@guardian-sdk/solana
v1.0.2
Published
Guardian SDK for Solana native staking
Downloads
249
Readme
@guardian-sdk/solana — Solana
Native staking support for Solana (stake accounts + vote-account delegation), part of the Guardian SDK.
Abstracts Solana Kit transaction construction, stake-account discovery, client-side activation math, and JSON-RPC behind a clean, type-safe API so you can stake SOL to a validator, deactivate, and withdraw after cooldown — without dealing with create-with-seed derivation, StakeHistory warmup/cooldown, or wire formats yourself.
Units are lamports.
1 SOL = 1_000_000_000lamports (decimals: 9). Allamountfields arebigintlamports.
Multi-account by design. Unlike Cardano’s single stake key, Solana stake is one on-chain account per position. A wallet can hold many stake accounts; undelegate and withdraw target a specific stake account pubkey (
stakeAccount), not “the wallet’s stake” as a whole.
Table of Contents
- How Solana Native Staking Works
- Installation
- Browser / frontend usage
- Quick Start
- API Reference
- Transaction Flows
- Signing Flows
- Caching and discovery
- Logging
- Error Handling
- Supported Chains
- Roadmap / out of scope
How Solana Native Staking Works
Solana native staking is a delegation of lamports held inside stake accounts to a validator’s vote account. There is no ERC-20 share token (unlike BSC StakeCredit), no “freeze for ENERGY/BANDWIDTH” split (unlike Tron), and no UTXO-style “nothing locks” model (unlike Cardano). The SOL you stake moves into a stake account; that account is owned by the Stake program and is what earns rewards.
Compared to BSC, Cardano, and Tron
Short resume — not a full feature matrix:
- What is staked: SOL lives in stake accounts (like locking principal). That differs from Cardano (nothing locked) and is closer to BSC/Tron freezes than to “stake key only” delegation.
- How you target a position: use
delegation.id→stakeAccount(same idea as BSC unbondindexor Tron per-resource rows). Wallet address alone is not enough to undelegate/claim. - Rewards: auto-compound into the stake account — no
ClaimRewards(unlike Cardano/Tron). No separateRewardsbalance. - Unbonding: deactivate → wait ~epoch(s) → withdraw (
ClaimDelegate). BSC ~7d, Tron ~14d, Cardano none. - No Vote / no Redelegate in v1 (Tron needs Vote after freeze; BSC/Cardano support redelegate).
- Fees / signing:
SolanaFee+ single Ed25519 key;getNonceis always0(like Cardano/Tron).
UI takeaway: list getDelegations, act per row with stakeAccount; do not show a claim-rewards button for native SOL stake.
Authority vs stake account
Native staking is not “lock SOL in the wallet.” SOL sits in a stake account owned by the Stake program. The wallet is the authority (fee payer, staker, and withdrawer in v1), not the stake account address.
Wallet (authority) Stake account (per position)
fee payer / staker / withdrawer holds lamports; Meta.authorized = wallet
| |
| CreateAccountWithSeed |
| InitializeChecked |
| DelegateStake |
+------------------------------------->|
| |
| Withdraw (close) |
|<-------------------------------------+
| |
| v
| Vote account (validator)| Address | Role |
|---|---|
| Wallet / authority | getBalances / getDelegations / transaction.account / fee payer |
| Stake account | Holds staked SOL; target of Deactivate / Withdraw (stakeAccount) |
| Vote account | Validator for Delegate.validator (operatorAddress from getValidators) |
Position targeting: Undelegate and ClaimDelegate require package-local stakeAccount (base58) — same extension pattern as Tron’s resource. Pass delegation.id from getDelegations.
Vote accounts (validators)
On Solana, “validators” for delegators are vote accounts, not node identity alone. getValidators() uses getVoteAccounts and sets:
id/operatorAddress= vote account addressstatus="Active"if incurrent,"Inactive"if delinquentcreditAddress=""(no BSC-style credit contract)apy= computed issuance APY (percent);0for delinquent validators or if APY is unavailable
Always pass the vote pubkey as Delegate.validator.
Lifecycle of a Stake
Delegate (seed N)
CreateAccountWithSeed + InitializeChecked + DelegateStake
│
▼
activating ──(epoch boundary, ≤9%/epoch cluster cap)──► active
│ │
│ Undelegate (Deactivate)
│ │
│ ▼
│ deactivating
│ │
│ (epoch boundary + cooldown)│
│ ▼
│ fully inactive
│ │
│ ClaimDelegate (Withdraw)
│ │
└──────────────────────────────────────────► SOL in wallet; account closed| Stage | SDK DelegationStatus | Balance bucket | User action |
|---|---|---|---|
| Activating or fully active | Active | Staked | Wait / later Undelegate |
| Deactivating | Pending | Pending | Wait for cooldown |
| Fully inactive, lamports remain | Claimable | Claimable | ClaimDelegate with stakeAccount |
| Closed (zeroed) | omitted from list | — | — |
Minimum path to get SOL back after an active stake:Undelegate → wait until status is Claimable → ClaimDelegate. Two user transactions, and one or more epoch boundaries of wall time before the stake is fully inactive (often ~2–5 days depending when you land in the epoch).
Epochs, activation, and cooldown
- 1 epoch ≈ 432,000 slots ≈ 2–2.5 days on mainnet.
- Stake changes take effect at epoch boundaries.
- Warmup / cooldown rate is cluster-wide (~9% per epoch after the reduce-warmup feature). Small stakes usually become fully active/inactive after one boundary if cluster churn is low.
getStakeActivationRPC was removed. This package computes effective / activating / deactivating from the stake account, the StakeHistory sysvar, and the current epoch (same algorithm the runtime uses).
Near the start of an epoch, partitioned reward distribution can temporarily block stake mutations — retry broadcast if you see epoch-rewards-related failures.
Rewards auto-compound (no ClaimRewards)
When rewards are paid, they are added to both the stake account’s lamports and delegation.stake. There is:
- No separate reward account to drain (unlike Cardano)
- No
WithdrawBalance-style claim (unlike Tron) - No
Rewardsentry fromgetBalances() ClaimRewardstransaction type → unsupported
MEV / Jito tips are not native stake rewards and are outside this package.
Seed-derived stake accounts
v1 creates stake accounts only via Kit’s createAddressWithSeed / System CreateAccountWithSeed with CLI-compatible seeds "0", "1", "2", …:
- Base = wallet (authority)
- Owner = Stake program
- Seed string = decimal index
On Delegate, the builder picks the next free seed (first index whose derived address has no account). There is no seed field on the transaction object.
Concurrent Delegates share the same "next free seed". Seed selection is stateless — two
Delegatebuilds issued before either confirms will both pick the same index and emitCreateAccountWithSeedat that address; the second lands on an existing account and is rejected on-chain (no funds lost, just a failed tx). SerializeDelegatesubmissions per wallet, or wait for confirmation between them.
Discovery (getDelegations / getBalances):
- Shared in-memory cache per authority (default TTL 30s)
- Seed-scan
0 … seedScanMax(default max 50), stop afterseedScanGapLimitconsecutive empty slots (default 5) - Optional heavy
getProgramAccounts(staker memcmp) ifenableGpaFallback: true— finds stake accounts not created with our seed scheme (other wallets/apps). The result is unbounded by the node (no cap on returned accounts); it stays off by default. Enable only for authorities you know hold a bounded number of stake accounts.
Multiple stake accounts — when and how to track them
When you get more than one
| Situation | Result |
|---|---|
| User stakes twice (two Delegate txs) | Seeds "0" and "1" (or next free) — two stake accounts |
| Stake to different validators | Still one account per position (often one account per validator) |
| Unstake only one of several | Other accounts stay Active |
| Full withdraw closes an account | Seed index can be reused by a later Delegate |
| Wallet also used Phantom / CLI / other app | Extra accounts may exist; seed-scan alone can miss them unless enableGpaFallback |
Do you need multiple withdrawals?
Yes, if several accounts are Claimable. Each ClaimDelegate withdraws one stake account. There is no “claim all” batch in this package (similar to BSC not implementing claimBatch — one claim per unbond index).
getDelegations(wallet)
├── id=StakeA status=Claimable → ClaimDelegate { stakeAccount: StakeA }
├── id=StakeB status=Pending → wait
└── id=StakeC status=Active → optional Undelegate { stakeAccount: StakeC }How to keep track (product recipe)
- Source of truth:
getDelegations(chain, wallet)after each confirmed mutation (or after cache TTL). - Stable handle:
delegation.id=== stake account base58 — store this for undelegate/claim, not only seed index. - Seed index:
delegation.delegationIndexwhen discovered via seed-scan (useful for support/debug); GPA-found accounts have no seed and report-1n— never treat that as seed 0. - Totals:
getBalancesfor Available / Staked / Pending / Claimable sums. - UI: one row per delegation; actions disabled until status allows (e.g. Withdraw only when
Claimable).
getBalances alone is not enough to withdraw — you must know which stakeAccount to pass.
Whole-account undelegate & claim
| Op | Amount rules |
|---|---|
| Delegate | Explicit amount (stake lamports); builder adds rent-exempt reserve; isMaxAmount: true rejected |
| Undelegate | Whole stake account (Deactivate); amount ignored; stakeAccount required |
| ClaimDelegate | Full withdraw / close; amount ignored; stakeAccount required |
Partial unstake would require Split (out of scope for v1). For comparison: Tron allows partial unfreeze; Cardano does not “lock” principal at all.
APR / APY
Validator.apy and stakingSummary.maxApy carry a computed issuance APY (percent):
networkApr = inflationRate.validator / stakedFraction(both annual, fromgetInflationRate+getSupply+ summedactivatedStake)- per validator:
× (1 − commission), then compounded per epoch to an APY (epochsPerYear = 78_894_000 / slotsInEpoch). stakingSummary.maxApy= the lowest-commission current validator's APY.
Issuance only — excludes MEV and priority/block fees. Live check (epoch 1006): ours ~5.65% vs Helius's own validator total_apy 5.63% (MEV was ~0.10pp). Delegator-facing yield is therefore very close; real yield runs marginally higher.
apy: 0 is also the "unavailable" sentinel. Delinquent validators are always 0 (not producing). If the inflation/supply/epoch fetch fails, the service logs a warning and reports apy 0 / maxApy 0 rather than breaking getValidators / getDelegations. Check logs to distinguish "unavailable" from a genuine ~0% (100%-commission) validator.
Inputs are fetched best-effort and cached with the validators list (~3 min TTL): 3 extra RPC calls per refresh, shared by getValidators and getDelegations.
Installation
npm install @guardian-sdk/solana @guardian-sdk/sdk
# or
pnpm add @guardian-sdk/solana @guardian-sdk/sdkDependencies
| Package | Role |
|---|---|
| @guardian-sdk/sdk | Peer — chain-agnostic contracts, shared Transaction / Balance / Fee types |
| @solana/kit | RPC, addresses (createAddressWithSeed), codecs, signers, transaction messages |
| @solana/sysvars | Clock / StakeHistory (and sysvar addresses) |
| @solana-program/stake | Stake instruction builders + stake state codec |
| @solana-program/system | CreateAccountWithSeed and system instructions |
No @solana/web3.js — this package is Kit-native.
Browser / frontend usage
@guardian-sdk/solana runs in the browser as-is — no polyfills required. @solana/kit is browser-first and this package pulls in no Node built-ins.
Note (browser safety): an earlier version imported
timingSafeEqualfromnode:cryptoin thecompile()path, which threw at runtime in browsers. That has been replaced with a pure-JS constant-time comparison, so the package is now fully browser-clean — you do not need to alias or shimcrypto.
For a wallet frontend, prefer the external-signer flow (prehash → your keystore / hardware / MPC signs the message bytes → compile) over sign(privateKey), so raw keys never pass through application JavaScript. See Signing Flows.
Quick Start
import { GuardianSDK } from "@guardian-sdk/sdk";
import {
solana,
chains,
LAMPORTS_PER_SOL,
type SolanaUndelegateTransaction,
type SolanaClaimDelegateTransaction,
} from "@guardian-sdk/solana";
const sdk = new GuardianSDK([
solana({
rpcUrl: process.env.SOLANA_RPC_URL ?? "https://api.mainnet-beta.solana.com",
}),
]);
const ADDRESS = process.env.SOLANA_ADDRESS!;
/** 32-byte Ed25519 seed as 64 hex characters (not a full 64-byte keypair file). */
const PRIVATE_KEY = process.env.SOLANA_PRIVATE_KEY!;
// 1. Validators (vote accounts). apy is computed issuance APY.
const { data: validators } = await sdk.getValidators(chains.solanaMainnet);
const voteAccount = validators[0]!.operatorAddress;
// 2. Stake 1 SOL → creates seed account + delegates in one tx
const delegate = {
type: "Delegate" as const,
chain: chains.solanaMainnet,
amount: 1n * LAMPORTS_PER_SOL,
isMaxAmount: false,
validator: voteAccount,
account: ADDRESS,
};
const fee = await sdk.estimateFee(delegate);
const rawTx = await sdk.sign({
transaction: delegate,
fee,
nonce: 0,
privateKey: PRIVATE_KEY,
});
const sig = await sdk.broadcast(chains.solanaMainnet, rawTx);
console.log(`Delegated: https://explorer.solana.com/tx/${sig}`);
// 3. List positions — one Delegation per stake account
const { delegations } = await sdk.getDelegations(chains.solanaMainnet, ADDRESS);
for (const d of delegations) {
console.log(d.status, d.id, d.amount, d.validator.operatorAddress);
}
const stakeAccount = delegations[0]!.id; // use as stakeAccount for undelegate/claim
// 4. Start unstake (whole account)
const undelegate: SolanaUndelegateTransaction = {
type: "Undelegate",
chain: chains.solanaMainnet,
amount: 0n, // ignored
isMaxAmount: false,
stakeAccount,
account: ADDRESS,
};
const undelegateFee = await sdk.estimateFee(undelegate);
const undelegateRaw = await sdk.sign({
transaction: undelegate,
fee: undelegateFee,
nonce: 0,
privateKey: PRIVATE_KEY,
});
await sdk.broadcast(chains.solanaMainnet, undelegateRaw);
// 5. After status becomes Claimable (epoch wait), withdraw
const claim: SolanaClaimDelegateTransaction = {
type: "ClaimDelegate",
chain: chains.solanaMainnet,
amount: 0n, // ignored
stakeAccount,
account: ADDRESS,
};
// estimateFee → sign → broadcast same as aboveFull runnable sample: examples/solana-native-staking-sample.ts.
API Reference
solana()
Factory. Returns a plain GuardianServiceContract for Solana mainnet (no facade class).
function solana(config: SolanaConfig): GuardianServiceContract
interface SolanaConfig {
rpcUrl: string;
logger?: Logger;
/** Microlamports per CU for priority fee ixs (default 100_000; pass 0n to opt out). */
defaultComputeUnitPrice?: bigint;
/** Stop seed scan after N consecutive missing accounts (default 5). */
seedScanGapLimit?: number;
/** Inclusive max seed index to probe (default 50). */
seedScanMax?: number;
/** Authority → stake positions cache TTL ms (default 30_000). */
stakeCacheTtlMs?: number;
/** Full vote-accounts list cache TTL ms (default 180_000). */
validatorsCacheTtlMs?: number;
/** getProgramAccounts by staker — heavy; default false. */
enableGpaFallback?: boolean;
/** JSON-RPC options forwarded to every sendTransaction (broadcast) call. */
broadcastOptions?: SolanaSendTransactionOptions;
/** Opt-in SSRF guard: reject rpcUrl hosts that are loopback/private/link-local/metadata. Default false. */
rejectPrivateRpcHosts?: boolean;
}
interface SolanaSendTransactionOptions {
skipPreflight?: boolean; // default false
preflightCommitment?: "processed" | "confirmed" | "finalized";
maxRetries?: number; // node-side resend attempts
minContextSlot?: bigint;
}
rpcUrlmust be operator-trusted configuration — never accept it directly from untrusted end-user input.rejectPrivateRpcHostsis opt-in (defaultfalse, i.e. unchanged behavior); whentrue, it rejects127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16(incl.169.254.169.254), and.local/localhosthostnames.
const sdk = new GuardianSDK([solana({ rpcUrl: "https://api.mainnet-beta.solana.com" })]);getValidators
Reads getVoteAccounts (current + delinquent). The full list is cached (~3 minutes by default), then paginated in memory.
const { data, pagination } = await sdk.getValidators(chains.solanaMainnet);
const page2 = await sdk.getValidators(chains.solanaMainnet, { page: 2, pageSize: 50 });Returns: Promise<ValidatorsPage>
interface Validator {
id: string; // vote account
status: ValidatorStatus; // "Active" | "Inactive" (delinquent)
name: string;
description: string;
image: undefined;
apy: number; // computed issuance APY (percent); 0 for delinquent or unavailable
delegators: number | undefined;
operatorAddress: string; // vote account — use as Delegate.validator
creditAddress: string; // always ""
}getDelegations
Discovers stake accounts for the authority, runs client-side activation, returns one Delegation per stake account.
const { delegations, stakingSummary } = await sdk.getDelegations(
chains.solanaMainnet,
ADDRESS
);interface Delegation {
id: string; // stake account base58 — pass as stakeAccount
validator: Validator; // never null
amount: bigint; // lamports for this position
status: DelegationStatus; // Active | Pending | Claimable
delegationIndex: bigint; // seed index when known
pendingUntil: number; // approx epoch-boundary ms when Pending, else 0
}| Derived state | status | amount meaning (actionable) | Balance type |
|---|---|---|---|
| active or activating | Active | effective + activating (or delegated stake) | Staked |
| deactivating | Pending | deactivating lamports | Pending |
| fully inactive w/ balance | Claimable | withdrawable (typically full lamports) | Claimable |
Activating is folded into
Active/Stakedin v1 (no separate status). Activating stake is not yet earning; still show it as staked principal.
Placeholder validator when inactive/claimable without a voter:
id: "solana-stake-inactive",name: "Inactive stake",status: "Inactive",apy: 0— always non-null (same idea as Tron’s Frozen placeholder).
Every shape getDelegations() can return
Amounts in SOL for readability (SDK returns lamports).
1. Single active stake
on-chain: seed0 delegated to VoteA, fully active
→ [ Active amount=… id=Stake0 validator=VoteA ]2. Two stakes (two Delegate txs)
→ [ Active id=Stake0 VoteA ]
[ Active id=Stake1 VoteB ]3. One deactivating, one still active
→ [ Active id=Stake0 VoteA ]
[ Pending id=Stake1 VoteA pendingUntil≈… ]4. Ready to withdraw
→ [ Claimable id=Stake1 amount=… validator=placeholder or last voter ]Invariants for a clean wallet (seed-only positions):Σ Staked + Σ Pending + Σ Claimable matches the stake-side of getBalances (Available is liquid wallet SOL only).
stakingSummary
| Field | Source |
|---|---|
| totalProtocolStake | Σ activated stake from vote accounts |
| maxApy | Lowest-commission current validator's issuance APY (percent) |
| minAmountToStake | getStakeMinimumDelegation |
| unboundPeriodInMillis | ~1 epoch wall-time estimate (approximate) |
| redelegateFeeRate | 0 |
| activeValidators | current.length |
| totalValidators | current + delinquent |
getBalances
| BalanceType | Source | Same as |
|---|---|---|
| Available | getBalance(wallet) | BSC/Cardano/Tron liquid |
| Staked | Σ active + activating positions | BSC Active, Tron frozen (conceptually) |
| Pending | Σ deactivating | BSC/Tron unbonding |
| Claimable | Σ fully inactive withdrawable | BSC/Tron matured unbond principal |
| Rewards | Not returned | Cardano/Tron only |
No double-counting: lamports in Pending are not also in Staked.
Rent-reserve asymmetry (by design):
Stakedcounts a position's delegated stake (effective + activating), which excludes the ~0.0023 SOL rent-exempt reserve, whileClaimablecounts the account's full lamports (reserve included, since a full withdraw closes the account and returns it). SoAvailable + Staked + Pending + Claimableslightly under-reports total controlled SOL by roughly one rent reserve per active/pending account. UseStakedas economic stake weight,Claimableas the exact withdrawable figure.
getBalances and getDelegations share one stake-position cache (default 30s TTL).
getNonce
Always returns 0. Solana messages use a recent blockhash (fetched at build time), not an account nonce. Same pattern as Cardano and Tron.
estimateFee
Returns SolanaFee — Solana’s fee model is neither EVM gas, Cardano UTxO, nor Tron bandwidth.
interface SolanaFee {
type: "SolanaFee";
computeUnits: bigint; // static budget per op class (v1)
computeUnitPrice: bigint; // microlamports per CU (from config / fee)
total: bigint; // lamports — base fee (getFeeForMessage) + priority fee
}- Message includes compute-budget ixs when
computeUnitPrice > 0. computeUnitPricedefaults to100_000microlamports/CU when the caller omitsdefaultComputeUnitPrice— so aDelegate(200k CU) pays ~20,000 lamports priority, an Undelegate/Claim (50k CU) ~5,000. PassdefaultComputeUnitPrice: 0n(config) orcomputeUnitPrice: 0n(per-tx) to opt out.total= base fee (getFeeForMessage) + priority fee (ceil(computeUnits × computeUnitPrice / 1e6)).getFeeForMessagereturns only the base signature fee, so the prioritization fee is added explicitly; withcomputeUnitPrice == 0it contributes 0.- Sign rejects non-
SolanaFeewithINVALID_FEE_TYPE. estimateFeedoes not require a funded wallet. ForDelegateit builds the message (fee size is independent of balance) but skips the funding sufficiency check thatsign()enforces — so you can quote a fee before funding.sign()still rejects an under-fundedDelegatewithINVALID_AMOUNT.
Typical static CU budgets (implementation defaults): Delegate higher than Undelegate/Claim (create+init+delegate is multi-ix).
sign
Builds the message (latest blockhash, fee payer = authority), signs with Ed25519, returns base64 wire transaction for broadcast.
| | Solana | BSC | Cardano | Tron | |---|---|---|---|---| | Curve | Ed25519 | secp256k1 | Ed25519 ×2 | secp256k1 | | Keys | One seed (v1) | One | payment + staking | One | | Output | base64 wire tx | signed hex tx | CBOR hex | JSON signed tx |
Private key (v1): 32-byte Ed25519 seed as 64 lowercase hex characters.
Full 64-byte solana-keygen secret arrays are out of scope — use the 32-byte seed only.
transaction.account must match the address derived from privateKey when both are provided.
prehash and compile
MPC / hardware path (same contract as other chains; payload differs):
| Method | Solana meaning |
|---|---|
| prehash | serializedTransaction = base64 of compiled message bytes (what Ed25519 signs) — not the wire tx. Threads _messageBytes / _wireTransaction on SolanaSignArgs. |
| compile | signature = base64 of 64-byte Ed25519 signature; returns base64 wire tx |
| broadcast | sendTransaction with base64 wire |
Compare: Tron prehash returns txID (secp256k1 digest); Cardano returns Blake2b-256 body hash; BSC returns RLP preimage.
const { serializedTransaction, signArgs } = await sdk.preHash({
transaction: delegate,
fee,
nonce: 0,
});
// Ed25519-sign the bytes decoded from serializedTransaction (message bytes)
const signatureBase64 = /* external 64-byte sig as base64 */;
const rawTx = await sdk.compile({ signArgs, signature: signatureBase64 });
await sdk.broadcast(chains.solanaMainnet, rawTx);broadcast
const signature = await sdk.broadcast(chains.solanaMainnet, rawTx);
// signature is base58 transaction signature for explorersConfiguring the send. The JSON-RPC sendTransaction parameters are set once on the factory via
broadcastOptions (they can't ride on the chain-agnostic broadcast(chain, rawTx) signature):
solana({
rpcUrl: "…",
broadcastOptions: {
skipPreflight: false, // default false
preflightCommitment: "confirmed", // "processed" | "confirmed" | "finalized"
maxRetries: 5, // node-side resend attempts
minContextSlot: 0n,
},
});Handling blockhash expiration. A signed transaction embeds a recent blockhash that expires after
~150 slots (~60–90s). When preflight is on (default), broadcasting an expired transaction throws
a BroadcastError with code === "BLOCKHASH_EXPIRED". The SDK does not auto-retry — catch it,
re-sign() (which fetches a fresh blockhash and re-signs), and rebroadcast:
import { BroadcastError } from "@guardian-sdk/solana";
async function submitWithBlockhashRetry(args, maxAttempts = 3): Promise<string> {
let rawTx = await sdk.sign(args);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await sdk.broadcast(chains.solanaMainnet, rawTx);
} catch (err) {
if (err instanceof BroadcastError && err.code === "BLOCKHASH_EXPIRED" && attempt < maxAttempts) {
rawTx = await sdk.sign(args); // fresh blockhash, re-signed (MPC: re-run preHash → compile)
continue;
}
throw err;
}
}
throw new Error("exhausted blockhash retries");
}With skipPreflight: true (or aggressive maxRetries), the node will not surface an expired
blockhash synchronously — in that mode run your own confirm-and-retry loop
(getSignatureStatuses) instead of catching BLOCKHASH_EXPIRED.
Transaction Flows
| User intent | SDK type | On-chain instructions |
|---|---|---|
| Stake SOL | Delegate | CreateAccountWithSeed + InitializeChecked + DelegateStake (+ optional CU budget) |
| Start unstake | SolanaUndelegateTransaction | Deactivate |
| Withdraw principal | SolanaClaimDelegateTransaction | Withdraw (full balance / close) |
Solana-only extensions
interface SolanaUndelegateTransaction extends UndelegateTransaction {
stakeAccount: string; // required
}
interface SolanaClaimDelegateTransaction extends ClaimDelegateTransaction {
stakeAccount: string; // required
}Delegate uses the shared DelegateTransaction (validator = vote account). Seed selection is internal.
Unsupported types (throw)
| Type | Why |
|---|---|
| Redelegate | On-chain Redelegate ix never activated; real path is multi-epoch or MoveStake (later) |
| ClaimRewards | Rewards auto-compound |
| Vote | Not a delegator action on Solana (contrast Tron) |
Also out of scope: Split / Merge / MoveStake / MoveLamports, lockup setup, custodian co-sign, dual authorities (staker ≠ withdrawer), ephemeral stake keypairs, liquid staking / stake pools, DeactivateDelinquent.
Delegate — create seed account and stake
const tx = {
type: "Delegate" as const,
chain: chains.solanaMainnet,
amount: 2n * LAMPORTS_PER_SOL, // stake body; rent added by builder
isMaxAmount: false,
validator: voteAccountPubkey,
account: wallet,
};- Rejects
isMaxAmount: true,amount ≤ 0, belowgetStakeMinimumDelegation. - Prefund check: wallet must cover
amount + rent + fee, where the fee component isfee.totalwhen the caller supplied a non-zero quote, otherwise the real priority fee (priorityFeeLamports(fee.computeUnits, computeUnitPrice)) plus a small base-fee cushion — not a flat constant that ignorescomputeUnitPrice. - Rent-exempt reserve is not delegated; only lamports above reserve are staked.
Undelegate — deactivate a stake account
const tx: SolanaUndelegateTransaction = {
type: "Undelegate",
chain: chains.solanaMainnet,
amount: 0n,
isMaxAmount: false,
stakeAccount: delegation.id,
account: wallet,
};- Rejects missing/invalid
stakeAccount, non–stake-program owner, wrong staker, already inactive/deactivating.
ClaimDelegate — withdraw after cooldown
const tx: SolanaClaimDelegateTransaction = {
type: "ClaimDelegate",
chain: chains.solanaMainnet,
amount: 0n,
stakeAccount: delegation.id,
account: wallet,
};- Requires fully inactive stake (never-deactivated accounts rejected client-side; residual cooldown also enforced on-chain).
- Rejects lockup in force (unix timestamp or epoch lockup). Custodian co-sign is not supported in v1.
- Withdraws all lamports to the authority and closes the account when empty.
Multiple claimable positions
const { delegations } = await sdk.getDelegations(chains.solanaMainnet, wallet);
const claimable = delegations.filter((d) => d.status === "Claimable");
for (const d of claimable) {
const tx: SolanaClaimDelegateTransaction = {
type: "ClaimDelegate",
chain: chains.solanaMainnet,
amount: 0n,
stakeAccount: d.id,
account: wallet,
};
const fee = await sdk.estimateFee(tx);
const raw = await sdk.sign({ transaction: tx, fee, nonce: 0, privateKey });
await sdk.broadcast(chains.solanaMainnet, raw);
}Same loop pattern as claiming several BSC unbond indexes one-by-one.
Signing Flows
| Flow | Steps |
|---|---|
| Direct | estimateFee → sign({ privateKey }) → broadcast |
| MPC / HSM | estimateFee → preHash → external Ed25519 over message bytes → compile → broadcast |
Always pass nonce: 0 (ignored for chain state; required by shared sign args).
Caching and discovery
| Cache | Default TTL | Key | |---|---|---| | Stake positions | 30s | authority address | | Vote accounts | 3 min | single list, then page |
- Shared between
getDelegationsandgetBalances. - After a successful stake-mutating tx: wait for confirmation, then re-query (or wait for TTL).
enableGpaFallback: trueonly if you must see non-seed stake accounts — expensive and often rate-limited by RPC providers.
Logging
Silent by default (NoopLogger). Pass logger into solana({ logger }). Private keys and signatures are never logged.
Error Handling
Errors extend GuardianError (ValidationError, ConfigError, SigningError) re-exported from this package / @guardian-sdk/sdk.
| Code | Typical cause |
|---|---|
| INVALID_ADDRESS | Bad authority / stake / vote address; wrong stake authority |
| INVALID_AMOUNT | Non-positive Delegate amount, isMaxAmount: true, below min delegation, insufficient balance |
| INVALID_FEE / INVALID_FEE_TYPE | Fee estimate failed or non-SolanaFee on sign |
| INVALID_SIGNING_ARGS | Bad private key format, missing prehash state, bad signature length |
| UNSUPPORTED_TRANSACTION_TYPE | Redelegate / ClaimRewards / Vote |
| UNSUPPORTED_OPERATION | Missing stakeAccount, wrong activation state, lockup in force, no free seed, etc. |
Supported Chains
| Chain | id | symbol | decimals | explorer |
|---|---|---|---|---|
| Solana mainnet | solana-mainnet | SOL | 9 | https://explorer.solana.com |
import { chains, solanaMainnet } from "@guardian-sdk/solana";
// chains.solanaMainnet === solanaMainnetRoadmap / out of scope
Intentionally not in v1 (may land later):
| Feature | Notes | |---|---| | Split / partial undelegate | Enables partial unstake without whole-account deactivate | | Merge / MoveStake / MoveLamports | Instant rebalance / dust cleanup | | Product redelegate | Multi-tx UX after deactivate | | Dual authority / lockup UI | Custody patterns | | Ephemeral stake keypairs | Multi-sig create (worse MPC story than seeds) | | GPA-default discovery | Opt-in only today | | Activating as distinct status | Currently folded into Active | | Liquid staking / stake pools | Different product surface |
Keep this README in sync when you change balance modelling, signing, fee shapes, or delegation mapping (same discipline as Cardano / Tron).
