diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index f88141938..9d35721d3 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -37,12 +37,6 @@ 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; @@ -53,6 +47,12 @@ constexpr uint8 RL_DEFAULT_SCHEDULE = 1 << WEDNESDAY | 1 << FRIDAY | 1 << SUNDAY constexpr uint32 RL_DEFAULT_INIT_TIME = 22 << 9 | 4 << 5 | 13; +constexpr uint64 RL_LOTTO_TOKEN_NAME = 0x4f54544f4cull; // "LOTTO" little-endian + +constexpr uint64 RL_DEFAULT_TOKEN_PRICE = 12; + +constexpr uint64 RL_TOKEN_REWARD_DIVISOR = 3; + /// Placeholder structure for future extensions. struct RL2 { @@ -78,12 +78,19 @@ struct RL : public ContractBase */ enum class EState : uint8 { - SELLING, // Ticket selling is open - LOCKED, // Ticket selling is closed - - INVALID = UINT8_MAX + SELLING = 1 << 0, // Ticket selling is open }; + friend EState operator|(const EState& a, const EState& b) { return static_cast(static_cast(a) | static_cast(b)); } + + friend EState operator&(const EState& a, const EState& b) { return static_cast(static_cast(a) & static_cast(b)); } + + friend EState operator~(const EState& a) { return static_cast(~static_cast(a)); } + + template friend bool operator==(const EState& a, const T& b) { return static_cast(a) == b; } + + template friend bool operator!=(const EState& a, const T& b) { return !(a == b); } + /** * @brief Standardized return / error codes for procedures. */ @@ -101,15 +108,25 @@ struct RL : public ContractBase // Value-related errors INVALID_VALUE, // Input value is not acceptable + // Token-related errors + TOKEN_TRANSFER_FAILED, // Token transfer to/from the contract failed + UNKNOWN_ERROR = UINT8_MAX }; + static constexpr uint8 toReturnCode(EReturnCode code) { return static_cast(code); } + 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 }; + struct TokenData + { + uint64 pricePerTicket; + }; + //---- User-facing I/O structures ------------------------------------------------------------- struct BuyTicket_input @@ -121,6 +138,16 @@ struct RL : public ContractBase uint8 returnCode; }; + struct BuyTicketWithToken_input + { + uint64 tokenAmount; + }; + + struct BuyTicketWithToken_output + { + uint8 returnCode; + }; + struct GetFees_input { }; @@ -229,7 +256,6 @@ struct RL : public ContractBase // 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 @@ -241,6 +267,18 @@ struct RL : public ContractBase uint64 i; // Loop counter }; + struct BuyTicketWithToken_locals + { + uint64 slotsLeft; // Remaining slots available to fill this epoch + uint64 desired; // How many tickets the caller wants to buy + uint64 remainder; // Change to return (tokenAmount % 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 in tokens: remainder + unfilled * price + uint64 burnAmount; // Tokens to burn corresponding to purchased tickets + uint64 i; // Loop counter + }; + struct ReturnAllTickets_input { }; @@ -276,14 +314,19 @@ struct RL : public ContractBase struct BEGIN_TICK_locals { id winnerAddress; + id firstPlayer; m256i mixedSpectrumValue; Entity entity; uint64 revenue; uint64 randomNum; + uint64 shuffleIndex; + uint64 swapIndex; uint64 winnerAmount; uint64 teamFee; uint64 distributionFee; uint64 burnedAmount; + uint64 index; + sint64 rewardPerTicket; FillWinnersInfo_locals fillWinnersInfoLocals; FillWinnersInfo_input fillWinnersInfoInput; uint32 currentDateStamp; @@ -291,10 +334,12 @@ struct RL : public ContractBase uint8 currentHour; uint8 isWednesday; uint8 isScheduledToday; + bit hasMultipleParticipants; ReturnAllTickets_locals returnAllTicketsLocals; ReturnAllTickets_input returnAllTicketsInput; ReturnAllTickets_output returnAllTicketsOutput; FillWinnersInfo_output fillWinnersInfoOutput; + uint64 tokenPrice; }; struct GetNextEpochData_input @@ -324,6 +369,40 @@ struct RL : public ContractBase uint8 schedule; }; + struct GetTokenData_input + { + }; + struct GetTokenData_output + { + TokenData tokenData; + }; + + struct SetTokenRewardDivisor_input + { + uint64 tokenRewardDivisor; + }; + + struct SetTokenRewardDivisor_output + { + uint8 returnCode; + }; + + struct TransferToken_input + { + id newOwner; + uint64 amount; + }; + + struct TransferToken_output + { + uint8 returnCode; + }; + + struct TransferToken_locals + { + id ownerAndPossessor; + }; + public: /** * @brief Registers all externally callable functions and procedures with their numeric @@ -341,9 +420,14 @@ struct RL : public ContractBase REGISTER_USER_FUNCTION(GetNextEpochData, 8); REGISTER_USER_FUNCTION(GetDrawHour, 9); REGISTER_USER_FUNCTION(GetSchedule, 10); + REGISTER_USER_FUNCTION(GetTokenData, 11); + REGISTER_USER_PROCEDURE(BuyTicket, 1); REGISTER_USER_PROCEDURE(SetPrice, 2); REGISTER_USER_PROCEDURE(SetSchedule, 3); + REGISTER_USER_PROCEDURE(BuyTicketWithToken, 4); + REGISTER_USER_PROCEDURE(SetTokenRewardDivisor, 7); + REGISTER_USER_PROCEDURE(TransferToken, 8); } /** @@ -367,7 +451,7 @@ struct RL : public ContractBase state.ticketPrice = RL_TICKET_PRICE; // Start in LOCKED state; selling must be explicitly opened with BEGIN_EPOCH - state.currentState = EState::LOCKED; + enableBuyTicket(state, false); // Reset player counter state.playerCounter = 0; @@ -397,6 +481,21 @@ struct RL : public ContractBase // Open selling for the new epoch enableBuyTicket(state, state.lastDrawDateStamp != RL_DEFAULT_INIT_TIME); + + if (!qpi.isAssetIssued(SELF, RL_LOTTO_TOKEN_NAME)) + { + qpi.issueAsset(RL_LOTTO_TOKEN_NAME, SELF, 0, MAX_AMOUNT, 0); + } + + if (state.lottoTokenPrice == 0) + { + state.lottoTokenPrice = RL_DEFAULT_TOKEN_PRICE; + } + + if (state.tokenRewardDivisor == 0) + { + state.tokenRewardDivisor = RL_TOKEN_REWARD_DIVISOR; + } } END_EPOCH() @@ -477,12 +576,46 @@ struct RL : public ContractBase // Draw { - if (state.playerCounter <= 1) + locals.hasMultipleParticipants = false; + if (state.playerCounter >= 2) + { + for (locals.index = 1; locals.index < state.playerCounter; ++locals.index) + { + if (state.players.get(locals.index) != state.players.get(0)) + { + locals.hasMultipleParticipants = true; + break; + } + } + } + + if (!locals.hasMultipleParticipants) { ReturnAllTickets(qpi, state, locals.returnAllTicketsInput, locals.returnAllTicketsOutput, locals.returnAllTicketsLocals); } else { + // Deterministically shuffle players before drawing so all nodes observe the same order + locals.mixedSpectrumValue = qpi.getPrevSpectrumDigest(); + locals.mixedSpectrumValue.u64._0 ^= qpi.tick(); + locals.mixedSpectrumValue.u64._1 ^= state.playerCounter; + locals.randomNum = qpi.K12(locals.mixedSpectrumValue).u64._0; + + for (locals.shuffleIndex = state.playerCounter - 1; locals.shuffleIndex > 0; --locals.shuffleIndex) + { + locals.randomNum ^= locals.randomNum << 13; + locals.randomNum ^= locals.randomNum >> 7; + locals.randomNum ^= locals.randomNum << 17; + locals.swapIndex = mod(locals.randomNum, locals.shuffleIndex + 1); + + if (locals.swapIndex != locals.shuffleIndex) + { + locals.firstPlayer = state.players.get(locals.shuffleIndex); + state.players.set(locals.shuffleIndex, state.players.get(locals.swapIndex)); + state.players.set(locals.swapIndex, locals.firstPlayer); + } + } + // Current contract net balance = incoming - outgoing for this contract qpi.getEntity(SELF, locals.entity); getSCRevenue(locals.entity, locals.revenue); @@ -493,11 +626,8 @@ struct RL : public ContractBase if (state.playerCounter != 0) { - locals.mixedSpectrumValue = qpi.getPrevSpectrumDigest(); - locals.mixedSpectrumValue.u64._0 ^= qpi.tick(); - locals.mixedSpectrumValue.u64._1 ^= state.playerCounter; - // Compute pseudo-random index based on K12(prevSpectrumDigest ^ tick) - locals.randomNum = mod(qpi.K12(locals.mixedSpectrumValue).u64._0, state.playerCounter); + locals.randomNum = qpi.K12(locals.mixedSpectrumValue).u64._0; + locals.randomNum = mod(locals.randomNum, state.playerCounter); // Index directly into players array locals.winnerAddress = state.players.get(locals.randomNum); @@ -507,10 +637,10 @@ struct RL : public ContractBase if (locals.winnerAddress != id::zero()) { // Split revenue by configured percentages - locals.winnerAmount = div(locals.revenue * state.winnerFeePercent, 100ULL); - locals.teamFee = div(locals.revenue * state.teamFeePercent, 100ULL); - locals.distributionFee = div(locals.revenue * state.distributionFeePercent, 100ULL); - locals.burnedAmount = div(locals.revenue * state.burnPercent, 100ULL); + locals.winnerAmount = div(smul(locals.revenue, static_cast(state.winnerFeePercent)), 100ULL); + locals.teamFee = div(smul(locals.revenue, static_cast(state.teamFeePercent)), 100ULL); + locals.distributionFee = div(smul(locals.revenue, static_cast(state.distributionFeePercent)), 100ULL); + locals.burnedAmount = div(smul(locals.revenue, static_cast(state.burnPercent)), 100ULL); // Team payout if (locals.teamFee > 0) @@ -549,6 +679,23 @@ struct RL : public ContractBase } } + // Reward Participants With LOTTO (disabled) + // { + // if (state.playerCounter != 0 && state.tokenRewardDivisor > 0) + // { + // if (state.lottoTokenPrice > 0) + // { + // locals.rewardPerTicket = max(1, div(state.lottoTokenPrice, state.tokenRewardDivisor)); + // + // for (locals.index = 0; locals.index < state.playerCounter; ++locals.index) + // { + // qpi.transferShareOwnershipAndPossession(RL_LOTTO_TOKEN_NAME, SELF, SELF, SELF, locals.rewardPerTicket, + // state.players.get(locals.index)); + // } + // } + // } + // } + clearStateOnEndDraw(state); // Resume selling unless today is Wednesday (remains closed until next epoch) @@ -604,6 +751,7 @@ struct RL : public ContractBase PUBLIC_FUNCTION(GetNextEpochData) { output.nextEpochData = state.nexEpochData; } PUBLIC_FUNCTION(GetDrawHour) { output.drawHour = state.drawHour; } PUBLIC_FUNCTION(GetSchedule) { output.schedule = state.schedule; } + PUBLIC_FUNCTION(GetTokenData) { output.tokenData.pricePerTicket = state.lottoTokenPrice; } PUBLIC_FUNCTION_WITH_LOCALS(GetBalance) { qpi.getEntity(SELF, locals.entity); @@ -612,41 +760,107 @@ struct RL : public ContractBase PUBLIC_PROCEDURE(SetPrice) { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + // Only team/owner can queue a price change if (qpi.invocator() != state.teamAddress) { - output.returnCode = static_cast(EReturnCode::ACCESS_DENIED); + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; } // Zero price is invalid if (input.newPrice == 0) { - output.returnCode = static_cast(EReturnCode::TICKET_INVALID_PRICE); + output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); return; } // Defer application until END_EPOCH state.nexEpochData.newPrice = input.newPrice; - output.returnCode = static_cast(EReturnCode::SUCCESS); + output.returnCode = toReturnCode(EReturnCode::SUCCESS); } PUBLIC_PROCEDURE(SetSchedule) { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + if (qpi.invocator() != state.teamAddress) { - output.returnCode = static_cast(EReturnCode::ACCESS_DENIED); + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); return; } if (input.newSchedule == 0) { - output.returnCode = static_cast(EReturnCode::INVALID_VALUE); + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); return; } state.nexEpochData.schedule = input.newSchedule; - output.returnCode = static_cast(EReturnCode::SUCCESS); + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + PUBLIC_PROCEDURE(SetTokenRewardDivisor) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (qpi.invocator() != state.ownerAddress) + { + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); + return; + } + + if (input.tokenRewardDivisor == 0) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + state.tokenRewardDivisor = input.tokenRewardDivisor; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + PUBLIC_PROCEDURE_WITH_LOCALS(TransferToken) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (input.amount == 0) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + if (isZero(input.newOwner)) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + locals.ownerAndPossessor = qpi.invocator() == state.ownerAddress ? SELF : qpi.invocator(); + if (qpi.numberOfPossessedShares(RL_LOTTO_TOKEN_NAME, SELF, locals.ownerAndPossessor, locals.ownerAndPossessor, SELF_INDEX, SELF_INDEX) < + input.amount) + { + output.returnCode = toReturnCode(EReturnCode::TOKEN_TRANSFER_FAILED); + return; + } + + qpi.transferShareOwnershipAndPossession(RL_LOTTO_TOKEN_NAME, SELF, locals.ownerAndPossessor, locals.ownerAndPossessor, input.amount, + input.newOwner); + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); } /** @@ -662,28 +876,26 @@ struct RL : public ContractBase locals.reward = qpi.invocationReward(); // Selling closed: refund any attached funds and exit - if (state.currentState == EState::LOCKED) + if (!isSellingOpen(state)) { if (locals.reward > 0) { qpi.transfer(qpi.invocator(), locals.reward); } - output.returnCode = static_cast(EReturnCode::TICKET_SELLING_CLOSED); + output.returnCode = toReturnCode(EReturnCode::TICKET_SELLING_CLOSED); return; } - locals.price = state.ticketPrice; - // Not enough to buy even a single ticket: refund everything - if (locals.reward < locals.price) + if (locals.reward < state.ticketPrice) { if (locals.reward > 0) { qpi.transfer(qpi.invocator(), locals.reward); } - output.returnCode = static_cast(EReturnCode::TICKET_INVALID_PRICE); + output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); return; } @@ -697,13 +909,13 @@ struct RL : public ContractBase { qpi.transfer(qpi.invocator(), locals.reward); } - output.returnCode = static_cast(EReturnCode::TICKET_ALL_SOLD_OUT); + output.returnCode = toReturnCode(EReturnCode::TICKET_ALL_SOLD_OUT); return; } // Compute desired number of tickets and change - locals.desired = div(locals.reward, locals.price); // How many tickets the caller attempts to buy - locals.remainder = mod(locals.reward, locals.price); // Change to return + locals.desired = div(locals.reward, state.ticketPrice); // How many tickets the caller attempts to buy + locals.remainder = mod(locals.reward, state.ticketPrice); // 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) @@ -718,13 +930,76 @@ struct RL : public ContractBase // Refund change and unfilled portion (if desired > slotsLeft) locals.unfilled = locals.desired - locals.toBuy; - locals.refundAmount = locals.remainder + (locals.unfilled * locals.price); + locals.refundAmount = locals.remainder + (smul(locals.unfilled, state.ticketPrice)); if (locals.refundAmount > 0) { qpi.transfer(qpi.invocator(), locals.refundAmount); } - output.returnCode = static_cast(EReturnCode::SUCCESS); + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicketWithToken) + { + // Refund any QU mistakenly attached to a token purchase + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (!isSellingOpen(state)) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_SELLING_CLOSED); + return; + } + + if (state.lottoTokenPrice == 0 || input.tokenAmount < state.lottoTokenPrice) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); + return; + } + + locals.slotsLeft = (state.playerCounter < state.players.capacity()) ? (state.players.capacity() - state.playerCounter) : 0; + if (locals.slotsLeft == 0) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_ALL_SOLD_OUT); + return; + } + + // Move tokens from buyer to contract + if (qpi.transferShareOwnershipAndPossession(RL_LOTTO_TOKEN_NAME, SELF, qpi.invocator(), qpi.invocator(), input.tokenAmount, SELF) < 0) + { + output.returnCode = toReturnCode(EReturnCode::TOKEN_TRANSFER_FAILED); + return; + } + + locals.desired = div(input.tokenAmount, state.lottoTokenPrice); + locals.remainder = mod(input.tokenAmount, state.lottoTokenPrice); + locals.toBuy = min(locals.desired, locals.slotsLeft); + + for (locals.i = 0; locals.i < locals.toBuy; ++locals.i) + { + if (state.playerCounter < state.players.capacity()) + { + state.players.set(state.playerCounter, qpi.invocator()); + state.playerCounter = min(state.playerCounter + 1, state.players.capacity()); + } + } + + locals.unfilled = locals.desired - locals.toBuy; + locals.refundAmount = locals.remainder + (smul(locals.unfilled, state.lottoTokenPrice)); + if (locals.refundAmount > 0) + { + qpi.transferShareOwnershipAndPossession(RL_LOTTO_TOKEN_NAME, SELF, SELF, SELF, locals.refundAmount, qpi.invocator()); + } + + locals.burnAmount = smul(locals.toBuy, state.lottoTokenPrice); + if (locals.burnAmount > 0) + { + qpi.transferShareOwnershipAndPossession(RL_LOTTO_TOKEN_NAME, SELF, SELF, SELF, locals.burnAmount, NULL_ID); + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); } private: @@ -858,6 +1133,10 @@ struct RL : public ContractBase */ EState currentState; + uint64 lottoTokenPrice; + + uint16 tokenRewardDivisor; + protected: static void clearStateOnEndEpoch(RL& state) { @@ -892,7 +1171,12 @@ struct RL : public ContractBase } } - static void enableBuyTicket(RL& state, bool bEnable) { state.currentState = bEnable ? EState::SELLING : EState::LOCKED; } + static void enableBuyTicket(RL& state, bool bEnable) + { + state.currentState = bEnable ? state.currentState | EState::SELLING : state.currentState & ~EState::SELLING; + } + + static bool isSellingOpen(const RL& state) { return (state.currentState & EState::SELLING) != 0; } static void getWinnerCounter(const RL& state, uint64& outCounter) { outCounter = mod(state.winnersCounter, state.winners.capacity()); } @@ -903,4 +1187,6 @@ struct RL : public ContractBase 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; } + + template static constexpr const T& max(const T& a, const T& b) { return (a > b) ? a : b; } }; diff --git a/test/contract_rl.cpp b/test/contract_rl.cpp index 1cd1f933d..a7f45816e 100644 --- a/test/contract_rl.cpp +++ b/test/contract_rl.cpp @@ -6,6 +6,10 @@ constexpr uint16 PROCEDURE_INDEX_BUY_TICKET = 1; constexpr uint16 PROCEDURE_INDEX_SET_PRICE = 2; constexpr uint16 PROCEDURE_INDEX_SET_SCHEDULE = 3; +constexpr uint16 PROCEDURE_INDEX_BUY_TICKET_WITH_TOKEN = 4; +constexpr uint16 PROCEDURE_INDEX_SET_TOKEN_REWARD_DIVISOR = 7; +constexpr uint16 PROCEDURE_INDEX_TRANSFER_TOKEN = 8; +constexpr uint16 PROCEDURE_INDEX_TRANSFER_SHARE_MANAGEMENT_RIGHTS = 9; constexpr uint16 FUNCTION_INDEX_GET_FEES = 1; constexpr uint16 FUNCTION_INDEX_GET_PLAYERS = 2; constexpr uint16 FUNCTION_INDEX_GET_WINNERS = 3; @@ -16,6 +20,10 @@ 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; +constexpr uint16 FUNCTION_INDEX_GET_TOKEN_DATA = 11; +constexpr uint16 QX_FUNCTION_INDEX_FEES = 1; +constexpr uint8 STATE_SELLING = static_cast(RL::EState::SELLING); +constexpr uint8 STATE_LOCKED = 0u; 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); @@ -28,6 +36,23 @@ static uint32 makeDateStamp(uint16 year, uint8 month, uint8 day) return static_cast(shortYear << 9 | month << 5 | day); } +inline bool operator==(uint8 left, RL::EReturnCode right) +{ + return left == RL::toReturnCode(right); +} +inline bool operator==(RL::EReturnCode left, uint8 right) +{ + return right == left; +} +inline bool operator!=(uint8 left, RL::EReturnCode right) +{ + return !(left == right); +} +inline bool operator!=(RL::EReturnCode left, uint8 right) +{ + return !(right == left); +} + // Equality operator for comparing WinnerInfo objects // Compares all fields (address, revenue, epoch, tick, dayOfWeek) bool operator==(const RL::WinnerInfo& left, const RL::WinnerInfo& right) @@ -36,6 +61,11 @@ bool operator==(const RL::WinnerInfo& left, const RL::WinnerInfo& right) left.dayOfWeek == right.dayOfWeek; } +static void encodeTokenName(uint64 tokenName, char (&out)[8]) +{ + copyMem(out, &tokenName, sizeof(tokenName)); +} + // Test helper that exposes internal state assertions and utilities class RLChecker : public RL { @@ -44,7 +74,7 @@ class RLChecker : public RL void checkFees(const GetFees_output& fees) { - EXPECT_EQ(fees.returnCode, static_cast(EReturnCode::SUCCESS)); + EXPECT_EQ(fees.returnCode, EReturnCode::SUCCESS); EXPECT_EQ(fees.distributionFeePercent, distributionFeePercent); EXPECT_EQ(fees.teamFeePercent, teamFeePercent); @@ -54,7 +84,7 @@ class RLChecker : public RL void checkPlayers(const GetPlayers_output& output) const { - EXPECT_EQ(output.returnCode, static_cast(EReturnCode::SUCCESS)); + EXPECT_EQ(output.returnCode, EReturnCode::SUCCESS); EXPECT_EQ(output.players.capacity(), players.capacity()); EXPECT_EQ(output.playerCounter, playerCounter); @@ -66,7 +96,7 @@ class RLChecker : public RL void checkWinners(const GetWinners_output& output) const { - EXPECT_EQ(output.returnCode, static_cast(EReturnCode::SUCCESS)); + EXPECT_EQ(output.returnCode, EReturnCode::SUCCESS); EXPECT_EQ(output.winners.capacity(), winners.capacity()); const uint64 expectedCount = mod(winnersCounter, winners.capacity()); @@ -115,6 +145,12 @@ class RLChecker : public RL uint8 getDrawHourInternal() const { return drawHour; } uint32 getLastDrawDateStamp() const { return lastDrawDateStamp; } + + uint64 getTokenRewardDivisor() const { return tokenRewardDivisor; } + + uint64 getLottoTokenPrice() const { return lottoTokenPrice; } + + void setLottoTokenPrice(uint64 price) { lottoTokenPrice = price; } }; class ContractTestingRL : protected ContractTesting @@ -124,6 +160,9 @@ class ContractTestingRL : protected ContractTesting { initEmptySpectrum(); initEmptyUniverse(); + INIT_CONTRACT(QX); + system.epoch = contractDescriptions[QX_CONTRACT_INDEX].constructionEpoch; + callSystemProcedure(QX_CONTRACT_INDEX, INITIALIZE); INIT_CONTRACT(RL); system.epoch = contractDescriptions[RL_CONTRACT_INDEX].constructionEpoch; callSystemProcedure(RL_CONTRACT_INDEX, INITIALIZE); @@ -223,13 +262,32 @@ class ContractTestingRL : protected ContractTesting return output; } + RL::GetTokenData_output getTokenData() + { + RL::GetTokenData_input input{}; + RL::GetTokenData_output output{}; + callFunction(RL_CONTRACT_INDEX, FUNCTION_INDEX_GET_TOKEN_DATA, input, output); + return output; + } + RL::BuyTicket_output buyTicket(const id& user, sint64 reward) { RL::BuyTicket_input input; RL::BuyTicket_output output; if (!invokeUserProcedure(RL_CONTRACT_INDEX, PROCEDURE_INDEX_BUY_TICKET, input, output, user, reward)) { - output.returnCode = static_cast(RL::EReturnCode::UNKNOWN_ERROR); + output.returnCode = RL::toReturnCode(RL::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + RL::BuyTicketWithToken_output buyTicketWithToken(const id& user, uint64 tokenAmount) + { + RL::BuyTicketWithToken_input input{tokenAmount}; + RL::BuyTicketWithToken_output output; + if (!invokeUserProcedure(RL_CONTRACT_INDEX, PROCEDURE_INDEX_BUY_TICKET_WITH_TOKEN, input, output, user, 0)) + { + output.returnCode = RL::toReturnCode(RL::EReturnCode::UNKNOWN_ERROR); } return output; } @@ -242,7 +300,7 @@ class ContractTestingRL : protected ContractTesting 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); + output.returnCode = RL::toReturnCode(RL::EReturnCode::UNKNOWN_ERROR); } return output; } @@ -255,11 +313,41 @@ class ContractTestingRL : protected ContractTesting 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); + output.returnCode = RL::toReturnCode(RL::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + RL::SetTokenRewardDivisor_output setTokenRewardDivisor(const id& invocator, uint64 divisor) + { + RL::SetTokenRewardDivisor_input input{divisor}; + RL::SetTokenRewardDivisor_output output{}; + if (!invokeUserProcedure(RL_CONTRACT_INDEX, PROCEDURE_INDEX_SET_TOKEN_REWARD_DIVISOR, input, output, invocator, 0)) + { + output.returnCode = RL::toReturnCode(RL::EReturnCode::UNKNOWN_ERROR); } return output; } + RL::TransferToken_output transferToken(const id& invocator, const id& newOwner, uint64 amount) + { + RL::TransferToken_input input{newOwner, amount}; + RL::TransferToken_output output{}; + if (!invokeUserProcedure(RL_CONTRACT_INDEX, PROCEDURE_INDEX_TRANSFER_TOKEN, input, output, invocator, 0)) + { + output.returnCode = RL::toReturnCode(RL::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + uint32 qxTransferFee() + { + QX::Fees_input input{}; + QX::Fees_output output{}; + callFunction(QX_CONTRACT_INDEX, QX_FUNCTION_INDEX_FEES, input, output); + return output.transferFee; + } + void BeginEpoch() { callSystemProcedure(RL_CONTRACT_INDEX, BEGIN_EPOCH); } void EndEpoch() { callSystemProcedure(RL_CONTRACT_INDEX, END_EPOCH); } @@ -267,7 +355,7 @@ class ContractTestingRL : protected ContractTesting void BeginTick() { callSystemProcedure(RL_CONTRACT_INDEX, BEGIN_TICK); } // Returns the SELF contract account address - id rlSelf() { return id(RL_CONTRACT_INDEX, 0, 0, 0); } + id rlSelf() const { 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 @@ -286,7 +374,17 @@ class ContractTestingRL : protected ContractTesting { increaseEnergy(user, ticketPrice * 2); const RL::BuyTicket_output out = ctl.buyTicket(user, ticketPrice); - EXPECT_EQ(out.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(out.returnCode, RL::EReturnCode::SUCCESS); + } + + sint64 tokenBalance(uint64 assetName, const id& owner, const id& issuer) const + { + return numberOfPossessedShares(assetName, issuer, owner, owner, RL_CONTRACT_INDEX, RL_CONTRACT_INDEX); + } + + sint64 tokenBalanceWithManagers(uint64 assetName, const id& owner, const id& issuer, uint16 managerIndex) const + { + return numberOfPossessedShares(assetName, issuer, owner, owner, managerIndex, managerIndex); } // Assert contract account balance equals the value returned by RL::GetBalance @@ -371,8 +469,8 @@ TEST(ContractRandomLottery, SetPriceAndScheduleApplyNextEpoch) constexpr uint64 newPrice = 5000000; constexpr uint8 wednesdayOnly = static_cast(1 << WEDNESDAY); increaseEnergy(RL_DEV_ADDRESS, 3); - EXPECT_EQ(ctl.setPrice(RL_DEV_ADDRESS, newPrice).returnCode, static_cast(RL::EReturnCode::SUCCESS)); - EXPECT_EQ(ctl.setSchedule(RL_DEV_ADDRESS, wednesdayOnly).returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setPrice(RL_DEV_ADDRESS, newPrice).returnCode, RL::EReturnCode::SUCCESS); + EXPECT_EQ(ctl.setSchedule(RL_DEV_ADDRESS, wednesdayOnly).returnCode, RL::EReturnCode::SUCCESS); const RL::NextEpochData nextDataBefore = ctl.getNextEpochData().nextEpochData; EXPECT_EQ(nextDataBefore.newPrice, newPrice); @@ -398,7 +496,7 @@ TEST(ContractRandomLottery, SetPriceAndScheduleApplyNextEpoch) const uint64 balBefore = getBalance(buyer); const uint64 playersBefore = ctl.state()->getPlayerCounter(); const RL::BuyTicket_output buyOut = ctl.buyTicket(buyer, newPrice); - EXPECT_EQ(buyOut.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(buyOut.returnCode, RL::EReturnCode::SUCCESS); const uint64 playersAfterFirstBuy = playersBefore + 1; EXPECT_EQ(ctl.state()->getPlayerCounter(), playersAfterFirstBuy); EXPECT_EQ(getBalance(buyer), balBefore - newPrice); @@ -408,7 +506,7 @@ TEST(ContractRandomLottery, SetPriceAndScheduleApplyNextEpoch) increaseEnergy(secondBuyer, newPrice * 2); const uint64 secondBalBefore = getBalance(secondBuyer); const RL::BuyTicket_output secondBuyOut = ctl.buyTicket(secondBuyer, newPrice); - EXPECT_EQ(secondBuyOut.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(secondBuyOut.returnCode, RL::EReturnCode::SUCCESS); const uint64 playersAfterBuy = playersAfterFirstBuy + 1; EXPECT_EQ(ctl.state()->getPlayerCounter(), playersAfterBuy); EXPECT_EQ(getBalance(secondBuyer), secondBalBefore - newPrice); @@ -419,7 +517,7 @@ TEST(ContractRandomLottery, SetPriceAndScheduleApplyNextEpoch) ctl.setDateTime(2025, 1, 15, RL_DEFAULT_DRAW_HOUR + 1); // current Wednesday ctl.forceBeginTick(); EXPECT_EQ(ctl.state()->getPlayerCounter(), playersAfterBuy); - EXPECT_EQ(ctl.getStateInfo().currentState, static_cast(RL::EState::SELLING)); + EXPECT_EQ(ctl.getStateInfo().currentState, STATE_SELLING); EXPECT_EQ(ctl.getWinners().winnersCounter, winnersBefore); // No draw on non-scheduled days between Wednesdays @@ -433,13 +531,13 @@ TEST(ContractRandomLottery, SetPriceAndScheduleApplyNextEpoch) ctl.forceBeginTick(); EXPECT_EQ(ctl.state()->getPlayerCounter(), 0u); EXPECT_EQ(ctl.getWinners().winnersCounter, winnersBefore + 1); - EXPECT_EQ(ctl.getStateInfo().currentState, static_cast(RL::EState::LOCKED)); + EXPECT_EQ(ctl.getStateInfo().currentState, STATE_LOCKED); // After the draw and before the next epoch begins, ticket purchases are blocked const id lockedBuyer = id::randomValue(); increaseEnergy(lockedBuyer, newPrice); const RL::BuyTicket_output lockedOut = ctl.buyTicket(lockedBuyer, newPrice); - EXPECT_EQ(lockedOut.returnCode, static_cast(RL::EReturnCode::TICKET_SELLING_CLOSED)); + EXPECT_EQ(lockedOut.returnCode, RL::EReturnCode::TICKET_SELLING_CLOSED); } TEST(ContractRandomLottery, DefaultInitTimeGuardSkipsPlaceholderDate) @@ -454,13 +552,13 @@ TEST(ContractRandomLottery, DefaultInitTimeGuardSkipsPlaceholderDate) // Simulate the placeholder 2022-04-13 QPI date during initialization ctl.setDateTime(2022, 4, 13, RL_DEFAULT_DRAW_HOUR + 1); ctl.BeginEpoch(); - EXPECT_EQ(ctl.getStateInfo().currentState, static_cast(RL::EState::LOCKED)); + EXPECT_EQ(ctl.getStateInfo().currentState, STATE_LOCKED); // Selling is blocked until a valid date arrives const id blockedBuyer = id::randomValue(); increaseEnergy(blockedBuyer, ticketPrice); const RL::BuyTicket_output denied = ctl.buyTicket(blockedBuyer, ticketPrice); - EXPECT_EQ(denied.returnCode, static_cast(RL::EReturnCode::TICKET_SELLING_CLOSED)); + EXPECT_EQ(denied.returnCode, RL::EReturnCode::TICKET_SELLING_CLOSED); EXPECT_EQ(ctl.state()->getPlayerCounter(), 0u); const uint64 winnersBefore = ctl.getWinners().winnersCounter; @@ -468,14 +566,14 @@ TEST(ContractRandomLottery, DefaultInitTimeGuardSkipsPlaceholderDate) // BEGIN_TICK should detect the placeholder date and skip processing, but remember the sentinel day ctl.forceBeginTick(); EXPECT_EQ(ctl.state()->getLastDrawDateStamp(), RL_DEFAULT_INIT_TIME); - EXPECT_EQ(ctl.getStateInfo().currentState, static_cast(RL::EState::LOCKED)); + EXPECT_EQ(ctl.getStateInfo().currentState, STATE_LOCKED); EXPECT_EQ(ctl.state()->getPlayerCounter(), 0u); EXPECT_EQ(ctl.getWinners().winnersCounter, winnersBefore); // First valid day re-opens selling but still skips the draw ctl.setDateTime(2025, 1, 10, RL_DEFAULT_DRAW_HOUR + 1); ctl.forceBeginTick(); - EXPECT_EQ(ctl.getStateInfo().currentState, static_cast(RL::EState::SELLING)); + EXPECT_EQ(ctl.getStateInfo().currentState, STATE_SELLING); EXPECT_NE(ctl.state()->getLastDrawDateStamp(), RL_DEFAULT_INIT_TIME); const id playerA = id::randomValue(); @@ -503,18 +601,18 @@ TEST(ContractRandomLottery, SellingUnlocksWhenTimeSetBeforeScheduledDay) const id deniedBuyer = id::randomValue(); increaseEnergy(deniedBuyer, ticketPrice); - EXPECT_EQ(ctl.buyTicket(deniedBuyer, ticketPrice).returnCode, static_cast(RL::EReturnCode::TICKET_SELLING_CLOSED)); + EXPECT_EQ(ctl.buyTicket(deniedBuyer, ticketPrice).returnCode, RL::EReturnCode::TICKET_SELLING_CLOSED); ctl.setDateTime(2025, 1, 14, RL_DEFAULT_DRAW_HOUR + 2); // Tuesday, not scheduled by default ctl.forceBeginTick(); - EXPECT_EQ(ctl.getStateInfo().currentState, static_cast(RL::EState::SELLING)); + EXPECT_EQ(ctl.getStateInfo().currentState, STATE_SELLING); EXPECT_EQ(ctl.state()->getLastDrawDateStamp(), 0u); const id allowedBuyer = id::randomValue(); increaseEnergy(allowedBuyer, ticketPrice); const RL::BuyTicket_output allowed = ctl.buyTicket(allowedBuyer, ticketPrice); - EXPECT_EQ(allowed.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(allowed.returnCode, RL::EReturnCode::SUCCESS); } TEST(ContractRandomLottery, SellingUnlocksWhenTimeSetOnDrawDay) @@ -528,19 +626,19 @@ TEST(ContractRandomLottery, SellingUnlocksWhenTimeSetOnDrawDay) const id deniedBuyer = id::randomValue(); increaseEnergy(deniedBuyer, ticketPrice); - EXPECT_EQ(ctl.buyTicket(deniedBuyer, ticketPrice).returnCode, static_cast(RL::EReturnCode::TICKET_SELLING_CLOSED)); + EXPECT_EQ(ctl.buyTicket(deniedBuyer, ticketPrice).returnCode, RL::EReturnCode::TICKET_SELLING_CLOSED); ctl.setDateTime(2025, 1, 15, RL_DEFAULT_DRAW_HOUR + 2); // Wednesday draw day ctl.forceBeginTick(); const uint32 expectedStamp = makeDateStamp(2025, 1, 15); EXPECT_EQ(ctl.state()->getLastDrawDateStamp(), expectedStamp); - EXPECT_EQ(ctl.getStateInfo().currentState, static_cast(RL::EState::SELLING)); + EXPECT_EQ(ctl.getStateInfo().currentState, STATE_SELLING); const id allowedBuyer = id::randomValue(); increaseEnergy(allowedBuyer, ticketPrice); const RL::BuyTicket_output allowed = ctl.buyTicket(allowedBuyer, ticketPrice); - EXPECT_EQ(allowed.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(allowed.returnCode, RL::EReturnCode::SUCCESS); } TEST(ContractRandomLottery, PostIncomingTransfer) @@ -605,7 +703,7 @@ TEST(ContractRandomLottery, BuyTicket) const id userLocked = id::randomValue(); 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(out.returnCode, RL::EReturnCode::TICKET_SELLING_CLOSED); EXPECT_EQ(ctl.state()->getPlayerCounter(), 0); } @@ -625,24 +723,24 @@ TEST(ContractRandomLottery, BuyTicket) { // < ticketPrice RL::BuyTicket_output outInvalid = ctl.buyTicket(user, ticketPrice - 1); - EXPECT_EQ(outInvalid.returnCode, static_cast(RL::EReturnCode::TICKET_INVALID_PRICE)); + EXPECT_EQ(outInvalid.returnCode, 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(outInvalid.returnCode, RL::EReturnCode::TICKET_INVALID_PRICE); EXPECT_EQ(ctl.state()->getPlayerCounter(), expectedPlayers); // < 0 outInvalid = ctl.buyTicket(user, -1LL * ticketPrice); - EXPECT_NE(outInvalid.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_NE(outInvalid.returnCode, RL::EReturnCode::SUCCESS); EXPECT_EQ(ctl.state()->getPlayerCounter(), expectedPlayers); } // (b) Valid purchase — player added { const RL::BuyTicket_output outOk = ctl.buyTicket(user, ticketPrice); - EXPECT_EQ(outOk.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(outOk.returnCode, RL::EReturnCode::SUCCESS); ++expectedPlayers; EXPECT_EQ(ctl.state()->getPlayerCounter(), expectedPlayers); } @@ -650,7 +748,7 @@ TEST(ContractRandomLottery, BuyTicket) // (c) Duplicate purchase — allowed, increases count { const RL::BuyTicket_output outDup = ctl.buyTicket(user, ticketPrice); - EXPECT_EQ(outDup.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(outDup.returnCode, RL::EReturnCode::SUCCESS); ++expectedPlayers; EXPECT_EQ(ctl.state()->getPlayerCounter(), expectedPlayers); } @@ -703,7 +801,7 @@ TEST(ContractRandomLottery, DrawAndPayout_BeginTick) const uint64 balanceBefore = getBalance(solo); const RL::BuyTicket_output out = ctl.buyTicket(solo, ticketPrice); - EXPECT_EQ(out.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(out.returnCode, RL::EReturnCode::SUCCESS); EXPECT_EQ(ctl.state()->getPlayerCounter(), 1u); EXPECT_EQ(getBalance(solo), balanceBefore - ticketPrice); @@ -720,6 +818,31 @@ TEST(ContractRandomLottery, DrawAndPayout_BeginTick) EXPECT_EQ(winners.winnersCounter, winnersBeforeCount); } + // --- Scenario 2b: Multiple tickets from the same player are treated as single participant --- + { + ctl.beginEpochWithValidTime(); + + const id solo = id::randomValue(); + increaseEnergy(solo, ticketPrice * 10); + const uint64 balanceBefore = getBalance(solo); + + for (int i = 0; i < 5; ++i) + { + EXPECT_EQ(ctl.buyTicket(solo, ticketPrice).returnCode, RL::EReturnCode::SUCCESS); + } + EXPECT_EQ(ctl.state()->getPlayerCounter(), 5u); + + const uint64 winnersBeforeCount = ctl.getWinners().winnersCounter; + + ctl.advanceOneDayAndDraw(); + + // All tickets refunded, no winner recorded + EXPECT_EQ(getBalance(solo), balanceBefore); + EXPECT_EQ(ctl.state()->getPlayerCounter(), 0u); + EXPECT_EQ(ctl.getWinners().winnersCounter, winnersBeforeCount); + EXPECT_EQ(getBalance(contractAddress), 0u); + } + // --- Scenario 3: Multiple players (winner chosen, fees processed, correct remaining on contract) --- { ctl.beginEpochWithValidTime(); @@ -882,6 +1005,46 @@ TEST(ContractRandomLottery, DrawAndPayout_BeginTick) } } +/*TEST(ContractRandomLottery, ParticipantsReceiveLOTTOPerTicketOnDraw) +{ + ContractTestingRL ctl; + ctl.forceSchedule(RL_ANY_DAY_DRAW_SCHEDULE); + + const uint64 ticketPrice = ctl.state()->getTicketPrice(); + constexpr uint64 tokenPrice = 15; + constexpr uint64 rewardDivisor = 5; + ctl.state()->setLottoTokenPrice(tokenPrice); + ctl.beginEpochWithValidTime(); + + increaseEnergy(RL_DEV_ADDRESS, 1); + EXPECT_EQ(ctl.setTokenRewardDivisor(RL_DEV_ADDRESS, rewardDivisor).returnCode, RL::EReturnCode::SUCCESS); + EXPECT_EQ(ctl.state()->getTokenRewardDivisor(), rewardDivisor); + + constexpr uint64 rewardPerTicket = std::max(1, tokenPrice / rewardDivisor); + const id playerA = id::randomValue(); + const id playerB = id::randomValue(); + + increaseEnergy(playerA, ticketPrice * 3); + increaseEnergy(playerB, ticketPrice * 2); + + EXPECT_EQ(ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, playerA, ctl.rlSelf()), 0); + EXPECT_EQ(ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, playerB, ctl.rlSelf()), 0); + + EXPECT_EQ(ctl.buyTicket(playerA, ticketPrice).returnCode, RL::EReturnCode::SUCCESS); + EXPECT_EQ(ctl.buyTicket(playerA, ticketPrice).returnCode, RL::EReturnCode::SUCCESS); + EXPECT_EQ(ctl.buyTicket(playerB, ticketPrice).returnCode, RL::EReturnCode::SUCCESS); + EXPECT_EQ(ctl.state()->getPlayerCounter(), 3u); + + const sint64 contractTokensBefore = ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, ctl.rlSelf(), ctl.rlSelf()); + + ctl.advanceOneDayAndDraw(); + + EXPECT_EQ(ctl.state()->getPlayerCounter(), 0u); + EXPECT_EQ(ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, playerA, ctl.rlSelf()), rewardPerTicket * 2); + EXPECT_EQ(ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, playerB, ctl.rlSelf()), rewardPerTicket); + EXPECT_EQ(ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, ctl.rlSelf(), ctl.rlSelf()), contractTokensBefore - (rewardPerTicket * 3)); +}*/ + TEST(ContractRandomLottery, GetBalance) { ContractTestingRL ctl; @@ -954,21 +1117,21 @@ TEST(ContractRandomLottery, GetState) // Initially LOCKED { const RL::GetState_output out0 = ctl.getStateInfo(); - EXPECT_EQ(out0.currentState, static_cast(RL::EState::LOCKED)); + EXPECT_EQ(out0.currentState, STATE_LOCKED); } // After BeginEpoch — SELLING ctl.beginEpochWithValidTime(); { const RL::GetState_output out1 = ctl.getStateInfo(); - EXPECT_EQ(out1.currentState, static_cast(RL::EState::SELLING)); + EXPECT_EQ(out1.currentState, STATE_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)); + EXPECT_EQ(out2.currentState, STATE_LOCKED); } } @@ -986,7 +1149,7 @@ TEST(ContractRandomLottery, SetPrice_AccessControl) increaseEnergy(randomUser, 1); const RL::SetPrice_output outDenied = ctl.setPrice(randomUser, newPrice); - EXPECT_EQ(outDenied.returnCode, static_cast(RL::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ(outDenied.returnCode, RL::EReturnCode::ACCESS_DENIED); // Price doesn't change immediately nor after END_EPOCH implicitly EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); @@ -1003,7 +1166,7 @@ TEST(ContractRandomLottery, SetPrice_ZeroNotAllowed) 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)); + EXPECT_EQ(outInvalid.returnCode, RL::EReturnCode::TICKET_INVALID_PRICE); // Price remains unchanged even after END_EPOCH EXPECT_EQ(ctl.getTicketPrice().ticketPrice, oldPrice); @@ -1021,7 +1184,7 @@ TEST(ContractRandomLottery, SetPrice_AppliesAfterEndEpoch) 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)); + EXPECT_EQ(outOk.returnCode, RL::EReturnCode::SUCCESS); // Check NextEpochData reflects pending change EXPECT_EQ(ctl.getNextEpochData().nextEpochData.newPrice, newPrice); @@ -1052,8 +1215,8 @@ TEST(ContractRandomLottery, SetPrice_OverrideBeforeEndEpoch) 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)); + EXPECT_EQ(ctl.setPrice(RL_DEV_ADDRESS, firstPrice).returnCode, RL::EReturnCode::SUCCESS); + EXPECT_EQ(ctl.setPrice(RL_DEV_ADDRESS, secondPrice).returnCode, RL::EReturnCode::SUCCESS); // NextEpochData shows the last queued value EXPECT_EQ(ctl.getNextEpochData().nextEpochData.newPrice, secondPrice); @@ -1080,13 +1243,13 @@ TEST(ContractRandomLottery, SetPrice_AffectsNextEpochBuys) increaseEnergy(u1, oldPrice * 2); { const RL::BuyTicket_output out1 = ctl.buyTicket(u1, oldPrice); - EXPECT_EQ(out1.returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(out1.returnCode, 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(setOut.returnCode, RL::EReturnCode::SUCCESS); EXPECT_EQ(ctl.getNextEpochData().nextEpochData.newPrice, newPrice); } @@ -1096,7 +1259,7 @@ TEST(ContractRandomLottery, SetPrice_AffectsNextEpochBuys) 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)); + EXPECT_EQ(outNow.returnCode, 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); @@ -1113,7 +1276,7 @@ TEST(ContractRandomLottery, SetPrice_AffectsNextEpochBuys) 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(outOk.returnCode, RL::EReturnCode::SUCCESS); EXPECT_EQ(ctl.state()->getPlayerCounter(), playersBefore + 1); EXPECT_EQ(getBalance(u2), balBefore - newPrice); } @@ -1125,11 +1288,11 @@ TEST(ContractRandomLottery, BuyMultipleTickets_ExactMultiple_NoRemainder) ctl.beginEpochWithValidTime(); const uint64 price = ctl.state()->getTicketPrice(); const id user = id::randomValue(); - const uint64 k = 7; + constexpr 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(out.returnCode, RL::EReturnCode::SUCCESS); EXPECT_EQ(ctl.state()->getPlayerCounter(), playersBefore + k); } @@ -1139,13 +1302,13 @@ TEST(ContractRandomLottery, BuyMultipleTickets_WithRemainder_Refunded) ctl.beginEpochWithValidTime(); const uint64 price = ctl.state()->getTicketPrice(); const id user = id::randomValue(); - const uint64 k = 5; + constexpr 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(out.returnCode, RL::EReturnCode::SUCCESS); EXPECT_EQ(ctl.state()->getPlayerCounter(), playersBefore + k); // Remainder refunded, only k * price spent EXPECT_EQ(getBalance(user), balBefore - k * price); @@ -1164,7 +1327,7 @@ TEST(ContractRandomLottery, BuyMultipleTickets_CapacityPartialRefund) { const id u = id::randomValue(); increaseEnergy(u, price); - EXPECT_EQ(ctl.buyTicket(u, price).returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.buyTicket(u, price).returnCode, RL::EReturnCode::SUCCESS); } EXPECT_EQ(ctl.state()->getPlayerCounter(), toFill); @@ -1173,7 +1336,7 @@ TEST(ContractRandomLottery, BuyMultipleTickets_CapacityPartialRefund) 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(out.returnCode, RL::EReturnCode::SUCCESS); EXPECT_EQ(ctl.state()->getPlayerCounter(), capacity); EXPECT_EQ(getBalance(buyer), balBefore - price * 5); } @@ -1190,7 +1353,7 @@ TEST(ContractRandomLottery, BuyMultipleTickets_AllSoldOut) { const id u = id::randomValue(); increaseEnergy(u, price); - EXPECT_EQ(ctl.buyTicket(u, price).returnCode, static_cast(RL::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.buyTicket(u, price).returnCode, RL::EReturnCode::SUCCESS); } EXPECT_EQ(ctl.state()->getPlayerCounter(), capacity); @@ -1199,7 +1362,7 @@ TEST(ContractRandomLottery, BuyMultipleTickets_AllSoldOut) 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(out.returnCode, RL::EReturnCode::TICKET_ALL_SOLD_OUT); EXPECT_EQ(getBalance(buyer), balBefore); } @@ -1217,17 +1380,17 @@ TEST(ContractRandomLottery, GetSchedule_And_SetSchedule) 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)); + EXPECT_EQ(outDenied.returnCode, 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)); + EXPECT_EQ(outInvalid.returnCode, 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(outOk.returnCode, RL::EReturnCode::SUCCESS); EXPECT_EQ(ctl.getNextEpochData().nextEpochData.schedule, newMask); // Not applied yet @@ -1250,3 +1413,168 @@ TEST(ContractRandomLottery, GetDrawHour_DefaultAfterBeginEpoch) ctl.beginEpochWithValidTime(); EXPECT_EQ(ctl.getDrawHour().drawHour, RL_DEFAULT_DRAW_HOUR); } + +TEST(ContractRandomLottery, LottoTokenPrice_DefaultExposed) +{ + ContractTestingRL ctl; + + EXPECT_EQ(ctl.state()->getLottoTokenPrice(), 0u); + + ctl.beginEpochWithValidTime(); + EXPECT_EQ(ctl.state()->getLottoTokenPrice(), RL_DEFAULT_TOKEN_PRICE); + + const RL::GetTokenData_output out = ctl.getTokenData(); + EXPECT_EQ(out.tokenData.pricePerTicket, RL_DEFAULT_TOKEN_PRICE); +} + +TEST(ContractRandomLottery, TokenRewardDivisor_SetAccessAndValidation) +{ + ContractTestingRL ctl; + ctl.beginEpochWithValidTime(); + + EXPECT_EQ(ctl.state()->getTokenRewardDivisor(), RL_TOKEN_REWARD_DIVISOR); + + const id randomUser = id::randomValue(); + increaseEnergy(randomUser, 1); + const RL::SetTokenRewardDivisor_output denied = ctl.setTokenRewardDivisor(randomUser, 5); + EXPECT_EQ(denied.returnCode, RL::EReturnCode::ACCESS_DENIED); + EXPECT_EQ(ctl.state()->getTokenRewardDivisor(), RL_TOKEN_REWARD_DIVISOR); + + increaseEnergy(RL_DEV_ADDRESS, 1); + const RL::SetTokenRewardDivisor_output invalid = ctl.setTokenRewardDivisor(RL_DEV_ADDRESS, 0); + EXPECT_EQ(invalid.returnCode, RL::EReturnCode::INVALID_VALUE); + EXPECT_EQ(ctl.state()->getTokenRewardDivisor(), RL_TOKEN_REWARD_DIVISOR); + + increaseEnergy(RL_DEV_ADDRESS, 1); + constexpr uint64 newDivisor = 7; + const RL::SetTokenRewardDivisor_output ok = ctl.setTokenRewardDivisor(RL_DEV_ADDRESS, newDivisor); + EXPECT_EQ(ok.returnCode, RL::EReturnCode::SUCCESS); + EXPECT_EQ(ctl.state()->getTokenRewardDivisor(), newDivisor); +} + +TEST(ContractRandomLottery, TransferToken_OwnerCanDistributeContractTokens) +{ + ContractTestingRL ctl; + ctl.beginEpochWithValidTime(); + + const sint64 contractTokensBefore = ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, ctl.rlSelf(), ctl.rlSelf()); + ASSERT_GT(contractTokensBefore, 0); + + const id recipient = id::randomValue(); + const uint64 transferAmount = std::min(5ul, contractTokensBefore); + + increaseEnergy(RL_DEV_ADDRESS, 1); + ctl.transferToken(RL_DEV_ADDRESS, recipient, transferAmount); + + EXPECT_EQ(ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, recipient, ctl.rlSelf()), transferAmount); + EXPECT_EQ(ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, ctl.rlSelf(), ctl.rlSelf()), contractTokensBefore - transferAmount); +} + +TEST(ContractRandomLottery, TransferToken_UserCanTransferOwnedTokensAndFailsWhenInsufficient) +{ + ContractTestingRL ctl; + ctl.beginEpochWithValidTime(); + + const id user = id::randomValue(); + const id target = id::randomValue(); + + increaseEnergy(RL_DEV_ADDRESS, 1); + constexpr uint64 seedAmount = 3; + ctl.transferToken(RL_DEV_ADDRESS, user, seedAmount); + EXPECT_EQ(ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, user, ctl.rlSelf()), seedAmount); + + increaseEnergy(user, 1); + ctl.transferToken(user, target, seedAmount + 1); // insufficient balance + EXPECT_EQ(ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, user, ctl.rlSelf()), seedAmount); + EXPECT_EQ(ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, target, ctl.rlSelf()), 0); + + increaseEnergy(user, 1); + constexpr uint64 sendAmount = seedAmount - 1; + ctl.transferToken(user, target, sendAmount); + EXPECT_EQ(ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, user, ctl.rlSelf()), seedAmount - sendAmount); + EXPECT_EQ(ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, target, ctl.rlSelf()), sendAmount); +} + +TEST(ContractRandomLottery, TransferToken_NoOpForZeroAmountOrRecipient) +{ + ContractTestingRL ctl; + ctl.beginEpochWithValidTime(); + + const sint64 contractTokensBefore = ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, ctl.rlSelf(), ctl.rlSelf()); + ASSERT_GT(contractTokensBefore, 0); + + const id recipient = id::randomValue(); + + increaseEnergy(RL_DEV_ADDRESS, 1); + ctl.transferToken(RL_DEV_ADDRESS, recipient, 0); + EXPECT_EQ(ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, ctl.rlSelf(), ctl.rlSelf()), contractTokensBefore); + EXPECT_EQ(ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, recipient, ctl.rlSelf()), 0); + + increaseEnergy(RL_DEV_ADDRESS, 1); + ctl.transferToken(RL_DEV_ADDRESS, id::zero(), 1); + EXPECT_EQ(ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, ctl.rlSelf(), ctl.rlSelf()), contractTokensBefore); +} + +TEST(ContractRandomLottery, BuyTicketWithToken_ClosedAndNotAllowed) +{ + ContractTestingRL ctl; + + const id buyer = id::randomValue(); + const uint64 tokenPrice = RL_DEFAULT_TOKEN_PRICE; + const uint64 mintedAmount = tokenPrice * 2; + const uint64 requestedAmount = mintedAmount + tokenPrice; // More than balance => transfer fails + increaseEnergy(buyer, 1); + increaseEnergy(RL_DEV_ADDRESS, 1); + + ctl.beginEpochWithValidTime(); + const RL::TransferToken_output fundOut = ctl.transferToken(RL_DEV_ADDRESS, buyer, mintedAmount); + EXPECT_EQ(fundOut.returnCode, RL::EReturnCode::SUCCESS); + + ctl.EndEpoch(); // Close selling after funding + + const sint64 lottoBefore = ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, buyer, ctl.rlSelf()); + const sint64 contractBefore = ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, ctl.rlSelf(), ctl.rlSelf()); + + const RL::BuyTicketWithToken_output closedOut = ctl.buyTicketWithToken(buyer, tokenPrice); + EXPECT_EQ(closedOut.returnCode, RL::EReturnCode::TICKET_SELLING_CLOSED); + EXPECT_EQ(ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, buyer, ctl.rlSelf()), lottoBefore); + EXPECT_EQ(ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, ctl.rlSelf(), ctl.rlSelf()), contractBefore); + + ctl.beginEpochWithValidTime(); + const RL::BuyTicketWithToken_output insufficientOut = ctl.buyTicketWithToken(buyer, requestedAmount); + EXPECT_EQ(insufficientOut.returnCode, RL::EReturnCode::TOKEN_TRANSFER_FAILED); + EXPECT_EQ(ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, buyer, ctl.rlSelf()), lottoBefore); + EXPECT_EQ(ctl.tokenBalance(RL_LOTTO_TOKEN_NAME, ctl.rlSelf(), ctl.rlSelf()), contractBefore); +} + +TEST(ContractRandomLottery, BuyTicketWithToken_SuccessAndRefundsRemainder) +{ + ContractTestingRL ctl; + const uint64 tokenName = RL_LOTTO_TOKEN_NAME; + const uint64 tokenPrice = RL_DEFAULT_TOKEN_PRICE; + + const id buyer = id::randomValue(); + const uint64 tokenAmount = tokenPrice * 3 + 2; // buy 3 tickets, refund 2 tokens + increaseEnergy(buyer, 1); + increaseEnergy(RL_DEV_ADDRESS, 1); + + ctl.beginEpochWithValidTime(); + + const RL::TransferToken_output fundOut = ctl.transferToken(RL_DEV_ADDRESS, buyer, tokenAmount); + EXPECT_EQ(fundOut.returnCode, RL::EReturnCode::SUCCESS); + + const uint64 assetName = tokenName; + + const uint64 resolvedTokenPrice = ctl.state()->getLottoTokenPrice(); + const uint64 playersBefore = ctl.state()->getPlayerCounter(); + const sint64 buyerTokensBefore = ctl.tokenBalance(assetName, buyer, ctl.rlSelf()); + const sint64 contractTokensBefore = ctl.tokenBalance(assetName, ctl.rlSelf(), ctl.rlSelf()); + + const RL::BuyTicketWithToken_output out = ctl.buyTicketWithToken(buyer, tokenAmount); + EXPECT_EQ(out.returnCode, RL::EReturnCode::SUCCESS); + + constexpr uint64 expectedBought = 3; + EXPECT_EQ(ctl.state()->getPlayerCounter(), playersBefore + expectedBought); + EXPECT_EQ(ctl.tokenBalance(assetName, buyer, ctl.rlSelf()), buyerTokensBefore - expectedBought * resolvedTokenPrice); + EXPECT_EQ(ctl.tokenBalance(assetName, ctl.rlSelf(), ctl.rlSelf()), contractTokensBefore); +}