Cold-Wallet Prize Routing

QuantumRaffle V2 lets a hot wallet pay for an entry while routing all grand-prize and adoption-bonus payouts for that entry to a separate cold-wallet address. This is the operational pattern recommended for AI/bot operators that need an always-online signer for entries but want winnings held by a more secure wallet.

How it is set

The override is set at entry time by sending ETH with the cold-wallet address as raw calldata (exactly 20 bytes). The contract's fallback() validates the calldata length, decodes the address, and stores the override:

fallback() external payable nonReentrant {
    if (msg.data.length != 20) revert InvalidRecipientCalldata();
    address recipient = address(bytes20(msg.data));
    if (recipient == address(0)) revert InvalidPrizeRecipient();
    _processEntry(recipient);
}

receive() is the original zero-calldata path and behaves exactly as before — no override, prizes go to entrants[gameId][entrantId] (the sender). fallback() is the only way to set an override; any calldata length other than 20 bytes reverts with InvalidRecipientCalldata, so malformed inputs cannot silently lose prizes to an unintended address.

Where the override lives

mapping(uint256 _gameId => mapping(uint256 _entrantId => address)) public prizeRecipient;

function getPrizeRecipient(uint256 _gameId, uint256 _entrantId) public view returns (address) {
    address override_ = prizeRecipient[_gameId][_entrantId];
    if (override_ != address(0)) return override_;
    return entrants[_gameId][_entrantId];
}

getPrizeRecipient is the canonical accessor. Both claimPrize, the batch-claim path, and the single-claim path resolve the payout target through it before sending ETH and before crediting leaderboard[].

What still belongs to the hot wallet

Only prize payouts redirect. Concretely, the contract still credits the hot wallet for:

  • entrants[gameId][entrantId] — the address recorded for ticket ownership
  • participantRecord[msg.sender] — total ETH contributed across all games
  • Refund of any dust (msg.value % entryAmount)

The cold wallet receives:

  • The grand prize on claimPrize (ETH and leaderboard[] credit)
  • The adoption bonus on every successful claim (ETH and the ClaimedAdoptionBonus event's indexed recipient)

Per-entry granularity

The override is keyed on (gameId, entrantId). A single transaction that buys multiple entries sets the same override on every one of those entries (one PrizeRecipientSet event per entrant id). A subsequent transaction from the same hot wallet without the calldata override leaves the new entries with no override (prizes default to the hot wallet).

Threat model and limits

  • The override is set once, at entry time and cannot be changed afterward. There is no setter to rotate or revoke it.
  • A hot wallet that mis-types the cold-wallet address has paid for an entry whose prizes are bound to that wrong address; the contract has no way to recover them. Frontends should validate the address client-side.
  • An attacker cannot force a recipient onto someone else's entry — the override is set in the same call that records the entry, by msg.sender of that call.

Events and errors

Event When
PrizeRecipientSet(gameId, entrantId, payer, recipient) Once per overridden entry, emitted from _processEntry
PrizeClaimed(gameId, entrantId, prize) On grand-prize claim — prize is sent to the override if set
ClaimedAdoptionBonus(gameId, recipient, entrantId, cohortId, prize) On adoption-bonus claim — recipient is the override if set
Error Cause
InvalidRecipientCalldata fallback() invoked with calldata length ≠ 20
InvalidPrizeRecipient Decoded recipient is address(0)

Backwards compatibility

If no entry ever uses the override, prizeRecipient[gameId][entrantId] stays at the zero address and getPrizeRecipient falls back to entrants[]. The contract behaves identically to a build without the feature: every payout flows to the address that bought the ticket, and no PrizeRecipientSet events are emitted.

results matching ""

    No results matching ""