Skip to content

State and Storage

The Fiat SDK separates two concerns: durable business state (orders, widgets, webhook dedup, jobs) and an optional best-effort cache (droppable provider metadata). This page is a deep dive on the FiatStateStore contract, the Cloudflare D1 adapter, how to bring your own store, the FiatCache, and the sealed-vs-store choice for quote state.

What needs durable state

FiatStateStore is the source of truth — not a hook or a cache. Everything that must survive a process restart lives here:

  • Orders — the full FiatOrderRecord plus its optimistic-concurrency version.
  • Widget state — enough to render GET /widget/{orderId} securely (provider origins, action, bridge token).
  • Webhook-receipt dedup — an idempotency ledger so a provider's retries are applied exactly once.
  • Background jobs — lease-protected work items that fiat.cron() drives.

A store is required at runtime for every stateful route and for the cron driver. quote() is the only method that runs without one:

SurfaceNeeds state?
POST /quoteNo — stateless
POST /orderYes
GET /order/{orderId}Yes
POST /webhook/{provider}Yes
GET /widget/{orderId}Yes
fiat.cron()Yes — plus the job methods

The runtime reports this back: GET /config returns features.order and features.cron as Boolean(state). With no store wired, only quoting is available.

The methods

These are the exact members of FiatStateStore. The first six are required for stateful HTTP; the last three are optional and only needed if you run fiat.cron().

MethodRequired?Purpose
createOrderFromQuote(input)RequiredThe only POST /order state-creation entry point. Atomic + idempotent.
getOrder(orderId)RequiredLoad an order, or null.
updateOrder({ orderId, expectedVersion, next })RequiredOptimistic CAS write; throws FiatStateConflictError on a version mismatch.
findOrderByProviderReference({ provider, type, value })RequiredReverse lookup for webhooks + reconciliation.
recordWebhookReceipt({ dedupeKey, provider, receivedAt, rawBodyHash })RequiredIdempotency ledger; returns { inserted: false } for a duplicate.
getOrderWidget(orderId)RequiredLoad persisted widget state for GET /widget/{orderId}.
enqueueJobs?({ orderId, jobs })Job-onlySchedule follow-on jobs. Required for fiat.cron().
claimDueJobs?({ kinds?, limit, now, leaseSeconds, owner })Job-onlyLease a batch of due jobs. Required for fiat.cron().
finishJob?({ jobId, outcome })Job-onlyResolve a leased job. Required for fiat.cron().

Webhook-only deployments

If you never run cron — for example you rely entirely on provider webhooks — you can omit the three job methods. fiat.cron() throws CONFIGURATION_ERROR if called without claimDueJobs/finishJob, but the stateful HTTP routes work fine.

Choosing a store

Three options, depending on where you host:

HostStoreDurable?
Cloudflare WorkerscreateD1FiatState from @tokenflight/fiat-storage-d1Yes
Dev / test / conformancecreateMemoryStateStore()No — per-process
Anywhere else (Postgres, Upstash, your DAO)Bring your own FiatStateStoreYes (your responsibility)

The D1 adapter is the reference production store on Workers. Wire it through the state field of createFiat:

ts
import { createFiat } from "@tokenflight/fiat";
import { createD1FiatState } from "@tokenflight/fiat-storage-d1";

const fiat = createFiat({
  basePath: "/v1/fiat",
  providers: buildProviders(env),
  state: createD1FiatState({ database: env.DB }),
  cors: { origins: ["https://app.example.com"] },
  quoteSecret: env.TOKENFLIGHT_FIAT_SECRET,
});

For dev, tests, and the conformance suite, drop in the in-memory store. It mirrors the load-bearing semantics of the D1 adapter (version-CAS, provider-reference uniqueness, webhook dedup, lease-based claiming) but is per-process and non-durable:

ts
import { createMemoryStateStore } from "@tokenflight/fiat";

const fiat = createFiat({
  providers: [mockProvider()],
  state: createMemoryStateStore(),
  cors: { origins: ["http://localhost:5173"] },
  quoteSecret: devSecret,
});

Never ship the memory store

createMemoryStateStore() loses all orders on restart and is not shared across instances. It exists for dev, tests, and conformance only. Use D1 or a BYO store in production.

Cloudflare D1 store

Construct the adapter with a single database binding:

ts
import { createD1FiatState } from "@tokenflight/fiat-storage-d1";

const state = createD1FiatState({ database: env.DB });

Migrations

The schema ships with the package at node_modules/@tokenflight/fiat-storage-d1/migrations/0001_initial.sql. Apply it to your D1 database before serving traffic. The direct command executes the file against your database and always works:

bash
# local D1 (for wrangler dev)
wrangler d1 execute fiat --local \
  --file node_modules/@tokenflight/fiat-storage-d1/migrations/0001_initial.sql

# remote D1 (before deploy)
wrangler d1 execute fiat --remote \
  --file node_modules/@tokenflight/fiat-storage-d1/migrations/0001_initial.sql

The migration is idempotent (CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS), so it is safe to re-run.

wrangler d1 migrations apply can't see the package folder

wrangler d1 migrations apply only reads the directory named by migrations_dir — it cannot reach the package's nested node_modules/@tokenflight/fiat-storage-d1/migrations/ folder, so pointing it there silently applies nothing. If you prefer migrations apply, first copy the package's .sql files into your Worker's migrations_dir (cp node_modules/@tokenflight/fiat-storage-d1/migrations/*.sql migrations/), then run wrangler d1 migrations apply fiat. Always apply migrations before the code that depends on them ships. See Deployment.

Load-bearing semantics

The adapter is small but the correctness rules it enforces are the whole point. Each maps to a SQL constraint in 0001_initial.sql.

Atomic order creation. createOrderFromQuote first checks for an existing order by reservedOrderId; if found (with its widget) it returns { existing: true }. Otherwise it assembles every insert — the fiat_order row, each fiat_order_provider_ref, the fiat_order_widget row, and the initial fiat_job rows — and runs them in one db.batch() so the whole order commits or none of it does. The quote_nonce column carries a UNIQUE constraint, so a racing duplicate fails the batch; the adapter catches that, reloads the winner, and returns it with existing: true.

sql
CREATE TABLE IF NOT EXISTS fiat_order (
  order_id     TEXT PRIMARY KEY,
  version      INTEGER NOT NULL,
  quote_nonce  TEXT NOT NULL UNIQUE,
  status       TEXT NOT NULL,
  -- ...
);

Version-CAS updateOrder. Updates are an optimistic compare-and-set on version:

sql
UPDATE fiat_order SET /* columns */, version = ?
WHERE order_id = ? AND version = ?;

If result.meta.changes === 0 the stored version no longer matches expectedVersion, and the adapter throws FiatStateConflictError. Callers (webhook, cron) treat that conflict as "someone else advanced this order" and re-read.

Webhook-receipt dedup. recordWebhookReceipt is an INSERT OR IGNORE keyed on dedupe_key (the PRIMARY KEY of fiat_webhook_receipt). inserted is meta.changes > 0, so a duplicate dedupeKey returns { inserted: false } and the webhook handler short-circuits.

sql
CREATE TABLE IF NOT EXISTS fiat_webhook_receipt (
  dedupe_key    TEXT PRIMARY KEY,
  provider      TEXT NOT NULL,
  received_at   TEXT NOT NULL,
  raw_body_hash TEXT NOT NULL
);

At most one live job per (order_id, kind). Job inserts use INSERT OR IGNORE, and a partial unique index guarantees only one pending/leased job per order and kind exists at a time:

sql
CREATE UNIQUE INDEX IF NOT EXISTS uq_job_live
  ON fiat_job (order_id, kind) WHERE status IN ('pending', 'leased');

Lease-based claiming with steal-on-expiry. claimDueJobs leases due rows in one UPDATE ... WHERE job_id IN (SELECT ...), claiming rows that are pending or leased with an expired lease (lease_until IS NULL OR lease_until < now). That is the steal: a crashed worker's lease lapses and another claim picks the job up. It then selects back the rows it just stamped with its lease_owner and lease_until.

sql
UPDATE fiat_job SET status='leased', lease_until=?, lease_owner=?, updated_at=?
WHERE job_id IN (
  SELECT job_id FROM fiat_job
  WHERE status IN ('pending','leased') AND run_at <= ? AND (lease_until IS NULL OR lease_until < ?)
  ORDER BY run_at ASC LIMIT ?
) AND (lease_until IS NULL OR lease_until < ?);

finishJob resolves a leased job: done marks it done; retry flips it back to pending, bumps attempt, and sets the new run_at; failed marks it failed. All three clear lease_until and lease_owner.

A typed provider-reference table enforces global uniqueness (phantom-mapping protection) so one provider reference can never bind to two orders:

sql
CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_ref
  ON fiat_order_provider_ref (provider, ref_type, value);

Implementing a custom FiatStateStore (BYO)

To run on Postgres, Upstash, or your own DAO, implement the FiatStateStore interface. The interface is small; the semantics are the contract. Use this as a checklist — a Postgres implementer can map each rule to a constraint or transaction.

Required methods

  • [ ] createOrderFromQuote — atomic and idempotent. Look up by reservedOrderId first; if it exists, return it with existing: true. Otherwise call input.build() (all external side effects already happened) and persist the order, widget, provider references, and initial jobs in one transaction. Enforce uniqueness on both quoteNonce and reservedOrderId. On a uniqueness conflict (a lost race), reload the winning order and return existing: true rather than erroring.
  • [ ] updateOrder — version-CAS. Write next only if the stored version still equals expectedVersion, then store expectedVersion + 1. If nothing matched, throw FiatStateConflictError. Never blind-write.
  • [ ] findOrderByProviderReference — typed reverse lookup. Resolve (provider, type, value) to an order. Keep provider references globally unique so a reference maps to at most one order.
  • [ ] recordWebhookReceipt — idempotent insert. Insert on dedupeKey; return { inserted: false } if it already existed. This is the only thing standing between a provider's retries and double-applying an event.
  • [ ] getOrder / getOrderWidget — straight reads returning null when absent.

Job methods (only if you run cron)

  • [ ] enqueueJobs — one live job per (orderId, kind). Skip inserting a job whose (orderId, kind) already has a pending or leased row.
  • [ ] claimDueJobs — lease with steal-on-expiry. Atomically lease up to limit jobs where runAt <= now and the lease is empty or expired, stamping leaseOwner and a leaseUntil = now + leaseSeconds * 1000. Expired leases are fair game so a dead worker never wedges a job. Optionally filter by kinds.
  • [ ] finishJob — resolve a lease. Handle all three FiatJobOutcome variants (done, retry with nextRunAt, failed) and clear the lease fields.

Deferred webhook dedup ordering

Do not record a dedup receipt for a webhook whose order you can't resolve yet. A webhook that races the create-order commit would otherwise be remembered as "seen", and the provider's retry — arriving after the order exists — would be dropped as a duplicate and never applied. The handler only calls recordWebhookReceipt after it has a live, non-terminal order to apply the event to. Preserve that ordering in any custom store integration.

The bundled createMemoryStateStore() is a complete, readable reference implementation of every rule above and is the fixture the conformance suite runs against. Read it before writing your own. See Configuration for the broader host-integration story.

FiatCache (optional)

FiatCache is a separate, best-effort interface for droppable data only. It is never the source of truth.

ts
export interface FiatCache {
  get(key: string): Promise<string | null>;
  put(key: string, value: string, options?: { ttlSeconds?: number }): Promise<void>;
  delete(key: string): Promise<void>;
}

Every internal call site wraps cache access so a failure can never break a request — reads .catch(() => null) and writes .catch(() => {}). The SDK uses it for four pieces of droppable data:

ConsumerCache keyTTL
Hyperstream token metadata (symbol/decimals/logo)tokenmeta:<chainId>:<address>1 hour
Transak access tokentransak:token:<apiKey>6 days
Transak crypto-currency listtransak:crypto-currencies1 hour
Transak fiat-currency listtransak:fiat-currencies1 hour

A minimal Cloudflare KV wrapper:

ts
import type { FiatCache } from "@tokenflight/fiat";

function kvCache(kv: KVNamespace): FiatCache {
  return {
    get: (key) => kv.get(key),
    async put(key, value, options) {
      await kv.put(key, value, options?.ttlSeconds ? { expirationTtl: Math.max(60, Math.floor(options.ttlSeconds)) } : {});
    },
    delete: (key) => kv.delete(key),
  };
}

Wire it conditionally — the cache is always optional:

ts
createFiat({
  // ...
  ...(env.API_CACHE ? { cache: kvCache(env.API_CACHE) } : {}),
});

Cache is droppable only

Never store correctness-critical data in FiatCache. Orders, dedup receipts, and jobs belong in FiatStateStore. A wiped cache must only ever cost a refetch, never an inconsistent order.

Quote state: sealed vs store

A quote has to be recoverable at POST /order. There are two strategies, configured via quoteState on createFiat (default: sealed using quoteSecret).

ts
export type QuoteStateConfig =
  | { strategy: "sealed"; secret: AeadSecret; kid?: string; maxTokenLength?: number }
  | { strategy: "store"; store: FiatQuoteStateStore };

Sealed (default)

The quote lives inside the quoteId itself — an opaque, AEAD-encrypted, self-contained token. No server-side storage, no extra round trip. The token format is fq1.<kid>.<nonce>.<ciphertext>; the plaintext is the minimal JSON needed to recreate and re-validate the order. POST /order re-validates everything after unsealing, so the token is an integrity mechanism, not an authorization boundary.

The default soft target is 512 characters and the hard cap is 2048 (maxTokenLength overrides it). If a sealed token would exceed the limit, issue() throws QUOTE_TOO_LARGE — that is your signal to switch to the store strategy. Unsealing surfaces QUOTE_INVALID (malformed/failed authentication) and QUOTE_REVOKED (unknown or rotated key id).

Store

The store strategy persists the sealed payload in a BYO FiatQuoteStateStore keyed by a short, random quoteId (fq_…), keeping only the id on the wire:

ts
export interface FiatQuoteStateStore {
  put(input: { quoteId: string; payload: SealedQuotePayload; expiresAt: number }): Promise<void>;
  get(quoteId: string): Promise<SealedQuotePayload | null>;
  delete?(quoteId: string): Promise<void>;
}

Choose store when:

  • Quote payloads exceed the sealed-token size limit (you would otherwise hit QUOTE_TOO_LARGE), or
  • You need server-side revocationconsume() can delete the quoteId so it can't be reused.

Once-only is durable order state

Neither strategy is the once-only mechanism. A quote being consumed at most once is guaranteed by durable order state (quoteNonce/reservedOrderId uniqueness in createOrderFromQuote), not by the quote-state store. The store's delete is best-effort cleanup.

For the sealed token's wire format, key id (kid), and rotation guidance, see Security.

See also

  • Configuration — wiring a store, cache, and providers into your own host.
  • Deployment — provisioning D1/KV and applying migrations before code ships.
  • Security — sealed-token format, key rotation, and trust boundaries.
  • API reference — request/response schemas for every route.