@fun-xyz/fiat-contract
v0.4.0
Published
Published conformance contract for Fun's headless fiat onramp: FlowState/Transition types, zod schemas, the transition table as data, recorded fixtures, assertion helpers.
Readme
fiat-contract
The published contract between fun-backend (emits envelopes) and funkit/connect-core
(renders them). Both repos test against it; neither owns it.
Four things, zero runtime logic beyond validation:
| File | What it is |
| --- | --- |
| src/types.ts | FlowState · Transition · FailureReason · Surface · Instructions · FormDescriptor · OrderStatus · StepResponse |
| src/schemas.ts | zod mirrors of every type — the single runtime validator |
| src/table.ts | the transition table as data: per state, its legal transition set, the states any call from it may return, and terminal: boolean |
| src/assert.ts + src/fixtures/ | assertEnvelope · assertLegalEmission · assertLegalReturn · walkTable · fixture loader + 16 recorded envelopes (inlined as data — no filesystem, so React Native can bundle it) |
Three entry points — production vs test-time
| Import | Weight | Contains | Used by |
| --- | --- | --- | --- |
| @fun-xyz/fiat-contract/types | 0.1 KB (types erase) | every type; no runtime values | production, both repos |
| @fun-xyz/fiat-contract/table | 13.8 KB, zero deps | TRANSITION_TABLE, stateKey, tableEntry, isTerminal, walkTable, TABLE_VERSION, TERMINAL_ORDER_STATUSES, DOCUMENTED_ENDPOINTS | production frontend + backend |
| @fun-xyz/fiat-contract | 47.6 KB, needs zod | the above + 46 zod schemas + assertions + 16 fixtures | tests, and backend dev/test guards |
./table is not a micro-optimisation. Terminality is table data a shipped client must read
(litmus rule 3 — clients never infer it), and Metro has no cross-module tree-shaking on by default,
so importing isTerminal from the root would ship zod and all 16 fixtures into a React Native
bundle. Two CI jobs hold that line: consumer asserts requiring ./table never loads zod into the
process, and metro bundles ./table with real Metro — in both package-exports modes, since
Metro before RN 0.79 ignores exports entirely — then greps the emitted bundle for zod, fixtures and
schemas and executes it.
Source of truth: Fiat Client Contract
(§The envelope · §Conformance package · §split InputSpec — ACCEPTED) and
Fiat Frontend — State Machine & Screen Map
(✅ Decisions · per-screen State details · Event bindings per flow state).
Tracking: Headless Fiat Onramp ·
this package is ENG-5268.
The litmus rules
- Errors are fields, never states. Every fallible state carries
error?: FailureReason. Stay-on-screen ⇒ an error field. Change-screen ⇒ a different state (session expiry just returns aSESSION_AUTHenvelope — there is no error routing table). params= server literals ·inputs= collected specs ·expects= injected surface results.body = {…params, …collected(inputs), …injected(expects)}; a key collision across the three is a contract violation, not last-write-wins. zod rejects aFieldSpechiding inparams, so the old double-duty bug is unrepresentable.- Terminality comes from the table.
terminalis enumerated per state entry — never inferred fromtransitions.length.KYC ON_HOLDcarriestransitions: []and is not terminal.ORDER{CREATED}terminality ridesstatus ∈ TERMINAL_ORDER_STATUSES, and a terminal status may still carry a recovery CTA (FAILED+ retryablefailureReason). SUBMITis a user-fired request, not a POST. The harness parses the verb off the endpoint string;GET /fiat/orders/:idunderSUBMITis legal.- Fixtures stay synthetic.
q_8f2,o_31c,eyJ…,"…"— never paste a real session token, bank field, or PII value into a fixture. Redaction applies to fixtures too.
Versioning — the package version IS the table version
TABLE_VERSION (exported from src/table.ts) must equal package.json#version; a test asserts it.
That version rides the capability handshake (supportedProviders / supportedSurfaces /
supportedStepKinds + table version), which is how BE↔FE skew stays explicit and designed-for:
the server never routes a flow into a state kind the installed SDK didn't declare.
Starting at 0.1.0. Bump the minor for additive vocabulary (a new field, a new fixture); bump the major when an existing shape changes meaning.
How each repo consumes it
fun-backend — adapters compile their provider's real flow into this state machine.
import { assertEnvelope, assertLegalEmission, walkTable } from '@fun-xyz/fiat-contract';
assertLegalEmission(state, transitions); // per emission: adapter conformance
assertEnvelope(outgoing); // outgoing-envelope validation in dev/test
const owed = walkTable((entry) => entry.allowedTransitions); // what the adapter must emitIts own suites: adapter conformance (fixture-driven + property-generated states), outgoing-envelope
validation, and the scheduled provider-sandbox drift run diffed against src/fixtures.
connect-core — the harness obeys transitions for sequencing and owns rendering.
import { FIXTURES, loadFixture, walkTable } from '@fun-xyz/fiat-contract';
walkTable((entry) => expect(computePage(entry.key)).toBeDefined()); // exhaustive resolver walk
FIXTURES.forEach(({ id }) => renderCold(loadFixture(id))); // stale-rule survivalIts own suites: exhaustive computePage table-walk, fixture-driven cold-render tests, harness units
(any-state handling, same-state error re-entry retains form values, one-live-envelope focus gating,
idempotency-key reuse).
Usage
Everything below is compiled against the packed tarball in CI (scripts/check-readme-examples.sh),
so these examples cannot drift from the API.
Production: a backend route handler
The backend builds envelopes, so its production use is entirely compile-time — the types are the verification. Runtime assertions stay behind a dev/test guard.
import type { FlowState, StepResponse, Transition } from '@fun-xyz/fiat-contract/types';
export async function createOrder(quoteRef: string): Promise<StepResponse> {
const state: FlowState = {
kind: 'PAYMENT',
phase: 'INSTRUCT',
instructions: { kind: 'BANK_FIELDS', fields: await beneficiaryFields(), expiresAt },
};
const transitions: Transition[] = [
{ id: 'confirm_paid', mode: 'SUBMIT', endpoint: `POST /fiat/orders/${orderId}/confirm-payment` },
// endpoint is a bounded union: `POST /fiat/orders/${string}/confirm-paymnt` is a compile error,
// and so is any path outside the documented /fiat/* surface
];
if (!isProduction) {
const { assertLegalEmission } = await import('@fun-xyz/fiat-contract');
assertLegalEmission(state, transitions); // dev/test only — never on the hot path
}
return { state, provider: 'TRANSAK', transitions };
}What the types buy at compile time: a state kind that isn't in the union won't type, a transition
mode/shape mismatch won't type, and — since FiatEndpoint is a bounded union — neither will a
typo'd endpoint. No runtime cost.
Production: a frontend screen
The client narrows on the state and reads terminality from the table. Both imports are
production-safe: types erase, ./table is 13.8 KB with no zod.
import { isTerminal, stateKey } from '@fun-xyz/fiat-contract/table';
import type { StepResponse } from '@fun-xyz/fiat-contract/types';
export function FiatScreen({ env }: { env: StepResponse }) {
const page = computePage(stateKey(env.state), clientLocal); // client owns state → screen
const done = isTerminal(env.state); // table data, never inferred
if (env.state.kind === 'PAYMENT' && env.state.phase === 'INSTRUCT') {
return render(page, env.state.instructions); // narrowed: instructions exists here
}
if (env.state.kind === 'ORDER' && env.state.phase === 'CREATED') {
return render(page, { status: env.state.status, done });
}
return render(page, env.state);
}isTerminal is the whole reason ./table exists as a runtime entry: KYC ON_HOLD carries
transitions: [] and is not terminal, while ORDER{CREATED, SETTLED} is — no client may
re-derive that rule locally.
Production: the transitions loop
import type { StepResponse, Transition } from '@fun-xyz/fiat-contract/types';
function useTransitions(env: StepResponse) {
return {
ctas: env.transitions.filter((t) => t.mode === 'SUBMIT'), // render buttons
poll: env.transitions.find((t) => t.mode === 'AWAIT'), // harness schedules
surface: env.transitions.find((t) => t.mode === 'CLIENT_SURFACE'), // harness mounts
};
}
function bodyFor(t: Transition, collected: Record<string, unknown>, surface?: Record<string, unknown>) {
if (t.mode === 'SUBMIT') return { ...t.params, ...collected }; // verb parsed from t.endpoint
if (t.mode === 'CLIENT_SURFACE') {
const injected = Object.fromEntries((t.report.expects ?? []).map((k) => [k, surface?.[k]]));
return { ...t.report.params, ...injected };
}
return { ...t.poll.params };
}Screens never inspect the array themselves; they receive ctas and bind labels
(label(t.id) ?? t.labelFallback — the contract carries no display copy). A key colliding across
params / inputs / expects is a contract violation, not last-write-wins.
Testing against it
These import from the root, which carries zod. Test-time only.
Validate an envelope at the boundary
assertEnvelope parses and returns a typed envelope, or throws ContractViolation listing every
problem. Use it on the way out of fun-backend (dev/test) and on the way in to connect-core tests.
import { assertEnvelope, ContractViolation } from '@fun-xyz/fiat-contract';
try {
const envelope = assertEnvelope(await res.json());
// ^? StepResponse — state is a narrowable discriminated union from here on
if (envelope.state.kind === 'PAYMENT' && envelope.state.phase === 'INSTRUCT') {
render(envelope.state.instructions); // narrowed: instructions exists, quote does not
}
} catch (err) {
if (err instanceof ContractViolation) console.error(err.issues); // ['state.quote: Required', …]
throw err;
}Assert an emission is legal (backend adapter conformance)
Shape validity is not sequence validity. assertLegalEmission judges (state, transitions) against
the table: is each transition in this state's legal set, does a terminal state carry none, does a
CLIENT_SURFACE transition have a Surface in state, do params/inputs/expects collide.
import { assertLegalEmission, checkLegalEmission } from '@fun-xyz/fiat-contract';
// Throwing form — use in adapter unit tests
assertLegalEmission(
{ kind: 'SESSION_AUTH', channel: 'EMAIL_OTP' },
[{
id: 'verify',
mode: 'SUBMIT',
endpoint: 'POST /fiat/session/verify',
params: { quoteRef: 'q_8f2' }, // server literal, spread verbatim
inputs: { code: { type: 'TEXT', length: 6 } }, // spec the client collects
}],
);
// Non-throwing form — use when you want to report in bulk across many generated states
const { issues, unjudgeable } = checkLegalEmission(state, transitions);
if (!unjudgeable && issues.length) report(issues);unjudgeable is true only for states the docs publish no transition set for (FUN_AUTH) — it
means "cannot judge", never "passed".
Walk the table exhaustively (frontend resolver coverage)
The table is data, so a runtime walk proves every state resolves to a screen — stronger than TS exhaustiveness alone, because it also fails when a new state is added to the contract.
import { walkTable, isTerminal, TRANSITION_TABLE } from '@fun-xyz/fiat-contract';
it('every contract state resolves to a screen', () => {
walkTable((entry) => {
expect(computePage(entry.key)).toBeDefined();
});
});
// Terminality is table data — never `transitions.length === 0`
isTerminal({ kind: 'KYC', phase: 'NO_ACTION_REQUIRED', reason: 'ON_HOLD' }); // false: empty, not over
isTerminal({ kind: 'ORDER', phase: 'CREATED', status: 'SETTLED' }); // true
isTerminal({ kind: 'ORDER', phase: 'CREATED', status: 'PROCESSING' }); // false
TRANSITION_TABLE['PAYMENT/INSTRUCT'].mayReturn; // ['ORDER/CREATED']Render every fixture cold (stale-rule survival)
Any call may return any state, so every screen must render from a cold envelope with no prior context. The fixtures are the FE doc's own envelopes, so this is a test against the spec.
import { FIXTURES, loadFixture, assertFixture } from '@fun-xyz/fiat-contract';
FIXTURES.forEach(({ id, stateKey, docRef }) => {
it(`${id} renders cold (${docRef})`, () => {
const { envelope } = assertFixture(id); // validated + emission-legality checked
expect(() => renderCold(envelope)).not.toThrow();
expect(computePage(stateKey)).toBeDefined();
});
});
loadFixture('screen-10-kyc-on-hold'); // raw JSON, fresh deep copy, `unknown` — validate before useUse a schema directly
All 46 schemas are exported when you need to validate a fragment rather than a whole envelope.
They are typed z.ZodType<T>, so you get .parse / .safeParse / .optional() — not .shape or
.extend, deliberately.
import { FlowStateSchema, QuoteSchema, TransitionSchema } from '@fun-xyz/fiat-contract';
const quote = QuoteSchema.parse(row.quote_json);
const result = FlowStateSchema.safeParse(input);
if (!result.success) log(result.error.issues);
void TransitionSchema;Check the handshake version
import { TABLE_VERSION } from '@fun-xyz/fiat-contract';
const handshake = {
supportedProviders: ['TRANSAK'],
supportedSurfaces: ['PCI_COMPONENT', 'PAY_SHEET'],
supportedStepKinds: ['QUOTE', 'SESSION_AUTH', 'KYC', 'PAYMENT', 'ORDER', 'BLOCKED'],
tableVersion: TABLE_VERSION, // the package version IS the table version
};Install
pnpm add -D @fun-xyz/fiat-contractimport type { StepResponse } from '@fun-xyz/fiat-contract/types'; // production, 0 KB
import { isTerminal } from '@fun-xyz/fiat-contract/table'; // production, 13.8 KB, no zod
import { assertEnvelope } from '@fun-xyz/fiat-contract'; // tests only, pulls zodPublic npm — no .npmrc, no token, no registry config. Releases fire from a v* tag
(.github/workflows/release.yml), which refuses to publish when the tag and package.json version
disagree, and no-ops when that version is already published.
Sourcemaps are excluded from the tarball ("!dist/**/*.map" in files) — unlike the funkit SDK
packages, which publish theirs on purpose. These declarations carry open-decision commentary that has
no reason to reach consumers. 50.5 kB, 21 files.
How connect-core consumes this
connect-core bundles ./table and ./types into its own dist rather than shipping them as
resolvable dependencies. One line of build config, not a copy of the code: one authored table, pinned
in connect-core's lockfile, recompiled into its output every build. Copy-pasting the table into
connect-core/src is not this, and would defeat the package.
// funkit/packages/connect-core/build.config.js — inside the externalize plugin
const BUNDLED_SPECIFIERS = new Set([
'@fun-xyz/fiat-contract/table',
'@fun-xyz/fiat-contract/types',
]);
build.onResolve({ filter }, (args) =>
BUNDLED_SPECIFIERS.has(args.path)
? undefined // let esbuild bundle it in
: { external: true, path: args.path }, // everything else stays external
);Why bundle rather than depend:
- Pins the tested pair. As a dependency, a partner's install resolves the semver range at their
install time, so an SDK tested against table 0.2.1 could run against 0.3.0 in the field. Bundling
compiles
TABLE_VERSIONinto the release, which is what the handshake then reports. - Metro never sees the subpath, so the package-exports question (§Packaging) cannot reach a partner's bundler.
Consequences:
- The build exception needs its comment, or it gets deleted as dead config. A test in connect-core asserts the table inlines and that zod and the fixtures stay out.
connect/connect-rnshould import the table from connect-core, not bundle their own copy (~14 KB apiece).- The shipped table version is not readable from a partner's
node_modules— read the handshake.
The root entry stays a plain devDependency in both repos: test-only, so it never reaches a published
artifact.
Packaging
Dual CJS + ESM, same shape as @funkit/fun-relay, because the two consumers load it differently:
| Requirement | Source | How it's met |
| --- | --- | --- |
| require() must work | fun-backend/apps/api-server compiles module: commonjs and runs plain node — no bundler | esbuild emits dist/index.js (CJS) + dist/index.mjs (ESM); exports maps require/import; no "type": "module" |
| Zero Node builtins | connect-core is React Native — Metro cannot resolve node:fs | fixtures are inlined as generated TS data (src/fixtures/data.ts), so nothing touches the filesystem; platform: browser, every bare import external |
| Subpath imports must resolve without exports support | Metro only reads package.json#exports from RN 0.79 on, and @funkit/connect-rn accepts react-native: >=0.74 | root compat stubs table.js / types.js (+ .d.ts) that re-export dist/. Without them Metro fails with "Unable to resolve module" — verified, not theorised. Resolvers that do read exports never see the stubs |
| Production code must not pull zod or fixtures | connect-core ships to React Native, where Metro does not tree-shake unused exports by default | three entry points: ./types (erased), ./table (13.8 KB, zero deps), root (schemas + fixtures, test-time). CI asserts require('.../table') never loads zod |
| .d.ts must not lock a zod major | Both repos run skipLibCheck: true, which turns a broken declaration into a silent any | every exported schema is annotated z.ZodType<T>, so declarations name only z.ZodType; the structural drift checks stay module-private |
Declarations come from tsc --emitDeclarationOnly; esbuild only emits JS. The fixture .json files
remain the verbatim record — data.ts is generated from them and CI fails if it drifts.
Scripts
npm run typecheck # tsc --noEmit, strict; includes the schema↔type mirror assertions
npm test # vitest: fixtures, zod round-trip, table integrity, negative asserts
npm run generate:fixtures # regenerate src/fixtures/data.ts from the .json record
npm run build # tsc declarations + esbuild dual CJS/ESM into dist/
npm run check # typecheck + test
./scripts/verify-consumer.sh 3.23.8 pnpm # pack, install, require/import, and typecheck a
# consumer with skipLibCheck OFF — catches packaging
# faults the in-repo suite structurally cannot
./scripts/check-readme-examples.sh # compile every ```ts block in this README against
# the packed package
./scripts/verify-metro.sh # bundle ./table with real Metro in both
# package-exports modes; assert no zod/fixtures reach
# the bundle, and that it executesDependencies
zod is an optional peer, range ^3.22.0 || ^4.0.0. Only the root entry needs it; ./table and
./types have no runtime dependency on zod, so an install that never imports the root entry needs no
zod at all.
| Repo | zod | Notes |
| --- | --- | --- |
| funkit (pnpm 9.4) | 3.23.8 (apps/fits, apps/frog) | connect-core declares it explicitly — required, see below |
| fun-backend (pnpm 10.16) | 3.25.76 | forced tree-wide by pnpm.overrides |
Neither repo is on zod 4; the || ^4.0.0 arm is verified, not required. CI runs the suite and a
packed-tarball consumer check on 3.22.4, 3.23.8, 3.25.76 and 4.4.3. (zod 3.25.0 exactly is
unusable — that release ships no dist/; 3.25.1+ is fine.)
A consumer importing the root entry must declare zod itself. The peer being optional means pnpm
installs none by default, so a test/** import of the root entry fails with a missing module — loud,
and the reason the peer is optional. When it was a required peer, pnpm instead resolved zod 4 and,
under dedupe-peer-dependents, moved funkit's viem onto it, which abitype's ^3 >=3.22.0 peer
rejects.
Declarations never bake in a zod major: every exported schema is annotated z.ZodType<T>. Consumers
get .parse / .safeParse / .optional(), not .shape / .extend — a contract you validate
against, not compose from.
Open decisions — encoded, not resolved
Every one of these is marked // TODO(open-decision): <doc ref> at the site that encodes it.
The current documented shape is what ships; none of these are settled here.
| Open item | How 0.1.0 encodes it |
| --- | --- |
| orderId placement | Both: optional on PAYMENT{INSTRUCT} (FE doc v0) and optional as an envelope sibling (contract worked example). One fixture of each. |
| PENDING_ORDER removal | Kind ships, with the removal proposal flagged on the type, the table entry, and the fixture. Screen 12 stays frozen. |
| [OQ7] failure enumeration | The published FailureReason taxonomy only. Expired instructions, partial payment, per-rail cancel eligibility, and terminal-vs-escalating rejections are flagged unenumerated. |
| Screen 11 escalation trigger | Encoded as published (SUBMIT GET /fiat/kyc + params: {tier}) with the doc's own warning that a GET carrying params is not a real shape. |
| Card capture report target | Both topologies are legal in the table (POST /fiat/orders for capture-then-order; POST /fiat/orders/:id/surface-result otherwise) pending the Transak answer. |
| Cancel placement | cancel is legal on PAYMENT{INSTRUCT} and ORDER{CREATED}, marked conditional on the placement decision. |
| FUN_AUTH shape | challenge: Record<string, JsonValue>; the table entry is docStatus: 'UNSPECIFIED', so assertLegalEmission reports it unjudgeable instead of guessing. |
| statusHistory element shape | The documented minimum ({status}) — no invented timestamps. |
| QR_IMAGE instruction | In the union per §The envelope, flagged against OQ1's "deliberately not pre-declared". |
Two fixture gaps are declared in FIXTURE_COVERAGE_GAPS rather than filled with invented envelopes:
FUN_AUTH (shape owned by the auth spike) and KYC/CAPTURE (Screen 8 tombstone, dropped from v1).
Every other state has a recorded envelope.
Not decided here — needs a human
- Whether
PENDING_ORDERships in the types or waits for the removal decision. It ships today (flagged everywhere) so the current server shape validates. - Repo hosting / org placement. The docs recommend a standalone repo (option B) or
fun-backend(option C) — explicitly not funkit. Backend sign-off is pending; this repo is standalone, which is reversible either way.
