From d67dc296bf1bd8133656244a7a2bc22d9b70dd23 Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 9 Oct 2025 20:44:19 +0300 Subject: [PATCH 01/22] Enhance RandomLottery: transfer invocation rewards on ticket purchase failures --- src/contracts/RandomLottery.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index 7a1a109ad..58a0ccf81 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -368,6 +368,8 @@ struct RL : public ContractBase if (state.players.contains(qpi.invocator())) { output.returnCode = static_cast(EReturnCode::TICKET_ALREADY_PURCHASED); + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + return; } @@ -375,6 +377,8 @@ struct RL : public ContractBase if (state.players.add(qpi.invocator()) == NULL_INDEX) { output.returnCode = static_cast(EReturnCode::TICKET_ALL_SOLD_OUT); + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + return; } From a7cc0b212ab15c903f88547539eabe7da2313c5a Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 15 Oct 2025 19:51:41 +0300 Subject: [PATCH 02/22] Fixes increment winnersInfoNextEmptyIndex --- src/contracts/RandomLottery.h | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index 58a0ccf81..448cf0c96 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -404,10 +404,9 @@ struct RL : public ContractBase { return; } - if (RL_MAX_NUMBER_OF_WINNERS_IN_HISTORY >= state.winners.capacity() - 1) - { - state.winnersInfoNextEmptyIndex = 0; - } + + state.winnersInfoNextEmptyIndex = mod(state.winnersInfoNextEmptyIndex, state.winners.capacity()); + locals.winnerInfo.winnerAddress = input.winnerAddress; locals.winnerInfo.revenue = input.revenue; locals.winnerInfo.epoch = qpi.epoch(); From 4dbf07ddd7a25dd45d979123f5a8f25f16a4933d Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 22 Oct 2025 19:13:43 +0300 Subject: [PATCH 03/22] Removes the limit on buying one ticket --- src/contracts/RandomLottery.h | 170 +++++++++++++++++++--------------- 1 file changed, 95 insertions(+), 75 deletions(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index 448cf0c96..e7a01b2cb 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -18,6 +18,14 @@ constexpr uint16 RL_MAX_NUMBER_OF_PLAYERS = 1024; /// Maximum number of winners kept in the on-chain winners history buffer. constexpr uint16 RL_MAX_NUMBER_OF_WINNERS_IN_HISTORY = 1024; +constexpr uint64 RL_TICKET_PRICE = 1000000; + +constexpr uint8 RL_TEAM_FEE_PERCENT = 10; + +constexpr uint8 RL_SHAREHOLDER_FEE_PERCENT = 20; + +constexpr uint8 RL_BURN_PERCENT = 2; + /// Placeholder structure for future extensions. struct RL2 { @@ -44,7 +52,9 @@ struct RL : public ContractBase enum class EState : uint8 { SELLING, - LOCKED + LOCKED, + + INVALID = 255 }; /** @@ -55,13 +65,12 @@ struct RL : public ContractBase SUCCESS = 0, // Ticket-related errors TICKET_INVALID_PRICE = 1, - TICKET_ALREADY_PURCHASED = 2, - TICKET_ALL_SOLD_OUT = 3, - TICKET_SELLING_CLOSED = 4, + TICKET_ALL_SOLD_OUT = 2, + TICKET_SELLING_CLOSED = 3, // Access-related errors - ACCESS_DENIED = 5, + ACCESS_DENIED = 4, // Fee-related errors - FEE_INVALID_PERCENT_VALUE = 6, + FEE_INVALID_PERCENT_VALUE = 5, // Fallback UNKNOW_ERROR = UINT8_MAX }; @@ -97,16 +106,10 @@ struct RL : public ContractBase struct GetPlayers_output { Array players; - uint16 numberOfPlayers = 0; + uint16 playerCounter = 0; uint8 returnCode = static_cast(EReturnCode::SUCCESS); }; - struct GetPlayers_locals - { - uint64 arrayIndex = 0; - sint64 i = 0; - }; - /** * @brief Stored winner snapshot for an epoch. */ @@ -157,10 +160,37 @@ struct RL : public ContractBase struct GetWinners_output { Array winners; - uint64 numberOfWinners = 0; + uint64 winnersCounter = 0; uint8 returnCode = static_cast(EReturnCode::SUCCESS); }; + struct GetTicketPrice_input + { + }; + + struct GetTicketPrice_output + { + uint64 ticketPrice = 0; + }; + + struct GetMaxNumberOfPlayers_input + { + }; + + struct GetMaxNumberOfPlayers_output + { + uint16 numberOfPlayers = 0; + }; + + struct GetState_input + { + }; + + struct GetState_output + { + uint8 currentState = static_cast(EState::INVALID); + }; + struct ReturnAllTickets_input { }; @@ -208,6 +238,9 @@ struct RL : public ContractBase REGISTER_USER_FUNCTION(GetFees, 1); REGISTER_USER_FUNCTION(GetPlayers, 2); REGISTER_USER_FUNCTION(GetWinners, 3); + REGISTER_USER_FUNCTION(GetTicketPrice, 4); + REGISTER_USER_FUNCTION(GetMaxNumberOfPlayers, 5); + REGISTER_USER_FUNCTION(GetState, 6); REGISTER_USER_PROCEDURE(BuyTicket, 1); } @@ -218,22 +251,25 @@ struct RL : public ContractBase INITIALIZE() { // Addresses - state.teamAddress = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, - _R, _L, _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); + state.teamAddress = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, + _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); // Owner address (currently identical to developer address; can be split in future revisions). state.ownerAddress = state.teamAddress; // Default fee percentages (sum <= 100; winner percent derived) - state.teamFeePercent = 10; - state.distributionFeePercent = 20; - state.burnPercent = 2; + state.teamFeePercent = RL_TEAM_FEE_PERCENT; + state.distributionFeePercent = RL_SHAREHOLDER_FEE_PERCENT; + state.burnPercent = RL_BURN_PERCENT; state.winnerFeePercent = 100 - state.teamFeePercent - state.distributionFeePercent - state.burnPercent; // Default ticket price - state.ticketPrice = 1000000; + state.ticketPrice = RL_TICKET_PRICE; // Start locked state.currentState = EState::LOCKED; + + // Initialize Player index + state.playerCounter = 0; } /** @@ -250,11 +286,11 @@ struct RL : public ContractBase state.currentState = EState::LOCKED; // Single-player edge case: refund instead of drawing. - if (state.players.population() == 1) + if (state.playerCounter == 1) { ReturnAllTickets(qpi, state, locals.returnAllTicketsInput, locals.returnAllTicketsOutput, locals.returnAllTicketsLocals); } - else if (state.players.population() > 1) + else if (state.playerCounter > 1) { qpi.getEntity(SELF, locals.entity); locals.revenue = locals.entity.incomingAmount - locals.entity.outgoingAmount; @@ -307,7 +343,7 @@ struct RL : public ContractBase } // Prepare for next epoch. - state.players.reset(); + state.playerCounter = 0; } /** @@ -324,18 +360,10 @@ struct RL : public ContractBase /** * @brief Retrieves the active players list for the ongoing epoch. */ - PUBLIC_FUNCTION_WITH_LOCALS(GetPlayers) + PUBLIC_FUNCTION(GetPlayers) { - locals.arrayIndex = 0; - - locals.i = state.players.nextElementIndex(NULL_INDEX); - while (locals.i != NULL_INDEX) - { - output.players.set(locals.arrayIndex++, state.players.key(locals.i)); - locals.i = state.players.nextElementIndex(locals.i); - }; - - output.numberOfPlayers = static_cast(locals.arrayIndex); + output.players = state.players; + output.playerCounter = state.playerCounter; } /** @@ -344,9 +372,13 @@ struct RL : public ContractBase PUBLIC_FUNCTION(GetWinners) { output.winners = state.winners; - output.numberOfWinners = state.winnersInfoNextEmptyIndex; + output.winnersCounter = state.winnersCounter; } + PUBLIC_FUNCTION(GetTicketPrice) { output.ticketPrice = state.ticketPrice; } + PUBLIC_FUNCTION(GetMaxNumberOfPlayers) { output.numberOfPlayers = RL_MAX_NUMBER_OF_PLAYERS; } + PUBLIC_FUNCTION(GetState) { output.currentState = static_cast(state.currentState); } + /** * @brief Attempts to buy a ticket (must send exact price unless zero is forbidden; state must * be SELLING). Reverts with proper return codes for invalid cases. @@ -356,41 +388,40 @@ struct RL : public ContractBase // Selling closed if (state.currentState == EState::LOCKED) { - output.returnCode = static_cast(EReturnCode::TICKET_SELLING_CLOSED); if (qpi.invocationReward() > 0) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); } + + output.returnCode = static_cast(EReturnCode::TICKET_SELLING_CLOSED); return; } - // Already purchased - if (state.players.contains(qpi.invocator())) + // Price mismatch (validate before any state mutation) + if (qpi.invocationReward() != state.ticketPrice && qpi.invocationReward() > 0) { - output.returnCode = static_cast(EReturnCode::TICKET_ALREADY_PURCHASED); qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = static_cast(EReturnCode::TICKET_INVALID_PRICE); return; } // Capacity full - if (state.players.add(qpi.invocator()) == NULL_INDEX) + if (state.playerCounter >= state.players.capacity()) { output.returnCode = static_cast(EReturnCode::TICKET_ALL_SOLD_OUT); - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } return; } - // Price mismatch - if (qpi.invocationReward() != state.ticketPrice && qpi.invocationReward() > 0) + // Protect against rewriting existing players (should not happen due to prior checks). + if (state.playerCounter < state.players.capacity()) { - output.returnCode = static_cast(EReturnCode::TICKET_INVALID_PRICE); - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - state.players.remove(qpi.invocator()); - - state.players.cleanupIfNeeded(80); - return; + state.players.set(state.playerCounter, qpi.invocator()); + state.playerCounter = min(++state.playerCounter, state.players.capacity()); } } @@ -405,13 +436,13 @@ struct RL : public ContractBase return; } - state.winnersInfoNextEmptyIndex = mod(state.winnersInfoNextEmptyIndex, state.winners.capacity()); + state.winnersCounter = mod(state.winnersCounter, state.winners.capacity()); locals.winnerInfo.winnerAddress = input.winnerAddress; locals.winnerInfo.revenue = input.revenue; locals.winnerInfo.epoch = qpi.epoch(); locals.winnerInfo.tick = qpi.tick(); - state.winners.set(state.winnersInfoNextEmptyIndex++, locals.winnerInfo); + state.winners.set(state.winnersCounter++, locals.winnerInfo); } /** @@ -419,37 +450,24 @@ struct RL : public ContractBase */ PRIVATE_PROCEDURE_WITH_LOCALS(GetWinner) { - if (state.players.population() == 0) + if (state.playerCounter == 0) { return; } - locals.randomNum = mod(qpi.K12(qpi.getPrevSpectrumDigest()).u64._0, state.players.population()); + locals.randomNum = mod(qpi.K12(qpi.getPrevSpectrumDigest()).u64._0, state.playerCounter); - locals.j = 0; - locals.i = state.players.nextElementIndex(NULL_INDEX); - while (locals.i != NULL_INDEX) - { - if (locals.j++ == locals.randomNum) - { - output.winnerAddress = state.players.key(locals.i); - output.index = locals.i; - break; - } - - locals.i = state.players.nextElementIndex(locals.i); - }; + // Direct indexing for Array + output.winnerAddress = state.players.get(locals.randomNum); + output.index = locals.randomNum; } PRIVATE_PROCEDURE_WITH_LOCALS(ReturnAllTickets) { - locals.i = state.players.nextElementIndex(NULL_INDEX); - while (locals.i != NULL_INDEX) + for (locals.i = 0; locals.i < state.playerCounter; ++locals.i) { - qpi.transfer(state.players.key(locals.i), state.ticketPrice); - - locals.i = state.players.nextElementIndex(locals.i); - }; + qpi.transfer(state.players.get(locals.i), state.ticketPrice); + } } protected: @@ -495,11 +513,13 @@ struct RL : public ContractBase */ uint64 ticketPrice = 0; + uint64 playerCounter = 0; + /** * @brief Set of players participating in the current lottery epoch. * Maximum capacity is defined by RL_MAX_NUMBER_OF_PLAYERS. */ - HashSet players = {}; + Array players = {}; /** * @brief Circular buffer storing the history of winners. @@ -511,7 +531,7 @@ struct RL : public ContractBase * @brief Index pointing to the next empty slot in the winners array. * Used for maintaining the circular buffer of winners. */ - uint64 winnersInfoNextEmptyIndex = 0; + uint64 winnersCounter = 0; /** * @brief Current state of the lottery contract. From f3d696d50a402f59a183bda8e9725d89f0988674 Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 22 Oct 2025 22:30:35 +0300 Subject: [PATCH 04/22] Add new public functions to retrieve ticket price, max number of players, contract state, and balance --- src/contracts/RandomLottery.h | 32 ++++- test/contract_rl.cpp | 263 ++++++++++++++++++++++++++-------- 2 files changed, 232 insertions(+), 63 deletions(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index e7a01b2cb..cf56299ef 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -106,7 +106,7 @@ struct RL : public ContractBase struct GetPlayers_output { Array players; - uint16 playerCounter = 0; + uint64 playerCounter = 0; uint8 returnCode = static_cast(EReturnCode::SUCCESS); }; @@ -179,7 +179,7 @@ struct RL : public ContractBase struct GetMaxNumberOfPlayers_output { - uint16 numberOfPlayers = 0; + uint64 numberOfPlayers = 0; }; struct GetState_input @@ -191,6 +191,20 @@ struct RL : public ContractBase uint8 currentState = static_cast(EState::INVALID); }; + struct GetBalance_input + { + }; + + struct GetBalance_output + { + uint64 balance = 0; + }; + + struct GetBalance_locals + { + Entity entity = {}; + }; + struct ReturnAllTickets_input { }; @@ -241,6 +255,7 @@ struct RL : public ContractBase REGISTER_USER_FUNCTION(GetTicketPrice, 4); REGISTER_USER_FUNCTION(GetMaxNumberOfPlayers, 5); REGISTER_USER_FUNCTION(GetState, 6); + REGISTER_USER_FUNCTION(GetBalance, 7); REGISTER_USER_PROCEDURE(BuyTicket, 1); } @@ -363,7 +378,7 @@ struct RL : public ContractBase PUBLIC_FUNCTION(GetPlayers) { output.players = state.players; - output.playerCounter = state.playerCounter; + output.playerCounter = min(state.playerCounter, state.players.capacity()); } /** @@ -372,12 +387,16 @@ struct RL : public ContractBase PUBLIC_FUNCTION(GetWinners) { output.winners = state.winners; - output.winnersCounter = state.winnersCounter; + output.winnersCounter = min(state.winnersCounter, state.winners.capacity()); } PUBLIC_FUNCTION(GetTicketPrice) { output.ticketPrice = state.ticketPrice; } PUBLIC_FUNCTION(GetMaxNumberOfPlayers) { output.numberOfPlayers = RL_MAX_NUMBER_OF_PLAYERS; } PUBLIC_FUNCTION(GetState) { output.currentState = static_cast(state.currentState); } + PUBLIC_FUNCTION_WITH_LOCALS(GetBalance) + { + output.balance = qpi.getEntity(SELF, locals.entity) ? locals.entity.incomingAmount - locals.entity.outgoingAmount : 0; + } /** * @brief Attempts to buy a ticket (must send exact price unless zero is forbidden; state must @@ -421,7 +440,7 @@ struct RL : public ContractBase if (state.playerCounter < state.players.capacity()) { state.players.set(state.playerCounter, qpi.invocator()); - state.playerCounter = min(++state.playerCounter, state.players.capacity()); + state.playerCounter = min(state.playerCounter + 1, state.players.capacity()); } } @@ -538,4 +557,7 @@ struct RL : public ContractBase * Can be either SELLING (tickets available) or LOCKED (epoch closed). */ EState currentState = EState::LOCKED; + +protected: + template static constexpr const T& min(const T& a, const T& b) { return (a < b) ? a : b; } }; diff --git a/test/contract_rl.cpp b/test/contract_rl.cpp index 5c0bd008b..30e263656 100644 --- a/test/contract_rl.cpp +++ b/test/contract_rl.cpp @@ -7,6 +7,10 @@ constexpr uint16 PROCEDURE_INDEX_BUY_TICKET = 1; constexpr uint16 FUNCTION_INDEX_GET_FEES = 1; constexpr uint16 FUNCTION_INDEX_GET_PLAYERS = 2; constexpr uint16 FUNCTION_INDEX_GET_WINNERS = 3; +constexpr uint16 FUNCTION_INDEX_GET_TICKET_PRICE = 4; +constexpr uint16 FUNCTION_INDEX_GET_MAX_NUM_PLAYERS = 5; +constexpr uint16 FUNCTION_INDEX_GET_STATE = 6; +constexpr uint16 FUNCTION_INDEX_GET_BALANCE = 7; static const id RL_DEV_ADDRESS = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); @@ -37,14 +41,11 @@ class RLChecker : public RL { EXPECT_EQ(output.returnCode, static_cast(EReturnCode::SUCCESS)); EXPECT_EQ(output.players.capacity(), players.capacity()); - EXPECT_EQ(static_cast(output.numberOfPlayers), players.population()); + EXPECT_EQ(output.playerCounter, playerCounter); - for (uint64 i = 0, playerArrayIndex = 0; i < players.capacity(); ++i) + for (uint64 i = 0; i < playerCounter; ++i) { - if (!players.isEmptySlot(i)) - { - EXPECT_EQ(output.players.get(playerArrayIndex++), players.key(i)); - } + EXPECT_EQ(output.players.get(i), players.get(i)); } } @@ -52,9 +53,9 @@ class RLChecker : public RL { EXPECT_EQ(output.returnCode, static_cast(EReturnCode::SUCCESS)); EXPECT_EQ(output.winners.capacity(), winners.capacity()); - EXPECT_EQ(output.numberOfWinners, winnersInfoNextEmptyIndex); + EXPECT_EQ(output.winnersCounter, winnersCounter); - for (uint64 i = 0; i < output.numberOfWinners; ++i) + for (uint64 i = 0; i < winnersCounter; ++i) { EXPECT_EQ(output.winners.get(i), winners.get(i)); } @@ -62,10 +63,10 @@ class RLChecker : public RL void randomlyAddPlayers(uint64 maxNewPlayers) { - const uint64 newPlayerCount = mod(maxNewPlayers, players.capacity()); - for (uint64 i = 0; i < newPlayerCount; ++i) + playerCounter = mod(maxNewPlayers, players.capacity()); + for (uint64 i = 0; i < playerCounter; ++i) { - players.add(id::randomValue()); + players.set(i, id::randomValue()); } } @@ -73,7 +74,7 @@ class RLChecker : public RL { const uint64 newWinnerCount = mod(maxNewWinners, winners.capacity()); - winnersInfoNextEmptyIndex = 0; + winnersCounter = 0; WinnerInfo wi; for (uint64 i = 0; i < newWinnerCount; ++i) @@ -82,15 +83,11 @@ class RLChecker : public RL wi.tick = 1; wi.revenue = 1000000; wi.winnerAddress = id::randomValue(); - winners.set(winnersInfoNextEmptyIndex++, wi); + winners.set(winnersCounter++, wi); } } - void setSelling() { currentState = EState::SELLING; } - - void setLocked() { currentState = EState::LOCKED; } - - uint64 playersPopulation() const { return players.population(); } + uint64 getPlayerCounter() const { return playerCounter; } uint64 getTicketPrice() const { return ticketPrice; } }; @@ -106,7 +103,8 @@ class ContractTestingRL : protected ContractTesting callSystemProcedure(RL_CONTRACT_INDEX, INITIALIZE); } - RLChecker* getState() { return reinterpret_cast(contractStates[RL_CONTRACT_INDEX]); } + // Access internal contract state for assertions + RLChecker* state() { return reinterpret_cast(contractStates[RL_CONTRACT_INDEX]); } RL::GetFees_output getFees() { @@ -135,6 +133,42 @@ class ContractTestingRL : protected ContractTesting return output; } + // Wrapper for public function RL::GetTicketPrice + RL::GetTicketPrice_output getTicketPrice() + { + RL::GetTicketPrice_input input; + RL::GetTicketPrice_output output; + callFunction(RL_CONTRACT_INDEX, FUNCTION_INDEX_GET_TICKET_PRICE, input, output); + return output; + } + + // Wrapper for public function RL::GetMaxNumberOfPlayers + RL::GetMaxNumberOfPlayers_output getMaxNumberOfPlayers() + { + RL::GetMaxNumberOfPlayers_input input; + RL::GetMaxNumberOfPlayers_output output; + callFunction(RL_CONTRACT_INDEX, FUNCTION_INDEX_GET_MAX_NUM_PLAYERS, input, output); + return output; + } + + // Wrapper for public function RL::GetState + RL::GetState_output getStateInfo() + { + RL::GetState_input input; + RL::GetState_output output; + callFunction(RL_CONTRACT_INDEX, FUNCTION_INDEX_GET_STATE, input, output); + return output; + } + + // Wrapper for public function RL::GetBalance + RL::GetBalance_output getBalanceInfo() + { + RL::GetBalance_input input; + RL::GetBalance_output output; + callFunction(RL_CONTRACT_INDEX, FUNCTION_INDEX_GET_BALANCE, input, output); + return output; + } + RL::BuyTicket_output buyTicket(const id& user, uint64 reward) { RL::BuyTicket_input input; @@ -146,13 +180,43 @@ class ContractTestingRL : protected ContractTesting void BeginEpoch() { callSystemProcedure(RL_CONTRACT_INDEX, BEGIN_EPOCH); } void EndEpoch() { callSystemProcedure(RL_CONTRACT_INDEX, END_EPOCH); } + + // Returns the SELF contract account address + id rlSelf() { return id(RL_CONTRACT_INDEX, 0, 0, 0); } + + // Computes remaining contract balance after winner/team/distribution/burn payouts + // Distribution is floored to a multiple of NUMBER_OF_COMPUTORS + uint64 expectedRemainingAfterPayout(uint64 before, const RL::GetFees_output& fees) + { + const uint64 burn = (before * fees.burnPercent) / 100; + const uint64 distribPer = ((before * fees.distributionFeePercent) / 100) / NUMBER_OF_COMPUTORS; + const uint64 distrib = distribPer * NUMBER_OF_COMPUTORS; // floor to a multiple + const uint64 team = (before * fees.teamFeePercent) / 100; + const uint64 winner = (before * fees.winnerFeePercent) / 100; + return before - burn - distrib - team - winner; + } + + // Fund user and buy a ticket, asserting success + void increaseAndBuy(ContractTestingRL& ctl, const id& user, uint64 ticketPrice) + { + increaseEnergy(user, ticketPrice * 2); + const RL::BuyTicket_output out = ctl.buyTicket(user, ticketPrice); + EXPECT_EQ(out.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + } + + // Assert contract account balance equals the value returned by RL::GetBalance + void expectContractBalanceEqualsGetBalance(ContractTestingRL& ctl, const id& contractAddress) + { + const RL::GetBalance_output out = ctl.getBalanceInfo(); + EXPECT_EQ(out.balance, getBalance(contractAddress)); + } }; TEST(ContractRandomLottery, GetFees) { ContractTestingRL ctl; RL::GetFees_output output = ctl.getFees(); - ctl.getState()->checkFees(output); + ctl.state()->checkFees(output); } TEST(ContractRandomLottery, GetPlayers) @@ -161,13 +225,13 @@ TEST(ContractRandomLottery, GetPlayers) // Initially empty RL::GetPlayers_output output = ctl.getPlayers(); - ctl.getState()->checkPlayers(output); + ctl.state()->checkPlayers(output); // Add random players directly to state (test helper) constexpr uint64 maxPlayersToAdd = 10; - ctl.getState()->randomlyAddPlayers(maxPlayersToAdd); + ctl.state()->randomlyAddPlayers(maxPlayersToAdd); output = ctl.getPlayers(); - ctl.getState()->checkPlayers(output); + ctl.state()->checkPlayers(output); } TEST(ContractRandomLottery, GetWinners) @@ -176,16 +240,16 @@ TEST(ContractRandomLottery, GetWinners) // Populate winners history artificially constexpr uint64 maxNewWinners = 10; - ctl.getState()->randomlyAddWinners(maxNewWinners); + ctl.state()->randomlyAddWinners(maxNewWinners); RL::GetWinners_output winnersOutput = ctl.getWinners(); - ctl.getState()->checkWinners(winnersOutput); + ctl.state()->checkWinners(winnersOutput); } TEST(ContractRandomLottery, BuyTicket) { ContractTestingRL ctl; - const uint64 ticketPrice = ctl.getState()->getTicketPrice(); + const uint64 ticketPrice = ctl.state()->getTicketPrice(); // 1. Attempt when state is LOCKED (should fail and refund invocation reward) { @@ -193,11 +257,11 @@ TEST(ContractRandomLottery, BuyTicket) increaseEnergy(userLocked, ticketPrice * 2); RL::BuyTicket_output out = ctl.buyTicket(userLocked, ticketPrice); EXPECT_EQ(out.returnCode, static_cast(RL::EReturnCode::TICKET_SELLING_CLOSED)); - EXPECT_EQ(ctl.getState()->playersPopulation(), 0); + EXPECT_EQ(ctl.state()->getPlayerCounter(), 0); } // Switch to SELLING to allow purchases - ctl.getState()->setSelling(); + ctl.BeginEpoch(); // 2. Loop over several users and test invalid price, success, duplicate constexpr uint64 userCount = 5; @@ -212,7 +276,7 @@ TEST(ContractRandomLottery, BuyTicket) { const RL::BuyTicket_output outInvalid = ctl.buyTicket(user, ticketPrice - 1); EXPECT_EQ(outInvalid.returnCode, static_cast(RL::EReturnCode::TICKET_INVALID_PRICE)); - EXPECT_EQ(ctl.getState()->playersPopulation(), expectedPlayers); + EXPECT_EQ(ctl.state()->getPlayerCounter(), expectedPlayers); } // (b) Valid purchase — player added @@ -220,28 +284,28 @@ TEST(ContractRandomLottery, BuyTicket) const RL::BuyTicket_output outOk = ctl.buyTicket(user, ticketPrice); EXPECT_EQ(outOk.returnCode, static_cast(RL::EReturnCode::SUCCESS)); ++expectedPlayers; - EXPECT_EQ(ctl.getState()->playersPopulation(), expectedPlayers); + EXPECT_EQ(ctl.state()->getPlayerCounter(), expectedPlayers); } - // (c) Duplicate purchase — rejected + // (c) Duplicate purchase — allowed, increases count { const RL::BuyTicket_output outDup = ctl.buyTicket(user, ticketPrice); - EXPECT_EQ(outDup.returnCode, static_cast(RL::EReturnCode::TICKET_ALREADY_PURCHASED)); - EXPECT_EQ(ctl.getState()->playersPopulation(), expectedPlayers); + EXPECT_EQ(outDup.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + ++expectedPlayers; + EXPECT_EQ(ctl.state()->getPlayerCounter(), expectedPlayers); } } - // 3. Sanity check: number of unique players matches expectations - EXPECT_EQ(expectedPlayers, userCount); + // 3. Sanity check: number of tickets equals twice the number of users (due to duplicate buys) + EXPECT_EQ(expectedPlayers, userCount * 2); } TEST(ContractRandomLottery, EndEpoch) { ContractTestingRL ctl; - // Helper: contract balance holder (SELF account) - const id contractAddress = id(RL_CONTRACT_INDEX, 0, 0, 0); - const uint64 ticketPrice = ctl.getState()->getTicketPrice(); + const id contractAddress = ctl.rlSelf(); + const uint64 ticketPrice = ctl.state()->getTicketPrice(); // Current fee configuration (set in INITIALIZE) const RL::GetFees_output fees = ctl.getFees(); @@ -253,16 +317,16 @@ TEST(ContractRandomLottery, EndEpoch) // --- Scenario 1: No players (should just lock and clear silently) --- { ctl.BeginEpoch(); - EXPECT_EQ(ctl.getState()->playersPopulation(), 0u); + EXPECT_EQ(ctl.state()->getPlayerCounter(), 0u); RL::GetWinners_output before = ctl.getWinners(); - EXPECT_EQ(before.numberOfWinners, 0u); + EXPECT_EQ(before.winnersCounter, 0u); ctl.EndEpoch(); RL::GetWinners_output after = ctl.getWinners(); - EXPECT_EQ(after.numberOfWinners, 0u); - EXPECT_EQ(ctl.getState()->playersPopulation(), 0u); + EXPECT_EQ(after.winnersCounter, 0u); + EXPECT_EQ(ctl.state()->getPlayerCounter(), 0u); } // --- Scenario 2: Exactly one player (ticket refunded, no winner recorded) --- @@ -275,17 +339,17 @@ TEST(ContractRandomLottery, EndEpoch) const RL::BuyTicket_output out = ctl.buyTicket(solo, ticketPrice); EXPECT_EQ(out.returnCode, static_cast(RL::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.getState()->playersPopulation(), 1u); + EXPECT_EQ(ctl.state()->getPlayerCounter(), 1u); EXPECT_EQ(getBalance(solo), balanceBefore - ticketPrice); ctl.EndEpoch(); // Refund happened EXPECT_EQ(getBalance(solo), balanceBefore); - EXPECT_EQ(ctl.getState()->playersPopulation(), 0u); + EXPECT_EQ(ctl.state()->getPlayerCounter(), 0u); const RL::GetWinners_output winners = ctl.getWinners(); - EXPECT_EQ(winners.numberOfWinners, 0u); + EXPECT_EQ(winners.winnersCounter, 0u); } // --- Scenario 3: Multiple players (winner chosen, fees processed, remainder burned) --- @@ -306,15 +370,12 @@ TEST(ContractRandomLottery, EndEpoch) for (uint32 i = 0; i < N; ++i) { const id randomUser = id::randomValue(); - increaseEnergy(randomUser, ticketPrice * 2); + ctl.increaseAndBuy(ctl, randomUser, ticketPrice); const uint64 bBefore = getBalance(randomUser); - const RL::BuyTicket_output out = ctl.buyTicket(randomUser, ticketPrice); - EXPECT_EQ(out.returnCode, static_cast(RL::EReturnCode::SUCCESS)); - EXPECT_EQ(getBalance(randomUser), bBefore - ticketPrice); - infos.push_back({randomUser, bBefore, bBefore - ticketPrice}); + infos.push_back({randomUser, bBefore + ticketPrice, bBefore}); // account for ticket deduction } - EXPECT_EQ(ctl.getState()->playersPopulation(), N); + EXPECT_EQ(ctl.state()->getPlayerCounter(), N); const uint64 contractBalanceBefore = getBalance(contractAddress); EXPECT_EQ(contractBalanceBefore, ticketPrice * N); @@ -322,15 +383,15 @@ TEST(ContractRandomLottery, EndEpoch) const uint64 teamBalanceBefore = getBalance(RL_DEV_ADDRESS); const RL::GetWinners_output winnersBefore = ctl.getWinners(); - const uint64 winnersCountBefore = winnersBefore.numberOfWinners; + const uint64 winnersCountBefore = winnersBefore.winnersCounter; ctl.EndEpoch(); // Players reset after epoch end - EXPECT_EQ(ctl.getState()->playersPopulation(), 0u); + EXPECT_EQ(ctl.state()->getPlayerCounter(), 0u); const RL::GetWinners_output winnersAfter = ctl.getWinners(); - EXPECT_EQ(winnersAfter.numberOfWinners, winnersCountBefore + 1); + EXPECT_EQ(winnersAfter.winnersCounter, winnersCountBefore + 1); // Newly appended winner info const RL::WinnerInfo wi = winnersAfter.winners.get(winnersCountBefore); @@ -361,10 +422,96 @@ TEST(ContractRandomLottery, EndEpoch) const uint64 teamFeeExpected = (ticketPrice * N * teamPercent) / 100; EXPECT_EQ(getBalance(RL_DEV_ADDRESS), teamBalanceBefore + teamFeeExpected); - // Burn - const uint64 burnExpected = contractBalanceBefore - ((contractBalanceBefore * burnPercent) / 100) - - ((((contractBalanceBefore * distributionPercent) / 100) / NUMBER_OF_COMPUTORS) * NUMBER_OF_COMPUTORS) - - ((contractBalanceBefore * teamPercent) / 100) - ((contractBalanceBefore * winnerPercent) / 100); + // Burn (remaining on contract) + const uint64 burnExpected = ctl.expectedRemainingAfterPayout(contractBalanceBefore, fees); EXPECT_EQ(getBalance(contractAddress), burnExpected); } } + +TEST(ContractRandomLottery, GetBalance) +{ + ContractTestingRL ctl; + + const id contractAddress = ctl.rlSelf(); + const uint64 ticketPrice = ctl.state()->getTicketPrice(); + + // Initially, contract balance is 0 + { + const RL::GetBalance_output out0 = ctl.getBalanceInfo(); + EXPECT_EQ(out0.balance, 0u); + EXPECT_EQ(out0.balance, getBalance(contractAddress)); + } + + // Open selling and perform several purchases + ctl.BeginEpoch(); + + constexpr uint32 K = 3; + for (uint32 i = 0; i < K; ++i) + { + const id user = id::randomValue(); + ctl.increaseAndBuy(ctl, user, ticketPrice); + ctl.expectContractBalanceEqualsGetBalance(ctl, contractAddress); + } + + // Before ending the epoch, balance equals the total cost of tickets + { + const RL::GetBalance_output outBefore = ctl.getBalanceInfo(); + EXPECT_EQ(outBefore.balance, ticketPrice * K); + } + + // End epoch and verify expected remaining amount against contract balance and function output + const uint64 contractBalanceBefore = getBalance(contractAddress); + const RL::GetFees_output fees = ctl.getFees(); + + ctl.EndEpoch(); + + const RL::GetBalance_output outAfter = ctl.getBalanceInfo(); + const uint64 envAfter = getBalance(contractAddress); + EXPECT_EQ(outAfter.balance, envAfter); + + const uint64 expectedRemaining = ctl.expectedRemainingAfterPayout(contractBalanceBefore, fees); + EXPECT_EQ(outAfter.balance, expectedRemaining); +} + +TEST(ContractRandomLottery, GetTicketPrice) +{ + ContractTestingRL ctl; + + const RL::GetTicketPrice_output out = ctl.getTicketPrice(); + EXPECT_EQ(out.ticketPrice, ctl.state()->getTicketPrice()); +} + +TEST(ContractRandomLottery, GetMaxNumberOfPlayers) +{ + ContractTestingRL ctl; + + const RL::GetMaxNumberOfPlayers_output out = ctl.getMaxNumberOfPlayers(); + // Compare against the players array capacity, fetched via GetPlayers + const RL::GetPlayers_output playersOut = ctl.getPlayers(); + EXPECT_EQ(static_cast(out.numberOfPlayers), static_cast(playersOut.players.capacity())); +} + +TEST(ContractRandomLottery, GetState) +{ + ContractTestingRL ctl; + + // Initially LOCKED + { + const RL::GetState_output out0 = ctl.getStateInfo(); + EXPECT_EQ(out0.currentState, static_cast(RL::EState::LOCKED)); + } + + // After BeginEpoch — SELLING + ctl.BeginEpoch(); + { + const RL::GetState_output out1 = ctl.getStateInfo(); + EXPECT_EQ(out1.currentState, static_cast(RL::EState::SELLING)); + } + + // After EndEpoch — back to LOCKED + ctl.EndEpoch(); + { + const RL::GetState_output out2 = ctl.getStateInfo(); + EXPECT_EQ(out2.currentState, static_cast(RL::EState::LOCKED)); + } +} From 1f9e39dffa4102d9db3ca9040d992887dc3371ac Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 23 Oct 2025 12:13:29 +0300 Subject: [PATCH 05/22] Fix ticket purchase validation and adjust player count expectations --- src/contracts/RandomLottery.h | 2 +- test/contract_rl.cpp | 27 +++++++++++++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index cf56299ef..e614e6928 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -417,7 +417,7 @@ struct RL : public ContractBase } // Price mismatch (validate before any state mutation) - if (qpi.invocationReward() != state.ticketPrice && qpi.invocationReward() > 0) + if (qpi.invocationReward() != state.ticketPrice) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); diff --git a/test/contract_rl.cpp b/test/contract_rl.cpp index 30e263656..63aac8774 100644 --- a/test/contract_rl.cpp +++ b/test/contract_rl.cpp @@ -173,7 +173,10 @@ class ContractTestingRL : protected ContractTesting { RL::BuyTicket_input input; RL::BuyTicket_output output; - invokeUserProcedure(RL_CONTRACT_INDEX, PROCEDURE_INDEX_BUY_TICKET, input, output, user, reward); + if (!invokeUserProcedure(RL_CONTRACT_INDEX, PROCEDURE_INDEX_BUY_TICKET, input, output, user, reward)) + { + output.returnCode = static_cast(RL::EReturnCode::UNKNOW_ERROR); + } return output; } @@ -274,9 +277,20 @@ TEST(ContractRandomLottery, BuyTicket) // (a) Invalid price (wrong reward sent) — player not added { - const RL::BuyTicket_output outInvalid = ctl.buyTicket(user, ticketPrice - 1); + // < ticketPrice + RL::BuyTicket_output outInvalid = ctl.buyTicket(user, ticketPrice - 1); + EXPECT_EQ(outInvalid.returnCode, static_cast(RL::EReturnCode::TICKET_INVALID_PRICE)); + EXPECT_EQ(ctl.state()->getPlayerCounter(), expectedPlayers); + + // == 0 + outInvalid = ctl.buyTicket(user, 0); EXPECT_EQ(outInvalid.returnCode, static_cast(RL::EReturnCode::TICKET_INVALID_PRICE)); EXPECT_EQ(ctl.state()->getPlayerCounter(), expectedPlayers); + + // < 0 + outInvalid = ctl.buyTicket(user, -ticketPrice); + EXPECT_NE(outInvalid.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getPlayerCounter(), expectedPlayers); } // (b) Valid purchase — player added @@ -297,7 +311,7 @@ TEST(ContractRandomLottery, BuyTicket) } // 3. Sanity check: number of tickets equals twice the number of users (due to duplicate buys) - EXPECT_EQ(expectedPlayers, userCount * 2); + EXPECT_EQ(ctl.state()->getPlayerCounter(), userCount * 2); } TEST(ContractRandomLottery, EndEpoch) @@ -356,7 +370,7 @@ TEST(ContractRandomLottery, EndEpoch) { ctl.BeginEpoch(); - constexpr uint32 N = 5; + constexpr uint32 N = 5 * 2; struct PlayerInfo { id addr; @@ -367,12 +381,13 @@ TEST(ContractRandomLottery, EndEpoch) infos.reserve(N); // Add N distinct players with valid purchases - for (uint32 i = 0; i < N; ++i) + for (uint32 i = 0; i < N; i+=2) { const id randomUser = id::randomValue(); ctl.increaseAndBuy(ctl, randomUser, ticketPrice); + ctl.increaseAndBuy(ctl, randomUser, ticketPrice); const uint64 bBefore = getBalance(randomUser); - infos.push_back({randomUser, bBefore + ticketPrice, bBefore}); // account for ticket deduction + infos.push_back({randomUser, bBefore + (ticketPrice * 2), bBefore}); // account for ticket deduction } EXPECT_EQ(ctl.state()->getPlayerCounter(), N); From a3cda953040fb40842a3698f6473353b5c057636 Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 23 Oct 2025 13:14:32 +0300 Subject: [PATCH 06/22] Refactor code formatting and add tests for multiple consecutive epochs in lottery contract --- src/contracts/RandomLottery.h | 2 +- test/contract_rl.cpp | 93 +++++++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index e614e6928..05d39e4be 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -72,7 +72,7 @@ struct RL : public ContractBase // Fee-related errors FEE_INVALID_PERCENT_VALUE = 5, // Fallback - UNKNOW_ERROR = UINT8_MAX + UNKNOWN_ERROR = UINT8_MAX }; //---- User-facing I/O structures ------------------------------------------------------------- diff --git a/test/contract_rl.cpp b/test/contract_rl.cpp index 63aac8774..00dbea2d2 100644 --- a/test/contract_rl.cpp +++ b/test/contract_rl.cpp @@ -13,7 +13,7 @@ constexpr uint16 FUNCTION_INDEX_GET_STATE = 6; constexpr uint16 FUNCTION_INDEX_GET_BALANCE = 7; static const id RL_DEV_ADDRESS = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, - _R, _L, _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); + _R, _L, _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); // Equality operator for comparing WinnerInfo objects bool operator==(const RL::WinnerInfo& left, const RL::WinnerInfo& right) @@ -175,7 +175,7 @@ class ContractTestingRL : protected ContractTesting RL::BuyTicket_output output; if (!invokeUserProcedure(RL_CONTRACT_INDEX, PROCEDURE_INDEX_BUY_TICKET, input, output, user, reward)) { - output.returnCode = static_cast(RL::EReturnCode::UNKNOW_ERROR); + output.returnCode = static_cast(RL::EReturnCode::UNKNOWN_ERROR); } return output; } @@ -380,8 +380,8 @@ TEST(ContractRandomLottery, EndEpoch) std::vector infos; infos.reserve(N); - // Add N distinct players with valid purchases - for (uint32 i = 0; i < N; i+=2) + // Add N/2 distinct players, each making two valid purchases + for (uint32 i = 0; i < N; i += 2) { const id randomUser = id::randomValue(); ctl.increaseAndBuy(ctl, randomUser, ticketPrice); @@ -441,6 +441,91 @@ TEST(ContractRandomLottery, EndEpoch) const uint64 burnExpected = ctl.expectedRemainingAfterPayout(contractBalanceBefore, fees); EXPECT_EQ(getBalance(contractAddress), burnExpected); } + + // --- Scenario 4: Several consecutive epochs (winners accumulate, balances consistent) --- + { + const uint32 rounds = 3; + const uint32 playersPerRound = 6 * 2; // even number to mimic duplicates if desired + + // Remember starting winners count and team balance + const uint64 winnersStart = ctl.getWinners().winnersCounter; + const uint64 teamStartBal = getBalance(RL_DEV_ADDRESS); + + uint64 teamAccrued = 0; + + for (uint32 r = 0; r < rounds; ++r) + { + ctl.BeginEpoch(); + + struct P + { + id addr; + uint64 balAfterBuy; + }; + std::vector

roundPlayers; + roundPlayers.reserve(playersPerRound); + + // Each player buys two tickets in this round + for (uint32 i = 0; i < playersPerRound; i += 2) + { + const id u = id::randomValue(); + ctl.increaseAndBuy(ctl, u, ticketPrice); + ctl.increaseAndBuy(ctl, u, ticketPrice); + const uint64 balAfter = getBalance(u); + roundPlayers.push_back({u, balAfter}); + } + + EXPECT_EQ(ctl.state()->getPlayerCounter(), playersPerRound); + + const uint64 winnersBefore = ctl.getWinners().winnersCounter; + const uint64 contractBefore = getBalance(contractAddress); + const uint64 teamBalBeforeRound = getBalance(RL_DEV_ADDRESS); + + ctl.EndEpoch(); + + // Winners should increase by exactly one + const RL::GetWinners_output wOut = ctl.getWinners(); + EXPECT_EQ(wOut.winnersCounter, winnersBefore + 1); + + // Validate winner entry + const RL::WinnerInfo newWi = wOut.winners.get(winnersBefore); + EXPECT_NE(newWi.winnerAddress, id::zero()); + EXPECT_EQ(newWi.revenue, (contractBefore * winnerPercent) / 100); + + // Winner must be one of the current round players + bool inRound = false; + for (const auto& p : roundPlayers) + { + if (p.addr == newWi.winnerAddress) + { + inRound = true; + break; + } + } + EXPECT_TRUE(inRound); + + // Check players' balances after payout + for (const auto& p : roundPlayers) + { + const uint64 b = getBalance(p.addr); + const uint64 expected = (p.addr == newWi.winnerAddress) ? (p.balAfterBuy + newWi.revenue) : p.balAfterBuy; + EXPECT_EQ(b, expected); + } + + // Team fee for the whole contract balance of the round + const uint64 teamFee = (contractBefore * teamPercent) / 100; + teamAccrued += teamFee; + EXPECT_EQ(getBalance(RL_DEV_ADDRESS), teamBalBeforeRound + teamFee); + + // Contract remaining should match expected + const uint64 expectedRemaining = ctl.expectedRemainingAfterPayout(contractBefore, fees); + EXPECT_EQ(getBalance(contractAddress), expectedRemaining); + } + + // After all rounds winners increased by rounds and team received cumulative fees + EXPECT_EQ(ctl.getWinners().winnersCounter, winnersStart + rounds); + EXPECT_EQ(getBalance(RL_DEV_ADDRESS), teamStartBal + teamAccrued); + } } TEST(ContractRandomLottery, GetBalance) From 0727963d4f531597ad39b7d9f9f677fedef0c87c Mon Sep 17 00:00:00 2001 From: N-010 Date: Sun, 26 Oct 2025 21:17:24 +0300 Subject: [PATCH 07/22] Add SetPrice procedure and corresponding tests for ticket price management --- src/contracts/RandomLottery.h | 279 ++++++++++++++++++++++++---------- test/contract_rl.cpp | 235 ++++++++++++++++++++++++++++ 2 files changed, 434 insertions(+), 80 deletions(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index 05d39e4be..df325100f 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -1,4 +1,5 @@ -/** +#pragma once +/** * @file RandomLottery.h * @brief Random Lottery contract definition: state, data structures, and user / internal * procedures. @@ -8,22 +9,31 @@ * - Draws a pseudo-random winner when the epoch ends. * - Distributes fees (team, distribution, burn, winner). * - Records winners' history in a ring-like buffer. + * + * Notes: + * - Percentages must sum to <= 100; the remainder goes to the winner. + * - Players array stores one entry per ticket, so a single address can appear multiple times. + * - When only one player bought a ticket in the epoch, funds are refunded instead of drawing. */ using namespace QPI; -/// Maximum number of players allowed in the lottery. +/// Maximum number of players allowed in the lottery for a single epoch (one entry == one ticket). constexpr uint16 RL_MAX_NUMBER_OF_PLAYERS = 1024; -/// Maximum number of winners kept in the on-chain winners history buffer. +/// Maximum number of winners stored in the on-chain winners history ring buffer. constexpr uint16 RL_MAX_NUMBER_OF_WINNERS_IN_HISTORY = 1024; +/// Default ticket price (denominated in the smallest currency unit). constexpr uint64 RL_TICKET_PRICE = 1000000; +/// Team fee percent of epoch revenue (0..100). constexpr uint8 RL_TEAM_FEE_PERCENT = 10; +/// Distribution (shareholders/validators) fee percent of epoch revenue (0..100). constexpr uint8 RL_SHAREHOLDER_FEE_PERCENT = 20; +/// Burn percent of epoch revenue (0..100). constexpr uint8 RL_BURN_PERCENT = 2; /// Placeholder structure for future extensions. @@ -51,8 +61,8 @@ struct RL : public ContractBase */ enum class EState : uint8 { - SELLING, - LOCKED, + SELLING, // Ticket selling is open + LOCKED, // Ticket selling is closed INVALID = 255 }; @@ -64,17 +74,20 @@ struct RL : public ContractBase { SUCCESS = 0, // Ticket-related errors - TICKET_INVALID_PRICE = 1, - TICKET_ALL_SOLD_OUT = 2, - TICKET_SELLING_CLOSED = 3, + TICKET_INVALID_PRICE = 1, // Not enough funds to buy at least one ticket / price mismatch + TICKET_ALL_SOLD_OUT = 2, // No free slots left in players array + TICKET_SELLING_CLOSED = 3, // Attempted to buy while state is LOCKED // Access-related errors - ACCESS_DENIED = 4, - // Fee-related errors - FEE_INVALID_PERCENT_VALUE = 5, - // Fallback + ACCESS_DENIED = 4, // Caller is not authorized to perform the action + UNKNOWN_ERROR = UINT8_MAX }; + struct NextEpochData + { + uint64 newPrice = 0; // Ticket price to apply after END_EPOCH; 0 means "no change queued" + }; + //---- User-facing I/O structures ------------------------------------------------------------- struct BuyTicket_input @@ -92,10 +105,10 @@ struct RL : public ContractBase struct GetFees_output { - uint8 teamFeePercent = 0; - uint8 distributionFeePercent = 0; - uint8 winnerFeePercent = 0; - uint8 burnPercent = 0; + uint8 teamFeePercent = 0; // Team share in percent + uint8 distributionFeePercent = 0; // Distribution/shareholders share in percent + uint8 winnerFeePercent = 0; // Winner share in percent + uint8 burnPercent = 0; // Burn share in percent uint8 returnCode = static_cast(EReturnCode::SUCCESS); }; @@ -105,8 +118,8 @@ struct RL : public ContractBase struct GetPlayers_output { - Array players; - uint64 playerCounter = 0; + Array players; // Current epoch ticket holders (duplicates allowed) + uint64 playerCounter = 0; // Actual count of filled entries uint8 returnCode = static_cast(EReturnCode::SUCCESS); }; @@ -115,16 +128,16 @@ struct RL : public ContractBase */ struct WinnerInfo { - id winnerAddress = id::zero(); - uint64 revenue = 0; - uint16 epoch = 0; - uint32 tick = 0; + id winnerAddress = id::zero(); // Winner address + uint64 revenue = 0; // Payout value sent to the winner for that epoch + uint16 epoch = 0; // Epoch number when winner was recorded + uint32 tick = 0; // Tick when the decision was made }; struct FillWinnersInfo_input { - id winnerAddress = id::zero(); - uint64 revenue = 0; + id winnerAddress = id::zero(); // Winner address to store + uint64 revenue = 0; // Winner payout to store }; struct FillWinnersInfo_output @@ -133,7 +146,7 @@ struct RL : public ContractBase struct FillWinnersInfo_locals { - WinnerInfo winnerInfo = {}; + WinnerInfo winnerInfo = {}; // Temporary buffer to compose a WinnerInfo record }; struct GetWinner_input @@ -142,15 +155,15 @@ struct RL : public ContractBase struct GetWinner_output { - id winnerAddress = id::zero(); - uint64 index = 0; + id winnerAddress = id::zero(); // Selected winner address (id::zero if none) + uint64 index = 0; // Index into players array }; struct GetWinner_locals { - uint64 randomNum = 0; - sint64 i = 0; - uint64 j = 0; + uint64 randomNum = 0; // Random index candidate in [0, playerCounter) + sint64 i = 0; // Reserved for future iteration logic + uint64 j = 0; // Reserved for future iteration logic }; struct GetWinners_input @@ -159,8 +172,8 @@ struct RL : public ContractBase struct GetWinners_output { - Array winners; - uint64 winnersCounter = 0; + Array winners; // Ring buffer snapshot + uint64 winnersCounter = 0; // Number of valid entries (bounded by capacity) uint8 returnCode = static_cast(EReturnCode::SUCCESS); }; @@ -170,7 +183,7 @@ struct RL : public ContractBase struct GetTicketPrice_output { - uint64 ticketPrice = 0; + uint64 ticketPrice = 0; // Current ticket price }; struct GetMaxNumberOfPlayers_input @@ -179,7 +192,7 @@ struct RL : public ContractBase struct GetMaxNumberOfPlayers_output { - uint64 numberOfPlayers = 0; + uint64 numberOfPlayers = 0; // Max capacity of players array }; struct GetState_input @@ -188,7 +201,7 @@ struct RL : public ContractBase struct GetState_output { - uint8 currentState = static_cast(EState::INVALID); + uint8 currentState = static_cast(EState::INVALID); // Current finite state of the lottery }; struct GetBalance_input @@ -197,12 +210,28 @@ struct RL : public ContractBase struct GetBalance_output { - uint64 balance = 0; + uint64 balance = 0; // Net balance (incoming - outgoing) for current epoch }; + // Local variables for GetBalance procedure struct GetBalance_locals { - Entity entity = {}; + Entity entity = {}; // Entity accounting snapshot for SELF + }; + + // Local variables for BuyTicket procedure + struct BuyTicket_locals + { + uint64 price = 0; // Current ticket price + uint64 reward = 0; // Funds sent with call (invocationReward) + uint64 capacity = 0; // Max capacity of players array + uint64 slotsLeft = 0; // Remaining slots available to fill this epoch + uint64 desired = 0; // How many tickets the caller wants to buy + uint64 remainder = 0; // Change to return (reward % price) + uint64 toBuy = 0; // Actual number of tickets to purchase (bounded by slotsLeft) + uint64 unfilled = 0; // Portion of desired tickets not purchased due to capacity limit + uint64 refundAmount = 0; // Total refund: remainder + unfilled * price + uint64 i = 0; // Loop counter }; struct ReturnAllTickets_input @@ -214,7 +243,7 @@ struct RL : public ContractBase struct ReturnAllTickets_locals { - sint64 i = 0; + uint64 i = 0; // Loop counter for mass-refund }; struct END_EPOCH_locals @@ -231,15 +260,25 @@ struct RL : public ContractBase ReturnAllTickets_output returnAllTicketsOutput = {}; ReturnAllTickets_locals returnAllTicketsLocals = {}; - uint64 teamFee = 0; - uint64 distributionFee = 0; - uint64 winnerAmount = 0; - uint64 burnedAmount = 0; + uint64 teamFee = 0; // Team payout portion + uint64 distributionFee = 0; // Distribution/shared payout portion + uint64 winnerAmount = 0; // Winner payout portion + uint64 burnedAmount = 0; // Burn portion + + uint64 revenue = 0; // Epoch revenue = incoming - outgoing + Entity entity = {}; // Accounting snapshot - uint64 revenue = 0; - Entity entity = {}; + sint32 i = 0; // Reserved + }; - sint32 i = 0; + struct SetPrice_input + { + uint64 newPrice = 0; // New ticket price to be applied at the end of the epoch + }; + + struct SetPrice_output + { + uint8 returnCode = static_cast(EReturnCode::SUCCESS); }; public: @@ -257,6 +296,7 @@ struct RL : public ContractBase REGISTER_USER_FUNCTION(GetState, 6); REGISTER_USER_FUNCTION(GetBalance, 7); REGISTER_USER_PROCEDURE(BuyTicket, 1); + REGISTER_USER_PROCEDURE(SetPrice, 2); } /** @@ -265,25 +305,24 @@ struct RL : public ContractBase */ INITIALIZE() { - // Addresses + // Set team/developer address (owner and team are the same for now) state.teamAddress = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); - // Owner address (currently identical to developer address; can be split in future revisions). state.ownerAddress = state.teamAddress; - // Default fee percentages (sum <= 100; winner percent derived) + // Fee configuration (winner gets the remainder) state.teamFeePercent = RL_TEAM_FEE_PERCENT; state.distributionFeePercent = RL_SHAREHOLDER_FEE_PERCENT; state.burnPercent = RL_BURN_PERCENT; state.winnerFeePercent = 100 - state.teamFeePercent - state.distributionFeePercent - state.burnPercent; - // Default ticket price + // Initial ticket price state.ticketPrice = RL_TICKET_PRICE; - // Start locked + // Start in LOCKED state; selling must be explicitly opened with BEGIN_EPOCH state.currentState = EState::LOCKED; - // Initialize Player index + // Reset player counter state.playerCounter = 0; } @@ -295,6 +334,13 @@ struct RL : public ContractBase /** * @brief Closes epoch: computes revenue, selects winner (if >1 player), * distributes fees, burns leftover, records winner, then clears players. + * + * Behavior: + * - If exactly 1 player, refund ticket price (no draw). + * - If >1 players, compute revenue, select winner with K12-based randomness, + * split revenue into winner/team/distribution/burn, perform transfers/burn, + * and store winner snapshot in the ring buffer. + * - Apply deferred price change for the next epoch if queued. */ END_EPOCH_WITH_LOCALS() { @@ -307,27 +353,28 @@ struct RL : public ContractBase } else if (state.playerCounter > 1) { + // Epoch revenue = incoming - outgoing for this contract qpi.getEntity(SELF, locals.entity); locals.revenue = locals.entity.incomingAmount - locals.entity.outgoingAmount; - // Winner selection (pseudo-random). + // Winner selection (pseudo-random using K12(prevSpectrumDigest)). GetWinner(qpi, state, locals.getWinnerInput, locals.getWinnerOutput, locals.getWinnerLocals); if (locals.getWinnerOutput.winnerAddress != id::zero()) { - // Fee splits + // Split revenue by configured percentages locals.winnerAmount = div(locals.revenue * state.winnerFeePercent, 100ULL); locals.teamFee = div(locals.revenue * state.teamFeePercent, 100ULL); locals.distributionFee = div(locals.revenue * state.distributionFeePercent, 100ULL); locals.burnedAmount = div(locals.revenue * state.burnPercent, 100ULL); - // Team fee + // Team payout if (locals.teamFee > 0) { qpi.transfer(state.teamAddress, locals.teamFee); } - // Distribution fee + // Distribution payout (equal per validator) if (locals.distributionFee > 0) { qpi.distributeDividends(div(locals.distributionFee, uint64(NUMBER_OF_COMPUTORS))); @@ -339,26 +386,32 @@ struct RL : public ContractBase qpi.transfer(locals.getWinnerOutput.winnerAddress, locals.winnerAmount); } - // Burn remainder + // Burn configured portion if (locals.burnedAmount > 0) { qpi.burn(locals.burnedAmount); } - // Persist winner record + // Store winner snapshot into history (ring buffer) locals.fillWinnersInfoInput.winnerAddress = locals.getWinnerOutput.winnerAddress; locals.fillWinnersInfoInput.revenue = locals.winnerAmount; FillWinnersInfo(qpi, state, locals.fillWinnersInfoInput, locals.fillWinnersInfoOutput, locals.fillWinnersInfoLocals); } else { - // Return funds to players if no winner could be selected (should be impossible). + // Fallback: if winner couldn't be selected (should not happen), refund all tickets ReturnAllTickets(qpi, state, locals.returnAllTicketsInput, locals.returnAllTicketsOutput, locals.returnAllTicketsLocals); } } - // Prepare for next epoch. + // Prepare for next epoch: clear players and apply deferred price if any state.playerCounter = 0; + + if (state.nexEpochData.newPrice != 0) + { + state.ticketPrice = state.nexEpochData.newPrice; + state.nexEpochData.newPrice = 0; + } } /** @@ -395,53 +448,107 @@ struct RL : public ContractBase PUBLIC_FUNCTION(GetState) { output.currentState = static_cast(state.currentState); } PUBLIC_FUNCTION_WITH_LOCALS(GetBalance) { + // Returns balance for current epoch (incoming - outgoing) output.balance = qpi.getEntity(SELF, locals.entity) ? locals.entity.incomingAmount - locals.entity.outgoingAmount : 0; } + PUBLIC_PROCEDURE(SetPrice) + { + // Only team/owner can queue a price change + if (qpi.invocator() != state.teamAddress) + { + output.returnCode = static_cast(EReturnCode::ACCESS_DENIED); + return; + } + + // Zero price is invalid + if (input.newPrice == 0) + { + output.returnCode = static_cast(EReturnCode::TICKET_INVALID_PRICE); + return; + } + + // Defer application until END_EPOCH + state.nexEpochData.newPrice = input.newPrice; + output.returnCode = static_cast(EReturnCode::SUCCESS); + } + /** - * @brief Attempts to buy a ticket (must send exact price unless zero is forbidden; state must - * be SELLING). Reverts with proper return codes for invalid cases. + * @brief Attempts to buy tickets while SELLING state is active. + * Logic: + * - If locked: refund full invocationReward and return TICKET_SELLING_CLOSED. + * - If reward < price: refund full reward and return TICKET_INVALID_PRICE. + * - If no capacity left: refund full reward and return TICKET_ALL_SOLD_OUT. + * - Otherwise: add up to slotsLeft tickets; refund remainder and unfilled part. */ - PUBLIC_PROCEDURE(BuyTicket) + PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicket) { - // Selling closed + locals.reward = qpi.invocationReward(); + + // Selling closed: refund any attached funds and exit if (state.currentState == EState::LOCKED) { - if (qpi.invocationReward() > 0) + if (locals.reward > 0) { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); + qpi.transfer(qpi.invocator(), locals.reward); } output.returnCode = static_cast(EReturnCode::TICKET_SELLING_CLOSED); return; } - // Price mismatch (validate before any state mutation) - if (qpi.invocationReward() != state.ticketPrice) + locals.price = state.ticketPrice; + + // Not enough to buy even a single ticket: refund everything + if (locals.reward < locals.price) { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); + if (locals.reward > 0) + { + qpi.transfer(qpi.invocator(), locals.reward); + } output.returnCode = static_cast(EReturnCode::TICKET_INVALID_PRICE); return; } - // Capacity full - if (state.playerCounter >= state.players.capacity()) + // Capacity check + locals.capacity = state.players.capacity(); + locals.slotsLeft = (state.playerCounter < locals.capacity) ? (locals.capacity - state.playerCounter) : 0; + if (locals.slotsLeft == 0) { - output.returnCode = static_cast(EReturnCode::TICKET_ALL_SOLD_OUT); - if (qpi.invocationReward() > 0) + // All sold out: refund full amount + if (locals.reward > 0) { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); + qpi.transfer(qpi.invocator(), locals.reward); } + output.returnCode = static_cast(EReturnCode::TICKET_ALL_SOLD_OUT); return; } - // Protect against rewriting existing players (should not happen due to prior checks). - if (state.playerCounter < state.players.capacity()) + // Compute desired number of tickets and change + locals.desired = locals.reward / locals.price; // How many tickets the caller attempts to buy + locals.remainder = locals.reward % locals.price; // Change to return + locals.toBuy = min(locals.desired, locals.slotsLeft); // Do not exceed available slots + + // Add tickets (the same address may be inserted multiple times) + for (locals.i = 0; locals.i < locals.toBuy; ++locals.i) + { + if (state.playerCounter < locals.capacity) + { + state.players.set(state.playerCounter, qpi.invocator()); + state.playerCounter = min(state.playerCounter + 1, locals.capacity); + } + } + + // Refund change and unfilled portion (if desired > slotsLeft) + locals.unfilled = locals.desired - locals.toBuy; + locals.refundAmount = locals.remainder + (locals.unfilled * locals.price); + if (locals.refundAmount > 0) { - state.players.set(state.playerCounter, qpi.invocator()); - state.playerCounter = min(state.playerCounter + 1, state.players.capacity()); + qpi.transfer(qpi.invocator(), locals.refundAmount); } + + output.returnCode = static_cast(EReturnCode::SUCCESS); } private: @@ -452,16 +559,18 @@ struct RL : public ContractBase { if (input.winnerAddress == id::zero()) { - return; + return; // Nothing to store } + // Use ring-buffer indexing to avoid overflow (overwrite oldest entries) state.winnersCounter = mod(state.winnersCounter, state.winners.capacity()); locals.winnerInfo.winnerAddress = input.winnerAddress; locals.winnerInfo.revenue = input.revenue; locals.winnerInfo.epoch = qpi.epoch(); locals.winnerInfo.tick = qpi.tick(); - state.winners.set(state.winnersCounter++, locals.winnerInfo); + state.winners.set(state.winnersCounter, locals.winnerInfo); + ++state.winnersCounter; } /** @@ -474,15 +583,17 @@ struct RL : public ContractBase return; } + // Compute pseudo-random index based on K12(prevSpectrumDigest) locals.randomNum = mod(qpi.K12(qpi.getPrevSpectrumDigest()).u64._0, state.playerCounter); - // Direct indexing for Array + // Index directly into players array output.winnerAddress = state.players.get(locals.randomNum); output.index = locals.randomNum; } PRIVATE_PROCEDURE_WITH_LOCALS(ReturnAllTickets) { + // Refund ticket price to each recorded player (one transfer per ticket entry) for (locals.i = 0; locals.i < state.playerCounter; ++locals.i) { qpi.transfer(state.players.get(locals.i), state.ticketPrice); @@ -532,8 +643,16 @@ struct RL : public ContractBase */ uint64 ticketPrice = 0; + /** + * @brief Number of players (tickets sold) in the current epoch. + */ uint64 playerCounter = 0; + /** + * @brief Data structure for deferred changes to apply at the end of the epoch. + */ + NextEpochData nexEpochData = {}; + /** * @brief Set of players participating in the current lottery epoch. * Maximum capacity is defined by RL_MAX_NUMBER_OF_PLAYERS. diff --git a/test/contract_rl.cpp b/test/contract_rl.cpp index 00dbea2d2..6fd466e96 100644 --- a/test/contract_rl.cpp +++ b/test/contract_rl.cpp @@ -4,6 +4,7 @@ #include "contract_testing.h" constexpr uint16 PROCEDURE_INDEX_BUY_TICKET = 1; +constexpr uint16 PROCEDURE_INDEX_SET_PRICE = 2; constexpr uint16 FUNCTION_INDEX_GET_FEES = 1; constexpr uint16 FUNCTION_INDEX_GET_PLAYERS = 2; constexpr uint16 FUNCTION_INDEX_GET_WINNERS = 3; @@ -180,6 +181,19 @@ class ContractTestingRL : protected ContractTesting return output; } + // Added: wrapper for SetPrice procedure + RL::SetPrice_output setPrice(const id& invocator, uint64 newPrice) + { + RL::SetPrice_input input; + input.newPrice = newPrice; + RL::SetPrice_output output; + if (!invokeUserProcedure(RL_CONTRACT_INDEX, PROCEDURE_INDEX_SET_PRICE, input, output, invocator, 0)) + { + output.returnCode = static_cast(RL::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + void BeginEpoch() { callSystemProcedure(RL_CONTRACT_INDEX, BEGIN_EPOCH); } void EndEpoch() { callSystemProcedure(RL_CONTRACT_INDEX, END_EPOCH); } @@ -615,3 +629,224 @@ TEST(ContractRandomLottery, GetState) EXPECT_EQ(out2.currentState, static_cast(RL::EState::LOCKED)); } } + +// --- New tests for SetPrice --- + +TEST(ContractRandomLottery, SetPrice_AccessControl) +{ + ContractTestingRL ctl; + + const uint64 oldPrice = ctl.state()->getTicketPrice(); + const uint64 newPrice = oldPrice * 2; + + // Random user must not have permission + const id randomUser = id::randomValue(); + increaseEnergy(randomUser, 1); + + const RL::SetPrice_output outDenied = ctl.setPrice(randomUser, newPrice); + EXPECT_EQ(outDenied.returnCode, static_cast(RL::EReturnCode::ACCESS_DENIED)); + + // Price doesn't change immediately nor after EndEpoch + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); + ctl.EndEpoch(); + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); +} + +TEST(ContractRandomLottery, SetPrice_ZeroNotAllowed) +{ + ContractTestingRL ctl; + + increaseEnergy(RL_DEV_ADDRESS, 1); + + const uint64 oldPrice = ctl.state()->getTicketPrice(); + + const RL::SetPrice_output outInvalid = ctl.setPrice(RL_DEV_ADDRESS, 0); + EXPECT_EQ(outInvalid.returnCode, static_cast(RL::EReturnCode::TICKET_INVALID_PRICE)); + + // Price remains unchanged even after EndEpoch + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); + ctl.EndEpoch(); + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); +} + +TEST(ContractRandomLottery, SetPrice_AppliesAfterEndEpoch) +{ + ContractTestingRL ctl; + + increaseEnergy(RL_DEV_ADDRESS, 1); + + const uint64 oldPrice = ctl.state()->getTicketPrice(); + const uint64 newPrice = oldPrice * 2; + + const RL::SetPrice_output outOk = ctl.setPrice(RL_DEV_ADDRESS, newPrice); + EXPECT_EQ(outOk.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + + // Until EndEpoch the price remains unchanged + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); + + // Applied after EndEpoch + ctl.EndEpoch(); + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, newPrice); + + // Another EndEpoch without a new SetPrice doesn't change the price + ctl.EndEpoch(); + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, newPrice); +} + +TEST(ContractRandomLottery, SetPrice_OverrideBeforeEndEpoch) +{ + ContractTestingRL ctl; + + increaseEnergy(RL_DEV_ADDRESS, 1); + + const uint64 oldPrice = ctl.state()->getTicketPrice(); + const uint64 firstPrice = oldPrice + 1000; + const uint64 secondPrice = oldPrice + 7777; + + // Two SetPrice calls before EndEpoch — the last one should apply + EXPECT_EQ(ctl.setPrice(RL_DEV_ADDRESS, firstPrice).returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setPrice(RL_DEV_ADDRESS, secondPrice).returnCode, static_cast(RL::EReturnCode::SUCCESS)); + + // Until EndEpoch the old price remains + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); + + ctl.EndEpoch(); + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, secondPrice); +} + +TEST(ContractRandomLottery, SetPrice_AffectsNextEpochBuys) +{ + ContractTestingRL ctl; + + increaseEnergy(RL_DEV_ADDRESS, 1); + + const uint64 oldPrice = ctl.state()->getTicketPrice(); + const uint64 newPrice = oldPrice * 3; + + // Open selling and buy at the old price + ctl.BeginEpoch(); + const id u1 = id::randomValue(); + increaseEnergy(u1, oldPrice * 2); + { + const RL::BuyTicket_output out1 = ctl.buyTicket(u1, oldPrice); + EXPECT_EQ(out1.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + } + + // Set a new price, but before EndEpoch purchases should use the old price + { + const RL::SetPrice_output setOut = ctl.setPrice(RL_DEV_ADDRESS, newPrice); + EXPECT_EQ(setOut.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + } + + const id u2 = id::randomValue(); + increaseEnergy(u2, newPrice * 2); + { + const uint64 balBefore = getBalance(u2); + const uint64 playersBefore = ctl.state()->getPlayerCounter(); + const RL::BuyTicket_output outNow = ctl.buyTicket(u2, newPrice); + EXPECT_EQ(outNow.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + // floor(newPrice/oldPrice) tickets were bought, the remainder was refunded + const uint64 bought = newPrice / oldPrice; + EXPECT_EQ(ctl.state()->getPlayerCounter(), playersBefore + bought); + EXPECT_EQ(getBalance(u2), balBefore - bought * oldPrice); + } + + // End the epoch: new price will apply + ctl.EndEpoch(); + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, newPrice); + + // In the next epoch, a purchase at the new price should succeed + ctl.BeginEpoch(); + { + const uint64 balBefore = getBalance(u2); + const uint64 playersBefore = ctl.state()->getPlayerCounter(); + const RL::BuyTicket_output outOk = ctl.buyTicket(u2, newPrice); + EXPECT_EQ(outOk.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getPlayerCounter(), playersBefore + 1); + EXPECT_EQ(getBalance(u2), balBefore - newPrice); + } +} + +TEST(ContractRandomLottery, BuyMultipleTickets_ExactMultiple_NoRemainder) +{ + ContractTestingRL ctl; + ctl.BeginEpoch(); + const uint64 price = ctl.state()->getTicketPrice(); + const id user = id::randomValue(); + const uint64 k = 7; + increaseEnergy(user, price * k); + const uint64 playersBefore = ctl.state()->getPlayerCounter(); + const RL::BuyTicket_output out = ctl.buyTicket(user, price * k); + EXPECT_EQ(out.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getPlayerCounter(), playersBefore + k); +} + +TEST(ContractRandomLottery, BuyMultipleTickets_WithRemainder_Refunded) +{ + ContractTestingRL ctl; + ctl.BeginEpoch(); + const uint64 price = ctl.state()->getTicketPrice(); + const id user = id::randomValue(); + const uint64 k = 5; + const uint64 r = price / 3; // partial remainder + increaseEnergy(user, price * k + r); + const uint64 balBefore = getBalance(user); + const uint64 playersBefore = ctl.state()->getPlayerCounter(); + const RL::BuyTicket_output out = ctl.buyTicket(user, price * k + r); + EXPECT_EQ(out.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getPlayerCounter(), playersBefore + k); + // Remainder refunded, only k * price spent + EXPECT_EQ(getBalance(user), balBefore - k * price); +} + +TEST(ContractRandomLottery, BuyMultipleTickets_CapacityPartialRefund) +{ + ContractTestingRL ctl; + ctl.BeginEpoch(); + const uint64 price = ctl.state()->getTicketPrice(); + const uint64 capacity = ctl.getPlayers().players.capacity(); + + // Fill almost up to capacity + const uint64 toFill = (capacity > 5) ? (capacity - 5) : 0; + for (uint64 i = 0; i < toFill; ++i) + { + const id u = id::randomValue(); + increaseEnergy(u, price); + EXPECT_EQ(ctl.buyTicket(u, price).returnCode, static_cast(RL::EReturnCode::SUCCESS)); + } + EXPECT_EQ(ctl.state()->getPlayerCounter(), toFill); + + // Try to buy 10 tickets — only remaining 5 accepted, the rest refunded + const id buyer = id::randomValue(); + increaseEnergy(buyer, price * 10); + const uint64 balBefore = getBalance(buyer); + const RL::BuyTicket_output out = ctl.buyTicket(buyer, price * 10); + EXPECT_EQ(out.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getPlayerCounter(), capacity); + EXPECT_EQ(getBalance(buyer), balBefore - price * 5); +} + +TEST(ContractRandomLottery, BuyMultipleTickets_AllSoldOut) +{ + ContractTestingRL ctl; + ctl.BeginEpoch(); + const uint64 price = ctl.state()->getTicketPrice(); + const uint64 capacity = ctl.getPlayers().players.capacity(); + + // Fill to capacity + for (uint64 i = 0; i < capacity; ++i) + { + const id u = id::randomValue(); + increaseEnergy(u, price); + EXPECT_EQ(ctl.buyTicket(u, price).returnCode, static_cast(RL::EReturnCode::SUCCESS)); + } + EXPECT_EQ(ctl.state()->getPlayerCounter(), capacity); + + // Any purchase refunds the full amount and returns ALL_SOLD_OUT code + const id buyer = id::randomValue(); + increaseEnergy(buyer, price * 3); + const uint64 balBefore = getBalance(buyer); + const RL::BuyTicket_output out = ctl.buyTicket(buyer, price * 3); + EXPECT_EQ(out.returnCode, static_cast(RL::EReturnCode::TICKET_ALL_SOLD_OUT)); + EXPECT_EQ(getBalance(buyer), balBefore); +} From 07bad874839b318ff3e67b6ea15a558b3fc60db0 Mon Sep 17 00:00:00 2001 From: N-010 Date: Sun, 26 Oct 2025 21:27:58 +0300 Subject: [PATCH 08/22] Removes #pragma once --- src/contracts/RandomLottery.h | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index df325100f..359abf6ed 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -1,5 +1,4 @@ -#pragma once -/** +/** * @file RandomLottery.h * @brief Random Lottery contract definition: state, data structures, and user / internal * procedures. From 81e47e65a284024775210c043d150c5838689475 Mon Sep 17 00:00:00 2001 From: N-010 Date: Sun, 26 Oct 2025 21:38:11 +0300 Subject: [PATCH 09/22] Fixes division operator --- src/contracts/RandomLottery.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index 359abf6ed..e839f7a04 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -525,8 +525,8 @@ struct RL : public ContractBase } // Compute desired number of tickets and change - locals.desired = locals.reward / locals.price; // How many tickets the caller attempts to buy - locals.remainder = locals.reward % locals.price; // Change to return + locals.desired = div(locals.reward, locals.price); // How many tickets the caller attempts to buy + locals.remainder = mod(locals.reward, locals.price); // Change to return locals.toBuy = min(locals.desired, locals.slotsLeft); // Do not exceed available slots // Add tickets (the same address may be inserted multiple times) From 43ba4bd7a4e97c3e5a51721110c0e4cef15d740d Mon Sep 17 00:00:00 2001 From: N-010 Date: Tue, 28 Oct 2025 22:01:52 +0300 Subject: [PATCH 10/22] Add utility functions for date handling and revenue calculation; enhance state management for lottery epochs --- src/contracts/RandomLottery.h | 508 ++++++++++++++++++++++------------ 1 file changed, 334 insertions(+), 174 deletions(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index e839f7a04..a9712b41a 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -35,6 +35,34 @@ constexpr uint8 RL_SHAREHOLDER_FEE_PERCENT = 20; /// Burn percent of epoch revenue (0..100). constexpr uint8 RL_BURN_PERCENT = 2; +constexpr uint8 RL_INVALID_DAY = 255; + +constexpr uint8 RL_INVALID_HOUR = 255; + +namespace RLUtils +{ + static void getCurrentDayOfWeek(const QpiContextFunctionCall& qpi, uint8& dayOfWeek) + { + dayOfWeek = qpi.dayOfWeek(qpi.year(), qpi.month(), qpi.day()); + } + + static void makeDateStamp(const QpiContextFunctionCall& qpi, uint32& res) + { + res = static_cast(qpi.year() << 9 | qpi.month() << 5 | qpi.day()); + } + + static void getSCRevenue(const QpiContextFunctionCall& qpi, Entity& entity, uint64& revenue) + { + qpi.getEntity(SELF, entity); + revenue = entity.incomingAmount - entity.outgoingAmount; + } + + template static constexpr const T& min(const T& a, const T& b) + { + return (a < b) ? a : b; + } +}; // namespace RLUtils + /// Placeholder structure for future extensions. struct RL2 { @@ -63,7 +91,7 @@ struct RL : public ContractBase SELLING, // Ticket selling is open LOCKED, // Ticket selling is closed - INVALID = 255 + INVALID = UINT8_MAX }; /** @@ -71,13 +99,17 @@ struct RL : public ContractBase */ enum class EReturnCode : uint8 { - SUCCESS = 0, + SUCCESS, + // Ticket-related errors - TICKET_INVALID_PRICE = 1, // Not enough funds to buy at least one ticket / price mismatch - TICKET_ALL_SOLD_OUT = 2, // No free slots left in players array - TICKET_SELLING_CLOSED = 3, // Attempted to buy while state is LOCKED + TICKET_INVALID_PRICE, // Not enough funds to buy at least one ticket / price mismatch + TICKET_ALL_SOLD_OUT, // No free slots left in players array + TICKET_SELLING_CLOSED, // Attempted to buy while state is LOCKED // Access-related errors - ACCESS_DENIED = 4, // Caller is not authorized to perform the action + ACCESS_DENIED, // Caller is not authorized to perform the action + + // Value-related errors + INVALID_VALUE, // Input value is not acceptable UNKNOWN_ERROR = UINT8_MAX }; @@ -85,6 +117,7 @@ struct RL : public ContractBase struct NextEpochData { uint64 newPrice = 0; // Ticket price to apply after END_EPOCH; 0 means "no change queued" + uint8 schedule = 0; }; //---- User-facing I/O structures ------------------------------------------------------------- @@ -127,10 +160,11 @@ struct RL : public ContractBase */ struct WinnerInfo { - id winnerAddress = id::zero(); // Winner address - uint64 revenue = 0; // Payout value sent to the winner for that epoch - uint16 epoch = 0; // Epoch number when winner was recorded - uint32 tick = 0; // Tick when the decision was made + id winnerAddress = id::zero(); // Winner address + uint64 revenue = 0; // Payout value sent to the winner for that epoch + uint32 tick = 0; // Tick when the decision was made + uint16 epoch = 0; // Epoch number when winner was recorded + uint8 dayOfWeek = RL_INVALID_DAY; // Day of week when the winner was drawn [0..6] 0 = WEDNESDAY }; struct FillWinnersInfo_input @@ -148,23 +182,6 @@ struct RL : public ContractBase WinnerInfo winnerInfo = {}; // Temporary buffer to compose a WinnerInfo record }; - struct GetWinner_input - { - }; - - struct GetWinner_output - { - id winnerAddress = id::zero(); // Selected winner address (id::zero if none) - uint64 index = 0; // Index into players array - }; - - struct GetWinner_locals - { - uint64 randomNum = 0; // Random index candidate in [0, playerCounter) - sint64 i = 0; // Reserved for future iteration logic - uint64 j = 0; // Reserved for future iteration logic - }; - struct GetWinners_input { }; @@ -245,39 +262,65 @@ struct RL : public ContractBase uint64 i = 0; // Loop counter for mass-refund }; - struct END_EPOCH_locals + struct SetPrice_input { - GetWinner_input getWinnerInput = {}; - GetWinner_output getWinnerOutput = {}; - GetWinner_locals getWinnerLocals = {}; + uint64 newPrice = 0; // New ticket price to be applied at the end of the epoch + }; - FillWinnersInfo_input fillWinnersInfoInput = {}; - FillWinnersInfo_output fillWinnersInfoOutput = {}; - FillWinnersInfo_locals fillWinnersInfoLocals = {}; + struct SetPrice_output + { + uint8 returnCode = static_cast(EReturnCode::SUCCESS); + }; + struct SetSchedule_input + { + uint8 newSchedule = 0; // New schedule bitmask to be applied at the end of the epoch + }; + + struct SetSchedule_output + { + uint8 returnCode = static_cast(EReturnCode::SUCCESS); + }; + + struct BEGIN_TICK_locals + { + id winnerAddress = id::zero(); + Entity entity = {}; + uint64 revenue = 0; + uint64 randomNum = 0; + uint64 winnerAmount = 0; + uint64 teamFee = 0; + uint64 distributionFee = 0; + uint64 burnedAmount = 0; + FillWinnersInfo_locals fillWinnersInfoLocals = {}; + FillWinnersInfo_input fillWinnersInfoInput = {}; + uint32 currentDateStamp = 0; + uint8 currentDayOfWeek = RL_INVALID_DAY; + uint8 currentHour = RL_INVALID_HOUR; + uint8 isWednesday = 0; + uint8 isScheduledToday = 0; + ReturnAllTickets_locals returnAllTicketsLocals = {}; ReturnAllTickets_input returnAllTicketsInput = {}; ReturnAllTickets_output returnAllTicketsOutput = {}; - ReturnAllTickets_locals returnAllTicketsLocals = {}; - - uint64 teamFee = 0; // Team payout portion - uint64 distributionFee = 0; // Distribution/shared payout portion - uint64 winnerAmount = 0; // Winner payout portion - uint64 burnedAmount = 0; // Burn portion + FillWinnersInfo_output fillWinnersInfoOutput = {}; + }; - uint64 revenue = 0; // Epoch revenue = incoming - outgoing - Entity entity = {}; // Accounting snapshot + struct GetNextEpochData_input + { + }; - sint32 i = 0; // Reserved + struct GetNextEpochData_output + { + NextEpochData nextEpochData = {}; }; - struct SetPrice_input + struct GetDrawHour_input { - uint64 newPrice = 0; // New ticket price to be applied at the end of the epoch }; - struct SetPrice_output + struct GetDrawHour_output { - uint8 returnCode = static_cast(EReturnCode::SUCCESS); + uint8 drawHour = RL_INVALID_HOUR; }; public: @@ -294,8 +337,11 @@ struct RL : public ContractBase REGISTER_USER_FUNCTION(GetMaxNumberOfPlayers, 5); REGISTER_USER_FUNCTION(GetState, 6); REGISTER_USER_FUNCTION(GetBalance, 7); + REGISTER_USER_FUNCTION(GetNextEpochData, 8); + REGISTER_USER_FUNCTION(GetDrawHour, 9); REGISTER_USER_PROCEDURE(BuyTicket, 1); REGISTER_USER_PROCEDURE(SetPrice, 2); + REGISTER_USER_PROCEDURE(SetSchedule, 3); } /** @@ -323,94 +369,154 @@ struct RL : public ContractBase // Reset player counter state.playerCounter = 0; + + // Default schedule: WEDNESDAY + state.schedule = 1 << WEDNESDAY; } /** * @brief Opens ticket selling for a new epoch. */ - BEGIN_EPOCH() { state.currentState = EState::SELLING; } + BEGIN_EPOCH() + { + if (state.schedule == 0) + { + // Default to WEDNESDAY if no schedule is set + state.schedule = 1 << WEDNESDAY; + } - /** - * @brief Closes epoch: computes revenue, selects winner (if >1 player), - * distributes fees, burns leftover, records winner, then clears players. - * - * Behavior: - * - If exactly 1 player, refund ticket price (no draw). - * - If >1 players, compute revenue, select winner with K12-based randomness, - * split revenue into winner/team/distribution/burn, perform transfers/burn, - * and store winner snapshot in the ring buffer. - * - Apply deferred price change for the next epoch if queued. - */ - END_EPOCH_WITH_LOCALS() + if (state.drawHour == 0) + { + state.drawHour = 11; // Default to 11 UTC + } + + // Mark the current day as already processed to avoid immediate toggling on the same day + RLUtils::getCurrentDayOfWeek(qpi, state.lastDrawDay); + state.lastDrawHour = state.drawHour; + // Force lastDrawDateStamp to today's date to prevent reprocessing + RLUtils::makeDateStamp(qpi, state.lastDrawDateStamp); + + enableBuyTicket(state, true); + } + + END_EPOCH() { - state.currentState = EState::LOCKED; + enableBuyTicket(state, false); - // Single-player edge case: refund instead of drawing. - if (state.playerCounter == 1) + clearStateOnEndEpoch(state); + applyNextEpochData(state); + } + + BEGIN_TICK_WITH_LOCALS() + { + // Only process once every 100 ticks + if (mod(qpi.tick(), 100u) != 0) { - ReturnAllTickets(qpi, state, locals.returnAllTicketsInput, locals.returnAllTicketsOutput, locals.returnAllTicketsLocals); + return; } - else if (state.playerCounter > 1) + + // Compute current day and hour + RLUtils::getCurrentDayOfWeek(qpi, locals.currentDayOfWeek); + locals.currentHour = qpi.hour(); + + // Only consider actions at or after the configured draw hour + if (locals.currentHour < state.drawHour) { - // Epoch revenue = incoming - outgoing for this contract - qpi.getEntity(SELF, locals.entity); - locals.revenue = locals.entity.incomingAmount - locals.entity.outgoingAmount; + return; + } - // Winner selection (pseudo-random using K12(prevSpectrumDigest)). - GetWinner(qpi, state, locals.getWinnerInput, locals.getWinnerOutput, locals.getWinnerLocals); + // Allow only one state change action per calendar day + RLUtils::makeDateStamp(qpi, locals.currentDateStamp); + if (state.lastDrawDateStamp == locals.currentDateStamp) + { + return; + } - if (locals.getWinnerOutput.winnerAddress != id::zero()) - { - // Split revenue by configured percentages - locals.winnerAmount = div(locals.revenue * state.winnerFeePercent, 100ULL); - locals.teamFee = div(locals.revenue * state.teamFeePercent, 100ULL); - locals.distributionFee = div(locals.revenue * state.distributionFeePercent, 100ULL); - locals.burnedAmount = div(locals.revenue * state.burnPercent, 100ULL); - - // Team payout - if (locals.teamFee > 0) - { - qpi.transfer(state.teamAddress, locals.teamFee); - } + locals.isWednesday = (locals.currentDayOfWeek == WEDNESDAY); + locals.isScheduledToday = ((state.schedule & (1u << locals.currentDayOfWeek)) != 0); - // Distribution payout (equal per validator) - if (locals.distributionFee > 0) - { - qpi.distributeDividends(div(locals.distributionFee, uint64(NUMBER_OF_COMPUTORS))); - } + // Two-Wednesdays rule takes precedence over schedule: + // - First Wednesday (epoch start) is handled in BEGIN_EPOCH (we mark the day as processed), + // - Second (and any subsequent) Wednesday always performs draw and closes selling, + // - On other days, we draw only if the day is in schedule and re-open selling afterwards. + if (!locals.isWednesday && !locals.isScheduledToday) + { + return; // Non-Wednesday day that is not in schedule: nothing to do + } - // Winner payout - if (locals.winnerAmount > 0) - { - qpi.transfer(locals.getWinnerOutput.winnerAddress, locals.winnerAmount); - } + // Mark as processed for this calendar day and snapshot time + state.lastDrawDay = locals.currentDayOfWeek; + state.lastDrawHour = locals.currentHour; + state.lastDrawDateStamp = locals.currentDateStamp; - // Burn configured portion - if (locals.burnedAmount > 0) - { - qpi.burn(locals.burnedAmount); - } + // Disable for current draw period + enableBuyTicket(state, false); - // Store winner snapshot into history (ring buffer) - locals.fillWinnersInfoInput.winnerAddress = locals.getWinnerOutput.winnerAddress; - locals.fillWinnersInfoInput.revenue = locals.winnerAmount; - FillWinnersInfo(qpi, state, locals.fillWinnersInfoInput, locals.fillWinnersInfoOutput, locals.fillWinnersInfoLocals); + // Draw + { + if (state.playerCounter <= 1) + { + ReturnAllTickets(qpi, state, locals.returnAllTicketsInput, locals.returnAllTicketsOutput, locals.returnAllTicketsLocals); } else { - // Fallback: if winner couldn't be selected (should not happen), refund all tickets - ReturnAllTickets(qpi, state, locals.returnAllTicketsInput, locals.returnAllTicketsOutput, locals.returnAllTicketsLocals); + // Epoch revenue = incoming - outgoing for this contract + qpi.getEntity(SELF, locals.entity); + RLUtils::getSCRevenue(qpi, locals.entity, locals.revenue); + + // Winner selection (pseudo-random using K12(prevSpectrumDigest)). + getRandomPlayer(state, qpi, locals.randomNum, locals.winnerAddress); + + if (locals.winnerAddress != id::zero()) + { + // Split revenue by configured percentages + locals.winnerAmount = div(locals.revenue * state.winnerFeePercent, 100ULL); + locals.teamFee = div(locals.revenue * state.teamFeePercent, 100ULL); + locals.distributionFee = div(locals.revenue * state.distributionFeePercent, 100ULL); + locals.burnedAmount = div(locals.revenue * state.burnPercent, 100ULL); + + // Team payout + if (locals.teamFee > 0) + { + qpi.transfer(state.teamAddress, locals.teamFee); + } + + // Distribution payout (equal per validator) + if (locals.distributionFee > 0) + { + qpi.distributeDividends(div(locals.distributionFee, static_cast(NUMBER_OF_COMPUTORS))); + } + + // Winner payout + if (locals.winnerAmount > 0) + { + qpi.transfer(locals.winnerAddress, locals.winnerAmount); + } + + // Burn configured portion + if (locals.burnedAmount > 0) + { + qpi.burn(locals.burnedAmount); + } + + // Store winner snapshot into history (ring buffer) + locals.fillWinnersInfoInput.winnerAddress = locals.winnerAddress; + locals.fillWinnersInfoInput.revenue = locals.winnerAmount; + FillWinnersInfo(qpi, state, locals.fillWinnersInfoInput, locals.fillWinnersInfoOutput, locals.fillWinnersInfoLocals); + } + else + { + // Fallback: if winner couldn't be selected (should not happen), refund all tickets + ReturnAllTickets(qpi, state, locals.returnAllTicketsInput, locals.returnAllTicketsOutput, locals.returnAllTicketsLocals); + } } } - // Prepare for next epoch: clear players and apply deferred price if any - state.playerCounter = 0; + clearStateOnEndDraw(state); - if (state.nexEpochData.newPrice != 0) - { - state.ticketPrice = state.nexEpochData.newPrice; - state.nexEpochData.newPrice = 0; - } + // Re-enable ticket buying if today is not Wednesday + // On Wednesdays, selling remains closed until next BEGIN_EPOCH + enableBuyTicket(state, !locals.isWednesday); } /** @@ -430,7 +536,7 @@ struct RL : public ContractBase PUBLIC_FUNCTION(GetPlayers) { output.players = state.players; - output.playerCounter = min(state.playerCounter, state.players.capacity()); + output.playerCounter = RLUtils::min(state.playerCounter, state.players.capacity()); } /** @@ -439,17 +545,15 @@ struct RL : public ContractBase PUBLIC_FUNCTION(GetWinners) { output.winners = state.winners; - output.winnersCounter = min(state.winnersCounter, state.winners.capacity()); + output.winnersCounter = RLUtils::min(state.winnersCounter, state.winners.capacity()); } PUBLIC_FUNCTION(GetTicketPrice) { output.ticketPrice = state.ticketPrice; } PUBLIC_FUNCTION(GetMaxNumberOfPlayers) { output.numberOfPlayers = RL_MAX_NUMBER_OF_PLAYERS; } PUBLIC_FUNCTION(GetState) { output.currentState = static_cast(state.currentState); } - PUBLIC_FUNCTION_WITH_LOCALS(GetBalance) - { - // Returns balance for current epoch (incoming - outgoing) - output.balance = qpi.getEntity(SELF, locals.entity) ? locals.entity.incomingAmount - locals.entity.outgoingAmount : 0; - } + PUBLIC_FUNCTION(GetNextEpochData) { output.nextEpochData = state.nexEpochData; } + PUBLIC_FUNCTION(GetDrawHour) { output.drawHour = state.drawHour; } + PUBLIC_FUNCTION_WITH_LOCALS(GetBalance) { RLUtils::getSCRevenue(qpi, locals.entity, output.balance); } PUBLIC_PROCEDURE(SetPrice) { @@ -472,6 +576,24 @@ struct RL : public ContractBase output.returnCode = static_cast(EReturnCode::SUCCESS); } + PUBLIC_PROCEDURE(SetSchedule) + { + if (qpi.invocator() != state.teamAddress) + { + output.returnCode = static_cast(EReturnCode::ACCESS_DENIED); + return; + } + + if (input.newSchedule == 0) + { + output.returnCode = static_cast(EReturnCode::INVALID_VALUE); + return; + } + + state.nexEpochData.schedule = input.newSchedule; + output.returnCode = static_cast(EReturnCode::SUCCESS); + } + /** * @brief Attempts to buy tickets while SELLING state is active. * Logic: @@ -525,9 +647,9 @@ struct RL : public ContractBase } // Compute desired number of tickets and change - locals.desired = div(locals.reward, locals.price); // How many tickets the caller attempts to buy - locals.remainder = mod(locals.reward, locals.price); // Change to return - locals.toBuy = min(locals.desired, locals.slotsLeft); // Do not exceed available slots + locals.desired = div(locals.reward, locals.price); // How many tickets the caller attempts to buy + locals.remainder = mod(locals.reward, locals.price); // Change to return + locals.toBuy = RLUtils::min(locals.desired, locals.slotsLeft); // Do not exceed available slots // Add tickets (the same address may be inserted multiple times) for (locals.i = 0; locals.i < locals.toBuy; ++locals.i) @@ -535,7 +657,7 @@ struct RL : public ContractBase if (state.playerCounter < locals.capacity) { state.players.set(state.playerCounter, qpi.invocator()); - state.playerCounter = min(state.playerCounter + 1, locals.capacity); + state.playerCounter = RLUtils::min(state.playerCounter + 1, locals.capacity); } } @@ -562,34 +684,17 @@ struct RL : public ContractBase } // Use ring-buffer indexing to avoid overflow (overwrite oldest entries) - state.winnersCounter = mod(state.winnersCounter, state.winners.capacity()); + state.winnersCounter = mod(state.winnersCounter, state.winners.capacity()); locals.winnerInfo.winnerAddress = input.winnerAddress; locals.winnerInfo.revenue = input.revenue; locals.winnerInfo.epoch = qpi.epoch(); locals.winnerInfo.tick = qpi.tick(); + RLUtils::getCurrentDayOfWeek(qpi, locals.winnerInfo.dayOfWeek); state.winners.set(state.winnersCounter, locals.winnerInfo); ++state.winnersCounter; } - /** - * @brief Internal: pseudo-random selection of a winner index using hardware RNG. - */ - PRIVATE_PROCEDURE_WITH_LOCALS(GetWinner) - { - if (state.playerCounter == 0) - { - return; - } - - // Compute pseudo-random index based on K12(prevSpectrumDigest) - locals.randomNum = mod(qpi.K12(qpi.getPrevSpectrumDigest()).u64._0, state.playerCounter); - - // Index directly into players array - output.winnerAddress = state.players.get(locals.randomNum); - output.index = locals.randomNum; - } - PRIVATE_PROCEDURE_WITH_LOCALS(ReturnAllTickets) { // Refund ticket price to each recorded player (one transfer per ticket entry) @@ -601,40 +706,33 @@ struct RL : public ContractBase protected: /** - * @brief Address of the team managing the lottery contract. - * Initialized to a zero address. - */ - id teamAddress = id::zero(); - - /** - * @brief Address of the owner of the lottery contract. - * Initialized to a zero address. + * @brief Circular buffer storing the history of winners. + * Maximum capacity is defined by RL_MAX_NUMBER_OF_WINNERS_IN_HISTORY. */ - id ownerAddress = id::zero(); + Array winners = {}; /** - * @brief Percentage of the revenue allocated to the team. - * Value is between 0 and 100. + * @brief Set of players participating in the current lottery epoch. + * Maximum capacity is defined by RL_MAX_NUMBER_OF_PLAYERS. */ - uint8 teamFeePercent = 0; + Array players = {}; /** - * @brief Percentage of the revenue allocated for distribution. - * Value is between 0 and 100. + * @brief Address of the team managing the lottery contract. + * Initialized to a zero address. */ - uint8 distributionFeePercent = 0; + id teamAddress = id::zero(); /** - * @brief Percentage of the revenue allocated to the winner. - * Automatically calculated as the remainder after other fees. + * @brief Address of the owner of the lottery contract. + * Initialized to a zero address. */ - uint8 winnerFeePercent = 0; + id ownerAddress = id::zero(); /** - * @brief Percentage of the revenue to be burned. - * Value is between 0 and 100. + * @brief Data structure for deferred changes to apply at the end of the epoch. */ - uint8 burnPercent = 0; + NextEpochData nexEpochData = {}; /** * @brief Price of a single lottery ticket. @@ -648,27 +746,42 @@ struct RL : public ContractBase uint64 playerCounter = 0; /** - * @brief Data structure for deferred changes to apply at the end of the epoch. + * @brief Index pointing to the next empty slot in the winners array. + * Used for maintaining the circular buffer of winners. */ - NextEpochData nexEpochData = {}; + uint64 winnersCounter = 0; + + uint8 lastDrawDay = RL_INVALID_DAY; + uint8 lastDrawHour = RL_INVALID_HOUR; + uint32 lastDrawDateStamp = 0; // calendar day marker to prevent multiple actions per day /** - * @brief Set of players participating in the current lottery epoch. - * Maximum capacity is defined by RL_MAX_NUMBER_OF_PLAYERS. + * @brief Percentage of the revenue allocated to the team. + * Value is between 0 and 100. */ - Array players = {}; + uint8 teamFeePercent = 0; /** - * @brief Circular buffer storing the history of winners. - * Maximum capacity is defined by RL_MAX_NUMBER_OF_WINNERS_IN_HISTORY. + * @brief Percentage of the revenue allocated for distribution. + * Value is between 0 and 100. */ - Array winners = {}; + uint8 distributionFeePercent = 0; /** - * @brief Index pointing to the next empty slot in the winners array. - * Used for maintaining the circular buffer of winners. + * @brief Percentage of the revenue allocated to the winner. + * Automatically calculated as the remainder after other fees. */ - uint64 winnersCounter = 0; + uint8 winnerFeePercent = 0; + + /** + * @brief Percentage of the revenue to be burned. + * Value is between 0 and 100. + */ + uint8 burnPercent = 0; + + uint8 schedule = 0; + + uint8 drawHour = 0; /** * @brief Current state of the lottery contract. @@ -677,5 +790,52 @@ struct RL : public ContractBase EState currentState = EState::LOCKED; protected: - template static constexpr const T& min(const T& a, const T& b) { return (a < b) ? a : b; } + static void clearStateOnEndEpoch(RL& state) + { + // Prepare for next epoch: clear players and apply deferred price if any + state.playerCounter = 0; + + state.lastDrawHour = RL_INVALID_HOUR; + state.lastDrawDay = RL_INVALID_DAY; + state.lastDrawDateStamp = 0; + } + + static void clearStateOnEndDraw(RL& state) + { + // Prepare for next draw period: clear players + state.playerCounter = 0; + } + + static void applyNextEpochData(RL& state) + { + if (state.nexEpochData.newPrice != 0) + { + state.ticketPrice = state.nexEpochData.newPrice; + state.nexEpochData.newPrice = 0; + } + + if (state.nexEpochData.schedule != 0) + { + state.schedule = state.nexEpochData.schedule; + state.nexEpochData.schedule = 0; + } + } + + static void enableBuyTicket(RL& state, bool bEnable) { state.currentState = bEnable ? EState::SELLING : EState::LOCKED; } + + static void getRandomPlayer(const RL& state, const QpiContextFunctionCall& qpi, uint64& randomNum, id& winnerAddress) + { + winnerAddress = id::zero(); + + if (state.playerCounter == 0) + { + return; + } + + // Compute pseudo-random index based on K12(prevSpectrumDigest) + randomNum = mod(qpi.K12(qpi.getPrevSpectrumDigest()).u64._0, state.playerCounter); + + // Index directly into players array + winnerAddress = state.players.get(randomNum); + } }; From 96b7e2957d825b3cc48fa14a604a4b06b674e2dd Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 30 Oct 2025 00:00:29 +0300 Subject: [PATCH 11/22] Add schedule management and draw hour functionality; implement GetSchedule and SetSchedule procedures --- src/contracts/RandomLottery.h | 15 ++- test/contract_rl.cpp | 238 +++++++++++++++++++++++++++++----- 2 files changed, 222 insertions(+), 31 deletions(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index a9712b41a..f326424b4 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -39,6 +39,8 @@ constexpr uint8 RL_INVALID_DAY = 255; constexpr uint8 RL_INVALID_HOUR = 255; +constexpr uint8 RL_TICK_UPDATE_PERIOD = 100; + namespace RLUtils { static void getCurrentDayOfWeek(const QpiContextFunctionCall& qpi, uint8& dayOfWeek) @@ -323,6 +325,15 @@ struct RL : public ContractBase uint8 drawHour = RL_INVALID_HOUR; }; + // New: expose current schedule mask + struct GetSchedule_input + { + }; + struct GetSchedule_output + { + uint8 schedule = 0; + }; + public: /** * @brief Registers all externally callable functions and procedures with their numeric @@ -339,6 +350,7 @@ struct RL : public ContractBase REGISTER_USER_FUNCTION(GetBalance, 7); REGISTER_USER_FUNCTION(GetNextEpochData, 8); REGISTER_USER_FUNCTION(GetDrawHour, 9); + REGISTER_USER_FUNCTION(GetSchedule, 10); REGISTER_USER_PROCEDURE(BuyTicket, 1); REGISTER_USER_PROCEDURE(SetPrice, 2); REGISTER_USER_PROCEDURE(SetSchedule, 3); @@ -410,7 +422,7 @@ struct RL : public ContractBase BEGIN_TICK_WITH_LOCALS() { // Only process once every 100 ticks - if (mod(qpi.tick(), 100u) != 0) + if (mod(qpi.tick(), static_cast(RL_TICK_UPDATE_PERIOD)) != 0) { return; } @@ -553,6 +565,7 @@ struct RL : public ContractBase PUBLIC_FUNCTION(GetState) { output.currentState = static_cast(state.currentState); } PUBLIC_FUNCTION(GetNextEpochData) { output.nextEpochData = state.nexEpochData; } PUBLIC_FUNCTION(GetDrawHour) { output.drawHour = state.drawHour; } + PUBLIC_FUNCTION(GetSchedule) { output.schedule = state.schedule; } PUBLIC_FUNCTION_WITH_LOCALS(GetBalance) { RLUtils::getSCRevenue(qpi, locals.entity, output.balance); } PUBLIC_PROCEDURE(SetPrice) diff --git a/test/contract_rl.cpp b/test/contract_rl.cpp index 6fd466e96..774c45093 100644 --- a/test/contract_rl.cpp +++ b/test/contract_rl.cpp @@ -5,6 +5,7 @@ constexpr uint16 PROCEDURE_INDEX_BUY_TICKET = 1; constexpr uint16 PROCEDURE_INDEX_SET_PRICE = 2; +constexpr uint16 PROCEDURE_INDEX_SET_SCHEDULE = 3; constexpr uint16 FUNCTION_INDEX_GET_FEES = 1; constexpr uint16 FUNCTION_INDEX_GET_PLAYERS = 2; constexpr uint16 FUNCTION_INDEX_GET_WINNERS = 3; @@ -12,14 +13,20 @@ constexpr uint16 FUNCTION_INDEX_GET_TICKET_PRICE = 4; constexpr uint16 FUNCTION_INDEX_GET_MAX_NUM_PLAYERS = 5; constexpr uint16 FUNCTION_INDEX_GET_STATE = 6; constexpr uint16 FUNCTION_INDEX_GET_BALANCE = 7; +constexpr uint16 FUNCTION_INDEX_GET_NEXT_EPOCH_DATA = 8; +constexpr uint16 FUNCTION_INDEX_GET_DRAW_HOUR = 9; +constexpr uint16 FUNCTION_INDEX_GET_SCHEDULE = 10; static const id RL_DEV_ADDRESS = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); +constexpr uint8 RL_ANY_DAY_DRAW_SCHEDULE = 0xFF; + // Equality operator for comparing WinnerInfo objects bool operator==(const RL::WinnerInfo& left, const RL::WinnerInfo& right) { - return left.winnerAddress == right.winnerAddress && left.revenue == right.revenue && left.epoch == right.epoch && left.tick == right.tick; + return left.winnerAddress == right.winnerAddress && left.revenue == right.revenue && left.epoch == right.epoch && left.tick == right.tick && + left.dayOfWeek == right.dayOfWeek; } // Test helper that exposes internal state assertions @@ -88,9 +95,15 @@ class RLChecker : public RL } } + void setScheduleMask(uint8 newMask) { schedule = newMask; } + uint64 getPlayerCounter() const { return playerCounter; } uint64 getTicketPrice() const { return ticketPrice; } + + uint8 getScheduleMask() const { return schedule; } + + uint8 getDrawHourInternal() const { return drawHour; } }; class ContractTestingRL : protected ContractTesting @@ -170,6 +183,33 @@ class ContractTestingRL : protected ContractTesting return output; } + // Wrapper for public function RL::GetNextEpochData + RL::GetNextEpochData_output getNextEpochData() + { + RL::GetNextEpochData_input input; + RL::GetNextEpochData_output output; + callFunction(RL_CONTRACT_INDEX, FUNCTION_INDEX_GET_NEXT_EPOCH_DATA, input, output); + return output; + } + + // Wrapper for public function RL::GetDrawHour + RL::GetDrawHour_output getDrawHour() + { + RL::GetDrawHour_input input; + RL::GetDrawHour_output output; + callFunction(RL_CONTRACT_INDEX, FUNCTION_INDEX_GET_DRAW_HOUR, input, output); + return output; + } + + // Wrapper for public function RL::GetSchedule + RL::GetSchedule_output getSchedule() + { + RL::GetSchedule_input input; + RL::GetSchedule_output output; + callFunction(RL_CONTRACT_INDEX, FUNCTION_INDEX_GET_SCHEDULE, input, output); + return output; + } + RL::BuyTicket_output buyTicket(const id& user, uint64 reward) { RL::BuyTicket_input input; @@ -194,10 +234,25 @@ class ContractTestingRL : protected ContractTesting return output; } + // Added: wrapper for SetSchedule procedure + RL::SetSchedule_output setSchedule(const id& invocator, uint8 newSchedule) + { + RL::SetSchedule_input input; + input.newSchedule = newSchedule; + RL::SetSchedule_output output; + if (!invokeUserProcedure(RL_CONTRACT_INDEX, PROCEDURE_INDEX_SET_SCHEDULE, input, output, invocator, 0)) + { + output.returnCode = static_cast(RL::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + void BeginEpoch() { callSystemProcedure(RL_CONTRACT_INDEX, BEGIN_EPOCH); } void EndEpoch() { callSystemProcedure(RL_CONTRACT_INDEX, END_EPOCH); } + void BeginTick() { callSystemProcedure(RL_CONTRACT_INDEX, BEGIN_TICK); } + // Returns the SELF contract account address id rlSelf() { return id(RL_CONTRACT_INDEX, 0, 0, 0); } @@ -227,6 +282,61 @@ class ContractTestingRL : protected ContractTesting const RL::GetBalance_output out = ctl.getBalanceInfo(); EXPECT_EQ(out.balance, getBalance(contractAddress)); } + + void setCurrentHour(uint8 hour) + { + updateTime(); + utcTime.Hour = hour; + updateQpiTime(); + } + + // New: set full date and hour + void setDateTime(uint16 year, uint8 month, uint8 day, uint8 hour) + { + updateTime(); + utcTime.Year = year; + utcTime.Month = month; + utcTime.Day = day; + utcTime.Hour = hour; + utcTime.Minute = 0; + utcTime.Second = 0; + utcTime.Nanosecond = 0; + updateQpiTime(); + } + + // New: perform many BEGIN_TICK calls to ensure one execution when tick % 100 == 0 + void forceBeginTick() + { + system.tick = system.tick + (RL_TICK_UPDATE_PERIOD - mod(system.tick, static_cast(RL_TICK_UPDATE_PERIOD))); + + BeginTick(); + } + + // New: helper to advance one day ahead and try to draw at 12:00 + void advanceOneDayAndDraw() + { + // Use a safe base month to avoid invalid dates: January 2025 + static uint16 y = 2025; + static uint8 m = 1; + static uint8 d = 10; // start from 10th + // advance one day within January bounds + d = static_cast(d + 1); + if (d > 31) + { + d = 1; // wrap within month for simplicity in tests + } + setDateTime(y, m, d, 12); + forceBeginTick(); + } + + void forceSchedule(uint8 scheduleMask) + { + state()->setScheduleMask(scheduleMask); + // increaseEnergy(RL_DEV_ADDRESS, 1); + // BeginEpoch(); + // EXPECT_EQ(setSchedule(RL_DEV_ADDRESS, scheduleMask).returnCode, static_cast(RL::EReturnCode::SUCCESS)); + // EndEpoch(); + } }; TEST(ContractRandomLottery, GetFees) @@ -328,7 +438,8 @@ TEST(ContractRandomLottery, BuyTicket) EXPECT_EQ(ctl.state()->getPlayerCounter(), userCount * 2); } -TEST(ContractRandomLottery, EndEpoch) +// Updated: payout is triggered by BEGIN_TICK with schedule/time, not by END_EPOCH +TEST(ContractRandomLottery, DrawAndPayout_BeginTick) { ContractTestingRL ctl; @@ -342,18 +453,22 @@ TEST(ContractRandomLottery, EndEpoch) const uint8 burnPercent = fees.burnPercent; // Burn percent const uint8 winnerPercent = fees.winnerFeePercent; // Winner payout percent - // --- Scenario 1: No players (should just lock and clear silently) --- + // Ensure schedule allows draw any day + ctl.forceSchedule(RL_ANY_DAY_DRAW_SCHEDULE); + + // --- Scenario 1: No players (should just clear silently) --- { ctl.BeginEpoch(); EXPECT_EQ(ctl.state()->getPlayerCounter(), 0u); RL::GetWinners_output before = ctl.getWinners(); - EXPECT_EQ(before.winnersCounter, 0u); + const uint64 winnersBefore = before.winnersCounter; - ctl.EndEpoch(); + // Need to move to a new day and call BEGIN_TICK to allow draw + ctl.advanceOneDayAndDraw(); RL::GetWinners_output after = ctl.getWinners(); - EXPECT_EQ(after.winnersCounter, 0u); + EXPECT_EQ(after.winnersCounter, winnersBefore); EXPECT_EQ(ctl.state()->getPlayerCounter(), 0u); } @@ -370,14 +485,17 @@ TEST(ContractRandomLottery, EndEpoch) EXPECT_EQ(ctl.state()->getPlayerCounter(), 1u); EXPECT_EQ(getBalance(solo), balanceBefore - ticketPrice); - ctl.EndEpoch(); + const uint64 winnersBeforeCount = ctl.getWinners().winnersCounter; + + ctl.advanceOneDayAndDraw(); // Refund happened EXPECT_EQ(getBalance(solo), balanceBefore); EXPECT_EQ(ctl.state()->getPlayerCounter(), 0u); const RL::GetWinners_output winners = ctl.getWinners(); - EXPECT_EQ(winners.winnersCounter, 0u); + // No new winners appended + EXPECT_EQ(winners.winnersCounter, winnersBeforeCount); } // --- Scenario 3: Multiple players (winner chosen, fees processed, remainder burned) --- @@ -414,16 +532,16 @@ TEST(ContractRandomLottery, EndEpoch) const RL::GetWinners_output winnersBefore = ctl.getWinners(); const uint64 winnersCountBefore = winnersBefore.winnersCounter; - ctl.EndEpoch(); + ctl.advanceOneDayAndDraw(); - // Players reset after epoch end + // Players reset after draw EXPECT_EQ(ctl.state()->getPlayerCounter(), 0u); const RL::GetWinners_output winnersAfter = ctl.getWinners(); EXPECT_EQ(winnersAfter.winnersCounter, winnersCountBefore + 1); // Newly appended winner info - const RL::WinnerInfo wi = winnersAfter.winners.get(winnersCountBefore); + const RL::WinnerInfo wi = winnersAfter.winners.get(mod(winnersCountBefore, winnersAfter.winners.capacity())); EXPECT_NE(wi.winnerAddress, id::zero()); EXPECT_EQ(wi.revenue, (ticketPrice * N * winnerPercent) / 100); @@ -456,7 +574,7 @@ TEST(ContractRandomLottery, EndEpoch) EXPECT_EQ(getBalance(contractAddress), burnExpected); } - // --- Scenario 4: Several consecutive epochs (winners accumulate, balances consistent) --- + // --- Scenario 4: Several consecutive draws (winners accumulate, balances consistent) --- { const uint32 rounds = 3; const uint32 playersPerRound = 6 * 2; // even number to mimic duplicates if desired @@ -495,14 +613,14 @@ TEST(ContractRandomLottery, EndEpoch) const uint64 contractBefore = getBalance(contractAddress); const uint64 teamBalBeforeRound = getBalance(RL_DEV_ADDRESS); - ctl.EndEpoch(); + ctl.advanceOneDayAndDraw(); // Winners should increase by exactly one const RL::GetWinners_output wOut = ctl.getWinners(); EXPECT_EQ(wOut.winnersCounter, winnersBefore + 1); // Validate winner entry - const RL::WinnerInfo newWi = wOut.winners.get(winnersBefore); + const RL::WinnerInfo newWi = wOut.winners.get(mod(winnersBefore, wOut.winners.capacity())); EXPECT_NE(newWi.winnerAddress, id::zero()); EXPECT_EQ(newWi.revenue, (contractBefore * winnerPercent) / 100); @@ -567,17 +685,19 @@ TEST(ContractRandomLottery, GetBalance) ctl.expectContractBalanceEqualsGetBalance(ctl, contractAddress); } - // Before ending the epoch, balance equals the total cost of tickets + // Before draw, balance equals the total cost of tickets { const RL::GetBalance_output outBefore = ctl.getBalanceInfo(); EXPECT_EQ(outBefore.balance, ticketPrice * K); } - // End epoch and verify expected remaining amount against contract balance and function output + // Trigger draw and verify expected remaining amount against contract balance and function output const uint64 contractBalanceBefore = getBalance(contractAddress); const RL::GetFees_output fees = ctl.getFees(); - ctl.EndEpoch(); + // Ensure schedule allows draw and perform it + ctl.forceSchedule(RL_ANY_DAY_DRAW_SCHEDULE); + ctl.advanceOneDayAndDraw(); const RL::GetBalance_output outAfter = ctl.getBalanceInfo(); const uint64 envAfter = getBalance(contractAddress); @@ -600,7 +720,7 @@ TEST(ContractRandomLottery, GetMaxNumberOfPlayers) ContractTestingRL ctl; const RL::GetMaxNumberOfPlayers_output out = ctl.getMaxNumberOfPlayers(); - // Compare against the players array capacity, fetched via GetPlayers + // Compare against the known constant via GetPlayers capacity const RL::GetPlayers_output playersOut = ctl.getPlayers(); EXPECT_EQ(static_cast(out.numberOfPlayers), static_cast(playersOut.players.capacity())); } @@ -622,7 +742,7 @@ TEST(ContractRandomLottery, GetState) EXPECT_EQ(out1.currentState, static_cast(RL::EState::SELLING)); } - // After EndEpoch — back to LOCKED + // After END_EPOCH — back to LOCKED (selling disabled until next epoch) ctl.EndEpoch(); { const RL::GetState_output out2 = ctl.getStateInfo(); @@ -630,7 +750,7 @@ TEST(ContractRandomLottery, GetState) } } -// --- New tests for SetPrice --- +// --- New tests for SetPrice and NextEpochData --- TEST(ContractRandomLottery, SetPrice_AccessControl) { @@ -646,7 +766,7 @@ TEST(ContractRandomLottery, SetPrice_AccessControl) const RL::SetPrice_output outDenied = ctl.setPrice(randomUser, newPrice); EXPECT_EQ(outDenied.returnCode, static_cast(RL::EReturnCode::ACCESS_DENIED)); - // Price doesn't change immediately nor after EndEpoch + // Price doesn't change immediately nor after END_EPOCH implicitly EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); ctl.EndEpoch(); EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); @@ -663,7 +783,7 @@ TEST(ContractRandomLottery, SetPrice_ZeroNotAllowed) const RL::SetPrice_output outInvalid = ctl.setPrice(RL_DEV_ADDRESS, 0); EXPECT_EQ(outInvalid.returnCode, static_cast(RL::EReturnCode::TICKET_INVALID_PRICE)); - // Price remains unchanged even after EndEpoch + // Price remains unchanged even after END_EPOCH EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); ctl.EndEpoch(); EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); @@ -681,14 +801,20 @@ TEST(ContractRandomLottery, SetPrice_AppliesAfterEndEpoch) const RL::SetPrice_output outOk = ctl.setPrice(RL_DEV_ADDRESS, newPrice); EXPECT_EQ(outOk.returnCode, static_cast(RL::EReturnCode::SUCCESS)); - // Until EndEpoch the price remains unchanged + // Check NextEpochData reflects pending change + EXPECT_EQ(ctl.getNextEpochData().nextEpochData.newPrice, newPrice); + + // Until END_EPOCH the price remains unchanged EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); - // Applied after EndEpoch + // Applied after END_EPOCH ctl.EndEpoch(); EXPECT_EQ(ctl.getTicketPrice().ticketPrice, newPrice); - // Another EndEpoch without a new SetPrice doesn't change the price + // NextEpochData cleared + EXPECT_EQ(ctl.getNextEpochData().nextEpochData.newPrice, 0u); + + // Another END_EPOCH without a new SetPrice doesn't change the price ctl.EndEpoch(); EXPECT_EQ(ctl.getTicketPrice().ticketPrice, newPrice); } @@ -703,11 +829,14 @@ TEST(ContractRandomLottery, SetPrice_OverrideBeforeEndEpoch) const uint64 firstPrice = oldPrice + 1000; const uint64 secondPrice = oldPrice + 7777; - // Two SetPrice calls before EndEpoch — the last one should apply + // Two SetPrice calls before END_EPOCH — the last one should apply EXPECT_EQ(ctl.setPrice(RL_DEV_ADDRESS, firstPrice).returnCode, static_cast(RL::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.setPrice(RL_DEV_ADDRESS, secondPrice).returnCode, static_cast(RL::EReturnCode::SUCCESS)); - // Until EndEpoch the old price remains + // NextEpochData shows the last queued value + EXPECT_EQ(ctl.getNextEpochData().nextEpochData.newPrice, secondPrice); + + // Until END_EPOCH the old price remains EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); ctl.EndEpoch(); @@ -732,10 +861,11 @@ TEST(ContractRandomLottery, SetPrice_AffectsNextEpochBuys) EXPECT_EQ(out1.returnCode, static_cast(RL::EReturnCode::SUCCESS)); } - // Set a new price, but before EndEpoch purchases should use the old price + // Set a new price, but before END_EPOCH purchases should use the old price logic (split by old price) { const RL::SetPrice_output setOut = ctl.setPrice(RL_DEV_ADDRESS, newPrice); EXPECT_EQ(setOut.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.getNextEpochData().nextEpochData.newPrice, newPrice); } const id u2 = id::randomValue(); @@ -751,11 +881,11 @@ TEST(ContractRandomLottery, SetPrice_AffectsNextEpochBuys) EXPECT_EQ(getBalance(u2), balBefore - bought * oldPrice); } - // End the epoch: new price will apply + // END_EPOCH: new price will apply ctl.EndEpoch(); EXPECT_EQ(ctl.getTicketPrice().ticketPrice, newPrice); - // In the next epoch, a purchase at the new price should succeed + // In the next epoch, a purchase at the new price should succeed exactly once per price ctl.BeginEpoch(); { const uint64 balBefore = getBalance(u2); @@ -850,3 +980,51 @@ TEST(ContractRandomLottery, BuyMultipleTickets_AllSoldOut) EXPECT_EQ(out.returnCode, static_cast(RL::EReturnCode::TICKET_ALL_SOLD_OUT)); EXPECT_EQ(getBalance(buyer), balBefore); } + +// functions related to schedule and draw hour + +TEST(ContractRandomLottery, GetSchedule_And_SetSchedule) +{ + ContractTestingRL ctl; + + // Default schedule set on initialize: Wednesday bit must be set + const RL::GetSchedule_output s0 = ctl.getSchedule(); + EXPECT_NE(s0.schedule, 0u); + + // Access control: random user cannot set schedule + const id rnd = id::randomValue(); + increaseEnergy(rnd, 1); + const RL::SetSchedule_output outDenied = ctl.setSchedule(rnd, RL_ANY_DAY_DRAW_SCHEDULE); + EXPECT_EQ(outDenied.returnCode, static_cast(RL::EReturnCode::ACCESS_DENIED)); + + // Invalid value: zero mask not allowed + increaseEnergy(RL_DEV_ADDRESS, 1); + const RL::SetSchedule_output outInvalid = ctl.setSchedule(RL_DEV_ADDRESS, 0); + EXPECT_EQ(outInvalid.returnCode, static_cast(RL::EReturnCode::INVALID_VALUE)); + + // Valid update queues into NextEpochData and applies after END_EPOCH + const uint8 newMask = 0x5A; // some non-zero mask + const RL::SetSchedule_output outOk = ctl.setSchedule(RL_DEV_ADDRESS, newMask); + EXPECT_EQ(outOk.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.getNextEpochData().nextEpochData.schedule, newMask); + + // Not applied yet + EXPECT_NE(ctl.getSchedule().schedule, newMask); + + // Apply + ctl.EndEpoch(); + EXPECT_EQ(ctl.getSchedule().schedule, newMask); + EXPECT_EQ(ctl.getNextEpochData().nextEpochData.schedule, 0u); +} + +TEST(ContractRandomLottery, GetDrawHour_DefaultAfterBeginEpoch) +{ + ContractTestingRL ctl; + + // Initially drawHour is 0 + EXPECT_EQ(ctl.getDrawHour().drawHour, 0u); + + // After BeginEpoch default is 11 + ctl.BeginEpoch(); + EXPECT_EQ(ctl.getDrawHour().drawHour, 11u); +} From 8b278edec0123555a420dfd816fda8887b9a06c1 Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 30 Oct 2025 00:11:48 +0300 Subject: [PATCH 12/22] Enhance lottery contract with detailed comments and clarify draw scheduling logic; update state management for draw hour and schedule --- src/contracts/RandomLottery.h | 75 +++++++++++++++++++++++------------ test/contract_rl.cpp | 34 ++++++++-------- 2 files changed, 67 insertions(+), 42 deletions(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index f326424b4..07d7a1e32 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -5,7 +5,7 @@ * * This header declares the RL (Random Lottery) contract which: * - Sells tickets during a SELLING epoch. - * - Draws a pseudo-random winner when the epoch ends. + * - Draws a pseudo-random winner when the epoch ends or at scheduled intra-epoch draws. * - Distributes fees (team, distribution, burn, winner). * - Records winners' history in a ring-like buffer. * @@ -13,6 +13,8 @@ * - Percentages must sum to <= 100; the remainder goes to the winner. * - Players array stores one entry per ticket, so a single address can appear multiple times. * - When only one player bought a ticket in the epoch, funds are refunded instead of drawing. + * - Day-of-week mapping used here is 0..6 where 0 = WEDNESDAY, 1 = THURSDAY, ..., 6 = TUESDAY. + * - Schedule uses a 7-bit mask aligned to the mapping above (bit 0 -> WEDNESDAY, bit 6 -> TUESDAY). */ using namespace QPI; @@ -35,24 +37,33 @@ constexpr uint8 RL_SHAREHOLDER_FEE_PERCENT = 20; /// Burn percent of epoch revenue (0..100). constexpr uint8 RL_BURN_PERCENT = 2; +/// Sentinel for "no valid day". constexpr uint8 RL_INVALID_DAY = 255; +/// Sentinel for "no valid hour". constexpr uint8 RL_INVALID_HOUR = 255; +/// Throttling period: process BEGIN_TICK logic once per this many ticks. constexpr uint8 RL_TICK_UPDATE_PERIOD = 100; +/// Default draw hour (UTC). +constexpr uint8 RL_DEFAULT_DRAW_HOUR = 11; // 11:00 UTC + namespace RLUtils { + // Returns current day-of-week in range [0..6], with 0 = WEDNESDAY according to platform mapping. static void getCurrentDayOfWeek(const QpiContextFunctionCall& qpi, uint8& dayOfWeek) { dayOfWeek = qpi.dayOfWeek(qpi.year(), qpi.month(), qpi.day()); } + // Packs current date into a compact stamp (Y/M/D) used to ensure a single action per calendar day. static void makeDateStamp(const QpiContextFunctionCall& qpi, uint32& res) { res = static_cast(qpi.year() << 9 | qpi.month() << 5 | qpi.day()); } + // Reads current net on-chain balance of SELF (incoming - outgoing). static void getSCRevenue(const QpiContextFunctionCall& qpi, Entity& entity, uint64& revenue) { qpi.getEntity(SELF, entity); @@ -119,7 +130,7 @@ struct RL : public ContractBase struct NextEpochData { uint64 newPrice = 0; // Ticket price to apply after END_EPOCH; 0 means "no change queued" - uint8 schedule = 0; + uint8 schedule = 0; // Schedule bitmask (bit 0 = WEDNESDAY, ..., bit 6 = TUESDAY); applied after END_EPOCH }; //---- User-facing I/O structures ------------------------------------------------------------- @@ -228,7 +239,7 @@ struct RL : public ContractBase struct GetBalance_output { - uint64 balance = 0; // Net balance (incoming - outgoing) for current epoch + uint64 balance = 0; // Current contract net balance (incoming - outgoing) }; // Local variables for GetBalance procedure @@ -393,21 +404,21 @@ struct RL : public ContractBase { if (state.schedule == 0) { - // Default to WEDNESDAY if no schedule is set + // Default to WEDNESDAY if no schedule is set (bit 0) state.schedule = 1 << WEDNESDAY; } if (state.drawHour == 0) { - state.drawHour = 11; // Default to 11 UTC + state.drawHour = RL_DEFAULT_DRAW_HOUR; // Default draw hour (UTC) } - // Mark the current day as already processed to avoid immediate toggling on the same day + // Mark the current date as already processed to avoid immediate draw on the same calendar day RLUtils::getCurrentDayOfWeek(qpi, state.lastDrawDay); state.lastDrawHour = state.drawHour; - // Force lastDrawDateStamp to today's date to prevent reprocessing RLUtils::makeDateStamp(qpi, state.lastDrawDateStamp); + // Open selling for the new epoch enableBuyTicket(state, true); } @@ -421,23 +432,23 @@ struct RL : public ContractBase BEGIN_TICK_WITH_LOCALS() { - // Only process once every 100 ticks + // Throttle: run logic only once per RL_TICK_UPDATE_PERIOD ticks if (mod(qpi.tick(), static_cast(RL_TICK_UPDATE_PERIOD)) != 0) { return; } - // Compute current day and hour + // Snapshot current day/hour RLUtils::getCurrentDayOfWeek(qpi, locals.currentDayOfWeek); locals.currentHour = qpi.hour(); - // Only consider actions at or after the configured draw hour + // Do nothing before the configured draw hour if (locals.currentHour < state.drawHour) { return; } - // Allow only one state change action per calendar day + // Ensure only one action per calendar day (UTC) RLUtils::makeDateStamp(qpi, locals.currentDateStamp); if (state.lastDrawDateStamp == locals.currentDateStamp) { @@ -447,21 +458,21 @@ struct RL : public ContractBase locals.isWednesday = (locals.currentDayOfWeek == WEDNESDAY); locals.isScheduledToday = ((state.schedule & (1u << locals.currentDayOfWeek)) != 0); - // Two-Wednesdays rule takes precedence over schedule: - // - First Wednesday (epoch start) is handled in BEGIN_EPOCH (we mark the day as processed), - // - Second (and any subsequent) Wednesday always performs draw and closes selling, - // - On other days, we draw only if the day is in schedule and re-open selling afterwards. + // Two-Wednesdays rule: + // - First Wednesday (epoch start) is "consumed" in BEGIN_EPOCH (we set lastDrawDateStamp), + // - Any subsequent Wednesday performs a draw and leaves selling CLOSED until next BEGIN_EPOCH, + // - Any other day performs a draw only if included in schedule and then re-opens selling. if (!locals.isWednesday && !locals.isScheduledToday) { - return; // Non-Wednesday day that is not in schedule: nothing to do + return; // Non-Wednesday day that is not scheduled: nothing to do } - // Mark as processed for this calendar day and snapshot time + // Mark today's action and timestamp state.lastDrawDay = locals.currentDayOfWeek; state.lastDrawHour = locals.currentHour; state.lastDrawDateStamp = locals.currentDateStamp; - // Disable for current draw period + // Temporarily close selling for the draw enableBuyTicket(state, false); // Draw @@ -526,8 +537,7 @@ struct RL : public ContractBase clearStateOnEndDraw(state); - // Re-enable ticket buying if today is not Wednesday - // On Wednesdays, selling remains closed until next BEGIN_EPOCH + // Resume selling unless today is Wednesday (remains closed until next epoch) enableBuyTicket(state, !locals.isWednesday); } @@ -688,6 +698,7 @@ struct RL : public ContractBase private: /** * @brief Internal: records a winner into the cyclic winners array. + * Overwrites oldest entries when capacity is exceeded (ring buffer). */ PRIVATE_PROCEDURE_WITH_LOCALS(FillWinnersInfo) { @@ -710,7 +721,7 @@ struct RL : public ContractBase PRIVATE_PROCEDURE_WITH_LOCALS(ReturnAllTickets) { - // Refund ticket price to each recorded player (one transfer per ticket entry) + // Refund ticket price to each recorded ticket entry (1 transfer per entry) for (locals.i = 0; locals.i < state.playerCounter; ++locals.i) { qpi.transfer(state.players.get(locals.i), state.ticketPrice); @@ -764,9 +775,13 @@ struct RL : public ContractBase */ uint64 winnersCounter = 0; + /** + * @brief Date/time guard for draw operations. + * lastDrawDateStamp prevents more than one action per calendar day (UTC). + */ uint8 lastDrawDay = RL_INVALID_DAY; uint8 lastDrawHour = RL_INVALID_HOUR; - uint32 lastDrawDateStamp = 0; // calendar day marker to prevent multiple actions per day + uint32 lastDrawDateStamp = 0; // Compact YYYY/MM/DD marker /** * @brief Percentage of the revenue allocated to the team. @@ -792,20 +807,28 @@ struct RL : public ContractBase */ uint8 burnPercent = 0; + /** + * @brief Schedule bitmask: bit 0 = WEDNESDAY, 1 = THURSDAY, ..., 6 = TUESDAY. + * If a bit is set, a draw may occur on that day (subject to drawHour and daily guard). + * Wednesday also follows the "Two-Wednesdays rule" (selling stays closed after Wednesday draw). + */ uint8 schedule = 0; + /** + * @brief UTC hour [0..23] when a draw is allowed to run (daily time gate). + */ uint8 drawHour = 0; /** * @brief Current state of the lottery contract. - * Can be either SELLING (tickets available) or LOCKED (epoch closed). + * SELLING: tickets available; LOCKED: selling closed. */ EState currentState = EState::LOCKED; protected: static void clearStateOnEndEpoch(RL& state) { - // Prepare for next epoch: clear players and apply deferred price if any + // Prepare for next epoch: clear players and reset daily guards state.playerCounter = 0; state.lastDrawHour = RL_INVALID_HOUR; @@ -815,18 +838,20 @@ struct RL : public ContractBase static void clearStateOnEndDraw(RL& state) { - // Prepare for next draw period: clear players + // After each draw period, clear current tickets state.playerCounter = 0; } static void applyNextEpochData(RL& state) { + // Apply deferred ticket price (if any) if (state.nexEpochData.newPrice != 0) { state.ticketPrice = state.nexEpochData.newPrice; state.nexEpochData.newPrice = 0; } + // Apply deferred schedule (if any) if (state.nexEpochData.schedule != 0) { state.schedule = state.nexEpochData.schedule; diff --git a/test/contract_rl.cpp b/test/contract_rl.cpp index 774c45093..d262ac4da 100644 --- a/test/contract_rl.cpp +++ b/test/contract_rl.cpp @@ -23,13 +23,14 @@ static const id RL_DEV_ADDRESS = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, constexpr uint8 RL_ANY_DAY_DRAW_SCHEDULE = 0xFF; // Equality operator for comparing WinnerInfo objects +// Compares all fields (address, revenue, epoch, tick, dayOfWeek) bool operator==(const RL::WinnerInfo& left, const RL::WinnerInfo& right) { return left.winnerAddress == right.winnerAddress && left.revenue == right.revenue && left.epoch == right.epoch && left.tick == right.tick && left.dayOfWeek == right.dayOfWeek; } -// Test helper that exposes internal state assertions +// Test helper that exposes internal state assertions and utilities class RLChecker : public RL { public: @@ -175,6 +176,7 @@ class ContractTestingRL : protected ContractTesting } // Wrapper for public function RL::GetBalance + // Returns current contract on-chain balance (incoming - outgoing) RL::GetBalance_output getBalanceInfo() { RL::GetBalance_input input; @@ -290,7 +292,7 @@ class ContractTestingRL : protected ContractTesting updateQpiTime(); } - // New: set full date and hour + // New: set full date and hour (UTC), then sync QPI time void setDateTime(uint16 year, uint8 month, uint8 day, uint8 hour) { updateTime(); @@ -304,7 +306,7 @@ class ContractTestingRL : protected ContractTesting updateQpiTime(); } - // New: perform many BEGIN_TICK calls to ensure one execution when tick % 100 == 0 + // New: advance to the next tick boundary where tick % RL_TICK_UPDATE_PERIOD == 0 and run BEGIN_TICK once void forceBeginTick() { system.tick = system.tick + (RL_TICK_UPDATE_PERIOD - mod(system.tick, static_cast(RL_TICK_UPDATE_PERIOD))); @@ -312,7 +314,7 @@ class ContractTestingRL : protected ContractTesting BeginTick(); } - // New: helper to advance one day ahead and try to draw at 12:00 + // New: helper to advance one calendar day and perform a scheduled draw at 12:00 UTC void advanceOneDayAndDraw() { // Use a safe base month to avoid invalid dates: January 2025 @@ -329,13 +331,11 @@ class ContractTestingRL : protected ContractTesting forceBeginTick(); } + // Force schedule mask directly in state (bypasses external call, suitable for tests) void forceSchedule(uint8 scheduleMask) { state()->setScheduleMask(scheduleMask); - // increaseEnergy(RL_DEV_ADDRESS, 1); - // BeginEpoch(); - // EXPECT_EQ(setSchedule(RL_DEV_ADDRESS, scheduleMask).returnCode, static_cast(RL::EReturnCode::SUCCESS)); - // EndEpoch(); + // NOTE: we do not call SetSchedule here to avoid epoch transitions in tests. } }; @@ -438,7 +438,7 @@ TEST(ContractRandomLottery, BuyTicket) EXPECT_EQ(ctl.state()->getPlayerCounter(), userCount * 2); } -// Updated: payout is triggered by BEGIN_TICK with schedule/time, not by END_EPOCH +// Updated: payout is triggered by BEGIN_TICK with schedule/time gating, not by END_EPOCH TEST(ContractRandomLottery, DrawAndPayout_BeginTick) { ContractTestingRL ctl; @@ -456,7 +456,7 @@ TEST(ContractRandomLottery, DrawAndPayout_BeginTick) // Ensure schedule allows draw any day ctl.forceSchedule(RL_ANY_DAY_DRAW_SCHEDULE); - // --- Scenario 1: No players (should just clear silently) --- + // --- Scenario 1: No players (nothing to payout, no winner recorded) --- { ctl.BeginEpoch(); EXPECT_EQ(ctl.state()->getPlayerCounter(), 0u); @@ -498,7 +498,7 @@ TEST(ContractRandomLottery, DrawAndPayout_BeginTick) EXPECT_EQ(winners.winnersCounter, winnersBeforeCount); } - // --- Scenario 3: Multiple players (winner chosen, fees processed, remainder burned) --- + // --- Scenario 3: Multiple players (winner chosen, fees processed, correct remaining on contract) --- { ctl.BeginEpoch(); @@ -644,7 +644,7 @@ TEST(ContractRandomLottery, DrawAndPayout_BeginTick) EXPECT_EQ(b, expected); } - // Team fee for the whole contract balance of the round + // Team fee for the whole round's contract balance const uint64 teamFee = (contractBefore * teamPercent) / 100; teamAccrued += teamFee; EXPECT_EQ(getBalance(RL_DEV_ADDRESS), teamBalBeforeRound + teamFee); @@ -987,7 +987,7 @@ TEST(ContractRandomLottery, GetSchedule_And_SetSchedule) { ContractTestingRL ctl; - // Default schedule set on initialize: Wednesday bit must be set + // Default schedule set on initialize must include Wednesday (bit 0) const RL::GetSchedule_output s0 = ctl.getSchedule(); EXPECT_NE(s0.schedule, 0u); @@ -1003,7 +1003,7 @@ TEST(ContractRandomLottery, GetSchedule_And_SetSchedule) EXPECT_EQ(outInvalid.returnCode, static_cast(RL::EReturnCode::INVALID_VALUE)); // Valid update queues into NextEpochData and applies after END_EPOCH - const uint8 newMask = 0x5A; // some non-zero mask + const uint8 newMask = 0x5A; // some non-zero mask (bits set for selected days) const RL::SetSchedule_output outOk = ctl.setSchedule(RL_DEV_ADDRESS, newMask); EXPECT_EQ(outOk.returnCode, static_cast(RL::EReturnCode::SUCCESS)); EXPECT_EQ(ctl.getNextEpochData().nextEpochData.schedule, newMask); @@ -1021,10 +1021,10 @@ TEST(ContractRandomLottery, GetDrawHour_DefaultAfterBeginEpoch) { ContractTestingRL ctl; - // Initially drawHour is 0 + // Initially drawHour is 0 (not configured) EXPECT_EQ(ctl.getDrawHour().drawHour, 0u); - // After BeginEpoch default is 11 + // After BeginEpoch default is 11 UTC ctl.BeginEpoch(); - EXPECT_EQ(ctl.getDrawHour().drawHour, 11u); + EXPECT_EQ(ctl.getDrawHour().drawHour, RL_DEFAULT_DRAW_HOUR); } From 57a3fc0b409d4327502e60156700bf44e24fd4ee Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 30 Oct 2025 00:23:36 +0300 Subject: [PATCH 13/22] Refactor winner management logic; improve comments and introduce getWinnerCounter utility function for better clarity and maintainability --- src/contracts/RandomLottery.h | 17 ++++++++++------- test/contract_rl.cpp | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index 07d7a1e32..8f3759737 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -193,6 +193,7 @@ struct RL : public ContractBase struct FillWinnersInfo_locals { WinnerInfo winnerInfo = {}; // Temporary buffer to compose a WinnerInfo record + uint64 insertIdx = 0; // Index in ring buffer where to insert new winner }; struct GetWinners_input @@ -483,8 +484,7 @@ struct RL : public ContractBase } else { - // Epoch revenue = incoming - outgoing for this contract - qpi.getEntity(SELF, locals.entity); + // Current contract net balance = incoming - outgoing for this contract RLUtils::getSCRevenue(qpi, locals.entity, locals.revenue); // Winner selection (pseudo-random using K12(prevSpectrumDigest)). @@ -567,7 +567,7 @@ struct RL : public ContractBase PUBLIC_FUNCTION(GetWinners) { output.winners = state.winners; - output.winnersCounter = RLUtils::min(state.winnersCounter, state.winners.capacity()); + getWinnerCounter(state, output.winnersCounter); } PUBLIC_FUNCTION(GetTicketPrice) { output.ticketPrice = state.ticketPrice; } @@ -707,16 +707,17 @@ struct RL : public ContractBase return; // Nothing to store } - // Use ring-buffer indexing to avoid overflow (overwrite oldest entries) - state.winnersCounter = mod(state.winnersCounter, state.winners.capacity()); + // Compute ring-buffer index without clamping the total counter + getWinnerCounter(state, locals.insertIdx); + ++state.winnersCounter; locals.winnerInfo.winnerAddress = input.winnerAddress; locals.winnerInfo.revenue = input.revenue; locals.winnerInfo.epoch = qpi.epoch(); locals.winnerInfo.tick = qpi.tick(); RLUtils::getCurrentDayOfWeek(qpi, locals.winnerInfo.dayOfWeek); - state.winners.set(state.winnersCounter, locals.winnerInfo); - ++state.winnersCounter; + + state.winners.set(locals.insertIdx, locals.winnerInfo); } PRIVATE_PROCEDURE_WITH_LOCALS(ReturnAllTickets) @@ -876,4 +877,6 @@ struct RL : public ContractBase // Index directly into players array winnerAddress = state.players.get(randomNum); } + + static void getWinnerCounter(const RL& state, uint64& outCounter) { outCounter = mod(state.winnersCounter, state.winners.capacity()); } }; diff --git a/test/contract_rl.cpp b/test/contract_rl.cpp index d262ac4da..c213f1601 100644 --- a/test/contract_rl.cpp +++ b/test/contract_rl.cpp @@ -20,7 +20,7 @@ constexpr uint16 FUNCTION_INDEX_GET_SCHEDULE = 10; static const id RL_DEV_ADDRESS = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); -constexpr uint8 RL_ANY_DAY_DRAW_SCHEDULE = 0xFF; +constexpr uint8 RL_ANY_DAY_DRAW_SCHEDULE = 0xFF; // 0xFF sets bits 0..6 (WED..TUE); bit 7 is unused/ignored by logic // Equality operator for comparing WinnerInfo objects // Compares all fields (address, revenue, epoch, tick, dayOfWeek) From 8a6d47eb87cd92508a8b3e5e38c837ea89609a34 Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 30 Oct 2025 00:31:25 +0300 Subject: [PATCH 14/22] Update winnersCounter calculation to reflect valid entries based on capacity; enhance test expectations for accuracy --- src/contracts/RandomLottery.h | 2 +- test/contract_rl.cpp | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index 8f3759737..b285d67f7 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -203,7 +203,7 @@ struct RL : public ContractBase struct GetWinners_output { Array winners; // Ring buffer snapshot - uint64 winnersCounter = 0; // Number of valid entries (bounded by capacity) + uint64 winnersCounter = 0; // Number of valid entries = (totalWinners % capacity) uint8 returnCode = static_cast(EReturnCode::SUCCESS); }; diff --git a/test/contract_rl.cpp b/test/contract_rl.cpp index c213f1601..539623a4f 100644 --- a/test/contract_rl.cpp +++ b/test/contract_rl.cpp @@ -62,9 +62,11 @@ class RLChecker : public RL { EXPECT_EQ(output.returnCode, static_cast(EReturnCode::SUCCESS)); EXPECT_EQ(output.winners.capacity(), winners.capacity()); - EXPECT_EQ(output.winnersCounter, winnersCounter); - for (uint64 i = 0; i < winnersCounter; ++i) + const uint64 expectedCount = mod(winnersCounter, winners.capacity()); + EXPECT_EQ(output.winnersCounter, expectedCount); + + for (uint64 i = 0; i < expectedCount; ++i) { EXPECT_EQ(output.winners.get(i), winners.get(i)); } From 4b20e8d6e9645b7c048b44a5bc3cb4bab3670cca Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 30 Oct 2025 00:40:04 +0300 Subject: [PATCH 15/22] Fix parameter type in getRandomPlayer function to use QpiContextProcedureCall for consistency --- src/contracts/RandomLottery.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index b285d67f7..0c513f457 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -862,7 +862,7 @@ struct RL : public ContractBase static void enableBuyTicket(RL& state, bool bEnable) { state.currentState = bEnable ? EState::SELLING : EState::LOCKED; } - static void getRandomPlayer(const RL& state, const QpiContextFunctionCall& qpi, uint64& randomNum, id& winnerAddress) + static void getRandomPlayer(const RL& state, const QpiContextProcedureCall& qpi, uint64& randomNum, id& winnerAddress) { winnerAddress = id::zero(); From 80db11807f0943af922be985da6137c97f35cbf8 Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 30 Oct 2025 00:46:32 +0300 Subject: [PATCH 16/22] Refactor winner selection logic; inline getRandomPlayer functionality for improved clarity and maintainability --- src/contracts/RandomLottery.h | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index 0c513f457..7e85a977b 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -488,7 +488,18 @@ struct RL : public ContractBase RLUtils::getSCRevenue(qpi, locals.entity, locals.revenue); // Winner selection (pseudo-random using K12(prevSpectrumDigest)). - getRandomPlayer(state, qpi, locals.randomNum, locals.winnerAddress); + { + locals.winnerAddress = id::zero(); + + if (state.playerCounter != 0) + { + // Compute pseudo-random index based on K12(prevSpectrumDigest) + locals.randomNum = mod(qpi.K12(qpi.getPrevSpectrumDigest()).u64._0, state.playerCounter); + + // Index directly into players array + locals.winnerAddress = state.players.get(locals.randomNum); + } + } if (locals.winnerAddress != id::zero()) { @@ -862,21 +873,5 @@ struct RL : public ContractBase static void enableBuyTicket(RL& state, bool bEnable) { state.currentState = bEnable ? EState::SELLING : EState::LOCKED; } - static void getRandomPlayer(const RL& state, const QpiContextProcedureCall& qpi, uint64& randomNum, id& winnerAddress) - { - winnerAddress = id::zero(); - - if (state.playerCounter == 0) - { - return; - } - - // Compute pseudo-random index based on K12(prevSpectrumDigest) - randomNum = mod(qpi.K12(qpi.getPrevSpectrumDigest()).u64._0, state.playerCounter); - - // Index directly into players array - winnerAddress = state.players.get(randomNum); - } - static void getWinnerCounter(const RL& state, uint64& outCounter) { outCounter = mod(state.winnersCounter, state.winners.capacity()); } }; From 7324060e6eeda9a31b0c456c8515f4a7e947e676 Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 30 Oct 2025 01:13:34 +0300 Subject: [PATCH 17/22] Refactor date and revenue utility functions; simplify parameters and improve clarity in usage --- src/contracts/RandomLottery.h | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index 7e85a977b..fe10ebf37 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -51,22 +51,15 @@ constexpr uint8 RL_DEFAULT_DRAW_HOUR = 11; // 11:00 UTC namespace RLUtils { - // Returns current day-of-week in range [0..6], with 0 = WEDNESDAY according to platform mapping. - static void getCurrentDayOfWeek(const QpiContextFunctionCall& qpi, uint8& dayOfWeek) - { - dayOfWeek = qpi.dayOfWeek(qpi.year(), qpi.month(), qpi.day()); - } - // Packs current date into a compact stamp (Y/M/D) used to ensure a single action per calendar day. - static void makeDateStamp(const QpiContextFunctionCall& qpi, uint32& res) + static void makeDateStamp(uint8 year, uint8 month, uint8 day, uint32& res) { - res = static_cast(qpi.year() << 9 | qpi.month() << 5 | qpi.day()); + res = static_cast(year << 9 | month << 5 | day); } // Reads current net on-chain balance of SELF (incoming - outgoing). - static void getSCRevenue(const QpiContextFunctionCall& qpi, Entity& entity, uint64& revenue) + static void getSCRevenue(const Entity& entity, uint64& revenue) { - qpi.getEntity(SELF, entity); revenue = entity.incomingAmount - entity.outgoingAmount; } @@ -415,9 +408,9 @@ struct RL : public ContractBase } // Mark the current date as already processed to avoid immediate draw on the same calendar day - RLUtils::getCurrentDayOfWeek(qpi, state.lastDrawDay); + state.lastDrawDay = qpi.dayOfWeek(qpi.year(), qpi.month(), qpi.day()); state.lastDrawHour = state.drawHour; - RLUtils::makeDateStamp(qpi, state.lastDrawDateStamp); + RLUtils::makeDateStamp(qpi.year(), qpi.month(), qpi.day(), state.lastDrawDateStamp); // Open selling for the new epoch enableBuyTicket(state, true); @@ -440,7 +433,7 @@ struct RL : public ContractBase } // Snapshot current day/hour - RLUtils::getCurrentDayOfWeek(qpi, locals.currentDayOfWeek); + locals.currentDayOfWeek = qpi.dayOfWeek(qpi.year(), qpi.month(), qpi.day()); locals.currentHour = qpi.hour(); // Do nothing before the configured draw hour @@ -450,7 +443,7 @@ struct RL : public ContractBase } // Ensure only one action per calendar day (UTC) - RLUtils::makeDateStamp(qpi, locals.currentDateStamp); + RLUtils::makeDateStamp(qpi.year(), qpi.month(), qpi.day(), locals.currentDateStamp); if (state.lastDrawDateStamp == locals.currentDateStamp) { return; @@ -485,7 +478,8 @@ struct RL : public ContractBase else { // Current contract net balance = incoming - outgoing for this contract - RLUtils::getSCRevenue(qpi, locals.entity, locals.revenue); + qpi.getEntity(SELF, locals.entity); + RLUtils::getSCRevenue(locals.entity, locals.revenue); // Winner selection (pseudo-random using K12(prevSpectrumDigest)). { @@ -587,7 +581,11 @@ struct RL : public ContractBase PUBLIC_FUNCTION(GetNextEpochData) { output.nextEpochData = state.nexEpochData; } PUBLIC_FUNCTION(GetDrawHour) { output.drawHour = state.drawHour; } PUBLIC_FUNCTION(GetSchedule) { output.schedule = state.schedule; } - PUBLIC_FUNCTION_WITH_LOCALS(GetBalance) { RLUtils::getSCRevenue(qpi, locals.entity, output.balance); } + PUBLIC_FUNCTION_WITH_LOCALS(GetBalance) + { + qpi.getEntity(SELF, locals.entity); + RLUtils::getSCRevenue(locals.entity, output.balance); + } PUBLIC_PROCEDURE(SetPrice) { @@ -726,7 +724,7 @@ struct RL : public ContractBase locals.winnerInfo.revenue = input.revenue; locals.winnerInfo.epoch = qpi.epoch(); locals.winnerInfo.tick = qpi.tick(); - RLUtils::getCurrentDayOfWeek(qpi, locals.winnerInfo.dayOfWeek); + locals.winnerInfo.dayOfWeek = qpi.dayOfWeek(qpi.year(), qpi.month(), qpi.day()); state.winners.set(locals.insertIdx, locals.winnerInfo); } From 27e1275deb6028785d1aaff26b6e4913a8252ab4 Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 30 Oct 2025 01:19:49 +0300 Subject: [PATCH 18/22] Refactor RLUtils namespace; inline utility functions for date stamping and revenue calculation to improve clarity and maintainability --- src/contracts/RandomLottery.h | 42 +++++++++++++---------------------- 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index fe10ebf37..e322d9990 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -49,26 +49,6 @@ constexpr uint8 RL_TICK_UPDATE_PERIOD = 100; /// Default draw hour (UTC). constexpr uint8 RL_DEFAULT_DRAW_HOUR = 11; // 11:00 UTC -namespace RLUtils -{ - // Packs current date into a compact stamp (Y/M/D) used to ensure a single action per calendar day. - static void makeDateStamp(uint8 year, uint8 month, uint8 day, uint32& res) - { - res = static_cast(year << 9 | month << 5 | day); - } - - // Reads current net on-chain balance of SELF (incoming - outgoing). - static void getSCRevenue(const Entity& entity, uint64& revenue) - { - revenue = entity.incomingAmount - entity.outgoingAmount; - } - - template static constexpr const T& min(const T& a, const T& b) - { - return (a < b) ? a : b; - } -}; // namespace RLUtils - /// Placeholder structure for future extensions. struct RL2 { @@ -410,7 +390,7 @@ struct RL : public ContractBase // Mark the current date as already processed to avoid immediate draw on the same calendar day state.lastDrawDay = qpi.dayOfWeek(qpi.year(), qpi.month(), qpi.day()); state.lastDrawHour = state.drawHour; - RLUtils::makeDateStamp(qpi.year(), qpi.month(), qpi.day(), state.lastDrawDateStamp); + makeDateStamp(qpi.year(), qpi.month(), qpi.day(), state.lastDrawDateStamp); // Open selling for the new epoch enableBuyTicket(state, true); @@ -443,7 +423,7 @@ struct RL : public ContractBase } // Ensure only one action per calendar day (UTC) - RLUtils::makeDateStamp(qpi.year(), qpi.month(), qpi.day(), locals.currentDateStamp); + makeDateStamp(qpi.year(), qpi.month(), qpi.day(), locals.currentDateStamp); if (state.lastDrawDateStamp == locals.currentDateStamp) { return; @@ -479,7 +459,7 @@ struct RL : public ContractBase { // Current contract net balance = incoming - outgoing for this contract qpi.getEntity(SELF, locals.entity); - RLUtils::getSCRevenue(locals.entity, locals.revenue); + getSCRevenue(locals.entity, locals.revenue); // Winner selection (pseudo-random using K12(prevSpectrumDigest)). { @@ -563,7 +543,7 @@ struct RL : public ContractBase PUBLIC_FUNCTION(GetPlayers) { output.players = state.players; - output.playerCounter = RLUtils::min(state.playerCounter, state.players.capacity()); + output.playerCounter = min(state.playerCounter, state.players.capacity()); } /** @@ -584,7 +564,7 @@ struct RL : public ContractBase PUBLIC_FUNCTION_WITH_LOCALS(GetBalance) { qpi.getEntity(SELF, locals.entity); - RLUtils::getSCRevenue(locals.entity, output.balance); + getSCRevenue(locals.entity, output.balance); } PUBLIC_PROCEDURE(SetPrice) @@ -681,7 +661,7 @@ struct RL : public ContractBase // Compute desired number of tickets and change locals.desired = div(locals.reward, locals.price); // How many tickets the caller attempts to buy locals.remainder = mod(locals.reward, locals.price); // Change to return - locals.toBuy = RLUtils::min(locals.desired, locals.slotsLeft); // Do not exceed available slots + locals.toBuy = min(locals.desired, locals.slotsLeft); // Do not exceed available slots // Add tickets (the same address may be inserted multiple times) for (locals.i = 0; locals.i < locals.toBuy; ++locals.i) @@ -689,7 +669,7 @@ struct RL : public ContractBase if (state.playerCounter < locals.capacity) { state.players.set(state.playerCounter, qpi.invocator()); - state.playerCounter = RLUtils::min(state.playerCounter + 1, locals.capacity); + state.playerCounter = min(state.playerCounter + 1, locals.capacity); } } @@ -872,4 +852,12 @@ struct RL : public ContractBase static void enableBuyTicket(RL& state, bool bEnable) { state.currentState = bEnable ? EState::SELLING : EState::LOCKED; } static void getWinnerCounter(const RL& state, uint64& outCounter) { outCounter = mod(state.winnersCounter, state.winners.capacity()); } + + // Packs current date into a compact stamp (Y/M/D) used to ensure a single action per calendar day. + static void makeDateStamp(uint8 year, uint8 month, uint8 day, uint32& res) { res = static_cast(year << 9 | month << 5 | day); } + + // Reads current net on-chain balance of SELF (incoming - outgoing). + static void getSCRevenue(const Entity& entity, uint64& revenue) { revenue = entity.incomingAmount - entity.outgoingAmount; } + + template static constexpr const T& min(const T& a, const T& b) { return (a < b) ? a : b; } }; From 5ed2168744446058c5486ac1fcf0aaf9fd6f0487 Mon Sep 17 00:00:00 2001 From: N-010 Date: Fri, 31 Oct 2025 19:01:55 +0300 Subject: [PATCH 19/22] Refactor data structures in RandomLottery.h; remove default initializations for clarity and consistency --- src/contracts/RandomLottery.h | 130 +++++++++++++++++----------------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index e322d9990..becf4f3e9 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -102,8 +102,8 @@ struct RL : public ContractBase struct NextEpochData { - uint64 newPrice = 0; // Ticket price to apply after END_EPOCH; 0 means "no change queued" - uint8 schedule = 0; // Schedule bitmask (bit 0 = WEDNESDAY, ..., bit 6 = TUESDAY); applied after END_EPOCH + uint64 newPrice; // Ticket price to apply after END_EPOCH; 0 means "no change queued" + uint8 schedule; // Schedule bitmask (bit 0 = WEDNESDAY, ..., bit 6 = TUESDAY); applied after END_EPOCH }; //---- User-facing I/O structures ------------------------------------------------------------- @@ -114,7 +114,7 @@ struct RL : public ContractBase struct BuyTicket_output { - uint8 returnCode = static_cast(EReturnCode::SUCCESS); + uint8 returnCode; }; struct GetFees_input @@ -123,11 +123,11 @@ struct RL : public ContractBase struct GetFees_output { - uint8 teamFeePercent = 0; // Team share in percent - uint8 distributionFeePercent = 0; // Distribution/shareholders share in percent - uint8 winnerFeePercent = 0; // Winner share in percent - uint8 burnPercent = 0; // Burn share in percent - uint8 returnCode = static_cast(EReturnCode::SUCCESS); + uint8 teamFeePercent; // Team share in percent + uint8 distributionFeePercent; // Distribution/shareholders share in percent + uint8 winnerFeePercent; // Winner share in percent + uint8 burnPercent; // Burn share in percent + uint8 returnCode; }; struct GetPlayers_input @@ -137,8 +137,8 @@ struct RL : public ContractBase struct GetPlayers_output { Array players; // Current epoch ticket holders (duplicates allowed) - uint64 playerCounter = 0; // Actual count of filled entries - uint8 returnCode = static_cast(EReturnCode::SUCCESS); + uint64 playerCounter; // Actual count of filled entries + uint8 returnCode; }; /** @@ -146,17 +146,17 @@ struct RL : public ContractBase */ struct WinnerInfo { - id winnerAddress = id::zero(); // Winner address - uint64 revenue = 0; // Payout value sent to the winner for that epoch - uint32 tick = 0; // Tick when the decision was made - uint16 epoch = 0; // Epoch number when winner was recorded - uint8 dayOfWeek = RL_INVALID_DAY; // Day of week when the winner was drawn [0..6] 0 = WEDNESDAY + id winnerAddress; // Winner address + uint64 revenue; // Payout value sent to the winner for that epoch + uint32 tick; // Tick when the decision was made + uint16 epoch; // Epoch number when winner was recorded + uint8 dayOfWeek; // Day of week when the winner was drawn [0..6] 0 = WEDNESDAY }; struct FillWinnersInfo_input { - id winnerAddress = id::zero(); // Winner address to store - uint64 revenue = 0; // Winner payout to store + id winnerAddress; // Winner address to store + uint64 revenue; // Winner payout to store }; struct FillWinnersInfo_output @@ -165,8 +165,8 @@ struct RL : public ContractBase struct FillWinnersInfo_locals { - WinnerInfo winnerInfo = {}; // Temporary buffer to compose a WinnerInfo record - uint64 insertIdx = 0; // Index in ring buffer where to insert new winner + WinnerInfo winnerInfo; // Temporary buffer to compose a WinnerInfo record + uint64 insertIdx; // Index in ring buffer where to insert new winner }; struct GetWinners_input @@ -176,8 +176,8 @@ struct RL : public ContractBase struct GetWinners_output { Array winners; // Ring buffer snapshot - uint64 winnersCounter = 0; // Number of valid entries = (totalWinners % capacity) - uint8 returnCode = static_cast(EReturnCode::SUCCESS); + uint64 winnersCounter; // Number of valid entries = (totalWinners % capacity) + uint8 returnCode; }; struct GetTicketPrice_input @@ -186,7 +186,7 @@ struct RL : public ContractBase struct GetTicketPrice_output { - uint64 ticketPrice = 0; // Current ticket price + uint64 ticketPrice; // Current ticket price }; struct GetMaxNumberOfPlayers_input @@ -195,7 +195,7 @@ struct RL : public ContractBase struct GetMaxNumberOfPlayers_output { - uint64 numberOfPlayers = 0; // Max capacity of players array + uint64 numberOfPlayers; // Max capacity of players array }; struct GetState_input @@ -204,7 +204,7 @@ struct RL : public ContractBase struct GetState_output { - uint8 currentState = static_cast(EState::INVALID); // Current finite state of the lottery + uint8 currentState; // Current finite state of the lottery }; struct GetBalance_input @@ -213,28 +213,28 @@ struct RL : public ContractBase struct GetBalance_output { - uint64 balance = 0; // Current contract net balance (incoming - outgoing) + uint64 balance; // Current contract net balance (incoming - outgoing) }; // Local variables for GetBalance procedure struct GetBalance_locals { - Entity entity = {}; // Entity accounting snapshot for SELF + Entity entity; // Entity accounting snapshot for SELF }; // Local variables for BuyTicket procedure struct BuyTicket_locals { - uint64 price = 0; // Current ticket price - uint64 reward = 0; // Funds sent with call (invocationReward) - uint64 capacity = 0; // Max capacity of players array - uint64 slotsLeft = 0; // Remaining slots available to fill this epoch - uint64 desired = 0; // How many tickets the caller wants to buy - uint64 remainder = 0; // Change to return (reward % price) - uint64 toBuy = 0; // Actual number of tickets to purchase (bounded by slotsLeft) - uint64 unfilled = 0; // Portion of desired tickets not purchased due to capacity limit - uint64 refundAmount = 0; // Total refund: remainder + unfilled * price - uint64 i = 0; // Loop counter + uint64 price; // Current ticket price + uint64 reward; // Funds sent with call (invocationReward) + uint64 capacity; // Max capacity of players array + uint64 slotsLeft; // Remaining slots available to fill this epoch + uint64 desired; // How many tickets the caller wants to buy + uint64 remainder; // Change to return (reward % price) + uint64 toBuy; // Actual number of tickets to purchase (bounded by slotsLeft) + uint64 unfilled; // Portion of desired tickets not purchased due to capacity limit + uint64 refundAmount; // Total refund: remainder + unfilled * price + uint64 i; // Loop counter }; struct ReturnAllTickets_input @@ -246,50 +246,50 @@ struct RL : public ContractBase struct ReturnAllTickets_locals { - uint64 i = 0; // Loop counter for mass-refund + uint64 i; // Loop counter for mass-refund }; struct SetPrice_input { - uint64 newPrice = 0; // New ticket price to be applied at the end of the epoch + uint64 newPrice; // New ticket price to be applied at the end of the epoch }; struct SetPrice_output { - uint8 returnCode = static_cast(EReturnCode::SUCCESS); + uint8 returnCode; }; struct SetSchedule_input { - uint8 newSchedule = 0; // New schedule bitmask to be applied at the end of the epoch + uint8 newSchedule; // New schedule bitmask to be applied at the end of the epoch }; struct SetSchedule_output { - uint8 returnCode = static_cast(EReturnCode::SUCCESS); + uint8 returnCode; }; struct BEGIN_TICK_locals { - id winnerAddress = id::zero(); - Entity entity = {}; - uint64 revenue = 0; - uint64 randomNum = 0; - uint64 winnerAmount = 0; - uint64 teamFee = 0; - uint64 distributionFee = 0; - uint64 burnedAmount = 0; - FillWinnersInfo_locals fillWinnersInfoLocals = {}; - FillWinnersInfo_input fillWinnersInfoInput = {}; - uint32 currentDateStamp = 0; - uint8 currentDayOfWeek = RL_INVALID_DAY; - uint8 currentHour = RL_INVALID_HOUR; - uint8 isWednesday = 0; - uint8 isScheduledToday = 0; - ReturnAllTickets_locals returnAllTicketsLocals = {}; - ReturnAllTickets_input returnAllTicketsInput = {}; - ReturnAllTickets_output returnAllTicketsOutput = {}; - FillWinnersInfo_output fillWinnersInfoOutput = {}; + id winnerAddress; + Entity entity; + uint64 revenue; + uint64 randomNum; + uint64 winnerAmount; + uint64 teamFee; + uint64 distributionFee; + uint64 burnedAmount; + FillWinnersInfo_locals fillWinnersInfoLocals; + FillWinnersInfo_input fillWinnersInfoInput; + uint32 currentDateStamp; + uint8 currentDayOfWeek; + uint8 currentHour; + uint8 isWednesday; + uint8 isScheduledToday; + ReturnAllTickets_locals returnAllTicketsLocals; + ReturnAllTickets_input returnAllTicketsInput; + ReturnAllTickets_output returnAllTicketsOutput; + FillWinnersInfo_output fillWinnersInfoOutput; }; struct GetNextEpochData_input @@ -298,7 +298,7 @@ struct RL : public ContractBase struct GetNextEpochData_output { - NextEpochData nextEpochData = {}; + NextEpochData nextEpochData; }; struct GetDrawHour_input @@ -307,7 +307,7 @@ struct RL : public ContractBase struct GetDrawHour_output { - uint8 drawHour = RL_INVALID_HOUR; + uint8 drawHour; }; // New: expose current schedule mask @@ -316,7 +316,7 @@ struct RL : public ContractBase }; struct GetSchedule_output { - uint8 schedule = 0; + uint8 schedule; }; public: @@ -659,8 +659,8 @@ struct RL : public ContractBase } // Compute desired number of tickets and change - locals.desired = div(locals.reward, locals.price); // How many tickets the caller attempts to buy - locals.remainder = mod(locals.reward, locals.price); // Change to return + locals.desired = div(locals.reward, locals.price); // How many tickets the caller attempts to buy + locals.remainder = mod(locals.reward, locals.price); // Change to return locals.toBuy = min(locals.desired, locals.slotsLeft); // Do not exceed available slots // Add tickets (the same address may be inserted multiple times) From 7ef9f4b4806c1e27ecd46f6c23507081201fe28e Mon Sep 17 00:00:00 2001 From: N-010 Date: Fri, 31 Oct 2025 23:30:38 +0300 Subject: [PATCH 20/22] Add default schedule for lottery draws; replace hardcoded values with RL_DEFAULT_SCHEDULE for clarity and maintainability --- src/contracts/RandomLottery.h | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index becf4f3e9..3758498f5 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -49,6 +49,8 @@ constexpr uint8 RL_TICK_UPDATE_PERIOD = 100; /// Default draw hour (UTC). constexpr uint8 RL_DEFAULT_DRAW_HOUR = 11; // 11:00 UTC +constexpr uint8 RL_DEFAULT_SCHEDULE = 1 << WEDNESDAY | 1 << FRIDAY | 1 << SUNDAY; // Draws on WED, FRI, SUN + /// Placeholder structure for future extensions. struct RL2 { @@ -368,7 +370,7 @@ struct RL : public ContractBase state.playerCounter = 0; // Default schedule: WEDNESDAY - state.schedule = 1 << WEDNESDAY; + state.schedule = RL_DEFAULT_SCHEDULE; } /** @@ -379,7 +381,7 @@ struct RL : public ContractBase if (state.schedule == 0) { // Default to WEDNESDAY if no schedule is set (bit 0) - state.schedule = 1 << WEDNESDAY; + state.schedule = RL_DEFAULT_SCHEDULE; } if (state.drawHour == 0) From 6a68ce8501d4794e99025af69b80a803a6006b18 Mon Sep 17 00:00:00 2001 From: N-010 Date: Mon, 3 Nov 2025 13:59:06 +0300 Subject: [PATCH 21/22] Update RandomLottery.h Remove initialize --- src/contracts/RandomLottery.h | 36 +++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index 3758498f5..74fbf9047 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -725,97 +725,97 @@ struct RL : public ContractBase * @brief Circular buffer storing the history of winners. * Maximum capacity is defined by RL_MAX_NUMBER_OF_WINNERS_IN_HISTORY. */ - Array winners = {}; + Array winners; /** * @brief Set of players participating in the current lottery epoch. * Maximum capacity is defined by RL_MAX_NUMBER_OF_PLAYERS. */ - Array players = {}; + Array players; /** * @brief Address of the team managing the lottery contract. * Initialized to a zero address. */ - id teamAddress = id::zero(); + id teamAddress; /** * @brief Address of the owner of the lottery contract. * Initialized to a zero address. */ - id ownerAddress = id::zero(); + id ownerAddress; /** * @brief Data structure for deferred changes to apply at the end of the epoch. */ - NextEpochData nexEpochData = {}; + NextEpochData nexEpochData; /** * @brief Price of a single lottery ticket. * Value is in the smallest currency unit (e.g., cents). */ - uint64 ticketPrice = 0; + uint64 ticketPrice; /** * @brief Number of players (tickets sold) in the current epoch. */ - uint64 playerCounter = 0; + uint64 playerCounter; /** * @brief Index pointing to the next empty slot in the winners array. * Used for maintaining the circular buffer of winners. */ - uint64 winnersCounter = 0; + uint64 winnersCounter; /** * @brief Date/time guard for draw operations. * lastDrawDateStamp prevents more than one action per calendar day (UTC). */ - uint8 lastDrawDay = RL_INVALID_DAY; - uint8 lastDrawHour = RL_INVALID_HOUR; - uint32 lastDrawDateStamp = 0; // Compact YYYY/MM/DD marker + uint8 lastDrawDay; + uint8 lastDrawHour; + uint32 lastDrawDateStamp; // Compact YYYY/MM/DD marker /** * @brief Percentage of the revenue allocated to the team. * Value is between 0 and 100. */ - uint8 teamFeePercent = 0; + uint8 teamFeePercent; /** * @brief Percentage of the revenue allocated for distribution. * Value is between 0 and 100. */ - uint8 distributionFeePercent = 0; + uint8 distributionFeePercent; /** * @brief Percentage of the revenue allocated to the winner. * Automatically calculated as the remainder after other fees. */ - uint8 winnerFeePercent = 0; + uint8 winnerFeePercent; /** * @brief Percentage of the revenue to be burned. * Value is between 0 and 100. */ - uint8 burnPercent = 0; + uint8 burnPercent; /** * @brief Schedule bitmask: bit 0 = WEDNESDAY, 1 = THURSDAY, ..., 6 = TUESDAY. * If a bit is set, a draw may occur on that day (subject to drawHour and daily guard). * Wednesday also follows the "Two-Wednesdays rule" (selling stays closed after Wednesday draw). */ - uint8 schedule = 0; + uint8 schedule; /** * @brief UTC hour [0..23] when a draw is allowed to run (daily time gate). */ - uint8 drawHour = 0; + uint8 drawHour; /** * @brief Current state of the lottery contract. * SELLING: tickets available; LOCKED: selling closed. */ - EState currentState = EState::LOCKED; + EState currentState; protected: static void clearStateOnEndEpoch(RL& state) From 4b15a0e32a8c278df5f21a73e3f78ca7e2ef34ef Mon Sep 17 00:00:00 2001 From: N-010 Date: Tue, 4 Nov 2025 11:32:09 +0300 Subject: [PATCH 22/22] Reset player states before the next epoch in RandomLottery --- src/contracts/RandomLottery.h | 1 + 1 file changed, 1 insertion(+) diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index 74fbf9047..92a973f6b 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -822,6 +822,7 @@ struct RL : public ContractBase { // Prepare for next epoch: clear players and reset daily guards state.playerCounter = 0; + state.players.setAll(id::zero()); state.lastDrawHour = RL_INVALID_HOUR; state.lastDrawDay = RL_INVALID_DAY;