diff --git a/src/contract_core/contract_def.h b/src/contract_core/contract_def.h index 9f5d9965..eeafbc7d 100644 --- a/src/contract_core/contract_def.h +++ b/src/contract_core/contract_def.h @@ -598,7 +598,7 @@ struct ContractStateChangeInfo // When enabling, replace both lines below, e.g.: //constexpr ContractStateChangeInfo contractStateChangeInfos[] = { { DUMMY_CONTRACT_INDEX, MIGRATE, 219 } }; //constexpr unsigned int contractStateChangeCount = sizeof(contractStateChangeInfos) / sizeof(contractStateChangeInfos[0]); -constexpr ContractStateChangeInfo contractStateChangeInfos[] = { { NOST_CONTRACT_INDEX, MIGRATE, 230 }}; +constexpr ContractStateChangeInfo contractStateChangeInfos[] = { { NOST_CONTRACT_INDEX, MIGRATE, 230 }, { QUSINO_CONTRACT_INDEX, PADDING, 231} }; constexpr unsigned int contractStateChangeCount = sizeof(contractStateChangeInfos) / sizeof(contractStateChangeInfos[0]); diff --git a/src/contracts/Qusino.h b/src/contracts/Qusino.h index e95997df..ec224c7a 100644 --- a/src/contracts/Qusino.h +++ b/src/contracts/Qusino.h @@ -15,7 +15,6 @@ constexpr uint32 QUSINO_SHAREHOLDERS_DIVIDENDS_PERCENT = 20; constexpr uint32 QUSINO_QST_HOLDERS_DIVIDENDS_PERCENT = 30; constexpr uint64 QUSINO_INFINITY_PRICE = 1000000000000000000ULL; constexpr uint64 QUSINO_QSC_PRICE = 100; // 1QSC = 100Qubic -constexpr uint64 QUSINO_DEVELOPER_FEE = 333; // 33.3% constexpr uint64 QUSINO_SUPPLY_OF_QST = 1200000000ULL; // 1.2 billion constexpr uint64 QUSINO_DAILY_CLAIM_BONUS_DURATION = 24 * 60 * 60; // in number of seconds constexpr uint64 QUSINO_BONUS_CLAIM_DURATION = 60; // 60s @@ -41,6 +40,13 @@ constexpr sint32 QUSINO_ALREADY_CLAIMED_TODAY = 16; constexpr sint32 QUSINO_BONUS_CLAIM_TIME_NOT_COME = 17; constexpr sint32 QUSINO_INSUFFICIENT_BONUS_AMOUNT = 18; constexpr sint32 QUSINO_INVALID_GAME_PROPOSER = 19; +constexpr sint32 QUSINO_INVALID_INPUT = 20; +constexpr sint32 QUSINO_RNG_NOT_READY = 21; +constexpr sint32 QUSINO_RNG_REFILL_TOO_SOON = 22; +constexpr sint32 QUSINO_RNG_REFILL_FAILED = 23; +constexpr sint32 QUSINO_EXCEEDS_MAX_BET = 24; +constexpr sint32 QUSINO_PROPOSER_CANNOT_VOTE = 25; +constexpr sint32 QUSINO_DUPLICATE_GAME_URI = 26; constexpr uint8 QUSINO_ASSET_TYPE_QUBIC = 0; constexpr uint8 QUSINO_ASSET_TYPE_QSC = 1; @@ -60,6 +66,69 @@ constexpr uint32 QUSINO_LOG_ALREADY_CLAIMED_TODAY = 9; constexpr uint32 QUSINO_LOG_BONUS_CLAIM_TIME_NOT_COME = 10; constexpr uint32 QUSINO_LOG_INSUFFICIENT_BONUS_AMOUNT = 11; constexpr uint32 QUSINO_LOG_INVALID_GAME_PROPOSER = 12; +constexpr uint32 QUSINO_LOG_INVALID_INPUT = 13; +constexpr uint32 QUSINO_LOG_RNG_NOT_READY = 14; +constexpr uint32 QUSINO_LOG_RNG_REFILL_TOO_SOON = 15; +constexpr uint32 QUSINO_LOG_RNG_REFILL_FAILED = 16; +constexpr uint32 QUSINO_LOG_RNG_REFILL_SUCCESS = 17; +constexpr uint32 QUSINO_LOG_COINFLIP_RESULT = 18; +constexpr uint32 QUSINO_LOG_EXCEEDS_MAX_BET = 19; +constexpr uint32 QUSINO_LOG_PROPOSER_CANNOT_VOTE = 20; +constexpr uint32 QUSINO_LOG_DUPLICATE_GAME_URI = 21; + +// --------------------------------------------------------------------------- +// Coin Flip + shared RNG "Result Bank" +// +// Entropy is bought in bulk from RANDOM and cached in a per-game pool backed by a +// shared overflow reserve, so each coinFlip() draw is instant instead of waiting on +// a fresh BuyEntropy call. refillRandomBank() is the only procedure that talks to +// RANDOM -- permissionless but rate-limited. Pool/reserve arrays are sized for +// QUSINO_RNG_MAX_GAMES so future games (Blackjack, Baccarat, ...) can reuse this +// plumbing; only Coin Flip is wired up so far. getRandom() itself is deliberately +// not a public procedure -- only this contract's own game logic can draw from it. +// --------------------------------------------------------------------------- +constexpr uint16 QUSINO_RNG_ENTROPY_BITS = 256; // bits bought from RANDOM per refill +constexpr uint8 QUSINO_RNG_COLLATERAL_TIER = 0; // cheapest / most populated RANDOM tier +constexpr uint64 QUSINO_RNG_ENTROPY_FEE = RANDOM_BITFEE * QUSINO_RNG_ENTROPY_BITS; // paid from bonusAmount (see QUSINO_GAME_BANKROLL_CAP) + +constexpr uint32 QUSINO_RNG_MAX_GAMES = 32; // array capacity for future games (~2KB state per slot) +constexpr uint32 QUSINO_RNG_ACTIVE_GAMES = 1; // games actually bootstrapped by refillRandomBank -- + // bump as new games launch, never QUSINO_RNG_MAX_GAMES +constexpr uint32 QUSINO_RNG_POOL_SIZE = 256; // pre-drawn values held per game +constexpr uint32 QUSINO_RNG_RESERVE_SIZE = 1024; // shared overflow reserve, refilled in one shot +constexpr uint32 QUSINO_RNG_MIN_REFILL_TICK_GAP = 5; // rate-limit for the permissionless refill call + +constexpr uint8 QUSINO_GAME_ID_COINFLIP = 0; + +// Coin Flip is played with QSC or STAR only, never raw Qu. A QSC bet is redeemed +// for Qu (QUSINO_QSC_PRICE); a win credits new QSC back to the user -- they redeem +// it themselves via redemptionQSCToQubic() -- debiting bonusAmount (QUSINO's Qu +// game bankroll, funded via depositBonus, also what refillRandomBank spends on +// RANDOM fees). A loss tops bonusAmount back up. STAR bets never touch Qu or +// bonusAmount: STAR isn't redeemable for Qubic, so a win mints STAR and a loss +// burns it, like a vote fee. +constexpr uint64 QUSINO_COINFLIP_MIN_BET = 3ULL; // min bet, in QSC or STAR units +// Max bet, in QSC or STAR units, regardless of asset, balance, or the +// bonusAmount pool's own affordability cap (QUSINO_INSUFFICIENT_BONUS_AMOUNT +// below is a separate, additional restriction on QSC specifically -- this +// ceiling applies on top of it, and to STAR too, where that other check +// doesn't apply at all). A flat business/UX limit, not something the +// protocol's own accounting requires -- unlike MIN_BET (avoids degenerate +// dust bets) or the bonus-pool gate (avoids underflowing bonusAmount), nothing +// here would go wrong arithmetically without this cap. It exists only so a +// single bet can never be enormous purely by virtue of a large balance or a +// large pool. Previously enforced client-side only (qusino-frontend's +// FIXED_MAX_BET_UNITS) -- moved on-chain after a report that a client +// bypassing/not using that frontend could place an arbitrarily large bet. +constexpr uint64 QUSINO_COINFLIP_MAX_BET = 100000ULL; +constexpr uint64 QUSINO_COINFLIP_PAYOUT_PERCENT = 196ULL; // 1.96x on win == ~2% house edge, placeholder + +// bonusAmount is shared by the daily-claim-bonus feature and Coin Flip's Qu +// bankroll, pinned at QUSINO_GAME_BANKROLL_CAP -- anything that would push it past +// the cap (an oversized depositBonus, or a Coin Flip loss) goes to epochRevenue +// instead (see addWithCap()). +constexpr uint64 QUSINO_GAME_BANKROLL_CAP = 2400000000ULL; // 2.4B Qu + struct QUSINOLogger { uint32 _contractIndex; @@ -137,6 +206,40 @@ struct QUSINO : public ContractBase sint32 returnCode; }; + struct refillRandomBank_input + { + }; + struct refillRandomBank_output + { + sint32 returnCode; + uint32 valuesAdded; + }; + + struct coinFlip_input + { + uint8 guess; // 0 = heads, 1 = tails + uint8 assetType; // QUSINO_ASSET_TYPE_QSC or QUSINO_ASSET_TYPE_STAR -- no other type is valid + uint64 amount; // bet size, in units of assetType; no invocationReward is taken + }; + struct coinFlip_output + { + sint32 returnCode; + uint8 result; // 0 = heads, 1 = tails + bit won; + uint64 payout; // QSC bets: QSC credited (redeem via redemptionQSCToQubic). + // STAR bets: STAR minted. 0 on a loss. + }; + + struct getRandomBankStatus_input + { + }; + struct getRandomBankStatus_output + { + bit poolInitialized; + uint32 reserveFilled; + uint32 lastRefillTick; + }; + struct getUserAssetVolume_input { id user; @@ -147,6 +250,16 @@ struct QUSINO : public ContractBase uint64 QSCAmount; }; + struct getDailyClaimStatus_input + { + id user; + }; + struct getDailyClaimStatus_output + { + bit canClaimNow; + uint32 secondsUntilNextClaim; // 0 when canClaimNow is true + }; + struct GameInfo { Array URI; @@ -188,6 +301,21 @@ struct QUSINO : public ContractBase Array gameIndexes; }; + // Passed proposals, waiting out QUSINO_REVOTE_DURATION before automatically + // returning to gameList for reconfirmation -- see approvedGameList's state doc + // comment and END_EPOCH. Each GameInfo's proposedEpoch is whichever epoch it was + // (re)confirmed live in, so a frontend can compute both "epochs since approval" + // and "epochs until it comes back up for revote" from proposedEpoch alone. + struct getApprovedGameList_input + { + uint32 offset; + }; + struct getApprovedGameList_output + { + Array games; + Array gameIndexes; + }; + struct TransferShareManagementRights_input { Asset asset; @@ -198,30 +326,11 @@ struct QUSINO : public ContractBase { sint64 transferredNumberOfShares; }; - struct getProposerEarnedQSCInfo_input - { - id proposer; - uint32 epoch; - }; - struct getProposerEarnedQSCInfo_output - { - uint64 earnedQSC; - }; - struct STARAndQSC { uint64 volumeOfSTAR; uint64 volumeOfQSC; }; - struct EarnedQSCInfo - { - id proposer; - uint32 epoch; - bool operator==(const EarnedQSCInfo& other) const - { - return proposer == other.proposer && epoch == other.epoch; - } - }; struct VoteInfo { id voter; @@ -239,9 +348,14 @@ struct QUSINO : public ContractBase HashMap userAssetVolume; HashMap gameList; HashMap failedGameList; + // Passed proposals, archived here instead of just vanishing after payout. + // Unlike failedGameList, this is NOT reset every epoch in END_EPOCH -- entries + // sit here across many epochs until QUSINO_REVOTE_DURATION elapses, at which + // point END_EPOCH moves them back into gameList (votes reset to 0/0) for a + // fresh reconfirmation vote. See END_EPOCH for the full lifecycle. + HashMap approvedGameList; HashMap voteList; HashMap userDailyClaimedBonus; - HashMap userEarnedQSCInfo; id LPDividendsAddress; id CCFDividendsAddress; id treasuryAddress; @@ -255,6 +369,17 @@ struct QUSINO : public ContractBase uint64 bonusAmount; sint64 transferRightsFee; uint32 lastClaimedTime; + + // RNG "Result Bank" (see comment above QUSINO_RNG_ENTROPY_BITS) + Array rngPools; // flattened [gameId * QUSINO_RNG_POOL_SIZE + slot] + Array rngPoolNonce; // per-game nonce, folded into index-selection entropy each draw + Array rngPoolInitialized; // 1 once a game's pool has been seeded, else 0 + Array rngReserve; + uint32 rngReserveHead; // next reserve slot to hand out (circular) + uint32 rngReserveFilled; // number of valid, unconsumed entries left in the reserve + uint32 rngLastRefillTick; // for rate-limiting refillRandomBank() + bit rngBankEverFilled; // set once the first refill succeeds; lets the tick-gap + // check skip the very first refill }; protected: /**************************************/ @@ -272,6 +397,29 @@ struct QUSINO : public ContractBase { return (a < b) ? a : b; } + // Adds toAdd to current, clamped at cap; whatever doesn't fit is reported via + // overflow instead of wrapping. Used to keep bonusAmount pinned at + // QUSINO_GAME_BANKROLL_CAP. + inline static void addWithCap(uint64 current, uint64 toAdd, uint64 cap, uint64& newValue, uint64& overflow) + { + // No stack locals allowed in contract code (see doc/contracts.md) -- every + // intermediate value below is recomputed inline rather than named. + if (current >= cap) + { + newValue = cap; + overflow = toAdd; + } + else if (toAdd <= (cap - current)) + { + newValue = current + toAdd; + overflow = 0; + } + else + { + newValue = cap; + overflow = toAdd - (cap - current); + } + } /** * Compare 2 date in uint32 format @@ -494,13 +642,16 @@ struct QUSINO : public ContractBase struct submitGame_locals { GameInfo newGame; + GameInfo existingGame; + sint64 idx; + uint32 i; QUSINOLogger log; }; PUBLIC_PROCEDURE_WITH_LOCALS(submitGame) { - if (qpi.invocationReward() < QUSINO_GAME_SUBMIT_FEE) + if (qpi.invocationReward() < QUSINO_GAME_SUBMIT_FEE) { - if (qpi.invocationReward() > 0) + if (qpi.invocationReward() > 0) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); } @@ -509,7 +660,65 @@ struct QUSINO : public ContractBase LOG_INFO(locals.log); return ; } - if (qpi.invocationReward() > QUSINO_GAME_SUBMIT_FEE) + // Reject a URI that's already live as a pending proposal (gameList) or + // already sitting in approvedGameList (including one waiting out its + // QUSINO_REVOTE_DURATION cooldown before resurfacing for + // reconfirmation) -- previously nothing stopped the exact same URI + // from being submitted as an unlimited number of separate, + // independently-votable proposals. A URI that only ever failed is + // deliberately NOT checked here: failedGameList is cleared every + // epoch specifically so a failed proposal can be tried again (see its + // own declaration comment), and blocking resubmission there would + // defeat that. + locals.idx = state.get().gameList.nextElementIndex(NULL_INDEX); + while (locals.idx != NULL_INDEX) + { + locals.existingGame = state.get().gameList.value(locals.idx); + for (locals.i = 0; locals.i < 64; locals.i++) + { + if (locals.existingGame.URI.get(locals.i) != input.URI.get(locals.i)) + { + break; + } + } + if (locals.i == 64) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + output.returnCode = QUSINO_DUPLICATE_GAME_URI; + locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_DUPLICATE_GAME_URI, 0 }; + LOG_INFO(locals.log); + return ; + } + locals.idx = state.get().gameList.nextElementIndex(locals.idx); + } + locals.idx = state.get().approvedGameList.nextElementIndex(NULL_INDEX); + while (locals.idx != NULL_INDEX) + { + locals.existingGame = state.get().approvedGameList.value(locals.idx); + for (locals.i = 0; locals.i < 64; locals.i++) + { + if (locals.existingGame.URI.get(locals.i) != input.URI.get(locals.i)) + { + break; + } + } + if (locals.i == 64) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + output.returnCode = QUSINO_DUPLICATE_GAME_URI; + locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_DUPLICATE_GAME_URI, 0 }; + LOG_INFO(locals.log); + return ; + } + locals.idx = state.get().approvedGameList.nextElementIndex(locals.idx); + } + if (qpi.invocationReward() > QUSINO_GAME_SUBMIT_FEE) { qpi.transfer(qpi.invocator(), qpi.invocationReward() - QUSINO_GAME_SUBMIT_FEE); } @@ -538,10 +747,28 @@ struct QUSINO : public ContractBase }; PUBLIC_PROCEDURE_WITH_LOCALS(voteInGameProposal) { - if (qpi.invocationReward() > 0) + if (qpi.invocationReward() > 0) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); } + // Must be exactly 1 (yes) or 2 (no) -- anything else used to slip through + // silently: a first vote with a bad value incremented neither yesVotes nor + // noVotes (but still burned the fee and recorded the caller as "voted", so a + // later real vote read it as a truthy prior status), and the vote-switching + // branch below unconditionally pairs an increment on one counter with a + // decrement on the other -- assuming that prior status really was a 1 or 2. + // A bad-then-real vote pair decremented a counter that was never incremented, + // underflowing yesVotes/noVotes (both uint32) to ~4.29 billion and permanently + // forcing that proposal to read as approved (or rejected) regardless of any + // real votes. Rejecting bad values up front, before any state is touched, + // closes this off entirely. + if (input.yesNo != 1 && input.yesNo != 2) + { + output.returnCode = QUSINO_INVALID_INPUT; + locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_INVALID_INPUT, 0 }; + LOG_INFO(locals.log); + return ; + } state.get().userAssetVolume.get(qpi.invocator(), locals.userVolume); if (locals.userVolume.volumeOfSTAR < QUSINO_VOTE_FEE) { @@ -551,14 +778,39 @@ struct QUSINO : public ContractBase return ; } state.get().gameList.get(input.gameIndex, locals.game); - if (locals.game.proposedEpoch != qpi.epoch() && locals.game.proposedEpoch + QUSINO_REVOTE_DURATION != qpi.epoch()) + // Every entry in gameList -- whether freshly submitted (submitGame sets + // proposedEpoch = qpi.epoch()) or just resurrected out of approvedGameList for + // reconfirmation (END_EPOCH re-anchors proposedEpoch to the epoch it re-enters + // gameList in, same rule) -- is guaranteed to have proposedEpoch == qpi.epoch() + // for as long as it's live: END_EPOCH fully drains gameList every single epoch + // (see its comment), so nothing can still be sitting here from an earlier one. + // A prior version of this check also accepted `proposedEpoch + REVOTE_DURATION + // == qpi.epoch()`, on the theory that a proposal could sit untouched in + // gameList until its revote epoch arrived N epochs later -- but nothing ever + // actually kept it there that long (see the QUSINO_REVOTE_DURATION comment on + // approvedGameList), so that branch could never fire and just masked the real + // bug: approved/failed proposals had no revote mechanism at all. + if (locals.game.proposedEpoch != qpi.epoch()) { output.returnCode = QUSINO_NOT_VOTE_TIME; locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_NOT_VOTE_TIME, 0 }; LOG_INFO(locals.log); return ; } - for (locals.i = 0; locals.i < 64; locals.i++) + // A proposal's own proposer voting on it was previously unguarded -- + // they could cast a "yes" for their own game like any other voter, + // padding yesVotes in their own favor. Every other actor with a stake + // in a proposal's outcome (the submit fee, the vote fee) is still free + // to participate normally; only the proposer themselves is excluded + // from voting on their own submission. + if (locals.game.proposer == qpi.invocator()) + { + output.returnCode = QUSINO_PROPOSER_CANNOT_VOTE; + locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_PROPOSER_CANNOT_VOTE, 0 }; + LOG_INFO(locals.log); + return ; + } + for (locals.i = 0; locals.i < 64; locals.i++) { if (locals.game.URI.get(locals.i) != input.URI.get(locals.i)) { @@ -618,12 +870,20 @@ struct QUSINO : public ContractBase struct depositBonus_locals { QUSINOLogger log; + uint64 newBonus; + uint64 overflow; }; PUBLIC_PROCEDURE_WITH_LOCALS(depositBonus) { if (qpi.invocationReward() > 0) { - state.mut().bonusAmount = sadd(state.get().bonusAmount, (uint64)qpi.invocationReward()); + // bonusAmount is capped at QUSINO_GAME_BANKROLL_CAP; excess goes to epochRevenue. + addWithCap(state.get().bonusAmount, (uint64)qpi.invocationReward(), QUSINO_GAME_BANKROLL_CAP, locals.newBonus, locals.overflow); + state.mut().bonusAmount = locals.newBonus; + if (locals.overflow > 0) + { + state.mut().epochRevenue = sadd(state.get().epochRevenue, locals.overflow); + } } output.returnCode = QUSINO_SUCCESS; locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_SUCCESS, 0 }; @@ -735,6 +995,369 @@ struct QUSINO : public ContractBase locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_SUCCESS, 0 }; LOG_INFO(locals.log); } + // refillRandomBank + // --------------------------------------------------------------------------- + // Permissionless call that tops up the RNG reserve by buying entropy from + // RANDOM, paid from bonusAmount (refuses if it can't cover the fee). Refuses + // while the reserve still has unspent values (would waste the fee); once + // drained, rate-limited to one refill per QUSINO_RNG_MIN_REFILL_TICK_GAP ticks + // (first-ever refill exempt). Also bootstraps any active game's pool that + // hasn't been seeded yet. + // Return codes: QUSINO_SUCCESS, QUSINO_RNG_REFILL_TOO_SOON, + // QUSINO_INSUFFICIENT_BONUS_AMOUNT, QUSINO_RNG_REFILL_FAILED. + // --------------------------------------------------------------------------- + struct refillRandomBank_locals + { + RANDOM::BuyEntropy_input buyEntropyInput; + RANDOM::BuyEntropy_output buyEntropyOutput; + m256i baseSeed; + m256i expanded; + uint64 seedIdx; + uint32 g; + uint32 slot; + QUSINOLogger log; + }; + PUBLIC_PROCEDURE_WITH_LOCALS(refillRandomBank) + { + // Takes no payment from the caller -- QUSINO funds the RANDOM purchase itself. + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + // Don't buy while the reserve still has unspent values (would waste the fee). + // Once empty, rate-limit successful refills too, except the very first ever. + if (state.get().rngReserveFilled > 0 + || (state.get().rngBankEverFilled && qpi.tick() < state.get().rngLastRefillTick + QUSINO_RNG_MIN_REFILL_TICK_GAP)) + { + output.returnCode = QUSINO_RNG_REFILL_TOO_SOON; + output.valuesAdded = 0; + locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_RNG_REFILL_TOO_SOON, 0 }; + LOG_INFO(locals.log); + return; + } + + // Don't even attempt a purchase the game bankroll can't afford. + if (state.get().bonusAmount < QUSINO_RNG_ENTROPY_FEE) + { + output.returnCode = QUSINO_INSUFFICIENT_BONUS_AMOUNT; + output.valuesAdded = 0; + locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_INSUFFICIENT_BONUS_AMOUNT, 0 }; + LOG_INFO(locals.log); + return; + } + + locals.buyEntropyInput.collateralTier = QUSINO_RNG_COLLATERAL_TIER; + locals.buyEntropyInput.numberOfBits = QUSINO_RNG_ENTROPY_BITS; + locals.buyEntropyInput.trustee = id::zero(); + INVOKE_OTHER_CONTRACT_PROCEDURE(RANDOM, BuyEntropy, locals.buyEntropyInput, locals.buyEntropyOutput, QUSINO_RNG_ENTROPY_FEE); + + if (interContractCallError != NoCallError || locals.buyEntropyOutput.entropy == BIT4096_ZERO) + { + // No entropy available this round -- try again on a later tick. + output.returnCode = QUSINO_RNG_REFILL_FAILED; + output.valuesAdded = 0; + locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_RNG_REFILL_FAILED, 0 }; + LOG_INFO(locals.log); + return; + } + + // Collapse entropy into a seed, then derive many values from it by re-hashing + // with an incrementing counter (same technique QRaffle uses). + locals.baseSeed = qpi.K12(locals.buyEntropyOutput.entropy); + for (locals.seedIdx = 0; locals.seedIdx < QUSINO_RNG_RESERVE_SIZE; locals.seedIdx++) + { + locals.expanded = qpi.K12(m256i(locals.baseSeed.u64._0, locals.baseSeed.u64._1, locals.baseSeed.u64._2, locals.baseSeed.u64._3 ^ (locals.seedIdx + 1ULL))); + state.mut().rngReserve.set(locals.seedIdx, locals.expanded.u64._0); + } + state.mut().rngReserveHead = 0; + state.mut().rngReserveFilled = QUSINO_RNG_RESERVE_SIZE; + state.mut().rngLastRefillTick = qpi.tick(); + state.mut().rngBankEverFilled = 1; + // Debit the fee actually spent (not done on the failure path -- RANDOM refunds + // QUSINO in full when it has no entropy to sell). + state.mut().bonusAmount -= QUSINO_RNG_ENTROPY_FEE; + + // Bootstrap any active game's pool that hasn't been seeded yet, straight out of + // the reserve just filled. Bounded by ACTIVE_GAMES, not MAX_GAMES, so we don't + // burn the reserve priming pools nothing uses yet. + for (locals.g = 0; locals.g < QUSINO_RNG_ACTIVE_GAMES; locals.g++) + { + if (state.get().rngPoolInitialized.get(locals.g) == 0 && state.get().rngReserveFilled >= QUSINO_RNG_POOL_SIZE) + { + for (locals.slot = 0; locals.slot < QUSINO_RNG_POOL_SIZE; locals.slot++) + { + state.mut().rngPools.set((uint64)locals.g * QUSINO_RNG_POOL_SIZE + locals.slot, state.get().rngReserve.get(state.get().rngReserveHead)); + state.mut().rngReserveHead = mod(state.get().rngReserveHead + 1, QUSINO_RNG_RESERVE_SIZE); + state.mut().rngReserveFilled--; + } + state.mut().rngPoolInitialized.set(locals.g, 1); + } + } + + output.returnCode = QUSINO_SUCCESS; + output.valuesAdded = QUSINO_RNG_RESERVE_SIZE; + locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_RNG_REFILL_SUCCESS, 0 }; + LOG_INFO(locals.log); + } + + // coinFlip + // --------------------------------------------------------------------------- + // Bets QSC or STAR (input.assetType) on heads/tails (input.guess); never raw + // Qu, no invocationReward taken. The wager always leaves the caller's balance + // up front. QSC: redeemed for Qu (QUSINO_QSC_PRICE); a win credits new QSC + // back to the caller -- redeem via redemptionQSCToQubic() -- debiting + // bonusAmount by that QSC's Qu backing; a loss tops bonusAmount back up + // (capped, overflow to epochRevenue). Rejected up front unless bonusAmount can + // cover the win. STAR: never touches Qu/bonusAmount -- a win mints STAR, a + // loss burns it (like a vote fee). Outcome is drawn instantly from the Coin + // Flip RNG pool (see Result Bank comment above), then the slot is topped up. + // Return codes: QUSINO_SUCCESS, QUSINO_INVALID_INPUT, QUSINO_WRONG_ASSET_TYPE, + // QUSINO_INSUFFICIENT_FUNDS, QUSINO_EXCEEDS_MAX_BET, QUSINO_RNG_NOT_READY, + // QUSINO_INSUFFICIENT_QSC / QUSINO_INSUFFICIENT_STAR, + // QUSINO_INSUFFICIENT_BONUS_AMOUNT. + // --------------------------------------------------------------------------- + struct CoinFlipSelectContext + { + m256i prevDigest; + id invocator; + uint32 tick; + uint32 nonce; + }; + struct CoinFlipOutcomeContext + { + uint64 poolValue; + m256i selectHash; + }; + struct coinFlip_locals + { + CoinFlipSelectContext selectCtx; + m256i selectHash; + CoinFlipOutcomeContext outcomeCtx; + m256i outcomeHash; + STARAndQSC userVolume; + uint64 index; + uint64 poolValue; + uint64 qscRedemptionValueQu; + uint64 winAmount; + uint64 qscPayout; + uint64 qscNetDebitQu; + uint64 newBonus; + uint64 overflow; + uint8 outcome; + QUSINOLogger log; + }; + PUBLIC_PROCEDURE_WITH_LOCALS(coinFlip) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (input.guess > 1) + { + output.returnCode = QUSINO_INVALID_INPUT; + output.result = 0; + output.won = 0; + output.payout = 0; + locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_INVALID_INPUT, 0 }; + LOG_INFO(locals.log); + return; + } + + if (input.assetType != QUSINO_ASSET_TYPE_QSC && input.assetType != QUSINO_ASSET_TYPE_STAR) + { + output.returnCode = QUSINO_WRONG_ASSET_TYPE; + output.result = 0; + output.won = 0; + output.payout = 0; + locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_WRONG_ASSET_TYPE, 0 }; + LOG_INFO(locals.log); + return; + } + + if (input.amount < QUSINO_COINFLIP_MIN_BET) + { + output.returnCode = QUSINO_INSUFFICIENT_FUNDS; + output.result = 0; + output.won = 0; + output.payout = 0; + locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_INSUFFICIENT_FUNDS, 0 }; + LOG_INFO(locals.log); + return; + } + + if (input.amount > QUSINO_COINFLIP_MAX_BET) + { + output.returnCode = QUSINO_EXCEEDS_MAX_BET; + output.result = 0; + output.won = 0; + output.payout = 0; + locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_EXCEEDS_MAX_BET, 0 }; + LOG_INFO(locals.log); + return; + } + + if (state.get().rngPoolInitialized.get(QUSINO_GAME_ID_COINFLIP) == 0) + { + // Bank not primed yet -- caller should trigger refillRandomBank() and retry. + output.returnCode = QUSINO_RNG_NOT_READY; + output.result = 0; + output.won = 0; + output.payout = 0; + locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_RNG_NOT_READY, 0 }; + LOG_INFO(locals.log); + return; + } + + state.get().userAssetVolume.get(qpi.invocator(), locals.userVolume); + + if (input.assetType == QUSINO_ASSET_TYPE_QSC) + { + if (locals.userVolume.volumeOfQSC < input.amount) + { + output.returnCode = QUSINO_INSUFFICIENT_QSC; + output.result = 0; + output.won = 0; + output.payout = 0; + locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_INSUFFICIENT_QSC, 0 }; + LOG_INFO(locals.log); + return; + } + + // Gate on the NET liability a win would add (payout backing minus the stake's + // own backing, freed by the unconditional burn below) -- not the gross payout, + // which would reject bets the pool can actually afford. Precomputed here since + // it only depends on `amount`, not the RNG outcome. + // + // Settle with one direct subtraction (see win branch below), no addWithCap: an + // add-then-subtract settlement could underflow if the credit-back gets capped + // at QUSINO_GAME_BANKROLL_CAP before the gross payout is debited, even though + // this net gate passed. A single subtraction can't underflow (gate guarantees + // bonusAmount >= qscNetDebitQu) and can't overflow the cap (it's a decrease). + locals.qscRedemptionValueQu = smul(input.amount, QUSINO_QSC_PRICE); + locals.winAmount = div(smul(locals.qscRedemptionValueQu, QUSINO_COINFLIP_PAYOUT_PERCENT), 100ULL); + locals.qscPayout = div(locals.winAmount, QUSINO_QSC_PRICE); + locals.qscNetDebitQu = smul(locals.qscPayout, QUSINO_QSC_PRICE) - locals.qscRedemptionValueQu; + if (state.get().bonusAmount < locals.qscNetDebitQu) + { + output.returnCode = QUSINO_INSUFFICIENT_BONUS_AMOUNT; + output.result = 0; + output.won = 0; + output.payout = 0; + locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_INSUFFICIENT_BONUS_AMOUNT, 0 }; + LOG_INFO(locals.log); + return; + } + } + else // QUSINO_ASSET_TYPE_STAR + { + if (locals.userVolume.volumeOfSTAR < input.amount) + { + output.returnCode = QUSINO_INSUFFICIENT_STAR; + output.result = 0; + output.won = 0; + output.payout = 0; + locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_INSUFFICIENT_STAR, 0 }; + LOG_INFO(locals.log); + return; + } + locals.winAmount = div(smul(input.amount, QUSINO_COINFLIP_PAYOUT_PERCENT), 100ULL); + } + + // Pick a pool slot from context unique to this call, so it can't be predicted. + locals.selectCtx.prevDigest = qpi.getPrevSpectrumDigest(); + locals.selectCtx.invocator = qpi.invocator(); + locals.selectCtx.tick = qpi.tick(); + locals.selectCtx.nonce = state.get().rngPoolNonce.get(QUSINO_GAME_ID_COINFLIP); + locals.selectHash = qpi.K12(locals.selectCtx); + locals.index = mod(locals.selectHash.u64._0, (uint64)QUSINO_RNG_POOL_SIZE); + + locals.poolValue = state.get().rngPools.get((uint64)QUSINO_GAME_ID_COINFLIP * QUSINO_RNG_POOL_SIZE + locals.index); + + // Consume the slot and refill it from the reserve so it's never handed out twice. + if (state.get().rngReserveFilled > 0) + { + state.mut().rngPools.set((uint64)QUSINO_GAME_ID_COINFLIP * QUSINO_RNG_POOL_SIZE + locals.index, state.get().rngReserve.get(state.get().rngReserveHead)); + state.mut().rngReserveHead = mod(state.get().rngReserveHead + 1, QUSINO_RNG_RESERVE_SIZE); + state.mut().rngReserveFilled--; + } + state.mut().rngPoolNonce.set(QUSINO_GAME_ID_COINFLIP, state.get().rngPoolNonce.get(QUSINO_GAME_ID_COINFLIP) + 1); + + // Re-hash the drawn value with the selection hash so the outcome stays unpredictable. + locals.outcomeCtx.poolValue = locals.poolValue; + locals.outcomeCtx.selectHash = locals.selectHash; + locals.outcomeHash = qpi.K12(locals.outcomeCtx); + locals.outcome = (uint8)(locals.outcomeHash.u64._0 & 1); + + output.result = locals.outcome; + output.won = (locals.outcome == input.guess) ? 1 : 0; + + if (input.assetType == QUSINO_ASSET_TYPE_QSC) + { + // The wager always leaves QSC circulation up front, redeemed either way. + locals.userVolume.volumeOfQSC -= input.amount; + state.mut().QSCCirclatingSupply -= input.amount; + + if (output.won) + { + // Credit QSC instead of sending Qu -- caller redeems it themselves later. + // (locals.qscPayout was already computed above, before the draw, to gate on + // locals.qscNetDebitQu.) + output.payout = locals.qscPayout; + locals.userVolume.volumeOfQSC = sadd(locals.userVolume.volumeOfQSC, locals.qscPayout); + state.mut().QSCCirclatingSupply = sadd(state.get().QSCCirclatingSupply, locals.qscPayout); + + // Net of the stake's backing (already freed by the burn above); gated + // above, so this direct subtraction is safe (see gate comment). + state.mut().bonusAmount -= locals.qscNetDebitQu; + } + else + { + // The wager's Qu value tops up the bankroll; overflow goes to epochRevenue. + output.payout = 0; + addWithCap(state.get().bonusAmount, locals.qscRedemptionValueQu, QUSINO_GAME_BANKROLL_CAP, locals.newBonus, locals.overflow); + state.mut().bonusAmount = locals.newBonus; + if (locals.overflow > 0) + { + state.mut().epochRevenue = sadd(state.get().epochRevenue, locals.overflow); + } + } + state.mut().userAssetVolume.set(qpi.invocator(), locals.userVolume); + } + else // QUSINO_ASSET_TYPE_STAR + { + // The wager always leaves the caller's STAR balance up front; a win mints + // the payout back on top, a loss is recorded as burnt (like a vote fee). + locals.userVolume.volumeOfSTAR -= input.amount; + state.mut().STARCirclatingSupply -= input.amount; + + if (output.won) + { + output.payout = locals.winAmount; + locals.userVolume.volumeOfSTAR = sadd(locals.userVolume.volumeOfSTAR, locals.winAmount); + state.mut().STARCirclatingSupply = sadd(state.get().STARCirclatingSupply, locals.winAmount); + } + else + { + output.payout = 0; + state.mut().burntSTAR = sadd(state.get().burntSTAR, input.amount); + } + state.mut().userAssetVolume.set(qpi.invocator(), locals.userVolume); + } + + output.returnCode = QUSINO_SUCCESS; + locals.log = QUSINOLogger{ CONTRACT_INDEX, QUSINO_LOG_COINFLIP_RESULT, 0 }; + LOG_INFO(locals.log); + } + + PUBLIC_FUNCTION(getRandomBankStatus) + { + output.poolInitialized = (state.get().rngPoolInitialized.get(QUSINO_GAME_ID_COINFLIP) != 0); + output.reserveFilled = state.get().rngReserveFilled; + output.lastRefillTick = state.get().rngLastRefillTick; + } + struct getUserAssetVolume_locals { STARAndQSC userAsset; @@ -746,6 +1369,39 @@ struct QUSINO : public ContractBase output.STARAmount = locals.userAsset.volumeOfSTAR; } + // Lets a client show "you can claim again in Xh Ym" instead of the user + // having to guess by clicking dailyClaimBonus and reading a rejection. + // Mirrors dailyClaimBonus's own per-user gate exactly (see its comment for + // the date-packing/diff scheme) but is read-only and doesn't touch state. + // Deliberately does NOT factor in the separate global 60-second + // cross-player throttle (state.lastClaimedTime/QUSINO_BONUS_CLAIM_DURATION) + // -- that's a momentary, constantly-resetting window measured in seconds, + // not something worth surfacing as a countdown; the per-user daily gate + // (hours) is the number a "next claim available" UI actually needs. + struct getDailyClaimStatus_locals + { + uint32 lastClaimedTime; + uint32 curDate; + sint32 i; + uint64 diffTime, dayA, dayB; + }; + PUBLIC_FUNCTION_WITH_LOCALS(getDailyClaimStatus) + { + packQusinoDate(qpi.year(), qpi.month(), qpi.day(), qpi.hour(), qpi.minute(), qpi.second(), locals.curDate); + state.get().userDailyClaimedBonus.get(input.user, locals.lastClaimedTime); + diffQusinoDateInSecond(locals.lastClaimedTime, locals.curDate, locals.i, locals.dayA, locals.dayB, locals.diffTime); + if (!locals.lastClaimedTime || locals.diffTime >= QUSINO_DAILY_CLAIM_BONUS_DURATION) + { + output.canClaimNow = true; + output.secondsUntilNextClaim = 0; + } + else + { + output.canClaimNow = false; + output.secondsUntilNextClaim = (uint32)(QUSINO_DAILY_CLAIM_BONUS_DURATION - locals.diffTime); + } + } + struct getFailedGameList_locals { GameInfo game; @@ -817,6 +1473,37 @@ struct QUSINO : public ContractBase } } + struct getApprovedGameList_locals + { + GameInfo game; + sint64 idx; + sint32 cur; + }; + PUBLIC_FUNCTION_WITH_LOCALS(getApprovedGameList) + { + if (input.offset > 1024u - 33u) + { + return ; + } + locals.cur = 0; + locals.idx = state.get().approvedGameList.nextElementIndex(NULL_INDEX); + while (locals.idx != NULL_INDEX) + { + if (locals.cur >= (sint32)input.offset) + { + if (locals.cur >= (sint32)(input.offset + 32)) + { + return ; + } + locals.game = state.get().approvedGameList.value(locals.idx); + output.games.set(locals.cur - input.offset, locals.game); + output.gameIndexes.set(locals.cur - input.offset, state.get().approvedGameList.key(locals.idx)); + } + locals.cur++; + locals.idx = state.get().approvedGameList.nextElementIndex(locals.idx); + } + } + PUBLIC_PROCEDURE(TransferShareManagementRights) { if (qpi.invocationReward() < state.get().transferRightsFee) @@ -857,25 +1544,15 @@ struct QUSINO : public ContractBase } } - struct getProposerEarnedQSCInfo_locals - { - EarnedQSCInfo earnedQSCInfo; - }; - - PUBLIC_FUNCTION_WITH_LOCALS(getProposerEarnedQSCInfo) - { - locals.earnedQSCInfo.proposer = input.proposer; - locals.earnedQSCInfo.epoch = input.epoch; - state.get().userEarnedQSCInfo.get(locals.earnedQSCInfo, output.earnedQSC); - } - REGISTER_USER_FUNCTIONS_AND_PROCEDURES() { REGISTER_USER_FUNCTION(getUserAssetVolume, 1); REGISTER_USER_FUNCTION(getFailedGameList, 2); REGISTER_USER_FUNCTION(getSCInfo, 3); REGISTER_USER_FUNCTION(getActiveGameList, 4); - REGISTER_USER_FUNCTION(getProposerEarnedQSCInfo, 5); + REGISTER_USER_FUNCTION(getDailyClaimStatus, 5); + REGISTER_USER_FUNCTION(getRandomBankStatus, 6); + REGISTER_USER_FUNCTION(getApprovedGameList, 7); REGISTER_USER_PROCEDURE(earnSTAR, 1); REGISTER_USER_PROCEDURE(transferSTAROrQSC, 2); @@ -885,6 +1562,8 @@ struct QUSINO : public ContractBase REGISTER_USER_PROCEDURE(depositBonus, 6); REGISTER_USER_PROCEDURE(dailyClaimBonus, 7); REGISTER_USER_PROCEDURE(redemptionQSCToQubic, 8); + REGISTER_USER_PROCEDURE(refillRandomBank, 9); + REGISTER_USER_PROCEDURE(coinFlip, 10); } INITIALIZE() @@ -906,29 +1585,30 @@ struct QUSINO : public ContractBase sint64 idx; AssetPossessionIterator iter; Asset QSTAsset; - EarnedQSCInfo earnedQSCInfo; uint64 epochSnapshot; - uint64 grossQubicFromQsc; - uint64 qscToEpochRevenue; uint64 lpShare; uint64 ccfShare; uint64 treasuryShare; uint64 shareholders676Part; - uint64 qstPerShareRate; sint64 possessionCount; uint64 qstPayout; - uint64 proposerQubic; }; END_EPOCH_WITH_LOCALS() { + // gameList is fully drained every single epoch -- every proposal gets exactly + // one epoch of live voting before being resolved here, whether it's a brand + // new submission or a reconfirmation resurrected from approvedGameList below. + // That guarantees proposedEpoch == qpi.epoch() for every entry we see (see the + // comment on voteInGameProposal's window check), so the simple equality check + // below is enough -- no "or it's been sitting here N epochs" branch needed. state.mut().failedGameList.reset(); locals.idx = state.get().gameList.nextElementIndex(NULL_INDEX); while (locals.idx != NULL_INDEX) { locals.game = state.get().gameList.value(locals.idx); - if (locals.game.noVotes >= locals.game.yesVotes) + if (locals.game.noVotes >= locals.game.yesVotes) { - if (locals.game.proposedEpoch == qpi.epoch() || locals.game.proposedEpoch + QUSINO_REVOTE_DURATION == qpi.epoch()) + if (locals.game.proposedEpoch == qpi.epoch()) { state.mut().failedGameList.set(state.get().gameList.key(locals.idx), locals.game); state.mut().gameList.removeByIndex(locals.idx); @@ -936,30 +1616,23 @@ struct QUSINO : public ContractBase continue; } } - // distribute QSC to the proposer - state.get().userAssetVolume.get(locals.game.proposer, locals.userVolume); - locals.grossQubicFromQsc = smul(locals.userVolume.volumeOfQSC, QUSINO_QSC_PRICE); - locals.qscToEpochRevenue = div(smul(locals.grossQubicFromQsc, (uint64)(1000 - QUSINO_DEVELOPER_FEE)), 1000ULL); - state.mut().epochRevenue = sadd(state.get().epochRevenue, locals.qscToEpochRevenue); - locals.proposerQubic = 0; - if (locals.grossQubicFromQsc >= locals.qscToEpochRevenue) - { - locals.proposerQubic = locals.grossQubicFromQsc - locals.qscToEpochRevenue; - } - if (locals.proposerQubic <= (uint64)INT64_MAX) - { - qpi.transfer(locals.game.proposer, (sint64)locals.proposerQubic); - } - state.mut().QSCCirclatingSupply -= locals.userVolume.volumeOfQSC; - - // add earned QSC to userEarnedQSCInfo - locals.earnedQSCInfo.proposer = locals.game.proposer; - locals.earnedQSCInfo.epoch = qpi.epoch(); - state.mut().userEarnedQSCInfo.set(locals.earnedQSCInfo, locals.userVolume.volumeOfQSC); - - // set userVolume to 0 - locals.userVolume.volumeOfQSC = 0; - state.mut().userAssetVolume.set(locals.game.proposer, locals.userVolume); + // Passed (first time, or reconfirmed on a revote) -- archive into + // approvedGameList instead of letting it just vanish. Kept under its + // existing key/gameIndex so voters and proposers still line up. See + // approvedGameList's declaration and the resurrection pass at the bottom + // of this procedure for how it eventually comes back for reconfirmation. + // + // No QSC-to-Qu conversion happens here for the proposer. An earlier + // version of this contract forcibly redeemed the proposer's entire QSC + // balance at this point (split by a "developer fee"), on the theory that + // being a proposer entitled them to an automatic payout. That was + // redundant and worse for the proposer than doing nothing: any user -- + // including a proposer, once their proposal is off gameList -- can + // already redeem QSC for Qu themselves via redemptionQSCToQubic(), at a + // straight 1:1 rate with no fee taken. Forcibly converting on their + // behalf only added an involuntary cut they wouldn't otherwise pay, so + // it's been removed; a proposer's QSC is simply left alone here. + state.mut().approvedGameList.set(state.get().gameList.key(locals.idx), locals.game); // remove game from gameList state.mut().gameList.removeByIndex(locals.idx); @@ -968,6 +1641,40 @@ struct QUSINO : public ContractBase state.mut().gameList.cleanupIfNeeded(); state.mut().voteList.reset(); + // Resurrect any approved proposal whose QUSINO_REVOTE_DURATION reconfirmation + // window starts next epoch: move it back into gameList with votes reset to + // 0/0 (voteList was just wiped above, so no stale per-voter records survive + // to interfere) and proposedEpoch re-anchored to the epoch it's about to be + // live in (qpi.epoch() + 1, since this END_EPOCH call is still processing the + // epoch that's ending). Re-anchoring is what lets this repeat indefinitely -- + // each reconfirmation gets its own fresh QUSINO_REVOTE_DURATION countdown from + // whenever it was (re)approved, rather than only ever firing once relative to + // the original submission epoch. + // + // Done as a separate pass *after* the drain loop above, not interleaved with + // it: inserting straight into gameList and letting the same pass immediately + // re-scan it would hit proposedEpoch == qpi.epoch() + 1, not qpi.epoch() -- + // neither resolution branch above would match yet -- and it would incorrectly + // fall through to the "already passed" distribution path before anyone had a + // chance to vote on the reconfirmation at all. + locals.idx = state.get().approvedGameList.nextElementIndex(NULL_INDEX); + while (locals.idx != NULL_INDEX) + { + locals.game = state.get().approvedGameList.value(locals.idx); + if (locals.game.proposedEpoch + QUSINO_REVOTE_DURATION == qpi.epoch() + 1) + { + locals.game.yesVotes = 0; + locals.game.noVotes = 0; + locals.game.proposedEpoch = (uint32)(qpi.epoch() + 1); + state.mut().gameList.set(state.get().approvedGameList.key(locals.idx), locals.game); + state.mut().approvedGameList.removeByIndex(locals.idx); + locals.idx = state.get().approvedGameList.nextElementIndex(locals.idx); + continue; + } + locals.idx = state.get().approvedGameList.nextElementIndex(locals.idx); + } + state.mut().approvedGameList.cleanupIfNeeded(); + locals.idx = state.get().userAssetVolume.nextElementIndex(NULL_INDEX); while (locals.idx != NULL_INDEX) { @@ -988,12 +1695,45 @@ struct QUSINO : public ContractBase div(smul(smul(locals.epochSnapshot, (uint64)QUSINO_SHAREHOLDERS_DIVIDENDS_PERCENT), 1ULL), 67600ULL), 676ULL); - qpi.transfer(state.get().LPDividendsAddress, (sint64)locals.lpShare); - qpi.transfer(state.get().CCFDividendsAddress, (sint64)locals.ccfShare); - qpi.transfer(state.get().treasuryAddress, (sint64)locals.treasuryShare); + // Same INT64_MAX guard qstPayout below already has, for consistency -- a + // uint64 share cast straight to sint64 without checking first would come out + // negative if it ever exceeded INT64_MAX (astronomically unlikely given real + // Qu supply bounds, but the other transfer below already defends against it, + // so these should too rather than being the only ones that don't). + if (locals.lpShare <= (uint64)INT64_MAX) + { + qpi.transfer(state.get().LPDividendsAddress, (sint64)locals.lpShare); + } + if (locals.ccfShare <= (uint64)INT64_MAX) + { + qpi.transfer(state.get().CCFDividendsAddress, (sint64)locals.ccfShare); + } + if (locals.treasuryShare <= (uint64)INT64_MAX) + { + qpi.transfer(state.get().treasuryAddress, (sint64)locals.treasuryShare); + } qpi.distributeDividends(div(smul(smul(locals.epochSnapshot, (uint64)QUSINO_SHAREHOLDERS_DIVIDENDS_PERCENT), 1ULL), 67600ULL)); locals.QSTDividends = 0; - locals.qstPerShareRate = div(smul(smul(locals.epochSnapshot, (uint64)QUSINO_QST_HOLDERS_DIVIDENDS_PERCENT), 1ULL), QUSINO_SUPPLY_OF_QST * 1000ULL); + // Each possessor's payout is (epochSnapshot * QST_HOLDERS_PERCENT * their share + // count) / (100 * QUSINO_SUPPLY_OF_QST) -- computed per possessor, inside this + // loop, with every multiplication done before the one division. + // + // An earlier version of this computed a single shared "per-share rate" ONCE, + // outside the loop, by dividing first: (epochSnapshot * percent / 100) / + // QUSINO_SUPPLY_OF_QST. That's fatal with real numbers -- the whole dividend + // pool (tens of millions of Qu in practice) divided by QUSINO_SUPPLY_OF_QST + // (1.2 BILLION shares) is a fraction of a single Qu per share, and integer + // division truncates any such fraction straight to 0. That zeroed out every + // QST holder's payout entirely, regardless of whether the percent-to-fraction + // denominator used *1000 or *100 (a previous fix here changed *1000 to *100, + // correctly diagnosing an extra factor of 10 by analogy with lpShare/ccfShare/ + // etc., but those are flat one-recipient payouts with no per-share division at + // all -- the *1000-vs-*100 choice was never the actual bug). Multiplying + // epochSnapshot * percent * possessionCount together before dividing once by + // QUSINO_SUPPLY_OF_QST * 100 keeps the precision that dividing early throws + // away. smul() saturates instead of wrapping if the product ever exceeds + // uint64 (astronomically unlikely given real Qu supply bounds, same + // extremely-low-risk tradeoff already accepted for the other shares above). locals.QSTAsset.assetName = state.get().QSTAssetName; locals.QSTAsset.issuer = state.get().QSTIssuer; locals.iter.begin(locals.QSTAsset); @@ -1002,7 +1742,7 @@ struct QUSINO : public ContractBase locals.possessionCount = locals.iter.numberOfPossessedShares(); if (locals.possessionCount > 0) { - locals.qstPayout = smul(locals.qstPerShareRate, (uint64)locals.possessionCount); + locals.qstPayout = div(smul(smul(locals.epochSnapshot, (uint64)QUSINO_QST_HOLDERS_DIVIDENDS_PERCENT), (uint64)locals.possessionCount), QUSINO_SUPPLY_OF_QST * 100ULL); locals.QSTDividends = sadd(locals.QSTDividends, locals.qstPayout); if (locals.qstPayout <= (uint64)INT64_MAX) { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 32da1f73..d5c1a4c7 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -35,6 +35,7 @@ add_executable( # contract_qx.cpp contract_qpayhub.cpp contract_qraffle.cpp + contract_qusino.cpp contract_random.cpp contract_vottunbridge.cpp # kangaroo_twelve.cpp diff --git a/test/contract_qusino.cpp b/test/contract_qusino.cpp index c2af3b0c..fdc37840 100644 --- a/test/contract_qusino.cpp +++ b/test/contract_qusino.cpp @@ -34,6 +34,12 @@ class ContractTestingQUSINO : protected ContractTesting { initEmptySpectrum(); initEmptyUniverse(); + // RANDOM must be constructed before QUSINO so refillRandomBank's cross-contract + // BuyEntropy call has an active contract to invoke (mirrors contract_qraffle.cpp). + system.epoch = contractDescriptions[RANDOM_CONTRACT_INDEX].constructionEpoch; + INIT_CONTRACT(RANDOM); + callSystemProcedure(RANDOM_CONTRACT_INDEX, INITIALIZE); + system.epoch = contractDescriptions[QUSINO_CONTRACT_INDEX].constructionEpoch; INIT_CONTRACT(QUSINO); callSystemProcedure(QUSINO_CONTRACT_INDEX, INITIALIZE); INIT_CONTRACT(QX); @@ -45,6 +51,31 @@ class ContractTestingQUSINO : protected ContractTesting return (QUSINOChecker*)contractStates[QUSINO_CONTRACT_INDEX]; } + RANDOM::StateData* randomState() + { + return reinterpret_cast(contractStates[RANDOM_CONTRACT_INDEX]); + } + + // Directly seeds RANDOM's finalized entropy for the stream/tier that QUSINO's + // refillRandomBank() will read when called at the current tick (mirrors the +2 + // offset BuyEntropy itself uses to read the last-finalized stream). Lets tests make + // the entropy purchase deterministically succeed without replaying the full + // RevealAndCommit/END_TICK provider cycle (see contract_qraffle.cpp for precedent). + QPI::bit_4096 seedRandomEntropy(uint64 seed) + { + QPI::bit_4096 entropy{}; + for (uint64 i = 0; i < QUSINO_RNG_ENTROPY_BITS; ++i) + { + entropy.set(i, ((seed + i) & 1ULL) != 0); + } + const uint32 stream = (system.tick + 2u) % 3u; + randomState()->entropy.set(stream * 10u + QUSINO_RNG_COLLATERAL_TIER, entropy); + return entropy; + } + + void setTick(uint32 tick) { system.tick = tick; } + uint32 getTick() const { return system.tick; } + void endEpoch(bool expectSuccess = true) { callSystemProcedure(QUSINO_CONTRACT_INDEX, END_EPOCH, expectSuccess); @@ -172,6 +203,15 @@ class ContractTestingQUSINO : protected ContractTesting return output; } + QUSINO::getDailyClaimStatus_output getDailyClaimStatus(const id& user) + { + QUSINO::getDailyClaimStatus_input input; + input.user = user; + QUSINO::getDailyClaimStatus_output output; + callFunction(QUSINO_CONTRACT_INDEX, 5, input, output); + return output; + } + QUSINO::getFailedGameList_output getFailedGameList(uint32 offset) { QUSINO::getFailedGameList_input input; @@ -198,15 +238,63 @@ class ContractTestingQUSINO : protected ContractTesting return output; } - QUSINO::getProposerEarnedQSCInfo_output getProposerEarnedQSCInfo(const id& proposer, uint32 epoch) + QUSINO::getApprovedGameList_output getApprovedGameList(uint32 offset) { - QUSINO::getProposerEarnedQSCInfo_input input; - input.proposer = proposer; - input.epoch = epoch; - QUSINO::getProposerEarnedQSCInfo_output output; - callFunction(QUSINO_CONTRACT_INDEX, 5, input, output); + QUSINO::getApprovedGameList_input input; + input.offset = offset; + QUSINO::getApprovedGameList_output output; + callFunction(QUSINO_CONTRACT_INDEX, 7, input, output); return output; } + + QUSINO::refillRandomBank_output refillRandomBank(const id& user, sint64 invocationReward = 0) + { + QUSINO::refillRandomBank_input input; + QUSINO::refillRandomBank_output output; + invokeUserProcedure(QUSINO_CONTRACT_INDEX, 9, input, output, user, invocationReward); + return output; + } + + QUSINO::coinFlip_output coinFlip(const id& user, uint8 guess, uint8 assetType, uint64 amount, sint64 invocationReward = 0) + { + QUSINO::coinFlip_input input; + input.guess = guess; + input.assetType = assetType; + input.amount = amount; + QUSINO::coinFlip_output output; + invokeUserProcedure(QUSINO_CONTRACT_INDEX, 10, input, output, user, invocationReward); + return output; + } + + QUSINO::getRandomBankStatus_output getRandomBankStatus() + { + QUSINO::getRandomBankStatus_input input; + QUSINO::getRandomBankStatus_output output; + callFunction(QUSINO_CONTRACT_INDEX, 6, input, output); + return output; + } + + // Funds bonusAmount (the Qu game bankroll) by having `owner` deposit `amount` -- + // mirrors how the game owner is expected to seed it in production. Also gives + // QUSINO's real spectrum balance the matching Qu, which refillRandomBank's actual + // cross-contract RANDOM fee transfer still separately depends on. + void fundBonusAmount(uint64 amount) + { + const id gameOwner = ID(_G, _O, _W, _N, _A, _A, _B, _C, _D, _E, _F, _G, _H, _I, _J, _K, _L, _M, _N, _O, _P, _Q, _R, _S, _T, _U, _V, _W, _X, _Y, _Z, _A, _B, _C, _D, _E, _F, _G, _H, _I, _J, _K, _L, _M, _N, _O, _P, _Q, _R, _S, _T, _U, _V, _W, _X, _Y); + increaseEnergy(gameOwner, (sint64)amount); + QUSINO::depositBonus_output out = depositBonus(gameOwner, amount); + ASSERT_EQ(out.returnCode, QUSINO_SUCCESS); + } + + // Credits `user` with `amount` QSC (and amount*100 STAR, incidentally) via earnSTAR + // -- the only path that mints QSC in this contract. + void giveUserQSC(const id& user, uint64 amount) + { + sint64 requiredReward = (sint64)(amount * QUSINO_STAR_PRICE * 100); + increaseEnergy(user, requiredReward); + QUSINO::earnSTAR_output out = earnSTAR(user, amount, requiredReward); + ASSERT_EQ(out.returnCode, QUSINO_SUCCESS); + } }; // Helper function to create a URI @@ -492,6 +580,159 @@ TEST(ContractQUSINO, voteInGameProposal_WrongGameURI) EXPECT_EQ(voteOutput.returnCode, QUSINO_WRONG_GAME_URI_FOR_VOTE); } +// Reported bug: a proposal's own proposer could vote "yes" on it like any +// other voter, padding their own proposal's yesVotes. See +// QUSINO_PROPOSER_CANNOT_VOTE's introduction in voteInGameProposal. +TEST(ContractQUSINO, voteInGameProposal_ProposerCannotVoteOnOwnGame) +{ + ContractTestingQUSINO QUSINO; + + id proposer = QUSINO_testUser1; + Array URI = createURI("https://example.com/game1"); + + sint64 requiredReward = QUSINO_GAME_SUBMIT_FEE; + increaseEnergy(proposer, requiredReward); + QUSINO::submitGame_output submitOutput = QUSINO.submitGame(proposer, URI, requiredReward); + EXPECT_EQ(submitOutput.returnCode, QUSINO_SUCCESS); + + // Give the proposer plenty of STAR too, so a QUSINO_INSUFFICIENT_VOTE_FEE + // rejection couldn't be mistaken for the proposer-block actually working. + uint64 starAmount = QUSINO_VOTE_FEE; + sint64 starReward = starAmount * QUSINO_STAR_PRICE * 100; + increaseEnergy(proposer, starReward); + QUSINO::earnSTAR_output earnOutput = QUSINO.earnSTAR(proposer, starAmount, starReward); + EXPECT_EQ(earnOutput.returnCode, QUSINO_SUCCESS); + + increaseEnergy(proposer, 1); + QUSINO::getActiveGameList_output gameList = QUSINO.getActiveGameList(0); + uint64 gameIndex = gameList.gameIndexes.get(0); + QUSINO::voteInGameProposal_output voteOutput = QUSINO.voteInGameProposal(proposer, URI, gameIndex, 1, 1); + EXPECT_EQ(voteOutput.returnCode, QUSINO_PROPOSER_CANNOT_VOTE); +} + +// Reported bug: the same URI could be submitted as an unlimited number of +// separate, independently-votable proposals. See QUSINO_DUPLICATE_GAME_URI's +// introduction in submitGame. +TEST(ContractQUSINO, submitGame_RejectsDuplicateURIStillPending) +{ + ContractTestingQUSINO QUSINO; + + id proposer1 = QUSINO_testUser1; + id proposer2 = QUSINO_testUser2; + Array URI = createURI("https://example.com/game1"); + + increaseEnergy(proposer1, QUSINO_GAME_SUBMIT_FEE); + QUSINO::submitGame_output firstSubmit = QUSINO.submitGame(proposer1, URI, QUSINO_GAME_SUBMIT_FEE); + EXPECT_EQ(firstSubmit.returnCode, QUSINO_SUCCESS); + + increaseEnergy(proposer2, QUSINO_GAME_SUBMIT_FEE); + // Snapshot right before the call, not before increaseEnergy -- the + // rejection should refund the fee the transaction attached, returning + // the balance to what it was at the moment of the call, not to some + // earlier point before this test even funded the account. + long long proposer2QuBefore = getBalance(proposer2); + QUSINO::submitGame_output secondSubmit = QUSINO.submitGame(proposer2, URI, QUSINO_GAME_SUBMIT_FEE); + EXPECT_EQ(secondSubmit.returnCode, QUSINO_DUPLICATE_GAME_URI); + // The rejected submitter's fee should come back in full, same as every + // other early-rejection branch in submitGame. + EXPECT_EQ(getBalance(proposer2), proposer2QuBefore); + + // Only the first submission should actually be in gameList. + QUSINO::getSCInfo_output scInfo = QUSINO.getSCInfo(); + EXPECT_EQ(scInfo.maxGameIndex, 2); +} + +// Same as above, but the existing entry has already passed a vote and moved +// into approvedGameList (including its QUSINO_REVOTE_DURATION cooldown +// window) rather than still sitting in gameList. +TEST(ContractQUSINO, submitGame_RejectsDuplicateURIAlreadyApproved) +{ + ContractTestingQUSINO QUSINO; + + id proposer = QUSINO_testUser1; + id voter = QUSINO_testUser2; + Array URI = createURI("https://example.com/game1"); + + increaseEnergy(proposer, QUSINO_GAME_SUBMIT_FEE); + QUSINO::submitGame_output submitOutput = QUSINO.submitGame(proposer, URI, QUSINO_GAME_SUBMIT_FEE); + EXPECT_EQ(submitOutput.returnCode, QUSINO_SUCCESS); + + uint64 starAmount = QUSINO_VOTE_FEE; + sint64 starReward = starAmount * QUSINO_STAR_PRICE * 100; + increaseEnergy(voter, starReward); + QUSINO::earnSTAR_output earnOutput = QUSINO.earnSTAR(voter, starAmount, starReward); + EXPECT_EQ(earnOutput.returnCode, QUSINO_SUCCESS); + + increaseEnergy(voter, 1); + QUSINO::getActiveGameList_output gameList = QUSINO.getActiveGameList(0); + uint64 gameIndex = gameList.gameIndexes.get(0); + QUSINO::voteInGameProposal_output voteOutput = QUSINO.voteInGameProposal(voter, URI, gameIndex, 1, 1); + EXPECT_EQ(voteOutput.returnCode, QUSINO_SUCCESS); + + QUSINO.endEpoch(); + ++system.epoch; + + // Confirm it actually landed in approvedGameList before relying on that + // for the real assertion below. + QUSINO::getApprovedGameList_output approvedList = QUSINO.getApprovedGameList(0); + EXPECT_EQ(approvedList.gameIndexes.get(0), gameIndex); + + id newProposer = QUSINO_testUser3; + increaseEnergy(newProposer, QUSINO_GAME_SUBMIT_FEE); + // Snapshot right before the call -- see the same note in + // submitGame_RejectsDuplicateURIStillPending above. + long long newProposerQuBefore = getBalance(newProposer); + QUSINO::submitGame_output duplicateSubmit = QUSINO.submitGame(newProposer, URI, QUSINO_GAME_SUBMIT_FEE); + EXPECT_EQ(duplicateSubmit.returnCode, QUSINO_DUPLICATE_GAME_URI); + EXPECT_EQ(getBalance(newProposer), newProposerQuBefore); +} + +TEST(ContractQUSINO, getDailyClaimStatus_CanClaimBeforeFirstEverClaim) +{ + ContractTestingQUSINO QUSINO; + + id user = QUSINO_testUser1; + QUSINO::getDailyClaimStatus_output status = QUSINO.getDailyClaimStatus(user); + EXPECT_TRUE(status.canClaimNow); + EXPECT_EQ(status.secondsUntilNextClaim, 0u); +} + +TEST(ContractQUSINO, getDailyClaimStatus_CannotClaimRightAfterClaiming) +{ + ContractTestingQUSINO QUSINO; + + id user = QUSINO_testUser1; + + // Same setup as dailyClaimBonus_Success -- a claim needs the bonus pool + // funded (bonusAmount starts at 0) and a fixed simulated time (qpi.year() + // etc. are otherwise whatever this process's default/real clock reads, + // which this test doesn't need to depend on). + uint64 bonusFund = QUSINO_BONUS_CLAIM_AMOUNT * 10; + increaseEnergy(user, bonusFund); + QUSINO::depositBonus_output depOutput = QUSINO.depositBonus(user, bonusFund); + EXPECT_EQ(depOutput.returnCode, QUSINO_SUCCESS); + + setMemory(utcTime, 0); + utcTime.Year = 2024; + utcTime.Month = 1; + utcTime.Day = 1; + utcTime.Hour = 0; + utcTime.Minute = 0; + utcTime.Second = 0; + updateQpiTime(); + + QUSINO::dailyClaimBonus_output claimOutput = QUSINO.dailyClaimBonus(user, 0); + EXPECT_EQ(claimOutput.returnCode, QUSINO_SUCCESS); + + QUSINO::getDailyClaimStatus_output status = QUSINO.getDailyClaimStatus(user); + EXPECT_FALSE(status.canClaimNow); + // Called immediately after a successful claim -- the full 24h window + // should still be ahead (allowing a couple of seconds of test-runtime + // slack rather than asserting the exact boundary value). + EXPECT_GT(status.secondsUntilNextClaim, (uint32)(QUSINO_DAILY_CLAIM_BONUS_DURATION - 5)); + EXPECT_LE(status.secondsUntilNextClaim, (uint32)QUSINO_DAILY_CLAIM_BONUS_DURATION); +} + TEST(ContractQUSINO, getUserAssetVolume_Empty) { ContractTestingQUSINO QUSINO; @@ -591,17 +832,33 @@ TEST(ContractQUSINO, END_EPOCH_FailedGameRemoval) // End epoch - game should be moved to failed list if no votes >= yes votes QUSINO.endEpoch(); ++system.epoch; - - // Check failed game list + + // Game should be in the failed list, gone from both the active list and + // approvedGameList (a rejected proposal never gets archived as approved). QUSINO::getFailedGameList_output failedList = QUSINO.getFailedGameList(0); - // Game should be in failed list + bool foundInFailedList = false; + for (uint32 i = 0; i < 32; i++) + { + if (failedList.games.get(i).proposer == proposer) + { + foundInFailedList = true; + break; + } + } + EXPECT_TRUE(foundInFailedList); } -TEST(ContractQUSINO, END_EPOCH_ProposerEarnedQSCInfo) +// A passed proposal used to trigger an automatic, involuntary QSC-to-Qu conversion +// of the proposer's entire balance (split by a "developer fee") right here in +// END_EPOCH. That's been removed as redundant: this proves a proposer's QSC is left +// completely untouched by their proposal passing -- balance unchanged, circulating +// supply unchanged -- and that they can redeem it themselves afterward via +// redemptionQSCToQubic() at the standard, fee-free 1:1 rate (strictly better than +// the old automatic conversion ever was). +TEST(ContractQUSINO, END_EPOCH_ApprovedProposalDoesNotTouchProposerQSC) { ContractTestingQUSINO QUSINO; - // issue QST id qstIssuer = QUSINO_QSTIssuer; uint64 qstAssetName = 5526353; uint64 totalShares = QUSINO_SUPPLY_OF_QST; @@ -622,7 +879,6 @@ TEST(ContractQUSINO, END_EPOCH_ProposerEarnedQSCInfo) QUSINO::earnSTAR_output earnOut = QUSINO.earnSTAR(proposer, qscAmount, starReward); EXPECT_EQ(earnOut.returnCode, QUSINO_SUCCESS); - uint32 epochBeforeEnd = system.epoch; increaseEnergy(voter, QUSINO_VOTE_FEE * QUSINO_STAR_PRICE * 100); QUSINO::earnSTAR_output voterEarn = QUSINO.earnSTAR(voter, QUSINO_VOTE_FEE, QUSINO_VOTE_FEE * QUSINO_STAR_PRICE * 100); EXPECT_EQ(voterEarn.returnCode, QUSINO_SUCCESS); @@ -631,11 +887,341 @@ TEST(ContractQUSINO, END_EPOCH_ProposerEarnedQSCInfo) QUSINO::voteInGameProposal_output voteOut = QUSINO.voteInGameProposal(voter, URI, gameIndex, 1, 0); EXPECT_EQ(voteOut.returnCode, QUSINO_SUCCESS); + uint64 qscSupplyBefore = QUSINO.getSCInfo().QSCCirclatingSupply; + long long proposerQuBefore = getBalance(proposer); + + QUSINO.endEpoch(); + ++system.epoch; + + // Proposal resolving passed didn't touch the proposer's QSC or transfer them + // any Qu -- no more automatic conversion. + EXPECT_EQ(QUSINO.getUserAssetVolume(proposer).QSCAmount, qscAmount); + EXPECT_EQ(QUSINO.getSCInfo().QSCCirclatingSupply, qscSupplyBefore); + EXPECT_EQ(getBalance(proposer), proposerQuBefore); + + // The proposer is no longer in gameList (their proposal is in approvedGameList + // now), so redemptionQSCToQubic's "you can't redeem while you have a pending + // proposal" guard no longer blocks them -- they can cash out the full amount + // themselves, at the standard 1:1 rate, whenever they want. + QUSINO::redemptionQSCToQubic_output redemption = QUSINO.redemptionQSCToQubic(proposer, qscAmount, 0); + EXPECT_EQ(redemption.returnCode, QUSINO_SUCCESS); + EXPECT_EQ((uint64)(getBalance(proposer) - proposerQuBefore), qscAmount * QUSINO_QSC_PRICE); + EXPECT_EQ(QUSINO.getUserAssetVolume(proposer).QSCAmount, 0u); +} + +// A passed proposal used to just vanish after the proposer's payout -- this proves +// it's archived into approvedGameList instead, and that it's no longer sitting in +// the active list once resolved. +TEST(ContractQUSINO, END_EPOCH_PassedGameArchivedToApprovedList) +{ + ContractTestingQUSINO QUSINO; + + id qstIssuer = QUSINO_QSTIssuer; + uint64 qstAssetName = 5526353; + uint64 totalShares = QUSINO_SUPPLY_OF_QST; + increaseEnergy(qstIssuer, QUSINO_ISSUE_ASSET_FEE); + EXPECT_EQ(QUSINO.issueAsset(qstIssuer, qstAssetName, totalShares), totalShares); + + id proposer = QUSINO_testUser1; + id voter = QUSINO_testUser2; + Array URI = createURI("https://example.com/approved-game"); + + increaseEnergy(proposer, QUSINO_GAME_SUBMIT_FEE); + EXPECT_EQ(QUSINO.submitGame(proposer, URI, QUSINO_GAME_SUBMIT_FEE).returnCode, QUSINO_SUCCESS); + + QUSINO::getActiveGameList_output activeBefore = QUSINO.getActiveGameList(0); + uint64 gameIndex = activeBefore.gameIndexes.get(0); + + increaseEnergy(voter, QUSINO_VOTE_FEE * QUSINO_STAR_PRICE * 100); + EXPECT_EQ(QUSINO.earnSTAR(voter, QUSINO_VOTE_FEE, QUSINO_VOTE_FEE * QUSINO_STAR_PRICE * 100).returnCode, QUSINO_SUCCESS); + EXPECT_EQ(QUSINO.voteInGameProposal(voter, URI, gameIndex, 1, 0).returnCode, QUSINO_SUCCESS); + + QUSINO.endEpoch(); + ++system.epoch; + + // Gone from the active list... + QUSINO::getActiveGameList_output activeAfter = QUSINO.getActiveGameList(0); + bool stillActive = false; + for (uint32 i = 0; i < 32; i++) + { + if (activeAfter.gameIndexes.get(i) == gameIndex && activeAfter.games.get(i).proposer == proposer) + { + stillActive = true; + } + } + EXPECT_FALSE(stillActive); + + // ...and archived in approvedGameList under the same gameIndex, not just discarded. + QUSINO::getApprovedGameList_output approved = QUSINO.getApprovedGameList(0); + bool foundApproved = false; + for (uint32 i = 0; i < 32; i++) + { + if (approved.gameIndexes.get(i) == gameIndex && approved.games.get(i).proposer == proposer) + { + foundApproved = true; + EXPECT_EQ(approved.games.get(i).yesVotes, 1u); + EXPECT_EQ(approved.games.get(i).noVotes, 0u); + } + } + EXPECT_TRUE(foundApproved); +} + +// The actual bug fix: an approved proposal should not just sit in approvedGameList +// forever -- QUSINO_REVOTE_DURATION epochs after it was (re)confirmed, it should come +// back to gameList for a fresh vote, with the old tally reset (not carried over) so +// it has to be genuinely reconfirmed, not just coast on a vote count from over a +// year and a half ago. +TEST(ContractQUSINO, END_EPOCH_ApprovedGameResurrectsAfterRevoteDuration) +{ + ContractTestingQUSINO QUSINO; + + id qstIssuer = QUSINO_QSTIssuer; + uint64 qstAssetName = 5526353; + uint64 totalShares = QUSINO_SUPPLY_OF_QST; + increaseEnergy(qstIssuer, QUSINO_ISSUE_ASSET_FEE); + EXPECT_EQ(QUSINO.issueAsset(qstIssuer, qstAssetName, totalShares), totalShares); + + id proposer = QUSINO_testUser1; + id voter = QUSINO_testUser2; + Array URI = createURI("https://example.com/revote-game"); + + increaseEnergy(proposer, QUSINO_GAME_SUBMIT_FEE); + EXPECT_EQ(QUSINO.submitGame(proposer, URI, QUSINO_GAME_SUBMIT_FEE).returnCode, QUSINO_SUCCESS); + + QUSINO::getActiveGameList_output activeBefore = QUSINO.getActiveGameList(0); + uint64 gameIndex = activeBefore.gameIndexes.get(0); + uint32 approvalEpoch = system.epoch; + + increaseEnergy(voter, QUSINO_VOTE_FEE * QUSINO_STAR_PRICE * 100); + EXPECT_EQ(QUSINO.earnSTAR(voter, QUSINO_VOTE_FEE, QUSINO_VOTE_FEE * QUSINO_STAR_PRICE * 100).returnCode, QUSINO_SUCCESS); + EXPECT_EQ(QUSINO.voteInGameProposal(voter, URI, gameIndex, 1, 0).returnCode, QUSINO_SUCCESS); + + QUSINO.endEpoch(); + ++system.epoch; // now approvalEpoch + 1; proposal sits in approvedGameList + + // Fast-forward straight to the epoch right before the revote window opens -- + // no need to actually call endEpoch() for every epoch in between, since nothing + // else in this test depends on those epochs' side effects (dividends etc.), only + // on qpi.epoch()'s value at the next endEpoch() call. + system.epoch = approvalEpoch + QUSINO_REVOTE_DURATION - 1; + + QUSINO.endEpoch(); + ++system.epoch; // now approvalEpoch + QUSINO_REVOTE_DURATION + + // No longer archived -- it's back in play. + QUSINO::getApprovedGameList_output approvedAfter = QUSINO.getApprovedGameList(0); + bool stillApproved = false; + for (uint32 i = 0; i < 32; i++) + { + if (approvedAfter.gameIndexes.get(i) == gameIndex) + { + stillApproved = true; + } + } + EXPECT_FALSE(stillApproved); + + // Back in gameList, same gameIndex, votes reset to zero -- not carrying over + // yesVotes=1/noVotes=0 from a year and a half ago. + QUSINO::getActiveGameList_output resurrected = QUSINO.getActiveGameList(0); + bool found = false; + for (uint32 i = 0; i < 32; i++) + { + if (resurrected.gameIndexes.get(i) == gameIndex) + { + found = true; + EXPECT_EQ(resurrected.games.get(i).proposer, proposer); + EXPECT_EQ(resurrected.games.get(i).yesVotes, 0u); + EXPECT_EQ(resurrected.games.get(i).noVotes, 0u); + EXPECT_EQ(resurrected.games.get(i).proposedEpoch, approvalEpoch + QUSINO_REVOTE_DURATION); + } + } + ASSERT_TRUE(found); + + // And it's genuinely votable again -- proves voteInGameProposal's simplified + // proposedEpoch == qpi.epoch() check still recognizes a resurrected entry. + id secondVoter = QUSINO_testUser3; + increaseEnergy(secondVoter, QUSINO_VOTE_FEE * QUSINO_STAR_PRICE * 100); + EXPECT_EQ(QUSINO.earnSTAR(secondVoter, QUSINO_VOTE_FEE, QUSINO_VOTE_FEE * QUSINO_STAR_PRICE * 100).returnCode, QUSINO_SUCCESS); + EXPECT_EQ(QUSINO.voteInGameProposal(secondVoter, URI, gameIndex, 1, 0).returnCode, QUSINO_SUCCESS); + QUSINO.endEpoch(); ++system.epoch; - QUSINO::getProposerEarnedQSCInfo_output info = QUSINO.getProposerEarnedQSCInfo(proposer, epochBeforeEnd); - EXPECT_EQ(info.earnedQSC, qscAmount); + // Reconfirmed -- back in approvedGameList, re-anchored to this new epoch, ready + // to repeat the same cycle again in another QUSINO_REVOTE_DURATION epochs. + QUSINO::getApprovedGameList_output reapproved = QUSINO.getApprovedGameList(0); + bool foundReapproved = false; + for (uint32 i = 0; i < 32; i++) + { + if (reapproved.gameIndexes.get(i) == gameIndex) + { + foundReapproved = true; + EXPECT_EQ(reapproved.games.get(i).proposedEpoch, approvalEpoch + QUSINO_REVOTE_DURATION); + } + } + EXPECT_TRUE(foundReapproved); +} + +// Proves the yesVotes/noVotes underflow exploit is closed: a garbage yesNo value +// (anything but 1 or 2) is rejected outright, before touching STAR balance or +// voteList -- not silently accepted as a no-op "vote" that still burns the fee and +// leaves the voter recorded as "already voted" (which previously let a follow-up +// real vote decrement a counter that was never incremented, underflowing it). +TEST(ContractQUSINO, voteInGameProposal_InvalidYesNoRejectedWithoutCorruptingCounts) +{ + ContractTestingQUSINO QUSINO; + + id qstIssuer = QUSINO_QSTIssuer; + uint64 qstAssetName = 5526353; + uint64 totalShares = QUSINO_SUPPLY_OF_QST; + increaseEnergy(qstIssuer, QUSINO_ISSUE_ASSET_FEE); + EXPECT_EQ(QUSINO.issueAsset(qstIssuer, qstAssetName, totalShares), totalShares); + + id proposer = QUSINO_testUser1; + id voter = QUSINO_testUser2; + Array URI = createURI("https://example.com/bad-yesno"); + + increaseEnergy(proposer, QUSINO_GAME_SUBMIT_FEE); + EXPECT_EQ(QUSINO.submitGame(proposer, URI, QUSINO_GAME_SUBMIT_FEE).returnCode, QUSINO_SUCCESS); + uint64 gameIndex = 1; // first submission in a fresh instance always gets index 1 + + increaseEnergy(voter, QUSINO_VOTE_FEE * QUSINO_STAR_PRICE * 100); + EXPECT_EQ(QUSINO.earnSTAR(voter, QUSINO_VOTE_FEE, QUSINO_VOTE_FEE * QUSINO_STAR_PRICE * 100).returnCode, QUSINO_SUCCESS); + + QUSINO::getUserAssetVolume_output starBefore = QUSINO.getUserAssetVolume(voter); + + QUSINO::voteInGameProposal_output badVoteZero = QUSINO.voteInGameProposal(voter, URI, gameIndex, 0, 0); + EXPECT_EQ(badVoteZero.returnCode, QUSINO_INVALID_INPUT); + + QUSINO::voteInGameProposal_output badVoteHigh = QUSINO.voteInGameProposal(voter, URI, gameIndex, 99, 0); + EXPECT_EQ(badVoteHigh.returnCode, QUSINO_INVALID_INPUT); + + // Neither rejected call should have burned the fee or touched the tally. + QUSINO::getUserAssetVolume_output starAfterRejections = QUSINO.getUserAssetVolume(voter); + EXPECT_EQ(starAfterRejections.STARAmount, starBefore.STARAmount); + + QUSINO::getActiveGameList_output stillZero = QUSINO.getActiveGameList(0); + EXPECT_EQ(stillZero.games.get(0).yesVotes, 0u); + EXPECT_EQ(stillZero.games.get(0).noVotes, 0u); + + // The exploit sequence: garbage vote, then a real one. Before the fix, this + // underflowed yesVotes to ~4.29 billion (the garbage call's rejected-now voteList + // entry made the real call think it was "switching" an existing yes vote it never + // actually cast). Since the garbage calls above were rejected before writing + // anything, this real vote is treated as a genuine first vote. + QUSINO::voteInGameProposal_output realVote = QUSINO.voteInGameProposal(voter, URI, gameIndex, 2, 0); + EXPECT_EQ(realVote.returnCode, QUSINO_SUCCESS); + + QUSINO::getActiveGameList_output afterReal = QUSINO.getActiveGameList(0); + EXPECT_EQ(afterReal.games.get(0).yesVotes, 0u); + EXPECT_EQ(afterReal.games.get(0).noVotes, 1u); +} + +// A proposer with two proposals both resolving as passed in the same epoch used to +// have their QSC balance zeroed by whichever one resolved first, with the second +// paid out (and recorded) from an already-drained balance. With the automatic +// conversion removed entirely, this just proves both proposals resolve +// independently without touching the proposer's QSC at all -- no ordering-dependent +// side effect between them. +TEST(ContractQUSINO, END_EPOCH_MultiplePassedProposalsFromSameProposerDoNotTouchQSC) +{ + ContractTestingQUSINO QUSINO; + + id qstIssuer = QUSINO_QSTIssuer; + uint64 qstAssetName = 5526353; + uint64 totalShares = QUSINO_SUPPLY_OF_QST; + increaseEnergy(qstIssuer, QUSINO_ISSUE_ASSET_FEE); + EXPECT_EQ(QUSINO.issueAsset(qstIssuer, qstAssetName, totalShares), totalShares); + + id proposer = QUSINO_testUser1; + id voter = QUSINO_testUser2; + Array uriA = createURI("https://example.com/multi-a"); + Array uriB = createURI("https://example.com/multi-b"); + + increaseEnergy(proposer, QUSINO_GAME_SUBMIT_FEE * 2); + EXPECT_EQ(QUSINO.submitGame(proposer, uriA, QUSINO_GAME_SUBMIT_FEE).returnCode, QUSINO_SUCCESS); + EXPECT_EQ(QUSINO.submitGame(proposer, uriB, QUSINO_GAME_SUBMIT_FEE).returnCode, QUSINO_SUCCESS); + uint64 gameIndexA = 1; + uint64 gameIndexB = 2; + + uint64 qscAmount = 500; + sint64 starReward = qscAmount * QUSINO_STAR_PRICE * 100; + increaseEnergy(proposer, starReward); + EXPECT_EQ(QUSINO.earnSTAR(proposer, qscAmount, starReward).returnCode, QUSINO_SUCCESS); + + increaseEnergy(voter, QUSINO_VOTE_FEE * QUSINO_STAR_PRICE * 100 * 2); + EXPECT_EQ(QUSINO.earnSTAR(voter, QUSINO_VOTE_FEE * 2, QUSINO_VOTE_FEE * QUSINO_STAR_PRICE * 100 * 2).returnCode, QUSINO_SUCCESS); + EXPECT_EQ(QUSINO.voteInGameProposal(voter, uriA, gameIndexA, 1, 0).returnCode, QUSINO_SUCCESS); + EXPECT_EQ(QUSINO.voteInGameProposal(voter, uriB, gameIndexB, 1, 0).returnCode, QUSINO_SUCCESS); + + QUSINO.endEpoch(); + ++system.epoch; + + EXPECT_EQ(QUSINO.getUserAssetVolume(proposer).QSCAmount, qscAmount); + + QUSINO::getApprovedGameList_output approved = QUSINO.getApprovedGameList(0); + int foundCount = 0; + for (uint32 i = 0; i < 32; i++) + { + if (approved.games.get(i).proposer == proposer) + { + foundCount++; + } + } + EXPECT_EQ(foundCount, 2); +} + +// Proves the QST dividend rate fix: QST holders should receive +// QUSINO_QST_HOLDERS_DIVIDENDS_PERCENT (30%) of epochRevenue each epoch. This used +// to fail two different ways: first an erroneous extra factor of 10 in a +// now-removed "per-share rate" denominator (QUSINO_SUPPLY_OF_QST * 1000 instead of +// * 100) capped holders at 3% instead of 30%; fixing that denominator alone still +// paid out exactly 0, because dividing the (tens-of-millions-of-Qu) dividend pool by +// QUSINO_SUPPLY_OF_QST (1.2 billion shares) to get a shared per-share rate produces +// a fraction under 1 Qu, which integer division truncates straight to 0 regardless +// of the *1000-vs-*100 denominator. The real fix restructured the payout to +// multiply epochSnapshot * percent * each possessor's own share count together +// before dividing once, computed per possessor inside the loop instead of as a +// shared rate outside it -- see END_EPOCH's QST payout comment in Qusino.h. +TEST(ContractQUSINO, END_EPOCH_QSTDividendRateIsCorrect) +{ + ContractTestingQUSINO QUSINO; + + id qstIssuer = QUSINO_QSTIssuer; + uint64 qstAssetName = 5526353; + uint64 totalShares = QUSINO_SUPPLY_OF_QST; + increaseEnergy(qstIssuer, QUSINO_ISSUE_ASSET_FEE); + EXPECT_EQ(QUSINO.issueAsset(qstIssuer, qstAssetName, totalShares), totalShares); + // qstIssuer now holds 100% of QUSINO_SUPPLY_OF_QST -- the whole QST dividend + // pool for the epoch should land on them alone. + + // Get a known, deterministic contribution to epochRevenue via submitGame's fee + // split (a passed proposal wouldn't add anything else to epochRevenue either -- + // approving no longer triggers any QSC conversion, see END_EPOCH -- but failing + // it keeps this test's setup obviously isolated to just the QST rate either way). + id proposer = QUSINO_testUser1; + id voter = QUSINO_testUser2; + Array URI = createURI("https://example.com/qst-dividend-test"); + increaseEnergy(proposer, QUSINO_GAME_SUBMIT_FEE); + EXPECT_EQ(QUSINO.submitGame(proposer, URI, QUSINO_GAME_SUBMIT_FEE).returnCode, QUSINO_SUCCESS); + uint64 gameIndex = 1; + + increaseEnergy(voter, QUSINO_VOTE_FEE * QUSINO_STAR_PRICE * 100); + EXPECT_EQ(QUSINO.earnSTAR(voter, QUSINO_VOTE_FEE, QUSINO_VOTE_FEE * QUSINO_STAR_PRICE * 100).returnCode, QUSINO_SUCCESS); + EXPECT_EQ(QUSINO.voteInGameProposal(voter, URI, gameIndex, 2, 0).returnCode, QUSINO_SUCCESS); + + uint64 epochRevenueBeforeSplit = QUSINO.getSCInfo().epochRevenue; + ASSERT_GT(epochRevenueBeforeSplit, 0ULL); + + long long qstIssuerQuBefore = getBalance(qstIssuer); + + QUSINO.endEpoch(); + ++system.epoch; + + long long qstIssuerQuAfter = getBalance(qstIssuer); + uint64 expectedQSTDividend = epochRevenueBeforeSplit * (uint64)QUSINO_QST_HOLDERS_DIVIDENDS_PERCENT / 100ULL; + ASSERT_GT(expectedQSTDividend, 0ULL); + EXPECT_EQ((uint64)(qstIssuerQuAfter - qstIssuerQuBefore), expectedQSTDividend); } TEST(ContractQUSINO, depositBonus_Success) @@ -795,3 +1381,488 @@ TEST(ContractQUSINO, dailyClaimBonus_InsufficientBonusAmount) QUSINO::dailyClaimBonus_output output = QUSINO.dailyClaimBonus(user, 0); EXPECT_EQ(output.returnCode, QUSINO_INSUFFICIENT_BONUS_AMOUNT); } + +// --------------------------------------------------------------------------- +// RNG Result Bank (refillRandomBank) + Coin Flip +// +// Coin Flip is funded entirely out of bonusAmount, QUSINO's Qu game bankroll (see +// the comment above QUSINO_GAME_BANKROLL_CAP in Qusino.h): the game owner funds it +// via depositBonus (QUSINO.fundBonusAmount() below), refillRandomBank's RANDOM fee +// is paid from it, and QSC bet payouts/losses flow through it. STAR bets never +// touch it at all. +// --------------------------------------------------------------------------- + +TEST(ContractQUSINO, refillRandomBank_FailsWhenNoEntropyAvailable) +{ + ContractTestingQUSINO QUSINO; + + // Fund the bankroll so it *could* pay RANDOM's fee, but never seed any entropy. + QUSINO.fundBonusAmount(1000000000ULL); + // The caller identity must have a spectrum entry for invokeUserProcedure to route + // the call at all, even though refillRandomBank itself takes no payment from it. + increaseEnergy(QUSINO_testUser1, 1); + + QUSINO::refillRandomBank_output output = QUSINO.refillRandomBank(QUSINO_testUser1); + EXPECT_EQ(output.returnCode, QUSINO_RNG_REFILL_FAILED); + EXPECT_EQ(output.valuesAdded, 0u); + + QUSINO::getRandomBankStatus_output status = QUSINO.getRandomBankStatus(); + EXPECT_FALSE(status.poolInitialized); + EXPECT_EQ(status.reserveFilled, 0u); +} + +TEST(ContractQUSINO, refillRandomBank_FailsWhenBonusAmountInsufficient) +{ + ContractTestingQUSINO QUSINO; + + // Entropy is available, but the game bankroll was never funded -- RANDOM must + // never even be called. + increaseEnergy(QUSINO_testUser1, 1); + QUSINO.seedRandomEntropy(0xA11CE); + + QUSINO::refillRandomBank_output output = QUSINO.refillRandomBank(QUSINO_testUser1); + EXPECT_EQ(output.returnCode, QUSINO_INSUFFICIENT_BONUS_AMOUNT); + EXPECT_EQ(output.valuesAdded, 0u); + + QUSINO::getRandomBankStatus_output status = QUSINO.getRandomBankStatus(); + EXPECT_FALSE(status.poolInitialized); + EXPECT_EQ(status.reserveFilled, 0u); + EXPECT_EQ(QUSINO.getSCInfo().bonusAmount, 0u); +} + +TEST(ContractQUSINO, refillRandomBank_SucceedsAndPrimesCoinFlipPool) +{ + ContractTestingQUSINO QUSINO; + + QUSINO.fundBonusAmount(1000000000ULL); + increaseEnergy(QUSINO_testUser1, 1); + QUSINO.seedRandomEntropy(0xA11CE); + uint64 bonusBefore = QUSINO.getSCInfo().bonusAmount; + + QUSINO::refillRandomBank_output output = QUSINO.refillRandomBank(QUSINO_testUser1); + EXPECT_EQ(output.returnCode, QUSINO_SUCCESS); + EXPECT_EQ(output.valuesAdded, QUSINO_RNG_RESERVE_SIZE); + + // The very first refill also bootstraps game 0's (Coin Flip's) pool straight out of + // the reserve it just filled, so reserveFilled should be RESERVE_SIZE - POOL_SIZE. + QUSINO::getRandomBankStatus_output status = QUSINO.getRandomBankStatus(); + EXPECT_TRUE(status.poolInitialized); + EXPECT_EQ(status.reserveFilled, QUSINO_RNG_RESERVE_SIZE - QUSINO_RNG_POOL_SIZE); + EXPECT_EQ(status.lastRefillTick, system.tick); + + // The entropy fee actually spent is debited from the game bankroll. + EXPECT_EQ(QUSINO.getSCInfo().bonusAmount, bonusBefore - QUSINO_RNG_ENTROPY_FEE); +} + +TEST(ContractQUSINO, refillRandomBank_TooSoonRejectedOnSameTick) +{ + ContractTestingQUSINO QUSINO; + + QUSINO.fundBonusAmount(1000000000ULL); + increaseEnergy(QUSINO_testUser1, 1); + QUSINO.seedRandomEntropy(0xA11CE); + + QUSINO::refillRandomBank_output first = QUSINO.refillRandomBank(QUSINO_testUser1); + EXPECT_EQ(first.returnCode, QUSINO_SUCCESS); + + // Rate limit applies regardless of whether entropy is available for the retry -- + // the check happens before RANDOM is ever called again. + QUSINO::refillRandomBank_output second = QUSINO.refillRandomBank(QUSINO_testUser1); + EXPECT_EQ(second.returnCode, QUSINO_RNG_REFILL_TOO_SOON); +} + +TEST(ContractQUSINO, refillRandomBank_BlockedWhileReserveNotEmptyEvenPastTickGap) +{ + ContractTestingQUSINO QUSINO; + + QUSINO.fundBonusAmount(1000000000ULL); + increaseEnergy(QUSINO_testUser1, 1); + QUSINO.seedRandomEntropy(0xA11CE); + + QUSINO::refillRandomBank_output first = QUSINO.refillRandomBank(QUSINO_testUser1); + ASSERT_EQ(first.returnCode, QUSINO_SUCCESS); + uint32 reserveAfterFirst = QUSINO.getRandomBankStatus().reserveFilled; + uint32 tickAfterFirst = QUSINO.getRandomBankStatus().lastRefillTick; + ASSERT_GT(reserveAfterFirst, 0u); + + // Advance well past QUSINO_RNG_MIN_REFILL_TICK_GAP and make fresh entropy available + // again -- under the old (buggy) rate-limit-only guard this would succeed and + // silently overwrite hundreds of still-unspent reserve entries, wasting the RANDOM + // fee already paid for them. It must now be rejected purely because the reserve + // isn't empty yet, tick gap notwithstanding. + QUSINO.setTick(QUSINO.getTick() + QUSINO_RNG_MIN_REFILL_TICK_GAP + 10); + QUSINO.seedRandomEntropy(0xF00D); + + QUSINO::refillRandomBank_output second = QUSINO.refillRandomBank(QUSINO_testUser1); + EXPECT_EQ(second.returnCode, QUSINO_RNG_REFILL_TOO_SOON); + EXPECT_EQ(second.valuesAdded, 0u); + + // No wasted purchase: reserve and last-refill-tick bookkeeping must be untouched. + QUSINO::getRandomBankStatus_output status = QUSINO.getRandomBankStatus(); + EXPECT_EQ(status.reserveFilled, reserveAfterFirst); + EXPECT_EQ(status.lastRefillTick, tickAfterFirst); +} + +TEST(ContractQUSINO, refillRandomBank_RefundsAnyAttachedInvocationReward) +{ + ContractTestingQUSINO QUSINO; + + QUSINO.fundBonusAmount(1000000000ULL); + QUSINO.seedRandomEntropy(0xA11CE); + + id caller = QUSINO_testUser1; + sint64 attachedReward = 12345; + increaseEnergy(caller, attachedReward); + long long balanceBefore = getBalance(caller); + + QUSINO::refillRandomBank_output output = QUSINO.refillRandomBank(caller, attachedReward); + EXPECT_EQ(output.returnCode, QUSINO_SUCCESS); + // refillRandomBank takes no payment from the caller -- QUSINO funds RANDOM's fee + // out of the game bankroll, so any attached reward must come straight back. + EXPECT_EQ(getBalance(caller), balanceBefore); +} + +TEST(ContractQUSINO, coinFlip_NotReadyBeforeBankPrimed) +{ + ContractTestingQUSINO QUSINO; + + id user = QUSINO_testUser1; + increaseEnergy(user, 1); + + QUSINO::coinFlip_output output = QUSINO.coinFlip(user, 0, QUSINO_ASSET_TYPE_QSC, QUSINO_COINFLIP_MIN_BET); + EXPECT_EQ(output.returnCode, QUSINO_RNG_NOT_READY); + EXPECT_EQ(output.payout, 0u); +} + +TEST(ContractQUSINO, coinFlip_InvalidGuessRejected) +{ + ContractTestingQUSINO QUSINO; + + QUSINO.fundBonusAmount(1000000000ULL); + increaseEnergy(QUSINO_testUser1, 1); + QUSINO.seedRandomEntropy(0xA11CE); + ASSERT_EQ(QUSINO.refillRandomBank(QUSINO_testUser1).returnCode, QUSINO_SUCCESS); + + id user = QUSINO_testUser2; + increaseEnergy(user, 1); + + QUSINO::coinFlip_output output = QUSINO.coinFlip(user, 2, QUSINO_ASSET_TYPE_QSC, QUSINO_COINFLIP_MIN_BET); // only 0/1 valid + EXPECT_EQ(output.returnCode, QUSINO_INVALID_INPUT); +} + +TEST(ContractQUSINO, coinFlip_InvalidAssetTypeRejected) +{ + ContractTestingQUSINO QUSINO; + + QUSINO.fundBonusAmount(1000000000ULL); + increaseEnergy(QUSINO_testUser1, 1); + QUSINO.seedRandomEntropy(0xA11CE); + ASSERT_EQ(QUSINO.refillRandomBank(QUSINO_testUser1).returnCode, QUSINO_SUCCESS); + + id user = QUSINO_testUser2; + increaseEnergy(user, 1); + + // Only QSC and STAR are valid Coin Flip bet assets -- raw Qu and QST are not. + QUSINO::coinFlip_output output = QUSINO.coinFlip(user, 0, QUSINO_ASSET_TYPE_QUBIC, QUSINO_COINFLIP_MIN_BET); + EXPECT_EQ(output.returnCode, QUSINO_WRONG_ASSET_TYPE); +} + +TEST(ContractQUSINO, coinFlip_BelowMinBetRejected) +{ + ContractTestingQUSINO QUSINO; + + QUSINO.fundBonusAmount(1000000000ULL); + increaseEnergy(QUSINO_testUser1, 1); + QUSINO.seedRandomEntropy(0xA11CE); + ASSERT_EQ(QUSINO.refillRandomBank(QUSINO_testUser1).returnCode, QUSINO_SUCCESS); + + id user = QUSINO_testUser2; + increaseEnergy(user, 1); + + QUSINO::coinFlip_output output = QUSINO.coinFlip(user, 0, QUSINO_ASSET_TYPE_QSC, QUSINO_COINFLIP_MIN_BET - 1); + EXPECT_EQ(output.returnCode, QUSINO_INSUFFICIENT_FUNDS); +} + +TEST(ContractQUSINO, coinFlip_AboveMaxBetRejected) +{ + ContractTestingQUSINO QUSINO; + + QUSINO.fundBonusAmount(1000000000ULL); + increaseEnergy(QUSINO_testUser1, 1); + QUSINO.seedRandomEntropy(0xA11CE); + ASSERT_EQ(QUSINO.refillRandomBank(QUSINO_testUser1).returnCode, QUSINO_SUCCESS); + + id user = QUSINO_testUser2; + increaseEnergy(user, 1); + + // Rejected purely on amount, before ever checking the caller's own QSC + // balance (this user has none) -- same ordering as the min-bet gate. + QUSINO::coinFlip_output output = QUSINO.coinFlip(user, 0, QUSINO_ASSET_TYPE_QSC, QUSINO_COINFLIP_MAX_BET + 1); + EXPECT_EQ(output.returnCode, QUSINO_EXCEEDS_MAX_BET); + + // Exactly at the ceiling is still fine (rejected here for a different, + // expected reason: this user genuinely has no QSC to bet with). + QUSINO::coinFlip_output atMax = QUSINO.coinFlip(user, 0, QUSINO_ASSET_TYPE_QSC, QUSINO_COINFLIP_MAX_BET); + EXPECT_EQ(atMax.returnCode, QUSINO_INSUFFICIENT_QSC); + + // STAR is covered by the same asset-agnostic gate, not just QSC. + QUSINO::coinFlip_output starOutput = QUSINO.coinFlip(user, 0, QUSINO_ASSET_TYPE_STAR, QUSINO_COINFLIP_MAX_BET + 1); + EXPECT_EQ(starOutput.returnCode, QUSINO_EXCEEDS_MAX_BET); +} + +TEST(ContractQUSINO, coinFlip_InsufficientQscRejected) +{ + ContractTestingQUSINO QUSINO; + + QUSINO.fundBonusAmount(1000000000ULL); + increaseEnergy(QUSINO_testUser1, 1); + QUSINO.seedRandomEntropy(0xA11CE); + ASSERT_EQ(QUSINO.refillRandomBank(QUSINO_testUser1).returnCode, QUSINO_SUCCESS); + + id user = QUSINO_testUser2; + increaseEnergy(user, 1); // spectrum entry only, no QSC minted + + QUSINO::coinFlip_output output = QUSINO.coinFlip(user, 0, QUSINO_ASSET_TYPE_QSC, QUSINO_COINFLIP_MIN_BET); + EXPECT_EQ(output.returnCode, QUSINO_INSUFFICIENT_QSC); +} + +TEST(ContractQUSINO, coinFlip_InsufficientStarRejected) +{ + ContractTestingQUSINO QUSINO; + + QUSINO.fundBonusAmount(1000000000ULL); + increaseEnergy(QUSINO_testUser1, 1); + QUSINO.seedRandomEntropy(0xA11CE); + ASSERT_EQ(QUSINO.refillRandomBank(QUSINO_testUser1).returnCode, QUSINO_SUCCESS); + + id user = QUSINO_testUser2; + increaseEnergy(user, 1); // spectrum entry only, no STAR minted + + QUSINO::coinFlip_output output = QUSINO.coinFlip(user, 0, QUSINO_ASSET_TYPE_STAR, QUSINO_COINFLIP_MIN_BET); + EXPECT_EQ(output.returnCode, QUSINO_INSUFFICIENT_STAR); +} + +TEST(ContractQUSINO, coinFlip_InsufficientBonusAmountRejectsQscBet) +{ + ContractTestingQUSINO QUSINO; + + // Fund the bankroll just enough for the entropy fee -- leaving it at exactly zero, + // nowhere near enough to cover even the net liability a win on this bet would incur + // (let alone the payout's gross backing). + QUSINO.fundBonusAmount(QUSINO_RNG_ENTROPY_FEE); + increaseEnergy(QUSINO_testUser1, 1); + QUSINO.seedRandomEntropy(0xA11CE); + ASSERT_EQ(QUSINO.refillRandomBank(QUSINO_testUser1).returnCode, QUSINO_SUCCESS); + ASSERT_EQ(QUSINO.getSCInfo().bonusAmount, 0u); + + id user = QUSINO_testUser2; + QUSINO.giveUserQSC(user, QUSINO_COINFLIP_MIN_BET); + uint64 qscBefore = QUSINO.getUserAssetVolume(user).QSCAmount; + + QUSINO::coinFlip_output output = QUSINO.coinFlip(user, 0, QUSINO_ASSET_TYPE_QSC, QUSINO_COINFLIP_MIN_BET); + EXPECT_EQ(output.returnCode, QUSINO_INSUFFICIENT_BONUS_AMOUNT); + // Rejected bet must not touch the user's QSC at all. + EXPECT_EQ(QUSINO.getUserAssetVolume(user).QSCAmount, qscBefore); +} + +TEST(ContractQUSINO, coinFlip_QscSettlesConsistentlyAndUpdatesBank) +{ + ContractTestingQUSINO QUSINO; + + QUSINO.fundBonusAmount(1000000000ULL); + increaseEnergy(QUSINO_testUser1, 1); + QUSINO.seedRandomEntropy(0xA11CE); + ASSERT_EQ(QUSINO.refillRandomBank(QUSINO_testUser1).returnCode, QUSINO_SUCCESS); + + uint32 reserveBefore = QUSINO.getRandomBankStatus().reserveFilled; + uint64 epochRevenueBefore = QUSINO.getSCInfo().epochRevenue; + uint64 bonusBefore = QUSINO.getSCInfo().bonusAmount; + + id user = QUSINO_testUser2; + uint64 bet = QUSINO_COINFLIP_MIN_BET; + QUSINO.giveUserQSC(user, bet); + // Captured *after* minting the bet's QSC via giveUserQSC, so this reflects supply + // right before the wager itself -- not before the mint that funded it. + uint64 qscSupplyBefore = QUSINO.getSCInfo().QSCCirclatingSupply; + uint64 qscBefore = QUSINO.getUserAssetVolume(user).QSCAmount; + long long qubicBalanceBefore = getBalance(user); + + QUSINO::coinFlip_output output = QUSINO.coinFlip(user, 0, QUSINO_ASSET_TYPE_QSC, bet); + EXPECT_EQ(output.returnCode, QUSINO_SUCCESS); + EXPECT_LE(output.result, 1); + + // The consumed pool slot is immediately replenished from the reserve, so exactly + // one reserve entry is spent per flip regardless of win/lose. + EXPECT_EQ(QUSINO.getRandomBankStatus().reserveFilled, reserveBefore - 1); + + // coinFlip takes no invocationReward and never sends Qu directly (win or lose), so + // the caller's real Qu balance is never touched by playing. + EXPECT_EQ(getBalance(user), qubicBalanceBefore); + + uint64 qscRedemptionValueQu = bet * QUSINO_QSC_PRICE; + uint64 winAmountQu = qscRedemptionValueQu * QUSINO_COINFLIP_PAYOUT_PERCENT / 100; + if (output.won) + { + // A win credits new QSC back to the caller (redeemable for Qu later via + // redemptionQSCToQubic) instead of paying Qu directly -- the wager itself + // still left circulation up front, so the net QSC change is payout - bet. + uint64 expectedQscPayout = winAmountQu / QUSINO_QSC_PRICE; + EXPECT_EQ(output.payout, expectedQscPayout); + EXPECT_EQ(QUSINO.getUserAssetVolume(user).QSCAmount, qscBefore - bet + expectedQscPayout); + EXPECT_EQ(QUSINO.getSCInfo().QSCCirclatingSupply, qscSupplyBefore - bet + expectedQscPayout); + // The bankroll moves by exactly the payout's Qu backing net of the stake's own + // freed backing (the stake's burn above already freed qscRedemptionValueQu of + // backing, so only the shortfall needs to come out of bonusAmount). + EXPECT_EQ(QUSINO.getSCInfo().bonusAmount, bonusBefore + qscRedemptionValueQu - expectedQscPayout * QUSINO_QSC_PRICE); + EXPECT_EQ(QUSINO.getSCInfo().epochRevenue, epochRevenueBefore); + } + else + { + EXPECT_EQ(output.payout, 0u); + EXPECT_EQ(QUSINO.getUserAssetVolume(user).QSCAmount, qscBefore - bet); + EXPECT_EQ(QUSINO.getSCInfo().QSCCirclatingSupply, qscSupplyBefore - bet); + // The redeemed QSC's Qu value tops up the game bankroll instead of paying out. + EXPECT_EQ(QUSINO.getSCInfo().bonusAmount, bonusBefore + qscRedemptionValueQu); + EXPECT_EQ(QUSINO.getSCInfo().epochRevenue, epochRevenueBefore); + } +} + +TEST(ContractQUSINO, coinFlip_NetGateAllowsBetGrossGateWouldReject) +{ + ContractTestingQUSINO QUSINO; + + id user = QUSINO_testUser2; + uint64 bet = 1000ULL; + uint64 qscRedemptionValueQu = bet * QUSINO_QSC_PRICE; + uint64 winAmountQu = qscRedemptionValueQu * QUSINO_COINFLIP_PAYOUT_PERCENT / 100; // gross backing: 196000 + uint64 qscPayout = winAmountQu / QUSINO_QSC_PRICE; // 1960 + uint64 netDebitQu = qscPayout * QUSINO_QSC_PRICE - qscRedemptionValueQu; // net liability: 96000 + + // Fund the bankroll to a level strictly between the net liability this bet would + // actually incur (netDebitQu) and the payout's full gross backing (winAmountQu) -- a + // bet the pool can genuinely afford, but only if gated on the net figure. The old + // gross-based gate would have wrongly rejected this exact bet. + uint64 targetBonus = (netDebitQu + winAmountQu) / 2; + ASSERT_GT(targetBonus, netDebitQu); + ASSERT_LT(targetBonus, winAmountQu); + + QUSINO.fundBonusAmount(QUSINO_RNG_ENTROPY_FEE + targetBonus); + increaseEnergy(QUSINO_testUser1, 1); + QUSINO.seedRandomEntropy(0xA11CE); + ASSERT_EQ(QUSINO.refillRandomBank(QUSINO_testUser1).returnCode, QUSINO_SUCCESS); + ASSERT_EQ(QUSINO.getSCInfo().bonusAmount, targetBonus); + + QUSINO.giveUserQSC(user, bet); + uint64 bonusBefore = QUSINO.getSCInfo().bonusAmount; + uint64 qscBefore = QUSINO.getUserAssetVolume(user).QSCAmount; + + QUSINO::coinFlip_output output = QUSINO.coinFlip(user, 0, QUSINO_ASSET_TYPE_QSC, bet); + + // The point of this test: bonusAmount sits below the gross payout backing but above + // the net liability, and the bet must still be accepted either way the coin lands. + EXPECT_EQ(output.returnCode, QUSINO_SUCCESS); + + if (output.won) + { + EXPECT_EQ(output.payout, qscPayout); + EXPECT_EQ(QUSINO.getUserAssetVolume(user).QSCAmount, qscBefore - bet + qscPayout); + EXPECT_EQ(QUSINO.getSCInfo().bonusAmount, bonusBefore - netDebitQu); + } + else + { + EXPECT_EQ(output.payout, 0u); + EXPECT_EQ(QUSINO.getUserAssetVolume(user).QSCAmount, qscBefore - bet); + EXPECT_EQ(QUSINO.getSCInfo().bonusAmount, bonusBefore + qscRedemptionValueQu); + } +} + +TEST(ContractQUSINO, coinFlip_QscConsecutiveFlipsAdvancePoolNonce) +{ + ContractTestingQUSINO QUSINO; + + QUSINO.fundBonusAmount(1000000000ULL); + increaseEnergy(QUSINO_testUser1, 1); + QUSINO.seedRandomEntropy(0xA11CE); + ASSERT_EQ(QUSINO.refillRandomBank(QUSINO_testUser1).returnCode, QUSINO_SUCCESS); + + id user = QUSINO_testUser2; + uint64 bet = QUSINO_COINFLIP_MIN_BET; + QUSINO.giveUserQSC(user, bet * 5); + + // Enough reserve and QSC for several flips; just confirm every one of them settles + // cleanly and the bank keeps accounting correctly call over call (no crash/ + // duplicate-spend of the same reserve slot). + for (int i = 0; i < 5; i++) + { + QUSINO::coinFlip_output output = QUSINO.coinFlip(user, (uint8)(i % 2), QUSINO_ASSET_TYPE_QSC, bet); + EXPECT_EQ(output.returnCode, QUSINO_SUCCESS); + } + + QUSINO::getRandomBankStatus_output status = QUSINO.getRandomBankStatus(); + EXPECT_EQ(status.reserveFilled, (QUSINO_RNG_RESERVE_SIZE - QUSINO_RNG_POOL_SIZE) - 5); +} + +TEST(ContractQUSINO, coinFlip_StarBetMintsOrBurnsDirectlyNoBonusAmount) +{ + ContractTestingQUSINO QUSINO; + + increaseEnergy(QUSINO_testUser1, 1); + QUSINO.seedRandomEntropy(0xA11CE); + // refillRandomBank still needs the bankroll to buy entropy in the first place, so + // fund it for only that one-time bootstrap, then confirm it's fully drained back to + // 0 -- proving the STAR bet below doesn't need or touch it at all. + QUSINO.fundBonusAmount(QUSINO_RNG_ENTROPY_FEE); + ASSERT_EQ(QUSINO.refillRandomBank(QUSINO_testUser1).returnCode, QUSINO_SUCCESS); + ASSERT_EQ(QUSINO.getSCInfo().bonusAmount, 0u); + + id user = QUSINO_testUser2; + uint64 bet = QUSINO_COINFLIP_MIN_BET; + QUSINO.giveUserQSC(user, bet); // earnSTAR mints STAR too (bet*100 units) + uint64 starBefore = QUSINO.getUserAssetVolume(user).STARAmount; + uint64 starSupplyBefore = QUSINO.getSCInfo().STARCirclatingSupply; + uint64 burntBefore = QUSINO.getSCInfo().burntSTAR; + long long qubicBefore = getBalance(user); + + QUSINO::coinFlip_output output = QUSINO.coinFlip(user, 0, QUSINO_ASSET_TYPE_STAR, bet); + EXPECT_EQ(output.returnCode, QUSINO_SUCCESS); + + // STAR bets never touch Qu or the game bankroll. + EXPECT_EQ(getBalance(user), qubicBefore); + EXPECT_EQ(QUSINO.getSCInfo().bonusAmount, 0u); + + if (output.won) + { + uint64 expectedPayout = bet * QUSINO_COINFLIP_PAYOUT_PERCENT / 100; + EXPECT_EQ(output.payout, expectedPayout); + // Net STAR change is the payout minted back on top of the wagered amount. + EXPECT_EQ(QUSINO.getUserAssetVolume(user).STARAmount, starBefore - bet + expectedPayout); + EXPECT_EQ(QUSINO.getSCInfo().STARCirclatingSupply, starSupplyBefore - bet + expectedPayout); + EXPECT_EQ(QUSINO.getSCInfo().burntSTAR, burntBefore); + } + else + { + EXPECT_EQ(output.payout, 0u); + EXPECT_EQ(QUSINO.getUserAssetVolume(user).STARAmount, starBefore - bet); + EXPECT_EQ(QUSINO.getSCInfo().STARCirclatingSupply, starSupplyBefore - bet); + EXPECT_EQ(QUSINO.getSCInfo().burntSTAR, burntBefore + bet); + } +} + +TEST(ContractQUSINO, depositBonus_CapsAtGameBankrollAndRoutesOverflowToEpochRevenue) +{ + ContractTestingQUSINO QUSINO; + + id owner = QUSINO_testUser1; + uint64 firstDeposit = QUSINO_GAME_BANKROLL_CAP - 100; + increaseEnergy(owner, (sint64)firstDeposit); + ASSERT_EQ(QUSINO.depositBonus(owner, firstDeposit).returnCode, QUSINO_SUCCESS); + EXPECT_EQ(QUSINO.getSCInfo().bonusAmount, firstDeposit); + + uint64 epochRevenueBefore = QUSINO.getSCInfo().epochRevenue; + uint64 secondDeposit = 1000; // pushes bonusAmount 900 past the cap + increaseEnergy(owner, (sint64)secondDeposit); + ASSERT_EQ(QUSINO.depositBonus(owner, secondDeposit).returnCode, QUSINO_SUCCESS); + + EXPECT_EQ(QUSINO.getSCInfo().bonusAmount, QUSINO_GAME_BANKROLL_CAP); + EXPECT_EQ(QUSINO.getSCInfo().epochRevenue, epochRevenueBefore + 900); +}