@anywalletkit/protocol-staking-native-solana
v0.1.4
Published
Native Solana staking for @anywalletkit/wallet-solana accounts: delegate SOL straight to the validators your app lists, through the Stake program. Seed-derived stake accounts (only the wallet signs, the position rebuilds from the wallet), exact activation
Maintainers
Readme
@anywalletkit/protocol-staking-native-solana
Native SOL staking for @anywalletkit/wallet-solana accounts: SOL delegated straight to the
validators your app lists, through Solana's Stake program. No pool, no token, no third-party
program. Each position is a stake account owned by the wallet.
npm install @anywalletkit/protocol-staking-native-solana @anywalletkit/wallet-solanaUsage
import { Wallet } from '@anywalletkit/kit'
import { WalletManagerSolana } from '@anywalletkit/wallet-solana'
import { NativeStakingProtocolSolana } from '@anywalletkit/protocol-staking-native-solana'
const wallet = new Wallet(seedPhrase)
wallet.registerWallet('solana', WalletManagerSolana, { provider: SOLANA_RPC })
wallet.registerProtocol('sol-staking', NativeStakingProtocolSolana, {
chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
config: {
validators: [{ voteAccount: VOTE_A, name: 'Our validator' }, VOTE_B], // required
maxCommission: 10,
},
})
const staking = await wallet.getProtocol<NativeStakingProtocolSolana>('sol-staking')
const validators = await staking.listValidators() // commission, delinquent, apr, eligible, refusals
await staking.quoteStake({ amount: 2_000_000_000n }) // fee, stake account, rent, readyEpoch; nothing signed
await staking.stake({ amount: 2_000_000_000n, validator: VOTE_A })
await staking.getStakeAccounts() // per account: status, amounts, validator, readyAt
await staking.getStakedBalance() // { balance, total, active, activating, deactivating, claimable }
await staking.requestWithdrawal({ amount: 1_000_000_000n }) // deactivate (split first when partial)
const q = await staking.getWithdrawalRequests()
if (q.claimableIds.length) await staking.claimWithdrawals({ ids: q.claimableIds })Amounts are lamports (bigint). Without provider in the config, reads go to the account's own RPC.
Config
| Option | Default | |
|---|---|---|
| validators | — (required) | Vote accounts (strings or { voteAccount, name }), or 'any' to let users pick from the whole cluster. No list, no module. |
| defaultValidator | first eligible of the list | Used when stake / delegate name none. Must be in the list. No default in 'any' mode. |
| maxCommission | 10 | Percent. A validator above it is refused at delegation time (commission can change after you list it). |
| allowDelinquent | false | Delinquent validators earn their stake nothing. |
| provider | the account's RPC | URL, list of URLs (failover), @solana/rpc client or { request(method, params) }. |
| discover | false | Also list stake accounts other wallets created for the address (getProgramAccounts; the RPC must serve it). Only those the wallet is the withdraw authority of (owned) count in the position and can be unstaked, delegated or merged. |
| seedPrefix / maxAccounts | stake: / 32 | Stake accounts live at create_with_seed(wallet, "<prefix><i>", Stake), i < maxAccounts. |
| feeReserve | 0.005 SOL | Kept on the wallet by stake, so the user can still pay to unstake and claim. |
| computeUnitLimit / computeUnitPrice | wallet decides | Explicit compute budget; otherwise the wallet sets it from simulation and recent fees. |
| enrich | — | (voteAccounts) => ({ [vote]: { apr, name, … } }): an explorer's APR (with MEV), names, scores. Decoration only. |
| commitment / timeoutMs / fetch | confirmed / 15 s / global | Reads. |
How it works
- Stake is one transaction: System
CreateAccountWithSeed(amount + rent, base = the wallet), StakeInitialize(staker = withdrawer = the wallet, no lockup),DelegateStake. Only the wallet signs. It becomes active at the next epoch boundary (~2 days). The minimum is the Stake program's minimum delegation (1 SOL) plus about 0.0017 SOL of rent, which returns on withdrawal. - Statuses come from the StakeHistory sysvar with the program's own integer warm-up / cool-down
math (9 % of the cluster per epoch), so
inactivemeans the program will let every lamport out. Each account reportseffective,activating,deactivating,withdrawable, and while it changes,readyEpoch/readyAt. - Unstake deactivates whole accounts and splits at most one: an exact match first, else the plan with the fewest steps where the split part and what stays behind both keep the minimum delegation. The split part goes to a new seed account (prefunded with rent) and is deactivated. A stake deactivated in the epoch it was made is free at once.
- Claim withdraws fully inactive accounts to the wallet (the accounts close), up to 10 per
transaction: with more ids or more ready accounts,
remainingsays how many are left for the next call. - Delegate re-delegates an inactive account, or, in the epoch an account was deactivated, puts it back on its validator (undoes the unstake; the validator must still pass the policy). An account still activating moves only to a validator named in the call. Merge joins two inactive accounts, two fully active ones on the same validator, or two activated this epoch on the same validator.
- APR:
getApr()/ValidatorInfo.apris validator inflation × supply ÷ staked SOL × (1 − commission). It leaves out MEV and block-reward sharing; passenrichfor a fuller figure.getRewards({ epochs })lists the inflation rewards the wallet's accounts actually received.
Safety
- Fail-closed before anything is signed: unlisted, unknown, delinquent or over-commission
validators; amounts under the minimum; a wallet that would drop under
feeReserve; accounts the wallet is not the authority of; a lockup in force; merges the program would reject. - Accounts the module creates have the wallet as staker and withdrawer and no lockup. It never sets
a custodian, never changes an authority, and never acts on an account whose SOL another address
withdraws (a
discovered account the wallet only manages). - Seed slots: someone can send dust to a
stake:<i>address before the wallet uses it. Empty slots are used first. When only pre-funded ones are left, one is taken over with Transfer + AllocateWithSeed + Initialize;wallet-solanasigns that by default because the same transaction makes the wallet the account's staker and withdrawer (a wallet that refuses it gets an error that says so;maxAccounts, up to 100, opens new slots). Every write the module builds is tested againstwallet-solana's signing checks with the default config. - StakeHistory from an RPC node that has not reached the current epoch is refused, so statuses are never computed from a stale sysvar.
- With a Solana
allowlistpolicy, the kit checks each Stake instruction by whom it pays or empowers: allow the wallet's own address, the stake accounts (getStakeAccountAddress(i)) and the vote accounts of the validators you list. Allowlisting the Stake program itself opens nothing.
Testing
pnpm test: codecs byte for byte against mainnet transactions, the activation math against hand-computed program cases, every operation over an in-memory node, conformance with recorded mainnet activity.pnpm verify:live [address] [rpcUrl]: reads on mainnet, then every write planned for a real wallet and simulated by the wallet (simulateTransaction, signatures off). Nothing is signed.
