Skip to content

A shared spend store

The default MemorySpendStore is per-process: ten agents have ten budgets, and a restart forgets all of them. A shared store replaces it with one authoritative ledger that every process reserves against, so a fleet shares one per-hour rate, one cumulative ceiling, one freeze switch, and one set of recipient pins. This runbook stands one up on Redis.

Everything here is off the size-gated core path (importing tx402 still loads no store), and the in-memory default is unchanged — a single process that never sets spendStore behaves exactly as it did in v0.1.

0.2.0 ships three reference stores. This page is Redis; the other two have their own runbooks.

Store Import Peer / extra Reach it from
Redis tx402/redis · tx402.stores.redis npm ioredis@^5.4 or redis@^4.7 (both optional) · PyPI tx402[redis] (redis>=5) Any process, any language, directly.
Durable Object tx402/durable-object none (Workers runtime types) · no Python adapter A Cloudflare Worker, or Python/CLI through a gateway. See DO topology.
Gateway tx402/gateway · tx402.stores.gateway none (global fetch) · none (httpx is a core dep) Any process, any language, over HTTPS. See gateway deploy.

The gateway is the recommended production boundary — it holds the raw store credential and hands clients only a scoped token — and it is the only way for a Python or CLI process to reach a Durable Object. Redis-direct is the trusted-single-tenant option below.

TypeScript — one of the two clients
pnpm add ioredis # or: pnpm add redis (node-redis v4)
Python
pip install "tx402[redis]" # adds redis-py 5+, which ships both the sync and async clients

You build the Redis client and hand it to the store — the store never dials a URL itself, so whatever connection options, TLS, and pooling you already use are respected.

TypeScript (ioredis)
import { Redis } from "ioredis";
import { RedisSpendStore } from "tx402/redis";
import { createTx402Client } from "tx402";
const store = new RedisSpendStore({
client: new Redis(process.env.TX402_SPEND_STORE!), // rediss://… (a data-user DSN)
namespace: "tx402", // isolation prefix; must match across the fleet
});
const tx402 = createTx402Client({ signers: { evm }, spendStore: store });

node-redis works identically — construct a createClient(...), await client.connect(), then new RedisSpendStore({ client }). The store auto-detects which client it was given.

Python (sync)
import os, redis
from tx402 import Tx402Client
from tx402.stores.redis import RedisSpendStore
store = RedisSpendStore(redis.Redis.from_url(os.environ["TX402_SPEND_STORE"]), namespace="tx402")
client = Tx402Client(evm_signer=evm, spend_store=store)

The async client mirrors it: AsyncRedisSpendStore(redis.asyncio.Redis.from_url(...)), passed to AsyncTx402Client(spend_store=...). Both arms come from the one tx402[redis] extra.

The data/admin boundary is what stops a drifted worker from unfreezing itself or raising its own cap. It needs two credentials, and the agent processes get only the first:

  • Data plane — reserve, commit, release, expose, and read. Constructed with the default admin: false (RedisSpendStore({ client })). This is what the fleet holds.
  • Admin plane — freeze, set pins, set limits, resolve exposed. Constructed with admin: true. An operator holds this; an agent must not.
// operator process only
const admin = new RedisSpendStore({ client: new Redis(ADMIN_DSN), admin: true });
await admin.freeze("api.merchant.example", Date.now());
// Provision the administered caps a drifted worker cannot exceed (the drifted-worker defense —
// see the policy guide's "Administered caps"). The limits are atomic-unit strings; an omitted
// field clears that cap. Raw Redis windows on backend time, so the trailing nowEpochMs is ignored
// there, but it is part of the SpendStoreAdmin.setBudgetLimits signature (every store takes it).
await admin.setBudgetLimits(
"api.merchant.example",
"eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
{ maxPerHourAtomic: "5000000", maxTotalAtomic: "100000000" },
Date.now(),
);

The Python admin store is the same call — set_budget_limits with a BudgetLimits:

# operator process only (tx402.stores.redis.RedisSpendStore, admin=True)
from tx402.ledger import BudgetLimits
admin.set_budget_limits(
"api.merchant.example",
"eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
BudgetLimits(max_per_hour_atomic="5000000", max_total_atomic="100000000"),
now_epoch_ms,
)

On raw Redis those two are two ACL users. Because the reserve logic ships as an EVAL script (not a Redis Function — see Upstash below), a data user needs +eval, so the boundary is enforced by a key-pattern ACL: the data user gets read/write on the reservation, committed, counter, and pins keys, and read-only on the freeze, limits, tofu-enabled, recipient-required, and global-frozen keys — and Redis ACL-checks every inner redis.call, so a hand-crafted EVAL that tries to write a frozen key is denied. One honest gap: the in-reserve TOFU claim must write the pins key, so a data user can write pins. If that gap matters, put the gateway in front — it is the durable boundary, and it closes it. As the security model says, neither an ACL nor the gateway closes the compromised-application spending path; that is 0.3.0.

A store is only as durable as its persistence. Enable the append-only file:

Terminal window
redis-server --appendonly yes --appendfsync everysec # or stricter

Without it a restart loses reservations and counters, which under a cumulative cap means a possibly-settled payment escapes the ceiling. The adapter will tell you: call store.warnIfPersistenceDisabled() (warn_if_persistence_disabled() in Python) at startup — it returns the warning string when AOF is off and null when it is on. The store never prints it itself (it holds no logger); log it yourself.

Upstash is a supported production backend — point the store at its native TLS endpoint:

Terminal window
export TX402_SPEND_STORE="rediss://default:<token>@<name>.upstash.io:6379"

That endpoint speaks the real Redis protocol and works with ioredis, node-redis, and redis-py unchanged. Two constraints, both already honoured by the adapter and worth knowing:

  • The reserve atom is EVAL/EVALSHA Lua, never FUNCTION LOAD. Managed Redis-Functions support has historically been spotty on serverless Redis, so the adapter depends only on scripting that every 7.0+ server has.
  • The Upstash REST API (@upstash/redis) is not supported. It is HTTP-only with no persistent connection and limited scripting, so it cannot back the server-side-atom design. Use the native TLS DSN above.

A managed serverless instance also cannot do ACL SETUSER, server restart, or controlled Cluster hash tags, so the data/admin ACL split and the durability harness are tested against a self-hosted Redis; on Upstash, the gateway is how you get the admin boundary.

  • Redis 7.0 is the floor. The adapter uses only EVAL scripting and TIME-in-atom, and avoids 7.4-only features (no HEXPIRE). 7.0 through current all work.
  • On Cluster, per-scope everything is atomic; global freeze is not. Every key a reservation touches for a scope shares the {ns:scope} hash tag, so the whole reserve is single-slot even in Cluster. The one exception is the store-wide "*" freeze: its key hashes to a different slot, so a Cluster store declares atomicGlobalFreeze: false and freeze("*") fails closed rather than pretending. Per-scope freeze("api.merchant.example") is atomic on every topology. See the kill-switch runbook.