diff --git a/src/contracts/QThirtyFour.h b/src/contracts/QThirtyFour.h index fcf416a51..64e886544 100644 --- a/src/contracts/QThirtyFour.h +++ b/src/contracts/QThirtyFour.h @@ -4,8 +4,12 @@ using namespace QPI; constexpr uint64 QTF_MAX_NUMBER_OF_PLAYERS = 1024; constexpr uint64 QTF_RANDOM_VALUES_COUNT = 4; constexpr uint64 QTF_MAX_RANDOM_VALUE = 30; +constexpr uint64 QTF_MAX_RANDOM_VALUE_ALIGNED = QTF_MAX_RANDOM_VALUE + 2; +constexpr uint64 QTF_MAX_BATCH_TICKETS = div(QTF_MAX_NUMBER_OF_PLAYERS, 4ULL); +constexpr uint64 QTF_BATCH_TICKET_VALUES_COUNT = QTF_MAX_BATCH_TICKETS * QTF_RANDOM_VALUES_COUNT; constexpr uint64 QTF_TICKET_PRICE = 1000000; constexpr uint64 QTF_WINNING_COMBINATIONS_HISTORY_SIZE = 128; +constexpr uint64 QTF_PREPARED_TICKET_MAX_COUNT = QTF_MAX_NUMBER_OF_PLAYERS * QTF_RANDOM_VALUES_COUNT; // Baseline split for k2/k3 when FR is OFF (per spec: k3=40%, k2=28% of Winners block). // Initial 32% of Winners block is unallocated; overflow will also include unawarded k2/k3 funds. @@ -73,6 +77,28 @@ struct QTF : ContractBase // Forward declaration for NextEpochData::apply struct StateData; + enum class EReturnCode : uint8 + { + SUCCESS, + INVALID_TICKET_PRICE, + MAX_PLAYERS_REACHED, + ACCESS_DENIED, + INVALID_NUMBERS, + INVALID_VALUE, + TICKET_SELLING_CLOSED, + PARTIAL_PURCHASE, + + MAX_VALUE = UINT8_MAX + }; + + static constexpr uint8 toReturnCode(const EReturnCode& code) { return static_cast(code); }; + + enum EState : uint8 + { + STATE_NONE = 0, + STATE_SELLING = 1 << 0 + }; + struct PlayerData { id player; @@ -114,7 +140,25 @@ struct QTF : ContractBase newDrawHour = 0; } - void apply(QPI::ContractState& state) const; + void apply(QPI::ContractState& state) const + { + if (newTicketPrice > 0) + { + state.mut().ticketPrice = newTicketPrice; + } + if (newTargetJackpot > 0) + { + state.mut().targetJackpot = newTargetJackpot; + } + if (newSchedule > 0) + { + state.mut().schedule = newSchedule; + } + if (newDrawHour > 0) + { + state.mut().drawHour = newDrawHour; + } + } uint64 newTicketPrice; uint64 newTargetJackpot; @@ -131,68 +175,6 @@ struct QTF : ContractBase uint16 roundsSinceK4; }; - struct StateData - { - WinnerData lastWinnerData; // last winners snapshot - - NextEpochData nextEpochData; // queued config (ticket price) - - Array players; // current epoch tickets - - id teamAddress; // Dev/team payout address - - id ownerAddress; // config authority - - uint64 numberOfPlayers; // tickets count in epoch - - uint64 ticketPrice; // active ticket price - - uint64 jackpot; // jackpot balance - - uint64 targetJackpot; // FR target jackpot - - uint64 overflowAlphaBP; // baseline reserve share of overflow (bp) - - uint8 schedule; // bitmask of draw days - - uint8 drawHour; // draw hour UTC - - uint32 lastDrawDateStamp; // guard to avoid multiple draws per day - - bit frActive; // FR flag - - uint16 frRoundsSinceK4; // rounds since last jackpot hit - - uint16 frRoundsAtOrAboveTarget; // hysteresis counter for FR off - - uint8 currentState; // bitmask of STATE_* flags (e.g., STATE_SELLING) - - Array, QTF_WINNING_COMBINATIONS_HISTORY_SIZE> - winningCombinationsHistory; // ring buffer of winning combinations - uint64 winningCombinationsCount; // next write position in ring buffer - }; - - enum class EReturnCode : uint8 - { - SUCCESS, - INVALID_TICKET_PRICE, - MAX_PLAYERS_REACHED, - ACCESS_DENIED, - INVALID_NUMBERS, - INVALID_VALUE, - TICKET_SELLING_CLOSED, - - MAX_VALUE = UINT8_MAX - }; - - static constexpr uint8 toReturnCode(const EReturnCode& code) { return static_cast(code); }; - - enum EState : uint8 - { - STATE_NONE = 0, - STATE_SELLING = 1 << 0 - }; - struct PoolsSnapshot_input { }; @@ -213,11 +195,69 @@ struct QTF : ContractBase }; struct ValidateNumbers_locals { - HashSet seen; + HashSet seen; uint8 idx; uint8 value; }; + struct CollectSelectionNumbers_input + { + Array numbers; + }; + struct CollectSelectionNumbers_output + { + Array normalizedNumbers; + bit isValid; + uint8 numberCount; + }; + struct CollectSelectionNumbers_locals + { + uint8 idx; + uint8 value; + uint8 outValue; + uint32 mask; + }; + + struct BuyPreparedTickets_input + { + Array tickets; + uint64 ticketCount; + }; + struct BuyPreparedTickets_output + { + uint16 boughtTicketCount; + uint8 returnCode; + }; + struct BuyPreparedTickets_locals + { + Array ticketValues; + uint64 effectiveTicketCount; + uint64 freeSlots; + uint64 requiredAmount; + uint64 excess; + uint64 i; + uint64 offset; + }; + + struct BuildSelectionTickets_input + { + Array normalizedNumbers; + uint8 numberCount; + }; + struct BuildSelectionTickets_output + { + BuyPreparedTickets_input preparedTickets; + }; + struct BuildSelectionTickets_locals + { + Array ticketValues; + uint64 i; + uint64 j; + uint64 k; + uint64 l; + uint64 preparedOffset; + }; + // Buy Ticket struct BuyTicket_input { @@ -229,10 +269,55 @@ struct QTF : ContractBase }; struct BuyTicket_locals { - // CALL parameters for ValidateNumbers ValidateNumbers_input validateInput; ValidateNumbers_output validateOutput; - uint64 excess; + BuyPreparedTickets_input buyPreparedInput; + BuyPreparedTickets_output buyPreparedOutput; + }; + + struct BuyTicketsBatch_input + { + Array tickets; + }; + struct BuyTicketsBatch_output + { + uint16 boughtTicketCount; + uint8 returnCode; + }; + struct BuyTicketsBatch_locals + { + ValidateNumbers_input validateInput; + ValidateNumbers_output validateOutput; + Array ticketValues; + uint64 ticketCount; + uint64 i; + uint64 offset; + uint64 preparedOffset; + BuyPreparedTickets_input buyPreparedInput; + BuyPreparedTickets_output buyPreparedOutput; + }; + + struct BuyTicketsBySelection_input + { + Array numbers; + }; + struct BuyTicketsBySelection_output + { + uint16 requestedTicketCount; + uint16 boughtTicketCount; + uint8 returnCode; + }; + struct BuyTicketsBySelection_locals + { + CollectSelectionNumbers_input collectSelectionInput; + CollectSelectionNumbers_output collectSelectionOutput; + BuildSelectionTickets_input buildSelectionTicketsInput; + BuildSelectionTickets_output buildSelectionTicketsOutput; + uint64 j; + uint64 k; + uint64 l; + BuyPreparedTickets_input buyPreparedInput; + BuyPreparedTickets_output buyPreparedOutput; }; // Set Price @@ -394,7 +479,7 @@ struct QTF : ContractBase uint8 candidate; uint8 attempts; uint8 fallback; - HashSet used; + HashSet used; }; // CalcReserveTopUp: Calculate safe reserve top-up amount @@ -768,11 +853,35 @@ struct QTF : ContractBase Entity entity; }; + struct StateData + { + WinnerData lastWinnerData; // last winners snapshot + NextEpochData nextEpochData; // queued config (ticket price) + Array players; // current epoch tickets + id teamAddress; // Dev/team payout address + id ownerAddress; // config authority + uint64 numberOfPlayers; // tickets count in epoch + uint64 ticketPrice; // active ticket price + uint64 jackpot; // jackpot balance + uint64 targetJackpot; // FR target jackpot + uint64 overflowAlphaBP; // baseline reserve share of overflow (bp) + uint8 schedule; // bitmask of draw days + uint8 drawHour; // draw hour UTC + uint32 lastDrawDateStamp; // guard to avoid multiple draws per day + bit frActive; // FR flag + uint16 frRoundsSinceK4; // rounds since last jackpot hit + uint16 frRoundsAtOrAboveTarget; // hysteresis counter for FR off + uint8 currentState; // bitmask of STATE_* flags (e.g., STATE_SELLING) + Array, QTF_WINNING_COMBINATIONS_HISTORY_SIZE> + winningCombinationsHistory; // ring buffer of winning combinations + uint64 winningCombinationsCount; // next write position in ring buffer + }; + // Contract lifecycle methods INITIALIZE() { - state.mut().teamAddress = ID(_O, _C, _Z, _W, _N, _J, _S, _N, _R, _U, _Q, _J, _U, _A, _H, _Z, _C, _T, _R, _P, _N, _Y, _W, _G, _G, _E, _F, _C, _X, _B, - _A, _V, _F, _O, _P, _R, _S, _N, _U, _L, _U, _E, _B, _S, _P, _U, _T, _R, _Z, _N, _T, _G, _F, _B, _I, _E); + state.mut().teamAddress = ID(_O, _C, _Z, _W, _N, _J, _S, _N, _R, _U, _Q, _J, _U, _A, _H, _Z, _C, _T, _R, _P, _N, _Y, _W, _G, _G, _E, _F, _C, + _X, _B, _A, _V, _F, _O, _P, _R, _S, _N, _U, _L, _U, _E, _B, _S, _P, _U, _T, _R, _Z, _N, _T, _G, _F, _B, _I, _E); state.mut().ownerAddress = state.get().teamAddress; state.mut().ticketPrice = QTF_TICKET_PRICE; state.mut().targetJackpot = QTF_DEFAULT_TARGET_JACKPOT; @@ -791,6 +900,8 @@ struct QTF : ContractBase REGISTER_USER_PROCEDURE(SetTargetJackpot, 4); REGISTER_USER_PROCEDURE(SetDrawHour, 5); REGISTER_USER_PROCEDURE(SyncJackpot, 6); + REGISTER_USER_PROCEDURE(BuyTicketsBatch, 7); + REGISTER_USER_PROCEDURE(BuyTicketsBySelection, 8); REGISTER_USER_FUNCTION(GetTicketPrice, 1); REGISTER_USER_FUNCTION(GetNextEpochData, 2); @@ -937,7 +1048,8 @@ struct QTF : ContractBase return; } - if (qpi.invocationReward() < static_cast(state.get().ticketPrice)) + if (state.get().ticketPrice > INT64_MAX || qpi.invocationReward() <= 0 || + static_cast(qpi.invocationReward()) < state.get().ticketPrice) { if (qpi.invocationReward() > 0) { @@ -960,20 +1072,124 @@ struct QTF : ContractBase return; } - addPlayerInfo(state, qpi.invocator(), input.randomValues); + locals.buyPreparedInput.ticketCount = 1; + locals.buyPreparedInput.tickets.set(0, input.randomValues.get(0)); + locals.buyPreparedInput.tickets.set(1, input.randomValues.get(1)); + locals.buyPreparedInput.tickets.set(2, input.randomValues.get(2)); + locals.buyPreparedInput.tickets.set(3, input.randomValues.get(3)); + CALL(BuyPreparedTickets, locals.buyPreparedInput, locals.buyPreparedOutput); + output.returnCode = locals.buyPreparedOutput.returnCode; + } - // If overpaid, accept ticket and return excess to invocator. - // Important: refund excess ONLY after validation, otherwise invalid tickets could be over-refunded. - if (qpi.invocationReward() > static_cast(state.get().ticketPrice)) + /** + * @brief Buys every valid ticket encoded in the flat batch payload. + * @param input.tickets Flat array of 4-number tickets stored sequentially. + * @return SUCCESS when at least one valid ticket is accepted; invalid or empty 4-number groups are ignored, + * and the batch is clipped to the number of free ticket slots left in the round. + */ + PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicketsBatch) + { + if ((state.get().currentState & STATE_SELLING) == 0) { - locals.excess = qpi.invocationReward() - state.get().ticketPrice; - if (locals.excess > 0) + if (qpi.invocationReward() > 0) { - qpi.transfer(qpi.invocator(), locals.excess); + qpi.transfer(qpi.invocator(), qpi.invocationReward()); } + + output.returnCode = toReturnCode(EReturnCode::TICKET_SELLING_CLOSED); + return; } - output.returnCode = toReturnCode(EReturnCode::SUCCESS); + while (locals.i < QTF_MAX_BATCH_TICKETS) + { + locals.offset = smul(locals.i, QTF_RANDOM_VALUES_COUNT); + locals.ticketValues.set(0, input.tickets.get(locals.offset)); + locals.ticketValues.set(1, input.tickets.get(locals.offset + 1)); + locals.ticketValues.set(2, input.tickets.get(locals.offset + 2)); + locals.ticketValues.set(3, input.tickets.get(locals.offset + 3)); + + if (isEmptyTicket(locals.ticketValues)) + { + ++locals.i; + continue; + } + + locals.validateInput.numbers = locals.ticketValues; + CALL(ValidateNumbers, locals.validateInput, locals.validateOutput); + if (!locals.validateOutput.isValid) + { + ++locals.i; + continue; + } + + locals.preparedOffset = smul(locals.ticketCount, QTF_RANDOM_VALUES_COUNT); + locals.buyPreparedInput.tickets.set(locals.preparedOffset, locals.ticketValues.get(0)); + locals.buyPreparedInput.tickets.set(locals.preparedOffset + 1, locals.ticketValues.get(1)); + locals.buyPreparedInput.tickets.set(locals.preparedOffset + 2, locals.ticketValues.get(2)); + locals.buyPreparedInput.tickets.set(locals.preparedOffset + 3, locals.ticketValues.get(3)); + ++locals.ticketCount; + ++locals.i; + } + + locals.buyPreparedInput.ticketCount = locals.ticketCount; + CALL(BuyPreparedTickets, locals.buyPreparedInput, locals.buyPreparedOutput); + output.boughtTicketCount = locals.buyPreparedOutput.boughtTicketCount; + output.returnCode = locals.buyPreparedOutput.returnCode; + } + + /** + * @brief Buys every 4-number combination generated from a validated number selection. + * @param input.numbers Selected values; all unique values in range [1..30] are used. + * @return SUCCESS when all generated tickets are accepted; PARTIAL_PURCHASE when the round has fewer free + * slots than generated tickets; selections that expand beyond the batch limit refund the full reward. + */ + PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicketsBySelection) + { + if ((state.get().currentState & STATE_SELLING) == 0) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + output.returnCode = toReturnCode(EReturnCode::TICKET_SELLING_CLOSED); + return; + } + + locals.collectSelectionInput.numbers = input.numbers; + CALL(CollectSelectionNumbers, locals.collectSelectionInput, locals.collectSelectionOutput); + if (!locals.collectSelectionOutput.isValid || locals.collectSelectionOutput.numberCount < QTF_RANDOM_VALUES_COUNT) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + output.returnCode = toReturnCode(EReturnCode::INVALID_NUMBERS); + return; + } + + locals.j = + smul(static_cast(locals.collectSelectionOutput.numberCount), static_cast(locals.collectSelectionOutput.numberCount - 1)); + locals.k = smul(locals.j, static_cast(locals.collectSelectionOutput.numberCount - 2)); + locals.l = smul(locals.k, static_cast(locals.collectSelectionOutput.numberCount - 3)); + output.requestedTicketCount = static_cast(div(locals.l, 24)); + if (output.requestedTicketCount > QTF_MAX_BATCH_TICKETS) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + locals.buildSelectionTicketsInput.normalizedNumbers = locals.collectSelectionOutput.normalizedNumbers; + locals.buildSelectionTicketsInput.numberCount = locals.collectSelectionOutput.numberCount; + CALL(BuildSelectionTickets, locals.buildSelectionTicketsInput, locals.buildSelectionTicketsOutput); + locals.buyPreparedInput = locals.buildSelectionTicketsOutput.preparedTickets; + CALL(BuyPreparedTickets, locals.buyPreparedInput, locals.buyPreparedOutput); + output.boughtTicketCount = locals.buyPreparedOutput.boughtTicketCount; + output.returnCode = locals.buyPreparedOutput.returnCode; } PUBLIC_PROCEDURE(SetPrice) @@ -1222,12 +1438,18 @@ struct QTF : ContractBase static void deriveOne(const uint64& r, const uint64& idx, uint64& outValue) { mix64(r + 0x9e3779b97f4a7c15ULL * (idx + 1), outValue); } - static void addPlayerInfo(QPI::ContractState& state, const id& playerId, const Array& randomValues) + static void addPlayerInfo(QPI::ContractState& state, const id& playerId, + const Array& randomValues) { state.mut().players.set(state.get().numberOfPlayers, {playerId, randomValues}); state.mut().numberOfPlayers++; } + static bool isEmptyTicket(const Array& ticketValues) + { + return (ticketValues.get(0) | ticketValues.get(1) | ticketValues.get(2) | ticketValues.get(3)) == 0; + } + static uint8 bitcount32(uint32 v) { v = v - ((v >> 1) & 0x55555555u); @@ -1249,8 +1471,8 @@ struct QTF : ContractBase static void clearWinerData(QPI::ContractState& state) { setMemory(state.mut().lastWinnerData, 0); } - static void fillWinnerData(QPI::ContractState& state, const WinnerPlayerData& winnerPlayerData, const Array& winnerValues, - const uint16& epoch) + static void fillWinnerData(QPI::ContractState& state, const WinnerPlayerData& winnerPlayerData, + const Array& winnerValues, const uint16& epoch) { if (winnerPlayerData.isValid()) { @@ -1265,14 +1487,88 @@ struct QTF : ContractBase state.mut().lastWinnerData.epoch = epoch; } - static void addWinningCombinationToHistory(QPI::ContractState& state, const Array& winnerValues) + static void addWinningCombinationToHistory(QPI::ContractState& state, + const Array& winnerValues) { state.mut().winningCombinationsHistory.set(state.get().winningCombinationsCount, winnerValues); state.mut().winningCombinationsCount = mod(state.get().winningCombinationsCount + 1, state.get().winningCombinationsHistory.capacity()); } - private: + PRIVATE_PROCEDURE_WITH_LOCALS(BuyPreparedTickets) + { + if ((state.get().currentState & STATE_SELLING) == 0) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + output.returnCode = toReturnCode(EReturnCode::TICKET_SELLING_CLOSED); + return; + } + + if (input.ticketCount == 0) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + output.returnCode = toReturnCode(EReturnCode::INVALID_NUMBERS); + return; + } + + locals.freeSlots = (state.get().numberOfPlayers < QTF_MAX_NUMBER_OF_PLAYERS) ? (QTF_MAX_NUMBER_OF_PLAYERS - state.get().numberOfPlayers) : 0; + if (locals.freeSlots == 0) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + output.returnCode = toReturnCode(EReturnCode::MAX_PLAYERS_REACHED); + return; + } + + locals.effectiveTicketCount = RL::min(input.ticketCount, locals.freeSlots); + locals.requiredAmount = smul(state.get().ticketPrice, locals.effectiveTicketCount); + if (locals.requiredAmount > INT64_MAX || qpi.invocationReward() <= 0 || static_cast(qpi.invocationReward()) < locals.requiredAmount) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + output.returnCode = toReturnCode(EReturnCode::INVALID_TICKET_PRICE); + return; + } + + while (locals.i < locals.effectiveTicketCount) + { + locals.offset = smul(locals.i, QTF_RANDOM_VALUES_COUNT); + locals.ticketValues.set(0, input.tickets.get(locals.offset)); + locals.ticketValues.set(1, input.tickets.get(locals.offset + 1)); + locals.ticketValues.set(2, input.tickets.get(locals.offset + 2)); + locals.ticketValues.set(3, input.tickets.get(locals.offset + 3)); + addPlayerInfo(state, qpi.invocator(), locals.ticketValues); + ++locals.i; + } + + if (qpi.invocationReward() > 0 && static_cast(qpi.invocationReward()) > locals.requiredAmount) + { + locals.excess = static_cast(qpi.invocationReward()) - locals.requiredAmount; + if (locals.excess > 0) + { + qpi.transfer(qpi.invocator(), locals.excess); + } + } + + output.boughtTicketCount = static_cast(locals.effectiveTicketCount); + output.returnCode = + (locals.effectiveTicketCount < input.ticketCount) ? toReturnCode(EReturnCode::PARTIAL_PURCHASE) : toReturnCode(EReturnCode::SUCCESS); + } + // Core settlement pipeline for one epoch: fees, FR redirects, payouts, jackpot/reserve updates. PRIVATE_PROCEDURE_WITH_LOCALS(SettleEpoch) { @@ -1855,6 +2151,77 @@ struct QTF : ContractBase } } + PRIVATE_FUNCTION_WITH_LOCALS(CollectSelectionNumbers) + { + output.isValid = true; + for (locals.idx = 0; locals.idx < input.numbers.capacity(); ++locals.idx) + { + locals.value = input.numbers.get(locals.idx); + if (locals.value == 0) + { + continue; + } + if (locals.value > QTF_MAX_RANDOM_VALUE) + { + output.isValid = false; + return; + } + if ((locals.mask & (1u << locals.value)) != 0) + { + output.isValid = false; + return; + } + locals.mask |= (1u << locals.value); + } + + // Get Sorted Unique Values from Mask + for (locals.outValue = 1; locals.outValue <= QTF_MAX_RANDOM_VALUE; ++locals.outValue) + { + if ((locals.mask & (1u << locals.outValue)) != 0) + { + output.normalizedNumbers.set(output.numberCount, locals.outValue); + ++output.numberCount; + } + } + } + + PRIVATE_FUNCTION_WITH_LOCALS(BuildSelectionTickets) + { + while (locals.i + 3 < input.numberCount) + { + locals.j = locals.i + 1; + while (locals.j + 2 < input.numberCount) + { + locals.k = locals.j + 1; + while (locals.k + 1 < input.numberCount) + { + locals.l = locals.k + 1; + while (locals.l < input.numberCount) + { + locals.ticketValues.set(0, input.normalizedNumbers.get(locals.i)); + locals.ticketValues.set(1, input.normalizedNumbers.get(locals.j)); + locals.ticketValues.set(2, input.normalizedNumbers.get(locals.k)); + locals.ticketValues.set(3, input.normalizedNumbers.get(locals.l)); + locals.preparedOffset = smul(output.preparedTickets.ticketCount, QTF_RANDOM_VALUES_COUNT); + output.preparedTickets.tickets.set(locals.preparedOffset, locals.ticketValues.get(0)); + output.preparedTickets.tickets.set(locals.preparedOffset + 1, locals.ticketValues.get(1)); + output.preparedTickets.tickets.set(locals.preparedOffset + 2, locals.ticketValues.get(2)); + output.preparedTickets.tickets.set(locals.preparedOffset + 3, locals.ticketValues.get(3)); + ++output.preparedTickets.ticketCount; + if (output.preparedTickets.ticketCount >= QTF_MAX_BATCH_TICKETS) + { + return; + } + ++locals.l; + } + ++locals.k; + } + ++locals.j; + } + ++locals.i; + } + } + /** * @brief Calculate safe reserve top-up amount respecting safety limits. * @@ -2102,24 +2469,3 @@ struct QTF : ContractBase outK2Pool = div(smul(winnersBlock, QTF_BASE_K2_SHARE_BP), 10000); } }; - -// Implementation of NextEpochData::apply (after QTF is fully defined) -inline void QTF::NextEpochData::apply(QPI::ContractState& state) const -{ - if (newTicketPrice > 0) - { - state.mut().ticketPrice = newTicketPrice; - } - if (newTargetJackpot > 0) - { - state.mut().targetJackpot = newTargetJackpot; - } - if (newSchedule > 0) - { - state.mut().schedule = newSchedule; - } - if (newDrawHour > 0) - { - state.mut().drawHour = newDrawHour; - } -} diff --git a/test/contract_qtf.cpp b/test/contract_qtf.cpp index 775a75724..998b3dbb7 100644 --- a/test/contract_qtf.cpp +++ b/test/contract_qtf.cpp @@ -12,6 +12,8 @@ constexpr uint16 QTF_PROCEDURE_SET_SCHEDULE = 3; constexpr uint16 QTF_PROCEDURE_SET_TARGET_JACKPOT = 4; constexpr uint16 QTF_PROCEDURE_SET_DRAW_HOUR = 5; constexpr uint16 QTF_PROCEDURE_SYNC_JACKPOT = 6; +constexpr uint16 QTF_PROCEDURE_BUY_TICKETS_BATCH = 7; +constexpr uint16 QTF_PROCEDURE_BUY_TICKETS_BY_SELECTION = 8; constexpr uint16 QTF_FUNCTION_GET_TICKET_PRICE = 1; constexpr uint16 QTF_FUNCTION_GET_NEXT_EPOCH_DATA = 2; @@ -86,6 +88,59 @@ namespace k2Pool = (winnersBlock * QTF_BASE_K2_SHARE_BP) / 10000ULL; k3Pool = (winnersBlock * QTF_BASE_K3_SHARE_BP) / 10000ULL; } + + static Array makeSelectionInput(std::initializer_list values) + { + Array numbers{}; + uint64 index = 0; + for (const uint8& value : values) + { + numbers.set(index++, value); + } + return numbers; + } + + static QTF::BuyPreparedTickets_input makePreparedTicketsInput(std::initializer_list tickets) + { + QTF::BuyPreparedTickets_input input{}; + uint64 ticketIndex = 0; + for (const auto& ticket : tickets) + { + const uint64 offset = ticketIndex * QTF_RANDOM_VALUES_COUNT; + for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) + { + input.tickets.set(offset + i, ticket.get(i)); + } + ++ticketIndex; + } + input.ticketCount = ticketIndex; + return input; + } + + static QTF::BuyTicketsBatch_input makeBatchTicketsInput(std::initializer_list tickets) + { + QTF::BuyTicketsBatch_input input{}; + uint64 ticketIndex = 0; + for (const auto& ticket : tickets) + { + const uint64 offset = ticketIndex * QTF_RANDOM_VALUES_COUNT; + for (uint64 i = 0; i < QTF_RANDOM_VALUES_COUNT; ++i) + { + input.tickets.set(offset + i, ticket.get(i)); + } + ++ticketIndex; + } + return input; + } + + static uint64 combinations4(uint64 numberCount) + { + if (numberCount < QTF_RANDOM_VALUES_COUNT) + { + return 0; + } + return (numberCount * (numberCount - 1) * (numberCount - 2) * (numberCount - 3)) / 24ULL; + } } // namespace constexpr uint8 QTF_ANY_DAY_SCHEDULE = 0xFF; @@ -94,10 +149,12 @@ constexpr uint8 QTF_ANY_DAY_SCHEDULE = 0xFF; class QTFChecker : public QTF, public QTF::StateData { public: - const QPI::ContractState& asState() const { + const QPI::ContractState& asState() const + { return *reinterpret_cast*>(static_cast(this)); } - QPI::ContractState& asMutState() { + QPI::ContractState& asMutState() + { return *reinterpret_cast*>(static_cast(this)); } @@ -112,6 +169,7 @@ class QTFChecker : public QTF, public QTF::StateData uint64 getWinningCombinationsWriteIndex() const { return winningCombinationsCount; } const id& team() const { return teamAddress; } + void setNumberOfPlayers(uint64 newCount) { numberOfPlayers = newCount; } void setScheduleMask(uint8 newMask) { schedule = newMask; } void setJackpot(uint64 value) { jackpot = value; } void setTargetJackpotInternal(uint64 value) { targetJackpot = value; } @@ -273,6 +331,43 @@ class QTFChecker : public QTF, public QTF::StateData ProcessTierPayout(qpi, asMutState(), input, output, locals); return output; } + + bool callIsEmptyTicket(const QTFRandomValues& ticketValues) const { return isEmptyTicket(ticketValues); } + + CollectSelectionNumbers_output callCollectSelectionNumbers(const QPI::QpiContextFunctionCall& qpi, + const Array& numbers) const + { + CollectSelectionNumbers_input input{}; + CollectSelectionNumbers_output output{}; + CollectSelectionNumbers_locals locals{}; + + input.numbers = numbers; + CollectSelectionNumbers(qpi, asState(), input, output, locals); + return output; + } + + BuildSelectionTickets_output callBuildSelectionTickets(const QPI::QpiContextFunctionCall& qpi, + const Array& normalizedNumbers, uint8 numberCount) const + { + BuildSelectionTickets_input input{}; + BuildSelectionTickets_output output{}; + BuildSelectionTickets_locals locals{}; + + input.normalizedNumbers = normalizedNumbers; + input.numberCount = numberCount; + BuildSelectionTickets(qpi, asState(), input, output, locals); + return output; + } + + BuyPreparedTickets_output callBuyPreparedTickets(const QPI::QpiContextProcedureCall& qpi, BuyPreparedTickets_input input) + { + BuyPreparedTickets_output output{}; + BuyPreparedTickets_locals locals{}; + BuyPreparedTickets_input mutableInput = input; + + BuyPreparedTickets(qpi, asMutState(), mutableInput, output, locals); + return output; + } }; class ContractTestingQTF : protected ContractTesting @@ -465,6 +560,28 @@ class ContractTestingQTF : protected ContractTesting return output; } + QTF::BuyTicketsBatch_output buyTicketsBatch(const id& user, uint64 reward, const QTF::BuyTicketsBatch_input& input) + { + QTF::BuyTicketsBatch_output output{}; + if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_BUY_TICKETS_BATCH, input, output, user, reward)) + { + output.returnCode = static_cast(QTF::EReturnCode::MAX_VALUE); + } + return output; + } + + QTF::BuyTicketsBySelection_output buyTicketsBySelection(const id& user, uint64 reward, const Array& numbers) + { + QTF::BuyTicketsBySelection_input input{}; + input.numbers = numbers; + QTF::BuyTicketsBySelection_output output{}; + if (!invokeUserProcedure(QTF_CONTRACT_INDEX, QTF_PROCEDURE_BUY_TICKETS_BY_SELECTION, input, output, user, reward)) + { + output.returnCode = static_cast(QTF::EReturnCode::MAX_VALUE); + } + return output; + } + // System procedure wrappers void beginEpoch() { callSystemProcedure(QTF_CONTRACT_INDEX, BEGIN_EPOCH); } void endEpoch() { callSystemProcedure(QTF_CONTRACT_INDEX, END_EPOCH); } @@ -727,6 +844,126 @@ TEST(ContractQThirtyFour_Private, ValidateNumbers_WorksForValidDuplicateAndRange EXPECT_FALSE(ctl.state()->callValidateNumbers(qpi, outOfRange).isValid); } +TEST(ContractQThirtyFour_Private, IsEmptyTicket_ReturnsTrueOnlyForAllZeroTicket) +{ + ContractTestingQTF ctl; + + static const QTFRandomValues empty{}; + EXPECT_TRUE(ctl.state()->callIsEmptyTicket(empty)); + + QTFRandomValues partiallyFilled{}; + partiallyFilled.set(2, 17); + EXPECT_FALSE(ctl.state()->callIsEmptyTicket(partiallyFilled)); + + const QTFRandomValues valid = ctl.makeValidNumbers(1, 2, 3, 4); + EXPECT_FALSE(ctl.state()->callIsEmptyTicket(valid)); +} + +TEST(ContractQThirtyFour_Private, CollectSelectionNumbers_NormalizesZerosAndRejectsInvalidInput) +{ + ContractTestingQTF ctl; + + QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); + primeQpiFunctionContext(qpi); + + { + const auto out = ctl.state()->callCollectSelectionNumbers(qpi, makeSelectionInput({7, 0, 3, 9, 1})); + EXPECT_TRUE(out.isValid); + EXPECT_EQ(out.numberCount, 4u); + EXPECT_EQ(out.normalizedNumbers.get(0), 1u); + EXPECT_EQ(out.normalizedNumbers.get(1), 3u); + EXPECT_EQ(out.normalizedNumbers.get(2), 7u); + EXPECT_EQ(out.normalizedNumbers.get(3), 9u); + } + + { + const auto out = ctl.state()->callCollectSelectionNumbers(qpi, makeSelectionInput({0, 0, 0})); + EXPECT_TRUE(out.isValid); + EXPECT_EQ(out.numberCount, 0u); + } + + { + const auto out = ctl.state()->callCollectSelectionNumbers(qpi, makeSelectionInput({4, 0, 4})); + EXPECT_FALSE(out.isValid); + } + + { + const auto out = ctl.state()->callCollectSelectionNumbers(qpi, makeSelectionInput({1, 2, 31})); + EXPECT_FALSE(out.isValid); + } +} + +TEST(ContractQThirtyFour_Private, BuildSelectionTickets_GeneratesCombinationsAndClipsToBatchLimit) +{ + ContractTestingQTF ctl; + + QpiContextUserFunctionCall qpi(QTF_CONTRACT_INDEX); + primeQpiFunctionContext(qpi); + + { + const QTF::BuildSelectionTickets_output& out = ctl.state()->callBuildSelectionTickets(qpi, makeSelectionInput({1, 2, 3}), 3); + EXPECT_EQ(out.preparedTickets.ticketCount, 0u); + } + + { + const QTF::BuildSelectionTickets_output& out = ctl.state()->callBuildSelectionTickets(qpi, makeSelectionInput({1, 2, 3, 4, 5}), 5); + EXPECT_EQ(out.preparedTickets.ticketCount, 5u); + + const QTFRandomValues expected[] = { + ctl.makeValidNumbers(1, 2, 3, 4), ctl.makeValidNumbers(1, 2, 3, 5), ctl.makeValidNumbers(1, 2, 4, 5), + ctl.makeValidNumbers(1, 3, 4, 5), ctl.makeValidNumbers(2, 3, 4, 5), + }; + + for (uint64 ticketIndex = 0; ticketIndex < 5; ++ticketIndex) + { + const uint64 offset = ticketIndex * QTF_RANDOM_VALUES_COUNT; + for (uint64 valueIndex = 0; valueIndex < QTF_RANDOM_VALUES_COUNT; ++valueIndex) + { + EXPECT_EQ(out.preparedTickets.tickets.get(offset + valueIndex), expected[ticketIndex].get(valueIndex)); + } + } + } + + { + Array numbers{}; + for (uint8 value = 1; value <= 11; ++value) + { + numbers.set(value - 1, value); + } + + const QTF::BuildSelectionTickets_output& out = ctl.state()->callBuildSelectionTickets(qpi, numbers, 11); + EXPECT_EQ(out.preparedTickets.ticketCount, QTF_MAX_BATCH_TICKETS); + + std::set uniqueTickets; + for (uint64 ticketIndex = 0; ticketIndex < out.preparedTickets.ticketCount; ++ticketIndex) + { + const uint64 offset = ticketIndex * QTF_RANDOM_VALUES_COUNT; + const uint8 a = out.preparedTickets.tickets.get(offset); + const uint8 b = out.preparedTickets.tickets.get(offset + 1); + const uint8 c = out.preparedTickets.tickets.get(offset + 2); + const uint8 d = out.preparedTickets.tickets.get(offset + 3); + + EXPECT_GE(a, 1u); + EXPECT_LE(d, 11u); + EXPECT_LT(a, b); + EXPECT_LT(b, c); + EXPECT_LT(c, d); + + QTFRandomValues generatedTicket{}; + generatedTicket.set(0, a); + generatedTicket.set(1, b); + generatedTicket.set(2, c); + generatedTicket.set(3, d); + EXPECT_TRUE(ctl.state()->callValidateNumbers(qpi, generatedTicket).isValid); + + const uint32 packedTicket = + static_cast(a) | (static_cast(b) << 8) | (static_cast(c) << 16) | (static_cast(d) << 24); + EXPECT_TRUE(uniqueTickets.insert(packedTicket).second); + } + EXPECT_EQ(uniqueTickets.size(), static_cast(QTF_MAX_BATCH_TICKETS)); + } +} + TEST(ContractQThirtyFour_Private, GetRandomValues_IsDeterministicAndUniqueInRange) { ContractTestingQTF ctl; @@ -994,6 +1231,128 @@ TEST(ContractQThirtyFour_Private, ReturnAllTickets_RefundsEachPlayerAndClearsVia EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0ULL); } +TEST(ContractQThirtyFour_Private, BuyPreparedTickets_RejectsClosedEmptyFullAndInvalidPrice) +{ + ContractTestingQTF ctl; + const id user = id::randomValue(); + + const auto oneTicket = makePreparedTicketsInput({ctl.makeValidNumbers(1, 2, 3, 4)}); + + { + QpiContextUserProcedureCall qpi(QTF_CONTRACT_INDEX, user, ctl.state()->getTicketPriceInternal()); + primeQpiProcedureContext(qpi, static_cast(ctl.state()->getDrawHourInternal())); + + increaseEnergy(ctl.qtfSelf(), ctl.state()->getTicketPriceInternal()); + const uint64 userBefore = getBalance(user); + const auto out = ctl.state()->callBuyPreparedTickets(qpi, oneTicket); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::TICKET_SELLING_CLOSED)); + EXPECT_EQ(getBalance(user), userBefore + ctl.state()->getTicketPriceInternal()); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); + } + + ctl.startAnyDayEpoch(); + + { + QpiContextUserProcedureCall qpi(QTF_CONTRACT_INDEX, user, ctl.state()->getTicketPriceInternal()); + primeQpiProcedureContext(qpi, static_cast(ctl.state()->getDrawHourInternal())); + + increaseEnergy(ctl.qtfSelf(), ctl.state()->getTicketPriceInternal()); + const uint64 userBefore = getBalance(user); + QTF::BuyPreparedTickets_input emptyInput{}; + const auto out = ctl.state()->callBuyPreparedTickets(qpi, emptyInput); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_NUMBERS)); + EXPECT_EQ(getBalance(user), userBefore + ctl.state()->getTicketPriceInternal()); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); + } + + for (uint64 i = 0; i < QTF_MAX_NUMBER_OF_PLAYERS; ++i) + { + ctl.addPlayerDirect(id::randomValue(), ctl.makeValidNumbers(1, 2, 3, 4)); + } + + { + QpiContextUserProcedureCall qpi(QTF_CONTRACT_INDEX, user, ctl.state()->getTicketPriceInternal()); + primeQpiProcedureContext(qpi, static_cast(ctl.state()->getDrawHourInternal())); + + increaseEnergy(ctl.qtfSelf(), ctl.state()->getTicketPriceInternal()); + const uint64 userBefore = getBalance(user); + const auto out = ctl.state()->callBuyPreparedTickets(qpi, oneTicket); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::MAX_PLAYERS_REACHED)); + EXPECT_EQ(getBalance(user), userBefore + ctl.state()->getTicketPriceInternal()); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), QTF_MAX_NUMBER_OF_PLAYERS); + } + + ctl.state()->setNumberOfPlayers(0); + ctl.state()->setTicketPriceInternal(static_cast(INT64_MAX)); + + { + const uint64 reward = 1; + QpiContextUserProcedureCall qpi(QTF_CONTRACT_INDEX, user, reward); + primeQpiProcedureContext(qpi, static_cast(ctl.state()->getDrawHourInternal())); + + increaseEnergy(ctl.qtfSelf(), reward); + const uint64 userBefore = getBalance(user); + const auto out = ctl.state()->callBuyPreparedTickets(qpi, makePreparedTicketsInput({ + ctl.makeValidNumbers(1, 2, 3, 4), + ctl.makeValidNumbers(5, 6, 7, 8), + })); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_TICKET_PRICE)); + EXPECT_EQ(getBalance(user), userBefore + reward); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); + } +} + +TEST(ContractQThirtyFour_Private, BuyPreparedTickets_SucceedsRefundsExcessAndSupportsPartialPurchase) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + const auto twoTickets = makePreparedTicketsInput({ + ctl.makeValidNumbers(1, 2, 3, 4), + ctl.makeValidNumbers(5, 6, 7, 8), + }); + + { + const uint64 reward = ticketPrice * 3; + QpiContextUserProcedureCall qpi(QTF_CONTRACT_INDEX, user, reward); + primeQpiProcedureContext(qpi, static_cast(ctl.state()->getDrawHourInternal())); + + increaseEnergy(ctl.qtfSelf(), reward); + const uint64 userBefore = getBalance(user); + const auto out = ctl.state()->callBuyPreparedTickets(qpi, twoTickets); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 2u); + EXPECT_EQ(getBalance(user), userBefore + ticketPrice); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 2u); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(0).randomValues, ctl.makeValidNumbers(1, 2, 3, 4))); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(1).randomValues, ctl.makeValidNumbers(5, 6, 7, 8))); + } + + while (ctl.state()->getNumberOfPlayers() < QTF_MAX_NUMBER_OF_PLAYERS - 1) + { + ctl.addPlayerDirect(id::randomValue(), ctl.makeValidNumbers(9, 10, 11, 12)); + } + + { + const uint64 reward = ticketPrice * 2; + QpiContextUserProcedureCall qpi(QTF_CONTRACT_INDEX, user, reward); + primeQpiProcedureContext(qpi, static_cast(ctl.state()->getDrawHourInternal())); + + increaseEnergy(ctl.qtfSelf(), reward); + const uint64 userBefore = getBalance(user); + const auto out = ctl.state()->callBuyPreparedTickets(qpi, twoTickets); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::PARTIAL_PURCHASE)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 1u); + EXPECT_EQ(getBalance(user), userBefore + ticketPrice); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), QTF_MAX_NUMBER_OF_PLAYERS); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(QTF_MAX_NUMBER_OF_PLAYERS - 1).randomValues, ctl.makeValidNumbers(1, 2, 3, 4))); + } +} + // ============================================================================ // BUY TICKET TESTS // ============================================================================ @@ -1256,6 +1615,331 @@ TEST(ContractQThirtyFour, BuyTicket_SamePlayerMultipleTickets_Allowed) EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 3u); } +// ============================================================================ +// BATCH AND SELECTION BUY TESTS +// ============================================================================ + +TEST(ContractQThirtyFour, BuyTicketsBatch_WhenSellingClosed_RefundsAndFails) +{ + ContractTestingQTF ctl; + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 2); + const uint64 balanceBefore = getBalance(user); + + const auto out = ctl.buyTicketsBatch(user, ticketPrice, + makeBatchTicketsInput({ + ctl.makeValidNumbers(1, 2, 3, 4), + })); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::TICKET_SELLING_CLOSED)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, BuyTicketsBatch_AllInvalidOrEmpty_RefundsAndFails) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 5); + const uint64 balanceBefore = getBalance(user); + + QTFRandomValues duplicate = ctl.makeValidNumbers(4, 5, 6, 7); + duplicate.set(3, 5); + QTFRandomValues outOfRange = ctl.makeValidNumbers(1, 2, 3, 4); + outOfRange.set(2, 31); + QTFRandomValues empty{}; + + const auto out = ctl.buyTicketsBatch(user, ticketPrice * 3, makeBatchTicketsInput({empty, duplicate, outOfRange})); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_NUMBERS)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, BuyTicketsBatch_RejectsInsufficientRewardForValidTickets) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 5); + const uint64 balanceBefore = getBalance(user); + + const auto out = ctl.buyTicketsBatch(user, ticketPrice, + makeBatchTicketsInput({ + ctl.makeValidNumbers(1, 2, 3, 4), + ctl.makeValidNumbers(5, 6, 7, 8), + })); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_TICKET_PRICE)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, BuyTicketsBatch_WhenMaxPlayersReached_RefundsAndFails) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + for (uint64 i = 0; i < QTF_MAX_NUMBER_OF_PLAYERS; ++i) + { + ctl.addPlayerDirect(id::randomValue(), ctl.makeValidNumbers(1, 2, 3, 4)); + } + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 2); + const uint64 balanceBefore = getBalance(user); + + const auto out = ctl.buyTicketsBatch(user, ticketPrice, + makeBatchTicketsInput({ + ctl.makeValidNumbers(5, 6, 7, 8), + })); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::MAX_PLAYERS_REACHED)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), QTF_MAX_NUMBER_OF_PLAYERS); +} + +TEST(ContractQThirtyFour, BuyTicketsBatch_PartialPurchase_UsesOnlyFreeSlots) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + while (ctl.state()->getNumberOfPlayers() < QTF_MAX_NUMBER_OF_PLAYERS - 2) + { + ctl.addPlayerDirect(id::randomValue(), ctl.makeValidNumbers(9, 10, 11, 12)); + } + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 5); + const uint64 balanceBefore = getBalance(user); + + const QTFRandomValues t1 = ctl.makeValidNumbers(1, 2, 3, 4); + const QTFRandomValues t2 = ctl.makeValidNumbers(5, 6, 7, 8); + const QTFRandomValues t3 = ctl.makeValidNumbers(13, 14, 15, 16); + const auto out = ctl.buyTicketsBatch(user, ticketPrice * 3, makeBatchTicketsInput({t1, t2, t3})); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::PARTIAL_PURCHASE)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 2u); + EXPECT_EQ(getBalance(user), balanceBefore - ticketPrice * 2); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), QTF_MAX_NUMBER_OF_PLAYERS); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(QTF_MAX_NUMBER_OF_PLAYERS - 2).randomValues, t1)); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(QTF_MAX_NUMBER_OF_PLAYERS - 1).randomValues, t2)); +} + +TEST(ContractQThirtyFour, BuyTicketsBatch_SkipsInvalidTicketsAndRefundsExcess) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 6); + const uint64 balanceBefore = getBalance(user); + + QTFRandomValues duplicate = ctl.makeValidNumbers(11, 12, 13, 14); + duplicate.set(3, 12); + QTFRandomValues outOfRange = ctl.makeValidNumbers(21, 22, 23, 24); + outOfRange.set(0, 0); + QTFRandomValues empty{}; + const QTFRandomValues valid1 = ctl.makeValidNumbers(1, 2, 3, 4); + const QTFRandomValues valid2 = ctl.makeValidNumbers(5, 6, 7, 8); + + const auto out = ctl.buyTicketsBatch(user, ticketPrice * 3, makeBatchTicketsInput({valid1, empty, duplicate, valid2, outOfRange})); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 2u); + EXPECT_EQ(getBalance(user), balanceBefore - ticketPrice * 2); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 2u); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(0).randomValues, valid1)); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(1).randomValues, valid2)); +} + +TEST(ContractQThirtyFour, BuyTicketsBySelection_WhenSellingClosed_RefundsAndFails) +{ + ContractTestingQTF ctl; + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 2); + const uint64 balanceBefore = getBalance(user); + + const auto out = ctl.buyTicketsBySelection(user, ticketPrice, makeSelectionInput({1, 2, 3, 4})); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::TICKET_SELLING_CLOSED)); + EXPECT_EQ(static_cast(out.requestedTicketCount), 0u); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, BuyTicketsBySelection_InvalidSelections_RefundAndFail) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 6); + const uint64 balanceBefore = getBalance(user); + + { + const auto out = ctl.buyTicketsBySelection(user, ticketPrice, makeSelectionInput({4, 0, 4, 8})); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_NUMBERS)); + EXPECT_EQ(static_cast(out.requestedTicketCount), 0u); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + } + + { + const auto out = ctl.buyTicketsBySelection(user, ticketPrice, makeSelectionInput({1, 2, 31, 4})); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_NUMBERS)); + EXPECT_EQ(static_cast(out.requestedTicketCount), 0u); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + } + + { + const auto out = ctl.buyTicketsBySelection(user, ticketPrice, makeSelectionInput({1, 2, 3, 0})); + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_NUMBERS)); + EXPECT_EQ(static_cast(out.requestedTicketCount), 0u); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + } + + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, BuyTicketsBySelection_TooManyCombinations_RefundsAndFails) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 40); + const uint64 balanceBefore = getBalance(user); + + const auto out = ctl.buyTicketsBySelection(user, ticketPrice * 30, makeSelectionInput({1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11})); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_VALUE)); + EXPECT_EQ(static_cast(out.requestedTicketCount), combinations4(11)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, BuyTicketsBySelection_RejectsInsufficientReward) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 10); + const uint64 balanceBefore = getBalance(user); + + const auto out = ctl.buyTicketsBySelection(user, ticketPrice * 4, makeSelectionInput({1, 2, 3, 4, 5})); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::INVALID_TICKET_PRICE)); + EXPECT_EQ(static_cast(out.requestedTicketCount), combinations4(5)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), 0u); +} + +TEST(ContractQThirtyFour, BuyTicketsBySelection_WhenMaxPlayersReached_RefundsAndFails) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + for (uint64 i = 0; i < QTF_MAX_NUMBER_OF_PLAYERS; ++i) + { + ctl.addPlayerDirect(id::randomValue(), ctl.makeValidNumbers(1, 2, 3, 4)); + } + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 2); + const uint64 balanceBefore = getBalance(user); + + const auto out = ctl.buyTicketsBySelection(user, ticketPrice, makeSelectionInput({1, 2, 3, 4})); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::MAX_PLAYERS_REACHED)); + EXPECT_EQ(static_cast(out.requestedTicketCount), 1u); + EXPECT_EQ(static_cast(out.boughtTicketCount), 0u); + EXPECT_EQ(getBalance(user), balanceBefore); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), QTF_MAX_NUMBER_OF_PLAYERS); +} + +TEST(ContractQThirtyFour, BuyTicketsBySelection_PartialPurchase_UsesOnlyFreeSlots) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + while (ctl.state()->getNumberOfPlayers() < QTF_MAX_NUMBER_OF_PLAYERS - 3) + { + ctl.addPlayerDirect(id::randomValue(), ctl.makeValidNumbers(20, 21, 22, 23)); + } + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 8); + const uint64 balanceBefore = getBalance(user); + + const auto out = ctl.buyTicketsBySelection(user, ticketPrice * 5, makeSelectionInput({9, 1, 5, 7, 3})); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::PARTIAL_PURCHASE)); + EXPECT_EQ(static_cast(out.requestedTicketCount), combinations4(5)); + EXPECT_EQ(static_cast(out.boughtTicketCount), 3u); + EXPECT_EQ(getBalance(user), balanceBefore - ticketPrice * 3); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), QTF_MAX_NUMBER_OF_PLAYERS); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(QTF_MAX_NUMBER_OF_PLAYERS - 3).randomValues, ctl.makeValidNumbers(1, 3, 5, 7))); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(QTF_MAX_NUMBER_OF_PLAYERS - 2).randomValues, ctl.makeValidNumbers(1, 3, 5, 9))); + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(QTF_MAX_NUMBER_OF_PLAYERS - 1).randomValues, ctl.makeValidNumbers(1, 3, 7, 9))); +} + +TEST(ContractQThirtyFour, BuyTicketsBySelection_SuccessBuildsNormalizedCombinationsAndRefundsExcess) +{ + ContractTestingQTF ctl; + ctl.startAnyDayEpoch(); + + const uint64 ticketPrice = ctl.state()->getTicketPriceInternal(); + const id user = id::randomValue(); + increaseEnergy(user, ticketPrice * 10); + const uint64 balanceBefore = getBalance(user); + + const auto out = ctl.buyTicketsBySelection(user, ticketPrice * 6, makeSelectionInput({9, 0, 3, 7, 1, 5})); + + EXPECT_EQ(out.returnCode, static_cast(QTF::EReturnCode::SUCCESS)); + EXPECT_EQ(static_cast(out.requestedTicketCount), combinations4(5)); + EXPECT_EQ(static_cast(out.boughtTicketCount), combinations4(5)); + EXPECT_EQ(getBalance(user), balanceBefore - ticketPrice * combinations4(5)); + EXPECT_EQ(ctl.state()->getNumberOfPlayers(), combinations4(5)); + + const QTFRandomValues expected[] = { + ctl.makeValidNumbers(1, 3, 5, 7), ctl.makeValidNumbers(1, 3, 5, 9), ctl.makeValidNumbers(1, 3, 7, 9), + ctl.makeValidNumbers(1, 5, 7, 9), ctl.makeValidNumbers(3, 5, 7, 9), + }; + + for (uint64 i = 0; i < combinations4(5); ++i) + { + EXPECT_TRUE(valuesEqual(ctl.state()->getPlayer(i).randomValues, expected[i])); + } +} + // ============================================================================ // CONFIGURATION CHANGE TESTS // ============================================================================