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
- Creator
- 0x11111972FE…c65b025B46
- 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