npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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_000 lamports (decimals: 9). All amount fields are bigint lamports.

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

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.idstakeAccount (same idea as BSC unbond index or 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 separate Rewards balance.
  • 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; getNonce is always 0 (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 address
  • status = "Active" if in current, "Inactive" if delinquent
  • creditAddress = "" (no BSC-style credit contract)
  • apy = computed issuance APY (percent); 0 for 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 ClaimableClaimDelegate. 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.
  • getStakeActivation RPC 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 Rewards entry from getBalances()
  • ClaimRewards transaction 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 Delegate builds issued before either confirms will both pick the same index and emit CreateAccountWithSeed at that address; the second lands on an existing account and is rejected on-chain (no funds lost, just a failed tx). Serialize Delegate submissions per wallet, or wait for confirmation between them.

Discovery (getDelegations / getBalances):

  1. Shared in-memory cache per authority (default TTL 30s)
  2. Seed-scan 0 … seedScanMax (default max 50), stop after seedScanGapLimit consecutive empty slots (default 5)
  3. Optional heavy getProgramAccounts (staker memcmp) if enableGpaFallback: 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)

  1. Source of truth: getDelegations(chain, wallet) after each confirmed mutation (or after cache TTL).
  2. Stable handle: delegation.id === stake account base58 — store this for undelegate/claim, not only seed index.
  3. Seed index: delegation.delegationIndex when discovered via seed-scan (useful for support/debug); GPA-found accounts have no seed and report -1n — never treat that as seed 0.
  4. Totals: getBalances for Available / Staked / Pending / Claimable sums.
  5. 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, from getInflationRate + getSupply + summed activatedStake)
  • 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/sdk

Dependencies

| 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 timingSafeEqual from node:crypto in the compile() 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 shim crypto.

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 above

Full 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;
}

rpcUrl must be operator-trusted configuration — never accept it directly from untrusted end-user input. rejectPrivateRpcHosts is opt-in (default false, i.e. unchanged behavior); when true, it rejects 127.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/localhost hostnames.

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 / Staked in 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): Staked counts a position's delegated stake (effective + activating), which excludes the ~0.0023 SOL rent-exempt reserve, while Claimable counts the account's full lamports (reserve included, since a full withdraw closes the account and returns it). So Available + Staked + Pending + Claimable slightly under-reports total controlled SOL by roughly one rent reserve per active/pending account. Use Staked as economic stake weight, Claimable as 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.
  • computeUnitPrice defaults to 100_000 microlamports/CU when the caller omits defaultComputeUnitPrice — so a Delegate (200k CU) pays ~20,000 lamports priority, an Undelegate/Claim (50k CU) ~5,000. Pass defaultComputeUnitPrice: 0n (config) or computeUnitPrice: 0n (per-tx) to opt out.
  • total = base fee (getFeeForMessage) + priority fee (ceil(computeUnits × computeUnitPrice / 1e6)). getFeeForMessage returns only the base signature fee, so the prioritization fee is added explicitly; with computeUnitPrice == 0 it contributes 0.
  • Sign rejects non-SolanaFee with INVALID_FEE_TYPE.
  • estimateFee does not require a funded wallet. For Delegate it builds the message (fee size is independent of balance) but skips the funding sufficiency check that sign() enforces — so you can quote a fee before funding. sign() still rejects an under-funded Delegate with INVALID_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 explorers

Configuring 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, below getStakeMinimumDelegation.
  • Prefund check: wallet must cover amount + rent + fee, where the fee component is fee.total when 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 ignores computeUnitPrice.
  • 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 | estimateFeesign({ privateKey })broadcast | | MPC / HSM | estimateFeepreHash → external Ed25519 over message bytes → compilebroadcast |

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 getDelegations and getBalances.
  • After a successful stake-mutating tx: wait for confirmation, then re-query (or wait for TTL).
  • enableGpaFallback: true only 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 === solanaMainnet

Roadmap / 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).