Skip to content

The Durable Object store and its topology

If you already run on Cloudflare Workers, tx402/durable-object gives you a shared store with no separate service to operate: a SQLite-backed Durable Object that holds the ledger, does the whole reservation as one synchronous transaction, and survives restart and eviction. It is TypeScript / Workers only — a Python or CLI process reaches a DO through a gateway, never directly.

src/index.ts
import { createTx402Client } from "tx402";
import { durableObjectSpendStore } from "tx402/durable-object";
export { Tx402SpendStoreDO } from "tx402/durable-object";
export default {
async fetch(request: Request, env: Env) {
const store = durableObjectSpendStore({
locate: (scope) => env.SPEND_DO.get(env.SPEND_DO.idFromName(scope)),
});
const tx402 = createTx402Client({ signers: { evm }, spendStore: store });
// …use tx402.fetch(...)
},
};
wrangler.jsonc
{
"name": "tx402-store",
"main": "src/index.ts",
"compatibility_date": "2024-11-01",
// REQUIRED. `tx402/durable-object` reaches `core/ledger`, which imports `node:crypto`;
// without this flag the Worker fails to boot with `No such module "node:crypto"`.
"compatibility_flags": ["nodejs_compat"],
"durable_objects": {
"bindings": [{ "name": "SPEND_DO", "class_name": "Tx402SpendStoreDO" }]
},
// SQLite-backed is required — amounts are TEXT and the reserve atom is a synchronous SQL batch.
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Tx402SpendStoreDO"] }]
}

compatibility_flags: ["nodejs_compat"] is not optional. tx402/durable-object reaches core/ledger, which imports node:crypto, so a Worker deploying the DO fails at startup without it — see the gateway runbook for which entry point needs the flag and which does not.

There is no url, namespace, or topology option — the store is configured entirely by the locate function you pass and one capability flag. That is the whole surface.

The src/index.ts above imports createTx402Client, and wrangler dev / wrangler deploy bundle with esbuild, which resolves tx402’s chain adapters at build time. The “the core loads no chain library until you pay on that chain” property is a runtime property — a Node process only import()s the adapter for a chain it actually pays on — and it does not carry to a bundler, which must resolve every import() it can see. So even an EVM-only Worker fails to bundle without the Solana peers, with errors like Could not resolve "@x402/svm", "@solana/kit", and "@solana-program/token".

Install both chains’ peer packages and it bundles cleanly (~1.3 MiB, ~258 KiB gzipped):

Terminal window
npm install @x402/evm @x402/svm @solana-program/token @solana/kit viem

If you never pay on one chain and want to keep it out of the bundle, alias its packages to an empty stub in wrangler.jsonc instead of installing them:

{
// empty.js is one line: `export default {};`
"alias": {
"@x402/svm": "./empty.js",
"@solana/kit": "./empty.js",
"@solana-program/token": "./empty.js"
}
}

A route that then tries the aliased chain fails at runtime — which is the point: you have declared you never use it.

A store-only Worker — one that imports durableObjectSpendStore (or the gateway worker) but not createTx402Client — pulls in no chain adapter and needs none of these peers. That store-holding boundary is the recommended shape: the Worker holds the store, and the client, with its chain peers, lives in the agent process.

The two topologies are just how locate maps a scope

Section titled “The two topologies are just how locate maps a scope”

locate: (scope) => env.SPEND_DO.get(env.SPEND_DO.idFromName(scope)) gives one DO per merchant host. Per-scope reserve, commit, expose, and freeze are atomic — one object serializes its own events. Because the shard is per merchant, Cloudflare’s soft ceiling of roughly 1,000 requests per second per object is per-merchant headroom, which is almost always enough.

What id-per-scope cannot do is atomic store-wide freeze: a global "*" freeze would have to be read by every per-scope object, and they are separate storage domains. So an id-per-scope store declares atomicGlobalFreeze: false, and freeze("*") fails closed with ConfigurationError (reason: "global-freeze-unsupported") rather than pretending to freeze everything. Per-scope freeze works normally. See the kill-switch runbook.

single-coordinator (opt-in, atomic global freeze)

Section titled “single-coordinator (opt-in, atomic global freeze)”

Route every scope through one fixed object:

const COORDINATOR = env.SPEND_DO.idFromName("spend-coordinator");
durableObjectSpendStore({
locate: () => env.SPEND_DO.get(COORDINATOR),
atomicGlobalFreeze: true,
});

Now freeze("*") is atomic — every reserve goes through the same object that holds the global flag. The cost is that the whole fleet now shares that ~1,000 req/s ceiling, which Cloudflare explicitly advises against for throughput. So it is opt-in, and the deployment must validate a throughput acceptance threshold against a deployed coordinator before relying on it — a local baseline is not that acceptance. Choose this only when fleet-wide-atomic freeze is worth a single-object throughput bound.

Under overload — a reserve that cannot reach its DO — the store fails closed: a retryable TransportError (causeCategory: "durable-object-unreachable") and no signature.

The admin boundary is a deployment secret, verified inside the DO

Section titled “The admin boundary is a deployment secret, verified inside the DO”

Cloudflare exposes a DO’s methods as RPC to any Worker holding the binding, so a separate TypeScript interface is not a security boundary. Instead, each admin method takes an admin token that the DO verifies inside itself, against a Worker environment secret:

Terminal window
wrangler secret put TX402_DO_ADMIN_SECRET
  • The data-plane adapter — durableObjectSpendStore({ locate }) — carries no admin token, so a data Worker holding the binding still cannot freeze, re-pin, or set a limit.
  • The admin adapter is new DurableObjectSpendStore({ locate, adminToken }), whose token is verified against TX402_DO_ADMIN_SECRET. Deploy it in an admin Worker (or the gateway) whose environment has the secret; the data Worker’s environment does not.

Because the trust root is a deployment secret and not an RPC first-write, there is no unauthenticated first call to race, even for a lazily created per-scope object. Rotation is a redeploy with a new secret. This makes the admin-state boundary real on Durable Objects; it does not close the compromised-application spending path (0.3.0), per the security model.

A Durable Object has no address a redis://-style DSN could name, so the standalone CLI refuses a bare do:// store. Front the DO with the Worker gateway and point clients and the CLI at that HTTPS endpoint instead.