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 ownershipparticipantRecord[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 andleaderboard[]credit) - The adoption bonus on every successful claim (ETH and the
ClaimedAdoptionBonusevent's indexedrecipient)
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.senderof 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.