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