diff --git a/src/Qubic.vcxproj b/src/Qubic.vcxproj index db159d520..7a964d453 100644 --- a/src/Qubic.vcxproj +++ b/src/Qubic.vcxproj @@ -48,6 +48,7 @@ + diff --git a/src/Qubic.vcxproj.filters b/src/Qubic.vcxproj.filters index 7925cb33b..c3ca89854 100644 --- a/src/Qubic.vcxproj.filters +++ b/src/Qubic.vcxproj.filters @@ -312,6 +312,9 @@ contracts + + contracts + contract_core diff --git a/src/contract_core/contract_def.h b/src/contract_core/contract_def.h index 29988c27a..9ac1c9d65 100644 --- a/src/contract_core/contract_def.h +++ b/src/contract_core/contract_def.h @@ -241,6 +241,16 @@ #define CONTRACT_STATE2_TYPE QDUEL2 #include "contracts/QDuel.h" +#undef CONTRACT_INDEX +#undef CONTRACT_STATE_TYPE +#undef CONTRACT_STATE2_TYPE + +#define PULSE_CONTRACT_INDEX 24 +#define CONTRACT_INDEX PULSE_CONTRACT_INDEX +#define CONTRACT_STATE_TYPE PULSE +#define CONTRACT_STATE2_TYPE PULSE2 +#include "contracts/Pulse.h" + // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES @@ -352,6 +362,7 @@ constexpr struct ContractDescription {"QRP", 199, 10000, sizeof(IPO)}, // proposal in epoch 197, IPO in 198, construction and first use in 199 {"QTF", 199, 10000, sizeof(QTF)}, // proposal in epoch 197, IPO in 198, construction and first use in 199 {"QDUEL", 199, 10000, sizeof(QDUEL)}, // proposal in epoch 197, IPO in 198, construction and first use in 199 + {"PULSE", 200, 10000, sizeof(PULSE)}, // proposal in epoch 198, IPO in 199, construction and first use in 200 // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES {"TESTEXA", 138, 10000, sizeof(TESTEXA)}, @@ -471,6 +482,7 @@ static void initializeContracts() REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QRP); REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QTF); REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QDUEL); + REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(PULSE); // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(TESTEXA); diff --git a/src/contracts/Pulse.h b/src/contracts/Pulse.h new file mode 100644 index 000000000..200c87c8d --- /dev/null +++ b/src/contracts/Pulse.h @@ -0,0 +1,1727 @@ +/** + * @file Pulse.h + * @brief Pulse lottery contract: 6 digits per ticket, winning digits are 6 draws from 0..9. + * + * Mechanics: + * - Tickets are sold during SELLING state (1 ticket per call). + * - Draw is triggered on scheduled days after drawHour (UTC). + * - Ticket revenue (QHeart asset) is split by configured percents; remainder stays in contract. + * - Fixed rewards are paid from the contract QHeart balance ("Pulse wallet"). + * - If contract balance exceeds cap, excess is sent to QHeart wallet. + */ + +using namespace QPI; + +constexpr uint16 PULSE_MAX_NUMBER_OF_PLAYERS = 1024; +constexpr uint16 PULSE_MAX_NUMBER_OF_AUTO_PARTICIPANTS = div(PULSE_MAX_NUMBER_OF_PLAYERS, 2); +constexpr uint8 PULSE_PLAYER_DIGITS = 6; +constexpr uint8 PULSE_PLAYER_DIGITS_ALIGNED = PULSE_PLAYER_DIGITS + 2; +constexpr uint8 PULSE_WINNING_DIGITS = PULSE_PLAYER_DIGITS; +constexpr uint8 PULSE_WINNING_DIGITS_ALIGNED = PULSE_PLAYER_DIGITS_ALIGNED; +constexpr uint8 PULSE_MAX_DIGIT = 9; +constexpr uint8 PULSE_MAX_DIGIT_ALIGNED = PULSE_MAX_DIGIT + 7; +constexpr uint64 PULSE_TICKET_PRICE_DEFAULT = 200000ULL; +constexpr uint16 PULSE_MAX_NUMBER_OF_WINNERS_IN_HISTORY = 1024; +constexpr uint64 PULSE_QHEART_ASSET_NAME = 92712259110993ULL; // "QHEART" +constexpr uint8 PULSE_DEFAULT_DEV_PERCENT = 10; +constexpr uint8 PULSE_DEFAULT_BURN_PERCENT = 5; +constexpr uint8 PULSE_DEFAULT_SHAREHOLDERS_PERCENT = 5; +constexpr uint8 PULSE_DEFAULT_QHEART_PERCENT = 5; +constexpr uint64 PULSE_DEFAULT_QHEART_HOLD_LIMIT = 2000000000ULL; +constexpr uint8 PULSE_TICK_UPDATE_PERIOD = 100; +constexpr uint8 PULSE_DEFAULT_DRAW_HOUR = 11; // 11:00 UTC +constexpr uint8 PULSE_DEFAULT_SCHEDULE = 1 << WEDNESDAY | 1 << FRIDAY | 1 << SUNDAY; +constexpr uint32 PULSE_DEFAULT_INIT_TIME = 22 << 9 | 4 << 5 | 13; +constexpr uint16 PULSE_DEFAULT_MAX_AUTO_TICKETS_PER_USER = div(PULSE_MAX_NUMBER_OF_PLAYERS, 2); +constexpr uint64 PULSE_CLEANUP_THRESHOLD = 75ULL; + +const id PULSE_QHEART_ISSUER = ID(_S, _S, _G, _X, _S, _L, _S, _X, _F, _E, _J, _O, _O, _B, _T, _Z, _W, _V, _D, _S, _R, _C, _E, _F, _G, _X, _N, _D, _Y, + _U, _V, _D, _X, _M, _Q, _A, _L, _X, _L, _B, _X, _G, _D, _C, _R, _X, _T, _K, _F, _Z, _I, _O, _T, _G, _Z, _F); +constexpr uint64 PULSE_CONTRACT_ASSET_NAME = 297750254928ULL; // "PULSE" + +struct PULSE2 +{ +}; + +struct PULSE : public ContractBase +{ +public: + // Bitmask for runtime state flags. + enum class EState : uint8 + { + SELLING = 1 << 0, + }; + + 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); } + + // Public return codes for user procedures/functions. + enum class EReturnCode : uint8 + { + SUCCESS, + TICKET_INVALID_PRICE, + TICKET_ALL_SOLD_OUT, + TICKET_SELLING_CLOSED, + AUTO_PARTICIPANTS_FULL, + INVALID_NUMBERS, + ACCESS_DENIED, + INVALID_VALUE, + TRANSFER_TO_PULSE_FAILED, + TRANSFER_FROM_PULSE_FAILED, + UNKNOWN_ERROR = UINT8_MAX + }; + + static constexpr uint8 toReturnCode(const EReturnCode& code) { return static_cast(code); }; + + // Ticket payload stored per round; digits use QPI-aligned storage. + struct Ticket + { + id player; + Array digits; + }; + + struct AutoParticipant + { + id player; + sint64 deposit; + uint16 desiredTickets; + }; + + // Deferred settings applied at END_EPOCH to avoid mid-round changes. + struct NextEpochData + { + void clear() + { + hasNewPrice = false; + hasNewSchedule = false; + hasNewDrawHour = false; + hasNewFee = false; + hasNewQHeartHoldLimit = false; + newPrice = 0; + newSchedule = 0; + newDrawHour = 0; + newDevPercent = 0; + newBurnPercent = 0; + newShareholdersPercent = 0; + newQHeartPercent = 0; + newQHeartHoldLimit = 0; + } + + void apply(PULSE& state) const + { + if (hasNewPrice) + { + state.ticketPrice = newPrice; + } + if (hasNewSchedule) + { + state.schedule = newSchedule; + } + if (hasNewDrawHour) + { + state.drawHour = newDrawHour; + } + if (hasNewFee) + { + state.devPercent = newDevPercent; + state.burnPercent = newBurnPercent; + state.shareholdersPercent = newShareholdersPercent; + state.qheartPercent = newQHeartPercent; + } + if (hasNewQHeartHoldLimit) + { + state.qheartHoldLimit = newQHeartHoldLimit; + } + } + + bit hasNewPrice; + bit hasNewSchedule; + bit hasNewDrawHour; + bit hasNewFee; + bit hasNewQHeartHoldLimit; + uint64 newPrice; + uint8 newSchedule; + uint8 newDrawHour; + uint8 newDevPercent; + uint8 newBurnPercent; + uint8 newShareholdersPercent; + uint8 newQHeartPercent; + uint64 newQHeartHoldLimit; + }; + + struct ValidateDigits_input + { + Array digits; + }; + struct ValidateDigits_output + { + bit isValid; + }; + struct ValidateDigits_locals + { + uint8 idx; + uint8 value; + }; + + struct BuyTicket_input + { + Array digits; + }; + + struct BuyTicket_output + { + uint8 returnCode; + }; + + struct BuyTicket_locals + { + uint64 slotsLeft; + sint64 userBalance; + sint64 transferResult; + Ticket ticket; + ValidateDigits_input validateInput; + ValidateDigits_output validateOutput; + }; + + struct GetRandomDigits_input + { + uint64 seed; + }; + struct GetRandomDigits_output + { + Array digits; + }; + struct GetRandomDigits_locals + { + uint64 tempValue; + uint8 index; + uint8 candidate; + }; + + struct PrepareRandomTickets_input + { + uint16 count; + }; + + struct PrepareRandomTickets_output + { + uint8 returnCode; + uint16 count; + }; + + struct PrepareRandomTickets_locals + { + uint64 slotsLeft; + }; + + struct ChargeTicketsFromPlayer_input + { + id player; + uint16 count; + }; + + struct ChargeTicketsFromPlayer_output + { + uint8 returnCode; + }; + + struct ChargeTicketsFromPlayer_locals + { + sint64 userBalance; + sint64 transferResult; + uint64 totalPrice; + }; + + struct AllocateRandomTickets_input + { + id player; + uint16 count; + }; + + struct AllocateRandomTickets_output + { + uint8 returnCode; + }; + + struct AllocateRandomTickets_locals + { + uint64 slotsLeft; + uint64 randomSeed; + uint64 tempSeed; + uint16 i; + Ticket ticket; + GetRandomDigits_input randomInput; + GetRandomDigits_output randomOutput; + + struct RandomData + { + m256i prevSpectrumDigest; + AllocateRandomTickets_input allocateInput; + sint64 ticketCounter; + } randomData; + }; + + struct BuyRandomTickets_input + { + uint16 count; + }; + + struct BuyRandomTickets_output + { + uint8 returnCode; + }; + + struct BuyRandomTickets_locals + { + PrepareRandomTickets_input prepareInput; + PrepareRandomTickets_output prepareOutput; + ChargeTicketsFromPlayer_input chargeInput; + ChargeTicketsFromPlayer_output chargeOutput; + AllocateRandomTickets_input allocateInput; + AllocateRandomTickets_output allocateOutput; + }; + + struct FindAutoParticipant_input + { + id player; + }; + struct FindAutoParticipant_output + { + bit found; + sint64 index; + }; + struct FindAutoParticipant_locals + { + sint64 elementIndex; + }; + + struct GetAutoParticipation_input + { + }; + struct GetAutoParticipation_output + { + uint64 deposit; + uint16 desiredTickets; + uint8 returnCode; + }; + struct GetAutoParticipation_locals + { + AutoParticipant entry; + }; + + struct GetAutoStats_input + { + }; + struct GetAutoStats_output + { + uint16 autoParticipantsCounter; + uint64 totalAutoDeposits; + sint64 autoStartIndex; + uint16 maxAutoTicketsPerUser; + uint8 returnCode; + }; + + struct DepositAutoParticipation_input + { + sint64 amount; + sint16 desiredTickets; + bit buyNow; + }; + struct DepositAutoParticipation_output + { + uint8 returnCode; + }; + struct DepositAutoParticipation_locals + { + sint64 userBalance; + sint64 transferResult; + AutoParticipant entry; + sint64 insertIndex; + sint64 totalPrice; + uint64 slotsLeft; + uint64 affordable; + uint64 toBuy; + uint64 spend; + uint64 seedIndex; + m256i mixedSpectrumValue; + uint64 randomSeed; + uint64 tempSeed; + uint16 i; + Ticket ticket; + GetRandomDigits_input randomInput; + GetRandomDigits_output randomOutput; + BuyRandomTickets_input buyRandomTicketsInput; + BuyRandomTickets_output buyRandomTicketsOutput; + }; + + struct WithdrawAutoParticipation_input + { + sint64 amount; + }; + struct WithdrawAutoParticipation_output + { + uint8 returnCode; + }; + struct WithdrawAutoParticipation_locals + { + sint64 transferResult; + AutoParticipant entry; + sint64 removedIndex; + sint64 withdrawAmount; + }; + + struct SetAutoConfig_input + { + sint16 desiredTickets; + }; + struct SetAutoConfig_output + { + uint8 returnCode; + }; + struct SetAutoConfig_locals + { + sint64 insertIndex; + sint64 removedIndex; + AutoParticipant entry; + FindAutoParticipant_input findInput; + FindAutoParticipant_output findOutput; + }; + + struct SetAutoLimits_input + { + uint16 maxTicketsPerUser; + }; + struct SetAutoLimits_output + { + uint8 returnCode; + }; + struct SetAutoLimits_locals + { + AutoParticipant autoParticipant; + sint64 index; + }; + + struct GetTicketPrice_input + { + }; + struct GetTicketPrice_output + { + uint64 ticketPrice; + }; + + struct GetSchedule_input + { + }; + struct GetSchedule_output + { + uint8 schedule; + }; + + struct GetDrawHour_input + { + }; + struct GetDrawHour_output + { + uint8 drawHour; + }; + + struct GetFees_input + { + }; + struct GetFees_output + { + uint8 devPercent; + uint8 burnPercent; + uint8 shareholdersPercent; + uint8 qheartPercent; + uint8 returnCode; + }; + + struct GetQHeartHoldLimit_input + { + }; + struct GetQHeartHoldLimit_output + { + uint64 qheartHoldLimit; + }; + + struct GetQHeartWallet_input + { + }; + struct GetQHeartWallet_output + { + id wallet; + }; + + struct GetWinningDigits_input + { + }; + struct GetWinningDigits_output + { + Array digits; + }; + + struct GetBalance_input + { + }; + struct GetBalance_output + { + uint64 balance; + }; + + // Winner history entry returned by GetWinners. + struct WinnerInfo + { + id winnerAddress; + uint64 revenue; + uint16 epoch; + }; + + struct FillWinnersInfo_input + { + id winnerAddress; + uint64 revenue; + }; + struct FillWinnersInfo_output + { + }; + struct FillWinnersInfo_locals + { + WinnerInfo winnerInfo; + uint64 insertIdx; + }; + + struct GetWinners_input + { + }; + struct GetWinners_output + { + Array winners; + uint64 winnersCounter; + uint8 returnCode; + }; + + struct SetPrice_input + { + uint64 newPrice; + }; + struct SetPrice_output + { + uint8 returnCode; + }; + + struct SetSchedule_input + { + uint8 newSchedule; + }; + struct SetSchedule_output + { + uint8 returnCode; + }; + + struct SetDrawHour_input + { + uint8 newDrawHour; + }; + struct SetDrawHour_output + { + uint8 returnCode; + }; + + struct SetFees_input + { + uint8 devPercent; + uint8 burnPercent; + uint8 shareholdersPercent; + uint8 qheartPercent; + }; + struct SetFees_output + { + uint8 returnCode; + }; + + struct SetQHeartHoldLimit_input + { + uint64 newQHeartHoldLimit; + }; + struct SetQHeartHoldLimit_output + { + uint8 returnCode; + }; + + struct SetQHeartWallet_input + { + id newWallet; + }; + struct SetQHeartWallet_output + { + uint8 returnCode; + }; + + struct ComputePrize_locals + { + uint8 leftAlignedMatches; + uint8 anyPositionMatches; + uint8 j; + uint8 digitValue; + uint64 leftAlignedReward; + uint64 anyPositionReward; + uint64 prize; + Array ticketCounts; + Array winningCounts; + }; + + struct SettleRound_input + { + }; + struct SettleRound_output + { + }; + struct SettleRound_locals + { + sint64 i; + sint64 roundRevenue; + sint64 devAmount; + sint64 burnAmount; + sint64 shareholdersAmount; + sint64 qheartAmount; + sint64 balanceSigned; + uint64 balance; + uint64 availableBalance; + uint64 prize; + uint64 totalPrize; + uint64 reservedBalance; + m256i mixedSpectrumValue; + uint64 randomSeed; + Asset shareholdersAsset; + AssetPossessionIterator shareholdersIter; + sint64 shareholdersTotalShares; + sint64 shareholdersDividendPerShare; + sint64 shareholdersHolderShares; + GetRandomDigits_input randomInput; + GetRandomDigits_output randomOutput; + Ticket ticket; + ComputePrize_locals computePrizeLocals; + FillWinnersInfo_input fillWinnersInfoInput; + FillWinnersInfo_output fillWinnersInfoOutput; + }; + + struct ProcessAutoTickets_input + { + }; + struct ProcessAutoTickets_output + { + }; + struct ProcessAutoTickets_locals + { + sint64 currentIndex; + sint64 slotsLeft; + sint64 affordable; + sint64 toBuy; + AutoParticipant entry; + AllocateRandomTickets_input allocateInput; + AllocateRandomTickets_output allocateOutput; + }; + + struct BEGIN_TICK_locals + { + uint32 currentDateStamp; + uint8 currentDayOfWeek; + uint8 currentHour; + uint8 isWednesday; + uint8 isScheduledToday; + SettleRound_input settleInput; + SettleRound_output settleOutput; + ProcessAutoTickets_input autoTicketsInput; + ProcessAutoTickets_output autoTicketsOutput; + }; + + struct BEGIN_EPOCH_locals + { + ProcessAutoTickets_input autoTicketsInput; + ProcessAutoTickets_output autoTicketsOutput; + }; + +public: + REGISTER_USER_FUNCTIONS_AND_PROCEDURES() + { + REGISTER_USER_FUNCTION(GetTicketPrice, 1); + REGISTER_USER_FUNCTION(GetSchedule, 2); + REGISTER_USER_FUNCTION(GetDrawHour, 3); + REGISTER_USER_FUNCTION(GetFees, 4); + REGISTER_USER_FUNCTION(GetQHeartHoldLimit, 5); + REGISTER_USER_FUNCTION(GetQHeartWallet, 6); + REGISTER_USER_FUNCTION(GetWinningDigits, 7); + REGISTER_USER_FUNCTION(GetBalance, 8); + REGISTER_USER_FUNCTION(GetWinners, 9); + REGISTER_USER_FUNCTION(GetAutoParticipation, 10); + REGISTER_USER_FUNCTION(GetAutoStats, 11); + + REGISTER_USER_PROCEDURE(BuyTicket, 1); + REGISTER_USER_PROCEDURE(SetPrice, 2); + REGISTER_USER_PROCEDURE(SetSchedule, 3); + REGISTER_USER_PROCEDURE(SetDrawHour, 4); + REGISTER_USER_PROCEDURE(SetFees, 5); + REGISTER_USER_PROCEDURE(SetQHeartHoldLimit, 6); + REGISTER_USER_PROCEDURE(BuyRandomTickets, 7); + REGISTER_USER_PROCEDURE(DepositAutoParticipation, 8); + REGISTER_USER_PROCEDURE(WithdrawAutoParticipation, 9); + REGISTER_USER_PROCEDURE(SetAutoConfig, 10); + REGISTER_USER_PROCEDURE(SetAutoLimits, 11); + } + + INITIALIZE() + { + state.teamAddress = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, + _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); + + state.ticketPrice = PULSE_TICKET_PRICE_DEFAULT; + state.devPercent = PULSE_DEFAULT_DEV_PERCENT; + state.burnPercent = PULSE_DEFAULT_BURN_PERCENT; + state.shareholdersPercent = PULSE_DEFAULT_SHAREHOLDERS_PERCENT; + state.qheartPercent = PULSE_DEFAULT_QHEART_PERCENT; + state.qheartHoldLimit = PULSE_DEFAULT_QHEART_HOLD_LIMIT; + + state.schedule = PULSE_DEFAULT_SCHEDULE; + state.drawHour = PULSE_DEFAULT_DRAW_HOUR; + state.lastDrawDateStamp = PULSE_DEFAULT_INIT_TIME; + + state.maxAutoTicketsPerUser = PULSE_DEFAULT_MAX_AUTO_TICKETS_PER_USER; + + enableBuyTicket(state, false); + } + + BEGIN_EPOCH_WITH_LOCALS() + { + if (state.schedule == 0) + { + state.schedule = PULSE_DEFAULT_SCHEDULE; + } + if (state.drawHour == 0) + { + state.drawHour = PULSE_DEFAULT_DRAW_HOUR; + } + + makeDateStamp(qpi.year(), qpi.month(), qpi.day(), state.lastDrawDateStamp); + enableBuyTicket(state, state.lastDrawDateStamp != PULSE_DEFAULT_INIT_TIME); + if (state.lastDrawDateStamp != PULSE_DEFAULT_INIT_TIME) + { + CALL(ProcessAutoTickets, locals.autoTicketsInput, locals.autoTicketsOutput); + } + } + + END_EPOCH() + { + enableBuyTicket(state, false); + clearStateOnEndEpoch(state); + state.nextEpochData.apply(state); + state.nextEpochData.clear(); + } + + BEGIN_TICK_WITH_LOCALS() + { + // Throttle draw checks to reduce per-tick cost. + if (mod(qpi.tick(), static_cast(PULSE_TICK_UPDATE_PERIOD)) != 0) + { + return; + } + + locals.currentHour = qpi.hour(); + locals.currentDayOfWeek = qpi.dayOfWeek(qpi.year(), qpi.month(), qpi.day()); + locals.isWednesday = locals.currentDayOfWeek == WEDNESDAY; + + if (locals.currentHour < state.drawHour) + { + return; + } + + makeDateStamp(qpi.year(), qpi.month(), qpi.day(), locals.currentDateStamp); + + if (locals.currentDateStamp == PULSE_DEFAULT_INIT_TIME) + { + enableBuyTicket(state, false); + state.lastDrawDateStamp = PULSE_DEFAULT_INIT_TIME; + return; + } + + if (state.lastDrawDateStamp == PULSE_DEFAULT_INIT_TIME) + { + enableBuyTicket(state, true); + CALL(ProcessAutoTickets, locals.autoTicketsInput, locals.autoTicketsOutput); + if (locals.isWednesday) + { + state.lastDrawDateStamp = locals.currentDateStamp; + } + else + { + state.lastDrawDateStamp = 0; + } + } + + if (state.lastDrawDateStamp == locals.currentDateStamp) + { + return; + } + + locals.isScheduledToday = ((state.schedule & (1u << locals.currentDayOfWeek)) != 0); + if (!locals.isWednesday && !locals.isScheduledToday) + { + return; + } + + state.lastDrawDateStamp = locals.currentDateStamp; + enableBuyTicket(state, false); + + CALL(SettleRound, locals.settleInput, locals.settleOutput); + + clearStateOnEndDraw(state); + enableBuyTicket(state, !locals.isWednesday); + if (!locals.isWednesday) + { + CALL(ProcessAutoTickets, locals.autoTicketsInput, locals.autoTicketsOutput); + } + } + + // Returns current ticket price in QHeart units. + PUBLIC_FUNCTION(GetTicketPrice) { output.ticketPrice = state.ticketPrice; } + // Returns current draw schedule bitmask. + PUBLIC_FUNCTION(GetSchedule) { output.schedule = state.schedule; } + // Returns draw hour in UTC. + PUBLIC_FUNCTION(GetDrawHour) { output.drawHour = state.drawHour; } + // Returns QHeart balance cap retained by the contract. + PUBLIC_FUNCTION(GetQHeartHoldLimit) { output.qheartHoldLimit = state.qheartHoldLimit; } + // Returns the designated QHeart issuer wallet. + PUBLIC_FUNCTION(GetQHeartWallet) { output.wallet = PULSE_QHEART_ISSUER; } + // Returns digits from the last settled draw. + PUBLIC_FUNCTION(GetWinningDigits) { output.digits = state.lastWinningDigits; } + + // Returns current fee split configuration. + PUBLIC_FUNCTION(GetFees) + { + output.devPercent = state.devPercent; + output.burnPercent = state.burnPercent; + output.shareholdersPercent = state.shareholdersPercent; + output.qheartPercent = state.qheartPercent; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + // Returns contract QHeart balance held in the Pulse wallet. + PUBLIC_FUNCTION(GetBalance) + { + output.balance = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, SELF_INDEX, SELF_INDEX); + } + + // Returns the winners ring buffer and total winners counter. + PUBLIC_FUNCTION(GetWinners) + { + output.winners = state.winners; + getWinnerCounter(state, output.winnersCounter); + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + /// Returns auto-participation settings for the invocator. + /// @return Current deposit, config fields, and status code. + PUBLIC_FUNCTION_WITH_LOCALS(GetAutoParticipation) + { + if (!state.autoParticipants.get(qpi.invocator(), locals.entry)) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + output.deposit = locals.entry.deposit; + output.desiredTickets = locals.entry.desiredTickets; + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + /// Returns global auto-participation limits and counters. + /// @return Current counters, limits, and status code. + PUBLIC_FUNCTION(GetAutoStats) + { + output.autoParticipantsCounter = static_cast(state.autoParticipants.population()); + output.maxAutoTicketsPerUser = state.maxAutoTicketsPerUser; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + // Schedules a new ticket price for the next epoch (owner-only). + PUBLIC_PROCEDURE(SetPrice) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (qpi.invocator() != PULSE_QHEART_ISSUER) + { + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); + return; + } + + if (input.newPrice == 0) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); + return; + } + + state.nextEpochData.hasNewPrice = true; + state.nextEpochData.newPrice = input.newPrice; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + // Schedules a new draw schedule bitmask for the next epoch (owner-only). + PUBLIC_PROCEDURE(SetSchedule) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (qpi.invocator() != PULSE_QHEART_ISSUER) + { + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); + return; + } + + if (input.newSchedule == 0) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + state.nextEpochData.hasNewSchedule = true; + state.nextEpochData.newSchedule = input.newSchedule; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + // Schedules a new draw hour in UTC for the next epoch (owner-only). + PUBLIC_PROCEDURE(SetDrawHour) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (qpi.invocator() != PULSE_QHEART_ISSUER) + { + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); + return; + } + + if (input.newDrawHour > 23) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + state.nextEpochData.hasNewDrawHour = true; + state.nextEpochData.newDrawHour = input.newDrawHour; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + // Schedules new fee splits for the next epoch (owner-only). + PUBLIC_PROCEDURE(SetFees) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (qpi.invocator() != PULSE_QHEART_ISSUER) + { + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); + return; + } + + if (input.devPercent + input.burnPercent + input.shareholdersPercent + input.qheartPercent > 100) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + state.nextEpochData.hasNewFee = true; + state.nextEpochData.newDevPercent = input.devPercent; + state.nextEpochData.newBurnPercent = input.burnPercent; + state.nextEpochData.newShareholdersPercent = input.shareholdersPercent; + state.nextEpochData.newQHeartPercent = input.qheartPercent; + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + // Schedules a new QHeart hold limit for the next epoch (owner-only). + PUBLIC_PROCEDURE(SetQHeartHoldLimit) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (qpi.invocator() != PULSE_QHEART_ISSUER) + { + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); + return; + } + + state.nextEpochData.hasNewQHeartHoldLimit = true; + state.nextEpochData.newQHeartHoldLimit = input.newQHeartHoldLimit; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + /** Deposits QHeart into the contract for automatic ticket purchases. + * @param amount QHeart amount to reserve for auto participation. + * @param desiredTickets Number of tickets to buy per draw. + * @param buyNow When true, tries to buy immediately if selling is open. + * @return Status code describing the result. + */ + PUBLIC_PROCEDURE_WITH_LOCALS(DepositAutoParticipation) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (state.autoParticipants.population() >= state.autoParticipants.capacity()) + { + output.returnCode = toReturnCode(EReturnCode::AUTO_PARTICIPANTS_FULL); + return; + } + + if (input.amount <= 0 || input.desiredTickets <= 0) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + if (state.maxAutoTicketsPerUser > 0) + { + input.desiredTickets = min(input.desiredTickets, static_cast(state.maxAutoTicketsPerUser)); + } + + locals.userBalance = + qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, qpi.invocator(), qpi.invocator(), SELF_INDEX, SELF_INDEX); + input.amount = min(locals.userBalance, input.amount); + + locals.totalPrice = smul(state.ticketPrice, static_cast(input.desiredTickets)); + if (input.amount < locals.totalPrice) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); + return; + } + + if (input.buyNow && isSellingOpen(state)) + { + locals.buyRandomTicketsInput.count = input.desiredTickets; + CALL(BuyRandomTickets, locals.buyRandomTicketsInput, locals.buyRandomTicketsOutput); + if (locals.buyRandomTicketsOutput.returnCode != toReturnCode(EReturnCode::SUCCESS)) + { + output.returnCode = locals.buyRandomTicketsOutput.returnCode; + return; + } + + input.buyNow = false; + input.amount = input.amount - locals.totalPrice; + + // The entire deposit was spent + if (input.amount <= 0) + { + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + return; + } + } + + state.autoParticipants.get(qpi.invocator(), locals.entry); + locals.entry.player = qpi.invocator(); + + locals.transferResult = qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, qpi.invocator(), + qpi.invocator(), input.amount, SELF); + if (locals.transferResult < 0) + { + output.returnCode = toReturnCode(EReturnCode::TRANSFER_TO_PULSE_FAILED); + return; + } + + locals.entry.deposit = sadd(locals.entry.deposit, input.amount); + if (input.desiredTickets > 0) + { + locals.entry.desiredTickets = input.desiredTickets; + } + + state.autoParticipants.set(qpi.invocator(), locals.entry); + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + /// Withdraws QHeart from the invocator's auto-participation deposit. + /// @param amount QHeart amount to withdraw; 0 withdraws the full deposit. + /// @return Status code describing the result. + PUBLIC_PROCEDURE_WITH_LOCALS(WithdrawAutoParticipation) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (!state.autoParticipants.contains(qpi.invocator())) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + if (!state.autoParticipants.get(qpi.invocator(), locals.entry)) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + locals.withdrawAmount = (input.amount <= 0) ? locals.entry.deposit : min(input.amount, locals.entry.deposit); + + if (locals.withdrawAmount == 0) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + locals.transferResult = + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, locals.withdrawAmount, qpi.invocator()); + if (locals.transferResult < 0) + { + output.returnCode = toReturnCode(EReturnCode::TRANSFER_FROM_PULSE_FAILED); + return; + } + + locals.entry.deposit -= locals.withdrawAmount; + + if (locals.entry.deposit <= 0) + { + state.autoParticipants.removeByKey(qpi.invocator()); + state.autoParticipants.cleanupIfNeeded(PULSE_CLEANUP_THRESHOLD); + } + else + { + state.autoParticipants.set(qpi.invocator(), locals.entry); + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + /// Sets auto-participation config for the invocator. + /// @param desiredTickets Signed: -1 ignore, >0 set new value. + /// @return Status code describing the result. + PUBLIC_PROCEDURE_WITH_LOCALS(SetAutoConfig) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (!state.autoParticipants.contains(qpi.invocator())) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + input.desiredTickets = max(input.desiredTickets, -1); + if (input.desiredTickets == 0) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + if (input.desiredTickets > 0) + { + if (state.maxAutoTicketsPerUser > 0) + { + input.desiredTickets = min(input.desiredTickets, static_cast(state.maxAutoTicketsPerUser)); + } + + state.autoParticipants.get(qpi.invocator(), locals.entry); + + // Update desired tickets if specified + locals.entry.desiredTickets = static_cast(input.desiredTickets); + state.autoParticipants.set(qpi.invocator(), locals.entry); + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + /// Sets auto-participation limits (owner-only). + /// @param maxTicketsPerUser Max tickets per user; 0 disables the limit. + /// @param maxDepositPerUser Max deposit per user; 0 disables the limit. + /// @return Status code describing the result. + PUBLIC_PROCEDURE_WITH_LOCALS(SetAutoLimits) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (qpi.invocator() != PULSE_QHEART_ISSUER) + { + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); + return; + } + + if (state.maxAutoTicketsPerUser == input.maxTicketsPerUser) + { + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + return; + } + + input.maxTicketsPerUser = min(input.maxTicketsPerUser, PULSE_MAX_NUMBER_OF_PLAYERS); + + state.maxAutoTicketsPerUser = input.maxTicketsPerUser; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + + // Update existing entries to comply with the new limit. + if (state.maxAutoTicketsPerUser > 0) + { + locals.index = state.autoParticipants.nextElementIndex(NULL_INDEX); + while (locals.index != NULL_INDEX) + { + locals.autoParticipant = state.autoParticipants.value(locals.index); + locals.autoParticipant.desiredTickets = min(locals.autoParticipant.desiredTickets, state.maxAutoTicketsPerUser); + state.autoParticipants.replace(state.autoParticipants.key(locals.index), locals.autoParticipant); + + locals.index = state.autoParticipants.nextElementIndex(locals.index); + } + } + } + + // Buys a single ticket; transfers ticket price from invocator. + PUBLIC_PROCEDURE_WITH_LOCALS(BuyTicket) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (!isSellingOpen(state)) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_SELLING_CLOSED); + return; + } + + locals.validateInput.digits = input.digits; + CALL(ValidateDigits, locals.validateInput, locals.validateOutput); + if (!locals.validateOutput.isValid) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_NUMBERS); + return; + } + + locals.slotsLeft = getSlotsLeft(state); + if (locals.slotsLeft == 0) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_ALL_SOLD_OUT); + return; + } + + locals.userBalance = + qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, qpi.invocator(), qpi.invocator(), SELF_INDEX, SELF_INDEX); + if (locals.userBalance < state.ticketPrice) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); + return; + } + + locals.transferResult = qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, qpi.invocator(), + qpi.invocator(), state.ticketPrice, SELF); + if (locals.transferResult < 0) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); + return; + } + + locals.ticket.player = qpi.invocator(); + locals.ticket.digits = input.digits; + state.tickets.set(state.ticketCounter, locals.ticket); + state.ticketCounter = min(static_cast(state.ticketCounter) + 1ull, state.tickets.capacity()); + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + // Buys multiple random tickets; transfers total price from invocator. + PUBLIC_PROCEDURE_WITH_LOCALS(BuyRandomTickets) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + locals.prepareInput.count = input.count; + CALL(PrepareRandomTickets, locals.prepareInput, locals.prepareOutput); + if (locals.prepareOutput.returnCode != toReturnCode(EReturnCode::SUCCESS)) + { + output.returnCode = locals.prepareOutput.returnCode; + return; + } + + locals.chargeInput.player = qpi.invocator(); + locals.chargeInput.count = locals.prepareOutput.count; + CALL(ChargeTicketsFromPlayer, locals.chargeInput, locals.chargeOutput); + if (locals.chargeOutput.returnCode != toReturnCode(EReturnCode::SUCCESS)) + { + output.returnCode = locals.chargeOutput.returnCode; + return; + } + + locals.allocateInput.player = qpi.invocator(); + locals.allocateInput.count = locals.prepareOutput.count; + CALL(AllocateRandomTickets, locals.allocateInput, locals.allocateOutput); + + output.returnCode = locals.allocateOutput.returnCode; + } + +private: + PRIVATE_PROCEDURE_WITH_LOCALS(ProcessAutoTickets) + { + if (!isSellingOpen(state) || state.autoParticipants.population() == 0) + { + return; + } + + locals.currentIndex = state.autoParticipants.nextElementIndex(NULL_INDEX); + while (locals.currentIndex != NULL_INDEX) + { + locals.slotsLeft = getSlotsLeft(state); + if (locals.slotsLeft == 0) + { + break; + } + + locals.entry = state.autoParticipants.value(locals.currentIndex); + + locals.affordable = div(locals.entry.deposit, state.ticketPrice); + if (locals.affordable == 0) + { + state.autoParticipants.removeByIndex(locals.currentIndex); + locals.currentIndex = state.autoParticipants.nextElementIndex(locals.currentIndex); + continue; + } + + locals.toBuy = locals.affordable; + if (state.maxAutoTicketsPerUser > 0) + { + locals.toBuy = min(locals.toBuy, static_cast(state.maxAutoTicketsPerUser)); + } + if (locals.entry.desiredTickets > 0) + { + locals.toBuy = min(locals.toBuy, static_cast(locals.entry.desiredTickets)); + } + + locals.toBuy = min(locals.toBuy, locals.slotsLeft); + if (locals.toBuy <= 0) + { + locals.currentIndex = state.autoParticipants.nextElementIndex(locals.currentIndex); + continue; + } + + locals.allocateInput.player = locals.entry.player; + locals.allocateInput.count = static_cast(locals.toBuy); + CALL(AllocateRandomTickets, locals.allocateInput, locals.allocateOutput); + if (locals.allocateOutput.returnCode != toReturnCode(EReturnCode::SUCCESS)) + { + locals.currentIndex = state.autoParticipants.nextElementIndex(locals.currentIndex); + continue; + } + + locals.entry.deposit -= smul(locals.toBuy, state.ticketPrice); + if (locals.entry.deposit <= 0) + { + state.autoParticipants.removeByIndex(locals.currentIndex); + } + else + { + state.autoParticipants.set(locals.entry.player, locals.entry); + } + + locals.currentIndex = state.autoParticipants.nextElementIndex(locals.currentIndex); + } + + state.autoParticipants.cleanupIfNeeded(PULSE_CLEANUP_THRESHOLD); + } + + PRIVATE_FUNCTION_WITH_LOCALS(ValidateDigits) + { + output.isValid = true; + for (locals.idx = 0; locals.idx < PULSE_PLAYER_DIGITS; ++locals.idx) + { + locals.value = input.digits.get(locals.idx); + if (locals.value > PULSE_MAX_DIGIT) + { + output.isValid = false; + return; + } + } + } + + PRIVATE_FUNCTION_WITH_LOCALS(GetRandomDigits) + { + // Derive each digit independently to avoid shared PRNG state. + for (locals.index = 0; locals.index < PULSE_WINNING_DIGITS; ++locals.index) + { + deriveOne(input.seed, locals.index, locals.tempValue); + locals.candidate = static_cast(mod(locals.tempValue, PULSE_MAX_DIGIT + 1ULL)); + + output.digits.set(locals.index, locals.candidate); + } + } + + PRIVATE_PROCEDURE_WITH_LOCALS(SettleRound) + { + if (state.ticketCounter == 0) + { + return; + } + + locals.roundRevenue = smul(state.ticketPrice, state.ticketCounter); + locals.devAmount = div(smul(locals.roundRevenue, static_cast(state.devPercent)), 100LL); + locals.burnAmount = div(smul(locals.roundRevenue, static_cast(state.burnPercent)), 100LL); + locals.shareholdersAmount = div(smul(locals.roundRevenue, static_cast(state.shareholdersPercent)), 100LL); + locals.qheartAmount = div(smul(locals.roundRevenue, static_cast(state.qheartPercent)), 100LL); + + if (locals.devAmount > 0) + { + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, locals.devAmount, state.teamAddress); + } + if (locals.shareholdersAmount > 0) + { + locals.shareholdersAsset.issuer = id::zero(); + locals.shareholdersAsset.assetName = PULSE_CONTRACT_ASSET_NAME; + locals.shareholdersTotalShares = NUMBER_OF_COMPUTORS; + + locals.shareholdersDividendPerShare = div(locals.shareholdersAmount, locals.shareholdersTotalShares); + if (locals.shareholdersDividendPerShare > 0) + { + locals.shareholdersIter.begin(locals.shareholdersAsset); + while (!locals.shareholdersIter.reachedEnd()) + { + locals.shareholdersHolderShares = locals.shareholdersIter.numberOfPossessedShares(); + if (locals.shareholdersHolderShares > 0) + { + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, + smul(locals.shareholdersHolderShares, locals.shareholdersDividendPerShare), + locals.shareholdersIter.possessor()); + } + locals.shareholdersIter.next(); + } + } + } + if (locals.burnAmount > 0) + { + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, locals.burnAmount, NULL_ID); + } + if (locals.qheartAmount > 0) + { + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, locals.qheartAmount, + PULSE_QHEART_ISSUER); + } + + locals.mixedSpectrumValue = qpi.getPrevSpectrumDigest(); + locals.randomSeed = qpi.K12(locals.mixedSpectrumValue).u64._0; + locals.randomInput.seed = locals.randomSeed; + CALL(GetRandomDigits, locals.randomInput, locals.randomOutput); + state.lastWinningDigits = locals.randomOutput.digits; + + locals.balanceSigned = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, SELF_INDEX, SELF_INDEX); + locals.balance = max(locals.balanceSigned, 0LL); + + locals.totalPrize = 0; + for (locals.i = 0; locals.i < state.ticketCounter; ++locals.i) + { + locals.ticket = state.tickets.get(locals.i); + locals.prize = computePrize(state, locals.ticket, state.lastWinningDigits, locals.computePrizeLocals); + locals.totalPrize += locals.prize; + } + + locals.availableBalance = locals.balance; + for (locals.i = 0; locals.i < state.ticketCounter; ++locals.i) + { + locals.ticket = state.tickets.get(locals.i); + locals.prize = computePrize(state, locals.ticket, state.lastWinningDigits, locals.computePrizeLocals); + + if (locals.totalPrize > 0 && locals.availableBalance < locals.totalPrize) + { + // Pro-rate payouts when the contract balance cannot cover all prizes. + locals.prize = div(smul(static_cast(locals.prize), static_cast(locals.availableBalance)), + static_cast(locals.totalPrize)); + } + + if (locals.prize > 0 && locals.balance >= locals.prize) + { + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, static_cast(locals.prize), + locals.ticket.player); + locals.balance -= locals.prize; + + locals.fillWinnersInfoInput.winnerAddress = locals.ticket.player; + locals.fillWinnersInfoInput.revenue = locals.prize; + CALL(FillWinnersInfo, locals.fillWinnersInfoInput, locals.fillWinnersInfoOutput); + } + } + + locals.balanceSigned = qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, SELF_INDEX, SELF_INDEX); + locals.balance = (locals.balanceSigned > 0) ? static_cast(locals.balanceSigned) : 0; + + if (state.qheartHoldLimit > 0 && locals.balance > state.qheartHoldLimit) + { + qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, SELF, SELF, + static_cast(locals.balance - state.qheartHoldLimit), PULSE_QHEART_ISSUER); + } + } + + PRIVATE_PROCEDURE_WITH_LOCALS(FillWinnersInfo) + { + if (input.winnerAddress == id::zero()) + { + return; + } + + getWinnerCounter(state, locals.insertIdx); + ++state.winnersCounter; + + locals.winnerInfo.winnerAddress = input.winnerAddress; + locals.winnerInfo.revenue = input.revenue; + locals.winnerInfo.epoch = qpi.epoch(); + + state.winners.set(locals.insertIdx, locals.winnerInfo); + } + + PRIVATE_PROCEDURE_WITH_LOCALS(PrepareRandomTickets) + { + if (input.count == 0) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + output.count = 0; + return; + } + + if (!isSellingOpen(state)) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_SELLING_CLOSED); + output.count = 0; + return; + } + + locals.slotsLeft = getSlotsLeft(state); + output.count = min(input.count, static_cast(locals.slotsLeft)); + if (output.count == 0) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_ALL_SOLD_OUT); + return; + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + PRIVATE_PROCEDURE_WITH_LOCALS(ChargeTicketsFromPlayer) + { + if (input.count == 0 || input.player == id::zero()) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + locals.totalPrice = smul(static_cast(input.count), state.ticketPrice); + locals.userBalance = + qpi.numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, input.player, input.player, SELF_INDEX, SELF_INDEX); + if (locals.userBalance < static_cast(locals.totalPrice)) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_INVALID_PRICE); + return; + } + + locals.transferResult = qpi.transferShareOwnershipAndPossession(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, input.player, input.player, + static_cast(locals.totalPrice), SELF); + if (locals.transferResult < 0) + { + output.returnCode = toReturnCode(EReturnCode::TRANSFER_TO_PULSE_FAILED); + return; + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + PRIVATE_PROCEDURE_WITH_LOCALS(AllocateRandomTickets) + { + if (input.count == 0 || input.player == id::zero()) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + if (!isSellingOpen(state)) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_SELLING_CLOSED); + return; + } + + locals.slotsLeft = getSlotsLeft(state); + if (locals.slotsLeft < input.count) + { + output.returnCode = toReturnCode(EReturnCode::TICKET_ALL_SOLD_OUT); + return; + } + + locals.randomData.prevSpectrumDigest = qpi.getPrevSpectrumDigest(); + locals.randomData.allocateInput = input; + locals.randomData.ticketCounter = state.ticketCounter; + + locals.randomSeed = qpi.K12(locals.randomData).u64._0; + for (locals.i = 0; locals.i < input.count; ++locals.i) + { + deriveOne(locals.randomSeed, locals.i, locals.tempSeed); + locals.randomInput.seed = locals.tempSeed; + CALL(GetRandomDigits, locals.randomInput, locals.randomOutput); + + locals.ticket.player = input.player; + locals.ticket.digits = locals.randomOutput.digits; + state.tickets.set(state.ticketCounter, locals.ticket); + state.ticketCounter = min(state.ticketCounter + 1LL, static_cast(state.tickets.capacity())); + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + +public: + // Encodes YYYY/MM/DD into a compact sortable date stamp. + static void makeDateStamp(uint8 year, uint8 month, uint8 day, uint32& res) { res = static_cast(year << 9 | month << 5 | day); } + + template static constexpr T min(const T& a, const T& b) { return (a < b) ? a : b; } + template static constexpr T max(const T& a, const T& b) { return a > b ? a : b; } + + // Per-index mix to deterministically expand a single seed. + static void deriveOne(const uint64& r, const uint64& idx, uint64& outValue) { mix64(r + 0x9e3779b97f4a7c15ULL * (idx + 1), outValue); } + + static void mix64(const uint64& x, uint64& outValue) + { + outValue = x; + outValue ^= outValue >> 30; + outValue *= 0xbf58476d1ce4e5b9ULL; + outValue ^= outValue >> 27; + outValue *= 0x94d049bb133111ebULL; + outValue ^= outValue >> 31; + } + + static sint64 getSlotsLeft(const PULSE& state) + { + return state.ticketCounter < static_cast(state.tickets.capacity()) + ? static_cast(state.tickets.capacity()) - state.ticketCounter + : 0LL; + } + +protected: + // Ring buffer of recent winners; index is winnersCounter % capacity. + Array winners; + // Tickets for the current round; valid range is [0, ticketCounter). + Array tickets; + // Auto-buy participants keyed by user id. + HashMap autoParticipants; + // Last settled winning digits; undefined before the first draw. + Array lastWinningDigits; + sint64 ticketCounter; + sint64 ticketPrice; + // Per-user auto-purchase limits; 0 means unlimited. + uint16 maxAutoTicketsPerUser; + // Contract balance above this cap is swept to the QHeart wallet after settlement. + uint64 qheartHoldLimit; + // Date stamp of the most recent draw; PULSE_DEFAULT_INIT_TIME is a bootstrap sentinel. + uint32 lastDrawDateStamp; + uint8 devPercent; + uint8 burnPercent; + uint8 shareholdersPercent; + uint8 qheartPercent; + uint8 schedule; + uint8 drawHour; + EState currentState; + id teamAddress; + NextEpochData nextEpochData; + // Monotonic winner count used to rotate the winners ring buffer. + uint64 winnersCounter; + +protected: + static void clearStateOnEndEpoch(PULSE& state) + { + clearStateOnEndDraw(state); + state.lastDrawDateStamp = 0; + } + + static void clearStateOnEndDraw(PULSE& state) + { + state.ticketCounter = 0; + setMemory(state.tickets, 0); + } + + static void enableBuyTicket(PULSE& state, bool bEnable) + { + state.currentState = bEnable ? state.currentState | EState::SELLING : state.currentState & ~EState::SELLING; + } + + static bool isSellingOpen(const PULSE& state) { return (state.currentState & EState::SELLING) != 0; } + + static void getWinnerCounter(const PULSE& state, uint64& outCounter) { outCounter = mod(state.winnersCounter, state.winners.capacity()); } + + static uint64 getLeftAlignedReward(const PULSE& state, uint8 matches) + { + switch (matches) + { + case 6: return 2000 * state.ticketPrice; + case 5: return 300 * state.ticketPrice; + case 4: return 60 * state.ticketPrice; + case 3: return 20 * state.ticketPrice; + case 2: return 4 * state.ticketPrice; + case 1: return 1 * state.ticketPrice; + default: return 0; + } + } + + static uint64 getAnyPositionReward(const PULSE& state, uint8 matches) + { + switch (matches) + { + case 6: return 150 * state.ticketPrice; + case 5: return 30 * state.ticketPrice; + case 4: return 8 * state.ticketPrice; + case 3: return 2 * state.ticketPrice; + case 2: + case 1: + default: return 0; + } + } + + static uint64 computePrize(const PULSE& state, const Ticket& ticket, const Array& winningDigits, + ComputePrize_locals& locals) + { + setMemory(locals, 0); + + for (locals.j = 0; locals.j < PULSE_PLAYER_DIGITS; ++locals.j) + { + if (ticket.digits.get(locals.j) != winningDigits.get(locals.j)) + { + break; + } + ++locals.leftAlignedMatches; + } + + STATIC_ASSERT(PULSE_PLAYER_DIGITS == PULSE_WINNING_DIGITS, "PULSE_PLAYER_DIGITS == PULSE_WINNING_DIGITS"); + for (locals.j = 0; locals.j < PULSE_PLAYER_DIGITS; ++locals.j) + { + locals.digitValue = ticket.digits.get(locals.j); + locals.ticketCounts.set(locals.digitValue, locals.ticketCounts.get(locals.digitValue) + 1); + + locals.digitValue = winningDigits.get(locals.j); + locals.winningCounts.set(locals.digitValue, locals.winningCounts.get(locals.digitValue) + 1); + } + + for (locals.digitValue = 0; locals.digitValue <= PULSE_MAX_DIGIT; ++locals.digitValue) + { + locals.anyPositionMatches += min(locals.ticketCounts.get(locals.digitValue), locals.winningCounts.get(locals.digitValue)); + } + + // Reward the best of left-aligned or any-position matches to avoid double counting. + locals.leftAlignedReward = getLeftAlignedReward(state, locals.leftAlignedMatches); + locals.anyPositionReward = getAnyPositionReward(state, locals.anyPositionMatches); + locals.prize = max(locals.leftAlignedReward, locals.anyPositionReward); + return locals.prize; + } +}; diff --git a/test/contract_pulse.cpp b/test/contract_pulse.cpp new file mode 100644 index 000000000..c23e66c08 --- /dev/null +++ b/test/contract_pulse.cpp @@ -0,0 +1,2080 @@ +#define NO_UEFI +#define _ALLOW_KEYWORD_MACROS 1 +// Allow tests to call internal helpers without changing production visibility. +#define private protected +#include "contract_testing.h" +#undef private +#undef _ALLOW_KEYWORD_MACROS + +#include + +// Procedure/function indices (must match REGISTER_USER_FUNCTIONS_AND_PROCEDURES in `src/contracts/Pulse.h`). +constexpr uint16 PULSE_PROCEDURE_BUY_TICKET = 1; +constexpr uint16 PULSE_PROCEDURE_SET_PRICE = 2; +constexpr uint16 PULSE_PROCEDURE_SET_SCHEDULE = 3; +constexpr uint16 PULSE_PROCEDURE_SET_DRAW_HOUR = 4; +constexpr uint16 PULSE_PROCEDURE_SET_FEES = 5; +constexpr uint16 PULSE_PROCEDURE_SET_QHEART_HOLD_LIMIT = 6; +constexpr uint16 PULSE_PROCEDURE_BUY_RANDOM_TICKETS = 7; +constexpr uint16 PULSE_PROCEDURE_DEPOSIT_AUTO_PARTICIPATION = 8; +constexpr uint16 PULSE_PROCEDURE_WITHDRAW_AUTO_PARTICIPATION = 9; +constexpr uint16 PULSE_PROCEDURE_SET_AUTO_CONFIG = 10; +constexpr uint16 PULSE_PROCEDURE_SET_AUTO_LIMITS = 11; + +constexpr uint16 PULSE_FUNCTION_GET_TICKET_PRICE = 1; +constexpr uint16 PULSE_FUNCTION_GET_SCHEDULE = 2; +constexpr uint16 PULSE_FUNCTION_GET_DRAW_HOUR = 3; +constexpr uint16 PULSE_FUNCTION_GET_FEES = 4; +constexpr uint16 PULSE_FUNCTION_GET_QHEART_HOLD_LIMIT = 5; +constexpr uint16 PULSE_FUNCTION_GET_QHEART_WALLET = 6; +constexpr uint16 PULSE_FUNCTION_GET_WINNING_DIGITS = 7; +constexpr uint16 PULSE_FUNCTION_GET_BALANCE = 8; +constexpr uint16 PULSE_FUNCTION_GET_WINNERS = 9; +constexpr uint16 PULSE_FUNCTION_GET_AUTO_PARTICIPATION = 10; +constexpr uint16 PULSE_FUNCTION_GET_AUTO_STATS = 11; + +namespace +{ + // QPI contexts must be primed with a call to satisfy internal checks. + void primeQpiFunctionContext(QpiContextUserFunctionCall& qpi) + { + PULSE::GetTicketPrice_input input{}; + qpi.call(PULSE_FUNCTION_GET_TICKET_PRICE, &input, sizeof(input)); + } + + // Use a safe call to seed procedure context for private calls. + void primeQpiProcedureContext(QpiContextUserProcedureCall& qpi) + { + PULSE::SetDrawHour_input input{}; + input.newDrawHour = PULSE_DEFAULT_DRAW_HOUR; + qpi.call(PULSE_PROCEDURE_SET_DRAW_HOUR, &input, sizeof(input)); + ASSERT_EQ(contractError[PULSE_CONTRACT_INDEX], 0); + } + + Array makePlayerDigits(uint8 d0, uint8 d1, uint8 d2, uint8 d3, uint8 d4, uint8 d5) + { + Array digits = {}; + digits.set(0, d0); + digits.set(1, d1); + digits.set(2, d2); + digits.set(3, d3); + digits.set(4, d4); + digits.set(5, d5); + return digits; + } + + void expectWinningDigitsInRange(const Array& digits) + { + for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + const uint8 v = digits.get(i); + EXPECT_LE(v, PULSE_MAX_DIGIT); + } + } +} // namespace + +// Test helper class exposing internal state +class PULSEChecker : public PULSE +{ +public: + uint64 getTicketCounter() const { return ticketCounter; } + uint64 getTicketPriceInternal() const { return ticketPrice; } + uint64 getQHeartHoldLimitInternal() const { return qheartHoldLimit; } + uint32 getLastDrawDateStamp() const { return lastDrawDateStamp; } + uint8 getScheduleInternal() const { return schedule; } + uint8 getDrawHourInternal() const { return drawHour; } + uint8 getDevPercentInternal() const { return devPercent; } + uint8 getBurnPercentInternal() const { return burnPercent; } + uint8 getShareholdersPercentInternal() const { return shareholdersPercent; } + uint8 getQHeartPercentInternal() const { return qheartPercent; } + const id& getTeamAddressInternal() const { return teamAddress; } + const Array& getLastWinningDigits() const { return lastWinningDigits; } + Ticket getTicket(uint64 index) const { return tickets.get(index); } + + void setTicketCounter(uint64 value) { ticketCounter = value; } + void setTicketPriceInternal(uint64 value) { ticketPrice = value; } + void setLastDrawDateStamp(uint32 value) { lastDrawDateStamp = value; } + void setScheduleInternal(uint8 value) { schedule = value; } + void setDrawHourInternal(uint8 value) { drawHour = value; } + + NextEpochData& nextEpochDataRef() { return nextEpochData; } + + void setTicketDirect(uint64 index, const id& player, const Array& digits) + { + Ticket ticket{player, digits}; + + tickets.set(index, ticket); + } + + void forceSelling(bool enable) { enableBuyTicket(*this, enable); } + bool isSelling() const { return isSellingOpen(*this); } + + ValidateDigits_output callValidateDigits(const QPI::QpiContextFunctionCall& qpi, const Array& digits) const + { + ValidateDigits_input input{}; + ValidateDigits_output output{}; + ValidateDigits_locals locals{}; + input.digits = digits; + ValidateDigits(qpi, *this, input, output, locals); + return output; + } + + GetRandomDigits_output callGetRandomDigits(const QPI::QpiContextFunctionCall& qpi, uint64 seed) const + { + GetRandomDigits_input input{}; + GetRandomDigits_output output{}; + GetRandomDigits_locals locals{}; + input.seed = seed; + GetRandomDigits(qpi, *this, input, output, locals); + return output; + } + + PrepareRandomTickets_output callPrepareRandomTickets(const QPI::QpiContextProcedureCall& qpi, uint16 count) + { + PrepareRandomTickets_input input{}; + PrepareRandomTickets_output output{}; + PrepareRandomTickets_locals locals{}; + input.count = count; + PrepareRandomTickets(qpi, *this, input, output, locals); + return output; + } + + ChargeTicketsFromPlayer_output callChargeTicketsFromPlayer(const QPI::QpiContextProcedureCall& qpi, const id& player, uint16 count) + { + ChargeTicketsFromPlayer_input input{}; + ChargeTicketsFromPlayer_output output{}; + ChargeTicketsFromPlayer_locals locals{}; + input.player = player; + input.count = count; + ChargeTicketsFromPlayer(qpi, *this, input, output, locals); + return output; + } + + AllocateRandomTickets_output callAllocateRandomTickets(const QPI::QpiContextProcedureCall& qpi, const id& player, uint16 count) + { + AllocateRandomTickets_input input{}; + AllocateRandomTickets_output output{}; + AllocateRandomTickets_locals locals{}; + input.player = player; + input.count = count; + AllocateRandomTickets(qpi, *this, input, output, locals); + return output; + } + + void callProcessAutoTickets(const QPI::QpiContextProcedureCall& qpi) + { + ProcessAutoTickets_input input{}; + ProcessAutoTickets_output output{}; + ProcessAutoTickets_locals locals{}; + ProcessAutoTickets(qpi, *this, input, output, locals); + } + + GetAutoParticipation_output callGetAutoParticipation(const QPI::QpiContextFunctionCall& qpi) const + { + GetAutoParticipation_input input{}; + GetAutoParticipation_output output{}; + GetAutoParticipation_locals locals{}; + GetAutoParticipation(qpi, *this, input, output, locals); + return output; + } + + void setAutoParticipant(const id& player, sint64 deposit, uint16 desiredTickets) + { + AutoParticipant entry{}; + entry.player = player; + entry.deposit = deposit; + entry.desiredTickets = desiredTickets; + autoParticipants.set(player, entry); + } + + uint64 callGetLeftAlignedReward(uint8 matches) const { return getLeftAlignedReward(*this, matches); } + uint64 callGetAnyPositionReward(uint8 matches) const { return getAnyPositionReward(*this, matches); } + uint64 callComputePrize(const Array& winning, const Array& digits) + { + Ticket ticket{}; + ticket.digits = digits; + ComputePrize_locals locals{}; + return computePrize(*this, ticket, winning, locals); + } +}; + +class ContractTestingPulse : protected ContractTesting +{ +public: + ContractTestingPulse() + { + initEmptySpectrum(); + initEmptyUniverse(); + INIT_CONTRACT(PULSE); + system.epoch = contractDescriptions[PULSE_CONTRACT_INDEX].constructionEpoch; + callSystemProcedure(PULSE_CONTRACT_INDEX, INITIALIZE); + } + + PULSEChecker* state() { return reinterpret_cast(contractStates[PULSE_CONTRACT_INDEX]); } + id pulseSelf() const { return id(PULSE_CONTRACT_INDEX, 0, 0, 0); } + + PULSE::GetTicketPrice_output getTicketPrice() + { + PULSE::GetTicketPrice_input input{}; + PULSE::GetTicketPrice_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_TICKET_PRICE, input, output); + return output; + } + + PULSE::GetSchedule_output getSchedule() + { + PULSE::GetSchedule_input input{}; + PULSE::GetSchedule_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_SCHEDULE, input, output); + return output; + } + + PULSE::GetDrawHour_output getDrawHour() + { + PULSE::GetDrawHour_input input{}; + PULSE::GetDrawHour_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_DRAW_HOUR, input, output); + return output; + } + + PULSE::GetFees_output getFees() + { + PULSE::GetFees_input input{}; + PULSE::GetFees_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_FEES, input, output); + return output; + } + + PULSE::GetQHeartHoldLimit_output getQHeartHoldLimit() + { + PULSE::GetQHeartHoldLimit_input input{}; + PULSE::GetQHeartHoldLimit_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_QHEART_HOLD_LIMIT, input, output); + return output; + } + + PULSE::GetQHeartWallet_output getQHeartWallet() + { + PULSE::GetQHeartWallet_input input{}; + PULSE::GetQHeartWallet_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_QHEART_WALLET, input, output); + return output; + } + + PULSE::GetWinningDigits_output getWinningDigits() + { + PULSE::GetWinningDigits_input input{}; + PULSE::GetWinningDigits_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_WINNING_DIGITS, input, output); + return output; + } + + PULSE::GetBalance_output getBalance() + { + PULSE::GetBalance_input input{}; + PULSE::GetBalance_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_BALANCE, input, output); + return output; + } + + PULSE::GetWinners_output getWinners() + { + PULSE::GetWinners_input input{}; + PULSE::GetWinners_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_WINNERS, input, output); + return output; + } + + PULSE::GetAutoParticipation_output getAutoParticipation(const id& user) + { + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, user, 0); + return state()->callGetAutoParticipation(qpi); + } + + PULSE::GetAutoStats_output getAutoStats() + { + PULSE::GetAutoStats_input input{}; + PULSE::GetAutoStats_output output{}; + callFunction(PULSE_CONTRACT_INDEX, PULSE_FUNCTION_GET_AUTO_STATS, input, output); + return output; + } + + PULSE::BuyTicket_output buyTicket(const id& user, const Array& digits) + { + ensureUserEnergy(user); + PULSE::BuyTicket_input input{}; + input.digits = digits; + PULSE::BuyTicket_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_BUY_TICKET, input, output, user, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::BuyRandomTickets_output buyRandomTickets(const id& user, uint16 count) + { + ensureUserEnergy(user); + PULSE::BuyRandomTickets_input input{}; + input.count = count; + PULSE::BuyRandomTickets_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_BUY_RANDOM_TICKETS, input, output, user, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::DepositAutoParticipation_output depositAutoParticipation(const id& user, sint64 amount, sint16 desiredTickets, bool buyNow) + { + ensureUserEnergy(user); + PULSE::DepositAutoParticipation_input input{}; + input.amount = amount; + input.desiredTickets = desiredTickets; + input.buyNow = buyNow; + PULSE::DepositAutoParticipation_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_DEPOSIT_AUTO_PARTICIPATION, input, output, user, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::WithdrawAutoParticipation_output withdrawAutoParticipation(const id& user, sint64 amount) + { + ensureUserEnergy(user); + PULSE::WithdrawAutoParticipation_input input{}; + input.amount = amount; + PULSE::WithdrawAutoParticipation_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_WITHDRAW_AUTO_PARTICIPATION, input, output, user, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::SetAutoConfig_output setAutoConfig(const id& user, sint16 desiredTickets) + { + ensureUserEnergy(user); + PULSE::SetAutoConfig_input input{}; + input.desiredTickets = desiredTickets; + PULSE::SetAutoConfig_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_AUTO_CONFIG, input, output, user, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::SetAutoLimits_output setAutoLimits(const id& invocator, uint16 maxTicketsPerUser) + { + ensureUserEnergy(invocator); + PULSE::SetAutoLimits_input input{}; + input.maxTicketsPerUser = maxTicketsPerUser; + PULSE::SetAutoLimits_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_AUTO_LIMITS, input, output, invocator, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::SetPrice_output setPrice(const id& invocator, uint64 newPrice) + { + ensureUserEnergy(invocator); + PULSE::SetPrice_input input{}; + input.newPrice = newPrice; + PULSE::SetPrice_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_PRICE, input, output, invocator, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::SetSchedule_output setSchedule(const id& invocator, uint8 newSchedule) + { + ensureUserEnergy(invocator); + PULSE::SetSchedule_input input{}; + input.newSchedule = newSchedule; + PULSE::SetSchedule_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_SCHEDULE, input, output, invocator, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::SetDrawHour_output setDrawHour(const id& invocator, uint8 newDrawHour) + { + ensureUserEnergy(invocator); + PULSE::SetDrawHour_input input{}; + input.newDrawHour = newDrawHour; + PULSE::SetDrawHour_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_DRAW_HOUR, input, output, invocator, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::SetFees_output setFees(const id& invocator, uint8 dev, uint8 burn, uint8 shareholders, uint8 qheart) + { + ensureUserEnergy(invocator); + PULSE::SetFees_input input{}; + input.devPercent = dev; + input.burnPercent = burn; + input.shareholdersPercent = shareholders; + input.qheartPercent = qheart; + PULSE::SetFees_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_FEES, input, output, invocator, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + PULSE::SetQHeartHoldLimit_output setQHeartHoldLimit(const id& invocator, uint64 newLimit) + { + ensureUserEnergy(invocator); + PULSE::SetQHeartHoldLimit_input input{}; + input.newQHeartHoldLimit = newLimit; + PULSE::SetQHeartHoldLimit_output output{}; + if (!invokeUserProcedure(PULSE_CONTRACT_INDEX, PULSE_PROCEDURE_SET_QHEART_HOLD_LIMIT, input, output, invocator, 0)) + { + output.returnCode = static_cast(PULSE::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + void beginEpoch() { callSystemProcedure(PULSE_CONTRACT_INDEX, BEGIN_EPOCH); } + void endEpoch() { callSystemProcedure(PULSE_CONTRACT_INDEX, END_EPOCH); } + void beginTick() { callSystemProcedure(PULSE_CONTRACT_INDEX, BEGIN_TICK); } + + void setDateTime(uint16 year, uint8 month, uint8 day, uint8 hour) + { + updateTime(); + utcTime.Year = year; + utcTime.Month = month; + utcTime.Day = day; + utcTime.Hour = hour; + utcTime.Minute = 0; + utcTime.Second = 0; + utcTime.Nanosecond = 0; + updateQpiTime(); + } + + void forceBeginTick() + { + // Align to update period so BEGIN_TICK evaluates draw logic. + system.tick = system.tick + (PULSE_TICK_UPDATE_PERIOD - (system.tick % PULSE_TICK_UPDATE_PERIOD)); + beginTick(); + } + + struct QHeartIssuance + { + int issuanceIndex; + int ownershipIndex; + int possessionIndex; + }; + + QHeartIssuance issueQHeart(sint64 totalShares) + { + static constexpr char name[7] = {'Q', 'H', 'E', 'A', 'R', 'T', 0}; + static constexpr char unit[7] = {}; + QHeartIssuance info{}; + const sint64 issued = issueAsset(PULSE_QHEART_ISSUER, name, 0, unit, totalShares, PULSE_CONTRACT_INDEX, &info.issuanceIndex, + &info.ownershipIndex, &info.possessionIndex); + EXPECT_EQ(issued, totalShares); + return info; + } + + void transferQHeart(const QHeartIssuance& issuance, const id& dest, sint64 amount) + { + int destOwnershipIndex = 0; + int destPossessionIndex = 0; + EXPECT_TRUE(transferShareOwnershipAndPossession(issuance.ownershipIndex, issuance.possessionIndex, dest, amount, &destOwnershipIndex, + &destPossessionIndex, true)); + } + + void issuePulseSharesTo(const id& holder, unsigned int shares) + { + std::vector> initialShares; + initialShares.emplace_back(holder, shares); + issueContractShares(PULSE_CONTRACT_INDEX, initialShares, false); + } + + uint64 qheartBalanceOf(const id& owner) const + { + const long long balance = + numberOfPossessedShares(PULSE_QHEART_ASSET_NAME, PULSE_QHEART_ISSUER, owner, owner, PULSE_CONTRACT_INDEX, PULSE_CONTRACT_INDEX); + return (balance > 0) ? static_cast(balance) : 0; + } + +private: + static void ensureUserEnergy(const id& user) { increaseEnergy(user, 1); } +}; + +namespace +{ + // Mirror contract RNG path so tests can assert deterministic winners. + Array deriveWinningDigits(ContractTestingPulse& ctl, const m256i& digest) + { + m256i hashResult; + KangarooTwelve(reinterpret_cast(&digest), sizeof(m256i), reinterpret_cast(&hashResult), sizeof(m256i)); + const uint64 seed = hashResult.m256i_u64[0]; + + QpiContextUserFunctionCall qpiFunc(PULSE_CONTRACT_INDEX); + primeQpiFunctionContext(qpiFunc); + return ctl.state()->callGetRandomDigits(qpiFunc, seed).digits; + } + + uint8 findMissingDigit(const Array& winning) + { + bool seen[PULSE_MAX_DIGIT + 1] = {}; + for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + seen[winning.get(i)] = true; + } + for (uint8 d = 0; d <= PULSE_MAX_DIGIT; ++d) + { + if (!seen[d]) + { + return d; + } + } + return 0; + } + +} // namespace + +// ============================================================================ +// STATIC + PRIVATE METHOD TESTS +// ============================================================================ + +// Regression coverage for deterministic helpers used by draw logic. +TEST(ContractPulse_Static, MakeDateStampMinMaxAndMixingAreDeterministic) +{ + uint32 stamp = 0; + PULSE::makeDateStamp(25, 1, 10, stamp); + EXPECT_EQ(stamp, static_cast(25 << 9 | 1 << 5 | 10)); + EXPECT_EQ(PULSE::min(3, 5), 3u); + EXPECT_EQ(PULSE::max(3, 5), 5u); + + uint64 mixed1 = 0; + uint64 mixed2 = 0; + PULSE::mix64(0x12345678ULL, mixed1); + PULSE::mix64(0x12345678ULL, mixed2); + EXPECT_EQ(mixed1, mixed2); + + uint64 d1 = 0; + uint64 d2 = 0; + PULSE::deriveOne(0xABCDEFULL, 0, d1); + PULSE::deriveOne(0xABCDEFULL, 1, d2); + EXPECT_NE(d1, d2); +} + +// Guard state flag transitions used to open/close ticket sales. +TEST(ContractPulse_Static, SellingFlagToggles) +{ + ContractTestingPulse ctl; + ctl.state()->forceSelling(true); + EXPECT_TRUE(ctl.state()->isSelling()); + ctl.state()->forceSelling(false); + EXPECT_FALSE(ctl.state()->isSelling()); +} + +// Ensure reward multipliers stay aligned with contract constants. +TEST(ContractPulse_Static, RewardTablesMatchContractConstants) +{ + ContractTestingPulse ctl; + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(6), 2000u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(5), 300u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(4), 60u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(3), 20u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(2), 4u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(1), 1u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetLeftAlignedReward(0), 0u); + + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(6), 150u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(5), 30u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(4), 8u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(3), 2u * ctl.getTicketPrice().ticketPrice); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(2), 0u); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(1), 0u); + EXPECT_EQ(ctl.state()->callGetAnyPositionReward(0), 0u); +} + +// Ensure computePrize picks the higher of left-aligned or any-position rewards. +TEST(ContractPulse_Static, ComputePrizeSelectsBestReward) +{ + ContractTestingPulse ctl; + const Array winning = makePlayerDigits(0, 1, 2, 3, 4, 5); + + const Array exact = makePlayerDigits(0, 1, 2, 3, 4, 5); + EXPECT_EQ(ctl.state()->callComputePrize(winning, exact), 2000u * ctl.getTicketPrice().ticketPrice); + + const Array permuted = makePlayerDigits(5, 4, 3, 2, 1, 0); + EXPECT_EQ(ctl.state()->callComputePrize(winning, permuted), 150u * ctl.getTicketPrice().ticketPrice); + + const Array none = makePlayerDigits(9, 9, 9, 9, 9, 9); + EXPECT_EQ(ctl.state()->callComputePrize(winning, none), 0u); +} + +// Prevent stale config from leaking across epochs. +TEST(ContractPulse_Private, NextEpochDataClearResetsFlagsAndValues) +{ + PULSE::NextEpochData data{}; + data.hasNewPrice = true; + data.hasNewSchedule = true; + data.hasNewDrawHour = true; + data.hasNewFee = true; + data.hasNewQHeartHoldLimit = true; + data.newPrice = 10; + data.newSchedule = 1; + data.newDrawHour = 2; + data.newDevPercent = 3; + data.newBurnPercent = 4; + data.newShareholdersPercent = 5; + data.newQHeartPercent = 6; + data.newQHeartHoldLimit = 7; + + data.clear(); + EXPECT_FALSE(data.hasNewPrice); + EXPECT_FALSE(data.hasNewSchedule); + EXPECT_FALSE(data.hasNewDrawHour); + EXPECT_FALSE(data.hasNewFee); + EXPECT_FALSE(data.hasNewQHeartHoldLimit); + EXPECT_EQ(data.newPrice, 0u); + EXPECT_EQ(data.newSchedule, 0u); + EXPECT_EQ(data.newDrawHour, 0u); + EXPECT_EQ(data.newDevPercent, 0u); + EXPECT_EQ(data.newBurnPercent, 0u); + EXPECT_EQ(data.newShareholdersPercent, 0u); + EXPECT_EQ(data.newQHeartPercent, 0u); + EXPECT_EQ(data.newQHeartHoldLimit, 0u); +} + +// Confirm deferred config applies only at epoch boundary. +TEST(ContractPulse_Private, NextEpochDataApplyUpdatesState) +{ + ContractTestingPulse ctl; + PULSE::NextEpochData data{}; + data.hasNewPrice = true; + data.hasNewSchedule = true; + data.hasNewDrawHour = true; + data.hasNewFee = true; + data.hasNewQHeartHoldLimit = true; + data.newPrice = 123; + data.newSchedule = 0xAA; + data.newDrawHour = 7; + data.newDevPercent = 11; + data.newBurnPercent = 22; + data.newShareholdersPercent = 33; + data.newQHeartPercent = 4; + data.newQHeartHoldLimit = 999; + + data.apply(*ctl.state()); + EXPECT_EQ(ctl.state()->getTicketPriceInternal(), 123u); + EXPECT_EQ(ctl.state()->getScheduleInternal(), 0xAA); + EXPECT_EQ(ctl.state()->getDrawHourInternal(), 7u); + EXPECT_EQ(ctl.state()->getDevPercentInternal(), 11u); + EXPECT_EQ(ctl.state()->getBurnPercentInternal(), 22u); + EXPECT_EQ(ctl.state()->getShareholdersPercentInternal(), 33u); + EXPECT_EQ(ctl.state()->getQHeartPercentInternal(), 4u); + EXPECT_EQ(ctl.state()->getQHeartHoldLimitInternal(), 999u); +} + +// Keep RNG output deterministic for auditability. +TEST(ContractPulse_Private, GetRandomDigitsDeterministic) +{ + ContractTestingPulse ctl; + QpiContextUserFunctionCall qpi(PULSE_CONTRACT_INDEX); + primeQpiFunctionContext(qpi); + + static constexpr uint64 seed = 0x123456789ABCDEF0ULL; + const PULSE::GetRandomDigits_output& out1 = ctl.state()->callGetRandomDigits(qpi, seed); + const PULSE::GetRandomDigits_output& out2 = ctl.state()->callGetRandomDigits(qpi, seed); + + for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + const uint8 v1 = out1.digits.get(i); + const uint8 v2 = out2.digits.get(i); + EXPECT_EQ(v1, v2); + EXPECT_LE(v1, PULSE_MAX_DIGIT); + } +} + +// Validate digit range checks for ticket input. +TEST(ContractPulse_Private, ValidateDigitsAcceptsRangeAndRejectsOutOfRange) +{ + ContractTestingPulse ctl; + QpiContextUserFunctionCall qpi(PULSE_CONTRACT_INDEX); + primeQpiFunctionContext(qpi); + + const PULSE::ValidateDigits_output valid = ctl.state()->callValidateDigits(qpi, makePlayerDigits(0, 1, 2, 3, 4, 5)); + EXPECT_TRUE(valid.isValid); + + const uint8 invalidValue = static_cast(PULSE_MAX_DIGIT + 1); + const PULSE::ValidateDigits_output invalid = ctl.state()->callValidateDigits(qpi, makePlayerDigits(0, 1, 2, 3, 4, invalidValue)); + EXPECT_FALSE(invalid.isValid); +} + +// Validate PrepareRandomTickets error cases. +TEST(ContractPulse_Private, PrepareRandomTicketsRejectsInvalidInputs) +{ + ContractTestingPulse ctl; + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpi); + + PULSE::PrepareRandomTickets_output out = ctl.state()->callPrepareRandomTickets(qpi, 0); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + ctl.endEpoch(); + out = ctl.state()->callPrepareRandomTickets(qpi, 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_SELLING_CLOSED)); +} + +// Guard sold-out behavior in PrepareRandomTickets. +TEST(ContractPulse_Private, PrepareRandomTicketsRejectsWhenSoldOut) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS); + + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpi); + const PULSE::PrepareRandomTickets_output out = ctl.state()->callPrepareRandomTickets(qpi, 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_ALL_SOLD_OUT)); +} + +// Validate ChargeTicketsFromPlayer rejects invalid inputs and insufficient balance. +TEST(ContractPulse_Private, ChargeTicketsFromPlayerRejectsInvalidOrInsufficient) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.transferQHeart(issuance, user, ticketPrice); + + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpi); + + PULSE::ChargeTicketsFromPlayer_output out = ctl.state()->callChargeTicketsFromPlayer(qpi, user, 0); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + out = ctl.state()->callChargeTicketsFromPlayer(qpi, id::zero(), 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + out = ctl.state()->callChargeTicketsFromPlayer(qpi, user, 2); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_INVALID_PRICE)); +} + +// Validate AllocateRandomTickets rejects invalid inputs and closed/sold-out states. +TEST(ContractPulse_Private, AllocateRandomTicketsRejectsInvalidOrClosed) +{ + ContractTestingPulse ctl; + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpi); + + PULSE::AllocateRandomTickets_output out = ctl.state()->callAllocateRandomTickets(qpi, id::zero(), 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + out = ctl.state()->callAllocateRandomTickets(qpi, id::randomValue(), 0); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + ctl.endEpoch(); + out = ctl.state()->callAllocateRandomTickets(qpi, id::randomValue(), 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_SELLING_CLOSED)); +} + +// Guard AllocateRandomTickets sold-out path. +TEST(ContractPulse_Private, AllocateRandomTicketsRejectsWhenSoldOut) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS); + + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpi); + const PULSE::AllocateRandomTickets_output out = ctl.state()->callAllocateRandomTickets(qpi, id::randomValue(), 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_ALL_SOLD_OUT)); +} + +// Ensure ProcessAutoTickets skips when selling is closed. +TEST(ContractPulse_Private, ProcessAutoTicketsSkipsWhenSellingClosed) +{ + ContractTestingPulse ctl; + const id user = id::randomValue(); + ctl.state()->setAutoParticipant(user, 1, 1); + ctl.state()->forceSelling(false); + + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpi); + ctl.state()->callProcessAutoTickets(qpi); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); +} + +// Ensure ProcessAutoTickets skips when no slots are left. +TEST(ContractPulse_Private, ProcessAutoTicketsSkipsWhenSoldOut) +{ + ContractTestingPulse ctl; + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.state()->setAutoParticipant(user, static_cast(ticketPrice), 1); + + ctl.state()->forceSelling(true); + ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS); + + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpi); + ctl.state()->callProcessAutoTickets(qpi); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getTicketCounter(), static_cast(PULSE_MAX_NUMBER_OF_PLAYERS)); +} + +// Remove auto participants that cannot afford a ticket. +TEST(ContractPulse_Private, ProcessAutoTicketsRemovesUnaffordableParticipant) +{ + ContractTestingPulse ctl; + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.state()->setAutoParticipant(user, static_cast(ticketPrice - 1), 1); + + ctl.state()->forceSelling(true); + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpi); + ctl.state()->callProcessAutoTickets(qpi); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// Checks process auto tickets removes zero desired tickets. +TEST(ContractPulse_Private, ProcessAutoTicketsRemovesZeroDesiredTickets) +{ + ContractTestingPulse ctl; + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.state()->setAutoParticipant(user, static_cast(ticketPrice * 2), 0); + + ctl.state()->forceSelling(true); + QpiContextUserProcedureCall qpi(PULSE_CONTRACT_INDEX, PULSE_QHEART_ISSUER, 0); + primeQpiProcedureContext(qpi); + ctl.state()->callProcessAutoTickets(qpi); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// ============================================================================ +// PUBLIC FUNCTIONS AND PROCEDURES +// ============================================================================ + +// Confirm defaults are visible through the public API after init. +TEST(ContractPulse_Public, GettersReturnDefaultsAfterInitialize) +{ + ContractTestingPulse ctl; + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, PULSE_TICKET_PRICE_DEFAULT); + EXPECT_EQ(ctl.getSchedule().schedule, PULSE_DEFAULT_SCHEDULE); + EXPECT_EQ(ctl.getDrawHour().drawHour, PULSE_DEFAULT_DRAW_HOUR); + EXPECT_EQ(ctl.getQHeartHoldLimit().qheartHoldLimit, PULSE_DEFAULT_QHEART_HOLD_LIMIT); + + const PULSE::GetFees_output& fees = ctl.getFees(); + EXPECT_EQ(fees.devPercent, PULSE_DEFAULT_DEV_PERCENT); + EXPECT_EQ(fees.burnPercent, PULSE_DEFAULT_BURN_PERCENT); + EXPECT_EQ(fees.shareholdersPercent, PULSE_DEFAULT_SHAREHOLDERS_PERCENT); + EXPECT_EQ(fees.qheartPercent, PULSE_DEFAULT_QHEART_PERCENT); + EXPECT_EQ(fees.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const PULSE::GetWinningDigits_output& win = ctl.getWinningDigits(); + for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + EXPECT_EQ(win.digits.get(i), 0u); + } + EXPECT_EQ(ctl.getBalance().balance, 0u); + EXPECT_EQ(ctl.getQHeartWallet().wallet, PULSE_QHEART_ISSUER); +} + +// Guard admin-only price changes and deferred apply. +TEST(ContractPulse_Public, SetPriceGuardsAccessAndAppliesOnEndEpoch) +{ + ContractTestingPulse ctl; + EXPECT_EQ(ctl.setPrice(id::randomValue(), 123).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, 0).returnCode, static_cast(PULSE::EReturnCode::TICKET_INVALID_PRICE)); + + EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, 555).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getTicketPriceInternal(), PULSE_TICKET_PRICE_DEFAULT); + + ctl.endEpoch(); + EXPECT_EQ(ctl.state()->getTicketPriceInternal(), 555u); +} + +// Ensure schedule validation and deferred apply are enforced. +TEST(ContractPulse_Public, SetScheduleValidatesAndAppliesOnEndEpoch) +{ + ContractTestingPulse ctl; + EXPECT_EQ(ctl.setSchedule(id::randomValue(), 1).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ(ctl.setSchedule(PULSE_QHEART_ISSUER, 0).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + EXPECT_EQ(ctl.setSchedule(PULSE_QHEART_ISSUER, 0x7F).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getScheduleInternal(), PULSE_DEFAULT_SCHEDULE); + + ctl.endEpoch(); + EXPECT_EQ(ctl.state()->getScheduleInternal(), 0x7Fu); +} + +// Ensure draw hour range checks and deferred apply are enforced. +TEST(ContractPulse_Public, SetDrawHourValidatesAndAppliesOnEndEpoch) +{ + ContractTestingPulse ctl; + EXPECT_EQ(ctl.setDrawHour(id::randomValue(), 12).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ(ctl.setDrawHour(PULSE_QHEART_ISSUER, 24).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + EXPECT_EQ(ctl.setDrawHour(PULSE_QHEART_ISSUER, 9).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getDrawHourInternal(), PULSE_DEFAULT_DRAW_HOUR); + + ctl.endEpoch(); + EXPECT_EQ(ctl.state()->getDrawHourInternal(), 9u); +} + +// Protect against invalid fee splits and apply on epoch end. +TEST(ContractPulse_Public, SetFeesValidatesAndAppliesOnEndEpoch) +{ + ContractTestingPulse ctl; + EXPECT_EQ(ctl.setFees(id::randomValue(), 1, 2, 3, 4).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 60, 60, 0, 0).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 11, 22, 33, 4).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getDevPercentInternal(), PULSE_DEFAULT_DEV_PERCENT); + EXPECT_EQ(ctl.state()->getBurnPercentInternal(), PULSE_DEFAULT_BURN_PERCENT); + EXPECT_EQ(ctl.state()->getShareholdersPercentInternal(), PULSE_DEFAULT_SHAREHOLDERS_PERCENT); + EXPECT_EQ(ctl.state()->getQHeartPercentInternal(), PULSE_DEFAULT_QHEART_PERCENT); + + ctl.endEpoch(); + EXPECT_EQ(ctl.state()->getDevPercentInternal(), 11u); + EXPECT_EQ(ctl.state()->getBurnPercentInternal(), 22u); + EXPECT_EQ(ctl.state()->getShareholdersPercentInternal(), 33u); + EXPECT_EQ(ctl.state()->getQHeartPercentInternal(), 4u); +} + +// Ensure hold-limit changes do not affect the current round. +TEST(ContractPulse_Public, SetQHeartHoldLimitAppliesOnEndEpoch) +{ + ContractTestingPulse ctl; + EXPECT_EQ(ctl.setQHeartHoldLimit(id::randomValue(), 100).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ(ctl.setQHeartHoldLimit(PULSE_QHEART_ISSUER, 1234).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getQHeartHoldLimitInternal(), PULSE_DEFAULT_QHEART_HOLD_LIMIT); + + ctl.endEpoch(); + EXPECT_EQ(ctl.state()->getQHeartHoldLimitInternal(), 1234u); +} + +// Ensure getters report newly applied config values after epoch end. +TEST(ContractPulse_Public, GettersReflectAppliedChanges) +{ + ContractTestingPulse ctl; + EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, 555).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setSchedule(PULSE_QHEART_ISSUER, 0x7F).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setDrawHour(PULSE_QHEART_ISSUER, 9).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 11, 22, 33, 4).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setQHeartHoldLimit(PULSE_QHEART_ISSUER, 4321).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + ctl.endEpoch(); + + EXPECT_EQ(ctl.getTicketPrice().ticketPrice, 555u); + EXPECT_EQ(ctl.getSchedule().schedule, 0x7Fu); + EXPECT_EQ(ctl.getDrawHour().drawHour, 9u); + EXPECT_EQ(ctl.getQHeartHoldLimit().qheartHoldLimit, 4321u); + + const PULSE::GetFees_output fees = ctl.getFees(); + EXPECT_EQ(fees.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(fees.devPercent, 11u); + EXPECT_EQ(fees.burnPercent, 22u); + EXPECT_EQ(fees.shareholdersPercent, 33u); + EXPECT_EQ(fees.qheartPercent, 4u); +} + +// Prevent ticket purchases outside the selling window. +TEST(ContractPulse_Public, BuyTicketWhenSellingClosedFails) +{ + ContractTestingPulse ctl; + const PULSE::BuyTicket_output& out = ctl.buyTicket(id::randomValue(), makePlayerDigits(0, 1, 2, 3, 4, 5)); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_SELLING_CLOSED)); +} + +// Reject malformed tickets before funds are transferred. +TEST(ContractPulse_Public, BuyTicketValidatesDigits) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + ctl.transferQHeart(issuance, user, PULSE_TICKET_PRICE_DEFAULT); + + const PULSE::BuyTicket_output& out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 10)); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_NUMBERS)); +} + +// Enforce hard cap on ticket count. +TEST(ContractPulse_Public, BuyTicketFailsWhenSoldOut) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS); + + const PULSE::BuyTicket_output& out = ctl.buyTicket(id::randomValue(), makePlayerDigits(0, 1, 2, 3, 4, 5)); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_ALL_SOLD_OUT)); +} + +// Avoid unintended debt when buyer lacks funds. +TEST(ContractPulse_Public, BuyTicketFailsWithInsufficientBalance) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + ctl.transferQHeart(issuance, user, PULSE_TICKET_PRICE_DEFAULT - 1); + + const PULSE::BuyTicket_output& out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 5)); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_INVALID_PRICE)); +} + +// Validate successful purchase moves funds and stores ticket. +TEST(ContractPulse_Public, BuyTicketSucceedsAndMovesQHeart) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + ctl.transferQHeart(issuance, user, PULSE_TICKET_PRICE_DEFAULT * 2); + + const uint64 userBefore = ctl.qheartBalanceOf(user); + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + + const PULSE::BuyTicket_output& out = ctl.buyTicket(user, makePlayerDigits(0, 1, 2, 3, 4, 5)); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getTicketCounter(), 1u); + EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore - PULSE_TICKET_PRICE_DEFAULT); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore + PULSE_TICKET_PRICE_DEFAULT); +} + +// Prevent random purchases outside the selling window. +TEST(ContractPulse_Public, BuyRandomTicketsFailsWhenSellingClosed) +{ + ContractTestingPulse ctl; + const id user = id::randomValue(); + const PULSE::BuyRandomTickets_output out = ctl.buyRandomTickets(user, 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_SELLING_CLOSED)); +} + +// Reject empty batch requests to avoid no-op transfers. +TEST(ContractPulse_Public, BuyRandomTicketsRejectsZeroCount) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const id user = id::randomValue(); + const PULSE::BuyRandomTickets_output out = ctl.buyRandomTickets(user, 0); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// Enforce capacity checks for batch purchases. +TEST(ContractPulse_Public, BuyRandomTicketsFailsWhenSoldOut) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS); + + const id user = id::randomValue(); + const PULSE::BuyRandomTickets_output out = ctl.buyRandomTickets(user, 2); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_ALL_SOLD_OUT)); +} + +// Avoid partial batch purchases when balance is insufficient. +TEST(ContractPulse_Public, BuyRandomTicketsFailsWithInsufficientBalance) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + ctl.transferQHeart(issuance, user, PULSE_TICKET_PRICE_DEFAULT); + + const PULSE::BuyRandomTickets_output out = ctl.buyRandomTickets(user, 2); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_INVALID_PRICE)); +} + +// Validate batch purchase moves funds and mints tickets. +TEST(ContractPulse_Public, BuyRandomTicketsSucceedsAndMovesQHeart) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + static constexpr uint16 ticketCount = 3; + static constexpr uint64 totalPrice = static_cast(ticketCount) * PULSE_TICKET_PRICE_DEFAULT; + ctl.transferQHeart(issuance, user, totalPrice); + + const uint64 userBefore = ctl.qheartBalanceOf(user); + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + etalonTick.prevSpectrumDigest = m256i(0xAAAABBBBULL, 0xCCCCDDDDULL, 0x11112222ULL, 0x33334444ULL); + + const PULSE::BuyRandomTickets_output out = ctl.buyRandomTickets(user, ticketCount); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getTicketCounter(), static_cast(static_cast(ticketCount))); + EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore - totalPrice); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore + totalPrice); + + std::set seen; + for (uint16 i = 0; i < ticketCount; ++i) + { + const PULSE::Ticket ticket = ctl.state()->getTicket(i); + EXPECT_EQ(ticket.player, user); + QpiContextUserFunctionCall qpi(PULSE_CONTRACT_INDEX); + primeQpiFunctionContext(qpi); + const PULSE::ValidateDigits_output validated = ctl.state()->callValidateDigits(qpi, ticket.digits); + EXPECT_TRUE(validated.isValid); + + uint32 key = 0; + uint32 mul = 1; + for (uint64 d = 0; d < PULSE_PLAYER_DIGITS; ++d) + { + key += static_cast(ticket.digits.get(d)) * mul; + mul *= 10; + } + EXPECT_TRUE(seen.insert(key).second); + } +} + +// Validate deterministic random tickets for a fixed spectrum digest. +TEST(ContractPulse_Public, BuyRandomTicketsDeterministicWithFixedDigest) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.transferQHeart(issuance, user, ticketPrice); + + const m256i digest(0xABCDEF01ULL, 0x12345678ULL, 0xCAFEBABEULL, 0x0BADF00DULL); + etalonTick.prevSpectrumDigest = digest; + + PULSE::AllocateRandomTickets_locals::RandomData randomData{}; + randomData.prevSpectrumDigest = digest; + randomData.allocateInput.player = user; + randomData.allocateInput.count = 1; + randomData.ticketCounter = static_cast(ctl.state()->getTicketCounter()); + + m256i hashResult; + KangarooTwelve(reinterpret_cast(&randomData), sizeof(randomData), reinterpret_cast(&hashResult), sizeof(m256i)); + const uint64 randomSeed = hashResult.m256i_u64[0]; + uint64 tempSeed = 0; + PULSEChecker::deriveOne(randomSeed, 0, tempSeed); + + QpiContextUserFunctionCall qpi(PULSE_CONTRACT_INDEX); + primeQpiFunctionContext(qpi); + const Array& expected = ctl.state()->callGetRandomDigits(qpi, tempSeed).digits; + + const PULSE::BuyRandomTickets_output out = ctl.buyRandomTickets(user, 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const PULSE::Ticket ticket = ctl.state()->getTicket(0); + for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + EXPECT_EQ(ticket.digits.get(i), expected.get(i)); + } +} + +// Clamp random ticket purchases to remaining capacity. +TEST(ContractPulse_Public, BuyRandomTicketsClampsToSlotsLeft) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.transferQHeart(issuance, user, ticketPrice * 2); + + ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS - 2); + etalonTick.prevSpectrumDigest = m256i(0xAAAAULL, 0xBBBBULL, 0xCCCCULL, 0xDDDDULL); + + const uint64 userBefore = ctl.qheartBalanceOf(user); + const PULSE::BuyRandomTickets_output out = ctl.buyRandomTickets(user, 5); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getTicketCounter(), static_cast(PULSE_MAX_NUMBER_OF_PLAYERS)); + EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore - (ticketPrice * 2)); +} + +// Reject non-positive auto-participation inputs. +TEST(ContractPulse_Public, DepositAutoParticipationRejectsInvalidValues) +{ + ContractTestingPulse ctl; + const id user = id::randomValue(); + + EXPECT_EQ(ctl.depositAutoParticipation(user, 0, 1, false).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + EXPECT_EQ(ctl.depositAutoParticipation(user, 1, 0, false).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + EXPECT_EQ(ctl.depositAutoParticipation(user, 1, -1, false).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// Clamp desired ticket counts to configured limits and store the deposit. +TEST(ContractPulse_Public, DepositAutoParticipationClampsDesiredTicketsAndStoresDeposit) +{ + ContractTestingPulse ctl; + EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, 2).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amount = static_cast(ticketPrice * 2); + ctl.transferQHeart(issuance, user, amount); + + const PULSE::DepositAutoParticipation_output out = ctl.depositAutoParticipation(user, amount, 5, false); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(entry.deposit, static_cast(amount)); + EXPECT_EQ(static_cast(entry.desiredTickets), 2u); +} + +// Clamp deposit amount to available user balance. +TEST(ContractPulse_Public, DepositAutoParticipationClampsAmountToBalance) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 balance = static_cast(ticketPrice * 2); + ctl.transferQHeart(issuance, user, static_cast(balance)); + + const PULSE::DepositAutoParticipation_output out = ctl.depositAutoParticipation(user, balance + ticketPrice, 1, false); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(entry.deposit, static_cast(balance)); +} + +// Subsequent deposits should add to the balance and update desired ticket count. +TEST(ContractPulse_Public, DepositAutoParticipationAccumulatesAndUpdatesDesiredTickets) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amountFirst = static_cast(ticketPrice * 2); + const sint64 amountSecond = static_cast(ticketPrice * 3); + const sint64 totalAmount = amountFirst + amountSecond; + ctl.transferQHeart(issuance, user, static_cast(totalAmount)); + + EXPECT_EQ(ctl.depositAutoParticipation(user, amountFirst, 1, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.depositAutoParticipation(user, amountSecond, 2, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(entry.deposit, static_cast(totalAmount)); + EXPECT_EQ(static_cast(entry.desiredTickets), 2u); +} + +// Enforce minimum balance for desired auto-purchases. +TEST(ContractPulse_Public, DepositAutoParticipationRejectsInsufficientAmount) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.transferQHeart(issuance, user, ticketPrice); + + const PULSE::DepositAutoParticipation_output out = ctl.depositAutoParticipation(user, ticketPrice, 2, false); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_INVALID_PRICE)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// Reject new participants once the auto list is at capacity. +TEST(ContractPulse_Public, DepositAutoParticipationRejectsWhenAutoParticipantsFull) +{ + ContractTestingPulse ctl; + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amount = static_cast(ticketPrice); + const sint64 totalShares = static_cast(ticketPrice) * static_cast(PULSE_MAX_NUMBER_OF_AUTO_PARTICIPANTS + 1); + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(totalShares); + + for (uint32 i = 0; i < PULSE_MAX_NUMBER_OF_AUTO_PARTICIPANTS; ++i) + { + const id user = id::randomValue(); + ctl.transferQHeart(issuance, user, ticketPrice); + EXPECT_EQ(ctl.depositAutoParticipation(user, amount, 1, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + } + + const id extraUser = id::randomValue(); + ctl.transferQHeart(issuance, extraUser, ticketPrice); + const PULSE::DepositAutoParticipation_output out = ctl.depositAutoParticipation(extraUser, amount, 1, false); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::AUTO_PARTICIPANTS_FULL)); +} + +// When buy-now spends the entire deposit, no auto-participation entry is created. +TEST(ContractPulse_Public, DepositAutoParticipationBuyNowConsumesAllAndSkipsDeposit) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + static constexpr uint16 desiredTickets = 2; + const uint64 totalPrice = ticketPrice * desiredTickets; + ctl.transferQHeart(issuance, user, totalPrice); + etalonTick.prevSpectrumDigest = m256i(0x1111ULL, 0x2222ULL, 0x3333ULL, 0x4444ULL); + + const uint64 userBefore = ctl.qheartBalanceOf(user); + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + const PULSE::DepositAutoParticipation_output out = ctl.depositAutoParticipation(user, totalPrice, desiredTickets, true); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getTicketCounter(), static_cast(static_cast(desiredTickets))); + EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore - totalPrice); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore + totalPrice); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// If buy-now leaves a remainder, keep it as a deposit entry. +TEST(ContractPulse_Public, DepositAutoParticipationBuyNowStoresRemainder) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + static constexpr uint16 desiredTickets = 2; + const uint64 totalPrice = ticketPrice * desiredTickets; + const sint64 amount = static_cast(totalPrice + ticketPrice); + ctl.transferQHeart(issuance, user, static_cast(amount)); + etalonTick.prevSpectrumDigest = m256i(0xAAAAULL, 0xBBBBULL, 0xCCCCULL, 0xDDDDULL); + + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + const PULSE::DepositAutoParticipation_output out = ctl.depositAutoParticipation(user, amount, desiredTickets, true); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getTicketCounter(), static_cast(static_cast(desiredTickets))); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(entry.deposit, ticketPrice); + EXPECT_EQ(static_cast(entry.desiredTickets), static_cast(desiredTickets)); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore + static_cast(amount)); +} + +// If selling is closed, buy-now should skip buying and keep the deposit. +TEST(ContractPulse_Public, DepositAutoParticipationBuyNowStoresDepositWhenSellingClosed) +{ + ContractTestingPulse ctl; + ctl.endEpoch(); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + static constexpr uint16 desiredTickets = 2; + const sint64 amount = static_cast(ticketPrice * desiredTickets); + ctl.transferQHeart(issuance, user, static_cast(amount)); + + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + const PULSE::DepositAutoParticipation_output out = ctl.depositAutoParticipation(user, amount, desiredTickets, true); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(entry.deposit, static_cast(amount)); + EXPECT_EQ(static_cast(entry.desiredTickets), static_cast(desiredTickets)); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore + static_cast(amount)); +} + +// If buy-now cannot allocate tickets, the deposit is not recorded. +TEST(ContractPulse_Public, DepositAutoParticipationBuyNowFailsWhenSoldOut) +{ + ContractTestingPulse ctl; + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + ctl.state()->setTicketCounter(PULSE_MAX_NUMBER_OF_PLAYERS); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + static constexpr uint16 desiredTickets = 1; + const uint64 totalPrice = ticketPrice * desiredTickets; + ctl.transferQHeart(issuance, user, totalPrice); + etalonTick.prevSpectrumDigest = m256i(0xAAAAULL, 0xBBBBULL, 0xCCCCULL, 0xDDDDULL); + + const uint64 userBefore = ctl.qheartBalanceOf(user); + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + const PULSE::DepositAutoParticipation_output out = ctl.depositAutoParticipation(user, totalPrice, desiredTickets, true); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TICKET_ALL_SOLD_OUT)); + EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// Reject withdrawals for unknown auto participants. +TEST(ContractPulse_Public, WithdrawAutoParticipationRejectsMissingEntry) +{ + ContractTestingPulse ctl; + const id user = id::randomValue(); + const PULSE::WithdrawAutoParticipation_output out = ctl.withdrawAutoParticipation(user, 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// Full withdrawal removes the entry and refunds the deposit. +TEST(ContractPulse_Public, WithdrawAutoParticipationFullRemovesEntry) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amount = static_cast(ticketPrice * 2); + ctl.transferQHeart(issuance, user, amount); + + EXPECT_EQ(ctl.depositAutoParticipation(user, amount, 2, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const uint64 userBefore = ctl.qheartBalanceOf(user); + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + const PULSE::WithdrawAutoParticipation_output out = ctl.withdrawAutoParticipation(user, 0); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore + static_cast(amount)); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore - static_cast(amount)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// Partial withdrawal keeps the entry with the remaining deposit. +TEST(ContractPulse_Public, WithdrawAutoParticipationPartialKeepsEntry) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amount = static_cast(ticketPrice * 3); + ctl.transferQHeart(issuance, user, amount); + + EXPECT_EQ(ctl.depositAutoParticipation(user, amount, 3, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + const PULSE::WithdrawAutoParticipation_output out = ctl.withdrawAutoParticipation(user, ticketPrice); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(entry.deposit, static_cast(amount - ticketPrice)); +} + +// Withdraws more than the deposit should return the full amount and remove the entry. +TEST(ContractPulse_Public, WithdrawAutoParticipationOverdrawsToFullWithdrawal) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amount = static_cast(ticketPrice * 2); + ctl.transferQHeart(issuance, user, amount); + + EXPECT_EQ(ctl.depositAutoParticipation(user, amount, 2, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const uint64 userBefore = ctl.qheartBalanceOf(user); + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + const PULSE::WithdrawAutoParticipation_output out = ctl.withdrawAutoParticipation(user, static_cast(ticketPrice * 5)); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.qheartBalanceOf(user), userBefore + static_cast(amount)); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore - static_cast(amount)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// Surface transfer failures when the contract lacks sufficient QHeart. +TEST(ContractPulse_Public, WithdrawAutoParticipationFailsWhenTransferFails) +{ + ContractTestingPulse ctl; + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.state()->setAutoParticipant(user, static_cast(ticketPrice), 1); + + const PULSE::WithdrawAutoParticipation_output out = ctl.withdrawAutoParticipation(user, ticketPrice); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::TRANSFER_FROM_PULSE_FAILED)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(entry.deposit, ticketPrice); +} + +// Validate SetAutoConfig input and clamp to limits. +TEST(ContractPulse_Public, SetAutoConfigValidatesAndClamps) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amount = static_cast(ticketPrice * 3); + ctl.transferQHeart(issuance, user, amount); + + EXPECT_EQ(ctl.depositAutoParticipation(user, amount, 3, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setAutoConfig(user, -2).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, 2).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setAutoConfig(user, -1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(static_cast(entry.desiredTickets), 2u); + + EXPECT_EQ(ctl.setAutoConfig(user, 5).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + entry = ctl.getAutoParticipation(user); + EXPECT_EQ(static_cast(entry.desiredTickets), 2u); +} + +// Reject desiredTickets = 0 updates. +TEST(ContractPulse_Public, SetAutoConfigRejectsZeroDesiredTickets) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amount = static_cast(ticketPrice * 2); + ctl.transferQHeart(issuance, user, amount); + + EXPECT_EQ(ctl.depositAutoParticipation(user, amount, 2, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setAutoConfig(user, 0).returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); + + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(static_cast(entry.desiredTickets), 2u); +} + +// Reject config updates for users without auto participation. +TEST(ContractPulse_Public, SetAutoConfigRejectsMissingEntry) +{ + ContractTestingPulse ctl; + const id user = id::randomValue(); + const PULSE::SetAutoConfig_output out = ctl.setAutoConfig(user, 1); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// Enforce access and range checks on auto limits. +TEST(ContractPulse_Public, SetAutoLimitsGuardsAccessAndValidates) +{ + ContractTestingPulse ctl; + EXPECT_EQ(ctl.setAutoLimits(id::randomValue(), 10).returnCode, static_cast(PULSE::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, PULSE_MAX_NUMBER_OF_PLAYERS + 1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(static_cast(ctl.getAutoStats().maxAutoTicketsPerUser), static_cast(PULSE_MAX_NUMBER_OF_PLAYERS)); + EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, 5).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const PULSE::GetAutoStats_output stats = ctl.getAutoStats(); + EXPECT_EQ(stats.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(static_cast(stats.maxAutoTicketsPerUser), 5u); +} + +// Allow disabling auto ticket limits by setting them to zero. +TEST(ContractPulse_Public, SetAutoLimitsAllowsDisabling) +{ + ContractTestingPulse ctl; + EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, 3).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setAutoLimits(PULSE_QHEART_ISSUER, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const PULSE::GetAutoStats_output stats = ctl.getAutoStats(); + EXPECT_EQ(stats.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(static_cast(stats.maxAutoTicketsPerUser), 0u); +} + +// Report auto participation counts through the public stats API. +TEST(ContractPulse_Public, GetAutoStatsReportsParticipantCount) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + + const id userA = id::randomValue(); + const id userB = id::randomValue(); + ctl.transferQHeart(issuance, userA, ticketPrice); + ctl.transferQHeart(issuance, userB, ticketPrice); + + EXPECT_EQ(ctl.depositAutoParticipation(userA, ticketPrice, 1, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.depositAutoParticipation(userB, ticketPrice, 1, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const PULSE::GetAutoStats_output stats = ctl.getAutoStats(); + EXPECT_EQ(stats.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(static_cast(stats.autoParticipantsCounter), 2u); +} + +// Ensure balance getter reflects actual QHeart wallet holdings. +TEST(ContractPulse_Public, GetBalanceReportsQHeartWalletBalance) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + ctl.transferQHeart(issuance, ctl.pulseSelf(), 12345); + EXPECT_EQ(ctl.getBalance().balance, 12345u); +} + +// Report empty winner history before any draws. +TEST(ContractPulse_Public, GetWinnersReportsEmptyWhenNoWinners) +{ + ContractTestingPulse ctl; + const PULSE::GetWinners_output winners = ctl.getWinners(); + EXPECT_EQ(winners.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(winners.winnersCounter, 0u); +} + +// Confirm winner history records paid prizes. +TEST(ContractPulse_Public, GetWinnersReportsPaidTickets) +{ + ContractTestingPulse ctl; + ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + + EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 0, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, 1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + ctl.endEpoch(); + + ctl.setDateTime(2025, 1, 9, 12); + ctl.beginEpoch(); + + ctl.transferQHeart(issuance, ctl.pulseSelf(), 10000); + const m256i digest(0x2222ULL, 0x3333ULL, 0x4444ULL, 0x5555ULL); + etalonTick.prevSpectrumDigest = digest; + const Array& winning = deriveWinningDigits(ctl, digest); + const uint8 missing = findMissingDigit(winning); + + const Array ticketA = + makePlayerDigits(winning.get(0), winning.get(1), winning.get(2), winning.get(3), winning.get(4), winning.get(5)); + const Array ticketB = + makePlayerDigits(winning.get(0), winning.get(1), winning.get(2), missing, missing, missing); + + const id playerA = id::randomValue(); + const id playerB = id::randomValue(); + ctl.transferQHeart(issuance, playerA, 1); + ctl.transferQHeart(issuance, playerB, 1); + EXPECT_EQ(ctl.buyTicket(playerA, ticketA).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.buyTicket(playerB, ticketB).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const uint64 prizeA = ctl.state()->callComputePrize(winning, ticketA); + const uint64 prizeB = ctl.state()->callComputePrize(winning, ticketB); + + ctl.setDateTime(2025, 1, 10, 12); + ctl.forceBeginTick(); + + const PULSE::GetWinners_output& winners = ctl.getWinners(); + EXPECT_EQ(winners.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(winners.winnersCounter, 2u); + EXPECT_EQ(winners.winners.get(0).winnerAddress, playerA); + EXPECT_EQ(winners.winners.get(0).revenue, prizeA); + EXPECT_EQ(winners.winners.get(1).winnerAddress, playerB); + EXPECT_EQ(winners.winners.get(1).revenue, prizeB); +} + +// ============================================================================ +// SYSTEM PROCEDURES +// ============================================================================ + +// Ensure epoch start repairs defaults and opens selling. +TEST(ContractPulse_System, BeginEpochRestoresDefaultsAndOpensSelling) +{ + ContractTestingPulse ctl; + ctl.state()->setScheduleInternal(0); + ctl.state()->setDrawHourInternal(0); + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + EXPECT_EQ(ctl.state()->getScheduleInternal(), PULSE_DEFAULT_SCHEDULE); + EXPECT_EQ(ctl.state()->getDrawHourInternal(), PULSE_DEFAULT_DRAW_HOUR); + EXPECT_TRUE(ctl.state()->isSelling()); +} + +// BeginEpoch should auto-buy tickets from stored deposits. +TEST(ContractPulse_System, BeginEpochProcessesAutoParticipants) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amount = static_cast(ticketPrice * 2); + ctl.transferQHeart(issuance, user, amount); + + EXPECT_EQ(ctl.depositAutoParticipation(user, amount, 2, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + etalonTick.prevSpectrumDigest = m256i(0xDEADULL, 0xBEEFULL, 0xFADEULL, 0xCAFEULL); + + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + EXPECT_EQ(ctl.state()->getTicketCounter(), 2u); + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::INVALID_VALUE)); +} + +// Auto-buy should leave remaining deposit when it is larger than the ticket cost. +TEST(ContractPulse_System, BeginEpochAutoParticipationLeavesRemainingDeposit) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + const id user = id::randomValue(); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + const sint64 amount = static_cast(ticketPrice * 3); + ctl.transferQHeart(issuance, user, static_cast(amount)); + + EXPECT_EQ(ctl.depositAutoParticipation(user, amount, 2, false).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + etalonTick.prevSpectrumDigest = m256i(0x1111ULL, 0x2222ULL, 0x3333ULL, 0x4444ULL); + + ctl.setDateTime(2025, 1, 10, 12); + ctl.beginEpoch(); + + EXPECT_EQ(ctl.state()->getTicketCounter(), 2u); + const PULSE::GetAutoParticipation_output entry = ctl.getAutoParticipation(user); + EXPECT_EQ(entry.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(entry.deposit, ticketPrice); + EXPECT_EQ(static_cast(entry.desiredTickets), 2u); +} + +// Ensure epoch end applies pending config and clears state. +TEST(ContractPulse_System, EndEpochAppliesPendingChangesAndClearsState) +{ + ContractTestingPulse ctl; + ctl.state()->setTicketCounter(3); + ctl.state()->setLastDrawDateStamp(77); + ctl.state()->nextEpochDataRef().hasNewPrice = true; + ctl.state()->nextEpochDataRef().newPrice = 999; + + ctl.endEpoch(); + EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); + EXPECT_EQ(ctl.state()->getLastDrawDateStamp(), 0u); + EXPECT_FALSE(ctl.state()->isSelling()); + EXPECT_EQ(ctl.state()->getTicketPriceInternal(), 999u); +} + +// Ensure draw is skipped before the configured draw hour. +TEST(ContractPulse_System, BeginTickSkipsBeforeDrawHour) +{ + ContractTestingPulse ctl; + ctl.state()->setDrawHourInternal(23); + + const id player = id::randomValue(); + ctl.state()->setTicketDirect(0, player, makePlayerDigits(0, 1, 2, 3, 4, 5)); + ctl.state()->setTicketCounter(1); + + ctl.setDateTime(2025, 1, 10, 12); + const uint32 lastStampBefore = ctl.state()->getLastDrawDateStamp(); + ctl.forceBeginTick(); + + EXPECT_EQ(ctl.state()->getTicketCounter(), 1u); + EXPECT_EQ(ctl.state()->getLastDrawDateStamp(), lastStampBefore); +} + +// Skip draws on non-scheduled days (excluding Wednesday fallback). +TEST(ContractPulse_System, BeginTickSkipsWhenNotScheduledDay) +{ + ContractTestingPulse ctl; + ctl.state()->setDrawHourInternal(1); + + const id player = id::randomValue(); + ctl.state()->setTicketDirect(0, player, makePlayerDigits(0, 1, 2, 3, 4, 5)); + ctl.state()->setTicketCounter(1); + + const Array beforeWinning = ctl.state()->getLastWinningDigits(); + + ctl.setDateTime(2025, 1, 11, 12); + ctl.forceBeginTick(); + + EXPECT_EQ(ctl.state()->getTicketCounter(), 1u); + for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + EXPECT_EQ(ctl.state()->getLastWinningDigits().get(i), beforeWinning.get(i)); + } +} + +// Validate scheduled draw trigger path. +TEST(ContractPulse_System, BeginTickRunsDrawOnScheduledDay) +{ + ContractTestingPulse ctl; + ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + ctl.transferQHeart(issuance, ctl.pulseSelf(), 100000); + + const id player = id::randomValue(); + ctl.state()->setTicketDirect(0, player, makePlayerDigits(0, 1, 2, 3, 4, 5)); + ctl.state()->setTicketCounter(1); + + ctl.setDateTime(2025, 1, 10, 12); // Friday + ctl.forceBeginTick(); + + EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); + EXPECT_TRUE(ctl.state()->isSelling()); + expectWinningDigitsInRange(ctl.state()->getLastWinningDigits()); + + const PULSE::GetWinningDigits_output win = ctl.getWinningDigits(); + for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + EXPECT_EQ(win.digits.get(i), ctl.state()->getLastWinningDigits().get(i)); + } +} + +// Exercise multi-round lifecycle across multiple players. +TEST(ContractPulse_Gameplay, MultipleRoundsMultiplePlayers) +{ + ContractTestingPulse ctl; + ctl.state()->setTicketPriceInternal(10); + + ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(100000000); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.transferQHeart(issuance, ctl.pulseSelf(), 10000000); + EXPECT_EQ(ctl.getBalance().balance, 10000000); + + struct RoundDates + { + uint8 startDay; + uint8 drawDay; + }; + static constexpr RoundDates rounds[] = { + {9, 10}, // Thu -> Fri + {11, 12}, // Sat -> Sun + {14, 15}, // Tue -> Wed + }; + + for (uint32 r = 0; r < 3; ++r) + { + ctl.setDateTime(2025, 1, rounds[r].startDay, 12); + ctl.beginEpoch(); + + const m256i digest(0x1111ULL + r, 0x2222ULL + r, 0x3333ULL + r, 0x4444ULL + r); + etalonTick.prevSpectrumDigest = digest; + const Array& winning = deriveWinningDigits(ctl, digest); + + const uint8 missing = findMissingDigit(winning); + const Array tickets[] = { + makePlayerDigits(winning.get(0), winning.get(1), winning.get(2), winning.get(3), winning.get(4), winning.get(5)), + makePlayerDigits(winning.get(1), winning.get(2), winning.get(3), winning.get(4), winning.get(5), winning.get(6)), + makePlayerDigits(winning.get(2), winning.get(3), winning.get(4), winning.get(5), winning.get(6), winning.get(7)), + makePlayerDigits(missing, winning.get(0), winning.get(2), winning.get(4), winning.get(6), winning.get(8)), + }; + + struct PlayerCheck + { + id player; + uint64 balanceAfterBuy; + uint64 expectedPrize; + }; + std::vector players; + players.reserve(4); + + for (const auto& ticketDigits : tickets) + { + const id player = id::randomValue(); + ctl.transferQHeart(issuance, player, ticketPrice); + const PULSE::BuyTicket_output out = ctl.buyTicket(player, ticketDigits); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + PlayerCheck info{}; + info.player = player; + info.balanceAfterBuy = ctl.qheartBalanceOf(player); + info.expectedPrize = ctl.state()->callComputePrize(winning, ticketDigits); + players.push_back(info); + } + + ctl.setDateTime(2025, 1, rounds[r].drawDay, 12); + ctl.forceBeginTick(); + + EXPECT_EQ(ctl.state()->getTicketCounter(), 0u); + for (uint64 i = 0; i < PULSE_WINNING_DIGITS; ++i) + { + EXPECT_EQ(ctl.state()->getLastWinningDigits().get(i), winning.get(i)); + } + + for (const auto& info : players) + { + EXPECT_EQ(ctl.qheartBalanceOf(info.player), info.balanceAfterBuy + info.expectedPrize); + } + } +} + +// Guard pro-rata payout logic when balance is short. +TEST(ContractPulse_Gameplay, ProRataPayoutWhenBalanceInsufficient) +{ + ContractTestingPulse ctl; + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(2000000); + + EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, 0, 0, 0, 0).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, 1).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + ctl.endEpoch(); + + ctl.setDateTime(2025, 1, 9, 12); + ctl.beginEpoch(); + + static constexpr uint64 preFund = 1000; + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + ctl.transferQHeart(issuance, ctl.pulseSelf(), preFund); + + const m256i digest(0x1234ULL, 0x5678ULL, 0x9ABCULL, 0xDEF0ULL); + etalonTick.prevSpectrumDigest = digest; + const Array& winning = deriveWinningDigits(ctl, digest); + const uint8 missing = findMissingDigit(winning); + + const Array ticketA = + makePlayerDigits(winning.get(0), winning.get(1), winning.get(2), winning.get(3), winning.get(4), winning.get(5)); + const Array ticketB = + makePlayerDigits(winning.get(0), winning.get(2), winning.get(4), winning.get(6), winning.get(8), missing); + + const id playerA = id::randomValue(); + const id playerB = id::randomValue(); + ctl.transferQHeart(issuance, playerA, ticketPrice); + ctl.transferQHeart(issuance, playerB, ticketPrice); + EXPECT_EQ(ctl.buyTicket(playerA, ticketA).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.buyTicket(playerB, ticketB).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const uint64 balanceAfterBuyA = ctl.qheartBalanceOf(playerA); + const uint64 balanceAfterBuyB = ctl.qheartBalanceOf(playerB); + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + const uint64 prizeA = ctl.state()->callComputePrize(winning, ticketA); + const uint64 prizeB = ctl.state()->callComputePrize(winning, ticketB); + const uint64 totalPrize = prizeA + prizeB; + ASSERT_GT(totalPrize, contractBefore); + + const uint64 expectedA = (prizeA * contractBefore) / totalPrize; + const uint64 expectedB = (prizeB * contractBefore) / totalPrize; + + ctl.setDateTime(2025, 1, 10, 12); + ctl.forceBeginTick(); + + EXPECT_EQ(ctl.qheartBalanceOf(playerA), balanceAfterBuyA + expectedA); + EXPECT_EQ(ctl.qheartBalanceOf(playerB), balanceAfterBuyB + expectedB); + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), contractBefore - (expectedA + expectedB)); +} + +// Validate fee distribution to dev, shareholders, and QHeart wallet. +TEST(ContractPulse_Gameplay, FeesDistributedToDevShareholdersAndQHeartWallet) +{ + ContractTestingPulse ctl; + const id shareholder = id::randomValue(); + ctl.issuePulseSharesTo(shareholder, NUMBER_OF_COMPUTORS); + + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(1000000); + static constexpr uint8 devPercent = 10; + static constexpr uint8 burnPercent = 0; + static constexpr uint8 shareholdersPercent = 10; + static constexpr uint8 qheartPercent = 10; + const uint64 ticketPrice = static_cast(NUMBER_OF_COMPUTORS) * 10; + + EXPECT_EQ(ctl.setFees(PULSE_QHEART_ISSUER, devPercent, burnPercent, shareholdersPercent, qheartPercent).returnCode, + static_cast(PULSE::EReturnCode::SUCCESS)); + EXPECT_EQ(ctl.setPrice(PULSE_QHEART_ISSUER, ticketPrice).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + ctl.endEpoch(); + + ctl.setDateTime(2025, 1, 9, 12); + ctl.beginEpoch(); + + const id player = id::randomValue(); + ctl.transferQHeart(issuance, player, ticketPrice); + EXPECT_EQ(ctl.buyTicket(player, makePlayerDigits(0, 1, 2, 3, 4, 5)).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const id devWallet = ctl.state()->getTeamAddressInternal(); + EXPECT_NE(devWallet, shareholder); + EXPECT_NE(devWallet, PULSE_QHEART_ISSUER); + + const uint64 devBefore = ctl.qheartBalanceOf(devWallet); + const uint64 shareholderBefore = ctl.qheartBalanceOf(shareholder); + const uint64 qheartWalletBefore = ctl.qheartBalanceOf(PULSE_QHEART_ISSUER); + + ctl.setDateTime(2025, 1, 10, 12); + ctl.forceBeginTick(); + + const uint64 roundRevenue = ticketPrice; + const uint64 expectedDev = (roundRevenue * devPercent) / 100; + const uint64 expectedShareholders = (roundRevenue * shareholdersPercent) / 100; + const uint64 expectedQHeart = (roundRevenue * qheartPercent) / 100; + const uint64 dividendPerShare = expectedShareholders / NUMBER_OF_COMPUTORS; + const uint64 expectedShareholderGain = dividendPerShare * NUMBER_OF_COMPUTORS; + + EXPECT_EQ(expectedShareholderGain, expectedShareholders); + EXPECT_EQ(ctl.qheartBalanceOf(devWallet), devBefore + expectedDev); + EXPECT_EQ(ctl.qheartBalanceOf(shareholder), shareholderBefore + expectedShareholderGain); + EXPECT_EQ(ctl.qheartBalanceOf(PULSE_QHEART_ISSUER), qheartWalletBefore + expectedQHeart); +} + +// Ensure excess balance is swept to QHeart wallet after settlement. +TEST(ContractPulse_Gameplay, QHeartHoldLimitExcessTransferred) +{ + ContractTestingPulse ctl; + ctl.issuePulseSharesTo(id::randomValue(), NUMBER_OF_COMPUTORS); + const ContractTestingPulse::QHeartIssuance& issuance = ctl.issueQHeart(5000000); + const uint64 ticketPrice = ctl.getTicketPrice().ticketPrice; + static constexpr uint64 holdLimit = 100000; + static constexpr uint64 preFund = 500000; + + EXPECT_EQ(ctl.setQHeartHoldLimit(PULSE_QHEART_ISSUER, holdLimit).returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + ctl.endEpoch(); + + ctl.setDateTime(2025, 1, 9, 12); + ctl.beginEpoch(); + + ctl.transferQHeart(issuance, ctl.pulseSelf(), preFund); + + const id player = id::randomValue(); + ctl.transferQHeart(issuance, player, ticketPrice); + const Array digits = makePlayerDigits(0, 1, 2, 3, 4, 5); + const PULSE::BuyTicket_output out = ctl.buyTicket(player, digits); + EXPECT_EQ(out.returnCode, static_cast(PULSE::EReturnCode::SUCCESS)); + + const m256i digest(0x11112222ULL, 0x33334444ULL, 0x55556666ULL, 0x77778888ULL); + etalonTick.prevSpectrumDigest = digest; + const Array& winning = deriveWinningDigits(ctl, digest); + const uint64 prize = ctl.state()->callComputePrize(winning, digits); + + const uint64 walletBefore = ctl.qheartBalanceOf(PULSE_QHEART_ISSUER); + const uint64 contractBefore = ctl.qheartBalanceOf(ctl.pulseSelf()); + + const PULSE::GetFees_output fees = ctl.getFees(); + const uint64 roundRevenue = ticketPrice; + const uint64 devAmount = (roundRevenue * fees.devPercent) / 100; + const uint64 burnAmount = (roundRevenue * fees.burnPercent) / 100; + const uint64 shareholdersAmount = (roundRevenue * fees.shareholdersPercent) / 100; + const uint64 qheartAmount = (roundRevenue * fees.qheartPercent) / 100; + const uint64 dividendPerShare = shareholdersAmount / NUMBER_OF_COMPUTORS; + const uint64 shareholdersPaid = dividendPerShare * NUMBER_OF_COMPUTORS; + const uint64 feesTotal = devAmount + burnAmount + shareholdersPaid + qheartAmount; + + const uint64 balanceAfterFees = contractBefore - feesTotal; + ASSERT_GE(balanceAfterFees, prize); + const uint64 balanceAfterPrizes = balanceAfterFees - prize; + const uint64 excess = (balanceAfterPrizes > holdLimit) ? (balanceAfterPrizes - holdLimit) : 0; + const uint64 expectedContractAfter = balanceAfterPrizes - excess; + const uint64 expectedWalletAfter = walletBefore + qheartAmount + excess; + + ctl.setDateTime(2025, 1, 10, 12); + ctl.forceBeginTick(); + + EXPECT_EQ(ctl.qheartBalanceOf(ctl.pulseSelf()), expectedContractAfter); + EXPECT_EQ(ctl.qheartBalanceOf(PULSE_QHEART_ISSUER), expectedWalletAfter); +} diff --git a/test/test.vcxproj b/test/test.vcxproj index 2581a0c63..86480b1a2 100644 --- a/test/test.vcxproj +++ b/test/test.vcxproj @@ -1,4 +1,4 @@ - + @@ -123,6 +123,7 @@ + diff --git a/test/test.vcxproj.filters b/test/test.vcxproj.filters index 43a188b75..d2ed31452 100644 --- a/test/test.vcxproj.filters +++ b/test/test.vcxproj.filters @@ -47,6 +47,7 @@ +