diff --git a/SmartContracts/2025-09-13-RandomLottery.md b/SmartContracts/2025-09-13-RandomLottery.md new file mode 100644 index 0000000..9340a80 --- /dev/null +++ b/SmartContracts/2025-09-13-RandomLottery.md @@ -0,0 +1,577 @@ +# 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++ +/** + * @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 RL_MAX_NUMBER_OF_PLAYERS = 1024; + +/// Maximum number of winners kept in the on-chain winners history buffer. +constexpr uint16 RL_MAX_NUMBER_OF_WINNERS_IN_HISTORY = 1024; + +/** + * @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); +/// 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_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.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) + { + 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 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; +}; +``` diff --git a/SmartContracts/2025-09-13-RandomLotteryV2.md b/SmartContracts/2025-09-13-RandomLotteryV2.md new file mode 100644 index 0000000..e577d18 --- /dev/null +++ b/SmartContracts/2025-09-13-RandomLotteryV2.md @@ -0,0 +1,53 @@ +# RL-02: Flexible Ticketing & Epoch Configuration + +**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: + +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. + +## Code change +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 +