@anywalletkit/protocol-staking-ssv-evm
A simple package that lets @anywalletkit/wallet-evm wallet accounts stake
through SSV Network: stake SSV → cSSV, claim the ETH
rewards, exit through the network's cooldown, and read APR and network stats.
Install
npm install @anywalletkit/protocol-staking-ssv-evm @anywalletkit/core
# or: pnpm add @anywalletkit/protocol-staking-ssv-evm @anywalletkit/core · yarn add @anywalletkit/protocol-staking-ssv-evm @anywalletkit/core
On npm: @anywalletkit/protocol-staking-ssv-evm, published from the release workflow with provenance.
Built as the second implementation of the StakingProtocol type in
@anywalletkit/core (the first is @anywalletkit/protocol-staking-lido-evm).
The base class lives in @anywalletkit/core (src/staking-protocol.ts here
keeps the runtime IStakingProtocol marker and the module's own types). A
token-staking protocol with a cooldown exit fitting it unchanged is the point:
the interface is not Lido-shaped.
The mechanics are extracted from a production wallet (React Native/Hermes) where every flow is exercised with real funds.
What SSV Network and the SSV token are
SSV Network is distributed validator technology (DVT) for Ethereum: a validator's key is split among several independent operators, so staking keeps running when any one of them is down or misbehaves. A large share of Ethereum liquid staking runs its validators on it. Operators pay the network's fees in ETH.
SSV is the network's ERC-20 token. Since SSV Network v2 (mainnet, 2026)
SSV holders can stake it: the network mints cSSV 1:1 — a transferable,
non-rebasing receipt token — and cSSV holders receive a pro-rata share of the
ETH fees, accrued block by block through an ETH-per-share index and claimable
at any time without unstaking. Staked weight also delegates voting power to the
network's Effective Balance oracles. Getting the SSV back is a cooldown exit:
requestUnstake burns cSSV into a cooldown entry (7 days at launch, DAO-tunable,
not cancellable), withdrawUnlocked pays out every entry whose cooldown has
ended. There is no slashing for stakers. The APR is ETH-denominated on an
SSV-denominated stake, so it moves with SSV's price.
Usage
import WalletManagerEvm from '@anywalletkit/wallet-evm'
import SsvProtocolEvm from '@anywalletkit/protocol-staking-ssv-evm'
const wallet = new WalletManagerEvm(seed, { provider: 'https://rpc.example.org' })
const account = await wallet.getAccount(0)
const ssv = new SsvProtocolEvm(account)
// Stake — approve (awaited) + stake on the first go, one transaction after
const { hash, fee, approveHash } = await ssv.stake({ amount: 10n ** 18n })
// Rewards accrue in ETH; claim any time, the stake stays put
await ssv.getClaimableRewards() // wei
await ssv.claimRewards()
// Exit, phase 1: burn cSSV into a cooldown entry (irreversible)
await ssv.requestWithdrawal({ amount: 10n ** 18n })
// …after the cooldown — exit, phase 2: withdraw every unlocked entry as SSV
const { claimableIds } = await ssv.getWithdrawalRequests()
if (claimableIds.length) await ssv.claimWithdrawals()
// Views
await ssv.getStakedBalance() // { balance: cSSV, wrappedBalance: 0n, rate: 1e18, total }
await ssv.getApr() // 23.8 (percent, from SSV Network's API)
SSV-specific extras beyond the interface:
await ssv.getTokenBalance() // liquid SSV on the account
await ssv.getClaimableRewards() // accrued ETH (wei)
await ssv.claimRewards() // claimEthRewards()
await ssv.getCooldown() // seconds — read it, never hardcode 7 days
await ssv.getNetworkStats() // { totalStaked, rewardsPool, accEthPerShare }
await ssv.getAprHistory({ limit: 7 })// daily samples with SSV/ETH prices
How it maps onto the interface
IStakingProtocol | SSV Network |
|---|---|
stake({ amount }) | allowance check → approve (awaited to a mined receipt) → stake on the SSVNetwork proxy; cSSV minted 1:1 |
requestWithdrawal({ amount }) | requestUnstake — burns cSSV into a cooldown entry; token ignored (single staked form) |
getWithdrawalRequests() | pendingUnstake → entries with id = position, unlockTime, claimable = unlockTime <= now; amounts are SSV |
claimWithdrawals() | withdrawUnlocked — pays every unlocked entry; ids are informational |
getStakedBalance() | cSSV balance, wrappedBalance 0, rate 1e18 |
getApr() | api.ssv.network/…/apr/current — the number stake.ssv.network shows |
Two honest differences from Lido are documented on the methods rather than papered over: the exit pays the staked token (SSV), not the chain's native token, and the withdraw call is all-or-nothing on the network side.
Why the write path looks the way it does
- No permit anywhere: neither SSV nor cSSV implements EIP-2612 (checked:
nonces/DOMAIN_SEPARATORrevert on both). The first stake is therefore approve + stake; the approval is awaited to a mined receipt becausestake's gas estimate reverts against an unmined allowance. With a standing allowance it is one transaction. - Proven before broadcast: every write is simulated with
eth_callfrom the account first. The staking module reverts with bare custom errors —StakeTooLow,UnstakeAmountExceedsBalance,NothingToWithdraw,NothingToClaim,InsufficientBalance… — anddescribeRevertturns the 4-byte selector back into a named error, wherever the provider (viem) buried it (error.data,info.error.data,cause, or the message text). A user sees "no unstake request has finished its cooldown yet", not "execution reverted", and pays no gas for it. - Claim rounding:
claimEthRewardspaysaccrued − accrued % 100000 weiand reverts when that is zero;claimRewardscheckspreviewClaimableEthagainstETH_CLAIM_UNITup front. - Unstable ids: cooldown entries are addressed by position and
withdrawUnlockedcompacts the list — re-readgetWithdrawalRequestsafter every write; nothing here caches ids. - Structural account checks: writability is
typeof account.sendTransaction === 'function', notinstanceof. - No
AbortSignal.timeout/AbortSignal.any: Hermes shipsAbortControllerwithout those statics; nothing here depends on them. - REST is decoration: APR and APR history come from the same API
stake.ssv.network reads (CORS
*), overridable viaconfig.endpoints; the on-chain flows never depend on them, andgetAprHistorydegrades to an empty array.
Configuration
new SsvProtocolEvm(account, {
provider, // RPC url or EIP-1193; defaults to the account's provider
chainId, // default 1; other chains must supply addresses
addresses, // { network, views, ssvToken, cssvToken }
endpoints // { apr, aprHistory }
})
Only mainnet ships as a default deployment — deliberately. The Hoodi testnet
addresses are listed in src/ssv-address-map.ts for explicit configuration,
so nobody stakes against an address a default quietly supplied.
Mainnet contracts (verified on-chain against the live proxy, v2.0.0):
SSVNetwork 0xDD9BC35aE942eF0cFa76930954a156B3fF30a4E1, SSVNetworkViews
0xafE830B6Ee262ba11cce5F32fDCd760FFE6a66e4, SSV
0x9D65fF81a3c488d585bBfb0Bfe3c7707c7917f54, cSSV
0xe018D31F120A637828F46aFD6c64EC099d960546.
Tests
pnpm build # TypeScript → dist/ (tsup: ESM + CJS + d.ts); built on viem
pnpm test # offline unit tests (vitest, stubbed EIP-1193 provider) + conformance
pnpm verify:live # read-only checks against mainnet (views, API, eth_call proofs; needs a build)
Activity
describeActivity(tx, { own }) labels SSV staking transactions for a wallet's history, offline and synchronously. A transaction is SSV's when it calls the network proxy the instance is configured with (mainnet by default) on that chain and the selector is stake → stake "Staked SSV", requestUnstake → unstake "Requested unstake", withdrawUnlocked → claim "Withdrew SSV", claimEthRewards → claim "Claimed ETH rewards" (SSV_ACTIONS, selectors computed from the signatures at module load). Operator / cluster management calls on the same proxy, SSV approves and anything else yield null. Subtitle "SSV Network · Ethereum", counterparty the proxy; assetsIn / assetsOut are the wallet's net movements (SSV, cSSV, ETH rewards) labelled by the module.
Fixtures in fixtures/activity/ are the test wallet's funded run (stake, requestUnstake ×2, withdrawUnlocked, claimEthRewards; 2026-09-03 … 10-07); negatives/ holds a Lido submit and a USDT approve.