Shares-based staking contract for $RNBW on Base. Exit fees drip linearly into the pool over a configurable period, increasing the exchange rate for remaining stakers. Positions are non-transferable.
- Chain: Base (EVM)
- Solidity: 0.8.24
- Framework: Foundry
- Dependencies: OpenZeppelin Contracts
| Feature | Details |
|---|---|
| Staking | stake() / stakeFor() / stakeForWithSignature() -- user calls directly or via relay (Gelato Turbo / Relay.link / EIP-7702). stakeFor enables third-party integrations (e.g., liquid staking token). stakeForWithSignature stakes from a pre-funded reserve with EIP-712 authorization. |
| Unstaking | unstake() -- user calls directly or via relay. Partial unstake toggleable by admin (default: disabled) |
| Exit fee | Configurable 1%--75%, default 10% -- dripped linearly into pool over dripDuration (default 7 days, configurable 1--60 days) |
| Cashback | Backend allocates via allocateCashbackWithSignature() -- mints shares immediately in one step |
| Dead shares | First deposit mints 1000 shares to 0xdead (UniswapV2-style inflation protection) |
| Cashback reserve | Pre-funded via fundCashbackReserve(), tracked separately, protected from emergency withdrawal |
| Staking reserve | Pre-funded via fundStakingReserve(), used by stakeForWithSignature, protected from emergency withdrawal |
| Access control | Safe (multisig) for admin ops, 2-step safe transfer, up to 3 trusted EIP-712 signers |
| Signatures | EIP-712 with expiry + nonce replay protection (cashback and stakeFor) |
When you stake RNBW you receive shares, not a 1:1 token balance. The exchange rate between shares and RNBW changes over time as exit fees drip into the pool (linearly over dripDuration).
Exchange Rate = totalPooledRnbw / totalShares
Your RNBW Value = yourShares * totalPooledRnbw / totalShares
The first staker gets shares at a 1:1 ratio (minus 1000 dead shares for inflation protection). Every subsequent staker gets shares at the current exchange rate.
Entry points:
stake(amount)-- user calls directly or via relay.msg.senderis both the token source and share recipient.stakeFor(recipient, amount)-- tokens come frommsg.sender, shares go torecipient. Built for third-party integrations like a liquid staking token (xRNBW). Reverts ifrecipientisaddress(0),address(this), orDEAD_ADDRESS.stakeForWithSignature(recipient, amount, nonce, expiry, signature)-- signature-gated staking from the pre-funded staking reserve. A trusted signer authorizes the operation off-chain; RNBW comes fromstakingReserve(funded viafundStakingReserve()), shares go torecipient, and any address can submit the tx (relayer). Two roles: signer (authorizes off-chain), caller/relayer (pays gas). Nonce is scoped to the recipient address. Same trust model as cashback -- compromised signer can drain the staking reserve by generating valid signatures. Risk is bounded by the reserve balance and the signature expiry window.
Preview: previewStake(user, amount) returns sharesToMint (returns 0 for dust amounts and for first-time stakers below minStakeAmount).
Steps:
- Validate -- amount > 0, first-time stakers must meet
minStakeAmount.stakeForadditionally rejects forbidden recipients. - Transfer -- RNBW moves from
msg.senderto contract viasafeTransferFrom(stake/stakeFor), or is deducted from the pre-fundedstakingReserve(stakeForWithSignature). - Calculate shares --
sharesToMint = (amount * totalShares) / totalPooledRnbw. First staker:sharesToMint = amount - 1000(dead shares minted to0xdead). - Guard -- reverts with
ZeroSharesMintedif shares round to 0. - Mint -- update
shares[recipient],totalShares,totalPooledRnbw. - Metadata -- set
stakingStartTime(first stake), accumulatetotalRnbwStakedon the recipient's position.
Example:
Pool: totalPooledRnbw = 100,000, totalShares = 100,000
--- Alice stakes 50,000 RNBW ---
sharesToMint = (50,000 * 100,000) / 100,000 = 50,000
Pool after: totalPooledRnbw = 150,000, totalShares = 150,000
Alice: 50,000 shares = 50,000 RNBW
Entry points:
unstake(sharesToBurn)-- burn specific shares. Subject to partial unstake toggle.unstakeAll()-- burn all shares. Always allowed regardless of partial unstake setting.
Partial unstake is disabled by default. When disabled, unstake() only accepts full unstake (sharesToBurn == shares[user]). Admin can enable via setAllowPartialUnstake(true).
The parameter is shares to burn, not RNBW amount. Use getSharesForRnbw(desiredAmount) to convert, or unstakeAll() for full exit.
Preview: previewUnstake(sharesToBurn) returns (rnbwValue, exitFee, netReceived).
Steps:
Both functions return netAmount (the RNBW transferred to the user after exit fee).
- Sync pool -- settle any pending fee drip into
totalPooledRnbwbefore calculating. - Validate -- shares > 0, sufficient balance, partial unstake check, dust guard (partial unstake must not leave fewer than
MIN_SHARES_THRESHOLDshares). - Calculate value --
rnbwValue = (sharesToBurn * totalPooledRnbw) / totalShares. - Exit fee --
exitFee = rnbwValue * exitFeeBps / 10,000(ceil-rounded, default 10%). - Net amount -- if ceil-rounded fee consumes everything (dust),
netAmount = 0and shares are burned with no transfer (lets users clear dust positions). - Burn shares -- deduct from user and totals. Full
rnbwValueis removed from pool; exit fee is routed to the drip pipeline (see Exit Fee Distribution). - Residual sweep -- if only dead shares remain, sweep pool and undistributed fees to safe and reset to clean slate.
- Metadata -- track
totalRnbwUnstaked,totalExitFeePaid. ResetstakingStartTimeon full exit. - Transfer -- send
netAmountto user (skipped whennetAmount == 0).
Example:
Pool: totalPooledRnbw = 100,000, totalShares = 100,000
Alice: 50,000 shares, Bob: 50,000 shares
--- Bob unstakes 50,000 shares ---
rnbwValue = (50,000 * 100,000) / 100,000 = 50,000 RNBW
exitFee = 50,000 * 1000 / 10,000 = 5,000 RNBW
netAmount = 50,000 - 5,000 = 45,000 RNBW
Immediately after:
totalPooledRnbw = 50,000 (full 50,000 removed)
undistributedFees = 5,000 (exit fee enters drip pipeline)
totalShares = 50,000
Exchange rate = 1.0 (unchanged — fee hasn't dripped yet)
Bob receives: 45,000 RNBW
After 7 days (drip complete):
totalPooledRnbw = 55,000 (5,000 dripped in linearly)
undistributedFees ≈ 0
Exchange rate = 1.10 (Alice gained 5,000 RNBW over 7 days)
The exit fee does not increase the exchange rate instantly. It drips into the pool linearly over dripDuration, producing a smooth exchange rate curve.
Exit fees are not added to the pool instantly. Instead, they are buffered in undistributedFees and dripped linearly into totalPooledRnbw over dripDuration (default 7 days, configurable 1--60 days via setDripDuration).
This prevents two attack vectors:
- Whale self-absorption: A whale who holds most of the pool cannot unstake in chunks and instantly recapture their own exit fees, because the fees haven't entered the pool yet.
- APY manipulation: An attacker cannot spike the exchange rate in a single block to fake high APY, because fee distribution is spread over days.
- Fee enters pipeline -- when a user unstakes, the full
rnbwValueis removed fromtotalPooledRnbw. The exit fee is passed to_addFees(), which adds it toundistributedFeesand setsrewardRateanddripEndTime. - Linear drip --
_syncPool()is called before every state-changing operation. It calculatesearned = elapsed * rewardRate, moves that amount fromundistributedFeesintototalPooledRnbw, and emitsExchangeRateUpdated. When the full drip window has elapsed, all remainingundistributedFeesare flushed at once (avoiding dust from integer division), andrewardRate/dripEndTimeare zeroed. - View functions --
getExchangeRate(),getRnbwForShares(),previewUnstake(), etc. use_effectivePooledRnbw()which simulates the pending drip without mutating state, so the frontend always shows the accurate current value. - Overlapping drips (rate preservation) -- if a second unstake happens mid-drip,
_syncPool()settles what's owed so far, then_addFees()applies rate-preservation logic:- No active drip (
block.timestamp >= dripEndTime): start a fresh cycle —rewardRate = undistributedFees / dripDuration, new 7-day window. - Rate goes up (
proposedRate >= rewardRate): the new fee is large enough to increase the drip speed — reset the full drip window with the higher rate. - Rate stays flat (
proposedRate < rewardRate): the new fee is small (e.g., dust unstake) — keep the currentrewardRateand extenddripEndTimejust enough to distribute the remaining fees at the current speed. This prevents an attacker from repeatedly dust-unstaking to reset the 7-day window and delay fee distribution.
- No active drip (
Day 0: Bob unstakes, 5,000 RNBW exit fee → undistributedFees = 5,000
rewardRate = 5,000 / 7 days ≈ 714.3 RNBW/day
Day 3: Next operation triggers _syncPool()
earned = 3 * 714.3 ≈ 2,143 RNBW moved to pool
undistributedFees ≈ 2,857
Day 3: Charlie unstakes, 2,000 RNBW exit fee (large → rate goes up)
undistributedFees = 2,857 + 2,000 = 4,857
proposedRate = 4,857 / 7 days ≈ 694 ≥ 714? No → keep rate at 714
dripEndTime extended to Day 3 + (4,857 / 714) ≈ Day 9.8
Day 3: (alternatively, if Charlie's fee were larger, e.g. 10,000 RNBW)
undistributedFees = 2,857 + 10,000 = 12,857
proposedRate = 12,857 / 7 days ≈ 1,837 ≥ 714? Yes → reset window
rewardRate = 1,837 RNBW/day, fresh 7-day window from Day 3
Key invariant: totalPooledRnbw + undistributedFees always equals the total RNBW that belongs to stakers.
Entry point: allocateCashbackWithSignature(user, rnbwCashback, nonce, expiry, sig) -- any msg.sender, signature validated.
Contract must be pre-funded via fundCashbackReserve(amount). The reserve is tracked separately from the staking pool and protected from emergencyWithdraw.
Cashback mints shares immediately -- no pending balance or compound step.
Steps:
- Validate signature -- EIP-712, expiry check, nonce replay protection.
- Check position -- user must have
shares > 0. - Check reserve --
rnbwCashback <= cashbackReserve. - Calculate shares --
sharesToMint = (rnbwCashback * totalShares) / totalPooledRnbw. - Guard -- reverts with
ZeroSharesMintedif shares round to 0. - Mint shares -- update balances, deduct from
cashbackReserve, accumulatetotalCashbackReceived.
No token transfer happens -- RNBW is already in the contract from fundCashbackReserve(). It moves from reserve into totalPooledRnbw by minting shares.
Cashback requires shares[user] > 0. If a user fully unstakes before their pending cashback is allocated, the call reverts with NoStakePosition. The backend must allocate cashback before or alongside unstaking -- never after a full exit. Unused reserve stays available for other users or can be reclaimed via defundCashbackReserve().
Nonces are arbitrary per (user, nonce) -- not sequential. Nonce 9999 can be used before nonce 1. This allows out-of-order processing (retries, parallel workers, batch resubmission). The expiry timestamp is the invalidation mechanism -- use short expiries (e.g., 1 hour) so stale signatures die quickly.
Shared nonce space: cashback and stakeForWithSignature share the same usedNonces[address][nonce] mapping. If the same address is both a cashback recipient and a stakeFor recipient, using nonce N for one operation blocks nonce N for the other. Cross-operation replay is still impossible (different typehashes mean signatures are not interchangeable), but the backend must avoid reusing the same nonce number for the same address across operation types. Simplest approach: use separate nonce ranges per operation (e.g., cashback: 0–999999, stakeFor: 1000000+).
Example:
Pool: totalPooledRnbw = 100,000, totalShares = 100,000
cashbackReserve = 5,000, Alice: 50,000 shares
--- 500 RNBW cashback to Alice ---
sharesToMint = (500 * 100,000) / 100,000 = 500
Pool after: totalPooledRnbw = 100,500, totalShares = 100,500
cashbackReserve = 4,500, Alice: 50,500 shares
| UI Element | Source |
|---|---|
| Staked RNBW | getPosition(user).stakedAmount (= shares converted at current exchange rate) |
| Exchange Rate | getExchangeRate() (scaled by 1e18) |
| Shares (advanced) | getPosition(user).userShares |
| Staking Since | getPosition(user).stakingStartTime |
| Lifetime Cashback | getPosition(user).totalCashbackReceived |
| Lifetime Staked | getPosition(user).totalRnbwStaked |
| Lifetime Unstaked | getPosition(user).totalRnbwUnstaked (net, after exit fee) |
| Lifetime Exit Fees | getPosition(user).totalExitFeePaid |
| Preview Stake | previewStake(user, amount) → shares to mint (0 for first-time stakers below min) |
| Preview Unstake | previewUnstake(shares) → (rnbwValue, exitFee, netReceived) |
Global APY is computed off-chain by comparing on-chain state at two blocks. No indexer required.
| Component | What drives it | On-chain signal |
|---|---|---|
| Exit Fee APY | Users unstaking (10% drips into pool over dripDuration) |
getExchangeRate() increases smoothly |
| Cashback APY | Cashback allocated to stakers | totalCashbackAllocated increases |
Cashback does not move the exchange rate (shares and pool grow proportionally), so it must be tracked separately.
// Block A (7 days ago)
const rateA = await contract.getExchangeRate({ blockTag: blockA });
const cashbackA = await contract.totalCashbackAllocated({ blockTag: blockA });
const poolA = await contract.totalPooledRnbw({ blockTag: blockA });
const tsA = (await provider.getBlock(blockA)).timestamp;
// Block B (now)
const rateB = await contract.getExchangeRate({ blockTag: blockB });
const cashbackB = await contract.totalCashbackAllocated({ blockTag: blockB });
const tsB = (await provider.getBlock(blockB)).timestamp;const SECONDS_PER_YEAR = 365.25 * 86400; // 31_557_600
const elapsed = tsB - tsA;
// 1. Exit Fee APY (from exchange rate growth)
const exitFeeApy = ((rateB / rateA) ** (SECONDS_PER_YEAR / elapsed) - 1) * 100;
// 2. Cashback APY (from global counter delta)
const cashbackApy = ((cashbackB - cashbackA) / poolA) * (SECONDS_PER_YEAR / elapsed) * 100;
// 3. Total APY
const totalApy = exitFeeApy + cashbackApy;| Window | Block gap (Base, 2s blocks) | Use case |
|---|---|---|
| 24 hours | ~43,200 blocks | Daily data point |
| 7 days | ~302,400 blocks | Primary displayed APY |
| 30 days | ~1,296,000 blocks | Long-term APY |
7 days balances smoothness with responsiveness. The linear fee drip ensures the exchange rate curve is smooth within any window, eliminating single-block spikes.
const blockB = await provider.getBlockNumber(); // now
const blockA = blockB - 302_400; // ~7 days ago on Baseconst [
stakedAmount, userShares, lastUpdateTime, stakingStartTime,
totalCashbackReceived, totalRnbwStaked, totalRnbwUnstaked, totalExitFeePaid
] = await contract.getPosition(user);
// Lifetime net profit (cashback already included in stakedAmount via shares)
const netProfit = stakedAmount + totalRnbwUnstaked - totalRnbwStaked;
// Decomposition
const totalCashbackEarned = totalCashbackReceived;
const exchangeRateGain = netProfit - totalCashbackEarned + totalExitFeePaid;
// Invariant: netProfit == totalCashbackEarned + exchangeRateGain - totalExitFeePaidGlobal APY (2-block approach):
| Metric | Formula | Meaning |
|---|---|---|
| Total APY | exitFeeApy + cashbackApy |
Annualized return for stakers |
| Exit Fee APY | (rateB/rateA)^(year/elapsed) - 1 |
Yield from other users unstaking |
| Cashback APY | (deltaCashback/pool) * (year/elapsed) |
Yield from cashback program |
Per-User P&L:
| Metric | Formula | Meaning |
|---|---|---|
| Net Profit | stakedAmount + totalUnstaked - totalStaked |
User's all-time profit/loss in RNBW |
| Cashback Earned | totalCashbackReceived |
Lifetime cashback allocated |
| Exit Fees Paid | totalExitFeePaid |
Lifetime exit fees deducted |
| Exchange Rate Gain | netProfit - cashbackEarned + exitFeesPaid |
Pure staking yield (exit fee redistribution) |
| Decomposition | netProfit == cashbackEarned + exchangeRateGain - exitFeesPaid |
Invariant check |
Per-wallet APY requires an indexer that replays contract events and computes Time-Weighted Average Capital (TWAC).
CREATE TABLE staking_events (
id BIGSERIAL PRIMARY KEY,
wallet TEXT NOT NULL,
event_type TEXT NOT NULL, -- 'stake', 'unstake', 'cashback'
block_number BIGINT NOT NULL,
block_timestamp TIMESTAMPTZ NOT NULL,
rnbw_amount NUMERIC(78,0) NOT NULL, -- gross: rnbwAmount (stake/cashback), rnbwValue (unstake)
shares_delta NUMERIC(78,0) NOT NULL,
exchange_rate NUMERIC(38,18) NOT NULL,
exit_fee NUMERIC(78,0) DEFAULT 0, -- only populated for unstake events
tx_hash TEXT NOT NULL,
UNIQUE(tx_hash, wallet, event_type)
);
CREATE INDEX idx_staking_events_wallet ON staking_events(wallet, block_timestamp);For unstake events: rnbw_amount is the gross value before fee (rnbwValue from the event), exit_fee is the fee deducted. Net received = rnbw_amount - exit_fee. The APY formula uses the net amount (see walkthrough below).
Two separate event sets serve different purposes:
Per-wallet events — needed for per-wallet APY (TWAC calculation). Each carries the user address, shares delta, and RNBW amounts:
| Event | Fields | Exchange Rate |
|---|---|---|
Staked(user, rnbwAmount, sharesMinted, _) |
who staked, how much, shares received | rnbwAmount / sharesMinted |
Unstaked(user, sharesBurned, rnbwValue, _, _) |
who unstaked, shares burned, gross value, fee | rnbwValue / sharesBurned |
CashbackAllocated(user, rnbwAmount, sharesMinted) |
who got cashback, amount, shares minted | rnbwAmount / sharesMinted |
Global pool event — for tracking the global exchange rate over time without reconstructing it from per-user events:
| Event | Fields | Exchange Rate |
|---|---|---|
PoolTotalsUpdated(totalPooledRnbw, totalShares) |
post-operation pool totals (no user address) | totalPooledRnbw / totalShares |
PoolTotalsUpdated fires after every stake, unstake, and cashback allocation. It is not useful for per-wallet APY because it has no user field. Use it when you only need the global rate history (e.g., charting exchange rate over time, computing global APY without the 2-block RPC approach).
Edge case: the very first stake mints 1000 dead shares, so
sharesMinted = amount - 1000. The derived rate is still correct but slightly above 1.0.
Events for Alice:
| Event | Timestamp | RNBW Amount (gross) | Exit Fee | Net Received | Shares Delta | Exchange Rate |
|---|---|---|---|---|---|---|
| stake | Jan 1 | 1000 | -- | -- | +1000 | 1.0 |
| cashback | Mar 1 | 50 | -- | -- | +50 | 1.0 |
| unstake | Jul 1 | 550 | 55 | 495 | -500 | 1.1 |
Step 1 — Build capital periods (what Alice had, for how long):
| Period | Running Shares | Rate | Capital (RNBW) | Duration (days) |
|---|---|---|---|---|
| Jan 1 → Mar 1 | 1000 | 1.0 | 1000 | 59 |
| Mar 1 → Jul 1 | 1050 | 1.0 | 1050 | 122 |
| Jul 1 → Dec 31 | 550 | 1.1 | 605 | 183 |
Each period starts when an event changes the wallet's share balance. The capital is running_shares × exchange_rate at that point. The exchange rate changes continuously between events (drip), so this is an approximation — accuracy improves with more frequent user activity.
Step 2 — Compute TWAC:
TWAC = (1000×59 + 1050×122 + 605×183) / (59 + 122 + 183)
= (59000 + 128100 + 110715) / 364
= 818.2 RNBW
Step 3 — Compute net profit:
totalUnstaked is the net amount after exit fee (matches totalRnbwUnstaked in the contract / net_received in the indexer), not the gross value.
netProfit = currentValue + totalUnstaked - totalStaked
= 605 + 495 - 1000
= 100 RNBW
Step 4 — APY:
duration = 364 days
APY = (netProfit / TWAC) × (365.25 / duration)
= (100 / 818.2) × (365.25 / 364)
= 0.123
= 12.3%
Per-Wallet APY = (netProfit / TWAC) × (secondsPerYear / durationSeconds)
Where:
netProfit = stakedAmount + totalUnstaked - totalStaked
totalUnstaked = SUM(rnbw_amount - exit_fee) from unstake events (after exit fee)
totalStaked = SUM(rnbw_amount) from stake events
TWAC = Σ(capital_i × duration_i) / Σ(duration_i)
secondsPerYear = 31,557,600 (365.25 days)
durationSeconds = now - first_stake_timestamp
totalUnstaked must use the net amount (after exit fee), not the gross. The contract stores this as totalRnbwUnstaked; in the indexer compute it as SUM(rnbw_amount - exit_fee) for unstake events.
Most users have 2-10 events, so per-wallet queries are trivially fast even at 500 tx/day.
- Dead shares: 1000 shares minted to
0xdeadon first deposit (prevents share inflation / first depositor attack) - Cashback reserve: tracked separately from staking pool, protected from
emergencyWithdraw - Staking reserve: tracked separately, protected from
emergencyWithdraw, reclaimable viadefundStakingReserve() - Emergency withdraw: for RNBW, the transfer is capped at excess above
totalPooledRnbw + cashbackReserve + stakingReserve + undistributedFees-- if the requested amount exceeds excess, it is silently capped (checkEmergencyWithdrawnevent for actual amount). The pool, both reserves, and pending drip fees are untouchable. Non-RNBW tokens pass through uncapped (rescue for accidental sends). - Min stake floor:
minStakeAmountcannot be set below 1 RNBW - Inflation guard:
ZeroSharesMintedrevert protects depositors from rounding attacks - Residual sweep: when only dead shares remain,
totalPooledRnbwandundistributedFeesare swept to safe and pool is reset - Linear fee drip: exit fees are buffered and dripped over
dripDuration(1--60 days) to prevent whale self-absorption and APY manipulation - Exit fee rounding: ceiling division ensures fractional wei favors the protocol
- Dust unstake handling: when ceil-rounded exit fee consumes 100% of a dust unstake, shares are burned with no transfer (clears dust positions without reverting)
- 2-step safe transfer:
proposeSafe()+acceptSafe()prevents transfer to wrong address - Partial unstake toggle:
allowPartialUnstake(default: disabled) - Partial unstake dust guard: when partial unstake is enabled,
_unstakereverts withDustSharesRemainingif the user's remaining shares would be > 0 but <MIN_SHARES_THRESHOLD(1e14). Prevents attackers from leaving dust shares to bypass the dead-share sweep and inflate the exchange rate. - Preview dust guard:
previewStake(user, amount)returns 0 instead of reverting for dust amounts and for first-time stakers belowminStakeAmount - Recipient guards:
stakeForandstakeForWithSignaturerejectaddress(0),address(this), andDEAD_ADDRESSto prevent token locking and dead-share corruption - Batch size limit:
batchAllocateCashbackWithSignaturecapped at 50 entries with upfront reserve check - Rich error context: user-facing errors include address and value params for debugging
All user-facing errors include contextual parameters for off-chain debugging. Admin errors use bare selectors since msg.sender provides sufficient context.
| Error | Parameters | Thrown in |
|---|---|---|
NoStakePosition |
(user) |
_unstake, _allocateCashback |
InsufficientShares |
(user, requested, available) |
_unstake |
BelowMinimumStake |
(user, amount, minRequired) |
_stake, _stakeFromReserve |
ZeroSharesMinted |
(user, amount) |
_mintShares, _allocateCashback |
InvalidRecipient |
-- | stakeFor, stakeForWithSignature |
PartialUnstakeDisabled |
(user, sharesToBurn, totalUserShares) |
_unstake |
DustSharesRemaining |
(user, remainingShares) |
_unstake |
DripDurationTooLow |
-- | setDripDuration |
DripDurationTooHigh |
-- | setDripDuration |
Dead shares prevent the share inflation / first depositor attack. They do not accumulate -- always exactly 0 or 1000.
Empty pool → first stake mints 1000 dead shares to 0xdead
Active pool → no dead shares minted, count stays at 1000
Last user unstakes → residual sweep resets dead shares to 0 (clean slate)
Invariants:
totalShares == 0 ⟹ totalPooledRnbw == 0 ∧ undistributedFees == 0RNBW.balanceOf(contract) ≥ totalPooledRnbw + cashbackReserve + stakingReserve + undistributedFees
forge buildforge testUnit tests (RNBWStaking.t.sol), invariant tests (RNBWStakingInvariant.t.sol), and simulation tests (RNBWStakingSimulation.t.sol).
forge test -vvv # verbose output
forge test --match-contract RNBWStakingSimulation -vvv # simulation onlyforge fmtCopy the appropriate example file and fill in values:
cp .env.staging.example .env.staging # for staging (Tenderly Virtual TestNet)
cp .env.production.example .env.production # for production (Base mainnet)| Variable | Description |
|---|---|
RPC_URL |
RPC endpoint (Tenderly for staging, https://mainnet.base.org for production) |
PRIVATE_KEY |
Deployer wallet private key |
ETHERSCAN_API_KEY |
Tenderly access token (staging) or Basescan API key (production) |
RNBW_TOKEN |
RNBW ERC20 token contract address |
SAFE_ADDRESS |
Admin multisig (Safe) address |
SIGNER |
Initial trusted EIP-712 signer address for cashback operations |
make deploy-staging # deploy to Tenderly Virtual TestNet
make deploy-production # deploy to Base mainnet (confirmation prompt)make verify-staging ADDRESS=0x... # verify on staging
make verify-production ADDRESS=0x... # verify on BasescanThe contract is compatible with EIP-7702 (account abstraction via code delegation). stake() and unstake() use msg.sender, so a 7702-delegated EOA can call them directly through its delegated code. These functions also work with Gelato Turbo Relayer and Relay.link, which use smart account patterns where msg.sender is the user's address. allocateCashbackWithSignature() and stakeForWithSignature() work with any msg.sender since they validate the trusted backend signer, not the caller.
Deployed on Base (Optimistic Rollup). No public mempool, so signature extraction / front-running is not practical. Block time ~2s.
RNBW is a standard ERC20 -- not fee-on-transfer, not deflationary, no transfer callbacks. The token address is immutable in the constructor. _stake does not do a balance-before/after check because there is no fee-on-transfer to reconcile.
batchAllocateCashbackWithSignature reverts the whole tx if any item fails. We considered skip-on-failure (emit ClaimSkipped, continue loop) but rejected it -- partial execution makes backend reconciliation harder, and on Base the main failure mode is rate drift causing ZeroSharesMinted on micro-cashbacks, which the backend should filter out before submitting.
The upfront totalCashback > cashbackReserve check is a gas optimization (fail before N ECDSA recovers). Each _allocateCashback also checks individually, so the batch pre-check is redundant for correctness.
Backend responsibility: filter amounts that would mint 0 shares at current rate, retry failed batches after removing stale items.
stake() and unstake() do not accept minSharesOut / minAmountOut parameters. On Base there is no public mempool and no transaction reordering, so sandwich attacks are not practical. The exchange rate only moves favorably for stakers (exit fees increase it). This is a deliberate simplification for Base-only deployment.
There is no function that lets the admin withdraw from totalPooledRnbw. Pool RNBW is only withdrawable by stakers burning shares. Cashback reserve is consumable via signed allocations or reclaimable via defundCashbackReserve(). Staking reserve is consumable via stakeForWithSignature or reclaimable via defundStakingReserve(). emergencyWithdraw caps RNBW transfers at excess above all four tracked pools (totalPooledRnbw + cashbackReserve + stakingReserve + undistributedFees) -- requested amounts above excess are silently capped. To wind down the protocol: stop new stakes (disable frontend / revoke signers), let existing users unstake normally, residual sweeps to safe when pool empties. Note: pause() is an emergency brake that freezes everything including unstaking -- it is not a wind-down mechanism.
Staking positions are non-transferable -- shares live inside the contract, not as ERC20 balances. A separate liquid staking token contract (xRNBW) can sit on top to make positions transferable and DeFi-composable (DEX trading, lending collateral, LPs, etc.).
User → xRNBW (ERC20) → RNBWStaking
| |
| stakeFor(xRNBW) | ← xRNBW contract accumulates shares
| unstake(shares) | ← burns xRNBW, redeems underlying RNBW
| |
← mint xRNBW to user
Deposit: xRNBW takes RNBW from the user, calls stakeFor(address(xRNBW), amount), mints xRNBW 1:1 with the shares it received.
Redeem: user burns xRNBW, contract calls unstake(sharesToBurn), forwards netAmount to user.
stakeFor, stakeForWithSignature, and the netAmount return value were added specifically for this. stakeForWithSignature adds backend-controlled staking from the pre-funded staking reserve with EIP-712 replay protection. InvalidRecipient keeps people from accidentally staking to the staking contract itself or 0xdead.