@fun-xyz/fiat-contract 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,10 +7,10 @@ Four things, zero runtime logic beyond validation:
7
7
 
8
8
  | File | What it is |
9
9
  | --- | --- |
10
- | `src/types.ts` | `FlowState` · `Transition` · `FailureReason` · `Surface` · `Instructions` · `FormDescriptor` · `OrderStatus` · `StepResponse` |
10
+ | `src/types.ts` | `FlowState` · `Transition` · `FailureReason` · `Surface` · `Instructions` · `FormDescriptor` · `OrderStatus` · `FiatStepResponse` |
11
11
  | `src/schemas.ts` | zod mirrors of every type — the single runtime validator |
12
12
  | `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` |
13
- | `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) |
13
+ | `src/assert.ts` + `src/fixtures/` | `assertFiatStepResponse` · `assertLegalEmission` · `assertLegalReturn` · `walkTable` · fixture loader + 16 recorded envelopes (inlined as data — no filesystem, so React Native can bundle it) |
14
14
 
15
15
  ## Three entry points — production vs test-time
16
16
 
@@ -55,23 +55,26 @@ this package is [ENG-5268](https://linear.app/funxyz/issue/ENG-5268).
55
55
 
56
56
  ## Versioning — the package version IS the table version
57
57
 
58
- `TABLE_VERSION` (exported from `src/table.ts`) must equal `package.json#version`; a test asserts it.
58
+ `TABLE_VERSION` (exported from `src/table.ts`) is derived from `package.json#version` at build
59
+ time — a version bump is one edit, in package.json, and the two can never drift. A test asserts
60
+ the wiring still resolves to the package version.
59
61
  That version rides the capability handshake (`supportedProviders` / `supportedSurfaces` /
60
62
  `supportedStepKinds` + table version), which is how BE↔FE skew stays explicit and designed-for:
61
63
  the server never routes a flow into a state kind the installed SDK didn't declare.
62
64
 
63
65
  Starting at **0.1.0**. Bump the minor for additive vocabulary (a new field, a new fixture); bump the
64
- major when an existing shape changes meaning.
66
+ major when an existing shape changes meaning. Until 1.0 the contract is MVP-beta: renames and
67
+ removals also ride a minor, with no deprecated aliases kept — consumers pin and migrate in one step.
65
68
 
66
69
  ## How each repo consumes it
67
70
 
68
71
  **`fun-backend`** — adapters compile their provider's real flow into this state machine.
69
72
 
70
73
  ```ts
71
- import { assertEnvelope, assertLegalEmission, walkTable } from '@fun-xyz/fiat-contract';
74
+ import { assertFiatStepResponse, assertLegalEmission, walkTable } from '@fun-xyz/fiat-contract';
72
75
 
73
- assertLegalEmission(state, transitions); // per emission: adapter conformance
74
- assertEnvelope(outgoing); // outgoing-envelope validation in dev/test
76
+ assertLegalEmission(state, transitions); // per emission: adapter conformance
77
+ assertFiatStepResponse(outgoing); // outgoing-envelope validation in dev/test
75
78
  const owed = walkTable((entry) => entry.allowedTransitions); // what the adapter must emit
76
79
  ```
77
80
 
@@ -102,9 +105,9 @@ The backend *builds* envelopes, so its production use is entirely compile-time
102
105
  verification. Runtime assertions stay behind a dev/test guard.
103
106
 
104
107
  ```ts
105
- import type { FlowState, StepResponse, Transition } from '@fun-xyz/fiat-contract/types';
108
+ import type { FlowState, FiatStepResponse, Transition } from '@fun-xyz/fiat-contract/types';
106
109
 
107
- export async function createOrder(quoteRef: string): Promise<StepResponse> {
110
+ export async function createOrder(quoteRef: string): Promise<FiatStepResponse> {
108
111
  const state: FlowState = {
109
112
  kind: 'PAYMENT',
110
113
  phase: 'INSTRUCT',
@@ -137,9 +140,9 @@ production-safe: types erase, `./table` is 13.8 KB with no zod.
137
140
 
138
141
  ```ts
139
142
  import { isTerminal, stateKey } from '@fun-xyz/fiat-contract/table';
140
- import type { StepResponse } from '@fun-xyz/fiat-contract/types';
143
+ import type { FiatStepResponse } from '@fun-xyz/fiat-contract/types';
141
144
 
142
- export function FiatScreen({ env }: { env: StepResponse }) {
145
+ export function FiatScreen({ env }: { env: FiatStepResponse }) {
143
146
  const page = computePage(stateKey(env.state), clientLocal); // client owns state → screen
144
147
  const done = isTerminal(env.state); // table data, never inferred
145
148
 
@@ -160,9 +163,9 @@ re-derive that rule locally.
160
163
  ### Production: the transitions loop
161
164
 
162
165
  ```ts
163
- import type { StepResponse, Transition } from '@fun-xyz/fiat-contract/types';
166
+ import type { FiatStepResponse, Transition } from '@fun-xyz/fiat-contract/types';
164
167
 
165
- function useTransitions(env: StepResponse) {
168
+ function useTransitions(env: FiatStepResponse) {
166
169
  return {
167
170
  ctas: env.transitions.filter((t) => t.mode === 'SUBMIT'), // render buttons
168
171
  poll: env.transitions.find((t) => t.mode === 'AWAIT'), // harness schedules
@@ -190,15 +193,16 @@ These import from the root, which carries zod. Test-time only.
190
193
 
191
194
  ### Validate an envelope at the boundary
192
195
 
193
- `assertEnvelope` parses and returns a typed envelope, or throws `ContractViolation` listing every
194
- problem. Use it on the way out of `fun-backend` (dev/test) and on the way in to `connect-core` tests.
196
+ `assertFiatStepResponse` parses and returns a typed step response, or throws `ContractViolation`
197
+ listing every problem. Use it on the way out of `fun-backend` (dev/test) and on the way in to
198
+ `connect-core` tests.
195
199
 
196
200
  ```ts
197
- import { assertEnvelope, ContractViolation } from '@fun-xyz/fiat-contract';
201
+ import { assertFiatStepResponse, ContractViolation } from '@fun-xyz/fiat-contract';
198
202
 
199
203
  try {
200
- const envelope = assertEnvelope(await res.json());
201
- // ^? StepResponse — state is a narrowable discriminated union from here on
204
+ const envelope = assertFiatStepResponse(await res.json());
205
+ // ^? FiatStepResponse — state is a narrowable discriminated union from here on
202
206
  if (envelope.state.kind === 'PAYMENT' && envelope.state.phase === 'INSTRUCT') {
203
207
  render(envelope.state.instructions); // narrowed: instructions exists, quote does not
204
208
  }
@@ -313,9 +317,9 @@ pnpm add -D @fun-xyz/fiat-contract
313
317
  ```
314
318
 
315
319
  ```ts
316
- import type { StepResponse } from '@fun-xyz/fiat-contract/types'; // production, 0 KB
317
- import { isTerminal } from '@fun-xyz/fiat-contract/table'; // production, 13.8 KB, no zod
318
- import { assertEnvelope } from '@fun-xyz/fiat-contract'; // tests only, pulls zod
320
+ import type { FiatStepResponse } from '@fun-xyz/fiat-contract/types'; // production, 0 KB
321
+ import { isTerminal } from '@fun-xyz/fiat-contract/table'; // production, 13.8 KB, no zod
322
+ import { assertFiatStepResponse } from '@fun-xyz/fiat-contract'; // tests only, pulls zod
319
323
  ```
320
324
 
321
325
  Public npm — no `.npmrc`, no token, no registry config. Releases fire from a `v*` tag
package/dist/assert.d.ts CHANGED
@@ -10,19 +10,14 @@
10
10
  */
11
11
  import { type EndpointTemplate, type StateKey, type TableEntry } from './table';
12
12
  import { type FixtureMeta } from './fixtures/index';
13
- import type { FlowState, StepResponse, Transition } from './types';
13
+ import type { FiatStepResponse, FlowState, Transition } from './types';
14
14
  /** Thrown for anything the contract calls a violation — 4xx-class, never a user-facing state. */
15
15
  export declare class ContractViolation extends Error {
16
16
  readonly issues: readonly string[];
17
17
  constructor(message: string, issues?: readonly string[]);
18
18
  }
19
- /** Validate a `/fiat/*` response envelope. Returns the parsed envelope; throws on violation. */
20
- export declare function assertEnvelope(json: unknown): StepResponse;
21
- /**
22
- * Validate an envelope whose `provider` is absent — only doc excerpts look like this
23
- * (see `src/fixtures`: fixtures marked `complete: false`). Never valid on the wire.
24
- */
25
- export declare function assertEnvelopeFragment(json: unknown): Omit<StepResponse, 'provider'>;
19
+ /** Validate a `/fiat/*` step response. Returns the parsed response; throws on violation. */
20
+ export declare function assertFiatStepResponse(json: unknown): FiatStepResponse;
26
21
  /** `"POST /fiat/orders/:id/cancel"` matches `"POST /fiat/orders/o_31c/cancel"`. */
27
22
  export declare function endpointMatches(template: EndpointTemplate, actual: string): boolean;
28
23
  export interface EmissionCheck {
@@ -54,10 +49,7 @@ export declare function assertLegalReturn(from: FlowState, to: FlowState): void;
54
49
  export declare function walkTable<T>(fn: (entry: TableEntry, key: StateKey) => T): T[];
55
50
  export interface ValidatedFixture {
56
51
  meta: FixtureMeta;
57
- /** `provider` is absent on fragment fixtures (`meta.complete === false`). */
58
- envelope: Omit<StepResponse, 'provider'> & {
59
- provider?: StepResponse['provider'];
60
- };
52
+ envelope: FiatStepResponse;
61
53
  }
62
54
  /** Load one fixture, validate its shape, and assert its emission is legal for its state. */
63
55
  export declare function assertFixture(id: string): ValidatedFixture;
@@ -1,5 +1,8 @@
1
+ // package.json
2
+ var version = "0.5.0";
3
+
1
4
  // src/table.ts
2
- var TABLE_VERSION = "0.4.0";
5
+ var TABLE_VERSION = version;
3
6
  var TERMINAL_ORDER_STATUSES = [
4
7
  "SETTLED",
5
8
  "EXPIRED",
@@ -409,4 +412,4 @@ export {
409
412
  tableEntry,
410
413
  isTerminal
411
414
  };
412
- //# sourceMappingURL=chunk-VLQLWQPK.mjs.map
415
+ //# sourceMappingURL=chunk-3R5GGUAK.mjs.map
@@ -9,10 +9,10 @@
9
9
  * already synthetic (`q_8f2`, `o_31c`, `eyJ…`, `"…"` placeholders); the redaction rule applies to
10
10
  * fixtures too, so never replace a fake session token, bank field, or PII value with a real one.
11
11
  *
12
- * Two fixture shapes exist:
13
- * - `complete: true` a full envelope (`state` + `provider` + `transitions`) ⇒ `assertEnvelope`
14
- * - `complete: false` a doc excerpt that omits `provider` `assertEnvelopeFragment`
15
- * (`provider` is always present on the wire; the contract doc's worked example just elides it)
12
+ * Every fixture is a full envelope (`state` + `provider` + `transitions`). The contract doc's worked
13
+ * example elides `provider` in its prose; the three `worked-example-*` fixtures add
14
+ * `"provider": "TRANSAK"` so they validate as real responses `provider` is always present on the
15
+ * wire, and the contract has no half-envelope shape.
16
16
  */
17
17
  import type { StateKey } from '../table';
18
18
  export type FixtureSource = 'FE_DOC' | 'CONTRACT_DOC';
@@ -25,8 +25,6 @@ export interface FixtureMeta {
25
25
  /** FE doc screen number, when the fixture comes from a screen card. */
26
26
  screen?: number;
27
27
  stateKey: StateKey;
28
- /** `false` ⇒ the doc excerpt omits `provider`; validate as a fragment. */
29
- complete: boolean;
30
28
  notes?: readonly string[];
31
29
  }
32
30
  export declare const FIXTURES: readonly FixtureMeta[];
package/dist/index.js CHANGED
@@ -29,7 +29,6 @@ __export(index_exports, {
29
29
  CryptoAmountSchema: () => CryptoAmountSchema,
30
30
  DOCUMENTED_ENDPOINTS: () => DOCUMENTED_ENDPOINTS,
31
31
  ENDPOINT_PATTERN: () => ENDPOINT_PATTERN,
32
- EnvelopeSchema: () => EnvelopeSchema,
33
32
  FIAT_CURRENCY_NAMES: () => FIAT_CURRENCY_NAMES,
34
33
  FIELD_SPEC_TYPES: () => FIELD_SPEC_TYPES,
35
34
  FIXTURES: () => FIXTURES,
@@ -40,6 +39,7 @@ __export(index_exports, {
40
39
  FiatCurrencyCode: () => FiatCurrencyCode,
41
40
  FiatEndpointSchema: () => FiatEndpointSchema,
42
41
  FiatProviderSchema: () => FiatProviderSchema,
42
+ FiatStepResponseSchema: () => FiatStepResponseSchema,
43
43
  FieldSpecSchema: () => FieldSpecSchema,
44
44
  FlowStateSchema: () => FlowStateSchema,
45
45
  FormDescriptorSchema: () => FormDescriptorSchema,
@@ -73,8 +73,6 @@ __export(index_exports, {
73
73
  SelectOptionSchema: () => SelectOptionSchema,
74
74
  SessionAuthStateSchema: () => SessionAuthStateSchema,
75
75
  StatusHistoryEntrySchema: () => StatusHistoryEntrySchema,
76
- StepResponseFragmentSchema: () => StepResponseFragmentSchema,
77
- StepResponseSchema: () => StepResponseSchema,
78
76
  SurfaceSchema: () => SurfaceSchema,
79
77
  TABLE_VERSION: () => TABLE_VERSION,
80
78
  TERMINAL_ORDER_STATUSES: () => TERMINAL_ORDER_STATUSES,
@@ -84,8 +82,7 @@ __export(index_exports, {
84
82
  TransitionSchema: () => TransitionSchema,
85
83
  TxSchema: () => TxSchema,
86
84
  assertAllFixtures: () => assertAllFixtures,
87
- assertEnvelope: () => assertEnvelope,
88
- assertEnvelopeFragment: () => assertEnvelopeFragment,
85
+ assertFiatStepResponse: () => assertFiatStepResponse,
89
86
  assertFixture: () => assertFixture,
90
87
  assertLegalEmission: () => assertLegalEmission,
91
88
  assertLegalReturn: () => assertLegalReturn,
@@ -560,13 +557,32 @@ var $params = import_zod.z.record(import_zod.z.string(), JsonSchema).superRefine
560
557
  var TransitionParamsSchema = $params;
561
558
  var $inputs = import_zod.z.record(import_zod.z.string(), $fieldSpec);
562
559
  var TransitionInputsSchema = $inputs;
560
+ var MAX_DELAY_MS = 2 ** 31 - 1;
561
+ var MIN_INTERVAL_MS = 500;
563
562
  var $pollSpec = obj({
564
563
  endpoint: $endpoint,
565
564
  params: $params.optional(),
566
- intervalMs: import_zod.z.number(),
567
- backoffFactor: import_zod.z.number().optional(),
568
- maxIntervalMs: import_zod.z.number().optional(),
569
- giveUpAfterMs: import_zod.z.number().optional()
565
+ intervalMs: import_zod.z.number().int().min(MIN_INTERVAL_MS).max(MAX_DELAY_MS),
566
+ // Not `.int()` — 1.5 is the recorded factor and a legitimate one. `.finite()` is load-bearing:
567
+ // zod 3's `z.number()` admits Infinity, and `Infinity >= 1` satisfies `.min(1)` on its own.
568
+ backoffFactor: import_zod.z.number().finite().min(1).optional(),
569
+ maxIntervalMs: import_zod.z.number().int().min(MIN_INTERVAL_MS).max(MAX_DELAY_MS).optional(),
570
+ giveUpAfterMs: import_zod.z.number().int().nonnegative().max(MAX_DELAY_MS).optional()
571
+ }).superRefine((spec, ctx) => {
572
+ if (spec.backoffFactor !== void 0 && spec.maxIntervalMs === void 0) {
573
+ ctx.addIssue({
574
+ code: "custom",
575
+ path: ["maxIntervalMs"],
576
+ message: "maxIntervalMs is required when backoffFactor is present \u2014 an unbounded backoff grows the interval past 2^31-1, where setTimeout truncates the delay to 0 and the poll hot-loops."
577
+ });
578
+ }
579
+ if (spec.maxIntervalMs !== void 0 && spec.maxIntervalMs < spec.intervalMs) {
580
+ ctx.addIssue({
581
+ code: "custom",
582
+ path: ["maxIntervalMs"],
583
+ message: `maxIntervalMs (${spec.maxIntervalMs}) is below intervalMs (${spec.intervalMs}) \u2014 a ceiling under the starting interval caps the poll at less than its own first delay.`
584
+ });
585
+ }
570
586
  });
571
587
  var PollSpecSchema = $pollSpec;
572
588
  var $reportSpec = obj({
@@ -699,18 +715,13 @@ var $stepResponse = obj({
699
715
  transitions: import_zod.z.array($transition),
700
716
  orderId: import_zod.z.string().optional()
701
717
  });
702
- var StepResponseSchema = $stepResponse;
703
- var $stepResponseFragment = obj({
704
- state: $flowState,
705
- provider: $provider.optional(),
706
- transitions: import_zod.z.array($transition),
707
- orderId: import_zod.z.string().optional()
708
- });
709
- var StepResponseFragmentSchema = $stepResponseFragment;
710
- var EnvelopeSchema = StepResponseSchema;
718
+ var FiatStepResponseSchema = $stepResponse;
719
+
720
+ // package.json
721
+ var version = "0.5.0";
711
722
 
712
723
  // src/table.ts
713
- var TABLE_VERSION = "0.4.0";
724
+ var TABLE_VERSION = version;
714
725
  var TERMINAL_ORDER_STATUSES = [
715
726
  "SETTLED",
716
727
  "EXPIRED",
@@ -1526,6 +1537,7 @@ var FIXTURE_DATA = {
1526
1537
  "expiresAt": "2026-08-11T21:33:00Z"
1527
1538
  }
1528
1539
  },
1540
+ "provider": "TRANSAK",
1529
1541
  "transitions": [
1530
1542
  {
1531
1543
  "id": "continue",
@@ -1547,6 +1559,7 @@ var FIXTURE_DATA = {
1547
1559
  "kind": "SESSION_AUTH",
1548
1560
  "channel": "EMAIL_OTP"
1549
1561
  },
1562
+ "provider": "TRANSAK",
1550
1563
  "transitions": [
1551
1564
  {
1552
1565
  "id": "verify",
@@ -1596,6 +1609,7 @@ var FIXTURE_DATA = {
1596
1609
  "expiresAt": "\u2026"
1597
1610
  }
1598
1611
  },
1612
+ "provider": "TRANSAK",
1599
1613
  "transitions": [
1600
1614
  {
1601
1615
  "id": "confirm_paid",
@@ -1614,8 +1628,7 @@ var FIXTURES = [
1614
1628
  source: "FE_DOC",
1615
1629
  docRef: "Screen 1 \xB7 Quote / review",
1616
1630
  screen: 1,
1617
- stateKey: "QUOTE",
1618
- complete: true
1631
+ stateKey: "QUOTE"
1619
1632
  },
1620
1633
  {
1621
1634
  id: "screen-02-session-auth",
@@ -1623,8 +1636,7 @@ var FIXTURES = [
1623
1636
  source: "FE_DOC",
1624
1637
  docRef: "Screen 2 \xB7 Verification code (OTP)",
1625
1638
  screen: 2,
1626
- stateKey: "SESSION_AUTH",
1627
- complete: true
1639
+ stateKey: "SESSION_AUTH"
1628
1640
  },
1629
1641
  {
1630
1642
  id: "screen-03-kyc-input-required",
@@ -1633,7 +1645,6 @@ var FIXTURES = [
1633
1645
  docRef: "Screen 3 \xB7 Identity form (KYC)",
1634
1646
  screen: 3,
1635
1647
  stateKey: "KYC/INPUT_REQUIRED",
1636
- complete: true,
1637
1648
  notes: ["Carries the FORM_VALUES input spec \u2014 the no-double-carry case of the accepted split."]
1638
1649
  },
1639
1650
  {
@@ -1643,7 +1654,6 @@ var FIXTURES = [
1643
1654
  docRef: "Screen 4 \xB7 Order review (bank rail)",
1644
1655
  screen: 4,
1645
1656
  stateKey: "ORDER/AWAITING_CONFIRMATION",
1646
- complete: true,
1647
1657
  notes: [
1648
1658
  `The screen card publishes this payload by reference \u2014 "quote: <same shape as Screen 1, refreshed>" \u2014 so the quote here is Screen 1's payload verbatim. The numbers are not re-published as refreshed values, so they are unchanged; only the shape is contractual.`
1649
1659
  ]
@@ -1655,7 +1665,6 @@ var FIXTURES = [
1655
1665
  docRef: "Screen 5 \xB7 Card payment",
1656
1666
  screen: 5,
1657
1667
  stateKey: "PAYMENT/CAPTURE",
1658
- complete: true,
1659
1668
  notes: [
1660
1669
  "Capture-then-order topology (report \u2192 POST /fiat/orders, expects requestId).",
1661
1670
  "TODO(open-decision): report target per card topology is a Transak ask (contract Flow A)."
@@ -1667,8 +1676,7 @@ var FIXTURES = [
1667
1676
  source: "FE_DOC",
1668
1677
  docRef: "Screen 9 \xB7 Verifying identity",
1669
1678
  screen: 9,
1670
- stateKey: "KYC/NO_ACTION_REQUIRED:IN_REVIEW",
1671
- complete: true
1679
+ stateKey: "KYC/NO_ACTION_REQUIRED:IN_REVIEW"
1672
1680
  },
1673
1681
  {
1674
1682
  id: "screen-10-kyc-on-hold",
@@ -1677,7 +1685,6 @@ var FIXTURES = [
1677
1685
  docRef: "Screen 10 \xB7 Verification on hold",
1678
1686
  screen: 10,
1679
1687
  stateKey: "KYC/NO_ACTION_REQUIRED:ON_HOLD",
1680
- complete: true,
1681
1688
  notes: ["transitions: [] and NOT terminal \u2014 the fixture that proves terminality is table data."]
1682
1689
  },
1683
1690
  {
@@ -1687,7 +1694,6 @@ var FIXTURES = [
1687
1694
  docRef: "Screen 11 \xB7 Verification unsuccessful",
1688
1695
  screen: 11,
1689
1696
  stateKey: "KYC/NO_ACTION_REQUIRED:REJECTED",
1690
- complete: true,
1691
1697
  notes: [
1692
1698
  "TODO(open-decision): the escalation transition is a flagged placeholder \u2014 a GET carrying params is not a real request shape (FE doc Screen 11 + open question 7).",
1693
1699
  "The terminal REJECTED variant (failureReason + transitions []) is described in prose, not published as JSON \u2014 no fixture."
@@ -1700,7 +1706,6 @@ var FIXTURES = [
1700
1706
  docRef: "Screen 12 \xB7 Order in progress",
1701
1707
  screen: 12,
1702
1708
  stateKey: "PENDING_ORDER",
1703
- complete: true,
1704
1709
  notes: [
1705
1710
  "TODO(open-decision): PENDING_ORDER removal proposed; Screen 12 frozen. Kept so 0.1.0 can validate the current shape."
1706
1711
  ]
@@ -1711,8 +1716,7 @@ var FIXTURES = [
1711
1716
  source: "FE_DOC",
1712
1717
  docRef: "Screen 13 \xB7 Success",
1713
1718
  screen: 13,
1714
- stateKey: "ORDER/CREATED",
1715
- complete: true
1719
+ stateKey: "ORDER/CREATED"
1716
1720
  },
1717
1721
  {
1718
1722
  id: "screen-14-order-failed",
@@ -1721,7 +1725,6 @@ var FIXTURES = [
1721
1725
  docRef: "Screen 14 \xB7 Something went wrong",
1722
1726
  screen: 14,
1723
1727
  stateKey: "ORDER/CREATED",
1724
- complete: true,
1725
1728
  notes: ["Terminal status (FAILED) still carrying a recovery transition \u2014 retryable failureReason."]
1726
1729
  },
1727
1730
  {
@@ -1730,8 +1733,7 @@ var FIXTURES = [
1730
1733
  source: "FE_DOC",
1731
1734
  docRef: "Screen 15 \xB7 Refund on the way",
1732
1735
  screen: 15,
1733
- stateKey: "ORDER/CREATED",
1734
- complete: true
1736
+ stateKey: "ORDER/CREATED"
1735
1737
  },
1736
1738
  {
1737
1739
  id: "screen-16-blocked",
@@ -1740,7 +1742,6 @@ var FIXTURES = [
1740
1742
  docRef: "Screen 16 \xB7 Not available",
1741
1743
  screen: 16,
1742
1744
  stateKey: "BLOCKED",
1743
- complete: true,
1744
1745
  notes: ["The only flatly terminal state entry in the table."]
1745
1746
  },
1746
1747
  {
@@ -1749,7 +1750,6 @@ var FIXTURES = [
1749
1750
  source: "CONTRACT_DOC",
1750
1751
  docRef: "\xA7Worked example, step 1 (method selection)",
1751
1752
  stateKey: "QUOTE",
1752
- complete: false,
1753
1753
  notes: ["Carries inputs {email: {type: TEXT}} \u2014 the collected-spec half of the split."]
1754
1754
  },
1755
1755
  {
@@ -1757,8 +1757,7 @@ var FIXTURES = [
1757
1757
  file: "worked-example-02-session-auth.json",
1758
1758
  source: "CONTRACT_DOC",
1759
1759
  docRef: "\xA7Worked example, step 2 (session challenge)",
1760
- stateKey: "SESSION_AUTH",
1761
- complete: false
1760
+ stateKey: "SESSION_AUTH"
1762
1761
  },
1763
1762
  {
1764
1763
  id: "worked-example-04-payment-instruct",
@@ -1766,7 +1765,6 @@ var FIXTURES = [
1766
1765
  source: "CONTRACT_DOC",
1767
1766
  docRef: "\xA7Worked example, steps 4\u20135 (order + bank instructions)",
1768
1767
  stateKey: "PAYMENT/INSTRUCT",
1769
- complete: false,
1770
1768
  notes: [
1771
1769
  "The envelope-sibling `orderId` case. TODO(open-decision): orderId placement (envelope sibling here vs inside state in the FE doc v0)."
1772
1770
  ]
@@ -1815,17 +1813,11 @@ var formatIssues = (error) => {
1815
1813
  return path ? `${path}: ${issue.message ?? "invalid"}` : issue.message ?? "invalid";
1816
1814
  });
1817
1815
  };
1818
- function assertEnvelope(json) {
1819
- const result = StepResponseSchema.safeParse(json);
1816
+ function assertFiatStepResponse(json) {
1817
+ const result = FiatStepResponseSchema.safeParse(json);
1820
1818
  if (!result.success) throw new ContractViolation("invalid envelope", formatIssues(result.error));
1821
1819
  return result.data;
1822
1820
  }
1823
- function assertEnvelopeFragment(json) {
1824
- const result = StepResponseFragmentSchema.safeParse(json);
1825
- if (!result.success)
1826
- throw new ContractViolation("invalid envelope fragment", formatIssues(result.error));
1827
- return result.data;
1828
- }
1829
1821
  function endpointMatches(template, actual) {
1830
1822
  const pattern = template.split("/").map((segment) => segment.startsWith(":") ? "[^/]+" : escapeRegExp(segment)).join("/");
1831
1823
  return new RegExp(`^${pattern}$`).test(actual);
@@ -1920,7 +1912,7 @@ function walkTable(fn) {
1920
1912
  function assertFixture(id) {
1921
1913
  const meta = fixtureMeta(id);
1922
1914
  const json = loadFixture(id);
1923
- const envelope = meta.complete ? assertEnvelope(json) : assertEnvelopeFragment(json);
1915
+ const envelope = assertFiatStepResponse(json);
1924
1916
  if (stateKey(envelope.state) !== meta.stateKey) {
1925
1917
  throw new ContractViolation(
1926
1918
  `fixture ${id}: manifest says ${meta.stateKey}, envelope carries ${stateKey(envelope.state)}`
package/dist/index.mjs CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  isTerminal,
9
9
  stateKey,
10
10
  tableEntry
11
- } from "./chunk-VLQLWQPK.mjs";
11
+ } from "./chunk-3R5GGUAK.mjs";
12
12
 
13
13
  // src/codes.ts
14
14
  var COUNTRY_CURRENCY_LIST = [
@@ -468,13 +468,32 @@ var $params = z.record(z.string(), JsonSchema).superRefine((params, ctx) => {
468
468
  var TransitionParamsSchema = $params;
469
469
  var $inputs = z.record(z.string(), $fieldSpec);
470
470
  var TransitionInputsSchema = $inputs;
471
+ var MAX_DELAY_MS = 2 ** 31 - 1;
472
+ var MIN_INTERVAL_MS = 500;
471
473
  var $pollSpec = obj({
472
474
  endpoint: $endpoint,
473
475
  params: $params.optional(),
474
- intervalMs: z.number(),
475
- backoffFactor: z.number().optional(),
476
- maxIntervalMs: z.number().optional(),
477
- giveUpAfterMs: z.number().optional()
476
+ intervalMs: z.number().int().min(MIN_INTERVAL_MS).max(MAX_DELAY_MS),
477
+ // Not `.int()` — 1.5 is the recorded factor and a legitimate one. `.finite()` is load-bearing:
478
+ // zod 3's `z.number()` admits Infinity, and `Infinity >= 1` satisfies `.min(1)` on its own.
479
+ backoffFactor: z.number().finite().min(1).optional(),
480
+ maxIntervalMs: z.number().int().min(MIN_INTERVAL_MS).max(MAX_DELAY_MS).optional(),
481
+ giveUpAfterMs: z.number().int().nonnegative().max(MAX_DELAY_MS).optional()
482
+ }).superRefine((spec, ctx) => {
483
+ if (spec.backoffFactor !== void 0 && spec.maxIntervalMs === void 0) {
484
+ ctx.addIssue({
485
+ code: "custom",
486
+ path: ["maxIntervalMs"],
487
+ message: "maxIntervalMs is required when backoffFactor is present \u2014 an unbounded backoff grows the interval past 2^31-1, where setTimeout truncates the delay to 0 and the poll hot-loops."
488
+ });
489
+ }
490
+ if (spec.maxIntervalMs !== void 0 && spec.maxIntervalMs < spec.intervalMs) {
491
+ ctx.addIssue({
492
+ code: "custom",
493
+ path: ["maxIntervalMs"],
494
+ message: `maxIntervalMs (${spec.maxIntervalMs}) is below intervalMs (${spec.intervalMs}) \u2014 a ceiling under the starting interval caps the poll at less than its own first delay.`
495
+ });
496
+ }
478
497
  });
479
498
  var PollSpecSchema = $pollSpec;
480
499
  var $reportSpec = obj({
@@ -607,15 +626,7 @@ var $stepResponse = obj({
607
626
  transitions: z.array($transition),
608
627
  orderId: z.string().optional()
609
628
  });
610
- var StepResponseSchema = $stepResponse;
611
- var $stepResponseFragment = obj({
612
- state: $flowState,
613
- provider: $provider.optional(),
614
- transitions: z.array($transition),
615
- orderId: z.string().optional()
616
- });
617
- var StepResponseFragmentSchema = $stepResponseFragment;
618
- var EnvelopeSchema = StepResponseSchema;
629
+ var FiatStepResponseSchema = $stepResponse;
619
630
 
620
631
  // src/fixtures/data.ts
621
632
  var FIXTURE_DATA = {
@@ -1033,6 +1044,7 @@ var FIXTURE_DATA = {
1033
1044
  "expiresAt": "2026-08-11T21:33:00Z"
1034
1045
  }
1035
1046
  },
1047
+ "provider": "TRANSAK",
1036
1048
  "transitions": [
1037
1049
  {
1038
1050
  "id": "continue",
@@ -1054,6 +1066,7 @@ var FIXTURE_DATA = {
1054
1066
  "kind": "SESSION_AUTH",
1055
1067
  "channel": "EMAIL_OTP"
1056
1068
  },
1069
+ "provider": "TRANSAK",
1057
1070
  "transitions": [
1058
1071
  {
1059
1072
  "id": "verify",
@@ -1103,6 +1116,7 @@ var FIXTURE_DATA = {
1103
1116
  "expiresAt": "\u2026"
1104
1117
  }
1105
1118
  },
1119
+ "provider": "TRANSAK",
1106
1120
  "transitions": [
1107
1121
  {
1108
1122
  "id": "confirm_paid",
@@ -1121,8 +1135,7 @@ var FIXTURES = [
1121
1135
  source: "FE_DOC",
1122
1136
  docRef: "Screen 1 \xB7 Quote / review",
1123
1137
  screen: 1,
1124
- stateKey: "QUOTE",
1125
- complete: true
1138
+ stateKey: "QUOTE"
1126
1139
  },
1127
1140
  {
1128
1141
  id: "screen-02-session-auth",
@@ -1130,8 +1143,7 @@ var FIXTURES = [
1130
1143
  source: "FE_DOC",
1131
1144
  docRef: "Screen 2 \xB7 Verification code (OTP)",
1132
1145
  screen: 2,
1133
- stateKey: "SESSION_AUTH",
1134
- complete: true
1146
+ stateKey: "SESSION_AUTH"
1135
1147
  },
1136
1148
  {
1137
1149
  id: "screen-03-kyc-input-required",
@@ -1140,7 +1152,6 @@ var FIXTURES = [
1140
1152
  docRef: "Screen 3 \xB7 Identity form (KYC)",
1141
1153
  screen: 3,
1142
1154
  stateKey: "KYC/INPUT_REQUIRED",
1143
- complete: true,
1144
1155
  notes: ["Carries the FORM_VALUES input spec \u2014 the no-double-carry case of the accepted split."]
1145
1156
  },
1146
1157
  {
@@ -1150,7 +1161,6 @@ var FIXTURES = [
1150
1161
  docRef: "Screen 4 \xB7 Order review (bank rail)",
1151
1162
  screen: 4,
1152
1163
  stateKey: "ORDER/AWAITING_CONFIRMATION",
1153
- complete: true,
1154
1164
  notes: [
1155
1165
  `The screen card publishes this payload by reference \u2014 "quote: <same shape as Screen 1, refreshed>" \u2014 so the quote here is Screen 1's payload verbatim. The numbers are not re-published as refreshed values, so they are unchanged; only the shape is contractual.`
1156
1166
  ]
@@ -1162,7 +1172,6 @@ var FIXTURES = [
1162
1172
  docRef: "Screen 5 \xB7 Card payment",
1163
1173
  screen: 5,
1164
1174
  stateKey: "PAYMENT/CAPTURE",
1165
- complete: true,
1166
1175
  notes: [
1167
1176
  "Capture-then-order topology (report \u2192 POST /fiat/orders, expects requestId).",
1168
1177
  "TODO(open-decision): report target per card topology is a Transak ask (contract Flow A)."
@@ -1174,8 +1183,7 @@ var FIXTURES = [
1174
1183
  source: "FE_DOC",
1175
1184
  docRef: "Screen 9 \xB7 Verifying identity",
1176
1185
  screen: 9,
1177
- stateKey: "KYC/NO_ACTION_REQUIRED:IN_REVIEW",
1178
- complete: true
1186
+ stateKey: "KYC/NO_ACTION_REQUIRED:IN_REVIEW"
1179
1187
  },
1180
1188
  {
1181
1189
  id: "screen-10-kyc-on-hold",
@@ -1184,7 +1192,6 @@ var FIXTURES = [
1184
1192
  docRef: "Screen 10 \xB7 Verification on hold",
1185
1193
  screen: 10,
1186
1194
  stateKey: "KYC/NO_ACTION_REQUIRED:ON_HOLD",
1187
- complete: true,
1188
1195
  notes: ["transitions: [] and NOT terminal \u2014 the fixture that proves terminality is table data."]
1189
1196
  },
1190
1197
  {
@@ -1194,7 +1201,6 @@ var FIXTURES = [
1194
1201
  docRef: "Screen 11 \xB7 Verification unsuccessful",
1195
1202
  screen: 11,
1196
1203
  stateKey: "KYC/NO_ACTION_REQUIRED:REJECTED",
1197
- complete: true,
1198
1204
  notes: [
1199
1205
  "TODO(open-decision): the escalation transition is a flagged placeholder \u2014 a GET carrying params is not a real request shape (FE doc Screen 11 + open question 7).",
1200
1206
  "The terminal REJECTED variant (failureReason + transitions []) is described in prose, not published as JSON \u2014 no fixture."
@@ -1207,7 +1213,6 @@ var FIXTURES = [
1207
1213
  docRef: "Screen 12 \xB7 Order in progress",
1208
1214
  screen: 12,
1209
1215
  stateKey: "PENDING_ORDER",
1210
- complete: true,
1211
1216
  notes: [
1212
1217
  "TODO(open-decision): PENDING_ORDER removal proposed; Screen 12 frozen. Kept so 0.1.0 can validate the current shape."
1213
1218
  ]
@@ -1218,8 +1223,7 @@ var FIXTURES = [
1218
1223
  source: "FE_DOC",
1219
1224
  docRef: "Screen 13 \xB7 Success",
1220
1225
  screen: 13,
1221
- stateKey: "ORDER/CREATED",
1222
- complete: true
1226
+ stateKey: "ORDER/CREATED"
1223
1227
  },
1224
1228
  {
1225
1229
  id: "screen-14-order-failed",
@@ -1228,7 +1232,6 @@ var FIXTURES = [
1228
1232
  docRef: "Screen 14 \xB7 Something went wrong",
1229
1233
  screen: 14,
1230
1234
  stateKey: "ORDER/CREATED",
1231
- complete: true,
1232
1235
  notes: ["Terminal status (FAILED) still carrying a recovery transition \u2014 retryable failureReason."]
1233
1236
  },
1234
1237
  {
@@ -1237,8 +1240,7 @@ var FIXTURES = [
1237
1240
  source: "FE_DOC",
1238
1241
  docRef: "Screen 15 \xB7 Refund on the way",
1239
1242
  screen: 15,
1240
- stateKey: "ORDER/CREATED",
1241
- complete: true
1243
+ stateKey: "ORDER/CREATED"
1242
1244
  },
1243
1245
  {
1244
1246
  id: "screen-16-blocked",
@@ -1247,7 +1249,6 @@ var FIXTURES = [
1247
1249
  docRef: "Screen 16 \xB7 Not available",
1248
1250
  screen: 16,
1249
1251
  stateKey: "BLOCKED",
1250
- complete: true,
1251
1252
  notes: ["The only flatly terminal state entry in the table."]
1252
1253
  },
1253
1254
  {
@@ -1256,7 +1257,6 @@ var FIXTURES = [
1256
1257
  source: "CONTRACT_DOC",
1257
1258
  docRef: "\xA7Worked example, step 1 (method selection)",
1258
1259
  stateKey: "QUOTE",
1259
- complete: false,
1260
1260
  notes: ["Carries inputs {email: {type: TEXT}} \u2014 the collected-spec half of the split."]
1261
1261
  },
1262
1262
  {
@@ -1264,8 +1264,7 @@ var FIXTURES = [
1264
1264
  file: "worked-example-02-session-auth.json",
1265
1265
  source: "CONTRACT_DOC",
1266
1266
  docRef: "\xA7Worked example, step 2 (session challenge)",
1267
- stateKey: "SESSION_AUTH",
1268
- complete: false
1267
+ stateKey: "SESSION_AUTH"
1269
1268
  },
1270
1269
  {
1271
1270
  id: "worked-example-04-payment-instruct",
@@ -1273,7 +1272,6 @@ var FIXTURES = [
1273
1272
  source: "CONTRACT_DOC",
1274
1273
  docRef: "\xA7Worked example, steps 4\u20135 (order + bank instructions)",
1275
1274
  stateKey: "PAYMENT/INSTRUCT",
1276
- complete: false,
1277
1275
  notes: [
1278
1276
  "The envelope-sibling `orderId` case. TODO(open-decision): orderId placement (envelope sibling here vs inside state in the FE doc v0)."
1279
1277
  ]
@@ -1322,17 +1320,11 @@ var formatIssues = (error) => {
1322
1320
  return path ? `${path}: ${issue.message ?? "invalid"}` : issue.message ?? "invalid";
1323
1321
  });
1324
1322
  };
1325
- function assertEnvelope(json) {
1326
- const result = StepResponseSchema.safeParse(json);
1323
+ function assertFiatStepResponse(json) {
1324
+ const result = FiatStepResponseSchema.safeParse(json);
1327
1325
  if (!result.success) throw new ContractViolation("invalid envelope", formatIssues(result.error));
1328
1326
  return result.data;
1329
1327
  }
1330
- function assertEnvelopeFragment(json) {
1331
- const result = StepResponseFragmentSchema.safeParse(json);
1332
- if (!result.success)
1333
- throw new ContractViolation("invalid envelope fragment", formatIssues(result.error));
1334
- return result.data;
1335
- }
1336
1328
  function endpointMatches(template, actual) {
1337
1329
  const pattern = template.split("/").map((segment) => segment.startsWith(":") ? "[^/]+" : escapeRegExp(segment)).join("/");
1338
1330
  return new RegExp(`^${pattern}$`).test(actual);
@@ -1427,7 +1419,7 @@ function walkTable(fn) {
1427
1419
  function assertFixture(id) {
1428
1420
  const meta = fixtureMeta(id);
1429
1421
  const json = loadFixture(id);
1430
- const envelope = meta.complete ? assertEnvelope(json) : assertEnvelopeFragment(json);
1422
+ const envelope = assertFiatStepResponse(json);
1431
1423
  if (stateKey(envelope.state) !== meta.stateKey) {
1432
1424
  throw new ContractViolation(
1433
1425
  `fixture ${id}: manifest says ${meta.stateKey}, envelope carries ${stateKey(envelope.state)}`
@@ -1449,7 +1441,6 @@ export {
1449
1441
  CryptoAmountSchema,
1450
1442
  DOCUMENTED_ENDPOINTS,
1451
1443
  ENDPOINT_PATTERN,
1452
- EnvelopeSchema,
1453
1444
  FIAT_CURRENCY_NAMES,
1454
1445
  FIELD_SPEC_TYPES,
1455
1446
  FIXTURES,
@@ -1460,6 +1451,7 @@ export {
1460
1451
  FiatCurrencyCode,
1461
1452
  FiatEndpointSchema,
1462
1453
  FiatProviderSchema,
1454
+ FiatStepResponseSchema,
1463
1455
  FieldSpecSchema,
1464
1456
  FlowStateSchema,
1465
1457
  FormDescriptorSchema,
@@ -1493,8 +1485,6 @@ export {
1493
1485
  SelectOptionSchema,
1494
1486
  SessionAuthStateSchema,
1495
1487
  StatusHistoryEntrySchema,
1496
- StepResponseFragmentSchema,
1497
- StepResponseSchema,
1498
1488
  SurfaceSchema,
1499
1489
  TABLE_VERSION,
1500
1490
  TERMINAL_ORDER_STATUSES,
@@ -1504,8 +1494,7 @@ export {
1504
1494
  TransitionSchema,
1505
1495
  TxSchema,
1506
1496
  assertAllFixtures,
1507
- assertEnvelope,
1508
- assertEnvelopeFragment,
1497
+ assertFiatStepResponse,
1509
1498
  assertFixture,
1510
1499
  assertLegalEmission,
1511
1500
  assertLegalReturn,
package/dist/schemas.d.ts CHANGED
@@ -27,11 +27,7 @@
27
27
  * They are module-private, so none of it reaches the published declarations.
28
28
  */
29
29
  import { z } from 'zod';
30
- import type { CryptoAmount, FailureReason, FeeLine, FiatEndpoint, FieldSpec, FlowState, FormDescriptor, FormField, FormFieldType, Instructions, InstructionField, Instrument, JsonValue, FiatAmount, OrderRef, OrderStatus, OrderSummary, PollSpec, FiatProvider, Quote, Recovery, Refund, ReportSpec, SelectOption, StatusHistoryEntry, StepResponse, Surface, Transition, TransitionInputs, TransitionParams, Tx } from './types';
31
- /** The envelope as doc excerpts show it: `provider` elided. Never valid on the wire. */
32
- export type EnvelopeFragment = Omit<StepResponse, 'provider'> & {
33
- provider?: FiatProvider;
34
- };
30
+ import type { CryptoAmount, FailureReason, FeeLine, FiatEndpoint, FieldSpec, FlowState, FormDescriptor, FormField, FormFieldType, Instructions, InstructionField, Instrument, JsonValue, FiatAmount, OrderRef, OrderStatus, OrderSummary, PollSpec, FiatProvider, Quote, Recovery, Refund, ReportSpec, SelectOption, StatusHistoryEntry, FiatStepResponse, Surface, Transition, TransitionInputs, TransitionParams, Tx } from './types';
35
31
  export declare const JsonSchema: z.ZodType<JsonValue>;
36
32
  export declare const HTTP_VERBS: readonly ["GET", "POST"];
37
33
  /** `"POST /fiat/session/verify"`, `"GET /fiat/orders/o_31c"` — the verb rides the string. */
@@ -91,8 +87,6 @@ export declare const PaymentInstructStateSchema: z.ZodType<State<'PAYMENT', 'INS
91
87
  export declare const OrderAwaitingConfirmationStateSchema: z.ZodType<State<'ORDER', 'AWAITING_CONFIRMATION'>>;
92
88
  export declare const OrderCreatedStateSchema: z.ZodType<State<'ORDER', 'CREATED'>>;
93
89
  export declare const FlowStateSchema: z.ZodType<FlowState>;
94
- export declare const StepResponseSchema: z.ZodType<StepResponse>;
95
- export declare const StepResponseFragmentSchema: z.ZodType<EnvelopeFragment>;
96
- export declare const EnvelopeSchema: z.ZodType<StepResponse>;
90
+ export declare const FiatStepResponseSchema: z.ZodType<FiatStepResponse>;
97
91
  export {};
98
92
  //# sourceMappingURL=schemas.d.ts.map
package/dist/table.d.ts CHANGED
@@ -20,9 +20,11 @@
20
20
  import type { FiatEndpoint, FlowState, OrderStatus, TransitionMode } from './types';
21
21
  /**
22
22
  * The package version IS the capability handshake's table version (contract §Conformance
23
- * package → Versioning). Must equal `package.json#version` asserted in the test suite.
23
+ * package → Versioning). Derived from `package.json#version` at build time so the two can
24
+ * never drift — a version bump is one edit, in package.json. The test suite asserts the
25
+ * wiring still resolves to the package version.
24
26
  */
25
- export declare const TABLE_VERSION = "0.4.0";
27
+ export declare const TABLE_VERSION: string;
26
28
  /**
27
29
  * State identity = kind + phase + the state's named discriminant (FE doc §Decisions).
28
30
  * The table keys on kind + phase (+ `reason` for KYC, whose three reasons differ in terminality
package/dist/table.js CHANGED
@@ -30,7 +30,12 @@ __export(table_exports, {
30
30
  tableEntry: () => tableEntry
31
31
  });
32
32
  module.exports = __toCommonJS(table_exports);
33
- var TABLE_VERSION = "0.4.0";
33
+
34
+ // package.json
35
+ var version = "0.5.0";
36
+
37
+ // src/table.ts
38
+ var TABLE_VERSION = version;
34
39
  var TERMINAL_ORDER_STATUSES = [
35
40
  "SETTLED",
36
41
  "EXPIRED",
package/dist/table.mjs CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  isTerminal,
8
8
  stateKey,
9
9
  tableEntry
10
- } from "./chunk-VLQLWQPK.mjs";
10
+ } from "./chunk-3R5GGUAK.mjs";
11
11
  export {
12
12
  DOCUMENTED_ENDPOINTS,
13
13
  STATE_KEYS,
package/dist/types.d.ts CHANGED
@@ -64,7 +64,7 @@ export type FiatEndpoint = 'GET /fiat/payment-methods' | 'POST /fiat/quote' | 'P
64
64
  * (contract §Decisions 3; Swapped-headless does not exist yet, Banxa is Tradeoff #4).
65
65
  * `SWAPPED` added at fun-backend's request (2026-08-17) so its legacy fops payment-provider id
66
66
  * has one shared vocabulary instead of a second fun-backend-local enum — it never appears as a
67
- * `StepResponse.provider` value since Swapped's fops flow doesn't emit this envelope.
67
+ * `FiatStepResponse.provider` value since Swapped's fops flow doesn't emit this envelope.
68
68
  */
69
69
  export type FiatProvider = 'TRANSAK' | 'SWAPPED';
70
70
  /**
@@ -311,12 +311,45 @@ export type FieldSpecType = FieldSpec['type'];
311
311
  export type TransitionParams = Record<string, JsonValue>;
312
312
  /** Client-collected specs — the harness renders + locally validates, then merges values in. */
313
313
  export type TransitionInputs = Record<string, FieldSpec>;
314
+ /**
315
+ * Timing for an `AWAIT` poll.
316
+ *
317
+ * The numbers are bounded by `PollSpecSchema`, not by the type — TypeScript can't express a range.
318
+ * The bounds are part of the contract, enforced on the emitter by conformance tests, so a client
319
+ * can consume these fields directly and does not need to sanitize them:
320
+ *
321
+ * | field | bound |
322
+ * | --- | --- |
323
+ * | `intervalMs` | integer, `500 … 2^31-1` |
324
+ * | `backoffFactor` | finite, `>= 1` (**not** an integer — 1.5 is the recorded value) |
325
+ * | `maxIntervalMs` | integer, `500 … 2^31-1` |
326
+ * | `giveUpAfterMs` | integer, `0 … 2^31-1` |
327
+ *
328
+ * `2^31-1` is the ceiling because a larger `setTimeout` delay is silently truncated to 0, which
329
+ * hot-loops exactly as `intervalMs: 0` would.
330
+ *
331
+ * Two cross-field rules, both enforced by the schema:
332
+ *
333
+ * 1. **`maxIntervalMs` is required whenever `backoffFactor` is present.** A backoff with no ceiling
334
+ * reaches that same truncation by a slower route. The factor itself is deliberately not capped —
335
+ * only 1.5 has ever been recorded, so any cap would be arbitrary, whereas requiring the ceiling
336
+ * that already exists is not.
337
+ * 2. **`maxIntervalMs >= intervalMs`.** A ceiling below the starting interval caps the poll at less
338
+ * than its own first delay.
339
+ *
340
+ * Note there is deliberately no `giveUpAfterMs >= intervalMs` rule: a deadline shorter than the
341
+ * first interval means "give up without polling", which is coherent and legitimately emittable.
342
+ */
314
343
  export interface PollSpec {
315
344
  endpoint: FiatEndpoint;
316
345
  params?: TransitionParams;
346
+ /** Integer, `500 … 2^31-1`. */
317
347
  intervalMs: number;
348
+ /** Finite, `>= 1`. Not constrained to an integer: 1.5 is legitimate. Requires `maxIntervalMs`. */
318
349
  backoffFactor?: number;
350
+ /** Integer, `500 … 2^31-1`. Required when `backoffFactor` is present. */
319
351
  maxIntervalMs?: number;
352
+ /** Integer, `0 … 2^31-1`. */
320
353
  giveUpAfterMs?: number;
321
354
  }
322
355
  export interface ReportSpec {
@@ -449,7 +482,7 @@ export type FlowState = {
449
482
  /**
450
483
  * TODO(open-decision): `orderId` placement — envelope sibling (contract §Worked example,
451
484
  * steps 4–5) vs inside state (FE doc v0, Screen 6). Both are accepted here; the FE doc's
452
- * in-state placement is the current shape, `StepResponse.orderId` covers the other.
485
+ * in-state placement is the current shape, `FiatStepResponse.orderId` covers the other.
453
486
  */
454
487
  orderId?: string;
455
488
  instructions: Instructions;
@@ -476,13 +509,11 @@ export type FlowStateKind = FlowState['kind'];
476
509
  * Every `/fiat/*` response. `transitions: []` = nothing the wizard can do; whether the *flow*
477
510
  * is over is read from the table (`terminal`), never from array emptiness (ON_HOLD is the proof).
478
511
  */
479
- export interface StepResponse {
512
+ export interface FiatStepResponse {
480
513
  state: FlowState;
481
514
  provider: FiatProvider;
482
515
  transitions: Transition[];
483
516
  /** TODO(open-decision): `orderId` placement — see `PAYMENT{INSTRUCT}.orderId` above. */
484
517
  orderId?: string;
485
518
  }
486
- /** The envelope as it appears in the docs' JSON — alias kept because both names are in use. */
487
- export type Envelope = StepResponse;
488
519
  //# sourceMappingURL=types.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fun-xyz/fiat-contract",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Published conformance contract for Fun's headless fiat onramp: FlowState/Transition types, zod schemas, the transition table as data, recorded fixtures, assertion helpers.",
5
5
  "license": "MIT",
6
6
  "repository": {