Prediction Mining - Architecture & Design Decisions
Created: January 23, 2026 Last Updated: March 7, 2026
This document tracks architectural decisions and optimizations made to ensure the simulator and customer-facing tools scale efficiently.
Smart Contract Architecture
Overview
Prediction Mining is a decentralized prediction game built on the BNB Smart Chain. Players play ETH/BNB on numbered squares, and when a round ends, a winning square is randomly selected. Winners share the pot proportionally to their play amounts. The system integrates with the WELEPHANT/ELEPHANT token ecosystem for rewards and staking.
Design Philosophy
The system is designed to be:
- Rug-proof - No emergency withdraw functions, fee percentages are constants
- Future-proof - On-chain history storage instead of relying on external indexers
- Permissionless - Staking vault has no owner, rewards are fully claimable
- Gas-efficient - Batch processing with gas-checked cursors for large operations
- Upgradable - RaffleMining is stateless; all mutable game state lives in persistent registries that survive upgrades
- Sponsorship-friendly - Supports third-party automation and gifting
Sponsorship Model
The system allows msg.sender to pay on behalf of other players, enabling:
- Gifting - Pay for friends' plays
- Automation - Bots can fund and manage plays for users
- Third-party services - External services can operate on behalf of players
| Operation | Who Pays | Who Benefits | Protection |
|---|---|---|---|
play(players[], ...) |
msg.sender | players[] addresses |
N/A - sponsor loses funds |
registerAutoPlay(player, ...) |
msg.sender | player address |
!active check prevents DoS |
deposit(player) |
msg.sender | player address |
N/A - sponsor loses funds |
Security invariants:
- Withdrawals are self-only:
cancelAutoPlay()andclaimRewards()always usemsg.sender - Sponsorship cannot steal: Sponsors can only give funds, never take
- DoS protection:
registerAutoPlayrequires player has no active auto-play
Contract Diagram
┌─────────────────────────────────────────────────────────────────────────┐
│ USER INTERFACE │
├─────────────────────────────────────────────────────────────────────────┤
│ RaffleMiningViews.sol - Read-only queries with pagination │
└────────────────────────────────┬────────────────────────────────────────┘
│
┌────────────────────────────────▼────────────────────────────────────────┐
│ GAME ENGINE LAYER │
├─────────────────────────────────────────────────────────────────────────┤
│ RaffleMining.sol │ RaffleMiningCrank.sol (CrankCore) │
│ - Player interactions │ - Crank orchestration │
│ - Stateless coordinator │ - Auto-play batch processing │
│ - Configuration │ - Pending plays processing │
│ - Access control │ - Round phase tracking │
│ │ │
│ │ CrankFinalization.sol │
│ │ - Round finalization & entropy │
│ │ - Single winner selection │
│ │ - WELEPHANT/ETH reward distribution │
│ │ - Vault swap processing │
│ │ - Fee distribution │
└─────────────┬───────────────────────────┬───────────────────────────────┘
│ │
┌─────────────▼───────────────────────────▼───────────────────────────────┐
│ STATE STORAGE LAYER │
├─────────────────────────────────────────────────────────────────────────┤
│ GameHistory.sol │ Verification.sol │
│ - Round data │ - Hash-chained entropy accumulation │
│ - Player stats │ - Randomness verification │
│ - Round summaries │ - Finalization results │
│ - Participant tracking │ - Audit trail │
├────────────────────────────┼────────────────────────────────────────────┤
│ AutoPlayRegistry.sol │ PendingPlaysRegistry.sol │
│ - Auto-play structs │ - Pending play queue │
│ - Active user set │ - ETH custody (between rounds) │
│ - Player settings │ │
│ - ETH custody (balances) │ │
└─────────────────────────────────────────────────────────────────────────┘
│
┌─────────────▼───────────────────────────────────────────────────────────┐
│ VAULT LAYER │
├─────────────────────────────────────────────────────────────────────────┤
│ WelephantRewards │ StakingVault │ WelephantVault │ GasChargeVault│
│ - ETH rewards │ - ERC4626 vault │ - WELEPHANT │ - Gas refunds │
│ - WELEPHANT rewards │ - Yield tracking │ liquidity │ - Pool mgmt │
│ - Partial claims │ - APR calc │ - JIT delivery │ │
└─────────────────────────────────────────────────────────────────────────┘
│
┌─────────────▼───────────────────────────────────────────────────────────┐
│ EXTERNAL INTEGRATIONS │
├─────────────────────────────────────────────────────────────────────────┤
│ WelephantSwap.sol │ External Contracts │
│ - ETH ↔ WELEPHANT swaps │ - ELEPHANT token │
│ - Fee collection │ - WELEPHANT wrapper │
│ - UniswapV2 routing │ - PancakeSwap router │
└─────────────────────────────────────────────────────────────────────────┘
Core Contracts
| Contract | Role | Key Features |
|---|---|---|
| RaffleMining.sol | Main game (stateless) | Player plays, configuration, access control — delegates all state to registries |
| RaffleMiningCrank.sol | Crank orchestrator (CrankCore) | Auto-play/pending play batching, round phase tracking, heartbeat |
| CrankFinalization.sol | Finalization & distribution | Round finalization, winner selection, reward distribution, vault swaps, fee distribution |
| GameHistory.sol | State storage | Rounds, stats, summaries (on-chain database) |
| Verification.sol | Randomness | Hash-chained entropy accumulation, provable fairness |
| RaffleMiningViews.sol | Read interface | Pagination, batch queries |
| RaffleMiningLib.sol | Utilities | Pure functions, calculations |
| RaffleMiningTypes.sol | Operational types | Game engine constants, PendingWork struct, RoundPhase enum |
| GameHistoryTypes.sol | History data-model types | RoundSummary, PlayerStats, PlayerRoundInfo, PlayerRoundPerformance |
| RegistryTypes.sol | Registry types | AutoPlay, PlayerAutoPlaySettings, PendingPlay |
Vault Contracts
| Contract | Role | Security |
|---|---|---|
| WelephantRewards.sol | Player rewards | DEPOSITOR_ROLE for credits, partial claims, ReentrancyGuard |
| StakingVault.sol | WELEPHANT staking | ERC4626, no owner (100% permissionless) |
| WelephantVault.sol | WELEPHANT liquidity | Central vault, async swaps, try/catch safety |
| GasChargeVault.sol | Gas refunds | Never reverts (graceful cap), 10% buffer before sweep |
| FeeAccounting.sol | Fee ledger | Async fee tracking, decoupled from transfers |
Registry Contracts (Persistent State)
These contracts hold all mutable game state. They survive RaffleMining upgrades — the gameContract pointer is updated to the new RaffleMining address and all player state (funds, registrations, pending plays) remains intact.
| Contract | Role | Security |
|---|---|---|
| AutoPlayRegistry.sol | Auto-play state + ETH custody | Merges former AutoPlayRegistry ETH balances with auto-play registration state (structs, active user set, player settings). Game-authorized operations only, no direct withdraw (anti-DDoS) |
| PendingPlaysRegistry.sol | Pending play queue + ETH custody | Holds plays submitted after round expiry and their ETH until the crank processes them into the next round. Game-authorized push/pop only |
External Integration
| Contract | Role |
|---|---|
| WelephantSwap.sol | DEX integration (PancakeSwap V2), TWAP oracle, dual-path routing |
| IPcsSnapshotTwapOracle | External TWAP oracle for manipulation-resistant pricing |
Access Control Roles
The system uses OpenZeppelin AccessControl for role-based permissions. Two contracts use Ownable instead (noted below).
AccessControl Roles (9 roles across 7 contracts)
| Role | Contract | Permissions | Granted To |
|---|---|---|---|
DEFAULT_ADMIN_ROLE |
RaffleMining | Grant/revoke all roles, game initialization | Deployer/Multisig |
ADMIN_ROLE |
RaffleMining | Update game parameters (payday chance, min players, gas settings, contract addresses) | Governance |
PAUSER_ROLE |
RaffleMining | Pause/unpause game | Emergency operator |
STORAGE_WRITER_ROLE |
RaffleMining | Write round state, player stats, emit events | RaffleMiningCrank, CrankFinalization |
WRITER_ROLE |
GameHistory | Write round data, player stats, payday fund | RaffleMining |
WRITER_ROLE |
Verification | Initialize/update/finalize round seeds | RaffleMining |
WRITER_ROLE |
FeeAccounting | Record admin and staking fees | CrankFinalization |
DEPOSITOR_ROLE |
WelephantRewards | Credit WELEPHANT rewards (accounting only) | RaffleMining |
DEPOSITOR_ROLE |
GasChargeVault | Deposit/withdraw gas charge ETH | RaffleMining |
DEPOSITOR_ROLE |
WelephantVault | Deposit ETH for swapping | RaffleMining |
CONSUMER_ROLE |
WelephantVault | Withdraw WELEPHANT (JIT delivery) | RaffleMining, WelephantRewards, CrankFinalization |
CRANK_ROLE |
WelephantVault | Trigger swaps, update TWAP oracle | CrankFinalization |
DISTRIBUTOR_ROLE |
FeeAccounting | Distribute accumulated fees | CrankFinalization |
Ownable Contracts
| Contract | Protected Function | Owner |
|---|---|---|
AutoPlayRegistry |
setGameContract() |
Deployer |
PendingPlaysRegistry |
setGameContract() |
Deployer |
AddressMinHeap |
setScore() |
GameHistory (set via constructor msg.sender) |
Role Assignment Flow
Deployer (DEFAULT_ADMIN_ROLE on all AccessControl contracts)
│
├─ RaffleMining
│ ├─ grants STORAGE_WRITER_ROLE → RaffleMiningCrank, CrankFinalization
│ ├─ holds WRITER_ROLE on GameHistory, Verification
│ ├─ holds DEPOSITOR_ROLE on GasChargeVault, WelephantVault, WelephantRewards
│ └─ holds CONSUMER_ROLE on WelephantVault
│
├─ RaffleMiningCrank (CrankCore)
│ ├─ holds STORAGE_WRITER_ROLE on RaffleMining
│ └─ delegates to CrankFinalization via setFinalizationContract()
│
├─ CrankFinalization
│ ├─ holds STORAGE_WRITER_ROLE on RaffleMining
│ ├─ holds WRITER_ROLE on FeeAccounting
│ ├─ holds CONSUMER_ROLE on WelephantVault
│ ├─ holds CRANK_ROLE on WelephantVault
│ ├─ holds DISTRIBUTOR_ROLE on FeeAccounting
│ └─ restricted to CrankCore via onlyCrankCore modifier
│
├─ WelephantRewards
│ └─ holds CONSUMER_ROLE on WelephantVault
│
└─ StakingVault — 100% permissionless (no access control)
Ownership Model & Renouncement
The system is split into two trust domains: a permissionless asset layer and an administered game layer.
Permissionless Contracts (No Admin, No Owner)
These contracts have zero admin functions. No one can modify, pause, or interfere with their operation. They are fully autonomous and immutable post-deployment.
| Contract | Type | Why Permissionless |
|---|---|---|
| StakingVault | ERC4626 vault | Deposit, withdraw, and yield accrual are fully autonomous. No owner, no roles, no kill switch |
| WelephantSwap | DEX router | All addresses are immutable (hardcoded or constructor-set). Swap fees route to StakingVault automatically |
| Multicall3 | Utility | Stateless batch-read helper |
| RaffleMiningViews | Read-only | Pure view functions, no state mutations |
Asset sovereignty: ELEPHANT, WELEPHANT, and stWELEPHANT are permissionless tokens. Once in a player's wallet, no admin can freeze, seize, or block transfers. The swap and staking infrastructure operates without any administrative dependency.
Administered Contracts (AccessControl — Multisig)
Game-layer contracts use OpenZeppelin AccessControl with DEFAULT_ADMIN_ROLE held by a multisig. These require active administration for operational needs.
| Contract | Why Admin Is Needed |
|---|---|
| RaffleMining | Role grants, contract address updates, game parameter tuning |
| WelephantVault | Swap configuration (chunk size, slippage tolerance), swap contract updates |
| WelephantRewards | Vault pointer management (which vault serves WELEPHANT claims) |
| GameHistory | Writer role grants for new game contract versions |
| Verification | Writer role grants for new game contract versions |
| FeeAccounting | Writer/distributor role grants |
| GasChargeVault | Depositor role grants |
What admin CAN do: Adjust game parameters, update contract wiring, grant/revoke roles, pause/unpause the game.
What admin CANNOT do: Change fee percentages (immutable constants), drain player funds, manipulate randomness outcomes, prevent fund withdrawals during pause.
Ownable Contracts (Deployer → Multisig)
Three contracts use OpenZeppelin Ownable for a single admin function each:
| Contract | Protected Function | Owner |
|---|---|---|
| AutoPlayRegistry | setGameContract() |
Deployer → Multisig |
| PendingPlaysRegistry | setGameContract() |
Deployer → Multisig |
| AddressMinHeap | setScore() |
GameHistory (constructor) |
These support the upgrade path: when a new RaffleMining is deployed, the owner updates the gameContract pointer so the registries accept writes from the new contract.
Why Full Renouncement Is Not Appropriate
Renouncing ownership on the game layer would permanently brick critical operational functions:
- Contract upgrades —
setGameContract()on registries could never be called, making RaffleMining non-upgradeable - Role management — New crank or finalization contracts could never be authorized
- Swap resilience — If swap parameters need adjustment (slippage, chunk size) due to market conditions, admin is required
- Vault wiring —
setWelephantVaulton WelephantRewards must be callable to point to a functioning vault
The permissionless asset layer (StakingVault, WelephantSwap) is already fully renounced by design — these contracts were deployed without any admin capability. Players minimize game-layer exposure by claiming rewards frequently, moving assets into the sovereign token layer.
Delegated Admin Checks
RaffleMiningCrank and CrankFinalization do not inherit AccessControl themselves but delegate admin checks through the storage contract:
require(storageContract.hasRole(bytes32(0), msg.sender), "E60"); // DEFAULT_ADMIN_ROLE
This protects setFinalizationContract() on CrankCore, and setCrankCore(), setWelephantVault(), setFeeAccounting() on CrankFinalization.
Cross-Contract Access Control
CrankFinalization uses an onlyCrankCore modifier for functions called by CrankCore during crank execution:
modifier onlyCrankCore() {
require(msg.sender == crankCore, "E60");
_;
}
This restricts finalization, distribution, and vault swap functions to only be callable by the CrankCore contract.
Emergency Pause Behavior
When PAUSER_ROLE calls pause() on RaffleMining, only new-money-in functions are blocked. All fund withdrawal paths remain available:
| Feature | Available When Paused? | Details |
|---|---|---|
| Place plays | No | whenNotPaused — blocks new plays |
| Register Auto-Play | No | whenNotPaused — blocks new registrations |
| Cancel Auto-Play | Yes | Returns remaining deposit to player |
| Claim rewards | Yes | WelephantRewards is a separate contract with no pause dependency |
| Unstake WELEPHANT | Yes | StakingVault is 100% permissionless with no pause dependency |
| Crank processing | Yes | Continues finalizing rounds and distributing rewards |
Design intent: Stop new money entering the game; let all existing money exit through normal paths. The crank keeps running so in-progress rounds can still finalize and credit rewards to winners.
Fee Distribution (Constants)
| Fee | Percentage | Destination |
|---|---|---|
| Admin Fee | 10% | feeCollector address |
| Staking Fee | 20% | StakingVault.fundYield() |
| Payday Fund | 20% | GameHistory.addPaydayFund() (accounting) |
| Winner Pool | 50% | Distributed to winners |
BNB Flow Through the System
This section traces how BNB enters, moves through, and exits the system.
Entry Points
BNB enters through these functions:
| Function | Components | Destination |
|---|---|---|
play() |
Play amount + Gas tax (1%) | GameHistory pot + GasChargeVault |
registerAutoPlay() |
Total plays + Gas tax (1%) | AutoPlayRegistry |
AutoPlayRegistry.deposit() |
Top-up amount | AutoPlayRegistry (player balance) |
Gas Tax Model:
Gas charges use a flat percentage tax on play amounts (1% = 100 basis points). This replaces the earlier per-play gas unit model. The gas tax is simple, predictable, and scales proportionally with play size.
gasCharge = totalPlayAmount × gasTaxBasisPoints / 10000
play() breakdown:
msg.value = playAmount + gasCharge (1% of playAmount)
├─→ gasCharge → GasChargeVault.deposit()
└─→ playAmount → GameHistory (pot accounting)
→ gameFee = playAmount × 10%
registerAutoPlay() breakdown:
totalPlayAmount = amountPerSquare × squares × rounds
msg.value = totalPlayAmount + gasCharge (1% of totalPlayAmount)
└─→ All funds → AutoPlayRegistry.register(player, ...)
(stores auto-play struct + credits ETH balance; debited per round by crank)
AutoPlayRegistry.deposit() breakdown:
msg.value = top-up amount
└─→ AutoPlayRegistry._balances[player] += msg.value
(available for future auto-play rounds)
Storage Locations
| Vault | Purpose | Holds |
|---|---|---|
| GasChargeVault | Operator compensation | Accumulated gas tax (1% of plays) |
| AutoPlayRegistry | Multi-round plays | Auto-play structs, active user set, player settings, ETH balances |
| PendingPlaysRegistry | Between-round plays | Pending play queue + ETH for plays submitted after round expiry |
| WelephantVault | Fee conversion | ETH awaiting swap to WELEPHANT |
| WelephantRewards | Winner claims | Credited rewards (ETH + WELEPHANT) |
Incremental Fee Forwarding (Anti-Front-Running)
Previously, all game fee ETH was deposited to WelephantVault at round finalization, creating a large predictable swap that could be front-run. Now fees are forwarded incrementally:
| Play Type | Fee Forwarding | Timing |
|---|---|---|
| Manual plays | Immediately in _recordPlay() |
Per-play, as plays occur |
| Auto-plays | Batch accumulated, forwarded at end of _processAutoPlays() |
End of crank batch |
| Pending plays | Batch accumulated, forwarded at end of _processPendingPlays() |
End of crank batch |
Flow:
Manual Play:
_recordPlay()
├─ gameHistory.addToRoundPot(roundId, amount, fee) // Track for accounting
└─ welephantVault.depositETH{value: fee}() // Forward immediately
Auto-Play Batch (reads from AutoPlayRegistry):
_processAutoPlays()
├─ Loop: fee = _executeAutoPlay() → accumulate autoPlayBatchAccumulatedFees
└─ End: depositETHToVault(autoPlayBatchAccumulatedFees) // Forward at batch end
Pending Plays Batch (pops from PendingPlaysRegistry):
_processPendingPlays()
├─ Loop: calculate fee → accumulate pendingPlaysBatchAccumulatedFees
└─ End: depositETHToVault(pendingPlaysBatchAccumulatedFees) // Forward at batch end
Why This Prevents Front-Running:
- Deposits are spread throughout the round instead of one large predictable deposit
- Swap timing becomes unpredictable - no single large transaction to target
- At round finalization, fees are already deposited - no finalization-time deposit to front-run
Round Finalization Flow
When a round ends:
Round Pot (e.g., 100 BNB)
│
├─→ Game Fee (10%) = 10 BNB
│ └─→ Already deposited to WelephantVault (incrementally)
│ └─→ Async swap to WELEPHANT
│ └─→ Split:
│ ├─ Admin (10% of fee) → feeCollector
│ ├─ Staking (20% of fee) → StakingVault
│ └─ Payday (20% of remaining) → accumulates
│
└─→ Winners Pool (90%) = 90 BNB
└─→ Proportional to winning square holders
└─→ Per player, based on settings:
├─ loopEthRewards=true → AutoPlayRegistry
└─ loopEthRewards=false → WelephantRewards
Note: At finalization, _finalizeRound() only estimates WELEPHANT for accounting purposes - the fee ETH has already been deposited incrementally during plays.
Winner Distribution
Winners on the winning square share 90% of the pot proportionally:
Your Reward = (Your Play on Square / Total on Square) × Winners Pool
Example: You play 0.1 BNB on Square 5. Total on Square 5 is 0.5 BNB. Round pot is 10 BNB.
- Winners Pool = 10 × 90% = 9 BNB
- Your Share = 9 × (0.1 / 0.5) = 1.8 BNB
Bitmask Storage Model & Uniform Amount Constraint
The Design Compromise
Players select one or more of the 16 squares and allocate a uniform amount per square — the same BNB amount on every selected square. This is a deliberate gameplay compromise that enables significant gas savings:
What players CAN do:
- Select any combination of the 16 squares (1 square, all 16, or anything in between)
- Choose their amount per square (minimum 0.0001 BNB)
- Make one play per round (manual or auto-play)
What players CANNOT do:
- Allocate different amounts to different squares in the same play (e.g., 1 BNB on square 3 and 0.5 BNB on square 7)
This constraint is inherent to the storage model — each player stores a single amountPerSquare value, not an array of per-square amounts. Players who want different allocations across squares would need to use multiple accounts.
Storage Layout
Each player's play in a round is stored as two values:
mapping(address => uint256) playerSquareBitmask; // bit N-1 = square N selected
mapping(address => uint256) playerAmountPerSquare; // uniform amount on each selected square
Encoding: Square N is represented by bit (N-1) in the bitmask. For 16 squares, only bits 0–15 are used.
Example: A player selecting squares 3, 7, and 12 with 0.5 BNB each:
squareBitmask = 0b0000100001000100 (bits 2, 6, 11 set)
amountPerSquare = 0.5 ether
totalPlay = 0.5 × 3 = 1.5 ether
Gas Optimization: 2 SSTOREs vs 32+ Array Pushes
The bitmask model replaces per-square dynamic arrays with two fixed storage slots per player per round:
| Operation | Old Per-Square Arrays | Bitmask Model |
|---|---|---|
| Record player's squares | 1 SSTORE per square (up to 16) | 1 SSTORE (bitmask) |
| Record player's amounts | 1 SSTORE per square (up to 16) | 1 SSTORE (amountPerSquare) |
| Total SSTOREs per play | Up to 32 | 2 |
| Read player's selection | Loop over array | Single SLOAD + bit check |
| Check if player is on square | Array search O(n) | Bit check O(1) |
At ~20,000 gas per new SSTORE, a 16-square play saves approximately 600,000 gas (30 avoided SSTOREs × 20,000 gas).
squareTotals Maintenance
While player data is compact (2 slots), the contract still maintains per-square aggregate totals for reward calculation:
mapping(uint256 => uint256) squareTotals; // square => total BNB on that square
When a play is recorded, squareTotals is updated by iterating the bitmask:
for (uint256 i = 0; i < numSquares; i++) {
if ((squareBitmask & (1 << i)) != 0) {
round.squareTotals[i + 1] += amountPerSquare;
}
}
This is necessary for proportional reward distribution: reward = winnersPool × playerAmountPerSquare / squareTotals[winningSquare].
One Play Per Round
Each player is limited to one play per round (manual or auto-play). This simplifies the bitmask model — the player's squareBitmask and amountPerSquare are written once and never updated within a round. Duplicate play attempts revert with E43. Pending plays that arrive after a player has already played in the round are refunded via depositEthReward.
Exit Points
BNB exits the system through only three paths:
| Exit | Trigger | Recipient |
|---|---|---|
| Claim rewards | WelephantRewards.claimRewards() |
Winner (msg.sender) |
| Cancel auto-play | RaffleMining.cancelAutoPlay() |
Player (balance refund) |
| Gas refunds | Crank execution | Crank operator |
Note: Admin fees and staking fees are distributed as WELEPHANT (after swap), not BNB. The game fee BNB is swapped to WELEPHANT in WelephantVault before distribution.
Complete Flow Diagram
┌─────────────────────────────────────────────────────────────────┐
│ ENTRY (BNB) │
│ │
│ play() ─────────────────┬─→ Gas Tax (1%) → GasChargeVault │
│ └─→ Play Amount → GameHistory pot │
│ │
│ registerAutoPlay() ─────────→ AutoPlayRegistry.credit(player) │
│ │
│ AutoPlayRegistry.deposit() ──→ AutoPlayRegistry (top-up balance) │
│ │
└─────────────────────────────────────────────────────────────────┘
│
↓ Round Ends
┌─────────────────────────────────────────────────────────────────┐
│ FINALIZATION │
│ │
│ Game Fee (10% BNB) ─────────→ WelephantVault │
│ │ │
│ ↓ Async Swap │
│ WELEPHANT (not BNB) │
│ │ │
│ ┌─────────────┼─────────────┐ │
│ ↓ ↓ ↓ │
│ Admin Fee Staking Fee Payday Fund │
│ (WELEPHANT) (WELEPHANT) (WELEPHANT) │
│ │ │ │ │
│ ↓ ↓ ↓ │
│ feeCollector StakingVault Jackpot Pool │
│ │
│ Winners Pool (90% BNB) ─────→ Distribution │
│ │ │
│ ┌─────────────┴─────────────┐ │
│ ↓ ↓ │
│ loopEthRewards=true loopEthRewards=false │
│ │ │ │
│ ↓ ↓ │
│ AutoPlayRegistry WelephantRewards │
│ (BNB for next round) (BNB claimable) │
└─────────────────────────────────────────────────────────────────┘
│
↓
┌─────────────────────────────────────────────────────────────────┐
│ EXIT (BNB only) │
│ │
│ claimRewards() ─────────────→ Winner receives BNB │
│ cancelAutoPlay() ───────────→ Player receives BNB refund │
│ Gas refunds ────────────────→ Crank operator receives BNB │
│ │
└─────────────────────────────────────────────────────────────────┘
Gas Charge Pool Sweep (Excess → Buybacks)
The GasChargeVault is capped at maxGasChargePool (default 10 ETH). When the pool exceeds 110% of the cap (a 10% buffer), the excess above maxGasChargePool is swept to WelephantVault. The excess pads WELEPHANT buybacks — it does not change the economics of the fee split.
GasChargeVault Pool
│
├─ ≤ 110% of cap → No sweep (buffer avoids frequent small sweeps)
│
└─ > 110% of cap → Sweep (pool - cap) to WelephantVault
└─→ Pads WELEPHANT buybacks
└─→ Pool drains back down to cap
Configuration:
uint256 maxGasChargePool = 10 ether; // Cap (adjustable: 0.01–100 ETH)
uint256 sweepThreshold = cap + cap / 10; // 110% of cap triggers sweep
Vault Balance Invariants
The system maintains these invariants:
- GasChargeVault.gasChargePool >= sum of all pending gas refunds (capped at
maxGasChargePool, excess swept to pad buybacks) - AutoPlayRegistry.totalTracked = sum of all player ETH balances
- PendingPlaysRegistry ETH balance = sum of all pending play amounts
- WelephantRewards balances = credited but unclaimed rewards
- WelephantVault ETH balance = pending swaps to WELEPHANT
Rewards System
This section provides a comprehensive overview of how all rewards are calculated and distributed.
Fee Percentages (Constants in RaffleMiningTypes.sol)
| Fee | Percentage | Source | Destination |
|---|---|---|---|
| Game Fee | 10% | Each play amount | Swapped to WELEPHANT |
| Winners Pool | 90% | Each play amount | BNB to winning square |
| Admin Fee | 10% | Of WELEPHANT (from game fee) | feeCollector address |
| Staking Fee | 20% | Of WELEPHANT (from game fee) | StakingVault.fundYield() |
| Payday Fund | 20% | Of WELEPHANT (from game fee) | Accumulates until payday |
| Player Share | 50% | Of WELEPHANT (from game fee) | Winners on winning square |
Complete Reward Calculation Example
For a round with 10 BNB total plays:
TOTAL INPUT: 10 BNB
BNB SPLIT:
├─ Game Fee: 1 BNB (10%) → WelephantVault → swapped to WELEPHANT
└─ Winners Pool: 9 BNB (90%) → distributed to winning square
WELEPHANT FROM 1 BNB SWAP (assume 1 BNB = 10 WELEPHANT):
├─ Admin Fee: 1 WELEPHANT (10%) → feeCollector
├─ Staking Fee: 2 WELEPHANT (20%) → StakingVault (as yield)
├─ Payday Fund: 2 WELEPHANT (20%) → accumulates for payday round
└─ Player Share: 5 WELEPHANT (50%) → distributed to winners
BNB Rewards Distribution
Winners on the winning square share the Winners Pool (90% of pot) proportionally:
Your BNB Reward = (Your Play on Square / Total on Square) × Winners Pool
Distribution paths based on player settings:
| Setting | Path | Use Case |
|---|---|---|
loopEthRewards = false |
WelephantRewards (claimable) | Manual claiming |
loopEthRewards = true |
AutoPlayRegistry (re-entry) | Continuous auto-play |
WELEPHANT Rewards Distribution
Winners receive WELEPHANT proportionally (same formula as BNB):
Your WELEPHANT = (Your Play on Square / Total on Square) × Player Share
Distribution paths based on player settings:
| Setting | Path | Benefit |
|---|---|---|
autoStakeWelephant = false |
WelephantRewards (claimable) | Liquid tokens |
autoStakeWelephant = true |
StakingVault.deposit() | Earn yield immediately |
Auto-Staking Flow:
Winner with autoStakeWelephant = true
│
▼ (Inline in distribution loop)
│
try depositToStakingVaultForPlayer(winner, reward)
│
├─ SUCCESS: Emit WelephantAutoStaked(winner, reward, shares)
│ └─ Winner receives vault shares, earns yield automatically
│
└─ CATCH: depositWelephantReward(winner, reward)
└─ Fallback to claimable reward in WelephantRewards
Design Note: Auto-staking uses try/catch so that any failure mode (insufficient vault liquidity, token transfer issue, staking vault error) gracefully falls back to a claimable reward. No pre-check needed — the try/catch handles everything. Since ETH fees are deposited and swapped throughout the round, the vault almost always has sufficient WELEPHANT by distribution time, so the catch path is rare.
Payday Mechanism
Trigger: 1/625 probability per round (determined by on-chain randomness)
Accumulation (regular rounds):
- 20% of WELEPHANT from each round goes to payday fund
- Fund grows until a payday round is triggered
Distribution (payday round):
- Accumulated fund + regular player share → all to winners
- 90% of accumulated fund distributed, 10% carries over
Example:
Regular rounds: Fund accumulates 2 WELEPHANT each round
After 10 rounds without payday:
paydayFund = 20 WELEPHANT (accumulated)
Round 11 - PAYDAY triggered:
totalForWinners = playerShare + paydayFund
= 5 + 20 = 25 WELEPHANT
All 25 WELEPHANT distributed to winning square players
Carryover: 2.5 WELEPHANT stays for next fund
ETH Looping (Auto-Reinvestment)
When loopEthRewards = true, BNB rewards are credited to AutoPlayRegistry instead of WelephantRewards:
Winner receives BNB reward
│
▼ (Inline in distribution loop)
│
Check loopEthRewards setting
│
├─ YES: creditAutoPlayRegistry(winner, reward)
│ └─ Balance available for next auto-play round
│
└─ NO: depositEthReward(winner, amount)
└─ WelephantRewards.depositEth(winner, amount)
└─ Player claims manually via claimRewards()
Design Note: ETH looping is handled inline in the BNB distribution loop. The ETH
funds are already held by RaffleMining, so creditAutoPlayRegistry is called directly
for each winner — no intermediate queue or flush needed.
Use Case: Enables continuous compounding - winnings automatically fund future plays.
Reward Claiming
Players claim rewards from WelephantRewards:
WelephantRewards.claimRewards()
├─ Transfer ETH balance directly to msg.sender (always succeeds if balance > 0)
│
└─ Withdraw WELEPHANT from WelephantVault
├─ If sufficient liquidity: transfer to msg.sender
└─ If insufficient: WELEPHANT balance restored for retry (ETH still claimed)
Important: Claims support partial completion. If WELEPHANT vault is underfunded, ETH is still delivered and WELEPHANT balance is preserved for a later retry. Claims never revert.
Complete Reward Flow Diagram
┌─────────────────────────────────────────────────────────────────────────┐
│ ROUND FINALIZATION │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Total Pot (e.g., 10 BNB) │
│ │ │
│ ├─► Game Fee (10% = 1 BNB) │
│ │ └─► WelephantVault.depositETH() │
│ │ │ │
│ │ ▼ (Async swap via crank) │
│ │ WELEPHANT │
│ │ │ │
│ │ ┌───────┼───────┬───────────┐ │
│ │ ▼ ▼ ▼ ▼ │
│ │ Admin Staking Payday Players │
│ │ 10% 20% 20% 50% │
│ │ │ │ │ │ │
│ │ ▼ ▼ ▼ ▼ │
│ │ feeColl Staking Fund Winners │
│ │ ector Vault (accum) (proportional) │
│ │ │ │
│ │ ┌───────┴───────┐ │
│ │ ▼ ▼ │
│ │ autoStake=true autoStake=false │
│ │ │ │ │
│ │ ▼ ▼ │
│ │ StakingVault WelephantRewards │
│ │ (shares) (claimable) │
│ │ │
│ └─► Winners Pool (90% = 9 BNB) │
│ │ │
│ ▼ (Proportional to play amounts) │
│ Winners on winning square │
│ │ │
│ ┌───────┴───────┐ │
│ ▼ ▼ │
│ loop=true loop=false │
│ │ │ │
│ ▼ ▼ │
│ AutoPlay Welephant │
│ Deposit Rewards │
│ (re-entry) (claimable) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Liquidity Safety Checks
WELEPHANT distributions handle insufficient liquidity gracefully:
| Location | Mechanism | On Failure |
|---|---|---|
_distributeWelephantToWinners() (inline) |
try/catch around depositToStakingVaultForPlayer |
Fallback to claimable reward |
_finalizeRound() (single winner) |
try/catch around depositToStakingVaultForPlayer |
Fallback to claimable reward |
_distributePendingFees() |
welephantVault.canWithdraw(amount) |
Skip, retry next crank |
WelephantRewards.claimRewards() |
Check vault balance | Return false, player retries |
Auto-stake uses try/catch rather than a pre-check, so any failure mode (insufficient liquidity, token issues, staking vault errors) gracefully falls back to a claimable reward. The crank never reverts due to auto-staking failures. ETH and WELEPHANT rewards are deposited directly per-winner (no batching), eliminating edge cases from batch accumulation.
Async Swap Architecture
Design Philosophy
The system decouples ETH→WELEPHANT swaps from round finalization to ensure resilient, non-blocking game operation:
OLD MODEL (problematic):
Round Ends → Swap ETH→WELEPHANT → Distribute Rewards
↓
If swap fails → GAME BRICKED FOREVER
NEW MODEL (resilient):
Round Ends → Deposit ETH to Vault → Record fees in FeeAccounting
↓
Crank processes swaps async (with try/catch)
↓
Distribute fees when liquidity available
Component Responsibilities
┌─────────────────────────────────────────────────────────────────────────┐
│ ASYNC SWAP FLOW │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Round Finalization (CrankFinalization) │
│ ├── depositETHToVault(gameFee) → ETH sits in WelephantVault │
│ ├── feeAccounting.addAdminFee() → Accounting only, no transfer │
│ └── feeAccounting.addStakingFee() → Accounting only, no transfer │
│ │
│ Crank Processing (CrankFinalization.processVaultSwaps) │
│ ├── welephantVault.update() → Refresh TWAP oracle paths │
│ ├── welephantVault.swapReserves() → ETH→WELEPHANT (try/catch) │
│ └── _distributePendingFees() → Transfer when liquidity ready │
│ │
│ WelephantVault │
│ ├── depositETH() → Receive ETH from game │
│ ├── swapReserves() → Chunked swaps with try/catch │
│ ├── withdrawWelephant() → JIT delivery to consumers │
│ └── canWithdraw() → Check available liquidity │
│ │
│ WelephantSwap │
│ ├── update() → Update TWAP oracle paths │
│ ├── estimateWelephantForETHTwap() → TWAP-based price estimate │
│ ├── swapExactETHForWrappedTWAP() → Swap using TWAP path selection │
│ └── _getBestPathTWAP() → Compare direct vs indirect │
│ │
│ FeeAccounting │
│ ├── addAdminFee() / addStakingFee() → Record (no transfer) │
│ ├── distributeAdminFees() → Return amount, zero state │
│ └── distributeStakingFees() → Return amount, zero state │
│ │
└─────────────────────────────────────────────────────────────────────────┘
WelephantVault: Central Liquidity Hub
Purpose: Holds all WELEPHANT liquidity and orchestrates async ETH→WELEPHANT swaps.
Key Features:
- Try/Catch Safety: Only contract in ecosystem with try/catch around swaps
- Chunked Swaps:
defaultSwapChunk = 1 ETHreduces single-swap risk - Failure Tracking:
consecutiveSwapFailuresandlastSwapErrorfor monitoring - JIT Delivery: Consumers withdraw WELEPHANT on-demand via
withdrawWelephant() - Graceful Degradation: Returns
falseif insufficient balance (never reverts)
Configuration:
uint256 defaultSwapChunk = 1 ether; // Max ETH per swap
uint256 minSwapAmount = 0.01 ether; // Dust prevention
uint256 maxSlippageBps = 500; // 5% slippage tolerance
WelephantSwap: TWAP-Aware Swap Router
Purpose: Handles ETH↔WELEPHANT swaps with dual-path routing and TWAP oracle integration.
TWAP Oracle Integration:
- Uses
IPcsSnapshotTwapOracleat0x5606ee12d741716c260fDA2f6C89EfDf60326D3C - Time-weighted average prices resist manipulation
- Must call
update()once per transaction before TWAP operations
Dual-Path Routing:
Direct Path: WBNB → ELEPHANT
Indirect Path: WBNB → BUSD → ELEPHANT
Best path selected based on:
- TWAP prices (for crank/automated swaps)
- Spot prices (for user/UI swaps)
Price Functions:
| Function | Use Case | Method |
|---|---|---|
estimateWelephantForETHTwap() |
Crank accounting | TWAP oracle |
pathEthForCore() |
UI/user swaps | Router spot prices |
getWelephantPriceInUSD() |
Display | Chainlink BNB/USD |
Fee Model:
- 0.25% swap fee on all swaps
- Fees accumulate until 1000 WELEPHANT threshold
- Auto-forwarded to StakingVault via
fundYield()
FeeAccounting: Async Fee Ledger
Purpose: Separates fee accounting from token transfers, enabling async distribution.
Flow:
1. Round Finalization (CrankFinalization._finalizeRound):
feeAccounting.addAdminFee(amount) → Recorded, not transferred
feeAccounting.addStakingFee(amount) → Recorded, not transferred
2. Crank Distribution (CrankFinalization._distributePendingFees, when liquidity available):
amount = feeAccounting.distributeAdminFees()
welephantVault.withdrawWelephant(feeCollector, amount)
amount = feeAccounting.distributeStakingFees()
welephantVault.withdrawWelephant(crankFinalization, amount)
stakingVault.fundYield(amount)
Key Property: Fee accounting never blocks round finalization.
Safety Mechanisms Summary
| Mechanism | Location | Benefit |
|---|---|---|
| Try/Catch | WelephantVault.swapReserves() | Crank never reverts on swap failure |
| TWAP Oracle | WelephantSwap | Resists price manipulation |
| Chunked Swaps | WelephantVault | Reduces single-swap exposure |
| Slippage Protection | estimateWelephantForETHTwap() | Guarantees minOut |
| Dust Prevention | minSwapAmount | Avoids gas-inefficient small swaps |
| Failure Tracking | consecutiveSwapFailures | Enables monitoring/alerting |
| Graceful Withdrawal | withdrawWelephant() | Returns false if insufficient |
| Async Fees | FeeAccounting | Decouples accounting from transfer |
Simulator & Tooling Overview
The Prediction Mining system consists of:
- Smart Contracts: Solidity contracts handling game logic, storage, and batch operations
- Simulator: TypeScript application for testing game mechanics at scale
- Frontend: Web UI for player interaction
raffle-mining/
├── contracts/ # Smart contracts
│ ├── RaffleMining.sol # Main game logic (stateless coordinator)
│ ├── RaffleMiningViews.sol # Read-only view functions (includes paginated batch reads)
│ ├── RaffleMiningCrank.sol # Crank orchestration (CrankCore)
│ ├── CrankFinalization.sol # Round finalization, distribution, vault swaps
│ ├── RaffleMiningLib.sol # Shared libraries
│ ├── RaffleMiningTypes.sol # Operational types & constants
│ ├── GameHistoryTypes.sol # Data-model types (decoupled from game engine)
│ └── RegistryTypes.sol # Registry types (decoupled from game engine)
├── simulator/src/ # Simulator
│ ├── simulation.ts # Main orchestration
│ ├── players.ts # Player management (optimized)
│ ├── roundLogger.ts # Round data reading & reporting (optimized)
│ ├── crank.ts # Crank execution
│ └── deployer.ts # Contract deployment
└── frontend/ # Web UI
RPC Optimization Strategy
Design Principle
Never perform O(n) individual RPC calls when batch alternatives exist or can be created.
Each RPC call incurs:
- Network latency (50-200ms typical)
- Rate limiting risk
- Cost on paid RPC providers
For 100 participants with 2 calls each = 200 calls × 100ms = 20 seconds of blocking I/O.
Pagination Pattern (CRITICAL)
Never assume bounded data sizes. Any function returning arrays must use cursor-based pagination:
// WRONG - Naive, can fail with large n
function getRoundParticipants(uint256 roundId) returns (address[] memory)
// RIGHT - Paginated, safe for any n
function getRoundParticipantCount(uint256 roundId) returns (uint256 count)
function getRoundParticipants(uint256 roundId, uint256 offset, uint256 limit)
returns (address[] memory participants, uint256 nextCursor)
Client-side pattern:
const PAGE_SIZE = 100;
let cursor = 0;
const allData: Data[] = [];
do {
const [page, nextCursor] = await contract.getData(cursor, PAGE_SIZE);
allData.push(...page);
cursor = nextCursor;
} while (cursor > 0); // nextCursor = 0 means no more data
Identified Anti-Patterns & Status
1. Round Logging - Player Info Fetching
Location: simulator/src/roundLogger.ts:265-346
Original Problem: Loop fetched player data one-by-one:
for (const participant of participants) {
const playerInfo = await raffleMining.getPlayerRoundInfo(participant, roundId); // RPC per participant
}
Impact: 1 RPC call × N participants
Status: ✅ OPTIMIZED (with pagination)
Solution Implemented:
- Added paginated
getPlayersRoundDataBatch(roundId, offset, limit)to contract - Returns participants, player info, and nextCursor in one call
- Iterates through participants by index internally (no need to fetch addresses first)
const PAGE_SIZE = 100;
let cursor = 0;
do {
// Single RPC call fetches a page of all data
const [participants, infos, bets, nextCursor] =
await raffleMiningViews.getPlayersRoundDataBatch(roundId, cursor, PAGE_SIZE);
for (let i = 0; i < participants.length; i++) {
// Process participant data...
}
cursor = nextCursor;
} while (cursor > 0);
Result: 200 RPC calls → ceil(n/100) calls (99%+ reduction, scales safely)
2. Auto-Play Registration - Repeated Contract Constants
Location: simulator/src/players.ts:169-207
Original Problem: Contract constants read inside loop for each player:
for (const player of autoPlayers) {
const currentRoundId = await raffleMining.currentRoundId(); // Same for all!
const gasUnitsPerPlay = await raffleMining.gasUnitsPerPlay(); // Same for all!
const gasTaxBasisPoints = await raffleMining.gasTaxBasisPoints(); // Same for all!
const defaultGasPrice = await raffleMining.defaultGasPrice(); // Same for all!
}
Impact: 4+ redundant RPC calls × N players
Status: ✅ OPTIMIZED
Solution Implemented: Cache constants before loop with parallel fetch:
// Fetch all constants in parallel ONCE before loop
[currentRoundId, gasTaxBasisPoints, defaultGasPrice] = await Promise.all([
raffleMining.currentRoundId(),
raffleMining.gasTaxBasisPoints(),
raffleMining.defaultGasPrice()
]);
// Reuse cached values in loop
for (const player of autoPlayers) {
// Uses cached currentRoundId, gasUnitsPerPlay, etc.
}
Result: 200+ RPC calls → 4 RPC calls (98% reduction for constants)
3. Reward Claiming - Balance Checks
Location: simulator/src/players.ts:500-544
Original Problem: Balance checked individually per player:
for (const player of this.players) {
const balance = await raffleMining.playerBalances(player.address); // RPC per player
}
Impact: 1 RPC call × N players
Status: ✅ OPTIMIZED
Solution Implemented:
- Added
getPlayerBalancesBatch(addresses[])to contract - Caller controls batch size (caller knows their player list)
// Single RPC call fetches all balances
const playerAddresses = this.players.map(p => p.address);
const balances = await raffleMiningViews.getPlayerBalancesBatch(playerAddresses);
for (let i = 0; i < this.players.length; i++) {
const balance = balances[i]; // Already fetched
}
Result: 100 RPC calls → 1 RPC call (99% reduction)
Contract Batch Read Support
Cursor-Based Paginated Functions (Safe for Large N)
All paginated functions follow the pattern: functionPaginated(offset, limit) → (data[], nextCursor)
nextCursor = 0means no more data- Use
PAGE_SIZE = 100as default limit
| Function | Description | Returns |
|---|---|---|
getRoundParticipantCount(roundId) |
Count of participants | uint256 |
getRoundParticipantsPaginated(roundId, offset, limit) |
Paginated addresses | (address[], nextCursor) |
getRoundSummariesPaginated(offset, limit) |
Paginated round summaries | (RoundSummary[], nextCursor) |
getPlayerRoundIdsPaginated(player, offset, limit) |
Player's round IDs | (uint256[], nextCursor) |
getPlayerRoundHistoryPaginated(player, offset, limit) |
Player's round history | (PlayerRoundInfo[], nextCursor) |
getPlayersRoundDataBatch(roundId, offset, limit) |
Combined paginated batch | (address[], PlayerRoundInfo[], nextCursor) |
Count Functions (For Total Size)
| Function | Description | Returns |
|---|---|---|
getRoundParticipantCount(roundId) |
Total participants in round | uint256 |
getPlayerRoundIdCount(player) |
Total rounds player participated in | uint256 |
getTotalRoundSummaries() |
Total finalized rounds | uint256 |
Address-Based Batch Functions (Caller Controls Size)
| Function | Description | Notes |
|---|---|---|
getPlayerBalancesBatch(addresses[]) |
Multiple player balances | Caller provides addresses, controls batch size |
Legacy Functions (Convenience, Bounded Use Only)
These exist for simple use cases with known-small N:
| Function | Notes |
|---|---|
getRoundParticipants(roundId, offset, limit) |
Paginated but no cursor return - use Paginated variant for large N |
getRoundSummaries(offset, limit) |
No cursor return - use Paginated variant for large N |
getRecentRounds(count) |
Safe if count is bounded |
Removed Functions (previously unsafe O(n)):
getRoundParticipants(roundId)- returned unbounded arraygetPlayerRoundHistory(player, offset, limit)- fetched full array internallygetPlayerRecentRounds(player, count)- fetched full array internallygetPlayerTotalRoundsandgetPlayerDetailedStatsnow use O(1)getPlayerRoundIdCount
Optimization Summary
Achieved Impact
| Scenario | Players | Before | After | Reduction |
|---|---|---|---|---|
| Round logging | 100 | 200 calls | 1 call | 99.5% |
| Round logging | 10,000 | 20,000 calls | 100 calls | 99.5% |
| Auto-play registration (constants) | 50 | 200 calls | 4 calls | 98% |
| Reward claim checks | 100 | 100 calls | 1 call | 99% |
Implementation Completed
- Phase 1: Cache contract constants in simulator (
players.ts) - Phase 2: Add paginated
getPlayersRoundDataBatchto contracts - Phase 3: Add
getPlayerBalancesBatchfor reward claiming - Phase 4: Update simulator to use cursor-based pagination
Other Design Decisions
Crank Architecture: CrankCore + CrankFinalization
The crank is split into two deployed contracts to stay under the 24,576 byte EVM contract size limit:
RaffleMiningCrank (CrankCore) — Orchestration:
crank()entry point with priority-based state machine- Auto-play batch processing (backward cursor iteration)
- Pending plays batch processing (forward cursor iteration)
- Round phase tracking and heartbeat events
- Delegates to CrankFinalization for all finalization/distribution/swap work
CrankFinalization — Finalization & distribution:
- Round finalization with entropy generation
- Single winner selection (cursor-based)
- WELEPHANT distribution to winners (cursor-based, auto-stake inline per winner)
- ETH distribution to winners (cursor-based, ETH looping inline per winner)
- Vault swap processing (
processVaultSwaps()) - Fee distribution (
_distributePendingFees())
Cross-contract delegation: CrankCore calls CrankFinalization via the ICrankFinalization interface. CrankFinalization restricts these calls via onlyCrankCore modifier. Both contracts share the same storageContract (IRaffleMiningWritableStorage) immutable reference.
Stateless RaffleMining (Upgrade Path)
RaffleMining holds no mutable game state between rounds. All persistent state lives in dedicated registry and storage contracts:
| State | Contract |
|---|---|
| Auto-play registrations, active user set, ETH balances | AutoPlayRegistry |
| Pending plays queue + ETH | PendingPlaysRegistry |
| Round history, pots, player stats | GameHistory |
| Entropy chain, finalization results | Verification |
Upgrade procedure: Pause RaffleMining, wait for the crank to finish processing, deploy a new RaffleMining, update each registry's gameContract pointer to the new address. All player state (funds, registrations, pending plays, history) remains intact.
All batch operations use a single-level state machine (cursor + inProgress flag). There are no nested state machines — inline operations like auto-staking and ETH looping are processed within the parent distribution loop, ensuring the cursor always advances and the distribution always completes.
Event-Based Data Collection
Where possible, prefer reading events over direct state queries:
- Events are indexed and queryable by block range
- Can reconstruct round history from events
- Reduces dependency on view functions
Parallel RPC Calls
When multiple independent reads are needed, use Promise.all():
const [a, b, c] = await Promise.all([
contract.methodA(),
contract.methodB(),
contract.methodC()
]);
This executes calls concurrently rather than sequentially.
ethers.js Frozen Objects
ethers.js returns frozen Result objects from contract calls. When iterating over batch results:
// Convert to mutable values
const value = BigInt(result[i].field);
const flag = Boolean(result[i].boolField);
const addr = String(result[i].addressField);
Security Q&A
Can an attacker receive outsized rewards?
No. The system has layered defenses that prevent any single actor from extracting disproportionate value:
| Attack Vector | Defense |
|---|---|
| Predict the winning square | Seed is masked during the round and finalized with entropy from 6 future blocks that don't exist at play time |
| Flash loan a massive play | Plays close at round end; finalization happens 6+ blocks later. A loan cannot span blocks |
| Front-run round finalization | Plays placed after the round timer expires go to the next round's pending queue, not the current round |
| Manipulate reward share | Winner payouts are purely proportional — (yourBet / totalBetsOnSquare) × winnerPool — immutable after the play is recorded |
| Claim rewards twice | CEI pattern zeroes balances before any transfer; nonReentrant on all claim functions |
| Play small on every square to guarantee single-winner prize | Single winner is selected weighted by play amount, not by player count. A 0.0001 BNB play gets ~0.0001 BNB worth of probability |
| Predict payday rounds | Payday is derived from the same masked, post-cooldown seed as the winning square. Cannot be known before plays close |
| Crank operator selects favorable outcomes | Randomness is finalized from block data that post-dates the closing block. The crank processes players in deterministic sequential order — it cannot skip or reorder |
| JIT yield snipe on StakingVault | The crank calls fundYield() every ~60 seconds with small, predictable amounts. Per-round yield is too small relative to gas costs for sandwich attacks to be profitable. The continuous drip cadence acts as an application-layer mitigation without requiring time-locks or entry fees, preserving the vault's fully permissionless design |
| AutoPlay register/withdraw DDoS | AutoPlayRegistry has no direct withdraw() — players must use cancelAutoPlay() which cleans up both ETH balance and registration state atomically. cancelAutoPlay() requires at least 1 round played (E85), preventing register-then-cancel churn |
The strongest architectural defense is time separation: plays close at round end, then a 6-block cooldown passes, then the seed is finalized using entropy that didn't exist when plays were placed. There is no single-transaction path from "place play" to "collect winnings."
Changelog
| Date | Change | Author |
|---|---|---|
| 2025-12-02 | Initial architecture doc - documented O(n) anti-patterns | Claude |
| 2025-12-02 | Implemented batch functions, updated simulator | Claude |
| 2025-12-02 | Converted to cursor-based pagination for unbounded safety | Claude |
| 2025-12-02 | Added full pagination coverage: getPlayerRoundIdsPaginated, getRoundParticipantsPaginated, getRoundSummariesPaginated, getPlayerRoundHistoryPaginated |
Claude |
| 2026-01-23 | Removed O(n) unbounded functions from RaffleMiningViews: getRoundParticipants(roundId), getPlayerRoundHistory, getPlayerRecentRounds. Fixed getPlayerTotalRounds and getPlayerDetailedStats to use O(1) count functions. |
Claude |
| 2026-01-29 | Added Async Swap Architecture section documenting WelephantVault, WelephantSwap TWAP integration, FeeAccounting, and safety mechanisms | Claude |
| 2026-02-03 | Added Incremental Fee Forwarding section documenting anti-front-running mechanism | Claude |
| 2026-02-07 | Updated distribution docs: inline auto-stake/ETH-loop replace nested flush queues (crank bricking fix) | Claude |
| 2026-02-07 | Removed reward batch accumulation: all deposits (ETH, WELEPHANT, auto-stake) are now per-winner inline. Auto-stake uses try/catch for universal failure handling | Claude |
| 2026-02-06 | Replaced XOR entropy accumulation with hash-chaining (keccak256) in Verification.updateSeed(); added block.prevrandao to playEntropy and autoplay square selection | Claude |
| 2026-02-07 | Comprehensive ACL documentation: added DEPOSITOR_ROLE, Ownable contracts, delegated admin checks, role assignment flow diagram. Updated WelephantRewards to reflect partial claims and removed batch deposit references | Claude |
| 2026-02-13 | Documented CrankCore + CrankFinalization split: RaffleMiningCrank split into two contracts to stay under 24,576 byte EVM limit. Updated contract diagram, core contracts table, access control roles (CONSUMER/CRANK/DISTRIBUTOR now to CrankFinalization), role assignment flow, async swap architecture, and file listing | Claude |
| 2026-02-13 | Added JIT yield sniping defense to Security Q&A: crank's 60-second fundYield() cadence mitigates sandwich attacks without time-locks or entry fees |
Claude |
| 2026-02-13 | Removed AutoPlayRegistry.withdraw() DDoS vector (AP-07): added anti-churn defense to Security Q&A, updated vault description | Claude |
| 2026-02-17 | Replaced per-play gas unit model with flat gas tax (gasTaxBasisPoints). Removed gasUnitsPerPlay and gasChargeMultiplier. Added 10% buffer threshold before sweeping gas charge excess to WelephantVault — excess pads buybacks without changing fee split economics | Claude |
| 2026-02-24 | Increased gas tax from 0.1% (10 bps) to 1% (100 bps) to ensure crank gas costs are covered at lower play volumes | Claude |
| 2026-02-19 | RaffleMining is now stateless: auto-play state (structs, active set, settings, ETH) moved to AutoPlayRegistry; pending plays moved to PendingPlaysRegistry. AutoPlayDeposit retired. RaffleMining can be upgraded by pausing, finishing the crank, deploying a new instance, and updating registry gameContract pointers |
Claude |
| 2026-02-22 | Types decoupling: split RaffleMiningTypes into three independent files. GameHistoryTypes.sol isolates data-model structs (RoundSummary, PlayerStats, etc.) so GameHistory is unaffected by operational type changes. RegistryTypes.sol isolates registry structs (AutoPlay, PendingPlay, PlayerAutoPlaySettings) so AutoPlayRegistry and PendingPlaysRegistry are unaffected by game engine type changes. RaffleMiningTypes.sol retains only operational types (PendingWork, RoundPhase, constants) | Claude |
| 2026-02-22 | Added Ownership Model & Renouncement section: documented permissionless asset layer (StakingVault, WelephantSwap), administered game layer (AccessControl contracts), Ownable contracts, delegated admin contracts, and why full renouncement is not appropriate for the game layer. Consolidated delegated admin and cross-contract access control documentation into the new section | Claude |
| 2026-03-07 | Added Bitmask Storage Model section: documented uniform amount constraint (gameplay compromise), 2-SSTORE vs 32-push gas savings, squareTotals maintenance, one-play-per-round enforcement, and encoding details | Claude |