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 @@
+