SlvrAutoCommitV3

0x5fd69ee67472495cdc0be784898647782e073ff5

Verification
Verified
v0.8.33+commit.64118f21
Type
Contract
15,019 bytes
ABI entries
57
22 read · 12 write
License
none

Contract information

Address
0x5fd69ee67472495cdc0be784898647782e073ff5
Chain
Robinhood Chain (4663)
Compiler
v0.8.33+commit.64118f21
Optimization
Enabled
Creation tx
0x363207b20a…c5e837422c

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (22)

ACCOUNT_DEPOSIT()uint256
BET_RESERVE_GAS()uint256
BPS()uint16
CLAIM_RESERVE_GAS()uint256
FEE_GAS_OVERHEAD()uint256
FEE_PREMIUM_CEILING_BPS()uint16
LOTTERY()address
MAX_CLAIMS_PER_EXECUTION()uint256
MAX_FEE_CEILING()uint256
MAX_PLAYS_PER_EXECUTION()uint32
SLVR()address
UNLIMITED_PLAYS()uint32
executedRounds(address, uint256)bool
feePremiumBps()uint16
feeReserve(uint256)uint256
feeReserve()uint256
locker()address
maxFeePerExecution()uint256
needsExecution(address)bool, string
owner()address
planInfo(address)bool, uint256, uint32, uint256, uint256, bool, uint8[], uint16[], uint256, uint8
plans(address)bool, uint256, uint32, uint256, uint256, bool, uint256, uint8

Events (15)

BalanceUpdatedClaimedClaimedToLockDepositedExecutorFeePaidFeeParamsUpdatedLockFailedLockerUpdatedOwnershipTransferredPlanCancelledPlanConfiguredPlanDisabledPlanLockModeSetRoundExecutedWithdrawn

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "lottery_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "slvr_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "locker_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "owner_",
        "type": "address"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "BadConfig",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotEnoughBalance",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NothingToExecute",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "owner",
        "type": "address"
      }
    ],
    "name": "OwnableInvalidOwner",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "account",
        "type": "address"
      }
    ],
    "name": "OwnableUnauthorizedAccount",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ReentrancyGuardReentrantCall",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "newBalance",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amountAdded",
        "type": "uint256"
      }
    ],
    "name": "BalanceUpdated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "roundId",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "nativeAmount",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "addedToBalance",
        "type": "uint256"
      }
    ],
    "name": "Claimed",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "roundId",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "slvrAmount",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "bool",
        "name": "permanent",
        "type": "bool"
      }
    ],
    "name": "ClaimedToLock",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "Deposited",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "executor",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "fee",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "gasUsed",
        "type": "uint256"
      }
    ],
    "name": "ExecutorFeePaid",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint16",
        "name": "feePremiumBps",
        "type": "uint16"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "maxFeePerExecution",
        "type": "uint256"
      }
    ],
    "name": "FeeParamsUpdated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "roundId",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "slvrAmount",
        "type": "uint256"
      }
    ],
    "name": "LockFailed",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "locker",
        "type": "address"
      }
    ],
    "name": "LockerUpdated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "previousOwner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "OwnershipTransferred",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "refundAmount",
        "type": "uint256"
      }
    ],
    "name": "PlanCancelled",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "nextRoundId",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint32",
        "name": "plays",
        "type": "uint32"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amountPerPlay",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "bool",
        "name": "autoClaim",
        "type": "bool"
      }
    ],
    "name": "PlanConfigured",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      }
    ],
    "name": "PlanDisabled",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint8",
        "name": "lockMode",
        "type": "uint8"
      }
    ],
    "name": "PlanLockModeSet",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "roundId",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint32",
        "name": "playsRemaining",
        "type": "uint32"
      }
    ],
    "name": "RoundExecuted",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "address",
        "name": "to",
        "type": "address"
      }
    ],
    "name": "Withdrawn",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "ACCOUNT_DEPOSIT",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "BET_RESERVE_GAS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "BPS",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "CLAIM_RESERVE_GAS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "FEE_GAS_OVERHEAD",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "FEE_PREMIUM_CEILING_BPS",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "LOTTERY",
    "outputs": [
      {
        "internalType": "contract ISlvrGridLottery",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_CLAIMS_PER_EXECUTION",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_FEE_CEILING",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_PLAYS_PER_EXECUTION",
    "outputs": [
      {
        "internalType": "uint32",
        "name": "",
        "type": "uint32"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "SLVR",
    "outputs": [
      {
        "internalType": "contract IERC20",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "UNLIMITED_PLAYS",
    "outputs": [
      {
        "internalType": "uint32",
        "name": "",
        "type": "uint32"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "cancelPlan",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "internalType": "uint256[]",
        "name": "claimRounds",
        "type": "uint256[]"
      }
    ],
    "name": "claimFor",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "claimed",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint32",
        "name": "plays",
        "type": "uint32"
      },
      {
        "internalType": "uint256",
        "name": "amountPerPlay",
        "type": "uint256"
      },
      {
        "internalType": "uint8[]",
        "name": "squares",
        "type": "uint8[]"
      },
      {
        "internalType": "uint16[]",
        "name": "bpsAlloc",
        "type": "uint16[]"
      },
      {
        "internalType": "bool",
        "name": "autoClaim_",
        "type": "bool"
      },
      {
        "internalType": "enum SlvrAutoCommitV3.LockMode",
        "name": "lockMode_",
        "type": "uint8"
      }
    ],
    "name": "configurePlan",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint32",
        "name": "plays",
        "type": "uint32"
      },
      {
        "internalType": "uint256",
        "name": "amountPerPlay",
        "type": "uint256"
      },
      {
        "internalType": "uint8[]",
        "name": "squares",
        "type": "uint8[]"
      },
      {
        "internalType": "uint16[]",
        "name": "bpsAlloc",
        "type": "uint16[]"
      },
      {
        "internalType": "bool",
        "name": "autoClaim_",
        "type": "bool"
      },
      {
        "internalType": "enum SlvrAutoCommitV3.LockMode",
        "name": "lockMode_",
        "type": "uint8"
      }
    ],
    "name": "configurePlanAndDeposit",
    "outputs": [],
    "stateMutability": "payable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "deposit",
    "outputs": [],
    "stateMutability": "payable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "disablePlan",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "internalType": "uint32",
        "name": "maxPlays",
        "type": "uint32"
      },
      {
        "internalType": "uint256[]",
        "name": "claimRounds",
        "type": "uint256[]"
      }
    ],
    "name": "executeFor",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "executedRounds",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "feePremiumBps",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "claimCount",
        "type": "uint256"
      }
    ],
    "name": "feeReserve",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "feeReserve",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "locker",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "maxFeePerExecution",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "user",
        "type": "address"
      }
    ],
    "name": "needsExecution",
    "outputs": [
      {
        "internalType": "bool",
        "name": "ready",
        "type": "bool"
      },
      {
        "internalType": "string",
        "name": "reason",
        "type": "string"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "owner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "user",
        "type": "address"
      }
    ],
    "name": "planInfo",
    "outputs": [
      {
        "internalType": "bool",
        "name": "enabled",
        "type": "bool"
      },
      {
        "internalType": "uint256",
        "name": "nextRoundId",
        "type": "uint256"
      },
      {
        "internalType": "uint32",
        "name": "playsRemaining",
        "type": "uint32"
      },
      {
        "internalType": "uint256",
        "name": "amountPerPlay",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "balance",
        "type": "uint256"
      },
      {
        "internalType": "bool",
        "name": "autoClaim",
        "type": "bool"
      },
      {
        "internalType": "uint8[]",
        "name": "squares",
        "type": "uint8[]"
      },
      {
        "internalType": "uint16[]",
        "name": "bpsAlloc",
        "type": "uint16[]"
      },
      {
        "internalType": "uint256",
        "name": "planStartRoundId",
        "type": "uint256"
      },
      {
        "internalType": "uint8",
        "name": "lockMode",
        "type": "uint8"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "plans",
    "outputs": [
      {
        "internalType": "bool",
        "name": "enabled",
        "type": "bool"
      },
      {
        "internalType": "uint256",
        "name": "nextRoundId",
        "type": "uint256"
      },
      {
        "internalType": "uint32",
        "name": "playsRemaining",
        "type": "uint32"
      },
      {
        "internalType": "uint256",
        "name": "amountPerPlay",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "balance",
        "type": "uint256"
      },
      {
        "internalType": "bool",
        "name": "autoClaim",
        "type": "bool"
      },
      {
        "internalType": "uint256",
        "name": "planStartRoundId",
        "type": "uint256"
      },
      {
        "internalType": "enum SlvrAutoCommitV3.LockMode",
        "name": "lockMode",
        "type": "uint8"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "renounceOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint16",
        "name": "feePremiumBps_",
        "type": "uint16"
      },
      {
        "internalType": "uint256",
        "name": "maxFeePerExecution_",
        "type": "uint256"
      }
    ],
    "name": "setFeeParams",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "locker_",
        "type": "address"
      }
    ],
    "name": "setLocker",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "transferOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      },
      {
        "internalType": "address payable",
        "name": "to",
        "type": "address"
      }
    ],
    "name": "withdraw",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "stateMutability": "payable",
    "type": "receive"
  }
]

Source code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

import {ISlvrGridLottery} from "./interfaces/ISlvrGridLottery.sol";
import {ISlvrClaimLockerV2} from "./interfaces/ISlvrClaimLockerV2.sol";

/// @title SlvrAutoCommitV3
/// @notice Allows users to pre-fund N future rounds with deterministic square allocations.
/// @dev V3 is V2 plus an opt-in PERMANENT LOCK mode per plan. Such a plan claims
///      each win in FULL instead of ETH-only: the ETH still recycles into the
///      plan balance (exactly like V2), and the refined SLVR is routed to
///      SlvrClaimLockerV2, which burns it into the user's single permanent lock
///      with the lottery's refining-fee bypass — same policy as the UI's
///      Burn & Stake.
///
///      There is deliberately NO auto-lock into a TMAX ("max-lock") veNFT. That
///      mode would pay the 10% refining fee on every single claim, which buys
///      nothing: the fee is proportional, so refining early costs the same
///      percentage as refining once by hand later, and in the meantime the SLVR
///      forfeits the refining dividends it earns while unrefined. It is exactly
///      the behavior V2 was changed to stop doing. Users who want a TMAX lock
///      keep LockMode.None and lock by hand whenever they choose.
///
///      Setting a lock mode requires autoClaim: the lock only ever happens as
///      part of the keeper's claim, and the user sets it on their OWN plan —
///      that consent is what makes a keeper-triggered irreversible lock safe.
///
///      Everything else — the executor fee model, off-chain claim discovery,
///      event signatures — is deliberately IDENTICAL to V2 so the existing
///      keeper binary drives this contract with only an address change:
///      PlanConfigured/PlanDisabled/RoundExecuted/Claimed keep their exact V2
///      topics, and planInfo only APPENDS a trailing lockMode output.
contract SlvrAutoCommitV3 is ReentrancyGuard, Ownable {
    uint16 public constant BPS = 10_000;
    uint32 public constant UNLIMITED_PLAYS = type(uint32).max; // Sentinel value for unlimited plays
    uint32 public constant MAX_PLAYS_PER_EXECUTION = 10;
    uint256 public constant MAX_CLAIMS_PER_EXECUTION = 10;
    uint256 public constant ACCOUNT_DEPOSIT = 0.0001e18; // one-time account-opening fee (matches SlvrGridLottery.ACCOUNT_DEPOSIT)

    // --- Executor fee model (unchanged from V2 — see SlvrAutoCommitV2) ---
    uint256 public constant FEE_GAS_OVERHEAD = 60_000; // intrinsic + calldata + fee transfer, not seen by gasleft() metering
    uint16 public constant FEE_PREMIUM_CEILING_BPS = 3_000; // owner can never set the premium above 30%
    uint256 public constant MAX_FEE_CEILING = 0.01e18; // owner can never set the per-call cap above this
    /// @notice Gas ceilings used to PRICE the play gate's fee reserve (not
    ///         limits on execution), measured against this repo's contracts: a
    ///         worst-case 25-square round-initializing bet metered 6.49M, and a
    ///         lock-mode claim ~0.4M marginal when batched. The reserve is
    ///         composition-aware — executeFor prices in the claim batch it was
    ///         actually given — and is priced at the same 2x-basefee cap the
    ///         fee charge uses, clamped by maxFeePerExecution.
    uint256 public constant BET_RESERVE_GAS = 7_000_000;
    uint256 public constant CLAIM_RESERVE_GAS = 400_000;

    uint16 public feePremiumBps = 1_000; // 10% premium over reimbursed gas
    uint256 public maxFeePerExecution = 0.001e18; // per-call cap protecting plan balances

    ISlvrGridLottery public immutable LOTTERY;
    IERC20 public immutable SLVR;

    /// @notice Where lock-mode plans' claimed SLVR is locked (SlvrClaimLockerV2).
    ///         Owner-swappable so the locking hub can be upgraded without
    ///         redeploying plans; the vote escrow's authorizedContract moves
    ///         with it.
    address public locker;

    /// @notice What happens to a lock-mode plan's SLVR winnings on auto-claim.
    /// @dev Kept as an enum rather than a bool so planInfo's trailing uint8 stays
    ///      stable and a future disposition can be added without an ABI break.
    enum LockMode {
        None, // V2 behavior: ETH-only claim, SLVR stays unrefined in miner state
        Permanent // full claim; SLVR burned into their permanent lock (refining fee bypassed)
    }

    struct Plan {
        bool enabled;
        uint256 nextRoundId;
        uint32 playsRemaining; // UNLIMITED_PLAYS means unlimited, 0 means no plays remaining
        uint256 amountPerPlay;
        uint8[] squares;
        uint16[] bpsAlloc;
        uint256 balance;
        bool autoClaim; // If true, keeper-supplied winning rounds are claimed back into balance
        uint256 planStartRoundId; // Round ID when this plan instance started
        LockMode lockMode; // V3: what to do with SLVR winnings on auto-claim
    }

    mapping(address => Plan) public plans;
    // Track which rounds have been executed for each user (prevents duplicate executions)
    mapping(address => mapping(uint256 => bool)) public executedRounds;
    // Track which user is currently claiming (for receive() attribution)
    address private claimingUser;

    // --- Events: the first seven are byte-identical to V2 (keeper compatibility) ---
    event Deposited(address indexed user, uint256 amount);
    event Withdrawn(address indexed user, uint256 amount, address to);
    event PlanConfigured(
        address indexed user, uint256 nextRoundId, uint32 plays, uint256 amountPerPlay, bool autoClaim
    );
    event PlanDisabled(address indexed user);
    event PlanCancelled(address indexed user, uint256 refundAmount);
    event RoundExecuted(address indexed user, uint256 indexed roundId, uint32 playsRemaining);
    event Claimed(address indexed user, uint256 indexed roundId, uint256 nativeAmount, uint256 addedToBalance);
    event BalanceUpdated(address indexed user, uint256 newBalance, uint256 amountAdded);
    event ExecutorFeePaid(address indexed user, address indexed executor, uint256 fee, uint256 gasUsed);
    event FeeParamsUpdated(uint16 feePremiumBps, uint256 maxFeePerExecution);
    // --- V3 additions ---
    event PlanLockModeSet(address indexed user, uint8 lockMode);
    event ClaimedToLock(address indexed user, uint256 indexed roundId, uint256 slvrAmount, bool permanent);
    /// @notice The claim succeeded but the locker call reverted — the SLVR sits
    ///         at the locker awaiting an owner sweep(). Reachable only in a
    ///         genuinely exceptional state: _lockForClaim already gated the
    ///         claim on _lockingOperational(), so an ordinary mis-wire degrades
    ///         to the ethOnly path instead of ever reaching here. The locker
    ///         reverts rather than delivering fee-bypassed liquid SLVR, which is
    ///         what leaves it parked awaiting the sweep.
    event LockFailed(address indexed user, uint256 indexed roundId, uint256 slvrAmount);
    event LockerUpdated(address indexed locker);

    error BadConfig();
    error NotEnoughBalance();
    error NothingToExecute();

    constructor(address lottery_, address slvr_, address locker_, address owner_) Ownable(owner_) {
        LOTTERY = ISlvrGridLottery(lottery_);
        SLVR = IERC20(slvr_);
        locker = locker_;
        emit LockerUpdated(locker_);
    }

    /// @notice Tune the executor-fee safety rails within the hard ceilings.
    function setFeeParams(uint16 feePremiumBps_, uint256 maxFeePerExecution_) external onlyOwner {
        require(feePremiumBps_ <= FEE_PREMIUM_CEILING_BPS, "premium too high");
        require(maxFeePerExecution_ > 0 && maxFeePerExecution_ <= MAX_FEE_CEILING, "bad max fee");
        feePremiumBps = feePremiumBps_;
        maxFeePerExecution = maxFeePerExecution_;
        emit FeeParamsUpdated(feePremiumBps_, maxFeePerExecution_);
    }

    /// @notice The balance a plan must hold ON TOP of the bet to play — the
    ///         executor fee's realistic worst case at CURRENT gas prices for an
    ///         execution carrying `claimCount` claims.
    /// @dev V2 reserved the full maxFeePerExecution cap unconditionally, which
    ///      at Robinhood's basefees demands ~2x the fee that can actually be
    ///      charged and stalls plans that could still fund several plays. The
    ///      fee is charged in the SAME tx as the bet at a price capped to
    ///      2*basefee, so pricing the reserve at that same cap is exact:
    ///      reserve >= any fee this execution can charge, as long as metered
    ///      gas stays under BET_RESERVE_GAS + claims * CLAIM_RESERVE_GAS (the
    ///      fee itself is additionally capped at maxFeePerExecution, which also
    ///      caps this reserve). With no basefee signal (basefee == 0), fall
    ///      back to the full cap — there is no price to bound the fee with.
    function feeReserve(uint256 claimCount) public view returns (uint256) {
        uint256 price = block.basefee * 2;
        if (price == 0) return maxFeePerExecution;
        uint256 reserveGas = BET_RESERVE_GAS + claimCount * CLAIM_RESERVE_GAS;
        uint256 reserve = (reserveGas * price * (uint256(BPS) + feePremiumBps)) / BPS;
        return reserve > maxFeePerExecution ? maxFeePerExecution : reserve;
    }

    /// @notice Claim-less reserve — what needsExecution gates on.
    function feeReserve() external view returns (uint256) {
        return feeReserve(0);
    }

    /// @notice Swap the locking hub. Existing lock-mode plans lock through the
    ///         new hub from the next claim on.
    function setLocker(address locker_) external onlyOwner {
        require(locker_ != address(0), "bad locker");
        locker = locker_;
        emit LockerUpdated(locker_);
    }

    receive() external payable {
        if (claimingUser != address(0)) {
            require(msg.sender == address(LOTTERY), "Unexpected ETH during auto-claim");
            Plan storage p = plans[claimingUser];
            p.balance += msg.value;
            emit BalanceUpdated(claimingUser, p.balance, msg.value);
        } else {
            revert("Use deposit() to add funds");
        }
    }

    function deposit() public payable nonReentrant {
        require(msg.value > 0, "value=0");
        plans[msg.sender].balance += msg.value;
        emit Deposited(msg.sender, msg.value);
    }

    function withdraw(uint256 amount, address payable to) external nonReentrant {
        Plan storage p = plans[msg.sender];
        require(to != address(0), "bad to");
        require(amount > 0 && amount <= p.balance, "bad amount");
        p.balance -= amount;
        (bool ok,) = to.call{value: amount}("");
        require(ok, "transfer failed");
        emit Withdrawn(msg.sender, amount, to);
    }

    function configurePlan(
        uint32 plays,
        uint256 amountPerPlay,
        uint8[] calldata squares,
        uint16[] calldata bpsAlloc,
        bool autoClaim_,
        LockMode lockMode_
    ) external nonReentrant {
        _configurePlan(plays, amountPerPlay, squares, bpsAlloc, autoClaim_, lockMode_);
    }

    /// @notice Configure a plan and deposit funds in a single transaction
    function configurePlanAndDeposit(
        uint32 plays,
        uint256 amountPerPlay,
        uint8[] calldata squares,
        uint16[] calldata bpsAlloc,
        bool autoClaim_,
        LockMode lockMode_
    ) external payable nonReentrant {
        require(msg.value > 0, "value=0");
        _configurePlan(plays, amountPerPlay, squares, bpsAlloc, autoClaim_, lockMode_);
        plans[msg.sender].balance += msg.value;
        emit Deposited(msg.sender, msg.value);
    }

    function _configurePlan(
        uint32 plays,
        uint256 amountPerPlay,
        uint8[] calldata squares,
        uint16[] calldata bpsAlloc,
        bool autoClaim_,
        LockMode lockMode_
    ) private {
        // plays = UNLIMITED_PLAYS means unlimited (run until balance runs out)
        // plays = 0 means no plays (plan will be disabled immediately)
        require(amountPerPlay > 0, "amt=0");
        require(squares.length > 0 && squares.length == bpsAlloc.length, "bad arrays");
        // The lock only ever happens as part of an auto-claim, so a lock mode
        // without autoClaim would be a plan that silently never locks.
        require(lockMode_ == LockMode.None || autoClaim_, "lock needs autoClaim");
        require(lockMode_ == LockMode.None || locker != address(0), "locker not set");

        uint256 mask;
        uint256 sumBps;
        for (uint256 i = 0; i < squares.length; i++) {
            uint8 s = squares[i];
            require(s < 25, "square oob");
            uint256 bit = uint256(1) << uint256(s);
            require((mask & bit) == 0, "dup square");
            mask |= bit;

            uint16 b = bpsAlloc[i];
            require(b > 0, "bps=0");
            sumBps += b;
        }
        require(sumBps == BPS, "bps sum != 10000");

        Plan storage p = plans[msg.sender];

        delete p.squares;
        delete p.bpsAlloc;
        for (uint256 i = 0; i < squares.length; i++) {
            p.squares.push(squares[i]);
            p.bpsAlloc.push(bpsAlloc[i]);
        }

        p.enabled = true;
        // If autoClaim is enabled, automatically use UNLIMITED_PLAYS to prevent the confusing
        // "enabled but inert" state where playsRemaining=0 but enabled=true
        if (autoClaim_) {
            p.playsRemaining = UNLIMITED_PLAYS;
        } else {
            p.playsRemaining = plays;
        }
        p.amountPerPlay = amountPerPlay;
        p.autoClaim = autoClaim_;
        p.lockMode = lockMode_;
        p.nextRoundId = LOTTERY.currentRoundId();
        p.planStartRoundId = p.nextRoundId;

        emit PlanConfigured(msg.sender, p.nextRoundId, p.playsRemaining, amountPerPlay, autoClaim_);
        emit PlanLockModeSet(msg.sender, uint8(lockMode_));
    }

    function disablePlan() external nonReentrant {
        Plan storage p = plans[msg.sender];
        p.enabled = false;
        _disarmLockMode(p);
        emit PlanDisabled(msg.sender);
    }

    /// @notice Cancel the plan and refund all remaining balance to the caller
    function cancelPlan() external nonReentrant {
        Plan storage p = plans[msg.sender];
        uint256 refundAmount = p.balance;

        p.enabled = false;
        p.balance = 0;
        _disarmLockMode(p);

        if (refundAmount > 0) {
            (bool ok,) = payable(msg.sender).call{value: refundAmount}("");
            require(ok, "refund failed");
        }

        emit PlanCancelled(msg.sender, refundAmount);
        emit PlanDisabled(msg.sender);
    }

    /// @dev Stopping a plan withdraws lock consent: pending wins swept after a
    ///      cancel/disable must NOT be locked (Permanent mode would burn them
    ///      irreversibly after the user walked away). autoClaim deliberately
    ///      survives — the sweep keeper recovering post-cancel ETH winnings
    ///      into the (withdrawable) balance is benign and long-standing V2
    ///      behavior; only the irreversible disposition is disarmed.
    function _disarmLockMode(Plan storage p) private {
        if (p.lockMode != LockMode.None) {
            p.lockMode = LockMode.None;
            emit PlanLockModeSet(msg.sender, uint8(LockMode.None));
        }
    }

    /// @notice Execute a user's plan: claim the given winning rounds into the plan
    ///         balance, then bet on due rounds. Callable by anyone; the caller is
    ///         reimbursed metered gas + premium from the user's plan balance.
    /// @param user The plan owner
    /// @param maxPlays Max bets to place this call (1..MAX_PLAYS_PER_EXECUTION)
    /// @param claimRounds Rounds the keeper determined off-chain to be claimable wins
    ///        (resolved, user on winning square, not yet claimed). Each is re-verified
    ///        on-chain; a non-claimable entry is a harmless no-op, not a revert.
    function executeFor(address user, uint32 maxPlays, uint256[] calldata claimRounds) external nonReentrant {
        uint256 g0 = gasleft();

        uint256 claimed = _claimRounds(user, claimRounds);
        // The reserve prices THIS call's actual work: the claim batch just
        // processed plus the bet about to go out.
        uint32 executed = _executePlays(user, maxPlays, feeReserve(claimRounds.length));

        if (executed == 0 && claimed == 0) revert NothingToExecute();

        _chargeExecutorFee(user, g0);
    }

    /// @notice Claim winning rounds into the plan balance without betting.
    ///         Useful when the balance is too low to bet until winnings land.
    ///         Caller is reimbursed like executeFor.
    function claimFor(address user, uint256[] calldata claimRounds) external nonReentrant returns (uint256 claimed) {
        uint256 g0 = gasleft();

        claimed = _claimRounds(user, claimRounds);
        if (claimed == 0) revert NothingToExecute();

        _chargeExecutorFee(user, g0);
    }

    function _claimRounds(address user, uint256[] calldata claimRounds) private returns (uint256 claimed) {
        if (claimRounds.length == 0) return 0;
        require(claimRounds.length <= MAX_CLAIMS_PER_EXECUTION, "too many claims");
        if (!plans[user].autoClaim) return 0;

        for (uint256 i = 0; i < claimRounds.length; i++) {
            if (_claimForUser(user, claimRounds[i])) {
                claimed++;
            }
        }
    }

    function _executePlays(address user, uint32 maxPlays, uint256 reserve) private returns (uint32 executed) {
        require(maxPlays > 0, "max=0");
        require(maxPlays <= MAX_PLAYS_PER_EXECUTION, "max too high");

        Plan storage p = plans[user];
        if (!p.enabled) return 0;

        uint8[] storage squares = p.squares;
        uint16[] storage bpsAlloc = p.bpsAlloc;
        if (squares.length == 0 || squares.length != bpsAlloc.length) revert BadConfig();

        // `reserve` is priced once per call by the caller: basefee and the
        // premium are constant within a tx. NOTE the fee is charged ONCE per
        // call while this reserve is re-required per play — a multi-play call
        // can out-gas the single-bet pricing, where the caller (who chose
        // maxPlays) may eat the shortfall; the keeper executes one play per call.

        // Continue until maxPlays reached, or playsRemaining hits 0 (if not unlimited), or balance runs out
        while (executed < maxPlays) {
            if (p.playsRemaining != UNLIMITED_PLAYS && p.playsRemaining == 0) {
                break;
            }

            uint256 r = LOTTERY.currentRoundId();
            // Only bet when the current round has reached nextRoundId — cannot bet in advance
            if (r < p.nextRoundId) {
                break;
            }

            // If rounds were missed, skip ahead to the current round
            if (r > p.nextRoundId) {
                p.nextRoundId = r;
            }

            // If the current round is not open, wait for the next one
            if (!LOTTERY.roundOpen(p.nextRoundId)) {
                p.nextRoundId = r + 1;
                break;
            }

            uint256 usedRound = p.nextRoundId;
            if (executedRounds[user][usedRound]) {
                p.nextRoundId = usedRound + 1;
                continue;
            }

            // New lottery accounts pay a one-time account deposit on their first bet
            bool needsAccountDeposit = !LOTTERY.getHasAccount(user);
            uint256 betValue = p.amountPerPlay + (needsAccountDeposit ? ACCOUNT_DEPOSIT : 0);

            // Reserve the fee's realistic worst case at current gas prices so
            // the executor can always be paid after the bets go out; the
            // reserve stays withdrawable by the user. (V2 reserved the full
            // maxFeePerExecution cap — see feeReserve().)
            if (p.balance < betValue + reserve) {
                break;
            }
            require(address(this).balance >= betValue, "insufficient contract balance");

            uint256[] memory amounts = _buildAllocations(p.amountPerPlay, bpsAlloc);

            p.balance -= betValue;
            if (p.playsRemaining != UNLIMITED_PLAYS) {
                p.playsRemaining -= 1;
            }
            // Mark executed BEFORE the external call / nextRoundId bump
            executedRounds[user][usedRound] = true;
            p.nextRoundId = usedRound + 1;
            executed += 1;

            emit RoundExecuted(user, usedRound, p.playsRemaining);

            LOTTERY.betFor{value: betValue}(usedRound, user, squares, amounts);

            if (p.playsRemaining == 0) {
                p.enabled = false;
                emit PlanDisabled(user);
            }
        }
    }

    /// @notice Reimburse msg.sender for the gas this call consumed, from the user's
    ///         plan balance. See fee-model constants at the top of the contract.
    function _chargeExecutorFee(address user, uint256 g0) private {
        Plan storage p = plans[user];

        uint256 gasUsed = g0 - gasleft() + FEE_GAS_OVERHEAD;
        uint256 gasPrice = tx.gasprice;
        uint256 priceCap = block.basefee * 2;
        if (priceCap > 0 && gasPrice > priceCap) {
            gasPrice = priceCap;
        }

        uint256 fee = (gasUsed * gasPrice * (uint256(BPS) + feePremiumBps)) / BPS;
        if (fee > maxFeePerExecution) fee = maxFeePerExecution;
        if (fee > p.balance) fee = p.balance;
        if (fee == 0) return;

        p.balance -= fee;
        (bool ok,) = msg.sender.call{value: fee}("");
        require(ok, "fee transfer failed");

        emit ExecutorFeePaid(user, msg.sender, fee, gasUsed);
    }

    /// @dev Claims one round for the user via the lottery's delegate claim.
    ///      Verifies claimability on-chain (callers are untrusted); returns false
    ///      instead of reverting when the round is not claimable.
    ///
    ///      LockMode.None → V2's ETH-only claim: ETH into the plan balance, SLVR
    ///      left unrefined in miner state (keeps accruing refining dividends).
    ///      Permanent → FULL claim: ETH into the plan balance (same receive()
    ///      attribution), refined SLVR delivered to the locker and burned into
    ///      the user's permanent lock, bypassing the refining fee
    ///      (lottery-enforced: the locker must be an authorized permanent-lock
    ///      contract).
    function _claimForUser(address user, uint256 roundId) private returns (bool) {
        (bool resolved, uint8 winningSquare) = _roundResult(roundId);
        if (!resolved) return false;

        if (LOTTERY.getHasClaimed(roundId, user)) return false;

        uint256 userBet = LOTTERY.getUserBet(roundId, winningSquare, user);
        if (userBet == 0) return false;

        // Requires the user to have approved this contract as a delegate
        if (!LOTTERY.getDelegate(user, address(this))) {
            return false;
        }

        LockMode mode = plans[user].lockMode;
        // The lock path requires ALL of:
        //  - a lock mode on the plan (user consent, set on their own plan),
        //  - the round was bet BY THIS PLAN. claimFor is permissionless and
        //    only verifies round-level facts, so without this scope anyone
        //    could route a user's MANUAL winning bets into an irreversible
        //    lock. Non-plan rounds take the V2 ethOnly path below.
        //  - locking is actually operational end-to-end. When any grant in the
        //    chain is missing (locker swap mid-wiring, deferred vote-escrow
        //    flip, missing fee-bypass authorization) the claim DEGRADES to the
        //    ethOnly path — the SLVR stays unrefined in miner state, fully
        //    user-recoverable — instead of stranding fee-bypassed tokens at
        //    the locker.
        bool lockPath = mode != LockMode.None
            && executedRounds[user][roundId]
            && _lockingOperational();

        // Set claimingUser so receive() can attribute the ETH.
        // Reentrancy protection is provided by nonReentrant on the external callers.
        claimingUser = user;

        uint256 balanceBefore = address(this).balance;
        uint256 lockerSlvrBefore = lockPath ? SLVR.balanceOf(locker) : 0;
        uint256 claimedAmount = 0;
        bool success = false;

        ISlvrGridLottery.ClaimParams memory params;
        params.user = user;
        params.roundId = roundId;
        params.recipientNative = address(this);
        if (lockPath) {
            // Full claim: SLVR (this round's reward + any accrued refining
            // dividends) is refined out to the locker for locking.
            params.recipientSlvr = locker;
            params.bypassFee = mode == LockMode.Permanent;
            params.ethOnly = false;
        } else {
            // V2 path: pull ONLY the ETH winnings back into the plan and leave
            // the user's SLVR unrefined in miner state — so it keeps accruing
            // and earning dividends instead of being auto-refined every round
            // (which charged the 10% refining fee).
            params.recipientSlvr = user;
            params.bypassFee = false;
            params.ethOnly = true;
        }
        try LOTTERY.claimAdvanced(params) {
            uint256 balanceAfter = address(this).balance;
            claimedAmount = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
            success = true;
        } catch {
            success = false;
        }

        claimingUser = address(0);

        // claimAdvanced can succeed with nativeOut == 0 (tiny bets rounding to 0);
        // still counts as claimed so the keeper stops resubmitting the round.
        if (success) {
            if (lockPath) {
                uint256 slvrReceived = SLVR.balanceOf(locker) - lockerSlvrBefore;
                if (slvrReceived > 0) {
                    try ISlvrClaimLockerV2(locker).lockFromClaim(user, slvrReceived, true) {
                        emit ClaimedToLock(user, roundId, slvrReceived, true);
                    } catch {
                        // Reached only in a genuinely exceptional state, because
                        // _lockingOperational already gated this path, so the
                        // locker reverts ONLY if the vote escrow itself fails
                        // while fully wired (e.g. paused) — the SLVR then sits
                        // at the locker for an owner sweep(). Don't
                        // re-revert here: that would roll back the claim and the
                        // keeper would retry into the same wall forever. Surface
                        // it instead.
                        emit LockFailed(user, roundId, slvrReceived);
                    }
                }
            }
            emit Claimed(user, roundId, claimedAmount, claimedAmount);
            return true;
        }

        return false;
    }

    /// @dev True when the full locking chain is wired: the locker authorizes
    ///      this contract, holds the vote escrow's authorizedContract slot, and
    ///      is an authorized fee-bypass recipient on the lottery. All staticcalls
    ///      against contracts this one already trusts.
    function _lockingOperational() private view returns (bool) {
        address l = locker;
        if (l == address(0)) return false;
        try ISlvrClaimLockerV2(l).canAutoLock(address(this)) returns (bool ok) {
            if (!ok) return false;
        } catch {
            return false;
        }
        return LOTTERY.authorizedPermanentLockContracts(l);
    }

    function _roundResult(uint256 roundId) private view returns (bool resolved, uint8 winningSquare) {
        (, resolved,,, winningSquare,,,,,,,,,,,) = LOTTERY.getRound(roundId);
    }

    function _buildAllocations(uint256 amountPerPlay, uint16[] storage bpsAlloc)
        private
        view
        returns (uint256[] memory amounts)
    {
        uint256 n = bpsAlloc.length;
        amounts = new uint256[](n);
        uint256 allocated;
        for (uint256 i = 0; i < n; i++) {
            uint256 a = (amountPerPlay * bpsAlloc[i]) / BPS;
            amounts[i] = a;
            allocated += a;
        }
        if (n > 0 && allocated != amountPerPlay) {
            amounts[0] += (amountPerPlay - allocated);
        }
    }

    /// @dev V2's planInfo outputs plus a TRAILING lockMode — head-encoded tuples
    ///      decode identically for callers using the V2 ABI (the keeper).
    function planInfo(address user)
        external
        view
        returns (
            bool enabled,
            uint256 nextRoundId,
            uint32 playsRemaining,
            uint256 amountPerPlay,
            uint256 balance,
            bool autoClaim,
            uint8[] memory squares,
            uint16[] memory bpsAlloc,
            uint256 planStartRoundId,
            uint8 lockMode
        )
    {
        Plan storage p = plans[user];
        return (
            p.enabled,
            p.nextRoundId,
            p.playsRemaining,
            p.amountPerPlay,
            p.balance,
            p.autoClaim,
            p.squares,
            p.bpsAlloc,
            p.planStartRoundId,
            uint8(p.lockMode)
        );
    }

    /// @notice Check if a plan needs execution (ready to bet on the next round).
    ///         Mirrors _executePlays' preconditions, including the fee reserve.
    function needsExecution(address user) external view returns (bool ready, string memory reason) {
        Plan storage p = plans[user];

        if (p.playsRemaining == 0) {
            return (false, "no plays remaining");
        }

        if (!p.enabled) {
            return (false, "plan disabled");
        }

        uint256 currentRound = LOTTERY.currentRoundId();
        if (currentRound < p.nextRoundId) {
            return (false, "waiting for round to start");
        }

        uint256 targetRound = currentRound > p.nextRoundId ? currentRound : p.nextRoundId;

        if (executedRounds[user][targetRound]) {
            return (false, "round already executed");
        }

        if (!LOTTERY.roundOpen(targetRound)) {
            return (false, "round not open");
        }

        bool needsAccountDeposit = !LOTTERY.getHasAccount(user);
        uint256 betValue = p.amountPerPlay + (needsAccountDeposit ? ACCOUNT_DEPOSIT : 0);

        if (p.balance < betValue + feeReserve(0)) {
            return (false, "insufficient balance");
        }

        return (true, "");
    }
}
Chain explorer2157msChain node78ms