Reference implementations of patterns I rely on across production work — Solana transaction sending and confirmation, SSRF-safe URL handling, and environment/secret loading.
Most of what I ship day to day sits in private client repositories. This exists so the approach is inspectable without those being public. Every module here is written for this repository rather than lifted from a client codebase, and each one is annotated with why it is built the way it is — the reasoning is the point, not the line count.
Typed strictly, tested, and buildable: npm test runs 74 tests.
| Module | Concern |
|---|---|
src/solana/verify.ts |
Checking a client-supplied transaction before signing it |
src/solana/confirm.ts |
Distinguishing the two ways a transaction fails |
src/solana/tx.ts |
Sending with retries that cannot double-spend |
src/http/url-guard.ts |
Validating a user-supplied URL before the server fetches it |
src/config/env.ts |
Failing at boot instead of at 2am |
When a backend co-signs a transaction the client assembled, the client controls those bytes. A wallet signature proves the user approved something — it says nothing about what. So the amounts can be lowered, the destinations repointed, extra instructions appended that the UI never displayed, and required co-signers dropped.
The only defence is to decode the transaction the server actually received and compare it against values the server derived itself. Never against numbers sent alongside it in the request body, which the same client controls.
const tx = deserializeTransaction(req.body.transaction)
const result = verifyTransaction({
tx,
lookupTables: [lut],
expectedSol: [
// Price read from the database, not from the request.
{ source: buyer, destination: treasury, lamports: priceLamports },
{ source: buyer, destination: feeWallet, lamports: feeLamports },
],
requiredSigners: [buyer, coSigner.publicKey.toBase58()],
})
if (!result.ok) return res.status(400).json({ error: result.reason })Three details carry most of the weight:
Amounts compare as integers. Transfers are checked in base units — lamports, or a
token's smallest unit — never converted to a decimal first. Dividing by 10 ** decimals
produces a float, and comparing floats forces a tolerance. A tolerance is a hole sized to
whatever it admits: at 1% on a 100 SOL transfer, a full SOL walks through. Comparing the raw
u64 as a bigint is exact and needs no allowance. It also survives amounts past
Number.MAX_SAFE_INTEGER, which a number silently rounds.
Unexpected instructions fail the check. Confirming that the expected transfers are present is not sufficient. A transaction can contain everything the server asked for and a second transfer draining the remaining balance. Every instruction must be either an expected transfer or a program on a short no-value allowlist — compute budget and memo, which wallets add on their own.
Extra signers are rejected, not just missing ones. An unexpected signer means the transaction can move an account the server never intended to involve.
Lookup tables must be passed in for a v0 message. A message stores most accounts as indexes into a table, so decompiling without it yields the wrong keys — and wrong keys are the dangerous failure, because verification then compares against addresses the transaction does not actually touch. When decompilation fails, the transaction is rejected rather than passed through unchecked.
Between a client assembling a transaction and the user approving it, the wallet adds instructions of its own: a compute-budget bump, an associated-token-account creation for a destination that does not have one, and increasingly a Lighthouse assertion guarding the balances it just showed the user.
A verifier that treats every unrecognised instruction as an attack rejects those — which means rejecting ordinary transactions from Phantom and Solflare. The fix is not to loosen the check but to name the exceptions and be able to justify each:
export const WALLET_INJECTED_PROGRAMS = [
COMPUTE_BUDGET_PROGRAM,
ASSOCIATED_TOKEN_PROGRAM,
LIGHTHOUSE_PROGRAM,
MEMO_PROGRAM,
MEMO_PROGRAM_V1,
]None of them can move an asset or an existing balance. Two are not quite free, and it is worth being precise rather than calling them harmless: compute budget sets a priority fee the payer pays, and creating an associated token account costs the payer rent. Neither can be aimed at an attacker's balance, which is the property that matters — and the SOL leaving the payer is still checked separately regardless.
Lighthouse is the clearest case. Its instructions can only assert a condition and abort; they cannot move anything. It exists to make the surrounding transaction safer, so rejecting it punishes wallets for protecting users.
Widening this list widens what may appear, never what may move value. There are tests for both halves of that: a Lighthouse assertion alongside a valid payment passes, and the same assertion alongside a redirected payment still fails.
Freeze, thaw, approve, revoke, burn and mintTo move no amount but decide who controls the asset. A standard NFT is locked by delegating it and freezing the token account; unlocked by thawing and revoking. Approving the wrong delegate gives the asset away as surely as transferring it would, so these are decoded and matched rather than waved through:
verifyTransaction({
tx,
expectedTokenOps: [
{ kind: 'approve', account: tokenAccount, delegate: escrow },
{ kind: 'freeze', account: tokenAccount, authority: escrow },
],
})Fields left undefined are not compared, so a caller can pin the delegate without having to predict the amount. Any decodable operation not on the list fails the count check — a burn cannot ride along with a lock.
verifyTransaction compares against an exact instruction list. That works when the server
knows every instruction it expects, which is true of payments and false of NFT custody,
because locking means something different for every asset class:
| Asset class | How it locks |
|---|---|
| Standard NFT | approve a delegate, then freeze the token account |
| pNFT | lockV1 / unlockV1; already frozen at the token level by design |
| MPL Core | add or update a freeze plugin |
| Compressed | cannot be frozen at all — transfer the leaf to escrow and back |
Enumerating those layouts is a losing race: they differ per program, change between
versions, and wallets inject instructions of their own. guardAssetTransaction inverts the
approach — rather than listing what may happen, it bounds what can go wrong:
- Every program must be one that cannot take custody of funds on its own. Token, Token-2022, ATA, Token Metadata, Core, Bubblegum, Account Compression, Noop, Auth Rules, memo, compute budget. None of them can move a user's SOL; only the System program can.
- System transfers out of the user's wallet are checked against an exact destination set, since that is the only way value leaves.
- The transaction must reference the specific asset and authority. This is the check that catches a structurally valid lock aimed at the wrong NFT, or assigned to an authority that is not the escrow.
const result = guardAssetTransaction({
tx,
payer: userWallet,
assetIdentifiers: [mint], // or the merkle tree for a compressed NFT
requiredAuthority: escrow.publicKey.toBase58(),
allowedTransfers: [{ destination: feeWallet, lamports: stakeFeeLamports }],
})Two details are easy to get wrong:
A compressed NFT has no mint account. Bubblegum instructions carry the merkle tree instead, so the tree is the only identifier available to bind against.
MPL Core encodes the freeze authority inside the instruction data, as a serialized plugin argument rather than a separate account. An account-key-only check rejects a legitimate first-time Core lock, so the authority is looked for in both places.
The lamport allowance exists because rent-exempt minimums shift slightly between accounts and a lock may create one. It applies only to the total paid to an already-approved destination — never to whether a destination is approved.
The three Solana modules form one path. A server that co-signs a client transaction runs all three in order:
// 1. Decode what actually arrived and check it against server-derived values.
const tx = deserializeTransaction(req.body.transaction)
const check = verifyTransaction({
tx,
lookupTables: [lut],
expectedSol: [{ source: buyer, destination: treasury, lamports: priceLamports }],
requiredSigners: [buyer, coSigner.publicKey.toBase58()],
})
if (!check.ok) return res.status(400).json({ error: check.reason })
// 2. Only now is it safe to add the server's signature and broadcast.
// sendTransaction retries internally, fetching a fresh blockhash each attempt.
const result = await sendTransactionWithSigner(coSigner, connection, instructions)
// 3. The result already carries confirmSignature's verdict, so the branch that matters
// is whether the transaction landed -- not whether the call threw.
if (result.ok) {
await recordFulfilment(result.signature)
} else if (result.reason === 'reverted') {
// Landed and failed on-chain. The user was charged nothing, but the attempt is spent:
// do not resend, and do not credit anything.
await recordFailure(result.signature, result.error)
} else {
// 'exhausted' -- never landed after every attempt expired. Nothing was processed, so
// this is the only branch where re-driving the whole flow is safe.
await queueForRetry()
}The ordering is the point. Verification happens before the server's signature exists, so a rejected transaction is one the server never endorsed. Confirmation happens after, and its three-way outcome decides whether retrying is safe — which is exactly the distinction the next section is about.
The hard part of confirming a Solana transaction is not detecting success. It is telling apart the two ways it fails, because they demand opposite reactions:
- The blockhash expired. The transaction never landed. Retrying is correct.
- It executed and reverted. It landed and failed. Retrying resubmits work that already ran — for a transfer, that can send funds twice.
connection.confirmTransaction collapses both into one rejection. Callers built on it either
retry failures they must not, or refuse to retry expiries they should. Polling the signature
status keeps the distinction, and the return type makes it impossible to ignore:
type ConfirmOutcome =
| { status: 'confirmed'; signature: string }
| { status: 'failed'; signature: string; error: string } // do not retry
| { status: 'expired'; signature: string } // safe to retryThe ordering inside the loop is load-bearing. err is checked before the confirmation
status, because a reverted transaction still reports confirmationStatus: 'confirmed' — it
confirmed that it failed. Read the status first and every revert is a success. That bug
survives casual testing because the happy path still works, so there is a test for exactly
this case.
The expiry check comes after the status check, so a transaction landing in the final slots before expiry is reported as confirmed rather than sent back for a retry it does not need.
Two rules:
A fresh blockhash every attempt. If an attempt failed because its blockhash expired, retrying with the same one fails identically. A retry loop that hoists the blockhash above the loop looks correct and can never recover — so the whole build-sign-send step is the retried unit, not just the send.
Only retry when nothing landed. confirmSignature already separates those cases, so the
loop returns immediately on a revert and continues only on an expiry.
const result = await sendTransaction(wallet, connection, instructions)
if (!result.ok && result.reason === 'reverted') {
// Landed and failed. Surface it — do not resend.
}sendTransactionWithSigner is a separate export rather than a flag, so a client bundle that
never imports it never pulls in a path that expects a secret key in memory.
Any endpoint that accepts a URL and fetches it is an SSRF vector. The request comes from the
server, so it is judged by the server's network position: cloud metadata at
169.254.169.254, services on localhost, anything inside a private network. Return the body
to the caller and it becomes a read primitive.
- Protocol —
http/httpsonly. - Host allowlist — coarse and reliable, when the use case allows one.
- Resolved address — the allowlist is not enough. A permitted hostname can resolve into private space through misconfiguration, or because someone controls DNS for a subdomain.
- No redirect following — an allowed host can
302to a blocked one and defeat 2 and 3.
169.254.0.0/16 is the range people forget. It carries the instance metadata service, which
on some providers hands credentials to anything that asks.
On testing this properly. The private-address tests deliberately put loopback and RFC1918 addresses on the allowlist. Without that the allowlist rejects them first, the DNS layer never executes, and the suite passes identically with that layer deleted. An SSRF guard that is untested while appearing tested is worse than one that is obviously absent.
Reading process.env.X at the point of use means a misconfigured service starts cleanly and
fails on the first request that needs the value. Validating at startup converts that into a
failed deploy, which is the cheapest place to find out.
requireEnv reports every missing variable at once, so a bad deploy is fixed in one pass
instead of one variable per attempt:
const config = requireEnv(['DATABASE_URL', 'REDIS_URL', 'JWT_SECRET'])
// ConfigError: Missing required environment variables: REDIS_URL, JWT_SECRETserverEnv throws if called in a browser context. That is not a security boundary — anything
in the user's own process can be worked around. It is a tripwire, so an accidental client-side
import fails loudly in development rather than quietly inlining a secret at build time.
npm install
npm run typecheck # tsc --noEmit, strict + noUncheckedIndexedAccess
npm test # 74 tests
npm run buildTests use Node's built-in runner with no framework. RPC and DNS are injected as parameters rather than mocked globally, so the suite is deterministic and never touches the network.
MIT