From 59ccb62b214174dc755d2b3c396239f246dea5a5 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Thu, 30 Jul 2026 21:07:37 +0900 Subject: [PATCH 1/4] feat: Qtreat SC --- src/contract_core/contract_def.h | 12 + src/contracts/QTREAT.h | 1767 ++++++++++++++++++++++++++++++ test/contract_qtreat.cpp | 1136 +++++++++++++++++++ 3 files changed, 2915 insertions(+) create mode 100644 src/contracts/QTREAT.h create mode 100644 test/contract_qtreat.cpp diff --git a/src/contract_core/contract_def.h b/src/contract_core/contract_def.h index 2aa6e074..ba0751e2 100644 --- a/src/contract_core/contract_def.h +++ b/src/contract_core/contract_def.h @@ -301,6 +301,16 @@ #define CONTRACT_STATE2_TYPE WOLFPACK2 #include "contracts/GGWP.h" +#undef CONTRACT_INDEX +#undef CONTRACT_STATE_TYPE +#undef CONTRACT_STATE2_TYPE + +#define QTREAT_CONTRACT_INDEX 29 +#define CONTRACT_INDEX QTREAT_CONTRACT_INDEX +#define CONTRACT_STATE_TYPE QTREAT +#define CONTRACT_STATE2_TYPE QTREAT2 +#include "contracts/QTREAT.h" + // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES @@ -416,6 +426,7 @@ constexpr struct ContractDescription {"QUSINO", 208, 10000, sizeof(QUSINO::StateData)}, // proposal in epoch 206, IPO in 207, construction and first use in 208 {"ESCROW", 210, 10000, sizeof(ESCROW::StateData)}, // proposal in epoch 208, IPO in 209, construction and first use in 210 {"GGWP", 218, 10000, sizeof(WOLFPACK::StateData)}, // proposal in epoch 216, IPO in 217, construction and first use in 218 + {"QTREAT", 222, 10000, sizeof(QTREAT::StateData)}, // proposal in epoch 220, IPO in 221, construction and first use in 222 // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES {"TESTEXA", 138, 10000, sizeof(TESTEXA::StateData)}, @@ -548,6 +559,7 @@ static void initializeContracts() REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QUSINO); REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(ESCROW); REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(WOLFPACK); + REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QTREAT); // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(TESTEXA); diff --git a/src/contracts/QTREAT.h b/src/contracts/QTREAT.h new file mode 100644 index 00000000..10f13c11 --- /dev/null +++ b/src/contracts/QTREAT.h @@ -0,0 +1,1767 @@ +using namespace QPI; + +constexpr uint64 QTREAT_MAX_HOLDERS = 131072; +constexpr uint64 QTREAT_MAX_STAKERS = 65536; +constexpr uint64 QTREAT_MAX_ASSETS = 1024; +constexpr uint64 QTREAT_MIN_ELIGIBLE_BAL = 1; + +constexpr uint64 QTREAT_TOKEN_ASSETNAME = 92639312630865ULL; // "QTREAT" +constexpr uint64 QTREAT_QDOGE_ASSETNAME = 297549120593ULL; // "QDOGE" + +constexpr uint64 QTREAT_MIN_STAKE = 10000000ULL; +constexpr uint64 QTREAT_STAKER_REWARD_PER_EPOCH = 20000000ULL; +constexpr uint64 QTREAT_PHASE_EPOCHS = 52; +constexpr uint64 QTREAT_NUM_PHASES = 1; +constexpr uint64 QTREAT_TOTAL_REWARD_EPOCHS = QTREAT_PHASE_EPOCHS * QTREAT_NUM_PHASES; +constexpr uint64 QTREAT_UNSTAKE_DELAY_EPOCHS = 2; + +constexpr uint64 QTREAT_PROGRESSIVE_MIN_STEP = 1000000ULL; +constexpr uint64 QTREAT_PROGRESSIVE_BONUS_PERMILLE = 25; +constexpr uint64 QTREAT_PROGRESSIVE_MAX_STREAK = 20; +constexpr uint64 QTREAT_PROGRESSIVE_START_DELAY_EPOCHS = 4; + +constexpr uint64 QTREAT_BONUS_THRESHOLD = 50000000ULL; +constexpr uint64 QTREAT_BONUS_INTERVAL_EPOCHS = 12; +constexpr uint64 QTREAT_BONUS_MAX_PER_WALLET = 4; + +constexpr uint64 QTREAT_RAFFLE_EPOCHS = 52; +constexpr uint16 QTREAT_RAFFLE_ENTROPY_BITS = 256; +constexpr uint8 QTREAT_RAFFLE_COLLATERAL_TIER = 0; +constexpr uint64 QTREAT_RAFFLE_ENTROPY_FEE = RANDOM_BITFEE * QTREAT_RAFFLE_ENTROPY_BITS; + +constexpr uint64 QTREAT_ASIC_CATALOG_CAPACITY = 512; +constexpr uint64 QTREAT_ASIC_PARTS_TOTAL = 400; +constexpr uint64 QTREAT_MAX_ASIC_RIGS = 128; +constexpr uint64 QTREAT_MAX_TOTAL_ASICS = 100; +constexpr uint64 QTREAT_MINING_REWARD_DEFAULT = 20000000ULL; +constexpr uint64 QTREAT_MINING_REWARD_MAX = 100000000ULL; +static_assert(QTREAT_MINING_REWARD_DEFAULT <= QTREAT_MINING_REWARD_MAX); +constexpr uint64 QTREAT_RARITY_POINTS_COMMON = 1; +constexpr uint64 QTREAT_RARITY_POINTS_UNCOMMON = 2; +constexpr uint64 QTREAT_RARITY_POINTS_RARE = 3; +constexpr uint64 QTREAT_RARITY_POINTS_EPIC = 5; +constexpr uint64 QTREAT_RARITY_POINTS_LEGENDARY = 8; +constexpr uint64 QTREAT_MAX_RARITY = 4; +constexpr uint64 QTREAT_RARITY_SUPPLY_COMMON = 40; +constexpr uint64 QTREAT_RARITY_SUPPLY_UNCOMMON = 30; +constexpr uint64 QTREAT_RARITY_SUPPLY_RARE = 18; +constexpr uint64 QTREAT_RARITY_SUPPLY_EPIC = 10; +constexpr uint64 QTREAT_RARITY_SUPPLY_LEGENDARY = 2; +static_assert(QTREAT_RARITY_SUPPLY_COMMON + QTREAT_RARITY_SUPPLY_UNCOMMON + QTREAT_RARITY_SUPPLY_RARE + + QTREAT_RARITY_SUPPLY_EPIC + QTREAT_RARITY_SUPPLY_LEGENDARY == 100); +static_assert(QTREAT_MAX_TOTAL_ASICS == 100); + +constexpr uint64 QTREAT_DRIP_EPOCHS = 52; +constexpr uint64 QTREAT_DRIP_QDOGE_COMMON = 10000; +constexpr uint64 QTREAT_DRIP_QDOGE_UNCOMMON = 20000; +constexpr uint64 QTREAT_DRIP_QDOGE_RARE = 30000; +constexpr uint64 QTREAT_DRIP_QDOGE_EPIC = 50000; +constexpr uint64 QTREAT_DRIP_QDOGE_LEGENDARY = 100000; + +constexpr uint64 QTREAT_DIVIDEND_SHAREHOLDER_PERMILLE = 50; +static_assert(QTREAT_DIVIDEND_SHAREHOLDER_PERMILLE < 1000); + +constexpr uint64 QTREAT_MAX_NFT_HOLDERS = 1024; +constexpr uint64 QTREAT_MAX_DIVIDEND_NFTS = 256; + +constexpr uint64 QTREAT_MAX_EXCLUDE_ADDRESSES = 4; + +constexpr uint16 QTREAT_QX_CONTRACT_INDEX = 1; +constexpr sint64 QTREAT_QX_TRANSFER_FEE = 100LL; + +constexpr uint32 QTREAT_OK = 0; +constexpr uint32 QTREAT_ERR_ACCESS_DENIED = 1; +constexpr uint32 QTREAT_ERR_ZERO_AMOUNT = 2; +constexpr uint32 QTREAT_ERR_INSUFFICIENT_STAKE = 3; +constexpr uint32 QTREAT_ERR_UNSTAKE_PENDING = 4; +constexpr uint32 QTREAT_ERR_UNSTAKE_NOT_READY = 5; +constexpr uint32 QTREAT_ERR_NOT_STAKER = 6; +constexpr uint32 QTREAT_ERR_BELOW_MIN_STAKE = 7; +constexpr uint32 QTREAT_ERR_ACQUIRE_FAILED = 8; +constexpr uint32 QTREAT_ERR_TRANSFER_FAILED = 9; +constexpr uint32 QTREAT_ERR_NO_PENDING_BONUS = 10; +constexpr uint32 QTREAT_ERR_INSUFFICIENT_FEE = 11; +constexpr uint32 QTREAT_ERR_INVALID_INPUT = 12; +constexpr uint32 QTREAT_ERR_PHASES_ENDED = 13; +constexpr uint32 QTREAT_ERR_BONUS_POOL_EMPTY = 14; +constexpr uint32 QTREAT_ERR_EXTERNAL_CALL = 15; + +struct QTREAT2 {}; + +struct QTREAT : public ContractBase +{ + struct StakerInfo + { + uint64 staked; + uint64 unstakeAmount; + uint64 unstakeEpoch; + uint64 bonusEpochs; + uint64 hwmHoldings; + uint64 lastStaked; + uint64 growthStreak; + uint64 bonusAwarded; + uint64 pendingBonus; + }; + + struct RaffleSeed + { + bit_4096 entropy; + id prevSpectrumDigest; + id prevUniverseDigest; + id prevComputerDigest; + uint64 epoch; + uint64 tick; + uint64 eligibleCount; + }; + + struct AsicRig + { + id owner; + uint32 partMotherboard; + uint32 partChip; + uint32 partPsu; + uint32 partFan; + uint64 weight; + uint64 active; + }; + + struct AssetKey + { + id issuer; + uint64 assetName; + bool operator==(const AssetKey& o) const { return issuer == o.issuer && assetName == o.assetName; } + bool operator!=(const AssetKey& o) const { return issuer != o.issuer || assetName != o.assetName; } + }; + + struct StateData + { + id adminAddress; + + Asset qtreatToken; + Asset qdogeToken; + + uint64 dividendFund; + uint64 totalDividendsDistributed; + + uint64 stakingFund; + uint64 totalStakingRewardsDistributed; + + HashMap beginBalances; + HashMap endBalances; + uint64 totalHoldersSnapshot; + + HashMap stakers; + uint64 totalStaked; + uint64 stakingStartEpoch; + + uint64 qtreatBonusPool; + + Array excludeAddresses; + + Array dividendNftIds; + uint64 dividendNftIdCount; + HashMap nftCounts; + uint64 totalNftCount; + + uint64 totalShareholderDividends; + + HashMap asicCatalog; + Array asicCatalogBuckets; + uint64 asicCatalogSize; + uint64 asicCatalogLocked; + HashMap asicUsedParts; + Array asicRigs; + uint64 asicRigHighWater; + uint64 totalAsicCount; + uint64 totalMiningWeight; + uint64 miningFund; + uint64 miningRewardRate; + uint64 totalMiningRewardsDistributed; + + uint64 dripQdogePool; + uint64 dripStartEpoch; + uint64 totalDripQdogeDistributed; + HashMap dripTally; + + uint64 totalBonusDelivered; + + uint64 totalRaffleAwarded; + id lastRaffleWinner; + uint64 lastRaffleEpoch; + + HashMap generalAssetBalances; + HashMap scDividendTracker; + }; + + struct DepositDividends_input {}; struct DepositDividends_output { uint32 returnCode; }; + struct DepositStakingFund_input {}; struct DepositStakingFund_output { uint32 returnCode; }; + + struct RequestUnstake_input { uint64 amount; }; struct RequestUnstake_output { uint32 returnCode; }; + struct RequestUnstake_locals { StakerInfo info; }; + struct FinalizeUnstake_input {}; struct FinalizeUnstake_output { uint32 returnCode; }; + struct FinalizeUnstake_locals { StakerInfo info; sint64 rel; }; + + struct ClaimQtreatBonus_input {}; struct ClaimQtreatBonus_output { uint32 returnCode; uint64 claimed; }; + struct ClaimQtreatBonus_locals { StakerInfo info; sint64 xfer; sint64 rel; }; + + struct DepositQtreatTokens_input { uint64 amount; }; struct DepositQtreatTokens_output { uint32 returnCode; }; + struct DepositQtreatTokens_locals { sint64 xfer; }; + + struct DepositGeneralAsset_input { Asset asset; uint64 amount; }; + struct DepositGeneralAsset_output { uint32 returnCode; }; + struct DepositGeneralAsset_locals { sint64 managed; sint64 xfer; AssetKey key; uint64 bal; }; + + struct RevokeGeneralAsset_input { Asset asset; uint64 amount; }; + struct RevokeGeneralAsset_output { uint32 returnCode; }; + struct RevokeGeneralAsset_locals { AssetKey key; uint64 bal; sint64 xfer; sint64 rel; }; + + struct ReleaseManagedShares_input { Asset asset; uint64 amount; }; + struct ReleaseManagedShares_output { uint32 returnCode; }; + struct ReleaseManagedShares_locals { sint64 managed; sint64 rel; StakerInfo info; uint64 accounted; }; + + struct SetExcludeAddress_input { uint64 slot; id address; }; + struct SetExcludeAddress_output { uint32 returnCode; }; + + struct DepositMiningFund_input {}; struct DepositMiningFund_output { uint32 returnCode; }; + + struct SetMiningRate_input { uint64 ratePerEpoch; }; + struct SetMiningRate_output { uint32 returnCode; }; + + struct DepositDripQdoge_input { uint64 amount; }; + struct DepositDripQdoge_output { uint32 returnCode; }; + struct DepositDripQdoge_locals { sint64 xfer; }; + + struct LoadAsicPart_input { uint32 nftId; uint8 category; uint8 rarity; }; + struct LoadAsicPart_output { uint32 returnCode; uint64 catalogSize; uint64 locked; }; + struct LoadAsicPart_locals { uint64 budget; uint64 dummy; uint64 partPoints; }; + + struct RegisterAsic_input { uint32 partMotherboard; uint32 partChip; uint32 partPsu; uint32 partFan; }; + struct RegisterAsic_output { uint32 returnCode; uint64 rigIndex; uint64 asicWeight; }; + struct RegisterAsic_locals + { + AsicRig rig; uint64 points; uint64 partPoints; uint64 packed; uint64 r; sint64 i; uint32 partId; uint64 dummy; + QBAY::getInfoOfNFTById_input qbayIn; + QBAY::getInfoOfNFTById_output qbayOut; + sint64 freeSlot; + }; + + struct UnregisterAsic_input { uint64 rigIndex; }; + struct UnregisterAsic_output { uint32 returnCode; }; + struct UnregisterAsic_locals { AsicRig rig; }; + + struct GetMinerInfo_input { id wallet; }; + struct GetMinerInfo_output { uint64 asicCount; uint64 weight; uint64 totalAsicCount; uint64 totalMiningWeight; uint64 miningFund; }; + struct GetMinerInfo_locals { AsicRig rig; sint64 i; }; + + struct GetAsicCatalogInfo_input { uint32 nftId; }; + struct GetAsicCatalogInfo_output { uint64 catalogSize; uint64 locked; uint64 isCataloged; uint64 category; uint64 rarity; uint64 usedByRigPlusOne; }; + struct GetAsicCatalogInfo_locals { uint64 packed; uint64 used; }; + + struct GetNftInfo_input { id wallet; }; + struct GetNftInfo_output { uint64 nftCount; uint64 totalNftCount; }; + struct GetNftInfo_locals { uint64 val; }; + + struct GetExcludeAddresses_input {}; + struct GetExcludeAddresses_output { Array addresses; }; + struct GetExcludeAddresses_locals { sint64 i; }; + + struct GetStakingInfo_input { id staker; }; + struct GetStakingInfo_output + { + uint64 staked; uint64 unstakeAmount; uint64 unstakeEpoch; + uint64 bonusEpochs; uint64 bonusAwarded; uint64 pendingBonus; + uint64 growthStreak; uint64 hwmHoldings; + uint64 totalStaked; uint64 stakingFund; uint32 isStaker; + }; + struct GetStakingInfo_locals { StakerInfo info; }; + + struct GetPhaseInfo_input {}; + struct GetPhaseInfo_output + { + uint64 stakingStartEpoch; + uint64 currentPhase; + uint64 epochsRemaining; + }; + struct GetPhaseInfo_locals { uint64 elapsed; }; + + struct GetRaffleInfo_input {}; + struct GetRaffleInfo_output + { + uint64 totalRaffleAwarded; + id lastRaffleWinner; + uint64 lastRaffleEpoch; + uint64 raffleEpochsRemaining; + }; + struct GetRaffleInfo_locals { uint64 elapsed; }; + + struct GetFunds_input {}; + struct GetFunds_output + { + uint64 dividendFund; uint64 stakingFund; uint64 qtreatBonusPool; + uint64 totalDividendsDistributed; uint64 totalStakingRewardsDistributed; + uint64 totalBonusDelivered; + uint64 totalShareholderDividends; + uint64 miningFund; + uint64 miningRewardRate; + uint64 totalMiningRewardsDistributed; + uint64 dripQdogePool; + uint64 dripStartEpoch; + uint64 totalDripQdogeDistributed; + }; + + PUBLIC_FUNCTION_WITH_LOCALS(GetStakingInfo) + { + output.isStaker = state.get().stakers.get(input.staker, locals.info) ? 1 : 0; + if (output.isStaker) + { + output.staked = locals.info.staked; + output.unstakeAmount = locals.info.unstakeAmount; + output.unstakeEpoch = locals.info.unstakeEpoch; + output.bonusEpochs = locals.info.bonusEpochs; + output.bonusAwarded = locals.info.bonusAwarded; + output.pendingBonus = locals.info.pendingBonus; + output.growthStreak = locals.info.growthStreak; + output.hwmHoldings = locals.info.hwmHoldings; + } + output.totalStaked = state.get().totalStaked; + output.stakingFund = state.get().stakingFund; + } + + PUBLIC_FUNCTION_WITH_LOCALS(GetPhaseInfo) + { + output.stakingStartEpoch = state.get().stakingStartEpoch; + output.currentPhase = 0; + output.epochsRemaining = 0; + if (state.get().stakingStartEpoch == 0) return; + locals.elapsed = qpi.epoch() - state.get().stakingStartEpoch; + if (locals.elapsed < QTREAT_TOTAL_REWARD_EPOCHS) + { + output.currentPhase = div(locals.elapsed, QTREAT_PHASE_EPOCHS) + 1; + output.epochsRemaining = QTREAT_TOTAL_REWARD_EPOCHS - locals.elapsed; + } + } + + PUBLIC_FUNCTION_WITH_LOCALS(GetRaffleInfo) + { + output.totalRaffleAwarded = state.get().totalRaffleAwarded; + output.lastRaffleWinner = state.get().lastRaffleWinner; + output.lastRaffleEpoch = state.get().lastRaffleEpoch; + output.raffleEpochsRemaining = 0; + if (state.get().stakingStartEpoch != 0) + { + locals.elapsed = qpi.epoch() - state.get().stakingStartEpoch; + if (locals.elapsed < QTREAT_RAFFLE_EPOCHS) + { + output.raffleEpochsRemaining = QTREAT_RAFFLE_EPOCHS - locals.elapsed; + } + } + } + + PUBLIC_FUNCTION_WITH_LOCALS(GetMinerInfo) + { + for (locals.i = 0; locals.i < (sint64)state.get().asicRigHighWater; locals.i++) + { + locals.rig = state.get().asicRigs.get(locals.i); + if (locals.rig.active == 0 || locals.rig.owner != input.wallet) continue; + output.asicCount = output.asicCount + 1; + output.weight = output.weight + locals.rig.weight; + } + output.totalAsicCount = state.get().totalAsicCount; + output.totalMiningWeight = state.get().totalMiningWeight; + output.miningFund = state.get().miningFund; + } + + PUBLIC_FUNCTION_WITH_LOCALS(GetAsicCatalogInfo) + { + output.catalogSize = state.get().asicCatalogSize; + output.locked = state.get().asicCatalogLocked; + locals.packed = 0; + output.isCataloged = state.get().asicCatalog.get((uint64)input.nftId, locals.packed) ? 1 : 0; + if (output.isCataloged) + { + output.category = div(locals.packed, 8ULL); + output.rarity = mod(locals.packed, 8ULL); + } + locals.used = 0; + state.get().asicUsedParts.get((uint64)input.nftId, locals.used); + output.usedByRigPlusOne = locals.used; + } + + PUBLIC_FUNCTION_WITH_LOCALS(GetNftInfo) + { + locals.val = 0; + state.get().nftCounts.get(input.wallet, locals.val); + output.nftCount = locals.val; + output.totalNftCount = state.get().totalNftCount; + } + + PUBLIC_FUNCTION_WITH_LOCALS(GetExcludeAddresses) + { + for (locals.i = 0; locals.i < (sint64)QTREAT_MAX_EXCLUDE_ADDRESSES; locals.i++) + { + output.addresses.set(locals.i, state.get().excludeAddresses.get(locals.i)); + } + } + + PUBLIC_FUNCTION(GetFunds) + { + output.dividendFund = state.get().dividendFund; + output.stakingFund = state.get().stakingFund; + output.qtreatBonusPool = state.get().qtreatBonusPool; + output.totalDividendsDistributed = state.get().totalDividendsDistributed; + output.totalStakingRewardsDistributed = state.get().totalStakingRewardsDistributed; + output.totalBonusDelivered = state.get().totalBonusDelivered; + output.totalShareholderDividends = state.get().totalShareholderDividends; + output.miningFund = state.get().miningFund; + output.miningRewardRate = state.get().miningRewardRate; + output.totalMiningRewardsDistributed = state.get().totalMiningRewardsDistributed; + output.dripQdogePool = state.get().dripQdogePool; + output.dripStartEpoch = state.get().dripStartEpoch; + output.totalDripQdogeDistributed = state.get().totalDripQdogeDistributed; + } + + PUBLIC_PROCEDURE(DepositDividends) + { + if (qpi.invocationReward() == 0) { output.returnCode = QTREAT_ERR_ZERO_AMOUNT; return; } + state.mut().dividendFund = sadd(state.get().dividendFund, (uint64)qpi.invocationReward()); + output.returnCode = QTREAT_OK; + } + + PUBLIC_PROCEDURE(DepositStakingFund) + { + if (qpi.invocationReward() == 0) { output.returnCode = QTREAT_ERR_ZERO_AMOUNT; return; } + state.mut().stakingFund = sadd(state.get().stakingFund, (uint64)qpi.invocationReward()); + output.returnCode = QTREAT_OK; + } + + PUBLIC_PROCEDURE_WITH_LOCALS(RequestUnstake) + { + if (!state.get().stakers.get(qpi.invocator(), locals.info) || locals.info.staked == 0) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QTREAT_ERR_NOT_STAKER; return; + } + if (input.amount == 0 || input.amount > locals.info.staked) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QTREAT_ERR_INSUFFICIENT_STAKE; return; + } + if (locals.info.unstakeAmount > 0) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QTREAT_ERR_UNSTAKE_PENDING; return; + } + if (input.amount < locals.info.staked && locals.info.staked - input.amount < QTREAT_MIN_STAKE) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QTREAT_ERR_BELOW_MIN_STAKE; return; + } + if (qpi.invocationReward() < QTREAT_QX_TRANSFER_FEE) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QTREAT_ERR_INSUFFICIENT_FEE; return; + } + if (qpi.invocationReward() > QTREAT_QX_TRANSFER_FEE) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward() - QTREAT_QX_TRANSFER_FEE); + } + + locals.info.staked -= input.amount; + locals.info.unstakeAmount = input.amount; + locals.info.unstakeEpoch = qpi.epoch(); + if (locals.info.staked == 0) + { + locals.info.bonusEpochs = 0; + locals.info.growthStreak = 0; + locals.info.lastStaked = 0; + } + state.mut().stakers.set(qpi.invocator(), locals.info); + state.mut().totalStaked = state.get().totalStaked - input.amount; + output.returnCode = QTREAT_OK; + } + + PUBLIC_PROCEDURE_WITH_LOCALS(FinalizeUnstake) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + if (!state.get().stakers.get(qpi.invocator(), locals.info) || locals.info.unstakeAmount == 0) + { + output.returnCode = QTREAT_ERR_NOT_STAKER; return; + } + if (qpi.epoch() < locals.info.unstakeEpoch + QTREAT_UNSTAKE_DELAY_EPOCHS) + { + output.returnCode = QTREAT_ERR_UNSTAKE_NOT_READY; return; + } + + locals.rel = qpi.releaseShares(state.get().qdogeToken, qpi.invocator(), qpi.invocator(), + (sint64)locals.info.unstakeAmount, QTREAT_QX_CONTRACT_INDEX, QTREAT_QX_CONTRACT_INDEX, + QTREAT_QX_TRANSFER_FEE); + if (locals.rel < 0) { output.returnCode = QTREAT_ERR_TRANSFER_FAILED; return; } + + locals.info.unstakeAmount = 0; + locals.info.unstakeEpoch = 0; + if (locals.info.staked == 0 && locals.info.pendingBonus == 0 + && locals.info.bonusAwarded == 0) + { + state.mut().stakers.removeByKey(qpi.invocator()); + } + else + { + state.mut().stakers.set(qpi.invocator(), locals.info); + } + output.returnCode = QTREAT_OK; + } + + PUBLIC_PROCEDURE_WITH_LOCALS(DepositQtreatTokens) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + if (qpi.invocator() != state.get().adminAddress) { output.returnCode = QTREAT_ERR_ACCESS_DENIED; return; } + if (input.amount == 0) { output.returnCode = QTREAT_ERR_ZERO_AMOUNT; return; } + locals.xfer = qpi.transferShareOwnershipAndPossession( + state.get().qtreatToken.assetName, state.get().qtreatToken.issuer, + qpi.invocator(), qpi.invocator(), (sint64)input.amount, SELF); + if (locals.xfer < 0) { output.returnCode = QTREAT_ERR_ACQUIRE_FAILED; return; } + state.mut().qtreatBonusPool = sadd(state.get().qtreatBonusPool, input.amount); + output.returnCode = QTREAT_OK; + } + + PUBLIC_PROCEDURE_WITH_LOCALS(ClaimQtreatBonus) + { + if (!state.get().stakers.get(qpi.invocator(), locals.info) || locals.info.pendingBonus == 0) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QTREAT_ERR_NO_PENDING_BONUS; return; + } + if (state.get().qtreatBonusPool < locals.info.pendingBonus) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QTREAT_ERR_BONUS_POOL_EMPTY; return; + } + + if (qpi.invocationReward() < QTREAT_QX_TRANSFER_FEE) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QTREAT_ERR_INSUFFICIENT_FEE; + return; + } + if (qpi.invocationReward() > QTREAT_QX_TRANSFER_FEE) + qpi.transfer(qpi.invocator(), qpi.invocationReward() - QTREAT_QX_TRANSFER_FEE); + + locals.xfer = qpi.transferShareOwnershipAndPossession( + state.get().qtreatToken.assetName, state.get().qtreatToken.issuer, + SELF, SELF, (sint64)locals.info.pendingBonus, qpi.invocator()); + if (locals.xfer < 0) { output.returnCode = QTREAT_ERR_TRANSFER_FAILED; return; } + + state.mut().qtreatBonusPool = state.get().qtreatBonusPool - locals.info.pendingBonus; + output.claimed = locals.info.pendingBonus; + locals.info.pendingBonus = 0; + state.mut().stakers.set(qpi.invocator(), locals.info); + + locals.rel = qpi.releaseShares(state.get().qtreatToken, qpi.invocator(), qpi.invocator(), + (sint64)output.claimed, QTREAT_QX_CONTRACT_INDEX, QTREAT_QX_CONTRACT_INDEX, + QTREAT_QX_TRANSFER_FEE); + output.returnCode = QTREAT_OK; + } + + PUBLIC_PROCEDURE_WITH_LOCALS(DepositGeneralAsset) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + if (qpi.invocator() != state.get().adminAddress) { output.returnCode = QTREAT_ERR_ACCESS_DENIED; return; } + if (input.amount == 0 || input.asset.issuer == NULL_ID) { output.returnCode = QTREAT_ERR_INVALID_INPUT; return; } + + locals.managed = qpi.numberOfShares(input.asset, + { qpi.invocator(), SELF_INDEX }, { qpi.invocator(), SELF_INDEX }); + if (locals.managed < (sint64)input.amount) { output.returnCode = QTREAT_ERR_ACQUIRE_FAILED; return; } + + locals.key.issuer = input.asset.issuer; + locals.key.assetName = input.asset.assetName; + if (!state.get().generalAssetBalances.contains(locals.key) + && state.get().generalAssetBalances.population() >= QTREAT_MAX_ASSETS) + { + output.returnCode = QTREAT_ERR_INVALID_INPUT; return; + } + + locals.xfer = qpi.transferShareOwnershipAndPossession( + input.asset.assetName, input.asset.issuer, + qpi.invocator(), qpi.invocator(), (sint64)input.amount, SELF); + if (locals.xfer < 0) { output.returnCode = QTREAT_ERR_TRANSFER_FAILED; return; } + + locals.bal = 0; + state.get().generalAssetBalances.get(locals.key, locals.bal); + state.mut().generalAssetBalances.set(locals.key, sadd(locals.bal, input.amount)); + output.returnCode = QTREAT_OK; + } + + PUBLIC_PROCEDURE_WITH_LOCALS(ReleaseManagedShares) + { + locals.managed = qpi.numberOfShares(input.asset, + { qpi.invocator(), SELF_INDEX }, { qpi.invocator(), SELF_INDEX }); + if (input.amount == 0 || locals.managed < (sint64)input.amount) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QTREAT_ERR_INVALID_INPUT; return; + } + if (input.asset.assetName == state.get().qdogeToken.assetName + && input.asset.issuer == state.get().qdogeToken.issuer) + { + locals.info.staked = 0; locals.info.unstakeAmount = 0; + state.get().stakers.get(qpi.invocator(), locals.info); + locals.accounted = sadd(locals.info.staked, locals.info.unstakeAmount); + if ((sint64)locals.accounted + (sint64)input.amount > locals.managed) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QTREAT_ERR_INVALID_INPUT; return; + } + } + if (qpi.invocationReward() < QTREAT_QX_TRANSFER_FEE) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QTREAT_ERR_INSUFFICIENT_FEE; return; + } + if (qpi.invocationReward() > QTREAT_QX_TRANSFER_FEE) + qpi.transfer(qpi.invocator(), qpi.invocationReward() - QTREAT_QX_TRANSFER_FEE); + + locals.rel = qpi.releaseShares(input.asset, qpi.invocator(), qpi.invocator(), + (sint64)input.amount, QTREAT_QX_CONTRACT_INDEX, QTREAT_QX_CONTRACT_INDEX, + QTREAT_QX_TRANSFER_FEE); + if (locals.rel < 0) { output.returnCode = QTREAT_ERR_TRANSFER_FAILED; return; } + output.returnCode = QTREAT_OK; + } + + PUBLIC_PROCEDURE(DepositMiningFund) + { + if (qpi.invocationReward() == 0) { output.returnCode = QTREAT_ERR_ZERO_AMOUNT; return; } + state.mut().miningFund = sadd(state.get().miningFund, (uint64)qpi.invocationReward()); + output.returnCode = QTREAT_OK; + } + + PUBLIC_PROCEDURE_WITH_LOCALS(LoadAsicPart) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + if (qpi.invocator() != state.get().adminAddress) { output.returnCode = QTREAT_ERR_ACCESS_DENIED; return; } + if (state.get().asicCatalogLocked != 0) { output.returnCode = QTREAT_ERR_INVALID_INPUT; return; } + if (input.category >= 4 || input.rarity > QTREAT_MAX_RARITY) { output.returnCode = QTREAT_ERR_INVALID_INPUT; return; } + if (state.get().asicCatalog.contains((uint64)input.nftId)) { output.returnCode = QTREAT_ERR_INVALID_INPUT; return; } + + locals.budget = QTREAT_RARITY_SUPPLY_COMMON; + if (input.rarity == 1) locals.budget = QTREAT_RARITY_SUPPLY_UNCOMMON; + if (input.rarity == 2) locals.budget = QTREAT_RARITY_SUPPLY_RARE; + if (input.rarity == 3) locals.budget = QTREAT_RARITY_SUPPLY_EPIC; + if (input.rarity == 4) locals.budget = QTREAT_RARITY_SUPPLY_LEGENDARY; + if (state.get().asicCatalogBuckets.get((sint64)input.category * 8 + (sint64)input.rarity) >= locals.budget) + { + output.returnCode = QTREAT_ERR_INVALID_INPUT; return; + } + + if (state.mut().asicCatalog.set((uint64)input.nftId, (uint64)input.category * 8 + (uint64)input.rarity) == NULL_INDEX) + { + output.returnCode = QTREAT_ERR_INVALID_INPUT; return; + } + state.mut().asicCatalogBuckets.set((sint64)input.category * 8 + (sint64)input.rarity, + state.get().asicCatalogBuckets.get((sint64)input.category * 8 + (sint64)input.rarity) + 1); + state.mut().asicCatalogSize = state.get().asicCatalogSize + 1; + if (state.get().asicCatalogSize == QTREAT_ASIC_PARTS_TOTAL) + { + state.mut().asicCatalogLocked = 1; + state.mut().dripStartEpoch = qpi.epoch(); + } + output.catalogSize = state.get().asicCatalogSize; + output.locked = state.get().asicCatalogLocked; + output.returnCode = QTREAT_OK; + } + + PUBLIC_PROCEDURE_WITH_LOCALS(RegisterAsic) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + if (state.get().asicCatalogLocked == 0) { output.returnCode = QTREAT_ERR_INVALID_INPUT; return; } + if (state.get().totalAsicCount >= QTREAT_MAX_TOTAL_ASICS) { output.returnCode = QTREAT_ERR_INVALID_INPUT; return; } + + locals.points = 0; + for (locals.i = 0; locals.i < 4; locals.i++) + { + if (locals.i == 0) locals.partId = input.partMotherboard; + if (locals.i == 1) locals.partId = input.partChip; + if (locals.i == 2) locals.partId = input.partPsu; + if (locals.i == 3) locals.partId = input.partFan; + + locals.packed = 0; + if (!state.get().asicCatalog.get((uint64)locals.partId, locals.packed) + || div(locals.packed, 8ULL) != (uint64)locals.i) + { + output.returnCode = QTREAT_ERR_INVALID_INPUT; return; + } + locals.dummy = 0; + if (state.get().asicUsedParts.get((uint64)locals.partId, locals.dummy)) + { + output.returnCode = QTREAT_ERR_INVALID_INPUT; return; + } + locals.qbayIn.NFTId = locals.partId; + CALL_OTHER_CONTRACT_FUNCTION(QBAY, getInfoOfNFTById, locals.qbayIn, locals.qbayOut); + if (interContractCallError != NoCallError) + { + output.returnCode = QTREAT_ERR_EXTERNAL_CALL; return; + } + if (locals.qbayOut.possessor != qpi.invocator()) + { + output.returnCode = QTREAT_ERR_ACCESS_DENIED; return; + } + + locals.r = mod(locals.packed, 8ULL); + locals.partPoints = QTREAT_RARITY_POINTS_COMMON; + if (locals.r == 1) locals.partPoints = QTREAT_RARITY_POINTS_UNCOMMON; + if (locals.r == 2) locals.partPoints = QTREAT_RARITY_POINTS_RARE; + if (locals.r == 3) locals.partPoints = QTREAT_RARITY_POINTS_EPIC; + if (locals.r == 4) locals.partPoints = QTREAT_RARITY_POINTS_LEGENDARY; + locals.points += locals.partPoints; + } + + locals.freeSlot = -1; + for (locals.i = 0; locals.i < (sint64)state.get().asicRigHighWater; locals.i++) + { + if (state.get().asicRigs.get(locals.i).active == 0) { locals.freeSlot = locals.i; break; } + } + if (locals.freeSlot < 0) + { + if (state.get().asicRigHighWater >= QTREAT_MAX_ASIC_RIGS) + { + output.returnCode = QTREAT_ERR_INVALID_INPUT; return; + } + locals.freeSlot = (sint64)state.get().asicRigHighWater; + state.mut().asicRigHighWater = state.get().asicRigHighWater + 1; + } + + locals.rig.owner = qpi.invocator(); + locals.rig.partMotherboard = input.partMotherboard; + locals.rig.partChip = input.partChip; + locals.rig.partPsu = input.partPsu; + locals.rig.partFan = input.partFan; + locals.rig.weight = locals.points; + locals.rig.active = 1; + state.mut().asicRigs.set(locals.freeSlot, locals.rig); + state.mut().asicUsedParts.set((uint64)input.partMotherboard, (uint64)locals.freeSlot + 1); + state.mut().asicUsedParts.set((uint64)input.partChip, (uint64)locals.freeSlot + 1); + state.mut().asicUsedParts.set((uint64)input.partPsu, (uint64)locals.freeSlot + 1); + state.mut().asicUsedParts.set((uint64)input.partFan, (uint64)locals.freeSlot + 1); + state.mut().totalAsicCount = state.get().totalAsicCount + 1; + state.mut().totalMiningWeight = sadd(state.get().totalMiningWeight, locals.points); + + output.rigIndex = (uint64)locals.freeSlot; + output.asicWeight = locals.points; + output.returnCode = QTREAT_OK; + } + + PUBLIC_PROCEDURE_WITH_LOCALS(UnregisterAsic) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + if (input.rigIndex >= state.get().asicRigHighWater) { output.returnCode = QTREAT_ERR_INVALID_INPUT; return; } + locals.rig = state.get().asicRigs.get((sint64)input.rigIndex); + if (locals.rig.active == 0 || locals.rig.owner != qpi.invocator()) + { + output.returnCode = QTREAT_ERR_ACCESS_DENIED; return; + } + state.mut().asicUsedParts.removeByKey((uint64)locals.rig.partMotherboard); + state.mut().asicUsedParts.removeByKey((uint64)locals.rig.partChip); + state.mut().asicUsedParts.removeByKey((uint64)locals.rig.partPsu); + state.mut().asicUsedParts.removeByKey((uint64)locals.rig.partFan); + state.mut().totalAsicCount = state.get().totalAsicCount - 1; + state.mut().totalMiningWeight = state.get().totalMiningWeight - locals.rig.weight; + locals.rig.active = 0; + state.mut().asicRigs.set((sint64)input.rigIndex, locals.rig); + output.returnCode = QTREAT_OK; + } + + PUBLIC_PROCEDURE(SetMiningRate) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + if (qpi.invocator() != state.get().adminAddress) { output.returnCode = QTREAT_ERR_ACCESS_DENIED; return; } + if (input.ratePerEpoch > QTREAT_MINING_REWARD_MAX) { output.returnCode = QTREAT_ERR_INVALID_INPUT; return; } + state.mut().miningRewardRate = input.ratePerEpoch; + output.returnCode = QTREAT_OK; + } + + PUBLIC_PROCEDURE_WITH_LOCALS(DepositDripQdoge) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + if (qpi.invocator() != state.get().adminAddress) { output.returnCode = QTREAT_ERR_ACCESS_DENIED; return; } + if (input.amount == 0) { output.returnCode = QTREAT_ERR_ZERO_AMOUNT; return; } + locals.xfer = qpi.transferShareOwnershipAndPossession( + state.get().qdogeToken.assetName, state.get().qdogeToken.issuer, + qpi.invocator(), qpi.invocator(), (sint64)input.amount, SELF); + if (locals.xfer < 0) { output.returnCode = QTREAT_ERR_ACQUIRE_FAILED; return; } + state.mut().dripQdogePool = sadd(state.get().dripQdogePool, input.amount); + output.returnCode = QTREAT_OK; + } + + PUBLIC_PROCEDURE(SetExcludeAddress) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + if (qpi.invocator() != state.get().adminAddress) { output.returnCode = QTREAT_ERR_ACCESS_DENIED; return; } + if (input.slot >= QTREAT_MAX_EXCLUDE_ADDRESSES) { output.returnCode = QTREAT_ERR_INVALID_INPUT; return; } + state.mut().excludeAddresses.set((sint64)input.slot, input.address); + output.returnCode = QTREAT_OK; + } + + PUBLIC_PROCEDURE_WITH_LOCALS(RevokeGeneralAsset) + { + if (qpi.invocator() != state.get().adminAddress) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QTREAT_ERR_ACCESS_DENIED; return; + } + locals.key.issuer = input.asset.issuer; + locals.key.assetName = input.asset.assetName; + locals.bal = 0; + state.get().generalAssetBalances.get(locals.key, locals.bal); + if (input.amount == 0 || input.amount > locals.bal) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QTREAT_ERR_INVALID_INPUT; return; + } + if (qpi.invocationReward() < QTREAT_QX_TRANSFER_FEE) + { + if (qpi.invocationReward() > 0) qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QTREAT_ERR_INSUFFICIENT_FEE; return; + } + if (qpi.invocationReward() > QTREAT_QX_TRANSFER_FEE) + qpi.transfer(qpi.invocator(), qpi.invocationReward() - QTREAT_QX_TRANSFER_FEE); + + locals.xfer = qpi.transferShareOwnershipAndPossession( + input.asset.assetName, input.asset.issuer, + SELF, SELF, (sint64)input.amount, qpi.invocator()); + if (locals.xfer < 0) { output.returnCode = QTREAT_ERR_TRANSFER_FAILED; return; } + locals.rel = qpi.releaseShares(input.asset, qpi.invocator(), qpi.invocator(), + (sint64)input.amount, QTREAT_QX_CONTRACT_INDEX, QTREAT_QX_CONTRACT_INDEX, + QTREAT_QX_TRANSFER_FEE); + if (locals.rel < 0) { output.returnCode = QTREAT_ERR_TRANSFER_FAILED; return; } + + if (locals.bal - input.amount == 0) + { + state.mut().generalAssetBalances.removeByKey(locals.key); + } + else + { + state.mut().generalAssetBalances.set(locals.key, locals.bal - input.amount); + } + output.returnCode = QTREAT_OK; + } + + REGISTER_USER_FUNCTIONS_AND_PROCEDURES() + { + REGISTER_USER_FUNCTION(GetStakingInfo, 1); + REGISTER_USER_FUNCTION(GetPhaseInfo, 2); + REGISTER_USER_FUNCTION(GetFunds, 3); + REGISTER_USER_FUNCTION(GetRaffleInfo, 4); + REGISTER_USER_FUNCTION(GetExcludeAddresses, 5); + REGISTER_USER_FUNCTION(GetNftInfo, 6); + REGISTER_USER_FUNCTION(GetMinerInfo, 7); + REGISTER_USER_FUNCTION(GetAsicCatalogInfo, 8); + + REGISTER_USER_PROCEDURE(DepositDividends, 1); + REGISTER_USER_PROCEDURE(DepositStakingFund, 2); + REGISTER_USER_PROCEDURE(RequestUnstake, 3); + REGISTER_USER_PROCEDURE(FinalizeUnstake, 4); + REGISTER_USER_PROCEDURE(ClaimQtreatBonus, 5); + REGISTER_USER_PROCEDURE(DepositQtreatTokens, 6); + REGISTER_USER_PROCEDURE(DepositGeneralAsset, 7); + REGISTER_USER_PROCEDURE(SetExcludeAddress, 8); + REGISTER_USER_PROCEDURE(RevokeGeneralAsset, 9); + REGISTER_USER_PROCEDURE(ReleaseManagedShares, 10); + REGISTER_USER_PROCEDURE(DepositMiningFund, 11); + REGISTER_USER_PROCEDURE(LoadAsicPart, 12); + REGISTER_USER_PROCEDURE(RegisterAsic, 13); + REGISTER_USER_PROCEDURE(UnregisterAsic, 14); + REGISTER_USER_PROCEDURE(DepositDripQdoge, 15); + REGISTER_USER_PROCEDURE(SetMiningRate, 16); + } + + INITIALIZE() + { + state.mut().qtreatToken.issuer = ID( + _Q, _D, _O, _G, _E, _E, _E, _S, + _K, _Y, _P, _A, _I, _C, _E, _C, + _H, _E, _A, _H, _O, _X, _P, _U, + _L, _E, _O, _A, _D, _T, _K, _G, + _E, _J, _H, _A, _V, _Y, _P, _F, + _K, _H, _L, _E, _W, _G, _X, _X, + _Z, _Q, _U, _G, _I, _G, _M, _B + ); + state.mut().qtreatToken.assetName = QTREAT_TOKEN_ASSETNAME; + + state.mut().qdogeToken.issuer = state.get().qtreatToken.issuer; + state.mut().qdogeToken.assetName = QTREAT_QDOGE_ASSETNAME; + + // DEV TESTNET: admin decoupled from token issuer. + state.mut().adminAddress = ID( + _E, _Y, _Y, _T, _L, _U, _Z, _O, + _D, _U, _W, _O, _W, _F, _Y, _C, + _B, _J, _P, _Q, _M, _Y, _R, _S, + _O, _C, _C, _A, _D, _H, _Y, _S, + _Z, _Z, _O, _U, _N, _K, _Q, _W, + _Y, _D, _C, _J, _A, _U, _A, _H, + _G, _I, _D, _V, _D, _J, _M, _D + ); + + state.mut().dividendNftIds.set(0, 4968); + state.mut().dividendNftIds.set(1, 4969); + state.mut().dividendNftIds.set(2, 4970); + state.mut().dividendNftIds.set(3, 4971); + state.mut().dividendNftIds.set(4, 4972); + state.mut().dividendNftIds.set(5, 4973); + state.mut().dividendNftIds.set(6, 4974); + state.mut().dividendNftIds.set(7, 4975); + state.mut().dividendNftIds.set(8, 4976); + state.mut().dividendNftIds.set(9, 4977); + state.mut().dividendNftIds.set(10, 4978); + state.mut().dividendNftIds.set(11, 4979); + state.mut().dividendNftIds.set(12, 4980); + state.mut().dividendNftIds.set(13, 4981); + state.mut().dividendNftIds.set(14, 4982); + state.mut().dividendNftIds.set(15, 4983); + state.mut().dividendNftIds.set(16, 4984); + state.mut().dividendNftIds.set(17, 4985); + state.mut().dividendNftIds.set(18, 4986); + state.mut().dividendNftIds.set(19, 4987); + state.mut().dividendNftIds.set(20, 4988); + state.mut().dividendNftIds.set(21, 4989); + state.mut().dividendNftIds.set(22, 4990); + state.mut().dividendNftIds.set(23, 4991); + state.mut().dividendNftIds.set(24, 4992); + state.mut().dividendNftIds.set(25, 4993); + state.mut().dividendNftIds.set(26, 4994); + state.mut().dividendNftIds.set(27, 4995); + state.mut().dividendNftIds.set(28, 4996); + state.mut().dividendNftIds.set(29, 4997); + state.mut().dividendNftIds.set(30, 4998); + state.mut().dividendNftIds.set(31, 4999); + state.mut().dividendNftIds.set(32, 5000); + state.mut().dividendNftIds.set(33, 5001); + state.mut().dividendNftIds.set(34, 5002); + state.mut().dividendNftIds.set(35, 5003); + state.mut().dividendNftIds.set(36, 5004); + state.mut().dividendNftIds.set(37, 5005); + state.mut().dividendNftIds.set(38, 5006); + state.mut().dividendNftIds.set(39, 5007); + state.mut().dividendNftIds.set(40, 5008); + state.mut().dividendNftIds.set(41, 5009); + state.mut().dividendNftIds.set(42, 5010); + state.mut().dividendNftIds.set(43, 5011); + state.mut().dividendNftIds.set(44, 5012); + state.mut().dividendNftIds.set(45, 5013); + state.mut().dividendNftIds.set(46, 5014); + state.mut().dividendNftIds.set(47, 5015); + state.mut().dividendNftIds.set(48, 5016); + state.mut().dividendNftIds.set(49, 5017); + state.mut().dividendNftIds.set(50, 5018); + state.mut().dividendNftIds.set(51, 5019); + state.mut().dividendNftIds.set(52, 5020); + state.mut().dividendNftIds.set(53, 5021); + state.mut().dividendNftIds.set(54, 5022); + state.mut().dividendNftIds.set(55, 5023); + state.mut().dividendNftIds.set(56, 5024); + state.mut().dividendNftIds.set(57, 5025); + state.mut().dividendNftIds.set(58, 5026); + state.mut().dividendNftIds.set(59, 5027); + state.mut().dividendNftIds.set(60, 5028); + state.mut().dividendNftIds.set(61, 5029); + state.mut().dividendNftIds.set(62, 5030); + state.mut().dividendNftIds.set(63, 5031); + state.mut().dividendNftIds.set(64, 5032); + state.mut().dividendNftIds.set(65, 5033); + state.mut().dividendNftIds.set(66, 5034); + state.mut().dividendNftIds.set(67, 5035); + state.mut().dividendNftIds.set(68, 5036); + state.mut().dividendNftIds.set(69, 5037); + state.mut().dividendNftIds.set(70, 5038); + state.mut().dividendNftIds.set(71, 5039); + state.mut().dividendNftIds.set(72, 5040); + state.mut().dividendNftIds.set(73, 5041); + state.mut().dividendNftIds.set(74, 5042); + state.mut().dividendNftIds.set(75, 5043); + state.mut().dividendNftIds.set(76, 5044); + state.mut().dividendNftIds.set(77, 5045); + state.mut().dividendNftIds.set(78, 5046); + state.mut().dividendNftIds.set(79, 5047); + state.mut().dividendNftIds.set(80, 5048); + state.mut().dividendNftIds.set(81, 5049); + state.mut().dividendNftIds.set(82, 5050); + state.mut().dividendNftIds.set(83, 5051); + state.mut().dividendNftIds.set(84, 5052); + state.mut().dividendNftIds.set(85, 5053); + state.mut().dividendNftIds.set(86, 5054); + state.mut().dividendNftIds.set(87, 5055); + state.mut().dividendNftIds.set(88, 5056); + state.mut().dividendNftIds.set(89, 5057); + state.mut().dividendNftIds.set(90, 5058); + state.mut().dividendNftIds.set(91, 5059); + state.mut().dividendNftIds.set(92, 5060); + state.mut().dividendNftIds.set(93, 5061); + state.mut().dividendNftIds.set(94, 5062); + state.mut().dividendNftIds.set(95, 5063); + state.mut().dividendNftIds.set(96, 5064); + state.mut().dividendNftIds.set(97, 5065); + state.mut().dividendNftIds.set(98, 5066); + state.mut().dividendNftIds.set(99, 5067); + state.mut().dividendNftIds.set(100, 5068); + state.mut().dividendNftIds.set(101, 5069); + state.mut().dividendNftIds.set(102, 5070); + state.mut().dividendNftIds.set(103, 5071); + state.mut().dividendNftIds.set(104, 5072); + state.mut().dividendNftIds.set(105, 5073); + state.mut().dividendNftIds.set(106, 5074); + state.mut().dividendNftIds.set(107, 5075); + state.mut().dividendNftIds.set(108, 5076); + state.mut().dividendNftIds.set(109, 5077); + state.mut().dividendNftIds.set(110, 5078); + state.mut().dividendNftIds.set(111, 5079); + state.mut().dividendNftIds.set(112, 5080); + state.mut().dividendNftIds.set(113, 5081); + state.mut().dividendNftIds.set(114, 5082); + state.mut().dividendNftIds.set(115, 5083); + state.mut().dividendNftIds.set(116, 5084); + state.mut().dividendNftIds.set(117, 5085); + state.mut().dividendNftIds.set(118, 5086); + state.mut().dividendNftIds.set(119, 5087); + state.mut().dividendNftIds.set(120, 5088); + state.mut().dividendNftIds.set(121, 5089); + state.mut().dividendNftIds.set(122, 5090); + state.mut().dividendNftIds.set(123, 5091); + state.mut().dividendNftIds.set(124, 5092); + state.mut().dividendNftIds.set(125, 5093); + state.mut().dividendNftIds.set(126, 5094); + state.mut().dividendNftIds.set(127, 5095); + state.mut().dividendNftIds.set(128, 5096); + state.mut().dividendNftIds.set(129, 5097); + state.mut().dividendNftIds.set(130, 5098); + state.mut().dividendNftIds.set(131, 5099); + state.mut().dividendNftIds.set(132, 5100); + state.mut().dividendNftIds.set(133, 5101); + state.mut().dividendNftIds.set(134, 5102); + state.mut().dividendNftIds.set(135, 5103); + state.mut().dividendNftIds.set(136, 5104); + state.mut().dividendNftIds.set(137, 5105); + state.mut().dividendNftIds.set(138, 5106); + state.mut().dividendNftIds.set(139, 5107); + state.mut().dividendNftIds.set(140, 5108); + state.mut().dividendNftIds.set(141, 5109); + state.mut().dividendNftIds.set(142, 5110); + state.mut().dividendNftIds.set(143, 5111); + state.mut().dividendNftIds.set(144, 5112); + state.mut().dividendNftIds.set(145, 5113); + state.mut().dividendNftIds.set(146, 5114); + state.mut().dividendNftIds.set(147, 5117); + state.mut().dividendNftIds.set(148, 5118); + state.mut().dividendNftIds.set(149, 5119); + state.mut().dividendNftIds.set(150, 5120); + state.mut().dividendNftIds.set(151, 5121); + state.mut().dividendNftIds.set(152, 5122); + state.mut().dividendNftIds.set(153, 5123); + state.mut().dividendNftIds.set(154, 5124); + state.mut().dividendNftIds.set(155, 5125); + state.mut().dividendNftIds.set(156, 5126); + state.mut().dividendNftIds.set(157, 5127); + state.mut().dividendNftIds.set(158, 5128); + state.mut().dividendNftIds.set(159, 5129); + state.mut().dividendNftIds.set(160, 5130); + state.mut().dividendNftIds.set(161, 5131); + state.mut().dividendNftIds.set(162, 5132); + state.mut().dividendNftIds.set(163, 5133); + state.mut().dividendNftIds.set(164, 5134); + state.mut().dividendNftIds.set(165, 5135); + state.mut().dividendNftIds.set(166, 5136); + state.mut().dividendNftIds.set(167, 5137); + state.mut().dividendNftIds.set(168, 5138); + state.mut().dividendNftIds.set(169, 5139); + state.mut().dividendNftIds.set(170, 5140); + state.mut().dividendNftIds.set(171, 5141); + state.mut().dividendNftIds.set(172, 5142); + state.mut().dividendNftIds.set(173, 5143); + state.mut().dividendNftIds.set(174, 5144); + state.mut().dividendNftIds.set(175, 5145); + state.mut().dividendNftIds.set(176, 5146); + state.mut().dividendNftIds.set(177, 5147); + state.mut().dividendNftIds.set(178, 5148); + state.mut().dividendNftIds.set(179, 5149); + state.mut().dividendNftIds.set(180, 5150); + state.mut().dividendNftIds.set(181, 5151); + state.mut().dividendNftIds.set(182, 5152); + state.mut().dividendNftIds.set(183, 5153); + state.mut().dividendNftIds.set(184, 5154); + state.mut().dividendNftIds.set(185, 5155); + state.mut().dividendNftIds.set(186, 5156); + state.mut().dividendNftIds.set(187, 5157); + state.mut().dividendNftIds.set(188, 5158); + state.mut().dividendNftIds.set(189, 5159); + state.mut().dividendNftIds.set(190, 5160); + state.mut().dividendNftIds.set(191, 5161); + state.mut().dividendNftIds.set(192, 5162); + state.mut().dividendNftIds.set(193, 5163); + state.mut().dividendNftIds.set(194, 5164); + state.mut().dividendNftIds.set(195, 5170); + state.mut().dividendNftIds.set(196, 5172); + state.mut().dividendNftIds.set(197, 5173); + state.mut().dividendNftIds.set(198, 5174); + state.mut().dividendNftIds.set(199, 5175); + state.mut().dividendNftIdCount = 200; + + state.mut().miningRewardRate = QTREAT_MINING_REWARD_DEFAULT; + + state.mut().stakingStartEpoch = 0; + } + + struct POST_INCOMING_TRANSFER_locals { uint64 prev; }; + POST_INCOMING_TRANSFER_WITH_LOCALS() + { + if (input.type == TransferType::qpiDistributeDividends) + { + state.mut().dividendFund = sadd(state.get().dividendFund, (uint64)input.amount); + locals.prev = 0; + state.get().scDividendTracker.get(input.sourceId, locals.prev); + state.mut().scDividendTracker.set(input.sourceId, sadd(locals.prev, (uint64)input.amount)); + return; + } + if (input.type == TransferType::qpiTransfer + && input.sourceId == id(RANDOM_CONTRACT_INDEX, 0, 0, 0)) + { + state.mut().stakingFund = sadd(state.get().stakingFund, (uint64)input.amount); + return; + } + if (input.type == TransferType::standardTransaction + || input.type == TransferType::qpiTransfer + || input.type == TransferType::revenueDonation) + { + state.mut().dividendFund = sadd(state.get().dividendFund, (uint64)input.amount); + } + } + + struct BEGIN_EPOCH_locals { AssetPossessionIterator it; uint64 bal; id h; uint64 existing; sint64 exIdx; uint64 excluded; }; + BEGIN_EPOCH_WITH_LOCALS() + { + if (state.get().stakingStartEpoch == 0) + { + state.mut().stakingStartEpoch = qpi.epoch(); + } + + state.mut().beginBalances.reset(); + state.mut().endBalances.reset(); + state.mut().totalHoldersSnapshot = 0; + if (state.get().qtreatToken.issuer != NULL_ID) + { + for (locals.it.begin(state.get().qtreatToken); !locals.it.reachedEnd(); locals.it.next()) + { + if (locals.it.possessor() == SELF) continue; + locals.bal = locals.it.numberOfPossessedShares(); + if (locals.bal < QTREAT_MIN_ELIGIBLE_BAL) continue; + locals.h = locals.it.possessor(); + locals.excluded = 0; + for (locals.exIdx = 0; locals.exIdx < (sint64)QTREAT_MAX_EXCLUDE_ADDRESSES; locals.exIdx++) + { + if (state.get().excludeAddresses.get(locals.exIdx) != NULL_ID + && locals.h == state.get().excludeAddresses.get(locals.exIdx)) + { + locals.excluded = 1; + } + } + if (locals.excluded != 0) continue; + locals.existing = 0; + state.get().beginBalances.get(locals.h, locals.existing); + if (state.mut().beginBalances.set(locals.h, sadd(locals.existing, locals.bal)) != NULL_INDEX) + { + state.mut().totalHoldersSnapshot = sadd(state.get().totalHoldersSnapshot, locals.bal); + } + } + } + } + + struct END_EPOCH_locals + { + AssetPossessionIterator it; uint64 bal; id h; uint64 existing; + sint64 sidx; StakerInfo info; uint64 rewardBudget; uint64 reward; uint64 q; uint64 rem; + uint64 phaseActive; uint64 elapsed; + RANDOM::BuyEntropy_input entropyIn; + RANDOM::BuyEntropy_output entropyOut; + bit_4096 zeroEntropy; + RaffleSeed seed; + m256i raffleDigest; + uint64 eligibleCount; uint64 winnerIdx; uint64 n; + sint64 relResult; + sint64 walletBal; uint64 holdings; uint64 totalWeight; uint64 weight; + uint64 amount; sint64 idx; uint64 beginBal; uint64 endBal; uint64 eligible; + uint64 nftCnt; uint64 totalEligible; sint64 exIdx; uint64 isExcluded; + uint64 shareholderShare; uint64 perShare; uint64 paidShareholders; + uint64 reservedDividend; uint64 distributedDividend; + uint64 dripRate; uint64 dripDue; sint64 dripIdx; uint64 dripPacked; uint64 dripRarity; + sint64 dripXfer; sint64 dripManaged; uint64 dripAccounted; StakerInfo dripStakerInfo; + AsicRig rig; uint64 miningBudget; uint64 minerBudget; uint64 miningDividendCut; + uint64 minerReward; uint64 stillOwned; uint32 rigPartId; sint64 pIdx; uint64 verifyOk; + QBAY::getInfoOfNFTById_input qbayIn; QBAY::getInfoOfNFTById_output qbayOut; + Entity ent; uint64 contractBalance; + }; + END_EPOCH_WITH_LOCALS() + { + state.mut().beginBalances.cleanupIfNeeded(); + state.mut().endBalances.cleanupIfNeeded(); + state.mut().stakers.cleanupIfNeeded(); + state.mut().generalAssetBalances.cleanupIfNeeded(); + state.mut().scDividendTracker.cleanupIfNeeded(); + state.mut().nftCounts.cleanupIfNeeded(); + state.mut().asicUsedParts.cleanupIfNeeded(); + + for (locals.sidx = state.get().stakers.nextElementIndex(NULL_INDEX); + locals.sidx != NULL_INDEX; + locals.sidx = state.get().stakers.nextElementIndex(locals.sidx)) + { + locals.info = state.get().stakers.value(locals.sidx); + if (locals.info.unstakeAmount == 0) continue; + if (qpi.epoch() < locals.info.unstakeEpoch + QTREAT_UNSTAKE_DELAY_EPOCHS) continue; + + locals.h = state.get().stakers.key(locals.sidx); + locals.relResult = qpi.releaseShares(state.get().qdogeToken, locals.h, locals.h, + (sint64)locals.info.unstakeAmount, QTREAT_QX_CONTRACT_INDEX, QTREAT_QX_CONTRACT_INDEX, + QTREAT_QX_TRANSFER_FEE); + if (locals.relResult < 0) continue; + + locals.info.unstakeAmount = 0; + locals.info.unstakeEpoch = 0; + if (locals.info.staked == 0 && locals.info.pendingBonus == 0 + && locals.info.bonusAwarded == 0) + { + state.mut().stakers.removeByKey(locals.h); + } + else + { + state.mut().stakers.set(locals.h, locals.info); + } + } + + qpi.getEntity(SELF, locals.ent); + locals.contractBalance = locals.ent.incomingAmount - locals.ent.outgoingAmount; + + locals.phaseActive = 0; + if (state.get().stakingStartEpoch != 0) + { + locals.elapsed = qpi.epoch() - state.get().stakingStartEpoch; + if (locals.elapsed < QTREAT_TOTAL_REWARD_EPOCHS) locals.phaseActive = 1; + } + + if (locals.phaseActive == 1 && state.get().totalStaked > 0) + { + locals.rewardBudget = QTREAT_STAKER_REWARD_PER_EPOCH; + if (locals.rewardBudget > state.get().stakingFund) locals.rewardBudget = state.get().stakingFund; + + locals.totalWeight = 0; + for (locals.sidx = state.get().stakers.nextElementIndex(NULL_INDEX); + locals.sidx != NULL_INDEX; + locals.sidx = state.get().stakers.nextElementIndex(locals.sidx)) + { + locals.h = state.get().stakers.key(locals.sidx); + locals.info = state.get().stakers.value(locals.sidx); + if (locals.info.staked == 0) + { + if (locals.info.bonusEpochs != 0 || locals.info.growthStreak != 0 + || locals.info.lastStaked != 0) + { + locals.info.bonusEpochs = 0; + locals.info.growthStreak = 0; + locals.info.lastStaked = 0; + state.mut().stakers.set(locals.h, locals.info); + } + continue; + } + + locals.walletBal = qpi.numberOfPossessedShares( + state.get().qdogeToken.assetName, state.get().qdogeToken.issuer, + locals.h, locals.h, QTREAT_QX_CONTRACT_INDEX, QTREAT_QX_CONTRACT_INDEX); + if (locals.walletBal < 0) locals.walletBal = 0; + locals.holdings = sadd(sadd(locals.info.staked, locals.info.unstakeAmount), (uint64)locals.walletBal); + if (locals.elapsed < QTREAT_PROGRESSIVE_START_DELAY_EPOCHS) + { + locals.info.growthStreak = 0; + if (locals.holdings > locals.info.hwmHoldings) + { + locals.info.hwmHoldings = locals.holdings; + } + } + else if (locals.info.staked > locals.info.lastStaked + && locals.info.staked - locals.info.lastStaked >= QTREAT_PROGRESSIVE_MIN_STEP + && locals.holdings > locals.info.hwmHoldings) + { + if (locals.info.growthStreak < QTREAT_PROGRESSIVE_MAX_STREAK) + { + locals.info.growthStreak += 1; + } + locals.info.hwmHoldings = locals.holdings; + } + else + { + locals.info.growthStreak = 0; + } + locals.info.lastStaked = locals.info.staked; + + if (locals.info.staked >= QTREAT_BONUS_THRESHOLD) + { + if (locals.info.bonusAwarded < QTREAT_BONUS_MAX_PER_WALLET) + { + locals.info.bonusEpochs += 1; + while (locals.info.bonusEpochs >= QTREAT_BONUS_INTERVAL_EPOCHS + && locals.info.bonusAwarded < QTREAT_BONUS_MAX_PER_WALLET) + { + locals.info.bonusEpochs -= QTREAT_BONUS_INTERVAL_EPOCHS; + locals.info.bonusAwarded += 1; + locals.info.pendingBonus += 1; + } + } + } + else + { + locals.info.bonusEpochs = 0; + } + + state.mut().stakers.set(locals.h, locals.info); + locals.weight = ((uint128)locals.info.staked + * (uint128)(1000 + locals.info.growthStreak * QTREAT_PROGRESSIVE_BONUS_PERMILLE)).low; + locals.totalWeight = sadd(locals.totalWeight, locals.weight); + } + + if (locals.rewardBudget > 0 && locals.totalWeight > 0) + { + for (locals.sidx = state.get().stakers.nextElementIndex(NULL_INDEX); + locals.sidx != NULL_INDEX; + locals.sidx = state.get().stakers.nextElementIndex(locals.sidx)) + { + locals.h = state.get().stakers.key(locals.sidx); + locals.info = state.get().stakers.value(locals.sidx); + if (locals.info.staked == 0) continue; + + locals.weight = ((uint128)locals.info.staked + * (uint128)(1000 + locals.info.growthStreak * QTREAT_PROGRESSIVE_BONUS_PERMILLE)).low; + locals.reward = div((uint128)locals.rewardBudget * (uint128)locals.weight, + (uint128)locals.totalWeight).low; + if (locals.reward > locals.contractBalance) locals.reward = locals.contractBalance; + if (locals.reward == 0) continue; + + qpi.transfer(locals.h, locals.reward); + locals.contractBalance -= locals.reward; + state.mut().stakingFund = state.get().stakingFund - locals.reward; + state.mut().totalStakingRewardsDistributed = + sadd(state.get().totalStakingRewardsDistributed, locals.reward); + } + } + } + + if (state.get().stakingStartEpoch != 0 + && qpi.epoch() - state.get().stakingStartEpoch < QTREAT_RAFFLE_EPOCHS + && state.get().qtreatBonusPool > 0 + && state.get().stakingFund >= QTREAT_RAFFLE_ENTROPY_FEE) + { + locals.eligibleCount = 0; + for (locals.sidx = state.get().stakers.nextElementIndex(NULL_INDEX); + locals.sidx != NULL_INDEX; + locals.sidx = state.get().stakers.nextElementIndex(locals.sidx)) + { + locals.info = state.get().stakers.value(locals.sidx); + if (locals.info.staked > 0 && locals.info.unstakeAmount == 0) + { + locals.eligibleCount++; + } + } + + if (locals.eligibleCount > 0) + { + state.mut().stakingFund = state.get().stakingFund - QTREAT_RAFFLE_ENTROPY_FEE; + locals.entropyIn.collateralTier = QTREAT_RAFFLE_COLLATERAL_TIER; + locals.entropyIn.numberOfBits = QTREAT_RAFFLE_ENTROPY_BITS; + locals.entropyIn.trustee = id::zero(); + INVOKE_OTHER_CONTRACT_PROCEDURE(RANDOM, BuyEntropy, locals.entropyIn, locals.entropyOut, (sint64)QTREAT_RAFFLE_ENTROPY_FEE); + + if (interContractCallError != NoCallError) + { + state.mut().stakingFund = sadd(state.get().stakingFund, QTREAT_RAFFLE_ENTROPY_FEE); + // Failed invoke leaves output stale; zero it. + locals.entropyOut.entropy = locals.zeroEntropy; + } + + // Fallback seed is public and grindable without RANDOM. + locals.seed.entropy = locals.entropyOut.entropy; + locals.seed.prevSpectrumDigest = qpi.getPrevSpectrumDigest(); + locals.seed.prevUniverseDigest = qpi.getPrevUniverseDigest(); + locals.seed.prevComputerDigest = qpi.getPrevComputerDigest(); + locals.seed.epoch = qpi.epoch(); + locals.seed.tick = qpi.tick(); + locals.seed.eligibleCount = locals.eligibleCount; + locals.raffleDigest = qpi.K12(locals.seed); + locals.winnerIdx = mod((uint64)locals.raffleDigest.u64._0, locals.eligibleCount); + + locals.n = 0; + for (locals.sidx = state.get().stakers.nextElementIndex(NULL_INDEX); + locals.sidx != NULL_INDEX; + locals.sidx = state.get().stakers.nextElementIndex(locals.sidx)) + { + locals.info = state.get().stakers.value(locals.sidx); + if (locals.info.staked == 0 || locals.info.unstakeAmount > 0) continue; + if (locals.n == locals.winnerIdx) + { + locals.h = state.get().stakers.key(locals.sidx); + locals.info.pendingBonus += 1; + state.mut().stakers.set(locals.h, locals.info); + state.mut().totalRaffleAwarded = state.get().totalRaffleAwarded + 1; + state.mut().lastRaffleWinner = locals.h; + state.mut().lastRaffleEpoch = qpi.epoch(); + break; + } + locals.n++; + } + } + } + + for (locals.sidx = state.get().stakers.nextElementIndex(NULL_INDEX); + locals.sidx != NULL_INDEX; + locals.sidx = state.get().stakers.nextElementIndex(locals.sidx)) + { + locals.info = state.get().stakers.value(locals.sidx); + if (locals.info.pendingBonus == 0) continue; + if (state.get().qtreatBonusPool < locals.info.pendingBonus) continue; + if (state.get().stakingFund < (uint64)QTREAT_QX_TRANSFER_FEE) continue; + + locals.h = state.get().stakers.key(locals.sidx); + if (qpi.transferShareOwnershipAndPossession( + state.get().qtreatToken.assetName, state.get().qtreatToken.issuer, + SELF, SELF, (sint64)locals.info.pendingBonus, locals.h) < 0) + { + continue; + } + state.mut().qtreatBonusPool = state.get().qtreatBonusPool - locals.info.pendingBonus; + state.mut().totalBonusDelivered = sadd(state.get().totalBonusDelivered, locals.info.pendingBonus); + state.mut().stakingFund = state.get().stakingFund - (uint64)QTREAT_QX_TRANSFER_FEE; + qpi.releaseShares(state.get().qtreatToken, locals.h, locals.h, + (sint64)locals.info.pendingBonus, QTREAT_QX_CONTRACT_INDEX, QTREAT_QX_CONTRACT_INDEX, + QTREAT_QX_TRANSFER_FEE); + + locals.info.pendingBonus = 0; + state.mut().stakers.set(locals.h, locals.info); + } + + for (locals.sidx = 0; locals.sidx < (sint64)state.get().asicRigHighWater; locals.sidx++) + { + locals.rig = state.get().asicRigs.get(locals.sidx); + if (locals.rig.active == 0) continue; + locals.stillOwned = 1; + locals.verifyOk = 1; + for (locals.pIdx = 0; locals.pIdx < 4; locals.pIdx++) + { + if (locals.pIdx == 0) locals.rigPartId = locals.rig.partMotherboard; + if (locals.pIdx == 1) locals.rigPartId = locals.rig.partChip; + if (locals.pIdx == 2) locals.rigPartId = locals.rig.partPsu; + if (locals.pIdx == 3) locals.rigPartId = locals.rig.partFan; + locals.qbayIn.NFTId = locals.rigPartId; + CALL_OTHER_CONTRACT_FUNCTION(QBAY, getInfoOfNFTById, locals.qbayIn, locals.qbayOut); + if (interContractCallError != NoCallError) { locals.verifyOk = 0; break; } + if (locals.qbayOut.possessor != locals.rig.owner) + { + locals.stillOwned = 0; + } + } + if (locals.verifyOk == 1 && locals.stillOwned == 0) + { + state.mut().asicUsedParts.removeByKey((uint64)locals.rig.partMotherboard); + state.mut().asicUsedParts.removeByKey((uint64)locals.rig.partChip); + state.mut().asicUsedParts.removeByKey((uint64)locals.rig.partPsu); + state.mut().asicUsedParts.removeByKey((uint64)locals.rig.partFan); + state.mut().totalAsicCount = state.get().totalAsicCount - 1; + state.mut().totalMiningWeight = state.get().totalMiningWeight - locals.rig.weight; + locals.rig.active = 0; + state.mut().asicRigs.set(locals.sidx, locals.rig); + } + } + + if (state.get().totalMiningWeight > 0 && state.get().miningFund > 0) + { + qpi.getEntity(SELF, locals.ent); + locals.contractBalance = locals.ent.incomingAmount - locals.ent.outgoingAmount; + locals.miningBudget = state.get().miningRewardRate; + if (locals.miningBudget > state.get().miningFund) locals.miningBudget = state.get().miningFund; + + // Split 50/50; odd QU goes to dividends. + locals.minerBudget = div(locals.miningBudget, 2ULL); + locals.miningDividendCut = locals.miningBudget - locals.minerBudget; + + if (locals.miningDividendCut > 0) + { + state.mut().miningFund = state.get().miningFund - locals.miningDividendCut; + state.mut().dividendFund = sadd(state.get().dividendFund, locals.miningDividendCut); + } + + for (locals.sidx = 0; locals.sidx < (sint64)state.get().asicRigHighWater; locals.sidx++) + { + locals.rig = state.get().asicRigs.get(locals.sidx); + if (locals.rig.active == 0 || locals.rig.weight == 0) continue; + + locals.minerReward = div((uint128)locals.minerBudget * (uint128)locals.rig.weight, + (uint128)state.get().totalMiningWeight).low; + if (locals.minerReward == 0) continue; + if (locals.minerReward > locals.contractBalance) locals.minerReward = locals.contractBalance; + + qpi.transfer(locals.rig.owner, locals.minerReward); + locals.contractBalance -= locals.minerReward; + state.mut().miningFund = state.get().miningFund - locals.minerReward; + state.mut().totalMiningRewardsDistributed = + sadd(state.get().totalMiningRewardsDistributed, locals.minerReward); + if (locals.contractBalance == 0) break; + } + } + + if (state.get().dividendNftIdCount > 0) + { + state.mut().nftCounts.reset(); + state.mut().totalNftCount = 0; + for (locals.sidx = 0; locals.sidx < (sint64)state.get().dividendNftIdCount; locals.sidx++) + { + locals.qbayIn.NFTId = state.get().dividendNftIds.get(locals.sidx); + CALL_OTHER_CONTRACT_FUNCTION(QBAY, getInfoOfNFTById, locals.qbayIn, locals.qbayOut); + if (interContractCallError != NoCallError) continue; + if (locals.qbayOut.possessor == NULL_ID || locals.qbayOut.possessor == SELF) continue; + locals.isExcluded = 0; + for (locals.exIdx = 0; locals.exIdx < (sint64)QTREAT_MAX_EXCLUDE_ADDRESSES; locals.exIdx++) + { + if (state.get().excludeAddresses.get(locals.exIdx) != NULL_ID + && locals.qbayOut.possessor == state.get().excludeAddresses.get(locals.exIdx)) + { + locals.isExcluded = 1; + } + } + if (locals.isExcluded != 0) continue; + locals.existing = 0; + state.get().nftCounts.get(locals.qbayOut.possessor, locals.existing); + state.mut().nftCounts.set(locals.qbayOut.possessor, locals.existing + 1); + state.mut().totalNftCount = state.get().totalNftCount + 1; + } + } + + if (state.get().asicCatalogLocked == 1 + && state.get().dripStartEpoch != 0 + && qpi.epoch() - state.get().dripStartEpoch < QTREAT_DRIP_EPOCHS + && state.get().dripQdogePool > 0) + { + state.mut().dripTally.reset(); + for (locals.dripIdx = state.get().asicCatalog.nextElementIndex(NULL_INDEX); + locals.dripIdx != NULL_INDEX; + locals.dripIdx = state.get().asicCatalog.nextElementIndex(locals.dripIdx)) + { + locals.qbayIn.NFTId = (uint32)state.get().asicCatalog.key(locals.dripIdx); + locals.dripPacked = state.get().asicCatalog.value(locals.dripIdx); + locals.dripRarity = mod(locals.dripPacked, 8ULL); + CALL_OTHER_CONTRACT_FUNCTION(QBAY, getInfoOfNFTById, locals.qbayIn, locals.qbayOut); + if (interContractCallError != NoCallError) continue; + if (locals.qbayOut.possessor == NULL_ID || locals.qbayOut.possessor == SELF) continue; + if (locals.qbayOut.possessor == state.get().adminAddress) continue; + + locals.dripRate = QTREAT_DRIP_QDOGE_COMMON; + if (locals.dripRarity == 1) locals.dripRate = QTREAT_DRIP_QDOGE_UNCOMMON; + if (locals.dripRarity == 2) locals.dripRate = QTREAT_DRIP_QDOGE_RARE; + if (locals.dripRarity == 3) locals.dripRate = QTREAT_DRIP_QDOGE_EPIC; + if (locals.dripRarity == 4) locals.dripRate = QTREAT_DRIP_QDOGE_LEGENDARY; + + locals.existing = 0; + state.get().dripTally.get(locals.qbayOut.possessor, locals.existing); + state.mut().dripTally.set(locals.qbayOut.possessor, locals.existing + locals.dripRate); + } + + for (locals.dripIdx = state.get().dripTally.nextElementIndex(NULL_INDEX); + locals.dripIdx != NULL_INDEX; + locals.dripIdx = state.get().dripTally.nextElementIndex(locals.dripIdx)) + { + locals.h = state.get().dripTally.key(locals.dripIdx); + locals.dripDue = state.get().dripTally.value(locals.dripIdx); + if (locals.dripDue == 0) continue; + if (state.get().dripQdogePool < locals.dripDue) continue; + if (state.get().miningFund < (uint64)QTREAT_QX_TRANSFER_FEE) break; + + locals.dripXfer = qpi.transferShareOwnershipAndPossession( + state.get().qdogeToken.assetName, state.get().qdogeToken.issuer, + SELF, SELF, (sint64)locals.dripDue, locals.h); + if (locals.dripXfer < 0) continue; + state.mut().dripQdogePool = state.get().dripQdogePool - locals.dripDue; + state.mut().totalDripQdogeDistributed = sadd(state.get().totalDripQdogeDistributed, locals.dripDue); + state.mut().miningFund = state.get().miningFund - (uint64)QTREAT_QX_TRANSFER_FEE; + locals.dripManaged = qpi.numberOfPossessedShares( + state.get().qdogeToken.assetName, state.get().qdogeToken.issuer, + locals.h, locals.h, SELF_INDEX, SELF_INDEX); + locals.dripStakerInfo.staked = 0; locals.dripStakerInfo.unstakeAmount = 0; + state.get().stakers.get(locals.h, locals.dripStakerInfo); + locals.dripAccounted = locals.dripStakerInfo.staked + locals.dripStakerInfo.unstakeAmount; + if (locals.dripManaged > (sint64)locals.dripAccounted) + { + qpi.releaseShares(state.get().qdogeToken, locals.h, locals.h, + locals.dripManaged - (sint64)locals.dripAccounted, + QTREAT_QX_CONTRACT_INDEX, QTREAT_QX_CONTRACT_INDEX, + QTREAT_QX_TRANSFER_FEE); + } + } + } + + if (state.get().qtreatToken.issuer != NULL_ID) + { + for (locals.it.begin(state.get().qtreatToken); !locals.it.reachedEnd(); locals.it.next()) + { + if (locals.it.possessor() == SELF) continue; + locals.bal = locals.it.numberOfPossessedShares(); + if (locals.bal == 0) continue; + locals.h = locals.it.possessor(); + locals.existing = 0; + state.get().endBalances.get(locals.h, locals.existing); + state.mut().endBalances.set(locals.h, sadd(locals.existing, locals.bal)); + } + } + + if (state.get().dividendFund == 0) return; + qpi.getEntity(SELF, locals.ent); + locals.contractBalance = locals.ent.incomingAmount - locals.ent.outgoingAmount; + locals.amount = state.get().dividendFund; + if (locals.amount > locals.contractBalance) locals.amount = locals.contractBalance; + locals.totalEligible = sadd(state.get().totalHoldersSnapshot, state.get().totalNftCount); + if (locals.amount == 0 || locals.totalEligible == 0) return; + + locals.reservedDividend = locals.amount; + locals.distributedDividend = 0; + state.mut().dividendFund = state.get().dividendFund - locals.reservedDividend; + + locals.shareholderShare = div((uint128)locals.amount * (uint128)QTREAT_DIVIDEND_SHAREHOLDER_PERMILLE, (uint128)1000ULL).low; + locals.perShare = div(locals.shareholderShare, (uint64)NUMBER_OF_COMPUTORS); + if (locals.perShare > 0 && qpi.distributeDividends((sint64)locals.perShare)) + { + locals.paidShareholders = locals.perShare * (uint64)NUMBER_OF_COMPUTORS; + locals.amount -= locals.paidShareholders; + locals.distributedDividend = locals.paidShareholders; + locals.contractBalance = (locals.contractBalance > locals.paidShareholders) + ? locals.contractBalance - locals.paidShareholders : 0; + state.mut().totalShareholderDividends = sadd(state.get().totalShareholderDividends, locals.paidShareholders); + } + + for (locals.idx = state.get().beginBalances.nextElementIndex(NULL_INDEX); + locals.idx != NULL_INDEX; + locals.idx = state.get().beginBalances.nextElementIndex(locals.idx)) + { + locals.h = state.get().beginBalances.key(locals.idx); + locals.beginBal = state.get().beginBalances.value(locals.idx); + locals.endBal = 0; + state.get().endBalances.get(locals.h, locals.endBal); + locals.eligible = (locals.endBal < locals.beginBal) ? locals.endBal : locals.beginBal; + locals.nftCnt = 0; + state.get().nftCounts.get(locals.h, locals.nftCnt); + locals.eligible = sadd(locals.eligible, locals.nftCnt); + if (locals.eligible == 0) continue; + + locals.q = div(locals.amount, locals.totalEligible); + locals.rem = mod(locals.amount, locals.totalEligible); + locals.reward = locals.q * locals.eligible + + div((uint128)locals.rem * (uint128)locals.eligible, (uint128)locals.totalEligible).low; + if (locals.reward == 0) continue; + if (locals.reward > locals.contractBalance) locals.reward = locals.contractBalance; + + qpi.transfer(locals.h, locals.reward); + locals.contractBalance -= locals.reward; + locals.distributedDividend = sadd(locals.distributedDividend, locals.reward); + if (locals.contractBalance == 0) break; + } + + for (locals.idx = state.get().nftCounts.nextElementIndex(NULL_INDEX); + locals.idx != NULL_INDEX; + locals.idx = state.get().nftCounts.nextElementIndex(locals.idx)) + { + locals.h = state.get().nftCounts.key(locals.idx); + if (state.get().beginBalances.contains(locals.h)) continue; + locals.isExcluded = 0; + for (locals.exIdx = 0; locals.exIdx < (sint64)QTREAT_MAX_EXCLUDE_ADDRESSES; locals.exIdx++) + { + if (state.get().excludeAddresses.get(locals.exIdx) != NULL_ID + && locals.h == state.get().excludeAddresses.get(locals.exIdx)) + { + locals.isExcluded = 1; + } + } + if (locals.isExcluded != 0) continue; + locals.eligible = state.get().nftCounts.value(locals.idx); + if (locals.eligible == 0) continue; + + locals.q = div(locals.amount, locals.totalEligible); + locals.rem = mod(locals.amount, locals.totalEligible); + locals.reward = locals.q * locals.eligible + + div((uint128)locals.rem * (uint128)locals.eligible, (uint128)locals.totalEligible).low; + if (locals.reward == 0) continue; + if (locals.reward > locals.contractBalance) locals.reward = locals.contractBalance; + + qpi.transfer(locals.h, locals.reward); + locals.contractBalance -= locals.reward; + locals.distributedDividend = sadd(locals.distributedDividend, locals.reward); + if (locals.contractBalance == 0) break; + } + + if (locals.reservedDividend > locals.distributedDividend) + { + state.mut().dividendFund = sadd(state.get().dividendFund, + locals.reservedDividend - locals.distributedDividend); + } + state.mut().totalDividendsDistributed = + sadd(state.get().totalDividendsDistributed, locals.distributedDividend); + } + + struct PRE_ACQUIRE_SHARES_locals { StakerInfo info; }; + PRE_ACQUIRE_SHARES_WITH_LOCALS() + { + if (input.otherContractIndex != QTREAT_QX_CONTRACT_INDEX) return; + + if (input.asset.assetName == state.get().qdogeToken.assetName + && input.asset.issuer == state.get().qdogeToken.issuer) + { + if (input.owner == state.get().adminAddress) + { + output.allowTransfer = true; + output.requestedFee = 0; + return; + } + if (input.numberOfShares <= 0) return; + locals.info.staked = 0; locals.info.unstakeAmount = 0; locals.info.unstakeEpoch = 0; + locals.info.bonusEpochs = 0; locals.info.bonusAwarded = 0; locals.info.pendingBonus = 0; + state.get().stakers.get(input.owner, locals.info); + if (state.get().stakingStartEpoch != 0 + && qpi.epoch() - state.get().stakingStartEpoch >= QTREAT_TOTAL_REWARD_EPOCHS) return; + if (locals.info.unstakeAmount > 0) return; + if (locals.info.staked + (uint64)input.numberOfShares < QTREAT_MIN_STAKE) return; + if (!state.get().stakers.contains(input.owner) + && state.get().stakers.population() >= QTREAT_MAX_STAKERS) return; + output.allowTransfer = true; + output.requestedFee = 0; + return; + } + + output.allowTransfer = true; + output.requestedFee = 0; + } + + struct POST_ACQUIRE_SHARES_locals { StakerInfo info; uint64 existed; sint64 wallet; }; + POST_ACQUIRE_SHARES_WITH_LOCALS() + { + if (input.otherContractIndex != QTREAT_QX_CONTRACT_INDEX) return; + if (input.asset.assetName != state.get().qdogeToken.assetName + || input.asset.issuer != state.get().qdogeToken.issuer) return; + if (input.owner == state.get().adminAddress) return; + if (input.numberOfShares <= 0) return; + + locals.info.staked = 0; locals.info.unstakeAmount = 0; locals.info.unstakeEpoch = 0; + locals.info.bonusEpochs = 0; locals.info.bonusAwarded = 0; locals.info.pendingBonus = 0; + locals.info.hwmHoldings = 0; locals.info.lastStaked = 0; locals.info.growthStreak = 0; + locals.existed = state.get().stakers.get(input.owner, locals.info) ? 1 : 0; + locals.info.staked = sadd(locals.info.staked, (uint64)input.numberOfShares); + if (locals.existed == 0) + { + locals.wallet = qpi.numberOfPossessedShares( + state.get().qdogeToken.assetName, state.get().qdogeToken.issuer, + input.owner, input.owner, QTREAT_QX_CONTRACT_INDEX, QTREAT_QX_CONTRACT_INDEX); + if (locals.wallet < 0) locals.wallet = 0; + locals.info.hwmHoldings = sadd(locals.info.staked, (uint64)locals.wallet); + } + if (state.mut().stakers.set(input.owner, locals.info) == NULL_INDEX) + { + return; + } + state.mut().totalStaked = sadd(state.get().totalStaked, (uint64)input.numberOfShares); + } +}; diff --git a/test/contract_qtreat.cpp b/test/contract_qtreat.cpp new file mode 100644 index 00000000..e3a3717a --- /dev/null +++ b/test/contract_qtreat.cpp @@ -0,0 +1,1136 @@ +#define NO_UEFI + +#include "contract_testing.h" + +// MARKETPLACE_OWNER must match the hardcoded id set in QBAY::INITIALIZE() (Qbay.h). +static const id QTREAT_TEST_MARKETPLACE_OWNER = ID(_R, _K, _D, _H, _C, _M, _R, _J, _Y, _C, _G, _K, _P, _D, _U, _Y, _R, _X, _G, _D, _Y, _Z, _C, _I, _Z, _I, _T, _A, _H, _Y, _O, _V, _G, _I, _U, _T, _K, _N, _D, _T, _E, _H, _P, _C, _C, _L, _W, _L, _Z, _X, _S, _H, _N, _F, _P, _D); + +static id getUser(uint64 i) +{ + return id(i + 1, i / 2 + 4, i + 10, i * 3 + 8); +} + +// Exposes QTREAT's internal state for direct assertions beyond what the view functions return. +class QTreatChecker : public QTREAT, public QTREAT::StateData +{ +public: + bool getStaker(const id& user, StakerInfo& out) const + { + return stakers.get(user, out); + } + uint64 stakerCount() const { return stakers.population(); } + uint64 getTotalStaked() const { return totalStaked; } + uint64 getQtreatBonusPool() const { return qtreatBonusPool; } + uint64 getDividendFund() const { return dividendFund; } + uint64 getStakingFund() const { return stakingFund; } + uint64 getMiningFund() const { return miningFund; } + uint64 getDripQdogePool() const { return dripQdogePool; } + uint64 getAsicCatalogSize() const { return asicCatalogSize; } + uint64 getAsicCatalogLocked() const { return asicCatalogLocked; } + uint64 getDripStartEpoch() const { return dripStartEpoch; } + uint64 getTotalAsicCount() const { return totalAsicCount; } + uint64 getTotalMiningWeight() const { return totalMiningWeight; } + AsicRig getRig(sint64 idx) const { return asicRigs.get(idx); } + uint64 getAsicRigHighWater() const { return asicRigHighWater; } + id getAdminAddress() const { return adminAddress; } + Asset getQdogeToken() const { return qdogeToken; } + Asset getQtreatToken() const { return qtreatToken; } + uint64 generalAssetBalanceOf(const id& issuer, uint64 assetName) const + { + AssetKey key; key.issuer = issuer; key.assetName = assetName; + uint64 bal = 0; + generalAssetBalances.get(key, bal); + return bal; + } +}; + +class ContractTestingQtreat : protected ContractTesting +{ +public: + id adminAddress; + id tokenIssuer; // shared issuer of both QTREAT and QDOGE assets + uint32 nextNftId = 0; + + ContractTestingQtreat() + { + initEmptySpectrum(); + initEmptyUniverse(); + system.epoch = contractDescriptions[QTREAT_CONTRACT_INDEX].constructionEpoch; + + INIT_CONTRACT(QX); + callSystemProcedure(QX_CONTRACT_INDEX, INITIALIZE); + INIT_CONTRACT(RANDOM); + callSystemProcedure(RANDOM_CONTRACT_INDEX, INITIALIZE); + INIT_CONTRACT(QBAY); + callSystemProcedure(QBAY_CONTRACT_INDEX, INITIALIZE); + INIT_CONTRACT(QTREAT); + callSystemProcedure(QTREAT_CONTRACT_INDEX, INITIALIZE); + + adminAddress = getState()->getAdminAddress(); + tokenIssuer = getState()->getQdogeToken().issuer; + EXPECT_EQ(tokenIssuer, getState()->getQtreatToken().issuer); + + increaseEnergy(adminAddress, 1); + increaseEnergy(tokenIssuer, 1); + + // Large pre-issued supplies of QDOGE and QTREAT so tests can freely fund users. + increaseEnergy(tokenIssuer, 2000000000ULL); + EXPECT_GT(issueAsset(tokenIssuer, QTREAT_QDOGE_ASSETNAME, 1000000000000LL, 0, 0), 0); + EXPECT_GT(issueAsset(tokenIssuer, QTREAT_TOKEN_ASSETNAME, 1000000000000LL, 0, 0), 0); + + // tokenIssuer still possesses almost the entire pre-issued QTREAT-token supply + // (tests only ever hand out small slices of it). Left un-excluded, that dwarfs + // any test holder's balance and would absorb virtually 100% of every dividend + // payout by weight. Exclude it (reserving the last slot so tests are free to use + // the others for their own exclude-address scenarios). + setExcludeAddress(adminAddress, QTREAT_MAX_EXCLUDE_ADDRESSES - 1, tokenIssuer); + + beginEpoch(); + } + + QTreatChecker* getState() + { + return (QTreatChecker*)contractStates[QTREAT_CONTRACT_INDEX]; + } + + void beginEpoch(bool expectSuccess = true) { callSystemProcedure(QTREAT_CONTRACT_INDEX, BEGIN_EPOCH, expectSuccess); } + void endEpoch(bool expectSuccess = true) { callSystemProcedure(QTREAT_CONTRACT_INDEX, END_EPOCH, expectSuccess); } + + // Ends the current epoch, moves the clock forward, and begins the next one. + void advanceEpoch() + { + endEpoch(); + system.epoch++; + beginEpoch(); + } + + // ---------------------------------------------------------------- QX helpers + + sint64 issueAsset(const id& issuer, uint64 assetName, sint64 numberOfShares, uint64 unitOfMeasurement, sint8 numberOfDecimalPlaces) + { + QX::IssueAsset_input input{ assetName, numberOfShares, unitOfMeasurement, numberOfDecimalPlaces }; + QX::IssueAsset_output output; + invokeUserProcedure(QX_CONTRACT_INDEX, 1, input, output, issuer, 1000000000ULL); + return output.issuedNumberOfShares; + } + + sint64 xferOwnership(const id& issuer, uint64 assetName, const id& from, sint64 numberOfShares, const id& to) + { + QX::TransferShareOwnershipAndPossession_input input; + QX::TransferShareOwnershipAndPossession_output output; + input.assetName = assetName; + input.issuer = issuer; + input.newOwnerAndPossessor = to; + input.numberOfShares = numberOfShares; + increaseEnergy(from, 100); + invokeUserProcedure(QX_CONTRACT_INDEX, 2, input, output, from, 100); + return output.transferredNumberOfShares; + } + + sint64 xferManagementRights(const id& issuer, uint64 assetName, uint32 newManagingContractIndex, sint64 numberOfShares, const id& currentOwner) + { + QX::TransferShareManagementRights_input input; + QX::TransferShareManagementRights_output output; + input.asset.assetName = assetName; + input.asset.issuer = issuer; + input.newManagingContractIndex = newManagingContractIndex; + input.numberOfShares = numberOfShares; + invokeUserProcedure(QX_CONTRACT_INDEX, 9, input, output, currentOwner, 0); + return output.transferredNumberOfShares; + } + + // Give `user` `amount` QDOGE they fully own/possess (management still with QX). + void giveQdoge(const id& user, uint64 amount) + { + EXPECT_EQ(xferOwnership(tokenIssuer, QTREAT_QDOGE_ASSETNAME, tokenIssuer, (sint64)amount, user), (sint64)amount); + } + + void giveQtreat(const id& user, uint64 amount) + { + EXPECT_EQ(xferOwnership(tokenIssuer, QTREAT_TOKEN_ASSETNAME, tokenIssuer, (sint64)amount, user), (sint64)amount); + } + + // Give + stake in one step: transfers `amount` QDOGE to `user`, then the user + // transfers management rights to QTREAT (this is what real staking looks like). + sint64 stakeQdoge(const id& user, uint64 amount) + { + giveQdoge(user, amount); + return xferManagementRights(tokenIssuer, QTREAT_QDOGE_ASSETNAME, QTREAT_CONTRACT_INDEX, (sint64)amount, user); + } + + // ---------------------------------------------------------------- QBAY helpers + + void activateQbayMarket() + { + increaseEnergy(QTREAT_TEST_MARKETPLACE_OWNER, 1); + QBAY::changeStatusOfMarketPlace_input input{ 1 }; + QBAY::changeStatusOfMarketPlace_output output; + invokeUserProcedure(QBAY_CONTRACT_INDEX, 17, input, output, QTREAT_TEST_MARKETPLACE_OWNER, 0); + EXPECT_EQ(output.returnCode, 0u); + } + + // Mints a single free-standing NFT (no collection) owned by `user`. IDs are + // assigned sequentially by QBAY starting at 0, so the first call here returns 0. + uint32 mintNft(const id& user) + { + QBAY::mint_input input{}; + input.royalty = 0; + input.collectionId = 0; + input.typeOfMint = 1; + QBAY::mint_output output; + increaseEnergy(user, QBAY_SINGLE_NFT_CREATE_FEE); + invokeUserProcedure(QBAY_CONTRACT_INDEX, 3, input, output, user, QBAY_SINGLE_NFT_CREATE_FEE); + EXPECT_EQ(output.returnCode, 0u); + return nextNftId++; + } + + void transferNft(const id& user, uint32 NFTid, const id& receiver) + { + QBAY::transfer_input input{ receiver, NFTid }; + QBAY::transfer_output output; + invokeUserProcedure(QBAY_CONTRACT_INDEX, 5, input, output, user, 0); + EXPECT_EQ(output.returnCode, 0u); + } + + // ---------------------------------------------------------------- QTREAT view wrappers + + QTREAT::GetStakingInfo_output getStakingInfo(const id& staker) + { + QTREAT::GetStakingInfo_input input{ staker }; + QTREAT::GetStakingInfo_output output; + callFunction(QTREAT_CONTRACT_INDEX, 1, input, output); + return output; + } + + QTREAT::GetPhaseInfo_output getPhaseInfo() + { + QTREAT::GetPhaseInfo_input input{}; + QTREAT::GetPhaseInfo_output output; + callFunction(QTREAT_CONTRACT_INDEX, 2, input, output); + return output; + } + + QTREAT::GetFunds_output getFunds() + { + QTREAT::GetFunds_input input{}; + QTREAT::GetFunds_output output; + callFunction(QTREAT_CONTRACT_INDEX, 3, input, output); + return output; + } + + QTREAT::GetRaffleInfo_output getRaffleInfo() + { + QTREAT::GetRaffleInfo_input input{}; + QTREAT::GetRaffleInfo_output output; + callFunction(QTREAT_CONTRACT_INDEX, 4, input, output); + return output; + } + + QTREAT::GetExcludeAddresses_output getExcludeAddresses() + { + QTREAT::GetExcludeAddresses_input input{}; + QTREAT::GetExcludeAddresses_output output; + callFunction(QTREAT_CONTRACT_INDEX, 5, input, output); + return output; + } + + QTREAT::GetNftInfo_output getNftInfo(const id& wallet) + { + QTREAT::GetNftInfo_input input{ wallet }; + QTREAT::GetNftInfo_output output; + callFunction(QTREAT_CONTRACT_INDEX, 6, input, output); + return output; + } + + QTREAT::GetMinerInfo_output getMinerInfo(const id& wallet) + { + QTREAT::GetMinerInfo_input input{ wallet }; + QTREAT::GetMinerInfo_output output; + callFunction(QTREAT_CONTRACT_INDEX, 7, input, output); + return output; + } + + QTREAT::GetAsicCatalogInfo_output getAsicCatalogInfo(uint32 nftId) + { + QTREAT::GetAsicCatalogInfo_input input{ nftId }; + QTREAT::GetAsicCatalogInfo_output output; + callFunction(QTREAT_CONTRACT_INDEX, 8, input, output); + return output; + } + + // ---------------------------------------------------------------- QTREAT procedure wrappers + + uint32 depositDividends(const id& user, sint64 amount) + { + QTREAT::DepositDividends_input input{}; + QTREAT::DepositDividends_output output; + invokeUserProcedure(QTREAT_CONTRACT_INDEX, 1, input, output, user, amount); + return output.returnCode; + } + + uint32 depositStakingFund(const id& user, sint64 amount) + { + QTREAT::DepositStakingFund_input input{}; + QTREAT::DepositStakingFund_output output; + invokeUserProcedure(QTREAT_CONTRACT_INDEX, 2, input, output, user, amount); + return output.returnCode; + } + + QTREAT::RequestUnstake_output requestUnstake(const id& user, uint64 amount, sint64 fee) + { + QTREAT::RequestUnstake_input input{ amount }; + QTREAT::RequestUnstake_output output; + invokeUserProcedure(QTREAT_CONTRACT_INDEX, 3, input, output, user, fee); + return output; + } + + uint32 finalizeUnstake(const id& user) + { + QTREAT::FinalizeUnstake_input input{}; + QTREAT::FinalizeUnstake_output output; + invokeUserProcedure(QTREAT_CONTRACT_INDEX, 4, input, output, user, 0); + return output.returnCode; + } + + QTREAT::ClaimQtreatBonus_output claimQtreatBonus(const id& user, sint64 fee) + { + QTREAT::ClaimQtreatBonus_input input{}; + QTREAT::ClaimQtreatBonus_output output; + invokeUserProcedure(QTREAT_CONTRACT_INDEX, 5, input, output, user, fee); + return output; + } + + // Admin path: transfers `amount` QTREAT-token management rights to QTREAT, then deposits. + uint32 depositQtreatTokensAsAdmin(uint64 amount) + { + giveQtreat(adminAddress, amount); + EXPECT_EQ(xferManagementRights(tokenIssuer, QTREAT_TOKEN_ASSETNAME, QTREAT_CONTRACT_INDEX, (sint64)amount, adminAddress), (sint64)amount); + QTREAT::DepositQtreatTokens_input input{ amount }; + QTREAT::DepositQtreatTokens_output output; + invokeUserProcedure(QTREAT_CONTRACT_INDEX, 6, input, output, adminAddress, 0); + return output.returnCode; + } + + uint32 depositQtreatTokensRaw(const id& caller, uint64 amount) + { + QTREAT::DepositQtreatTokens_input input{ amount }; + QTREAT::DepositQtreatTokens_output output; + invokeUserProcedure(QTREAT_CONTRACT_INDEX, 6, input, output, caller, 0); + return output.returnCode; + } + + uint32 depositGeneralAssetRaw(const id& caller, const Asset& asset, uint64 amount) + { + QTREAT::DepositGeneralAsset_input input{ asset, amount }; + QTREAT::DepositGeneralAsset_output output; + invokeUserProcedure(QTREAT_CONTRACT_INDEX, 7, input, output, caller, 0); + return output.returnCode; + } + + uint32 setExcludeAddress(const id& caller, uint64 slot, const id& address) + { + QTREAT::SetExcludeAddress_input input{ slot, address }; + QTREAT::SetExcludeAddress_output output; + invokeUserProcedure(QTREAT_CONTRACT_INDEX, 8, input, output, caller, 0); + return output.returnCode; + } + + uint32 revokeGeneralAsset(const id& caller, const Asset& asset, uint64 amount, sint64 fee) + { + QTREAT::RevokeGeneralAsset_input input{ asset, amount }; + QTREAT::RevokeGeneralAsset_output output; + invokeUserProcedure(QTREAT_CONTRACT_INDEX, 9, input, output, caller, fee); + return output.returnCode; + } + + uint32 releaseManagedShares(const id& caller, const Asset& asset, uint64 amount, sint64 fee) + { + QTREAT::ReleaseManagedShares_input input{ asset, amount }; + QTREAT::ReleaseManagedShares_output output; + invokeUserProcedure(QTREAT_CONTRACT_INDEX, 10, input, output, caller, fee); + return output.returnCode; + } + + uint32 depositMiningFund(const id& user, sint64 amount) + { + QTREAT::DepositMiningFund_input input{}; + QTREAT::DepositMiningFund_output output; + invokeUserProcedure(QTREAT_CONTRACT_INDEX, 11, input, output, user, amount); + return output.returnCode; + } + + QTREAT::LoadAsicPart_output loadAsicPart(const id& caller, uint32 nftId, uint8 category, uint8 rarity) + { + QTREAT::LoadAsicPart_input input{ nftId, category, rarity }; + QTREAT::LoadAsicPart_output output; + invokeUserProcedure(QTREAT_CONTRACT_INDEX, 12, input, output, caller, 0); + return output; + } + + QTREAT::RegisterAsic_output registerAsic(const id& caller, uint32 m, uint32 c, uint32 p, uint32 f) + { + QTREAT::RegisterAsic_input input{ m, c, p, f }; + QTREAT::RegisterAsic_output output; + invokeUserProcedure(QTREAT_CONTRACT_INDEX, 13, input, output, caller, 0); + return output; + } + + uint32 unregisterAsic(const id& caller, uint64 rigIndex) + { + QTREAT::UnregisterAsic_input input{ rigIndex }; + QTREAT::UnregisterAsic_output output; + invokeUserProcedure(QTREAT_CONTRACT_INDEX, 14, input, output, caller, 0); + return output.returnCode; + } + + uint32 depositDripQdogeAsAdmin(uint64 amount) + { + // Admin transfers QDOGE management rights to QTREAT without becoming a staker + // (PRE_ACQUIRE_SHARES / POST_ACQUIRE_SHARES both bypass the admin address). + giveQdoge(adminAddress, amount); + EXPECT_EQ(xferManagementRights(tokenIssuer, QTREAT_QDOGE_ASSETNAME, QTREAT_CONTRACT_INDEX, (sint64)amount, adminAddress), (sint64)amount); + QTREAT::DepositDripQdoge_input input{ amount }; + QTREAT::DepositDripQdoge_output output; + invokeUserProcedure(QTREAT_CONTRACT_INDEX, 15, input, output, adminAddress, 0); + return output.returnCode; + } + + uint32 depositDripQdogeRaw(const id& caller, uint64 amount) + { + QTREAT::DepositDripQdoge_input input{ amount }; + QTREAT::DepositDripQdoge_output output; + invokeUserProcedure(QTREAT_CONTRACT_INDEX, 15, input, output, caller, 0); + return output.returnCode; + } + + uint32 setMiningRate(const id& caller, uint64 rate) + { + QTREAT::SetMiningRate_input input{ rate }; + QTREAT::SetMiningRate_output output; + invokeUserProcedure(QTREAT_CONTRACT_INDEX, 16, input, output, caller, 0); + return output.returnCode; + } + + // Loads the full 400-part ASIC catalog. Reserves nftId==category (0,1,2,3) as the + // single COMMON-rarity part of each category, so a real 4-part rig can be built + // from just 4 minted QBAY NFTs (ids 0..3) without needing 400 real NFTs. + void loadFullAsicCatalog() + { + static const uint64 budgets[5] = { 40, 30, 18, 10, 2 }; + uint32 next = 10000; + for (uint8 cat = 0; cat < 4; cat++) + { + for (uint8 rarity = 0; rarity < 5; rarity++) + { + for (uint64 n = 0; n < budgets[rarity]; n++) + { + uint32 id = (rarity == 0 && n == 0) ? (uint32)cat : next++; + auto out = loadAsicPart(adminAddress, id, cat, rarity); + EXPECT_EQ(out.returnCode, QTREAT_OK); + } + } + } + EXPECT_EQ(getState()->getAsicCatalogSize(), QTREAT_ASIC_PARTS_TOTAL); + EXPECT_EQ(getState()->getAsicCatalogLocked(), 1u); + } +}; + +// ============================================================================= +// Basic state / views +// ============================================================================= + +TEST(ContractQtreat, InitialStateAfterFirstBeginEpoch) +{ + ContractTestingQtreat t; + + auto phase = t.getPhaseInfo(); + EXPECT_EQ(phase.stakingStartEpoch, (uint64)contractDescriptions[QTREAT_CONTRACT_INDEX].constructionEpoch); + EXPECT_EQ(phase.currentPhase, 1u); + EXPECT_EQ(phase.epochsRemaining, QTREAT_TOTAL_REWARD_EPOCHS); + + auto raffle = t.getRaffleInfo(); + EXPECT_EQ(raffle.totalRaffleAwarded, 0u); + EXPECT_EQ(raffle.lastRaffleWinner, NULL_ID); + EXPECT_EQ(raffle.raffleEpochsRemaining, QTREAT_RAFFLE_EPOCHS); + + auto funds = t.getFunds(); + EXPECT_EQ(funds.dividendFund, 0u); + EXPECT_EQ(funds.stakingFund, 0u); + EXPECT_EQ(funds.qtreatBonusPool, 0u); + EXPECT_EQ(funds.miningFund, 0u); + + auto info = t.getStakingInfo(getUser(1)); + EXPECT_EQ(info.isStaker, 0u); + EXPECT_EQ(info.totalStaked, 0u); + + // Slot QTREAT_MAX_EXCLUDE_ADDRESSES-1 is reserved by the test fixture itself to + // exclude tokenIssuer from dividend/NFT eligibility (see ContractTestingQtreat ctor). + auto excl = t.getExcludeAddresses(); + for (sint64 i = 0; i < (sint64)QTREAT_MAX_EXCLUDE_ADDRESSES - 1; i++) + EXPECT_EQ(excl.addresses.get(i), NULL_ID); + EXPECT_EQ(excl.addresses.get((sint64)QTREAT_MAX_EXCLUDE_ADDRESSES - 1), t.tokenIssuer); + + EXPECT_EQ(t.getNftInfo(getUser(1)).nftCount, 0u); + EXPECT_EQ(t.getMinerInfo(getUser(1)).asicCount, 0u); + EXPECT_EQ(t.getAsicCatalogInfo(0).isCataloged, 0u); +} + +TEST(ContractQtreat, DepositFundsAccumulateAndRejectZero) +{ + ContractTestingQtreat t; + id user = getUser(1); + increaseEnergy(user, 1000); + + EXPECT_EQ(t.depositDividends(user, 0), QTREAT_ERR_ZERO_AMOUNT); + EXPECT_EQ(t.depositStakingFund(user, 0), QTREAT_ERR_ZERO_AMOUNT); + EXPECT_EQ(t.depositMiningFund(user, 0), QTREAT_ERR_ZERO_AMOUNT); + + EXPECT_EQ(t.depositDividends(user, 100), QTREAT_OK); + EXPECT_EQ(t.depositStakingFund(user, 200), QTREAT_OK); + EXPECT_EQ(t.depositMiningFund(user, 300), QTREAT_OK); + + auto funds = t.getFunds(); + EXPECT_EQ(funds.dividendFund, 100u); + EXPECT_EQ(funds.stakingFund, 200u); + EXPECT_EQ(funds.miningFund, 300u); +} + +TEST(ContractQtreat, PlainTransferRoutesToDividendFund) +{ + ContractTestingQtreat t; + // POST_INCOMING_TRANSFER credits standardTransaction/qpiTransfer/revenueDonation to dividendFund. + // invokeUserProcedure's fee path (amount>0 triggers POST_INCOMING_TRANSFER with procedureTransaction, + // which is intentionally NOT one of the routed types) is exercised implicitly by DepositDividends above; + // here we confirm GetFunds reflects a single accumulation path deterministically. + id user = getUser(2); + increaseEnergy(user, 500); + EXPECT_EQ(t.depositDividends(user, 500), QTREAT_OK); + EXPECT_EQ(t.getFunds().dividendFund, 500u); +} + +// ============================================================================= +// Staking / unstaking +// ============================================================================= + +TEST(ContractQtreat, StakingEnforcesMinimum) +{ + ContractTestingQtreat t; + id user = getUser(1); + increaseEnergy(user, 1000); + + // Below minimum: PRE_ACQUIRE_SHARES denies the transfer, so QX reports 0 shares moved. + EXPECT_EQ(t.stakeQdoge(user, QTREAT_MIN_STAKE - 1), 0); + EXPECT_EQ(t.getStakingInfo(user).isStaker, 0u); + + // At minimum: succeeds. + EXPECT_EQ(t.stakeQdoge(user, QTREAT_MIN_STAKE), (sint64)QTREAT_MIN_STAKE); + auto info = t.getStakingInfo(user); + EXPECT_EQ(info.isStaker, 1u); + EXPECT_EQ(info.staked, QTREAT_MIN_STAKE); + EXPECT_EQ(t.getState()->getTotalStaked(), QTREAT_MIN_STAKE); +} + +TEST(ContractQtreat, StakingAccumulatesAcrossMultipleTransfers) +{ + ContractTestingQtreat t; + id user = getUser(1); + increaseEnergy(user, 1000); + EXPECT_EQ(t.stakeQdoge(user, QTREAT_MIN_STAKE), (sint64)QTREAT_MIN_STAKE); + EXPECT_EQ(t.stakeQdoge(user, 5000000ULL), 5000000); + EXPECT_EQ(t.getStakingInfo(user).staked, QTREAT_MIN_STAKE + 5000000ULL); + EXPECT_EQ(t.getState()->getTotalStaked(), QTREAT_MIN_STAKE + 5000000ULL); +} + +TEST(ContractQtreat, RequestUnstakeValidations) +{ + ContractTestingQtreat t; + id staker = getUser(1); + id nonStaker = getUser(2); + increaseEnergy(nonStaker, 1000); + increaseEnergy(staker, 1000); + + EXPECT_EQ(t.stakeQdoge(staker, QTREAT_MIN_STAKE * 2), (sint64)(QTREAT_MIN_STAKE * 2)); + + // Not a staker at all. + EXPECT_EQ(t.requestUnstake(nonStaker, 1, QTREAT_QX_TRANSFER_FEE).returnCode, QTREAT_ERR_NOT_STAKER); + + // Zero / excess amount. + EXPECT_EQ(t.requestUnstake(staker, 0, QTREAT_QX_TRANSFER_FEE).returnCode, QTREAT_ERR_INSUFFICIENT_STAKE); + EXPECT_EQ(t.requestUnstake(staker, QTREAT_MIN_STAKE * 2 + 1, QTREAT_QX_TRANSFER_FEE).returnCode, QTREAT_ERR_INSUFFICIENT_STAKE); + + // Leaving a dust remainder below the minimum is rejected. + EXPECT_EQ(t.requestUnstake(staker, QTREAT_MIN_STAKE * 2 - 1, QTREAT_QX_TRANSFER_FEE).returnCode, QTREAT_ERR_BELOW_MIN_STAKE); + + // Fee too low. + EXPECT_EQ(t.requestUnstake(staker, QTREAT_MIN_STAKE, QTREAT_QX_TRANSFER_FEE - 1).returnCode, QTREAT_ERR_INSUFFICIENT_FEE); + + // Successful partial unstake with fee overpayment refunded. + long long balBefore = getBalance(staker); + auto out = t.requestUnstake(staker, QTREAT_MIN_STAKE, QTREAT_QX_TRANSFER_FEE + 50); + EXPECT_EQ(out.returnCode, QTREAT_OK); + EXPECT_EQ(getBalance(staker), balBefore - QTREAT_QX_TRANSFER_FEE); + + auto info = t.getStakingInfo(staker); + EXPECT_EQ(info.staked, QTREAT_MIN_STAKE); + EXPECT_EQ(info.unstakeAmount, QTREAT_MIN_STAKE); + + // A second unstake request while one is pending is rejected. + EXPECT_EQ(t.requestUnstake(staker, 1, QTREAT_QX_TRANSFER_FEE).returnCode, QTREAT_ERR_UNSTAKE_PENDING); +} + +TEST(ContractQtreat, FinalizeUnstakeRespectsDelayAndReleasesQdoge) +{ + ContractTestingQtreat t; + id staker = getUser(1); + increaseEnergy(staker, 1000); + EXPECT_EQ(t.stakeQdoge(staker, QTREAT_MIN_STAKE), (sint64)QTREAT_MIN_STAKE); + ASSERT_EQ(t.requestUnstake(staker, QTREAT_MIN_STAKE, QTREAT_QX_TRANSFER_FEE).returnCode, QTREAT_OK); + + // Not ready immediately. + EXPECT_EQ(t.finalizeUnstake(staker), QTREAT_ERR_UNSTAKE_NOT_READY); + + for (uint64 i = 0; i < QTREAT_UNSTAKE_DELAY_EPOCHS; i++) + t.advanceEpoch(); + + sint64 walletBefore = numberOfPossessedShares(QTREAT_QDOGE_ASSETNAME, t.tokenIssuer, staker, staker, QX_CONTRACT_INDEX, QX_CONTRACT_INDEX); + EXPECT_EQ(t.finalizeUnstake(staker), QTREAT_OK); + sint64 walletAfter = numberOfPossessedShares(QTREAT_QDOGE_ASSETNAME, t.tokenIssuer, staker, staker, QX_CONTRACT_INDEX, QX_CONTRACT_INDEX); + EXPECT_EQ(walletAfter - walletBefore, (sint64)QTREAT_MIN_STAKE); + + // Staker record is dropped once fully unwound (no stake, no unstake, no bonus). + QTREAT::StakerInfo info; + EXPECT_FALSE(t.getState()->getStaker(staker, info)); +} + +TEST(ContractQtreat, EndEpochAutoReleasesMaturedUnstakes) +{ + ContractTestingQtreat t; + id staker = getUser(1); + increaseEnergy(staker, 1000); + EXPECT_EQ(t.stakeQdoge(staker, QTREAT_MIN_STAKE), (sint64)QTREAT_MIN_STAKE); + ASSERT_EQ(t.requestUnstake(staker, QTREAT_MIN_STAKE, QTREAT_QX_TRANSFER_FEE).returnCode, QTREAT_OK); + + // advanceEpoch() runs END_EPOCH BEFORE bumping the epoch counter, so maturity + // (epoch >= unstakeEpoch + DELAY_EPOCHS) is only reached by the (DELAY_EPOCHS+1)-th + // END_EPOCH call — one more than the number of epochs elapsed. + for (uint64 i = 0; i <= QTREAT_UNSTAKE_DELAY_EPOCHS; i++) + t.advanceEpoch(); + + // END_EPOCH itself (of the tick where the delay has elapsed) auto-releases; no manual + // FinalizeUnstake call needed. + sint64 wallet = numberOfPossessedShares(QTREAT_QDOGE_ASSETNAME, t.tokenIssuer, staker, staker, QX_CONTRACT_INDEX, QX_CONTRACT_INDEX); + EXPECT_EQ(wallet, (sint64)QTREAT_MIN_STAKE); + EXPECT_EQ(t.getStakingInfo(staker).unstakeAmount, 0u); +} + +// ============================================================================= +// Growth streak / progressive bonus +// ============================================================================= + +TEST(ContractQtreat, GrowthStreakIncrementsWhenStakeGrowsAndResetsOtherwise) +{ + ContractTestingQtreat t; + id grower = getUser(1); + id flat = getUser(2); + increaseEnergy(grower, 1000); + increaseEnergy(flat, 1000); + + EXPECT_EQ(t.stakeQdoge(grower, QTREAT_MIN_STAKE), (sint64)QTREAT_MIN_STAKE); + EXPECT_EQ(t.stakeQdoge(flat, QTREAT_MIN_STAKE), (sint64)QTREAT_MIN_STAKE); + + // Progressive bonus only starts accruing after QTREAT_PROGRESSIVE_START_DELAY_EPOCHS. + for (uint64 i = 0; i < QTREAT_PROGRESSIVE_START_DELAY_EPOCHS; i++) + { + EXPECT_EQ(t.stakeQdoge(grower, QTREAT_PROGRESSIVE_MIN_STEP), (sint64)QTREAT_PROGRESSIVE_MIN_STEP); + t.advanceEpoch(); + } + EXPECT_EQ(t.getStakingInfo(grower).growthStreak, 0u); + EXPECT_EQ(t.getStakingInfo(flat).growthStreak, 0u); + + // Now growing epoch over epoch increments the streak. + EXPECT_EQ(t.stakeQdoge(grower, QTREAT_PROGRESSIVE_MIN_STEP), (sint64)QTREAT_PROGRESSIVE_MIN_STEP); + t.advanceEpoch(); + EXPECT_EQ(t.getStakingInfo(grower).growthStreak, 1u); + EXPECT_EQ(t.getStakingInfo(flat).growthStreak, 0u); + + EXPECT_EQ(t.stakeQdoge(grower, QTREAT_PROGRESSIVE_MIN_STEP), (sint64)QTREAT_PROGRESSIVE_MIN_STEP); + t.advanceEpoch(); + EXPECT_EQ(t.getStakingInfo(grower).growthStreak, 2u); + + // A flat epoch (no growth) resets the streak to zero. + t.advanceEpoch(); + EXPECT_EQ(t.getStakingInfo(grower).growthStreak, 0u); +} + +TEST(ContractQtreat, StakingRewardWeightedByGrowthStreak) +{ + ContractTestingQtreat t; + id grower = getUser(1); + id flat = getUser(2); + increaseEnergy(grower, 1000); + increaseEnergy(flat, 1000); + + EXPECT_EQ(t.stakeQdoge(grower, QTREAT_MIN_STAKE), (sint64)QTREAT_MIN_STAKE); + EXPECT_EQ(t.stakeQdoge(flat, QTREAT_MIN_STAKE), (sint64)QTREAT_MIN_STAKE); + + for (uint64 i = 0; i < QTREAT_PROGRESSIVE_START_DELAY_EPOCHS; i++) + t.advanceEpoch(); + + EXPECT_EQ(t.stakeQdoge(grower, QTREAT_PROGRESSIVE_MIN_STEP), (sint64)QTREAT_PROGRESSIVE_MIN_STEP); + + id funder = getUser(3); + increaseEnergy(funder, QTREAT_STAKER_REWARD_PER_EPOCH * 2); + EXPECT_EQ(t.depositStakingFund(funder, (sint64)QTREAT_STAKER_REWARD_PER_EPOCH * 2), QTREAT_OK); + + long long growerBefore = getBalance(grower); + long long flatBefore = getBalance(flat); + t.advanceEpoch(); // pays out with grower now on a 1-epoch streak (+25 permille weight) + + long long growerReward = getBalance(grower) - growerBefore; + long long flatReward = getBalance(flat) - flatBefore; + // Grower staked slightly more AND has a streak bonus, so must earn strictly more. + EXPECT_GT(growerReward, flatReward); +} + +// ============================================================================= +// Loyalty bonus / raffle +// ============================================================================= + +TEST(ContractQtreat, LoyaltyBonusAccruesAndAutoDelivers) +{ + // Deliberately leave stakingFund/qtreatBonusPool at zero for the 12-epoch accrual + // phase: RAFFLE also requires qtreatBonusPool>0 to run, and a funded stakingFund + // would ALSO be swept by that same epoch's staking-reward payout (this staker holds + // 100% of the weight) before any delivery loop could see it. Zero-funding both keeps + // this phase a clean, deterministic test of the bonusEpochs/bonusAwarded counters + // alone, with neither the reward payout nor the raffle able to interfere. + ContractTestingQtreat t; + id staker = getUser(1); + increaseEnergy(staker, 1000); + EXPECT_EQ(t.stakeQdoge(staker, QTREAT_BONUS_THRESHOLD), (sint64)QTREAT_BONUS_THRESHOLD); + + for (uint64 i = 0; i < QTREAT_BONUS_INTERVAL_EPOCHS; i++) + t.advanceEpoch(); + + auto info = t.getStakingInfo(staker); + EXPECT_EQ(info.bonusAwarded, 1u); + EXPECT_EQ(info.pendingBonus, 1u); // earned, but undelivered: qtreatBonusPool is still empty + + // Now fund both pools and let one more END_EPOCH run the auto-delivery loop. (The + // raffle may also fire and hand out an extra token in this same epoch - that's fine, + // this only asserts the loyalty bonus's own pendingBonus got cleared and the staker + // received at least their 1 bonus token.) + id funder = getUser(9); + increaseEnergy(funder, QTREAT_STAKER_REWARD_PER_EPOCH + QTREAT_RAFFLE_ENTROPY_FEE + 1000); + EXPECT_EQ(t.depositStakingFund(funder, (sint64)(QTREAT_STAKER_REWARD_PER_EPOCH + QTREAT_RAFFLE_ENTROPY_FEE + 1000)), QTREAT_OK); + EXPECT_EQ(t.depositQtreatTokensAsAdmin(10), QTREAT_OK); + + t.advanceEpoch(); + + EXPECT_EQ(t.getStakingInfo(staker).pendingBonus, 0u); + sint64 held = numberOfPossessedShares(QTREAT_TOKEN_ASSETNAME, t.tokenIssuer, staker, staker, QX_CONTRACT_INDEX, QX_CONTRACT_INDEX); + EXPECT_GE(held, 1); +} + +TEST(ContractQtreat, LoyaltyBonusManualClaimFallback) +{ + ContractTestingQtreat t; + id staker = getUser(1); + increaseEnergy(staker, 1000); + EXPECT_EQ(t.stakeQdoge(staker, QTREAT_BONUS_THRESHOLD), (sint64)QTREAT_BONUS_THRESHOLD); + + // No pending bonus yet. + EXPECT_EQ(t.claimQtreatBonus(staker, QTREAT_QX_TRANSFER_FEE).returnCode, QTREAT_ERR_NO_PENDING_BONUS); + + // Fund the staking fund only (delivery fee available) but leave the bonus pool empty, + // so END_EPOCH auto-delivery is skipped and the bonus stays pending. + id funder = getUser(9); + increaseEnergy(funder, QTREAT_STAKER_REWARD_PER_EPOCH * QTREAT_BONUS_INTERVAL_EPOCHS); + EXPECT_EQ(t.depositStakingFund(funder, (sint64)(QTREAT_STAKER_REWARD_PER_EPOCH * QTREAT_BONUS_INTERVAL_EPOCHS)), QTREAT_OK); + + for (uint64 i = 0; i < QTREAT_BONUS_INTERVAL_EPOCHS; i++) + t.advanceEpoch(); + + EXPECT_EQ(t.getStakingInfo(staker).pendingBonus, 1u); + + // Bonus pool still empty: claim fails. + EXPECT_EQ(t.claimQtreatBonus(staker, QTREAT_QX_TRANSFER_FEE).returnCode, QTREAT_ERR_BONUS_POOL_EMPTY); + + EXPECT_EQ(t.depositQtreatTokensAsAdmin(10), QTREAT_OK); + auto claim = t.claimQtreatBonus(staker, QTREAT_QX_TRANSFER_FEE); + EXPECT_EQ(claim.returnCode, QTREAT_OK); + EXPECT_EQ(claim.claimed, 1u); + EXPECT_EQ(t.getStakingInfo(staker).pendingBonus, 0u); +} + +TEST(ContractQtreat, RaffleFallsBackToChainStateEntropyAndPaysWinner) +{ + ContractTestingQtreat t; + id staker = getUser(1); + increaseEnergy(staker, 1000); + EXPECT_EQ(t.stakeQdoge(staker, QTREAT_MIN_STAKE), (sint64)QTREAT_MIN_STAKE); + + // The staking-reward payout runs (and fully drains stakingFund, since this staker + // holds 100% of the weight) BEFORE the raffle block in the same END_EPOCH call, so + // stakingFund must cover a full epoch of staking reward *plus* the entropy fee. + id funder = getUser(9); + sint64 stakingFundTopUp = (sint64)QTREAT_STAKER_REWARD_PER_EPOCH + (sint64)QTREAT_RAFFLE_ENTROPY_FEE + 1000; + increaseEnergy(funder, stakingFundTopUp); + EXPECT_EQ(t.depositStakingFund(funder, stakingFundTopUp), QTREAT_OK); + EXPECT_EQ(t.depositQtreatTokensAsAdmin(5), QTREAT_OK); + + // RANDOM has no registered entropy providers, so BuyEntropy refunds (all-zero entropy); + // QTREAT must still pick a winner via the chain-state-digest fallback (single eligible + // staker here, so the outcome is deterministic). + t.advanceEpoch(); + + auto raffle = t.getRaffleInfo(); + EXPECT_EQ(raffle.totalRaffleAwarded, 1u); + EXPECT_EQ(raffle.lastRaffleWinner, staker); + + sint64 held = numberOfPossessedShares(QTREAT_TOKEN_ASSETNAME, t.tokenIssuer, staker, staker, QX_CONTRACT_INDEX, QX_CONTRACT_INDEX); + EXPECT_EQ(held, 1); +} + +TEST(ContractQtreat, RaffleSkipsIneligibleUnstakingStaker) +{ + ContractTestingQtreat t; + id staker = getUser(1); + increaseEnergy(staker, 1000); + EXPECT_EQ(t.stakeQdoge(staker, QTREAT_MIN_STAKE), (sint64)QTREAT_MIN_STAKE); + ASSERT_EQ(t.requestUnstake(staker, QTREAT_MIN_STAKE, QTREAT_QX_TRANSFER_FEE).returnCode, QTREAT_OK); + // Fully unstaked: staked==0, so no longer an eligible raffle participant either. + + id funder = getUser(9); + increaseEnergy(funder, QTREAT_RAFFLE_ENTROPY_FEE + 1000); + EXPECT_EQ(t.depositStakingFund(funder, (sint64)QTREAT_RAFFLE_ENTROPY_FEE + 1000), QTREAT_OK); + EXPECT_EQ(t.depositQtreatTokensAsAdmin(5), QTREAT_OK); + + t.advanceEpoch(); + EXPECT_EQ(t.getRaffleInfo().totalRaffleAwarded, 0u); +} + +// ============================================================================= +// Dividends +// ============================================================================= + +TEST(ContractQtreat, DividendsSplitBetweenShareholdersAndMinBalanceHolders) +{ + ContractTestingQtreat t; + id holder = getUser(1); + increaseEnergy(holder, 1000); + t.giveQtreat(holder, 1000); + + // Start a fresh epoch so BEGIN_EPOCH's snapshot picks up the holder's balance. + t.advanceEpoch(); + + id funder = getUser(9); + increaseEnergy(funder, 1000000); + EXPECT_EQ(t.depositDividends(funder, 1000000), QTREAT_OK); + + long long before = getBalance(holder); + t.advanceEpoch(); + long long after = getBalance(holder); + + // Shareholder cut is paid out per-computor (dust lost to integer division stays + // undistributed to shareholders); the sole holder receives the entire remainder. + uint64 shareholderShare = 1000000ULL * QTREAT_DIVIDEND_SHAREHOLDER_PERMILLE / 1000; + uint64 paidShareholders = (shareholderShare / NUMBER_OF_COMPUTORS) * NUMBER_OF_COMPUTORS; + uint64 expectedHolderShare = 1000000ULL - paidShareholders; + EXPECT_EQ((uint64)(after - before), expectedHolderShare); + EXPECT_GT(t.getState()->totalShareholderDividends, 0u); +} + +TEST(ContractQtreat, DividendsUseMinOfBeginAndEndBalance) +{ + ContractTestingQtreat t; + id holderA = getUser(1); // keeps full balance all epoch + id holderB = getUser(2); // sells half mid-epoch + increaseEnergy(holderA, 1000); + increaseEnergy(holderB, 1000); + t.giveQtreat(holderA, 1000); + t.giveQtreat(holderB, 1000); + + t.advanceEpoch(); // snapshot begin balances: both at 1000 + + // holderB moves half away mid-epoch -> end balance 500, min(begin,end)=500. + id sink = getUser(3); + EXPECT_EQ(t.xferOwnership(t.tokenIssuer, QTREAT_TOKEN_ASSETNAME, holderB, 500, sink), 500); + + id funder = getUser(9); + increaseEnergy(funder, 3000000); + EXPECT_EQ(t.depositDividends(funder, 3000000), QTREAT_OK); + + long long beforeA = getBalance(holderA); + long long beforeB = getBalance(holderB); + t.advanceEpoch(); + long long rewardA = getBalance(holderA) - beforeA; + long long rewardB = getBalance(holderB) - beforeB; + + // holderA's effective weight (1000) is double holderB's (500 = min(1000,500)). + EXPECT_GT(rewardA, rewardB); + EXPECT_NEAR((double)rewardA / (double)rewardB, 2.0, 0.05); +} + +TEST(ContractQtreat, DividendsExcludeConfiguredAddress) +{ + ContractTestingQtreat t; + id excluded = getUser(1); + id normal = getUser(2); + increaseEnergy(excluded, 1000); + increaseEnergy(normal, 1000); + t.giveQtreat(excluded, 1000); + t.giveQtreat(normal, 1000); + + EXPECT_EQ(t.setExcludeAddress(t.adminAddress, 0, excluded), QTREAT_OK); + // Non-admin cannot set exclusions. + EXPECT_EQ(t.setExcludeAddress(normal, 1, normal), QTREAT_ERR_ACCESS_DENIED); + + t.advanceEpoch(); + + id funder = getUser(9); + increaseEnergy(funder, 1000000); + EXPECT_EQ(t.depositDividends(funder, 1000000), QTREAT_OK); + + long long beforeExcluded = getBalance(excluded); + long long beforeNormal = getBalance(normal); + t.advanceEpoch(); + + EXPECT_EQ(getBalance(excluded), beforeExcluded); + EXPECT_GT(getBalance(normal), beforeNormal); +} + +// ============================================================================= +// General asset custody +// ============================================================================= + +TEST(ContractQtreat, GeneralAssetCustodyIsAdminGatedAndRoundTrips) +{ + ContractTestingQtreat t; + id nonAdmin = getUser(1); + increaseEnergy(nonAdmin, 1000); + + id assetIssuer = getUser(50); + increaseEnergy(assetIssuer, 2000000000ULL); + uint64 assetName = assetNameFromString("TASSET"); + EXPECT_GT(t.issueAsset(assetIssuer, assetName, 1000000, 0, 0), 0); + + Asset asset; asset.issuer = assetIssuer; asset.assetName = assetName; + + // Give the admin the shares, but do NOT transfer management rights: DepositGeneralAsset + // should refuse a non-admin caller regardless, and the admin call needs mgmt rights first. + t.xferOwnership(assetIssuer, assetName, assetIssuer, 5000, t.adminAddress); + EXPECT_EQ(t.depositGeneralAssetRaw(nonAdmin, asset, 1000), QTREAT_ERR_ACCESS_DENIED); + + EXPECT_EQ(t.xferManagementRights(assetIssuer, assetName, QTREAT_CONTRACT_INDEX, 5000, t.adminAddress), 5000); + EXPECT_EQ(t.depositGeneralAssetRaw(t.adminAddress, asset, 5000), QTREAT_OK); + EXPECT_EQ(t.getState()->generalAssetBalanceOf(assetIssuer, assetName), 5000u); + + // Revoke: non-admin rejected, over-revoke rejected, full revoke clears the entry. + EXPECT_EQ(t.revokeGeneralAsset(nonAdmin, asset, 1000, QTREAT_QX_TRANSFER_FEE), QTREAT_ERR_ACCESS_DENIED); + increaseEnergy(t.adminAddress, QTREAT_QX_TRANSFER_FEE * 2); + EXPECT_EQ(t.revokeGeneralAsset(t.adminAddress, asset, 6000, QTREAT_QX_TRANSFER_FEE), QTREAT_ERR_INVALID_INPUT); + EXPECT_EQ(t.revokeGeneralAsset(t.adminAddress, asset, 5000, QTREAT_QX_TRANSFER_FEE), QTREAT_OK); + EXPECT_EQ(t.getState()->generalAssetBalanceOf(assetIssuer, assetName), 0u); + + sint64 backInWallet = numberOfPossessedShares(assetName, assetIssuer, t.adminAddress, t.adminAddress, QX_CONTRACT_INDEX, QX_CONTRACT_INDEX); + EXPECT_EQ(backInWallet, 5000); +} + +TEST(ContractQtreat, ReleaseManagedSharesAllowsAdminToPullBackUnaccountedQdoge) +{ + ContractTestingQtreat t; + // Admin transfers QDOGE management to QTREAT without staking (bypass path). + t.giveQdoge(t.adminAddress, 1000000); + EXPECT_EQ(t.xferManagementRights(t.tokenIssuer, QTREAT_QDOGE_ASSETNAME, QTREAT_CONTRACT_INDEX, 1000000, t.adminAddress), 1000000); + + increaseEnergy(t.adminAddress, QTREAT_QX_TRANSFER_FEE); + EXPECT_EQ(t.releaseManagedShares(t.adminAddress, t.getState()->getQdogeToken(), 1000000, QTREAT_QX_TRANSFER_FEE), QTREAT_OK); + + sint64 backInWallet = numberOfPossessedShares(QTREAT_QDOGE_ASSETNAME, t.tokenIssuer, t.adminAddress, t.adminAddress, QX_CONTRACT_INDEX, QX_CONTRACT_INDEX); + EXPECT_EQ(backInWallet, 1000000); +} + +TEST(ContractQtreat, ReleaseManagedSharesRejectsReleasingActiveStake) +{ + ContractTestingQtreat t; + id staker = getUser(1); + increaseEnergy(staker, QTREAT_QX_TRANSFER_FEE * 2); + EXPECT_EQ(t.stakeQdoge(staker, QTREAT_MIN_STAKE), (sint64)QTREAT_MIN_STAKE); + + // All managed QDOGE is accounted for by `staked`, so there is no surplus to release. + EXPECT_EQ(t.releaseManagedShares(staker, t.getState()->getQdogeToken(), QTREAT_MIN_STAKE, QTREAT_QX_TRANSFER_FEE), QTREAT_ERR_INVALID_INPUT); +} + +// ============================================================================= +// ASIC mining / drip +// ============================================================================= + +TEST(ContractQtreat, LoadAsicPartValidatesInputsAndEnforcesPerRarityBudget) +{ + ContractTestingQtreat t; + id nonAdmin = getUser(1); + increaseEnergy(nonAdmin, 1000); + + EXPECT_EQ(t.loadAsicPart(nonAdmin, 0, 0, 0).returnCode, QTREAT_ERR_ACCESS_DENIED); + EXPECT_EQ(t.loadAsicPart(t.adminAddress, 0, 4, 0).returnCode, QTREAT_ERR_INVALID_INPUT); // category >= 4 + EXPECT_EQ(t.loadAsicPart(t.adminAddress, 0, 0, 5).returnCode, QTREAT_ERR_INVALID_INPUT); // rarity > MAX_RARITY + + EXPECT_EQ(t.loadAsicPart(t.adminAddress, 100, 0, 0).returnCode, QTREAT_OK); + EXPECT_EQ(t.loadAsicPart(t.adminAddress, 100, 0, 0).returnCode, QTREAT_ERR_INVALID_INPUT); // duplicate nftId + + // Legendary (rarity 4) budget is only 2 per category. + EXPECT_EQ(t.loadAsicPart(t.adminAddress, 200, 1, 4).returnCode, QTREAT_OK); + EXPECT_EQ(t.loadAsicPart(t.adminAddress, 201, 1, 4).returnCode, QTREAT_OK); + EXPECT_EQ(t.loadAsicPart(t.adminAddress, 202, 1, 4).returnCode, QTREAT_ERR_INVALID_INPUT); + + auto catInfo = t.getAsicCatalogInfo(100); + EXPECT_EQ(catInfo.isCataloged, 1u); + EXPECT_EQ(catInfo.category, 0u); + EXPECT_EQ(catInfo.rarity, 0u); + EXPECT_EQ(catInfo.locked, 0u); +} + +TEST(ContractQtreat, RegisterAsicRejectedBeforeCatalogLocked) +{ + ContractTestingQtreat t; + t.activateQbayMarket(); + uint32 m = t.mintNft(getUser(1)); + uint32 c = t.mintNft(getUser(1)); + uint32 p = t.mintNft(getUser(1)); + uint32 f = t.mintNft(getUser(1)); + EXPECT_EQ(t.loadAsicPart(t.adminAddress, m, 0, 0).returnCode, QTREAT_OK); + EXPECT_EQ(t.loadAsicPart(t.adminAddress, c, 1, 0).returnCode, QTREAT_OK); + EXPECT_EQ(t.loadAsicPart(t.adminAddress, p, 2, 0).returnCode, QTREAT_OK); + EXPECT_EQ(t.loadAsicPart(t.adminAddress, f, 3, 0).returnCode, QTREAT_OK); + + EXPECT_EQ(t.registerAsic(getUser(1), m, c, p, f).returnCode, QTREAT_ERR_INVALID_INPUT); +} + +TEST(ContractQtreat, RegisterAndUnregisterAsicAfterCatalogLocked) +{ + ContractTestingQtreat t; + t.activateQbayMarket(); + + id owner = getUser(1); + id notOwner = getUser(2); + increaseEnergy(notOwner, 1000); + uint32 nftMotherboard = t.mintNft(owner); // 0 + uint32 nftChip = t.mintNft(owner); // 1 + uint32 nftPsu = t.mintNft(owner); // 2 + uint32 nftFan = t.mintNft(owner); // 3 + ASSERT_EQ(nftMotherboard, 0u); + ASSERT_EQ(nftFan, 3u); + + t.loadFullAsicCatalog(); // reserves ids 0,1,2,3 as common parts of categories 0..3 + EXPECT_EQ(t.getState()->getDripStartEpoch(), (uint64)system.epoch); + + // Wrong owner: notOwner doesn't possess these NFTs. + EXPECT_EQ(t.registerAsic(notOwner, nftMotherboard, nftChip, nftPsu, nftFan).returnCode, QTREAT_ERR_ACCESS_DENIED); + + auto reg = t.registerAsic(owner, nftMotherboard, nftChip, nftPsu, nftFan); + EXPECT_EQ(reg.returnCode, QTREAT_OK); + // All 4 parts are COMMON rarity (1 point each) -> weight 4. + EXPECT_EQ(reg.asicWeight, 4u); + EXPECT_EQ(t.getState()->getTotalAsicCount(), 1u); + EXPECT_EQ(t.getState()->getTotalMiningWeight(), 4u); + + auto minerInfo = t.getMinerInfo(owner); + EXPECT_EQ(minerInfo.asicCount, 1u); + EXPECT_EQ(minerInfo.weight, 4u); + + // Reusing an already-used part is rejected. + uint32 spareMotherboard = t.mintNft(owner); + // spareMotherboard isn't in the catalog at all yet, so registering with it fails as invalid input too; + // instead verify direct re-use of nftMotherboard (already used) is rejected against a fresh valid set: + EXPECT_EQ(t.registerAsic(owner, nftMotherboard, nftChip, nftPsu, nftFan).returnCode, QTREAT_ERR_INVALID_INPUT); + (void)spareMotherboard; + + // Unregister: non-owner rejected, owner succeeds and frees weight/parts. + EXPECT_EQ(t.unregisterAsic(notOwner, reg.rigIndex), QTREAT_ERR_ACCESS_DENIED); + EXPECT_EQ(t.unregisterAsic(owner, reg.rigIndex), QTREAT_OK); + EXPECT_EQ(t.getState()->getTotalAsicCount(), 0u); + EXPECT_EQ(t.getState()->getTotalMiningWeight(), 0u); +} + +TEST(ContractQtreat, MiningRewardSplits5050WithDividendFundAndPaysByWeight) +{ + ContractTestingQtreat t; + t.activateQbayMarket(); + id owner = getUser(1); + uint32 m = t.mintNft(owner), c = t.mintNft(owner), p = t.mintNft(owner), f = t.mintNft(owner); + t.loadFullAsicCatalog(); + auto reg = t.registerAsic(owner, m, c, p, f); + ASSERT_EQ(reg.returnCode, QTREAT_OK); + + EXPECT_EQ(t.setMiningRate(t.adminAddress, QTREAT_MINING_REWARD_MAX + 1), QTREAT_ERR_INVALID_INPUT); + EXPECT_EQ(t.setMiningRate(t.adminAddress, 1000000), QTREAT_OK); + + id funder = getUser(9); + increaseEnergy(funder, 1000000); + EXPECT_EQ(t.depositMiningFund(funder, 1000000), QTREAT_OK); + + uint64 dividendBefore = t.getFunds().dividendFund; + long long ownerBefore = getBalance(owner); + t.advanceEpoch(); + + // Sole rig owns 100% of totalMiningWeight, so it gets the full miner half. + uint64 expectedMinerHalf = 1000000 / 2; + uint64 expectedDividendHalf = 1000000 - expectedMinerHalf; + EXPECT_EQ((uint64)(getBalance(owner) - ownerBefore), expectedMinerHalf); + EXPECT_EQ(t.getFunds().dividendFund - dividendBefore, expectedDividendHalf); +} + +TEST(ContractQtreat, AsicRigDeactivatedWhenPartOwnershipChanges) +{ + ContractTestingQtreat t; + t.activateQbayMarket(); + id owner = getUser(1); + id newOwner = getUser(2); + uint32 m = t.mintNft(owner), c = t.mintNft(owner), p = t.mintNft(owner), f = t.mintNft(owner); + t.loadFullAsicCatalog(); + auto reg = t.registerAsic(owner, m, c, p, f); + ASSERT_EQ(reg.returnCode, QTREAT_OK); + ASSERT_EQ(t.getState()->getTotalAsicCount(), 1u); + + t.transferNft(owner, m, newOwner); // motherboard no longer owned by the rig's registered owner + + t.advanceEpoch(); + + EXPECT_EQ(t.getState()->getTotalAsicCount(), 0u); + EXPECT_EQ(t.getState()->getRig((sint64)reg.rigIndex).active, 0u); +} + +TEST(ContractQtreat, DripQdogePaysCatalogedNftHoldersByRarityAndExcludesAdmin) +{ + ContractTestingQtreat t; + t.activateQbayMarket(); + id holder = getUser(1); + // Mint the 4 NFTs that will land on the reserved common ids (0..3, category==id). + uint32 id0 = t.mintNft(holder); + uint32 id1 = t.mintNft(holder); + uint32 id2 = t.mintNft(holder); + uint32 id3 = t.mintNft(holder); + ASSERT_EQ(id0, 0u); ASSERT_EQ(id1, 1u); ASSERT_EQ(id2, 2u); ASSERT_EQ(id3, 3u); + + t.loadFullAsicCatalog(); // locks catalog + sets dripStartEpoch; ids 0..3 are COMMON parts + + id nonAdmin = getUser(5); + increaseEnergy(nonAdmin, 1000); + EXPECT_EQ(t.depositDripQdogeRaw(nonAdmin, 1000), QTREAT_ERR_ACCESS_DENIED); + + EXPECT_EQ(t.depositDripQdogeAsAdmin(1000000), QTREAT_OK); + + // Drip payouts release QDOGE back to QX per-recipient, and that release fee is paid + // out of miningFund (not dripQdogePool/stakingFund) - fund it too. + id miningFunder = getUser(6); + increaseEnergy(miningFunder, 100000); + EXPECT_EQ(t.depositMiningFund(miningFunder, 100000), QTREAT_OK); + + sint64 walletBefore = numberOfPossessedShares(QTREAT_QDOGE_ASSETNAME, t.tokenIssuer, holder, holder, QX_CONTRACT_INDEX, QX_CONTRACT_INDEX); + t.advanceEpoch(); + sint64 walletAfter = numberOfPossessedShares(QTREAT_QDOGE_ASSETNAME, t.tokenIssuer, holder, holder, QX_CONTRACT_INDEX, QX_CONTRACT_INDEX); + + // holder possesses 4 COMMON-rarity cataloged NFTs -> 4 * QTREAT_DRIP_QDOGE_COMMON. + EXPECT_EQ((uint64)(walletAfter - walletBefore), 4ull * QTREAT_DRIP_QDOGE_COMMON); +} From 91f45c3cabf7413cf39ac07da6c364a47f2c73bf Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Sat, 1 Aug 2026 20:41:34 +0900 Subject: [PATCH 2/4] fix: admin wallet and execution-exhaustion fix --- src/contracts/QTREAT.h | 85 +++++++++++++++++++++++++++++----------- test/contract_qtreat.cpp | 45 ++++++++++++++++++++- 2 files changed, 107 insertions(+), 23 deletions(-) diff --git a/src/contracts/QTREAT.h b/src/contracts/QTREAT.h index 10f13c11..b79af455 100644 --- a/src/contracts/QTREAT.h +++ b/src/contracts/QTREAT.h @@ -33,6 +33,12 @@ constexpr uint64 QTREAT_ASIC_CATALOG_CAPACITY = 512; constexpr uint64 QTREAT_ASIC_PARTS_TOTAL = 400; constexpr uint64 QTREAT_MAX_ASIC_RIGS = 128; constexpr uint64 QTREAT_MAX_TOTAL_ASICS = 100; +// Ownership re-verification / possessor snapshotting is spread across this many epochs +// (round-robin by slot index) instead of touching every entry every epoch, so a large +// registered-rig or dividend-NFT set can't force hundreds of external QBAY calls into a +// single END_EPOCH. Each entry is still re-checked at least once every N epochs. +constexpr uint64 QTREAT_ASIC_VERIFY_SPREAD_EPOCHS = 8; +constexpr uint64 QTREAT_NFT_SNAPSHOT_SPREAD_EPOCHS = 4; constexpr uint64 QTREAT_MINING_REWARD_DEFAULT = 20000000ULL; constexpr uint64 QTREAT_MINING_REWARD_MAX = 100000000ULL; static_assert(QTREAT_MINING_REWARD_DEFAULT <= QTREAT_MINING_REWARD_MAX); @@ -160,6 +166,10 @@ struct QTREAT : public ContractBase Array dividendNftIds; uint64 dividendNftIdCount; + // Last possessor observed for each dividendNftIds slot (NULL_ID = none/excluded), so the + // round-robin snapshot below can update nftCounts incrementally instead of resetting and + // re-querying all QTREAT_MAX_DIVIDEND_NFTS ids every single epoch. + Array dividendNftLastPossessor; HashMap nftCounts; uint64 totalNftCount; @@ -889,15 +899,15 @@ struct QTREAT : public ContractBase state.mut().qdogeToken.issuer = state.get().qtreatToken.issuer; state.mut().qdogeToken.assetName = QTREAT_QDOGE_ASSETNAME; - // DEV TESTNET: admin decoupled from token issuer. + // Admin address: QTREATZZIVFYQAIBKCZPSHGLIRMALZKHEWAPFLFXJAMDAXMGTBKQVXHHDHUD state.mut().adminAddress = ID( - _E, _Y, _Y, _T, _L, _U, _Z, _O, - _D, _U, _W, _O, _W, _F, _Y, _C, - _B, _J, _P, _Q, _M, _Y, _R, _S, - _O, _C, _C, _A, _D, _H, _Y, _S, - _Z, _Z, _O, _U, _N, _K, _Q, _W, - _Y, _D, _C, _J, _A, _U, _A, _H, - _G, _I, _D, _V, _D, _J, _M, _D + _Q, _T, _R, _E, _A, _T, _Z, _Z, + _I, _V, _F, _Y, _Q, _A, _I, _B, + _K, _C, _Z, _P, _S, _H, _G, _L, + _I, _R, _M, _A, _L, _Z, _K, _H, + _E, _W, _A, _P, _F, _L, _F, _X, + _J, _A, _M, _D, _A, _X, _M, _G, + _T, _B, _K, _Q, _V, _X, _H, _H ); state.mut().dividendNftIds.set(0, 4968); @@ -1194,6 +1204,7 @@ struct QTREAT : public ContractBase uint64 minerReward; uint64 stillOwned; uint32 rigPartId; sint64 pIdx; uint64 verifyOk; QBAY::getInfoOfNFTById_input qbayIn; QBAY::getInfoOfNFTById_output qbayOut; Entity ent; uint64 contractBalance; + id oldPossessor; }; END_EPOCH_WITH_LOCALS() { @@ -1443,6 +1454,9 @@ struct QTREAT : public ContractBase { locals.rig = state.get().asicRigs.get(locals.sidx); if (locals.rig.active == 0) continue; + // Only re-verify a rotating slice of rigs this epoch (see QTREAT_ASIC_VERIFY_SPREAD_EPOCHS); + // un-checked rigs simply keep their current active state until their turn comes up. + if (mod((uint64)locals.sidx + qpi.epoch(), QTREAT_ASIC_VERIFY_SPREAD_EPOCHS) != 0) continue; locals.stillOwned = 1; locals.verifyOk = 1; for (locals.pIdx = 0; locals.pIdx < 4; locals.pIdx++) @@ -1510,28 +1524,55 @@ struct QTREAT : public ContractBase if (state.get().dividendNftIdCount > 0) { - state.mut().nftCounts.reset(); - state.mut().totalNftCount = 0; + // Re-check only a rotating slice of the dividend-NFT ids this epoch (see + // QTREAT_NFT_SNAPSHOT_SPREAD_EPOCHS) instead of resetting nftCounts and re-querying + // QBAY for all of them every epoch. nftCounts/totalNftCount are updated incrementally + // against dividendNftLastPossessor, so ids not re-checked this epoch keep contributing + // their last-known possessor unchanged. for (locals.sidx = 0; locals.sidx < (sint64)state.get().dividendNftIdCount; locals.sidx++) { + if (mod((uint64)locals.sidx + qpi.epoch(), QTREAT_NFT_SNAPSHOT_SPREAD_EPOCHS) != 0) continue; + locals.qbayIn.NFTId = state.get().dividendNftIds.get(locals.sidx); CALL_OTHER_CONTRACT_FUNCTION(QBAY, getInfoOfNFTById, locals.qbayIn, locals.qbayOut); - if (interContractCallError != NoCallError) continue; - if (locals.qbayOut.possessor == NULL_ID || locals.qbayOut.possessor == SELF) continue; - locals.isExcluded = 0; - for (locals.exIdx = 0; locals.exIdx < (sint64)QTREAT_MAX_EXCLUDE_ADDRESSES; locals.exIdx++) + locals.h = (interContractCallError != NoCallError) ? NULL_ID : locals.qbayOut.possessor; + if (locals.h == SELF) locals.h = NULL_ID; + if (locals.h != NULL_ID) { - if (state.get().excludeAddresses.get(locals.exIdx) != NULL_ID - && locals.qbayOut.possessor == state.get().excludeAddresses.get(locals.exIdx)) + locals.isExcluded = 0; + for (locals.exIdx = 0; locals.exIdx < (sint64)QTREAT_MAX_EXCLUDE_ADDRESSES; locals.exIdx++) { - locals.isExcluded = 1; + if (state.get().excludeAddresses.get(locals.exIdx) != NULL_ID + && locals.h == state.get().excludeAddresses.get(locals.exIdx)) + { + locals.isExcluded = 1; + } } + if (locals.isExcluded != 0) locals.h = NULL_ID; + } + + locals.oldPossessor = state.get().dividendNftLastPossessor.get(locals.sidx); + if (locals.h != locals.oldPossessor) + { + if (locals.oldPossessor != NULL_ID) + { + locals.nftCnt = 0; + state.get().nftCounts.get(locals.oldPossessor, locals.nftCnt); + if (locals.nftCnt <= 1) + state.mut().nftCounts.removeByKey(locals.oldPossessor); + else + state.mut().nftCounts.set(locals.oldPossessor, locals.nftCnt - 1); + state.mut().totalNftCount = state.get().totalNftCount - 1; + } + if (locals.h != NULL_ID) + { + locals.nftCnt = 0; + state.get().nftCounts.get(locals.h, locals.nftCnt); + state.mut().nftCounts.set(locals.h, locals.nftCnt + 1); + state.mut().totalNftCount = state.get().totalNftCount + 1; + } + state.mut().dividendNftLastPossessor.set(locals.sidx, locals.h); } - if (locals.isExcluded != 0) continue; - locals.existing = 0; - state.get().nftCounts.get(locals.qbayOut.possessor, locals.existing); - state.mut().nftCounts.set(locals.qbayOut.possessor, locals.existing + 1); - state.mut().totalNftCount = state.get().totalNftCount + 1; } } diff --git a/test/contract_qtreat.cpp b/test/contract_qtreat.cpp index e3a3717a..b2173bc7 100644 --- a/test/contract_qtreat.cpp +++ b/test/contract_qtreat.cpp @@ -1,5 +1,6 @@ #define NO_UEFI +#include #include "contract_testing.h" // MARKETPLACE_OWNER must match the hardcoded id set in QBAY::INITIALIZE() (Qbay.h). @@ -439,6 +440,18 @@ class ContractTestingQtreat : protected ContractTesting // Basic state / views // ============================================================================= +TEST(ContractQtreat, AdminAddressRoundTripsToExpectedIdentity) +{ + // Confirms the ID(...) letter macro in INITIALIZE() encodes exactly the intended + // 60-character address, checksum included (the checksum is a K12 hash of the + // underlying public key, not derivable by inspection of the 56 identity letters + // alone - this is the only reliable way to catch a transcription error here). + ContractTestingQtreat t; + std::ostringstream oss; + oss << t.getState()->getAdminAddress(); + EXPECT_EQ(oss.str(), "QTREATZZIVFYQAIBKCZPSHGLIRMALZKHEWAPFLFXJAMDAXMGTBKQVXHHDHUD"); +} + TEST(ContractQtreat, InitialStateAfterFirstBeginEpoch) { ContractTestingQtreat t; @@ -1095,12 +1108,42 @@ TEST(ContractQtreat, AsicRigDeactivatedWhenPartOwnershipChanges) t.transferNft(owner, m, newOwner); // motherboard no longer owned by the rig's registered owner - t.advanceEpoch(); + // Ownership re-verification is spread across QTREAT_ASIC_VERIFY_SPREAD_EPOCHS epochs + // (round-robin by rig slot) rather than checking every rig every epoch, so the + // deactivation isn't guaranteed on the very next epoch - only within that window. + for (uint64 i = 0; i < QTREAT_ASIC_VERIFY_SPREAD_EPOCHS; i++) + t.advanceEpoch(); EXPECT_EQ(t.getState()->getTotalAsicCount(), 0u); EXPECT_EQ(t.getState()->getRig((sint64)reg.rigIndex).active, 0u); } +TEST(ContractQtreat, AsicRigStaysActiveUntilItsVerificationSlotComesUp) +{ + // The very first registered rig lands at slot 0. constructionEpoch mod + // QTREAT_ASIC_VERIFY_SPREAD_EPOCHS != 0 for this fixture's construction epoch, so slot 0 + // is *not* re-verified on the first END_EPOCH after registration - only after enough + // epochs have passed for (slot + epoch) mod SPREAD to hit 0. This pins down the + // "eventually consistent, not immediate" tradeoff explicitly rather than leaving it implicit. + ContractTestingQtreat t; + t.activateQbayMarket(); + id owner = getUser(1); + id newOwner = getUser(2); + uint32 m = t.mintNft(owner), c = t.mintNft(owner), p = t.mintNft(owner), f = t.mintNft(owner); + t.loadFullAsicCatalog(); + auto reg = t.registerAsic(owner, m, c, p, f); + ASSERT_EQ(reg.returnCode, QTREAT_OK); + ASSERT_EQ(reg.rigIndex, 0u); + ASSERT_NE(((uint64)reg.rigIndex + (uint64)system.epoch) % QTREAT_ASIC_VERIFY_SPREAD_EPOCHS, 0u) + << "test assumption violated: adjust which epoch this fixture starts at, or the rig slot used"; + + t.transferNft(owner, m, newOwner); + t.advanceEpoch(); // this epoch's slot isn't due for re-verification yet + + EXPECT_EQ(t.getState()->getTotalAsicCount(), 1u); + EXPECT_EQ(t.getState()->getRig((sint64)reg.rigIndex).active, 1u); +} + TEST(ContractQtreat, DripQdogePaysCatalogedNftHoldersByRarityAndExcludesAdmin) { ContractTestingQtreat t; From 3c5e38b811e342461ec5e661f2d6821028941477 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Mon, 3 Aug 2026 10:10:27 +0900 Subject: [PATCH 3/4] fix: eliminate reward misallocation window in ASIC/NFT epoch spreading The earlier fix that spread ASIC ownership re-verification and NFT dividend snapshots across multiple epochs (to bound external QBAY calls per END_EPOCH) had a real fund-misallocation gap: mining rewards and NFT dividends kept being paid to the last-known owner/possessor during the staleness window, so a seller could keep collecting for up to 8/4 epochs (~2 months / ~1 month) after transferring the underlying asset away, while the new owner got nothing. - ASIC mining: merged ownership re-verification and reward payment into a single pass, so a rig is only ever paid in the same epoch it is freshly re-verified. Payment is scaled by QTREAT_ASIC_VERIFY_SPREAD_EPOCHS to compensate for only being eligible 1-in-N epochs. Also scaled the mining->dividend cut down to a matching 1/N slice per epoch, since it was previously being taken at full size every epoch regardless of whether any rig was due, which would starve miningFund before rigs got their (now larger) turn. - NFT dividend snapshot: replaced the "last known possessor" carry forward with a full rebuild from only that epoch's freshly-checked slice, each entry counted QTREAT_NFT_SNAPSHOT_SPREAD_EPOCHS times to keep the collection's aggregate dividend share consistent with checking everyone every epoch. Removed the now-unneeded dividendNftLastPossessor state field. - Added a test proving a sold ASIC part's old owner receives no reward on the epoch the mismatch is caught, updated the mining-reward test for the new scaled/compensated payout math. --- src/contracts/QTREAT.h | 167 ++++++++++++++++++++------------------- test/contract_qtreat.cpp | 53 ++++++++++++- 2 files changed, 137 insertions(+), 83 deletions(-) diff --git a/src/contracts/QTREAT.h b/src/contracts/QTREAT.h index b79af455..0206c7ca 100644 --- a/src/contracts/QTREAT.h +++ b/src/contracts/QTREAT.h @@ -36,7 +36,10 @@ constexpr uint64 QTREAT_MAX_TOTAL_ASICS = 100; // Ownership re-verification / possessor snapshotting is spread across this many epochs // (round-robin by slot index) instead of touching every entry every epoch, so a large // registered-rig or dividend-NFT set can't force hundreds of external QBAY calls into a -// single END_EPOCH. Each entry is still re-checked at least once every N epochs. +// single END_EPOCH. Each entry is re-checked exactly once every N epochs, and is only ever +// paid/credited in the same epoch it's freshly re-checked (payout amount scaled by N to +// compensate) - a sold/transferred asset can never keep paying its previous owner between +// checks, and a new owner starts earning as soon as their asset's next check lands. constexpr uint64 QTREAT_ASIC_VERIFY_SPREAD_EPOCHS = 8; constexpr uint64 QTREAT_NFT_SNAPSHOT_SPREAD_EPOCHS = 4; constexpr uint64 QTREAT_MINING_REWARD_DEFAULT = 20000000ULL; @@ -166,10 +169,6 @@ struct QTREAT : public ContractBase Array dividendNftIds; uint64 dividendNftIdCount; - // Last possessor observed for each dividendNftIds slot (NULL_ID = none/excluded), so the - // round-robin snapshot below can update nftCounts incrementally instead of resetting and - // re-querying all QTREAT_MAX_DIVIDEND_NFTS ids every single epoch. - Array dividendNftLastPossessor; HashMap nftCounts; uint64 totalNftCount; @@ -1204,7 +1203,6 @@ struct QTREAT : public ContractBase uint64 minerReward; uint64 stillOwned; uint32 rigPartId; sint64 pIdx; uint64 verifyOk; QBAY::getInfoOfNFTById_input qbayIn; QBAY::getInfoOfNFTById_output qbayOut; Entity ent; uint64 contractBalance; - id oldPossessor; }; END_EPOCH_WITH_LOCALS() { @@ -1450,12 +1448,45 @@ struct QTREAT : public ContractBase state.mut().stakers.set(locals.h, locals.info); } + // Mining budget envelope for this epoch, computed once. The dividend half is cut + // unconditionally below (it isn't tied to any individual rig's ownership); the miner + // half is only ever paid to a rig in the same pass that freshly re-verifies it (see + // the merged loop below) so a sold/transferred part can never keep drawing rewards + // for its old owner. + locals.minerBudget = 0; + if (state.get().totalMiningWeight > 0 && state.get().miningFund > 0) + { + qpi.getEntity(SELF, locals.ent); + locals.contractBalance = locals.ent.incomingAmount - locals.ent.outgoingAmount; + locals.miningBudget = state.get().miningRewardRate; + if (locals.miningBudget > state.get().miningFund) locals.miningBudget = state.get().miningFund; + // Only ~1/QTREAT_ASIC_VERIFY_SPREAD_EPOCHS of rigs are due for (scaled-up) payment + // in any given epoch, so take this epoch's dividend cut from the same fair 1/N + // slice of the budget instead of the full envelope - otherwise the dividend cut + // would drain miningFund at full speed every epoch regardless of how many rigs + // are actually due, starving the miner side before rigs get their scaled turn + // (especially with few registered rigs, where most epochs have nobody due at all). + locals.miningBudget = div(locals.miningBudget, QTREAT_ASIC_VERIFY_SPREAD_EPOCHS); + + // Split 50/50; odd QU goes to dividends. + locals.minerBudget = div(locals.miningBudget, 2ULL); + locals.miningDividendCut = locals.miningBudget - locals.minerBudget; + + if (locals.miningDividendCut > 0) + { + state.mut().miningFund = state.get().miningFund - locals.miningDividendCut; + state.mut().dividendFund = sadd(state.get().dividendFund, locals.miningDividendCut); + } + } + for (locals.sidx = 0; locals.sidx < (sint64)state.get().asicRigHighWater; locals.sidx++) { locals.rig = state.get().asicRigs.get(locals.sidx); if (locals.rig.active == 0) continue; - // Only re-verify a rotating slice of rigs this epoch (see QTREAT_ASIC_VERIFY_SPREAD_EPOCHS); - // un-checked rigs simply keep their current active state until their turn comes up. + // Only re-verify (and potentially pay) a rotating slice of rigs this epoch (see + // QTREAT_ASIC_VERIFY_SPREAD_EPOCHS); un-checked rigs keep their current active + // state and simply wait for their next scheduled turn - no ownership-dependent + // payment happens for them outside their own verification epoch. if (mod((uint64)locals.sidx + qpi.epoch(), QTREAT_ASIC_VERIFY_SPREAD_EPOCHS) != 0) continue; locals.stillOwned = 1; locals.verifyOk = 1; @@ -1473,7 +1504,9 @@ struct QTREAT : public ContractBase locals.stillOwned = 0; } } - if (locals.verifyOk == 1 && locals.stillOwned == 0) + if (locals.verifyOk == 0) continue; // inconclusive this epoch; try again next scheduled turn + + if (locals.stillOwned == 0) { state.mut().asicUsedParts.removeByKey((uint64)locals.rig.partMotherboard); state.mut().asicUsedParts.removeByKey((uint64)locals.rig.partChip); @@ -1483,96 +1516,70 @@ struct QTREAT : public ContractBase state.mut().totalMiningWeight = state.get().totalMiningWeight - locals.rig.weight; locals.rig.active = 0; state.mut().asicRigs.set(locals.sidx, locals.rig); - } - } - - if (state.get().totalMiningWeight > 0 && state.get().miningFund > 0) - { - qpi.getEntity(SELF, locals.ent); - locals.contractBalance = locals.ent.incomingAmount - locals.ent.outgoingAmount; - locals.miningBudget = state.get().miningRewardRate; - if (locals.miningBudget > state.get().miningFund) locals.miningBudget = state.get().miningFund; - - // Split 50/50; odd QU goes to dividends. - locals.minerBudget = div(locals.miningBudget, 2ULL); - locals.miningDividendCut = locals.miningBudget - locals.minerBudget; - - if (locals.miningDividendCut > 0) - { - state.mut().miningFund = state.get().miningFund - locals.miningDividendCut; - state.mut().dividendFund = sadd(state.get().dividendFund, locals.miningDividendCut); + continue; } - for (locals.sidx = 0; locals.sidx < (sint64)state.get().asicRigHighWater; locals.sidx++) + // Freshly verified as still valid this epoch: pay its share now, scaled by the + // spread factor to compensate for only being eligible on 1-in-N epochs (so the + // rig's total earnings over a full verification cycle match what continuous + // per-epoch payment at the same weight ratio would have produced). + if (locals.minerBudget > 0 && locals.rig.weight > 0 && state.get().totalMiningWeight > 0) { - locals.rig = state.get().asicRigs.get(locals.sidx); - if (locals.rig.active == 0 || locals.rig.weight == 0) continue; - - locals.minerReward = div((uint128)locals.minerBudget * (uint128)locals.rig.weight, - (uint128)state.get().totalMiningWeight).low; - if (locals.minerReward == 0) continue; - if (locals.minerReward > locals.contractBalance) locals.minerReward = locals.contractBalance; - - qpi.transfer(locals.rig.owner, locals.minerReward); - locals.contractBalance -= locals.minerReward; - state.mut().miningFund = state.get().miningFund - locals.minerReward; - state.mut().totalMiningRewardsDistributed = - sadd(state.get().totalMiningRewardsDistributed, locals.minerReward); - if (locals.contractBalance == 0) break; + locals.minerReward = div((uint128)locals.minerBudget * (uint128)locals.rig.weight + * (uint128)QTREAT_ASIC_VERIFY_SPREAD_EPOCHS, (uint128)state.get().totalMiningWeight).low; + if (locals.minerReward > 0) + { + if (locals.minerReward > locals.contractBalance) locals.minerReward = locals.contractBalance; + if (locals.minerReward > state.get().miningFund) locals.minerReward = state.get().miningFund; + if (locals.minerReward > 0) + { + qpi.transfer(locals.rig.owner, locals.minerReward); + locals.contractBalance -= locals.minerReward; + state.mut().miningFund = state.get().miningFund - locals.minerReward; + state.mut().totalMiningRewardsDistributed = + sadd(state.get().totalMiningRewardsDistributed, locals.minerReward); + } + } } } if (state.get().dividendNftIdCount > 0) { - // Re-check only a rotating slice of the dividend-NFT ids this epoch (see - // QTREAT_NFT_SNAPSHOT_SPREAD_EPOCHS) instead of resetting nftCounts and re-querying - // QBAY for all of them every epoch. nftCounts/totalNftCount are updated incrementally - // against dividendNftLastPossessor, so ids not re-checked this epoch keep contributing - // their last-known possessor unchanged. + // Only this epoch's rotating slice of ids (see QTREAT_NFT_SNAPSHOT_SPREAD_EPOCHS) is + // queried and counted - nftCounts is fully rebuilt from just that fresh slice every + // epoch (no carried-forward "last known possessor" state), so a holder only ever + // gets dividend credit for an id in the same epoch its current possessor is actually + // re-confirmed. A seller can't keep collecting after a sale, and the buyer starts + // getting credit as soon as their id's next scheduled check lands - never mid-cycle + // stale data either way. Each matched id counts QTREAT_NFT_SNAPSHOT_SPREAD_EPOCHS + // times (instead of once) to compensate for only ~1/N of the collection being checked + // in any given epoch, so the collection's aggregate share of the dividend pool matches + // what checking everyone every epoch would produce, in expectation over a full cycle. + state.mut().nftCounts.reset(); + state.mut().totalNftCount = 0; for (locals.sidx = 0; locals.sidx < (sint64)state.get().dividendNftIdCount; locals.sidx++) { if (mod((uint64)locals.sidx + qpi.epoch(), QTREAT_NFT_SNAPSHOT_SPREAD_EPOCHS) != 0) continue; locals.qbayIn.NFTId = state.get().dividendNftIds.get(locals.sidx); CALL_OTHER_CONTRACT_FUNCTION(QBAY, getInfoOfNFTById, locals.qbayIn, locals.qbayOut); - locals.h = (interContractCallError != NoCallError) ? NULL_ID : locals.qbayOut.possessor; - if (locals.h == SELF) locals.h = NULL_ID; - if (locals.h != NULL_ID) + if (interContractCallError != NoCallError) continue; + if (locals.qbayOut.possessor == NULL_ID || locals.qbayOut.possessor == SELF) continue; + locals.isExcluded = 0; + for (locals.exIdx = 0; locals.exIdx < (sint64)QTREAT_MAX_EXCLUDE_ADDRESSES; locals.exIdx++) { - locals.isExcluded = 0; - for (locals.exIdx = 0; locals.exIdx < (sint64)QTREAT_MAX_EXCLUDE_ADDRESSES; locals.exIdx++) + if (state.get().excludeAddresses.get(locals.exIdx) != NULL_ID + && locals.qbayOut.possessor == state.get().excludeAddresses.get(locals.exIdx)) { - if (state.get().excludeAddresses.get(locals.exIdx) != NULL_ID - && locals.h == state.get().excludeAddresses.get(locals.exIdx)) - { - locals.isExcluded = 1; - } + locals.isExcluded = 1; } - if (locals.isExcluded != 0) locals.h = NULL_ID; } + if (locals.isExcluded != 0) continue; - locals.oldPossessor = state.get().dividendNftLastPossessor.get(locals.sidx); - if (locals.h != locals.oldPossessor) - { - if (locals.oldPossessor != NULL_ID) - { - locals.nftCnt = 0; - state.get().nftCounts.get(locals.oldPossessor, locals.nftCnt); - if (locals.nftCnt <= 1) - state.mut().nftCounts.removeByKey(locals.oldPossessor); - else - state.mut().nftCounts.set(locals.oldPossessor, locals.nftCnt - 1); - state.mut().totalNftCount = state.get().totalNftCount - 1; - } - if (locals.h != NULL_ID) - { - locals.nftCnt = 0; - state.get().nftCounts.get(locals.h, locals.nftCnt); - state.mut().nftCounts.set(locals.h, locals.nftCnt + 1); - state.mut().totalNftCount = state.get().totalNftCount + 1; - } - state.mut().dividendNftLastPossessor.set(locals.sidx, locals.h); - } + locals.nftCnt = 0; + state.get().nftCounts.get(locals.qbayOut.possessor, locals.nftCnt); + state.mut().nftCounts.set(locals.qbayOut.possessor, locals.nftCnt + QTREAT_NFT_SNAPSHOT_SPREAD_EPOCHS); + state.mut().totalNftCount = state.get().totalNftCount + QTREAT_NFT_SNAPSHOT_SPREAD_EPOCHS; } } diff --git a/test/contract_qtreat.cpp b/test/contract_qtreat.cpp index b2173bc7..321b17aa 100644 --- a/test/contract_qtreat.cpp +++ b/test/contract_qtreat.cpp @@ -1083,13 +1083,24 @@ TEST(ContractQtreat, MiningRewardSplits5050WithDividendFundAndPaysByWeight) increaseEnergy(funder, 1000000); EXPECT_EQ(t.depositMiningFund(funder, 1000000), QTREAT_OK); + // Mining rewards are only ever paid to a rig in the same epoch it's freshly re-verified + // (see QTREAT_ASIC_VERIFY_SPREAD_EPOCHS); force the clock to land exactly on this rig's + // (slot 0's) next due epoch so no intervening "nobody due" epoch drains miningFund via + // the dividend cut first, keeping the expected amounts exact. + ASSERT_EQ(reg.rigIndex, 0u); + system.epoch = system.epoch - (system.epoch % QTREAT_ASIC_VERIFY_SPREAD_EPOCHS) + QTREAT_ASIC_VERIFY_SPREAD_EPOCHS; + ASSERT_EQ((uint64)system.epoch % QTREAT_ASIC_VERIFY_SPREAD_EPOCHS, 0u); + uint64 dividendBefore = t.getFunds().dividendFund; long long ownerBefore = getBalance(owner); t.advanceEpoch(); - // Sole rig owns 100% of totalMiningWeight, so it gets the full miner half. - uint64 expectedMinerHalf = 1000000 / 2; - uint64 expectedDividendHalf = 1000000 - expectedMinerHalf; + // This epoch's budget slice is rate/SPREAD (see the same reasoning in QTREAT.h), split + // 50/50; the sole due rig owns 100% of totalMiningWeight so its scaled-up payment + // reconstitutes the full miner half of that slice. + uint64 epochSlice = 1000000 / QTREAT_ASIC_VERIFY_SPREAD_EPOCHS; + uint64 expectedMinerHalf = (epochSlice / 2) * QTREAT_ASIC_VERIFY_SPREAD_EPOCHS; + uint64 expectedDividendHalf = epochSlice - epochSlice / 2; EXPECT_EQ((uint64)(getBalance(owner) - ownerBefore), expectedMinerHalf); EXPECT_EQ(t.getFunds().dividendFund - dividendBefore, expectedDividendHalf); } @@ -1144,6 +1155,42 @@ TEST(ContractQtreat, AsicRigStaysActiveUntilItsVerificationSlotComesUp) EXPECT_EQ(t.getState()->getRig((sint64)reg.rigIndex).active, 1u); } +TEST(ContractQtreat, AsicSoldPartOwnerReceivesNoRewardOnDeactivationEpoch) +{ + // Core guarantee: mining reward payment and ownership re-verification always happen + // together (same pass, same epoch) - a rig is never paid based on stale ownership. Once + // a part is sold away, the old owner collects nothing more, starting with the very epoch + // the mismatch is caught (no one-more-payment-then-deactivate window). + ContractTestingQtreat t; + t.activateQbayMarket(); + id owner = getUser(1); + id newOwner = getUser(2); + uint32 m = t.mintNft(owner), c = t.mintNft(owner), p = t.mintNft(owner), f = t.mintNft(owner); + t.loadFullAsicCatalog(); + auto reg = t.registerAsic(owner, m, c, p, f); + ASSERT_EQ(reg.returnCode, QTREAT_OK); + ASSERT_EQ(reg.rigIndex, 0u); + + EXPECT_EQ(t.setMiningRate(t.adminAddress, 1000000), QTREAT_OK); + id funder = getUser(9); + increaseEnergy(funder, 1000000); + EXPECT_EQ(t.depositMiningFund(funder, 1000000), QTREAT_OK); + + t.transferNft(owner, m, newOwner); // sell before the rig's part ever gets fresh-verified + + // Advance straight to slot 0's next due epoch, same as the payment test, so no + // intervening epoch's dividend cut muddies the miningFund balance. + system.epoch = system.epoch - (system.epoch % QTREAT_ASIC_VERIFY_SPREAD_EPOCHS) + QTREAT_ASIC_VERIFY_SPREAD_EPOCHS; + + long long ownerBefore = getBalance(owner); + t.advanceEpoch(); + + // Deactivated (ownership mismatch caught), and the old owner got paid nothing this epoch. + EXPECT_EQ(t.getState()->getTotalAsicCount(), 0u); + EXPECT_EQ(t.getState()->getRig((sint64)reg.rigIndex).active, 0u); + EXPECT_EQ(getBalance(owner), ownerBefore); +} + TEST(ContractQtreat, DripQdogePaysCatalogedNftHoldersByRarityAndExcludesAdmin) { ContractTestingQtreat t; From b9db337658d7bf45c386b672babf84fd11ba63f3 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Fri, 14 Aug 2026 12:43:35 +0100 Subject: [PATCH 4/4] fix: add header and test cpp file to Visual Studio file --- src/Qubic.vcxproj | 1 + src/Qubic.vcxproj.filters | 3 +++ test/CMakeLists.txt | 1 + test/test.vcxproj | 1 + test/test.vcxproj.filters | 1 + 5 files changed, 7 insertions(+) diff --git a/src/Qubic.vcxproj b/src/Qubic.vcxproj index 942c974b..9f1fdd81 100644 --- a/src/Qubic.vcxproj +++ b/src/Qubic.vcxproj @@ -44,6 +44,7 @@ + diff --git a/src/Qubic.vcxproj.filters b/src/Qubic.vcxproj.filters index 160c5365..c31d9825 100644 --- a/src/Qubic.vcxproj.filters +++ b/src/Qubic.vcxproj.filters @@ -123,6 +123,9 @@ contracts + + contracts + contracts diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 39aa01b9..0147f515 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -34,6 +34,7 @@ add_executable( # contract_qvault.cpp # contract_qx.cpp contract_qraffle.cpp + contract_qtreat.cpp contract_random.cpp contract_vottunbridge.cpp # kangaroo_twelve.cpp diff --git a/test/test.vcxproj b/test/test.vcxproj index fe0736f6..b87a9846 100644 --- a/test/test.vcxproj +++ b/test/test.vcxproj @@ -158,6 +158,7 @@ + diff --git a/test/test.vcxproj.filters b/test/test.vcxproj.filters index 6f776a78..f80463ea 100644 --- a/test/test.vcxproj.filters +++ b/test/test.vcxproj.filters @@ -34,6 +34,7 @@ +