Solana
Introduction
Section titled “Introduction”Solana works exactly like an EVM chain — you name it. The driver auto-mounts on first use via a single lazy import, so a pure-EVM install never downloads the Solana libraries. The only setup is installing the peer dependencies, and there is no receiver prerequisite: the payer’s transaction idempotently creates the recipient’s token account for you.
npm install @solana/web3.js @solana/spl-token bs58Charge in one line
Section titled “Charge in one line”Name the chain and the token. Pass the recipient’s wallet address as payTo (a base58
pubkey), never a token-account address.
import { requirePayment } from '@piprail/sdk'
requirePayment({ chain: 'solana', token: 'USDC', amount: '0.10', payTo: 'YourSolanaAddr' })The driver mounts on first use — there is no setup call. See requirePayment & createPaymentGate for the full server side.
Pay in one line
Section titled “Pay in one line”A PipRailClient bound to solana pays any 402 it can settle.
import { PipRailClient } from '@piprail/sdk'
const client = new PipRailClient({ chain: 'solana', wallet: { secretKey: process.env.AGENT_KEY }, // base58 string or Uint8Array})
const res = await client.fetch('https://api.example.com/report')// → a normal Response — the 402 was paid and retried transparentlyconst report = await res.json()The wallet shape
Section titled “The wallet shape”Solana wallets are { secretKey } or a ready { signer }. The secretKey may be a
Uint8Array or a base58 string; the SDK wraps it into a Keypair. Passing an EVM wallet shape
({ privateKey } or { walletClient }) throws a clear
WrongFamilyError on first use.
new PipRailClient({ chain: 'solana', wallet: { secretKey: process.env.AGENT_KEY } }) // base58 or Uint8Arraynew PipRailClient({ chain: 'solana', wallet: { signer: keypair } }) // a @solana/web3.js KeypairSee Wallets by family for every family’s wallet input.
Supported tokens
Section titled “Supported tokens”token | What it is | Decimals |
|---|---|---|
'native' | SOL | 9 |
'USDC' | Circle-native USDC | 6 |
'USDT' | Tether-native USDT | 6 |
{ mint, decimals } | Any other SPL token, by mint | as given |
USDC and USDT are pre-filled with their canonical mints, so you never paste a mint address. Any
other SPL token works by passing { mint, decimals } — no allowlist.
// A custom SPL token by mint:const payTo = 'YourSolanaAddr'requirePayment({ chain: 'solana', token: { mint: 'EPjF…Dt1v', decimals: 6 }, amount: '0.10', payTo })Receiver setup — none
Section titled “Receiver setup — none”Solana needs no recipient prerequisite. The payer’s transaction idempotently creates the
recipient’s associated token account and pays its ~0.00204 SOL rent as part of the same
transfer, so payTo never has to opt in or register ahead of time.
The payer needs SOL for gas plus a funded source token account for the SPL token being sent.
Proof binding — digest-bound (Template B)
Section titled “Proof binding — digest-bound (Template B)”Solana uses Template B: the payment proof is the transaction signature, and verify() reads
the transaction back from your RPC to prove it. It re-derives every checked field from the
trusted accept, never the client-supplied reference, and confirms four things:
- the transaction exists and succeeded (
meta.err === null); - it is recent — its
blockTimefalls inside themaxTimeoutSecondswindow (a missingblockTimefails closed, not open); - it actually moved at least
amountof the asset topayTo, proven from the transaction’s own balance deltas (pre/postTokenBalancesfor SPL,pre/postBalancesfor SOL) — the same way Solana Pay’svalidateTransferdoes, robust to however the transfer was built; - the signature is single-use against the proof set.
Because the proof is single-use, multi-instance deployments should plug in a persistent
isUsed / markUsed store and keep maxTimeoutSeconds tight. See
Replay protection and
Proof binding for the shared model.
Planning a payment before you spend
Section titled “Planning a payment before you spend”planPayment(url) reads balances, gas, and recipient readiness on-chain and tells you whether a
rail is settleable — without paying and without throwing. On Solana “I hold USDC but no SOL for
gas” surfaces as an INSUFFICIENT_GAS blocker rather than a failed broadcast. It returns
PaymentPlan | null (null when the URL isn’t payment-gated), so null-guard the result first.
const url = 'https://api.example.com/report'const plan = await client.planPayment(url)
if (!plan) { await client.fetch(url) // not payment-gated — fetch it for free} else if (plan.payable) { await client.fetch(url) // safe — we checked} else { console.log(plan.fundingHint) // one human-readable line: what to top up (SOL gas, or the token)}See planPayment() for the full PaymentPlan shape.
When a payment can’t go through
Section titled “When a payment can’t go through”Affordability always converges on one typed
InsufficientFundsError (.code === 'INSUFFICIENT_FUNDS') — whether
you’re short on the token or short on SOL for gas. On Solana the gas-token shortfall is
the headline trap: you hold USDC but no SOL to send it. Catch it and read the .code:
import { PipRailError } from '@piprail/sdk'
const url = 'https://api.example.com/report'try { const res = await client.fetch(url) const report = await res.json()} catch (err) { if (err instanceof PipRailError && err.code === 'INSUFFICIENT_FUNDS') { // out of USDC, or out of SOL for gas — fund the payer and retry console.error('Payer is short:', err.message) } else { throw err }}The built-in default RPC (api.mainnet-beta.solana.com) is rate-limited. Pass your own
rpcUrl in production — there is no separate API-key field, so fold any key into the URL.
requirePayment({ chain: 'solana', token: 'USDC', amount: '0.10', payTo: 'YourSolanaAddr', rpcUrl: process.env.SOLANA_RPC,})In the browser
Section titled “In the browser”Solana runs in the browser as well as on the server: the libraries load from a CDN via an import map that pins them to a browser-ESM build. The lazy import means a pure-EVM page never downloads them. For server-only the same one line runs unchanged on Node, Bun, Deno, or Workers.