From 35b4805f55b962da338897b3ab6f1b3dba2c917d Mon Sep 17 00:00:00 2001 From: N-010 Date: Sat, 13 Sep 2025 23:22:32 +0300 Subject: [PATCH 1/9] Create 2025-09-13-RandomLottery.md --- SmartContracts/2025-09-13-RandomLottery.md | 609 +++++++++++++++++++++ 1 file changed, 609 insertions(+) create mode 100644 SmartContracts/2025-09-13-RandomLottery.md diff --git a/SmartContracts/2025-09-13-RandomLottery.md b/SmartContracts/2025-09-13-RandomLottery.md new file mode 100644 index 0000000..1aae681 --- /dev/null +++ b/SmartContracts/2025-09-13-RandomLottery.md @@ -0,0 +1,609 @@ +# Proposal to include Random Lottery Smart Contract + + +## Proposal + +Allow Random Lottery Smart Contract to be deployed on Qubic. + +## Available Options + +> Option 0: no, don’t allow + +> Option 1: yes, allow + +## What is Random Lottery + +Random Lottery (RL) is a simple raffle contract that sells one ticket per account per epoch and, at the end of the epoch, draws a winner and splits revenue into buckets (team, distribution/shareholders, winner, burn). It keeps a ring-buffer history of winners and supports owner-controlled settings for ticket price and fee percentages. The contract operates in two states: SELLING (open for tickets) and LOCKED (closed). + +## Random Lottery Key Features + +1. Clear lifecycle & states: INITIALIZE → BEGIN_EPOCH → SELLING → END_EPOCH → LOCKED. The epoch end computes revenue from on-chain entity I/O, pays team, distributes dividends, pays winner, and burns residuals (including rounding dust). +1. Owner-configurable fees: teamFeePercent, distributionFeePercent, burnPrecent; winner share is computed as the remainder to 100%. Validation guarantees the sum of (team + distribution + burn) ≤ 100. +1. Owner-configurable ticket price: must be > 0. +1. One ticket per player per epoch, with caps: uniqueness enforced via HashSet. Capacity guard returns TICKET_ALL_SOLD_OUT. Defaults: MAX_NUMBER_OF_PLAYERS = 1024, MAX_NUMBER_OF_WINNERS_IN_HISTORY = 1024. +1. Winners history: cyclic Array with (winnerAddress, revenue, epoch, tick). +1. Edge-case refund: if only 1 player participated, the ticket price is refunded instead of drawing. +1. Interfaces (stable IDs): + 1. Functions: GetFees(1), GetPlayers(2), GetWinners(3) + 1. Procedures: BuyTicket(1), SetTicketPrice(2), SetFeePrecent(3) +1. Return codes: e.g., SUCCESS, TICKET_INVALID_PRICE, TICKET_ALREADY_PURCHASED, TICKET_ALL_SOLD_OUT, TICKET_SELLING_CLOSED, ACCESS_DENIED, FEE_INVALID_PRECENT_VALUE. +1. Randomness: winner index obtained from _rdrand64_step() reduced modulo participants; then selected by iterating non-empty slots. (Note: hardware RNG is convenient for testing; a consensus-friendly RNG source may be preferred on mainnet.) + +## Fee Income Distribution + +* Defaults (set in INITIALIZE): + * Team: 10% + * Distribution (shareholders): 20% + * Burn: 2% + * Winner: computed remainder (68% with defaults) + * Default ticket price: 1_000_000 minimal units. +* At END_EPOCH: + 1. revenue = incomingAmount − outgoingAmount (via qpi.getEntity). + 1. Compute winnerAmount, teamFee, distributionFee from percentages; transfer team and winner, call qpi.distributeDividends(...) for distribution. + 1. Re-read entity and burn any leftover (covers rounding). + 1. Append WinnerInfo to history; clear players for next epoch. + +## Technical Implementation + +```C++ +#pragma once + +/** + * @file RandomLottery.h + * @brief Random Lottery contract definition: state, data structures, and user / internal procedures. + * + * This header declares the RL (Random Lottery) contract which: + * - Sells tickets during a SELLING epoch. + * - Draws a pseudo-random winner when the epoch ends. + * - Distributes fees (team, distribution, burn, winner). + * - Records winners' history in a ring-like buffer. + */ + +using namespace QPI; + +// Maximum number of players allowed in the lottery. +constexpr uint16 MAX_NUMBER_OF_PLAYERS = 1024; + +/// Maximum number of winners kept in the on-chain winners history buffer. +constexpr uint16 MAX_NUMBER_OF_WINNERS_IN_HISTORY = 1024; + +/** + * @brief Developer address for the RandomLottery contract. + * + * IMPORTANT: + * The macro ID and the individual token macros (_Z, _T, _Q, etc.) must be available. + * If clang reports 'ID' undeclared here, include the QPI identity / address utilities first. + */ +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, _U, _E, _J, _J); + +/// Owner address (currently identical to developer address; can be split in future revisions). +static const id RL_OWNER_ADDRESS = RL_DEV_ADDRESS; + +/// Placeholder structure for future extensions. +struct RL2 { +}; + +/** + * @brief Main contract class implementing the random lottery mechanics. + * + * Lifecycle: + * 1. INITIALIZE sets defaults (fees, ticket price, state LOCKED). + * 2. BEGIN_EPOCH opens ticket selling (SELLING). + * 3. Users call BuyTicket while SELLING. + * 4. END_EPOCH closes, computes fees, selects winner, distributes, burns rest. + * 5. Players list is cleared for next epoch. + */ +struct RL : public ContractBase { +public: + /** + * @brief High-level finite state of the lottery. + * SELLING: tickets can be purchased. + * LOCKED: purchases closed; waiting for epoch transition. + */ + enum class EState : uint8 { + SELLING, + LOCKED + }; + + /** + * @brief Standardized return / error codes for procedures. + */ + enum class EReturnCode : uint8 { + SUCCESS = 0, + // Ticket-related errors + TICKET_INVALID_PRICE = 1, + TICKET_ALREADY_PURCHASED = 2, + TICKET_ALL_SOLD_OUT = 3, + TICKET_SELLING_CLOSED = 4, + // Access-related errors + ACCESS_DENIED = 5, + // Fee-related errors + FEE_INVALID_PRECENT_VALUE = 6, + // Fallback + UNKNOW_ERROR = UINT8_MAX + }; + + //---- User-facing I/O structures ------------------------------------------------------------- + + struct BuyTicket_input { + }; + + struct BuyTicket_output { + uint8 returnCode = static_cast(EReturnCode::SUCCESS); + }; + + struct GetFees_input { + }; + + struct GetFees_output { + uint8 teamFeePercent = 0; + uint8 distributionFeePercent = 0; + uint8 winnerFeePercent = 0; + uint8 burnPrecent = 0; + uint8 returnCode = static_cast(EReturnCode::SUCCESS); + }; + + struct GetPlayers_input { + }; + + struct GetPlayers_output { + Array players; + uint16 numberOfPlayers = 0; + uint8 returnCode = static_cast(EReturnCode::SUCCESS); + }; + + struct GetPlayers_locals { + uint64 arrayIndex = 0; + }; + + struct SetTicketPrice_input { + uint64 newTicketPrice = 0; + }; + + struct SetTicketPrice_output { + uint8 returnCode = static_cast(EReturnCode::SUCCESS); + }; + + struct SetTicketPriceInner_input { + SetTicketPrice_input setTicketPriceInput; + }; + + struct SetTicketPriceInner_output { + uint8 returnCode = static_cast(EReturnCode::SUCCESS); + }; + + struct SetTicketPrice_locals { + SetTicketPriceInner_input setTicketPriceInnerInput; + SetTicketPriceInner_output setTicketPriceInnerOutput; + }; + + /** + * @brief Stored winner snapshot for an epoch. + */ + struct WinnerInfo { + id winnerAddress = id::zero(); + uint64 revenue = 0; + uint16 epoch = 0; + uint32 tick = 0; + }; + + struct FillWinnersInfo_input { + id winnerAddress = id::zero(); + uint64 revenue = 0; + }; + + struct FillWinnersInfo_output { + }; + + struct FillWinnersInfo_locals { + WinnerInfo winnerInfo = {}; + }; + + struct GetWinner_input { + }; + + struct GetWinner_output { + id winnerAddress = id::zero(); + uint64 index = 0; + }; + + struct GetWinner_locals { + uint64 randomNum = 0; + }; + + struct GetWinners_input { + }; + + struct GetWinners_output { + Array winners; + uint64 numberOfWinners = 0; + uint8 returnCode = static_cast(EReturnCode::SUCCESS); + }; + + struct END_EPOCH_locals { + GetWinner_input getWinnerInput = {}; + GetWinner_output getWinnerOutput = {}; + GetWinner_locals getWinnerLocals = {}; + + FillWinnersInfo_input fillWinnersInfoInput = {}; + FillWinnersInfo_output fillWinnersInfoOutput = {}; + FillWinnersInfo_locals fillWinnersInfoLocals = {}; + + uint64 teamFee = 0; + uint64 distributionFee = 0; + uint64 winnerAmount = 0; + uint64 burnedAmount = 0; + + uint64 revenue = 0; + Entity entity; + }; + + struct SetFeePrecent_input { + uint8 teamFeePercent = 0; + uint8 distributionFeePercent = 0; + uint8 burnPrecent = 0; + }; + + struct SetFeePrecent_output { + uint8 returnCode = static_cast(EReturnCode::SUCCESS); + }; + + struct SetFeePrecentInner_input { + SetFeePrecent_input setFeePrecentInput = {}; + }; + + struct SetFeePrecentInner_output { + uint8 returnCode = static_cast(EReturnCode::SUCCESS); + }; + + struct SetFeePrecent_locals { + SetFeePrecentInner_input setFeePrecentInnerInput = {}; + SetFeePrecentInner_output setFeePrecentInnerOutput = {}; + }; + + struct INITIALIZE_locals { + SetFeePrecentInner_input setFeePrecentInnerInput = {}; + SetFeePrecentInner_output setFeePrecentInnerOutput = {}; + + SetTicketPriceInner_input setTicketPriceInnerInput = {}; + SetTicketPriceInner_output setTicketPriceInnerOutput = {}; + }; + +public: + /** + * @brief Registers all externally callable functions and procedures with their numeric identifiers. + * Mapping numbers must remain stable to preserve external interface compatibility. + */ + REGISTER_USER_FUNCTIONS_AND_PROCEDURES() { + REGISTER_USER_FUNCTION(GetFees, 1); + REGISTER_USER_FUNCTION(GetPlayers, 2); + REGISTER_USER_FUNCTION(GetWinners, 3); + REGISTER_USER_PROCEDURE(BuyTicket, 1); + REGISTER_USER_PROCEDURE(SetTicketPrice, 2); + REGISTER_USER_PROCEDURE(SetFeePrecent, 3); + } + + /** + * @brief Contract initialization hook. + * Sets default fees, ticket price, addresses, and locks the lottery (no selling yet). + */ + INITIALIZE_WITH_LOCALS() { + // Addresses + state.teamAddress = RL_DEV_ADDRESS; + state.ownerAddress = RL_OWNER_ADDRESS; + + // Default fee percentages (sum <= 100; winner percent derived) + state.burnPrecent = 0; // (Will be overridden by inner call) + locals.setFeePrecentInnerInput.setFeePrecentInput.burnPrecent = 2; + locals.setFeePrecentInnerInput.setFeePrecentInput.teamFeePercent = 10; + locals.setFeePrecentInnerInput.setFeePrecentInput.distributionFeePercent = 20; + CALL(SetFeePrecentInner, locals.setFeePrecentInnerInput, locals.setFeePrecentInnerOutput); + + // Default ticket price + locals.setTicketPriceInnerInput.setTicketPriceInput.newTicketPrice = 1000000; + CALL(SetTicketPriceInner, locals.setTicketPriceInnerInput, locals.setTicketPriceInnerOutput); + + // Start locked + state.currentState = EState::LOCKED; + } + + /** + * @brief Opens ticket selling for a new epoch. + */ + BEGIN_EPOCH() { + state.currentState = EState::SELLING; + } + + /** + * @brief Closes epoch: computes revenue, selects winner (if >1 player), + * distributes fees, burns leftover, records winner, then clears players. + */ + END_EPOCH_WITH_LOCALS() { + state.currentState = EState::LOCKED; + + // Single-player edge case: refund instead of drawing. + if (state.players.population() == 1) { + for (sint32 i = 0; i < state.players.capacity(); ++i) { + if (!state.players.isEmptySlot(i)) { + qpi.transfer(state.players.key(i), state.ticketPrice); + break; + } + } + } else if (state.players.population() > 1) { + qpi.getEntity(SELF, locals.entity); + locals.revenue = locals.entity.incomingAmount - locals.entity.outgoingAmount; + + // Winner selection (pseudo-random). + GetWinner(qpi, state, locals.getWinnerInput, locals.getWinnerOutput, locals.getWinnerLocals); + + if (locals.getWinnerOutput.winnerAddress != id::zero()) { + // 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); + + // Team fee + if (locals.teamFee > 0) { + qpi.transfer(state.teamAddress, locals.teamFee); + } + // Distribution fee + if (locals.distributionFee > 0) { + qpi.distributeDividends(div(locals.distributionFee, uint64(NUMBER_OF_COMPUTORS))); + } + // Winner payout + if (locals.winnerAmount > 0) { + qpi.transfer(locals.getWinnerOutput.winnerAddress, locals.winnerAmount); + } + + // Burn all residual (handles rounding dust). + { + qpi.getEntity(SELF, locals.entity); + locals.burnedAmount = locals.entity.incomingAmount - locals.entity.outgoingAmount; + qpi.burn(locals.burnedAmount); + } + + // Persist winner record + locals.fillWinnersInfoInput.winnerAddress = locals.getWinnerOutput.winnerAddress; + locals.fillWinnersInfoInput.revenue = locals.winnerAmount; + FillWinnersInfo(qpi, state, locals.fillWinnersInfoInput, locals.fillWinnersInfoOutput, + locals.fillWinnersInfoLocals); + } + } + + // Prepare for next epoch. + state.players.reset(); + } + + /** + * @brief Returns currently configured fee percentages. + */ + PUBLIC_FUNCTION(GetFees) { + output.teamFeePercent = state.teamFeePercent; + output.distributionFeePercent = state.distributionFeePercent; + output.winnerFeePercent = state.winnerFeePercent; + output.burnPrecent = state.burnPrecent; + } + + /** + * @brief Retrieves the active players list for the ongoing epoch. + */ + PUBLIC_FUNCTION_WITH_LOCALS(GetPlayers) { + locals.arrayIndex = 0; + output.numberOfPlayers = state.players.population(); + if (output.numberOfPlayers == 0) { + return; + } + for (sint64 i = 0; i < state.players.capacity(); ++i) { + if (!state.players.isEmptySlot(i)) { + output.players.set(locals.arrayIndex++, state.players.key(i)); + } + } + } + + /** + * @brief Returns historical winners (ring buffer segment). + */ + PUBLIC_FUNCTION(GetWinners) { + output.winners = state.winners; + output.numberOfWinners = state.winnersInfoNextEmptyIndex; + } + + /** + * @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. + */ + PUBLIC_PROCEDURE(BuyTicket) { + // 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()); + } + return; + } + + // Already purchased + if (state.players.contains(qpi.invocator())) { + output.returnCode = static_cast(EReturnCode::TICKET_ALREADY_PURCHASED); + return; + } + + // Capacity full + if (state.players.add(qpi.invocator()) == NULL_INDEX) { + output.returnCode = static_cast(EReturnCode::TICKET_ALL_SOLD_OUT); + return; + } + + // Price mismatch + if (qpi.invocationReward() != state.ticketPrice && qpi.invocationReward() > 0) { + output.returnCode = static_cast(EReturnCode::TICKET_INVALID_PRICE); + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + state.players.remove(qpi.invocator()); + return; + } + } + + /** + * @brief Owner-only: updates ticket price (must be > 0). + */ + PUBLIC_PROCEDURE_WITH_LOCALS(SetTicketPrice) { + if (qpi.invocator() != state.ownerAddress) { + output.returnCode = static_cast(EReturnCode::ACCESS_DENIED); + return; + } + locals.setTicketPriceInnerInput.setTicketPriceInput = input; + CALL(SetTicketPriceInner, locals.setTicketPriceInnerInput, locals.setTicketPriceInnerOutput); + output.returnCode = locals.setTicketPriceInnerOutput.returnCode; + } + + /** + * @brief Owner-only: sets fee distribution (sum of team + distribution + burn <= 100). + * Winner share auto-computed as remainder. + */ + PUBLIC_PROCEDURE_WITH_LOCALS(SetFeePrecent) { + if (qpi.invocator() != state.ownerAddress) { + output.returnCode = static_cast(EReturnCode::ACCESS_DENIED); + return; + } + locals.setFeePrecentInnerInput.setFeePrecentInput = input; + CALL(SetFeePrecentInner, locals.setFeePrecentInnerInput, locals.setFeePrecentInnerOutput); + output.returnCode = locals.setFeePrecentInnerOutput.returnCode; + } + +private: + /** + * @brief Internal: records a winner into the cyclic winners array. + */ + PRIVATE_PROCEDURE_WITH_LOCALS(FillWinnersInfo) { + if (input.winnerAddress == id::zero()) { + return; + } + if (MAX_NUMBER_OF_WINNERS_IN_HISTORY >= state.winners.capacity() - 1) { + state.winnersInfoNextEmptyIndex = 0; + } + 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_FUNCTION_WITH_LOCALS(GetWinner) { + if (state.players.population() == 0) { + return; + } + _rdrand64_step(&locals.randomNum); + locals.randomNum = mod(locals.randomNum, state.players.population()); + for (sint64 i = 0, j = 0; i < state.players.capacity(); ++i) { + if (!state.players.isEmptySlot(i)) { + if (j++ == locals.randomNum) { + output.winnerAddress = state.players.key(i); + output.index = i; + break; + } + } + } + } + + /** + * @brief Internal: validates and applies fee percentages. + */ + PRIVATE_PROCEDURE(SetFeePrecentInner) { + if (input.setFeePrecentInput.teamFeePercent + + input.setFeePrecentInput.distributionFeePercent + + input.setFeePrecentInput.burnPrecent > 100) { + output.returnCode = static_cast(EReturnCode::FEE_INVALID_PRECENT_VALUE); + return; + } + state.teamFeePercent = input.setFeePrecentInput.teamFeePercent; + state.distributionFeePercent = input.setFeePrecentInput.distributionFeePercent; + state.burnPrecent = input.setFeePrecentInput.burnPrecent; + state.winnerFeePercent = 100 - state.teamFeePercent - state.distributionFeePercent - state.burnPrecent; + } + + /** + * @brief Internal: validates and sets ticket price. + */ + PRIVATE_PROCEDURE(SetTicketPriceInner) { + if (input.setTicketPriceInput.newTicketPrice == 0) { + output.returnCode = static_cast(EReturnCode::TICKET_INVALID_PRICE); + return; + } + state.ticketPrice = input.setTicketPriceInput.newTicketPrice; + } + +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. + */ + id ownerAddress = id::zero(); + + /** + * @brief Percentage of the revenue allocated to the team. + * Value is between 0 and 100. + */ + uint8 teamFeePercent = 0; + + /** + * @brief Percentage of the revenue allocated for distribution. + * Value is between 0 and 100. + */ + uint8 distributionFeePercent = 0; + + /** + * @brief Percentage of the revenue allocated to the winner. + * Automatically calculated as the remainder after other fees. + */ + uint8 winnerFeePercent = 0; + + /** + * @brief Percentage of the revenue to be burned. + * Value is between 0 and 100. + */ + uint8 burnPrecent = 0; + + /** + * @brief Price of a single lottery ticket. + * Value is in the smallest currency unit (e.g., cents). + */ + uint64 ticketPrice = 0; + + /** + * @brief Set of players participating in the current lottery epoch. + * Maximum capacity is defined by MAX_NUMBER_OF_PLAYERS. + */ + HashSet players = {}; + + /** + * @brief Circular buffer storing the history of winners. + * Maximum capacity is defined by MAX_NUMBER_OF_WINNERS_IN_HISTORY. + */ + Array winners = {}; + + /** + * @brief Index pointing to the next empty slot in the winners array. + * Used for maintaining the circular buffer of winners. + */ + uint64 winnersInfoNextEmptyIndex = 0; + + /** + * @brief Current state of the lottery contract. + * Can be either SELLING (tickets available) or LOCKED (epoch closed). + */ + EState currentState = EState::LOCKED; +}; +``` From dbf9a055aa9782e49449db0d9becbe819f56b6be Mon Sep 17 00:00:00 2001 From: N-010 Date: Sun, 14 Sep 2025 22:39:07 +0300 Subject: [PATCH 2/9] Update 2025-09-13-RandomLottery.md --- SmartContracts/2025-09-13-RandomLottery.md | 127 ++------------------- 1 file changed, 9 insertions(+), 118 deletions(-) diff --git a/SmartContracts/2025-09-13-RandomLottery.md b/SmartContracts/2025-09-13-RandomLottery.md index 1aae681..5301932 100644 --- a/SmartContracts/2025-09-13-RandomLottery.md +++ b/SmartContracts/2025-09-13-RandomLottery.md @@ -160,27 +160,6 @@ public: uint64 arrayIndex = 0; }; - struct SetTicketPrice_input { - uint64 newTicketPrice = 0; - }; - - struct SetTicketPrice_output { - uint8 returnCode = static_cast(EReturnCode::SUCCESS); - }; - - struct SetTicketPriceInner_input { - SetTicketPrice_input setTicketPriceInput; - }; - - struct SetTicketPriceInner_output { - uint8 returnCode = static_cast(EReturnCode::SUCCESS); - }; - - struct SetTicketPrice_locals { - SetTicketPriceInner_input setTicketPriceInnerInput; - SetTicketPriceInner_output setTicketPriceInnerOutput; - }; - /** * @brief Stored winner snapshot for an epoch. */ @@ -242,37 +221,6 @@ public: Entity entity; }; - struct SetFeePrecent_input { - uint8 teamFeePercent = 0; - uint8 distributionFeePercent = 0; - uint8 burnPrecent = 0; - }; - - struct SetFeePrecent_output { - uint8 returnCode = static_cast(EReturnCode::SUCCESS); - }; - - struct SetFeePrecentInner_input { - SetFeePrecent_input setFeePrecentInput = {}; - }; - - struct SetFeePrecentInner_output { - uint8 returnCode = static_cast(EReturnCode::SUCCESS); - }; - - struct SetFeePrecent_locals { - SetFeePrecentInner_input setFeePrecentInnerInput = {}; - SetFeePrecentInner_output setFeePrecentInnerOutput = {}; - }; - - struct INITIALIZE_locals { - SetFeePrecentInner_input setFeePrecentInnerInput = {}; - SetFeePrecentInner_output setFeePrecentInnerOutput = {}; - - SetTicketPriceInner_input setTicketPriceInnerInput = {}; - SetTicketPriceInner_output setTicketPriceInnerOutput = {}; - }; - public: /** * @brief Registers all externally callable functions and procedures with their numeric identifiers. @@ -283,29 +231,26 @@ public: REGISTER_USER_FUNCTION(GetPlayers, 2); REGISTER_USER_FUNCTION(GetWinners, 3); REGISTER_USER_PROCEDURE(BuyTicket, 1); - REGISTER_USER_PROCEDURE(SetTicketPrice, 2); - REGISTER_USER_PROCEDURE(SetFeePrecent, 3); } /** * @brief Contract initialization hook. * Sets default fees, ticket price, addresses, and locks the lottery (no selling yet). */ - INITIALIZE_WITH_LOCALS() { + INITIALIZE() { // Addresses state.teamAddress = RL_DEV_ADDRESS; state.ownerAddress = RL_OWNER_ADDRESS; // Default fee percentages (sum <= 100; winner percent derived) - state.burnPrecent = 0; // (Will be overridden by inner call) - locals.setFeePrecentInnerInput.setFeePrecentInput.burnPrecent = 2; - locals.setFeePrecentInnerInput.setFeePrecentInput.teamFeePercent = 10; - locals.setFeePrecentInnerInput.setFeePrecentInput.distributionFeePercent = 20; - CALL(SetFeePrecentInner, locals.setFeePrecentInnerInput, locals.setFeePrecentInnerOutput); + state.teamFeePercent = 10; + state.distributionFeePercent = 20; + state.burnPrecent = 2; + state.winnerFeePercent = 100 - state.teamFeePercent - state.distributionFeePercent - state.burnPrecent; // Default ticket price - locals.setTicketPriceInnerInput.setTicketPriceInput.newTicketPrice = 1000000; - CALL(SetTicketPriceInner, locals.setTicketPriceInnerInput, locals.setTicketPriceInnerOutput); + state.ticketPrice = 1000000; + // Start locked state.currentState = EState::LOCKED; @@ -447,33 +392,6 @@ public: } } - /** - * @brief Owner-only: updates ticket price (must be > 0). - */ - PUBLIC_PROCEDURE_WITH_LOCALS(SetTicketPrice) { - if (qpi.invocator() != state.ownerAddress) { - output.returnCode = static_cast(EReturnCode::ACCESS_DENIED); - return; - } - locals.setTicketPriceInnerInput.setTicketPriceInput = input; - CALL(SetTicketPriceInner, locals.setTicketPriceInnerInput, locals.setTicketPriceInnerOutput); - output.returnCode = locals.setTicketPriceInnerOutput.returnCode; - } - - /** - * @brief Owner-only: sets fee distribution (sum of team + distribution + burn <= 100). - * Winner share auto-computed as remainder. - */ - PUBLIC_PROCEDURE_WITH_LOCALS(SetFeePrecent) { - if (qpi.invocator() != state.ownerAddress) { - output.returnCode = static_cast(EReturnCode::ACCESS_DENIED); - return; - } - locals.setFeePrecentInnerInput.setFeePrecentInput = input; - CALL(SetFeePrecentInner, locals.setFeePrecentInnerInput, locals.setFeePrecentInnerOutput); - output.returnCode = locals.setFeePrecentInnerOutput.returnCode; - } - private: /** * @brief Internal: records a winner into the cyclic winners array. @@ -499,8 +417,8 @@ private: if (state.players.population() == 0) { return; } - _rdrand64_step(&locals.randomNum); - locals.randomNum = mod(locals.randomNum, state.players.population()); + + locals.randomNum = mod(qpi.K12(qpi.getPrevSpectrumDigest()).u64._0, state.players.population()); for (sint64 i = 0, j = 0; i < state.players.capacity(); ++i) { if (!state.players.isEmptySlot(i)) { if (j++ == locals.randomNum) { @@ -512,33 +430,6 @@ private: } } - /** - * @brief Internal: validates and applies fee percentages. - */ - PRIVATE_PROCEDURE(SetFeePrecentInner) { - if (input.setFeePrecentInput.teamFeePercent - + input.setFeePrecentInput.distributionFeePercent - + input.setFeePrecentInput.burnPrecent > 100) { - output.returnCode = static_cast(EReturnCode::FEE_INVALID_PRECENT_VALUE); - return; - } - state.teamFeePercent = input.setFeePrecentInput.teamFeePercent; - state.distributionFeePercent = input.setFeePrecentInput.distributionFeePercent; - state.burnPrecent = input.setFeePrecentInput.burnPrecent; - state.winnerFeePercent = 100 - state.teamFeePercent - state.distributionFeePercent - state.burnPrecent; - } - - /** - * @brief Internal: validates and sets ticket price. - */ - PRIVATE_PROCEDURE(SetTicketPriceInner) { - if (input.setTicketPriceInput.newTicketPrice == 0) { - output.returnCode = static_cast(EReturnCode::TICKET_INVALID_PRICE); - return; - } - state.ticketPrice = input.setTicketPriceInput.newTicketPrice; - } - protected: /** * @brief Address of the team managing the lottery contract. From cca65bfeb53976f06d73c430a4928167a0815840 Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 17 Sep 2025 15:25:18 +0300 Subject: [PATCH 3/9] Update 2025-09-13-RandomLottery.md --- SmartContracts/2025-09-13-RandomLottery.md | 56 ++++++++++++---------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/SmartContracts/2025-09-13-RandomLottery.md b/SmartContracts/2025-09-13-RandomLottery.md index 5301932..0247311 100644 --- a/SmartContracts/2025-09-13-RandomLottery.md +++ b/SmartContracts/2025-09-13-RandomLottery.md @@ -46,8 +46,6 @@ Random Lottery (RL) is a simple raffle contract that sells one ticket per accoun ## Technical Implementation ```C++ -#pragma once - /** * @file RandomLottery.h * @brief Random Lottery contract definition: state, data structures, and user / internal procedures. @@ -62,10 +60,10 @@ Random Lottery (RL) is a simple raffle contract that sells one ticket per accoun using namespace QPI; // Maximum number of players allowed in the lottery. -constexpr uint16 MAX_NUMBER_OF_PLAYERS = 1024; +constexpr uint16 RL_MAX_NUMBER_OF_PLAYERS = 1024; /// Maximum number of winners kept in the on-chain winners history buffer. -constexpr uint16 MAX_NUMBER_OF_WINNERS_IN_HISTORY = 1024; +constexpr uint16 RL_MAX_NUMBER_OF_WINNERS_IN_HISTORY = 1024; /** * @brief Developer address for the RandomLottery contract. @@ -151,13 +149,14 @@ public: }; struct GetPlayers_output { - Array players; + Array players; uint16 numberOfPlayers = 0; uint8 returnCode = static_cast(EReturnCode::SUCCESS); }; struct GetPlayers_locals { uint64 arrayIndex = 0; + sint32 i = 0; }; /** @@ -192,13 +191,15 @@ public: struct GetWinner_locals { uint64 randomNum = 0; + sint32 i = 0; + sint32 j = 0; }; struct GetWinners_input { }; struct GetWinners_output { - Array winners; + Array winners; uint64 numberOfWinners = 0; uint8 returnCode = static_cast(EReturnCode::SUCCESS); }; @@ -219,6 +220,8 @@ public: uint64 revenue = 0; Entity entity; + + sint32 i = 0; }; public: @@ -272,9 +275,9 @@ public: // Single-player edge case: refund instead of drawing. if (state.players.population() == 1) { - for (sint32 i = 0; i < state.players.capacity(); ++i) { - if (!state.players.isEmptySlot(i)) { - qpi.transfer(state.players.key(i), state.ticketPrice); + for (locals.i = 0; locals.i < state.players.capacity(); ++locals.i) { + if (!state.players.isEmptySlot(locals.i)) { + qpi.transfer(state.players.key(locals.i), state.ticketPrice); break; } } @@ -290,24 +293,26 @@ public: 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.burnPrecent, 100ULL); // Team fee if (locals.teamFee > 0) { qpi.transfer(state.teamAddress, locals.teamFee); } + // Distribution fee if (locals.distributionFee > 0) { qpi.distributeDividends(div(locals.distributionFee, uint64(NUMBER_OF_COMPUTORS))); } + // Winner payout if (locals.winnerAmount > 0) { qpi.transfer(locals.getWinnerOutput.winnerAddress, locals.winnerAmount); } - // Burn all residual (handles rounding dust). + // Burn remainder + if (locals.burnedAmount > 0) { - qpi.getEntity(SELF, locals.entity); - locals.burnedAmount = locals.entity.incomingAmount - locals.entity.outgoingAmount; qpi.burn(locals.burnedAmount); } @@ -342,9 +347,10 @@ public: if (output.numberOfPlayers == 0) { return; } - for (sint64 i = 0; i < state.players.capacity(); ++i) { - if (!state.players.isEmptySlot(i)) { - output.players.set(locals.arrayIndex++, state.players.key(i)); + locals.i = 0; + for (locals.i = 0; locals.i < state.players.capacity(); ++locals.i) { + if (!state.players.isEmptySlot(locals.i)) { + output.players.set(locals.arrayIndex++, state.players.key(locals.i)); } } } @@ -400,7 +406,7 @@ private: if (input.winnerAddress == id::zero()) { return; } - if (MAX_NUMBER_OF_WINNERS_IN_HISTORY >= state.winners.capacity() - 1) { + if (RL_MAX_NUMBER_OF_WINNERS_IN_HISTORY >= state.winners.capacity() - 1) { state.winnersInfoNextEmptyIndex = 0; } locals.winnerInfo.winnerAddress = input.winnerAddress; @@ -419,11 +425,11 @@ private: } locals.randomNum = mod(qpi.K12(qpi.getPrevSpectrumDigest()).u64._0, state.players.population()); - for (sint64 i = 0, j = 0; i < state.players.capacity(); ++i) { - if (!state.players.isEmptySlot(i)) { - if (j++ == locals.randomNum) { - output.winnerAddress = state.players.key(i); - output.index = i; + for (locals.i = 0, locals.j = 0; locals.i < state.players.capacity(); ++locals.i) { + if (!state.players.isEmptySlot(locals.i)) { + if (locals.j++ == locals.randomNum) { + output.winnerAddress = state.players.key(locals.i); + output.index = locals.i; break; } } @@ -475,15 +481,15 @@ protected: /** * @brief Set of players participating in the current lottery epoch. - * Maximum capacity is defined by MAX_NUMBER_OF_PLAYERS. + * Maximum capacity is defined by RL_MAX_NUMBER_OF_PLAYERS. */ - HashSet players = {}; + HashSet players = {}; /** * @brief Circular buffer storing the history of winners. - * Maximum capacity is defined by MAX_NUMBER_OF_WINNERS_IN_HISTORY. + * Maximum capacity is defined by RL_MAX_NUMBER_OF_WINNERS_IN_HISTORY. */ - Array winners = {}; + Array winners = {}; /** * @brief Index pointing to the next empty slot in the winners array. From b07e483dcc24dd7120295ff2dfe61e0875b3cb5e Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 18 Sep 2025 22:46:36 +0300 Subject: [PATCH 4/9] Update 2025-09-13-RandomLottery.md --- SmartContracts/2025-09-13-RandomLottery.md | 23 +++++++++++----------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/SmartContracts/2025-09-13-RandomLottery.md b/SmartContracts/2025-09-13-RandomLottery.md index 0247311..02626d4 100644 --- a/SmartContracts/2025-09-13-RandomLottery.md +++ b/SmartContracts/2025-09-13-RandomLottery.md @@ -73,11 +73,8 @@ constexpr uint16 RL_MAX_NUMBER_OF_WINNERS_IN_HISTORY = 1024; * If clang reports 'ID' undeclared here, include the QPI identity / address utilities first. */ 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, _U, _E, _J, _J); - + _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). static const id RL_OWNER_ADDRESS = RL_DEV_ADDRESS; @@ -273,6 +270,8 @@ public: END_EPOCH_WITH_LOCALS() { state.currentState = EState::LOCKED; + state.players.cleanup(); + // Single-player edge case: refund instead of drawing. if (state.players.population() == 1) { for (locals.i = 0; locals.i < state.players.capacity(); ++locals.i) { @@ -311,8 +310,7 @@ public: } // Burn remainder - if (locals.burnedAmount > 0) - { + if (locals.burnedAmount > 0) { qpi.burn(locals.burnedAmount); } @@ -343,16 +341,15 @@ public: */ PUBLIC_FUNCTION_WITH_LOCALS(GetPlayers) { locals.arrayIndex = 0; - output.numberOfPlayers = state.players.population(); - if (output.numberOfPlayers == 0) { - return; - } + locals.i = 0; for (locals.i = 0; locals.i < state.players.capacity(); ++locals.i) { if (!state.players.isEmptySlot(locals.i)) { output.players.set(locals.arrayIndex++, state.players.key(locals.i)); } } + + output.numberOfPlayers = locals.arrayIndex; } /** @@ -419,7 +416,9 @@ private: /** * @brief Internal: pseudo-random selection of a winner index using hardware RNG. */ - PRIVATE_FUNCTION_WITH_LOCALS(GetWinner) { + PRIVATE_PROCEDURE_WITH_LOCALS(GetWinner) { + state.players.cleanup(); + if (state.players.population() == 0) { return; } From a8e8a80d11d2cf2e57c4bf296e25d0e027efab35 Mon Sep 17 00:00:00 2001 From: N-010 Date: Sat, 27 Sep 2025 17:46:16 +0300 Subject: [PATCH 5/9] Update 2025-09-13-RandomLottery.md --- SmartContracts/2025-09-13-RandomLottery.md | 890 +++++++++++---------- 1 file changed, 481 insertions(+), 409 deletions(-) diff --git a/SmartContracts/2025-09-13-RandomLottery.md b/SmartContracts/2025-09-13-RandomLottery.md index 02626d4..9340a80 100644 --- a/SmartContracts/2025-09-13-RandomLottery.md +++ b/SmartContracts/2025-09-13-RandomLottery.md @@ -46,9 +46,10 @@ Random Lottery (RL) is a simple raffle contract that sells one ticket per accoun ## Technical Implementation ```C++ -/** +/** * @file RandomLottery.h - * @brief Random Lottery contract definition: state, data structures, and user / internal procedures. + * @brief Random Lottery contract definition: state, data structures, and user / internal + * procedures. * * This header declares the RL (Random Lottery) contract which: * - Sells tickets during a SELLING epoch. @@ -59,7 +60,7 @@ Random Lottery (RL) is a simple raffle contract that sells one ticket per accoun using namespace QPI; -// Maximum number of players allowed in the lottery. +/// Maximum number of players allowed in the lottery. constexpr uint16 RL_MAX_NUMBER_OF_PLAYERS = 1024; /// Maximum number of winners kept in the on-chain winners history buffer. @@ -72,14 +73,14 @@ constexpr uint16 RL_MAX_NUMBER_OF_WINNERS_IN_HISTORY = 1024; * The macro ID and the individual token macros (_Z, _T, _Q, etc.) must be available. * If clang reports 'ID' undeclared here, include the QPI identity / address utilities first. */ -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); +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); /// Owner address (currently identical to developer address; can be split in future revisions). static const id RL_OWNER_ADDRESS = RL_DEV_ADDRESS; /// Placeholder structure for future extensions. -struct RL2 { +struct RL2 +{ }; /** @@ -92,414 +93,485 @@ struct RL2 { * 4. END_EPOCH closes, computes fees, selects winner, distributes, burns rest. * 5. Players list is cleared for next epoch. */ -struct RL : public ContractBase { +struct RL : public ContractBase +{ public: - /** - * @brief High-level finite state of the lottery. - * SELLING: tickets can be purchased. - * LOCKED: purchases closed; waiting for epoch transition. - */ - enum class EState : uint8 { - SELLING, - LOCKED - }; - - /** - * @brief Standardized return / error codes for procedures. - */ - enum class EReturnCode : uint8 { - SUCCESS = 0, - // Ticket-related errors - TICKET_INVALID_PRICE = 1, - TICKET_ALREADY_PURCHASED = 2, - TICKET_ALL_SOLD_OUT = 3, - TICKET_SELLING_CLOSED = 4, - // Access-related errors - ACCESS_DENIED = 5, - // Fee-related errors - FEE_INVALID_PRECENT_VALUE = 6, - // Fallback - UNKNOW_ERROR = UINT8_MAX - }; - - //---- User-facing I/O structures ------------------------------------------------------------- - - struct BuyTicket_input { - }; - - struct BuyTicket_output { - uint8 returnCode = static_cast(EReturnCode::SUCCESS); - }; - - struct GetFees_input { - }; - - struct GetFees_output { - uint8 teamFeePercent = 0; - uint8 distributionFeePercent = 0; - uint8 winnerFeePercent = 0; - uint8 burnPrecent = 0; - uint8 returnCode = static_cast(EReturnCode::SUCCESS); - }; - - struct GetPlayers_input { - }; - - struct GetPlayers_output { - Array players; - uint16 numberOfPlayers = 0; - uint8 returnCode = static_cast(EReturnCode::SUCCESS); - }; - - struct GetPlayers_locals { - uint64 arrayIndex = 0; - sint32 i = 0; - }; - - /** - * @brief Stored winner snapshot for an epoch. - */ - struct WinnerInfo { - id winnerAddress = id::zero(); - uint64 revenue = 0; - uint16 epoch = 0; - uint32 tick = 0; - }; - - struct FillWinnersInfo_input { - id winnerAddress = id::zero(); - uint64 revenue = 0; - }; - - struct FillWinnersInfo_output { - }; - - struct FillWinnersInfo_locals { - WinnerInfo winnerInfo = {}; - }; - - struct GetWinner_input { - }; - - struct GetWinner_output { - id winnerAddress = id::zero(); - uint64 index = 0; - }; - - struct GetWinner_locals { - uint64 randomNum = 0; - sint32 i = 0; - sint32 j = 0; - }; - - struct GetWinners_input { - }; - - struct GetWinners_output { - Array winners; - uint64 numberOfWinners = 0; - uint8 returnCode = static_cast(EReturnCode::SUCCESS); - }; - - struct END_EPOCH_locals { - GetWinner_input getWinnerInput = {}; - GetWinner_output getWinnerOutput = {}; - GetWinner_locals getWinnerLocals = {}; - - FillWinnersInfo_input fillWinnersInfoInput = {}; - FillWinnersInfo_output fillWinnersInfoOutput = {}; - FillWinnersInfo_locals fillWinnersInfoLocals = {}; - - uint64 teamFee = 0; - uint64 distributionFee = 0; - uint64 winnerAmount = 0; - uint64 burnedAmount = 0; - - uint64 revenue = 0; - Entity entity; - - sint32 i = 0; - }; + /** + * @brief High-level finite state of the lottery. + * SELLING: tickets can be purchased. + * LOCKED: purchases closed; waiting for epoch transition. + */ + enum class EState : uint8 + { + SELLING, + LOCKED + }; + + /** + * @brief Standardized return / error codes for procedures. + */ + enum class EReturnCode : uint8 + { + SUCCESS = 0, + // Ticket-related errors + TICKET_INVALID_PRICE = 1, + TICKET_ALREADY_PURCHASED = 2, + TICKET_ALL_SOLD_OUT = 3, + TICKET_SELLING_CLOSED = 4, + // Access-related errors + ACCESS_DENIED = 5, + // Fee-related errors + FEE_INVALID_PERCENT_VALUE = 6, + // Fallback + UNKNOW_ERROR = UINT8_MAX + }; + + //---- User-facing I/O structures ------------------------------------------------------------- + + struct BuyTicket_input + { + }; + + struct BuyTicket_output + { + uint8 returnCode = static_cast(EReturnCode::SUCCESS); + }; + + struct GetFees_input + { + }; + + struct GetFees_output + { + uint8 teamFeePercent = 0; + uint8 distributionFeePercent = 0; + uint8 winnerFeePercent = 0; + uint8 burnPercent = 0; + uint8 returnCode = static_cast(EReturnCode::SUCCESS); + }; + + struct GetPlayers_input + { + }; + + struct GetPlayers_output + { + Array players; + uint16 numberOfPlayers = 0; + uint8 returnCode = static_cast(EReturnCode::SUCCESS); + }; + + struct GetPlayers_locals + { + uint64 arrayIndex = 0; + sint32 i = 0; + }; + + /** + * @brief Stored winner snapshot for an epoch. + */ + struct WinnerInfo + { + id winnerAddress = id::zero(); + uint64 revenue = 0; + uint16 epoch = 0; + uint32 tick = 0; + }; + + struct FillWinnersInfo_input + { + id winnerAddress = id::zero(); + uint64 revenue = 0; + }; + + struct FillWinnersInfo_output + { + }; + + struct FillWinnersInfo_locals + { + WinnerInfo winnerInfo = {}; + }; + + struct GetWinner_input + { + }; + + struct GetWinner_output + { + id winnerAddress = id::zero(); + uint64 index = 0; + }; + + struct GetWinner_locals + { + uint64 randomNum = 0; + sint32 i = 0; + sint32 j = 0; + }; + + struct GetWinners_input + { + }; + + struct GetWinners_output + { + Array winners; + uint64 numberOfWinners = 0; + uint8 returnCode = static_cast(EReturnCode::SUCCESS); + }; + + struct ReturnAllTickets_input + { + }; + struct ReturnAllTickets_output + { + }; + + struct ReturnAllTickets_locals + { + sint32 i = 0; + }; + + struct END_EPOCH_locals + { + GetWinner_input getWinnerInput = {}; + GetWinner_output getWinnerOutput = {}; + GetWinner_locals getWinnerLocals = {}; + + FillWinnersInfo_input fillWinnersInfoInput = {}; + FillWinnersInfo_output fillWinnersInfoOutput = {}; + FillWinnersInfo_locals fillWinnersInfoLocals = {}; + + ReturnAllTickets_input returnAllTicketsInput = {}; + ReturnAllTickets_output returnAllTicketsOutput = {}; + ReturnAllTickets_locals returnAllTicketsLocals = {}; + + uint64 teamFee = 0; + uint64 distributionFee = 0; + uint64 winnerAmount = 0; + uint64 burnedAmount = 0; + + uint64 revenue = 0; + Entity entity = {}; + + sint32 i = 0; + }; public: - /** - * @brief Registers all externally callable functions and procedures with their numeric identifiers. - * Mapping numbers must remain stable to preserve external interface compatibility. - */ - REGISTER_USER_FUNCTIONS_AND_PROCEDURES() { - REGISTER_USER_FUNCTION(GetFees, 1); - REGISTER_USER_FUNCTION(GetPlayers, 2); - REGISTER_USER_FUNCTION(GetWinners, 3); - REGISTER_USER_PROCEDURE(BuyTicket, 1); - } - - /** - * @brief Contract initialization hook. - * Sets default fees, ticket price, addresses, and locks the lottery (no selling yet). - */ - INITIALIZE() { - // Addresses - state.teamAddress = RL_DEV_ADDRESS; - state.ownerAddress = RL_OWNER_ADDRESS; - - // Default fee percentages (sum <= 100; winner percent derived) - state.teamFeePercent = 10; - state.distributionFeePercent = 20; - state.burnPrecent = 2; - state.winnerFeePercent = 100 - state.teamFeePercent - state.distributionFeePercent - state.burnPrecent; - - // Default ticket price - state.ticketPrice = 1000000; - - - // Start locked - state.currentState = EState::LOCKED; - } - - /** - * @brief Opens ticket selling for a new epoch. - */ - BEGIN_EPOCH() { - state.currentState = EState::SELLING; - } - - /** - * @brief Closes epoch: computes revenue, selects winner (if >1 player), - * distributes fees, burns leftover, records winner, then clears players. - */ - END_EPOCH_WITH_LOCALS() { - state.currentState = EState::LOCKED; - - state.players.cleanup(); - - // Single-player edge case: refund instead of drawing. - if (state.players.population() == 1) { - for (locals.i = 0; locals.i < state.players.capacity(); ++locals.i) { - if (!state.players.isEmptySlot(locals.i)) { - qpi.transfer(state.players.key(locals.i), state.ticketPrice); - break; - } - } - } else if (state.players.population() > 1) { - qpi.getEntity(SELF, locals.entity); - locals.revenue = locals.entity.incomingAmount - locals.entity.outgoingAmount; - - // Winner selection (pseudo-random). - GetWinner(qpi, state, locals.getWinnerInput, locals.getWinnerOutput, locals.getWinnerLocals); - - if (locals.getWinnerOutput.winnerAddress != id::zero()) { - // 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.burnPrecent, 100ULL); - - // Team fee - if (locals.teamFee > 0) { - qpi.transfer(state.teamAddress, locals.teamFee); - } - - // Distribution fee - if (locals.distributionFee > 0) { - qpi.distributeDividends(div(locals.distributionFee, uint64(NUMBER_OF_COMPUTORS))); - } - - // Winner payout - if (locals.winnerAmount > 0) { - qpi.transfer(locals.getWinnerOutput.winnerAddress, locals.winnerAmount); - } - - // Burn remainder - if (locals.burnedAmount > 0) { - qpi.burn(locals.burnedAmount); - } - - // Persist winner record - locals.fillWinnersInfoInput.winnerAddress = locals.getWinnerOutput.winnerAddress; - locals.fillWinnersInfoInput.revenue = locals.winnerAmount; - FillWinnersInfo(qpi, state, locals.fillWinnersInfoInput, locals.fillWinnersInfoOutput, - locals.fillWinnersInfoLocals); - } - } - - // Prepare for next epoch. - state.players.reset(); - } - - /** - * @brief Returns currently configured fee percentages. - */ - PUBLIC_FUNCTION(GetFees) { - output.teamFeePercent = state.teamFeePercent; - output.distributionFeePercent = state.distributionFeePercent; - output.winnerFeePercent = state.winnerFeePercent; - output.burnPrecent = state.burnPrecent; - } - - /** - * @brief Retrieves the active players list for the ongoing epoch. - */ - PUBLIC_FUNCTION_WITH_LOCALS(GetPlayers) { - locals.arrayIndex = 0; - - locals.i = 0; - for (locals.i = 0; locals.i < state.players.capacity(); ++locals.i) { - if (!state.players.isEmptySlot(locals.i)) { - output.players.set(locals.arrayIndex++, state.players.key(locals.i)); - } - } - - output.numberOfPlayers = locals.arrayIndex; - } - - /** - * @brief Returns historical winners (ring buffer segment). - */ - PUBLIC_FUNCTION(GetWinners) { - output.winners = state.winners; - output.numberOfWinners = state.winnersInfoNextEmptyIndex; - } - - /** - * @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. - */ - PUBLIC_PROCEDURE(BuyTicket) { - // 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()); - } - return; - } - - // Already purchased - if (state.players.contains(qpi.invocator())) { - output.returnCode = static_cast(EReturnCode::TICKET_ALREADY_PURCHASED); - return; - } - - // Capacity full - if (state.players.add(qpi.invocator()) == NULL_INDEX) { - output.returnCode = static_cast(EReturnCode::TICKET_ALL_SOLD_OUT); - return; - } - - // Price mismatch - if (qpi.invocationReward() != state.ticketPrice && qpi.invocationReward() > 0) { - output.returnCode = static_cast(EReturnCode::TICKET_INVALID_PRICE); - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - state.players.remove(qpi.invocator()); - return; - } - } + /** + * @brief Registers all externally callable functions and procedures with their numeric + * identifiers. Mapping numbers must remain stable to preserve external interface compatibility. + */ + REGISTER_USER_FUNCTIONS_AND_PROCEDURES() + { + REGISTER_USER_FUNCTION(GetFees, 1); + REGISTER_USER_FUNCTION(GetPlayers, 2); + REGISTER_USER_FUNCTION(GetWinners, 3); + REGISTER_USER_PROCEDURE(BuyTicket, 1); + } + + /** + * @brief Contract initialization hook. + * Sets default fees, ticket price, addresses, and locks the lottery (no selling yet). + */ + INITIALIZE() + { + // Addresses + state.teamAddress = RL_DEV_ADDRESS; + state.ownerAddress = RL_OWNER_ADDRESS; + + // Default fee percentages (sum <= 100; winner percent derived) + state.teamFeePercent = 10; + state.distributionFeePercent = 20; + state.burnPercent = 2; + state.winnerFeePercent = 100 - state.teamFeePercent - state.distributionFeePercent - state.burnPercent; + + // Default ticket price + state.ticketPrice = 1000000; + + // Start locked + state.currentState = EState::LOCKED; + } + + /** + * @brief Opens ticket selling for a new epoch. + */ + BEGIN_EPOCH() { state.currentState = EState::SELLING; } + + /** + * @brief Closes epoch: computes revenue, selects winner (if >1 player), + * distributes fees, burns leftover, records winner, then clears players. + */ + END_EPOCH_WITH_LOCALS() + { + state.currentState = EState::LOCKED; + + // Single-player edge case: refund instead of drawing. + if (state.players.population() == 1) + { + ReturnAllTickets(qpi, state, locals.returnAllTicketsInput, locals.returnAllTicketsOutput, locals.returnAllTicketsLocals); + } + else if (state.players.population() > 1) + { + qpi.getEntity(SELF, locals.entity); + locals.revenue = locals.entity.incomingAmount - locals.entity.outgoingAmount; + + // Winner selection (pseudo-random). + GetWinner(qpi, state, locals.getWinnerInput, locals.getWinnerOutput, locals.getWinnerLocals); + + if (locals.getWinnerOutput.winnerAddress != id::zero()) + { + // 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); + } + + // Distribution fee + if (locals.distributionFee > 0) + { + qpi.distributeDividends(div(locals.distributionFee, uint64(NUMBER_OF_COMPUTORS))); + } + + // Winner payout + if (locals.winnerAmount > 0) + { + qpi.transfer(locals.getWinnerOutput.winnerAddress, locals.winnerAmount); + } + + // Burn remainder + if (locals.burnedAmount > 0) + { + qpi.burn(locals.burnedAmount); + } + + // 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(); + } + + /** + * @brief Returns currently configured fee percentages. + */ + PUBLIC_FUNCTION(GetFees) + { + output.teamFeePercent = state.teamFeePercent; + output.distributionFeePercent = state.distributionFeePercent; + output.winnerFeePercent = state.winnerFeePercent; + output.burnPercent = state.burnPercent; + } + + /** + * @brief Retrieves the active players list for the ongoing epoch. + */ + PUBLIC_FUNCTION_WITH_LOCALS(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 = locals.arrayIndex; + } + + /** + * @brief Returns historical winners (ring buffer segment). + */ + PUBLIC_FUNCTION(GetWinners) + { + output.winners = state.winners; + output.numberOfWinners = state.winnersInfoNextEmptyIndex; + } + + /** + * @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. + */ + PUBLIC_PROCEDURE(BuyTicket) + { + // 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()); + } + return; + } + + // Already purchased + if (state.players.contains(qpi.invocator())) + { + output.returnCode = static_cast(EReturnCode::TICKET_ALREADY_PURCHASED); + return; + } + + // Capacity full + if (state.players.add(qpi.invocator()) == NULL_INDEX) + { + output.returnCode = static_cast(EReturnCode::TICKET_ALL_SOLD_OUT); + return; + } + + // Price mismatch + if (qpi.invocationReward() != state.ticketPrice && qpi.invocationReward() > 0) + { + output.returnCode = static_cast(EReturnCode::TICKET_INVALID_PRICE); + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + state.players.remove(qpi.invocator()); + + state.players.cleanupIfNeeded(80); + return; + } + } private: - /** - * @brief Internal: records a winner into the cyclic winners array. - */ - PRIVATE_PROCEDURE_WITH_LOCALS(FillWinnersInfo) { - if (input.winnerAddress == id::zero()) { - return; - } - if (RL_MAX_NUMBER_OF_WINNERS_IN_HISTORY >= state.winners.capacity() - 1) { - state.winnersInfoNextEmptyIndex = 0; - } - 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) { - state.players.cleanup(); - - if (state.players.population() == 0) { - return; - } - - locals.randomNum = mod(qpi.K12(qpi.getPrevSpectrumDigest()).u64._0, state.players.population()); - for (locals.i = 0, locals.j = 0; locals.i < state.players.capacity(); ++locals.i) { - if (!state.players.isEmptySlot(locals.i)) { - if (locals.j++ == locals.randomNum) { - output.winnerAddress = state.players.key(locals.i); - output.index = locals.i; - break; - } - } - } - } + /** + * @brief Internal: records a winner into the cyclic winners array. + */ + PRIVATE_PROCEDURE_WITH_LOCALS(FillWinnersInfo) + { + if (input.winnerAddress == id::zero()) + { + return; + } + if (RL_MAX_NUMBER_OF_WINNERS_IN_HISTORY >= state.winners.capacity() - 1) + { + state.winnersInfoNextEmptyIndex = 0; + } + 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.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); + }; + } + + PRIVATE_PROCEDURE_WITH_LOCALS(ReturnAllTickets) + { + locals.i = state.players.nextElementIndex(NULL_INDEX); + while (locals.i != NULL_INDEX) + { + qpi.transfer(state.players.key(locals.i), state.ticketPrice); + + locals.i = state.players.nextElementIndex(locals.i); + }; + } 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. - */ - id ownerAddress = id::zero(); - - /** - * @brief Percentage of the revenue allocated to the team. - * Value is between 0 and 100. - */ - uint8 teamFeePercent = 0; - - /** - * @brief Percentage of the revenue allocated for distribution. - * Value is between 0 and 100. - */ - uint8 distributionFeePercent = 0; - - /** - * @brief Percentage of the revenue allocated to the winner. - * Automatically calculated as the remainder after other fees. - */ - uint8 winnerFeePercent = 0; - - /** - * @brief Percentage of the revenue to be burned. - * Value is between 0 and 100. - */ - uint8 burnPrecent = 0; - - /** - * @brief Price of a single lottery ticket. - * Value is in the smallest currency unit (e.g., cents). - */ - uint64 ticketPrice = 0; - - /** - * @brief Set of players participating in the current lottery epoch. - * Maximum capacity is defined by RL_MAX_NUMBER_OF_PLAYERS. - */ - HashSet players = {}; - - /** - * @brief Circular buffer storing the history of winners. - * Maximum capacity is defined by RL_MAX_NUMBER_OF_WINNERS_IN_HISTORY. - */ - Array winners = {}; - - /** - * @brief Index pointing to the next empty slot in the winners array. - * Used for maintaining the circular buffer of winners. - */ - uint64 winnersInfoNextEmptyIndex = 0; - - /** - * @brief Current state of the lottery contract. - * Can be either SELLING (tickets available) or LOCKED (epoch closed). - */ - EState currentState = EState::LOCKED; + /** + * @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. + */ + id ownerAddress = id::zero(); + + /** + * @brief Percentage of the revenue allocated to the team. + * Value is between 0 and 100. + */ + uint8 teamFeePercent = 0; + + /** + * @brief Percentage of the revenue allocated for distribution. + * Value is between 0 and 100. + */ + uint8 distributionFeePercent = 0; + + /** + * @brief Percentage of the revenue allocated to the winner. + * Automatically calculated as the remainder after other fees. + */ + uint8 winnerFeePercent = 0; + + /** + * @brief Percentage of the revenue to be burned. + * Value is between 0 and 100. + */ + uint8 burnPercent = 0; + + /** + * @brief Price of a single lottery ticket. + * Value is in the smallest currency unit (e.g., cents). + */ + uint64 ticketPrice = 0; + + /** + * @brief Set of players participating in the current lottery epoch. + * Maximum capacity is defined by RL_MAX_NUMBER_OF_PLAYERS. + */ + HashSet players = {}; + + /** + * @brief Circular buffer storing the history of winners. + * Maximum capacity is defined by RL_MAX_NUMBER_OF_WINNERS_IN_HISTORY. + */ + Array winners = {}; + + /** + * @brief Index pointing to the next empty slot in the winners array. + * Used for maintaining the circular buffer of winners. + */ + uint64 winnersInfoNextEmptyIndex = 0; + + /** + * @brief Current state of the lottery contract. + * Can be either SELLING (tickets available) or LOCKED (epoch closed). + */ + EState currentState = EState::LOCKED; }; ``` From d54d79d17c27592ee28fa3c6d74ea0b83021e963 Mon Sep 17 00:00:00 2001 From: N-010 Date: Sun, 26 Oct 2025 11:33:22 +0300 Subject: [PATCH 6/9] Create 2025-09-13-RandomLotteryV2.md --- SmartContracts/2025-09-13-RandomLotteryV2.md | 39 ++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 SmartContracts/2025-09-13-RandomLotteryV2.md diff --git a/SmartContracts/2025-09-13-RandomLotteryV2.md b/SmartContracts/2025-09-13-RandomLotteryV2.md new file mode 100644 index 0000000..350de76 --- /dev/null +++ b/SmartContracts/2025-09-13-RandomLotteryV2.md @@ -0,0 +1,39 @@ +# RL-02: Flexible Ticketing & Epoch Configuration + +**Author:** RandomLottery (RL) Team +**Goal:** Enable more lottery formats without changing code. + +## Summary + +We propose four product changes to RL: + +1. **Unlimited tickets per account** — a single account can buy any number of tickets within one epoch. +2. **Configurable price for the next epoch** — set the price in advance; it takes effect at the start of the following epoch. +3. **Configurable number of draws per epoch** — run one or multiple draws within the same epoch. +4. **Configurable number of winners** — each draw can have one or multiple winners. + +Together, these changes enable “premium,” “standard,” and “dynamic” formats while preserving RL’s transparency. + +## Motivation + +* **Flexibility:** tune price, pacing, and winner count to match demand, promotions, and events. +* **Higher engagement:** wins happen more often, with varied play styles. +* **Operational simplicity:** all adjustments are planned per epoch, with no code redeploys. + +## What Changes for Players + +* You can buy **multiple tickets** in a single epoch. +* The site will show both the **current price** and the **scheduled next-epoch price**. +* An epoch may include **multiple draws**, and **each draw can have multiple winners** (the winner share is divided among them). + +## Benefits + +* **Larger prize pools:** power users can grow the bank by buying more tickets. +* **Different play styles:** from rare “premium” draws to frequent “dynamic” ones with several winners. +* **Transparency:** all changes are announced in advance and take effect from the next epoch. + +## Example Formats + +* **Premium:** higher ticket price, 1 draw, 1 winner — maximized single payout (e.g., every 3–4 epochs). +* **Dynamic:** lower ticket price, 2–3 draws per epoch, **2–5 winners** in each — frequent wins and more happy players. +* **Community Weeks:** temporary price reductions + more winners to attract new players. From 0abfe91bb2ed0fec3357c9728367ada1bda81105 Mon Sep 17 00:00:00 2001 From: N-010 Date: Wed, 29 Oct 2025 20:53:10 +0300 Subject: [PATCH 7/9] Update 2025-09-13-RandomLotteryV2.md --- SmartContracts/2025-09-13-RandomLotteryV2.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/SmartContracts/2025-09-13-RandomLotteryV2.md b/SmartContracts/2025-09-13-RandomLotteryV2.md index 350de76..3b2aeb7 100644 --- a/SmartContracts/2025-09-13-RandomLotteryV2.md +++ b/SmartContracts/2025-09-13-RandomLotteryV2.md @@ -3,6 +3,12 @@ **Author:** RandomLottery (RL) Team **Goal:** Enable more lottery formats without changing code. +## Available Options + +> Option 0: no, don’t allow + +> Option 1: yes, allow + ## Summary We propose four product changes to RL: From 69056146c6e811648cf4ba55031b3db6c7b9139b Mon Sep 17 00:00:00 2001 From: N-010 Date: Thu, 30 Oct 2025 21:46:28 +0300 Subject: [PATCH 8/9] Update 2025-09-13-RandomLotteryV2.md --- SmartContracts/2025-09-13-RandomLotteryV2.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/SmartContracts/2025-09-13-RandomLotteryV2.md b/SmartContracts/2025-09-13-RandomLotteryV2.md index 3b2aeb7..3f8dc12 100644 --- a/SmartContracts/2025-09-13-RandomLotteryV2.md +++ b/SmartContracts/2025-09-13-RandomLotteryV2.md @@ -43,3 +43,8 @@ Together, these changes enable “premium,” “standard,” and “dynamic” * **Premium:** higher ticket price, 1 draw, 1 winner — maximized single payout (e.g., every 3–4 epochs). * **Dynamic:** lower ticket price, 2–3 draws per epoch, **2–5 winners** in each — frequent wins and more happy players. * **Community Weeks:** temporary price reductions + more winners to attract new players. + +## Code change +For detailled code changes see the pull request below: +https://github.com/qubic/core/pull/586 + From fddb3689a367b19a6e2a1b55ecc393ab5a355531 Mon Sep 17 00:00:00 2001 From: N-010 Date: Mon, 3 Nov 2025 22:53:28 +0300 Subject: [PATCH 9/9] Update 2025-09-13-RandomLotteryV2.md --- SmartContracts/2025-09-13-RandomLotteryV2.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/SmartContracts/2025-09-13-RandomLotteryV2.md b/SmartContracts/2025-09-13-RandomLotteryV2.md index 3f8dc12..e577d18 100644 --- a/SmartContracts/2025-09-13-RandomLotteryV2.md +++ b/SmartContracts/2025-09-13-RandomLotteryV2.md @@ -48,3 +48,6 @@ Together, these changes enable “premium,” “standard,” and “dynamic” For detailled code changes see the pull request below: https://github.com/qubic/core/pull/586 +## Migration tool +https://github.com/N-010/convert-rl-state +