Skip to content

Solana

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.

Terminal window
npm install @solana/web3.js @solana/spl-token bs58

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.

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 transparently
const report = await res.json()

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 Uint8Array
new PipRailClient({ chain: 'solana', wallet: { signer: keypair } }) // a @solana/web3.js Keypair

See Wallets by family for every family’s wallet input.

tokenWhat it isDecimals
'native'SOL9
'USDC'Circle-native USDC6
'USDT'Tether-native USDT6
{ mint, decimals }Any other SPL token, by mintas 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 })

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 blockTime falls inside the maxTimeoutSeconds window (a missing blockTime fails closed, not open);
  • it actually moved at least amount of the asset to payTo, proven from the transaction’s own balance deltas (pre/postTokenBalances for SPL, pre/postBalances for SOL) — the same way Solana Pay’s validateTransfer does, 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.

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.

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,
})

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.