From 3fd5e3428df89e127878f28c9cecdb1a320db319 Mon Sep 17 00:00:00 2001 From: N-010 Date: Sat, 8 Nov 2025 21:44:31 +0300 Subject: [PATCH 01/77] Implement QThirtyFour contract with ticket purchasing and winner selection logic --- src/contracts/QThirtyFour.h | 284 ++++++++++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 src/contracts/QThirtyFour.h diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h new file mode 100644 index 000000000..460d9bb2c --- /dev/null +++ b/src/contracts/QThirtyFour.h @@ -0,0 +1,284 @@ +using namespace QPI; + +struct QTF2 +{ +}; + +static constexpr uint64 QTF_MAX_NUMBER_OF_PLAYERS = 1024; +static constexpr uint64 QTF_RANDOM_VALUES_COUNT = 4; +static constexpr uint64 QTF_TICKET_PRICE = 1000000; + +static id QTF_ADDRESS_DEV_TEAM = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, + _R, _L, _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); + +using QTFRandomValues = Array; +using QFTWinnerPlayers = Array; + +struct QTF : public ContractBase +{ +public: + enum class EReturnCode : uint8 + { + SUCCESS = 0, + INVALID_TICKET_PRICE = 1, + MAX_PLAYERS_REACHED = 2, + ACCESS_DENIED = 3, + + MAX_VALUE = UINT8_MAX + }; + + struct PlayerData + { + id player; + QTFRandomValues randomValues; + }; + + struct WinnerData + { + Array winners; + QTFRandomValues winnerValues; + uint64 winnerCounter; + uint16 epoch; + }; + + struct NextEpochData + { + uint64 newTicketPrice; + }; + + // Buy Ticket + struct BuyTicket_input + { + QTFRandomValues randomValues; + }; + struct BuyTicket_output + { + EReturnCode returnCode; + }; + + // Set Price + struct SetPrice_input + { + uint64 newPrice; + }; + struct SetPrice_output + { + EReturnCode returnCode; + }; + + // Ticket Price + struct GetTicketPrice_input + { + }; + struct GetTicketPrice_output + { + uint64 ticketPrice; + }; + + // Next Epoch Data + struct GetNextEpochData_input + { + }; + struct GetNextEpochData_output + { + NextEpochData nextEpochData; + }; + + // Winner Data + struct GetWinnerData_input + { + }; + + struct GetWinnerData_output + { + WinnerData winnerData; + }; + +public: + // Contract lifecycle methods + INITIALIZE() + { + // Addresses + state.teamAddress = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, + _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); + + // Owner address (currently identical to developer address; can be split in future revisions). + state.ownerAddress = state.teamAddress; + + state.ticketPrice = QTF_TICKET_PRICE; + } + + REGISTER_USER_FUNCTIONS_AND_PROCEDURES() + { + // Procedures + REGISTER_USER_PROCEDURE(BuyTicket, 1); + REGISTER_USER_PROCEDURE(SetPrice, 2); + // Functions + REGISTER_USER_FUNCTION(GetTicketPrice, 1); + REGISTER_USER_FUNCTION(GetNextEpochData, 2); + REGISTER_USER_FUNCTION(GetWinnerData, 3); + } + + BEGIN_EPOCH() + { + applyNextEpochData(state); + clearState(state); + } + + END_EPOCH() {} + + // Procedures + PUBLIC_PROCEDURE(BuyTicket) + { + if (state.numberOfPlayers >= QTF_MAX_NUMBER_OF_PLAYERS) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + output.returnCode = EReturnCode::MAX_PLAYERS_REACHED; + return; + } + + if (qpi.invocationReward() != state.ticketPrice) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + output.returnCode = EReturnCode::INVALID_TICKET_PRICE; + return; + } + + addPlayerInfo(state, qpi.invocator(), input.randomValues); + output.returnCode = EReturnCode::SUCCESS; + } + + PUBLIC_PROCEDURE(SetPrice) + { + if (qpi.invocator() != state.ownerAddress) + { + output.returnCode = EReturnCode::ACCESS_DENIED; + return; + } + + if (input.newPrice == 0) + { + output.returnCode = EReturnCode::INVALID_TICKET_PRICE; + return; + } + + state.nextEpochData.newTicketPrice = input.newPrice; + } + + // Functions + PUBLIC_FUNCTION(GetTicketPrice) { output.ticketPrice = state.ticketPrice; } + PUBLIC_FUNCTION(GetNextEpochData) { output.nextEpochData = state.nextEpochData; } + PUBLIC_FUNCTION(GetWinnerData) { output.winnerData = state.lastWinnerData; } + +public: +protected: + static void clearState(QTF& state) + { + // Clear players list + if (state.numberOfPlayers > 0) + { + setMemory(state.players, 0); + state.numberOfPlayers = 0; + } + + // Clear temporary value + { + state.tmpValue64 = 0; + state.tmpValue8 = 0; + setMemory(state.tempPlayerInfo, 0); + } + + setMemory(state.nextEpochData, 0); + } + + static void applyNextEpochData(QTF& state) + { + // Apply new ticket price + if (state.nextEpochData.newTicketPrice > 0) + { + state.ticketPrice = state.nextEpochData.newTicketPrice; + state.nextEpochData.newTicketPrice = 0; + } + } + + static void getRandomValues(const uint64& seed, uint64& tmpValue, uint8& index, QTFRandomValues& output) + { + tmpValue = seed; + for (index = 0; index < output.capacity(); ++index) + { + tmpValue ^= tmpValue >> 12; + tmpValue ^= tmpValue << 25; + tmpValue ^= tmpValue >> 27; + tmpValue *= 2685821657736338717ULL; + output.set(index, tmpValue); + } + } + + static void mix64(const uint64& x, uint64& outValue) + { + outValue = x; + + outValue ^= outValue >> 30; + outValue *= 0xbf58476d1ce4e5b9ULL; + outValue ^= outValue >> 27; + outValue *= 0x94d049bb133111ebULL; + outValue ^= outValue >> 31; + } + + static void deriveOne(const uint64& r, const uint64& idx, uint64& outValue) { mix64(r + 0x9e3779b97f4a7c15ULL * (idx + 1), outValue); } + + static void deriveFour(const uint64& r, uint64& tempValue, uint8& index, QTFRandomValues& out) + { + for (index = 0; index < out.capacity(); ++index) + { + deriveOne(r, index, tempValue); + out.set(index, tempValue); + } + } + + static void addPlayerInfo(QTF& state, const id& playerId, const QTFRandomValues& randomValues) + { + state.tempPlayerInfo.player = playerId; + state.tempPlayerInfo.randomValues = randomValues; + + state.players.set(state.numberOfPlayers++, state.tempPlayerInfo); + } + + static void clearWinnerData(QTF& state) { setMemory(state.lastWinnerData, 0); } + + static void fillWinnerData(QTF& state, const PlayerData& playerData, const QTFRandomValues& winnerValues, const uint16& epoch) + { + state.lastWinnerData.winners.set(state.lastWinnerData.winnerCounter++, playerData); + state.lastWinnerData.winnerValues = winnerValues; + state.lastWinnerData.epoch = epoch; + } + +protected: + WinnerData lastWinnerData; + + NextEpochData nextEpochData; + + Array players; + + id teamAddress; + + id ownerAddress; + + uint64 numberOfPlayers; + + uint64 ticketPrice; + + PlayerData tempPlayerInfo; + + uint64 tmpValue64; + + uint8 tmpValue8; +}; From d7f0bf32235a58e4d56d7b3c3ba4b2299f532c8f Mon Sep 17 00:00:00 2001 From: N-010 Date: Sun, 9 Nov 2025 23:44:54 +0300 Subject: [PATCH 02/77] Add QReservePool and QTF contract definitions with related functionalities --- src/Qubic.vcxproj.filters | 6 ++ src/contracts/QReservePool.h | 203 +++++++++++++++++++++++++++++++++++ src/contracts/QThirtyFour.h | 4 +- test/test.vcxproj | 3 +- test/test.vcxproj.filters | 3 +- 5 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 src/contracts/QReservePool.h diff --git a/src/Qubic.vcxproj.filters b/src/Qubic.vcxproj.filters index 2c0da5fb5..e4ad51acb 100644 --- a/src/Qubic.vcxproj.filters +++ b/src/Qubic.vcxproj.filters @@ -303,6 +303,12 @@ contracts + + contracts + + + contracts + contract_core diff --git a/src/contracts/QReservePool.h b/src/contracts/QReservePool.h new file mode 100644 index 000000000..c7d91d4c0 --- /dev/null +++ b/src/contracts/QReservePool.h @@ -0,0 +1,203 @@ +using namespace QPI; + +// Number of available smart contracts in the QRP contract. +static constexpr uint16 QRP_AVAILABLE_SC_NUM = 128; +static constexpr uint64 QRP_QTF_INDEX = 19; + +enum class QRPReturnCode : uint8 +{ + SUCCESS = 0, + ACCESS_DENIED = 1, + INSUFFICIENT_RESERVE = 2, + + MAX_VALUE = UINT8_MAX +}; + +struct QRP2 +{ +}; + +struct QRP : public ContractBase +{ +public: + // Get Reserve + struct GetReserve_input + { + uint64 revenue; + }; + + struct GetReserve_output + { + // How much revenue is allocated to SC + uint64 allocatedRevenue; + QRPReturnCode returnCode; + }; + + struct GetReserve_locals + { + Entity entity; + uint64 checkAmount; + }; + + // Add Available Smart Contract + struct AddAvailableSC_input + { + uint64 scIndex; + }; + + struct AddAvailableSC_output + { + QRPReturnCode returnCode; + }; + + // Remove Available Smart Contract + struct RemoveAvailableSC_input + { + uint64 scIndex; + }; + + struct RemoveAvailableSC_output + { + QRPReturnCode returnCode; + }; + + // Get Available Reserve + struct GetAvailableReserve_input + { + }; + + struct GetAvailableReserve_output + { + uint64 availableReserve; + }; + + struct GetAvailableReserve_locals + { + Entity entity; + }; + + // Get Available Smart Contract + struct GetAvailableSC_input + { + }; + + struct GetAvailableSC_output + { + Array availableSCs; + }; + + struct GetAvailableSC_locals + { + sint64 nextIndex; + uint64 arrayIndex; + }; + +public: + INITIALIZE() + { + // Set team/developer address (owner and team are the same for now) + state.teamAddress = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, + _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); + state.ownerAddress = state.teamAddress; + + // Adds QTF to the list of available smart contracts. + state.availableSmartContracts.add(id(QRP_QTF_INDEX, 0, 0, 0)); + } + + REGISTER_USER_FUNCTIONS_AND_PROCEDURES() + { + // Procedures + REGISTER_USER_PROCEDURE(GetReserve, 1); + REGISTER_USER_PROCEDURE(AddAvailableSC, 2); + REGISTER_USER_PROCEDURE(RemoveAvailableSC, 3); + // Functions + REGISTER_USER_FUNCTION(GetAvailableReserve, 1); + REGISTER_USER_FUNCTION(GetAvailableSC, 2); + } + + PUBLIC_PROCEDURE_WITH_LOCALS(GetReserve) + { + + if (!state.availableSmartContracts.contains(qpi.invocator())) + { + output.allocatedRevenue = 0; + output.returnCode = QRPReturnCode::ACCESS_DENIED; + return; + } + + qpi.getEntity(SELF, locals.entity); + locals.checkAmount = max(locals.entity.incomingAmount - locals.entity.outgoingAmount, 0i64); + if (locals.checkAmount == 0 || input.revenue > locals.checkAmount) + { + output.allocatedRevenue = 0; + output.returnCode = QRPReturnCode::INSUFFICIENT_RESERVE; + return; + } + + output.allocatedRevenue = input.revenue; + output.returnCode = QRPReturnCode::SUCCESS; + + qpi.transfer(qpi.invocator(), output.allocatedRevenue); + } + + PUBLIC_PROCEDURE(AddAvailableSC) + { + if (qpi.invocator() != state.ownerAddress) + { + output.returnCode = QRPReturnCode::ACCESS_DENIED; + return; + } + + state.availableSmartContracts.add(id(input.scIndex, 0, 0, 0)); + output.returnCode = QRPReturnCode::SUCCESS; + } + + PUBLIC_PROCEDURE(RemoveAvailableSC) + { + if (qpi.invocator() != state.ownerAddress) + { + output.returnCode = QRPReturnCode::ACCESS_DENIED; + return; + } + + state.availableSmartContracts.remove(id(input.scIndex, 0, 0, 0)); + output.returnCode = QRPReturnCode::SUCCESS; + } + + PUBLIC_FUNCTION_WITH_LOCALS(GetAvailableReserve) + { + qpi.getEntity(SELF, locals.entity); + output.availableReserve = max(locals.entity.incomingAmount - locals.entity.outgoingAmount, 0i64); + } + + PUBLIC_FUNCTION_WITH_LOCALS(GetAvailableSC) + { + locals.arrayIndex = 0; + locals.nextIndex = -1; + + locals.nextIndex = state.availableSmartContracts.nextElementIndex(locals.nextIndex); + while (locals.nextIndex != NULL_INDEX) + { + output.availableSCs.set(locals.arrayIndex++, state.availableSmartContracts.key(locals.nextIndex)); + locals.nextIndex = state.availableSmartContracts.nextElementIndex(locals.nextIndex); + } + } + +protected: + template static constexpr const T& max(const T& a, const T& b) { return (a > b) ? a : b; } + +protected: + /** + * @brief Address of the team managing the lottery contract. + * Initialized to a zero address. + */ + id teamAddress; + + /** + * @brief Address of the owner of the lottery contract. + * Initialized to a zero address. + */ + id ownerAddress; + + HashSet availableSmartContracts; +}; diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 460d9bb2c..64ce25d0d 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -6,12 +6,13 @@ struct QTF2 static constexpr uint64 QTF_MAX_NUMBER_OF_PLAYERS = 1024; static constexpr uint64 QTF_RANDOM_VALUES_COUNT = 4; +static constexpr uint64 QTF_MAX_RANDOM_VALUE = 30; static constexpr uint64 QTF_TICKET_PRICE = 1000000; static id QTF_ADDRESS_DEV_TEAM = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); -using QTFRandomValues = Array; +using QTFRandomValues = Array; using QFTWinnerPlayers = Array; struct QTF : public ContractBase @@ -218,6 +219,7 @@ struct QTF : public ContractBase tmpValue ^= tmpValue << 25; tmpValue ^= tmpValue >> 27; tmpValue *= 2685821657736338717ULL; + tmpValue = mod(tmpValue, QTF_MAX_RANDOM_VALUE) + 1; output.set(index, tmpValue); } } diff --git a/test/test.vcxproj b/test/test.vcxproj index 5132f62d4..da0862aa2 100644 --- a/test/test.vcxproj +++ b/test/test.vcxproj @@ -120,6 +120,7 @@ + @@ -192,4 +193,4 @@ - \ No newline at end of file + diff --git a/test/test.vcxproj.filters b/test/test.vcxproj.filters index 725c90fc9..b07da6b71 100644 --- a/test/test.vcxproj.filters +++ b/test/test.vcxproj.filters @@ -44,6 +44,7 @@ + @@ -69,4 +70,4 @@ core - \ No newline at end of file + From 33ac4cb2978151974e480f437de1b7a56dc1483c Mon Sep 17 00:00:00 2001 From: N-010 Date: Mon, 10 Nov 2025 10:46:06 +0300 Subject: [PATCH 03/77] Refactor QThirtyFour.h: reposition QTF2 struct definition for clarity --- src/contracts/QThirtyFour.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 64ce25d0d..05461bd65 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -1,9 +1,5 @@ using namespace QPI; -struct QTF2 -{ -}; - static constexpr uint64 QTF_MAX_NUMBER_OF_PLAYERS = 1024; static constexpr uint64 QTF_RANDOM_VALUES_COUNT = 4; static constexpr uint64 QTF_MAX_RANDOM_VALUE = 30; @@ -15,6 +11,10 @@ static id QTF_ADDRESS_DEV_TEAM = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, using QTFRandomValues = Array; using QFTWinnerPlayers = Array; +struct QTF2 +{ +}; + struct QTF : public ContractBase { public: From 47852f8f115091a8400c705f3bd75afd962a3d00 Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 4 Dec 2025 22:04:38 +0300 Subject: [PATCH 04/77] Test version for QThirtyFour --- src/contracts/QReservePool.h | 3 +- src/contracts/QThirtyFour.h | 1542 ++++++++++++++++++++++++++++++++-- test/contract_qrp.cpp | 165 ++++ 3 files changed, 1633 insertions(+), 77 deletions(-) create mode 100644 test/contract_qrp.cpp diff --git a/src/contracts/QReservePool.h b/src/contracts/QReservePool.h index c7d91d4c0..4a5985808 100644 --- a/src/contracts/QReservePool.h +++ b/src/contracts/QReservePool.h @@ -2,7 +2,7 @@ // Number of available smart contracts in the QRP contract. static constexpr uint16 QRP_AVAILABLE_SC_NUM = 128; -static constexpr uint64 QRP_QTF_INDEX = 19; +static constexpr uint64 QRP_QTF_INDEX = 20; enum class QRPReturnCode : uint8 { @@ -117,7 +117,6 @@ struct QRP : public ContractBase PUBLIC_PROCEDURE_WITH_LOCALS(GetReserve) { - if (!state.availableSmartContracts.contains(qpi.invocator())) { output.allocatedRevenue = 0; diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 05461bd65..52960e584 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -1,12 +1,74 @@ -using namespace QPI; +using namespace QPI; +// --- Core game parameters ---------------------------------------------------- static constexpr uint64 QTF_MAX_NUMBER_OF_PLAYERS = 1024; static constexpr uint64 QTF_RANDOM_VALUES_COUNT = 4; static constexpr uint64 QTF_MAX_RANDOM_VALUE = 30; static constexpr uint64 QTF_TICKET_PRICE = 1000000; -static id QTF_ADDRESS_DEV_TEAM = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, - _R, _L, _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); +// Baseline split for k2/k3 when FR is OFF (spec leaves this open; choose 50/50 as default). +static constexpr uint64 QTF_BASE_K3_SHARE_BP = 5000; // basis points of winners block (50.00%). + +// --- Fast-Recovery (FR) parameters (spec defaults) -------------------------- +// Fast-Recovery base redirect percentages (always active when FR=ON) +static constexpr uint64 QTF_FR_DEV_REDIRECT_BP = 100; // 1.00% of R (base redirect, always applied) +static constexpr uint64 QTF_FR_DIST_REDIRECT_BP = 100; // 1.00% of R (base redirect, always applied) + +// Deficit-driven extra redirect parameters (dynamic, no hard N threshold) +// The extra redirect is calculated based on: +// - Deficit: Δ = max(0, targetJackpot - currentJackpot) +// - Expected rounds to k=4: E_k4(N) = 1 / (1 - (1-p4)^N) +// - Horizon: H = min(E_k4(N), R_goal_rounds_cap) +// - Needed gain: g_need = max(0, Δ/H - baseGain) +// - Extra percentage: extra_pp = clamp(g_need / R, 0, extra_max) +// - Split equally: dev_extra = dist_extra = extra_pp / 2 +static constexpr uint64 QTF_FR_EXTRA_MAX_BP = 70; // Maximum extra redirect: 0.70% of R total (0.35% each Dev/Dist) +static constexpr uint64 QTF_FR_GOAL_ROUNDS_CAP = 100; // Cap on expected rounds horizon H for deficit calculation +static constexpr uint64 QTF_FIXED_POINT_SCALE = 1000000; // Scale for fixed-point arithmetic (6 decimals precision) + +// Probability constants for k=4 win (exact combinatorics: 4-of-30) +// p4 = C(4,4) * C(26,0) / C(30,4) = 1 / 27405 +static constexpr uint64 QTF_P4_DENOMINATOR = 27405; // Denominator for k=4 probability (1/27405) +static constexpr uint64 QTF_FR_WINNERS_RAKE_BP = 500; // 5% of winners block from k3 +static constexpr uint64 QTF_FR_K3_SHARE_BP = 3500; // 35% of win_eff to k3 +static constexpr uint64 QTF_FR_K2_SHARE_BP = 6500; // 65% of win_eff to k2 +static constexpr uint64 QTF_FR_ALPHA_BP = 500; // alpha = 0.05 -> 95% overflow to jackpot +static constexpr uint16 QTF_FR_POST_K4_WINDOW_ROUNDS = 50; +static constexpr uint16 QTF_FR_HYSTERESIS_ROUNDS = 3; + +// --- Floors and reserve safety ---------------------------------------------- +static constexpr uint64 QTF_K2_FLOOR_MULT = 1; // numerator for 0.5 * P (we divide by 2) +static constexpr uint64 QTF_K2_FLOOR_DIV = 2; +static constexpr uint64 QTF_K3_FLOOR_MULT = 5; // 5 * P +static constexpr uint64 QTF_TOPUP_PER_WINNER_CAP_MULT = 25; // 25 * P +static constexpr uint64 QTF_TOPUP_RESERVE_PCT_BP = 1000; // 10% of reserve per round +static constexpr uint64 QTF_RESERVE_SOFT_FLOOR_MULT = 20; // keep at least 20 * P in reserve + +// Baseline overflow split (reserve share in basis points). If spec is updated, adjust here. +static constexpr uint64 QTF_BASELINE_OVERFLOW_ALPHA_BP = 5000; // 50% reserve / 50% jackpot + +// Reserve split between JackpotRebuild and General (50/50 default) +static constexpr uint64 QTF_RESERVE_SPLIT_JACKPOT_BP = 5000; // 50% to JackpotRebuild, 50% to General + +// Default fee percentages (fallback if RL::GetFees fails) +static constexpr uint64 QTF_DEFAULT_DEV_PERCENT = 10; +static constexpr uint64 QTF_DEFAULT_DIST_PERCENT = 20; +static constexpr uint64 QTF_DEFAULT_BURN_PERCENT = 2; +static constexpr uint64 QTF_DEFAULT_WINNERS_PERCENT = 68; + +// Maximum attempts to generate unique random value before fallback +static constexpr uint8 QTF_MAX_RANDOM_GENERATION_ATTEMPTS = 100; + +static constexpr uint8 QTF_DEFAULT_SCHEDULE = 1u << WEDNESDAY; +static constexpr uint8 QTF_DEFAULT_DRAW_HOUR = 11; // 11:00 UTC +static constexpr uint32 QTF_DEFAULT_INIT_TIME = 22u << 9 | 4u << 5 | 13u; // RL_DEFAULT_INIT_TIME + +static const id QTF_ADDRESS_DEV_TEAM = + ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, _W, _E, _T, _H, _N, _G, + _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); +static const id QTF_RANDOM_LOTTERY_CONTRACT_ID = id(RL_CONTRACT_INDEX, 0, 0, 0); +static const uint64 QTF_RANDOM_LOTTERY_ASSET_NAME = *reinterpret_cast("RL"); +static const id QTF_RESERVE_POOL_CONTRACT_ID = id(QRP_CONTRACT_INDEX, 0, 0, 0); using QTFRandomValues = Array; using QFTWinnerPlayers = Array; @@ -20,14 +82,23 @@ struct QTF : public ContractBase public: enum class EReturnCode : uint8 { - SUCCESS = 0, - INVALID_TICKET_PRICE = 1, - MAX_PLAYERS_REACHED = 2, - ACCESS_DENIED = 3, + SUCCESS, + INVALID_TICKET_PRICE, + MAX_PLAYERS_REACHED, + ACCESS_DENIED, + INVALID_NUMBERS, + INVALID_VALUE, + TICKET_SELLING_CLOSED, MAX_VALUE = UINT8_MAX }; + enum EState : uint8 + { + STATE_NONE = 0, + STATE_SELLING = 1 << 0 + }; + struct PlayerData { id player; @@ -44,7 +115,69 @@ struct QTF : public ContractBase struct NextEpochData { + public: + void clear() { setMemory(*this, 0); } + + void apply(QTF& state) const + { + if (newTicketPrice > 0) + { + state.ticketPrice = newTicketPrice; + } + if (newTargetJackpot > 0) + { + state.targetJackpot = newTargetJackpot; + } + if (newSchedule > 0) + { + state.schedule = newSchedule; + } + if (newDrawHour > 0) + { + state.drawHour = newDrawHour; + } + } + + public: uint64 newTicketPrice; + uint64 newTargetJackpot; + uint8 newSchedule; + uint8 newDrawHour; + }; + + struct PoolsSnapshot + { + uint64 jackpot; + uint64 reserveGeneral; + uint64 reserveJackpot; + uint64 targetJackpot; + uint8 frActive; + uint16 roundsSinceK4; + }; + + struct PoolsSnapshot_input + { + }; + + struct PoolsSnapshot_output + { + PoolsSnapshot pools; + }; + + // ValidateNumbers: Check if all numbers are valid and unique + struct ValidateNumbers_input + { + QTFRandomValues numbers; // Numbers to validate + }; + struct ValidateNumbers_output + { + bit isValid; // true if all numbers valid and unique + }; + struct ValidateNumbers_locals + { + HashSet seen; + uint8 idx; + uint8 value; }; // Buy Ticket @@ -56,6 +189,12 @@ struct QTF : public ContractBase { EReturnCode returnCode; }; + struct BuyTicket_locals + { + // CALL parameters for ValidateNumbers + ValidateNumbers_input validateInput; + ValidateNumbers_output validateOutput; + }; // Set Price struct SetPrice_input @@ -67,6 +206,177 @@ struct QTF : public ContractBase EReturnCode returnCode; }; + // Set Schedule + struct SetSchedule_input + { + uint8 newSchedule; + }; + struct SetSchedule_output + { + EReturnCode returnCode; + }; + + // Set draw hour + struct SetDrawHour_input + { + uint8 newDrawHour; + }; + struct SetDrawHour_output + { + EReturnCode returnCode; + }; + + // Set Target Carry (Jackpot target) + struct SetTargetJackpot_input + { + uint64 newTargetJackpot; + }; + struct SetTargetJackpot_output + { + EReturnCode returnCode; + }; + + // Return All Tickets (refund all players) + struct ReturnAllTickets_input + { + }; + struct ReturnAllTickets_output + { + }; + struct ReturnAllTickets_locals + { + uint64 i; // Loop counter for mass-refund + }; + + // Check Contract Balance + struct CheckContractBalance_input + { + uint64 expectedRevenue; // Expected revenue to compare against balance + }; + struct CheckContractBalance_output + { + bit hasEnough; // true if balance >= expectedRevenue + uint64 actualBalance; // Current contract balance + }; + struct CheckContractBalance_locals + { + Entity entity; + }; + + // Calculate Base Gain (FR base carry growth estimation) + struct CalculateBaseGain_input + { + uint64 revenue; // Round revenue (N * ticketPrice) + uint64 winnersBlock; // 68% of revenue allocated to winners + }; + struct CalculateBaseGain_output + { + uint64 baseGain; // Estimated carry gain in qu + }; + struct CalculateBaseGain_locals + { + uint64 devRedirect; + uint64 distRedirect; + uint64 winnersRake; + uint64 estimatedOverflow; + uint64 overflowToCarry; + }; + + // PowerFixedPoint: Computes (base^exp) in fixed-point arithmetic + struct PowerFixedPoint_input + { + uint64 base; // Base value in fixed-point (scaled by QTF_FIXED_POINT_SCALE) + uint64 exp; // Exponent (integer) + }; + struct PowerFixedPoint_output + { + uint64 result; // base^exp in fixed-point + }; + struct PowerFixedPoint_locals + { + uint64 tmpBase; + uint64 expCopy; // Copy of exp (modified during computation) + }; + + // CalculateExpectedRoundsToK4: E_k4(N) = 1 / (1 - (1-p4)^N) + struct CalculateExpectedRoundsToK4_input + { + uint64 N; // Number of tickets + }; + struct CalculateExpectedRoundsToK4_output + { + uint64 expectedRounds; // E_k4(N) in fixed-point + }; + struct CalculateExpectedRoundsToK4_locals + { + uint64 oneMinusP4; + uint64 pow1mP4N; + uint64 denomFP; + PowerFixedPoint_input pfInput; + PowerFixedPoint_output pfOutput; + }; + + // Calculate Extra Redirect BP (deficit-driven) + struct CalculateExtraRedirectBP_input + { + uint64 N; // Number of tickets + uint64 delta; // Deficit to target jackpot + uint64 revenue; // Round revenue + uint64 baseGain; // Base carry gain per round (without extra) + }; + struct CalculateExtraRedirectBP_output + { + uint64 extraBP; // Extra redirect in basis points (total, to be split 50/50 Dev/Dist) + }; + struct CalculateExtraRedirectBP_locals + { + uint64 horizonFP; + uint64 horizon; + uint64 requiredGainPerRound; + uint64 gNeed; + uint64 extraBPTemp; + CalculateExpectedRoundsToK4_input calcE4Input; + CalculateExpectedRoundsToK4_output calcE4Output; + }; + + // GetRandomValues: Generate 4 unique random values from [1..30] + struct GetRandomValues_input + { + uint64 seed; // Random seed from K12 + }; + struct GetRandomValues_output + { + QTFRandomValues values; // 4 unique random values [1..30] + }; + struct GetRandomValues_locals + { + uint64 tempValue; + uint8 index; + uint8 candidate; + uint8 attempts; + uint8 fallback; + HashSet used; + }; + + // CalcReserveTopUp: Calculate safe reserve top-up amount + struct CalcReserveTopUp_input + { + uint64 availableReserve; + uint64 needed; + uint64 perWinnerCapTotal; + uint64 ticketPrice; + }; + struct CalcReserveTopUp_output + { + uint64 topUpAmount; + }; + struct CalcReserveTopUp_locals + { + uint64 softFloor; + uint64 usableReserve; + uint64 maxPerRound; + }; + // Ticket Price struct GetTicketPrice_input { @@ -85,6 +395,15 @@ struct QTF : public ContractBase NextEpochData nextEpochData; }; + // Schedule + struct GetSchedule_input + { + }; + struct GetSchedule_output + { + uint8 schedule; + }; + // Winner Data struct GetWinnerData_input { @@ -95,42 +414,315 @@ struct QTF : public ContractBase WinnerData winnerData; }; + // Pools + struct GetPools_input + { + }; + struct GetPools_output + { + PoolsSnapshot pools; + }; + + // Draw hour + struct GetDrawHour_input + { + }; + struct GetDrawHour_output + { + uint8 drawHour; + }; + + struct GetState_input + { + }; + struct GetState_output + { + uint8 currentState; + }; + + struct SettleEpoch_input + { + }; + + struct SettleEpoch_output + { + }; + + struct CountMatches_input + { + QTFRandomValues playerValues; + QTFRandomValues winningValues; + }; + + struct CountMatches_output + { + uint8 matches; + }; + struct CountMatches_locals + { + uint64 i; + uint8 maskA; + uint8 maskB; + }; + + struct SettlementLocals + { + QTFRandomValues winningValues; + ReturnAllTickets_input returnAllTicketsInput; + ReturnAllTickets_output returnAllTicketsOutput; + ReturnAllTickets_locals returnAllTicketsLocals; + CheckContractBalance_input checkBalanceInput; + CheckContractBalance_output checkBalanceOutput; + CheckContractBalance_locals checkBalanceLocals; + CountMatches_input countMatchesInput; + CountMatches_output countMatchesOutput; + uint16 currentEpoch; + uint64 revenue; // ticketPrice * players count + uint64 winnersBlock; + uint64 k2Pool; + uint64 k3Pool; + uint64 carryAdd; + uint64 reserveAdd; + uint64 winnersOverflow; + uint64 devPayout; // Dev after redirects + uint64 distPayout; // Distribution after redirects + uint64 burnAmount; + uint64 devPercent; + uint64 distPercent; + uint64 burnPercent; + uint64 winnersPercent; + uint64 devRedirect; + uint64 distRedirect; + uint64 winnersRake; + uint64 k2PayoutPool; + uint64 k3PayoutPool; + uint64 k2PerWinner; + uint64 k3PerWinner; + uint64 topUpK2; + uint64 topUpK3; + uint64 countK2; + uint64 countK3; + uint64 countK4; + uint64 tmp64a; + uint64 tmp64b; + uint64 tmp64c; + uint64 i; + uint8 matches; + bit shouldActivateFR; + // Deficit-driven extra redirect calculation + uint64 delta; // Deficit: max(0, targetJackpot - jackpot) + uint64 devExtraBP; // Dev share of extra: extraRedirectBP / 2 + uint64 distExtraBP; // Dist share of extra: extraRedirectBP / 2 + // CALL parameters for CalculateBaseGain + CalculateBaseGain_input calcBaseGainInput; + CalculateBaseGain_output calcBaseGainOutput; + // CALL parameters for CalculateExtraRedirectBP + CalculateExtraRedirectBP_input calcExtraInput; + CalculateExtraRedirectBP_output calcExtraOutput; + // CALL parameters for GetRandomValues + GetRandomValues_input getRandomInput; + GetRandomValues_output getRandomOutput; + // CALL parameters for CalcReserveTopUp + CalcReserveTopUp_input calcTopUpInput; + CalcReserveTopUp_output calcTopUpOutput; + // CALL_OTHER_CONTRACT parameters for QRP::GetReserve (external reserve pool) + QRP::GetReserve_input qrpGetReserveInput; + QRP::GetReserve_output qrpGetReserveOutput; + uint64 qrpRequested; // Amount requested from QRP + uint64 qrpReceived; // Amount actually received from QRP + RL::GetFees_input feesInput; + RL::GetFees_output feesOutput; + uint64 dividendPerShare; + Asset rlAsset; + AssetPossessionIterator rlIter; + uint64 rlTotalShares; + uint64 rlPayback; + uint64 rlShares; + // Cache for countMatches results to avoid redundant calculations + Array cachedMatches; + }; + + struct SettleEpoch_locals : public SettlementLocals + { + }; + + struct END_EPOCH_locals + { + SettleEpoch_locals settlement; + SettleEpoch_input settleInput; + SettleEpoch_output settleOutput; + }; + + struct BEGIN_TICK_locals + { + SettleEpoch_input settleInput; + SettleEpoch_output settleOutput; + uint32 currentDateStamp; + uint8 currentDayOfWeek; + uint8 currentHour; + bit isWednesday; + bit isScheduledToday; + }; + public: // Contract lifecycle methods INITIALIZE() { - // Addresses - state.teamAddress = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, - _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); - - // Owner address (currently identical to developer address; can be split in future revisions). + state.teamAddress = QTF_ADDRESS_DEV_TEAM; state.ownerAddress = state.teamAddress; - state.ticketPrice = QTF_TICKET_PRICE; + state.targetJackpot = 1000000000ULL; + state.overflowAlphaBP = QTF_BASELINE_OVERFLOW_ALPHA_BP; + state.schedule = QTF_DEFAULT_SCHEDULE; + state.drawHour = QTF_DEFAULT_DRAW_HOUR; + state.lastDrawDateStamp = QTF_DEFAULT_INIT_TIME; + state.frActive = false; + state.frRoundsSinceK4 = 0; + state.frRoundsAtOrAboveTarget = 0; + state.numberOfPlayers = 0; + state.jackpot = 0; + state.reserveGeneral = 0; + state.reserveJackpotRebuild = 0; + state.currentState = STATE_NONE; } REGISTER_USER_FUNCTIONS_AND_PROCEDURES() { - // Procedures REGISTER_USER_PROCEDURE(BuyTicket, 1); REGISTER_USER_PROCEDURE(SetPrice, 2); - // Functions + REGISTER_USER_PROCEDURE(SetSchedule, 3); + REGISTER_USER_PROCEDURE(SetTargetJackpot, 4); + REGISTER_USER_PROCEDURE(SetDrawHour, 5); REGISTER_USER_FUNCTION(GetTicketPrice, 1); REGISTER_USER_FUNCTION(GetNextEpochData, 2); REGISTER_USER_FUNCTION(GetWinnerData, 3); + REGISTER_USER_FUNCTION(GetPools, 4); + REGISTER_USER_FUNCTION(GetSchedule, 5); + REGISTER_USER_FUNCTION(GetDrawHour, 6); + REGISTER_USER_FUNCTION(GetState, 7); } BEGIN_EPOCH() { applyNextEpochData(state); - clearState(state); + + if (state.schedule == 0) + { + state.schedule = QTF_DEFAULT_SCHEDULE; + } + if (state.drawHour == 0) + { + state.drawHour = QTF_DEFAULT_DRAW_HOUR; + } + RL::makeDateStamp(qpi.year(), qpi.month(), qpi.day(), state.lastDrawDateStamp); + clearEpochState(state); + enableBuyTicket(state, state.lastDrawDateStamp != RL_DEFAULT_INIT_TIME); + } + + // Settle and reset at epoch end (uses locals buffer) + END_EPOCH_WITH_LOCALS() + { + enableBuyTicket(state, false); + CALL(SettleEpoch, locals.settleInput, locals.settleOutput); + clearEpochState(state); + } + + // Scheduled draw processor + BEGIN_TICK_WITH_LOCALS() + { + // Throttle: run logic only once per RL_TICK_UPDATE_PERIOD ticks + if (mod(qpi.tick(), static_cast(RL_TICK_UPDATE_PERIOD)) != 0) + { + return; + } + + locals.currentHour = qpi.hour(); + if (locals.currentHour < state.drawHour) + { + return; + } + + locals.currentDayOfWeek = qpi.dayOfWeek(qpi.year(), qpi.month(), qpi.day()); + RL::makeDateStamp(qpi.year(), qpi.month(), qpi.day(), locals.currentDateStamp); + + // Wait for valid time initialization + if (locals.currentDateStamp == QTF_DEFAULT_INIT_TIME) + { + enableBuyTicket(state, false); + state.lastDrawDateStamp = QTF_DEFAULT_INIT_TIME; + return; + } + + // First valid date after init: just record and exit + if (state.lastDrawDateStamp == QTF_DEFAULT_INIT_TIME && locals.currentDateStamp != QTF_DEFAULT_INIT_TIME) + { + enableBuyTicket(state, true); + if (locals.currentDayOfWeek == WEDNESDAY) + { + state.lastDrawDateStamp = locals.currentDateStamp; + } + else + { + state.lastDrawDateStamp = 0; + } + + return; + } + + if (locals.currentDateStamp == state.lastDrawDateStamp) + { + return; + } + + locals.isWednesday = (locals.currentDayOfWeek == WEDNESDAY); + locals.isScheduledToday = ((state.schedule & (1u << locals.currentDayOfWeek)) != 0); + + // Always draw on Wednesday; otherwise require schedule bit. + if (!locals.isWednesday && !locals.isScheduledToday) + { + return; + } + + state.lastDrawDateStamp = locals.currentDateStamp; + + // Pause selling during draw/settlement. + enableBuyTicket(state, false); + + CALL(SettleEpoch, locals.settleInput, locals.settleOutput); + clearEpochState(state); + applyNextEpochData(state); + enableBuyTicket(state, !locals.isWednesday); } - END_EPOCH() {} + POST_INCOMING_TRANSFER() + { + switch (input.type) + { + case TransferType::standardTransaction: + // Return any funds sent via standard transaction + if (input.amount > 0) + { + qpi.transfer(input.sourceId, input.amount); + } + break; + default: break; + } + } // Procedures - PUBLIC_PROCEDURE(BuyTicket) + PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicket) { + if ((state.currentState & STATE_SELLING) == 0) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + output.returnCode = EReturnCode::TICKET_SELLING_CLOSED; + return; + } + if (state.numberOfPlayers >= QTF_MAX_NUMBER_OF_PLAYERS) { if (qpi.invocationReward() > 0) @@ -153,6 +745,18 @@ struct QTF : public ContractBase return; } + locals.validateInput.numbers = input.randomValues; + CALL(ValidateNumbers, locals.validateInput, locals.validateOutput); + if (!locals.validateOutput.isValid) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + output.returnCode = EReturnCode::INVALID_NUMBERS; + return; + } + addPlayerInfo(state, qpi.invocator(), input.randomValues); output.returnCode = EReturnCode::SUCCESS; } @@ -172,62 +776,110 @@ struct QTF : public ContractBase } state.nextEpochData.newTicketPrice = input.newPrice; + output.returnCode = EReturnCode::SUCCESS; } - // Functions - PUBLIC_FUNCTION(GetTicketPrice) { output.ticketPrice = state.ticketPrice; } - PUBLIC_FUNCTION(GetNextEpochData) { output.nextEpochData = state.nextEpochData; } - PUBLIC_FUNCTION(GetWinnerData) { output.winnerData = state.lastWinnerData; } - -public: -protected: - static void clearState(QTF& state) + PUBLIC_PROCEDURE(SetSchedule) { - // Clear players list - if (state.numberOfPlayers > 0) + if (qpi.invocator() != state.ownerAddress) { - setMemory(state.players, 0); - state.numberOfPlayers = 0; + output.returnCode = EReturnCode::ACCESS_DENIED; + return; } - // Clear temporary value + if (input.newSchedule == 0) { - state.tmpValue64 = 0; - state.tmpValue8 = 0; - setMemory(state.tempPlayerInfo, 0); + output.returnCode = EReturnCode::INVALID_VALUE; + return; } - setMemory(state.nextEpochData, 0); + state.nextEpochData.newSchedule = input.newSchedule; + output.returnCode = EReturnCode::SUCCESS; } - static void applyNextEpochData(QTF& state) + PUBLIC_PROCEDURE(SetTargetJackpot) { - // Apply new ticket price - if (state.nextEpochData.newTicketPrice > 0) + if (qpi.invocator() != state.ownerAddress) { - state.ticketPrice = state.nextEpochData.newTicketPrice; - state.nextEpochData.newTicketPrice = 0; + output.returnCode = EReturnCode::ACCESS_DENIED; + return; } - } - static void getRandomValues(const uint64& seed, uint64& tmpValue, uint8& index, QTFRandomValues& output) - { - tmpValue = seed; - for (index = 0; index < output.capacity(); ++index) + if (input.newTargetJackpot == 0) { - tmpValue ^= tmpValue >> 12; - tmpValue ^= tmpValue << 25; - tmpValue ^= tmpValue >> 27; - tmpValue *= 2685821657736338717ULL; - tmpValue = mod(tmpValue, QTF_MAX_RANDOM_VALUE) + 1; - output.set(index, tmpValue); + output.returnCode = EReturnCode::INVALID_VALUE; + return; } - } + + state.nextEpochData.newTargetJackpot = input.newTargetJackpot; + output.returnCode = EReturnCode::SUCCESS; + } + + PUBLIC_PROCEDURE(SetDrawHour) + { + if (qpi.invocator() != state.ownerAddress) + { + output.returnCode = EReturnCode::ACCESS_DENIED; + return; + } + + if (input.newDrawHour == 0 || input.newDrawHour > 23) + { + output.returnCode = EReturnCode::INVALID_VALUE; + return; + } + + state.nextEpochData.newDrawHour = input.newDrawHour; + output.returnCode = EReturnCode::SUCCESS; + } + + // Functions + PUBLIC_FUNCTION(GetTicketPrice) { output.ticketPrice = state.ticketPrice; } + PUBLIC_FUNCTION(GetNextEpochData) { output.nextEpochData = state.nextEpochData; } + PUBLIC_FUNCTION(GetWinnerData) { output.winnerData = state.lastWinnerData; } + PUBLIC_FUNCTION(GetPools) + { + output.pools.jackpot = state.jackpot; + output.pools.reserveGeneral = state.reserveGeneral; + output.pools.reserveJackpot = state.reserveJackpotRebuild; + output.pools.targetJackpot = state.targetJackpot; + output.pools.frActive = state.frActive; + output.pools.roundsSinceK4 = state.frRoundsSinceK4; + } + PUBLIC_FUNCTION(GetSchedule) { output.schedule = state.schedule; } + PUBLIC_FUNCTION(GetDrawHour) { output.drawHour = state.drawHour; } + PUBLIC_FUNCTION(GetState) { output.currentState = static_cast(state.currentState); } + +protected: + static void clearEpochState(QTF& state) + { + clearPlayerData(state); + clearWinnerData(state); + } + + static void applyNextEpochData(QTF& state) + { + state.nextEpochData.apply(state); + state.nextEpochData.clear(); + } + + static void enableBuyTicket(QTF& state, bool bEnable) + { + if (bEnable) + { + state.currentState = static_cast(state.currentState | STATE_SELLING); + } + else + { + state.currentState = static_cast(state.currentState & static_cast(~STATE_SELLING)); + } + } + + // ========== Helper static functions ========== static void mix64(const uint64& x, uint64& outValue) { outValue = x; - outValue ^= outValue >> 30; outValue *= 0xbf58476d1ce4e5b9ULL; outValue ^= outValue >> 27; @@ -237,50 +889,790 @@ struct QTF : public ContractBase static void deriveOne(const uint64& r, const uint64& idx, uint64& outValue) { mix64(r + 0x9e3779b97f4a7c15ULL * (idx + 1), outValue); } - static void deriveFour(const uint64& r, uint64& tempValue, uint8& index, QTFRandomValues& out) + static void addPlayerInfo(QTF& state, const id& playerId, const QTFRandomValues& randomValues) { - for (index = 0; index < out.capacity(); ++index) - { - deriveOne(r, index, tempValue); - out.set(index, tempValue); - } + state.players.set(state.numberOfPlayers++, {playerId, randomValues}); } - static void addPlayerInfo(QTF& state, const id& playerId, const QTFRandomValues& randomValues) + static uint8 bitcount32(uint32 v) { - state.tempPlayerInfo.player = playerId; - state.tempPlayerInfo.randomValues = randomValues; - - state.players.set(state.numberOfPlayers++, state.tempPlayerInfo); + v = v - ((v >> 1) & 0x55555555u); + v = (v & 0x33333333u) + ((v >> 2) & 0x33333333u); + v = (v + (v >> 4)) & 0x0F0F0F0Fu; + v = v + (v >> 8); + v = v + (v >> 16); + return static_cast(v & 0x3Fu); } static void clearWinnerData(QTF& state) { setMemory(state.lastWinnerData, 0); } + static void clearPlayerData(QTF& state) + { + if (state.numberOfPlayers > 0) + { + setMemory(state.players, 0); + state.numberOfPlayers = 0; + } + } + static void fillWinnerData(QTF& state, const PlayerData& playerData, const QTFRandomValues& winnerValues, const uint16& epoch) { - state.lastWinnerData.winners.set(state.lastWinnerData.winnerCounter++, playerData); + if (state.lastWinnerData.winnerCounter < state.lastWinnerData.winners.capacity()) + { + state.lastWinnerData.winners.set(state.lastWinnerData.winnerCounter++, playerData); + } state.lastWinnerData.winnerValues = winnerValues; state.lastWinnerData.epoch = epoch; } protected: - WinnerData lastWinnerData; + WinnerData lastWinnerData; // last winners snapshot + + NextEpochData nextEpochData; // queued config (ticket price) + + Array players; // current epoch tickets + + id teamAddress; // Dev/team payout address + + id ownerAddress; // config authority + + uint64 numberOfPlayers; // tickets count in epoch + + uint64 ticketPrice; // active ticket price + + uint64 jackpot; // jackpot balance + + uint64 reserveGeneral; // reserve for floors + + uint64 reserveJackpotRebuild; // reserve earmarked to reseed jackpot + + uint64 targetJackpot; // FR target jackpot + + uint64 overflowAlphaBP; // baseline reserve share of overflow (bp) + + uint8 schedule; // bitmask of draw days + + uint8 drawHour; // draw hour UTC + + uint32 lastDrawDateStamp; // guard to avoid multiple draws per day + + bit frActive; // FR flag + + uint16 frRoundsSinceK4; // rounds since last jackpot hit + + uint16 frRoundsAtOrAboveTarget; // hysteresis counter for FR off + + uint8 currentState; // bitmask of STATE_* flags (e.g., STATE_SELLING) + +private: + // Core settlement pipeline for one epoch: fees, FR redirects, payouts, jackpot/reserve updates. + PRIVATE_PROCEDURE_WITH_LOCALS(SettleEpoch) + { + if (state.numberOfPlayers == 0) + { + return; + } + + locals.currentEpoch = qpi.epoch(); + locals.revenue = smul(state.ticketPrice, state.numberOfPlayers); + if (locals.revenue == 0) + { + CALL(ReturnAllTickets, locals.returnAllTicketsInput, locals.returnAllTicketsOutput); + clearPlayerData(state); + + return; + } + + // Check if contract has sufficient balance for settlement + locals.checkBalanceInput.expectedRevenue = locals.revenue; + CALL(CheckContractBalance, locals.checkBalanceInput, locals.checkBalanceOutput); + if (!locals.checkBalanceOutput.hasEnough) + { + // Insufficient balance: refund all players and abort settlement + CALL(ReturnAllTickets, locals.returnAllTicketsInput, locals.returnAllTicketsOutput); + clearPlayerData(state); + + return; + } + + // Pull fee percents from RL so Distribution matches RL shareholders. + // Fallback to default percentages if RL returns zeros. + CALL_OTHER_CONTRACT_FUNCTION(RL, GetFees, locals.feesInput, locals.feesOutput); + locals.devPercent = locals.feesOutput.teamFeePercent; + locals.distPercent = locals.feesOutput.distributionFeePercent; + locals.burnPercent = locals.feesOutput.burnPercent; + locals.winnersPercent = locals.feesOutput.winnerFeePercent; + + // Sanity check: if RL returns invalid fees, use defaults + if (locals.devPercent == 0 || locals.distPercent == 0 || locals.winnersPercent == 0) + { + locals.devPercent = QTF_DEFAULT_DEV_PERCENT; + locals.distPercent = QTF_DEFAULT_DIST_PERCENT; + locals.burnPercent = QTF_DEFAULT_BURN_PERCENT; + locals.winnersPercent = QTF_DEFAULT_WINNERS_PERCENT; + } + + locals.winnersBlock = div(smul(locals.revenue, locals.winnersPercent), 100); + locals.devPayout = div(smul(locals.revenue, locals.devPercent), 100); + locals.distPayout = div(smul(locals.revenue, locals.distPercent), 100); + locals.burnAmount = div(smul(locals.revenue, locals.burnPercent), 100); + + // FR detection and hysteresis logic. + // Update hysteresis counter BEFORE activation check to ensure correct deactivation timing. + if (state.jackpot >= state.targetJackpot) + { + state.frRoundsAtOrAboveTarget = sadd(state.frRoundsAtOrAboveTarget, 1); + } + else + { + state.frRoundsAtOrAboveTarget = 0; + } + + // FR Activation/Deactivation logic (deficit-driven, no hard N threshold) + // Activation: when carry < target AND within post-k4 window (adaptive) + // Deactivation (hysteresis): after carry >= target for 3+ rounds + locals.shouldActivateFR = (state.jackpot < state.targetJackpot) && (state.frRoundsSinceK4 < QTF_FR_POST_K4_WINDOW_ROUNDS); + if (locals.shouldActivateFR) + { + state.frActive = true; + } + else if (state.frRoundsAtOrAboveTarget >= QTF_FR_HYSTERESIS_ROUNDS) + { + // Deactivate FR after target held for hysteresis rounds + state.frActive = false; + } + + // Fast-Recovery (FR) mode: redirect portions of Dev/Distribution to jackpot with deficit-driven extra. + // Base redirect is always 1% Dev + 1% Dist when FR=ON. + // Extra redirect is calculated dynamically based on deficit, expected k4 timing, and ticket volume. + if (state.frActive) + { + // Calculate deficit to target jackpot + locals.delta = (state.jackpot < state.targetJackpot) ? (state.targetJackpot - state.jackpot) : 0; + + // Estimate base gain from existing FR mechanisms (without extra) + locals.calcBaseGainInput.revenue = locals.revenue; + locals.calcBaseGainInput.winnersBlock = locals.winnersBlock; + CALL(CalculateBaseGain, locals.calcBaseGainInput, locals.calcBaseGainOutput); + + // Calculate deficit-driven extra redirect in basis points + locals.calcExtraInput.N = state.numberOfPlayers; + locals.calcExtraInput.delta = locals.delta; + locals.calcExtraInput.revenue = locals.revenue; + locals.calcExtraInput.baseGain = locals.calcBaseGainOutput.baseGain; + CALL(CalculateExtraRedirectBP, locals.calcExtraInput, locals.calcExtraOutput); + + // Split extra equally between Dev and Dist + locals.devExtraBP = div(locals.calcExtraOutput.extraBP, 2); + locals.distExtraBP = locals.calcExtraOutput.extraBP - locals.devExtraBP; // Handle odd remainder + + // Total redirect BP = base + extra + locals.tmp64a = sadd(QTF_FR_DEV_REDIRECT_BP, locals.devExtraBP); + locals.tmp64b = sadd(QTF_FR_DIST_REDIRECT_BP, locals.distExtraBP); + + // Calculate redirect amounts + locals.devRedirect = div(smul(locals.revenue, locals.tmp64a), 10000); + locals.distRedirect = div(smul(locals.revenue, locals.tmp64b), 10000); + + // Deduct redirects from payouts (capped at available amounts) + if (locals.devPayout > locals.devRedirect) + { + locals.devPayout -= locals.devRedirect; + } + else + { + locals.devRedirect = locals.devPayout; + locals.devPayout = 0; + } + + if (locals.distPayout > locals.distRedirect) + { + locals.distPayout -= locals.distRedirect; + } + else + { + locals.distRedirect = locals.distPayout; + locals.distPayout = 0; + } + + // FR rake: take 5% of winners block from k3 tier to accelerate jackpot rebuild + locals.winnersRake = div(smul(locals.winnersBlock, QTF_FR_WINNERS_RAKE_BP), 10000); + locals.winnersBlock -= locals.winnersRake; + + // FR tier split: 35% k3, 65% k2 (tilted toward k2 for stability during FR) + locals.k3Pool = div(smul(locals.winnersBlock, QTF_FR_K3_SHARE_BP), 10000); + locals.k2Pool = locals.winnersBlock - locals.k3Pool; + } + else + { + // Baseline mode: 50/50 split between k2 and k3 (no rake, no redirects) + locals.k3Pool = div(smul(locals.winnersBlock, QTF_BASE_K3_SHARE_BP), 10000); + locals.k2Pool = locals.winnersBlock - locals.k3Pool; + } + + locals.k2PayoutPool = locals.k2Pool; // mutable pools after top-ups + locals.k3PayoutPool = locals.k3Pool; + + // Generate winning random values using CALL + locals.getRandomInput.seed = qpi.K12(qpi.getPrevSpectrumDigest()).u64._0; + CALL(GetRandomValues, locals.getRandomInput, locals.getRandomOutput); + locals.winningValues = locals.getRandomOutput.values; + + // First pass: count matches and cache results for second pass + locals.i = 0; + while (locals.i < state.numberOfPlayers) + { + locals.countMatchesInput.playerValues = state.players.get(locals.i).randomValues; + locals.countMatchesInput.winningValues = locals.winningValues; + CALL(CountMatches, locals.countMatchesInput, locals.countMatchesOutput); + + locals.cachedMatches.set(locals.i, locals.countMatchesOutput.matches); // Cache result + + if (locals.countMatchesOutput.matches == 2) + { + ++locals.countK2; + } + else if (locals.countMatchesOutput.matches == 3) + { + ++locals.countK3; + } + else if (locals.countMatchesOutput.matches == 4) + { + ++locals.countK4; + } + ++locals.i; + } + + // Minimum payout floors: ensure k2 winners get >= 0.5*P, k3 winners get >= 5*P. + // Top up from Reserve.General if pool insufficient (subject to safety limits). + if (locals.countK2 > 0) + { + // k2 floor: 0.5 * ticketPrice per winner + locals.tmp64a = div(smul(state.ticketPrice, QTF_K2_FLOOR_MULT), QTF_K2_FLOOR_DIV); + locals.tmp64b = smul(locals.tmp64a, locals.countK2); // total floor needed + locals.tmp64c = smul(state.ticketPrice, QTF_TOPUP_PER_WINNER_CAP_MULT); // per-winner cap (25*P) + + if (locals.k2PayoutPool < locals.tmp64b) + { + // Pool insufficient: top up from reserve (respecting safety limits) + locals.calcTopUpInput.availableReserve = state.reserveGeneral; + locals.calcTopUpInput.needed = locals.tmp64b - locals.k2PayoutPool; + locals.calcTopUpInput.perWinnerCapTotal = smul(locals.tmp64c, locals.countK2); + locals.calcTopUpInput.ticketPrice = state.ticketPrice; + CALL(CalcReserveTopUp, locals.calcTopUpInput, locals.calcTopUpOutput); + locals.topUpK2 = RL::min(locals.calcTopUpOutput.topUpAmount, locals.tmp64b - locals.k2PayoutPool); + state.reserveGeneral -= locals.topUpK2; + locals.k2PayoutPool = sadd(locals.k2PayoutPool, locals.topUpK2); + } + + // Calculate actual per-winner payout (capped at 25*P) + locals.k2PerWinner = RL::min(locals.tmp64c, locals.k2PayoutPool / locals.countK2); + locals.winnersOverflow = sadd(locals.winnersOverflow, (locals.k2PayoutPool - smul(locals.k2PerWinner, locals.countK2))); + } + else + { + // No k2 winners: entire k2 pool becomes overflow + locals.winnersOverflow = sadd(locals.winnersOverflow, locals.k2PayoutPool); + } + + if (locals.countK3 > 0) + { + // k3 floor: 5 * ticketPrice per winner + locals.tmp64a = smul(state.ticketPrice, QTF_K3_FLOOR_MULT); + locals.tmp64b = smul(locals.tmp64a, locals.countK3); // total floor needed + locals.tmp64c = smul(state.ticketPrice, QTF_TOPUP_PER_WINNER_CAP_MULT); // per-winner cap (25*P) + + if (locals.k3PayoutPool < locals.tmp64b) + { + // Pool insufficient: top up from reserve (respecting safety limits) + locals.calcTopUpInput.availableReserve = state.reserveGeneral; + locals.calcTopUpInput.needed = locals.tmp64b - locals.k3PayoutPool; + locals.calcTopUpInput.perWinnerCapTotal = smul(locals.tmp64c, locals.countK3); + locals.calcTopUpInput.ticketPrice = state.ticketPrice; + CALL(CalcReserveTopUp, locals.calcTopUpInput, locals.calcTopUpOutput); + locals.topUpK3 = RL::min(locals.calcTopUpOutput.topUpAmount, locals.tmp64b - locals.k3PayoutPool); + state.reserveGeneral -= locals.topUpK3; + locals.k3PayoutPool = sadd(locals.k3PayoutPool, locals.topUpK3); + } - NextEpochData nextEpochData; + // Calculate actual per-winner payout (capped at 25*P) + locals.k3PerWinner = RL::min(locals.tmp64c, locals.k3PayoutPool / locals.countK3); + locals.winnersOverflow = sadd(locals.winnersOverflow, (locals.k3PayoutPool - smul(locals.k3PerWinner, locals.countK3))); + } + else + { + // No k3 winners: entire k3 pool becomes overflow + locals.winnersOverflow = sadd(locals.winnersOverflow, locals.k3PayoutPool); + } - Array players; + locals.carryAdd = sadd(locals.carryAdd, locals.winnersRake); - id teamAddress; + // Second pass: payout loop using cached match results (avoids redundant countMatches calls) + // (Optimization: reduces player iteration from 4 passes to 2 passes + eliminates duplicate countMatches) + locals.i = 0; + while (locals.i < state.numberOfPlayers) + { + locals.matches = locals.cachedMatches.get(locals.i); // Use cached result - id ownerAddress; + // k2 payout + if (locals.matches == 2 && locals.countK2 > 0 && locals.k2PerWinner > 0) + { + qpi.transfer(state.players.get(locals.i).player, locals.k2PerWinner); + fillWinnerData(state, state.players.get(locals.i), locals.winningValues, locals.currentEpoch); + } + // k3 payout + else if (locals.matches == 3 && locals.countK3 > 0 && locals.k3PerWinner > 0) + { + qpi.transfer(state.players.get(locals.i).player, locals.k3PerWinner); + fillWinnerData(state, state.players.get(locals.i), locals.winningValues, locals.currentEpoch); + } + // k4 payout (jackpot) + else if (locals.matches == 4 && locals.countK4 > 0 && state.jackpot > 0) + { + locals.tmp64a = state.jackpot / locals.countK4; + if (locals.tmp64a > 0) + { + qpi.transfer(state.players.get(locals.i).player, locals.tmp64a); + fillWinnerData(state, state.players.get(locals.i), locals.winningValues, locals.currentEpoch); + } + } - uint64 numberOfPlayers; + ++locals.i; + } - uint64 ticketPrice; + // Post-jackpot (k4) logic: reset counters and reseed if jackpot was hit + if (locals.countK4 > 0 && state.jackpot > 0) + { + // Jackpot was paid out in combined loop above, now deplete it + state.jackpot = 0; - PlayerData tempPlayerInfo; + // Reset FR counters after jackpot hit + state.frRoundsSinceK4 = 0; + state.frRoundsAtOrAboveTarget = 0; - uint64 tmpValue64; + // Reseed jackpot from Reserve.JackpotRebuild (up to targetJackpot or available balance) + locals.tmp64b = RL::min(state.reserveJackpotRebuild, state.targetJackpot); + state.jackpot = sadd(state.jackpot, locals.tmp64b); + state.reserveJackpotRebuild -= locals.tmp64b; + } + else + { + // No jackpot hit: increment rounds counter for FR post-k4 window tracking + ++state.frRoundsSinceK4; + } - uint8 tmpValue8; + // Overflow split: unawarded tier funds split between reserve and jackpot. + // FR mode: 95% to jackpot, 5% to reserve (alpha=0.05) + // Baseline mode: 50/50 split (alpha=0.50) + if (locals.winnersOverflow > 0) + { + if (state.frActive) + { + locals.reserveAdd = div(smul(locals.winnersOverflow, QTF_FR_ALPHA_BP), 10000); + } + else + { + locals.reserveAdd = div(smul(locals.winnersOverflow, state.overflowAlphaBP), 10000); + } + locals.carryAdd = sadd(locals.carryAdd, (locals.winnersOverflow - locals.reserveAdd)); + } + + // Add all jackpot contributions: overflow carryAdd + FR redirects (if active) + locals.tmp64a = sadd(locals.carryAdd, sadd(locals.devRedirect, locals.distRedirect)); + state.jackpot = sadd(state.jackpot, locals.tmp64a); + + // Split reserve overflow between JackpotRebuild and General using configured split ratio + locals.tmp64a = div(smul(locals.reserveAdd, QTF_RESERVE_SPLIT_JACKPOT_BP), 10000); + state.reserveJackpotRebuild = sadd(state.reserveJackpotRebuild, locals.tmp64a); + state.reserveGeneral = sadd(state.reserveGeneral, (locals.reserveAdd - locals.tmp64a)); + + if (locals.devPayout > 0) + { + qpi.transfer(state.teamAddress, locals.devPayout); + } + // Manual dividend payout to RL shareholders (no extra fee). + if (locals.distPayout > 0) + { + locals.rlAsset.issuer = QTF_RANDOM_LOTTERY_CONTRACT_ID; + locals.rlAsset.assetName = QTF_RANDOM_LOTTERY_ASSET_NAME; + locals.rlTotalShares = NUMBER_OF_COMPUTORS; + + if (locals.rlTotalShares > 0) + { + locals.dividendPerShare = div(locals.distPayout, locals.rlTotalShares); + if (locals.dividendPerShare > 0) + { + locals.rlIter.begin(locals.rlAsset); + while (!locals.rlIter.reachedEnd()) + { + locals.rlShares = static_cast(locals.rlIter.numberOfPossessedShares()); + if (locals.rlShares > 0) + { + qpi.transfer(locals.rlIter.possessor(), smul(locals.rlShares, locals.dividendPerShare)); + } + locals.rlIter.next(); + } + + locals.rlPayback = locals.distPayout - smul(locals.dividendPerShare, locals.rlTotalShares); + if (locals.rlPayback > 0) + { + qpi.transfer(QTF_RANDOM_LOTTERY_CONTRACT_ID, locals.rlPayback); + } + } + } + } + + if (locals.burnAmount > 0) + { + qpi.burn(locals.burnAmount); + } + } + + /** + * @brief Refunds ticket price to all players who bought tickets in the current epoch. + * + * This procedure is used to return funds to all participants, typically in cases where: + * - The round is invalid or cancelled + * - Technical issues prevent proper settlement + * - Only one player participated (cannot draw fairly) + * + * Performs one transfer per player entry. After refund, caller should reset numberOfPlayers. + */ + PRIVATE_PROCEDURE_WITH_LOCALS(ReturnAllTickets) + { + // Refund ticket price to each player + for (locals.i = 0; locals.i < state.numberOfPlayers; ++locals.i) + { + qpi.transfer(state.players.get(locals.i).player, state.ticketPrice); + } + } + + PRIVATE_FUNCTION_WITH_LOCALS(CountMatches) + { + locals.maskA = 0; + locals.maskB = 0; + for (locals.i = 0; locals.i < input.playerValues.capacity(); ++locals.i) + { + // Ensure value is in valid range [1..30] to prevent underflow/overflow in bit shift + ASSERT(input.playerValues.get(locals.i) > 0 && input.playerValues.get(locals.i) <= QTF_MAX_RANDOM_VALUE); + locals.maskA |= (1u << (input.playerValues.get(locals.i) - 1)); + } + for (locals.i = 0; locals.i < input.winningValues.capacity(); ++locals.i) + { + ASSERT(input.winningValues.get(locals.i) > 0 && input.winningValues.get(locals.i) <= QTF_MAX_RANDOM_VALUE); + locals.maskB |= (1u << (input.winningValues.get(locals.i) - 1)); + } + output.matches = bitcount32(locals.maskA & locals.maskB); + } + + /** + * @brief Checks if the contract has sufficient balance to cover expected revenue. + * + * This function verifies that the on-chain balance (incoming - outgoing) of the contract + * is at least equal to the expected revenue. Used as a safety check before settlement + * to prevent underflow or incomplete payouts. + * + * @param input.expectedRevenue - The amount of revenue expected to be available + * @param output.hasEnough - true if actualBalance >= expectedRevenue + * @param output.actualBalance - Current net balance of the contract + */ + PRIVATE_FUNCTION_WITH_LOCALS(CheckContractBalance) + { + qpi.getEntity(SELF, locals.entity); + output.actualBalance = RL::max(locals.entity.incomingAmount - locals.entity.outgoingAmount, 0i64); + output.hasEnough = (output.actualBalance >= input.expectedRevenue); + } + + /** + * @brief Computes (base^exp) in fixed-point arithmetic using fast exponentiation. + * + * @param input.base - Base value in fixed-point (scaled by QTF_FIXED_POINT_SCALE) + * @param input.exp - Exponent (integer) + * @param output.result - (base^exp) in fixed-point + */ + PRIVATE_FUNCTION_WITH_LOCALS(PowerFixedPoint) + { + if (input.exp == 0) + { + output.result = QTF_FIXED_POINT_SCALE; // base^0 = 1.0 + return; + } + + locals.tmpBase = input.base; + locals.expCopy = input.exp; + output.result = QTF_FIXED_POINT_SCALE; + + while (locals.expCopy > 0) + { + if (locals.expCopy & 1) + { + // result *= tmpBase (both in fixed-point, so divide by SCALE) + output.result = div(smul(output.result, locals.tmpBase), QTF_FIXED_POINT_SCALE); + } + // tmpBase *= tmpBase + locals.tmpBase = div(smul(locals.tmpBase, locals.tmpBase), QTF_FIXED_POINT_SCALE); + locals.expCopy >>= 1; + } + } + + /** + * @brief Calculates expected rounds until at least one k=4 win: E_k4(N) = 1 / (1 - (1-p4)^N) + * + * Uses exact p4 = 1/27405 (combinatorics for 4-of-30). + * Returns result in fixed-point scaled by QTF_FIXED_POINT_SCALE. + * + * @param input.N - Number of tickets sold in round + * @param output.expectedRounds - E_k4(N) in fixed-point + */ + PRIVATE_FUNCTION_WITH_LOCALS(CalculateExpectedRoundsToK4) + { + if (input.N == 0) + { + // No tickets, infinite expected rounds + output.expectedRounds = UINT64_MAX; + return; + } + + // p4 = 1/27405, so (1 - p4) = 27404/27405 + // In fixed-point: (1 - p4) = (27404 * SCALE) / 27405 + locals.oneMinusP4 = div(smul(27404ULL, QTF_FIXED_POINT_SCALE), QTF_P4_DENOMINATOR); + + // Compute (1-p4)^N in fixed-point using CALL + locals.pfInput.base = locals.oneMinusP4; + locals.pfInput.exp = input.N; + CALL(PowerFixedPoint, locals.pfInput, locals.pfOutput); + locals.pow1mP4N = locals.pfOutput.result; + + // Compute 1 - (1-p4)^N + if (locals.pow1mP4N < QTF_FIXED_POINT_SCALE) + { + locals.denomFP = QTF_FIXED_POINT_SCALE - locals.pow1mP4N; + } + else + { + // Fallback: should not happen, but protect against underflow + locals.denomFP = 1; + } + + // E_k4 = 1 / (1 - (1-p4)^N) = SCALE / denomFP + if (locals.denomFP > 0) + { + output.expectedRounds = div(QTF_FIXED_POINT_SCALE, locals.denomFP); + } + else + { + // Extremely unlikely, fallback to large value + output.expectedRounds = UINT64_MAX; + } + + // Additional safety: if result unreasonably large, cap it + if (output.expectedRounds > smul(QTF_FR_GOAL_ROUNDS_CAP, QTF_FIXED_POINT_SCALE)) + { + output.expectedRounds = smul(QTF_FR_GOAL_ROUNDS_CAP, QTF_FIXED_POINT_SCALE); + } + } + + /** + * @brief Generate 4 unique random values from [1..30] using seed. + * + * Protection against infinite loop: if unable to find unique value after max attempts, + * fallback to sequential selection of first available unused value. + */ + PRIVATE_FUNCTION_WITH_LOCALS(GetRandomValues) + { + for (locals.index = 0; locals.index < output.values.capacity(); ++locals.index) + { + deriveOne(input.seed, locals.index, locals.tempValue); + locals.candidate = static_cast(mod(locals.tempValue, QTF_MAX_RANDOM_VALUE) + 1); + + locals.attempts = 0; + while (locals.used.contains(locals.candidate) && locals.attempts < QTF_MAX_RANDOM_GENERATION_ATTEMPTS) + { + ++locals.attempts; + // Regenerate a fresh candidate from the evolving tempValue until it is unique + locals.tempValue ^= locals.tempValue >> 12; + locals.tempValue ^= locals.tempValue << 25; + locals.tempValue ^= locals.tempValue >> 27; + locals.tempValue *= 2685821657736338717ULL; + locals.candidate = static_cast(mod(locals.tempValue, QTF_MAX_RANDOM_VALUE) + 1); + } + + // Fallback: if still duplicate after max attempts, find first unused value + if (locals.used.contains(locals.candidate)) + { + for (locals.fallback = 1; locals.fallback <= QTF_MAX_RANDOM_VALUE; ++locals.fallback) + { + if (!locals.used.contains(locals.fallback)) + { + locals.candidate = locals.fallback; + break; + } + } + } + + locals.used.add(locals.candidate); + output.values.set(locals.index, locals.candidate); + } + } + + /** + * @brief Validates that all numbers in the array are unique and in range [1..30]. + * + * @param input.numbers - Array of numbers to validate + * @param output.isValid - true if all numbers are valid and unique + */ + PRIVATE_FUNCTION_WITH_LOCALS(ValidateNumbers) + { + output.isValid = true; + for (locals.idx = 0; locals.idx < input.numbers.capacity(); ++locals.idx) + { + locals.value = input.numbers.get(locals.idx); + if (locals.value == 0 || locals.value > QTF_MAX_RANDOM_VALUE) + { + output.isValid = false; + return; + } + if (locals.seen.contains(locals.value)) + { + output.isValid = false; + return; + } + locals.seen.add(locals.value); + } + } + + /** + * @brief Calculate safe reserve top-up amount respecting safety limits. + * + * @param input.availableReserve - Current reserve balance + * @param input.needed - Amount needed for top-up + * @param input.perWinnerCapTotal - Per-winner cap multiplied by winner count + * @param input.ticketPrice - Current ticket price + * @param output.topUpAmount - Safe amount to top up from reserve + */ + PRIVATE_FUNCTION_WITH_LOCALS(CalcReserveTopUp) + { + if (input.availableReserve == 0) + { + output.topUpAmount = 0; + return; + } + + // Calculate soft floor: keep at least 20 * P in reserve + locals.softFloor = smul(input.ticketPrice, QTF_RESERVE_SOFT_FLOOR_MULT); + + // Calculate usable reserve (above soft floor) + if (input.availableReserve > locals.softFloor) + { + locals.usableReserve = input.availableReserve - locals.softFloor; + } + else + { + locals.usableReserve = 0; + } + + // Calculate max per round (10% of available reserve) + locals.maxPerRound = div(smul(input.availableReserve, QTF_TOPUP_RESERVE_PCT_BP), 10000); + + // Cap by usable reserve + if (locals.maxPerRound > locals.usableReserve) + { + locals.maxPerRound = locals.usableReserve; + } + + // Cap by per-winner cap + if (locals.maxPerRound > input.perWinnerCapTotal) + { + locals.maxPerRound = input.perWinnerCapTotal; + } + + // Return min of needed and max allowed + if (input.needed < locals.maxPerRound) + { + output.topUpAmount = input.needed; + } + else + { + output.topUpAmount = locals.maxPerRound; + } + } + + /** + * @brief Estimates base carry gain per round from FR mechanisms (without extra redirect). + * + * Includes: + * - Base Dev redirect: QTF_FR_DEV_REDIRECT_BP of R + * - Base Dist redirect: QTF_FR_DIST_REDIRECT_BP of R + * - Winners rake: QTF_FR_WINNERS_RAKE_BP of winners block + * - Overflow bias: (1 - alpha_fr) of overflow to carry + * + * This is a simplified estimate; actual gain depends on winners count and overflow. + * We use conservative approximation: assume moderate overflow and typical winner distribution. + */ + PRIVATE_FUNCTION_WITH_LOCALS(CalculateBaseGain) + { + // Base redirects from Dev and Dist + locals.devRedirect = div(smul(input.revenue, QTF_FR_DEV_REDIRECT_BP), 10000); + locals.distRedirect = div(smul(input.revenue, QTF_FR_DIST_REDIRECT_BP), 10000); + + // Winners rake: 5% of winners block + locals.winnersRake = div(smul(input.winnersBlock, QTF_FR_WINNERS_RAKE_BP), 10000); + + // Overflow estimate: assume ~10% of winnersBlock not paid out (conservative) + // With alpha_fr = 0.05, 95% of overflow goes to carry + locals.estimatedOverflow = div(input.winnersBlock, 10); + locals.overflowToCarry = div(smul(locals.estimatedOverflow, 10000 - QTF_FR_ALPHA_BP), 10000); + + // Total base gain + output.baseGain = sadd(sadd(locals.devRedirect, locals.distRedirect), sadd(locals.winnersRake, locals.overflowToCarry)); + } + + /** + * @brief Calculates deficit-driven extra redirect percentage in basis points. + * + * Formula: + * - delta = max(0, targetJackpot - currentJackpot) + * - E_k4(N) = expected rounds to k=4 + * - H = min(E_k4(N), cap) + * - g_need = max(0, delta/H - baseGain) + * - extra_pp = clamp(g_need / revenue, 0, extra_max) + */ + PRIVATE_FUNCTION_WITH_LOCALS(CalculateExtraRedirectBP) + { + if (input.delta == 0 || input.revenue == 0 || input.N == 0) + { + output.extraBP = 0; + return; + } + + // Calculate E_k4(N) in fixed-point using CALL + locals.calcE4Input.N = input.N; + CALL(CalculateExpectedRoundsToK4, locals.calcE4Input, locals.calcE4Output); + + // Apply cap: H = min(E_k4, cap) + locals.horizonFP = RL::min(locals.calcE4Output.expectedRounds, smul(QTF_FR_GOAL_ROUNDS_CAP, QTF_FIXED_POINT_SCALE)); + + // Convert horizon back to integer rounds (divide by SCALE) + locals.horizon = RL::max(div(locals.horizonFP, QTF_FIXED_POINT_SCALE), 1ULL); + + // Calculate required gain per round: delta / H + locals.requiredGainPerRound = div(input.delta, locals.horizon); + + // Calculate needed additional gain: g_need = max(0, requiredGainPerRound - baseGain) + if (locals.requiredGainPerRound > input.baseGain) + { + locals.gNeed = locals.requiredGainPerRound - input.baseGain; + } + else + { + output.extraBP = 0; + return; + } + + // Convert g_need to percentage of revenue in basis points: (g_need / revenue) * 10000 + locals.extraBPTemp = div(smul(locals.gNeed, 10000ULL), input.revenue); + + // Clamp to maximum + output.extraBP = RL::min(locals.extraBPTemp, QTF_FR_EXTRA_MAX_BP); + } }; diff --git a/test/contract_qrp.cpp b/test/contract_qrp.cpp new file mode 100644 index 000000000..56e7ece89 --- /dev/null +++ b/test/contract_qrp.cpp @@ -0,0 +1,165 @@ +#define NO_UEFI + +#include "contract_testing.h" + +static const id QRP_CONTRACT_ID(QRP_CONTRACT_INDEX, 0, 0, 0); +static const id QRP_DEFAULT_SC_ID(QRP_QTF_INDEX, 0, 0, 0); +static const id QRP_OWNER_TEAM_ADDRESS = + ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, _W, _E, _T, _H, _N, _G, + _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); + +class QRPChecker : public QRP +{ +public: + const id& team() const { return teamAddress; } + const id& owner() const { return ownerAddress; } + bool hasAvailableSC(const id& sc) const { return availableSmartContracts.contains(sc); } + uint64 availableCount() const { return availableSmartContracts.population(); } +}; + +class ContractTestingQRP : protected ContractTesting +{ +public: + ContractTestingQRP() + { + initEmptySpectrum(); + initEmptyUniverse(); + INIT_CONTRACT(QRP); + callSystemProcedure(QRP_CONTRACT_INDEX, INITIALIZE); + } + + QRPChecker* state() { return reinterpret_cast(contractStates[QRP_CONTRACT_INDEX]); } + + void fundContract(uint64 amount) { increaseEnergy(QRP_CONTRACT_ID, amount); } + + QRP::GetReserve_output getReserve(const id& invocator, uint64 revenue, sint64 attachedAmount = 0) + { + QRP::GetReserve_input input{revenue}; + QRP::GetReserve_output output{}; + invokeUserProcedure(QRP_CONTRACT_INDEX, 1, input, output, invocator, attachedAmount); + return output; + } + + QRP::AddAvailableSC_output addAvailableSC(const id& invocator, uint64 scIndex) + { + QRP::AddAvailableSC_input input{scIndex}; + QRP::AddAvailableSC_output output{}; + invokeUserProcedure(QRP_CONTRACT_INDEX, 2, input, output, invocator, 0); + return output; + } + + QRP::RemoveAvailableSC_output removeAvailableSC(const id& invocator, uint64 scIndex) + { + QRP::RemoveAvailableSC_input input{scIndex}; + QRP::RemoveAvailableSC_output output{}; + invokeUserProcedure(QRP_CONTRACT_INDEX, 3, input, output, invocator, 0); + return output; + } + + QRP::GetAvailableReserve_output getAvailableReserve() const + { + QRP::GetAvailableReserve_input input{}; + QRP::GetAvailableReserve_output output{}; + callFunction(QRP_CONTRACT_INDEX, 1, input, output); + return output; + } + + QRP::GetAvailableSC_output getAvailableSCs() const + { + QRP::GetAvailableSC_input input{}; + QRP::GetAvailableSC_output output{}; + callFunction(QRP_CONTRACT_INDEX, 2, input, output); + return output; + } +}; + +TEST(ContractQReservePool, InitializesOwnerAndDefaultSCList) +{ + ContractTestingQRP qrp; + auto* state = qrp.state(); + + EXPECT_EQ(state->team(), QRP_OWNER_TEAM_ADDRESS); + EXPECT_EQ(state->owner(), QRP_OWNER_TEAM_ADDRESS); + EXPECT_TRUE(state->hasAvailableSC(QRP_DEFAULT_SC_ID)); + EXPECT_EQ(state->availableCount(), 1u); + + const auto available = qrp.getAvailableSCs(); + bool foundDefault = false; + for (uint64 i = 0; i < QRP_AVAILABLE_SC_NUM; ++i) + { + if (available.availableSCs.get(i) == QRP_DEFAULT_SC_ID) + { + foundDefault = true; + break; + } + } + EXPECT_TRUE(foundDefault); +} + +TEST(ContractQReservePool, GetReserveEnforcesAuthorizationAndBalance) +{ + ContractTestingQRP qrp; + const id unauthorized(77, 0, 0, 0); + increaseEnergy(unauthorized, 0); + increaseEnergy(QRP_DEFAULT_SC_ID, 0); + + + auto denied = qrp.getReserve(unauthorized, 100); + EXPECT_EQ(denied.returnCode, QRPReturnCode::ACCESS_DENIED); + EXPECT_EQ(denied.allocatedRevenue, 0ull); + + qrp.fundContract(1000); + EXPECT_EQ(getBalance(QRP_CONTRACT_ID), 1000); + + auto granted = qrp.getReserve(QRP_DEFAULT_SC_ID, 600); + EXPECT_EQ(granted.returnCode, QRPReturnCode::SUCCESS); + EXPECT_EQ(granted.allocatedRevenue, 600ull); + EXPECT_EQ(getBalance(QRP_CONTRACT_ID), 400); + EXPECT_EQ(getBalance(QRP_DEFAULT_SC_ID), 600); + + auto insufficient = qrp.getReserve(QRP_DEFAULT_SC_ID, 500); + EXPECT_EQ(insufficient.returnCode, QRPReturnCode::INSUFFICIENT_RESERVE); + EXPECT_EQ(insufficient.allocatedRevenue, 0ull); + EXPECT_EQ(getBalance(QRP_CONTRACT_ID), 400); + EXPECT_EQ(getBalance(QRP_DEFAULT_SC_ID), 600); +} + +TEST(ContractQReservePool, OwnerAddsAndRemovesSmartContracts) +{ + ContractTestingQRP qrp; + auto* state = qrp.state(); + const uint64 newScIndex = 77; + const id newScId(newScIndex, 0, 0, 0); + const id outsider(200, 0, 0, 0); + increaseEnergy(newScId, 0); + increaseEnergy(outsider, 0); + increaseEnergy(state->owner(), 0); + + auto deniedAdd = qrp.addAvailableSC(outsider, newScIndex); + EXPECT_EQ(deniedAdd.returnCode, QRPReturnCode::ACCESS_DENIED); + EXPECT_FALSE(state->hasAvailableSC(newScId)); + + auto approvedAdd = qrp.addAvailableSC(state->owner(), newScIndex); + EXPECT_EQ(approvedAdd.returnCode, QRPReturnCode::SUCCESS); + EXPECT_TRUE(state->hasAvailableSC(newScId)); + + auto available = qrp.getAvailableSCs(); + bool foundNew = false; + for (uint64 i = 0; i < QRP_AVAILABLE_SC_NUM; ++i) + { + if (available.availableSCs.get(i) == newScId) + { + foundNew = true; + break; + } + } + EXPECT_TRUE(foundNew); + + auto deniedRemove = qrp.removeAvailableSC(outsider, newScIndex); + EXPECT_EQ(deniedRemove.returnCode, QRPReturnCode::ACCESS_DENIED); + EXPECT_TRUE(state->hasAvailableSC(newScId)); + + auto approvedRemove = qrp.removeAvailableSC(state->owner(), newScIndex); + EXPECT_EQ(approvedRemove.returnCode, QRPReturnCode::SUCCESS); + EXPECT_FALSE(state->hasAvailableSC(newScId)); +} From d3c4f53d3554a56c4cf26fcac110a67a425c4289 Mon Sep 17 00:00:00 2001 From: N-010 Date: Sun, 7 Dec 2025 19:56:09 +0300 Subject: [PATCH 05/77] CalcReserveTopUp --- src/contracts/QThirtyFour.h | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 52960e584..4eb546da7 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -1544,7 +1544,12 @@ struct QTF : public ContractBase /** * @brief Calculate safe reserve top-up amount respecting safety limits. * - * @param input.availableReserve - Current reserve balance + * Safety limits per spec: + * - Max 10% of total QRP reserve per round + * - Soft floor: keep at least 20*P in QRP reserve + * - Per-winner cap: max 25*P per winner + * + * @param input.totalQRPBalance - Actual QRP contract balance (for 10% limit and soft floor) * @param input.needed - Amount needed for top-up * @param input.perWinnerCapTotal - Per-winner cap multiplied by winner count * @param input.ticketPrice - Current ticket price @@ -1552,39 +1557,31 @@ struct QTF : public ContractBase */ PRIVATE_FUNCTION_WITH_LOCALS(CalcReserveTopUp) { - if (input.availableReserve == 0) + if (input.totalQRPBalance == 0) { output.topUpAmount = 0; return; } - // Calculate soft floor: keep at least 20 * P in reserve + // Calculate soft floor: keep at least 20 * P in total QRP reserve locals.softFloor = smul(input.ticketPrice, QTF_RESERVE_SOFT_FLOOR_MULT); - // Calculate usable reserve (above soft floor) - if (input.availableReserve > locals.softFloor) + // Calculate available reserve from QRP (above soft floor) + if (input.totalQRPBalance > locals.softFloor) { - locals.usableReserve = input.availableReserve - locals.softFloor; + locals.availableAboveFloor = input.totalQRPBalance - locals.softFloor; } else { - locals.usableReserve = 0; - } - - // Calculate max per round (10% of available reserve) - locals.maxPerRound = div(smul(input.availableReserve, QTF_TOPUP_RESERVE_PCT_BP), 10000); - - // Cap by usable reserve - if (locals.maxPerRound > locals.usableReserve) - { - locals.maxPerRound = locals.usableReserve; + locals.availableAboveFloor = 0; } + // Calculate max per round (10% of total QRP reserve per spec) + locals.maxPerRound = div(smul(input.totalQRPBalance, QTF_TOPUP_RESERVE_PCT_BP), 10000); + // Cap by available above floor + locals.maxPerRound = RL::min(locals.maxPerRound, locals.availableAboveFloor); // Cap by per-winner cap - if (locals.maxPerRound > input.perWinnerCapTotal) - { - locals.maxPerRound = input.perWinnerCapTotal; - } + locals.maxPerRound = RL::min(locals.maxPerRound, input.perWinnerCapTotal); // Return min of needed and max allowed if (input.needed < locals.maxPerRound) From edaaf0bdf106c3d294d1a535b672dfa5cf89c0b3 Mon Sep 17 00:00:00 2001 From: N-010 Date: Sun, 7 Dec 2025 19:57:26 +0300 Subject: [PATCH 06/77] New structs --- src/contracts/QThirtyFour.h | 79 ++++++++++++++++++++++++------------- 1 file changed, 52 insertions(+), 27 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 4eb546da7..5d2c4617f 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -47,9 +47,6 @@ static constexpr uint64 QTF_RESERVE_SOFT_FLOOR_MULT = 20; // keep at least 20 // Baseline overflow split (reserve share in basis points). If spec is updated, adjust here. static constexpr uint64 QTF_BASELINE_OVERFLOW_ALPHA_BP = 5000; // 50% reserve / 50% jackpot -// Reserve split between JackpotRebuild and General (50/50 default) -static constexpr uint64 QTF_RESERVE_SPLIT_JACKPOT_BP = 5000; // 50% to JackpotRebuild, 50% to General - // Default fee percentages (fallback if RL::GetFees fails) static constexpr uint64 QTF_DEFAULT_DEV_PERCENT = 10; static constexpr uint64 QTF_DEFAULT_DIST_PERCENT = 20; @@ -148,8 +145,7 @@ struct QTF : public ContractBase struct PoolsSnapshot { uint64 jackpot; - uint64 reserveGeneral; - uint64 reserveJackpot; + uint64 reserve; // Total reserve from QRP uint64 targetJackpot; uint8 frActive; uint16 roundsSinceK4; @@ -361,7 +357,7 @@ struct QTF : public ContractBase // CalcReserveTopUp: Calculate safe reserve top-up amount struct CalcReserveTopUp_input { - uint64 availableReserve; + uint64 totalQRPBalance; // Actual QRP balance (for 10% limit and soft floor) uint64 needed; uint64 perWinnerCapTotal; uint64 ticketPrice; @@ -373,10 +369,37 @@ struct QTF : public ContractBase struct CalcReserveTopUp_locals { uint64 softFloor; - uint64 usableReserve; + uint64 availableAboveFloor; uint64 maxPerRound; }; + // ProcessTierPayout: Unified tier payout processing (k2/k3) + struct ProcessTierPayout_input + { + uint64 floorPerWinner; // Floor payout per winner (0.5*P for k2, 5*P for k3) + uint64 winnerCount; // Number of winners in this tier + uint64 payoutPool; // Initial payout pool for this tier + uint64 perWinnerCap; // Per-winner cap (25*P) + uint64 totalQRPBalance; // QRP balance for safety limits + uint64 ticketPrice; // Current ticket price + }; + struct ProcessTierPayout_output + { + uint64 perWinnerPayout; // Calculated per-winner payout + uint64 overflow; // Overflow amount (unused funds) + uint64 topUpReceived; // Amount received from QRP top-up + }; + struct ProcessTierPayout_locals + { + uint64 floorTotalNeeded; + uint64 finalPool; + uint64 qrpRequested; + CalcReserveTopUp_input calcTopUpInput; + CalcReserveTopUp_output calcTopUpOutput; + QRP::GetReserve_input qrpGetReserveInput; + QRP::GetReserve_output qrpGetReserveOutput; + }; + // Ticket Price struct GetTicketPrice_input { @@ -422,6 +445,11 @@ struct QTF : public ContractBase { PoolsSnapshot pools; }; + struct GetPools_locals + { + QRP::GetAvailableReserve_input qrpInput; + QRP::GetAvailableReserve_output qrpOutput; + }; // Draw hour struct GetDrawHour_input @@ -498,14 +526,14 @@ struct QTF : public ContractBase uint64 k3PayoutPool; uint64 k2PerWinner; uint64 k3PerWinner; - uint64 topUpK2; - uint64 topUpK3; uint64 countK2; uint64 countK3; uint64 countK4; - uint64 tmp64a; - uint64 tmp64b; - uint64 tmp64c; + uint64 totalDevRedirectBP; // Total dev redirect in basis points (base + extra) + uint64 totalDistRedirectBP; // Total dist redirect in basis points (base + extra) + uint64 perWinnerCap; // Per-winner payout cap (25*P) + uint64 jackpotPerK4Winner; // Jackpot share per k4 winner + uint64 totalJackpotContribution; // Total amount to add to jackpot uint64 i; uint8 matches; bit shouldActivateFR; @@ -522,14 +550,17 @@ struct QTF : public ContractBase // CALL parameters for GetRandomValues GetRandomValues_input getRandomInput; GetRandomValues_output getRandomOutput; - // CALL parameters for CalcReserveTopUp - CalcReserveTopUp_input calcTopUpInput; - CalcReserveTopUp_output calcTopUpOutput; - // CALL_OTHER_CONTRACT parameters for QRP::GetReserve (external reserve pool) + // CALL parameters for ProcessTierPayout (unified k2/k3 processing) + ProcessTierPayout_input tierPayoutInput; + ProcessTierPayout_output tierPayoutOutput; + // CALL_OTHER_CONTRACT parameters for QRP (external reserve pool) QRP::GetReserve_input qrpGetReserveInput; QRP::GetReserve_output qrpGetReserveOutput; - uint64 qrpRequested; // Amount requested from QRP - uint64 qrpReceived; // Amount actually received from QRP + QRP::GetAvailableReserve_input qrpGetAvailableInput; + QRP::GetAvailableReserve_output qrpGetAvailableOutput; + uint64 qrpRequested; // Amount requested from QRP + uint64 qrpReceived; // Amount actually received from QRP + uint64 totalQRPBalance; // Total balance in QRP (for safety limits) RL::GetFees_input feesInput; RL::GetFees_output feesOutput; uint64 dividendPerShare; @@ -581,8 +612,6 @@ struct QTF : public ContractBase state.frRoundsAtOrAboveTarget = 0; state.numberOfPlayers = 0; state.jackpot = 0; - state.reserveGeneral = 0; - state.reserveJackpotRebuild = 0; state.currentState = STATE_NONE; } @@ -837,11 +866,11 @@ struct QTF : public ContractBase PUBLIC_FUNCTION(GetTicketPrice) { output.ticketPrice = state.ticketPrice; } PUBLIC_FUNCTION(GetNextEpochData) { output.nextEpochData = state.nextEpochData; } PUBLIC_FUNCTION(GetWinnerData) { output.winnerData = state.lastWinnerData; } - PUBLIC_FUNCTION(GetPools) + PUBLIC_FUNCTION_WITH_LOCALS(GetPools) { output.pools.jackpot = state.jackpot; - output.pools.reserveGeneral = state.reserveGeneral; - output.pools.reserveJackpot = state.reserveJackpotRebuild; + CALL_OTHER_CONTRACT_FUNCTION(QRP, GetAvailableReserve, locals.qrpInput, locals.qrpOutput); + output.pools.reserve = locals.qrpOutput.availableReserve; output.pools.targetJackpot = state.targetJackpot; output.pools.frActive = state.frActive; output.pools.roundsSinceK4 = state.frRoundsSinceK4; @@ -942,10 +971,6 @@ struct QTF : public ContractBase uint64 jackpot; // jackpot balance - uint64 reserveGeneral; // reserve for floors - - uint64 reserveJackpotRebuild; // reserve earmarked to reseed jackpot - uint64 targetJackpot; // FR target jackpot uint64 overflowAlphaBP; // baseline reserve share of overflow (bp) From f9bd84dd220d7a36243b0c4a756e0fec3742ce47 Mon Sep 17 00:00:00 2001 From: N-010 Date: Sun, 7 Dec 2025 20:12:39 +0300 Subject: [PATCH 07/77] Fees --- src/contracts/QThirtyFour.h | 132 +++++++++++++++++++++--------------- 1 file changed, 77 insertions(+), 55 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 5d2c4617f..2966d78c4 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -48,10 +48,10 @@ static constexpr uint64 QTF_RESERVE_SOFT_FLOOR_MULT = 20; // keep at least 20 static constexpr uint64 QTF_BASELINE_OVERFLOW_ALPHA_BP = 5000; // 50% reserve / 50% jackpot // Default fee percentages (fallback if RL::GetFees fails) -static constexpr uint64 QTF_DEFAULT_DEV_PERCENT = 10; -static constexpr uint64 QTF_DEFAULT_DIST_PERCENT = 20; -static constexpr uint64 QTF_DEFAULT_BURN_PERCENT = 2; -static constexpr uint64 QTF_DEFAULT_WINNERS_PERCENT = 68; +static constexpr uint8 QTF_DEFAULT_DEV_PERCENT = 10; +static constexpr uint8 QTF_DEFAULT_DIST_PERCENT = 20; +static constexpr uint8 QTF_DEFAULT_BURN_PERCENT = 2; +static constexpr uint8 QTF_DEFAULT_WINNERS_PERCENT = 68; // Maximum attempts to generate unique random value before fallback static constexpr uint8 QTF_MAX_RANDOM_GENERATION_ATTEMPTS = 100; @@ -90,6 +90,8 @@ struct QTF : public ContractBase MAX_VALUE = UINT8_MAX }; + static constexpr uint8 toReturnCode(const EReturnCode& code) { return static_cast(code); }; + enum EState : uint8 { STATE_NONE = 0, @@ -183,7 +185,7 @@ struct QTF : public ContractBase }; struct BuyTicket_output { - EReturnCode returnCode; + uint8 returnCode; }; struct BuyTicket_locals { @@ -199,7 +201,7 @@ struct QTF : public ContractBase }; struct SetPrice_output { - EReturnCode returnCode; + uint8 returnCode; }; // Set Schedule @@ -209,7 +211,7 @@ struct QTF : public ContractBase }; struct SetSchedule_output { - EReturnCode returnCode; + uint8 returnCode; }; // Set draw hour @@ -219,7 +221,7 @@ struct QTF : public ContractBase }; struct SetDrawHour_output { - EReturnCode returnCode; + uint8 returnCode; }; // Set Target Carry (Jackpot target) @@ -229,7 +231,7 @@ struct QTF : public ContractBase }; struct SetTargetJackpot_output { - EReturnCode returnCode; + uint8 returnCode; }; // Return All Tickets (refund all players) @@ -493,6 +495,25 @@ struct QTF : public ContractBase uint8 maskB; }; + struct GetFees_input + { + }; + + struct GetFees_output + { + uint8 teamFeePercent; // Team share in percent + uint8 distributionFeePercent; // Distribution/shareholders share in percent + uint8 winnerFeePercent; // Winner share in percent + uint8 burnPercent; // Burn share in percent + uint8 returnCode; + }; + + struct GetFees_locals + { + RL::GetFees_input feesInput; + RL::GetFees_output feesOutput; + }; + struct SettlementLocals { QTFRandomValues winningValues; @@ -515,10 +536,6 @@ struct QTF : public ContractBase uint64 devPayout; // Dev after redirects uint64 distPayout; // Distribution after redirects uint64 burnAmount; - uint64 devPercent; - uint64 distPercent; - uint64 burnPercent; - uint64 winnersPercent; uint64 devRedirect; uint64 distRedirect; uint64 winnersRake; @@ -558,11 +575,11 @@ struct QTF : public ContractBase QRP::GetReserve_output qrpGetReserveOutput; QRP::GetAvailableReserve_input qrpGetAvailableInput; QRP::GetAvailableReserve_output qrpGetAvailableOutput; - uint64 qrpRequested; // Amount requested from QRP - uint64 qrpReceived; // Amount actually received from QRP - uint64 totalQRPBalance; // Total balance in QRP (for safety limits) - RL::GetFees_input feesInput; - RL::GetFees_output feesOutput; + uint64 qrpRequested; // Amount requested from QRP + uint64 qrpReceived; // Amount actually received from QRP + uint64 totalQRPBalance; // Total balance in QRP (for safety limits) + GetFees_input feesInput; + GetFees_output feesOutput; uint64 dividendPerShare; Asset rlAsset; AssetPossessionIterator rlIter; @@ -629,6 +646,7 @@ struct QTF : public ContractBase REGISTER_USER_FUNCTION(GetSchedule, 5); REGISTER_USER_FUNCTION(GetDrawHour, 6); REGISTER_USER_FUNCTION(GetState, 7); + REGISTER_USER_FUNCTION(GetFees, 8); } BEGIN_EPOCH() @@ -748,7 +766,7 @@ struct QTF : public ContractBase qpi.transfer(qpi.invocator(), qpi.invocationReward()); } - output.returnCode = EReturnCode::TICKET_SELLING_CLOSED; + output.returnCode = toReturnCode(EReturnCode::TICKET_SELLING_CLOSED); return; } @@ -759,7 +777,7 @@ struct QTF : public ContractBase qpi.transfer(qpi.invocator(), qpi.invocationReward()); } - output.returnCode = EReturnCode::MAX_PLAYERS_REACHED; + output.returnCode = toReturnCode(EReturnCode::MAX_PLAYERS_REACHED); return; } @@ -770,7 +788,7 @@ struct QTF : public ContractBase qpi.transfer(qpi.invocator(), qpi.invocationReward()); } - output.returnCode = EReturnCode::INVALID_TICKET_PRICE; + output.returnCode = toReturnCode(EReturnCode::INVALID_TICKET_PRICE); return; } @@ -782,84 +800,84 @@ struct QTF : public ContractBase { qpi.transfer(qpi.invocator(), qpi.invocationReward()); } - output.returnCode = EReturnCode::INVALID_NUMBERS; + output.returnCode = toReturnCode(EReturnCode::INVALID_NUMBERS); return; } addPlayerInfo(state, qpi.invocator(), input.randomValues); - output.returnCode = EReturnCode::SUCCESS; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); } PUBLIC_PROCEDURE(SetPrice) { if (qpi.invocator() != state.ownerAddress) { - output.returnCode = EReturnCode::ACCESS_DENIED; + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; } if (input.newPrice == 0) { - output.returnCode = EReturnCode::INVALID_TICKET_PRICE; + output.returnCode = toReturnCode(EReturnCode::INVALID_TICKET_PRICE); return; } state.nextEpochData.newTicketPrice = input.newPrice; - output.returnCode = EReturnCode::SUCCESS; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); } PUBLIC_PROCEDURE(SetSchedule) { if (qpi.invocator() != state.ownerAddress) { - output.returnCode = EReturnCode::ACCESS_DENIED; + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; } if (input.newSchedule == 0) { - output.returnCode = EReturnCode::INVALID_VALUE; + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); return; } state.nextEpochData.newSchedule = input.newSchedule; - output.returnCode = EReturnCode::SUCCESS; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); } PUBLIC_PROCEDURE(SetTargetJackpot) { if (qpi.invocator() != state.ownerAddress) { - output.returnCode = EReturnCode::ACCESS_DENIED; + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; } if (input.newTargetJackpot == 0) { - output.returnCode = EReturnCode::INVALID_VALUE; + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); return; } state.nextEpochData.newTargetJackpot = input.newTargetJackpot; - output.returnCode = EReturnCode::SUCCESS; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); } PUBLIC_PROCEDURE(SetDrawHour) { if (qpi.invocator() != state.ownerAddress) { - output.returnCode = EReturnCode::ACCESS_DENIED; + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; } if (input.newDrawHour == 0 || input.newDrawHour > 23) { - output.returnCode = EReturnCode::INVALID_VALUE; + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); return; } state.nextEpochData.newDrawHour = input.newDrawHour; - output.returnCode = EReturnCode::SUCCESS; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); } // Functions @@ -878,6 +896,25 @@ struct QTF : public ContractBase PUBLIC_FUNCTION(GetSchedule) { output.schedule = state.schedule; } PUBLIC_FUNCTION(GetDrawHour) { output.drawHour = state.drawHour; } PUBLIC_FUNCTION(GetState) { output.currentState = static_cast(state.currentState); } + PUBLIC_FUNCTION_WITH_LOCALS(GetFees) + { + CALL_OTHER_CONTRACT_FUNCTION(RL, GetFees, locals.feesInput, locals.feesOutput); + output.teamFeePercent = locals.feesOutput.teamFeePercent; + output.distributionFeePercent = locals.feesOutput.distributionFeePercent; + output.burnPercent = locals.feesOutput.burnPercent; + output.winnerFeePercent = locals.feesOutput.winnerFeePercent; + + // Sanity check: if RL returns invalid fees, use defaults + if (output.teamFeePercent == 0 || output.distributionFeePercent == 0 || output.winnerFeePercent == 0) + { + output.teamFeePercent = QTF_DEFAULT_DEV_PERCENT; + output.distributionFeePercent = QTF_DEFAULT_DIST_PERCENT; + output.burnPercent = QTF_DEFAULT_BURN_PERCENT; + output.winnerFeePercent = QTF_DEFAULT_WINNERS_PERCENT; + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } protected: static void clearEpochState(QTF& state) @@ -1020,27 +1057,12 @@ struct QTF : public ContractBase return; } - // Pull fee percents from RL so Distribution matches RL shareholders. - // Fallback to default percentages if RL returns zeros. - CALL_OTHER_CONTRACT_FUNCTION(RL, GetFees, locals.feesInput, locals.feesOutput); - locals.devPercent = locals.feesOutput.teamFeePercent; - locals.distPercent = locals.feesOutput.distributionFeePercent; - locals.burnPercent = locals.feesOutput.burnPercent; - locals.winnersPercent = locals.feesOutput.winnerFeePercent; - - // Sanity check: if RL returns invalid fees, use defaults - if (locals.devPercent == 0 || locals.distPercent == 0 || locals.winnersPercent == 0) - { - locals.devPercent = QTF_DEFAULT_DEV_PERCENT; - locals.distPercent = QTF_DEFAULT_DIST_PERCENT; - locals.burnPercent = QTF_DEFAULT_BURN_PERCENT; - locals.winnersPercent = QTF_DEFAULT_WINNERS_PERCENT; - } + CALL(GetFees, locals.feesInput, locals.feesOutput); - locals.winnersBlock = div(smul(locals.revenue, locals.winnersPercent), 100); - locals.devPayout = div(smul(locals.revenue, locals.devPercent), 100); - locals.distPayout = div(smul(locals.revenue, locals.distPercent), 100); - locals.burnAmount = div(smul(locals.revenue, locals.burnPercent), 100); + locals.winnersBlock = div(smul(locals.revenue, static_cast(locals.feesOutput.winnerFeePercent)), 100); + locals.devPayout = div(smul(locals.revenue, static_cast(locals.feesOutput.teamFeePercent)), 100); + locals.distPayout = div(smul(locals.revenue, static_cast(locals.feesOutput.distributionFeePercent)), 100); + locals.burnAmount = div(smul(locals.revenue, static_cast(locals.feesOutput.burnPercent)), 100); // FR detection and hysteresis logic. // Update hysteresis counter BEFORE activation check to ensure correct deactivation timing. From 466fd5a39a6b100b1146e4c0d6de11a26e52febc Mon Sep 17 00:00:00 2001 From: N-010 Date: Mon, 8 Dec 2025 23:05:09 +0300 Subject: [PATCH 08/77] Refactoring --- src/contracts/QThirtyFour.h | 189 +++++++++++++++++++++--------------- 1 file changed, 113 insertions(+), 76 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 2966d78c4..76747b299 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -1114,12 +1114,12 @@ struct QTF : public ContractBase locals.distExtraBP = locals.calcExtraOutput.extraBP - locals.devExtraBP; // Handle odd remainder // Total redirect BP = base + extra - locals.tmp64a = sadd(QTF_FR_DEV_REDIRECT_BP, locals.devExtraBP); - locals.tmp64b = sadd(QTF_FR_DIST_REDIRECT_BP, locals.distExtraBP); + locals.totalDevRedirectBP = sadd(QTF_FR_DEV_REDIRECT_BP, locals.devExtraBP); + locals.totalDistRedirectBP = sadd(QTF_FR_DIST_REDIRECT_BP, locals.distExtraBP); // Calculate redirect amounts - locals.devRedirect = div(smul(locals.revenue, locals.tmp64a), 10000); - locals.distRedirect = div(smul(locals.revenue, locals.tmp64b), 10000); + locals.devRedirect = div(smul(locals.revenue, locals.totalDevRedirectBP), 10000); + locals.distRedirect = div(smul(locals.revenue, locals.totalDistRedirectBP), 10000); // Deduct redirects from payouts (capped at available amounts) if (locals.devPayout > locals.devRedirect) @@ -1192,65 +1192,32 @@ struct QTF : public ContractBase // Minimum payout floors: ensure k2 winners get >= 0.5*P, k3 winners get >= 5*P. // Top up from Reserve.General if pool insufficient (subject to safety limits). - if (locals.countK2 > 0) - { - // k2 floor: 0.5 * ticketPrice per winner - locals.tmp64a = div(smul(state.ticketPrice, QTF_K2_FLOOR_MULT), QTF_K2_FLOOR_DIV); - locals.tmp64b = smul(locals.tmp64a, locals.countK2); // total floor needed - locals.tmp64c = smul(state.ticketPrice, QTF_TOPUP_PER_WINNER_CAP_MULT); // per-winner cap (25*P) - - if (locals.k2PayoutPool < locals.tmp64b) - { - // Pool insufficient: top up from reserve (respecting safety limits) - locals.calcTopUpInput.availableReserve = state.reserveGeneral; - locals.calcTopUpInput.needed = locals.tmp64b - locals.k2PayoutPool; - locals.calcTopUpInput.perWinnerCapTotal = smul(locals.tmp64c, locals.countK2); - locals.calcTopUpInput.ticketPrice = state.ticketPrice; - CALL(CalcReserveTopUp, locals.calcTopUpInput, locals.calcTopUpOutput); - locals.topUpK2 = RL::min(locals.calcTopUpOutput.topUpAmount, locals.tmp64b - locals.k2PayoutPool); - state.reserveGeneral -= locals.topUpK2; - locals.k2PayoutPool = sadd(locals.k2PayoutPool, locals.topUpK2); - } - - // Calculate actual per-winner payout (capped at 25*P) - locals.k2PerWinner = RL::min(locals.tmp64c, locals.k2PayoutPool / locals.countK2); - locals.winnersOverflow = sadd(locals.winnersOverflow, (locals.k2PayoutPool - smul(locals.k2PerWinner, locals.countK2))); - } - else - { - // No k2 winners: entire k2 pool becomes overflow - locals.winnersOverflow = sadd(locals.winnersOverflow, locals.k2PayoutPool); - } - - if (locals.countK3 > 0) - { - // k3 floor: 5 * ticketPrice per winner - locals.tmp64a = smul(state.ticketPrice, QTF_K3_FLOOR_MULT); - locals.tmp64b = smul(locals.tmp64a, locals.countK3); // total floor needed - locals.tmp64c = smul(state.ticketPrice, QTF_TOPUP_PER_WINNER_CAP_MULT); // per-winner cap (25*P) - - if (locals.k3PayoutPool < locals.tmp64b) - { - // Pool insufficient: top up from reserve (respecting safety limits) - locals.calcTopUpInput.availableReserve = state.reserveGeneral; - locals.calcTopUpInput.needed = locals.tmp64b - locals.k3PayoutPool; - locals.calcTopUpInput.perWinnerCapTotal = smul(locals.tmp64c, locals.countK3); - locals.calcTopUpInput.ticketPrice = state.ticketPrice; - CALL(CalcReserveTopUp, locals.calcTopUpInput, locals.calcTopUpOutput); - locals.topUpK3 = RL::min(locals.calcTopUpOutput.topUpAmount, locals.tmp64b - locals.k3PayoutPool); - state.reserveGeneral -= locals.topUpK3; - locals.k3PayoutPool = sadd(locals.k3PayoutPool, locals.topUpK3); - } - - // Calculate actual per-winner payout (capped at 25*P) - locals.k3PerWinner = RL::min(locals.tmp64c, locals.k3PayoutPool / locals.countK3); - locals.winnersOverflow = sadd(locals.winnersOverflow, (locals.k3PayoutPool - smul(locals.k3PerWinner, locals.countK3))); - } - else - { - // No k3 winners: entire k3 pool becomes overflow - locals.winnersOverflow = sadd(locals.winnersOverflow, locals.k3PayoutPool); - } + // First, get total QRP balance for safety limit calculations (10% of total reserve per round). + CALL_OTHER_CONTRACT_FUNCTION(QRP, GetAvailableReserve, locals.qrpGetAvailableInput, locals.qrpGetAvailableOutput); + locals.totalQRPBalance = locals.qrpGetAvailableOutput.availableReserve; + locals.perWinnerCap = smul(state.ticketPrice, QTF_TOPUP_PER_WINNER_CAP_MULT); // 25*P + + // Process k2 tier payout + locals.tierPayoutInput.floorPerWinner = div(smul(state.ticketPrice, QTF_K2_FLOOR_MULT), QTF_K2_FLOOR_DIV); + locals.tierPayoutInput.winnerCount = locals.countK2; + locals.tierPayoutInput.payoutPool = locals.k2PayoutPool; + locals.tierPayoutInput.perWinnerCap = locals.perWinnerCap; + locals.tierPayoutInput.totalQRPBalance = locals.totalQRPBalance; + locals.tierPayoutInput.ticketPrice = state.ticketPrice; + CALL(ProcessTierPayout, locals.tierPayoutInput, locals.tierPayoutOutput); + locals.k2PerWinner = locals.tierPayoutOutput.perWinnerPayout; + locals.winnersOverflow = sadd(locals.winnersOverflow, locals.tierPayoutOutput.overflow); + + // Process k3 tier payout + locals.tierPayoutInput.floorPerWinner = smul(state.ticketPrice, QTF_K3_FLOOR_MULT); + locals.tierPayoutInput.winnerCount = locals.countK3; + locals.tierPayoutInput.payoutPool = locals.k3PayoutPool; + locals.tierPayoutInput.perWinnerCap = locals.perWinnerCap; + locals.tierPayoutInput.totalQRPBalance = locals.totalQRPBalance; + locals.tierPayoutInput.ticketPrice = state.ticketPrice; + CALL(ProcessTierPayout, locals.tierPayoutInput, locals.tierPayoutOutput); + locals.k3PerWinner = locals.tierPayoutOutput.perWinnerPayout; + locals.winnersOverflow = sadd(locals.winnersOverflow, locals.tierPayoutOutput.overflow); locals.carryAdd = sadd(locals.carryAdd, locals.winnersRake); @@ -1276,10 +1243,10 @@ struct QTF : public ContractBase // k4 payout (jackpot) else if (locals.matches == 4 && locals.countK4 > 0 && state.jackpot > 0) { - locals.tmp64a = state.jackpot / locals.countK4; - if (locals.tmp64a > 0) + locals.jackpotPerK4Winner = state.jackpot / locals.countK4; + if (locals.jackpotPerK4Winner > 0) { - qpi.transfer(state.players.get(locals.i).player, locals.tmp64a); + qpi.transfer(state.players.get(locals.i).player, locals.jackpotPerK4Winner); fillWinnerData(state, state.players.get(locals.i), locals.winningValues, locals.currentEpoch); } } @@ -1297,10 +1264,19 @@ struct QTF : public ContractBase state.frRoundsSinceK4 = 0; state.frRoundsAtOrAboveTarget = 0; - // Reseed jackpot from Reserve.JackpotRebuild (up to targetJackpot or available balance) - locals.tmp64b = RL::min(state.reserveJackpotRebuild, state.targetJackpot); - state.jackpot = sadd(state.jackpot, locals.tmp64b); - state.reserveJackpotRebuild -= locals.tmp64b; + // Reseed jackpot from QReservePool (up to targetJackpot or available reserve) + locals.qrpRequested = RL::min(locals.totalQRPBalance, state.targetJackpot); + if (locals.qrpRequested > 0) + { + locals.qrpGetReserveInput.revenue = locals.qrpRequested; + INVOKE_OTHER_CONTRACT_PROCEDURE(QRP, GetReserve, locals.qrpGetReserveInput, locals.qrpGetReserveOutput, 0ll); + + if (locals.qrpGetReserveOutput.returnCode == QRPReturnCode::SUCCESS) + { + locals.qrpReceived = locals.qrpGetReserveOutput.allocatedRevenue; + state.jackpot = sadd(state.jackpot, locals.qrpReceived); + } + } } else { @@ -1325,13 +1301,14 @@ struct QTF : public ContractBase } // Add all jackpot contributions: overflow carryAdd + FR redirects (if active) - locals.tmp64a = sadd(locals.carryAdd, sadd(locals.devRedirect, locals.distRedirect)); - state.jackpot = sadd(state.jackpot, locals.tmp64a); + locals.totalJackpotContribution = sadd(locals.carryAdd, sadd(locals.devRedirect, locals.distRedirect)); + state.jackpot = sadd(state.jackpot, locals.totalJackpotContribution); - // Split reserve overflow between JackpotRebuild and General using configured split ratio - locals.tmp64a = div(smul(locals.reserveAdd, QTF_RESERVE_SPLIT_JACKPOT_BP), 10000); - state.reserveJackpotRebuild = sadd(state.reserveJackpotRebuild, locals.tmp64a); - state.reserveGeneral = sadd(state.reserveGeneral, (locals.reserveAdd - locals.tmp64a)); + // Transfer reserve overflow to QReservePool + if (locals.reserveAdd > 0) + { + qpi.transfer(QTF_RESERVE_POOL_CONTRACT_ID, locals.reserveAdd); + } if (locals.devPayout > 0) { @@ -1641,6 +1618,66 @@ struct QTF : public ContractBase } } + /** + * @brief Unified tier payout processing for k2/k3 winners. + * + * Handles floor guarantee with QRP top-up if pool is insufficient. + * Calculates per-winner payout (capped at perWinnerCap) and overflow. + * + * @param input.floorPerWinner - Floor payout per winner (0.5*P for k2, 5*P for k3) + * @param input.winnerCount - Number of winners in this tier + * @param input.payoutPool - Initial payout pool for this tier + * @param input.perWinnerCap - Per-winner cap (25*P) + * @param input.totalQRPBalance - QRP balance for safety limits + * @param input.ticketPrice - Current ticket price + * @param output.perWinnerPayout - Calculated per-winner payout + * @param output.overflow - Overflow amount (unused funds) + * @param output.topUpReceived - Amount received from QRP top-up + */ + PRIVATE_PROCEDURE_WITH_LOCALS(ProcessTierPayout) + { + output.topUpReceived = 0; + output.overflow = 0; + output.perWinnerPayout = 0; + + if (input.winnerCount == 0) + { + // No winners: entire pool becomes overflow + output.overflow = input.payoutPool; + return; + } + + locals.floorTotalNeeded = smul(input.floorPerWinner, input.winnerCount); + locals.finalPool = input.payoutPool; + + // Top-up from QRP if pool insufficient for floor guarantee + if (input.payoutPool < locals.floorTotalNeeded) + { + locals.calcTopUpInput.totalQRPBalance = input.totalQRPBalance; + locals.calcTopUpInput.needed = locals.floorTotalNeeded - input.payoutPool; + locals.calcTopUpInput.perWinnerCapTotal = smul(input.perWinnerCap, input.winnerCount); + locals.calcTopUpInput.ticketPrice = input.ticketPrice; + CALL(CalcReserveTopUp, locals.calcTopUpInput, locals.calcTopUpOutput); + + locals.qrpRequested = RL::min(locals.calcTopUpOutput.topUpAmount, locals.floorTotalNeeded - input.payoutPool); + if (locals.qrpRequested > 0) + { + locals.qrpGetReserveInput.revenue = locals.qrpRequested; + INVOKE_OTHER_CONTRACT_PROCEDURE(QRP, GetReserve, locals.qrpGetReserveInput, locals.qrpGetReserveOutput, 0ll); + + if (locals.qrpGetReserveOutput.returnCode == QRPReturnCode::SUCCESS) + { + output.topUpReceived = locals.qrpGetReserveOutput.allocatedRevenue; + locals.finalPool = sadd(input.payoutPool, output.topUpReceived); + } + } + } + + // Calculate per-winner payout (capped at perWinnerCap) + output.perWinnerPayout = RL::min(input.perWinnerCap, locals.finalPool / input.winnerCount); + output.overflow = locals.finalPool - smul(output.perWinnerPayout, input.winnerCount); + } + /** * @brief Estimates base carry gain per round from FR mechanisms (without extra redirect). * From 93c76a6096caf80ce73222992a12e34ebd452e86 Mon Sep 17 00:00:00 2001 From: N-010 Date: Tue, 9 Dec 2025 22:30:44 +0300 Subject: [PATCH 09/77] Remove profit transfer from k3 to k2 with FR enabled --- src/contracts/QThirtyFour.h | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 76747b299..4568123d0 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -6,8 +6,10 @@ static constexpr uint64 QTF_RANDOM_VALUES_COUNT = 4; static constexpr uint64 QTF_MAX_RANDOM_VALUE = 30; static constexpr uint64 QTF_TICKET_PRICE = 1000000; -// Baseline split for k2/k3 when FR is OFF (spec leaves this open; choose 50/50 as default). -static constexpr uint64 QTF_BASE_K3_SHARE_BP = 5000; // basis points of winners block (50.00%). +// Baseline split for k2/k3 when FR is OFF (per spec: k3=40%, k2=28% of Winners block). +// Remaining 32% of Winners block goes to overflow. +static constexpr uint64 QTF_BASE_K3_SHARE_BP = 4000; // 40% of winners block to k3 +static constexpr uint64 QTF_BASE_K2_SHARE_BP = 2800; // 28% of winners block to k2 // --- Fast-Recovery (FR) parameters (spec defaults) -------------------------- // Fast-Recovery base redirect percentages (always active when FR=ON) @@ -30,8 +32,6 @@ static constexpr uint64 QTF_FIXED_POINT_SCALE = 1000000; // Scale for fixed-poin // p4 = C(4,4) * C(26,0) / C(30,4) = 1 / 27405 static constexpr uint64 QTF_P4_DENOMINATOR = 27405; // Denominator for k=4 probability (1/27405) static constexpr uint64 QTF_FR_WINNERS_RAKE_BP = 500; // 5% of winners block from k3 -static constexpr uint64 QTF_FR_K3_SHARE_BP = 3500; // 35% of win_eff to k3 -static constexpr uint64 QTF_FR_K2_SHARE_BP = 6500; // 65% of win_eff to k2 static constexpr uint64 QTF_FR_ALPHA_BP = 500; // alpha = 0.05 -> 95% overflow to jackpot static constexpr uint16 QTF_FR_POST_K4_WINDOW_ROUNDS = 50; static constexpr uint16 QTF_FR_HYSTERESIS_ROUNDS = 3; @@ -1146,15 +1146,18 @@ struct QTF : public ContractBase locals.winnersRake = div(smul(locals.winnersBlock, QTF_FR_WINNERS_RAKE_BP), 10000); locals.winnersBlock -= locals.winnersRake; - // FR tier split: 35% k3, 65% k2 (tilted toward k2 for stability during FR) - locals.k3Pool = div(smul(locals.winnersBlock, QTF_FR_K3_SHARE_BP), 10000); - locals.k2Pool = locals.winnersBlock - locals.k3Pool; + // FR tier split: same as baseline (k3=40%, k2=28% of win_eff) + calcK2K3Pool(locals.winnersBlock, locals.k2Pool, locals.k3Pool); + // Remaining goes to overflow (will be split with FR alpha: 95% carry, 5% reserve) + locals.winnersOverflow = locals.winnersBlock - locals.k3Pool - locals.k2Pool; } else { - // Baseline mode: 50/50 split between k2 and k3 (no rake, no redirects) - locals.k3Pool = div(smul(locals.winnersBlock, QTF_BASE_K3_SHARE_BP), 10000); - locals.k2Pool = locals.winnersBlock - locals.k3Pool; + // Baseline mode: k3=40%, k2=28% of Winners block + // Remaining 32% of Winners block goes to overflow automatically + calcK2K3Pool(locals.winnersBlock, locals.k2Pool, locals.k3Pool); + // Add baseline overflow (32% of winnersBlock) to winnersOverflow + locals.winnersOverflow = locals.winnersBlock - locals.k3Pool - locals.k2Pool; } locals.k2PayoutPool = locals.k2Pool; // mutable pools after top-ups @@ -1756,4 +1759,10 @@ struct QTF : public ContractBase // Clamp to maximum output.extraBP = RL::min(locals.extraBPTemp, QTF_FR_EXTRA_MAX_BP); } + + static void calcK2K3Pool(uint64 winnersBlock, uint64& outK2Pool, uint64& outK3Pool) + { + outK3Pool = div(smul(winnersBlock, QTF_BASE_K3_SHARE_BP), 10000); + outK2Pool = div(smul(winnersBlock, QTF_BASE_K2_SHARE_BP), 10000); + } }; From 569f7a8d52eb8491c64640b449acfcce0631643a Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 10 Dec 2025 22:46:10 +0300 Subject: [PATCH 10/77] Adds tests --- src/contracts/QThirtyFour.h | 77 +- test/contract_qtf.cpp | 1351 +++++++++++++++++++++++++++++++++++ 2 files changed, 1389 insertions(+), 39 deletions(-) create mode 100644 test/contract_qtf.cpp diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 4568123d0..54d09137a 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -1,20 +1,20 @@ using namespace QPI; // --- Core game parameters ---------------------------------------------------- -static constexpr uint64 QTF_MAX_NUMBER_OF_PLAYERS = 1024; -static constexpr uint64 QTF_RANDOM_VALUES_COUNT = 4; -static constexpr uint64 QTF_MAX_RANDOM_VALUE = 30; -static constexpr uint64 QTF_TICKET_PRICE = 1000000; +constexpr uint64 QTF_MAX_NUMBER_OF_PLAYERS = 1024; +constexpr uint64 QTF_RANDOM_VALUES_COUNT = 4; +constexpr uint64 QTF_MAX_RANDOM_VALUE = 30; +constexpr uint64 QTF_TICKET_PRICE = 1000000; // Baseline split for k2/k3 when FR is OFF (per spec: k3=40%, k2=28% of Winners block). // Remaining 32% of Winners block goes to overflow. -static constexpr uint64 QTF_BASE_K3_SHARE_BP = 4000; // 40% of winners block to k3 -static constexpr uint64 QTF_BASE_K2_SHARE_BP = 2800; // 28% of winners block to k2 +constexpr uint64 QTF_BASE_K3_SHARE_BP = 4000; // 40% of winners block to k3 +constexpr uint64 QTF_BASE_K2_SHARE_BP = 2800; // 28% of winners block to k2 // --- Fast-Recovery (FR) parameters (spec defaults) -------------------------- // Fast-Recovery base redirect percentages (always active when FR=ON) -static constexpr uint64 QTF_FR_DEV_REDIRECT_BP = 100; // 1.00% of R (base redirect, always applied) -static constexpr uint64 QTF_FR_DIST_REDIRECT_BP = 100; // 1.00% of R (base redirect, always applied) +constexpr uint64 QTF_FR_DEV_REDIRECT_BP = 100; // 1.00% of R (base redirect, always applied) +constexpr uint64 QTF_FR_DIST_REDIRECT_BP = 100; // 1.00% of R (base redirect, always applied) // Deficit-driven extra redirect parameters (dynamic, no hard N threshold) // The extra redirect is calculated based on: @@ -24,48 +24,47 @@ static constexpr uint64 QTF_FR_DIST_REDIRECT_BP = 100; // 1.00% of R (base redir // - Needed gain: g_need = max(0, Δ/H - baseGain) // - Extra percentage: extra_pp = clamp(g_need / R, 0, extra_max) // - Split equally: dev_extra = dist_extra = extra_pp / 2 -static constexpr uint64 QTF_FR_EXTRA_MAX_BP = 70; // Maximum extra redirect: 0.70% of R total (0.35% each Dev/Dist) -static constexpr uint64 QTF_FR_GOAL_ROUNDS_CAP = 100; // Cap on expected rounds horizon H for deficit calculation -static constexpr uint64 QTF_FIXED_POINT_SCALE = 1000000; // Scale for fixed-point arithmetic (6 decimals precision) +constexpr uint64 QTF_FR_EXTRA_MAX_BP = 70; // Maximum extra redirect: 0.70% of R total (0.35% each Dev/Dist) +constexpr uint64 QTF_FR_GOAL_ROUNDS_CAP = 50; // Cap on expected rounds horizon H for deficit calculation +constexpr uint64 QTF_FIXED_POINT_SCALE = 1000000; // Scale for fixed-point arithmetic (6 decimals precision) // Probability constants for k=4 win (exact combinatorics: 4-of-30) // p4 = C(4,4) * C(26,0) / C(30,4) = 1 / 27405 -static constexpr uint64 QTF_P4_DENOMINATOR = 27405; // Denominator for k=4 probability (1/27405) -static constexpr uint64 QTF_FR_WINNERS_RAKE_BP = 500; // 5% of winners block from k3 -static constexpr uint64 QTF_FR_ALPHA_BP = 500; // alpha = 0.05 -> 95% overflow to jackpot -static constexpr uint16 QTF_FR_POST_K4_WINDOW_ROUNDS = 50; -static constexpr uint16 QTF_FR_HYSTERESIS_ROUNDS = 3; +constexpr uint64 QTF_P4_DENOMINATOR = 27405; // Denominator for k=4 probability (1/27405) +constexpr uint64 QTF_FR_WINNERS_RAKE_BP = 500; // 5% of winners block from k3 +constexpr uint64 QTF_FR_ALPHA_BP = 500; // alpha = 0.05 -> 95% overflow to jackpot +constexpr uint16 QTF_FR_POST_K4_WINDOW_ROUNDS = 50; +constexpr uint16 QTF_FR_HYSTERESIS_ROUNDS = 3; // --- Floors and reserve safety ---------------------------------------------- -static constexpr uint64 QTF_K2_FLOOR_MULT = 1; // numerator for 0.5 * P (we divide by 2) -static constexpr uint64 QTF_K2_FLOOR_DIV = 2; -static constexpr uint64 QTF_K3_FLOOR_MULT = 5; // 5 * P -static constexpr uint64 QTF_TOPUP_PER_WINNER_CAP_MULT = 25; // 25 * P -static constexpr uint64 QTF_TOPUP_RESERVE_PCT_BP = 1000; // 10% of reserve per round -static constexpr uint64 QTF_RESERVE_SOFT_FLOOR_MULT = 20; // keep at least 20 * P in reserve +constexpr uint64 QTF_K2_FLOOR_MULT = 1; // numerator for 0.5 * P (we divide by 2) +constexpr uint64 QTF_K2_FLOOR_DIV = 2; +constexpr uint64 QTF_K3_FLOOR_MULT = 5; // 5 * P +constexpr uint64 QTF_TOPUP_PER_WINNER_CAP_MULT = 25; // 25 * P +constexpr uint64 QTF_TOPUP_RESERVE_PCT_BP = 1000; // 10% of reserve per round +constexpr uint64 QTF_RESERVE_SOFT_FLOOR_MULT = 20; // keep at least 20 * P in reserve // Baseline overflow split (reserve share in basis points). If spec is updated, adjust here. -static constexpr uint64 QTF_BASELINE_OVERFLOW_ALPHA_BP = 5000; // 50% reserve / 50% jackpot +constexpr uint64 QTF_BASELINE_OVERFLOW_ALPHA_BP = 5000; // 50% reserve / 50% jackpot // Default fee percentages (fallback if RL::GetFees fails) -static constexpr uint8 QTF_DEFAULT_DEV_PERCENT = 10; -static constexpr uint8 QTF_DEFAULT_DIST_PERCENT = 20; -static constexpr uint8 QTF_DEFAULT_BURN_PERCENT = 2; -static constexpr uint8 QTF_DEFAULT_WINNERS_PERCENT = 68; +constexpr uint8 QTF_DEFAULT_DEV_PERCENT = 10; +constexpr uint8 QTF_DEFAULT_DIST_PERCENT = 20; +constexpr uint8 QTF_DEFAULT_BURN_PERCENT = 2; +constexpr uint8 QTF_DEFAULT_WINNERS_PERCENT = 68; // Maximum attempts to generate unique random value before fallback -static constexpr uint8 QTF_MAX_RANDOM_GENERATION_ATTEMPTS = 100; - -static constexpr uint8 QTF_DEFAULT_SCHEDULE = 1u << WEDNESDAY; -static constexpr uint8 QTF_DEFAULT_DRAW_HOUR = 11; // 11:00 UTC -static constexpr uint32 QTF_DEFAULT_INIT_TIME = 22u << 9 | 4u << 5 | 13u; // RL_DEFAULT_INIT_TIME - -static const id QTF_ADDRESS_DEV_TEAM = - ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, _W, _E, _T, _H, _N, _G, - _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); -static const id QTF_RANDOM_LOTTERY_CONTRACT_ID = id(RL_CONTRACT_INDEX, 0, 0, 0); -static const uint64 QTF_RANDOM_LOTTERY_ASSET_NAME = *reinterpret_cast("RL"); -static const id QTF_RESERVE_POOL_CONTRACT_ID = id(QRP_CONTRACT_INDEX, 0, 0, 0); +constexpr uint8 QTF_MAX_RANDOM_GENERATION_ATTEMPTS = 100; + +constexpr uint8 QTF_DEFAULT_SCHEDULE = 1u << WEDNESDAY; +constexpr uint8 QTF_DEFAULT_DRAW_HOUR = 11; // 11:00 UTC +constexpr uint32 QTF_DEFAULT_INIT_TIME = 22u << 9 | 4u << 5 | 13u; // RL_DEFAULT_INIT_TIME + +const id QTF_ADDRESS_DEV_TEAM = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, + _L, _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); +const id QTF_RANDOM_LOTTERY_CONTRACT_ID = id(RL_CONTRACT_INDEX, 0, 0, 0); +const uint64 QTF_RANDOM_LOTTERY_ASSET_NAME = *reinterpret_cast("RL"); +const id QTF_RESERVE_POOL_CONTRACT_ID = id(QRP_CONTRACT_INDEX, 0, 0, 0); using QTFRandomValues = Array; using QFTWinnerPlayers = Array; diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp new file mode 100644 index 000000000..7d7d75db3 --- /dev/null +++ b/test/contract_qtf.cpp @@ -0,0 +1,1351 @@ +// File: test/contract_qtf.cpp +// Tests for QThirtyFour (4-of-30 lottery) smart contract +// Based on specification: doc/QThirtyFour_Proposal.md + +#define NO_UEFI + +#include "contract_testing.h" +#include +#include + +// Procedure indices (must match REGISTER_USER_FUNCTIONS_AND_PROCEDURES in QThirtyFour.h) +constexpr uint16 QTF_PROCEDURE_BUY_TICKET = 1; +constexpr uint16 QTF_PROCEDURE_SET_PRICE = 2; +constexpr uint16 QTF_PROCEDURE_SET_SCHEDULE = 3; +constexpr uint16 QTF_PROCEDURE_SET_TARGET_JACKPOT = 4; +constexpr uint16 QTF_PROCEDURE_SET_DRAW_HOUR = 5; + +// Function indices +constexpr uint16 QTF_FUNCTION_GET_TICKET_PRICE = 1; +constexpr uint16 QTF_FUNCTION_GET_NEXT_EPOCH_DATA = 2; +constexpr uint16 QTF_FUNCTION_GET_WINNER_DATA = 3; +constexpr uint16 QTF_FUNCTION_GET_POOLS = 4; +constexpr uint16 QTF_FUNCTION_GET_SCHEDULE = 5; +constexpr uint16 QTF_FUNCTION_GET_DRAW_HOUR = 6; +constexpr uint16 QTF_FUNCTION_GET_STATE = 7; +constexpr uint16 QTF_FUNCTION_GET_FEES = 8; + +static const id QTF_DEV_ADDRESS = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, + _R, _L, _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); + +constexpr uint8 QTF_ANY_DAY_SCHEDULE = 0xFF; + +// Test helper class exposing internal state +class QTFChecker : public QTF +{ +public: + uint64 getNumberOfPlayers() const { return numberOfPlayers; } + uint64 getTicketPriceInternal() const { return ticketPrice; } + uint64 getJackpot() const { return jackpot; } + uint64 getTargetJackpotInternal() const { return targetJackpot; } + unsigned int getScheduleInternal() const { return schedule; } + unsigned int getDrawHourInternal() const { return drawHour; } + uint32 getLastDrawDateStamp() const { return lastDrawDateStamp; } + bool getFrActive() const { return frActive; } + unsigned int getFrRoundsSinceK4() const { return frRoundsSinceK4; } + unsigned int getFrRoundsAtOrAboveTarget() const { return frRoundsAtOrAboveTarget; } + unsigned int getCurrentState() const { return currentState; } + const id& getTeamAddress() const { return teamAddress; } + const id& getOwnerAddress() const { return ownerAddress; } + + void setScheduleMask(uint8 newMask) { schedule = newMask; } + void setJackpot(uint64 value) { jackpot = value; } + void setTargetJackpotInternal(uint64 value) { targetJackpot = value; } + void setFrActive(bit value) { frActive = value; } + void setFrRoundsSinceK4(uint16 value) { frRoundsSinceK4 = value; } + void setFrRoundsAtOrAboveTarget(uint16 value) { frRoundsAtOrAboveTarget = value; } + + const PlayerData& getPlayer(uint64 index) const { return players.get(index); } + const WinnerData& getLastWinnerDataInternal() const { return lastWinnerData; } +}; + +class ContractTestingQTF : protected ContractTesting +{ +public: + ContractTestingQTF() + { + initEmptySpectrum(); + initEmptyUniverse(); + INIT_CONTRACT(QRP); + INIT_CONTRACT(RL); + INIT_CONTRACT(QTF); + + // Initialize QRP first (QTF depends on it for reserve operations) + callSystemProcedure(QRP_CONTRACT_INDEX, INITIALIZE); + // Initialize RL (QTF queries fees from RL) + callSystemProcedure(RL_CONTRACT_INDEX, INITIALIZE); + // Initialize QTF + system.epoch = contractDescriptions[QTF_CONTRACT_INDEX].constructionEpoch; + callSystemProcedure(QTF_CONTRACT_INDEX, INITIALIZE); + } + + // Access internal contract state + QTFChecker* state() { return reinterpret_cast(contractStates[QTF_CONTRACT_INDEX]); } + + id qtfSelf() { return id(QTF_CONTRACT_INDEX, 0, 0, 0); } + id qrpSelf() { return id(QRP_CONTRACT_INDEX, 0, 0, 0); } + + // Public function wrappers + QTF::GetTicketPrice_output getTicketPrice() + { + QTF::GetTicketPrice_input input; + QTF::GetTicketPrice_output output; + callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_TICKET_PRICE, input, output); + return output; + } + + QTF::GetNextEpochData_output getNextEpochData() + { + QTF::GetNextEpochData_input input; + QTF::GetNextEpochData_output output; + callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_NEXT_EPOCH_DATA, input, output); + return output; + } + + QTF::GetWinnerData_output getWinnerData() + { + QTF::GetWinnerData_input input; + QTF::GetWinnerData_output output; + callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_WINNER_DATA, input, output); + return output; + } + + QTF::GetPools_output getPools() + { + QTF::GetPools_input input; + QTF::GetPools_output output; + callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_POOLS, input, output); + return output; + } + + QTF::GetSchedule_output getSchedule() + { + QTF::GetSchedule_input input; + QTF::GetSchedule_output output; + callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_SCHEDULE, input, output); + return output; + } + + QTF::GetDrawHour_output getDrawHour() + { + QTF::GetDrawHour_input input; + QTF::GetDrawHour_output output; + callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_DRAW_HOUR, input, output); + return output; + } + + QTF::GetState_output getStateInfo() + { + QTF::GetState_input input; + QTF::GetState_output output; + callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_STATE, input, output); + return output; + } + + QTF::GetFees_output getFees() + { + QTF::GetFees_input input; + QTF::GetFees_output output; + callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_FEES, input, output); + return output; + } + + // Procedure wrappers + QTF::BuyTicket_output buyTicket(const id& user, uint64 reward, const QTFRandomValues& numbers) + { + QTF::BuyTicket_input input; + input.randomValues = numbers; + QTF::BuyTicket_output output; + if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_BUY_TICKET, input, output, user, reward)) + { + output.returnCode = static_cast(QTF::EReturnCode::MAX_VALUE); + } + return output; + } + + QTF::SetPrice_output setPrice(const id& invocator, uint64 newPrice) + { + QTF::SetPrice_input input; + input.newPrice = newPrice; + QTF::SetPrice_output output; + if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_SET_PRICE, input, output, invocator, 0)) + { + output.returnCode = static_cast(QTF::EReturnCode::MAX_VALUE); + } + return output; + } + + QTF::SetSchedule_output setSchedule(const id& invocator, uint8 newSchedule) + { + QTF::SetSchedule_input input; + input.newSchedule = newSchedule; + QTF::SetSchedule_output output; + if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_SET_SCHEDULE, input, output, invocator, 0)) + { + output.returnCode = static_cast(QTF::EReturnCode::MAX_VALUE); + } + return output; + } + + QTF::SetTargetJackpot_output setTargetJackpot(const id& invocator, uint64 newTarget) + { + QTF::SetTargetJackpot_input input; + input.newTargetJackpot = newTarget; + QTF::SetTargetJackpot_output output; + if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_SET_TARGET_JACKPOT, input, output, invocator, 0)) + { + output.returnCode = static_cast(QTF::EReturnCode::MAX_VALUE); + } + return output; + } + + QTF::SetDrawHour_output setDrawHour(const id& invocator, uint8 newHour) + { + QTF::SetDrawHour_input input; + input.newDrawHour = newHour; + QTF::SetDrawHour_output output; + if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_SET_DRAW_HOUR, input, output, invocator, 0)) + { + output.returnCode = static_cast(QTF::EReturnCode::MAX_VALUE); + } + return output; + } + + // System procedure wrappers + void beginEpoch() { callSystemProcedure(QTF_CONTRACT_INDEX, BEGIN_EPOCH); } + void endEpoch() { callSystemProcedure(QTF_CONTRACT_INDEX, END_EPOCH); } + void beginTick() { callSystemProcedure(QTF_CONTRACT_INDEX, BEGIN_TICK); } + + // Time helpers + void setCurrentHour(uint8 hour) + { + updateTime(); + utcTime.Hour = hour; + updateQpiTime(); + } + + void setDateTime(uint16 year, uint8 month, uint8 day, uint8 hour) + { + updateTime(); + utcTime.Year = year; + utcTime.Month = month; + utcTime.Day = day; + utcTime.Hour = hour; + utcTime.Minute = 0; + utcTime.Second = 0; + utcTime.Nanosecond = 0; + updateQpiTime(); + } + + void forceBeginTick() + { + system.tick = system.tick + (RL_TICK_UPDATE_PERIOD - mod(system.tick, static_cast(RL_TICK_UPDATE_PERIOD))); + beginTick(); + } + + void beginEpochWithDate(uint16 year, uint8 month, uint8 day, uint8 hour = 12) + { + setDateTime(year, month, day, hour); + beginEpoch(); + } + + void beginEpochWithValidTime() + { + beginEpochWithDate(2025, 1, 20); + } + + // Force schedule mask directly in state + void forceSchedule(uint8 scheduleMask) + { + state()->setScheduleMask(scheduleMask); + } + + // Advance to next day and trigger draw + void advanceOneDayAndDraw() + { + constexpr uint16 y = 2025; + constexpr uint8 m = 1; + constexpr uint8 d = 10; + setDateTime(y, m, d, 12); + forceBeginTick(); + } + + // Helper to create valid random values + QTFRandomValues makeValidNumbers(uint8 n1, uint8 n2, uint8 n3, uint8 n4) + { + QTFRandomValues values; + values.set(0, n1); + values.set(1, n2); + values.set(2, n3); + values.set(3, n4); + return values; + } + + // Fund user and buy a ticket + void fundAndBuyTicket(const id& user, uint64 ticketPrice, const QTFRandomValues& numbers) + { + increaseEnergy(user, ticketPrice * 2); + const QTF::BuyTicket_output out = buyTicket(user, ticketPrice, numbers); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + } + + // Fund QRP reserve pool + void fundQRP(uint64 amount) + { + increaseEnergy(qrpSelf(), amount); + } +}; + +// ============================================================================ +// BUY TICKET TESTS +// ============================================================================ + +TEST(ContractQThirtyFour, BuyTicket_WhenSellingClosed_RefundsAndFails) +{ + ContractTestingQTF ctl; + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + // Selling is closed initially (before beginEpoch with valid time) + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 2); + const uint64 balBefore = getBalance(user); + + QTFRandomValues nums = ctl.makeValidNumbers(1, 2, 3, 4); + const QTF::BuyTicket_output out = ctl.buyTicket(user, ticketPrice, nums); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::TICKET_SELLING_CLOSED)); + EXPECT_EQ(getBalance(user), balBefore); // Refunded + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, BuyTicket_WrongPrice_RefundsAndFails) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 5); + const uint64 balBefore = getBalance(user); + + QTFRandomValues nums = ctl.makeValidNumbers(1, 2, 3, 4); + + // Test with wrong price (too low) + const QTF::BuyTicket_output outLow = ctl.buyTicket(user, ticketPrice - 1, nums); + EXPECT_EQ(outLow.returnCode, static_cast(QTF::EReturnCode::INVALID_TICKET_PRICE)); + EXPECT_EQ(getBalance(user), balBefore); // Refunded + + // Test with wrong price (too high) + const QTF::BuyTicket_output outHigh = ctl.buyTicket(user, ticketPrice + 1, nums); + EXPECT_EQ(outHigh.returnCode, static_cast(QTF::EReturnCode::INVALID_TICKET_PRICE)); + EXPECT_EQ(getBalance(user), balBefore); // Refunded + + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, BuyTicket_InvalidNumbers_OutOfRange_Fails) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 10); + const uint64 balBefore = getBalance(user); + + // Number 0 is invalid (valid range is 1-30) + QTFRandomValues numsWithZero; + numsWithZero.set(0, 0); + numsWithZero.set(1, 2); + numsWithZero.set(2, 3); + numsWithZero.set(3, 4); + + const QTF::BuyTicket_output out1 = ctl.buyTicket(user, ticketPrice, numsWithZero); + EXPECT_EQ(out1.returnCode, static_cast(QTF::EReturnCode::INVALID_NUMBERS)); + EXPECT_EQ(getBalance(user), balBefore); + + // Number 31 is invalid (valid range is 1-30) + QTFRandomValues numsOver30; + numsOver30.set(0, 1); + numsOver30.set(1, 2); + numsOver30.set(2, 3); + numsOver30.set(3, 31); + + const QTF::BuyTicket_output out2 = ctl.buyTicket(user, ticketPrice, numsOver30); + EXPECT_EQ(out2.returnCode, static_cast(QTF::EReturnCode::INVALID_NUMBERS)); + EXPECT_EQ(getBalance(user), balBefore); + + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, BuyTicket_DuplicateNumbers_Fails) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 5); + const uint64 balBefore = getBalance(user); + + // Duplicate number 5 + QTFRandomValues dupNums; + dupNums.set(0, 5); + dupNums.set(1, 5); + dupNums.set(2, 10); + dupNums.set(3, 15); + + const QTF::BuyTicket_output out = ctl.buyTicket(user, ticketPrice, dupNums); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_NUMBERS)); + EXPECT_EQ(getBalance(user), balBefore); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, BuyTicket_ValidPurchase_Success) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 2); + const uint64 balBefore = getBalance(user); + + QTFRandomValues nums = ctl.makeValidNumbers(5, 10, 15, 20); + + const QTF::BuyTicket_output out = ctl.buyTicket(user, ticketPrice, nums); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + EXPECT_EQ(getBalance(user), balBefore - ticketPrice); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); + + // Verify player data stored correctly + const QTF::PlayerData& player = ctl.state()->getPlayer(0); + EXPECT_EQ(player.player, user); + EXPECT_EQ(player.randomValues.get(0), 5); + EXPECT_EQ(player.randomValues.get(1), 10); + EXPECT_EQ(player.randomValues.get(2), 15); + EXPECT_EQ(player.randomValues.get(3), 20); +} + +TEST(ContractQThirtyFour, BuyTicket_MultiplePlayers_Success) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + // Add 10 different players + for (int i = 0; i < 10; ++i) + { + const id user = id::randomValue(); + QTFRandomValues nums = ctl.makeValidNumbers( + static_cast(1 + i), + static_cast(11 + i), + static_cast(21), + static_cast(30)); + + ctl.fundAndBuyTicket(user, ticketPrice, nums); + } + + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 10u); +} + +TEST(ContractQThirtyFour, BuyTicket_MaxPlayersReached_Fails) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + // Fill up to max players (1024) + for (uint64 i = 0; i < QTF_MAX_NUMBER_OF_PLAYERS; ++i) + { + const id user = id::randomValue(); + QTFRandomValues nums = ctl.makeValidNumbers( + static_cast((i % 27) + 1), + static_cast(((i + 1) % 27) + 1), + static_cast(((i + 2) % 27) + 1), + static_cast(((i + 3) % 27) + 1)); + + // Only fund and buy; we expect all to succeed until max + increaseEnergy(user, ticketPrice * 2); + const QTF::BuyTicket_output out = ctl.buyTicket(user, ticketPrice, nums); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + } + + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), QTF_MAX_NUMBER_OF_PLAYERS); + + // Try one more - should fail + const id extraUser = id::randomValue(); + increaseEnergy(extraUser, ticketPrice * 2); + const uint64 balBefore = getBalance(extraUser); + QTFRandomValues nums = ctl.makeValidNumbers(1, 2, 3, 4); + + const QTF::BuyTicket_output out = ctl.buyTicket(extraUser, ticketPrice, nums); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::MAX_PLAYERS_REACHED)); + EXPECT_EQ(getBalance(extraUser), balBefore); // Refunded +} + +TEST(ContractQThirtyFour, BuyTicket_SamePlayerMultipleTickets_Allowed) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 10); + + // Same player buys multiple tickets with different numbers + QTFRandomValues nums1 = ctl.makeValidNumbers(1, 2, 3, 4); + QTFRandomValues nums2 = ctl.makeValidNumbers(5, 6, 7, 8); + QTFRandomValues nums3 = ctl.makeValidNumbers(9, 10, 11, 12); + + EXPECT_EQ(ctl.buyTicket(user, ticketPrice, nums1).returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.buyTicket(user, ticketPrice, nums2).returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.buyTicket(user, ticketPrice, nums3).returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 3u); +} + +// ============================================================================ +// CONFIGURATION CHANGE TESTS +// ============================================================================ + +TEST(ContractQThirtyFour, SetPrice_AccessControl) +{ + ContractTestingQTF ctl; + + const uint64 oldPrice = ctl.state()->getTicketPriceInternal(); + const uint64 newPrice = oldPrice * 2; + + // Random user should be denied + const id randomUser = id::randomValue(); + increaseEnergy(randomUser, 1); + const QTF::SetPrice_output outDenied = ctl.setPrice(randomUser, newPrice); + EXPECT_EQ(outDenied.returnCode, static_cast(QTF::EReturnCode::ACCESS_DENIED)); + + // Price unchanged + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); +} + +TEST(ContractQThirtyFour, SetPrice_ZeroNotAllowed) +{ + ContractTestingQTF ctl; + increaseEnergy(QTF_DEV_ADDRESS, 1); + + const uint64 oldPrice = ctl.state()->getTicketPriceInternal(); + const QTF::SetPrice_output outInvalid = ctl.setPrice(QTF_DEV_ADDRESS, 0); + EXPECT_EQ(outInvalid.returnCode, static_cast(QTF::EReturnCode::INVALID_TICKET_PRICE)); + + // Price unchanged + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); +} + +TEST(ContractQThirtyFour, SetPrice_AppliesAfterEndEpoch) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + increaseEnergy(QTF_DEV_ADDRESS, 1); + + const uint64 oldPrice = ctl.state()->getTicketPriceInternal(); + const uint64 newPrice = oldPrice * 3; + + const QTF::SetPrice_output outOk = ctl.setPrice(QTF_DEV_ADDRESS, newPrice); + EXPECT_EQ(outOk.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + + // Queued in NextEpochData + EXPECT_EQ(ctl.getNextEpochData().nextEpochData.newTicketPrice, newPrice); + + // Old price still active + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); + + // Apply after END_EPOCH + ctl.endEpoch(); + ctl.beginEpoch(); + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, newPrice); + + // NextEpochData cleared + EXPECT_EQ(ctl.getNextEpochData().nextEpochData.newTicketPrice, 0u); +} + +TEST(ContractQThirtyFour, SetSchedule_AccessControl) +{ + ContractTestingQTF ctl; + + const id randomUser = id::randomValue(); + increaseEnergy(randomUser, 1); + const QTF::SetSchedule_output outDenied = ctl.setSchedule(randomUser, QTF_ANY_DAY_SCHEDULE); + EXPECT_EQ(outDenied.returnCode, static_cast(QTF::EReturnCode::ACCESS_DENIED)); +} + +TEST(ContractQThirtyFour, SetSchedule_ZeroNotAllowed) +{ + ContractTestingQTF ctl; + increaseEnergy(QTF_DEV_ADDRESS, 1); + + const QTF::SetSchedule_output outInvalid = ctl.setSchedule(QTF_DEV_ADDRESS, 0); + EXPECT_EQ(outInvalid.returnCode, static_cast(QTF::EReturnCode::INVALID_VALUE)); +} + +TEST(ContractQThirtyFour, SetSchedule_AppliesAfterEndEpoch) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + increaseEnergy(QTF_DEV_ADDRESS, 1); + + const uint8 newSchedule = 0x7F; // All days + + const QTF::SetSchedule_output outOk = ctl.setSchedule(QTF_DEV_ADDRESS, newSchedule); + EXPECT_EQ(outOk.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + + // Queued + EXPECT_EQ(ctl.getNextEpochData().nextEpochData.newSchedule, newSchedule); + + // Apply + ctl.endEpoch(); + ctl.beginEpoch(); + EXPECT_EQ(ctl.getSchedule().schedule, newSchedule); +} + +TEST(ContractQThirtyFour, SetTargetJackpot_AccessControl) +{ + ContractTestingQTF ctl; + + const id randomUser = id::randomValue(); + increaseEnergy(randomUser, 1); + const QTF::SetTargetJackpot_output outDenied = ctl.setTargetJackpot(randomUser, 2000000000ULL); + EXPECT_EQ(outDenied.returnCode, static_cast(QTF::EReturnCode::ACCESS_DENIED)); +} + +TEST(ContractQThirtyFour, SetTargetJackpot_ZeroNotAllowed) +{ + ContractTestingQTF ctl; + increaseEnergy(QTF_DEV_ADDRESS, 1); + + const QTF::SetTargetJackpot_output outInvalid = ctl.setTargetJackpot(QTF_DEV_ADDRESS, 0); + EXPECT_EQ(outInvalid.returnCode, static_cast(QTF::EReturnCode::INVALID_VALUE)); +} + +TEST(ContractQThirtyFour, SetTargetJackpot_AppliesAfterEndEpoch) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + increaseEnergy(QTF_DEV_ADDRESS, 1); + + const uint64 newTarget = 5000000000ULL; + + const QTF::SetTargetJackpot_output outOk = ctl.setTargetJackpot(QTF_DEV_ADDRESS, newTarget); + EXPECT_EQ(outOk.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + + // Queued + EXPECT_EQ(ctl.getNextEpochData().nextEpochData.newTargetJackpot, newTarget); + + // Apply + ctl.endEpoch(); + ctl.beginEpoch(); + EXPECT_EQ(ctl.state()->getTargetJackpotInternal(), newTarget); +} + +TEST(ContractQThirtyFour, SetDrawHour_AccessControl) +{ + ContractTestingQTF ctl; + + const id randomUser = id::randomValue(); + increaseEnergy(randomUser, 1); + const QTF::SetDrawHour_output outDenied = ctl.setDrawHour(randomUser, 15); + EXPECT_EQ(outDenied.returnCode, static_cast(QTF::EReturnCode::ACCESS_DENIED)); +} + +TEST(ContractQThirtyFour, SetDrawHour_InvalidValues) +{ + ContractTestingQTF ctl; + increaseEnergy(QTF_DEV_ADDRESS, 2); + + // 0 is invalid + const QTF::SetDrawHour_output out0 = ctl.setDrawHour(QTF_DEV_ADDRESS, 0); + EXPECT_EQ(out0.returnCode, static_cast(QTF::EReturnCode::INVALID_VALUE)); + + // 24+ is invalid + const QTF::SetDrawHour_output out24 = ctl.setDrawHour(QTF_DEV_ADDRESS, 24); + EXPECT_EQ(out24.returnCode, static_cast(QTF::EReturnCode::INVALID_VALUE)); +} + +TEST(ContractQThirtyFour, SetDrawHour_AppliesAfterEndEpoch) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + increaseEnergy(QTF_DEV_ADDRESS, 1); + + const uint8 newHour = 18; + + const QTF::SetDrawHour_output outOk = ctl.setDrawHour(QTF_DEV_ADDRESS, newHour); + EXPECT_EQ(outOk.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + + // Queued + EXPECT_EQ(ctl.getNextEpochData().nextEpochData.newDrawHour, newHour); + + // Apply + ctl.endEpoch(); + ctl.beginEpoch(); + EXPECT_EQ(ctl.getDrawHour().drawHour, newHour); +} + +// ============================================================================ +// STATE AND POOLS TESTS +// ============================================================================ + +TEST(ContractQThirtyFour, GetState_InitiallyNotSelling) +{ + ContractTestingQTF ctl; + // Before valid time initialization, state should be NONE (not selling) + EXPECT_EQ(ctl.getStateInfo().currentState, static_cast(QTF::EState::STATE_NONE)); +} + +TEST(ContractQThirtyFour, GetState_SellingAfterValidEpochStart) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + EXPECT_EQ(ctl.getStateInfo().currentState, static_cast(QTF::EState::STATE_SELLING)); +} + +TEST(ContractQThirtyFour, GetPools_InitialValues) +{ + ContractTestingQTF ctl; + + const QTF::GetPools_output pools = ctl.getPools(); + EXPECT_EQ(pools.pools.jackpot, 0u); + EXPECT_EQ(pools.pools.targetJackpot, 1000000000ULL); + EXPECT_EQ(pools.pools.frActive, 0u); + EXPECT_EQ((uint64)pools.pools.roundsSinceK4, 0u); +} + +// ============================================================================ +// SETTLEMENT AND PAYOUT TESTS +// ============================================================================ + +TEST(ContractQThirtyFour, Settlement_NoPlayers_NoChanges) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + ctl.beginEpochWithValidTime(); + + const uint64 jackpotBefore = ctl.state()->getJackpot(); + const QTF::GetWinnerData_output winnersBefore = ctl.getWinnerData(); + + ctl.advanceOneDayAndDraw(); + + // No changes when no players + EXPECT_EQ(ctl.state()->getJackpot(), jackpotBefore); + const QTF::GetWinnerData_output winnersAfter = ctl.getWinnerData(); + EXPECT_EQ(winnersAfter.winnerData.winnerCounter, winnersBefore.winnerData.winnerCounter); +} + +TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const QTF::GetFees_output fees = ctl.getFees(); + + // Add 10 players + for (int i = 0; i < 10; ++i) + { + const id user = id::randomValue(); + QTFRandomValues nums = ctl.makeValidNumbers( + static_cast((i % 26) + 1), + static_cast((i % 26) + 2), + static_cast((i % 26) + 3), + static_cast((i % 26) + 4)); + ctl.fundAndBuyTicket(user, ticketPrice, nums); + } + + const uint64 totalRevenue = ticketPrice * 10; + const uint64 devBalBefore = getBalance(QTF_DEV_ADDRESS); + const uint64 contractBalBefore = getBalance(ctl.qtfSelf()); + + EXPECT_EQ(contractBalBefore, totalRevenue); + + ctl.advanceOneDayAndDraw(); + + // Check dev received their fee (10% of revenue) + const uint64 expectedDevFee = (totalRevenue * fees.teamFeePercent) / 100; + // Note: May be reduced by FR redirects if FR is active + EXPECT_GE(getBalance(QTF_DEV_ADDRESS), devBalBefore); + + // Players cleared + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, Settlement_JackpotGrowsFromOverflow) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const uint64 jackpotBefore = ctl.state()->getJackpot(); + + // Add players + for (int i = 0; i < 20; ++i) + { + const id user = id::randomValue(); + QTFRandomValues nums = ctl.makeValidNumbers( + static_cast((i % 25) + 1), + static_cast((i % 25) + 2), + static_cast((i % 25) + 3), + static_cast((i % 25) + 4)); + ctl.fundAndBuyTicket(user, ticketPrice, nums); + } + + ctl.advanceOneDayAndDraw(); + + // Jackpot should grow from overflow + // (32% of winners block goes to overflow, then split 50/50 in baseline mode) + const uint64 jackpotAfter = ctl.state()->getJackpot(); + EXPECT_GE(jackpotAfter, jackpotBefore); +} + +TEST(ContractQThirtyFour, Settlement_RoundsSinceK4_Increments) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + // Run several rounds without k=4 win + for (int round = 0; round < 3; ++round) + { + ctl.beginEpochWithValidTime(); + + for (int i = 0; i < 5; ++i) + { + const id user = id::randomValue(); + QTFRandomValues nums = ctl.makeValidNumbers( + static_cast((i * round % 25) + 1), + static_cast((i * round % 25) + 2), + static_cast((i * round % 25) + 3), + static_cast((i * round % 25) + 4)); + ctl.fundAndBuyTicket(user, ticketPrice, nums); + } + + const uint16 roundsBefore = ctl.state()->getFrRoundsSinceK4(); + ctl.advanceOneDayAndDraw(); + + // Rounds counter should increment (unless k=4 win occurred, which is very unlikely) + // Since we can't control the winning numbers, we just check it's at least what it was + EXPECT_GE((uint64)ctl.state()->getFrRoundsSinceK4(), (uint64)roundsBefore); + } +} + +// ============================================================================ +// FAST-RECOVERY (FR) TESTS +// ============================================================================ + +TEST(ContractQThirtyFour, FR_Activation_WhenBelowTarget) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + + // Set jackpot below target to trigger FR + ctl.state()->setJackpot(100000000ULL); // 100M + ctl.state()->setTargetJackpotInternal(1000000000ULL); // 1B target + ctl.state()->setFrRoundsSinceK4(5); // Within post-k4 window + + EXPECT_EQ(ctl.state()->getFrActive(), false); + + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + // Add players and settle + for (int i = 0; i < 10; ++i) + { + const id user = id::randomValue(); + QTFRandomValues nums = ctl.makeValidNumbers( + static_cast((i % 25) + 1), + static_cast((i % 25) + 2), + static_cast((i % 25) + 3), + static_cast((i % 25) + 4)); + ctl.fundAndBuyTicket(user, ticketPrice, nums); + } + + ctl.advanceOneDayAndDraw(); + + // FR should be active since jackpot < target and within window + EXPECT_EQ(ctl.state()->getFrActive(), true); +} + +TEST(ContractQThirtyFour, FR_Deactivation_AfterHysteresis) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + + // Set jackpot at target + ctl.state()->setJackpot(1000000000ULL); + ctl.state()->setTargetJackpotInternal(1000000000ULL); + ctl.state()->setFrActive(true); + ctl.state()->setFrRoundsAtOrAboveTarget(0); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + // Run 3 rounds at or above target (hysteresis requirement) + for (int round = 0; round < 3; ++round) + { + ctl.beginEpochWithValidTime(); + + for (int i = 0; i < 5; ++i) + { + const id user = id::randomValue(); + QTFRandomValues nums = ctl.makeValidNumbers( + static_cast((i + round) % 25 + 1), + static_cast((i + round) % 25 + 2), + static_cast((i + round) % 25 + 3), + static_cast((i + round) % 25 + 4)); + ctl.fundAndBuyTicket(user, ticketPrice, nums); + } + + // Keep jackpot at target (add back what might be paid out) + ctl.state()->setJackpot(1000000000ULL); + ctl.advanceOneDayAndDraw(); + } + + // After 3 rounds at target, FR should deactivate + EXPECT_GE((uint64)ctl.state()->getFrRoundsAtOrAboveTarget(), 3u); + EXPECT_EQ(ctl.state()->getFrActive(), false); +} + +TEST(ContractQThirtyFour, FR_OverflowBias_95PercentToJackpot) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + + // Activate FR + ctl.state()->setJackpot(100000000ULL); // Below target + ctl.state()->setTargetJackpotInternal(1000000000ULL); + ctl.state()->setFrActive(true); + ctl.state()->setFrRoundsSinceK4(5); + + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const uint64 jackpotBefore = ctl.state()->getJackpot(); + + // Add many players to generate significant overflow + for (int i = 0; i < 50; ++i) + { + const id user = id::randomValue(); + QTFRandomValues nums = ctl.makeValidNumbers( + static_cast((i % 25) + 1), + static_cast((i % 25) + 2), + static_cast((i % 25) + 3), + static_cast((i % 25) + 4)); + ctl.fundAndBuyTicket(user, ticketPrice, nums); + } + + ctl.advanceOneDayAndDraw(); + + // In FR mode, 95% of overflow goes to jackpot (vs 50% in baseline) + // Jackpot should grow significantly + EXPECT_GT(ctl.state()->getJackpot(), jackpotBefore); +} + +// ============================================================================ +// WINNER COUNTING AND TIER TESTS +// ============================================================================ + +TEST(ContractQThirtyFour, WinnerData_RecordsWinners) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + // Add players + std::vector players; + for (int i = 0; i < 20; ++i) + { + const id user = id::randomValue(); + players.push_back(user); + QTFRandomValues nums = ctl.makeValidNumbers( + static_cast((i % 27) + 1), + static_cast((i % 27) + 2), + static_cast((i % 27) + 3), + static_cast((i % 27) + 4)); + ctl.fundAndBuyTicket(user, ticketPrice, nums); + } + + const uint64 winnerCountBefore = ctl.getWinnerData().winnerData.winnerCounter; + ctl.advanceOneDayAndDraw(); + + // Winner data should record any winners + const QTF::GetWinnerData_output winnerData = ctl.getWinnerData(); + + // At minimum, winning values should be set + bool hasWinningValues = false; + for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) + { + if (winnerData.winnerData.winnerValues.get(i) > 0) + { + hasWinningValues = true; + break; + } + } + EXPECT_TRUE(hasWinningValues); + + // All winning values should be in valid range [1..30] + for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) + { + const uint8 val = winnerData.winnerData.winnerValues.get(i); + EXPECT_GE(val, 1u); + EXPECT_LE(val, 30u); + } +} + +TEST(ContractQThirtyFour, WinnerData_UniqueWinningNumbers) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + // Add some players + for (int i = 0; i < 5; ++i) + { + const id user = id::randomValue(); + QTFRandomValues nums = ctl.makeValidNumbers( + static_cast(i + 1), + static_cast(i + 5), + static_cast(i + 10), + static_cast(i + 15)); + ctl.fundAndBuyTicket(user, ticketPrice, nums); + } + + ctl.advanceOneDayAndDraw(); + + // Check winning numbers are unique + const QTF::GetWinnerData_output winnerData = ctl.getWinnerData(); + std::vector winningNums; + for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) + { + winningNums.push_back(winnerData.winnerData.winnerValues.get(i)); + } + + // Sort and check for duplicates + std::sort(winningNums.begin(), winningNums.end()); + for (size_t i = 1; i < winningNums.size(); ++i) + { + EXPECT_NE(winningNums[i], winningNums[i - 1]) << "Duplicate winning number found: " << (int)winningNums[i]; + } +} + +// ============================================================================ +// RESERVE INTEGRATION TESTS +// ============================================================================ + +TEST(ContractQThirtyFour, ReserveTopUp_WhenPoolInsufficient) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + + // Fund QRP with substantial reserve + ctl.fundQRP(1000000000ULL); // 1B in reserve + + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const uint64 qrpBalBefore = getBalance(ctl.qrpSelf()); + + // Add many players to potentially trigger floor guarantees + for (int i = 0; i < 100; ++i) + { + const id user = id::randomValue(); + QTFRandomValues nums = ctl.makeValidNumbers( + static_cast((i % 27) + 1), + static_cast((i % 27) + 2), + static_cast((i % 27) + 3), + static_cast((i % 27) + 4)); + ctl.fundAndBuyTicket(user, ticketPrice, nums); + } + + ctl.advanceOneDayAndDraw(); + + // QRP may have been used for top-ups (or reserve contributions) + // The balance change depends on winners and floors + const uint64 qrpBalAfter = getBalance(ctl.qrpSelf()); + + // Either QRP was tapped for top-up (decreases) or received overflow (increases) + // We just verify the system didn't crash and values are reasonable + EXPECT_GE(qrpBalAfter, 0u); +} + +// ============================================================================ +// EDGE CASE TESTS +// ============================================================================ + +TEST(ContractQThirtyFour, EdgeCase_AllNumbersBoundary) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + + // Test boundary numbers: 1, 2, 29, 30 + QTFRandomValues boundaryNums = ctl.makeValidNumbers(1, 2, 29, 30); + ctl.fundAndBuyTicket(user, ticketPrice, boundaryNums); + + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); +} + +TEST(ContractQThirtyFour, EdgeCase_ConsecutiveNumbers) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + + // Test consecutive numbers + QTFRandomValues consecutiveNums = ctl.makeValidNumbers(15, 16, 17, 18); + ctl.fundAndBuyTicket(user, ticketPrice, consecutiveNums); + + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); +} + +TEST(ContractQThirtyFour, EdgeCase_HighestNumbers) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + + // Test highest valid numbers + QTFRandomValues highNums = ctl.makeValidNumbers(27, 28, 29, 30); + ctl.fundAndBuyTicket(user, ticketPrice, highNums); + + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); +} + +TEST(ContractQThirtyFour, EdgeCase_LowestNumbers) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + + // Test lowest valid numbers + QTFRandomValues lowNums = ctl.makeValidNumbers(1, 2, 3, 4); + ctl.fundAndBuyTicket(user, ticketPrice, lowNums); + + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); +} + +// ============================================================================ +// MULTIPLE ROUNDS TESTS +// ============================================================================ + +TEST(ContractQThirtyFour, MultipleRounds_JackpotAccumulates) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + uint64 prevJackpot = 0; + + // Run multiple rounds + for (int round = 0; round < 5; ++round) + { + ctl.beginEpochWithValidTime(); + + for (int i = 0; i < 10; ++i) + { + const id user = id::randomValue(); + QTFRandomValues nums = ctl.makeValidNumbers( + static_cast((i + round) % 27 + 1), + static_cast((i + round + 1) % 27 + 1), + static_cast((i + round + 2) % 27 + 1), + static_cast((i + round + 3) % 27 + 1)); + ctl.fundAndBuyTicket(user, ticketPrice, nums); + } + + ctl.advanceOneDayAndDraw(); + + // Jackpot should generally increase (unless k=4 win depletes it) + const uint64 currentJackpot = ctl.state()->getJackpot(); + // We just check it's a valid value + EXPECT_GE(currentJackpot, 0u); + + // Track for next iteration + prevJackpot = currentJackpot; + } +} + +TEST(ContractQThirtyFour, MultipleRounds_StateResetsCorrectly) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + for (int round = 0; round < 3; ++round) + { + ctl.beginEpochWithValidTime(); + + // Add different number of players each round + const int playersThisRound = 5 + round * 3; + for (int i = 0; i < playersThisRound; ++i) + { + const id user = id::randomValue(); + QTFRandomValues nums = ctl.makeValidNumbers( + static_cast((i + round) % 27 + 1), + static_cast((i + round + 5) % 27 + 1), + static_cast((i + round + 10) % 27 + 1), + static_cast((i + round + 15) % 27 + 1)); + ctl.fundAndBuyTicket(user, ticketPrice, nums); + } + + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), static_cast(playersThisRound)); + + ctl.advanceOneDayAndDraw(); + + // Players should be cleared after each round + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); + } +} + +// ============================================================================ +// POST_INCOMING_TRANSFER TEST +// ============================================================================ + +TEST(ContractQThirtyFour, PostIncomingTransfer_StandardTransaction_Refunded) +{ + ContractTestingQTF ctl; + constexpr uint64 transferAmount = 123456789; + + const id sender = id::randomValue(); + increaseEnergy(sender, transferAmount); + EXPECT_EQ(getBalance(sender), transferAmount); + + const id contractAddress = ctl.qtfSelf(); + EXPECT_EQ(getBalance(contractAddress), 0); + + // Standard transaction should be refunded + notifyContractOfIncomingTransfer(sender, contractAddress, transferAmount, QPI::TransferType::standardTransaction); + + // Amount should be refunded to sender + EXPECT_EQ(getBalance(sender), transferAmount); + EXPECT_EQ(getBalance(contractAddress), 0); +} + +// ============================================================================ +// SCHEDULE AND TIME TESTS +// ============================================================================ + +TEST(ContractQThirtyFour, Schedule_DrawOnlyOnScheduledDays) +{ + ContractTestingQTF ctl; + + // Set schedule to Wednesday only (default) + const uint8 wednesdayOnly = static_cast(1 << WEDNESDAY); + ctl.forceSchedule(wednesdayOnly); + + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + // Add players + for (int i = 0; i < 5; ++i) + { + const id user = id::randomValue(); + QTFRandomValues nums = ctl.makeValidNumbers( + static_cast(i + 1), + static_cast(i + 5), + static_cast(i + 10), + static_cast(i + 15)); + ctl.fundAndBuyTicket(user, ticketPrice, nums); + } + + const uint64 playersBefore = ctl.state()->getNumberOfPlayers(); + EXPECT_EQ(playersBefore, 5u); + + // Tuesday 2025-01-14 is not scheduled - should NOT trigger draw + ctl.setDateTime(2025, 1, 14, 12); + ctl.forceBeginTick(); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), playersBefore); // Unchanged + + // Wednesday 2025-01-15 IS scheduled - should trigger draw + ctl.setDateTime(2025, 1, 15, 12); + ctl.forceBeginTick(); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); // Cleared after draw +} + +TEST(ContractQThirtyFour, DrawHour_NoDrawBeforeScheduledHour) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + // Add players + for (int i = 0; i < 5; ++i) + { + const id user = id::randomValue(); + QTFRandomValues nums = ctl.makeValidNumbers( + static_cast(i + 1), + static_cast(i + 5), + static_cast(i + 10), + static_cast(i + 15)); + ctl.fundAndBuyTicket(user, ticketPrice, nums); + } + + const uint8 drawHour = ctl.state()->getDrawHourInternal(); + const uint64 playersBefore = ctl.state()->getNumberOfPlayers(); + + // Before draw hour - should NOT trigger draw + ctl.setDateTime(2025, 1, 15, drawHour - 1); + ctl.forceBeginTick(); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), playersBefore); + + // At or after draw hour - should trigger draw + ctl.setDateTime(2025, 1, 15, drawHour); + ctl.forceBeginTick(); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +// ============================================================================ +// PROBABILITY AND COMBINATORICS VERIFICATION +// ============================================================================ + +TEST(ContractQThirtyFour, Combinatorics_P4Denominator) +{ + // Verify the P4 denominator constant matches combinatorics + // C(30,4) = 30! / (4! * 26!) = 27405 + constexpr uint64 numerator = 30 * 29 * 28 * 27; + constexpr uint64 denominator = 4 * 3 * 2 * 1; + constexpr uint64 expected = numerator / denominator; + + EXPECT_EQ(expected, QTF_P4_DENOMINATOR); + EXPECT_EQ(QTF_P4_DENOMINATOR, 27405u); +} + +// ============================================================================ +// FEE CALCULATION VERIFICATION +// ============================================================================ + +TEST(ContractQThirtyFour, FeeCalculation_TotalEquals100Percent) +{ + ContractTestingQTF ctl; + const QTF::GetFees_output fees = ctl.getFees(); + + const uint32 total = fees.teamFeePercent + fees.distributionFeePercent + + fees.winnerFeePercent + fees.burnPercent; + + EXPECT_EQ(total, 100u); +} \ No newline at end of file From bf67be4de59a96515726904d31f0e2675b86829a Mon Sep 17 00:00:00 2001 From: N-010 Date: Fri, 12 Dec 2025 00:59:20 +0300 Subject: [PATCH 11/77] Updates testes --- src/contracts/QThirtyFour.h | 54 +++-- test/contract_qrp.cpp | 23 +- test/contract_qtf.cpp | 438 +++++++++++++++++++----------------- 3 files changed, 275 insertions(+), 240 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 54d09137a..81c7141ce 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -56,6 +56,7 @@ constexpr uint8 QTF_DEFAULT_WINNERS_PERCENT = 68; // Maximum attempts to generate unique random value before fallback constexpr uint8 QTF_MAX_RANDOM_GENERATION_ATTEMPTS = 100; +constexpr uint64 QTF_DEFAULT_TARGET_JACKPOT = 1000000000ULL; // 1 billion QU (1B) constexpr uint8 QTF_DEFAULT_SCHEDULE = 1u << WEDNESDAY; constexpr uint8 QTF_DEFAULT_DRAW_HOUR = 11; // 11:00 UTC constexpr uint32 QTF_DEFAULT_INIT_TIME = 22u << 9 | 4u << 5 | 13u; // RL_DEFAULT_INIT_TIME @@ -67,7 +68,6 @@ const uint64 QTF_RANDOM_LOTTERY_ASSET_NAME = *reinterpret_cast("R const id QTF_RESERVE_POOL_CONTRACT_ID = id(QRP_CONTRACT_INDEX, 0, 0, 0); using QTFRandomValues = Array; -using QFTWinnerPlayers = Array; struct QTF2 { @@ -513,6 +513,31 @@ struct QTF : public ContractBase RL::GetFees_output feesOutput; }; + // EstimateFRJackpotGrowth: Calculate minimum expected jackpot growth in FR mode + // Used for testing to verify 95% overflow bias is working correctly + struct EstimateFRJackpotGrowth_input + { + uint64 revenue; // Total revenue (ticketPrice * numPlayers) + uint64 winnersPercent; // Winners block percentage (typically 68) + }; + struct EstimateFRJackpotGrowth_output + { + uint64 minJackpotGrowth; // Minimum expected jackpot growth + uint64 winnersRake; // 5% of winners block + uint64 overflowToJackpot; // 95% of overflow + uint64 devRedirect; // 1% of revenue + uint64 distRedirect; // 1% of revenue + }; + struct EstimateFRJackpotGrowth_locals + { + uint64 winnersBlock; + uint64 winnersBlockAfterRake; + uint64 k3Pool; + uint64 k2Pool; + uint64 winnersOverflow; + uint64 reserveAdd; + }; + struct SettlementLocals { QTFRandomValues winningValues; @@ -618,16 +643,11 @@ struct QTF : public ContractBase state.teamAddress = QTF_ADDRESS_DEV_TEAM; state.ownerAddress = state.teamAddress; state.ticketPrice = QTF_TICKET_PRICE; - state.targetJackpot = 1000000000ULL; + state.targetJackpot = QTF_DEFAULT_TARGET_JACKPOT; state.overflowAlphaBP = QTF_BASELINE_OVERFLOW_ALPHA_BP; state.schedule = QTF_DEFAULT_SCHEDULE; state.drawHour = QTF_DEFAULT_DRAW_HOUR; state.lastDrawDateStamp = QTF_DEFAULT_INIT_TIME; - state.frActive = false; - state.frRoundsSinceK4 = 0; - state.frRoundsAtOrAboveTarget = 0; - state.numberOfPlayers = 0; - state.jackpot = 0; state.currentState = STATE_NONE; } @@ -916,11 +936,7 @@ struct QTF : public ContractBase } protected: - static void clearEpochState(QTF& state) - { - clearPlayerData(state); - clearWinnerData(state); - } + static void clearEpochState(QTF& state) { clearPlayerData(state); } static void applyNextEpochData(QTF& state) { @@ -969,8 +985,6 @@ struct QTF : public ContractBase return static_cast(v & 0x3Fu); } - static void clearWinnerData(QTF& state) { setMemory(state.lastWinnerData, 0); } - static void clearPlayerData(QTF& state) { if (state.numberOfPlayers > 0) @@ -982,10 +996,14 @@ struct QTF : public ContractBase static void fillWinnerData(QTF& state, const PlayerData& playerData, const QTFRandomValues& winnerValues, const uint16& epoch) { - if (state.lastWinnerData.winnerCounter < state.lastWinnerData.winners.capacity()) + if (!isZero(playerData.player)) { - state.lastWinnerData.winners.set(state.lastWinnerData.winnerCounter++, playerData); + if (state.lastWinnerData.winnerCounter < state.lastWinnerData.winners.capacity()) + { + state.lastWinnerData.winners.set(state.lastWinnerData.winnerCounter++, playerData); + } } + state.lastWinnerData.winnerValues = winnerValues; state.lastWinnerData.epoch = epoch; } @@ -1256,6 +1274,10 @@ struct QTF : public ContractBase ++locals.i; } + // Always save winning values and epoch, even if no winners + state.lastWinnerData.winnerValues = locals.winningValues; + state.lastWinnerData.epoch = locals.currentEpoch; + // Post-jackpot (k4) logic: reset counters and reseed if jackpot was hit if (locals.countK4 > 0 && state.jackpot > 0) { diff --git a/test/contract_qrp.cpp b/test/contract_qrp.cpp index 56e7ece89..355d7b36e 100644 --- a/test/contract_qrp.cpp +++ b/test/contract_qrp.cpp @@ -76,14 +76,14 @@ class ContractTestingQRP : protected ContractTesting TEST(ContractQReservePool, InitializesOwnerAndDefaultSCList) { ContractTestingQRP qrp; - auto* state = qrp.state(); + QRPChecker* state = qrp.state(); EXPECT_EQ(state->team(), QRP_OWNER_TEAM_ADDRESS); EXPECT_EQ(state->owner(), QRP_OWNER_TEAM_ADDRESS); EXPECT_TRUE(state->hasAvailableSC(QRP_DEFAULT_SC_ID)); EXPECT_EQ(state->availableCount(), 1u); - const auto available = qrp.getAvailableSCs(); + const QRP::GetAvailableSC_output available = qrp.getAvailableSCs(); bool foundDefault = false; for (uint64 i = 0; i < QRP_AVAILABLE_SC_NUM; ++i) { @@ -103,21 +103,20 @@ TEST(ContractQReservePool, GetReserveEnforcesAuthorizationAndBalance) increaseEnergy(unauthorized, 0); increaseEnergy(QRP_DEFAULT_SC_ID, 0); - - auto denied = qrp.getReserve(unauthorized, 100); + QRP::GetReserve_output denied = qrp.getReserve(unauthorized, 100); EXPECT_EQ(denied.returnCode, QRPReturnCode::ACCESS_DENIED); EXPECT_EQ(denied.allocatedRevenue, 0ull); qrp.fundContract(1000); EXPECT_EQ(getBalance(QRP_CONTRACT_ID), 1000); - auto granted = qrp.getReserve(QRP_DEFAULT_SC_ID, 600); + QRP::GetReserve_output granted = qrp.getReserve(QRP_DEFAULT_SC_ID, 600); EXPECT_EQ(granted.returnCode, QRPReturnCode::SUCCESS); EXPECT_EQ(granted.allocatedRevenue, 600ull); EXPECT_EQ(getBalance(QRP_CONTRACT_ID), 400); EXPECT_EQ(getBalance(QRP_DEFAULT_SC_ID), 600); - auto insufficient = qrp.getReserve(QRP_DEFAULT_SC_ID, 500); + QRP::GetReserve_output insufficient = qrp.getReserve(QRP_DEFAULT_SC_ID, 500); EXPECT_EQ(insufficient.returnCode, QRPReturnCode::INSUFFICIENT_RESERVE); EXPECT_EQ(insufficient.allocatedRevenue, 0ull); EXPECT_EQ(getBalance(QRP_CONTRACT_ID), 400); @@ -127,7 +126,7 @@ TEST(ContractQReservePool, GetReserveEnforcesAuthorizationAndBalance) TEST(ContractQReservePool, OwnerAddsAndRemovesSmartContracts) { ContractTestingQRP qrp; - auto* state = qrp.state(); + QRPChecker* state = qrp.state(); const uint64 newScIndex = 77; const id newScId(newScIndex, 0, 0, 0); const id outsider(200, 0, 0, 0); @@ -135,15 +134,15 @@ TEST(ContractQReservePool, OwnerAddsAndRemovesSmartContracts) increaseEnergy(outsider, 0); increaseEnergy(state->owner(), 0); - auto deniedAdd = qrp.addAvailableSC(outsider, newScIndex); + QRP::AddAvailableSC_output deniedAdd = qrp.addAvailableSC(outsider, newScIndex); EXPECT_EQ(deniedAdd.returnCode, QRPReturnCode::ACCESS_DENIED); EXPECT_FALSE(state->hasAvailableSC(newScId)); - auto approvedAdd = qrp.addAvailableSC(state->owner(), newScIndex); + QRP::AddAvailableSC_output approvedAdd = qrp.addAvailableSC(state->owner(), newScIndex); EXPECT_EQ(approvedAdd.returnCode, QRPReturnCode::SUCCESS); EXPECT_TRUE(state->hasAvailableSC(newScId)); - auto available = qrp.getAvailableSCs(); + QRP::GetAvailableSC_output available = qrp.getAvailableSCs(); bool foundNew = false; for (uint64 i = 0; i < QRP_AVAILABLE_SC_NUM; ++i) { @@ -155,11 +154,11 @@ TEST(ContractQReservePool, OwnerAddsAndRemovesSmartContracts) } EXPECT_TRUE(foundNew); - auto deniedRemove = qrp.removeAvailableSC(outsider, newScIndex); + QRP::RemoveAvailableSC_output deniedRemove = qrp.removeAvailableSC(outsider, newScIndex); EXPECT_EQ(deniedRemove.returnCode, QRPReturnCode::ACCESS_DENIED); EXPECT_TRUE(state->hasAvailableSC(newScId)); - auto approvedRemove = qrp.removeAvailableSC(state->owner(), newScIndex); + QRP::RemoveAvailableSC_output approvedRemove = qrp.removeAvailableSC(state->owner(), newScIndex); EXPECT_EQ(approvedRemove.returnCode, QRPReturnCode::SUCCESS); EXPECT_FALSE(state->hasAvailableSC(newScId)); } diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp index 7d7d75db3..c1bcdd392 100644 --- a/test/contract_qtf.cpp +++ b/test/contract_qtf.cpp @@ -5,8 +5,8 @@ #define NO_UEFI #include "contract_testing.h" -#include #include +#include // Procedure indices (must match REGISTER_USER_FUNCTIONS_AND_PROCEDURES in QThirtyFour.h) constexpr uint16 QTF_PROCEDURE_BUY_TICKET = 1; @@ -38,15 +38,10 @@ class QTFChecker : public QTF uint64 getTicketPriceInternal() const { return ticketPrice; } uint64 getJackpot() const { return jackpot; } uint64 getTargetJackpotInternal() const { return targetJackpot; } - unsigned int getScheduleInternal() const { return schedule; } - unsigned int getDrawHourInternal() const { return drawHour; } - uint32 getLastDrawDateStamp() const { return lastDrawDateStamp; } + uint32 getDrawHourInternal() const { return drawHour; } bool getFrActive() const { return frActive; } - unsigned int getFrRoundsSinceK4() const { return frRoundsSinceK4; } - unsigned int getFrRoundsAtOrAboveTarget() const { return frRoundsAtOrAboveTarget; } - unsigned int getCurrentState() const { return currentState; } - const id& getTeamAddress() const { return teamAddress; } - const id& getOwnerAddress() const { return ownerAddress; } + uint32 getFrRoundsSinceK4() const { return frRoundsSinceK4; } + uint32 getFrRoundsAtOrAboveTarget() const { return frRoundsAtOrAboveTarget; } void setScheduleMask(uint8 newMask) { schedule = newMask; } void setJackpot(uint64 value) { jackpot = value; } @@ -56,7 +51,6 @@ class QTFChecker : public QTF void setFrRoundsAtOrAboveTarget(uint16 value) { frRoundsAtOrAboveTarget = value; } const PlayerData& getPlayer(uint64 index) const { return players.get(index); } - const WinnerData& getLastWinnerDataInternal() const { return lastWinnerData; } }; class ContractTestingQTF : protected ContractTesting @@ -216,14 +210,6 @@ class ContractTestingQTF : protected ContractTesting void endEpoch() { callSystemProcedure(QTF_CONTRACT_INDEX, END_EPOCH); } void beginTick() { callSystemProcedure(QTF_CONTRACT_INDEX, BEGIN_TICK); } - // Time helpers - void setCurrentHour(uint8 hour) - { - updateTime(); - utcTime.Hour = hour; - updateQpiTime(); - } - void setDateTime(uint16 year, uint8 month, uint8 day, uint8 hour) { updateTime(); @@ -249,16 +235,10 @@ class ContractTestingQTF : protected ContractTesting beginEpoch(); } - void beginEpochWithValidTime() - { - beginEpochWithDate(2025, 1, 20); - } + void beginEpochWithValidTime() { beginEpochWithDate(2025, 1, 20); } // Force schedule mask directly in state - void forceSchedule(uint8 scheduleMask) - { - state()->setScheduleMask(scheduleMask); - } + void forceSchedule(uint8 scheduleMask) { state()->setScheduleMask(scheduleMask); } // Advance to next day and trigger draw void advanceOneDayAndDraw() @@ -288,12 +268,6 @@ class ContractTestingQTF : protected ContractTesting const QTF::BuyTicket_output out = buyTicket(user, ticketPrice, numbers); EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); } - - // Fund QRP reserve pool - void fundQRP(uint64 amount) - { - increaseEnergy(qrpSelf(), amount); - } }; // ============================================================================ @@ -438,11 +412,8 @@ TEST(ContractQThirtyFour, BuyTicket_MultiplePlayers_Success) for (int i = 0; i < 10; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers( - static_cast(1 + i), - static_cast(11 + i), - static_cast(21), - static_cast(30)); + QTFRandomValues nums = + ctl.makeValidNumbers(static_cast(1 + i), static_cast(11 + i), static_cast(21), static_cast(30)); ctl.fundAndBuyTicket(user, ticketPrice, nums); } @@ -461,11 +432,8 @@ TEST(ContractQThirtyFour, BuyTicket_MaxPlayersReached_Fails) for (uint64 i = 0; i < QTF_MAX_NUMBER_OF_PLAYERS; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers( - static_cast((i % 27) + 1), - static_cast(((i + 1) % 27) + 1), - static_cast(((i + 2) % 27) + 1), - static_cast(((i + 3) % 27) + 1)); + QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i % 27) + 1), static_cast(((i + 1) % 27) + 1), + static_cast(((i + 2) % 27) + 1), static_cast(((i + 3) % 27) + 1)); // Only fund and buy; we expect all to succeed until max increaseEnergy(user, ticketPrice * 2); @@ -708,17 +676,6 @@ TEST(ContractQThirtyFour, GetState_SellingAfterValidEpochStart) EXPECT_EQ(ctl.getStateInfo().currentState, static_cast(QTF::EState::STATE_SELLING)); } -TEST(ContractQThirtyFour, GetPools_InitialValues) -{ - ContractTestingQTF ctl; - - const QTF::GetPools_output pools = ctl.getPools(); - EXPECT_EQ(pools.pools.jackpot, 0u); - EXPECT_EQ(pools.pools.targetJackpot, 1000000000ULL); - EXPECT_EQ(pools.pools.frActive, 0u); - EXPECT_EQ((uint64)pools.pools.roundsSinceK4, 0u); -} - // ============================================================================ // SETTLEMENT AND PAYOUT TESTS // ============================================================================ @@ -748,20 +705,23 @@ TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed) const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); const QTF::GetFees_output fees = ctl.getFees(); + constexpr uint64 numPlayers = 10; - // Add 10 players - for (int i = 0; i < 10; ++i) + ctl.state()->setJackpot(QTF_DEFAULT_TARGET_JACKPOT); + + // Verify FR is not active initially (baseline mode) + EXPECT_EQ(ctl.state()->getFrActive(), false); + + // Add players + for (uint64 i = 0; i < numPlayers; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers( - static_cast((i % 26) + 1), - static_cast((i % 26) + 2), - static_cast((i % 26) + 3), - static_cast((i % 26) + 4)); + QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i % 26) + 1), static_cast((i % 26) + 2), + static_cast((i % 26) + 3), static_cast((i % 26) + 4)); ctl.fundAndBuyTicket(user, ticketPrice, nums); } - const uint64 totalRevenue = ticketPrice * 10; + const uint64 totalRevenue = ticketPrice * numPlayers; const uint64 devBalBefore = getBalance(QTF_DEV_ADDRESS); const uint64 contractBalBefore = getBalance(ctl.qtfSelf()); @@ -769,10 +729,78 @@ TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed) ctl.advanceOneDayAndDraw(); - // Check dev received their fee (10% of revenue) + EXPECT_EQ(ctl.state()->getFrActive(), false); + + // In baseline mode (FR not active), dev receives full 10% of revenue + // No redirects are applied const uint64 expectedDevFee = (totalRevenue * fees.teamFeePercent) / 100; - // Note: May be reduced by FR redirects if FR is active - EXPECT_GE(getBalance(QTF_DEV_ADDRESS), devBalBefore); + EXPECT_EQ(getBalance(QTF_DEV_ADDRESS), devBalBefore + expectedDevFee) + << "In baseline mode, dev should receive full " << static_cast(fees.teamFeePercent) << "% of revenue"; + + // Players cleared + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed_FRMode) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + + // Activate FR mode + ctl.state()->setJackpot(100000000ULL); // Below target + ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); + ctl.state()->setFrActive(true); + ctl.state()->setFrRoundsSinceK4(5); + + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const QTF::GetFees_output fees = ctl.getFees(); + constexpr uint64 numPlayers = 10; + + // Verify FR is active + EXPECT_EQ(ctl.state()->getFrActive(), true); + + // Add players + for (uint64 i = 0; i < numPlayers; ++i) + { + const id user = id::randomValue(); + QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i % 26) + 1), static_cast((i % 26) + 2), + static_cast((i % 26) + 3), static_cast((i % 26) + 4)); + ctl.fundAndBuyTicket(user, ticketPrice, nums); + } + + const uint64 totalRevenue = ticketPrice * numPlayers; + const uint64 devBalBefore = getBalance(QTF_DEV_ADDRESS); + const uint64 contractBalBefore = getBalance(ctl.qtfSelf()); + const uint64 jackpotBefore = ctl.state()->getJackpot(); + + EXPECT_EQ(contractBalBefore, totalRevenue); + + ctl.advanceOneDayAndDraw(); + + // In FR mode, dev receives less than full 10% of revenue + // Base redirect: 1% of revenue (QTF_FR_DEV_REDIRECT_BP = 100 basis points) + // Possible extra redirect depending on deficit + const uint64 baseDevRedirect = (totalRevenue * QTF_FR_DEV_REDIRECT_BP) / 10000; + + // Full dev fee from revenue split (10%) + const uint64 fullDevFee = (totalRevenue * fees.teamFeePercent) / 100; + + // Actual dev payout = fullDevFee - redirects + // Expected: fullDevFee - at least baseDevRedirect + const uint64 maxExpectedDevPayout = fullDevFee - baseDevRedirect; + + const uint64 actualDevPayout = getBalance(QTF_DEV_ADDRESS) - devBalBefore; + + // Dev should receive less than full fee (due to redirects to jackpot) + EXPECT_LT(actualDevPayout, fullDevFee) << "In FR mode, dev payout should be reduced by redirects"; + + // Dev should receive at most fullDevFee - baseDevRedirect + EXPECT_LE(actualDevPayout, maxExpectedDevPayout) << "Dev payout should be reduced by at least base redirect (1%)"; + + // Jackpot should have grown (receives redirects) + EXPECT_GT(ctl.state()->getJackpot(), jackpotBefore) << "Jackpot should grow from dev/dist redirects in FR mode"; // Players cleared EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); @@ -786,25 +814,55 @@ TEST(ContractQThirtyFour, Settlement_JackpotGrowsFromOverflow) const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); const uint64 jackpotBefore = ctl.state()->getJackpot(); + constexpr uint64 numPlayers = 20; // Add players - for (int i = 0; i < 20; ++i) + for (uint64 i = 0; i < numPlayers; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers( - static_cast((i % 25) + 1), - static_cast((i % 25) + 2), - static_cast((i % 25) + 3), - static_cast((i % 25) + 4)); + QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i % 25) + 1), static_cast((i % 25) + 2), + static_cast((i % 25) + 3), static_cast((i % 25) + 4)); ctl.fundAndBuyTicket(user, ticketPrice, nums); } + // Calculate expected jackpot growth in baseline mode (FR not active) + const uint64 revenue = ticketPrice * numPlayers; + const QTF::GetFees_output fees = ctl.getFees(); + + // winnersBlock = revenue * winnerFeePercent / 100 (68%) + const uint64 winnersBlock = (revenue * fees.winnerFeePercent) / 100; + + // In baseline mode: no rake, standard tier split + // k3Pool = 40% of winnersBlock, k2Pool = 28% of winnersBlock + const uint64 k3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000; + const uint64 k2Pool = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000; + + // Remaining 32% becomes overflow + const uint64 winnersOverflow = winnersBlock - k3Pool - k2Pool; + + // In baseline mode: 50% of overflow goes to jackpot, 50% to reserve + const uint64 reserveAdd = (winnersOverflow * QTF_BASELINE_OVERFLOW_ALPHA_BP) / 10000; + const uint64 overflowToJackpot = winnersOverflow - reserveAdd; + + // Minimum expected jackpot growth (assuming no k2/k3 winners, all overflow goes to jackpot) + const uint64 minExpectedGrowth = overflowToJackpot; + ctl.advanceOneDayAndDraw(); - // Jackpot should grow from overflow - // (32% of winners block goes to overflow, then split 50/50 in baseline mode) + // Verify jackpot growth const uint64 jackpotAfter = ctl.state()->getJackpot(); - EXPECT_GE(jackpotAfter, jackpotBefore); + const uint64 actualGrowth = jackpotAfter - jackpotBefore; + + // In baseline mode, 50% of overflow goes to jackpot + // Allow 5% tolerance for rounding and potential winners + const uint64 tolerance = minExpectedGrowth / 20; // 5% + EXPECT_GE(actualGrowth + tolerance, minExpectedGrowth) + << "Actual growth: " << actualGrowth << ", Expected minimum: " << minExpectedGrowth << ", Overflow to jackpot (50%): " << overflowToJackpot; + + // Verify the 50% overflow split is working correctly + const uint64 expected50Percent = winnersOverflow / 2; + EXPECT_GE(overflowToJackpot, expected50Percent - 1) << "50% overflow split verification"; + EXPECT_LE(overflowToJackpot, winnersOverflow) << "Overflow to jackpot should not exceed total overflow"; } TEST(ContractQThirtyFour, Settlement_RoundsSinceK4_Increments) @@ -822,11 +880,8 @@ TEST(ContractQThirtyFour, Settlement_RoundsSinceK4_Increments) for (int i = 0; i < 5; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers( - static_cast((i * round % 25) + 1), - static_cast((i * round % 25) + 2), - static_cast((i * round % 25) + 3), - static_cast((i * round % 25) + 4)); + QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i * round % 25) + 1), static_cast((i * round % 25) + 2), + static_cast((i * round % 25) + 3), static_cast((i * round % 25) + 4)); ctl.fundAndBuyTicket(user, ticketPrice, nums); } @@ -849,9 +904,9 @@ TEST(ContractQThirtyFour, FR_Activation_WhenBelowTarget) ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); // Set jackpot below target to trigger FR - ctl.state()->setJackpot(100000000ULL); // 100M - ctl.state()->setTargetJackpotInternal(1000000000ULL); // 1B target - ctl.state()->setFrRoundsSinceK4(5); // Within post-k4 window + ctl.state()->setJackpot(100000000ULL); // 100M + ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); // 1B target + ctl.state()->setFrRoundsSinceK4(5); // Within post-k4 window EXPECT_EQ(ctl.state()->getFrActive(), false); @@ -863,11 +918,8 @@ TEST(ContractQThirtyFour, FR_Activation_WhenBelowTarget) for (int i = 0; i < 10; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers( - static_cast((i % 25) + 1), - static_cast((i % 25) + 2), - static_cast((i % 25) + 3), - static_cast((i % 25) + 4)); + QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i % 25) + 1), static_cast((i % 25) + 2), + static_cast((i % 25) + 3), static_cast((i % 25) + 4)); ctl.fundAndBuyTicket(user, ticketPrice, nums); } @@ -883,36 +935,33 @@ TEST(ContractQThirtyFour, FR_Deactivation_AfterHysteresis) ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); // Set jackpot at target - ctl.state()->setJackpot(1000000000ULL); - ctl.state()->setTargetJackpotInternal(1000000000ULL); + ctl.state()->setJackpot(QTF_DEFAULT_TARGET_JACKPOT); + ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); ctl.state()->setFrActive(true); ctl.state()->setFrRoundsAtOrAboveTarget(0); const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - // Run 3 rounds at or above target (hysteresis requirement) - for (int round = 0; round < 3; ++round) + // Run rounds at or above target (hysteresis requirement) + for (int round = 0; round < QTF_FR_HYSTERESIS_ROUNDS; ++round) { ctl.beginEpochWithValidTime(); for (int i = 0; i < 5; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers( - static_cast((i + round) % 25 + 1), - static_cast((i + round) % 25 + 2), - static_cast((i + round) % 25 + 3), - static_cast((i + round) % 25 + 4)); + QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i + round) % 25 + 1), static_cast((i + round) % 25 + 2), + static_cast((i + round) % 25 + 3), static_cast((i + round) % 25 + 4)); ctl.fundAndBuyTicket(user, ticketPrice, nums); } // Keep jackpot at target (add back what might be paid out) - ctl.state()->setJackpot(1000000000ULL); + ctl.state()->setJackpot(QTF_DEFAULT_TARGET_JACKPOT); ctl.advanceOneDayAndDraw(); } // After 3 rounds at target, FR should deactivate - EXPECT_GE((uint64)ctl.state()->getFrRoundsAtOrAboveTarget(), 3u); + EXPECT_GE((uint64)ctl.state()->getFrRoundsAtOrAboveTarget(), (uint64)QTF_FR_HYSTERESIS_ROUNDS); EXPECT_EQ(ctl.state()->getFrActive(), false); } @@ -922,8 +971,8 @@ TEST(ContractQThirtyFour, FR_OverflowBias_95PercentToJackpot) ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); // Activate FR - ctl.state()->setJackpot(100000000ULL); // Below target - ctl.state()->setTargetJackpotInternal(1000000000ULL); + ctl.state()->setJackpot(100000000ULL); // Below target + ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); ctl.state()->setFrActive(true); ctl.state()->setFrRoundsSinceK4(5); @@ -931,24 +980,63 @@ TEST(ContractQThirtyFour, FR_OverflowBias_95PercentToJackpot) const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); const uint64 jackpotBefore = ctl.state()->getJackpot(); + constexpr uint64 numPlayers = 50; // Add many players to generate significant overflow - for (int i = 0; i < 50; ++i) + for (uint64 i = 0; i < numPlayers; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers( - static_cast((i % 25) + 1), - static_cast((i % 25) + 2), - static_cast((i % 25) + 3), - static_cast((i % 25) + 4)); + QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i % 25) + 1), static_cast((i % 25) + 2), + static_cast((i % 25) + 3), static_cast((i % 25) + 4)); ctl.fundAndBuyTicket(user, ticketPrice, nums); } + // Calculate expected jackpot growth + const uint64 revenue = ticketPrice * numPlayers; + const QTF::GetFees_output fees = ctl.getFees(); + + // winnersBlock = revenue * winnerFeePercent / 100 (68%) + const uint64 winnersBlock = (revenue * fees.winnerFeePercent) / 100; + + // In FR mode: 5% rake from winnersBlock goes to jackpot + const uint64 winnersRake = (winnersBlock * QTF_FR_WINNERS_RAKE_BP) / 10000; + const uint64 winnersBlockAfterRake = winnersBlock - winnersRake; + + // k3Pool = 40% of winnersBlockAfterRake, k2Pool = 28% of winnersBlockAfterRake + // Remaining 32% becomes overflow + const uint64 k3Pool = (winnersBlockAfterRake * QTF_BASE_K3_SHARE_BP) / 10000; + const uint64 k2Pool = (winnersBlockAfterRake * QTF_BASE_K2_SHARE_BP) / 10000; + const uint64 winnersOverflow = winnersBlockAfterRake - k3Pool - k2Pool; + + // In FR mode: 95% of overflow goes to jackpot, 5% to reserve + const uint64 reserveAdd = (winnersOverflow * QTF_FR_ALPHA_BP) / 10000; + const uint64 overflowToJackpot = winnersOverflow - reserveAdd; + + // Dev and Dist redirects (base 1% each from revenue in FR mode) + const uint64 devRedirect = (revenue * QTF_FR_DEV_REDIRECT_BP) / 10000; + const uint64 distRedirect = (revenue * QTF_FR_DIST_REDIRECT_BP) / 10000; + + // Minimum expected jackpot growth (without extra redirects, assuming no k2/k3 winners) + // totalJackpotContribution = overflowToJackpot + winnersRake + devRedirect + distRedirect + const uint64 minExpectedGrowth = overflowToJackpot + winnersRake + devRedirect + distRedirect; + ctl.advanceOneDayAndDraw(); + // Verify that jackpot grew by at least the minimum expected amount + const uint64 actualGrowth = ctl.state()->getJackpot() - jackpotBefore; + // In FR mode, 95% of overflow goes to jackpot (vs 50% in baseline) - // Jackpot should grow significantly - EXPECT_GT(ctl.state()->getJackpot(), jackpotBefore); + // Allow 5% tolerance for rounding and potential winners + const uint64 tolerance = minExpectedGrowth / 20; // 5% + EXPECT_GE(actualGrowth + tolerance, minExpectedGrowth) + << "Actual growth: " << actualGrowth << ", Expected minimum: " << minExpectedGrowth << ", Overflow to jackpot (95%): " << overflowToJackpot + << ", Winners rake: " << winnersRake; + + // Verify the 95% overflow bias is working correctly + // overflowToJackpot should be ~95% of winnersOverflow + const uint64 expected95Percent = (winnersOverflow * 95) / 100; + EXPECT_GE(overflowToJackpot, expected95Percent - 1) << "95% overflow bias verification"; + EXPECT_LE(overflowToJackpot, winnersOverflow) << "Overflow to jackpot should not exceed total overflow"; } // ============================================================================ @@ -963,45 +1051,32 @@ TEST(ContractQThirtyFour, WinnerData_RecordsWinners) const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - // Add players + // Add players with diverse number combinations to increase chance of winners std::vector players; for (int i = 0; i < 20; ++i) { const id user = id::randomValue(); players.push_back(user); - QTFRandomValues nums = ctl.makeValidNumbers( - static_cast((i % 27) + 1), - static_cast((i % 27) + 2), - static_cast((i % 27) + 3), - static_cast((i % 27) + 4)); + QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i % 27) + 1), static_cast((i % 27) + 2), + static_cast((i % 27) + 3), static_cast((i % 27) + 4)); ctl.fundAndBuyTicket(user, ticketPrice, nums); } - const uint64 winnerCountBefore = ctl.getWinnerData().winnerData.winnerCounter; ctl.advanceOneDayAndDraw(); - // Winner data should record any winners + // Winner data should always record winning values and epoch, even if no winners const QTF::GetWinnerData_output winnerData = ctl.getWinnerData(); - // At minimum, winning values should be set - bool hasWinningValues = false; - for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) - { - if (winnerData.winnerData.winnerValues.get(i) > 0) - { - hasWinningValues = true; - break; - } - } - EXPECT_TRUE(hasWinningValues); - - // All winning values should be in valid range [1..30] + // Winning values should always be set and valid after a draw for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) { const uint8 val = winnerData.winnerData.winnerValues.get(i); - EXPECT_GE(val, 1u); - EXPECT_LE(val, 30u); + EXPECT_GE(val, 1u) << "Winning value " << i << " should be >= 1"; + EXPECT_LE(val, 30u) << "Winning value " << i << " should be <= 30"; } + + // Epoch should be recorded + EXPECT_GT((uint64)winnerData.winnerData.epoch, 0u) << "Epoch should be recorded after draw"; } TEST(ContractQThirtyFour, WinnerData_UniqueWinningNumbers) @@ -1016,70 +1091,23 @@ TEST(ContractQThirtyFour, WinnerData_UniqueWinningNumbers) for (int i = 0; i < 5; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers( - static_cast(i + 1), - static_cast(i + 5), - static_cast(i + 10), - static_cast(i + 15)); + QTFRandomValues nums = + ctl.makeValidNumbers(static_cast(i + 1), static_cast(i + 5), static_cast(i + 10), static_cast(i + 15)); ctl.fundAndBuyTicket(user, ticketPrice, nums); } ctl.advanceOneDayAndDraw(); - // Check winning numbers are unique + // Winning numbers should always be unique after a draw const QTF::GetWinnerData_output winnerData = ctl.getWinnerData(); - std::vector winningNums; - for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) - { - winningNums.push_back(winnerData.winnerData.winnerValues.get(i)); - } - // Sort and check for duplicates - std::sort(winningNums.begin(), winningNums.end()); - for (size_t i = 1; i < winningNums.size(); ++i) - { - EXPECT_NE(winningNums[i], winningNums[i - 1]) << "Duplicate winning number found: " << (int)winningNums[i]; - } -} - -// ============================================================================ -// RESERVE INTEGRATION TESTS -// ============================================================================ - -TEST(ContractQThirtyFour, ReserveTopUp_WhenPoolInsufficient) -{ - ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - - // Fund QRP with substantial reserve - ctl.fundQRP(1000000000ULL); // 1B in reserve - - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const uint64 qrpBalBefore = getBalance(ctl.qrpSelf()); - - // Add many players to potentially trigger floor guarantees - for (int i = 0; i < 100; ++i) + std::set winningNums; + for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) { - const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers( - static_cast((i % 27) + 1), - static_cast((i % 27) + 2), - static_cast((i % 27) + 3), - static_cast((i % 27) + 4)); - ctl.fundAndBuyTicket(user, ticketPrice, nums); + winningNums.emplace(winnerData.winnerData.winnerValues.get(i)); } - ctl.advanceOneDayAndDraw(); - - // QRP may have been used for top-ups (or reserve contributions) - // The balance change depends on winners and floors - const uint64 qrpBalAfter = getBalance(ctl.qrpSelf()); - - // Either QRP was tapped for top-up (decreases) or received overflow (increases) - // We just verify the system didn't crash and values are reasonable - EXPECT_GE(qrpBalAfter, 0u); + EXPECT_EQ(winningNums.size(), QTF_RANDOM_VALUES_COUNT) << "All 4 winning numbers should be unique"; } // ============================================================================ @@ -1166,20 +1194,16 @@ TEST(ContractQThirtyFour, MultipleRounds_JackpotAccumulates) for (int i = 0; i < 10; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers( - static_cast((i + round) % 27 + 1), - static_cast((i + round + 1) % 27 + 1), - static_cast((i + round + 2) % 27 + 1), - static_cast((i + round + 3) % 27 + 1)); + QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i + round) % 27 + 1), static_cast((i + round + 1) % 27 + 1), + static_cast((i + round + 2) % 27 + 1), static_cast((i + round + 3) % 27 + 1)); ctl.fundAndBuyTicket(user, ticketPrice, nums); } ctl.advanceOneDayAndDraw(); - // Jackpot should generally increase (unless k=4 win depletes it) + // Jackpot should increase each round (no k=4 winners in this test) const uint64 currentJackpot = ctl.state()->getJackpot(); - // We just check it's a valid value - EXPECT_GE(currentJackpot, 0u); + EXPECT_GT(currentJackpot, prevJackpot) << "Round " << round << ": jackpot should grow"; // Track for next iteration prevJackpot = currentJackpot; @@ -1202,11 +1226,8 @@ TEST(ContractQThirtyFour, MultipleRounds_StateResetsCorrectly) for (int i = 0; i < playersThisRound; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers( - static_cast((i + round) % 27 + 1), - static_cast((i + round + 5) % 27 + 1), - static_cast((i + round + 10) % 27 + 1), - static_cast((i + round + 15) % 27 + 1)); + QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i + round) % 27 + 1), static_cast((i + round + 5) % 27 + 1), + static_cast((i + round + 10) % 27 + 1), static_cast((i + round + 15) % 27 + 1)); ctl.fundAndBuyTicket(user, ticketPrice, nums); } @@ -1263,11 +1284,8 @@ TEST(ContractQThirtyFour, Schedule_DrawOnlyOnScheduledDays) for (int i = 0; i < 5; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers( - static_cast(i + 1), - static_cast(i + 5), - static_cast(i + 10), - static_cast(i + 15)); + QTFRandomValues nums = + ctl.makeValidNumbers(static_cast(i + 1), static_cast(i + 5), static_cast(i + 10), static_cast(i + 15)); ctl.fundAndBuyTicket(user, ticketPrice, nums); } @@ -1297,11 +1315,8 @@ TEST(ContractQThirtyFour, DrawHour_NoDrawBeforeScheduledHour) for (int i = 0; i < 5; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers( - static_cast(i + 1), - static_cast(i + 5), - static_cast(i + 10), - static_cast(i + 15)); + QTFRandomValues nums = + ctl.makeValidNumbers(static_cast(i + 1), static_cast(i + 5), static_cast(i + 10), static_cast(i + 15)); ctl.fundAndBuyTicket(user, ticketPrice, nums); } @@ -1327,8 +1342,8 @@ TEST(ContractQThirtyFour, Combinatorics_P4Denominator) { // Verify the P4 denominator constant matches combinatorics // C(30,4) = 30! / (4! * 26!) = 27405 - constexpr uint64 numerator = 30 * 29 * 28 * 27; - constexpr uint64 denominator = 4 * 3 * 2 * 1; + constexpr uint64 numerator = QTF_MAX_RANDOM_VALUE * 29 * 28 * 27; + constexpr uint64 denominator = QTF_RANDOM_VALUES_COUNT * 3 * 2 * 1; constexpr uint64 expected = numerator / denominator; EXPECT_EQ(expected, QTF_P4_DENOMINATOR); @@ -1344,8 +1359,7 @@ TEST(ContractQThirtyFour, FeeCalculation_TotalEquals100Percent) ContractTestingQTF ctl; const QTF::GetFees_output fees = ctl.getFees(); - const uint32 total = fees.teamFeePercent + fees.distributionFeePercent + - fees.winnerFeePercent + fees.burnPercent; + const uint32 total = fees.teamFeePercent + fees.distributionFeePercent + fees.winnerFeePercent + fees.burnPercent; EXPECT_EQ(total, 100u); -} \ No newline at end of file +} From be509ddb56f97facbad4bd111c93953536cdb7b3 Mon Sep 17 00:00:00 2001 From: N-010 Date: Fri, 12 Dec 2025 01:37:26 +0300 Subject: [PATCH 12/77] Does not block the purchase of a ticket at a higher price --- src/contracts/QThirtyFour.h | 9 ++++++++- test/contract_qtf.cpp | 36 ++++++++++++++++++++++++++++-------- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 81c7141ce..5933e7844 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -800,7 +800,7 @@ struct QTF : public ContractBase return; } - if (qpi.invocationReward() != state.ticketPrice) + if (qpi.invocationReward() < state.ticketPrice) { if (qpi.invocationReward() > 0) { @@ -811,6 +811,13 @@ struct QTF : public ContractBase return; } + // If overpaid, accept ticket and return excess to invocator + if (qpi.invocationReward() > state.ticketPrice) + { + const uint64 excess = qpi.invocationReward() - state.ticketPrice; + qpi.transfer(qpi.invocator(), excess); + } + locals.validateInput.numbers = input.randomValues; CALL(ValidateNumbers, locals.validateInput, locals.validateOutput); if (!locals.validateOutput.isValid) diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp index c1bcdd392..b5b351c6f 100644 --- a/test/contract_qtf.cpp +++ b/test/contract_qtf.cpp @@ -292,7 +292,7 @@ TEST(ContractQThirtyFour, BuyTicket_WhenSellingClosed_RefundsAndFails) EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); } -TEST(ContractQThirtyFour, BuyTicket_WrongPrice_RefundsAndFails) +TEST(ContractQThirtyFour, BuyTicket_TooLowPrice_RefundsAndFails) { ContractTestingQTF ctl; ctl.beginEpochWithValidTime(); @@ -304,19 +304,39 @@ TEST(ContractQThirtyFour, BuyTicket_WrongPrice_RefundsAndFails) QTFRandomValues nums = ctl.makeValidNumbers(1, 2, 3, 4); - // Test with wrong price (too low) + // Test with price too low - should fail and refund const QTF::BuyTicket_output outLow = ctl.buyTicket(user, ticketPrice - 1, nums); EXPECT_EQ(outLow.returnCode, static_cast(QTF::EReturnCode::INVALID_TICKET_PRICE)); - EXPECT_EQ(getBalance(user), balBefore); // Refunded - - // Test with wrong price (too high) - const QTF::BuyTicket_output outHigh = ctl.buyTicket(user, ticketPrice + 1, nums); - EXPECT_EQ(outHigh.returnCode, static_cast(QTF::EReturnCode::INVALID_TICKET_PRICE)); - EXPECT_EQ(getBalance(user), balBefore); // Refunded + EXPECT_EQ(getBalance(user), balBefore); // Fully refunded EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); } +TEST(ContractQThirtyFour, BuyTicket_OverpaidPrice_AcceptsAndReturnsExcess) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + const uint64 overpayment = ticketPrice * 2; // Pay double + increaseEnergy(user, overpayment * 2); + const uint64 balBefore = getBalance(user); + + QTFRandomValues nums = ctl.makeValidNumbers(1, 2, 3, 4); + + // Test with overpayment - should accept ticket and return excess + const QTF::BuyTicket_output outHigh = ctl.buyTicket(user, overpayment, nums); + EXPECT_EQ(outHigh.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + + // Should have paid exactly ticketPrice, excess returned + const uint64 excess = overpayment - ticketPrice; + EXPECT_EQ(getBalance(user), balBefore - ticketPrice) << "User should pay exactly ticket price, excess returned"; + + // Ticket should be registered + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); +} + TEST(ContractQThirtyFour, BuyTicket_InvalidNumbers_OutOfRange_Fails) { ContractTestingQTF ctl; From d05e7dd8a65a96097a0c1b0b7f7420a0756e36fb Mon Sep 17 00:00:00 2001 From: N-010 Date: Tue, 16 Dec 2025 15:49:03 +0300 Subject: [PATCH 13/77] =?UTF-8?q?=20=20-=20src/contracts/QThirtyFour.h:=20?= =?UTF-8?q?ensure=20k4=20reseed=20uses=20up-to-date=20QRP=20reserve=20and?= =?UTF-8?q?=20don=E2=80=99t=20consume=20reseed=20budget=20with=20tier=20to?= =?UTF-8?q?p-ups=20on=20k4=20rounds=20=20=20-=20test/contract=5Fqtf.cpp:?= =?UTF-8?q?=20expose=20private/protected=20internals=20for=20unit=20tests,?= =?UTF-8?q?=20add=20exact-match=20k2/k3=20ticket=20generators=20(unique),?= =?UTF-8?q?=20fund=20jackpot=20balance=20in=20k4=20test,=20and=20force=20F?= =?UTF-8?q?R=20off=20in=20baseline=20k2/k3=20revenue-split=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/contracts/QThirtyFour.h | 310 +++++-- test/contract_qtf.cpp | 1652 +++++++++++++++++++++++++++++++++-- 2 files changed, 1829 insertions(+), 133 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 5933e7844..208251b2e 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -7,14 +7,14 @@ constexpr uint64 QTF_MAX_RANDOM_VALUE = 30; constexpr uint64 QTF_TICKET_PRICE = 1000000; // Baseline split for k2/k3 when FR is OFF (per spec: k3=40%, k2=28% of Winners block). -// Remaining 32% of Winners block goes to overflow. +// Initial 32% of Winners block is unallocated; overflow will also include unawarded k2/k3 funds. constexpr uint64 QTF_BASE_K3_SHARE_BP = 4000; // 40% of winners block to k3 constexpr uint64 QTF_BASE_K2_SHARE_BP = 2800; // 28% of winners block to k2 // --- Fast-Recovery (FR) parameters (spec defaults) -------------------------- -// Fast-Recovery base redirect percentages (always active when FR=ON) -constexpr uint64 QTF_FR_DEV_REDIRECT_BP = 100; // 1.00% of R (base redirect, always applied) -constexpr uint64 QTF_FR_DIST_REDIRECT_BP = 100; // 1.00% of R (base redirect, always applied) +// Fast-Recovery base redirect percentages (applied when FR=ON, capped at available amounts) +constexpr uint64 QTF_FR_DEV_REDIRECT_BP = 100; // 1.00% of R (base redirect) +constexpr uint64 QTF_FR_DIST_REDIRECT_BP = 100; // 1.00% of R (base redirect) // Deficit-driven extra redirect parameters (dynamic, no hard N threshold) // The extra redirect is calculated based on: @@ -33,8 +33,8 @@ constexpr uint64 QTF_FIXED_POINT_SCALE = 1000000; // Scale for fixed-point arith constexpr uint64 QTF_P4_DENOMINATOR = 27405; // Denominator for k=4 probability (1/27405) constexpr uint64 QTF_FR_WINNERS_RAKE_BP = 500; // 5% of winners block from k3 constexpr uint64 QTF_FR_ALPHA_BP = 500; // alpha = 0.05 -> 95% overflow to jackpot -constexpr uint16 QTF_FR_POST_K4_WINDOW_ROUNDS = 50; -constexpr uint16 QTF_FR_HYSTERESIS_ROUNDS = 3; +constexpr uint8 QTF_FR_POST_K4_WINDOW_ROUNDS = 50; +constexpr uint8 QTF_FR_HYSTERESIS_ROUNDS = 3; // --- Floors and reserve safety ---------------------------------------------- constexpr uint64 QTF_K2_FLOOR_MULT = 1; // numerator for 0.5 * P (we divide by 2) @@ -146,7 +146,7 @@ struct QTF : public ContractBase struct PoolsSnapshot { uint64 jackpot; - uint64 reserve; // Total reserve from QRP + uint64 reserve; // Available reserve from QRP (not including locked amounts) uint64 targetJackpot; uint8 frActive; uint16 roundsSinceK4; @@ -161,7 +161,7 @@ struct QTF : public ContractBase PoolsSnapshot pools; }; - // ValidateNumbers: Check if all numbers are valid and unique + // ValidateNumbers: Check if all numbers are valid [1..30] and unique struct ValidateNumbers_input { QTFRandomValues numbers; // Numbers to validate @@ -191,6 +191,7 @@ struct QTF : public ContractBase // CALL parameters for ValidateNumbers ValidateNumbers_input validateInput; ValidateNumbers_output validateOutput; + uint64 excess; }; // Set Price @@ -223,7 +224,7 @@ struct QTF : public ContractBase uint8 returnCode; }; - // Set Target Carry (Jackpot target) + // Set Target Jackpot struct SetTargetJackpot_input { uint64 newTargetJackpot; @@ -260,7 +261,7 @@ struct QTF : public ContractBase Entity entity; }; - // Calculate Base Gain (FR base carry growth estimation) + // Calculate Base Gain (FR base carry growth estimation, excluding extra deficit-driven redirect) struct CalculateBaseGain_input { uint64 revenue; // Round revenue (N * ticketPrice) @@ -490,8 +491,9 @@ struct QTF : public ContractBase struct CountMatches_locals { uint64 i; - uint8 maskA; - uint8 maskB; + uint32 maskA; + uint32 maskB; + uint8 randomValue; }; struct GetFees_input @@ -538,6 +540,56 @@ struct QTF : public ContractBase uint64 reserveAdd; }; + // CalculatePrizePools: Calculate k2/k3 prize pools from revenue + // Reusable function for both settlement and estimation + struct CalculatePrizePools_input + { + uint64 revenue; // Total revenue (ticketPrice * numberOfPlayers) + bit applyFRRake; // Whether to apply 5% FR rake + }; + struct CalculatePrizePools_output + { + uint64 winnersBlock; // Winners block after fees + uint64 winnersRake; // 5% rake (if FR active) + uint64 k2Pool; // 28% of winners block (after rake) + uint64 k3Pool; // 40% of winners block (after rake) + }; + struct CalculatePrizePools_locals + { + GetFees_input feesInput; + GetFees_output feesOutput; + uint64 winnersBlockBeforeRake; + }; + + // EstimatePrizePayouts: Calculate estimated prize payouts for k=2 and k=3 tiers + // Based on current ticket sales and number of winners per tier + struct EstimatePrizePayouts_input + { + uint64 k2WinnerCount; // Number of k=2 winners (estimated or actual) + uint64 k3WinnerCount; // Number of k=3 winners (estimated or actual) + }; + struct EstimatePrizePayouts_output + { + uint64 k2PayoutPerWinner; // Estimated payout per k=2 winner + uint64 k3PayoutPerWinner; // Estimated payout per k=3 winner + uint64 k2MinFloor; // Minimum guaranteed payout for k=2 (0.5*P) + uint64 k3MinFloor; // Minimum guaranteed payout for k=3 (5*P) + uint64 perWinnerCap; // Maximum payout per winner (25*P) + uint64 totalRevenue; // Total revenue from ticket sales + uint64 k2Pool; // Total pool for k=2 tier + uint64 k3Pool; // Total pool for k=3 tier + }; + struct EstimatePrizePayouts_locals + { + uint64 revenue; + uint64 k2FloorTotal; + uint64 k3FloorTotal; + uint64 k2PayoutPoolEffective; + uint64 k3PayoutPoolEffective; + CalculatePrizePools_input calcPoolsInput; + CalculatePrizePools_output calcPoolsOutput; + }; + struct SettlementLocals { QTFRandomValues winningValues; @@ -582,6 +634,9 @@ struct QTF : public ContractBase uint64 delta; // Deficit: max(0, targetJackpot - jackpot) uint64 devExtraBP; // Dev share of extra: extraRedirectBP / 2 uint64 distExtraBP; // Dist share of extra: extraRedirectBP / 2 + // CALL parameters for CalculatePrizePools (shared function) + CalculatePrizePools_input calcPoolsInput; + CalculatePrizePools_output calcPoolsOutput; // CALL parameters for CalculateBaseGain CalculateBaseGain_input calcBaseGainInput; CalculateBaseGain_output calcBaseGainOutput; @@ -666,6 +721,7 @@ struct QTF : public ContractBase REGISTER_USER_FUNCTION(GetDrawHour, 6); REGISTER_USER_FUNCTION(GetState, 7); REGISTER_USER_FUNCTION(GetFees, 8); + REGISTER_USER_FUNCTION(EstimatePrizePayouts, 9); } BEGIN_EPOCH() @@ -811,13 +867,6 @@ struct QTF : public ContractBase return; } - // If overpaid, accept ticket and return excess to invocator - if (qpi.invocationReward() > state.ticketPrice) - { - const uint64 excess = qpi.invocationReward() - state.ticketPrice; - qpi.transfer(qpi.invocator(), excess); - } - locals.validateInput.numbers = input.randomValues; CALL(ValidateNumbers, locals.validateInput, locals.validateOutput); if (!locals.validateOutput.isValid) @@ -831,6 +880,18 @@ struct QTF : public ContractBase } addPlayerInfo(state, qpi.invocator(), input.randomValues); + + // If overpaid, accept ticket and return excess to invocator. + // Important: refund excess ONLY after validation, otherwise invalid tickets could be over-refunded. + if (qpi.invocationReward() > state.ticketPrice) + { + locals.excess = qpi.invocationReward() - state.ticketPrice; + if (locals.excess > 0) + { + qpi.transfer(qpi.invocator(), locals.excess); + } + } + output.returnCode = toReturnCode(EReturnCode::SUCCESS); } @@ -942,6 +1003,83 @@ struct QTF : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } + PUBLIC_FUNCTION_WITH_LOCALS(EstimatePrizePayouts) + { + // Calculate total revenue from current ticket sales + locals.revenue = smul(state.ticketPrice, state.numberOfPlayers); + output.totalRevenue = locals.revenue; + + // Set minimum floors and cap + output.k2MinFloor = div(smul(state.ticketPrice, QTF_K2_FLOOR_MULT), QTF_K2_FLOOR_DIV); // 0.5*P + output.k3MinFloor = smul(state.ticketPrice, QTF_K3_FLOOR_MULT); // 5*P + output.perWinnerCap = smul(state.ticketPrice, QTF_TOPUP_PER_WINNER_CAP_MULT); // 25*P + + if (locals.revenue == 0 || state.numberOfPlayers == 0) + { + // No tickets sold, no payouts + output.k2PayoutPerWinner = 0; + output.k3PayoutPerWinner = 0; + output.k2Pool = 0; + output.k3Pool = 0; + return; + } + + // Use shared CalculatePrizePools function to compute pools + locals.calcPoolsInput.revenue = locals.revenue; + locals.calcPoolsInput.applyFRRake = state.frActive; + CALL(CalculatePrizePools, locals.calcPoolsInput, locals.calcPoolsOutput); + + output.k2Pool = locals.calcPoolsOutput.k2Pool; + output.k3Pool = locals.calcPoolsOutput.k3Pool; + + // Calculate k2 payout per winner + if (input.k2WinnerCount > 0) + { + locals.k2FloorTotal = smul(output.k2MinFloor, input.k2WinnerCount); + locals.k2PayoutPoolEffective = output.k2Pool; + + // Note: This is an estimate - actual implementation may top up from reserve + // If pool insufficient, we show floor; otherwise calculate actual per-winner amount + if (locals.k2PayoutPoolEffective >= locals.k2FloorTotal) + { + output.k2PayoutPerWinner = RL::min(output.perWinnerCap, locals.k2PayoutPoolEffective / input.k2WinnerCount); + } + else + { + // Pool insufficient, would need reserve top-up - show floor as estimate + output.k2PayoutPerWinner = output.k2MinFloor; + } + } + else + { + // No winners - show what a single winner would get + output.k2PayoutPerWinner = RL::min(output.perWinnerCap, output.k2Pool); + } + + // Calculate k3 payout per winner + if (input.k3WinnerCount > 0) + { + locals.k3FloorTotal = smul(output.k3MinFloor, input.k3WinnerCount); + locals.k3PayoutPoolEffective = output.k3Pool; + + // Note: This is an estimate - actual implementation may top up from reserve + if (locals.k3PayoutPoolEffective >= locals.k3FloorTotal) + { + output.k3PayoutPerWinner = RL::min(output.perWinnerCap, locals.k3PayoutPoolEffective / input.k3WinnerCount); + } + else + { + // Pool insufficient, would need reserve top-up - show floor as estimate + output.k3PayoutPerWinner = output.k3MinFloor; + } + } + else + { + // No winners - show what a single winner would get + output.k3PayoutPerWinner = RL::min(output.perWinnerCap, output.k3Pool); + } + } + protected: static void clearEpochState(QTF& state) { clearPlayerData(state); } @@ -1001,6 +1139,11 @@ struct QTF : public ContractBase } } + static void clearWinerData(QTF& state) + { + setMemory(state.lastWinnerData, 0); + } + static void fillWinnerData(QTF& state, const PlayerData& playerData, const QTFRandomValues& winnerValues, const uint16& epoch) { if (!isZero(playerData.player)) @@ -1083,13 +1226,13 @@ struct QTF : public ContractBase CALL(GetFees, locals.feesInput, locals.feesOutput); - locals.winnersBlock = div(smul(locals.revenue, static_cast(locals.feesOutput.winnerFeePercent)), 100); locals.devPayout = div(smul(locals.revenue, static_cast(locals.feesOutput.teamFeePercent)), 100); locals.distPayout = div(smul(locals.revenue, static_cast(locals.feesOutput.distributionFeePercent)), 100); locals.burnAmount = div(smul(locals.revenue, static_cast(locals.feesOutput.burnPercent)), 100); // FR detection and hysteresis logic. - // Update hysteresis counter BEFORE activation check to ensure correct deactivation timing. + // Update hysteresis counter BEFORE activation check so deactivation can occur + // immediately when reaching the threshold (3 consecutive rounds at/above target). if (state.jackpot >= state.targetJackpot) { state.frRoundsAtOrAboveTarget = sadd(state.frRoundsAtOrAboveTarget, 1); @@ -1113,6 +1256,20 @@ struct QTF : public ContractBase state.frActive = false; } + // Calculate prize pools using shared function (handles FR rake if active) + locals.calcPoolsInput.revenue = locals.revenue; + locals.calcPoolsInput.applyFRRake = state.frActive; + CALL(CalculatePrizePools, locals.calcPoolsInput, locals.calcPoolsOutput); + + locals.winnersBlock = locals.calcPoolsOutput.winnersBlock; + locals.winnersRake = locals.calcPoolsOutput.winnersRake; + locals.k2Pool = locals.calcPoolsOutput.k2Pool; + locals.k3Pool = locals.calcPoolsOutput.k3Pool; + + // Calculate initial overflow: unallocated funds after k2/k3 allocation (32% baseline) + // Additional unawarded k2/k3 funds will be added to this after tier processing + locals.winnersOverflow = locals.winnersBlock - locals.k3Pool - locals.k2Pool; + // Fast-Recovery (FR) mode: redirect portions of Dev/Distribution to jackpot with deficit-driven extra. // Base redirect is always 1% Dev + 1% Dist when FR=ON. // Extra redirect is calculated dynamically based on deficit, expected k4 timing, and ticket volume. @@ -1123,7 +1280,7 @@ struct QTF : public ContractBase // Estimate base gain from existing FR mechanisms (without extra) locals.calcBaseGainInput.revenue = locals.revenue; - locals.calcBaseGainInput.winnersBlock = locals.winnersBlock; + locals.calcBaseGainInput.winnersBlock = locals.calcPoolsOutput.winnersBlock; CALL(CalculateBaseGain, locals.calcBaseGainInput, locals.calcBaseGainOutput); // Calculate deficit-driven extra redirect in basis points @@ -1165,28 +1322,14 @@ struct QTF : public ContractBase locals.distRedirect = locals.distPayout; locals.distPayout = 0; } - - // FR rake: take 5% of winners block from k3 tier to accelerate jackpot rebuild - locals.winnersRake = div(smul(locals.winnersBlock, QTF_FR_WINNERS_RAKE_BP), 10000); - locals.winnersBlock -= locals.winnersRake; - - // FR tier split: same as baseline (k3=40%, k2=28% of win_eff) - calcK2K3Pool(locals.winnersBlock, locals.k2Pool, locals.k3Pool); - // Remaining goes to overflow (will be split with FR alpha: 95% carry, 5% reserve) - locals.winnersOverflow = locals.winnersBlock - locals.k3Pool - locals.k2Pool; - } - else - { - // Baseline mode: k3=40%, k2=28% of Winners block - // Remaining 32% of Winners block goes to overflow automatically - calcK2K3Pool(locals.winnersBlock, locals.k2Pool, locals.k3Pool); - // Add baseline overflow (32% of winnersBlock) to winnersOverflow - locals.winnersOverflow = locals.winnersBlock - locals.k3Pool - locals.k2Pool; } locals.k2PayoutPool = locals.k2Pool; // mutable pools after top-ups locals.k3PayoutPool = locals.k3Pool; + // Reset last-winner snapshot for this settlement (per-round view). + clearWinerData(state); + // Generate winning random values using CALL locals.getRandomInput.seed = qpi.K12(qpi.getPrevSpectrumDigest()).u64._0; CALL(GetRandomValues, locals.getRandomInput, locals.getRandomOutput); @@ -1222,6 +1365,21 @@ struct QTF : public ContractBase // First, get total QRP balance for safety limit calculations (10% of total reserve per round). CALL_OTHER_CONTRACT_FUNCTION(QRP, GetAvailableReserve, locals.qrpGetAvailableInput, locals.qrpGetAvailableOutput); locals.totalQRPBalance = locals.qrpGetAvailableOutput.availableReserve; + + // If a k=4 win happened this round, we will try to reseed the jackpot back up to target after payouts. + // To avoid draining the reserve needed for reseed with k2/k3 floor top-ups (QRP is a single pool in this implementation), + // limit top-ups to the portion that is above the target jackpot amount. + if (locals.countK4 > 0) + { + if (locals.totalQRPBalance > state.targetJackpot) + { + locals.totalQRPBalance -= state.targetJackpot; + } + else + { + locals.totalQRPBalance = 0; + } + } locals.perWinnerCap = smul(state.ticketPrice, QTF_TOPUP_PER_WINNER_CAP_MULT); // 25*P // Process k2 tier payout @@ -1296,7 +1454,9 @@ struct QTF : public ContractBase state.frRoundsAtOrAboveTarget = 0; // Reseed jackpot from QReservePool (up to targetJackpot or available reserve) - locals.qrpRequested = RL::min(locals.totalQRPBalance, state.targetJackpot); + // Re-query available reserve because k2/k3 top-ups may have reduced it. + CALL_OTHER_CONTRACT_FUNCTION(QRP, GetAvailableReserve, locals.qrpGetAvailableInput, locals.qrpGetAvailableOutput); + locals.qrpRequested = RL::min(locals.qrpGetAvailableOutput.availableReserve, state.targetJackpot); if (locals.qrpRequested > 0) { locals.qrpGetReserveInput.revenue = locals.qrpRequested; @@ -1387,9 +1547,8 @@ struct QTF : public ContractBase * @brief Refunds ticket price to all players who bought tickets in the current epoch. * * This procedure is used to return funds to all participants, typically in cases where: - * - The round is invalid or cancelled - * - Technical issues prevent proper settlement - * - Only one player participated (cannot draw fairly) + * - Revenue calculation resulted in 0 (overflow or invalid state) + * - Contract balance is insufficient for settlement * * Performs one transfer per player entry. After refund, caller should reset numberOfPlayers. */ @@ -1408,14 +1567,18 @@ struct QTF : public ContractBase locals.maskB = 0; for (locals.i = 0; locals.i < input.playerValues.capacity(); ++locals.i) { - // Ensure value is in valid range [1..30] to prevent underflow/overflow in bit shift - ASSERT(input.playerValues.get(locals.i) > 0 && input.playerValues.get(locals.i) <= QTF_MAX_RANDOM_VALUE); - locals.maskA |= (1u << (input.playerValues.get(locals.i) - 1)); + locals.randomValue = input.playerValues.get(locals.i); + ASSERT(locals.randomValue > 0 && locals.randomValue <= QTF_MAX_RANDOM_VALUE); + + locals.maskA |= 1u << locals.randomValue; } + for (locals.i = 0; locals.i < input.winningValues.capacity(); ++locals.i) { - ASSERT(input.winningValues.get(locals.i) > 0 && input.winningValues.get(locals.i) <= QTF_MAX_RANDOM_VALUE); - locals.maskB |= (1u << (input.winningValues.get(locals.i) - 1)); + locals.randomValue = input.winningValues.get(locals.i); + ASSERT(locals.randomValue > 0 && locals.randomValue <= QTF_MAX_RANDOM_VALUE); + + locals.maskB |= 1u << locals.randomValue; } output.matches = bitcount32(locals.maskA & locals.maskB); } @@ -1709,6 +1872,55 @@ struct QTF : public ContractBase output.overflow = locals.finalPool - smul(output.perWinnerPayout, input.winnerCount); } + /** + * @brief Calculate k2/k3 prize pools from revenue (reusable for settlement and estimation). + * + * This function encapsulates the common logic for calculating prize pools: + * 1. Get fee percentages from RL contract + * 2. Calculate winners block (typically 68% of revenue) + * 3. Apply FR rake if active (5% of winners block) + * 4. Split remaining into k2 (28%) and k3 (40%) pools + * + * @param input.revenue - Total revenue from ticket sales + * @param input.applyFRRake - Whether to apply 5% FR rake + * @param output.winnersBlock - Winners block after rake + * @param output.winnersRake - Amount taken as FR rake (0 if not applied) + * @param output.k2Pool - Prize pool for k=2 tier + * @param output.k3Pool - Prize pool for k=3 tier + */ + PRIVATE_FUNCTION_WITH_LOCALS(CalculatePrizePools) + { + if (input.revenue == 0) + { + output.winnersBlock = 0; + output.winnersRake = 0; + output.k2Pool = 0; + output.k3Pool = 0; + return; + } + + // Get fee percentages from RL contract + CALL(GetFees, locals.feesInput, locals.feesOutput); + + // Calculate winners block (typically 68% of revenue) + locals.winnersBlockBeforeRake = div(smul(input.revenue, static_cast(locals.feesOutput.winnerFeePercent)), 100); + + // Apply FR rake if requested + if (input.applyFRRake) + { + output.winnersRake = div(smul(locals.winnersBlockBeforeRake, QTF_FR_WINNERS_RAKE_BP), 10000); + output.winnersBlock = locals.winnersBlockBeforeRake - output.winnersRake; + } + else + { + output.winnersRake = 0; + output.winnersBlock = locals.winnersBlockBeforeRake; + } + + // Calculate k2 and k3 pools using shared static function + calcK2K3Pool(output.winnersBlock, output.k2Pool, output.k3Pool); + } + /** * @brief Estimates base carry gain per round from FR mechanisms (without extra redirect). * @@ -1730,7 +1942,7 @@ struct QTF : public ContractBase // Winners rake: 5% of winners block locals.winnersRake = div(smul(input.winnersBlock, QTF_FR_WINNERS_RAKE_BP), 10000); - // Overflow estimate: assume ~10% of winnersBlock not paid out (conservative) + // Overflow estimate: assume ~10% of winnersBlock not paid out (conservative heuristic) // With alpha_fr = 0.05, 95% of overflow goes to carry locals.estimatedOverflow = div(input.winnersBlock, 10); locals.overflowToCarry = div(smul(locals.estimatedOverflow, 10000 - QTF_FR_ALPHA_BP), 10000); diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp index b5b351c6f..ca8018066 100644 --- a/test/contract_qtf.cpp +++ b/test/contract_qtf.cpp @@ -1,11 +1,15 @@ -// File: test/contract_qtf.cpp -// Tests for QThirtyFour (4-of-30 lottery) smart contract -// Based on specification: doc/QThirtyFour_Proposal.md - #define NO_UEFI +#define _ALLOW_KEYWORD_MACROS 1 + +#define private protected #include "contract_testing.h" +#undef private +#undef _ALLOW_KEYWORD_MACROS + #include +#include +#include #include // Procedure indices (must match REGISTER_USER_FUNCTIONS_AND_PROCEDURES in QThirtyFour.h) @@ -24,6 +28,7 @@ constexpr uint16 QTF_FUNCTION_GET_SCHEDULE = 5; constexpr uint16 QTF_FUNCTION_GET_DRAW_HOUR = 6; constexpr uint16 QTF_FUNCTION_GET_STATE = 7; constexpr uint16 QTF_FUNCTION_GET_FEES = 8; +constexpr uint16 QTF_FUNCTION_ESTIMATE_PRIZE_PAYOUTS = 9; static const id QTF_DEV_ADDRESS = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); @@ -46,11 +51,185 @@ class QTFChecker : public QTF void setScheduleMask(uint8 newMask) { schedule = newMask; } void setJackpot(uint64 value) { jackpot = value; } void setTargetJackpotInternal(uint64 value) { targetJackpot = value; } + void setTicketPriceInternal(uint64 value) { ticketPrice = value; } void setFrActive(bit value) { frActive = value; } void setFrRoundsSinceK4(uint16 value) { frRoundsSinceK4 = value; } void setFrRoundsAtOrAboveTarget(uint16 value) { frRoundsAtOrAboveTarget = value; } const PlayerData& getPlayer(uint64 index) const { return players.get(index); } + void addPlayerDirect(const id& playerId, const QTFRandomValues& randomValues) { players.set(numberOfPlayers++, {playerId, randomValues}); } + + // ---- Private method wrappers (private->protected in this TU) -------------- + ValidateNumbers_output callValidateNumbers(const QPI::QpiContextFunctionCall& qpi, const QTFRandomValues& numbers) const + { + ValidateNumbers_input input{}; + ValidateNumbers_output output{}; + std::aligned_storage_t localsStorage; + auto& locals = *reinterpret_cast(&localsStorage); + setMemory(locals, 0); + input.numbers = numbers; + ValidateNumbers(qpi, *this, input, output, locals); + return output; + } + + GetRandomValues_output callGetRandomValues(const QPI::QpiContextFunctionCall& qpi, uint64 seed) const + { + GetRandomValues_input input{}; + GetRandomValues_output output{}; + std::aligned_storage_t localsStorage; + auto& locals = *reinterpret_cast(&localsStorage); + setMemory(locals, 0); + input.seed = seed; + GetRandomValues(qpi, *this, input, output, locals); + return output; + } + + CountMatches_output callCountMatches(const QPI::QpiContextFunctionCall& qpi, const QTFRandomValues& playerValues, + const QTFRandomValues& winningValues) const + { + CountMatches_input input{}; + CountMatches_output output{}; + std::aligned_storage_t localsStorage; + auto& locals = *reinterpret_cast(&localsStorage); + setMemory(locals, 0); + input.playerValues = playerValues; + input.winningValues = winningValues; + CountMatches(qpi, *this, input, output, locals); + return output; + } + + CheckContractBalance_output callCheckContractBalance(const QPI::QpiContextFunctionCall& qpi, uint64 expectedRevenue) const + { + CheckContractBalance_input input{}; + CheckContractBalance_output output{}; + std::aligned_storage_t localsStorage; + auto& locals = *reinterpret_cast(&localsStorage); + setMemory(locals, 0); + input.expectedRevenue = expectedRevenue; + CheckContractBalance(qpi, *this, input, output, locals); + return output; + } + + PowerFixedPoint_output callPowerFixedPoint(const QPI::QpiContextFunctionCall& qpi, uint64 base, uint64 exp) const + { + PowerFixedPoint_input input{}; + PowerFixedPoint_output output{}; + std::aligned_storage_t localsStorage; + auto& locals = *reinterpret_cast(&localsStorage); + setMemory(locals, 0); + input.base = base; + input.exp = exp; + PowerFixedPoint(qpi, *this, input, output, locals); + return output; + } + + CalculateExpectedRoundsToK4_output callCalculateExpectedRoundsToK4(const QPI::QpiContextFunctionCall& qpi, uint64 N) const + { + CalculateExpectedRoundsToK4_input input{}; + CalculateExpectedRoundsToK4_output output{}; + std::aligned_storage_t localsStorage; + auto& locals = *reinterpret_cast(&localsStorage); + setMemory(locals, 0); + input.N = N; + CalculateExpectedRoundsToK4(qpi, *this, input, output, locals); + return output; + } + + CalcReserveTopUp_output callCalcReserveTopUp(const QPI::QpiContextFunctionCall& qpi, uint64 totalQRPBalance, uint64 needed, + uint64 perWinnerCapTotal, uint64 ticketPrice) const + { + CalcReserveTopUp_input input{}; + CalcReserveTopUp_output output{}; + std::aligned_storage_t localsStorage; + auto& locals = *reinterpret_cast(&localsStorage); + setMemory(locals, 0); + input.totalQRPBalance = totalQRPBalance; + input.needed = needed; + input.perWinnerCapTotal = perWinnerCapTotal; + input.ticketPrice = ticketPrice; + CalcReserveTopUp(qpi, *this, input, output, locals); + return output; + } + + CalculatePrizePools_output callCalculatePrizePools(const QPI::QpiContextFunctionCall& qpi, uint64 revenue, bit applyFRRake) const + { + CalculatePrizePools_input input{}; + CalculatePrizePools_output output{}; + std::aligned_storage_t localsStorage; + auto& locals = *reinterpret_cast(&localsStorage); + setMemory(locals, 0); + input.revenue = revenue; + input.applyFRRake = applyFRRake; + CalculatePrizePools(qpi, *this, input, output, locals); + return output; + } + + CalculateBaseGain_output callCalculateBaseGain(const QPI::QpiContextFunctionCall& qpi, uint64 revenue, uint64 winnersBlock) const + { + CalculateBaseGain_input input{}; + CalculateBaseGain_output output{}; + std::aligned_storage_t localsStorage; + auto& locals = *reinterpret_cast(&localsStorage); + setMemory(locals, 0); + input.revenue = revenue; + input.winnersBlock = winnersBlock; + CalculateBaseGain(qpi, *this, input, output, locals); + return output; + } + + CalculateExtraRedirectBP_output callCalculateExtraRedirectBP(const QPI::QpiContextFunctionCall& qpi, uint64 N, uint64 delta, uint64 revenue, + uint64 baseGain) const + { + CalculateExtraRedirectBP_input input{}; + CalculateExtraRedirectBP_output output{}; + std::aligned_storage_t localsStorage; + auto& locals = *reinterpret_cast(&localsStorage); + setMemory(locals, 0); + input.N = N; + input.delta = delta; + input.revenue = revenue; + input.baseGain = baseGain; + CalculateExtraRedirectBP(qpi, *this, input, output, locals); + return output; + } + + void callReturnAllTickets(const QPI::QpiContextProcedureCall& qpi) + { + ReturnAllTickets_input input{}; + ReturnAllTickets_output output{}; + std::aligned_storage_t localsStorage; + auto& locals = *reinterpret_cast(&localsStorage); + setMemory(locals, 0); + ReturnAllTickets(qpi, *this, input, output, locals); + } + + ProcessTierPayout_output callProcessTierPayout(const QPI::QpiContextProcedureCall& qpi, uint64 floorPerWinner, uint64 winnerCount, + uint64 payoutPool, uint64 perWinnerCap, uint64 totalQRPBalance, uint64 ticketPrice) + { + ProcessTierPayout_input input{}; + ProcessTierPayout_output output{}; + std::aligned_storage_t localsStorage; + auto& locals = *reinterpret_cast(&localsStorage); + setMemory(locals, 0); + input.floorPerWinner = floorPerWinner; + input.winnerCount = winnerCount; + input.payoutPool = payoutPool; + input.perWinnerCap = perWinnerCap; + input.totalQRPBalance = totalQRPBalance; + input.ticketPrice = ticketPrice; + ProcessTierPayout(qpi, *this, input, output, locals); + return output; + } + + void callSettleEpoch(const QPI::QpiContextProcedureCall& qpi) + { + SettleEpoch_input input{}; + SettleEpoch_output output{}; + std::aligned_storage_t localsStorage; + auto& locals = *reinterpret_cast(&localsStorage); + setMemory(locals, 0); + SettleEpoch(qpi, *this, input, output, locals); + } }; class ContractTestingQTF : protected ContractTesting @@ -66,7 +245,7 @@ class ContractTestingQTF : protected ContractTesting // Initialize QRP first (QTF depends on it for reserve operations) callSystemProcedure(QRP_CONTRACT_INDEX, INITIALIZE); - // Initialize RL (QTF queries fees from RL) + // Initialize RL (RandomLottery contract) callSystemProcedure(RL_CONTRACT_INDEX, INITIALIZE); // Initialize QTF system.epoch = contractDescriptions[QTF_CONTRACT_INDEX].constructionEpoch; @@ -78,6 +257,7 @@ class ContractTestingQTF : protected ContractTesting id qtfSelf() { return id(QTF_CONTRACT_INDEX, 0, 0, 0); } id qrpSelf() { return id(QRP_CONTRACT_INDEX, 0, 0, 0); } + void addPlayerDirect(const id& playerId, const QTFRandomValues& randomValues) { state()->addPlayerDirect(playerId, randomValues); } // Public function wrappers QTF::GetTicketPrice_output getTicketPrice() @@ -144,6 +324,16 @@ class ContractTestingQTF : protected ContractTesting return output; } + QTF::EstimatePrizePayouts_output estimatePrizePayouts(uint64 k2WinnerCount, uint64 k3WinnerCount) + { + QTF::EstimatePrizePayouts_input input; + input.k2WinnerCount = k2WinnerCount; + input.k3WinnerCount = k3WinnerCount; + QTF::EstimatePrizePayouts_output output; + callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_ESTIMATE_PRIZE_PAYOUTS, input, output); + return output; + } + // Procedure wrappers QTF::BuyTicket_output buyTicket(const id& user, uint64 reward, const QTFRandomValues& numbers) { @@ -268,8 +458,458 @@ class ContractTestingQTF : protected ContractTesting const QTF::BuyTicket_output out = buyTicket(user, ticketPrice, numbers); EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); } + + // Set prevSpectrumDigest for deterministic random number generation + // This allows tests to predict winning numbers by fixing the RNG seed + void setPrevSpectrumDigest(const m256i& digest) { etalonTick.prevSpectrumDigest = digest; } + + // Compute winning numbers that would be generated for a given prevSpectrumDigest + // This mirrors the logic in QThirtyFour::GetRandomValues (lines 1663-1698) + // Returns the 4 winning numbers in ascending order + QTFRandomValues computeWinningNumbersForDigest(const m256i& digest) + { + // Replicate QTF's GetRandomValues logic + // seed = qpi.K12(digest).u64._0 + m256i hashResult; + KangarooTwelve((const uint8*)&digest, sizeof(m256i), (uint8*)&hashResult, sizeof(m256i)); + const uint64 seed = hashResult.m256i_u64[0]; + + QTFRandomValues result; + uint8 used[31] = {0}; // Track used numbers [0..30], we only use [1..30] + + for (uint8 index = 0; index < 4; ++index) + { + // deriveOne(seed, index, tempValue) + uint64 tempValue = seed + 0x9e3779b97f4a7c15ULL * (index + 1); + // mix64 + tempValue ^= tempValue >> 30; + tempValue *= 0xbf58476d1ce4e5b9ULL; + tempValue ^= tempValue >> 27; + tempValue *= 0x94d049bb133111ebULL; + tempValue ^= tempValue >> 31; + + uint8 candidate = static_cast((tempValue % 30) + 1); + + // Handle collisions with the same regeneration logic as contract + uint32 attempts = 0; + while (used[candidate] && attempts < 100) + { + ++attempts; + tempValue ^= tempValue >> 12; + tempValue ^= tempValue << 25; + tempValue ^= tempValue >> 27; + tempValue *= 2685821657736338717ULL; + candidate = static_cast((tempValue % 30) + 1); + } + + // Fallback: find first unused + if (used[candidate]) + { + for (uint8 fallback = 1; fallback <= 30; ++fallback) + { + if (!used[fallback]) + { + candidate = fallback; + break; + } + } + } + + used[candidate] = 1; + result.set(index, candidate); + } + + return result; + } + + QTFRandomValues makeLosingNumbers(const QTFRandomValues& winningNumbers) + { + bool isWinning[31] = {}; + for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) + { + isWinning[winningNumbers.get(i)] = true; + } + + QTFRandomValues losingNumbers; + uint64 outIndex = 0; + for (uint8 candidate = 1; candidate <= QTF_MAX_RANDOM_VALUE && outIndex < QTF_RANDOM_VALUES_COUNT; ++candidate) + { + if (!isWinning[candidate]) + { + losingNumbers.set(outIndex++, candidate); + } + } + EXPECT_EQ(outIndex, static_cast(QTF_RANDOM_VALUES_COUNT)); + return losingNumbers; + } + + // Create a ticket that matches exactly `matchCount` numbers with `winningNumbers`. + // Guarantees values are unique and in [1..30]. + QTFRandomValues makeNumbersWithExactMatches(const QTFRandomValues& winningNumbers, uint8 matchCount) + { + EXPECT_LE(matchCount, static_cast(QTF_RANDOM_VALUES_COUNT)); + + bool isWinning[31] = {}; + bool used[31] = {}; + for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) + { + const uint8 v = winningNumbers.get(i); + EXPECT_GE(v, 1u); + EXPECT_LE(v, QTF_MAX_RANDOM_VALUE); + EXPECT_FALSE(isWinning[v]) << "winningNumbers must be unique"; + isWinning[v] = true; + } + + QTFRandomValues ticket; + uint64 outIndex = 0; + + // Take first `matchCount` winning numbers as the matches. + for (uint8 i = 0; i < matchCount; ++i) + { + const uint8 v = winningNumbers.get(i); + used[v] = true; + ticket.set(outIndex++, v); + } + + // Fill the remaining positions with non-winning numbers. + for (uint8 candidate = 1; candidate <= QTF_MAX_RANDOM_VALUE && outIndex < QTF_RANDOM_VALUES_COUNT; ++candidate) + { + if (!isWinning[candidate] && !used[candidate]) + { + used[candidate] = true; + ticket.set(outIndex++, candidate); + } + } + + EXPECT_EQ(outIndex, static_cast(QTF_RANDOM_VALUES_COUNT)); + + // Verify exact overlap count and uniqueness (debug safety for tests). + uint64 overlap = 0; + std::set uniqueValues; + for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) + { + const uint8 v = ticket.get(i); + EXPECT_GE(v, 1u); + EXPECT_LE(v, QTF_MAX_RANDOM_VALUE); + uniqueValues.insert(v); + if (isWinning[v]) + { + ++overlap; + } + } + EXPECT_EQ(uniqueValues.size(), static_cast(QTF_RANDOM_VALUES_COUNT)); + EXPECT_EQ(overlap, static_cast(matchCount)); + + return ticket; + } + + QTFRandomValues makeK2Numbers(const QTFRandomValues& winningNumbers) { return makeNumbersWithExactMatches(winningNumbers, 2); } + QTFRandomValues makeK3Numbers(const QTFRandomValues& winningNumbers) { return makeNumbersWithExactMatches(winningNumbers, 3); } }; +// ============================================================================ +// PRIVATE METHOD TESTS +// ============================================================================ + +TEST(ContractQThirtyFour_Private, CountMatches_CountsOverlappingNumbers) +{ + ContractTestingQTF ctl; + + QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); + QTF::GetTicketPrice_input primeIn{}; + qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + + // Include values > 8 to cover the full [1..30] bitmask range. + const QTFRandomValues player = ctl.makeValidNumbers(1, 16, 29, 30); + const QTFRandomValues winning = ctl.makeValidNumbers(16, 29, 2, 3); + const auto out = ctl.state()->callCountMatches(qpi, player, winning); + EXPECT_EQ(out.matches, 2); +} + +TEST(ContractQThirtyFour_Private, ValidateNumbers_WorksForValidDuplicateAndRangeErrors) +{ + ContractTestingQTF ctl; + + QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); + QTF::GetTicketPrice_input primeIn{}; + qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + + const QTFRandomValues ok = ctl.makeValidNumbers(1, 2, 3, 4); + EXPECT_TRUE(ctl.state()->callValidateNumbers(qpi, ok).isValid); + + QTFRandomValues dup = ctl.makeValidNumbers(1, 2, 3, 4); + dup.set(3, 2); + EXPECT_FALSE(ctl.state()->callValidateNumbers(qpi, dup).isValid); + + QTFRandomValues outOfRange = ctl.makeValidNumbers(1, 2, 3, 4); + outOfRange.set(2, 31); + EXPECT_FALSE(ctl.state()->callValidateNumbers(qpi, outOfRange).isValid); +} + +TEST(ContractQThirtyFour_Private, GetRandomValues_IsDeterministicAndUniqueInRange) +{ + ContractTestingQTF ctl; + + QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); + QTF::GetTicketPrice_input primeIn{}; + qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + + const uint64 seed = 0x123456789ABCDEF0ULL; + const auto out1 = ctl.state()->callGetRandomValues(qpi, seed); + const auto out2 = ctl.state()->callGetRandomValues(qpi, seed); + + std::set seen; + for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) + { + const uint8 v = out1.values.get(i); + EXPECT_GE(v, 1); + EXPECT_LE(v, QTF_MAX_RANDOM_VALUE); + seen.insert(v); + EXPECT_EQ(out1.values.get(i), out2.values.get(i)); + } + EXPECT_EQ(seen.size(), static_cast(QTF_RANDOM_VALUES_COUNT)); +} + +TEST(ContractQThirtyFour_Private, CheckContractBalance_UsesIncomingMinusOutgoing) +{ + ContractTestingQTF ctl; + + QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); + QTF::GetTicketPrice_input primeIn{}; + qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + + const uint64 balance = 123456; + increaseEnergy(ctl.qtfSelf(), balance); + + const auto outExact = ctl.state()->callCheckContractBalance(qpi, balance); + EXPECT_TRUE(outExact.hasEnough); + EXPECT_EQ(outExact.actualBalance, balance); + + const auto outTooHigh = ctl.state()->callCheckContractBalance(qpi, balance + 1); + EXPECT_FALSE(outTooHigh.hasEnough); + EXPECT_EQ(outTooHigh.actualBalance, balance); +} + +TEST(ContractQThirtyFour_Private, PowerFixedPoint_ComputesFastExponentiationInFixedPoint) +{ + ContractTestingQTF ctl; + + QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); + QTF::GetTicketPrice_input primeIn{}; + qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + + // 0.5^2 = 0.25 + const auto out025 = ctl.state()->callPowerFixedPoint(qpi, QTF_FIXED_POINT_SCALE / 2, 2); + EXPECT_EQ(out025.result, QTF_FIXED_POINT_SCALE / 4); + + // 2.0^3 = 8.0 + const auto out8 = ctl.state()->callPowerFixedPoint(qpi, 2 * QTF_FIXED_POINT_SCALE, 3); + EXPECT_EQ(out8.result, 8 * QTF_FIXED_POINT_SCALE); +} + +TEST(ContractQThirtyFour_Private, CalculateExpectedRoundsToK4_HandlesEdgeCaseAndMonotonicity) +{ + ContractTestingQTF ctl; + + QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); + QTF::GetTicketPrice_input primeIn{}; + qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + + const auto out0 = ctl.state()->callCalculateExpectedRoundsToK4(qpi, 0); + EXPECT_EQ(out0.expectedRounds, UINT64_MAX); + + const auto out1 = ctl.state()->callCalculateExpectedRoundsToK4(qpi, 1); + const auto out100 = ctl.state()->callCalculateExpectedRoundsToK4(qpi, 100); + EXPECT_GT(out1.expectedRounds, 0ULL); + EXPECT_GT(out100.expectedRounds, 0ULL); + EXPECT_LE(out1.expectedRounds, QTF_FIXED_POINT_SCALE); + EXPECT_LE(out100.expectedRounds, QTF_FIXED_POINT_SCALE); + EXPECT_GT(out1.expectedRounds, out100.expectedRounds); +} + +TEST(ContractQThirtyFour_Private, CalcReserveTopUp_RespectsSoftFloorPerRoundAndPerWinnerCaps) +{ + ContractTestingQTF ctl; + + QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); + QTF::GetTicketPrice_input primeIn{}; + qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + + const uint64 P = 1000000ULL; + + // Soft floor binds availableAboveFloor and per-round is 10% of total. + { + const auto out = ctl.state()->callCalcReserveTopUp(qpi, 25000000ULL, 10000000ULL, 1000000000ULL, P); + EXPECT_EQ(out.topUpAmount, 2500000ULL); + } + + // Per-winner cap binds. + { + const auto out = ctl.state()->callCalcReserveTopUp(qpi, 1000000000ULL, 50000000ULL, 1000000ULL, P); + EXPECT_EQ(out.topUpAmount, 1000000ULL); + } + + // Needed is below all caps. + { + const auto out = ctl.state()->callCalcReserveTopUp(qpi, 1000000000ULL, 12345ULL, 1000000000ULL, P); + EXPECT_EQ(out.topUpAmount, 12345ULL); + } +} + +TEST(ContractQThirtyFour_Private, CalculatePrizePools_MatchesFeeAndRakeMath) +{ + ContractTestingQTF ctl; + + QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); + QTF::GetTicketPrice_input primeIn{}; + qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + + const auto fees = ctl.getFees(); + ASSERT_NE(fees.winnerFeePercent, 0); + + const uint64 revenue = 1000000ULL; + const uint64 winnersBlockBeforeRake = (revenue * static_cast(fees.winnerFeePercent)) / 100ULL; + + { + const auto out = ctl.state()->callCalculatePrizePools(qpi, revenue, false); + EXPECT_EQ(out.winnersRake, 0ULL); + EXPECT_EQ(out.winnersBlock, winnersBlockBeforeRake); + EXPECT_EQ(out.k3Pool, (out.winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000ULL); + EXPECT_EQ(out.k2Pool, (out.winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000ULL); + } + + { + const auto out = ctl.state()->callCalculatePrizePools(qpi, revenue, true); + const uint64 expectedRake = (winnersBlockBeforeRake * QTF_FR_WINNERS_RAKE_BP) / 10000ULL; + EXPECT_EQ(out.winnersRake, expectedRake); + EXPECT_EQ(out.winnersBlock, winnersBlockBeforeRake - expectedRake); + EXPECT_EQ(out.k3Pool, (out.winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000ULL); + EXPECT_EQ(out.k2Pool, (out.winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000ULL); + } +} + +TEST(ContractQThirtyFour_Private, CalculateBaseGain_FollowsConfiguredRedirectsAndOverflowBias) +{ + ContractTestingQTF ctl; + + QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); + QTF::GetTicketPrice_input primeIn{}; + qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + + const uint64 revenue = 1000000ULL; + const uint64 winnersBlock = 680000ULL; + + const auto out = ctl.state()->callCalculateBaseGain(qpi, revenue, winnersBlock); + EXPECT_EQ(out.baseGain, 118600ULL); +} + +TEST(ContractQThirtyFour_Private, CalculateExtraRedirectBP_ReturnsZeroOrClampsToMax) +{ + ContractTestingQTF ctl; + + QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); + QTF::GetTicketPrice_input primeIn{}; + qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + + // Early exits + EXPECT_EQ(ctl.state()->callCalculateExtraRedirectBP(qpi, 0, 1, 1, 0).extraBP, 0ULL); + EXPECT_EQ(ctl.state()->callCalculateExtraRedirectBP(qpi, 1, 0, 1, 0).extraBP, 0ULL); + EXPECT_EQ(ctl.state()->callCalculateExtraRedirectBP(qpi, 1, 1, 0, 0).extraBP, 0ULL); + + // Clamp to max under large deficit. + { + const uint64 revenue = 1000000ULL; + const uint64 delta = revenue * 1000ULL; + const auto out = ctl.state()->callCalculateExtraRedirectBP(qpi, 100, delta, revenue, 0); + EXPECT_EQ(out.extraBP, QTF_FR_EXTRA_MAX_BP); + } + + // Base gain already covers required gain -> zero. + { + const auto out = ctl.state()->callCalculateExtraRedirectBP(qpi, 100, 1000ULL, 1000000ULL, 2000ULL); + EXPECT_EQ(out.extraBP, 0ULL); + } +} + +TEST(ContractQThirtyFour_Private, ProcessTierPayout_ComputesPayoutAndOptionalTopUp) +{ + ContractTestingQTF ctl; + + const id originator = id::randomValue(); + QpiContextUserProcedureCall qpi(QTF_CONTRACT_INDEX, originator, 0); + QTF::SetDrawHour_input primeIn{}; + QTF::SetDrawHour_output primeOut{}; + primeIn.newDrawHour = ctl.state()->getDrawHourInternal(); + qpi.call(QTF_PROCEDURE_SET_DRAW_HOUR, &primeIn, sizeof(primeIn)); + copyMem(&primeOut, qpi.outputBuffer, sizeof(primeOut)); + ASSERT_EQ(contractError[QTF_CONTRACT_INDEX], 0); + + // No winners -> all overflow. + { + const auto out = ctl.state()->callProcessTierPayout(qpi, 50, 0, 123, 100, 0, 1000000ULL); + EXPECT_EQ(out.perWinnerPayout, 0ULL); + EXPECT_EQ(out.overflow, 123ULL); + EXPECT_EQ(out.topUpReceived, 0ULL); + } + + // Top-up from QRP to meet floor. + { + const uint64 qrpBalanceBefore = 1000000000ULL; + increaseEnergy(ctl.qrpSelf(), qrpBalanceBefore); + + const uint64 qtfBalanceBefore = getBalance(ctl.qtfSelf()); + const uint64 qrpBalanceBeforeActual = getBalance(ctl.qrpSelf()); + + const auto out = ctl.state()->callProcessTierPayout(qpi, 50, 2, 10, 100, qrpBalanceBeforeActual, 1000000ULL); + EXPECT_EQ(out.perWinnerPayout, 50ULL); + EXPECT_EQ(out.overflow, 0ULL); + EXPECT_EQ(out.topUpReceived, 90ULL); + + EXPECT_EQ(getBalance(ctl.qtfSelf()), qtfBalanceBefore + 90); + EXPECT_EQ(getBalance(ctl.qrpSelf()), qrpBalanceBeforeActual - 90); + } +} + +TEST(ContractQThirtyFour_Private, ReturnAllTickets_RefundsEachPlayerAndClearsViaSettleEpochRevenueZeroBranch) +{ + ContractTestingQTF ctl; + + const id originator = id::randomValue(); + QpiContextUserProcedureCall qpi(QTF_CONTRACT_INDEX, originator, 0); + QTF::SetDrawHour_input primeIn{}; + primeIn.newDrawHour = ctl.state()->getDrawHourInternal(); + qpi.call(QTF_PROCEDURE_SET_DRAW_HOUR, &primeIn, sizeof(primeIn)); + ASSERT_EQ(contractError[QTF_CONTRACT_INDEX], 0); + + // Setup a few players and refund them. + const uint64 ticketPrice = 10; + ctl.state()->setTicketPriceInternal(ticketPrice); + + const id p1 = id::randomValue(); + const id p2 = id::randomValue(); + const QTFRandomValues n1 = ctl.makeValidNumbers(1, 2, 3, 4); + const QTFRandomValues n2 = ctl.makeValidNumbers(5, 6, 7, 8); + ctl.addPlayerDirect(p1, n1); + ctl.addPlayerDirect(p2, n2); + + increaseEnergy(ctl.qtfSelf(), static_cast(ticketPrice * 2)); + const uint64 balBeforeContract = getBalance(ctl.qtfSelf()); + const uint64 balBeforeP1 = getBalance(p1); + const uint64 balBeforeP2 = getBalance(p2); + + ctl.state()->callReturnAllTickets(qpi); + + EXPECT_EQ(getBalance(p1), balBeforeP1 + ticketPrice); + EXPECT_EQ(getBalance(p2), balBeforeP2 + ticketPrice); + EXPECT_EQ(getBalance(ctl.qtfSelf()), balBeforeContract - (ticketPrice * 2)); + + // Now exercise SettleEpoch revenue==0 branch, which must clear players. + ctl.state()->setTicketPriceInternal(0); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 2ULL); + ctl.state()->callSettleEpoch(qpi); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0ULL); +} + // ============================================================================ // BUY TICKET TESTS // ============================================================================ @@ -337,6 +977,26 @@ TEST(ContractQThirtyFour, BuyTicket_OverpaidPrice_AcceptsAndReturnsExcess) EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); } +TEST(ContractQThirtyFour, BuyTicket_OverpaidInvalidNumbers_RefundsFull_NoLeak) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + const uint64 overpayment = ticketPrice * 2; + increaseEnergy(user, overpayment * 2); + const uint64 balBefore = getBalance(user); + + // Invalid: out of range + const QTFRandomValues invalidNums = ctl.makeValidNumbers(1, 2, 3, 31); + const QTF::BuyTicket_output out = ctl.buyTicket(user, overpayment, invalidNums); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_NUMBERS)); + EXPECT_EQ(getBalance(user), balBefore) << "Full invocationReward must be refunded once"; + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + TEST(ContractQThirtyFour, BuyTicket_InvalidNumbers_OutOfRange_Fails) { ContractTestingQTF ctl; @@ -723,6 +1383,12 @@ TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed) ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); ctl.beginEpochWithValidTime(); + // Fix RNG so we can deterministically avoid winners (and especially k=4). + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0x1010101010101010ULL; + const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); + const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); const QTF::GetFees_output fees = ctl.getFees(); constexpr uint64 numPlayers = 10; @@ -736,9 +1402,7 @@ TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed) for (uint64 i = 0; i < numPlayers; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i % 26) + 1), static_cast((i % 26) + 2), - static_cast((i % 26) + 3), static_cast((i % 26) + 4)); - ctl.fundAndBuyTicket(user, ticketPrice, nums); + ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); } const uint64 totalRevenue = ticketPrice * numPlayers; @@ -747,6 +1411,7 @@ TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed) EXPECT_EQ(contractBalBefore, totalRevenue); + ctl.setPrevSpectrumDigest(testDigest); ctl.advanceOneDayAndDraw(); EXPECT_EQ(ctl.state()->getFrActive(), false); @@ -766,6 +1431,12 @@ TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed_FRMode) ContractTestingQTF ctl; ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + // Fix RNG so we can deterministically avoid winners (and especially k=4). + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0x2020202020202020ULL; + const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); + const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + // Activate FR mode ctl.state()->setJackpot(100000000ULL); // Below target ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); @@ -785,9 +1456,7 @@ TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed_FRMode) for (uint64 i = 0; i < numPlayers; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i % 26) + 1), static_cast((i % 26) + 2), - static_cast((i % 26) + 3), static_cast((i % 26) + 4)); - ctl.fundAndBuyTicket(user, ticketPrice, nums); + ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); } const uint64 totalRevenue = ticketPrice * numPlayers; @@ -797,6 +1466,7 @@ TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed_FRMode) EXPECT_EQ(contractBalBefore, totalRevenue); + ctl.setPrevSpectrumDigest(testDigest); ctl.advanceOneDayAndDraw(); // In FR mode, dev receives less than full 10% of revenue @@ -832,6 +1502,12 @@ TEST(ContractQThirtyFour, Settlement_JackpotGrowsFromOverflow) ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); ctl.beginEpochWithValidTime(); + // Fix RNG so we can deterministically create "no winners" tickets. + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0xBADC0FFEE0DDF00DULL; + const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); + const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); const uint64 jackpotBefore = ctl.state()->getJackpot(); constexpr uint64 numPlayers = 20; @@ -840,9 +1516,7 @@ TEST(ContractQThirtyFour, Settlement_JackpotGrowsFromOverflow) for (uint64 i = 0; i < numPlayers; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i % 25) + 1), static_cast((i % 25) + 2), - static_cast((i % 25) + 3), static_cast((i % 25) + 4)); - ctl.fundAndBuyTicket(user, ticketPrice, nums); + ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); } // Calculate expected jackpot growth in baseline mode (FR not active) @@ -852,21 +1526,17 @@ TEST(ContractQThirtyFour, Settlement_JackpotGrowsFromOverflow) // winnersBlock = revenue * winnerFeePercent / 100 (68%) const uint64 winnersBlock = (revenue * fees.winnerFeePercent) / 100; - // In baseline mode: no rake, standard tier split - // k3Pool = 40% of winnersBlock, k2Pool = 28% of winnersBlock - const uint64 k3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000; - const uint64 k2Pool = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000; - - // Remaining 32% becomes overflow - const uint64 winnersOverflow = winnersBlock - k3Pool - k2Pool; + // With no winners, the entire winners block becomes overflow (k2+k3 pools also roll into overflow). + const uint64 winnersOverflow = winnersBlock; - // In baseline mode: 50% of overflow goes to jackpot, 50% to reserve + // In baseline mode: 50% of overflow goes to jackpot, 50% to reserve. const uint64 reserveAdd = (winnersOverflow * QTF_BASELINE_OVERFLOW_ALPHA_BP) / 10000; const uint64 overflowToJackpot = winnersOverflow - reserveAdd; // Minimum expected jackpot growth (assuming no k2/k3 winners, all overflow goes to jackpot) const uint64 minExpectedGrowth = overflowToJackpot; + ctl.setPrevSpectrumDigest(testDigest); ctl.advanceOneDayAndDraw(); // Verify jackpot growth @@ -890,6 +1560,11 @@ TEST(ContractQThirtyFour, Settlement_RoundsSinceK4_Increments) ContractTestingQTF ctl; ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0x1111222233334444ULL; + const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); + const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); // Run several rounds without k=4 win @@ -900,17 +1575,15 @@ TEST(ContractQThirtyFour, Settlement_RoundsSinceK4_Increments) for (int i = 0; i < 5; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i * round % 25) + 1), static_cast((i * round % 25) + 2), - static_cast((i * round % 25) + 3), static_cast((i * round % 25) + 4)); - ctl.fundAndBuyTicket(user, ticketPrice, nums); + ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); } - const uint16 roundsBefore = ctl.state()->getFrRoundsSinceK4(); + const uint64 roundsBefore = ctl.state()->getFrRoundsSinceK4(); + ctl.setPrevSpectrumDigest(testDigest); ctl.advanceOneDayAndDraw(); - // Rounds counter should increment (unless k=4 win occurred, which is very unlikely) - // Since we can't control the winning numbers, we just check it's at least what it was - EXPECT_GE((uint64)ctl.state()->getFrRoundsSinceK4(), (uint64)roundsBefore); + // Deterministic: no ticket matches any winning number, so k=4 cannot occur. + EXPECT_EQ(ctl.state()->getFrRoundsSinceK4(), roundsBefore + 1); } } @@ -954,6 +1627,11 @@ TEST(ContractQThirtyFour, FR_Deactivation_AfterHysteresis) ContractTestingQTF ctl; ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0x3030303030303030ULL; + const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); + const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + // Set jackpot at target ctl.state()->setJackpot(QTF_DEFAULT_TARGET_JACKPOT); ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); @@ -970,18 +1648,17 @@ TEST(ContractQThirtyFour, FR_Deactivation_AfterHysteresis) for (int i = 0; i < 5; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i + round) % 25 + 1), static_cast((i + round) % 25 + 2), - static_cast((i + round) % 25 + 3), static_cast((i + round) % 25 + 4)); - ctl.fundAndBuyTicket(user, ticketPrice, nums); + ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); } // Keep jackpot at target (add back what might be paid out) ctl.state()->setJackpot(QTF_DEFAULT_TARGET_JACKPOT); + ctl.setPrevSpectrumDigest(testDigest); ctl.advanceOneDayAndDraw(); } // After 3 rounds at target, FR should deactivate - EXPECT_GE((uint64)ctl.state()->getFrRoundsAtOrAboveTarget(), (uint64)QTF_FR_HYSTERESIS_ROUNDS); + EXPECT_GE(ctl.state()->getFrRoundsAtOrAboveTarget(), QTF_FR_HYSTERESIS_ROUNDS); EXPECT_EQ(ctl.state()->getFrActive(), false); } @@ -990,6 +1667,12 @@ TEST(ContractQThirtyFour, FR_OverflowBias_95PercentToJackpot) ContractTestingQTF ctl; ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + // Fix RNG so we can deterministically create "no winners" tickets. + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0xCAFEBABEDEADBEEFULL; + const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); + const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + // Activate FR ctl.state()->setJackpot(100000000ULL); // Below target ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); @@ -1006,9 +1689,7 @@ TEST(ContractQThirtyFour, FR_OverflowBias_95PercentToJackpot) for (uint64 i = 0; i < numPlayers; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i % 25) + 1), static_cast((i % 25) + 2), - static_cast((i % 25) + 3), static_cast((i % 25) + 4)); - ctl.fundAndBuyTicket(user, ticketPrice, nums); + ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); } // Calculate expected jackpot growth @@ -1022,11 +1703,8 @@ TEST(ContractQThirtyFour, FR_OverflowBias_95PercentToJackpot) const uint64 winnersRake = (winnersBlock * QTF_FR_WINNERS_RAKE_BP) / 10000; const uint64 winnersBlockAfterRake = winnersBlock - winnersRake; - // k3Pool = 40% of winnersBlockAfterRake, k2Pool = 28% of winnersBlockAfterRake - // Remaining 32% becomes overflow - const uint64 k3Pool = (winnersBlockAfterRake * QTF_BASE_K3_SHARE_BP) / 10000; - const uint64 k2Pool = (winnersBlockAfterRake * QTF_BASE_K2_SHARE_BP) / 10000; - const uint64 winnersOverflow = winnersBlockAfterRake - k3Pool - k2Pool; + // With no winners, the entire winners block after rake becomes overflow (k2+k3 pools also roll into overflow). + const uint64 winnersOverflow = winnersBlockAfterRake; // In FR mode: 95% of overflow goes to jackpot, 5% to reserve const uint64 reserveAdd = (winnersOverflow * QTF_FR_ALPHA_BP) / 10000; @@ -1040,6 +1718,7 @@ TEST(ContractQThirtyFour, FR_OverflowBias_95PercentToJackpot) // totalJackpotContribution = overflowToJackpot + winnersRake + devRedirect + distRedirect const uint64 minExpectedGrowth = overflowToJackpot + winnersRake + devRedirect + distRedirect; + ctl.setPrevSpectrumDigest(testDigest); ctl.advanceOneDayAndDraw(); // Verify that jackpot grew by at least the minimum expected amount @@ -1130,68 +1809,76 @@ TEST(ContractQThirtyFour, WinnerData_UniqueWinningNumbers) EXPECT_EQ(winningNums.size(), QTF_RANDOM_VALUES_COUNT) << "All 4 winning numbers should be unique"; } -// ============================================================================ -// EDGE CASE TESTS -// ============================================================================ - -TEST(ContractQThirtyFour, EdgeCase_AllNumbersBoundary) +TEST(ContractQThirtyFour, WinnerData_ResetEachRound) { ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const id user = id::randomValue(); + // Round 1: force a deterministic k=2 winner so winnerCounter becomes > 0. + m256i digest1 = {}; + digest1.m256i_u64[0] = 0x13579BDF2468ACE0ULL; + const QTFRandomValues winning1 = ctl.computeWinningNumbersForDigest(digest1); - // Test boundary numbers: 1, 2, 29, 30 - QTFRandomValues boundaryNums = ctl.makeValidNumbers(1, 2, 29, 30); - ctl.fundAndBuyTicket(user, ticketPrice, boundaryNums); + ctl.beginEpochWithValidTime(); + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + QTFRandomValues k2Numbers = ctl.makeK2Numbers(winning1); + const id k2Winner = id::randomValue(); + ctl.fundAndBuyTicket(k2Winner, ticketPrice, k2Numbers); EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); -} -TEST(ContractQThirtyFour, EdgeCase_ConsecutiveNumbers) -{ - ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const id user = id::randomValue(); + ctl.setPrevSpectrumDigest(digest1); + ctl.advanceOneDayAndDraw(); - // Test consecutive numbers - QTFRandomValues consecutiveNums = ctl.makeValidNumbers(15, 16, 17, 18); - ctl.fundAndBuyTicket(user, ticketPrice, consecutiveNums); + const QTF::GetWinnerData_output afterRound1 = ctl.getWinnerData(); + EXPECT_GT(afterRound1.winnerData.winnerCounter, 0u); - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); -} + // Round 2: force a deterministic "no winners" round, winnerCounter must reset to 0. + m256i digest2 = {}; + digest2.m256i_u64[0] = 0x0F0E0D0C0B0A0908ULL; + const QTFRandomValues winning2 = ctl.computeWinningNumbersForDigest(digest2); + const QTFRandomValues losing2 = ctl.makeLosingNumbers(winning2); -TEST(ContractQThirtyFour, EdgeCase_HighestNumbers) -{ - ContractTestingQTF ctl; ctl.beginEpochWithValidTime(); + for (int i = 0; i < 5; ++i) + { + const id user = id::randomValue(); + ctl.fundAndBuyTicket(user, ticketPrice, losing2); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), i + 1u); + } - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const id user = id::randomValue(); - - // Test highest valid numbers - QTFRandomValues highNums = ctl.makeValidNumbers(27, 28, 29, 30); - ctl.fundAndBuyTicket(user, ticketPrice, highNums); + ctl.setPrevSpectrumDigest(digest2); + ctl.advanceOneDayAndDraw(); - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); + const QTF::GetWinnerData_output afterRound2 = ctl.getWinnerData(); + EXPECT_EQ(afterRound2.winnerData.winnerCounter, 0u) << "Winner snapshot must reset each round"; } -TEST(ContractQThirtyFour, EdgeCase_LowestNumbers) +// ============================================================================ +// EDGE CASE TESTS +// ============================================================================ + +TEST(ContractQThirtyFour, BuyTicket_ValidNumberSelections_EdgeCases_Success) { ContractTestingQTF ctl; ctl.beginEpochWithValidTime(); const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const id user = id::randomValue(); - // Test lowest valid numbers - QTFRandomValues lowNums = ctl.makeValidNumbers(1, 2, 3, 4); - ctl.fundAndBuyTicket(user, ticketPrice, lowNums); + static constexpr uint8 cases[][4] = { + {1, 2, 29, 30}, // boundary + {15, 16, 17, 18}, // consecutive + {27, 28, 29, 30}, // highest + {1, 2, 3, 4}, // lowest + }; - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); + for (uint64 i = 0; i < (sizeof(cases) / sizeof(cases[0])); ++i) + { + const id user = id::randomValue(); + const QTFRandomValues nums = ctl.makeValidNumbers(cases[i][0], cases[i][1], cases[i][2], cases[i][3]); + ctl.fundAndBuyTicket(user, ticketPrice, nums); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), i + 1); + } } // ============================================================================ @@ -1203,6 +1890,11 @@ TEST(ContractQThirtyFour, MultipleRounds_JackpotAccumulates) ContractTestingQTF ctl; ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0x0DDC0FFEE0DDF00DULL; + const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); + const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); uint64 prevJackpot = 0; @@ -1214,11 +1906,10 @@ TEST(ContractQThirtyFour, MultipleRounds_JackpotAccumulates) for (int i = 0; i < 10; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i + round) % 27 + 1), static_cast((i + round + 1) % 27 + 1), - static_cast((i + round + 2) % 27 + 1), static_cast((i + round + 3) % 27 + 1)); - ctl.fundAndBuyTicket(user, ticketPrice, nums); + ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); } + ctl.setPrevSpectrumDigest(testDigest); ctl.advanceOneDayAndDraw(); // Jackpot should increase each round (no k=4 winners in this test) @@ -1383,3 +2074,796 @@ TEST(ContractQThirtyFour, FeeCalculation_TotalEquals100Percent) EXPECT_EQ(total, 100u); } + +// ============================================================================ +// PRIZE PAYOUT ESTIMATION +// ============================================================================ + +TEST(ContractQThirtyFour, EstimatePrizePayouts_NoTickets) +{ + ContractTestingQTF ctl; + + // No tickets sold, should return zero payouts + QTF::EstimatePrizePayouts_output estimate = ctl.estimatePrizePayouts(1, 1); + + EXPECT_EQ(estimate.k2PayoutPerWinner, 0ull); + EXPECT_EQ(estimate.k3PayoutPerWinner, 0ull); + EXPECT_EQ(estimate.k2Pool, 0ull); + EXPECT_EQ(estimate.k3Pool, 0ull); + EXPECT_EQ(estimate.totalRevenue, 0ull); +} + +TEST(ContractQThirtyFour, EstimatePrizePayouts_WithTicketsSingleWinner) +{ + ContractTestingQTF ctl; + + ctl.beginEpochWithValidTime(); + + // Buy 100 tickets + constexpr uint64 ticketPrice = 1000000ull; // 1M QU + constexpr uint64 numTickets = 100; + + QTFRandomValues numbers; + numbers.set(0, 1); + numbers.set(1, 2); + numbers.set(2, 3); + numbers.set(3, 4); + + for (uint64 i = 0; i < numTickets; ++i) + { + id user = id::randomValue(); + increaseEnergy(user, ticketPrice); + QTF::BuyTicket_output result = ctl.buyTicket(user, ticketPrice, numbers); + EXPECT_EQ(result.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + } + + // Verify tickets were purchased + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), numTickets); + + // Estimate for 1 k2 winner and 1 k3 winner + QTF::EstimatePrizePayouts_output estimate = ctl.estimatePrizePayouts(1, 1); + + const uint64 expectedRevenue = ticketPrice * numTickets; + EXPECT_EQ(estimate.totalRevenue, expectedRevenue); + + // Check minimum floors and cap using constants from contract + constexpr uint64 expectedK2Floor = ticketPrice * QTF_K2_FLOOR_MULT / QTF_K2_FLOOR_DIV; + constexpr uint64 expectedK3Floor = ticketPrice * QTF_K3_FLOOR_MULT; + constexpr uint64 expectedCap = ticketPrice * QTF_TOPUP_PER_WINNER_CAP_MULT; + EXPECT_EQ(estimate.k2MinFloor, expectedK2Floor); + EXPECT_EQ(estimate.k3MinFloor, expectedK3Floor); + EXPECT_EQ(estimate.perWinnerCap, expectedCap); + + // Winners block using contract constants + const QTF::GetFees_output fees = ctl.getFees(); + const uint64 winnersBlock = (expectedRevenue * fees.winnerFeePercent) / 100; + const uint64 k2PoolExpected = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000; + const uint64 k3PoolExpected = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000; + + EXPECT_EQ(estimate.k2Pool, k2PoolExpected); + EXPECT_EQ(estimate.k3Pool, k3PoolExpected); + + // With 1 winner each: k2 payout equals pool (below cap), k3 payout is capped at 25*P + EXPECT_EQ(estimate.k2PayoutPerWinner, k2PoolExpected); // 19.04M < 25M cap + EXPECT_EQ(estimate.k3PayoutPerWinner, expectedCap); // 27.2M capped to 25M +} + +TEST(ContractQThirtyFour, EstimatePrizePayouts_WithMultipleWinners) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + + // Buy 1000 tickets + const uint64 ticketPrice = 1000000ull; + const uint64 numTickets = 1000; + + QTFRandomValues numbers; + numbers.set(0, 5); + numbers.set(1, 10); + numbers.set(2, 15); + numbers.set(3, 20); + + for (uint64 i = 0; i < numTickets; ++i) + { + id user = id::randomValue(); + increaseEnergy(user, ticketPrice); + QTF::BuyTicket_output result = ctl.buyTicket(user, ticketPrice, numbers); + EXPECT_EQ(result.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + } + + // Verify tickets were purchased + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), numTickets); + + // Estimate for 10 k2 winners and 5 k3 winners + QTF::EstimatePrizePayouts_output estimate = ctl.estimatePrizePayouts(10, 5); + + const uint64 expectedRevenue = ticketPrice * numTickets; + const QTF::GetFees_output fees = ctl.getFees(); + const uint64 winnersBlock = (expectedRevenue * fees.winnerFeePercent) / 100; + const uint64 k2Pool = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000; + const uint64 k3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000; + + // Verify pools + EXPECT_EQ(estimate.k2Pool, k2Pool); + EXPECT_EQ(estimate.k3Pool, k3Pool); + + // Verify per-winner payouts (should be pool / winner count, capped) + const uint64 k2ExpectedPerWinner = k2Pool / 10; + const uint64 k3ExpectedPerWinner = k3Pool / 5; + + EXPECT_EQ(estimate.k2PayoutPerWinner, std::min(k2ExpectedPerWinner, estimate.perWinnerCap)); + EXPECT_EQ(estimate.k3PayoutPerWinner, std::min(k3ExpectedPerWinner, estimate.perWinnerCap)); + + // Both should be above minimum floors + EXPECT_GE(estimate.k2PayoutPerWinner, estimate.k2MinFloor); + EXPECT_GE(estimate.k3PayoutPerWinner, estimate.k3MinFloor); +} + +TEST(ContractQThirtyFour, EstimatePrizePayouts_NoWinnersShowsPotential) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + + // Buy 50 tickets + const uint64 ticketPrice = 1000000ull; + const uint64 numTickets = 50; + + QTFRandomValues numbers; + numbers.set(0, 7); + numbers.set(1, 14); + numbers.set(2, 21); + numbers.set(3, 28); + + for (uint64 i = 0; i < numTickets; ++i) + { + id user = id::randomValue(); + increaseEnergy(user, ticketPrice); + QTF::BuyTicket_output result = ctl.buyTicket(user, ticketPrice, numbers); + EXPECT_EQ(result.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + } + + // Verify tickets were purchased + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), numTickets); + + // Estimate with 0 winners (shows what a single winner would get) + QTF::EstimatePrizePayouts_output estimate = ctl.estimatePrizePayouts(0, 0); + + const uint64 expectedRevenue = ticketPrice * numTickets; + const QTF::GetFees_output fees = ctl.getFees(); + const uint64 winnersBlock = (expectedRevenue * fees.winnerFeePercent) / 100; + const uint64 k2Pool = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000; + const uint64 k3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000; + + // When no winners specified, should show full pool (capped) + EXPECT_EQ(estimate.k2PayoutPerWinner, std::min(k2Pool, estimate.perWinnerCap)); + EXPECT_EQ(estimate.k3PayoutPerWinner, std::min(k3Pool, estimate.perWinnerCap)); +} + +// ============================================================================ +// K=4 JACKPOT WIN TESTS +// ============================================================================ + +// ============================================================================ +// DETERMINISTIC WINNER TESTING +// ============================================================================ +// Solution: By fixing prevSpectrumDigest, we can deterministically control winning numbers +// +// Background: +// Settlement generates winning numbers using: seed = K12(prevSpectrumDigest).u64._0 +// This seed is then used in GetRandomValues (QThirtyFour.h:1663-1698) to derive 4 numbers. +// +// Approach: +// 1. Create a fixed test prevSpectrumDigest (e.g., testDigest) +// 2. Call computeWinningNumbersForDigest(testDigest) to pre-compute winning numbers +// 3. Buy tickets with exact winning numbers (for k=4), partial matches (for k=2/k=3), etc. +// 4. Call setPrevSpectrumDigest(testDigest) BEFORE triggering settlement +// 5. Settlement will use our fixed digest, generating the pre-computed winning numbers +// 6. Verify actual payouts, jackpot depletion, FR resets, etc. +// +// This enables comprehensive testing of: +// ✅ Actual k=4 jackpot win payouts and jackpot depletion +// ✅ Actual k=2/k=3 winner payouts with real matching logic +// ✅ Actual FR reset behavior after k=4 win (frRoundsSinceK4 = 0) +// ✅ Pool splitting among multiple winners +// ✅ Revenue distribution and fee calculations with real winners + +TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_DepletesAndReseeds) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + + // Ensure QRP has enough reserve to reseed to target. + increaseEnergy(ctl.qrpSelf(), QTF_DEFAULT_TARGET_JACKPOT + 1000000ULL); + const uint64 qrpBalanceBefore = static_cast(getBalance(ctl.qrpSelf())); + + // Create a deterministic prevSpectrumDigest + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0x123456789ABCDEF0ULL; // Arbitrary seed + + // Pre-compute winning numbers for this digest + QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); + + // Setup: FR active with jackpot below target + const uint64 initialJackpot = 800000000ULL; // 800M QU + ctl.state()->setJackpot(initialJackpot); + ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); // 1B target + ctl.state()->setFrActive(true); + ctl.state()->setFrRoundsSinceK4(10); + // IMPORTANT: internal `state.jackpot` must be backed by actual contract balance, otherwise transfers will fail. + increaseEnergy(ctl.qtfSelf(), static_cast(initialJackpot)); + + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + // User1: Buy ticket with EXACT winning numbers (k=4 winner) + const id k4Winner = id::randomValue(); + ctl.fundAndBuyTicket(k4Winner, ticketPrice, winningNumbers); + + // User2: Buy ticket with 3 matching numbers (k=3 winner) + QTFRandomValues k3Numbers = ctl.makeK3Numbers(winningNumbers); + const id k3Winner = id::randomValue(); + ctl.fundAndBuyTicket(k3Winner, ticketPrice, k3Numbers); + + // User3: Buy ticket with 2 matching numbers (k=2 winner) + QTFRandomValues k2Numbers = ctl.makeK2Numbers(winningNumbers); + const id k2Winner = id::randomValue(); + ctl.fundAndBuyTicket(k2Winner, ticketPrice, k2Numbers); + + // User4: No match + const id loser = id::randomValue(); + QTFRandomValues loserNumbers = ctl.makeValidNumbers(1, 2, 3, 4); + ctl.fundAndBuyTicket(loser, ticketPrice, loserNumbers); + + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 4ULL); + + // Verify state before settlement + const uint64 jackpotBefore = ctl.state()->getJackpot(); + const uint64 roundsSinceK4Before = ctl.state()->getFrRoundsSinceK4(); + EXPECT_EQ(jackpotBefore, initialJackpot); + EXPECT_EQ(roundsSinceK4Before, 10u); + + // Set the deterministic prevSpectrumDigest BEFORE triggering settlement + ctl.setPrevSpectrumDigest(testDigest); + + // Trigger settlement - this will use our fixed prevSpectrumDigest + ctl.advanceOneDayAndDraw(); + + // Verify k=4 jackpot win behavior: + const uint64 jackpotAfter = ctl.state()->getJackpot(); + EXPECT_GE(jackpotAfter, QTF_DEFAULT_TARGET_JACKPOT) << "Jackpot should be reseeded from QRP after k=4 win"; + EXPECT_LT(static_cast(getBalance(ctl.qrpSelf())), qrpBalanceBefore) << "QRP reserve should decrease due to reseed"; + + // FR counters reset + const uint64 roundsSinceK4After = ctl.state()->getFrRoundsSinceK4(); + EXPECT_EQ(roundsSinceK4After, 0u) << "frRoundsSinceK4 should reset to 0 after k=4 win"; + + const uint64 roundsAtTargetAfter = ctl.state()->getFrRoundsAtOrAboveTarget(); + EXPECT_EQ(roundsAtTargetAfter, 0u) << "frRoundsAtOrAboveTarget should reset to 0 after k=4 win"; + + // 3. Verify winner data contains our winning numbers + QTF::GetWinnerData_output winnerData = ctl.getWinnerData(); + EXPECT_EQ(winnerData.winnerData.winnerValues.get(0), winningNumbers.get(0)); + EXPECT_EQ(winnerData.winnerData.winnerValues.get(1), winningNumbers.get(1)); + EXPECT_EQ(winnerData.winnerData.winnerValues.get(2), winningNumbers.get(2)); + EXPECT_EQ(winnerData.winnerData.winnerValues.get(3), winningNumbers.get(3)); + + // Verify k=4 winner received payout (full jackpot share). + const long long k4WinnerBalance = getBalance(k4Winner); + EXPECT_GE(k4WinnerBalance, static_cast(initialJackpot)); +} + +// Test k=2 and k=3 payouts with deterministic winning numbers +TEST(ContractQThirtyFour, DeterministicWinner_K2K3Payouts_VerifyRevenueSplit) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + + // This test validates baseline k2/k3 pool splitting (no FR rake). + // Force FR activation window to be expired so SettleEpoch cannot auto-enable FR. + ctl.state()->setFrActive(false); + ctl.state()->setFrRoundsSinceK4(QTF_FR_POST_K4_WINDOW_ROUNDS); + + // Create deterministic prevSpectrumDigest + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0xFEDCBA9876543210ULL; // Different seed + + // Pre-compute winning numbers + QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); + + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + // Create multiple k=2 and k=3 winners to test pool splitting + // 2 k=3 winners + QTFRandomValues k3Numbers1 = ctl.makeK3Numbers(winningNumbers); + const id k3Winner1 = id::randomValue(); + ctl.fundAndBuyTicket(k3Winner1, ticketPrice, k3Numbers1); + + QTFRandomValues k3Numbers2 = ctl.makeK3Numbers(winningNumbers); + // Ensure two different k3 tickets (avoid identical picks across players). + if (k3Numbers2.get(0) == k3Numbers1.get(0) && k3Numbers2.get(1) == k3Numbers1.get(1) && k3Numbers2.get(2) == k3Numbers1.get(2) + && k3Numbers2.get(3) == k3Numbers1.get(3)) + { + k3Numbers2 = ctl.makeNumbersWithExactMatches(winningNumbers, 3); + // Swap a non-winning position deterministically: replace last entry with next available losing number. + const QTFRandomValues losing = ctl.makeLosingNumbers(winningNumbers); + k3Numbers2.set(3, losing.get(1)); + } + const id k3Winner2 = id::randomValue(); + ctl.fundAndBuyTicket(k3Winner2, ticketPrice, k3Numbers2); + + // 3 k=2 winners + QTFRandomValues k2Numbers1 = ctl.makeK2Numbers(winningNumbers); + const id k2Winner1 = id::randomValue(); + ctl.fundAndBuyTicket(k2Winner1, ticketPrice, k2Numbers1); + + QTFRandomValues k2Numbers2 = ctl.makeK2Numbers(winningNumbers); + // Make it different from k2Numbers1 while keeping exactly 2 matches. + if (k2Numbers2.get(0) == k2Numbers1.get(0) && k2Numbers2.get(1) == k2Numbers1.get(1) && k2Numbers2.get(2) == k2Numbers1.get(2) + && k2Numbers2.get(3) == k2Numbers1.get(3)) + { + const QTFRandomValues losing = ctl.makeLosingNumbers(winningNumbers); + k2Numbers2.set(2, losing.get(0)); + } + const id k2Winner2 = id::randomValue(); + ctl.fundAndBuyTicket(k2Winner2, ticketPrice, k2Numbers2); + + QTFRandomValues k2Numbers3 = ctl.makeK2Numbers(winningNumbers); + // Make it different from previous k2 tickets while keeping exactly 2 matches. + if ((k2Numbers3.get(0) == k2Numbers1.get(0) && k2Numbers3.get(1) == k2Numbers1.get(1) && k2Numbers3.get(2) == k2Numbers1.get(2) + && k2Numbers3.get(3) == k2Numbers1.get(3)) + || (k2Numbers3.get(0) == k2Numbers2.get(0) && k2Numbers3.get(1) == k2Numbers2.get(1) && k2Numbers3.get(2) == k2Numbers2.get(2) + && k2Numbers3.get(3) == k2Numbers2.get(3))) + { + const QTFRandomValues losing = ctl.makeLosingNumbers(winningNumbers); + k2Numbers3.set(3, losing.get(2)); + } + const id k2Winner3 = id::randomValue(); + ctl.fundAndBuyTicket(k2Winner3, ticketPrice, k2Numbers3); + + // 5 losers (no matches) + for (int i = 0; i < 5; ++i) + { + const id loser = id::randomValue(); + QTFRandomValues loserNumbers = ctl.makeValidNumbers(1, 2, 3, 4); + ctl.fundAndBuyTicket(loser, ticketPrice, loserNumbers); + } + + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 10ULL); + + // Calculate expected pools + const uint64 revenue = ticketPrice * 10; + const QTF::GetFees_output fees = ctl.getFees(); + const uint64 winnersBlock = (revenue * fees.winnerFeePercent) / 100; // 68% + const uint64 expectedK2Pool = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000; // 28% of winners block + const uint64 expectedK3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000; // 40% of winners block + + // Set deterministic prevSpectrumDigest + ctl.setPrevSpectrumDigest(testDigest); + + // Get balances before settlement + const long long k3Winner1Before = getBalance(k3Winner1); + const long long k2Winner1Before = getBalance(k2Winner1); + + // Trigger settlement + ctl.advanceOneDayAndDraw(); + + // Verify winner payouts + // k=3 pool split between 2 winners + const uint64 expectedK3PayoutPerWinner = expectedK3Pool / 2; + const long long k3Winner1After = getBalance(k3Winner1); + const long long k3Winner1Gained = k3Winner1After - k3Winner1Before; + EXPECT_EQ(static_cast(k3Winner1Gained), expectedK3PayoutPerWinner) << "k=3 winner should receive half of k3 pool"; + + // k=2 pool split between 3 winners + const uint64 expectedK2PayoutPerWinner = expectedK2Pool / 3; + const long long k2Winner1After = getBalance(k2Winner1); + const long long k2Winner1Gained = k2Winner1After - k2Winner1Before; + EXPECT_EQ(static_cast(k2Winner1Gained), expectedK2PayoutPerWinner) << "k=2 winner should receive one-third of k2 pool"; + + // Verify winning numbers in winner data + QTF::GetWinnerData_output winnerData = ctl.getWinnerData(); + EXPECT_EQ(winnerData.winnerData.winnerValues.get(0), winningNumbers.get(0)); + EXPECT_EQ(winnerData.winnerData.winnerValues.get(1), winningNumbers.get(1)); + EXPECT_EQ(winnerData.winnerData.winnerValues.get(2), winningNumbers.get(2)); + EXPECT_EQ(winnerData.winnerData.winnerValues.get(3), winningNumbers.get(3)); + + // Jackpot should have grown (no k=4 winner) + EXPECT_GT(ctl.state()->getJackpot(), 0ULL); +} + +TEST(ContractQThirtyFour, Settlement_NoWinners_JackpotGrowsAndCounterIncrements) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0x9999AAAABBBBCCCCULL; + const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); + const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + + // Setup: FR active with jackpot below target + ctl.state()->setJackpot(800000000ULL); // 800M QU jackpot + ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); // 1B target + ctl.state()->setFrActive(true); + ctl.state()->setFrRoundsSinceK4(10); + + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const uint64 jackpotBefore = ctl.state()->getJackpot(); + EXPECT_EQ(jackpotBefore, 800000000ULL); + + constexpr int numPlayers = 50; + for (int i = 0; i < numPlayers; ++i) + { + const id user = id::randomValue(); + ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); + } + + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), static_cast(numPlayers)); + + const uint64 roundsSinceK4Before = ctl.state()->getFrRoundsSinceK4(); + EXPECT_EQ(roundsSinceK4Before, 10u); + + ctl.setPrevSpectrumDigest(testDigest); + ctl.advanceOneDayAndDraw(); + + // After settlement (deterministic: no k=4 win is possible): + const uint64 jackpotAfter = ctl.state()->getJackpot(); + EXPECT_GT(jackpotAfter, jackpotBefore) << "Jackpot should grow when no k=4 winner"; + + const uint64 roundsSinceK4After = ctl.state()->getFrRoundsSinceK4(); + EXPECT_EQ(roundsSinceK4After, roundsSinceK4Before + 1) << "Counter should increment when no k=4 win"; + + // Note: This test verifies the no-win path. A full k=4 win test would require + // either mocking K12 output or extensive probabilistic testing with many rounds. + // The k=4 win logic in SettleEpoch (lines 1417-1444) handles: + // - Jackpot payout: jackpot / countK4 + // - Depletion: state.jackpot = 0 + // - Counter reset: frRoundsSinceK4 = 0, frRoundsAtOrAboveTarget = 0 + // - QRP reseed: request min(QRP balance, targetJackpot) +} + +TEST(ContractQThirtyFour, EstimatePrizePayouts_FRMode_AppliesRakeToPools) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + // Enable FR so EstimatePrizePayouts applies the 5% winners rake. + ctl.state()->setJackpot(QTF_DEFAULT_TARGET_JACKPOT / 2); + ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); + ctl.state()->setFrActive(true); + ctl.state()->setFrRoundsSinceK4(1); + + constexpr uint64 numPlayers = 100; + for (uint64 i = 0; i < numPlayers; ++i) + { + const id user = id::randomValue(); + QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i % 26) + 1), static_cast((i % 26) + 2), + static_cast((i % 26) + 3), static_cast((i % 26) + 4)); + ctl.fundAndBuyTicket(user, ticketPrice, nums); + } + + const QTF::EstimatePrizePayouts_output estimate = ctl.estimatePrizePayouts(0, 0); + + const uint64 revenue = ticketPrice * numPlayers; + const QTF::GetFees_output fees = ctl.getFees(); + const uint64 winnersBlock = (revenue * fees.winnerFeePercent) / 100; + const uint64 winnersRake = (winnersBlock * QTF_FR_WINNERS_RAKE_BP) / 10000; + const uint64 winnersBlockAfterRake = winnersBlock - winnersRake; + + const uint64 expectedK2Pool = (winnersBlockAfterRake * QTF_BASE_K2_SHARE_BP) / 10000; + const uint64 expectedK3Pool = (winnersBlockAfterRake * QTF_BASE_K3_SHARE_BP) / 10000; + + EXPECT_EQ(estimate.totalRevenue, revenue); + EXPECT_EQ(estimate.k2Pool, expectedK2Pool); + EXPECT_EQ(estimate.k3Pool, expectedK3Pool); +} + +// ============================================================================ +// RESERVE TOP-UP AND FLOOR GUARANTEE TESTS +// ============================================================================ + +TEST(ContractQThirtyFour, ReserveTopUp_FloorGuarantee_VerifyLimits) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + // Add only 2 players to create small prize pools + // With 2 tickets, revenue = 2M, winners block = 1.36M (68%) + // k2 pool = 1.36M * 28% = 380.8k, k3 pool = 1.36M * 40% = 544k + // These are below floor requirements for multiple winners + constexpr int numPlayers = 2; + for (int i = 0; i < numPlayers; ++i) + { + const id user = id::randomValue(); + QTFRandomValues nums = + ctl.makeValidNumbers(static_cast(i + 1), static_cast(i + 10), static_cast(i + 15), static_cast(i + 20)); + ctl.fundAndBuyTicket(user, ticketPrice, nums); + } + + const QTF::GetPools_output poolsBefore = ctl.getPools(); + const uint64 revenue = ticketPrice * numPlayers; + + // Calculate expected pools + const QTF::GetFees_output fees = ctl.getFees(); + const uint64 winnersBlock = (revenue * fees.winnerFeePercent) / 100; + const uint64 expectedK2Pool = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000; + const uint64 expectedK3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000; + + // Verify floors + const uint64 k2Floor = ticketPrice * QTF_K2_FLOOR_MULT / QTF_K2_FLOOR_DIV; // 0.5*P = 500k + const uint64 k3Floor = ticketPrice * QTF_K3_FLOOR_MULT; // 5*P = 5M + + // If we hypothetically had 2 k2 winners: floor requirement = 2 * 500k = 1M + // But k2 pool is only ~380k, so would need ~620k from reserve + const uint64 k2FloorTotal2Winners = k2Floor * 2; // 1M + EXPECT_LT(expectedK2Pool, k2FloorTotal2Winners) << "k2 pool should be insufficient for 2 winners with floor"; + + // If we hypothetically had 1 k3 winner: floor requirement = 5M + // But k3 pool is only ~544k, so would need ~4.46M from reserve + EXPECT_LT(expectedK3Pool, k3Floor) << "k3 pool should be insufficient for 1 winner with floor"; + + // Per-winner cap verification + const uint64 perWinnerCap = ticketPrice * QTF_TOPUP_PER_WINNER_CAP_MULT; // 25M + EXPECT_EQ(perWinnerCap, 25000000ULL); + + // Reserve safety limits + const uint64 softFloor = ticketPrice * QTF_RESERVE_SOFT_FLOOR_MULT; // 20*P = 20M + EXPECT_EQ(softFloor, 20000000ULL); + + // Note: In actual settlement with winners, the ProcessTierPayout function (QThirtyFour.h:1795-1837) + // would call CalcReserveTopUp (lines 1740-1777) to determine how much to request from QRP. + // The top-up is capped at: + // 1. 10% of QRP balance per round (QTF_TOPUP_RESERVE_PCT_BP) + // 2. Soft floor: don't deplete QRP below 20*P (QTF_RESERVE_SOFT_FLOOR_MULT) + // 3. Per-winner cap: max 25*P per winner (QTF_TOPUP_PER_WINNER_CAP_MULT) + + // This test verifies the floor requirements exist and are calculated correctly. + // Full integration test would require actual winner detection (probabilistic) + // or mocking the random number generation to guarantee specific winners. +} + +// ============================================================================ +// HIGH-DEFICIT FR EXTRA REDIRECTS TESTS +// ============================================================================ + +TEST(ContractQThirtyFour, FR_HighDeficit_ExtraRedirectsCalculated) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + + // Fix RNG so we can deterministically avoid winners (and especially k=4). + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0x4040404040404040ULL; + const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); + const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + + // Setup: High deficit scenario + // Jackpot = 0, Target = 1B, FR active + ctl.state()->setJackpot(0ULL); // Empty jackpot + ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); // 1B target + ctl.state()->setFrActive(true); + ctl.state()->setFrRoundsSinceK4(5); + + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const QTF::GetFees_output fees = ctl.getFees(); + + // Add many players to generate high revenue + constexpr int numPlayers = 500; + for (int i = 0; i < numPlayers; ++i) + { + const id user = id::randomValue(); + ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); + } + + const uint64 revenue = ticketPrice * numPlayers; // 500M QU + const uint64 deficit = QTF_DEFAULT_TARGET_JACKPOT - 0; // 1B deficit + + // With high deficit (1B) and significant revenue (500M), extra redirects should be calculated + // Formula (from spec and QThirtyFour.h:1928-1965): + // - deficit Δ = 1B + // - E_k4(500) ≈ 55 rounds (expected rounds to k=4 with 500 tickets) + // - horizon H = min(55, 50) = 50 (capped) + // - required gain per round = Δ/H = 1B/50 = 20M + // - base gain (without extra) ≈ 1% dev + 1% dist + 5% rake + 95% overflow + // ≈ 5M + 5M + 17M + ~98M = ~125M (rough estimate) + // - Since base gain (125M) > required (20M), extra might be 0 or small + // But let's verify the mechanism is working + + const uint64 devBalBefore = getBalance(QTF_DEV_ADDRESS); + const uint64 jackpotBefore = ctl.state()->getJackpot(); + EXPECT_EQ(jackpotBefore, 0ULL); + + ctl.setPrevSpectrumDigest(testDigest); + ctl.advanceOneDayAndDraw(); + + // After settlement with FR active and high deficit: + const uint64 devBalAfter = getBalance(QTF_DEV_ADDRESS); + const uint64 jackpotAfter = ctl.state()->getJackpot(); + + // Verify FR is still active + EXPECT_EQ(ctl.state()->getFrActive(), true); + + // Dev should receive less than full 10% of revenue due to FR redirects + const uint64 fullDevPayout = (revenue * fees.teamFeePercent) / 100; // 50M (10% of 500M) + const uint64 actualDevPayout = devBalAfter - devBalBefore; + + // Base redirect alone is 1% of revenue = 5M + const uint64 baseDevRedirect = (revenue * QTF_FR_DEV_REDIRECT_BP) / 10000; // 5M + EXPECT_LT(actualDevPayout, fullDevPayout) << "Dev should receive less than full 10% in FR mode"; + EXPECT_LE(actualDevPayout, fullDevPayout - baseDevRedirect) << "Dev redirect should be at least base 1%"; + + // Jackpot should have grown significantly from: + // - Winners rake (5% of 340M winners block = 17M) + // - Dev/Dist redirects (base 1% each + possible extra) + // - Overflow bias (95% of overflow) + EXPECT_GT(jackpotAfter, 100000000ULL) << "Jackpot should grow by at least 100M from FR mechanisms"; + + // Verify extra redirect cap: dev redirect should not exceed base (1%) + extra max (0.35%) = 1.35% total + const uint64 maxDevRedirectTotal = (revenue * (QTF_FR_DEV_REDIRECT_BP + QTF_FR_EXTRA_MAX_BP / 2)) / 10000; // 1.35% + const uint64 actualDevRedirect = fullDevPayout - actualDevPayout; + EXPECT_LE(actualDevRedirect, maxDevRedirectTotal) << "Dev redirect should not exceed 1.35% of revenue"; + + // Note: The exact extra redirect amount depends on complex calculation in CalculateExtraRedirectBP + // (QThirtyFour.h:1928-1965), which uses fixed-point arithmetic, power calculations, and horizon capping. + // This test verifies the mechanism is active and within bounds. +} + +// ============================================================================ +// POST-K4 WINDOW EXPIRY TESTS +// ============================================================================ + +TEST(ContractQThirtyFour, FR_PostK4WindowExpiry_DoesNotActivateWhenInactive) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0xABCDABCDABCDABCDULL; + const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); + const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + + // Setup: Jackpot below target, but window expired and FR inactive. + ctl.state()->setJackpot(QTF_DEFAULT_TARGET_JACKPOT / 2); + ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); + ctl.state()->setFrActive(false); + ctl.state()->setFrRoundsSinceK4(QTF_FR_POST_K4_WINDOW_ROUNDS); + + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + constexpr int numPlayers = 10; + for (int i = 0; i < numPlayers; ++i) + { + const id user = id::randomValue(); + ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); + } + + ctl.setPrevSpectrumDigest(testDigest); + ctl.advanceOneDayAndDraw(); + + EXPECT_EQ(ctl.state()->getFrActive(), false); + EXPECT_EQ(ctl.state()->getFrRoundsSinceK4(), QTF_FR_POST_K4_WINDOW_ROUNDS + 1); +} + +TEST(ContractQThirtyFour, FR_PostK4WindowExpiry_DoesNotReactivateWhenWindowExpired) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0xFACEFEEDFACEFEEDULL; + const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); + const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + + // Setup: FR active, jackpot below target, but approaching window expiry + ctl.state()->setJackpot(QTF_DEFAULT_TARGET_JACKPOT / 2); // 500M (below target) + ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); // 1B target + ctl.state()->setFrActive(true); + ctl.state()->setFrRoundsSinceK4(QTF_FR_POST_K4_WINDOW_ROUNDS - 1); // One round before window expiry (50 = QTF_FR_POST_K4_WINDOW_ROUNDS) + + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + // Add players + constexpr int numPlayers = 10; + for (int i = 0; i < numPlayers; ++i) + { + const id user = id::randomValue(); + ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); + } + + // Verify FR is active before settlement + EXPECT_EQ(ctl.state()->getFrActive(), true); + EXPECT_EQ(ctl.state()->getFrRoundsSinceK4(), QTF_FR_POST_K4_WINDOW_ROUNDS - 1); + EXPECT_LT(ctl.state()->getJackpot(), ctl.state()->getTargetJackpotInternal()); + + ctl.setPrevSpectrumDigest(testDigest); + ctl.advanceOneDayAndDraw(); + + // After settlement (deterministic: no k=4 win is possible): + // - roundsSinceK4 should increment to 50 + // - FR should remain active this round (activation check happens BEFORE increment) + // But in the NEXT round, FR won't activate because roundsSinceK4 >= 50 + + const uint64 roundsSinceK4After = ctl.state()->getFrRoundsSinceK4(); + EXPECT_EQ(roundsSinceK4After, QTF_FR_POST_K4_WINDOW_ROUNDS) << "Counter should increment to 50 after draw"; + + // FR activation logic (QThirtyFour.h:1236-1245): + // shouldActivateFR = (jackpot < target) AND (roundsSinceK4 < 50) + // At roundsSinceK4 = 50, condition is false, so FR won't activate in next round + + // Run one more round: FR cannot re-activate, so state should not change + ctl.beginEpochWithValidTime(); + + for (int i = 0; i < numPlayers; ++i) + { + const id user = id::randomValue(); + ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); + } + + ctl.setPrevSpectrumDigest(testDigest); + ctl.advanceOneDayAndDraw(); + + // After second round: + // - Jackpot still below target + // - roundsSinceK4 = 51 (>= 50) + EXPECT_EQ(ctl.state()->getFrRoundsSinceK4(), QTF_FR_POST_K4_WINDOW_ROUNDS + 1); + + // Important: FR deactivation logic (QThirtyFour.h:1236-1245) + // FR.frActive is set to TRUE only if: (jackpot < target AND roundsSinceK4 < 50) + // FR.frActive is set to FALSE only if: frRoundsAtOrAboveTarget >= 3 + // Otherwise, frActive retains its previous state. + // + // In this test: + // - shouldActivateFR = false (because roundsSinceK4 >= 50) + // - frRoundsAtOrAboveTarget = 0 (because jackpot < target) + // - Neither activation nor deactivation condition met + // - FR remains in previous state (true) + // + // This means FR doesn't automatically deactivate when window expires, + // but it won't RE-ACTIVATE in future rounds while roundsSinceK4 >= 50. + + // The key behavior verified by this test: + // Once roundsSinceK4 >= 50, FR will NOT be re-activated (shouldActivateFR = false) + // even if jackpot drops below target again. FR stays in whatever state it was. + + // To fully deactivate FR after window expiry, jackpot must reach target + // and stay there for 3 rounds (hysteresis). Let's verify that FR won't re-activate: + + const bool frActiveBeforeThirdRound = ctl.state()->getFrActive(); + + // Run a third round - FR should remain in same state (no re-activation) + ctl.beginEpochWithValidTime(); + for (int i = 0; i < numPlayers; ++i) + { + const id user = id::randomValue(); + ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); + } + ctl.setPrevSpectrumDigest(testDigest); + ctl.advanceOneDayAndDraw(); + + // FR state should not change (no re-activation possible when roundsSinceK4 >= 50) + EXPECT_EQ(ctl.state()->getFrActive(), frActiveBeforeThirdRound) << "FR should not re-activate when roundsSinceK4 >= 50, even if jackpot < target"; + EXPECT_EQ(ctl.state()->getFrRoundsSinceK4(), QTF_FR_POST_K4_WINDOW_ROUNDS + 2); + + // This test verifies the post-k4 window mechanism: + // FR can only be ACTIVATED within 50 rounds after last k=4 win. + // After 50 rounds, FR won't re-activate regardless of jackpot level, + // until the next k=4 win resets the counter. + // However, if FR was already active, it stays active until hysteresis deactivates it. +} From 4b0c229f14346aa7adc45ce99f85bac265283a08 Mon Sep 17 00:00:00 2001 From: N-010 Date: Tue, 16 Dec 2025 15:58:06 +0300 Subject: [PATCH 14/77] Removes SettlementLocals and aligned_storage_t --- src/contracts/QThirtyFour.h | 13 ++----- test/contract_qtf.cpp | 77 ++++++++++++++++--------------------- 2 files changed, 36 insertions(+), 54 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 208251b2e..d8d41d80c 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -590,7 +590,7 @@ struct QTF : public ContractBase CalculatePrizePools_output calcPoolsOutput; }; - struct SettlementLocals + struct SettleEpoch_locals { QTFRandomValues winningValues; ReturnAllTickets_input returnAllTicketsInput; @@ -669,10 +669,6 @@ struct QTF : public ContractBase Array cachedMatches; }; - struct SettleEpoch_locals : public SettlementLocals - { - }; - struct END_EPOCH_locals { SettleEpoch_locals settlement; @@ -1139,10 +1135,7 @@ struct QTF : public ContractBase } } - static void clearWinerData(QTF& state) - { - setMemory(state.lastWinnerData, 0); - } + static void clearWinerData(QTF& state) { setMemory(state.lastWinnerData, 0); } static void fillWinnerData(QTF& state, const PlayerData& playerData, const QTFRandomValues& winnerValues, const uint16& epoch) { @@ -1569,7 +1562,7 @@ struct QTF : public ContractBase { locals.randomValue = input.playerValues.get(locals.i); ASSERT(locals.randomValue > 0 && locals.randomValue <= QTF_MAX_RANDOM_VALUE); - + locals.maskA |= 1u << locals.randomValue; } diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp index ca8018066..393c6d865 100644 --- a/test/contract_qtf.cpp +++ b/test/contract_qtf.cpp @@ -64,9 +64,8 @@ class QTFChecker : public QTF { ValidateNumbers_input input{}; ValidateNumbers_output output{}; - std::aligned_storage_t localsStorage; - auto& locals = *reinterpret_cast(&localsStorage); - setMemory(locals, 0); + ValidateNumbers_locals locals{}; + input.numbers = numbers; ValidateNumbers(qpi, *this, input, output, locals); return output; @@ -76,9 +75,8 @@ class QTFChecker : public QTF { GetRandomValues_input input{}; GetRandomValues_output output{}; - std::aligned_storage_t localsStorage; - auto& locals = *reinterpret_cast(&localsStorage); - setMemory(locals, 0); + GetRandomValues_locals locals{}; + input.seed = seed; GetRandomValues(qpi, *this, input, output, locals); return output; @@ -89,9 +87,8 @@ class QTFChecker : public QTF { CountMatches_input input{}; CountMatches_output output{}; - std::aligned_storage_t localsStorage; - auto& locals = *reinterpret_cast(&localsStorage); - setMemory(locals, 0); + CountMatches_locals locals{}; + input.playerValues = playerValues; input.winningValues = winningValues; CountMatches(qpi, *this, input, output, locals); @@ -102,9 +99,8 @@ class QTFChecker : public QTF { CheckContractBalance_input input{}; CheckContractBalance_output output{}; - std::aligned_storage_t localsStorage; - auto& locals = *reinterpret_cast(&localsStorage); - setMemory(locals, 0); + CheckContractBalance_locals locals{}; + input.expectedRevenue = expectedRevenue; CheckContractBalance(qpi, *this, input, output, locals); return output; @@ -114,9 +110,8 @@ class QTFChecker : public QTF { PowerFixedPoint_input input{}; PowerFixedPoint_output output{}; - std::aligned_storage_t localsStorage; - auto& locals = *reinterpret_cast(&localsStorage); - setMemory(locals, 0); + PowerFixedPoint_locals locals{}; + input.base = base; input.exp = exp; PowerFixedPoint(qpi, *this, input, output, locals); @@ -127,9 +122,8 @@ class QTFChecker : public QTF { CalculateExpectedRoundsToK4_input input{}; CalculateExpectedRoundsToK4_output output{}; - std::aligned_storage_t localsStorage; - auto& locals = *reinterpret_cast(&localsStorage); - setMemory(locals, 0); + CalculateExpectedRoundsToK4_locals locals{}; + input.N = N; CalculateExpectedRoundsToK4(qpi, *this, input, output, locals); return output; @@ -140,9 +134,8 @@ class QTFChecker : public QTF { CalcReserveTopUp_input input{}; CalcReserveTopUp_output output{}; - std::aligned_storage_t localsStorage; - auto& locals = *reinterpret_cast(&localsStorage); - setMemory(locals, 0); + CalcReserveTopUp_locals locals{}; + input.totalQRPBalance = totalQRPBalance; input.needed = needed; input.perWinnerCapTotal = perWinnerCapTotal; @@ -155,9 +148,8 @@ class QTFChecker : public QTF { CalculatePrizePools_input input{}; CalculatePrizePools_output output{}; - std::aligned_storage_t localsStorage; - auto& locals = *reinterpret_cast(&localsStorage); - setMemory(locals, 0); + CalculatePrizePools_locals locals{}; + input.revenue = revenue; input.applyFRRake = applyFRRake; CalculatePrizePools(qpi, *this, input, output, locals); @@ -168,9 +160,8 @@ class QTFChecker : public QTF { CalculateBaseGain_input input{}; CalculateBaseGain_output output{}; - std::aligned_storage_t localsStorage; - auto& locals = *reinterpret_cast(&localsStorage); - setMemory(locals, 0); + CalculateBaseGain_locals locals{}; + input.revenue = revenue; input.winnersBlock = winnersBlock; CalculateBaseGain(qpi, *this, input, output, locals); @@ -182,9 +173,8 @@ class QTFChecker : public QTF { CalculateExtraRedirectBP_input input{}; CalculateExtraRedirectBP_output output{}; - std::aligned_storage_t localsStorage; - auto& locals = *reinterpret_cast(&localsStorage); - setMemory(locals, 0); + CalculateExtraRedirectBP_locals locals{}; + input.N = N; input.delta = delta; input.revenue = revenue; @@ -197,9 +187,8 @@ class QTFChecker : public QTF { ReturnAllTickets_input input{}; ReturnAllTickets_output output{}; - std::aligned_storage_t localsStorage; - auto& locals = *reinterpret_cast(&localsStorage); - setMemory(locals, 0); + ReturnAllTickets_locals locals{}; + ReturnAllTickets(qpi, *this, input, output, locals); } @@ -208,9 +197,8 @@ class QTFChecker : public QTF { ProcessTierPayout_input input{}; ProcessTierPayout_output output{}; - std::aligned_storage_t localsStorage; - auto& locals = *reinterpret_cast(&localsStorage); - setMemory(locals, 0); + ProcessTierPayout_locals locals{}; + input.floorPerWinner = floorPerWinner; input.winnerCount = winnerCount; input.payoutPool = payoutPool; @@ -228,6 +216,7 @@ class QTFChecker : public QTF std::aligned_storage_t localsStorage; auto& locals = *reinterpret_cast(&localsStorage); setMemory(locals, 0); + SettleEpoch(qpi, *this, input, output, locals); } }; @@ -2383,8 +2372,8 @@ TEST(ContractQThirtyFour, DeterministicWinner_K2K3Payouts_VerifyRevenueSplit) QTFRandomValues k3Numbers2 = ctl.makeK3Numbers(winningNumbers); // Ensure two different k3 tickets (avoid identical picks across players). - if (k3Numbers2.get(0) == k3Numbers1.get(0) && k3Numbers2.get(1) == k3Numbers1.get(1) && k3Numbers2.get(2) == k3Numbers1.get(2) - && k3Numbers2.get(3) == k3Numbers1.get(3)) + if (k3Numbers2.get(0) == k3Numbers1.get(0) && k3Numbers2.get(1) == k3Numbers1.get(1) && k3Numbers2.get(2) == k3Numbers1.get(2) && + k3Numbers2.get(3) == k3Numbers1.get(3)) { k3Numbers2 = ctl.makeNumbersWithExactMatches(winningNumbers, 3); // Swap a non-winning position deterministically: replace last entry with next available losing number. @@ -2401,8 +2390,8 @@ TEST(ContractQThirtyFour, DeterministicWinner_K2K3Payouts_VerifyRevenueSplit) QTFRandomValues k2Numbers2 = ctl.makeK2Numbers(winningNumbers); // Make it different from k2Numbers1 while keeping exactly 2 matches. - if (k2Numbers2.get(0) == k2Numbers1.get(0) && k2Numbers2.get(1) == k2Numbers1.get(1) && k2Numbers2.get(2) == k2Numbers1.get(2) - && k2Numbers2.get(3) == k2Numbers1.get(3)) + if (k2Numbers2.get(0) == k2Numbers1.get(0) && k2Numbers2.get(1) == k2Numbers1.get(1) && k2Numbers2.get(2) == k2Numbers1.get(2) && + k2Numbers2.get(3) == k2Numbers1.get(3)) { const QTFRandomValues losing = ctl.makeLosingNumbers(winningNumbers); k2Numbers2.set(2, losing.get(0)); @@ -2412,10 +2401,10 @@ TEST(ContractQThirtyFour, DeterministicWinner_K2K3Payouts_VerifyRevenueSplit) QTFRandomValues k2Numbers3 = ctl.makeK2Numbers(winningNumbers); // Make it different from previous k2 tickets while keeping exactly 2 matches. - if ((k2Numbers3.get(0) == k2Numbers1.get(0) && k2Numbers3.get(1) == k2Numbers1.get(1) && k2Numbers3.get(2) == k2Numbers1.get(2) - && k2Numbers3.get(3) == k2Numbers1.get(3)) - || (k2Numbers3.get(0) == k2Numbers2.get(0) && k2Numbers3.get(1) == k2Numbers2.get(1) && k2Numbers3.get(2) == k2Numbers2.get(2) - && k2Numbers3.get(3) == k2Numbers2.get(3))) + if ((k2Numbers3.get(0) == k2Numbers1.get(0) && k2Numbers3.get(1) == k2Numbers1.get(1) && k2Numbers3.get(2) == k2Numbers1.get(2) && + k2Numbers3.get(3) == k2Numbers1.get(3)) || + (k2Numbers3.get(0) == k2Numbers2.get(0) && k2Numbers3.get(1) == k2Numbers2.get(1) && k2Numbers3.get(2) == k2Numbers2.get(2) && + k2Numbers3.get(3) == k2Numbers2.get(3))) { const QTFRandomValues losing = ctl.makeLosingNumbers(winningNumbers); k2Numbers3.set(3, losing.get(2)); From 6aaa82d6934fb805fa0ffc355d8315f31540679d Mon Sep 17 00:00:00 2001 From: N-010 Date: Tue, 16 Dec 2025 19:37:08 +0300 Subject: [PATCH 15/77] Updates tests --- test/contract_qrp.cpp | 131 ++++--- test/contract_qtf.cpp | 786 ++++++++++++++++++------------------------ 2 files changed, 420 insertions(+), 497 deletions(-) diff --git a/test/contract_qrp.cpp b/test/contract_qrp.cpp index 355d7b36e..04bfc6986 100644 --- a/test/contract_qrp.cpp +++ b/test/contract_qrp.cpp @@ -2,6 +2,14 @@ #include "contract_testing.h" +// Procedure/function indices (must match REGISTER_USER_FUNCTIONS_AND_PROCEDURES in `src/contracts/QReservePool.h`). +constexpr uint16 QRP_PROC_GET_RESERVE = 1; +constexpr uint16 QRP_PROC_ADD_AVAILABLE_SC = 2; +constexpr uint16 QRP_PROC_REMOVE_AVAILABLE_SC = 3; + +constexpr uint16 QRP_FUNC_GET_AVAILABLE_RESERVE = 1; +constexpr uint16 QRP_FUNC_GET_AVAILABLE_SCS = 2; + static const id QRP_CONTRACT_ID(QRP_CONTRACT_INDEX, 0, 0, 0); static const id QRP_DEFAULT_SC_ID(QRP_QTF_INDEX, 0, 0, 0); static const id QRP_OWNER_TEAM_ADDRESS = @@ -30,13 +38,16 @@ class ContractTestingQRP : protected ContractTesting QRPChecker* state() { return reinterpret_cast(contractStates[QRP_CONTRACT_INDEX]); } - void fundContract(uint64 amount) { increaseEnergy(QRP_CONTRACT_ID, amount); } + uint64 balanceOf(const id& account) const { return static_cast(getBalance(account)); } + uint64 balanceQrp() const { return balanceOf(QRP_CONTRACT_ID); } + void fund(const id& account, uint64 amount) { increaseEnergy(account, amount); } + void fundQrp(uint64 amount) { fund(QRP_CONTRACT_ID, amount); } QRP::GetReserve_output getReserve(const id& invocator, uint64 revenue, sint64 attachedAmount = 0) { QRP::GetReserve_input input{revenue}; QRP::GetReserve_output output{}; - invokeUserProcedure(QRP_CONTRACT_INDEX, 1, input, output, invocator, attachedAmount); + invokeUserProcedure(QRP_CONTRACT_INDEX, QRP_PROC_GET_RESERVE, input, output, invocator, attachedAmount); return output; } @@ -44,7 +55,7 @@ class ContractTestingQRP : protected ContractTesting { QRP::AddAvailableSC_input input{scIndex}; QRP::AddAvailableSC_output output{}; - invokeUserProcedure(QRP_CONTRACT_INDEX, 2, input, output, invocator, 0); + invokeUserProcedure(QRP_CONTRACT_INDEX, QRP_PROC_ADD_AVAILABLE_SC, input, output, invocator, 0); return output; } @@ -52,7 +63,7 @@ class ContractTestingQRP : protected ContractTesting { QRP::RemoveAvailableSC_input input{scIndex}; QRP::RemoveAvailableSC_output output{}; - invokeUserProcedure(QRP_CONTRACT_INDEX, 3, input, output, invocator, 0); + invokeUserProcedure(QRP_CONTRACT_INDEX, QRP_PROC_REMOVE_AVAILABLE_SC, input, output, invocator, 0); return output; } @@ -60,7 +71,7 @@ class ContractTestingQRP : protected ContractTesting { QRP::GetAvailableReserve_input input{}; QRP::GetAvailableReserve_output output{}; - callFunction(QRP_CONTRACT_INDEX, 1, input, output); + callFunction(QRP_CONTRACT_INDEX, QRP_FUNC_GET_AVAILABLE_RESERVE, input, output); return output; } @@ -68,71 +79,82 @@ class ContractTestingQRP : protected ContractTesting { QRP::GetAvailableSC_input input{}; QRP::GetAvailableSC_output output{}; - callFunction(QRP_CONTRACT_INDEX, 2, input, output); + callFunction(QRP_CONTRACT_INDEX, QRP_FUNC_GET_AVAILABLE_SCS, input, output); return output; } }; -TEST(ContractQReservePool, InitializesOwnerAndDefaultSCList) +static bool containsAvailableSC(const QRP::GetAvailableSC_output& available, const id& sc) { - ContractTestingQRP qrp; - QRPChecker* state = qrp.state(); - - EXPECT_EQ(state->team(), QRP_OWNER_TEAM_ADDRESS); - EXPECT_EQ(state->owner(), QRP_OWNER_TEAM_ADDRESS); - EXPECT_TRUE(state->hasAvailableSC(QRP_DEFAULT_SC_ID)); - EXPECT_EQ(state->availableCount(), 1u); - - const QRP::GetAvailableSC_output available = qrp.getAvailableSCs(); - bool foundDefault = false; for (uint64 i = 0; i < QRP_AVAILABLE_SC_NUM; ++i) { - if (available.availableSCs.get(i) == QRP_DEFAULT_SC_ID) + if (available.availableSCs.get(i) == sc) { - foundDefault = true; - break; + return true; } } - EXPECT_TRUE(foundDefault); + return false; } TEST(ContractQReservePool, GetReserveEnforcesAuthorizationAndBalance) { ContractTestingQRP qrp; - const id unauthorized(77, 0, 0, 0); - increaseEnergy(unauthorized, 0); - increaseEnergy(QRP_DEFAULT_SC_ID, 0); + const id unauthorized = id::randomValue(); + qrp.fund(unauthorized, 0); + qrp.fund(QRP_DEFAULT_SC_ID, 0); QRP::GetReserve_output denied = qrp.getReserve(unauthorized, 100); EXPECT_EQ(denied.returnCode, QRPReturnCode::ACCESS_DENIED); EXPECT_EQ(denied.allocatedRevenue, 0ull); - qrp.fundContract(1000); - EXPECT_EQ(getBalance(QRP_CONTRACT_ID), 1000); + qrp.fundQrp(1000); + EXPECT_EQ(qrp.balanceQrp(), 1000); QRP::GetReserve_output granted = qrp.getReserve(QRP_DEFAULT_SC_ID, 600); EXPECT_EQ(granted.returnCode, QRPReturnCode::SUCCESS); EXPECT_EQ(granted.allocatedRevenue, 600ull); - EXPECT_EQ(getBalance(QRP_CONTRACT_ID), 400); - EXPECT_EQ(getBalance(QRP_DEFAULT_SC_ID), 600); + EXPECT_EQ(qrp.balanceQrp(), 400); + EXPECT_EQ(qrp.balanceOf(QRP_DEFAULT_SC_ID), 600); QRP::GetReserve_output insufficient = qrp.getReserve(QRP_DEFAULT_SC_ID, 500); EXPECT_EQ(insufficient.returnCode, QRPReturnCode::INSUFFICIENT_RESERVE); EXPECT_EQ(insufficient.allocatedRevenue, 0ull); - EXPECT_EQ(getBalance(QRP_CONTRACT_ID), 400); - EXPECT_EQ(getBalance(QRP_DEFAULT_SC_ID), 600); + EXPECT_EQ(qrp.balanceQrp(), 400); + EXPECT_EQ(qrp.balanceOf(QRP_DEFAULT_SC_ID), 600); +} + +TEST(ContractQReservePool, GetReserve_ZeroAndExactRemaining) +{ + ContractTestingQRP qrp; + qrp.fund(QRP_DEFAULT_SC_ID, 0); + + qrp.fundQrp(1000); + EXPECT_EQ(qrp.balanceQrp(), 1000); + + // Zero request should not move funds. + const QRP::GetReserve_output zero = qrp.getReserve(QRP_DEFAULT_SC_ID, 0); + EXPECT_EQ(zero.returnCode, QRPReturnCode::SUCCESS); + EXPECT_EQ(zero.allocatedRevenue, 0ull); + EXPECT_EQ(qrp.balanceQrp(), 1000); + + // Exact remaining should succeed and drain the reserve. + const QRP::GetReserve_output exact = qrp.getReserve(QRP_DEFAULT_SC_ID, 1000); + EXPECT_EQ(exact.returnCode, QRPReturnCode::SUCCESS); + EXPECT_EQ(exact.allocatedRevenue, 1000ull); + EXPECT_EQ(qrp.balanceQrp(), 0); + EXPECT_EQ(qrp.balanceOf(QRP_DEFAULT_SC_ID), 1000); } TEST(ContractQReservePool, OwnerAddsAndRemovesSmartContracts) { ContractTestingQRP qrp; QRPChecker* state = qrp.state(); - const uint64 newScIndex = 77; + constexpr uint64 newScIndex = 77; const id newScId(newScIndex, 0, 0, 0); const id outsider(200, 0, 0, 0); - increaseEnergy(newScId, 0); - increaseEnergy(outsider, 0); - increaseEnergy(state->owner(), 0); + qrp.fund(newScId, 0); + qrp.fund(outsider, 0); + qrp.fund(state->owner(), 0); QRP::AddAvailableSC_output deniedAdd = qrp.addAvailableSC(outsider, newScIndex); EXPECT_EQ(deniedAdd.returnCode, QRPReturnCode::ACCESS_DENIED); @@ -143,16 +165,7 @@ TEST(ContractQReservePool, OwnerAddsAndRemovesSmartContracts) EXPECT_TRUE(state->hasAvailableSC(newScId)); QRP::GetAvailableSC_output available = qrp.getAvailableSCs(); - bool foundNew = false; - for (uint64 i = 0; i < QRP_AVAILABLE_SC_NUM; ++i) - { - if (available.availableSCs.get(i) == newScId) - { - foundNew = true; - break; - } - } - EXPECT_TRUE(foundNew); + EXPECT_TRUE(containsAvailableSC(available, newScId)); QRP::RemoveAvailableSC_output deniedRemove = qrp.removeAvailableSC(outsider, newScIndex); EXPECT_EQ(deniedRemove.returnCode, QRPReturnCode::ACCESS_DENIED); @@ -162,3 +175,33 @@ TEST(ContractQReservePool, OwnerAddsAndRemovesSmartContracts) EXPECT_EQ(approvedRemove.returnCode, QRPReturnCode::SUCCESS); EXPECT_FALSE(state->hasAvailableSC(newScId)); } + +TEST(ContractQReservePool, OwnerAddRemove_IdempotencyAndBounds) +{ + ContractTestingQRP qrp; + QRPChecker* state = qrp.state(); + qrp.fund(state->owner(), 0); + + constexpr uint64 newScIndex = 88; + const id newScId(newScIndex, 0, 0, 0); + qrp.fund(newScId, 0); + + EXPECT_FALSE(state->hasAvailableSC(newScId)); + + // This test focuses on idempotency (repeat add/remove) while keeping authorization valid. + // Add twice: first should succeed, second should not change membership (return code may be SUCCESS or specific). + const auto add1 = qrp.addAvailableSC(state->owner(), newScIndex); + EXPECT_EQ(add1.returnCode, QRPReturnCode::SUCCESS); + EXPECT_TRUE(state->hasAvailableSC(newScId)); + + const auto add2 = qrp.addAvailableSC(state->owner(), newScIndex); + EXPECT_TRUE(state->hasAvailableSC(newScId)); + + // Remove twice: first should succeed, second should keep it removed (return code may be SUCCESS or specific). + const auto rem1 = qrp.removeAvailableSC(state->owner(), newScIndex); + EXPECT_EQ(rem1.returnCode, QRPReturnCode::SUCCESS); + EXPECT_FALSE(state->hasAvailableSC(newScId)); + + const auto rem2 = qrp.removeAvailableSC(state->owner(), newScIndex); + EXPECT_FALSE(state->hasAvailableSC(newScId)); +} diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp index 393c6d865..e2a9ad3b2 100644 --- a/test/contract_qtf.cpp +++ b/test/contract_qtf.cpp @@ -9,17 +9,14 @@ #include #include -#include -#include -// Procedure indices (must match REGISTER_USER_FUNCTIONS_AND_PROCEDURES in QThirtyFour.h) +// Procedure/function indices (must match REGISTER_USER_FUNCTIONS_AND_PROCEDURES in `src/contracts/QThirtyFour.h`). constexpr uint16 QTF_PROCEDURE_BUY_TICKET = 1; constexpr uint16 QTF_PROCEDURE_SET_PRICE = 2; constexpr uint16 QTF_PROCEDURE_SET_SCHEDULE = 3; constexpr uint16 QTF_PROCEDURE_SET_TARGET_JACKPOT = 4; constexpr uint16 QTF_PROCEDURE_SET_DRAW_HOUR = 5; -// Function indices constexpr uint16 QTF_FUNCTION_GET_TICKET_PRICE = 1; constexpr uint16 QTF_FUNCTION_GET_NEXT_EPOCH_DATA = 2; constexpr uint16 QTF_FUNCTION_GET_WINNER_DATA = 3; @@ -30,6 +27,49 @@ constexpr uint16 QTF_FUNCTION_GET_STATE = 7; constexpr uint16 QTF_FUNCTION_GET_FEES = 8; constexpr uint16 QTF_FUNCTION_ESTIMATE_PRIZE_PAYOUTS = 9; +namespace +{ + static void primeQpiFunctionContext(QpiContextUserFunctionCall& qpi) + { + QTF::GetTicketPrice_input input{}; + qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &input, sizeof(input)); + } + + static void primeQpiProcedureContext(QpiContextUserProcedureCall& qpi, uint8 drawHour) + { + QTF::SetDrawHour_input input{}; + input.newDrawHour = drawHour; + qpi.call(QTF_PROCEDURE_SET_DRAW_HOUR, &input, sizeof(input)); + ASSERT_EQ(contractError[QTF_CONTRACT_INDEX], 0); + } + + static bool valuesEqual(const QTFRandomValues& a, const QTFRandomValues& b) + { + return memcmp(&a, &b, sizeof(a)) == 0; + } + + static void expectWinnerValuesValidAndUnique(const QTF::GetWinnerData_output& winnerData) + { + std::set unique; + for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) + { + const uint8 v = winnerData.winnerData.winnerValues.get(i); + EXPECT_GE(v, 1u) << "Winning value " << i << " should be >= 1"; + EXPECT_LE(v, QTF_MAX_RANDOM_VALUE) << "Winning value " << i << " should be <= 30"; + unique.insert(v); + } + EXPECT_EQ(unique.size(), static_cast(QTF_RANDOM_VALUES_COUNT)) << "All 4 winning numbers should be unique"; + EXPECT_GT(static_cast(winnerData.winnerData.epoch), 0u) << "Epoch should be recorded after draw"; + } + + static void computeBaselinePrizePools(uint64 revenue, const QTF::GetFees_output& fees, uint64& winnersBlock, uint64& k2Pool, uint64& k3Pool) + { + winnersBlock = (revenue * static_cast(fees.winnerFeePercent)) / 100ULL; + k2Pool = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000ULL; + k3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000ULL; + } +} // namespace + static const id QTF_DEV_ADDRESS = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); @@ -208,17 +248,6 @@ class QTFChecker : public QTF ProcessTierPayout(qpi, *this, input, output, locals); return output; } - - void callSettleEpoch(const QPI::QpiContextProcedureCall& qpi) - { - SettleEpoch_input input{}; - SettleEpoch_output output{}; - std::aligned_storage_t localsStorage; - auto& locals = *reinterpret_cast(&localsStorage); - setMemory(locals, 0); - - SettleEpoch(qpi, *this, input, output, locals); - } }; class ContractTestingQTF : protected ContractTesting @@ -251,74 +280,74 @@ class ContractTestingQTF : protected ContractTesting // Public function wrappers QTF::GetTicketPrice_output getTicketPrice() { - QTF::GetTicketPrice_input input; - QTF::GetTicketPrice_output output; + QTF::GetTicketPrice_input input{}; + QTF::GetTicketPrice_output output{}; callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_TICKET_PRICE, input, output); return output; } QTF::GetNextEpochData_output getNextEpochData() { - QTF::GetNextEpochData_input input; - QTF::GetNextEpochData_output output; + QTF::GetNextEpochData_input input{}; + QTF::GetNextEpochData_output output{}; callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_NEXT_EPOCH_DATA, input, output); return output; } QTF::GetWinnerData_output getWinnerData() { - QTF::GetWinnerData_input input; - QTF::GetWinnerData_output output; + QTF::GetWinnerData_input input{}; + QTF::GetWinnerData_output output{}; callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_WINNER_DATA, input, output); return output; } QTF::GetPools_output getPools() { - QTF::GetPools_input input; - QTF::GetPools_output output; + QTF::GetPools_input input{}; + QTF::GetPools_output output{}; callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_POOLS, input, output); return output; } QTF::GetSchedule_output getSchedule() { - QTF::GetSchedule_input input; - QTF::GetSchedule_output output; + QTF::GetSchedule_input input{}; + QTF::GetSchedule_output output{}; callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_SCHEDULE, input, output); return output; } QTF::GetDrawHour_output getDrawHour() { - QTF::GetDrawHour_input input; - QTF::GetDrawHour_output output; + QTF::GetDrawHour_input input{}; + QTF::GetDrawHour_output output{}; callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_DRAW_HOUR, input, output); return output; } QTF::GetState_output getStateInfo() { - QTF::GetState_input input; - QTF::GetState_output output; + QTF::GetState_input input{}; + QTF::GetState_output output{}; callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_STATE, input, output); return output; } QTF::GetFees_output getFees() { - QTF::GetFees_input input; - QTF::GetFees_output output; + QTF::GetFees_input input{}; + QTF::GetFees_output output{}; callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_FEES, input, output); return output; } QTF::EstimatePrizePayouts_output estimatePrizePayouts(uint64 k2WinnerCount, uint64 k3WinnerCount) { - QTF::EstimatePrizePayouts_input input; + QTF::EstimatePrizePayouts_input input{}; input.k2WinnerCount = k2WinnerCount; input.k3WinnerCount = k3WinnerCount; - QTF::EstimatePrizePayouts_output output; + QTF::EstimatePrizePayouts_output output{}; callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_ESTIMATE_PRIZE_PAYOUTS, input, output); return output; } @@ -326,9 +355,9 @@ class ContractTestingQTF : protected ContractTesting // Procedure wrappers QTF::BuyTicket_output buyTicket(const id& user, uint64 reward, const QTFRandomValues& numbers) { - QTF::BuyTicket_input input; + QTF::BuyTicket_input input{}; input.randomValues = numbers; - QTF::BuyTicket_output output; + QTF::BuyTicket_output output{}; if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_BUY_TICKET, input, output, user, reward)) { output.returnCode = static_cast(QTF::EReturnCode::MAX_VALUE); @@ -338,9 +367,9 @@ class ContractTestingQTF : protected ContractTesting QTF::SetPrice_output setPrice(const id& invocator, uint64 newPrice) { - QTF::SetPrice_input input; + QTF::SetPrice_input input{}; input.newPrice = newPrice; - QTF::SetPrice_output output; + QTF::SetPrice_output output{}; if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_SET_PRICE, input, output, invocator, 0)) { output.returnCode = static_cast(QTF::EReturnCode::MAX_VALUE); @@ -350,9 +379,9 @@ class ContractTestingQTF : protected ContractTesting QTF::SetSchedule_output setSchedule(const id& invocator, uint8 newSchedule) { - QTF::SetSchedule_input input; + QTF::SetSchedule_input input{}; input.newSchedule = newSchedule; - QTF::SetSchedule_output output; + QTF::SetSchedule_output output{}; if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_SET_SCHEDULE, input, output, invocator, 0)) { output.returnCode = static_cast(QTF::EReturnCode::MAX_VALUE); @@ -362,9 +391,9 @@ class ContractTestingQTF : protected ContractTesting QTF::SetTargetJackpot_output setTargetJackpot(const id& invocator, uint64 newTarget) { - QTF::SetTargetJackpot_input input; + QTF::SetTargetJackpot_input input{}; input.newTargetJackpot = newTarget; - QTF::SetTargetJackpot_output output; + QTF::SetTargetJackpot_output output{}; if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_SET_TARGET_JACKPOT, input, output, invocator, 0)) { output.returnCode = static_cast(QTF::EReturnCode::MAX_VALUE); @@ -374,9 +403,9 @@ class ContractTestingQTF : protected ContractTesting QTF::SetDrawHour_output setDrawHour(const id& invocator, uint8 newHour) { - QTF::SetDrawHour_input input; + QTF::SetDrawHour_input input{}; input.newDrawHour = newHour; - QTF::SetDrawHour_output output; + QTF::SetDrawHour_output output{}; if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_SET_DRAW_HOUR, input, output, invocator, 0)) { output.returnCode = static_cast(QTF::EReturnCode::MAX_VALUE); @@ -419,8 +448,26 @@ class ContractTestingQTF : protected ContractTesting // Force schedule mask directly in state void forceSchedule(uint8 scheduleMask) { state()->setScheduleMask(scheduleMask); } - // Advance to next day and trigger draw - void advanceOneDayAndDraw() + void forceFRDisabledForBaseline() + { + state()->setFrActive(false); + state()->setFrRoundsSinceK4(QTF_FR_POST_K4_WINDOW_ROUNDS); + } + + void forceFREnabledWithinWindow(uint16 roundsSinceK4 = 1) + { + state()->setFrActive(true); + state()->setFrRoundsSinceK4(roundsSinceK4); + } + + void startAnyDayEpoch() + { + forceSchedule(QTF_ANY_DAY_SCHEDULE); + beginEpochWithValidTime(); + } + + // Trigger a tick that performs the draw (time is set to a scheduled day and hour). + void triggerDrawTick() { constexpr uint16 y = 2025; constexpr uint8 m = 1; @@ -452,9 +499,15 @@ class ContractTestingQTF : protected ContractTesting // This allows tests to predict winning numbers by fixing the RNG seed void setPrevSpectrumDigest(const m256i& digest) { etalonTick.prevSpectrumDigest = digest; } - // Compute winning numbers that would be generated for a given prevSpectrumDigest - // This mirrors the logic in QThirtyFour::GetRandomValues (lines 1663-1698) - // Returns the 4 winning numbers in ascending order + void drawWithDigest(const m256i& digest) + { + setPrevSpectrumDigest(digest); + triggerDrawTick(); + } + + // Compute the winning numbers that would be generated for a given prevSpectrumDigest. + // This mirrors the contract GetRandomValues logic (including collision handling). + // Returns values in generation order (not sorted). QTFRandomValues computeWinningNumbersForDigest(const m256i& digest) { // Replicate QTF's GetRandomValues logic @@ -511,6 +564,12 @@ class ContractTestingQTF : protected ContractTesting return result; } + struct WinningAndLosing + { + QTFRandomValues winning; + QTFRandomValues losing; + }; + QTFRandomValues makeLosingNumbers(const QTFRandomValues& winningNumbers) { bool isWinning[31] = {}; @@ -532,9 +591,27 @@ class ContractTestingQTF : protected ContractTesting return losingNumbers; } + WinningAndLosing computeWinningAndLosing(const m256i& digest) + { + WinningAndLosing out; + out.winning = computeWinningNumbersForDigest(digest); + out.losing = makeLosingNumbers(out.winning); + return out; + } + + void buyRandomTickets(uint64 count, uint64 ticketPrice, const QTFRandomValues& numbers) + { + for (uint64 i = 0; i < count; ++i) + { + const id user = id::randomValue(); + fundAndBuyTicket(user, ticketPrice, numbers); + } + } + // Create a ticket that matches exactly `matchCount` numbers with `winningNumbers`. + // `variant` makes it deterministic to generate multiple distinct tickets for the same winning set. // Guarantees values are unique and in [1..30]. - QTFRandomValues makeNumbersWithExactMatches(const QTFRandomValues& winningNumbers, uint8 matchCount) + QTFRandomValues makeNumbersWithExactMatches(const QTFRandomValues& winningNumbers, uint8 matchCount, uint8 variant = 0) { EXPECT_LE(matchCount, static_cast(QTF_RANDOM_VALUES_COUNT)); @@ -552,17 +629,19 @@ class ContractTestingQTF : protected ContractTesting QTFRandomValues ticket; uint64 outIndex = 0; - // Take first `matchCount` winning numbers as the matches. + // Take `matchCount` winning numbers as the matches (variant-dependent, wrap around 4). for (uint8 i = 0; i < matchCount; ++i) { - const uint8 v = winningNumbers.get(i); + const uint8 v = winningNumbers.get((variant + i) % QTF_RANDOM_VALUES_COUNT); used[v] = true; ticket.set(outIndex++, v); } // Fill the remaining positions with non-winning numbers. - for (uint8 candidate = 1; candidate <= QTF_MAX_RANDOM_VALUE && outIndex < QTF_RANDOM_VALUES_COUNT; ++candidate) + const uint8 start = static_cast((variant * 7) % QTF_MAX_RANDOM_VALUE + 1); + for (uint8 step = 0; step < QTF_MAX_RANDOM_VALUE && outIndex < QTF_RANDOM_VALUES_COUNT; ++step) { + const uint8 candidate = static_cast(((start - 1 + step) % QTF_MAX_RANDOM_VALUE) + 1); if (!isWinning[candidate] && !used[candidate]) { used[candidate] = true; @@ -592,8 +671,14 @@ class ContractTestingQTF : protected ContractTesting return ticket; } - QTFRandomValues makeK2Numbers(const QTFRandomValues& winningNumbers) { return makeNumbersWithExactMatches(winningNumbers, 2); } - QTFRandomValues makeK3Numbers(const QTFRandomValues& winningNumbers) { return makeNumbersWithExactMatches(winningNumbers, 3); } + QTFRandomValues makeK2Numbers(const QTFRandomValues& winningNumbers, uint8 variant = 0) + { + return makeNumbersWithExactMatches(winningNumbers, 2, variant); + } + QTFRandomValues makeK3Numbers(const QTFRandomValues& winningNumbers, uint8 variant = 0) + { + return makeNumbersWithExactMatches(winningNumbers, 3, variant); + } }; // ============================================================================ @@ -605,8 +690,7 @@ TEST(ContractQThirtyFour_Private, CountMatches_CountsOverlappingNumbers) ContractTestingQTF ctl; QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - QTF::GetTicketPrice_input primeIn{}; - qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + primeQpiFunctionContext(qpi); // Include values > 8 to cover the full [1..30] bitmask range. const QTFRandomValues player = ctl.makeValidNumbers(1, 16, 29, 30); @@ -620,8 +704,7 @@ TEST(ContractQThirtyFour_Private, ValidateNumbers_WorksForValidDuplicateAndRange ContractTestingQTF ctl; QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - QTF::GetTicketPrice_input primeIn{}; - qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + primeQpiFunctionContext(qpi); const QTFRandomValues ok = ctl.makeValidNumbers(1, 2, 3, 4); EXPECT_TRUE(ctl.state()->callValidateNumbers(qpi, ok).isValid); @@ -640,12 +723,12 @@ TEST(ContractQThirtyFour_Private, GetRandomValues_IsDeterministicAndUniqueInRang ContractTestingQTF ctl; QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - QTF::GetTicketPrice_input primeIn{}; - qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + primeQpiFunctionContext(qpi); const uint64 seed = 0x123456789ABCDEF0ULL; const auto out1 = ctl.state()->callGetRandomValues(qpi, seed); const auto out2 = ctl.state()->callGetRandomValues(qpi, seed); + EXPECT_TRUE(valuesEqual(out1.values, out2.values)); std::set seen; for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) @@ -664,8 +747,7 @@ TEST(ContractQThirtyFour_Private, CheckContractBalance_UsesIncomingMinusOutgoing ContractTestingQTF ctl; QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - QTF::GetTicketPrice_input primeIn{}; - qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + primeQpiFunctionContext(qpi); const uint64 balance = 123456; increaseEnergy(ctl.qtfSelf(), balance); @@ -684,8 +766,7 @@ TEST(ContractQThirtyFour_Private, PowerFixedPoint_ComputesFastExponentiationInFi ContractTestingQTF ctl; QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - QTF::GetTicketPrice_input primeIn{}; - qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + primeQpiFunctionContext(qpi); // 0.5^2 = 0.25 const auto out025 = ctl.state()->callPowerFixedPoint(qpi, QTF_FIXED_POINT_SCALE / 2, 2); @@ -701,8 +782,7 @@ TEST(ContractQThirtyFour_Private, CalculateExpectedRoundsToK4_HandlesEdgeCaseAnd ContractTestingQTF ctl; QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - QTF::GetTicketPrice_input primeIn{}; - qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + primeQpiFunctionContext(qpi); const auto out0 = ctl.state()->callCalculateExpectedRoundsToK4(qpi, 0); EXPECT_EQ(out0.expectedRounds, UINT64_MAX); @@ -721,11 +801,17 @@ TEST(ContractQThirtyFour_Private, CalcReserveTopUp_RespectsSoftFloorPerRoundAndP ContractTestingQTF ctl; QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - QTF::GetTicketPrice_input primeIn{}; - qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + primeQpiFunctionContext(qpi); const uint64 P = 1000000ULL; + // Below soft floor => nothing can be topped up. + { + const uint64 softFloor = smul(P, QTF_RESERVE_SOFT_FLOOR_MULT); + const auto out = ctl.state()->callCalcReserveTopUp(qpi, softFloor - 1, 1000ULL, 1000000000ULL, P); + EXPECT_EQ(out.topUpAmount, 0ULL); + } + // Soft floor binds availableAboveFloor and per-round is 10% of total. { const auto out = ctl.state()->callCalcReserveTopUp(qpi, 25000000ULL, 10000000ULL, 1000000000ULL, P); @@ -750,8 +836,7 @@ TEST(ContractQThirtyFour_Private, CalculatePrizePools_MatchesFeeAndRakeMath) ContractTestingQTF ctl; QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - QTF::GetTicketPrice_input primeIn{}; - qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + primeQpiFunctionContext(qpi); const auto fees = ctl.getFees(); ASSERT_NE(fees.winnerFeePercent, 0); @@ -782,8 +867,7 @@ TEST(ContractQThirtyFour_Private, CalculateBaseGain_FollowsConfiguredRedirectsAn ContractTestingQTF ctl; QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - QTF::GetTicketPrice_input primeIn{}; - qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + primeQpiFunctionContext(qpi); const uint64 revenue = 1000000ULL; const uint64 winnersBlock = 680000ULL; @@ -797,8 +881,7 @@ TEST(ContractQThirtyFour_Private, CalculateExtraRedirectBP_ReturnsZeroOrClampsTo ContractTestingQTF ctl; QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - QTF::GetTicketPrice_input primeIn{}; - qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &primeIn, sizeof(primeIn)); + primeQpiFunctionContext(qpi); // Early exits EXPECT_EQ(ctl.state()->callCalculateExtraRedirectBP(qpi, 0, 1, 1, 0).extraBP, 0ULL); @@ -826,12 +909,7 @@ TEST(ContractQThirtyFour_Private, ProcessTierPayout_ComputesPayoutAndOptionalTop const id originator = id::randomValue(); QpiContextUserProcedureCall qpi(QTF_CONTRACT_INDEX, originator, 0); - QTF::SetDrawHour_input primeIn{}; - QTF::SetDrawHour_output primeOut{}; - primeIn.newDrawHour = ctl.state()->getDrawHourInternal(); - qpi.call(QTF_PROCEDURE_SET_DRAW_HOUR, &primeIn, sizeof(primeIn)); - copyMem(&primeOut, qpi.outputBuffer, sizeof(primeOut)); - ASSERT_EQ(contractError[QTF_CONTRACT_INDEX], 0); + primeQpiProcedureContext(qpi, static_cast(ctl.state()->getDrawHourInternal())); // No winners -> all overflow. { @@ -857,18 +935,26 @@ TEST(ContractQThirtyFour_Private, ProcessTierPayout_ComputesPayoutAndOptionalTop EXPECT_EQ(getBalance(ctl.qtfSelf()), qtfBalanceBefore + 90); EXPECT_EQ(getBalance(ctl.qrpSelf()), qrpBalanceBeforeActual - 90); } + + // Per-winner cap applies and leaves overflow. + { + const uint64 P = 1000000ULL; + const uint64 cap = smul(P, QTF_TOPUP_PER_WINNER_CAP_MULT); + const auto out = ctl.state()->callProcessTierPayout(qpi, div(P, 2), 1, sadd(cap, 1234ULL), cap, 0, P); + EXPECT_EQ(out.perWinnerPayout, cap); + EXPECT_EQ(out.topUpReceived, 0ULL); + EXPECT_EQ(out.overflow, 1234ULL); + } } TEST(ContractQThirtyFour_Private, ReturnAllTickets_RefundsEachPlayerAndClearsViaSettleEpochRevenueZeroBranch) { ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); const id originator = id::randomValue(); QpiContextUserProcedureCall qpi(QTF_CONTRACT_INDEX, originator, 0); - QTF::SetDrawHour_input primeIn{}; - primeIn.newDrawHour = ctl.state()->getDrawHourInternal(); - qpi.call(QTF_PROCEDURE_SET_DRAW_HOUR, &primeIn, sizeof(primeIn)); - ASSERT_EQ(contractError[QTF_CONTRACT_INDEX], 0); + primeQpiProcedureContext(qpi, static_cast(ctl.state()->getDrawHourInternal())); // Setup a few players and refund them. const uint64 ticketPrice = 10; @@ -895,7 +981,7 @@ TEST(ContractQThirtyFour_Private, ReturnAllTickets_RefundsEachPlayerAndClearsVia // Now exercise SettleEpoch revenue==0 branch, which must clear players. ctl.state()->setTicketPriceInternal(0); EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 2ULL); - ctl.state()->callSettleEpoch(qpi); + ctl.triggerDrawTick(); EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0ULL); } @@ -1331,18 +1417,34 @@ TEST(ContractQThirtyFour, SetDrawHour_AppliesAfterEndEpoch) // STATE AND POOLS TESTS // ============================================================================ -TEST(ContractQThirtyFour, GetState_InitiallyNotSelling) +TEST(ContractQThirtyFour, GetState_NoneThenSelling) { ContractTestingQTF ctl; - // Before valid time initialization, state should be NONE (not selling) + + // Initially not selling EXPECT_EQ(ctl.getStateInfo().currentState, static_cast(QTF::EState::STATE_NONE)); + + // After epoch start with valid time it should sell + ctl.beginEpochWithValidTime(); + EXPECT_EQ(ctl.getStateInfo().currentState, static_cast(QTF::EState::STATE_SELLING)); } -TEST(ContractQThirtyFour, GetState_SellingAfterValidEpochStart) +TEST(ContractQThirtyFour, GetPools_ReserveReflectsQRPAvailable) { ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); - EXPECT_EQ(ctl.getStateInfo().currentState, static_cast(QTF::EState::STATE_SELLING)); + + const QTF::GetPools_output poolsBefore = ctl.getPools(); + const uint64 before = poolsBefore.pools.reserve; + + constexpr uint64 qrpFunding = 10'000'000'000ULL; + increaseEnergy(ctl.qrpSelf(), qrpFunding); + + const QTF::GetPools_output poolsAfter = ctl.getPools(); + const uint64 after = poolsAfter.pools.reserve; + + EXPECT_GE(after, before); + EXPECT_GT(after, 0u); + EXPECT_LE(after, before + qrpFunding); } // ============================================================================ @@ -1352,13 +1454,12 @@ TEST(ContractQThirtyFour, GetState_SellingAfterValidEpochStart) TEST(ContractQThirtyFour, Settlement_NoPlayers_NoChanges) { ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - ctl.beginEpochWithValidTime(); + ctl.startAnyDayEpoch(); const uint64 jackpotBefore = ctl.state()->getJackpot(); const QTF::GetWinnerData_output winnersBefore = ctl.getWinnerData(); - ctl.advanceOneDayAndDraw(); + ctl.triggerDrawTick(); // No changes when no players EXPECT_EQ(ctl.state()->getJackpot(), jackpotBefore); @@ -1369,14 +1470,13 @@ TEST(ContractQThirtyFour, Settlement_NoPlayers_NoChanges) TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed) { ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - ctl.beginEpochWithValidTime(); + ctl.startAnyDayEpoch(); + ctl.forceFRDisabledForBaseline(); // Fix RNG so we can deterministically avoid winners (and especially k=4). m256i testDigest = {}; testDigest.m256i_u64[0] = 0x1010101010101010ULL; - const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); - const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + const auto nums = ctl.computeWinningAndLosing(testDigest); const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); const QTF::GetFees_output fees = ctl.getFees(); @@ -1388,11 +1488,7 @@ TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed) EXPECT_EQ(ctl.state()->getFrActive(), false); // Add players - for (uint64 i = 0; i < numPlayers; ++i) - { - const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); - } + ctl.buyRandomTickets(numPlayers, ticketPrice, nums.losing); const uint64 totalRevenue = ticketPrice * numPlayers; const uint64 devBalBefore = getBalance(QTF_DEV_ADDRESS); @@ -1400,8 +1496,7 @@ TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed) EXPECT_EQ(contractBalBefore, totalRevenue); - ctl.setPrevSpectrumDigest(testDigest); - ctl.advanceOneDayAndDraw(); + ctl.drawWithDigest(testDigest); EXPECT_EQ(ctl.state()->getFrActive(), false); @@ -1415,24 +1510,48 @@ TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed) EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); } +TEST(ContractQThirtyFour, Settlement_InsufficientBalance_ClearsPlayersAndAbortsSettlement) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0x3030303030303030ULL; + const auto nums = ctl.computeWinningAndLosing(testDigest); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + constexpr uint64 numPlayers = 2; + + ctl.buyRandomTickets(numPlayers, ticketPrice, nums.losing); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), numPlayers); + + // Drain the contract so CheckContractBalance() fails in SettleEpoch. + const uint64 totalRevenue = ticketPrice * numPlayers; + const int qtfIndex = spectrumIndex(ctl.qtfSelf()); + ASSERT_GE(qtfIndex, 0); + ASSERT_TRUE(decreaseEnergy(qtfIndex, static_cast(totalRevenue))); + EXPECT_EQ(getBalance(ctl.qtfSelf()), 0); + + ctl.drawWithDigest(testDigest); + + // Even if refunds can't be paid (because we drained balance), the contract must clear the epoch state. + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0ULL); +} + TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed_FRMode) { ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + ctl.startAnyDayEpoch(); // Fix RNG so we can deterministically avoid winners (and especially k=4). m256i testDigest = {}; testDigest.m256i_u64[0] = 0x2020202020202020ULL; - const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); - const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + const auto nums = ctl.computeWinningAndLosing(testDigest); // Activate FR mode ctl.state()->setJackpot(100000000ULL); // Below target ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); - ctl.state()->setFrActive(true); - ctl.state()->setFrRoundsSinceK4(5); - - ctl.beginEpochWithValidTime(); + ctl.forceFREnabledWithinWindow(5); const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); const QTF::GetFees_output fees = ctl.getFees(); @@ -1442,21 +1561,17 @@ TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed_FRMode) EXPECT_EQ(ctl.state()->getFrActive(), true); // Add players - for (uint64 i = 0; i < numPlayers; ++i) - { - const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); - } + ctl.buyRandomTickets(numPlayers, ticketPrice, nums.losing); const uint64 totalRevenue = ticketPrice * numPlayers; const uint64 devBalBefore = getBalance(QTF_DEV_ADDRESS); const uint64 contractBalBefore = getBalance(ctl.qtfSelf()); const uint64 jackpotBefore = ctl.state()->getJackpot(); + const uint64 roundsSinceK4Before = ctl.state()->getFrRoundsSinceK4(); EXPECT_EQ(contractBalBefore, totalRevenue); - ctl.setPrevSpectrumDigest(testDigest); - ctl.advanceOneDayAndDraw(); + ctl.drawWithDigest(testDigest); // In FR mode, dev receives less than full 10% of revenue // Base redirect: 1% of revenue (QTF_FR_DEV_REDIRECT_BP = 100 basis points) @@ -1481,6 +1596,9 @@ TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed_FRMode) // Jackpot should have grown (receives redirects) EXPECT_GT(ctl.state()->getJackpot(), jackpotBefore) << "Jackpot should grow from dev/dist redirects in FR mode"; + // No k=4 can happen (we buy losing tickets), so counter increments. + EXPECT_EQ(ctl.state()->getFrRoundsSinceK4(), roundsSinceK4Before + 1); + // Players cleared EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); } @@ -1488,14 +1606,12 @@ TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed_FRMode) TEST(ContractQThirtyFour, Settlement_JackpotGrowsFromOverflow) { ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - ctl.beginEpochWithValidTime(); + ctl.startAnyDayEpoch(); // Fix RNG so we can deterministically create "no winners" tickets. m256i testDigest = {}; testDigest.m256i_u64[0] = 0xBADC0FFEE0DDF00DULL; - const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); - const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + const auto nums = ctl.computeWinningAndLosing(testDigest); const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); const uint64 jackpotBefore = ctl.state()->getJackpot(); @@ -1505,7 +1621,7 @@ TEST(ContractQThirtyFour, Settlement_JackpotGrowsFromOverflow) for (uint64 i = 0; i < numPlayers; ++i) { const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); + ctl.fundAndBuyTicket(user, ticketPrice, nums.losing); } // Calculate expected jackpot growth in baseline mode (FR not active) @@ -1525,8 +1641,7 @@ TEST(ContractQThirtyFour, Settlement_JackpotGrowsFromOverflow) // Minimum expected jackpot growth (assuming no k2/k3 winners, all overflow goes to jackpot) const uint64 minExpectedGrowth = overflowToJackpot; - ctl.setPrevSpectrumDigest(testDigest); - ctl.advanceOneDayAndDraw(); + ctl.drawWithDigest(testDigest); // Verify jackpot growth const uint64 jackpotAfter = ctl.state()->getJackpot(); @@ -1551,8 +1666,7 @@ TEST(ContractQThirtyFour, Settlement_RoundsSinceK4_Increments) m256i testDigest = {}; testDigest.m256i_u64[0] = 0x1111222233334444ULL; - const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); - const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + const auto nums = ctl.computeWinningAndLosing(testDigest); const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); @@ -1561,15 +1675,10 @@ TEST(ContractQThirtyFour, Settlement_RoundsSinceK4_Increments) { ctl.beginEpochWithValidTime(); - for (int i = 0; i < 5; ++i) - { - const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); - } + ctl.buyRandomTickets(5, ticketPrice, nums.losing); const uint64 roundsBefore = ctl.state()->getFrRoundsSinceK4(); - ctl.setPrevSpectrumDigest(testDigest); - ctl.advanceOneDayAndDraw(); + ctl.drawWithDigest(testDigest); // Deterministic: no ticket matches any winning number, so k=4 cannot occur. EXPECT_EQ(ctl.state()->getFrRoundsSinceK4(), roundsBefore + 1); @@ -1605,7 +1714,7 @@ TEST(ContractQThirtyFour, FR_Activation_WhenBelowTarget) ctl.fundAndBuyTicket(user, ticketPrice, nums); } - ctl.advanceOneDayAndDraw(); + ctl.triggerDrawTick(); // FR should be active since jackpot < target and within window EXPECT_EQ(ctl.state()->getFrActive(), true); @@ -1618,8 +1727,7 @@ TEST(ContractQThirtyFour, FR_Deactivation_AfterHysteresis) m256i testDigest = {}; testDigest.m256i_u64[0] = 0x3030303030303030ULL; - const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); - const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + const auto nums = ctl.computeWinningAndLosing(testDigest); // Set jackpot at target ctl.state()->setJackpot(QTF_DEFAULT_TARGET_JACKPOT); @@ -1634,16 +1742,11 @@ TEST(ContractQThirtyFour, FR_Deactivation_AfterHysteresis) { ctl.beginEpochWithValidTime(); - for (int i = 0; i < 5; ++i) - { - const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); - } + ctl.buyRandomTickets(5, ticketPrice, nums.losing); // Keep jackpot at target (add back what might be paid out) ctl.state()->setJackpot(QTF_DEFAULT_TARGET_JACKPOT); - ctl.setPrevSpectrumDigest(testDigest); - ctl.advanceOneDayAndDraw(); + ctl.drawWithDigest(testDigest); } // After 3 rounds at target, FR should deactivate @@ -1659,8 +1762,7 @@ TEST(ContractQThirtyFour, FR_OverflowBias_95PercentToJackpot) // Fix RNG so we can deterministically create "no winners" tickets. m256i testDigest = {}; testDigest.m256i_u64[0] = 0xCAFEBABEDEADBEEFULL; - const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); - const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + const auto nums = ctl.computeWinningAndLosing(testDigest); // Activate FR ctl.state()->setJackpot(100000000ULL); // Below target @@ -1675,11 +1777,7 @@ TEST(ContractQThirtyFour, FR_OverflowBias_95PercentToJackpot) constexpr uint64 numPlayers = 50; // Add many players to generate significant overflow - for (uint64 i = 0; i < numPlayers; ++i) - { - const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); - } + ctl.buyRandomTickets(numPlayers, ticketPrice, nums.losing); // Calculate expected jackpot growth const uint64 revenue = ticketPrice * numPlayers; @@ -1707,8 +1805,7 @@ TEST(ContractQThirtyFour, FR_OverflowBias_95PercentToJackpot) // totalJackpotContribution = overflowToJackpot + winnersRake + devRedirect + distRedirect const uint64 minExpectedGrowth = overflowToJackpot + winnersRake + devRedirect + distRedirect; - ctl.setPrevSpectrumDigest(testDigest); - ctl.advanceOneDayAndDraw(); + ctl.drawWithDigest(testDigest); // Verify that jackpot grew by at least the minimum expected amount const uint64 actualGrowth = ctl.state()->getJackpot() - jackpotBefore; @@ -1734,68 +1831,18 @@ TEST(ContractQThirtyFour, FR_OverflowBias_95PercentToJackpot) TEST(ContractQThirtyFour, WinnerData_RecordsWinners) { ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - // Add players with diverse number combinations to increase chance of winners - std::vector players; - for (int i = 0; i < 20; ++i) - { - const id user = id::randomValue(); - players.push_back(user); - QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i % 27) + 1), static_cast((i % 27) + 2), - static_cast((i % 27) + 3), static_cast((i % 27) + 4)); - ctl.fundAndBuyTicket(user, ticketPrice, nums); - } - - ctl.advanceOneDayAndDraw(); - - // Winner data should always record winning values and epoch, even if no winners - const QTF::GetWinnerData_output winnerData = ctl.getWinnerData(); - - // Winning values should always be set and valid after a draw - for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) - { - const uint8 val = winnerData.winnerData.winnerValues.get(i); - EXPECT_GE(val, 1u) << "Winning value " << i << " should be >= 1"; - EXPECT_LE(val, 30u) << "Winning value " << i << " should be <= 30"; - } - - // Epoch should be recorded - EXPECT_GT((uint64)winnerData.winnerData.epoch, 0u) << "Epoch should be recorded after draw"; -} - -TEST(ContractQThirtyFour, WinnerData_UniqueWinningNumbers) -{ - ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - ctl.beginEpochWithValidTime(); + ctl.startAnyDayEpoch(); const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - // Add some players - for (int i = 0; i < 5; ++i) - { - const id user = id::randomValue(); - QTFRandomValues nums = - ctl.makeValidNumbers(static_cast(i + 1), static_cast(i + 5), static_cast(i + 10), static_cast(i + 15)); - ctl.fundAndBuyTicket(user, ticketPrice, nums); - } + // At least one ticket is required, otherwise END_EPOCH returns early and winner values are not generated. + const id user = id::randomValue(); + ctl.fundAndBuyTicket(user, ticketPrice, ctl.makeValidNumbers(1, 2, 3, 4)); - ctl.advanceOneDayAndDraw(); + ctl.triggerDrawTick(); - // Winning numbers should always be unique after a draw const QTF::GetWinnerData_output winnerData = ctl.getWinnerData(); - - std::set winningNums; - for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) - { - winningNums.emplace(winnerData.winnerData.winnerValues.get(i)); - } - - EXPECT_EQ(winningNums.size(), QTF_RANDOM_VALUES_COUNT) << "All 4 winning numbers should be unique"; + expectWinnerValuesValidAndUnique(winnerData); } TEST(ContractQThirtyFour, WinnerData_ResetEachRound) @@ -1806,18 +1853,17 @@ TEST(ContractQThirtyFour, WinnerData_ResetEachRound) // Round 1: force a deterministic k=2 winner so winnerCounter becomes > 0. m256i digest1 = {}; digest1.m256i_u64[0] = 0x13579BDF2468ACE0ULL; - const QTFRandomValues winning1 = ctl.computeWinningNumbersForDigest(digest1); + const auto nums1 = ctl.computeWinningAndLosing(digest1); ctl.beginEpochWithValidTime(); const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - QTFRandomValues k2Numbers = ctl.makeK2Numbers(winning1); + QTFRandomValues k2Numbers = ctl.makeK2Numbers(nums1.winning); const id k2Winner = id::randomValue(); ctl.fundAndBuyTicket(k2Winner, ticketPrice, k2Numbers); EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); - ctl.setPrevSpectrumDigest(digest1); - ctl.advanceOneDayAndDraw(); + ctl.drawWithDigest(digest1); const QTF::GetWinnerData_output afterRound1 = ctl.getWinnerData(); EXPECT_GT(afterRound1.winnerData.winnerCounter, 0u); @@ -1825,19 +1871,13 @@ TEST(ContractQThirtyFour, WinnerData_ResetEachRound) // Round 2: force a deterministic "no winners" round, winnerCounter must reset to 0. m256i digest2 = {}; digest2.m256i_u64[0] = 0x0F0E0D0C0B0A0908ULL; - const QTFRandomValues winning2 = ctl.computeWinningNumbersForDigest(digest2); - const QTFRandomValues losing2 = ctl.makeLosingNumbers(winning2); + const auto nums2 = ctl.computeWinningAndLosing(digest2); ctl.beginEpochWithValidTime(); - for (int i = 0; i < 5; ++i) - { - const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, losing2); - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), i + 1u); - } + ctl.buyRandomTickets(5, ticketPrice, nums2.losing); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 5u); - ctl.setPrevSpectrumDigest(digest2); - ctl.advanceOneDayAndDraw(); + ctl.drawWithDigest(digest2); const QTF::GetWinnerData_output afterRound2 = ctl.getWinnerData(); EXPECT_EQ(afterRound2.winnerData.winnerCounter, 0u) << "Winner snapshot must reset each round"; @@ -1850,7 +1890,7 @@ TEST(ContractQThirtyFour, WinnerData_ResetEachRound) TEST(ContractQThirtyFour, BuyTicket_ValidNumberSelections_EdgeCases_Success) { ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); + ctl.startAnyDayEpoch(); const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); @@ -1881,8 +1921,7 @@ TEST(ContractQThirtyFour, MultipleRounds_JackpotAccumulates) m256i testDigest = {}; testDigest.m256i_u64[0] = 0x0DDC0FFEE0DDF00DULL; - const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); - const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + const auto nums = ctl.computeWinningAndLosing(testDigest); const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); uint64 prevJackpot = 0; @@ -1892,14 +1931,8 @@ TEST(ContractQThirtyFour, MultipleRounds_JackpotAccumulates) { ctl.beginEpochWithValidTime(); - for (int i = 0; i < 10; ++i) - { - const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); - } - - ctl.setPrevSpectrumDigest(testDigest); - ctl.advanceOneDayAndDraw(); + ctl.buyRandomTickets(10, ticketPrice, nums.losing); + ctl.drawWithDigest(testDigest); // Jackpot should increase each round (no k=4 winners in this test) const uint64 currentJackpot = ctl.state()->getJackpot(); @@ -1933,7 +1966,7 @@ TEST(ContractQThirtyFour, MultipleRounds_StateResetsCorrectly) EXPECT_EQ(ctl.state()->getNumberOfPlayers(), static_cast(playersThisRound)); - ctl.advanceOneDayAndDraw(); + ctl.triggerDrawTick(); // Players should be cleared after each round EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); @@ -2006,8 +2039,7 @@ TEST(ContractQThirtyFour, Schedule_DrawOnlyOnScheduledDays) TEST(ContractQThirtyFour, DrawHour_NoDrawBeforeScheduledHour) { ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - ctl.beginEpochWithValidTime(); + ctl.startAnyDayEpoch(); const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); @@ -2086,25 +2118,14 @@ TEST(ContractQThirtyFour, EstimatePrizePayouts_WithTicketsSingleWinner) { ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); + ctl.startAnyDayEpoch(); // Buy 100 tickets constexpr uint64 ticketPrice = 1000000ull; // 1M QU constexpr uint64 numTickets = 100; - QTFRandomValues numbers; - numbers.set(0, 1); - numbers.set(1, 2); - numbers.set(2, 3); - numbers.set(3, 4); - - for (uint64 i = 0; i < numTickets; ++i) - { - id user = id::randomValue(); - increaseEnergy(user, ticketPrice); - QTF::BuyTicket_output result = ctl.buyTicket(user, ticketPrice, numbers); - EXPECT_EQ(result.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); - } + const QTFRandomValues numbers = ctl.makeValidNumbers(1, 2, 3, 4); + ctl.buyRandomTickets(numTickets, ticketPrice, numbers); // Verify tickets were purchased EXPECT_EQ(ctl.state()->getNumberOfPlayers(), numTickets); @@ -2125,9 +2146,8 @@ TEST(ContractQThirtyFour, EstimatePrizePayouts_WithTicketsSingleWinner) // Winners block using contract constants const QTF::GetFees_output fees = ctl.getFees(); - const uint64 winnersBlock = (expectedRevenue * fees.winnerFeePercent) / 100; - const uint64 k2PoolExpected = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000; - const uint64 k3PoolExpected = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000; + uint64 winnersBlock = 0, k2PoolExpected = 0, k3PoolExpected = 0; + computeBaselinePrizePools(expectedRevenue, fees, winnersBlock, k2PoolExpected, k3PoolExpected); EXPECT_EQ(estimate.k2Pool, k2PoolExpected); EXPECT_EQ(estimate.k3Pool, k3PoolExpected); @@ -2140,25 +2160,14 @@ TEST(ContractQThirtyFour, EstimatePrizePayouts_WithTicketsSingleWinner) TEST(ContractQThirtyFour, EstimatePrizePayouts_WithMultipleWinners) { ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); + ctl.startAnyDayEpoch(); // Buy 1000 tickets const uint64 ticketPrice = 1000000ull; const uint64 numTickets = 1000; - QTFRandomValues numbers; - numbers.set(0, 5); - numbers.set(1, 10); - numbers.set(2, 15); - numbers.set(3, 20); - - for (uint64 i = 0; i < numTickets; ++i) - { - id user = id::randomValue(); - increaseEnergy(user, ticketPrice); - QTF::BuyTicket_output result = ctl.buyTicket(user, ticketPrice, numbers); - EXPECT_EQ(result.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); - } + const QTFRandomValues numbers = ctl.makeValidNumbers(5, 10, 15, 20); + ctl.buyRandomTickets(numTickets, ticketPrice, numbers); // Verify tickets were purchased EXPECT_EQ(ctl.state()->getNumberOfPlayers(), numTickets); @@ -2168,9 +2177,8 @@ TEST(ContractQThirtyFour, EstimatePrizePayouts_WithMultipleWinners) const uint64 expectedRevenue = ticketPrice * numTickets; const QTF::GetFees_output fees = ctl.getFees(); - const uint64 winnersBlock = (expectedRevenue * fees.winnerFeePercent) / 100; - const uint64 k2Pool = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000; - const uint64 k3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000; + uint64 winnersBlock = 0, k2Pool = 0, k3Pool = 0; + computeBaselinePrizePools(expectedRevenue, fees, winnersBlock, k2Pool, k3Pool); // Verify pools EXPECT_EQ(estimate.k2Pool, k2Pool); @@ -2191,25 +2199,14 @@ TEST(ContractQThirtyFour, EstimatePrizePayouts_WithMultipleWinners) TEST(ContractQThirtyFour, EstimatePrizePayouts_NoWinnersShowsPotential) { ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); + ctl.startAnyDayEpoch(); // Buy 50 tickets const uint64 ticketPrice = 1000000ull; const uint64 numTickets = 50; - QTFRandomValues numbers; - numbers.set(0, 7); - numbers.set(1, 14); - numbers.set(2, 21); - numbers.set(3, 28); - - for (uint64 i = 0; i < numTickets; ++i) - { - id user = id::randomValue(); - increaseEnergy(user, ticketPrice); - QTF::BuyTicket_output result = ctl.buyTicket(user, ticketPrice, numbers); - EXPECT_EQ(result.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); - } + const QTFRandomValues numbers = ctl.makeValidNumbers(7, 14, 21, 28); + ctl.buyRandomTickets(numTickets, ticketPrice, numbers); // Verify tickets were purchased EXPECT_EQ(ctl.state()->getNumberOfPlayers(), numTickets); @@ -2219,19 +2216,14 @@ TEST(ContractQThirtyFour, EstimatePrizePayouts_NoWinnersShowsPotential) const uint64 expectedRevenue = ticketPrice * numTickets; const QTF::GetFees_output fees = ctl.getFees(); - const uint64 winnersBlock = (expectedRevenue * fees.winnerFeePercent) / 100; - const uint64 k2Pool = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000; - const uint64 k3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000; + uint64 winnersBlock = 0, k2Pool = 0, k3Pool = 0; + computeBaselinePrizePools(expectedRevenue, fees, winnersBlock, k2Pool, k3Pool); // When no winners specified, should show full pool (capped) EXPECT_EQ(estimate.k2PayoutPerWinner, std::min(k2Pool, estimate.perWinnerCap)); EXPECT_EQ(estimate.k3PayoutPerWinner, std::min(k3Pool, estimate.perWinnerCap)); } -// ============================================================================ -// K=4 JACKPOT WIN TESTS -// ============================================================================ - // ============================================================================ // DETERMINISTIC WINNER TESTING // ============================================================================ @@ -2243,23 +2235,23 @@ TEST(ContractQThirtyFour, EstimatePrizePayouts_NoWinnersShowsPotential) // // Approach: // 1. Create a fixed test prevSpectrumDigest (e.g., testDigest) -// 2. Call computeWinningNumbersForDigest(testDigest) to pre-compute winning numbers +// 2. Compute expected winning numbers for that digest // 3. Buy tickets with exact winning numbers (for k=4), partial matches (for k=2/k=3), etc. -// 4. Call setPrevSpectrumDigest(testDigest) BEFORE triggering settlement +// 4. Trigger settlement with drawWithDigest(testDigest) // 5. Settlement will use our fixed digest, generating the pre-computed winning numbers // 6. Verify actual payouts, jackpot depletion, FR resets, etc. // -// This enables comprehensive testing of: -// ✅ Actual k=4 jackpot win payouts and jackpot depletion -// ✅ Actual k=2/k=3 winner payouts with real matching logic -// ✅ Actual FR reset behavior after k=4 win (frRoundsSinceK4 = 0) -// ✅ Pool splitting among multiple winners -// ✅ Revenue distribution and fee calculations with real winners +// This enables deterministic testing of: +// - Actual k=4 jackpot win payouts and jackpot depletion +// - Actual k=2/k=3 winner payouts with real matching logic +// - Actual FR reset behavior after k=4 win (frRoundsSinceK4 = 0) +// - Pool splitting among multiple winners +// - Revenue distribution and fee calculations with real winners TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_DepletesAndReseeds) { ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + ctl.startAnyDayEpoch(); // Ensure QRP has enough reserve to reseed to target. increaseEnergy(ctl.qrpSelf(), QTF_DEFAULT_TARGET_JACKPOT + 1000000ULL); @@ -2269,33 +2261,29 @@ TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_DepletesAndReseeds) m256i testDigest = {}; testDigest.m256i_u64[0] = 0x123456789ABCDEF0ULL; // Arbitrary seed - // Pre-compute winning numbers for this digest - QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); + const auto nums = ctl.computeWinningAndLosing(testDigest); // Setup: FR active with jackpot below target const uint64 initialJackpot = 800000000ULL; // 800M QU ctl.state()->setJackpot(initialJackpot); ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); // 1B target - ctl.state()->setFrActive(true); - ctl.state()->setFrRoundsSinceK4(10); + ctl.forceFREnabledWithinWindow(10); // IMPORTANT: internal `state.jackpot` must be backed by actual contract balance, otherwise transfers will fail. increaseEnergy(ctl.qtfSelf(), static_cast(initialJackpot)); - ctl.beginEpochWithValidTime(); - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); // User1: Buy ticket with EXACT winning numbers (k=4 winner) const id k4Winner = id::randomValue(); - ctl.fundAndBuyTicket(k4Winner, ticketPrice, winningNumbers); + ctl.fundAndBuyTicket(k4Winner, ticketPrice, nums.winning); // User2: Buy ticket with 3 matching numbers (k=3 winner) - QTFRandomValues k3Numbers = ctl.makeK3Numbers(winningNumbers); + QTFRandomValues k3Numbers = ctl.makeK3Numbers(nums.winning); const id k3Winner = id::randomValue(); ctl.fundAndBuyTicket(k3Winner, ticketPrice, k3Numbers); // User3: Buy ticket with 2 matching numbers (k=2 winner) - QTFRandomValues k2Numbers = ctl.makeK2Numbers(winningNumbers); + QTFRandomValues k2Numbers = ctl.makeK2Numbers(nums.winning); const id k2Winner = id::randomValue(); ctl.fundAndBuyTicket(k2Winner, ticketPrice, k2Numbers); @@ -2312,11 +2300,8 @@ TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_DepletesAndReseeds) EXPECT_EQ(jackpotBefore, initialJackpot); EXPECT_EQ(roundsSinceK4Before, 10u); - // Set the deterministic prevSpectrumDigest BEFORE triggering settlement - ctl.setPrevSpectrumDigest(testDigest); - - // Trigger settlement - this will use our fixed prevSpectrumDigest - ctl.advanceOneDayAndDraw(); + // Trigger settlement using our fixed prevSpectrumDigest + ctl.drawWithDigest(testDigest); // Verify k=4 jackpot win behavior: const uint64 jackpotAfter = ctl.state()->getJackpot(); @@ -2332,10 +2317,10 @@ TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_DepletesAndReseeds) // 3. Verify winner data contains our winning numbers QTF::GetWinnerData_output winnerData = ctl.getWinnerData(); - EXPECT_EQ(winnerData.winnerData.winnerValues.get(0), winningNumbers.get(0)); - EXPECT_EQ(winnerData.winnerData.winnerValues.get(1), winningNumbers.get(1)); - EXPECT_EQ(winnerData.winnerData.winnerValues.get(2), winningNumbers.get(2)); - EXPECT_EQ(winnerData.winnerData.winnerValues.get(3), winningNumbers.get(3)); + EXPECT_EQ(winnerData.winnerData.winnerValues.get(0), nums.winning.get(0)); + EXPECT_EQ(winnerData.winnerData.winnerValues.get(1), nums.winning.get(1)); + EXPECT_EQ(winnerData.winnerData.winnerValues.get(2), nums.winning.get(2)); + EXPECT_EQ(winnerData.winnerData.winnerValues.get(3), nums.winning.get(3)); // Verify k=4 winner received payout (full jackpot share). const long long k4WinnerBalance = getBalance(k4Winner); @@ -2346,69 +2331,40 @@ TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_DepletesAndReseeds) TEST(ContractQThirtyFour, DeterministicWinner_K2K3Payouts_VerifyRevenueSplit) { ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + ctl.startAnyDayEpoch(); // This test validates baseline k2/k3 pool splitting (no FR rake). // Force FR activation window to be expired so SettleEpoch cannot auto-enable FR. - ctl.state()->setFrActive(false); - ctl.state()->setFrRoundsSinceK4(QTF_FR_POST_K4_WINDOW_ROUNDS); + ctl.forceFRDisabledForBaseline(); // Create deterministic prevSpectrumDigest m256i testDigest = {}; testDigest.m256i_u64[0] = 0xFEDCBA9876543210ULL; // Different seed - // Pre-compute winning numbers - QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); - - ctl.beginEpochWithValidTime(); + const auto nums = ctl.computeWinningAndLosing(testDigest); const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); // Create multiple k=2 and k=3 winners to test pool splitting // 2 k=3 winners - QTFRandomValues k3Numbers1 = ctl.makeK3Numbers(winningNumbers); + QTFRandomValues k3Numbers1 = ctl.makeK3Numbers(nums.winning, 0); const id k3Winner1 = id::randomValue(); ctl.fundAndBuyTicket(k3Winner1, ticketPrice, k3Numbers1); - QTFRandomValues k3Numbers2 = ctl.makeK3Numbers(winningNumbers); - // Ensure two different k3 tickets (avoid identical picks across players). - if (k3Numbers2.get(0) == k3Numbers1.get(0) && k3Numbers2.get(1) == k3Numbers1.get(1) && k3Numbers2.get(2) == k3Numbers1.get(2) && - k3Numbers2.get(3) == k3Numbers1.get(3)) - { - k3Numbers2 = ctl.makeNumbersWithExactMatches(winningNumbers, 3); - // Swap a non-winning position deterministically: replace last entry with next available losing number. - const QTFRandomValues losing = ctl.makeLosingNumbers(winningNumbers); - k3Numbers2.set(3, losing.get(1)); - } + QTFRandomValues k3Numbers2 = ctl.makeK3Numbers(nums.winning, 1); const id k3Winner2 = id::randomValue(); ctl.fundAndBuyTicket(k3Winner2, ticketPrice, k3Numbers2); // 3 k=2 winners - QTFRandomValues k2Numbers1 = ctl.makeK2Numbers(winningNumbers); + QTFRandomValues k2Numbers1 = ctl.makeK2Numbers(nums.winning, 0); const id k2Winner1 = id::randomValue(); ctl.fundAndBuyTicket(k2Winner1, ticketPrice, k2Numbers1); - QTFRandomValues k2Numbers2 = ctl.makeK2Numbers(winningNumbers); - // Make it different from k2Numbers1 while keeping exactly 2 matches. - if (k2Numbers2.get(0) == k2Numbers1.get(0) && k2Numbers2.get(1) == k2Numbers1.get(1) && k2Numbers2.get(2) == k2Numbers1.get(2) && - k2Numbers2.get(3) == k2Numbers1.get(3)) - { - const QTFRandomValues losing = ctl.makeLosingNumbers(winningNumbers); - k2Numbers2.set(2, losing.get(0)); - } + QTFRandomValues k2Numbers2 = ctl.makeK2Numbers(nums.winning, 1); const id k2Winner2 = id::randomValue(); ctl.fundAndBuyTicket(k2Winner2, ticketPrice, k2Numbers2); - QTFRandomValues k2Numbers3 = ctl.makeK2Numbers(winningNumbers); - // Make it different from previous k2 tickets while keeping exactly 2 matches. - if ((k2Numbers3.get(0) == k2Numbers1.get(0) && k2Numbers3.get(1) == k2Numbers1.get(1) && k2Numbers3.get(2) == k2Numbers1.get(2) && - k2Numbers3.get(3) == k2Numbers1.get(3)) || - (k2Numbers3.get(0) == k2Numbers2.get(0) && k2Numbers3.get(1) == k2Numbers2.get(1) && k2Numbers3.get(2) == k2Numbers2.get(2) && - k2Numbers3.get(3) == k2Numbers2.get(3))) - { - const QTFRandomValues losing = ctl.makeLosingNumbers(winningNumbers); - k2Numbers3.set(3, losing.get(2)); - } + QTFRandomValues k2Numbers3 = ctl.makeK2Numbers(nums.winning, 2); const id k2Winner3 = id::randomValue(); ctl.fundAndBuyTicket(k2Winner3, ticketPrice, k2Numbers3); @@ -2429,15 +2385,12 @@ TEST(ContractQThirtyFour, DeterministicWinner_K2K3Payouts_VerifyRevenueSplit) const uint64 expectedK2Pool = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000; // 28% of winners block const uint64 expectedK3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000; // 40% of winners block - // Set deterministic prevSpectrumDigest - ctl.setPrevSpectrumDigest(testDigest); - // Get balances before settlement const long long k3Winner1Before = getBalance(k3Winner1); const long long k2Winner1Before = getBalance(k2Winner1); // Trigger settlement - ctl.advanceOneDayAndDraw(); + ctl.drawWithDigest(testDigest); // Verify winner payouts // k=3 pool split between 2 winners @@ -2454,82 +2407,26 @@ TEST(ContractQThirtyFour, DeterministicWinner_K2K3Payouts_VerifyRevenueSplit) // Verify winning numbers in winner data QTF::GetWinnerData_output winnerData = ctl.getWinnerData(); - EXPECT_EQ(winnerData.winnerData.winnerValues.get(0), winningNumbers.get(0)); - EXPECT_EQ(winnerData.winnerData.winnerValues.get(1), winningNumbers.get(1)); - EXPECT_EQ(winnerData.winnerData.winnerValues.get(2), winningNumbers.get(2)); - EXPECT_EQ(winnerData.winnerData.winnerValues.get(3), winningNumbers.get(3)); + EXPECT_EQ(winnerData.winnerData.winnerValues.get(0), nums.winning.get(0)); + EXPECT_EQ(winnerData.winnerData.winnerValues.get(1), nums.winning.get(1)); + EXPECT_EQ(winnerData.winnerData.winnerValues.get(2), nums.winning.get(2)); + EXPECT_EQ(winnerData.winnerData.winnerValues.get(3), nums.winning.get(3)); // Jackpot should have grown (no k=4 winner) EXPECT_GT(ctl.state()->getJackpot(), 0ULL); } -TEST(ContractQThirtyFour, Settlement_NoWinners_JackpotGrowsAndCounterIncrements) -{ - ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0x9999AAAABBBBCCCCULL; - const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); - const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); - - // Setup: FR active with jackpot below target - ctl.state()->setJackpot(800000000ULL); // 800M QU jackpot - ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); // 1B target - ctl.state()->setFrActive(true); - ctl.state()->setFrRoundsSinceK4(10); - - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const uint64 jackpotBefore = ctl.state()->getJackpot(); - EXPECT_EQ(jackpotBefore, 800000000ULL); - - constexpr int numPlayers = 50; - for (int i = 0; i < numPlayers; ++i) - { - const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); - } - - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), static_cast(numPlayers)); - - const uint64 roundsSinceK4Before = ctl.state()->getFrRoundsSinceK4(); - EXPECT_EQ(roundsSinceK4Before, 10u); - - ctl.setPrevSpectrumDigest(testDigest); - ctl.advanceOneDayAndDraw(); - - // After settlement (deterministic: no k=4 win is possible): - const uint64 jackpotAfter = ctl.state()->getJackpot(); - EXPECT_GT(jackpotAfter, jackpotBefore) << "Jackpot should grow when no k=4 winner"; - - const uint64 roundsSinceK4After = ctl.state()->getFrRoundsSinceK4(); - EXPECT_EQ(roundsSinceK4After, roundsSinceK4Before + 1) << "Counter should increment when no k=4 win"; - - // Note: This test verifies the no-win path. A full k=4 win test would require - // either mocking K12 output or extensive probabilistic testing with many rounds. - // The k=4 win logic in SettleEpoch (lines 1417-1444) handles: - // - Jackpot payout: jackpot / countK4 - // - Depletion: state.jackpot = 0 - // - Counter reset: frRoundsSinceK4 = 0, frRoundsAtOrAboveTarget = 0 - // - QRP reseed: request min(QRP balance, targetJackpot) -} - TEST(ContractQThirtyFour, EstimatePrizePayouts_FRMode_AppliesRakeToPools) { ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - - ctl.beginEpochWithValidTime(); + ctl.startAnyDayEpoch(); const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); // Enable FR so EstimatePrizePayouts applies the 5% winners rake. ctl.state()->setJackpot(QTF_DEFAULT_TARGET_JACKPOT / 2); ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); - ctl.state()->setFrActive(true); - ctl.state()->setFrRoundsSinceK4(1); + ctl.forceFREnabledWithinWindow(1); constexpr uint64 numPlayers = 100; for (uint64 i = 0; i < numPlayers; ++i) @@ -2563,8 +2460,7 @@ TEST(ContractQThirtyFour, EstimatePrizePayouts_FRMode_AppliesRakeToPools) TEST(ContractQThirtyFour, ReserveTopUp_FloorGuarantee_VerifyLimits) { ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - ctl.beginEpochWithValidTime(); + ctl.startAnyDayEpoch(); const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); @@ -2635,8 +2531,7 @@ TEST(ContractQThirtyFour, FR_HighDeficit_ExtraRedirectsCalculated) // Fix RNG so we can deterministically avoid winners (and especially k=4). m256i testDigest = {}; testDigest.m256i_u64[0] = 0x4040404040404040ULL; - const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); - const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + const auto nums = ctl.computeWinningAndLosing(testDigest); // Setup: High deficit scenario // Jackpot = 0, Target = 1B, FR active @@ -2652,11 +2547,7 @@ TEST(ContractQThirtyFour, FR_HighDeficit_ExtraRedirectsCalculated) // Add many players to generate high revenue constexpr int numPlayers = 500; - for (int i = 0; i < numPlayers; ++i) - { - const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); - } + ctl.buyRandomTickets(numPlayers, ticketPrice, nums.losing); const uint64 revenue = ticketPrice * numPlayers; // 500M QU const uint64 deficit = QTF_DEFAULT_TARGET_JACKPOT - 0; // 1B deficit @@ -2676,8 +2567,7 @@ TEST(ContractQThirtyFour, FR_HighDeficit_ExtraRedirectsCalculated) const uint64 jackpotBefore = ctl.state()->getJackpot(); EXPECT_EQ(jackpotBefore, 0ULL); - ctl.setPrevSpectrumDigest(testDigest); - ctl.advanceOneDayAndDraw(); + ctl.drawWithDigest(testDigest); // After settlement with FR active and high deficit: const uint64 devBalAfter = getBalance(QTF_DEV_ADDRESS); @@ -2722,8 +2612,7 @@ TEST(ContractQThirtyFour, FR_PostK4WindowExpiry_DoesNotActivateWhenInactive) m256i testDigest = {}; testDigest.m256i_u64[0] = 0xABCDABCDABCDABCDULL; - const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); - const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + const auto nums = ctl.computeWinningAndLosing(testDigest); // Setup: Jackpot below target, but window expired and FR inactive. ctl.state()->setJackpot(QTF_DEFAULT_TARGET_JACKPOT / 2); @@ -2735,14 +2624,9 @@ TEST(ContractQThirtyFour, FR_PostK4WindowExpiry_DoesNotActivateWhenInactive) const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); constexpr int numPlayers = 10; - for (int i = 0; i < numPlayers; ++i) - { - const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); - } + ctl.buyRandomTickets(numPlayers, ticketPrice, nums.losing); - ctl.setPrevSpectrumDigest(testDigest); - ctl.advanceOneDayAndDraw(); + ctl.drawWithDigest(testDigest); EXPECT_EQ(ctl.state()->getFrActive(), false); EXPECT_EQ(ctl.state()->getFrRoundsSinceK4(), QTF_FR_POST_K4_WINDOW_ROUNDS + 1); @@ -2755,8 +2639,7 @@ TEST(ContractQThirtyFour, FR_PostK4WindowExpiry_DoesNotReactivateWhenWindowExpir m256i testDigest = {}; testDigest.m256i_u64[0] = 0xFACEFEEDFACEFEEDULL; - const QTFRandomValues winningNumbers = ctl.computeWinningNumbersForDigest(testDigest); - const QTFRandomValues losingNumbers = ctl.makeLosingNumbers(winningNumbers); + const auto nums = ctl.computeWinningAndLosing(testDigest); // Setup: FR active, jackpot below target, but approaching window expiry ctl.state()->setJackpot(QTF_DEFAULT_TARGET_JACKPOT / 2); // 500M (below target) @@ -2773,7 +2656,7 @@ TEST(ContractQThirtyFour, FR_PostK4WindowExpiry_DoesNotReactivateWhenWindowExpir for (int i = 0; i < numPlayers; ++i) { const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); + ctl.fundAndBuyTicket(user, ticketPrice, nums.losing); } // Verify FR is active before settlement @@ -2781,8 +2664,7 @@ TEST(ContractQThirtyFour, FR_PostK4WindowExpiry_DoesNotReactivateWhenWindowExpir EXPECT_EQ(ctl.state()->getFrRoundsSinceK4(), QTF_FR_POST_K4_WINDOW_ROUNDS - 1); EXPECT_LT(ctl.state()->getJackpot(), ctl.state()->getTargetJackpotInternal()); - ctl.setPrevSpectrumDigest(testDigest); - ctl.advanceOneDayAndDraw(); + ctl.drawWithDigest(testDigest); // After settlement (deterministic: no k=4 win is possible): // - roundsSinceK4 should increment to 50 @@ -2802,11 +2684,10 @@ TEST(ContractQThirtyFour, FR_PostK4WindowExpiry_DoesNotReactivateWhenWindowExpir for (int i = 0; i < numPlayers; ++i) { const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); + ctl.fundAndBuyTicket(user, ticketPrice, nums.losing); } - ctl.setPrevSpectrumDigest(testDigest); - ctl.advanceOneDayAndDraw(); + ctl.drawWithDigest(testDigest); // After second round: // - Jackpot still below target @@ -2841,10 +2722,9 @@ TEST(ContractQThirtyFour, FR_PostK4WindowExpiry_DoesNotReactivateWhenWindowExpir for (int i = 0; i < numPlayers; ++i) { const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, losingNumbers); + ctl.fundAndBuyTicket(user, ticketPrice, nums.losing); } - ctl.setPrevSpectrumDigest(testDigest); - ctl.advanceOneDayAndDraw(); + ctl.drawWithDigest(testDigest); // FR state should not change (no re-activation possible when roundsSinceK4 >= 50) EXPECT_EQ(ctl.state()->getFrActive(), frActiveBeforeThirdRound) << "FR should not re-activate when roundsSinceK4 >= 50, even if jackpot < target"; From f94e5a6350f03404c529eb6e43f69b74c115142f Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 17 Dec 2025 00:17:42 +0300 Subject: [PATCH 16/77] Update tests --- src/contracts/QThirtyFour.h | 4 +- test/contract_qtf.cpp | 405 +++++++++++++++++++++++++++--------- 2 files changed, 314 insertions(+), 95 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index d8d41d80c..6497d440e 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -64,7 +64,7 @@ constexpr uint32 QTF_DEFAULT_INIT_TIME = 22u << 9 | 4u << 5 | 13u; // RL_DEFAULT const id QTF_ADDRESS_DEV_TEAM = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); const id QTF_RANDOM_LOTTERY_CONTRACT_ID = id(RL_CONTRACT_INDEX, 0, 0, 0); -const uint64 QTF_RANDOM_LOTTERY_ASSET_NAME = *reinterpret_cast("RL"); +constexpr uint64 QTF_RANDOM_LOTTERY_ASSET_NAME = 19538; // RL const id QTF_RESERVE_POOL_CONTRACT_ID = id(QRP_CONTRACT_INDEX, 0, 0, 0); using QTFRandomValues = Array; @@ -1501,7 +1501,7 @@ struct QTF : public ContractBase // Manual dividend payout to RL shareholders (no extra fee). if (locals.distPayout > 0) { - locals.rlAsset.issuer = QTF_RANDOM_LOTTERY_CONTRACT_ID; + locals.rlAsset.issuer = id::zero(); locals.rlAsset.assetName = QTF_RANDOM_LOTTERY_ASSET_NAME; locals.rlTotalShares = NUMBER_OF_COMPUTORS; diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp index e2a9ad3b2..7aba72ad6 100644 --- a/test/contract_qtf.cpp +++ b/test/contract_qtf.cpp @@ -9,6 +9,7 @@ #include #include +#include // Procedure/function indices (must match REGISTER_USER_FUNCTIONS_AND_PROCEDURES in `src/contracts/QThirtyFour.h`). constexpr uint16 QTF_PROCEDURE_BUY_TICKET = 1; @@ -29,6 +30,11 @@ constexpr uint16 QTF_FUNCTION_ESTIMATE_PRIZE_PAYOUTS = 9; namespace { + static void issueRlSharesTo(std::vector>& initialOwnerShares, bool warnOnTooFewShares = true) + { + issueContractShares(RL_CONTRACT_INDEX, initialOwnerShares, warnOnTooFewShares); + } + static void primeQpiFunctionContext(QpiContextUserFunctionCall& qpi) { QTF::GetTicketPrice_input input{}; @@ -95,6 +101,7 @@ class QTFChecker : public QTF void setFrActive(bit value) { frActive = value; } void setFrRoundsSinceK4(uint16 value) { frRoundsSinceK4 = value; } void setFrRoundsAtOrAboveTarget(uint16 value) { frRoundsAtOrAboveTarget = value; } + void setOverflowAlphaBP(uint64 value) { overflowAlphaBP = value; } const PlayerData& getPlayer(uint64 index) const { return players.get(index); } void addPlayerDirect(const id& playerId, const QTFRandomValues& randomValues) { players.set(numberOfPlayers++, {playerId, randomValues}); } @@ -433,7 +440,7 @@ class ContractTestingQTF : protected ContractTesting void forceBeginTick() { - system.tick = system.tick + (RL_TICK_UPDATE_PERIOD - mod(system.tick, static_cast(RL_TICK_UPDATE_PERIOD))); + system.tick = system.tick + (RL_TICK_UPDATE_PERIOD - (system.tick % RL_TICK_UPDATE_PERIOD)); beginTick(); } @@ -452,6 +459,7 @@ class ContractTestingQTF : protected ContractTesting { state()->setFrActive(false); state()->setFrRoundsSinceK4(QTF_FR_POST_K4_WINDOW_ROUNDS); + state()->setOverflowAlphaBP(QTF_BASELINE_OVERFLOW_ALPHA_BP); } void forceFREnabledWithinWindow(uint16 roundsSinceK4 = 1) @@ -506,62 +514,18 @@ class ContractTestingQTF : protected ContractTesting } // Compute the winning numbers that would be generated for a given prevSpectrumDigest. - // This mirrors the contract GetRandomValues logic (including collision handling). + // Uses the contract GetRandomValues() implementation (so tests don't duplicate RNG logic). // Returns values in generation order (not sorted). QTFRandomValues computeWinningNumbersForDigest(const m256i& digest) { - // Replicate QTF's GetRandomValues logic - // seed = qpi.K12(digest).u64._0 m256i hashResult; KangarooTwelve((const uint8*)&digest, sizeof(m256i), (uint8*)&hashResult, sizeof(m256i)); const uint64 seed = hashResult.m256i_u64[0]; - QTFRandomValues result; - uint8 used[31] = {0}; // Track used numbers [0..30], we only use [1..30] - - for (uint8 index = 0; index < 4; ++index) - { - // deriveOne(seed, index, tempValue) - uint64 tempValue = seed + 0x9e3779b97f4a7c15ULL * (index + 1); - // mix64 - tempValue ^= tempValue >> 30; - tempValue *= 0xbf58476d1ce4e5b9ULL; - tempValue ^= tempValue >> 27; - tempValue *= 0x94d049bb133111ebULL; - tempValue ^= tempValue >> 31; - - uint8 candidate = static_cast((tempValue % 30) + 1); - - // Handle collisions with the same regeneration logic as contract - uint32 attempts = 0; - while (used[candidate] && attempts < 100) - { - ++attempts; - tempValue ^= tempValue >> 12; - tempValue ^= tempValue << 25; - tempValue ^= tempValue >> 27; - tempValue *= 2685821657736338717ULL; - candidate = static_cast((tempValue % 30) + 1); - } - - // Fallback: find first unused - if (used[candidate]) - { - for (uint8 fallback = 1; fallback <= 30; ++fallback) - { - if (!used[fallback]) - { - candidate = fallback; - break; - } - } - } - - used[candidate] = 1; - result.set(index, candidate); - } - - return result; + QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); + primeQpiFunctionContext(qpi); + const auto out = state()->callGetRandomValues(qpi, seed); + return out.values; } struct WinningAndLosing @@ -967,7 +931,7 @@ TEST(ContractQThirtyFour_Private, ReturnAllTickets_RefundsEachPlayerAndClearsVia ctl.addPlayerDirect(p1, n1); ctl.addPlayerDirect(p2, n2); - increaseEnergy(ctl.qtfSelf(), static_cast(ticketPrice * 2)); + increaseEnergy(ctl.qtfSelf(), ticketPrice * 2); const uint64 balBeforeContract = getBalance(ctl.qtfSelf()); const uint64 balBeforeP1 = getBalance(p1); const uint64 balBeforeP2 = getBalance(p2); @@ -1167,8 +1131,7 @@ TEST(ContractQThirtyFour, BuyTicket_MultiplePlayers_Success) for (int i = 0; i < 10; ++i) { const id user = id::randomValue(); - QTFRandomValues nums = - ctl.makeValidNumbers(static_cast(1 + i), static_cast(11 + i), static_cast(21), static_cast(30)); + QTFRandomValues nums = ctl.makeValidNumbers(static_cast(1 + i), static_cast(11 + i), 21, 30); ctl.fundAndBuyTicket(user, ticketPrice, nums); } @@ -1451,22 +1414,6 @@ TEST(ContractQThirtyFour, GetPools_ReserveReflectsQRPAvailable) // SETTLEMENT AND PAYOUT TESTS // ============================================================================ -TEST(ContractQThirtyFour, Settlement_NoPlayers_NoChanges) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - - const uint64 jackpotBefore = ctl.state()->getJackpot(); - const QTF::GetWinnerData_output winnersBefore = ctl.getWinnerData(); - - ctl.triggerDrawTick(); - - // No changes when no players - EXPECT_EQ(ctl.state()->getJackpot(), jackpotBefore); - const QTF::GetWinnerData_output winnersAfter = ctl.getWinnerData(); - EXPECT_EQ(winnersAfter.winnerData.winnerCounter, winnersBefore.winnerData.winnerCounter); -} - TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed) { ContractTestingQTF ctl; @@ -1482,7 +1429,13 @@ TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed) const QTF::GetFees_output fees = ctl.getFees(); constexpr uint64 numPlayers = 10; - ctl.state()->setJackpot(QTF_DEFAULT_TARGET_JACKPOT); + // Ensure RL shares exist so distribution path is exercised deterministically. + const id shareholder1 = id::randomValue(); + const id shareholder2 = id::randomValue(); + constexpr uint32 shares1 = NUMBER_OF_COMPUTORS / 3; + constexpr uint32 shares2 = NUMBER_OF_COMPUTORS - shares1; + std::vector> rlShares{{shareholder1, shares1}, {shareholder2, shares2}}; + issueRlSharesTo(rlShares, false); // Verify FR is not active initially (baseline mode) EXPECT_EQ(ctl.state()->getFrActive(), false); @@ -1492,6 +1445,9 @@ TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed) const uint64 totalRevenue = ticketPrice * numPlayers; const uint64 devBalBefore = getBalance(QTF_DEV_ADDRESS); + const uint64 sh1Before = getBalance(shareholder1); + const uint64 sh2Before = getBalance(shareholder2); + const uint64 rlBefore = getBalance(id(RL_CONTRACT_INDEX, 0, 0, 0)); const uint64 contractBalBefore = getBalance(ctl.qtfSelf()); EXPECT_EQ(contractBalBefore, totalRevenue); @@ -1506,10 +1462,43 @@ TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed) EXPECT_EQ(getBalance(QTF_DEV_ADDRESS), devBalBefore + expectedDevFee) << "In baseline mode, dev should receive full " << static_cast(fees.teamFeePercent) << "% of revenue"; + // Distribution is paid to RL shareholders with flooring to dividendPerShare and payback remainder to RL contract. + const uint64 expectedDistFee = (totalRevenue * fees.distributionFeePercent) / 100; + const uint64 dividendPerShare = expectedDistFee / NUMBER_OF_COMPUTORS; + const uint64 expectedSh1Gain = static_cast(shares1) * dividendPerShare; + const uint64 expectedSh2Gain = static_cast(shares2) * dividendPerShare; + const uint64 expectedPayback = expectedDistFee - (dividendPerShare * NUMBER_OF_COMPUTORS); + EXPECT_EQ(getBalance(shareholder1), sh1Before + expectedSh1Gain); + EXPECT_EQ(getBalance(shareholder2), sh2Before + expectedSh2Gain); + EXPECT_EQ(getBalance(id(RL_CONTRACT_INDEX, 0, 0, 0)), rlBefore + expectedPayback); + + // No winners -> winnersOverflow == winnersBlock. In baseline: 50/50 split reserve/jackpot. + const uint64 winnersBlock = (totalRevenue * fees.winnerFeePercent) / 100; + const uint64 reserveAdd = (winnersBlock * QTF_BASELINE_OVERFLOW_ALPHA_BP) / 10000; + const uint64 expectedJackpotAdd = winnersBlock - reserveAdd; + EXPECT_EQ(ctl.state()->getJackpot(), expectedJackpotAdd); + EXPECT_EQ(static_cast(getBalance(ctl.qtfSelf())), expectedJackpotAdd) << "Contract balance should match carry (jackpot) after settlement"; + // Players cleared EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); } +TEST(ContractQThirtyFour, Settlement_NoPlayers_NoChanges) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + const uint64 jackpotBefore = ctl.state()->getJackpot(); + const QTF::GetWinnerData_output winnersBefore = ctl.getWinnerData(); + + ctl.triggerDrawTick(); + + // No changes when no players + EXPECT_EQ(ctl.state()->getJackpot(), jackpotBefore); + const QTF::GetWinnerData_output winnersAfter = ctl.getWinnerData(); + EXPECT_EQ(winnersAfter.winnerData.winnerCounter, winnersBefore.winnerData.winnerCounter); +} + TEST(ContractQThirtyFour, Settlement_InsufficientBalance_ClearsPlayersAndAbortsSettlement) { ContractTestingQTF ctl; @@ -1529,7 +1518,7 @@ TEST(ContractQThirtyFour, Settlement_InsufficientBalance_ClearsPlayersAndAbortsS const uint64 totalRevenue = ticketPrice * numPlayers; const int qtfIndex = spectrumIndex(ctl.qtfSelf()); ASSERT_GE(qtfIndex, 0); - ASSERT_TRUE(decreaseEnergy(qtfIndex, static_cast(totalRevenue))); + ASSERT_TRUE(decreaseEnergy(qtfIndex, totalRevenue)); EXPECT_EQ(getBalance(ctl.qtfSelf()), 0); ctl.drawWithDigest(testDigest); @@ -1607,6 +1596,7 @@ TEST(ContractQThirtyFour, Settlement_JackpotGrowsFromOverflow) { ContractTestingQTF ctl; ctl.startAnyDayEpoch(); + ctl.forceFRDisabledForBaseline(); // Fix RNG so we can deterministically create "no winners" tickets. m256i testDigest = {}; @@ -1647,11 +1637,9 @@ TEST(ContractQThirtyFour, Settlement_JackpotGrowsFromOverflow) const uint64 jackpotAfter = ctl.state()->getJackpot(); const uint64 actualGrowth = jackpotAfter - jackpotBefore; - // In baseline mode, 50% of overflow goes to jackpot - // Allow 5% tolerance for rounding and potential winners - const uint64 tolerance = minExpectedGrowth / 20; // 5% - EXPECT_GE(actualGrowth + tolerance, minExpectedGrowth) - << "Actual growth: " << actualGrowth << ", Expected minimum: " << minExpectedGrowth << ", Overflow to jackpot (50%): " << overflowToJackpot; + // Deterministic: losing tickets guarantee no winners, so growth should match exactly. + EXPECT_EQ(actualGrowth, minExpectedGrowth) << "Actual growth: " << actualGrowth << ", Expected: " << minExpectedGrowth + << ", Overflow to jackpot (50%): " << overflowToJackpot; // Verify the 50% overflow split is working correctly const uint64 expected50Percent = winnersOverflow / 2; @@ -1810,12 +1798,9 @@ TEST(ContractQThirtyFour, FR_OverflowBias_95PercentToJackpot) // Verify that jackpot grew by at least the minimum expected amount const uint64 actualGrowth = ctl.state()->getJackpot() - jackpotBefore; - // In FR mode, 95% of overflow goes to jackpot (vs 50% in baseline) - // Allow 5% tolerance for rounding and potential winners - const uint64 tolerance = minExpectedGrowth / 20; // 5% - EXPECT_GE(actualGrowth + tolerance, minExpectedGrowth) - << "Actual growth: " << actualGrowth << ", Expected minimum: " << minExpectedGrowth << ", Overflow to jackpot (95%): " << overflowToJackpot - << ", Winners rake: " << winnersRake; + // Deterministic: losing tickets guarantee no winners, so growth should match exactly. + EXPECT_EQ(actualGrowth, minExpectedGrowth) << "Actual growth: " << actualGrowth << ", Expected: " << minExpectedGrowth + << ", Overflow to jackpot (95%): " << overflowToJackpot << ", Winners rake: " << winnersRake; // Verify the 95% overflow bias is working correctly // overflowToJackpot should be ~95% of winnersOverflow @@ -2006,7 +1991,7 @@ TEST(ContractQThirtyFour, Schedule_DrawOnlyOnScheduledDays) ContractTestingQTF ctl; // Set schedule to Wednesday only (default) - const uint8 wednesdayOnly = static_cast(1 << WEDNESDAY); + constexpr uint8 wednesdayOnly = 1 << WEDNESDAY; ctl.forceSchedule(wednesdayOnly); ctl.beginEpochWithValidTime(); @@ -2269,7 +2254,7 @@ TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_DepletesAndReseeds) ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); // 1B target ctl.forceFREnabledWithinWindow(10); // IMPORTANT: internal `state.jackpot` must be backed by actual contract balance, otherwise transfers will fail. - increaseEnergy(ctl.qtfSelf(), static_cast(initialJackpot)); + increaseEnergy(ctl.qtfSelf(), initialJackpot); const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); @@ -2301,7 +2286,9 @@ TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_DepletesAndReseeds) EXPECT_EQ(roundsSinceK4Before, 10u); // Trigger settlement using our fixed prevSpectrumDigest + const uint64 k4WinnerBefore = getBalance(k4Winner); ctl.drawWithDigest(testDigest); + const uint64 k4WinnerAfter = getBalance(k4Winner); // Verify k=4 jackpot win behavior: const uint64 jackpotAfter = ctl.state()->getJackpot(); @@ -2322,9 +2309,83 @@ TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_DepletesAndReseeds) EXPECT_EQ(winnerData.winnerData.winnerValues.get(2), nums.winning.get(2)); EXPECT_EQ(winnerData.winnerData.winnerValues.get(3), nums.winning.get(3)); - // Verify k=4 winner received payout (full jackpot share). - const long long k4WinnerBalance = getBalance(k4Winner); - EXPECT_GE(k4WinnerBalance, static_cast(initialJackpot)); + // Verify k=4 winner received exact payout (jackpotBefore / countK4). + EXPECT_EQ(static_cast(k4WinnerAfter - k4WinnerBefore), initialJackpot); +} + +TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_MultipleWinners_SplitsEvenly) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + // Ensure QRP has enough reserve to reseed (so settlement completes without relying on carry math). + increaseEnergy(ctl.qrpSelf(), QTF_DEFAULT_TARGET_JACKPOT + 1000000ULL); + + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0xA5A5A5A5A5A5A5A5ULL; + const auto nums = ctl.computeWinningAndLosing(testDigest); + + const uint64 initialJackpot = 900000000ULL; + ctl.state()->setJackpot(initialJackpot); + ctl.forceFREnabledWithinWindow(1); + increaseEnergy(ctl.qtfSelf(), initialJackpot); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + const id w1 = id::randomValue(); + const id w2 = id::randomValue(); + ctl.fundAndBuyTicket(w1, ticketPrice, nums.winning); + ctl.fundAndBuyTicket(w2, ticketPrice, nums.winning); + + const uint64 w1Before = getBalance(w1); + const uint64 w2Before = getBalance(w2); + + ctl.drawWithDigest(testDigest); + + const uint64 expectedPerWinner = initialJackpot / 2; + EXPECT_EQ(static_cast(getBalance(w1) - w1Before), expectedPerWinner); + EXPECT_EQ(static_cast(getBalance(w2) - w2Before), expectedPerWinner); +} + +TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_ReseedLimitedByQRP) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + ctl.forceFRDisabledForBaseline(); + + // Fund QRP below target so reseed amount is limited by available reserve. + const uint64 qrpFunded = 200000000ULL; + increaseEnergy(ctl.qrpSelf(), qrpFunded); + + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0x0A0B0C0D0E0F1011ULL; + const auto nums = ctl.computeWinningAndLosing(testDigest); + + const uint64 initialJackpot = 800000000ULL; + ctl.state()->setJackpot(initialJackpot); + increaseEnergy(ctl.qtfSelf(), initialJackpot); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id w1 = id::randomValue(); + ctl.fundAndBuyTicket(w1, ticketPrice, nums.winning); + + const uint64 qrpBefore = static_cast(getBalance(ctl.qrpSelf())); + const uint64 w1Before = getBalance(w1); + + ctl.drawWithDigest(testDigest); + + EXPECT_EQ(static_cast(getBalance(w1) - w1Before), initialJackpot); + + // With a single winning ticket and baseline overflow split, winnersOverflow == winnersBlock, reserveAdd == winnersBlock/2, carryAdd == + // winnersBlock/2. + const QTF::GetFees_output fees = ctl.getFees(); + const uint64 revenue = ticketPrice; + const uint64 winnersBlock = (revenue * fees.winnerFeePercent) / 100; + const uint64 reserveAdd = (winnersBlock * QTF_BASELINE_OVERFLOW_ALPHA_BP) / 10000; + const uint64 carryAdd = winnersBlock - reserveAdd; + + EXPECT_EQ(ctl.state()->getJackpot(), qrpFunded + carryAdd); + EXPECT_EQ(static_cast(getBalance(ctl.qrpSelf())), qrpBefore - qrpFunded + reserveAdd); } // Test k=2 and k=3 payouts with deterministic winning numbers @@ -2386,8 +2447,8 @@ TEST(ContractQThirtyFour, DeterministicWinner_K2K3Payouts_VerifyRevenueSplit) const uint64 expectedK3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000; // 40% of winners block // Get balances before settlement - const long long k3Winner1Before = getBalance(k3Winner1); - const long long k2Winner1Before = getBalance(k2Winner1); + const uint64 k3Winner1Before = getBalance(k3Winner1); + const uint64 k2Winner1Before = getBalance(k2Winner1); // Trigger settlement ctl.drawWithDigest(testDigest); @@ -2395,14 +2456,14 @@ TEST(ContractQThirtyFour, DeterministicWinner_K2K3Payouts_VerifyRevenueSplit) // Verify winner payouts // k=3 pool split between 2 winners const uint64 expectedK3PayoutPerWinner = expectedK3Pool / 2; - const long long k3Winner1After = getBalance(k3Winner1); - const long long k3Winner1Gained = k3Winner1After - k3Winner1Before; + const uint64 k3Winner1After = getBalance(k3Winner1); + const uint64 k3Winner1Gained = k3Winner1After - k3Winner1Before; EXPECT_EQ(static_cast(k3Winner1Gained), expectedK3PayoutPerWinner) << "k=3 winner should receive half of k3 pool"; // k=2 pool split between 3 winners const uint64 expectedK2PayoutPerWinner = expectedK2Pool / 3; - const long long k2Winner1After = getBalance(k2Winner1); - const long long k2Winner1Gained = k2Winner1After - k2Winner1Before; + const uint64 k2Winner1After = getBalance(k2Winner1); + const uint64 k2Winner1Gained = k2Winner1After - k2Winner1Before; EXPECT_EQ(static_cast(k2Winner1Gained), expectedK2PayoutPerWinner) << "k=2 winner should receive one-third of k2 pool"; // Verify winning numbers in winner data @@ -2519,6 +2580,89 @@ TEST(ContractQThirtyFour, ReserveTopUp_FloorGuarantee_VerifyLimits) // or mocking the random number generation to guarantee specific winners. } +TEST(ContractQThirtyFour, Settlement_FloorTopUp_Integration_K2K3FloorsMetWhenReserveSufficient) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + ctl.forceFRDisabledForBaseline(); + + // Ensure RL shares exist so distribution path is exercised (and rounding/payback is deterministic). + const id shareholder1 = id::randomValue(); + const id shareholder2 = id::randomValue(); + constexpr uint32 shares1 = NUMBER_OF_COMPUTORS / 4; + constexpr uint32 shares2 = NUMBER_OF_COMPUTORS - shares1; + std::vector> rlShares{{shareholder1, shares1}, {shareholder2, shares2}}; + issueRlSharesTo(rlShares, false); + + // Fund QRP enough so both tiers can be topped up to floors under all caps. + const uint64 qrpFunding = 100000000ULL; // 100M, 10% cap = 10M, soft floor = 20M. + increaseEnergy(ctl.qrpSelf(), qrpFunding); + + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0x5566778899AABBCCULL; + const auto nums = ctl.computeWinningAndLosing(testDigest); + + const uint64 P = ctl.state()->getTicketPriceInternal(); + + // Create deterministic winners: 2x k2 winners and 1x k3 winner => pools are small and must be topped up. + const id k2w1 = id::randomValue(); + const id k2w2 = id::randomValue(); + const id k3w1 = id::randomValue(); + ctl.fundAndBuyTicket(k2w1, P, ctl.makeK2Numbers(nums.winning, 0)); + ctl.fundAndBuyTicket(k2w2, P, ctl.makeK2Numbers(nums.winning, 1)); + ctl.fundAndBuyTicket(k3w1, P, ctl.makeK3Numbers(nums.winning, 2)); + + const uint64 qrpBefore = static_cast(getBalance(ctl.qrpSelf())); + const uint64 qtfBefore = static_cast(getBalance(ctl.qtfSelf())); + const uint64 k2w1Before = getBalance(k2w1); + const uint64 k3w1Before = getBalance(k3w1); + const uint64 sh1Before = getBalance(shareholder1); + const uint64 sh2Before = getBalance(shareholder2); + const uint64 rlBefore = getBalance(id(RL_CONTRACT_INDEX, 0, 0, 0)); + + EXPECT_EQ(qtfBefore, 3 * P); + + ctl.drawWithDigest(testDigest); + + // Expected pools and top-ups. + const QTF::GetFees_output fees = ctl.getFees(); + const uint64 revenue = 3 * P; + const uint64 winnersBlock = (revenue * fees.winnerFeePercent) / 100; + const uint64 k2Pool = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000; + const uint64 k3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000; + + const uint64 k2Floor = P / 2; + const uint64 k3Floor = 5 * P; + const uint64 k2TopUp = (k2Floor * 2 > k2Pool) ? (k2Floor * 2 - k2Pool) : 0; + const uint64 k3TopUp = (k3Floor > k3Pool) ? (k3Floor - k3Pool) : 0; + + // Winners must receive the floors (no per-winner cap binding in this scenario). + EXPECT_EQ(static_cast(getBalance(k2w1) - k2w1Before), k2Floor); + EXPECT_EQ(static_cast(getBalance(k3w1) - k3w1Before), k3Floor); + + // Baseline overflow is the unallocated 32% of winnersBlock (tier pools are fully paid out with floor top-ups, so no extra overflow). + const uint64 winnersOverflow = winnersBlock - k2Pool - k3Pool; + const uint64 reserveAdd = (winnersOverflow * QTF_BASELINE_OVERFLOW_ALPHA_BP) / 10000; + const uint64 carryAdd = winnersOverflow - reserveAdd; + + // Contract balance should match carry (jackpot) after settlement. + EXPECT_EQ(ctl.state()->getJackpot(), carryAdd); + EXPECT_EQ(static_cast(getBalance(ctl.qtfSelf())), carryAdd); + + // QRP: receives reserveAdd, pays out top-ups. + EXPECT_EQ(static_cast(getBalance(ctl.qrpSelf())), qrpBefore - k2TopUp - k3TopUp + reserveAdd); + + // Distribution: verify two holders and RL payback remainder. + const uint64 expectedDistFee = (revenue * fees.distributionFeePercent) / 100; + const uint64 dividendPerShare = expectedDistFee / NUMBER_OF_COMPUTORS; + const uint64 expectedSh1Gain = static_cast(shares1) * dividendPerShare; + const uint64 expectedSh2Gain = static_cast(shares2) * dividendPerShare; + const uint64 expectedPayback = expectedDistFee - (dividendPerShare * NUMBER_OF_COMPUTORS); + EXPECT_EQ(getBalance(shareholder1), sh1Before + expectedSh1Gain); + EXPECT_EQ(getBalance(shareholder2), sh2Before + expectedSh2Gain); + EXPECT_EQ(getBalance(id(RL_CONTRACT_INDEX, 0, 0, 0)), rlBefore + expectedPayback); +} + // ============================================================================ // HIGH-DEFICIT FR EXTRA REDIRECTS TESTS // ============================================================================ @@ -2601,6 +2745,81 @@ TEST(ContractQThirtyFour, FR_HighDeficit_ExtraRedirectsCalculated) // This test verifies the mechanism is active and within bounds. } +TEST(ContractQThirtyFour, Settlement_FRMode_ExtraRedirect_ClampsToMax_AndAffectsDevAndDist) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + + // Ensure RL shares exist so distribution can be asserted. + const id shareholder1 = id::randomValue(); + const id shareholder2 = id::randomValue(); + constexpr uint32 shares1 = NUMBER_OF_COMPUTORS / 2; + constexpr uint32 shares2 = NUMBER_OF_COMPUTORS - shares1; + std::vector> rlShares{{shareholder1, shares1}, {shareholder2, shares2}}; + issueRlSharesTo(rlShares, false); + + // Deterministic no-winner tickets. + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0x7777777777777777ULL; + const auto nums = ctl.computeWinningAndLosing(testDigest); + + // Force FR on and create an extreme deficit to guarantee extra redirect clamps to max. + ctl.state()->setJackpot(0ULL); + ctl.state()->setTargetJackpotInternal(1000000000000000ULL); // 1e15 + ctl.state()->setFrActive(true); + ctl.state()->setFrRoundsSinceK4(1); + + ctl.beginEpochWithValidTime(); + + const uint64 P = ctl.state()->getTicketPriceInternal(); + constexpr uint64 numPlayers = 10; + ctl.buyRandomTickets(numPlayers, P, nums.losing); + + const QTF::GetFees_output fees = ctl.getFees(); + const uint64 revenue = P * numPlayers; + + const uint64 devBefore = getBalance(QTF_DEV_ADDRESS); + const uint64 sh1Before = getBalance(shareholder1); + const uint64 sh2Before = getBalance(shareholder2); + const uint64 rlBefore = getBalance(id(RL_CONTRACT_INDEX, 0, 0, 0)); + + // Pre-compute expected extra BP using the same private helpers as the contract. + QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); + primeQpiFunctionContext(qpi); + const auto pools = ctl.state()->callCalculatePrizePools(qpi, revenue, true); + const auto baseGainOut = ctl.state()->callCalculateBaseGain(qpi, revenue, pools.winnersBlock); + const uint64 delta = ctl.state()->getTargetJackpotInternal() - ctl.state()->getJackpot(); + const auto extraOut = ctl.state()->callCalculateExtraRedirectBP(qpi, numPlayers, delta, revenue, baseGainOut.baseGain); + ASSERT_EQ(extraOut.extraBP, QTF_FR_EXTRA_MAX_BP); + + const uint64 devExtraBP = extraOut.extraBP / 2; + const uint64 distExtraBP = extraOut.extraBP - devExtraBP; + const uint64 totalDevRedirectBP = QTF_FR_DEV_REDIRECT_BP + devExtraBP; + const uint64 totalDistRedirectBP = QTF_FR_DIST_REDIRECT_BP + distExtraBP; + + const uint64 fullDevFee = (revenue * fees.teamFeePercent) / 100; + const uint64 fullDistFee = (revenue * fees.distributionFeePercent) / 100; + + const uint64 expectedDevRedirect = (revenue * totalDevRedirectBP) / 10000; + const uint64 expectedDistRedirect = (revenue * totalDistRedirectBP) / 10000; + const uint64 expectedDevPayout = fullDevFee - expectedDevRedirect; + const uint64 expectedDistPayout = fullDistFee - expectedDistRedirect; + + ctl.drawWithDigest(testDigest); + + // Dev payout must match exact base+extra redirect math (no caps expected in this scenario). + EXPECT_EQ(static_cast(getBalance(QTF_DEV_ADDRESS) - devBefore), expectedDevPayout); + + // Distribution must match expectedDistPayout (dividendPerShare flooring + payback). + const uint64 dividendPerShare = expectedDistPayout / NUMBER_OF_COMPUTORS; + const uint64 expectedSh1Gain = static_cast(shares1) * dividendPerShare; + const uint64 expectedSh2Gain = static_cast(shares2) * dividendPerShare; + const uint64 expectedPayback = expectedDistPayout - (dividendPerShare * NUMBER_OF_COMPUTORS); + EXPECT_EQ(getBalance(shareholder1), sh1Before + expectedSh1Gain); + EXPECT_EQ(getBalance(shareholder2), sh2Before + expectedSh2Gain); + EXPECT_EQ(getBalance(id(RL_CONTRACT_INDEX, 0, 0, 0)), rlBefore + expectedPayback); +} + // ============================================================================ // POST-K4 WINDOW EXPIRY TESTS // ============================================================================ From c5d31f9438547223b0cee20460025a8f79799fe4 Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 17 Dec 2025 00:35:16 +0300 Subject: [PATCH 17/77] Update tests --- test/contract_qtf.cpp | 42 +++++++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp index 7aba72ad6..faef53f88 100644 --- a/test/contract_qtf.cpp +++ b/test/contract_qtf.cpp @@ -30,9 +30,9 @@ constexpr uint16 QTF_FUNCTION_ESTIMATE_PRIZE_PAYOUTS = 9; namespace { - static void issueRlSharesTo(std::vector>& initialOwnerShares, bool warnOnTooFewShares = true) + static void issueRlSharesTo(std::vector>& initialOwnerShares) { - issueContractShares(RL_CONTRACT_INDEX, initialOwnerShares, warnOnTooFewShares); + issueContractShares(RL_CONTRACT_INDEX, initialOwnerShares, false); } static void primeQpiFunctionContext(QpiContextUserFunctionCall& qpi) @@ -1435,7 +1435,7 @@ TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed) constexpr uint32 shares1 = NUMBER_OF_COMPUTORS / 3; constexpr uint32 shares2 = NUMBER_OF_COMPUTORS - shares1; std::vector> rlShares{{shareholder1, shares1}, {shareholder2, shares2}}; - issueRlSharesTo(rlShares, false); + issueRlSharesTo(rlShares); // Verify FR is not active initially (baseline mode) EXPECT_EQ(ctl.state()->getFrActive(), false); @@ -1785,22 +1785,38 @@ TEST(ContractQThirtyFour, FR_OverflowBias_95PercentToJackpot) const uint64 reserveAdd = (winnersOverflow * QTF_FR_ALPHA_BP) / 10000; const uint64 overflowToJackpot = winnersOverflow - reserveAdd; - // Dev and Dist redirects (base 1% each from revenue in FR mode) - const uint64 devRedirect = (revenue * QTF_FR_DEV_REDIRECT_BP) / 10000; - const uint64 distRedirect = (revenue * QTF_FR_DIST_REDIRECT_BP) / 10000; + // Dev and Dist redirects in FR mode: base (1% each) + extra (deficit-driven) + // First calculate base gain to pass to extra redirect calculation + QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); + primeQpiFunctionContext(qpi); + const auto baseGainOut = ctl.state()->callCalculateBaseGain(qpi, revenue, winnersBlock); + + // Calculate extra redirect based on deficit + const uint64 delta = ctl.state()->getTargetJackpotInternal() - jackpotBefore; + const auto extraOut = ctl.state()->callCalculateExtraRedirectBP(qpi, numPlayers, delta, revenue, baseGainOut.baseGain); - // Minimum expected jackpot growth (without extra redirects, assuming no k2/k3 winners) + // Total redirect BP = base + extra (split 50/50 between dev and dist) + const uint64 devExtraBP = extraOut.extraBP / 2; + const uint64 distExtraBP = extraOut.extraBP - devExtraBP; + const uint64 totalDevRedirectBP = QTF_FR_DEV_REDIRECT_BP + devExtraBP; + const uint64 totalDistRedirectBP = QTF_FR_DIST_REDIRECT_BP + distExtraBP; + + const uint64 devRedirect = (revenue * totalDevRedirectBP) / 10000; + const uint64 distRedirect = (revenue * totalDistRedirectBP) / 10000; + + // Expected jackpot growth (with both base and extra redirects, assuming no k2/k3 winners) // totalJackpotContribution = overflowToJackpot + winnersRake + devRedirect + distRedirect - const uint64 minExpectedGrowth = overflowToJackpot + winnersRake + devRedirect + distRedirect; + const uint64 expectedGrowth = overflowToJackpot + winnersRake + devRedirect + distRedirect; ctl.drawWithDigest(testDigest); - // Verify that jackpot grew by at least the minimum expected amount + // Verify that jackpot grew by the expected amount const uint64 actualGrowth = ctl.state()->getJackpot() - jackpotBefore; // Deterministic: losing tickets guarantee no winners, so growth should match exactly. - EXPECT_EQ(actualGrowth, minExpectedGrowth) << "Actual growth: " << actualGrowth << ", Expected: " << minExpectedGrowth - << ", Overflow to jackpot (95%): " << overflowToJackpot << ", Winners rake: " << winnersRake; + EXPECT_EQ(actualGrowth, expectedGrowth) << "Actual growth: " << actualGrowth << ", Expected: " << expectedGrowth + << ", Overflow to jackpot (95%): " << overflowToJackpot << ", Winners rake: " << winnersRake + << ", Extra redirect BP: " << extraOut.extraBP; // Verify the 95% overflow bias is working correctly // overflowToJackpot should be ~95% of winnersOverflow @@ -2592,7 +2608,7 @@ TEST(ContractQThirtyFour, Settlement_FloorTopUp_Integration_K2K3FloorsMetWhenRes constexpr uint32 shares1 = NUMBER_OF_COMPUTORS / 4; constexpr uint32 shares2 = NUMBER_OF_COMPUTORS - shares1; std::vector> rlShares{{shareholder1, shares1}, {shareholder2, shares2}}; - issueRlSharesTo(rlShares, false); + issueRlSharesTo(rlShares); // Fund QRP enough so both tiers can be topped up to floors under all caps. const uint64 qrpFunding = 100000000ULL; // 100M, 10% cap = 10M, soft floor = 20M. @@ -2756,7 +2772,7 @@ TEST(ContractQThirtyFour, Settlement_FRMode_ExtraRedirect_ClampsToMax_AndAffects constexpr uint32 shares1 = NUMBER_OF_COMPUTORS / 2; constexpr uint32 shares2 = NUMBER_OF_COMPUTORS - shares1; std::vector> rlShares{{shareholder1, shares1}, {shareholder2, shares2}}; - issueRlSharesTo(rlShares, false); + issueRlSharesTo(rlShares); // Deterministic no-winner tickets. m256i testDigest = {}; From 2de4c87e6ef515e3ac36cb6eb7c9141581f7b64a Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 17 Dec 2025 00:43:45 +0300 Subject: [PATCH 18/77] Updates index --- src/contracts/QReservePool.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contracts/QReservePool.h b/src/contracts/QReservePool.h index 4a5985808..254c66b76 100644 --- a/src/contracts/QReservePool.h +++ b/src/contracts/QReservePool.h @@ -2,7 +2,7 @@ // Number of available smart contracts in the QRP contract. static constexpr uint16 QRP_AVAILABLE_SC_NUM = 128; -static constexpr uint64 QRP_QTF_INDEX = 20; +static constexpr uint64 QRP_QTF_INDEX = 21; enum class QRPReturnCode : uint8 { From 706cbb0c873d2162fb64265d950cef1596f16712 Mon Sep 17 00:00:00 2001 From: N-010 Date: Fri, 19 Dec 2025 13:19:08 +0300 Subject: [PATCH 19/77] =?UTF-8?q?Updates=20inde=E2=80=A2=20Fix=20k=3D4=20s?= =?UTF-8?q?ettlement:=20protect=20jackpot=20reseed=20from=20k2/k3=20reserv?= =?UTF-8?q?e=20top-ups;=20correct=20schedule=20bitmask=20in=20specx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/contracts/QThirtyFour.h | 28 ++-- test/contract_qtf.cpp | 313 ++++++++++++++++++++++++++---------- 2 files changed, 248 insertions(+), 93 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 6497d440e..c47252c21 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -57,7 +57,7 @@ constexpr uint8 QTF_DEFAULT_WINNERS_PERCENT = 68; constexpr uint8 QTF_MAX_RANDOM_GENERATION_ATTEMPTS = 100; constexpr uint64 QTF_DEFAULT_TARGET_JACKPOT = 1000000000ULL; // 1 billion QU (1B) -constexpr uint8 QTF_DEFAULT_SCHEDULE = 1u << WEDNESDAY; +constexpr uint8 QTF_DEFAULT_SCHEDULE = 1 << SATURDAY | 1u << WEDNESDAY; constexpr uint8 QTF_DEFAULT_DRAW_HOUR = 11; // 11:00 UTC constexpr uint32 QTF_DEFAULT_INIT_TIME = 22u << 9 | 4u << 5 | 13u; // RL_DEFAULT_INIT_TIME @@ -1243,6 +1243,11 @@ struct QTF : public ContractBase { state.frActive = true; } + else if (state.frRoundsSinceK4 >= QTF_FR_POST_K4_WINDOW_ROUNDS) + { + // Outside post-k4 window: FR must be OFF. + state.frActive = false; + } else if (state.frRoundsAtOrAboveTarget >= QTF_FR_HYSTERESIS_ROUNDS) { // Deactivate FR after target held for hysteresis rounds @@ -1358,10 +1363,9 @@ struct QTF : public ContractBase // First, get total QRP balance for safety limit calculations (10% of total reserve per round). CALL_OTHER_CONTRACT_FUNCTION(QRP, GetAvailableReserve, locals.qrpGetAvailableInput, locals.qrpGetAvailableOutput); locals.totalQRPBalance = locals.qrpGetAvailableOutput.availableReserve; - - // If a k=4 win happened this round, we will try to reseed the jackpot back up to target after payouts. - // To avoid draining the reserve needed for reseed with k2/k3 floor top-ups (QRP is a single pool in this implementation), - // limit top-ups to the portion that is above the target jackpot amount. + // If a k=4 win happened this round, the jackpot reseed must not be blocked by floor top-ups. + // We emulate reserve partitioning by limiting k2/k3 top-ups to the portion of QRP above targetJackpot. + // This guarantees that if QRP had >= targetJackpot before settlement, reseed can still reach target after payouts. if (locals.countK4 > 0) { if (locals.totalQRPBalance > state.targetJackpot) @@ -1399,6 +1403,13 @@ struct QTF : public ContractBase locals.carryAdd = sadd(locals.carryAdd, locals.winnersRake); + // Compute k=4 jackpot payout per winner once. + locals.jackpotPerK4Winner = 0; + if (locals.countK4 > 0) + { + locals.jackpotPerK4Winner = div(state.jackpot, locals.countK4); + } + // Second pass: payout loop using cached match results (avoids redundant countMatches calls) // (Optimization: reduces player iteration from 4 passes to 2 passes + eliminates duplicate countMatches) locals.i = 0; @@ -1419,14 +1430,13 @@ struct QTF : public ContractBase fillWinnerData(state, state.players.get(locals.i), locals.winningValues, locals.currentEpoch); } // k4 payout (jackpot) - else if (locals.matches == 4 && locals.countK4 > 0 && state.jackpot > 0) + else if (locals.matches == 4 && locals.countK4 > 0) { - locals.jackpotPerK4Winner = state.jackpot / locals.countK4; if (locals.jackpotPerK4Winner > 0) { qpi.transfer(state.players.get(locals.i).player, locals.jackpotPerK4Winner); - fillWinnerData(state, state.players.get(locals.i), locals.winningValues, locals.currentEpoch); } + fillWinnerData(state, state.players.get(locals.i), locals.winningValues, locals.currentEpoch); } ++locals.i; @@ -1437,7 +1447,7 @@ struct QTF : public ContractBase state.lastWinnerData.epoch = locals.currentEpoch; // Post-jackpot (k4) logic: reset counters and reseed if jackpot was hit - if (locals.countK4 > 0 && state.jackpot > 0) + if (locals.countK4 > 0) { // Jackpot was paid out in combined loop above, now deplete it state.jackpot = 0; diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp index faef53f88..2b5674938 100644 --- a/test/contract_qtf.cpp +++ b/test/contract_qtf.cpp @@ -991,6 +991,24 @@ TEST(ContractQThirtyFour, BuyTicket_TooLowPrice_RefundsAndFails) EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); } +TEST(ContractQThirtyFour, BuyTicket_ZeroPrice_RefundsAndFails) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 2); + const uint64 balBefore = getBalance(user); + + const QTFRandomValues nums = ctl.makeValidNumbers(1, 2, 3, 4); + + const QTF::BuyTicket_output out = ctl.buyTicket(user, 0, nums); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_TICKET_PRICE)); + EXPECT_EQ(getBalance(user), balBefore); // Fully refunded (0) + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + TEST(ContractQThirtyFour, BuyTicket_OverpaidPrice_AcceptsAndReturnsExcess) { ContractTestingQTF ctl; @@ -2002,6 +2020,36 @@ TEST(ContractQThirtyFour, PostIncomingTransfer_StandardTransaction_Refunded) // SCHEDULE AND TIME TESTS // ============================================================================ +TEST(ContractQThirtyFour, Schedule_WednesdayAlwaysDraws_IgnoresScheduleMask) +{ + ContractTestingQTF ctl; + + // Exclude Wednesday from schedule mask (e.g., Monday only). + constexpr uint8 mondayOnly = 1 << MONDAY; + ctl.forceSchedule(mondayOnly); + + ctl.beginEpochWithValidTime(); + + const m256i testDigest = {}; + ctl.setPrevSpectrumDigest(testDigest); + const auto nums = ctl.computeWinningAndLosing(testDigest); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + for (int i = 0; i < 5; ++i) + { + const id user = id::randomValue(); + ctl.fundAndBuyTicket(user, ticketPrice, nums.losing); + } + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 5u); + + // Wednesday should always trigger a draw at/after draw hour, even if schedule mask does not include it. + const uint8 drawHour = ctl.state()->getDrawHourInternal(); + ctl.setDateTime(2025, 1, 15, drawHour); + ctl.forceBeginTick(); + + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + TEST(ContractQThirtyFour, Schedule_DrawOnlyOnScheduledDays) { ContractTestingQTF ctl; @@ -2037,6 +2085,57 @@ TEST(ContractQThirtyFour, Schedule_DrawOnlyOnScheduledDays) EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); // Cleared after draw } +TEST(ContractQThirtyFour, Schedule_DrawAtMostOncePerDay_LastDrawDateStampGuards) +{ + ContractTestingQTF ctl; + + // Use a non-Wednesday scheduled day so selling is re-enabled after the draw. + constexpr uint8 thursdayOnly = 1 << THURSDAY; + ctl.forceSchedule(thursdayOnly); + + ctl.beginEpochWithValidTime(); + + const m256i testDigest = {}; + ctl.setPrevSpectrumDigest(testDigest); + const auto nums = ctl.computeWinningAndLosing(testDigest); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + { + const id user = id::randomValue(); + ctl.fundAndBuyTicket(user, ticketPrice, nums.losing); + } + + const uint8 drawHour = ctl.state()->getDrawHourInternal(); + + // First draw on Thursday. + ctl.setDateTime(2025, 1, 16, drawHour); + ctl.forceBeginTick(); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); + + const uint64 jackpotAfterFirst = ctl.state()->getJackpot(); + const QTF::GetWinnerData_output winnersAfterFirst = ctl.getWinnerData(); + + // Buy another ticket on the same date (selling should be open on non-Wednesday). + { + const id user2 = id::randomValue(); + ctl.fundAndBuyTicket(user2, ticketPrice, nums.losing); + } + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); + + // Second tick on the same date must NOT trigger another draw. + ctl.setDateTime(2025, 1, 16, drawHour); + ctl.forceBeginTick(); + + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); + EXPECT_EQ(ctl.state()->getJackpot(), jackpotAfterFirst); + const QTF::GetWinnerData_output winnersAfterSecondAttempt = ctl.getWinnerData(); + for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) + { + EXPECT_EQ(winnersAfterSecondAttempt.winnerData.winnerValues.get(i), winnersAfterFirst.winnerData.winnerValues.get(i)); + } + EXPECT_EQ((uint64)winnersAfterSecondAttempt.winnerData.epoch, (uint64)winnersAfterFirst.winnerData.epoch); +} + TEST(ContractQThirtyFour, DrawHour_NoDrawBeforeScheduledHour) { ContractTestingQTF ctl; @@ -2067,6 +2166,37 @@ TEST(ContractQThirtyFour, DrawHour_NoDrawBeforeScheduledHour) EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); } +TEST(ContractQThirtyFour, DrawHour_WednesdayDrawClosesTicketSelling) +{ + ContractTestingQTF ctl; + + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + ctl.beginEpochWithValidTime(); + + const m256i testDigest = {}; + ctl.setPrevSpectrumDigest(testDigest); + const auto nums = ctl.computeWinningAndLosing(testDigest); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + { + const id user = id::randomValue(); + ctl.fundAndBuyTicket(user, ticketPrice, nums.losing); + } + + const uint8 drawHour = ctl.state()->getDrawHourInternal(); + ctl.setDateTime(2025, 1, 15, drawHour); + ctl.forceBeginTick(); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); + + // After a Wednesday draw, selling must remain closed until next epoch. + const id lateBuyer = id::randomValue(); + increaseEnergy(lateBuyer, ticketPrice * 2); + const uint64 before = getBalance(lateBuyer); + const QTF::BuyTicket_output out = ctl.buyTicket(lateBuyer, ticketPrice, nums.losing); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::TICKET_SELLING_CLOSED)); + EXPECT_EQ(getBalance(lateBuyer), before); +} + // ============================================================================ // PROBABILITY AND COMBINATORICS VERIFICATION // ============================================================================ @@ -2534,66 +2664,114 @@ TEST(ContractQThirtyFour, EstimatePrizePayouts_FRMode_AppliesRakeToPools) // RESERVE TOP-UP AND FLOOR GUARANTEE TESTS // ============================================================================ -TEST(ContractQThirtyFour, ReserveTopUp_FloorGuarantee_VerifyLimits) +TEST(ContractQThirtyFour, Settlement_PerWinnerCap_AppliesToK3Winner_OverflowAccountsForRemainder) { ContractTestingQTF ctl; ctl.startAnyDayEpoch(); + ctl.forceFRDisabledForBaseline(); - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + // Ensure RL shares exist so distribution payouts leave the contract (otherwise most of distPayout can remain in QTF balance). + const id shareholder1 = id::randomValue(); + const id shareholder2 = id::randomValue(); + constexpr uint32 shares1 = NUMBER_OF_COMPUTORS / 3; + constexpr uint32 shares2 = NUMBER_OF_COMPUTORS - shares1; + std::vector> rlShares{{shareholder1, shares1}, {shareholder2, shares2}}; + issueRlSharesTo(rlShares); - // Add only 2 players to create small prize pools - // With 2 tickets, revenue = 2M, winners block = 1.36M (68%) - // k2 pool = 1.36M * 28% = 380.8k, k3 pool = 1.36M * 40% = 544k - // These are below floor requirements for multiple winners - constexpr int numPlayers = 2; - for (int i = 0; i < numPlayers; ++i) - { - const id user = id::randomValue(); - QTFRandomValues nums = - ctl.makeValidNumbers(static_cast(i + 1), static_cast(i + 10), static_cast(i + 15), static_cast(i + 20)); - ctl.fundAndBuyTicket(user, ticketPrice, nums); - } + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0xD1CEB00BD1CEB00BULL; + const auto nums = ctl.computeWinningAndLosing(testDigest); - const QTF::GetPools_output poolsBefore = ctl.getPools(); - const uint64 revenue = ticketPrice * numPlayers; + const uint64 P = ctl.state()->getTicketPriceInternal(); + const uint64 perWinnerCap = smul(P, QTF_TOPUP_PER_WINNER_CAP_MULT); - // Calculate expected pools + const id k3Winner = id::randomValue(); + ctl.fundAndBuyTicket(k3Winner, P, ctl.makeK3Numbers(nums.winning, 0)); + + constexpr uint64 numLosers = 100; + ctl.buyRandomTickets(numLosers, P, nums.losing); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), numLosers + 1); + + const uint64 qrpBefore = static_cast(getBalance(ctl.qrpSelf())); + const uint64 k3Before = getBalance(k3Winner); + + ctl.drawWithDigest(testDigest); + + EXPECT_EQ(static_cast(getBalance(k3Winner) - k3Before), perWinnerCap); + + // Baseline settlement: with no k2 winners and exactly one k3 winner capped at 25*P, + // winnersOverflow ends up being winnersBlock - perWinnerCap. const QTF::GetFees_output fees = ctl.getFees(); - const uint64 winnersBlock = (revenue * fees.winnerFeePercent) / 100; - const uint64 expectedK2Pool = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000; - const uint64 expectedK3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000; + const uint64 revenue = smul(P, numLosers + 1); + const uint64 winnersBlock = div(smul(revenue, static_cast(fees.winnerFeePercent)), 100); + const uint64 winnersOverflow = winnersBlock - perWinnerCap; + const uint64 reserveAdd = (winnersOverflow * QTF_BASELINE_OVERFLOW_ALPHA_BP) / 10000; + const uint64 carryAdd = winnersOverflow - reserveAdd; - // Verify floors - const uint64 k2Floor = ticketPrice * QTF_K2_FLOOR_MULT / QTF_K2_FLOOR_DIV; // 0.5*P = 500k - const uint64 k3Floor = ticketPrice * QTF_K3_FLOOR_MULT; // 5*P = 5M + EXPECT_EQ(ctl.state()->getJackpot(), carryAdd); + EXPECT_EQ(static_cast(getBalance(ctl.qtfSelf())), carryAdd); + EXPECT_EQ(static_cast(getBalance(ctl.qrpSelf())), qrpBefore + reserveAdd); +} - // If we hypothetically had 2 k2 winners: floor requirement = 2 * 500k = 1M - // But k2 pool is only ~380k, so would need ~620k from reserve - const uint64 k2FloorTotal2Winners = k2Floor * 2; // 1M - EXPECT_LT(expectedK2Pool, k2FloorTotal2Winners) << "k2 pool should be insufficient for 2 winners with floor"; +TEST(ContractQThirtyFour, Settlement_FloorTopUp_LimitedBySafetyCaps_PayoutBelowFloor) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + ctl.forceFRDisabledForBaseline(); - // If we hypothetically had 1 k3 winner: floor requirement = 5M - // But k3 pool is only ~544k, so would need ~4.46M from reserve - EXPECT_LT(expectedK3Pool, k3Floor) << "k3 pool should be insufficient for 1 winner with floor"; + // Ensure RL shares exist so distribution payouts leave the contract (otherwise most of distPayout can remain in QTF balance). + const id shareholder1 = id::randomValue(); + const id shareholder2 = id::randomValue(); + constexpr uint32 shares1 = NUMBER_OF_COMPUTORS / 2; + constexpr uint32 shares2 = NUMBER_OF_COMPUTORS - shares1; + std::vector> rlShares{{shareholder1, shares1}, {shareholder2, shares2}}; + issueRlSharesTo(rlShares); - // Per-winner cap verification - const uint64 perWinnerCap = ticketPrice * QTF_TOPUP_PER_WINNER_CAP_MULT; // 25M - EXPECT_EQ(perWinnerCap, 25000000ULL); + // Fund QRP just above soft floor so top-up is limited by both 10% cap and soft floor. + const uint64 P = ctl.state()->getTicketPriceInternal(); + const uint64 softFloor = smul(P, QTF_RESERVE_SOFT_FLOOR_MULT); // 20*P + const uint64 qrpFunding = softFloor + 5 * P; // 25*P + increaseEnergy(ctl.qrpSelf(), qrpFunding); - // Reserve safety limits - const uint64 softFloor = ticketPrice * QTF_RESERVE_SOFT_FLOOR_MULT; // 20*P = 20M - EXPECT_EQ(softFloor, 20000000ULL); + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0x0DDC0FFEE0DDF00DULL; + const auto nums = ctl.computeWinningAndLosing(testDigest); + + const id k3Winner = id::randomValue(); + ctl.fundAndBuyTicket(k3Winner, P, ctl.makeK3Numbers(nums.winning, 0)); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); - // Note: In actual settlement with winners, the ProcessTierPayout function (QThirtyFour.h:1795-1837) - // would call CalcReserveTopUp (lines 1740-1777) to determine how much to request from QRP. - // The top-up is capped at: - // 1. 10% of QRP balance per round (QTF_TOPUP_RESERVE_PCT_BP) - // 2. Soft floor: don't deplete QRP below 20*P (QTF_RESERVE_SOFT_FLOOR_MULT) - // 3. Per-winner cap: max 25*P per winner (QTF_TOPUP_PER_WINNER_CAP_MULT) + const uint64 qrpBefore = static_cast(getBalance(ctl.qrpSelf())); + const uint64 k3Before = getBalance(k3Winner); + + ctl.drawWithDigest(testDigest); - // This test verifies the floor requirements exist and are calculated correctly. - // Full integration test would require actual winner detection (probabilistic) - // or mocking the random number generation to guarantee specific winners. + const QTF::GetFees_output fees = ctl.getFees(); + const uint64 revenue = P; + const uint64 winnersBlock = div(smul(revenue, static_cast(fees.winnerFeePercent)), 100); + const uint64 k3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000; + const uint64 k3Floor = smul(P, QTF_K3_FLOOR_MULT); + const uint64 needed = k3Floor - k3Pool; + const uint64 availableAboveFloor = qrpBefore - softFloor; // 5*P + const uint64 maxPerRound = (qrpBefore * QTF_TOPUP_RESERVE_PCT_BP) / 10000; // 10% of total + const uint64 perWinnerCapTotal = smul(P, QTF_TOPUP_PER_WINNER_CAP_MULT); // 25*P + const uint64 maxAllowed = std::min(std::min(maxPerRound, availableAboveFloor), perWinnerCapTotal); // 2.5*P + const uint64 expectedTopUp = std::min(needed, maxAllowed); + const uint64 expectedPayout = k3Pool + expectedTopUp; + + EXPECT_LT(expectedPayout, k3Floor); + EXPECT_EQ(static_cast(getBalance(k3Winner) - k3Before), expectedPayout); + + // With no k2 winners and k3 pool fully paid (top-ups only increase payouts), + // winnersOverflow equals winnersBlock - k3Pool. + const uint64 winnersOverflow = winnersBlock - k3Pool; + const uint64 reserveAdd = (winnersOverflow * QTF_BASELINE_OVERFLOW_ALPHA_BP) / 10000; + const uint64 carryAdd = winnersOverflow - reserveAdd; + + EXPECT_EQ(ctl.state()->getJackpot(), carryAdd); + EXPECT_EQ(static_cast(getBalance(ctl.qtfSelf())), carryAdd); + EXPECT_EQ(static_cast(getBalance(ctl.qrpSelf())), qrpBefore - expectedTopUp + reserveAdd); + EXPECT_GE(static_cast(getBalance(ctl.qrpSelf())), softFloor); } TEST(ContractQThirtyFour, Settlement_FloorTopUp_Integration_K2K3FloorsMetWhenReserveSufficient) @@ -2903,17 +3081,12 @@ TEST(ContractQThirtyFour, FR_PostK4WindowExpiry_DoesNotReactivateWhenWindowExpir // After settlement (deterministic: no k=4 win is possible): // - roundsSinceK4 should increment to 50 - // - FR should remain active this round (activation check happens BEFORE increment) - // But in the NEXT round, FR won't activate because roundsSinceK4 >= 50 + // - Next round starts outside the FR post-k4 window. const uint64 roundsSinceK4After = ctl.state()->getFrRoundsSinceK4(); EXPECT_EQ(roundsSinceK4After, QTF_FR_POST_K4_WINDOW_ROUNDS) << "Counter should increment to 50 after draw"; - // FR activation logic (QThirtyFour.h:1236-1245): - // shouldActivateFR = (jackpot < target) AND (roundsSinceK4 < 50) - // At roundsSinceK4 = 50, condition is false, so FR won't activate in next round - - // Run one more round: FR cannot re-activate, so state should not change + // Run one more round: FR must be OFF because roundsSinceK4 >= 50. ctl.beginEpochWithValidTime(); for (int i = 0; i < numPlayers; ++i) @@ -2927,32 +3100,11 @@ TEST(ContractQThirtyFour, FR_PostK4WindowExpiry_DoesNotReactivateWhenWindowExpir // After second round: // - Jackpot still below target // - roundsSinceK4 = 51 (>= 50) + // - FR is forced OFF outside the window. EXPECT_EQ(ctl.state()->getFrRoundsSinceK4(), QTF_FR_POST_K4_WINDOW_ROUNDS + 1); + EXPECT_EQ(ctl.state()->getFrActive(), false); - // Important: FR deactivation logic (QThirtyFour.h:1236-1245) - // FR.frActive is set to TRUE only if: (jackpot < target AND roundsSinceK4 < 50) - // FR.frActive is set to FALSE only if: frRoundsAtOrAboveTarget >= 3 - // Otherwise, frActive retains its previous state. - // - // In this test: - // - shouldActivateFR = false (because roundsSinceK4 >= 50) - // - frRoundsAtOrAboveTarget = 0 (because jackpot < target) - // - Neither activation nor deactivation condition met - // - FR remains in previous state (true) - // - // This means FR doesn't automatically deactivate when window expires, - // but it won't RE-ACTIVATE in future rounds while roundsSinceK4 >= 50. - - // The key behavior verified by this test: - // Once roundsSinceK4 >= 50, FR will NOT be re-activated (shouldActivateFR = false) - // even if jackpot drops below target again. FR stays in whatever state it was. - - // To fully deactivate FR after window expiry, jackpot must reach target - // and stay there for 3 rounds (hysteresis). Let's verify that FR won't re-activate: - - const bool frActiveBeforeThirdRound = ctl.state()->getFrActive(); - - // Run a third round - FR should remain in same state (no re-activation) + // Run a third round to ensure FR stays OFF while still outside the window. ctl.beginEpochWithValidTime(); for (int i = 0; i < numPlayers; ++i) { @@ -2961,13 +3113,6 @@ TEST(ContractQThirtyFour, FR_PostK4WindowExpiry_DoesNotReactivateWhenWindowExpir } ctl.drawWithDigest(testDigest); - // FR state should not change (no re-activation possible when roundsSinceK4 >= 50) - EXPECT_EQ(ctl.state()->getFrActive(), frActiveBeforeThirdRound) << "FR should not re-activate when roundsSinceK4 >= 50, even if jackpot < target"; EXPECT_EQ(ctl.state()->getFrRoundsSinceK4(), QTF_FR_POST_K4_WINDOW_ROUNDS + 2); - - // This test verifies the post-k4 window mechanism: - // FR can only be ACTIVATED within 50 rounds after last k=4 win. - // After 50 rounds, FR won't re-activate regardless of jackpot level, - // until the next k=4 win resets the counter. - // However, if FR was already active, it stays active until hysteresis deactivates it. + EXPECT_EQ(ctl.state()->getFrActive(), false); } From c2ec8057e01fa9059514e307befe16e8f0d92211 Mon Sep 17 00:00:00 2001 From: N-010 Date: Fri, 19 Dec 2025 14:32:31 +0300 Subject: [PATCH 20/77] Fixes ContractVerify --- src/contracts/QReservePool.h | 41 +++++++++++++++++++----------------- src/contracts/QThirtyFour.h | 4 ++-- test/contract_qrp.cpp | 22 +++++++++---------- test/contract_qtf.cpp | 1 + 4 files changed, 36 insertions(+), 32 deletions(-) diff --git a/src/contracts/QReservePool.h b/src/contracts/QReservePool.h index 254c66b76..ca247e735 100644 --- a/src/contracts/QReservePool.h +++ b/src/contracts/QReservePool.h @@ -4,21 +4,24 @@ static constexpr uint16 QRP_AVAILABLE_SC_NUM = 128; static constexpr uint64 QRP_QTF_INDEX = 21; -enum class QRPReturnCode : uint8 -{ - SUCCESS = 0, - ACCESS_DENIED = 1, - INSUFFICIENT_RESERVE = 2, - - MAX_VALUE = UINT8_MAX -}; - struct QRP2 { }; struct QRP : public ContractBase { +public: + enum class EReturnCode : uint8 + { + SUCCESS = 0, + ACCESS_DENIED = 1, + INSUFFICIENT_RESERVE = 2, + + MAX_VALUE = UINT8_MAX + }; + + static constexpr uint8 toReturnCode(const EReturnCode& code) { return static_cast(code); }; + public: // Get Reserve struct GetReserve_input @@ -30,7 +33,7 @@ struct QRP : public ContractBase { // How much revenue is allocated to SC uint64 allocatedRevenue; - QRPReturnCode returnCode; + uint8 returnCode; }; struct GetReserve_locals @@ -47,7 +50,7 @@ struct QRP : public ContractBase struct AddAvailableSC_output { - QRPReturnCode returnCode; + uint8 returnCode; }; // Remove Available Smart Contract @@ -58,7 +61,7 @@ struct QRP : public ContractBase struct RemoveAvailableSC_output { - QRPReturnCode returnCode; + uint8 returnCode; }; // Get Available Reserve @@ -120,7 +123,7 @@ struct QRP : public ContractBase if (!state.availableSmartContracts.contains(qpi.invocator())) { output.allocatedRevenue = 0; - output.returnCode = QRPReturnCode::ACCESS_DENIED; + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; } @@ -129,12 +132,12 @@ struct QRP : public ContractBase if (locals.checkAmount == 0 || input.revenue > locals.checkAmount) { output.allocatedRevenue = 0; - output.returnCode = QRPReturnCode::INSUFFICIENT_RESERVE; + output.returnCode = toReturnCode(EReturnCode::INSUFFICIENT_RESERVE); return; } output.allocatedRevenue = input.revenue; - output.returnCode = QRPReturnCode::SUCCESS; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); qpi.transfer(qpi.invocator(), output.allocatedRevenue); } @@ -143,24 +146,24 @@ struct QRP : public ContractBase { if (qpi.invocator() != state.ownerAddress) { - output.returnCode = QRPReturnCode::ACCESS_DENIED; + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; } state.availableSmartContracts.add(id(input.scIndex, 0, 0, 0)); - output.returnCode = QRPReturnCode::SUCCESS; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); } PUBLIC_PROCEDURE(RemoveAvailableSC) { if (qpi.invocator() != state.ownerAddress) { - output.returnCode = QRPReturnCode::ACCESS_DENIED; + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; } state.availableSmartContracts.remove(id(input.scIndex, 0, 0, 0)); - output.returnCode = QRPReturnCode::SUCCESS; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); } PUBLIC_FUNCTION_WITH_LOCALS(GetAvailableReserve) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index c47252c21..521940944 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -1465,7 +1465,7 @@ struct QTF : public ContractBase locals.qrpGetReserveInput.revenue = locals.qrpRequested; INVOKE_OTHER_CONTRACT_PROCEDURE(QRP, GetReserve, locals.qrpGetReserveInput, locals.qrpGetReserveOutput, 0ll); - if (locals.qrpGetReserveOutput.returnCode == QRPReturnCode::SUCCESS) + if (locals.qrpGetReserveOutput.returnCode == QRP::toReturnCode(QRP::EReturnCode::SUCCESS)) { locals.qrpReceived = locals.qrpGetReserveOutput.allocatedRevenue; state.jackpot = sadd(state.jackpot, locals.qrpReceived); @@ -1862,7 +1862,7 @@ struct QTF : public ContractBase locals.qrpGetReserveInput.revenue = locals.qrpRequested; INVOKE_OTHER_CONTRACT_PROCEDURE(QRP, GetReserve, locals.qrpGetReserveInput, locals.qrpGetReserveOutput, 0ll); - if (locals.qrpGetReserveOutput.returnCode == QRPReturnCode::SUCCESS) + if (locals.qrpGetReserveOutput.returnCode == QRP::toReturnCode(QRP::EReturnCode::SUCCESS)) { output.topUpReceived = locals.qrpGetReserveOutput.allocatedRevenue; locals.finalPool = sadd(input.payoutPool, output.topUpReceived); diff --git a/test/contract_qrp.cpp b/test/contract_qrp.cpp index 04bfc6986..1bf3233e5 100644 --- a/test/contract_qrp.cpp +++ b/test/contract_qrp.cpp @@ -104,20 +104,20 @@ TEST(ContractQReservePool, GetReserveEnforcesAuthorizationAndBalance) qrp.fund(QRP_DEFAULT_SC_ID, 0); QRP::GetReserve_output denied = qrp.getReserve(unauthorized, 100); - EXPECT_EQ(denied.returnCode, QRPReturnCode::ACCESS_DENIED); + EXPECT_EQ(denied.returnCode, QRP::toReturnCode(QRP::EReturnCode::ACCESS_DENIED)); EXPECT_EQ(denied.allocatedRevenue, 0ull); qrp.fundQrp(1000); EXPECT_EQ(qrp.balanceQrp(), 1000); QRP::GetReserve_output granted = qrp.getReserve(QRP_DEFAULT_SC_ID, 600); - EXPECT_EQ(granted.returnCode, QRPReturnCode::SUCCESS); + EXPECT_EQ(granted.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); EXPECT_EQ(granted.allocatedRevenue, 600ull); EXPECT_EQ(qrp.balanceQrp(), 400); EXPECT_EQ(qrp.balanceOf(QRP_DEFAULT_SC_ID), 600); QRP::GetReserve_output insufficient = qrp.getReserve(QRP_DEFAULT_SC_ID, 500); - EXPECT_EQ(insufficient.returnCode, QRPReturnCode::INSUFFICIENT_RESERVE); + EXPECT_EQ(insufficient.returnCode, QRP::toReturnCode(QRP::EReturnCode::INSUFFICIENT_RESERVE)); EXPECT_EQ(insufficient.allocatedRevenue, 0ull); EXPECT_EQ(qrp.balanceQrp(), 400); EXPECT_EQ(qrp.balanceOf(QRP_DEFAULT_SC_ID), 600); @@ -133,13 +133,13 @@ TEST(ContractQReservePool, GetReserve_ZeroAndExactRemaining) // Zero request should not move funds. const QRP::GetReserve_output zero = qrp.getReserve(QRP_DEFAULT_SC_ID, 0); - EXPECT_EQ(zero.returnCode, QRPReturnCode::SUCCESS); + EXPECT_EQ(zero.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); EXPECT_EQ(zero.allocatedRevenue, 0ull); EXPECT_EQ(qrp.balanceQrp(), 1000); // Exact remaining should succeed and drain the reserve. const QRP::GetReserve_output exact = qrp.getReserve(QRP_DEFAULT_SC_ID, 1000); - EXPECT_EQ(exact.returnCode, QRPReturnCode::SUCCESS); + EXPECT_EQ(exact.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); EXPECT_EQ(exact.allocatedRevenue, 1000ull); EXPECT_EQ(qrp.balanceQrp(), 0); EXPECT_EQ(qrp.balanceOf(QRP_DEFAULT_SC_ID), 1000); @@ -157,22 +157,22 @@ TEST(ContractQReservePool, OwnerAddsAndRemovesSmartContracts) qrp.fund(state->owner(), 0); QRP::AddAvailableSC_output deniedAdd = qrp.addAvailableSC(outsider, newScIndex); - EXPECT_EQ(deniedAdd.returnCode, QRPReturnCode::ACCESS_DENIED); + EXPECT_EQ(deniedAdd.returnCode, QRP::toReturnCode(QRP::EReturnCode::ACCESS_DENIED)); EXPECT_FALSE(state->hasAvailableSC(newScId)); QRP::AddAvailableSC_output approvedAdd = qrp.addAvailableSC(state->owner(), newScIndex); - EXPECT_EQ(approvedAdd.returnCode, QRPReturnCode::SUCCESS); + EXPECT_EQ(approvedAdd.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); EXPECT_TRUE(state->hasAvailableSC(newScId)); QRP::GetAvailableSC_output available = qrp.getAvailableSCs(); EXPECT_TRUE(containsAvailableSC(available, newScId)); QRP::RemoveAvailableSC_output deniedRemove = qrp.removeAvailableSC(outsider, newScIndex); - EXPECT_EQ(deniedRemove.returnCode, QRPReturnCode::ACCESS_DENIED); + EXPECT_EQ(deniedRemove.returnCode, QRP::toReturnCode(QRP::EReturnCode::ACCESS_DENIED)); EXPECT_TRUE(state->hasAvailableSC(newScId)); QRP::RemoveAvailableSC_output approvedRemove = qrp.removeAvailableSC(state->owner(), newScIndex); - EXPECT_EQ(approvedRemove.returnCode, QRPReturnCode::SUCCESS); + EXPECT_EQ(approvedRemove.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); EXPECT_FALSE(state->hasAvailableSC(newScId)); } @@ -191,7 +191,7 @@ TEST(ContractQReservePool, OwnerAddRemove_IdempotencyAndBounds) // This test focuses on idempotency (repeat add/remove) while keeping authorization valid. // Add twice: first should succeed, second should not change membership (return code may be SUCCESS or specific). const auto add1 = qrp.addAvailableSC(state->owner(), newScIndex); - EXPECT_EQ(add1.returnCode, QRPReturnCode::SUCCESS); + EXPECT_EQ(add1.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); EXPECT_TRUE(state->hasAvailableSC(newScId)); const auto add2 = qrp.addAvailableSC(state->owner(), newScIndex); @@ -199,7 +199,7 @@ TEST(ContractQReservePool, OwnerAddRemove_IdempotencyAndBounds) // Remove twice: first should succeed, second should keep it removed (return code may be SUCCESS or specific). const auto rem1 = qrp.removeAvailableSC(state->owner(), newScIndex); - EXPECT_EQ(rem1.returnCode, QRPReturnCode::SUCCESS); + EXPECT_EQ(rem1.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); EXPECT_FALSE(state->hasAvailableSC(newScId)); const auto rem2 = qrp.removeAvailableSC(state->owner(), newScIndex); diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp index 2b5674938..f7c56c4bf 100644 --- a/test/contract_qtf.cpp +++ b/test/contract_qtf.cpp @@ -481,6 +481,7 @@ class ContractTestingQTF : protected ContractTesting constexpr uint8 m = 1; constexpr uint8 d = 10; setDateTime(y, m, d, 12); + __pauseLogMessage(); forceBeginTick(); } From edfe234ed8db36dc9db089dc628e0c0aa46fca88 Mon Sep 17 00:00:00 2001 From: N-010 Date: Fri, 19 Dec 2025 15:13:27 +0300 Subject: [PATCH 21/77] Use RL::max --- src/contracts/QReservePool.h | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/contracts/QReservePool.h b/src/contracts/QReservePool.h index ca247e735..710a81073 100644 --- a/src/contracts/QReservePool.h +++ b/src/contracts/QReservePool.h @@ -128,7 +128,7 @@ struct QRP : public ContractBase } qpi.getEntity(SELF, locals.entity); - locals.checkAmount = max(locals.entity.incomingAmount - locals.entity.outgoingAmount, 0i64); + locals.checkAmount = RL::max(locals.entity.incomingAmount - locals.entity.outgoingAmount, 0i64); if (locals.checkAmount == 0 || input.revenue > locals.checkAmount) { output.allocatedRevenue = 0; @@ -169,7 +169,7 @@ struct QRP : public ContractBase PUBLIC_FUNCTION_WITH_LOCALS(GetAvailableReserve) { qpi.getEntity(SELF, locals.entity); - output.availableReserve = max(locals.entity.incomingAmount - locals.entity.outgoingAmount, 0i64); + output.availableReserve = RL::max(locals.entity.incomingAmount - locals.entity.outgoingAmount, 0i64); } PUBLIC_FUNCTION_WITH_LOCALS(GetAvailableSC) @@ -185,9 +185,6 @@ struct QRP : public ContractBase } } -protected: - template static constexpr const T& max(const T& a, const T& b) { return (a > b) ? a : b; } - protected: /** * @brief Address of the team managing the lottery contract. From 7be394dfb62f12b2403063bb96dffd37d13fd167 Mon Sep 17 00:00:00 2001 From: N-010 Date: Fri, 19 Dec 2025 18:51:16 +0300 Subject: [PATCH 22/77] Fixes ContractVerify --- src/contracts/QThirtyFour.h | 45 ++++++++++++++++++++----------------- test/contract_qtf.cpp | 2 ++ 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 521940944..2cb6bd7c2 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -67,8 +67,6 @@ const id QTF_RANDOM_LOTTERY_CONTRACT_ID = id(RL_CONTRACT_INDEX, 0, 0, 0); constexpr uint64 QTF_RANDOM_LOTTERY_ASSET_NAME = 19538; // RL const id QTF_RESERVE_POOL_CONTRACT_ID = id(QRP_CONTRACT_INDEX, 0, 0, 0); -using QTFRandomValues = Array; - struct QTF2 { }; @@ -100,13 +98,13 @@ struct QTF : public ContractBase struct PlayerData { id player; - QTFRandomValues randomValues; + Array randomValues; }; struct WinnerData { Array winners; - QTFRandomValues winnerValues; + Array winnerValues; uint64 winnerCounter; uint16 epoch; }; @@ -114,7 +112,13 @@ struct QTF : public ContractBase struct NextEpochData { public: - void clear() { setMemory(*this, 0); } + void clear() + { + newTicketPrice = 0; + newTargetJackpot = 0; + newSchedule = 0; + newDrawHour = 0; + } void apply(QTF& state) const { @@ -164,7 +168,7 @@ struct QTF : public ContractBase // ValidateNumbers: Check if all numbers are valid [1..30] and unique struct ValidateNumbers_input { - QTFRandomValues numbers; // Numbers to validate + Array numbers; // Numbers to validate }; struct ValidateNumbers_output { @@ -180,7 +184,7 @@ struct QTF : public ContractBase // Buy Ticket struct BuyTicket_input { - QTFRandomValues randomValues; + Array randomValues; }; struct BuyTicket_output { @@ -344,7 +348,7 @@ struct QTF : public ContractBase }; struct GetRandomValues_output { - QTFRandomValues values; // 4 unique random values [1..30] + Array values; // 4 unique random values [1..30] }; struct GetRandomValues_locals { @@ -480,8 +484,8 @@ struct QTF : public ContractBase struct CountMatches_input { - QTFRandomValues playerValues; - QTFRandomValues winningValues; + Array playerValues; + Array winningValues; }; struct CountMatches_output @@ -592,7 +596,7 @@ struct QTF : public ContractBase struct SettleEpoch_locals { - QTFRandomValues winningValues; + Array winningValues; ReturnAllTickets_input returnAllTicketsInput; ReturnAllTickets_output returnAllTicketsOutput; ReturnAllTickets_locals returnAllTicketsLocals; @@ -1038,7 +1042,7 @@ struct QTF : public ContractBase // If pool insufficient, we show floor; otherwise calculate actual per-winner amount if (locals.k2PayoutPoolEffective >= locals.k2FloorTotal) { - output.k2PayoutPerWinner = RL::min(output.perWinnerCap, locals.k2PayoutPoolEffective / input.k2WinnerCount); + output.k2PayoutPerWinner = RL::min(output.perWinnerCap, div(locals.k2PayoutPoolEffective, input.k2WinnerCount)); } else { @@ -1061,7 +1065,7 @@ struct QTF : public ContractBase // Note: This is an estimate - actual implementation may top up from reserve if (locals.k3PayoutPoolEffective >= locals.k3FloorTotal) { - output.k3PayoutPerWinner = RL::min(output.perWinnerCap, locals.k3PayoutPoolEffective / input.k3WinnerCount); + output.k3PayoutPerWinner = RL::min(output.perWinnerCap, div(locals.k3PayoutPoolEffective, input.k3WinnerCount)); } else { @@ -1111,7 +1115,7 @@ struct QTF : public ContractBase static void deriveOne(const uint64& r, const uint64& idx, uint64& outValue) { mix64(r + 0x9e3779b97f4a7c15ULL * (idx + 1), outValue); } - static void addPlayerInfo(QTF& state, const id& playerId, const QTFRandomValues& randomValues) + static void addPlayerInfo(QTF& state, const id& playerId, const Array& randomValues) { state.players.set(state.numberOfPlayers++, {playerId, randomValues}); } @@ -1137,7 +1141,8 @@ struct QTF : public ContractBase static void clearWinerData(QTF& state) { setMemory(state.lastWinnerData, 0); } - static void fillWinnerData(QTF& state, const PlayerData& playerData, const QTFRandomValues& winnerValues, const uint16& epoch) + static void fillWinnerData(QTF& state, const PlayerData& playerData, const Array& winnerValues, + const uint16& epoch) { if (!isZero(playerData.player)) { @@ -1424,13 +1429,13 @@ struct QTF : public ContractBase fillWinnerData(state, state.players.get(locals.i), locals.winningValues, locals.currentEpoch); } // k3 payout - else if (locals.matches == 3 && locals.countK3 > 0 && locals.k3PerWinner > 0) + if (locals.matches == 3 && locals.countK3 > 0 && locals.k3PerWinner > 0) { qpi.transfer(state.players.get(locals.i).player, locals.k3PerWinner); fillWinnerData(state, state.players.get(locals.i), locals.winningValues, locals.currentEpoch); } // k4 payout (jackpot) - else if (locals.matches == 4 && locals.countK4 > 0) + if (locals.matches == 4 && locals.countK4 > 0) { if (locals.jackpotPerK4Winner > 0) { @@ -1704,7 +1709,7 @@ struct QTF : public ContractBase for (locals.index = 0; locals.index < output.values.capacity(); ++locals.index) { deriveOne(input.seed, locals.index, locals.tempValue); - locals.candidate = static_cast(mod(locals.tempValue, QTF_MAX_RANDOM_VALUE) + 1); + locals.candidate = static_cast(sadd(mod(locals.tempValue, QTF_MAX_RANDOM_VALUE), 1ull)); locals.attempts = 0; while (locals.used.contains(locals.candidate) && locals.attempts < QTF_MAX_RANDOM_GENERATION_ATTEMPTS) @@ -1715,7 +1720,7 @@ struct QTF : public ContractBase locals.tempValue ^= locals.tempValue << 25; locals.tempValue ^= locals.tempValue >> 27; locals.tempValue *= 2685821657736338717ULL; - locals.candidate = static_cast(mod(locals.tempValue, QTF_MAX_RANDOM_VALUE) + 1); + locals.candidate = static_cast(sadd(mod(locals.tempValue, QTF_MAX_RANDOM_VALUE), 1ull)); } // Fallback: if still duplicate after max attempts, find first unused value @@ -1871,7 +1876,7 @@ struct QTF : public ContractBase } // Calculate per-winner payout (capped at perWinnerCap) - output.perWinnerPayout = RL::min(input.perWinnerCap, locals.finalPool / input.winnerCount); + output.perWinnerPayout = RL::min(input.perWinnerCap, div(locals.finalPool, input.winnerCount)); output.overflow = locals.finalPool - smul(output.perWinnerPayout, input.winnerCount); } diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp index f7c56c4bf..9ecd05747 100644 --- a/test/contract_qtf.cpp +++ b/test/contract_qtf.cpp @@ -28,6 +28,8 @@ constexpr uint16 QTF_FUNCTION_GET_STATE = 7; constexpr uint16 QTF_FUNCTION_GET_FEES = 8; constexpr uint16 QTF_FUNCTION_ESTIMATE_PRIZE_PAYOUTS = 9; +using QTFRandomValues = Array; + namespace { static void issueRlSharesTo(std::vector>& initialOwnerShares) From 981d2f3ea1fa3b91ba3a530379024df6e41ecbd2 Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 8 Jan 2026 18:46:36 +0300 Subject: [PATCH 23/77] Renames Available to Allowed for SC Auto-update QTF index in QRP Adds cleanup for allowedSmartContracts in QRP --- src/contracts/QReservePool.h | 76 ++++++++++++++-------------- src/contracts/QThirtyFour.h | 19 +++---- test/contract_qrp.cpp | 98 ++++++++++++++++++------------------ 3 files changed, 95 insertions(+), 98 deletions(-) diff --git a/src/contracts/QReservePool.h b/src/contracts/QReservePool.h index 710a81073..c0d7fc707 100644 --- a/src/contracts/QReservePool.h +++ b/src/contracts/QReservePool.h @@ -1,16 +1,16 @@ using namespace QPI; // Number of available smart contracts in the QRP contract. -static constexpr uint16 QRP_AVAILABLE_SC_NUM = 128; -static constexpr uint64 QRP_QTF_INDEX = 21; +constexpr uint16 QRP_ALLOWED_SC_NUM = 128; +constexpr uint64 QRP_QTF_INDEX = QRP_CONTRACT_INDEX + 1; +constexpr uint64 QRP_REMOVAL_THRESHOLD_PERCENT = 75; struct QRP2 { }; -struct QRP : public ContractBase +struct QRP : ContractBase { -public: enum class EReturnCode : uint8 { SUCCESS = 0, @@ -22,44 +22,43 @@ struct QRP : public ContractBase static constexpr uint8 toReturnCode(const EReturnCode& code) { return static_cast(code); }; -public: // Get Reserve - struct GetReserve_input + struct WithdrawReserve_input { uint64 revenue; }; - struct GetReserve_output + struct WithdrawReserve_output { // How much revenue is allocated to SC uint64 allocatedRevenue; uint8 returnCode; }; - struct GetReserve_locals + struct WithdrawReserve_locals { Entity entity; uint64 checkAmount; }; - // Add Available Smart Contract - struct AddAvailableSC_input + // Add Allowed Smart Contract + struct AddAllowedSC_input { uint64 scIndex; }; - struct AddAvailableSC_output + struct AddAllowedSC_output { uint8 returnCode; }; - // Remove Available Smart Contract - struct RemoveAvailableSC_input + // Remove Allowed Smart Contract + struct RemoveAllowedSC_input { uint64 scIndex; }; - struct RemoveAvailableSC_output + struct RemoveAllowedSC_output { uint8 returnCode; }; @@ -79,23 +78,22 @@ struct QRP : public ContractBase Entity entity; }; - // Get Available Smart Contract - struct GetAvailableSC_input + // Get Allowed Smart Contract + struct GetAllowedSC_input { }; - struct GetAvailableSC_output + struct GetAllowedSC_output { - Array availableSCs; + Array allowedSC; }; - struct GetAvailableSC_locals + struct GetAllowedSC_locals { sint64 nextIndex; uint64 arrayIndex; }; -public: INITIALIZE() { // Set team/developer address (owner and team are the same for now) @@ -103,24 +101,26 @@ struct QRP : public ContractBase _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); state.ownerAddress = state.teamAddress; - // Adds QTF to the list of available smart contracts. - state.availableSmartContracts.add(id(QRP_QTF_INDEX, 0, 0, 0)); + // Adds QTF to the list of allowed smart contracts. + state.allowedSmartContracts.add(id(QRP_QTF_INDEX, 0, 0, 0)); } REGISTER_USER_FUNCTIONS_AND_PROCEDURES() { // Procedures - REGISTER_USER_PROCEDURE(GetReserve, 1); - REGISTER_USER_PROCEDURE(AddAvailableSC, 2); - REGISTER_USER_PROCEDURE(RemoveAvailableSC, 3); + REGISTER_USER_PROCEDURE(WithdrawReserve, 1); + REGISTER_USER_PROCEDURE(AddAllowedSC, 2); + REGISTER_USER_PROCEDURE(RemoveAllowedSC, 3); // Functions REGISTER_USER_FUNCTION(GetAvailableReserve, 1); - REGISTER_USER_FUNCTION(GetAvailableSC, 2); + REGISTER_USER_FUNCTION(GetAllowedSC, 2); } - PUBLIC_PROCEDURE_WITH_LOCALS(GetReserve) + END_EPOCH() { state.allowedSmartContracts.cleanup(); } + + PUBLIC_PROCEDURE_WITH_LOCALS(WithdrawReserve) { - if (!state.availableSmartContracts.contains(qpi.invocator())) + if (!state.allowedSmartContracts.contains(qpi.invocator())) { output.allocatedRevenue = 0; output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); @@ -142,7 +142,7 @@ struct QRP : public ContractBase qpi.transfer(qpi.invocator(), output.allocatedRevenue); } - PUBLIC_PROCEDURE(AddAvailableSC) + PUBLIC_PROCEDURE(AddAllowedSC) { if (qpi.invocator() != state.ownerAddress) { @@ -150,11 +150,11 @@ struct QRP : public ContractBase return; } - state.availableSmartContracts.add(id(input.scIndex, 0, 0, 0)); + state.allowedSmartContracts.add(id(input.scIndex, 0, 0, 0)); output.returnCode = toReturnCode(EReturnCode::SUCCESS); } - PUBLIC_PROCEDURE(RemoveAvailableSC) + PUBLIC_PROCEDURE(RemoveAllowedSC) { if (qpi.invocator() != state.ownerAddress) { @@ -162,8 +162,10 @@ struct QRP : public ContractBase return; } - state.availableSmartContracts.remove(id(input.scIndex, 0, 0, 0)); + state.allowedSmartContracts.remove(id(input.scIndex, 0, 0, 0)); output.returnCode = toReturnCode(EReturnCode::SUCCESS); + + state.allowedSmartContracts.cleanupIfNeeded(QRP_REMOVAL_THRESHOLD_PERCENT); } PUBLIC_FUNCTION_WITH_LOCALS(GetAvailableReserve) @@ -172,16 +174,16 @@ struct QRP : public ContractBase output.availableReserve = RL::max(locals.entity.incomingAmount - locals.entity.outgoingAmount, 0i64); } - PUBLIC_FUNCTION_WITH_LOCALS(GetAvailableSC) + PUBLIC_FUNCTION_WITH_LOCALS(GetAllowedSC) { locals.arrayIndex = 0; locals.nextIndex = -1; - locals.nextIndex = state.availableSmartContracts.nextElementIndex(locals.nextIndex); + locals.nextIndex = state.allowedSmartContracts.nextElementIndex(locals.nextIndex); while (locals.nextIndex != NULL_INDEX) { - output.availableSCs.set(locals.arrayIndex++, state.availableSmartContracts.key(locals.nextIndex)); - locals.nextIndex = state.availableSmartContracts.nextElementIndex(locals.nextIndex); + output.allowedSC.set(locals.arrayIndex++, state.allowedSmartContracts.key(locals.nextIndex)); + locals.nextIndex = state.allowedSmartContracts.nextElementIndex(locals.nextIndex); } } @@ -198,5 +200,5 @@ struct QRP : public ContractBase */ id ownerAddress; - HashSet availableSmartContracts; + HashSet allowedSmartContracts; }; diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 2cb6bd7c2..7033d8831 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -71,9 +71,8 @@ struct QTF2 { }; -struct QTF : public ContractBase +struct QTF : ContractBase { -public: enum class EReturnCode : uint8 { SUCCESS, @@ -111,7 +110,6 @@ struct QTF : public ContractBase struct NextEpochData { - public: void clear() { newTicketPrice = 0; @@ -140,7 +138,6 @@ struct QTF : public ContractBase } } - public: uint64 newTicketPrice; uint64 newTargetJackpot; uint8 newSchedule; @@ -402,8 +399,8 @@ struct QTF : public ContractBase uint64 qrpRequested; CalcReserveTopUp_input calcTopUpInput; CalcReserveTopUp_output calcTopUpOutput; - QRP::GetReserve_input qrpGetReserveInput; - QRP::GetReserve_output qrpGetReserveOutput; + QRP::WithdrawReserve_input qrpGetReserveInput; + QRP::WithdrawReserve_output qrpGetReserveOutput; }; // Ticket Price @@ -654,8 +651,8 @@ struct QTF : public ContractBase ProcessTierPayout_input tierPayoutInput; ProcessTierPayout_output tierPayoutOutput; // CALL_OTHER_CONTRACT parameters for QRP (external reserve pool) - QRP::GetReserve_input qrpGetReserveInput; - QRP::GetReserve_output qrpGetReserveOutput; + QRP::WithdrawReserve_input qrpGetReserveInput; + QRP::WithdrawReserve_output qrpGetReserveOutput; QRP::GetAvailableReserve_input qrpGetAvailableInput; QRP::GetAvailableReserve_output qrpGetAvailableOutput; uint64 qrpRequested; // Amount requested from QRP @@ -691,7 +688,6 @@ struct QTF : public ContractBase bit isScheduledToday; }; -public: // Contract lifecycle methods INITIALIZE() { @@ -1156,7 +1152,6 @@ struct QTF : public ContractBase state.lastWinnerData.epoch = epoch; } -protected: WinnerData lastWinnerData; // last winners snapshot NextEpochData nextEpochData; // queued config (ticket price) @@ -1468,7 +1463,7 @@ struct QTF : public ContractBase if (locals.qrpRequested > 0) { locals.qrpGetReserveInput.revenue = locals.qrpRequested; - INVOKE_OTHER_CONTRACT_PROCEDURE(QRP, GetReserve, locals.qrpGetReserveInput, locals.qrpGetReserveOutput, 0ll); + INVOKE_OTHER_CONTRACT_PROCEDURE(QRP, WithdrawReserve, locals.qrpGetReserveInput, locals.qrpGetReserveOutput, 0ll); if (locals.qrpGetReserveOutput.returnCode == QRP::toReturnCode(QRP::EReturnCode::SUCCESS)) { @@ -1865,7 +1860,7 @@ struct QTF : public ContractBase if (locals.qrpRequested > 0) { locals.qrpGetReserveInput.revenue = locals.qrpRequested; - INVOKE_OTHER_CONTRACT_PROCEDURE(QRP, GetReserve, locals.qrpGetReserveInput, locals.qrpGetReserveOutput, 0ll); + INVOKE_OTHER_CONTRACT_PROCEDURE(QRP, WithdrawReserve, locals.qrpGetReserveInput, locals.qrpGetReserveOutput, 0ll); if (locals.qrpGetReserveOutput.returnCode == QRP::toReturnCode(QRP::EReturnCode::SUCCESS)) { diff --git a/test/contract_qrp.cpp b/test/contract_qrp.cpp index 1bf3233e5..9881d4dca 100644 --- a/test/contract_qrp.cpp +++ b/test/contract_qrp.cpp @@ -4,11 +4,11 @@ // Procedure/function indices (must match REGISTER_USER_FUNCTIONS_AND_PROCEDURES in `src/contracts/QReservePool.h`). constexpr uint16 QRP_PROC_GET_RESERVE = 1; -constexpr uint16 QRP_PROC_ADD_AVAILABLE_SC = 2; -constexpr uint16 QRP_PROC_REMOVE_AVAILABLE_SC = 3; +constexpr uint16 QRP_PROC_ADD_ALLOWED_SC = 2; +constexpr uint16 QRP_PROC_REMOVE_ALLOWED_SC = 3; constexpr uint16 QRP_FUNC_GET_AVAILABLE_RESERVE = 1; -constexpr uint16 QRP_FUNC_GET_AVAILABLE_SCS = 2; +constexpr uint16 QRP_FUNC_GET_ALLOWED_SC = 2; static const id QRP_CONTRACT_ID(QRP_CONTRACT_INDEX, 0, 0, 0); static const id QRP_DEFAULT_SC_ID(QRP_QTF_INDEX, 0, 0, 0); @@ -21,8 +21,8 @@ class QRPChecker : public QRP public: const id& team() const { return teamAddress; } const id& owner() const { return ownerAddress; } - bool hasAvailableSC(const id& sc) const { return availableSmartContracts.contains(sc); } - uint64 availableCount() const { return availableSmartContracts.population(); } + bool hasAllowedSC(const id& sc) const { return allowedSmartContracts.contains(sc); } + uint64 allowedCount() const { return allowedSmartContracts.population(); } }; class ContractTestingQRP : protected ContractTesting @@ -43,27 +43,27 @@ class ContractTestingQRP : protected ContractTesting void fund(const id& account, uint64 amount) { increaseEnergy(account, amount); } void fundQrp(uint64 amount) { fund(QRP_CONTRACT_ID, amount); } - QRP::GetReserve_output getReserve(const id& invocator, uint64 revenue, sint64 attachedAmount = 0) + QRP::WithdrawReserve_output withdrawReserveReserve(const id& invocator, uint64 revenue, sint64 attachedAmount = 0) { - QRP::GetReserve_input input{revenue}; - QRP::GetReserve_output output{}; + QRP::WithdrawReserve_input input{revenue}; + QRP::WithdrawReserve_output output{}; invokeUserProcedure(QRP_CONTRACT_INDEX, QRP_PROC_GET_RESERVE, input, output, invocator, attachedAmount); return output; } - QRP::AddAvailableSC_output addAvailableSC(const id& invocator, uint64 scIndex) + QRP::AddAllowedSC_output addAllowedSC(const id& invocator, uint64 scIndex) { - QRP::AddAvailableSC_input input{scIndex}; - QRP::AddAvailableSC_output output{}; - invokeUserProcedure(QRP_CONTRACT_INDEX, QRP_PROC_ADD_AVAILABLE_SC, input, output, invocator, 0); + QRP::AddAllowedSC_input input{scIndex}; + QRP::AddAllowedSC_output output{}; + invokeUserProcedure(QRP_CONTRACT_INDEX, QRP_PROC_ADD_ALLOWED_SC, input, output, invocator, 0); return output; } - QRP::RemoveAvailableSC_output removeAvailableSC(const id& invocator, uint64 scIndex) + QRP::RemoveAllowedSC_output removeAllowedSC(const id& invocator, uint64 scIndex) { - QRP::RemoveAvailableSC_input input{scIndex}; - QRP::RemoveAvailableSC_output output{}; - invokeUserProcedure(QRP_CONTRACT_INDEX, QRP_PROC_REMOVE_AVAILABLE_SC, input, output, invocator, 0); + QRP::RemoveAllowedSC_input input{scIndex}; + QRP::RemoveAllowedSC_output output{}; + invokeUserProcedure(QRP_CONTRACT_INDEX, QRP_PROC_REMOVE_ALLOWED_SC, input, output, invocator, 0); return output; } @@ -75,20 +75,20 @@ class ContractTestingQRP : protected ContractTesting return output; } - QRP::GetAvailableSC_output getAvailableSCs() const + QRP::GetAllowedSC_output getAllowedSC() const { - QRP::GetAvailableSC_input input{}; - QRP::GetAvailableSC_output output{}; - callFunction(QRP_CONTRACT_INDEX, QRP_FUNC_GET_AVAILABLE_SCS, input, output); + QRP::GetAllowedSC_input input{}; + QRP::GetAllowedSC_output output{}; + callFunction(QRP_CONTRACT_INDEX, QRP_FUNC_GET_ALLOWED_SC, input, output); return output; } }; -static bool containsAvailableSC(const QRP::GetAvailableSC_output& available, const id& sc) +static bool containsAllowedSC(const QRP::GetAllowedSC_output& allowed, const id& sc) { - for (uint64 i = 0; i < QRP_AVAILABLE_SC_NUM; ++i) + for (uint64 i = 0; i < QRP_ALLOWED_SC_NUM; ++i) { - if (available.availableSCs.get(i) == sc) + if (allowed.allowedSC.get(i) == sc) { return true; } @@ -96,34 +96,34 @@ static bool containsAvailableSC(const QRP::GetAvailableSC_output& available, con return false; } -TEST(ContractQReservePool, GetReserveEnforcesAuthorizationAndBalance) +TEST(ContractQReservePool, WithdrawReserveEnforcesAuthorizationAndBalance) { ContractTestingQRP qrp; const id unauthorized = id::randomValue(); qrp.fund(unauthorized, 0); qrp.fund(QRP_DEFAULT_SC_ID, 0); - QRP::GetReserve_output denied = qrp.getReserve(unauthorized, 100); + QRP::WithdrawReserve_output denied = qrp.withdrawReserveReserve(unauthorized, 100); EXPECT_EQ(denied.returnCode, QRP::toReturnCode(QRP::EReturnCode::ACCESS_DENIED)); EXPECT_EQ(denied.allocatedRevenue, 0ull); qrp.fundQrp(1000); EXPECT_EQ(qrp.balanceQrp(), 1000); - QRP::GetReserve_output granted = qrp.getReserve(QRP_DEFAULT_SC_ID, 600); + QRP::WithdrawReserve_output granted = qrp.withdrawReserveReserve(QRP_DEFAULT_SC_ID, 600); EXPECT_EQ(granted.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); EXPECT_EQ(granted.allocatedRevenue, 600ull); EXPECT_EQ(qrp.balanceQrp(), 400); EXPECT_EQ(qrp.balanceOf(QRP_DEFAULT_SC_ID), 600); - QRP::GetReserve_output insufficient = qrp.getReserve(QRP_DEFAULT_SC_ID, 500); + QRP::WithdrawReserve_output insufficient = qrp.withdrawReserveReserve(QRP_DEFAULT_SC_ID, 500); EXPECT_EQ(insufficient.returnCode, QRP::toReturnCode(QRP::EReturnCode::INSUFFICIENT_RESERVE)); EXPECT_EQ(insufficient.allocatedRevenue, 0ull); EXPECT_EQ(qrp.balanceQrp(), 400); EXPECT_EQ(qrp.balanceOf(QRP_DEFAULT_SC_ID), 600); } -TEST(ContractQReservePool, GetReserve_ZeroAndExactRemaining) +TEST(ContractQReservePool, WithdrawReserve_ZeroAndExactRemaining) { ContractTestingQRP qrp; qrp.fund(QRP_DEFAULT_SC_ID, 0); @@ -132,13 +132,13 @@ TEST(ContractQReservePool, GetReserve_ZeroAndExactRemaining) EXPECT_EQ(qrp.balanceQrp(), 1000); // Zero request should not move funds. - const QRP::GetReserve_output zero = qrp.getReserve(QRP_DEFAULT_SC_ID, 0); + const QRP::WithdrawReserve_output zero = qrp.withdrawReserveReserve(QRP_DEFAULT_SC_ID, 0); EXPECT_EQ(zero.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); EXPECT_EQ(zero.allocatedRevenue, 0ull); EXPECT_EQ(qrp.balanceQrp(), 1000); // Exact remaining should succeed and drain the reserve. - const QRP::GetReserve_output exact = qrp.getReserve(QRP_DEFAULT_SC_ID, 1000); + const QRP::WithdrawReserve_output exact = qrp.withdrawReserveReserve(QRP_DEFAULT_SC_ID, 1000); EXPECT_EQ(exact.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); EXPECT_EQ(exact.allocatedRevenue, 1000ull); EXPECT_EQ(qrp.balanceQrp(), 0); @@ -156,24 +156,24 @@ TEST(ContractQReservePool, OwnerAddsAndRemovesSmartContracts) qrp.fund(outsider, 0); qrp.fund(state->owner(), 0); - QRP::AddAvailableSC_output deniedAdd = qrp.addAvailableSC(outsider, newScIndex); + QRP::AddAllowedSC_output deniedAdd = qrp.addAllowedSC(outsider, newScIndex); EXPECT_EQ(deniedAdd.returnCode, QRP::toReturnCode(QRP::EReturnCode::ACCESS_DENIED)); - EXPECT_FALSE(state->hasAvailableSC(newScId)); + EXPECT_FALSE(state->hasAllowedSC(newScId)); - QRP::AddAvailableSC_output approvedAdd = qrp.addAvailableSC(state->owner(), newScIndex); + QRP::AddAllowedSC_output approvedAdd = qrp.addAllowedSC(state->owner(), newScIndex); EXPECT_EQ(approvedAdd.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); - EXPECT_TRUE(state->hasAvailableSC(newScId)); + EXPECT_TRUE(state->hasAllowedSC(newScId)); - QRP::GetAvailableSC_output available = qrp.getAvailableSCs(); - EXPECT_TRUE(containsAvailableSC(available, newScId)); + QRP::GetAllowedSC_output allowed = qrp.getAllowedSC(); + EXPECT_TRUE(containsAllowedSC(allowed, newScId)); - QRP::RemoveAvailableSC_output deniedRemove = qrp.removeAvailableSC(outsider, newScIndex); + QRP::RemoveAllowedSC_output deniedRemove = qrp.removeAllowedSC(outsider, newScIndex); EXPECT_EQ(deniedRemove.returnCode, QRP::toReturnCode(QRP::EReturnCode::ACCESS_DENIED)); - EXPECT_TRUE(state->hasAvailableSC(newScId)); + EXPECT_TRUE(state->hasAllowedSC(newScId)); - QRP::RemoveAvailableSC_output approvedRemove = qrp.removeAvailableSC(state->owner(), newScIndex); + QRP::RemoveAllowedSC_output approvedRemove = qrp.removeAllowedSC(state->owner(), newScIndex); EXPECT_EQ(approvedRemove.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); - EXPECT_FALSE(state->hasAvailableSC(newScId)); + EXPECT_FALSE(state->hasAllowedSC(newScId)); } TEST(ContractQReservePool, OwnerAddRemove_IdempotencyAndBounds) @@ -186,22 +186,22 @@ TEST(ContractQReservePool, OwnerAddRemove_IdempotencyAndBounds) const id newScId(newScIndex, 0, 0, 0); qrp.fund(newScId, 0); - EXPECT_FALSE(state->hasAvailableSC(newScId)); + EXPECT_FALSE(state->hasAllowedSC(newScId)); // This test focuses on idempotency (repeat add/remove) while keeping authorization valid. // Add twice: first should succeed, second should not change membership (return code may be SUCCESS or specific). - const auto add1 = qrp.addAvailableSC(state->owner(), newScIndex); + const auto add1 = qrp.addAllowedSC(state->owner(), newScIndex); EXPECT_EQ(add1.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); - EXPECT_TRUE(state->hasAvailableSC(newScId)); + EXPECT_TRUE(state->hasAllowedSC(newScId)); - const auto add2 = qrp.addAvailableSC(state->owner(), newScIndex); - EXPECT_TRUE(state->hasAvailableSC(newScId)); + const auto add2 = qrp.addAllowedSC(state->owner(), newScIndex); + EXPECT_TRUE(state->hasAllowedSC(newScId)); // Remove twice: first should succeed, second should keep it removed (return code may be SUCCESS or specific). - const auto rem1 = qrp.removeAvailableSC(state->owner(), newScIndex); + const auto rem1 = qrp.removeAllowedSC(state->owner(), newScIndex); EXPECT_EQ(rem1.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); - EXPECT_FALSE(state->hasAvailableSC(newScId)); + EXPECT_FALSE(state->hasAllowedSC(newScId)); - const auto rem2 = qrp.removeAvailableSC(state->owner(), newScIndex); - EXPECT_FALSE(state->hasAvailableSC(newScId)); + const auto rem2 = qrp.removeAllowedSC(state->owner(), newScIndex); + EXPECT_FALSE(state->hasAllowedSC(newScId)); } From 1a826142f603302620cdd17d57879bc785ed8a8b Mon Sep 17 00:00:00 2001 From: N-010 Date: Mon, 12 Jan 2026 13:15:15 +0300 Subject: [PATCH 24/77] Pulse prototype --- src/contract_core/contract_def.h | 12 + src/contracts/Pulse.h | 944 +++++++++++++++++++++++++++++++ 2 files changed, 956 insertions(+) create mode 100644 src/contracts/Pulse.h diff --git a/src/contract_core/contract_def.h b/src/contract_core/contract_def.h index a0bc113bb..a2494eafe 100644 --- a/src/contract_core/contract_def.h +++ b/src/contract_core/contract_def.h @@ -215,6 +215,16 @@ #endif +#undef CONTRACT_INDEX +#undef CONTRACT_STATE_TYPE +#undef CONTRACT_STATE2_TYPE + +#define PULSE_CONTRACT_INDEX 22 +#define CONTRACT_INDEX PULSE_CONTRACT_INDEX +#define CONTRACT_STATE_TYPE PULSE +#define CONTRACT_STATE2_TYPE PULSE2 +#include "contracts/Pulse.h" + // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES @@ -325,6 +335,7 @@ constexpr struct ContractDescription #ifndef NO_QRWA {"QRWA", 197, 10000, sizeof(QRWA)}, // proposal in epoch 195, IPO in 196, construction and first use in 197 #endif + {"PULSE", 200, 10000, sizeof(PULSE)}, // proposal in epoch 198, IPO in 199, construction and first use in 200 // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES {"TESTEXA", 138, 10000, sizeof(TESTEXA)}, @@ -443,6 +454,7 @@ static void initializeContracts() #ifndef NO_QRWA REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QRWA); #endif + REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(PULSE); // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(TESTEXA); diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h new file mode 100644 index 000000000..864195f55 --- /dev/null +++ b/src/contracts/Pulse.h @@ -0,0 +1,944 @@ +/** + * @file Pulse.h + * @brief Pulse lottery contract: 6 unique digits out of 9 with fixed QHeart rewards. + * + * Mechanics: + * - Tickets are sold during SELLING state (1 ticket per call). + * - Draw is triggered on scheduled days after drawHour (UTC). + * - Ticket revenue (QHeart asset) is split by configured percents; remainder stays in contract. + * - Fixed rewards are paid from the contract QHeart balance ("Pulse wallet"). + * - If contract balance exceeds cap, excess is sent to QHeart wallet. + */ + +using namespace QPI; + +constexpr uint16 PULSE_MAX_NUMBER_OF_PLAYERS = 1024; +constexpr uint8 PULSE_PLAYER_DIGITS = 6; +constexpr uint8 PULSE_WINNING_DIGITS = 9; +constexpr uint8 PULSE_MAX_DIGIT = 9; +constexpr uint64 PULSE_TICKET_PRICE_DEFAULT = 200000; +constexpr uint64 PULSE_QHEART_ASSET_NAME = 92712259110993ULL; // "QHEART" +constexpr uint8 PULSE_DEFAULT_DEV_PERCENT = 10; +constexpr uint8 PULSE_DEFAULT_BURN_PERCENT = 5; +constexpr uint8 PULSE_DEFAULT_SHAREHOLDERS_PERCENT = 5; +constexpr uint8 PULSE_DEFAULT_QHEART_PERCENT = 5; +constexpr uint64 PULSE_DEFAULT_QHEART_HOLD_LIMIT = 2000000000ULL; +constexpr uint8 PULSE_TICK_UPDATE_PERIOD = 100; +constexpr uint8 PULSE_DEFAULT_DRAW_HOUR = 11; // 11:00 UTC +constexpr uint8 PULSE_DEFAULT_SCHEDULE = 1 << WEDNESDAY | 1 << FRIDAY | 1 << SUNDAY; +constexpr uint32 PULSE_DEFAULT_INIT_TIME = 22 << 9 | 4 << 5 | 13; + +const id PULSE_QHEART_ISSUER = ID(_S, _S, _G, _X, _S, _L, _S, _X, _F, _E, _J, _O, _O, _B, _T, _Z, _W, _V, _D, _S, _R, _C, _E, _F, _G, _X, _N, _D, _Y, + _U, _V, _D, _X, _M, _Q, _A, _L, _X, _L, _B, _X, _G, _D, _C, _R, _X, _T, _K, _F, _Z, _I, _O, _T, _G, _Z, _F); +constexpr uint64 PULSE_CONTRACT_ASSET_NAME = 297750254928ULL; // "PULSE" + +struct PULSE2 +{ +}; + +struct PULSE : public ContractBase +{ +public: + enum class EState : uint8 + { + SELLING = 1 << 0, + }; + + friend EState operator|(const EState& a, const EState& b) { return static_cast(static_cast(a) | static_cast(b)); } + friend EState operator&(const EState& a, const EState& b) { return static_cast(static_cast(a) & static_cast(b)); } + friend EState operator~(const EState& a) { return static_cast(~static_cast(a)); } + template friend bool operator==(const EState& a, const T& b) { return static_cast(a) == b; } + template friend bool operator!=(const EState& a, const T& b) { return !(a == b); } + + enum class EReturnCode : uint8 + { + SUCCESS, + TICKET_INVALID_PRICE, + TICKET_ALL_SOLD_OUT, + TICKET_SELLING_CLOSED, + INVALID_NUMBERS, + ACCESS_DENIED, + INVALID_VALUE, + UNKNOWN_ERROR = UINT8_MAX + }; + + static constexpr uint8 toReturnCode(const EReturnCode& code) { return static_cast(code); }; + + struct Ticket + { + id player; + Array digits; + }; + + struct NextEpochData + { + void clear() + { + hasNewPrice = false; + hasNewSchedule = false; + hasNewDrawHour = false; + hasNewFee = false; + hasNewQHeartHoldLimit = false; + newPrice = 0; + newSchedule = 0; + newDrawHour = 0; + newDevPercent = 0; + newBurnPercent = 0; + newShareholdersPercent = 0; + newQHeartPercent = 0; + newQHeartHoldLimit = 0; + } + + void apply(PULSE& state) const + { + if (hasNewPrice) + { + state.ticketPrice = newPrice; + } + if (hasNewSchedule) + { + state.schedule = newSchedule; + } + if (hasNewDrawHour) + { + state.drawHour = newDrawHour; + } + if (hasNewFee) + { + state.devPercent = newDevPercent; + state.burnPercent = newBurnPercent; + state.shareholdersPercent = newShareholdersPercent; + state.qheartPercent = newQHeartPercent; + } + if (hasNewQHeartHoldLimit) + { + state.qheartHoldLimit = newQHeartHoldLimit; + } + } + + bit hasNewPrice; + bit hasNewSchedule; + bit hasNewDrawHour; + bit hasNewFee; + bit hasNewQHeartHoldLimit; + uint64 newPrice; + uint8 newSchedule; + uint8 newDrawHour; + uint8 newDevPercent; + uint8 newBurnPercent; + uint8 newShareholdersPercent; + uint8 newQHeartPercent; + uint64 newQHeartHoldLimit; + }; + + struct ValidateDigits_input + { + Array digits; + }; + struct ValidateDigits_output + { + bit isValid; + }; + struct ValidateDigits_locals + { + HashSet seen; + uint8 idx; + uint8 value; + }; + + struct BuyTicket_input + { + Array digits; + }; + + struct BuyTicket_output + { + uint8 returnCode; + }; + + struct BuyTicket_locals + { + uint64 reward; + uint64 capacity; + uint64 slotsLeft; + sint64 userBalance; + sint64 transferResult; + Ticket ticket; + ValidateDigits_input validateInput; + ValidateDigits_output validateOutput; + }; + + struct GetTicketPrice_input + { + }; + struct GetTicketPrice_output + { + uint64 ticketPrice; + }; + + struct GetSchedule_input + { + }; + struct GetSchedule_output + { + uint8 schedule; + }; + + struct GetDrawHour_input + { + }; + struct GetDrawHour_output + { + uint8 drawHour; + }; + + struct GetFees_input + { + }; + struct GetFees_output + { + uint8 devPercent; + uint8 burnPercent; + uint8 shareholdersPercent; + uint8 qheartPercent; + uint8 returnCode; + }; + + struct GetQHeartHoldLimit_input + { + }; + struct GetQHeartHoldLimit_output + { + uint64 qheartHoldLimit; + }; + + struct GetQHeartWallet_input + { + }; + struct GetQHeartWallet_output + { + id wallet; + }; + + struct GetWinningDigits_input + { + }; + struct GetWinningDigits_output + { + Array digits; + }; + + struct GetBalance_input + { + }; + struct GetBalance_output + { + uint64 balance; + }; + struct GetBalance_locals + { + sint64 balance; + }; + + struct SetPrice_input + { + uint64 newPrice; + }; + struct SetPrice_output + { + uint8 returnCode; + }; + + struct SetSchedule_input + { + uint8 newSchedule; + }; + struct SetSchedule_output + { + uint8 returnCode; + }; + + struct SetDrawHour_input + { + uint8 newDrawHour; + }; + struct SetDrawHour_output + { + uint8 returnCode; + }; + + struct SetFees_input + { + uint8 devPercent; + uint8 burnPercent; + uint8 shareholdersPercent; + uint8 qheartPercent; + }; + struct SetFees_output + { + uint8 returnCode; + }; + + struct SetQHeartHoldLimit_input + { + uint64 newQHeartHoldLimit; + }; + struct SetQHeartHoldLimit_output + { + uint8 returnCode; + }; + + struct SetQHeartWallet_input + { + id newWallet; + }; + struct SetQHeartWallet_output + { + uint8 returnCode; + }; + + struct GetRandomDigits_input + { + uint64 seed; + }; + struct GetRandomDigits_output + { + Array digits; + }; + struct GetRandomDigits_locals + { + uint64 tempValue; + uint8 index; + uint8 candidate; + uint8 attempts; + uint8 fallback; + HashSet used; + }; + + struct SettleRound_input + { + }; + struct SettleRound_output + { + }; + struct SettleRound_locals + { + uint64 i; + uint64 j; + sint64 roundRevenue; + sint64 devAmount; + sint64 burnAmount; + sint64 shareholdersAmount; + sint64 qheartAmount; + sint64 balanceSigned; + uint64 balance; + uint64 prize; + uint64 leftAlignedReward; + uint64 anyPositionReward; + uint8 leftAlignedMatches; + uint8 anyPositionMatches; + uint16 winningMask; + m256i mixedSpectrumValue; + uint64 randomSeed; + Asset qheartAsset; + AssetPossessionIterator qheartIter; + uint64 totalShares; + uint64 dividendPerShare; + uint64 holderShares; + Asset shareholdersAsset; + AssetPossessionIterator shareholdersIter; + sint64 shareholdersTotalShares; + sint64 shareholdersDividendPerShare; + sint64 shareholdersHolderShares; + GetRandomDigits_input randomInput; + GetRandomDigits_output randomOutput; + Ticket ticket; + }; + + struct BEGIN_TICK_locals + { + uint32 currentDateStamp; + uint8 currentDayOfWeek; + uint8 currentHour; + uint8 isWednesday; + uint8 isScheduledToday; + SettleRound_locals settleLocals; + SettleRound_input settleInput; + SettleRound_output settleOutput; + }; + +public: + REGISTER_USER_FUNCTIONS_AND_PROCEDURES() + { + REGISTER_USER_FUNCTION(GetTicketPrice, 1); + REGISTER_USER_FUNCTION(GetSchedule, 2); + REGISTER_USER_FUNCTION(GetDrawHour, 3); + REGISTER_USER_FUNCTION(GetFees, 4); + REGISTER_USER_FUNCTION(GetQHeartHoldLimit, 5); + REGISTER_USER_FUNCTION(GetQHeartWallet, 6); + REGISTER_USER_FUNCTION(GetWinningDigits, 7); + REGISTER_USER_FUNCTION(GetBalance, 8); + + REGISTER_USER_PROCEDURE(BuyTicket, 1); + REGISTER_USER_PROCEDURE(SetPrice, 2); + REGISTER_USER_PROCEDURE(SetSchedule, 3); + REGISTER_USER_PROCEDURE(SetDrawHour, 4); + REGISTER_USER_PROCEDURE(SetFees, 5); + REGISTER_USER_PROCEDURE(SetQHeartHoldLimit, 6); + } + + INITIALIZE() + { + state.teamAddress = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, + _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); + + state.ticketPrice = PULSE_TICKET_PRICE_DEFAULT; + state.devPercent = PULSE_DEFAULT_DEV_PERCENT; + state.burnPercent = PULSE_DEFAULT_BURN_PERCENT; + state.shareholdersPercent = PULSE_DEFAULT_SHAREHOLDERS_PERCENT; + state.qheartPercent = PULSE_DEFAULT_QHEART_PERCENT; + state.qheartHoldLimit = PULSE_DEFAULT_QHEART_HOLD_LIMIT; + + state.schedule = PULSE_DEFAULT_SCHEDULE; + state.drawHour = PULSE_DEFAULT_DRAW_HOUR; + state.lastDrawDateStamp = PULSE_DEFAULT_INIT_TIME; + state.ticketCounter = 0; + setMemory(state.tickets, 0); + setMemory(state.lastWinningDigits, 0); + state.nextEpochData.clear(); + + enableBuyTicket(state, false); + } + + BEGIN_EPOCH() + { + if (state.schedule == 0) + { + state.schedule = PULSE_DEFAULT_SCHEDULE; + } + if (state.drawHour == 0) + { + state.drawHour = PULSE_DEFAULT_DRAW_HOUR; + } + + makeDateStamp(qpi.year(), qpi.month(), qpi.day(), state.lastDrawDateStamp); + enableBuyTicket(state, state.lastDrawDateStamp != PULSE_DEFAULT_INIT_TIME); + } + + END_EPOCH() + { + enableBuyTicket(state, false); + clearStateOnEndEpoch(state); + state.nextEpochData.apply(state); + state.nextEpochData.clear(); + } + + BEGIN_TICK_WITH_LOCALS() + { + if (mod(qpi.tick(), static_cast(PULSE_TICK_UPDATE_PERIOD)) != 0) + { + return; + } + + locals.currentHour = qpi.hour(); + locals.currentDayOfWeek = qpi.dayOfWeek(qpi.year(), qpi.month(), qpi.day()); + locals.isWednesday = locals.currentDayOfWeek == WEDNESDAY; + + if (locals.currentHour < state.drawHour) + { + return; + } + + makeDateStamp(qpi.year(), qpi.month(), qpi.day(), locals.currentDateStamp); + + if (locals.currentDateStamp == PULSE_DEFAULT_INIT_TIME) + { + enableBuyTicket(state, false); + state.lastDrawDateStamp = PULSE_DEFAULT_INIT_TIME; + return; + } + + if (state.lastDrawDateStamp == PULSE_DEFAULT_INIT_TIME) + { + enableBuyTicket(state, true); + if (locals.isWednesday) + { + state.lastDrawDateStamp = locals.currentDateStamp; + } + else + { + state.lastDrawDateStamp = 0; + } + } + + if (state.lastDrawDateStamp == locals.currentDateStamp) + { + return; + } + + locals.isScheduledToday = ((state.schedule & (1u << locals.currentDayOfWeek)) != 0); + if (!locals.isWednesday && !locals.isScheduledToday) + { + return; + } + + state.lastDrawDateStamp = locals.currentDateStamp; + enableBuyTicket(state, false); + + SettleRound(qpi, state, locals.settleInput, locals.settleOutput, locals.settleLocals); + + clearStateOnEndDraw(state); + enableBuyTicket(state, !locals.isWednesday); + } + + POST_INCOMING_TRANSFER() + { + switch (input.type) + { + case TransferType::standardTransaction: + if (input.amount > 0) + { + qpi.transfer(input.sourceId, input.amount); + } + default: break; + } + } + + PUBLIC_FUNCTION(GetTicketPrice) { output.ticketPrice = state.ticketPrice; } + PUBLIC_FUNCTION(GetSchedule) { output.schedule = state.schedule; } + PUBLIC_FUNCTION(GetDrawHour) { output.drawHour = state.drawHour; } + PUBLIC_FUNCTION(GetQHeartHoldLimit) { output.qheartHoldLimit = state.qheartHoldLimit; } + PUBLIC_FUNCTION(GetQHeartWallet) { output.wallet = PULSE_QHEART_ISSUER; } + PUBLIC_FUNCTION(GetWinningDigits) { output.digits = state.lastWinningDigits; } + + PUBLIC_FUNCTION(GetFees) + { + output.devPercent = state.devPercent; + output.burnPercent = state.burnPercent; + output.shareholdersPercent = state.shareholdersPercent; + output.qheartPercent = state.qheartPercent; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + PUBLIC_FUNCTION_WITH_LOCALS(GetBalance) + { + locals.balance = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, SELF_INDEX, SELF_INDEX); + output.balance = (locals.balance > 0) ? static_cast(locals.balance) : 0; + } + + PUBLIC_PROCEDURE(SetPrice) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (qpi.invocator() != PULSE_QHEART_ISSUER) + { + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); + return; + } + + if (input.newPrice == 0) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); + return; + } + + state.nextEpochData.hasNewPrice = true; + state.nextEpochData.newPrice = input.newPrice; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + PUBLIC_PROCEDURE(SetSchedule) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (qpi.invocator() != PULSE_QHEART_ISSUER) + { + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); + return; + } + + if (input.newSchedule == 0) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + state.nextEpochData.hasNewSchedule = true; + state.nextEpochData.newSchedule = input.newSchedule; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + PUBLIC_PROCEDURE(SetDrawHour) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (qpi.invocator() != PULSE_QHEART_ISSUER) + { + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); + return; + } + + if (input.newDrawHour > 23) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + state.nextEpochData.hasNewDrawHour = true; + state.nextEpochData.newDrawHour = input.newDrawHour; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + PUBLIC_PROCEDURE(SetFees) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (qpi.invocator() != PULSE_QHEART_ISSUER) + { + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); + return; + } + + if (input.devPercent + input.burnPercent + input.shareholdersPercent + input.qheartPercent > 100) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + state.nextEpochData.hasNewFee = true; + state.nextEpochData.newDevPercent = input.devPercent; + state.nextEpochData.newBurnPercent = input.burnPercent; + state.nextEpochData.newShareholdersPercent = input.shareholdersPercent; + state.nextEpochData.newQHeartPercent = input.qheartPercent; + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + PUBLIC_PROCEDURE(SetQHeartHoldLimit) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (qpi.invocator() != PULSE_QHEART_ISSUER) + { + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); + return; + } + + state.nextEpochData.hasNewQHeartHoldLimit = true; + state.nextEpochData.newQHeartHoldLimit = input.newQHeartHoldLimit; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicket) + { + locals.reward = qpi.invocationReward(); + if (locals.reward > 0) + { + qpi.transfer(qpi.invocator(), locals.reward); + } + + if (!isSellingOpen(state)) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_SELLING_CLOSED); + return; + } + + locals.validateInput.digits = input.digits; + CALL(ValidateDigits, locals.validateInput, locals.validateOutput); + if (!locals.validateOutput.isValid) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_NUMBERS); + return; + } + + locals.capacity = state.tickets.capacity(); + locals.slotsLeft = (state.ticketCounter < locals.capacity) ? (locals.capacity - state.ticketCounter) : 0; + if (locals.slotsLeft == 0) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_ALL_SOLD_OUT); + return; + } + + locals.userBalance = + qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, qpi.invocator(), qpi.invocator(), SELF_INDEX, SELF_INDEX); + if (locals.userBalance < static_cast(state.ticketPrice)) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); + return; + } + + locals.transferResult = qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, qpi.invocator(), + qpi.invocator(), static_cast(state.ticketPrice), SELF); + if (locals.transferResult < 0) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); + return; + } + + locals.ticket.player = qpi.invocator(); + locals.ticket.digits = input.digits; + state.tickets.set(state.ticketCounter, locals.ticket); + state.ticketCounter = min(state.ticketCounter + 1, locals.capacity); + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + +private: + PRIVATE_FUNCTION_WITH_LOCALS(ValidateDigits) + { + output.isValid = true; + for (locals.idx = 0; locals.idx < PULSE_PLAYER_DIGITS; ++locals.idx) + { + locals.value = input.digits.get(locals.idx); + if (locals.value > PULSE_MAX_DIGIT) + { + output.isValid = false; + return; + } + if (locals.seen.contains(locals.value)) + { + output.isValid = false; + return; + } + locals.seen.add(locals.value); + } + } + + PRIVATE_FUNCTION_WITH_LOCALS(GetRandomDigits) + { + for (locals.index = 0; locals.index < PULSE_WINNING_DIGITS; ++locals.index) + { + deriveOne(input.seed, locals.index, locals.tempValue); + locals.candidate = static_cast(mod(locals.tempValue, static_cast(PULSE_MAX_DIGIT + 1))); + + locals.attempts = 0; + while (locals.used.contains(locals.candidate) && locals.attempts < 100) + { + ++locals.attempts; + locals.tempValue ^= locals.tempValue >> 12; + locals.tempValue ^= locals.tempValue << 25; + locals.tempValue ^= locals.tempValue >> 27; + locals.tempValue *= 2685821657736338717ULL; + locals.candidate = static_cast(mod(locals.tempValue, static_cast(PULSE_MAX_DIGIT + 1))); + } + + if (locals.used.contains(locals.candidate)) + { + for (locals.fallback = 0; locals.fallback <= PULSE_MAX_DIGIT; ++locals.fallback) + { + if (!locals.used.contains(locals.fallback)) + { + locals.candidate = locals.fallback; + break; + } + } + } + + locals.used.add(locals.candidate); + output.digits.set(locals.index, locals.candidate); + } + } + + PRIVATE_PROCEDURE_WITH_LOCALS(SettleRound) + { + if (state.ticketCounter == 0) + { + return; + } + + locals.roundRevenue = static_cast(smul(state.ticketPrice, state.ticketCounter)); + locals.devAmount = div(smul(locals.roundRevenue, static_cast(state.devPercent)), 100LL); + locals.burnAmount = div(smul(locals.roundRevenue, static_cast(state.burnPercent)), 100LL); + locals.shareholdersAmount = div(smul(locals.roundRevenue, static_cast(state.shareholdersPercent)), 100LL); + locals.qheartAmount = div(smul(locals.roundRevenue, static_cast(state.qheartPercent)), 100LL); + + if (locals.devAmount > 0) + { + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, static_cast(locals.devAmount), + state.teamAddress); + } + if (locals.shareholdersAmount > 0) + { + locals.shareholdersAsset.issuer = id::zero(); + locals.shareholdersAsset.assetName = PULSE_CONTRACT_ASSET_NAME; + locals.shareholdersTotalShares = NUMBER_OF_COMPUTORS; + + locals.shareholdersDividendPerShare = div(locals.shareholdersAmount, locals.shareholdersTotalShares); + if (locals.shareholdersDividendPerShare > 0) + { + locals.shareholdersIter.begin(locals.shareholdersAsset); + while (!locals.shareholdersIter.reachedEnd()) + { + locals.shareholdersHolderShares = locals.shareholdersIter.numberOfPossessedShares(); + if (locals.shareholdersHolderShares > 0) + { + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, + smul(locals.shareholdersHolderShares, locals.shareholdersDividendPerShare), + locals.shareholdersIter.possessor()); + } + locals.shareholdersIter.next(); + } + } + } + if (locals.burnAmount > 0) + { + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, static_cast(locals.burnAmount), + NULL_ID); + } + if (locals.qheartAmount > 0) + { + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, locals.qheartAmount, + PULSE_QHEART_ISSUER); + } + + locals.mixedSpectrumValue = qpi.getPrevSpectrumDigest(); + locals.randomSeed = qpi.K12(locals.mixedSpectrumValue).u64._0; + locals.randomInput.seed = locals.randomSeed; + CALL(GetRandomDigits, locals.randomInput, locals.randomOutput); + state.lastWinningDigits = locals.randomOutput.digits; + + for (locals.i = 0; locals.i < PULSE_WINNING_DIGITS; ++locals.i) + { + locals.winningMask = static_cast(locals.winningMask | (1u << state.lastWinningDigits.get(locals.i))); + } + + locals.balanceSigned = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, SELF_INDEX, SELF_INDEX); + locals.balance = (locals.balanceSigned > 0) ? static_cast(locals.balanceSigned) : 0; + + for (locals.i = 0; locals.i < state.ticketCounter; ++locals.i) + { + locals.leftAlignedMatches = 0; + locals.anyPositionMatches = 0; + locals.ticket = state.tickets.get(locals.i); + for (locals.j = 0; locals.j < PULSE_PLAYER_DIGITS; ++locals.j) + { + if (locals.ticket.digits.get(locals.j) == state.lastWinningDigits.get(locals.j)) + { + ++locals.leftAlignedMatches; + } + if ((locals.winningMask & (1u << locals.ticket.digits.get(locals.j))) != 0) + { + ++locals.anyPositionMatches; + } + } + + locals.leftAlignedReward = getLeftAlignedReward(locals.leftAlignedMatches); + locals.anyPositionReward = getAnyPositionReward(locals.anyPositionMatches); + locals.prize = max(locals.leftAlignedReward, locals.anyPositionReward); + + if (locals.prize > 0 && locals.balance >= locals.prize) + { + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, static_cast(locals.prize), + locals.ticket.player); + locals.balance -= locals.prize; + } + } + + locals.balanceSigned = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, SELF_INDEX, SELF_INDEX); + locals.balance = (locals.balanceSigned > 0) ? static_cast(locals.balanceSigned) : 0; + + if (state.qheartHoldLimit > 0 && locals.balance > state.qheartHoldLimit) + { + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, + static_cast(locals.balance - state.qheartHoldLimit), PULSE_QHEART_ISSUER); + } + } + +public: + static void makeDateStamp(uint8 year, uint8 month, uint8 day, uint32& res) { res = static_cast(year << 9 | month << 5 | day); } + + template static constexpr T min(const T& a, const T& b) { return (a < b) ? a : b; } + template static constexpr T max(const T& a, const T& b) { return a > b ? a : b; } + + static void deriveOne(const uint64& r, const uint64& idx, uint64& outValue) { mix64(r + 0x9e3779b97f4a7c15ULL * (idx + 1), outValue); } + + static void mix64(const uint64& x, uint64& outValue) + { + outValue = x; + outValue ^= outValue >> 30; + outValue *= 0xbf58476d1ce4e5b9ULL; + outValue ^= outValue >> 27; + outValue *= 0x94d049bb133111ebULL; + outValue ^= outValue >> 31; + } + +protected: + Array tickets; + Array lastWinningDigits; + uint64 ticketCounter; + uint64 ticketPrice; + uint64 qheartHoldLimit; + uint32 lastDrawDateStamp; + uint8 devPercent; + uint8 burnPercent; + uint8 shareholdersPercent; + uint8 qheartPercent; + uint8 schedule; + uint8 drawHour; + EState currentState; + id teamAddress; + NextEpochData nextEpochData; + +protected: + static void clearStateOnEndEpoch(PULSE& state) + { + clearStateOnEndDraw(state); + state.lastDrawDateStamp = 0; + } + + static void clearStateOnEndDraw(PULSE& state) + { + state.ticketCounter = 0; + setMemory(state.tickets, 0); + } + + static void enableBuyTicket(PULSE& state, bool bEnable) + { + state.currentState = bEnable ? state.currentState | EState::SELLING : state.currentState & ~EState::SELLING; + } + + static bool isSellingOpen(const PULSE& state) { return (state.currentState & EState::SELLING) != 0; } + + static uint64 getLeftAlignedReward(uint8 matches) + { + switch (matches) + { + case 6: return 2400; + case 5: return 600; + case 4: return 150; + case 3: return 30; + case 2: return 8; + case 1: return 1; + default: return 0; + } + } + + static uint64 getAnyPositionReward(uint8 matches) + { + switch (matches) + { + case 6: return 0; + case 5: return 400; + case 4: return 50; + case 3: return 8; + case 2: return 2; + case 1: return 0; + default: return 0; + } + } +}; From 5d6efe411b8237287738eb3f68f6b37bd62d0685 Mon Sep 17 00:00:00 2001 From: N-010 Date: Mon, 12 Jan 2026 18:37:36 +0300 Subject: [PATCH 25/77] Tests --- src/contracts/Pulse.h | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 864195f55..769f9c7ea 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -336,7 +336,9 @@ struct PULSE : public ContractBase uint64 leftAlignedReward; uint64 anyPositionReward; uint8 leftAlignedMatches; + uint8 leftAlignedMatchesAtOffset; uint8 anyPositionMatches; + uint8 leftAlignedOffset; uint16 winningMask; m256i mixedSpectrumValue; uint64 randomSeed; @@ -825,12 +827,23 @@ struct PULSE : public ContractBase locals.leftAlignedMatches = 0; locals.anyPositionMatches = 0; locals.ticket = state.tickets.get(locals.i); - for (locals.j = 0; locals.j < PULSE_PLAYER_DIGITS; ++locals.j) + for (locals.leftAlignedOffset = 0; locals.leftAlignedOffset + PULSE_PLAYER_DIGITS <= PULSE_WINNING_DIGITS; ++locals.leftAlignedOffset) { - if (locals.ticket.digits.get(locals.j) == state.lastWinningDigits.get(locals.j)) + locals.leftAlignedMatchesAtOffset = 0; + for (locals.j = 0; locals.j < PULSE_PLAYER_DIGITS; ++locals.j) + { + if (locals.ticket.digits.get(locals.j) == state.lastWinningDigits.get(locals.leftAlignedOffset + locals.j)) + { + ++locals.leftAlignedMatchesAtOffset; + } + } + if (locals.leftAlignedMatchesAtOffset > locals.leftAlignedMatches) { - ++locals.leftAlignedMatches; + locals.leftAlignedMatches = locals.leftAlignedMatchesAtOffset; } + } + for (locals.j = 0; locals.j < PULSE_PLAYER_DIGITS; ++locals.j) + { if ((locals.winningMask & (1u << locals.ticket.digits.get(locals.j))) != 0) { ++locals.anyPositionMatches; From 2c86c4a008016028bb7d65b94847c5298e2461b9 Mon Sep 17 00:00:00 2001 From: N-010 Date: Mon, 12 Jan 2026 18:38:05 +0300 Subject: [PATCH 26/77] Tests --- test/contract_pulse.cpp | 818 ++++++++++++++++++++++++++++++++++++++ test/test.vcxproj | 3 +- test/test.vcxproj.filters | 1 + 3 files changed, 821 insertions(+), 1 deletion(-) create mode 100644 test/contract_pulse.cpp diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp new file mode 100644 index 000000000..c67695dd1 --- /dev/null +++ b/test/contract_pulse.cpp @@ -0,0 +1,818 @@ +#define NO_UEFI +#define _ALLOW_KEYWORD_MACROS 1 +#define private protected +#include "contract_testing.h" +#undef private +#undef _ALLOW_KEYWORD_MACROS + +#include +#include + +// Procedure/function indices (must match REGISTER_USER_FUNCTIONS_AND_PROCEDURES in `src/contracts/Pulse.h`). +constexpr uint16 PULSE_PROCEDURE_BUY_TICKET = 1; +constexpr uint16 PULSE_PROCEDURE_SET_PRICE = 2; +constexpr uint16 PULSE_PROCEDURE_SET_SCHEDULE = 3; +constexpr uint16 PULSE_PROCEDURE_SET_DRAW_HOUR = 4; +constexpr uint16 PULSE_PROCEDURE_SET_FEES = 5; +constexpr uint16 PULSE_PROCEDURE_SET_QHEART_HOLD_LIMIT = 6; + +constexpr uint16 PULSE_FUNCTION_GET_TICKET_PRICE = 1; +constexpr uint16 PULSE_FUNCTION_GET_SCHEDULE = 2; +constexpr uint16 PULSE_FUNCTION_GET_DRAW_HOUR = 3; +constexpr uint16 PULSE_FUNCTION_GET_FEES = 4; +constexpr uint16 PULSE_FUNCTION_GET_QHEART_HOLD_LIMIT = 5; +constexpr uint16 PULSE_FUNCTION_GET_QHEART_WALLET = 6; +constexpr uint16 PULSE_FUNCTION_GET_WINNING_DIGITS = 7; +constexpr uint16 PULSE_FUNCTION_GET_BALANCE = 8; + +namespace +{ + static void primeQpiFunctionContext(QpiContextUserFunctionCall& qpi) + { + PULSE::GetTicketPrice_input input{}; + qpi.call(PULSE_FUNCTION_GET_TICKET_PRICE, &input, sizeof(input)); + } + + static void primeQpiProcedureContext(QpiContextUserProcedureCall& qpi) + { + PULSE::SetDrawHour_input input{}; + input.newDrawHour = PULSE_DEFAULT_DRAW_HOUR; + qpi.call(PULSE_PROCEDURE_SET_DRAW_HOUR, &input, sizeof(input)); + ASSERT_EQ(contractError[PULSE_CONTRACT_INDEX], 0); + } + + static Array makePlayerDigits( + uint8 d0, uint8 d1, uint8 d2, uint8 d3, uint8 d4, uint8 d5) + { + Array digits; + digits.set(0, d0); + digits.set(1, d1); + digits.set(2, d2); + digits.set(3, d3); + digits.set(4, d4); + digits.set(5, d5); + return digits; + } + + static void expectWinningDigitsUniqueAndInRange(const Array& digits) + { + std::set seen; + for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + const uint8 v = digits.get(i); + EXPECT_LE(v, PULSE_MAX_DIGIT); + seen.insert(v); + } + EXPECT_EQ(seen.size(), static_cast(PULSE_WINNING_DIGITS)); + } +} + +// Test helper class exposing internal state +class PULSEChecker : public PULSE +{ +public: + uint64 getTicketCounter() const { return ticketCounter; } + uint64 getTicketPriceInternal() const { return ticketPrice; } + uint64 getQHeartHoldLimitInternal() const { return qheartHoldLimit; } + uint32 getLastDrawDateStamp() const { return lastDrawDateStamp; } + uint8 getScheduleInternal() const { return schedule; } + uint8 getDrawHourInternal() const { return drawHour; } + uint8 getDevPercentInternal() const { return devPercent; } + uint8 getBurnPercentInternal() const { return burnPercent; } + uint8 getShareholdersPercentInternal() const { return shareholdersPercent; } + uint8 getQHeartPercentInternal() const { return qheartPercent; } + const Array& getLastWinningDigits() const { return lastWinningDigits; } + + void setTicketCounter(uint64 value) { ticketCounter = value; } + void setTicketPriceInternal(uint64 value) { ticketPrice = value; } + void setQHeartHoldLimitInternal(uint64 value) { qheartHoldLimit = value; } + void setLastDrawDateStamp(uint32 value) { lastDrawDateStamp = value; } + void setScheduleInternal(uint8 value) { schedule = value; } + void setDrawHourInternal(uint8 value) { drawHour = value; } + void setLastWinningDigits(const Array& digits) { lastWinningDigits = digits; } + + NextEpochData& nextEpochDataRef() { return nextEpochData; } + + void setTicketDirect(uint64 index, const id& player, const Array& digits) + { + Ticket ticket; + ticket.player = player; + ticket.digits = digits; + tickets.set(index, ticket); + } + + void forceSelling(bool enable) { enableBuyTicket(*this, enable); } + bool isSelling() const { return isSellingOpen(*this); } + + ValidateDigits_output callValidateDigits( + const QPI::QpiContextFunctionCall& qpi, + const Array& digits) const + { + ValidateDigits_input input{}; + ValidateDigits_output output{}; + ValidateDigits_locals locals{}; + input.digits = digits; + ValidateDigits(qpi, *this, input, output, locals); + return output; + } + + GetRandomDigits_output callGetRandomDigits(const QPI::QpiContextFunctionCall& qpi, uint64 seed) const + { + GetRandomDigits_input input{}; + GetRandomDigits_output output{}; + GetRandomDigits_locals locals{}; + input.seed = seed; + GetRandomDigits(qpi, *this, input, output, locals); + return output; + } + + void callSettleRound(const QPI::QpiContextProcedureCall& qpi) + { + SettleRound_input input{}; + SettleRound_output output{}; + SettleRound_locals locals{}; + SettleRound(qpi, *this, input, output, locals); + } + + void callClearStateOnEndDraw() { clearStateOnEndDraw(*this); } + void callClearStateOnEndEpoch() { clearStateOnEndEpoch(*this); } + uint64 callGetLeftAlignedReward(uint8 matches) const { return getLeftAlignedReward(matches); } + uint64 callGetAnyPositionReward(uint8 matches) const { return getAnyPositionReward(matches); } +}; + +class ContractTestingPulse : protected ContractTesting +{ +public: + ContractTestingPulse() + { + initEmptySpectrum(); + initEmptyUniverse(); + INIT_CONTRACT(PULSE); + system.epoch = contractDescriptions[PULSE_CONTRACT_INDEX].constructionEpoch; + callSystemProcedure(PULSE_CONTRACT_INDEX, INITIALIZE); + } + + PULSEChecker* state() { return reinterpret_cast(contractStates[PULSE_CONTRACT_INDEX]); } + id pulseSelf() const { return id(PULSE_CONTRACT_INDEX, 0, 0, 0); } + + PULSE::GetTicketPrice_output getTicketPrice() + { + PULSE::GetTicketPrice_input input{}; + PULSE::GetTicketPrice_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_TICKET_PRICE, input, output); + return output; + } + + PULSE::GetSchedule_output getSchedule() + { + PULSE::GetSchedule_input input{}; + PULSE::GetSchedule_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_SCHEDULE, input, output); + return output; + } + + PULSE::GetDrawHour_output getDrawHour() + { + PULSE::GetDrawHour_input input{}; + PULSE::GetDrawHour_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_DRAW_HOUR, input, output); + return output; + } + + PULSE::GetFees_output getFees() + { + PULSE::GetFees_input input{}; + PULSE::GetFees_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_FEES, input, output); + return output; + } + + PULSE::GetQHeartHoldLimit_output getQHeartHoldLimit() + { + PULSE::GetQHeartHoldLimit_input input{}; + PULSE::GetQHeartHoldLimit_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_QHEART_HOLD_LIMIT, input, output); + return output; + } + + PULSE::GetQHeartWallet_output getQHeartWallet() + { + PULSE::GetQHeartWallet_input input{}; + PULSE::GetQHeartWallet_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_QHEART_WALLET, input, output); + return output; + } + + PULSE::GetWinningDigits_output getWinningDigits() + { + PULSE::GetWinningDigits_input input{}; + PULSE::GetWinningDigits_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_WINNING_DIGITS, input, output); + return output; + } + + PULSE::GetBalance_output getBalance() + { + PULSE::GetBalance_input input{}; + PULSE::GetBalance_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_BALANCE, input, output); + return output; + } + + PULSE::BuyTicket_output buyTicket(const id& user, const Array& digits) + { + PULSE::BuyTicket_input input{}; + input.digits = digits; + PULSE::BuyTicket_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_BUY_TICKET, input, output, user, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::SetPrice_output setPrice(const id& invocator, uint64 newPrice) + { + PULSE::SetPrice_input input{}; + input.newPrice = newPrice; + PULSE::SetPrice_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_PRICE, input, output, invocator, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::SetSchedule_output setSchedule(const id& invocator, uint8 newSchedule) + { + PULSE::SetSchedule_input input{}; + input.newSchedule = newSchedule; + PULSE::SetSchedule_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_SCHEDULE, input, output, invocator, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::SetDrawHour_output setDrawHour(const id& invocator, uint8 newDrawHour) + { + PULSE::SetDrawHour_input input{}; + input.newDrawHour = newDrawHour; + PULSE::SetDrawHour_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_DRAW_HOUR, input, output, invocator, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::SetFees_output setFees(const id& invocator, uint8 dev, uint8 burn, uint8 shareholders, uint8 qheart) + { + PULSE::SetFees_input input{}; + input.devPercent = dev; + input.burnPercent = burn; + input.shareholdersPercent = shareholders; + input.qheartPercent = qheart; + PULSE::SetFees_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_FEES, input, output, invocator, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::SetQHeartHoldLimit_output setQHeartHoldLimit(const id& invocator, uint64 newLimit) + { + PULSE::SetQHeartHoldLimit_input input{}; + input.newQHeartHoldLimit = newLimit; + PULSE::SetQHeartHoldLimit_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_QHEART_HOLD_LIMIT, input, output, invocator, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + void beginEpoch() { callSystemProcedure(PULSE_CONTRACT_INDEX, BEGIN_EPOCH); } + void endEpoch() { callSystemProcedure(PULSE_CONTRACT_INDEX, END_EPOCH); } + void beginTick() { callSystemProcedure(PULSE_CONTRACT_INDEX, BEGIN_TICK); } + + void setDateTime(uint16 year, uint8 month, uint8 day, uint8 hour) + { + updateTime(); + utcTime.Year = year; + utcTime.Month = month; + utcTime.Day = day; + utcTime.Hour = hour; + utcTime.Minute = 0; + utcTime.Second = 0; + utcTime.Nanosecond = 0; + updateQpiTime(); + } + + void forceBeginTick() + { + system.tick = system.tick + (PULSE_TICK_UPDATE_PERIOD - (system.tick % PULSE_TICK_UPDATE_PERIOD)); + beginTick(); + } + + struct QHeartIssuance + { + int issuanceIndex; + int ownershipIndex; + int possessionIndex; + }; + + QHeartIssuance issueQHeart(sint64 totalShares) + { + char name[7] = { 'Q', 'H', 'E', 'A', 'R', 'T', 0 }; + char unit[7] = {}; + QHeartIssuance info{}; + const long long issued = issueAsset(PULSE_QHEART_ISSUER, name, 0, unit, totalShares, + PULSE_CONTRACT_INDEX, &info.issuanceIndex, &info.ownershipIndex, &info.possessionIndex); + EXPECT_EQ(issued, totalShares); + return info; + } + + void transferQHeart(const QHeartIssuance& issuance, const id& dest, sint64 amount) + { + int destOwnershipIndex = 0; + int destPossessionIndex = 0; + EXPECT_TRUE(transferShareOwnershipAndPossession( + issuance.ownershipIndex, issuance.possessionIndex, dest, amount, + &destOwnershipIndex, &destPossessionIndex, true)); + } + + void issuePulseSharesTo(const id& holder, unsigned int shares) + { + std::vector> initialShares; + initialShares.emplace_back(holder, shares); + issueContractShares(PULSE_CONTRACT_INDEX, initialShares, false); + } + + uint64 qheartBalanceOf(const id& owner) const + { + const long long balance = numberOfPossessedShares( + PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, owner, owner, + PULSE_CONTRACT_INDEX, PULSE_CONTRACT_INDEX); + return (balance > 0) ? static_cast(balance) : 0; + } +}; + +// ============================================================================ +// STATIC + PRIVATE METHOD TESTS +// ============================================================================ + +TEST(ContractPulse_Static, MakeDateStampMinMaxAndMixingAreDeterministic) +{ + uint32 stamp = 0; + PULSE::makeDateStamp(25, 1, 10, stamp); + EXPECT_EQ(stamp, static_cast(25 << 9 | 1 << 5 | 10)); + EXPECT_EQ(PULSE::min(3, 5), 3u); + EXPECT_EQ(PULSE::max(3, 5), 5u); + + uint64 mixed1 = 0; + uint64 mixed2 = 0; + PULSE::mix64(0x12345678ULL, mixed1); + PULSE::mix64(0x12345678ULL, mixed2); + EXPECT_EQ(mixed1, mixed2); + + uint64 d1 = 0; + uint64 d2 = 0; + PULSE::deriveOne(0xABCDEFULL, 0, d1); + PULSE::deriveOne(0xABCDEFULL, 1, d2); + EXPECT_NE(d1, d2); +} + +TEST(ContractPulse_Static, SellingFlagToggles) +{ + ContractTestingPulse ctl; + ctl.state()->forceSelling(true); + EXPECT_TRUE(ctl.state()->isSelling()); + ctl.state()->forceSelling(false); + EXPECT_FALSE(ctl.state()->isSelling()); +} + +TEST(ContractPulse_Static, RewardTablesMatchContractConstants) +{ + ContractTestingPulse ctl; + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(6), 2400u); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(5), 600u); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(4), 150u); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(3), 30u); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(2), 8u); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(1), 1u); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(0), 0u); + + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(6), 0u); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(5), 400u); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(4), 50u); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(3), 8u); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(2), 2u); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(1), 0u); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(0), 0u); +} + +TEST(ContractPulse_Private, NextEpochDataClearResetsFlagsAndValues) +{ + PULSE::NextEpochData data{}; + data.hasNewPrice = true; + data.hasNewSchedule = true; + data.hasNewDrawHour = true; + data.hasNewFee = true; + data.hasNewQHeartHoldLimit = true; + data.newPrice = 10; + data.newSchedule = 1; + data.newDrawHour = 2; + data.newDevPercent = 3; + data.newBurnPercent = 4; + data.newShareholdersPercent = 5; + data.newQHeartPercent = 6; + data.newQHeartHoldLimit = 7; + + data.clear(); + EXPECT_FALSE(data.hasNewPrice); + EXPECT_FALSE(data.hasNewSchedule); + EXPECT_FALSE(data.hasNewDrawHour); + EXPECT_FALSE(data.hasNewFee); + EXPECT_FALSE(data.hasNewQHeartHoldLimit); + EXPECT_EQ(data.newPrice, 0u); + EXPECT_EQ(data.newSchedule, 0u); + EXPECT_EQ(data.newDrawHour, 0u); + EXPECT_EQ(data.newDevPercent, 0u); + EXPECT_EQ(data.newBurnPercent, 0u); + EXPECT_EQ(data.newShareholdersPercent, 0u); + EXPECT_EQ(data.newQHeartPercent, 0u); + EXPECT_EQ(data.newQHeartHoldLimit, 0u); +} + +TEST(ContractPulse_Private, NextEpochDataApplyUpdatesState) +{ + ContractTestingPulse ctl; + PULSE::NextEpochData data{}; + data.hasNewPrice = true; + data.hasNewSchedule = true; + data.hasNewDrawHour = true; + data.hasNewFee = true; + data.hasNewQHeartHoldLimit = true; + data.newPrice = 123; + data.newSchedule = 0xAA; + data.newDrawHour = 7; + data.newDevPercent = 11; + data.newBurnPercent = 22; + data.newShareholdersPercent = 33; + data.newQHeartPercent = 4; + data.newQHeartHoldLimit = 999; + + data.apply(*ctl.state()); + EXPECT_EQ(ctl.state()->getTicketPriceInternal(), 123u); + EXPECT_EQ(ctl.state()->getScheduleInternal(), 0xAA); + EXPECT_EQ(ctl.state()->getDrawHourInternal(), 7u); + EXPECT_EQ(ctl.state()->getDevPercentInternal(), 11u); + EXPECT_EQ(ctl.state()->getBurnPercentInternal(), 22u); + EXPECT_EQ(ctl.state()->getShareholdersPercentInternal(), 33u); + EXPECT_EQ(ctl.state()->getQHeartPercentInternal(), 4u); + EXPECT_EQ(ctl.state()->getQHeartHoldLimitInternal(), 999u); +} + +TEST(ContractPulse_Private, ValidateDigitsRejectsDuplicateAndOutOfRange) +{ + ContractTestingPulse ctl; + QpiContextUserFunctionCall qpi(PULSE_CONTRACT_INDEX); + primeQpiFunctionContext(qpi); + + const auto ok = makePlayerDigits(0, 1, 2, 3, 4, 5); + EXPECT_TRUE(ctl.state()->callValidateDigits(qpi, ok).isValid); + + const auto dup = makePlayerDigits(0, 1, 2, 3, 4, 4); + EXPECT_FALSE(ctl.state()->callValidateDigits(qpi, dup).isValid); + + const auto outOfRange = makePlayerDigits(0, 1, 2, 3, 4, 10); + EXPECT_FALSE(ctl.state()->callValidateDigits(qpi, outOfRange).isValid); +} + +TEST(ContractPulse_Private, GetRandomDigitsDeterministicAndUnique) +{ + ContractTestingPulse ctl; + QpiContextUserFunctionCall qpi(PULSE_CONTRACT_INDEX); + primeQpiFunctionContext(qpi); + + const uint64 seed = 0x123456789ABCDEF0ULL; + const auto out1 = ctl.state()->callGetRandomDigits(qpi, seed); + const auto out2 = ctl.state()->callGetRandomDigits(qpi, seed); + + std::set seen; + for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + const uint8 v1 = out1.digits.get(i); + const uint8 v2 = out2.digits.get(i); + EXPECT_EQ(v1, v2); + EXPECT_LE(v1, PULSE_MAX_DIGIT); + seen.insert(v1); + } + EXPECT_EQ(seen.size(), static_cast(PULSE_WINNING_DIGITS)); +} + +TEST(ContractPulse_Private, ClearStateHelpersResetTicketData) +{ + ContractTestingPulse ctl; + ctl.state()->setTicketCounter(2); + ctl.state()->setLastDrawDateStamp(42); + ctl.state()->callClearStateOnEndDraw(); + EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); + ctl.state()->setLastDrawDateStamp(99); + ctl.state()->callClearStateOnEndEpoch(); + EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); + EXPECT_EQ(ctl.state()->getLastDrawDateStamp(), 0u); +} + +TEST(ContractPulse_Private, SettleRoundUpdatesWinningDigitsAndPaysPrize) +{ + ContractTestingPulse ctl; + const auto issuance = ctl.issueQHeart(1000000); + ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); + + const m256i digest = m256i::randomValue(); + etalonTick.prevSpectrumDigest = digest; + + m256i hashResult; + KangarooTwelve((const uint8*)&digest, sizeof(m256i), (uint8*)&hashResult, sizeof(m256i)); + const uint64 seed = hashResult.m256i_u64[0]; + + QpiContextUserFunctionCall qpiFunc(PULSE_CONTRACT_INDEX); + primeQpiFunctionContext(qpiFunc); + const auto winning = ctl.state()->callGetRandomDigits(qpiFunc, seed).digits; + + const id player = id::randomValue(); + const auto ticketDigits = makePlayerDigits( + winning.get(0), winning.get(1), winning.get(2), + winning.get(3), winning.get(4), winning.get(5)); + + ctl.state()->setTicketDirect(0, player, ticketDigits); + ctl.state()->setTicketCounter(1); + ctl.transferQHeart(issuance, ctl.pulseSelf(), 100000); + + const uint64 playerBalanceBefore = ctl.qheartBalanceOf(player); + QpiContextUserProcedureCall qpiProc(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpiProc); + ctl.state()->callSettleRound(qpiProc); + + const uint64 playerBalanceAfter = ctl.qheartBalanceOf(player); + EXPECT_EQ(playerBalanceAfter - playerBalanceBefore, 2400u); + expectWinningDigitsUniqueAndInRange(ctl.state()->getLastWinningDigits()); +} + +// ============================================================================ +// PUBLIC FUNCTIONS AND PROCEDURES +// ============================================================================ + +TEST(ContractPulse_Public, GettersReturnDefaultsAfterInitialize) +{ + ContractTestingPulse ctl; + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, PULSE_TICKET_PRICE_DEFAULT); + EXPECT_EQ(ctl.getSchedule().schedule, PULSE_DEFAULT_SCHEDULE); + EXPECT_EQ(ctl.getDrawHour().drawHour, PULSE_DEFAULT_DRAW_HOUR); + EXPECT_EQ(ctl.getQHeartHoldLimit().qheartHoldLimit, PULSE_DEFAULT_QHEART_HOLD_LIMIT); + + const auto fees = ctl.getFees(); + EXPECT_EQ(fees.devPercent, PULSE_DEFAULT_DEV_PERCENT); + EXPECT_EQ(fees.burnPercent, PULSE_DEFAULT_BURN_PERCENT); + EXPECT_EQ(fees.shareholdersPercent, PULSE_DEFAULT_SHAREHOLDERS_PERCENT); + EXPECT_EQ(fees.qheartPercent, PULSE_DEFAULT_QHEART_PERCENT); + EXPECT_EQ(fees.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const auto win = ctl.getWinningDigits(); + for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + EXPECT_EQ(win.digits.get(i), 0u); + } + EXPECT_EQ(ctl.getBalance().balance, 0u); + EXPECT_EQ(ctl.getQHeartWallet().wallet, PULSE_QHEART_ISSUER); +} + +TEST(ContractPulse_Public, SetPriceGuardsAccessAndAppliesOnEndEpoch) +{ + ContractTestingPulse ctl; + EXPECT_EQ( + ctl.setPrice(id::randomValue(), 123).returnCode, + static_cast(PULSE::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ( + ctl.setPrice(PULSE_QHEART_ISSUER, 0).returnCode, + static_cast(PULSE::EReturnCode::TICKET_INVALID_PRICE)); + + EXPECT_EQ( + ctl.setPrice(PULSE_QHEART_ISSUER, 555).returnCode, + static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getTicketPriceInternal(), PULSE_TICKET_PRICE_DEFAULT); + + ctl.endEpoch(); + EXPECT_EQ(ctl.state()->getTicketPriceInternal(), 555u); +} + +TEST(ContractPulse_Public, SetScheduleValidatesAndAppliesOnEndEpoch) +{ + ContractTestingPulse ctl; + EXPECT_EQ( + ctl.setSchedule(id::randomValue(), 1).returnCode, + static_cast(PULSE::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ( + ctl.setSchedule(PULSE_QHEART_ISSUER, 0).returnCode, + static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + EXPECT_EQ( + ctl.setSchedule(PULSE_QHEART_ISSUER, 0x7F).returnCode, + static_cast(PULSE::EReturnCode::SUCCESS)); + ctl.endEpoch(); + EXPECT_EQ(ctl.state()->getScheduleInternal(), 0x7Fu); +} + +TEST(ContractPulse_Public, SetDrawHourValidatesAndAppliesOnEndEpoch) +{ + ContractTestingPulse ctl; + EXPECT_EQ( + ctl.setDrawHour(id::randomValue(), 12).returnCode, + static_cast(PULSE::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ( + ctl.setDrawHour(PULSE_QHEART_ISSUER, 24).returnCode, + static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + EXPECT_EQ( + ctl.setDrawHour(PULSE_QHEART_ISSUER, 9).returnCode, + static_cast(PULSE::EReturnCode::SUCCESS)); + ctl.endEpoch(); + EXPECT_EQ(ctl.state()->getDrawHourInternal(), 9u); +} + +TEST(ContractPulse_Public, SetFeesValidatesAndAppliesOnEndEpoch) +{ + ContractTestingPulse ctl; + EXPECT_EQ( + ctl.setFees(id::randomValue(), 1, 2, 3, 4).returnCode, + static_cast(PULSE::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ( + ctl.setFees(PULSE_QHEART_ISSUER, 60, 60, 0, 0).returnCode, + static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + EXPECT_EQ( + ctl.setFees(PULSE_QHEART_ISSUER, 11, 22, 33, 4).returnCode, + static_cast(PULSE::EReturnCode::SUCCESS)); + ctl.endEpoch(); + EXPECT_EQ(ctl.state()->getDevPercentInternal(), 11u); + EXPECT_EQ(ctl.state()->getBurnPercentInternal(), 22u); + EXPECT_EQ(ctl.state()->getShareholdersPercentInternal(), 33u); + EXPECT_EQ(ctl.state()->getQHeartPercentInternal(), 4u); +} + +TEST(ContractPulse_Public, SetQHeartHoldLimitAppliesOnEndEpoch) +{ + ContractTestingPulse ctl; + EXPECT_EQ( + ctl.setQHeartHoldLimit(id::randomValue(), 100).returnCode, + static_cast(PULSE::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ( + ctl.setQHeartHoldLimit(PULSE_QHEART_ISSUER, 1234).returnCode, + static_cast(PULSE::EReturnCode::SUCCESS)); + ctl.endEpoch(); + EXPECT_EQ(ctl.state()->getQHeartHoldLimitInternal(), 1234u); +} + +TEST(ContractPulse_Public, BuyTicketWhenSellingClosedFails) +{ + ContractTestingPulse ctl; + const auto out = ctl.buyTicket(id::randomValue(), makePlayerDigits(0, 1, 2, 3, 4, 5)); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_SELLING_CLOSED)); +} + +TEST(ContractPulse_Public, BuyTicketValidatesDigits) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const auto issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + ctl.transferQHeart(issuance, user, PULSE_TICKET_PRICE_DEFAULT); + + const auto out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 4)); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_NUMBERS)); +} + +TEST(ContractPulse_Public, BuyTicketFailsWhenSoldOut) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS); + + const auto out = ctl.buyTicket(id::randomValue(), makePlayerDigits(0, 1, 2, 3, 4, 5)); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_ALL_SOLD_OUT)); +} + +TEST(ContractPulse_Public, BuyTicketFailsWithInsufficientBalance) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const auto issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + ctl.transferQHeart(issuance, user, PULSE_TICKET_PRICE_DEFAULT - 1); + + const auto out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 5)); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_INVALID_PRICE)); +} + +TEST(ContractPulse_Public, BuyTicketSucceedsAndMovesQHeart) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const auto issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + ctl.transferQHeart(issuance, user, PULSE_TICKET_PRICE_DEFAULT * 2); + + const uint64 userBefore = ctl.qheartBalanceOf(user); + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + + const auto out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 5)); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getTicketCounter(), 1u); + EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore - PULSE_TICKET_PRICE_DEFAULT); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore + PULSE_TICKET_PRICE_DEFAULT); +} + +TEST(ContractPulse_Public, GetBalanceReportsQHeartWalletBalance) +{ + ContractTestingPulse ctl; + const auto issuance = ctl.issueQHeart(1000000); + ctl.transferQHeart(issuance, ctl.pulseSelf(), 12345); + EXPECT_EQ(ctl.getBalance().balance, 12345u); +} + +// ============================================================================ +// SYSTEM PROCEDURES +// ============================================================================ + +TEST(ContractPulse_System, BeginEpochRestoresDefaultsAndOpensSelling) +{ + ContractTestingPulse ctl; + ctl.state()->setScheduleInternal(0); + ctl.state()->setDrawHourInternal(0); + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + EXPECT_EQ(ctl.state()->getScheduleInternal(), PULSE_DEFAULT_SCHEDULE); + EXPECT_EQ(ctl.state()->getDrawHourInternal(), PULSE_DEFAULT_DRAW_HOUR); + EXPECT_TRUE(ctl.state()->isSelling()); +} + +TEST(ContractPulse_System, EndEpochAppliesPendingChangesAndClearsState) +{ + ContractTestingPulse ctl; + ctl.state()->setTicketCounter(3); + ctl.state()->setLastDrawDateStamp(77); + ctl.state()->nextEpochDataRef().hasNewPrice = true; + ctl.state()->nextEpochDataRef().newPrice = 999; + + ctl.endEpoch(); + EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); + EXPECT_EQ(ctl.state()->getLastDrawDateStamp(), 0u); + EXPECT_FALSE(ctl.state()->isSelling()); + EXPECT_EQ(ctl.state()->getTicketPriceInternal(), 999u); +} + +TEST(ContractPulse_System, BeginTickRunsDrawOnScheduledDay) +{ + ContractTestingPulse ctl; + ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); + const auto issuance = ctl.issueQHeart(1000000); + ctl.transferQHeart(issuance, ctl.pulseSelf(), 100000); + + const id player = id::randomValue(); + ctl.state()->setTicketDirect(0, player, makePlayerDigits(0, 1, 2, 3, 4, 5)); + ctl.state()->setTicketCounter(1); + + ctl.setDateTime(2025, 1, 10, 12); // Friday + ctl.forceBeginTick(); + + EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); + EXPECT_TRUE(ctl.state()->isSelling()); + expectWinningDigitsUniqueAndInRange(ctl.state()->getLastWinningDigits()); +} + +TEST(ContractPulse_System, PostIncomingTransferStandardTransactionReturnsFunds) +{ + ContractTestingPulse ctl; + const id sender = id::randomValue(); + const uint64 amount = 5000; + increaseEnergy(ctl.pulseSelf(), amount); + const uint64 senderBefore = getBalance(sender); + + QpiContextSystemProcedureCall qpi(PULSE_CONTRACT_INDEX, POST_INCOMING_TRANSFER); + QPI::PostIncomingTransfer_input input{ sender, static_cast(amount), QPI::TransferType::standardTransaction }; + qpi.call(input); + + EXPECT_EQ(getBalance(sender), senderBefore + amount); +} diff --git a/test/test.vcxproj b/test/test.vcxproj index da0862aa2..b71ec0b28 100644 --- a/test/test.vcxproj +++ b/test/test.vcxproj @@ -1,4 +1,4 @@ - + @@ -120,6 +120,7 @@ + diff --git a/test/test.vcxproj.filters b/test/test.vcxproj.filters index b07da6b71..2f924cd8a 100644 --- a/test/test.vcxproj.filters +++ b/test/test.vcxproj.filters @@ -45,6 +45,7 @@ + From ceafdf4c36630032bb247e5808a9bf801c966234 Mon Sep 17 00:00:00 2001 From: N-010 Date: Mon, 12 Jan 2026 20:44:55 +0300 Subject: [PATCH 27/77] Adds Pulse.h --- src/Qubic.vcxproj | 1 + src/Qubic.vcxproj.filters | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/Qubic.vcxproj b/src/Qubic.vcxproj index 78634bba1..a8f0a969c 100644 --- a/src/Qubic.vcxproj +++ b/src/Qubic.vcxproj @@ -45,6 +45,7 @@ + diff --git a/src/Qubic.vcxproj.filters b/src/Qubic.vcxproj.filters index e4ad51acb..251c7e079 100644 --- a/src/Qubic.vcxproj.filters +++ b/src/Qubic.vcxproj.filters @@ -309,6 +309,9 @@ contracts + + contracts + contract_core From ac91da7e0524d358555e1f6f199300747da27ebe Mon Sep 17 00:00:00 2001 From: N-010 Date: Mon, 12 Jan 2026 22:47:20 +0300 Subject: [PATCH 28/77] Fixes build --- src/contracts/Pulse.h | 10 +- test/contract_pulse.cpp | 1274 +++++++++++++++++++-------------------- 2 files changed, 626 insertions(+), 658 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 769f9c7ea..2698ebd53 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -764,10 +764,10 @@ struct PULSE : public ContractBase } locals.roundRevenue = static_cast(smul(state.ticketPrice, state.ticketCounter)); - locals.devAmount = div(smul(locals.roundRevenue, static_cast(state.devPercent)), 100LL); - locals.burnAmount = div(smul(locals.roundRevenue, static_cast(state.burnPercent)), 100LL); - locals.shareholdersAmount = div(smul(locals.roundRevenue, static_cast(state.shareholdersPercent)), 100LL); - locals.qheartAmount = div(smul(locals.roundRevenue, static_cast(state.qheartPercent)), 100LL); + locals.devAmount = div(smul(locals.roundRevenue, static_cast(state.devPercent)), 100LL); + locals.burnAmount = div(smul(locals.roundRevenue, static_cast(state.burnPercent)), 100LL); + locals.shareholdersAmount = div(smul(locals.roundRevenue, static_cast(state.shareholdersPercent)), 100LL); + locals.qheartAmount = div(smul(locals.roundRevenue, static_cast(state.qheartPercent)), 100LL); if (locals.devAmount > 0) { @@ -780,7 +780,7 @@ struct PULSE : public ContractBase locals.shareholdersAsset.assetName = PULSE_CONTRACT_ASSET_NAME; locals.shareholdersTotalShares = NUMBER_OF_COMPUTORS; - locals.shareholdersDividendPerShare = div(locals.shareholdersAmount, locals.shareholdersTotalShares); + locals.shareholdersDividendPerShare = div(locals.shareholdersAmount, locals.shareholdersTotalShares); if (locals.shareholdersDividendPerShare > 0) { locals.shareholdersIter.begin(locals.shareholdersAsset); diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index c67695dd1..f9cd9585f 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -27,337 +27,335 @@ constexpr uint16 PULSE_FUNCTION_GET_BALANCE = 8; namespace { - static void primeQpiFunctionContext(QpiContextUserFunctionCall& qpi) - { - PULSE::GetTicketPrice_input input{}; - qpi.call(PULSE_FUNCTION_GET_TICKET_PRICE, &input, sizeof(input)); - } - - static void primeQpiProcedureContext(QpiContextUserProcedureCall& qpi) - { - PULSE::SetDrawHour_input input{}; - input.newDrawHour = PULSE_DEFAULT_DRAW_HOUR; - qpi.call(PULSE_PROCEDURE_SET_DRAW_HOUR, &input, sizeof(input)); - ASSERT_EQ(contractError[PULSE_CONTRACT_INDEX], 0); - } - - static Array makePlayerDigits( - uint8 d0, uint8 d1, uint8 d2, uint8 d3, uint8 d4, uint8 d5) - { - Array digits; - digits.set(0, d0); - digits.set(1, d1); - digits.set(2, d2); - digits.set(3, d3); - digits.set(4, d4); - digits.set(5, d5); - return digits; - } - - static void expectWinningDigitsUniqueAndInRange(const Array& digits) - { - std::set seen; - for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) - { - const uint8 v = digits.get(i); - EXPECT_LE(v, PULSE_MAX_DIGIT); - seen.insert(v); - } - EXPECT_EQ(seen.size(), static_cast(PULSE_WINNING_DIGITS)); - } -} + void primeQpiFunctionContext(QpiContextUserFunctionCall& qpi) + { + PULSE::GetTicketPrice_input input{}; + qpi.call(PULSE_FUNCTION_GET_TICKET_PRICE, &input, sizeof(input)); + } + + void primeQpiProcedureContext(QpiContextUserProcedureCall& qpi) + { + PULSE::SetDrawHour_input input{}; + input.newDrawHour = PULSE_DEFAULT_DRAW_HOUR; + qpi.call(PULSE_PROCEDURE_SET_DRAW_HOUR, &input, sizeof(input)); + ASSERT_EQ(contractError[PULSE_CONTRACT_INDEX], 0); + } + + Array makePlayerDigits(uint8 d0, uint8 d1, uint8 d2, uint8 d3, uint8 d4, uint8 d5) + { + Array digits; + digits.set(0, d0); + digits.set(1, d1); + digits.set(2, d2); + digits.set(3, d3); + digits.set(4, d4); + digits.set(5, d5); + return digits; + } + + void expectWinningDigitsUniqueAndInRange(const Array& digits) + { + std::set seen; + for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + const uint8 v = digits.get(i); + EXPECT_LE(v, PULSE_MAX_DIGIT); + seen.insert(v); + } + EXPECT_EQ(seen.size(), static_cast(PULSE_WINNING_DIGITS)); + } +} // namespace // Test helper class exposing internal state class PULSEChecker : public PULSE { public: - uint64 getTicketCounter() const { return ticketCounter; } - uint64 getTicketPriceInternal() const { return ticketPrice; } - uint64 getQHeartHoldLimitInternal() const { return qheartHoldLimit; } - uint32 getLastDrawDateStamp() const { return lastDrawDateStamp; } - uint8 getScheduleInternal() const { return schedule; } - uint8 getDrawHourInternal() const { return drawHour; } - uint8 getDevPercentInternal() const { return devPercent; } - uint8 getBurnPercentInternal() const { return burnPercent; } - uint8 getShareholdersPercentInternal() const { return shareholdersPercent; } - uint8 getQHeartPercentInternal() const { return qheartPercent; } - const Array& getLastWinningDigits() const { return lastWinningDigits; } - - void setTicketCounter(uint64 value) { ticketCounter = value; } - void setTicketPriceInternal(uint64 value) { ticketPrice = value; } - void setQHeartHoldLimitInternal(uint64 value) { qheartHoldLimit = value; } - void setLastDrawDateStamp(uint32 value) { lastDrawDateStamp = value; } - void setScheduleInternal(uint8 value) { schedule = value; } - void setDrawHourInternal(uint8 value) { drawHour = value; } - void setLastWinningDigits(const Array& digits) { lastWinningDigits = digits; } - - NextEpochData& nextEpochDataRef() { return nextEpochData; } - - void setTicketDirect(uint64 index, const id& player, const Array& digits) - { - Ticket ticket; - ticket.player = player; - ticket.digits = digits; - tickets.set(index, ticket); - } - - void forceSelling(bool enable) { enableBuyTicket(*this, enable); } - bool isSelling() const { return isSellingOpen(*this); } - - ValidateDigits_output callValidateDigits( - const QPI::QpiContextFunctionCall& qpi, - const Array& digits) const - { - ValidateDigits_input input{}; - ValidateDigits_output output{}; - ValidateDigits_locals locals{}; - input.digits = digits; - ValidateDigits(qpi, *this, input, output, locals); - return output; - } - - GetRandomDigits_output callGetRandomDigits(const QPI::QpiContextFunctionCall& qpi, uint64 seed) const - { - GetRandomDigits_input input{}; - GetRandomDigits_output output{}; - GetRandomDigits_locals locals{}; - input.seed = seed; - GetRandomDigits(qpi, *this, input, output, locals); - return output; - } - - void callSettleRound(const QPI::QpiContextProcedureCall& qpi) - { - SettleRound_input input{}; - SettleRound_output output{}; - SettleRound_locals locals{}; - SettleRound(qpi, *this, input, output, locals); - } - - void callClearStateOnEndDraw() { clearStateOnEndDraw(*this); } - void callClearStateOnEndEpoch() { clearStateOnEndEpoch(*this); } - uint64 callGetLeftAlignedReward(uint8 matches) const { return getLeftAlignedReward(matches); } - uint64 callGetAnyPositionReward(uint8 matches) const { return getAnyPositionReward(matches); } + uint64 getTicketCounter() const { return ticketCounter; } + uint64 getTicketPriceInternal() const { return ticketPrice; } + uint64 getQHeartHoldLimitInternal() const { return qheartHoldLimit; } + uint32 getLastDrawDateStamp() const { return lastDrawDateStamp; } + uint8 getScheduleInternal() const { return schedule; } + uint8 getDrawHourInternal() const { return drawHour; } + uint8 getDevPercentInternal() const { return devPercent; } + uint8 getBurnPercentInternal() const { return burnPercent; } + uint8 getShareholdersPercentInternal() const { return shareholdersPercent; } + uint8 getQHeartPercentInternal() const { return qheartPercent; } + const Array& getLastWinningDigits() const { return lastWinningDigits; } + + void setTicketCounter(uint64 value) { ticketCounter = value; } + void setTicketPriceInternal(uint64 value) { ticketPrice = value; } + void setQHeartHoldLimitInternal(uint64 value) { qheartHoldLimit = value; } + void setLastDrawDateStamp(uint32 value) { lastDrawDateStamp = value; } + void setScheduleInternal(uint8 value) { schedule = value; } + void setDrawHourInternal(uint8 value) { drawHour = value; } + void setLastWinningDigits(const Array& digits) { lastWinningDigits = digits; } + + NextEpochData& nextEpochDataRef() { return nextEpochData; } + + void setTicketDirect(uint64 index, const id& player, const Array& digits) + { + Ticket ticket; + ticket.player = player; + ticket.digits = digits; + tickets.set(index, ticket); + } + + void forceSelling(bool enable) { enableBuyTicket(*this, enable); } + bool isSelling() const { return isSellingOpen(*this); } + + ValidateDigits_output callValidateDigits(const QPI::QpiContextFunctionCall& qpi, const Array& digits) const + { + ValidateDigits_input input{}; + ValidateDigits_output output{}; + ValidateDigits_locals locals{}; + input.digits = digits; + ValidateDigits(qpi, *this, input, output, locals); + return output; + } + + GetRandomDigits_output callGetRandomDigits(const QPI::QpiContextFunctionCall& qpi, uint64 seed) const + { + GetRandomDigits_input input{}; + GetRandomDigits_output output{}; + GetRandomDigits_locals locals{}; + input.seed = seed; + GetRandomDigits(qpi, *this, input, output, locals); + return output; + } + + void callSettleRound(const QPI::QpiContextProcedureCall& qpi) + { + SettleRound_input input{}; + SettleRound_output output{}; + std::aligned_storage_t localsStorage; + auto& locals = *reinterpret_cast(&localsStorage); + setMemory(locals, 0); + + SettleRound(qpi, *this, input, output, locals); + } + + void callClearStateOnEndDraw() { clearStateOnEndDraw(*this); } + void callClearStateOnEndEpoch() { clearStateOnEndEpoch(*this); } + uint64 callGetLeftAlignedReward(uint8 matches) const { return getLeftAlignedReward(matches); } + uint64 callGetAnyPositionReward(uint8 matches) const { return getAnyPositionReward(matches); } }; class ContractTestingPulse : protected ContractTesting { public: - ContractTestingPulse() - { - initEmptySpectrum(); - initEmptyUniverse(); - INIT_CONTRACT(PULSE); - system.epoch = contractDescriptions[PULSE_CONTRACT_INDEX].constructionEpoch; - callSystemProcedure(PULSE_CONTRACT_INDEX, INITIALIZE); - } - - PULSEChecker* state() { return reinterpret_cast(contractStates[PULSE_CONTRACT_INDEX]); } - id pulseSelf() const { return id(PULSE_CONTRACT_INDEX, 0, 0, 0); } - - PULSE::GetTicketPrice_output getTicketPrice() - { - PULSE::GetTicketPrice_input input{}; - PULSE::GetTicketPrice_output output{}; - callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_TICKET_PRICE, input, output); - return output; - } - - PULSE::GetSchedule_output getSchedule() - { - PULSE::GetSchedule_input input{}; - PULSE::GetSchedule_output output{}; - callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_SCHEDULE, input, output); - return output; - } - - PULSE::GetDrawHour_output getDrawHour() - { - PULSE::GetDrawHour_input input{}; - PULSE::GetDrawHour_output output{}; - callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_DRAW_HOUR, input, output); - return output; - } - - PULSE::GetFees_output getFees() - { - PULSE::GetFees_input input{}; - PULSE::GetFees_output output{}; - callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_FEES, input, output); - return output; - } - - PULSE::GetQHeartHoldLimit_output getQHeartHoldLimit() - { - PULSE::GetQHeartHoldLimit_input input{}; - PULSE::GetQHeartHoldLimit_output output{}; - callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_QHEART_HOLD_LIMIT, input, output); - return output; - } - - PULSE::GetQHeartWallet_output getQHeartWallet() - { - PULSE::GetQHeartWallet_input input{}; - PULSE::GetQHeartWallet_output output{}; - callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_QHEART_WALLET, input, output); - return output; - } - - PULSE::GetWinningDigits_output getWinningDigits() - { - PULSE::GetWinningDigits_input input{}; - PULSE::GetWinningDigits_output output{}; - callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_WINNING_DIGITS, input, output); - return output; - } - - PULSE::GetBalance_output getBalance() - { - PULSE::GetBalance_input input{}; - PULSE::GetBalance_output output{}; - callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_BALANCE, input, output); - return output; - } - - PULSE::BuyTicket_output buyTicket(const id& user, const Array& digits) - { - PULSE::BuyTicket_input input{}; - input.digits = digits; - PULSE::BuyTicket_output output{}; - if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_BUY_TICKET, input, output, user, 0)) - { - output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); - } - return output; - } - - PULSE::SetPrice_output setPrice(const id& invocator, uint64 newPrice) - { - PULSE::SetPrice_input input{}; - input.newPrice = newPrice; - PULSE::SetPrice_output output{}; - if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_PRICE, input, output, invocator, 0)) - { - output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); - } - return output; - } - - PULSE::SetSchedule_output setSchedule(const id& invocator, uint8 newSchedule) - { - PULSE::SetSchedule_input input{}; - input.newSchedule = newSchedule; - PULSE::SetSchedule_output output{}; - if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_SCHEDULE, input, output, invocator, 0)) - { - output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); - } - return output; - } - - PULSE::SetDrawHour_output setDrawHour(const id& invocator, uint8 newDrawHour) - { - PULSE::SetDrawHour_input input{}; - input.newDrawHour = newDrawHour; - PULSE::SetDrawHour_output output{}; - if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_DRAW_HOUR, input, output, invocator, 0)) - { - output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); - } - return output; - } - - PULSE::SetFees_output setFees(const id& invocator, uint8 dev, uint8 burn, uint8 shareholders, uint8 qheart) - { - PULSE::SetFees_input input{}; - input.devPercent = dev; - input.burnPercent = burn; - input.shareholdersPercent = shareholders; - input.qheartPercent = qheart; - PULSE::SetFees_output output{}; - if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_FEES, input, output, invocator, 0)) - { - output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); - } - return output; - } - - PULSE::SetQHeartHoldLimit_output setQHeartHoldLimit(const id& invocator, uint64 newLimit) - { - PULSE::SetQHeartHoldLimit_input input{}; - input.newQHeartHoldLimit = newLimit; - PULSE::SetQHeartHoldLimit_output output{}; - if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_QHEART_HOLD_LIMIT, input, output, invocator, 0)) - { - output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); - } - return output; - } - - void beginEpoch() { callSystemProcedure(PULSE_CONTRACT_INDEX, BEGIN_EPOCH); } - void endEpoch() { callSystemProcedure(PULSE_CONTRACT_INDEX, END_EPOCH); } - void beginTick() { callSystemProcedure(PULSE_CONTRACT_INDEX, BEGIN_TICK); } - - void setDateTime(uint16 year, uint8 month, uint8 day, uint8 hour) - { - updateTime(); - utcTime.Year = year; - utcTime.Month = month; - utcTime.Day = day; - utcTime.Hour = hour; - utcTime.Minute = 0; - utcTime.Second = 0; - utcTime.Nanosecond = 0; - updateQpiTime(); - } - - void forceBeginTick() - { - system.tick = system.tick + (PULSE_TICK_UPDATE_PERIOD - (system.tick % PULSE_TICK_UPDATE_PERIOD)); - beginTick(); - } - - struct QHeartIssuance - { - int issuanceIndex; - int ownershipIndex; - int possessionIndex; - }; - - QHeartIssuance issueQHeart(sint64 totalShares) - { - char name[7] = { 'Q', 'H', 'E', 'A', 'R', 'T', 0 }; - char unit[7] = {}; - QHeartIssuance info{}; - const long long issued = issueAsset(PULSE_QHEART_ISSUER, name, 0, unit, totalShares, - PULSE_CONTRACT_INDEX, &info.issuanceIndex, &info.ownershipIndex, &info.possessionIndex); - EXPECT_EQ(issued, totalShares); - return info; - } - - void transferQHeart(const QHeartIssuance& issuance, const id& dest, sint64 amount) - { - int destOwnershipIndex = 0; - int destPossessionIndex = 0; - EXPECT_TRUE(transferShareOwnershipAndPossession( - issuance.ownershipIndex, issuance.possessionIndex, dest, amount, - &destOwnershipIndex, &destPossessionIndex, true)); - } - - void issuePulseSharesTo(const id& holder, unsigned int shares) - { - std::vector> initialShares; - initialShares.emplace_back(holder, shares); - issueContractShares(PULSE_CONTRACT_INDEX, initialShares, false); - } - - uint64 qheartBalanceOf(const id& owner) const - { - const long long balance = numberOfPossessedShares( - PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, owner, owner, - PULSE_CONTRACT_INDEX, PULSE_CONTRACT_INDEX); - return (balance > 0) ? static_cast(balance) : 0; - } + ContractTestingPulse() + { + initEmptySpectrum(); + initEmptyUniverse(); + INIT_CONTRACT(PULSE); + system.epoch = contractDescriptions[PULSE_CONTRACT_INDEX].constructionEpoch; + callSystemProcedure(PULSE_CONTRACT_INDEX, INITIALIZE); + } + + PULSEChecker* state() { return reinterpret_cast(contractStates[PULSE_CONTRACT_INDEX]); } + id pulseSelf() const { return id(PULSE_CONTRACT_INDEX, 0, 0, 0); } + + PULSE::GetTicketPrice_output getTicketPrice() + { + PULSE::GetTicketPrice_input input{}; + PULSE::GetTicketPrice_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_TICKET_PRICE, input, output); + return output; + } + + PULSE::GetSchedule_output getSchedule() + { + PULSE::GetSchedule_input input{}; + PULSE::GetSchedule_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_SCHEDULE, input, output); + return output; + } + + PULSE::GetDrawHour_output getDrawHour() + { + PULSE::GetDrawHour_input input{}; + PULSE::GetDrawHour_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_DRAW_HOUR, input, output); + return output; + } + + PULSE::GetFees_output getFees() + { + PULSE::GetFees_input input{}; + PULSE::GetFees_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_FEES, input, output); + return output; + } + + PULSE::GetQHeartHoldLimit_output getQHeartHoldLimit() + { + PULSE::GetQHeartHoldLimit_input input{}; + PULSE::GetQHeartHoldLimit_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_QHEART_HOLD_LIMIT, input, output); + return output; + } + + PULSE::GetQHeartWallet_output getQHeartWallet() + { + PULSE::GetQHeartWallet_input input{}; + PULSE::GetQHeartWallet_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_QHEART_WALLET, input, output); + return output; + } + + PULSE::GetWinningDigits_output getWinningDigits() + { + PULSE::GetWinningDigits_input input{}; + PULSE::GetWinningDigits_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_WINNING_DIGITS, input, output); + return output; + } + + PULSE::GetBalance_output getBalance() + { + PULSE::GetBalance_input input{}; + PULSE::GetBalance_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_BALANCE, input, output); + return output; + } + + PULSE::BuyTicket_output buyTicket(const id& user, const Array& digits) + { + PULSE::BuyTicket_input input{}; + input.digits = digits; + PULSE::BuyTicket_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_BUY_TICKET, input, output, user, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::SetPrice_output setPrice(const id& invocator, uint64 newPrice) + { + PULSE::SetPrice_input input{}; + input.newPrice = newPrice; + PULSE::SetPrice_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_PRICE, input, output, invocator, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::SetSchedule_output setSchedule(const id& invocator, uint8 newSchedule) + { + PULSE::SetSchedule_input input{}; + input.newSchedule = newSchedule; + PULSE::SetSchedule_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_SCHEDULE, input, output, invocator, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::SetDrawHour_output setDrawHour(const id& invocator, uint8 newDrawHour) + { + PULSE::SetDrawHour_input input{}; + input.newDrawHour = newDrawHour; + PULSE::SetDrawHour_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_DRAW_HOUR, input, output, invocator, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::SetFees_output setFees(const id& invocator, uint8 dev, uint8 burn, uint8 shareholders, uint8 qheart) + { + PULSE::SetFees_input input{}; + input.devPercent = dev; + input.burnPercent = burn; + input.shareholdersPercent = shareholders; + input.qheartPercent = qheart; + PULSE::SetFees_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_FEES, input, output, invocator, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::SetQHeartHoldLimit_output setQHeartHoldLimit(const id& invocator, uint64 newLimit) + { + PULSE::SetQHeartHoldLimit_input input{}; + input.newQHeartHoldLimit = newLimit; + PULSE::SetQHeartHoldLimit_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_QHEART_HOLD_LIMIT, input, output, invocator, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + void beginEpoch() { callSystemProcedure(PULSE_CONTRACT_INDEX, BEGIN_EPOCH); } + void endEpoch() { callSystemProcedure(PULSE_CONTRACT_INDEX, END_EPOCH); } + void beginTick() { callSystemProcedure(PULSE_CONTRACT_INDEX, BEGIN_TICK); } + + void setDateTime(uint16 year, uint8 month, uint8 day, uint8 hour) + { + updateTime(); + utcTime.Year = year; + utcTime.Month = month; + utcTime.Day = day; + utcTime.Hour = hour; + utcTime.Minute = 0; + utcTime.Second = 0; + utcTime.Nanosecond = 0; + updateQpiTime(); + } + + void forceBeginTick() + { + system.tick = system.tick + (PULSE_TICK_UPDATE_PERIOD - (system.tick % PULSE_TICK_UPDATE_PERIOD)); + beginTick(); + } + + struct QHeartIssuance + { + int issuanceIndex; + int ownershipIndex; + int possessionIndex; + }; + + QHeartIssuance issueQHeart(sint64 totalShares) + { + char name[7] = {'Q', 'H', 'E', 'A', 'R', 'T', 0}; + char unit[7] = {}; + QHeartIssuance info{}; + const long long issued = issueAsset(PULSE_QHEART_ISSUER, name, 0, unit, totalShares, PULSE_CONTRACT_INDEX, &info.issuanceIndex, + &info.ownershipIndex, &info.possessionIndex); + EXPECT_EQ(issued, totalShares); + return info; + } + + void transferQHeart(const QHeartIssuance& issuance, const id& dest, sint64 amount) + { + int destOwnershipIndex = 0; + int destPossessionIndex = 0; + EXPECT_TRUE(transferShareOwnershipAndPossession(issuance.ownershipIndex, issuance.possessionIndex, dest, amount, &destOwnershipIndex, + &destPossessionIndex, true)); + } + + void issuePulseSharesTo(const id& holder, unsigned int shares) + { + std::vector> initialShares; + initialShares.emplace_back(holder, shares); + issueContractShares(PULSE_CONTRACT_INDEX, initialShares, false); + } + + uint64 qheartBalanceOf(const id& owner) const + { + const long long balance = + numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, owner, owner, PULSE_CONTRACT_INDEX, PULSE_CONTRACT_INDEX); + return (balance > 0) ? static_cast(balance) : 0; + } }; // ============================================================================ @@ -366,201 +364,199 @@ class ContractTestingPulse : protected ContractTesting TEST(ContractPulse_Static, MakeDateStampMinMaxAndMixingAreDeterministic) { - uint32 stamp = 0; - PULSE::makeDateStamp(25, 1, 10, stamp); - EXPECT_EQ(stamp, static_cast(25 << 9 | 1 << 5 | 10)); - EXPECT_EQ(PULSE::min(3, 5), 3u); - EXPECT_EQ(PULSE::max(3, 5), 5u); - - uint64 mixed1 = 0; - uint64 mixed2 = 0; - PULSE::mix64(0x12345678ULL, mixed1); - PULSE::mix64(0x12345678ULL, mixed2); - EXPECT_EQ(mixed1, mixed2); - - uint64 d1 = 0; - uint64 d2 = 0; - PULSE::deriveOne(0xABCDEFULL, 0, d1); - PULSE::deriveOne(0xABCDEFULL, 1, d2); - EXPECT_NE(d1, d2); + uint32 stamp = 0; + PULSE::makeDateStamp(25, 1, 10, stamp); + EXPECT_EQ(stamp, static_cast(25 << 9 | 1 << 5 | 10)); + EXPECT_EQ(PULSE::min(3, 5), 3u); + EXPECT_EQ(PULSE::max(3, 5), 5u); + + uint64 mixed1 = 0; + uint64 mixed2 = 0; + PULSE::mix64(0x12345678ULL, mixed1); + PULSE::mix64(0x12345678ULL, mixed2); + EXPECT_EQ(mixed1, mixed2); + + uint64 d1 = 0; + uint64 d2 = 0; + PULSE::deriveOne(0xABCDEFULL, 0, d1); + PULSE::deriveOne(0xABCDEFULL, 1, d2); + EXPECT_NE(d1, d2); } TEST(ContractPulse_Static, SellingFlagToggles) { - ContractTestingPulse ctl; - ctl.state()->forceSelling(true); - EXPECT_TRUE(ctl.state()->isSelling()); - ctl.state()->forceSelling(false); - EXPECT_FALSE(ctl.state()->isSelling()); + ContractTestingPulse ctl; + ctl.state()->forceSelling(true); + EXPECT_TRUE(ctl.state()->isSelling()); + ctl.state()->forceSelling(false); + EXPECT_FALSE(ctl.state()->isSelling()); } TEST(ContractPulse_Static, RewardTablesMatchContractConstants) { - ContractTestingPulse ctl; - EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(6), 2400u); - EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(5), 600u); - EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(4), 150u); - EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(3), 30u); - EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(2), 8u); - EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(1), 1u); - EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(0), 0u); - - EXPECT_EQ(ctl.state()->callGetAnyPositionReward(6), 0u); - EXPECT_EQ(ctl.state()->callGetAnyPositionReward(5), 400u); - EXPECT_EQ(ctl.state()->callGetAnyPositionReward(4), 50u); - EXPECT_EQ(ctl.state()->callGetAnyPositionReward(3), 8u); - EXPECT_EQ(ctl.state()->callGetAnyPositionReward(2), 2u); - EXPECT_EQ(ctl.state()->callGetAnyPositionReward(1), 0u); - EXPECT_EQ(ctl.state()->callGetAnyPositionReward(0), 0u); + ContractTestingPulse ctl; + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(6), 2400u); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(5), 600u); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(4), 150u); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(3), 30u); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(2), 8u); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(1), 1u); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(0), 0u); + + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(6), 0u); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(5), 400u); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(4), 50u); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(3), 8u); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(2), 2u); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(1), 0u); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(0), 0u); } TEST(ContractPulse_Private, NextEpochDataClearResetsFlagsAndValues) { - PULSE::NextEpochData data{}; - data.hasNewPrice = true; - data.hasNewSchedule = true; - data.hasNewDrawHour = true; - data.hasNewFee = true; - data.hasNewQHeartHoldLimit = true; - data.newPrice = 10; - data.newSchedule = 1; - data.newDrawHour = 2; - data.newDevPercent = 3; - data.newBurnPercent = 4; - data.newShareholdersPercent = 5; - data.newQHeartPercent = 6; - data.newQHeartHoldLimit = 7; - - data.clear(); - EXPECT_FALSE(data.hasNewPrice); - EXPECT_FALSE(data.hasNewSchedule); - EXPECT_FALSE(data.hasNewDrawHour); - EXPECT_FALSE(data.hasNewFee); - EXPECT_FALSE(data.hasNewQHeartHoldLimit); - EXPECT_EQ(data.newPrice, 0u); - EXPECT_EQ(data.newSchedule, 0u); - EXPECT_EQ(data.newDrawHour, 0u); - EXPECT_EQ(data.newDevPercent, 0u); - EXPECT_EQ(data.newBurnPercent, 0u); - EXPECT_EQ(data.newShareholdersPercent, 0u); - EXPECT_EQ(data.newQHeartPercent, 0u); - EXPECT_EQ(data.newQHeartHoldLimit, 0u); + PULSE::NextEpochData data{}; + data.hasNewPrice = true; + data.hasNewSchedule = true; + data.hasNewDrawHour = true; + data.hasNewFee = true; + data.hasNewQHeartHoldLimit = true; + data.newPrice = 10; + data.newSchedule = 1; + data.newDrawHour = 2; + data.newDevPercent = 3; + data.newBurnPercent = 4; + data.newShareholdersPercent = 5; + data.newQHeartPercent = 6; + data.newQHeartHoldLimit = 7; + + data.clear(); + EXPECT_FALSE(data.hasNewPrice); + EXPECT_FALSE(data.hasNewSchedule); + EXPECT_FALSE(data.hasNewDrawHour); + EXPECT_FALSE(data.hasNewFee); + EXPECT_FALSE(data.hasNewQHeartHoldLimit); + EXPECT_EQ(data.newPrice, 0u); + EXPECT_EQ(data.newSchedule, 0u); + EXPECT_EQ(data.newDrawHour, 0u); + EXPECT_EQ(data.newDevPercent, 0u); + EXPECT_EQ(data.newBurnPercent, 0u); + EXPECT_EQ(data.newShareholdersPercent, 0u); + EXPECT_EQ(data.newQHeartPercent, 0u); + EXPECT_EQ(data.newQHeartHoldLimit, 0u); } TEST(ContractPulse_Private, NextEpochDataApplyUpdatesState) { - ContractTestingPulse ctl; - PULSE::NextEpochData data{}; - data.hasNewPrice = true; - data.hasNewSchedule = true; - data.hasNewDrawHour = true; - data.hasNewFee = true; - data.hasNewQHeartHoldLimit = true; - data.newPrice = 123; - data.newSchedule = 0xAA; - data.newDrawHour = 7; - data.newDevPercent = 11; - data.newBurnPercent = 22; - data.newShareholdersPercent = 33; - data.newQHeartPercent = 4; - data.newQHeartHoldLimit = 999; - - data.apply(*ctl.state()); - EXPECT_EQ(ctl.state()->getTicketPriceInternal(), 123u); - EXPECT_EQ(ctl.state()->getScheduleInternal(), 0xAA); - EXPECT_EQ(ctl.state()->getDrawHourInternal(), 7u); - EXPECT_EQ(ctl.state()->getDevPercentInternal(), 11u); - EXPECT_EQ(ctl.state()->getBurnPercentInternal(), 22u); - EXPECT_EQ(ctl.state()->getShareholdersPercentInternal(), 33u); - EXPECT_EQ(ctl.state()->getQHeartPercentInternal(), 4u); - EXPECT_EQ(ctl.state()->getQHeartHoldLimitInternal(), 999u); + ContractTestingPulse ctl; + PULSE::NextEpochData data{}; + data.hasNewPrice = true; + data.hasNewSchedule = true; + data.hasNewDrawHour = true; + data.hasNewFee = true; + data.hasNewQHeartHoldLimit = true; + data.newPrice = 123; + data.newSchedule = 0xAA; + data.newDrawHour = 7; + data.newDevPercent = 11; + data.newBurnPercent = 22; + data.newShareholdersPercent = 33; + data.newQHeartPercent = 4; + data.newQHeartHoldLimit = 999; + + data.apply(*ctl.state()); + EXPECT_EQ(ctl.state()->getTicketPriceInternal(), 123u); + EXPECT_EQ(ctl.state()->getScheduleInternal(), 0xAA); + EXPECT_EQ(ctl.state()->getDrawHourInternal(), 7u); + EXPECT_EQ(ctl.state()->getDevPercentInternal(), 11u); + EXPECT_EQ(ctl.state()->getBurnPercentInternal(), 22u); + EXPECT_EQ(ctl.state()->getShareholdersPercentInternal(), 33u); + EXPECT_EQ(ctl.state()->getQHeartPercentInternal(), 4u); + EXPECT_EQ(ctl.state()->getQHeartHoldLimitInternal(), 999u); } TEST(ContractPulse_Private, ValidateDigitsRejectsDuplicateAndOutOfRange) { - ContractTestingPulse ctl; - QpiContextUserFunctionCall qpi(PULSE_CONTRACT_INDEX); - primeQpiFunctionContext(qpi); + ContractTestingPulse ctl; + QpiContextUserFunctionCall qpi(PULSE_CONTRACT_INDEX); + primeQpiFunctionContext(qpi); - const auto ok = makePlayerDigits(0, 1, 2, 3, 4, 5); - EXPECT_TRUE(ctl.state()->callValidateDigits(qpi, ok).isValid); + const auto ok = makePlayerDigits(0, 1, 2, 3, 4, 5); + EXPECT_TRUE(ctl.state()->callValidateDigits(qpi, ok).isValid); - const auto dup = makePlayerDigits(0, 1, 2, 3, 4, 4); - EXPECT_FALSE(ctl.state()->callValidateDigits(qpi, dup).isValid); + const auto dup = makePlayerDigits(0, 1, 2, 3, 4, 4); + EXPECT_FALSE(ctl.state()->callValidateDigits(qpi, dup).isValid); - const auto outOfRange = makePlayerDigits(0, 1, 2, 3, 4, 10); - EXPECT_FALSE(ctl.state()->callValidateDigits(qpi, outOfRange).isValid); + const auto outOfRange = makePlayerDigits(0, 1, 2, 3, 4, 10); + EXPECT_FALSE(ctl.state()->callValidateDigits(qpi, outOfRange).isValid); } TEST(ContractPulse_Private, GetRandomDigitsDeterministicAndUnique) { - ContractTestingPulse ctl; - QpiContextUserFunctionCall qpi(PULSE_CONTRACT_INDEX); - primeQpiFunctionContext(qpi); - - const uint64 seed = 0x123456789ABCDEF0ULL; - const auto out1 = ctl.state()->callGetRandomDigits(qpi, seed); - const auto out2 = ctl.state()->callGetRandomDigits(qpi, seed); - - std::set seen; - for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) - { - const uint8 v1 = out1.digits.get(i); - const uint8 v2 = out2.digits.get(i); - EXPECT_EQ(v1, v2); - EXPECT_LE(v1, PULSE_MAX_DIGIT); - seen.insert(v1); - } - EXPECT_EQ(seen.size(), static_cast(PULSE_WINNING_DIGITS)); + ContractTestingPulse ctl; + QpiContextUserFunctionCall qpi(PULSE_CONTRACT_INDEX); + primeQpiFunctionContext(qpi); + + const uint64 seed = 0x123456789ABCDEF0ULL; + const auto out1 = ctl.state()->callGetRandomDigits(qpi, seed); + const auto out2 = ctl.state()->callGetRandomDigits(qpi, seed); + + std::set seen; + for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + const uint8 v1 = out1.digits.get(i); + const uint8 v2 = out2.digits.get(i); + EXPECT_EQ(v1, v2); + EXPECT_LE(v1, PULSE_MAX_DIGIT); + seen.insert(v1); + } + EXPECT_EQ(seen.size(), static_cast(PULSE_WINNING_DIGITS)); } TEST(ContractPulse_Private, ClearStateHelpersResetTicketData) { - ContractTestingPulse ctl; - ctl.state()->setTicketCounter(2); - ctl.state()->setLastDrawDateStamp(42); - ctl.state()->callClearStateOnEndDraw(); - EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); - ctl.state()->setLastDrawDateStamp(99); - ctl.state()->callClearStateOnEndEpoch(); - EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); - EXPECT_EQ(ctl.state()->getLastDrawDateStamp(), 0u); + ContractTestingPulse ctl; + ctl.state()->setTicketCounter(2); + ctl.state()->setLastDrawDateStamp(42); + ctl.state()->callClearStateOnEndDraw(); + EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); + ctl.state()->setLastDrawDateStamp(99); + ctl.state()->callClearStateOnEndEpoch(); + EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); + EXPECT_EQ(ctl.state()->getLastDrawDateStamp(), 0u); } TEST(ContractPulse_Private, SettleRoundUpdatesWinningDigitsAndPaysPrize) { - ContractTestingPulse ctl; - const auto issuance = ctl.issueQHeart(1000000); - ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); - - const m256i digest = m256i::randomValue(); - etalonTick.prevSpectrumDigest = digest; - - m256i hashResult; - KangarooTwelve((const uint8*)&digest, sizeof(m256i), (uint8*)&hashResult, sizeof(m256i)); - const uint64 seed = hashResult.m256i_u64[0]; - - QpiContextUserFunctionCall qpiFunc(PULSE_CONTRACT_INDEX); - primeQpiFunctionContext(qpiFunc); - const auto winning = ctl.state()->callGetRandomDigits(qpiFunc, seed).digits; - - const id player = id::randomValue(); - const auto ticketDigits = makePlayerDigits( - winning.get(0), winning.get(1), winning.get(2), - winning.get(3), winning.get(4), winning.get(5)); - - ctl.state()->setTicketDirect(0, player, ticketDigits); - ctl.state()->setTicketCounter(1); - ctl.transferQHeart(issuance, ctl.pulseSelf(), 100000); - - const uint64 playerBalanceBefore = ctl.qheartBalanceOf(player); - QpiContextUserProcedureCall qpiProc(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); - primeQpiProcedureContext(qpiProc); - ctl.state()->callSettleRound(qpiProc); - - const uint64 playerBalanceAfter = ctl.qheartBalanceOf(player); - EXPECT_EQ(playerBalanceAfter - playerBalanceBefore, 2400u); - expectWinningDigitsUniqueAndInRange(ctl.state()->getLastWinningDigits()); + ContractTestingPulse ctl; + const auto issuance = ctl.issueQHeart(1000000); + ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); + + const m256i digest = m256i::randomValue(); + etalonTick.prevSpectrumDigest = digest; + + m256i hashResult; + KangarooTwelve((const uint8*)&digest, sizeof(m256i), (uint8*)&hashResult, sizeof(m256i)); + const uint64 seed = hashResult.m256i_u64[0]; + + QpiContextUserFunctionCall qpiFunc(PULSE_CONTRACT_INDEX); + primeQpiFunctionContext(qpiFunc); + const auto winning = ctl.state()->callGetRandomDigits(qpiFunc, seed).digits; + + const id player = id::randomValue(); + const auto ticketDigits = makePlayerDigits(winning.get(0), winning.get(1), winning.get(2), winning.get(3), winning.get(4), winning.get(5)); + + ctl.state()->setTicketDirect(0, player, ticketDigits); + ctl.state()->setTicketCounter(1); + ctl.transferQHeart(issuance, ctl.pulseSelf(), 100000); + + const uint64 playerBalanceBefore = ctl.qheartBalanceOf(player); + QpiContextUserProcedureCall qpiProc(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpiProc); + ctl.state()->callSettleRound(qpiProc); + + const uint64 playerBalanceAfter = ctl.qheartBalanceOf(player); + EXPECT_EQ(playerBalanceAfter - playerBalanceBefore, 2400u); + expectWinningDigitsUniqueAndInRange(ctl.state()->getLastWinningDigits()); } // ============================================================================ @@ -569,186 +565,158 @@ TEST(ContractPulse_Private, SettleRoundUpdatesWinningDigitsAndPaysPrize) TEST(ContractPulse_Public, GettersReturnDefaultsAfterInitialize) { - ContractTestingPulse ctl; - EXPECT_EQ(ctl.getTicketPrice().ticketPrice, PULSE_TICKET_PRICE_DEFAULT); - EXPECT_EQ(ctl.getSchedule().schedule, PULSE_DEFAULT_SCHEDULE); - EXPECT_EQ(ctl.getDrawHour().drawHour, PULSE_DEFAULT_DRAW_HOUR); - EXPECT_EQ(ctl.getQHeartHoldLimit().qheartHoldLimit, PULSE_DEFAULT_QHEART_HOLD_LIMIT); - - const auto fees = ctl.getFees(); - EXPECT_EQ(fees.devPercent, PULSE_DEFAULT_DEV_PERCENT); - EXPECT_EQ(fees.burnPercent, PULSE_DEFAULT_BURN_PERCENT); - EXPECT_EQ(fees.shareholdersPercent, PULSE_DEFAULT_SHAREHOLDERS_PERCENT); - EXPECT_EQ(fees.qheartPercent, PULSE_DEFAULT_QHEART_PERCENT); - EXPECT_EQ(fees.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - - const auto win = ctl.getWinningDigits(); - for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) - { - EXPECT_EQ(win.digits.get(i), 0u); - } - EXPECT_EQ(ctl.getBalance().balance, 0u); - EXPECT_EQ(ctl.getQHeartWallet().wallet, PULSE_QHEART_ISSUER); + ContractTestingPulse ctl; + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, PULSE_TICKET_PRICE_DEFAULT); + EXPECT_EQ(ctl.getSchedule().schedule, PULSE_DEFAULT_SCHEDULE); + EXPECT_EQ(ctl.getDrawHour().drawHour, PULSE_DEFAULT_DRAW_HOUR); + EXPECT_EQ(ctl.getQHeartHoldLimit().qheartHoldLimit, PULSE_DEFAULT_QHEART_HOLD_LIMIT); + + const auto fees = ctl.getFees(); + EXPECT_EQ(fees.devPercent, PULSE_DEFAULT_DEV_PERCENT); + EXPECT_EQ(fees.burnPercent, PULSE_DEFAULT_BURN_PERCENT); + EXPECT_EQ(fees.shareholdersPercent, PULSE_DEFAULT_SHAREHOLDERS_PERCENT); + EXPECT_EQ(fees.qheartPercent, PULSE_DEFAULT_QHEART_PERCENT); + EXPECT_EQ(fees.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const auto win = ctl.getWinningDigits(); + for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + EXPECT_EQ(win.digits.get(i), 0u); + } + EXPECT_EQ(ctl.getBalance().balance, 0u); + EXPECT_EQ(ctl.getQHeartWallet().wallet, PULSE_QHEART_ISSUER); } TEST(ContractPulse_Public, SetPriceGuardsAccessAndAppliesOnEndEpoch) { - ContractTestingPulse ctl; - EXPECT_EQ( - ctl.setPrice(id::randomValue(), 123).returnCode, - static_cast(PULSE::EReturnCode::ACCESS_DENIED)); - EXPECT_EQ( - ctl.setPrice(PULSE_QHEART_ISSUER, 0).returnCode, - static_cast(PULSE::EReturnCode::TICKET_INVALID_PRICE)); - - EXPECT_EQ( - ctl.setPrice(PULSE_QHEART_ISSUER, 555).returnCode, - static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.state()->getTicketPriceInternal(), PULSE_TICKET_PRICE_DEFAULT); - - ctl.endEpoch(); - EXPECT_EQ(ctl.state()->getTicketPriceInternal(), 555u); + ContractTestingPulse ctl; + EXPECT_EQ(ctl.setPrice(id::randomValue(), 123).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, 0).returnCode, static_cast(PULSE::EReturnCode::TICKET_INVALID_PRICE)); + + EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, 555).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getTicketPriceInternal(), PULSE_TICKET_PRICE_DEFAULT); + + ctl.endEpoch(); + EXPECT_EQ(ctl.state()->getTicketPriceInternal(), 555u); } TEST(ContractPulse_Public, SetScheduleValidatesAndAppliesOnEndEpoch) { - ContractTestingPulse ctl; - EXPECT_EQ( - ctl.setSchedule(id::randomValue(), 1).returnCode, - static_cast(PULSE::EReturnCode::ACCESS_DENIED)); - EXPECT_EQ( - ctl.setSchedule(PULSE_QHEART_ISSUER, 0).returnCode, - static_cast(PULSE::EReturnCode::INVALID_VALUE)); - - EXPECT_EQ( - ctl.setSchedule(PULSE_QHEART_ISSUER, 0x7F).returnCode, - static_cast(PULSE::EReturnCode::SUCCESS)); - ctl.endEpoch(); - EXPECT_EQ(ctl.state()->getScheduleInternal(), 0x7Fu); + ContractTestingPulse ctl; + EXPECT_EQ(ctl.setSchedule(id::randomValue(), 1).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ(ctl.setSchedule(PULSE_QHEART_ISSUER, 0).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + EXPECT_EQ(ctl.setSchedule(PULSE_QHEART_ISSUER, 0x7F).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + ctl.endEpoch(); + EXPECT_EQ(ctl.state()->getScheduleInternal(), 0x7Fu); } TEST(ContractPulse_Public, SetDrawHourValidatesAndAppliesOnEndEpoch) { - ContractTestingPulse ctl; - EXPECT_EQ( - ctl.setDrawHour(id::randomValue(), 12).returnCode, - static_cast(PULSE::EReturnCode::ACCESS_DENIED)); - EXPECT_EQ( - ctl.setDrawHour(PULSE_QHEART_ISSUER, 24).returnCode, - static_cast(PULSE::EReturnCode::INVALID_VALUE)); - - EXPECT_EQ( - ctl.setDrawHour(PULSE_QHEART_ISSUER, 9).returnCode, - static_cast(PULSE::EReturnCode::SUCCESS)); - ctl.endEpoch(); - EXPECT_EQ(ctl.state()->getDrawHourInternal(), 9u); + ContractTestingPulse ctl; + EXPECT_EQ(ctl.setDrawHour(id::randomValue(), 12).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ(ctl.setDrawHour(PULSE_QHEART_ISSUER, 24).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + EXPECT_EQ(ctl.setDrawHour(PULSE_QHEART_ISSUER, 9).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + ctl.endEpoch(); + EXPECT_EQ(ctl.state()->getDrawHourInternal(), 9u); } TEST(ContractPulse_Public, SetFeesValidatesAndAppliesOnEndEpoch) { - ContractTestingPulse ctl; - EXPECT_EQ( - ctl.setFees(id::randomValue(), 1, 2, 3, 4).returnCode, - static_cast(PULSE::EReturnCode::ACCESS_DENIED)); - EXPECT_EQ( - ctl.setFees(PULSE_QHEART_ISSUER, 60, 60, 0, 0).returnCode, - static_cast(PULSE::EReturnCode::INVALID_VALUE)); - - EXPECT_EQ( - ctl.setFees(PULSE_QHEART_ISSUER, 11, 22, 33, 4).returnCode, - static_cast(PULSE::EReturnCode::SUCCESS)); - ctl.endEpoch(); - EXPECT_EQ(ctl.state()->getDevPercentInternal(), 11u); - EXPECT_EQ(ctl.state()->getBurnPercentInternal(), 22u); - EXPECT_EQ(ctl.state()->getShareholdersPercentInternal(), 33u); - EXPECT_EQ(ctl.state()->getQHeartPercentInternal(), 4u); + ContractTestingPulse ctl; + EXPECT_EQ(ctl.setFees(id::randomValue(), 1, 2, 3, 4).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 60, 60, 0, 0).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 11, 22, 33, 4).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + ctl.endEpoch(); + EXPECT_EQ(ctl.state()->getDevPercentInternal(), 11u); + EXPECT_EQ(ctl.state()->getBurnPercentInternal(), 22u); + EXPECT_EQ(ctl.state()->getShareholdersPercentInternal(), 33u); + EXPECT_EQ(ctl.state()->getQHeartPercentInternal(), 4u); } TEST(ContractPulse_Public, SetQHeartHoldLimitAppliesOnEndEpoch) { - ContractTestingPulse ctl; - EXPECT_EQ( - ctl.setQHeartHoldLimit(id::randomValue(), 100).returnCode, - static_cast(PULSE::EReturnCode::ACCESS_DENIED)); - EXPECT_EQ( - ctl.setQHeartHoldLimit(PULSE_QHEART_ISSUER, 1234).returnCode, - static_cast(PULSE::EReturnCode::SUCCESS)); - ctl.endEpoch(); - EXPECT_EQ(ctl.state()->getQHeartHoldLimitInternal(), 1234u); + ContractTestingPulse ctl; + EXPECT_EQ(ctl.setQHeartHoldLimit(id::randomValue(), 100).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ(ctl.setQHeartHoldLimit(PULSE_QHEART_ISSUER, 1234).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + ctl.endEpoch(); + EXPECT_EQ(ctl.state()->getQHeartHoldLimitInternal(), 1234u); } TEST(ContractPulse_Public, BuyTicketWhenSellingClosedFails) { - ContractTestingPulse ctl; - const auto out = ctl.buyTicket(id::randomValue(), makePlayerDigits(0, 1, 2, 3, 4, 5)); - EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_SELLING_CLOSED)); + ContractTestingPulse ctl; + const auto out = ctl.buyTicket(id::randomValue(), makePlayerDigits(0, 1, 2, 3, 4, 5)); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_SELLING_CLOSED)); } TEST(ContractPulse_Public, BuyTicketValidatesDigits) { - ContractTestingPulse ctl; - ctl.setDateTime(2025, 1, 10, 12); - ctl.beginEpoch(); + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); - const auto issuance = ctl.issueQHeart(1000000); - const id user = id::randomValue(); - ctl.transferQHeart(issuance, user, PULSE_TICKET_PRICE_DEFAULT); + const auto issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + ctl.transferQHeart(issuance, user, PULSE_TICKET_PRICE_DEFAULT); - const auto out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 4)); - EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_NUMBERS)); + const auto out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 4)); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_NUMBERS)); } TEST(ContractPulse_Public, BuyTicketFailsWhenSoldOut) { - ContractTestingPulse ctl; - ctl.setDateTime(2025, 1, 10, 12); - ctl.beginEpoch(); - ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS); + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS); - const auto out = ctl.buyTicket(id::randomValue(), makePlayerDigits(0, 1, 2, 3, 4, 5)); - EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_ALL_SOLD_OUT)); + const auto out = ctl.buyTicket(id::randomValue(), makePlayerDigits(0, 1, 2, 3, 4, 5)); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_ALL_SOLD_OUT)); } TEST(ContractPulse_Public, BuyTicketFailsWithInsufficientBalance) { - ContractTestingPulse ctl; - ctl.setDateTime(2025, 1, 10, 12); - ctl.beginEpoch(); + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); - const auto issuance = ctl.issueQHeart(1000000); - const id user = id::randomValue(); - ctl.transferQHeart(issuance, user, PULSE_TICKET_PRICE_DEFAULT - 1); + const auto issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + ctl.transferQHeart(issuance, user, PULSE_TICKET_PRICE_DEFAULT - 1); - const auto out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 5)); - EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_INVALID_PRICE)); + const auto out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 5)); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_INVALID_PRICE)); } TEST(ContractPulse_Public, BuyTicketSucceedsAndMovesQHeart) { - ContractTestingPulse ctl; - ctl.setDateTime(2025, 1, 10, 12); - ctl.beginEpoch(); - - const auto issuance = ctl.issueQHeart(1000000); - const id user = id::randomValue(); - ctl.transferQHeart(issuance, user, PULSE_TICKET_PRICE_DEFAULT * 2); - - const uint64 userBefore = ctl.qheartBalanceOf(user); - const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); - - const auto out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 5)); - EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.state()->getTicketCounter(), 1u); - EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore - PULSE_TICKET_PRICE_DEFAULT); - EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore + PULSE_TICKET_PRICE_DEFAULT); + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const auto issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + ctl.transferQHeart(issuance, user, PULSE_TICKET_PRICE_DEFAULT * 2); + + const uint64 userBefore = ctl.qheartBalanceOf(user); + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + + const auto out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 5)); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getTicketCounter(), 1u); + EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore - PULSE_TICKET_PRICE_DEFAULT); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore + PULSE_TICKET_PRICE_DEFAULT); } TEST(ContractPulse_Public, GetBalanceReportsQHeartWalletBalance) { - ContractTestingPulse ctl; - const auto issuance = ctl.issueQHeart(1000000); - ctl.transferQHeart(issuance, ctl.pulseSelf(), 12345); - EXPECT_EQ(ctl.getBalance().balance, 12345u); + ContractTestingPulse ctl; + const auto issuance = ctl.issueQHeart(1000000); + ctl.transferQHeart(issuance, ctl.pulseSelf(), 12345); + EXPECT_EQ(ctl.getBalance().balance, 12345u); } // ============================================================================ @@ -757,62 +725,62 @@ TEST(ContractPulse_Public, GetBalanceReportsQHeartWalletBalance) TEST(ContractPulse_System, BeginEpochRestoresDefaultsAndOpensSelling) { - ContractTestingPulse ctl; - ctl.state()->setScheduleInternal(0); - ctl.state()->setDrawHourInternal(0); - ctl.setDateTime(2025, 1, 10, 12); - ctl.beginEpoch(); - - EXPECT_EQ(ctl.state()->getScheduleInternal(), PULSE_DEFAULT_SCHEDULE); - EXPECT_EQ(ctl.state()->getDrawHourInternal(), PULSE_DEFAULT_DRAW_HOUR); - EXPECT_TRUE(ctl.state()->isSelling()); + ContractTestingPulse ctl; + ctl.state()->setScheduleInternal(0); + ctl.state()->setDrawHourInternal(0); + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + EXPECT_EQ(ctl.state()->getScheduleInternal(), PULSE_DEFAULT_SCHEDULE); + EXPECT_EQ(ctl.state()->getDrawHourInternal(), PULSE_DEFAULT_DRAW_HOUR); + EXPECT_TRUE(ctl.state()->isSelling()); } TEST(ContractPulse_System, EndEpochAppliesPendingChangesAndClearsState) { - ContractTestingPulse ctl; - ctl.state()->setTicketCounter(3); - ctl.state()->setLastDrawDateStamp(77); - ctl.state()->nextEpochDataRef().hasNewPrice = true; - ctl.state()->nextEpochDataRef().newPrice = 999; - - ctl.endEpoch(); - EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); - EXPECT_EQ(ctl.state()->getLastDrawDateStamp(), 0u); - EXPECT_FALSE(ctl.state()->isSelling()); - EXPECT_EQ(ctl.state()->getTicketPriceInternal(), 999u); + ContractTestingPulse ctl; + ctl.state()->setTicketCounter(3); + ctl.state()->setLastDrawDateStamp(77); + ctl.state()->nextEpochDataRef().hasNewPrice = true; + ctl.state()->nextEpochDataRef().newPrice = 999; + + ctl.endEpoch(); + EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); + EXPECT_EQ(ctl.state()->getLastDrawDateStamp(), 0u); + EXPECT_FALSE(ctl.state()->isSelling()); + EXPECT_EQ(ctl.state()->getTicketPriceInternal(), 999u); } TEST(ContractPulse_System, BeginTickRunsDrawOnScheduledDay) { - ContractTestingPulse ctl; - ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); - const auto issuance = ctl.issueQHeart(1000000); - ctl.transferQHeart(issuance, ctl.pulseSelf(), 100000); + ContractTestingPulse ctl; + ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); + const auto issuance = ctl.issueQHeart(1000000); + ctl.transferQHeart(issuance, ctl.pulseSelf(), 100000); - const id player = id::randomValue(); - ctl.state()->setTicketDirect(0, player, makePlayerDigits(0, 1, 2, 3, 4, 5)); - ctl.state()->setTicketCounter(1); + const id player = id::randomValue(); + ctl.state()->setTicketDirect(0, player, makePlayerDigits(0, 1, 2, 3, 4, 5)); + ctl.state()->setTicketCounter(1); - ctl.setDateTime(2025, 1, 10, 12); // Friday - ctl.forceBeginTick(); + ctl.setDateTime(2025, 1, 10, 12); // Friday + ctl.forceBeginTick(); - EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); - EXPECT_TRUE(ctl.state()->isSelling()); - expectWinningDigitsUniqueAndInRange(ctl.state()->getLastWinningDigits()); + EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); + EXPECT_TRUE(ctl.state()->isSelling()); + expectWinningDigitsUniqueAndInRange(ctl.state()->getLastWinningDigits()); } TEST(ContractPulse_System, PostIncomingTransferStandardTransactionReturnsFunds) { - ContractTestingPulse ctl; - const id sender = id::randomValue(); - const uint64 amount = 5000; - increaseEnergy(ctl.pulseSelf(), amount); - const uint64 senderBefore = getBalance(sender); + ContractTestingPulse ctl; + const id sender = id::randomValue(); + const uint64 amount = 5000; + increaseEnergy(ctl.pulseSelf(), amount); + const uint64 senderBefore = getBalance(sender); - QpiContextSystemProcedureCall qpi(PULSE_CONTRACT_INDEX, POST_INCOMING_TRANSFER); - QPI::PostIncomingTransfer_input input{ sender, static_cast(amount), QPI::TransferType::standardTransaction }; - qpi.call(input); + QpiContextSystemProcedureCall qpi(PULSE_CONTRACT_INDEX, POST_INCOMING_TRANSFER); + QPI::PostIncomingTransfer_input input{sender, static_cast(amount), QPI::TransferType::standardTransaction}; + qpi.call(input); - EXPECT_EQ(getBalance(sender), senderBefore + amount); + EXPECT_EQ(getBalance(sender), senderBefore + amount); } From 09a37f0f6f87ac759b375c50c5405bfbbc0f143a Mon Sep 17 00:00:00 2001 From: N-010 Date: Mon, 12 Jan 2026 23:57:05 +0300 Subject: [PATCH 29/77] Removes POST_INCOMING_TRANSFER --- src/contracts/Pulse.h | 13 ------ test/contract_pulse.cpp | 89 ++++++++++++++++++++++------------------- 2 files changed, 48 insertions(+), 54 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 2698ebd53..5cb784425 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -493,19 +493,6 @@ struct PULSE : public ContractBase enableBuyTicket(state, !locals.isWednesday); } - POST_INCOMING_TRANSFER() - { - switch (input.type) - { - case TransferType::standardTransaction: - if (input.amount > 0) - { - qpi.transfer(input.sourceId, input.amount); - } - default: break; - } - } - PUBLIC_FUNCTION(GetTicketPrice) { output.ticketPrice = state.ticketPrice; } PUBLIC_FUNCTION(GetSchedule) { output.schedule = state.schedule; } PUBLIC_FUNCTION(GetDrawHour) { output.drawHour = state.drawHour; } diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index f9cd9585f..3aac65c94 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -43,7 +43,7 @@ namespace Array makePlayerDigits(uint8 d0, uint8 d1, uint8 d2, uint8 d3, uint8 d4, uint8 d5) { - Array digits; + Array digits = {}; digits.set(0, d0); digits.set(1, d1); digits.set(2, d2); @@ -128,7 +128,7 @@ class PULSEChecker : public PULSE SettleRound_input input{}; SettleRound_output output{}; std::aligned_storage_t localsStorage; - auto& locals = *reinterpret_cast(&localsStorage); + SettleRound_locals& locals = *reinterpret_cast(&localsStorage); setMemory(locals, 0); SettleRound(qpi, *this, input, output, locals); @@ -221,6 +221,7 @@ class ContractTestingPulse : protected ContractTesting PULSE::BuyTicket_output buyTicket(const id& user, const Array& digits) { + ensureUserEnergy(user); PULSE::BuyTicket_input input{}; input.digits = digits; PULSE::BuyTicket_output output{}; @@ -233,6 +234,7 @@ class ContractTestingPulse : protected ContractTesting PULSE::SetPrice_output setPrice(const id& invocator, uint64 newPrice) { + ensureUserEnergy(invocator); PULSE::SetPrice_input input{}; input.newPrice = newPrice; PULSE::SetPrice_output output{}; @@ -245,6 +247,7 @@ class ContractTestingPulse : protected ContractTesting PULSE::SetSchedule_output setSchedule(const id& invocator, uint8 newSchedule) { + ensureUserEnergy(invocator); PULSE::SetSchedule_input input{}; input.newSchedule = newSchedule; PULSE::SetSchedule_output output{}; @@ -257,6 +260,7 @@ class ContractTestingPulse : protected ContractTesting PULSE::SetDrawHour_output setDrawHour(const id& invocator, uint8 newDrawHour) { + ensureUserEnergy(invocator); PULSE::SetDrawHour_input input{}; input.newDrawHour = newDrawHour; PULSE::SetDrawHour_output output{}; @@ -269,6 +273,7 @@ class ContractTestingPulse : protected ContractTesting PULSE::SetFees_output setFees(const id& invocator, uint8 dev, uint8 burn, uint8 shareholders, uint8 qheart) { + ensureUserEnergy(invocator); PULSE::SetFees_input input{}; input.devPercent = dev; input.burnPercent = burn; @@ -284,6 +289,7 @@ class ContractTestingPulse : protected ContractTesting PULSE::SetQHeartHoldLimit_output setQHeartHoldLimit(const id& invocator, uint64 newLimit) { + ensureUserEnergy(invocator); PULSE::SetQHeartHoldLimit_input input{}; input.newQHeartHoldLimit = newLimit; PULSE::SetQHeartHoldLimit_output output{}; @@ -326,8 +332,8 @@ class ContractTestingPulse : protected ContractTesting QHeartIssuance issueQHeart(sint64 totalShares) { - char name[7] = {'Q', 'H', 'E', 'A', 'R', 'T', 0}; - char unit[7] = {}; + static constexpr char name[7] = {'Q', 'H', 'E', 'A', 'R', 'T', 0}; + static constexpr char unit[7] = {}; QHeartIssuance info{}; const long long issued = issueAsset(PULSE_QHEART_ISSUER, name, 0, unit, totalShares, PULSE_CONTRACT_INDEX, &info.issuanceIndex, &info.ownershipIndex, &info.possessionIndex); @@ -356,6 +362,9 @@ class ContractTestingPulse : protected ContractTesting numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, owner, owner, PULSE_CONTRACT_INDEX, PULSE_CONTRACT_INDEX); return (balance > 0) ? static_cast(balance) : 0; } + +private: + static void ensureUserEnergy(const id& user) { increaseEnergy(user, 1); } }; // ============================================================================ @@ -480,13 +489,13 @@ TEST(ContractPulse_Private, ValidateDigitsRejectsDuplicateAndOutOfRange) QpiContextUserFunctionCall qpi(PULSE_CONTRACT_INDEX); primeQpiFunctionContext(qpi); - const auto ok = makePlayerDigits(0, 1, 2, 3, 4, 5); + const Array& ok = makePlayerDigits(0, 1, 2, 3, 4, 5); EXPECT_TRUE(ctl.state()->callValidateDigits(qpi, ok).isValid); - const auto dup = makePlayerDigits(0, 1, 2, 3, 4, 4); + const Array& dup = makePlayerDigits(0, 1, 2, 3, 4, 4); EXPECT_FALSE(ctl.state()->callValidateDigits(qpi, dup).isValid); - const auto outOfRange = makePlayerDigits(0, 1, 2, 3, 4, 10); + const Array& outOfRange = makePlayerDigits(0, 1, 2, 3, 4, 10); EXPECT_FALSE(ctl.state()->callValidateDigits(qpi, outOfRange).isValid); } @@ -496,9 +505,9 @@ TEST(ContractPulse_Private, GetRandomDigitsDeterministicAndUnique) QpiContextUserFunctionCall qpi(PULSE_CONTRACT_INDEX); primeQpiFunctionContext(qpi); - const uint64 seed = 0x123456789ABCDEF0ULL; - const auto out1 = ctl.state()->callGetRandomDigits(qpi, seed); - const auto out2 = ctl.state()->callGetRandomDigits(qpi, seed); + static constexpr uint64 seed = 0x123456789ABCDEF0ULL; + const PULSE::GetRandomDigits_output& out1 = ctl.state()->callGetRandomDigits(qpi, seed); + const PULSE::GetRandomDigits_output& out2 = ctl.state()->callGetRandomDigits(qpi, seed); std::set seen; for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) @@ -519,6 +528,7 @@ TEST(ContractPulse_Private, ClearStateHelpersResetTicketData) ctl.state()->setLastDrawDateStamp(42); ctl.state()->callClearStateOnEndDraw(); EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); + EXPECT_EQ(ctl.state()->getLastDrawDateStamp(), 42u); ctl.state()->setLastDrawDateStamp(99); ctl.state()->callClearStateOnEndEpoch(); EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); @@ -528,22 +538,23 @@ TEST(ContractPulse_Private, ClearStateHelpersResetTicketData) TEST(ContractPulse_Private, SettleRoundUpdatesWinningDigitsAndPaysPrize) { ContractTestingPulse ctl; - const auto issuance = ctl.issueQHeart(1000000); + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); const m256i digest = m256i::randomValue(); etalonTick.prevSpectrumDigest = digest; m256i hashResult; - KangarooTwelve((const uint8*)&digest, sizeof(m256i), (uint8*)&hashResult, sizeof(m256i)); + KangarooTwelve(reinterpret_cast(&digest), sizeof(m256i), reinterpret_cast(&hashResult), sizeof(m256i)); const uint64 seed = hashResult.m256i_u64[0]; QpiContextUserFunctionCall qpiFunc(PULSE_CONTRACT_INDEX); primeQpiFunctionContext(qpiFunc); - const auto winning = ctl.state()->callGetRandomDigits(qpiFunc, seed).digits; + const Array& winning = ctl.state()->callGetRandomDigits(qpiFunc, seed).digits; const id player = id::randomValue(); - const auto ticketDigits = makePlayerDigits(winning.get(0), winning.get(1), winning.get(2), winning.get(3), winning.get(4), winning.get(5)); + const Array& ticketDigits = + makePlayerDigits(winning.get(0), winning.get(1), winning.get(2), winning.get(3), winning.get(4), winning.get(5)); ctl.state()->setTicketDirect(0, player, ticketDigits); ctl.state()->setTicketCounter(1); @@ -571,14 +582,14 @@ TEST(ContractPulse_Public, GettersReturnDefaultsAfterInitialize) EXPECT_EQ(ctl.getDrawHour().drawHour, PULSE_DEFAULT_DRAW_HOUR); EXPECT_EQ(ctl.getQHeartHoldLimit().qheartHoldLimit, PULSE_DEFAULT_QHEART_HOLD_LIMIT); - const auto fees = ctl.getFees(); + const PULSE::GetFees_output& fees = ctl.getFees(); EXPECT_EQ(fees.devPercent, PULSE_DEFAULT_DEV_PERCENT); EXPECT_EQ(fees.burnPercent, PULSE_DEFAULT_BURN_PERCENT); EXPECT_EQ(fees.shareholdersPercent, PULSE_DEFAULT_SHAREHOLDERS_PERCENT); EXPECT_EQ(fees.qheartPercent, PULSE_DEFAULT_QHEART_PERCENT); EXPECT_EQ(fees.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - const auto win = ctl.getWinningDigits(); + const PULSE::GetWinningDigits_output& win = ctl.getWinningDigits(); for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) { EXPECT_EQ(win.digits.get(i), 0u); @@ -607,6 +618,8 @@ TEST(ContractPulse_Public, SetScheduleValidatesAndAppliesOnEndEpoch) EXPECT_EQ(ctl.setSchedule(PULSE_QHEART_ISSUER, 0).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); EXPECT_EQ(ctl.setSchedule(PULSE_QHEART_ISSUER, 0x7F).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getScheduleInternal(), PULSE_DEFAULT_SCHEDULE); + ctl.endEpoch(); EXPECT_EQ(ctl.state()->getScheduleInternal(), 0x7Fu); } @@ -618,6 +631,8 @@ TEST(ContractPulse_Public, SetDrawHourValidatesAndAppliesOnEndEpoch) EXPECT_EQ(ctl.setDrawHour(PULSE_QHEART_ISSUER, 24).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); EXPECT_EQ(ctl.setDrawHour(PULSE_QHEART_ISSUER, 9).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getDrawHourInternal(), PULSE_DEFAULT_DRAW_HOUR); + ctl.endEpoch(); EXPECT_EQ(ctl.state()->getDrawHourInternal(), 9u); } @@ -629,6 +644,11 @@ TEST(ContractPulse_Public, SetFeesValidatesAndAppliesOnEndEpoch) EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 60, 60, 0, 0).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 11, 22, 33, 4).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getDevPercentInternal(), PULSE_DEFAULT_DEV_PERCENT); + EXPECT_EQ(ctl.state()->getBurnPercentInternal(), PULSE_DEFAULT_BURN_PERCENT); + EXPECT_EQ(ctl.state()->getShareholdersPercentInternal(), PULSE_DEFAULT_SHAREHOLDERS_PERCENT); + EXPECT_EQ(ctl.state()->getQHeartPercentInternal(), PULSE_DEFAULT_QHEART_PERCENT); + ctl.endEpoch(); EXPECT_EQ(ctl.state()->getDevPercentInternal(), 11u); EXPECT_EQ(ctl.state()->getBurnPercentInternal(), 22u); @@ -641,6 +661,8 @@ TEST(ContractPulse_Public, SetQHeartHoldLimitAppliesOnEndEpoch) ContractTestingPulse ctl; EXPECT_EQ(ctl.setQHeartHoldLimit(id::randomValue(), 100).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); EXPECT_EQ(ctl.setQHeartHoldLimit(PULSE_QHEART_ISSUER, 1234).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getQHeartHoldLimitInternal(), PULSE_DEFAULT_QHEART_HOLD_LIMIT); + ctl.endEpoch(); EXPECT_EQ(ctl.state()->getQHeartHoldLimitInternal(), 1234u); } @@ -648,7 +670,7 @@ TEST(ContractPulse_Public, SetQHeartHoldLimitAppliesOnEndEpoch) TEST(ContractPulse_Public, BuyTicketWhenSellingClosedFails) { ContractTestingPulse ctl; - const auto out = ctl.buyTicket(id::randomValue(), makePlayerDigits(0, 1, 2, 3, 4, 5)); + const PULSE::BuyTicket_output& out = ctl.buyTicket(id::randomValue(), makePlayerDigits(0, 1, 2, 3, 4, 5)); EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_SELLING_CLOSED)); } @@ -658,11 +680,11 @@ TEST(ContractPulse_Public, BuyTicketValidatesDigits) ctl.setDateTime(2025, 1, 10, 12); ctl.beginEpoch(); - const auto issuance = ctl.issueQHeart(1000000); + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); const id user = id::randomValue(); ctl.transferQHeart(issuance, user, PULSE_TICKET_PRICE_DEFAULT); - const auto out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 4)); + const PULSE::BuyTicket_output& out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 4)); EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_NUMBERS)); } @@ -673,7 +695,7 @@ TEST(ContractPulse_Public, BuyTicketFailsWhenSoldOut) ctl.beginEpoch(); ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS); - const auto out = ctl.buyTicket(id::randomValue(), makePlayerDigits(0, 1, 2, 3, 4, 5)); + const PULSE::BuyTicket_output& out = ctl.buyTicket(id::randomValue(), makePlayerDigits(0, 1, 2, 3, 4, 5)); EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_ALL_SOLD_OUT)); } @@ -683,11 +705,11 @@ TEST(ContractPulse_Public, BuyTicketFailsWithInsufficientBalance) ctl.setDateTime(2025, 1, 10, 12); ctl.beginEpoch(); - const auto issuance = ctl.issueQHeart(1000000); + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); const id user = id::randomValue(); ctl.transferQHeart(issuance, user, PULSE_TICKET_PRICE_DEFAULT - 1); - const auto out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 5)); + const PULSE::BuyTicket_output& out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 5)); EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_INVALID_PRICE)); } @@ -697,14 +719,14 @@ TEST(ContractPulse_Public, BuyTicketSucceedsAndMovesQHeart) ctl.setDateTime(2025, 1, 10, 12); ctl.beginEpoch(); - const auto issuance = ctl.issueQHeart(1000000); + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); const id user = id::randomValue(); ctl.transferQHeart(issuance, user, PULSE_TICKET_PRICE_DEFAULT * 2); const uint64 userBefore = ctl.qheartBalanceOf(user); const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); - const auto out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 5)); + const PULSE::BuyTicket_output& out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 5)); EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.state()->getTicketCounter(), 1u); EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore - PULSE_TICKET_PRICE_DEFAULT); @@ -714,7 +736,7 @@ TEST(ContractPulse_Public, BuyTicketSucceedsAndMovesQHeart) TEST(ContractPulse_Public, GetBalanceReportsQHeartWalletBalance) { ContractTestingPulse ctl; - const auto issuance = ctl.issueQHeart(1000000); + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); ctl.transferQHeart(issuance, ctl.pulseSelf(), 12345); EXPECT_EQ(ctl.getBalance().balance, 12345u); } @@ -755,7 +777,7 @@ TEST(ContractPulse_System, BeginTickRunsDrawOnScheduledDay) { ContractTestingPulse ctl; ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); - const auto issuance = ctl.issueQHeart(1000000); + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); ctl.transferQHeart(issuance, ctl.pulseSelf(), 100000); const id player = id::randomValue(); @@ -769,18 +791,3 @@ TEST(ContractPulse_System, BeginTickRunsDrawOnScheduledDay) EXPECT_TRUE(ctl.state()->isSelling()); expectWinningDigitsUniqueAndInRange(ctl.state()->getLastWinningDigits()); } - -TEST(ContractPulse_System, PostIncomingTransferStandardTransactionReturnsFunds) -{ - ContractTestingPulse ctl; - const id sender = id::randomValue(); - const uint64 amount = 5000; - increaseEnergy(ctl.pulseSelf(), amount); - const uint64 senderBefore = getBalance(sender); - - QpiContextSystemProcedureCall qpi(PULSE_CONTRACT_INDEX, POST_INCOMING_TRANSFER); - QPI::PostIncomingTransfer_input input{sender, static_cast(amount), QPI::TransferType::standardTransaction}; - qpi.call(input); - - EXPECT_EQ(getBalance(sender), senderBefore + amount); -} From c621a904154d60fd1232a8387fd5c38004896dd3 Mon Sep 17 00:00:00 2001 From: N-010 Date: Tue, 13 Jan 2026 10:48:43 +0300 Subject: [PATCH 30/77] Adds test --- test/contract_pulse.cpp | 198 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index 3aac65c94..b0ec7084e 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -367,6 +367,77 @@ class ContractTestingPulse : protected ContractTesting static void ensureUserEnergy(const id& user) { increaseEnergy(user, 1); } }; +namespace +{ + Array deriveWinningDigits(ContractTestingPulse& ctl, const m256i& digest) + { + m256i hashResult; + KangarooTwelve(reinterpret_cast(&digest), sizeof(m256i), reinterpret_cast(&hashResult), sizeof(m256i)); + const uint64 seed = hashResult.m256i_u64[0]; + + QpiContextUserFunctionCall qpiFunc(PULSE_CONTRACT_INDEX); + primeQpiFunctionContext(qpiFunc); + return ctl.state()->callGetRandomDigits(qpiFunc, seed).digits; + } + + uint8 findMissingDigit(const Array& winning) + { + bool seen[PULSE_MAX_DIGIT + 1] = {}; + for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + seen[winning.get(i)] = true; + } + for (uint8 d = 0; d <= PULSE_MAX_DIGIT; ++d) + { + if (!seen[d]) + { + return d; + } + } + return 0; + } + + uint64 computeExpectedPrize(PULSEChecker* state, const Array& winning, + const Array& digits) + { + uint8 leftAlignedMatches = 0; + for (uint8 offset = 0; offset + PULSE_PLAYER_DIGITS <= PULSE_WINNING_DIGITS; ++offset) + { + uint8 matchesAtOffset = 0; + for (uint8 j = 0; j < PULSE_PLAYER_DIGITS; ++j) + { + if (digits.get(j) == winning.get(offset + j)) + { + ++matchesAtOffset; + } + } + if (matchesAtOffset > leftAlignedMatches) + { + leftAlignedMatches = matchesAtOffset; + } + } + + uint16 winningMask = 0; + for (uint8 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + winningMask = static_cast(winningMask | (1u << winning.get(i))); + } + + uint8 anyPositionMatches = 0; + for (uint8 j = 0; j < PULSE_PLAYER_DIGITS; ++j) + { + if ((winningMask & (1u << digits.get(j))) != 0) + { + ++anyPositionMatches; + } + } + + const uint64 leftReward = state->callGetLeftAlignedReward(leftAlignedMatches); + const uint64 anyReward = state->callGetAnyPositionReward(anyPositionMatches); + return (leftReward > anyReward) ? leftReward : anyReward; + } +} // namespace + // ============================================================================ // STATIC + PRIVATE METHOD TESTS // ============================================================================ @@ -791,3 +862,130 @@ TEST(ContractPulse_System, BeginTickRunsDrawOnScheduledDay) EXPECT_TRUE(ctl.state()->isSelling()); expectWinningDigitsUniqueAndInRange(ctl.state()->getLastWinningDigits()); } + +TEST(ContractPulse_Gameplay, MultipleRoundsMultiplePlayers) +{ + ContractTestingPulse ctl; + ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(10000000); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + + struct RoundDates + { + uint8 startDay; + uint8 drawDay; + }; + static constexpr RoundDates rounds[] = { + {9, 10}, // Thu -> Fri + {11, 12}, // Sat -> Sun + {14, 15}, // Tue -> Wed + }; + + for (uint32 r = 0; r < 3; ++r) + { + ctl.setDateTime(2025, 1, rounds[r].startDay, 12); + ctl.beginEpoch(); + + const m256i digest(0x1111ULL + r, 0x2222ULL + r, 0x3333ULL + r, 0x4444ULL + r); + etalonTick.prevSpectrumDigest = digest; + const Array& winning = deriveWinningDigits(ctl, digest); + + const uint8 missing = findMissingDigit(winning); + const Array tickets[] = { + makePlayerDigits(winning.get(0), winning.get(1), winning.get(2), winning.get(3), winning.get(4), winning.get(5)), + makePlayerDigits(winning.get(1), winning.get(2), winning.get(3), winning.get(4), winning.get(5), winning.get(6)), + makePlayerDigits(winning.get(2), winning.get(3), winning.get(4), winning.get(5), winning.get(6), winning.get(7)), + makePlayerDigits(missing, winning.get(0), winning.get(2), winning.get(4), winning.get(6), winning.get(8)), + }; + + struct PlayerCheck + { + id player; + uint64 balanceAfterBuy; + uint64 expectedPrize; + }; + std::vector players; + players.reserve(4); + + for (const auto& ticketDigits : tickets) + { + const id player = id::randomValue(); + ctl.transferQHeart(issuance, player, ticketPrice); + const PULSE::BuyTicket_output out = ctl.buyTicket(player, ticketDigits); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + PlayerCheck info{}; + info.player = player; + info.balanceAfterBuy = ctl.qheartBalanceOf(player); + info.expectedPrize = computeExpectedPrize(ctl.state(), winning, ticketDigits); + players.push_back(info); + } + + ctl.setDateTime(2025, 1, rounds[r].drawDay, 12); + ctl.forceBeginTick(); + + EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); + for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + EXPECT_EQ(ctl.state()->getLastWinningDigits().get(i), winning.get(i)); + } + + for (const auto& info : players) + { + EXPECT_EQ(ctl.qheartBalanceOf(info.player), info.balanceAfterBuy + info.expectedPrize); + } + } +} + +TEST(ContractPulse_Gameplay, QHeartHoldLimitExcessTransferred) +{ + ContractTestingPulse ctl; + ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(5000000); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const uint64 holdLimit = 100000; + const uint64 preFund = 500000; + + EXPECT_EQ(ctl.setQHeartHoldLimit(PULSE_QHEART_ISSUER, holdLimit).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + ctl.endEpoch(); + + ctl.setDateTime(2025, 1, 9, 12); + ctl.beginEpoch(); + + ctl.transferQHeart(issuance, ctl.pulseSelf(), preFund); + + const id player = id::randomValue(); + ctl.transferQHeart(issuance, player, ticketPrice); + const Array digits = makePlayerDigits(0, 1, 2, 3, 4, 5); + const PULSE::BuyTicket_output out = ctl.buyTicket(player, digits); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const m256i digest(0x11112222ULL, 0x33334444ULL, 0x55556666ULL, 0x77778888ULL); + etalonTick.prevSpectrumDigest = digest; + const Array& winning = deriveWinningDigits(ctl, digest); + const uint64 prize = computeExpectedPrize(ctl.state(), winning, digits); + + const uint64 walletBefore = ctl.qheartBalanceOf(PULSE_QHEART_ISSUER); + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + + const PULSE::GetFees_output fees = ctl.getFees(); + const uint64 roundRevenue = ticketPrice; + const uint64 devAmount = (roundRevenue * fees.devPercent) / 100; + const uint64 burnAmount = (roundRevenue * fees.burnPercent) / 100; + const uint64 shareholdersAmount = (roundRevenue * fees.shareholdersPercent) / 100; + const uint64 qheartAmount = (roundRevenue * fees.qheartPercent) / 100; + const uint64 feesTotal = devAmount + burnAmount + shareholdersAmount + qheartAmount; + + const uint64 balanceAfterFees = contractBefore - feesTotal; + ASSERT_GE(balanceAfterFees, prize); + const uint64 balanceAfterPrizes = balanceAfterFees - prize; + const uint64 excess = (balanceAfterPrizes > holdLimit) ? (balanceAfterPrizes - holdLimit) : 0; + const uint64 expectedContractAfter = balanceAfterPrizes - excess; + const uint64 expectedWalletAfter = walletBefore + qheartAmount + excess; + + ctl.setDateTime(2025, 1, 10, 12); + ctl.forceBeginTick(); + + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), expectedContractAfter); + EXPECT_EQ(ctl.qheartBalanceOf(PULSE_QHEART_ISSUER), expectedWalletAfter); +} From cea706800c62f1e0569c812a8f86fb136c30d379 Mon Sep 17 00:00:00 2001 From: N-010 Date: Tue, 13 Jan 2026 21:30:26 +0300 Subject: [PATCH 31/77] Fixes reward --- src/contracts/Pulse.h | 29 +++++----------- test/contract_pulse.cpp | 76 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 76 insertions(+), 29 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 5cb784425..29d0c0119 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -159,7 +159,6 @@ struct PULSE : public ContractBase struct BuyTicket_locals { uint64 reward; - uint64 capacity; uint64 slotsLeft; sint64 userBalance; sint64 transferResult; @@ -235,10 +234,6 @@ struct PULSE : public ContractBase { uint64 balance; }; - struct GetBalance_locals - { - sint64 balance; - }; struct SetPrice_input { @@ -404,10 +399,6 @@ struct PULSE : public ContractBase state.schedule = PULSE_DEFAULT_SCHEDULE; state.drawHour = PULSE_DEFAULT_DRAW_HOUR; state.lastDrawDateStamp = PULSE_DEFAULT_INIT_TIME; - state.ticketCounter = 0; - setMemory(state.tickets, 0); - setMemory(state.lastWinningDigits, 0); - state.nextEpochData.clear(); enableBuyTicket(state, false); } @@ -487,7 +478,7 @@ struct PULSE : public ContractBase state.lastDrawDateStamp = locals.currentDateStamp; enableBuyTicket(state, false); - SettleRound(qpi, state, locals.settleInput, locals.settleOutput, locals.settleLocals); + CALL(SettleRound, locals.settleInput, locals.settleOutput); clearStateOnEndDraw(state); enableBuyTicket(state, !locals.isWednesday); @@ -509,10 +500,9 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } - PUBLIC_FUNCTION_WITH_LOCALS(GetBalance) + PUBLIC_FUNCTION(GetBalance) { - locals.balance = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, SELF_INDEX, SELF_INDEX); - output.balance = (locals.balance > 0) ? static_cast(locals.balance) : 0; + output.balance = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, SELF_INDEX, SELF_INDEX); } PUBLIC_PROCEDURE(SetPrice) @@ -655,8 +645,7 @@ struct PULSE : public ContractBase return; } - locals.capacity = state.tickets.capacity(); - locals.slotsLeft = (state.ticketCounter < locals.capacity) ? (locals.capacity - state.ticketCounter) : 0; + locals.slotsLeft = (state.ticketCounter < state.tickets.capacity()) ? (state.tickets.capacity() - state.ticketCounter) : 0; if (locals.slotsLeft == 0) { output.returnCode = toReturnCode(EReturnCode::TICKET_ALL_SOLD_OUT); @@ -682,7 +671,7 @@ struct PULSE : public ContractBase locals.ticket.player = qpi.invocator(); locals.ticket.digits = input.digits; state.tickets.set(state.ticketCounter, locals.ticket); - state.ticketCounter = min(state.ticketCounter + 1, locals.capacity); + state.ticketCounter = min(state.ticketCounter + 1, state.tickets.capacity()); output.returnCode = toReturnCode(EReturnCode::SUCCESS); } @@ -837,7 +826,7 @@ struct PULSE : public ContractBase } } - locals.leftAlignedReward = getLeftAlignedReward(locals.leftAlignedMatches); + locals.leftAlignedReward = getLeftAlignedReward(state, locals.leftAlignedMatches); locals.anyPositionReward = getAnyPositionReward(locals.anyPositionMatches); locals.prize = max(locals.leftAlignedReward, locals.anyPositionReward); @@ -914,11 +903,11 @@ struct PULSE : public ContractBase static bool isSellingOpen(const PULSE& state) { return (state.currentState & EState::SELLING) != 0; } - static uint64 getLeftAlignedReward(uint8 matches) + static uint64 getLeftAlignedReward(const PULSE& state, uint8 matches) { switch (matches) { - case 6: return 2400; + case 6: return 2400 * state.ticketPrice; case 5: return 600; case 4: return 150; case 3: return 30; @@ -932,7 +921,7 @@ struct PULSE : public ContractBase { switch (matches) { - case 6: return 0; + case 6: return 1500; case 5: return 400; case 4: return 50; case 3: return 8; diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index b0ec7084e..fced4e3f9 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -80,6 +80,7 @@ class PULSEChecker : public PULSE uint8 getBurnPercentInternal() const { return burnPercent; } uint8 getShareholdersPercentInternal() const { return shareholdersPercent; } uint8 getQHeartPercentInternal() const { return qheartPercent; } + const id& getTeamAddressInternal() const { return teamAddress; } const Array& getLastWinningDigits() const { return lastWinningDigits; } void setTicketCounter(uint64 value) { ticketCounter = value; } @@ -136,7 +137,7 @@ class PULSEChecker : public PULSE void callClearStateOnEndDraw() { clearStateOnEndDraw(*this); } void callClearStateOnEndEpoch() { clearStateOnEndEpoch(*this); } - uint64 callGetLeftAlignedReward(uint8 matches) const { return getLeftAlignedReward(matches); } + uint64 callGetLeftAlignedReward(uint8 matches) const { return getLeftAlignedReward(*this, matches); } uint64 callGetAnyPositionReward(uint8 matches) const { return getAnyPositionReward(matches); } }; @@ -475,7 +476,7 @@ TEST(ContractPulse_Static, SellingFlagToggles) TEST(ContractPulse_Static, RewardTablesMatchContractConstants) { ContractTestingPulse ctl; - EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(6), 2400u); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(6), 2400u * ctl.getTicketPrice().ticketPrice); EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(5), 600u); EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(4), 150u); EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(3), 30u); @@ -483,7 +484,7 @@ TEST(ContractPulse_Static, RewardTablesMatchContractConstants) EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(1), 1u); EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(0), 0u); - EXPECT_EQ(ctl.state()->callGetAnyPositionReward(6), 0u); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(6), 1500u); EXPECT_EQ(ctl.state()->callGetAnyPositionReward(5), 400u); EXPECT_EQ(ctl.state()->callGetAnyPositionReward(4), 50u); EXPECT_EQ(ctl.state()->callGetAnyPositionReward(3), 8u); @@ -609,7 +610,9 @@ TEST(ContractPulse_Private, ClearStateHelpersResetTicketData) TEST(ContractPulse_Private, SettleRoundUpdatesWinningDigitsAndPaysPrize) { ContractTestingPulse ctl; - const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + ctl.state()->setTicketPriceInternal(2); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000000); ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); const m256i digest = m256i::randomValue(); @@ -637,7 +640,7 @@ TEST(ContractPulse_Private, SettleRoundUpdatesWinningDigitsAndPaysPrize) ctl.state()->callSettleRound(qpiProc); const uint64 playerBalanceAfter = ctl.qheartBalanceOf(player); - EXPECT_EQ(playerBalanceAfter - playerBalanceBefore, 2400u); + EXPECT_EQ(playerBalanceAfter - playerBalanceBefore, ctl.state()->callGetLeftAlignedReward(6)); expectWinningDigitsUniqueAndInRange(ctl.state()->getLastWinningDigits()); } @@ -866,9 +869,13 @@ TEST(ContractPulse_System, BeginTickRunsDrawOnScheduledDay) TEST(ContractPulse_Gameplay, MultipleRoundsMultiplePlayers) { ContractTestingPulse ctl; + ctl.state()->setTicketPriceInternal(10); + ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); - const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(10000000); + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(100000000); const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.transferQHeart(issuance, ctl.pulseSelf(), 10000000); + EXPECT_EQ(ctl.getBalance().balance, 10000000); struct RoundDates { @@ -937,14 +944,63 @@ TEST(ContractPulse_Gameplay, MultipleRoundsMultiplePlayers) } } +TEST(ContractPulse_Gameplay, FeesDistributedToDevShareholdersAndQHeartWallet) +{ + ContractTestingPulse ctl; + const id shareholder = id::randomValue(); + ctl.issuePulseSharesTo(shareholder, NUMBER_OF_COMPUTORS); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + static constexpr uint8 devPercent = 10; + static constexpr uint8 burnPercent = 0; + static constexpr uint8 shareholdersPercent = 10; + static constexpr uint8 qheartPercent = 10; + const uint64 ticketPrice = static_cast(NUMBER_OF_COMPUTORS) * 10; + + EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, devPercent, burnPercent, shareholdersPercent, qheartPercent).returnCode, + static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, ticketPrice).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + ctl.endEpoch(); + + ctl.setDateTime(2025, 1, 9, 12); + ctl.beginEpoch(); + + const id player = id::randomValue(); + ctl.transferQHeart(issuance, player, ticketPrice); + EXPECT_EQ(ctl.buyTicket(player, makePlayerDigits(0, 1, 2, 3, 4, 5)).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const id devWallet = ctl.state()->getTeamAddressInternal(); + EXPECT_NE(devWallet, shareholder); + EXPECT_NE(devWallet, PULSE_QHEART_ISSUER); + + const uint64 devBefore = ctl.qheartBalanceOf(devWallet); + const uint64 shareholderBefore = ctl.qheartBalanceOf(shareholder); + const uint64 qheartWalletBefore = ctl.qheartBalanceOf(PULSE_QHEART_ISSUER); + + ctl.setDateTime(2025, 1, 10, 12); + ctl.forceBeginTick(); + + const uint64 roundRevenue = ticketPrice; + const uint64 expectedDev = (roundRevenue * devPercent) / 100; + const uint64 expectedShareholders = (roundRevenue * shareholdersPercent) / 100; + const uint64 expectedQHeart = (roundRevenue * qheartPercent) / 100; + const uint64 dividendPerShare = expectedShareholders / NUMBER_OF_COMPUTORS; + const uint64 expectedShareholderGain = dividendPerShare * NUMBER_OF_COMPUTORS; + + EXPECT_EQ(expectedShareholderGain, expectedShareholders); + EXPECT_EQ(ctl.qheartBalanceOf(devWallet), devBefore + expectedDev); + EXPECT_EQ(ctl.qheartBalanceOf(shareholder), shareholderBefore + expectedShareholderGain); + EXPECT_EQ(ctl.qheartBalanceOf(PULSE_QHEART_ISSUER), qheartWalletBefore + expectedQHeart); +} + TEST(ContractPulse_Gameplay, QHeartHoldLimitExcessTransferred) { ContractTestingPulse ctl; ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(5000000); const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; - const uint64 holdLimit = 100000; - const uint64 preFund = 500000; + static constexpr uint64 holdLimit = 100000; + static constexpr uint64 preFund = 500000; EXPECT_EQ(ctl.setQHeartHoldLimit(PULSE_QHEART_ISSUER, holdLimit).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); ctl.endEpoch(); @@ -974,7 +1030,9 @@ TEST(ContractPulse_Gameplay, QHeartHoldLimitExcessTransferred) const uint64 burnAmount = (roundRevenue * fees.burnPercent) / 100; const uint64 shareholdersAmount = (roundRevenue * fees.shareholdersPercent) / 100; const uint64 qheartAmount = (roundRevenue * fees.qheartPercent) / 100; - const uint64 feesTotal = devAmount + burnAmount + shareholdersAmount + qheartAmount; + const uint64 dividendPerShare = shareholdersAmount / NUMBER_OF_COMPUTORS; + const uint64 shareholdersPaid = dividendPerShare * NUMBER_OF_COMPUTORS; + const uint64 feesTotal = devAmount + burnAmount + shareholdersPaid + qheartAmount; const uint64 balanceAfterFees = contractBefore - feesTotal; ASSERT_GE(balanceAfterFees, prize); From 4b559b35c00d1585ae1ca9d06f3b7569f4e003c1 Mon Sep 17 00:00:00 2001 From: N-010 Date: Tue, 13 Jan 2026 21:53:09 +0300 Subject: [PATCH 32/77] Fixes for contractverify --- src/contracts/Pulse.h | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 29d0c0119..21ff674ba 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -14,8 +14,11 @@ using namespace QPI; constexpr uint16 PULSE_MAX_NUMBER_OF_PLAYERS = 1024; constexpr uint8 PULSE_PLAYER_DIGITS = 6; +constexpr uint8 PULSE_PLAYER_DIGITS_ALIGNED = PULSE_PLAYER_DIGITS + 2; constexpr uint8 PULSE_WINNING_DIGITS = 9; +constexpr uint8 PULSE_WINNING_DIGITS_ALIGNED = PULSE_WINNING_DIGITS + 7; constexpr uint8 PULSE_MAX_DIGIT = 9; +constexpr uint8 PULSE_MAX_DIGIT_ALIGNED = PULSE_MAX_DIGIT + 7; constexpr uint64 PULSE_TICKET_PRICE_DEFAULT = 200000; constexpr uint64 PULSE_QHEART_ASSET_NAME = 92712259110993ULL; // "QHEART" constexpr uint8 PULSE_DEFAULT_DEV_PERCENT = 10; @@ -67,7 +70,7 @@ struct PULSE : public ContractBase struct Ticket { id player; - Array digits; + Array digits; }; struct NextEpochData @@ -133,7 +136,7 @@ struct PULSE : public ContractBase struct ValidateDigits_input { - Array digits; + Array digits; }; struct ValidateDigits_output { @@ -141,14 +144,14 @@ struct PULSE : public ContractBase }; struct ValidateDigits_locals { - HashSet seen; + HashSet seen; uint8 idx; uint8 value; }; struct BuyTicket_input { - Array digits; + Array digits; }; struct BuyTicket_output @@ -224,7 +227,7 @@ struct PULSE : public ContractBase }; struct GetWinningDigits_output { - Array digits; + Array digits; }; struct GetBalance_input @@ -298,7 +301,7 @@ struct PULSE : public ContractBase }; struct GetRandomDigits_output { - Array digits; + Array digits; }; struct GetRandomDigits_locals { @@ -307,7 +310,7 @@ struct PULSE : public ContractBase uint8 candidate; uint8 attempts; uint8 fallback; - HashSet used; + HashSet used; }; struct SettleRound_input From 2b592df090e99e8b8f51c1218faedaa3b1b6bd28 Mon Sep 17 00:00:00 2001 From: N-010 Date: Tue, 13 Jan 2026 23:10:00 +0300 Subject: [PATCH 33/77] Proportional rule (pro-rata scaling) --- src/contracts/Pulse.h | 90 ++++++++++++++++++++++++++--------------- test/contract_pulse.cpp | 52 ++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 33 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 21ff674ba..5e83d31ef 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -322,7 +322,6 @@ struct PULSE : public ContractBase struct SettleRound_locals { uint64 i; - uint64 j; sint64 roundRevenue; sint64 devAmount; sint64 burnAmount; @@ -331,12 +330,8 @@ struct PULSE : public ContractBase sint64 balanceSigned; uint64 balance; uint64 prize; - uint64 leftAlignedReward; - uint64 anyPositionReward; - uint8 leftAlignedMatches; - uint8 leftAlignedMatchesAtOffset; - uint8 anyPositionMatches; - uint8 leftAlignedOffset; + uint64 totalPrize; + uint64 availableBalance; uint16 winningMask; m256i mixedSpectrumValue; uint64 randomSeed; @@ -801,38 +796,26 @@ struct PULSE : public ContractBase locals.balanceSigned = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, SELF_INDEX, SELF_INDEX); locals.balance = (locals.balanceSigned > 0) ? static_cast(locals.balanceSigned) : 0; + locals.totalPrize = 0; for (locals.i = 0; locals.i < state.ticketCounter; ++locals.i) { - locals.leftAlignedMatches = 0; - locals.anyPositionMatches = 0; locals.ticket = state.tickets.get(locals.i); - for (locals.leftAlignedOffset = 0; locals.leftAlignedOffset + PULSE_PLAYER_DIGITS <= PULSE_WINNING_DIGITS; ++locals.leftAlignedOffset) - { - locals.leftAlignedMatchesAtOffset = 0; - for (locals.j = 0; locals.j < PULSE_PLAYER_DIGITS; ++locals.j) - { - if (locals.ticket.digits.get(locals.j) == state.lastWinningDigits.get(locals.leftAlignedOffset + locals.j)) - { - ++locals.leftAlignedMatchesAtOffset; - } - } - if (locals.leftAlignedMatchesAtOffset > locals.leftAlignedMatches) - { - locals.leftAlignedMatches = locals.leftAlignedMatchesAtOffset; - } - } - for (locals.j = 0; locals.j < PULSE_PLAYER_DIGITS; ++locals.j) + locals.prize = computePrize(state, locals.ticket, state.lastWinningDigits, locals.winningMask); + locals.totalPrize += locals.prize; + } + + locals.availableBalance = locals.balance; + for (locals.i = 0; locals.i < state.ticketCounter; ++locals.i) + { + locals.ticket = state.tickets.get(locals.i); + locals.prize = computePrize(state, locals.ticket, state.lastWinningDigits, locals.winningMask); + + if (locals.totalPrize > 0 && locals.availableBalance < locals.totalPrize) { - if ((locals.winningMask & (1u << locals.ticket.digits.get(locals.j))) != 0) - { - ++locals.anyPositionMatches; - } + locals.prize = div(smul(static_cast(locals.prize), static_cast(locals.availableBalance)), + static_cast(locals.totalPrize)); } - locals.leftAlignedReward = getLeftAlignedReward(state, locals.leftAlignedMatches); - locals.anyPositionReward = getAnyPositionReward(locals.anyPositionMatches); - locals.prize = max(locals.leftAlignedReward, locals.anyPositionReward); - if (locals.prize > 0 && locals.balance >= locals.prize) { qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, static_cast(locals.prize), @@ -933,4 +916,45 @@ struct PULSE : public ContractBase default: return 0; } } + + static uint64 computePrize(const PULSE& state, const Ticket& ticket, const Array& winningDigits, + uint16 winningMask) + { + uint8 leftAlignedMatches = 0; + uint8 leftAlignedMatchesAtOffset = 0; + uint8 anyPositionMatches = 0; + uint8 leftAlignedOffset = 0; + uint64 leftAlignedReward = 0; + uint64 anyPositionReward = 0; + uint64 prize = 0; + + for (leftAlignedOffset = 0; leftAlignedOffset + PULSE_PLAYER_DIGITS <= PULSE_WINNING_DIGITS; ++leftAlignedOffset) + { + leftAlignedMatchesAtOffset = 0; + for (uint8 j = 0; j < PULSE_PLAYER_DIGITS; ++j) + { + if (ticket.digits.get(j) == winningDigits.get(leftAlignedOffset + j)) + { + ++leftAlignedMatchesAtOffset; + } + } + if (leftAlignedMatchesAtOffset > leftAlignedMatches) + { + leftAlignedMatches = leftAlignedMatchesAtOffset; + } + } + + for (uint8 j = 0; j < PULSE_PLAYER_DIGITS; ++j) + { + if ((winningMask & (1u << ticket.digits.get(j))) != 0) + { + ++anyPositionMatches; + } + } + + leftAlignedReward = getLeftAlignedReward(state, leftAlignedMatches); + anyPositionReward = getAnyPositionReward(anyPositionMatches); + prize = max(leftAlignedReward, anyPositionReward); + return prize; + } }; diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index fced4e3f9..faa9c9900 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -944,6 +944,58 @@ TEST(ContractPulse_Gameplay, MultipleRoundsMultiplePlayers) } } +TEST(ContractPulse_Gameplay, ProRataPayoutWhenBalanceInsufficient) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(2000000); + + EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 0, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, 1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + ctl.endEpoch(); + + ctl.setDateTime(2025, 1, 9, 12); + ctl.beginEpoch(); + + static constexpr uint64 preFund = 1000; + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.transferQHeart(issuance, ctl.pulseSelf(), preFund); + + const m256i digest(0x1234ULL, 0x5678ULL, 0x9ABCULL, 0xDEF0ULL); + etalonTick.prevSpectrumDigest = digest; + const Array& winning = deriveWinningDigits(ctl, digest); + const uint8 missing = findMissingDigit(winning); + + const Array ticketA = + makePlayerDigits(winning.get(0), winning.get(1), winning.get(2), winning.get(3), winning.get(4), winning.get(5)); + const Array ticketB = + makePlayerDigits(winning.get(0), winning.get(2), winning.get(4), winning.get(6), winning.get(8), missing); + + const id playerA = id::randomValue(); + const id playerB = id::randomValue(); + ctl.transferQHeart(issuance, playerA, ticketPrice); + ctl.transferQHeart(issuance, playerB, ticketPrice); + EXPECT_EQ(ctl.buyTicket(playerA, ticketA).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.buyTicket(playerB, ticketB).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const uint64 balanceAfterBuyA = ctl.qheartBalanceOf(playerA); + const uint64 balanceAfterBuyB = ctl.qheartBalanceOf(playerB); + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + const uint64 prizeA = computeExpectedPrize(ctl.state(), winning, ticketA); + const uint64 prizeB = computeExpectedPrize(ctl.state(), winning, ticketB); + const uint64 totalPrize = prizeA + prizeB; + ASSERT_GT(totalPrize, contractBefore); + + const uint64 expectedA = (prizeA * contractBefore) / totalPrize; + const uint64 expectedB = (prizeB * contractBefore) / totalPrize; + + ctl.setDateTime(2025, 1, 10, 12); + ctl.forceBeginTick(); + + EXPECT_EQ(ctl.qheartBalanceOf(playerA), balanceAfterBuyA + expectedA); + EXPECT_EQ(ctl.qheartBalanceOf(playerB), balanceAfterBuyB + expectedB); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore - (expectedA + expectedB)); +} + TEST(ContractPulse_Gameplay, FeesDistributedToDevShareholdersAndQHeartWallet) { ContractTestingPulse ctl; From eb3240ee05d818236459352dbde07aa33d621c60 Mon Sep 17 00:00:00 2001 From: N-010 Date: Tue, 13 Jan 2026 23:33:32 +0300 Subject: [PATCH 34/77] Removes qrp and qtf --- src/Qubic.vcxproj.filters | 6 ------ test/test.vcxproj | 1 - test/test.vcxproj.filters | 1 - 3 files changed, 8 deletions(-) diff --git a/src/Qubic.vcxproj.filters b/src/Qubic.vcxproj.filters index 251c7e079..2deb58715 100644 --- a/src/Qubic.vcxproj.filters +++ b/src/Qubic.vcxproj.filters @@ -303,12 +303,6 @@ contracts - - contracts - - - contracts - contracts diff --git a/test/test.vcxproj b/test/test.vcxproj index b71ec0b28..c2a81a054 100644 --- a/test/test.vcxproj +++ b/test/test.vcxproj @@ -121,7 +121,6 @@ - diff --git a/test/test.vcxproj.filters b/test/test.vcxproj.filters index 2f924cd8a..b107ca775 100644 --- a/test/test.vcxproj.filters +++ b/test/test.vcxproj.filters @@ -44,7 +44,6 @@ - From 86e2764a399fa23fc48d33537e49d3e8843c1751 Mon Sep 17 00:00:00 2001 From: N-010 Date: Tue, 13 Jan 2026 23:34:15 +0300 Subject: [PATCH 35/77] Change contract index --- src/contract_core/contract_def.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contract_core/contract_def.h b/src/contract_core/contract_def.h index a2494eafe..13ff6dab9 100644 --- a/src/contract_core/contract_def.h +++ b/src/contract_core/contract_def.h @@ -219,7 +219,7 @@ #undef CONTRACT_STATE_TYPE #undef CONTRACT_STATE2_TYPE -#define PULSE_CONTRACT_INDEX 22 +#define PULSE_CONTRACT_INDEX 24 #define CONTRACT_INDEX PULSE_CONTRACT_INDEX #define CONTRACT_STATE_TYPE PULSE #define CONTRACT_STATE2_TYPE PULSE2 From 9c964f8d82f172e55b748ef4c9e035ddcd3fb295 Mon Sep 17 00:00:00 2001 From: N-010 Date: Tue, 13 Jan 2026 23:36:22 +0300 Subject: [PATCH 36/77] Removes QTF and QRP --- src/contracts/QReservePool.h | 204 --- src/contracts/QThirtyFour.h | 2011 ---------------------- test/contract_qrp.cpp | 207 --- test/contract_qtf.cpp | 3121 ---------------------------------- 4 files changed, 5543 deletions(-) delete mode 100644 src/contracts/QReservePool.h delete mode 100644 src/contracts/QThirtyFour.h delete mode 100644 test/contract_qrp.cpp delete mode 100644 test/contract_qtf.cpp diff --git a/src/contracts/QReservePool.h b/src/contracts/QReservePool.h deleted file mode 100644 index c0d7fc707..000000000 --- a/src/contracts/QReservePool.h +++ /dev/null @@ -1,204 +0,0 @@ -using namespace QPI; - -// Number of available smart contracts in the QRP contract. -constexpr uint16 QRP_ALLOWED_SC_NUM = 128; -constexpr uint64 QRP_QTF_INDEX = QRP_CONTRACT_INDEX + 1; -constexpr uint64 QRP_REMOVAL_THRESHOLD_PERCENT = 75; - -struct QRP2 -{ -}; - -struct QRP : ContractBase -{ - enum class EReturnCode : uint8 - { - SUCCESS = 0, - ACCESS_DENIED = 1, - INSUFFICIENT_RESERVE = 2, - - MAX_VALUE = UINT8_MAX - }; - - static constexpr uint8 toReturnCode(const EReturnCode& code) { return static_cast(code); }; - - // Get Reserve - struct WithdrawReserve_input - { - uint64 revenue; - }; - - struct WithdrawReserve_output - { - // How much revenue is allocated to SC - uint64 allocatedRevenue; - uint8 returnCode; - }; - - struct WithdrawReserve_locals - { - Entity entity; - uint64 checkAmount; - }; - - // Add Allowed Smart Contract - struct AddAllowedSC_input - { - uint64 scIndex; - }; - - struct AddAllowedSC_output - { - uint8 returnCode; - }; - - // Remove Allowed Smart Contract - struct RemoveAllowedSC_input - { - uint64 scIndex; - }; - - struct RemoveAllowedSC_output - { - uint8 returnCode; - }; - - // Get Available Reserve - struct GetAvailableReserve_input - { - }; - - struct GetAvailableReserve_output - { - uint64 availableReserve; - }; - - struct GetAvailableReserve_locals - { - Entity entity; - }; - - // Get Allowed Smart Contract - struct GetAllowedSC_input - { - }; - - struct GetAllowedSC_output - { - Array allowedSC; - }; - - struct GetAllowedSC_locals - { - sint64 nextIndex; - uint64 arrayIndex; - }; - - INITIALIZE() - { - // Set team/developer address (owner and team are the same for now) - state.teamAddress = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, - _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); - state.ownerAddress = state.teamAddress; - - // Adds QTF to the list of allowed smart contracts. - state.allowedSmartContracts.add(id(QRP_QTF_INDEX, 0, 0, 0)); - } - - REGISTER_USER_FUNCTIONS_AND_PROCEDURES() - { - // Procedures - REGISTER_USER_PROCEDURE(WithdrawReserve, 1); - REGISTER_USER_PROCEDURE(AddAllowedSC, 2); - REGISTER_USER_PROCEDURE(RemoveAllowedSC, 3); - // Functions - REGISTER_USER_FUNCTION(GetAvailableReserve, 1); - REGISTER_USER_FUNCTION(GetAllowedSC, 2); - } - - END_EPOCH() { state.allowedSmartContracts.cleanup(); } - - PUBLIC_PROCEDURE_WITH_LOCALS(WithdrawReserve) - { - if (!state.allowedSmartContracts.contains(qpi.invocator())) - { - output.allocatedRevenue = 0; - output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); - return; - } - - qpi.getEntity(SELF, locals.entity); - locals.checkAmount = RL::max(locals.entity.incomingAmount - locals.entity.outgoingAmount, 0i64); - if (locals.checkAmount == 0 || input.revenue > locals.checkAmount) - { - output.allocatedRevenue = 0; - output.returnCode = toReturnCode(EReturnCode::INSUFFICIENT_RESERVE); - return; - } - - output.allocatedRevenue = input.revenue; - output.returnCode = toReturnCode(EReturnCode::SUCCESS); - - qpi.transfer(qpi.invocator(), output.allocatedRevenue); - } - - PUBLIC_PROCEDURE(AddAllowedSC) - { - if (qpi.invocator() != state.ownerAddress) - { - output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); - return; - } - - state.allowedSmartContracts.add(id(input.scIndex, 0, 0, 0)); - output.returnCode = toReturnCode(EReturnCode::SUCCESS); - } - - PUBLIC_PROCEDURE(RemoveAllowedSC) - { - if (qpi.invocator() != state.ownerAddress) - { - output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); - return; - } - - state.allowedSmartContracts.remove(id(input.scIndex, 0, 0, 0)); - output.returnCode = toReturnCode(EReturnCode::SUCCESS); - - state.allowedSmartContracts.cleanupIfNeeded(QRP_REMOVAL_THRESHOLD_PERCENT); - } - - PUBLIC_FUNCTION_WITH_LOCALS(GetAvailableReserve) - { - qpi.getEntity(SELF, locals.entity); - output.availableReserve = RL::max(locals.entity.incomingAmount - locals.entity.outgoingAmount, 0i64); - } - - PUBLIC_FUNCTION_WITH_LOCALS(GetAllowedSC) - { - locals.arrayIndex = 0; - locals.nextIndex = -1; - - locals.nextIndex = state.allowedSmartContracts.nextElementIndex(locals.nextIndex); - while (locals.nextIndex != NULL_INDEX) - { - output.allowedSC.set(locals.arrayIndex++, state.allowedSmartContracts.key(locals.nextIndex)); - locals.nextIndex = state.allowedSmartContracts.nextElementIndex(locals.nextIndex); - } - } - -protected: - /** - * @brief Address of the team managing the lottery contract. - * Initialized to a zero address. - */ - id teamAddress; - - /** - * @brief Address of the owner of the lottery contract. - * Initialized to a zero address. - */ - id ownerAddress; - - HashSet allowedSmartContracts; -}; diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h deleted file mode 100644 index 7033d8831..000000000 --- a/src/contracts/QThirtyFour.h +++ /dev/null @@ -1,2011 +0,0 @@ -using namespace QPI; - -// --- Core game parameters ---------------------------------------------------- -constexpr uint64 QTF_MAX_NUMBER_OF_PLAYERS = 1024; -constexpr uint64 QTF_RANDOM_VALUES_COUNT = 4; -constexpr uint64 QTF_MAX_RANDOM_VALUE = 30; -constexpr uint64 QTF_TICKET_PRICE = 1000000; - -// Baseline split for k2/k3 when FR is OFF (per spec: k3=40%, k2=28% of Winners block). -// Initial 32% of Winners block is unallocated; overflow will also include unawarded k2/k3 funds. -constexpr uint64 QTF_BASE_K3_SHARE_BP = 4000; // 40% of winners block to k3 -constexpr uint64 QTF_BASE_K2_SHARE_BP = 2800; // 28% of winners block to k2 - -// --- Fast-Recovery (FR) parameters (spec defaults) -------------------------- -// Fast-Recovery base redirect percentages (applied when FR=ON, capped at available amounts) -constexpr uint64 QTF_FR_DEV_REDIRECT_BP = 100; // 1.00% of R (base redirect) -constexpr uint64 QTF_FR_DIST_REDIRECT_BP = 100; // 1.00% of R (base redirect) - -// Deficit-driven extra redirect parameters (dynamic, no hard N threshold) -// The extra redirect is calculated based on: -// - Deficit: Δ = max(0, targetJackpot - currentJackpot) -// - Expected rounds to k=4: E_k4(N) = 1 / (1 - (1-p4)^N) -// - Horizon: H = min(E_k4(N), R_goal_rounds_cap) -// - Needed gain: g_need = max(0, Δ/H - baseGain) -// - Extra percentage: extra_pp = clamp(g_need / R, 0, extra_max) -// - Split equally: dev_extra = dist_extra = extra_pp / 2 -constexpr uint64 QTF_FR_EXTRA_MAX_BP = 70; // Maximum extra redirect: 0.70% of R total (0.35% each Dev/Dist) -constexpr uint64 QTF_FR_GOAL_ROUNDS_CAP = 50; // Cap on expected rounds horizon H for deficit calculation -constexpr uint64 QTF_FIXED_POINT_SCALE = 1000000; // Scale for fixed-point arithmetic (6 decimals precision) - -// Probability constants for k=4 win (exact combinatorics: 4-of-30) -// p4 = C(4,4) * C(26,0) / C(30,4) = 1 / 27405 -constexpr uint64 QTF_P4_DENOMINATOR = 27405; // Denominator for k=4 probability (1/27405) -constexpr uint64 QTF_FR_WINNERS_RAKE_BP = 500; // 5% of winners block from k3 -constexpr uint64 QTF_FR_ALPHA_BP = 500; // alpha = 0.05 -> 95% overflow to jackpot -constexpr uint8 QTF_FR_POST_K4_WINDOW_ROUNDS = 50; -constexpr uint8 QTF_FR_HYSTERESIS_ROUNDS = 3; - -// --- Floors and reserve safety ---------------------------------------------- -constexpr uint64 QTF_K2_FLOOR_MULT = 1; // numerator for 0.5 * P (we divide by 2) -constexpr uint64 QTF_K2_FLOOR_DIV = 2; -constexpr uint64 QTF_K3_FLOOR_MULT = 5; // 5 * P -constexpr uint64 QTF_TOPUP_PER_WINNER_CAP_MULT = 25; // 25 * P -constexpr uint64 QTF_TOPUP_RESERVE_PCT_BP = 1000; // 10% of reserve per round -constexpr uint64 QTF_RESERVE_SOFT_FLOOR_MULT = 20; // keep at least 20 * P in reserve - -// Baseline overflow split (reserve share in basis points). If spec is updated, adjust here. -constexpr uint64 QTF_BASELINE_OVERFLOW_ALPHA_BP = 5000; // 50% reserve / 50% jackpot - -// Default fee percentages (fallback if RL::GetFees fails) -constexpr uint8 QTF_DEFAULT_DEV_PERCENT = 10; -constexpr uint8 QTF_DEFAULT_DIST_PERCENT = 20; -constexpr uint8 QTF_DEFAULT_BURN_PERCENT = 2; -constexpr uint8 QTF_DEFAULT_WINNERS_PERCENT = 68; - -// Maximum attempts to generate unique random value before fallback -constexpr uint8 QTF_MAX_RANDOM_GENERATION_ATTEMPTS = 100; - -constexpr uint64 QTF_DEFAULT_TARGET_JACKPOT = 1000000000ULL; // 1 billion QU (1B) -constexpr uint8 QTF_DEFAULT_SCHEDULE = 1 << SATURDAY | 1u << WEDNESDAY; -constexpr uint8 QTF_DEFAULT_DRAW_HOUR = 11; // 11:00 UTC -constexpr uint32 QTF_DEFAULT_INIT_TIME = 22u << 9 | 4u << 5 | 13u; // RL_DEFAULT_INIT_TIME - -const id QTF_ADDRESS_DEV_TEAM = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, - _L, _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); -const id QTF_RANDOM_LOTTERY_CONTRACT_ID = id(RL_CONTRACT_INDEX, 0, 0, 0); -constexpr uint64 QTF_RANDOM_LOTTERY_ASSET_NAME = 19538; // RL -const id QTF_RESERVE_POOL_CONTRACT_ID = id(QRP_CONTRACT_INDEX, 0, 0, 0); - -struct QTF2 -{ -}; - -struct QTF : ContractBase -{ - enum class EReturnCode : uint8 - { - SUCCESS, - INVALID_TICKET_PRICE, - MAX_PLAYERS_REACHED, - ACCESS_DENIED, - INVALID_NUMBERS, - INVALID_VALUE, - TICKET_SELLING_CLOSED, - - MAX_VALUE = UINT8_MAX - }; - - static constexpr uint8 toReturnCode(const EReturnCode& code) { return static_cast(code); }; - - enum EState : uint8 - { - STATE_NONE = 0, - STATE_SELLING = 1 << 0 - }; - - struct PlayerData - { - id player; - Array randomValues; - }; - - struct WinnerData - { - Array winners; - Array winnerValues; - uint64 winnerCounter; - uint16 epoch; - }; - - struct NextEpochData - { - void clear() - { - newTicketPrice = 0; - newTargetJackpot = 0; - newSchedule = 0; - newDrawHour = 0; - } - - void apply(QTF& state) const - { - if (newTicketPrice > 0) - { - state.ticketPrice = newTicketPrice; - } - if (newTargetJackpot > 0) - { - state.targetJackpot = newTargetJackpot; - } - if (newSchedule > 0) - { - state.schedule = newSchedule; - } - if (newDrawHour > 0) - { - state.drawHour = newDrawHour; - } - } - - uint64 newTicketPrice; - uint64 newTargetJackpot; - uint8 newSchedule; - uint8 newDrawHour; - }; - - struct PoolsSnapshot - { - uint64 jackpot; - uint64 reserve; // Available reserve from QRP (not including locked amounts) - uint64 targetJackpot; - uint8 frActive; - uint16 roundsSinceK4; - }; - - struct PoolsSnapshot_input - { - }; - - struct PoolsSnapshot_output - { - PoolsSnapshot pools; - }; - - // ValidateNumbers: Check if all numbers are valid [1..30] and unique - struct ValidateNumbers_input - { - Array numbers; // Numbers to validate - }; - struct ValidateNumbers_output - { - bit isValid; // true if all numbers valid and unique - }; - struct ValidateNumbers_locals - { - HashSet seen; - uint8 idx; - uint8 value; - }; - - // Buy Ticket - struct BuyTicket_input - { - Array randomValues; - }; - struct BuyTicket_output - { - uint8 returnCode; - }; - struct BuyTicket_locals - { - // CALL parameters for ValidateNumbers - ValidateNumbers_input validateInput; - ValidateNumbers_output validateOutput; - uint64 excess; - }; - - // Set Price - struct SetPrice_input - { - uint64 newPrice; - }; - struct SetPrice_output - { - uint8 returnCode; - }; - - // Set Schedule - struct SetSchedule_input - { - uint8 newSchedule; - }; - struct SetSchedule_output - { - uint8 returnCode; - }; - - // Set draw hour - struct SetDrawHour_input - { - uint8 newDrawHour; - }; - struct SetDrawHour_output - { - uint8 returnCode; - }; - - // Set Target Jackpot - struct SetTargetJackpot_input - { - uint64 newTargetJackpot; - }; - struct SetTargetJackpot_output - { - uint8 returnCode; - }; - - // Return All Tickets (refund all players) - struct ReturnAllTickets_input - { - }; - struct ReturnAllTickets_output - { - }; - struct ReturnAllTickets_locals - { - uint64 i; // Loop counter for mass-refund - }; - - // Check Contract Balance - struct CheckContractBalance_input - { - uint64 expectedRevenue; // Expected revenue to compare against balance - }; - struct CheckContractBalance_output - { - bit hasEnough; // true if balance >= expectedRevenue - uint64 actualBalance; // Current contract balance - }; - struct CheckContractBalance_locals - { - Entity entity; - }; - - // Calculate Base Gain (FR base carry growth estimation, excluding extra deficit-driven redirect) - struct CalculateBaseGain_input - { - uint64 revenue; // Round revenue (N * ticketPrice) - uint64 winnersBlock; // 68% of revenue allocated to winners - }; - struct CalculateBaseGain_output - { - uint64 baseGain; // Estimated carry gain in qu - }; - struct CalculateBaseGain_locals - { - uint64 devRedirect; - uint64 distRedirect; - uint64 winnersRake; - uint64 estimatedOverflow; - uint64 overflowToCarry; - }; - - // PowerFixedPoint: Computes (base^exp) in fixed-point arithmetic - struct PowerFixedPoint_input - { - uint64 base; // Base value in fixed-point (scaled by QTF_FIXED_POINT_SCALE) - uint64 exp; // Exponent (integer) - }; - struct PowerFixedPoint_output - { - uint64 result; // base^exp in fixed-point - }; - struct PowerFixedPoint_locals - { - uint64 tmpBase; - uint64 expCopy; // Copy of exp (modified during computation) - }; - - // CalculateExpectedRoundsToK4: E_k4(N) = 1 / (1 - (1-p4)^N) - struct CalculateExpectedRoundsToK4_input - { - uint64 N; // Number of tickets - }; - struct CalculateExpectedRoundsToK4_output - { - uint64 expectedRounds; // E_k4(N) in fixed-point - }; - struct CalculateExpectedRoundsToK4_locals - { - uint64 oneMinusP4; - uint64 pow1mP4N; - uint64 denomFP; - PowerFixedPoint_input pfInput; - PowerFixedPoint_output pfOutput; - }; - - // Calculate Extra Redirect BP (deficit-driven) - struct CalculateExtraRedirectBP_input - { - uint64 N; // Number of tickets - uint64 delta; // Deficit to target jackpot - uint64 revenue; // Round revenue - uint64 baseGain; // Base carry gain per round (without extra) - }; - struct CalculateExtraRedirectBP_output - { - uint64 extraBP; // Extra redirect in basis points (total, to be split 50/50 Dev/Dist) - }; - struct CalculateExtraRedirectBP_locals - { - uint64 horizonFP; - uint64 horizon; - uint64 requiredGainPerRound; - uint64 gNeed; - uint64 extraBPTemp; - CalculateExpectedRoundsToK4_input calcE4Input; - CalculateExpectedRoundsToK4_output calcE4Output; - }; - - // GetRandomValues: Generate 4 unique random values from [1..30] - struct GetRandomValues_input - { - uint64 seed; // Random seed from K12 - }; - struct GetRandomValues_output - { - Array values; // 4 unique random values [1..30] - }; - struct GetRandomValues_locals - { - uint64 tempValue; - uint8 index; - uint8 candidate; - uint8 attempts; - uint8 fallback; - HashSet used; - }; - - // CalcReserveTopUp: Calculate safe reserve top-up amount - struct CalcReserveTopUp_input - { - uint64 totalQRPBalance; // Actual QRP balance (for 10% limit and soft floor) - uint64 needed; - uint64 perWinnerCapTotal; - uint64 ticketPrice; - }; - struct CalcReserveTopUp_output - { - uint64 topUpAmount; - }; - struct CalcReserveTopUp_locals - { - uint64 softFloor; - uint64 availableAboveFloor; - uint64 maxPerRound; - }; - - // ProcessTierPayout: Unified tier payout processing (k2/k3) - struct ProcessTierPayout_input - { - uint64 floorPerWinner; // Floor payout per winner (0.5*P for k2, 5*P for k3) - uint64 winnerCount; // Number of winners in this tier - uint64 payoutPool; // Initial payout pool for this tier - uint64 perWinnerCap; // Per-winner cap (25*P) - uint64 totalQRPBalance; // QRP balance for safety limits - uint64 ticketPrice; // Current ticket price - }; - struct ProcessTierPayout_output - { - uint64 perWinnerPayout; // Calculated per-winner payout - uint64 overflow; // Overflow amount (unused funds) - uint64 topUpReceived; // Amount received from QRP top-up - }; - struct ProcessTierPayout_locals - { - uint64 floorTotalNeeded; - uint64 finalPool; - uint64 qrpRequested; - CalcReserveTopUp_input calcTopUpInput; - CalcReserveTopUp_output calcTopUpOutput; - QRP::WithdrawReserve_input qrpGetReserveInput; - QRP::WithdrawReserve_output qrpGetReserveOutput; - }; - - // Ticket Price - struct GetTicketPrice_input - { - }; - struct GetTicketPrice_output - { - uint64 ticketPrice; - }; - - // Next Epoch Data - struct GetNextEpochData_input - { - }; - struct GetNextEpochData_output - { - NextEpochData nextEpochData; - }; - - // Schedule - struct GetSchedule_input - { - }; - struct GetSchedule_output - { - uint8 schedule; - }; - - // Winner Data - struct GetWinnerData_input - { - }; - - struct GetWinnerData_output - { - WinnerData winnerData; - }; - - // Pools - struct GetPools_input - { - }; - struct GetPools_output - { - PoolsSnapshot pools; - }; - struct GetPools_locals - { - QRP::GetAvailableReserve_input qrpInput; - QRP::GetAvailableReserve_output qrpOutput; - }; - - // Draw hour - struct GetDrawHour_input - { - }; - struct GetDrawHour_output - { - uint8 drawHour; - }; - - struct GetState_input - { - }; - struct GetState_output - { - uint8 currentState; - }; - - struct SettleEpoch_input - { - }; - - struct SettleEpoch_output - { - }; - - struct CountMatches_input - { - Array playerValues; - Array winningValues; - }; - - struct CountMatches_output - { - uint8 matches; - }; - struct CountMatches_locals - { - uint64 i; - uint32 maskA; - uint32 maskB; - uint8 randomValue; - }; - - struct GetFees_input - { - }; - - struct GetFees_output - { - uint8 teamFeePercent; // Team share in percent - uint8 distributionFeePercent; // Distribution/shareholders share in percent - uint8 winnerFeePercent; // Winner share in percent - uint8 burnPercent; // Burn share in percent - uint8 returnCode; - }; - - struct GetFees_locals - { - RL::GetFees_input feesInput; - RL::GetFees_output feesOutput; - }; - - // EstimateFRJackpotGrowth: Calculate minimum expected jackpot growth in FR mode - // Used for testing to verify 95% overflow bias is working correctly - struct EstimateFRJackpotGrowth_input - { - uint64 revenue; // Total revenue (ticketPrice * numPlayers) - uint64 winnersPercent; // Winners block percentage (typically 68) - }; - struct EstimateFRJackpotGrowth_output - { - uint64 minJackpotGrowth; // Minimum expected jackpot growth - uint64 winnersRake; // 5% of winners block - uint64 overflowToJackpot; // 95% of overflow - uint64 devRedirect; // 1% of revenue - uint64 distRedirect; // 1% of revenue - }; - struct EstimateFRJackpotGrowth_locals - { - uint64 winnersBlock; - uint64 winnersBlockAfterRake; - uint64 k3Pool; - uint64 k2Pool; - uint64 winnersOverflow; - uint64 reserveAdd; - }; - - // CalculatePrizePools: Calculate k2/k3 prize pools from revenue - // Reusable function for both settlement and estimation - struct CalculatePrizePools_input - { - uint64 revenue; // Total revenue (ticketPrice * numberOfPlayers) - bit applyFRRake; // Whether to apply 5% FR rake - }; - struct CalculatePrizePools_output - { - uint64 winnersBlock; // Winners block after fees - uint64 winnersRake; // 5% rake (if FR active) - uint64 k2Pool; // 28% of winners block (after rake) - uint64 k3Pool; // 40% of winners block (after rake) - }; - struct CalculatePrizePools_locals - { - GetFees_input feesInput; - GetFees_output feesOutput; - uint64 winnersBlockBeforeRake; - }; - - // EstimatePrizePayouts: Calculate estimated prize payouts for k=2 and k=3 tiers - // Based on current ticket sales and number of winners per tier - struct EstimatePrizePayouts_input - { - uint64 k2WinnerCount; // Number of k=2 winners (estimated or actual) - uint64 k3WinnerCount; // Number of k=3 winners (estimated or actual) - }; - struct EstimatePrizePayouts_output - { - uint64 k2PayoutPerWinner; // Estimated payout per k=2 winner - uint64 k3PayoutPerWinner; // Estimated payout per k=3 winner - uint64 k2MinFloor; // Minimum guaranteed payout for k=2 (0.5*P) - uint64 k3MinFloor; // Minimum guaranteed payout for k=3 (5*P) - uint64 perWinnerCap; // Maximum payout per winner (25*P) - uint64 totalRevenue; // Total revenue from ticket sales - uint64 k2Pool; // Total pool for k=2 tier - uint64 k3Pool; // Total pool for k=3 tier - }; - struct EstimatePrizePayouts_locals - { - uint64 revenue; - uint64 k2FloorTotal; - uint64 k3FloorTotal; - uint64 k2PayoutPoolEffective; - uint64 k3PayoutPoolEffective; - CalculatePrizePools_input calcPoolsInput; - CalculatePrizePools_output calcPoolsOutput; - }; - - struct SettleEpoch_locals - { - Array winningValues; - ReturnAllTickets_input returnAllTicketsInput; - ReturnAllTickets_output returnAllTicketsOutput; - ReturnAllTickets_locals returnAllTicketsLocals; - CheckContractBalance_input checkBalanceInput; - CheckContractBalance_output checkBalanceOutput; - CheckContractBalance_locals checkBalanceLocals; - CountMatches_input countMatchesInput; - CountMatches_output countMatchesOutput; - uint16 currentEpoch; - uint64 revenue; // ticketPrice * players count - uint64 winnersBlock; - uint64 k2Pool; - uint64 k3Pool; - uint64 carryAdd; - uint64 reserveAdd; - uint64 winnersOverflow; - uint64 devPayout; // Dev after redirects - uint64 distPayout; // Distribution after redirects - uint64 burnAmount; - uint64 devRedirect; - uint64 distRedirect; - uint64 winnersRake; - uint64 k2PayoutPool; - uint64 k3PayoutPool; - uint64 k2PerWinner; - uint64 k3PerWinner; - uint64 countK2; - uint64 countK3; - uint64 countK4; - uint64 totalDevRedirectBP; // Total dev redirect in basis points (base + extra) - uint64 totalDistRedirectBP; // Total dist redirect in basis points (base + extra) - uint64 perWinnerCap; // Per-winner payout cap (25*P) - uint64 jackpotPerK4Winner; // Jackpot share per k4 winner - uint64 totalJackpotContribution; // Total amount to add to jackpot - uint64 i; - uint8 matches; - bit shouldActivateFR; - // Deficit-driven extra redirect calculation - uint64 delta; // Deficit: max(0, targetJackpot - jackpot) - uint64 devExtraBP; // Dev share of extra: extraRedirectBP / 2 - uint64 distExtraBP; // Dist share of extra: extraRedirectBP / 2 - // CALL parameters for CalculatePrizePools (shared function) - CalculatePrizePools_input calcPoolsInput; - CalculatePrizePools_output calcPoolsOutput; - // CALL parameters for CalculateBaseGain - CalculateBaseGain_input calcBaseGainInput; - CalculateBaseGain_output calcBaseGainOutput; - // CALL parameters for CalculateExtraRedirectBP - CalculateExtraRedirectBP_input calcExtraInput; - CalculateExtraRedirectBP_output calcExtraOutput; - // CALL parameters for GetRandomValues - GetRandomValues_input getRandomInput; - GetRandomValues_output getRandomOutput; - // CALL parameters for ProcessTierPayout (unified k2/k3 processing) - ProcessTierPayout_input tierPayoutInput; - ProcessTierPayout_output tierPayoutOutput; - // CALL_OTHER_CONTRACT parameters for QRP (external reserve pool) - QRP::WithdrawReserve_input qrpGetReserveInput; - QRP::WithdrawReserve_output qrpGetReserveOutput; - QRP::GetAvailableReserve_input qrpGetAvailableInput; - QRP::GetAvailableReserve_output qrpGetAvailableOutput; - uint64 qrpRequested; // Amount requested from QRP - uint64 qrpReceived; // Amount actually received from QRP - uint64 totalQRPBalance; // Total balance in QRP (for safety limits) - GetFees_input feesInput; - GetFees_output feesOutput; - uint64 dividendPerShare; - Asset rlAsset; - AssetPossessionIterator rlIter; - uint64 rlTotalShares; - uint64 rlPayback; - uint64 rlShares; - // Cache for countMatches results to avoid redundant calculations - Array cachedMatches; - }; - - struct END_EPOCH_locals - { - SettleEpoch_locals settlement; - SettleEpoch_input settleInput; - SettleEpoch_output settleOutput; - }; - - struct BEGIN_TICK_locals - { - SettleEpoch_input settleInput; - SettleEpoch_output settleOutput; - uint32 currentDateStamp; - uint8 currentDayOfWeek; - uint8 currentHour; - bit isWednesday; - bit isScheduledToday; - }; - - // Contract lifecycle methods - INITIALIZE() - { - state.teamAddress = QTF_ADDRESS_DEV_TEAM; - state.ownerAddress = state.teamAddress; - state.ticketPrice = QTF_TICKET_PRICE; - state.targetJackpot = QTF_DEFAULT_TARGET_JACKPOT; - state.overflowAlphaBP = QTF_BASELINE_OVERFLOW_ALPHA_BP; - state.schedule = QTF_DEFAULT_SCHEDULE; - state.drawHour = QTF_DEFAULT_DRAW_HOUR; - state.lastDrawDateStamp = QTF_DEFAULT_INIT_TIME; - state.currentState = STATE_NONE; - } - - REGISTER_USER_FUNCTIONS_AND_PROCEDURES() - { - REGISTER_USER_PROCEDURE(BuyTicket, 1); - REGISTER_USER_PROCEDURE(SetPrice, 2); - REGISTER_USER_PROCEDURE(SetSchedule, 3); - REGISTER_USER_PROCEDURE(SetTargetJackpot, 4); - REGISTER_USER_PROCEDURE(SetDrawHour, 5); - REGISTER_USER_FUNCTION(GetTicketPrice, 1); - REGISTER_USER_FUNCTION(GetNextEpochData, 2); - REGISTER_USER_FUNCTION(GetWinnerData, 3); - REGISTER_USER_FUNCTION(GetPools, 4); - REGISTER_USER_FUNCTION(GetSchedule, 5); - REGISTER_USER_FUNCTION(GetDrawHour, 6); - REGISTER_USER_FUNCTION(GetState, 7); - REGISTER_USER_FUNCTION(GetFees, 8); - REGISTER_USER_FUNCTION(EstimatePrizePayouts, 9); - } - - BEGIN_EPOCH() - { - applyNextEpochData(state); - - if (state.schedule == 0) - { - state.schedule = QTF_DEFAULT_SCHEDULE; - } - if (state.drawHour == 0) - { - state.drawHour = QTF_DEFAULT_DRAW_HOUR; - } - RL::makeDateStamp(qpi.year(), qpi.month(), qpi.day(), state.lastDrawDateStamp); - clearEpochState(state); - enableBuyTicket(state, state.lastDrawDateStamp != RL_DEFAULT_INIT_TIME); - } - - // Settle and reset at epoch end (uses locals buffer) - END_EPOCH_WITH_LOCALS() - { - enableBuyTicket(state, false); - CALL(SettleEpoch, locals.settleInput, locals.settleOutput); - clearEpochState(state); - } - - // Scheduled draw processor - BEGIN_TICK_WITH_LOCALS() - { - // Throttle: run logic only once per RL_TICK_UPDATE_PERIOD ticks - if (mod(qpi.tick(), static_cast(RL_TICK_UPDATE_PERIOD)) != 0) - { - return; - } - - locals.currentHour = qpi.hour(); - if (locals.currentHour < state.drawHour) - { - return; - } - - locals.currentDayOfWeek = qpi.dayOfWeek(qpi.year(), qpi.month(), qpi.day()); - RL::makeDateStamp(qpi.year(), qpi.month(), qpi.day(), locals.currentDateStamp); - - // Wait for valid time initialization - if (locals.currentDateStamp == QTF_DEFAULT_INIT_TIME) - { - enableBuyTicket(state, false); - state.lastDrawDateStamp = QTF_DEFAULT_INIT_TIME; - return; - } - - // First valid date after init: just record and exit - if (state.lastDrawDateStamp == QTF_DEFAULT_INIT_TIME && locals.currentDateStamp != QTF_DEFAULT_INIT_TIME) - { - enableBuyTicket(state, true); - if (locals.currentDayOfWeek == WEDNESDAY) - { - state.lastDrawDateStamp = locals.currentDateStamp; - } - else - { - state.lastDrawDateStamp = 0; - } - - return; - } - - if (locals.currentDateStamp == state.lastDrawDateStamp) - { - return; - } - - locals.isWednesday = (locals.currentDayOfWeek == WEDNESDAY); - locals.isScheduledToday = ((state.schedule & (1u << locals.currentDayOfWeek)) != 0); - - // Always draw on Wednesday; otherwise require schedule bit. - if (!locals.isWednesday && !locals.isScheduledToday) - { - return; - } - - state.lastDrawDateStamp = locals.currentDateStamp; - - // Pause selling during draw/settlement. - enableBuyTicket(state, false); - - CALL(SettleEpoch, locals.settleInput, locals.settleOutput); - clearEpochState(state); - applyNextEpochData(state); - enableBuyTicket(state, !locals.isWednesday); - } - - POST_INCOMING_TRANSFER() - { - switch (input.type) - { - case TransferType::standardTransaction: - // Return any funds sent via standard transaction - if (input.amount > 0) - { - qpi.transfer(input.sourceId, input.amount); - } - break; - default: break; - } - } - - // Procedures - PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicket) - { - if ((state.currentState & STATE_SELLING) == 0) - { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } - - output.returnCode = toReturnCode(EReturnCode::TICKET_SELLING_CLOSED); - return; - } - - if (state.numberOfPlayers >= QTF_MAX_NUMBER_OF_PLAYERS) - { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } - - output.returnCode = toReturnCode(EReturnCode::MAX_PLAYERS_REACHED); - return; - } - - if (qpi.invocationReward() < state.ticketPrice) - { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } - - output.returnCode = toReturnCode(EReturnCode::INVALID_TICKET_PRICE); - return; - } - - locals.validateInput.numbers = input.randomValues; - CALL(ValidateNumbers, locals.validateInput, locals.validateOutput); - if (!locals.validateOutput.isValid) - { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } - output.returnCode = toReturnCode(EReturnCode::INVALID_NUMBERS); - return; - } - - addPlayerInfo(state, qpi.invocator(), input.randomValues); - - // If overpaid, accept ticket and return excess to invocator. - // Important: refund excess ONLY after validation, otherwise invalid tickets could be over-refunded. - if (qpi.invocationReward() > state.ticketPrice) - { - locals.excess = qpi.invocationReward() - state.ticketPrice; - if (locals.excess > 0) - { - qpi.transfer(qpi.invocator(), locals.excess); - } - } - - output.returnCode = toReturnCode(EReturnCode::SUCCESS); - } - - PUBLIC_PROCEDURE(SetPrice) - { - if (qpi.invocator() != state.ownerAddress) - { - output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); - return; - } - - if (input.newPrice == 0) - { - output.returnCode = toReturnCode(EReturnCode::INVALID_TICKET_PRICE); - return; - } - - state.nextEpochData.newTicketPrice = input.newPrice; - output.returnCode = toReturnCode(EReturnCode::SUCCESS); - } - - PUBLIC_PROCEDURE(SetSchedule) - { - if (qpi.invocator() != state.ownerAddress) - { - output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); - return; - } - - if (input.newSchedule == 0) - { - output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); - return; - } - - state.nextEpochData.newSchedule = input.newSchedule; - output.returnCode = toReturnCode(EReturnCode::SUCCESS); - } - - PUBLIC_PROCEDURE(SetTargetJackpot) - { - if (qpi.invocator() != state.ownerAddress) - { - output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); - return; - } - - if (input.newTargetJackpot == 0) - { - output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); - return; - } - - state.nextEpochData.newTargetJackpot = input.newTargetJackpot; - output.returnCode = toReturnCode(EReturnCode::SUCCESS); - } - - PUBLIC_PROCEDURE(SetDrawHour) - { - if (qpi.invocator() != state.ownerAddress) - { - output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); - return; - } - - if (input.newDrawHour == 0 || input.newDrawHour > 23) - { - output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); - return; - } - - state.nextEpochData.newDrawHour = input.newDrawHour; - output.returnCode = toReturnCode(EReturnCode::SUCCESS); - } - - // Functions - PUBLIC_FUNCTION(GetTicketPrice) { output.ticketPrice = state.ticketPrice; } - PUBLIC_FUNCTION(GetNextEpochData) { output.nextEpochData = state.nextEpochData; } - PUBLIC_FUNCTION(GetWinnerData) { output.winnerData = state.lastWinnerData; } - PUBLIC_FUNCTION_WITH_LOCALS(GetPools) - { - output.pools.jackpot = state.jackpot; - CALL_OTHER_CONTRACT_FUNCTION(QRP, GetAvailableReserve, locals.qrpInput, locals.qrpOutput); - output.pools.reserve = locals.qrpOutput.availableReserve; - output.pools.targetJackpot = state.targetJackpot; - output.pools.frActive = state.frActive; - output.pools.roundsSinceK4 = state.frRoundsSinceK4; - } - PUBLIC_FUNCTION(GetSchedule) { output.schedule = state.schedule; } - PUBLIC_FUNCTION(GetDrawHour) { output.drawHour = state.drawHour; } - PUBLIC_FUNCTION(GetState) { output.currentState = static_cast(state.currentState); } - PUBLIC_FUNCTION_WITH_LOCALS(GetFees) - { - CALL_OTHER_CONTRACT_FUNCTION(RL, GetFees, locals.feesInput, locals.feesOutput); - output.teamFeePercent = locals.feesOutput.teamFeePercent; - output.distributionFeePercent = locals.feesOutput.distributionFeePercent; - output.burnPercent = locals.feesOutput.burnPercent; - output.winnerFeePercent = locals.feesOutput.winnerFeePercent; - - // Sanity check: if RL returns invalid fees, use defaults - if (output.teamFeePercent == 0 || output.distributionFeePercent == 0 || output.winnerFeePercent == 0) - { - output.teamFeePercent = QTF_DEFAULT_DEV_PERCENT; - output.distributionFeePercent = QTF_DEFAULT_DIST_PERCENT; - output.burnPercent = QTF_DEFAULT_BURN_PERCENT; - output.winnerFeePercent = QTF_DEFAULT_WINNERS_PERCENT; - } - - output.returnCode = toReturnCode(EReturnCode::SUCCESS); - } - - PUBLIC_FUNCTION_WITH_LOCALS(EstimatePrizePayouts) - { - // Calculate total revenue from current ticket sales - locals.revenue = smul(state.ticketPrice, state.numberOfPlayers); - output.totalRevenue = locals.revenue; - - // Set minimum floors and cap - output.k2MinFloor = div(smul(state.ticketPrice, QTF_K2_FLOOR_MULT), QTF_K2_FLOOR_DIV); // 0.5*P - output.k3MinFloor = smul(state.ticketPrice, QTF_K3_FLOOR_MULT); // 5*P - output.perWinnerCap = smul(state.ticketPrice, QTF_TOPUP_PER_WINNER_CAP_MULT); // 25*P - - if (locals.revenue == 0 || state.numberOfPlayers == 0) - { - // No tickets sold, no payouts - output.k2PayoutPerWinner = 0; - output.k3PayoutPerWinner = 0; - output.k2Pool = 0; - output.k3Pool = 0; - return; - } - - // Use shared CalculatePrizePools function to compute pools - locals.calcPoolsInput.revenue = locals.revenue; - locals.calcPoolsInput.applyFRRake = state.frActive; - CALL(CalculatePrizePools, locals.calcPoolsInput, locals.calcPoolsOutput); - - output.k2Pool = locals.calcPoolsOutput.k2Pool; - output.k3Pool = locals.calcPoolsOutput.k3Pool; - - // Calculate k2 payout per winner - if (input.k2WinnerCount > 0) - { - locals.k2FloorTotal = smul(output.k2MinFloor, input.k2WinnerCount); - locals.k2PayoutPoolEffective = output.k2Pool; - - // Note: This is an estimate - actual implementation may top up from reserve - // If pool insufficient, we show floor; otherwise calculate actual per-winner amount - if (locals.k2PayoutPoolEffective >= locals.k2FloorTotal) - { - output.k2PayoutPerWinner = RL::min(output.perWinnerCap, div(locals.k2PayoutPoolEffective, input.k2WinnerCount)); - } - else - { - // Pool insufficient, would need reserve top-up - show floor as estimate - output.k2PayoutPerWinner = output.k2MinFloor; - } - } - else - { - // No winners - show what a single winner would get - output.k2PayoutPerWinner = RL::min(output.perWinnerCap, output.k2Pool); - } - - // Calculate k3 payout per winner - if (input.k3WinnerCount > 0) - { - locals.k3FloorTotal = smul(output.k3MinFloor, input.k3WinnerCount); - locals.k3PayoutPoolEffective = output.k3Pool; - - // Note: This is an estimate - actual implementation may top up from reserve - if (locals.k3PayoutPoolEffective >= locals.k3FloorTotal) - { - output.k3PayoutPerWinner = RL::min(output.perWinnerCap, div(locals.k3PayoutPoolEffective, input.k3WinnerCount)); - } - else - { - // Pool insufficient, would need reserve top-up - show floor as estimate - output.k3PayoutPerWinner = output.k3MinFloor; - } - } - else - { - // No winners - show what a single winner would get - output.k3PayoutPerWinner = RL::min(output.perWinnerCap, output.k3Pool); - } - } - -protected: - static void clearEpochState(QTF& state) { clearPlayerData(state); } - - static void applyNextEpochData(QTF& state) - { - state.nextEpochData.apply(state); - state.nextEpochData.clear(); - } - - static void enableBuyTicket(QTF& state, bool bEnable) - { - if (bEnable) - { - state.currentState = static_cast(state.currentState | STATE_SELLING); - } - else - { - state.currentState = static_cast(state.currentState & static_cast(~STATE_SELLING)); - } - } - - // ========== Helper static functions ========== - - static void mix64(const uint64& x, uint64& outValue) - { - outValue = x; - outValue ^= outValue >> 30; - outValue *= 0xbf58476d1ce4e5b9ULL; - outValue ^= outValue >> 27; - outValue *= 0x94d049bb133111ebULL; - outValue ^= outValue >> 31; - } - - static void deriveOne(const uint64& r, const uint64& idx, uint64& outValue) { mix64(r + 0x9e3779b97f4a7c15ULL * (idx + 1), outValue); } - - static void addPlayerInfo(QTF& state, const id& playerId, const Array& randomValues) - { - state.players.set(state.numberOfPlayers++, {playerId, randomValues}); - } - - static uint8 bitcount32(uint32 v) - { - v = v - ((v >> 1) & 0x55555555u); - v = (v & 0x33333333u) + ((v >> 2) & 0x33333333u); - v = (v + (v >> 4)) & 0x0F0F0F0Fu; - v = v + (v >> 8); - v = v + (v >> 16); - return static_cast(v & 0x3Fu); - } - - static void clearPlayerData(QTF& state) - { - if (state.numberOfPlayers > 0) - { - setMemory(state.players, 0); - state.numberOfPlayers = 0; - } - } - - static void clearWinerData(QTF& state) { setMemory(state.lastWinnerData, 0); } - - static void fillWinnerData(QTF& state, const PlayerData& playerData, const Array& winnerValues, - const uint16& epoch) - { - if (!isZero(playerData.player)) - { - if (state.lastWinnerData.winnerCounter < state.lastWinnerData.winners.capacity()) - { - state.lastWinnerData.winners.set(state.lastWinnerData.winnerCounter++, playerData); - } - } - - state.lastWinnerData.winnerValues = winnerValues; - state.lastWinnerData.epoch = epoch; - } - - WinnerData lastWinnerData; // last winners snapshot - - NextEpochData nextEpochData; // queued config (ticket price) - - Array players; // current epoch tickets - - id teamAddress; // Dev/team payout address - - id ownerAddress; // config authority - - uint64 numberOfPlayers; // tickets count in epoch - - uint64 ticketPrice; // active ticket price - - uint64 jackpot; // jackpot balance - - uint64 targetJackpot; // FR target jackpot - - uint64 overflowAlphaBP; // baseline reserve share of overflow (bp) - - uint8 schedule; // bitmask of draw days - - uint8 drawHour; // draw hour UTC - - uint32 lastDrawDateStamp; // guard to avoid multiple draws per day - - bit frActive; // FR flag - - uint16 frRoundsSinceK4; // rounds since last jackpot hit - - uint16 frRoundsAtOrAboveTarget; // hysteresis counter for FR off - - uint8 currentState; // bitmask of STATE_* flags (e.g., STATE_SELLING) - -private: - // Core settlement pipeline for one epoch: fees, FR redirects, payouts, jackpot/reserve updates. - PRIVATE_PROCEDURE_WITH_LOCALS(SettleEpoch) - { - if (state.numberOfPlayers == 0) - { - return; - } - - locals.currentEpoch = qpi.epoch(); - locals.revenue = smul(state.ticketPrice, state.numberOfPlayers); - if (locals.revenue == 0) - { - CALL(ReturnAllTickets, locals.returnAllTicketsInput, locals.returnAllTicketsOutput); - clearPlayerData(state); - - return; - } - - // Check if contract has sufficient balance for settlement - locals.checkBalanceInput.expectedRevenue = locals.revenue; - CALL(CheckContractBalance, locals.checkBalanceInput, locals.checkBalanceOutput); - if (!locals.checkBalanceOutput.hasEnough) - { - // Insufficient balance: refund all players and abort settlement - CALL(ReturnAllTickets, locals.returnAllTicketsInput, locals.returnAllTicketsOutput); - clearPlayerData(state); - - return; - } - - CALL(GetFees, locals.feesInput, locals.feesOutput); - - locals.devPayout = div(smul(locals.revenue, static_cast(locals.feesOutput.teamFeePercent)), 100); - locals.distPayout = div(smul(locals.revenue, static_cast(locals.feesOutput.distributionFeePercent)), 100); - locals.burnAmount = div(smul(locals.revenue, static_cast(locals.feesOutput.burnPercent)), 100); - - // FR detection and hysteresis logic. - // Update hysteresis counter BEFORE activation check so deactivation can occur - // immediately when reaching the threshold (3 consecutive rounds at/above target). - if (state.jackpot >= state.targetJackpot) - { - state.frRoundsAtOrAboveTarget = sadd(state.frRoundsAtOrAboveTarget, 1); - } - else - { - state.frRoundsAtOrAboveTarget = 0; - } - - // FR Activation/Deactivation logic (deficit-driven, no hard N threshold) - // Activation: when carry < target AND within post-k4 window (adaptive) - // Deactivation (hysteresis): after carry >= target for 3+ rounds - locals.shouldActivateFR = (state.jackpot < state.targetJackpot) && (state.frRoundsSinceK4 < QTF_FR_POST_K4_WINDOW_ROUNDS); - if (locals.shouldActivateFR) - { - state.frActive = true; - } - else if (state.frRoundsSinceK4 >= QTF_FR_POST_K4_WINDOW_ROUNDS) - { - // Outside post-k4 window: FR must be OFF. - state.frActive = false; - } - else if (state.frRoundsAtOrAboveTarget >= QTF_FR_HYSTERESIS_ROUNDS) - { - // Deactivate FR after target held for hysteresis rounds - state.frActive = false; - } - - // Calculate prize pools using shared function (handles FR rake if active) - locals.calcPoolsInput.revenue = locals.revenue; - locals.calcPoolsInput.applyFRRake = state.frActive; - CALL(CalculatePrizePools, locals.calcPoolsInput, locals.calcPoolsOutput); - - locals.winnersBlock = locals.calcPoolsOutput.winnersBlock; - locals.winnersRake = locals.calcPoolsOutput.winnersRake; - locals.k2Pool = locals.calcPoolsOutput.k2Pool; - locals.k3Pool = locals.calcPoolsOutput.k3Pool; - - // Calculate initial overflow: unallocated funds after k2/k3 allocation (32% baseline) - // Additional unawarded k2/k3 funds will be added to this after tier processing - locals.winnersOverflow = locals.winnersBlock - locals.k3Pool - locals.k2Pool; - - // Fast-Recovery (FR) mode: redirect portions of Dev/Distribution to jackpot with deficit-driven extra. - // Base redirect is always 1% Dev + 1% Dist when FR=ON. - // Extra redirect is calculated dynamically based on deficit, expected k4 timing, and ticket volume. - if (state.frActive) - { - // Calculate deficit to target jackpot - locals.delta = (state.jackpot < state.targetJackpot) ? (state.targetJackpot - state.jackpot) : 0; - - // Estimate base gain from existing FR mechanisms (without extra) - locals.calcBaseGainInput.revenue = locals.revenue; - locals.calcBaseGainInput.winnersBlock = locals.calcPoolsOutput.winnersBlock; - CALL(CalculateBaseGain, locals.calcBaseGainInput, locals.calcBaseGainOutput); - - // Calculate deficit-driven extra redirect in basis points - locals.calcExtraInput.N = state.numberOfPlayers; - locals.calcExtraInput.delta = locals.delta; - locals.calcExtraInput.revenue = locals.revenue; - locals.calcExtraInput.baseGain = locals.calcBaseGainOutput.baseGain; - CALL(CalculateExtraRedirectBP, locals.calcExtraInput, locals.calcExtraOutput); - - // Split extra equally between Dev and Dist - locals.devExtraBP = div(locals.calcExtraOutput.extraBP, 2); - locals.distExtraBP = locals.calcExtraOutput.extraBP - locals.devExtraBP; // Handle odd remainder - - // Total redirect BP = base + extra - locals.totalDevRedirectBP = sadd(QTF_FR_DEV_REDIRECT_BP, locals.devExtraBP); - locals.totalDistRedirectBP = sadd(QTF_FR_DIST_REDIRECT_BP, locals.distExtraBP); - - // Calculate redirect amounts - locals.devRedirect = div(smul(locals.revenue, locals.totalDevRedirectBP), 10000); - locals.distRedirect = div(smul(locals.revenue, locals.totalDistRedirectBP), 10000); - - // Deduct redirects from payouts (capped at available amounts) - if (locals.devPayout > locals.devRedirect) - { - locals.devPayout -= locals.devRedirect; - } - else - { - locals.devRedirect = locals.devPayout; - locals.devPayout = 0; - } - - if (locals.distPayout > locals.distRedirect) - { - locals.distPayout -= locals.distRedirect; - } - else - { - locals.distRedirect = locals.distPayout; - locals.distPayout = 0; - } - } - - locals.k2PayoutPool = locals.k2Pool; // mutable pools after top-ups - locals.k3PayoutPool = locals.k3Pool; - - // Reset last-winner snapshot for this settlement (per-round view). - clearWinerData(state); - - // Generate winning random values using CALL - locals.getRandomInput.seed = qpi.K12(qpi.getPrevSpectrumDigest()).u64._0; - CALL(GetRandomValues, locals.getRandomInput, locals.getRandomOutput); - locals.winningValues = locals.getRandomOutput.values; - - // First pass: count matches and cache results for second pass - locals.i = 0; - while (locals.i < state.numberOfPlayers) - { - locals.countMatchesInput.playerValues = state.players.get(locals.i).randomValues; - locals.countMatchesInput.winningValues = locals.winningValues; - CALL(CountMatches, locals.countMatchesInput, locals.countMatchesOutput); - - locals.cachedMatches.set(locals.i, locals.countMatchesOutput.matches); // Cache result - - if (locals.countMatchesOutput.matches == 2) - { - ++locals.countK2; - } - else if (locals.countMatchesOutput.matches == 3) - { - ++locals.countK3; - } - else if (locals.countMatchesOutput.matches == 4) - { - ++locals.countK4; - } - ++locals.i; - } - - // Minimum payout floors: ensure k2 winners get >= 0.5*P, k3 winners get >= 5*P. - // Top up from Reserve.General if pool insufficient (subject to safety limits). - // First, get total QRP balance for safety limit calculations (10% of total reserve per round). - CALL_OTHER_CONTRACT_FUNCTION(QRP, GetAvailableReserve, locals.qrpGetAvailableInput, locals.qrpGetAvailableOutput); - locals.totalQRPBalance = locals.qrpGetAvailableOutput.availableReserve; - // If a k=4 win happened this round, the jackpot reseed must not be blocked by floor top-ups. - // We emulate reserve partitioning by limiting k2/k3 top-ups to the portion of QRP above targetJackpot. - // This guarantees that if QRP had >= targetJackpot before settlement, reseed can still reach target after payouts. - if (locals.countK4 > 0) - { - if (locals.totalQRPBalance > state.targetJackpot) - { - locals.totalQRPBalance -= state.targetJackpot; - } - else - { - locals.totalQRPBalance = 0; - } - } - locals.perWinnerCap = smul(state.ticketPrice, QTF_TOPUP_PER_WINNER_CAP_MULT); // 25*P - - // Process k2 tier payout - locals.tierPayoutInput.floorPerWinner = div(smul(state.ticketPrice, QTF_K2_FLOOR_MULT), QTF_K2_FLOOR_DIV); - locals.tierPayoutInput.winnerCount = locals.countK2; - locals.tierPayoutInput.payoutPool = locals.k2PayoutPool; - locals.tierPayoutInput.perWinnerCap = locals.perWinnerCap; - locals.tierPayoutInput.totalQRPBalance = locals.totalQRPBalance; - locals.tierPayoutInput.ticketPrice = state.ticketPrice; - CALL(ProcessTierPayout, locals.tierPayoutInput, locals.tierPayoutOutput); - locals.k2PerWinner = locals.tierPayoutOutput.perWinnerPayout; - locals.winnersOverflow = sadd(locals.winnersOverflow, locals.tierPayoutOutput.overflow); - - // Process k3 tier payout - locals.tierPayoutInput.floorPerWinner = smul(state.ticketPrice, QTF_K3_FLOOR_MULT); - locals.tierPayoutInput.winnerCount = locals.countK3; - locals.tierPayoutInput.payoutPool = locals.k3PayoutPool; - locals.tierPayoutInput.perWinnerCap = locals.perWinnerCap; - locals.tierPayoutInput.totalQRPBalance = locals.totalQRPBalance; - locals.tierPayoutInput.ticketPrice = state.ticketPrice; - CALL(ProcessTierPayout, locals.tierPayoutInput, locals.tierPayoutOutput); - locals.k3PerWinner = locals.tierPayoutOutput.perWinnerPayout; - locals.winnersOverflow = sadd(locals.winnersOverflow, locals.tierPayoutOutput.overflow); - - locals.carryAdd = sadd(locals.carryAdd, locals.winnersRake); - - // Compute k=4 jackpot payout per winner once. - locals.jackpotPerK4Winner = 0; - if (locals.countK4 > 0) - { - locals.jackpotPerK4Winner = div(state.jackpot, locals.countK4); - } - - // Second pass: payout loop using cached match results (avoids redundant countMatches calls) - // (Optimization: reduces player iteration from 4 passes to 2 passes + eliminates duplicate countMatches) - locals.i = 0; - while (locals.i < state.numberOfPlayers) - { - locals.matches = locals.cachedMatches.get(locals.i); // Use cached result - - // k2 payout - if (locals.matches == 2 && locals.countK2 > 0 && locals.k2PerWinner > 0) - { - qpi.transfer(state.players.get(locals.i).player, locals.k2PerWinner); - fillWinnerData(state, state.players.get(locals.i), locals.winningValues, locals.currentEpoch); - } - // k3 payout - if (locals.matches == 3 && locals.countK3 > 0 && locals.k3PerWinner > 0) - { - qpi.transfer(state.players.get(locals.i).player, locals.k3PerWinner); - fillWinnerData(state, state.players.get(locals.i), locals.winningValues, locals.currentEpoch); - } - // k4 payout (jackpot) - if (locals.matches == 4 && locals.countK4 > 0) - { - if (locals.jackpotPerK4Winner > 0) - { - qpi.transfer(state.players.get(locals.i).player, locals.jackpotPerK4Winner); - } - fillWinnerData(state, state.players.get(locals.i), locals.winningValues, locals.currentEpoch); - } - - ++locals.i; - } - - // Always save winning values and epoch, even if no winners - state.lastWinnerData.winnerValues = locals.winningValues; - state.lastWinnerData.epoch = locals.currentEpoch; - - // Post-jackpot (k4) logic: reset counters and reseed if jackpot was hit - if (locals.countK4 > 0) - { - // Jackpot was paid out in combined loop above, now deplete it - state.jackpot = 0; - - // Reset FR counters after jackpot hit - state.frRoundsSinceK4 = 0; - state.frRoundsAtOrAboveTarget = 0; - - // Reseed jackpot from QReservePool (up to targetJackpot or available reserve) - // Re-query available reserve because k2/k3 top-ups may have reduced it. - CALL_OTHER_CONTRACT_FUNCTION(QRP, GetAvailableReserve, locals.qrpGetAvailableInput, locals.qrpGetAvailableOutput); - locals.qrpRequested = RL::min(locals.qrpGetAvailableOutput.availableReserve, state.targetJackpot); - if (locals.qrpRequested > 0) - { - locals.qrpGetReserveInput.revenue = locals.qrpRequested; - INVOKE_OTHER_CONTRACT_PROCEDURE(QRP, WithdrawReserve, locals.qrpGetReserveInput, locals.qrpGetReserveOutput, 0ll); - - if (locals.qrpGetReserveOutput.returnCode == QRP::toReturnCode(QRP::EReturnCode::SUCCESS)) - { - locals.qrpReceived = locals.qrpGetReserveOutput.allocatedRevenue; - state.jackpot = sadd(state.jackpot, locals.qrpReceived); - } - } - } - else - { - // No jackpot hit: increment rounds counter for FR post-k4 window tracking - ++state.frRoundsSinceK4; - } - - // Overflow split: unawarded tier funds split between reserve and jackpot. - // FR mode: 95% to jackpot, 5% to reserve (alpha=0.05) - // Baseline mode: 50/50 split (alpha=0.50) - if (locals.winnersOverflow > 0) - { - if (state.frActive) - { - locals.reserveAdd = div(smul(locals.winnersOverflow, QTF_FR_ALPHA_BP), 10000); - } - else - { - locals.reserveAdd = div(smul(locals.winnersOverflow, state.overflowAlphaBP), 10000); - } - locals.carryAdd = sadd(locals.carryAdd, (locals.winnersOverflow - locals.reserveAdd)); - } - - // Add all jackpot contributions: overflow carryAdd + FR redirects (if active) - locals.totalJackpotContribution = sadd(locals.carryAdd, sadd(locals.devRedirect, locals.distRedirect)); - state.jackpot = sadd(state.jackpot, locals.totalJackpotContribution); - - // Transfer reserve overflow to QReservePool - if (locals.reserveAdd > 0) - { - qpi.transfer(QTF_RESERVE_POOL_CONTRACT_ID, locals.reserveAdd); - } - - if (locals.devPayout > 0) - { - qpi.transfer(state.teamAddress, locals.devPayout); - } - // Manual dividend payout to RL shareholders (no extra fee). - if (locals.distPayout > 0) - { - locals.rlAsset.issuer = id::zero(); - locals.rlAsset.assetName = QTF_RANDOM_LOTTERY_ASSET_NAME; - locals.rlTotalShares = NUMBER_OF_COMPUTORS; - - if (locals.rlTotalShares > 0) - { - locals.dividendPerShare = div(locals.distPayout, locals.rlTotalShares); - if (locals.dividendPerShare > 0) - { - locals.rlIter.begin(locals.rlAsset); - while (!locals.rlIter.reachedEnd()) - { - locals.rlShares = static_cast(locals.rlIter.numberOfPossessedShares()); - if (locals.rlShares > 0) - { - qpi.transfer(locals.rlIter.possessor(), smul(locals.rlShares, locals.dividendPerShare)); - } - locals.rlIter.next(); - } - - locals.rlPayback = locals.distPayout - smul(locals.dividendPerShare, locals.rlTotalShares); - if (locals.rlPayback > 0) - { - qpi.transfer(QTF_RANDOM_LOTTERY_CONTRACT_ID, locals.rlPayback); - } - } - } - } - - if (locals.burnAmount > 0) - { - qpi.burn(locals.burnAmount); - } - } - - /** - * @brief Refunds ticket price to all players who bought tickets in the current epoch. - * - * This procedure is used to return funds to all participants, typically in cases where: - * - Revenue calculation resulted in 0 (overflow or invalid state) - * - Contract balance is insufficient for settlement - * - * Performs one transfer per player entry. After refund, caller should reset numberOfPlayers. - */ - PRIVATE_PROCEDURE_WITH_LOCALS(ReturnAllTickets) - { - // Refund ticket price to each player - for (locals.i = 0; locals.i < state.numberOfPlayers; ++locals.i) - { - qpi.transfer(state.players.get(locals.i).player, state.ticketPrice); - } - } - - PRIVATE_FUNCTION_WITH_LOCALS(CountMatches) - { - locals.maskA = 0; - locals.maskB = 0; - for (locals.i = 0; locals.i < input.playerValues.capacity(); ++locals.i) - { - locals.randomValue = input.playerValues.get(locals.i); - ASSERT(locals.randomValue > 0 && locals.randomValue <= QTF_MAX_RANDOM_VALUE); - - locals.maskA |= 1u << locals.randomValue; - } - - for (locals.i = 0; locals.i < input.winningValues.capacity(); ++locals.i) - { - locals.randomValue = input.winningValues.get(locals.i); - ASSERT(locals.randomValue > 0 && locals.randomValue <= QTF_MAX_RANDOM_VALUE); - - locals.maskB |= 1u << locals.randomValue; - } - output.matches = bitcount32(locals.maskA & locals.maskB); - } - - /** - * @brief Checks if the contract has sufficient balance to cover expected revenue. - * - * This function verifies that the on-chain balance (incoming - outgoing) of the contract - * is at least equal to the expected revenue. Used as a safety check before settlement - * to prevent underflow or incomplete payouts. - * - * @param input.expectedRevenue - The amount of revenue expected to be available - * @param output.hasEnough - true if actualBalance >= expectedRevenue - * @param output.actualBalance - Current net balance of the contract - */ - PRIVATE_FUNCTION_WITH_LOCALS(CheckContractBalance) - { - qpi.getEntity(SELF, locals.entity); - output.actualBalance = RL::max(locals.entity.incomingAmount - locals.entity.outgoingAmount, 0i64); - output.hasEnough = (output.actualBalance >= input.expectedRevenue); - } - - /** - * @brief Computes (base^exp) in fixed-point arithmetic using fast exponentiation. - * - * @param input.base - Base value in fixed-point (scaled by QTF_FIXED_POINT_SCALE) - * @param input.exp - Exponent (integer) - * @param output.result - (base^exp) in fixed-point - */ - PRIVATE_FUNCTION_WITH_LOCALS(PowerFixedPoint) - { - if (input.exp == 0) - { - output.result = QTF_FIXED_POINT_SCALE; // base^0 = 1.0 - return; - } - - locals.tmpBase = input.base; - locals.expCopy = input.exp; - output.result = QTF_FIXED_POINT_SCALE; - - while (locals.expCopy > 0) - { - if (locals.expCopy & 1) - { - // result *= tmpBase (both in fixed-point, so divide by SCALE) - output.result = div(smul(output.result, locals.tmpBase), QTF_FIXED_POINT_SCALE); - } - // tmpBase *= tmpBase - locals.tmpBase = div(smul(locals.tmpBase, locals.tmpBase), QTF_FIXED_POINT_SCALE); - locals.expCopy >>= 1; - } - } - - /** - * @brief Calculates expected rounds until at least one k=4 win: E_k4(N) = 1 / (1 - (1-p4)^N) - * - * Uses exact p4 = 1/27405 (combinatorics for 4-of-30). - * Returns result in fixed-point scaled by QTF_FIXED_POINT_SCALE. - * - * @param input.N - Number of tickets sold in round - * @param output.expectedRounds - E_k4(N) in fixed-point - */ - PRIVATE_FUNCTION_WITH_LOCALS(CalculateExpectedRoundsToK4) - { - if (input.N == 0) - { - // No tickets, infinite expected rounds - output.expectedRounds = UINT64_MAX; - return; - } - - // p4 = 1/27405, so (1 - p4) = 27404/27405 - // In fixed-point: (1 - p4) = (27404 * SCALE) / 27405 - locals.oneMinusP4 = div(smul(27404ULL, QTF_FIXED_POINT_SCALE), QTF_P4_DENOMINATOR); - - // Compute (1-p4)^N in fixed-point using CALL - locals.pfInput.base = locals.oneMinusP4; - locals.pfInput.exp = input.N; - CALL(PowerFixedPoint, locals.pfInput, locals.pfOutput); - locals.pow1mP4N = locals.pfOutput.result; - - // Compute 1 - (1-p4)^N - if (locals.pow1mP4N < QTF_FIXED_POINT_SCALE) - { - locals.denomFP = QTF_FIXED_POINT_SCALE - locals.pow1mP4N; - } - else - { - // Fallback: should not happen, but protect against underflow - locals.denomFP = 1; - } - - // E_k4 = 1 / (1 - (1-p4)^N) = SCALE / denomFP - if (locals.denomFP > 0) - { - output.expectedRounds = div(QTF_FIXED_POINT_SCALE, locals.denomFP); - } - else - { - // Extremely unlikely, fallback to large value - output.expectedRounds = UINT64_MAX; - } - - // Additional safety: if result unreasonably large, cap it - if (output.expectedRounds > smul(QTF_FR_GOAL_ROUNDS_CAP, QTF_FIXED_POINT_SCALE)) - { - output.expectedRounds = smul(QTF_FR_GOAL_ROUNDS_CAP, QTF_FIXED_POINT_SCALE); - } - } - - /** - * @brief Generate 4 unique random values from [1..30] using seed. - * - * Protection against infinite loop: if unable to find unique value after max attempts, - * fallback to sequential selection of first available unused value. - */ - PRIVATE_FUNCTION_WITH_LOCALS(GetRandomValues) - { - for (locals.index = 0; locals.index < output.values.capacity(); ++locals.index) - { - deriveOne(input.seed, locals.index, locals.tempValue); - locals.candidate = static_cast(sadd(mod(locals.tempValue, QTF_MAX_RANDOM_VALUE), 1ull)); - - locals.attempts = 0; - while (locals.used.contains(locals.candidate) && locals.attempts < QTF_MAX_RANDOM_GENERATION_ATTEMPTS) - { - ++locals.attempts; - // Regenerate a fresh candidate from the evolving tempValue until it is unique - locals.tempValue ^= locals.tempValue >> 12; - locals.tempValue ^= locals.tempValue << 25; - locals.tempValue ^= locals.tempValue >> 27; - locals.tempValue *= 2685821657736338717ULL; - locals.candidate = static_cast(sadd(mod(locals.tempValue, QTF_MAX_RANDOM_VALUE), 1ull)); - } - - // Fallback: if still duplicate after max attempts, find first unused value - if (locals.used.contains(locals.candidate)) - { - for (locals.fallback = 1; locals.fallback <= QTF_MAX_RANDOM_VALUE; ++locals.fallback) - { - if (!locals.used.contains(locals.fallback)) - { - locals.candidate = locals.fallback; - break; - } - } - } - - locals.used.add(locals.candidate); - output.values.set(locals.index, locals.candidate); - } - } - - /** - * @brief Validates that all numbers in the array are unique and in range [1..30]. - * - * @param input.numbers - Array of numbers to validate - * @param output.isValid - true if all numbers are valid and unique - */ - PRIVATE_FUNCTION_WITH_LOCALS(ValidateNumbers) - { - output.isValid = true; - for (locals.idx = 0; locals.idx < input.numbers.capacity(); ++locals.idx) - { - locals.value = input.numbers.get(locals.idx); - if (locals.value == 0 || locals.value > QTF_MAX_RANDOM_VALUE) - { - output.isValid = false; - return; - } - if (locals.seen.contains(locals.value)) - { - output.isValid = false; - return; - } - locals.seen.add(locals.value); - } - } - - /** - * @brief Calculate safe reserve top-up amount respecting safety limits. - * - * Safety limits per spec: - * - Max 10% of total QRP reserve per round - * - Soft floor: keep at least 20*P in QRP reserve - * - Per-winner cap: max 25*P per winner - * - * @param input.totalQRPBalance - Actual QRP contract balance (for 10% limit and soft floor) - * @param input.needed - Amount needed for top-up - * @param input.perWinnerCapTotal - Per-winner cap multiplied by winner count - * @param input.ticketPrice - Current ticket price - * @param output.topUpAmount - Safe amount to top up from reserve - */ - PRIVATE_FUNCTION_WITH_LOCALS(CalcReserveTopUp) - { - if (input.totalQRPBalance == 0) - { - output.topUpAmount = 0; - return; - } - - // Calculate soft floor: keep at least 20 * P in total QRP reserve - locals.softFloor = smul(input.ticketPrice, QTF_RESERVE_SOFT_FLOOR_MULT); - - // Calculate available reserve from QRP (above soft floor) - if (input.totalQRPBalance > locals.softFloor) - { - locals.availableAboveFloor = input.totalQRPBalance - locals.softFloor; - } - else - { - locals.availableAboveFloor = 0; - } - - // Calculate max per round (10% of total QRP reserve per spec) - locals.maxPerRound = div(smul(input.totalQRPBalance, QTF_TOPUP_RESERVE_PCT_BP), 10000); - // Cap by available above floor - locals.maxPerRound = RL::min(locals.maxPerRound, locals.availableAboveFloor); - // Cap by per-winner cap - locals.maxPerRound = RL::min(locals.maxPerRound, input.perWinnerCapTotal); - - // Return min of needed and max allowed - if (input.needed < locals.maxPerRound) - { - output.topUpAmount = input.needed; - } - else - { - output.topUpAmount = locals.maxPerRound; - } - } - - /** - * @brief Unified tier payout processing for k2/k3 winners. - * - * Handles floor guarantee with QRP top-up if pool is insufficient. - * Calculates per-winner payout (capped at perWinnerCap) and overflow. - * - * @param input.floorPerWinner - Floor payout per winner (0.5*P for k2, 5*P for k3) - * @param input.winnerCount - Number of winners in this tier - * @param input.payoutPool - Initial payout pool for this tier - * @param input.perWinnerCap - Per-winner cap (25*P) - * @param input.totalQRPBalance - QRP balance for safety limits - * @param input.ticketPrice - Current ticket price - * @param output.perWinnerPayout - Calculated per-winner payout - * @param output.overflow - Overflow amount (unused funds) - * @param output.topUpReceived - Amount received from QRP top-up - */ - PRIVATE_PROCEDURE_WITH_LOCALS(ProcessTierPayout) - { - output.topUpReceived = 0; - output.overflow = 0; - output.perWinnerPayout = 0; - - if (input.winnerCount == 0) - { - // No winners: entire pool becomes overflow - output.overflow = input.payoutPool; - return; - } - - locals.floorTotalNeeded = smul(input.floorPerWinner, input.winnerCount); - locals.finalPool = input.payoutPool; - - // Top-up from QRP if pool insufficient for floor guarantee - if (input.payoutPool < locals.floorTotalNeeded) - { - locals.calcTopUpInput.totalQRPBalance = input.totalQRPBalance; - locals.calcTopUpInput.needed = locals.floorTotalNeeded - input.payoutPool; - locals.calcTopUpInput.perWinnerCapTotal = smul(input.perWinnerCap, input.winnerCount); - locals.calcTopUpInput.ticketPrice = input.ticketPrice; - CALL(CalcReserveTopUp, locals.calcTopUpInput, locals.calcTopUpOutput); - - locals.qrpRequested = RL::min(locals.calcTopUpOutput.topUpAmount, locals.floorTotalNeeded - input.payoutPool); - if (locals.qrpRequested > 0) - { - locals.qrpGetReserveInput.revenue = locals.qrpRequested; - INVOKE_OTHER_CONTRACT_PROCEDURE(QRP, WithdrawReserve, locals.qrpGetReserveInput, locals.qrpGetReserveOutput, 0ll); - - if (locals.qrpGetReserveOutput.returnCode == QRP::toReturnCode(QRP::EReturnCode::SUCCESS)) - { - output.topUpReceived = locals.qrpGetReserveOutput.allocatedRevenue; - locals.finalPool = sadd(input.payoutPool, output.topUpReceived); - } - } - } - - // Calculate per-winner payout (capped at perWinnerCap) - output.perWinnerPayout = RL::min(input.perWinnerCap, div(locals.finalPool, input.winnerCount)); - output.overflow = locals.finalPool - smul(output.perWinnerPayout, input.winnerCount); - } - - /** - * @brief Calculate k2/k3 prize pools from revenue (reusable for settlement and estimation). - * - * This function encapsulates the common logic for calculating prize pools: - * 1. Get fee percentages from RL contract - * 2. Calculate winners block (typically 68% of revenue) - * 3. Apply FR rake if active (5% of winners block) - * 4. Split remaining into k2 (28%) and k3 (40%) pools - * - * @param input.revenue - Total revenue from ticket sales - * @param input.applyFRRake - Whether to apply 5% FR rake - * @param output.winnersBlock - Winners block after rake - * @param output.winnersRake - Amount taken as FR rake (0 if not applied) - * @param output.k2Pool - Prize pool for k=2 tier - * @param output.k3Pool - Prize pool for k=3 tier - */ - PRIVATE_FUNCTION_WITH_LOCALS(CalculatePrizePools) - { - if (input.revenue == 0) - { - output.winnersBlock = 0; - output.winnersRake = 0; - output.k2Pool = 0; - output.k3Pool = 0; - return; - } - - // Get fee percentages from RL contract - CALL(GetFees, locals.feesInput, locals.feesOutput); - - // Calculate winners block (typically 68% of revenue) - locals.winnersBlockBeforeRake = div(smul(input.revenue, static_cast(locals.feesOutput.winnerFeePercent)), 100); - - // Apply FR rake if requested - if (input.applyFRRake) - { - output.winnersRake = div(smul(locals.winnersBlockBeforeRake, QTF_FR_WINNERS_RAKE_BP), 10000); - output.winnersBlock = locals.winnersBlockBeforeRake - output.winnersRake; - } - else - { - output.winnersRake = 0; - output.winnersBlock = locals.winnersBlockBeforeRake; - } - - // Calculate k2 and k3 pools using shared static function - calcK2K3Pool(output.winnersBlock, output.k2Pool, output.k3Pool); - } - - /** - * @brief Estimates base carry gain per round from FR mechanisms (without extra redirect). - * - * Includes: - * - Base Dev redirect: QTF_FR_DEV_REDIRECT_BP of R - * - Base Dist redirect: QTF_FR_DIST_REDIRECT_BP of R - * - Winners rake: QTF_FR_WINNERS_RAKE_BP of winners block - * - Overflow bias: (1 - alpha_fr) of overflow to carry - * - * This is a simplified estimate; actual gain depends on winners count and overflow. - * We use conservative approximation: assume moderate overflow and typical winner distribution. - */ - PRIVATE_FUNCTION_WITH_LOCALS(CalculateBaseGain) - { - // Base redirects from Dev and Dist - locals.devRedirect = div(smul(input.revenue, QTF_FR_DEV_REDIRECT_BP), 10000); - locals.distRedirect = div(smul(input.revenue, QTF_FR_DIST_REDIRECT_BP), 10000); - - // Winners rake: 5% of winners block - locals.winnersRake = div(smul(input.winnersBlock, QTF_FR_WINNERS_RAKE_BP), 10000); - - // Overflow estimate: assume ~10% of winnersBlock not paid out (conservative heuristic) - // With alpha_fr = 0.05, 95% of overflow goes to carry - locals.estimatedOverflow = div(input.winnersBlock, 10); - locals.overflowToCarry = div(smul(locals.estimatedOverflow, 10000 - QTF_FR_ALPHA_BP), 10000); - - // Total base gain - output.baseGain = sadd(sadd(locals.devRedirect, locals.distRedirect), sadd(locals.winnersRake, locals.overflowToCarry)); - } - - /** - * @brief Calculates deficit-driven extra redirect percentage in basis points. - * - * Formula: - * - delta = max(0, targetJackpot - currentJackpot) - * - E_k4(N) = expected rounds to k=4 - * - H = min(E_k4(N), cap) - * - g_need = max(0, delta/H - baseGain) - * - extra_pp = clamp(g_need / revenue, 0, extra_max) - */ - PRIVATE_FUNCTION_WITH_LOCALS(CalculateExtraRedirectBP) - { - if (input.delta == 0 || input.revenue == 0 || input.N == 0) - { - output.extraBP = 0; - return; - } - - // Calculate E_k4(N) in fixed-point using CALL - locals.calcE4Input.N = input.N; - CALL(CalculateExpectedRoundsToK4, locals.calcE4Input, locals.calcE4Output); - - // Apply cap: H = min(E_k4, cap) - locals.horizonFP = RL::min(locals.calcE4Output.expectedRounds, smul(QTF_FR_GOAL_ROUNDS_CAP, QTF_FIXED_POINT_SCALE)); - - // Convert horizon back to integer rounds (divide by SCALE) - locals.horizon = RL::max(div(locals.horizonFP, QTF_FIXED_POINT_SCALE), 1ULL); - - // Calculate required gain per round: delta / H - locals.requiredGainPerRound = div(input.delta, locals.horizon); - - // Calculate needed additional gain: g_need = max(0, requiredGainPerRound - baseGain) - if (locals.requiredGainPerRound > input.baseGain) - { - locals.gNeed = locals.requiredGainPerRound - input.baseGain; - } - else - { - output.extraBP = 0; - return; - } - - // Convert g_need to percentage of revenue in basis points: (g_need / revenue) * 10000 - locals.extraBPTemp = div(smul(locals.gNeed, 10000ULL), input.revenue); - - // Clamp to maximum - output.extraBP = RL::min(locals.extraBPTemp, QTF_FR_EXTRA_MAX_BP); - } - - static void calcK2K3Pool(uint64 winnersBlock, uint64& outK2Pool, uint64& outK3Pool) - { - outK3Pool = div(smul(winnersBlock, QTF_BASE_K3_SHARE_BP), 10000); - outK2Pool = div(smul(winnersBlock, QTF_BASE_K2_SHARE_BP), 10000); - } -}; diff --git a/test/contract_qrp.cpp b/test/contract_qrp.cpp deleted file mode 100644 index 9881d4dca..000000000 --- a/test/contract_qrp.cpp +++ /dev/null @@ -1,207 +0,0 @@ -#define NO_UEFI - -#include "contract_testing.h" - -// Procedure/function indices (must match REGISTER_USER_FUNCTIONS_AND_PROCEDURES in `src/contracts/QReservePool.h`). -constexpr uint16 QRP_PROC_GET_RESERVE = 1; -constexpr uint16 QRP_PROC_ADD_ALLOWED_SC = 2; -constexpr uint16 QRP_PROC_REMOVE_ALLOWED_SC = 3; - -constexpr uint16 QRP_FUNC_GET_AVAILABLE_RESERVE = 1; -constexpr uint16 QRP_FUNC_GET_ALLOWED_SC = 2; - -static const id QRP_CONTRACT_ID(QRP_CONTRACT_INDEX, 0, 0, 0); -static const id QRP_DEFAULT_SC_ID(QRP_QTF_INDEX, 0, 0, 0); -static const id QRP_OWNER_TEAM_ADDRESS = - ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, _W, _E, _T, _H, _N, _G, - _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); - -class QRPChecker : public QRP -{ -public: - const id& team() const { return teamAddress; } - const id& owner() const { return ownerAddress; } - bool hasAllowedSC(const id& sc) const { return allowedSmartContracts.contains(sc); } - uint64 allowedCount() const { return allowedSmartContracts.population(); } -}; - -class ContractTestingQRP : protected ContractTesting -{ -public: - ContractTestingQRP() - { - initEmptySpectrum(); - initEmptyUniverse(); - INIT_CONTRACT(QRP); - callSystemProcedure(QRP_CONTRACT_INDEX, INITIALIZE); - } - - QRPChecker* state() { return reinterpret_cast(contractStates[QRP_CONTRACT_INDEX]); } - - uint64 balanceOf(const id& account) const { return static_cast(getBalance(account)); } - uint64 balanceQrp() const { return balanceOf(QRP_CONTRACT_ID); } - void fund(const id& account, uint64 amount) { increaseEnergy(account, amount); } - void fundQrp(uint64 amount) { fund(QRP_CONTRACT_ID, amount); } - - QRP::WithdrawReserve_output withdrawReserveReserve(const id& invocator, uint64 revenue, sint64 attachedAmount = 0) - { - QRP::WithdrawReserve_input input{revenue}; - QRP::WithdrawReserve_output output{}; - invokeUserProcedure(QRP_CONTRACT_INDEX, QRP_PROC_GET_RESERVE, input, output, invocator, attachedAmount); - return output; - } - - QRP::AddAllowedSC_output addAllowedSC(const id& invocator, uint64 scIndex) - { - QRP::AddAllowedSC_input input{scIndex}; - QRP::AddAllowedSC_output output{}; - invokeUserProcedure(QRP_CONTRACT_INDEX, QRP_PROC_ADD_ALLOWED_SC, input, output, invocator, 0); - return output; - } - - QRP::RemoveAllowedSC_output removeAllowedSC(const id& invocator, uint64 scIndex) - { - QRP::RemoveAllowedSC_input input{scIndex}; - QRP::RemoveAllowedSC_output output{}; - invokeUserProcedure(QRP_CONTRACT_INDEX, QRP_PROC_REMOVE_ALLOWED_SC, input, output, invocator, 0); - return output; - } - - QRP::GetAvailableReserve_output getAvailableReserve() const - { - QRP::GetAvailableReserve_input input{}; - QRP::GetAvailableReserve_output output{}; - callFunction(QRP_CONTRACT_INDEX, QRP_FUNC_GET_AVAILABLE_RESERVE, input, output); - return output; - } - - QRP::GetAllowedSC_output getAllowedSC() const - { - QRP::GetAllowedSC_input input{}; - QRP::GetAllowedSC_output output{}; - callFunction(QRP_CONTRACT_INDEX, QRP_FUNC_GET_ALLOWED_SC, input, output); - return output; - } -}; - -static bool containsAllowedSC(const QRP::GetAllowedSC_output& allowed, const id& sc) -{ - for (uint64 i = 0; i < QRP_ALLOWED_SC_NUM; ++i) - { - if (allowed.allowedSC.get(i) == sc) - { - return true; - } - } - return false; -} - -TEST(ContractQReservePool, WithdrawReserveEnforcesAuthorizationAndBalance) -{ - ContractTestingQRP qrp; - const id unauthorized = id::randomValue(); - qrp.fund(unauthorized, 0); - qrp.fund(QRP_DEFAULT_SC_ID, 0); - - QRP::WithdrawReserve_output denied = qrp.withdrawReserveReserve(unauthorized, 100); - EXPECT_EQ(denied.returnCode, QRP::toReturnCode(QRP::EReturnCode::ACCESS_DENIED)); - EXPECT_EQ(denied.allocatedRevenue, 0ull); - - qrp.fundQrp(1000); - EXPECT_EQ(qrp.balanceQrp(), 1000); - - QRP::WithdrawReserve_output granted = qrp.withdrawReserveReserve(QRP_DEFAULT_SC_ID, 600); - EXPECT_EQ(granted.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); - EXPECT_EQ(granted.allocatedRevenue, 600ull); - EXPECT_EQ(qrp.balanceQrp(), 400); - EXPECT_EQ(qrp.balanceOf(QRP_DEFAULT_SC_ID), 600); - - QRP::WithdrawReserve_output insufficient = qrp.withdrawReserveReserve(QRP_DEFAULT_SC_ID, 500); - EXPECT_EQ(insufficient.returnCode, QRP::toReturnCode(QRP::EReturnCode::INSUFFICIENT_RESERVE)); - EXPECT_EQ(insufficient.allocatedRevenue, 0ull); - EXPECT_EQ(qrp.balanceQrp(), 400); - EXPECT_EQ(qrp.balanceOf(QRP_DEFAULT_SC_ID), 600); -} - -TEST(ContractQReservePool, WithdrawReserve_ZeroAndExactRemaining) -{ - ContractTestingQRP qrp; - qrp.fund(QRP_DEFAULT_SC_ID, 0); - - qrp.fundQrp(1000); - EXPECT_EQ(qrp.balanceQrp(), 1000); - - // Zero request should not move funds. - const QRP::WithdrawReserve_output zero = qrp.withdrawReserveReserve(QRP_DEFAULT_SC_ID, 0); - EXPECT_EQ(zero.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); - EXPECT_EQ(zero.allocatedRevenue, 0ull); - EXPECT_EQ(qrp.balanceQrp(), 1000); - - // Exact remaining should succeed and drain the reserve. - const QRP::WithdrawReserve_output exact = qrp.withdrawReserveReserve(QRP_DEFAULT_SC_ID, 1000); - EXPECT_EQ(exact.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); - EXPECT_EQ(exact.allocatedRevenue, 1000ull); - EXPECT_EQ(qrp.balanceQrp(), 0); - EXPECT_EQ(qrp.balanceOf(QRP_DEFAULT_SC_ID), 1000); -} - -TEST(ContractQReservePool, OwnerAddsAndRemovesSmartContracts) -{ - ContractTestingQRP qrp; - QRPChecker* state = qrp.state(); - constexpr uint64 newScIndex = 77; - const id newScId(newScIndex, 0, 0, 0); - const id outsider(200, 0, 0, 0); - qrp.fund(newScId, 0); - qrp.fund(outsider, 0); - qrp.fund(state->owner(), 0); - - QRP::AddAllowedSC_output deniedAdd = qrp.addAllowedSC(outsider, newScIndex); - EXPECT_EQ(deniedAdd.returnCode, QRP::toReturnCode(QRP::EReturnCode::ACCESS_DENIED)); - EXPECT_FALSE(state->hasAllowedSC(newScId)); - - QRP::AddAllowedSC_output approvedAdd = qrp.addAllowedSC(state->owner(), newScIndex); - EXPECT_EQ(approvedAdd.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); - EXPECT_TRUE(state->hasAllowedSC(newScId)); - - QRP::GetAllowedSC_output allowed = qrp.getAllowedSC(); - EXPECT_TRUE(containsAllowedSC(allowed, newScId)); - - QRP::RemoveAllowedSC_output deniedRemove = qrp.removeAllowedSC(outsider, newScIndex); - EXPECT_EQ(deniedRemove.returnCode, QRP::toReturnCode(QRP::EReturnCode::ACCESS_DENIED)); - EXPECT_TRUE(state->hasAllowedSC(newScId)); - - QRP::RemoveAllowedSC_output approvedRemove = qrp.removeAllowedSC(state->owner(), newScIndex); - EXPECT_EQ(approvedRemove.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); - EXPECT_FALSE(state->hasAllowedSC(newScId)); -} - -TEST(ContractQReservePool, OwnerAddRemove_IdempotencyAndBounds) -{ - ContractTestingQRP qrp; - QRPChecker* state = qrp.state(); - qrp.fund(state->owner(), 0); - - constexpr uint64 newScIndex = 88; - const id newScId(newScIndex, 0, 0, 0); - qrp.fund(newScId, 0); - - EXPECT_FALSE(state->hasAllowedSC(newScId)); - - // This test focuses on idempotency (repeat add/remove) while keeping authorization valid. - // Add twice: first should succeed, second should not change membership (return code may be SUCCESS or specific). - const auto add1 = qrp.addAllowedSC(state->owner(), newScIndex); - EXPECT_EQ(add1.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); - EXPECT_TRUE(state->hasAllowedSC(newScId)); - - const auto add2 = qrp.addAllowedSC(state->owner(), newScIndex); - EXPECT_TRUE(state->hasAllowedSC(newScId)); - - // Remove twice: first should succeed, second should keep it removed (return code may be SUCCESS or specific). - const auto rem1 = qrp.removeAllowedSC(state->owner(), newScIndex); - EXPECT_EQ(rem1.returnCode, QRP::toReturnCode(QRP::EReturnCode::SUCCESS)); - EXPECT_FALSE(state->hasAllowedSC(newScId)); - - const auto rem2 = qrp.removeAllowedSC(state->owner(), newScIndex); - EXPECT_FALSE(state->hasAllowedSC(newScId)); -} diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp deleted file mode 100644 index 9ecd05747..000000000 --- a/test/contract_qtf.cpp +++ /dev/null @@ -1,3121 +0,0 @@ -#define NO_UEFI - -#define _ALLOW_KEYWORD_MACROS 1 - -#define private protected -#include "contract_testing.h" -#undef private -#undef _ALLOW_KEYWORD_MACROS - -#include -#include -#include - -// Procedure/function indices (must match REGISTER_USER_FUNCTIONS_AND_PROCEDURES in `src/contracts/QThirtyFour.h`). -constexpr uint16 QTF_PROCEDURE_BUY_TICKET = 1; -constexpr uint16 QTF_PROCEDURE_SET_PRICE = 2; -constexpr uint16 QTF_PROCEDURE_SET_SCHEDULE = 3; -constexpr uint16 QTF_PROCEDURE_SET_TARGET_JACKPOT = 4; -constexpr uint16 QTF_PROCEDURE_SET_DRAW_HOUR = 5; - -constexpr uint16 QTF_FUNCTION_GET_TICKET_PRICE = 1; -constexpr uint16 QTF_FUNCTION_GET_NEXT_EPOCH_DATA = 2; -constexpr uint16 QTF_FUNCTION_GET_WINNER_DATA = 3; -constexpr uint16 QTF_FUNCTION_GET_POOLS = 4; -constexpr uint16 QTF_FUNCTION_GET_SCHEDULE = 5; -constexpr uint16 QTF_FUNCTION_GET_DRAW_HOUR = 6; -constexpr uint16 QTF_FUNCTION_GET_STATE = 7; -constexpr uint16 QTF_FUNCTION_GET_FEES = 8; -constexpr uint16 QTF_FUNCTION_ESTIMATE_PRIZE_PAYOUTS = 9; - -using QTFRandomValues = Array; - -namespace -{ - static void issueRlSharesTo(std::vector>& initialOwnerShares) - { - issueContractShares(RL_CONTRACT_INDEX, initialOwnerShares, false); - } - - static void primeQpiFunctionContext(QpiContextUserFunctionCall& qpi) - { - QTF::GetTicketPrice_input input{}; - qpi.call(QTF_FUNCTION_GET_TICKET_PRICE, &input, sizeof(input)); - } - - static void primeQpiProcedureContext(QpiContextUserProcedureCall& qpi, uint8 drawHour) - { - QTF::SetDrawHour_input input{}; - input.newDrawHour = drawHour; - qpi.call(QTF_PROCEDURE_SET_DRAW_HOUR, &input, sizeof(input)); - ASSERT_EQ(contractError[QTF_CONTRACT_INDEX], 0); - } - - static bool valuesEqual(const QTFRandomValues& a, const QTFRandomValues& b) - { - return memcmp(&a, &b, sizeof(a)) == 0; - } - - static void expectWinnerValuesValidAndUnique(const QTF::GetWinnerData_output& winnerData) - { - std::set unique; - for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) - { - const uint8 v = winnerData.winnerData.winnerValues.get(i); - EXPECT_GE(v, 1u) << "Winning value " << i << " should be >= 1"; - EXPECT_LE(v, QTF_MAX_RANDOM_VALUE) << "Winning value " << i << " should be <= 30"; - unique.insert(v); - } - EXPECT_EQ(unique.size(), static_cast(QTF_RANDOM_VALUES_COUNT)) << "All 4 winning numbers should be unique"; - EXPECT_GT(static_cast(winnerData.winnerData.epoch), 0u) << "Epoch should be recorded after draw"; - } - - static void computeBaselinePrizePools(uint64 revenue, const QTF::GetFees_output& fees, uint64& winnersBlock, uint64& k2Pool, uint64& k3Pool) - { - winnersBlock = (revenue * static_cast(fees.winnerFeePercent)) / 100ULL; - k2Pool = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000ULL; - k3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000ULL; - } -} // namespace - -static const id QTF_DEV_ADDRESS = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, - _R, _L, _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); - -constexpr uint8 QTF_ANY_DAY_SCHEDULE = 0xFF; - -// Test helper class exposing internal state -class QTFChecker : public QTF -{ -public: - uint64 getNumberOfPlayers() const { return numberOfPlayers; } - uint64 getTicketPriceInternal() const { return ticketPrice; } - uint64 getJackpot() const { return jackpot; } - uint64 getTargetJackpotInternal() const { return targetJackpot; } - uint32 getDrawHourInternal() const { return drawHour; } - bool getFrActive() const { return frActive; } - uint32 getFrRoundsSinceK4() const { return frRoundsSinceK4; } - uint32 getFrRoundsAtOrAboveTarget() const { return frRoundsAtOrAboveTarget; } - - void setScheduleMask(uint8 newMask) { schedule = newMask; } - void setJackpot(uint64 value) { jackpot = value; } - void setTargetJackpotInternal(uint64 value) { targetJackpot = value; } - void setTicketPriceInternal(uint64 value) { ticketPrice = value; } - void setFrActive(bit value) { frActive = value; } - void setFrRoundsSinceK4(uint16 value) { frRoundsSinceK4 = value; } - void setFrRoundsAtOrAboveTarget(uint16 value) { frRoundsAtOrAboveTarget = value; } - void setOverflowAlphaBP(uint64 value) { overflowAlphaBP = value; } - - const PlayerData& getPlayer(uint64 index) const { return players.get(index); } - void addPlayerDirect(const id& playerId, const QTFRandomValues& randomValues) { players.set(numberOfPlayers++, {playerId, randomValues}); } - - // ---- Private method wrappers (private->protected in this TU) -------------- - ValidateNumbers_output callValidateNumbers(const QPI::QpiContextFunctionCall& qpi, const QTFRandomValues& numbers) const - { - ValidateNumbers_input input{}; - ValidateNumbers_output output{}; - ValidateNumbers_locals locals{}; - - input.numbers = numbers; - ValidateNumbers(qpi, *this, input, output, locals); - return output; - } - - GetRandomValues_output callGetRandomValues(const QPI::QpiContextFunctionCall& qpi, uint64 seed) const - { - GetRandomValues_input input{}; - GetRandomValues_output output{}; - GetRandomValues_locals locals{}; - - input.seed = seed; - GetRandomValues(qpi, *this, input, output, locals); - return output; - } - - CountMatches_output callCountMatches(const QPI::QpiContextFunctionCall& qpi, const QTFRandomValues& playerValues, - const QTFRandomValues& winningValues) const - { - CountMatches_input input{}; - CountMatches_output output{}; - CountMatches_locals locals{}; - - input.playerValues = playerValues; - input.winningValues = winningValues; - CountMatches(qpi, *this, input, output, locals); - return output; - } - - CheckContractBalance_output callCheckContractBalance(const QPI::QpiContextFunctionCall& qpi, uint64 expectedRevenue) const - { - CheckContractBalance_input input{}; - CheckContractBalance_output output{}; - CheckContractBalance_locals locals{}; - - input.expectedRevenue = expectedRevenue; - CheckContractBalance(qpi, *this, input, output, locals); - return output; - } - - PowerFixedPoint_output callPowerFixedPoint(const QPI::QpiContextFunctionCall& qpi, uint64 base, uint64 exp) const - { - PowerFixedPoint_input input{}; - PowerFixedPoint_output output{}; - PowerFixedPoint_locals locals{}; - - input.base = base; - input.exp = exp; - PowerFixedPoint(qpi, *this, input, output, locals); - return output; - } - - CalculateExpectedRoundsToK4_output callCalculateExpectedRoundsToK4(const QPI::QpiContextFunctionCall& qpi, uint64 N) const - { - CalculateExpectedRoundsToK4_input input{}; - CalculateExpectedRoundsToK4_output output{}; - CalculateExpectedRoundsToK4_locals locals{}; - - input.N = N; - CalculateExpectedRoundsToK4(qpi, *this, input, output, locals); - return output; - } - - CalcReserveTopUp_output callCalcReserveTopUp(const QPI::QpiContextFunctionCall& qpi, uint64 totalQRPBalance, uint64 needed, - uint64 perWinnerCapTotal, uint64 ticketPrice) const - { - CalcReserveTopUp_input input{}; - CalcReserveTopUp_output output{}; - CalcReserveTopUp_locals locals{}; - - input.totalQRPBalance = totalQRPBalance; - input.needed = needed; - input.perWinnerCapTotal = perWinnerCapTotal; - input.ticketPrice = ticketPrice; - CalcReserveTopUp(qpi, *this, input, output, locals); - return output; - } - - CalculatePrizePools_output callCalculatePrizePools(const QPI::QpiContextFunctionCall& qpi, uint64 revenue, bit applyFRRake) const - { - CalculatePrizePools_input input{}; - CalculatePrizePools_output output{}; - CalculatePrizePools_locals locals{}; - - input.revenue = revenue; - input.applyFRRake = applyFRRake; - CalculatePrizePools(qpi, *this, input, output, locals); - return output; - } - - CalculateBaseGain_output callCalculateBaseGain(const QPI::QpiContextFunctionCall& qpi, uint64 revenue, uint64 winnersBlock) const - { - CalculateBaseGain_input input{}; - CalculateBaseGain_output output{}; - CalculateBaseGain_locals locals{}; - - input.revenue = revenue; - input.winnersBlock = winnersBlock; - CalculateBaseGain(qpi, *this, input, output, locals); - return output; - } - - CalculateExtraRedirectBP_output callCalculateExtraRedirectBP(const QPI::QpiContextFunctionCall& qpi, uint64 N, uint64 delta, uint64 revenue, - uint64 baseGain) const - { - CalculateExtraRedirectBP_input input{}; - CalculateExtraRedirectBP_output output{}; - CalculateExtraRedirectBP_locals locals{}; - - input.N = N; - input.delta = delta; - input.revenue = revenue; - input.baseGain = baseGain; - CalculateExtraRedirectBP(qpi, *this, input, output, locals); - return output; - } - - void callReturnAllTickets(const QPI::QpiContextProcedureCall& qpi) - { - ReturnAllTickets_input input{}; - ReturnAllTickets_output output{}; - ReturnAllTickets_locals locals{}; - - ReturnAllTickets(qpi, *this, input, output, locals); - } - - ProcessTierPayout_output callProcessTierPayout(const QPI::QpiContextProcedureCall& qpi, uint64 floorPerWinner, uint64 winnerCount, - uint64 payoutPool, uint64 perWinnerCap, uint64 totalQRPBalance, uint64 ticketPrice) - { - ProcessTierPayout_input input{}; - ProcessTierPayout_output output{}; - ProcessTierPayout_locals locals{}; - - input.floorPerWinner = floorPerWinner; - input.winnerCount = winnerCount; - input.payoutPool = payoutPool; - input.perWinnerCap = perWinnerCap; - input.totalQRPBalance = totalQRPBalance; - input.ticketPrice = ticketPrice; - ProcessTierPayout(qpi, *this, input, output, locals); - return output; - } -}; - -class ContractTestingQTF : protected ContractTesting -{ -public: - ContractTestingQTF() - { - initEmptySpectrum(); - initEmptyUniverse(); - INIT_CONTRACT(QRP); - INIT_CONTRACT(RL); - INIT_CONTRACT(QTF); - - // Initialize QRP first (QTF depends on it for reserve operations) - callSystemProcedure(QRP_CONTRACT_INDEX, INITIALIZE); - // Initialize RL (RandomLottery contract) - callSystemProcedure(RL_CONTRACT_INDEX, INITIALIZE); - // Initialize QTF - system.epoch = contractDescriptions[QTF_CONTRACT_INDEX].constructionEpoch; - callSystemProcedure(QTF_CONTRACT_INDEX, INITIALIZE); - } - - // Access internal contract state - QTFChecker* state() { return reinterpret_cast(contractStates[QTF_CONTRACT_INDEX]); } - - id qtfSelf() { return id(QTF_CONTRACT_INDEX, 0, 0, 0); } - id qrpSelf() { return id(QRP_CONTRACT_INDEX, 0, 0, 0); } - void addPlayerDirect(const id& playerId, const QTFRandomValues& randomValues) { state()->addPlayerDirect(playerId, randomValues); } - - // Public function wrappers - QTF::GetTicketPrice_output getTicketPrice() - { - QTF::GetTicketPrice_input input{}; - QTF::GetTicketPrice_output output{}; - callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_TICKET_PRICE, input, output); - return output; - } - - QTF::GetNextEpochData_output getNextEpochData() - { - QTF::GetNextEpochData_input input{}; - QTF::GetNextEpochData_output output{}; - callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_NEXT_EPOCH_DATA, input, output); - return output; - } - - QTF::GetWinnerData_output getWinnerData() - { - QTF::GetWinnerData_input input{}; - QTF::GetWinnerData_output output{}; - callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_WINNER_DATA, input, output); - return output; - } - - QTF::GetPools_output getPools() - { - QTF::GetPools_input input{}; - QTF::GetPools_output output{}; - callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_POOLS, input, output); - return output; - } - - QTF::GetSchedule_output getSchedule() - { - QTF::GetSchedule_input input{}; - QTF::GetSchedule_output output{}; - callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_SCHEDULE, input, output); - return output; - } - - QTF::GetDrawHour_output getDrawHour() - { - QTF::GetDrawHour_input input{}; - QTF::GetDrawHour_output output{}; - callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_DRAW_HOUR, input, output); - return output; - } - - QTF::GetState_output getStateInfo() - { - QTF::GetState_input input{}; - QTF::GetState_output output{}; - callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_STATE, input, output); - return output; - } - - QTF::GetFees_output getFees() - { - QTF::GetFees_input input{}; - QTF::GetFees_output output{}; - callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_FEES, input, output); - return output; - } - - QTF::EstimatePrizePayouts_output estimatePrizePayouts(uint64 k2WinnerCount, uint64 k3WinnerCount) - { - QTF::EstimatePrizePayouts_input input{}; - input.k2WinnerCount = k2WinnerCount; - input.k3WinnerCount = k3WinnerCount; - QTF::EstimatePrizePayouts_output output{}; - callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_ESTIMATE_PRIZE_PAYOUTS, input, output); - return output; - } - - // Procedure wrappers - QTF::BuyTicket_output buyTicket(const id& user, uint64 reward, const QTFRandomValues& numbers) - { - QTF::BuyTicket_input input{}; - input.randomValues = numbers; - QTF::BuyTicket_output output{}; - if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_BUY_TICKET, input, output, user, reward)) - { - output.returnCode = static_cast(QTF::EReturnCode::MAX_VALUE); - } - return output; - } - - QTF::SetPrice_output setPrice(const id& invocator, uint64 newPrice) - { - QTF::SetPrice_input input{}; - input.newPrice = newPrice; - QTF::SetPrice_output output{}; - if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_SET_PRICE, input, output, invocator, 0)) - { - output.returnCode = static_cast(QTF::EReturnCode::MAX_VALUE); - } - return output; - } - - QTF::SetSchedule_output setSchedule(const id& invocator, uint8 newSchedule) - { - QTF::SetSchedule_input input{}; - input.newSchedule = newSchedule; - QTF::SetSchedule_output output{}; - if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_SET_SCHEDULE, input, output, invocator, 0)) - { - output.returnCode = static_cast(QTF::EReturnCode::MAX_VALUE); - } - return output; - } - - QTF::SetTargetJackpot_output setTargetJackpot(const id& invocator, uint64 newTarget) - { - QTF::SetTargetJackpot_input input{}; - input.newTargetJackpot = newTarget; - QTF::SetTargetJackpot_output output{}; - if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_SET_TARGET_JACKPOT, input, output, invocator, 0)) - { - output.returnCode = static_cast(QTF::EReturnCode::MAX_VALUE); - } - return output; - } - - QTF::SetDrawHour_output setDrawHour(const id& invocator, uint8 newHour) - { - QTF::SetDrawHour_input input{}; - input.newDrawHour = newHour; - QTF::SetDrawHour_output output{}; - if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_SET_DRAW_HOUR, input, output, invocator, 0)) - { - output.returnCode = static_cast(QTF::EReturnCode::MAX_VALUE); - } - return output; - } - - // System procedure wrappers - void beginEpoch() { callSystemProcedure(QTF_CONTRACT_INDEX, BEGIN_EPOCH); } - void endEpoch() { callSystemProcedure(QTF_CONTRACT_INDEX, END_EPOCH); } - void beginTick() { callSystemProcedure(QTF_CONTRACT_INDEX, BEGIN_TICK); } - - void setDateTime(uint16 year, uint8 month, uint8 day, uint8 hour) - { - updateTime(); - utcTime.Year = year; - utcTime.Month = month; - utcTime.Day = day; - utcTime.Hour = hour; - utcTime.Minute = 0; - utcTime.Second = 0; - utcTime.Nanosecond = 0; - updateQpiTime(); - } - - void forceBeginTick() - { - system.tick = system.tick + (RL_TICK_UPDATE_PERIOD - (system.tick % RL_TICK_UPDATE_PERIOD)); - beginTick(); - } - - void beginEpochWithDate(uint16 year, uint8 month, uint8 day, uint8 hour = 12) - { - setDateTime(year, month, day, hour); - beginEpoch(); - } - - void beginEpochWithValidTime() { beginEpochWithDate(2025, 1, 20); } - - // Force schedule mask directly in state - void forceSchedule(uint8 scheduleMask) { state()->setScheduleMask(scheduleMask); } - - void forceFRDisabledForBaseline() - { - state()->setFrActive(false); - state()->setFrRoundsSinceK4(QTF_FR_POST_K4_WINDOW_ROUNDS); - state()->setOverflowAlphaBP(QTF_BASELINE_OVERFLOW_ALPHA_BP); - } - - void forceFREnabledWithinWindow(uint16 roundsSinceK4 = 1) - { - state()->setFrActive(true); - state()->setFrRoundsSinceK4(roundsSinceK4); - } - - void startAnyDayEpoch() - { - forceSchedule(QTF_ANY_DAY_SCHEDULE); - beginEpochWithValidTime(); - } - - // Trigger a tick that performs the draw (time is set to a scheduled day and hour). - void triggerDrawTick() - { - constexpr uint16 y = 2025; - constexpr uint8 m = 1; - constexpr uint8 d = 10; - setDateTime(y, m, d, 12); - __pauseLogMessage(); - forceBeginTick(); - } - - // Helper to create valid random values - QTFRandomValues makeValidNumbers(uint8 n1, uint8 n2, uint8 n3, uint8 n4) - { - QTFRandomValues values; - values.set(0, n1); - values.set(1, n2); - values.set(2, n3); - values.set(3, n4); - return values; - } - - // Fund user and buy a ticket - void fundAndBuyTicket(const id& user, uint64 ticketPrice, const QTFRandomValues& numbers) - { - increaseEnergy(user, ticketPrice * 2); - const QTF::BuyTicket_output out = buyTicket(user, ticketPrice, numbers); - EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); - } - - // Set prevSpectrumDigest for deterministic random number generation - // This allows tests to predict winning numbers by fixing the RNG seed - void setPrevSpectrumDigest(const m256i& digest) { etalonTick.prevSpectrumDigest = digest; } - - void drawWithDigest(const m256i& digest) - { - setPrevSpectrumDigest(digest); - triggerDrawTick(); - } - - // Compute the winning numbers that would be generated for a given prevSpectrumDigest. - // Uses the contract GetRandomValues() implementation (so tests don't duplicate RNG logic). - // Returns values in generation order (not sorted). - QTFRandomValues computeWinningNumbersForDigest(const m256i& digest) - { - m256i hashResult; - KangarooTwelve((const uint8*)&digest, sizeof(m256i), (uint8*)&hashResult, sizeof(m256i)); - const uint64 seed = hashResult.m256i_u64[0]; - - QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - primeQpiFunctionContext(qpi); - const auto out = state()->callGetRandomValues(qpi, seed); - return out.values; - } - - struct WinningAndLosing - { - QTFRandomValues winning; - QTFRandomValues losing; - }; - - QTFRandomValues makeLosingNumbers(const QTFRandomValues& winningNumbers) - { - bool isWinning[31] = {}; - for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) - { - isWinning[winningNumbers.get(i)] = true; - } - - QTFRandomValues losingNumbers; - uint64 outIndex = 0; - for (uint8 candidate = 1; candidate <= QTF_MAX_RANDOM_VALUE && outIndex < QTF_RANDOM_VALUES_COUNT; ++candidate) - { - if (!isWinning[candidate]) - { - losingNumbers.set(outIndex++, candidate); - } - } - EXPECT_EQ(outIndex, static_cast(QTF_RANDOM_VALUES_COUNT)); - return losingNumbers; - } - - WinningAndLosing computeWinningAndLosing(const m256i& digest) - { - WinningAndLosing out; - out.winning = computeWinningNumbersForDigest(digest); - out.losing = makeLosingNumbers(out.winning); - return out; - } - - void buyRandomTickets(uint64 count, uint64 ticketPrice, const QTFRandomValues& numbers) - { - for (uint64 i = 0; i < count; ++i) - { - const id user = id::randomValue(); - fundAndBuyTicket(user, ticketPrice, numbers); - } - } - - // Create a ticket that matches exactly `matchCount` numbers with `winningNumbers`. - // `variant` makes it deterministic to generate multiple distinct tickets for the same winning set. - // Guarantees values are unique and in [1..30]. - QTFRandomValues makeNumbersWithExactMatches(const QTFRandomValues& winningNumbers, uint8 matchCount, uint8 variant = 0) - { - EXPECT_LE(matchCount, static_cast(QTF_RANDOM_VALUES_COUNT)); - - bool isWinning[31] = {}; - bool used[31] = {}; - for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) - { - const uint8 v = winningNumbers.get(i); - EXPECT_GE(v, 1u); - EXPECT_LE(v, QTF_MAX_RANDOM_VALUE); - EXPECT_FALSE(isWinning[v]) << "winningNumbers must be unique"; - isWinning[v] = true; - } - - QTFRandomValues ticket; - uint64 outIndex = 0; - - // Take `matchCount` winning numbers as the matches (variant-dependent, wrap around 4). - for (uint8 i = 0; i < matchCount; ++i) - { - const uint8 v = winningNumbers.get((variant + i) % QTF_RANDOM_VALUES_COUNT); - used[v] = true; - ticket.set(outIndex++, v); - } - - // Fill the remaining positions with non-winning numbers. - const uint8 start = static_cast((variant * 7) % QTF_MAX_RANDOM_VALUE + 1); - for (uint8 step = 0; step < QTF_MAX_RANDOM_VALUE && outIndex < QTF_RANDOM_VALUES_COUNT; ++step) - { - const uint8 candidate = static_cast(((start - 1 + step) % QTF_MAX_RANDOM_VALUE) + 1); - if (!isWinning[candidate] && !used[candidate]) - { - used[candidate] = true; - ticket.set(outIndex++, candidate); - } - } - - EXPECT_EQ(outIndex, static_cast(QTF_RANDOM_VALUES_COUNT)); - - // Verify exact overlap count and uniqueness (debug safety for tests). - uint64 overlap = 0; - std::set uniqueValues; - for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) - { - const uint8 v = ticket.get(i); - EXPECT_GE(v, 1u); - EXPECT_LE(v, QTF_MAX_RANDOM_VALUE); - uniqueValues.insert(v); - if (isWinning[v]) - { - ++overlap; - } - } - EXPECT_EQ(uniqueValues.size(), static_cast(QTF_RANDOM_VALUES_COUNT)); - EXPECT_EQ(overlap, static_cast(matchCount)); - - return ticket; - } - - QTFRandomValues makeK2Numbers(const QTFRandomValues& winningNumbers, uint8 variant = 0) - { - return makeNumbersWithExactMatches(winningNumbers, 2, variant); - } - QTFRandomValues makeK3Numbers(const QTFRandomValues& winningNumbers, uint8 variant = 0) - { - return makeNumbersWithExactMatches(winningNumbers, 3, variant); - } -}; - -// ============================================================================ -// PRIVATE METHOD TESTS -// ============================================================================ - -TEST(ContractQThirtyFour_Private, CountMatches_CountsOverlappingNumbers) -{ - ContractTestingQTF ctl; - - QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - primeQpiFunctionContext(qpi); - - // Include values > 8 to cover the full [1..30] bitmask range. - const QTFRandomValues player = ctl.makeValidNumbers(1, 16, 29, 30); - const QTFRandomValues winning = ctl.makeValidNumbers(16, 29, 2, 3); - const auto out = ctl.state()->callCountMatches(qpi, player, winning); - EXPECT_EQ(out.matches, 2); -} - -TEST(ContractQThirtyFour_Private, ValidateNumbers_WorksForValidDuplicateAndRangeErrors) -{ - ContractTestingQTF ctl; - - QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - primeQpiFunctionContext(qpi); - - const QTFRandomValues ok = ctl.makeValidNumbers(1, 2, 3, 4); - EXPECT_TRUE(ctl.state()->callValidateNumbers(qpi, ok).isValid); - - QTFRandomValues dup = ctl.makeValidNumbers(1, 2, 3, 4); - dup.set(3, 2); - EXPECT_FALSE(ctl.state()->callValidateNumbers(qpi, dup).isValid); - - QTFRandomValues outOfRange = ctl.makeValidNumbers(1, 2, 3, 4); - outOfRange.set(2, 31); - EXPECT_FALSE(ctl.state()->callValidateNumbers(qpi, outOfRange).isValid); -} - -TEST(ContractQThirtyFour_Private, GetRandomValues_IsDeterministicAndUniqueInRange) -{ - ContractTestingQTF ctl; - - QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - primeQpiFunctionContext(qpi); - - const uint64 seed = 0x123456789ABCDEF0ULL; - const auto out1 = ctl.state()->callGetRandomValues(qpi, seed); - const auto out2 = ctl.state()->callGetRandomValues(qpi, seed); - EXPECT_TRUE(valuesEqual(out1.values, out2.values)); - - std::set seen; - for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) - { - const uint8 v = out1.values.get(i); - EXPECT_GE(v, 1); - EXPECT_LE(v, QTF_MAX_RANDOM_VALUE); - seen.insert(v); - EXPECT_EQ(out1.values.get(i), out2.values.get(i)); - } - EXPECT_EQ(seen.size(), static_cast(QTF_RANDOM_VALUES_COUNT)); -} - -TEST(ContractQThirtyFour_Private, CheckContractBalance_UsesIncomingMinusOutgoing) -{ - ContractTestingQTF ctl; - - QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - primeQpiFunctionContext(qpi); - - const uint64 balance = 123456; - increaseEnergy(ctl.qtfSelf(), balance); - - const auto outExact = ctl.state()->callCheckContractBalance(qpi, balance); - EXPECT_TRUE(outExact.hasEnough); - EXPECT_EQ(outExact.actualBalance, balance); - - const auto outTooHigh = ctl.state()->callCheckContractBalance(qpi, balance + 1); - EXPECT_FALSE(outTooHigh.hasEnough); - EXPECT_EQ(outTooHigh.actualBalance, balance); -} - -TEST(ContractQThirtyFour_Private, PowerFixedPoint_ComputesFastExponentiationInFixedPoint) -{ - ContractTestingQTF ctl; - - QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - primeQpiFunctionContext(qpi); - - // 0.5^2 = 0.25 - const auto out025 = ctl.state()->callPowerFixedPoint(qpi, QTF_FIXED_POINT_SCALE / 2, 2); - EXPECT_EQ(out025.result, QTF_FIXED_POINT_SCALE / 4); - - // 2.0^3 = 8.0 - const auto out8 = ctl.state()->callPowerFixedPoint(qpi, 2 * QTF_FIXED_POINT_SCALE, 3); - EXPECT_EQ(out8.result, 8 * QTF_FIXED_POINT_SCALE); -} - -TEST(ContractQThirtyFour_Private, CalculateExpectedRoundsToK4_HandlesEdgeCaseAndMonotonicity) -{ - ContractTestingQTF ctl; - - QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - primeQpiFunctionContext(qpi); - - const auto out0 = ctl.state()->callCalculateExpectedRoundsToK4(qpi, 0); - EXPECT_EQ(out0.expectedRounds, UINT64_MAX); - - const auto out1 = ctl.state()->callCalculateExpectedRoundsToK4(qpi, 1); - const auto out100 = ctl.state()->callCalculateExpectedRoundsToK4(qpi, 100); - EXPECT_GT(out1.expectedRounds, 0ULL); - EXPECT_GT(out100.expectedRounds, 0ULL); - EXPECT_LE(out1.expectedRounds, QTF_FIXED_POINT_SCALE); - EXPECT_LE(out100.expectedRounds, QTF_FIXED_POINT_SCALE); - EXPECT_GT(out1.expectedRounds, out100.expectedRounds); -} - -TEST(ContractQThirtyFour_Private, CalcReserveTopUp_RespectsSoftFloorPerRoundAndPerWinnerCaps) -{ - ContractTestingQTF ctl; - - QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - primeQpiFunctionContext(qpi); - - const uint64 P = 1000000ULL; - - // Below soft floor => nothing can be topped up. - { - const uint64 softFloor = smul(P, QTF_RESERVE_SOFT_FLOOR_MULT); - const auto out = ctl.state()->callCalcReserveTopUp(qpi, softFloor - 1, 1000ULL, 1000000000ULL, P); - EXPECT_EQ(out.topUpAmount, 0ULL); - } - - // Soft floor binds availableAboveFloor and per-round is 10% of total. - { - const auto out = ctl.state()->callCalcReserveTopUp(qpi, 25000000ULL, 10000000ULL, 1000000000ULL, P); - EXPECT_EQ(out.topUpAmount, 2500000ULL); - } - - // Per-winner cap binds. - { - const auto out = ctl.state()->callCalcReserveTopUp(qpi, 1000000000ULL, 50000000ULL, 1000000ULL, P); - EXPECT_EQ(out.topUpAmount, 1000000ULL); - } - - // Needed is below all caps. - { - const auto out = ctl.state()->callCalcReserveTopUp(qpi, 1000000000ULL, 12345ULL, 1000000000ULL, P); - EXPECT_EQ(out.topUpAmount, 12345ULL); - } -} - -TEST(ContractQThirtyFour_Private, CalculatePrizePools_MatchesFeeAndRakeMath) -{ - ContractTestingQTF ctl; - - QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - primeQpiFunctionContext(qpi); - - const auto fees = ctl.getFees(); - ASSERT_NE(fees.winnerFeePercent, 0); - - const uint64 revenue = 1000000ULL; - const uint64 winnersBlockBeforeRake = (revenue * static_cast(fees.winnerFeePercent)) / 100ULL; - - { - const auto out = ctl.state()->callCalculatePrizePools(qpi, revenue, false); - EXPECT_EQ(out.winnersRake, 0ULL); - EXPECT_EQ(out.winnersBlock, winnersBlockBeforeRake); - EXPECT_EQ(out.k3Pool, (out.winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000ULL); - EXPECT_EQ(out.k2Pool, (out.winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000ULL); - } - - { - const auto out = ctl.state()->callCalculatePrizePools(qpi, revenue, true); - const uint64 expectedRake = (winnersBlockBeforeRake * QTF_FR_WINNERS_RAKE_BP) / 10000ULL; - EXPECT_EQ(out.winnersRake, expectedRake); - EXPECT_EQ(out.winnersBlock, winnersBlockBeforeRake - expectedRake); - EXPECT_EQ(out.k3Pool, (out.winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000ULL); - EXPECT_EQ(out.k2Pool, (out.winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000ULL); - } -} - -TEST(ContractQThirtyFour_Private, CalculateBaseGain_FollowsConfiguredRedirectsAndOverflowBias) -{ - ContractTestingQTF ctl; - - QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - primeQpiFunctionContext(qpi); - - const uint64 revenue = 1000000ULL; - const uint64 winnersBlock = 680000ULL; - - const auto out = ctl.state()->callCalculateBaseGain(qpi, revenue, winnersBlock); - EXPECT_EQ(out.baseGain, 118600ULL); -} - -TEST(ContractQThirtyFour_Private, CalculateExtraRedirectBP_ReturnsZeroOrClampsToMax) -{ - ContractTestingQTF ctl; - - QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - primeQpiFunctionContext(qpi); - - // Early exits - EXPECT_EQ(ctl.state()->callCalculateExtraRedirectBP(qpi, 0, 1, 1, 0).extraBP, 0ULL); - EXPECT_EQ(ctl.state()->callCalculateExtraRedirectBP(qpi, 1, 0, 1, 0).extraBP, 0ULL); - EXPECT_EQ(ctl.state()->callCalculateExtraRedirectBP(qpi, 1, 1, 0, 0).extraBP, 0ULL); - - // Clamp to max under large deficit. - { - const uint64 revenue = 1000000ULL; - const uint64 delta = revenue * 1000ULL; - const auto out = ctl.state()->callCalculateExtraRedirectBP(qpi, 100, delta, revenue, 0); - EXPECT_EQ(out.extraBP, QTF_FR_EXTRA_MAX_BP); - } - - // Base gain already covers required gain -> zero. - { - const auto out = ctl.state()->callCalculateExtraRedirectBP(qpi, 100, 1000ULL, 1000000ULL, 2000ULL); - EXPECT_EQ(out.extraBP, 0ULL); - } -} - -TEST(ContractQThirtyFour_Private, ProcessTierPayout_ComputesPayoutAndOptionalTopUp) -{ - ContractTestingQTF ctl; - - const id originator = id::randomValue(); - QpiContextUserProcedureCall qpi(QTF_CONTRACT_INDEX, originator, 0); - primeQpiProcedureContext(qpi, static_cast(ctl.state()->getDrawHourInternal())); - - // No winners -> all overflow. - { - const auto out = ctl.state()->callProcessTierPayout(qpi, 50, 0, 123, 100, 0, 1000000ULL); - EXPECT_EQ(out.perWinnerPayout, 0ULL); - EXPECT_EQ(out.overflow, 123ULL); - EXPECT_EQ(out.topUpReceived, 0ULL); - } - - // Top-up from QRP to meet floor. - { - const uint64 qrpBalanceBefore = 1000000000ULL; - increaseEnergy(ctl.qrpSelf(), qrpBalanceBefore); - - const uint64 qtfBalanceBefore = getBalance(ctl.qtfSelf()); - const uint64 qrpBalanceBeforeActual = getBalance(ctl.qrpSelf()); - - const auto out = ctl.state()->callProcessTierPayout(qpi, 50, 2, 10, 100, qrpBalanceBeforeActual, 1000000ULL); - EXPECT_EQ(out.perWinnerPayout, 50ULL); - EXPECT_EQ(out.overflow, 0ULL); - EXPECT_EQ(out.topUpReceived, 90ULL); - - EXPECT_EQ(getBalance(ctl.qtfSelf()), qtfBalanceBefore + 90); - EXPECT_EQ(getBalance(ctl.qrpSelf()), qrpBalanceBeforeActual - 90); - } - - // Per-winner cap applies and leaves overflow. - { - const uint64 P = 1000000ULL; - const uint64 cap = smul(P, QTF_TOPUP_PER_WINNER_CAP_MULT); - const auto out = ctl.state()->callProcessTierPayout(qpi, div(P, 2), 1, sadd(cap, 1234ULL), cap, 0, P); - EXPECT_EQ(out.perWinnerPayout, cap); - EXPECT_EQ(out.topUpReceived, 0ULL); - EXPECT_EQ(out.overflow, 1234ULL); - } -} - -TEST(ContractQThirtyFour_Private, ReturnAllTickets_RefundsEachPlayerAndClearsViaSettleEpochRevenueZeroBranch) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - - const id originator = id::randomValue(); - QpiContextUserProcedureCall qpi(QTF_CONTRACT_INDEX, originator, 0); - primeQpiProcedureContext(qpi, static_cast(ctl.state()->getDrawHourInternal())); - - // Setup a few players and refund them. - const uint64 ticketPrice = 10; - ctl.state()->setTicketPriceInternal(ticketPrice); - - const id p1 = id::randomValue(); - const id p2 = id::randomValue(); - const QTFRandomValues n1 = ctl.makeValidNumbers(1, 2, 3, 4); - const QTFRandomValues n2 = ctl.makeValidNumbers(5, 6, 7, 8); - ctl.addPlayerDirect(p1, n1); - ctl.addPlayerDirect(p2, n2); - - increaseEnergy(ctl.qtfSelf(), ticketPrice * 2); - const uint64 balBeforeContract = getBalance(ctl.qtfSelf()); - const uint64 balBeforeP1 = getBalance(p1); - const uint64 balBeforeP2 = getBalance(p2); - - ctl.state()->callReturnAllTickets(qpi); - - EXPECT_EQ(getBalance(p1), balBeforeP1 + ticketPrice); - EXPECT_EQ(getBalance(p2), balBeforeP2 + ticketPrice); - EXPECT_EQ(getBalance(ctl.qtfSelf()), balBeforeContract - (ticketPrice * 2)); - - // Now exercise SettleEpoch revenue==0 branch, which must clear players. - ctl.state()->setTicketPriceInternal(0); - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 2ULL); - ctl.triggerDrawTick(); - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0ULL); -} - -// ============================================================================ -// BUY TICKET TESTS -// ============================================================================ - -TEST(ContractQThirtyFour, BuyTicket_WhenSellingClosed_RefundsAndFails) -{ - ContractTestingQTF ctl; - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - // Selling is closed initially (before beginEpoch with valid time) - const id user = id::randomValue(); - increaseEnergy(user, ticketPrice * 2); - const uint64 balBefore = getBalance(user); - - QTFRandomValues nums = ctl.makeValidNumbers(1, 2, 3, 4); - const QTF::BuyTicket_output out = ctl.buyTicket(user, ticketPrice, nums); - - EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::TICKET_SELLING_CLOSED)); - EXPECT_EQ(getBalance(user), balBefore); // Refunded - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); -} - -TEST(ContractQThirtyFour, BuyTicket_TooLowPrice_RefundsAndFails) -{ - ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const id user = id::randomValue(); - increaseEnergy(user, ticketPrice * 5); - const uint64 balBefore = getBalance(user); - - QTFRandomValues nums = ctl.makeValidNumbers(1, 2, 3, 4); - - // Test with price too low - should fail and refund - const QTF::BuyTicket_output outLow = ctl.buyTicket(user, ticketPrice - 1, nums); - EXPECT_EQ(outLow.returnCode, static_cast(QTF::EReturnCode::INVALID_TICKET_PRICE)); - EXPECT_EQ(getBalance(user), balBefore); // Fully refunded - - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); -} - -TEST(ContractQThirtyFour, BuyTicket_ZeroPrice_RefundsAndFails) -{ - ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const id user = id::randomValue(); - increaseEnergy(user, ticketPrice * 2); - const uint64 balBefore = getBalance(user); - - const QTFRandomValues nums = ctl.makeValidNumbers(1, 2, 3, 4); - - const QTF::BuyTicket_output out = ctl.buyTicket(user, 0, nums); - EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_TICKET_PRICE)); - EXPECT_EQ(getBalance(user), balBefore); // Fully refunded (0) - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); -} - -TEST(ContractQThirtyFour, BuyTicket_OverpaidPrice_AcceptsAndReturnsExcess) -{ - ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const id user = id::randomValue(); - const uint64 overpayment = ticketPrice * 2; // Pay double - increaseEnergy(user, overpayment * 2); - const uint64 balBefore = getBalance(user); - - QTFRandomValues nums = ctl.makeValidNumbers(1, 2, 3, 4); - - // Test with overpayment - should accept ticket and return excess - const QTF::BuyTicket_output outHigh = ctl.buyTicket(user, overpayment, nums); - EXPECT_EQ(outHigh.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); - - // Should have paid exactly ticketPrice, excess returned - const uint64 excess = overpayment - ticketPrice; - EXPECT_EQ(getBalance(user), balBefore - ticketPrice) << "User should pay exactly ticket price, excess returned"; - - // Ticket should be registered - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); -} - -TEST(ContractQThirtyFour, BuyTicket_OverpaidInvalidNumbers_RefundsFull_NoLeak) -{ - ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const id user = id::randomValue(); - const uint64 overpayment = ticketPrice * 2; - increaseEnergy(user, overpayment * 2); - const uint64 balBefore = getBalance(user); - - // Invalid: out of range - const QTFRandomValues invalidNums = ctl.makeValidNumbers(1, 2, 3, 31); - const QTF::BuyTicket_output out = ctl.buyTicket(user, overpayment, invalidNums); - - EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_NUMBERS)); - EXPECT_EQ(getBalance(user), balBefore) << "Full invocationReward must be refunded once"; - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); -} - -TEST(ContractQThirtyFour, BuyTicket_InvalidNumbers_OutOfRange_Fails) -{ - ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const id user = id::randomValue(); - increaseEnergy(user, ticketPrice * 10); - const uint64 balBefore = getBalance(user); - - // Number 0 is invalid (valid range is 1-30) - QTFRandomValues numsWithZero; - numsWithZero.set(0, 0); - numsWithZero.set(1, 2); - numsWithZero.set(2, 3); - numsWithZero.set(3, 4); - - const QTF::BuyTicket_output out1 = ctl.buyTicket(user, ticketPrice, numsWithZero); - EXPECT_EQ(out1.returnCode, static_cast(QTF::EReturnCode::INVALID_NUMBERS)); - EXPECT_EQ(getBalance(user), balBefore); - - // Number 31 is invalid (valid range is 1-30) - QTFRandomValues numsOver30; - numsOver30.set(0, 1); - numsOver30.set(1, 2); - numsOver30.set(2, 3); - numsOver30.set(3, 31); - - const QTF::BuyTicket_output out2 = ctl.buyTicket(user, ticketPrice, numsOver30); - EXPECT_EQ(out2.returnCode, static_cast(QTF::EReturnCode::INVALID_NUMBERS)); - EXPECT_EQ(getBalance(user), balBefore); - - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); -} - -TEST(ContractQThirtyFour, BuyTicket_DuplicateNumbers_Fails) -{ - ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const id user = id::randomValue(); - increaseEnergy(user, ticketPrice * 5); - const uint64 balBefore = getBalance(user); - - // Duplicate number 5 - QTFRandomValues dupNums; - dupNums.set(0, 5); - dupNums.set(1, 5); - dupNums.set(2, 10); - dupNums.set(3, 15); - - const QTF::BuyTicket_output out = ctl.buyTicket(user, ticketPrice, dupNums); - EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_NUMBERS)); - EXPECT_EQ(getBalance(user), balBefore); - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); -} - -TEST(ContractQThirtyFour, BuyTicket_ValidPurchase_Success) -{ - ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const id user = id::randomValue(); - increaseEnergy(user, ticketPrice * 2); - const uint64 balBefore = getBalance(user); - - QTFRandomValues nums = ctl.makeValidNumbers(5, 10, 15, 20); - - const QTF::BuyTicket_output out = ctl.buyTicket(user, ticketPrice, nums); - EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); - EXPECT_EQ(getBalance(user), balBefore - ticketPrice); - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); - - // Verify player data stored correctly - const QTF::PlayerData& player = ctl.state()->getPlayer(0); - EXPECT_EQ(player.player, user); - EXPECT_EQ(player.randomValues.get(0), 5); - EXPECT_EQ(player.randomValues.get(1), 10); - EXPECT_EQ(player.randomValues.get(2), 15); - EXPECT_EQ(player.randomValues.get(3), 20); -} - -TEST(ContractQThirtyFour, BuyTicket_MultiplePlayers_Success) -{ - ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - // Add 10 different players - for (int i = 0; i < 10; ++i) - { - const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers(static_cast(1 + i), static_cast(11 + i), 21, 30); - - ctl.fundAndBuyTicket(user, ticketPrice, nums); - } - - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 10u); -} - -TEST(ContractQThirtyFour, BuyTicket_MaxPlayersReached_Fails) -{ - ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - // Fill up to max players (1024) - for (uint64 i = 0; i < QTF_MAX_NUMBER_OF_PLAYERS; ++i) - { - const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i % 27) + 1), static_cast(((i + 1) % 27) + 1), - static_cast(((i + 2) % 27) + 1), static_cast(((i + 3) % 27) + 1)); - - // Only fund and buy; we expect all to succeed until max - increaseEnergy(user, ticketPrice * 2); - const QTF::BuyTicket_output out = ctl.buyTicket(user, ticketPrice, nums); - EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); - } - - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), QTF_MAX_NUMBER_OF_PLAYERS); - - // Try one more - should fail - const id extraUser = id::randomValue(); - increaseEnergy(extraUser, ticketPrice * 2); - const uint64 balBefore = getBalance(extraUser); - QTFRandomValues nums = ctl.makeValidNumbers(1, 2, 3, 4); - - const QTF::BuyTicket_output out = ctl.buyTicket(extraUser, ticketPrice, nums); - EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::MAX_PLAYERS_REACHED)); - EXPECT_EQ(getBalance(extraUser), balBefore); // Refunded -} - -TEST(ContractQThirtyFour, BuyTicket_SamePlayerMultipleTickets_Allowed) -{ - ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const id user = id::randomValue(); - increaseEnergy(user, ticketPrice * 10); - - // Same player buys multiple tickets with different numbers - QTFRandomValues nums1 = ctl.makeValidNumbers(1, 2, 3, 4); - QTFRandomValues nums2 = ctl.makeValidNumbers(5, 6, 7, 8); - QTFRandomValues nums3 = ctl.makeValidNumbers(9, 10, 11, 12); - - EXPECT_EQ(ctl.buyTicket(user, ticketPrice, nums1).returnCode, static_cast(QTF::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.buyTicket(user, ticketPrice, nums2).returnCode, static_cast(QTF::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.buyTicket(user, ticketPrice, nums3).returnCode, static_cast(QTF::EReturnCode::SUCCESS)); - - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 3u); -} - -// ============================================================================ -// CONFIGURATION CHANGE TESTS -// ============================================================================ - -TEST(ContractQThirtyFour, SetPrice_AccessControl) -{ - ContractTestingQTF ctl; - - const uint64 oldPrice = ctl.state()->getTicketPriceInternal(); - const uint64 newPrice = oldPrice * 2; - - // Random user should be denied - const id randomUser = id::randomValue(); - increaseEnergy(randomUser, 1); - const QTF::SetPrice_output outDenied = ctl.setPrice(randomUser, newPrice); - EXPECT_EQ(outDenied.returnCode, static_cast(QTF::EReturnCode::ACCESS_DENIED)); - - // Price unchanged - EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); -} - -TEST(ContractQThirtyFour, SetPrice_ZeroNotAllowed) -{ - ContractTestingQTF ctl; - increaseEnergy(QTF_DEV_ADDRESS, 1); - - const uint64 oldPrice = ctl.state()->getTicketPriceInternal(); - const QTF::SetPrice_output outInvalid = ctl.setPrice(QTF_DEV_ADDRESS, 0); - EXPECT_EQ(outInvalid.returnCode, static_cast(QTF::EReturnCode::INVALID_TICKET_PRICE)); - - // Price unchanged - EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); -} - -TEST(ContractQThirtyFour, SetPrice_AppliesAfterEndEpoch) -{ - ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); - increaseEnergy(QTF_DEV_ADDRESS, 1); - - const uint64 oldPrice = ctl.state()->getTicketPriceInternal(); - const uint64 newPrice = oldPrice * 3; - - const QTF::SetPrice_output outOk = ctl.setPrice(QTF_DEV_ADDRESS, newPrice); - EXPECT_EQ(outOk.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); - - // Queued in NextEpochData - EXPECT_EQ(ctl.getNextEpochData().nextEpochData.newTicketPrice, newPrice); - - // Old price still active - EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); - - // Apply after END_EPOCH - ctl.endEpoch(); - ctl.beginEpoch(); - EXPECT_EQ(ctl.getTicketPrice().ticketPrice, newPrice); - - // NextEpochData cleared - EXPECT_EQ(ctl.getNextEpochData().nextEpochData.newTicketPrice, 0u); -} - -TEST(ContractQThirtyFour, SetSchedule_AccessControl) -{ - ContractTestingQTF ctl; - - const id randomUser = id::randomValue(); - increaseEnergy(randomUser, 1); - const QTF::SetSchedule_output outDenied = ctl.setSchedule(randomUser, QTF_ANY_DAY_SCHEDULE); - EXPECT_EQ(outDenied.returnCode, static_cast(QTF::EReturnCode::ACCESS_DENIED)); -} - -TEST(ContractQThirtyFour, SetSchedule_ZeroNotAllowed) -{ - ContractTestingQTF ctl; - increaseEnergy(QTF_DEV_ADDRESS, 1); - - const QTF::SetSchedule_output outInvalid = ctl.setSchedule(QTF_DEV_ADDRESS, 0); - EXPECT_EQ(outInvalid.returnCode, static_cast(QTF::EReturnCode::INVALID_VALUE)); -} - -TEST(ContractQThirtyFour, SetSchedule_AppliesAfterEndEpoch) -{ - ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); - increaseEnergy(QTF_DEV_ADDRESS, 1); - - const uint8 newSchedule = 0x7F; // All days - - const QTF::SetSchedule_output outOk = ctl.setSchedule(QTF_DEV_ADDRESS, newSchedule); - EXPECT_EQ(outOk.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); - - // Queued - EXPECT_EQ(ctl.getNextEpochData().nextEpochData.newSchedule, newSchedule); - - // Apply - ctl.endEpoch(); - ctl.beginEpoch(); - EXPECT_EQ(ctl.getSchedule().schedule, newSchedule); -} - -TEST(ContractQThirtyFour, SetTargetJackpot_AccessControl) -{ - ContractTestingQTF ctl; - - const id randomUser = id::randomValue(); - increaseEnergy(randomUser, 1); - const QTF::SetTargetJackpot_output outDenied = ctl.setTargetJackpot(randomUser, 2000000000ULL); - EXPECT_EQ(outDenied.returnCode, static_cast(QTF::EReturnCode::ACCESS_DENIED)); -} - -TEST(ContractQThirtyFour, SetTargetJackpot_ZeroNotAllowed) -{ - ContractTestingQTF ctl; - increaseEnergy(QTF_DEV_ADDRESS, 1); - - const QTF::SetTargetJackpot_output outInvalid = ctl.setTargetJackpot(QTF_DEV_ADDRESS, 0); - EXPECT_EQ(outInvalid.returnCode, static_cast(QTF::EReturnCode::INVALID_VALUE)); -} - -TEST(ContractQThirtyFour, SetTargetJackpot_AppliesAfterEndEpoch) -{ - ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); - increaseEnergy(QTF_DEV_ADDRESS, 1); - - const uint64 newTarget = 5000000000ULL; - - const QTF::SetTargetJackpot_output outOk = ctl.setTargetJackpot(QTF_DEV_ADDRESS, newTarget); - EXPECT_EQ(outOk.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); - - // Queued - EXPECT_EQ(ctl.getNextEpochData().nextEpochData.newTargetJackpot, newTarget); - - // Apply - ctl.endEpoch(); - ctl.beginEpoch(); - EXPECT_EQ(ctl.state()->getTargetJackpotInternal(), newTarget); -} - -TEST(ContractQThirtyFour, SetDrawHour_AccessControl) -{ - ContractTestingQTF ctl; - - const id randomUser = id::randomValue(); - increaseEnergy(randomUser, 1); - const QTF::SetDrawHour_output outDenied = ctl.setDrawHour(randomUser, 15); - EXPECT_EQ(outDenied.returnCode, static_cast(QTF::EReturnCode::ACCESS_DENIED)); -} - -TEST(ContractQThirtyFour, SetDrawHour_InvalidValues) -{ - ContractTestingQTF ctl; - increaseEnergy(QTF_DEV_ADDRESS, 2); - - // 0 is invalid - const QTF::SetDrawHour_output out0 = ctl.setDrawHour(QTF_DEV_ADDRESS, 0); - EXPECT_EQ(out0.returnCode, static_cast(QTF::EReturnCode::INVALID_VALUE)); - - // 24+ is invalid - const QTF::SetDrawHour_output out24 = ctl.setDrawHour(QTF_DEV_ADDRESS, 24); - EXPECT_EQ(out24.returnCode, static_cast(QTF::EReturnCode::INVALID_VALUE)); -} - -TEST(ContractQThirtyFour, SetDrawHour_AppliesAfterEndEpoch) -{ - ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); - increaseEnergy(QTF_DEV_ADDRESS, 1); - - const uint8 newHour = 18; - - const QTF::SetDrawHour_output outOk = ctl.setDrawHour(QTF_DEV_ADDRESS, newHour); - EXPECT_EQ(outOk.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); - - // Queued - EXPECT_EQ(ctl.getNextEpochData().nextEpochData.newDrawHour, newHour); - - // Apply - ctl.endEpoch(); - ctl.beginEpoch(); - EXPECT_EQ(ctl.getDrawHour().drawHour, newHour); -} - -// ============================================================================ -// STATE AND POOLS TESTS -// ============================================================================ - -TEST(ContractQThirtyFour, GetState_NoneThenSelling) -{ - ContractTestingQTF ctl; - - // Initially not selling - EXPECT_EQ(ctl.getStateInfo().currentState, static_cast(QTF::EState::STATE_NONE)); - - // After epoch start with valid time it should sell - ctl.beginEpochWithValidTime(); - EXPECT_EQ(ctl.getStateInfo().currentState, static_cast(QTF::EState::STATE_SELLING)); -} - -TEST(ContractQThirtyFour, GetPools_ReserveReflectsQRPAvailable) -{ - ContractTestingQTF ctl; - - const QTF::GetPools_output poolsBefore = ctl.getPools(); - const uint64 before = poolsBefore.pools.reserve; - - constexpr uint64 qrpFunding = 10'000'000'000ULL; - increaseEnergy(ctl.qrpSelf(), qrpFunding); - - const QTF::GetPools_output poolsAfter = ctl.getPools(); - const uint64 after = poolsAfter.pools.reserve; - - EXPECT_GE(after, before); - EXPECT_GT(after, 0u); - EXPECT_LE(after, before + qrpFunding); -} - -// ============================================================================ -// SETTLEMENT AND PAYOUT TESTS -// ============================================================================ - -TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - ctl.forceFRDisabledForBaseline(); - - // Fix RNG so we can deterministically avoid winners (and especially k=4). - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0x1010101010101010ULL; - const auto nums = ctl.computeWinningAndLosing(testDigest); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const QTF::GetFees_output fees = ctl.getFees(); - constexpr uint64 numPlayers = 10; - - // Ensure RL shares exist so distribution path is exercised deterministically. - const id shareholder1 = id::randomValue(); - const id shareholder2 = id::randomValue(); - constexpr uint32 shares1 = NUMBER_OF_COMPUTORS / 3; - constexpr uint32 shares2 = NUMBER_OF_COMPUTORS - shares1; - std::vector> rlShares{{shareholder1, shares1}, {shareholder2, shares2}}; - issueRlSharesTo(rlShares); - - // Verify FR is not active initially (baseline mode) - EXPECT_EQ(ctl.state()->getFrActive(), false); - - // Add players - ctl.buyRandomTickets(numPlayers, ticketPrice, nums.losing); - - const uint64 totalRevenue = ticketPrice * numPlayers; - const uint64 devBalBefore = getBalance(QTF_DEV_ADDRESS); - const uint64 sh1Before = getBalance(shareholder1); - const uint64 sh2Before = getBalance(shareholder2); - const uint64 rlBefore = getBalance(id(RL_CONTRACT_INDEX, 0, 0, 0)); - const uint64 contractBalBefore = getBalance(ctl.qtfSelf()); - - EXPECT_EQ(contractBalBefore, totalRevenue); - - ctl.drawWithDigest(testDigest); - - EXPECT_EQ(ctl.state()->getFrActive(), false); - - // In baseline mode (FR not active), dev receives full 10% of revenue - // No redirects are applied - const uint64 expectedDevFee = (totalRevenue * fees.teamFeePercent) / 100; - EXPECT_EQ(getBalance(QTF_DEV_ADDRESS), devBalBefore + expectedDevFee) - << "In baseline mode, dev should receive full " << static_cast(fees.teamFeePercent) << "% of revenue"; - - // Distribution is paid to RL shareholders with flooring to dividendPerShare and payback remainder to RL contract. - const uint64 expectedDistFee = (totalRevenue * fees.distributionFeePercent) / 100; - const uint64 dividendPerShare = expectedDistFee / NUMBER_OF_COMPUTORS; - const uint64 expectedSh1Gain = static_cast(shares1) * dividendPerShare; - const uint64 expectedSh2Gain = static_cast(shares2) * dividendPerShare; - const uint64 expectedPayback = expectedDistFee - (dividendPerShare * NUMBER_OF_COMPUTORS); - EXPECT_EQ(getBalance(shareholder1), sh1Before + expectedSh1Gain); - EXPECT_EQ(getBalance(shareholder2), sh2Before + expectedSh2Gain); - EXPECT_EQ(getBalance(id(RL_CONTRACT_INDEX, 0, 0, 0)), rlBefore + expectedPayback); - - // No winners -> winnersOverflow == winnersBlock. In baseline: 50/50 split reserve/jackpot. - const uint64 winnersBlock = (totalRevenue * fees.winnerFeePercent) / 100; - const uint64 reserveAdd = (winnersBlock * QTF_BASELINE_OVERFLOW_ALPHA_BP) / 10000; - const uint64 expectedJackpotAdd = winnersBlock - reserveAdd; - EXPECT_EQ(ctl.state()->getJackpot(), expectedJackpotAdd); - EXPECT_EQ(static_cast(getBalance(ctl.qtfSelf())), expectedJackpotAdd) << "Contract balance should match carry (jackpot) after settlement"; - - // Players cleared - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); -} - -TEST(ContractQThirtyFour, Settlement_NoPlayers_NoChanges) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - - const uint64 jackpotBefore = ctl.state()->getJackpot(); - const QTF::GetWinnerData_output winnersBefore = ctl.getWinnerData(); - - ctl.triggerDrawTick(); - - // No changes when no players - EXPECT_EQ(ctl.state()->getJackpot(), jackpotBefore); - const QTF::GetWinnerData_output winnersAfter = ctl.getWinnerData(); - EXPECT_EQ(winnersAfter.winnerData.winnerCounter, winnersBefore.winnerData.winnerCounter); -} - -TEST(ContractQThirtyFour, Settlement_InsufficientBalance_ClearsPlayersAndAbortsSettlement) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0x3030303030303030ULL; - const auto nums = ctl.computeWinningAndLosing(testDigest); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - constexpr uint64 numPlayers = 2; - - ctl.buyRandomTickets(numPlayers, ticketPrice, nums.losing); - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), numPlayers); - - // Drain the contract so CheckContractBalance() fails in SettleEpoch. - const uint64 totalRevenue = ticketPrice * numPlayers; - const int qtfIndex = spectrumIndex(ctl.qtfSelf()); - ASSERT_GE(qtfIndex, 0); - ASSERT_TRUE(decreaseEnergy(qtfIndex, totalRevenue)); - EXPECT_EQ(getBalance(ctl.qtfSelf()), 0); - - ctl.drawWithDigest(testDigest); - - // Even if refunds can't be paid (because we drained balance), the contract must clear the epoch state. - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0ULL); -} - -TEST(ContractQThirtyFour, Settlement_WithPlayers_FeesDistributed_FRMode) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - - // Fix RNG so we can deterministically avoid winners (and especially k=4). - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0x2020202020202020ULL; - const auto nums = ctl.computeWinningAndLosing(testDigest); - - // Activate FR mode - ctl.state()->setJackpot(100000000ULL); // Below target - ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); - ctl.forceFREnabledWithinWindow(5); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const QTF::GetFees_output fees = ctl.getFees(); - constexpr uint64 numPlayers = 10; - - // Verify FR is active - EXPECT_EQ(ctl.state()->getFrActive(), true); - - // Add players - ctl.buyRandomTickets(numPlayers, ticketPrice, nums.losing); - - const uint64 totalRevenue = ticketPrice * numPlayers; - const uint64 devBalBefore = getBalance(QTF_DEV_ADDRESS); - const uint64 contractBalBefore = getBalance(ctl.qtfSelf()); - const uint64 jackpotBefore = ctl.state()->getJackpot(); - const uint64 roundsSinceK4Before = ctl.state()->getFrRoundsSinceK4(); - - EXPECT_EQ(contractBalBefore, totalRevenue); - - ctl.drawWithDigest(testDigest); - - // In FR mode, dev receives less than full 10% of revenue - // Base redirect: 1% of revenue (QTF_FR_DEV_REDIRECT_BP = 100 basis points) - // Possible extra redirect depending on deficit - const uint64 baseDevRedirect = (totalRevenue * QTF_FR_DEV_REDIRECT_BP) / 10000; - - // Full dev fee from revenue split (10%) - const uint64 fullDevFee = (totalRevenue * fees.teamFeePercent) / 100; - - // Actual dev payout = fullDevFee - redirects - // Expected: fullDevFee - at least baseDevRedirect - const uint64 maxExpectedDevPayout = fullDevFee - baseDevRedirect; - - const uint64 actualDevPayout = getBalance(QTF_DEV_ADDRESS) - devBalBefore; - - // Dev should receive less than full fee (due to redirects to jackpot) - EXPECT_LT(actualDevPayout, fullDevFee) << "In FR mode, dev payout should be reduced by redirects"; - - // Dev should receive at most fullDevFee - baseDevRedirect - EXPECT_LE(actualDevPayout, maxExpectedDevPayout) << "Dev payout should be reduced by at least base redirect (1%)"; - - // Jackpot should have grown (receives redirects) - EXPECT_GT(ctl.state()->getJackpot(), jackpotBefore) << "Jackpot should grow from dev/dist redirects in FR mode"; - - // No k=4 can happen (we buy losing tickets), so counter increments. - EXPECT_EQ(ctl.state()->getFrRoundsSinceK4(), roundsSinceK4Before + 1); - - // Players cleared - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); -} - -TEST(ContractQThirtyFour, Settlement_JackpotGrowsFromOverflow) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - ctl.forceFRDisabledForBaseline(); - - // Fix RNG so we can deterministically create "no winners" tickets. - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0xBADC0FFEE0DDF00DULL; - const auto nums = ctl.computeWinningAndLosing(testDigest); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const uint64 jackpotBefore = ctl.state()->getJackpot(); - constexpr uint64 numPlayers = 20; - - // Add players - for (uint64 i = 0; i < numPlayers; ++i) - { - const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, nums.losing); - } - - // Calculate expected jackpot growth in baseline mode (FR not active) - const uint64 revenue = ticketPrice * numPlayers; - const QTF::GetFees_output fees = ctl.getFees(); - - // winnersBlock = revenue * winnerFeePercent / 100 (68%) - const uint64 winnersBlock = (revenue * fees.winnerFeePercent) / 100; - - // With no winners, the entire winners block becomes overflow (k2+k3 pools also roll into overflow). - const uint64 winnersOverflow = winnersBlock; - - // In baseline mode: 50% of overflow goes to jackpot, 50% to reserve. - const uint64 reserveAdd = (winnersOverflow * QTF_BASELINE_OVERFLOW_ALPHA_BP) / 10000; - const uint64 overflowToJackpot = winnersOverflow - reserveAdd; - - // Minimum expected jackpot growth (assuming no k2/k3 winners, all overflow goes to jackpot) - const uint64 minExpectedGrowth = overflowToJackpot; - - ctl.drawWithDigest(testDigest); - - // Verify jackpot growth - const uint64 jackpotAfter = ctl.state()->getJackpot(); - const uint64 actualGrowth = jackpotAfter - jackpotBefore; - - // Deterministic: losing tickets guarantee no winners, so growth should match exactly. - EXPECT_EQ(actualGrowth, minExpectedGrowth) << "Actual growth: " << actualGrowth << ", Expected: " << minExpectedGrowth - << ", Overflow to jackpot (50%): " << overflowToJackpot; - - // Verify the 50% overflow split is working correctly - const uint64 expected50Percent = winnersOverflow / 2; - EXPECT_GE(overflowToJackpot, expected50Percent - 1) << "50% overflow split verification"; - EXPECT_LE(overflowToJackpot, winnersOverflow) << "Overflow to jackpot should not exceed total overflow"; -} - -TEST(ContractQThirtyFour, Settlement_RoundsSinceK4_Increments) -{ - ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0x1111222233334444ULL; - const auto nums = ctl.computeWinningAndLosing(testDigest); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - // Run several rounds without k=4 win - for (int round = 0; round < 3; ++round) - { - ctl.beginEpochWithValidTime(); - - ctl.buyRandomTickets(5, ticketPrice, nums.losing); - - const uint64 roundsBefore = ctl.state()->getFrRoundsSinceK4(); - ctl.drawWithDigest(testDigest); - - // Deterministic: no ticket matches any winning number, so k=4 cannot occur. - EXPECT_EQ(ctl.state()->getFrRoundsSinceK4(), roundsBefore + 1); - } -} - -// ============================================================================ -// FAST-RECOVERY (FR) TESTS -// ============================================================================ - -TEST(ContractQThirtyFour, FR_Activation_WhenBelowTarget) -{ - ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - - // Set jackpot below target to trigger FR - ctl.state()->setJackpot(100000000ULL); // 100M - ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); // 1B target - ctl.state()->setFrRoundsSinceK4(5); // Within post-k4 window - - EXPECT_EQ(ctl.state()->getFrActive(), false); - - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - // Add players and settle - for (int i = 0; i < 10; ++i) - { - const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i % 25) + 1), static_cast((i % 25) + 2), - static_cast((i % 25) + 3), static_cast((i % 25) + 4)); - ctl.fundAndBuyTicket(user, ticketPrice, nums); - } - - ctl.triggerDrawTick(); - - // FR should be active since jackpot < target and within window - EXPECT_EQ(ctl.state()->getFrActive(), true); -} - -TEST(ContractQThirtyFour, FR_Deactivation_AfterHysteresis) -{ - ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0x3030303030303030ULL; - const auto nums = ctl.computeWinningAndLosing(testDigest); - - // Set jackpot at target - ctl.state()->setJackpot(QTF_DEFAULT_TARGET_JACKPOT); - ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); - ctl.state()->setFrActive(true); - ctl.state()->setFrRoundsAtOrAboveTarget(0); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - // Run rounds at or above target (hysteresis requirement) - for (int round = 0; round < QTF_FR_HYSTERESIS_ROUNDS; ++round) - { - ctl.beginEpochWithValidTime(); - - ctl.buyRandomTickets(5, ticketPrice, nums.losing); - - // Keep jackpot at target (add back what might be paid out) - ctl.state()->setJackpot(QTF_DEFAULT_TARGET_JACKPOT); - ctl.drawWithDigest(testDigest); - } - - // After 3 rounds at target, FR should deactivate - EXPECT_GE(ctl.state()->getFrRoundsAtOrAboveTarget(), QTF_FR_HYSTERESIS_ROUNDS); - EXPECT_EQ(ctl.state()->getFrActive(), false); -} - -TEST(ContractQThirtyFour, FR_OverflowBias_95PercentToJackpot) -{ - ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - - // Fix RNG so we can deterministically create "no winners" tickets. - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0xCAFEBABEDEADBEEFULL; - const auto nums = ctl.computeWinningAndLosing(testDigest); - - // Activate FR - ctl.state()->setJackpot(100000000ULL); // Below target - ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); - ctl.state()->setFrActive(true); - ctl.state()->setFrRoundsSinceK4(5); - - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const uint64 jackpotBefore = ctl.state()->getJackpot(); - constexpr uint64 numPlayers = 50; - - // Add many players to generate significant overflow - ctl.buyRandomTickets(numPlayers, ticketPrice, nums.losing); - - // Calculate expected jackpot growth - const uint64 revenue = ticketPrice * numPlayers; - const QTF::GetFees_output fees = ctl.getFees(); - - // winnersBlock = revenue * winnerFeePercent / 100 (68%) - const uint64 winnersBlock = (revenue * fees.winnerFeePercent) / 100; - - // In FR mode: 5% rake from winnersBlock goes to jackpot - const uint64 winnersRake = (winnersBlock * QTF_FR_WINNERS_RAKE_BP) / 10000; - const uint64 winnersBlockAfterRake = winnersBlock - winnersRake; - - // With no winners, the entire winners block after rake becomes overflow (k2+k3 pools also roll into overflow). - const uint64 winnersOverflow = winnersBlockAfterRake; - - // In FR mode: 95% of overflow goes to jackpot, 5% to reserve - const uint64 reserveAdd = (winnersOverflow * QTF_FR_ALPHA_BP) / 10000; - const uint64 overflowToJackpot = winnersOverflow - reserveAdd; - - // Dev and Dist redirects in FR mode: base (1% each) + extra (deficit-driven) - // First calculate base gain to pass to extra redirect calculation - QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - primeQpiFunctionContext(qpi); - const auto baseGainOut = ctl.state()->callCalculateBaseGain(qpi, revenue, winnersBlock); - - // Calculate extra redirect based on deficit - const uint64 delta = ctl.state()->getTargetJackpotInternal() - jackpotBefore; - const auto extraOut = ctl.state()->callCalculateExtraRedirectBP(qpi, numPlayers, delta, revenue, baseGainOut.baseGain); - - // Total redirect BP = base + extra (split 50/50 between dev and dist) - const uint64 devExtraBP = extraOut.extraBP / 2; - const uint64 distExtraBP = extraOut.extraBP - devExtraBP; - const uint64 totalDevRedirectBP = QTF_FR_DEV_REDIRECT_BP + devExtraBP; - const uint64 totalDistRedirectBP = QTF_FR_DIST_REDIRECT_BP + distExtraBP; - - const uint64 devRedirect = (revenue * totalDevRedirectBP) / 10000; - const uint64 distRedirect = (revenue * totalDistRedirectBP) / 10000; - - // Expected jackpot growth (with both base and extra redirects, assuming no k2/k3 winners) - // totalJackpotContribution = overflowToJackpot + winnersRake + devRedirect + distRedirect - const uint64 expectedGrowth = overflowToJackpot + winnersRake + devRedirect + distRedirect; - - ctl.drawWithDigest(testDigest); - - // Verify that jackpot grew by the expected amount - const uint64 actualGrowth = ctl.state()->getJackpot() - jackpotBefore; - - // Deterministic: losing tickets guarantee no winners, so growth should match exactly. - EXPECT_EQ(actualGrowth, expectedGrowth) << "Actual growth: " << actualGrowth << ", Expected: " << expectedGrowth - << ", Overflow to jackpot (95%): " << overflowToJackpot << ", Winners rake: " << winnersRake - << ", Extra redirect BP: " << extraOut.extraBP; - - // Verify the 95% overflow bias is working correctly - // overflowToJackpot should be ~95% of winnersOverflow - const uint64 expected95Percent = (winnersOverflow * 95) / 100; - EXPECT_GE(overflowToJackpot, expected95Percent - 1) << "95% overflow bias verification"; - EXPECT_LE(overflowToJackpot, winnersOverflow) << "Overflow to jackpot should not exceed total overflow"; -} - -// ============================================================================ -// WINNER COUNTING AND TIER TESTS -// ============================================================================ - -TEST(ContractQThirtyFour, WinnerData_RecordsWinners) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - // At least one ticket is required, otherwise END_EPOCH returns early and winner values are not generated. - const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, ctl.makeValidNumbers(1, 2, 3, 4)); - - ctl.triggerDrawTick(); - - const QTF::GetWinnerData_output winnerData = ctl.getWinnerData(); - expectWinnerValuesValidAndUnique(winnerData); -} - -TEST(ContractQThirtyFour, WinnerData_ResetEachRound) -{ - ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - - // Round 1: force a deterministic k=2 winner so winnerCounter becomes > 0. - m256i digest1 = {}; - digest1.m256i_u64[0] = 0x13579BDF2468ACE0ULL; - const auto nums1 = ctl.computeWinningAndLosing(digest1); - - ctl.beginEpochWithValidTime(); - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - QTFRandomValues k2Numbers = ctl.makeK2Numbers(nums1.winning); - const id k2Winner = id::randomValue(); - ctl.fundAndBuyTicket(k2Winner, ticketPrice, k2Numbers); - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); - - ctl.drawWithDigest(digest1); - - const QTF::GetWinnerData_output afterRound1 = ctl.getWinnerData(); - EXPECT_GT(afterRound1.winnerData.winnerCounter, 0u); - - // Round 2: force a deterministic "no winners" round, winnerCounter must reset to 0. - m256i digest2 = {}; - digest2.m256i_u64[0] = 0x0F0E0D0C0B0A0908ULL; - const auto nums2 = ctl.computeWinningAndLosing(digest2); - - ctl.beginEpochWithValidTime(); - ctl.buyRandomTickets(5, ticketPrice, nums2.losing); - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 5u); - - ctl.drawWithDigest(digest2); - - const QTF::GetWinnerData_output afterRound2 = ctl.getWinnerData(); - EXPECT_EQ(afterRound2.winnerData.winnerCounter, 0u) << "Winner snapshot must reset each round"; -} - -// ============================================================================ -// EDGE CASE TESTS -// ============================================================================ - -TEST(ContractQThirtyFour, BuyTicket_ValidNumberSelections_EdgeCases_Success) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - static constexpr uint8 cases[][4] = { - {1, 2, 29, 30}, // boundary - {15, 16, 17, 18}, // consecutive - {27, 28, 29, 30}, // highest - {1, 2, 3, 4}, // lowest - }; - - for (uint64 i = 0; i < (sizeof(cases) / sizeof(cases[0])); ++i) - { - const id user = id::randomValue(); - const QTFRandomValues nums = ctl.makeValidNumbers(cases[i][0], cases[i][1], cases[i][2], cases[i][3]); - ctl.fundAndBuyTicket(user, ticketPrice, nums); - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), i + 1); - } -} - -// ============================================================================ -// MULTIPLE ROUNDS TESTS -// ============================================================================ - -TEST(ContractQThirtyFour, MultipleRounds_JackpotAccumulates) -{ - ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0x0DDC0FFEE0DDF00DULL; - const auto nums = ctl.computeWinningAndLosing(testDigest); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - uint64 prevJackpot = 0; - - // Run multiple rounds - for (int round = 0; round < 5; ++round) - { - ctl.beginEpochWithValidTime(); - - ctl.buyRandomTickets(10, ticketPrice, nums.losing); - ctl.drawWithDigest(testDigest); - - // Jackpot should increase each round (no k=4 winners in this test) - const uint64 currentJackpot = ctl.state()->getJackpot(); - EXPECT_GT(currentJackpot, prevJackpot) << "Round " << round << ": jackpot should grow"; - - // Track for next iteration - prevJackpot = currentJackpot; - } -} - -TEST(ContractQThirtyFour, MultipleRounds_StateResetsCorrectly) -{ - ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - for (int round = 0; round < 3; ++round) - { - ctl.beginEpochWithValidTime(); - - // Add different number of players each round - const int playersThisRound = 5 + round * 3; - for (int i = 0; i < playersThisRound; ++i) - { - const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i + round) % 27 + 1), static_cast((i + round + 5) % 27 + 1), - static_cast((i + round + 10) % 27 + 1), static_cast((i + round + 15) % 27 + 1)); - ctl.fundAndBuyTicket(user, ticketPrice, nums); - } - - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), static_cast(playersThisRound)); - - ctl.triggerDrawTick(); - - // Players should be cleared after each round - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); - } -} - -// ============================================================================ -// POST_INCOMING_TRANSFER TEST -// ============================================================================ - -TEST(ContractQThirtyFour, PostIncomingTransfer_StandardTransaction_Refunded) -{ - ContractTestingQTF ctl; - constexpr uint64 transferAmount = 123456789; - - const id sender = id::randomValue(); - increaseEnergy(sender, transferAmount); - EXPECT_EQ(getBalance(sender), transferAmount); - - const id contractAddress = ctl.qtfSelf(); - EXPECT_EQ(getBalance(contractAddress), 0); - - // Standard transaction should be refunded - notifyContractOfIncomingTransfer(sender, contractAddress, transferAmount, QPI::TransferType::standardTransaction); - - // Amount should be refunded to sender - EXPECT_EQ(getBalance(sender), transferAmount); - EXPECT_EQ(getBalance(contractAddress), 0); -} - -// ============================================================================ -// SCHEDULE AND TIME TESTS -// ============================================================================ - -TEST(ContractQThirtyFour, Schedule_WednesdayAlwaysDraws_IgnoresScheduleMask) -{ - ContractTestingQTF ctl; - - // Exclude Wednesday from schedule mask (e.g., Monday only). - constexpr uint8 mondayOnly = 1 << MONDAY; - ctl.forceSchedule(mondayOnly); - - ctl.beginEpochWithValidTime(); - - const m256i testDigest = {}; - ctl.setPrevSpectrumDigest(testDigest); - const auto nums = ctl.computeWinningAndLosing(testDigest); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - for (int i = 0; i < 5; ++i) - { - const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, nums.losing); - } - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 5u); - - // Wednesday should always trigger a draw at/after draw hour, even if schedule mask does not include it. - const uint8 drawHour = ctl.state()->getDrawHourInternal(); - ctl.setDateTime(2025, 1, 15, drawHour); - ctl.forceBeginTick(); - - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); -} - -TEST(ContractQThirtyFour, Schedule_DrawOnlyOnScheduledDays) -{ - ContractTestingQTF ctl; - - // Set schedule to Wednesday only (default) - constexpr uint8 wednesdayOnly = 1 << WEDNESDAY; - ctl.forceSchedule(wednesdayOnly); - - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - // Add players - for (int i = 0; i < 5; ++i) - { - const id user = id::randomValue(); - QTFRandomValues nums = - ctl.makeValidNumbers(static_cast(i + 1), static_cast(i + 5), static_cast(i + 10), static_cast(i + 15)); - ctl.fundAndBuyTicket(user, ticketPrice, nums); - } - - const uint64 playersBefore = ctl.state()->getNumberOfPlayers(); - EXPECT_EQ(playersBefore, 5u); - - // Tuesday 2025-01-14 is not scheduled - should NOT trigger draw - ctl.setDateTime(2025, 1, 14, 12); - ctl.forceBeginTick(); - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), playersBefore); // Unchanged - - // Wednesday 2025-01-15 IS scheduled - should trigger draw - ctl.setDateTime(2025, 1, 15, 12); - ctl.forceBeginTick(); - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); // Cleared after draw -} - -TEST(ContractQThirtyFour, Schedule_DrawAtMostOncePerDay_LastDrawDateStampGuards) -{ - ContractTestingQTF ctl; - - // Use a non-Wednesday scheduled day so selling is re-enabled after the draw. - constexpr uint8 thursdayOnly = 1 << THURSDAY; - ctl.forceSchedule(thursdayOnly); - - ctl.beginEpochWithValidTime(); - - const m256i testDigest = {}; - ctl.setPrevSpectrumDigest(testDigest); - const auto nums = ctl.computeWinningAndLosing(testDigest); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - { - const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, nums.losing); - } - - const uint8 drawHour = ctl.state()->getDrawHourInternal(); - - // First draw on Thursday. - ctl.setDateTime(2025, 1, 16, drawHour); - ctl.forceBeginTick(); - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); - - const uint64 jackpotAfterFirst = ctl.state()->getJackpot(); - const QTF::GetWinnerData_output winnersAfterFirst = ctl.getWinnerData(); - - // Buy another ticket on the same date (selling should be open on non-Wednesday). - { - const id user2 = id::randomValue(); - ctl.fundAndBuyTicket(user2, ticketPrice, nums.losing); - } - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); - - // Second tick on the same date must NOT trigger another draw. - ctl.setDateTime(2025, 1, 16, drawHour); - ctl.forceBeginTick(); - - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); - EXPECT_EQ(ctl.state()->getJackpot(), jackpotAfterFirst); - const QTF::GetWinnerData_output winnersAfterSecondAttempt = ctl.getWinnerData(); - for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) - { - EXPECT_EQ(winnersAfterSecondAttempt.winnerData.winnerValues.get(i), winnersAfterFirst.winnerData.winnerValues.get(i)); - } - EXPECT_EQ((uint64)winnersAfterSecondAttempt.winnerData.epoch, (uint64)winnersAfterFirst.winnerData.epoch); -} - -TEST(ContractQThirtyFour, DrawHour_NoDrawBeforeScheduledHour) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - // Add players - for (int i = 0; i < 5; ++i) - { - const id user = id::randomValue(); - QTFRandomValues nums = - ctl.makeValidNumbers(static_cast(i + 1), static_cast(i + 5), static_cast(i + 10), static_cast(i + 15)); - ctl.fundAndBuyTicket(user, ticketPrice, nums); - } - - const uint8 drawHour = ctl.state()->getDrawHourInternal(); - const uint64 playersBefore = ctl.state()->getNumberOfPlayers(); - - // Before draw hour - should NOT trigger draw - ctl.setDateTime(2025, 1, 15, drawHour - 1); - ctl.forceBeginTick(); - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), playersBefore); - - // At or after draw hour - should trigger draw - ctl.setDateTime(2025, 1, 15, drawHour); - ctl.forceBeginTick(); - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); -} - -TEST(ContractQThirtyFour, DrawHour_WednesdayDrawClosesTicketSelling) -{ - ContractTestingQTF ctl; - - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - ctl.beginEpochWithValidTime(); - - const m256i testDigest = {}; - ctl.setPrevSpectrumDigest(testDigest); - const auto nums = ctl.computeWinningAndLosing(testDigest); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - { - const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, nums.losing); - } - - const uint8 drawHour = ctl.state()->getDrawHourInternal(); - ctl.setDateTime(2025, 1, 15, drawHour); - ctl.forceBeginTick(); - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); - - // After a Wednesday draw, selling must remain closed until next epoch. - const id lateBuyer = id::randomValue(); - increaseEnergy(lateBuyer, ticketPrice * 2); - const uint64 before = getBalance(lateBuyer); - const QTF::BuyTicket_output out = ctl.buyTicket(lateBuyer, ticketPrice, nums.losing); - EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::TICKET_SELLING_CLOSED)); - EXPECT_EQ(getBalance(lateBuyer), before); -} - -// ============================================================================ -// PROBABILITY AND COMBINATORICS VERIFICATION -// ============================================================================ - -TEST(ContractQThirtyFour, Combinatorics_P4Denominator) -{ - // Verify the P4 denominator constant matches combinatorics - // C(30,4) = 30! / (4! * 26!) = 27405 - constexpr uint64 numerator = QTF_MAX_RANDOM_VALUE * 29 * 28 * 27; - constexpr uint64 denominator = QTF_RANDOM_VALUES_COUNT * 3 * 2 * 1; - constexpr uint64 expected = numerator / denominator; - - EXPECT_EQ(expected, QTF_P4_DENOMINATOR); - EXPECT_EQ(QTF_P4_DENOMINATOR, 27405u); -} - -// ============================================================================ -// FEE CALCULATION VERIFICATION -// ============================================================================ - -TEST(ContractQThirtyFour, FeeCalculation_TotalEquals100Percent) -{ - ContractTestingQTF ctl; - const QTF::GetFees_output fees = ctl.getFees(); - - const uint32 total = fees.teamFeePercent + fees.distributionFeePercent + fees.winnerFeePercent + fees.burnPercent; - - EXPECT_EQ(total, 100u); -} - -// ============================================================================ -// PRIZE PAYOUT ESTIMATION -// ============================================================================ - -TEST(ContractQThirtyFour, EstimatePrizePayouts_NoTickets) -{ - ContractTestingQTF ctl; - - // No tickets sold, should return zero payouts - QTF::EstimatePrizePayouts_output estimate = ctl.estimatePrizePayouts(1, 1); - - EXPECT_EQ(estimate.k2PayoutPerWinner, 0ull); - EXPECT_EQ(estimate.k3PayoutPerWinner, 0ull); - EXPECT_EQ(estimate.k2Pool, 0ull); - EXPECT_EQ(estimate.k3Pool, 0ull); - EXPECT_EQ(estimate.totalRevenue, 0ull); -} - -TEST(ContractQThirtyFour, EstimatePrizePayouts_WithTicketsSingleWinner) -{ - ContractTestingQTF ctl; - - ctl.startAnyDayEpoch(); - - // Buy 100 tickets - constexpr uint64 ticketPrice = 1000000ull; // 1M QU - constexpr uint64 numTickets = 100; - - const QTFRandomValues numbers = ctl.makeValidNumbers(1, 2, 3, 4); - ctl.buyRandomTickets(numTickets, ticketPrice, numbers); - - // Verify tickets were purchased - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), numTickets); - - // Estimate for 1 k2 winner and 1 k3 winner - QTF::EstimatePrizePayouts_output estimate = ctl.estimatePrizePayouts(1, 1); - - const uint64 expectedRevenue = ticketPrice * numTickets; - EXPECT_EQ(estimate.totalRevenue, expectedRevenue); - - // Check minimum floors and cap using constants from contract - constexpr uint64 expectedK2Floor = ticketPrice * QTF_K2_FLOOR_MULT / QTF_K2_FLOOR_DIV; - constexpr uint64 expectedK3Floor = ticketPrice * QTF_K3_FLOOR_MULT; - constexpr uint64 expectedCap = ticketPrice * QTF_TOPUP_PER_WINNER_CAP_MULT; - EXPECT_EQ(estimate.k2MinFloor, expectedK2Floor); - EXPECT_EQ(estimate.k3MinFloor, expectedK3Floor); - EXPECT_EQ(estimate.perWinnerCap, expectedCap); - - // Winners block using contract constants - const QTF::GetFees_output fees = ctl.getFees(); - uint64 winnersBlock = 0, k2PoolExpected = 0, k3PoolExpected = 0; - computeBaselinePrizePools(expectedRevenue, fees, winnersBlock, k2PoolExpected, k3PoolExpected); - - EXPECT_EQ(estimate.k2Pool, k2PoolExpected); - EXPECT_EQ(estimate.k3Pool, k3PoolExpected); - - // With 1 winner each: k2 payout equals pool (below cap), k3 payout is capped at 25*P - EXPECT_EQ(estimate.k2PayoutPerWinner, k2PoolExpected); // 19.04M < 25M cap - EXPECT_EQ(estimate.k3PayoutPerWinner, expectedCap); // 27.2M capped to 25M -} - -TEST(ContractQThirtyFour, EstimatePrizePayouts_WithMultipleWinners) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - - // Buy 1000 tickets - const uint64 ticketPrice = 1000000ull; - const uint64 numTickets = 1000; - - const QTFRandomValues numbers = ctl.makeValidNumbers(5, 10, 15, 20); - ctl.buyRandomTickets(numTickets, ticketPrice, numbers); - - // Verify tickets were purchased - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), numTickets); - - // Estimate for 10 k2 winners and 5 k3 winners - QTF::EstimatePrizePayouts_output estimate = ctl.estimatePrizePayouts(10, 5); - - const uint64 expectedRevenue = ticketPrice * numTickets; - const QTF::GetFees_output fees = ctl.getFees(); - uint64 winnersBlock = 0, k2Pool = 0, k3Pool = 0; - computeBaselinePrizePools(expectedRevenue, fees, winnersBlock, k2Pool, k3Pool); - - // Verify pools - EXPECT_EQ(estimate.k2Pool, k2Pool); - EXPECT_EQ(estimate.k3Pool, k3Pool); - - // Verify per-winner payouts (should be pool / winner count, capped) - const uint64 k2ExpectedPerWinner = k2Pool / 10; - const uint64 k3ExpectedPerWinner = k3Pool / 5; - - EXPECT_EQ(estimate.k2PayoutPerWinner, std::min(k2ExpectedPerWinner, estimate.perWinnerCap)); - EXPECT_EQ(estimate.k3PayoutPerWinner, std::min(k3ExpectedPerWinner, estimate.perWinnerCap)); - - // Both should be above minimum floors - EXPECT_GE(estimate.k2PayoutPerWinner, estimate.k2MinFloor); - EXPECT_GE(estimate.k3PayoutPerWinner, estimate.k3MinFloor); -} - -TEST(ContractQThirtyFour, EstimatePrizePayouts_NoWinnersShowsPotential) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - - // Buy 50 tickets - const uint64 ticketPrice = 1000000ull; - const uint64 numTickets = 50; - - const QTFRandomValues numbers = ctl.makeValidNumbers(7, 14, 21, 28); - ctl.buyRandomTickets(numTickets, ticketPrice, numbers); - - // Verify tickets were purchased - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), numTickets); - - // Estimate with 0 winners (shows what a single winner would get) - QTF::EstimatePrizePayouts_output estimate = ctl.estimatePrizePayouts(0, 0); - - const uint64 expectedRevenue = ticketPrice * numTickets; - const QTF::GetFees_output fees = ctl.getFees(); - uint64 winnersBlock = 0, k2Pool = 0, k3Pool = 0; - computeBaselinePrizePools(expectedRevenue, fees, winnersBlock, k2Pool, k3Pool); - - // When no winners specified, should show full pool (capped) - EXPECT_EQ(estimate.k2PayoutPerWinner, std::min(k2Pool, estimate.perWinnerCap)); - EXPECT_EQ(estimate.k3PayoutPerWinner, std::min(k3Pool, estimate.perWinnerCap)); -} - -// ============================================================================ -// DETERMINISTIC WINNER TESTING -// ============================================================================ -// Solution: By fixing prevSpectrumDigest, we can deterministically control winning numbers -// -// Background: -// Settlement generates winning numbers using: seed = K12(prevSpectrumDigest).u64._0 -// This seed is then used in GetRandomValues (QThirtyFour.h:1663-1698) to derive 4 numbers. -// -// Approach: -// 1. Create a fixed test prevSpectrumDigest (e.g., testDigest) -// 2. Compute expected winning numbers for that digest -// 3. Buy tickets with exact winning numbers (for k=4), partial matches (for k=2/k=3), etc. -// 4. Trigger settlement with drawWithDigest(testDigest) -// 5. Settlement will use our fixed digest, generating the pre-computed winning numbers -// 6. Verify actual payouts, jackpot depletion, FR resets, etc. -// -// This enables deterministic testing of: -// - Actual k=4 jackpot win payouts and jackpot depletion -// - Actual k=2/k=3 winner payouts with real matching logic -// - Actual FR reset behavior after k=4 win (frRoundsSinceK4 = 0) -// - Pool splitting among multiple winners -// - Revenue distribution and fee calculations with real winners - -TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_DepletesAndReseeds) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - - // Ensure QRP has enough reserve to reseed to target. - increaseEnergy(ctl.qrpSelf(), QTF_DEFAULT_TARGET_JACKPOT + 1000000ULL); - const uint64 qrpBalanceBefore = static_cast(getBalance(ctl.qrpSelf())); - - // Create a deterministic prevSpectrumDigest - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0x123456789ABCDEF0ULL; // Arbitrary seed - - const auto nums = ctl.computeWinningAndLosing(testDigest); - - // Setup: FR active with jackpot below target - const uint64 initialJackpot = 800000000ULL; // 800M QU - ctl.state()->setJackpot(initialJackpot); - ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); // 1B target - ctl.forceFREnabledWithinWindow(10); - // IMPORTANT: internal `state.jackpot` must be backed by actual contract balance, otherwise transfers will fail. - increaseEnergy(ctl.qtfSelf(), initialJackpot); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - // User1: Buy ticket with EXACT winning numbers (k=4 winner) - const id k4Winner = id::randomValue(); - ctl.fundAndBuyTicket(k4Winner, ticketPrice, nums.winning); - - // User2: Buy ticket with 3 matching numbers (k=3 winner) - QTFRandomValues k3Numbers = ctl.makeK3Numbers(nums.winning); - const id k3Winner = id::randomValue(); - ctl.fundAndBuyTicket(k3Winner, ticketPrice, k3Numbers); - - // User3: Buy ticket with 2 matching numbers (k=2 winner) - QTFRandomValues k2Numbers = ctl.makeK2Numbers(nums.winning); - const id k2Winner = id::randomValue(); - ctl.fundAndBuyTicket(k2Winner, ticketPrice, k2Numbers); - - // User4: No match - const id loser = id::randomValue(); - QTFRandomValues loserNumbers = ctl.makeValidNumbers(1, 2, 3, 4); - ctl.fundAndBuyTicket(loser, ticketPrice, loserNumbers); - - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 4ULL); - - // Verify state before settlement - const uint64 jackpotBefore = ctl.state()->getJackpot(); - const uint64 roundsSinceK4Before = ctl.state()->getFrRoundsSinceK4(); - EXPECT_EQ(jackpotBefore, initialJackpot); - EXPECT_EQ(roundsSinceK4Before, 10u); - - // Trigger settlement using our fixed prevSpectrumDigest - const uint64 k4WinnerBefore = getBalance(k4Winner); - ctl.drawWithDigest(testDigest); - const uint64 k4WinnerAfter = getBalance(k4Winner); - - // Verify k=4 jackpot win behavior: - const uint64 jackpotAfter = ctl.state()->getJackpot(); - EXPECT_GE(jackpotAfter, QTF_DEFAULT_TARGET_JACKPOT) << "Jackpot should be reseeded from QRP after k=4 win"; - EXPECT_LT(static_cast(getBalance(ctl.qrpSelf())), qrpBalanceBefore) << "QRP reserve should decrease due to reseed"; - - // FR counters reset - const uint64 roundsSinceK4After = ctl.state()->getFrRoundsSinceK4(); - EXPECT_EQ(roundsSinceK4After, 0u) << "frRoundsSinceK4 should reset to 0 after k=4 win"; - - const uint64 roundsAtTargetAfter = ctl.state()->getFrRoundsAtOrAboveTarget(); - EXPECT_EQ(roundsAtTargetAfter, 0u) << "frRoundsAtOrAboveTarget should reset to 0 after k=4 win"; - - // 3. Verify winner data contains our winning numbers - QTF::GetWinnerData_output winnerData = ctl.getWinnerData(); - EXPECT_EQ(winnerData.winnerData.winnerValues.get(0), nums.winning.get(0)); - EXPECT_EQ(winnerData.winnerData.winnerValues.get(1), nums.winning.get(1)); - EXPECT_EQ(winnerData.winnerData.winnerValues.get(2), nums.winning.get(2)); - EXPECT_EQ(winnerData.winnerData.winnerValues.get(3), nums.winning.get(3)); - - // Verify k=4 winner received exact payout (jackpotBefore / countK4). - EXPECT_EQ(static_cast(k4WinnerAfter - k4WinnerBefore), initialJackpot); -} - -TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_MultipleWinners_SplitsEvenly) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - - // Ensure QRP has enough reserve to reseed (so settlement completes without relying on carry math). - increaseEnergy(ctl.qrpSelf(), QTF_DEFAULT_TARGET_JACKPOT + 1000000ULL); - - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0xA5A5A5A5A5A5A5A5ULL; - const auto nums = ctl.computeWinningAndLosing(testDigest); - - const uint64 initialJackpot = 900000000ULL; - ctl.state()->setJackpot(initialJackpot); - ctl.forceFREnabledWithinWindow(1); - increaseEnergy(ctl.qtfSelf(), initialJackpot); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - const id w1 = id::randomValue(); - const id w2 = id::randomValue(); - ctl.fundAndBuyTicket(w1, ticketPrice, nums.winning); - ctl.fundAndBuyTicket(w2, ticketPrice, nums.winning); - - const uint64 w1Before = getBalance(w1); - const uint64 w2Before = getBalance(w2); - - ctl.drawWithDigest(testDigest); - - const uint64 expectedPerWinner = initialJackpot / 2; - EXPECT_EQ(static_cast(getBalance(w1) - w1Before), expectedPerWinner); - EXPECT_EQ(static_cast(getBalance(w2) - w2Before), expectedPerWinner); -} - -TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_ReseedLimitedByQRP) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - ctl.forceFRDisabledForBaseline(); - - // Fund QRP below target so reseed amount is limited by available reserve. - const uint64 qrpFunded = 200000000ULL; - increaseEnergy(ctl.qrpSelf(), qrpFunded); - - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0x0A0B0C0D0E0F1011ULL; - const auto nums = ctl.computeWinningAndLosing(testDigest); - - const uint64 initialJackpot = 800000000ULL; - ctl.state()->setJackpot(initialJackpot); - increaseEnergy(ctl.qtfSelf(), initialJackpot); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const id w1 = id::randomValue(); - ctl.fundAndBuyTicket(w1, ticketPrice, nums.winning); - - const uint64 qrpBefore = static_cast(getBalance(ctl.qrpSelf())); - const uint64 w1Before = getBalance(w1); - - ctl.drawWithDigest(testDigest); - - EXPECT_EQ(static_cast(getBalance(w1) - w1Before), initialJackpot); - - // With a single winning ticket and baseline overflow split, winnersOverflow == winnersBlock, reserveAdd == winnersBlock/2, carryAdd == - // winnersBlock/2. - const QTF::GetFees_output fees = ctl.getFees(); - const uint64 revenue = ticketPrice; - const uint64 winnersBlock = (revenue * fees.winnerFeePercent) / 100; - const uint64 reserveAdd = (winnersBlock * QTF_BASELINE_OVERFLOW_ALPHA_BP) / 10000; - const uint64 carryAdd = winnersBlock - reserveAdd; - - EXPECT_EQ(ctl.state()->getJackpot(), qrpFunded + carryAdd); - EXPECT_EQ(static_cast(getBalance(ctl.qrpSelf())), qrpBefore - qrpFunded + reserveAdd); -} - -// Test k=2 and k=3 payouts with deterministic winning numbers -TEST(ContractQThirtyFour, DeterministicWinner_K2K3Payouts_VerifyRevenueSplit) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - - // This test validates baseline k2/k3 pool splitting (no FR rake). - // Force FR activation window to be expired so SettleEpoch cannot auto-enable FR. - ctl.forceFRDisabledForBaseline(); - - // Create deterministic prevSpectrumDigest - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0xFEDCBA9876543210ULL; // Different seed - - const auto nums = ctl.computeWinningAndLosing(testDigest); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - // Create multiple k=2 and k=3 winners to test pool splitting - // 2 k=3 winners - QTFRandomValues k3Numbers1 = ctl.makeK3Numbers(nums.winning, 0); - const id k3Winner1 = id::randomValue(); - ctl.fundAndBuyTicket(k3Winner1, ticketPrice, k3Numbers1); - - QTFRandomValues k3Numbers2 = ctl.makeK3Numbers(nums.winning, 1); - const id k3Winner2 = id::randomValue(); - ctl.fundAndBuyTicket(k3Winner2, ticketPrice, k3Numbers2); - - // 3 k=2 winners - QTFRandomValues k2Numbers1 = ctl.makeK2Numbers(nums.winning, 0); - const id k2Winner1 = id::randomValue(); - ctl.fundAndBuyTicket(k2Winner1, ticketPrice, k2Numbers1); - - QTFRandomValues k2Numbers2 = ctl.makeK2Numbers(nums.winning, 1); - const id k2Winner2 = id::randomValue(); - ctl.fundAndBuyTicket(k2Winner2, ticketPrice, k2Numbers2); - - QTFRandomValues k2Numbers3 = ctl.makeK2Numbers(nums.winning, 2); - const id k2Winner3 = id::randomValue(); - ctl.fundAndBuyTicket(k2Winner3, ticketPrice, k2Numbers3); - - // 5 losers (no matches) - for (int i = 0; i < 5; ++i) - { - const id loser = id::randomValue(); - QTFRandomValues loserNumbers = ctl.makeValidNumbers(1, 2, 3, 4); - ctl.fundAndBuyTicket(loser, ticketPrice, loserNumbers); - } - - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 10ULL); - - // Calculate expected pools - const uint64 revenue = ticketPrice * 10; - const QTF::GetFees_output fees = ctl.getFees(); - const uint64 winnersBlock = (revenue * fees.winnerFeePercent) / 100; // 68% - const uint64 expectedK2Pool = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000; // 28% of winners block - const uint64 expectedK3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000; // 40% of winners block - - // Get balances before settlement - const uint64 k3Winner1Before = getBalance(k3Winner1); - const uint64 k2Winner1Before = getBalance(k2Winner1); - - // Trigger settlement - ctl.drawWithDigest(testDigest); - - // Verify winner payouts - // k=3 pool split between 2 winners - const uint64 expectedK3PayoutPerWinner = expectedK3Pool / 2; - const uint64 k3Winner1After = getBalance(k3Winner1); - const uint64 k3Winner1Gained = k3Winner1After - k3Winner1Before; - EXPECT_EQ(static_cast(k3Winner1Gained), expectedK3PayoutPerWinner) << "k=3 winner should receive half of k3 pool"; - - // k=2 pool split between 3 winners - const uint64 expectedK2PayoutPerWinner = expectedK2Pool / 3; - const uint64 k2Winner1After = getBalance(k2Winner1); - const uint64 k2Winner1Gained = k2Winner1After - k2Winner1Before; - EXPECT_EQ(static_cast(k2Winner1Gained), expectedK2PayoutPerWinner) << "k=2 winner should receive one-third of k2 pool"; - - // Verify winning numbers in winner data - QTF::GetWinnerData_output winnerData = ctl.getWinnerData(); - EXPECT_EQ(winnerData.winnerData.winnerValues.get(0), nums.winning.get(0)); - EXPECT_EQ(winnerData.winnerData.winnerValues.get(1), nums.winning.get(1)); - EXPECT_EQ(winnerData.winnerData.winnerValues.get(2), nums.winning.get(2)); - EXPECT_EQ(winnerData.winnerData.winnerValues.get(3), nums.winning.get(3)); - - // Jackpot should have grown (no k=4 winner) - EXPECT_GT(ctl.state()->getJackpot(), 0ULL); -} - -TEST(ContractQThirtyFour, EstimatePrizePayouts_FRMode_AppliesRakeToPools) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - // Enable FR so EstimatePrizePayouts applies the 5% winners rake. - ctl.state()->setJackpot(QTF_DEFAULT_TARGET_JACKPOT / 2); - ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); - ctl.forceFREnabledWithinWindow(1); - - constexpr uint64 numPlayers = 100; - for (uint64 i = 0; i < numPlayers; ++i) - { - const id user = id::randomValue(); - QTFRandomValues nums = ctl.makeValidNumbers(static_cast((i % 26) + 1), static_cast((i % 26) + 2), - static_cast((i % 26) + 3), static_cast((i % 26) + 4)); - ctl.fundAndBuyTicket(user, ticketPrice, nums); - } - - const QTF::EstimatePrizePayouts_output estimate = ctl.estimatePrizePayouts(0, 0); - - const uint64 revenue = ticketPrice * numPlayers; - const QTF::GetFees_output fees = ctl.getFees(); - const uint64 winnersBlock = (revenue * fees.winnerFeePercent) / 100; - const uint64 winnersRake = (winnersBlock * QTF_FR_WINNERS_RAKE_BP) / 10000; - const uint64 winnersBlockAfterRake = winnersBlock - winnersRake; - - const uint64 expectedK2Pool = (winnersBlockAfterRake * QTF_BASE_K2_SHARE_BP) / 10000; - const uint64 expectedK3Pool = (winnersBlockAfterRake * QTF_BASE_K3_SHARE_BP) / 10000; - - EXPECT_EQ(estimate.totalRevenue, revenue); - EXPECT_EQ(estimate.k2Pool, expectedK2Pool); - EXPECT_EQ(estimate.k3Pool, expectedK3Pool); -} - -// ============================================================================ -// RESERVE TOP-UP AND FLOOR GUARANTEE TESTS -// ============================================================================ - -TEST(ContractQThirtyFour, Settlement_PerWinnerCap_AppliesToK3Winner_OverflowAccountsForRemainder) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - ctl.forceFRDisabledForBaseline(); - - // Ensure RL shares exist so distribution payouts leave the contract (otherwise most of distPayout can remain in QTF balance). - const id shareholder1 = id::randomValue(); - const id shareholder2 = id::randomValue(); - constexpr uint32 shares1 = NUMBER_OF_COMPUTORS / 3; - constexpr uint32 shares2 = NUMBER_OF_COMPUTORS - shares1; - std::vector> rlShares{{shareholder1, shares1}, {shareholder2, shares2}}; - issueRlSharesTo(rlShares); - - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0xD1CEB00BD1CEB00BULL; - const auto nums = ctl.computeWinningAndLosing(testDigest); - - const uint64 P = ctl.state()->getTicketPriceInternal(); - const uint64 perWinnerCap = smul(P, QTF_TOPUP_PER_WINNER_CAP_MULT); - - const id k3Winner = id::randomValue(); - ctl.fundAndBuyTicket(k3Winner, P, ctl.makeK3Numbers(nums.winning, 0)); - - constexpr uint64 numLosers = 100; - ctl.buyRandomTickets(numLosers, P, nums.losing); - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), numLosers + 1); - - const uint64 qrpBefore = static_cast(getBalance(ctl.qrpSelf())); - const uint64 k3Before = getBalance(k3Winner); - - ctl.drawWithDigest(testDigest); - - EXPECT_EQ(static_cast(getBalance(k3Winner) - k3Before), perWinnerCap); - - // Baseline settlement: with no k2 winners and exactly one k3 winner capped at 25*P, - // winnersOverflow ends up being winnersBlock - perWinnerCap. - const QTF::GetFees_output fees = ctl.getFees(); - const uint64 revenue = smul(P, numLosers + 1); - const uint64 winnersBlock = div(smul(revenue, static_cast(fees.winnerFeePercent)), 100); - const uint64 winnersOverflow = winnersBlock - perWinnerCap; - const uint64 reserveAdd = (winnersOverflow * QTF_BASELINE_OVERFLOW_ALPHA_BP) / 10000; - const uint64 carryAdd = winnersOverflow - reserveAdd; - - EXPECT_EQ(ctl.state()->getJackpot(), carryAdd); - EXPECT_EQ(static_cast(getBalance(ctl.qtfSelf())), carryAdd); - EXPECT_EQ(static_cast(getBalance(ctl.qrpSelf())), qrpBefore + reserveAdd); -} - -TEST(ContractQThirtyFour, Settlement_FloorTopUp_LimitedBySafetyCaps_PayoutBelowFloor) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - ctl.forceFRDisabledForBaseline(); - - // Ensure RL shares exist so distribution payouts leave the contract (otherwise most of distPayout can remain in QTF balance). - const id shareholder1 = id::randomValue(); - const id shareholder2 = id::randomValue(); - constexpr uint32 shares1 = NUMBER_OF_COMPUTORS / 2; - constexpr uint32 shares2 = NUMBER_OF_COMPUTORS - shares1; - std::vector> rlShares{{shareholder1, shares1}, {shareholder2, shares2}}; - issueRlSharesTo(rlShares); - - // Fund QRP just above soft floor so top-up is limited by both 10% cap and soft floor. - const uint64 P = ctl.state()->getTicketPriceInternal(); - const uint64 softFloor = smul(P, QTF_RESERVE_SOFT_FLOOR_MULT); // 20*P - const uint64 qrpFunding = softFloor + 5 * P; // 25*P - increaseEnergy(ctl.qrpSelf(), qrpFunding); - - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0x0DDC0FFEE0DDF00DULL; - const auto nums = ctl.computeWinningAndLosing(testDigest); - - const id k3Winner = id::randomValue(); - ctl.fundAndBuyTicket(k3Winner, P, ctl.makeK3Numbers(nums.winning, 0)); - EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 1u); - - const uint64 qrpBefore = static_cast(getBalance(ctl.qrpSelf())); - const uint64 k3Before = getBalance(k3Winner); - - ctl.drawWithDigest(testDigest); - - const QTF::GetFees_output fees = ctl.getFees(); - const uint64 revenue = P; - const uint64 winnersBlock = div(smul(revenue, static_cast(fees.winnerFeePercent)), 100); - const uint64 k3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000; - const uint64 k3Floor = smul(P, QTF_K3_FLOOR_MULT); - const uint64 needed = k3Floor - k3Pool; - const uint64 availableAboveFloor = qrpBefore - softFloor; // 5*P - const uint64 maxPerRound = (qrpBefore * QTF_TOPUP_RESERVE_PCT_BP) / 10000; // 10% of total - const uint64 perWinnerCapTotal = smul(P, QTF_TOPUP_PER_WINNER_CAP_MULT); // 25*P - const uint64 maxAllowed = std::min(std::min(maxPerRound, availableAboveFloor), perWinnerCapTotal); // 2.5*P - const uint64 expectedTopUp = std::min(needed, maxAllowed); - const uint64 expectedPayout = k3Pool + expectedTopUp; - - EXPECT_LT(expectedPayout, k3Floor); - EXPECT_EQ(static_cast(getBalance(k3Winner) - k3Before), expectedPayout); - - // With no k2 winners and k3 pool fully paid (top-ups only increase payouts), - // winnersOverflow equals winnersBlock - k3Pool. - const uint64 winnersOverflow = winnersBlock - k3Pool; - const uint64 reserveAdd = (winnersOverflow * QTF_BASELINE_OVERFLOW_ALPHA_BP) / 10000; - const uint64 carryAdd = winnersOverflow - reserveAdd; - - EXPECT_EQ(ctl.state()->getJackpot(), carryAdd); - EXPECT_EQ(static_cast(getBalance(ctl.qtfSelf())), carryAdd); - EXPECT_EQ(static_cast(getBalance(ctl.qrpSelf())), qrpBefore - expectedTopUp + reserveAdd); - EXPECT_GE(static_cast(getBalance(ctl.qrpSelf())), softFloor); -} - -TEST(ContractQThirtyFour, Settlement_FloorTopUp_Integration_K2K3FloorsMetWhenReserveSufficient) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - ctl.forceFRDisabledForBaseline(); - - // Ensure RL shares exist so distribution path is exercised (and rounding/payback is deterministic). - const id shareholder1 = id::randomValue(); - const id shareholder2 = id::randomValue(); - constexpr uint32 shares1 = NUMBER_OF_COMPUTORS / 4; - constexpr uint32 shares2 = NUMBER_OF_COMPUTORS - shares1; - std::vector> rlShares{{shareholder1, shares1}, {shareholder2, shares2}}; - issueRlSharesTo(rlShares); - - // Fund QRP enough so both tiers can be topped up to floors under all caps. - const uint64 qrpFunding = 100000000ULL; // 100M, 10% cap = 10M, soft floor = 20M. - increaseEnergy(ctl.qrpSelf(), qrpFunding); - - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0x5566778899AABBCCULL; - const auto nums = ctl.computeWinningAndLosing(testDigest); - - const uint64 P = ctl.state()->getTicketPriceInternal(); - - // Create deterministic winners: 2x k2 winners and 1x k3 winner => pools are small and must be topped up. - const id k2w1 = id::randomValue(); - const id k2w2 = id::randomValue(); - const id k3w1 = id::randomValue(); - ctl.fundAndBuyTicket(k2w1, P, ctl.makeK2Numbers(nums.winning, 0)); - ctl.fundAndBuyTicket(k2w2, P, ctl.makeK2Numbers(nums.winning, 1)); - ctl.fundAndBuyTicket(k3w1, P, ctl.makeK3Numbers(nums.winning, 2)); - - const uint64 qrpBefore = static_cast(getBalance(ctl.qrpSelf())); - const uint64 qtfBefore = static_cast(getBalance(ctl.qtfSelf())); - const uint64 k2w1Before = getBalance(k2w1); - const uint64 k3w1Before = getBalance(k3w1); - const uint64 sh1Before = getBalance(shareholder1); - const uint64 sh2Before = getBalance(shareholder2); - const uint64 rlBefore = getBalance(id(RL_CONTRACT_INDEX, 0, 0, 0)); - - EXPECT_EQ(qtfBefore, 3 * P); - - ctl.drawWithDigest(testDigest); - - // Expected pools and top-ups. - const QTF::GetFees_output fees = ctl.getFees(); - const uint64 revenue = 3 * P; - const uint64 winnersBlock = (revenue * fees.winnerFeePercent) / 100; - const uint64 k2Pool = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000; - const uint64 k3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000; - - const uint64 k2Floor = P / 2; - const uint64 k3Floor = 5 * P; - const uint64 k2TopUp = (k2Floor * 2 > k2Pool) ? (k2Floor * 2 - k2Pool) : 0; - const uint64 k3TopUp = (k3Floor > k3Pool) ? (k3Floor - k3Pool) : 0; - - // Winners must receive the floors (no per-winner cap binding in this scenario). - EXPECT_EQ(static_cast(getBalance(k2w1) - k2w1Before), k2Floor); - EXPECT_EQ(static_cast(getBalance(k3w1) - k3w1Before), k3Floor); - - // Baseline overflow is the unallocated 32% of winnersBlock (tier pools are fully paid out with floor top-ups, so no extra overflow). - const uint64 winnersOverflow = winnersBlock - k2Pool - k3Pool; - const uint64 reserveAdd = (winnersOverflow * QTF_BASELINE_OVERFLOW_ALPHA_BP) / 10000; - const uint64 carryAdd = winnersOverflow - reserveAdd; - - // Contract balance should match carry (jackpot) after settlement. - EXPECT_EQ(ctl.state()->getJackpot(), carryAdd); - EXPECT_EQ(static_cast(getBalance(ctl.qtfSelf())), carryAdd); - - // QRP: receives reserveAdd, pays out top-ups. - EXPECT_EQ(static_cast(getBalance(ctl.qrpSelf())), qrpBefore - k2TopUp - k3TopUp + reserveAdd); - - // Distribution: verify two holders and RL payback remainder. - const uint64 expectedDistFee = (revenue * fees.distributionFeePercent) / 100; - const uint64 dividendPerShare = expectedDistFee / NUMBER_OF_COMPUTORS; - const uint64 expectedSh1Gain = static_cast(shares1) * dividendPerShare; - const uint64 expectedSh2Gain = static_cast(shares2) * dividendPerShare; - const uint64 expectedPayback = expectedDistFee - (dividendPerShare * NUMBER_OF_COMPUTORS); - EXPECT_EQ(getBalance(shareholder1), sh1Before + expectedSh1Gain); - EXPECT_EQ(getBalance(shareholder2), sh2Before + expectedSh2Gain); - EXPECT_EQ(getBalance(id(RL_CONTRACT_INDEX, 0, 0, 0)), rlBefore + expectedPayback); -} - -// ============================================================================ -// HIGH-DEFICIT FR EXTRA REDIRECTS TESTS -// ============================================================================ - -TEST(ContractQThirtyFour, FR_HighDeficit_ExtraRedirectsCalculated) -{ - ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - - // Fix RNG so we can deterministically avoid winners (and especially k=4). - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0x4040404040404040ULL; - const auto nums = ctl.computeWinningAndLosing(testDigest); - - // Setup: High deficit scenario - // Jackpot = 0, Target = 1B, FR active - ctl.state()->setJackpot(0ULL); // Empty jackpot - ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); // 1B target - ctl.state()->setFrActive(true); - ctl.state()->setFrRoundsSinceK4(5); - - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - const QTF::GetFees_output fees = ctl.getFees(); - - // Add many players to generate high revenue - constexpr int numPlayers = 500; - ctl.buyRandomTickets(numPlayers, ticketPrice, nums.losing); - - const uint64 revenue = ticketPrice * numPlayers; // 500M QU - const uint64 deficit = QTF_DEFAULT_TARGET_JACKPOT - 0; // 1B deficit - - // With high deficit (1B) and significant revenue (500M), extra redirects should be calculated - // Formula (from spec and QThirtyFour.h:1928-1965): - // - deficit Δ = 1B - // - E_k4(500) ≈ 55 rounds (expected rounds to k=4 with 500 tickets) - // - horizon H = min(55, 50) = 50 (capped) - // - required gain per round = Δ/H = 1B/50 = 20M - // - base gain (without extra) ≈ 1% dev + 1% dist + 5% rake + 95% overflow - // ≈ 5M + 5M + 17M + ~98M = ~125M (rough estimate) - // - Since base gain (125M) > required (20M), extra might be 0 or small - // But let's verify the mechanism is working - - const uint64 devBalBefore = getBalance(QTF_DEV_ADDRESS); - const uint64 jackpotBefore = ctl.state()->getJackpot(); - EXPECT_EQ(jackpotBefore, 0ULL); - - ctl.drawWithDigest(testDigest); - - // After settlement with FR active and high deficit: - const uint64 devBalAfter = getBalance(QTF_DEV_ADDRESS); - const uint64 jackpotAfter = ctl.state()->getJackpot(); - - // Verify FR is still active - EXPECT_EQ(ctl.state()->getFrActive(), true); - - // Dev should receive less than full 10% of revenue due to FR redirects - const uint64 fullDevPayout = (revenue * fees.teamFeePercent) / 100; // 50M (10% of 500M) - const uint64 actualDevPayout = devBalAfter - devBalBefore; - - // Base redirect alone is 1% of revenue = 5M - const uint64 baseDevRedirect = (revenue * QTF_FR_DEV_REDIRECT_BP) / 10000; // 5M - EXPECT_LT(actualDevPayout, fullDevPayout) << "Dev should receive less than full 10% in FR mode"; - EXPECT_LE(actualDevPayout, fullDevPayout - baseDevRedirect) << "Dev redirect should be at least base 1%"; - - // Jackpot should have grown significantly from: - // - Winners rake (5% of 340M winners block = 17M) - // - Dev/Dist redirects (base 1% each + possible extra) - // - Overflow bias (95% of overflow) - EXPECT_GT(jackpotAfter, 100000000ULL) << "Jackpot should grow by at least 100M from FR mechanisms"; - - // Verify extra redirect cap: dev redirect should not exceed base (1%) + extra max (0.35%) = 1.35% total - const uint64 maxDevRedirectTotal = (revenue * (QTF_FR_DEV_REDIRECT_BP + QTF_FR_EXTRA_MAX_BP / 2)) / 10000; // 1.35% - const uint64 actualDevRedirect = fullDevPayout - actualDevPayout; - EXPECT_LE(actualDevRedirect, maxDevRedirectTotal) << "Dev redirect should not exceed 1.35% of revenue"; - - // Note: The exact extra redirect amount depends on complex calculation in CalculateExtraRedirectBP - // (QThirtyFour.h:1928-1965), which uses fixed-point arithmetic, power calculations, and horizon capping. - // This test verifies the mechanism is active and within bounds. -} - -TEST(ContractQThirtyFour, Settlement_FRMode_ExtraRedirect_ClampsToMax_AndAffectsDevAndDist) -{ - ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - - // Ensure RL shares exist so distribution can be asserted. - const id shareholder1 = id::randomValue(); - const id shareholder2 = id::randomValue(); - constexpr uint32 shares1 = NUMBER_OF_COMPUTORS / 2; - constexpr uint32 shares2 = NUMBER_OF_COMPUTORS - shares1; - std::vector> rlShares{{shareholder1, shares1}, {shareholder2, shares2}}; - issueRlSharesTo(rlShares); - - // Deterministic no-winner tickets. - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0x7777777777777777ULL; - const auto nums = ctl.computeWinningAndLosing(testDigest); - - // Force FR on and create an extreme deficit to guarantee extra redirect clamps to max. - ctl.state()->setJackpot(0ULL); - ctl.state()->setTargetJackpotInternal(1000000000000000ULL); // 1e15 - ctl.state()->setFrActive(true); - ctl.state()->setFrRoundsSinceK4(1); - - ctl.beginEpochWithValidTime(); - - const uint64 P = ctl.state()->getTicketPriceInternal(); - constexpr uint64 numPlayers = 10; - ctl.buyRandomTickets(numPlayers, P, nums.losing); - - const QTF::GetFees_output fees = ctl.getFees(); - const uint64 revenue = P * numPlayers; - - const uint64 devBefore = getBalance(QTF_DEV_ADDRESS); - const uint64 sh1Before = getBalance(shareholder1); - const uint64 sh2Before = getBalance(shareholder2); - const uint64 rlBefore = getBalance(id(RL_CONTRACT_INDEX, 0, 0, 0)); - - // Pre-compute expected extra BP using the same private helpers as the contract. - QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); - primeQpiFunctionContext(qpi); - const auto pools = ctl.state()->callCalculatePrizePools(qpi, revenue, true); - const auto baseGainOut = ctl.state()->callCalculateBaseGain(qpi, revenue, pools.winnersBlock); - const uint64 delta = ctl.state()->getTargetJackpotInternal() - ctl.state()->getJackpot(); - const auto extraOut = ctl.state()->callCalculateExtraRedirectBP(qpi, numPlayers, delta, revenue, baseGainOut.baseGain); - ASSERT_EQ(extraOut.extraBP, QTF_FR_EXTRA_MAX_BP); - - const uint64 devExtraBP = extraOut.extraBP / 2; - const uint64 distExtraBP = extraOut.extraBP - devExtraBP; - const uint64 totalDevRedirectBP = QTF_FR_DEV_REDIRECT_BP + devExtraBP; - const uint64 totalDistRedirectBP = QTF_FR_DIST_REDIRECT_BP + distExtraBP; - - const uint64 fullDevFee = (revenue * fees.teamFeePercent) / 100; - const uint64 fullDistFee = (revenue * fees.distributionFeePercent) / 100; - - const uint64 expectedDevRedirect = (revenue * totalDevRedirectBP) / 10000; - const uint64 expectedDistRedirect = (revenue * totalDistRedirectBP) / 10000; - const uint64 expectedDevPayout = fullDevFee - expectedDevRedirect; - const uint64 expectedDistPayout = fullDistFee - expectedDistRedirect; - - ctl.drawWithDigest(testDigest); - - // Dev payout must match exact base+extra redirect math (no caps expected in this scenario). - EXPECT_EQ(static_cast(getBalance(QTF_DEV_ADDRESS) - devBefore), expectedDevPayout); - - // Distribution must match expectedDistPayout (dividendPerShare flooring + payback). - const uint64 dividendPerShare = expectedDistPayout / NUMBER_OF_COMPUTORS; - const uint64 expectedSh1Gain = static_cast(shares1) * dividendPerShare; - const uint64 expectedSh2Gain = static_cast(shares2) * dividendPerShare; - const uint64 expectedPayback = expectedDistPayout - (dividendPerShare * NUMBER_OF_COMPUTORS); - EXPECT_EQ(getBalance(shareholder1), sh1Before + expectedSh1Gain); - EXPECT_EQ(getBalance(shareholder2), sh2Before + expectedSh2Gain); - EXPECT_EQ(getBalance(id(RL_CONTRACT_INDEX, 0, 0, 0)), rlBefore + expectedPayback); -} - -// ============================================================================ -// POST-K4 WINDOW EXPIRY TESTS -// ============================================================================ - -TEST(ContractQThirtyFour, FR_PostK4WindowExpiry_DoesNotActivateWhenInactive) -{ - ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0xABCDABCDABCDABCDULL; - const auto nums = ctl.computeWinningAndLosing(testDigest); - - // Setup: Jackpot below target, but window expired and FR inactive. - ctl.state()->setJackpot(QTF_DEFAULT_TARGET_JACKPOT / 2); - ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); - ctl.state()->setFrActive(false); - ctl.state()->setFrRoundsSinceK4(QTF_FR_POST_K4_WINDOW_ROUNDS); - - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - constexpr int numPlayers = 10; - ctl.buyRandomTickets(numPlayers, ticketPrice, nums.losing); - - ctl.drawWithDigest(testDigest); - - EXPECT_EQ(ctl.state()->getFrActive(), false); - EXPECT_EQ(ctl.state()->getFrRoundsSinceK4(), QTF_FR_POST_K4_WINDOW_ROUNDS + 1); -} - -TEST(ContractQThirtyFour, FR_PostK4WindowExpiry_DoesNotReactivateWhenWindowExpired) -{ - ContractTestingQTF ctl; - ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); - - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0xFACEFEEDFACEFEEDULL; - const auto nums = ctl.computeWinningAndLosing(testDigest); - - // Setup: FR active, jackpot below target, but approaching window expiry - ctl.state()->setJackpot(QTF_DEFAULT_TARGET_JACKPOT / 2); // 500M (below target) - ctl.state()->setTargetJackpotInternal(QTF_DEFAULT_TARGET_JACKPOT); // 1B target - ctl.state()->setFrActive(true); - ctl.state()->setFrRoundsSinceK4(QTF_FR_POST_K4_WINDOW_ROUNDS - 1); // One round before window expiry (50 = QTF_FR_POST_K4_WINDOW_ROUNDS) - - ctl.beginEpochWithValidTime(); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - // Add players - constexpr int numPlayers = 10; - for (int i = 0; i < numPlayers; ++i) - { - const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, nums.losing); - } - - // Verify FR is active before settlement - EXPECT_EQ(ctl.state()->getFrActive(), true); - EXPECT_EQ(ctl.state()->getFrRoundsSinceK4(), QTF_FR_POST_K4_WINDOW_ROUNDS - 1); - EXPECT_LT(ctl.state()->getJackpot(), ctl.state()->getTargetJackpotInternal()); - - ctl.drawWithDigest(testDigest); - - // After settlement (deterministic: no k=4 win is possible): - // - roundsSinceK4 should increment to 50 - // - Next round starts outside the FR post-k4 window. - - const uint64 roundsSinceK4After = ctl.state()->getFrRoundsSinceK4(); - EXPECT_EQ(roundsSinceK4After, QTF_FR_POST_K4_WINDOW_ROUNDS) << "Counter should increment to 50 after draw"; - - // Run one more round: FR must be OFF because roundsSinceK4 >= 50. - ctl.beginEpochWithValidTime(); - - for (int i = 0; i < numPlayers; ++i) - { - const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, nums.losing); - } - - ctl.drawWithDigest(testDigest); - - // After second round: - // - Jackpot still below target - // - roundsSinceK4 = 51 (>= 50) - // - FR is forced OFF outside the window. - EXPECT_EQ(ctl.state()->getFrRoundsSinceK4(), QTF_FR_POST_K4_WINDOW_ROUNDS + 1); - EXPECT_EQ(ctl.state()->getFrActive(), false); - - // Run a third round to ensure FR stays OFF while still outside the window. - ctl.beginEpochWithValidTime(); - for (int i = 0; i < numPlayers; ++i) - { - const id user = id::randomValue(); - ctl.fundAndBuyTicket(user, ticketPrice, nums.losing); - } - ctl.drawWithDigest(testDigest); - - EXPECT_EQ(ctl.state()->getFrRoundsSinceK4(), QTF_FR_POST_K4_WINDOW_ROUNDS + 2); - EXPECT_EQ(ctl.state()->getFrActive(), false); -} From c8f22202702c6372b5444cd15219f56f99db35ea Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 14 Jan 2026 00:15:57 +0300 Subject: [PATCH 37/77] Fixes contractverify, contract index --- src/contract_core/contract_def.h | 2 +- src/contracts/Pulse.h | 61 +++++++++++++++++++------------- 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/src/contract_core/contract_def.h b/src/contract_core/contract_def.h index 13ff6dab9..98f3bc743 100644 --- a/src/contract_core/contract_def.h +++ b/src/contract_core/contract_def.h @@ -219,7 +219,7 @@ #undef CONTRACT_STATE_TYPE #undef CONTRACT_STATE2_TYPE -#define PULSE_CONTRACT_INDEX 24 +#define PULSE_CONTRACT_INDEX 21 #define CONTRACT_INDEX PULSE_CONTRACT_INDEX #define CONTRACT_STATE_TYPE PULSE #define CONTRACT_STATE2_TYPE PULSE2 diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 5e83d31ef..6b4ac57ff 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -313,6 +313,18 @@ struct PULSE : public ContractBase HashSet used; }; + struct ComputePrize_locals + { + uint8 leftAlignedMatches; + uint8 leftAlignedMatchesAtOffset; + uint8 anyPositionMatches; + uint8 leftAlignedOffset; + uint8 j; + uint64 leftAlignedReward; + uint64 anyPositionReward; + uint64 prize; + }; + struct SettleRound_input { }; @@ -348,6 +360,7 @@ struct PULSE : public ContractBase GetRandomDigits_input randomInput; GetRandomDigits_output randomOutput; Ticket ticket; + ComputePrize_locals computePrizeLocals; }; struct BEGIN_TICK_locals @@ -800,7 +813,7 @@ struct PULSE : public ContractBase for (locals.i = 0; locals.i < state.ticketCounter; ++locals.i) { locals.ticket = state.tickets.get(locals.i); - locals.prize = computePrize(state, locals.ticket, state.lastWinningDigits, locals.winningMask); + locals.prize = computePrize(state, locals.ticket, state.lastWinningDigits, locals.winningMask, locals.computePrizeLocals); locals.totalPrize += locals.prize; } @@ -808,7 +821,7 @@ struct PULSE : public ContractBase for (locals.i = 0; locals.i < state.ticketCounter; ++locals.i) { locals.ticket = state.tickets.get(locals.i); - locals.prize = computePrize(state, locals.ticket, state.lastWinningDigits, locals.winningMask); + locals.prize = computePrize(state, locals.ticket, state.lastWinningDigits, locals.winningMask, locals.computePrizeLocals); if (locals.totalPrize > 0 && locals.availableBalance < locals.totalPrize) { @@ -918,43 +931,43 @@ struct PULSE : public ContractBase } static uint64 computePrize(const PULSE& state, const Ticket& ticket, const Array& winningDigits, - uint16 winningMask) + uint16 winningMask, SettleRound_locals::ComputePrize_locals& locals) { - uint8 leftAlignedMatches = 0; - uint8 leftAlignedMatchesAtOffset = 0; - uint8 anyPositionMatches = 0; - uint8 leftAlignedOffset = 0; - uint64 leftAlignedReward = 0; - uint64 anyPositionReward = 0; - uint64 prize = 0; + locals.leftAlignedMatches = 0; + locals.leftAlignedMatchesAtOffset = 0; + locals.anyPositionMatches = 0; + locals.leftAlignedOffset = 0; + locals.leftAlignedReward = 0; + locals.anyPositionReward = 0; + locals.prize = 0; - for (leftAlignedOffset = 0; leftAlignedOffset + PULSE_PLAYER_DIGITS <= PULSE_WINNING_DIGITS; ++leftAlignedOffset) + for (locals.leftAlignedOffset = 0; locals.leftAlignedOffset + PULSE_PLAYER_DIGITS <= PULSE_WINNING_DIGITS; ++locals.leftAlignedOffset) { - leftAlignedMatchesAtOffset = 0; - for (uint8 j = 0; j < PULSE_PLAYER_DIGITS; ++j) + locals.leftAlignedMatchesAtOffset = 0; + for (locals.j = 0; locals.j < PULSE_PLAYER_DIGITS; ++locals.j) { - if (ticket.digits.get(j) == winningDigits.get(leftAlignedOffset + j)) + if (ticket.digits.get(locals.j) == winningDigits.get(locals.leftAlignedOffset + locals.j)) { - ++leftAlignedMatchesAtOffset; + ++locals.leftAlignedMatchesAtOffset; } } - if (leftAlignedMatchesAtOffset > leftAlignedMatches) + if (locals.leftAlignedMatchesAtOffset > locals.leftAlignedMatches) { - leftAlignedMatches = leftAlignedMatchesAtOffset; + locals.leftAlignedMatches = locals.leftAlignedMatchesAtOffset; } } - for (uint8 j = 0; j < PULSE_PLAYER_DIGITS; ++j) + for (locals.j = 0; locals.j < PULSE_PLAYER_DIGITS; ++locals.j) { - if ((winningMask & (1u << ticket.digits.get(j))) != 0) + if ((winningMask & (1u << ticket.digits.get(locals.j))) != 0) { - ++anyPositionMatches; + ++locals.anyPositionMatches; } } - leftAlignedReward = getLeftAlignedReward(state, leftAlignedMatches); - anyPositionReward = getAnyPositionReward(anyPositionMatches); - prize = max(leftAlignedReward, anyPositionReward); - return prize; + locals.leftAlignedReward = getLeftAlignedReward(state, locals.leftAlignedMatches); + locals.anyPositionReward = getAnyPositionReward(locals.anyPositionMatches); + locals.prize = max(locals.leftAlignedReward, locals.anyPositionReward); + return locals.prize; } }; From 3871bf2ba8ccb15601f3f511c116bd9e03bfc9a4 Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 14 Jan 2026 11:25:22 +0300 Subject: [PATCH 38/77] Fixes build --- src/contracts/Pulse.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 6b4ac57ff..73842f1b1 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -931,7 +931,7 @@ struct PULSE : public ContractBase } static uint64 computePrize(const PULSE& state, const Ticket& ticket, const Array& winningDigits, - uint16 winningMask, SettleRound_locals::ComputePrize_locals& locals) + uint16 winningMask, ComputePrize_locals& locals) { locals.leftAlignedMatches = 0; locals.leftAlignedMatchesAtOffset = 0; From 2d295b6eb0bce754bf7d360b0835aaea70d8de35 Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 14 Jan 2026 18:20:40 +0300 Subject: [PATCH 39/77] Update GameMechanics --- src/contracts/Pulse.h | 135 ++++++++++++++-------------------------- test/contract_pulse.cpp | 76 +++++++++++----------- 2 files changed, 84 insertions(+), 127 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 73842f1b1..ad44dde4b 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -1,6 +1,6 @@ /** * @file Pulse.h - * @brief Pulse lottery contract: 6 unique digits out of 9 with fixed QHeart rewards. + * @brief Pulse lottery contract: 6 digits per ticket, winning digits are 6 draws from 0..9. * * Mechanics: * - Tickets are sold during SELLING state (1 ticket per call). @@ -15,10 +15,9 @@ using namespace QPI; constexpr uint16 PULSE_MAX_NUMBER_OF_PLAYERS = 1024; constexpr uint8 PULSE_PLAYER_DIGITS = 6; constexpr uint8 PULSE_PLAYER_DIGITS_ALIGNED = PULSE_PLAYER_DIGITS + 2; -constexpr uint8 PULSE_WINNING_DIGITS = 9; -constexpr uint8 PULSE_WINNING_DIGITS_ALIGNED = PULSE_WINNING_DIGITS + 7; +constexpr uint8 PULSE_WINNING_DIGITS = PULSE_PLAYER_DIGITS; +constexpr uint8 PULSE_WINNING_DIGITS_ALIGNED = PULSE_PLAYER_DIGITS_ALIGNED; constexpr uint8 PULSE_MAX_DIGIT = 9; -constexpr uint8 PULSE_MAX_DIGIT_ALIGNED = PULSE_MAX_DIGIT + 7; constexpr uint64 PULSE_TICKET_PRICE_DEFAULT = 200000; constexpr uint64 PULSE_QHEART_ASSET_NAME = 92712259110993ULL; // "QHEART" constexpr uint8 PULSE_DEFAULT_DEV_PERCENT = 10; @@ -144,7 +143,6 @@ struct PULSE : public ContractBase }; struct ValidateDigits_locals { - HashSet seen; uint8 idx; uint8 value; }; @@ -308,9 +306,6 @@ struct PULSE : public ContractBase uint64 tempValue; uint8 index; uint8 candidate; - uint8 attempts; - uint8 fallback; - HashSet used; }; struct ComputePrize_locals @@ -318,11 +313,13 @@ struct PULSE : public ContractBase uint8 leftAlignedMatches; uint8 leftAlignedMatchesAtOffset; uint8 anyPositionMatches; - uint8 leftAlignedOffset; uint8 j; + uint8 digitValue; uint64 leftAlignedReward; uint64 anyPositionReward; uint64 prize; + uint16 ticketMask; + uint16 winningMask; }; struct SettleRound_input @@ -344,14 +341,8 @@ struct PULSE : public ContractBase uint64 prize; uint64 totalPrize; uint64 availableBalance; - uint16 winningMask; m256i mixedSpectrumValue; uint64 randomSeed; - Asset qheartAsset; - AssetPossessionIterator qheartIter; - uint64 totalShares; - uint64 dividendPerShare; - uint64 holderShares; Asset shareholdersAsset; AssetPossessionIterator shareholdersIter; sint64 shareholdersTotalShares; @@ -370,7 +361,6 @@ struct PULSE : public ContractBase uint8 currentHour; uint8 isWednesday; uint8 isScheduledToday; - SettleRound_locals settleLocals; SettleRound_input settleInput; SettleRound_output settleOutput; }; @@ -699,12 +689,6 @@ struct PULSE : public ContractBase output.isValid = false; return; } - if (locals.seen.contains(locals.value)) - { - output.isValid = false; - return; - } - locals.seen.add(locals.value); } } @@ -715,30 +699,6 @@ struct PULSE : public ContractBase deriveOne(input.seed, locals.index, locals.tempValue); locals.candidate = static_cast(mod(locals.tempValue, static_cast(PULSE_MAX_DIGIT + 1))); - locals.attempts = 0; - while (locals.used.contains(locals.candidate) && locals.attempts < 100) - { - ++locals.attempts; - locals.tempValue ^= locals.tempValue >> 12; - locals.tempValue ^= locals.tempValue << 25; - locals.tempValue ^= locals.tempValue >> 27; - locals.tempValue *= 2685821657736338717ULL; - locals.candidate = static_cast(mod(locals.tempValue, static_cast(PULSE_MAX_DIGIT + 1))); - } - - if (locals.used.contains(locals.candidate)) - { - for (locals.fallback = 0; locals.fallback <= PULSE_MAX_DIGIT; ++locals.fallback) - { - if (!locals.used.contains(locals.fallback)) - { - locals.candidate = locals.fallback; - break; - } - } - } - - locals.used.add(locals.candidate); output.digits.set(locals.index, locals.candidate); } } @@ -801,11 +761,6 @@ struct PULSE : public ContractBase CALL(GetRandomDigits, locals.randomInput, locals.randomOutput); state.lastWinningDigits = locals.randomOutput.digits; - for (locals.i = 0; locals.i < PULSE_WINNING_DIGITS; ++locals.i) - { - locals.winningMask = static_cast(locals.winningMask | (1u << state.lastWinningDigits.get(locals.i))); - } - locals.balanceSigned = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, SELF_INDEX, SELF_INDEX); locals.balance = (locals.balanceSigned > 0) ? static_cast(locals.balanceSigned) : 0; @@ -813,7 +768,7 @@ struct PULSE : public ContractBase for (locals.i = 0; locals.i < state.ticketCounter; ++locals.i) { locals.ticket = state.tickets.get(locals.i); - locals.prize = computePrize(state, locals.ticket, state.lastWinningDigits, locals.winningMask, locals.computePrizeLocals); + locals.prize = computePrize(state, locals.ticket, state.lastWinningDigits, locals.computePrizeLocals); locals.totalPrize += locals.prize; } @@ -821,7 +776,7 @@ struct PULSE : public ContractBase for (locals.i = 0; locals.i < state.ticketCounter; ++locals.i) { locals.ticket = state.tickets.get(locals.i); - locals.prize = computePrize(state, locals.ticket, state.lastWinningDigits, locals.winningMask, locals.computePrizeLocals); + locals.prize = computePrize(state, locals.ticket, state.lastWinningDigits, locals.computePrizeLocals); if (locals.totalPrize > 0 && locals.availableBalance < locals.totalPrize) { @@ -867,7 +822,7 @@ struct PULSE : public ContractBase protected: Array tickets; - Array lastWinningDigits; + Array lastWinningDigits; uint64 ticketCounter; uint64 ticketPrice; uint64 qheartHoldLimit; @@ -906,67 +861,69 @@ struct PULSE : public ContractBase { switch (matches) { - case 6: return 2400 * state.ticketPrice; - case 5: return 600; - case 4: return 150; - case 3: return 30; - case 2: return 8; - case 1: return 1; + case 6: return 2000 * state.ticketPrice; + case 5: return 300 * state.ticketPrice; + case 4: return 60 * state.ticketPrice; + case 3: return 20 * state.ticketPrice; + case 2: return 4 * state.ticketPrice; + case 1: return 1 * state.ticketPrice; default: return 0; } } - static uint64 getAnyPositionReward(uint8 matches) + static uint64 getAnyPositionReward(const PULSE& state, uint8 matches) { switch (matches) { - case 6: return 1500; - case 5: return 400; - case 4: return 50; - case 3: return 8; - case 2: return 2; - case 1: return 0; + case 6: return 150 * state.ticketPrice; + case 5: return 30 * state.ticketPrice; + case 4: return 8 * state.ticketPrice; + case 3: return 2 * state.ticketPrice; + case 2: + case 1: default: return 0; } } static uint64 computePrize(const PULSE& state, const Ticket& ticket, const Array& winningDigits, - uint16 winningMask, ComputePrize_locals& locals) + ComputePrize_locals& locals) { - locals.leftAlignedMatches = 0; - locals.leftAlignedMatchesAtOffset = 0; - locals.anyPositionMatches = 0; - locals.leftAlignedOffset = 0; - locals.leftAlignedReward = 0; - locals.anyPositionReward = 0; - locals.prize = 0; + setMemory(locals, 0); - for (locals.leftAlignedOffset = 0; locals.leftAlignedOffset + PULSE_PLAYER_DIGITS <= PULSE_WINNING_DIGITS; ++locals.leftAlignedOffset) + for (locals.j = 0; locals.j < PULSE_PLAYER_DIGITS; ++locals.j) { - locals.leftAlignedMatchesAtOffset = 0; - for (locals.j = 0; locals.j < PULSE_PLAYER_DIGITS; ++locals.j) + if (ticket.digits.get(locals.j) == winningDigits.get(locals.j)) { - if (ticket.digits.get(locals.j) == winningDigits.get(locals.leftAlignedOffset + locals.j)) - { - ++locals.leftAlignedMatchesAtOffset; - } + ++locals.leftAlignedMatchesAtOffset; } - if (locals.leftAlignedMatchesAtOffset > locals.leftAlignedMatches) + else { - locals.leftAlignedMatches = locals.leftAlignedMatchesAtOffset; + locals.leftAlignedMatchesAtOffset = 0; } + + locals.leftAlignedMatches = max(locals.leftAlignedMatches, locals.leftAlignedMatchesAtOffset); + } + + for (locals.j = 0; locals.j < PULSE_WINNING_DIGITS; ++locals.j) + { + locals.winningMask = static_cast(locals.winningMask | (1u << winningDigits.get(locals.j))); } for (locals.j = 0; locals.j < PULSE_PLAYER_DIGITS; ++locals.j) { - if ((winningMask & (1u << ticket.digits.get(locals.j))) != 0) - { - ++locals.anyPositionMatches; - } + locals.digitValue = ticket.digits.get(locals.j); + locals.ticketMask = static_cast(locals.ticketMask | (1u << locals.digitValue)); + } + + locals.ticketMask = static_cast(locals.ticketMask & locals.winningMask); + while (locals.ticketMask != 0) + { + locals.anyPositionMatches += static_cast(locals.ticketMask & 1u); + locals.ticketMask = static_cast(locals.ticketMask >> 1); } locals.leftAlignedReward = getLeftAlignedReward(state, locals.leftAlignedMatches); - locals.anyPositionReward = getAnyPositionReward(locals.anyPositionMatches); + locals.anyPositionReward = getAnyPositionReward(state, locals.anyPositionMatches); locals.prize = max(locals.leftAlignedReward, locals.anyPositionReward); return locals.prize; } diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index faa9c9900..33066eb68 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -41,9 +41,9 @@ namespace ASSERT_EQ(contractError[PULSE_CONTRACT_INDEX], 0); } - Array makePlayerDigits(uint8 d0, uint8 d1, uint8 d2, uint8 d3, uint8 d4, uint8 d5) + Array makePlayerDigits(uint8 d0, uint8 d1, uint8 d2, uint8 d3, uint8 d4, uint8 d5) { - Array digits = {}; + Array digits = {}; digits.set(0, d0); digits.set(1, d1); digits.set(2, d2); @@ -53,7 +53,7 @@ namespace return digits; } - void expectWinningDigitsUniqueAndInRange(const Array& digits) + void expectWinningDigitsUniqueAndInRange(const Array& digits) { std::set seen; for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) @@ -81,7 +81,7 @@ class PULSEChecker : public PULSE uint8 getShareholdersPercentInternal() const { return shareholdersPercent; } uint8 getQHeartPercentInternal() const { return qheartPercent; } const id& getTeamAddressInternal() const { return teamAddress; } - const Array& getLastWinningDigits() const { return lastWinningDigits; } + const Array& getLastWinningDigits() const { return lastWinningDigits; } void setTicketCounter(uint64 value) { ticketCounter = value; } void setTicketPriceInternal(uint64 value) { ticketPrice = value; } @@ -89,11 +89,11 @@ class PULSEChecker : public PULSE void setLastDrawDateStamp(uint32 value) { lastDrawDateStamp = value; } void setScheduleInternal(uint8 value) { schedule = value; } void setDrawHourInternal(uint8 value) { drawHour = value; } - void setLastWinningDigits(const Array& digits) { lastWinningDigits = digits; } + void setLastWinningDigits(const Array& digits) { lastWinningDigits = digits; } NextEpochData& nextEpochDataRef() { return nextEpochData; } - void setTicketDirect(uint64 index, const id& player, const Array& digits) + void setTicketDirect(uint64 index, const id& player, const Array& digits) { Ticket ticket; ticket.player = player; @@ -104,7 +104,7 @@ class PULSEChecker : public PULSE void forceSelling(bool enable) { enableBuyTicket(*this, enable); } bool isSelling() const { return isSellingOpen(*this); } - ValidateDigits_output callValidateDigits(const QPI::QpiContextFunctionCall& qpi, const Array& digits) const + ValidateDigits_output callValidateDigits(const QPI::QpiContextFunctionCall& qpi, const Array& digits) const { ValidateDigits_input input{}; ValidateDigits_output output{}; @@ -138,7 +138,7 @@ class PULSEChecker : public PULSE void callClearStateOnEndDraw() { clearStateOnEndDraw(*this); } void callClearStateOnEndEpoch() { clearStateOnEndEpoch(*this); } uint64 callGetLeftAlignedReward(uint8 matches) const { return getLeftAlignedReward(*this, matches); } - uint64 callGetAnyPositionReward(uint8 matches) const { return getAnyPositionReward(matches); } + uint64 callGetAnyPositionReward(uint8 matches) const { return getAnyPositionReward(*this, matches); } }; class ContractTestingPulse : protected ContractTesting @@ -220,7 +220,7 @@ class ContractTestingPulse : protected ContractTesting return output; } - PULSE::BuyTicket_output buyTicket(const id& user, const Array& digits) + PULSE::BuyTicket_output buyTicket(const id& user, const Array& digits) { ensureUserEnergy(user); PULSE::BuyTicket_input input{}; @@ -370,7 +370,7 @@ class ContractTestingPulse : protected ContractTesting namespace { - Array deriveWinningDigits(ContractTestingPulse& ctl, const m256i& digest) + Array deriveWinningDigits(ContractTestingPulse& ctl, const m256i& digest) { m256i hashResult; KangarooTwelve(reinterpret_cast(&digest), sizeof(m256i), reinterpret_cast(&hashResult), sizeof(m256i)); @@ -381,7 +381,7 @@ namespace return ctl.state()->callGetRandomDigits(qpiFunc, seed).digits; } - uint8 findMissingDigit(const Array& winning) + uint8 findMissingDigit(const Array& winning) { bool seen[PULSE_MAX_DIGIT + 1] = {}; for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) @@ -398,8 +398,8 @@ namespace return 0; } - uint64 computeExpectedPrize(PULSEChecker* state, const Array& winning, - const Array& digits) + uint64 computeExpectedPrize(PULSEChecker* state, const Array& winning, + const Array& digits) { uint8 leftAlignedMatches = 0; for (uint8 offset = 0; offset + PULSE_PLAYER_DIGITS <= PULSE_WINNING_DIGITS; ++offset) @@ -476,19 +476,19 @@ TEST(ContractPulse_Static, SellingFlagToggles) TEST(ContractPulse_Static, RewardTablesMatchContractConstants) { ContractTestingPulse ctl; - EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(6), 2400u * ctl.getTicketPrice().ticketPrice); - EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(5), 600u); - EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(4), 150u); - EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(3), 30u); - EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(2), 8u); - EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(1), 1u); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(6), 2000u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(5), 300u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(4), 60u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(3), 20u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(2), 4u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(1), 1u * ctl.getTicketPrice().ticketPrice); EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(0), 0u); - EXPECT_EQ(ctl.state()->callGetAnyPositionReward(6), 1500u); - EXPECT_EQ(ctl.state()->callGetAnyPositionReward(5), 400u); - EXPECT_EQ(ctl.state()->callGetAnyPositionReward(4), 50u); - EXPECT_EQ(ctl.state()->callGetAnyPositionReward(3), 8u); - EXPECT_EQ(ctl.state()->callGetAnyPositionReward(2), 2u); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(6), 150u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(5), 30u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(4), 8u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(3), 2u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(2), 0u); EXPECT_EQ(ctl.state()->callGetAnyPositionReward(1), 0u); EXPECT_EQ(ctl.state()->callGetAnyPositionReward(0), 0u); } @@ -561,17 +561,17 @@ TEST(ContractPulse_Private, ValidateDigitsRejectsDuplicateAndOutOfRange) QpiContextUserFunctionCall qpi(PULSE_CONTRACT_INDEX); primeQpiFunctionContext(qpi); - const Array& ok = makePlayerDigits(0, 1, 2, 3, 4, 5); + const Array& ok = makePlayerDigits(0, 1, 2, 3, 4, 5); EXPECT_TRUE(ctl.state()->callValidateDigits(qpi, ok).isValid); - const Array& dup = makePlayerDigits(0, 1, 2, 3, 4, 4); + const Array& dup = makePlayerDigits(0, 1, 2, 3, 4, 4); EXPECT_FALSE(ctl.state()->callValidateDigits(qpi, dup).isValid); - const Array& outOfRange = makePlayerDigits(0, 1, 2, 3, 4, 10); + const Array& outOfRange = makePlayerDigits(0, 1, 2, 3, 4, 10); EXPECT_FALSE(ctl.state()->callValidateDigits(qpi, outOfRange).isValid); } -TEST(ContractPulse_Private, GetRandomDigitsDeterministicAndUnique) +TEST(ContractPulse_Private, GetRandomDigitsDeterministic) { ContractTestingPulse ctl; QpiContextUserFunctionCall qpi(PULSE_CONTRACT_INDEX); @@ -581,7 +581,7 @@ TEST(ContractPulse_Private, GetRandomDigitsDeterministicAndUnique) const PULSE::GetRandomDigits_output& out1 = ctl.state()->callGetRandomDigits(qpi, seed); const PULSE::GetRandomDigits_output& out2 = ctl.state()->callGetRandomDigits(qpi, seed); - std::set seen; + std::vector seen; for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) { const uint8 v1 = out1.digits.get(i); @@ -624,10 +624,10 @@ TEST(ContractPulse_Private, SettleRoundUpdatesWinningDigitsAndPaysPrize) QpiContextUserFunctionCall qpiFunc(PULSE_CONTRACT_INDEX); primeQpiFunctionContext(qpiFunc); - const Array& winning = ctl.state()->callGetRandomDigits(qpiFunc, seed).digits; + const Array& winning = ctl.state()->callGetRandomDigits(qpiFunc, seed).digits; const id player = id::randomValue(); - const Array& ticketDigits = + const Array& ticketDigits = makePlayerDigits(winning.get(0), winning.get(1), winning.get(2), winning.get(3), winning.get(4), winning.get(5)); ctl.state()->setTicketDirect(0, player, ticketDigits); @@ -895,10 +895,10 @@ TEST(ContractPulse_Gameplay, MultipleRoundsMultiplePlayers) const m256i digest(0x1111ULL + r, 0x2222ULL + r, 0x3333ULL + r, 0x4444ULL + r); etalonTick.prevSpectrumDigest = digest; - const Array& winning = deriveWinningDigits(ctl, digest); + const Array& winning = deriveWinningDigits(ctl, digest); const uint8 missing = findMissingDigit(winning); - const Array tickets[] = { + const Array tickets[] = { makePlayerDigits(winning.get(0), winning.get(1), winning.get(2), winning.get(3), winning.get(4), winning.get(5)), makePlayerDigits(winning.get(1), winning.get(2), winning.get(3), winning.get(4), winning.get(5), winning.get(6)), makePlayerDigits(winning.get(2), winning.get(3), winning.get(4), winning.get(5), winning.get(6), winning.get(7)), @@ -962,12 +962,12 @@ TEST(ContractPulse_Gameplay, ProRataPayoutWhenBalanceInsufficient) const m256i digest(0x1234ULL, 0x5678ULL, 0x9ABCULL, 0xDEF0ULL); etalonTick.prevSpectrumDigest = digest; - const Array& winning = deriveWinningDigits(ctl, digest); + const Array& winning = deriveWinningDigits(ctl, digest); const uint8 missing = findMissingDigit(winning); - const Array ticketA = + const Array ticketA = makePlayerDigits(winning.get(0), winning.get(1), winning.get(2), winning.get(3), winning.get(4), winning.get(5)); - const Array ticketB = + const Array ticketB = makePlayerDigits(winning.get(0), winning.get(2), winning.get(4), winning.get(6), winning.get(8), missing); const id playerA = id::randomValue(); @@ -1064,13 +1064,13 @@ TEST(ContractPulse_Gameplay, QHeartHoldLimitExcessTransferred) const id player = id::randomValue(); ctl.transferQHeart(issuance, player, ticketPrice); - const Array digits = makePlayerDigits(0, 1, 2, 3, 4, 5); + const Array digits = makePlayerDigits(0, 1, 2, 3, 4, 5); const PULSE::BuyTicket_output out = ctl.buyTicket(player, digits); EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); const m256i digest(0x11112222ULL, 0x33334444ULL, 0x55556666ULL, 0x77778888ULL); etalonTick.prevSpectrumDigest = digest; - const Array& winning = deriveWinningDigits(ctl, digest); + const Array& winning = deriveWinningDigits(ctl, digest); const uint64 prize = computeExpectedPrize(ctl.state(), winning, digits); const uint64 walletBefore = ctl.qheartBalanceOf(PULSE_QHEART_ISSUER); From 272b96072e897d229be42392f283fa5e9cb08f93 Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 14 Jan 2026 19:00:35 +0300 Subject: [PATCH 40/77] Fixes tests --- test/contract_pulse.cpp | 70 ++++++++++------------------------------- 1 file changed, 16 insertions(+), 54 deletions(-) diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index 33066eb68..1d9dd0f19 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -53,16 +53,13 @@ namespace return digits; } - void expectWinningDigitsUniqueAndInRange(const Array& digits) + void expectWinningDigitsInRange(const Array& digits) { - std::set seen; for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) { const uint8 v = digits.get(i); EXPECT_LE(v, PULSE_MAX_DIGIT); - seen.insert(v); } - EXPECT_EQ(seen.size(), static_cast(PULSE_WINNING_DIGITS)); } } // namespace @@ -139,6 +136,13 @@ class PULSEChecker : public PULSE void callClearStateOnEndEpoch() { clearStateOnEndEpoch(*this); } uint64 callGetLeftAlignedReward(uint8 matches) const { return getLeftAlignedReward(*this, matches); } uint64 callGetAnyPositionReward(uint8 matches) const { return getAnyPositionReward(*this, matches); } + uint64 callComputePrize(const Array& winning, const Array& digits) + { + Ticket ticket{}; + ticket.digits = digits; + ComputePrize_locals locals{}; + return computePrize(*this, ticket, winning, locals); + } }; class ContractTestingPulse : protected ContractTesting @@ -398,45 +402,6 @@ namespace return 0; } - uint64 computeExpectedPrize(PULSEChecker* state, const Array& winning, - const Array& digits) - { - uint8 leftAlignedMatches = 0; - for (uint8 offset = 0; offset + PULSE_PLAYER_DIGITS <= PULSE_WINNING_DIGITS; ++offset) - { - uint8 matchesAtOffset = 0; - for (uint8 j = 0; j < PULSE_PLAYER_DIGITS; ++j) - { - if (digits.get(j) == winning.get(offset + j)) - { - ++matchesAtOffset; - } - } - if (matchesAtOffset > leftAlignedMatches) - { - leftAlignedMatches = matchesAtOffset; - } - } - - uint16 winningMask = 0; - for (uint8 i = 0; i < PULSE_WINNING_DIGITS; ++i) - { - winningMask = static_cast(winningMask | (1u << winning.get(i))); - } - - uint8 anyPositionMatches = 0; - for (uint8 j = 0; j < PULSE_PLAYER_DIGITS; ++j) - { - if ((winningMask & (1u << digits.get(j))) != 0) - { - ++anyPositionMatches; - } - } - - const uint64 leftReward = state->callGetLeftAlignedReward(leftAlignedMatches); - const uint64 anyReward = state->callGetAnyPositionReward(anyPositionMatches); - return (leftReward > anyReward) ? leftReward : anyReward; - } } // namespace // ============================================================================ @@ -565,7 +530,7 @@ TEST(ContractPulse_Private, ValidateDigitsRejectsDuplicateAndOutOfRange) EXPECT_TRUE(ctl.state()->callValidateDigits(qpi, ok).isValid); const Array& dup = makePlayerDigits(0, 1, 2, 3, 4, 4); - EXPECT_FALSE(ctl.state()->callValidateDigits(qpi, dup).isValid); + EXPECT_TRUE(ctl.state()->callValidateDigits(qpi, dup).isValid); const Array& outOfRange = makePlayerDigits(0, 1, 2, 3, 4, 10); EXPECT_FALSE(ctl.state()->callValidateDigits(qpi, outOfRange).isValid); @@ -581,16 +546,13 @@ TEST(ContractPulse_Private, GetRandomDigitsDeterministic) const PULSE::GetRandomDigits_output& out1 = ctl.state()->callGetRandomDigits(qpi, seed); const PULSE::GetRandomDigits_output& out2 = ctl.state()->callGetRandomDigits(qpi, seed); - std::vector seen; for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) { const uint8 v1 = out1.digits.get(i); const uint8 v2 = out2.digits.get(i); EXPECT_EQ(v1, v2); EXPECT_LE(v1, PULSE_MAX_DIGIT); - seen.insert(v1); } - EXPECT_EQ(seen.size(), static_cast(PULSE_WINNING_DIGITS)); } TEST(ContractPulse_Private, ClearStateHelpersResetTicketData) @@ -641,7 +603,7 @@ TEST(ContractPulse_Private, SettleRoundUpdatesWinningDigitsAndPaysPrize) const uint64 playerBalanceAfter = ctl.qheartBalanceOf(player); EXPECT_EQ(playerBalanceAfter - playerBalanceBefore, ctl.state()->callGetLeftAlignedReward(6)); - expectWinningDigitsUniqueAndInRange(ctl.state()->getLastWinningDigits()); + expectWinningDigitsInRange(ctl.state()->getLastWinningDigits()); } // ============================================================================ @@ -758,7 +720,7 @@ TEST(ContractPulse_Public, BuyTicketValidatesDigits) const id user = id::randomValue(); ctl.transferQHeart(issuance, user, PULSE_TICKET_PRICE_DEFAULT); - const PULSE::BuyTicket_output& out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 4)); + const PULSE::BuyTicket_output& out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 10)); EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_NUMBERS)); } @@ -863,7 +825,7 @@ TEST(ContractPulse_System, BeginTickRunsDrawOnScheduledDay) EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); EXPECT_TRUE(ctl.state()->isSelling()); - expectWinningDigitsUniqueAndInRange(ctl.state()->getLastWinningDigits()); + expectWinningDigitsInRange(ctl.state()->getLastWinningDigits()); } TEST(ContractPulse_Gameplay, MultipleRoundsMultiplePlayers) @@ -924,7 +886,7 @@ TEST(ContractPulse_Gameplay, MultipleRoundsMultiplePlayers) PlayerCheck info{}; info.player = player; info.balanceAfterBuy = ctl.qheartBalanceOf(player); - info.expectedPrize = computeExpectedPrize(ctl.state(), winning, ticketDigits); + info.expectedPrize = ctl.state()->callComputePrize(winning, ticketDigits); players.push_back(info); } @@ -980,8 +942,8 @@ TEST(ContractPulse_Gameplay, ProRataPayoutWhenBalanceInsufficient) const uint64 balanceAfterBuyA = ctl.qheartBalanceOf(playerA); const uint64 balanceAfterBuyB = ctl.qheartBalanceOf(playerB); const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); - const uint64 prizeA = computeExpectedPrize(ctl.state(), winning, ticketA); - const uint64 prizeB = computeExpectedPrize(ctl.state(), winning, ticketB); + const uint64 prizeA = ctl.state()->callComputePrize(winning, ticketA); + const uint64 prizeB = ctl.state()->callComputePrize(winning, ticketB); const uint64 totalPrize = prizeA + prizeB; ASSERT_GT(totalPrize, contractBefore); @@ -1071,7 +1033,7 @@ TEST(ContractPulse_Gameplay, QHeartHoldLimitExcessTransferred) const m256i digest(0x11112222ULL, 0x33334444ULL, 0x55556666ULL, 0x77778888ULL); etalonTick.prevSpectrumDigest = digest; const Array& winning = deriveWinningDigits(ctl, digest); - const uint64 prize = computeExpectedPrize(ctl.state(), winning, digits); + const uint64 prize = ctl.state()->callComputePrize(winning, digits); const uint64 walletBefore = ctl.qheartBalanceOf(PULSE_QHEART_ISSUER); const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); From 642e6cac78da1b85cc602be1a50f9d4c6cbb4cdc Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 14 Jan 2026 19:15:27 +0300 Subject: [PATCH 41/77] Rename test --- test/contract_pulse.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index 1d9dd0f19..c4880c3df 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -520,7 +520,7 @@ TEST(ContractPulse_Private, NextEpochDataApplyUpdatesState) EXPECT_EQ(ctl.state()->getQHeartHoldLimitInternal(), 999u); } -TEST(ContractPulse_Private, ValidateDigitsRejectsDuplicateAndOutOfRange) +TEST(ContractPulse_Private, ValidateDigitsOutOfRange) { ContractTestingPulse ctl; QpiContextUserFunctionCall qpi(PULSE_CONTRACT_INDEX); From 336d82a52cc6fc2661d7718cdb400ba04fcd1fbd Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 14 Jan 2026 20:18:53 +0300 Subject: [PATCH 42/77] Update GameMechanics --- src/contracts/Pulse.h | 34 +++++++++++++--------------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index ad44dde4b..62e071f43 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -18,6 +18,7 @@ constexpr uint8 PULSE_PLAYER_DIGITS_ALIGNED = PULSE_PLAYER_DIGITS + 2; constexpr uint8 PULSE_WINNING_DIGITS = PULSE_PLAYER_DIGITS; constexpr uint8 PULSE_WINNING_DIGITS_ALIGNED = PULSE_PLAYER_DIGITS_ALIGNED; constexpr uint8 PULSE_MAX_DIGIT = 9; +constexpr uint8 PULSE_MAX_DIGIT_ALIGNED = PULSE_MAX_DIGIT + 7; constexpr uint64 PULSE_TICKET_PRICE_DEFAULT = 200000; constexpr uint64 PULSE_QHEART_ASSET_NAME = 92712259110993ULL; // "QHEART" constexpr uint8 PULSE_DEFAULT_DEV_PERCENT = 10; @@ -311,15 +312,14 @@ struct PULSE : public ContractBase struct ComputePrize_locals { uint8 leftAlignedMatches; - uint8 leftAlignedMatchesAtOffset; uint8 anyPositionMatches; uint8 j; uint8 digitValue; uint64 leftAlignedReward; uint64 anyPositionReward; uint64 prize; - uint16 ticketMask; - uint16 winningMask; + Array ticketCounts; + Array winningCounts; }; struct SettleRound_input @@ -892,34 +892,26 @@ struct PULSE : public ContractBase for (locals.j = 0; locals.j < PULSE_PLAYER_DIGITS; ++locals.j) { - if (ticket.digits.get(locals.j) == winningDigits.get(locals.j)) + if (ticket.digits.get(locals.j) != winningDigits.get(locals.j)) { - ++locals.leftAlignedMatchesAtOffset; + break; } - else - { - locals.leftAlignedMatchesAtOffset = 0; - } - - locals.leftAlignedMatches = max(locals.leftAlignedMatches, locals.leftAlignedMatchesAtOffset); - } - - for (locals.j = 0; locals.j < PULSE_WINNING_DIGITS; ++locals.j) - { - locals.winningMask = static_cast(locals.winningMask | (1u << winningDigits.get(locals.j))); + ++locals.leftAlignedMatches; } + STATIC_ASSERT(PULSE_PLAYER_DIGITS == PULSE_WINNING_DIGITS, "PULSE_PLAYER_DIGITS == PULSE_WINNING_DIGITS"); for (locals.j = 0; locals.j < PULSE_PLAYER_DIGITS; ++locals.j) { locals.digitValue = ticket.digits.get(locals.j); - locals.ticketMask = static_cast(locals.ticketMask | (1u << locals.digitValue)); + locals.ticketCounts.set(locals.digitValue, locals.ticketCounts.get(locals.digitValue) + 1); + + locals.digitValue = winningDigits.get(locals.j); + locals.winningCounts.set(locals.digitValue, locals.winningCounts.get(locals.digitValue) + 1); } - locals.ticketMask = static_cast(locals.ticketMask & locals.winningMask); - while (locals.ticketMask != 0) + for (locals.digitValue = 0; locals.digitValue <= PULSE_MAX_DIGIT; ++locals.digitValue) { - locals.anyPositionMatches += static_cast(locals.ticketMask & 1u); - locals.ticketMask = static_cast(locals.ticketMask >> 1); + locals.anyPositionMatches += min(locals.ticketCounts.get(locals.digitValue), locals.winningCounts.get(locals.digitValue)); } locals.leftAlignedReward = getLeftAlignedReward(state, locals.leftAlignedMatches); From 062b0f99499a3089be2cf3644bc84c6c1fec03da Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 14 Jan 2026 21:13:51 +0300 Subject: [PATCH 43/77] GetWinners --- src/contracts/Pulse.h | 67 +++++++++++++++++++++++++++++++++++++++++ test/contract_pulse.cpp | 56 +++++++++++++++++++++++++++++++++- 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 62e071f43..3daa7db2c 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -20,6 +20,7 @@ constexpr uint8 PULSE_WINNING_DIGITS_ALIGNED = PULSE_PLAYER_DIGITS_ALIGNED; constexpr uint8 PULSE_MAX_DIGIT = 9; constexpr uint8 PULSE_MAX_DIGIT_ALIGNED = PULSE_MAX_DIGIT + 7; constexpr uint64 PULSE_TICKET_PRICE_DEFAULT = 200000; +constexpr uint16 PULSE_MAX_NUMBER_OF_WINNERS_IN_HISTORY = 1024; constexpr uint64 PULSE_QHEART_ASSET_NAME = 92712259110993ULL; // "QHEART" constexpr uint8 PULSE_DEFAULT_DEV_PERCENT = 10; constexpr uint8 PULSE_DEFAULT_BURN_PERCENT = 5; @@ -237,6 +238,37 @@ struct PULSE : public ContractBase uint64 balance; }; + struct WinnerInfo + { + id winnerAddress; + uint64 revenue; + uint16 epoch; + }; + + struct FillWinnersInfo_input + { + id winnerAddress; + uint64 revenue; + }; + struct FillWinnersInfo_output + { + }; + struct FillWinnersInfo_locals + { + WinnerInfo winnerInfo; + uint64 insertIdx; + }; + + struct GetWinners_input + { + }; + struct GetWinners_output + { + Array winners; + uint64 winnersCounter; + uint8 returnCode; + }; + struct SetPrice_input { uint64 newPrice; @@ -352,6 +384,8 @@ struct PULSE : public ContractBase GetRandomDigits_output randomOutput; Ticket ticket; ComputePrize_locals computePrizeLocals; + FillWinnersInfo_input fillWinnersInfoInput; + FillWinnersInfo_output fillWinnersInfoOutput; }; struct BEGIN_TICK_locals @@ -376,6 +410,7 @@ struct PULSE : public ContractBase REGISTER_USER_FUNCTION(GetQHeartWallet, 6); REGISTER_USER_FUNCTION(GetWinningDigits, 7); REGISTER_USER_FUNCTION(GetBalance, 8); + REGISTER_USER_FUNCTION(GetWinners, 9); REGISTER_USER_PROCEDURE(BuyTicket, 1); REGISTER_USER_PROCEDURE(SetPrice, 2); @@ -506,6 +541,13 @@ struct PULSE : public ContractBase output.balance = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, SELF_INDEX, SELF_INDEX); } + PUBLIC_FUNCTION(GetWinners) + { + output.winners = state.winners; + getWinnerCounter(state, output.winnersCounter); + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + PUBLIC_PROCEDURE(SetPrice) { if (qpi.invocationReward() > 0) @@ -789,6 +831,10 @@ struct PULSE : public ContractBase qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, static_cast(locals.prize), locals.ticket.player); locals.balance -= locals.prize; + + locals.fillWinnersInfoInput.winnerAddress = locals.ticket.player; + locals.fillWinnersInfoInput.revenue = locals.prize; + CALL(FillWinnersInfo, locals.fillWinnersInfoInput, locals.fillWinnersInfoOutput); } } @@ -802,6 +848,23 @@ struct PULSE : public ContractBase } } + PRIVATE_PROCEDURE_WITH_LOCALS(FillWinnersInfo) + { + if (input.winnerAddress == id::zero()) + { + return; + } + + getWinnerCounter(state, locals.insertIdx); + ++state.winnersCounter; + + locals.winnerInfo.winnerAddress = input.winnerAddress; + locals.winnerInfo.revenue = input.revenue; + locals.winnerInfo.epoch = qpi.epoch(); + + state.winners.set(locals.insertIdx, locals.winnerInfo); + } + public: static void makeDateStamp(uint8 year, uint8 month, uint8 day, uint32& res) { res = static_cast(year << 9 | month << 5 | day); } @@ -821,6 +884,7 @@ struct PULSE : public ContractBase } protected: + Array winners; Array tickets; Array lastWinningDigits; uint64 ticketCounter; @@ -836,6 +900,7 @@ struct PULSE : public ContractBase EState currentState; id teamAddress; NextEpochData nextEpochData; + uint64 winnersCounter; protected: static void clearStateOnEndEpoch(PULSE& state) @@ -857,6 +922,8 @@ struct PULSE : public ContractBase static bool isSellingOpen(const PULSE& state) { return (state.currentState & EState::SELLING) != 0; } + static void getWinnerCounter(const PULSE& state, uint64& outCounter) { outCounter = mod(state.winnersCounter, state.winners.capacity()); } + static uint64 getLeftAlignedReward(const PULSE& state, uint8 matches) { switch (matches) diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index c4880c3df..fbb37e928 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -5,7 +5,6 @@ #undef private #undef _ALLOW_KEYWORD_MACROS -#include #include // Procedure/function indices (must match REGISTER_USER_FUNCTIONS_AND_PROCEDURES in `src/contracts/Pulse.h`). @@ -24,6 +23,7 @@ constexpr uint16 PULSE_FUNCTION_GET_QHEART_HOLD_LIMIT = 5; constexpr uint16 PULSE_FUNCTION_GET_QHEART_WALLET = 6; constexpr uint16 PULSE_FUNCTION_GET_WINNING_DIGITS = 7; constexpr uint16 PULSE_FUNCTION_GET_BALANCE = 8; +constexpr uint16 PULSE_FUNCTION_GET_WINNERS = 9; namespace { @@ -224,6 +224,14 @@ class ContractTestingPulse : protected ContractTesting return output; } + PULSE::GetWinners_output getWinners() + { + PULSE::GetWinners_input input{}; + PULSE::GetWinners_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_WINNERS, input, output); + return output; + } + PULSE::BuyTicket_output buyTicket(const id& user, const Array& digits) { ensureUserEnergy(user); @@ -777,6 +785,52 @@ TEST(ContractPulse_Public, GetBalanceReportsQHeartWalletBalance) EXPECT_EQ(ctl.getBalance().balance, 12345u); } +TEST(ContractPulse_Public, GetWinnersReportsPaidTickets) +{ + ContractTestingPulse ctl; + ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + + EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 0, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, 1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + ctl.endEpoch(); + + ctl.setDateTime(2025, 1, 9, 12); + ctl.beginEpoch(); + + ctl.transferQHeart(issuance, ctl.pulseSelf(), 10000); + const m256i digest(0x2222ULL, 0x3333ULL, 0x4444ULL, 0x5555ULL); + etalonTick.prevSpectrumDigest = digest; + const Array& winning = deriveWinningDigits(ctl, digest); + const uint8 missing = findMissingDigit(winning); + + const Array ticketA = + makePlayerDigits(winning.get(0), winning.get(1), winning.get(2), winning.get(3), winning.get(4), winning.get(5)); + const Array ticketB = + makePlayerDigits(winning.get(0), winning.get(1), winning.get(2), missing, missing, missing); + + const id playerA = id::randomValue(); + const id playerB = id::randomValue(); + ctl.transferQHeart(issuance, playerA, 1); + ctl.transferQHeart(issuance, playerB, 1); + EXPECT_EQ(ctl.buyTicket(playerA, ticketA).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.buyTicket(playerB, ticketB).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const uint64 prizeA = ctl.state()->callComputePrize(winning, ticketA); + const uint64 prizeB = ctl.state()->callComputePrize(winning, ticketB); + + ctl.setDateTime(2025, 1, 10, 12); + ctl.forceBeginTick(); + + const PULSE::GetWinners_output& winners = ctl.getWinners(); + EXPECT_EQ(winners.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(winners.winnersCounter, 2u); + EXPECT_EQ(winners.winners.get(0).winnerAddress, playerA); + EXPECT_EQ(winners.winners.get(0).revenue, prizeA); + EXPECT_EQ(winners.winners.get(1).winnerAddress, playerB); + EXPECT_EQ(winners.winners.get(1).revenue, prizeB); +} + // ============================================================================ // SYSTEM PROCEDURES // ============================================================================ From c3ea379f892faf1b94e84fd248e9c3f4ffe796fc Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 14 Jan 2026 22:14:50 +0300 Subject: [PATCH 44/77] BuyRandomTickets --- src/contracts/Pulse.h | 118 +++++++++++++++++++++++++++++++++++----- test/contract_pulse.cpp | 92 +++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 15 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 3daa7db2c..19608f979 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -170,6 +170,47 @@ struct PULSE : public ContractBase ValidateDigits_output validateOutput; }; + struct GetRandomDigits_input + { + uint64 seed; + }; + struct GetRandomDigits_output + { + Array digits; + }; + struct GetRandomDigits_locals + { + uint64 tempValue; + uint8 index; + uint8 candidate; + }; + + struct BuyRandomTickets_input + { + uint16 count; + }; + + struct BuyRandomTickets_output + { + uint8 returnCode; + }; + + struct BuyRandomTickets_locals + { + uint64 reward; + uint64 slotsLeft; + sint64 userBalance; + sint64 transferResult; + uint64 totalPrice; + m256i mixedSpectrumValue; + uint64 randomSeed; + uint64 tempSeed; + uint16 i; + Ticket ticket; + GetRandomDigits_input randomInput; + GetRandomDigits_output randomOutput; + }; + struct GetTicketPrice_input { }; @@ -326,21 +367,6 @@ struct PULSE : public ContractBase uint8 returnCode; }; - struct GetRandomDigits_input - { - uint64 seed; - }; - struct GetRandomDigits_output - { - Array digits; - }; - struct GetRandomDigits_locals - { - uint64 tempValue; - uint8 index; - uint8 candidate; - }; - struct ComputePrize_locals { uint8 leftAlignedMatches; @@ -418,6 +444,7 @@ struct PULSE : public ContractBase REGISTER_USER_PROCEDURE(SetDrawHour, 4); REGISTER_USER_PROCEDURE(SetFees, 5); REGISTER_USER_PROCEDURE(SetQHeartHoldLimit, 6); + REGISTER_USER_PROCEDURE(BuyRandomTickets, 7); } INITIALIZE() @@ -719,6 +746,67 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } + PUBLIC_PROCEDURE_WITH_LOCALS(BuyRandomTickets) + { + locals.reward = qpi.invocationReward(); + if (locals.reward > 0) + { + qpi.transfer(qpi.invocator(), locals.reward); + } + + if (!isSellingOpen(state)) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_SELLING_CLOSED); + return; + } + + if (input.count == 0) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + locals.slotsLeft = (state.ticketCounter < state.tickets.capacity()) ? (state.tickets.capacity() - state.ticketCounter) : 0; + if (locals.slotsLeft < input.count) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_ALL_SOLD_OUT); + return; + } + + locals.totalPrice = smul(static_cast(input.count), state.ticketPrice); + locals.userBalance = + qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, qpi.invocator(), qpi.invocator(), SELF_INDEX, SELF_INDEX); + if (locals.userBalance < static_cast(locals.totalPrice)) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); + return; + } + + locals.transferResult = qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, qpi.invocator(), + qpi.invocator(), static_cast(locals.totalPrice), SELF); + if (locals.transferResult < 0) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); + return; + } + + locals.mixedSpectrumValue = qpi.getPrevSpectrumDigest(); + locals.randomSeed = qpi.K12(locals.mixedSpectrumValue).u64._0; + for (locals.i = 0; locals.i < input.count; ++locals.i) + { + deriveOne(locals.randomSeed, locals.i, locals.tempSeed); + locals.randomInput.seed = locals.tempSeed; + CALL(GetRandomDigits, locals.randomInput, locals.randomOutput); + + locals.ticket.player = qpi.invocator(); + locals.ticket.digits = locals.randomOutput.digits; + state.tickets.set(state.ticketCounter, locals.ticket); + state.ticketCounter = min(state.ticketCounter + 1, state.tickets.capacity()); + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + private: PRIVATE_FUNCTION_WITH_LOCALS(ValidateDigits) { diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index fbb37e928..73f16b789 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -14,6 +14,7 @@ constexpr uint16 PULSE_PROCEDURE_SET_SCHEDULE = 3; constexpr uint16 PULSE_PROCEDURE_SET_DRAW_HOUR = 4; constexpr uint16 PULSE_PROCEDURE_SET_FEES = 5; constexpr uint16 PULSE_PROCEDURE_SET_QHEART_HOLD_LIMIT = 6; +constexpr uint16 PULSE_PROCEDURE_BUY_RANDOM_TICKETS = 7; constexpr uint16 PULSE_FUNCTION_GET_TICKET_PRICE = 1; constexpr uint16 PULSE_FUNCTION_GET_SCHEDULE = 2; @@ -79,6 +80,7 @@ class PULSEChecker : public PULSE uint8 getQHeartPercentInternal() const { return qheartPercent; } const id& getTeamAddressInternal() const { return teamAddress; } const Array& getLastWinningDigits() const { return lastWinningDigits; } + Ticket getTicket(uint64 index) const { return tickets.get(index); } void setTicketCounter(uint64 value) { ticketCounter = value; } void setTicketPriceInternal(uint64 value) { ticketPrice = value; } @@ -245,6 +247,19 @@ class ContractTestingPulse : protected ContractTesting return output; } + PULSE::BuyRandomTickets_output buyRandomTickets(const id& user, uint16 count) + { + ensureUserEnergy(user); + PULSE::BuyRandomTickets_input input{}; + input.count = count; + PULSE::BuyRandomTickets_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_BUY_RANDOM_TICKETS, input, output, user, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + PULSE::SetPrice_output setPrice(const id& invocator, uint64 newPrice) { ensureUserEnergy(invocator); @@ -777,6 +792,83 @@ TEST(ContractPulse_Public, BuyTicketSucceedsAndMovesQHeart) EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore + PULSE_TICKET_PRICE_DEFAULT); } +TEST(ContractPulse_Public, BuyRandomTicketsFailsWhenSellingClosed) +{ + ContractTestingPulse ctl; + const id user = id::randomValue(); + const PULSE::BuyRandomTickets_output out = ctl.buyRandomTickets(user, 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_SELLING_CLOSED)); +} + +TEST(ContractPulse_Public, BuyRandomTicketsRejectsZeroCount) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const id user = id::randomValue(); + const PULSE::BuyRandomTickets_output out = ctl.buyRandomTickets(user, 0); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +TEST(ContractPulse_Public, BuyRandomTicketsFailsWhenSoldOut) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS - 1); + + const id user = id::randomValue(); + const PULSE::BuyRandomTickets_output out = ctl.buyRandomTickets(user, 2); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_ALL_SOLD_OUT)); +} + +TEST(ContractPulse_Public, BuyRandomTicketsFailsWithInsufficientBalance) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + ctl.transferQHeart(issuance, user, PULSE_TICKET_PRICE_DEFAULT); + + const PULSE::BuyRandomTickets_output out = ctl.buyRandomTickets(user, 2); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_INVALID_PRICE)); +} + +TEST(ContractPulse_Public, BuyRandomTicketsSucceedsAndMovesQHeart) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + static constexpr uint16 ticketCount = 3; + static constexpr uint64 totalPrice = static_cast(ticketCount) * PULSE_TICKET_PRICE_DEFAULT; + ctl.transferQHeart(issuance, user, totalPrice); + + const uint64 userBefore = ctl.qheartBalanceOf(user); + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + + const PULSE::BuyRandomTickets_output out = ctl.buyRandomTickets(user, ticketCount); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getTicketCounter(), static_cast(ticketCount)); + EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore - totalPrice); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore + totalPrice); + + for (uint16 i = 0; i < ticketCount; ++i) + { + const PULSE::Ticket ticket = ctl.state()->getTicket(i); + EXPECT_EQ(ticket.player, user); + QpiContextUserFunctionCall qpi(PULSE_CONTRACT_INDEX); + primeQpiFunctionContext(qpi); + const PULSE::ValidateDigits_output validated = ctl.state()->callValidateDigits(qpi, ticket.digits); + EXPECT_TRUE(validated.isValid); + } +} + TEST(ContractPulse_Public, GetBalanceReportsQHeartWalletBalance) { ContractTestingPulse ctl; From 5414f2b52201cdb14fcc5b2eee519e15d3b34a98 Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 14 Jan 2026 22:31:16 +0300 Subject: [PATCH 45/77] Update BuyRandomTicketsSucceedsAndMovesQHeart --- test/contract_pulse.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index 73f16b789..6cfc77e50 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -851,6 +851,7 @@ TEST(ContractPulse_Public, BuyRandomTicketsSucceedsAndMovesQHeart) const uint64 userBefore = ctl.qheartBalanceOf(user); const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + etalonTick.prevSpectrumDigest = m256i(0xAAAABBBBULL, 0xCCCCDDDDULL, 0x11112222ULL, 0x33334444ULL); const PULSE::BuyRandomTickets_output out = ctl.buyRandomTickets(user, ticketCount); EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); @@ -858,6 +859,7 @@ TEST(ContractPulse_Public, BuyRandomTicketsSucceedsAndMovesQHeart) EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore - totalPrice); EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore + totalPrice); + std::set seen; for (uint16 i = 0; i < ticketCount; ++i) { const PULSE::Ticket ticket = ctl.state()->getTicket(i); @@ -866,6 +868,15 @@ TEST(ContractPulse_Public, BuyRandomTicketsSucceedsAndMovesQHeart) primeQpiFunctionContext(qpi); const PULSE::ValidateDigits_output validated = ctl.state()->callValidateDigits(qpi, ticket.digits); EXPECT_TRUE(validated.isValid); + + uint32 key = 0; + uint32 mul = 1; + for (uint64 d = 0; d < PULSE_PLAYER_DIGITS; ++d) + { + key += static_cast(ticket.digits.get(d)) * mul; + mul *= 10; + } + EXPECT_TRUE(seen.insert(key).second); } } From c326cd6c3b91681fb44203e3264774c727cfcc78 Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 14 Jan 2026 22:48:32 +0300 Subject: [PATCH 46/77] Adds comments --- src/contracts/Pulse.h | 33 +++++++++++++++++++++++++++++++++ test/contract_pulse.cpp | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 19608f979..800168623 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -43,6 +43,7 @@ struct PULSE2 struct PULSE : public ContractBase { public: + // Bitmask for runtime state flags. enum class EState : uint8 { SELLING = 1 << 0, @@ -54,6 +55,7 @@ struct PULSE : public ContractBase template friend bool operator==(const EState& a, const T& b) { return static_cast(a) == b; } template friend bool operator!=(const EState& a, const T& b) { return !(a == b); } + // Public return codes for user procedures/functions. enum class EReturnCode : uint8 { SUCCESS, @@ -68,12 +70,14 @@ struct PULSE : public ContractBase static constexpr uint8 toReturnCode(const EReturnCode& code) { return static_cast(code); }; + // Ticket payload stored per round; digits use QPI-aligned storage. struct Ticket { id player; Array digits; }; + // Deferred settings applied at END_EPOCH to avoid mid-round changes. struct NextEpochData { void clear() @@ -279,6 +283,7 @@ struct PULSE : public ContractBase uint64 balance; }; + // Winner history entry returned by GetWinners. struct WinnerInfo { id winnerAddress; @@ -491,6 +496,7 @@ struct PULSE : public ContractBase BEGIN_TICK_WITH_LOCALS() { + // Throttle draw checks to reduce per-tick cost. if (mod(qpi.tick(), static_cast(PULSE_TICK_UPDATE_PERIOD)) != 0) { return; @@ -547,13 +553,20 @@ struct PULSE : public ContractBase enableBuyTicket(state, !locals.isWednesday); } + // Returns current ticket price in QHeart units. PUBLIC_FUNCTION(GetTicketPrice) { output.ticketPrice = state.ticketPrice; } + // Returns current draw schedule bitmask. PUBLIC_FUNCTION(GetSchedule) { output.schedule = state.schedule; } + // Returns draw hour in UTC. PUBLIC_FUNCTION(GetDrawHour) { output.drawHour = state.drawHour; } + // Returns QHeart balance cap retained by the contract. PUBLIC_FUNCTION(GetQHeartHoldLimit) { output.qheartHoldLimit = state.qheartHoldLimit; } + // Returns the designated QHeart issuer wallet. PUBLIC_FUNCTION(GetQHeartWallet) { output.wallet = PULSE_QHEART_ISSUER; } + // Returns digits from the last settled draw. PUBLIC_FUNCTION(GetWinningDigits) { output.digits = state.lastWinningDigits; } + // Returns current fee split configuration. PUBLIC_FUNCTION(GetFees) { output.devPercent = state.devPercent; @@ -563,11 +576,13 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } + // Returns contract QHeart balance held in the Pulse wallet. PUBLIC_FUNCTION(GetBalance) { output.balance = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, SELF_INDEX, SELF_INDEX); } + // Returns the winners ring buffer and total winners counter. PUBLIC_FUNCTION(GetWinners) { output.winners = state.winners; @@ -575,6 +590,7 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } + // Schedules a new ticket price for the next epoch (owner-only). PUBLIC_PROCEDURE(SetPrice) { if (qpi.invocationReward() > 0) @@ -599,6 +615,7 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } + // Schedules a new draw schedule bitmask for the next epoch (owner-only). PUBLIC_PROCEDURE(SetSchedule) { if (qpi.invocationReward() > 0) @@ -623,6 +640,7 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } + // Schedules a new draw hour in UTC for the next epoch (owner-only). PUBLIC_PROCEDURE(SetDrawHour) { if (qpi.invocationReward() > 0) @@ -647,6 +665,7 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } + // Schedules new fee splits for the next epoch (owner-only). PUBLIC_PROCEDURE(SetFees) { if (qpi.invocationReward() > 0) @@ -675,6 +694,7 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } + // Schedules a new QHeart hold limit for the next epoch (owner-only). PUBLIC_PROCEDURE(SetQHeartHoldLimit) { if (qpi.invocationReward() > 0) @@ -693,6 +713,7 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } + // Buys a single ticket; transfers ticket price from invocator. PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicket) { locals.reward = qpi.invocationReward(); @@ -746,6 +767,7 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } + // Buys multiple random tickets; transfers total price from invocator. PUBLIC_PROCEDURE_WITH_LOCALS(BuyRandomTickets) { locals.reward = qpi.invocationReward(); @@ -824,6 +846,7 @@ struct PULSE : public ContractBase PRIVATE_FUNCTION_WITH_LOCALS(GetRandomDigits) { + // Derive each digit independently to avoid shared PRNG state. for (locals.index = 0; locals.index < PULSE_WINNING_DIGITS; ++locals.index) { deriveOne(input.seed, locals.index, locals.tempValue); @@ -910,6 +933,7 @@ struct PULSE : public ContractBase if (locals.totalPrize > 0 && locals.availableBalance < locals.totalPrize) { + // Pro-rate payouts when the contract balance cannot cover all prizes. locals.prize = div(smul(static_cast(locals.prize), static_cast(locals.availableBalance)), static_cast(locals.totalPrize)); } @@ -954,11 +978,13 @@ struct PULSE : public ContractBase } public: + // Encodes YYYY/MM/DD into a compact sortable date stamp. static void makeDateStamp(uint8 year, uint8 month, uint8 day, uint32& res) { res = static_cast(year << 9 | month << 5 | day); } template static constexpr T min(const T& a, const T& b) { return (a < b) ? a : b; } template static constexpr T max(const T& a, const T& b) { return a > b ? a : b; } + // Per-index mix to deterministically expand a single seed. static void deriveOne(const uint64& r, const uint64& idx, uint64& outValue) { mix64(r + 0x9e3779b97f4a7c15ULL * (idx + 1), outValue); } static void mix64(const uint64& x, uint64& outValue) @@ -972,12 +998,17 @@ struct PULSE : public ContractBase } protected: + // Ring buffer of recent winners; index is winnersCounter % capacity. Array winners; + // Tickets for the current round; valid range is [0, ticketCounter). Array tickets; + // Last settled winning digits; undefined before the first draw. Array lastWinningDigits; uint64 ticketCounter; uint64 ticketPrice; + // Contract balance above this cap is swept to the QHeart wallet after settlement. uint64 qheartHoldLimit; + // Date stamp of the most recent draw; PULSE_DEFAULT_INIT_TIME is a bootstrap sentinel. uint32 lastDrawDateStamp; uint8 devPercent; uint8 burnPercent; @@ -988,6 +1019,7 @@ struct PULSE : public ContractBase EState currentState; id teamAddress; NextEpochData nextEpochData; + // Monotonic winner count used to rotate the winners ring buffer. uint64 winnersCounter; protected: @@ -1069,6 +1101,7 @@ struct PULSE : public ContractBase locals.anyPositionMatches += min(locals.ticketCounts.get(locals.digitValue), locals.winningCounts.get(locals.digitValue)); } + // Reward the best of left-aligned or any-position matches to avoid double counting. locals.leftAlignedReward = getLeftAlignedReward(state, locals.leftAlignedMatches); locals.anyPositionReward = getAnyPositionReward(state, locals.anyPositionMatches); locals.prize = max(locals.leftAlignedReward, locals.anyPositionReward); diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index 6cfc77e50..ab18b146b 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -1,5 +1,6 @@ #define NO_UEFI #define _ALLOW_KEYWORD_MACROS 1 +// Allow tests to call internal helpers without changing production visibility. #define private protected #include "contract_testing.h" #undef private @@ -28,12 +29,14 @@ constexpr uint16 PULSE_FUNCTION_GET_WINNERS = 9; namespace { + // QPI contexts must be primed with a call to satisfy internal checks. void primeQpiFunctionContext(QpiContextUserFunctionCall& qpi) { PULSE::GetTicketPrice_input input{}; qpi.call(PULSE_FUNCTION_GET_TICKET_PRICE, &input, sizeof(input)); } + // Use a safe call to seed procedure context for private calls. void primeQpiProcedureContext(QpiContextUserProcedureCall& qpi) { PULSE::SetDrawHour_input input{}; @@ -347,6 +350,7 @@ class ContractTestingPulse : protected ContractTesting void forceBeginTick() { + // Align to update period so BEGIN_TICK evaluates draw logic. system.tick = system.tick + (PULSE_TICK_UPDATE_PERIOD - (system.tick % PULSE_TICK_UPDATE_PERIOD)); beginTick(); } @@ -397,6 +401,7 @@ class ContractTestingPulse : protected ContractTesting namespace { + // Mirror contract RNG path so tests can assert deterministic winners. Array deriveWinningDigits(ContractTestingPulse& ctl, const m256i& digest) { m256i hashResult; @@ -431,6 +436,7 @@ namespace // STATIC + PRIVATE METHOD TESTS // ============================================================================ +// Regression coverage for deterministic helpers used by draw logic. TEST(ContractPulse_Static, MakeDateStampMinMaxAndMixingAreDeterministic) { uint32 stamp = 0; @@ -452,6 +458,7 @@ TEST(ContractPulse_Static, MakeDateStampMinMaxAndMixingAreDeterministic) EXPECT_NE(d1, d2); } +// Guard state flag transitions used to open/close ticket sales. TEST(ContractPulse_Static, SellingFlagToggles) { ContractTestingPulse ctl; @@ -461,6 +468,7 @@ TEST(ContractPulse_Static, SellingFlagToggles) EXPECT_FALSE(ctl.state()->isSelling()); } +// Ensure reward multipliers stay aligned with contract constants. TEST(ContractPulse_Static, RewardTablesMatchContractConstants) { ContractTestingPulse ctl; @@ -481,6 +489,7 @@ TEST(ContractPulse_Static, RewardTablesMatchContractConstants) EXPECT_EQ(ctl.state()->callGetAnyPositionReward(0), 0u); } +// Prevent stale config from leaking across epochs. TEST(ContractPulse_Private, NextEpochDataClearResetsFlagsAndValues) { PULSE::NextEpochData data{}; @@ -514,6 +523,7 @@ TEST(ContractPulse_Private, NextEpochDataClearResetsFlagsAndValues) EXPECT_EQ(data.newQHeartHoldLimit, 0u); } +// Confirm deferred config applies only at epoch boundary. TEST(ContractPulse_Private, NextEpochDataApplyUpdatesState) { ContractTestingPulse ctl; @@ -543,6 +553,7 @@ TEST(ContractPulse_Private, NextEpochDataApplyUpdatesState) EXPECT_EQ(ctl.state()->getQHeartHoldLimitInternal(), 999u); } +// Reject invalid digits early to keep prize logic safe. TEST(ContractPulse_Private, ValidateDigitsOutOfRange) { ContractTestingPulse ctl; @@ -559,6 +570,7 @@ TEST(ContractPulse_Private, ValidateDigitsOutOfRange) EXPECT_FALSE(ctl.state()->callValidateDigits(qpi, outOfRange).isValid); } +// Keep RNG output deterministic for auditability. TEST(ContractPulse_Private, GetRandomDigitsDeterministic) { ContractTestingPulse ctl; @@ -578,6 +590,7 @@ TEST(ContractPulse_Private, GetRandomDigitsDeterministic) } } +// Ensure draw/epoch cleanup fully clears round state. TEST(ContractPulse_Private, ClearStateHelpersResetTicketData) { ContractTestingPulse ctl; @@ -592,6 +605,7 @@ TEST(ContractPulse_Private, ClearStateHelpersResetTicketData) EXPECT_EQ(ctl.state()->getLastDrawDateStamp(), 0u); } +// Validate settlement updates winners and pays prizes. TEST(ContractPulse_Private, SettleRoundUpdatesWinningDigitsAndPaysPrize) { ContractTestingPulse ctl; @@ -633,6 +647,7 @@ TEST(ContractPulse_Private, SettleRoundUpdatesWinningDigitsAndPaysPrize) // PUBLIC FUNCTIONS AND PROCEDURES // ============================================================================ +// Confirm defaults are visible through the public API after init. TEST(ContractPulse_Public, GettersReturnDefaultsAfterInitialize) { ContractTestingPulse ctl; @@ -657,6 +672,7 @@ TEST(ContractPulse_Public, GettersReturnDefaultsAfterInitialize) EXPECT_EQ(ctl.getQHeartWallet().wallet, PULSE_QHEART_ISSUER); } +// Guard admin-only price changes and deferred apply. TEST(ContractPulse_Public, SetPriceGuardsAccessAndAppliesOnEndEpoch) { ContractTestingPulse ctl; @@ -670,6 +686,7 @@ TEST(ContractPulse_Public, SetPriceGuardsAccessAndAppliesOnEndEpoch) EXPECT_EQ(ctl.state()->getTicketPriceInternal(), 555u); } +// Ensure schedule validation and deferred apply are enforced. TEST(ContractPulse_Public, SetScheduleValidatesAndAppliesOnEndEpoch) { ContractTestingPulse ctl; @@ -683,6 +700,7 @@ TEST(ContractPulse_Public, SetScheduleValidatesAndAppliesOnEndEpoch) EXPECT_EQ(ctl.state()->getScheduleInternal(), 0x7Fu); } +// Ensure draw hour range checks and deferred apply are enforced. TEST(ContractPulse_Public, SetDrawHourValidatesAndAppliesOnEndEpoch) { ContractTestingPulse ctl; @@ -696,6 +714,7 @@ TEST(ContractPulse_Public, SetDrawHourValidatesAndAppliesOnEndEpoch) EXPECT_EQ(ctl.state()->getDrawHourInternal(), 9u); } +// Protect against invalid fee splits and apply on epoch end. TEST(ContractPulse_Public, SetFeesValidatesAndAppliesOnEndEpoch) { ContractTestingPulse ctl; @@ -715,6 +734,7 @@ TEST(ContractPulse_Public, SetFeesValidatesAndAppliesOnEndEpoch) EXPECT_EQ(ctl.state()->getQHeartPercentInternal(), 4u); } +// Ensure hold-limit changes do not affect the current round. TEST(ContractPulse_Public, SetQHeartHoldLimitAppliesOnEndEpoch) { ContractTestingPulse ctl; @@ -726,6 +746,7 @@ TEST(ContractPulse_Public, SetQHeartHoldLimitAppliesOnEndEpoch) EXPECT_EQ(ctl.state()->getQHeartHoldLimitInternal(), 1234u); } +// Prevent ticket purchases outside the selling window. TEST(ContractPulse_Public, BuyTicketWhenSellingClosedFails) { ContractTestingPulse ctl; @@ -733,6 +754,7 @@ TEST(ContractPulse_Public, BuyTicketWhenSellingClosedFails) EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_SELLING_CLOSED)); } +// Reject malformed tickets before funds are transferred. TEST(ContractPulse_Public, BuyTicketValidatesDigits) { ContractTestingPulse ctl; @@ -747,6 +769,7 @@ TEST(ContractPulse_Public, BuyTicketValidatesDigits) EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_NUMBERS)); } +// Enforce hard cap on ticket count. TEST(ContractPulse_Public, BuyTicketFailsWhenSoldOut) { ContractTestingPulse ctl; @@ -758,6 +781,7 @@ TEST(ContractPulse_Public, BuyTicketFailsWhenSoldOut) EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_ALL_SOLD_OUT)); } +// Avoid unintended debt when buyer lacks funds. TEST(ContractPulse_Public, BuyTicketFailsWithInsufficientBalance) { ContractTestingPulse ctl; @@ -772,6 +796,7 @@ TEST(ContractPulse_Public, BuyTicketFailsWithInsufficientBalance) EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_INVALID_PRICE)); } +// Validate successful purchase moves funds and stores ticket. TEST(ContractPulse_Public, BuyTicketSucceedsAndMovesQHeart) { ContractTestingPulse ctl; @@ -792,6 +817,7 @@ TEST(ContractPulse_Public, BuyTicketSucceedsAndMovesQHeart) EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore + PULSE_TICKET_PRICE_DEFAULT); } +// Prevent random purchases outside the selling window. TEST(ContractPulse_Public, BuyRandomTicketsFailsWhenSellingClosed) { ContractTestingPulse ctl; @@ -800,6 +826,7 @@ TEST(ContractPulse_Public, BuyRandomTicketsFailsWhenSellingClosed) EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_SELLING_CLOSED)); } +// Reject empty batch requests to avoid no-op transfers. TEST(ContractPulse_Public, BuyRandomTicketsRejectsZeroCount) { ContractTestingPulse ctl; @@ -811,6 +838,7 @@ TEST(ContractPulse_Public, BuyRandomTicketsRejectsZeroCount) EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); } +// Enforce capacity checks for batch purchases. TEST(ContractPulse_Public, BuyRandomTicketsFailsWhenSoldOut) { ContractTestingPulse ctl; @@ -823,6 +851,7 @@ TEST(ContractPulse_Public, BuyRandomTicketsFailsWhenSoldOut) EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_ALL_SOLD_OUT)); } +// Avoid partial batch purchases when balance is insufficient. TEST(ContractPulse_Public, BuyRandomTicketsFailsWithInsufficientBalance) { ContractTestingPulse ctl; @@ -837,6 +866,7 @@ TEST(ContractPulse_Public, BuyRandomTicketsFailsWithInsufficientBalance) EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_INVALID_PRICE)); } +// Validate batch purchase moves funds and mints tickets. TEST(ContractPulse_Public, BuyRandomTicketsSucceedsAndMovesQHeart) { ContractTestingPulse ctl; @@ -880,6 +910,7 @@ TEST(ContractPulse_Public, BuyRandomTicketsSucceedsAndMovesQHeart) } } +// Ensure balance getter reflects actual QHeart wallet holdings. TEST(ContractPulse_Public, GetBalanceReportsQHeartWalletBalance) { ContractTestingPulse ctl; @@ -888,6 +919,7 @@ TEST(ContractPulse_Public, GetBalanceReportsQHeartWalletBalance) EXPECT_EQ(ctl.getBalance().balance, 12345u); } +// Confirm winner history records paid prizes. TEST(ContractPulse_Public, GetWinnersReportsPaidTickets) { ContractTestingPulse ctl; @@ -938,6 +970,7 @@ TEST(ContractPulse_Public, GetWinnersReportsPaidTickets) // SYSTEM PROCEDURES // ============================================================================ +// Ensure epoch start repairs defaults and opens selling. TEST(ContractPulse_System, BeginEpochRestoresDefaultsAndOpensSelling) { ContractTestingPulse ctl; @@ -951,6 +984,7 @@ TEST(ContractPulse_System, BeginEpochRestoresDefaultsAndOpensSelling) EXPECT_TRUE(ctl.state()->isSelling()); } +// Ensure epoch end applies pending config and clears state. TEST(ContractPulse_System, EndEpochAppliesPendingChangesAndClearsState) { ContractTestingPulse ctl; @@ -966,6 +1000,7 @@ TEST(ContractPulse_System, EndEpochAppliesPendingChangesAndClearsState) EXPECT_EQ(ctl.state()->getTicketPriceInternal(), 999u); } +// Validate scheduled draw trigger path. TEST(ContractPulse_System, BeginTickRunsDrawOnScheduledDay) { ContractTestingPulse ctl; @@ -985,6 +1020,7 @@ TEST(ContractPulse_System, BeginTickRunsDrawOnScheduledDay) expectWinningDigitsInRange(ctl.state()->getLastWinningDigits()); } +// Exercise multi-round lifecycle across multiple players. TEST(ContractPulse_Gameplay, MultipleRoundsMultiplePlayers) { ContractTestingPulse ctl; @@ -1063,6 +1099,7 @@ TEST(ContractPulse_Gameplay, MultipleRoundsMultiplePlayers) } } +// Guard pro-rata payout logic when balance is short. TEST(ContractPulse_Gameplay, ProRataPayoutWhenBalanceInsufficient) { ContractTestingPulse ctl; @@ -1115,6 +1152,7 @@ TEST(ContractPulse_Gameplay, ProRataPayoutWhenBalanceInsufficient) EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore - (expectedA + expectedB)); } +// Validate fee distribution to dev, shareholders, and QHeart wallet. TEST(ContractPulse_Gameplay, FeesDistributedToDevShareholdersAndQHeartWallet) { ContractTestingPulse ctl; @@ -1164,6 +1202,7 @@ TEST(ContractPulse_Gameplay, FeesDistributedToDevShareholdersAndQHeartWallet) EXPECT_EQ(ctl.qheartBalanceOf(PULSE_QHEART_ISSUER), qheartWalletBefore + expectedQHeart); } +// Ensure excess balance is swept to QHeart wallet after settlement. TEST(ContractPulse_Gameplay, QHeartHoldLimitExcessTransferred) { ContractTestingPulse ctl; From b9f73e21caa58abed95a74336967517bdf660eb7 Mon Sep 17 00:00:00 2001 From: N-010 Date: Mon, 19 Jan 2026 21:10:09 +0300 Subject: [PATCH 47/77] Rooms --- src/contracts/Pulse.h | 705 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 651 insertions(+), 54 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 800168623..74137ad4c 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -13,6 +13,7 @@ using namespace QPI; constexpr uint16 PULSE_MAX_NUMBER_OF_PLAYERS = 1024; +constexpr uint16 PULSE_MAX_NUMBER_OF_AUTO_PARTICIPANTS = PULSE_MAX_NUMBER_OF_PLAYERS / 2; constexpr uint8 PULSE_PLAYER_DIGITS = 6; constexpr uint8 PULSE_PLAYER_DIGITS_ALIGNED = PULSE_PLAYER_DIGITS + 2; constexpr uint8 PULSE_WINNING_DIGITS = PULSE_PLAYER_DIGITS; @@ -31,6 +32,8 @@ constexpr uint8 PULSE_TICK_UPDATE_PERIOD = 100; constexpr uint8 PULSE_DEFAULT_DRAW_HOUR = 11; // 11:00 UTC constexpr uint8 PULSE_DEFAULT_SCHEDULE = 1 << WEDNESDAY | 1 << FRIDAY | 1 << SUNDAY; constexpr uint32 PULSE_DEFAULT_INIT_TIME = 22 << 9 | 4 << 5 | 13; +constexpr uint16 PULSE_DEFAULT_MAX_AUTO_TICKETS_PER_USER = PULSE_MAX_NUMBER_OF_PLAYERS; +constexpr uint64 PULSE_CLEANUP_THRESHOLD = 75; const id PULSE_QHEART_ISSUER = ID(_S, _S, _G, _X, _S, _L, _S, _X, _F, _E, _J, _O, _O, _B, _T, _Z, _W, _V, _D, _S, _R, _C, _E, _F, _G, _X, _N, _D, _Y, _U, _V, _D, _X, _M, _Q, _A, _L, _X, _L, _B, _X, _G, _D, _C, _R, _X, _T, _K, _F, _Z, _I, _O, _T, _G, _Z, _F); @@ -62,9 +65,12 @@ struct PULSE : public ContractBase TICKET_INVALID_PRICE, TICKET_ALL_SOLD_OUT, TICKET_SELLING_CLOSED, + AUTO_PARTICIPANTS_FULL, INVALID_NUMBERS, ACCESS_DENIED, INVALID_VALUE, + TRANSFER_TO_PULSE_FAILED, + TRANSFER_FROM_PULSE_FAILED, UNKNOWN_ERROR = UINT8_MAX }; @@ -77,6 +83,13 @@ struct PULSE : public ContractBase Array digits; }; + struct AutoParticipant + { + id player; + sint64 deposit; + uint16 desiredTickets; + }; + // Deferred settings applied at END_EPOCH to avoid mid-round changes. struct NextEpochData { @@ -165,7 +178,6 @@ struct PULSE : public ContractBase struct BuyTicket_locals { - uint64 reward; uint64 slotsLeft; sint64 userBalance; sint64 transferResult; @@ -189,6 +201,63 @@ struct PULSE : public ContractBase uint8 candidate; }; + struct PrepareRandomTickets_input + { + uint16 count; + }; + + struct PrepareRandomTickets_output + { + uint8 returnCode; + uint16 count; + }; + + struct PrepareRandomTickets_locals + { + uint64 slotsLeft; + }; + + struct ChargeTicketsFromPlayer_input + { + id player; + uint16 count; + }; + + struct ChargeTicketsFromPlayer_output + { + uint8 returnCode; + }; + + struct ChargeTicketsFromPlayer_locals + { + sint64 userBalance; + sint64 transferResult; + uint64 totalPrice; + }; + + struct AllocateRandomTickets_input + { + id player; + uint16 count; + }; + + struct AllocateRandomTickets_output + { + uint8 returnCode; + }; + + struct AllocateRandomTickets_locals + { + uint64 slotsLeft; + m256i mixedSpectrumValue; + uint64 randomSeed; + uint64 tempSeed; + uint16 i; + Ticket ticket; + GetRandomDigits_input randomInput; + GetRandomDigits_output randomOutput; + }; + struct BuyRandomTickets_input { uint16 count; @@ -201,11 +270,76 @@ struct PULSE : public ContractBase struct BuyRandomTickets_locals { - uint64 reward; - uint64 slotsLeft; + PrepareRandomTickets_input prepareInput; + PrepareRandomTickets_output prepareOutput; + ChargeTicketsFromPlayer_input chargeInput; + ChargeTicketsFromPlayer_output chargeOutput; + AllocateRandomTickets_input allocateInput; + AllocateRandomTickets_output allocateOutput; + }; + + struct FindAutoParticipant_input + { + id player; + }; + struct FindAutoParticipant_output + { + bit found; + sint64 index; + }; + struct FindAutoParticipant_locals + { + sint64 elementIndex; + }; + + struct GetAutoParticipation_input + { + }; + struct GetAutoParticipation_output + { + uint64 deposit; + uint16 desiredTickets; + uint8 returnCode; + }; + struct GetAutoParticipation_locals + { + AutoParticipant entry; + }; + + struct GetAutoStats_input + { + }; + struct GetAutoStats_output + { + uint16 autoParticipantsCounter; + uint64 totalAutoDeposits; + sint64 autoStartIndex; + uint16 maxAutoTicketsPerUser; + uint8 returnCode; + }; + + struct DepositAutoParticipation_input + { + sint64 amount; + sint16 desiredTickets; + bit buyNow; + }; + struct DepositAutoParticipation_output + { + uint8 returnCode; + }; + struct DepositAutoParticipation_locals + { sint64 userBalance; sint64 transferResult; - uint64 totalPrice; + AutoParticipant entry; + sint64 insertIndex; + sint64 totalPrice; + uint64 slotsLeft; + uint64 affordable; + uint64 toBuy; + uint64 spend; + uint64 seedIndex; m256i mixedSpectrumValue; uint64 randomSeed; uint64 tempSeed; @@ -213,6 +347,51 @@ struct PULSE : public ContractBase Ticket ticket; GetRandomDigits_input randomInput; GetRandomDigits_output randomOutput; + BuyRandomTickets_input buyRandomTicketsInput; + BuyRandomTickets_output buyRandomTicketsOutput; + }; + + struct WithdrawAutoParticipation_input + { + sint64 amount; + }; + struct WithdrawAutoParticipation_output + { + uint8 returnCode; + }; + struct WithdrawAutoParticipation_locals + { + sint64 transferResult; + AutoParticipant entry; + sint64 removedIndex; + sint64 withdrawAmount; + }; + + struct SetAutoConfig_input + { + sint16 desiredTickets; + }; + struct SetAutoConfig_output + { + uint8 returnCode; + }; + struct SetAutoConfig_locals + { + sint64 insertIndex; + sint64 removedIndex; + AutoParticipant entry; + uint16 desiredValue; + FindAutoParticipant_input findInput; + FindAutoParticipant_output findOutput; + }; + + struct SetAutoLimits_input + { + uint16 maxTicketsPerUser; + }; + struct SetAutoLimits_output + { + uint8 returnCode; }; struct GetTicketPrice_input @@ -404,6 +583,7 @@ struct PULSE : public ContractBase uint64 prize; uint64 totalPrize; uint64 availableBalance; + uint64 reservedBalance; m256i mixedSpectrumValue; uint64 randomSeed; Asset shareholdersAsset; @@ -419,6 +599,23 @@ struct PULSE : public ContractBase FillWinnersInfo_output fillWinnersInfoOutput; }; + struct ProcessAutoTickets_input + { + }; + struct ProcessAutoTickets_output + { + }; + struct ProcessAutoTickets_locals + { + sint64 currentIndex; + sint64 slotsLeft; + uint64 affordable; + sint64 toBuy; + AutoParticipant entry; + AllocateRandomTickets_input allocateInput; + AllocateRandomTickets_output allocateOutput; + }; + struct BEGIN_TICK_locals { uint32 currentDateStamp; @@ -428,6 +625,14 @@ struct PULSE : public ContractBase uint8 isScheduledToday; SettleRound_input settleInput; SettleRound_output settleOutput; + ProcessAutoTickets_input autoTicketsInput; + ProcessAutoTickets_output autoTicketsOutput; + }; + + struct BEGIN_EPOCH_locals + { + ProcessAutoTickets_input autoTicketsInput; + ProcessAutoTickets_output autoTicketsOutput; }; public: @@ -442,6 +647,8 @@ struct PULSE : public ContractBase REGISTER_USER_FUNCTION(GetWinningDigits, 7); REGISTER_USER_FUNCTION(GetBalance, 8); REGISTER_USER_FUNCTION(GetWinners, 9); + REGISTER_USER_FUNCTION(GetAutoParticipation, 10); + REGISTER_USER_FUNCTION(GetAutoStats, 11); REGISTER_USER_PROCEDURE(BuyTicket, 1); REGISTER_USER_PROCEDURE(SetPrice, 2); @@ -450,6 +657,10 @@ struct PULSE : public ContractBase REGISTER_USER_PROCEDURE(SetFees, 5); REGISTER_USER_PROCEDURE(SetQHeartHoldLimit, 6); REGISTER_USER_PROCEDURE(BuyRandomTickets, 7); + REGISTER_USER_PROCEDURE(DepositAutoParticipation, 8); + REGISTER_USER_PROCEDURE(WithdrawAutoParticipation, 9); + REGISTER_USER_PROCEDURE(SetAutoConfig, 10); + REGISTER_USER_PROCEDURE(SetAutoLimits, 11); } INITIALIZE() @@ -468,10 +679,12 @@ struct PULSE : public ContractBase state.drawHour = PULSE_DEFAULT_DRAW_HOUR; state.lastDrawDateStamp = PULSE_DEFAULT_INIT_TIME; + state.maxAutoTicketsPerUser = PULSE_DEFAULT_MAX_AUTO_TICKETS_PER_USER; + enableBuyTicket(state, false); } - BEGIN_EPOCH() + BEGIN_EPOCH_WITH_LOCALS() { if (state.schedule == 0) { @@ -484,6 +697,10 @@ struct PULSE : public ContractBase makeDateStamp(qpi.year(), qpi.month(), qpi.day(), state.lastDrawDateStamp); enableBuyTicket(state, state.lastDrawDateStamp != PULSE_DEFAULT_INIT_TIME); + if (state.lastDrawDateStamp != PULSE_DEFAULT_INIT_TIME) + { + CALL(ProcessAutoTickets, locals.autoTicketsInput, locals.autoTicketsOutput); + } } END_EPOCH() @@ -523,6 +740,7 @@ struct PULSE : public ContractBase if (state.lastDrawDateStamp == PULSE_DEFAULT_INIT_TIME) { enableBuyTicket(state, true); + CALL(ProcessAutoTickets, locals.autoTicketsInput, locals.autoTicketsOutput); if (locals.isWednesday) { state.lastDrawDateStamp = locals.currentDateStamp; @@ -551,6 +769,10 @@ struct PULSE : public ContractBase clearStateOnEndDraw(state); enableBuyTicket(state, !locals.isWednesday); + if (!locals.isWednesday) + { + CALL(ProcessAutoTickets, locals.autoTicketsInput, locals.autoTicketsOutput); + } } // Returns current ticket price in QHeart units. @@ -590,6 +812,31 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } + /// Returns auto-participation settings for the invocator. + /// @return Current deposit, config fields, and status code. + PUBLIC_FUNCTION_WITH_LOCALS(GetAutoParticipation) + { + if (!state.autoParticipants.get(qpi.invocator(), locals.entry)) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + output.deposit = locals.entry.deposit; + output.desiredTickets = locals.entry.desiredTickets; + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + /// Returns global auto-participation limits and counters. + /// @return Current counters, limits, and status code. + PUBLIC_FUNCTION(GetAutoStats) + { + output.autoParticipantsCounter = static_cast(state.autoParticipants.population()); + output.maxAutoTicketsPerUser = state.maxAutoTicketsPerUser; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + // Schedules a new ticket price for the next epoch (owner-only). PUBLIC_PROCEDURE(SetPrice) { @@ -713,13 +960,229 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } + /** Deposits QHeart into the contract for automatic ticket purchases. + * @param amount QHeart amount to reserve for auto participation. + * @param desiredTickets Number of tickets to buy per draw + * @param buyNow When true, tries to buy immediately if selling is open. + * @return Status code describing the result. + */ + PUBLIC_PROCEDURE_WITH_LOCALS(DepositAutoParticipation) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (state.autoParticipants.population() >= state.autoParticipants.capacity()) + { + output.returnCode = toReturnCode(EReturnCode::AUTO_PARTICIPANTS_FULL); + return; + } + + if (input.amount == 0 || input.desiredTickets == 0) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + if (state.maxAutoTicketsPerUser != 0) + { + input.desiredTickets = min(input.desiredTickets, state.maxAutoTicketsPerUser); + } + + locals.userBalance = + qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, qpi.invocator(), qpi.invocator(), SELF_INDEX, SELF_INDEX); + input.amount = min(locals.userBalance, input.amount); + + locals.totalPrice = smul(state.ticketPrice, static_cast(input.desiredTickets)); + + if (input.amount < locals.totalPrice) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); + return; + } + + if (input.buyNow && isSellingOpen(state)) + { + locals.buyRandomTicketsInput.count = input.desiredTickets; + CALL(BuyRandomTickets, locals.buyRandomTicketsInput, locals.buyRandomTicketsOutput); + if (locals.buyRandomTicketsOutput.returnCode != toReturnCode(EReturnCode::SUCCESS)) + { + output.returnCode = locals.buyRandomTicketsOutput.returnCode; + return; + } + + input.buyNow = false; + input.amount = input.amount - locals.totalPrice; + + // The entire deposit was spent + if (input.amount <= 0) + { + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + return; + } + } + + state.autoParticipants.get(qpi.invocator(), locals.entry); + locals.entry.player = qpi.invocator(); + + locals.transferResult = qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, qpi.invocator(), + qpi.invocator(), input.amount, SELF); + if (locals.transferResult < 0) + { + output.returnCode = toReturnCode(EReturnCode::TRANSFER_TO_PULSE_FAILED); + return; + } + + locals.entry.deposit = sadd(locals.entry.deposit, input.amount); + if (input.desiredTickets > 0) + { + locals.entry.desiredTickets = input.desiredTickets; + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + /// Withdraws QHeart from the invocator's auto-participation deposit. + /// @param amount QHeart amount to withdraw; 0 withdraws the full deposit. + /// @return Status code describing the result. + PUBLIC_PROCEDURE_WITH_LOCALS(WithdrawAutoParticipation) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (!state.autoParticipants.contains(qpi.invocator())) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + if (!state.autoParticipants.get(qpi.invocator(), locals.entry)) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + locals.withdrawAmount = (input.amount <= 0) ? locals.entry.deposit : min(input.amount, locals.entry.deposit); + + if (locals.withdrawAmount == 0) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + locals.transferResult = + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, locals.withdrawAmount, qpi.invocator()); + if (locals.transferResult < 0) + { + output.returnCode = toReturnCode(EReturnCode::TRANSFER_FROM_PULSE_FAILED); + return; + } + + locals.entry.deposit -= locals.withdrawAmount; + + if (locals.entry.deposit <= 0) + { + state.autoParticipants.removeByKey(qpi.invocator()); + state.autoParticipants.cleanupIfNeeded(PULSE_CLEANUP_THRESHOLD); + } + else + { + state.autoParticipants.set(qpi.invocator(), locals.entry); + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + /// Sets auto-participation config for the invocator. + /// @param desiredTickets Signed: -1 ignore, 0 disable, >0 set new value. + /// @param minTicketsToBuy Signed: -1 ignore, 0 disable, >0 set new value. + /// @return Status code describing the result. + PUBLIC_PROCEDURE_WITH_LOCALS(SetAutoConfig) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (input.desiredTickets < -1) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + if (!state.autoParticipants.contains(qpi.invocator())) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + input.desiredTickets = min(input.desiredTickets, state.maxAutoTicketsPerUser); + + state.autoParticipants.get(qpi.invocator(), locals.entry); + + locals.desiredValue = locals.entry.desiredTickets; + + if (input.desiredTickets != -1) + { + if (input.desiredTickets < 0) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + locals.desiredValue = static_cast(input.desiredTickets); + } + + locals.entry.desiredTickets = locals.desiredValue; + + if (locals.entry.deposit == 0 && locals.entry.desiredTickets == 0) + { + locals.removedIndex = state.autoParticipants.removeByKey(qpi.invocator()); + state.autoParticipants.cleanupIfNeeded(PULSE_CLEANUP_THRESHOLD); + } + else + { + state.autoParticipants.set(qpi.invocator(), locals.entry); + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + /// Sets auto-participation limits (owner-only). + /// @param maxTicketsPerUser Max tickets per user; 0 disables the limit. + /// @param maxDepositPerUser Max deposit per user; 0 disables the limit. + /// @return Status code describing the result. + PUBLIC_PROCEDURE(SetAutoLimits) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (qpi.invocator() != PULSE_QHEART_ISSUER) + { + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); + return; + } + + if (input.maxTicketsPerUser != 0 && input.maxTicketsPerUser > PULSE_MAX_NUMBER_OF_PLAYERS) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + state.maxAutoTicketsPerUser = input.maxTicketsPerUser; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + // Buys a single ticket; transfers ticket price from invocator. PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicket) { - locals.reward = qpi.invocationReward(); - if (locals.reward > 0) + if (qpi.invocationReward() > 0) { - qpi.transfer(qpi.invocator(), locals.reward); + qpi.transfer(qpi.invocator(), qpi.invocationReward()); } if (!isSellingOpen(state)) @@ -736,7 +1199,7 @@ struct PULSE : public ContractBase return; } - locals.slotsLeft = (state.ticketCounter < state.tickets.capacity()) ? (state.tickets.capacity() - state.ticketCounter) : 0; + locals.slotsLeft = getSlotsLeft(state); if (locals.slotsLeft == 0) { output.returnCode = toReturnCode(EReturnCode::TICKET_ALL_SOLD_OUT); @@ -745,14 +1208,14 @@ struct PULSE : public ContractBase locals.userBalance = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, qpi.invocator(), qpi.invocator(), SELF_INDEX, SELF_INDEX); - if (locals.userBalance < static_cast(state.ticketPrice)) + if (locals.userBalance < state.ticketPrice) { output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); return; } locals.transferResult = qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, qpi.invocator(), - qpi.invocator(), static_cast(state.ticketPrice), SELF); + qpi.invocator(), state.ticketPrice, SELF); if (locals.transferResult < 0) { output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); @@ -762,7 +1225,7 @@ struct PULSE : public ContractBase locals.ticket.player = qpi.invocator(); locals.ticket.digits = input.digits; state.tickets.set(state.ticketCounter, locals.ticket); - state.ticketCounter = min(state.ticketCounter + 1, state.tickets.capacity()); + state.ticketCounter = min(static_cast(state.ticketCounter) + 1ull, state.tickets.capacity()); output.returnCode = toReturnCode(EReturnCode::SUCCESS); } @@ -770,66 +1233,101 @@ struct PULSE : public ContractBase // Buys multiple random tickets; transfers total price from invocator. PUBLIC_PROCEDURE_WITH_LOCALS(BuyRandomTickets) { - locals.reward = qpi.invocationReward(); - if (locals.reward > 0) + if (qpi.invocationReward() > 0) { - qpi.transfer(qpi.invocator(), locals.reward); + qpi.transfer(qpi.invocator(), qpi.invocationReward()); } - if (!isSellingOpen(state)) + locals.prepareInput.count = input.count; + CALL(PrepareRandomTickets, locals.prepareInput, locals.prepareOutput); + if (locals.prepareOutput.returnCode != toReturnCode(EReturnCode::SUCCESS)) { - output.returnCode = toReturnCode(EReturnCode::TICKET_SELLING_CLOSED); + output.returnCode = locals.prepareOutput.returnCode; return; } - if (input.count == 0) + locals.chargeInput.player = qpi.invocator(); + locals.chargeInput.count = locals.prepareOutput.count; + CALL(ChargeTicketsFromPlayer, locals.chargeInput, locals.chargeOutput); + if (locals.chargeOutput.returnCode != toReturnCode(EReturnCode::SUCCESS)) { - output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + output.returnCode = locals.chargeOutput.returnCode; return; } - locals.slotsLeft = (state.ticketCounter < state.tickets.capacity()) ? (state.tickets.capacity() - state.ticketCounter) : 0; - if (locals.slotsLeft < input.count) - { - output.returnCode = toReturnCode(EReturnCode::TICKET_ALL_SOLD_OUT); - return; - } + locals.allocateInput.player = qpi.invocator(); + locals.allocateInput.count = locals.prepareOutput.count; + CALL(AllocateRandomTickets, locals.allocateInput, locals.allocateOutput); - locals.totalPrice = smul(static_cast(input.count), state.ticketPrice); - locals.userBalance = - qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, qpi.invocator(), qpi.invocator(), SELF_INDEX, SELF_INDEX); - if (locals.userBalance < static_cast(locals.totalPrice)) + output.returnCode = locals.allocateOutput.returnCode; + } + +private: + PRIVATE_PROCEDURE_WITH_LOCALS(ProcessAutoTickets) + { + if (!isSellingOpen(state) || state.autoParticipants.population() == 0) { - output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); return; } - locals.transferResult = qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, qpi.invocator(), - qpi.invocator(), static_cast(locals.totalPrice), SELF); - if (locals.transferResult < 0) + locals.slotsLeft = getSlotsLeft(state); + if (locals.slotsLeft == 0) { - output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); return; } - locals.mixedSpectrumValue = qpi.getPrevSpectrumDigest(); - locals.randomSeed = qpi.K12(locals.mixedSpectrumValue).u64._0; - for (locals.i = 0; locals.i < input.count; ++locals.i) + locals.currentIndex = state.autoParticipants.nextElementIndex(NULL_INDEX); + while (locals.currentIndex != NULL_INDEX) { - deriveOne(locals.randomSeed, locals.i, locals.tempSeed); - locals.randomInput.seed = locals.tempSeed; - CALL(GetRandomDigits, locals.randomInput, locals.randomOutput); + locals.slotsLeft = getSlotsLeft(state); + if (locals.slotsLeft == 0) + { + break; + } - locals.ticket.player = qpi.invocator(); - locals.ticket.digits = locals.randomOutput.digits; - state.tickets.set(state.ticketCounter, locals.ticket); - state.ticketCounter = min(state.ticketCounter + 1, state.tickets.capacity()); + locals.entry = state.autoParticipants.value(locals.currentIndex); + + locals.affordable = div(locals.entry.deposit, state.ticketPrice); + if (locals.affordable == 0) + { + state.autoParticipants.removeByIndex(locals.currentIndex); + locals.currentIndex = state.autoParticipants.nextElementIndex(locals.currentIndex); + continue; + } + + locals.toBuy = static_cast(min(locals.affordable, static_cast(locals.entry.desiredTickets))); + locals.toBuy = min(locals.toBuy, locals.slotsLeft); + if (locals.toBuy <= 0) + { + locals.currentIndex = state.autoParticipants.nextElementIndex(locals.currentIndex); + continue; + } + + locals.allocateInput.player = locals.entry.player; + locals.allocateInput.count = static_cast(locals.toBuy); + CALL(AllocateRandomTickets, locals.allocateInput, locals.allocateOutput); + if (locals.allocateOutput.returnCode != toReturnCode(EReturnCode::SUCCESS)) + { + locals.currentIndex = state.autoParticipants.nextElementIndex(locals.currentIndex); + continue; + } + + locals.entry.deposit -= smul(locals.toBuy, state.ticketPrice); + if (locals.entry.deposit <= 0) + { + state.autoParticipants.removeByIndex(locals.currentIndex); + } + else + { + state.autoParticipants.set(locals.entry.player, locals.entry); + } + + locals.currentIndex = state.autoParticipants.nextElementIndex(locals.currentIndex); } - output.returnCode = toReturnCode(EReturnCode::SUCCESS); + state.autoParticipants.cleanupIfNeeded(PULSE_CLEANUP_THRESHOLD); } -private: PRIVATE_FUNCTION_WITH_LOCALS(ValidateDigits) { output.isValid = true; @@ -863,7 +1361,7 @@ struct PULSE : public ContractBase return; } - locals.roundRevenue = static_cast(smul(state.ticketPrice, state.ticketCounter)); + locals.roundRevenue = smul(state.ticketPrice, state.ticketCounter); locals.devAmount = div(smul(locals.roundRevenue, static_cast(state.devPercent)), 100LL); locals.burnAmount = div(smul(locals.roundRevenue, static_cast(state.burnPercent)), 100LL); locals.shareholdersAmount = div(smul(locals.roundRevenue, static_cast(state.shareholdersPercent)), 100LL); @@ -871,8 +1369,7 @@ struct PULSE : public ContractBase if (locals.devAmount > 0) { - qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, static_cast(locals.devAmount), - state.teamAddress); + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, locals.devAmount, state.teamAddress); } if (locals.shareholdersAmount > 0) { @@ -899,8 +1396,7 @@ struct PULSE : public ContractBase } if (locals.burnAmount > 0) { - qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, static_cast(locals.burnAmount), - NULL_ID); + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, locals.burnAmount, NULL_ID); } if (locals.qheartAmount > 0) { @@ -925,7 +1421,6 @@ struct PULSE : public ContractBase locals.totalPrize += locals.prize; } - locals.availableBalance = locals.balance; for (locals.i = 0; locals.i < state.ticketCounter; ++locals.i) { locals.ticket = state.tickets.get(locals.i); @@ -977,6 +1472,99 @@ struct PULSE : public ContractBase state.winners.set(locals.insertIdx, locals.winnerInfo); } + PRIVATE_PROCEDURE_WITH_LOCALS(PrepareRandomTickets) + { + if (input.count == 0) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + output.count = 0; + return; + } + + if (!isSellingOpen(state)) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_SELLING_CLOSED); + output.count = 0; + return; + } + + locals.slotsLeft = getSlotsLeft(state); + output.count = min(input.count, static_cast(locals.slotsLeft)); + if (output.count == 0) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_ALL_SOLD_OUT); + return; + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + PRIVATE_PROCEDURE_WITH_LOCALS(ChargeTicketsFromPlayer) + { + if (input.count == 0 || input.player == id::zero()) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + locals.totalPrice = smul(static_cast(input.count), state.ticketPrice); + locals.userBalance = + qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, input.player, input.player, SELF_INDEX, SELF_INDEX); + if (locals.userBalance < static_cast(locals.totalPrice)) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); + return; + } + + locals.transferResult = qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, input.player, input.player, + static_cast(locals.totalPrice), SELF); + if (locals.transferResult < 0) + { + output.returnCode = toReturnCode(EReturnCode::TRANSFER_TO_PULSE_FAILED); + return; + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + PRIVATE_PROCEDURE_WITH_LOCALS(AllocateRandomTickets) + { + if (input.count == 0 || input.player == id::zero()) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + if (!isSellingOpen(state)) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_SELLING_CLOSED); + return; + } + + locals.slotsLeft = getSlotsLeft(state); + if (locals.slotsLeft < input.count) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_ALL_SOLD_OUT); + return; + } + + locals.mixedSpectrumValue = qpi.getPrevSpectrumDigest(); + locals.randomSeed = qpi.K12(locals.mixedSpectrumValue).u64._0; + for (locals.i = 0; locals.i < input.count; ++locals.i) + { + deriveOne(locals.randomSeed, locals.i, locals.tempSeed); + locals.randomInput.seed = locals.tempSeed; + CALL(GetRandomDigits, locals.randomInput, locals.randomOutput); + + locals.ticket.player = input.player; + locals.ticket.digits = locals.randomOutput.digits; + state.tickets.set(state.ticketCounter, locals.ticket); + state.ticketCounter = min(static_cast(state.ticketCounter) + 1ULL, state.tickets.capacity()); + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + public: // Encodes YYYY/MM/DD into a compact sortable date stamp. static void makeDateStamp(uint8 year, uint8 month, uint8 day, uint32& res) { res = static_cast(year << 9 | month << 5 | day); } @@ -997,15 +1585,24 @@ struct PULSE : public ContractBase outValue ^= outValue >> 31; } + static sint64 getSlotsLeft(const PULSE& state) + { + return state.ticketCounter < state.tickets.capacity() ? (state.tickets.capacity() - state.ticketCounter) : 0; + } + protected: // Ring buffer of recent winners; index is winnersCounter % capacity. Array winners; // Tickets for the current round; valid range is [0, ticketCounter). Array tickets; + // Auto-buy participants keyed by user id. + HashMap autoParticipants; // Last settled winning digits; undefined before the first draw. Array lastWinningDigits; - uint64 ticketCounter; - uint64 ticketPrice; + sint64 ticketCounter; + sint64 ticketPrice; + // Per-user auto-purchase limits; 0 means unlimited. + sint16 maxAutoTicketsPerUser; // Contract balance above this cap is swept to the QHeart wallet after settlement. uint64 qheartHoldLimit; // Date stamp of the most recent draw; PULSE_DEFAULT_INIT_TIME is a bootstrap sentinel. From 3e54880221a6c3bf08affbf10c3d8255e8ef0416 Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 21 Jan 2026 21:57:56 +0300 Subject: [PATCH 48/77] Fixes: availableBalance, save data in DepositAutoParticipation --- src/contracts/Pulse.h | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 74137ad4c..8098e4507 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -580,9 +580,9 @@ struct PULSE : public ContractBase sint64 qheartAmount; sint64 balanceSigned; uint64 balance; + uint64 availableBalance; uint64 prize; uint64 totalPrize; - uint64 availableBalance; uint64 reservedBalance; m256i mixedSpectrumValue; uint64 randomSeed; @@ -979,7 +979,7 @@ struct PULSE : public ContractBase return; } - if (input.amount == 0 || input.desiredTickets == 0) + if (input.amount <= 0 || input.desiredTickets <= 0) { output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); return; @@ -1040,6 +1040,7 @@ struct PULSE : public ContractBase locals.entry.desiredTickets = input.desiredTickets; } + state.autoParticipants.set(qpi.invocator(), locals.entry); output.returnCode = toReturnCode(EReturnCode::SUCCESS); } @@ -1411,7 +1412,7 @@ struct PULSE : public ContractBase state.lastWinningDigits = locals.randomOutput.digits; locals.balanceSigned = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, SELF_INDEX, SELF_INDEX); - locals.balance = (locals.balanceSigned > 0) ? static_cast(locals.balanceSigned) : 0; + locals.balance = max(locals.balanceSigned, 0LL); locals.totalPrize = 0; for (locals.i = 0; locals.i < state.ticketCounter; ++locals.i) @@ -1421,6 +1422,7 @@ struct PULSE : public ContractBase locals.totalPrize += locals.prize; } + locals.availableBalance = locals.balance; for (locals.i = 0; locals.i < state.ticketCounter; ++locals.i) { locals.ticket = state.tickets.get(locals.i); From 870cd352494bbab82b7845b92f1d0aba3048e8d1 Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 21 Jan 2026 23:38:02 +0300 Subject: [PATCH 49/77] Adds tests --- test/contract_pulse.cpp | 886 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 819 insertions(+), 67 deletions(-) diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index ab18b146b..83b8ea5da 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -16,6 +16,10 @@ constexpr uint16 PULSE_PROCEDURE_SET_DRAW_HOUR = 4; constexpr uint16 PULSE_PROCEDURE_SET_FEES = 5; constexpr uint16 PULSE_PROCEDURE_SET_QHEART_HOLD_LIMIT = 6; constexpr uint16 PULSE_PROCEDURE_BUY_RANDOM_TICKETS = 7; +constexpr uint16 PULSE_PROCEDURE_DEPOSIT_AUTO_PARTICIPATION = 8; +constexpr uint16 PULSE_PROCEDURE_WITHDRAW_AUTO_PARTICIPATION = 9; +constexpr uint16 PULSE_PROCEDURE_SET_AUTO_CONFIG = 10; +constexpr uint16 PULSE_PROCEDURE_SET_AUTO_LIMITS = 11; constexpr uint16 PULSE_FUNCTION_GET_TICKET_PRICE = 1; constexpr uint16 PULSE_FUNCTION_GET_SCHEDULE = 2; @@ -26,6 +30,8 @@ constexpr uint16 PULSE_FUNCTION_GET_QHEART_WALLET = 6; constexpr uint16 PULSE_FUNCTION_GET_WINNING_DIGITS = 7; constexpr uint16 PULSE_FUNCTION_GET_BALANCE = 8; constexpr uint16 PULSE_FUNCTION_GET_WINNERS = 9; +constexpr uint16 PULSE_FUNCTION_GET_AUTO_PARTICIPATION = 10; +constexpr uint16 PULSE_FUNCTION_GET_AUTO_STATS = 11; namespace { @@ -87,11 +93,9 @@ class PULSEChecker : public PULSE void setTicketCounter(uint64 value) { ticketCounter = value; } void setTicketPriceInternal(uint64 value) { ticketPrice = value; } - void setQHeartHoldLimitInternal(uint64 value) { qheartHoldLimit = value; } void setLastDrawDateStamp(uint32 value) { lastDrawDateStamp = value; } void setScheduleInternal(uint8 value) { schedule = value; } void setDrawHourInternal(uint8 value) { drawHour = value; } - void setLastWinningDigits(const Array& digits) { lastWinningDigits = digits; } NextEpochData& nextEpochDataRef() { return nextEpochData; } @@ -126,19 +130,64 @@ class PULSEChecker : public PULSE return output; } - void callSettleRound(const QPI::QpiContextProcedureCall& qpi) + PrepareRandomTickets_output callPrepareRandomTickets(const QPI::QpiContextProcedureCall& qpi, uint16 count) { - SettleRound_input input{}; - SettleRound_output output{}; - std::aligned_storage_t localsStorage; - SettleRound_locals& locals = *reinterpret_cast(&localsStorage); - setMemory(locals, 0); + PrepareRandomTickets_input input{}; + PrepareRandomTickets_output output{}; + PrepareRandomTickets_locals locals{}; + input.count = count; + PrepareRandomTickets(qpi, *this, input, output, locals); + return output; + } - SettleRound(qpi, *this, input, output, locals); + ChargeTicketsFromPlayer_output callChargeTicketsFromPlayer(const QPI::QpiContextProcedureCall& qpi, const id& player, uint16 count) + { + ChargeTicketsFromPlayer_input input{}; + ChargeTicketsFromPlayer_output output{}; + ChargeTicketsFromPlayer_locals locals{}; + input.player = player; + input.count = count; + ChargeTicketsFromPlayer(qpi, *this, input, output, locals); + return output; + } + + AllocateRandomTickets_output callAllocateRandomTickets(const QPI::QpiContextProcedureCall& qpi, const id& player, uint16 count) + { + AllocateRandomTickets_input input{}; + AllocateRandomTickets_output output{}; + AllocateRandomTickets_locals locals{}; + input.player = player; + input.count = count; + AllocateRandomTickets(qpi, *this, input, output, locals); + return output; + } + + void callProcessAutoTickets(const QPI::QpiContextProcedureCall& qpi) + { + ProcessAutoTickets_input input{}; + ProcessAutoTickets_output output{}; + ProcessAutoTickets_locals locals{}; + ProcessAutoTickets(qpi, *this, input, output, locals); + } + + GetAutoParticipation_output callGetAutoParticipation(const QPI::QpiContextFunctionCall& qpi) const + { + GetAutoParticipation_input input{}; + GetAutoParticipation_output output{}; + GetAutoParticipation_locals locals{}; + GetAutoParticipation(qpi, *this, input, output, locals); + return output; + } + + void setAutoParticipant(const id& player, sint64 deposit, uint16 desiredTickets) + { + AutoParticipant entry{}; + entry.player = player; + entry.deposit = deposit; + entry.desiredTickets = desiredTickets; + autoParticipants.set(player, entry); } - void callClearStateOnEndDraw() { clearStateOnEndDraw(*this); } - void callClearStateOnEndEpoch() { clearStateOnEndEpoch(*this); } uint64 callGetLeftAlignedReward(uint8 matches) const { return getLeftAlignedReward(*this, matches); } uint64 callGetAnyPositionReward(uint8 matches) const { return getAnyPositionReward(*this, matches); } uint64 callComputePrize(const Array& winning, const Array& digits) @@ -237,6 +286,20 @@ class ContractTestingPulse : protected ContractTesting return output; } + PULSE::GetAutoParticipation_output getAutoParticipation(const id& user) + { + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, user, 0); + return state()->callGetAutoParticipation(qpi); + } + + PULSE::GetAutoStats_output getAutoStats() + { + PULSE::GetAutoStats_input input{}; + PULSE::GetAutoStats_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_AUTO_STATS, input, output); + return output; + } + PULSE::BuyTicket_output buyTicket(const id& user, const Array& digits) { ensureUserEnergy(user); @@ -263,6 +326,60 @@ class ContractTestingPulse : protected ContractTesting return output; } + PULSE::DepositAutoParticipation_output depositAutoParticipation(const id& user, sint64 amount, sint16 desiredTickets, bool buyNow) + { + ensureUserEnergy(user); + PULSE::DepositAutoParticipation_input input{}; + input.amount = amount; + input.desiredTickets = desiredTickets; + input.buyNow = buyNow; + PULSE::DepositAutoParticipation_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_DEPOSIT_AUTO_PARTICIPATION, input, output, user, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::WithdrawAutoParticipation_output withdrawAutoParticipation(const id& user, sint64 amount) + { + ensureUserEnergy(user); + PULSE::WithdrawAutoParticipation_input input{}; + input.amount = amount; + PULSE::WithdrawAutoParticipation_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_WITHDRAW_AUTO_PARTICIPATION, input, output, user, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::SetAutoConfig_output setAutoConfig(const id& user, sint16 desiredTickets) + { + ensureUserEnergy(user); + PULSE::SetAutoConfig_input input{}; + input.desiredTickets = desiredTickets; + PULSE::SetAutoConfig_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_AUTO_CONFIG, input, output, user, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::SetAutoLimits_output setAutoLimits(const id& invocator, uint16 maxTicketsPerUser) + { + ensureUserEnergy(invocator); + PULSE::SetAutoLimits_input input{}; + input.maxTicketsPerUser = maxTicketsPerUser; + PULSE::SetAutoLimits_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_AUTO_LIMITS, input, output, invocator, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + PULSE::SetPrice_output setPrice(const id& invocator, uint64 newPrice) { ensureUserEnergy(invocator); @@ -367,8 +484,8 @@ class ContractTestingPulse : protected ContractTesting static constexpr char name[7] = {'Q', 'H', 'E', 'A', 'R', 'T', 0}; static constexpr char unit[7] = {}; QHeartIssuance info{}; - const long long issued = issueAsset(PULSE_QHEART_ISSUER, name, 0, unit, totalShares, PULSE_CONTRACT_INDEX, &info.issuanceIndex, - &info.ownershipIndex, &info.possessionIndex); + const sint64 issued = issueAsset(PULSE_QHEART_ISSUER, name, 0, unit, totalShares, PULSE_CONTRACT_INDEX, &info.issuanceIndex, + &info.ownershipIndex, &info.possessionIndex); EXPECT_EQ(issued, totalShares); return info; } @@ -553,23 +670,6 @@ TEST(ContractPulse_Private, NextEpochDataApplyUpdatesState) EXPECT_EQ(ctl.state()->getQHeartHoldLimitInternal(), 999u); } -// Reject invalid digits early to keep prize logic safe. -TEST(ContractPulse_Private, ValidateDigitsOutOfRange) -{ - ContractTestingPulse ctl; - QpiContextUserFunctionCall qpi(PULSE_CONTRACT_INDEX); - primeQpiFunctionContext(qpi); - - const Array& ok = makePlayerDigits(0, 1, 2, 3, 4, 5); - EXPECT_TRUE(ctl.state()->callValidateDigits(qpi, ok).isValid); - - const Array& dup = makePlayerDigits(0, 1, 2, 3, 4, 4); - EXPECT_TRUE(ctl.state()->callValidateDigits(qpi, dup).isValid); - - const Array& outOfRange = makePlayerDigits(0, 1, 2, 3, 4, 10); - EXPECT_FALSE(ctl.state()->callValidateDigits(qpi, outOfRange).isValid); -} - // Keep RNG output deterministic for auditability. TEST(ContractPulse_Private, GetRandomDigitsDeterministic) { @@ -590,57 +690,161 @@ TEST(ContractPulse_Private, GetRandomDigitsDeterministic) } } -// Ensure draw/epoch cleanup fully clears round state. -TEST(ContractPulse_Private, ClearStateHelpersResetTicketData) +// Validate PrepareRandomTickets error cases. +TEST(ContractPulse_Private, PrepareRandomTicketsRejectsInvalidInputs) { ContractTestingPulse ctl; - ctl.state()->setTicketCounter(2); - ctl.state()->setLastDrawDateStamp(42); - ctl.state()->callClearStateOnEndDraw(); - EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); - EXPECT_EQ(ctl.state()->getLastDrawDateStamp(), 42u); - ctl.state()->setLastDrawDateStamp(99); - ctl.state()->callClearStateOnEndEpoch(); + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpi); + + PULSE::PrepareRandomTickets_output out = ctl.state()->callPrepareRandomTickets(qpi, 0); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + ctl.endEpoch(); + out = ctl.state()->callPrepareRandomTickets(qpi, 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_SELLING_CLOSED)); +} + +// Guard sold-out behavior in PrepareRandomTickets. +TEST(ContractPulse_Private, PrepareRandomTicketsRejectsWhenSoldOut) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS); + + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpi); + const PULSE::PrepareRandomTickets_output out = ctl.state()->callPrepareRandomTickets(qpi, 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_ALL_SOLD_OUT)); +} + +// Validate ChargeTicketsFromPlayer rejects invalid inputs and insufficient balance. +TEST(ContractPulse_Private, ChargeTicketsFromPlayerRejectsInvalidOrInsufficient) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.transferQHeart(issuance, user, ticketPrice); + + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpi); + + PULSE::ChargeTicketsFromPlayer_output out = ctl.state()->callChargeTicketsFromPlayer(qpi, user, 0); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + out = ctl.state()->callChargeTicketsFromPlayer(qpi, id::zero(), 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + out = ctl.state()->callChargeTicketsFromPlayer(qpi, user, 2); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_INVALID_PRICE)); +} + +// Validate AllocateRandomTickets rejects invalid inputs and closed/sold-out states. +TEST(ContractPulse_Private, AllocateRandomTicketsRejectsInvalidOrClosed) +{ + ContractTestingPulse ctl; + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpi); + + PULSE::AllocateRandomTickets_output out = ctl.state()->callAllocateRandomTickets(qpi, id::zero(), 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + out = ctl.state()->callAllocateRandomTickets(qpi, id::randomValue(), 0); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + ctl.endEpoch(); + out = ctl.state()->callAllocateRandomTickets(qpi, id::randomValue(), 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_SELLING_CLOSED)); +} + +// Guard AllocateRandomTickets sold-out path. +TEST(ContractPulse_Private, AllocateRandomTicketsRejectsWhenSoldOut) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS); + + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpi); + const PULSE::AllocateRandomTickets_output out = ctl.state()->callAllocateRandomTickets(qpi, id::randomValue(), 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_ALL_SOLD_OUT)); +} + +// Ensure ProcessAutoTickets skips when selling is closed. +TEST(ContractPulse_Private, ProcessAutoTicketsSkipsWhenSellingClosed) +{ + ContractTestingPulse ctl; + const id user = id::randomValue(); + ctl.state()->setAutoParticipant(user, 1, 1); + ctl.state()->forceSelling(false); + + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpi); + ctl.state()->callProcessAutoTickets(qpi); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); - EXPECT_EQ(ctl.state()->getLastDrawDateStamp(), 0u); } -// Validate settlement updates winners and pays prizes. -TEST(ContractPulse_Private, SettleRoundUpdatesWinningDigitsAndPaysPrize) +// Ensure ProcessAutoTickets skips when no slots are left. +TEST(ContractPulse_Private, ProcessAutoTicketsSkipsWhenSoldOut) { ContractTestingPulse ctl; - ctl.state()->setTicketPriceInternal(2); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.state()->setAutoParticipant(user, static_cast(ticketPrice), 1); - const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000000); - ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); + ctl.state()->forceSelling(true); + ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS); - const m256i digest = m256i::randomValue(); - etalonTick.prevSpectrumDigest = digest; + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpi); + ctl.state()->callProcessAutoTickets(qpi); - m256i hashResult; - KangarooTwelve(reinterpret_cast(&digest), sizeof(m256i), reinterpret_cast(&hashResult), sizeof(m256i)); - const uint64 seed = hashResult.m256i_u64[0]; + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getTicketCounter(), static_cast(PULSE_MAX_NUMBER_OF_PLAYERS)); +} - QpiContextUserFunctionCall qpiFunc(PULSE_CONTRACT_INDEX); - primeQpiFunctionContext(qpiFunc); - const Array& winning = ctl.state()->callGetRandomDigits(qpiFunc, seed).digits; +// Remove auto participants that cannot afford a ticket. +TEST(ContractPulse_Private, ProcessAutoTicketsRemovesUnaffordableParticipant) +{ + ContractTestingPulse ctl; + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.state()->setAutoParticipant(user, static_cast(ticketPrice - 1), 1); - const id player = id::randomValue(); - const Array& ticketDigits = - makePlayerDigits(winning.get(0), winning.get(1), winning.get(2), winning.get(3), winning.get(4), winning.get(5)); + ctl.state()->forceSelling(true); + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpi); + ctl.state()->callProcessAutoTickets(qpi); - ctl.state()->setTicketDirect(0, player, ticketDigits); - ctl.state()->setTicketCounter(1); - ctl.transferQHeart(issuance, ctl.pulseSelf(), 100000); + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} - const uint64 playerBalanceBefore = ctl.qheartBalanceOf(player); - QpiContextUserProcedureCall qpiProc(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); - primeQpiProcedureContext(qpiProc); - ctl.state()->callSettleRound(qpiProc); +// Keep auto participant when desired ticket count is zero. +TEST(ContractPulse_Private, ProcessAutoTicketsSkipsZeroDesiredTickets) +{ + ContractTestingPulse ctl; + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.state()->setAutoParticipant(user, static_cast(ticketPrice * 2), 0); - const uint64 playerBalanceAfter = ctl.qheartBalanceOf(player); - EXPECT_EQ(playerBalanceAfter - playerBalanceBefore, ctl.state()->callGetLeftAlignedReward(6)); - expectWinningDigitsInRange(ctl.state()->getLastWinningDigits()); + ctl.state()->forceSelling(true); + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpi); + ctl.state()->callProcessAutoTickets(qpi); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(entry.deposit, ticketPrice * 2); + EXPECT_EQ(static_cast(entry.desiredTickets), 0u); + EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); } // ============================================================================ @@ -746,6 +950,31 @@ TEST(ContractPulse_Public, SetQHeartHoldLimitAppliesOnEndEpoch) EXPECT_EQ(ctl.state()->getQHeartHoldLimitInternal(), 1234u); } +// Ensure getters report newly applied config values after epoch end. +TEST(ContractPulse_Public, GettersReflectAppliedChanges) +{ + ContractTestingPulse ctl; + EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, 555).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setSchedule(PULSE_QHEART_ISSUER, 0x7F).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setDrawHour(PULSE_QHEART_ISSUER, 9).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 11, 22, 33, 4).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setQHeartHoldLimit(PULSE_QHEART_ISSUER, 4321).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + ctl.endEpoch(); + + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, 555u); + EXPECT_EQ(ctl.getSchedule().schedule, 0x7Fu); + EXPECT_EQ(ctl.getDrawHour().drawHour, 9u); + EXPECT_EQ(ctl.getQHeartHoldLimit().qheartHoldLimit, 4321u); + + const PULSE::GetFees_output fees = ctl.getFees(); + EXPECT_EQ(fees.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(fees.devPercent, 11u); + EXPECT_EQ(fees.burnPercent, 22u); + EXPECT_EQ(fees.shareholdersPercent, 33u); + EXPECT_EQ(fees.qheartPercent, 4u); +} + // Prevent ticket purchases outside the selling window. TEST(ContractPulse_Public, BuyTicketWhenSellingClosedFails) { @@ -844,7 +1073,7 @@ TEST(ContractPulse_Public, BuyRandomTicketsFailsWhenSoldOut) ContractTestingPulse ctl; ctl.setDateTime(2025, 1, 10, 12); ctl.beginEpoch(); - ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS - 1); + ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS); const id user = id::randomValue(); const PULSE::BuyRandomTickets_output out = ctl.buyRandomTickets(user, 2); @@ -910,6 +1139,479 @@ TEST(ContractPulse_Public, BuyRandomTicketsSucceedsAndMovesQHeart) } } +// Validate deterministic random tickets for a fixed spectrum digest. +TEST(ContractPulse_Public, BuyRandomTicketsDeterministicWithFixedDigest) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.transferQHeart(issuance, user, ticketPrice); + + const m256i digest(0xABCDEF01ULL, 0x12345678ULL, 0xCAFEBABEULL, 0x0BADF00DULL); + etalonTick.prevSpectrumDigest = digest; + + m256i hashResult; + KangarooTwelve(reinterpret_cast(&digest), sizeof(m256i), reinterpret_cast(&hashResult), sizeof(m256i)); + const uint64 randomSeed = hashResult.m256i_u64[0]; + uint64 tempSeed = 0; + PULSEChecker::deriveOne(randomSeed, 0, tempSeed); + + QpiContextUserFunctionCall qpi(PULSE_CONTRACT_INDEX); + primeQpiFunctionContext(qpi); + const Array& expected = ctl.state()->callGetRandomDigits(qpi, tempSeed).digits; + + const PULSE::BuyRandomTickets_output out = ctl.buyRandomTickets(user, 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const PULSE::Ticket ticket = ctl.state()->getTicket(0); + for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + EXPECT_EQ(ticket.digits.get(i), expected.get(i)); + } +} + +// Clamp random ticket purchases to remaining capacity. +TEST(ContractPulse_Public, BuyRandomTicketsClampsToSlotsLeft) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.transferQHeart(issuance, user, ticketPrice * 2); + + ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS - 2); + etalonTick.prevSpectrumDigest = m256i(0xAAAAULL, 0xBBBBULL, 0xCCCCULL, 0xDDDDULL); + + const uint64 userBefore = ctl.qheartBalanceOf(user); + const PULSE::BuyRandomTickets_output out = ctl.buyRandomTickets(user, 5); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getTicketCounter(), static_cast(PULSE_MAX_NUMBER_OF_PLAYERS)); + EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore - (ticketPrice * 2)); +} + +// Reject non-positive auto-participation inputs. +TEST(ContractPulse_Public, DepositAutoParticipationRejectsInvalidValues) +{ + ContractTestingPulse ctl; + const id user = id::randomValue(); + + EXPECT_EQ(ctl.depositAutoParticipation(user, 0, 1, false).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + EXPECT_EQ(ctl.depositAutoParticipation(user, 1, 0, false).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + EXPECT_EQ(ctl.depositAutoParticipation(user, 1, -1, false).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// Clamp desired ticket counts to configured limits and store the deposit. +TEST(ContractPulse_Public, DepositAutoParticipationClampsDesiredTicketsAndStoresDeposit) +{ + ContractTestingPulse ctl; + EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, 2).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amount = static_cast(ticketPrice * 2); + ctl.transferQHeart(issuance, user, amount); + + const PULSE::DepositAutoParticipation_output out = ctl.depositAutoParticipation(user, amount, 5, false); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(entry.deposit, static_cast(amount)); + EXPECT_EQ(static_cast(entry.desiredTickets), 2u); +} + +// Clamp deposit amount to available user balance. +TEST(ContractPulse_Public, DepositAutoParticipationClampsAmountToBalance) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 balance = static_cast(ticketPrice * 2); + ctl.transferQHeart(issuance, user, static_cast(balance)); + + const PULSE::DepositAutoParticipation_output out = ctl.depositAutoParticipation(user, balance + ticketPrice, 1, false); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(entry.deposit, static_cast(balance)); +} + +// Subsequent deposits should add to the balance and update desired ticket count. +TEST(ContractPulse_Public, DepositAutoParticipationAccumulatesAndUpdatesDesiredTickets) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amountFirst = static_cast(ticketPrice * 2); + const sint64 amountSecond = static_cast(ticketPrice * 3); + const sint64 totalAmount = amountFirst + amountSecond; + ctl.transferQHeart(issuance, user, static_cast(totalAmount)); + + EXPECT_EQ(ctl.depositAutoParticipation(user, amountFirst, 1, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.depositAutoParticipation(user, amountSecond, 2, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(entry.deposit, static_cast(totalAmount)); + EXPECT_EQ(static_cast(entry.desiredTickets), 2u); +} + +// Enforce minimum balance for desired auto-purchases. +TEST(ContractPulse_Public, DepositAutoParticipationRejectsInsufficientAmount) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.transferQHeart(issuance, user, ticketPrice); + + const PULSE::DepositAutoParticipation_output out = ctl.depositAutoParticipation(user, ticketPrice, 2, false); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_INVALID_PRICE)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// Reject new participants once the auto list is at capacity. +TEST(ContractPulse_Public, DepositAutoParticipationRejectsWhenAutoParticipantsFull) +{ + ContractTestingPulse ctl; + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amount = static_cast(ticketPrice); + const sint64 totalShares = static_cast(ticketPrice) * static_cast(PULSE_MAX_NUMBER_OF_AUTO_PARTICIPANTS + 1); + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(totalShares); + + for (uint32 i = 0; i < PULSE_MAX_NUMBER_OF_AUTO_PARTICIPANTS; ++i) + { + const id user = id::randomValue(); + ctl.transferQHeart(issuance, user, ticketPrice); + EXPECT_EQ(ctl.depositAutoParticipation(user, amount, 1, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + } + + const id extraUser = id::randomValue(); + ctl.transferQHeart(issuance, extraUser, ticketPrice); + const PULSE::DepositAutoParticipation_output out = ctl.depositAutoParticipation(extraUser, amount, 1, false); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::AUTO_PARTICIPANTS_FULL)); +} + +// When buy-now spends the entire deposit, no auto-participation entry is created. +TEST(ContractPulse_Public, DepositAutoParticipationBuyNowConsumesAllAndSkipsDeposit) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + static constexpr uint16 desiredTickets = 2; + const uint64 totalPrice = ticketPrice * desiredTickets; + ctl.transferQHeart(issuance, user, totalPrice); + etalonTick.prevSpectrumDigest = m256i(0x1111ULL, 0x2222ULL, 0x3333ULL, 0x4444ULL); + + const uint64 userBefore = ctl.qheartBalanceOf(user); + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + const PULSE::DepositAutoParticipation_output out = ctl.depositAutoParticipation(user, totalPrice, desiredTickets, true); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getTicketCounter(), static_cast(desiredTickets)); + EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore - totalPrice); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore + totalPrice); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// If buy-now leaves a remainder, keep it as a deposit entry. +TEST(ContractPulse_Public, DepositAutoParticipationBuyNowStoresRemainder) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + static constexpr uint16 desiredTickets = 2; + const uint64 totalPrice = ticketPrice * desiredTickets; + const sint64 amount = static_cast(totalPrice + ticketPrice); + ctl.transferQHeart(issuance, user, static_cast(amount)); + etalonTick.prevSpectrumDigest = m256i(0xAAAAULL, 0xBBBBULL, 0xCCCCULL, 0xDDDDULL); + + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + const PULSE::DepositAutoParticipation_output out = ctl.depositAutoParticipation(user, amount, desiredTickets, true); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getTicketCounter(), static_cast(desiredTickets)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(entry.deposit, ticketPrice); + EXPECT_EQ(static_cast(entry.desiredTickets), static_cast(desiredTickets)); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore + static_cast(amount)); +} + +// If selling is closed, buy-now should skip buying and keep the deposit. +TEST(ContractPulse_Public, DepositAutoParticipationBuyNowStoresDepositWhenSellingClosed) +{ + ContractTestingPulse ctl; + ctl.endEpoch(); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + static constexpr uint16 desiredTickets = 2; + const sint64 amount = static_cast(ticketPrice * desiredTickets); + ctl.transferQHeart(issuance, user, static_cast(amount)); + + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + const PULSE::DepositAutoParticipation_output out = ctl.depositAutoParticipation(user, amount, desiredTickets, true); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(entry.deposit, static_cast(amount)); + EXPECT_EQ(static_cast(entry.desiredTickets), static_cast(desiredTickets)); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore + static_cast(amount)); +} + +// If buy-now cannot allocate tickets, the deposit is not recorded. +TEST(ContractPulse_Public, DepositAutoParticipationBuyNowFailsWhenSoldOut) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + static constexpr uint16 desiredTickets = 1; + const uint64 totalPrice = ticketPrice * desiredTickets; + ctl.transferQHeart(issuance, user, totalPrice); + etalonTick.prevSpectrumDigest = m256i(0xAAAAULL, 0xBBBBULL, 0xCCCCULL, 0xDDDDULL); + + const uint64 userBefore = ctl.qheartBalanceOf(user); + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + const PULSE::DepositAutoParticipation_output out = ctl.depositAutoParticipation(user, totalPrice, desiredTickets, true); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_ALL_SOLD_OUT)); + EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// Reject withdrawals for unknown auto participants. +TEST(ContractPulse_Public, WithdrawAutoParticipationRejectsMissingEntry) +{ + ContractTestingPulse ctl; + const id user = id::randomValue(); + const PULSE::WithdrawAutoParticipation_output out = ctl.withdrawAutoParticipation(user, 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// Full withdrawal removes the entry and refunds the deposit. +TEST(ContractPulse_Public, WithdrawAutoParticipationFullRemovesEntry) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amount = static_cast(ticketPrice * 2); + ctl.transferQHeart(issuance, user, amount); + + EXPECT_EQ(ctl.depositAutoParticipation(user, amount, 2, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const uint64 userBefore = ctl.qheartBalanceOf(user); + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + const PULSE::WithdrawAutoParticipation_output out = ctl.withdrawAutoParticipation(user, 0); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore + static_cast(amount)); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore - static_cast(amount)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// Partial withdrawal keeps the entry with the remaining deposit. +TEST(ContractPulse_Public, WithdrawAutoParticipationPartialKeepsEntry) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amount = static_cast(ticketPrice * 3); + ctl.transferQHeart(issuance, user, amount); + + EXPECT_EQ(ctl.depositAutoParticipation(user, amount, 3, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + const PULSE::WithdrawAutoParticipation_output out = ctl.withdrawAutoParticipation(user, ticketPrice); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(entry.deposit, static_cast(amount - ticketPrice)); +} + +// Withdraws more than the deposit should return the full amount and remove the entry. +TEST(ContractPulse_Public, WithdrawAutoParticipationOverdrawsToFullWithdrawal) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amount = static_cast(ticketPrice * 2); + ctl.transferQHeart(issuance, user, amount); + + EXPECT_EQ(ctl.depositAutoParticipation(user, amount, 2, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const uint64 userBefore = ctl.qheartBalanceOf(user); + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + const PULSE::WithdrawAutoParticipation_output out = ctl.withdrawAutoParticipation(user, static_cast(ticketPrice * 5)); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore + static_cast(amount)); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore - static_cast(amount)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// Surface transfer failures when the contract lacks sufficient QHeart. +TEST(ContractPulse_Public, WithdrawAutoParticipationFailsWhenTransferFails) +{ + ContractTestingPulse ctl; + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.state()->setAutoParticipant(user, static_cast(ticketPrice), 1); + + const PULSE::WithdrawAutoParticipation_output out = ctl.withdrawAutoParticipation(user, ticketPrice); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TRANSFER_FROM_PULSE_FAILED)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(entry.deposit, ticketPrice); +} + +// Validate SetAutoConfig input and clamp to limits. +TEST(ContractPulse_Public, SetAutoConfigValidatesAndClamps) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amount = static_cast(ticketPrice * 3); + ctl.transferQHeart(issuance, user, amount); + + EXPECT_EQ(ctl.depositAutoParticipation(user, amount, 3, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setAutoConfig(user, -2).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, 2).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setAutoConfig(user, -1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(static_cast(entry.desiredTickets), 3u); + + EXPECT_EQ(ctl.setAutoConfig(user, 5).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + entry = ctl.getAutoParticipation(user); + EXPECT_EQ(static_cast(entry.desiredTickets), 2u); +} + +// Allow disabling auto tickets with desiredTickets = 0. +TEST(ContractPulse_Public, SetAutoConfigDisablesDesiredTickets) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amount = static_cast(ticketPrice * 2); + ctl.transferQHeart(issuance, user, amount); + + EXPECT_EQ(ctl.depositAutoParticipation(user, amount, 2, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setAutoConfig(user, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(static_cast(entry.desiredTickets), 0u); +} + +// Remove entry when both deposit and desired tickets are zero. +TEST(ContractPulse_Public, SetAutoConfigRemovesEmptyEntry) +{ + ContractTestingPulse ctl; + const id user = id::randomValue(); + ctl.state()->setAutoParticipant(user, 0, 1); + + EXPECT_EQ(ctl.setAutoConfig(user, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// Reject config updates for users without auto participation. +TEST(ContractPulse_Public, SetAutoConfigRejectsMissingEntry) +{ + ContractTestingPulse ctl; + const id user = id::randomValue(); + const PULSE::SetAutoConfig_output out = ctl.setAutoConfig(user, 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// Enforce access and range checks on auto limits. +TEST(ContractPulse_Public, SetAutoLimitsGuardsAccessAndValidates) +{ + ContractTestingPulse ctl; + EXPECT_EQ(ctl.setAutoLimits(id::randomValue(), 10).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, PULSE_MAX_NUMBER_OF_PLAYERS + 1).returnCode, + static_cast(PULSE::EReturnCode::INVALID_VALUE)); + EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, 5).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const PULSE::GetAutoStats_output stats = ctl.getAutoStats(); + EXPECT_EQ(stats.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(static_cast(stats.maxAutoTicketsPerUser), 5u); +} + +// Allow disabling auto ticket limits by setting them to zero. +TEST(ContractPulse_Public, SetAutoLimitsAllowsDisabling) +{ + ContractTestingPulse ctl; + EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, 3).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const PULSE::GetAutoStats_output stats = ctl.getAutoStats(); + EXPECT_EQ(stats.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(static_cast(stats.maxAutoTicketsPerUser), 0u); +} + +// Report auto participation counts through the public stats API. +TEST(ContractPulse_Public, GetAutoStatsReportsParticipantCount) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + + const id userA = id::randomValue(); + const id userB = id::randomValue(); + ctl.transferQHeart(issuance, userA, ticketPrice); + ctl.transferQHeart(issuance, userB, ticketPrice); + + EXPECT_EQ(ctl.depositAutoParticipation(userA, ticketPrice, 1, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.depositAutoParticipation(userB, ticketPrice, 1, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const PULSE::GetAutoStats_output stats = ctl.getAutoStats(); + EXPECT_EQ(stats.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(static_cast(stats.autoParticipantsCounter), 2u); +} + // Ensure balance getter reflects actual QHeart wallet holdings. TEST(ContractPulse_Public, GetBalanceReportsQHeartWalletBalance) { @@ -984,6 +1686,50 @@ TEST(ContractPulse_System, BeginEpochRestoresDefaultsAndOpensSelling) EXPECT_TRUE(ctl.state()->isSelling()); } +// BeginEpoch should auto-buy tickets from stored deposits. +TEST(ContractPulse_System, BeginEpochProcessesAutoParticipants) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amount = static_cast(ticketPrice * 2); + ctl.transferQHeart(issuance, user, amount); + + EXPECT_EQ(ctl.depositAutoParticipation(user, amount, 2, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + etalonTick.prevSpectrumDigest = m256i(0xDEADULL, 0xBEEFULL, 0xFADEULL, 0xCAFEULL); + + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + EXPECT_EQ(ctl.state()->getTicketCounter(), 2u); + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// Auto-buy should leave remaining deposit when it is larger than the ticket cost. +TEST(ContractPulse_System, BeginEpochAutoParticipationLeavesRemainingDeposit) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amount = static_cast(ticketPrice * 3); + ctl.transferQHeart(issuance, user, static_cast(amount)); + + EXPECT_EQ(ctl.depositAutoParticipation(user, amount, 2, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + etalonTick.prevSpectrumDigest = m256i(0x1111ULL, 0x2222ULL, 0x3333ULL, 0x4444ULL); + + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + EXPECT_EQ(ctl.state()->getTicketCounter(), 2u); + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(entry.deposit, ticketPrice); + EXPECT_EQ(static_cast(entry.desiredTickets), 2u); +} + // Ensure epoch end applies pending config and clears state. TEST(ContractPulse_System, EndEpochAppliesPendingChangesAndClearsState) { @@ -1018,6 +1764,12 @@ TEST(ContractPulse_System, BeginTickRunsDrawOnScheduledDay) EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); EXPECT_TRUE(ctl.state()->isSelling()); expectWinningDigitsInRange(ctl.state()->getLastWinningDigits()); + + const PULSE::GetWinningDigits_output win = ctl.getWinningDigits(); + for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + EXPECT_EQ(win.digits.get(i), ctl.state()->getLastWinningDigits().get(i)); + } } // Exercise multi-round lifecycle across multiple players. From b4598594cf43fe1daa353d798a95b37df5fe5b55 Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 21 Jan 2026 23:43:39 +0300 Subject: [PATCH 50/77] Fixes for Contract Verify --- src/contracts/Pulse.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 8098e4507..7589f1057 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -13,7 +13,7 @@ using namespace QPI; constexpr uint16 PULSE_MAX_NUMBER_OF_PLAYERS = 1024; -constexpr uint16 PULSE_MAX_NUMBER_OF_AUTO_PARTICIPANTS = PULSE_MAX_NUMBER_OF_PLAYERS / 2; +constexpr uint16 PULSE_MAX_NUMBER_OF_AUTO_PARTICIPANTS = PULSE_MAX_NUMBER_OF_PLAYERS * 0.5; constexpr uint8 PULSE_PLAYER_DIGITS = 6; constexpr uint8 PULSE_PLAYER_DIGITS_ALIGNED = PULSE_PLAYER_DIGITS + 2; constexpr uint8 PULSE_WINNING_DIGITS = PULSE_PLAYER_DIGITS; From 1c27edb7e6a8bc35bcdb742b3d4124d610bfd34b Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 21 Jan 2026 23:48:44 +0300 Subject: [PATCH 51/77] Delete account if deposit is empty --- src/contracts/Pulse.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 7589f1057..6887ef604 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -1138,7 +1138,7 @@ struct PULSE : public ContractBase locals.entry.desiredTickets = locals.desiredValue; - if (locals.entry.deposit == 0 && locals.entry.desiredTickets == 0) + if (locals.entry.deposit == 0) { locals.removedIndex = state.autoParticipants.removeByKey(qpi.invocator()); state.autoParticipants.cleanupIfNeeded(PULSE_CLEANUP_THRESHOLD); From 960114154e6417b3bffb5d0d2fe0f7395caae6b2 Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 22 Jan 2026 00:01:35 +0300 Subject: [PATCH 52/77] Refactor SetAutoConfig to reject zero desiredTickets and update tests accordingly --- src/contracts/Pulse.h | 9 ++++++--- test/contract_pulse.cpp | 21 ++++----------------- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 6887ef604..392efd329 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -1098,7 +1098,7 @@ struct PULSE : public ContractBase } /// Sets auto-participation config for the invocator. - /// @param desiredTickets Signed: -1 ignore, 0 disable, >0 set new value. + /// @param desiredTickets Signed: -1 ignore, >0 set new value. /// @param minTicketsToBuy Signed: -1 ignore, 0 disable, >0 set new value. /// @return Status code describing the result. PUBLIC_PROCEDURE_WITH_LOCALS(SetAutoConfig) @@ -1120,7 +1120,10 @@ struct PULSE : public ContractBase return; } - input.desiredTickets = min(input.desiredTickets, state.maxAutoTicketsPerUser); + if (input.desiredTickets > 0 && state.maxAutoTicketsPerUser != 0) + { + input.desiredTickets = min(input.desiredTickets, static_cast(state.maxAutoTicketsPerUser)); + } state.autoParticipants.get(qpi.invocator(), locals.entry); @@ -1128,7 +1131,7 @@ struct PULSE : public ContractBase if (input.desiredTickets != -1) { - if (input.desiredTickets < 0) + if (input.desiredTickets <= 0) { output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); return; diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index 83b8ea5da..c056d4083 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -1526,8 +1526,8 @@ TEST(ContractPulse_Public, SetAutoConfigValidatesAndClamps) EXPECT_EQ(static_cast(entry.desiredTickets), 2u); } -// Allow disabling auto tickets with desiredTickets = 0. -TEST(ContractPulse_Public, SetAutoConfigDisablesDesiredTickets) +// Reject desiredTickets = 0 updates. +TEST(ContractPulse_Public, SetAutoConfigRejectsZeroDesiredTickets) { ContractTestingPulse ctl; const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); @@ -1537,24 +1537,11 @@ TEST(ContractPulse_Public, SetAutoConfigDisablesDesiredTickets) ctl.transferQHeart(issuance, user, amount); EXPECT_EQ(ctl.depositAutoParticipation(user, amount, 2, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.setAutoConfig(user, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setAutoConfig(user, 0).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(static_cast(entry.desiredTickets), 0u); -} - -// Remove entry when both deposit and desired tickets are zero. -TEST(ContractPulse_Public, SetAutoConfigRemovesEmptyEntry) -{ - ContractTestingPulse ctl; - const id user = id::randomValue(); - ctl.state()->setAutoParticipant(user, 0, 1); - - EXPECT_EQ(ctl.setAutoConfig(user, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - - const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); - EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + EXPECT_EQ(static_cast(entry.desiredTickets), 2u); } // Reject config updates for users without auto participation. From 178bcb0c7fb7f6ca00763047533f997bfdd45430 Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 28 Jan 2026 21:44:54 +0300 Subject: [PATCH 53/77] Fixes after review --- src/contracts/Pulse.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 392efd329..bc6f1d30b 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -13,14 +13,14 @@ using namespace QPI; constexpr uint16 PULSE_MAX_NUMBER_OF_PLAYERS = 1024; -constexpr uint16 PULSE_MAX_NUMBER_OF_AUTO_PARTICIPANTS = PULSE_MAX_NUMBER_OF_PLAYERS * 0.5; +constexpr uint16 PULSE_MAX_NUMBER_OF_AUTO_PARTICIPANTS = div(PULSE_MAX_NUMBER_OF_PLAYERS, 2); constexpr uint8 PULSE_PLAYER_DIGITS = 6; constexpr uint8 PULSE_PLAYER_DIGITS_ALIGNED = PULSE_PLAYER_DIGITS + 2; constexpr uint8 PULSE_WINNING_DIGITS = PULSE_PLAYER_DIGITS; constexpr uint8 PULSE_WINNING_DIGITS_ALIGNED = PULSE_PLAYER_DIGITS_ALIGNED; constexpr uint8 PULSE_MAX_DIGIT = 9; constexpr uint8 PULSE_MAX_DIGIT_ALIGNED = PULSE_MAX_DIGIT + 7; -constexpr uint64 PULSE_TICKET_PRICE_DEFAULT = 200000; +constexpr uint64 PULSE_TICKET_PRICE_DEFAULT = 200000ULL; constexpr uint16 PULSE_MAX_NUMBER_OF_WINNERS_IN_HISTORY = 1024; constexpr uint64 PULSE_QHEART_ASSET_NAME = 92712259110993ULL; // "QHEART" constexpr uint8 PULSE_DEFAULT_DEV_PERCENT = 10; @@ -32,8 +32,8 @@ constexpr uint8 PULSE_TICK_UPDATE_PERIOD = 100; constexpr uint8 PULSE_DEFAULT_DRAW_HOUR = 11; // 11:00 UTC constexpr uint8 PULSE_DEFAULT_SCHEDULE = 1 << WEDNESDAY | 1 << FRIDAY | 1 << SUNDAY; constexpr uint32 PULSE_DEFAULT_INIT_TIME = 22 << 9 | 4 << 5 | 13; -constexpr uint16 PULSE_DEFAULT_MAX_AUTO_TICKETS_PER_USER = PULSE_MAX_NUMBER_OF_PLAYERS; -constexpr uint64 PULSE_CLEANUP_THRESHOLD = 75; +constexpr uint16 PULSE_DEFAULT_MAX_AUTO_TICKETS_PER_USER = div(PULSE_MAX_NUMBER_OF_PLAYERS, 2); +constexpr uint64 PULSE_CLEANUP_THRESHOLD = 75ULL; const id PULSE_QHEART_ISSUER = ID(_S, _S, _G, _X, _S, _L, _S, _X, _F, _E, _J, _O, _O, _B, _T, _Z, _W, _V, _D, _S, _R, _C, _E, _F, _G, _X, _N, _D, _Y, _U, _V, _D, _X, _M, _Q, _A, _L, _X, _L, _B, _X, _G, _D, _C, _R, _X, _T, _K, _F, _Z, _I, _O, _T, _G, _Z, _F); From deb7b038fcecddbd781a40f0e6cedca4cb694e27 Mon Sep 17 00:00:00 2001 From: N-010 Date: Fri, 30 Jan 2026 17:21:05 +0300 Subject: [PATCH 54/77] Refactor SetAutoConfig and SetAutoLimits to enforce positive ticket limits and update existing entries accordingly --- src/contracts/Pulse.h | 109 +++++++++++++++++++++++------------------- 1 file changed, 60 insertions(+), 49 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index bc6f1d30b..463764d8a 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -249,13 +249,18 @@ struct PULSE : public ContractBase struct AllocateRandomTickets_locals { uint64 slotsLeft; - m256i mixedSpectrumValue; uint64 randomSeed; uint64 tempSeed; uint16 i; Ticket ticket; GetRandomDigits_input randomInput; GetRandomDigits_output randomOutput; + + struct RandomData + { + m256i prevSpectrumDigest; + AllocateRandomTickets_input allocateInput; + } randomData; }; struct BuyRandomTickets_input @@ -380,7 +385,6 @@ struct PULSE : public ContractBase sint64 insertIndex; sint64 removedIndex; AutoParticipant entry; - uint16 desiredValue; FindAutoParticipant_input findInput; FindAutoParticipant_output findOutput; }; @@ -393,6 +397,11 @@ struct PULSE : public ContractBase { uint8 returnCode; }; + struct SetAutoLimits_locals + { + AutoParticipant autoParticipant; + sint64 index; + }; struct GetTicketPrice_input { @@ -609,7 +618,7 @@ struct PULSE : public ContractBase { sint64 currentIndex; sint64 slotsLeft; - uint64 affordable; + sint64 affordable; sint64 toBuy; AutoParticipant entry; AllocateRandomTickets_input allocateInput; @@ -962,7 +971,7 @@ struct PULSE : public ContractBase /** Deposits QHeart into the contract for automatic ticket purchases. * @param amount QHeart amount to reserve for auto participation. - * @param desiredTickets Number of tickets to buy per draw + * @param desiredTickets Number of tickets to buy per draw. * @param buyNow When true, tries to buy immediately if selling is open. * @return Status code describing the result. */ @@ -985,9 +994,9 @@ struct PULSE : public ContractBase return; } - if (state.maxAutoTicketsPerUser != 0) + if (state.maxAutoTicketsPerUser > 0) { - input.desiredTickets = min(input.desiredTickets, state.maxAutoTicketsPerUser); + input.desiredTickets = min(input.desiredTickets, static_cast(state.maxAutoTicketsPerUser)); } locals.userBalance = @@ -995,7 +1004,6 @@ struct PULSE : public ContractBase input.amount = min(locals.userBalance, input.amount); locals.totalPrice = smul(state.ticketPrice, static_cast(input.desiredTickets)); - if (input.amount < locals.totalPrice) { output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); @@ -1099,7 +1107,6 @@ struct PULSE : public ContractBase /// Sets auto-participation config for the invocator. /// @param desiredTickets Signed: -1 ignore, >0 set new value. - /// @param minTicketsToBuy Signed: -1 ignore, 0 disable, >0 set new value. /// @return Status code describing the result. PUBLIC_PROCEDURE_WITH_LOCALS(SetAutoConfig) { @@ -1108,46 +1115,29 @@ struct PULSE : public ContractBase qpi.transfer(qpi.invocator(), qpi.invocationReward()); } - if (input.desiredTickets < -1) + if (!state.autoParticipants.contains(qpi.invocator())) { output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); return; } - - if (!state.autoParticipants.contains(qpi.invocator())) + input.desiredTickets = max(input.desiredTickets, -1); + if (input.desiredTickets == 0) { output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); return; } - if (input.desiredTickets > 0 && state.maxAutoTicketsPerUser != 0) - { - input.desiredTickets = min(input.desiredTickets, static_cast(state.maxAutoTicketsPerUser)); - } - - state.autoParticipants.get(qpi.invocator(), locals.entry); - - locals.desiredValue = locals.entry.desiredTickets; - - if (input.desiredTickets != -1) + if (input.desiredTickets > 0) { - if (input.desiredTickets <= 0) + if (state.maxAutoTicketsPerUser > 0) { - output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); - return; + input.desiredTickets = min(input.desiredTickets, static_cast(state.maxAutoTicketsPerUser)); } - locals.desiredValue = static_cast(input.desiredTickets); - } - locals.entry.desiredTickets = locals.desiredValue; + state.autoParticipants.get(qpi.invocator(), locals.entry); - if (locals.entry.deposit == 0) - { - locals.removedIndex = state.autoParticipants.removeByKey(qpi.invocator()); - state.autoParticipants.cleanupIfNeeded(PULSE_CLEANUP_THRESHOLD); - } - else - { + // Update desired tickets if specified + locals.entry.desiredTickets = static_cast(input.desiredTickets); state.autoParticipants.set(qpi.invocator(), locals.entry); } @@ -1158,7 +1148,7 @@ struct PULSE : public ContractBase /// @param maxTicketsPerUser Max tickets per user; 0 disables the limit. /// @param maxDepositPerUser Max deposit per user; 0 disables the limit. /// @return Status code describing the result. - PUBLIC_PROCEDURE(SetAutoLimits) + PUBLIC_PROCEDURE_WITH_LOCALS(SetAutoLimits) { if (qpi.invocationReward() > 0) { @@ -1171,14 +1161,30 @@ struct PULSE : public ContractBase return; } - if (input.maxTicketsPerUser != 0 && input.maxTicketsPerUser > PULSE_MAX_NUMBER_OF_PLAYERS) + if (state.maxAutoTicketsPerUser == input.maxTicketsPerUser) { - output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + output.returnCode = toReturnCode(EReturnCode::SUCCESS); return; } + input.maxTicketsPerUser = min(input.maxTicketsPerUser, PULSE_MAX_NUMBER_OF_PLAYERS); + state.maxAutoTicketsPerUser = input.maxTicketsPerUser; output.returnCode = toReturnCode(EReturnCode::SUCCESS); + + // Update existing entries to comply with the new limit. + if (state.maxAutoTicketsPerUser > 0) + { + locals.index = state.autoParticipants.nextElementIndex(NULL_INDEX); + while (locals.index != NULL_INDEX) + { + locals.autoParticipant = state.autoParticipants.value(locals.index); + locals.autoParticipant.desiredTickets = min(locals.autoParticipant.desiredTickets, state.maxAutoTicketsPerUser); + state.autoParticipants.replace(state.autoParticipants.key(locals.index), locals.autoParticipant); + + locals.index = state.autoParticipants.nextElementIndex(locals.index); + } + } } // Buys a single ticket; transfers ticket price from invocator. @@ -1274,12 +1280,6 @@ struct PULSE : public ContractBase return; } - locals.slotsLeft = getSlotsLeft(state); - if (locals.slotsLeft == 0) - { - return; - } - locals.currentIndex = state.autoParticipants.nextElementIndex(NULL_INDEX); while (locals.currentIndex != NULL_INDEX) { @@ -1299,7 +1299,16 @@ struct PULSE : public ContractBase continue; } - locals.toBuy = static_cast(min(locals.affordable, static_cast(locals.entry.desiredTickets))); + locals.toBuy = locals.affordable; + if (state.maxAutoTicketsPerUser > 0) + { + locals.toBuy = min(locals.toBuy, static_cast(state.maxAutoTicketsPerUser)); + } + if (locals.entry.desiredTickets > 0) + { + locals.toBuy = min(locals.toBuy, static_cast(locals.entry.desiredTickets)); + } + locals.toBuy = min(locals.toBuy, locals.slotsLeft); if (locals.toBuy <= 0) { @@ -1352,7 +1361,7 @@ struct PULSE : public ContractBase for (locals.index = 0; locals.index < PULSE_WINNING_DIGITS; ++locals.index) { deriveOne(input.seed, locals.index, locals.tempValue); - locals.candidate = static_cast(mod(locals.tempValue, static_cast(PULSE_MAX_DIGIT + 1))); + locals.candidate = static_cast(mod(locals.tempValue, PULSE_MAX_DIGIT + 1ULL)); output.digits.set(locals.index, locals.candidate); } @@ -1553,8 +1562,10 @@ struct PULSE : public ContractBase return; } - locals.mixedSpectrumValue = qpi.getPrevSpectrumDigest(); - locals.randomSeed = qpi.K12(locals.mixedSpectrumValue).u64._0; + locals.randomData.prevSpectrumDigest = qpi.getPrevSpectrumDigest(); + locals.randomData.allocateInput = input; + + locals.randomSeed = qpi.K12(locals.randomData).u64._0; for (locals.i = 0; locals.i < input.count; ++locals.i) { deriveOne(locals.randomSeed, locals.i, locals.tempSeed); @@ -1564,7 +1575,7 @@ struct PULSE : public ContractBase locals.ticket.player = input.player; locals.ticket.digits = locals.randomOutput.digits; state.tickets.set(state.ticketCounter, locals.ticket); - state.ticketCounter = min(static_cast(state.ticketCounter) + 1ULL, state.tickets.capacity()); + state.ticketCounter = min(state.ticketCounter + 1LL, static_cast(state.tickets.capacity())); } output.returnCode = toReturnCode(EReturnCode::SUCCESS); @@ -1607,7 +1618,7 @@ struct PULSE : public ContractBase sint64 ticketCounter; sint64 ticketPrice; // Per-user auto-purchase limits; 0 means unlimited. - sint16 maxAutoTicketsPerUser; + uint16 maxAutoTicketsPerUser; // Contract balance above this cap is swept to the QHeart wallet after settlement. uint64 qheartHoldLimit; // Date stamp of the most recent draw; PULSE_DEFAULT_INIT_TIME is a bootstrap sentinel. From 760b783b0201ea569acd3f0c9849cf1eb6172e83 Mon Sep 17 00:00:00 2001 From: N-010 Date: Fri, 30 Jan 2026 18:27:21 +0300 Subject: [PATCH 55/77] Add ticketCounter to randomData structure for tracking ticket counts --- src/contracts/Pulse.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 463764d8a..4ee5510ba 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -260,6 +260,7 @@ struct PULSE : public ContractBase { m256i prevSpectrumDigest; AllocateRandomTickets_input allocateInput; + sint64 ticketCounter; } randomData; }; @@ -1564,6 +1565,7 @@ struct PULSE : public ContractBase locals.randomData.prevSpectrumDigest = qpi.getPrevSpectrumDigest(); locals.randomData.allocateInput = input; + locals.randomData.ticketCounter = state.ticketCounter; locals.randomSeed = qpi.K12(locals.randomData).u64._0; for (locals.i = 0; locals.i < input.count; ++locals.i) From a2ed4d47f544914b00790c7893eb849adeee38a2 Mon Sep 17 00:00:00 2001 From: N-010 Date: Fri, 30 Jan 2026 20:46:09 +0300 Subject: [PATCH 56/77] Refactor ticket handling in Pulse: change data types for ticket counter and update related logic for consistency --- src/contracts/Pulse.h | 6 ++++-- test/contract_pulse.cpp | 25 ++++++++++++++----------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 4ee5510ba..200c87c8d 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -582,7 +582,7 @@ struct PULSE : public ContractBase }; struct SettleRound_locals { - uint64 i; + sint64 i; sint64 roundRevenue; sint64 devAmount; sint64 burnAmount; @@ -1605,7 +1605,9 @@ struct PULSE : public ContractBase static sint64 getSlotsLeft(const PULSE& state) { - return state.ticketCounter < state.tickets.capacity() ? (state.tickets.capacity() - state.ticketCounter) : 0; + return state.ticketCounter < static_cast(state.tickets.capacity()) + ? static_cast(state.tickets.capacity()) - state.ticketCounter + : 0LL; } protected: diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index c056d4083..003a6c01c 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -827,8 +827,8 @@ TEST(ContractPulse_Private, ProcessAutoTicketsRemovesUnaffordableParticipant) EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); } -// Keep auto participant when desired ticket count is zero. -TEST(ContractPulse_Private, ProcessAutoTicketsSkipsZeroDesiredTickets) +// Checks process auto tickets removes zero desired tickets. +TEST(ContractPulse_Private, ProcessAutoTicketsRemovesZeroDesiredTickets) { ContractTestingPulse ctl; const id user = id::randomValue(); @@ -841,10 +841,7 @@ TEST(ContractPulse_Private, ProcessAutoTicketsSkipsZeroDesiredTickets) ctl.state()->callProcessAutoTickets(qpi); const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); - EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(entry.deposit, ticketPrice * 2); - EXPECT_EQ(static_cast(entry.desiredTickets), 0u); - EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); } // ============================================================================ @@ -1154,8 +1151,14 @@ TEST(ContractPulse_Public, BuyRandomTicketsDeterministicWithFixedDigest) const m256i digest(0xABCDEF01ULL, 0x12345678ULL, 0xCAFEBABEULL, 0x0BADF00DULL); etalonTick.prevSpectrumDigest = digest; + PULSE::AllocateRandomTickets_locals::RandomData randomData{}; + randomData.prevSpectrumDigest = digest; + randomData.allocateInput.player = user; + randomData.allocateInput.count = 1; + randomData.ticketCounter = static_cast(ctl.state()->getTicketCounter()); + m256i hashResult; - KangarooTwelve(reinterpret_cast(&digest), sizeof(m256i), reinterpret_cast(&hashResult), sizeof(m256i)); + KangarooTwelve(reinterpret_cast(&randomData), sizeof(randomData), reinterpret_cast(&hashResult), sizeof(m256i)); const uint64 randomSeed = hashResult.m256i_u64[0]; uint64 tempSeed = 0; PULSEChecker::deriveOne(randomSeed, 0, tempSeed); @@ -1513,13 +1516,13 @@ TEST(ContractPulse_Public, SetAutoConfigValidatesAndClamps) ctl.transferQHeart(issuance, user, amount); EXPECT_EQ(ctl.depositAutoParticipation(user, amount, 3, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.setAutoConfig(user, -2).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + EXPECT_EQ(ctl.setAutoConfig(user, -2).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, 2).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.setAutoConfig(user, -1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); - EXPECT_EQ(static_cast(entry.desiredTickets), 3u); + EXPECT_EQ(static_cast(entry.desiredTickets), 2u); EXPECT_EQ(ctl.setAutoConfig(user, 5).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); entry = ctl.getAutoParticipation(user); @@ -1558,8 +1561,8 @@ TEST(ContractPulse_Public, SetAutoLimitsGuardsAccessAndValidates) { ContractTestingPulse ctl; EXPECT_EQ(ctl.setAutoLimits(id::randomValue(), 10).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); - EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, PULSE_MAX_NUMBER_OF_PLAYERS + 1).returnCode, - static_cast(PULSE::EReturnCode::INVALID_VALUE)); + EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, PULSE_MAX_NUMBER_OF_PLAYERS + 1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(static_cast(ctl.getAutoStats().maxAutoTicketsPerUser), static_cast(PULSE_MAX_NUMBER_OF_PLAYERS)); EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, 5).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); const PULSE::GetAutoStats_output stats = ctl.getAutoStats(); From bc2af79f376677f6db452ce5661c76d9a16ff1c1 Mon Sep 17 00:00:00 2001 From: N-010 Date: Fri, 30 Jan 2026 21:25:35 +0300 Subject: [PATCH 57/77] Add unit tests for prize computation and ticket validation; refactor ticket initialization --- test/contract_pulse.cpp | 91 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 6 deletions(-) diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index 003a6c01c..c23e66c08 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -101,9 +101,8 @@ class PULSEChecker : public PULSE void setTicketDirect(uint64 index, const id& player, const Array& digits) { - Ticket ticket; - ticket.player = player; - ticket.digits = digits; + Ticket ticket{player, digits}; + tickets.set(index, ticket); } @@ -606,6 +605,22 @@ TEST(ContractPulse_Static, RewardTablesMatchContractConstants) EXPECT_EQ(ctl.state()->callGetAnyPositionReward(0), 0u); } +// Ensure computePrize picks the higher of left-aligned or any-position rewards. +TEST(ContractPulse_Static, ComputePrizeSelectsBestReward) +{ + ContractTestingPulse ctl; + const Array winning = makePlayerDigits(0, 1, 2, 3, 4, 5); + + const Array exact = makePlayerDigits(0, 1, 2, 3, 4, 5); + EXPECT_EQ(ctl.state()->callComputePrize(winning, exact), 2000u * ctl.getTicketPrice().ticketPrice); + + const Array permuted = makePlayerDigits(5, 4, 3, 2, 1, 0); + EXPECT_EQ(ctl.state()->callComputePrize(winning, permuted), 150u * ctl.getTicketPrice().ticketPrice); + + const Array none = makePlayerDigits(9, 9, 9, 9, 9, 9); + EXPECT_EQ(ctl.state()->callComputePrize(winning, none), 0u); +} + // Prevent stale config from leaking across epochs. TEST(ContractPulse_Private, NextEpochDataClearResetsFlagsAndValues) { @@ -690,6 +705,21 @@ TEST(ContractPulse_Private, GetRandomDigitsDeterministic) } } +// Validate digit range checks for ticket input. +TEST(ContractPulse_Private, ValidateDigitsAcceptsRangeAndRejectsOutOfRange) +{ + ContractTestingPulse ctl; + QpiContextUserFunctionCall qpi(PULSE_CONTRACT_INDEX); + primeQpiFunctionContext(qpi); + + const PULSE::ValidateDigits_output valid = ctl.state()->callValidateDigits(qpi, makePlayerDigits(0, 1, 2, 3, 4, 5)); + EXPECT_TRUE(valid.isValid); + + const uint8 invalidValue = static_cast(PULSE_MAX_DIGIT + 1); + const PULSE::ValidateDigits_output invalid = ctl.state()->callValidateDigits(qpi, makePlayerDigits(0, 1, 2, 3, 4, invalidValue)); + EXPECT_FALSE(invalid.isValid); +} + // Validate PrepareRandomTickets error cases. TEST(ContractPulse_Private, PrepareRandomTicketsRejectsInvalidInputs) { @@ -1111,7 +1141,7 @@ TEST(ContractPulse_Public, BuyRandomTicketsSucceedsAndMovesQHeart) const PULSE::BuyRandomTickets_output out = ctl.buyRandomTickets(user, ticketCount); EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.state()->getTicketCounter(), static_cast(ticketCount)); + EXPECT_EQ(ctl.state()->getTicketCounter(), static_cast(static_cast(ticketCount))); EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore - totalPrice); EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore + totalPrice); @@ -1327,7 +1357,7 @@ TEST(ContractPulse_Public, DepositAutoParticipationBuyNowConsumesAllAndSkipsDepo const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); const PULSE::DepositAutoParticipation_output out = ctl.depositAutoParticipation(user, totalPrice, desiredTickets, true); EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.state()->getTicketCounter(), static_cast(desiredTickets)); + EXPECT_EQ(ctl.state()->getTicketCounter(), static_cast(static_cast(desiredTickets))); EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore - totalPrice); EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore + totalPrice); @@ -1354,7 +1384,7 @@ TEST(ContractPulse_Public, DepositAutoParticipationBuyNowStoresRemainder) const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); const PULSE::DepositAutoParticipation_output out = ctl.depositAutoParticipation(user, amount, desiredTickets, true); EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.state()->getTicketCounter(), static_cast(desiredTickets)); + EXPECT_EQ(ctl.state()->getTicketCounter(), static_cast(static_cast(desiredTickets))); const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); @@ -1611,6 +1641,15 @@ TEST(ContractPulse_Public, GetBalanceReportsQHeartWalletBalance) EXPECT_EQ(ctl.getBalance().balance, 12345u); } +// Report empty winner history before any draws. +TEST(ContractPulse_Public, GetWinnersReportsEmptyWhenNoWinners) +{ + ContractTestingPulse ctl; + const PULSE::GetWinners_output winners = ctl.getWinners(); + EXPECT_EQ(winners.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(winners.winnersCounter, 0u); +} + // Confirm winner history records paid prizes. TEST(ContractPulse_Public, GetWinnersReportsPaidTickets) { @@ -1736,6 +1775,46 @@ TEST(ContractPulse_System, EndEpochAppliesPendingChangesAndClearsState) EXPECT_EQ(ctl.state()->getTicketPriceInternal(), 999u); } +// Ensure draw is skipped before the configured draw hour. +TEST(ContractPulse_System, BeginTickSkipsBeforeDrawHour) +{ + ContractTestingPulse ctl; + ctl.state()->setDrawHourInternal(23); + + const id player = id::randomValue(); + ctl.state()->setTicketDirect(0, player, makePlayerDigits(0, 1, 2, 3, 4, 5)); + ctl.state()->setTicketCounter(1); + + ctl.setDateTime(2025, 1, 10, 12); + const uint32 lastStampBefore = ctl.state()->getLastDrawDateStamp(); + ctl.forceBeginTick(); + + EXPECT_EQ(ctl.state()->getTicketCounter(), 1u); + EXPECT_EQ(ctl.state()->getLastDrawDateStamp(), lastStampBefore); +} + +// Skip draws on non-scheduled days (excluding Wednesday fallback). +TEST(ContractPulse_System, BeginTickSkipsWhenNotScheduledDay) +{ + ContractTestingPulse ctl; + ctl.state()->setDrawHourInternal(1); + + const id player = id::randomValue(); + ctl.state()->setTicketDirect(0, player, makePlayerDigits(0, 1, 2, 3, 4, 5)); + ctl.state()->setTicketCounter(1); + + const Array beforeWinning = ctl.state()->getLastWinningDigits(); + + ctl.setDateTime(2025, 1, 11, 12); + ctl.forceBeginTick(); + + EXPECT_EQ(ctl.state()->getTicketCounter(), 1u); + for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + EXPECT_EQ(ctl.state()->getLastWinningDigits().get(i), beforeWinning.get(i)); + } +} + // Validate scheduled draw trigger path. TEST(ContractPulse_System, BeginTickRunsDrawOnScheduledDay) { From 6a5c6b54a647175479369e689b6f81ba6d2fae05 Mon Sep 17 00:00:00 2001 From: N-010 Date: Tue, 3 Feb 2026 19:20:33 +0300 Subject: [PATCH 58/77] Adds fee for RL --- src/contracts/Pulse.h | 105 +++++++++++++++++++++++++++++----------- test/contract_pulse.cpp | 76 +++++++++++++++++++++++++---- 2 files changed, 145 insertions(+), 36 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 200c87c8d..fd1a93aae 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -25,7 +25,8 @@ constexpr uint16 PULSE_MAX_NUMBER_OF_WINNERS_IN_HISTORY = 1024; constexpr uint64 PULSE_QHEART_ASSET_NAME = 92712259110993ULL; // "QHEART" constexpr uint8 PULSE_DEFAULT_DEV_PERCENT = 10; constexpr uint8 PULSE_DEFAULT_BURN_PERCENT = 5; -constexpr uint8 PULSE_DEFAULT_SHAREHOLDERS_PERCENT = 5; +constexpr uint8 PULSE_DEFAULT_SHAREHOLDERS_PERCENT = 8; +constexpr uint8 PULSE_DEFAULT_RL_SHAREHOLDERS_PERCENT = 2; constexpr uint8 PULSE_DEFAULT_QHEART_PERCENT = 5; constexpr uint64 PULSE_DEFAULT_QHEART_HOLD_LIMIT = 2000000000ULL; constexpr uint8 PULSE_TICK_UPDATE_PERIOD = 100; @@ -129,6 +130,7 @@ struct PULSE : public ContractBase state.devPercent = newDevPercent; state.burnPercent = newBurnPercent; state.shareholdersPercent = newShareholdersPercent; + state.rlShareholdersPercent = newRLShareholdersPercent; state.qheartPercent = newQHeartPercent; } if (hasNewQHeartHoldLimit) @@ -148,6 +150,7 @@ struct PULSE : public ContractBase uint8 newDevPercent; uint8 newBurnPercent; uint8 newShareholdersPercent; + uint8 newRLShareholdersPercent; uint8 newQHeartPercent; uint64 newQHeartHoldLimit; }; @@ -436,6 +439,7 @@ struct PULSE : public ContractBase uint8 devPercent; uint8 burnPercent; uint8 shareholdersPercent; + uint8 rlShareholdersPercent; uint8 qheartPercent; uint8 returnCode; }; @@ -536,6 +540,7 @@ struct PULSE : public ContractBase uint8 devPercent; uint8 burnPercent; uint8 shareholdersPercent; + uint8 rlShareholdersPercent; uint8 qheartPercent; }; struct SetFees_output @@ -574,6 +579,24 @@ struct PULSE : public ContractBase Array winningCounts; }; + struct TransferTokenToShareholder_input + { + sint64 shareholdersAmount; + Asset shareholdersAsset; + sint64 shareholdersTotalShares; + }; + + struct TransferTokenToShareholder_output + { + }; + + struct TransferTokenToShareholder_locals + { + AssetPossessionIterator shareholdersIter; + sint64 shareholdersDividendPerShare; + sint64 shareholdersHolderShares; + }; + struct SettleRound_input { }; @@ -587,6 +610,7 @@ struct PULSE : public ContractBase sint64 devAmount; sint64 burnAmount; sint64 shareholdersAmount; + sint64 rlShareholdersAmount; sint64 qheartAmount; sint64 balanceSigned; uint64 balance; @@ -596,17 +620,14 @@ struct PULSE : public ContractBase uint64 reservedBalance; m256i mixedSpectrumValue; uint64 randomSeed; - Asset shareholdersAsset; - AssetPossessionIterator shareholdersIter; - sint64 shareholdersTotalShares; - sint64 shareholdersDividendPerShare; - sint64 shareholdersHolderShares; GetRandomDigits_input randomInput; GetRandomDigits_output randomOutput; Ticket ticket; ComputePrize_locals computePrizeLocals; FillWinnersInfo_input fillWinnersInfoInput; FillWinnersInfo_output fillWinnersInfoOutput; + TransferTokenToShareholder_input transferInput; + TransferTokenToShareholder_output transferOutput; }; struct ProcessAutoTickets_input @@ -682,6 +703,7 @@ struct PULSE : public ContractBase state.devPercent = PULSE_DEFAULT_DEV_PERCENT; state.burnPercent = PULSE_DEFAULT_BURN_PERCENT; state.shareholdersPercent = PULSE_DEFAULT_SHAREHOLDERS_PERCENT; + state.rlShareholdersPercent = PULSE_DEFAULT_RL_SHAREHOLDERS_PERCENT; state.qheartPercent = PULSE_DEFAULT_QHEART_PERCENT; state.qheartHoldLimit = PULSE_DEFAULT_QHEART_HOLD_LIMIT; @@ -804,6 +826,7 @@ struct PULSE : public ContractBase output.devPercent = state.devPercent; output.burnPercent = state.burnPercent; output.shareholdersPercent = state.shareholdersPercent; + output.rlShareholdersPercent = state.rlShareholdersPercent; output.qheartPercent = state.qheartPercent; output.returnCode = toReturnCode(EReturnCode::SUCCESS); } @@ -936,7 +959,7 @@ struct PULSE : public ContractBase return; } - if (input.devPercent + input.burnPercent + input.shareholdersPercent + input.qheartPercent > 100) + if (input.devPercent + input.burnPercent + input.shareholdersPercent + input.qheartPercent + input.rlShareholdersPercent > 100) { output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); return; @@ -946,6 +969,7 @@ struct PULSE : public ContractBase state.nextEpochData.newDevPercent = input.devPercent; state.nextEpochData.newBurnPercent = input.burnPercent; state.nextEpochData.newShareholdersPercent = input.shareholdersPercent; + state.nextEpochData.newRLShareholdersPercent = input.rlShareholdersPercent; state.nextEpochData.newQHeartPercent = input.qheartPercent; output.returnCode = toReturnCode(EReturnCode::SUCCESS); @@ -1379,6 +1403,7 @@ struct PULSE : public ContractBase locals.devAmount = div(smul(locals.roundRevenue, static_cast(state.devPercent)), 100LL); locals.burnAmount = div(smul(locals.roundRevenue, static_cast(state.burnPercent)), 100LL); locals.shareholdersAmount = div(smul(locals.roundRevenue, static_cast(state.shareholdersPercent)), 100LL); + locals.rlShareholdersAmount = div(smul(locals.roundRevenue, static_cast(state.rlShareholdersPercent)), 100LL); locals.qheartAmount = div(smul(locals.roundRevenue, static_cast(state.qheartPercent)), 100LL); if (locals.devAmount > 0) @@ -1387,26 +1412,19 @@ struct PULSE : public ContractBase } if (locals.shareholdersAmount > 0) { - locals.shareholdersAsset.issuer = id::zero(); - locals.shareholdersAsset.assetName = PULSE_CONTRACT_ASSET_NAME; - locals.shareholdersTotalShares = NUMBER_OF_COMPUTORS; - - locals.shareholdersDividendPerShare = div(locals.shareholdersAmount, locals.shareholdersTotalShares); - if (locals.shareholdersDividendPerShare > 0) - { - locals.shareholdersIter.begin(locals.shareholdersAsset); - while (!locals.shareholdersIter.reachedEnd()) - { - locals.shareholdersHolderShares = locals.shareholdersIter.numberOfPossessedShares(); - if (locals.shareholdersHolderShares > 0) - { - qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, - smul(locals.shareholdersHolderShares, locals.shareholdersDividendPerShare), - locals.shareholdersIter.possessor()); - } - locals.shareholdersIter.next(); - } - } + locals.transferInput.shareholdersAmount = locals.shareholdersAmount; + locals.transferInput.shareholdersAsset.issuer = id::zero(); + locals.transferInput.shareholdersAsset.assetName = PULSE_CONTRACT_ASSET_NAME; + locals.transferInput.shareholdersTotalShares = NUMBER_OF_COMPUTORS; + CALL(TransferTokenToShareholder, locals.transferInput, locals.transferOutput); + } + if (locals.rlShareholdersAmount > 0) + { + locals.transferInput.shareholdersAmount = locals.rlShareholdersAmount; + locals.transferInput.shareholdersAsset.issuer = id::zero(); + locals.transferInput.shareholdersAsset.assetName = QTF_RANDOM_LOTTERY_ASSET_NAME; + locals.transferInput.shareholdersTotalShares = NUMBER_OF_COMPUTORS; + CALL(TransferTokenToShareholder, locals.transferInput, locals.transferOutput); } if (locals.burnAmount > 0) { @@ -1583,6 +1601,38 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } + PRIVATE_PROCEDURE_WITH_LOCALS(TransferTokenToShareholder) + { + if (input.shareholdersAmount <= 0 || input.shareholdersTotalShares <= 0) + { + return; + } + + if (input.shareholdersAsset.assetName == 0) + { + return; + } + + locals.shareholdersDividendPerShare = div(input.shareholdersAmount, input.shareholdersTotalShares); + if (locals.shareholdersDividendPerShare <= 0) + { + return; + } + + locals.shareholdersIter.begin(input.shareholdersAsset); + while (!locals.shareholdersIter.reachedEnd()) + { + locals.shareholdersHolderShares = locals.shareholdersIter.numberOfPossessedShares(); + if (locals.shareholdersHolderShares > 0) + { + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, + smul(locals.shareholdersHolderShares, locals.shareholdersDividendPerShare), + locals.shareholdersIter.possessor()); + } + locals.shareholdersIter.next(); + } + }; + public: // Encodes YYYY/MM/DD into a compact sortable date stamp. static void makeDateStamp(uint8 year, uint8 month, uint8 day, uint32& res) { res = static_cast(year << 9 | month << 5 | day); } @@ -1630,6 +1680,7 @@ struct PULSE : public ContractBase uint8 devPercent; uint8 burnPercent; uint8 shareholdersPercent; + uint8 rlShareholdersPercent; uint8 qheartPercent; uint8 schedule; uint8 drawHour; diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index c23e66c08..d202a981b 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -86,6 +86,7 @@ class PULSEChecker : public PULSE uint8 getDevPercentInternal() const { return devPercent; } uint8 getBurnPercentInternal() const { return burnPercent; } uint8 getShareholdersPercentInternal() const { return shareholdersPercent; } + uint8 getRLShareholdersPercentInternal() const { return rlShareholdersPercent; } uint8 getQHeartPercentInternal() const { return qheartPercent; } const id& getTeamAddressInternal() const { return teamAddress; } const Array& getLastWinningDigits() const { return lastWinningDigits; } @@ -418,13 +419,14 @@ class ContractTestingPulse : protected ContractTesting return output; } - PULSE::SetFees_output setFees(const id& invocator, uint8 dev, uint8 burn, uint8 shareholders, uint8 qheart) + PULSE::SetFees_output setFees(const id& invocator, uint8 dev, uint8 burn, uint8 shareholders, uint8 rlShareholders, uint8 qheart) { ensureUserEnergy(invocator); PULSE::SetFees_input input{}; input.devPercent = dev; input.burnPercent = burn; input.shareholdersPercent = shareholders; + input.rlShareholdersPercent = rlShareholders; input.qheartPercent = qheart; PULSE::SetFees_output output{}; if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_FEES, input, output, invocator, 0)) @@ -504,6 +506,13 @@ class ContractTestingPulse : protected ContractTesting issueContractShares(PULSE_CONTRACT_INDEX, initialShares, false); } + void issueRandomLotterySharesTo(const id& holder, unsigned int shares) + { + std::vector> initialShares; + initialShares.emplace_back(holder, shares); + issueContractShares(RL_CONTRACT_INDEX, initialShares, false); + } + uint64 qheartBalanceOf(const id& owner) const { const long long balance = @@ -891,6 +900,7 @@ TEST(ContractPulse_Public, GettersReturnDefaultsAfterInitialize) EXPECT_EQ(fees.devPercent, PULSE_DEFAULT_DEV_PERCENT); EXPECT_EQ(fees.burnPercent, PULSE_DEFAULT_BURN_PERCENT); EXPECT_EQ(fees.shareholdersPercent, PULSE_DEFAULT_SHAREHOLDERS_PERCENT); + EXPECT_EQ(fees.rlShareholdersPercent, PULSE_DEFAULT_RL_SHAREHOLDERS_PERCENT); EXPECT_EQ(fees.qheartPercent, PULSE_DEFAULT_QHEART_PERCENT); EXPECT_EQ(fees.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); @@ -949,19 +959,21 @@ TEST(ContractPulse_Public, SetDrawHourValidatesAndAppliesOnEndEpoch) TEST(ContractPulse_Public, SetFeesValidatesAndAppliesOnEndEpoch) { ContractTestingPulse ctl; - EXPECT_EQ(ctl.setFees(id::randomValue(), 1, 2, 3, 4).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); - EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 60, 60, 0, 0).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + EXPECT_EQ(ctl.setFees(id::randomValue(), 1, 2, 3, 4, 5).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 60, 60, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); - EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 11, 22, 33, 4).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 11, 22, 33, 6, 4).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.state()->getDevPercentInternal(), PULSE_DEFAULT_DEV_PERCENT); EXPECT_EQ(ctl.state()->getBurnPercentInternal(), PULSE_DEFAULT_BURN_PERCENT); EXPECT_EQ(ctl.state()->getShareholdersPercentInternal(), PULSE_DEFAULT_SHAREHOLDERS_PERCENT); + EXPECT_EQ(ctl.state()->getRLShareholdersPercentInternal(), PULSE_DEFAULT_RL_SHAREHOLDERS_PERCENT); EXPECT_EQ(ctl.state()->getQHeartPercentInternal(), PULSE_DEFAULT_QHEART_PERCENT); ctl.endEpoch(); EXPECT_EQ(ctl.state()->getDevPercentInternal(), 11u); EXPECT_EQ(ctl.state()->getBurnPercentInternal(), 22u); EXPECT_EQ(ctl.state()->getShareholdersPercentInternal(), 33u); + EXPECT_EQ(ctl.state()->getRLShareholdersPercentInternal(), 6u); EXPECT_EQ(ctl.state()->getQHeartPercentInternal(), 4u); } @@ -984,7 +996,7 @@ TEST(ContractPulse_Public, GettersReflectAppliedChanges) EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, 555).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.setSchedule(PULSE_QHEART_ISSUER, 0x7F).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.setDrawHour(PULSE_QHEART_ISSUER, 9).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 11, 22, 33, 4).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 11, 22, 33, 6, 4).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.setQHeartHoldLimit(PULSE_QHEART_ISSUER, 4321).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); ctl.endEpoch(); @@ -999,6 +1011,7 @@ TEST(ContractPulse_Public, GettersReflectAppliedChanges) EXPECT_EQ(fees.devPercent, 11u); EXPECT_EQ(fees.burnPercent, 22u); EXPECT_EQ(fees.shareholdersPercent, 33u); + EXPECT_EQ(fees.rlShareholdersPercent, 6u); EXPECT_EQ(fees.qheartPercent, 4u); } @@ -1657,7 +1670,7 @@ TEST(ContractPulse_Public, GetWinnersReportsPaidTickets) ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); - EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 0, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 0, 0, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, 1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); ctl.endEpoch(); @@ -1926,7 +1939,7 @@ TEST(ContractPulse_Gameplay, ProRataPayoutWhenBalanceInsufficient) ContractTestingPulse ctl; const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(2000000); - EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 0, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 0, 0, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, 1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); ctl.endEpoch(); @@ -1987,7 +2000,7 @@ TEST(ContractPulse_Gameplay, FeesDistributedToDevShareholdersAndQHeartWallet) static constexpr uint8 qheartPercent = 10; const uint64 ticketPrice = static_cast(NUMBER_OF_COMPUTORS) * 10; - EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, devPercent, burnPercent, shareholdersPercent, qheartPercent).returnCode, + EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, devPercent, burnPercent, shareholdersPercent, 0, qheartPercent).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, ticketPrice).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); ctl.endEpoch(); @@ -2023,11 +2036,53 @@ TEST(ContractPulse_Gameplay, FeesDistributedToDevShareholdersAndQHeartWallet) EXPECT_EQ(ctl.qheartBalanceOf(PULSE_QHEART_ISSUER), qheartWalletBefore + expectedQHeart); } + +// Validate fee distribution to Random Lottery shareholders. +TEST(ContractPulse_Gameplay, FeesDistributedToRLShareholders) +{ + ContractTestingPulse ctl; + const id rlShareholder = id::randomValue(); + ctl.issueRandomLotterySharesTo(rlShareholder, NUMBER_OF_COMPUTORS); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + static constexpr uint8 devPercent = 0; + static constexpr uint8 burnPercent = 0; + static constexpr uint8 shareholdersPercent = 0; + static constexpr uint8 rlShareholdersPercent = 10; + static constexpr uint8 qheartPercent = 0; + constexpr uint64 ticketPrice = static_cast(NUMBER_OF_COMPUTORS) * 10ULL; + + EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, devPercent, burnPercent, shareholdersPercent, rlShareholdersPercent, qheartPercent).returnCode, + static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, ticketPrice).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + ctl.endEpoch(); + + ctl.setDateTime(2025, 1, 9, 12); + ctl.beginEpoch(); + + const id player = id::randomValue(); + ctl.transferQHeart(issuance, player, ticketPrice); + EXPECT_EQ(ctl.buyTicket(player, makePlayerDigits(0, 1, 2, 3, 4, 5)).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const uint64 rlBefore = ctl.qheartBalanceOf(rlShareholder); + + ctl.setDateTime(2025, 1, 10, 12); + ctl.forceBeginTick(); + + static constexpr uint64 roundRevenue = ticketPrice; + static constexpr uint64 expectedRL = (roundRevenue * rlShareholdersPercent) / 100; + static constexpr uint64 dividendPerShare = expectedRL / NUMBER_OF_COMPUTORS; + static constexpr uint64 expectedRLGain = dividendPerShare * NUMBER_OF_COMPUTORS; + + EXPECT_EQ(ctl.qheartBalanceOf(rlShareholder), rlBefore + expectedRLGain); +} + // Ensure excess balance is swept to QHeart wallet after settlement. TEST(ContractPulse_Gameplay, QHeartHoldLimitExcessTransferred) { ContractTestingPulse ctl; ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); + ctl.issueRandomLotterySharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(5000000); const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; static constexpr uint64 holdLimit = 100000; @@ -2060,10 +2115,13 @@ TEST(ContractPulse_Gameplay, QHeartHoldLimitExcessTransferred) const uint64 devAmount = (roundRevenue * fees.devPercent) / 100; const uint64 burnAmount = (roundRevenue * fees.burnPercent) / 100; const uint64 shareholdersAmount = (roundRevenue * fees.shareholdersPercent) / 100; + const uint64 rlShareholdersAmount = (roundRevenue * fees.rlShareholdersPercent) / 100; const uint64 qheartAmount = (roundRevenue * fees.qheartPercent) / 100; const uint64 dividendPerShare = shareholdersAmount / NUMBER_OF_COMPUTORS; const uint64 shareholdersPaid = dividendPerShare * NUMBER_OF_COMPUTORS; - const uint64 feesTotal = devAmount + burnAmount + shareholdersPaid + qheartAmount; + const uint64 rlDividendPerShare = rlShareholdersAmount / NUMBER_OF_COMPUTORS; + const uint64 rlShareholdersPaid = rlDividendPerShare * NUMBER_OF_COMPUTORS; + const uint64 feesTotal = devAmount + burnAmount + shareholdersPaid + rlShareholdersPaid + qheartAmount; const uint64 balanceAfterFees = contractBefore - feesTotal; ASSERT_GE(balanceAfterFees, prize); From da2f576282f26e5af167c7c4d80d2faa85b9de1d Mon Sep 17 00:00:00 2001 From: N-010 Date: Mon, 16 Feb 2026 12:19:39 +0300 Subject: [PATCH 59/77] Set valid constructionEpoch --- src/contract_core/contract_def.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contract_core/contract_def.h b/src/contract_core/contract_def.h index bca69decb..d07db22f6 100644 --- a/src/contract_core/contract_def.h +++ b/src/contract_core/contract_def.h @@ -365,7 +365,7 @@ constexpr struct ContractDescription {"QRP", 199, 10000, sizeof(IPO)}, // proposal in epoch 197, IPO in 198, construction and first use in 199 {"QTF", 199, 10000, sizeof(QTF)}, // proposal in epoch 197, IPO in 198, construction and first use in 199 {"QDUEL", 199, 10000, sizeof(QDUEL)}, // proposal in epoch 197, IPO in 198, construction and first use in 199 - {"PULSE", 200, 10000, sizeof(PULSE)}, // proposal in epoch 198, IPO in 199, construction and first use in 200 + {"PULSE", 202, 10000, sizeof(PULSE)}, // proposal in epoch 200, IPO in 201, construction and first use in 202 // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES {"TESTEXA", 138, 10000, sizeof(TESTEXA)}, From 761f775526ee7c67020c98a753cb23c152992f51 Mon Sep 17 00:00:00 2001 From: N-010 Date: Mon, 16 Feb 2026 12:49:05 +0300 Subject: [PATCH 60/77] Fixes global const --- src/contracts/Pulse.h | 59 ++++++++++++------------- test/contract_pulse.cpp | 96 +++++++++++++++++++++-------------------- 2 files changed, 79 insertions(+), 76 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index fd1a93aae..6e180df85 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -36,8 +36,6 @@ constexpr uint32 PULSE_DEFAULT_INIT_TIME = 22 << 9 | 4 << 5 | 13; constexpr uint16 PULSE_DEFAULT_MAX_AUTO_TICKETS_PER_USER = div(PULSE_MAX_NUMBER_OF_PLAYERS, 2); constexpr uint64 PULSE_CLEANUP_THRESHOLD = 75ULL; -const id PULSE_QHEART_ISSUER = ID(_S, _S, _G, _X, _S, _L, _S, _X, _F, _E, _J, _O, _O, _B, _T, _Z, _W, _V, _D, _S, _R, _C, _E, _F, _G, _X, _N, _D, _Y, - _U, _V, _D, _X, _M, _Q, _A, _L, _X, _L, _B, _X, _G, _D, _C, _R, _X, _T, _K, _F, _Z, _I, _O, _T, _G, _Z, _F); constexpr uint64 PULSE_CONTRACT_ASSET_NAME = 297750254928ULL; // "PULSE" struct PULSE2 @@ -696,8 +694,10 @@ struct PULSE : public ContractBase INITIALIZE() { - state.teamAddress = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, - _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); + state.teamAddress = ID(_R, _O, _J, _V, _A, _E, _M, _F, _B, _X, _X, _Y, _N, _G, _A, _U, _A, _U, _I, _I, _X, _L, _B, _U, _P, _D, _H, _C, _D, _P, + _E, _S, _Y, _Z, _O, _V, _W, _U, _Y, _E, _C, _B, _Q, _V, _Z, _R, _F, _T, _K, _A, _G, _S, _H, _T, _N, _A); + state.qheartIssuer = ID(_S, _S, _G, _X, _S, _L, _S, _X, _F, _E, _J, _O, _O, _B, _T, _Z, _W, _V, _D, _S, _R, _C, _E, _F, _G, _X, _N, _D, _Y, + _U, _V, _D, _X, _M, _Q, _A, _L, _X, _L, _B, _X, _G, _D, _C, _R, _X, _T, _K, _F, _Z, _I, _O, _T, _G, _Z, _F); state.ticketPrice = PULSE_TICKET_PRICE_DEFAULT; state.devPercent = PULSE_DEFAULT_DEV_PERCENT; @@ -816,7 +816,7 @@ struct PULSE : public ContractBase // Returns QHeart balance cap retained by the contract. PUBLIC_FUNCTION(GetQHeartHoldLimit) { output.qheartHoldLimit = state.qheartHoldLimit; } // Returns the designated QHeart issuer wallet. - PUBLIC_FUNCTION(GetQHeartWallet) { output.wallet = PULSE_QHEART_ISSUER; } + PUBLIC_FUNCTION(GetQHeartWallet) { output.wallet = state.qheartIssuer; } // Returns digits from the last settled draw. PUBLIC_FUNCTION(GetWinningDigits) { output.digits = state.lastWinningDigits; } @@ -834,7 +834,7 @@ struct PULSE : public ContractBase // Returns contract QHeart balance held in the Pulse wallet. PUBLIC_FUNCTION(GetBalance) { - output.balance = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, SELF_INDEX, SELF_INDEX); + output.balance = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, state.qheartIssuer, SELF, SELF, SELF_INDEX, SELF_INDEX); } // Returns the winners ring buffer and total winners counter. @@ -878,7 +878,7 @@ struct PULSE : public ContractBase qpi.transfer(qpi.invocator(), qpi.invocationReward()); } - if (qpi.invocator() != PULSE_QHEART_ISSUER) + if (qpi.invocator() != state.qheartIssuer) { output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; @@ -903,7 +903,7 @@ struct PULSE : public ContractBase qpi.transfer(qpi.invocator(), qpi.invocationReward()); } - if (qpi.invocator() != PULSE_QHEART_ISSUER) + if (qpi.invocator() != state.qheartIssuer) { output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; @@ -928,7 +928,7 @@ struct PULSE : public ContractBase qpi.transfer(qpi.invocator(), qpi.invocationReward()); } - if (qpi.invocator() != PULSE_QHEART_ISSUER) + if (qpi.invocator() != state.qheartIssuer) { output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; @@ -953,7 +953,7 @@ struct PULSE : public ContractBase qpi.transfer(qpi.invocator(), qpi.invocationReward()); } - if (qpi.invocator() != PULSE_QHEART_ISSUER) + if (qpi.invocator() != state.qheartIssuer) { output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; @@ -983,7 +983,7 @@ struct PULSE : public ContractBase qpi.transfer(qpi.invocator(), qpi.invocationReward()); } - if (qpi.invocator() != PULSE_QHEART_ISSUER) + if (qpi.invocator() != state.qheartIssuer) { output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; @@ -1025,7 +1025,7 @@ struct PULSE : public ContractBase } locals.userBalance = - qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, qpi.invocator(), qpi.invocator(), SELF_INDEX, SELF_INDEX); + qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, state.qheartIssuer, qpi.invocator(), qpi.invocator(), SELF_INDEX, SELF_INDEX); input.amount = min(locals.userBalance, input.amount); locals.totalPrice = smul(state.ticketPrice, static_cast(input.desiredTickets)); @@ -1059,7 +1059,7 @@ struct PULSE : public ContractBase state.autoParticipants.get(qpi.invocator(), locals.entry); locals.entry.player = qpi.invocator(); - locals.transferResult = qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, qpi.invocator(), + locals.transferResult = qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, state.qheartIssuer, qpi.invocator(), qpi.invocator(), input.amount, SELF); if (locals.transferResult < 0) { @@ -1108,7 +1108,7 @@ struct PULSE : public ContractBase } locals.transferResult = - qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, locals.withdrawAmount, qpi.invocator()); + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, state.qheartIssuer, SELF, SELF, locals.withdrawAmount, qpi.invocator()); if (locals.transferResult < 0) { output.returnCode = toReturnCode(EReturnCode::TRANSFER_FROM_PULSE_FAILED); @@ -1180,7 +1180,7 @@ struct PULSE : public ContractBase qpi.transfer(qpi.invocator(), qpi.invocationReward()); } - if (qpi.invocator() != PULSE_QHEART_ISSUER) + if (qpi.invocator() != state.qheartIssuer) { output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; @@ -1242,14 +1242,14 @@ struct PULSE : public ContractBase } locals.userBalance = - qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, qpi.invocator(), qpi.invocator(), SELF_INDEX, SELF_INDEX); + qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, state.qheartIssuer, qpi.invocator(), qpi.invocator(), SELF_INDEX, SELF_INDEX); if (locals.userBalance < state.ticketPrice) { output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); return; } - locals.transferResult = qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, qpi.invocator(), + locals.transferResult = qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, state.qheartIssuer, qpi.invocator(), qpi.invocator(), state.ticketPrice, SELF); if (locals.transferResult < 0) { @@ -1408,7 +1408,7 @@ struct PULSE : public ContractBase if (locals.devAmount > 0) { - qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, locals.devAmount, state.teamAddress); + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, state.qheartIssuer, SELF, SELF, locals.devAmount, state.teamAddress); } if (locals.shareholdersAmount > 0) { @@ -1428,12 +1428,12 @@ struct PULSE : public ContractBase } if (locals.burnAmount > 0) { - qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, locals.burnAmount, NULL_ID); + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, state.qheartIssuer, SELF, SELF, locals.burnAmount, NULL_ID); } if (locals.qheartAmount > 0) { - qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, locals.qheartAmount, - PULSE_QHEART_ISSUER); + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, state.qheartIssuer, SELF, SELF, locals.qheartAmount, + state.qheartIssuer); } locals.mixedSpectrumValue = qpi.getPrevSpectrumDigest(); @@ -1442,7 +1442,7 @@ struct PULSE : public ContractBase CALL(GetRandomDigits, locals.randomInput, locals.randomOutput); state.lastWinningDigits = locals.randomOutput.digits; - locals.balanceSigned = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, SELF_INDEX, SELF_INDEX); + locals.balanceSigned = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, state.qheartIssuer, SELF, SELF, SELF_INDEX, SELF_INDEX); locals.balance = max(locals.balanceSigned, 0LL); locals.totalPrize = 0; @@ -1468,7 +1468,7 @@ struct PULSE : public ContractBase if (locals.prize > 0 && locals.balance >= locals.prize) { - qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, static_cast(locals.prize), + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, state.qheartIssuer, SELF, SELF, static_cast(locals.prize), locals.ticket.player); locals.balance -= locals.prize; @@ -1478,13 +1478,13 @@ struct PULSE : public ContractBase } } - locals.balanceSigned = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, SELF_INDEX, SELF_INDEX); + locals.balanceSigned = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, state.qheartIssuer, SELF, SELF, SELF_INDEX, SELF_INDEX); locals.balance = (locals.balanceSigned > 0) ? static_cast(locals.balanceSigned) : 0; if (state.qheartHoldLimit > 0 && locals.balance > state.qheartHoldLimit) { - qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, - static_cast(locals.balance - state.qheartHoldLimit), PULSE_QHEART_ISSUER); + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, state.qheartIssuer, SELF, SELF, + static_cast(locals.balance - state.qheartHoldLimit), state.qheartIssuer); } } @@ -1542,14 +1542,14 @@ struct PULSE : public ContractBase locals.totalPrice = smul(static_cast(input.count), state.ticketPrice); locals.userBalance = - qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, input.player, input.player, SELF_INDEX, SELF_INDEX); + qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, state.qheartIssuer, input.player, input.player, SELF_INDEX, SELF_INDEX); if (locals.userBalance < static_cast(locals.totalPrice)) { output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); return; } - locals.transferResult = qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, input.player, input.player, + locals.transferResult = qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, state.qheartIssuer, input.player, input.player, static_cast(locals.totalPrice), SELF); if (locals.transferResult < 0) { @@ -1625,7 +1625,7 @@ struct PULSE : public ContractBase locals.shareholdersHolderShares = locals.shareholdersIter.numberOfPossessedShares(); if (locals.shareholdersHolderShares > 0) { - qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, state.qheartIssuer, SELF, SELF, smul(locals.shareholdersHolderShares, locals.shareholdersDividendPerShare), locals.shareholdersIter.possessor()); } @@ -1686,6 +1686,7 @@ struct PULSE : public ContractBase uint8 drawHour; EState currentState; id teamAddress; + id qheartIssuer; NextEpochData nextEpochData; // Monotonic winner count used to rotate the winners ring buffer. uint64 winnersCounter; diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index d202a981b..f6e51f93d 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -91,6 +91,7 @@ class PULSEChecker : public PULSE const id& getTeamAddressInternal() const { return teamAddress; } const Array& getLastWinningDigits() const { return lastWinningDigits; } Ticket getTicket(uint64 index) const { return tickets.get(index); } + id getQHeartIssuer() const { return qheartIssuer; } void setTicketCounter(uint64 value) { ticketCounter = value; } void setTicketPriceInternal(uint64 value) { ticketPrice = value; } @@ -212,6 +213,8 @@ class ContractTestingPulse : protected ContractTesting } PULSEChecker* state() { return reinterpret_cast(contractStates[PULSE_CONTRACT_INDEX]); } + const PULSEChecker* state() const { return reinterpret_cast(contractStates[PULSE_CONTRACT_INDEX]); } + id pulseSelf() const { return id(PULSE_CONTRACT_INDEX, 0, 0, 0); } PULSE::GetTicketPrice_output getTicketPrice() @@ -485,7 +488,7 @@ class ContractTestingPulse : protected ContractTesting static constexpr char name[7] = {'Q', 'H', 'E', 'A', 'R', 'T', 0}; static constexpr char unit[7] = {}; QHeartIssuance info{}; - const sint64 issued = issueAsset(PULSE_QHEART_ISSUER, name, 0, unit, totalShares, PULSE_CONTRACT_INDEX, &info.issuanceIndex, + const sint64 issued = issueAsset(state()->getQHeartIssuer(), name, 0, unit, totalShares, PULSE_CONTRACT_INDEX, &info.issuanceIndex, &info.ownershipIndex, &info.possessionIndex); EXPECT_EQ(issued, totalShares); return info; @@ -516,7 +519,7 @@ class ContractTestingPulse : protected ContractTesting uint64 qheartBalanceOf(const id& owner) const { const long long balance = - numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, owner, owner, PULSE_CONTRACT_INDEX, PULSE_CONTRACT_INDEX); + numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, state()->getQHeartIssuer(), owner, owner, PULSE_CONTRACT_INDEX, PULSE_CONTRACT_INDEX); return (balance > 0) ? static_cast(balance) : 0; } @@ -733,7 +736,7 @@ TEST(ContractPulse_Private, ValidateDigitsAcceptsRangeAndRejectsOutOfRange) TEST(ContractPulse_Private, PrepareRandomTicketsRejectsInvalidInputs) { ContractTestingPulse ctl; - QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, ctl.state()->getQHeartIssuer(), 0); primeQpiProcedureContext(qpi); PULSE::PrepareRandomTickets_output out = ctl.state()->callPrepareRandomTickets(qpi, 0); @@ -752,7 +755,7 @@ TEST(ContractPulse_Private, PrepareRandomTicketsRejectsWhenSoldOut) ctl.beginEpoch(); ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS); - QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, ctl.state()->getQHeartIssuer(), 0); primeQpiProcedureContext(qpi); const PULSE::PrepareRandomTickets_output out = ctl.state()->callPrepareRandomTickets(qpi, 1); EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_ALL_SOLD_OUT)); @@ -767,7 +770,7 @@ TEST(ContractPulse_Private, ChargeTicketsFromPlayerRejectsInvalidOrInsufficient) const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; ctl.transferQHeart(issuance, user, ticketPrice); - QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, ctl.state()->getQHeartIssuer(), 0); primeQpiProcedureContext(qpi); PULSE::ChargeTicketsFromPlayer_output out = ctl.state()->callChargeTicketsFromPlayer(qpi, user, 0); @@ -784,7 +787,7 @@ TEST(ContractPulse_Private, ChargeTicketsFromPlayerRejectsInvalidOrInsufficient) TEST(ContractPulse_Private, AllocateRandomTicketsRejectsInvalidOrClosed) { ContractTestingPulse ctl; - QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, ctl.state()->getQHeartIssuer(), 0); primeQpiProcedureContext(qpi); PULSE::AllocateRandomTickets_output out = ctl.state()->callAllocateRandomTickets(qpi, id::zero(), 1); @@ -806,7 +809,7 @@ TEST(ContractPulse_Private, AllocateRandomTicketsRejectsWhenSoldOut) ctl.beginEpoch(); ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS); - QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, ctl.state()->getQHeartIssuer(), 0); primeQpiProcedureContext(qpi); const PULSE::AllocateRandomTickets_output out = ctl.state()->callAllocateRandomTickets(qpi, id::randomValue(), 1); EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_ALL_SOLD_OUT)); @@ -820,7 +823,7 @@ TEST(ContractPulse_Private, ProcessAutoTicketsSkipsWhenSellingClosed) ctl.state()->setAutoParticipant(user, 1, 1); ctl.state()->forceSelling(false); - QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, ctl.state()->getQHeartIssuer(), 0); primeQpiProcedureContext(qpi); ctl.state()->callProcessAutoTickets(qpi); @@ -840,7 +843,7 @@ TEST(ContractPulse_Private, ProcessAutoTicketsSkipsWhenSoldOut) ctl.state()->forceSelling(true); ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS); - QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, ctl.state()->getQHeartIssuer(), 0); primeQpiProcedureContext(qpi); ctl.state()->callProcessAutoTickets(qpi); @@ -858,7 +861,7 @@ TEST(ContractPulse_Private, ProcessAutoTicketsRemovesUnaffordableParticipant) ctl.state()->setAutoParticipant(user, static_cast(ticketPrice - 1), 1); ctl.state()->forceSelling(true); - QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, ctl.state()->getQHeartIssuer(), 0); primeQpiProcedureContext(qpi); ctl.state()->callProcessAutoTickets(qpi); @@ -875,7 +878,7 @@ TEST(ContractPulse_Private, ProcessAutoTicketsRemovesZeroDesiredTickets) ctl.state()->setAutoParticipant(user, static_cast(ticketPrice * 2), 0); ctl.state()->forceSelling(true); - QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, ctl.state()->getQHeartIssuer(), 0); primeQpiProcedureContext(qpi); ctl.state()->callProcessAutoTickets(qpi); @@ -910,7 +913,7 @@ TEST(ContractPulse_Public, GettersReturnDefaultsAfterInitialize) EXPECT_EQ(win.digits.get(i), 0u); } EXPECT_EQ(ctl.getBalance().balance, 0u); - EXPECT_EQ(ctl.getQHeartWallet().wallet, PULSE_QHEART_ISSUER); + EXPECT_EQ(ctl.getQHeartWallet().wallet, ctl.state()->getQHeartIssuer()); } // Guard admin-only price changes and deferred apply. @@ -918,9 +921,9 @@ TEST(ContractPulse_Public, SetPriceGuardsAccessAndAppliesOnEndEpoch) { ContractTestingPulse ctl; EXPECT_EQ(ctl.setPrice(id::randomValue(), 123).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); - EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, 0).returnCode, static_cast(PULSE::EReturnCode::TICKET_INVALID_PRICE)); + EXPECT_EQ(ctl.setPrice(ctl.state()->getQHeartIssuer(), 0).returnCode, static_cast(PULSE::EReturnCode::TICKET_INVALID_PRICE)); - EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, 555).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setPrice(ctl.state()->getQHeartIssuer(), 555).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.state()->getTicketPriceInternal(), PULSE_TICKET_PRICE_DEFAULT); ctl.endEpoch(); @@ -932,9 +935,9 @@ TEST(ContractPulse_Public, SetScheduleValidatesAndAppliesOnEndEpoch) { ContractTestingPulse ctl; EXPECT_EQ(ctl.setSchedule(id::randomValue(), 1).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); - EXPECT_EQ(ctl.setSchedule(PULSE_QHEART_ISSUER, 0).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + EXPECT_EQ(ctl.setSchedule(ctl.state()->getQHeartIssuer(), 0).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); - EXPECT_EQ(ctl.setSchedule(PULSE_QHEART_ISSUER, 0x7F).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setSchedule(ctl.state()->getQHeartIssuer(), 0x7F).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.state()->getScheduleInternal(), PULSE_DEFAULT_SCHEDULE); ctl.endEpoch(); @@ -946,9 +949,9 @@ TEST(ContractPulse_Public, SetDrawHourValidatesAndAppliesOnEndEpoch) { ContractTestingPulse ctl; EXPECT_EQ(ctl.setDrawHour(id::randomValue(), 12).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); - EXPECT_EQ(ctl.setDrawHour(PULSE_QHEART_ISSUER, 24).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + EXPECT_EQ(ctl.setDrawHour(ctl.state()->getQHeartIssuer(), 24).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); - EXPECT_EQ(ctl.setDrawHour(PULSE_QHEART_ISSUER, 9).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setDrawHour(ctl.state()->getQHeartIssuer(), 9).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.state()->getDrawHourInternal(), PULSE_DEFAULT_DRAW_HOUR); ctl.endEpoch(); @@ -960,9 +963,9 @@ TEST(ContractPulse_Public, SetFeesValidatesAndAppliesOnEndEpoch) { ContractTestingPulse ctl; EXPECT_EQ(ctl.setFees(id::randomValue(), 1, 2, 3, 4, 5).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); - EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 60, 60, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), 60, 60, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); - EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 11, 22, 33, 6, 4).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), 11, 22, 33, 6, 4).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.state()->getDevPercentInternal(), PULSE_DEFAULT_DEV_PERCENT); EXPECT_EQ(ctl.state()->getBurnPercentInternal(), PULSE_DEFAULT_BURN_PERCENT); EXPECT_EQ(ctl.state()->getShareholdersPercentInternal(), PULSE_DEFAULT_SHAREHOLDERS_PERCENT); @@ -982,7 +985,7 @@ TEST(ContractPulse_Public, SetQHeartHoldLimitAppliesOnEndEpoch) { ContractTestingPulse ctl; EXPECT_EQ(ctl.setQHeartHoldLimit(id::randomValue(), 100).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); - EXPECT_EQ(ctl.setQHeartHoldLimit(PULSE_QHEART_ISSUER, 1234).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setQHeartHoldLimit(ctl.state()->getQHeartIssuer(), 1234).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.state()->getQHeartHoldLimitInternal(), PULSE_DEFAULT_QHEART_HOLD_LIMIT); ctl.endEpoch(); @@ -993,11 +996,11 @@ TEST(ContractPulse_Public, SetQHeartHoldLimitAppliesOnEndEpoch) TEST(ContractPulse_Public, GettersReflectAppliedChanges) { ContractTestingPulse ctl; - EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, 555).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.setSchedule(PULSE_QHEART_ISSUER, 0x7F).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.setDrawHour(PULSE_QHEART_ISSUER, 9).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 11, 22, 33, 6, 4).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.setQHeartHoldLimit(PULSE_QHEART_ISSUER, 4321).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setPrice(ctl.state()->getQHeartIssuer(), 555).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setSchedule(ctl.state()->getQHeartIssuer(), 0x7F).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setDrawHour(ctl.state()->getQHeartIssuer(), 9).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), 11, 22, 33, 6, 4).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setQHeartHoldLimit(ctl.state()->getQHeartIssuer(), 4321).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); ctl.endEpoch(); @@ -1257,7 +1260,7 @@ TEST(ContractPulse_Public, DepositAutoParticipationRejectsInvalidValues) TEST(ContractPulse_Public, DepositAutoParticipationClampsDesiredTicketsAndStoresDeposit) { ContractTestingPulse ctl; - EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, 2).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setAutoLimits(ctl.state()->getQHeartIssuer(), 2).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); const id user = id::randomValue(); @@ -1561,7 +1564,7 @@ TEST(ContractPulse_Public, SetAutoConfigValidatesAndClamps) EXPECT_EQ(ctl.depositAutoParticipation(user, amount, 3, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.setAutoConfig(user, -2).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, 2).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setAutoLimits(ctl.state()->getQHeartIssuer(), 2).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.setAutoConfig(user, -1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); @@ -1604,9 +1607,9 @@ TEST(ContractPulse_Public, SetAutoLimitsGuardsAccessAndValidates) { ContractTestingPulse ctl; EXPECT_EQ(ctl.setAutoLimits(id::randomValue(), 10).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); - EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, PULSE_MAX_NUMBER_OF_PLAYERS + 1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setAutoLimits(ctl.state()->getQHeartIssuer(), PULSE_MAX_NUMBER_OF_PLAYERS + 1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(static_cast(ctl.getAutoStats().maxAutoTicketsPerUser), static_cast(PULSE_MAX_NUMBER_OF_PLAYERS)); - EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, 5).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setAutoLimits(ctl.state()->getQHeartIssuer(), 5).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); const PULSE::GetAutoStats_output stats = ctl.getAutoStats(); EXPECT_EQ(stats.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); @@ -1617,8 +1620,8 @@ TEST(ContractPulse_Public, SetAutoLimitsGuardsAccessAndValidates) TEST(ContractPulse_Public, SetAutoLimitsAllowsDisabling) { ContractTestingPulse ctl; - EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, 3).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setAutoLimits(ctl.state()->getQHeartIssuer(), 3).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setAutoLimits(ctl.state()->getQHeartIssuer(), 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); const PULSE::GetAutoStats_output stats = ctl.getAutoStats(); EXPECT_EQ(stats.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); @@ -1670,8 +1673,8 @@ TEST(ContractPulse_Public, GetWinnersReportsPaidTickets) ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); - EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 0, 0, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, 1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), 0, 0, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setPrice(ctl.state()->getQHeartIssuer(), 1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); ctl.endEpoch(); ctl.setDateTime(2025, 1, 9, 12); @@ -1939,8 +1942,8 @@ TEST(ContractPulse_Gameplay, ProRataPayoutWhenBalanceInsufficient) ContractTestingPulse ctl; const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(2000000); - EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 0, 0, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, 1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), 0, 0, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setPrice(ctl.state()->getQHeartIssuer(), 1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); ctl.endEpoch(); ctl.setDateTime(2025, 1, 9, 12); @@ -2000,9 +2003,9 @@ TEST(ContractPulse_Gameplay, FeesDistributedToDevShareholdersAndQHeartWallet) static constexpr uint8 qheartPercent = 10; const uint64 ticketPrice = static_cast(NUMBER_OF_COMPUTORS) * 10; - EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, devPercent, burnPercent, shareholdersPercent, 0, qheartPercent).returnCode, + EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), devPercent, burnPercent, shareholdersPercent, 0, qheartPercent).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, ticketPrice).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setPrice(ctl.state()->getQHeartIssuer(), ticketPrice).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); ctl.endEpoch(); ctl.setDateTime(2025, 1, 9, 12); @@ -2014,11 +2017,11 @@ TEST(ContractPulse_Gameplay, FeesDistributedToDevShareholdersAndQHeartWallet) const id devWallet = ctl.state()->getTeamAddressInternal(); EXPECT_NE(devWallet, shareholder); - EXPECT_NE(devWallet, PULSE_QHEART_ISSUER); + EXPECT_NE(devWallet, ctl.state()->getQHeartIssuer()); const uint64 devBefore = ctl.qheartBalanceOf(devWallet); const uint64 shareholderBefore = ctl.qheartBalanceOf(shareholder); - const uint64 qheartWalletBefore = ctl.qheartBalanceOf(PULSE_QHEART_ISSUER); + const uint64 qheartWalletBefore = ctl.qheartBalanceOf(ctl.state()->getQHeartIssuer()); ctl.setDateTime(2025, 1, 10, 12); ctl.forceBeginTick(); @@ -2033,10 +2036,9 @@ TEST(ContractPulse_Gameplay, FeesDistributedToDevShareholdersAndQHeartWallet) EXPECT_EQ(expectedShareholderGain, expectedShareholders); EXPECT_EQ(ctl.qheartBalanceOf(devWallet), devBefore + expectedDev); EXPECT_EQ(ctl.qheartBalanceOf(shareholder), shareholderBefore + expectedShareholderGain); - EXPECT_EQ(ctl.qheartBalanceOf(PULSE_QHEART_ISSUER), qheartWalletBefore + expectedQHeart); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.state()->getQHeartIssuer()), qheartWalletBefore + expectedQHeart); } - // Validate fee distribution to Random Lottery shareholders. TEST(ContractPulse_Gameplay, FeesDistributedToRLShareholders) { @@ -2052,9 +2054,9 @@ TEST(ContractPulse_Gameplay, FeesDistributedToRLShareholders) static constexpr uint8 qheartPercent = 0; constexpr uint64 ticketPrice = static_cast(NUMBER_OF_COMPUTORS) * 10ULL; - EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, devPercent, burnPercent, shareholdersPercent, rlShareholdersPercent, qheartPercent).returnCode, + EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), devPercent, burnPercent, shareholdersPercent, rlShareholdersPercent, qheartPercent).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, ticketPrice).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setPrice(ctl.state()->getQHeartIssuer(), ticketPrice).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); ctl.endEpoch(); ctl.setDateTime(2025, 1, 9, 12); @@ -2088,7 +2090,7 @@ TEST(ContractPulse_Gameplay, QHeartHoldLimitExcessTransferred) static constexpr uint64 holdLimit = 100000; static constexpr uint64 preFund = 500000; - EXPECT_EQ(ctl.setQHeartHoldLimit(PULSE_QHEART_ISSUER, holdLimit).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setQHeartHoldLimit(ctl.state()->getQHeartIssuer(), holdLimit).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); ctl.endEpoch(); ctl.setDateTime(2025, 1, 9, 12); @@ -2107,7 +2109,7 @@ TEST(ContractPulse_Gameplay, QHeartHoldLimitExcessTransferred) const Array& winning = deriveWinningDigits(ctl, digest); const uint64 prize = ctl.state()->callComputePrize(winning, digits); - const uint64 walletBefore = ctl.qheartBalanceOf(PULSE_QHEART_ISSUER); + const uint64 walletBefore = ctl.qheartBalanceOf(ctl.state()->getQHeartIssuer()); const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); const PULSE::GetFees_output fees = ctl.getFees(); @@ -2134,5 +2136,5 @@ TEST(ContractPulse_Gameplay, QHeartHoldLimitExcessTransferred) ctl.forceBeginTick(); EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), expectedContractAfter); - EXPECT_EQ(ctl.qheartBalanceOf(PULSE_QHEART_ISSUER), expectedWalletAfter); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.state()->getQHeartIssuer()), expectedWalletAfter); } From b3afdae1b0cb03030049689631347605695540af Mon Sep 17 00:00:00 2001 From: Franziska Mueller <11660876+Franziska-Mueller@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:46:52 +0100 Subject: [PATCH 61/77] remove _ALLOW_KEYWORD_MACROS in gtest --- test/contract_pulse.cpp | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index f6e51f93d..6de8b0d67 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -1,10 +1,6 @@ #define NO_UEFI -#define _ALLOW_KEYWORD_MACROS 1 -// Allow tests to call internal helpers without changing production visibility. -#define private protected + #include "contract_testing.h" -#undef private -#undef _ALLOW_KEYWORD_MACROS #include From 3a386928f5a12db213a641eee052091a7bfec441 Mon Sep 17 00:00:00 2001 From: N-010 Date: Fri, 20 Feb 2026 19:48:08 +0300 Subject: [PATCH 62/77] Update constants --- src/contract_core/contract_def.h | 2 +- src/contracts/Pulse.h | 14 +++++++------- test/contract_pulse.cpp | 10 +++++----- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/contract_core/contract_def.h b/src/contract_core/contract_def.h index d07db22f6..1b399bb9a 100644 --- a/src/contract_core/contract_def.h +++ b/src/contract_core/contract_def.h @@ -365,7 +365,7 @@ constexpr struct ContractDescription {"QRP", 199, 10000, sizeof(IPO)}, // proposal in epoch 197, IPO in 198, construction and first use in 199 {"QTF", 199, 10000, sizeof(QTF)}, // proposal in epoch 197, IPO in 198, construction and first use in 199 {"QDUEL", 199, 10000, sizeof(QDUEL)}, // proposal in epoch 197, IPO in 198, construction and first use in 199 - {"PULSE", 202, 10000, sizeof(PULSE)}, // proposal in epoch 200, IPO in 201, construction and first use in 202 + {"PULSE", 203, 10000, sizeof(PULSE)}, // proposal in epoch 201, IPO in 202, construction and first use in 203 // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES {"TESTEXA", 138, 10000, sizeof(TESTEXA)}, diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 6e180df85..88e5e98e2 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -24,9 +24,9 @@ constexpr uint64 PULSE_TICKET_PRICE_DEFAULT = 200000ULL; constexpr uint16 PULSE_MAX_NUMBER_OF_WINNERS_IN_HISTORY = 1024; constexpr uint64 PULSE_QHEART_ASSET_NAME = 92712259110993ULL; // "QHEART" constexpr uint8 PULSE_DEFAULT_DEV_PERCENT = 10; -constexpr uint8 PULSE_DEFAULT_BURN_PERCENT = 5; -constexpr uint8 PULSE_DEFAULT_SHAREHOLDERS_PERCENT = 8; -constexpr uint8 PULSE_DEFAULT_RL_SHAREHOLDERS_PERCENT = 2; +constexpr uint8 PULSE_DEFAULT_BURN_PERCENT = 10; +constexpr uint8 PULSE_DEFAULT_SHAREHOLDERS_PERCENT = 10; +constexpr uint8 PULSE_DEFAULT_RL_SHAREHOLDERS_PERCENT = 5; constexpr uint8 PULSE_DEFAULT_QHEART_PERCENT = 5; constexpr uint64 PULSE_DEFAULT_QHEART_HOLD_LIMIT = 2000000000ULL; constexpr uint8 PULSE_TICK_UPDATE_PERIOD = 100; @@ -1731,10 +1731,10 @@ struct PULSE : public ContractBase { switch (matches) { - case 6: return 150 * state.ticketPrice; - case 5: return 30 * state.ticketPrice; - case 4: return 8 * state.ticketPrice; - case 3: return 2 * state.ticketPrice; + case 6: return 100 * state.ticketPrice; + case 5: return 10 * state.ticketPrice; + case 4: return 3 * state.ticketPrice; + case 3: return 1 * state.ticketPrice; case 2: case 1: default: return 0; diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index 6de8b0d67..c16fc58a8 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -604,10 +604,10 @@ TEST(ContractPulse_Static, RewardTablesMatchContractConstants) EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(1), 1u * ctl.getTicketPrice().ticketPrice); EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(0), 0u); - EXPECT_EQ(ctl.state()->callGetAnyPositionReward(6), 150u * ctl.getTicketPrice().ticketPrice); - EXPECT_EQ(ctl.state()->callGetAnyPositionReward(5), 30u * ctl.getTicketPrice().ticketPrice); - EXPECT_EQ(ctl.state()->callGetAnyPositionReward(4), 8u * ctl.getTicketPrice().ticketPrice); - EXPECT_EQ(ctl.state()->callGetAnyPositionReward(3), 2u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(6), 100u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(5), 10u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(4), 3u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(3), 1u * ctl.getTicketPrice().ticketPrice); EXPECT_EQ(ctl.state()->callGetAnyPositionReward(2), 0u); EXPECT_EQ(ctl.state()->callGetAnyPositionReward(1), 0u); EXPECT_EQ(ctl.state()->callGetAnyPositionReward(0), 0u); @@ -623,7 +623,7 @@ TEST(ContractPulse_Static, ComputePrizeSelectsBestReward) EXPECT_EQ(ctl.state()->callComputePrize(winning, exact), 2000u * ctl.getTicketPrice().ticketPrice); const Array permuted = makePlayerDigits(5, 4, 3, 2, 1, 0); - EXPECT_EQ(ctl.state()->callComputePrize(winning, permuted), 150u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callComputePrize(winning, permuted), 100u * ctl.getTicketPrice().ticketPrice); const Array none = makePlayerDigits(9, 9, 9, 9, 9, 9); EXPECT_EQ(ctl.state()->callComputePrize(winning, none), 0u); From 7eedcb2e6990608915d938bed65f2478305e5a90 Mon Sep 17 00:00:00 2001 From: N-010 Date: Fri, 20 Feb 2026 20:24:22 +0300 Subject: [PATCH 63/77] Removes QHeart Percent Fixes padding --- src/contracts/Pulse.h | 33 ++++++++------------------------ test/contract_pulse.cpp | 42 +++++++++++++---------------------------- 2 files changed, 21 insertions(+), 54 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 88e5e98e2..6d36be84d 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -27,7 +27,6 @@ constexpr uint8 PULSE_DEFAULT_DEV_PERCENT = 10; constexpr uint8 PULSE_DEFAULT_BURN_PERCENT = 10; constexpr uint8 PULSE_DEFAULT_SHAREHOLDERS_PERCENT = 10; constexpr uint8 PULSE_DEFAULT_RL_SHAREHOLDERS_PERCENT = 5; -constexpr uint8 PULSE_DEFAULT_QHEART_PERCENT = 5; constexpr uint64 PULSE_DEFAULT_QHEART_HOLD_LIMIT = 2000000000ULL; constexpr uint8 PULSE_TICK_UPDATE_PERIOD = 100; constexpr uint8 PULSE_DEFAULT_DRAW_HOUR = 11; // 11:00 UTC @@ -105,7 +104,6 @@ struct PULSE : public ContractBase newDevPercent = 0; newBurnPercent = 0; newShareholdersPercent = 0; - newQHeartPercent = 0; newQHeartHoldLimit = 0; } @@ -129,7 +127,6 @@ struct PULSE : public ContractBase state.burnPercent = newBurnPercent; state.shareholdersPercent = newShareholdersPercent; state.rlShareholdersPercent = newRLShareholdersPercent; - state.qheartPercent = newQHeartPercent; } if (hasNewQHeartHoldLimit) { @@ -149,7 +146,6 @@ struct PULSE : public ContractBase uint8 newBurnPercent; uint8 newShareholdersPercent; uint8 newRLShareholdersPercent; - uint8 newQHeartPercent; uint64 newQHeartHoldLimit; }; @@ -438,7 +434,6 @@ struct PULSE : public ContractBase uint8 burnPercent; uint8 shareholdersPercent; uint8 rlShareholdersPercent; - uint8 qheartPercent; uint8 returnCode; }; @@ -539,7 +534,6 @@ struct PULSE : public ContractBase uint8 burnPercent; uint8 shareholdersPercent; uint8 rlShareholdersPercent; - uint8 qheartPercent; }; struct SetFees_output { @@ -609,7 +603,6 @@ struct PULSE : public ContractBase sint64 burnAmount; sint64 shareholdersAmount; sint64 rlShareholdersAmount; - sint64 qheartAmount; sint64 balanceSigned; uint64 balance; uint64 availableBalance; @@ -704,7 +697,6 @@ struct PULSE : public ContractBase state.burnPercent = PULSE_DEFAULT_BURN_PERCENT; state.shareholdersPercent = PULSE_DEFAULT_SHAREHOLDERS_PERCENT; state.rlShareholdersPercent = PULSE_DEFAULT_RL_SHAREHOLDERS_PERCENT; - state.qheartPercent = PULSE_DEFAULT_QHEART_PERCENT; state.qheartHoldLimit = PULSE_DEFAULT_QHEART_HOLD_LIMIT; state.schedule = PULSE_DEFAULT_SCHEDULE; @@ -827,7 +819,6 @@ struct PULSE : public ContractBase output.burnPercent = state.burnPercent; output.shareholdersPercent = state.shareholdersPercent; output.rlShareholdersPercent = state.rlShareholdersPercent; - output.qheartPercent = state.qheartPercent; output.returnCode = toReturnCode(EReturnCode::SUCCESS); } @@ -959,7 +950,7 @@ struct PULSE : public ContractBase return; } - if (input.devPercent + input.burnPercent + input.shareholdersPercent + input.qheartPercent + input.rlShareholdersPercent > 100) + if (input.devPercent + input.burnPercent + input.shareholdersPercent + input.rlShareholdersPercent > 100) { output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); return; @@ -970,7 +961,6 @@ struct PULSE : public ContractBase state.nextEpochData.newBurnPercent = input.burnPercent; state.nextEpochData.newShareholdersPercent = input.shareholdersPercent; state.nextEpochData.newRLShareholdersPercent = input.rlShareholdersPercent; - state.nextEpochData.newQHeartPercent = input.qheartPercent; output.returnCode = toReturnCode(EReturnCode::SUCCESS); } @@ -1404,7 +1394,6 @@ struct PULSE : public ContractBase locals.burnAmount = div(smul(locals.roundRevenue, static_cast(state.burnPercent)), 100LL); locals.shareholdersAmount = div(smul(locals.roundRevenue, static_cast(state.shareholdersPercent)), 100LL); locals.rlShareholdersAmount = div(smul(locals.roundRevenue, static_cast(state.rlShareholdersPercent)), 100LL); - locals.qheartAmount = div(smul(locals.roundRevenue, static_cast(state.qheartPercent)), 100LL); if (locals.devAmount > 0) { @@ -1430,11 +1419,6 @@ struct PULSE : public ContractBase { qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, state.qheartIssuer, SELF, SELF, locals.burnAmount, NULL_ID); } - if (locals.qheartAmount > 0) - { - qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, state.qheartIssuer, SELF, SELF, locals.qheartAmount, - state.qheartIssuer); - } locals.mixedSpectrumValue = qpi.getPrevSpectrumDigest(); locals.randomSeed = qpi.K12(locals.mixedSpectrumValue).u64._0; @@ -1669,27 +1653,26 @@ struct PULSE : public ContractBase HashMap autoParticipants; // Last settled winning digits; undefined before the first draw. Array lastWinningDigits; + NextEpochData nextEpochData; + id teamAddress; + id qheartIssuer; + // Monotonic winner count used to rotate the winners ring buffer. + uint64 winnersCounter; sint64 ticketCounter; sint64 ticketPrice; - // Per-user auto-purchase limits; 0 means unlimited. - uint16 maxAutoTicketsPerUser; // Contract balance above this cap is swept to the QHeart wallet after settlement. uint64 qheartHoldLimit; // Date stamp of the most recent draw; PULSE_DEFAULT_INIT_TIME is a bootstrap sentinel. uint32 lastDrawDateStamp; + // Per-user auto-purchase limits; 0 means unlimited. + uint16 maxAutoTicketsPerUser; uint8 devPercent; uint8 burnPercent; uint8 shareholdersPercent; uint8 rlShareholdersPercent; - uint8 qheartPercent; uint8 schedule; uint8 drawHour; EState currentState; - id teamAddress; - id qheartIssuer; - NextEpochData nextEpochData; - // Monotonic winner count used to rotate the winners ring buffer. - uint64 winnersCounter; protected: static void clearStateOnEndEpoch(PULSE& state) diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index c16fc58a8..563739a64 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -83,7 +83,6 @@ class PULSEChecker : public PULSE uint8 getBurnPercentInternal() const { return burnPercent; } uint8 getShareholdersPercentInternal() const { return shareholdersPercent; } uint8 getRLShareholdersPercentInternal() const { return rlShareholdersPercent; } - uint8 getQHeartPercentInternal() const { return qheartPercent; } const id& getTeamAddressInternal() const { return teamAddress; } const Array& getLastWinningDigits() const { return lastWinningDigits; } Ticket getTicket(uint64 index) const { return tickets.get(index); } @@ -418,7 +417,7 @@ class ContractTestingPulse : protected ContractTesting return output; } - PULSE::SetFees_output setFees(const id& invocator, uint8 dev, uint8 burn, uint8 shareholders, uint8 rlShareholders, uint8 qheart) + PULSE::SetFees_output setFees(const id& invocator, uint8 dev, uint8 burn, uint8 shareholders, uint8 rlShareholders) { ensureUserEnergy(invocator); PULSE::SetFees_input input{}; @@ -426,7 +425,6 @@ class ContractTestingPulse : protected ContractTesting input.burnPercent = burn; input.shareholdersPercent = shareholders; input.rlShareholdersPercent = rlShareholders; - input.qheartPercent = qheart; PULSE::SetFees_output output{}; if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_FEES, input, output, invocator, 0)) { @@ -644,7 +642,6 @@ TEST(ContractPulse_Private, NextEpochDataClearResetsFlagsAndValues) data.newDevPercent = 3; data.newBurnPercent = 4; data.newShareholdersPercent = 5; - data.newQHeartPercent = 6; data.newQHeartHoldLimit = 7; data.clear(); @@ -659,7 +656,6 @@ TEST(ContractPulse_Private, NextEpochDataClearResetsFlagsAndValues) EXPECT_EQ(data.newDevPercent, 0u); EXPECT_EQ(data.newBurnPercent, 0u); EXPECT_EQ(data.newShareholdersPercent, 0u); - EXPECT_EQ(data.newQHeartPercent, 0u); EXPECT_EQ(data.newQHeartHoldLimit, 0u); } @@ -679,7 +675,6 @@ TEST(ContractPulse_Private, NextEpochDataApplyUpdatesState) data.newDevPercent = 11; data.newBurnPercent = 22; data.newShareholdersPercent = 33; - data.newQHeartPercent = 4; data.newQHeartHoldLimit = 999; data.apply(*ctl.state()); @@ -689,7 +684,6 @@ TEST(ContractPulse_Private, NextEpochDataApplyUpdatesState) EXPECT_EQ(ctl.state()->getDevPercentInternal(), 11u); EXPECT_EQ(ctl.state()->getBurnPercentInternal(), 22u); EXPECT_EQ(ctl.state()->getShareholdersPercentInternal(), 33u); - EXPECT_EQ(ctl.state()->getQHeartPercentInternal(), 4u); EXPECT_EQ(ctl.state()->getQHeartHoldLimitInternal(), 999u); } @@ -900,7 +894,6 @@ TEST(ContractPulse_Public, GettersReturnDefaultsAfterInitialize) EXPECT_EQ(fees.burnPercent, PULSE_DEFAULT_BURN_PERCENT); EXPECT_EQ(fees.shareholdersPercent, PULSE_DEFAULT_SHAREHOLDERS_PERCENT); EXPECT_EQ(fees.rlShareholdersPercent, PULSE_DEFAULT_RL_SHAREHOLDERS_PERCENT); - EXPECT_EQ(fees.qheartPercent, PULSE_DEFAULT_QHEART_PERCENT); EXPECT_EQ(fees.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); const PULSE::GetWinningDigits_output& win = ctl.getWinningDigits(); @@ -958,22 +951,20 @@ TEST(ContractPulse_Public, SetDrawHourValidatesAndAppliesOnEndEpoch) TEST(ContractPulse_Public, SetFeesValidatesAndAppliesOnEndEpoch) { ContractTestingPulse ctl; - EXPECT_EQ(ctl.setFees(id::randomValue(), 1, 2, 3, 4, 5).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); - EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), 60, 60, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + EXPECT_EQ(ctl.setFees(id::randomValue(), 1, 2, 3, 4).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), 60, 60, 0, 0).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); - EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), 11, 22, 33, 6, 4).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), 11, 22, 33, 6).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.state()->getDevPercentInternal(), PULSE_DEFAULT_DEV_PERCENT); EXPECT_EQ(ctl.state()->getBurnPercentInternal(), PULSE_DEFAULT_BURN_PERCENT); EXPECT_EQ(ctl.state()->getShareholdersPercentInternal(), PULSE_DEFAULT_SHAREHOLDERS_PERCENT); EXPECT_EQ(ctl.state()->getRLShareholdersPercentInternal(), PULSE_DEFAULT_RL_SHAREHOLDERS_PERCENT); - EXPECT_EQ(ctl.state()->getQHeartPercentInternal(), PULSE_DEFAULT_QHEART_PERCENT); ctl.endEpoch(); EXPECT_EQ(ctl.state()->getDevPercentInternal(), 11u); EXPECT_EQ(ctl.state()->getBurnPercentInternal(), 22u); EXPECT_EQ(ctl.state()->getShareholdersPercentInternal(), 33u); EXPECT_EQ(ctl.state()->getRLShareholdersPercentInternal(), 6u); - EXPECT_EQ(ctl.state()->getQHeartPercentInternal(), 4u); } // Ensure hold-limit changes do not affect the current round. @@ -995,7 +986,7 @@ TEST(ContractPulse_Public, GettersReflectAppliedChanges) EXPECT_EQ(ctl.setPrice(ctl.state()->getQHeartIssuer(), 555).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.setSchedule(ctl.state()->getQHeartIssuer(), 0x7F).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.setDrawHour(ctl.state()->getQHeartIssuer(), 9).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), 11, 22, 33, 6, 4).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), 11, 22, 33, 6).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.setQHeartHoldLimit(ctl.state()->getQHeartIssuer(), 4321).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); ctl.endEpoch(); @@ -1011,7 +1002,6 @@ TEST(ContractPulse_Public, GettersReflectAppliedChanges) EXPECT_EQ(fees.burnPercent, 22u); EXPECT_EQ(fees.shareholdersPercent, 33u); EXPECT_EQ(fees.rlShareholdersPercent, 6u); - EXPECT_EQ(fees.qheartPercent, 4u); } // Prevent ticket purchases outside the selling window. @@ -1669,7 +1659,7 @@ TEST(ContractPulse_Public, GetWinnersReportsPaidTickets) ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); - EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), 0, 0, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), 0, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.setPrice(ctl.state()->getQHeartIssuer(), 1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); ctl.endEpoch(); @@ -1938,7 +1928,7 @@ TEST(ContractPulse_Gameplay, ProRataPayoutWhenBalanceInsufficient) ContractTestingPulse ctl; const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(2000000); - EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), 0, 0, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), 0, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.setPrice(ctl.state()->getQHeartIssuer(), 1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); ctl.endEpoch(); @@ -1985,8 +1975,8 @@ TEST(ContractPulse_Gameplay, ProRataPayoutWhenBalanceInsufficient) EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore - (expectedA + expectedB)); } -// Validate fee distribution to dev, shareholders, and QHeart wallet. -TEST(ContractPulse_Gameplay, FeesDistributedToDevShareholdersAndQHeartWallet) +// Validate fee distribution to dev and shareholders. +TEST(ContractPulse_Gameplay, FeesDistributedToDevAndShareholders) { ContractTestingPulse ctl; const id shareholder = id::randomValue(); @@ -1996,10 +1986,9 @@ TEST(ContractPulse_Gameplay, FeesDistributedToDevShareholdersAndQHeartWallet) static constexpr uint8 devPercent = 10; static constexpr uint8 burnPercent = 0; static constexpr uint8 shareholdersPercent = 10; - static constexpr uint8 qheartPercent = 10; const uint64 ticketPrice = static_cast(NUMBER_OF_COMPUTORS) * 10; - EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), devPercent, burnPercent, shareholdersPercent, 0, qheartPercent).returnCode, + EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), devPercent, burnPercent, shareholdersPercent, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.setPrice(ctl.state()->getQHeartIssuer(), ticketPrice).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); ctl.endEpoch(); @@ -2017,7 +2006,6 @@ TEST(ContractPulse_Gameplay, FeesDistributedToDevShareholdersAndQHeartWallet) const uint64 devBefore = ctl.qheartBalanceOf(devWallet); const uint64 shareholderBefore = ctl.qheartBalanceOf(shareholder); - const uint64 qheartWalletBefore = ctl.qheartBalanceOf(ctl.state()->getQHeartIssuer()); ctl.setDateTime(2025, 1, 10, 12); ctl.forceBeginTick(); @@ -2025,14 +2013,12 @@ TEST(ContractPulse_Gameplay, FeesDistributedToDevShareholdersAndQHeartWallet) const uint64 roundRevenue = ticketPrice; const uint64 expectedDev = (roundRevenue * devPercent) / 100; const uint64 expectedShareholders = (roundRevenue * shareholdersPercent) / 100; - const uint64 expectedQHeart = (roundRevenue * qheartPercent) / 100; const uint64 dividendPerShare = expectedShareholders / NUMBER_OF_COMPUTORS; const uint64 expectedShareholderGain = dividendPerShare * NUMBER_OF_COMPUTORS; EXPECT_EQ(expectedShareholderGain, expectedShareholders); EXPECT_EQ(ctl.qheartBalanceOf(devWallet), devBefore + expectedDev); EXPECT_EQ(ctl.qheartBalanceOf(shareholder), shareholderBefore + expectedShareholderGain); - EXPECT_EQ(ctl.qheartBalanceOf(ctl.state()->getQHeartIssuer()), qheartWalletBefore + expectedQHeart); } // Validate fee distribution to Random Lottery shareholders. @@ -2047,10 +2033,9 @@ TEST(ContractPulse_Gameplay, FeesDistributedToRLShareholders) static constexpr uint8 burnPercent = 0; static constexpr uint8 shareholdersPercent = 0; static constexpr uint8 rlShareholdersPercent = 10; - static constexpr uint8 qheartPercent = 0; constexpr uint64 ticketPrice = static_cast(NUMBER_OF_COMPUTORS) * 10ULL; - EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), devPercent, burnPercent, shareholdersPercent, rlShareholdersPercent, qheartPercent).returnCode, + EXPECT_EQ(ctl.setFees(ctl.state()->getQHeartIssuer(), devPercent, burnPercent, shareholdersPercent, rlShareholdersPercent).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.setPrice(ctl.state()->getQHeartIssuer(), ticketPrice).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); ctl.endEpoch(); @@ -2114,19 +2099,18 @@ TEST(ContractPulse_Gameplay, QHeartHoldLimitExcessTransferred) const uint64 burnAmount = (roundRevenue * fees.burnPercent) / 100; const uint64 shareholdersAmount = (roundRevenue * fees.shareholdersPercent) / 100; const uint64 rlShareholdersAmount = (roundRevenue * fees.rlShareholdersPercent) / 100; - const uint64 qheartAmount = (roundRevenue * fees.qheartPercent) / 100; const uint64 dividendPerShare = shareholdersAmount / NUMBER_OF_COMPUTORS; const uint64 shareholdersPaid = dividendPerShare * NUMBER_OF_COMPUTORS; const uint64 rlDividendPerShare = rlShareholdersAmount / NUMBER_OF_COMPUTORS; const uint64 rlShareholdersPaid = rlDividendPerShare * NUMBER_OF_COMPUTORS; - const uint64 feesTotal = devAmount + burnAmount + shareholdersPaid + rlShareholdersPaid + qheartAmount; + const uint64 feesTotal = devAmount + burnAmount + shareholdersPaid + rlShareholdersPaid; const uint64 balanceAfterFees = contractBefore - feesTotal; ASSERT_GE(balanceAfterFees, prize); const uint64 balanceAfterPrizes = balanceAfterFees - prize; const uint64 excess = (balanceAfterPrizes > holdLimit) ? (balanceAfterPrizes - holdLimit) : 0; const uint64 expectedContractAfter = balanceAfterPrizes - excess; - const uint64 expectedWalletAfter = walletBefore + qheartAmount + excess; + const uint64 expectedWalletAfter = walletBefore + excess; ctl.setDateTime(2025, 1, 10, 12); ctl.forceBeginTick(); From 679d7ae5dc2a0f01fbc98108db0af09ea0fb4cd1 Mon Sep 17 00:00:00 2001 From: N-010 Date: Tue, 17 Mar 2026 23:48:16 +0300 Subject: [PATCH 64/77] Adds PRE_ACQUIRE_SHARES --- src/contracts/Pulse.h | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 2f6332b81..1fc741f91 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -688,6 +688,8 @@ struct PULSE : public ContractBase struct BEGIN_EPOCH_locals { + QX::Fees_input feesInput; + QX::Fees_output feesOutput; ProcessAutoTickets_input autoTicketsInput; ProcessAutoTickets_output autoTicketsOutput; }; @@ -722,10 +724,10 @@ struct PULSE : public ContractBase INITIALIZE() { - state.mut().teamAddress = ID(_R, _O, _J, _V, _A, _E, _M, _F, _B, _X, _X, _Y, _N, _G, _A, _U, _A, _U, _I, _I, _X, _L, _B, _U, _P, _D, _H, _C, _D, _P, - _E, _S, _Y, _Z, _O, _V, _W, _U, _Y, _E, _C, _B, _Q, _V, _Z, _R, _F, _T, _K, _A, _G, _S, _H, _T, _N, _A); - state.mut().qheartIssuer = ID(_S, _S, _G, _X, _S, _L, _S, _X, _F, _E, _J, _O, _O, _B, _T, _Z, _W, _V, _D, _S, _R, _C, _E, _F, _G, _X, _N, _D, _Y, - _U, _V, _D, _X, _M, _Q, _A, _L, _X, _L, _B, _X, _G, _D, _C, _R, _X, _T, _K, _F, _Z, _I, _O, _T, _G, _Z, _F); + state.mut().teamAddress = ID(_R, _O, _J, _V, _A, _E, _M, _F, _B, _X, _X, _Y, _N, _G, _A, _U, _A, _U, _I, _I, _X, _L, _B, _U, _P, _D, _H, _C, + _D, _P, _E, _S, _Y, _Z, _O, _V, _W, _U, _Y, _E, _C, _B, _Q, _V, _Z, _R, _F, _T, _K, _A, _G, _S, _H, _T, _N, _A); + state.mut().qheartIssuer = ID(_S, _S, _G, _X, _S, _L, _S, _X, _F, _E, _J, _O, _O, _B, _T, _Z, _W, _V, _D, _S, _R, _C, _E, _F, _G, _X, _N, _D, + _Y, _U, _V, _D, _X, _M, _Q, _A, _L, _X, _L, _B, _X, _G, _D, _C, _R, _X, _T, _K, _F, _Z, _I, _O, _T, _G, _Z, _F); state.mut().ticketPrice = PULSE_TICKET_PRICE_DEFAULT; state.mut().devPercent = PULSE_DEFAULT_DEV_PERCENT; @@ -834,6 +836,12 @@ struct PULSE : public ContractBase } } + PRE_ACQUIRE_SHARES() + { + output.requestedFee = 0; + output.allowTransfer = true; + } + // Returns current ticket price in QHeart units. PUBLIC_FUNCTION(GetTicketPrice) { output.ticketPrice = state.get().ticketPrice; } // Returns current draw schedule bitmask. @@ -1132,8 +1140,8 @@ struct PULSE : public ContractBase return; } - locals.transferResult = - qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, state.get().qheartIssuer, SELF, SELF, locals.withdrawAmount, qpi.invocator()); + locals.transferResult = qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, state.get().qheartIssuer, SELF, SELF, + locals.withdrawAmount, qpi.invocator()); if (locals.transferResult < 0) { output.returnCode = toReturnCode(EReturnCode::TRANSFER_FROM_PULSE_FAILED); @@ -1432,7 +1440,8 @@ struct PULSE : public ContractBase if (locals.devAmount > 0) { - qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, state.get().qheartIssuer, SELF, SELF, locals.devAmount, state.get().teamAddress); + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, state.get().qheartIssuer, SELF, SELF, locals.devAmount, + state.get().teamAddress); } if (locals.shareholdersAmount > 0) { @@ -1487,8 +1496,8 @@ struct PULSE : public ContractBase if (locals.prize > 0 && locals.balance >= locals.prize) { - qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, state.get().qheartIssuer, SELF, SELF, static_cast(locals.prize), - locals.ticket.player); + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, state.get().qheartIssuer, SELF, SELF, + static_cast(locals.prize), locals.ticket.player); locals.balance -= locals.prize; locals.fillWinnersInfoInput.winnerAddress = locals.ticket.player; @@ -1697,9 +1706,15 @@ struct PULSE : public ContractBase state.mut().currentState = bEnable ? state.get().currentState | EState::SELLING : state.get().currentState & ~EState::SELLING; } - static bool isSellingOpen(const QPI::ContractState& state) { return (state.get().currentState & EState::SELLING) != 0; } + static bool isSellingOpen(const QPI::ContractState& state) + { + return (state.get().currentState & EState::SELLING) != 0; + } - static void getWinnerCounter(const QPI::ContractState& state, uint64& outCounter) { outCounter = mod(state.get().winnersCounter, state.get().winners.capacity()); } + static void getWinnerCounter(const QPI::ContractState& state, uint64& outCounter) + { + outCounter = mod(state.get().winnersCounter, state.get().winners.capacity()); + } static uint64 getLeftAlignedReward(const QPI::ContractState& state, uint8 matches) { @@ -1729,8 +1744,8 @@ struct PULSE : public ContractBase } } - static uint64 computePrize(const QPI::ContractState& state, const Ticket& ticket, const Array& winningDigits, - ComputePrize_locals& locals) + static uint64 computePrize(const QPI::ContractState& state, const Ticket& ticket, + const Array& winningDigits, ComputePrize_locals& locals) { setMemory(locals, 0); From b14a270ea8a21b1491c7b05c860f74082efca06d Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 19 Mar 2026 13:15:12 +0300 Subject: [PATCH 65/77] Refactor Pulse contract: merge GetSchedule and GetDrawHour into GetRoundState; introduce new functions for ticket, prize, and round handling; update tests accordingly. --- src/contracts/Pulse.h | 221 ++++++++++++++++++++++++++++------------ test/contract_pulse.cpp | 32 +++--- 2 files changed, 168 insertions(+), 85 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 1fc741f91..447dbfa4b 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -452,22 +452,6 @@ struct PULSE : public ContractBase uint64 ticketPrice; }; - struct GetSchedule_input - { - }; - struct GetSchedule_output - { - uint8 schedule; - }; - - struct GetDrawHour_input - { - }; - struct GetDrawHour_output - { - uint8 drawHour; - }; - struct GetFees_input { }; @@ -512,6 +496,51 @@ struct PULSE : public ContractBase uint64 balance; }; + struct GetPlayers_input + { + }; + struct GetPlayers_output + { + Array players; + uint8 returnCode; + }; + + struct GetPrizeTable_input + { + }; + struct GetPrizeTable_output + { + Array leftAlignedRewards; + Array anyPositionRewards; + uint64 ticketPrice; + uint8 returnCode; + }; + struct GetPrizeTable_locals + { + uint8 matches; + }; + + struct GetRoundState_input + { + }; + struct GetRoundState_output + { + uint32 epoch; + uint32 lastDrawDateStamp; + uint16 ticketCounter; + uint16 maxPlayers; + uint16 slotsLeft; + uint8 currentState; + uint8 drawHour; + uint8 schedule; + bit sellingOpen; + uint8 returnCode; + }; + struct GetRoundState_locals + { + sint64 slotsLeft; + }; + struct FillWinnersInfo_input { id winnerAddress; @@ -698,8 +727,6 @@ struct PULSE : public ContractBase REGISTER_USER_FUNCTIONS_AND_PROCEDURES() { REGISTER_USER_FUNCTION(GetTicketPrice, 1); - REGISTER_USER_FUNCTION(GetSchedule, 2); - REGISTER_USER_FUNCTION(GetDrawHour, 3); REGISTER_USER_FUNCTION(GetFees, 4); REGISTER_USER_FUNCTION(GetQHeartHoldLimit, 5); REGISTER_USER_FUNCTION(GetQHeartWallet, 6); @@ -708,6 +735,10 @@ struct PULSE : public ContractBase REGISTER_USER_FUNCTION(GetWinners, 9); REGISTER_USER_FUNCTION(GetAutoParticipation, 10); REGISTER_USER_FUNCTION(GetAutoStats, 11); + REGISTER_USER_FUNCTION(ValidateDigits, 12); + REGISTER_USER_FUNCTION(GetPlayers, 13); + REGISTER_USER_FUNCTION(GetPrizeTable, 14); + REGISTER_USER_FUNCTION(GetRoundState, 15); REGISTER_USER_PROCEDURE(BuyTicket, 1); REGISTER_USER_PROCEDURE(SetPrice, 2); @@ -842,20 +873,35 @@ struct PULSE : public ContractBase output.allowTransfer = true; } - // Returns current ticket price in QHeart units. + /** + * Validates a ticket payload without changing contract state. + * @param digits Candidate ticket digits. + * @return `isValid = true` when every digit is within the supported range [0..9]. + */ + PUBLIC_FUNCTION_WITH_LOCALS(ValidateDigits) + { + output.isValid = true; + for (locals.idx = 0; locals.idx < PULSE_PLAYER_DIGITS; ++locals.idx) + { + locals.value = input.digits.get(locals.idx); + if (locals.value > PULSE_MAX_DIGIT) + { + output.isValid = false; + return; + } + } + } + + /** Returns the current ticket price in QHeart units. */ PUBLIC_FUNCTION(GetTicketPrice) { output.ticketPrice = state.get().ticketPrice; } - // Returns current draw schedule bitmask. - PUBLIC_FUNCTION(GetSchedule) { output.schedule = state.get().schedule; } - // Returns draw hour in UTC. - PUBLIC_FUNCTION(GetDrawHour) { output.drawHour = state.get().drawHour; } - // Returns QHeart balance cap retained by the contract. + /** Returns the QHeart balance cap retained by the contract. */ PUBLIC_FUNCTION(GetQHeartHoldLimit) { output.qheartHoldLimit = state.get().qheartHoldLimit; } - // Returns the designated QHeart issuer wallet. + /** Returns the designated QHeart issuer wallet. */ PUBLIC_FUNCTION(GetQHeartWallet) { output.wallet = state.get().qheartIssuer; } - // Returns digits from the last settled draw. + /** Returns the digits from the last settled draw. */ PUBLIC_FUNCTION(GetWinningDigits) { output.digits = state.get().lastWinningDigits; } - // Returns current fee split configuration. + /** Returns the current fee split configuration. */ PUBLIC_FUNCTION(GetFees) { output.devPercent = state.get().devPercent; @@ -865,13 +911,58 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } - // Returns contract QHeart balance held in the Pulse wallet. + /** Returns the contract QHeart balance held in the Pulse wallet. */ PUBLIC_FUNCTION(GetBalance) { output.balance = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, state.get().qheartIssuer, SELF, SELF, SELF_INDEX, SELF_INDEX); } - // Returns the winners ring buffer and total winners counter. + /** + * Returns the current round ticket snapshot. + * @return Ticket entries already allocated for the ongoing round; unused trailing slots remain zeroed. + */ + PUBLIC_FUNCTION(GetPlayers) + { + output.players = state.get().tickets; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + /** + * Returns the payout table derived from the current ticket price. + * @return Reward arrays indexed by match count for left-aligned and any-position payouts, plus the active ticket price. + */ + PUBLIC_FUNCTION_WITH_LOCALS(GetPrizeTable) + { + output.ticketPrice = state.get().ticketPrice; + for (locals.matches = 0; locals.matches <= PULSE_PLAYER_DIGITS; ++locals.matches) + { + output.leftAlignedRewards.set(locals.matches, getLeftAlignedReward(state, locals.matches)); + output.anyPositionRewards.set(locals.matches, getAnyPositionReward(state, locals.matches)); + } + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + /** + * Returns the current round lifecycle state and sale progress. + * @return Current epoch, last processed draw date stamp, ticket counters, runtime state flags, active schedule, draw hour, and selling status. + */ + PUBLIC_FUNCTION_WITH_LOCALS(GetRoundState) + { + locals.slotsLeft = getSlotsLeft(state); + + output.epoch = qpi.epoch(); + output.lastDrawDateStamp = state.get().lastDrawDateStamp; + output.ticketCounter = static_cast(min(static_cast(max(state.get().ticketCounter, 0LL)), state.get().tickets.capacity())); + output.maxPlayers = static_cast(state.get().tickets.capacity()); + output.slotsLeft = static_cast(min(static_cast(max(locals.slotsLeft, 0LL)), state.get().tickets.capacity())); + output.currentState = static_cast(state.get().currentState); + output.drawHour = state.get().drawHour; + output.schedule = state.get().schedule; + output.sellingOpen = isSellingOpen(state); + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + /** Returns the winners ring buffer snapshot and the current insertion counter. */ PUBLIC_FUNCTION(GetWinners) { output.winners = state.get().winners; @@ -879,8 +970,10 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } - /// Returns auto-participation settings for the invocator. - /// @return Current deposit, config fields, and status code. + /** + * Returns auto-participation settings for the invocator. + * @return Current reserved deposit, desired ticket count, and status code for the invocator. + */ PUBLIC_FUNCTION_WITH_LOCALS(GetAutoParticipation) { if (!state.get().autoParticipants.get(qpi.invocator(), locals.entry)) @@ -895,8 +988,10 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } - /// Returns global auto-participation limits and counters. - /// @return Current counters, limits, and status code. + /** + * Returns currently exposed global auto-participation counters. + * @return Participant count, max tickets per user, and status code. + */ PUBLIC_FUNCTION(GetAutoStats) { output.autoParticipantsCounter = static_cast(state.get().autoParticipants.population()); @@ -904,7 +999,7 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } - // Schedules a new ticket price for the next epoch (owner-only). + /** Schedules a new ticket price for the next epoch (owner-only). */ PUBLIC_PROCEDURE(SetPrice) { if (qpi.invocationReward() > 0) @@ -929,7 +1024,7 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } - // Schedules a new draw schedule bitmask for the next epoch (owner-only). + /** Schedules a new draw schedule bitmask for the next epoch (owner-only). */ PUBLIC_PROCEDURE(SetSchedule) { if (qpi.invocationReward() > 0) @@ -954,7 +1049,7 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } - // Schedules a new draw hour in UTC for the next epoch (owner-only). + /** Schedules a new draw hour in UTC for the next epoch (owner-only). */ PUBLIC_PROCEDURE(SetDrawHour) { if (qpi.invocationReward() > 0) @@ -979,7 +1074,7 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } - // Schedules new fee splits for the next epoch (owner-only). + /** Schedules new fee splits for the next epoch (owner-only). */ PUBLIC_PROCEDURE(SetFees) { if (qpi.invocationReward() > 0) @@ -1008,7 +1103,7 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } - // Schedules a new QHeart hold limit for the next epoch (owner-only). + /** Schedules a new QHeart hold limit for the next epoch (owner-only). */ PUBLIC_PROCEDURE(SetQHeartHoldLimit) { if (qpi.invocationReward() > 0) @@ -1027,7 +1122,8 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } - /** Deposits QHeart into the contract for automatic ticket purchases. + /** + * Deposits QHeart into the contract for automatic ticket purchases. * @param amount QHeart amount to reserve for auto participation. * @param desiredTickets Number of tickets to buy per draw. * @param buyNow When true, tries to buy immediately if selling is open. @@ -1110,9 +1206,11 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } - /// Withdraws QHeart from the invocator's auto-participation deposit. - /// @param amount QHeart amount to withdraw; 0 withdraws the full deposit. - /// @return Status code describing the result. + /** + * Withdraws QHeart from the invocator's auto-participation deposit. + * @param amount QHeart amount to withdraw; 0 withdraws the full deposit. + * @return Status code describing whether the requested amount was transferred back. + */ PUBLIC_PROCEDURE_WITH_LOCALS(WithdrawAutoParticipation) { if (qpi.invocationReward() > 0) @@ -1163,9 +1261,11 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } - /// Sets auto-participation config for the invocator. - /// @param desiredTickets Signed: -1 ignore, >0 set new value. - /// @return Status code describing the result. + /** + * Updates the invocator's auto-participation configuration. + * @param desiredTickets `-1` keeps the current value; values greater than `0` replace the desired ticket count. + * @return Status code describing whether the configuration was accepted. + */ PUBLIC_PROCEDURE_WITH_LOCALS(SetAutoConfig) { if (qpi.invocationReward() > 0) @@ -1202,10 +1302,11 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } - /// Sets auto-participation limits (owner-only). - /// @param maxTicketsPerUser Max tickets per user; 0 disables the limit. - /// @param maxDepositPerUser Max deposit per user; 0 disables the limit. - /// @return Status code describing the result. + /** + * Sets the global auto-participation ticket limit (owner-only). + * @param maxTicketsPerUser Maximum tickets to auto-buy per user; `0` disables the limit. + * @return Status code describing whether the new limit was accepted. + */ PUBLIC_PROCEDURE_WITH_LOCALS(SetAutoLimits) { if (qpi.invocationReward() > 0) @@ -1245,7 +1346,7 @@ struct PULSE : public ContractBase } } - // Buys a single ticket; transfers ticket price from invocator. + /** Buys a single ticket and transfers the ticket price from the invocator. */ PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicket) { if (qpi.invocationReward() > 0) @@ -1298,7 +1399,7 @@ struct PULSE : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } - // Buys multiple random tickets; transfers total price from invocator. + /** Buys multiple random tickets and transfers the total price from the invocator. */ PUBLIC_PROCEDURE_WITH_LOCALS(BuyRandomTickets) { if (qpi.invocationReward() > 0) @@ -1399,20 +1500,6 @@ struct PULSE : public ContractBase state.mut().autoParticipants.cleanupIfNeeded(PULSE_CLEANUP_THRESHOLD); } - PRIVATE_FUNCTION_WITH_LOCALS(ValidateDigits) - { - output.isValid = true; - for (locals.idx = 0; locals.idx < PULSE_PLAYER_DIGITS; ++locals.idx) - { - locals.value = input.digits.get(locals.idx); - if (locals.value > PULSE_MAX_DIGIT) - { - output.isValid = false; - return; - } - } - } - PRIVATE_FUNCTION_WITH_LOCALS(GetRandomDigits) { // Derive each digit independently to avoid shared PRNG state. @@ -1662,13 +1749,13 @@ struct PULSE : public ContractBase }; public: - // Encodes YYYY/MM/DD into a compact sortable date stamp. + /** Encodes YYYY/MM/DD into a compact sortable date stamp. */ static void makeDateStamp(uint8 year, uint8 month, uint8 day, uint32& res) { res = static_cast(year << 9 | month << 5 | day); } template static constexpr T min(const T& a, const T& b) { return (a < b) ? a : b; } template static constexpr T max(const T& a, const T& b) { return a > b ? a : b; } - // Per-index mix to deterministically expand a single seed. + /** Applies a per-index mix to deterministically expand a single seed. */ static void deriveOne(const uint64& r, const uint64& idx, uint64& outValue) { mix64(r + 0x9e3779b97f4a7c15ULL * (idx + 1), outValue); } static void mix64(const uint64& x, uint64& outValue) diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index 7215d8b44..de2e55e5a 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -18,8 +18,6 @@ constexpr uint16 PULSE_PROCEDURE_SET_AUTO_CONFIG = 10; constexpr uint16 PULSE_PROCEDURE_SET_AUTO_LIMITS = 11; constexpr uint16 PULSE_FUNCTION_GET_TICKET_PRICE = 1; -constexpr uint16 PULSE_FUNCTION_GET_SCHEDULE = 2; -constexpr uint16 PULSE_FUNCTION_GET_DRAW_HOUR = 3; constexpr uint16 PULSE_FUNCTION_GET_FEES = 4; constexpr uint16 PULSE_FUNCTION_GET_QHEART_HOLD_LIMIT = 5; constexpr uint16 PULSE_FUNCTION_GET_QHEART_WALLET = 6; @@ -28,6 +26,10 @@ constexpr uint16 PULSE_FUNCTION_GET_BALANCE = 8; constexpr uint16 PULSE_FUNCTION_GET_WINNERS = 9; constexpr uint16 PULSE_FUNCTION_GET_AUTO_PARTICIPATION = 10; constexpr uint16 PULSE_FUNCTION_GET_AUTO_STATS = 11; +constexpr uint16 PULSE_FUNCTION_VALIDATE_DIGITS = 12; +constexpr uint16 PULSE_FUNCTION_GET_PLAYERS = 13; +constexpr uint16 PULSE_FUNCTION_GET_PRIZE_TABLE = 14; +constexpr uint16 PULSE_FUNCTION_GET_ROUND_STATE = 15; namespace { @@ -227,19 +229,11 @@ class ContractTestingPulse : protected ContractTesting return output; } - PULSE::GetSchedule_output getSchedule() + PULSE::GetRoundState_output getRoundState() { - PULSE::GetSchedule_input input{}; - PULSE::GetSchedule_output output{}; - callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_SCHEDULE, input, output); - return output; - } - - PULSE::GetDrawHour_output getDrawHour() - { - PULSE::GetDrawHour_input input{}; - PULSE::GetDrawHour_output output{}; - callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_DRAW_HOUR, input, output); + PULSE::GetRoundState_input input{}; + PULSE::GetRoundState_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_ROUND_STATE, input, output); return output; } @@ -892,8 +886,9 @@ TEST(ContractPulse_Public, GettersReturnDefaultsAfterInitialize) { ContractTestingPulse ctl; EXPECT_EQ(ctl.getTicketPrice().ticketPrice, PULSE_TICKET_PRICE_DEFAULT); - EXPECT_EQ(ctl.getSchedule().schedule, PULSE_DEFAULT_SCHEDULE); - EXPECT_EQ(ctl.getDrawHour().drawHour, PULSE_DEFAULT_DRAW_HOUR); + const PULSE::GetRoundState_output roundState = ctl.getRoundState(); + EXPECT_EQ(roundState.schedule, PULSE_DEFAULT_SCHEDULE); + EXPECT_EQ(roundState.drawHour, PULSE_DEFAULT_DRAW_HOUR); EXPECT_EQ(ctl.getQHeartHoldLimit().qheartHoldLimit, PULSE_DEFAULT_QHEART_HOLD_LIMIT); const PULSE::GetFees_output& fees = ctl.getFees(); @@ -999,8 +994,9 @@ TEST(ContractPulse_Public, GettersReflectAppliedChanges) ctl.endEpoch(); EXPECT_EQ(ctl.getTicketPrice().ticketPrice, 555u); - EXPECT_EQ(ctl.getSchedule().schedule, 0x7Fu); - EXPECT_EQ(ctl.getDrawHour().drawHour, 9u); + const PULSE::GetRoundState_output roundState = ctl.getRoundState(); + EXPECT_EQ(roundState.schedule, 0x7Fu); + EXPECT_EQ(roundState.drawHour, 9u); EXPECT_EQ(ctl.getQHeartHoldLimit().qheartHoldLimit, 4321u); const PULSE::GetFees_output fees = ctl.getFees(); From a9a4a59e7aae50b2cefd3313a424b085b22ba8a3 Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 19 Mar 2026 14:42:46 +0300 Subject: [PATCH 66/77] Refactor `GetAutoStats` function: add `HashMapConverter`, expand output structure, and introduce ticket clamping logic. --- src/contracts/Pulse.h | 74 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 447dbfa4b..611fc3d11 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -44,6 +44,28 @@ struct PULSE2 struct PULSE : public ContractBase { public: + template + struct HashMapConverter + { + void convert(const HashMap& hasMap, Array& array) + { + arrayIndex = 0; + setMemory(array, 0); + + hasMapIndex = hasMap.nextElementIndex(NULL_INDEX); + while (hasMapIndex != NULL_INDEX) + { + array.set(arrayIndex, hasMap.value(hasMapIndex)); + hasMapIndex = hasMap.nextElementIndex(hasMapIndex); + ++arrayIndex; + } + } + + private: + sint64 hasMapIndex; + uint64 arrayIndex; + }; + // Bitmask for runtime state flags. enum class EState : uint8 { @@ -53,8 +75,16 @@ struct PULSE : public ContractBase friend EState operator|(const EState& a, const EState& b) { return static_cast(static_cast(a) | static_cast(b)); } friend EState operator&(const EState& a, const EState& b) { return static_cast(static_cast(a) & static_cast(b)); } friend EState operator~(const EState& a) { return static_cast(~static_cast(a)); } - template friend bool operator==(const EState& a, const T& b) { return static_cast(a) == b; } - template friend bool operator!=(const EState& a, const T& b) { return !(a == b); } + template + friend bool operator==(const EState& a, const T& b) + { + return static_cast(a) == b; + } + template + friend bool operator!=(const EState& a, const T& b) + { + return !(a == b); + } // Public return codes for user procedures/functions. enum class EReturnCode : uint8 @@ -357,12 +387,16 @@ struct PULSE : public ContractBase }; struct GetAutoStats_output { - uint16 autoParticipantsCounter; - uint64 totalAutoDeposits; - sint64 autoStartIndex; + Array participants; + uint16 maxAutoParticipants; uint16 maxAutoTicketsPerUser; + uint16 roundSlotsLeft; uint8 returnCode; }; + struct GetAutoStats_locals + { + HashMapConverter converter; + }; struct DepositAutoParticipation_input { @@ -989,13 +1023,18 @@ struct PULSE : public ContractBase } /** - * Returns currently exposed global auto-participation counters. - * @return Participant count, max tickets per user, and status code. + * Returns the current auto-participation roster and shared limits. + * @return All registered auto participants, the participant capacity, the per-user auto-ticket limit, remaining slots in the current round, and + * the status code. */ - PUBLIC_FUNCTION(GetAutoStats) + PUBLIC_FUNCTION_WITH_LOCALS(GetAutoStats) { - output.autoParticipantsCounter = static_cast(state.get().autoParticipants.population()); + locals.converter.convert(state.get().autoParticipants, output.participants); + + output.maxAutoParticipants = static_cast(state.get().autoParticipants.capacity()); output.maxAutoTicketsPerUser = state.get().maxAutoTicketsPerUser; + + output.roundSlotsLeft = clampPublicTicketCount(state, getSlotsLeft(state)); output.returnCode = toReturnCode(EReturnCode::SUCCESS); } @@ -1752,8 +1791,16 @@ struct PULSE : public ContractBase /** Encodes YYYY/MM/DD into a compact sortable date stamp. */ static void makeDateStamp(uint8 year, uint8 month, uint8 day, uint32& res) { res = static_cast(year << 9 | month << 5 | day); } - template static constexpr T min(const T& a, const T& b) { return (a < b) ? a : b; } - template static constexpr T max(const T& a, const T& b) { return a > b ? a : b; } + template + static constexpr T min(const T& a, const T& b) + { + return (a < b) ? a : b; + } + template + static constexpr T max(const T& a, const T& b) + { + return a > b ? a : b; + } /** Applies a per-index mix to deterministically expand a single seed. */ static void deriveOne(const uint64& r, const uint64& idx, uint64& outValue) { mix64(r + 0x9e3779b97f4a7c15ULL * (idx + 1), outValue); } @@ -1866,4 +1913,9 @@ struct PULSE : public ContractBase locals.prize = max(locals.leftAlignedReward, locals.anyPositionReward); return locals.prize; } + + static uint16 clampPublicTicketCount(const QPI::ContractState& state, sint64 value) + { + return static_cast(min(static_cast(max(value, 0LL)), state.get().tickets.capacity())); + } }; From 95cc26e4b7ef721dc3d5bc65d4176ae8c5b2b0b7 Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 19 Mar 2026 14:49:15 +0300 Subject: [PATCH 67/77] Add utility functions to test suite: `countAutoParticipants` and `sumAutoDeposits`. Update `GetAutoStats` test for expanded participant roster and shared state verification. --- test/contract_pulse.cpp | 52 +++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index de2e55e5a..3c34d598b 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -1,7 +1,6 @@ #define NO_UEFI #include "contract_testing.h" - #include // Procedure/function indices (must match REGISTER_USER_FUNCTIONS_AND_PROCEDURES in `src/contracts/Pulse.h`). @@ -69,16 +68,47 @@ namespace EXPECT_LE(v, PULSE_MAX_DIGIT); } } + + uint32 countAutoParticipants(const PULSE::GetAutoStats_output& stats) + { + uint32 count = 0; + for (uint64 i = 0; i < stats.participants.capacity(); ++i) + { + if (stats.participants.get(i).player != id::zero()) + { + ++count; + } + } + + return count; + } + + uint64 sumAutoDeposits(const PULSE::GetAutoStats_output& stats) + { + uint64 totalDeposits = 0; + for (uint64 i = 0; i < stats.participants.capacity(); ++i) + { + const PULSE::AutoParticipant& participant = stats.participants.get(i); + if (participant.deposit > 0) + { + totalDeposits += static_cast(participant.deposit); + } + } + + return totalDeposits; + } } // namespace // Test helper class exposing internal state class PULSEChecker : public PULSE, public PULSE::StateData { public: - const QPI::ContractState& asState() const { + const QPI::ContractState& asState() const + { return *reinterpret_cast*>(static_cast(this)); } - QPI::ContractState& asMutState() { + QPI::ContractState& asMutState() + { return *reinterpret_cast*>(static_cast(this)); } @@ -1596,7 +1626,8 @@ TEST(ContractPulse_Public, SetAutoLimitsGuardsAccessAndValidates) { ContractTestingPulse ctl; EXPECT_EQ(ctl.setAutoLimits(id::randomValue(), 10).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); - EXPECT_EQ(ctl.setAutoLimits(ctl.state()->getQHeartIssuer(), PULSE_MAX_NUMBER_OF_PLAYERS + 1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setAutoLimits(ctl.state()->getQHeartIssuer(), PULSE_MAX_NUMBER_OF_PLAYERS + 1).returnCode, + static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(static_cast(ctl.getAutoStats().maxAutoTicketsPerUser), static_cast(PULSE_MAX_NUMBER_OF_PLAYERS)); EXPECT_EQ(ctl.setAutoLimits(ctl.state()->getQHeartIssuer(), 5).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); @@ -1617,8 +1648,8 @@ TEST(ContractPulse_Public, SetAutoLimitsAllowsDisabling) EXPECT_EQ(static_cast(stats.maxAutoTicketsPerUser), 0u); } -// Report auto participation counts through the public stats API. -TEST(ContractPulse_Public, GetAutoStatsReportsParticipantCount) +// Report auto participation roster and shared limits through the stats API. +TEST(ContractPulse_Public, GetAutoStatsReportsParticipantRosterAndSharedState) { ContractTestingPulse ctl; const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); @@ -1629,12 +1660,19 @@ TEST(ContractPulse_Public, GetAutoStatsReportsParticipantCount) ctl.transferQHeart(issuance, userA, ticketPrice); ctl.transferQHeart(issuance, userB, ticketPrice); + EXPECT_EQ(ctl.setAutoLimits(ctl.state()->getQHeartIssuer(), 4).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.depositAutoParticipation(userA, ticketPrice, 1, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.depositAutoParticipation(userB, ticketPrice, 1, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + ctl.state()->setTicketCounter(7); + ctl.state()->forceSelling(true); const PULSE::GetAutoStats_output stats = ctl.getAutoStats(); EXPECT_EQ(stats.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); - EXPECT_EQ(static_cast(stats.autoParticipantsCounter), 2u); + EXPECT_EQ(countAutoParticipants(stats), 2u); + EXPECT_EQ(sumAutoDeposits(stats), ticketPrice * 2); + EXPECT_EQ(static_cast(stats.maxAutoParticipants), static_cast(PULSE_MAX_NUMBER_OF_AUTO_PARTICIPANTS)); + EXPECT_EQ(static_cast(stats.maxAutoTicketsPerUser), 4u); + EXPECT_EQ(static_cast(stats.roundSlotsLeft), static_cast(PULSE_MAX_NUMBER_OF_PLAYERS - 7)); } // Ensure balance getter reflects actual QHeart wallet holdings. From 8f2686a308830bd744ebdc90bc4cad6552d441c2 Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 19 Mar 2026 17:00:58 +0300 Subject: [PATCH 68/77] Add GetPlayerBalance function --- src/contracts/Pulse.h | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 611fc3d11..64e438339 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -486,6 +486,15 @@ struct PULSE : public ContractBase uint64 ticketPrice; }; + struct GetPlayerBalance_input + { + }; + struct GetPlayerBalance_output + { + uint64 balance; + uint8 returnCode; + }; + struct GetFees_input { }; @@ -761,6 +770,7 @@ struct PULSE : public ContractBase REGISTER_USER_FUNCTIONS_AND_PROCEDURES() { REGISTER_USER_FUNCTION(GetTicketPrice, 1); + REGISTER_USER_FUNCTION(GetPlayerBalance, 2); REGISTER_USER_FUNCTION(GetFees, 4); REGISTER_USER_FUNCTION(GetQHeartHoldLimit, 5); REGISTER_USER_FUNCTION(GetQHeartWallet, 6); @@ -928,6 +938,14 @@ struct PULSE : public ContractBase /** Returns the current ticket price in QHeart units. */ PUBLIC_FUNCTION(GetTicketPrice) { output.ticketPrice = state.get().ticketPrice; } + + PUBLIC_FUNCTION(GetPlayerBalance) + { + output.balance = + qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, state.get().qheartIssuer, qpi.invocator(), qpi.invocator(), SELF_INDEX, SELF_INDEX); + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + /** Returns the QHeart balance cap retained by the contract. */ PUBLIC_FUNCTION(GetQHeartHoldLimit) { output.qheartHoldLimit = state.get().qheartHoldLimit; } /** Returns the designated QHeart issuer wallet. */ From ad2e759cc12e94d4134ff29b02254b24193ec044 Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 19 Mar 2026 17:31:21 +0300 Subject: [PATCH 69/77] Update `GetPlayerBalance`: include `player` input parameter for share calculation. --- src/contracts/Pulse.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 64e438339..6bc774cfe 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -488,6 +488,7 @@ struct PULSE : public ContractBase struct GetPlayerBalance_input { + id player; }; struct GetPlayerBalance_output { @@ -942,7 +943,7 @@ struct PULSE : public ContractBase PUBLIC_FUNCTION(GetPlayerBalance) { output.balance = - qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, state.get().qheartIssuer, qpi.invocator(), qpi.invocator(), SELF_INDEX, SELF_INDEX); + qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, state.get().qheartIssuer, input.player, input.player, SELF_INDEX, SELF_INDEX); output.returnCode = toReturnCode(EReturnCode::SUCCESS); } From 6676fb46f8a841efd40e08607dce61e50f5e8e2d Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 19 Mar 2026 18:12:56 +0300 Subject: [PATCH 70/77] Update `GetAutoParticipation`: replace `invocator()` with explicit `player` input parameter for clarity and consistency. --- src/contracts/Pulse.h | 3 ++- test/contract_pulse.cpp | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 6bc774cfe..2b6e2ed9b 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -370,6 +370,7 @@ struct PULSE : public ContractBase struct GetAutoParticipation_input { + id player; }; struct GetAutoParticipation_output { @@ -1029,7 +1030,7 @@ struct PULSE : public ContractBase */ PUBLIC_FUNCTION_WITH_LOCALS(GetAutoParticipation) { - if (!state.get().autoParticipants.get(qpi.invocator(), locals.entry)) + if (!state.get().autoParticipants.get(input.player, locals.entry)) { output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); return; diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index 3c34d598b..8bbbeae38 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -207,7 +207,7 @@ class PULSEChecker : public PULSE, public PULSE::StateData GetAutoParticipation_output callGetAutoParticipation(const QPI::QpiContextFunctionCall& qpi) const { - GetAutoParticipation_input input{}; + GetAutoParticipation_input input{qpi.invocator()}; GetAutoParticipation_output output{}; GetAutoParticipation_locals locals{}; GetAutoParticipation(qpi, asState(), input, output, locals); From be4e2d04d5c1de0b7eae9b62970ed433a58b6bb0 Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 19 Mar 2026 18:40:08 +0300 Subject: [PATCH 71/77] Refactor `GetRoundState`: update function ID and registration to ensure consistency across definitions and contract handling. --- src/contracts/Pulse.h | 2 +- test/contract_pulse.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 2b6e2ed9b..4fc2f6836 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -773,6 +773,7 @@ struct PULSE : public ContractBase { REGISTER_USER_FUNCTION(GetTicketPrice, 1); REGISTER_USER_FUNCTION(GetPlayerBalance, 2); + REGISTER_USER_FUNCTION(GetRoundState, 3); REGISTER_USER_FUNCTION(GetFees, 4); REGISTER_USER_FUNCTION(GetQHeartHoldLimit, 5); REGISTER_USER_FUNCTION(GetQHeartWallet, 6); @@ -784,7 +785,6 @@ struct PULSE : public ContractBase REGISTER_USER_FUNCTION(ValidateDigits, 12); REGISTER_USER_FUNCTION(GetPlayers, 13); REGISTER_USER_FUNCTION(GetPrizeTable, 14); - REGISTER_USER_FUNCTION(GetRoundState, 15); REGISTER_USER_PROCEDURE(BuyTicket, 1); REGISTER_USER_PROCEDURE(SetPrice, 2); diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index 8bbbeae38..9f4654393 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -17,6 +17,7 @@ constexpr uint16 PULSE_PROCEDURE_SET_AUTO_CONFIG = 10; constexpr uint16 PULSE_PROCEDURE_SET_AUTO_LIMITS = 11; constexpr uint16 PULSE_FUNCTION_GET_TICKET_PRICE = 1; +constexpr uint16 PULSE_FUNCTION_GET_ROUND_STATE = 3; constexpr uint16 PULSE_FUNCTION_GET_FEES = 4; constexpr uint16 PULSE_FUNCTION_GET_QHEART_HOLD_LIMIT = 5; constexpr uint16 PULSE_FUNCTION_GET_QHEART_WALLET = 6; @@ -28,7 +29,6 @@ constexpr uint16 PULSE_FUNCTION_GET_AUTO_STATS = 11; constexpr uint16 PULSE_FUNCTION_VALIDATE_DIGITS = 12; constexpr uint16 PULSE_FUNCTION_GET_PLAYERS = 13; constexpr uint16 PULSE_FUNCTION_GET_PRIZE_TABLE = 14; -constexpr uint16 PULSE_FUNCTION_GET_ROUND_STATE = 15; namespace { From 7ba0cd2fa6b6ef7ddab516af1b72df92a1f519bb Mon Sep 17 00:00:00 2001 From: N-010 Date: Fri, 20 Mar 2026 20:24:40 +0300 Subject: [PATCH 72/77] Fixes asset name --- src/contracts/Pulse.h | 58 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 4fc2f6836..32765c3b4 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -479,6 +479,22 @@ struct PULSE : public ContractBase sint64 index; }; + struct TransferTokenToQx_input + { + sint64 numberOfShares; + }; + struct TransferTokenToQx_output + { + uint8 returnCode; + }; + struct TransferTokenToQx_locals + { + Asset asset; + sint64 releaseResult; + QX::Fees_input feesInput; + QX::Fees_output feesOutput; + }; + struct GetTicketPrice_input { }; @@ -797,6 +813,7 @@ struct PULSE : public ContractBase REGISTER_USER_PROCEDURE(WithdrawAutoParticipation, 9); REGISTER_USER_PROCEDURE(SetAutoConfig, 10); REGISTER_USER_PROCEDURE(SetAutoLimits, 11); + REGISTER_USER_PROCEDURE(TransferTokenToQx, 12); } INITIALIZE() @@ -1490,6 +1507,47 @@ struct PULSE : public ContractBase output.returnCode = locals.allocateOutput.returnCode; } + /** + * @brief Releases PULSE share management rights back to QX for the invocator. + * @param input Number of PULSE shares to transfer under QX management. + * @param output Number of shares transferred and a status code. + * @note The current QX transfer fee is paid from the Pulse contract balance; any invocation reward is refunded. + */ + PUBLIC_PROCEDURE_WITH_LOCALS(TransferTokenToQx) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (input.numberOfShares <= 0) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + if (qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, state.get().qheartIssuer, qpi.invocator(), qpi.invocator(), SELF_INDEX, + SELF_INDEX) < input.numberOfShares) + { + output.returnCode = toReturnCode(EReturnCode::TRANSFER_FROM_PULSE_FAILED); + return; + } + + CALL_OTHER_CONTRACT_FUNCTION(QX, Fees, locals.feesInput, locals.feesOutput); + + locals.asset.issuer = state.get().qheartIssuer; + locals.asset.assetName = PULSE_QHEART_ASSET_NAME; + locals.releaseResult = qpi.releaseShares(locals.asset, qpi.invocator(), qpi.invocator(), input.numberOfShares, QX_CONTRACT_INDEX, + QX_CONTRACT_INDEX, locals.feesOutput.transferFee); + if (locals.releaseResult < 0) + { + output.returnCode = toReturnCode(EReturnCode::TRANSFER_FROM_PULSE_FAILED); + return; + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + private: PRIVATE_PROCEDURE_WITH_LOCALS(ProcessAutoTickets) { From fd45e8d122048cd9dbd260b1fdacb9757735d50a Mon Sep 17 00:00:00 2001 From: N-010 Date: Fri, 27 Mar 2026 16:51:11 +0300 Subject: [PATCH 73/77] Fix typo in `HashMapConverter`: rename `hasMap` to `hashMap` for variable consistency. --- src/contracts/Pulse.h | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 32765c3b4..03a92c16d 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -47,22 +47,22 @@ struct PULSE : public ContractBase template struct HashMapConverter { - void convert(const HashMap& hasMap, Array& array) + void convert(const HashMap& hashMap, Array& array) { arrayIndex = 0; setMemory(array, 0); - hasMapIndex = hasMap.nextElementIndex(NULL_INDEX); - while (hasMapIndex != NULL_INDEX) + hashMapIndex = hashMap.nextElementIndex(NULL_INDEX); + while (hashMapIndex != NULL_INDEX) { - array.set(arrayIndex, hasMap.value(hasMapIndex)); - hasMapIndex = hasMap.nextElementIndex(hasMapIndex); + array.set(arrayIndex, hashMap.value(hashMapIndex)); + hashMapIndex = hashMap.nextElementIndex(hashMapIndex); ++arrayIndex; } } private: - sint64 hasMapIndex; + sint64 hashMapIndex; uint64 arrayIndex; }; @@ -1061,7 +1061,8 @@ struct PULSE : public ContractBase /** * Returns the current auto-participation roster and shared limits. - * @return All registered auto participants, the participant capacity, the per-user auto-ticket limit, remaining slots in the current round, and + * @return All registered auto participants, the participant capacity, + * the per-user auto-ticket limit, remaining slots in the current round, and * the status code. */ PUBLIC_FUNCTION_WITH_LOCALS(GetAutoStats) From 8b332032160042eaa1b83b3cb6b574affc584870 Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 1 Apr 2026 19:58:51 +0300 Subject: [PATCH 74/77] Add `DepositManagedQHeart` procedure: enable QHeart transfer from invocator to Pulse wallet with validation and testing. --- src/contracts/Pulse.h | 53 +++++++++++++++++++++++++++ test/contract_pulse.cpp | 80 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 03a92c16d..f1ba76f5b 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -479,6 +479,20 @@ struct PULSE : public ContractBase sint64 index; }; + struct DepositManagedQHeart_input + { + sint64 amount; + }; + struct DepositManagedQHeart_output + { + uint8 returnCode; + }; + struct DepositManagedQHeart_locals + { + sint64 transferResult; + sint64 userBalance; + }; + struct TransferTokenToQx_input { sint64 numberOfShares; @@ -814,6 +828,7 @@ struct PULSE : public ContractBase REGISTER_USER_PROCEDURE(SetAutoConfig, 10); REGISTER_USER_PROCEDURE(SetAutoLimits, 11); REGISTER_USER_PROCEDURE(TransferTokenToQx, 12); + REGISTER_USER_PROCEDURE(DepositManagedQHeart, 13); } INITIALIZE() @@ -1508,6 +1523,44 @@ struct PULSE : public ContractBase output.returnCode = locals.allocateOutput.returnCode; } + /** + * Deposits QHeart already managed by Pulse into the Pulse wallet. + * @param amount QHeart amount to transfer from the invocator to the contract. + * @return Status code describing whether the transfer succeeded. + * @note This only moves managed QHeart into `SELF`; it does not update auto-participation or other accounting. + */ + PUBLIC_PROCEDURE_WITH_LOCALS(DepositManagedQHeart) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (input.amount <= 0) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + locals.userBalance = + qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, state.get().qheartIssuer, qpi.invocator(), qpi.invocator(), SELF_INDEX, SELF_INDEX); + if (locals.userBalance < input.amount) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); + return; + } + + locals.transferResult = qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, state.get().qheartIssuer, qpi.invocator(), + qpi.invocator(), input.amount, SELF); + if (locals.transferResult < 0) + { + output.returnCode = toReturnCode(EReturnCode::TRANSFER_TO_PULSE_FAILED); + return; + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + /** * @brief Releases PULSE share management rights back to QX for the invocator. * @param input Number of PULSE shares to transfer under QX management. diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp index 9f4654393..3235fafaa 100644 --- a/test/contract_pulse.cpp +++ b/test/contract_pulse.cpp @@ -15,6 +15,9 @@ constexpr uint16 PULSE_PROCEDURE_DEPOSIT_AUTO_PARTICIPATION = 8; constexpr uint16 PULSE_PROCEDURE_WITHDRAW_AUTO_PARTICIPATION = 9; constexpr uint16 PULSE_PROCEDURE_SET_AUTO_CONFIG = 10; constexpr uint16 PULSE_PROCEDURE_SET_AUTO_LIMITS = 11; +constexpr uint16 PULSE_PROCEDURE_DEPOSIT_MANAGED_QHEART = 13; + +constexpr uint16 QX_PROCEDURE_TRANSFER_SHARE_MANAGEMENT_RIGHTS = 9; constexpr uint16 PULSE_FUNCTION_GET_TICKET_PRICE = 1; constexpr uint16 PULSE_FUNCTION_GET_ROUND_STATE = 3; @@ -249,6 +252,12 @@ class ContractTestingPulse : protected ContractTesting PULSEChecker* state() { return reinterpret_cast(contractStates[PULSE_CONTRACT_INDEX]); } const PULSEChecker* state() const { return reinterpret_cast(contractStates[PULSE_CONTRACT_INDEX]); } + void qxInitialize() + { + INIT_CONTRACT(QX); + callSystemProcedure(QX_CONTRACT_INDEX, INITIALIZE); + } + id pulseSelf() const { return id(PULSE_CONTRACT_INDEX, 0, 0, 0); } PULSE::GetTicketPrice_output getTicketPrice() @@ -370,6 +379,19 @@ class ContractTestingPulse : protected ContractTesting return output; } + PULSE::DepositManagedQHeart_output depositManagedQHeart(const id& user, sint64 amount) + { + ensureUserEnergy(user); + PULSE::DepositManagedQHeart_input input{}; + input.amount = amount; + PULSE::DepositManagedQHeart_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_DEPOSIT_MANAGED_QHEART, input, output, user, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + PULSE::WithdrawAutoParticipation_output withdrawAutoParticipation(const id& user, sint64 amount) { ensureUserEnergy(user); @@ -519,6 +541,17 @@ class ContractTestingPulse : protected ContractTesting return info; } + QHeartIssuance issueQHeartOnQx(sint64 totalShares) + { + static constexpr char name[7] = {'Q', 'H', 'E', 'A', 'R', 'T', 0}; + static constexpr char unit[7] = {}; + QHeartIssuance info{}; + const sint64 issued = issueAsset(state()->getQHeartIssuer(), name, 0, unit, totalShares, QX_CONTRACT_INDEX, &info.issuanceIndex, + &info.ownershipIndex, &info.possessionIndex); + EXPECT_EQ(issued, totalShares); + return info; + } + void transferQHeart(const QHeartIssuance& issuance, const id& dest, sint64 amount) { int destOwnershipIndex = 0; @@ -541,6 +574,22 @@ class ContractTestingPulse : protected ContractTesting issueContractShares(RL_CONTRACT_INDEX, initialShares, false); } + sint64 transferQHeartManagementRightsToPulse(const id& currentOwner, sint64 shares) + { + ensureUserEnergy(currentOwner); + QX::TransferShareManagementRights_input input{}; + QX::TransferShareManagementRights_output output{}; + input.asset.assetName = PULSE_QHEART_ASSET_NAME; + input.asset.issuer = state()->getQHeartIssuer(); + input.numberOfShares = shares; + input.newManagingContractIndex = PULSE_CONTRACT_INDEX; + if (!invokeUserProcedure(QX_CONTRACT_INDEX, QX_PROCEDURE_TRANSFER_SHARE_MANAGEMENT_RIGHTS, input, output, currentOwner, 0)) + { + return 0; + } + return output.transferredNumberOfShares; + } + uint64 qheartBalanceOf(const id& owner) const { const long long balance = @@ -548,6 +597,13 @@ class ContractTestingPulse : protected ContractTesting return (balance > 0) ? static_cast(balance) : 0; } + uint64 managedQheartBalanceOf(const id& owner, unsigned int managingContractIndex) const + { + const long long balance = + numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, state()->getQHeartIssuer(), owner, owner, managingContractIndex, managingContractIndex); + return (balance > 0) ? static_cast(balance) : 0; + } + private: static void ensureUserEnergy(const id& user) { increaseEnergy(user, 1); } }; @@ -1684,6 +1740,30 @@ TEST(ContractPulse_Public, GetBalanceReportsQHeartWalletBalance) EXPECT_EQ(ctl.getBalance().balance, 12345u); } +// QX-managed QHEART can be re-managed by Pulse and then deposited into the Pulse wallet. +TEST(ContractPulse_Public, DepositManagedQHeartAfterQxManagementTransfer) +{ + ContractTestingPulse ctl; + ctl.qxInitialize(); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeartOnQx(1000000); + const id user = id::randomValue(); + static constexpr sint64 depositAmount = 12345; + + ctl.transferQHeart(issuance, user, depositAmount); + + EXPECT_EQ(ctl.managedQheartBalanceOf(user, QX_CONTRACT_INDEX), static_cast(depositAmount)); + EXPECT_EQ(ctl.managedQheartBalanceOf(user, PULSE_CONTRACT_INDEX), 0u); + EXPECT_EQ(ctl.transferQHeartManagementRightsToPulse(user, depositAmount), depositAmount); + EXPECT_EQ(ctl.managedQheartBalanceOf(user, QX_CONTRACT_INDEX), 0u); + EXPECT_EQ(ctl.managedQheartBalanceOf(user, PULSE_CONTRACT_INDEX), static_cast(depositAmount)); + + const PULSE::DepositManagedQHeart_output deposit = ctl.depositManagedQHeart(user, depositAmount); + EXPECT_EQ(deposit.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.managedQheartBalanceOf(user, PULSE_CONTRACT_INDEX), 0u); + EXPECT_EQ(ctl.getBalance().balance, static_cast(depositAmount)); +} + // Report empty winner history before any draws. TEST(ContractPulse_Public, GetWinnersReportsEmptyWhenNoWinners) { From 7b98f5979f5195ea6d283b11b27c9cdaf3805904 Mon Sep 17 00:00:00 2001 From: N-010 Date: Mon, 25 May 2026 18:19:10 +0300 Subject: [PATCH 75/77] Rename `TransferTokenToQx` to `TransferShareManagementRights`, introduce `returnInvocatorReward` utility, and refactor reward logic across procedures for consistency and improved readability. --- src/contract_core/qpi_spectrum_impl.h | 10 +++ src/contracts/Pulse.h | 107 +++++++++----------------- src/contracts/qpi.h | 9 +++ test/qpi.cpp | 27 ++++++- 4 files changed, 81 insertions(+), 72 deletions(-) diff --git a/src/contract_core/qpi_spectrum_impl.h b/src/contract_core/qpi_spectrum_impl.h index 8ca95d44b..c15fbed35 100644 --- a/src/contract_core/qpi_spectrum_impl.h +++ b/src/contract_core/qpi_spectrum_impl.h @@ -182,6 +182,16 @@ long long QPI::QpiContextProcedureCall::transfer(const m256i& destination, long return __transfer(destination, amount, TransferType::qpiTransfer); } +sint64 QPI::QpiContextProcedureCall::returnInvocatorReward() const +{ + if (invocationReward() > 0) + { + return transfer(invocator(), invocationReward()); + } + + return 0; +} + m256i QPI::QpiContextFunctionCall::nextId(const m256i& currentId) const { int index = spectrumIndex(currentId); diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index f1ba76f5b..9bb96995b 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -493,15 +493,16 @@ struct PULSE : public ContractBase sint64 userBalance; }; - struct TransferTokenToQx_input + struct TransferShareManagementRights_input { sint64 numberOfShares; + uint16 newManagingContractIndex; }; - struct TransferTokenToQx_output + struct TransferShareManagementRights_output { uint8 returnCode; }; - struct TransferTokenToQx_locals + struct TransferShareManagementRights_locals { Asset asset; sint64 releaseResult; @@ -827,7 +828,7 @@ struct PULSE : public ContractBase REGISTER_USER_PROCEDURE(WithdrawAutoParticipation, 9); REGISTER_USER_PROCEDURE(SetAutoConfig, 10); REGISTER_USER_PROCEDURE(SetAutoLimits, 11); - REGISTER_USER_PROCEDURE(TransferTokenToQx, 12); + REGISTER_USER_PROCEDURE(TransferShareManagementRights, 12); REGISTER_USER_PROCEDURE(DepositManagedQHeart, 13); } @@ -1094,10 +1095,7 @@ struct PULSE : public ContractBase /** Schedules a new ticket price for the next epoch (owner-only). */ PUBLIC_PROCEDURE(SetPrice) { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } + qpi.returnInvocatorReward(); if (qpi.invocator() != state.get().qheartIssuer) { @@ -1119,10 +1117,7 @@ struct PULSE : public ContractBase /** Schedules a new draw schedule bitmask for the next epoch (owner-only). */ PUBLIC_PROCEDURE(SetSchedule) { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } + qpi.returnInvocatorReward(); if (qpi.invocator() != state.get().qheartIssuer) { @@ -1144,10 +1139,7 @@ struct PULSE : public ContractBase /** Schedules a new draw hour in UTC for the next epoch (owner-only). */ PUBLIC_PROCEDURE(SetDrawHour) { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } + qpi.returnInvocatorReward(); if (qpi.invocator() != state.get().qheartIssuer) { @@ -1169,10 +1161,7 @@ struct PULSE : public ContractBase /** Schedules new fee splits for the next epoch (owner-only). */ PUBLIC_PROCEDURE(SetFees) { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } + qpi.returnInvocatorReward(); if (qpi.invocator() != state.get().qheartIssuer) { @@ -1198,10 +1187,7 @@ struct PULSE : public ContractBase /** Schedules a new QHeart hold limit for the next epoch (owner-only). */ PUBLIC_PROCEDURE(SetQHeartHoldLimit) { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } + qpi.returnInvocatorReward(); if (qpi.invocator() != state.get().qheartIssuer) { @@ -1223,10 +1209,7 @@ struct PULSE : public ContractBase */ PUBLIC_PROCEDURE_WITH_LOCALS(DepositAutoParticipation) { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } + qpi.returnInvocatorReward(); if (state.get().autoParticipants.population() >= state.get().autoParticipants.capacity()) { @@ -1305,10 +1288,7 @@ struct PULSE : public ContractBase */ PUBLIC_PROCEDURE_WITH_LOCALS(WithdrawAutoParticipation) { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } + qpi.returnInvocatorReward(); if (!state.get().autoParticipants.contains(qpi.invocator())) { @@ -1360,10 +1340,7 @@ struct PULSE : public ContractBase */ PUBLIC_PROCEDURE_WITH_LOCALS(SetAutoConfig) { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } + qpi.returnInvocatorReward(); if (!state.get().autoParticipants.contains(qpi.invocator())) { @@ -1401,10 +1378,7 @@ struct PULSE : public ContractBase */ PUBLIC_PROCEDURE_WITH_LOCALS(SetAutoLimits) { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } + qpi.returnInvocatorReward(); if (qpi.invocator() != state.get().qheartIssuer) { @@ -1441,10 +1415,7 @@ struct PULSE : public ContractBase /** Buys a single ticket and transfers the ticket price from the invocator. */ PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicket) { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } + qpi.returnInvocatorReward(); if (!isSellingOpen(state)) { @@ -1494,10 +1465,7 @@ struct PULSE : public ContractBase /** Buys multiple random tickets and transfers the total price from the invocator. */ PUBLIC_PROCEDURE_WITH_LOCALS(BuyRandomTickets) { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } + qpi.returnInvocatorReward(); locals.prepareInput.count = input.count; CALL(PrepareRandomTickets, locals.prepareInput, locals.prepareOutput); @@ -1531,10 +1499,7 @@ struct PULSE : public ContractBase */ PUBLIC_PROCEDURE_WITH_LOCALS(DepositManagedQHeart) { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } + qpi.returnInvocatorReward(); if (input.amount <= 0) { @@ -1562,43 +1527,45 @@ struct PULSE : public ContractBase } /** - * @brief Releases PULSE share management rights back to QX for the invocator. - * @param input Number of PULSE shares to transfer under QX management. - * @param output Number of shares transferred and a status code. - * @note The current QX transfer fee is paid from the Pulse contract balance; any invocation reward is refunded. - */ - PUBLIC_PROCEDURE_WITH_LOCALS(TransferTokenToQx) + * @brief Releases PULSE share management rights to another contract for the invocator. + * @param input Number of PULSE shares and the contract index that should acquire management rights. + * @param output Number of shares transferred and a status code. + * @note The destination contract fee is paid from the invocation reward; any unused reward is refunded. + */ + PUBLIC_PROCEDURE_WITH_LOCALS(TransferShareManagementRights) { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } - - if (input.numberOfShares <= 0) + if (input.numberOfShares <= 0 || input.newManagingContractIndex == 0 || input.newManagingContractIndex >= MAX_NUMBER_OF_CONTRACTS || input.newManagingContractIndex == SELF_INDEX) { + qpi.returnInvocatorReward(); output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); return; } - if (qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, state.get().qheartIssuer, qpi.invocator(), qpi.invocator(), SELF_INDEX, - SELF_INDEX) < input.numberOfShares) + if (qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, state.get().qheartIssuer, qpi.invocator(), qpi.invocator(), SELF_INDEX, SELF_INDEX) < + input.numberOfShares) { + qpi.returnInvocatorReward(); output.returnCode = toReturnCode(EReturnCode::TRANSFER_FROM_PULSE_FAILED); return; } - CALL_OTHER_CONTRACT_FUNCTION(QX, Fees, locals.feesInput, locals.feesOutput); - locals.asset.issuer = state.get().qheartIssuer; locals.asset.assetName = PULSE_QHEART_ASSET_NAME; - locals.releaseResult = qpi.releaseShares(locals.asset, qpi.invocator(), qpi.invocator(), input.numberOfShares, QX_CONTRACT_INDEX, - QX_CONTRACT_INDEX, locals.feesOutput.transferFee); + + locals.releaseResult = qpi.releaseShares(locals.asset, qpi.invocator(), qpi.invocator(), input.numberOfShares, input.newManagingContractIndex, + input.newManagingContractIndex, qpi.invocationReward()); if (locals.releaseResult < 0) { + qpi.returnInvocatorReward(); output.returnCode = toReturnCode(EReturnCode::TRANSFER_FROM_PULSE_FAILED); return; } + if (qpi.invocationReward() > locals.releaseResult) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward() - locals.releaseResult); + } + output.returnCode = toReturnCode(EReturnCode::SUCCESS); } diff --git a/src/contracts/qpi.h b/src/contracts/qpi.h index a39cf9a40..24cdb2e44 100644 --- a/src/contracts/qpi.h +++ b/src/contracts/qpi.h @@ -2635,6 +2635,15 @@ namespace QPI const id& newOwnerAndPossessor // New owner and possessor. Pass NULL_ID to burn shares (not allowed for contract shares). ) const; // Returns remaining number of possessed shares satisfying all the conditions; if the value is less than 0, the attempt has failed, in this case the absolute value equals to the insufficient number, INVALID_AMOUNT indicates another error + /** + * @brief Return the full invocation reward to the current invocator. + * @return Remaining energy amount of the current contract on success; a negative value means the transfer failed, + * in which case the absolute value equals the missing amount. INVALID_AMOUNT indicates another error. + * @note Equivalent to calling transfer(invocator(), invocationReward()) and therefore sends 0 when no invocation + * reward is attached. + */ + inline sint64 returnInvocatorReward() const; + /// Unsubscribe oracle based on subscription ID (returning false if oracleSubscriptionId is invalid). inline bool unsubscribeOracle( sint32 oracleSubscriptionId diff --git a/test/qpi.cpp b/test/qpi.cpp index ca0b62c9c..cbdd1941a 100644 --- a/test/qpi.cpp +++ b/test/qpi.cpp @@ -381,6 +381,29 @@ struct ContractExecInitDeinitGuard } }; +TEST(TestCoreQPI, ReturnInvocatorReward) +{ + ContractTesting test; + test.initEmptySpectrum(); + + const QPI::id invocator(101, 202, 303, 404); + const QPI::id contractId(QX_CONTRACT_INDEX, 0, 0, 0); + constexpr sint64 invocationReward = 123; + constexpr sint64 extraContractBalance = 77; + + increaseEnergy(contractId, invocationReward + extraContractBalance); + + const sint64 contractBalanceBefore = getBalance(contractId); + const sint64 invocatorBalanceBefore = getBalance(invocator); + + QpiContextUserProcedureCall qpi(QX_CONTRACT_INDEX, invocator, invocationReward); + const sint64 remainingContractBalance = qpi.returnInvocatorReward(); + + EXPECT_EQ(remainingContractBalance, extraContractBalance); + EXPECT_EQ(getBalance(contractId), contractBalanceBefore - invocationReward); + EXPECT_EQ(getBalance(invocator), invocatorBalanceBefore + invocationReward); +} + TEST(TestCoreQPI, ProposalAndVotingByComputors) { ContractExecInitDeinitGuard initDeinitGuard; @@ -1767,8 +1790,8 @@ TEST(TestCoreQPI, ProposalVotingV1proposalByAnyoneWithoutScalarVoteSupport) testProposalVotingComputorsV1(); } -// TODO: ProposalVoting YesNo - +// TODO: ProposalVoting YesNo + template void testProposalVotingShareholdersV1() { From 093a11e73b0d6b96e309ebd0efe50887a19a2256 Mon Sep 17 00:00:00 2001 From: N-010 Date: Mon, 25 May 2026 18:35:30 +0300 Subject: [PATCH 76/77] Add comprehensive documentation to all input and output structs in `Pulse.h` for improved clarity and maintainability. --- src/contracts/Pulse.h | 248 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 9bb96995b..4b72153e3 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -222,12 +222,20 @@ struct PULSE : public ContractBase EState currentState; }; + /** + * @brief Input for ValidateDigits. + */ struct ValidateDigits_input { + // Candidate ticket digits; only the first PULSE_PLAYER_DIGITS entries are validated. Array digits; }; + /** + * @brief Output for ValidateDigits. + */ struct ValidateDigits_output { + // True when every checked digit is in the supported range [0..9]. bit isValid; }; struct ValidateDigits_locals @@ -236,13 +244,21 @@ struct PULSE : public ContractBase uint8 value; }; + /** + * @brief Input for BuyTicket. + */ struct BuyTicket_input { + // Ticket digits chosen by the invocator; only the first PULSE_PLAYER_DIGITS entries are used. Array digits; }; + /** + * @brief Output for BuyTicket. + */ struct BuyTicket_output { + // EReturnCode value describing whether the ticket was accepted and paid. uint8 returnCode; }; @@ -334,13 +350,21 @@ struct PULSE : public ContractBase } randomData; }; + /** + * @brief Input for BuyRandomTickets. + */ struct BuyRandomTickets_input { + // Requested number of random tickets to buy for the invocator. uint16 count; }; + /** + * @brief Output for BuyRandomTickets. + */ struct BuyRandomTickets_output { + // EReturnCode value describing validation, payment, or allocation result. uint8 returnCode; }; @@ -368,14 +392,24 @@ struct PULSE : public ContractBase sint64 elementIndex; }; + /** + * @brief Input for GetAutoParticipation. + */ struct GetAutoParticipation_input { + // Account whose auto-participation entry should be read. id player; }; + /** + * @brief Output for GetAutoParticipation. + */ struct GetAutoParticipation_output { + // QHeart amount reserved for future automatic purchases. uint64 deposit; + // Number of tickets requested per automatic purchase round. uint16 desiredTickets; + // EReturnCode::SUCCESS when an entry exists, otherwise EReturnCode::INVALID_VALUE. uint8 returnCode; }; struct GetAutoParticipation_locals @@ -383,15 +417,27 @@ struct PULSE : public ContractBase AutoParticipant entry; }; + /** + * @brief Input for GetAutoStats. + * @note This function has no parameters. + */ struct GetAutoStats_input { }; + /** + * @brief Output for GetAutoStats. + */ struct GetAutoStats_output { + // Snapshot of registered auto-participants; unused trailing slots are zeroed. Array participants; + // Maximum number of auto-participants supported by storage. uint16 maxAutoParticipants; + // Current per-user automatic ticket cap; 0 means unlimited. uint16 maxAutoTicketsPerUser; + // Remaining ticket capacity in the current round. uint16 roundSlotsLeft; + // EReturnCode value for the query. uint8 returnCode; }; struct GetAutoStats_locals @@ -399,14 +445,24 @@ struct PULSE : public ContractBase HashMapConverter converter; }; + /** + * @brief Input for DepositAutoParticipation. + */ struct DepositAutoParticipation_input { + // QHeart amount to transfer into the auto-participation deposit. sint64 amount; + // Desired number of tickets to buy automatically per draw. sint16 desiredTickets; + // When true, attempts an immediate random-ticket purchase before storing the remaining deposit. bit buyNow; }; + /** + * @brief Output for DepositAutoParticipation. + */ struct DepositAutoParticipation_output { + // EReturnCode value describing validation, transfer, or purchase result. uint8 returnCode; }; struct DepositAutoParticipation_locals @@ -432,12 +488,20 @@ struct PULSE : public ContractBase BuyRandomTickets_output buyRandomTicketsOutput; }; + /** + * @brief Input for WithdrawAutoParticipation. + */ struct WithdrawAutoParticipation_input { + // QHeart amount to withdraw; values <= 0 request the full stored deposit. sint64 amount; }; + /** + * @brief Output for WithdrawAutoParticipation. + */ struct WithdrawAutoParticipation_output { + // EReturnCode value describing whether the withdrawal succeeded. uint8 returnCode; }; struct WithdrawAutoParticipation_locals @@ -448,12 +512,20 @@ struct PULSE : public ContractBase sint64 withdrawAmount; }; + /** + * @brief Input for SetAutoConfig. + */ struct SetAutoConfig_input { + // New desired ticket count; -1 keeps the current value. sint16 desiredTickets; }; + /** + * @brief Output for SetAutoConfig. + */ struct SetAutoConfig_output { + // EReturnCode value describing whether the configuration update was accepted. uint8 returnCode; }; struct SetAutoConfig_locals @@ -465,12 +537,20 @@ struct PULSE : public ContractBase FindAutoParticipant_output findOutput; }; + /** + * @brief Input for SetAutoLimits. + */ struct SetAutoLimits_input { + // Maximum automatic tickets per participant; 0 disables the limit. uint16 maxTicketsPerUser; }; + /** + * @brief Output for SetAutoLimits. + */ struct SetAutoLimits_output { + // EReturnCode value describing whether the owner-only update succeeded. uint8 returnCode; }; struct SetAutoLimits_locals @@ -479,12 +559,20 @@ struct PULSE : public ContractBase sint64 index; }; + /** + * @brief Input for DepositManagedQHeart. + */ struct DepositManagedQHeart_input { + // QHeart amount to move from the invocator into the Pulse contract wallet. sint64 amount; }; + /** + * @brief Output for DepositManagedQHeart. + */ struct DepositManagedQHeart_output { + // EReturnCode value describing validation or transfer result. uint8 returnCode; }; struct DepositManagedQHeart_locals @@ -493,13 +581,22 @@ struct PULSE : public ContractBase sint64 userBalance; }; + /** + * @brief Input for TransferShareManagementRights. + */ struct TransferShareManagementRights_input { + // Number of managed QHeart shares to release. sint64 numberOfShares; + // Destination contract index that should acquire management rights. uint16 newManagingContractIndex; }; + /** + * @brief Output for TransferShareManagementRights. + */ struct TransferShareManagementRights_output { + // EReturnCode value describing validation or share-release result. uint8 returnCode; }; struct TransferShareManagementRights_locals @@ -510,85 +607,166 @@ struct PULSE : public ContractBase QX::Fees_output feesOutput; }; + /** + * @brief Input for GetTicketPrice. + * @note This function has no parameters. + */ struct GetTicketPrice_input { }; + /** + * @brief Output for GetTicketPrice. + */ struct GetTicketPrice_output { + // Current ticket price in QHeart units. uint64 ticketPrice; }; + /** + * @brief Input for GetPlayerBalance. + */ struct GetPlayerBalance_input { + // Account whose QHeart balance should be queried. id player; }; + /** + * @brief Output for GetPlayerBalance. + */ struct GetPlayerBalance_output { + // Player QHeart balance managed by the Pulse contract context. uint64 balance; + // EReturnCode value for the query. uint8 returnCode; }; + /** + * @brief Input for GetFees. + * @note This function has no parameters. + */ struct GetFees_input { }; + /** + * @brief Output for GetFees. + */ struct GetFees_output { + // Percent of round revenue allocated to the dev wallet. uint8 devPercent; + // Percent of round revenue burned. uint8 burnPercent; + // Percent of round revenue distributed to Pulse shareholders. uint8 shareholdersPercent; + // Percent of round revenue distributed to RandomLottery shareholders. uint8 rlShareholdersPercent; + // EReturnCode value for the query. uint8 returnCode; }; + /** + * @brief Input for GetQHeartHoldLimit. + * @note This function has no parameters. + */ struct GetQHeartHoldLimit_input { }; + /** + * @brief Output for GetQHeartHoldLimit. + */ struct GetQHeartHoldLimit_output { + // Maximum QHeart balance retained by the Pulse wallet after settlement. uint64 qheartHoldLimit; }; + /** + * @brief Input for GetQHeartWallet. + * @note This function has no parameters. + */ struct GetQHeartWallet_input { }; + /** + * @brief Output for GetQHeartWallet. + */ struct GetQHeartWallet_output { + // Current QHeart issuer wallet configured for Pulse. id wallet; }; + /** + * @brief Input for GetWinningDigits. + * @note This function has no parameters. + */ struct GetWinningDigits_input { }; + /** + * @brief Output for GetWinningDigits. + */ struct GetWinningDigits_output { + // Winning digits from the last settled draw. Array digits; }; + /** + * @brief Input for GetBalance. + * @note This function has no parameters. + */ struct GetBalance_input { }; + /** + * @brief Output for GetBalance. + */ struct GetBalance_output { + // QHeart balance currently held by the Pulse contract wallet. uint64 balance; }; + /** + * @brief Input for GetPlayers. + * @note This function has no parameters. + */ struct GetPlayers_input { }; + /** + * @brief Output for GetPlayers. + */ struct GetPlayers_output { + // Snapshot of current-round tickets; unused trailing slots are zeroed. Array players; + // EReturnCode value for the query. uint8 returnCode; }; + /** + * @brief Input for GetPrizeTable. + * @note This function has no parameters. + */ struct GetPrizeTable_input { }; + /** + * @brief Output for GetPrizeTable. + */ struct GetPrizeTable_output { + // Reward table indexed by left-aligned match count. Array leftAlignedRewards; + // Reward table indexed by any-position match count. Array anyPositionRewards; + // Ticket price used to derive the reward tables. uint64 ticketPrice; + // EReturnCode value for the query. uint8 returnCode; }; struct GetPrizeTable_locals @@ -596,20 +774,37 @@ struct PULSE : public ContractBase uint8 matches; }; + /** + * @brief Input for GetRoundState. + * @note This function has no parameters. + */ struct GetRoundState_input { }; + /** + * @brief Output for GetRoundState. + */ struct GetRoundState_output { + // Current Qubic epoch. uint32 epoch; + // Compact date stamp for the most recent draw. uint32 lastDrawDateStamp; + // Number of tickets allocated in the current round. uint16 ticketCounter; + // Maximum ticket capacity for one round. uint16 maxPlayers; + // Remaining ticket slots for the current round. uint16 slotsLeft; + // Runtime state flags encoded from EState. uint8 currentState; + // UTC hour when scheduled draws become eligible. uint8 drawHour; + // Weekday bitmask for scheduled draws. uint8 schedule; + // True when ticket sales are currently open. bit sellingOpen; + // EReturnCode value for the query. uint8 returnCode; }; struct GetRoundState_locals @@ -631,61 +826,114 @@ struct PULSE : public ContractBase uint64 insertIdx; }; + /** + * @brief Input for GetWinners. + * @note This function has no parameters. + */ struct GetWinners_input { }; + /** + * @brief Output for GetWinners. + */ struct GetWinners_output { + // Winner history ring buffer snapshot. Array winners; + // Monotonic insertion counter for interpreting ring-buffer order. uint64 winnersCounter; + // EReturnCode value for the query. uint8 returnCode; }; + /** + * @brief Input for SetPrice. + */ struct SetPrice_input { + // Ticket price to apply at the next epoch. uint64 newPrice; }; + /** + * @brief Output for SetPrice. + */ struct SetPrice_output { + // EReturnCode value describing whether the owner-only update was scheduled. uint8 returnCode; }; + /** + * @brief Input for SetSchedule. + */ struct SetSchedule_input { + // Weekday bitmask to apply at the next epoch. uint8 newSchedule; }; + /** + * @brief Output for SetSchedule. + */ struct SetSchedule_output { + // EReturnCode value describing whether the owner-only update was scheduled. uint8 returnCode; }; + /** + * @brief Input for SetDrawHour. + */ struct SetDrawHour_input { + // UTC draw hour to apply at the next epoch; valid range is 0..23. uint8 newDrawHour; }; + /** + * @brief Output for SetDrawHour. + */ struct SetDrawHour_output { + // EReturnCode value describing whether the owner-only update was scheduled. uint8 returnCode; }; + /** + * @brief Input for SetFees. + */ struct SetFees_input { + // Dev fee percent to apply at the next epoch. uint8 devPercent; + // Burn percent to apply at the next epoch. uint8 burnPercent; + // Pulse shareholder distribution percent to apply at the next epoch. uint8 shareholdersPercent; + // RandomLottery shareholder distribution percent to apply at the next epoch. uint8 rlShareholdersPercent; }; + /** + * @brief Output for SetFees. + */ struct SetFees_output { + // EReturnCode value describing whether the owner-only update was scheduled. uint8 returnCode; }; + /** + * @brief Input for SetQHeartHoldLimit. + */ struct SetQHeartHoldLimit_input { + // QHeart balance cap to apply at the next epoch. uint64 newQHeartHoldLimit; }; + /** + * @brief Output for SetQHeartHoldLimit. + */ struct SetQHeartHoldLimit_output { + // EReturnCode value describing whether the owner-only update was scheduled. uint8 returnCode; }; From c9ea4edb4d43b2603819799e8e8b5adf2a3f1741 Mon Sep 17 00:00:00 2001 From: N-010 Date: Tue, 26 May 2026 19:39:02 +0300 Subject: [PATCH 77/77] Fixes the comment for the TransferShareManagementRights procedure --- src/contracts/Pulse.h | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h index 4b72153e3..a9078f0cb 100644 --- a/src/contracts/Pulse.h +++ b/src/contracts/Pulse.h @@ -1775,14 +1775,15 @@ struct PULSE : public ContractBase } /** - * @brief Releases PULSE share management rights to another contract for the invocator. - * @param input Number of PULSE shares and the contract index that should acquire management rights. - * @param output Number of shares transferred and a status code. - * @note The destination contract fee is paid from the invocation reward; any unused reward is refunded. - */ + * @brief Releases managed QHeart token rights to another contract for the invocator. + * @param input Number of QHeart tokens and the contract index that should acquire management rights. + * @param output Status code describing validation or rights-release result. + * @note The destination contract fee is paid from the invocation reward; any unused reward is refunded. + */ PUBLIC_PROCEDURE_WITH_LOCALS(TransferShareManagementRights) { - if (input.numberOfShares <= 0 || input.newManagingContractIndex == 0 || input.newManagingContractIndex >= MAX_NUMBER_OF_CONTRACTS || input.newManagingContractIndex == SELF_INDEX) + if (input.numberOfShares <= 0 || input.newManagingContractIndex == 0 || input.newManagingContractIndex >= MAX_NUMBER_OF_CONTRACTS || + input.newManagingContractIndex == SELF_INDEX) { qpi.returnInvocatorReward(); output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); @@ -1790,7 +1791,7 @@ struct PULSE : public ContractBase } if (qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, state.get().qheartIssuer, qpi.invocator(), qpi.invocator(), SELF_INDEX, SELF_INDEX) < - input.numberOfShares) + input.numberOfShares) { qpi.returnInvocatorReward(); output.returnCode = toReturnCode(EReturnCode::TRANSFER_FROM_PULSE_FAILED); @@ -1801,7 +1802,7 @@ struct PULSE : public ContractBase locals.asset.assetName = PULSE_QHEART_ASSET_NAME; locals.releaseResult = qpi.releaseShares(locals.asset, qpi.invocator(), qpi.invocator(), input.numberOfShares, input.newManagingContractIndex, - input.newManagingContractIndex, qpi.invocationReward()); + input.newManagingContractIndex, qpi.invocationReward()); if (locals.releaseResult < 0) { qpi.returnInvocatorReward();