Skip to content
docs

Earn

Earn modules come in two shapes, and every module of a shape has the same methods:

Interface Modules Flow
StakingProtocol Lido (ETH), SSV Network, Tonstakers (TON), SPL stake pools (Jito on Solana), native SOL staking, native MON staking (Monad) Stake → request a withdrawal → wait for the queue → claim
LendingProtocol Aave v3, Morpho, Mellow, Kamino Supply / withdraw, and on markets that allow it, borrow / repay

This page uses Lido and Aave on Ethereum. The other modules work the same way, with different packages and chains (see Packages). Snippets come from examples/earn, which our CI typechecks and tests.

Terminal window
npm install @anywalletkit/protocol-staking-lido-evm @anywalletkit/protocol-lending-aave-evm

Register each module as a protocol, bound to the chain its account lives on:

import { Wallet } from '@anywalletkit/kit'
import { WalletManagerEvm } from '@anywalletkit/wallet-evm'
import LidoProtocolEvm from '@anywalletkit/protocol-staking-lido-evm'
import { AaveProtocolEvm } from '@anywalletkit/protocol-lending-aave-evm'
export function walletWithEarn(seedPhrase: string): Wallet {
const wallet = new Wallet(seedPhrase)
wallet.registerWallet('eip155:1', WalletManagerEvm, { provider: 'https://ethereum-rpc.publicnode.com', chainId: 1 })
// A protocol is bound to the account of one chain.
wallet.registerProtocol('lido', LidoProtocolEvm, { chain: 'eip155:1' })
wallet.registerProtocol('aave', AaveProtocolEvm, { chain: 'eip155:1', config: { market: 'core' } })
return wallet
}

Every write has a quote… twin that returns the gas fee without signing anything. Show it before the user confirms. Policies apply to every write a module makes, because modules sign through the same account.

export async function stakeEth(wallet: Wallet, amount: bigint) {
const lido = await wallet.getProtocol<LidoProtocolEvm>('lido')
const { fee } = await lido.quoteStake({ amount }) // gas in wei, nothing is signed
const { hash } = await lido.stake({ amount }) // ETH → stETH
return { hash, fee }
}
export async function stakingPosition(wallet: Wallet) {
const lido = await wallet.getProtocol<LidoProtocolEvm>('lido')
const position = await lido.getStakedBalance()
return {
staked: position.total, // the whole position in stETH, wei
apr: await lido.getApr(), // percent: 2.7 = 2.7 %
}
}

Staked funds do not come back in one transaction. A withdrawal request joins the protocol’s queue, finalizes later (hours to days, depending on the protocol), and only then can be claimed. The interface names the queue instead of pretending withdraw() returns money:

export async function unstake(wallet: Wallet, amount: bigint) {
const lido = await wallet.getProtocol<LidoProtocolEvm>('lido')
// Phase 1: join the withdrawal queue.
await lido.requestWithdrawal({ amount })
}
export async function claimIfReady(wallet: Wallet) {
const lido = await wallet.getProtocol<LidoProtocolEvm>('lido')
const queue = await lido.getWithdrawalRequests()
// queue.pendingAmount: still in the queue; queue.claimableAmount: ready to claim
if (queue.claimableIds.length === 0) return null
// Phase 2: claim the finalized requests back as ETH.
return lido.claimWithdrawals({ ids: queue.claimableIds })
}

Show pendingAmount as “unstaking” and claimableAmount with a Claim button. Check the queue when the screen opens; there is nothing to watch in between.

const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'
export async function lendUsdc(wallet: Wallet, amount: bigint) {
const aave = await wallet.getProtocol<AaveProtocolEvm>('aave')
const apy = await aave.getSupplyApy(USDC) // 4.1 = 4.1 %
const { fee } = await aave.quoteSupply({ token: USDC, amount })
const result = await aave.supply({ token: USDC, amount }) // permit when the token supports it, else an exact approval
return { apy, fee, hash: result.hash }
}
export async function withdrawAll(wallet: Wallet) {
const aave = await wallet.getProtocol<AaveProtocolEvm>('aave')
return aave.withdraw({ token: USDC, amount: 'max' }) // principal + interest
}

Tokens are contract addresses; 'native' supplies the chain’s coin (ETH) through Aave’s gateway. Where the token supports EIP-2612, the module signs a permit instead of sending a separate approval transaction. amount: 'max' withdraws or repays everything, interest included.

export async function lendingPosition(wallet: Wallet) {
const aave = await wallet.getProtocol<AaveProtocolEvm>('aave')
const { supplies, borrows, account } = await aave.getPosition()
return {
supplies, // [{ token, symbol, amount, supplyApy, collateral }]
borrows, // [{ token, symbol, amount, borrowApy }]
healthFactor: account.healthFactor, // < 1 = liquidation; Infinity without debt
}
}

With debt, keep an eye on healthFactor: below 1.0 the position can be liquidated. Show it on every borrow and withdraw screen, and warn well before it gets close (for example under 1.5).

@anywalletkit/protocol-staking-native-solana delegates SOL straight to validators through Solana’s Stake program: no pool, no token. Your app decides which validators it offers; the module has no default list and refuses to start without one.

Terminal window
npm install @anywalletkit/protocol-staking-native-solana @anywalletkit/wallet-solana
import { Wallet } from '@anywalletkit/kit'
import { WalletManagerSolana } from '@anywalletkit/wallet-solana'
import { NativeStakingProtocolSolana } from '@anywalletkit/protocol-staking-native-solana'
const SOLANA = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'
export function walletWithNativeStaking(seedPhrase: string, rpc = 'https://api.mainnet-beta.solana.com'): Wallet {
const wallet = new Wallet(seedPhrase)
wallet.registerWallet('solana', WalletManagerSolana, { provider: rpc })
wallet.registerProtocol('sol-staking', NativeStakingProtocolSolana, {
chain: SOLANA,
config: {
// Required: the vote accounts your wallet delegates to. Replace these with your own choice.
validators: [
{ voteAccount: 'he1iusunGwqrNtafDtLdhsUQDFvo13z9sUa36PauBtk', name: 'Validator A' },
{ voteAccount: 'CatzoSMUkTRidT5DwBxAC2pEtnwMBTpkCepHkFgZDiqb', name: 'Validator B' },
],
maxCommission: 10, // refuse a validator whose commission is above 10 % (checked at every delegation)
},
})
return wallet
}

Each stake goes to a validator from this list (or any voting validator with validators: 'any', when your users pick from the whole cluster). Before every delegation the module reads the validator live and refuses it when it is delinquent or its commission is above maxCommission (10 % unless you set it). enrich can attach an explorer’s APR or a logo to each validator; the on-chain checks do not depend on it.

export async function stakeSol(wallet: Wallet, lamports: bigint) {
const staking = await wallet.getProtocol<NativeStakingProtocolSolana>('sol-staking')
const validators = await staking.listValidators() // live commission, stake, APR; `eligible` and `refusals`
const pick = validators.find((v) => v.eligible)!
const quote = await staking.quoteStake({ amount: lamports, validator: pick.voteAccount }) // ≥ 1 SOL; nothing is signed
// quote.total = amount + the stake account's rent (returned on withdraw); quote.fee = network fee
const result = await staking.stake({ amount: lamports, validator: pick.voteAccount })
return { hash: result.hash, stakeAccount: result.stakeAccount, activeFrom: result.readyEpoch }
}

A stake is one transaction: a new stake account at an address derived from the wallet (seed stake:<i>), initialized with the wallet as its only authority and delegated. Only the wallet signs, and the position can be rebuilt from the wallet address alone. The minimum is 1 SOL; the account also holds about 0.0017 SOL of rent, which comes back on withdrawal. The stake starts earning from the next epoch boundary (an epoch is about two days).

export async function nativePosition(wallet: Wallet) {
const staking = await wallet.getProtocol<NativeStakingProtocolSolana>('sol-staking')
const accounts = await staking.getStakeAccounts() // one per delegation: status, validator, amounts, readyAt
const { balance, total } = await staking.getStakedBalance() // delegated lamports; everything in stake accounts
return { accounts, balance, total, apr: await staking.getApr() }
}

status is computed from the StakeHistory sysvar the way the Stake program computes it: activating, active, deactivating or inactive, with readyEpoch / readyAt while it changes. Rewards compound into each account at every epoch boundary; getRewards() lists them per epoch.

export async function unstakeSol(wallet: Wallet, lamports: bigint) {
const staking = await wallet.getProtocol<NativeStakingProtocolSolana>('sol-staking')
// Whole accounts are deactivated; a partial amount is split into a new account first.
const { steps, readyAt } = await staking.requestWithdrawal({ amount: lamports })
return { steps, readyAt } // the SOL can be claimed after the epoch boundary (~2 days)
}
export async function claimSol(wallet: Wallet) {
const staking = await wallet.getProtocol<NativeStakingProtocolSolana>('sol-staking')
const { claimableIds } = await staking.getWithdrawalRequests()
if (claimableIds.length === 0) return null
return staking.claimWithdrawals({ ids: claimableIds }) // every lamport, rent included, back to the wallet
}

Unstaking deactivates whole accounts and, for a partial amount, splits it off one account into a new one first; both parts must keep the 1 SOL minimum. The SOL can be withdrawn after the cool-down (the next epoch boundary). A stake deactivated in the epoch it was made is free at once, and delegate({ account }) in the same epoch undoes an unstake. merge joins two compatible accounts on the same validator.

@anywalletkit/protocol-staking-native-monad delegates MON straight to Monad validators through the staking precompile (0x…1000): no pool, no token. Your app decides which validator ids it offers; the module has no default list and refuses to start without one.

Terminal window
npm install @anywalletkit/protocol-staking-native-monad @anywalletkit/wallet-evm
import { Wallet } from '@anywalletkit/kit'
import { WalletManagerEvm } from '@anywalletkit/wallet-evm'
import { NativeStakingProtocolMonad } from '@anywalletkit/protocol-staking-native-monad'
export function walletWithMonadStaking(seedPhrase: string, rpc = 'https://rpc.monad.xyz'): Wallet {
const wallet = new Wallet(seedPhrase)
wallet.registerWallet('eip155:143', WalletManagerEvm, { provider: rpc, chainId: 143 })
wallet.registerProtocol('mon-staking', NativeStakingProtocolMonad, {
chain: 'eip155:143',
config: {
// Required: the validator ids your wallet delegates to. Replace them with your own choice.
validators: [{ id: 97n, name: 'Validator A' }, { id: 21n, name: 'Validator B' }],
defaultValidator: 97n,
maxCommission: 10, // refuse a validator whose commission is above 10 % (checked at every delegation)
},
})
return wallet
}

validators: 'any' lets users pick from the whole active set (listValidators()). Before every delegation the module reads the validator live and refuses it when it does not exist, is not in the consensus set in good standing, or charges more than maxCommission (10 % unless you set it; many validators charge 10–15 %). enrich can attach an explorer’s APR or a logo.

export async function stakeMon(wallet: Wallet, wei: bigint) {
const staking = await wallet.getProtocol<NativeStakingProtocolMonad>('mon-staking')
const validators = await staking.listValidators() // live commission, standing, APR; `eligible` and `refusals`
const quote = await staking.quoteStake({ amount: wei }) // simulated, nothing is signed; keeps the 10 MON reserve
const result = await staking.stake({ amount: wei }) // delegate(97) with the amount as value
return { validators, fee: quote.fee, hash: result.hash, activeFrom: result.activatesAtEpoch } // epoch n+1, or n+2 after the boundary block
}
export async function monadPosition(wallet: Wallet) {
const staking = await wallet.getProtocol<NativeStakingProtocolMonad>('mon-staking')
const positions = await staking.listPositions() // per validator: active, activating, unclaimed rewards
const { balance, total, unclaimedRewards } = await staking.getStakedBalance()
return { positions, balance, total, unclaimedRewards, apr: await staking.getApr() }
}

A stake becomes active at the next epoch, or the one after when it is sent after the epoch’s boundary block (getEpoch().activatesAtEpoch; epochs are about 4 hours). Monad keeps a 10 MON reserve on every account: a stake that would leave less is refused (getMaxStakeable()). Rewards accrue separately in unclaimedRewards; claim them or compound them into the stake.

export async function unstakeMon(wallet: Wallet, wei: bigint) {
const staking = await wallet.getProtocol<NativeStakingProtocolMonad>('mon-staking')
// Only active stake can be undelegated; the module picks a free withdrawal slot (0..255).
const { id, withdrawableAtEpoch } = await staking.requestWithdrawal({ amount: wei, validator: 97n })
return { id, withdrawableAtEpoch } // claimable one epoch after the stake deactivates
}
export async function claimMon(wallet: Wallet) {
const staking = await wallet.getProtocol<NativeStakingProtocolMonad>('mon-staking')
const { claimableIds } = await staking.getWithdrawalRequests({ validator: 97n })
if (claimableIds.length > 0) await staking.claimWithdrawals({ ids: claimableIds }) // one withdraw per request
const position = (await staking.listPositions()).find((p) => p.validatorId === 97n)
if (position && position.unclaimedRewards > 0n) await staking.claimRewards({ validator: 97n }) // or compound({ validator })
return { claimed: claimableIds.length }
}

Only active stake can be undelegated. Each unstake takes a withdrawal slot (0–255 per validator) and can be withdrawn one epoch after the stake deactivates. The precompile withdraws one request per transaction, so claimWithdrawals may send several. After a full exit the precompile no longer lists the validator among the wallet’s delegations: keep the id that requestWithdrawal returns, or pass the validator in validators, so the withdrawal is found again.

The shared interfaces cover what every module can do. Modules add their own methods on top: Lido wraps stETH into wstETH, Aave toggles collateral and lists reserves with their rates and caps, Morpho lists vaults, SSV reports APR history. They are listed in each package’s API reference.