Weekly lottery driven by Synthetix perps trading fees. Ethereum mainnet · Solidity · Foundry · UUPS upgradeable
Snaxpot is an epoch-based weekly lottery whose jackpot is denominated in USDT on Ethereum mainnet. An off-chain operator derives lottery tickets from Synthetix perpetual-futures trading fees, commits a Merkle root of all tickets on-chain, and triggers a Chainlink VRF draw. Only the top-tier jackpot (5 balls + Snax ball) is settled on-chain; all smaller prize tiers are resolved off-chain.
Epochs run concurrently: a new epoch opens each week while the previous epoch's drawing and resolution pipeline completes in the background.
This repository contains only the on-chain contracts. The off-chain ticket engine, dashboard, and distribution service are maintained separately.
┌──────────────────────────────────────────────┐
│ Snaxpot (UUPS) │
│ - Epoch lifecycle │
│ - Merkle root storage │
│ - Chainlink VRF integration │
│ - Jackpot accounting & rollover │
│ - Small-prize event emission │
│ - Access control (Admin / Operator) │
└──────────┬───────────────────┬───────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌────────────────────┐
│ JackpotClaimer │ │ PrizeDistributor │
│ (claiming) │ │ (small winners) │
│ │ │ │
│ Jackpot winner │ │ Standalone. │
│ calls claim() │ │ Distributes via │
│ to withdraw │ │ external deposit │
│ USDT │ │ contract. │
└─────────────────┘ └────────────────────┘
| Contract | Upgradeability | Purpose |
|---|---|---|
| Snaxpot | UUPS proxy | Main lottery logic — epochs, VRF, Merkle verification, jackpot settlement |
| JackpotClaimer | UUPS proxy | Escrow for jackpot winnings; winner calls claim() to withdraw USDT |
| PrizeDistributor | TBD | Standalone USDT pool for small-prize distribution via external deposit contract. No interaction with Snaxpot or JackpotClaimer. |
Two roles minimum, enforced via OpenZeppelin AccessControl (or equivalent):
| Role | Permissions |
|---|---|
| ADMIN | Upgrade contracts (UUPS), grant/revoke roles, set protocol parameters (VRF config, fee splits), pause/unpause, rescueToken (non-USDT only) |
| OPERATOR | openEpoch, closeEpoch, commitMerkleRoot, drawJackpot, resolveJackpot, resolveSmallPrizes, logTickets |
Admin is the only role that can change Operator addresses. Operator cannot self-escalate.
An epoch is the fundamental time unit — one week of trading mapped to one draw. Multiple epochs can be in-flight simultaneously: a new epoch opens as soon as the previous one closes, while the previous epoch is still going through its drawing and resolution pipeline.
Each epoch progresses through its own independent state machine:
OPEN ──▶ CLOSED ──▶ DRAWING ──▶ RESOLVED
| State | Description |
|---|---|
OPEN |
Epoch is active. VRF seed generated at open. Off-chain engine uses this seed to derive tickets from trading activity during the epoch window. |
CLOSED |
Operator has explicitly closed the epoch. No more trading activity counts. Operator must commit the Merkle root. |
DRAWING |
Merkle root committed and drawJackpot() called. Waiting for Chainlink VRF callback with winning numbers. |
RESOLVED |
Winning numbers posted. Jackpot either claimed or rolled over. Small prizes emitted. Epoch is finalised. |
The contract tracks each epoch's state independently via its epochId. There is no global "contract state" that blocks new epochs from opening while an old one is resolving.
Typical steady-state timeline:
Week N Week N+1 Week N+2
├──── Epoch N: OPEN ────────────┤ │
│ trading activity counted │ │
│ ├──── Epoch N+1: OPEN ─────────┤
│ │ trading activity counted │
│ │ ├── Epoch N+2: OPEN ──▶
│ │ │
├─ Epoch N: CLOSED ─────────────┼───────────────────────────────┤
│ (operator posts root, │ │
│ draws, resolves — can │ │
│ overlap with N+1 OPEN) │ │
│ ├─ DRAWING ─┤ │ │
│ ├─ RESOLVED │ │
Key rules:
openEpoch()opens a new epoch immediately. It does not require the previous epoch to be resolved.- Only one epoch can be
OPENat a time (the current collection window). The operator must explicitly close the current epoch before (or atomically when) opening a new one. - Multiple epochs can be in
CLOSED,DRAWING, orRESOLVEDstates simultaneously — the drawing/resolution pipeline for epoch N runs in parallel with epoch N+1 beingOPEN. - The contract tracks
currentEpochIdto identify which epoch is currently accepting trading activity (theOPENone).
1. Operator calls openEpoch()
→ Requires no epoch currently OPEN (operator must close the previous one first)
→ New epoch state: OPEN
→ Chainlink VRF request fires → seed stored on callback
→ event EpochOpened(epochId, vrfSeed, startTimestamp)
[Meanwhile, prior epoch N-1 may still be in CLOSED/DRAWING/RESOLVED pipeline]
2. [~1 week passes — off-chain engine accumulates tickets for current epoch using vrfSeed]
3. Operator calls closeEpoch(epochId)
→ Requires epoch state == OPEN
→ Snapshots currentJackpot into epoch.jackpotAmount, resets currentJackpot to 0
→ Epoch state → CLOSED
→ event EpochClosed(epochId, closeTimestamp)
Operator can then call openEpoch() separately, or use the convenience function:
closeAndOpenEpoch(epochId)
→ Atomically closes current epoch + opens next epoch in one tx
→ Internally calls closeEpoch() then openEpoch()
Both paths exist so the operator can pause between close and open if needed.
4. Operator calls commitMerkleRoot(epochId, root)
→ Requires epoch state == CLOSED
→ Stores root for that epochId
5. Operator calls drawJackpot(epochId)
→ Requires merkle root committed for that epochId
→ Epoch state → DRAWING
→ Requests Chainlink VRF for winning numbers
→ VRF callback stores winning balls
→ event WinningNumbersDrawn(epochId, balls[5], snaxBall)
6. Operator calls resolveJackpot(epochId, winner, ticket, merkleProof)
— OR —
Operator calls resolveJackpotNoWinner(epochId)
If winner:
→ Verifies merkle proof on-chain
→ Transfers jackpot USDT to JackpotClaimer
→ event JackpotWon(epochId, winner, amount)
If no winner:
→ Jackpot rolls into next epoch's pool
→ event JackpotRolledOver(epochId, rolledAmount)
7. Operator calls resolveSmallPrizes(epochId, totalAmount, winners[])
→ Emits event for off-chain distribution
→ No on-chain USDT transfer for small prizes (handled via PrizeDistributor contract)
→ event SmallPrizesResolved(epochId, totalAmount, winnerCount)
→ Epoch state → RESOLVED
Steps 4-7 happen while the next epoch is already OPEN and collecting tickets.
- At most one epoch in
OPENstate at any time. - Multiple epochs may be in
CLOSED/DRAWINGsimultaneously (abnormal but not forbidden — e.g., operator delays). - An epoch can only transition forward:
OPEN → CLOSED → DRAWING → RESOLVED. No backwards transitions. resolveJackpot/resolveJackpotNoWinnercan only be called once per epoch.- Jackpot rollover credits the currently open epoch (or the next one to be opened if none is open yet).
Two distinct VRF usages per epoch:
| Purpose | When | Output |
|---|---|---|
| Epoch seed | openEpoch() |
Single uint256 seed — published in event, used off-chain for ticket derivation |
| Winning numbers | drawJackpot() |
Derives 5 standard balls + 1 Snax ball |
From the VRF random word(s), derive:
for i in 0..5:
balls[i] = (uint256(keccak256(abi.encode(randomWord, i))) % BALL_MAX) + 1
snaxBall = (uint256(keccak256(abi.encode(randomWord, 5))) % SNAX_BALL_MAX) + 1
BALL_MAX = 32 and SNAX_BALL_MAX = 5. Standard balls range 1–32, Snax ball ranges 1–5. Total combinations: C(32,5) × 5 = 1,015,080.
The 5 standard balls are sorted ascending after derivation to match ticket format (see §8).
subscriptionIdkeyHashcallbackGasLimitrequestConfirmationsnumWords
Anyone (operator, protocol fee router, or external donor) can deposit USDT into Snaxpot at any time, including mid-epoch. This allows the jackpot to grow throughout the week as trading fees are converted and deposited.
function fundJackpot(uint256 amount) external;- Transfers
amountUSDT from caller into the contract viaSafeERC20.safeTransferFrom. - Adds
amounttocurrentJackpot(the live, running total). - Can be called multiple times per epoch — each call increases the pot.
- Emits
JackpotFunded(amount, currentJackpot). - No restrictions on caller or timing (not role-gated). Permissionless deposits let the operator automate funding from a fee-splitter contract.
uint256 public currentJackpot; // live running total, grows with every fundJackpot() call
mapping(uint256 => uint256) public epochJackpot; // frozen snapshot for a specific drawcurrentJackpot is the real-time jackpot amount. It increases on every fundJackpot() call and on rollovers. The dashboard displays this value.
epochJackpot[epochId] is set once, at the moment closeEpoch(epochId) is called. It snapshots currentJackpot at close time and resets currentJackpot to 0:
closeEpoch(epochId):
epochJackpot[epochId] = currentJackpot
currentJackpot = 0
Any USDT deposited after the snapshot (i.e., after closeEpoch() but before or during the next epoch) goes into the next epoch's pot automatically since it adds to currentJackpot which starts fresh at 0.
If resolveJackpotNoWinner(epochId) is called:
currentJackpot += epochJackpot[epochId]
epochJackpot[epochId] = 0 // or leave as historical record and track rollover separately
The unclaimed amount flows back into currentJackpot, stacking on top of any new deposits that arrived since close. No USDT moves — it's purely internal accounting.
On resolveJackpot(epochId, ...):
- Verifies the winning ticket via Merkle proof (see §8).
- Transfers
epochJackpot[epochId]in USDT toJackpotClaimer. - Records the claim entitlement:
JackpotClaimer.credit(winner, amount). epochJackpot[epochId]marked as claimed.
currentJackpot is unaffected — it already had the snapshot subtracted at close time and may have new deposits accumulating for the next draw.
Mon Tue Wed Thu Fri Sat Sun │ Mon (next week)
│
fundJackpot(100) │
currentJackpot = 100 │
fundJackpot(50) │
currentJackpot = 150 │
fundJackpot(200) │
currentJackpot = 350 │
│
── epoch closes ── │
closeEpoch(N) │
epochJackpot[N]=350 │
currentJackpot = 0 │
│
fundJackpot(80) │ ← already counts toward epoch N+1
currentJackpot │= 80
│
resolveJackpotNoWinner │(N)
currentJackpot = 430 │ (80 new + 350 rolled over)
Rejecting native ETH: The contract has no receive() or fallback() function (or they explicitly revert). Any direct ETH transfer will fail.
Non-USDT ERC-20 tokens: There is no on-chain mechanism to prevent arbitrary ERC-20 tokens from being transferred to the contract — ERC-20 transfer() executes on the token contract with no receiver callback. Non-USDT tokens sent to the contract are effectively stuck. An admin-only rescueToken(address token, address to, uint256 amount) function allows recovering accidentally sent tokens. Hardcoded to reject USDT — there is no admin function that can withdraw USDT from the contract. The only way to extract USDT is through the normal lottery flow (resolveJackpot → JackpotClaimer → claim). If USDT recovery is ever truly needed (e.g., contract migration), it requires a UUPS upgrade to deploy new logic.
function rescueToken(address token, address to, uint256 amount) external onlyAdmin {
require(token != address(usdt), "cannot withdraw USDT");
SafeERC20.safeTransfer(IERC20(token), to, amount);
}USDT reconciliation — reconciling direct transfers: If someone sends USDT directly to the contract (via usdt.transfer(snaxpot, amount) instead of fundJackpot()), the contract has no callback to detect it. An admin-only reconcileUSDT() function reconciles the actual USDT balance against internal accounting and credits the difference to the jackpot:
uint256 public totalAccountedUSDT; // sum of all tracked USDT (jackpot + epoch snapshots + pending payouts)
function reconcileUSDT() external onlyAdmin {
uint256 actual = usdt.balanceOf(address(this));
uint256 surplus = actual - totalAccountedUSDT;
if (surplus > 0) {
currentJackpot += surplus;
totalAccountedUSDT += surplus;
emit JackpotFunded(surplus, currentJackpot);
}
}- Admin-only.
totalAccountedUSDTis updated on everyfundJackpot(),closeEpoch(),resolveJackpot(), and rollover to stay in sync.
USDT quirks:
transfer/transferFromdo not returnboolon mainnet.- Use OpenZeppelin
SafeERC20for all USDT interactions. - Use
SafeERC20.forceApprove()for all approvals.
Simple escrow allowing verified jackpot winners to claim.
// Simplified interface
function credit(address winner, uint256 amount) external; // only callable by Snaxpot
function claim() external; // winner withdraws their USDT
function claimableBalance(address user) external view returns (uint256);
function sweepExpired(address winner) external; // admin-only, reclaims expired credits- USDT transferred in from Snaxpot on
credit(). Each credit records a timestamp. - Winner calls
claim()to withdraw their USDT at any time within the claim window. - 3-month expiry: unclaimed credits expire after 90 days. Admin calls
sweepExpired()to return expired funds toSnaxpot(credited back tocurrentJackpot). - One active credit per winner per epoch.
We use OpenZeppelin's @openzeppelin/merkle-tree (StandardMerkleTree) for both off-chain tree construction and on-chain proof verification. This means all leaf hashing follows OZ's convention: double-hashed with abi.encode (not abi.encodePacked).
Each ticket is one leaf. The leaf value stored in the tree is:
leaf = keccak256(bytes.concat(keccak256(abi.encode(
trader, // address — ticket owner
epochId, // uint256 — epoch identifier
balls[0], // uint8 — sorted ascending
balls[1], // uint8
balls[2], // uint8
balls[3], // uint8
balls[4], // uint8
snaxBall, // uint8
ticketIndex // uint256 — unique index within trader's tickets for this epoch
))))
Why double-hash? OpenZeppelin double-hashes leaves (keccak256(bytes.concat(keccak256(...)))) so that leaf hashes can never collide with internal node hashes (second preimage resistance). The inner hash produces the raw digest; the outer hash domain-separates it from branch nodes.
Why abi.encode (not abi.encodePacked)? abi.encode pads each value to 32 bytes, eliminating ambiguity when adjacent dynamic-length or small-width types are packed together. This matches what StandardMerkleTree does internally.
Ball ordering: The 5 standard balls MUST be sorted in ascending order before hashing. This ensures the same set of numbers always produces the same leaf hash regardless of selection order. The contract enforces sorted order when verifying.
One leaf per ticket: If Alice has 3 tickets, the tree contains 3 separate leaves (each with different numbers and a unique ticketIndex). A trader with 500 tickets produces 500 leaves.
The operator builds the tree using StandardMerkleTree from @openzeppelin/merkle-tree:
import { StandardMerkleTree } from "@openzeppelin/merkle-tree";
const leaves = tickets.map(t => [
t.trader, // address
t.epochId, // uint256
t.balls[0], // uint8
t.balls[1], // uint8
t.balls[2], // uint8
t.balls[3], // uint8
t.balls[4], // uint8
t.snaxBall, // uint8
t.ticketIndex, // uint256
]);
const tree = StandardMerkleTree.of(leaves, [
"address", "uint256",
"uint8", "uint8", "uint8", "uint8", "uint8",
"uint8", "uint256"
]);
// tree.root — bytes32 to commit on-chain
// tree.getProof(i) — proof for leaf iStandardMerkleTree handles the double-hashing and sorted-pair internal hashing automatically. Do not manually hash leaves before passing them in — the library does it for you.
If you are not using @openzeppelin/merkle-tree (e.g. building the tree in Go, Rust, Python), you must replicate the exact algorithm. Three things must match or proofs will fail on-chain.
abi.encode pads every value to exactly 32 bytes, big-endian, left-padded with zeroes:
Offset Type Encoding (32 bytes each)
------ -------- -----------------------------------------------
0 address 0x000000000000000000000000 ++ <20-byte address>
32 uint256 <32-byte big-endian epochId>
64 uint8 0x00...00 ++ <1-byte balls[0]> (31 zero bytes + 1)
96 uint8 0x00...00 ++ <1-byte balls[1]>
128 uint8 0x00...00 ++ <1-byte balls[2]>
160 uint8 0x00...00 ++ <1-byte balls[3]>
192 uint8 0x00...00 ++ <1-byte balls[4]>
224 uint8 0x00...00 ++ <1-byte snaxBall>
256 uint256 <32-byte big-endian ticketIndex>
------
Total: 9 × 32 = 288 bytes
innerHash = keccak256(encodedBuffer) // 32 bytes
leafHash = keccak256(innerHash) // 32 bytes — this is the leaf node in the tree
- Compute
leafHashfor every ticket. - Sort all leaf hashes in ascending byte order.
- Build the tree bottom-up. For each pair of sibling nodes
(a, b):- If
a <= b(byte comparison):parent = keccak256(a ++ b)(64-byte input) - If
a > b:parent = keccak256(b ++ a) - i.e. always sort the pair before hashing.
- If
- If a level has an odd number of nodes, the last node is promoted to the next level without hashing.
- The final single node is the Merkle root.
Proof generation: for a given leaf at index i, the proof is the list of sibling hashes from bottom to top. The verifier walks the proof, sorted-pair hashing at each level, and checks that the result equals the root.
- Go:
go-ethereum/accounts/abi— useArguments.Pack(...)which produces identical output to Solidityabi.encode. Usesha3.NewLegacyKeccak256()fromgolang.org/x/crypto/sha3for keccak256. - Rust:
alloyorethabicrate for ABI encoding;tiny-keccakorsha3crate for hashing. - Python:
eth_abi.encode(...)+Web3.keccak(...).
In all cases, verify your implementation against the JS StandardMerkleTree on a small test set before deploying.
- The operator publishes the full tree data (all leaves + proofs) via IPFS or API. The
StandardMerkleTreecan be serialized withtree.dump()and restored withStandardMerkleTree.load(...). - Any user can verify their tickets are in the tree by recomputing their leaf hash and checking the proof against the on-chain root.
- The dashboard shows per-user tickets with Merkle proofs of inclusion.
- During jackpot settlement, the contract verifies winning tickets on-chain using
MerkleProof.verify().
| Scenario | Traders | Avg tickets/trader | Total leaves | Proof depth |
|---|---|---|---|---|
| Small epoch | 500 | 5 | 2,500 | ~12 hashes |
| Medium epoch | 2,000 | 10 | 20,000 | ~15 hashes |
| Large epoch | 5,000 | 20 | 100,000 | ~17 hashes |
The on-chain leaf hash must mirror the OZ double-hash so proofs validate correctly:
function _verifyTicket(
uint256 epochId,
address trader,
uint8[5] calldata balls, // must be sorted ascending
uint8 snaxBall,
uint256 ticketIndex,
bytes32[] calldata proof
) internal view returns (bool) {
require(balls[0] <= balls[1] && balls[1] <= balls[2] &&
balls[2] <= balls[3] && balls[3] <= balls[4], "unsorted");
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(
trader, epochId,
balls[0], balls[1], balls[2], balls[3], balls[4],
snaxBall, ticketIndex
))));
return MerkleProof.verify(proof, epochMerkleRoots[epochId], leaf);
}Critical: both sides must use
abi.encode+ doublekeccak256. If either the off-chain tree or the on-chain verifier uses a different encoding (abi.encodePacked) or a single hash, proofs will fail.
Small prizes (fewer than 5+Snax matching balls) are not settled on-chain within Snaxpot. The flow:
- Operator computes all small-prize winners off-chain.
- Operator calls
resolveSmallPrizes(epochId, ...)on Snaxpot — emits event only, no USDT moves. - Distribution is handled via the PrizeDistributor contract (see §9.1).
Standalone contract. No on-chain interaction with Snaxpot or JackpotClaimer. Holds a USDT pool that the operator draws from to distribute small prizes via an external deposit contract.
Roles:
| Role | Permissions |
|---|---|
| ADMIN / Treasury | fund(), recoverUSDT() |
| OPERATOR (relayer) | distribute() |
Interface:
function fund(uint256 amount) external;
function recoverUSDT(address to, uint256 amount) external onlyAdmin;
function distribute(address[] calldata winners, uint256[] calldata amounts) external onlyOperator;fund(uint256 amount)
- Transfers USDT into the PrizeDistributor from caller. Permissionless (anyone can fund) or admin-only — TBD.
- Emits
PrizeDistributorFunded(amount).
recoverUSDT(address to, uint256 amount)
- Admin/treasury can withdraw USDT from the pool at any time (unlike Snaxpot, this pool is explicitly recoverable).
distribute(address[] winners, uint256[] amounts)
- Operator-only. For each winner, approves and deposits their USDT amount into an external deposit contract (address stored as config, details TBD).
- Does not send USDT directly to winners — it calls the deposit contract on their behalf.
- Emits
PrizeDistributed(winner, amount)per winner.
address public depositContract; // external contract, set by admin
function distribute(address[] calldata winners, uint256[] calldata amounts) external onlyOperator {
for (uint256 i = 0; i < winners.length; i++) {
SafeERC20.forceApprove(usdt, depositContract, amounts[i]);
IDepositContract(depositContract).deposit(winners[i], amounts[i]);
emit PrizeDistributed(winners[i], amounts[i]);
}
}Key properties:
- Fully siloed — no calls to/from Snaxpot or JackpotClaimer.
- Admin can recover all USDT (this is a funded pool, not user escrow).
- Distribution goes through an external deposit contract, not direct transfers to winners.
- Deposit contract interface details TBD.
An optional operator function that emits an event for each ticket. This is purely informational — it modifies no state, holds no funds, and has no effect on the lottery logic. It exists solely to put ticket data on-chain as indexed events for off-chain consumers / block explorers.
struct TicketLog {
address trader;
uint8[5] balls; // sorted ascending
uint8 snaxBall;
uint256 ticketIndex;
}
function logTickets(uint256 epochId, TicketLog[] calldata tickets) external onlyOperator {
for (uint256 i = 0; i < tickets.length; i++) {
emit TicketAdded(
epochId,
tickets[i].trader,
tickets[i].balls,
tickets[i].snaxBall,
tickets[i].ticketIndex
);
}
}- Operator-only. Can be called at any time for any epoch.
- Does not write to storage — the only side effect is event emission.
- The Merkle tree remains the authoritative ticket commitment; these events are a convenience layer.
- Gas cost scales linearly with array length (event emission only, no SSTOREs).
event EpochOpened(uint256 indexed epochId, uint256 vrfSeed, uint256 startTimestamp);
event EpochClosed(uint256 indexed epochId, uint256 closeTimestamp);
event MerkleRootCommitted(uint256 indexed epochId, bytes32 root);
event WinningNumbersDrawn(uint256 indexed epochId, uint8[5] balls, uint8 snaxBall, uint256 vrfRequestId);
event JackpotWon(uint256 indexed epochId, address indexed winner, uint256 amount);
event JackpotRolledOver(uint256 indexed epochId, uint256 rolledAmount);
event SmallPrizesResolved(uint256 indexed epochId, uint256 totalAmount, uint256 winnerCount);
event JackpotFunded(uint256 amount, uint256 newTotal);
event TicketAdded(uint256 indexed epochId, address indexed trader, uint8[5] balls, uint8 snaxBall, uint256 ticketIndex);Key state variables (UUPS-safe, no storage collisions with proxy):
// Epoch tracking
uint256 public currentEpochId;
mapping(uint256 => Epoch) public epochs;
struct Epoch {
EpochState state;
uint256 startTimestamp;
uint256 closeTimestamp; // set when operator calls closeEpoch()
uint256 vrfSeed; // from openEpoch VRF callback
bytes32 merkleRoot;
uint8[5] winningBalls; // sorted ascending
uint8 winningSnaxBall;
uint256 jackpotAmount; // snapshot at draw time
uint256 vrfRequestId; // for winning numbers request
bool jackpotClaimed;
}
enum EpochState { OPEN, CLOSED, DRAWING, RESOLVED }
// Jackpot
uint256 public currentJackpot;
// VRF
mapping(uint256 => uint256) public vrfRequestToEpoch; // requestId → epochId
// Config
address public usdt;
address public jackpotClaimer;Snaxpot inherits UUPSUpgradeable. Only ADMIN role can call upgradeTo() / upgradeToAndCall().
Storage layout must follow OpenZeppelin's upgradeable contract patterns:
- Use
@openzeppelin-contracts-upgradeable. - Storage gaps in base contracts.
- No constructors; use
initialize(). _disableInitializers()in constructor of implementation.
| Risk | Mitigation |
|---|---|
| Operator posts fraudulent Merkle root | Off-chain monitoring; root published to IPFS for public audit; consider commit-reveal or dispute window |
| VRF manipulation | Chainlink VRF V2.5 provides tamper-proof randomness; requestConfirmations ≥ 3 |
| USDT blocklist | Winner's address could be USDT-blocklisted; claim() should handle transfer failure gracefully |
| Re-entrancy on claim | Use checks-effects-interactions; SafeERC20; USDT is not a re-entrant token but guard anyway |
| Stale epoch (operator goes offline) | Admin can force-resolve or pause; consider timeout-based fallback |
| Storage collision on upgrade | Follow OZ upgrade-safe patterns; use storage gaps; run forge inspect |
Front-running resolveJackpot |
Only operator can call; no MEV advantage since winner is deterministic from Merkle proof |
| Compromised operator key / exploit | Admin calls pause() — all operator functions (openEpoch, closeEpoch, commitMerkleRoot, drawJackpot, resolveJackpot, resolveSmallPrizes, logTickets) and fundJackpot are gated with whenNotPaused. Admin can still unpause(), upgrade, or recover funds while paused. claim() on JackpotClaimer remains callable while paused so existing winners can still withdraw. |
| Dependency | Version | Purpose |
|---|---|---|
| OpenZeppelin Contracts Upgradeable | latest stable | AccessControl, UUPSUpgradeable, MerkleProof, SafeERC20 |
| Chainlink VRF V2.5 | latest stable | On-chain verifiable randomness |
| Foundry (forge, cast, anvil) | latest | Build, test, deploy |
├── src/
│ ├── Snaxpot.sol # Main lottery logic (UUPS)
│ ├── JackpotClaimer.sol # Jackpot claim escrow
│ ├── PrizeDistributor.sol # Small prize distribution
│ └── interfaces/
│ ├── ISnaxpot.sol
│ ├── IJackpotClaimer.sol
│ └── IPrizeDistributor.sol
├── script/
│ ├── Deploy.s.sol
│ └── Upgrade.s.sol
├── test/
│ ├── Snaxpot.t.sol
│ ├── JackpotClaimer.t.sol
│ └── mocks/
│ ├── MockVRFCoordinator.sol
│ └── MockUSDT.sol
├── foundry.toml
└── README.md # ← you are here