diff --git a/src/contract_core/contract_def.h b/src/contract_core/contract_def.h index f6f11909..b718a479 100644 --- a/src/contract_core/contract_def.h +++ b/src/contract_core/contract_def.h @@ -417,7 +417,7 @@ constexpr struct ContractDescription {"QUSINO", 208, 10000, sizeof(QUSINO::StateData)}, // proposal in epoch 206, IPO in 207, construction and first use in 208 {"ESCROW", 210, 10000, sizeof(ESCROW::StateData)}, // proposal in epoch 208, IPO in 209, construction and first use in 210 #ifndef NO_GGWP - {"GGWP", 217, 10000, sizeof(WOLFPACK::StateData)}, // proposal in epoch 215, IPO in 216, construction and first use in 217 + {"GGWP", 218, 10000, sizeof(WOLFPACK::StateData)}, // proposal in epoch 216, IPO in 217, construction and first use in 218 #endif // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES @@ -557,7 +557,9 @@ static void initializeContracts() // Automatic Contract State Changes enum ContractStateChangeType { + // Keeps the saved state's old bytes, only zero-fills the new bytes at the end (used when struct grew; old fields preserved) PADDING, + // Discards the saved state entirely, zeros the whole buffer RESET, }; struct ContractStateChangeInfo @@ -566,8 +568,9 @@ struct ContractStateChangeInfo ContractStateChangeType changeType; }; // Contracts whose state struct changed this epoch. Update this list each epoch as needed. +// Each entry is { CONTRACT_INDEX, PADDING or RESET } // When enabling, replace both lines below, e.g.: -constexpr ContractStateChangeInfo contractStateChangeInfos[] = { { QIP_CONTRACT_INDEX, RESET } }; +constexpr ContractStateChangeInfo contractStateChangeInfos[] = { {RANDOM_CONTRACT_INDEX, PADDING} }; constexpr unsigned int contractStateChangeCount = sizeof(contractStateChangeInfos) / sizeof(contractStateChangeInfos[0]); // constexpr const ContractStateChangeInfo* contractStateChangeInfos = nullptr; // constexpr unsigned int contractStateChangeCount = 0; diff --git a/src/contracts/GGWP.h b/src/contracts/GGWP.h index 8859c5f5..16011179 100644 --- a/src/contracts/GGWP.h +++ b/src/contracts/GGWP.h @@ -3,7 +3,7 @@ using namespace QPI; // ============================================================================ // WolfPack (GGWP) - Revenue Distribution & Staking Smart Contract // -// --- Revenue Payout (triggered daily at 11:00 UTC via END_TICK) --- +// --- Revenue Payout (once per epoch / weekly, via END_EPOCH) --- // // Revenue split: // 70% -> GGWP token holders (proportional to token holdings) @@ -25,17 +25,36 @@ using namespace QPI; // --- Constants --- constexpr uint64 WOLFPACK_MAX_HOLDERS = 16384; -constexpr uint64 WOLFPACK_MAX_SHAREHOLDERS = 1024; +constexpr uint64 WOLFPACK_MAX_SHAREHOLDERS = 1024; // HashMap capacity must be 2^N and >= 676 SC shares (676 is not a power of two) constexpr uint64 WOLFPACK_MAX_CLAN_MEMBERS = 8192; +// Dust filter: token holders below this (accumulated) balance are excluded from the +// 70% distribution snapshot, so dust-spray can't bloat the holder loop. 1B supply / 10k = anti-dust. +constexpr uint64 WOLFPACK_MIN_ELIGIBLE_BALANCE = 10000; +// Minimum staked position: a staker's total staked balance must reach this. +// After a partial unstake the remaining balance must be 0 (full exit) or >= this. +constexpr uint64 WOLFPACK_MIN_STAKE = 500000; constexpr uint64 WOLFPACK_DISTRIBUTION_PERMILLE_HOLDERS = 700; constexpr uint64 WOLFPACK_DISTRIBUTION_PERMILLE_SHAREHOLDERS = 100; constexpr uint64 WOLFPACK_DISTRIBUTION_PERMILLE_CLAN = 100; -constexpr uint64 WOLFPACK_DISTRIBUTION_PERMILLE_REINVEST = 100; +constexpr uint64 WOLFPACK_DISTRIBUTION_PERMILLE_REINVEST = 90; // was 100 +constexpr uint64 WOLFPACK_DISTRIBUTION_PERMILLE_EXEC_RESERVE = 10; // NEW: retained in-contract for execution-fee reserve (never paid out) +// Distribution permille (holders + shareholders + clan + reinvest + exec-reserve) must sum to 1000. +static_assert(WOLFPACK_DISTRIBUTION_PERMILLE_HOLDERS + WOLFPACK_DISTRIBUTION_PERMILLE_SHAREHOLDERS + + WOLFPACK_DISTRIBUTION_PERMILLE_CLAN + WOLFPACK_DISTRIBUTION_PERMILLE_REINVEST + + WOLFPACK_DISTRIBUTION_PERMILLE_EXEC_RESERVE == 1000); constexpr uint64 WOLFPACK_SC_ASSET_NAME = 1347897159ULL; // "GGWP" as uint64 +// --- Shareholder governance: change admin / reinvest address by >51% of SC shares --- +constexpr uint64 WOLFPACK_TOTAL_SC_SHARES = 676; // total IPO shares = 100% +constexpr uint64 WOLFPACK_GOV_THRESHOLD_PERCENT = 51; // pass at >= 51% of all SC shares (>= 345 shares) +constexpr uint64 WOLFPACK_GOV_PROPOSAL_MAX_EPOCHS = 4; // a proposal auto-expires after this many epochs +constexpr uint8 WOLFPACK_GOV_TARGET_NONE = 0; +constexpr uint8 WOLFPACK_GOV_TARGET_ADMIN = 1; +constexpr uint8 WOLFPACK_GOV_TARGET_REINVEST = 2; +constexpr uint64 WOLFPACK_MAX_GOV_PROPOSALS = 8; // up to this many shareholder proposals active at once + // Payout timing -constexpr uint8 WOLFPACK_PAYOUT_HOUR = 11; // 11:00 UTC -constexpr uint64 WOLFPACK_MIN_PAYOUT_INTERVAL_TICKS = 1000; // prevent double-payout in same hour +// Revenue is distributed once per epoch (weekly) in END_EPOCH - no time/day gate needed. // Return codes constexpr uint32 WOLFPACK_OK = 0; @@ -63,6 +82,7 @@ constexpr uint64 WOLFPACK_STAKING_REWARD_PER_EPOCH = 1923076ULL; // ~100M / 52 e constexpr uint64 WOLFPACK_UNSTAKE_DELAY_EPOCHS = 2; constexpr uint16 WOLFPACK_QX_CONTRACT_INDEX = 1; constexpr sint64 WOLFPACK_QX_TRANSFER_FEE = 100LL; // QX fee for management rights transfer +constexpr sint64 WOLFPACK_STAKE_FEE = 900LL; // QU charged on Stake(), retained in the execution-fee reserve (causer-pays). User also pays the ~100 QU QX mgmt-rights fee separately → ~1000 total. // Additional error codes constexpr uint32 WOLFPACK_ERROR_INSUFFICIENT_STAKE = 8; @@ -72,6 +92,15 @@ constexpr uint32 WOLFPACK_ERROR_TRANSFER_FAILED = 11; constexpr uint32 WOLFPACK_ERROR_NO_PENDING_REWARDS = 12; constexpr uint32 WOLFPACK_ERROR_UNSTAKE_NOT_READY = 13; constexpr uint32 WOLFPACK_ERROR_NOT_STAKER = 14; +constexpr uint32 WOLFPACK_ERROR_NOT_SHAREHOLDER = 16; +constexpr uint32 WOLFPACK_ERROR_NO_ACTIVE_PROPOSAL = 17; +constexpr uint32 WOLFPACK_ERROR_PROPOSAL_ACTIVE = 18; +constexpr uint32 WOLFPACK_ERROR_INVALID_TARGET = 19; +constexpr uint32 WOLFPACK_ERROR_NULL_ADDRESS = 20; +constexpr uint32 WOLFPACK_ERROR_INSUFFICIENT_FEE = 21; +constexpr uint32 WOLFPACK_ERROR_BELOW_MIN_STAKE = 22; +constexpr uint32 WOLFPACK_ERROR_NO_PROPOSAL_SLOT = 23; +constexpr uint32 WOLFPACK_ERROR_INVALID_PROPOSAL = 24; // Secondary state struct, reserved for future EXPAND events. struct WOLFPACK2 @@ -80,6 +109,16 @@ struct WOLFPACK2 struct WOLFPACK : public ContractBase { + // A single shareholder governance proposal (one slot in govProposals). + struct WolfpackGovProposal + { + id proposedAddress; // candidate new admin/reinvest address + uint64 proposalId; // unique increasing id (0 = none); votes reference this id + uint64 proposalEpoch; // epoch the proposal was opened (for expiry) + uint8 status; // 0 = inactive/empty, 1 = active + uint8 targetType; // WOLFPACK_GOV_TARGET_ADMIN / _REINVEST + }; + // ======================== STATE ======================== struct StateData { @@ -93,11 +132,8 @@ struct WOLFPACK : public ContractBase uint64 totalTokensSnapshot; uint64 holderCount; - // SC shareholder snapshot (taken at BEGIN_EPOCH) - 10% pool - // These are the 676 IPO shares (issuer=NULL_ID, name="GGWP") - HashMap shareholderBalances; - uint64 totalSharesSnapshot; - uint64 shareholderCount; + // SC shareholders (the 676 IPO shares) are paid via qpi.distributeDividends() + // and their voting power is queried live via qpi.numberOfShares() - no snapshot kept. // Clan system HashMap clanRanks; @@ -107,6 +143,7 @@ struct WOLFPACK : public ContractBase // Revenue tracking uint64 pendingRevenue; uint64 reinvestmentFund; // cumulative total sent to reinvestAddress + uint64 execReserveFund; // cumulative QU retained in-contract for execution-fee reserve uint64 totalDistributed; uint64 totalDeposited; uint64 lastDistributionEpoch; @@ -119,6 +156,12 @@ struct WOLFPACK : public ContractBase // Recipient of the reinvestment share (10% of each payout) id reinvestAddress; + // Shareholder governance (change adminAddress / reinvestAddress by >51% of SC shares). + // Multiple proposals can be active at once; each holds one slot. + Array govProposals; + uint64 govNextProposalId; // monotonic counter; assigns a unique id to each proposal + HashMap govVoteMap; // shareholder -> proposalId they support (one vote) + // Staking system HashMap stakedBalances; uint64 totalStaked; @@ -159,6 +202,19 @@ struct WOLFPACK : public ContractBase struct SetExcludeAddress_input { uint64 slot; id address; }; struct SetExcludeAddress_output { uint32 returnCode; }; + // Shareholder governance I/O + struct ProposeGovChange_input { uint8 targetType; id newAddress; }; + struct ProposeGovChange_output { uint32 returnCode; uint64 proposalIndex; uint8 passed; }; + struct ProposeGovChange_locals { uint64 power; uint64 yesShares; sint64 vIdx; id voter; uint64 snap; sint64 slot; sint64 freeSlot; uint64 newId; WolfpackGovProposal prop; }; + + struct VoteGovChange_input { uint64 proposalIndex; uint8 approve; }; + struct VoteGovChange_output { uint32 returnCode; uint8 passed; }; + struct VoteGovChange_locals { uint64 power; uint64 yesShares; sint64 vIdx; id voter; uint64 snap; uint64 voted; WolfpackGovProposal prop; }; + + struct GetGovProposal_input { uint64 proposalIndex; }; + struct GetGovProposal_output { uint8 status; uint8 targetType; id proposedAddress; uint64 proposalId; uint64 proposalEpoch; uint64 yesShares; uint64 totalShares; uint64 requiredShares; }; + struct GetGovProposal_locals { sint64 vIdx; id voter; uint64 snap; WolfpackGovProposal prop; }; + // Staking I/O struct Stake_input { uint64 numberOfShares; }; struct Stake_output { uint32 returnCode; }; @@ -189,12 +245,11 @@ struct WOLFPACK : public ContractBase { uint64 holderCount; uint64 totalTokensSnapshot; - uint64 shareholderCount; - uint64 totalSharesSnapshot; uint64 clanMemberCount; uint64 clanWeightedTotal; uint64 pendingRevenue; uint64 reinvestmentFund; + uint64 execReserveFund; uint64 totalDistributed; uint64 totalDeposited; uint64 lastPayoutTick; @@ -217,11 +272,11 @@ struct WOLFPACK : public ContractBase struct GetExcludeAddresses_input { }; struct GetExcludeAddresses_output { id address1; id address2; }; - // BUG-SONDE: returns the split that END_TICK would compute for a given + // BUG-SONDE: returns the split that END_EPOCH would compute for a given // amount, without executing the transfers. Lets tests verify the // permille arithmetic in isolation from the qpi.transfer step. struct GetDistributionPreview_input { uint64 amount; }; - struct GetDistributionPreview_output { uint64 holderShare; uint64 shareholderShare; uint64 clanShare; uint64 reinvestShare; }; + struct GetDistributionPreview_output { uint64 holderShare; uint64 shareholderShare; uint64 clanShare; uint64 reinvestShare; uint64 execReserveShare; }; // ======================== FUNCTIONS (read-only) ======================== @@ -229,12 +284,11 @@ struct WOLFPACK : public ContractBase { output.holderCount = state.get().holderCount; output.totalTokensSnapshot = state.get().totalTokensSnapshot; - output.shareholderCount = state.get().shareholderCount; - output.totalSharesSnapshot = state.get().totalSharesSnapshot; output.clanMemberCount = state.get().clanMemberCount; output.clanWeightedTotal = state.get().clanWeightedTotal; output.pendingRevenue = state.get().pendingRevenue; output.reinvestmentFund = state.get().reinvestmentFund; + output.execReserveFund = state.get().execReserveFund; output.totalDistributed = state.get().totalDistributed; output.totalDeposited = state.get().totalDeposited; output.lastPayoutTick = state.get().lastPayoutTick; @@ -253,11 +307,11 @@ struct WOLFPACK : public ContractBase PUBLIC_FUNCTION_WITH_LOCALS(GetShareholderInfo) { - output.isShareholder = state.get().shareholderBalances.get(input.shareholderAddress, locals.val) ? 1 : 0; - if (output.isShareholder) - { - output.shares = locals.val; - } + // Live SC-share ownership (the 676 IPO shares, issuer=NULL_ID, name="GGWP"). + output.shares = (uint64)qpi.numberOfShares({ NULL_ID, WOLFPACK_SC_ASSET_NAME }, + AssetOwnershipSelect::byOwner(input.shareholderAddress), + AssetPossessionSelect::byPossessor(input.shareholderAddress)); + output.isShareholder = (output.shares > 0) ? 1 : 0; } PUBLIC_FUNCTION_WITH_LOCALS(GetClanMemberInfo) @@ -282,7 +336,40 @@ struct WOLFPACK : public ContractBase output.holderShare = div((uint128)input.amount * (uint128)WOLFPACK_DISTRIBUTION_PERMILLE_HOLDERS, (uint128)1000ULL).low; output.shareholderShare = div((uint128)input.amount * (uint128)WOLFPACK_DISTRIBUTION_PERMILLE_SHAREHOLDERS, (uint128)1000ULL).low; output.clanShare = div((uint128)input.amount * (uint128)WOLFPACK_DISTRIBUTION_PERMILLE_CLAN, (uint128)1000ULL).low; - output.reinvestShare = input.amount - output.holderShare - output.shareholderShare - output.clanShare; + output.execReserveShare = div((uint128)input.amount * (uint128)WOLFPACK_DISTRIBUTION_PERMILLE_EXEC_RESERVE, (uint128)1000ULL).low; + output.reinvestShare = input.amount - output.holderShare - output.shareholderShare - output.clanShare - output.execReserveShare; + } + + PUBLIC_FUNCTION_WITH_LOCALS(GetGovProposal) + { + output.totalShares = WOLFPACK_TOTAL_SC_SHARES; + // required = ceil(totalShares * threshold% / 100) + output.requiredShares = div(WOLFPACK_TOTAL_SC_SHARES * WOLFPACK_GOV_THRESHOLD_PERCENT + 99, 100ULL); + if (input.proposalIndex >= WOLFPACK_MAX_GOV_PROPOSALS) + { + return; // out of range -> zeroed output + } + locals.prop = state.get().govProposals.get((sint64)input.proposalIndex); + output.targetType = locals.prop.targetType; + output.proposedAddress = locals.prop.proposedAddress; + output.proposalId = locals.prop.proposalId; + output.proposalEpoch = locals.prop.proposalEpoch; + // active only if marked active AND not yet expired + output.status = (locals.prop.status == 1 && + qpi.epoch() < locals.prop.proposalEpoch + WOLFPACK_GOV_PROPOSAL_MAX_EPOCHS) ? 1 : 0; + + // Sum current-snapshot voting power of voters supporting this proposalId. + output.yesShares = 0; + for (locals.vIdx = state.get().govVoteMap.nextElementIndex(NULL_INDEX); + locals.vIdx != NULL_INDEX; + locals.vIdx = state.get().govVoteMap.nextElementIndex(locals.vIdx)) + { + if (state.get().govVoteMap.value(locals.vIdx) != locals.prop.proposalId) continue; + locals.voter = state.get().govVoteMap.key(locals.vIdx); + locals.snap = (uint64)qpi.numberOfShares({ NULL_ID, WOLFPACK_SC_ASSET_NAME }, + AssetOwnershipSelect::byOwner(locals.voter), AssetPossessionSelect::byPossessor(locals.voter)); + output.yesShares = sadd(output.yesShares, locals.snap); + } } PUBLIC_FUNCTION_WITH_LOCALS(GetStakingInfo) @@ -421,6 +508,176 @@ struct WOLFPACK : public ContractBase output.returnCode = WOLFPACK_ERROR_ACCESS_DENIED; } + // Open a shareholder proposal to change the admin or reinvest address. + // Caller must be an SC shareholder (present in the current BEGIN_EPOCH snapshot). + // Up to WOLFPACK_MAX_GOV_PROPOSALS proposals can be active at the same time. + PUBLIC_PROCEDURE_WITH_LOCALS(ProposeGovChange) + { + // Governance calls are not payable - refund any attached amount. + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + locals.power = (uint64)qpi.numberOfShares({ NULL_ID, WOLFPACK_SC_ASSET_NAME }, + AssetOwnershipSelect::byOwner(qpi.invocator()), AssetPossessionSelect::byPossessor(qpi.invocator())); + if (locals.power == 0) + { + output.returnCode = WOLFPACK_ERROR_NOT_SHAREHOLDER; + return; + } + if (input.targetType != WOLFPACK_GOV_TARGET_ADMIN && input.targetType != WOLFPACK_GOV_TARGET_REINVEST) + { + output.returnCode = WOLFPACK_ERROR_INVALID_TARGET; + return; + } + if (input.newAddress == NULL_ID) + { + output.returnCode = WOLFPACK_ERROR_NULL_ADDRESS; + return; + } + + // Find a free slot: empty (status 0) or holding an expired proposal. + locals.freeSlot = -1; + for (locals.slot = 0; locals.slot < (sint64)WOLFPACK_MAX_GOV_PROPOSALS; locals.slot++) + { + locals.prop = state.get().govProposals.get(locals.slot); + if (locals.prop.status == 0 || + qpi.epoch() >= locals.prop.proposalEpoch + WOLFPACK_GOV_PROPOSAL_MAX_EPOCHS) + { + locals.freeSlot = locals.slot; + break; + } + } + if (locals.freeSlot < 0) + { + output.returnCode = WOLFPACK_ERROR_NO_PROPOSAL_SLOT; + return; + } + + // Open the proposal with a fresh unique id; proposer auto-votes "yes". + // The unique id means stale votes from a recycled slot can never count. + locals.newId = state.get().govNextProposalId + 1; + state.mut().govNextProposalId = locals.newId; + + locals.prop.proposedAddress = input.newAddress; + locals.prop.proposalId = locals.newId; + locals.prop.proposalEpoch = qpi.epoch(); + locals.prop.status = 1; + locals.prop.targetType = input.targetType; + state.mut().govProposals.set(locals.freeSlot, locals.prop); + + state.mut().govVoteMap.set(qpi.invocator(), locals.newId); + + // Tally shares of voters supporting this proposalId. + locals.yesShares = 0; + for (locals.vIdx = state.get().govVoteMap.nextElementIndex(NULL_INDEX); + locals.vIdx != NULL_INDEX; + locals.vIdx = state.get().govVoteMap.nextElementIndex(locals.vIdx)) + { + if (state.get().govVoteMap.value(locals.vIdx) != locals.newId) continue; + locals.voter = state.get().govVoteMap.key(locals.vIdx); + locals.snap = (uint64)qpi.numberOfShares({ NULL_ID, WOLFPACK_SC_ASSET_NAME }, + AssetOwnershipSelect::byOwner(locals.voter), AssetPossessionSelect::byPossessor(locals.voter)); + locals.yesShares = sadd(locals.yesShares, locals.snap); + } + + output.proposalIndex = (uint64)locals.freeSlot; + output.passed = 0; + if (locals.yesShares * 100 >= WOLFPACK_TOTAL_SC_SHARES * WOLFPACK_GOV_THRESHOLD_PERCENT) + { + if (locals.prop.targetType == WOLFPACK_GOV_TARGET_ADMIN) + { + state.mut().adminAddress = locals.prop.proposedAddress; + } + else + { + state.mut().reinvestAddress = locals.prop.proposedAddress; + } + locals.prop.status = 0; + state.mut().govProposals.set(locals.freeSlot, locals.prop); + output.passed = 1; + } + output.returnCode = WOLFPACK_OK; + } + + // Vote on a specific proposal by index. approve=1 casts/keeps a "yes"; approve=0 withdraws. + // A shareholder supports at most one proposal at a time. Executes once >= 51% is reached. + PUBLIC_PROCEDURE_WITH_LOCALS(VoteGovChange) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (input.proposalIndex >= WOLFPACK_MAX_GOV_PROPOSALS) + { + output.returnCode = WOLFPACK_ERROR_INVALID_PROPOSAL; + return; + } + locals.prop = state.get().govProposals.get((sint64)input.proposalIndex); + if (locals.prop.status == 0 || + qpi.epoch() >= locals.prop.proposalEpoch + WOLFPACK_GOV_PROPOSAL_MAX_EPOCHS) + { + output.returnCode = WOLFPACK_ERROR_NO_ACTIVE_PROPOSAL; + return; + } + + locals.power = (uint64)qpi.numberOfShares({ NULL_ID, WOLFPACK_SC_ASSET_NAME }, + AssetOwnershipSelect::byOwner(qpi.invocator()), AssetPossessionSelect::byPossessor(qpi.invocator())); + if (locals.power == 0) + { + output.returnCode = WOLFPACK_ERROR_NOT_SHAREHOLDER; + return; + } + + if (input.approve == 0) + { + // Withdraw only if currently supporting THIS proposal. + locals.voted = 0; + if (state.get().govVoteMap.get(qpi.invocator(), locals.voted) && locals.voted == locals.prop.proposalId) + { + state.mut().govVoteMap.removeByKey(qpi.invocator()); + } + } + else + { + // One vote per voter: switches support to this proposal. + state.mut().govVoteMap.set(qpi.invocator(), locals.prop.proposalId); + } + + // Tally shares of voters supporting this proposalId (transfers can't double-count - + // a sold-out voter contributes 0 from the snapshot). + locals.yesShares = 0; + for (locals.vIdx = state.get().govVoteMap.nextElementIndex(NULL_INDEX); + locals.vIdx != NULL_INDEX; + locals.vIdx = state.get().govVoteMap.nextElementIndex(locals.vIdx)) + { + if (state.get().govVoteMap.value(locals.vIdx) != locals.prop.proposalId) continue; + locals.voter = state.get().govVoteMap.key(locals.vIdx); + locals.snap = (uint64)qpi.numberOfShares({ NULL_ID, WOLFPACK_SC_ASSET_NAME }, + AssetOwnershipSelect::byOwner(locals.voter), AssetPossessionSelect::byPossessor(locals.voter)); + locals.yesShares = sadd(locals.yesShares, locals.snap); + } + + output.passed = 0; + if (locals.yesShares * 100 >= WOLFPACK_TOTAL_SC_SHARES * WOLFPACK_GOV_THRESHOLD_PERCENT) + { + if (locals.prop.targetType == WOLFPACK_GOV_TARGET_ADMIN) + { + state.mut().adminAddress = locals.prop.proposedAddress; + } + else + { + state.mut().reinvestAddress = locals.prop.proposedAddress; + } + locals.prop.status = 0; + state.mut().govProposals.set((sint64)input.proposalIndex, locals.prop); + output.passed = 1; + } + output.returnCode = WOLFPACK_OK; + } + PUBLIC_PROCEDURE(SetExcludeAddress) { if (qpi.invocator() != state.get().adminAddress) @@ -458,6 +715,14 @@ struct WOLFPACK : public ContractBase output.returnCode = WOLFPACK_ERROR_UNSTAKE_PENDING; return; } + // Minimum stake: the resulting staked position must reach WOLFPACK_MIN_STAKE. + locals.existingStake = 0; + state.get().stakedBalances.get(qpi.invocator(), locals.existingStake); + if (locals.existingStake + input.numberOfShares < WOLFPACK_MIN_STAKE) + { + output.returnCode = WOLFPACK_ERROR_BELOW_MIN_STAKE; + return; + } // Verify invocator has enough GGWP shares already under WP's management. // User must call QX.TransferShareManagementRights(asset=wpToken, shares=N, newMgmtIdx=GGWP) first. if (qpi.numberOfPossessedShares(state.get().wpToken.assetName, state.get().wpToken.issuer, @@ -467,6 +732,20 @@ struct WOLFPACK : public ContractBase return; } + // Causer-pays (self-sustain): the stake fee stays in the contract's QU balance + // (= execution-fee reserve). It is NOT transferred out and NOT burned. + if (qpi.invocationReward() < WOLFPACK_STAKE_FEE) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = WOLFPACK_ERROR_INSUFFICIENT_FEE; + return; + } + if (qpi.invocationReward() > WOLFPACK_STAKE_FEE) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward() - WOLFPACK_STAKE_FEE); + } + state.mut().execReserveFund = state.get().execReserveFund + (uint64)WOLFPACK_STAKE_FEE; + locals.existingStake = 0; state.get().stakedBalances.get(qpi.invocator(), locals.existingStake); state.mut().stakedBalances.set(qpi.invocator(), locals.existingStake + input.numberOfShares); @@ -500,6 +779,14 @@ struct WOLFPACK : public ContractBase output.returnCode = WOLFPACK_ERROR_UNSTAKE_PENDING; return; } + // Either fully exit (remaining 0) or keep at least the minimum stake. + // Partial unstakes that would leave a sub-minimum dust position are rejected. + if (input.numberOfShares < locals.currentStake && + locals.currentStake - input.numberOfShares < WOLFPACK_MIN_STAKE) + { + output.returnCode = WOLFPACK_ERROR_BELOW_MIN_STAKE; + return; + } if (input.numberOfShares == locals.currentStake) { @@ -532,6 +819,21 @@ struct WOLFPACK : public ContractBase return; } + // Unstake fee (mirrors Stake): 100 QU covers the QX release fee, 900 QU is + // retained in the execution-fee reserve. Total = WOLFPACK_QX_TRANSFER_FEE + WOLFPACK_STAKE_FEE. + // (The 100 QU offsets the fee releaseShares deducts from SELF; the 900 QU stay in the balance.) + if (qpi.invocationReward() < WOLFPACK_QX_TRANSFER_FEE + WOLFPACK_STAKE_FEE) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = WOLFPACK_ERROR_INSUFFICIENT_FEE; + return; + } + if (qpi.invocationReward() > WOLFPACK_QX_TRANSFER_FEE + WOLFPACK_STAKE_FEE) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward() - (WOLFPACK_QX_TRANSFER_FEE + WOLFPACK_STAKE_FEE)); + } + state.mut().execReserveFund = state.get().execReserveFund + (uint64)WOLFPACK_STAKE_FEE; + locals.releaseResult = qpi.releaseShares(state.get().wpToken, qpi.invocator(), qpi.invocator(), (sint64)locals.unstakeAmount, WOLFPACK_QX_CONTRACT_INDEX, WOLFPACK_QX_CONTRACT_INDEX, WOLFPACK_QX_TRANSFER_FEE); if (locals.releaseResult < 0) @@ -574,6 +876,18 @@ struct WOLFPACK : public ContractBase return; } + // Fix 1: the user covers the QX release fee (the contract no longer pays it). + if (qpi.invocationReward() < WOLFPACK_QX_TRANSFER_FEE) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = WOLFPACK_ERROR_INSUFFICIENT_FEE; + return; + } + if (qpi.invocationReward() > WOLFPACK_QX_TRANSFER_FEE) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward() - WOLFPACK_QX_TRANSFER_FEE); + } + locals.releaseResult = qpi.releaseShares(state.get().wpToken, qpi.invocator(), qpi.invocator(), (sint64)locals.pending, WOLFPACK_QX_CONTRACT_INDEX, WOLFPACK_QX_CONTRACT_INDEX, WOLFPACK_QX_TRANSFER_FEE); if (locals.releaseResult < 0) @@ -607,10 +921,13 @@ struct WOLFPACK : public ContractBase REGISTER_USER_PROCEDURE(FinalizeUnstake, 9); REGISTER_USER_PROCEDURE(DepositStakingRewards, 10); REGISTER_USER_PROCEDURE(ClaimStakingRewards, 11); + REGISTER_USER_PROCEDURE(ProposeGovChange, 12); + REGISTER_USER_PROCEDURE(VoteGovChange, 13); REGISTER_USER_FUNCTION(GetStakingInfo, 5); REGISTER_USER_FUNCTION(GetExcludeAddresses, 6); REGISTER_USER_FUNCTION(GetDistributionPreview, 7); + REGISTER_USER_FUNCTION(GetGovProposal, 8); } // ======================== SYSTEM PROCEDURES ======================== @@ -636,14 +953,18 @@ struct WOLFPACK : public ContractBase state.mut().adminAddress = state.get().wpToken.issuer; state.mut().reinvestAddress = state.get().wpToken.issuer; + // Shareholder governance starts with no active proposals. + // (govProposals slots are zero-initialised => status 0 / inactive.) + state.mut().govNextProposalId = 0; + state.mut().govVoteMap.reset(); + state.mut().totalTokensSnapshot = 0; state.mut().holderCount = 0; - state.mut().totalSharesSnapshot = 0; - state.mut().shareholderCount = 0; state.mut().clanMemberCount = 0; state.mut().clanWeightedTotal = 0; state.mut().pendingRevenue = 0; state.mut().reinvestmentFund = 0; + state.mut().execReserveFund = 0; state.mut().totalDistributed = 0; state.mut().totalDeposited = 0; state.mut().lastDistributionEpoch = 0; @@ -663,8 +984,6 @@ struct WOLFPACK : public ContractBase struct BEGIN_EPOCH_locals { AssetPossessionIterator tokenIter; - AssetPossessionIterator scIter; - Asset scAsset; uint64 balance; id holder; uint64 existingBalance; @@ -701,6 +1020,10 @@ struct WOLFPACK : public ContractBase state.get().holderBalances.get(locals.holder, locals.existingBalance); locals.balance = sadd(locals.existingBalance, locals.balance); + // Dust filter: only include holders whose accumulated balance reaches + // the minimum. Keeps dust-spray addresses out of the distribution loop. + if (locals.balance < WOLFPACK_MIN_ELIGIBLE_BALANCE) continue; + if (state.mut().holderBalances.set(locals.holder, locals.balance) != NULL_INDEX) { state.mut().totalTokensSnapshot = sadd(state.get().totalTokensSnapshot, (uint64)locals.tokenIter.numberOfPossessedShares()); @@ -713,39 +1036,8 @@ struct WOLFPACK : public ContractBase } } - // ---- Pass 2: SC shareholders (IPO shares, issuer=NULL_ID, name="GGWP") ---- - state.mut().shareholderBalances.reset(); - state.mut().totalSharesSnapshot = 0; - state.mut().shareholderCount = 0; - - locals.scAsset.issuer = NULL_ID; - locals.scAsset.assetName = WOLFPACK_SC_ASSET_NAME; - - for (locals.scIter.begin(locals.scAsset); !locals.scIter.reachedEnd(); locals.scIter.next()) - { - if (locals.scIter.possessor() == SELF) continue; - if (state.get().excludeAddress1 != NULL_ID && locals.scIter.possessor() == state.get().excludeAddress1) continue; - if (state.get().excludeAddress2 != NULL_ID && locals.scIter.possessor() == state.get().excludeAddress2) continue; - - locals.balance = locals.scIter.numberOfPossessedShares(); - locals.holder = locals.scIter.possessor(); - - if (locals.balance > 0) - { - locals.existingBalance = 0; - state.get().shareholderBalances.get(locals.holder, locals.existingBalance); - locals.balance = sadd(locals.existingBalance, locals.balance); - - if (state.mut().shareholderBalances.set(locals.holder, locals.balance) != NULL_INDEX) - { - state.mut().totalSharesSnapshot = sadd(state.get().totalSharesSnapshot, (uint64)locals.scIter.numberOfPossessedShares()); - if (locals.existingBalance == 0) - { - state.mut().shareholderCount = state.get().shareholderCount + 1; - } - } - } - } + // SC shareholders (10% pool) are paid via qpi.distributeDividends() in END_EPOCH; + // their voting power is read live via qpi.numberOfShares() - no snapshot needed. // ---- Staking reward distribution ---- locals.rewardThisEpoch = WOLFPACK_STAKING_REWARD_PER_EPOCH; @@ -777,29 +1069,23 @@ struct WOLFPACK : public ContractBase } } - END_EPOCH() + END_TICK() { - state.mut().holderBalances.cleanupIfNeeded(); - state.mut().shareholderBalances.cleanupIfNeeded(); - state.mut().clanRanks.cleanupIfNeeded(); - state.mut().stakedBalances.cleanupIfNeeded(); - state.mut().unstakeAmounts.cleanupIfNeeded(); - state.mut().unstakeEpochs.cleanupIfNeeded(); - state.mut().pendingStakingRewards.cleanupIfNeeded(); } BEGIN_TICK() { } - // Auto-payout at 11:00 UTC daily - struct END_TICK_locals + // Weekly revenue payout runs once per epoch in END_EPOCH (below). + struct END_EPOCH_locals { uint64 amount; uint64 holderShare; uint64 shareholderShare; uint64 clanShare; uint64 reinvestShare; + uint64 execReserveShare; sint64 idx; id holder; uint64 tokens; @@ -811,18 +1097,19 @@ struct WOLFPACK : public ContractBase uint64 quotient; uint64 remainder; }; - END_TICK_WITH_LOCALS() + END_EPOCH_WITH_LOCALS() { - // Gate: only at hour 11 and enough ticks since last payout - if (qpi.hour() != WOLFPACK_PAYOUT_HOUR) - { - return; - } - if (state.get().lastPayoutTick != 0 && - qpi.tick() < state.get().lastPayoutTick + WOLFPACK_MIN_PAYOUT_INTERVAL_TICKS) - { - return; - } + // Compact hash maps once per epoch (previously done in END_EPOCH). + state.mut().holderBalances.cleanupIfNeeded(); + state.mut().clanRanks.cleanupIfNeeded(); + state.mut().stakedBalances.cleanupIfNeeded(); + state.mut().unstakeAmounts.cleanupIfNeeded(); + state.mut().unstakeEpochs.cleanupIfNeeded(); + state.mut().pendingStakingRewards.cleanupIfNeeded(); + state.mut().govVoteMap.cleanupIfNeeded(); + + // Weekly revenue distribution: fires exactly once per epoch, using this + // epoch's BEGIN_EPOCH holder/shareholder snapshot. No time/day gate needed. if (state.get().pendingRevenue == 0) { return; @@ -833,13 +1120,17 @@ struct WOLFPACK : public ContractBase locals.holderShare = div((uint128)locals.amount * (uint128)WOLFPACK_DISTRIBUTION_PERMILLE_HOLDERS, (uint128)1000ULL).low; locals.shareholderShare = div((uint128)locals.amount * (uint128)WOLFPACK_DISTRIBUTION_PERMILLE_SHAREHOLDERS, (uint128)1000ULL).low; locals.clanShare = div((uint128)locals.amount * (uint128)WOLFPACK_DISTRIBUTION_PERMILLE_CLAN, (uint128)1000ULL).low; - locals.reinvestShare = locals.amount - locals.holderShare - locals.shareholderShare - locals.clanShare; + locals.execReserveShare = div((uint128)locals.amount * (uint128)WOLFPACK_DISTRIBUTION_PERMILLE_EXEC_RESERVE, (uint128)1000ULL).low; + // Execution-fee reserve: retained in the contract's own QU balance (never transferred out). + // reinvestShare takes the remainder so all five portions sum to exactly `amount`. + locals.reinvestShare = locals.amount - locals.holderShare - locals.shareholderShare - locals.clanShare - locals.execReserveShare; state.mut().pendingRevenue = 0; state.mut().totalDistributed = state.get().totalDistributed + locals.amount; state.mut().lastDistributionEpoch = qpi.epoch(); state.mut().lastPayoutTick = qpi.tick(); state.mut().reinvestmentFund = state.get().reinvestmentFund + locals.reinvestShare; + state.mut().execReserveFund = state.get().execReserveFund + locals.execReserveShare; qpi.getEntity(SELF, locals.entity); locals.contractBalance = locals.entity.incomingAmount - locals.entity.outgoingAmount; @@ -867,32 +1158,15 @@ struct WOLFPACK : public ContractBase } } - // --- Step 3: Push 10% to SC shareholders --- - if (locals.shareholderShare > 0 && state.get().totalSharesSnapshot > 0) + // --- Step 3: Push 10% to SC shareholders via the protocol dividend mechanism --- + // distributeDividends() pays `perShare` to each of the 676 SC shares automatically + // (no snapshot/loop needed). The integer remainder stays in the contract balance. + if (locals.shareholderShare > 0) { - if (locals.contractBalance == 0) - { - qpi.getEntity(SELF, locals.entity); - locals.contractBalance = locals.entity.incomingAmount - locals.entity.outgoingAmount; - } - - for (locals.idx = state.get().shareholderBalances.nextElementIndex(NULL_INDEX); - locals.idx != NULL_INDEX; - locals.idx = state.get().shareholderBalances.nextElementIndex(locals.idx)) + locals.quotient = div(locals.shareholderShare, (uint64)NUMBER_OF_COMPUTORS); // per-share amount + if (locals.quotient > 0) { - locals.holder = state.get().shareholderBalances.key(locals.idx); - locals.tokens = state.get().shareholderBalances.value(locals.idx); - if (locals.tokens == 0) continue; - - locals.quotient = div(locals.shareholderShare, state.get().totalSharesSnapshot); - locals.remainder = mod(locals.shareholderShare, state.get().totalSharesSnapshot); - locals.reward = locals.quotient * locals.tokens + div((uint128)locals.remainder * (uint128)locals.tokens, (uint128)state.get().totalSharesSnapshot).low; - if (locals.reward == 0) continue; - if (locals.reward > locals.contractBalance) locals.reward = locals.contractBalance; - - qpi.transfer(locals.holder, locals.reward); - locals.contractBalance = locals.contractBalance - locals.reward; - if (locals.contractBalance == 0) break; + qpi.distributeDividends((sint64)locals.quotient); } } diff --git a/src/contracts/Random.h b/src/contracts/Random.h index 290b9258..6f9b9e2c 100644 --- a/src/contracts/Random.h +++ b/src/contracts/Random.h @@ -1,5 +1,9 @@ using namespace QPI; +constexpr uint32 RANDOM_BITFEE = 100; +constexpr uint32 RANDOM_STREAM_CAPACITY = 1365; +constexpr uint32 RANDOM_MAX_PROVIDERS = 4096; + struct RANDOM2 { }; @@ -19,10 +23,44 @@ struct RANDOM : public ContractBase struct RevealAndCommit_locals { - bit_4096 zeroReveal; // TODO: Use a constant from either QPI or global state + bit_4096 zeroReveal; uint32 stream; uint32 collateralTier; uint32 i; + uint32 index; + }; + + struct Fees_input + { + }; + + struct Fees_output + { + Array fees; + }; + + struct BuyEntropy_input + { + uint8 collateralTier; + uint16 numberOfBits; + id trustee; + }; + + struct BuyEntropy_output + { + bit_4096 entropy; + }; + + struct BuyEntropy_locals + { + bit_4096 zeroEntropy; + bit_4096 entropy; + uint64 i; + uint64 entropyIdx; + sint64 entropyCost; + uint32 stream; + uint32 index; + sint8 trusteeOk; }; struct StateData @@ -31,21 +69,104 @@ struct RANDOM : public ContractBase uint64 distributedAmount; uint64 burnedAmount; - uint32 bitFee; // Amount of qus - - Array populations; // 3 - Array providers; // 3 * 1365 - Array collateralTiers; // 3 * 1365 - Array commits; // 3 * 1365 - Array reveals; // 3 * 1365 - bit_4096 revealOrCommitFlags; // 3 * 1365 - Array entropy; // 3 * 10 + uint32 bitFee; + + Array populations; + Array providers; + Array collateralTiers; + Array commits; + Array reveals; + bit_4096 revealOrCommitFlags; + Array entropy; + + // lockedCollateralAmounts: stake locked per-provider; refunded on reveal, burned on no-show. + // revealedThisTickFlags: set when provider revealed this tick (not just committed). + Array lockedCollateralAmounts; + bit_4096 revealedThisTickFlags; + // Cleared each END_TICK; set when reveal is XOR'd into entropy. BuyEntropy uses for trustee verification. + bit_4096 contributedToEntropyFlags; }; + PUBLIC_FUNCTION(Fees) + { + output.fees.set(0, state.get().bitFee); + output.fees.set(1, state.get().bitFee); + output.fees.set(2, state.get().bitFee); + output.fees.set(3, state.get().bitFee); + output.fees.set(4, state.get().bitFee); + output.fees.set(5, state.get().bitFee); + output.fees.set(6, state.get().bitFee); + output.fees.set(7, state.get().bitFee); + output.fees.set(8, state.get().bitFee); + output.fees.set(9, state.get().bitFee); + } + + PUBLIC_PROCEDURE_WITH_LOCALS(BuyEntropy) + { + locals.entropyCost = static_cast(input.numberOfBits) * state.get().bitFee; + + if (input.collateralTier <= 9 + && input.numberOfBits >= 1 && input.numberOfBits <= 4096 + && qpi.invocationReward() >= locals.entropyCost) + { + // Offset +2: skip the current tick's slot (being overwritten) and read the last finalized stream. + locals.stream = mod(qpi.tick() + 2, 3); + locals.entropyIdx = locals.stream * 10 + input.collateralTier; + locals.entropy = state.get().entropy.get(locals.entropyIdx); + + locals.trusteeOk = (input.trustee == id::zero()) ? 1 : 0; + + if(!locals.trusteeOk) + { + for (locals.i = 0; locals.i < state.get().populations.get(locals.stream); locals.i++) + { + locals.index = locals.stream * RANDOM_STREAM_CAPACITY + static_cast(locals.i); + if (input.trustee == state.get().providers.get(locals.index) + && input.collateralTier == state.get().collateralTiers.get(locals.index) + && state.get().contributedToEntropyFlags.get(locals.index)) + { + locals.trusteeOk = 1; + break; + } + } + } + + if (locals.entropy == locals.zeroEntropy || !locals.trusteeOk) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + else + { + state.mut().earnedAmount += static_cast(locals.entropyCost); + if (qpi.invocationReward() > locals.entropyCost) + { + // Refund overpayment. + qpi.transfer(qpi.invocator(), qpi.invocationReward() - locals.entropyCost); + } + + // Copy requested bits; remaining output bits are zero (framework-zeroed). + for (locals.i = 0; locals.i < input.numberOfBits; locals.i++) + { + output.entropy.set(locals.i, locals.entropy.get(locals.i)); + } + } + } + else + { + // Invalid input — refund in full. + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + } + private: PUBLIC_PROCEDURE_WITH_LOCALS(RevealAndCommit) { - // TODO: Reject transactions from smart contracts! + // Entropy providers must be user accounts, not smart contracts. + if (qpi.isContractId(qpi.invocator())) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + return; + } switch (qpi.invocationReward()) { @@ -72,238 +193,266 @@ struct RANDOM : public ContractBase default: qpi.transfer(qpi.invocator(), qpi.invocationReward()); return; } + // Reject empty commit before any state change — double-refund risk: reveal path sets + // the participation flag, then END_TICK would refund the collateral a second time. + if (input.commit == id::zero()) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + return; + } + locals.stream = mod(qpi.tick(), 3); - if (input.reveal != locals.zeroReveal) // Don't need to initialize [locals.zeroReveal] because - // locals struct has been zeroed (bad practice, but - // it's for spreading awareness about this nuance) + if (input.reveal != locals.zeroReveal) { - for (; locals.i < state.get().populations.get(locals.stream); locals.i++) // Don't need to initialize [locals.i] because locals - // struct has been zeroed (bad practice, but it's for - // spreading awareness about this nuance) + // Reveal path: verify preimage of prior commit and re-commit for next round. + for (locals.i = 0; locals.i < state.get().populations.get(locals.stream); locals.i++) { - if (qpi.invocator() == state.get().providers.get(locals.stream * 1365 + locals.i) && - locals.collateralTier == state.get().collateralTiers.get(locals.stream * 1365 + locals.i)) + if (qpi.invocator() == state.get().providers.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i) && + locals.collateralTier == state.get().collateralTiers.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i)) { break; } } if (locals.i == state.get().populations.get(locals.stream) || - state.get().reveals.get(locals.stream * 1365 + locals.i) != locals.zeroReveal || - qpi.K12(input.reveal) != state.get().commits.get(locals.stream * 1365 + locals.i)) + state.get().reveals.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i) != locals.zeroReveal || + qpi.K12(input.reveal) != state.get().commits.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i) || + state.get().revealOrCommitFlags.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i)) // same-tick commit+reveal is forbidden { qpi.transfer(qpi.invocator(), qpi.invocationReward()); return; } - state.mut().reveals.set(locals.stream * 1365 + locals.i, input.reveal); - state.mut().revealOrCommitFlags.set(locals.stream * 1365 + locals.i, 1); - } + locals.index = locals.stream * RANDOM_STREAM_CAPACITY + locals.i; + + // Refund prior collateral — reveal fulfills obligation. + if (state.get().lockedCollateralAmounts.get(locals.index) > 0) + { + qpi.transfer(qpi.invocator(), state.get().lockedCollateralAmounts.get(locals.index)); + } + + // Record the reveal for END_TICK entropy mixing. + state.mut().reveals.set(locals.index, input.reveal); + + // Store the next commit and lock fresh collateral for it. + state.mut().commits.set(locals.index, input.commit); + state.mut().lockedCollateralAmounts.set(locals.index, qpi.invocationReward()); + + state.mut().revealOrCommitFlags.set(locals.index, 1); + state.mut().revealedThisTickFlags.set(locals.index, 1); - if (input.commit == id::zero()) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); return; } + // First-commit path: register a brand-new provider for this stream/tier. for (locals.i = 0; locals.i < state.get().populations.get(locals.stream); locals.i++) { - if (qpi.invocator() == state.get().providers.get(locals.stream * 1365 + locals.i) && - locals.collateralTier == state.get().collateralTiers.get(locals.stream * 1365 + locals.i)) - { - break; - } - } - if (locals.i == state.get().populations.get(locals.stream)) - { - if (locals.i == 1365) + if (qpi.invocator() == state.get().providers.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i) && + locals.collateralTier == state.get().collateralTiers.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i)) { + // Existing provider must reveal before re-committing. qpi.transfer(qpi.invocator(), qpi.invocationReward()); return; } - - state.mut().providers.set(locals.stream * 1365 + locals.i, qpi.invocator()); - state.mut().collateralTiers.set(locals.stream * 1365 + locals.i, locals.collateralTier); - state.mut().populations.set(locals.stream, locals.i + 1); } - else + if (locals.i == RANDOM_STREAM_CAPACITY) { - if (state.get().reveals.get(locals.stream * 1365 + locals.i) == locals.zeroReveal) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - return; - } + // Stream is full. + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + return; } - state.mut().commits.set(locals.stream * 1365 + locals.i, input.commit); - state.mut().revealOrCommitFlags.set(locals.stream * 1365 + locals.i, 1); + + locals.index = locals.stream * RANDOM_STREAM_CAPACITY + locals.i; + + state.mut().providers.set(locals.index, qpi.invocator()); + state.mut().collateralTiers.set(locals.index, locals.collateralTier); + state.mut().commits.set(locals.index, input.commit); + state.mut().reveals.set(locals.index, locals.zeroReveal); + + // Lock collateral until future reveal (refund) or no-show (slash). + state.mut().lockedCollateralAmounts.set(locals.index, qpi.invocationReward()); + + state.mut().revealOrCommitFlags.set(locals.index, 1); + state.mut().revealedThisTickFlags.set(locals.index, 0); + + state.mut().populations.set(locals.stream, locals.i + 1); } struct END_TICK_locals { - bit_4096 zeroReveal; // TODO: Use a constant from either QPI or global state + bit_4096 zeroReveal; // TODO: replace with a QPI/global zero constant bit_4096 entropy; - id collateralRecipient; uint32 stream; uint32 i, j; + uint32 index; + uint32 lastIndex; + uint32 tier; uint16 collateralTierFlags; + uint64 lockedAmount; }; END_TICK_WITH_LOCALS() { locals.stream = mod(qpi.tick(), 3); - for (; locals.i < 10; locals.i++) // Don't need to initialize [locals.i] because locals - // struct has been zeroed (bad practice, but it's for - // spreading awareness about this nuance) + // Entropy for this stream is recomputed from scratch every cycle. + for (locals.i = 0; locals.i < 10; locals.i++) { - state.mut().entropy.set(locals.stream * 10 + locals.i, - locals.zeroReveal); // Don't need to initialize [locals.zeroReveal] - // because locals struct has been zeroed (bad - // practice, but it's for spreading awareness - // about this nuance) + state.mut().entropy.set(locals.stream * 10 + locals.i, locals.zeroReveal); } + // Reset contribution flags; only this tick's reveals can satisfy BuyEntropy trustee checks. for (locals.i = 0; locals.i < state.get().populations.get(locals.stream); locals.i++) { - if (state.get().revealOrCommitFlags.get(locals.stream * 1365 + locals.i)) - { - break; - } + state.mut().contributedToEntropyFlags.set( + locals.stream * RANDOM_STREAM_CAPACITY + locals.i, 0); } - if (locals.i == state.get().populations.get(locals.stream)) // Nobody provided their reveal, that - // tick was probably empty + + // Check if any provider revealed this tick. + for (locals.i = 0; locals.i < state.get().populations.get(locals.stream); locals.i++) { - while (locals.i--) + if (state.get().revealedThisTickFlags.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i)) { - switch (state.get().collateralTiers.get(locals.stream * 1365 + locals.i)) - { - case 0: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 1); break; - - case 1: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 10); break; - - case 2: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 100); break; - - case 3: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 1000); break; - - case 4: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 10000); break; - - case 5: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 100000); break; - - case 6: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 1000000); break; - - case 7: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 10000000); break; - - case 8: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 100000000); break; - - default: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 1000000000); - } - state.mut().providers.set(locals.stream * 1365 + locals.i, id::zero()); - state.mut().collateralTiers.set(locals.stream * 1365 + locals.i, 0); - // Don't need to zero [state.reveals], they are all-zeros anyway - state.mut().commits.set(locals.stream * 1365 + locals.i, id::zero()); + break; } - state.mut().populations.set(locals.stream, 0); } - else + if (locals.i == state.get().populations.get(locals.stream)) { - // Don't need to initialize [locals.collateralTierFlags] because locals - // struct has been zeroed (bad practice, but it's for spreading awareness - // about this nuance) - + // Empty tick: no reveals. Refund stale (unflagged) providers; keep fresh commits. + // No-shows are not slashed when the whole pool went silent. for (locals.i = state.get().populations.get(locals.stream); locals.i--;) { - if (state.get().revealOrCommitFlags.get(locals.stream * 1365 + locals.i)) - { - locals.collateralRecipient = state.get().providers.get(locals.stream * 1365 + locals.i); - } - else + locals.index = locals.stream * RANDOM_STREAM_CAPACITY + locals.i; + if (state.get().revealOrCommitFlags.get(locals.index)) { - locals.collateralRecipient = id::zero(); + state.mut().revealOrCommitFlags.set(locals.index, 0); + continue; } - switch (state.get().collateralTiers.get(locals.stream * 1365 + locals.i)) + locals.lockedAmount = state.get().lockedCollateralAmounts.get(locals.index); + if (locals.lockedAmount > 0) { - case 0: qpi.transfer(locals.collateralRecipient, 1); break; - - case 1: qpi.transfer(locals.collateralRecipient, 10); break; - - case 2: qpi.transfer(locals.collateralRecipient, 100); break; - - case 3: qpi.transfer(locals.collateralRecipient, 1000); break; - - case 4: qpi.transfer(locals.collateralRecipient, 10000); break; - - case 5: qpi.transfer(locals.collateralRecipient, 100000); break; - - case 6: qpi.transfer(locals.collateralRecipient, 1000000); break; - - case 7: qpi.transfer(locals.collateralRecipient, 10000000); break; - - case 8: qpi.transfer(locals.collateralRecipient, 100000000); break; - - default: qpi.transfer(locals.collateralRecipient, 1000000000); + qpi.transfer(state.get().providers.get(locals.index), + static_cast(locals.lockedAmount)); } - if (locals.collateralRecipient == id::zero()) + // Swap-delete: move the last active provider into this slot. + locals.lastIndex = locals.stream * RANDOM_STREAM_CAPACITY + state.get().populations.get(locals.stream) - 1; + if (locals.index != locals.lastIndex) { - locals.collateralTierFlags |= (1 << state.get().collateralTiers.get(locals.stream * 1365 + locals.i)); - - state.mut().providers.set(locals.stream * 1365 + locals.i, - state.get().providers.get(locals.stream * 1365 + state.get().populations.get(locals.stream))); - state.mut().providers.set(locals.stream * 1365 + state.get().populations.get(locals.stream), id::zero()); - - state.mut().collateralTiers.set( - locals.stream * 1365 + locals.i, - state.get().collateralTiers.get(locals.stream * 1365 + state.get().populations.get(locals.stream))); - state.mut().collateralTiers.set(locals.stream * 1365 + state.get().populations.get(locals.stream), 0); - - state.mut().reveals.set(locals.stream * 1365 + locals.i, - state.get().reveals.get(locals.stream * 1365 + state.get().populations.get(locals.stream))); - state.mut().reveals.set(locals.stream * 1365 + state.get().populations.get(locals.stream), locals.zeroReveal); + state.mut().providers.set(locals.index, state.get().providers.get(locals.lastIndex)); + state.mut().collateralTiers.set(locals.index, state.get().collateralTiers.get(locals.lastIndex)); + state.mut().commits.set(locals.index, state.get().commits.get(locals.lastIndex)); + state.mut().reveals.set(locals.index, state.get().reveals.get(locals.lastIndex)); + state.mut().lockedCollateralAmounts.set(locals.index, state.get().lockedCollateralAmounts.get(locals.lastIndex)); + state.mut().revealOrCommitFlags.set(locals.index, state.get().revealOrCommitFlags.get(locals.lastIndex)); + state.mut().revealedThisTickFlags.set(locals.index, state.get().revealedThisTickFlags.get(locals.lastIndex)); + state.mut().contributedToEntropyFlags.set(locals.index, state.get().contributedToEntropyFlags.get(locals.lastIndex)); + } + state.mut().providers.set(locals.lastIndex, id::zero()); + state.mut().collateralTiers.set(locals.lastIndex, 0); + state.mut().commits.set(locals.lastIndex, id::zero()); + state.mut().reveals.set(locals.lastIndex, locals.zeroReveal); + state.mut().lockedCollateralAmounts.set(locals.lastIndex, 0); + state.mut().revealOrCommitFlags.set(locals.lastIndex, 0); + state.mut().revealedThisTickFlags.set(locals.lastIndex, 0); + state.mut().contributedToEntropyFlags.set(locals.lastIndex, 0); + + state.mut().populations.set(locals.stream, state.get().populations.get(locals.stream) - 1); + } + return; + } - state.mut().commits.set(locals.stream * 1365 + locals.i, - state.get().commits.get(locals.stream * 1365 + state.get().populations.get(locals.stream))); - state.mut().commits.set(locals.stream * 1365 + state.get().populations.get(locals.stream), id::zero()); + // Back-to-front walk so swap-delete doesn't disturb unvisited slots. + for (locals.i = state.get().populations.get(locals.stream); locals.i--;) + { + locals.index = locals.stream * RANDOM_STREAM_CAPACITY + locals.i; + locals.tier = static_cast(state.get().collateralTiers.get(locals.index)); - state.mut().populations.set(locals.stream, state.get().populations.get(locals.stream) - 1); - } - else + if (state.get().revealOrCommitFlags.get(locals.index)) + { + // Participated this tick; collateral remains locked. + if (state.get().revealedThisTickFlags.get(locals.index)) { - if (!(locals.collateralTierFlags & (1 << state.get().collateralTiers.get(locals.stream * 1365 + locals.i)))) + // XOR reveal into tier entropy unless the tier is already poisoned by a no-show. + if (!(locals.collateralTierFlags & (1 << locals.tier))) { - locals.entropy = - state.get().entropy.get(locals.stream * 10 + state.get().collateralTiers.get(locals.stream * 1365 + locals.i)); + locals.entropy = state.get().entropy.get(locals.stream * 10 + locals.tier); for (locals.j = 0; locals.j < 4096; locals.j++) { locals.entropy.set(locals.j, - locals.entropy.get(locals.j) ^ state.get().reveals.get(locals.stream * 1365 + locals.i).get(locals.j)); + locals.entropy.get(locals.j) ^ state.get().reveals.get(locals.index).get(locals.j)); } - state.mut().entropy.set(locals.stream * 10 + state.get().collateralTiers.get(locals.stream * 1365 + locals.i), - locals.entropy); - state.mut().reveals.set(locals.stream * 1365 + locals.i, locals.zeroReveal); + state.mut().entropy.set(locals.stream * 10 + locals.tier, locals.entropy); + // Mark contribution for BuyEntropy trustee verification. + state.mut().contributedToEntropyFlags.set(locals.index, 1); } + // Clear reveal regardless — provider can reveal again next round. + state.mut().reveals.set(locals.index, locals.zeroReveal); } - state.mut().revealOrCommitFlags.set(locals.stream * 1365 + locals.i, 0); + state.mut().revealOrCommitFlags.set(locals.index, 0); + state.mut().revealedThisTickFlags.set(locals.index, 0); } - - for (locals.i = 0; locals.i < 10; locals.i++) + else { - if (locals.collateralTierFlags & (1 << locals.i)) + // No-show: burn collateral and evict from pool. + locals.lockedAmount = state.get().lockedCollateralAmounts.get(locals.index); + if (locals.lockedAmount > 0) + { + qpi.burn(static_cast(locals.lockedAmount)); + state.mut().burnedAmount += locals.lockedAmount; + } + + // A missing reveal invalidates this tier's entropy for the round. + locals.collateralTierFlags |= (1 << locals.tier); + + // Swap-delete: move the last active provider into this slot. + locals.lastIndex = locals.stream * RANDOM_STREAM_CAPACITY + state.get().populations.get(locals.stream) - 1; + if (locals.index != locals.lastIndex) { - state.mut().entropy.set(locals.stream * 10 + locals.i, locals.zeroReveal); + state.mut().providers.set(locals.index, state.get().providers.get(locals.lastIndex)); + state.mut().collateralTiers.set(locals.index, state.get().collateralTiers.get(locals.lastIndex)); + state.mut().commits.set(locals.index, state.get().commits.get(locals.lastIndex)); + state.mut().reveals.set(locals.index, state.get().reveals.get(locals.lastIndex)); + state.mut().lockedCollateralAmounts.set(locals.index, state.get().lockedCollateralAmounts.get(locals.lastIndex)); + state.mut().revealOrCommitFlags.set(locals.index, state.get().revealOrCommitFlags.get(locals.lastIndex)); + state.mut().revealedThisTickFlags.set(locals.index, state.get().revealedThisTickFlags.get(locals.lastIndex)); + state.mut().contributedToEntropyFlags.set(locals.index, state.get().contributedToEntropyFlags.get(locals.lastIndex)); } + state.mut().providers.set(locals.lastIndex, id::zero()); + state.mut().collateralTiers.set(locals.lastIndex, 0); + state.mut().commits.set(locals.lastIndex, id::zero()); + state.mut().reveals.set(locals.lastIndex, locals.zeroReveal); + state.mut().lockedCollateralAmounts.set(locals.lastIndex, 0); + state.mut().revealOrCommitFlags.set(locals.lastIndex, 0); + state.mut().revealedThisTickFlags.set(locals.lastIndex, 0); + state.mut().contributedToEntropyFlags.set(locals.lastIndex, 0); + + state.mut().populations.set(locals.stream, state.get().populations.get(locals.stream) - 1); + } + } + + // Drop entropy for any tier that had a no-show this round. + for (locals.i = 0; locals.i < 10; locals.i++) + { + if (locals.collateralTierFlags & (1 << locals.i)) + { + state.mut().entropy.set(locals.stream * 10 + locals.i, locals.zeroReveal); } } } REGISTER_USER_FUNCTIONS_AND_PROCEDURES() { + REGISTER_USER_FUNCTION(Fees, 1); + REGISTER_USER_PROCEDURE(RevealAndCommit, 1); + REGISTER_USER_PROCEDURE(BuyEntropy, 2); } INITIALIZE() { - state.mut().bitFee = 1000; + state.mut().bitFee = RANDOM_BITFEE; } -}; +}; \ No newline at end of file diff --git a/src/public_settings.h b/src/public_settings.h index 371bded5..7d6e4b85 100644 --- a/src/public_settings.h +++ b/src/public_settings.h @@ -70,12 +70,12 @@ static_assert(AUTO_FORCE_NEXT_TICK_THRESHOLD* TARGET_TICK_DURATION >= PEER_REFRE // Config options that should NOT be changed by operators #define VERSION_A 1 -#define VERSION_B 294 +#define VERSION_B 295 #define VERSION_C 0 // Epoch and initial tick for node startup -#define EPOCH 216 -#define TICK 55900000 +#define EPOCH 217 +#define TICK 57700000 #define TICK_IS_FIRST_TICK_OF_EPOCH 1 // Set to 0 if the network is restarted during the EPOCH with a new initial TICK #define ARBITRATOR "AFZPUAIYVPNUYGJRQVLUKOPPVLHAZQTGLYAAUUNBXFTVTAMSBKQBLEIEPCVJ" @@ -100,7 +100,7 @@ static constexpr unsigned long long HYPERIDENTITY_NUMBER_OF_TICKS = 1000; static constexpr unsigned long long HYPERIDENTITY_NUMBER_OF_NEIGHBORS = 728; // 2M. Must be divided by 2 static constexpr unsigned long long HYPERIDENTITY_NUMBER_OF_MUTATIONS = 150; static constexpr unsigned long long HYPERIDENTITY_POPULATION_THRESHOLD = HYPERIDENTITY_NUMBER_OF_INPUT_NEURONS + HYPERIDENTITY_NUMBER_OF_OUTPUT_NEURONS + HYPERIDENTITY_NUMBER_OF_MUTATIONS; // P -static constexpr unsigned int HYPERIDENTITY_SOLUTION_THRESHOLD_DEFAULT = 321; +static constexpr unsigned int HYPERIDENTITY_SOLUTION_THRESHOLD_DEFAULT = 313; static constexpr unsigned long long ADDITION_NUMBER_OF_INPUT_NEURONS = 14; static constexpr unsigned long long ADDITION_NUMBER_OF_OUTPUT_NEURONS = 8; @@ -109,7 +109,7 @@ static constexpr unsigned long long ADDITION_POPULATION_THRESHOLD = 256; // Each neuron is connected to every other neuron(exclude self). The effective is clamp to (ADDITION_POPULATION_THRESHOLD - 1) at runtime static constexpr unsigned long long ADDITION_NUMBER_OF_NEIGHBORS = ADDITION_POPULATION_THRESHOLD; static constexpr unsigned long long ADDITION_NUMBER_OF_MUTATIONS = 256; -static constexpr unsigned int ADDITION_SOLUTION_THRESHOLD_DEFAULT = 76000; +static constexpr unsigned int ADDITION_SOLUTION_THRESHOLD_DEFAULT = 74300; // Multipler of score static constexpr unsigned int HYPERIDENTITY_SOLUTION_MULTIPLER = 1; diff --git a/src/qubic.cpp b/src/qubic.cpp index a64670d6..cdb9853a 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -161,7 +161,7 @@ static unsigned int numberOfTransactions = 0; static unsigned long long spectrumChangeFlags[SPECTRUM_CAPACITY / (sizeof(unsigned long long) * 8)]; static unsigned long long mainLoopNumerator = 0, mainLoopDenominator = 0; -static unsigned char contractProcessorState = 0; +static volatile unsigned char contractProcessorState = 0; static unsigned int contractProcessorPhase; static const Transaction* contractProcessorTransaction = 0; // does not have signature in some cases, see notifyContractOfIncomingTransfer() static int contractProcessorTransactionMoneyflew = 0; diff --git a/test/contract_ggwp.cpp b/test/contract_ggwp.cpp index f42346fc..46b69f51 100644 --- a/test/contract_ggwp.cpp +++ b/test/contract_ggwp.cpp @@ -226,6 +226,30 @@ class ContractTestingWP : protected ContractTesting invokeUserProcedure(WOLFPACK_CONTRACT_INDEX, 11, input, output, sender, 0); return output; } + + WOLFPACK::ProposeGovChange_output proposeGovChange(const id& sender, uint8 targetType, const id& newAddress) + { + WOLFPACK::ProposeGovChange_input input{ targetType, newAddress }; + WOLFPACK::ProposeGovChange_output output; + invokeUserProcedure(WOLFPACK_CONTRACT_INDEX, 12, input, output, sender, 0); + return output; + } + + WOLFPACK::VoteGovChange_output voteGovChange(const id& sender, uint64 proposalIndex, uint8 approve) + { + WOLFPACK::VoteGovChange_input input{ proposalIndex, approve }; + WOLFPACK::VoteGovChange_output output; + invokeUserProcedure(WOLFPACK_CONTRACT_INDEX, 13, input, output, sender, 0); + return output; + } + + WOLFPACK::GetGovProposal_output getGovProposal(uint64 proposalIndex) + { + WOLFPACK::GetGovProposal_input input{ proposalIndex }; + WOLFPACK::GetGovProposal_output output; + callFunction(WOLFPACK_CONTRACT_INDEX, 8, input, output); + return output; + } }; // ============================================================================ @@ -239,8 +263,6 @@ TEST(TestWolfPack, Initialization) EXPECT_EQ(s->totalTokensSnapshot, 0ULL); EXPECT_EQ(s->holderCount, 0ULL); - EXPECT_EQ(s->totalSharesSnapshot, 0ULL); - EXPECT_EQ(s->shareholderCount, 0ULL); EXPECT_EQ(s->clanMemberCount, 0ULL); EXPECT_EQ(s->clanWeightedTotal, 0ULL); EXPECT_EQ(s->pendingRevenue, 0ULL); @@ -465,7 +487,7 @@ TEST(TestWolfPack, SetExcludeAddress) // Invalid slot out = wp.setExcludeAddress(adminAddr, 3, excl1); - EXPECT_EQ(out.returnCode, WOLFPACK_ERROR_INVALID_SLOT); + EXPECT_EQ(out.returnCode, WOLFPACK_ERROR_INVALID_SLOT); } // ============================================================================ @@ -514,13 +536,15 @@ TEST(TestWolfPack, RevenueSplitMath) uint64 holderShare = amount * WOLFPACK_DISTRIBUTION_PERMILLE_HOLDERS / 1000ULL; uint64 shareholderShare = amount * WOLFPACK_DISTRIBUTION_PERMILLE_SHAREHOLDERS / 1000ULL; uint64 clanShare = amount * WOLFPACK_DISTRIBUTION_PERMILLE_CLAN / 1000ULL; - uint64 reinvestShare = amount - holderShare - shareholderShare - clanShare; + uint64 execReserveShare = amount * WOLFPACK_DISTRIBUTION_PERMILLE_EXEC_RESERVE / 1000ULL; + uint64 reinvestShare = amount - holderShare - shareholderShare - clanShare - execReserveShare; EXPECT_EQ(holderShare, 700000ULL); EXPECT_EQ(shareholderShare, 100000ULL); EXPECT_EQ(clanShare, 100000ULL); - EXPECT_EQ(reinvestShare, 100000ULL); - EXPECT_EQ(holderShare + shareholderShare + clanShare + reinvestShare, amount); + EXPECT_EQ(execReserveShare, 10000ULL); // 1.0% + EXPECT_EQ(reinvestShare, 90000ULL); // 9.0% + EXPECT_EQ(holderShare + shareholderShare + clanShare + execReserveShare + reinvestShare, amount); } // ============================================================================ @@ -555,9 +579,10 @@ TEST(TestWolfPack, StakeInsufficientSharesUnderManagement) { ContractTestingWP wp; - // user1 has no WP shares under WP's management in empty universe - // -> numberOfPossessedShares returns 0, stake must fail - auto out = wp.stake(user1, 100); + // user1 has no WP shares under WP's management in empty universe. + // Use >= WOLFPACK_MIN_STAKE so the min-stake gate passes and we reach the + // possession check -> numberOfPossessedShares returns 0 -> ACQUIRE_FAILED. + auto out = wp.stake(user1, WOLFPACK_MIN_STAKE); EXPECT_EQ(out.returnCode, WOLFPACK_ERROR_ACQUIRE_FAILED); } @@ -626,25 +651,26 @@ TEST(TestWolfPack, RequestUnstakePartial) ContractTestingWP wp; auto* s = wp.getState(); - s->stakedBalances.set(user1, 100); - s->totalStaked = 100; + // Use realistic amounts so the remaining position stays >= WOLFPACK_MIN_STAKE. + s->stakedBalances.set(user1, 1000000); + s->totalStaked = 1000000; s->stakerCount = 1; - auto out = wp.requestUnstake(user1, 40); + auto out = wp.requestUnstake(user1, 400000); EXPECT_EQ(out.returnCode, WOLFPACK_OK); - EXPECT_EQ(s->totalStaked, 60ULL); + EXPECT_EQ(s->totalStaked, 600000ULL); EXPECT_EQ(s->stakerCount, 1ULL); EXPECT_EQ(s->unstakeCount, 1ULL); - // Check remaining stake + // Check remaining stake (>= 500k) uint64 remaining = 0; EXPECT_TRUE(s->stakedBalances.get(user1, remaining)); - EXPECT_EQ(remaining, 60ULL); + EXPECT_EQ(remaining, 600000ULL); // Check unstake request uint64 unstakeAmt = 0; EXPECT_TRUE(s->unstakeAmounts.get(user1, unstakeAmt)); - EXPECT_EQ(unstakeAmt, 40ULL); + EXPECT_EQ(unstakeAmt, 400000ULL); uint64 unstakeEpoch = 0; EXPECT_TRUE(s->unstakeEpochs.get(user1, unstakeEpoch)); @@ -675,15 +701,15 @@ TEST(TestWolfPack, RequestUnstakeAlreadyPending) ContractTestingWP wp; auto* s = wp.getState(); - s->stakedBalances.set(user1, 100); - s->totalStaked = 100; + s->stakedBalances.set(user1, 1000000); + s->totalStaked = 1000000; s->stakerCount = 1; - wp.requestUnstake(user1, 50); + // Leaves 600000 (>= 500k) -> first request succeeds and becomes pending. + wp.requestUnstake(user1, 400000); - // Second unstake should fail while first is pending - // Need to re-stake for this to work - user1 still has 50 staked - auto out = wp.requestUnstake(user1, 30); + // Second unstake should fail while first is pending. + auto out = wp.requestUnstake(user1, 30000); EXPECT_EQ(out.returnCode, WOLFPACK_ERROR_UNSTAKE_PENDING); } @@ -708,12 +734,13 @@ TEST(TestWolfPack, StakeBlockedWhileUnstakePending) ContractTestingWP wp; auto* s = wp.getState(); - s->stakedBalances.set(user1, 100); - s->totalStaked = 100; + s->stakedBalances.set(user1, 1000000); + s->totalStaked = 1000000; s->stakerCount = 1; - wp.requestUnstake(user1, 50); + wp.requestUnstake(user1, 400000); // leaves 600000, becomes pending + // Stake is blocked while an unstake is pending (checked before min-stake/fee). auto out = wp.stake(user1, 10); EXPECT_EQ(out.returnCode, WOLFPACK_ERROR_UNSTAKE_PENDING); } @@ -902,3 +929,161 @@ TEST(TestWolfPack, StakingRewardOverflowEdgeCase) EXPECT_EQ(reward, 1923076ULL); // Should get full pool EXPECT_EQ(s->stakingRewardPool, 0ULL); } + +// ============================================================================ +// Execution-fee reserve (1% distribution cut, retained in contract) +// ============================================================================ + +TEST(TestWolfPack, ExecReserveCutOnDistribution) +{ + ContractTestingWP wp; + auto* s = wp.getState(); + + wp.depositRevenue(depositor, 100000); + EXPECT_EQ(s->pendingRevenue, 100000ULL); + + // No holders/shareholders/clan in the snapshot -> only the reinvest share is + // transferred out; the exec-reserve share is retained (never paid out). + wp.endEpoch(); + + EXPECT_EQ(s->pendingRevenue, 0ULL); + EXPECT_EQ(s->totalDistributed, 100000ULL); + EXPECT_EQ(s->execReserveFund, 1000ULL); // 1.0% of 100000 + EXPECT_EQ(s->reinvestmentFund, 9000ULL); // 9.0% of 100000 +} + +// ============================================================================ +// Min-stake (>= 500k) and unstake consistency (0 or >= 500k remaining) +// ============================================================================ + +TEST(TestWolfPack, StakeBelowMinFails) +{ + ContractTestingWP wp; + + // 100000 < WOLFPACK_MIN_STAKE (500000); checked before the possession gate. + auto out = wp.stake(user1, 100000); + EXPECT_EQ(out.returnCode, WOLFPACK_ERROR_BELOW_MIN_STAKE); +} + +TEST(TestWolfPack, RequestUnstakeBelowMinRemainingFails) +{ + ContractTestingWP wp; + auto* s = wp.getState(); + + s->stakedBalances.set(user1, 1000000); + s->totalStaked = 1000000; + s->stakerCount = 1; + + // Leaving 300000 (< 500000) must be rejected. + auto out = wp.requestUnstake(user1, 700000); + EXPECT_EQ(out.returnCode, WOLFPACK_ERROR_BELOW_MIN_STAKE); + + // Full exit (remaining 0) is always allowed. + out = wp.requestUnstake(user1, 1000000); + EXPECT_EQ(out.returnCode, WOLFPACK_OK); + EXPECT_EQ(s->totalStaked, 0ULL); +} + +// ============================================================================ +// Shareholder governance (multi-proposal, >51% of 676 SC shares) +// ============================================================================ + +TEST(TestWolfPack, GovProposeRequiresShareholder) +{ + ContractTestingWP wp; + // All SC shares go to user2; user1 owns none -> not a shareholder. + std::vector> owners = { { user2, 676 } }; + issueContractShares(WOLFPACK_CONTRACT_INDEX, owners, false); + auto out = wp.proposeGovChange(user1, WOLFPACK_GOV_TARGET_ADMIN, user2); + EXPECT_EQ(out.returnCode, WOLFPACK_ERROR_NOT_SHAREHOLDER); +} + +TEST(TestWolfPack, GovProposeNullAddressRejected) +{ + ContractTestingWP wp; + std::vector> owners = { { user1, 400 }, { user2, 276 } }; + issueContractShares(WOLFPACK_CONTRACT_INDEX, owners, false); + + auto out = wp.proposeGovChange(user1, WOLFPACK_GOV_TARGET_ADMIN, NULL_ID); + EXPECT_EQ(out.returnCode, WOLFPACK_ERROR_NULL_ADDRESS); +} + +TEST(TestWolfPack, GovProposePassesWithMajority) +{ + ContractTestingWP wp; + auto* s = wp.getState(); + std::vector> owners = { { user1, 400 }, { user2, 276 } }; + issueContractShares(WOLFPACK_CONTRACT_INDEX, owners, false); // user1: 400 (>= 345) + + auto out = wp.proposeGovChange(user1, WOLFPACK_GOV_TARGET_ADMIN, user2); + EXPECT_EQ(out.returnCode, WOLFPACK_OK); + EXPECT_EQ(out.passed, 1u); + EXPECT_TRUE(s->adminAddress == user2); +} + +TEST(TestWolfPack, GovProposeBelowMajorityStaysOpen) +{ + ContractTestingWP wp; + std::vector> owners = { { user1, 200 }, { user2, 476 } }; + issueContractShares(WOLFPACK_CONTRACT_INDEX, owners, false); // user1: 200 (< 345) + + auto out = wp.proposeGovChange(user1, WOLFPACK_GOV_TARGET_REINVEST, user2); + EXPECT_EQ(out.returnCode, WOLFPACK_OK); + EXPECT_EQ(out.passed, 0u); + EXPECT_EQ(out.proposalIndex, 0ULL); + + auto info = wp.getGovProposal(0); + EXPECT_EQ(info.status, 1u); + EXPECT_EQ(info.yesShares, 200ULL); + EXPECT_EQ(info.requiredShares, 345ULL); + EXPECT_EQ(info.totalShares, 676ULL); +} + +TEST(TestWolfPack, GovVoteReachesThreshold) +{ + ContractTestingWP wp; + auto* s = wp.getState(); + std::vector> owners = { { user1, 200 }, { user2, 200 }, { user3, 276 } }; + issueContractShares(WOLFPACK_CONTRACT_INDEX, owners, false); + + auto p = wp.proposeGovChange(user1, WOLFPACK_GOV_TARGET_REINVEST, user3); + EXPECT_EQ(p.passed, 0u); + + // user2 votes yes on the same proposal -> 200 + 200 = 400 >= 345 -> pass. + auto v = wp.voteGovChange(user2, p.proposalIndex, 1); + EXPECT_EQ(v.returnCode, WOLFPACK_OK); + EXPECT_EQ(v.passed, 1u); + EXPECT_TRUE(s->reinvestAddress == user3); +} + +TEST(TestWolfPack, GovMultipleProposalsCoexist) +{ + ContractTestingWP wp; + std::vector> owners = { { user1, 100 }, { user2, 100 }, { user3, 476 } }; + issueContractShares(WOLFPACK_CONTRACT_INDEX, owners, false); + + auto p1 = wp.proposeGovChange(user1, WOLFPACK_GOV_TARGET_ADMIN, user3); + auto p2 = wp.proposeGovChange(user2, WOLFPACK_GOV_TARGET_REINVEST, user3); + EXPECT_EQ(p1.returnCode, WOLFPACK_OK); + EXPECT_EQ(p2.returnCode, WOLFPACK_OK); + EXPECT_EQ(p1.proposalIndex, 0ULL); + EXPECT_EQ(p2.proposalIndex, 1ULL); + + EXPECT_EQ(wp.getGovProposal(0).status, 1u); + EXPECT_EQ(wp.getGovProposal(1).status, 1u); +} + +TEST(TestWolfPack, GovVoteNoActiveProposal) +{ + ContractTestingWP wp; + // No proposal opened -> NO_ACTIVE_PROPOSAL (checked before the shareholder check). + auto v = wp.voteGovChange(user1, 0, 1); + EXPECT_EQ(v.returnCode, WOLFPACK_ERROR_NO_ACTIVE_PROPOSAL); +} + +TEST(TestWolfPack, GovVoteInvalidIndex) +{ + ContractTestingWP wp; + auto v = wp.voteGovChange(user1, WOLFPACK_MAX_GOV_PROPOSALS, 1); + EXPECT_EQ(v.returnCode, WOLFPACK_ERROR_INVALID_PROPOSAL); +} diff --git a/test/contract_random.cpp b/test/contract_random.cpp new file mode 100644 index 00000000..4b8e1f4f --- /dev/null +++ b/test/contract_random.cpp @@ -0,0 +1,1086 @@ +#define NO_UEFI + +#include "contract_testing.h" +#include "kangaroo_twelve.h" + +// Helper: build a deterministic non-zero bit_4096 from a seed. +static QPI::bit_4096 makeReveal(uint64 seed) +{ + QPI::bit_4096 v; + v.setAll(0); + for (uint64 i = 0; i < 4096; ++i) + { + v.set(i, (bit)(((seed * 0x9E3779B97F4A7C15ULL + i * 0xBF58476D1CE4E5B9ULL) >> 32) & 1)); + } + return v; +} + +// Helper: K12(reveal) -> id, matching qpi.K12() used by RevealAndCommit to validate reveals. +static id commitOf(const QPI::bit_4096& reveal) +{ + id digest; + KangarooTwelve(&reveal, sizeof(reveal), &digest, sizeof(digest)); + return digest; +} + +static id getUser(uint64 i) +{ + return id(i + 1, i / 2 + 4, i + 10, i * 3 + 8); +} + +// Reward amount required by RevealAndCommit to select a given collateral tier. +static sint64 collateralForTier(uint8 tier) +{ + sint64 v = 1; + for (uint8 t = 0; t < tier; ++t) + v *= 10; + return v; +} + +class ContractTestingRandom : public ContractTesting +{ +public: + ContractTestingRandom() + { + initEmptySpectrum(); + initEmptyUniverse(); + system.epoch = contractDescriptions[RANDOM_CONTRACT_INDEX].constructionEpoch; + system.tick = 1000; + INIT_CONTRACT(RANDOM); + callSystemProcedure(RANDOM_CONTRACT_INDEX, INITIALIZE); + } + + RANDOM::StateData* state() + { + return (RANDOM::StateData*)contractStates[RANDOM_CONTRACT_INDEX]; + } + + void setTick(uint32 t) { system.tick = t; } + uint32 tick() const { return system.tick; } + void endTick() { callSystemProcedure(RANDOM_CONTRACT_INDEX, END_TICK); } + + RANDOM::Fees_output fees() + { + RANDOM::Fees_input input{}; + RANDOM::Fees_output output{}; + callFunction(RANDOM_CONTRACT_INDEX, 1, input, output); + return output; + } + + void revealAndCommit(const id& user, const QPI::bit_4096& reveal, + const id& commit, sint64 collateral) + { + RANDOM::RevealAndCommit_input input{}; + input.reveal = reveal; + input.commit = commit; + RANDOM::RevealAndCommit_output output{}; + invokeUserProcedure(RANDOM_CONTRACT_INDEX, 1, input, output, user, collateral); + } + + RANDOM::BuyEntropy_output buyEntropy(const id& user, uint8 collateralTier, + uint16 numberOfBits, sint64 amount, + const id& trustee = id::zero()) + { + RANDOM::BuyEntropy_input input{}; + input.collateralTier = collateralTier; + input.numberOfBits = numberOfBits; + input.trustee = trustee; + RANDOM::BuyEntropy_output output{}; + invokeUserProcedure(RANDOM_CONTRACT_INDEX, 2, input, output, user, amount); + return output; + } +}; + +// --------------------------------------------------------------------------- +// Basic sanity tests +// --------------------------------------------------------------------------- + +TEST(ContractRandom, FeesReturns100PerBitForFirstTenTiers) +{ + ContractTestingRandom r; + auto out = r.fees(); + for (uint64 i = 0; i < 10; ++i) + { + EXPECT_EQ(out.fees.get(i), 100u) << "fees[" << i << "] should be 100 qu/bit"; + } +} + +TEST(ContractRandom, BuyEntropyRefundsWhenEntropyMissing) +{ + ContractTestingRandom r; + + const uint8 tier = 3; + const uint16 numberOfBits = 64; + const sint64 fee = (sint64)numberOfBits * 100; + + id user = getUser(1); + increaseEnergy(user, fee); + const long long balanceBefore = getBalance(user); + + auto out = r.buyEntropy(user, tier, numberOfBits, fee); + + EXPECT_EQ(getBalance(user), balanceBefore) << "BuyEntropy must refund when no entropy is available"; + + QPI::bit_4096 zero; zero.setAll(0); + EXPECT_TRUE(out.entropy == zero) << "Output must remain all-zero on refund"; + EXPECT_EQ(r.state()->earnedAmount, 0u) << "Refund must not credit earnedAmount"; +} + +// Spec: "BuyEntropy procedure is for buying entropy. The fee is returned if there +// is no entropy, in this case output will be all zeros." By symmetry, invalid +// inputs (bad tier, zero/oversize bits, underpaid fee) must also refund in full: +// no entropy delivered, earnedAmount unchanged. + +TEST(ContractRandom, BuyEntropyRefundsOnInvalidTier) +{ + ContractTestingRandom r; + const sint64 fee = 64 * 100; + + id user = getUser(200); + increaseEnergy(user, fee); + const long long before = getBalance(user); + const uint64 earnedBefore = r.state()->earnedAmount; + + auto out = r.buyEntropy(user, /*tier*/10, /*bits*/64, fee); + + QPI::bit_4096 zero; zero.setAll(0); + EXPECT_EQ(getBalance(user), before) + << "invalid tier must refund the full fee"; + EXPECT_EQ(r.state()->earnedAmount, earnedBefore) + << "invalid tier must NOT credit earnedAmount"; + EXPECT_TRUE(out.entropy == zero) + << "rejected request must not deliver entropy"; +} + +TEST(ContractRandom, BuyEntropyRefundsOnZeroBits) +{ + ContractTestingRandom r; + const sint64 fee = 64 * 100; + + id user = getUser(201); + increaseEnergy(user, fee); + const long long before = getBalance(user); + const uint64 earnedBefore = r.state()->earnedAmount; + + auto out = r.buyEntropy(user, /*tier*/0, /*bits*/0, fee); + + QPI::bit_4096 zero; zero.setAll(0); + EXPECT_EQ(getBalance(user), before) + << "numberOfBits=0 must refund the full fee"; + EXPECT_EQ(r.state()->earnedAmount, earnedBefore); + EXPECT_TRUE(out.entropy == zero); +} + +TEST(ContractRandom, BuyEntropyRefundsOnUnderpaidFee) +{ + ContractTestingRandom r; + const sint64 fee = 64 * 100; + + id user = getUser(202); + increaseEnergy(user, fee - 1); + const long long before = getBalance(user); + const uint64 earnedBefore = r.state()->earnedAmount; + + auto out = r.buyEntropy(user, /*tier*/0, /*bits*/64, fee - 1); + + QPI::bit_4096 zero; zero.setAll(0); + EXPECT_EQ(getBalance(user), before) + << "underpaid fee must be refunded (no entropy was delivered)"; + EXPECT_EQ(r.state()->earnedAmount, earnedBefore); + EXPECT_TRUE(out.entropy == zero); +} + +TEST(ContractRandom, BuyEntropyRefundsOnOversizeBits) +{ + ContractTestingRandom r; + // 4097 bits is one more than the bit_4096 capacity -> must be rejected. + const uint16 oversize = 4097; + const sint64 fee = (sint64)oversize * 100; + + id user = getUser(203); + increaseEnergy(user, fee); + const long long before = getBalance(user); + const uint64 earnedBefore = r.state()->earnedAmount; + + auto out = r.buyEntropy(user, /*tier*/0, oversize, fee); + + QPI::bit_4096 zero; zero.setAll(0); + EXPECT_EQ(getBalance(user), before) + << "numberOfBits > 4096 must be refunded"; + EXPECT_EQ(r.state()->earnedAmount, earnedBefore); + EXPECT_TRUE(out.entropy == zero); +} + +// --------------------------------------------------------------------------- +// End-to-end: drive RevealAndCommit + END_TICK to produce real entropy, then +// verify BuyEntropy reads the correct finalized stream. +// +// Lifecycle on stream s = startTick % 3: +// Tick T : first-commit only. END_TICK leaves entropy[s*10+tier] = 0. +// Tick T+3 : reveal=reveal1, re-commit. END_TICK XORs reveal1 in -> entropy = reveal1. +// Tick T+4 : BuyEntropy reads stream (T+4+2)%3 = T%3 = s -> must return reveal1. +// +// Repeated for all three residues of T mod 3 to catch the original bug where +// BuyEntropy always read stream 0 instead of the latest finalized stream. +// --------------------------------------------------------------------------- + +namespace +{ + void runFullRandomCycle(uint32 startTick) + { + ContractTestingRandom r; + r.setTick(startTick); + + const uint8 tier = 2; // 100 qu collateral + const sint64 collateral = collateralForTier(tier); // 100 + const uint32 stream = startTick % 3; + + // NOTE: identifiers C1/C2 (and R1/R2 with the FourQ headers) are + // macros in src/four_q.h, so we use longer names here to avoid the + // macro expansion. + const QPI::bit_4096 reveal1 = makeReveal(startTick * 1234567u + 1u); + const QPI::bit_4096 reveal2 = makeReveal(startTick * 1234567u + 2u); + const id commit1 = commitOf(reveal1); + const id commit2 = commitOf(reveal2); + + const id provider = getUser(0xC0DE + startTick); + + // ----- Tick T : initial commit only ------------------------------- + increaseEnergy(provider, collateral); + QPI::bit_4096 zero; zero.setAll(0); + r.revealAndCommit(provider, /*reveal=*/zero, /*commit=*/commit1, collateral); + + // Provider was registered at slot 0 of this stream + EXPECT_EQ(r.state()->populations.get(stream), 1u); + + r.endTick(); + // After END_TICK on stream s: flag cleared, collateral stays locked, entropy stays zero. + EXPECT_TRUE(r.state()->entropy.get(stream * 10 + tier) == zero); + EXPECT_EQ(r.state()->populations.get(stream), 1u); + + // Advance two ticks (streams (s+1)%3 and (s+2)%3). We don't need to + // call END_TICK for them; they have no providers, so the BuyEntropy + // verification only depends on stream s. + // ----- Tick T+3 : reveal reveal1, commit commit2 ---------------------- + r.setTick(startTick + 3); + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, /*reveal=*/reveal1, /*commit=*/commit2, collateral); + + // The reveal must have been accepted (commits[i] == K12(reveal1)) + EXPECT_FALSE(r.state()->reveals.get(stream * 1365 + 0) == zero) + << "Reveal was not accepted by RevealAndCommit"; + + r.endTick(); + + // END_TICK XORs reveal1 into entropy[stream*10+tier], leaving it = reveal1. + EXPECT_TRUE(r.state()->entropy.get(stream * 10 + tier) == reveal1) + << "END_TICK should have finalized entropy = reveal1 for stream " + << stream << " tier " << tier; + + // ----- Tick T+4 : BuyEntropy must return the latest entropy ------- + r.setTick(startTick + 4); + + // Sanity: the contract's "latest finalized stream" formula must + // resolve to s, which is where we just wrote reveal1. + const uint32 expectedLatest = (r.tick() + 2u) % 3u; + ASSERT_EQ(expectedLatest, stream); + + // Request the full 4096 bits so the whole entropy value is delivered + // and can be compared against reveal1 directly. + const uint16 numberOfBits = 4096; + const sint64 fee = (sint64)numberOfBits * 100; + + id buyer = getUser(0xBEEF + startTick); + increaseEnergy(buyer, fee); + const long long buyerBefore = getBalance(buyer); + const uint64 earnedBefore = r.state()->earnedAmount; + + auto out = r.buyEntropy(buyer, tier, numberOfBits, fee); + + // Fee was consumed (no refund path) + EXPECT_EQ(getBalance(buyer), buyerBefore - fee) + << "BuyEntropy must consume fee when entropy is available"; + EXPECT_EQ(r.state()->earnedAmount, earnedBefore + (uint64)fee); + + // Output entropy must equal what END_TICK produced + EXPECT_TRUE(out.entropy == reveal1) + << "BuyEntropy at tick " << r.tick() + << " (stream " << stream + << ") returned wrong entropy. With the old bug " + "(entropy[tier] = stream 0 only) this fails on startTick%3 != 0."; + } +} + +TEST(ContractRandom, EndToEnd_LatestEntropyRetrieved_TickMod3_Is0) +{ + runFullRandomCycle(/*startTick=*/1002); // 1002 % 3 == 0 -> stream 0 +} + +TEST(ContractRandom, EndToEnd_LatestEntropyRetrieved_TickMod3_Is1) +{ + runFullRandomCycle(/*startTick=*/1003); // 1003 % 3 == 1 -> stream 1 +} + +TEST(ContractRandom, EndToEnd_LatestEntropyRetrieved_TickMod3_Is2) +{ + runFullRandomCycle(/*startTick=*/1004); // 1004 % 3 == 2 -> stream 2 +} + +// --------------------------------------------------------------------------- +// Trustee condition. BuyEntropy carries a single optional trustee: +// - trustee == 0 : unconditional, plain buy. +// - trustee is a provider in the +// stream/tier being bought : entropy delivered, fee charged. +// - trustee absent from that stream/tier : nothing delivered, nothing charged. +// --------------------------------------------------------------------------- + +namespace +{ + // Drive one provider through commit -> reveal so entropy[stream*10+tier] = reveal1 + // at tick T+4. Leaves the contract at tick T+4. + struct TrusteeScenario + { + uint8 tier; + uint32 stream; + id provider; + QPI::bit_4096 entropy; + }; + + TrusteeScenario buildEntropyWithProvider(ContractTestingRandom& r, uint32 startTick, + uint8 tier, uint64 providerSeed) + { + r.setTick(startTick); + const sint64 collateral = collateralForTier(tier); + const uint32 stream = startTick % 3; + + const QPI::bit_4096 reveal1 = makeReveal(providerSeed * 7654321u + 1u); + const id commit1 = commitOf(reveal1); + const id commit2 = commitOf(makeReveal(providerSeed * 7654321u + 2u)); + const id provider = getUser(providerSeed); + + QPI::bit_4096 zero; zero.setAll(0); + + // Tick T: first commit (no reveal yet). + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, zero, commit1, collateral); + r.endTick(); + + // Tick T+3 (same stream): reveal reveal1, recommit. END_TICK XORs + // reveal1 into entropy[stream*10+tier]. + r.setTick(startTick + 3); + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, reveal1, commit2, collateral); + r.endTick(); + + // Tick T+4: the latest finalized stream is exactly `stream`. + r.setTick(startTick + 4); + + TrusteeScenario s; + s.tier = tier; + s.stream = stream; + s.provider = provider; + s.entropy = reveal1; + return s; + } +} + +// A trustee that is a provider in the bought stream/tier: behaves like a plain +// buy (entropy delivered, fee charged). +TEST(ContractRandom, BuyEntropyWithMatchingTrusteeSucceeds) +{ + ContractTestingRandom r; + auto s = buildEntropyWithProvider(r, /*startTick=*/1002, /*tier=*/2, /*seed=*/0xA11CE); + ASSERT_TRUE(r.state()->entropy.get(s.stream * 10 + s.tier) == s.entropy); + + const uint16 numberOfBits = 4096; + const sint64 fee = (sint64)numberOfBits * 100; + id buyer = getUser(0xB0B); + increaseEnergy(buyer, fee); + const long long before = getBalance(buyer); + const uint64 earnedBefore = r.state()->earnedAmount; + + auto out = r.buyEntropy(buyer, s.tier, numberOfBits, fee, /*trustee=*/s.provider); + + EXPECT_EQ(getBalance(buyer), before - fee) + << "buy with a present trustee must charge the fee"; + EXPECT_EQ(r.state()->earnedAmount, earnedBefore + (uint64)fee); + EXPECT_TRUE(out.entropy == s.entropy) + << "buy with a present trustee must return the finalized entropy"; +} + +// A trustee absent from the stream: charge nothing, return all-zeros. +TEST(ContractRandom, BuyEntropyWithAbsentTrusteeRefundsAndReturnsZero) +{ + ContractTestingRandom r; + auto s = buildEntropyWithProvider(r, /*startTick=*/1002, /*tier=*/2, /*seed=*/0xA11CE); + + const uint16 numberOfBits = 4096; + const sint64 fee = (sint64)numberOfBits * 100; + id buyer = getUser(0xB0B); + increaseEnergy(buyer, fee); + const long long before = getBalance(buyer); + const uint64 earnedBefore = r.state()->earnedAmount; + + const id absentTrustee = getUser(0xDEAD); // never registered as a provider + auto out = r.buyEntropy(buyer, s.tier, numberOfBits, fee, /*trustee=*/absentTrustee); + + EXPECT_EQ(getBalance(buyer), before) + << "buy with an absent trustee must charge nothing (full refund)"; + EXPECT_EQ(r.state()->earnedAmount, earnedBefore) + << "buy with an absent trustee must not earn any fee"; + QPI::bit_4096 zero; zero.setAll(0); + EXPECT_TRUE(out.entropy == zero) + << "buy with an absent trustee must return all-zero entropy"; +} + +// A zero trustee imposes no condition: identical to the original BuyEntropy. +TEST(ContractRandom, BuyEntropyZeroTrusteeBehavesLikePlainBuy) +{ + ContractTestingRandom r; + auto s = buildEntropyWithProvider(r, /*startTick=*/1002, /*tier=*/2, /*seed=*/0xA11CE); + + const uint16 numberOfBits = 4096; + const sint64 fee = (sint64)numberOfBits * 100; + id buyer = getUser(0xB0B); + increaseEnergy(buyer, fee); + const long long before = getBalance(buyer); + + auto out = r.buyEntropy(buyer, s.tier, numberOfBits, fee, /*trustee=*/id::zero()); + + EXPECT_EQ(getBalance(buyer), before - fee); + EXPECT_TRUE(out.entropy == s.entropy); +} + +// A trustee present only in a different tier does not satisfy the condition +// (tier must match). The bought tier has its own real entropy. +TEST(ContractRandom, BuyEntropyTrusteeInOtherTierRefunds) +{ + ContractTestingRandom r; + const uint32 startTick = 1002; + const uint32 stream = startTick % 3; + const uint8 boughtTier = 2; + const uint8 otherTier = 3; + const sint64 collateralBought = collateralForTier(boughtTier); + const sint64 collateralOther = collateralForTier(otherTier); + + const id providerBought = getUser(0xA11CE); + const id providerOther = getUser(0xB0B0); + + const QPI::bit_4096 revealBought = makeReveal(11); + const QPI::bit_4096 revealOther = makeReveal(22); + QPI::bit_4096 zero; zero.setAll(0); + + // Tick T: both providers first-commit on the same stream, different tiers. + r.setTick(startTick); + increaseEnergy(providerBought, collateralBought); + r.revealAndCommit(providerBought, zero, commitOf(revealBought), collateralBought); + increaseEnergy(providerOther, collateralOther); + r.revealAndCommit(providerOther, zero, commitOf(revealOther), collateralOther); + r.endTick(); + + // Tick T+3: both reveal, so tier 2 and tier 3 entropy both become non-zero. + r.setTick(startTick + 3); + increaseEnergy(providerBought, collateralBought); + r.revealAndCommit(providerBought, revealBought, commitOf(makeReveal(111)), collateralBought); + increaseEnergy(providerOther, collateralOther); + r.revealAndCommit(providerOther, revealOther, commitOf(makeReveal(222)), collateralOther); + r.endTick(); + + r.setTick(startTick + 4); + ASSERT_FALSE(r.state()->entropy.get(stream * 10 + boughtTier) == zero) + << "tier being bought must have its own real entropy for this test to isolate the tier check"; + + const uint16 numberOfBits = 4096; + const sint64 fee = (sint64)numberOfBits * 100; + id buyer = getUser(0xBEEF); + increaseEnergy(buyer, fee); + const long long before = getBalance(buyer); + + // Buy tier 2 but name the tier-3 provider as trustee. + auto out = r.buyEntropy(buyer, boughtTier, numberOfBits, fee, /*trustee=*/providerOther); + + EXPECT_EQ(getBalance(buyer), before) + << "a trustee present only in another tier must not satisfy the condition"; + EXPECT_TRUE(out.entropy == zero); + + // Sanity: naming the correct-tier provider succeeds against the same state. + increaseEnergy(buyer, fee); + const long long before2 = getBalance(buyer); + auto out2 = r.buyEntropy(buyer, boughtTier, numberOfBits, fee, /*trustee=*/providerBought); + EXPECT_EQ(getBalance(buyer), before2 - fee); + EXPECT_TRUE(out2.entropy == r.state()->entropy.get(stream * 10 + boughtTier)); +} + +// Enrollment alone doesn't satisfy the trustee condition — the provider must have +// actually XOR'd a reveal into entropy this round (contributedToEntropyFlags=1). +// +// Scenario (stream 0, T=1002): Bob commits; T+3 Bob reveals + Alice first-commits; +// T+4 trustee=Alice refunds (flag=0), trustee=Bob succeeds (flag=1). +TEST(ContractRandom, BuyEntropyFreshTrusteeRefunds) +{ + ContractTestingRandom r; + const uint32 startTick = 1002; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = startTick % 3; + + const id bob = getUser(0xB0B); + const id alice = getUser(0xA11CE); + + const QPI::bit_4096 revealBob = makeReveal(0xB0B * 7 + 1); + const id commitBob1 = commitOf(revealBob); + const id commitBob2 = commitOf(makeReveal(0xB0B * 7 + 2)); + const id commitAlice = commitOf(makeReveal(0xA11CE * 7 + 1)); + + QPI::bit_4096 zero; zero.setAll(0); + + // Tick T: Bob first-commits only. + r.setTick(startTick); + increaseEnergy(bob, collateral); + r.revealAndCommit(bob, zero, commitBob1, collateral); + r.endTick(); + // Empty reveal channel -> entropy stays zero, Bob stays enrolled. + ASSERT_TRUE(r.state()->entropy.get(stream * 10 + tier) == zero); + ASSERT_EQ(r.state()->populations.get(stream), 1u); + + // Tick T+3: Bob reveals (entropy becomes revealBob), Alice first-commits. + r.setTick(startTick + 3); + increaseEnergy(bob, collateral); + r.revealAndCommit(bob, revealBob, commitBob2, collateral); + increaseEnergy(alice, collateral); + r.revealAndCommit(alice, zero, commitAlice, collateral); + r.endTick(); + + // Sanity: entropy is now non-zero (Bob's reveal was XOR'd in). + ASSERT_FALSE(r.state()->entropy.get(stream * 10 + tier) == zero) + << "Bob's reveal must have produced non-zero entropy"; + ASSERT_EQ(r.state()->populations.get(stream), 2u) + << "Both Bob and Alice must be enrolled"; + + // Find Alice's index and verify her contributedToEntropyFlags is 0. + bool aliceFound = false; + for (uint32 i = 0; i < r.state()->populations.get(stream); i++) + { + uint32 idx = stream * RANDOM_STREAM_CAPACITY + i; + if (r.state()->providers.get(idx) == alice) + { + aliceFound = true; + EXPECT_EQ(r.state()->contributedToEntropyFlags.get(idx), 0) + << "Alice only committed this round; her contribution flag must be 0"; + break; + } + } + ASSERT_TRUE(aliceFound) << "Alice must be in the pool after first-commit"; + + // Tick T+4: BuyEntropy. + r.setTick(startTick + 4); + const uint16 numberOfBits = 4096; + const sint64 fee = (sint64)numberOfBits * 100; + + // --- Alice as trustee: must refund (she did not contribute to the entropy). + id buyer = getUser(0xBEEF); + increaseEnergy(buyer, fee); + const long long beforeAlice = getBalance(buyer); + const uint64 earnedBeforeAlice = r.state()->earnedAmount; + + auto outAlice = r.buyEntropy(buyer, tier, numberOfBits, fee, /*trustee=*/alice); + + EXPECT_EQ(getBalance(buyer), beforeAlice) + << "BuyEntropy with fresh-committer trustee must refund (trustee did not reveal)"; + EXPECT_EQ(r.state()->earnedAmount, earnedBeforeAlice) + << "earnedAmount must not increase on trustee refund"; + EXPECT_TRUE(outAlice.entropy == zero) + << "output must be all-zero when trustee check fails"; + + // --- Bob as trustee: must succeed (he did reveal and his flag is set). + increaseEnergy(buyer, fee); + const long long beforeBob = getBalance(buyer); + const uint64 earnedBeforeBob = r.state()->earnedAmount; + + auto outBob = r.buyEntropy(buyer, tier, numberOfBits, fee, /*trustee=*/bob); + + EXPECT_EQ(getBalance(buyer), beforeBob - fee) + << "BuyEntropy with an established trustee must charge the fee"; + EXPECT_EQ(r.state()->earnedAmount, earnedBeforeBob + (uint64)fee); + EXPECT_FALSE(outBob.entropy == zero) + << "BuyEntropy with an established trustee must return real entropy"; +} + +// --------------------------------------------------------------------------- +// Collateral-lifecycle tests (C1 / C2 / C3 / H4). +// +// The contract holds a provider's stake from the moment they commit until +// they reveal it in a later round. The core invariant is: the contract never +// pays or burns a tier amount unless that exact amount is recorded in +// state.lockedCollateralAmounts[index]. +// --------------------------------------------------------------------------- + +// C3: a first commit must NOT be refunded at END_TICK. The stake stays locked +// in the contract until the provider reveals it (or is slashed for not doing +// so). Refunding at the commit tick means no real stake is ever held. +TEST(ContractRandom, FirstCommitHoldsCollateralThroughEndTick) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); // 100 + const uint32 stream = r.tick() % 3; + const uint32 index = stream * 1365 + 0; + + id provider = getUser(1); + increaseEnergy(provider, collateral); + + QPI::bit_4096 zero; zero.setAll(0); + r.revealAndCommit(provider, zero, commitOf(makeReveal(1)), collateral); + + // Stake was paid in and recorded as locked. + EXPECT_EQ(getBalance(provider), 0); + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(index), (uint64)collateral); + + r.endTick(); + + // C3: the collateral must still be locked after END_TICK, not refunded. + EXPECT_EQ(getBalance(provider), 0) + << "first-commit collateral must stay locked through END_TICK"; + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(index), (uint64)collateral) + << "lockedCollateralAmounts must be unchanged by END_TICK"; + EXPECT_EQ(r.state()->populations.get(stream), 1u) + << "a provider who committed must remain in the pool"; +} + +// C3: a valid reveal refunds exactly the previously locked stake (once) and +// locks the freshly supplied stake for the next round. +TEST(ContractRandom, RevealRefundsOldCollateralExactlyOnce) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = r.tick() % 3; + const uint32 index = stream * 1365 + 0; + + auto reveal1 = makeReveal(1); + id provider = getUser(1); + QPI::bit_4096 zero; zero.setAll(0); + + // First commit. + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, zero, commitOf(reveal1), collateral); + r.endTick(); + + // Next cycle: reveal the preimage and commit again. + r.setTick(r.tick() + 3); + increaseEnergy(provider, collateral); + const long long before = getBalance(provider); // just-funded new stake + + r.revealAndCommit(provider, reveal1, commitOf(makeReveal(2)), collateral); + + // The provider paid one new stake and got the old one back -> net zero. + EXPECT_EQ(getBalance(provider), before) + << "reveal must refund exactly the previously locked collateral"; + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(index), (uint64)collateral) + << "the new commit's collateral must now be locked"; +} + +// C2: a reveal carrying an empty commit must be rejected BEFORE any state +// change and refunded exactly once. The old bug set the participation flag and +// refunded, then END_TICK refunded the collateral a second time. +TEST(ContractRandom, RevealWithZeroCommitIsRejectedNoDoublePay) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = r.tick() % 3; + const uint32 index = stream * 1365 + 0; + + auto reveal1 = makeReveal(1); + id provider = getUser(1); + QPI::bit_4096 zero; zero.setAll(0); + + // First commit. + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, zero, commitOf(reveal1), collateral); + r.endTick(); + + // Next cycle: attempt to reveal with commit == 0. + r.setTick(r.tick() + 3); + increaseEnergy(provider, collateral); + const long long before = getBalance(provider); + + r.revealAndCommit(provider, reveal1, /*commit=*/id::zero(), collateral); + + // The call is rejected and refunded once; no state was touched. + EXPECT_EQ(getBalance(provider), before) + << "commit==0 must be refunded once, without double-paying"; + EXPECT_TRUE(r.state()->reveals.get(index) == zero) + << "a rejected commit==0 call must not store the reveal"; + EXPECT_EQ(r.state()->revealedThisTickFlags.get(index), 0) + << "a rejected commit==0 call must not set the participation flag"; +} + +// S1: commit and reveal in the same tick must be rejected. The whole point of the +// commit-reveal scheme is that the preimage is locked before the reveal window opens. +// If both are allowed in one tick, a provider can observe other reveals, pick an +// optimal preimage, and submit commit+reveal together — breaking entropy security. +// +// The guard reuses revealOrCommitFlags: the first-commit path sets the flag; END_TICK +// clears it. So a reveal attempted while the flag is already set means the provider +// committed this tick and must be rejected. +TEST(ContractRandom, SameTickCommitAndRevealIsRejected) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = r.tick() % 3; + const uint32 index = stream * RANDOM_STREAM_CAPACITY + 0; + + const QPI::bit_4096 reveal1 = makeReveal(0xDEAD); + const id commit1 = commitOf(reveal1); + const id commit2 = commitOf(makeReveal(0xBEEF)); + id provider = getUser(1); + QPI::bit_4096 zero; zero.setAll(0); + + // Tx 1: first-commit. Registers the provider and sets revealOrCommitFlags=1. + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, zero, commit1, collateral); + ASSERT_EQ(r.state()->populations.get(stream), 1u); + ASSERT_TRUE(r.state()->commits.get(index) == commit1); + + // Tx 2 (same tick): attempt to reveal the preimage of commit1 immediately. + // Must be rejected — provider must not be able to pick the preimage after + // observing other reveals in the same tick. + increaseEnergy(provider, collateral); + const long long before = getBalance(provider); + + r.revealAndCommit(provider, reveal1, commit2, collateral); + + EXPECT_EQ(getBalance(provider), before) + << "same-tick reveal must be refunded in full"; + EXPECT_TRUE(r.state()->reveals.get(index) == zero) + << "same-tick reveal must not store the reveal value"; + EXPECT_EQ(r.state()->revealedThisTickFlags.get(index), 0) + << "same-tick reveal must not set the revealed flag"; + EXPECT_TRUE(r.state()->commits.get(index) == commit1) + << "same-tick reveal must not overwrite the existing commit"; + + // After END_TICK the provider is still enrolled (they committed legitimately). + r.endTick(); + EXPECT_EQ(r.state()->populations.get(stream), 1u) + << "provider must remain enrolled after END_TICK"; + + // Tick T+3: the normal reveal must now succeed. + r.setTick(r.tick() + 3); + increaseEnergy(provider, collateral); + const long long before2 = getBalance(provider); + + r.revealAndCommit(provider, reveal1, commit2, collateral); + + EXPECT_EQ(getBalance(provider), before2) + << "legitimate reveal at T+3 must refund prior collateral (net zero)"; + EXPECT_EQ(r.state()->revealedThisTickFlags.get(index), 1) + << "legitimate reveal at T+3 must set the revealed flag"; +} + +// C1: when at least one reveal lands in a round, a no-show provider's stake is +// burned and they are evicted. A witness provider proves the reveal channel was +// live — without one END_TICK takes the empty-tick path and refunds instead of slashing. +TEST(ContractRandom, SilentProviderIsSlashedNotPaid) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = r.tick() % 3; + + id silent = getUser(1); + id witness = getUser(2); + auto witR1 = makeReveal(101); + auto witR2 = makeReveal(102); + QPI::bit_4096 zero; zero.setAll(0); + + // T: both providers commit. T+3 will be a slash-eligible round once the + // witness reveals. + increaseEnergy(silent, collateral); + increaseEnergy(witness, collateral); + r.revealAndCommit(silent, zero, commitOf(makeReveal(1)), collateral); + r.revealAndCommit(witness, zero, commitOf(witR1), collateral); + r.endTick(); + + const uint64 burnedBefore = r.state()->burnedAmount; + const long long silentBefore = getBalance(silent); + + // T+3: witness reveals, silent does nothing. The reveal channel is proven + // usable, so slashing applies to silent. + r.setTick(r.tick() + 3); + increaseEnergy(witness, collateral); + r.revealAndCommit(witness, witR1, commitOf(witR2), collateral); + r.endTick(); + + EXPECT_EQ(getBalance(silent), silentBefore) + << "a silent provider must not receive any payout when a peer revealed"; + EXPECT_EQ(r.state()->burnedAmount, burnedBefore + (uint64)collateral) + << "the silent provider's locked collateral must be burned"; + EXPECT_EQ(r.state()->populations.get(stream), 1u) + << "only the witness must remain; the silent provider is removed"; +} + +TEST(ContractRandom, EmptyRevealChannelRefundsAndRemovesPendingCommit) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = r.tick() % 3; + + id provider = getUser(1); + QPI::bit_4096 zero; zero.setAll(0); + + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, zero, commitOf(makeReveal(1)), collateral); + r.endTick(); + const uint32 index = stream * RANDOM_STREAM_CAPACITY; + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(index), (uint64)collateral); + + const uint64 burnedBefore = r.state()->burnedAmount; + const long long balanceBefore = getBalance(provider); + + // T+3: nobody reveals. END_TICK refunds the stake and evicts the provider + // so the slot is reusable — a stale commit would brick re-registration. + r.setTick(r.tick() + 3); + r.endTick(); + + EXPECT_EQ(getBalance(provider), balanceBefore + collateral) + << "pending-commit stake must be refunded on empty reveal channel"; + EXPECT_EQ(r.state()->burnedAmount, burnedBefore) + << "nothing must be burned when the reveal channel was empty"; + EXPECT_EQ(r.state()->populations.get(stream), 0u) + << "stale-commit holder must be swap-deleted so slot is reusable"; + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(index), 0u); + EXPECT_TRUE(r.state()->providers.get(index) == id::zero()) + << "provider slot must be cleared so re-registration is possible"; + EXPECT_TRUE(r.state()->commits.get(index) == id::zero()); +} + +// R1 regression test: a force-majeured provider must be able to register +// again on the next stream-tick. Without swap-delete in the empty branch +// their slot would stay bricked. +TEST(ContractRandom, ProviderCanReRegisterAfterEmptyRevealChannel) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = r.tick() % 3; + + id provider = getUser(1); + QPI::bit_4096 zero; zero.setAll(0); + + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, zero, commitOf(makeReveal(1)), collateral); + r.endTick(); + + r.setTick(r.tick() + 3); + r.endTick(); + ASSERT_EQ(r.state()->populations.get(stream), 0u); + + // Walk forward until tick%3 == stream again so we are in this stream's + // commit window. + while ((r.tick() % 3) != stream) { r.setTick(r.tick() + 1); r.endTick(); } + + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, zero, commitOf(makeReveal(2)), collateral); + + EXPECT_EQ(r.state()->populations.get(stream), 1u) + << "provider must be able to register again after a force-majeure refund"; + const uint32 index = stream * RANDOM_STREAM_CAPACITY; + EXPECT_TRUE(r.state()->providers.get(index) == provider) + << "fresh registration must populate the freed slot"; + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(index), (uint64)collateral) + << "fresh stake must be locked"; +} + +TEST(ContractRandom, FirstCommitDoesNotCountAsRevealForEmptyChannel) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = r.tick() % 3; + + id stale = getUser(1); // committed at T, expected to reveal at T+3 + id joiner = getUser(2); // first-commits at T+3 + QPI::bit_4096 zero; zero.setAll(0); + + // T: stale commits. + increaseEnergy(stale, collateral); + r.revealAndCommit(stale, zero, commitOf(makeReveal(1)), collateral); + r.endTick(); + + const uint64 burnedBefore = r.state()->burnedAmount; + const long long staleBefore = getBalance(stale); + + // T+3: stale does nothing, joiner does a first-commit only. No actual + // reveal landed → empty-reveal-channel branch must fire. + r.setTick(r.tick() + 3); + increaseEnergy(joiner, collateral); + r.revealAndCommit(joiner, zero, commitOf(makeReveal(2)), collateral); + r.endTick(); + + EXPECT_EQ(getBalance(stale), staleBefore + collateral) + << "stale provider must be refunded - a first-commit is not a reveal"; + EXPECT_EQ(r.state()->burnedAmount, burnedBefore) + << "no burn when the reveal channel was empty"; + // Stale is swap-deleted; fresh joiner stays. After swap-delete the + // joiner may end up at any index (depends on which slot was vacated). + EXPECT_EQ(r.state()->populations.get(stream), 1u) + << "stale removed, joiner kept"; + + bool joinerFound = false; + for (uint32 i = 0; i < r.state()->populations.get(stream); i++) + { + const uint32 idx = stream * RANDOM_STREAM_CAPACITY + i; + if (r.state()->providers.get(idx) == joiner) + { + joinerFound = true; + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(idx), (uint64)collateral) + << "joiner's fresh stake must remain locked for next round"; + EXPECT_FALSE(r.state()->commits.get(idx) == id::zero()) + << "joiner's fresh commit must be preserved"; + } + } + EXPECT_TRUE(joinerFound) << "joiner must remain in the pool"; +} + +TEST(ContractRandom, EmptyRevealChannelIsolatedToStream) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + + // Align so each stream has a committed provider going into its next round. + r.setTick(r.tick()); + const uint32 stream0 = r.tick() % 3; + const uint32 stream1 = (r.tick() + 1) % 3; + const uint32 stream2 = (r.tick() + 2) % 3; + + id p0 = getUser(10); + id p1 = getUser(11); + id p2 = getUser(12); + QPI::bit_4096 zero; zero.setAll(0); + + // Each provider commits in their own stream's tick. + increaseEnergy(p0, collateral); + r.revealAndCommit(p0, zero, commitOf(makeReveal(1)), collateral); + r.endTick(); + + r.setTick(r.tick() + 1); + increaseEnergy(p1, collateral); + r.revealAndCommit(p1, zero, commitOf(makeReveal(2)), collateral); + r.endTick(); + + r.setTick(r.tick() + 1); + increaseEnergy(p2, collateral); + r.revealAndCommit(p2, zero, commitOf(makeReveal(3)), collateral); + r.endTick(); + + // Now jump forward exactly 3 ticks (so we land on stream0's reveal round) + // and run END_TICK with no reveal activity. Only stream0 must be touched. + r.setTick(r.tick() + 1); // now == original_tick + 3, mod 3 == stream0 + const uint64 lockedS1Before = r.state()->lockedCollateralAmounts.get(stream1 * RANDOM_STREAM_CAPACITY); + const uint64 lockedS2Before = r.state()->lockedCollateralAmounts.get(stream2 * RANDOM_STREAM_CAPACITY); + r.endTick(); + + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(stream0 * RANDOM_STREAM_CAPACITY), 0u) + << "stream0's locked stake must be refunded (empty reveal tick)"; + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(stream1 * RANDOM_STREAM_CAPACITY), lockedS1Before) + << "stream1 must be untouched by stream0's END_TICK"; + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(stream2 * RANDOM_STREAM_CAPACITY), lockedS2Before) + << "stream2 must be untouched by stream0's END_TICK"; + EXPECT_EQ(r.state()->populations.get(stream0), 0u) + << "stream0's stale-commit holder is swap-deleted"; + EXPECT_EQ(r.state()->populations.get(stream1), 1u) + << "stream1 untouched"; + EXPECT_EQ(r.state()->populations.get(stream2), 1u) + << "stream2 untouched"; +} + +TEST(ContractRandom, ConsecutiveEmptyRevealChannelsDoNotDoubleRefund) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = r.tick() % 3; + + id provider = getUser(1); + QPI::bit_4096 zero; zero.setAll(0); + + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, zero, commitOf(makeReveal(1)), collateral); + r.endTick(); + + const long long balanceBefore = getBalance(provider); + + // First empty reveal tick: refund. + r.setTick(r.tick() + 3); + r.endTick(); + EXPECT_EQ(getBalance(provider), balanceBefore + collateral) + << "first empty reveal tick must refund once"; + + // After the first empty tick the provider was swap-deleted, so the + // second empty tick has no providers to process at all. + r.setTick(r.tick() + 3); + r.endTick(); + EXPECT_EQ(getBalance(provider), balanceBefore + collateral) + << "second empty reveal tick must NOT refund again"; + EXPECT_EQ(r.state()->populations.get(stream), 0u) + << "stream must remain empty across consecutive empty ticks"; +} + +// H4: when one provider in a tier is a no-show, the other providers in that +// same tier must not be locked out. END_TICK clears their reveal slot even +// though the poisoned tier's entropy XOR is skipped. +TEST(ContractRandom, NoShowInTierDoesNotLockOutOtherProviders) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = r.tick() % 3; + + auto good1 = makeReveal(10); + auto good2 = makeReveal(11); + auto good3 = makeReveal(12); + auto bad1 = makeReveal(20); + + id good = getUser(1); + id bad = getUser(2); + QPI::bit_4096 zero; zero.setAll(0); + + // Tick T: both providers register in the same tier. + increaseEnergy(good, collateral); + increaseEnergy(bad, collateral); + r.revealAndCommit(good, zero, commitOf(good1), collateral); + r.revealAndCommit(bad, zero, commitOf(bad1), collateral); + r.endTick(); + EXPECT_EQ(r.state()->populations.get(stream), 2u); + + // Tick T+3: good reveals, bad is a no-show. + r.setTick(r.tick() + 3); + increaseEnergy(good, collateral); + r.revealAndCommit(good, good1, commitOf(good2), collateral); + r.endTick(); + + EXPECT_EQ(r.state()->populations.get(stream), 1u) + << "the no-show provider must be removed from the pool"; + + // Tick T+6: good must still be able to reveal+commit. This only works if + // END_TICK cleared good's reveal slot at T+3 despite the poisoned tier. + r.setTick(r.tick() + 3); + increaseEnergy(good, collateral); + r.revealAndCommit(good, good2, commitOf(good3), collateral); + + bool found = false; + for (uint32 i = 0; i < r.state()->populations.get(stream); i++) + { + if (r.state()->providers.get(stream * 1365 + i) == good) + { + found = true; + EXPECT_EQ(r.state()->revealedThisTickFlags.get(stream * 1365 + i), 1) + << "good provider's reveal at T+6 must be accepted, not rejected"; + } + } + EXPECT_TRUE(found) << "good provider must still be in the pool"; +}