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/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..0206c7ca --- /dev/null +++ b/src/contracts/QTREAT.h @@ -0,0 +1,1815 @@ +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; +// 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 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; +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; + + // Admin address: QTREATZZIVFYQAIBKCZPSHGLIRMALZKHEWAPFLFXJAMDAXMGTBKQVXHHDHUD + state.mut().adminAddress = ID( + _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); + 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); + } + + // 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 (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; + 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 == 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); + 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); + continue; + } + + // 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.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) + { + // 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); + 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.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; + } + } + + 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/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/contract_qtreat.cpp b/test/contract_qtreat.cpp new file mode 100644 index 00000000..321b17aa --- /dev/null +++ b/test/contract_qtreat.cpp @@ -0,0 +1,1226 @@ +#define NO_UEFI + +#include +#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, 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; + + 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); + + // 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(); + + // 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); +} + +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 + + // 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, 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; + 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); +} 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 @@ +