Skip to content

Repository files navigation

Engineering Patterns

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.

What's here

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

Verification: never trust the bytes the client sent

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.

The wallet does not sign what the client built

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.

Custody instructions, not just transfers

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.

Asset custody: bounding what you cannot enumerate

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:

  1. 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.
  2. System transfers out of the user's wallet are checked against an exact destination set, since that is the only way value leaves.
  3. 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.

Putting it together: verify, send, confirm

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.

Confirmation: two failures, opposite responses

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 retry

The 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.

Sending: retries that cannot duplicate

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.

URL guard: four checks, because each is bypassable alone

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.

  1. Protocolhttp/https only.
  2. Host allowlist — coarse and reliable, when the use case allows one.
  3. 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.
  4. No redirect following — an allowed host can 302 to 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.

Environment: fail at boot, not at 2am

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_SECRET

serverEnv 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.

Running it

npm install
npm run typecheck   # tsc --noEmit, strict + noUncheckedIndexedAccess
npm test            # 74 tests
npm run build

Tests 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.

License

MIT

About

Reference implementations of the patterns I rely on in production: Solana transaction sending and confirmation, SSRF-safe URL handling, and environment/secret loading.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages