A few weeks ago we shipped gasless USDC checkout on EVM chains using EIP-3009. The promise was simple: a shopper holds USDC, clicks Pay, and the transfer happens without them ever needing to own ETH. One signature, one transaction, no allowance dance.
The reaction from merchants was immediate and consistent: "Great. Now do it on Solana."
Solana is the second-largest USDC venue by transfer count, and a
disproportionate share of high-frequency, low-ticket flows — tipping,
in-game purchases, content unlocks — settle there because confirmation
is sub-second and base fees are sub-cent. But the SPL token program
has no transferWithAuthorization. There is no EIP-712, no permit,
no convenient ERC-20 extension to lean on. If you naively port the EVM
flow, you end up either holding the user's keys or asking them to
sign two transactions, which defeats the point.
The good news is Solana already has every primitive you need to do this properly. They just have unfamiliar names: fee payer, partial signing, and durable nonces. This post is how we combine those three to deliver a checkout that feels identical to the EIP-3009 flow — with one MetaMask-equivalent popup, no SOL in the shopper's wallet, and no custodial risk.
The three primitives#
Every Solana transaction has three properties that matter here:
- A fee payer — the account whose lamports are debited for the transaction's base fee and prioritization fee. The fee payer is listed first in the account keys array. It does not have to be the same account as any of the senders inside the transaction's instructions.
- Multiple signers — every account marked
is_signer = truein the message must produce an Ed25519 signature over the transaction. The fee payer is always a signer; other signers are added per instruction. - A recent blockhash — every transaction commits to a blockhash that must be no more than 150 slots old (~60 seconds) when the leader processes it. This is Solana's replay protection.
The first two combine into the trick: a transaction can be built so that the shopper signs the SPL transfer and a separate relayer signs the fee payment, and either party can sign first. That is exactly the property EIP-3009 gives you on Ethereum — separation of authorization from gas — built directly into the protocol.
The third primitive, the blockhash window, is what forces the design decision that follows.
Why a recent blockhash isn't good enough#
In the obvious version of this flow, the relayer fetches a blockhash, builds the transaction, partially signs as fee payer, sends the half-signed tx bytes to the browser, the shopper signs and ships it back. Done.
In practice the user takes 8 to 30 seconds to read the wallet popup
and click Approve. By the time their signature comes back, your
blockhash might have aged out, the cluster might be congested, or the
RPC you used to fetch the hash might have been on a forked tip. The
transaction lands as BlockhashNotFound and you start over. We
measured this in production for two weeks: 17.4% of attempts
expired the blockhash mid-flight on Mainnet-Beta during peak hours.
You can paper over it with retries, but every retry forces a fresh wallet popup because the bytes the user signed are no longer valid. That is a checkout-killer.
The fix is the third primitive: durable nonces. A durable nonce is a Solana account owned by the system program that stores a nonce value. When you use a durable nonce as the first instruction in a transaction, the recent blockhash slot is replaced with the nonce value, and the transaction is valid until the nonce account advances — which happens exactly once, atomically, when the transaction is processed.
Translation: your half-signed transaction stops being a 60-second ticking time bomb and becomes a stable, cancellable object you can keep on your server until the user signs.
End-to-end flow#
shopper browser ──► GET /v1/payment_intents/:id → returns tx bytes
│ (relayer has already partially signed
│ as fee payer, advanceNonce instruction
│ prepended, transfer instruction included)
▼
shopper wallet ──► sign Ed25519 over message
│
▼
shopper browser ──► POST /v1/payment_intents/:id/submit
│ { signature }
▼
BchainPay API ──► attach shopper sig, sendTransaction()
│
▼
Solana RPC ──► confirm Finalized
│
▼
indexer ──► payment_intent.succeeded webhook
Three things to notice:
- The relayer signs first. It commits to paying the fee before the shopper has authorized anything. That's fine because the fee is trivial (~0.00001 SOL) and we cap it.
- The shopper signs once, and what they sign is the entire message — including the relayer's account in the fee-payer slot, the exact USDC amount, and the destination ATA. Their wallet shows them a transfer they actually approved.
- The nonce account is single-use per intent. We allocate one, advance it on submit, and recycle it later via a sweep job.
Building the half-signed transaction#
import {
Connection, PublicKey, Transaction, SystemProgram, NonceAccount,
} from '@solana/web3.js';
import {
createTransferCheckedInstruction, getAssociatedTokenAddress,
} from '@solana/spl-token';
export async function buildPaymentTx({
conn, shopper, merchant, usdcMint, amount, decimals,
relayer, nonceAccount, nonceAuthority,
}: BuildArgs) {
const fromAta = await getAssociatedTokenAddress(usdcMint, shopper);
const toAta = await getAssociatedTokenAddress(usdcMint, merchant);
// 1. Read the durable nonce. This becomes the recent blockhash.
const info = await conn.getAccountInfo(nonceAccount);
const nonce = NonceAccount.fromAccountData(info!.data);
const tx = new Transaction({
feePayer: relayer, // relayer pays
recentBlockhash: nonce.nonce, // durable, not time-bound
});
// 2. First instruction MUST be advanceNonce.
tx.add(SystemProgram.nonceAdvance({
noncePubKey: nonceAccount,
authorizedPubkey: nonceAuthority,
}));
// 3. The actual SPL transfer, signed by the shopper.
tx.add(createTransferCheckedInstruction(
fromAta, usdcMint, toAta, shopper,
BigInt(amount), decimals,
));
// 4. Partial sign as relayer + nonceAuthority. Send bytes to client.
tx.partialSign(relayerKeypair, nonceAuthorityKeypair);
return tx.serialize({
requireAllSignatures: false,
verifySignatures: false,
});
}The two non-obvious bits:
advanceNoncemust be the first instruction in the transaction. The runtime treats this as a structural requirement; if you put anything before it, the cluster rejects the tx withInvalidNonceAccounteven when every signature is valid.requireAllSignatures: falselets you serialize a transaction that isn't fully signed yet. The browser will deserialize it, ask the shopper's wallet to add their signature, then ship it back.
On the browser side it's about ten lines:
import { Transaction } from '@solana/web3.js';
export async function signAndSubmit(intentId: string, txBytes: Uint8Array) {
const tx = Transaction.from(txBytes);
// Wallet adapter handles the popup. The wallet sees the full message,
// including amount and destination ATA, before approving.
const signed = await wallet.signTransaction(tx);
await fetch(`/api/v1/payment_intents/${intentId}/submit`, {
method: 'POST',
body: JSON.stringify({
signature: bs58.encode(signed.signatures.find(s => s.publicKey.equals(wallet.publicKey))!.signature!),
}),
});
}Verifying server-side before you broadcast#
This is the part you cannot skip. Once the shopper's signature comes back, never broadcast it without re-checking that what they signed is what you think they signed. A compromised browser can, in principle, ship you a signature for a different payload. The cluster won't catch it because the signature is over their own intended message; your job is to refuse to relay anything that doesn't match the intent.
import nacl from 'tweetnacl';
export async function submit(intent: PaymentIntent, sig: Uint8Array) {
const tx = Transaction.from(intent.serializedTx);
tx.addSignature(new PublicKey(intent.shopper), Buffer.from(sig));
// 1. Re-verify every signature against the message bytes.
const msg = tx.serializeMessage();
for (const { signature, publicKey } of tx.signatures) {
if (!signature) throw new Error('missing sig');
const ok = nacl.sign.detached.verify(msg, signature, publicKey.toBytes());
if (!ok) throw new Error('bad sig');
}
// 2. Re-decode the transfer instruction and assert amount/destination.
const transferIx = tx.instructions[1];
const decoded = decodeTransferChecked(transferIx);
assert.equal(decoded.amount, BigInt(intent.amount));
assert.equal(decoded.destination.toBase58(), intent.merchantAta);
// 3. Send with maxRetries 0 — we own retry policy.
const sigStr = await conn.sendRawTransaction(tx.serialize(), {
skipPreflight: false,
maxRetries: 0,
});
await persistPending(intent.id, sigStr);
return sigStr;
}Three habits we drilled in after a near-miss:
- Always run preflight on the first send. Solana's preflight is effectively a free simulation. If the shopper's USDC balance has dropped between intent creation and submit, you'll know in 30ms instead of paying a base fee to find out.
- Own your retry policy. Set
maxRetries: 0and resubmit from the worker yourself, with backoff, until the durable nonce advances (success) or you decide to abandon (and free the nonce). The default RPC retry behavior is unpredictable across providers. - Watch for
BlockhashNotFoundeven with durable nonces. If someone else advances your nonce account out from under you (they shouldn't — onlynonceAuthoritycan — but bugs happen), you'll get this error. It means the intent is dead; refund and rebuild.
Pricing and abuse limits#
Solana base fees are 5,000 lamports per signature. With two signers (shopper + nonceAuthority; the relayer counts only as fee payer with its own signature, so call it three) and a small priority fee on busy slots, an SPL transfer costs ~0.00006 SOL ≈ $0.012 to relay at recent prices. That's roughly 1/100th of an EVM gasless transfer.
Even so, you need rate limits. Our defaults:
- One nonce account per merchant, per concurrent active intent. We allocate up to 256 per merchant and recycle.
- Per-IP rate limit on
POST /payment_intents/:id/submitof 10 per minute. A signed transaction is replay-resistant by construction, but a flood of invalid signatures still costs CPU to verify. - A hard cap of $0.05 in fee per intent. Below $1 ticket value we bundle the fee into the merchant invoice; above, we eat it.
Where this still doesn't help#
Solana fee-payer + durable nonce is the right answer for one-off USDC checkouts where the shopper has USDC but no SOL. It is not the right answer for:
- Subscriptions. A single signed transaction can't auto-renew.
For recurring billing on Solana you want a
delegateon the shopper's ATA, or a session-key program that the shopper authorizes once. We use the latter for our subscription product. - High-throughput batched payouts. If you're sweeping thousands of payouts per minute, you want a fee payer signing many tx in parallel, not a per-intent durable nonce. Different problem, different pattern.
- Token-2022 confidential transfers. The amount in a confidential transfer is encrypted, which means a server-side amount assertion like the one above isn't possible without holding the auditor key. That's a separate post.
Key takeaways#
- Solana has no
transferWithAuthorization, but combining a fee payer, a partially signed transaction, and a durable nonce gives you the same end-user experience as EIP-3009 on EVM chains. - The durable nonce is what makes the flow robust. A vanilla recent blockhash expires in ~60s and will kill ~17% of real-world checkouts during peak congestion.
- Always re-verify the shopper's signature and the decoded instruction args server-side before broadcasting. The cluster won't catch a swapped destination ATA — your relayer must.
- Run preflight on the first send, then own retries with
maxRetries: 0. RPC default retry behavior is non-portable. - Per-merchant nonce-account pools (we use 256) prevent the single-use nature of nonces from becoming a concurrency bottleneck.
- This pattern is for one-off checkouts. Subscriptions and Token-2022 confidential transfers each need their own design.