diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index 448cf0c96..92a973f6b 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -5,19 +5,52 @@ * * 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. + * + * 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. + * - 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; -/// 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; + +/// 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 + +constexpr uint8 RL_DEFAULT_SCHEDULE = 1 << WEDNESDAY | 1 << FRIDAY | 1 << SUNDAY; // Draws on WED, FRI, SUN + /// Placeholder structure for future extensions. struct RL2 { @@ -43,8 +76,10 @@ struct RL : public ContractBase */ enum class EState : uint8 { - SELLING, - LOCKED + SELLING, // Ticket selling is open + LOCKED, // Ticket selling is closed + + INVALID = UINT8_MAX }; /** @@ -52,18 +87,25 @@ struct RL : public ContractBase */ enum class EReturnCode : uint8 { - SUCCESS = 0, + SUCCESS, + // Ticket-related errors - TICKET_INVALID_PRICE = 1, - TICKET_ALREADY_PURCHASED = 2, - TICKET_ALL_SOLD_OUT = 3, - TICKET_SELLING_CLOSED = 4, + 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 = 5, - // Fee-related errors - FEE_INVALID_PERCENT_VALUE = 6, - // Fallback - UNKNOW_ERROR = UINT8_MAX + ACCESS_DENIED, // Caller is not authorized to perform the action + + // Value-related errors + INVALID_VALUE, // Input value is not acceptable + + UNKNOWN_ERROR = UINT8_MAX + }; + + struct NextEpochData + { + 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 ------------------------------------------------------------- @@ -74,7 +116,7 @@ struct RL : public ContractBase struct BuyTicket_output { - uint8 returnCode = static_cast(EReturnCode::SUCCESS); + uint8 returnCode; }; struct GetFees_input @@ -83,11 +125,11 @@ struct RL : public ContractBase struct GetFees_output { - uint8 teamFeePercent = 0; - uint8 distributionFeePercent = 0; - uint8 winnerFeePercent = 0; - uint8 burnPercent = 0; - 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 @@ -96,15 +138,9 @@ struct RL : public ContractBase struct GetPlayers_output { - Array players; - uint16 numberOfPlayers = 0; - uint8 returnCode = static_cast(EReturnCode::SUCCESS); - }; - - struct GetPlayers_locals - { - uint64 arrayIndex = 0; - sint64 i = 0; + Array players; // Current epoch ticket holders (duplicates allowed) + uint64 playerCounter; // Actual count of filled entries + uint8 returnCode; }; /** @@ -112,16 +148,17 @@ struct RL : public ContractBase */ struct WinnerInfo { - id winnerAddress = id::zero(); - uint64 revenue = 0; - uint16 epoch = 0; - uint32 tick = 0; + 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(); - uint64 revenue = 0; + id winnerAddress; // Winner address to store + uint64 revenue; // Winner payout to store }; struct FillWinnersInfo_output @@ -130,35 +167,76 @@ struct RL : public ContractBase struct FillWinnersInfo_locals { - WinnerInfo winnerInfo = {}; + WinnerInfo winnerInfo; // Temporary buffer to compose a WinnerInfo record + uint64 insertIdx; // Index in ring buffer where to insert new winner }; - struct GetWinner_input + struct GetWinners_input { }; - struct GetWinner_output + struct GetWinners_output { - id winnerAddress = id::zero(); - uint64 index = 0; + Array winners; // Ring buffer snapshot + uint64 winnersCounter; // Number of valid entries = (totalWinners % capacity) + uint8 returnCode; }; - struct GetWinner_locals + struct GetTicketPrice_input { - uint64 randomNum = 0; - sint64 i = 0; - uint64 j = 0; }; - struct GetWinners_input + struct GetTicketPrice_output { + uint64 ticketPrice; // Current ticket price }; - struct GetWinners_output + struct GetMaxNumberOfPlayers_input + { + }; + + struct GetMaxNumberOfPlayers_output + { + uint64 numberOfPlayers; // Max capacity of players array + }; + + struct GetState_input { - Array winners; - uint64 numberOfWinners = 0; - uint8 returnCode = static_cast(EReturnCode::SUCCESS); + }; + + struct GetState_output + { + uint8 currentState; // Current finite state of the lottery + }; + + struct GetBalance_input + { + }; + + struct GetBalance_output + { + uint64 balance; // Current contract net balance (incoming - outgoing) + }; + + // Local variables for GetBalance procedure + struct GetBalance_locals + { + Entity entity; // Entity accounting snapshot for SELF + }; + + // Local variables for BuyTicket procedure + struct BuyTicket_locals + { + 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 @@ -170,32 +248,77 @@ struct RL : public ContractBase struct ReturnAllTickets_locals { - sint64 i = 0; + uint64 i; // Loop counter for mass-refund + }; + + struct SetPrice_input + { + uint64 newPrice; // New ticket price to be applied at the end of the epoch + }; + + struct SetPrice_output + { + uint8 returnCode; }; - struct END_EPOCH_locals + struct SetSchedule_input { - GetWinner_input getWinnerInput = {}; - GetWinner_output getWinnerOutput = {}; - GetWinner_locals getWinnerLocals = {}; + uint8 newSchedule; // New schedule bitmask to be applied at the end of the epoch + }; - FillWinnersInfo_input fillWinnersInfoInput = {}; - FillWinnersInfo_output fillWinnersInfoOutput = {}; - FillWinnersInfo_locals fillWinnersInfoLocals = {}; + struct SetSchedule_output + { + uint8 returnCode; + }; - ReturnAllTickets_input returnAllTicketsInput = {}; - ReturnAllTickets_output returnAllTicketsOutput = {}; - ReturnAllTickets_locals returnAllTicketsLocals = {}; + struct BEGIN_TICK_locals + { + 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 + { + }; + + struct GetNextEpochData_output + { + NextEpochData nextEpochData; + }; - uint64 teamFee = 0; - uint64 distributionFee = 0; - uint64 winnerAmount = 0; - uint64 burnedAmount = 0; + struct GetDrawHour_input + { + }; - uint64 revenue = 0; - Entity entity = {}; + struct GetDrawHour_output + { + uint8 drawHour; + }; - sint32 i = 0; + // New: expose current schedule mask + struct GetSchedule_input + { + }; + struct GetSchedule_output + { + uint8 schedule; }; public: @@ -208,7 +331,16 @@ 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_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); } /** @@ -217,97 +349,183 @@ 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); - // Owner address (currently identical to developer address; can be split in future revisions). + // Set team/developer address (owner and team are the same for now) + state.teamAddress = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, + _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); state.ownerAddress = state.teamAddress; - // Default fee percentages (sum <= 100; winner percent derived) - state.teamFeePercent = 10; - state.distributionFeePercent = 20; - state.burnPercent = 2; + // 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 - state.ticketPrice = 1000000; + // 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; + + // Reset player counter + state.playerCounter = 0; + + // Default schedule: WEDNESDAY + state.schedule = RL_DEFAULT_SCHEDULE; } /** * @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 (bit 0) + state.schedule = RL_DEFAULT_SCHEDULE; + } - /** - * @brief Closes epoch: computes revenue, selects winner (if >1 player), - * distributes fees, burns leftover, records winner, then clears players. - */ - END_EPOCH_WITH_LOCALS() + if (state.drawHour == 0) + { + state.drawHour = RL_DEFAULT_DRAW_HOUR; // Default draw hour (UTC) + } + + // 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; + makeDateStamp(qpi.year(), qpi.month(), qpi.day(), state.lastDrawDateStamp); + + // Open selling for the new epoch + enableBuyTicket(state, true); + } + + END_EPOCH() { - state.currentState = EState::LOCKED; + enableBuyTicket(state, false); + + clearStateOnEndEpoch(state); + applyNextEpochData(state); + } + + BEGIN_TICK_WITH_LOCALS() + { + // Throttle: run logic only once per RL_TICK_UPDATE_PERIOD ticks + if (mod(qpi.tick(), static_cast(RL_TICK_UPDATE_PERIOD)) != 0) + { + return; + } + + // Snapshot current day/hour + locals.currentDayOfWeek = qpi.dayOfWeek(qpi.year(), qpi.month(), qpi.day()); + locals.currentHour = qpi.hour(); + + // Do nothing before the configured draw hour + if (locals.currentHour < state.drawHour) + { + return; + } - // Single-player edge case: refund instead of drawing. - if (state.players.population() == 1) + // Ensure only one action per calendar day (UTC) + makeDateStamp(qpi.year(), qpi.month(), qpi.day(), locals.currentDateStamp); + if (state.lastDrawDateStamp == locals.currentDateStamp) { - ReturnAllTickets(qpi, state, locals.returnAllTicketsInput, locals.returnAllTicketsOutput, locals.returnAllTicketsLocals); + return; } - else if (state.players.population() > 1) + + locals.isWednesday = (locals.currentDayOfWeek == WEDNESDAY); + locals.isScheduledToday = ((state.schedule & (1u << locals.currentDayOfWeek)) != 0); + + // 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) { - qpi.getEntity(SELF, locals.entity); - locals.revenue = locals.entity.incomingAmount - locals.entity.outgoingAmount; + return; // Non-Wednesday day that is not scheduled: nothing to do + } + + // Mark today's action and timestamp + state.lastDrawDay = locals.currentDayOfWeek; + state.lastDrawHour = locals.currentHour; + state.lastDrawDateStamp = locals.currentDateStamp; - // Winner selection (pseudo-random). - GetWinner(qpi, state, locals.getWinnerInput, locals.getWinnerOutput, locals.getWinnerLocals); + // Temporarily close selling for the draw + enableBuyTicket(state, false); - if (locals.getWinnerOutput.winnerAddress != id::zero()) + // Draw + { + if (state.playerCounter <= 1) { - // Fee splits - 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 - if (locals.teamFee > 0) - { - qpi.transfer(state.teamAddress, locals.teamFee); - } + ReturnAllTickets(qpi, state, locals.returnAllTicketsInput, locals.returnAllTicketsOutput, locals.returnAllTicketsLocals); + } + else + { + // Current contract net balance = incoming - outgoing for this contract + qpi.getEntity(SELF, locals.entity); + getSCRevenue(locals.entity, locals.revenue); - // Distribution fee - if (locals.distributionFee > 0) + // Winner selection (pseudo-random using K12(prevSpectrumDigest)). { - qpi.distributeDividends(div(locals.distributionFee, uint64(NUMBER_OF_COMPUTORS))); + 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); + } } - // Winner payout - if (locals.winnerAmount > 0) + if (locals.winnerAddress != id::zero()) { - qpi.transfer(locals.getWinnerOutput.winnerAddress, locals.winnerAmount); + // 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); } - - // Burn remainder - if (locals.burnedAmount > 0) + else { - qpi.burn(locals.burnedAmount); + // Fallback: if winner couldn't be selected (should not happen), refund all tickets + ReturnAllTickets(qpi, state, locals.returnAllTicketsInput, locals.returnAllTicketsOutput, locals.returnAllTicketsLocals); } - - // Persist winner record - 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). - ReturnAllTickets(qpi, state, locals.returnAllTicketsInput, locals.returnAllTicketsOutput, locals.returnAllTicketsLocals); } } - // Prepare for next epoch. - state.players.reset(); + clearStateOnEndDraw(state); + + // Resume selling unless today is Wednesday (remains closed until next epoch) + enableBuyTicket(state, !locals.isWednesday); } /** @@ -324,18 +542,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 = min(state.playerCounter, state.players.capacity()); } /** @@ -344,178 +554,313 @@ struct RL : public ContractBase PUBLIC_FUNCTION(GetWinners) { output.winners = state.winners; - output.numberOfWinners = state.winnersInfoNextEmptyIndex; + getWinnerCounter(state, output.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); } + 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) + { + qpi.getEntity(SELF, locals.entity); + getSCRevenue(locals.entity, output.balance); + } + + 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); + } + + 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 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) { - output.returnCode = static_cast(EReturnCode::TICKET_SELLING_CLOSED); - 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; } - // Already purchased - if (state.players.contains(qpi.invocator())) + locals.price = state.ticketPrice; + + // Not enough to buy even a single ticket: refund everything + if (locals.reward < locals.price) { - output.returnCode = static_cast(EReturnCode::TICKET_ALREADY_PURCHASED); - 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.players.add(qpi.invocator()) == NULL_INDEX) + // Capacity check + locals.capacity = state.players.capacity(); + locals.slotsLeft = (state.playerCounter < locals.capacity) ? (locals.capacity - state.playerCounter) : 0; + if (locals.slotsLeft == 0) { + // All sold out: refund full amount + if (locals.reward > 0) + { + qpi.transfer(qpi.invocator(), locals.reward); + } output.returnCode = static_cast(EReturnCode::TICKET_ALL_SOLD_OUT); - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - return; } - // Price mismatch - if (qpi.invocationReward() != state.ticketPrice && qpi.invocationReward() > 0) + // 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 + + // Add tickets (the same address may be inserted multiple times) + for (locals.i = 0; locals.i < locals.toBuy; ++locals.i) { - output.returnCode = static_cast(EReturnCode::TICKET_INVALID_PRICE); - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - state.players.remove(qpi.invocator()); + if (state.playerCounter < locals.capacity) + { + state.players.set(state.playerCounter, qpi.invocator()); + state.playerCounter = min(state.playerCounter + 1, locals.capacity); + } + } - state.players.cleanupIfNeeded(80); - return; + // 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) + { + qpi.transfer(qpi.invocator(), locals.refundAmount); } + + output.returnCode = static_cast(EReturnCode::SUCCESS); } 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) { if (input.winnerAddress == id::zero()) { - return; + return; // Nothing to store } - state.winnersInfoNextEmptyIndex = mod(state.winnersInfoNextEmptyIndex, 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(); - state.winners.set(state.winnersInfoNextEmptyIndex++, locals.winnerInfo); - } - - /** - * @brief Internal: pseudo-random selection of a winner index using hardware RNG. - */ - PRIVATE_PROCEDURE_WITH_LOCALS(GetWinner) - { - if (state.players.population() == 0) - { - return; - } + locals.winnerInfo.dayOfWeek = qpi.dayOfWeek(qpi.year(), qpi.month(), qpi.day()); - locals.randomNum = mod(qpi.K12(qpi.getPrevSpectrumDigest()).u64._0, state.players.population()); - - 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); - }; + state.winners.set(locals.insertIdx, locals.winnerInfo); } PRIVATE_PROCEDURE_WITH_LOCALS(ReturnAllTickets) { - locals.i = state.players.nextElementIndex(NULL_INDEX); - while (locals.i != NULL_INDEX) + // 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.key(locals.i), state.ticketPrice); - - locals.i = state.players.nextElementIndex(locals.i); - }; + qpi.transfer(state.players.get(locals.i), state.ticketPrice); + } } protected: + /** + * @brief Circular buffer storing the history of winners. + * Maximum capacity is defined by RL_MAX_NUMBER_OF_WINNERS_IN_HISTORY. + */ + Array winners; + + /** + * @brief Set of players participating in the current lottery epoch. + * Maximum capacity is defined by RL_MAX_NUMBER_OF_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; + + /** + * @brief Price of a single lottery ticket. + * Value is in the smallest currency unit (e.g., cents). + */ + uint64 ticketPrice; + + /** + * @brief Number of players (tickets sold) in the current epoch. + */ + uint64 playerCounter; + + /** + * @brief Index pointing to the next empty slot in the winners array. + * Used for maintaining the circular buffer of winners. + */ + uint64 winnersCounter; + + /** + * @brief Date/time guard for draw operations. + * lastDrawDateStamp prevents more than one action per calendar day (UTC). + */ + 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 Price of a single lottery ticket. - * Value is in the smallest currency unit (e.g., cents). + * @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). */ - uint64 ticketPrice = 0; + uint8 schedule; /** - * @brief Set of players participating in the current lottery epoch. - * Maximum capacity is defined by RL_MAX_NUMBER_OF_PLAYERS. + * @brief UTC hour [0..23] when a draw is allowed to run (daily time gate). */ - HashSet players = {}; + uint8 drawHour; /** - * @brief Circular buffer storing the history of winners. - * Maximum capacity is defined by RL_MAX_NUMBER_OF_WINNERS_IN_HISTORY. + * @brief Current state of the lottery contract. + * SELLING: tickets available; LOCKED: selling closed. */ - Array winners = {}; + EState currentState; - /** - * @brief Index pointing to the next empty slot in the winners array. - * Used for maintaining the circular buffer of winners. - */ - uint64 winnersInfoNextEmptyIndex = 0; +protected: + static void clearStateOnEndEpoch(RL& state) + { + // Prepare for next epoch: clear players and reset daily guards + state.playerCounter = 0; + state.players.setAll(id::zero()); - /** - * @brief Current state of the lottery contract. - * Can be either SELLING (tickets available) or LOCKED (epoch closed). - */ - EState currentState = EState::LOCKED; + state.lastDrawHour = RL_INVALID_HOUR; + state.lastDrawDay = RL_INVALID_DAY; + state.lastDrawDateStamp = 0; + } + + static void clearStateOnEndDraw(RL& state) + { + // 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; + state.nexEpochData.schedule = 0; + } + } + + 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; } }; diff --git a/test/contract_rl.cpp b/test/contract_rl.cpp index 5c0bd008b..539623a4f 100644 --- a/test/contract_rl.cpp +++ b/test/contract_rl.cpp @@ -4,20 +4,33 @@ #include "contract_testing.h" 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; +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); + _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; // 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) 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 +// Test helper that exposes internal state assertions and utilities class RLChecker : public RL { public: @@ -37,14 +50,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 +62,11 @@ class RLChecker : public RL { EXPECT_EQ(output.returnCode, static_cast(EReturnCode::SUCCESS)); EXPECT_EQ(output.winners.capacity(), winners.capacity()); - EXPECT_EQ(output.numberOfWinners, winnersInfoNextEmptyIndex); - for (uint64 i = 0; i < output.numberOfWinners; ++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)); } @@ -62,10 +74,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 +85,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,17 +94,19 @@ 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 setScheduleMask(uint8 newMask) { schedule = newMask; } - void setLocked() { currentState = EState::LOCKED; } - - uint64 playersPopulation() const { return players.population(); } + uint64 getPlayerCounter() const { return playerCounter; } uint64 getTicketPrice() const { return ticketPrice; } + + uint8 getScheduleMask() const { return schedule; } + + uint8 getDrawHourInternal() const { return drawHour; } }; class ContractTestingRL : protected ContractTesting @@ -106,7 +120,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,24 +150,202 @@ 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 + // Returns current contract on-chain balance (incoming - outgoing) + RL::GetBalance_output getBalanceInfo() + { + RL::GetBalance_input input; + RL::GetBalance_output output; + callFunction(RL_CONTRACT_INDEX, FUNCTION_INDEX_GET_BALANCE, input, output); + 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; 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::UNKNOWN_ERROR); + } + 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; + } + + // 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); } + + // 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)); + } + + void setCurrentHour(uint8 hour) + { + updateTime(); + utcTime.Hour = hour; + updateQpiTime(); + } + + // New: set full date and hour (UTC), then sync QPI time + 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: 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))); + + BeginTick(); + } + + // 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 + 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(); + } + + // Force schedule mask directly in state (bypasses external call, suitable for tests) + void forceSchedule(uint8 scheduleMask) + { + state()->setScheduleMask(scheduleMask); + // NOTE: we do not call SetSchedule here to avoid epoch transitions in tests. + } }; TEST(ContractRandomLottery, GetFees) { ContractTestingRL ctl; RL::GetFees_output output = ctl.getFees(); - ctl.getState()->checkFees(output); + ctl.state()->checkFees(output); } TEST(ContractRandomLottery, GetPlayers) @@ -161,13 +354,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 +369,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 +386,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; @@ -210,9 +403,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.getState()->playersPopulation(), expectedPlayers); + 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 @@ -220,28 +424,29 @@ 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(ctl.state()->getPlayerCounter(), userCount * 2); } -TEST(ContractRandomLottery, EndEpoch) +// Updated: payout is triggered by BEGIN_TICK with schedule/time gating, not by END_EPOCH +TEST(ContractRandomLottery, DrawAndPayout_BeginTick) { 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(); @@ -250,19 +455,23 @@ 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 (nothing to payout, no winner recorded) --- { 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); + 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.numberOfWinners, 0u); - EXPECT_EQ(ctl.getState()->playersPopulation(), 0u); + EXPECT_EQ(after.winnersCounter, winnersBefore); + EXPECT_EQ(ctl.state()->getPlayerCounter(), 0u); } // --- Scenario 2: Exactly one player (ticket refunded, no winner recorded) --- @@ -275,24 +484,27 @@ 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(); + const uint64 winnersBeforeCount = ctl.getWinners().winnersCounter; + + ctl.advanceOneDayAndDraw(); // 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); + // No new winners appended + 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(); - constexpr uint32 N = 5; + constexpr uint32 N = 5 * 2; struct PlayerInfo { id addr; @@ -302,19 +514,17 @@ TEST(ContractRandomLottery, EndEpoch) std::vector infos; infos.reserve(N); - // Add N distinct players with valid purchases - for (uint32 i = 0; i < N; ++i) + // Add N/2 distinct players, each making two valid purchases + for (uint32 i = 0; i < N; i += 2) { const id randomUser = id::randomValue(); - increaseEnergy(randomUser, ticketPrice * 2); + ctl.increaseAndBuy(ctl, randomUser, ticketPrice); + 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 * 2), 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,18 +532,18 @@ 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(); + ctl.advanceOneDayAndDraw(); - // Players reset after epoch end - EXPECT_EQ(ctl.getState()->playersPopulation(), 0u); + // Players reset after draw + 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); + 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); @@ -361,10 +571,462 @@ 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); } + + // --- 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 + + // 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.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(mod(winnersBefore, wOut.winners.capacity())); + 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 round's contract balance + 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) +{ + 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 draw, balance equals the total cost of tickets + { + const RL::GetBalance_output outBefore = ctl.getBalanceInfo(); + EXPECT_EQ(outBefore.balance, ticketPrice * K); + } + + // 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(); + + // 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); + 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 known constant via GetPlayers capacity + 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 END_EPOCH — back to LOCKED (selling disabled until next epoch) + ctl.EndEpoch(); + { + const RL::GetState_output out2 = ctl.getStateInfo(); + EXPECT_EQ(out2.currentState, static_cast(RL::EState::LOCKED)); + } +} + +// --- New tests for SetPrice and NextEpochData --- + +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 END_EPOCH implicitly + 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 END_EPOCH + 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)); + + // 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 END_EPOCH + ctl.EndEpoch(); + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, newPrice); + + // 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); +} + +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 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)); + + // 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(); + 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 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(); + 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_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 exactly once per price + 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); +} + +// functions related to schedule and draw hour + +TEST(ContractRandomLottery, GetSchedule_And_SetSchedule) +{ + ContractTestingRL ctl; + + // Default schedule set on initialize must include Wednesday (bit 0) + 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 (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); + + // 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 (not configured) + EXPECT_EQ(ctl.getDrawHour().drawHour, 0u); + + // After BeginEpoch default is 11 UTC + ctl.BeginEpoch(); + EXPECT_EQ(ctl.getDrawHour().drawHour, RL_DEFAULT_DRAW_HOUR); }