SlvrClaimLockerV2
0x83f84c5d431a986a1ab209f902b954b5d3550d8c
Verification
Verified
v0.8.33+commit.64118f21
Type
Contract
6,658 bytes
ABI entries
38
8 read · 8 write
License
none
Contract information
- Address
- 0x83f84c5d431a986a1ab209f902b954b5d3550d8c
- Chain
- Robinhood Chain (4663)
- Compiler
- v0.8.33+commit.64118f21
- Optimization
- Enabled
- Creator
- 0x11111972FE…c65b025B46
- Creation tx
- 0x493a349c21…99c2a5a8e4
Token
Not a token
This contract does not expose ERC-20 metadata.
Read contract (8)
LOTTERY() → address
SLVR() → address
VOTE_ESCROW() → address
authorizedLockers(address) → bool
autoLockTokenId(address) → uint256
canAutoLock(address) → bool
maxLockAvailable(address) → bool
owner() → address
Events (6)
AuthorizedLockerSetAutoLockFallbackAutoLockTargetSetAutoLockedClaimedAndLockedOwnershipTransferred
ABI
[
{
"inputs": [
{
"internalType": "address",
"name": "_lottery",
"type": "address"
},
{
"internalType": "address",
"name": "_voteEscrow",
"type": "address"
},
{
"internalType": "address",
"name": "_slvr",
"type": "address"
},
{
"internalType": "address",
"name": "_owner",
"type": "address"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [],
"name": "BadDuration",
"type": "error"
},
{
"inputs": [],
"name": "BadTarget",
"type": "error"
},
{
"inputs": [],
"name": "CannotAddToPermanentLock",
"type": "error"
},
{
"inputs": [],
"name": "InsufficientSlvr",
"type": "error"
},
{
"inputs": [],
"name": "NotAuthorizedLocker",
"type": "error"
},
{
"inputs": [],
"name": "NotOwner",
"type": "error"
},
{
"inputs": [],
"name": "NotUser",
"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": "PermanentLockExists",
"type": "error"
},
{
"inputs": [],
"name": "PermanentLockUnavailable",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "SafeERC20FailedOperation",
"type": "error"
},
{
"inputs": [],
"name": "TransferFailed",
"type": "error"
},
{
"inputs": [],
"name": "VoteEscrowNotSet",
"type": "error"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "locker",
"type": "address"
},
{
"indexed": false,
"internalType": "bool",
"name": "authorized",
"type": "bool"
}
],
"name": "AuthorizedLockerSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "user",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "AutoLockFallback",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "user",
"type": "address"
},
{
"indexed": true,
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "AutoLockTargetSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "user",
"type": "address"
},
{
"indexed": true,
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
},
{
"indexed": false,
"internalType": "bool",
"name": "permanent",
"type": "bool"
}
],
"name": "AutoLocked",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "uint256",
"name": "roundId",
"type": "uint256"
},
{
"indexed": true,
"internalType": "address",
"name": "user",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"indexed": false,
"internalType": "bool",
"name": "permanent",
"type": "bool"
},
{
"indexed": false,
"internalType": "uint256",
"name": "duration",
"type": "uint256"
}
],
"name": "ClaimedAndLocked",
"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"
},
{
"inputs": [],
"name": "LOTTERY",
"outputs": [
{
"internalType": "contract ISlvrGridLottery",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "SLVR",
"outputs": [
{
"internalType": "contract IERC20",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "VOTE_ESCROW",
"outputs": [
{
"internalType": "contract ISlvrVoteEscrow",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "authorizedLockers",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "autoLockTokenId",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "user",
"type": "address"
},
{
"internalType": "uint256[]",
"name": "roundIds",
"type": "uint256[]"
},
{
"internalType": "bool",
"name": "permanent",
"type": "bool"
},
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "duration",
"type": "uint256"
}
],
"name": "batchClaimAndLock",
"outputs": [
{
"internalType": "uint256",
"name": "tokenId_",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "caller",
"type": "address"
}
],
"name": "canAutoLock",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "user",
"type": "address"
},
{
"internalType": "uint256",
"name": "roundId",
"type": "uint256"
},
{
"internalType": "bool",
"name": "permanent",
"type": "bool"
},
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "duration",
"type": "uint256"
}
],
"name": "claimAndLock",
"outputs": [
{
"internalType": "uint256",
"name": "tokenId_",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "user",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
},
{
"internalType": "bool",
"name": "permanent",
"type": "bool"
}
],
"name": "lockFromClaim",
"outputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "user",
"type": "address"
}
],
"name": "maxLockAvailable",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "owner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "renounceOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "locker",
"type": "address"
},
{
"internalType": "bool",
"name": "authorized",
"type": "bool"
}
],
"name": "setAuthorizedLocker",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "setAutoLockTarget",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
},
{
"internalType": "address payable",
"name": "to",
"type": "address"
}
],
"name": "sweep",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "transferOwnership",
"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 {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ISlvrGridLottery} from "./interfaces/ISlvrGridLottery.sol";
import {ISlvrVoteEscrow} from "./interfaces/ISlvrVoteEscrow.sol";
import {ISlvrClaimLockerV2} from "./interfaces/ISlvrClaimLockerV2.sol";
/// @title SlvrClaimLockerV2
/// @notice The vote-escrow locking hub. Replaces SlvrClaimLocker as the vote
/// escrow's single `authorizedContract`, and serves two paths:
///
/// 1. MANUAL — `claimAndLock`/`batchClaimAndLock`, byte-compatible with
/// SlvrClaimLocker's signatures so the UI only changes the address.
/// Callable only by the user themselves (griefing protection: a
/// delegate must never be able to force someone's rewards into a
/// permanent, non-withdrawable lock).
/// 2. AUTO — `lockFromClaim`, callable only by owner-authorized locker
/// contracts (SlvrAutoCommitV3). Consent comes from the user having
/// set a lock mode on their own auto-commit plan; the auto-commit
/// contract routes claimed SLVR here and this contract locks it.
///
/// The vote escrow has no registry of a user's TMAX locks (its
/// getMaxLockTokenId only knows permanent locks) and addToMaxLock mints
/// a NEW TMAX NFT on every call — so the auto path tracks its own
/// per-user target NFT (`autoLockTokenId`) and tops it back up to TMAX
/// on every claim instead of minting one NFT per win.
///
/// @dev SLVR amounts are always measured as balance deltas by the callers and
/// passed in explicitly; SLVR never legitimately rests in this contract
/// between transactions. The auto path never reverts for lock-shaped
/// reasons — when a lock is impossible (e.g. the user wants a TMAX lock
/// but holds a permanent one) the refined SLVR falls back to their wallet.
contract SlvrClaimLockerV2 is Ownable, ISlvrClaimLockerV2 {
using SafeERC20 for IERC20;
ISlvrGridLottery public immutable LOTTERY;
ISlvrVoteEscrow public immutable VOTE_ESCROW;
IERC20 public immutable SLVR;
/// @dev The vote escrow's TMAX is a compile-time constant there — cached at
/// deploy so lock paths don't pay an external call per claim.
uint256 private immutable TMAX;
/// @notice Contracts allowed to call lockFromClaim (SlvrAutoCommitV3).
mapping(address => bool) public authorizedLockers;
/// @notice The veNFT the auto path adds a user's TMAX locks into. Created on
/// first auto-lock; users may point it at an NFT they already own.
mapping(address => uint256) public autoLockTokenId;
event ClaimedAndLocked(
uint256 indexed roundId,
address indexed user,
uint256 tokenId,
bool permanent,
uint256 duration
);
event AutoLocked(address indexed user, uint256 indexed tokenId, uint256 amount, bool permanent);
/// @notice The auto path could not lock (e.g. TMAX requested but a permanent
/// lock exists, or the vote escrow reverted) — the refined SLVR was
/// sent to the user's wallet instead.
event AutoLockFallback(address indexed user, uint256 amount);
event AuthorizedLockerSet(address indexed locker, bool authorized);
event AutoLockTargetSet(address indexed user, uint256 indexed tokenId);
error VoteEscrowNotSet();
error NotUser();
error NotOwner();
error NotAuthorizedLocker();
error CannotAddToPermanentLock();
error BadDuration();
error TransferFailed();
error InsufficientSlvr();
error PermanentLockExists();
error BadTarget();
error PermanentLockUnavailable();
constructor(address _lottery, address _voteEscrow, address _slvr, address _owner) Ownable(_owner) {
LOTTERY = ISlvrGridLottery(_lottery);
VOTE_ESCROW = ISlvrVoteEscrow(_voteEscrow);
SLVR = IERC20(_slvr);
TMAX = ISlvrVoteEscrow(_voteEscrow).TMAX();
}
/// @inheritdoc ISlvrClaimLockerV2
function canAutoLock(address caller) external view returns (bool) {
return authorizedLockers[caller] && VOTE_ESCROW.authorizedContract() == address(this);
}
/// @inheritdoc ISlvrClaimLockerV2
function maxLockAvailable(address user) external view returns (bool) {
if (VOTE_ESCROW.getPermanentLockTokenId(user) == 0) return true;
uint256 target = autoLockTokenId[user];
return target != 0 && _usableAutoTarget(user, target);
}
/// @notice Authorize/deauthorize a contract for the auto lock path.
function setAuthorizedLocker(address locker, bool authorized) external onlyOwner {
authorizedLockers[locker] = authorized;
emit AuthorizedLockerSet(locker, authorized);
}
/// @notice Point your auto-lock at a veNFT you already own (0 clears — the
/// next auto-lock creates a fresh TMAX lock). The target must be a
/// non-permanent lock you own; it is re-validated at lock time.
function setAutoLockTarget(uint256 tokenId) external {
if (tokenId != 0) {
if (!_usableAutoTarget(msg.sender, tokenId)) revert BadTarget();
}
autoLockTokenId[msg.sender] = tokenId;
emit AutoLockTargetSet(msg.sender, tokenId);
}
// ---------------------------------------------------------------------
// AUTO PATH — called by SlvrAutoCommitV3 after a delegate claim delivered
// refined SLVR to this contract.
// ---------------------------------------------------------------------
/// @inheritdoc ISlvrClaimLockerV2
function lockFromClaim(address user, uint256 amount, bool permanent)
external
returns (uint256 tokenId)
{
if (!authorizedLockers[msg.sender]) revert NotAuthorizedLocker();
if (amount == 0) return 0;
// The claim that just executed must have delivered the SLVR here.
if (SLVR.balanceOf(address(this)) < amount) revert InsufficientSlvr();
if (permanent) {
// addToMaxLock(permanent=true) creates the user's single permanent
// lock or adds to it — no revert paths beyond genuine failures.
//
// NO wallet fallback here, deliberately: a permanent-mode claim was
// taken with the refining fee BYPASSED on the promise the SLVR gets
// burned. Falling back to a transfer would hand out fee-free liquid
// SLVR (a refining-fee evasion during any mis-wired window), so a
// lock failure reverts instead — the caller (SlvrAutoCommitV3)
// pre-checks canAutoLock/authorizedPermanentLockContracts and takes
// the ETH-only claim path when locking isn't operational, making
// this revert unreachable outside truly exceptional states.
SLVR.forceApprove(address(VOTE_ESCROW), amount);
try VOTE_ESCROW.addToMaxLock(user, amount, true) returns (uint256 id) {
emit AutoLocked(user, id, amount, true);
return id;
} catch {
revert PermanentLockUnavailable();
}
}
// TMAX mode: reuse the tracked target so wins compound into ONE NFT,
// re-extending it to full TMAX on every claim.
uint256 target = autoLockTokenId[user];
if (target != 0 && _usableAutoTarget(user, target)) {
SLVR.forceApprove(address(VOTE_ESCROW), amount);
try VOTE_ESCROW.increaseLockFor(target, amount, TMAX) {
emit AutoLocked(user, target, amount, false);
return target;
} catch {
SLVR.forceApprove(address(VOTE_ESCROW), 0);
return _fallbackToWallet(user, amount);
}
}
// No usable target. A permanent lock blocks creating TMAX locks in the
// vote escrow, and silently burning into the permanent lock instead
// would destroy value the user chose to keep withdrawable — fall back.
if (VOTE_ESCROW.getPermanentLockTokenId(user) != 0) {
return _fallbackToWallet(user, amount);
}
SLVR.forceApprove(address(VOTE_ESCROW), amount);
try VOTE_ESCROW.addToMaxLock(user, amount, false) returns (uint256 id) {
autoLockTokenId[user] = id;
emit AutoLockTargetSet(user, id);
emit AutoLocked(user, id, amount, false);
return id;
} catch {
SLVR.forceApprove(address(VOTE_ESCROW), 0);
return _fallbackToWallet(user, amount);
}
}
/// @dev A target is usable when the token still exists, the user still owns
/// it, and it is not permanent (increaseLockFor rejects permanent).
function _usableAutoTarget(address user, uint256 tokenId) private view returns (bool) {
try VOTE_ESCROW.ownerOf(tokenId) returns (address o) {
if (o != user) return false;
} catch {
return false; // burned/withdrawn
}
return !VOTE_ESCROW.getLock(tokenId).permanent;
}
/// @dev Deliver refined SLVR to the user's wallet when locking is impossible.
function _fallbackToWallet(address user, uint256 amount) private returns (uint256) {
SLVR.safeTransfer(user, amount);
emit AutoLockFallback(user, amount);
return 0;
}
// ---------------------------------------------------------------------
// MANUAL PATH — ported from SlvrClaimLocker (same signatures/semantics),
// with received SLVR measured as a balance DELTA rather than the whole
// contract balance, so stray tokens can never be locked into the wrong NFT.
// ---------------------------------------------------------------------
/// @notice Claim rewards from a round and lock into a veNFT
/// @param user The user to claim for (must have approved this contract as delegate)
/// @param roundId The round ID to claim from
/// @param permanent If true, create a permanent lock (burn). If false, create a time-based lock.
/// @param tokenId If > 0, add to existing lock. If 0, create new lock.
/// @param duration Lock duration in seconds (only used if permanent=false and tokenId=0). Use TMAX for max lock.
/// @return tokenId_ The token ID of the lock (new or existing)
function claimAndLock(
address user,
uint256 roundId,
bool permanent,
uint256 tokenId,
uint256 duration
) external returns (uint256 tokenId_) {
if (address(VOTE_ESCROW) == address(0)) revert VoteEscrowNotSet();
// Only the user themselves may route their own rewards into a lock (see
// contract natspec — permanent locks are non-withdrawable).
if (msg.sender != user) revert NotUser();
(uint256 slvrAmount) = _claimToSelf(user, roundId, permanent);
if (slvrAmount > 0) {
tokenId_ = _lockSlvr(user, slvrAmount, permanent, tokenId, duration);
} else {
tokenId_ = tokenId;
}
emit ClaimedAndLocked(roundId, user, tokenId_, permanent, permanent ? 0 : duration);
}
/// @notice Batch claim rewards from multiple rounds and lock into a veNFT
/// @dev See claimAndLock for parameter semantics.
function batchClaimAndLock(
address user,
uint256[] calldata roundIds,
bool permanent,
uint256 tokenId,
uint256 duration
) external returns (uint256 tokenId_) {
if (address(VOTE_ESCROW) == address(0)) revert VoteEscrowNotSet();
if (msg.sender != user) revert NotUser();
uint256 currentTokenId = tokenId;
for (uint256 i = 0; i < roundIds.length; i++) {
uint256 slvrAmount = _claimToSelf(user, roundIds[i], permanent);
if (slvrAmount > 0) {
uint256 resultTokenId = _lockSlvr(user, slvrAmount, permanent, currentTokenId, duration);
if (resultTokenId > 0) {
currentTokenId = resultTokenId;
}
}
emit ClaimedAndLocked(roundIds[i], user, currentTokenId, permanent, permanent ? 0 : duration);
}
tokenId_ = currentTokenId;
}
/// @dev Claim one round with this contract as both recipients; forward the
/// native winnings to the user and return the SLVR received (delta).
/// Fee bypass only applies to permanent locks (lottery-enforced).
function _claimToSelf(address user, uint256 roundId, bool permanent) private returns (uint256 slvrAmount) {
uint256 slvrBefore = SLVR.balanceOf(address(this));
ISlvrGridLottery.ClaimParams memory params;
params.user = user;
params.roundId = roundId;
params.recipientNative = address(this);
params.recipientSlvr = address(this);
params.bypassFee = permanent;
params.ethOnly = false;
LOTTERY.claimAdvanced(params);
slvrAmount = SLVR.balanceOf(address(this)) - slvrBefore;
uint256 nativeBalance = address(this).balance;
if (nativeBalance > 0) {
(bool ok,) = payable(user).call{value: nativeBalance}("");
if (!ok) revert TransferFailed();
}
}
/// @dev Lock SLVR into a veNFT — ported unchanged from SlvrClaimLocker.
function _lockSlvr(
address user,
uint256 amount,
bool permanent,
uint256 tokenId,
uint256 duration
) internal returns (uint256 tokenId_) {
uint256 tmax = TMAX;
if (permanent) {
uint256 existingPermanentLockId = VOTE_ESCROW.getPermanentLockTokenId(user);
if (existingPermanentLockId > 0) {
tokenId = existingPermanentLockId;
duration = 0;
}
} else {
uint256 existingMaxLockId = VOTE_ESCROW.getMaxLockTokenId(user);
if (existingMaxLockId > 0) {
ISlvrVoteEscrow.Lock memory existingLock = VOTE_ESCROW.getLock(existingMaxLockId);
if (existingLock.permanent) {
revert PermanentLockExists();
}
if (tokenId == 0 && duration == tmax) {
tokenId = existingMaxLockId;
duration = 0;
} else if (tokenId > 0 && tokenId != existingMaxLockId && duration == tmax) {
tokenId = existingMaxLockId;
duration = 0;
} else if (tokenId == existingMaxLockId && duration == tmax) {
duration = 0;
}
}
}
SLVR.forceApprove(address(VOTE_ESCROW), amount);
if (permanent || duration == tmax) {
tokenId_ = VOTE_ESCROW.addToMaxLock(user, amount, permanent);
} else if (tokenId > 0) {
if (VOTE_ESCROW.ownerOf(tokenId) != user) revert NotOwner();
ISlvrVoteEscrow.Lock memory existingLock = VOTE_ESCROW.getLock(tokenId);
if (existingLock.permanent) revert CannotAddToPermanentLock();
VOTE_ESCROW.increaseLockFor(tokenId, amount, 0);
tokenId_ = tokenId;
} else {
if (duration == 0 || duration > tmax) revert BadDuration();
tokenId_ = VOTE_ESCROW.createLockFor(user, amount, duration);
}
}
/// @notice Recover tokens or ETH stranded here by a failed downstream call.
/// No user funds legitimately rest in this contract between
/// transactions (both paths settle within one tx), so this can only
/// ever touch strays.
function sweep(address token, address payable to) external onlyOwner {
if (token == address(0)) {
(bool ok,) = to.call{value: address(this).balance}("");
if (!ok) revert TransferFailed();
} else {
IERC20(token).safeTransfer(to, IERC20(token).balanceOf(address(this)));
}
}
// Receive native claim proceeds mid-claim.
receive() external payable {}
}
Chain explorer3336msChain node87ms