From 993faecb838b1f9dffc05548ab7aa158d9a1d467 Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 19 Feb 2026 15:25:15 +0300 Subject: [PATCH 01/15] Get players Sync Jackpot with balance --- src/contracts/QThirtyFour.h | 47 +++++++++++++++++++ test/contract_qtf.cpp | 91 ++++++++++++++++++++++++++++++++++--- 2 files changed, 132 insertions(+), 6 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index a007f24a6..6b4dd25a3 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -684,6 +684,30 @@ struct QTF : ContractBase bit isScheduledToday; }; + struct GetPlayers_input + { + }; + + struct GetPlayers_output + { + Array players; + uint8 returnCode; + }; + + struct SyncJackpot_input + { + }; + + struct SyncJackpot_output + { + uint8 returnCode; + }; + + struct SyncJackpot_locals + { + Entity entity; + }; + // Contract lifecycle methods INITIALIZE() { @@ -706,6 +730,8 @@ struct QTF : ContractBase REGISTER_USER_PROCEDURE(SetSchedule, 3); REGISTER_USER_PROCEDURE(SetTargetJackpot, 4); REGISTER_USER_PROCEDURE(SetDrawHour, 5); + REGISTER_USER_PROCEDURE(SyncJackpot, 6); + REGISTER_USER_FUNCTION(GetTicketPrice, 1); REGISTER_USER_FUNCTION(GetNextEpochData, 2); REGISTER_USER_FUNCTION(GetWinnerData, 3); @@ -715,6 +741,7 @@ struct QTF : ContractBase REGISTER_USER_FUNCTION(GetState, 7); REGISTER_USER_FUNCTION(GetFees, 8); REGISTER_USER_FUNCTION(EstimatePrizePayouts, 9); + REGISTER_USER_FUNCTION(GetPlayers, 10); } BEGIN_EPOCH() @@ -960,6 +987,20 @@ struct QTF : ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } + PUBLIC_PROCEDURE_WITH_LOCALS(SyncJackpot) + { + if (qpi.invocator() != state.ownerAddress) + { + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); + return; + } + + qpi.getEntity(SELF, locals.entity); + state.jackpot = locals.entity.incomingAmount - locals.entity.outgoingAmount; + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + // Functions PUBLIC_FUNCTION(GetTicketPrice) { output.ticketPrice = state.ticketPrice; } PUBLIC_FUNCTION(GetNextEpochData) { output.nextEpochData = state.nextEpochData; } @@ -1073,6 +1114,12 @@ struct QTF : ContractBase } } + PUBLIC_FUNCTION(GetPlayers) + { + output.players = state.players; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + protected: static void clearEpochState(QTF& state) { clearPlayerData(state); } diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp index af7c5961e..246b6b40f 100644 --- a/test/contract_qtf.cpp +++ b/test/contract_qtf.cpp @@ -1,7 +1,6 @@ #define NO_UEFI #include "contract_testing.h" - #include #include #include @@ -12,6 +11,7 @@ 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_PROCEDURE_SYNC_JACKPOT = 6; constexpr uint16 QTF_FUNCTION_GET_TICKET_PRICE = 1; constexpr uint16 QTF_FUNCTION_GET_NEXT_EPOCH_DATA = 2; @@ -22,6 +22,7 @@ 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; +constexpr uint16 QTF_FUNCTION_GET_PLAYERS = 10; using QTFRandomValues = Array; @@ -354,6 +355,14 @@ class ContractTestingQTF : protected ContractTesting return output; } + QTF::GetPlayers_output getPlayers() + { + QTF::GetPlayers_input input{}; + QTF::GetPlayers_output output{}; + callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_PLAYERS, input, output); + return output; + } + // Procedure wrappers QTF::BuyTicket_output buyTicket(const id& user, uint64 reward, const QTFRandomValues& numbers) { @@ -415,6 +424,17 @@ class ContractTestingQTF : protected ContractTesting return output; } + QTF::SyncJackpot_output syncJackpot(const id& invocator) + { + QTF::SyncJackpot_input input{}; + QTF::SyncJackpot_output output{}; + if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_SYNC_JACKPOT, 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); } @@ -1390,6 +1410,66 @@ TEST(ContractQThirtyFour, SetDrawHour_AppliesAfterEndEpoch) EXPECT_EQ(ctl.getDrawHour().drawHour, newHour); } +TEST(ContractQThirtyFour, SyncJackpot_AccessControlAndUpdatesFromContractBalance) +{ + ContractTestingQTF ctl; + static constexpr uint64 newJackpotValue = 12345ULL; + ctl.state()->setJackpot(newJackpotValue); + + const id outsider = id::randomValue(); + increaseEnergy(outsider, 1); + + const QTF::SyncJackpot_output denied = ctl.syncJackpot(outsider); + EXPECT_EQ(denied.returnCode, static_cast(QTF::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ(ctl.state()->getJackpot(), newJackpotValue); + + static constexpr uint64 expectedBalance = 777777ULL; + increaseEnergy(ctl.qtfSelf(), expectedBalance); + increaseEnergy(ctl.state()->team(), 1); + + const QTF::SyncJackpot_output synced = ctl.syncJackpot(ctl.state()->team()); + EXPECT_EQ(synced.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getJackpot(), expectedBalance); +} + +TEST(ContractQThirtyFour, GetPlayers_NoTickets_ReturnsEmptyArray) +{ + ContractTestingQTF ctl; + + static const QTF::PlayerData emptyPlayerData = {}; + + const QTF::GetPlayers_output players = ctl.getPlayers(); + EXPECT_EQ(players.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + EXPECT_EQ(memcmp(&players.players.get(0), &emptyPlayerData, sizeof(QTF::PlayerData)), 0); +} + +TEST(ContractQThirtyFour, GetPlayers_ReturnsPurchasedTickets) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user1 = id::randomValue(); + const id user2 = id::randomValue(); + const QTFRandomValues nums1 = ctl.makeValidNumbers(1, 4, 7, 10); + const QTFRandomValues nums2 = ctl.makeValidNumbers(2, 5, 8, 11); + + ctl.fundAndBuyTicket(user1, ticketPrice, nums1); + ctl.fundAndBuyTicket(user2, ticketPrice, nums2); + + const QTF::GetPlayers_output players = ctl.getPlayers(); + EXPECT_EQ(players.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + + const QTF::PlayerData player0 = players.players.get(0); + const QTF::PlayerData player1 = players.players.get(1); + EXPECT_EQ(player0.player, user1); + EXPECT_TRUE(valuesEqual(player0.randomValues, nums1)); + EXPECT_EQ(player1.player, user2); + EXPECT_TRUE(valuesEqual(player1.randomValues, nums2)); + EXPECT_TRUE(isZero(players.players.get(2).player)); + EXPECT_TRUE(isZero(players.players.get(3).player)); +} + // ============================================================================ // STATE AND POOLS TESTS // ============================================================================ @@ -2726,7 +2806,7 @@ TEST(ContractQThirtyFour, Settlement_FloorTopUp_LimitedBySafetyCaps_PayoutBelowF // 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 + const uint64 qrpFunding = softFloor + 5 * P; // 25*P increaseEnergy(ctl.qrpSelf(), qrpFunding); m256i testDigest = {}; @@ -2748,9 +2828,9 @@ TEST(ContractQThirtyFour, Settlement_FloorTopUp_LimitedBySafetyCaps_PayoutBelowF 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 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; @@ -3112,4 +3192,3 @@ TEST(ContractQThirtyFour, FR_PostK4WindowExpiry_DoesNotReactivateWhenWindowExpir EXPECT_EQ(ctl.state()->getFrRoundsSinceK4(), QTF_FR_POST_K4_WINDOW_ROUNDS + 2); EXPECT_EQ(ctl.state()->getFrActive(), false); } - From 780a493d588c7bcb7eee48d1a6e1205e046c48af Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 19 Feb 2026 17:34:06 +0300 Subject: [PATCH 02/15] GetWinningCombinationsHistory --- src/contracts/QThirtyFour.h | 74 +++++++++++++++++++-------------- test/contract_qtf.cpp | 82 +++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 31 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 6b4dd25a3..cb7535b22 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -5,6 +5,7 @@ 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; +constexpr uint64 QTF_WINNING_COMBINATIONS_HISTORY_SIZE = 128; // 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. @@ -694,6 +695,16 @@ struct QTF : ContractBase uint8 returnCode; }; + struct GetWinningCombinationsHistory_input + { + }; + + struct GetWinningCombinationsHistory_output + { + Array, QTF_WINNING_COMBINATIONS_HISTORY_SIZE> history; + uint8 returnCode; + }; + struct SyncJackpot_input { }; @@ -742,6 +753,7 @@ struct QTF : ContractBase REGISTER_USER_FUNCTION(GetFees, 8); REGISTER_USER_FUNCTION(EstimatePrizePayouts, 9); REGISTER_USER_FUNCTION(GetPlayers, 10); + REGISTER_USER_FUNCTION(GetWinningCombinationsHistory, 11); } BEGIN_EPOCH() @@ -1120,6 +1132,12 @@ struct QTF : ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } + PUBLIC_FUNCTION(GetWinningCombinationsHistory) + { + output.history = state.winningCombinationsHistory; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + protected: static void clearEpochState(QTF& state) { clearPlayerData(state); } @@ -1196,39 +1214,32 @@ struct QTF : ContractBase state.lastWinnerData.epoch = epoch; } - WinnerData lastWinnerData; // last winners snapshot - - NextEpochData nextEpochData; // queued config (ticket price) + static void addWinningCombinationToHistory(QTF& state, const Array& winnerValues) + { + state.winningCombinationsHistory.set(state.winningCombinationsCount, winnerValues); + state.winningCombinationsCount = mod(++state.winningCombinationsCount, state.winningCombinationsHistory.capacity()); + } + 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) + 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) + Array, QTF_WINNING_COMBINATIONS_HISTORY_SIZE> + winningCombinationsHistory; // ring buffer of winning combinations + uint64 winningCombinationsCount; // next write position in ring buffer private: // Core settlement pipeline for one epoch: fees, FR redirects, payouts, jackpot/reserve updates. @@ -1489,6 +1500,7 @@ struct QTF : ContractBase // Always save winning values and epoch, even if no winners state.lastWinnerData.winnerValues = locals.winningValues; state.lastWinnerData.epoch = locals.currentEpoch; + addWinningCombinationToHistory(state, locals.winningValues); // Post-jackpot (k4) logic: reset counters and reseed if jackpot was hit if (locals.countK4 > 0) diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp index 246b6b40f..6a1b405a9 100644 --- a/test/contract_qtf.cpp +++ b/test/contract_qtf.cpp @@ -23,6 +23,7 @@ constexpr uint16 QTF_FUNCTION_GET_STATE = 7; constexpr uint16 QTF_FUNCTION_GET_FEES = 8; constexpr uint16 QTF_FUNCTION_ESTIMATE_PRIZE_PAYOUTS = 9; constexpr uint16 QTF_FUNCTION_GET_PLAYERS = 10; +constexpr uint16 QTF_FUNCTION_GET_WINNING_COMBINATIONS_HISTORY = 11; using QTFRandomValues = Array; @@ -52,6 +53,19 @@ namespace return memcmp(&a, &b, sizeof(a)) == 0; } + static bool isEmptyRandomValues(const QTFRandomValues& randomValues) + { + for (uint64 i = 0; i < randomValues.capacity(); ++i) + { + if (randomValues.get(i) != 0) + { + return false; + } + } + + return true; + } + static void expectWinnerValuesValidAndUnique(const QTF::GetWinnerData_output& winnerData) { std::set unique; @@ -88,6 +102,7 @@ class QTFChecker : public QTF bool getFrActive() const { return frActive; } uint32 getFrRoundsSinceK4() const { return frRoundsSinceK4; } uint32 getFrRoundsAtOrAboveTarget() const { return frRoundsAtOrAboveTarget; } + uint64 getWinningCombinationsWriteIndex() const { return winningCombinationsCount; } const id& team() const { return teamAddress; } void setScheduleMask(uint8 newMask) { schedule = newMask; } @@ -363,6 +378,14 @@ class ContractTestingQTF : protected ContractTesting return output; } + QTF::GetWinningCombinationsHistory_output getWinningCombinationsHistory() + { + QTF::GetWinningCombinationsHistory_input input{}; + QTF::GetWinningCombinationsHistory_output output{}; + callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_WINNING_COMBINATIONS_HISTORY, input, output); + return output; + } + // Procedure wrappers QTF::BuyTicket_output buyTicket(const id& user, uint64 reward, const QTFRandomValues& numbers) { @@ -1470,6 +1493,65 @@ TEST(ContractQThirtyFour, GetPlayers_ReturnsPurchasedTickets) EXPECT_TRUE(isZero(players.players.get(3).player)); } +TEST(ContractQThirtyFour, WinningCombinationsHistory_StoresWinningCombinationAfterDraw) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + ctl.fundAndBuyTicket(user, ticketPrice, ctl.makeValidNumbers(1, 2, 3, 4)); + + m256i digest = {}; + digest.m256i_u64[0] = 0x1122334455667788ULL; + ctl.drawWithDigest(digest); + + const QTF::GetWinnerData_output winnerData = ctl.getWinnerData(); + const QTF::GetWinningCombinationsHistory_output history = ctl.getWinningCombinationsHistory(); + + EXPECT_EQ(history.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + EXPECT_TRUE(valuesEqual(history.history.get(0), winnerData.winnerData.winnerValues)); + EXPECT_EQ(ctl.state()->getWinningCombinationsWriteIndex(), 1ULL); +} + +TEST(ContractQThirtyFour, WinningCombinationsHistory_WrapAroundKeepsLatestAtLastWrittenSlot) +{ + ContractTestingQTF ctl; + ctl.forceSchedule(QTF_ANY_DAY_SCHEDULE); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + static constexpr uint64 rounds = QTF_WINNING_COMBINATIONS_HISTORY_SIZE + 3ULL; + QTFRandomValues lastWinningValues{}; + + for (uint64 i = 0; i < rounds; ++i) + { + ctl.beginEpochWithValidTime(); + const id user = id::randomValue(); + ctl.fundAndBuyTicket(user, ticketPrice, ctl.makeValidNumbers(1, 2, 3, 4)); + + m256i digest = {}; + digest.m256i_u64[0] = 0xABCDEF0000000000ULL + i; + ctl.drawWithDigest(digest); + + lastWinningValues = ctl.getWinnerData().winnerData.winnerValues; + } + + const QTF::GetWinningCombinationsHistory_output history = ctl.getWinningCombinationsHistory(); + EXPECT_EQ(history.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + + for (uint64 i = 0; i < history.history.capacity(); ++i) + { + EXPECT_FALSE(isEmptyRandomValues(history.history.get(i))); + } + + const uint64 writeIndex = ctl.state()->getWinningCombinationsWriteIndex(); + EXPECT_EQ(writeIndex, rounds % QTF_WINNING_COMBINATIONS_HISTORY_SIZE); + + const uint64 lastWrittenSlot = + (writeIndex + QTF_WINNING_COMBINATIONS_HISTORY_SIZE - 1ULL) % QTF_WINNING_COMBINATIONS_HISTORY_SIZE; + EXPECT_TRUE(valuesEqual(history.history.get(lastWrittenSlot), lastWinningValues)); +} + // ============================================================================ // STATE AND POOLS TESTS // ============================================================================ From 3b3e171b2b9b41b2df0d7f84405bb6886e9962b1 Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 19 Feb 2026 17:57:17 +0300 Subject: [PATCH 03/15] Fixes --- src/contracts/QThirtyFour.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index cb7535b22..4d9664ed4 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -1656,7 +1656,7 @@ struct QTF : ContractBase PRIVATE_FUNCTION_WITH_LOCALS(CheckContractBalance) { qpi.getEntity(SELF, locals.entity); - output.actualBalance = RL::max(locals.entity.incomingAmount - locals.entity.outgoingAmount, 0i64); + output.actualBalance = RL::max(locals.entity.incomingAmount - locals.entity.outgoingAmount, 0LL); output.hasEnough = (output.actualBalance >= input.expectedRevenue); } From 697757c1af655a016bacba9891fc5be4cad12ce4 Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 19 Feb 2026 19:34:36 +0300 Subject: [PATCH 04/15] Fixes: contract verify --- src/contracts/QThirtyFour.h | 9 +++++++-- test/contract_qtf.cpp | 6 +++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 4d9664ed4..0e7c1b13d 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -701,7 +701,12 @@ struct QTF : ContractBase struct GetWinningCombinationsHistory_output { - Array, QTF_WINNING_COMBINATIONS_HISTORY_SIZE> history; + struct WinningCombination + { + Array values; + }; + + Array history; uint8 returnCode; }; @@ -1134,7 +1139,7 @@ struct QTF : ContractBase PUBLIC_FUNCTION(GetWinningCombinationsHistory) { - output.history = state.winningCombinationsHistory; + copyMemory(output.history, state.winningCombinationsHistory); output.returnCode = toReturnCode(EReturnCode::SUCCESS); } diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp index 6a1b405a9..0d9f609ae 100644 --- a/test/contract_qtf.cpp +++ b/test/contract_qtf.cpp @@ -1510,7 +1510,7 @@ TEST(ContractQThirtyFour, WinningCombinationsHistory_StoresWinningCombinationAft const QTF::GetWinningCombinationsHistory_output history = ctl.getWinningCombinationsHistory(); EXPECT_EQ(history.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); - EXPECT_TRUE(valuesEqual(history.history.get(0), winnerData.winnerData.winnerValues)); + EXPECT_TRUE(valuesEqual(history.history.get(0).values, winnerData.winnerData.winnerValues)); EXPECT_EQ(ctl.state()->getWinningCombinationsWriteIndex(), 1ULL); } @@ -1541,7 +1541,7 @@ TEST(ContractQThirtyFour, WinningCombinationsHistory_WrapAroundKeepsLatestAtLast for (uint64 i = 0; i < history.history.capacity(); ++i) { - EXPECT_FALSE(isEmptyRandomValues(history.history.get(i))); + EXPECT_FALSE(isEmptyRandomValues(history.history.get(i).values)); } const uint64 writeIndex = ctl.state()->getWinningCombinationsWriteIndex(); @@ -1549,7 +1549,7 @@ TEST(ContractQThirtyFour, WinningCombinationsHistory_WrapAroundKeepsLatestAtLast const uint64 lastWrittenSlot = (writeIndex + QTF_WINNING_COMBINATIONS_HISTORY_SIZE - 1ULL) % QTF_WINNING_COMBINATIONS_HISTORY_SIZE; - EXPECT_TRUE(valuesEqual(history.history.get(lastWrittenSlot), lastWinningValues)); + EXPECT_TRUE(valuesEqual(history.history.get(lastWrittenSlot).values, lastWinningValues)); } // ============================================================================ From d550676f27696b42b595f9b5a58e0bf977b4c6fd Mon Sep 17 00:00:00 2001 From: N-010 Date: Sun, 22 Feb 2026 00:22:17 +0300 Subject: [PATCH 05/15] Adds wonAmount --- src/contracts/QThirtyFour.h | 33 +++++++++++++----- test/contract_qtf.cpp | 67 +++++++++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 0e7c1b13d..421e1d96f 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -95,11 +95,21 @@ struct QTF : ContractBase { id player; Array randomValues; + + bool isValid() const { return !isZero(player); } + }; + + struct WinnerPlayerData + { + PlayerData playerData; + uint64 wonAmount; + + bool isValid() const { return playerData.isValid(); } }; struct WinnerData { - Array winners; + Array winners; Array winnerValues; uint64 winnerCounter; uint16 epoch; @@ -665,6 +675,7 @@ struct QTF : ContractBase uint64 rlShares; // Cache for countMatches results to avoid redundant calculations Array cachedMatches; + WinnerPlayerData winnerPlayerData; }; struct END_EPOCH_locals @@ -1204,14 +1215,14 @@ struct QTF : ContractBase static void clearWinerData(QTF& state) { setMemory(state.lastWinnerData, 0); } - static void fillWinnerData(QTF& state, const PlayerData& playerData, const Array& winnerValues, + static void fillWinnerData(QTF& state, const WinnerPlayerData& winnerPlayerData, const Array& winnerValues, const uint16& epoch) { - if (!isZero(playerData.player)) + if (winnerPlayerData.isValid()) { if (state.lastWinnerData.winnerCounter < state.lastWinnerData.winners.capacity()) { - state.lastWinnerData.winners.set(state.lastWinnerData.winnerCounter++, playerData); + state.lastWinnerData.winners.set(state.lastWinnerData.winnerCounter++, winnerPlayerData); } } @@ -1222,7 +1233,7 @@ struct QTF : ContractBase static void addWinningCombinationToHistory(QTF& state, const Array& winnerValues) { state.winningCombinationsHistory.set(state.winningCombinationsCount, winnerValues); - state.winningCombinationsCount = mod(++state.winningCombinationsCount, state.winningCombinationsHistory.capacity()); + state.winningCombinationsCount = mod(++state.winningCombinationsCount, state.winningCombinationsHistory.capacity()); } WinnerData lastWinnerData; // last winners snapshot @@ -1481,13 +1492,17 @@ struct QTF : ContractBase 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); + locals.winnerPlayerData.playerData = state.players.get(locals.i); + locals.winnerPlayerData.wonAmount = locals.k2PerWinner; + fillWinnerData(state, locals.winnerPlayerData, 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); + locals.winnerPlayerData.playerData = state.players.get(locals.i); + locals.winnerPlayerData.wonAmount = locals.k3PerWinner; + fillWinnerData(state, locals.winnerPlayerData, locals.winningValues, locals.currentEpoch); } // k4 payout (jackpot) if (locals.matches == 4 && locals.countK4 > 0) @@ -1496,7 +1511,9 @@ struct QTF : ContractBase { qpi.transfer(state.players.get(locals.i).player, locals.jackpotPerK4Winner); } - fillWinnerData(state, state.players.get(locals.i), locals.winningValues, locals.currentEpoch); + locals.winnerPlayerData.playerData = state.players.get(locals.i); + locals.winnerPlayerData.wonAmount = locals.jackpotPerK4Winner; + fillWinnerData(state, locals.winnerPlayerData, locals.winningValues, locals.currentEpoch); } ++locals.i; diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp index 0d9f609ae..8972931d2 100644 --- a/test/contract_qtf.cpp +++ b/test/contract_qtf.cpp @@ -1547,8 +1547,7 @@ TEST(ContractQThirtyFour, WinningCombinationsHistory_WrapAroundKeepsLatestAtLast const uint64 writeIndex = ctl.state()->getWinningCombinationsWriteIndex(); EXPECT_EQ(writeIndex, rounds % QTF_WINNING_COMBINATIONS_HISTORY_SIZE); - const uint64 lastWrittenSlot = - (writeIndex + QTF_WINNING_COMBINATIONS_HISTORY_SIZE - 1ULL) % QTF_WINNING_COMBINATIONS_HISTORY_SIZE; + const uint64 lastWrittenSlot = (writeIndex + QTF_WINNING_COMBINATIONS_HISTORY_SIZE - 1ULL) % QTF_WINNING_COMBINATIONS_HISTORY_SIZE; EXPECT_TRUE(valuesEqual(history.history.get(lastWrittenSlot).values, lastWinningValues)); } @@ -2651,6 +2650,70 @@ TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_MultipleWinners_Split EXPECT_EQ(static_cast(getBalance(w2) - w2Before), expectedPerWinner); } +TEST(ContractQThirtyFour, WinnerData_WonAmount_MatchesBalanceGain_ForK2K3K4) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0x1122334455667788ULL; + const auto nums = ctl.computeWinningAndLosing(testDigest); + + static constexpr uint64 initialJackpot = 500000000ULL; + ctl.state()->setJackpot(initialJackpot); + increaseEnergy(ctl.qtfSelf(), initialJackpot); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + const id k4Winner = id::randomValue(); + const id k3Winner = id::randomValue(); + const id k2Winner = id::randomValue(); + const id loser = id::randomValue(); + + ctl.fundAndBuyTicket(k4Winner, ticketPrice, nums.winning); + ctl.fundAndBuyTicket(k3Winner, ticketPrice, ctl.makeK3Numbers(nums.winning, 0)); + ctl.fundAndBuyTicket(k2Winner, ticketPrice, ctl.makeK2Numbers(nums.winning, 0)); + ctl.fundAndBuyTicket(loser, ticketPrice, nums.losing); + + const uint64 k4Before = getBalance(k4Winner); + const uint64 k3Before = getBalance(k3Winner); + const uint64 k2Before = getBalance(k2Winner); + + ctl.drawWithDigest(testDigest); + + const uint64 k4Gain = static_cast(getBalance(k4Winner) - k4Before); + const uint64 k3Gain = static_cast(getBalance(k3Winner) - k3Before); + const uint64 k2Gain = static_cast(getBalance(k2Winner) - k2Before); + + const QTF::GetWinnerData_output winnerData = ctl.getWinnerData(); + bool foundK4 = false; + bool foundK3 = false; + bool foundK2 = false; + for (uint64 i = 0; i < winnerData.winnerData.winnerCounter; ++i) + { + const QTF::WinnerPlayerData& winnerEntry = winnerData.winnerData.winners.get(i); + if (winnerEntry.playerData.player == k4Winner) + { + EXPECT_EQ(winnerEntry.wonAmount, k4Gain); + foundK4 = true; + } + if (winnerEntry.playerData.player == k3Winner) + { + EXPECT_EQ(winnerEntry.wonAmount, k3Gain); + foundK3 = true; + } + if (winnerEntry.playerData.player == k2Winner) + { + EXPECT_EQ(winnerEntry.wonAmount, k2Gain); + foundK2 = true; + } + } + + EXPECT_TRUE(foundK4); + EXPECT_TRUE(foundK3); + EXPECT_TRUE(foundK2); +} + TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_ReseedLimitedByQRP) { ContractTestingQTF ctl; From da0c1ce265bfd69ec18cda01bb12b7d0a5eba1f5 Mon Sep 17 00:00:00 2001 From: N-010 Date: Mon, 23 Feb 2026 22:06:17 +0300 Subject: [PATCH 06/15] Fixes save to state --- src/contracts/QThirtyFour.h | 70 ++++++------ test/contract_qtf.cpp | 214 +++++++++++++++++++++++++----------- 2 files changed, 189 insertions(+), 95 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 421e1d96f..8b8207628 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -95,21 +95,11 @@ struct QTF : ContractBase { id player; Array randomValues; - - bool isValid() const { return !isZero(player); } - }; - - struct WinnerPlayerData - { - PlayerData playerData; - uint64 wonAmount; - - bool isValid() const { return playerData.isValid(); } }; struct WinnerData { - Array winners; + Array winners; Array winnerValues; uint64 winnerCounter; uint16 epoch; @@ -673,9 +663,10 @@ struct QTF : ContractBase uint64 rlTotalShares; uint64 rlPayback; uint64 rlShares; + id winnerPlayerId; + uint64 winnerReward; // Cache for countMatches results to avoid redundant calculations Array cachedMatches; - WinnerPlayerData winnerPlayerData; }; struct END_EPOCH_locals @@ -1213,16 +1204,21 @@ struct QTF : ContractBase } } - static void clearWinerData(QTF& state) { setMemory(state.lastWinnerData, 0); } + static void clearWinerData(QTF& state) + { + // Clear per-draw winner snapshots only for a settlement that actually reaches draw/payout stage. + setMemory(state.lastWinnerData, 0); + setMemory(state.winnerRewardByPlayer, 0); + } - static void fillWinnerData(QTF& state, const WinnerPlayerData& winnerPlayerData, const Array& winnerValues, + static void fillWinnerData(QTF& state, const PlayerData& playerData, const Array& winnerValues, const uint16& epoch) { - if (winnerPlayerData.isValid()) + if (!isZero(playerData.player)) { if (state.lastWinnerData.winnerCounter < state.lastWinnerData.winners.capacity()) { - state.lastWinnerData.winners.set(state.lastWinnerData.winnerCounter++, winnerPlayerData); + state.lastWinnerData.winners.set(state.lastWinnerData.winnerCounter++, playerData); } } @@ -1230,6 +1226,18 @@ struct QTF : ContractBase state.lastWinnerData.epoch = epoch; } + static void addWinnerReward(QTF& state, const id& winnerPlayerId, const uint64& winnerPayout, uint64& winnerReward) + { + if (state.winnerRewardByPlayer.get(winnerPlayerId, winnerReward)) + { + state.winnerRewardByPlayer.set(winnerPlayerId, sadd(winnerReward, winnerPayout)); + } + else + { + state.winnerRewardByPlayer.set(winnerPlayerId, winnerPayout); + } + } + static void addWinningCombinationToHistory(QTF& state, const Array& winnerValues) { state.winningCombinationsHistory.set(state.winningCombinationsCount, winnerValues); @@ -1254,13 +1262,15 @@ struct QTF : ContractBase uint16 frRoundsAtOrAboveTarget; // hysteresis counter for FR off uint8 currentState; // bitmask of STATE_* flags (e.g., STATE_SELLING) Array, QTF_WINNING_COMBINATIONS_HISTORY_SIZE> - winningCombinationsHistory; // ring buffer of winning combinations - uint64 winningCombinationsCount; // next write position in ring buffer + winningCombinationsHistory; // ring buffer of winning combinations + uint64 winningCombinationsCount; // next write position in ring buffer + HashMap winnerRewardByPlayer; // payout amount by winner id for the latest successful draw private: // Core settlement pipeline for one epoch: fees, FR redirects, payouts, jackpot/reserve updates. PRIVATE_PROCEDURE_WITH_LOCALS(SettleEpoch) { + // Intentionally preserve previous winner snapshots on early exits below (no draw or aborted settlement). if (state.numberOfPlayers == 0) { return; @@ -1396,7 +1406,7 @@ struct QTF : ContractBase locals.k2PayoutPool = locals.k2Pool; // mutable pools after top-ups locals.k3PayoutPool = locals.k3Pool; - // Reset last-winner snapshot for this settlement (per-round view). + // Reset winner snapshots only after settlement preconditions pass. clearWinerData(state); // Generate winning random values using CALL @@ -1487,33 +1497,31 @@ struct QTF : ContractBase while (locals.i < state.numberOfPlayers) { locals.matches = locals.cachedMatches.get(locals.i); // Use cached result + locals.winnerPlayerId = state.players.get(locals.i).player; // k2 payout if (locals.matches == 2 && locals.countK2 > 0 && locals.k2PerWinner > 0) { - qpi.transfer(state.players.get(locals.i).player, locals.k2PerWinner); - locals.winnerPlayerData.playerData = state.players.get(locals.i); - locals.winnerPlayerData.wonAmount = locals.k2PerWinner; - fillWinnerData(state, locals.winnerPlayerData, locals.winningValues, locals.currentEpoch); + qpi.transfer(locals.winnerPlayerId, locals.k2PerWinner); + addWinnerReward(state, locals.winnerPlayerId, locals.k2PerWinner, locals.winnerReward); + 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); - locals.winnerPlayerData.playerData = state.players.get(locals.i); - locals.winnerPlayerData.wonAmount = locals.k3PerWinner; - fillWinnerData(state, locals.winnerPlayerData, locals.winningValues, locals.currentEpoch); + qpi.transfer(locals.winnerPlayerId, locals.k3PerWinner); + addWinnerReward(state, locals.winnerPlayerId, locals.k3PerWinner, locals.winnerReward); + 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); + qpi.transfer(locals.winnerPlayerId, locals.jackpotPerK4Winner); + addWinnerReward(state, locals.winnerPlayerId, locals.jackpotPerK4Winner, locals.winnerReward); } - locals.winnerPlayerData.playerData = state.players.get(locals.i); - locals.winnerPlayerData.wonAmount = locals.jackpotPerK4Winner; - fillWinnerData(state, locals.winnerPlayerData, locals.winningValues, locals.currentEpoch); + fillWinnerData(state, state.players.get(locals.i), locals.winningValues, locals.currentEpoch); } ++locals.i; diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp index 8972931d2..b6803cb6c 100644 --- a/test/contract_qtf.cpp +++ b/test/contract_qtf.cpp @@ -104,6 +104,7 @@ class QTFChecker : public QTF uint32 getFrRoundsAtOrAboveTarget() const { return frRoundsAtOrAboveTarget; } uint64 getWinningCombinationsWriteIndex() const { return winningCombinationsCount; } const id& team() const { return teamAddress; } + bool tryGetWinnerReward(const id& playerId, uint64& reward) const { return winnerRewardByPlayer.get(playerId, reward); } void setScheduleMask(uint8 newMask) { schedule = newMask; } void setJackpot(uint64 value) { jackpot = value; } @@ -1848,6 +1849,110 @@ TEST(ContractQThirtyFour, Settlement_RoundsSinceK4_Increments) } } +TEST(ContractQThirtyFour, Settlement_WinnerRewardMap_StoresWinnerAmount) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + ctl.forceFRDisabledForBaseline(); + + m256i digest = {}; + digest.m256i_u64[0] = 0x4444555566667777ULL; + + const QTFRandomValues winning = ctl.computeWinningNumbersForDigest(digest); + const QTFRandomValues k2Ticket = ctl.makeK2Numbers(winning, 0); + const QTFRandomValues losingTicket = ctl.makeLosingNumbers(winning); + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + const id k2Winner = id::randomValue(); + const id loser = id::randomValue(); + ctl.fundAndBuyTicket(k2Winner, ticketPrice, k2Ticket); + ctl.fundAndBuyTicket(loser, ticketPrice, losingTicket); + + ctl.setPrevSpectrumDigest(digest); + ctl.endEpoch(); + + const QTF::GetFees_output fees = ctl.getFees(); + uint64 winnersBlock = 0; + uint64 k2Pool = 0; + uint64 k3Pool = 0; + computeBaselinePrizePools(ticketPrice * 2ULL, fees, winnersBlock, k2Pool, k3Pool); + + uint64 winnerReward = 0; + EXPECT_TRUE(ctl.state()->tryGetWinnerReward(k2Winner, winnerReward)); + EXPECT_EQ(winnerReward, k2Pool); + + uint64 loserReward = 0; + EXPECT_FALSE(ctl.state()->tryGetWinnerReward(loser, loserReward)); +} + +TEST(ContractQThirtyFour, Settlement_WinnerRewardMap_IsClearedBeforeNextRound) +{ + ContractTestingQTF ctl; + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + // Round 1: produce one k2 winner and ensure reward exists. + ctl.beginEpochWithValidTime(); + ctl.forceFRDisabledForBaseline(); + + m256i digestRound1 = {}; + digestRound1.m256i_u64[0] = 0x88889999AAAABBBBULL; + const QTFRandomValues winningRound1 = ctl.computeWinningNumbersForDigest(digestRound1); + + const id winnerRound1 = id::randomValue(); + ctl.fundAndBuyTicket(winnerRound1, ticketPrice, ctl.makeK2Numbers(winningRound1, 1)); + + ctl.setPrevSpectrumDigest(digestRound1); + ctl.endEpoch(); + + uint64 rewardRound1 = 0; + EXPECT_TRUE(ctl.state()->tryGetWinnerReward(winnerRound1, rewardRound1)); + EXPECT_GT(rewardRound1, 0ULL); + + // Round 2: no winners, so per-draw map must be cleared. + ctl.beginEpochWithValidTime(); + ctl.forceFRDisabledForBaseline(); + + m256i digestRound2 = {}; + digestRound2.m256i_u64[0] = 0xCCCCDDDDEEEEFFFFULL; + const auto numsRound2 = ctl.computeWinningAndLosing(digestRound2); + const id loserRound2 = id::randomValue(); + ctl.fundAndBuyTicket(loserRound2, ticketPrice, numsRound2.losing); + + ctl.setPrevSpectrumDigest(digestRound2); + ctl.endEpoch(); + + uint64 staleReward = 0; + EXPECT_FALSE(ctl.state()->tryGetWinnerReward(winnerRound1, staleReward)); + EXPECT_FALSE(ctl.state()->tryGetWinnerReward(loserRound2, staleReward)); +} + +TEST(ContractQThirtyFour, Settlement_WinnerRewardMap_AccumulatesForSamePlayerMultiTicketWins) +{ + ContractTestingQTF ctl; + ctl.beginEpochWithValidTime(); + ctl.forceFRDisabledForBaseline(); + + m256i digest = {}; + digest.m256i_u64[0] = 0x1234ABCDEF995533ULL; + const QTFRandomValues winning = ctl.computeWinningNumbersForDigest(digest); + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + const id multiWinner = id::randomValue(); + ctl.fundAndBuyTicket(multiWinner, ticketPrice, ctl.makeK2Numbers(winning, 0)); + ctl.fundAndBuyTicket(multiWinner, ticketPrice, ctl.makeK3Numbers(winning, 1)); + ctl.fundAndBuyTicket(multiWinner, ticketPrice, ctl.makeK2Numbers(winning, 2)); + + const uint64 balanceBefore = getBalance(multiWinner); + ctl.setPrevSpectrumDigest(digest); + ctl.endEpoch(); + const uint64 gained = static_cast(getBalance(multiWinner) - balanceBefore); + + uint64 recordedReward = 0; + EXPECT_TRUE(ctl.state()->tryGetWinnerReward(multiWinner, recordedReward)); + EXPECT_EQ(recordedReward, gained); + EXPECT_GT(recordedReward, 0ULL); +} + // ============================================================================ // FAST-RECOVERY (FR) TESTS // ============================================================================ @@ -2590,8 +2695,12 @@ TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_DepletesAndReseeds) // Trigger settlement using our fixed prevSpectrumDigest const uint64 k4WinnerBefore = getBalance(k4Winner); + const uint64 k3WinnerBefore = getBalance(k3Winner); + const uint64 k2WinnerBefore = getBalance(k2Winner); ctl.drawWithDigest(testDigest); const uint64 k4WinnerAfter = getBalance(k4Winner); + const uint64 k3WinnerAfter = getBalance(k3Winner); + const uint64 k2WinnerAfter = getBalance(k2Winner); // Verify k=4 jackpot win behavior: const uint64 jackpotAfter = ctl.state()->getJackpot(); @@ -2614,6 +2723,16 @@ TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_DepletesAndReseeds) // Verify k=4 winner received exact payout (jackpotBefore / countK4). EXPECT_EQ(static_cast(k4WinnerAfter - k4WinnerBefore), initialJackpot); + + // Verify winner reward map stores exact per-draw paid amounts. + uint64 reward = 0; + EXPECT_TRUE(ctl.state()->tryGetWinnerReward(k4Winner, reward)); + EXPECT_EQ(reward, static_cast(k4WinnerAfter - k4WinnerBefore)); + EXPECT_TRUE(ctl.state()->tryGetWinnerReward(k3Winner, reward)); + EXPECT_EQ(reward, static_cast(k3WinnerAfter - k3WinnerBefore)); + EXPECT_TRUE(ctl.state()->tryGetWinnerReward(k2Winner, reward)); + EXPECT_EQ(reward, static_cast(k2WinnerAfter - k2WinnerBefore)); + EXPECT_FALSE(ctl.state()->tryGetWinnerReward(loser, reward)); } TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_MultipleWinners_SplitsEvenly) @@ -2628,7 +2747,7 @@ TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_MultipleWinners_Split testDigest.m256i_u64[0] = 0xA5A5A5A5A5A5A5A5ULL; const auto nums = ctl.computeWinningAndLosing(testDigest); - const uint64 initialJackpot = 900000000ULL; + static constexpr uint64 initialJackpot = 900000000ULL; ctl.state()->setJackpot(initialJackpot); ctl.forceFREnabledWithinWindow(1); increaseEnergy(ctl.qtfSelf(), initialJackpot); @@ -2648,70 +2767,12 @@ TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_MultipleWinners_Split const uint64 expectedPerWinner = initialJackpot / 2; EXPECT_EQ(static_cast(getBalance(w1) - w1Before), expectedPerWinner); EXPECT_EQ(static_cast(getBalance(w2) - w2Before), expectedPerWinner); -} - -TEST(ContractQThirtyFour, WinnerData_WonAmount_MatchesBalanceGain_ForK2K3K4) -{ - ContractTestingQTF ctl; - ctl.startAnyDayEpoch(); - - m256i testDigest = {}; - testDigest.m256i_u64[0] = 0x1122334455667788ULL; - const auto nums = ctl.computeWinningAndLosing(testDigest); - - static constexpr uint64 initialJackpot = 500000000ULL; - ctl.state()->setJackpot(initialJackpot); - increaseEnergy(ctl.qtfSelf(), initialJackpot); - - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - const id k4Winner = id::randomValue(); - const id k3Winner = id::randomValue(); - const id k2Winner = id::randomValue(); - const id loser = id::randomValue(); - - ctl.fundAndBuyTicket(k4Winner, ticketPrice, nums.winning); - ctl.fundAndBuyTicket(k3Winner, ticketPrice, ctl.makeK3Numbers(nums.winning, 0)); - ctl.fundAndBuyTicket(k2Winner, ticketPrice, ctl.makeK2Numbers(nums.winning, 0)); - ctl.fundAndBuyTicket(loser, ticketPrice, nums.losing); - - const uint64 k4Before = getBalance(k4Winner); - const uint64 k3Before = getBalance(k3Winner); - const uint64 k2Before = getBalance(k2Winner); - - ctl.drawWithDigest(testDigest); - - const uint64 k4Gain = static_cast(getBalance(k4Winner) - k4Before); - const uint64 k3Gain = static_cast(getBalance(k3Winner) - k3Before); - const uint64 k2Gain = static_cast(getBalance(k2Winner) - k2Before); - const QTF::GetWinnerData_output winnerData = ctl.getWinnerData(); - bool foundK4 = false; - bool foundK3 = false; - bool foundK2 = false; - for (uint64 i = 0; i < winnerData.winnerData.winnerCounter; ++i) - { - const QTF::WinnerPlayerData& winnerEntry = winnerData.winnerData.winners.get(i); - if (winnerEntry.playerData.player == k4Winner) - { - EXPECT_EQ(winnerEntry.wonAmount, k4Gain); - foundK4 = true; - } - if (winnerEntry.playerData.player == k3Winner) - { - EXPECT_EQ(winnerEntry.wonAmount, k3Gain); - foundK3 = true; - } - if (winnerEntry.playerData.player == k2Winner) - { - EXPECT_EQ(winnerEntry.wonAmount, k2Gain); - foundK2 = true; - } - } - - EXPECT_TRUE(foundK4); - EXPECT_TRUE(foundK3); - EXPECT_TRUE(foundK2); + uint64 reward = 0; + EXPECT_TRUE(ctl.state()->tryGetWinnerReward(w1, reward)); + EXPECT_EQ(reward, expectedPerWinner); + EXPECT_TRUE(ctl.state()->tryGetWinnerReward(w2, reward)); + EXPECT_EQ(reward, expectedPerWinner); } TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_ReseedLimitedByQRP) @@ -2742,6 +2803,9 @@ TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_ReseedLimitedByQRP) ctl.drawWithDigest(testDigest); EXPECT_EQ(static_cast(getBalance(w1) - w1Before), initialJackpot); + uint64 reward = 0; + EXPECT_TRUE(ctl.state()->tryGetWinnerReward(w1, reward)); + EXPECT_EQ(reward, initialJackpot); // With a single winning ticket and baseline overflow split, winnersOverflow == winnersBlock, reserveAdd == winnersBlock/2, carryAdd == // winnersBlock/2. @@ -2815,7 +2879,10 @@ TEST(ContractQThirtyFour, DeterministicWinner_K2K3Payouts_VerifyRevenueSplit) // Get balances before settlement const uint64 k3Winner1Before = getBalance(k3Winner1); + const uint64 k3Winner2Before = getBalance(k3Winner2); const uint64 k2Winner1Before = getBalance(k2Winner1); + const uint64 k2Winner2Before = getBalance(k2Winner2); + const uint64 k2Winner3Before = getBalance(k2Winner3); // Trigger settlement ctl.drawWithDigest(testDigest); @@ -2833,6 +2900,25 @@ TEST(ContractQThirtyFour, DeterministicWinner_K2K3Payouts_VerifyRevenueSplit) const uint64 k2Winner1Gained = k2Winner1After - k2Winner1Before; EXPECT_EQ(static_cast(k2Winner1Gained), expectedK2PayoutPerWinner) << "k=2 winner should receive one-third of k2 pool"; + const uint64 k3Winner2After = getBalance(k3Winner2); + const uint64 k2Winner2After = getBalance(k2Winner2); + const uint64 k2Winner3After = getBalance(k2Winner3); + EXPECT_EQ(static_cast(k3Winner2After - k3Winner2Before), expectedK3PayoutPerWinner); + EXPECT_EQ(static_cast(k2Winner2After - k2Winner2Before), expectedK2PayoutPerWinner); + EXPECT_EQ(static_cast(k2Winner3After - k2Winner3Before), expectedK2PayoutPerWinner); + + uint64 reward = 0; + EXPECT_TRUE(ctl.state()->tryGetWinnerReward(k3Winner1, reward)); + EXPECT_EQ(reward, expectedK3PayoutPerWinner); + EXPECT_TRUE(ctl.state()->tryGetWinnerReward(k3Winner2, reward)); + EXPECT_EQ(reward, expectedK3PayoutPerWinner); + EXPECT_TRUE(ctl.state()->tryGetWinnerReward(k2Winner1, reward)); + EXPECT_EQ(reward, expectedK2PayoutPerWinner); + EXPECT_TRUE(ctl.state()->tryGetWinnerReward(k2Winner2, reward)); + EXPECT_EQ(reward, expectedK2PayoutPerWinner); + EXPECT_TRUE(ctl.state()->tryGetWinnerReward(k2Winner3, reward)); + EXPECT_EQ(reward, expectedK2PayoutPerWinner); + // Verify winning numbers in winner data QTF::GetWinnerData_output winnerData = ctl.getWinnerData(); EXPECT_EQ(winnerData.winnerData.winnerValues.get(0), nums.winning.get(0)); From e21a102b8c3599ecc617d7f8c961fe188abd5572 Mon Sep 17 00:00:00 2001 From: N-010 Date: Mon, 23 Feb 2026 22:39:52 +0300 Subject: [PATCH 07/15] GetWinnerRewards --- src/contracts/QThirtyFour.h | 39 +++++++++++++++++++++++++++++++++++++ test/contract_qtf.cpp | 32 ++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 8b8207628..db1c0cebf 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -437,6 +437,29 @@ struct QTF : ContractBase WinnerData winnerData; }; + struct WinnerReward + { + id playerId; + uint64 reward; + }; + + struct GetWinnerRewards_input + { + }; + + struct GetWinnerRewards_output + { + Array winnerRewards; + uint64 numberOfWinnerRewards; + uint8 returnCode; + }; + + struct GetWinnerRewards_locals + { + WinnerReward winnerReward; + sint64 mapIndex; + }; + // Pools struct GetPools_input { @@ -761,6 +784,7 @@ struct QTF : ContractBase REGISTER_USER_FUNCTION(EstimatePrizePayouts, 9); REGISTER_USER_FUNCTION(GetPlayers, 10); REGISTER_USER_FUNCTION(GetWinningCombinationsHistory, 11); + REGISTER_USER_FUNCTION(GetWinnerRewards, 12); } BEGIN_EPOCH() @@ -1024,6 +1048,21 @@ struct QTF : ContractBase 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(GetWinnerRewards) + { + output.numberOfWinnerRewards = 0; + locals.mapIndex = state.winnerRewardByPlayer.nextElementIndex(NULL_INDEX); + while (locals.mapIndex != NULL_INDEX) + { + locals.winnerReward.playerId = state.winnerRewardByPlayer.key(locals.mapIndex); + locals.winnerReward.reward = state.winnerRewardByPlayer.value(locals.mapIndex); + + output.winnerRewards.set(output.numberOfWinnerRewards++, locals.winnerReward); + locals.mapIndex = state.winnerRewardByPlayer.nextElementIndex(locals.mapIndex); + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } PUBLIC_FUNCTION_WITH_LOCALS(GetPools) { output.pools.jackpot = state.jackpot; diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp index b6803cb6c..2f9628f90 100644 --- a/test/contract_qtf.cpp +++ b/test/contract_qtf.cpp @@ -24,6 +24,7 @@ constexpr uint16 QTF_FUNCTION_GET_FEES = 8; constexpr uint16 QTF_FUNCTION_ESTIMATE_PRIZE_PAYOUTS = 9; constexpr uint16 QTF_FUNCTION_GET_PLAYERS = 10; constexpr uint16 QTF_FUNCTION_GET_WINNING_COMBINATIONS_HISTORY = 11; +constexpr uint16 QTF_FUNCTION_GET_WINNER_REWARDS = 12; using QTFRandomValues = Array; @@ -86,6 +87,21 @@ namespace k2Pool = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000ULL; k3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000ULL; } + + static bool tryGetWinnerRewardFromArray(const QTF::GetWinnerRewards_output& output, const id& playerId, uint64& reward) + { + for (uint64 i = 0; i < output.numberOfWinnerRewards; ++i) + { + const QTF::WinnerReward entry = output.winnerRewards.get(i); + if (entry.playerId == playerId) + { + reward = entry.reward; + return true; + } + } + + return false; + } } // namespace constexpr uint8 QTF_ANY_DAY_SCHEDULE = 0xFF; @@ -387,6 +403,14 @@ class ContractTestingQTF : protected ContractTesting return output; } + QTF::GetWinnerRewards_output getWinnerRewards() + { + QTF::GetWinnerRewards_input input{}; + QTF::GetWinnerRewards_output output{}; + callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_WINNER_REWARDS, input, output); + return output; + } + // Procedure wrappers QTF::BuyTicket_output buyTicket(const id& user, uint64 reward, const QTFRandomValues& numbers) { @@ -1883,6 +1907,14 @@ TEST(ContractQThirtyFour, Settlement_WinnerRewardMap_StoresWinnerAmount) uint64 loserReward = 0; EXPECT_FALSE(ctl.state()->tryGetWinnerReward(loser, loserReward)); + + const QTF::GetWinnerRewards_output winnerRewardsPublic = ctl.getWinnerRewards(); + EXPECT_EQ(winnerRewardsPublic.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + + uint64 publicReward = 0; + EXPECT_TRUE(tryGetWinnerRewardFromArray(winnerRewardsPublic, k2Winner, publicReward)); + EXPECT_EQ(publicReward, k2Pool); + EXPECT_FALSE(tryGetWinnerRewardFromArray(winnerRewardsPublic, loser, publicReward)); } TEST(ContractQThirtyFour, Settlement_WinnerRewardMap_IsClearedBeforeNextRound) From 7c8ea9bd142828ec6e975fa2b8f12f217026a686 Mon Sep 17 00:00:00 2001 From: N-010 Date: Mon, 23 Feb 2026 23:08:00 +0300 Subject: [PATCH 08/15] Fixes type wonAmount --- src/contracts/QThirtyFour.h | 116 ++++++----------- test/contract_qtf.cpp | 251 ++++++++++-------------------------- 2 files changed, 104 insertions(+), 263 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index db1c0cebf..393edc746 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -95,11 +95,28 @@ struct QTF : ContractBase { id player; Array randomValues; + + bool isValid() const { return !isZero(player); } + }; + + struct WinnerPlayerData + { + id player; + Array randomValues; + uint32 wonAmount; + + void addPlayerData(const PlayerData& data) + { + player = data.player; + randomValues = data.randomValues; + } + + bool isValid() const { return !isZero(player); } }; struct WinnerData { - Array winners; + Array winners; Array winnerValues; uint64 winnerCounter; uint16 epoch; @@ -437,29 +454,6 @@ struct QTF : ContractBase WinnerData winnerData; }; - struct WinnerReward - { - id playerId; - uint64 reward; - }; - - struct GetWinnerRewards_input - { - }; - - struct GetWinnerRewards_output - { - Array winnerRewards; - uint64 numberOfWinnerRewards; - uint8 returnCode; - }; - - struct GetWinnerRewards_locals - { - WinnerReward winnerReward; - sint64 mapIndex; - }; - // Pools struct GetPools_input { @@ -686,10 +680,9 @@ struct QTF : ContractBase uint64 rlTotalShares; uint64 rlPayback; uint64 rlShares; - id winnerPlayerId; - uint64 winnerReward; // Cache for countMatches results to avoid redundant calculations Array cachedMatches; + WinnerPlayerData winnerPlayerData; }; struct END_EPOCH_locals @@ -784,7 +777,6 @@ struct QTF : ContractBase REGISTER_USER_FUNCTION(EstimatePrizePayouts, 9); REGISTER_USER_FUNCTION(GetPlayers, 10); REGISTER_USER_FUNCTION(GetWinningCombinationsHistory, 11); - REGISTER_USER_FUNCTION(GetWinnerRewards, 12); } BEGIN_EPOCH() @@ -1048,21 +1040,6 @@ struct QTF : ContractBase 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(GetWinnerRewards) - { - output.numberOfWinnerRewards = 0; - locals.mapIndex = state.winnerRewardByPlayer.nextElementIndex(NULL_INDEX); - while (locals.mapIndex != NULL_INDEX) - { - locals.winnerReward.playerId = state.winnerRewardByPlayer.key(locals.mapIndex); - locals.winnerReward.reward = state.winnerRewardByPlayer.value(locals.mapIndex); - - output.winnerRewards.set(output.numberOfWinnerRewards++, locals.winnerReward); - locals.mapIndex = state.winnerRewardByPlayer.nextElementIndex(locals.mapIndex); - } - - output.returnCode = toReturnCode(EReturnCode::SUCCESS); - } PUBLIC_FUNCTION_WITH_LOCALS(GetPools) { output.pools.jackpot = state.jackpot; @@ -1243,21 +1220,16 @@ struct QTF : ContractBase } } - static void clearWinerData(QTF& state) - { - // Clear per-draw winner snapshots only for a settlement that actually reaches draw/payout stage. - setMemory(state.lastWinnerData, 0); - setMemory(state.winnerRewardByPlayer, 0); - } + static void clearWinerData(QTF& state) { setMemory(state.lastWinnerData, 0); } - static void fillWinnerData(QTF& state, const PlayerData& playerData, const Array& winnerValues, + static void fillWinnerData(QTF& state, const WinnerPlayerData& winnerPlayerData, const Array& winnerValues, const uint16& epoch) { - if (!isZero(playerData.player)) + if (winnerPlayerData.isValid()) { if (state.lastWinnerData.winnerCounter < state.lastWinnerData.winners.capacity()) { - state.lastWinnerData.winners.set(state.lastWinnerData.winnerCounter++, playerData); + state.lastWinnerData.winners.set(state.lastWinnerData.winnerCounter++, winnerPlayerData); } } @@ -1265,18 +1237,6 @@ struct QTF : ContractBase state.lastWinnerData.epoch = epoch; } - static void addWinnerReward(QTF& state, const id& winnerPlayerId, const uint64& winnerPayout, uint64& winnerReward) - { - if (state.winnerRewardByPlayer.get(winnerPlayerId, winnerReward)) - { - state.winnerRewardByPlayer.set(winnerPlayerId, sadd(winnerReward, winnerPayout)); - } - else - { - state.winnerRewardByPlayer.set(winnerPlayerId, winnerPayout); - } - } - static void addWinningCombinationToHistory(QTF& state, const Array& winnerValues) { state.winningCombinationsHistory.set(state.winningCombinationsCount, winnerValues); @@ -1301,15 +1261,13 @@ struct QTF : ContractBase uint16 frRoundsAtOrAboveTarget; // hysteresis counter for FR off uint8 currentState; // bitmask of STATE_* flags (e.g., STATE_SELLING) Array, QTF_WINNING_COMBINATIONS_HISTORY_SIZE> - winningCombinationsHistory; // ring buffer of winning combinations - uint64 winningCombinationsCount; // next write position in ring buffer - HashMap winnerRewardByPlayer; // payout amount by winner id for the latest successful draw + winningCombinationsHistory; // ring buffer of winning combinations + uint64 winningCombinationsCount; // next write position in ring buffer private: // Core settlement pipeline for one epoch: fees, FR redirects, payouts, jackpot/reserve updates. PRIVATE_PROCEDURE_WITH_LOCALS(SettleEpoch) { - // Intentionally preserve previous winner snapshots on early exits below (no draw or aborted settlement). if (state.numberOfPlayers == 0) { return; @@ -1445,7 +1403,7 @@ struct QTF : ContractBase locals.k2PayoutPool = locals.k2Pool; // mutable pools after top-ups locals.k3PayoutPool = locals.k3Pool; - // Reset winner snapshots only after settlement preconditions pass. + // Reset last-winner snapshot for this settlement (per-round view). clearWinerData(state); // Generate winning random values using CALL @@ -1536,31 +1494,33 @@ struct QTF : ContractBase while (locals.i < state.numberOfPlayers) { locals.matches = locals.cachedMatches.get(locals.i); // Use cached result - locals.winnerPlayerId = state.players.get(locals.i).player; // k2 payout if (locals.matches == 2 && locals.countK2 > 0 && locals.k2PerWinner > 0) { - qpi.transfer(locals.winnerPlayerId, locals.k2PerWinner); - addWinnerReward(state, locals.winnerPlayerId, locals.k2PerWinner, locals.winnerReward); - fillWinnerData(state, state.players.get(locals.i), locals.winningValues, locals.currentEpoch); + qpi.transfer(state.players.get(locals.i).player, locals.k2PerWinner); + locals.winnerPlayerData.addPlayerData(state.players.get(locals.i)); + locals.winnerPlayerData.wonAmount = static_cast(locals.k2PerWinner); + fillWinnerData(state, locals.winnerPlayerData, locals.winningValues, locals.currentEpoch); } // k3 payout if (locals.matches == 3 && locals.countK3 > 0 && locals.k3PerWinner > 0) { - qpi.transfer(locals.winnerPlayerId, locals.k3PerWinner); - addWinnerReward(state, locals.winnerPlayerId, locals.k3PerWinner, locals.winnerReward); - fillWinnerData(state, state.players.get(locals.i), locals.winningValues, locals.currentEpoch); + qpi.transfer(state.players.get(locals.i).player, locals.k3PerWinner); + locals.winnerPlayerData.addPlayerData(state.players.get(locals.i)); + locals.winnerPlayerData.wonAmount = static_cast(locals.k3PerWinner); + fillWinnerData(state, locals.winnerPlayerData, locals.winningValues, locals.currentEpoch); } // k4 payout (jackpot) if (locals.matches == 4 && locals.countK4 > 0) { if (locals.jackpotPerK4Winner > 0) { - qpi.transfer(locals.winnerPlayerId, locals.jackpotPerK4Winner); - addWinnerReward(state, locals.winnerPlayerId, locals.jackpotPerK4Winner, locals.winnerReward); + qpi.transfer(state.players.get(locals.i).player, locals.jackpotPerK4Winner); } - fillWinnerData(state, state.players.get(locals.i), locals.winningValues, locals.currentEpoch); + locals.winnerPlayerData.addPlayerData(state.players.get(locals.i)); + locals.winnerPlayerData.wonAmount = static_cast(locals.jackpotPerK4Winner); + fillWinnerData(state, locals.winnerPlayerData, locals.winningValues, locals.currentEpoch); } ++locals.i; diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp index 2f9628f90..c6172a8b1 100644 --- a/test/contract_qtf.cpp +++ b/test/contract_qtf.cpp @@ -24,7 +24,6 @@ constexpr uint16 QTF_FUNCTION_GET_FEES = 8; constexpr uint16 QTF_FUNCTION_ESTIMATE_PRIZE_PAYOUTS = 9; constexpr uint16 QTF_FUNCTION_GET_PLAYERS = 10; constexpr uint16 QTF_FUNCTION_GET_WINNING_COMBINATIONS_HISTORY = 11; -constexpr uint16 QTF_FUNCTION_GET_WINNER_REWARDS = 12; using QTFRandomValues = Array; @@ -87,21 +86,6 @@ namespace k2Pool = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000ULL; k3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000ULL; } - - static bool tryGetWinnerRewardFromArray(const QTF::GetWinnerRewards_output& output, const id& playerId, uint64& reward) - { - for (uint64 i = 0; i < output.numberOfWinnerRewards; ++i) - { - const QTF::WinnerReward entry = output.winnerRewards.get(i); - if (entry.playerId == playerId) - { - reward = entry.reward; - return true; - } - } - - return false; - } } // namespace constexpr uint8 QTF_ANY_DAY_SCHEDULE = 0xFF; @@ -120,7 +104,6 @@ class QTFChecker : public QTF uint32 getFrRoundsAtOrAboveTarget() const { return frRoundsAtOrAboveTarget; } uint64 getWinningCombinationsWriteIndex() const { return winningCombinationsCount; } const id& team() const { return teamAddress; } - bool tryGetWinnerReward(const id& playerId, uint64& reward) const { return winnerRewardByPlayer.get(playerId, reward); } void setScheduleMask(uint8 newMask) { schedule = newMask; } void setJackpot(uint64 value) { jackpot = value; } @@ -403,14 +386,6 @@ class ContractTestingQTF : protected ContractTesting return output; } - QTF::GetWinnerRewards_output getWinnerRewards() - { - QTF::GetWinnerRewards_input input{}; - QTF::GetWinnerRewards_output output{}; - callFunction(QTF_CONTRACT_INDEX, QTF_FUNCTION_GET_WINNER_REWARDS, input, output); - return output; - } - // Procedure wrappers QTF::BuyTicket_output buyTicket(const id& user, uint64 reward, const QTFRandomValues& numbers) { @@ -621,9 +596,8 @@ class ContractTestingQTF : protected ContractTesting WinningAndLosing computeWinningAndLosing(const m256i& digest) { - WinningAndLosing out; - out.winning = computeWinningNumbersForDigest(digest); - out.losing = makeLosingNumbers(out.winning); + WinningAndLosing out = {computeWinningNumbersForDigest(digest), makeLosingNumbers(out.winning)}; + return out; } @@ -654,7 +628,7 @@ class ContractTestingQTF : protected ContractTesting isWinning[v] = true; } - QTFRandomValues ticket; + QTFRandomValues ticket{}; uint64 outIndex = 0; // Take `matchCount` winning numbers as the matches (variant-dependent, wrap around 4). @@ -1873,118 +1847,6 @@ TEST(ContractQThirtyFour, Settlement_RoundsSinceK4_Increments) } } -TEST(ContractQThirtyFour, Settlement_WinnerRewardMap_StoresWinnerAmount) -{ - ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); - ctl.forceFRDisabledForBaseline(); - - m256i digest = {}; - digest.m256i_u64[0] = 0x4444555566667777ULL; - - const QTFRandomValues winning = ctl.computeWinningNumbersForDigest(digest); - const QTFRandomValues k2Ticket = ctl.makeK2Numbers(winning, 0); - const QTFRandomValues losingTicket = ctl.makeLosingNumbers(winning); - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - const id k2Winner = id::randomValue(); - const id loser = id::randomValue(); - ctl.fundAndBuyTicket(k2Winner, ticketPrice, k2Ticket); - ctl.fundAndBuyTicket(loser, ticketPrice, losingTicket); - - ctl.setPrevSpectrumDigest(digest); - ctl.endEpoch(); - - const QTF::GetFees_output fees = ctl.getFees(); - uint64 winnersBlock = 0; - uint64 k2Pool = 0; - uint64 k3Pool = 0; - computeBaselinePrizePools(ticketPrice * 2ULL, fees, winnersBlock, k2Pool, k3Pool); - - uint64 winnerReward = 0; - EXPECT_TRUE(ctl.state()->tryGetWinnerReward(k2Winner, winnerReward)); - EXPECT_EQ(winnerReward, k2Pool); - - uint64 loserReward = 0; - EXPECT_FALSE(ctl.state()->tryGetWinnerReward(loser, loserReward)); - - const QTF::GetWinnerRewards_output winnerRewardsPublic = ctl.getWinnerRewards(); - EXPECT_EQ(winnerRewardsPublic.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); - - uint64 publicReward = 0; - EXPECT_TRUE(tryGetWinnerRewardFromArray(winnerRewardsPublic, k2Winner, publicReward)); - EXPECT_EQ(publicReward, k2Pool); - EXPECT_FALSE(tryGetWinnerRewardFromArray(winnerRewardsPublic, loser, publicReward)); -} - -TEST(ContractQThirtyFour, Settlement_WinnerRewardMap_IsClearedBeforeNextRound) -{ - ContractTestingQTF ctl; - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - // Round 1: produce one k2 winner and ensure reward exists. - ctl.beginEpochWithValidTime(); - ctl.forceFRDisabledForBaseline(); - - m256i digestRound1 = {}; - digestRound1.m256i_u64[0] = 0x88889999AAAABBBBULL; - const QTFRandomValues winningRound1 = ctl.computeWinningNumbersForDigest(digestRound1); - - const id winnerRound1 = id::randomValue(); - ctl.fundAndBuyTicket(winnerRound1, ticketPrice, ctl.makeK2Numbers(winningRound1, 1)); - - ctl.setPrevSpectrumDigest(digestRound1); - ctl.endEpoch(); - - uint64 rewardRound1 = 0; - EXPECT_TRUE(ctl.state()->tryGetWinnerReward(winnerRound1, rewardRound1)); - EXPECT_GT(rewardRound1, 0ULL); - - // Round 2: no winners, so per-draw map must be cleared. - ctl.beginEpochWithValidTime(); - ctl.forceFRDisabledForBaseline(); - - m256i digestRound2 = {}; - digestRound2.m256i_u64[0] = 0xCCCCDDDDEEEEFFFFULL; - const auto numsRound2 = ctl.computeWinningAndLosing(digestRound2); - const id loserRound2 = id::randomValue(); - ctl.fundAndBuyTicket(loserRound2, ticketPrice, numsRound2.losing); - - ctl.setPrevSpectrumDigest(digestRound2); - ctl.endEpoch(); - - uint64 staleReward = 0; - EXPECT_FALSE(ctl.state()->tryGetWinnerReward(winnerRound1, staleReward)); - EXPECT_FALSE(ctl.state()->tryGetWinnerReward(loserRound2, staleReward)); -} - -TEST(ContractQThirtyFour, Settlement_WinnerRewardMap_AccumulatesForSamePlayerMultiTicketWins) -{ - ContractTestingQTF ctl; - ctl.beginEpochWithValidTime(); - ctl.forceFRDisabledForBaseline(); - - m256i digest = {}; - digest.m256i_u64[0] = 0x1234ABCDEF995533ULL; - const QTFRandomValues winning = ctl.computeWinningNumbersForDigest(digest); - const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); - - const id multiWinner = id::randomValue(); - ctl.fundAndBuyTicket(multiWinner, ticketPrice, ctl.makeK2Numbers(winning, 0)); - ctl.fundAndBuyTicket(multiWinner, ticketPrice, ctl.makeK3Numbers(winning, 1)); - ctl.fundAndBuyTicket(multiWinner, ticketPrice, ctl.makeK2Numbers(winning, 2)); - - const uint64 balanceBefore = getBalance(multiWinner); - ctl.setPrevSpectrumDigest(digest); - ctl.endEpoch(); - const uint64 gained = static_cast(getBalance(multiWinner) - balanceBefore); - - uint64 recordedReward = 0; - EXPECT_TRUE(ctl.state()->tryGetWinnerReward(multiWinner, recordedReward)); - EXPECT_EQ(recordedReward, gained); - EXPECT_GT(recordedReward, 0ULL); -} - // ============================================================================ // FAST-RECOVERY (FR) TESTS // ============================================================================ @@ -2727,12 +2589,8 @@ TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_DepletesAndReseeds) // Trigger settlement using our fixed prevSpectrumDigest const uint64 k4WinnerBefore = getBalance(k4Winner); - const uint64 k3WinnerBefore = getBalance(k3Winner); - const uint64 k2WinnerBefore = getBalance(k2Winner); ctl.drawWithDigest(testDigest); const uint64 k4WinnerAfter = getBalance(k4Winner); - const uint64 k3WinnerAfter = getBalance(k3Winner); - const uint64 k2WinnerAfter = getBalance(k2Winner); // Verify k=4 jackpot win behavior: const uint64 jackpotAfter = ctl.state()->getJackpot(); @@ -2755,16 +2613,6 @@ TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_DepletesAndReseeds) // Verify k=4 winner received exact payout (jackpotBefore / countK4). EXPECT_EQ(static_cast(k4WinnerAfter - k4WinnerBefore), initialJackpot); - - // Verify winner reward map stores exact per-draw paid amounts. - uint64 reward = 0; - EXPECT_TRUE(ctl.state()->tryGetWinnerReward(k4Winner, reward)); - EXPECT_EQ(reward, static_cast(k4WinnerAfter - k4WinnerBefore)); - EXPECT_TRUE(ctl.state()->tryGetWinnerReward(k3Winner, reward)); - EXPECT_EQ(reward, static_cast(k3WinnerAfter - k3WinnerBefore)); - EXPECT_TRUE(ctl.state()->tryGetWinnerReward(k2Winner, reward)); - EXPECT_EQ(reward, static_cast(k2WinnerAfter - k2WinnerBefore)); - EXPECT_FALSE(ctl.state()->tryGetWinnerReward(loser, reward)); } TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_MultipleWinners_SplitsEvenly) @@ -2799,12 +2647,70 @@ TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_MultipleWinners_Split const uint64 expectedPerWinner = initialJackpot / 2; EXPECT_EQ(static_cast(getBalance(w1) - w1Before), expectedPerWinner); EXPECT_EQ(static_cast(getBalance(w2) - w2Before), expectedPerWinner); +} + +TEST(ContractQThirtyFour, WinnerData_WonAmount_MatchesBalanceGain_ForK2K3K4) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + m256i testDigest = {}; + testDigest.m256i_u64[0] = 0x1122334455667788ULL; + const auto nums = ctl.computeWinningAndLosing(testDigest); + + static constexpr uint64 initialJackpot = 500000000ULL; + ctl.state()->setJackpot(initialJackpot); + increaseEnergy(ctl.qtfSelf(), initialJackpot); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + + const id k4Winner = id::randomValue(); + const id k3Winner = id::randomValue(); + const id k2Winner = id::randomValue(); + const id loser = id::randomValue(); + + ctl.fundAndBuyTicket(k4Winner, ticketPrice, nums.winning); + ctl.fundAndBuyTicket(k3Winner, ticketPrice, ctl.makeK3Numbers(nums.winning, 0)); + ctl.fundAndBuyTicket(k2Winner, ticketPrice, ctl.makeK2Numbers(nums.winning, 0)); + ctl.fundAndBuyTicket(loser, ticketPrice, nums.losing); + + const uint64 k4Before = getBalance(k4Winner); + const uint64 k3Before = getBalance(k3Winner); + const uint64 k2Before = getBalance(k2Winner); + + ctl.drawWithDigest(testDigest); + + const uint64 k4Gain = static_cast(getBalance(k4Winner) - k4Before); + const uint64 k3Gain = static_cast(getBalance(k3Winner) - k3Before); + const uint64 k2Gain = static_cast(getBalance(k2Winner) - k2Before); - uint64 reward = 0; - EXPECT_TRUE(ctl.state()->tryGetWinnerReward(w1, reward)); - EXPECT_EQ(reward, expectedPerWinner); - EXPECT_TRUE(ctl.state()->tryGetWinnerReward(w2, reward)); - EXPECT_EQ(reward, expectedPerWinner); + const QTF::GetWinnerData_output winnerData = ctl.getWinnerData(); + bool foundK4 = false; + bool foundK3 = false; + bool foundK2 = false; + for (uint64 i = 0; i < winnerData.winnerData.winnerCounter; ++i) + { + const QTF::WinnerPlayerData& winnerEntry = winnerData.winnerData.winners.get(i); + if (winnerEntry.player == k4Winner) + { + EXPECT_EQ(winnerEntry.wonAmount, k4Gain); + foundK4 = true; + } + if (winnerEntry.player == k3Winner) + { + EXPECT_EQ(winnerEntry.wonAmount, k3Gain); + foundK3 = true; + } + if (winnerEntry.player == k2Winner) + { + EXPECT_EQ(winnerEntry.wonAmount, k2Gain); + foundK2 = true; + } + } + + EXPECT_TRUE(foundK4); + EXPECT_TRUE(foundK3); + EXPECT_TRUE(foundK2); } TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_ReseedLimitedByQRP) @@ -2835,9 +2741,6 @@ TEST(ContractQThirtyFour, DeterministicWinner_K4JackpotWin_ReseedLimitedByQRP) ctl.drawWithDigest(testDigest); EXPECT_EQ(static_cast(getBalance(w1) - w1Before), initialJackpot); - uint64 reward = 0; - EXPECT_TRUE(ctl.state()->tryGetWinnerReward(w1, reward)); - EXPECT_EQ(reward, initialJackpot); // With a single winning ticket and baseline overflow split, winnersOverflow == winnersBlock, reserveAdd == winnersBlock/2, carryAdd == // winnersBlock/2. @@ -2911,10 +2814,7 @@ TEST(ContractQThirtyFour, DeterministicWinner_K2K3Payouts_VerifyRevenueSplit) // Get balances before settlement const uint64 k3Winner1Before = getBalance(k3Winner1); - const uint64 k3Winner2Before = getBalance(k3Winner2); const uint64 k2Winner1Before = getBalance(k2Winner1); - const uint64 k2Winner2Before = getBalance(k2Winner2); - const uint64 k2Winner3Before = getBalance(k2Winner3); // Trigger settlement ctl.drawWithDigest(testDigest); @@ -2932,25 +2832,6 @@ TEST(ContractQThirtyFour, DeterministicWinner_K2K3Payouts_VerifyRevenueSplit) const uint64 k2Winner1Gained = k2Winner1After - k2Winner1Before; EXPECT_EQ(static_cast(k2Winner1Gained), expectedK2PayoutPerWinner) << "k=2 winner should receive one-third of k2 pool"; - const uint64 k3Winner2After = getBalance(k3Winner2); - const uint64 k2Winner2After = getBalance(k2Winner2); - const uint64 k2Winner3After = getBalance(k2Winner3); - EXPECT_EQ(static_cast(k3Winner2After - k3Winner2Before), expectedK3PayoutPerWinner); - EXPECT_EQ(static_cast(k2Winner2After - k2Winner2Before), expectedK2PayoutPerWinner); - EXPECT_EQ(static_cast(k2Winner3After - k2Winner3Before), expectedK2PayoutPerWinner); - - uint64 reward = 0; - EXPECT_TRUE(ctl.state()->tryGetWinnerReward(k3Winner1, reward)); - EXPECT_EQ(reward, expectedK3PayoutPerWinner); - EXPECT_TRUE(ctl.state()->tryGetWinnerReward(k3Winner2, reward)); - EXPECT_EQ(reward, expectedK3PayoutPerWinner); - EXPECT_TRUE(ctl.state()->tryGetWinnerReward(k2Winner1, reward)); - EXPECT_EQ(reward, expectedK2PayoutPerWinner); - EXPECT_TRUE(ctl.state()->tryGetWinnerReward(k2Winner2, reward)); - EXPECT_EQ(reward, expectedK2PayoutPerWinner); - EXPECT_TRUE(ctl.state()->tryGetWinnerReward(k2Winner3, reward)); - EXPECT_EQ(reward, expectedK2PayoutPerWinner); - // Verify winning numbers in winner data QTF::GetWinnerData_output winnerData = ctl.getWinnerData(); EXPECT_EQ(winnerData.winnerData.winnerValues.get(0), nums.winning.get(0)); From 4a4b36962dd6141b73fea4fc39208041be3dfe11 Mon Sep 17 00:00:00 2001 From: N-010 Date: Sun, 15 Mar 2026 11:45:13 +0300 Subject: [PATCH 09/15] Add batch ticket purchase functionality and selection validation --- src/contracts/QThirtyFour.h | 377 ++++++++++++++++++++++++++++++++++-- 1 file changed, 366 insertions(+), 11 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 393edc746..dc5160aed 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -4,6 +4,8 @@ using namespace QPI; 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_MAX_BATCH_TICKETS = QTF_MAX_NUMBER_OF_PLAYERS / 2; +constexpr uint64 QTF_BATCH_TICKET_VALUES_COUNT = QTF_MAX_BATCH_TICKETS * QTF_RANDOM_VALUES_COUNT; constexpr uint64 QTF_TICKET_PRICE = 1000000; constexpr uint64 QTF_WINNING_COMBINATIONS_HISTORY_SIZE = 128; @@ -79,6 +81,7 @@ struct QTF : ContractBase INVALID_NUMBERS, INVALID_VALUE, TICKET_SELLING_CLOSED, + PARTIAL_PURCHASE, MAX_VALUE = UINT8_MAX }; @@ -192,6 +195,45 @@ struct QTF : ContractBase uint8 value; }; + struct CollectSelectionNumbers_input + { + Array numbers; + }; + struct CollectSelectionNumbers_output + { + Array normalizedNumbers; + bit isValid; + uint8 numberCount; + }; + struct CollectSelectionNumbers_locals + { + uint8 idx; + uint8 value; + uint8 outValue; + uint32 mask; + }; + + struct BuyPreparedTickets_input + { + Array tickets; + uint64 ticketCount; + }; + struct BuyPreparedTickets_output + { + uint16 boughtTicketCount; + uint8 returnCode; + }; + struct BuyPreparedTickets_locals + { + Array ticketValues; + uint64 effectiveTicketCount; + uint64 freeSlots; + uint64 requiredAmount; + uint64 excess; + uint64 i; + uint64 offset; + }; + // Buy Ticket struct BuyTicket_input { @@ -203,10 +245,56 @@ struct QTF : ContractBase }; struct BuyTicket_locals { - // CALL parameters for ValidateNumbers ValidateNumbers_input validateInput; ValidateNumbers_output validateOutput; - uint64 excess; + BuyPreparedTickets_input buyPreparedInput; + BuyPreparedTickets_output buyPreparedOutput; + }; + + struct BuyTicketsBatch_input + { + Array tickets; + }; + struct BuyTicketsBatch_output + { + uint16 boughtTicketCount; + uint8 returnCode; + }; + struct BuyTicketsBatch_locals + { + ValidateNumbers_input validateInput; + ValidateNumbers_output validateOutput; + Array ticketValues; + uint64 ticketCount; + uint64 i; + uint64 offset; + uint64 preparedOffset; + BuyPreparedTickets_input buyPreparedInput; + BuyPreparedTickets_output buyPreparedOutput; + }; + + struct BuyTicketsBySelection_input + { + Array numbers; + }; + struct BuyTicketsBySelection_output + { + uint16 requestedTicketCount; + uint16 boughtTicketCount; + uint8 returnCode; + }; + struct BuyTicketsBySelection_locals + { + CollectSelectionNumbers_input collectSelectionInput; + CollectSelectionNumbers_output collectSelectionOutput; + Array ticketValues; + uint64 i; + uint64 j; + uint64 k; + uint64 l; + uint64 preparedOffset; + BuyPreparedTickets_input buyPreparedInput; + BuyPreparedTickets_output buyPreparedOutput; }; // Set Price @@ -765,6 +853,8 @@ struct QTF : ContractBase REGISTER_USER_PROCEDURE(SetTargetJackpot, 4); REGISTER_USER_PROCEDURE(SetDrawHour, 5); REGISTER_USER_PROCEDURE(SyncJackpot, 6); + REGISTER_USER_PROCEDURE(BuyTicketsBatch, 7); + REGISTER_USER_PROCEDURE(BuyTicketsBySelection, 8); REGISTER_USER_FUNCTION(GetTicketPrice, 1); REGISTER_USER_FUNCTION(GetNextEpochData, 2); @@ -911,7 +1001,7 @@ struct QTF : ContractBase return; } - if (qpi.invocationReward() < static_cast(state.ticketPrice)) + if (state.ticketPrice > INT64_MAX || qpi.invocationReward() <= 0 || static_cast(qpi.invocationReward()) < state.ticketPrice) { if (qpi.invocationReward() > 0) { @@ -934,20 +1024,172 @@ struct QTF : ContractBase return; } - addPlayerInfo(state, qpi.invocator(), input.randomValues); + locals.buyPreparedInput.ticketCount = 1; + locals.buyPreparedInput.tickets.set(0, input.randomValues.get(0)); + locals.buyPreparedInput.tickets.set(1, input.randomValues.get(1)); + locals.buyPreparedInput.tickets.set(2, input.randomValues.get(2)); + locals.buyPreparedInput.tickets.set(3, input.randomValues.get(3)); + CALL(BuyPreparedTickets, locals.buyPreparedInput, locals.buyPreparedOutput); + output.returnCode = locals.buyPreparedOutput.returnCode; + } - // 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() > static_cast(state.ticketPrice)) + /// @brief Buys every valid ticket encoded in the flat batch payload. + /// @param input.tickets Flat array of 4-number tickets stored sequentially. + /// @return SUCCESS when at least one valid ticket is accepted; invalid or empty 4-number groups are ignored, + /// and the batch is clipped to the number of free ticket slots left in the round. + PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicketsBatch) + { + if ((state.currentState & STATE_SELLING) == 0) { - locals.excess = qpi.invocationReward() - state.ticketPrice; - if (locals.excess > 0) + if (qpi.invocationReward() > 0) { - qpi.transfer(qpi.invocator(), locals.excess); + qpi.transfer(qpi.invocator(), qpi.invocationReward()); } + + output.returnCode = toReturnCode(EReturnCode::TICKET_SELLING_CLOSED); + return; } - output.returnCode = toReturnCode(EReturnCode::SUCCESS); + while (locals.i < QTF_MAX_BATCH_TICKETS) + { + locals.offset = smul(locals.i, QTF_RANDOM_VALUES_COUNT); + locals.ticketValues.set(0, input.tickets.get(locals.offset)); + locals.ticketValues.set(1, input.tickets.get(locals.offset + 1)); + locals.ticketValues.set(2, input.tickets.get(locals.offset + 2)); + locals.ticketValues.set(3, input.tickets.get(locals.offset + 3)); + + if (isEmptyTicket(locals.ticketValues)) + { + ++locals.i; + continue; + } + + locals.validateInput.numbers = locals.ticketValues; + CALL(ValidateNumbers, locals.validateInput, locals.validateOutput); + if (!locals.validateOutput.isValid) + { + ++locals.i; + continue; + } + + locals.preparedOffset = smul(locals.ticketCount, QTF_RANDOM_VALUES_COUNT); + locals.buyPreparedInput.tickets.set(locals.preparedOffset, locals.ticketValues.get(0)); + locals.buyPreparedInput.tickets.set(locals.preparedOffset + 1, locals.ticketValues.get(1)); + locals.buyPreparedInput.tickets.set(locals.preparedOffset + 2, locals.ticketValues.get(2)); + locals.buyPreparedInput.tickets.set(locals.preparedOffset + 3, locals.ticketValues.get(3)); + ++locals.ticketCount; + ++locals.i; + } + + locals.buyPreparedInput.ticketCount = locals.ticketCount; + CALL(BuyPreparedTickets, locals.buyPreparedInput, locals.buyPreparedOutput); + output.boughtTicketCount = locals.buyPreparedOutput.boughtTicketCount; + output.returnCode = locals.buyPreparedOutput.returnCode; + } + + /// @brief Buys every 4-number combination generated from a validated number selection. + /// @param input.numbers Selected values; all unique values in range [1..30] are used. + /// @return SUCCESS when all generated tickets are accepted; selections that expand beyond the batch limit + /// or do not fit in the remaining round capacity refund the full reward. + PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicketsBySelection) + { + if ((state.currentState & STATE_SELLING) == 0) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + output.returnCode = toReturnCode(EReturnCode::TICKET_SELLING_CLOSED); + return; + } + + locals.collectSelectionInput.numbers = input.numbers; + CALL(CollectSelectionNumbers, locals.collectSelectionInput, locals.collectSelectionOutput); + if (!locals.collectSelectionOutput.isValid || locals.collectSelectionOutput.numberCount < QTF_RANDOM_VALUES_COUNT) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + output.returnCode = toReturnCode(EReturnCode::INVALID_NUMBERS); + return; + } + + locals.j = + smul(static_cast(locals.collectSelectionOutput.numberCount), static_cast(locals.collectSelectionOutput.numberCount - 1)); + locals.k = smul(locals.j, static_cast(locals.collectSelectionOutput.numberCount - 2)); + locals.l = smul(locals.k, static_cast(locals.collectSelectionOutput.numberCount - 3)); + output.requestedTicketCount = static_cast(div(locals.l, 24)); + if (output.requestedTicketCount > QTF_MAX_BATCH_TICKETS) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + locals.j = (state.numberOfPlayers < QTF_MAX_NUMBER_OF_PLAYERS) ? (QTF_MAX_NUMBER_OF_PLAYERS - state.numberOfPlayers) : 0; + if (output.requestedTicketCount > locals.j) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + output.returnCode = toReturnCode(EReturnCode::MAX_PLAYERS_REACHED); + return; + } + + while (locals.i + 3 < locals.collectSelectionOutput.numberCount) + { + locals.j = locals.i + 1; + while (locals.j + 2 < locals.collectSelectionOutput.numberCount) + { + locals.k = locals.j + 1; + while (locals.k + 1 < locals.collectSelectionOutput.numberCount) + { + locals.l = locals.k + 1; + while (locals.l < locals.collectSelectionOutput.numberCount) + { + locals.ticketValues.set(0, locals.collectSelectionOutput.normalizedNumbers.get(locals.i)); + locals.ticketValues.set(1, locals.collectSelectionOutput.normalizedNumbers.get(locals.j)); + locals.ticketValues.set(2, locals.collectSelectionOutput.normalizedNumbers.get(locals.k)); + locals.ticketValues.set(3, locals.collectSelectionOutput.normalizedNumbers.get(locals.l)); + locals.preparedOffset = smul(locals.buyPreparedInput.ticketCount, QTF_RANDOM_VALUES_COUNT); + locals.buyPreparedInput.tickets.set(locals.preparedOffset, locals.ticketValues.get(0)); + locals.buyPreparedInput.tickets.set(locals.preparedOffset + 1, locals.ticketValues.get(1)); + locals.buyPreparedInput.tickets.set(locals.preparedOffset + 2, locals.ticketValues.get(2)); + locals.buyPreparedInput.tickets.set(locals.preparedOffset + 3, locals.ticketValues.get(3)); + ++locals.buyPreparedInput.ticketCount; + if (locals.buyPreparedInput.ticketCount >= QTF_MAX_BATCH_TICKETS) + { + break; + } + ++locals.l; + } + if (locals.buyPreparedInput.ticketCount >= QTF_MAX_BATCH_TICKETS) + { + break; + } + ++locals.k; + } + if (locals.buyPreparedInput.ticketCount >= QTF_MAX_BATCH_TICKETS) + { + break; + } + ++locals.j; + } + if (locals.buyPreparedInput.ticketCount >= QTF_MAX_BATCH_TICKETS) + { + break; + } + ++locals.i; + } + + CALL(BuyPreparedTickets, locals.buyPreparedInput, locals.buyPreparedOutput); + output.boughtTicketCount = locals.buyPreparedOutput.boughtTicketCount; + output.returnCode = locals.buyPreparedOutput.returnCode; } PUBLIC_PROCEDURE(SetPrice) @@ -1201,6 +1443,11 @@ struct QTF : ContractBase state.players.set(state.numberOfPlayers++, {playerId, randomValues}); } + static bool isEmptyTicket(const Array& ticketValues) + { + return (ticketValues.get(0) | ticketValues.get(1) | ticketValues.get(2) | ticketValues.get(3)) == 0; + } + static uint8 bitcount32(uint32 v) { v = v - ((v >> 1) & 0x55555555u); @@ -1265,6 +1512,80 @@ struct QTF : ContractBase uint64 winningCombinationsCount; // next write position in ring buffer private: + PRIVATE_PROCEDURE_WITH_LOCALS(BuyPreparedTickets) + { + 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 (input.ticketCount == 0) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + output.returnCode = toReturnCode(EReturnCode::INVALID_NUMBERS); + return; + } + + locals.freeSlots = (state.numberOfPlayers < QTF_MAX_NUMBER_OF_PLAYERS) ? (QTF_MAX_NUMBER_OF_PLAYERS - state.numberOfPlayers) : 0; + if (locals.freeSlots == 0) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + output.returnCode = toReturnCode(EReturnCode::MAX_PLAYERS_REACHED); + return; + } + + locals.effectiveTicketCount = RL::min(input.ticketCount, locals.freeSlots); + locals.requiredAmount = smul(state.ticketPrice, locals.effectiveTicketCount); + if (locals.requiredAmount > INT64_MAX || qpi.invocationReward() <= 0 || static_cast(qpi.invocationReward()) < locals.requiredAmount) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + output.returnCode = toReturnCode(EReturnCode::INVALID_TICKET_PRICE); + return; + } + + while (locals.i < locals.effectiveTicketCount) + { + locals.offset = smul(locals.i, QTF_RANDOM_VALUES_COUNT); + locals.ticketValues.set(0, input.tickets.get(locals.offset)); + locals.ticketValues.set(1, input.tickets.get(locals.offset + 1)); + locals.ticketValues.set(2, input.tickets.get(locals.offset + 2)); + locals.ticketValues.set(3, input.tickets.get(locals.offset + 3)); + addPlayerInfo(state, qpi.invocator(), locals.ticketValues); + ++locals.i; + } + + if (qpi.invocationReward() > 0 && static_cast(qpi.invocationReward()) > locals.requiredAmount) + { + locals.excess = static_cast(qpi.invocationReward()) - locals.requiredAmount; + if (locals.excess > 0) + { + qpi.transfer(qpi.invocator(), locals.excess); + } + } + + output.boughtTicketCount = static_cast(locals.effectiveTicketCount); + output.returnCode = + (locals.effectiveTicketCount < input.ticketCount) ? toReturnCode(EReturnCode::PARTIAL_PURCHASE) : toReturnCode(EReturnCode::SUCCESS); + } + // Core settlement pipeline for one epoch: fees, FR redirects, payouts, jackpot/reserve updates. PRIVATE_PROCEDURE_WITH_LOCALS(SettleEpoch) { @@ -1847,6 +2168,40 @@ struct QTF : ContractBase } } + PRIVATE_FUNCTION_WITH_LOCALS(CollectSelectionNumbers) + { + 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) + { + continue; + } + if (locals.value > QTF_MAX_RANDOM_VALUE) + { + output.isValid = false; + return; + } + if ((locals.mask & (1u << locals.value)) != 0) + { + output.isValid = false; + return; + } + locals.mask |= (1u << locals.value); + } + + // Get Sorted Unique Values from Mask + for (locals.outValue = 1; locals.outValue <= QTF_MAX_RANDOM_VALUE; ++locals.outValue) + { + if ((locals.mask & (1u << locals.outValue)) != 0) + { + output.normalizedNumbers.set(output.numberCount, locals.outValue); + ++output.numberCount; + } + } + } + /** * @brief Calculate safe reserve top-up amount respecting safety limits. * From abe6b139ae8301110efb36d3c9ca6d74cb8ed45f Mon Sep 17 00:00:00 2001 From: N-010 Date: Sun, 15 Mar 2026 11:56:26 +0300 Subject: [PATCH 10/15] Add BuildSelectionTickets functionality for batch ticket preparation --- src/contracts/QThirtyFour.h | 125 ++++++++++++++++++------------------ 1 file changed, 64 insertions(+), 61 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index dc5160aed..fdd95bc94 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -234,6 +234,25 @@ struct QTF : ContractBase uint64 offset; }; + struct BuildSelectionTickets_input + { + Array normalizedNumbers; + uint8 numberCount; + }; + struct BuildSelectionTickets_output + { + BuyPreparedTickets_input preparedTickets; + }; + struct BuildSelectionTickets_locals + { + Array ticketValues; + uint64 i; + uint64 j; + uint64 k; + uint64 l; + uint64 preparedOffset; + }; + // Buy Ticket struct BuyTicket_input { @@ -287,12 +306,11 @@ struct QTF : ContractBase { CollectSelectionNumbers_input collectSelectionInput; CollectSelectionNumbers_output collectSelectionOutput; - Array ticketValues; - uint64 i; + BuildSelectionTickets_input buildSelectionTicketsInput; + BuildSelectionTickets_output buildSelectionTicketsOutput; uint64 j; uint64 k; uint64 l; - uint64 preparedOffset; BuyPreparedTickets_input buyPreparedInput; BuyPreparedTickets_output buyPreparedOutput; }; @@ -1089,8 +1107,8 @@ struct QTF : ContractBase /// @brief Buys every 4-number combination generated from a validated number selection. /// @param input.numbers Selected values; all unique values in range [1..30] are used. - /// @return SUCCESS when all generated tickets are accepted; selections that expand beyond the batch limit - /// or do not fit in the remaining round capacity refund the full reward. + /// @return SUCCESS when all generated tickets are accepted; PARTIAL_PURCHASE when the round has fewer free + /// slots than generated tickets; selections that expand beyond the batch limit refund the full reward. PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicketsBySelection) { if ((state.currentState & STATE_SELLING) == 0) @@ -1130,63 +1148,11 @@ struct QTF : ContractBase output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); return; } - locals.j = (state.numberOfPlayers < QTF_MAX_NUMBER_OF_PLAYERS) ? (QTF_MAX_NUMBER_OF_PLAYERS - state.numberOfPlayers) : 0; - if (output.requestedTicketCount > locals.j) - { - if (qpi.invocationReward() > 0) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - } - output.returnCode = toReturnCode(EReturnCode::MAX_PLAYERS_REACHED); - return; - } - - while (locals.i + 3 < locals.collectSelectionOutput.numberCount) - { - locals.j = locals.i + 1; - while (locals.j + 2 < locals.collectSelectionOutput.numberCount) - { - locals.k = locals.j + 1; - while (locals.k + 1 < locals.collectSelectionOutput.numberCount) - { - locals.l = locals.k + 1; - while (locals.l < locals.collectSelectionOutput.numberCount) - { - locals.ticketValues.set(0, locals.collectSelectionOutput.normalizedNumbers.get(locals.i)); - locals.ticketValues.set(1, locals.collectSelectionOutput.normalizedNumbers.get(locals.j)); - locals.ticketValues.set(2, locals.collectSelectionOutput.normalizedNumbers.get(locals.k)); - locals.ticketValues.set(3, locals.collectSelectionOutput.normalizedNumbers.get(locals.l)); - locals.preparedOffset = smul(locals.buyPreparedInput.ticketCount, QTF_RANDOM_VALUES_COUNT); - locals.buyPreparedInput.tickets.set(locals.preparedOffset, locals.ticketValues.get(0)); - locals.buyPreparedInput.tickets.set(locals.preparedOffset + 1, locals.ticketValues.get(1)); - locals.buyPreparedInput.tickets.set(locals.preparedOffset + 2, locals.ticketValues.get(2)); - locals.buyPreparedInput.tickets.set(locals.preparedOffset + 3, locals.ticketValues.get(3)); - ++locals.buyPreparedInput.ticketCount; - if (locals.buyPreparedInput.ticketCount >= QTF_MAX_BATCH_TICKETS) - { - break; - } - ++locals.l; - } - if (locals.buyPreparedInput.ticketCount >= QTF_MAX_BATCH_TICKETS) - { - break; - } - ++locals.k; - } - if (locals.buyPreparedInput.ticketCount >= QTF_MAX_BATCH_TICKETS) - { - break; - } - ++locals.j; - } - if (locals.buyPreparedInput.ticketCount >= QTF_MAX_BATCH_TICKETS) - { - break; - } - ++locals.i; - } + locals.buildSelectionTicketsInput.normalizedNumbers = locals.collectSelectionOutput.normalizedNumbers; + locals.buildSelectionTicketsInput.numberCount = locals.collectSelectionOutput.numberCount; + CALL(BuildSelectionTickets, locals.buildSelectionTicketsInput, locals.buildSelectionTicketsOutput); + locals.buyPreparedInput = locals.buildSelectionTicketsOutput.preparedTickets; CALL(BuyPreparedTickets, locals.buyPreparedInput, locals.buyPreparedOutput); output.boughtTicketCount = locals.buyPreparedOutput.boughtTicketCount; output.returnCode = locals.buyPreparedOutput.returnCode; @@ -2202,6 +2168,43 @@ struct QTF : ContractBase } } + PRIVATE_FUNCTION_WITH_LOCALS(BuildSelectionTickets) + { + while (locals.i + 3 < input.numberCount) + { + locals.j = locals.i + 1; + while (locals.j + 2 < input.numberCount) + { + locals.k = locals.j + 1; + while (locals.k + 1 < input.numberCount) + { + locals.l = locals.k + 1; + while (locals.l < input.numberCount) + { + locals.ticketValues.set(0, input.normalizedNumbers.get(locals.i)); + locals.ticketValues.set(1, input.normalizedNumbers.get(locals.j)); + locals.ticketValues.set(2, input.normalizedNumbers.get(locals.k)); + locals.ticketValues.set(3, input.normalizedNumbers.get(locals.l)); + locals.preparedOffset = smul(output.preparedTickets.ticketCount, QTF_RANDOM_VALUES_COUNT); + output.preparedTickets.tickets.set(locals.preparedOffset, locals.ticketValues.get(0)); + output.preparedTickets.tickets.set(locals.preparedOffset + 1, locals.ticketValues.get(1)); + output.preparedTickets.tickets.set(locals.preparedOffset + 2, locals.ticketValues.get(2)); + output.preparedTickets.tickets.set(locals.preparedOffset + 3, locals.ticketValues.get(3)); + ++output.preparedTickets.ticketCount; + if (output.preparedTickets.ticketCount >= QTF_MAX_BATCH_TICKETS) + { + return; + } + ++locals.l; + } + ++locals.k; + } + ++locals.j; + } + ++locals.i; + } + } + /** * @brief Calculate safe reserve top-up amount respecting safety limits. * From 68034af311a98a110244508714ffcc73a1aa39d7 Mon Sep 17 00:00:00 2001 From: N-010 Date: Sun, 15 Mar 2026 12:12:10 +0300 Subject: [PATCH 11/15] Enhance documentation for BuyTicketsBatch and BuyTicketsBySelection procedures --- src/contracts/QThirtyFour.h | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index fdd95bc94..20c92ba50 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -1051,10 +1051,12 @@ struct QTF : ContractBase output.returnCode = locals.buyPreparedOutput.returnCode; } - /// @brief Buys every valid ticket encoded in the flat batch payload. - /// @param input.tickets Flat array of 4-number tickets stored sequentially. - /// @return SUCCESS when at least one valid ticket is accepted; invalid or empty 4-number groups are ignored, - /// and the batch is clipped to the number of free ticket slots left in the round. + /** + * @brief Buys every valid ticket encoded in the flat batch payload. + * @param input.tickets Flat array of 4-number tickets stored sequentially. + * @return SUCCESS when at least one valid ticket is accepted; invalid or empty 4-number groups are ignored, + * and the batch is clipped to the number of free ticket slots left in the round. + */ PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicketsBatch) { if ((state.currentState & STATE_SELLING) == 0) @@ -1105,10 +1107,12 @@ struct QTF : ContractBase output.returnCode = locals.buyPreparedOutput.returnCode; } - /// @brief Buys every 4-number combination generated from a validated number selection. - /// @param input.numbers Selected values; all unique values in range [1..30] are used. - /// @return SUCCESS when all generated tickets are accepted; PARTIAL_PURCHASE when the round has fewer free - /// slots than generated tickets; selections that expand beyond the batch limit refund the full reward. + /** + * @brief Buys every 4-number combination generated from a validated number selection. + * @param input.numbers Selected values; all unique values in range [1..30] are used. + * @return SUCCESS when all generated tickets are accepted; PARTIAL_PURCHASE when the round has fewer free + * slots than generated tickets; selections that expand beyond the batch limit refund the full reward. + */ PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicketsBySelection) { if ((state.currentState & STATE_SELLING) == 0) From 75007eae8cb5411ee3b70e0b25e5566d5c9ec7ed Mon Sep 17 00:00:00 2001 From: N-010 Date: Sun, 15 Mar 2026 12:32:41 +0300 Subject: [PATCH 12/15] Convert to StateData --- src/contracts/QThirtyFour.h | 321 +++++++++++++++++++----------------- 1 file changed, 166 insertions(+), 155 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 20c92ba50..82655e742 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -72,6 +72,9 @@ struct QTF2 struct QTF : ContractBase { + // Forward declaration for NextEpochData::apply + struct StateData; + enum class EReturnCode : uint8 { SUCCESS, @@ -135,23 +138,23 @@ struct QTF : ContractBase newDrawHour = 0; } - void apply(QTF& state) const + void apply(QPI::ContractState& state) const { if (newTicketPrice > 0) { - state.ticketPrice = newTicketPrice; + state.mut().ticketPrice = newTicketPrice; } if (newTargetJackpot > 0) { - state.targetJackpot = newTargetJackpot; + state.mut().targetJackpot = newTargetJackpot; } if (newSchedule > 0) { - state.schedule = newSchedule; + state.mut().schedule = newSchedule; } if (newDrawHour > 0) { - state.drawHour = newDrawHour; + state.mut().drawHour = newDrawHour; } } @@ -848,19 +851,43 @@ struct QTF : ContractBase Entity entity; }; + struct StateData + { + 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) + Array, QTF_WINNING_COMBINATIONS_HISTORY_SIZE> + winningCombinationsHistory; // ring buffer of winning combinations + uint64 winningCombinationsCount; // next write position in ring buffer + }; + // Contract lifecycle methods INITIALIZE() { - state.teamAddress = ID(_O, _C, _Z, _W, _N, _J, _S, _N, _R, _U, _Q, _J, _U, _A, _H, _Z, _C, _T, _R, _P, _N, _Y, _W, _G, _G, _E, _F, _C, _X, _B, - _A, _V, _F, _O, _P, _R, _S, _N, _U, _L, _U, _E, _B, _S, _P, _U, _T, _R, _Z, _N, _T, _G, _F, _B, _I, _E); - 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; + state.mut().teamAddress = ID(_O, _C, _Z, _W, _N, _J, _S, _N, _R, _U, _Q, _J, _U, _A, _H, _Z, _C, _T, _R, _P, _N, _Y, _W, _G, _G, _E, _F, _C, + _X, _B, _A, _V, _F, _O, _P, _R, _S, _N, _U, _L, _U, _E, _B, _S, _P, _U, _T, _R, _Z, _N, _T, _G, _F, _B, _I, _E); + state.mut().ownerAddress = state.get().teamAddress; + state.mut().ticketPrice = QTF_TICKET_PRICE; + state.mut().targetJackpot = QTF_DEFAULT_TARGET_JACKPOT; + state.mut().overflowAlphaBP = QTF_BASELINE_OVERFLOW_ALPHA_BP; + state.mut().schedule = QTF_DEFAULT_SCHEDULE; + state.mut().drawHour = QTF_DEFAULT_DRAW_HOUR; + state.mut().lastDrawDateStamp = QTF_DEFAULT_INIT_TIME; + state.mut().currentState = STATE_NONE; } REGISTER_USER_FUNCTIONS_AND_PROCEDURES() @@ -891,17 +918,17 @@ struct QTF : ContractBase { applyNextEpochData(state); - if (state.schedule == 0) + if (state.get().schedule == 0) { - state.schedule = QTF_DEFAULT_SCHEDULE; + state.mut().schedule = QTF_DEFAULT_SCHEDULE; } - if (state.drawHour == 0) + if (state.get().drawHour == 0) { - state.drawHour = QTF_DEFAULT_DRAW_HOUR; + state.mut().drawHour = QTF_DEFAULT_DRAW_HOUR; } - RL::makeDateStamp(qpi.year(), qpi.month(), qpi.day(), state.lastDrawDateStamp); + RL::makeDateStamp(qpi.year(), qpi.month(), qpi.day(), state.mut().lastDrawDateStamp); clearEpochState(state); - enableBuyTicket(state, state.lastDrawDateStamp != RL_DEFAULT_INIT_TIME); + enableBuyTicket(state, state.get().lastDrawDateStamp != RL_DEFAULT_INIT_TIME); } // Settle and reset at epoch end (uses locals buffer) @@ -922,7 +949,7 @@ struct QTF : ContractBase } locals.currentHour = qpi.hour(); - if (locals.currentHour < state.drawHour) + if (locals.currentHour < state.get().drawHour) { return; } @@ -934,33 +961,33 @@ struct QTF : ContractBase if (locals.currentDateStamp == QTF_DEFAULT_INIT_TIME) { enableBuyTicket(state, false); - state.lastDrawDateStamp = QTF_DEFAULT_INIT_TIME; + state.mut().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) + if (state.get().lastDrawDateStamp == QTF_DEFAULT_INIT_TIME && locals.currentDateStamp != QTF_DEFAULT_INIT_TIME) { enableBuyTicket(state, true); if (locals.currentDayOfWeek == WEDNESDAY) { - state.lastDrawDateStamp = locals.currentDateStamp; + state.mut().lastDrawDateStamp = locals.currentDateStamp; } else { - state.lastDrawDateStamp = 0; + state.mut().lastDrawDateStamp = 0; } return; } - if (locals.currentDateStamp == state.lastDrawDateStamp) + if (locals.currentDateStamp == state.get().lastDrawDateStamp) { return; } locals.isWednesday = (locals.currentDayOfWeek == WEDNESDAY); - locals.isScheduledToday = ((state.schedule & (1u << locals.currentDayOfWeek)) != 0); + locals.isScheduledToday = ((state.get().schedule & (1u << locals.currentDayOfWeek)) != 0); // Always draw on Wednesday; otherwise require schedule bit. if (!locals.isWednesday && !locals.isScheduledToday) @@ -968,7 +995,7 @@ struct QTF : ContractBase return; } - state.lastDrawDateStamp = locals.currentDateStamp; + state.mut().lastDrawDateStamp = locals.currentDateStamp; // Pause selling during draw/settlement. enableBuyTicket(state, false); @@ -997,7 +1024,7 @@ struct QTF : ContractBase // Procedures PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicket) { - if ((state.currentState & STATE_SELLING) == 0) + if ((state.get().currentState & STATE_SELLING) == 0) { if (qpi.invocationReward() > 0) { @@ -1008,7 +1035,7 @@ struct QTF : ContractBase return; } - if (state.numberOfPlayers >= QTF_MAX_NUMBER_OF_PLAYERS) + if (state.get().numberOfPlayers >= QTF_MAX_NUMBER_OF_PLAYERS) { if (qpi.invocationReward() > 0) { @@ -1019,7 +1046,8 @@ struct QTF : ContractBase return; } - if (state.ticketPrice > INT64_MAX || qpi.invocationReward() <= 0 || static_cast(qpi.invocationReward()) < state.ticketPrice) + if (state.get().ticketPrice > INT64_MAX || qpi.invocationReward() <= 0 || + static_cast(qpi.invocationReward()) < state.get().ticketPrice) { if (qpi.invocationReward() > 0) { @@ -1059,7 +1087,7 @@ struct QTF : ContractBase */ PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicketsBatch) { - if ((state.currentState & STATE_SELLING) == 0) + if ((state.get().currentState & STATE_SELLING) == 0) { if (qpi.invocationReward() > 0) { @@ -1115,7 +1143,7 @@ struct QTF : ContractBase */ PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicketsBySelection) { - if ((state.currentState & STATE_SELLING) == 0) + if ((state.get().currentState & STATE_SELLING) == 0) { if (qpi.invocationReward() > 0) { @@ -1164,7 +1192,7 @@ struct QTF : ContractBase PUBLIC_PROCEDURE(SetPrice) { - if (qpi.invocator() != state.ownerAddress) + if (qpi.invocator() != state.get().ownerAddress) { output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; @@ -1176,13 +1204,13 @@ struct QTF : ContractBase return; } - state.nextEpochData.newTicketPrice = input.newPrice; + state.mut().nextEpochData.newTicketPrice = input.newPrice; output.returnCode = toReturnCode(EReturnCode::SUCCESS); } PUBLIC_PROCEDURE(SetSchedule) { - if (qpi.invocator() != state.ownerAddress) + if (qpi.invocator() != state.get().ownerAddress) { output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; @@ -1194,13 +1222,13 @@ struct QTF : ContractBase return; } - state.nextEpochData.newSchedule = input.newSchedule; + state.mut().nextEpochData.newSchedule = input.newSchedule; output.returnCode = toReturnCode(EReturnCode::SUCCESS); } PUBLIC_PROCEDURE(SetTargetJackpot) { - if (qpi.invocator() != state.ownerAddress) + if (qpi.invocator() != state.get().ownerAddress) { output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; @@ -1212,13 +1240,13 @@ struct QTF : ContractBase return; } - state.nextEpochData.newTargetJackpot = input.newTargetJackpot; + state.mut().nextEpochData.newTargetJackpot = input.newTargetJackpot; output.returnCode = toReturnCode(EReturnCode::SUCCESS); } PUBLIC_PROCEDURE(SetDrawHour) { - if (qpi.invocator() != state.ownerAddress) + if (qpi.invocator() != state.get().ownerAddress) { output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; @@ -1230,40 +1258,40 @@ struct QTF : ContractBase return; } - state.nextEpochData.newDrawHour = input.newDrawHour; + state.mut().nextEpochData.newDrawHour = input.newDrawHour; output.returnCode = toReturnCode(EReturnCode::SUCCESS); } PUBLIC_PROCEDURE_WITH_LOCALS(SyncJackpot) { - if (qpi.invocator() != state.ownerAddress) + if (qpi.invocator() != state.get().ownerAddress) { output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; } qpi.getEntity(SELF, locals.entity); - state.jackpot = locals.entity.incomingAmount - locals.entity.outgoingAmount; + state.mut().jackpot = locals.entity.incomingAmount - locals.entity.outgoingAmount; 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(GetTicketPrice) { output.ticketPrice = state.get().ticketPrice; } + PUBLIC_FUNCTION(GetNextEpochData) { output.nextEpochData = state.get().nextEpochData; } + PUBLIC_FUNCTION(GetWinnerData) { output.winnerData = state.get().lastWinnerData; } PUBLIC_FUNCTION_WITH_LOCALS(GetPools) { - output.pools.jackpot = state.jackpot; + output.pools.jackpot = state.get().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; + output.pools.targetJackpot = state.get().targetJackpot; + output.pools.frActive = state.get().frActive; + output.pools.roundsSinceK4 = state.get().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(GetSchedule) { output.schedule = state.get().schedule; } + PUBLIC_FUNCTION(GetDrawHour) { output.drawHour = state.get().drawHour; } + PUBLIC_FUNCTION(GetState) { output.currentState = static_cast(state.get().currentState); } PUBLIC_FUNCTION_WITH_LOCALS(GetFees) { CALL_OTHER_CONTRACT_FUNCTION(RL, GetFees, locals.feesInput, locals.feesOutput); @@ -1287,15 +1315,15 @@ struct QTF : ContractBase PUBLIC_FUNCTION_WITH_LOCALS(EstimatePrizePayouts) { // Calculate total revenue from current ticket sales - locals.revenue = smul(state.ticketPrice, state.numberOfPlayers); + locals.revenue = smul(state.get().ticketPrice, state.get().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 + output.k2MinFloor = div(smul(state.get().ticketPrice, QTF_K2_FLOOR_MULT), QTF_K2_FLOOR_DIV); // 0.5*P + output.k3MinFloor = smul(state.get().ticketPrice, QTF_K3_FLOOR_MULT); // 5*P + output.perWinnerCap = smul(state.get().ticketPrice, QTF_TOPUP_PER_WINNER_CAP_MULT); // 25*P - if (locals.revenue == 0 || state.numberOfPlayers == 0) + if (locals.revenue == 0 || state.get().numberOfPlayers == 0) { // No tickets sold, no payouts output.k2PayoutPerWinner = 0; @@ -1307,7 +1335,7 @@ struct QTF : ContractBase // Use shared CalculatePrizePools function to compute pools locals.calcPoolsInput.revenue = locals.revenue; - locals.calcPoolsInput.applyFRRake = state.frActive; + locals.calcPoolsInput.applyFRRake = state.get().frActive; CALL(CalculatePrizePools, locals.calcPoolsInput, locals.calcPoolsOutput); output.k2Pool = locals.calcPoolsOutput.k2Pool; @@ -1363,34 +1391,34 @@ struct QTF : ContractBase PUBLIC_FUNCTION(GetPlayers) { - output.players = state.players; + output.players = state.get().players; output.returnCode = toReturnCode(EReturnCode::SUCCESS); } PUBLIC_FUNCTION(GetWinningCombinationsHistory) { - copyMemory(output.history, state.winningCombinationsHistory); + copyMemory(output.history, state.get().winningCombinationsHistory); output.returnCode = toReturnCode(EReturnCode::SUCCESS); } protected: - static void clearEpochState(QTF& state) { clearPlayerData(state); } + static void clearEpochState(QPI::ContractState& state) { clearPlayerData(state); } - static void applyNextEpochData(QTF& state) + static void applyNextEpochData(QPI::ContractState& state) { - state.nextEpochData.apply(state); - state.nextEpochData.clear(); + state.get().nextEpochData.apply(state); + state.mut().nextEpochData.clear(); } - static void enableBuyTicket(QTF& state, bool bEnable) + static void enableBuyTicket(QPI::ContractState& state, bool bEnable) { if (bEnable) { - state.currentState = static_cast(state.currentState | STATE_SELLING); + state.mut().currentState = static_cast(state.get().currentState | STATE_SELLING); } else { - state.currentState = static_cast(state.currentState & static_cast(~STATE_SELLING)); + state.mut().currentState = static_cast(state.get().currentState & static_cast(~STATE_SELLING)); } } @@ -1408,9 +1436,11 @@ struct QTF : 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 Array& randomValues) + static void addPlayerInfo(QPI::ContractState& state, const id& playerId, + const Array& randomValues) { - state.players.set(state.numberOfPlayers++, {playerId, randomValues}); + state.mut().players.set(state.get().numberOfPlayers, {playerId, randomValues}); + state.mut().numberOfPlayers++; } static bool isEmptyTicket(const Array& ticketValues) @@ -1428,63 +1458,44 @@ struct QTF : ContractBase return static_cast(v & 0x3Fu); } - static void clearPlayerData(QTF& state) + static void clearPlayerData(QPI::ContractState& state) { - if (state.numberOfPlayers > 0) + if (state.get().numberOfPlayers > 0) { - setMemory(state.players, 0); - state.numberOfPlayers = 0; + setMemory(state.mut().players, 0); + state.mut().numberOfPlayers = 0; } } - static void clearWinerData(QTF& state) { setMemory(state.lastWinnerData, 0); } + static void clearWinerData(QPI::ContractState& state) { setMemory(state.mut().lastWinnerData, 0); } - static void fillWinnerData(QTF& state, const WinnerPlayerData& winnerPlayerData, const Array& winnerValues, - const uint16& epoch) + static void fillWinnerData(QPI::ContractState& state, const WinnerPlayerData& winnerPlayerData, + const Array& winnerValues, const uint16& epoch) { if (winnerPlayerData.isValid()) { - if (state.lastWinnerData.winnerCounter < state.lastWinnerData.winners.capacity()) + if (state.get().lastWinnerData.winnerCounter < state.get().lastWinnerData.winners.capacity()) { - state.lastWinnerData.winners.set(state.lastWinnerData.winnerCounter++, winnerPlayerData); + state.mut().lastWinnerData.winners.set(state.get().lastWinnerData.winnerCounter, winnerPlayerData); + state.mut().lastWinnerData.winnerCounter++; } } - state.lastWinnerData.winnerValues = winnerValues; - state.lastWinnerData.epoch = epoch; + state.mut().lastWinnerData.winnerValues = winnerValues; + state.mut().lastWinnerData.epoch = epoch; } - static void addWinningCombinationToHistory(QTF& state, const Array& winnerValues) + static void addWinningCombinationToHistory(QPI::ContractState& state, + const Array& winnerValues) { - state.winningCombinationsHistory.set(state.winningCombinationsCount, winnerValues); - state.winningCombinationsCount = mod(++state.winningCombinationsCount, state.winningCombinationsHistory.capacity()); + state.mut().winningCombinationsHistory.set(state.get().winningCombinationsCount, winnerValues); + state.mut().winningCombinationsCount = mod(state.get().winningCombinationsCount + 1, state.get().winningCombinationsHistory.capacity()); } - 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) - Array, QTF_WINNING_COMBINATIONS_HISTORY_SIZE> - winningCombinationsHistory; // ring buffer of winning combinations - uint64 winningCombinationsCount; // next write position in ring buffer - private: PRIVATE_PROCEDURE_WITH_LOCALS(BuyPreparedTickets) { - if ((state.currentState & STATE_SELLING) == 0) + if ((state.get().currentState & STATE_SELLING) == 0) { if (qpi.invocationReward() > 0) { @@ -1506,7 +1517,7 @@ struct QTF : ContractBase return; } - locals.freeSlots = (state.numberOfPlayers < QTF_MAX_NUMBER_OF_PLAYERS) ? (QTF_MAX_NUMBER_OF_PLAYERS - state.numberOfPlayers) : 0; + locals.freeSlots = (state.get().numberOfPlayers < QTF_MAX_NUMBER_OF_PLAYERS) ? (QTF_MAX_NUMBER_OF_PLAYERS - state.get().numberOfPlayers) : 0; if (locals.freeSlots == 0) { if (qpi.invocationReward() > 0) @@ -1519,7 +1530,7 @@ struct QTF : ContractBase } locals.effectiveTicketCount = RL::min(input.ticketCount, locals.freeSlots); - locals.requiredAmount = smul(state.ticketPrice, locals.effectiveTicketCount); + locals.requiredAmount = smul(state.get().ticketPrice, locals.effectiveTicketCount); if (locals.requiredAmount > INT64_MAX || qpi.invocationReward() <= 0 || static_cast(qpi.invocationReward()) < locals.requiredAmount) { if (qpi.invocationReward() > 0) @@ -1559,13 +1570,13 @@ struct QTF : ContractBase // Core settlement pipeline for one epoch: fees, FR redirects, payouts, jackpot/reserve updates. PRIVATE_PROCEDURE_WITH_LOCALS(SettleEpoch) { - if (state.numberOfPlayers == 0) + if (state.get().numberOfPlayers == 0) { return; } locals.currentEpoch = qpi.epoch(); - locals.revenue = smul(state.ticketPrice, state.numberOfPlayers); + locals.revenue = smul(state.get().ticketPrice, state.get().numberOfPlayers); if (locals.revenue == 0) { CALL(ReturnAllTickets, locals.returnAllTicketsInput, locals.returnAllTicketsOutput); @@ -1595,37 +1606,37 @@ struct QTF : ContractBase // 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) + if (state.get().jackpot >= state.get().targetJackpot) { - state.frRoundsAtOrAboveTarget = sadd(state.frRoundsAtOrAboveTarget, 1); + state.mut().frRoundsAtOrAboveTarget = sadd(state.get().frRoundsAtOrAboveTarget, 1); } else { - state.frRoundsAtOrAboveTarget = 0; + state.mut().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); + locals.shouldActivateFR = (state.get().jackpot < state.get().targetJackpot) && (state.get().frRoundsSinceK4 < QTF_FR_POST_K4_WINDOW_ROUNDS); if (locals.shouldActivateFR) { - state.frActive = true; + state.mut().frActive = true; } - else if (state.frRoundsSinceK4 >= QTF_FR_POST_K4_WINDOW_ROUNDS) + else if (state.get().frRoundsSinceK4 >= QTF_FR_POST_K4_WINDOW_ROUNDS) { // Outside post-k4 window: FR must be OFF. - state.frActive = false; + state.mut().frActive = false; } - else if (state.frRoundsAtOrAboveTarget >= QTF_FR_HYSTERESIS_ROUNDS) + else if (state.get().frRoundsAtOrAboveTarget >= QTF_FR_HYSTERESIS_ROUNDS) { // Deactivate FR after target held for hysteresis rounds - state.frActive = false; + state.mut().frActive = false; } // Calculate prize pools using shared function (handles FR rake if active) locals.calcPoolsInput.revenue = locals.revenue; - locals.calcPoolsInput.applyFRRake = state.frActive; + locals.calcPoolsInput.applyFRRake = state.get().frActive; CALL(CalculatePrizePools, locals.calcPoolsInput, locals.calcPoolsOutput); locals.winnersBlock = locals.calcPoolsOutput.winnersBlock; @@ -1640,10 +1651,10 @@ struct QTF : ContractBase // 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) + if (state.get().frActive) { // Calculate deficit to target jackpot - locals.delta = (state.jackpot < state.targetJackpot) ? (state.targetJackpot - state.jackpot) : 0; + locals.delta = (state.get().jackpot < state.get().targetJackpot) ? (state.get().targetJackpot - state.get().jackpot) : 0; // Estimate base gain from existing FR mechanisms (without extra) locals.calcBaseGainInput.revenue = locals.revenue; @@ -1651,7 +1662,7 @@ struct QTF : ContractBase CALL(CalculateBaseGain, locals.calcBaseGainInput, locals.calcBaseGainOutput); // Calculate deficit-driven extra redirect in basis points - locals.calcExtraInput.N = state.numberOfPlayers; + locals.calcExtraInput.N = state.get().numberOfPlayers; locals.calcExtraInput.delta = locals.delta; locals.calcExtraInput.revenue = locals.revenue; locals.calcExtraInput.baseGain = locals.calcBaseGainOutput.baseGain; @@ -1704,9 +1715,9 @@ struct QTF : ContractBase // First pass: count matches and cache results for second pass locals.i = 0; - while (locals.i < state.numberOfPlayers) + while (locals.i < state.get().numberOfPlayers) { - locals.countMatchesInput.playerValues = state.players.get(locals.i).randomValues; + locals.countMatchesInput.playerValues = state.get().players.get(locals.i).randomValues; locals.countMatchesInput.winningValues = locals.winningValues; CALL(CountMatches, locals.countMatchesInput, locals.countMatchesOutput); @@ -1737,35 +1748,35 @@ struct QTF : ContractBase // 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) + if (locals.totalQRPBalance > state.get().targetJackpot) { - locals.totalQRPBalance -= state.targetJackpot; + locals.totalQRPBalance -= state.get().targetJackpot; } else { locals.totalQRPBalance = 0; } } - locals.perWinnerCap = smul(state.ticketPrice, QTF_TOPUP_PER_WINNER_CAP_MULT); // 25*P + locals.perWinnerCap = smul(state.get().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.floorPerWinner = div(smul(state.get().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; + locals.tierPayoutInput.ticketPrice = state.get().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.floorPerWinner = smul(state.get().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; + locals.tierPayoutInput.ticketPrice = state.get().ticketPrice; CALL(ProcessTierPayout, locals.tierPayoutInput, locals.tierPayoutOutput); locals.k3PerWinner = locals.tierPayoutOutput.perWinnerPayout; locals.winnersOverflow = sadd(locals.winnersOverflow, locals.tierPayoutOutput.overflow); @@ -1776,29 +1787,29 @@ struct QTF : ContractBase locals.jackpotPerK4Winner = 0; if (locals.countK4 > 0) { - locals.jackpotPerK4Winner = div(state.jackpot, locals.countK4); + locals.jackpotPerK4Winner = div(state.get().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) + while (locals.i < state.get().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); - locals.winnerPlayerData.addPlayerData(state.players.get(locals.i)); + qpi.transfer(state.get().players.get(locals.i).player, locals.k2PerWinner); + locals.winnerPlayerData.addPlayerData(state.get().players.get(locals.i)); locals.winnerPlayerData.wonAmount = static_cast(locals.k2PerWinner); fillWinnerData(state, locals.winnerPlayerData, 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); - locals.winnerPlayerData.addPlayerData(state.players.get(locals.i)); + qpi.transfer(state.get().players.get(locals.i).player, locals.k3PerWinner); + locals.winnerPlayerData.addPlayerData(state.get().players.get(locals.i)); locals.winnerPlayerData.wonAmount = static_cast(locals.k3PerWinner); fillWinnerData(state, locals.winnerPlayerData, locals.winningValues, locals.currentEpoch); } @@ -1807,9 +1818,9 @@ struct QTF : ContractBase { if (locals.jackpotPerK4Winner > 0) { - qpi.transfer(state.players.get(locals.i).player, locals.jackpotPerK4Winner); + qpi.transfer(state.get().players.get(locals.i).player, locals.jackpotPerK4Winner); } - locals.winnerPlayerData.addPlayerData(state.players.get(locals.i)); + locals.winnerPlayerData.addPlayerData(state.get().players.get(locals.i)); locals.winnerPlayerData.wonAmount = static_cast(locals.jackpotPerK4Winner); fillWinnerData(state, locals.winnerPlayerData, locals.winningValues, locals.currentEpoch); } @@ -1818,24 +1829,24 @@ struct QTF : ContractBase } // Always save winning values and epoch, even if no winners - state.lastWinnerData.winnerValues = locals.winningValues; - state.lastWinnerData.epoch = locals.currentEpoch; + state.mut().lastWinnerData.winnerValues = locals.winningValues; + state.mut().lastWinnerData.epoch = locals.currentEpoch; addWinningCombinationToHistory(state, locals.winningValues); // 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; + state.mut().jackpot = 0; // Reset FR counters after jackpot hit - state.frRoundsSinceK4 = 0; - state.frRoundsAtOrAboveTarget = 0; + state.mut().frRoundsSinceK4 = 0; + state.mut().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); + locals.qrpRequested = RL::min(locals.qrpGetAvailableOutput.availableReserve, state.get().targetJackpot); if (locals.qrpRequested > 0) { locals.qrpGetReserveInput.revenue = locals.qrpRequested; @@ -1844,14 +1855,14 @@ struct QTF : ContractBase if (locals.qrpGetReserveOutput.returnCode == QRP::toReturnCode(QRP::EReturnCode::SUCCESS)) { locals.qrpReceived = locals.qrpGetReserveOutput.allocatedRevenue; - state.jackpot = sadd(state.jackpot, locals.qrpReceived); + state.mut().jackpot = sadd(state.get().jackpot, locals.qrpReceived); } } } else { // No jackpot hit: increment rounds counter for FR post-k4 window tracking - ++state.frRoundsSinceK4; + state.mut().frRoundsSinceK4++; } // Overflow split: unawarded tier funds split between reserve and jackpot. @@ -1859,20 +1870,20 @@ struct QTF : ContractBase // Baseline mode: 50/50 split (alpha=0.50) if (locals.winnersOverflow > 0) { - if (state.frActive) + if (state.get().frActive) { locals.reserveAdd = div(smul(locals.winnersOverflow, QTF_FR_ALPHA_BP), 10000); } else { - locals.reserveAdd = div(smul(locals.winnersOverflow, state.overflowAlphaBP), 10000); + locals.reserveAdd = div(smul(locals.winnersOverflow, state.get().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); + state.mut().jackpot = sadd(state.get().jackpot, locals.totalJackpotContribution); // Transfer reserve overflow to QReservePool if (locals.reserveAdd > 0) @@ -1882,7 +1893,7 @@ struct QTF : ContractBase if (locals.devPayout > 0) { - qpi.transfer(state.teamAddress, locals.devPayout); + qpi.transfer(state.get().teamAddress, locals.devPayout); } // Manual dividend payout to RL shareholders (no extra fee). if (locals.distPayout > 0) @@ -1934,9 +1945,9 @@ struct QTF : ContractBase PRIVATE_PROCEDURE_WITH_LOCALS(ReturnAllTickets) { // Refund ticket price to each player - for (locals.i = 0; locals.i < state.numberOfPlayers; ++locals.i) + for (locals.i = 0; locals.i < state.get().numberOfPlayers; ++locals.i) { - qpi.transfer(state.players.get(locals.i).player, state.ticketPrice); + qpi.transfer(state.get().players.get(locals.i).player, state.get().ticketPrice); } } From 8c5bcc20baff3e582e9deb82995e1965900cf3f0 Mon Sep 17 00:00:00 2001 From: N-010 Date: Sun, 15 Mar 2026 17:45:38 +0300 Subject: [PATCH 13/15] Reduces the value of QTF_MAX_BATCH_TICKETS from 512 to 256 --- src/contracts/QThirtyFour.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index 82655e742..ae0ab7314 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -4,7 +4,7 @@ using namespace QPI; 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_MAX_BATCH_TICKETS = QTF_MAX_NUMBER_OF_PLAYERS / 2; +constexpr uint64 QTF_MAX_BATCH_TICKETS = div(QTF_MAX_NUMBER_OF_PLAYERS, 4ULL); constexpr uint64 QTF_BATCH_TICKET_VALUES_COUNT = QTF_MAX_BATCH_TICKETS * QTF_RANDOM_VALUES_COUNT; constexpr uint64 QTF_TICKET_PRICE = 1000000; constexpr uint64 QTF_WINNING_COMBINATIONS_HISTORY_SIZE = 128; From 2b297a491840c15c4d73c2cb5b575bcc6a7836c5 Mon Sep 17 00:00:00 2001 From: N-010 Date: Sun, 15 Mar 2026 19:23:42 +0300 Subject: [PATCH 14/15] New tests --- test/contract_qtf.cpp | 688 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 686 insertions(+), 2 deletions(-) diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp index 775a75724..998b3dbb7 100644 --- a/test/contract_qtf.cpp +++ b/test/contract_qtf.cpp @@ -12,6 +12,8 @@ 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_PROCEDURE_SYNC_JACKPOT = 6; +constexpr uint16 QTF_PROCEDURE_BUY_TICKETS_BATCH = 7; +constexpr uint16 QTF_PROCEDURE_BUY_TICKETS_BY_SELECTION = 8; constexpr uint16 QTF_FUNCTION_GET_TICKET_PRICE = 1; constexpr uint16 QTF_FUNCTION_GET_NEXT_EPOCH_DATA = 2; @@ -86,6 +88,59 @@ namespace k2Pool = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000ULL; k3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000ULL; } + + static Array makeSelectionInput(std::initializer_list values) + { + Array numbers{}; + uint64 index = 0; + for (const uint8& value : values) + { + numbers.set(index++, value); + } + return numbers; + } + + static QTF::BuyPreparedTickets_input makePreparedTicketsInput(std::initializer_list tickets) + { + QTF::BuyPreparedTickets_input input{}; + uint64 ticketIndex = 0; + for (const auto& ticket : tickets) + { + const uint64 offset = ticketIndex * QTF_RANDOM_VALUES_COUNT; + for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) + { + input.tickets.set(offset + i, ticket.get(i)); + } + ++ticketIndex; + } + input.ticketCount = ticketIndex; + return input; + } + + static QTF::BuyTicketsBatch_input makeBatchTicketsInput(std::initializer_list tickets) + { + QTF::BuyTicketsBatch_input input{}; + uint64 ticketIndex = 0; + for (const auto& ticket : tickets) + { + const uint64 offset = ticketIndex * QTF_RANDOM_VALUES_COUNT; + for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) + { + input.tickets.set(offset + i, ticket.get(i)); + } + ++ticketIndex; + } + return input; + } + + static uint64 combinations4(uint64 numberCount) + { + if (numberCount < QTF_RANDOM_VALUES_COUNT) + { + return 0; + } + return (numberCount * (numberCount - 1) * (numberCount - 2) * (numberCount - 3)) / 24ULL; + } } // namespace constexpr uint8 QTF_ANY_DAY_SCHEDULE = 0xFF; @@ -94,10 +149,12 @@ constexpr uint8 QTF_ANY_DAY_SCHEDULE = 0xFF; class QTFChecker : public QTF, public QTF::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)); } @@ -112,6 +169,7 @@ class QTFChecker : public QTF, public QTF::StateData uint64 getWinningCombinationsWriteIndex() const { return winningCombinationsCount; } const id& team() const { return teamAddress; } + void setNumberOfPlayers(uint64 newCount) { numberOfPlayers = newCount; } void setScheduleMask(uint8 newMask) { schedule = newMask; } void setJackpot(uint64 value) { jackpot = value; } void setTargetJackpotInternal(uint64 value) { targetJackpot = value; } @@ -273,6 +331,43 @@ class QTFChecker : public QTF, public QTF::StateData ProcessTierPayout(qpi, asMutState(), input, output, locals); return output; } + + bool callIsEmptyTicket(const QTFRandomValues& ticketValues) const { return isEmptyTicket(ticketValues); } + + CollectSelectionNumbers_output callCollectSelectionNumbers(const QPI::QpiContextFunctionCall& qpi, + const Array& numbers) const + { + CollectSelectionNumbers_input input{}; + CollectSelectionNumbers_output output{}; + CollectSelectionNumbers_locals locals{}; + + input.numbers = numbers; + CollectSelectionNumbers(qpi, asState(), input, output, locals); + return output; + } + + BuildSelectionTickets_output callBuildSelectionTickets(const QPI::QpiContextFunctionCall& qpi, + const Array& normalizedNumbers, uint8 numberCount) const + { + BuildSelectionTickets_input input{}; + BuildSelectionTickets_output output{}; + BuildSelectionTickets_locals locals{}; + + input.normalizedNumbers = normalizedNumbers; + input.numberCount = numberCount; + BuildSelectionTickets(qpi, asState(), input, output, locals); + return output; + } + + BuyPreparedTickets_output callBuyPreparedTickets(const QPI::QpiContextProcedureCall& qpi, BuyPreparedTickets_input input) + { + BuyPreparedTickets_output output{}; + BuyPreparedTickets_locals locals{}; + BuyPreparedTickets_input mutableInput = input; + + BuyPreparedTickets(qpi, asMutState(), mutableInput, output, locals); + return output; + } }; class ContractTestingQTF : protected ContractTesting @@ -465,6 +560,28 @@ class ContractTestingQTF : protected ContractTesting return output; } + QTF::BuyTicketsBatch_output buyTicketsBatch(const id& user, uint64 reward, const QTF::BuyTicketsBatch_input& input) + { + QTF::BuyTicketsBatch_output output{}; + if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_BUY_TICKETS_BATCH, input, output, user, reward)) + { + output.returnCode = static_cast(QTF::EReturnCode::MAX_VALUE); + } + return output; + } + + QTF::BuyTicketsBySelection_output buyTicketsBySelection(const id& user, uint64 reward, const Array& numbers) + { + QTF::BuyTicketsBySelection_input input{}; + input.numbers = numbers; + QTF::BuyTicketsBySelection_output output{}; + if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_BUY_TICKETS_BY_SELECTION, input, output, user, reward)) + { + 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); } @@ -727,6 +844,126 @@ TEST(ContractQThirtyFour_Private, ValidateNumbers_WorksForValidDuplicateAndRange EXPECT_FALSE(ctl.state()->callValidateNumbers(qpi, outOfRange).isValid); } +TEST(ContractQThirtyFour_Private, IsEmptyTicket_ReturnsTrueOnlyForAllZeroTicket) +{ + ContractTestingQTF ctl; + + static const QTFRandomValues empty{}; + EXPECT_TRUE(ctl.state()->callIsEmptyTicket(empty)); + + QTFRandomValues partiallyFilled{}; + partiallyFilled.set(2, 17); + EXPECT_FALSE(ctl.state()->callIsEmptyTicket(partiallyFilled)); + + const QTFRandomValues valid = ctl.makeValidNumbers(1, 2, 3, 4); + EXPECT_FALSE(ctl.state()->callIsEmptyTicket(valid)); +} + +TEST(ContractQThirtyFour_Private, CollectSelectionNumbers_NormalizesZerosAndRejectsInvalidInput) +{ + ContractTestingQTF ctl; + + QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); + primeQpiFunctionContext(qpi); + + { + const auto out = ctl.state()->callCollectSelectionNumbers(qpi, makeSelectionInput({7, 0, 3, 9, 1})); + EXPECT_TRUE(out.isValid); + EXPECT_EQ(out.numberCount, 4u); + EXPECT_EQ(out.normalizedNumbers.get(0), 1u); + EXPECT_EQ(out.normalizedNumbers.get(1), 3u); + EXPECT_EQ(out.normalizedNumbers.get(2), 7u); + EXPECT_EQ(out.normalizedNumbers.get(3), 9u); + } + + { + const auto out = ctl.state()->callCollectSelectionNumbers(qpi, makeSelectionInput({0, 0, 0})); + EXPECT_TRUE(out.isValid); + EXPECT_EQ(out.numberCount, 0u); + } + + { + const auto out = ctl.state()->callCollectSelectionNumbers(qpi, makeSelectionInput({4, 0, 4})); + EXPECT_FALSE(out.isValid); + } + + { + const auto out = ctl.state()->callCollectSelectionNumbers(qpi, makeSelectionInput({1, 2, 31})); + EXPECT_FALSE(out.isValid); + } +} + +TEST(ContractQThirtyFour_Private, BuildSelectionTickets_GeneratesCombinationsAndClipsToBatchLimit) +{ + ContractTestingQTF ctl; + + QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); + primeQpiFunctionContext(qpi); + + { + const QTF::BuildSelectionTickets_output& out = ctl.state()->callBuildSelectionTickets(qpi, makeSelectionInput({1, 2, 3}), 3); + EXPECT_EQ(out.preparedTickets.ticketCount, 0u); + } + + { + const QTF::BuildSelectionTickets_output& out = ctl.state()->callBuildSelectionTickets(qpi, makeSelectionInput({1, 2, 3, 4, 5}), 5); + EXPECT_EQ(out.preparedTickets.ticketCount, 5u); + + const QTFRandomValues expected[] = { + ctl.makeValidNumbers(1, 2, 3, 4), ctl.makeValidNumbers(1, 2, 3, 5), ctl.makeValidNumbers(1, 2, 4, 5), + ctl.makeValidNumbers(1, 3, 4, 5), ctl.makeValidNumbers(2, 3, 4, 5), + }; + + for (uint64 ticketIndex = 0; ticketIndex < 5; ++ticketIndex) + { + const uint64 offset = ticketIndex * QTF_RANDOM_VALUES_COUNT; + for (uint64 valueIndex = 0; valueIndex < QTF_RANDOM_VALUES_COUNT; ++valueIndex) + { + EXPECT_EQ(out.preparedTickets.tickets.get(offset + valueIndex), expected[ticketIndex].get(valueIndex)); + } + } + } + + { + Array numbers{}; + for (uint8 value = 1; value <= 11; ++value) + { + numbers.set(value - 1, value); + } + + const QTF::BuildSelectionTickets_output& out = ctl.state()->callBuildSelectionTickets(qpi, numbers, 11); + EXPECT_EQ(out.preparedTickets.ticketCount, QTF_MAX_BATCH_TICKETS); + + std::set uniqueTickets; + for (uint64 ticketIndex = 0; ticketIndex < out.preparedTickets.ticketCount; ++ticketIndex) + { + const uint64 offset = ticketIndex * QTF_RANDOM_VALUES_COUNT; + const uint8 a = out.preparedTickets.tickets.get(offset); + const uint8 b = out.preparedTickets.tickets.get(offset + 1); + const uint8 c = out.preparedTickets.tickets.get(offset + 2); + const uint8 d = out.preparedTickets.tickets.get(offset + 3); + + EXPECT_GE(a, 1u); + EXPECT_LE(d, 11u); + EXPECT_LT(a, b); + EXPECT_LT(b, c); + EXPECT_LT(c, d); + + QTFRandomValues generatedTicket{}; + generatedTicket.set(0, a); + generatedTicket.set(1, b); + generatedTicket.set(2, c); + generatedTicket.set(3, d); + EXPECT_TRUE(ctl.state()->callValidateNumbers(qpi, generatedTicket).isValid); + + const uint32 packedTicket = + static_cast(a) | (static_cast(b) << 8) | (static_cast(c) << 16) | (static_cast(d) << 24); + EXPECT_TRUE(uniqueTickets.insert(packedTicket).second); + } + EXPECT_EQ(uniqueTickets.size(), static_cast(QTF_MAX_BATCH_TICKETS)); + } +} + TEST(ContractQThirtyFour_Private, GetRandomValues_IsDeterministicAndUniqueInRange) { ContractTestingQTF ctl; @@ -994,6 +1231,128 @@ TEST(ContractQThirtyFour_Private, ReturnAllTickets_RefundsEachPlayerAndClearsVia EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0ULL); } +TEST(ContractQThirtyFour_Private, BuyPreparedTickets_RejectsClosedEmptyFullAndInvalidPrice) +{ + ContractTestingQTF ctl; + const id user = id::randomValue(); + + const auto oneTicket = makePreparedTicketsInput({ctl.makeValidNumbers(1, 2, 3, 4)}); + + { + QpiContextUserProcedureCall qpi(QTF_CONTRACT_INDEX, user, ctl.state()->getTicketPriceInternal()); + primeQpiProcedureContext(qpi, static_cast(ctl.state()->getDrawHourInternal())); + + increaseEnergy(ctl.qtfSelf(), ctl.state()->getTicketPriceInternal()); + const uint64 userBefore = getBalance(user); + const auto out = ctl.state()->callBuyPreparedTickets(qpi, oneTicket); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::TICKET_SELLING_CLOSED)); + EXPECT_EQ(getBalance(user), userBefore + ctl.state()->getTicketPriceInternal()); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); + } + + ctl.startAnyDayEpoch(); + + { + QpiContextUserProcedureCall qpi(QTF_CONTRACT_INDEX, user, ctl.state()->getTicketPriceInternal()); + primeQpiProcedureContext(qpi, static_cast(ctl.state()->getDrawHourInternal())); + + increaseEnergy(ctl.qtfSelf(), ctl.state()->getTicketPriceInternal()); + const uint64 userBefore = getBalance(user); + QTF::BuyPreparedTickets_input emptyInput{}; + const auto out = ctl.state()->callBuyPreparedTickets(qpi, emptyInput); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_NUMBERS)); + EXPECT_EQ(getBalance(user), userBefore + ctl.state()->getTicketPriceInternal()); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); + } + + for (uint64 i = 0; i < QTF_MAX_NUMBER_OF_PLAYERS; ++i) + { + ctl.addPlayerDirect(id::randomValue(), ctl.makeValidNumbers(1, 2, 3, 4)); + } + + { + QpiContextUserProcedureCall qpi(QTF_CONTRACT_INDEX, user, ctl.state()->getTicketPriceInternal()); + primeQpiProcedureContext(qpi, static_cast(ctl.state()->getDrawHourInternal())); + + increaseEnergy(ctl.qtfSelf(), ctl.state()->getTicketPriceInternal()); + const uint64 userBefore = getBalance(user); + const auto out = ctl.state()->callBuyPreparedTickets(qpi, oneTicket); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::MAX_PLAYERS_REACHED)); + EXPECT_EQ(getBalance(user), userBefore + ctl.state()->getTicketPriceInternal()); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), QTF_MAX_NUMBER_OF_PLAYERS); + } + + ctl.state()->setNumberOfPlayers(0); + ctl.state()->setTicketPriceInternal(static_cast(INT64_MAX)); + + { + const uint64 reward = 1; + QpiContextUserProcedureCall qpi(QTF_CONTRACT_INDEX, user, reward); + primeQpiProcedureContext(qpi, static_cast(ctl.state()->getDrawHourInternal())); + + increaseEnergy(ctl.qtfSelf(), reward); + const uint64 userBefore = getBalance(user); + const auto out = ctl.state()->callBuyPreparedTickets(qpi, makePreparedTicketsInput({ + ctl.makeValidNumbers(1, 2, 3, 4), + ctl.makeValidNumbers(5, 6, 7, 8), + })); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_TICKET_PRICE)); + EXPECT_EQ(getBalance(user), userBefore + reward); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); + } +} + +TEST(ContractQThirtyFour_Private, BuyPreparedTickets_SucceedsRefundsExcessAndSupportsPartialPurchase) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + const auto twoTickets = makePreparedTicketsInput({ + ctl.makeValidNumbers(1, 2, 3, 4), + ctl.makeValidNumbers(5, 6, 7, 8), + }); + + { + const uint64 reward = ticketPrice * 3; + QpiContextUserProcedureCall qpi(QTF_CONTRACT_INDEX, user, reward); + primeQpiProcedureContext(qpi, static_cast(ctl.state()->getDrawHourInternal())); + + increaseEnergy(ctl.qtfSelf(), reward); + const uint64 userBefore = getBalance(user); + const auto out = ctl.state()->callBuyPreparedTickets(qpi, twoTickets); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 2u); + EXPECT_EQ(getBalance(user), userBefore + ticketPrice); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 2u); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(0).randomValues, ctl.makeValidNumbers(1, 2, 3, 4))); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(1).randomValues, ctl.makeValidNumbers(5, 6, 7, 8))); + } + + while (ctl.state()->getNumberOfPlayers() < QTF_MAX_NUMBER_OF_PLAYERS - 1) + { + ctl.addPlayerDirect(id::randomValue(), ctl.makeValidNumbers(9, 10, 11, 12)); + } + + { + const uint64 reward = ticketPrice * 2; + QpiContextUserProcedureCall qpi(QTF_CONTRACT_INDEX, user, reward); + primeQpiProcedureContext(qpi, static_cast(ctl.state()->getDrawHourInternal())); + + increaseEnergy(ctl.qtfSelf(), reward); + const uint64 userBefore = getBalance(user); + const auto out = ctl.state()->callBuyPreparedTickets(qpi, twoTickets); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::PARTIAL_PURCHASE)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 1u); + EXPECT_EQ(getBalance(user), userBefore + ticketPrice); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), QTF_MAX_NUMBER_OF_PLAYERS); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(QTF_MAX_NUMBER_OF_PLAYERS - 1).randomValues, ctl.makeValidNumbers(1, 2, 3, 4))); + } +} + // ============================================================================ // BUY TICKET TESTS // ============================================================================ @@ -1256,6 +1615,331 @@ TEST(ContractQThirtyFour, BuyTicket_SamePlayerMultipleTickets_Allowed) EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 3u); } +// ============================================================================ +// BATCH AND SELECTION BUY TESTS +// ============================================================================ + +TEST(ContractQThirtyFour, BuyTicketsBatch_WhenSellingClosed_RefundsAndFails) +{ + ContractTestingQTF ctl; + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 2); + const uint64 balanceBefore = getBalance(user); + + const auto out = ctl.buyTicketsBatch(user, ticketPrice, + makeBatchTicketsInput({ + ctl.makeValidNumbers(1, 2, 3, 4), + })); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::TICKET_SELLING_CLOSED)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, BuyTicketsBatch_AllInvalidOrEmpty_RefundsAndFails) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 5); + const uint64 balanceBefore = getBalance(user); + + QTFRandomValues duplicate = ctl.makeValidNumbers(4, 5, 6, 7); + duplicate.set(3, 5); + QTFRandomValues outOfRange = ctl.makeValidNumbers(1, 2, 3, 4); + outOfRange.set(2, 31); + QTFRandomValues empty{}; + + const auto out = ctl.buyTicketsBatch(user, ticketPrice * 3, makeBatchTicketsInput({empty, duplicate, outOfRange})); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_NUMBERS)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, BuyTicketsBatch_RejectsInsufficientRewardForValidTickets) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 5); + const uint64 balanceBefore = getBalance(user); + + const auto out = ctl.buyTicketsBatch(user, ticketPrice, + makeBatchTicketsInput({ + ctl.makeValidNumbers(1, 2, 3, 4), + ctl.makeValidNumbers(5, 6, 7, 8), + })); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_TICKET_PRICE)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, BuyTicketsBatch_WhenMaxPlayersReached_RefundsAndFails) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + for (uint64 i = 0; i < QTF_MAX_NUMBER_OF_PLAYERS; ++i) + { + ctl.addPlayerDirect(id::randomValue(), ctl.makeValidNumbers(1, 2, 3, 4)); + } + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 2); + const uint64 balanceBefore = getBalance(user); + + const auto out = ctl.buyTicketsBatch(user, ticketPrice, + makeBatchTicketsInput({ + ctl.makeValidNumbers(5, 6, 7, 8), + })); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::MAX_PLAYERS_REACHED)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), QTF_MAX_NUMBER_OF_PLAYERS); +} + +TEST(ContractQThirtyFour, BuyTicketsBatch_PartialPurchase_UsesOnlyFreeSlots) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + while (ctl.state()->getNumberOfPlayers() < QTF_MAX_NUMBER_OF_PLAYERS - 2) + { + ctl.addPlayerDirect(id::randomValue(), ctl.makeValidNumbers(9, 10, 11, 12)); + } + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 5); + const uint64 balanceBefore = getBalance(user); + + const QTFRandomValues t1 = ctl.makeValidNumbers(1, 2, 3, 4); + const QTFRandomValues t2 = ctl.makeValidNumbers(5, 6, 7, 8); + const QTFRandomValues t3 = ctl.makeValidNumbers(13, 14, 15, 16); + const auto out = ctl.buyTicketsBatch(user, ticketPrice * 3, makeBatchTicketsInput({t1, t2, t3})); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::PARTIAL_PURCHASE)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 2u); + EXPECT_EQ(getBalance(user), balanceBefore - ticketPrice * 2); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), QTF_MAX_NUMBER_OF_PLAYERS); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(QTF_MAX_NUMBER_OF_PLAYERS - 2).randomValues, t1)); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(QTF_MAX_NUMBER_OF_PLAYERS - 1).randomValues, t2)); +} + +TEST(ContractQThirtyFour, BuyTicketsBatch_SkipsInvalidTicketsAndRefundsExcess) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 6); + const uint64 balanceBefore = getBalance(user); + + QTFRandomValues duplicate = ctl.makeValidNumbers(11, 12, 13, 14); + duplicate.set(3, 12); + QTFRandomValues outOfRange = ctl.makeValidNumbers(21, 22, 23, 24); + outOfRange.set(0, 0); + QTFRandomValues empty{}; + const QTFRandomValues valid1 = ctl.makeValidNumbers(1, 2, 3, 4); + const QTFRandomValues valid2 = ctl.makeValidNumbers(5, 6, 7, 8); + + const auto out = ctl.buyTicketsBatch(user, ticketPrice * 3, makeBatchTicketsInput({valid1, empty, duplicate, valid2, outOfRange})); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 2u); + EXPECT_EQ(getBalance(user), balanceBefore - ticketPrice * 2); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 2u); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(0).randomValues, valid1)); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(1).randomValues, valid2)); +} + +TEST(ContractQThirtyFour, BuyTicketsBySelection_WhenSellingClosed_RefundsAndFails) +{ + ContractTestingQTF ctl; + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 2); + const uint64 balanceBefore = getBalance(user); + + const auto out = ctl.buyTicketsBySelection(user, ticketPrice, makeSelectionInput({1, 2, 3, 4})); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::TICKET_SELLING_CLOSED)); + EXPECT_EQ(static_cast(out.requestedTicketCount), 0u); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, BuyTicketsBySelection_InvalidSelections_RefundAndFail) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 6); + const uint64 balanceBefore = getBalance(user); + + { + const auto out = ctl.buyTicketsBySelection(user, ticketPrice, makeSelectionInput({4, 0, 4, 8})); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_NUMBERS)); + EXPECT_EQ(static_cast(out.requestedTicketCount), 0u); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + } + + { + const auto out = ctl.buyTicketsBySelection(user, ticketPrice, makeSelectionInput({1, 2, 31, 4})); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_NUMBERS)); + EXPECT_EQ(static_cast(out.requestedTicketCount), 0u); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + } + + { + const auto out = ctl.buyTicketsBySelection(user, ticketPrice, makeSelectionInput({1, 2, 3, 0})); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_NUMBERS)); + EXPECT_EQ(static_cast(out.requestedTicketCount), 0u); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + } + + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, BuyTicketsBySelection_TooManyCombinations_RefundsAndFails) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 40); + const uint64 balanceBefore = getBalance(user); + + const auto out = ctl.buyTicketsBySelection(user, ticketPrice * 30, makeSelectionInput({1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11})); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_VALUE)); + EXPECT_EQ(static_cast(out.requestedTicketCount), combinations4(11)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, BuyTicketsBySelection_RejectsInsufficientReward) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 10); + const uint64 balanceBefore = getBalance(user); + + const auto out = ctl.buyTicketsBySelection(user, ticketPrice * 4, makeSelectionInput({1, 2, 3, 4, 5})); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_TICKET_PRICE)); + EXPECT_EQ(static_cast(out.requestedTicketCount), combinations4(5)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, BuyTicketsBySelection_WhenMaxPlayersReached_RefundsAndFails) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + for (uint64 i = 0; i < QTF_MAX_NUMBER_OF_PLAYERS; ++i) + { + ctl.addPlayerDirect(id::randomValue(), ctl.makeValidNumbers(1, 2, 3, 4)); + } + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 2); + const uint64 balanceBefore = getBalance(user); + + const auto out = ctl.buyTicketsBySelection(user, ticketPrice, makeSelectionInput({1, 2, 3, 4})); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::MAX_PLAYERS_REACHED)); + EXPECT_EQ(static_cast(out.requestedTicketCount), 1u); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), QTF_MAX_NUMBER_OF_PLAYERS); +} + +TEST(ContractQThirtyFour, BuyTicketsBySelection_PartialPurchase_UsesOnlyFreeSlots) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + while (ctl.state()->getNumberOfPlayers() < QTF_MAX_NUMBER_OF_PLAYERS - 3) + { + ctl.addPlayerDirect(id::randomValue(), ctl.makeValidNumbers(20, 21, 22, 23)); + } + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 8); + const uint64 balanceBefore = getBalance(user); + + const auto out = ctl.buyTicketsBySelection(user, ticketPrice * 5, makeSelectionInput({9, 1, 5, 7, 3})); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::PARTIAL_PURCHASE)); + EXPECT_EQ(static_cast(out.requestedTicketCount), combinations4(5)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 3u); + EXPECT_EQ(getBalance(user), balanceBefore - ticketPrice * 3); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), QTF_MAX_NUMBER_OF_PLAYERS); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(QTF_MAX_NUMBER_OF_PLAYERS - 3).randomValues, ctl.makeValidNumbers(1, 3, 5, 7))); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(QTF_MAX_NUMBER_OF_PLAYERS - 2).randomValues, ctl.makeValidNumbers(1, 3, 5, 9))); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(QTF_MAX_NUMBER_OF_PLAYERS - 1).randomValues, ctl.makeValidNumbers(1, 3, 7, 9))); +} + +TEST(ContractQThirtyFour, BuyTicketsBySelection_SuccessBuildsNormalizedCombinationsAndRefundsExcess) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 10); + const uint64 balanceBefore = getBalance(user); + + const auto out = ctl.buyTicketsBySelection(user, ticketPrice * 6, makeSelectionInput({9, 0, 3, 7, 1, 5})); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + EXPECT_EQ(static_cast(out.requestedTicketCount), combinations4(5)); + EXPECT_EQ(static_cast(out.boughtTicketCount), combinations4(5)); + EXPECT_EQ(getBalance(user), balanceBefore - ticketPrice * combinations4(5)); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), combinations4(5)); + + const QTFRandomValues expected[] = { + ctl.makeValidNumbers(1, 3, 5, 7), ctl.makeValidNumbers(1, 3, 5, 9), ctl.makeValidNumbers(1, 3, 7, 9), + ctl.makeValidNumbers(1, 5, 7, 9), ctl.makeValidNumbers(3, 5, 7, 9), + }; + + for (uint64 i = 0; i < combinations4(5); ++i) + { + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(i).randomValues, expected[i])); + } +} + // ============================================================================ // CONFIGURATION CHANGE TESTS // ============================================================================ From 31b5ad61c102870f962de99a952b372ef6cfc2eb Mon Sep 17 00:00:00 2001 From: N-010 Date: Sun, 15 Mar 2026 19:34:18 +0300 Subject: [PATCH 15/15] Fixes Contract Verify --- src/contracts/QThirtyFour.h | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index ae0ab7314..64e886544 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -4,10 +4,12 @@ using namespace QPI; 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_MAX_RANDOM_VALUE_ALIGNED = QTF_MAX_RANDOM_VALUE + 2; constexpr uint64 QTF_MAX_BATCH_TICKETS = div(QTF_MAX_NUMBER_OF_PLAYERS, 4ULL); constexpr uint64 QTF_BATCH_TICKET_VALUES_COUNT = QTF_MAX_BATCH_TICKETS * QTF_RANDOM_VALUES_COUNT; constexpr uint64 QTF_TICKET_PRICE = 1000000; constexpr uint64 QTF_WINNING_COMBINATIONS_HISTORY_SIZE = 128; +constexpr uint64 QTF_PREPARED_TICKET_MAX_COUNT = QTF_MAX_NUMBER_OF_PLAYERS * QTF_RANDOM_VALUES_COUNT; // 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. @@ -193,18 +195,18 @@ struct QTF : ContractBase }; struct ValidateNumbers_locals { - HashSet seen; + HashSet seen; uint8 idx; uint8 value; }; struct CollectSelectionNumbers_input { - Array numbers; + Array numbers; }; struct CollectSelectionNumbers_output { - Array normalizedNumbers; + Array normalizedNumbers; bit isValid; uint8 numberCount; }; @@ -218,7 +220,7 @@ struct QTF : ContractBase struct BuyPreparedTickets_input { - Array tickets; + Array tickets; uint64 ticketCount; }; struct BuyPreparedTickets_output @@ -239,7 +241,7 @@ struct QTF : ContractBase struct BuildSelectionTickets_input { - Array normalizedNumbers; + Array normalizedNumbers; uint8 numberCount; }; struct BuildSelectionTickets_output @@ -297,7 +299,7 @@ struct QTF : ContractBase struct BuyTicketsBySelection_input { - Array numbers; + Array numbers; }; struct BuyTicketsBySelection_output { @@ -477,7 +479,7 @@ struct QTF : ContractBase uint8 candidate; uint8 attempts; uint8 fallback; - HashSet used; + HashSet used; }; // CalcReserveTopUp: Calculate safe reserve top-up amount