From 607df4d4eb78dff1cf90c191faf53c3a4684f446 Mon Sep 17 00:00:00 2001 From: IanLaFlair Date: Sun, 15 Mar 2026 09:36:11 +0700 Subject: [PATCH] feat: Add QSurv trustless survey contract and tests - Add QSurv.h with StateData architecture (state.get/state.mut) - Register QSURV at index 25 in contract_def.h - Add contract_qsurv.cpp test suite - Add project references in vcxproj files --- src/Qubic.vcxproj | 1 + src/Qubic.vcxproj.filters | 4 + src/contract_core/contract_def.h | 12 + src/contracts/QSurv.h | 514 +++++++++++++++++++++++++++++++ test/contract_qsurv.cpp | 277 +++++++++++++++++ test/test.vcxproj | 1 + test/test.vcxproj.filters | 1 + 7 files changed, 810 insertions(+) create mode 100644 src/contracts/QSurv.h create mode 100644 test/contract_qsurv.cpp diff --git a/src/Qubic.vcxproj b/src/Qubic.vcxproj index c7a4f2f45..da3e949c7 100644 --- a/src/Qubic.vcxproj +++ b/src/Qubic.vcxproj @@ -48,6 +48,7 @@ + diff --git a/src/Qubic.vcxproj.filters b/src/Qubic.vcxproj.filters index 24b11e9e1..102571a1c 100644 --- a/src/Qubic.vcxproj.filters +++ b/src/Qubic.vcxproj.filters @@ -305,6 +305,10 @@ contracts + + + contracts + contracts diff --git a/src/contract_core/contract_def.h b/src/contract_core/contract_def.h index a0e8ff8d8..f8f96d1f6 100644 --- a/src/contract_core/contract_def.h +++ b/src/contract_core/contract_def.h @@ -254,6 +254,16 @@ #define CONTRACT_STATE2_TYPE PULSE2 #include "contracts/Pulse.h" +#undef CONTRACT_INDEX +#undef CONTRACT_STATE_TYPE +#undef CONTRACT_STATE2_TYPE + +#define QSURV_CONTRACT_INDEX 25 +#define CONTRACT_INDEX QSURV_CONTRACT_INDEX +#define CONTRACT_STATE_TYPE QSURV +#define CONTRACT_STATE2_TYPE QSURV2 +#include "contracts/QSurv.h" + // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES @@ -364,6 +374,7 @@ constexpr struct ContractDescription {"QTF", 199, 10000, sizeof(QTF::StateData)}, // proposal in epoch 197, IPO in 198, construction and first use in 199 {"QDUEL", 199, 10000, sizeof(QDUEL::StateData)}, // proposal in epoch 197, IPO in 198, construction and first use in 199 {"PULSE", 204, 10000, sizeof(PULSE::StateData)}, // proposal in epoch 202, IPO in 203, construction and first use in 204 + {"QSURV", 205, 10000, sizeof(QSURV::StateData)}, // proposal in epoch 203, IPO in 204, construction and first use in 205 // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES {"TESTEXA", 138, 10000, sizeof(TESTEXA::StateData)}, @@ -484,6 +495,7 @@ static void initializeContracts() REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QTF); REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QDUEL); REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(PULSE); + REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QSURV); // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(TESTEXA); diff --git a/src/contracts/QSurv.h b/src/contracts/QSurv.h new file mode 100644 index 000000000..76550b73f --- /dev/null +++ b/src/contracts/QSurv.h @@ -0,0 +1,514 @@ +using namespace QPI; + +// ============================================ +// QSurv - Trustless Survey Platform +// Decentralized survey creation with escrow and AI-verified payouts +// ============================================ + +// Forward declaration for state expansion +struct QSURV2 +{ +}; + +struct QSURV : public ContractBase +{ + // ============================================ + // CONSTANTS + // ============================================ + + static constexpr uint32 MAX_SURVEYS = 1024; + static constexpr uint32 MAX_RESPONDENTS_PER_SURVEY = 128; + static constexpr uint32 IPFS_HASH_SIZE = 64; + + // ============================================ + // STRUCTS + // ============================================ + struct Survey + { + uint64 surveyId; + id creator; + uint64 rewardAmount; + uint64 rewardPerRespondent; + uint32 maxRespondents; + uint32 currentRespondents; + uint64 balance; + Array ipfsHash; + Array paidRespondents; // track who has been paid + bit isActive; + }; + + // ============================================ + // STATE (Persistent Storage) + // ============================================ + public: + struct StateData + { + Array _surveys; + uint32 _surveyCount; + id _oracleAddress; + }; + + // ============================================ + // SYSTEM PROCEDURES + // ============================================ + public: + INITIALIZE() + { + // Establish the Genesis Oracle to prevent sniper attacks + // ID: BOWFPORUCOPUOCNOIBDNSSRHQYCAXCRHGKBUKCMZJCZBHQVUUWWLAGIFWGVN + state.mut()._oracleAddress = ID(_B, _O, _W, _F, _P, _O, _R, _U, _C, _O, _P, _U, _O, _C, _N, _O, _I, _B, _D, _N, + _S, _S, _R, _H, _Q, _Y, _C, _A, _X, _C, _R, _H, _G, _K, _B, _U, _K, _C, _M, _Z, + _J, _C, _Z, _B, _H, _Q, _V, _U, _U, _W, _W, _L, _A, _G, _I, _F); + } + + BEGIN_EPOCH() + { + // Called at the beginning of each epoch + } + + END_EPOCH() + { + // Called at the end of each epoch + } + + BEGIN_TICK() + { + // Called before processing transactions in a tick + } + + END_TICK() + { + // Called after processing transactions in a tick + } + + // ============================================ + // INPUT/OUTPUT STRUCTS + // ============================================ + + // --- CreateSurvey --- + struct createSurvey_input + { + uint64 rewardPool; + uint32 maxRespondents; + Array ipfsHash; + }; + + struct createSurvey_output + { + uint64 surveyId; + bit success; + }; + + struct createSurvey_locals + { + uint32 i; + uint32 index; + Survey tempSurvey; + }; + + // --- Payout --- + struct payout_input + { + uint64 surveyId; + id respondentAddress; + id referrerAddress; + uint8 respondentTier; + }; + + struct payout_output + { + uint64 amountPaid; + uint64 bonusPaid; + uint64 referralPaid; + bit success; + }; + + struct payout_locals + { + uint32 index; + bit found; + bit isDuplicate; + uint64 totalReward; + uint64 baseReward; + uint64 referralReward; + uint64 platformFee; + uint64 burnFee; + uint64 oracleFee; + uint64 bonus; + uint64 totalSpent; + uint32 i; + Survey tempSurvey; + }; + + // --- GetSurvey (Read-only) --- + struct getSurvey_input + { + uint64 surveyId; + }; + + struct getSurvey_output + { + uint64 surveyId; + id creator; + uint64 rewardAmount; + uint64 rewardPerRespondent; + uint32 maxRespondents; + uint32 currentRespondents; + uint64 balance; + bit isActive; + bit found; + }; + + struct getSurvey_locals + { + uint32 i; + }; + + // --- GetSurveyCount (Read-only) --- + struct getSurveyCount_input + { + }; + + struct getSurveyCount_output + { + uint32 count; + }; + + // --- AbortSurvey (Creator only) --- + struct abortSurvey_input + { + uint64 surveyId; + }; + + struct abortSurvey_output + { + bit success; + }; + + struct abortSurvey_locals + { + uint32 i; + uint32 index; + bit found; + Survey tempSurvey; + }; + + // --- SetOracle (Admin) --- + struct setOracle_input + { + id newOracleAddress; + }; + + struct setOracle_output + { + bit success; + }; + + // ============================================ + // USER PROCEDURES (State-Modifying) + // ============================================ + + PUBLIC_PROCEDURE_WITH_LOCALS(createSurvey) + { + // Validation checks - refund invocation reward on failure + if (input.maxRespondents == 0 || input.maxRespondents > MAX_RESPONDENTS_PER_SURVEY) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + return; + } + if (input.rewardPool == 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + return; + } + + // Verify invocation reward matches rewardPool + if ((uint64)qpi.invocationReward() < input.rewardPool) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + return; + } + + // Scan for an empty (inactive) slot to reuse + locals.i = MAX_SURVEYS; // sentinel: no slot found yet + for (locals.index = 0; locals.index < state.get()._surveyCount; locals.index++) + { + if (!state.get()._surveys.get(locals.index).isActive) + { + locals.i = locals.index; + break; + } + } + + // If no inactive slot found, try to allocate a new one + if (locals.i == MAX_SURVEYS) + { + if (state.get()._surveyCount >= MAX_SURVEYS) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + return; // All slots occupied and active, truly full + } + locals.i = state.get()._surveyCount; + state.mut()._surveyCount++; + } + + // Create new survey in the found/allocated slot + locals.tempSurvey.surveyId = locals.i + 1; + locals.tempSurvey.creator = qpi.invocator(); + locals.tempSurvey.rewardAmount = input.rewardPool; + locals.tempSurvey.maxRespondents = input.maxRespondents; + locals.tempSurvey.rewardPerRespondent = QPI::div(input.rewardPool, (uint64)input.maxRespondents); + locals.tempSurvey.balance = input.rewardPool; + locals.tempSurvey.currentRespondents = 0; + locals.tempSurvey.isActive = 1; + + // Zero out paid respondents list for safety (especially on slot reuse) + for (locals.index = 0; locals.index < MAX_RESPONDENTS_PER_SURVEY; locals.index++) + { + locals.tempSurvey.paidRespondents.set(locals.index, NULL_ID); + } + + // Copy IPFS hash using Array's set method + for (locals.index = 0; locals.index < IPFS_HASH_SIZE; locals.index++) + { + locals.tempSurvey.ipfsHash.set(locals.index, input.ipfsHash.get(locals.index)); + } + + // Commit state changes + state.mut()._surveys.set(locals.i, locals.tempSurvey); + + output.surveyId = locals.i + 1; + output.success = 1; + } + + PUBLIC_PROCEDURE_WITH_LOCALS(payout) + { + // Security: Oracle-only execution + if (qpi.invocator() != state.get()._oracleAddress) + { + return; + } + + // Find survey by ID using found flag pattern + for (locals.i = 0; locals.i < state.get()._surveyCount; locals.i++) + { + if (state.get()._surveys.get(locals.i).surveyId == input.surveyId) + { + locals.index = locals.i; + locals.found = 1; + break; + } + } + + if (!locals.found) + { + return; + } + + // Copy state to local variable for reading and modification + locals.tempSurvey = state.get()._surveys.get(locals.index); + + // Validation checks + if (!locals.tempSurvey.isActive) + { + return; + } + if (locals.tempSurvey.currentRespondents >= locals.tempSurvey.maxRespondents) + { + return; + } + if (locals.tempSurvey.balance < locals.tempSurvey.rewardPerRespondent) + { + return; + } + + // Double-payout prevention: check if respondent was already paid + for (locals.i = 0; locals.i < locals.tempSurvey.currentRespondents; locals.i++) + { + if (locals.tempSurvey.paidRespondents.get(locals.i) == input.respondentAddress) + { + locals.isDuplicate = 1; + break; + } + } + if (locals.isDuplicate) + { + return; + } + + // Calculate reward splits using QPI::div (no / operator allowed) + locals.totalReward = locals.tempSurvey.rewardPerRespondent; + + // Minimum guaranteed 40%, fixed referral 20% + locals.baseReward = QPI::div(locals.totalReward * 40, 100ULL); + locals.referralReward = QPI::div(locals.totalReward * 20, 100ULL); + + // Staking bonus tier system and fair oracle incentivization + if (input.respondentTier == 1) + { + locals.bonus = QPI::div(locals.totalReward * 10, 100ULL); // 10% + locals.platformFee = QPI::div(locals.totalReward * 3, 100ULL); // 3% + } + else if (input.respondentTier == 2) + { + locals.bonus = QPI::div(locals.totalReward * 20, 100ULL); // 20% + locals.platformFee = QPI::div(locals.totalReward * 5, 100ULL); // 5% + } + else if (input.respondentTier == 3) + { + locals.bonus = QPI::div(locals.totalReward * 30, 100ULL); // 30% + locals.platformFee = QPI::div(locals.totalReward * 10, 100ULL); // 10% + } + else // Tier 0 + { + locals.bonus = 0; + locals.platformFee = QPI::div(locals.totalReward * 1, 100ULL); // 1% + } + + locals.totalSpent = locals.baseReward + locals.bonus + locals.referralReward + locals.platformFee; + + // Split platform fee: half burned to sustain execution fee reserve, + // half goes to oracle as operational compensation + locals.burnFee = QPI::div(locals.platformFee, 2ULL); + locals.oracleFee = locals.platformFee - locals.burnFee; + + // Execute fund transfers + qpi.transfer(input.respondentAddress, locals.baseReward + locals.bonus); + + if (input.referrerAddress != NULL_ID) + { + qpi.transfer(input.referrerAddress, locals.referralReward); + } + else + { + qpi.transfer(state.get()._oracleAddress, locals.referralReward); + } + + qpi.transfer(state.get()._oracleAddress, locals.oracleFee); + qpi.burn(locals.burnFee); // Replenish execution fee reserve + + // Record this respondent to prevent double-payout + locals.tempSurvey.paidRespondents.set(locals.tempSurvey.currentRespondents, input.respondentAddress); + + // Update state in local variable. Only deduct what was ACTUALLY spent! + locals.tempSurvey.balance = locals.tempSurvey.balance - locals.totalSpent; + locals.tempSurvey.currentRespondents++; + + if (locals.tempSurvey.currentRespondents >= locals.tempSurvey.maxRespondents) + { + locals.tempSurvey.isActive = 0; + // Refund leftover balance to creator + if (locals.tempSurvey.balance > 0) + { + qpi.transfer(locals.tempSurvey.creator, locals.tempSurvey.balance); + locals.tempSurvey.balance = 0; + } + } + + // Commit modifications back to state + state.mut()._surveys.set(locals.index, locals.tempSurvey); + + output.success = 1; + output.amountPaid = locals.baseReward; + output.bonusPaid = locals.bonus; + output.referralPaid = locals.referralReward; + } + + PUBLIC_PROCEDURE_WITH_LOCALS(abortSurvey) + { + for (locals.i = 0; locals.i < state.get()._surveyCount; locals.i++) + { + if (state.get()._surveys.get(locals.i).surveyId == input.surveyId) + { + locals.index = locals.i; + locals.found = 1; + break; + } + } + + if (!locals.found) + { + return; + } + + locals.tempSurvey = state.get()._surveys.get(locals.index); + + // Only creator can abort + if (qpi.invocator() != locals.tempSurvey.creator) + { + return; + } + + // Must be active + if (!locals.tempSurvey.isActive) + { + return; + } + + // Refund remaining balance to creator + if (locals.tempSurvey.balance > 0) + { + qpi.transfer(locals.tempSurvey.creator, locals.tempSurvey.balance); + } + + // Zero out completely to reclaim slot safely + locals.tempSurvey.isActive = 0; + locals.tempSurvey.balance = 0; + locals.tempSurvey.currentRespondents = locals.tempSurvey.maxRespondents; + + state.mut()._surveys.set(locals.index, locals.tempSurvey); + output.success = 1; + } + + PUBLIC_PROCEDURE(setOracle) + { + // Only allow setting oracle if not already set, or by current oracle + if (state.get()._oracleAddress == NULL_ID || qpi.invocator() == state.get()._oracleAddress) + { + state.mut()._oracleAddress = input.newOracleAddress; + output.success = 1; + } + } + + // ============================================ + // USER FUNCTIONS (Read-Only) + // ============================================ + + PUBLIC_FUNCTION_WITH_LOCALS(getSurvey) + { + for (locals.i = 0; locals.i < state.get()._surveyCount; locals.i++) + { + if (state.get()._surveys.get(locals.i).surveyId == input.surveyId) + { + output.surveyId = state.get()._surveys.get(locals.i).surveyId; + output.creator = state.get()._surveys.get(locals.i).creator; + output.rewardAmount = state.get()._surveys.get(locals.i).rewardAmount; + output.rewardPerRespondent = state.get()._surveys.get(locals.i).rewardPerRespondent; + output.maxRespondents = state.get()._surveys.get(locals.i).maxRespondents; + output.currentRespondents = state.get()._surveys.get(locals.i).currentRespondents; + output.balance = state.get()._surveys.get(locals.i).balance; + output.isActive = state.get()._surveys.get(locals.i).isActive; + output.found = 1; + return; + } + } + } + + PUBLIC_FUNCTION(getSurveyCount) { output.count = state.get()._surveyCount; } + + // ============================================ + // REGISTER USER FUNCTIONS AND PROCEDURES + // ============================================ + REGISTER_USER_FUNCTIONS_AND_PROCEDURES() + { + // Functions (Read-only queries) + REGISTER_USER_FUNCTION(getSurvey, 1); + REGISTER_USER_FUNCTION(getSurveyCount, 2); + + // Procedures (State-modifying) + REGISTER_USER_PROCEDURE(createSurvey, 1); + REGISTER_USER_PROCEDURE(payout, 2); + REGISTER_USER_PROCEDURE(setOracle, 3); + REGISTER_USER_PROCEDURE(abortSurvey, 4); + } +}; diff --git a/test/contract_qsurv.cpp b/test/contract_qsurv.cpp new file mode 100644 index 000000000..48c965f9d --- /dev/null +++ b/test/contract_qsurv.cpp @@ -0,0 +1,277 @@ +#define NO_UEFI +#include "contract_testing.h" + +constexpr uint16 PROCEDURE_INDEX_CREATE_SURVEY = 1; +constexpr uint16 PROCEDURE_INDEX_PAYOUT = 2; +constexpr uint16 PROCEDURE_INDEX_SET_ORACLE = 3; +constexpr uint16 PROCEDURE_INDEX_ABORT_SURVEY = 4; + +constexpr uint16 FUNCTION_INDEX_GET_SURVEY = 1; +constexpr uint16 FUNCTION_INDEX_GET_SURVEY_COUNT = 2; + +class ContractTestingQSurv : public ContractTesting +{ + public: + ContractTestingQSurv() + { + initEmptySpectrum(); + initEmptyUniverse(); + INIT_CONTRACT(QSURV); + callSystemProcedure(QSURV_CONTRACT_INDEX, INITIALIZE); + } + + QSURV::createSurvey_output createSurvey(const id &user, uint64 rewardPool, uint32 maxRespondents, + const QPI::Array &ipfsHash, uint64 attachedAmount) + { + QSURV::createSurvey_input input; + input.rewardPool = rewardPool; + input.maxRespondents = maxRespondents; + for (int i = 0; i < 64; i++) + { + input.ipfsHash.set(i, ipfsHash.get(i)); + } + + QSURV::createSurvey_output output; + output.success = 0; + invokeUserProcedure(QSURV_CONTRACT_INDEX, PROCEDURE_INDEX_CREATE_SURVEY, input, output, user, attachedAmount); + return output; + } + + QSURV::payout_output payout(const id &user, uint64 surveyId, const id &respondent, const id &referrer, uint8 tier) + { + QSURV::payout_input input; + input.surveyId = surveyId; + input.respondentAddress = respondent; + input.referrerAddress = referrer; + input.respondentTier = tier; + + QSURV::payout_output output; + output.success = 0; + invokeUserProcedure(QSURV_CONTRACT_INDEX, PROCEDURE_INDEX_PAYOUT, input, output, user, 0); + return output; + } + + QSURV::setOracle_output setOracle(const id &user, const id &newOracle) + { + QSURV::setOracle_input input; + input.newOracleAddress = newOracle; + + QSURV::setOracle_output output; + output.success = 0; + invokeUserProcedure(QSURV_CONTRACT_INDEX, PROCEDURE_INDEX_SET_ORACLE, input, output, user, 0); + return output; + } + + QSURV::abortSurvey_output abortSurvey(const id &user, uint64 surveyId) + { + QSURV::abortSurvey_input input; + input.surveyId = surveyId; + + QSURV::abortSurvey_output output; + output.success = 0; + invokeUserProcedure(QSURV_CONTRACT_INDEX, PROCEDURE_INDEX_ABORT_SURVEY, input, output, user, 0); + return output; + } + + QSURV::getSurvey_output getSurvey(uint64 surveyId) + { + QSURV::getSurvey_input input; + input.surveyId = surveyId; + QSURV::getSurvey_output output; + output.found = 0; + callFunction(QSURV_CONTRACT_INDEX, FUNCTION_INDEX_GET_SURVEY, input, output); + return output; + } + + QSURV::getSurveyCount_output getSurveyCount() + { + QSURV::getSurveyCount_input input; + QSURV::getSurveyCount_output output; + output.count = 0; + callFunction(QSURV_CONTRACT_INDEX, FUNCTION_INDEX_GET_SURVEY_COUNT, input, output); + return output; + } +}; + +TEST(ContractQSurv, CreateSurvey_Success) +{ + ContractTestingQSurv qsurv; + const id creator(1, 0, 0, 0); + const uint64 rewardPool = 1000; + const uint32 maxRespondents = 10; + + increaseEnergy(creator, rewardPool + 1000); + + QPI::Array hash; + for (int i = 0; i < 64; i++) + { + hash.set(i, 1); + } + + auto output = qsurv.createSurvey(creator, rewardPool, maxRespondents, hash, rewardPool); + + EXPECT_EQ(output.success, 1); + EXPECT_EQ(output.surveyId, 1); + + auto countOut = qsurv.getSurveyCount(); + EXPECT_EQ(countOut.count, 1); + + auto surveyOut = qsurv.getSurvey(1); + EXPECT_EQ(surveyOut.found, 1); + EXPECT_EQ(surveyOut.rewardAmount, rewardPool); + EXPECT_EQ(surveyOut.creator, creator); +} + +TEST(ContractQSurv, CreateSurvey_Fail_ZeroRespondentsOrPool) +{ + ContractTestingQSurv qsurv; + const id creator(1, 0, 0, 0); + increaseEnergy(creator, 5000); + + QPI::Array hash; + for (int i = 0; i < 64; i++) + { + hash.set(i, 1); + } + + // Pool is 0 + auto out1 = qsurv.createSurvey(creator, 0, 10, hash, 0); + EXPECT_EQ(out1.success, 0); + + // Respondents is 0 + auto out2 = qsurv.createSurvey(creator, 1000, 0, hash, 1000); + EXPECT_EQ(out2.success, 0); +} + +TEST(ContractQSurv, SetOracle_Security) +{ + ContractTestingQSurv qsurv; + const id GENESIS_ORACLE_ID = ID(_B, _O, _W, _F, _P, _O, _R, _U, _C, _O, _P, _U, _O, _C, _N, _O, _I, _B, _D, _N, _S, + _S, _R, _H, _Q, _Y, _C, _A, _X, _C, _R, _H, _G, _K, _B, _U, _K, _C, _M, _Z, _J, _C, + _Z, _B, _H, _Q, _V, _U, _U, _W, _W, _L, _A, _G, _I, _F); + const id oracle(999, 0, 0, 0); + const id hacker(888, 0, 0, 0); + + increaseEnergy(GENESIS_ORACLE_ID, 1000); + increaseEnergy(hacker, 1000); + + // Oracle starts as genesis oracle, only they can change it + auto setOut1 = qsurv.setOracle(GENESIS_ORACLE_ID, oracle); + EXPECT_EQ(setOut1.success, 1); + + // Hacker tries to change oracle + auto setOut2 = qsurv.setOracle(hacker, hacker); + EXPECT_EQ(setOut2.success, 0); +} + +TEST(ContractQSurv, Payout_VerifyBalancesAndCompletion) +{ + ContractTestingQSurv qsurv; + const id GENESIS_ORACLE_ID = ID(_B, _O, _W, _F, _P, _O, _R, _U, _C, _O, _P, _U, _O, _C, _N, _O, _I, _B, _D, _N, _S, + _S, _R, _H, _Q, _Y, _C, _A, _X, _C, _R, _H, _G, _K, _B, _U, _K, _C, _M, _Z, _J, _C, + _Z, _B, _H, _Q, _V, _U, _U, _W, _W, _L, _A, _G, _I, _F); + const id creator(1, 0, 0, 0); + const id oracle(999, 0, 0, 0); + const id respondent(2, 0, 0, 0); + const id referrer(3, 0, 0, 0); + + increaseEnergy(creator, 10000); + increaseEnergy(GENESIS_ORACLE_ID, 1000); + increaseEnergy(oracle, 1000); // Oracle needs energy to call payout + + // Set oracle (must be called by genesis oracle) + auto setOut = qsurv.setOracle(GENESIS_ORACLE_ID, oracle); + EXPECT_EQ(setOut.success, 1); + + QPI::Array hash; + for (int i = 0; i < 64; i++) + { + hash.set(i, 1); + } + + uint64 rewardPool = 1000; + uint32 maxResp = 1; // Only 1 to test completion logic easily + + qsurv.createSurvey(creator, rewardPool, maxResp, hash, rewardPool); + + auto surveyBefore = qsurv.getSurvey(1); + EXPECT_EQ(surveyBefore.balance, 1000); + EXPECT_EQ(surveyBefore.isActive, 1); + + uint64 respondentBalBefore = getBalance(respondent); + uint64 referrerBalBefore = getBalance(referrer); + uint64 oracleBalBefore = getBalance(oracle); + uint64 creatorBalBefore = getBalance(creator); + + // Payout with Tier 1 (10% bonus) + // Reward per resp = 1000 + // Base (40%) = 400, Referral (20%) = 200, Platform (3%) = 30 + // Bonus (10%) = 100. burnFee = 15, oracleFee = 15. + // Total Spent = 730. Leftover = 270 refunded to creator. + auto payoutOut = qsurv.payout(oracle, 1, respondent, referrer, 1); + EXPECT_EQ(payoutOut.success, 1); + + uint64 respondentBalAfter = getBalance(respondent); + uint64 referrerBalAfter = getBalance(referrer); + uint64 oracleBalAfter = getBalance(oracle); + uint64 creatorBalAfter = getBalance(creator); + + EXPECT_EQ(respondentBalAfter - respondentBalBefore, + 500); // 400 base + 100 bonus + EXPECT_EQ(referrerBalAfter - referrerBalBefore, 200); // 200 referral + EXPECT_EQ(oracleBalAfter - oracleBalBefore, + 15); // 15 oracle (half of 30 platform fee; other half burned) + EXPECT_EQ(creatorBalAfter - creatorBalBefore, 270); // leftover refunded + + auto surveyAfter = qsurv.getSurvey(1); + EXPECT_EQ(surveyAfter.balance, 0); // Balance refunded to creator + EXPECT_EQ(surveyAfter.isActive, + 0); // Marked inactive since max respondents reached + + // Test over-payout fail + auto overPayoutOut = qsurv.payout(oracle, 1, respondent, referrer, 1); + EXPECT_EQ(overPayoutOut.success, + 0); // Should fail since inactive and respondents full +} + +TEST(ContractQSurv, AbortSurvey_RefundAndReset) +{ + ContractTestingQSurv qsurv; + const id creator(1, 0, 0, 0); + const id hacker(888, 0, 0, 0); + + increaseEnergy(creator, 5000); + + QPI::Array hash; + for (int i = 0; i < 64; i++) + { + hash.set(i, 1); + } + + uint64 rewardPool = 2000; + qsurv.createSurvey(creator, rewardPool, 2, hash, rewardPool); + + auto surveyBefore = qsurv.getSurvey(1); + EXPECT_EQ(surveyBefore.balance, 2000); + EXPECT_EQ(surveyBefore.isActive, 1); + + uint64 creatorBalBefore = getBalance(creator); + + // Hacker tries to abort + auto abortHacker = qsurv.abortSurvey(hacker, 1); + EXPECT_EQ(abortHacker.success, 0); + + // Creator successfully aborts + auto abortCreator = qsurv.abortSurvey(creator, 1); + EXPECT_EQ(abortCreator.success, 1); + + uint64 creatorBalAfter = getBalance(creator); + EXPECT_EQ(creatorBalAfter - creatorBalBefore, + 2000); // Reclaimed the unspent balance! + + // Survey slot should be entirely reset to allow reclaiming + auto surveyAfter = qsurv.getSurvey(1); + EXPECT_EQ(surveyAfter.isActive, 0); + EXPECT_EQ(surveyAfter.balance, 0); + EXPECT_EQ(surveyAfter.currentRespondents, surveyAfter.maxRespondents); +} diff --git a/test/test.vcxproj b/test/test.vcxproj index b88db9fcf..928e47741 100644 --- a/test/test.vcxproj +++ b/test/test.vcxproj @@ -124,6 +124,7 @@ + diff --git a/test/test.vcxproj.filters b/test/test.vcxproj.filters index 589346059..276050e4d 100644 --- a/test/test.vcxproj.filters +++ b/test/test.vcxproj.filters @@ -47,6 +47,7 @@ +