Staking Integration (spec 065)¶
Liquid & delegated staking is a frontend-only feature (no contracts/
changes) inside the Earn section, mirroring the lending integration (spec 050).
It calls audited third-party protocols from the member's own wallet via the
spec-041 unified send rail. Launch network: Ethereum mainnet (chainId 1).
Architecture¶
Config-gated capability → lib → hooks → a view + bottom sheet hosted at
/wallet?tab=earn&view=stake, plus a notification source and a ledger source.
- Config —
frontend/src/config/staking.js(provider addresses, curated validator allowlist,stakingPath()), and astakingblock on chain 1 infrontend/src/config/networks.jswithcapabilities.stakingand the helpersisStakingAvailable/getStakingConfig/getStakingNetworks. - ABIs —
abis/LidoStETH.js,LidoWstETH.js,LidoWithdrawalQueue.js,SPOLController.js,PolygonValidatorShare.js,PolygonStakeManager.js. - Libs —
lib/staking/:lidoStaking.js,spolStaking.js,polygonDelegation.js(build{target,data,value}call batches + on-chain reads),stakingActions.js(validation + gas reserve + provider dispatch),pendingUnbonds.js,stakingCopy.js,stakingActivityBuffer.js. - Hooks —
useStakingOptions(normalized option list + best-effort enrichment),useStakingPositions(on-chain positions + exit/ready detection, 60s poll),useStakingActions(stake/unstake/withdraw/claim viauseEarnSend). - UI —
components/earn/StakeView.jsx,StakeSheet.jsx,StakingPositionsList.jsx; the Earn hub's Stake area is now live. - Wiring — notification domain/category +
data/notifications/sources/stakingSource.js; ledgerSTAKINGclass +data/ledger/sources/stakingLedgerSource.js(captureStakingAction); the portfolio asset sheet's Stake action.
Providers¶
| Model | Asset | Provider | Where | Exit |
|---|---|---|---|---|
| Liquid | ETH | Lido (wstETH) | L1 | Withdrawal Queue (finalized → claim) |
| Liquid | POL | sPOL (Polygon official LST) | L1 mint | sellSPOL → unbond → withdrawPOL, or instant DEX swap |
| Delegated | POL | Polygon ValidatorShare | L1 | sellVoucherPOL → unbond → unstakeClaimTokens_newPOL |
sPOL and Polygon delegation execute on Ethereum L1 (the Polygon staking
contracts live there). See specs/065-liquid-delegated-staking/contracts/ for
the exact methods and status-detection.
Curated validator allowlist (FR-008)¶
Delegated targets are a fixed, curated allowlist in
config/staking.js#CURATED_POLYGON_VALIDATORS — never free-form. The Polygon
staking API only decorates allowlisted entries (commission/status); it never
expands the list.
Build-time revalidation (required): commission and validator state are live
per-checkpoint. Before shipping a config change, re-read
https://staking-api.polygon.technology/api/v2/validators for each validatorId
and confirm it is still HEALTHY + delegationEnabled with unchanged
commission; each validatorShare is run through getAddress() at load. Verify
the Lido/sPOL/POL/StakeManager addresses against the Lido deployed-contracts page
and 0xPolygon/spol-contracts too.
Fees & admin controls (spec 066 — StakingRouter)¶
Spec 066 adds the StakingRouter — a per-network UUPS control surface
(contracts/staking/StakingRouter.sol, deployment keys stakingRouter /
stakingRouterImpl) — that both governs the staking service and charges the
platform fee on liquid staking.
Fee (liquid only). The fee rate stays the single spec-060 FeeRouter
source of truth: per-provider services stake.lido / stake.polygon
(FEE_SERVICES.STAKE_LIDO / STAKE_POLYGON), each ConfigOnly, cap 250 bps,
rate 0 until set from the Fees tab (FEE_ADMIN_ROLE). None of Lido submit /
sPOL buySPOL are ERC-4626 deposits, so the FeeRouter's depositToVaultWithFee
doesn't fit; instead the StakingRouter reads quoteFee / feeBps / treasury()
and does the fee-and-forward itself — skim to the treasury, forward the net to the
provider, return the LST — atomically, with a maxFeeBps consent ceiling
(FeeAboveQuoted) and a no-residual invariant (ResidualFunds). A zero/unset rate
is byte-identical to fee-free (SC-003).
Delegated is fee-free in v1. Polygon buyVoucherPOL binds the delegation to
msg.sender, so a router call would make the router the (custodial, un-exitable)
delegator. Delegated staying a direct member call is the only non-custodial option,
so it ships fee-free; the router still governs its allowlist + pause. Provider
protocol fees (Lido's ~10% of rewards, sPOL's rewardFee) are the provider's and
disclosed as such.
Runtime read + fallback. useStakingOptions overlays the router's provider
addresses, validator allowlist, paused flag, and the per-provider fee onto the
spec-065 options (lib/staking/stakingRouter.js, overlayRouterConfig). When the
router is undeployed or unreadable it keeps the spec-065 build-time constants
verbatim — fee-free, direct staking — never a broken or fee-guessing screen
(FR-009). stakingActions.buildStakeForOption routes through the router only when a
fee applies; otherwise it emits the byte-identical direct calls.
Governance & ops. STAKING_ADMIN_ROLE (provider addresses + validator
allowlist) and GUARDIAN_ROLE (emergency pause) are held by a multisig with
no timelock (FR-018). Operators drive it from the AdminPanel Staking tab;
the fee rate is read-only there and edited in the Fees tab. See
docs/runbooks/staking-operations.md.
Adding a network or provider¶
Add a staking block to the target network in networks.js (only where a real,
deposits-open provider exists) with the provider's addresses; the capability and
helpers pick it up automatically. A follow-up is the Polygon-PoS-native sPOL
deposit path via sPOLChild (chainId 137, cross-chain settle) — see research.md
R2.
End-to-end coverage (spec 094, issue #1237)¶
frontend/cypress/e2e/full/31-earn-lend-stake.cy.js (on-chain tier) covers the three
staking rows of the coverage matrix alongside the two lending ones:
| Flow | Test | Settles |
|---|---|---|
earn.stake-and-delegate |
ES-01 |
The validator's own books credit the member, and exactly that much POL left their wallet |
earn.unstake |
ES-02 |
The stake leaves the validator into an unbond that is not claimable, then — one checkpoint later — comes back |
admin.staking-controls |
ES-03 |
The operator's console pauses the router and retires a validator, and the member's screen tells the truth about both |
Why delegation is the half under test. It is a direct member call to Polygon's
ValidatorShare with no FairWins contract in the path, so "we never hold your funds" is
a claim about the calldata rather than about a router's balance. The liquid paths route
through StakingRouter only when a fee applies, and spec 060's own suite already settles
the rate and the maxFeeBps ceiling.
The local wiring. scripts/deploy/deploy-staking-router.js gained a local-doubles
branch — the same shape deploy-bridge-liquidity.js uses — so on hardhat/localhost
it stands up contracts/mocks/MockStakingProviders.sol (Lido, sPOL) and
contracts/mocks/MockPolygonDelegation.sol (StakeManager + one ValidatorShare), points
the router at them, and allowlists the local validator. It is wired into setup:e2e as
deploy:local:staking, and config/networks.js carries a DEV-only staking block for
chain 80002 behind the existing E2E_AMOY_LOCAL seam.
One detail in MockPolygonDelegation.sol is load-bearing rather than incidental: the
token pull runs through the StakeManager, because that is the spender
buildDelegateCalls approves. A mock that pulled from the ValidatorShare directly would
pass while the app approved the wrong contract.
Note on the allowlist. overlayRouterConfig drops any curated validator the router
does not list, so on a chain where the router IS deployed the allowlist — not the config
file — decides what is offered. That is what ES-03 asserts from both ends.