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: { key: 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 { key } or a ready { signer }. The key may be a
Uint8Array or a base58 string; the SDK wraps it into a Keypair. Passing an EVM wallet shape
(a viem { walletClient }) throws a clear
WrongFamilyError on first use.
new PipRailClient({ chain: 'solana', wallet: { key: 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 })Gasless — the standard exact rail
Section titled “Gasless — the standard exact rail”Solana supports the ratified x402 exact scheme (the svm method), so any standard x402 client
can pay your gate and the buyer spends zero SOL. It works for any SPL token — USDC and
USDT alike — because the gasless-ness comes from the transaction’s fee payer, not from a token
feature (there is no EIP-3009/Permit2 equivalent on Solana, and none is needed).
Fully gasless via a facilitator (recommended) — neither buyer nor merchant pays SOL; the
facilitator (e.g. PayAI, no API key) sponsors the gas. The gate
discovers the facilitator’s fee-payer pubkey from its GET /supported automatically. Live-proven on
mainnet.
// Seller — fully gasless. No relayer, no SOL.requirePayment({ chain: 'solana', token: 'USDC', amount: '0.05', payTo: 'YourReceiveAddr', exact: { settle: { facilitator: 'https://facilitator.payai.network' } },})
// Or self-settle with your own relayer (fee payer ≠ payTo) — your relayer pays the sub-cent fee:// exact: { settle: 'self', relayer: { key: process.env.SOLANA_RELAYER_KEY } }
// Buyer — opt in; the client signs the transfer, the sponsor pays the fee.new PipRailClient({ chain: 'solana', wallet, schemes: ['onchain-proof', 'exact'] })The buyer partial-signs the canonical [cu-limit, cu-price, TransferChecked, Memo] transaction — adding a
spec-required SPL-Memo (the rail’s extra.memo, else a random hex nonce) for transaction uniqueness — and
leaves the fee-payer slot empty; the facilitator (or your relayer) co-signs as fee payer and broadcasts. The
buyer needs only the token (zero SOL). The recipient’s token account must already exist — the exact rail
won’t create it (a brand-new recipient is payable on onchain-proof, which does). Native SOL is not
exact-payable.
Because the fee payer (the facilitator, or your self-settle relayer) co-signs a buyer-built transaction,
the gate enforces the scheme’s fee-payer safety rules (the fee payer in no instruction, never a program,
never drained) and caps the compute budget before co-signing (MAX_COMPUTE_UNIT_LIMIT = 300 000 units,
MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 100 000) — so a buyer can’t inflate the fee to drain the sponsor.
See sponsor protection. Full
details: Gasless payments ·
exact rail (buyer) · exact rail (seller).
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.