From 4e907cca9e3c03fa7e0a22c32f395a8724f7d928 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Thu, 26 Mar 2026 01:23:20 +0100 Subject: [PATCH 01/16] update qraffle --- src/contracts/QRaffle.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/contracts/QRaffle.h b/src/contracts/QRaffle.h index ed5dc7bd0..8c964b1c4 100644 --- a/src/contracts/QRaffle.h +++ b/src/contracts/QRaffle.h @@ -1,18 +1,18 @@ using namespace QPI; constexpr uint64 QRAFFLE_REGISTER_AMOUNT = 1000000000ull; -constexpr uint64 QRAFFLE_QXMR_REGISTER_AMOUNT = 100000000ull; +constexpr uint64 QRAFFLE_QXMR_REGISTER_AMOUNT = 250000000ull; constexpr uint64 QRAFFLE_MAX_QRE_AMOUNT = 1000000000ull; constexpr uint64 QRAFFLE_ASSET_NAME = 19505638103142993; constexpr uint64 QRAFFLE_QXMR_ASSET_NAME = 1380800593; // QXMR token asset name constexpr uint32 QRAFFLE_LOGOUT_FEE = 50000000; constexpr uint32 QRAFFLE_QXMR_LOGOUT_FEE = 5000000; // QXMR logout fee constexpr uint32 QRAFFLE_TRANSFER_SHARE_FEE = 100; -constexpr uint32 QRAFFLE_BURN_FEE = 10; // percent +constexpr uint32 QRAFFLE_BURN_FEE = 5; // percent constexpr uint32 QRAFFLE_REGISTER_FEE = 5; // percent constexpr uint32 QRAFFLE_FEE = 1; // percent constexpr uint32 QRAFFLE_CHARITY_FEE = 1; // percent -constexpr uint32 QRAFFLE_SHRAEHOLDER_FEE = 3; // percent +constexpr uint32 QRAFFLE_SHRAEHOLDER_FEE = 8; // percent constexpr uint32 QRAFFLE_MAX_EPOCH = 65536; constexpr uint32 QRAFFLE_MAX_PROPOSAL_EPOCH = 128; constexpr uint32 QRAFFLE_MAX_MEMBER = 65536; From b1fbfd032778e21462e18a3aec89571f4df702a5 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Tue, 7 Apr 2026 21:23:16 +0900 Subject: [PATCH 02/16] update: qxmr logout fee 12.5m --- src/contracts/QRaffle.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contracts/QRaffle.h b/src/contracts/QRaffle.h index 8c964b1c4..9082dae47 100644 --- a/src/contracts/QRaffle.h +++ b/src/contracts/QRaffle.h @@ -6,7 +6,7 @@ constexpr uint64 QRAFFLE_MAX_QRE_AMOUNT = 1000000000ull; constexpr uint64 QRAFFLE_ASSET_NAME = 19505638103142993; constexpr uint64 QRAFFLE_QXMR_ASSET_NAME = 1380800593; // QXMR token asset name constexpr uint32 QRAFFLE_LOGOUT_FEE = 50000000; -constexpr uint32 QRAFFLE_QXMR_LOGOUT_FEE = 5000000; // QXMR logout fee +constexpr uint32 QRAFFLE_QXMR_LOGOUT_FEE = 12500000; // QXMR logout fee constexpr uint32 QRAFFLE_TRANSFER_SHARE_FEE = 100; constexpr uint32 QRAFFLE_BURN_FEE = 5; // percent constexpr uint32 QRAFFLE_REGISTER_FEE = 5; // percent From 2f320509774a6c1b39eb1b0a65ee11750a6c8c79 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Sat, 18 Apr 2026 00:40:54 +0900 Subject: [PATCH 03/16] fix: calculation of fee by div --- src/contracts/QRaffle.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/contracts/QRaffle.h b/src/contracts/QRaffle.h index 9082dae47..8550f23be 100644 --- a/src/contracts/QRaffle.h +++ b/src/contracts/QRaffle.h @@ -5,8 +5,8 @@ constexpr uint64 QRAFFLE_QXMR_REGISTER_AMOUNT = 250000000ull; constexpr uint64 QRAFFLE_MAX_QRE_AMOUNT = 1000000000ull; constexpr uint64 QRAFFLE_ASSET_NAME = 19505638103142993; constexpr uint64 QRAFFLE_QXMR_ASSET_NAME = 1380800593; // QXMR token asset name -constexpr uint32 QRAFFLE_LOGOUT_FEE = 50000000; -constexpr uint32 QRAFFLE_QXMR_LOGOUT_FEE = 12500000; // QXMR logout fee +constexpr uint32 QRAFFLE_LOGOUT_FEE = div(QRAFFLE_REGISTER_AMOUNT, 20); +constexpr uint32 QRAFFLE_QXMR_LOGOUT_FEE = div(QRAFFLE_QXMR_REGISTER_AMOUNT, 20); // QXMR logout fee constexpr uint32 QRAFFLE_TRANSFER_SHARE_FEE = 100; constexpr uint32 QRAFFLE_BURN_FEE = 5; // percent constexpr uint32 QRAFFLE_REGISTER_FEE = 5; // percent From 4fa58aba7afde617cedbd7371d340712dd87c51e Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Sat, 18 Apr 2026 22:25:55 +0900 Subject: [PATCH 04/16] fix: variable name and constant --- src/contracts/QRaffle.h | 12 +++++++----- test/contract_qraffle.cpp | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/contracts/QRaffle.h b/src/contracts/QRaffle.h index 8550f23be..6255e9a1c 100644 --- a/src/contracts/QRaffle.h +++ b/src/contracts/QRaffle.h @@ -12,13 +12,15 @@ constexpr uint32 QRAFFLE_BURN_FEE = 5; // percent constexpr uint32 QRAFFLE_REGISTER_FEE = 5; // percent constexpr uint32 QRAFFLE_FEE = 1; // percent constexpr uint32 QRAFFLE_CHARITY_FEE = 1; // percent -constexpr uint32 QRAFFLE_SHRAEHOLDER_FEE = 8; // percent +constexpr uint32 QRAFFLE_SHAREHOLDER_FEE = 8; // percent constexpr uint32 QRAFFLE_MAX_EPOCH = 65536; constexpr uint32 QRAFFLE_MAX_PROPOSAL_EPOCH = 128; constexpr uint32 QRAFFLE_MAX_MEMBER = 65536; constexpr uint32 QRAFFLE_DEFAULT_QRAFFLE_AMOUNT = 10000000ull; constexpr uint32 QRAFFLE_MIN_QRAFFLE_AMOUNT = 1000000ull; constexpr uint32 QRAFFLE_MAX_QRAFFLE_AMOUNT = 1000000000ull; +constexpr uint32 QRAFFLE_MAX_TOKEN_RAFFLES = 1048576; +constexpr uint32 QRAFFLE_MAX_SHAREHOLDERS = 1024; constexpr sint32 QRAFFLE_SUCCESS = 0; constexpr sint32 QRAFFLE_INSUFFICIENT_FUND = 1; @@ -207,9 +209,9 @@ struct QRAFFLE : public ContractBase Array tmpTokenRaffleMembers; Array QuRaffles; - Array tokenRaffle; + Array tokenRaffle; HashMap quRaffleEntryAmount; - HashSet shareholdersList; + HashSet shareholdersList; id initialRegister1, initialRegister2, initialRegister3, initialRegister4, initialRegister5; id charityAddress, feeAddress, QXMRIssuer; @@ -1271,7 +1273,7 @@ struct QRAFFLE : public ContractBase // Calculate fee distributions locals.burnAmount = div(state.get().qREAmount * state.get().numberOfQuRaffleMembers * QRAFFLE_BURN_FEE, 100); locals.charityRevenue = div(state.get().qREAmount * state.get().numberOfQuRaffleMembers * QRAFFLE_CHARITY_FEE, 100); - locals.shareholderRevenue = div(state.get().qREAmount * state.get().numberOfQuRaffleMembers * QRAFFLE_SHRAEHOLDER_FEE, 100); + locals.shareholderRevenue = div(state.get().qREAmount * state.get().numberOfQuRaffleMembers * QRAFFLE_SHAREHOLDER_FEE, 100); locals.registerRevenue = div(state.get().qREAmount * state.get().numberOfQuRaffleMembers * QRAFFLE_REGISTER_FEE, 100); locals.fee = div(state.get().qREAmount * state.get().numberOfQuRaffleMembers * QRAFFLE_FEE, 100); locals.winnerRevenue = state.get().qREAmount * state.get().numberOfQuRaffleMembers - locals.burnAmount - locals.charityRevenue - div(locals.shareholderRevenue, 676) * 676 - div(locals.registerRevenue, state.get().numberOfRegisters) * state.get().numberOfRegisters - locals.fee; @@ -1370,7 +1372,7 @@ struct QRAFFLE : public ContractBase // Calculate token raffle fee distributions locals.burnAmount = div(locals.acTokenRaffle.entryAmount * state.get().numberOfTokenRaffleMembers.get(locals.i) * QRAFFLE_BURN_FEE, 100); locals.charityRevenue = div(locals.acTokenRaffle.entryAmount * state.get().numberOfTokenRaffleMembers.get(locals.i) * QRAFFLE_CHARITY_FEE, 100); - locals.shareholderRevenue = div(locals.acTokenRaffle.entryAmount * state.get().numberOfTokenRaffleMembers.get(locals.i) * QRAFFLE_SHRAEHOLDER_FEE, 100); + locals.shareholderRevenue = div(locals.acTokenRaffle.entryAmount * state.get().numberOfTokenRaffleMembers.get(locals.i) * QRAFFLE_SHAREHOLDER_FEE, 100); locals.registerRevenue = div(locals.acTokenRaffle.entryAmount * state.get().numberOfTokenRaffleMembers.get(locals.i) * QRAFFLE_REGISTER_FEE, 100); locals.fee = div(locals.acTokenRaffle.entryAmount * state.get().numberOfTokenRaffleMembers.get(locals.i) * QRAFFLE_FEE, 100); locals.winnerRevenue = locals.acTokenRaffle.entryAmount * state.get().numberOfTokenRaffleMembers.get(locals.i) - locals.burnAmount - locals.charityRevenue - div(locals.shareholderRevenue, 676) * 676 - div(locals.registerRevenue, state.get().numberOfRegisters) * state.get().numberOfRegisters - locals.fee; diff --git a/test/contract_qraffle.cpp b/test/contract_qraffle.cpp index ed1644c0b..43750e182 100644 --- a/test/contract_qraffle.cpp +++ b/test/contract_qraffle.cpp @@ -974,7 +974,7 @@ TEST(ContractQraffle, GetFunctions) expectedTotalBurnAmount += (totalQuRaffleAmount * QRAFFLE_BURN_FEE) / 100; expectedTotalCharityAmount += (totalQuRaffleAmount * QRAFFLE_CHARITY_FEE) / 100; - expectedTotalShareholderAmount += ((totalQuRaffleAmount * QRAFFLE_SHRAEHOLDER_FEE) / 100) / 676 * 676; + expectedTotalShareholderAmount += ((totalQuRaffleAmount * QRAFFLE_SHAREHOLDER_FEE) / 100) / 676 * 676; expectedTotalRegisterAmount += ((totalQuRaffleAmount * QRAFFLE_REGISTER_FEE) / 100) / registerCount * registerCount; expectedTotalFeeAmount += (totalQuRaffleAmount * QRAFFLE_FEE) / 100; From 15c9df6a2c465153d997c8a4f7fb79deb172f216 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Tue, 21 Apr 2026 05:27:12 +0900 Subject: [PATCH 05/16] QRaffle: fix END_EPOCH winner randomness --- src/contracts/QRaffle.h | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/contracts/QRaffle.h b/src/contracts/QRaffle.h index 6255e9a1c..7357dd52f 100644 --- a/src/contracts/QRaffle.h +++ b/src/contracts/QRaffle.h @@ -1230,7 +1230,7 @@ struct QRAFFLE : public ContractBase ActiveTokenRaffleInfo acTokenRaffle; AssetPossessionIterator iter; Asset QraffleAsset; - id digest, winner, shareholder; + id digest, winner, shareholder, baseSeed, raffleSeed; sint64 idx; uint64 sumOfEntryAmountSubmitted, r, winnerRevenue, burnAmount, charityRevenue, shareholderRevenue, registerRevenue, fee, oneShareholderRev; uint32 i, j, winnerIndex; @@ -1249,7 +1249,9 @@ struct QRAFFLE : public ContractBase state.mut().epochRevenue -= locals.oneShareholderRev * 676; locals.digest = qpi.getPrevSpectrumDigest(); - locals.r = (qpi.numberOfTickTransactions() + 1) * locals.digest.u64._0 + (qpi.second()) * locals.digest.u64._1 + locals.digest.u64._2; + locals.baseSeed = qpi.K12(m256i(locals.digest.u64._0 ^ (uint64)qpi.epoch(), locals.digest.u64._1 ^ (uint64)qpi.tick(), locals.digest.u64._2 ^ (uint64)(qpi.numberOfTickTransactions() + 1), locals.digest.u64._3 ^ (uint64)qpi.second())); + locals.raffleSeed = qpi.K12(m256i(locals.baseSeed.u64._0, locals.baseSeed.u64._1, locals.baseSeed.u64._2, locals.baseSeed.u64._3 ^ 0ULL)); + locals.r = locals.raffleSeed.u64._0; locals.winnerIndex = (uint32)mod(locals.r, state.get().numberOfQuRaffleMembers * 1ull); locals.winner = state.get().quRaffleMembers.get(locals.winnerIndex); @@ -1363,6 +1365,8 @@ struct QRAFFLE : public ContractBase { if (state.get().numberOfTokenRaffleMembers.get(locals.i) > 0) { + locals.raffleSeed = qpi.K12(m256i(locals.baseSeed.u64._0, locals.baseSeed.u64._1, locals.baseSeed.u64._2, locals.baseSeed.u64._3 ^ ((uint64)locals.i + 1ULL))); + locals.r = locals.raffleSeed.u64._0; locals.winnerIndex = (uint32)mod(locals.r, state.get().numberOfTokenRaffleMembers.get(locals.i) * 1ull); state.get().tokenRaffleMembers.get(locals.i, state.mut().tmpTokenRaffleMembers); locals.winner = state.get().tmpTokenRaffleMembers.get(locals.winnerIndex); From 7f3562e56642ff84152ca927cba957e6df958a18 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Tue, 21 Apr 2026 08:21:43 +0900 Subject: [PATCH 06/16] QRaffle: cap proposals per user and validate submitProposal inputs --- src/contracts/QRaffle.h | 36 ++++++++++++++++++++++++++++++++++-- test/contract_qraffle.cpp | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/contracts/QRaffle.h b/src/contracts/QRaffle.h index 7357dd52f..bf2433629 100644 --- a/src/contracts/QRaffle.h +++ b/src/contracts/QRaffle.h @@ -21,6 +21,7 @@ constexpr uint32 QRAFFLE_MIN_QRAFFLE_AMOUNT = 1000000ull; constexpr uint32 QRAFFLE_MAX_QRAFFLE_AMOUNT = 1000000000ull; constexpr uint32 QRAFFLE_MAX_TOKEN_RAFFLES = 1048576; constexpr uint32 QRAFFLE_MAX_SHAREHOLDERS = 1024; +constexpr uint8 QRAFFLE_MAX_PROPOSALS_PER_PROPOSER = 3; // per epoch; mitigates global-slot DoS without a proposal fee constexpr sint32 QRAFFLE_SUCCESS = 0; constexpr sint32 QRAFFLE_INSUFFICIENT_FUND = 1; @@ -41,6 +42,7 @@ constexpr sint32 QRAFFLE_USER_NOT_FOUND = 15; constexpr sint32 QRAFFLE_INVALID_ENTRY_AMOUNT = 16; constexpr sint32 QRAFFLE_EMPTY_QU_RAFFLE = 17; constexpr sint32 QRAFFLE_EMPTY_TOKEN_RAFFLE = 18; +constexpr sint32 QRAFFLE_MAX_PROPOSAL_PER_USER_REACHED = 19; struct QRAFFLE2 { @@ -82,7 +84,8 @@ struct QRAFFLE : public ContractBase QRAFFLE_tokenRaffleDeposited = 29, QRAFFLE_shareManagementRightsTransferred = 30, QRAFFLE_emptyQuRaffle = 31, - QRAFFLE_emptyTokenRaffle = 32 + QRAFFLE_emptyTokenRaffle = 32, + QRAFFLE_maxProposalPerUserReached = 33 }; struct Logger @@ -195,7 +198,6 @@ struct QRAFFLE : public ContractBase struct StateData { HashMap registers; - Array proposals; HashMap , QRAFFLE_MAX_PROPOSAL_EPOCH> voteStatus; @@ -218,6 +220,7 @@ struct QRAFFLE : public ContractBase uint64 epochRevenue, epochQXMRRevenue, qREAmount, totalBurnAmount, totalCharityAmount, totalShareholderAmount, totalRegisterAmount, totalFeeAmount, totalWinnerAmount, largestWinnerAmount; uint32 numberOfRegisters, numberOfQuRaffleMembers, numberOfEntryAmountSubmitted, numberOfProposals, numberOfActiveTokenRaffle, numberOfEndedTokenRaffle; Array daoMemberCount; // Number of DAO members (registers) at each epoch + HashMap proposalsPerProposer; }; struct registerInSystem_input @@ -627,6 +630,7 @@ struct QRAFFLE : public ContractBase struct submitProposal_locals { ProposalInfo proposal; + uint8 countThisEpoch; Logger log; }; @@ -650,12 +654,39 @@ struct QRAFFLE : public ContractBase LOG_INFO(locals.log); return ; } + locals.countThisEpoch = 0; + if (state.get().proposalsPerProposer.contains(qpi.invocator())) + { + state.get().proposalsPerProposer.get(qpi.invocator(), locals.countThisEpoch); + } + if (locals.countThisEpoch >= QRAFFLE_MAX_PROPOSALS_PER_PROPOSER) + { + output.returnCode = QRAFFLE_MAX_PROPOSAL_PER_USER_REACHED; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_maxProposalPerUserReached, 0 }; + LOG_INFO(locals.log); + return ; + } + if (input.entryAmount < QRAFFLE_MIN_QRAFFLE_AMOUNT || input.entryAmount > QRAFFLE_MAX_QRAFFLE_AMOUNT) + { + output.returnCode = QRAFFLE_INVALID_ENTRY_AMOUNT; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_invalidEntryAmount, 0 }; + LOG_INFO(locals.log); + return ; + } + if (!qpi.isAssetIssued(input.tokenIssuer, input.tokenName)) + { + output.returnCode = QRAFFLE_INVALID_TOKEN_TYPE; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_invalidTokenType, 0 }; + LOG_INFO(locals.log); + return ; + } locals.proposal.token.issuer = input.tokenIssuer; locals.proposal.token.assetName = input.tokenName; locals.proposal.entryAmount = input.entryAmount; locals.proposal.proposer = qpi.invocator(); state.mut().proposals.set(state.get().numberOfProposals, locals.proposal); state.mut().numberOfProposals++; + state.mut().proposalsPerProposer.set(qpi.invocator(), locals.countThisEpoch + 1); output.returnCode = QRAFFLE_SUCCESS; locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_proposalSubmitted, 0 }; LOG_INFO(locals.log); @@ -1487,6 +1518,7 @@ struct QRAFFLE : public ContractBase state.mut().numberOfVotedInProposal.setAll(0); state.mut().tokenRaffleMembers.reset(); + state.mut().proposalsPerProposer.reset(); state.mut().quRaffleEntryAmount.reset(); state.mut().shareholdersList.reset(); state.mut().voteStatus.reset(); diff --git a/test/contract_qraffle.cpp b/test/contract_qraffle.cpp index 43750e182..54a123292 100644 --- a/test/contract_qraffle.cpp +++ b/test/contract_qraffle.cpp @@ -587,7 +587,42 @@ TEST(ContractQraffle, SubmitProposal) increaseEnergy(unregisteredUser, QRAFFLE_REGISTER_AMOUNT); auto result = qraffle.submitProposal(unregisteredUser, token1, 1000000); EXPECT_EQ(result.returnCode, QRAFFLE_UNREGISTERED); -} +} + +TEST(ContractQraffle, SubmitProposalPerUserLimitAndValidation) +{ + ContractTestingQraffle qraffle; + + auto users = getRandomUsers(100, 100); + for (const auto& user : users) + { + increaseEnergy(user, QRAFFLE_REGISTER_AMOUNT); + qraffle.registerInSystem(user, QRAFFLE_REGISTER_AMOUNT, 0); + } + + id issuer = getUser(2000); + increaseEnergy(issuer, 1000000000ULL); + uint64 assetName = assetNameFromString("LIMVAL"); + qraffle.issueAsset(issuer, assetName, 10000000, 0, 0); + + Asset token; + token.assetName = assetName; + token.issuer = issuer; + + increaseEnergy(users[0], 1000); + EXPECT_EQ(qraffle.submitProposal(users[0], token, 1000000ULL).returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(qraffle.submitProposal(users[0], token, 2000000ULL).returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(qraffle.submitProposal(users[0], token, 3000000ULL).returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(qraffle.submitProposal(users[0], token, 4000000ULL).returnCode, QRAFFLE_MAX_PROPOSAL_PER_USER_REACHED); + + EXPECT_EQ(qraffle.submitProposal(users[0], token, 500000ULL).returnCode, QRAFFLE_INVALID_ENTRY_AMOUNT); + EXPECT_EQ(qraffle.submitProposal(users[0], token, 2000000000ULL).returnCode, QRAFFLE_INVALID_ENTRY_AMOUNT); + + Asset fakeToken; + fakeToken.assetName = assetNameFromString("NOTISS"); + fakeToken.issuer = issuer; + EXPECT_EQ(qraffle.submitProposal(users[1], fakeToken, 1000000ULL).returnCode, QRAFFLE_INVALID_TOKEN_TYPE); +} TEST(ContractQraffle, VoteInProposal) { From 6279533c5cb8a73030c8d2e09df0edae905d4440 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Wed, 22 Apr 2026 19:19:46 +0900 Subject: [PATCH 07/16] QRaffle: security fixes, fee accounting, and correctness improvements --- src/contracts/QRaffle.h | 383 +++++++++++++++++++++++++++++--------- test/contract_qraffle.cpp | 45 ++++- 2 files changed, 329 insertions(+), 99 deletions(-) diff --git a/src/contracts/QRaffle.h b/src/contracts/QRaffle.h index bf2433629..4b393d9d2 100644 --- a/src/contracts/QRaffle.h +++ b/src/contracts/QRaffle.h @@ -21,7 +21,7 @@ constexpr uint32 QRAFFLE_MIN_QRAFFLE_AMOUNT = 1000000ull; constexpr uint32 QRAFFLE_MAX_QRAFFLE_AMOUNT = 1000000000ull; constexpr uint32 QRAFFLE_MAX_TOKEN_RAFFLES = 1048576; constexpr uint32 QRAFFLE_MAX_SHAREHOLDERS = 1024; -constexpr uint8 QRAFFLE_MAX_PROPOSALS_PER_PROPOSER = 3; // per epoch; mitigates global-slot DoS without a proposal fee +constexpr uint8 QRAFFLE_MAX_PROPOSALS_PER_PROPOSER = 3; // max proposals per user per epoch constexpr sint32 QRAFFLE_SUCCESS = 0; constexpr sint32 QRAFFLE_INSUFFICIENT_FUND = 1; @@ -680,6 +680,15 @@ struct QRAFFLE : public ContractBase LOG_INFO(locals.log); return ; } + // Reject internal tokens: QRAFFLE shares and QXMR are reserved for dividends/registration. + if ((input.tokenName == QRAFFLE_ASSET_NAME && input.tokenIssuer == NULL_ID) + || (input.tokenName == QRAFFLE_QXMR_ASSET_NAME && input.tokenIssuer == state.get().QXMRIssuer)) + { + output.returnCode = QRAFFLE_INVALID_TOKEN_TYPE; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_invalidTokenType, 0 }; + LOG_INFO(locals.log); + return ; + } locals.proposal.token.issuer = input.tokenIssuer; locals.proposal.token.assetName = input.tokenName; locals.proposal.entryAmount = input.entryAmount; @@ -696,7 +705,9 @@ struct QRAFFLE : public ContractBase { ProposalInfo proposal; VotedId votedId; + VotedId emptyVote; uint32 i; + uint32 votedCount; Logger log; }; @@ -721,8 +732,20 @@ struct QRAFFLE : public ContractBase return ; } locals.proposal = state.get().proposals.get(input.indexOfProposal); - state.get().voteStatus.get(input.indexOfProposal, state.mut().tmpVoteStatus); - for (locals.i = 0; locals.i < state.get().numberOfVotedInProposal.get(input.indexOfProposal); locals.i++) + // Load vote buffer for this proposal. Clear it if no votes exist yet to avoid + // matching stale entries left over from a previous proposal in the same slot. + if (state.get().voteStatus.contains(input.indexOfProposal)) + { + state.get().voteStatus.get(input.indexOfProposal, state.mut().tmpVoteStatus); + } + else + { + locals.emptyVote.user = NULL_ID; + locals.emptyVote.status = 0; + state.mut().tmpVoteStatus.setAll(locals.emptyVote); + } + locals.votedCount = state.get().numberOfVotedInProposal.get(input.indexOfProposal); + for (locals.i = 0; locals.i < locals.votedCount; locals.i++) { if (state.get().tmpVoteStatus.get(locals.i).user == qpi.invocator()) { @@ -735,15 +758,22 @@ struct QRAFFLE : public ContractBase } else { + // Guard against unsigned underflow when flipping the vote. if (input.yes) { locals.proposal.nYes++; - locals.proposal.nNo--; + if (locals.proposal.nNo > 0) + { + locals.proposal.nNo--; + } } else { locals.proposal.nNo++; - locals.proposal.nYes--; + if (locals.proposal.nYes > 0) + { + locals.proposal.nYes--; + } } state.mut().proposals.set(input.indexOfProposal, locals.proposal); } @@ -758,6 +788,14 @@ struct QRAFFLE : public ContractBase return ; } } + // Reject new votes once the per-proposal buffer is full. + if (locals.votedCount >= QRAFFLE_MAX_MEMBER) + { + output.returnCode = QRAFFLE_MAX_MEMBER_REACHED; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_maxMemberReached, 0 }; + LOG_INFO(locals.log); + return ; + } if (input.yes) { locals.proposal.nYes++; @@ -770,9 +808,9 @@ struct QRAFFLE : public ContractBase locals.votedId.user = qpi.invocator(); locals.votedId.status = input.yes; - state.mut().tmpVoteStatus.set(state.get().numberOfVotedInProposal.get(input.indexOfProposal), locals.votedId); + state.mut().tmpVoteStatus.set(locals.votedCount, locals.votedId); state.mut().voteStatus.set(input.indexOfProposal, state.get().tmpVoteStatus); - state.mut().numberOfVotedInProposal.set(input.indexOfProposal, state.get().numberOfVotedInProposal.get(input.indexOfProposal) + 1); + state.mut().numberOfVotedInProposal.set(input.indexOfProposal, locals.votedCount + 1); output.returnCode = QRAFFLE_SUCCESS; locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_proposalVoted, 0 }; LOG_INFO(locals.log); @@ -832,7 +870,9 @@ struct QRAFFLE : public ContractBase struct depositInTokenRaffle_locals { + ActiveTokenRaffleInfo raffleInfo; uint32 i; + uint32 currentMembers; Logger log; }; @@ -849,6 +889,18 @@ struct QRAFFLE : public ContractBase LOG_INFO(locals.log); return ; } + // Only registered members may deposit. + if (state.get().registers.contains(qpi.invocator()) == 0) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + output.returnCode = QRAFFLE_UNREGISTERED; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_unregistered, 0 }; + LOG_INFO(locals.log); + return ; + } if (input.indexOfTokenRaffle >= state.get().numberOfActiveTokenRaffle) { if (qpi.invocationReward() > 0) @@ -860,7 +912,36 @@ struct QRAFFLE : public ContractBase LOG_INFO(locals.log); return ; } - if (qpi.transferShareOwnershipAndPossession(state.get().activeTokenRaffle.get(input.indexOfTokenRaffle).token.assetName, state.get().activeTokenRaffle.get(input.indexOfTokenRaffle).token.issuer, qpi.invocator(), qpi.invocator(), state.get().activeTokenRaffle.get(input.indexOfTokenRaffle).entryAmount, SELF) < 0) + locals.currentMembers = state.get().numberOfTokenRaffleMembers.get(input.indexOfTokenRaffle); + if (locals.currentMembers >= QRAFFLE_MAX_MEMBER) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + output.returnCode = QRAFFLE_MAX_MEMBER_REACHED; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_maxMemberReached, 0 }; + LOG_INFO(locals.log); + return ; + } + // Reject duplicate deposit from the same user. + state.get().tokenRaffleMembers.get(input.indexOfTokenRaffle, state.mut().tmpTokenRaffleMembers); + for (locals.i = 0; locals.i < locals.currentMembers; locals.i++) + { + if (state.get().tmpTokenRaffleMembers.get(locals.i) == qpi.invocator()) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + output.returnCode = QRAFFLE_ALREADY_REGISTERED; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_alreadyRegistered, 0 }; + LOG_INFO(locals.log); + return ; + } + } + locals.raffleInfo = state.get().activeTokenRaffle.get(input.indexOfTokenRaffle); + if (qpi.transferShareOwnershipAndPossession(locals.raffleInfo.token.assetName, locals.raffleInfo.token.issuer, qpi.invocator(), qpi.invocator(), locals.raffleInfo.entryAmount, SELF) < 0) { if (qpi.invocationReward() > 0) { @@ -871,10 +952,14 @@ struct QRAFFLE : public ContractBase LOG_INFO(locals.log); return ; } - qpi.transfer(qpi.invocator(), qpi.invocationReward() - QRAFFLE_TRANSFER_SHARE_FEE); - state.get().tokenRaffleMembers.get(input.indexOfTokenRaffle, state.mut().tmpTokenRaffleMembers); - state.mut().tmpTokenRaffleMembers.set(state.get().numberOfTokenRaffleMembers.get(input.indexOfTokenRaffle), qpi.invocator()); - state.mut().numberOfTokenRaffleMembers.set(input.indexOfTokenRaffle, state.get().numberOfTokenRaffleMembers.get(input.indexOfTokenRaffle) + 1); + // Keep QRAFFLE_TRANSFER_SHARE_FEE as service revenue; refund any excess. + if (qpi.invocationReward() > QRAFFLE_TRANSFER_SHARE_FEE) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward() - QRAFFLE_TRANSFER_SHARE_FEE); + } + state.mut().epochRevenue += QRAFFLE_TRANSFER_SHARE_FEE; + state.mut().tmpTokenRaffleMembers.set(locals.currentMembers, qpi.invocator()); + state.mut().numberOfTokenRaffleMembers.set(input.indexOfTokenRaffle, locals.currentMembers + 1); state.mut().tokenRaffleMembers.set(input.indexOfTokenRaffle, state.get().tmpTokenRaffleMembers); output.returnCode = QRAFFLE_SUCCESS; locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_tokenRaffleDeposited, 0 }; @@ -884,11 +969,15 @@ struct QRAFFLE : public ContractBase struct TransferShareManagementRights_locals { Asset asset; + sint64 offeredFee; + sint64 paidFee; Logger log; }; PUBLIC_PROCEDURE_WITH_LOCALS(TransferShareManagementRights) { + // Requires QRAFFLE_TRANSFER_SHARE_FEE minimum. The rest is offered to the destination + // contract as transfer fee; the unused portion is refunded after releaseShares. if (qpi.invocationReward() < QRAFFLE_TRANSFER_SHARE_FEE) { if (qpi.invocationReward() > 0) @@ -902,7 +991,7 @@ struct QRAFFLE : public ContractBase if (qpi.numberOfPossessedShares(input.tokenName, input.tokenIssuer,qpi.invocator(), qpi.invocator(), SELF_INDEX, SELF_INDEX) < input.numberOfShares) { - // not enough shares available + // Not enough shares — refund in full. output.transferredNumberOfShares = 0; locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_notEnoughShares, 0 }; LOG_INFO(locals.log); @@ -915,10 +1004,12 @@ struct QRAFFLE : public ContractBase { locals.asset.assetName = input.tokenName; locals.asset.issuer = input.tokenIssuer; - if (qpi.releaseShares(locals.asset, qpi.invocator(), qpi.invocator(), input.numberOfShares, - input.newManagingContractIndex, input.newManagingContractIndex, QRAFFLE_TRANSFER_SHARE_FEE) < 0) + locals.offeredFee = qpi.invocationReward() - QRAFFLE_TRANSFER_SHARE_FEE; + locals.paidFee = qpi.releaseShares(locals.asset, qpi.invocator(), qpi.invocator(), input.numberOfShares, + input.newManagingContractIndex, input.newManagingContractIndex, locals.offeredFee); + if (locals.paidFee < 0) { - // error + // Transfer rejected by the destination — refund everything. output.transferredNumberOfShares = 0; locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_transferFailed, 0 }; LOG_INFO(locals.log); @@ -929,11 +1020,12 @@ struct QRAFFLE : public ContractBase } else { - // success + // Success — keep service fee as revenue, refund unused transfer fee. output.transferredNumberOfShares = input.numberOfShares; - if (qpi.invocationReward() > QRAFFLE_TRANSFER_SHARE_FEE) + state.mut().epochRevenue += QRAFFLE_TRANSFER_SHARE_FEE; + if (locals.offeredFee > locals.paidFee) { - qpi.transfer(qpi.invocator(), qpi.invocationReward() - QRAFFLE_TRANSFER_SHARE_FEE); + qpi.transfer(qpi.invocator(), locals.offeredFee - locals.paidFee); } locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_shareManagementRightsTransferred, 0 }; LOG_INFO(locals.log); @@ -1116,6 +1208,9 @@ struct QRAFFLE : public ContractBase PUBLIC_FUNCTION_WITH_LOCALS(getEpochRaffleIndexes) { + // Initialise outputs so callers see deterministic values when no raffles match. + output.StartIndex = 0; + output.EndIndex = 0; if (input.epoch > qpi.epoch()) { output.returnCode = QRAFFLE_INVALID_EPOCH; @@ -1125,6 +1220,7 @@ struct QRAFFLE : public ContractBase { output.StartIndex = 0; output.EndIndex = state.get().numberOfActiveTokenRaffle; + output.returnCode = QRAFFLE_SUCCESS; return ; } for (locals.i = 0; locals.i < (sint32)state.get().numberOfEndedTokenRaffle; locals.i++) @@ -1148,6 +1244,10 @@ struct QRAFFLE : public ContractBase PUBLIC_FUNCTION(getEndedQuRaffle) { + // Note: indices are masked by the underlying Array (capacity is power-of-two), so + // any uint32 epoch value is safely bounded inside the QuRaffles ring. Slots that + // were never written return a zero-initialised QuRaffleInfo, which the caller can + // detect via numberOfMembers == 0 / epochWinner == NULL_ID. output.epochWinner = state.get().QuRaffles.get(input.epoch).epochWinner; output.receivedAmount = state.get().QuRaffles.get(input.epoch).receivedAmount; output.entryAmount = state.get().QuRaffles.get(input.epoch).entryAmount; @@ -1194,10 +1294,13 @@ struct QRAFFLE : public ContractBase PUBLIC_FUNCTION_WITH_LOCALS(getQuRaffleEntryAverageAmount) { + locals.entryAmount = 0; + locals.totalEntryAmount = 0; locals.idx = state.get().quRaffleEntryAmount.nextElementIndex(NULL_INDEX); while (locals.idx != NULL_INDEX) { - locals.totalEntryAmount += state.get().quRaffleEntryAmount.value(locals.idx); + locals.entryAmount = state.get().quRaffleEntryAmount.value(locals.idx); + locals.totalEntryAmount += locals.entryAmount; locals.idx = state.get().quRaffleEntryAmount.nextElementIndex(locals.idx); } if (state.get().numberOfEntryAmountSubmitted > 0) @@ -1261,9 +1364,20 @@ struct QRAFFLE : public ContractBase ActiveTokenRaffleInfo acTokenRaffle; AssetPossessionIterator iter; Asset QraffleAsset; - id digest, winner, shareholder, baseSeed, raffleSeed; + id digest, computerDigest, winner, shareholder, baseSeed, raffleSeed; sint64 idx; + sint64 sharesHeld; + sint64 perShare; + sint64 transferResult; uint64 sumOfEntryAmountSubmitted, r, winnerRevenue, burnAmount, charityRevenue, shareholderRevenue, registerRevenue, fee, oneShareholderRev; + uint64 tokenPool; + uint64 shareholderPerShareUnit; + uint64 registerPerShareUnit; + uint64 actualShareholderTotal; + uint64 actualRegisterTotal; + uint64 qxmrPerShare; + uint64 qxmrDistributedTotal; + uint64 qxmrContractBalance; uint32 i, j, winnerIndex; Logger log; EmptyTokenRaffleLogger emptyTokenRafflelog; @@ -1275,68 +1389,89 @@ struct QRAFFLE : public ContractBase END_EPOCH_WITH_LOCALS() { - locals.oneShareholderRev = div(state.get().epochRevenue, 676); - qpi.distributeDividends(locals.oneShareholderRev); - state.mut().epochRevenue -= locals.oneShareholderRev * 676; + // Distribute logout-fee revenue to shareholders. + locals.oneShareholderRev = div(state.get().epochRevenue, NUMBER_OF_COMPUTORS); + if (locals.oneShareholderRev > 0) + { + qpi.distributeDividends(locals.oneShareholderRev); + state.mut().epochRevenue -= locals.oneShareholderRev * NUMBER_OF_COMPUTORS; + } + // RNG seed: XOR of prevSpectrumDigest and prevComputerDigest, hashed with K12. + // Tick-level inputs are excluded so a computor cannot grind the seed. locals.digest = qpi.getPrevSpectrumDigest(); - locals.baseSeed = qpi.K12(m256i(locals.digest.u64._0 ^ (uint64)qpi.epoch(), locals.digest.u64._1 ^ (uint64)qpi.tick(), locals.digest.u64._2 ^ (uint64)(qpi.numberOfTickTransactions() + 1), locals.digest.u64._3 ^ (uint64)qpi.second())); - locals.raffleSeed = qpi.K12(m256i(locals.baseSeed.u64._0, locals.baseSeed.u64._1, locals.baseSeed.u64._2, locals.baseSeed.u64._3 ^ 0ULL)); - locals.r = locals.raffleSeed.u64._0; - locals.winnerIndex = (uint32)mod(locals.r, state.get().numberOfQuRaffleMembers * 1ull); - locals.winner = state.get().quRaffleMembers.get(locals.winnerIndex); - - // Get QRAFFLE asset shareholders + locals.computerDigest = qpi.getPrevComputerDigest(); + locals.baseSeed = qpi.K12(m256i( + locals.digest.u64._0 ^ locals.computerDigest.u64._0, + locals.digest.u64._1 ^ locals.computerDigest.u64._1, + locals.digest.u64._2 ^ locals.computerDigest.u64._2, + locals.digest.u64._3 ^ locals.computerDigest.u64._3)); + + // Build the shareholder set for token-raffle and QXMR distributions. locals.QraffleAsset.assetName = QRAFFLE_ASSET_NAME; locals.QraffleAsset.issuer = NULL_ID; locals.iter.begin(locals.QraffleAsset); while (!locals.iter.reachedEnd()) { - locals.shareholder = locals.iter.possessor(); - if (state.get().shareholdersList.contains(locals.shareholder) == 0) + if (locals.iter.numberOfPossessedShares() > 0) { - state.mut().shareholdersList.add(locals.shareholder); + locals.shareholder = locals.iter.possessor(); + if (state.get().shareholdersList.contains(locals.shareholder) == 0) + { + state.mut().shareholdersList.add(locals.shareholder); + } } - locals.iter.next(); } if (state.get().numberOfQuRaffleMembers > 0) { - // Calculate fee distributions - locals.burnAmount = div(state.get().qREAmount * state.get().numberOfQuRaffleMembers * QRAFFLE_BURN_FEE, 100); - locals.charityRevenue = div(state.get().qREAmount * state.get().numberOfQuRaffleMembers * QRAFFLE_CHARITY_FEE, 100); - locals.shareholderRevenue = div(state.get().qREAmount * state.get().numberOfQuRaffleMembers * QRAFFLE_SHAREHOLDER_FEE, 100); - locals.registerRevenue = div(state.get().qREAmount * state.get().numberOfQuRaffleMembers * QRAFFLE_REGISTER_FEE, 100); - locals.fee = div(state.get().qREAmount * state.get().numberOfQuRaffleMembers * QRAFFLE_FEE, 100); - locals.winnerRevenue = state.get().qREAmount * state.get().numberOfQuRaffleMembers - locals.burnAmount - locals.charityRevenue - div(locals.shareholderRevenue, 676) * 676 - div(locals.registerRevenue, state.get().numberOfRegisters) * state.get().numberOfRegisters - locals.fee; + // Pick winner. + locals.raffleSeed = qpi.K12(m256i(locals.baseSeed.u64._0, locals.baseSeed.u64._1, locals.baseSeed.u64._2, locals.baseSeed.u64._3)); + locals.r = locals.raffleSeed.u64._0; + locals.winnerIndex = (uint32)mod(locals.r, state.get().numberOfQuRaffleMembers * 1ull); + locals.winner = state.get().quRaffleMembers.get(locals.winnerIndex); + + // Calculate fee distributions. + locals.tokenPool = state.get().qREAmount * state.get().numberOfQuRaffleMembers; + locals.burnAmount = div(locals.tokenPool * QRAFFLE_BURN_FEE, 100); + locals.charityRevenue = div(locals.tokenPool * QRAFFLE_CHARITY_FEE, 100); + locals.shareholderRevenue = div(locals.tokenPool * QRAFFLE_SHAREHOLDER_FEE, 100); + locals.registerRevenue = div(locals.tokenPool * QRAFFLE_REGISTER_FEE, 100); + locals.fee = div(locals.tokenPool * QRAFFLE_FEE, 100); + // Round down per-share amounts; winner gets the remainder. + locals.shareholderPerShareUnit = div(locals.shareholderRevenue, NUMBER_OF_COMPUTORS); + locals.actualShareholderTotal = locals.shareholderPerShareUnit * NUMBER_OF_COMPUTORS; + locals.registerPerShareUnit = div(locals.registerRevenue, state.get().numberOfRegisters); + locals.actualRegisterTotal = locals.registerPerShareUnit * state.get().numberOfRegisters; + locals.winnerRevenue = locals.tokenPool - locals.burnAmount - locals.charityRevenue - locals.actualShareholderTotal - locals.actualRegisterTotal - locals.fee; - // Log detailed revenue distribution information locals.revenueLog = RevenueLogger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_revenueDistributed, locals.burnAmount, locals.charityRevenue, - div(locals.shareholderRevenue, 676) * 676, - div(locals.registerRevenue, state.get().numberOfRegisters) * state.get().numberOfRegisters, + locals.actualShareholderTotal, + locals.actualRegisterTotal, locals.fee, locals.winnerRevenue, 0 }; LOG_INFO(locals.revenueLog); - // Execute transfers and log each distribution qpi.transfer(locals.winner, locals.winnerRevenue); qpi.burn(locals.burnAmount); qpi.transfer(state.get().charityAddress, locals.charityRevenue); - qpi.distributeDividends(div(locals.shareholderRevenue, 676)); + if (locals.shareholderPerShareUnit > 0) + { + qpi.distributeDividends(locals.shareholderPerShareUnit); + } qpi.transfer(state.get().feeAddress, locals.fee); - // Update total amounts and log largest winner update state.mut().totalBurnAmount += locals.burnAmount; state.mut().totalCharityAmount += locals.charityRevenue; - state.mut().totalShareholderAmount += div(locals.shareholderRevenue, 676) * 676; - state.mut().totalRegisterAmount += div(locals.registerRevenue, state.get().numberOfRegisters) * state.get().numberOfRegisters; + state.mut().totalShareholderAmount += locals.actualShareholderTotal; + state.mut().totalRegisterAmount += locals.actualRegisterTotal; state.mut().totalFeeAmount += locals.fee; state.mut().totalWinnerAmount += locals.winnerRevenue; if (locals.winnerRevenue > state.get().largestWinnerAmount) @@ -1344,46 +1479,62 @@ struct QRAFFLE : public ContractBase state.mut().largestWinnerAmount = locals.winnerRevenue; } - locals.idx = state.get().registers.nextElementIndex(NULL_INDEX); - while (locals.idx != NULL_INDEX) + if (locals.registerPerShareUnit > 0) { - qpi.transfer(state.get().registers.key(locals.idx), div(locals.registerRevenue, state.get().numberOfRegisters)); - locals.idx = state.get().registers.nextElementIndex(locals.idx); + locals.idx = state.get().registers.nextElementIndex(NULL_INDEX); + while (locals.idx != NULL_INDEX) + { + qpi.transfer(state.get().registers.key(locals.idx), locals.registerPerShareUnit); + locals.idx = state.get().registers.nextElementIndex(locals.idx); + } } - // Store QuRaffle results and log completion with detailed information locals.qraffle.epochWinner = locals.winner; locals.qraffle.receivedAmount = locals.winnerRevenue; locals.qraffle.entryAmount = state.get().qREAmount; locals.qraffle.numberOfMembers = state.get().numberOfQuRaffleMembers; locals.qraffle.winnerIndex = locals.winnerIndex; state.mut().QuRaffles.set(qpi.epoch(), locals.qraffle); - state.mut().daoMemberCount.set(qpi.epoch(), state.get().numberOfRegisters); // Store DAO member count for this epoch - // Log QuRaffle completion with detailed information locals.endEpochLog = EndEpochLogger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_revenueDistributed, qpi.epoch(), state.get().numberOfQuRaffleMembers, - state.get().qREAmount * state.get().numberOfQuRaffleMembers, + locals.tokenPool, locals.winnerRevenue, locals.winnerIndex, 0 }; LOG_INFO(locals.endEpochLog); - if (state.get().epochQXMRRevenue >= 676) + // Distribute QXMR to shareholders. Only deduct what was actually transferred. + locals.qxmrPerShare = div(state.get().epochQXMRRevenue, NUMBER_OF_COMPUTORS); + locals.qxmrDistributedTotal = 0; + if (locals.qxmrPerShare > 0) { - locals.idx = state.get().shareholdersList.nextElementIndex(NULL_INDEX); - while (locals.idx != NULL_INDEX) + locals.qxmrContractBalance = (uint64)qpi.numberOfPossessedShares(QRAFFLE_QXMR_ASSET_NAME, state.get().QXMRIssuer, SELF, SELF, SELF_INDEX, SELF_INDEX); + locals.iter.begin(locals.QraffleAsset); + while (!locals.iter.reachedEnd()) { - locals.shareholder = state.get().shareholdersList.key(locals.idx); - qpi.transferShareOwnershipAndPossession(QRAFFLE_QXMR_ASSET_NAME, state.get().QXMRIssuer, SELF, SELF, div(state.get().epochQXMRRevenue, 676) * qpi.numberOfShares(locals.QraffleAsset, AssetOwnershipSelect::byOwner(locals.shareholder), AssetPossessionSelect::byPossessor(locals.shareholder)), locals.shareholder); - locals.idx = state.get().shareholdersList.nextElementIndex(locals.idx); + locals.sharesHeld = locals.iter.numberOfPossessedShares(); + if (locals.sharesHeld > 0) + { + locals.shareholder = locals.iter.possessor(); + locals.perShare = (sint64)(locals.qxmrPerShare * (uint64)locals.sharesHeld); + if ((uint64)locals.perShare <= locals.qxmrContractBalance - locals.qxmrDistributedTotal) + { + locals.transferResult = qpi.transferShareOwnershipAndPossession(QRAFFLE_QXMR_ASSET_NAME, state.get().QXMRIssuer, SELF, SELF, locals.perShare, locals.shareholder); + if (locals.transferResult >= 0) + { + locals.qxmrDistributedTotal += (uint64)locals.perShare; + } + } + } + locals.iter.next(); } - state.mut().epochQXMRRevenue -= div(state.get().epochQXMRRevenue, 676) * 676; } + state.mut().epochQXMRRevenue -= locals.qxmrDistributedTotal; } else { @@ -1391,7 +1542,7 @@ struct QRAFFLE : public ContractBase LOG_INFO(locals.log); } - // Process each active token raffle and log + // Process each active token raffle. for (locals.i = 0 ; locals.i < state.get().numberOfActiveTokenRaffle; locals.i++) { if (state.get().numberOfTokenRaffleMembers.get(locals.i) > 0) @@ -1404,33 +1555,71 @@ struct QRAFFLE : public ContractBase locals.acTokenRaffle = state.get().activeTokenRaffle.get(locals.i); - // Calculate token raffle fee distributions - locals.burnAmount = div(locals.acTokenRaffle.entryAmount * state.get().numberOfTokenRaffleMembers.get(locals.i) * QRAFFLE_BURN_FEE, 100); - locals.charityRevenue = div(locals.acTokenRaffle.entryAmount * state.get().numberOfTokenRaffleMembers.get(locals.i) * QRAFFLE_CHARITY_FEE, 100); - locals.shareholderRevenue = div(locals.acTokenRaffle.entryAmount * state.get().numberOfTokenRaffleMembers.get(locals.i) * QRAFFLE_SHAREHOLDER_FEE, 100); - locals.registerRevenue = div(locals.acTokenRaffle.entryAmount * state.get().numberOfTokenRaffleMembers.get(locals.i) * QRAFFLE_REGISTER_FEE, 100); - locals.fee = div(locals.acTokenRaffle.entryAmount * state.get().numberOfTokenRaffleMembers.get(locals.i) * QRAFFLE_FEE, 100); - locals.winnerRevenue = locals.acTokenRaffle.entryAmount * state.get().numberOfTokenRaffleMembers.get(locals.i) - locals.burnAmount - locals.charityRevenue - div(locals.shareholderRevenue, 676) * 676 - div(locals.registerRevenue, state.get().numberOfRegisters) * state.get().numberOfRegisters - locals.fee; - - // Execute token transfers and log each - qpi.transferShareOwnershipAndPossession(locals.acTokenRaffle.token.assetName, locals.acTokenRaffle.token.issuer, SELF, SELF, locals.winnerRevenue, locals.winner); - qpi.transferShareOwnershipAndPossession(locals.acTokenRaffle.token.assetName, locals.acTokenRaffle.token.issuer, SELF, SELF, locals.burnAmount, NULL_ID); - qpi.transferShareOwnershipAndPossession(locals.acTokenRaffle.token.assetName, locals.acTokenRaffle.token.issuer, SELF, SELF, locals.charityRevenue, state.get().charityAddress); - qpi.transferShareOwnershipAndPossession(locals.acTokenRaffle.token.assetName, locals.acTokenRaffle.token.issuer, SELF, SELF, locals.fee, state.get().feeAddress); - - locals.idx = state.get().shareholdersList.nextElementIndex(NULL_INDEX); - while (locals.idx != NULL_INDEX) + locals.tokenPool = locals.acTokenRaffle.entryAmount * state.get().numberOfTokenRaffleMembers.get(locals.i); + locals.burnAmount = div(locals.tokenPool * QRAFFLE_BURN_FEE, 100); + locals.charityRevenue = div(locals.tokenPool * QRAFFLE_CHARITY_FEE, 100); + locals.shareholderRevenue = div(locals.tokenPool * QRAFFLE_SHAREHOLDER_FEE, 100); + locals.registerRevenue = div(locals.tokenPool * QRAFFLE_REGISTER_FEE, 100); + locals.fee = div(locals.tokenPool * QRAFFLE_FEE, 100); + // Round down per-share amounts; winner gets the remainder. + locals.shareholderPerShareUnit = div(locals.shareholderRevenue, NUMBER_OF_COMPUTORS); + locals.actualShareholderTotal = locals.shareholderPerShareUnit * NUMBER_OF_COMPUTORS; + locals.registerPerShareUnit = div(locals.registerRevenue, state.get().numberOfRegisters); + locals.actualRegisterTotal = locals.registerPerShareUnit * state.get().numberOfRegisters; + locals.winnerRevenue = locals.tokenPool - locals.burnAmount - locals.charityRevenue - locals.actualShareholderTotal - locals.actualRegisterTotal - locals.fee; + + // Send winner share. Skip the whole raffle if this fails to prevent partial distribution. + locals.transferResult = qpi.transferShareOwnershipAndPossession(locals.acTokenRaffle.token.assetName, locals.acTokenRaffle.token.issuer, SELF, SELF, locals.winnerRevenue, locals.winner); + if (locals.transferResult < 0) { - locals.shareholder = state.get().shareholdersList.key(locals.idx); - qpi.transferShareOwnershipAndPossession(locals.acTokenRaffle.token.assetName, locals.acTokenRaffle.token.issuer, SELF, SELF, div(locals.shareholderRevenue, 676) * qpi.numberOfShares(locals.QraffleAsset, AssetOwnershipSelect::byOwner(locals.shareholder), AssetPossessionSelect::byPossessor(locals.shareholder)), locals.shareholder); - locals.idx = state.get().shareholdersList.nextElementIndex(locals.idx); + // Winner transfer failed — skip raffle, leave funds for next epoch. + locals.emptyTokenRafflelog = EmptyTokenRaffleLogger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_transferFailed, locals.i, 0 }; + LOG_INFO(locals.emptyTokenRafflelog); + state.mut().numberOfTokenRaffleMembers.set(locals.i, 0); + continue; } - locals.idx = state.get().registers.nextElementIndex(NULL_INDEX); - while (locals.idx != NULL_INDEX) + // Burn shares (NULL_ID); fall back to charity if burn is rejected. + if (locals.burnAmount > 0) { - qpi.transferShareOwnershipAndPossession(locals.acTokenRaffle.token.assetName, locals.acTokenRaffle.token.issuer, SELF, SELF, div(locals.registerRevenue, state.get().numberOfRegisters), state.get().registers.key(locals.idx)); - locals.idx = state.get().registers.nextElementIndex(locals.idx); + locals.transferResult = qpi.transferShareOwnershipAndPossession(locals.acTokenRaffle.token.assetName, locals.acTokenRaffle.token.issuer, SELF, SELF, locals.burnAmount, NULL_ID); + if (locals.transferResult < 0) + { + qpi.transferShareOwnershipAndPossession(locals.acTokenRaffle.token.assetName, locals.acTokenRaffle.token.issuer, SELF, SELF, locals.burnAmount, state.get().charityAddress); + } + } + if (locals.charityRevenue > 0) + { + qpi.transferShareOwnershipAndPossession(locals.acTokenRaffle.token.assetName, locals.acTokenRaffle.token.issuer, SELF, SELF, locals.charityRevenue, state.get().charityAddress); + } + if (locals.fee > 0) + { + qpi.transferShareOwnershipAndPossession(locals.acTokenRaffle.token.assetName, locals.acTokenRaffle.token.issuer, SELF, SELF, locals.fee, state.get().feeAddress); + } + + // Pay shareholders proportional to possessed shares. + if (locals.shareholderPerShareUnit > 0) + { + locals.iter.begin(locals.QraffleAsset); + while (!locals.iter.reachedEnd()) + { + locals.sharesHeld = locals.iter.numberOfPossessedShares(); + if (locals.sharesHeld > 0) + { + qpi.transferShareOwnershipAndPossession(locals.acTokenRaffle.token.assetName, locals.acTokenRaffle.token.issuer, SELF, SELF, (sint64)(locals.shareholderPerShareUnit * (uint64)locals.sharesHeld), locals.iter.possessor()); + } + locals.iter.next(); + } + } + + if (locals.registerPerShareUnit > 0) + { + locals.idx = state.get().registers.nextElementIndex(NULL_INDEX); + while (locals.idx != NULL_INDEX) + { + qpi.transferShareOwnershipAndPossession(locals.acTokenRaffle.token.assetName, locals.acTokenRaffle.token.issuer, SELF, SELF, (sint64)locals.registerPerShareUnit, state.get().registers.key(locals.idx)); + locals.idx = state.get().registers.nextElementIndex(locals.idx); + } } locals.tRaffle.epochWinner = locals.winner; @@ -1442,7 +1631,6 @@ struct QRAFFLE : public ContractBase locals.tRaffle.epoch = qpi.epoch(); state.mut().tokenRaffle.set(state.get().numberOfEndedTokenRaffle, locals.tRaffle); - // Log token raffle ended with detailed information locals.tokenRaffleLog = TokenRaffleLogger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_tokenRaffleEnded, @@ -1516,6 +1704,9 @@ struct QRAFFLE : public ContractBase } } + // Record DAO member count for this epoch before resetting per-epoch state. + state.mut().daoMemberCount.set(qpi.epoch(), state.get().numberOfRegisters); + state.mut().numberOfVotedInProposal.setAll(0); state.mut().tokenRaffleMembers.reset(); state.mut().proposalsPerProposer.reset(); @@ -1530,6 +1721,18 @@ struct QRAFFLE : public ContractBase PRE_ACQUIRE_SHARES() { + // Accept all incoming share management transfers for free. + // Service fees are collected in user procedures (depositInTokenRaffle, TransferShareManagementRights). + output.requestedFee = 0; output.allowTransfer = true; } + + POST_ACQUIRE_SHARES() + { + // Credit any received fee to epochRevenue. + if (input.receivedFee > 0) + { + state.mut().epochRevenue += (uint64)input.receivedFee; + } + } }; diff --git a/test/contract_qraffle.cpp b/test/contract_qraffle.cpp index 54a123292..3990d80b6 100644 --- a/test/contract_qraffle.cpp +++ b/test/contract_qraffle.cpp @@ -423,6 +423,11 @@ class ContractTestingQraffle : protected ContractTesting } sint64 TransferShareManagementRightsQraffle(const id& issuer, uint64 assetName, uint32 newManagingContractIndex, sint64 numberOfShares, const id& currentOwner) + { + return TransferShareManagementRightsQraffleWithFee(issuer, assetName, newManagingContractIndex, numberOfShares, currentOwner, QRAFFLE_TRANSFER_SHARE_FEE); + } + + sint64 TransferShareManagementRightsQraffleWithFee(const id& issuer, uint64 assetName, uint32 newManagingContractIndex, sint64 numberOfShares, const id& currentOwner, sint64 invocationReward) { QRAFFLE::TransferShareManagementRights_input input; QRAFFLE::TransferShareManagementRights_output output; @@ -432,7 +437,7 @@ class ContractTestingQraffle : protected ContractTesting input.newManagingContractIndex = newManagingContractIndex; input.numberOfShares = numberOfShares; - invokeUserProcedure(QRAFFLE_CONTRACT_INDEX, 8, input, output, currentOwner, QRAFFLE_TRANSFER_SHARE_FEE); + invokeUserProcedure(QRAFFLE_CONTRACT_INDEX, 8, input, output, currentOwner, invocationReward); return output.transferredNumberOfShares; } }; @@ -553,7 +558,8 @@ TEST(ContractQraffle, SubmitProposal) // Issue some test assets id issuer = getUser(2000); - increaseEnergy(issuer, 1000000000ULL); + // Asset issuance fee on QX is 1,000,000,000 per asset; need at least 2 fees here. + increaseEnergy(issuer, 2 * 1000000000ULL); uint64 assetName1 = assetNameFromString("TEST1"); uint64 assetName2 = assetNameFromString("TEST2"); qraffle.issueAsset(issuer, assetName1, 1000000, 0, 0); @@ -615,13 +621,14 @@ TEST(ContractQraffle, SubmitProposalPerUserLimitAndValidation) EXPECT_EQ(qraffle.submitProposal(users[0], token, 3000000ULL).returnCode, QRAFFLE_SUCCESS); EXPECT_EQ(qraffle.submitProposal(users[0], token, 4000000ULL).returnCode, QRAFFLE_MAX_PROPOSAL_PER_USER_REACHED); - EXPECT_EQ(qraffle.submitProposal(users[0], token, 500000ULL).returnCode, QRAFFLE_INVALID_ENTRY_AMOUNT); - EXPECT_EQ(qraffle.submitProposal(users[0], token, 2000000000ULL).returnCode, QRAFFLE_INVALID_ENTRY_AMOUNT); + // Use a different proposer for entry-amount validation; per-user cap would otherwise mask it. + EXPECT_EQ(qraffle.submitProposal(users[1], token, 500000ULL).returnCode, QRAFFLE_INVALID_ENTRY_AMOUNT); + EXPECT_EQ(qraffle.submitProposal(users[1], token, 2000000000ULL).returnCode, QRAFFLE_INVALID_ENTRY_AMOUNT); Asset fakeToken; fakeToken.assetName = assetNameFromString("NOTISS"); fakeToken.issuer = issuer; - EXPECT_EQ(qraffle.submitProposal(users[1], fakeToken, 1000000ULL).returnCode, QRAFFLE_INVALID_TOKEN_TYPE); + EXPECT_EQ(qraffle.submitProposal(users[2], fakeToken, 1000000ULL).returnCode, QRAFFLE_INVALID_TOKEN_TYPE); } TEST(ContractQraffle, VoteInProposal) @@ -802,13 +809,25 @@ TEST(ContractQraffle, DepositInTokenRaffle) auto result = qraffle.depositInTokenRaffle(poorUser, 0, QRAFFLE_TRANSFER_SHARE_FEE - 1); EXPECT_EQ(result.returnCode, QRAFFLE_INSUFFICIENT_FUND); - // Test insufficient Token + // Test unregistered user (must be a DAO member to deposit in token raffle) + increaseEnergy(poorUser, QRAFFLE_TRANSFER_SHARE_FEE); + result = qraffle.depositInTokenRaffle(poorUser, 0, QRAFFLE_TRANSFER_SHARE_FEE); + EXPECT_EQ(result.returnCode, QRAFFLE_UNREGISTERED); + + // Test insufficient Token (registered DAO member with too few shares) id poorUser2 = getUser(8888); + increaseEnergy(poorUser2, QRAFFLE_REGISTER_AMOUNT); + qraffle.registerInSystem(poorUser2, QRAFFLE_REGISTER_AMOUNT, 0); increaseEnergy(poorUser2, QRAFFLE_TRANSFER_SHARE_FEE); qraffle.transferShareOwnershipAndPossession(issuer, assetName, issuer, 999999, poorUser2); result = qraffle.depositInTokenRaffle(poorUser2, 0, QRAFFLE_TRANSFER_SHARE_FEE); EXPECT_EQ(result.returnCode, QRAFFLE_FAILED_TO_DEPOSIT); + // Test duplicate deposit by same user (already deposited above) + increaseEnergy(users[0], QRAFFLE_TRANSFER_SHARE_FEE); + result = qraffle.depositInTokenRaffle(users[0], 0, QRAFFLE_TRANSFER_SHARE_FEE); + EXPECT_EQ(result.returnCode, QRAFFLE_ALREADY_REGISTERED); + // Test invalid token raffle index increaseEnergy(users[0], QRAFFLE_TRANSFER_SHARE_FEE); result = qraffle.depositInTokenRaffle(users[0], 999, QRAFFLE_TRANSFER_SHARE_FEE); @@ -831,7 +850,11 @@ TEST(ContractQraffle, TransferShareManagementRights) EXPECT_EQ(numberOfPossessedShares(assetName, issuer, user1, user1, QRAFFLE_CONTRACT_INDEX, QRAFFLE_CONTRACT_INDEX), 1000000); increaseEnergy(user1, 1000000000ULL); - qraffle.TransferShareManagementRightsQraffle(issuer, assetName, QX_CONTRACT_INDEX, 1000000, user1); + // QRAFFLE's procedure now forwards (invocationReward - QRAFFLE_TRANSFER_SHARE_FEE) as the + // offeredTransferFee to the destination contract (QX requires 100 QU). Previously the test + // only paid QRAFFLE's own fee, leaving 0 to offer QX which silently failed. We now pass + // a sufficient amount and rely on the procedure to refund any excess. + qraffle.TransferShareManagementRightsQraffleWithFee(issuer, assetName, QX_CONTRACT_INDEX, 1000000, user1, 2 * QRAFFLE_TRANSFER_SHARE_FEE); EXPECT_EQ(numberOfPossessedShares(assetName, issuer, user1, user1, QX_CONTRACT_INDEX, QX_CONTRACT_INDEX), 1000000); } @@ -866,7 +889,8 @@ TEST(ContractQraffle, GetFunctions) // Create some proposals id issuer = getUser(2000); - increaseEnergy(issuer, 1000000000ULL); + // Asset issuance fee on QX is 1,000,000,000 per asset; need at least 2 fees here. + increaseEnergy(issuer, 2 * 1000000000ULL); uint64 assetName1 = assetNameFromString("TEST1"); uint64 assetName2 = assetNameFromString("TEST2"); qraffle.issueAsset(issuer, assetName1, 1000000000, 0, 0); @@ -1454,7 +1478,10 @@ TEST(ContractQraffle, QXMRRevenueDistribution) qraffle.depositInQuRaffle(users[0], QRAFFLE_DEFAULT_QRAFFLE_AMOUNT); qraffle.endEpoch(); - EXPECT_EQ(qraffle.getState()->getEpochQXMRRevenue(), expectedQXMRRevenue - div(expectedQXMRRevenue, 676ull) * 676); + // No QRAFFLE shareholders exist in this test universe, so no QXMR is actually transferred. + // The contract must therefore retain the full epochQXMRRevenue rather than booking it as + // distributed (previous behavior silently leaked QXMR accounting). + EXPECT_EQ(qraffle.getState()->getEpochQXMRRevenue(), expectedQXMRRevenue); } TEST(ContractQraffle, GetQuRaffleEntryAmountPerUser) From 48a4ba75dcef6176c71962da5f4b5b062e2f29cb Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Fri, 24 Apr 2026 10:41:31 +0900 Subject: [PATCH 08/16] fix: voteStatus's data structure - O(N) -> O(1) --- src/contracts/QRaffle.h | 111 ++++++++++++++++------------------------ 1 file changed, 45 insertions(+), 66 deletions(-) diff --git a/src/contracts/QRaffle.h b/src/contracts/QRaffle.h index 4b393d9d2..60501ceb8 100644 --- a/src/contracts/QRaffle.h +++ b/src/contracts/QRaffle.h @@ -166,11 +166,6 @@ struct QRAFFLE : public ContractBase uint32 nNo; }; - struct VotedId { - id user; - bit status; - }; - struct QuRaffleInfo { id epochWinner; @@ -200,8 +195,10 @@ struct QRAFFLE : public ContractBase HashMap registers; Array proposals; - HashMap , QRAFFLE_MAX_PROPOSAL_EPOCH> voteStatus; - Array tmpVoteStatus; + // Per-user vote tracking with dual BitArray (qRWA pattern). + // O(1) lookup via id hash, 1 bit per proposal. ~4 MB each, ~8 MB total. + HashMap , QRAFFLE_MAX_MEMBER> voteParticipation; // bit=1 if user has voted + HashMap , QRAFFLE_MAX_MEMBER> voteValues; // bit=1 for yes, bit=0 for no Array numberOfVotedInProposal; Array quRaffleMembers; @@ -704,9 +701,8 @@ struct QRAFFLE : public ContractBase struct voteInProposal_locals { ProposalInfo proposal; - VotedId votedId; - VotedId emptyVote; - uint32 i; + BitArray participation; + BitArray values; uint32 votedCount; Logger log; }; @@ -732,63 +728,42 @@ struct QRAFFLE : public ContractBase return ; } locals.proposal = state.get().proposals.get(input.indexOfProposal); - // Load vote buffer for this proposal. Clear it if no votes exist yet to avoid - // matching stale entries left over from a previous proposal in the same slot. - if (state.get().voteStatus.contains(input.indexOfProposal)) - { - state.get().voteStatus.get(input.indexOfProposal, state.mut().tmpVoteStatus); - } - else - { - locals.emptyVote.user = NULL_ID; - locals.emptyVote.status = 0; - state.mut().tmpVoteStatus.setAll(locals.emptyVote); - } - locals.votedCount = state.get().numberOfVotedInProposal.get(input.indexOfProposal); - for (locals.i = 0; locals.i < locals.votedCount; locals.i++) + + // O(1) vote lookup via per-user bitfield (id hash, no K12 overhead). + state.get().voteParticipation.get(qpi.invocator(), locals.participation); + if (locals.participation.get(input.indexOfProposal)) { - if (state.get().tmpVoteStatus.get(locals.i).user == qpi.invocator()) + // Already voted — check if same direction. + state.get().voteValues.get(qpi.invocator(), locals.values); + if (locals.values.get(input.indexOfProposal) == input.yes) { - if (state.get().tmpVoteStatus.get(locals.i).status == input.yes) - { - output.returnCode = QRAFFLE_ALREADY_VOTED; - locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_alreadyVoted, 0 }; - LOG_INFO(locals.log); - return ; - } - else - { - // Guard against unsigned underflow when flipping the vote. - if (input.yes) - { - locals.proposal.nYes++; - if (locals.proposal.nNo > 0) - { - locals.proposal.nNo--; - } - } - else - { - locals.proposal.nNo++; - if (locals.proposal.nYes > 0) - { - locals.proposal.nYes--; - } - } - state.mut().proposals.set(input.indexOfProposal, locals.proposal); - } - - locals.votedId.user = qpi.invocator(); - locals.votedId.status = input.yes; - state.mut().tmpVoteStatus.set(locals.i, locals.votedId); - state.mut().voteStatus.set(input.indexOfProposal, state.get().tmpVoteStatus); - output.returnCode = QRAFFLE_SUCCESS; - locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_proposalVoted, 0 }; + output.returnCode = QRAFFLE_ALREADY_VOTED; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_alreadyVoted, 0 }; LOG_INFO(locals.log); return ; } + // Flip the vote: update counters with underflow guard. + if (input.yes) + { + locals.proposal.nYes++; + if (locals.proposal.nNo > 0) { locals.proposal.nNo--; } + } + else + { + locals.proposal.nNo++; + if (locals.proposal.nYes > 0) { locals.proposal.nYes--; } + } + state.mut().proposals.set(input.indexOfProposal, locals.proposal); + locals.values.set(input.indexOfProposal, input.yes); + state.mut().voteValues.replace(qpi.invocator(), locals.values); + output.returnCode = QRAFFLE_SUCCESS; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_proposalVoted, 0 }; + LOG_INFO(locals.log); + return ; } - // Reject new votes once the per-proposal buffer is full. + + // New vote: check capacity before inserting. + locals.votedCount = state.get().numberOfVotedInProposal.get(input.indexOfProposal); if (locals.votedCount >= QRAFFLE_MAX_MEMBER) { output.returnCode = QRAFFLE_MAX_MEMBER_REACHED; @@ -806,10 +781,13 @@ struct QRAFFLE : public ContractBase } state.mut().proposals.set(input.indexOfProposal, locals.proposal); - locals.votedId.user = qpi.invocator(); - locals.votedId.status = input.yes; - state.mut().tmpVoteStatus.set(locals.votedCount, locals.votedId); - state.mut().voteStatus.set(input.indexOfProposal, state.get().tmpVoteStatus); + // Mark participation and record vote direction. + locals.participation.set(input.indexOfProposal, 1); + state.mut().voteParticipation.set(qpi.invocator(), locals.participation); + state.get().voteValues.get(qpi.invocator(), locals.values); + locals.values.set(input.indexOfProposal, input.yes); + state.mut().voteValues.set(qpi.invocator(), locals.values); + state.mut().numberOfVotedInProposal.set(input.indexOfProposal, locals.votedCount + 1); output.returnCode = QRAFFLE_SUCCESS; locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_proposalVoted, 0 }; @@ -1712,7 +1690,8 @@ struct QRAFFLE : public ContractBase state.mut().proposalsPerProposer.reset(); state.mut().quRaffleEntryAmount.reset(); state.mut().shareholdersList.reset(); - state.mut().voteStatus.reset(); + state.mut().voteParticipation.reset(); + state.mut().voteValues.reset(); state.mut().numberOfEntryAmountSubmitted = 0; state.mut().numberOfProposals = 0; state.mut().numberOfQuRaffleMembers = 0; From b7421a99c6a0ae0b6194b3de449be1636cbae381 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Fri, 24 Apr 2026 20:59:44 +0900 Subject: [PATCH 09/16] QRaffle: cut vote and token-raffle state size; O(1) vote and deposit paths --- src/contracts/QRaffle.h | 44 ++++++++++++++++++++------------------- test/contract_qraffle.cpp | 3 +-- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/contracts/QRaffle.h b/src/contracts/QRaffle.h index 60501ceb8..7d665197f 100644 --- a/src/contracts/QRaffle.h +++ b/src/contracts/QRaffle.h @@ -21,6 +21,7 @@ constexpr uint32 QRAFFLE_MIN_QRAFFLE_AMOUNT = 1000000ull; constexpr uint32 QRAFFLE_MAX_QRAFFLE_AMOUNT = 1000000000ull; constexpr uint32 QRAFFLE_MAX_TOKEN_RAFFLES = 1048576; constexpr uint32 QRAFFLE_MAX_SHAREHOLDERS = 1024; +constexpr uint32 QRAFFLE_TOKEN_RAFFLE_SLOT_SIZE = 512; // 2^9, max members per token raffle constexpr uint8 QRAFFLE_MAX_PROPOSALS_PER_PROPOSER = 3; // max proposals per user per epoch constexpr sint32 QRAFFLE_SUCCESS = 0; @@ -203,9 +204,11 @@ struct QRAFFLE : public ContractBase Array quRaffleMembers; Array activeTokenRaffle; - HashMap , QRAFFLE_MAX_PROPOSAL_EPOCH> tokenRaffleMembers; + // Per-user O(1) duplicate check for token raffle deposits. ~4 MB. + HashMap , QRAFFLE_MAX_MEMBER> tokenRaffleParticipation; + // Flat indexed member storage: raffle i occupies slots [i*SLOT_SIZE .. i*SLOT_SIZE+count). ~2 MB. + Array tokenRaffleMemberSlots; Array numberOfTokenRaffleMembers; - Array tmpTokenRaffleMembers; Array QuRaffles; Array tokenRaffle; @@ -849,7 +852,7 @@ struct QRAFFLE : public ContractBase struct depositInTokenRaffle_locals { ActiveTokenRaffleInfo raffleInfo; - uint32 i; + BitArray participation; uint32 currentMembers; Logger log; }; @@ -891,7 +894,7 @@ struct QRAFFLE : public ContractBase return ; } locals.currentMembers = state.get().numberOfTokenRaffleMembers.get(input.indexOfTokenRaffle); - if (locals.currentMembers >= QRAFFLE_MAX_MEMBER) + if (locals.currentMembers >= QRAFFLE_TOKEN_RAFFLE_SLOT_SIZE) { if (qpi.invocationReward() > 0) { @@ -902,21 +905,18 @@ struct QRAFFLE : public ContractBase LOG_INFO(locals.log); return ; } - // Reject duplicate deposit from the same user. - state.get().tokenRaffleMembers.get(input.indexOfTokenRaffle, state.mut().tmpTokenRaffleMembers); - for (locals.i = 0; locals.i < locals.currentMembers; locals.i++) + // O(1) duplicate check via per-user bitfield. + state.get().tokenRaffleParticipation.get(qpi.invocator(), locals.participation); + if (locals.participation.get(input.indexOfTokenRaffle)) { - if (state.get().tmpTokenRaffleMembers.get(locals.i) == qpi.invocator()) + if (qpi.invocationReward() > 0) { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } - output.returnCode = QRAFFLE_ALREADY_REGISTERED; - locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_alreadyRegistered, 0 }; - LOG_INFO(locals.log); - return ; + qpi.transfer(qpi.invocator(), qpi.invocationReward()); } + output.returnCode = QRAFFLE_ALREADY_REGISTERED; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_alreadyRegistered, 0 }; + LOG_INFO(locals.log); + return ; } locals.raffleInfo = state.get().activeTokenRaffle.get(input.indexOfTokenRaffle); if (qpi.transferShareOwnershipAndPossession(locals.raffleInfo.token.assetName, locals.raffleInfo.token.issuer, qpi.invocator(), qpi.invocator(), locals.raffleInfo.entryAmount, SELF) < 0) @@ -936,9 +936,12 @@ struct QRAFFLE : public ContractBase qpi.transfer(qpi.invocator(), qpi.invocationReward() - QRAFFLE_TRANSFER_SHARE_FEE); } state.mut().epochRevenue += QRAFFLE_TRANSFER_SHARE_FEE; - state.mut().tmpTokenRaffleMembers.set(locals.currentMembers, qpi.invocator()); + + // Store member in flat slot array and mark participation. + state.mut().tokenRaffleMemberSlots.set(input.indexOfTokenRaffle * QRAFFLE_TOKEN_RAFFLE_SLOT_SIZE + locals.currentMembers, qpi.invocator()); state.mut().numberOfTokenRaffleMembers.set(input.indexOfTokenRaffle, locals.currentMembers + 1); - state.mut().tokenRaffleMembers.set(input.indexOfTokenRaffle, state.get().tmpTokenRaffleMembers); + locals.participation.set(input.indexOfTokenRaffle, 1); + state.mut().tokenRaffleParticipation.set(qpi.invocator(), locals.participation); output.returnCode = QRAFFLE_SUCCESS; locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_tokenRaffleDeposited, 0 }; LOG_INFO(locals.log); @@ -1528,8 +1531,7 @@ struct QRAFFLE : public ContractBase locals.raffleSeed = qpi.K12(m256i(locals.baseSeed.u64._0, locals.baseSeed.u64._1, locals.baseSeed.u64._2, locals.baseSeed.u64._3 ^ ((uint64)locals.i + 1ULL))); locals.r = locals.raffleSeed.u64._0; locals.winnerIndex = (uint32)mod(locals.r, state.get().numberOfTokenRaffleMembers.get(locals.i) * 1ull); - state.get().tokenRaffleMembers.get(locals.i, state.mut().tmpTokenRaffleMembers); - locals.winner = state.get().tmpTokenRaffleMembers.get(locals.winnerIndex); + locals.winner = state.get().tokenRaffleMemberSlots.get(locals.i * QRAFFLE_TOKEN_RAFFLE_SLOT_SIZE + locals.winnerIndex); locals.acTokenRaffle = state.get().activeTokenRaffle.get(locals.i); @@ -1686,7 +1688,7 @@ struct QRAFFLE : public ContractBase state.mut().daoMemberCount.set(qpi.epoch(), state.get().numberOfRegisters); state.mut().numberOfVotedInProposal.setAll(0); - state.mut().tokenRaffleMembers.reset(); + state.mut().tokenRaffleParticipation.reset(); state.mut().proposalsPerProposer.reset(); state.mut().quRaffleEntryAmount.reset(); state.mut().shareholdersList.reset(); diff --git a/test/contract_qraffle.cpp b/test/contract_qraffle.cpp index d87f62c48..4d6f9b6be 100644 --- a/test/contract_qraffle.cpp +++ b/test/contract_qraffle.cpp @@ -105,11 +105,10 @@ class QRaffleChecker : public QRAFFLE, public QRAFFLE::StateData void tokenRaffleMemberChecker(uint32 raffleIndex, const id& user, uint32 expectedMembers) { - tokenRaffleMembers.get(raffleIndex, tmpTokenRaffleMembers); bool found = false; for (uint32 i = 0; i < numberOfTokenRaffleMembers.get(raffleIndex); i++) { - if (tmpTokenRaffleMembers.get(i) == user) + if (tokenRaffleMemberSlots.get(raffleIndex * QRAFFLE_TOKEN_RAFFLE_SLOT_SIZE + i) == user) { found = true; break; From 1603dc4a406294a55d436e00a926bbf96d4a5703 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Fri, 24 Apr 2026 22:31:04 +0900 Subject: [PATCH 10/16] QRaffle: O(1) qu-raffle dedup, shrink token-raffle history ring, fix ring queries --- src/contracts/QRaffle.h | 48 +++++++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/src/contracts/QRaffle.h b/src/contracts/QRaffle.h index 7d665197f..af46a1cd5 100644 --- a/src/contracts/QRaffle.h +++ b/src/contracts/QRaffle.h @@ -19,7 +19,9 @@ constexpr uint32 QRAFFLE_MAX_MEMBER = 65536; constexpr uint32 QRAFFLE_DEFAULT_QRAFFLE_AMOUNT = 10000000ull; constexpr uint32 QRAFFLE_MIN_QRAFFLE_AMOUNT = 1000000ull; constexpr uint32 QRAFFLE_MAX_QRAFFLE_AMOUNT = 1000000000ull; -constexpr uint32 QRAFFLE_MAX_TOKEN_RAFFLES = 1048576; +// Ended token-raffle ring: 16 384 slots × ~96 B ≈ 1.5 MB. +// At most QRAFFLE_MAX_PROPOSAL_EPOCH (128) raffles/epoch → covers ~128 epochs of history. +constexpr uint32 QRAFFLE_MAX_TOKEN_RAFFLES = 16384; constexpr uint32 QRAFFLE_MAX_SHAREHOLDERS = 1024; constexpr uint32 QRAFFLE_TOKEN_RAFFLE_SLOT_SIZE = 512; // 2^9, max members per token raffle constexpr uint8 QRAFFLE_MAX_PROPOSALS_PER_PROPOSER = 3; // max proposals per user per epoch @@ -202,6 +204,8 @@ struct QRAFFLE : public ContractBase HashMap , QRAFFLE_MAX_MEMBER> voteValues; // bit=1 for yes, bit=0 for no Array numberOfVotedInProposal; Array quRaffleMembers; + // O(1) duplicate guard for quRaffle entries; mirrors quRaffleMembers for membership tests. + HashSet quRaffleMemberSet; Array activeTokenRaffle; // Per-user O(1) duplicate check for token raffle deposits. ~4 MB. @@ -799,7 +803,6 @@ struct QRAFFLE : public ContractBase struct depositInQuRaffle_locals { - uint32 i; Logger log; }; @@ -827,22 +830,21 @@ struct QRAFFLE : public ContractBase LOG_INFO(locals.log); return ; } - for (locals.i = 0 ; locals.i < state.get().numberOfQuRaffleMembers; locals.i++) + // O(1) duplicate check via HashSet (replaces former O(N) linear scan over quRaffleMembers). + if (state.get().quRaffleMemberSet.contains(qpi.invocator())) { - if (state.get().quRaffleMembers.get(locals.i) == qpi.invocator()) + if (qpi.invocationReward() > 0) { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } - output.returnCode = QRAFFLE_ALREADY_REGISTERED; - locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_alreadyRegistered, 0 }; - LOG_INFO(locals.log); - return ; + qpi.transfer(qpi.invocator(), qpi.invocationReward()); } + output.returnCode = QRAFFLE_ALREADY_REGISTERED; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_alreadyRegistered, 0 }; + LOG_INFO(locals.log); + return ; } qpi.transfer(qpi.invocator(), qpi.invocationReward() - state.get().qREAmount); state.mut().quRaffleMembers.set(state.get().numberOfQuRaffleMembers, qpi.invocator()); + state.mut().quRaffleMemberSet.add(qpi.invocator()); state.mut().numberOfQuRaffleMembers++; output.returnCode = QRAFFLE_SUCCESS; locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_quRaffleDeposited, 0 }; @@ -1172,6 +1174,13 @@ struct QRAFFLE : public ContractBase output.returnCode = QRAFFLE_INVALID_TOKEN_RAFFLE; return ; } + // Reject indices that have been overwritten by the ring buffer. + if (state.get().numberOfEndedTokenRaffle > QRAFFLE_MAX_TOKEN_RAFFLES + && input.indexOfRaffle < state.get().numberOfEndedTokenRaffle - QRAFFLE_MAX_TOKEN_RAFFLES) + { + output.returnCode = QRAFFLE_INVALID_TOKEN_RAFFLE; + return ; + } output.epochWinner = state.get().tokenRaffle.get(input.indexOfRaffle).epochWinner; output.tokenName = state.get().tokenRaffle.get(input.indexOfRaffle).token.assetName; output.tokenIssuer = state.get().tokenRaffle.get(input.indexOfRaffle).token.issuer; @@ -1184,12 +1193,13 @@ struct QRAFFLE : public ContractBase struct getEpochRaffleIndexes_locals { - sint32 i; + uint32 ringStart; + uint32 ringEnd; + uint32 i; }; PUBLIC_FUNCTION_WITH_LOCALS(getEpochRaffleIndexes) { - // Initialise outputs so callers see deterministic values when no raffles match. output.StartIndex = 0; output.EndIndex = 0; if (input.epoch > qpi.epoch()) @@ -1204,7 +1214,10 @@ struct QRAFFLE : public ContractBase output.returnCode = QRAFFLE_SUCCESS; return ; } - for (locals.i = 0; locals.i < (sint32)state.get().numberOfEndedTokenRaffle; locals.i++) + // Only scan the valid ring window to avoid re-reading overwritten slots. + locals.ringEnd = state.get().numberOfEndedTokenRaffle; + locals.ringStart = (locals.ringEnd > QRAFFLE_MAX_TOKEN_RAFFLES) ? (locals.ringEnd - QRAFFLE_MAX_TOKEN_RAFFLES) : 0; + for (locals.i = locals.ringStart; locals.i < locals.ringEnd; locals.i++) { if (state.get().tokenRaffle.get(locals.i).epoch == input.epoch) { @@ -1212,8 +1225,10 @@ struct QRAFFLE : public ContractBase break; } } - for (locals.i = (sint32)state.get().numberOfEndedTokenRaffle - 1; locals.i >= 0; locals.i--) + locals.i = locals.ringEnd; + while (locals.i > locals.ringStart) { + locals.i--; if (state.get().tokenRaffle.get(locals.i).epoch == input.epoch) { output.EndIndex = locals.i; @@ -1697,6 +1712,7 @@ struct QRAFFLE : public ContractBase state.mut().numberOfEntryAmountSubmitted = 0; state.mut().numberOfProposals = 0; state.mut().numberOfQuRaffleMembers = 0; + state.mut().quRaffleMemberSet.reset(); if (state.get().registers.needsCleanup()) { state.mut().registers.cleanup(); } } From 3144fcd4bb4dcb49ebe8e8e0da86eef7081e4d76 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Fri, 1 May 2026 00:14:23 +0900 Subject: [PATCH 11/16] feat: add assets raffles --- src/contracts/QRaffle.h | 1097 ++++++++++++++++++++++- test/contract_qraffle.cpp | 1721 +++++++++++++++++++++++++++++++++++++ 2 files changed, 2788 insertions(+), 30 deletions(-) diff --git a/src/contracts/QRaffle.h b/src/contracts/QRaffle.h index af46a1cd5..096dd732f 100644 --- a/src/contracts/QRaffle.h +++ b/src/contracts/QRaffle.h @@ -19,12 +19,10 @@ constexpr uint32 QRAFFLE_MAX_MEMBER = 65536; constexpr uint32 QRAFFLE_DEFAULT_QRAFFLE_AMOUNT = 10000000ull; constexpr uint32 QRAFFLE_MIN_QRAFFLE_AMOUNT = 1000000ull; constexpr uint32 QRAFFLE_MAX_QRAFFLE_AMOUNT = 1000000000ull; -// Ended token-raffle ring: 16 384 slots × ~96 B ≈ 1.5 MB. -// At most QRAFFLE_MAX_PROPOSAL_EPOCH (128) raffles/epoch → covers ~128 epochs of history. -constexpr uint32 QRAFFLE_MAX_TOKEN_RAFFLES = 16384; +constexpr uint32 QRAFFLE_MAX_TOKEN_RAFFLES = 16384; // ring buffer: 16384 × ~96 B ≈ 1.5 MB, covers ~128 epochs constexpr uint32 QRAFFLE_MAX_SHAREHOLDERS = 1024; -constexpr uint32 QRAFFLE_TOKEN_RAFFLE_SLOT_SIZE = 512; // 2^9, max members per token raffle -constexpr uint8 QRAFFLE_MAX_PROPOSALS_PER_PROPOSER = 3; // max proposals per user per epoch +constexpr uint32 QRAFFLE_TOKEN_RAFFLE_SLOT_SIZE = 512; // flat stride per token raffle; must be power of 2 +constexpr uint8 QRAFFLE_MAX_PROPOSALS_PER_PROPOSER = 3; // per epoch constexpr sint32 QRAFFLE_SUCCESS = 0; constexpr sint32 QRAFFLE_INSUFFICIENT_FUND = 1; @@ -47,6 +45,31 @@ constexpr sint32 QRAFFLE_EMPTY_QU_RAFFLE = 17; constexpr sint32 QRAFFLE_EMPTY_TOKEN_RAFFLE = 18; constexpr sint32 QRAFFLE_MAX_PROPOSAL_PER_USER_REACHED = 19; +// Asset Raffle return codes +constexpr sint32 QRAFFLE_INVALID_BUNDLE = 20; +constexpr sint32 QRAFFLE_INVALID_RESERVE_PRICE = 21; +constexpr sint32 QRAFFLE_BUNDLE_ESCROW_FAILED = 22; +constexpr sint32 QRAFFLE_INVALID_ASSET_RAFFLE = 23; +constexpr sint32 QRAFFLE_ASSET_RAFFLE_FULL = 24; +constexpr sint32 QRAFFLE_TICKET_LIMIT_REACHED = 25; +constexpr sint32 QRAFFLE_MAX_ASSET_RAFFLES_REACHED = 26; +constexpr sint32 QRAFFLE_CANCEL_NOT_ALLOWED = 27; + +// Asset Raffle configuration +constexpr uint64 QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE = 500000ull; // 500K Qu; non-refundable anti-spam +constexpr uint32 QRAFFLE_ASSET_RAFFLE_CREATOR_PCT = 80; // creator's share of Qu pool (%) +constexpr uint32 QRAFFLE_MAX_ASSET_RAFFLES_PER_EPOCH = 64; // concurrent active raffles +constexpr uint32 QRAFFLE_MAX_ASSETS_PER_BUNDLE = 4; // items per bundle +constexpr uint32 QRAFFLE_MAX_ASSET_TICKET_BUYERS = 1024; // distinct buyers per raffle +constexpr uint32 QRAFFLE_MAX_TICKETS_PER_BUYER = 100; // per-buyer cap (anti-griefing) +constexpr uint32 QRAFFLE_MAX_ASSET_RAFFLES_PER_CREATOR = 2; // per creator per epoch +constexpr uint32 QRAFFLE_MAX_ENDED_ASSET_RAFFLES = 8192; // history ring buffer +constexpr uint64 QRAFFLE_MIN_ASSET_TICKET_AMOUNT = 1000000ull; // 1M Qu +constexpr uint64 QRAFFLE_MAX_ASSET_TICKET_AMOUNT = 1000000000000ull;// 1T Qu +// Flat array strides: raffle i occupies [i*stride .. i*stride+count) +constexpr uint32 QRAFFLE_ASSET_RAFFLE_BUNDLE_FLAT_SIZE = QRAFFLE_MAX_ASSET_RAFFLES_PER_EPOCH * QRAFFLE_MAX_ASSETS_PER_BUNDLE; // 512 +constexpr uint32 QRAFFLE_ASSET_RAFFLE_BUYERS_FLAT_SIZE = QRAFFLE_MAX_ASSET_RAFFLES_PER_EPOCH * QRAFFLE_MAX_ASSET_TICKET_BUYERS; // 65536 + struct QRAFFLE2 { }; @@ -88,7 +111,21 @@ struct QRAFFLE : public ContractBase QRAFFLE_shareManagementRightsTransferred = 30, QRAFFLE_emptyQuRaffle = 31, QRAFFLE_emptyTokenRaffle = 32, - QRAFFLE_maxProposalPerUserReached = 33 + QRAFFLE_maxProposalPerUserReached = 33, + // Asset Raffle log types + QRAFFLE_assetRaffleCreated = 34, + QRAFFLE_assetRaffleTicketBought = 35, + QRAFFLE_assetRaffleSucceeded = 36, + QRAFFLE_assetRaffleRefunded = 37, + QRAFFLE_assetRaffleBundleEscrowFailed = 38, + QRAFFLE_assetRaffleBundleDeliveryFailed = 39, + QRAFFLE_assetRaffleCancelled = 40, + QRAFFLE_invalidBundle = 41, + QRAFFLE_invalidReservePrice = 42, + QRAFFLE_assetRaffleFull = 43, + QRAFFLE_ticketLimitReached = 44, + QRAFFLE_maxAssetRafflesReached = 45, + QRAFFLE_cancelNotAllowed = 46 }; struct Logger @@ -161,6 +198,80 @@ struct QRAFFLE : public ContractBase sint8 _terminator; }; + struct AssetRaffleCreatedLogger + { + uint32 _contractIndex; + uint32 _type; + uint32 _raffleIndex; + id _creator; + uint64 _reservePriceQu; + uint64 _entryTicketQu; + uint32 _bundleSize; + sint8 _terminator; + }; + + struct AssetRaffleTicketLogger + { + uint32 _contractIndex; + uint32 _type; + uint32 _raffleIndex; + id _buyer; + uint32 _tickets; + uint64 _cost; + sint8 _terminator; + }; + + struct AssetRaffleEndedLogger + { + uint32 _contractIndex; + uint32 _type; + uint32 _raffleIndex; + id _creator; + id _winner; + uint64 _grossPoolQu; + uint64 _creatorPaidQu; + uint8 _reserveMet; + sint8 _terminator; + }; + + // One item in an asset raffle bundle (token or SC share + quantity). + struct AssetRaffleItem + { + Asset asset; + sint64 numberOfShares; + }; + + // Active asset raffle state (live during the epoch it was created). + struct AssetRaffleInfo + { + id creator; + uint64 reservePriceQu; // net Qu creator wants AFTER 20% fee + uint64 entryTicketQu; // Qu per ticket + uint64 totalTicketsPaidQu; // gross Qu pool so far + uint32 numberOfBuyers; + uint32 totalTickets; + uint32 bundleSize; + uint32 epoch; + }; + + // Historical record written at END_EPOCH for each settled asset raffle. + // Field order keeps the largest types first so trailing padding is minimal and + // deterministic across compilers (no explicit pad needed → no plain C arrays). + struct EndedAssetRaffleInfo + { + id creator; + id epochWinner; // NULL_ID if reserve was missed + uint64 reservePriceQu; + uint64 entryTicketQu; + uint64 grossPoolQu; + uint64 creatorPaidQu; // 0 if reserve missed + uint32 totalTickets; + uint32 numberOfBuyers; + uint32 bundleSize; + uint32 epoch; + uint32 reserveMet; // 1 = reserve met and winner paid; 0 = refunded (uint32 keeps natural alignment) + }; + struct ProposalInfo { Asset token; id proposer; @@ -198,19 +309,19 @@ struct QRAFFLE : public ContractBase HashMap registers; Array proposals; - // Per-user vote tracking with dual BitArray (qRWA pattern). - // O(1) lookup via id hash, 1 bit per proposal. ~4 MB each, ~8 MB total. - HashMap , QRAFFLE_MAX_MEMBER> voteParticipation; // bit=1 if user has voted - HashMap , QRAFFLE_MAX_MEMBER> voteValues; // bit=1 for yes, bit=0 for no + // Dual-bitfield vote store: O(1) has-voted check + direction, ~4 MB each. + // bit[i]=1 in voteParticipation → user voted on proposal i; bit[i] in voteValues → direction (1=yes). + HashMap , QRAFFLE_MAX_MEMBER> voteParticipation; + HashMap , QRAFFLE_MAX_MEMBER> voteValues; Array numberOfVotedInProposal; Array quRaffleMembers; - // O(1) duplicate guard for quRaffle entries; mirrors quRaffleMembers for membership tests. + // Shadow set for O(1) duplicate check; kept in sync with quRaffleMembers. HashSet quRaffleMemberSet; Array activeTokenRaffle; - // Per-user O(1) duplicate check for token raffle deposits. ~4 MB. + // O(1) has-deposited check per user per raffle. ~4 MB. HashMap , QRAFFLE_MAX_MEMBER> tokenRaffleParticipation; - // Flat indexed member storage: raffle i occupies slots [i*SLOT_SIZE .. i*SLOT_SIZE+count). ~2 MB. + // Flat member list: raffle i occupies [i*SLOT_SIZE .. i*SLOT_SIZE+count). ~2 MB. Array tokenRaffleMemberSlots; Array numberOfTokenRaffleMembers; @@ -225,6 +336,44 @@ struct QRAFFLE : public ContractBase uint32 numberOfRegisters, numberOfQuRaffleMembers, numberOfEntryAmountSubmitted, numberOfProposals, numberOfActiveTokenRaffle, numberOfEndedTokenRaffle; Array daoMemberCount; // Number of DAO members (registers) at each epoch HashMap proposalsPerProposer; + + // ── Asset Raffle state ──────────────────────────────────────────────────────── + Array activeAssetRaffles; + uint32 numberOfActiveAssetRaffles; + + // Bundle items: raffle i occupies [i*8 .. i*8+bundleSize). + Array activeAssetRaffleItems; + + // Buyer lists: raffle i occupies [i*1024 .. i*1024+numberOfBuyers). + Array activeAssetRaffleBuyers; + Array activeAssetRaffleBuyerTickets; + + // O(1) has-bought check: bit[i]=1 means this user has tickets in raffle i. + HashMap, QRAFFLE_MAX_MEMBER> assetRaffleParticipation; + + // O(1) slot lookup: entry[i] = buyer's 0-based position in raffle i's buyer region. + // Sentinel 0xFFFF = not present. ~8 MB (64 raffles × 2 B × 65536 buyers). + // Reset each epoch alongside the buyer arrays. + HashMap, QRAFFLE_MAX_MEMBER> assetRaffleBuyerSlotIndex; + + // Per-creator raffle count; reset each epoch to enforce QRAFFLE_MAX_ASSET_RAFFLES_PER_CREATOR. + HashMap assetRafflesPerCreator; + + // Settled raffle history ring buffer. + Array endedAssetRaffles; + uint32 numberOfEndedAssetRaffles; + + // Accumulated proposal fees destined for DAO registers (50% of each 500K fee); + // distributed in one O(R) pass at END_EPOCH alongside the register share bucket. + uint64 epochAssetRaffleDaoBucket; + + // Aggregate analytics (monotonically increasing). + uint64 totalAssetRaffleProposalFees; + uint64 totalAssetRaffleCreatorPaid; + uint64 totalAssetRaffleRefunded; + uint32 totalAssetRafflesCreated; + uint32 totalAssetRafflesSucceeded; + uint32 totalAssetRafflesFailed; }; struct registerInSystem_input @@ -443,6 +592,128 @@ struct QRAFFLE : public ContractBase sint32 returnCode; }; + // ── Asset Raffle I/O structs ─────────────────────────────────────────────── + + struct createAssetRaffle_input + { + // Bundle: fixed-capacity QPI Array; only [0..bundleSize) are used. + Array bundleItems; + uint32 bundleSize; + uint64 reservePriceQu; // min Qu creator wants AFTER 20% service fee + uint64 entryTicketQu; // Qu per ticket + }; + + struct createAssetRaffle_output + { + uint32 raffleIndex; + sint32 returnCode; + }; + + struct buyAssetRaffleTicket_input + { + uint32 indexOfAssetRaffle; + uint32 numberOfTickets; + }; + + struct buyAssetRaffleTicket_output + { + uint32 ticketsBought; + sint32 returnCode; + }; + + struct cancelAssetRaffle_input + { + uint32 indexOfAssetRaffle; + }; + + struct cancelAssetRaffle_output + { + sint32 returnCode; + }; + + struct getActiveAssetRaffle_input + { + uint32 indexOfAssetRaffle; + }; + + struct getActiveAssetRaffle_output + { + id creator; + uint64 reservePriceQu; + uint64 entryTicketQu; + uint64 totalTicketsPaidQu; + uint32 numberOfBuyers; + uint32 totalTickets; + uint32 bundleSize; + uint32 epoch; + sint32 returnCode; + }; + + struct getActiveAssetRaffleBundleItem_input + { + uint32 indexOfAssetRaffle; + uint32 itemIndex; + }; + + struct getActiveAssetRaffleBundleItem_output + { + id assetIssuer; + uint64 assetName; + sint64 numberOfShares; + sint32 returnCode; + }; + + struct getActiveAssetRaffleBuyer_input + { + uint32 indexOfAssetRaffle; + uint32 buyerIndex; + }; + + struct getActiveAssetRaffleBuyer_output + { + id buyer; + uint32 ticketCount; + sint32 returnCode; + }; + + struct getEndedAssetRaffle_input + { + uint32 indexOfRaffle; + }; + + struct getEndedAssetRaffle_output + { + id creator; + id epochWinner; + uint64 reservePriceQu; + uint64 entryTicketQu; + uint64 grossPoolQu; + uint64 creatorPaidQu; + uint32 totalTickets; + uint32 numberOfBuyers; + uint32 bundleSize; + uint32 epoch; + uint8 reserveMet; + sint32 returnCode; + }; + + struct getAssetRaffleAnalytics_input + { + }; + + struct getAssetRaffleAnalytics_output + { + uint64 totalAssetRaffleProposalFees; + uint64 totalAssetRaffleCreatorPaid; + uint64 totalAssetRaffleRefunded; + uint32 numberOfActiveAssetRaffles; + uint32 numberOfEndedAssetRaffles; + uint32 totalAssetRafflesCreated; + uint32 totalAssetRafflesSucceeded; + uint32 totalAssetRafflesFailed; + sint32 returnCode; + }; + protected: @@ -736,11 +1007,10 @@ struct QRAFFLE : public ContractBase } locals.proposal = state.get().proposals.get(input.indexOfProposal); - // O(1) vote lookup via per-user bitfield (id hash, no K12 overhead). state.get().voteParticipation.get(qpi.invocator(), locals.participation); if (locals.participation.get(input.indexOfProposal)) { - // Already voted — check if same direction. + // User voted before — reject if same direction, flip counters if different. state.get().voteValues.get(qpi.invocator(), locals.values); if (locals.values.get(input.indexOfProposal) == input.yes) { @@ -749,7 +1019,7 @@ struct QRAFFLE : public ContractBase LOG_INFO(locals.log); return ; } - // Flip the vote: update counters with underflow guard. + // nYes/nNo are uint32; decrement is guarded to prevent underflow on corrupt state. if (input.yes) { locals.proposal.nYes++; @@ -769,7 +1039,6 @@ struct QRAFFLE : public ContractBase return ; } - // New vote: check capacity before inserting. locals.votedCount = state.get().numberOfVotedInProposal.get(input.indexOfProposal); if (locals.votedCount >= QRAFFLE_MAX_MEMBER) { @@ -788,7 +1057,6 @@ struct QRAFFLE : public ContractBase } state.mut().proposals.set(input.indexOfProposal, locals.proposal); - // Mark participation and record vote direction. locals.participation.set(input.indexOfProposal, 1); state.mut().voteParticipation.set(qpi.invocator(), locals.participation); state.get().voteValues.get(qpi.invocator(), locals.values); @@ -830,7 +1098,6 @@ struct QRAFFLE : public ContractBase LOG_INFO(locals.log); return ; } - // O(1) duplicate check via HashSet (replaces former O(N) linear scan over quRaffleMembers). if (state.get().quRaffleMemberSet.contains(qpi.invocator())) { if (qpi.invocationReward() > 0) @@ -872,7 +1139,6 @@ struct QRAFFLE : public ContractBase LOG_INFO(locals.log); return ; } - // Only registered members may deposit. if (state.get().registers.contains(qpi.invocator()) == 0) { if (qpi.invocationReward() > 0) @@ -907,7 +1173,6 @@ struct QRAFFLE : public ContractBase LOG_INFO(locals.log); return ; } - // O(1) duplicate check via per-user bitfield. state.get().tokenRaffleParticipation.get(qpi.invocator(), locals.participation); if (locals.participation.get(input.indexOfTokenRaffle)) { @@ -932,14 +1197,13 @@ struct QRAFFLE : public ContractBase LOG_INFO(locals.log); return ; } - // Keep QRAFFLE_TRANSFER_SHARE_FEE as service revenue; refund any excess. if (qpi.invocationReward() > QRAFFLE_TRANSFER_SHARE_FEE) { qpi.transfer(qpi.invocator(), qpi.invocationReward() - QRAFFLE_TRANSFER_SHARE_FEE); } state.mut().epochRevenue += QRAFFLE_TRANSFER_SHARE_FEE; - // Store member in flat slot array and mark participation. + // Store member in flat slot array and mark participation bitfield. state.mut().tokenRaffleMemberSlots.set(input.indexOfTokenRaffle * QRAFFLE_TOKEN_RAFFLE_SLOT_SIZE + locals.currentMembers, qpi.invocator()); state.mut().numberOfTokenRaffleMembers.set(input.indexOfTokenRaffle, locals.currentMembers + 1); locals.participation.set(input.indexOfTokenRaffle, 1); @@ -959,8 +1223,8 @@ struct QRAFFLE : public ContractBase PUBLIC_PROCEDURE_WITH_LOCALS(TransferShareManagementRights) { - // Requires QRAFFLE_TRANSFER_SHARE_FEE minimum. The rest is offered to the destination - // contract as transfer fee; the unused portion is refunded after releaseShares. + // Minimum fee covers service cost. Any amount above that is passed to releaseShares + // as the offered transfer fee; the destination keeps what it needs, the rest is refunded. if (qpi.invocationReward() < QRAFFLE_TRANSFER_SHARE_FEE) { if (qpi.invocationReward() > 0) @@ -974,8 +1238,7 @@ struct QRAFFLE : public ContractBase if (qpi.numberOfPossessedShares(input.tokenName, input.tokenIssuer,qpi.invocator(), qpi.invocator(), SELF_INDEX, SELF_INDEX) < input.numberOfShares) { - // Not enough shares — refund in full. - output.transferredNumberOfShares = 0; + output.transferredNumberOfShares = 0; locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_notEnoughShares, 0 }; LOG_INFO(locals.log); if (qpi.invocationReward() > 0) @@ -992,7 +1255,6 @@ struct QRAFFLE : public ContractBase input.newManagingContractIndex, input.newManagingContractIndex, locals.offeredFee); if (locals.paidFee < 0) { - // Transfer rejected by the destination — refund everything. output.transferredNumberOfShares = 0; locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_transferFailed, 0 }; LOG_INFO(locals.log); @@ -1003,17 +1265,447 @@ struct QRAFFLE : public ContractBase } else { - // Success — keep service fee as revenue, refund unused transfer fee. + // Keep base service fee; refund whatever the destination didn't consume. output.transferredNumberOfShares = input.numberOfShares; state.mut().epochRevenue += QRAFFLE_TRANSFER_SHARE_FEE; if (locals.offeredFee > locals.paidFee) { qpi.transfer(qpi.invocator(), locals.offeredFee - locals.paidFee); } - locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_shareManagementRightsTransferred, 0 }; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_shareManagementRightsTransferred, 0 }; + LOG_INFO(locals.log); + } + } + } + + // ── createAssetRaffle ────────────────────────────────────────────────────── + struct createAssetRaffle_locals + { + AssetRaffleItem item; + AssetRaffleItem dupItem; + AssetRaffleItem rollbackItem; + AssetRaffleInfo info; + AssetRaffleCreatedLogger clog; + Logger log; + uint64 proposalFeeHalf; + uint8 creatorCount; + uint32 slot; + uint32 i; + uint32 j; + sint64 escrowResult; + bit dupFound; + }; + + PUBLIC_PROCEDURE_WITH_LOCALS(createAssetRaffle) + { + if (qpi.invocationReward() < (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE) + { + if (qpi.invocationReward() > 0) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); } + output.returnCode = QRAFFLE_INSUFFICIENT_FUND; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_insufficientQubic, 0 }; + LOG_INFO(locals.log); + return; + } + if (!state.get().registers.contains(qpi.invocator())) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QRAFFLE_UNREGISTERED; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_unregistered, 0 }; + LOG_INFO(locals.log); + return; + } + locals.creatorCount = 0; + if (state.get().assetRafflesPerCreator.contains(qpi.invocator())) + { + state.get().assetRafflesPerCreator.get(qpi.invocator(), locals.creatorCount); + } + if (locals.creatorCount >= QRAFFLE_MAX_ASSET_RAFFLES_PER_CREATOR) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QRAFFLE_MAX_ASSET_RAFFLES_REACHED; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_maxAssetRafflesReached, 0 }; + LOG_INFO(locals.log); + return; + } + if (state.get().numberOfActiveAssetRaffles >= QRAFFLE_MAX_ASSET_RAFFLES_PER_EPOCH) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QRAFFLE_MAX_ASSET_RAFFLES_REACHED; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_maxAssetRafflesReached, 0 }; + LOG_INFO(locals.log); + return; + } + if (input.bundleSize == 0 || input.bundleSize > QRAFFLE_MAX_ASSETS_PER_BUNDLE) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QRAFFLE_INVALID_BUNDLE; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_invalidBundle, 0 }; + LOG_INFO(locals.log); + return; + } + if (input.entryTicketQu < QRAFFLE_MIN_ASSET_TICKET_AMOUNT || input.entryTicketQu > QRAFFLE_MAX_ASSET_TICKET_AMOUNT) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QRAFFLE_INVALID_ENTRY_AMOUNT; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_invalidEntryAmount, 0 }; + LOG_INFO(locals.log); + return; + } + if (input.reservePriceQu == 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QRAFFLE_INVALID_RESERVE_PRICE; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_invalidReservePrice, 0 }; + LOG_INFO(locals.log); + return; + } + // Guard against uint64 overflow in the END_EPOCH reserve check (reservePriceQu * 100). + if (input.reservePriceQu > div(0xFFFFFFFFFFFFFFFFull, 100ull)) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QRAFFLE_INVALID_RESERVE_PRICE; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_invalidReservePrice, 0 }; + LOG_INFO(locals.log); + return; + } + for (locals.i = 0; locals.i < input.bundleSize; locals.i++) + { + locals.item = input.bundleItems.get(locals.i); + if (!qpi.isAssetIssued(locals.item.asset.issuer, locals.item.asset.assetName)) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QRAFFLE_INVALID_BUNDLE; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_invalidBundle, 0 }; + LOG_INFO(locals.log); + return; + } + if (locals.item.numberOfShares <= 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QRAFFLE_INVALID_BUNDLE; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_invalidBundle, 0 }; + LOG_INFO(locals.log); + return; + } + // QRAFFLE and QXMR are reserved for dividends/registration; disallow in bundles. + if ((locals.item.asset.assetName == QRAFFLE_ASSET_NAME && locals.item.asset.issuer == NULL_ID) + || (locals.item.asset.assetName == QRAFFLE_QXMR_ASSET_NAME && locals.item.asset.issuer == state.get().QXMRIssuer)) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QRAFFLE_INVALID_BUNDLE; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_invalidBundle, 0 }; + LOG_INFO(locals.log); + return; + } + // Duplicate-asset check within the bundle; O(N²) acceptable for N ≤ 8. + locals.dupFound = 0; + for (locals.j = 0; locals.j < locals.i; locals.j++) + { + locals.dupItem = input.bundleItems.get(locals.j); + if (locals.dupItem.asset.assetName == locals.item.asset.assetName + && locals.dupItem.asset.issuer == locals.item.asset.issuer) + { + locals.dupFound = 1; + break; + } + } + if (locals.dupFound) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QRAFFLE_INVALID_BUNDLE; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_invalidBundle, 0 }; + LOG_INFO(locals.log); + return; + } + } + + // Atomic escrow: if any item fails, roll back all previously transferred items. + locals.slot = state.get().numberOfActiveAssetRaffles; + for (locals.i = 0; locals.i < input.bundleSize; locals.i++) + { + locals.item = input.bundleItems.get(locals.i); + locals.escrowResult = qpi.transferShareOwnershipAndPossession( + locals.item.asset.assetName, locals.item.asset.issuer, + qpi.invocator(), qpi.invocator(), + locals.item.numberOfShares, SELF); + if (locals.escrowResult < 0) + { + // Rollback: return all previously escrowed items to the invocator. + // These transfers move shares we just received from the same invocator back to + // them, so they should not fail in normal circumstances. If a rollback transfer + // does fail (e.g. spectrum entry evicted), residual shares stay under contract + // management for governance recovery. + for (locals.j = 0; locals.j < locals.i; locals.j++) + { + locals.rollbackItem = input.bundleItems.get(locals.j); + qpi.transferShareOwnershipAndPossession( + locals.rollbackItem.asset.assetName, locals.rollbackItem.asset.issuer, + SELF, SELF, + locals.rollbackItem.numberOfShares, qpi.invocator()); + } + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = QRAFFLE_BUNDLE_ESCROW_FAILED; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_assetRaffleBundleEscrowFailed, 0 }; + LOG_INFO(locals.log); + return; + } + state.mut().activeAssetRaffleItems.set(locals.slot * QRAFFLE_MAX_ASSETS_PER_BUNDLE + locals.i, locals.item); + } + + locals.info.creator = qpi.invocator(); + locals.info.reservePriceQu = input.reservePriceQu; + locals.info.entryTicketQu = input.entryTicketQu; + locals.info.totalTicketsPaidQu = 0; + locals.info.numberOfBuyers = 0; + locals.info.totalTickets = 0; + locals.info.bundleSize = input.bundleSize; + locals.info.epoch = qpi.epoch(); + state.mut().activeAssetRaffles.set(locals.slot, locals.info); + state.mut().numberOfActiveAssetRaffles++; + state.mut().assetRafflesPerCreator.set(qpi.invocator(), locals.creatorCount + 1); + + // 50% of proposal fee → shareholders via epochRevenue; 50% → DAO bucket distributed in END_EPOCH. + locals.proposalFeeHalf = div(QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE, 2); + state.mut().epochRevenue += locals.proposalFeeHalf; + state.mut().epochAssetRaffleDaoBucket += (QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE - locals.proposalFeeHalf); + state.mut().totalAssetRaffleProposalFees += QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE; + state.mut().totalAssetRafflesCreated++; + + if (qpi.invocationReward() > (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward() - (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE); + } + + output.raffleIndex = locals.slot; + output.returnCode = QRAFFLE_SUCCESS; + locals.clog = AssetRaffleCreatedLogger{ + QRAFFLE_CONTRACT_INDEX, QRAFFLE_assetRaffleCreated, + locals.slot, qpi.invocator(), + input.reservePriceQu, input.entryTicketQu, + input.bundleSize, 0 + }; + LOG_INFO(locals.clog); + } + + // ── buyAssetRaffleTicket ─────────────────────────────────────────────────── + struct buyAssetRaffleTicket_locals + { + AssetRaffleInfo info; + AssetRaffleTicketLogger tlog; + Logger log; + BitArray participation; + Array slotIndexArr; + uint64 cost; + uint32 baseSlot; + uint32 existingTickets; + uint32 buyerSlot; + bit isNewBuyer; + }; + + PUBLIC_PROCEDURE_WITH_LOCALS(buyAssetRaffleTicket) + { + if (input.indexOfAssetRaffle >= state.get().numberOfActiveAssetRaffles) + { + if (qpi.invocationReward() > 0) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); } + output.returnCode = QRAFFLE_INVALID_ASSET_RAFFLE; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_invalidTokenRaffle, 0 }; + LOG_INFO(locals.log); + return; + } + if (input.numberOfTickets == 0 || input.numberOfTickets > QRAFFLE_MAX_TICKETS_PER_BUYER) + { + // Reject zero or absurd ticket counts up-front so the cost multiplication below cannot overflow. + if (qpi.invocationReward() > 0) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); } + output.returnCode = QRAFFLE_INVALID_ENTRY_AMOUNT; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_invalidEntryAmount, 0 }; + LOG_INFO(locals.log); + return; + } + locals.info = state.get().activeAssetRaffles.get(input.indexOfAssetRaffle); + // Overflow-safe cost: entryTicketQu ≤ 1e12 (QRAFFLE_MAX_ASSET_TICKET_AMOUNT) and tickets ≤ 100, + // so cost ≤ 1e14, safely under uint64 max (~1.8e19). totalTicketsPaidQu accumulates across + // up to 1024 buyers, max ~1e17, also safe. + locals.cost = locals.info.entryTicketQu * (uint64)input.numberOfTickets; + if (qpi.invocationReward() < (sint64)locals.cost) + { + if (qpi.invocationReward() > 0) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); } + output.returnCode = QRAFFLE_INSUFFICIENT_FUND; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_insufficientQubic, 0 }; + LOG_INFO(locals.log); + return; + } + + locals.baseSlot = input.indexOfAssetRaffle * QRAFFLE_MAX_ASSET_TICKET_BUYERS; + locals.existingTickets = 0; + locals.isNewBuyer = 1; + locals.buyerSlot = locals.baseSlot + locals.info.numberOfBuyers; + + state.get().assetRaffleParticipation.get(qpi.invocator(), locals.participation); + if (locals.participation.get(input.indexOfAssetRaffle)) + { + // Returning buyer: resolve slot via index map (O(1)). + locals.isNewBuyer = 0; + state.get().assetRaffleBuyerSlotIndex.get(qpi.invocator(), locals.slotIndexArr); + locals.buyerSlot = locals.baseSlot + (uint32)locals.slotIndexArr.get(input.indexOfAssetRaffle); + locals.existingTickets = state.get().activeAssetRaffleBuyerTickets.get(locals.buyerSlot); + } + + if (locals.existingTickets + input.numberOfTickets > QRAFFLE_MAX_TICKETS_PER_BUYER) + { + if (qpi.invocationReward() > 0) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); } + output.returnCode = QRAFFLE_TICKET_LIMIT_REACHED; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_ticketLimitReached, 0 }; + LOG_INFO(locals.log); + return; + } + + if (locals.isNewBuyer) + { + if (locals.info.numberOfBuyers >= QRAFFLE_MAX_ASSET_TICKET_BUYERS) + { + if (qpi.invocationReward() > 0) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); } + output.returnCode = QRAFFLE_ASSET_RAFFLE_FULL; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_assetRaffleFull, 0 }; LOG_INFO(locals.log); + return; + } + state.mut().activeAssetRaffleBuyers.set(locals.buyerSlot, qpi.invocator()); + // Record slot position so future purchases skip the buyer-list scan. + state.get().assetRaffleBuyerSlotIndex.get(qpi.invocator(), locals.slotIndexArr); + locals.slotIndexArr.set(input.indexOfAssetRaffle, (uint16)locals.info.numberOfBuyers); + state.mut().assetRaffleBuyerSlotIndex.set(qpi.invocator(), locals.slotIndexArr); + locals.participation.set(input.indexOfAssetRaffle, 1); + state.mut().assetRaffleParticipation.set(qpi.invocator(), locals.participation); + locals.info.numberOfBuyers++; + } + + state.mut().activeAssetRaffleBuyerTickets.set(locals.buyerSlot, locals.existingTickets + input.numberOfTickets); + locals.info.totalTickets += input.numberOfTickets; + locals.info.totalTicketsPaidQu += locals.cost; + state.mut().activeAssetRaffles.set(input.indexOfAssetRaffle, locals.info); + + if (qpi.invocationReward() > (sint64)locals.cost) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward() - (sint64)locals.cost); + } + + output.ticketsBought = input.numberOfTickets; + output.returnCode = QRAFFLE_SUCCESS; + locals.tlog = AssetRaffleTicketLogger{ + QRAFFLE_CONTRACT_INDEX, QRAFFLE_assetRaffleTicketBought, + input.indexOfAssetRaffle, qpi.invocator(), + input.numberOfTickets, locals.cost, 0 + }; + LOG_INFO(locals.tlog); + } + + // ── cancelAssetRaffle ────────────────────────────────────────────────────── + struct cancelAssetRaffle_locals + { + AssetRaffleInfo info; + AssetRaffleItem item; + AssetRaffleInfo lastInfo; + AssetRaffleItem lastItem; + Array slotIndexArr; + BitArray participation; + Logger log; + id movedBuyer; + uint8 creatorCount; + uint32 lastSlot; + uint32 i; + }; + + PUBLIC_PROCEDURE_WITH_LOCALS(cancelAssetRaffle) + { + if (qpi.invocationReward() > 0) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); } + + if (input.indexOfAssetRaffle >= state.get().numberOfActiveAssetRaffles) + { + output.returnCode = QRAFFLE_INVALID_ASSET_RAFFLE; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_invalidTokenRaffle, 0 }; + LOG_INFO(locals.log); + return; + } + locals.info = state.get().activeAssetRaffles.get(input.indexOfAssetRaffle); + if (locals.info.creator != qpi.invocator()) + { + output.returnCode = QRAFFLE_CANCEL_NOT_ALLOWED; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_cancelNotAllowed, 0 }; + LOG_INFO(locals.log); + return; + } + if (locals.info.numberOfBuyers > 0) + { + output.returnCode = QRAFFLE_CANCEL_NOT_ALLOWED; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_cancelNotAllowed, 0 }; + LOG_INFO(locals.log); + return; + } + + for (locals.i = 0; locals.i < locals.info.bundleSize; locals.i++) + { + locals.item = state.get().activeAssetRaffleItems.get(input.indexOfAssetRaffle * QRAFFLE_MAX_ASSETS_PER_BUNDLE + locals.i); + qpi.transferShareOwnershipAndPossession( + locals.item.asset.assetName, locals.item.asset.issuer, + SELF, SELF, + locals.item.numberOfShares, qpi.invocator()); + } + + // Swap-and-pop: fill the vacated slot with the last active raffle. + // Three parallel arrays must be kept in sync: raffle info, bundle items, and buyer lists. + locals.lastSlot = state.get().numberOfActiveAssetRaffles - 1; + if (input.indexOfAssetRaffle < locals.lastSlot) + { + locals.lastInfo = state.get().activeAssetRaffles.get(locals.lastSlot); + state.mut().activeAssetRaffles.set(input.indexOfAssetRaffle, locals.lastInfo); + + for (locals.i = 0; locals.i < locals.lastInfo.bundleSize; locals.i++) + { + locals.lastItem = state.get().activeAssetRaffleItems.get(locals.lastSlot * QRAFFLE_MAX_ASSETS_PER_BUNDLE + locals.i); + state.mut().activeAssetRaffleItems.set(input.indexOfAssetRaffle * QRAFFLE_MAX_ASSETS_PER_BUNDLE + locals.i, locals.lastItem); + } + + // Cancelled raffle already has numberOfBuyers==0, so the destination region is safe to overwrite. + for (locals.i = 0; locals.i < locals.lastInfo.numberOfBuyers; locals.i++) + { + locals.movedBuyer = state.get().activeAssetRaffleBuyers.get(locals.lastSlot * QRAFFLE_MAX_ASSET_TICKET_BUYERS + locals.i); + state.mut().activeAssetRaffleBuyers.set( + input.indexOfAssetRaffle * QRAFFLE_MAX_ASSET_TICKET_BUYERS + locals.i, + locals.movedBuyer); + state.mut().activeAssetRaffleBuyerTickets.set( + input.indexOfAssetRaffle * QRAFFLE_MAX_ASSET_TICKET_BUYERS + locals.i, + state.get().activeAssetRaffleBuyerTickets.get(locals.lastSlot * QRAFFLE_MAX_ASSET_TICKET_BUYERS + locals.i)); + // Update slot-index map: raffle index changed from lastSlot to indexOfAssetRaffle; + // relative position within the buyer region is preserved. + state.get().assetRaffleBuyerSlotIndex.get(locals.movedBuyer, locals.slotIndexArr); + locals.slotIndexArr.set(input.indexOfAssetRaffle, locals.slotIndexArr.get(locals.lastSlot)); + locals.slotIndexArr.set(locals.lastSlot, 0xFFFF); + state.mut().assetRaffleBuyerSlotIndex.replace(locals.movedBuyer, locals.slotIndexArr); + + // Keep participation bitfield aligned with moved raffle index. + state.get().assetRaffleParticipation.get(locals.movedBuyer, locals.participation); + locals.participation.set(input.indexOfAssetRaffle, 1); + locals.participation.set(locals.lastSlot, 0); + state.mut().assetRaffleParticipation.replace(locals.movedBuyer, locals.participation); + } + } + state.mut().numberOfActiveAssetRaffles--; + + // Decrement per-creator counter so the creator can replace the cancelled raffle + // within the same epoch. The 500K proposal fee is *not* refunded (anti-spam). + locals.creatorCount = 0; + if (state.get().assetRafflesPerCreator.contains(qpi.invocator())) + { + state.get().assetRafflesPerCreator.get(qpi.invocator(), locals.creatorCount); + if (locals.creatorCount > 0) + { + state.mut().assetRafflesPerCreator.set(qpi.invocator(), (uint8)(locals.creatorCount - 1)); } } + + output.returnCode = QRAFFLE_SUCCESS; + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_assetRaffleCancelled, 0 }; + LOG_INFO(locals.log); } struct getRegisters_locals @@ -1310,6 +2002,133 @@ struct QRAFFLE : public ContractBase output.returnCode = QRAFFLE_SUCCESS; } + // ── Asset Raffle view functions ──────────────────────────────────────────── + + struct getActiveAssetRaffle_locals + { + AssetRaffleInfo info; + }; + + PUBLIC_FUNCTION_WITH_LOCALS(getActiveAssetRaffle) + { + if (input.indexOfAssetRaffle >= state.get().numberOfActiveAssetRaffles) + { + output.returnCode = QRAFFLE_INVALID_ASSET_RAFFLE; + return; + } + locals.info = state.get().activeAssetRaffles.get(input.indexOfAssetRaffle); + output.creator = locals.info.creator; + output.reservePriceQu = locals.info.reservePriceQu; + output.entryTicketQu = locals.info.entryTicketQu; + output.totalTicketsPaidQu = locals.info.totalTicketsPaidQu; + output.numberOfBuyers = locals.info.numberOfBuyers; + output.totalTickets = locals.info.totalTickets; + output.bundleSize = locals.info.bundleSize; + output.epoch = locals.info.epoch; + output.returnCode = QRAFFLE_SUCCESS; + } + + struct getActiveAssetRaffleBundleItem_locals + { + AssetRaffleInfo info; + AssetRaffleItem item; + }; + + PUBLIC_FUNCTION_WITH_LOCALS(getActiveAssetRaffleBundleItem) + { + if (input.indexOfAssetRaffle >= state.get().numberOfActiveAssetRaffles) + { + output.returnCode = QRAFFLE_INVALID_ASSET_RAFFLE; + return; + } + locals.info = state.get().activeAssetRaffles.get(input.indexOfAssetRaffle); + if (input.itemIndex >= locals.info.bundleSize) + { + output.returnCode = QRAFFLE_INVALID_BUNDLE; + return; + } + locals.item = state.get().activeAssetRaffleItems.get( + input.indexOfAssetRaffle * QRAFFLE_MAX_ASSETS_PER_BUNDLE + input.itemIndex); + output.assetIssuer = locals.item.asset.issuer; + output.assetName = locals.item.asset.assetName; + output.numberOfShares = locals.item.numberOfShares; + output.returnCode = QRAFFLE_SUCCESS; + } + + struct getActiveAssetRaffleBuyer_locals + { + AssetRaffleInfo info; + uint32 slot; + }; + + PUBLIC_FUNCTION_WITH_LOCALS(getActiveAssetRaffleBuyer) + { + if (input.indexOfAssetRaffle >= state.get().numberOfActiveAssetRaffles) + { + output.returnCode = QRAFFLE_INVALID_ASSET_RAFFLE; + return; + } + locals.info = state.get().activeAssetRaffles.get(input.indexOfAssetRaffle); + if (input.buyerIndex >= locals.info.numberOfBuyers) + { + output.returnCode = QRAFFLE_INVALID_OFFSET_OR_LIMIT; + return; + } + locals.slot = input.indexOfAssetRaffle * QRAFFLE_MAX_ASSET_TICKET_BUYERS + input.buyerIndex; + output.buyer = state.get().activeAssetRaffleBuyers.get(locals.slot); + output.ticketCount = state.get().activeAssetRaffleBuyerTickets.get(locals.slot); + output.returnCode = QRAFFLE_SUCCESS; + } + + struct getEndedAssetRaffle_locals + { + EndedAssetRaffleInfo r; + uint32 slot; + }; + + PUBLIC_FUNCTION_WITH_LOCALS(getEndedAssetRaffle) + { + if (input.indexOfRaffle >= state.get().numberOfEndedAssetRaffles) + { + output.returnCode = QRAFFLE_INVALID_ASSET_RAFFLE; + return; + } + // Reject indices that have been overwritten by the ring buffer. + if (state.get().numberOfEndedAssetRaffles > QRAFFLE_MAX_ENDED_ASSET_RAFFLES + && input.indexOfRaffle < state.get().numberOfEndedAssetRaffles - QRAFFLE_MAX_ENDED_ASSET_RAFFLES) + { + output.returnCode = QRAFFLE_INVALID_ASSET_RAFFLE; + return; + } + locals.slot = mod(input.indexOfRaffle, QRAFFLE_MAX_ENDED_ASSET_RAFFLES); + locals.r = state.get().endedAssetRaffles.get(locals.slot); + output.creator = locals.r.creator; + output.epochWinner = locals.r.epochWinner; + output.reservePriceQu = locals.r.reservePriceQu; + output.entryTicketQu = locals.r.entryTicketQu; + output.grossPoolQu = locals.r.grossPoolQu; + output.creatorPaidQu = locals.r.creatorPaidQu; + output.totalTickets = locals.r.totalTickets; + output.numberOfBuyers = locals.r.numberOfBuyers; + output.bundleSize = locals.r.bundleSize; + output.epoch = locals.r.epoch; + output.reserveMet = locals.r.reserveMet; + output.returnCode = QRAFFLE_SUCCESS; + } + + PUBLIC_FUNCTION(getAssetRaffleAnalytics) + { + output.totalAssetRaffleProposalFees = state.get().totalAssetRaffleProposalFees; + output.totalAssetRaffleCreatorPaid = state.get().totalAssetRaffleCreatorPaid; + output.totalAssetRaffleRefunded = state.get().totalAssetRaffleRefunded; + output.numberOfActiveAssetRaffles = state.get().numberOfActiveAssetRaffles; + output.numberOfEndedAssetRaffles = state.get().numberOfEndedAssetRaffles; + output.totalAssetRafflesCreated = state.get().totalAssetRafflesCreated; + output.totalAssetRafflesSucceeded = state.get().totalAssetRafflesSucceeded; + output.totalAssetRafflesFailed = state.get().totalAssetRafflesFailed; + output.returnCode = QRAFFLE_SUCCESS; + } + REGISTER_USER_FUNCTIONS_AND_PROCEDURES() { REGISTER_USER_FUNCTION(getRegisters, 1); @@ -1330,6 +2149,16 @@ struct QRAFFLE : public ContractBase REGISTER_USER_PROCEDURE(depositInQuRaffle, 6); REGISTER_USER_PROCEDURE(depositInTokenRaffle, 7); REGISTER_USER_PROCEDURE(TransferShareManagementRights, 8); + // Asset Raffle procedures + REGISTER_USER_PROCEDURE(createAssetRaffle, 9); + REGISTER_USER_PROCEDURE(buyAssetRaffleTicket, 10); + REGISTER_USER_PROCEDURE(cancelAssetRaffle, 11); + // Asset Raffle view functions + REGISTER_USER_FUNCTION(getActiveAssetRaffle, 10); + REGISTER_USER_FUNCTION(getActiveAssetRaffleBundleItem, 11); + REGISTER_USER_FUNCTION(getActiveAssetRaffleBuyer, 12); + REGISTER_USER_FUNCTION(getEndedAssetRaffle, 13); + REGISTER_USER_FUNCTION(getAssetRaffleAnalytics, 14); } INITIALIZE() @@ -1381,6 +2210,32 @@ struct QRAFFLE : public ContractBase RevenueLogger revenueLog; TokenRaffleLogger tokenRaffleLog; ProposalLogger proposalLog; + // Asset raffle settlement locals + AssetRaffleInfo arInfo; + AssetRaffleItem arItem; + EndedAssetRaffleInfo arEnded; + AssetRaffleEndedLogger arLog; + uint64 arGross; + uint64 arCreatorPay; + uint64 arBurn; + uint64 arCharity; + uint64 arShareholderRev; + uint64 arRegisterRev; + uint64 arFee; + uint64 arShareholderPerShare; + uint64 arRegisterPerShare; + uint64 arRegisterPerShareActual; + uint64 arRegisterBucket; // accumulated register share across all successful asset raffles + uint64 arRegisterBucketPerReg; // per-register amount distributed after the settlement loop + uint64 arDaoBucketPerRegister; + uint64 arTicketAcc; + uint64 arRefund; // per-buyer refund amount (used in reserve-missed path) + uint32 arI; + uint32 arJ; + uint32 arBuyerSlot; + uint32 arWinnerIndex; + uint32 arEndedIdx; + bit arReserveMet; }; END_EPOCH_WITH_LOCALS() @@ -1648,6 +2503,188 @@ struct QRAFFLE : public ContractBase } } + // ── Asset Raffle settlement ──────────────────────────────────────────── + locals.arRegisterBucket = 0; + for (locals.arI = 0; locals.arI < state.get().numberOfActiveAssetRaffles; locals.arI++) + { + locals.arInfo = state.get().activeAssetRaffles.get(locals.arI); + locals.arGross = locals.arInfo.totalTicketsPaidQu; + + // Reserve test: gross * 80 >= reservePriceQu * 100 + locals.arReserveMet = (locals.arInfo.totalTickets > 0) + && (locals.arGross * 80ull >= locals.arInfo.reservePriceQu * 100ull); + + if (locals.arReserveMet) + { + // Weighted winner selection by ticket count. + locals.raffleSeed = qpi.K12(m256i( + locals.baseSeed.u64._0, locals.baseSeed.u64._1, + locals.baseSeed.u64._2, + locals.baseSeed.u64._3 ^ (0xA55E7000ULL + (uint64)locals.arI))); + locals.r = locals.raffleSeed.u64._0; + locals.r = mod(locals.r, (uint64)locals.arInfo.totalTickets); + locals.arTicketAcc = 0; + locals.arWinnerIndex = 0; + locals.winner = NULL_ID; + for (locals.arJ = 0; locals.arJ < locals.arInfo.numberOfBuyers; locals.arJ++) + { + locals.arBuyerSlot = locals.arI * QRAFFLE_MAX_ASSET_TICKET_BUYERS + locals.arJ; + locals.arTicketAcc += (uint64)state.get().activeAssetRaffleBuyerTickets.get(locals.arBuyerSlot); + if (locals.r < locals.arTicketAcc) + { + locals.arWinnerIndex = locals.arJ; + locals.winner = state.get().activeAssetRaffleBuyers.get(locals.arBuyerSlot); + break; + } + } + + // Transfer each bundle item to the winner. If an item fails, log and continue; + // we cannot pull assets back from a user's wallet, so the winner keeps whatever + // was delivered and the remaining escrowed items stay in the contract. + for (locals.arJ = 0; locals.arJ < locals.arInfo.bundleSize; locals.arJ++) + { + locals.arItem = state.get().activeAssetRaffleItems.get( + locals.arI * QRAFFLE_MAX_ASSETS_PER_BUNDLE + locals.arJ); + locals.transferResult = qpi.transferShareOwnershipAndPossession( + locals.arItem.asset.assetName, locals.arItem.asset.issuer, + SELF, SELF, + locals.arItem.numberOfShares, locals.winner); + if (locals.transferResult < 0) + { + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_assetRaffleBundleDeliveryFailed, 0 }; + LOG_INFO(locals.log); + } + } + + // Qu pool distribution: 80% to creator, 20% to fee buckets. + // Always executed when reserve is met, regardless of per-item delivery outcome. + // (Assets already delivered to winner; we cannot recall them from a user's wallet.) + locals.arBurn = div(locals.arGross * (uint64)QRAFFLE_BURN_FEE, 100ull); + locals.arCharity = div(locals.arGross * (uint64)QRAFFLE_CHARITY_FEE, 100ull); + locals.arShareholderRev = div(locals.arGross * (uint64)QRAFFLE_SHAREHOLDER_FEE, 100ull); + locals.arRegisterRev = div(locals.arGross * (uint64)QRAFFLE_REGISTER_FEE, 100ull); + locals.arFee = div(locals.arGross * (uint64)QRAFFLE_FEE, 100ull); + // Round down per-share amounts; all rounding dust goes to creator. + locals.arShareholderPerShare = div(locals.arShareholderRev, (uint64)NUMBER_OF_COMPUTORS); + locals.arRegisterPerShare = (state.get().numberOfRegisters > 0) + ? div(locals.arRegisterRev, (uint64)state.get().numberOfRegisters) + : 0; + locals.arRegisterPerShareActual = locals.arRegisterPerShare * (uint64)state.get().numberOfRegisters; + // Creator gets gross minus all deductions; rounding dust stays with creator. + locals.arCreatorPay = locals.arGross + - locals.arBurn + - locals.arCharity + - (locals.arShareholderPerShare * (uint64)NUMBER_OF_COMPUTORS) + - locals.arRegisterPerShareActual + - locals.arFee; + + qpi.transfer(locals.arInfo.creator, locals.arCreatorPay); + qpi.burn(locals.arBurn); + qpi.transfer(state.get().charityAddress, locals.arCharity); + if (locals.arShareholderPerShare > 0) + { + qpi.distributeDividends(locals.arShareholderPerShare); + } + qpi.transfer(state.get().feeAddress, locals.arFee); + // Accumulate register share; distributed in a single pass after the loop. + locals.arRegisterBucket += locals.arRegisterPerShareActual; + + state.mut().totalAssetRaffleCreatorPaid += locals.arCreatorPay; + state.mut().totalAssetRafflesSucceeded++; + } + + if (!locals.arReserveMet) + { + // Reserve not met: refund all Qu to buyers and return bundle to creator. + for (locals.arJ = 0; locals.arJ < locals.arInfo.numberOfBuyers; locals.arJ++) + { + locals.arBuyerSlot = locals.arI * QRAFFLE_MAX_ASSET_TICKET_BUYERS + locals.arJ; + locals.arRefund = (uint64)state.get().activeAssetRaffleBuyerTickets.get(locals.arBuyerSlot) * locals.arInfo.entryTicketQu; + qpi.transfer(state.get().activeAssetRaffleBuyers.get(locals.arBuyerSlot), locals.arRefund); + state.mut().totalAssetRaffleRefunded += locals.arRefund; + } + for (locals.arJ = 0; locals.arJ < locals.arInfo.bundleSize; locals.arJ++) + { + locals.arItem = state.get().activeAssetRaffleItems.get( + locals.arI * QRAFFLE_MAX_ASSETS_PER_BUNDLE + locals.arJ); + qpi.transferShareOwnershipAndPossession( + locals.arItem.asset.assetName, locals.arItem.asset.issuer, + SELF, SELF, + locals.arItem.numberOfShares, locals.arInfo.creator); + } + locals.arCreatorPay = 0; + locals.winner = NULL_ID; + locals.arWinnerIndex = 0; + state.mut().totalAssetRafflesFailed++; + } + + // Write to ended ring buffer. + locals.arEndedIdx = mod(state.get().numberOfEndedAssetRaffles, QRAFFLE_MAX_ENDED_ASSET_RAFFLES); + locals.arEnded.creator = locals.arInfo.creator; + locals.arEnded.epochWinner = locals.winner; + locals.arEnded.reservePriceQu = locals.arInfo.reservePriceQu; + locals.arEnded.entryTicketQu = locals.arInfo.entryTicketQu; + locals.arEnded.grossPoolQu = locals.arGross; + locals.arEnded.creatorPaidQu = locals.arCreatorPay; + locals.arEnded.totalTickets = locals.arInfo.totalTickets; + locals.arEnded.numberOfBuyers = locals.arInfo.numberOfBuyers; + locals.arEnded.bundleSize = locals.arInfo.bundleSize; + locals.arEnded.epoch = locals.arInfo.epoch; + locals.arEnded.reserveMet = locals.arReserveMet ? 1u : 0u; + state.mut().endedAssetRaffles.set(locals.arEndedIdx, locals.arEnded); + state.mut().numberOfEndedAssetRaffles++; + + locals.arLog = AssetRaffleEndedLogger{ + QRAFFLE_CONTRACT_INDEX, + locals.arReserveMet ? (uint32)QRAFFLE_assetRaffleSucceeded : (uint32)QRAFFLE_assetRaffleRefunded, + locals.arI, + locals.arInfo.creator, + locals.winner, + locals.arGross, + locals.arCreatorPay, + locals.arReserveMet ? (uint8)1 : (uint8)0, + 0 + }; + LOG_INFO(locals.arLog); + } + + // Distribute accumulated register share from all successful asset raffles in one O(R) pass. + if (locals.arRegisterBucket > 0 && state.get().numberOfRegisters > 0) + { + locals.arRegisterBucketPerReg = div(locals.arRegisterBucket, (uint64)state.get().numberOfRegisters); + if (locals.arRegisterBucketPerReg > 0) + { + locals.idx = state.get().registers.nextElementIndex(NULL_INDEX); + while (locals.idx != NULL_INDEX) + { + qpi.transfer(state.get().registers.key(locals.idx), locals.arRegisterBucketPerReg); + locals.idx = state.get().registers.nextElementIndex(locals.idx); + } + } + } + + // Distribute DAO proposal-fee bucket evenly to registers. + if (state.get().epochAssetRaffleDaoBucket > 0 && state.get().numberOfRegisters > 0) + { + locals.arDaoBucketPerRegister = div(state.get().epochAssetRaffleDaoBucket, (uint64)state.get().numberOfRegisters); + if (locals.arDaoBucketPerRegister > 0) + { + locals.idx = state.get().registers.nextElementIndex(NULL_INDEX); + while (locals.idx != NULL_INDEX) + { + qpi.transfer(state.get().registers.key(locals.idx), locals.arDaoBucketPerRegister); + locals.idx = state.get().registers.nextElementIndex(locals.idx); + } + } + state.mut().epochAssetRaffleDaoBucket = 0; + } + + // Reset asset raffle per-epoch state. + state.mut().numberOfActiveAssetRaffles = 0; + state.mut().assetRaffleParticipation.reset(); + state.mut().assetRaffleBuyerSlotIndex.reset(); + state.mut().assetRafflesPerCreator.reset(); + // Calculate new qREAmount and log locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_revenueDistributed, 0 }; LOG_INFO(locals.log); diff --git a/test/contract_qraffle.cpp b/test/contract_qraffle.cpp index 4d6f9b6be..1ba591a54 100644 --- a/test/contract_qraffle.cpp +++ b/test/contract_qraffle.cpp @@ -199,6 +199,88 @@ class QRaffleChecker : public QRAFFLE, public QRAFFLE::StateData { return QXMRIssuer; } + + // ── Asset Raffle checkers ────────────────────────────────────────────────── + + void assetRaffleCreatedChecker(uint32 index, const id& expectedCreator, + uint64 expectedReservePrice, uint64 expectedEntryTicket, + uint32 expectedBundleSize, uint32 expectedEpoch) + { + EXPECT_LT(index, numberOfActiveAssetRaffles); + const AssetRaffleInfo& info = activeAssetRaffles.get(index); + EXPECT_EQ(info.creator, expectedCreator); + EXPECT_EQ(info.reservePriceQu, expectedReservePrice); + EXPECT_EQ(info.entryTicketQu, expectedEntryTicket); + EXPECT_EQ(info.bundleSize, expectedBundleSize); + EXPECT_EQ(info.epoch, expectedEpoch); + } + + void assetRaffleBundleItemChecker(uint32 raffleIndex, uint32 itemIndex, + const id& expectedIssuer, uint64 expectedAssetName, + sint64 expectedShares) + { + EXPECT_LT(raffleIndex, numberOfActiveAssetRaffles); + EXPECT_LT(itemIndex, activeAssetRaffles.get(raffleIndex).bundleSize); + const AssetRaffleItem& item = activeAssetRaffleItems.get( + raffleIndex * QRAFFLE_MAX_ASSETS_PER_BUNDLE + itemIndex); + EXPECT_EQ(item.asset.issuer, expectedIssuer); + EXPECT_EQ(item.asset.assetName, expectedAssetName); + EXPECT_EQ(item.numberOfShares, expectedShares); + } + + void assetRaffleBuyerChecker(uint32 raffleIndex, const id& buyer, + bool expectPresent, uint32 expectedTickets = 0) + { + const AssetRaffleInfo& info = activeAssetRaffles.get(raffleIndex); + bool found = false; + uint32 foundTickets = 0; + for (uint32 i = 0; i < info.numberOfBuyers; i++) + { + if (activeAssetRaffleBuyers.get(raffleIndex * QRAFFLE_MAX_ASSET_TICKET_BUYERS + i) == buyer) + { + found = true; + foundTickets = activeAssetRaffleBuyerTickets.get(raffleIndex * QRAFFLE_MAX_ASSET_TICKET_BUYERS + i); + break; + } + } + EXPECT_EQ(found, expectPresent); + if (expectPresent && expectedTickets > 0) + EXPECT_EQ(foundTickets, expectedTickets); + } + + void assetRafflePoolChecker(uint32 index, uint64 expectedGross, uint32 expectedTotalTickets, uint32 expectedBuyers) + { + const AssetRaffleInfo& info = activeAssetRaffles.get(index); + EXPECT_EQ(info.totalTicketsPaidQu, expectedGross); + EXPECT_EQ(info.totalTickets, expectedTotalTickets); + EXPECT_EQ(info.numberOfBuyers, expectedBuyers); + } + + void endedAssetRaffleChecker(uint32 index, bool expectedReserveMet, + const id& expectedCreator, uint64 expectedGross, + uint32 expectedEpoch) + { + uint32 slot = index % QRAFFLE_MAX_ENDED_ASSET_RAFFLES; + const EndedAssetRaffleInfo& r = endedAssetRaffles.get(slot); + EXPECT_EQ(r.reserveMet, (uint8)(expectedReserveMet ? 1 : 0)); + EXPECT_EQ(r.creator, expectedCreator); + EXPECT_EQ(r.grossPoolQu, expectedGross); + EXPECT_EQ(r.epoch, expectedEpoch); + } + + void assetRaffleAnalyticsChecker(uint32 expectedCreated, uint32 expectedSucceeded, + uint32 expectedFailed) + { + EXPECT_EQ(totalAssetRafflesCreated, expectedCreated); + EXPECT_EQ(totalAssetRafflesSucceeded, expectedSucceeded); + EXPECT_EQ(totalAssetRafflesFailed, expectedFailed); + } + + uint32 getNumberOfActiveAssetRaffles() const { return numberOfActiveAssetRaffles; } + uint32 getNumberOfEndedAssetRaffles() const { return numberOfEndedAssetRaffles; } + uint64 getEpochAssetRaffleDaoBucket() const { return epochAssetRaffleDaoBucket; } + uint64 getTotalAssetRaffleRefunded() const { return totalAssetRaffleRefunded; } + uint64 getTotalAssetRaffleCreatorPaid()const { return totalAssetRaffleCreatorPaid; } }; class ContractTestingQraffle : protected ContractTesting @@ -440,6 +522,158 @@ class ContractTestingQraffle : protected ContractTesting invokeUserProcedure(QRAFFLE_CONTRACT_INDEX, 8, input, output, currentOwner, invocationReward); return output.transferredNumberOfShares; } + + // ── Asset Raffle API wrappers ────────────────────────────────────────────── + + QRAFFLE::createAssetRaffle_output createAssetRaffle( + const id& creator, + uint64 reservePriceQu, + uint64 entryTicketQu, + const std::vector>& bundle) + { + return createAssetRaffleWithReward( + creator, reservePriceQu, entryTicketQu, bundle, + (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE); + } + + QRAFFLE::createAssetRaffle_output createAssetRaffleWithReward( + const id& creator, + uint64 reservePriceQu, + uint64 entryTicketQu, + const std::vector>& bundle, + sint64 invocationReward) + { + QRAFFLE::createAssetRaffle_input input{}; + QRAFFLE::createAssetRaffle_output output{}; + + input.reservePriceQu = reservePriceQu; + input.entryTicketQu = entryTicketQu; + input.bundleSize = (uint32)bundle.size(); + for (uint32 i = 0; i < input.bundleSize && i < QRAFFLE_MAX_ASSETS_PER_BUNDLE; i++) + { + QRAFFLE::AssetRaffleItem item{}; + item.asset = bundle[i].first; + item.numberOfShares = bundle[i].second; + input.bundleItems.set(i, item); + } + invokeUserProcedure(QRAFFLE_CONTRACT_INDEX, 9, input, output, creator, + invocationReward); + return output; + } + + QRAFFLE::buyAssetRaffleTicket_output buyAssetRaffleTicket( + const id& buyer, uint32 raffleIndex, uint32 ticketCount, uint64 totalPayment) + { + QRAFFLE::buyAssetRaffleTicket_input input{}; + QRAFFLE::buyAssetRaffleTicket_output output{}; + + input.indexOfAssetRaffle = raffleIndex; + input.numberOfTickets = ticketCount; + invokeUserProcedure(QRAFFLE_CONTRACT_INDEX, 10, input, output, buyer, (sint64)totalPayment); + return output; + } + + QRAFFLE::cancelAssetRaffle_output cancelAssetRaffle(const id& caller, uint32 raffleIndex) + { + QRAFFLE::cancelAssetRaffle_input input{}; + QRAFFLE::cancelAssetRaffle_output output{}; + + input.indexOfAssetRaffle = raffleIndex; + invokeUserProcedure(QRAFFLE_CONTRACT_INDEX, 11, input, output, caller, 0); + return output; + } + + QRAFFLE::getActiveAssetRaffle_output getActiveAssetRaffle(uint32 raffleIndex) + { + QRAFFLE::getActiveAssetRaffle_input input{}; + QRAFFLE::getActiveAssetRaffle_output output{}; + + input.indexOfAssetRaffle = raffleIndex; + callFunction(QRAFFLE_CONTRACT_INDEX, 10, input, output); + return output; + } + + QRAFFLE::getActiveAssetRaffleBundleItem_output getActiveAssetRaffleBundleItem( + uint32 raffleIndex, uint32 itemIndex) + { + QRAFFLE::getActiveAssetRaffleBundleItem_input input{}; + QRAFFLE::getActiveAssetRaffleBundleItem_output output{}; + + input.indexOfAssetRaffle = raffleIndex; + input.itemIndex = itemIndex; + callFunction(QRAFFLE_CONTRACT_INDEX, 11, input, output); + return output; + } + + QRAFFLE::getActiveAssetRaffleBuyer_output getActiveAssetRaffleBuyer( + uint32 raffleIndex, uint32 buyerIndex) + { + QRAFFLE::getActiveAssetRaffleBuyer_input input{}; + QRAFFLE::getActiveAssetRaffleBuyer_output output{}; + + input.indexOfAssetRaffle = raffleIndex; + input.buyerIndex = buyerIndex; + callFunction(QRAFFLE_CONTRACT_INDEX, 12, input, output); + return output; + } + + QRAFFLE::getEndedAssetRaffle_output getEndedAssetRaffle(uint32 index) + { + QRAFFLE::getEndedAssetRaffle_input input{}; + QRAFFLE::getEndedAssetRaffle_output output{}; + + input.indexOfRaffle = index; + callFunction(QRAFFLE_CONTRACT_INDEX, 13, input, output); + return output; + } + + QRAFFLE::getAssetRaffleAnalytics_output getAssetRaffleAnalytics() + { + QRAFFLE::getAssetRaffleAnalytics_input input{}; + QRAFFLE::getAssetRaffleAnalytics_output output{}; + + callFunction(QRAFFLE_CONTRACT_INDEX, 14, input, output); + return output; + } + + // Helper: register a user in the DAO system + void registerDAOMember(const id& user) + { + increaseEnergy(user, QRAFFLE_REGISTER_AMOUNT); + auto r = registerInSystem(user, QRAFFLE_REGISTER_AMOUNT, 0); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS); + } + + // Helper: issue a plain token and give all shares to 'owner' with QRAFFLE as manager. + Asset setupToken(const id& issuer, const char* name, sint64 totalShares, const id& owner) + { + // issuance fee (1B) + QX ownership-transfer fee (100) + increaseEnergy(issuer, 1000000000ULL + 100); + uint64 aname = assetNameFromString(name); + sint64 issued = issueAsset(issuer, aname, totalShares, 0, 0); + EXPECT_EQ(issued, totalShares); + if (!(issuer == owner)) + { + // Transfer ownership first (QX proc 2, invocationReward = 100) + sint64 moved = transferShareOwnershipAndPossession(issuer, aname, issuer, totalShares, owner); + EXPECT_EQ(moved, totalShares); + // Ensure owner is in spectrum so invokeUserProcedure doesn't reject the call. + increaseEnergy(owner, 1); + // Transfer management rights to QRAFFLE via owner (QX proc 9, fee = 0) + sint64 mgmt = TransferShareManagementRights(issuer, aname, QRAFFLE_CONTRACT_INDEX, totalShares, owner); + EXPECT_EQ(mgmt, totalShares); + } + else + { + // issuer == owner: directly transfer management rights (QX proc 9, fee = 0) + sint64 mgmt = TransferShareManagementRights(issuer, aname, QRAFFLE_CONTRACT_INDEX, totalShares, issuer); + EXPECT_EQ(mgmt, totalShares); + } + Asset a; + a.assetName = aname; + a.issuer = issuer; + return a; + } }; TEST(ContractQraffle, RegisterInSystem) @@ -1640,4 +1874,1491 @@ TEST(ContractQraffle, GetQuRaffleEntryAverageAmount) auto recalculatedAverageResult = qraffle.getQuRaffleEntryAverageAmount(); EXPECT_EQ(recalculatedAverageResult.returnCode, QRAFFLE_SUCCESS); EXPECT_EQ(recalculatedAverageResult.entryAverageAmount, recalculatedAverage); +} + +// ============================================================================ +// Asset Raffle Tests +// ============================================================================ + +// ── Helpers used across asset raffle tests ────────────────────────────────── + +// Register N sequential users starting at offset as DAO members; return their ids. +static std::vector registerDAOUsers(ContractTestingQraffle& q, uint32 count, uint32 offset = 5000) +{ + std::vector users; + users.reserve(count); + for (uint32 i = 0; i < count; i++) + { + id u = getUser(offset + i); + q.registerDAOMember(u); + users.push_back(u); + } + return users; +} + +// Build a single-item bundle (plain token already transferred to QRAFFLE management). +static std::vector> makeBundle(const Asset& a, sint64 shares) +{ + return { { a, shares } }; +} + +// ── DIAG: setupToken share-transfer sanity ──────────────────────────────────── +TEST(ContractQraffle, AssetRaffle_DiagSetupToken) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id issuer = getUser(6100); + id owner = getUser(6000); + increaseEnergy(issuer, 1000000000ULL + 100); // issuance fee + QX transfer fee + uint64 aname = assetNameFromString("DIAG"); + sint64 issued = qraffle.issueAsset(issuer, aname, 1000000, 0, 0); + EXPECT_EQ(issued, 1000000) << "IssueAsset failed"; + + // shares should be with issuer, managed by QX + EXPECT_EQ(numberOfPossessedShares(aname, issuer, issuer, issuer, QX_CONTRACT_INDEX, QX_CONTRACT_INDEX), 1000000) + << "issuer should possess all shares under QX management"; + + // transfer ownership from issuer to owner (still QX-managed) + sint64 moved = qraffle.transferShareOwnershipAndPossession(issuer, aname, issuer, 1000000, owner); + EXPECT_EQ(moved, 1000000) << "transferShareOwnershipAndPossession failed"; + + EXPECT_EQ(numberOfPossessedShares(aname, issuer, owner, owner, QX_CONTRACT_INDEX, QX_CONTRACT_INDEX), 1000000) + << "owner should possess all shares under QX management after transfer"; + + // Ensure owner is in spectrum before calling QX proc 9 with owner as invocator. + increaseEnergy(owner, 1); + // transfer management rights to QRAFFLE (owner calls QX proc 9) + sint64 mgmt = qraffle.TransferShareManagementRights(issuer, aname, QRAFFLE_CONTRACT_INDEX, 1000000, owner); + EXPECT_EQ(mgmt, 1000000) << "TransferShareManagementRights (QX proc9) failed"; + + EXPECT_EQ(numberOfPossessedShares(aname, issuer, owner, owner, QRAFFLE_CONTRACT_INDEX, QRAFFLE_CONTRACT_INDEX), 1000000) + << "owner should possess all shares under QRAFFLE management after rights transfer"; +} + +// ── TEST 1: createAssetRaffle – basic success ──────────────────────────────── +TEST(ContractQraffle, AssetRaffle_CreateBasicSuccess) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + // Register creator as DAO member + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + // Issue token and give to creator (under QRAFFLE management) + id tokenIssuer = getUser(6100); + Asset token = qraffle.setupToken(tokenIssuer, "ARTEST", 1000000, creator); + + // 500K proposal fee + buffer for energy + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + + auto bundle = makeBundle(token, 500000); + auto result = qraffle.createAssetRaffle(creator, 125000000ULL, 5000000ULL, bundle); + EXPECT_EQ(result.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 1u); + + // Verify raffle metadata via state checker + qraffle.getState()->assetRaffleCreatedChecker(0, creator, 125000000ULL, 5000000ULL, 1, 200); + + // Verify bundle item + qraffle.getState()->assetRaffleBundleItemChecker(0, 0, tokenIssuer, token.assetName, 500000); +} + +// ── TEST 2: createAssetRaffle – validation failures ────────────────────────── +TEST(ContractQraffle, AssetRaffle_CreateValidation) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6100); + Asset token = qraffle.setupToken(tokenIssuer, "VALTEST", 1000000, creator); + auto bundle = makeBundle(token, 500000); + + // Insufficient proposal fee: invoke with one less than required and assert the + // contract returns QRAFFLE_INSUFFICIENT_FUND (instead of only testing harness behavior). + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE - 1); + { + auto r = qraffle.createAssetRaffleWithReward( + creator, 125000000ULL, 5000000ULL, bundle, + (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE - 1); + EXPECT_EQ(r.returnCode, QRAFFLE_INSUFFICIENT_FUND); + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 0u); + } + + // Restore energy for remaining tests + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE * 10); + + // Not a DAO member → QRAFFLE_UNREGISTERED + id nonMember = getUser(9000); + increaseEnergy(nonMember, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + { + auto r = qraffle.createAssetRaffle(nonMember, 125000000ULL, 5000000ULL, bundle); + EXPECT_EQ(r.returnCode, QRAFFLE_UNREGISTERED); + } + + // Entry ticket below minimum → QRAFFLE_INVALID_BUNDLE or QRAFFLE_INVALID_RESERVE_PRICE + { + auto r = qraffle.createAssetRaffle(creator, 125000000ULL, + QRAFFLE_MIN_ASSET_TICKET_AMOUNT - 1, bundle); + EXPECT_NE(r.returnCode, QRAFFLE_SUCCESS); + } + + // Entry ticket above maximum + { + auto r = qraffle.createAssetRaffle(creator, 125000000ULL, + QRAFFLE_MAX_ASSET_TICKET_AMOUNT + 1, bundle); + EXPECT_NE(r.returnCode, QRAFFLE_SUCCESS); + } + + // Empty bundle → QRAFFLE_INVALID_BUNDLE + { + auto r = qraffle.createAssetRaffle(creator, 125000000ULL, 5000000ULL, {}); + EXPECT_EQ(r.returnCode, QRAFFLE_INVALID_BUNDLE); + } + + // Bundle larger than max → QRAFFLE_INVALID_BUNDLE + { + std::vector> bigBundle; + for (uint32 i = 0; i <= QRAFFLE_MAX_ASSETS_PER_BUNDLE; i++) + bigBundle.push_back({ token, 1000 }); + auto r = qraffle.createAssetRaffle(creator, 125000000ULL, 5000000ULL, bigBundle); + EXPECT_EQ(r.returnCode, QRAFFLE_INVALID_BUNDLE); + } +} + +// ── TEST 3: createAssetRaffle – per-creator epoch limit ────────────────────── +TEST(ContractQraffle, AssetRaffle_CreatePerCreatorEpochLimit) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6100); + increaseEnergy(tokenIssuer, 5 * 1000000000ULL); + + // Give creator enough shares for multiple raffles + Asset token = qraffle.setupToken(tokenIssuer, "LIMTEST", 10000000, creator); + auto bundle = makeBundle(token, 100000); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE * (QRAFFLE_MAX_ASSET_RAFFLES_PER_CREATOR + 2) + 10000); + + // Fill up to per-creator limit + for (uint32 i = 0; i < QRAFFLE_MAX_ASSET_RAFFLES_PER_CREATOR; i++) + { + auto r = qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, bundle); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS) << "raffle " << i; + } + + // Next attempt must fail + auto r = qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, bundle); + EXPECT_EQ(r.returnCode, QRAFFLE_MAX_ASSET_RAFFLES_REACHED); +} + +// ── TEST 4: createAssetRaffle – global concurrent limit ───────────────────── +TEST(ContractQraffle, AssetRaffle_CreateGlobalLimit) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id tokenIssuer = getUser(7000); + increaseEnergy(tokenIssuer, 5 * 1000000000ULL); + uint64 aname = assetNameFromString("GBLTEST"); + qraffle.issueAsset(tokenIssuer, aname, 1000000000LL, 0, 0); + Asset token; + token.assetName = aname; + token.issuer = tokenIssuer; + + // We need QRAFFLE_MAX_ASSET_RAFFLES_PER_EPOCH distinct creators (each limited to 1 raffle + // because QRAFFLE_MAX_ASSET_RAFFLES_PER_CREATOR == 2, and filling 64 slots means at least + // 32 creators). For simplicity: use 64 creators, each creates 1 raffle. + uint32 creatorsNeeded = QRAFFLE_MAX_ASSET_RAFFLES_PER_EPOCH; + for (uint32 i = 0; i < creatorsNeeded; i++) + { + id creator = getUser(8000 + i); + qraffle.registerDAOMember(creator); + // Give shares + sint64 shares = 10000; + qraffle.transferShareOwnershipAndPossession(tokenIssuer, aname, tokenIssuer, shares, creator); + qraffle.TransferShareManagementRights(tokenIssuer, aname, QRAFFLE_CONTRACT_INDEX, shares, creator); + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + + auto bundle = makeBundle(token, shares); + auto r = qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, bundle); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS) << "slot " << i; + } + + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), QRAFFLE_MAX_ASSET_RAFFLES_PER_EPOCH); + + // One more must fail + id extraCreator = getUser(9000); + qraffle.registerDAOMember(extraCreator); + qraffle.transferShareOwnershipAndPossession(tokenIssuer, aname, tokenIssuer, 10000, extraCreator); + qraffle.TransferShareManagementRights(tokenIssuer, aname, QRAFFLE_CONTRACT_INDEX, 10000, extraCreator); + increaseEnergy(extraCreator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + auto rExtra = qraffle.createAssetRaffle(extraCreator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(token, 10000)); + EXPECT_EQ(rExtra.returnCode, QRAFFLE_MAX_ASSET_RAFFLES_REACHED); +} + +// ── TEST 5: createAssetRaffle – multi-item bundle ──────────────────────────── +TEST(ContractQraffle, AssetRaffle_CreateMultiItemBundle) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id issuer1 = getUser(6100); + id issuer2 = getUser(6200); + id issuer3 = getUser(6300); + + Asset t1 = qraffle.setupToken(issuer1, "BNDA", 500000, creator); + Asset t2 = qraffle.setupToken(issuer2, "BNDB", 300000, creator); + Asset t3 = qraffle.setupToken(issuer3, "BNDC", 200000, creator); + + std::vector> bundle = { + { t1, 500000 }, + { t2, 300000 }, + { t3, 200000 }, + }; + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + auto r = qraffle.createAssetRaffle(creator, 1000000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, bundle); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 1u); + + qraffle.getState()->assetRaffleCreatedChecker(0, creator, 1000000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, 3, 200); + qraffle.getState()->assetRaffleBundleItemChecker(0, 0, issuer1, t1.assetName, 500000); + qraffle.getState()->assetRaffleBundleItemChecker(0, 1, issuer2, t2.assetName, 300000); + qraffle.getState()->assetRaffleBundleItemChecker(0, 2, issuer3, t3.assetName, 200000); +} + +// ── TEST 6: createAssetRaffle – proposal fee distribution ──────────────────── +TEST(ContractQraffle, AssetRaffle_ProposalFeeDistribution) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + // Register a few DAO members so the DAO bucket has recipients + auto daoMembers = registerDAOUsers(qraffle, 4, 6000); + + id creator = getUser(6500); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6600); + Asset token = qraffle.setupToken(tokenIssuer, "PFTEST", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + auto bundle = makeBundle(token, 500000); + auto r = qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, bundle); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS); + + // 50% of proposal fee should have gone to epochAssetRaffleDaoBucket + uint64 expectedDaoBucket = QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE / 2; + EXPECT_EQ(qraffle.getState()->getEpochAssetRaffleDaoBucket(), expectedDaoBucket); +} + +// ── TEST 7: buyAssetRaffleTicket – basic success ───────────────────────────── +TEST(ContractQraffle, AssetRaffle_BuyTicketBasicSuccess) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + id buyer = getUser(6100); + + id tokenIssuer = getUser(6200); + Asset token = qraffle.setupToken(tokenIssuer, "BUYTEST", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + uint64 ticketPrice = 10000000ULL; // 10M Qu + auto cr = qraffle.createAssetRaffle(creator, 125000000ULL, ticketPrice, makeBundle(token, 500000)); + EXPECT_EQ(cr.returnCode, QRAFFLE_SUCCESS); + + uint32 ticketsToBuy = 3; + uint64 payment = ticketPrice * ticketsToBuy; + increaseEnergy(buyer, (sint64)payment); + auto br = qraffle.buyAssetRaffleTicket(buyer, 0, ticketsToBuy, payment); + EXPECT_EQ(br.returnCode, QRAFFLE_SUCCESS); + + qraffle.getState()->assetRaffleBuyerChecker(0, buyer, true, ticketsToBuy); + qraffle.getState()->assetRafflePoolChecker(0, payment, ticketsToBuy, 1); +} + +// ── TEST 8: buyAssetRaffleTicket – overpayment refund ──────────────────────── +TEST(ContractQraffle, AssetRaffle_BuyTicketRefundExcess) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + id buyer = getUser(6100); + + id tokenIssuer = getUser(6200); + Asset token = qraffle.setupToken(tokenIssuer, "REFTEST", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + uint64 ticketPrice = 10000000ULL; + qraffle.createAssetRaffle(creator, 125000000ULL, ticketPrice, makeBundle(token, 500000)); + + // Pay for 4 tickets but request only 2 (excess should be refunded) + uint64 payment = ticketPrice * 4; + increaseEnergy(buyer, (sint64)payment); + sint64 balanceBefore = getBalance(buyer); + auto br = qraffle.buyAssetRaffleTicket(buyer, 0, 2, payment); + EXPECT_EQ(br.returnCode, QRAFFLE_SUCCESS); + + // Buyer should have been refunded the overpayment for 2 extra tickets + sint64 expectedRefund = (sint64)(ticketPrice * 2); + EXPECT_EQ(getBalance(buyer), balanceBefore - (sint64)(ticketPrice * 2)); + + qraffle.getState()->assetRaffleBuyerChecker(0, buyer, true, 2); +} + +// ── TEST 9: buyAssetRaffleTicket – validations ─────────────────────────────── +TEST(ContractQraffle, AssetRaffle_BuyTicketValidations) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6200); + Asset token = qraffle.setupToken(tokenIssuer, "BVTEST", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + uint64 ticketPrice = 10000000ULL; + qraffle.createAssetRaffle(creator, 125000000ULL, ticketPrice, makeBundle(token, 500000)); + + // Any user (even non-DAO member) can buy tickets + id nonMember = getUser(9000); + increaseEnergy(nonMember, (sint64)ticketPrice); + auto r1 = qraffle.buyAssetRaffleTicket(nonMember, 0, 1, ticketPrice); + EXPECT_EQ(r1.returnCode, QRAFFLE_SUCCESS); + + // Invalid raffle index + id buyer = getUser(6100); + increaseEnergy(buyer, (sint64)ticketPrice * 10); + auto r2 = qraffle.buyAssetRaffleTicket(buyer, 9999, 1, ticketPrice); + EXPECT_EQ(r2.returnCode, QRAFFLE_INVALID_ASSET_RAFFLE); + + // Insufficient payment (underpayment) + auto r3 = qraffle.buyAssetRaffleTicket(buyer, 0, 1, ticketPrice - 1); + EXPECT_EQ(r3.returnCode, QRAFFLE_INSUFFICIENT_FUND); + + // Zero ticket count + auto r4 = qraffle.buyAssetRaffleTicket(buyer, 0, 0, 0); + EXPECT_NE(r4.returnCode, QRAFFLE_SUCCESS); +} + +// ── TEST 10: buyAssetRaffleTicket – per-buyer ticket cap ───────────────────── +TEST(ContractQraffle, AssetRaffle_BuyTicketCapPerBuyer) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + id buyer = getUser(6100); + + id tokenIssuer = getUser(6200); + Asset token = qraffle.setupToken(tokenIssuer, "CAPTEST", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + uint64 ticketPrice = QRAFFLE_MIN_ASSET_TICKET_AMOUNT; + qraffle.createAssetRaffle(creator, 125000000ULL, ticketPrice, makeBundle(token, 500000)); + + // Buy up to the cap in batches + uint32 remaining = QRAFFLE_MAX_TICKETS_PER_BUYER; + uint32 batch = 100; + while (remaining > 0) + { + uint32 count = (remaining >= batch) ? batch : remaining; + uint64 payment = ticketPrice * count; + increaseEnergy(buyer, (sint64)payment); + auto r = qraffle.buyAssetRaffleTicket(buyer, 0, count, payment); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS); + remaining -= count; + } + + // One more ticket over the cap must fail + increaseEnergy(buyer, (sint64)ticketPrice); + auto rOver = qraffle.buyAssetRaffleTicket(buyer, 0, 1, ticketPrice); + EXPECT_EQ(rOver.returnCode, QRAFFLE_TICKET_LIMIT_REACHED); +} + +// ── TEST 11: buyAssetRaffleTicket – multiple buyers accumulate pool ─────────── +TEST(ContractQraffle, AssetRaffle_BuyTicketMultipleBuyers) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6200); + Asset token = qraffle.setupToken(tokenIssuer, "MBTEST", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + uint64 ticketPrice = QRAFFLE_MIN_ASSET_TICKET_AMOUNT; + qraffle.createAssetRaffle(creator, 125000000ULL, ticketPrice, makeBundle(token, 500000)); + + uint32 numBuyers = 10; + uint32 ticketsEach = 5; + uint64 expectedPool = 0; + + for (uint32 i = 0; i < numBuyers; i++) + { + id buyer = getUser(7000 + i); + uint64 payment = ticketPrice * ticketsEach; + increaseEnergy(buyer, (sint64)payment); + auto r = qraffle.buyAssetRaffleTicket(buyer, 0, ticketsEach, payment); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS); + expectedPool += payment; + } + + qraffle.getState()->assetRafflePoolChecker(0, expectedPool, numBuyers * ticketsEach, numBuyers); +} + +// ── TEST 12: cancelAssetRaffle – creator cancels with no tickets ────────────── +TEST(ContractQraffle, AssetRaffle_CancelNoTickets) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6100); + Asset token = qraffle.setupToken(tokenIssuer, "CNTEST", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + uint64 ticketPrice = QRAFFLE_MIN_ASSET_TICKET_AMOUNT; + auto cr = qraffle.createAssetRaffle(creator, 125000000ULL, ticketPrice, makeBundle(token, 500000)); + EXPECT_EQ(cr.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 1u); + + // Cancel must succeed + auto cancel = qraffle.cancelAssetRaffle(creator, 0); + EXPECT_EQ(cancel.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 0u); + + // Shares should be back with creator: setupToken gave 1M, 500K escrowed → all 1M back now + EXPECT_EQ(numberOfPossessedShares(token.assetName, tokenIssuer, creator, creator, + QRAFFLE_CONTRACT_INDEX, QRAFFLE_CONTRACT_INDEX), 1000000); +} + +// ── TEST 13: cancelAssetRaffle – cannot cancel when tickets sold ────────────── +TEST(ContractQraffle, AssetRaffle_CancelWithTicketsFails) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + id buyer = getUser(6100); + + id tokenIssuer = getUser(6200); + Asset token = qraffle.setupToken(tokenIssuer, "CTTEST", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + uint64 ticketPrice = QRAFFLE_MIN_ASSET_TICKET_AMOUNT; + qraffle.createAssetRaffle(creator, 125000000ULL, ticketPrice, makeBundle(token, 500000)); + + uint64 payment = ticketPrice * 2; + increaseEnergy(buyer, (sint64)payment); + qraffle.buyAssetRaffleTicket(buyer, 0, 2, payment); + + auto cancel = qraffle.cancelAssetRaffle(creator, 0); + EXPECT_EQ(cancel.returnCode, QRAFFLE_CANCEL_NOT_ALLOWED); + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 1u); +} + +// ── TEST 14: cancelAssetRaffle – non-creator cannot cancel ─────────────────── +TEST(ContractQraffle, AssetRaffle_CancelByNonCreatorFails) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + id other = getUser(6100); + qraffle.registerDAOMember(other); + + id tokenIssuer = getUser(6200); + Asset token = qraffle.setupToken(tokenIssuer, "NCTEST", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(token, 500000)); + + auto cancel = qraffle.cancelAssetRaffle(other, 0); + EXPECT_NE(cancel.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 1u); +} + +// ── TEST 15: cancelAssetRaffle – invalid index ──────────────────────────────── +TEST(ContractQraffle, AssetRaffle_CancelInvalidIndex) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6100); + Asset token = qraffle.setupToken(tokenIssuer, "CITEST", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(token, 500000)); + + auto cancel = qraffle.cancelAssetRaffle(creator, 9999); + EXPECT_EQ(cancel.returnCode, QRAFFLE_INVALID_ASSET_RAFFLE); +} + +// ── TEST 15b: cancelAssetRaffle – swap-and-pop keeps buyer mapping consistent ── +TEST(ContractQraffle, AssetRaffle_CancelSwapPopMaintainsBuyerState) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + id buyer = getUser(6100); + + id issuerA = getUser(6200); + id issuerB = getUser(6300); + Asset tokenA = qraffle.setupToken(issuerA, "SWPA", 1000000, creator); + Asset tokenB = qraffle.setupToken(issuerB, "SWPB", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE * 2 + 1000); + + // Raffle 0 has no buyers and will be cancelled. + auto r0 = qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, makeBundle(tokenA, 500000)); + EXPECT_EQ(r0.returnCode, QRAFFLE_SUCCESS); + // Raffle 1 has one buyer; this raffle should move to slot 0 after cancelling raffle 0. + auto r1 = qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, makeBundle(tokenB, 500000)); + EXPECT_EQ(r1.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 2u); + + increaseEnergy(buyer, (sint64)QRAFFLE_MIN_ASSET_TICKET_AMOUNT * 2); + auto b1 = qraffle.buyAssetRaffleTicket(buyer, 1, 1, QRAFFLE_MIN_ASSET_TICKET_AMOUNT); + EXPECT_EQ(b1.returnCode, QRAFFLE_SUCCESS); + + auto cancel = qraffle.cancelAssetRaffle(creator, 0); + EXPECT_EQ(cancel.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 1u); + + // After swap-and-pop, buyer should still be recognized as existing buyer in slot 0. + // Buying again must accumulate tickets without creating a second buyer slot. + auto b2 = qraffle.buyAssetRaffleTicket(buyer, 0, 1, QRAFFLE_MIN_ASSET_TICKET_AMOUNT); + EXPECT_EQ(b2.returnCode, QRAFFLE_SUCCESS); + + qraffle.getState()->assetRaffleBuyerChecker(0, buyer, true, 2); + qraffle.getState()->assetRafflePoolChecker(0, QRAFFLE_MIN_ASSET_TICKET_AMOUNT * 2, 2, 1); +} + +// ── TEST 16: END_EPOCH – reserve met, winner receives 100 % of bundle ───────── +TEST(ContractQraffle, AssetRaffle_EndEpoch_ReserveMet) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + // DAO members: creator + buyers + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + uint32 numBuyers = 20; + std::vector buyers; + for (uint32 i = 0; i < numBuyers; i++) + { + buyers.push_back(getUser(7000 + i)); + } + + id tokenIssuer = getUser(6100); + Asset token = qraffle.setupToken(tokenIssuer, "WRTEST", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + + // reservePrice = 100M Qu → creator wants 100M after 20% fee → gross needed ≥ 125M + uint64 ticketPrice = 10000000ULL; // 10M + uint64 reservePrice = 125000000ULL; // 125M: (gross * 80) >= (125M * 100) → gross ≥ 156.25M + // 20 buyers × 10M = 200M gross (above the threshold) + qraffle.createAssetRaffle(creator, reservePrice, ticketPrice, makeBundle(token, 500000)); + + uint64 totalPool = 0; + for (const auto& b : buyers) + { + uint64 payment = ticketPrice; + increaseEnergy(b, (sint64)payment); + auto r = qraffle.buyAssetRaffleTicket(b, 0, 1, payment); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS); + totalPool += payment; + } + + uint64 creatorBalBefore = (uint64)getBalance(creator); + qraffle.endEpoch(); + + // Raffle should now be cleared + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 0u); + EXPECT_EQ(qraffle.getState()->getNumberOfEndedAssetRaffles(), 1u); + + // Ended raffle recorded as reserve met + qraffle.getState()->endedAssetRaffleChecker(0, true, creator, totalPool, 200); + + // Creator should have received 80% of pool + uint64 expectedCreatorPay = (totalPool * QRAFFLE_ASSET_RAFFLE_CREATOR_PCT) / 100; + EXPECT_GE((uint64)getBalance(creator), creatorBalBefore + expectedCreatorPay - totalPool / 100); + + // Analytics: 1 created, 1 succeeded, 0 failed + qraffle.getState()->assetRaffleAnalyticsChecker(1, 1, 0); +} + +// ── TEST 17: END_EPOCH – reserve NOT met, full refund + bundle return ───────── +TEST(ContractQraffle, AssetRaffle_EndEpoch_ReserveNotMet) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id buyer1 = getUser(7000); + id buyer2 = getUser(7001); + + id tokenIssuer = getUser(6100); + Asset token = qraffle.setupToken(tokenIssuer, "RFTEST", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + + // Set a very high reserve that won't be reached with just 2 buyers + uint64 ticketPrice = QRAFFLE_MIN_ASSET_TICKET_AMOUNT; // 1M + uint64 reservePrice = 1000000000000ULL; // 1T – far above 2M gross + qraffle.createAssetRaffle(creator, reservePrice, ticketPrice, makeBundle(token, 500000)); + + // Two buyers each buy 1 ticket → gross = 2M + for (const auto& b : {buyer1, buyer2}) + { + increaseEnergy(b, (sint64)ticketPrice); + qraffle.buyAssetRaffleTicket(b, 0, 1, ticketPrice); + } + + sint64 buyer1BalBefore = getBalance(buyer1); + sint64 buyer2BalBefore = getBalance(buyer2); + + qraffle.endEpoch(); + + // Raffle resolved as failed (reserve not met) + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 0u); + EXPECT_EQ(qraffle.getState()->getNumberOfEndedAssetRaffles(), 1u); + qraffle.getState()->endedAssetRaffleChecker(0, false, creator, ticketPrice * 2, 200); + + // Both buyers refunded + EXPECT_EQ(getBalance(buyer1), buyer1BalBefore + (sint64)ticketPrice); + EXPECT_EQ(getBalance(buyer2), buyer2BalBefore + (sint64)ticketPrice); + + // Bundle returned to creator: setupToken gave creator 1M shares, 500K were escrowed → all 1M back now + EXPECT_EQ(numberOfPossessedShares(token.assetName, tokenIssuer, creator, creator, + QRAFFLE_CONTRACT_INDEX, QRAFFLE_CONTRACT_INDEX), 1000000); + + // Analytics: 1 created, 0 succeeded, 1 failed + qraffle.getState()->assetRaffleAnalyticsChecker(1, 0, 1); +} + +// ── TEST 18: END_EPOCH – zero buyers, reserve not met → return bundle ───────── +TEST(ContractQraffle, AssetRaffle_EndEpoch_NoBuyers) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6100); + Asset token = qraffle.setupToken(tokenIssuer, "NOTEST", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(token, 500000)); + + qraffle.endEpoch(); + + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 0u); + qraffle.getState()->endedAssetRaffleChecker(0, false, creator, 0, 200); + qraffle.getState()->assetRaffleAnalyticsChecker(1, 0, 1); + + // Bundle must have been returned: setupToken gave 1M, 500K escrowed → all 1M back now + EXPECT_EQ(numberOfPossessedShares(token.assetName, tokenIssuer, creator, creator, + QRAFFLE_CONTRACT_INDEX, QRAFFLE_CONTRACT_INDEX), 1000000); +} + +// ── TEST 19: END_EPOCH – Qu fee distribution (burn/charity/shareholders/DAO) ── +TEST(ContractQraffle, AssetRaffle_EndEpoch_FeeSplit) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + // Need DAO members for register fee split + auto daoMembers = registerDAOUsers(qraffle, 10, 5000); + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + uint32 numBuyers = 30; + + id tokenIssuer = getUser(6100); + Asset token = qraffle.setupToken(tokenIssuer, "FEETEST", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + uint64 ticketPrice = 50000000ULL; // 50M + uint64 reservePrice = 125000000ULL; + qraffle.createAssetRaffle(creator, reservePrice, ticketPrice, makeBundle(token, 500000)); + + uint64 totalPool = 0; + for (uint32 i = 0; i < numBuyers; i++) + { + id b = getUser(7000 + i); + increaseEnergy(b, (sint64)ticketPrice); + qraffle.buyAssetRaffleTicket(b, 0, 1, ticketPrice); + totalPool += ticketPrice; + } + + qraffle.endEpoch(); + auto analyticsAfterEpoch = qraffle.getAssetRaffleAnalytics(); + + EXPECT_EQ(analyticsAfterEpoch.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(analyticsAfterEpoch.totalAssetRafflesCreated, 1u); + EXPECT_EQ(analyticsAfterEpoch.totalAssetRafflesSucceeded, 1u); + EXPECT_EQ(analyticsAfterEpoch.totalAssetRafflesFailed, 0u); + + // Creator paid + refunded (0 on success) + proposal fees = total inflow + uint64 totalInflow = totalPool + QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE; + EXPECT_LE(analyticsAfterEpoch.totalAssetRaffleCreatorPaid, totalPool); + EXPECT_GT(analyticsAfterEpoch.totalAssetRaffleCreatorPaid, 0ULL); + // Proposal fee tracked separately + EXPECT_EQ(analyticsAfterEpoch.totalAssetRaffleProposalFees, QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE); + // Refunded should be 0 (reserve was met) + EXPECT_EQ(analyticsAfterEpoch.totalAssetRaffleRefunded, 0ULL); +} + +// ── TEST 20: END_EPOCH – reserve boundary (exactly met) ────────────────────── +TEST(ContractQraffle, AssetRaffle_EndEpoch_ReserveBoundaryExactlyMet) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6100); + Asset token = qraffle.setupToken(tokenIssuer, "BDTEST", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + + // reservePrice = 100M, so creator wants 100M after 20% fee + // The contract checks: gross * 80 >= reservePrice * 100 + // → gross >= reservePrice * 100 / 80 = reservePrice * 5 / 4 + // Use ticketPrice = 100M and 1 buyer, so gross = 100M + // 100M * 80 = 8_000_000_000 vs 100M * 100 = 10_000_000_000 → NOT met at 100M + // Minimum gross = ceil(100M * 100 / 80) = 125M exactly. + // Use ticketPrice = 125M and 1 buyer. + uint64 reservePrice = 100000000ULL; + uint64 ticketPrice = 125000000ULL; // 1 ticket → gross = 125M, boundary is exact + + qraffle.createAssetRaffle(creator, reservePrice, ticketPrice, makeBundle(token, 500000)); + + id buyer = getUser(7000); + increaseEnergy(buyer, (sint64)ticketPrice); + auto br = qraffle.buyAssetRaffleTicket(buyer, 0, 1, ticketPrice); + EXPECT_EQ(br.returnCode, QRAFFLE_SUCCESS); + + qraffle.endEpoch(); + + // Should succeed (exactly at boundary) + qraffle.getState()->endedAssetRaffleChecker(0, true, creator, ticketPrice, 200); + qraffle.getState()->assetRaffleAnalyticsChecker(1, 1, 0); +} + +// ── TEST 21: END_EPOCH – reserve boundary (one ticket below) ───────────────── +TEST(ContractQraffle, AssetRaffle_EndEpoch_ReserveBoundaryJustMissed) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6100); + Asset token = qraffle.setupToken(tokenIssuer, "BDJTEST", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + + // Same math as previous test; use ticketPrice = 124M → gross = 124M + // 124M * 80 = 9_920_000_000 vs 100M * 100 = 10_000_000_000 → NOT met + uint64 reservePrice = 100000000ULL; + uint64 ticketPrice = 124000000ULL; + + qraffle.createAssetRaffle(creator, reservePrice, ticketPrice, makeBundle(token, 500000)); + + id buyer = getUser(7000); + increaseEnergy(buyer, (sint64)ticketPrice); + qraffle.buyAssetRaffleTicket(buyer, 0, 1, ticketPrice); + + sint64 buyerBalBefore = getBalance(buyer); + qraffle.endEpoch(); + + // Should fail (below boundary) + qraffle.getState()->endedAssetRaffleChecker(0, false, creator, ticketPrice, 200); + qraffle.getState()->assetRaffleAnalyticsChecker(1, 0, 1); + // Buyer refunded + EXPECT_EQ(getBalance(buyer), buyerBalBefore + (sint64)ticketPrice); +} + +// ── TEST 22: END_EPOCH – multiple concurrent raffles ───────────────────────── +TEST(ContractQraffle, AssetRaffle_EndEpoch_MultipleConcurrent) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id tokenIssuer = getUser(6000); + increaseEnergy(tokenIssuer, 5 * 1000000000ULL); + uint64 aname = assetNameFromString("MCTEST"); + sint64 totalShares = 10000000; + qraffle.issueAsset(tokenIssuer, aname, totalShares, 0, 0); + Asset token; + token.assetName = aname; + token.issuer = tokenIssuer; + + uint32 numRaffles = 3; + std::vector creators; + for (uint32 i = 0; i < numRaffles; i++) + { + id creator = getUser(7000 + i); + qraffle.registerDAOMember(creator); + creators.push_back(creator); + + sint64 shares = 100000; + qraffle.transferShareOwnershipAndPossession(tokenIssuer, aname, tokenIssuer, shares, creator); + qraffle.TransferShareManagementRights(tokenIssuer, aname, QRAFFLE_CONTRACT_INDEX, shares, creator); + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + auto bundle = makeBundle(token, shares); + auto r = qraffle.createAssetRaffle(creator, 50000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, bundle); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS) << "raffle " << i; + } + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), numRaffles); + + // Buy enough tickets for raffle 0 and 2 to meet reserve; leave raffle 1 short + // Raffle 0: 10 buyers × 1M = 10M, reserve 50M → will FAIL + // Raffle 1: 0 buyers → will FAIL + // Raffle 2: 100 buyers × 1M = 100M, reserve 50M → gross*80=8_000M >= 50M*100=5_000M → PASS + auto buyForRaffle = [&](uint32 raffleIdx, uint32 numBuyers, uint32 buyerOffset) + { + for (uint32 i = 0; i < numBuyers; i++) + { + id b = getUser(8000 + buyerOffset + i); + increaseEnergy(b, (sint64)QRAFFLE_MIN_ASSET_TICKET_AMOUNT); + qraffle.buyAssetRaffleTicket(b, raffleIdx, 1, QRAFFLE_MIN_ASSET_TICKET_AMOUNT); + } + }; + buyForRaffle(0, 10, 0); // 10M gross → below 50M reserve + buyForRaffle(2, 100, 200); // 100M gross → above 50M reserve + + qraffle.endEpoch(); + + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 0u); + EXPECT_EQ(qraffle.getState()->getNumberOfEndedAssetRaffles(), 3u); + qraffle.getState()->assetRaffleAnalyticsChecker(3, 1, 2); +} + +// ── TEST 23: END_EPOCH – state reset after epoch ───────────────────────────── +TEST(ContractQraffle, AssetRaffle_EndEpoch_StateReset) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6100); + Asset token = qraffle.setupToken(tokenIssuer, "SRTST", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(token, 500000)); + + qraffle.endEpoch(); + + // Per-epoch maps must be cleared + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 0u); + // assetRafflesPerCreator and assetRaffleParticipation are cleared per epoch + // so the creator can create new raffles in the next epoch + system.epoch = 201; + Asset token2 = qraffle.setupToken(tokenIssuer, "SRTEST", 500000, creator); + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + auto r2 = qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(token2, 250000)); + EXPECT_EQ(r2.returnCode, QRAFFLE_SUCCESS); +} + +// ── TEST 24: END_EPOCH – DAO bucket distributed to registers ───────────────── +TEST(ContractQraffle, AssetRaffle_EndEpoch_DaoBucketDistributed) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + uint32 daoCount = 5; + auto daoMembers = registerDAOUsers(qraffle, daoCount, 5000); + + id creator = getUser(6500); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6600); + Asset token = qraffle.setupToken(tokenIssuer, "DAOTEST", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + uint64 ticketPrice = 100000000ULL; // 100M + uint64 reservePrice = 125000000ULL; + qraffle.createAssetRaffle(creator, reservePrice, ticketPrice, makeBundle(token, 500000)); + + // Buy enough to meet reserve (1 buyer × 100M ... below 125M reserve: 100*80=8000 < 125*100=12500 → fail) + // Use 2 buyers → 200M gross; 200*80=16000 >= 125*100=12500 → pass + id buyer1 = getUser(7000); + id buyer2 = getUser(7001); + increaseEnergy(buyer1, (sint64)ticketPrice); + increaseEnergy(buyer2, (sint64)ticketPrice); + qraffle.buyAssetRaffleTicket(buyer1, 0, 1, ticketPrice); + qraffle.buyAssetRaffleTicket(buyer2, 0, 1, ticketPrice); + + uint64 daoBucketBefore = qraffle.getState()->getEpochAssetRaffleDaoBucket(); + EXPECT_GT(daoBucketBefore, 0u); // proposal fee portion + + qraffle.endEpoch(); + + // After epoch the dao bucket should have been distributed (set to 0 or remainder) + // The exact value depends on divisibility; just assert it hasn't grown unexpectedly + uint64 daoBucketAfter = qraffle.getState()->getEpochAssetRaffleDaoBucket(); + // The bucket is cleared each epoch — remainder ≤ number of registers + uint32 numRegisters = qraffle.getState()->getNumberOfRegisters(); + EXPECT_LT(daoBucketAfter, (uint64)numRegisters); +} + +// ── TEST 25: getActiveAssetRaffle – view function ──────────────────────────── +TEST(ContractQraffle, AssetRaffle_GetActiveAssetRaffle) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6100); + Asset token = qraffle.setupToken(tokenIssuer, "VIEWT", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + uint64 ticketPrice = QRAFFLE_MIN_ASSET_TICKET_AMOUNT; + uint64 reservePrice = 125000000ULL; + qraffle.createAssetRaffle(creator, reservePrice, ticketPrice, makeBundle(token, 500000)); + + // Valid index + auto r = qraffle.getActiveAssetRaffle(0); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(r.creator, creator); + EXPECT_EQ(r.reservePriceQu, reservePrice); + EXPECT_EQ(r.entryTicketQu, ticketPrice); + EXPECT_EQ(r.bundleSize, 1u); + EXPECT_EQ(r.epoch, 200u); + + // Invalid index + auto rInv = qraffle.getActiveAssetRaffle(9999); + EXPECT_EQ(rInv.returnCode, QRAFFLE_INVALID_ASSET_RAFFLE); +} + +// ── TEST 26: getActiveAssetRaffleBundleItem – view function ────────────────── +TEST(ContractQraffle, AssetRaffle_GetActiveAssetRaffleBundleItem) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id issuer1 = getUser(6100); + id issuer2 = getUser(6200); + Asset t1 = qraffle.setupToken(issuer1, "BITA", 500000, creator); + Asset t2 = qraffle.setupToken(issuer2, "BITB", 300000, creator); + + std::vector> bundle = { { t1, 500000 }, { t2, 300000 } }; + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, bundle); + + auto item0 = qraffle.getActiveAssetRaffleBundleItem(0, 0); + EXPECT_EQ(item0.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(item0.assetIssuer, issuer1); + EXPECT_EQ(item0.assetName, t1.assetName); + EXPECT_EQ(item0.numberOfShares, 500000); + + auto item1 = qraffle.getActiveAssetRaffleBundleItem(0, 1); + EXPECT_EQ(item1.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(item1.assetIssuer, issuer2); + EXPECT_EQ(item1.assetName, t2.assetName); + EXPECT_EQ(item1.numberOfShares, 300000); + + // Out-of-range item + auto rInv = qraffle.getActiveAssetRaffleBundleItem(0, 999); + EXPECT_NE(rInv.returnCode, QRAFFLE_SUCCESS); +} + +// ── TEST 27: getActiveAssetRaffleBuyer – view function ─────────────────────── +TEST(ContractQraffle, AssetRaffle_GetActiveAssetRaffleBuyer) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + id buyer = getUser(6100); + + id tokenIssuer = getUser(6200); + Asset token = qraffle.setupToken(tokenIssuer, "BUYVT", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + uint64 ticketPrice = QRAFFLE_MIN_ASSET_TICKET_AMOUNT; + qraffle.createAssetRaffle(creator, 125000000ULL, ticketPrice, makeBundle(token, 500000)); + + uint32 tickets = 3; + increaseEnergy(buyer, (sint64)(ticketPrice * tickets)); + qraffle.buyAssetRaffleTicket(buyer, 0, tickets, ticketPrice * tickets); + + auto r = qraffle.getActiveAssetRaffleBuyer(0, 0); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(r.buyer, buyer); + EXPECT_EQ(r.ticketCount, tickets); + + // Invalid buyer index + auto rInv = qraffle.getActiveAssetRaffleBuyer(0, 9999); + EXPECT_NE(rInv.returnCode, QRAFFLE_SUCCESS); + + // Invalid raffle index + auto rInvR = qraffle.getActiveAssetRaffleBuyer(9999, 0); + EXPECT_NE(rInvR.returnCode, QRAFFLE_SUCCESS); +} + +// ── TEST 28: getEndedAssetRaffle – view function ───────────────────────────── +TEST(ContractQraffle, AssetRaffle_GetEndedAssetRaffle) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6100); + Asset token = qraffle.setupToken(tokenIssuer, "ENDVT", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + uint64 ticketPrice = 200000000ULL; // 200M + uint64 reservePrice = 125000000ULL; + + // 1 buyer × 200M → 200M gross; 200*80=16000 >= 125*100=12500 → reserve met + qraffle.createAssetRaffle(creator, reservePrice, ticketPrice, makeBundle(token, 500000)); + + id buyer = getUser(7000); + increaseEnergy(buyer, (sint64)ticketPrice); + qraffle.buyAssetRaffleTicket(buyer, 0, 1, ticketPrice); + + qraffle.endEpoch(); + + auto r = qraffle.getEndedAssetRaffle(0); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(r.creator, creator); + EXPECT_EQ(r.grossPoolQu, ticketPrice); + EXPECT_EQ(r.reserveMet, 1u); + EXPECT_EQ(r.epoch, 200u); + EXPECT_NE(r.epochWinner, id(0, 0, 0, 0)); // winner must be set + + // Index beyond what exists + auto rInv = qraffle.getEndedAssetRaffle(9999); + EXPECT_NE(rInv.returnCode, QRAFFLE_SUCCESS); +} + +// ── TEST 29: getAssetRaffleAnalytics – view function ───────────────────────── +TEST(ContractQraffle, AssetRaffle_GetAnalytics) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + // Analytics initially all zero + auto r0 = qraffle.getAssetRaffleAnalytics(); + EXPECT_EQ(r0.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(r0.totalAssetRafflesCreated, 0u); + EXPECT_EQ(r0.totalAssetRafflesSucceeded, 0u); + EXPECT_EQ(r0.totalAssetRafflesFailed, 0u); + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6100); + Asset token = qraffle.setupToken(tokenIssuer, "ANLTEST", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + qraffle.createAssetRaffle(creator, 1000000000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(token, 500000)); + + auto r1 = qraffle.getAssetRaffleAnalytics(); + EXPECT_EQ(r1.totalAssetRafflesCreated, 1u); + EXPECT_EQ(r1.numberOfActiveAssetRaffles, 1u); + + qraffle.endEpoch(); // no buyers → fail + + auto r2 = qraffle.getAssetRaffleAnalytics(); + EXPECT_EQ(r2.totalAssetRafflesCreated, 1u); + EXPECT_EQ(r2.totalAssetRafflesSucceeded, 0u); + EXPECT_EQ(r2.totalAssetRafflesFailed, 1u); + EXPECT_EQ(r2.numberOfActiveAssetRaffles, 0u); + EXPECT_EQ(r2.numberOfEndedAssetRaffles, 1u); +} + +// ── TEST 30: ring-buffer wrap-around for ended asset raffles ────────────────── +TEST(ContractQraffle, AssetRaffle_EndedRingBufferWrap) +{ + // Create more ended raffles than QRAFFLE_MAX_ENDED_ASSET_RAFFLES to verify + // the ring buffer wraps correctly and old entries are overwritten. + // For speed we only do a small batch (e.g. 5 wrap cycles with small buffer). + // Since QRAFFLE_MAX_ENDED_ASSET_RAFFLES = 8192 it's impractical to fill in a + // unit test; instead we verify that the ring-buffer slot formula is correct + // by checking a few ended raffles in sequence across two epochs. + + ContractTestingQraffle qraffle; + system.epoch = 200; + + id tokenIssuer = getUser(9000); + increaseEnergy(tokenIssuer, 20 * 1000000000ULL); + uint64 aname = assetNameFromString("RING"); + qraffle.issueAsset(tokenIssuer, aname, 100000000LL, 0, 0); + Asset token; + token.assetName = aname; + token.issuer = tokenIssuer; + + // Epoch 100: create 3 raffles, end epoch → 3 ended + for (uint32 i = 0; i < 3; i++) + { + id creator = getUser(7000 + i); + qraffle.registerDAOMember(creator); + sint64 shares = 10000; + qraffle.transferShareOwnershipAndPossession(tokenIssuer, aname, tokenIssuer, shares, creator); + qraffle.TransferShareManagementRights(tokenIssuer, aname, QRAFFLE_CONTRACT_INDEX, shares, creator); + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + qraffle.createAssetRaffle(creator, 1000000000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(token, shares)); + } + qraffle.endEpoch(); + EXPECT_EQ(qraffle.getState()->getNumberOfEndedAssetRaffles(), 3u); + + // Verify first 3 ended slots are accessible + for (uint32 i = 0; i < 3; i++) + { + auto r = qraffle.getEndedAssetRaffle(i); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(r.epoch, 200u); + } + + // Epoch 101: 2 more raffles + system.epoch = 201; + for (uint32 i = 0; i < 2; i++) + { + id creator = getUser(8000 + i); + qraffle.registerDAOMember(creator); + sint64 shares = 10000; + qraffle.transferShareOwnershipAndPossession(tokenIssuer, aname, tokenIssuer, shares, creator); + qraffle.TransferShareManagementRights(tokenIssuer, aname, QRAFFLE_CONTRACT_INDEX, shares, creator); + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + qraffle.createAssetRaffle(creator, 1000000000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(token, shares)); + } + qraffle.endEpoch(); + EXPECT_EQ(qraffle.getState()->getNumberOfEndedAssetRaffles(), 5u); + + // Last two entries should be epoch 201 + auto r3 = qraffle.getEndedAssetRaffle(3); + auto r4 = qraffle.getEndedAssetRaffle(4); + EXPECT_EQ(r3.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(r4.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(r3.epoch, 201u); + EXPECT_EQ(r4.epoch, 201u); +} + +// ── TEST 31: createAssetRaffle – escrow rollback on partial bundle failure ───── +TEST(ContractQraffle, AssetRaffle_CreateEscrowRollbackOnFailure) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id issuer1 = getUser(6100); + // token1: give shares to creator so escrow succeeds for item 0 + Asset t1 = qraffle.setupToken(issuer1, "RLBA", 500000, creator); + + // token2: NOT transferred to creator — escrow should fail for item 1 + id issuer2 = getUser(6200); + increaseEnergy(issuer2, 1000000000ULL); + uint64 aname2 = assetNameFromString("RLBB"); + qraffle.issueAsset(issuer2, aname2, 300000, 0, 0); + // Shares of RLBB stay with issuer2 — creator has 0 + Asset t2; + t2.assetName = aname2; + t2.issuer = issuer2; + + std::vector> bundle = { { t1, 500000 }, { t2, 300000 } }; + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + auto r = qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, bundle); + + // Must fail because creator doesn't have t2 shares + EXPECT_EQ(r.returnCode, QRAFFLE_BUNDLE_ESCROW_FAILED); + // No raffle must have been created + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 0u); + + // t1 shares must be back with creator (rollback) + EXPECT_EQ(numberOfPossessedShares(t1.assetName, issuer1, creator, creator, + QRAFFLE_CONTRACT_INDEX, QRAFFLE_CONTRACT_INDEX), 500000); +} + +// ── TEST 32: full lifecycle – create, buy, endEpoch, verify winner ──────────── +TEST(ContractQraffle, AssetRaffle_FullLifecycle) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + // Multiple token types in bundle + id issuerA = getUser(6100); + id issuerB = getUser(6200); + Asset tA = qraffle.setupToken(issuerA, "LIFA", 1000000, creator); + Asset tB = qraffle.setupToken(issuerB, "LIFB", 500000, creator); + std::vector> bundle = { { tA, 1000000 }, { tB, 500000 } }; + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + uint64 ticketPrice = 50000000ULL; // 50M + uint64 reservePrice = 125000000ULL; + auto cr = qraffle.createAssetRaffle(creator, reservePrice, ticketPrice, bundle); + EXPECT_EQ(cr.returnCode, QRAFFLE_SUCCESS); + + // Get raffle info via view + auto viewRaffle = qraffle.getActiveAssetRaffle(0); + EXPECT_EQ(viewRaffle.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(viewRaffle.bundleSize, 2u); + + // Verify bundle items via view + auto bItem0 = qraffle.getActiveAssetRaffleBundleItem(0, 0); + auto bItem1 = qraffle.getActiveAssetRaffleBundleItem(0, 1); + EXPECT_EQ(bItem0.assetIssuer, issuerA); + EXPECT_EQ(bItem1.assetIssuer, issuerB); + + // 5 buyers buy tickets — 5 × 50M = 250M gross; 250*80=20000 >= 125*100=12500 → pass + uint32 numBuyers = 5; + std::vector buyers; + uint64 totalPool = 0; + for (uint32 i = 0; i < numBuyers; i++) + { + id b = getUser(7000 + i); + buyers.push_back(b); + increaseEnergy(b, (sint64)ticketPrice); + auto br = qraffle.buyAssetRaffleTicket(b, 0, 1, ticketPrice); + EXPECT_EQ(br.returnCode, QRAFFLE_SUCCESS); + totalPool += ticketPrice; + } + + // Verify buyer view + for (uint32 i = 0; i < numBuyers; i++) + { + auto bv = qraffle.getActiveAssetRaffleBuyer(0, i); + EXPECT_EQ(bv.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(bv.ticketCount, 1u); + } + + uint64 creatorBalBefore = (uint64)getBalance(creator); + qraffle.endEpoch(); + + // Winner selection: 1 of the 5 buyers must now own both bundle assets + auto ended = qraffle.getEndedAssetRaffle(0); + EXPECT_EQ(ended.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(ended.reserveMet, 1u); + EXPECT_NE(ended.epochWinner, id(0, 0, 0, 0)); + + id winner = ended.epochWinner; + + // Verify winner holds both token types + EXPECT_EQ(numberOfPossessedShares(tA.assetName, issuerA, winner, winner, + QRAFFLE_CONTRACT_INDEX, QRAFFLE_CONTRACT_INDEX), 1000000); + EXPECT_EQ(numberOfPossessedShares(tB.assetName, issuerB, winner, winner, + QRAFFLE_CONTRACT_INDEX, QRAFFLE_CONTRACT_INDEX), 500000); + + // Creator received ~80% of pool + uint64 minCreatorPay = (totalPool * QRAFFLE_ASSET_RAFFLE_CREATOR_PCT) / 100 - 1; + EXPECT_GE((uint64)getBalance(creator), creatorBalBefore + minCreatorPay); + + // Analytics + auto analytics = qraffle.getAssetRaffleAnalytics(); + EXPECT_EQ(analytics.totalAssetRafflesCreated, 1u); + EXPECT_EQ(analytics.totalAssetRafflesSucceeded, 1u); + EXPECT_EQ(analytics.totalAssetRafflesFailed, 0u); +} + +// ── TEST 33: createAssetRaffle – non-DAO member fails ──────────────────────── +TEST(ContractQraffle, AssetRaffle_CreateNonDAOMemberFails) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id nonMember = getUser(9000); + // NOT registered as DAO member + + id tokenIssuer = getUser(9100); + increaseEnergy(tokenIssuer, 1000000000ULL); + uint64 aname = assetNameFromString("NDMT"); + qraffle.issueAsset(tokenIssuer, aname, 1000000, 0, 0); + Asset token; + token.assetName = aname; + token.issuer = tokenIssuer; + + increaseEnergy(nonMember, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + auto r = qraffle.createAssetRaffle(nonMember, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(token, 100000)); + EXPECT_EQ(r.returnCode, QRAFFLE_UNREGISTERED); + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 0u); +} + +// ── TEST 34: buyAssetRaffleTicket – unregistered buyer can buy ─────────────── +TEST(ContractQraffle, AssetRaffle_BuyTicketUnregisteredBuyerAllowed) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6200); + Asset token = qraffle.setupToken(tokenIssuer, "UNBUY", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + uint64 ticketPrice = QRAFFLE_MIN_ASSET_TICKET_AMOUNT; + auto cr = qraffle.createAssetRaffle(creator, 125000000ULL, ticketPrice, makeBundle(token, 500000)); + EXPECT_EQ(cr.returnCode, QRAFFLE_SUCCESS); + + // Buyer is NOT registered as a DAO member + id buyer = getUser(9999); + increaseEnergy(buyer, (sint64)(ticketPrice * 2)); + auto br = qraffle.buyAssetRaffleTicket(buyer, 0, 2, ticketPrice * 2); + EXPECT_EQ(br.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(br.ticketsBought, 2u); + + qraffle.getState()->assetRaffleBuyerChecker(0, buyer, true, 2); + qraffle.getState()->assetRafflePoolChecker(0, ticketPrice * 2, 2, 1); +} + +// ── TEST 35: buyAssetRaffleTicket – successive purchases from same buyer ─────── +TEST(ContractQraffle, AssetRaffle_BuyTicketSuccessivePurchases) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + id buyer = getUser(6100); + + id tokenIssuer = getUser(6200); + Asset token = qraffle.setupToken(tokenIssuer, "SUCBUY", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + uint64 ticketPrice = QRAFFLE_MIN_ASSET_TICKET_AMOUNT; + qraffle.createAssetRaffle(creator, 125000000ULL, ticketPrice, makeBundle(token, 500000)); + + // First purchase + increaseEnergy(buyer, (sint64)(ticketPrice * 5)); + auto r1 = qraffle.buyAssetRaffleTicket(buyer, 0, 5, ticketPrice * 5); + EXPECT_EQ(r1.returnCode, QRAFFLE_SUCCESS); + qraffle.getState()->assetRaffleBuyerChecker(0, buyer, true, 5); + + // Second purchase (same buyer accumulates) + increaseEnergy(buyer, (sint64)(ticketPrice * 3)); + auto r2 = qraffle.buyAssetRaffleTicket(buyer, 0, 3, ticketPrice * 3); + EXPECT_EQ(r2.returnCode, QRAFFLE_SUCCESS); + qraffle.getState()->assetRaffleBuyerChecker(0, buyer, true, 8); + qraffle.getState()->assetRafflePoolChecker(0, ticketPrice * 8, 8, 1); +} + +// ── TEST 35: cross-check analytics before and after epoch ───────────────────── +TEST(ContractQraffle, AssetRaffle_AnalyticsCrossCheckMultipleEpochs) +{ + ContractTestingQraffle qraffle; + + id tokenIssuer = getUser(9000); + increaseEnergy(tokenIssuer, 10 * 1000000000ULL); + uint64 aname = assetNameFromString("ACCT"); + qraffle.issueAsset(tokenIssuer, aname, 100000000LL, 0, 0); + Asset token; + token.assetName = aname; + token.issuer = tokenIssuer; + + uint32 totalCreated = 0; + uint32 totalSucceeded = 0; + uint32 totalFailed = 0; + + for (uint32 epoch = 200; epoch < 203; epoch++) + { + system.epoch = (uint16)epoch; + + // 2 creators per epoch + for (uint32 c = 0; c < 2; c++) + { + id creator = getUser(7000 + epoch * 10 + c); + qraffle.registerDAOMember(creator); + sint64 shares = 10000; + qraffle.transferShareOwnershipAndPossession(tokenIssuer, aname, tokenIssuer, shares, creator); + qraffle.TransferShareManagementRights(tokenIssuer, aname, QRAFFLE_CONTRACT_INDEX, shares, creator); + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + + uint64 reservePrice = (c == 0) ? QRAFFLE_MIN_ASSET_TICKET_AMOUNT * 5 : 1000000000000ULL; + auto r = qraffle.createAssetRaffle(creator, reservePrice, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(token, shares)); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS); + totalCreated++; + + // Buy 10 tickets for raffle 0 (low reserve) → will succeed + if (c == 0) + { + for (uint32 b = 0; b < 10; b++) + { + id buyer = getUser(8000 + epoch * 100 + c * 10 + b); + increaseEnergy(buyer, (sint64)QRAFFLE_MIN_ASSET_TICKET_AMOUNT); + qraffle.buyAssetRaffleTicket(buyer, c, 1, QRAFFLE_MIN_ASSET_TICKET_AMOUNT); + } + } + // Raffle 1 has very high reserve → will fail (no buyers) + } + + qraffle.endEpoch(); + totalSucceeded += 1; // c==0 raffle should succeed + totalFailed += 1; // c==1 raffle should fail (no buyers or tiny pool) + + auto a = qraffle.getAssetRaffleAnalytics(); + EXPECT_EQ(a.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(a.totalAssetRafflesCreated, totalCreated); + EXPECT_EQ(a.totalAssetRafflesSucceeded, totalSucceeded); + EXPECT_EQ(a.totalAssetRafflesFailed, totalFailed); + } } \ No newline at end of file From f76123154f2905dae5dbaf093c22a80857663b70 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Fri, 8 May 2026 09:18:45 +0900 Subject: [PATCH 12/16] fix: qraffle test file --- test/CMakeLists.txt | 1 + test/contract_qraffle.cpp | 24 +++++++++++++++--------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d429b8716..2424ea757 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -33,6 +33,7 @@ add_executable( # contract_qearn.cpp # contract_qvault.cpp # contract_qx.cpp + contract_qraffle.cpp contract_vottunbridge.cpp # kangaroo_twelve.cpp m256.cpp diff --git a/test/contract_qraffle.cpp b/test/contract_qraffle.cpp index 4d6f9b6be..b7bf4e580 100644 --- a/test/contract_qraffle.cpp +++ b/test/contract_qraffle.cpp @@ -661,12 +661,14 @@ TEST(ContractQraffle, VoteInProposal) proposalCount++; uint32 yesVotes = 0, noVotes = 0; + bit users0OriginalVote = 0; // Test voting - for (const auto& user : users) + for (size_t vi = 0; vi < users.size(); vi++) { bit vote = (bit)random(0, 2); - auto result = qraffle.voteInProposal(user, 0, vote); + if (vi == 0) users0OriginalVote = vote; + auto result = qraffle.voteInProposal(users[vi], 0, vote); EXPECT_EQ(result.returnCode, QRAFFLE_SUCCESS); if (vote) @@ -677,8 +679,8 @@ TEST(ContractQraffle, VoteInProposal) qraffle.getState()->voteChecker(0, yesVotes, noVotes); } - // Test duplicate vote (should change vote) - bit newVote = (bit)random(0, 2); + // Test duplicate vote: explicitly use the opposite direction to guarantee the vote changes + bit newVote = users0OriginalVote ? (bit)0 : (bit)1; auto result = qraffle.voteInProposal(users[0], 0, newVote); EXPECT_EQ(result.returnCode, QRAFFLE_SUCCESS); @@ -974,8 +976,8 @@ TEST(ContractQraffle, GetFunctions) auto registers2 = qraffle.getRegisters(registerCount + 10, 5); EXPECT_EQ(registers2.returnCode, QRAFFLE_INVALID_OFFSET_OR_LIMIT); - // Test with limit exceeding maximum (1024) - auto registers3 = qraffle.getRegisters(0, 1025); + // Test with limit exceeding maximum (20) + auto registers3 = qraffle.getRegisters(0, 21); EXPECT_EQ(registers3.returnCode, QRAFFLE_INVALID_OFFSET_OR_LIMIT); // Test with offset + limit exceeding total registers @@ -1110,12 +1112,16 @@ TEST(ContractQraffle, GetFunctions) // Test with current epoch (0) auto endedQuRaffle = qraffle.getEndedQuRaffle(0); EXPECT_EQ(endedQuRaffle.returnCode, QRAFFLE_SUCCESS); - EXPECT_NE(endedQuRaffle.epochWinner, id(0, 0, 0, 0)); // Winner should be set - EXPECT_GT(endedQuRaffle.receivedAmount, 0); - EXPECT_EQ(endedQuRaffle.entryAmount, 10000000); EXPECT_EQ(endedQuRaffle.numberOfMembers, memberCount); EXPECT_GT(endedQuRaffle.numberOfDaoMembers, 0u); EXPECT_EQ(endedQuRaffle.numberOfDaoMembers, qraffle.getState()->getNumberOfRegisters()); + // Winner and prize are only set when at least one member participated + if (memberCount > 0) + { + EXPECT_NE(endedQuRaffle.epochWinner, id(0, 0, 0, 0)); + EXPECT_GT(endedQuRaffle.receivedAmount, 0); + EXPECT_EQ(endedQuRaffle.entryAmount, 10000000); + } // Test with future epoch auto futureQuRaffle = qraffle.getEndedQuRaffle(1); From 27cbc08da2bba7813f81a27dc0a6b5ae3e44cd91 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Tue, 12 May 2026 12:30:04 +0100 Subject: [PATCH 13/16] update: gtest qraffle --- test/contract_qraffle.cpp | 794 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 794 insertions(+) diff --git a/test/contract_qraffle.cpp b/test/contract_qraffle.cpp index 1ba591a54..244646f2b 100644 --- a/test/contract_qraffle.cpp +++ b/test/contract_qraffle.cpp @@ -3361,4 +3361,798 @@ TEST(ContractQraffle, AssetRaffle_AnalyticsCrossCheckMultipleEpochs) EXPECT_EQ(a.totalAssetRafflesSucceeded, totalSucceeded); EXPECT_EQ(a.totalAssetRafflesFailed, totalFailed); } +} + +// ============================================================================ +// Extended / Power Tests +// ============================================================================ + +// ── TEST: InitialRegisters_CannotLogout ────────────────────────────────────── +// The 5 bootstrap registers baked into INITIALIZE must never be able to logout. +TEST(ContractQraffle, InitialRegisters_CannotLogout) +{ + ContractTestingQraffle qraffle; + + // Pull the 5 initial register IDs directly from contract state. + QRaffleChecker* s = qraffle.getState(); + id ir[5] = { + s->initialRegister1, s->initialRegister2, s->initialRegister3, + s->initialRegister4, s->initialRegister5 + }; + + EXPECT_EQ(s->numberOfRegisters, 5u); + + for (const auto& reg : ir) + { + auto result = qraffle.logoutInSystem(reg); + EXPECT_EQ(result.returnCode, QRAFFLE_INITIAL_REGISTER_CANNOT_LOGOUT); + } + + // Count must remain 5. + EXPECT_EQ(s->numberOfRegisters, 5u); +} + +// ── TEST: SubmitProposal_ReservedTokenRejected ─────────────────────────────── +// QRAFFLE shares and QXMR tokens must be rejected as proposal targets. +TEST(ContractQraffle, SubmitProposal_ReservedTokenRejected) +{ + ContractTestingQraffle qraffle; + + id member = getUser(2000); + increaseEnergy(member, QRAFFLE_REGISTER_AMOUNT); + qraffle.registerInSystem(member, QRAFFLE_REGISTER_AMOUNT, 0); + + // Build QRAFFLE-share asset descriptor (issuer = NULL_ID). + Asset qraffleShare; + qraffleShare.assetName = QRAFFLE_ASSET_NAME; + qraffleShare.issuer = NULL_ID; + + auto r1 = qraffle.submitProposal(member, qraffleShare, QRAFFLE_MIN_QRAFFLE_AMOUNT); + EXPECT_EQ(r1.returnCode, QRAFFLE_INVALID_TOKEN_TYPE); + + // Build QXMR asset descriptor. + Asset qxmr; + qxmr.assetName = QRAFFLE_QXMR_ASSET_NAME; + qxmr.issuer = qraffle.getState()->QXMRIssuer; + + auto r2 = qraffle.submitProposal(member, qxmr, QRAFFLE_MIN_QRAFFLE_AMOUNT); + EXPECT_EQ(r2.returnCode, QRAFFLE_INVALID_TOKEN_TYPE); + + // Normal token should still work. + id tokenIssuer = getUser(3000); + increaseEnergy(tokenIssuer, 1000000000ULL); + uint64 aname = assetNameFromString("RSVTEST"); + qraffle.issueAsset(tokenIssuer, aname, 1000000, 0, 0); + Asset good; + good.assetName = aname; + good.issuer = tokenIssuer; + auto r3 = qraffle.submitProposal(member, good, QRAFFLE_MIN_QRAFFLE_AMOUNT); + EXPECT_EQ(r3.returnCode, QRAFFLE_SUCCESS); +} + +// ── TEST: VoteInProposal_SameDirectionRejected ─────────────────────────────── +// Voting the same direction twice must return QRAFFLE_ALREADY_VOTED. +TEST(ContractQraffle, VoteInProposal_SameDirectionRejected) +{ + ContractTestingQraffle qraffle; + + id member = getUser(2000); + increaseEnergy(member, QRAFFLE_REGISTER_AMOUNT); + qraffle.registerInSystem(member, QRAFFLE_REGISTER_AMOUNT, 0); + + id tokenIssuer = getUser(3000); + increaseEnergy(tokenIssuer, 1000000000ULL); + uint64 aname = assetNameFromString("SDTEST"); + qraffle.issueAsset(tokenIssuer, aname, 1000000, 0, 0); + Asset token{ aname, tokenIssuer }; + + qraffle.submitProposal(member, token, QRAFFLE_MIN_QRAFFLE_AMOUNT); + + // First vote: yes. + EXPECT_EQ(qraffle.voteInProposal(member, 0, 1).returnCode, QRAFFLE_SUCCESS); + qraffle.getState()->voteChecker(0, 1, 0); + + // Same direction again: must fail. + EXPECT_EQ(qraffle.voteInProposal(member, 0, 1).returnCode, QRAFFLE_ALREADY_VOTED); + qraffle.getState()->voteChecker(0, 1, 0); +} + +// ── TEST: VoteInProposal_FlipToOppositeDirection ───────────────────────────── +// Voting the opposite direction from a prior vote must flip counters exactly. +TEST(ContractQraffle, VoteInProposal_FlipToOppositeDirection) +{ + ContractTestingQraffle qraffle; + + // Register enough voters for a meaningful flip scenario. + const uint32 numVoters = 6; + id voters[numVoters]; + for (uint32 i = 0; i < numVoters; i++) + { + voters[i] = getUser(2000 + i); + increaseEnergy(voters[i], QRAFFLE_REGISTER_AMOUNT); + qraffle.registerInSystem(voters[i], QRAFFLE_REGISTER_AMOUNT, 0); + } + + id tokenIssuer = getUser(3000); + increaseEnergy(tokenIssuer, 1000000000ULL); + uint64 aname = assetNameFromString("FLIPT"); + qraffle.issueAsset(tokenIssuer, aname, 1000000, 0, 0); + Asset token{ aname, tokenIssuer }; + + qraffle.submitProposal(voters[0], token, QRAFFLE_MIN_QRAFFLE_AMOUNT); + + // 4 yes, 2 no. + for (uint32 i = 0; i < 4; i++) + EXPECT_EQ(qraffle.voteInProposal(voters[i], 0, 1).returnCode, QRAFFLE_SUCCESS); + for (uint32 i = 4; i < 6; i++) + EXPECT_EQ(qraffle.voteInProposal(voters[i], 0, 0).returnCode, QRAFFLE_SUCCESS); + qraffle.getState()->voteChecker(0, 4, 2); + + // Voter 0 flips from yes → no: nYes becomes 3, nNo becomes 3. + EXPECT_EQ(qraffle.voteInProposal(voters[0], 0, 0).returnCode, QRAFFLE_SUCCESS); + qraffle.getState()->voteChecker(0, 3, 3); + + // Voter 4 flips from no → yes: nYes becomes 4, nNo becomes 2. + EXPECT_EQ(qraffle.voteInProposal(voters[4], 0, 1).returnCode, QRAFFLE_SUCCESS); + qraffle.getState()->voteChecker(0, 4, 2); +} + +// ── TEST: SubmitEntryAmount_BoundaryValues ──────────────────────────────────── +// Exactly-at-boundary amounts must succeed; one unit outside must fail. +TEST(ContractQraffle, SubmitEntryAmount_BoundaryValues) +{ + ContractTestingQraffle qraffle; + + id member = getUser(2000); + increaseEnergy(member, QRAFFLE_REGISTER_AMOUNT); + qraffle.registerInSystem(member, QRAFFLE_REGISTER_AMOUNT, 0); + + // Below minimum. + EXPECT_EQ(qraffle.submitEntryAmount(member, QRAFFLE_MIN_QRAFFLE_AMOUNT - 1).returnCode, + QRAFFLE_INVALID_ENTRY_AMOUNT); + + // Exactly minimum. + EXPECT_EQ(qraffle.submitEntryAmount(member, QRAFFLE_MIN_QRAFFLE_AMOUNT).returnCode, + QRAFFLE_SUCCESS); + + // Exactly maximum. + EXPECT_EQ(qraffle.submitEntryAmount(member, QRAFFLE_MAX_QRAFFLE_AMOUNT).returnCode, + QRAFFLE_SUCCESS); + + // Above maximum. + EXPECT_EQ(qraffle.submitEntryAmount(member, (uint64)QRAFFLE_MAX_QRAFFLE_AMOUNT + 1).returnCode, + QRAFFLE_INVALID_ENTRY_AMOUNT); +} + +// ── TEST: EndEpoch_ProposalRejectedWhenNoWins ───────────────────────────────── +// A proposal where nNo >= nYes must NOT produce an active token raffle. +TEST(ContractQraffle, EndEpoch_ProposalRejectedWhenNoWins) +{ + ContractTestingQraffle qraffle; + + const uint32 numVoters = 6; + id voters[numVoters]; + for (uint32 i = 0; i < numVoters; i++) + { + voters[i] = getUser(2000 + i); + increaseEnergy(voters[i], QRAFFLE_REGISTER_AMOUNT); + qraffle.registerInSystem(voters[i], QRAFFLE_REGISTER_AMOUNT, 0); + } + + id tokenIssuer = getUser(3000); + increaseEnergy(tokenIssuer, 1000000000ULL); + uint64 aname = assetNameFromString("NOWINS"); + qraffle.issueAsset(tokenIssuer, aname, 1000000, 0, 0); + Asset token{ aname, tokenIssuer }; + + qraffle.submitProposal(voters[0], token, QRAFFLE_MIN_QRAFFLE_AMOUNT); + + // 2 yes, 4 no → nNo > nYes, proposal should be rejected. + for (uint32 i = 0; i < 2; i++) + qraffle.voteInProposal(voters[i], 0, 1); + for (uint32 i = 2; i < 6; i++) + qraffle.voteInProposal(voters[i], 0, 0); + qraffle.getState()->voteChecker(0, 2, 4); + + qraffle.endEpoch(); + + EXPECT_EQ(qraffle.getState()->getNumberOfActiveTokenRaffle(), 0u); +} + +// ── TEST: EndEpoch_qREAmountCalculation ────────────────────────────────────── +// After endEpoch, qREAmount should equal the arithmetic mean of all submitted +// entry amounts (integer division, as the contract uses div). +TEST(ContractQraffle, EndEpoch_qREAmountCalculation) +{ + ContractTestingQraffle qraffle; + + const uint32 numMembers = 5; + uint64 amounts[numMembers] = { + QRAFFLE_MIN_QRAFFLE_AMOUNT, + QRAFFLE_MIN_QRAFFLE_AMOUNT * 2, + QRAFFLE_MIN_QRAFFLE_AMOUNT * 3, + QRAFFLE_MIN_QRAFFLE_AMOUNT * 4, + QRAFFLE_MIN_QRAFFLE_AMOUNT * 5 + }; + uint64 totalAmount = 0; + for (uint32 i = 0; i < numMembers; i++) totalAmount += amounts[i]; + + id members[numMembers]; + for (uint32 i = 0; i < numMembers; i++) + { + members[i] = getUser(2000 + i); + increaseEnergy(members[i], QRAFFLE_REGISTER_AMOUNT); + qraffle.registerInSystem(members[i], QRAFFLE_REGISTER_AMOUNT, 0); + EXPECT_EQ(qraffle.submitEntryAmount(members[i], amounts[i]).returnCode, QRAFFLE_SUCCESS); + } + + qraffle.endEpoch(); + + uint64 expected = totalAmount / numMembers; + EXPECT_EQ(qraffle.getState()->getQuRaffleEntryAmount(), expected); +} + +// ── TEST: EndEpoch_QuRaffleEmptyNoWinner ───────────────────────────────────── +// endEpoch with 0 QuRaffle members must complete without crash; no winner +// is recorded in the QuRaffles ring for that epoch. +TEST(ContractQraffle, EndEpoch_QuRaffleEmptyNoWinner) +{ + ContractTestingQraffle qraffle; + system.epoch = 10; + + // Register a member but do NOT deposit into QuRaffle. + id member = getUser(2000); + increaseEnergy(member, QRAFFLE_REGISTER_AMOUNT); + qraffle.registerInSystem(member, QRAFFLE_REGISTER_AMOUNT, 0); + + // Should not crash. + qraffle.endEpoch(); + + // No winner recorded. + auto info = qraffle.getEndedQuRaffle(10); + EXPECT_EQ(info.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(info.numberOfMembers, 0u); + EXPECT_EQ(info.epochWinner, id(0, 0, 0, 0)); + + // qREAmount falls back to default when no submissions. + EXPECT_EQ(qraffle.getState()->getQuRaffleEntryAmount(), QRAFFLE_DEFAULT_QRAFFLE_AMOUNT); +} + +// ── TEST: MultipleEpochs_StateResetAndReuse ─────────────────────────────────── +// Verify that per-epoch state (proposals, votes, QuRaffle members) is fully +// cleared after endEpoch and new activity in the next epoch works correctly. +TEST(ContractQraffle, MultipleEpochs_StateResetAndReuse) +{ + ContractTestingQraffle qraffle; + system.epoch = 5; + + const uint32 numMembers = 10; + id members[numMembers]; + for (uint32 i = 0; i < numMembers; i++) + { + members[i] = getUser(2000 + i); + increaseEnergy(members[i], QRAFFLE_REGISTER_AMOUNT); + qraffle.registerInSystem(members[i], QRAFFLE_REGISTER_AMOUNT, 0); + } + + id tokenIssuer = getUser(3000); + increaseEnergy(tokenIssuer, 2000000000ULL); + uint64 aname1 = assetNameFromString("MRST1"); + uint64 aname2 = assetNameFromString("MRST2"); + qraffle.issueAsset(tokenIssuer, aname1, 1000000000, 0, 0); + qraffle.issueAsset(tokenIssuer, aname2, 1000000000, 0, 0); + + Asset token1{ aname1, tokenIssuer }; + Asset token2{ aname2, tokenIssuer }; + + // ── Epoch 5: submit proposal, vote yes, add QuRaffle members ────────────── + qraffle.submitProposal(members[0], token1, QRAFFLE_MIN_QRAFFLE_AMOUNT); + for (uint32 i = 0; i < numMembers; i++) + qraffle.voteInProposal(members[i], 0, 1); + qraffle.getState()->voteChecker(0, numMembers, 0); + + for (uint32 i = 0; i < numMembers; i++) + { + increaseEnergy(members[i], qraffle.getState()->getQuRaffleEntryAmount()); + EXPECT_EQ(qraffle.depositInQuRaffle(members[i], qraffle.getState()->getQuRaffleEntryAmount()).returnCode, + QRAFFLE_SUCCESS); + } + EXPECT_EQ(qraffle.getState()->numberOfQuRaffleMembers, numMembers); + + qraffle.endEpoch(); + + // Epoch 5 data persists in QuRaffles ring. + auto quResult5 = qraffle.getEndedQuRaffle(5); + EXPECT_EQ(quResult5.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(quResult5.numberOfMembers, numMembers); + EXPECT_NE(quResult5.epochWinner, id(0, 0, 0, 0)); + + // Per-epoch state cleared. + EXPECT_EQ(qraffle.getState()->numberOfQuRaffleMembers, 0u); + EXPECT_EQ(qraffle.getState()->numberOfProposals, 0u); + EXPECT_EQ(qraffle.getState()->numberOfEntryAmountSubmitted, 0u); + + // ── Epoch 6: fresh proposal and QuRaffle round ──────────────────────────── + system.epoch = 6; + qraffle.submitProposal(members[1], token2, QRAFFLE_MIN_QRAFFLE_AMOUNT * 2); + EXPECT_EQ(qraffle.getState()->numberOfProposals, 1u); + // Check the new proposal is for token2. + EXPECT_EQ(qraffle.getState()->proposals.get(0).token.assetName, aname2); + + for (uint32 i = 0; i < numMembers; i++) + EXPECT_EQ(qraffle.voteInProposal(members[i], 0, 1).returnCode, QRAFFLE_SUCCESS); + + for (uint32 i = 0; i < numMembers; i++) + { + increaseEnergy(members[i], qraffle.getState()->getQuRaffleEntryAmount()); + EXPECT_EQ(qraffle.depositInQuRaffle(members[i], qraffle.getState()->getQuRaffleEntryAmount()).returnCode, + QRAFFLE_SUCCESS); + } + + qraffle.endEpoch(); + + auto quResult6 = qraffle.getEndedQuRaffle(6); + EXPECT_EQ(quResult6.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(quResult6.numberOfMembers, numMembers); + + // Token2 raffle should now be active. + EXPECT_EQ(qraffle.getState()->getNumberOfActiveTokenRaffle(), 1u); + EXPECT_EQ(qraffle.getState()->activeTokenRaffle.get(0).token.assetName, aname2); +} + +// ── TEST: TokenRaffle_WinnerReceivesTokensMinusFees ────────────────────────── +// At endEpoch, exactly the winner's share of tokens must be transferred to the +// winner; the fee percentages must match constants defined in the header. +TEST(ContractQraffle, TokenRaffle_WinnerReceivesTokensMinusFees) +{ + ContractTestingQraffle qraffle; + system.epoch = 5; + + const uint32 numMembers = 8; + id members[numMembers]; + for (uint32 i = 0; i < numMembers; i++) + { + members[i] = getUser(2000 + i); + increaseEnergy(members[i], QRAFFLE_REGISTER_AMOUNT); + qraffle.registerInSystem(members[i], QRAFFLE_REGISTER_AMOUNT, 0); + } + + id tokenIssuer = getUser(3000); + increaseEnergy(tokenIssuer, 2000000000ULL); + uint64 aname = assetNameFromString("WINTOK"); + sint64 totalShares = 1000000000LL; + qraffle.issueAsset(tokenIssuer, aname, totalShares, 0, 0); + Asset token{ aname, tokenIssuer }; + + // Propose and vote yes. + uint64 entryAmount = 5000000ULL; + qraffle.submitProposal(members[0], token, entryAmount); + for (uint32 i = 0; i < numMembers; i++) + qraffle.voteInProposal(members[i], 0, 1); + + // Add QuRaffle deposit so endEpoch doesn't crash on empty QuRaffle. + increaseEnergy(members[0], qraffle.getState()->getQuRaffleEntryAmount()); + qraffle.depositInQuRaffle(members[0], qraffle.getState()->getQuRaffleEntryAmount()); + + qraffle.endEpoch(); + + // Token raffle is now active. + ASSERT_EQ(qraffle.getState()->getNumberOfActiveTokenRaffle(), 1u); + EXPECT_EQ(qraffle.getState()->activeTokenRaffle.get(0).entryAmount, entryAmount); + + // All numMembers deposit into the token raffle. + for (uint32 i = 0; i < numMembers; i++) + { + increaseEnergy(members[i], QRAFFLE_TRANSFER_SHARE_FEE); + // Give each member entryAmount shares under QRAFFLE management. + qraffle.transferShareOwnershipAndPossession(tokenIssuer, aname, tokenIssuer, (sint64)entryAmount, members[i]); + qraffle.TransferShareManagementRights(tokenIssuer, aname, QRAFFLE_CONTRACT_INDEX, (sint64)entryAmount, members[i]); + EXPECT_EQ(qraffle.depositInTokenRaffle(members[i], 0, QRAFFLE_TRANSFER_SHARE_FEE).returnCode, QRAFFLE_SUCCESS); + } + + // Deposit one more QuRaffle participant for the next epoch. + increaseEnergy(members[0], qraffle.getState()->getQuRaffleEntryAmount()); + qraffle.depositInQuRaffle(members[0], qraffle.getState()->getQuRaffleEntryAmount()); + + system.epoch = 6; + qraffle.endEpoch(); + + // Exactly 1 token raffle must have ended. + EXPECT_EQ(qraffle.getState()->getNumberOfEndedTokenRaffle(), 1u); + auto ended = qraffle.getEndedTokenRaffle(0); + EXPECT_EQ(ended.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(ended.numberOfMembers, numMembers); + EXPECT_NE(ended.epochWinner, id(0, 0, 0, 0)); + + // Compute expected winner share. + uint64 pool = (uint64)entryAmount * numMembers; + uint64 burn = pool * QRAFFLE_BURN_FEE / 100; + uint64 charity = pool * QRAFFLE_CHARITY_FEE / 100; + uint64 shareholder = (pool * QRAFFLE_SHAREHOLDER_FEE / 100) / NUMBER_OF_COMPUTORS * NUMBER_OF_COMPUTORS; + uint32 numRegisters = qraffle.getState()->getNumberOfRegisters(); + uint64 reg = (pool * QRAFFLE_REGISTER_FEE / 100) / numRegisters * numRegisters; + uint64 fee = pool * QRAFFLE_FEE / 100; + uint64 expectedWinner = pool - burn - charity - shareholder - reg - fee; + + id winner = ended.epochWinner; + sint64 winnerShares = numberOfPossessedShares(aname, tokenIssuer, winner, winner, + QRAFFLE_CONTRACT_INDEX, QRAFFLE_CONTRACT_INDEX); + EXPECT_EQ((uint64)winnerShares, expectedWinner); +} + +// ── TEST: AssetRaffle_DuplicateAssetInBundle ────────────────────────────────── +// A bundle containing the same asset twice must be rejected with QRAFFLE_INVALID_BUNDLE. +TEST(ContractQraffle, AssetRaffle_DuplicateAssetInBundle) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6100); + Asset token = qraffle.setupToken(tokenIssuer, "DUPBND", 2000000, creator); + + // Two slots with the same asset. + std::vector> dupBundle = { + { token, 500000 }, + { token, 500000 } + }; + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + auto r = qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, dupBundle); + EXPECT_EQ(r.returnCode, QRAFFLE_INVALID_BUNDLE); + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 0u); +} + +// ── TEST: AssetRaffle_ReservedTokenInBundle ─────────────────────────────────── +// Bundles containing QRAFFLE shares or QXMR must be rejected. +TEST(ContractQraffle, AssetRaffle_ReservedTokenInBundle) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + // QRAFFLE share descriptor. + Asset qraffleShare; + qraffleShare.assetName = QRAFFLE_ASSET_NAME; + qraffleShare.issuer = NULL_ID; + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE * 4 + 1000); + + auto r1 = qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(qraffleShare, 100)); + EXPECT_EQ(r1.returnCode, QRAFFLE_INVALID_BUNDLE); + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 0u); + + // QXMR descriptor. + Asset qxmr; + qxmr.assetName = QRAFFLE_QXMR_ASSET_NAME; + qxmr.issuer = qraffle.getState()->QXMRIssuer; + + auto r2 = qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(qxmr, 100)); + EXPECT_EQ(r2.returnCode, QRAFFLE_INVALID_BUNDLE); + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 0u); +} + +// ── TEST: AssetRaffle_ZeroSharesInBundle ───────────────────────────────────── +// A bundle item with numberOfShares == 0 (or negative) must be rejected. +TEST(ContractQraffle, AssetRaffle_ZeroOrNegativeSharesInBundle) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6100); + Asset token = qraffle.setupToken(tokenIssuer, "ZERSHR", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE * 4 + 1000); + + // Zero shares. + auto r1 = qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(token, 0)); + EXPECT_EQ(r1.returnCode, QRAFFLE_INVALID_BUNDLE); + + // Negative shares. + auto r2 = qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(token, -1)); + EXPECT_EQ(r2.returnCode, QRAFFLE_INVALID_BUNDLE); + + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), 0u); +} + +// ── TEST: AssetRaffle_CreateAfterCancel_SameEpoch ──────────────────────────── +// After cancelling a raffle the per-creator counter is decremented, so the +// creator can immediately open a replacement within the same epoch. +TEST(ContractQraffle, AssetRaffle_CreateAfterCancel_SameEpoch) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6100); + Asset token = qraffle.setupToken(tokenIssuer, "REPTEST", 2000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE * 4 + 10000); + + // Fill the per-creator limit. + for (uint32 i = 0; i < QRAFFLE_MAX_ASSET_RAFFLES_PER_CREATOR; i++) + { + auto r = qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(token, 100)); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS) << "slot " << i; + } + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), QRAFFLE_MAX_ASSET_RAFFLES_PER_CREATOR); + + // Cancel raffle 0 (no buyers). + EXPECT_EQ(qraffle.cancelAssetRaffle(creator, 0).returnCode, QRAFFLE_SUCCESS); + + // Creator should now be able to open one more replacement raffle. + auto r = qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(token, 100)); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(qraffle.getState()->getNumberOfActiveAssetRaffles(), QRAFFLE_MAX_ASSET_RAFFLES_PER_CREATOR); +} + +// ── TEST: AssetRaffle_ReservePrice_OverflowGuard ───────────────────────────── +// A reserve price so large that reservePriceQu * 100 would overflow uint64 +// must be rejected with QRAFFLE_INVALID_RESERVE_PRICE. +TEST(ContractQraffle, AssetRaffle_ReservePrice_OverflowGuard) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6100); + Asset token = qraffle.setupToken(tokenIssuer, "OFGRD", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + + // Max uint64 / 100 + 1 should trigger the overflow guard. + uint64 overflowReserve = 0xFFFFFFFFFFFFFFFFull / 100ull + 1ull; + auto r = qraffle.createAssetRaffle(creator, overflowReserve, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(token, 500000)); + EXPECT_EQ(r.returnCode, QRAFFLE_INVALID_RESERVE_PRICE); +} + +// ── TEST: AssetRaffle_BuyTicketInvalidAmount ────────────────────────────────── +// Buying more than QRAFFLE_MAX_TICKETS_PER_BUYER in a single call should fail. +TEST(ContractQraffle, AssetRaffle_BuyTicketInvalidAmount) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6200); + Asset token = qraffle.setupToken(tokenIssuer, "INVAMT", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 1000); + uint64 ticketPrice = QRAFFLE_MIN_ASSET_TICKET_AMOUNT; + qraffle.createAssetRaffle(creator, 125000000ULL, ticketPrice, makeBundle(token, 500000)); + + id buyer = getUser(7000); + uint32 tooMany = QRAFFLE_MAX_TICKETS_PER_BUYER + 1; + uint64 payment = ticketPrice * tooMany; + increaseEnergy(buyer, (sint64)payment); + auto r = qraffle.buyAssetRaffleTicket(buyer, 0, tooMany, payment); + EXPECT_NE(r.returnCode, QRAFFLE_SUCCESS); +} + +// ── TEST: QuRaffle_DepositDoesNotRequireDAOMembership ──────────────────────── +// depositInQuRaffle requires only sufficient Qu — DAO membership is not required. +TEST(ContractQraffle, QuRaffle_DepositDoesNotRequireDAOMembership) +{ + ContractTestingQraffle qraffle; + + // Non-DAO member with enough Qu. + id nonMember = getUser(9999); + increaseEnergy(nonMember, qraffle.getState()->getQuRaffleEntryAmount() + 100); + auto r = qraffle.depositInQuRaffle(nonMember, qraffle.getState()->getQuRaffleEntryAmount()); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(qraffle.getState()->numberOfQuRaffleMembers, 1u); +} + +// ── TEST: EndEpoch_QuRaffleAmountAverageUpdateAndReset ─────────────────────── +// qREAmount resets to QRAFFLE_DEFAULT_QRAFFLE_AMOUNT when no entries submitted. +TEST(ContractQraffle, EndEpoch_qREAmountResetsToDefaultWhenNoSubmissions) +{ + ContractTestingQraffle qraffle; + + // Submit one entry to change qREAmount from default. + id member = getUser(2000); + increaseEnergy(member, QRAFFLE_REGISTER_AMOUNT); + qraffle.registerInSystem(member, QRAFFLE_REGISTER_AMOUNT, 0); + EXPECT_EQ(qraffle.submitEntryAmount(member, QRAFFLE_MAX_QRAFFLE_AMOUNT).returnCode, QRAFFLE_SUCCESS); + + qraffle.endEpoch(); + // After epoch, qREAmount should be QRAFFLE_MAX_QRAFFLE_AMOUNT (single entry). + EXPECT_EQ(qraffle.getState()->getQuRaffleEntryAmount(), (uint64)QRAFFLE_MAX_QRAFFLE_AMOUNT); + + // Second epoch: no new submissions. + qraffle.endEpoch(); + // No entries → falls back to default. + EXPECT_EQ(qraffle.getState()->getQuRaffleEntryAmount(), QRAFFLE_DEFAULT_QRAFFLE_AMOUNT); +} + +// ── TEST: TokenRaffle_DepositSlotFull ──────────────────────────────────────── +// Once a token raffle's member slot (QRAFFLE_TOKEN_RAFFLE_SLOT_SIZE) is full, +// further deposits must return QRAFFLE_MAX_MEMBER_REACHED. +// NOTE: QRAFFLE_TOKEN_RAFFLE_SLOT_SIZE = 512, so we register 512 members. +TEST(ContractQraffle, TokenRaffle_DepositSlotFull) +{ + ContractTestingQraffle qraffle; + system.epoch = 5; + + // Create a token. + id tokenIssuer = getUser(100); + increaseEnergy(tokenIssuer, 2000000000ULL); + uint64 aname = assetNameFromString("SLOTFLL"); + sint64 totalShares = 1000000000LL; + qraffle.issueAsset(tokenIssuer, aname, totalShares, 0, 0); + Asset token{ aname, tokenIssuer }; + + // Register one proposer and vote to activate the token raffle. + const uint32 numVoters = 10; + id voters[numVoters]; + for (uint32 i = 0; i < numVoters; i++) + { + voters[i] = getUser(200 + i); + increaseEnergy(voters[i], QRAFFLE_REGISTER_AMOUNT); + qraffle.registerInSystem(voters[i], QRAFFLE_REGISTER_AMOUNT, 0); + } + + uint64 entryAmt = 1000000ULL; + qraffle.submitProposal(voters[0], token, entryAmt); + for (uint32 i = 0; i < numVoters; i++) + qraffle.voteInProposal(voters[i], 0, 1); + + increaseEnergy(voters[0], qraffle.getState()->getQuRaffleEntryAmount()); + qraffle.depositInQuRaffle(voters[0], qraffle.getState()->getQuRaffleEntryAmount()); + qraffle.endEpoch(); + + ASSERT_EQ(qraffle.getState()->getNumberOfActiveTokenRaffle(), 1u); + + // Register and deposit QRAFFLE_TOKEN_RAFFLE_SLOT_SIZE members. + const uint32 slotSize = QRAFFLE_TOKEN_RAFFLE_SLOT_SIZE; + for (uint32 i = 0; i < slotSize; i++) + { + id m = getUser(10000 + i); + increaseEnergy(m, QRAFFLE_REGISTER_AMOUNT + QRAFFLE_TRANSFER_SHARE_FEE); + qraffle.registerInSystem(m, QRAFFLE_REGISTER_AMOUNT, 0); + qraffle.transferShareOwnershipAndPossession(tokenIssuer, aname, tokenIssuer, (sint64)entryAmt, m); + qraffle.TransferShareManagementRights(tokenIssuer, aname, QRAFFLE_CONTRACT_INDEX, (sint64)entryAmt, m); + auto r = qraffle.depositInTokenRaffle(m, 0, QRAFFLE_TRANSFER_SHARE_FEE); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS) << "member " << i; + } + EXPECT_EQ(qraffle.getState()->numberOfTokenRaffleMembers.get(0), slotSize); + + // One more deposit must fail with MAX_MEMBER_REACHED. + id extra = getUser(20000); + increaseEnergy(extra, QRAFFLE_REGISTER_AMOUNT + QRAFFLE_TRANSFER_SHARE_FEE); + qraffle.registerInSystem(extra, QRAFFLE_REGISTER_AMOUNT, 0); + qraffle.transferShareOwnershipAndPossession(tokenIssuer, aname, tokenIssuer, (sint64)entryAmt, extra); + qraffle.TransferShareManagementRights(tokenIssuer, aname, QRAFFLE_CONTRACT_INDEX, (sint64)entryAmt, extra); + auto overflow = qraffle.depositInTokenRaffle(extra, 0, QRAFFLE_TRANSFER_SHARE_FEE); + EXPECT_EQ(overflow.returnCode, QRAFFLE_MAX_MEMBER_REACHED); +} + +// ── TEST: getRegisters_PaginationCorrect ───────────────────────────────────── +// Verify that paginated getRegisters returns contiguous, non-overlapping slices. +TEST(ContractQraffle, getRegisters_PaginationCorrect) +{ + ContractTestingQraffle qraffle; + + // Register exactly 20 extra users (initial 5 already present). + const uint32 extra = 20; + for (uint32 i = 0; i < extra; i++) + { + id u = getUser(5000 + i); + increaseEnergy(u, QRAFFLE_REGISTER_AMOUNT); + EXPECT_EQ(qraffle.registerInSystem(u, QRAFFLE_REGISTER_AMOUNT, 0).returnCode, QRAFFLE_SUCCESS); + } + const uint32 total = 5 + extra; + + // Page 0: offset=0, limit=10. + auto page0 = qraffle.getRegisters(0, 10); + EXPECT_EQ(page0.returnCode, QRAFFLE_SUCCESS); + + // Page 1: offset=10, limit=10. + auto page1 = qraffle.getRegisters(10, 10); + EXPECT_EQ(page1.returnCode, QRAFFLE_SUCCESS); + + // Page 2: offset=20, limit=5. (remaining 5) + auto page2 = qraffle.getRegisters(20, 5); + EXPECT_EQ(page2.returnCode, QRAFFLE_SUCCESS); + + // offset + limit > total → invalid. + auto bad = qraffle.getRegisters(20, 6); + EXPECT_EQ(bad.returnCode, QRAFFLE_INVALID_OFFSET_OR_LIMIT); + + // limit > 20 → invalid. + auto bad2 = qraffle.getRegisters(0, 21); + EXPECT_EQ(bad2.returnCode, QRAFFLE_INVALID_OFFSET_OR_LIMIT); +} + +// ── TEST: AssetRaffle_ProposalFeeNonRefundable ──────────────────────────────── +// When a raffle is cancelled the 500K proposal fee must NOT be returned. +TEST(ContractQraffle, AssetRaffle_ProposalFeeNonRefundable) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6100); + Asset token = qraffle.setupToken(tokenIssuer, "NRFUND", 1000000, creator); + + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE + 5000); + sint64 balBefore = getBalance(creator); + + auto cr = qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(token, 500000)); + EXPECT_EQ(cr.returnCode, QRAFFLE_SUCCESS); + sint64 balAfterCreate = getBalance(creator); + EXPECT_EQ(balBefore - balAfterCreate, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE); + + // Cancel with no tickets sold. + EXPECT_EQ(qraffle.cancelAssetRaffle(creator, 0).returnCode, QRAFFLE_SUCCESS); + + // Balance must NOT have recovered the proposal fee. + sint64 balAfterCancel = getBalance(creator); + EXPECT_EQ(balAfterCancel, balAfterCreate); // unchanged (no refund) +} + +// ── TEST: AssetRaffle_MultipleEpochs_PerCreatorCounterReset ────────────────── +// After endEpoch, assetRafflesPerCreator resets so a creator can start fresh. +TEST(ContractQraffle, AssetRaffle_MultipleEpochs_PerCreatorCounterReset) +{ + ContractTestingQraffle qraffle; + system.epoch = 200; + + id creator = getUser(6000); + qraffle.registerDAOMember(creator); + + id tokenIssuer = getUser(6100); + + // Epoch 200: fill per-creator limit. + increaseEnergy(tokenIssuer, 5 * 1000000000ULL); + Asset t200 = qraffle.setupToken(tokenIssuer, "MER200", 10000000, creator); + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE * (QRAFFLE_MAX_ASSET_RAFFLES_PER_CREATOR + 2) + 10000); + + for (uint32 i = 0; i < QRAFFLE_MAX_ASSET_RAFFLES_PER_CREATOR; i++) + { + auto r = qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(t200, 100)); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS) << "epoch 200 raffle " << i; + } + auto rOver = qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(t200, 100)); + EXPECT_EQ(rOver.returnCode, QRAFFLE_MAX_ASSET_RAFFLES_REACHED); + + qraffle.endEpoch(); + + // Epoch 201: counter should be cleared → creator can open fresh raffles. + system.epoch = 201; + increaseEnergy(tokenIssuer, 5 * 1000000000ULL); + Asset t201 = qraffle.setupToken(tokenIssuer, "MER201", 10000000, creator); + increaseEnergy(creator, (sint64)QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE * (QRAFFLE_MAX_ASSET_RAFFLES_PER_CREATOR + 1) + 10000); + + for (uint32 i = 0; i < QRAFFLE_MAX_ASSET_RAFFLES_PER_CREATOR; i++) + { + auto r = qraffle.createAssetRaffle(creator, 125000000ULL, QRAFFLE_MIN_ASSET_TICKET_AMOUNT, + makeBundle(t201, 100)); + EXPECT_EQ(r.returnCode, QRAFFLE_SUCCESS) << "epoch 201 raffle " << i; + } } \ No newline at end of file From 80b34199f4ef292f6b62c03b0b89dec57598dc8d Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Wed, 13 May 2026 13:29:47 +0100 Subject: [PATCH 14/16] chore: update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index bf4f57667..a35e10f7f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ x64/ .DS_Store .clang-format tmp +build/ From 78b3eddb06ab5d124b9254cbb71face048190b80 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Thu, 14 May 2026 17:30:47 +0100 Subject: [PATCH 15/16] fix:qraffle test file --- test/contract_qraffle.cpp | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/test/contract_qraffle.cpp b/test/contract_qraffle.cpp index 39a47d4ac..c4150a23b 100644 --- a/test/contract_qraffle.cpp +++ b/test/contract_qraffle.cpp @@ -245,7 +245,9 @@ class QRaffleChecker : public QRAFFLE, public QRAFFLE::StateData } EXPECT_EQ(found, expectPresent); if (expectPresent && expectedTickets > 0) + { EXPECT_EQ(foundTickets, expectedTickets); + } } void assetRafflePoolChecker(uint32 index, uint64 expectedGross, uint32 expectedTotalTickets, uint32 expectedBuyers) @@ -2224,7 +2226,6 @@ TEST(ContractQraffle, AssetRaffle_BuyTicketRefundExcess) EXPECT_EQ(br.returnCode, QRAFFLE_SUCCESS); // Buyer should have been refunded the overpayment for 2 extra tickets - sint64 expectedRefund = (sint64)(ticketPrice * 2); EXPECT_EQ(getBalance(buyer), balanceBefore - (sint64)(ticketPrice * 2)); qraffle.getState()->assetRaffleBuyerChecker(0, buyer, true, 2); @@ -2649,7 +2650,6 @@ TEST(ContractQraffle, AssetRaffle_EndEpoch_FeeSplit) EXPECT_EQ(analyticsAfterEpoch.totalAssetRafflesFailed, 0u); // Creator paid + refunded (0 on success) + proposal fees = total inflow - uint64 totalInflow = totalPool + QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE; EXPECT_LE(analyticsAfterEpoch.totalAssetRaffleCreatorPaid, totalPool); EXPECT_GT(analyticsAfterEpoch.totalAssetRaffleCreatorPaid, 0ULL); // Proposal fee tracked separately @@ -3450,7 +3450,7 @@ TEST(ContractQraffle, VoteInProposal_SameDirectionRejected) increaseEnergy(tokenIssuer, 1000000000ULL); uint64 aname = assetNameFromString("SDTEST"); qraffle.issueAsset(tokenIssuer, aname, 1000000, 0, 0); - Asset token{ aname, tokenIssuer }; + Asset token{ tokenIssuer, aname }; qraffle.submitProposal(member, token, QRAFFLE_MIN_QRAFFLE_AMOUNT); @@ -3483,7 +3483,7 @@ TEST(ContractQraffle, VoteInProposal_FlipToOppositeDirection) increaseEnergy(tokenIssuer, 1000000000ULL); uint64 aname = assetNameFromString("FLIPT"); qraffle.issueAsset(tokenIssuer, aname, 1000000, 0, 0); - Asset token{ aname, tokenIssuer }; + Asset token{ tokenIssuer, aname }; qraffle.submitProposal(voters[0], token, QRAFFLE_MIN_QRAFFLE_AMOUNT); @@ -3549,7 +3549,7 @@ TEST(ContractQraffle, EndEpoch_ProposalRejectedWhenNoWins) increaseEnergy(tokenIssuer, 1000000000ULL); uint64 aname = assetNameFromString("NOWINS"); qraffle.issueAsset(tokenIssuer, aname, 1000000, 0, 0); - Asset token{ aname, tokenIssuer }; + Asset token{ tokenIssuer, aname }; qraffle.submitProposal(voters[0], token, QRAFFLE_MIN_QRAFFLE_AMOUNT); @@ -3648,8 +3648,8 @@ TEST(ContractQraffle, MultipleEpochs_StateResetAndReuse) qraffle.issueAsset(tokenIssuer, aname1, 1000000000, 0, 0); qraffle.issueAsset(tokenIssuer, aname2, 1000000000, 0, 0); - Asset token1{ aname1, tokenIssuer }; - Asset token2{ aname2, tokenIssuer }; + Asset token1{ tokenIssuer, aname1 }; + Asset token2{ tokenIssuer, aname2 }; // ── Epoch 5: submit proposal, vote yes, add QuRaffle members ────────────── qraffle.submitProposal(members[0], token1, QRAFFLE_MIN_QRAFFLE_AMOUNT); @@ -3728,7 +3728,7 @@ TEST(ContractQraffle, TokenRaffle_WinnerReceivesTokensMinusFees) uint64 aname = assetNameFromString("WINTOK"); sint64 totalShares = 1000000000LL; qraffle.issueAsset(tokenIssuer, aname, totalShares, 0, 0); - Asset token{ aname, tokenIssuer }; + Asset token{ tokenIssuer, aname }; // Propose and vote yes. uint64 entryAmount = 5000000ULL; @@ -4006,7 +4006,7 @@ TEST(ContractQraffle, TokenRaffle_DepositSlotFull) uint64 aname = assetNameFromString("SLOTFLL"); sint64 totalShares = 1000000000LL; qraffle.issueAsset(tokenIssuer, aname, totalShares, 0, 0); - Asset token{ aname, tokenIssuer }; + Asset token{ tokenIssuer, aname }; // Register one proposer and vote to activate the token raffle. const uint32 numVoters = 10; @@ -4067,8 +4067,6 @@ TEST(ContractQraffle, getRegisters_PaginationCorrect) increaseEnergy(u, QRAFFLE_REGISTER_AMOUNT); EXPECT_EQ(qraffle.registerInSystem(u, QRAFFLE_REGISTER_AMOUNT, 0).returnCode, QRAFFLE_SUCCESS); } - const uint32 total = 5 + extra; - // Page 0: offset=0, limit=10. auto page0 = qraffle.getRegisters(0, 10); EXPECT_EQ(page0.returnCode, QRAFFLE_SUCCESS); From 85ee0d269ba2631d88bcd95234627191b580568c Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Thu, 28 May 2026 12:56:15 +0900 Subject: [PATCH 16/16] fix: issues and remove gitignore --- .gitignore | 1 - src/contracts/QRaffle.h | 187 ++++++++++++++++++-------------------- test/contract_qraffle.cpp | 62 +++++++++---- 3 files changed, 135 insertions(+), 115 deletions(-) diff --git a/.gitignore b/.gitignore index a35e10f7f..bf4f57667 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,3 @@ x64/ .DS_Store .clang-format tmp -build/ diff --git a/src/contracts/QRaffle.h b/src/contracts/QRaffle.h index 916360e31..2993b8987 100644 --- a/src/contracts/QRaffle.h +++ b/src/contracts/QRaffle.h @@ -22,7 +22,6 @@ constexpr uint32 QRAFFLE_MAX_QRAFFLE_AMOUNT = 1000000000ull; // Ended token-raffle ring: 16 384 slots × ~96 B ≈ 1.5 MB. // At most QRAFFLE_MAX_PROPOSAL_EPOCH (128) raffles/epoch → covers ~128 epochs of history. constexpr uint32 QRAFFLE_MAX_TOKEN_RAFFLES = 16384; -constexpr uint32 QRAFFLE_MAX_SHAREHOLDERS = 1024; constexpr uint32 QRAFFLE_TOKEN_RAFFLE_SLOT_SIZE = 512; // 2^9, max members per token raffle constexpr uint8 QRAFFLE_MAX_PROPOSALS_PER_PROPOSER = 3; // max proposals per user per epoch @@ -59,7 +58,6 @@ constexpr sint32 QRAFFLE_CANCEL_NOT_ALLOWED = 27; // Asset Raffle configuration constexpr uint64 QRAFFLE_ASSET_RAFFLE_PROPOSAL_FEE = 500000ull; // 500K Qu; non-refundable anti-spam -constexpr uint32 QRAFFLE_ASSET_RAFFLE_CREATOR_PCT = 80; // creator's share of Qu pool (%) constexpr uint32 QRAFFLE_MAX_ASSET_RAFFLES_PER_EPOCH = 64; // concurrent active raffles constexpr uint32 QRAFFLE_MAX_ASSETS_PER_BUNDLE = 4; // items per bundle constexpr uint32 QRAFFLE_MAX_ASSET_TICKET_BUYERS = 1024; // distinct buyers per raffle @@ -330,7 +328,6 @@ struct QRAFFLE : public ContractBase Array QuRaffles; Array tokenRaffle; HashMap quRaffleEntryAmount; - HashSet shareholdersList; id initialRegister1, initialRegister2, initialRegister3, initialRegister4, initialRegister5; id charityAddress, feeAddress, QXMRIssuer; @@ -1733,9 +1730,9 @@ struct QRAFFLE : public ContractBase output.returnCode = QRAFFLE_INVALID_OFFSET_OR_LIMIT; return ; } - if (input.offset + input.limit > state.get().numberOfRegisters) + if (input.offset >= state.get().numberOfRegisters) { - output.returnCode = QRAFFLE_INVALID_OFFSET_OR_LIMIT; + output.returnCode = QRAFFLE_SUCCESS; return ; } locals.idx = state.get().registers.nextElementIndex(NULL_INDEX); @@ -2245,7 +2242,8 @@ struct QRAFFLE : public ContractBase uint32 arJ; uint32 arBuyerSlot; uint32 arWinnerIndex; - uint32 arEndedIdx; + uint32 arEndedIdx; // ring-buffer slot (masked) the settled raffle is written to + uint32 arEndedGlobalIdx; // monotonic global index callers pass to getEndedAssetRaffle bit arReserveMet; }; @@ -2269,22 +2267,10 @@ struct QRAFFLE : public ContractBase locals.digest.u64._2 ^ locals.computerDigest.u64._2, locals.digest.u64._3 ^ locals.computerDigest.u64._3)); - // Build the shareholder set for token-raffle and QXMR distributions. + // Asset descriptor reused by the QXMR distribution and per-token-raffle shareholder + // payout loops below; both iterate QRAFFLE_ASSET possessors directly via locals.iter. locals.QraffleAsset.assetName = QRAFFLE_ASSET_NAME; locals.QraffleAsset.issuer = NULL_ID; - locals.iter.begin(locals.QraffleAsset); - while (!locals.iter.reachedEnd()) - { - if (locals.iter.numberOfPossessedShares() > 0) - { - locals.shareholder = locals.iter.possessor(); - if (state.get().shareholdersList.contains(locals.shareholder) == 0) - { - state.mut().shareholdersList.add(locals.shareholder); - } - } - locals.iter.next(); - } if (state.get().numberOfQuRaffleMembers > 0) { @@ -2369,40 +2355,39 @@ struct QRAFFLE : public ContractBase 0 }; LOG_INFO(locals.endEpochLog); + } + else + { + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_emptyQuRaffle, 0 }; + LOG_INFO(locals.log); + } - // Distribute QXMR to shareholders. Only deduct what was actually transferred. - locals.qxmrPerShare = div(state.get().epochQXMRRevenue, NUMBER_OF_COMPUTORS); - locals.qxmrDistributedTotal = 0; - if (locals.qxmrPerShare > 0) + locals.qxmrPerShare = div(state.get().epochQXMRRevenue, NUMBER_OF_COMPUTORS); + locals.qxmrDistributedTotal = 0; + if (locals.qxmrPerShare > 0) + { + locals.qxmrContractBalance = (uint64)qpi.numberOfPossessedShares(QRAFFLE_QXMR_ASSET_NAME, state.get().QXMRIssuer, SELF, SELF, SELF_INDEX, SELF_INDEX); + locals.iter.begin(locals.QraffleAsset); + while (!locals.iter.reachedEnd()) { - locals.qxmrContractBalance = (uint64)qpi.numberOfPossessedShares(QRAFFLE_QXMR_ASSET_NAME, state.get().QXMRIssuer, SELF, SELF, SELF_INDEX, SELF_INDEX); - locals.iter.begin(locals.QraffleAsset); - while (!locals.iter.reachedEnd()) + locals.sharesHeld = locals.iter.numberOfPossessedShares(); + if (locals.sharesHeld > 0) { - locals.sharesHeld = locals.iter.numberOfPossessedShares(); - if (locals.sharesHeld > 0) + locals.shareholder = locals.iter.possessor(); + locals.perShare = (sint64)(locals.qxmrPerShare * (uint64)locals.sharesHeld); + if ((uint64)locals.perShare <= locals.qxmrContractBalance - locals.qxmrDistributedTotal) { - locals.shareholder = locals.iter.possessor(); - locals.perShare = (sint64)(locals.qxmrPerShare * (uint64)locals.sharesHeld); - if ((uint64)locals.perShare <= locals.qxmrContractBalance - locals.qxmrDistributedTotal) + locals.transferResult = qpi.transferShareOwnershipAndPossession(QRAFFLE_QXMR_ASSET_NAME, state.get().QXMRIssuer, SELF, SELF, locals.perShare, locals.shareholder); + if (locals.transferResult >= 0) { - locals.transferResult = qpi.transferShareOwnershipAndPossession(QRAFFLE_QXMR_ASSET_NAME, state.get().QXMRIssuer, SELF, SELF, locals.perShare, locals.shareholder); - if (locals.transferResult >= 0) - { - locals.qxmrDistributedTotal += (uint64)locals.perShare; - } + locals.qxmrDistributedTotal += (uint64)locals.perShare; } } - locals.iter.next(); } + locals.iter.next(); } - state.mut().epochQXMRRevenue -= locals.qxmrDistributedTotal; - } - else - { - locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_emptyQuRaffle, 0 }; - LOG_INFO(locals.log); } + state.mut().epochQXMRRevenue -= locals.qxmrDistributedTotal; // Process each active token raffle. for (locals.i = 0 ; locals.i < state.get().numberOfActiveTokenRaffle; locals.i++) @@ -2549,59 +2534,59 @@ struct QRAFFLE : public ContractBase } } - // Transfer each bundle item to the winner. If an item fails, log and continue; - // we cannot pull assets back from a user's wallet, so the winner keeps whatever - // was delivered and the remaining escrowed items stay in the contract. - for (locals.arJ = 0; locals.arJ < locals.arInfo.bundleSize; locals.arJ++) - { - locals.arItem = state.get().activeAssetRaffleItems.get( - locals.arI * QRAFFLE_MAX_ASSETS_PER_BUNDLE + locals.arJ); - locals.transferResult = qpi.transferShareOwnershipAndPossession( - locals.arItem.asset.assetName, locals.arItem.asset.issuer, - SELF, SELF, - locals.arItem.numberOfShares, locals.winner); - if (locals.transferResult < 0) + // Transfer each bundle item to the winner. If an item fails, log and continue; + // we cannot pull assets back from a user's wallet, so the winner keeps whatever + // was delivered and the remaining escrowed items stay in the contract. + for (locals.arJ = 0; locals.arJ < locals.arInfo.bundleSize; locals.arJ++) { - locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_assetRaffleBundleDeliveryFailed, 0 }; - LOG_INFO(locals.log); + locals.arItem = state.get().activeAssetRaffleItems.get( + locals.arI * QRAFFLE_MAX_ASSETS_PER_BUNDLE + locals.arJ); + locals.transferResult = qpi.transferShareOwnershipAndPossession( + locals.arItem.asset.assetName, locals.arItem.asset.issuer, + SELF, SELF, + locals.arItem.numberOfShares, locals.winner); + if (locals.transferResult < 0) + { + locals.log = Logger{ QRAFFLE_CONTRACT_INDEX, QRAFFLE_assetRaffleBundleDeliveryFailed, 0 }; + LOG_INFO(locals.log); + } } - } - // Qu pool distribution: 80% to creator, 20% to fee buckets. - // Always executed when reserve is met, regardless of per-item delivery outcome. - // (Assets already delivered to winner; we cannot recall them from a user's wallet.) - locals.arBurn = div(locals.arGross * (uint64)QRAFFLE_BURN_FEE, 100ull); - locals.arCharity = div(locals.arGross * (uint64)QRAFFLE_CHARITY_FEE, 100ull); - locals.arShareholderRev = div(locals.arGross * (uint64)QRAFFLE_SHAREHOLDER_FEE, 100ull); - locals.arRegisterRev = div(locals.arGross * (uint64)QRAFFLE_REGISTER_FEE, 100ull); - locals.arFee = div(locals.arGross * (uint64)QRAFFLE_FEE, 100ull); - // Round down per-share amounts; all rounding dust goes to creator. - locals.arShareholderPerShare = div(locals.arShareholderRev, (uint64)NUMBER_OF_COMPUTORS); - locals.arRegisterPerShare = (state.get().numberOfRegisters > 0) - ? div(locals.arRegisterRev, (uint64)state.get().numberOfRegisters) - : 0; - locals.arRegisterPerShareActual = locals.arRegisterPerShare * (uint64)state.get().numberOfRegisters; - // Creator gets gross minus all deductions; rounding dust stays with creator. - locals.arCreatorPay = locals.arGross - - locals.arBurn - - locals.arCharity - - (locals.arShareholderPerShare * (uint64)NUMBER_OF_COMPUTORS) - - locals.arRegisterPerShareActual - - locals.arFee; - - qpi.transfer(locals.arInfo.creator, locals.arCreatorPay); - qpi.burn(locals.arBurn); - qpi.transfer(state.get().charityAddress, locals.arCharity); - if (locals.arShareholderPerShare > 0) - { - qpi.distributeDividends(locals.arShareholderPerShare); - } - qpi.transfer(state.get().feeAddress, locals.arFee); - // Accumulate register share; distributed in a single pass after the loop. - locals.arRegisterBucket += locals.arRegisterPerShareActual; + // Qu pool distribution: 80% to creator, 20% to fee buckets. + // Always executed when reserve is met, regardless of per-item delivery outcome. + // (Assets already delivered to winner; we cannot recall them from a user's wallet.) + locals.arBurn = div(locals.arGross * (uint64)QRAFFLE_BURN_FEE, 100ull); + locals.arCharity = div(locals.arGross * (uint64)QRAFFLE_CHARITY_FEE, 100ull); + locals.arShareholderRev = div(locals.arGross * (uint64)QRAFFLE_SHAREHOLDER_FEE, 100ull); + locals.arRegisterRev = div(locals.arGross * (uint64)QRAFFLE_REGISTER_FEE, 100ull); + locals.arFee = div(locals.arGross * (uint64)QRAFFLE_FEE, 100ull); + // Round down per-share amounts; all rounding dust goes to creator. + locals.arShareholderPerShare = div(locals.arShareholderRev, (uint64)NUMBER_OF_COMPUTORS); + locals.arRegisterPerShare = (state.get().numberOfRegisters > 0) + ? div(locals.arRegisterRev, (uint64)state.get().numberOfRegisters) + : 0; + locals.arRegisterPerShareActual = locals.arRegisterPerShare * (uint64)state.get().numberOfRegisters; + // Creator gets gross minus all deductions; rounding dust stays with creator. + locals.arCreatorPay = locals.arGross + - locals.arBurn + - locals.arCharity + - (locals.arShareholderPerShare * (uint64)NUMBER_OF_COMPUTORS) + - locals.arRegisterPerShareActual + - locals.arFee; + + qpi.transfer(locals.arInfo.creator, locals.arCreatorPay); + qpi.burn(locals.arBurn); + qpi.transfer(state.get().charityAddress, locals.arCharity); + if (locals.arShareholderPerShare > 0) + { + qpi.distributeDividends(locals.arShareholderPerShare); + } + qpi.transfer(state.get().feeAddress, locals.arFee); + // Accumulate register share; distributed in a single pass after the loop. + locals.arRegisterBucket += locals.arRegisterPerShareActual; - state.mut().totalAssetRaffleCreatorPaid += locals.arCreatorPay; - state.mut().totalAssetRafflesSucceeded++; + state.mut().totalAssetRaffleCreatorPaid += locals.arCreatorPay; + state.mut().totalAssetRafflesSucceeded++; } if (!locals.arReserveMet) @@ -2630,7 +2615,10 @@ struct QRAFFLE : public ContractBase } // Write to ended ring buffer. - locals.arEndedIdx = mod(state.get().numberOfEndedAssetRaffles, QRAFFLE_MAX_ENDED_ASSET_RAFFLES); + // arEndedGlobalIdx is the monotonic logical index (pre-increment); this is the + // value callers pass to getEndedAssetRaffle. arEndedIdx is its ring-masked slot. + locals.arEndedGlobalIdx = state.get().numberOfEndedAssetRaffles; + locals.arEndedIdx = mod(locals.arEndedGlobalIdx, QRAFFLE_MAX_ENDED_ASSET_RAFFLES); locals.arEnded.creator = locals.arInfo.creator; locals.arEnded.epochWinner = locals.winner; locals.arEnded.reservePriceQu = locals.arInfo.reservePriceQu; @@ -2648,7 +2636,7 @@ struct QRAFFLE : public ContractBase locals.arLog = AssetRaffleEndedLogger{ QRAFFLE_CONTRACT_INDEX, locals.arReserveMet ? (uint32)QRAFFLE_assetRaffleSucceeded : (uint32)QRAFFLE_assetRaffleRefunded, - locals.arI, + locals.arEndedGlobalIdx, locals.arInfo.creator, locals.winner, locals.arGross, @@ -2660,6 +2648,9 @@ struct QRAFFLE : public ContractBase } // Distribute accumulated register share from all successful asset raffles in one O(R) pass. + // Any integer-division remainder (up to numberOfRegisters-1 Qu) is folded into the DAO + // bucket below so the dust either pays out this same epoch via the DAO distribution that + // follows, or carries forward — never silently leaks into untracked contract balance. if (locals.arRegisterBucket > 0 && state.get().numberOfRegisters > 0) { locals.arRegisterBucketPerReg = div(locals.arRegisterBucket, (uint64)state.get().numberOfRegisters); @@ -2672,9 +2663,12 @@ struct QRAFFLE : public ContractBase locals.idx = state.get().registers.nextElementIndex(locals.idx); } } + state.mut().epochAssetRaffleDaoBucket += locals.arRegisterBucket - locals.arRegisterBucketPerReg * (uint64)state.get().numberOfRegisters; } // Distribute DAO proposal-fee bucket evenly to registers. + // Subtract only what was actually paid out so the integer-division remainder carries + // forward to the next epoch instead of being silently dropped into contract balance. if (state.get().epochAssetRaffleDaoBucket > 0 && state.get().numberOfRegisters > 0) { locals.arDaoBucketPerRegister = div(state.get().epochAssetRaffleDaoBucket, (uint64)state.get().numberOfRegisters); @@ -2687,7 +2681,7 @@ struct QRAFFLE : public ContractBase locals.idx = state.get().registers.nextElementIndex(locals.idx); } } - state.mut().epochAssetRaffleDaoBucket = 0; + state.mut().epochAssetRaffleDaoBucket -= locals.arDaoBucketPerRegister * (uint64)state.get().numberOfRegisters; } // Reset asset raffle per-epoch state. @@ -2754,7 +2748,6 @@ struct QRAFFLE : public ContractBase state.mut().tokenRaffleParticipation.reset(); state.mut().proposalsPerProposer.reset(); state.mut().quRaffleEntryAmount.reset(); - state.mut().shareholdersList.reset(); state.mut().voteParticipation.reset(); state.mut().voteValues.reset(); state.mut().numberOfEntryAmountSubmitted = 0; diff --git a/test/contract_qraffle.cpp b/test/contract_qraffle.cpp index c4150a23b..57cffdf78 100644 --- a/test/contract_qraffle.cpp +++ b/test/contract_qraffle.cpp @@ -5,6 +5,11 @@ #include "contract_testing.h" +// Test-local expectation for the asset-raffle creator share. The contract derives the +// creator payout implicitly by subtracting the 20% fee split (5% burn + 1% charity + +// 8% shareholder + 5% register + 1% fee), so 80% is the expected creator percentage. +static constexpr uint32 QRAFFLE_TEST_ASSET_RAFFLE_CREATOR_PCT = 80; + static std::mt19937_64 rand64; static unsigned long long random(unsigned long long minValue, unsigned long long maxValue) @@ -1207,19 +1212,27 @@ TEST(ContractQraffle, GetFunctions) // Test with valid offset and limit auto registers = qraffle.getRegisters(0, 10); EXPECT_EQ(registers.returnCode, QRAFFLE_SUCCESS); - - // Test with offset beyond available registers + + // Test with offset beyond available registers — now returns SUCCESS with an + // empty result (all output slots stay NULL_ID). auto registers2 = qraffle.getRegisters(registerCount + 10, 5); - EXPECT_EQ(registers2.returnCode, QRAFFLE_INVALID_OFFSET_OR_LIMIT); - - // Test with limit exceeding maximum (20) + EXPECT_EQ(registers2.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(registers2.register1, id(0, 0, 0, 0)); + + // Test with limit exceeding maximum (20) — still invalid because the output + // struct can only carry 20 ids. auto registers3 = qraffle.getRegisters(0, 21); EXPECT_EQ(registers3.returnCode, QRAFFLE_INVALID_OFFSET_OR_LIMIT); - - // Test with offset + limit exceeding total registers + + // Test with offset + limit exceeding total registers — now returns SUCCESS with + // however many registers are actually available; trailing output slots stay + // NULL_ID so the caller can detect end-of-data. auto registers4 = qraffle.getRegisters(registerCount - 5, 10); - EXPECT_EQ(registers4.returnCode, QRAFFLE_INVALID_OFFSET_OR_LIMIT); - + EXPECT_EQ(registers4.returnCode, QRAFFLE_SUCCESS); + EXPECT_NE(registers4.register1, id(0, 0, 0, 0)); + EXPECT_NE(registers4.register5, id(0, 0, 0, 0)); + EXPECT_EQ(registers4.register6, id(0, 0, 0, 0)); + // Test with zero limit auto registers5 = qraffle.getRegisters(0, 0); EXPECT_EQ(registers5.returnCode, QRAFFLE_SUCCESS); @@ -2525,7 +2538,7 @@ TEST(ContractQraffle, AssetRaffle_EndEpoch_ReserveMet) qraffle.getState()->endedAssetRaffleChecker(0, true, creator, totalPool, 200); // Creator should have received 80% of pool - uint64 expectedCreatorPay = (totalPool * QRAFFLE_ASSET_RAFFLE_CREATOR_PCT) / 100; + uint64 expectedCreatorPay = (totalPool * QRAFFLE_TEST_ASSET_RAFFLE_CREATOR_PCT) / 100; EXPECT_GE((uint64)getBalance(creator), creatorBalBefore + expectedCreatorPay - totalPool / 100); // Analytics: 1 created, 1 succeeded, 0 failed @@ -3214,7 +3227,7 @@ TEST(ContractQraffle, AssetRaffle_FullLifecycle) QRAFFLE_CONTRACT_INDEX, QRAFFLE_CONTRACT_INDEX), 500000); // Creator received ~80% of pool - uint64 minCreatorPay = (totalPool * QRAFFLE_ASSET_RAFFLE_CREATOR_PCT) / 100 - 1; + uint64 minCreatorPay = (totalPool * QRAFFLE_TEST_ASSET_RAFFLE_CREATOR_PCT) / 100 - 1; EXPECT_GE((uint64)getBalance(creator), creatorBalBefore + minCreatorPay); // Analytics @@ -4075,15 +4088,30 @@ TEST(ContractQraffle, getRegisters_PaginationCorrect) auto page1 = qraffle.getRegisters(10, 10); EXPECT_EQ(page1.returnCode, QRAFFLE_SUCCESS); - // Page 2: offset=20, limit=5. (remaining 5) + // Page 2: offset=20, limit=5. (remaining 5 — exact fit) auto page2 = qraffle.getRegisters(20, 5); EXPECT_EQ(page2.returnCode, QRAFFLE_SUCCESS); - // offset + limit > total → invalid. - auto bad = qraffle.getRegisters(20, 6); - EXPECT_EQ(bad.returnCode, QRAFFLE_INVALID_OFFSET_OR_LIMIT); - - // limit > 20 → invalid. + // Partial trailing page: offset=20, limit=6 with 25 total → returns the 5 + // available registers; the 6th slot stays NULL_ID so the caller can detect + // end-of-data. (Previously this rejected with INVALID_OFFSET_OR_LIMIT.) + auto partial = qraffle.getRegisters(20, 6); + EXPECT_EQ(partial.returnCode, QRAFFLE_SUCCESS); + EXPECT_NE(partial.register1, id(0, 0, 0, 0)); + EXPECT_NE(partial.register5, id(0, 0, 0, 0)); + EXPECT_EQ(partial.register6, id(0, 0, 0, 0)); + + // Offset past end → SUCCESS with an empty result (all NULL_ID). + auto pastEnd = qraffle.getRegisters(25, 5); + EXPECT_EQ(pastEnd.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(pastEnd.register1, id(0, 0, 0, 0)); + + // Offset far past end (defensive: large offset value should not iterate the list). + auto wayPast = qraffle.getRegisters(1000000, 10); + EXPECT_EQ(wayPast.returnCode, QRAFFLE_SUCCESS); + EXPECT_EQ(wayPast.register1, id(0, 0, 0, 0)); + + // limit > 20 → still invalid (output struct only holds 20 ids). auto bad2 = qraffle.getRegisters(0, 21); EXPECT_EQ(bad2.returnCode, QRAFFLE_INVALID_OFFSET_OR_LIMIT); }