Appearance
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
FiatOrderRecordplus its optimistic-concurrencyversion. - 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:
| Surface | Needs state? |
|---|---|
POST /quote | No — stateless |
POST /order | Yes |
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().
| Method | Required? | Purpose |
|---|---|---|
createOrderFromQuote(input) | Required | The only POST /order state-creation entry point. Atomic + idempotent. |
getOrder(orderId) | Required | Load an order, or null. |
updateOrder({ orderId, expectedVersion, next }) | Required | Optimistic CAS write; throws FiatStateConflictError on a version mismatch. |
findOrderByProviderReference({ provider, type, value }) | Required | Reverse lookup for webhooks + reconciliation. |
recordWebhookReceipt({ dedupeKey, provider, receivedAt, rawBodyHash }) | Required | Idempotency ledger; returns { inserted: false } for a duplicate. |
getOrderWidget(orderId) | Required | Load persisted widget state for GET /widget/{orderId}. |
enqueueJobs?({ orderId, jobs }) | Job-only | Schedule follow-on jobs. Required for fiat.cron(). |
claimDueJobs?({ kinds?, limit, now, leaseSeconds, owner }) | Job-only | Lease a batch of due jobs. Required for fiat.cron(). |
finishJob?({ jobId, outcome }) | Job-only | Resolve 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:
| Host | Store | Durable? |
|---|---|---|
| Cloudflare Workers | createD1FiatState from @tokenflight/fiat-storage-d1 | Yes |
| Dev / test / conformance | createMemoryStateStore() | No — per-process |
| Anywhere else (Postgres, Upstash, your DAO) | Bring your own FiatStateStore | Yes (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.sqlThe 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 byreservedOrderIdfirst; if it exists, return it withexisting: true. Otherwise callinput.build()(all external side effects already happened) and persist the order, widget, provider references, and initial jobs in one transaction. Enforce uniqueness on bothquoteNonceandreservedOrderId. On a uniqueness conflict (a lost race), reload the winning order and returnexisting: truerather than erroring. - [ ]
updateOrder— version-CAS. Writenextonly if the storedversionstill equalsexpectedVersion, then storeexpectedVersion + 1. If nothing matched, throwFiatStateConflictError. 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 ondedupeKey; 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 returningnullwhen absent.
Job methods (only if you run cron)
- [ ]
enqueueJobs— one live job per(orderId, kind). Skip inserting a job whose(orderId, kind)already has apendingorleasedrow. - [ ]
claimDueJobs— lease with steal-on-expiry. Atomically lease up tolimitjobs whererunAt <= nowand the lease is empty or expired, stampingleaseOwnerand aleaseUntil = now + leaseSeconds * 1000. Expired leases are fair game so a dead worker never wedges a job. Optionally filter bykinds. - [ ]
finishJob— resolve a lease. Handle all threeFiatJobOutcomevariants (done,retrywithnextRunAt,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:
| Consumer | Cache key | TTL |
|---|---|---|
| Hyperstream token metadata (symbol/decimals/logo) | tokenmeta:<chainId>:<address> | 1 hour |
| Transak access token | transak:token:<apiKey> | 6 days |
| Transak crypto-currency list | transak:crypto-currencies | 1 hour |
| Transak fiat-currency list | transak:fiat-currencies | 1 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 revocation —
consume()candeletethequoteIdso 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.