@tokenflight/fiat-testkit 0.0.1-rc.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/LICENSE.md ADDED
@@ -0,0 +1,52 @@
1
+ # TokenFlight SDK License
2
+
3
+ Copyright © 2026 Khalani Labs, Ltd. All rights reserved.
4
+
5
+ This software and its accompanying documentation (the "Software") are the
6
+ proprietary property of Khalani Labs, Ltd. ("Khalani").
7
+
8
+ ## Grant
9
+
10
+ Subject to the terms below, Khalani grants you a limited, non-exclusive,
11
+ non-transferable, non-sublicensable, revocable license to install and use the
12
+ Software, in unmodified form only, solely to integrate your applications with
13
+ TokenFlight and Hyperstream services operated by or with the authorization of
14
+ Khalani.
15
+
16
+ ## Restrictions
17
+
18
+ Except as expressly permitted above or required by applicable law, you may not:
19
+
20
+ - modify, adapt, translate, or create derivative works of the Software;
21
+ - distribute, sublicense, rent, lease, sell, or otherwise transfer the
22
+ Software or any copy of it, other than as an unmodified dependency bundled
23
+ into your application;
24
+ - reverse engineer, decompile, or disassemble the Software, except to the
25
+ extent such restriction is prohibited by applicable law;
26
+ - remove or alter any proprietary notices in the Software;
27
+ - use the Software to build a product or service that competes with
28
+ TokenFlight or Hyperstream services.
29
+
30
+ ## Ownership
31
+
32
+ The Software is licensed, not sold. Khalani retains all right, title, and
33
+ interest in and to the Software, including all intellectual property rights.
34
+
35
+ ## Termination
36
+
37
+ This license terminates automatically if you breach any of its terms. Upon
38
+ termination you must cease use of the Software and destroy all copies in your
39
+ possession. Khalani may discontinue the Software or this license at any time.
40
+
41
+ ## Disclaimer of Warranty
42
+
43
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
44
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
45
+ FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
46
+
47
+ ## Limitation of Liability
48
+
49
+ TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL KHALANI
50
+ BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF
51
+ CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE
52
+ SOFTWARE OR THE USE OF OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # @tokenflight/fiat-testkit
2
+
3
+ Build and verify `@tokenflight/fiat` extensions without real provider credentials.
4
+
5
+ - **`mockProvider(options?)`** — a complete reference `FiatProviderAdapter` (quotes, orders, signed webhooks). Use it as the template for a new provider, or to drive full quote → order → webhook flows in your own tests.
6
+ - **`createMockWebhook(secret, event)`** — signed (or deliberately tampered) webhook payloads for idempotency and verification tests.
7
+ - **`runProviderConformance(adapter, options)`** — checks a provider adapter against the contracts the SDK core relies on: RAW-integer quote amounts, future expiry, typed unsupported-asset rejection, action-matches-capabilities, webhook verify never throws / rejects tampered bodies, and more.
8
+ - **`runStateStoreConformance(makeStore)`** — checks a custom state store's concurrency semantics: version CAS conflicts, nonce replay, webhook dedup, job leases, `listOrders` paging.
9
+
10
+ ```ts
11
+ import { runProviderConformance, failedChecks } from "@tokenflight/fiat-testkit";
12
+
13
+ const results = await runProviderConformance(myProvider(), { asset: { chainId: 8453, address: "0x…" } });
14
+ expect(failedChecks(results)).toEqual([]);
15
+ ```
16
+
17
+ Framework-free: runners return `CheckResult[]` instead of asserting, so they work under vitest, jest, or a plain script.
18
+
19
+ © Khalani Labs, Ltd. All rights reserved.
@@ -0,0 +1,15 @@
1
+ export interface CheckResult {
2
+ name: string;
3
+ group: "provider" | "store";
4
+ ok: boolean;
5
+ /** True when the check could not run (missing optional method / fixture). Counts as ok. */
6
+ skipped?: boolean;
7
+ detail?: string;
8
+ }
9
+ export declare function runCheck(name: string, group: CheckResult["group"], fn: () => Promise<string | {
10
+ skip: string;
11
+ } | void>): Promise<CheckResult>;
12
+ export declare function checkAssert(condition: unknown, message: string): asserts condition;
13
+ /** Convenience for test runners: the failed checks (skips count as passing). */
14
+ export declare function failedChecks(results: CheckResult[]): CheckResult[];
15
+ //# sourceMappingURL=check.d.ts.map
package/dist/check.js ADDED
@@ -0,0 +1,33 @@
1
+ //#region src/check.ts
2
+ async function e(e, t, n) {
3
+ try {
4
+ let r = await n();
5
+ return r && typeof r == "object" && "skip" in r ? {
6
+ name: e,
7
+ group: t,
8
+ ok: !0,
9
+ skipped: !0,
10
+ detail: r.skip
11
+ } : {
12
+ name: e,
13
+ group: t,
14
+ ok: !0,
15
+ ...typeof r == "string" ? { detail: r } : {}
16
+ };
17
+ } catch (n) {
18
+ return {
19
+ name: e,
20
+ group: t,
21
+ ok: !1,
22
+ detail: n instanceof Error ? n.message : String(n)
23
+ };
24
+ }
25
+ }
26
+ function t(e, t) {
27
+ if (!e) throw Error(t);
28
+ }
29
+ function n(e) {
30
+ return e.filter((e) => !e.ok);
31
+ }
32
+ //#endregion
33
+ export { t as checkAssert, n as failedChecks, e as runCheck };
@@ -0,0 +1,5 @@
1
+ export { mockProvider, createMockWebhook, type MockProviderOptions } from './mock-provider.js';
2
+ export { runProviderConformance, type ProviderConformanceOptions, type WebhookSample } from './provider-conformance.js';
3
+ export { runStateStoreConformance, buildTestOrder, type StoreConformanceOptions } from './store-conformance.js';
4
+ export { checkAssert, failedChecks, runCheck, type CheckResult } from './check.js';
5
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ import { createMockWebhook as e, mockProvider as t } from "./mock-provider.js";
2
+ import { checkAssert as n, failedChecks as r, runCheck as i } from "./check.js";
3
+ import { runProviderConformance as a } from "./provider-conformance.js";
4
+ import { buildTestOrder as o, runStateStoreConformance as s } from "./store-conformance.js";
5
+ export { o as buildTestOrder, n as checkAssert, e as createMockWebhook, r as failedChecks, t as mockProvider, i as runCheck, a as runProviderConformance, s as runStateStoreConformance };
@@ -0,0 +1,37 @@
1
+ import { FiatAssetRef, FiatProviderAdapter } from '@tokenflight/fiat';
2
+ export interface MockProviderOptions {
3
+ /** Provider id (default "mock") — lets a test register several distinct mock providers. */
4
+ id?: string;
5
+ /** Human-facing display name; unset exercises the SDK's id fallback. */
6
+ displayName?: string;
7
+ webhookSecret?: string;
8
+ usdPerToken?: number;
9
+ jumpOnlyAssets?: FiatAssetRef[];
10
+ /** Assets the provider rejects outright with ProviderUnsupportedAssetError. */
11
+ unsupportedAssets?: FiatAssetRef[];
12
+ decimals?: number;
13
+ widgetOrigin?: string;
14
+ settles?: boolean;
15
+ /** Widget/session lifetime in seconds (negative = already expired). Default 3600. */
16
+ widgetExpiresInSeconds?: number;
17
+ /** Delivery tx hash reported on polled delivered statuses (webhooks carry their own). */
18
+ transferTxHash?: string;
19
+ /** Artificial quote latency, for exercising fan-out timeouts. */
20
+ quoteDelayMs?: number;
21
+ }
22
+ export declare function mockProvider(options?: MockProviderOptions): FiatProviderAdapter;
23
+ export declare function createMockWebhook(secret: string | undefined, body: {
24
+ partnerOrderId: string;
25
+ providerOrderId?: string;
26
+ status: string;
27
+ eventId?: string;
28
+ deliveredAmount?: string;
29
+ providerCryptoAmount?: string;
30
+ transferTxHash?: string;
31
+ reason?: string;
32
+ walletAddress?: string;
33
+ }): Promise<{
34
+ rawBody: string;
35
+ headers: Record<string, string>;
36
+ }>;
37
+ //# sourceMappingURL=mock-provider.d.ts.map
@@ -0,0 +1,171 @@
1
+ import { ProviderUnsupportedAssetError as e, fromRaw as t, isSameAsset as n, toRaw as r } from "@tokenflight/fiat";
2
+ //#region src/mock-provider.ts
3
+ var i = new TextEncoder();
4
+ function a(e) {
5
+ let t = "";
6
+ for (let n of e) t += n.toString(16).padStart(2, "0");
7
+ return t;
8
+ }
9
+ async function o(e, t) {
10
+ let n = await crypto.subtle.importKey("raw", i.encode(e), {
11
+ name: "HMAC",
12
+ hash: "SHA-256"
13
+ }, !1, ["sign"]), r = await crypto.subtle.sign("HMAC", n, i.encode(t));
14
+ return a(new Uint8Array(r));
15
+ }
16
+ async function s(e) {
17
+ let t = await crypto.subtle.digest("SHA-256", i.encode(e));
18
+ return a(new Uint8Array(t));
19
+ }
20
+ function c(e, t) {
21
+ if (e.length !== t.length) return !1;
22
+ let n = 0;
23
+ for (let r = 0; r < e.length; r++) n |= e.charCodeAt(r) ^ t.charCodeAt(r);
24
+ return n === 0;
25
+ }
26
+ async function l(e, t, n) {
27
+ return c(await o(e, t), n);
28
+ }
29
+ var u = {
30
+ PENDING: "pending",
31
+ PROCESSING: "processing",
32
+ COMPLETED: "delivered",
33
+ FAILED: "failed",
34
+ CANCELLED: "canceled",
35
+ EXPIRED: "expired",
36
+ REFUNDED: "refunded"
37
+ };
38
+ function d(i = {}) {
39
+ let a = i.id ?? "mock", o = i.usdPerToken ?? 1, c = i.decimals ?? 6, d = i.settles ?? !0, f = i.widgetOrigin ?? "https://mock.widget.test", p = i.widgetExpiresInSeconds ?? 3600;
40
+ function m(e) {
41
+ return r(e, c);
42
+ }
43
+ function h(e) {
44
+ return t(e, c);
45
+ }
46
+ let g = {
47
+ quoteKinds: ["estimate", "firm"],
48
+ orderActions: ["wrapper"],
49
+ supportsWebhook: !0,
50
+ supportsStatusPolling: !0,
51
+ supportedFiatCurrencies: ["USD", "EUR"],
52
+ supportedPaymentMethods: ["credit_debit_card"],
53
+ supportedAssets: { mode: "dynamic" },
54
+ requirements: {
55
+ origin: "optional",
56
+ endUserIp: "optional"
57
+ }
58
+ }, _ = {
59
+ allowedOrigins: [f],
60
+ connectSrcDomains: [new URL(f).host],
61
+ permissionsFeatures: ["payment"]
62
+ };
63
+ function v(e, t) {
64
+ return {
65
+ providerOrderId: e,
66
+ ...t ? { partnerOrderId: t } : {},
67
+ state: "delivered",
68
+ rawStatus: "COMPLETED",
69
+ ...i.transferTxHash ? { transferTxHash: i.transferTxHash } : {}
70
+ };
71
+ }
72
+ return {
73
+ id: a,
74
+ ...i.displayName ? { display: { name: i.displayName } } : {},
75
+ getCapabilities: () => Promise.resolve(g),
76
+ widgetProfile: () => _,
77
+ canBuyAsset(e) {
78
+ let t = i.unsupportedAssets?.some((t) => n(t, e)) ?? !1, r = i.jumpOnlyAssets?.some((t) => n(t, e)) ?? !1;
79
+ return Promise.resolve(!t && !r);
80
+ },
81
+ async quote(t) {
82
+ if (i.unsupportedAssets?.some((e) => n(e, t.asset))) throw new e({
83
+ provider: a,
84
+ asset: t.asset
85
+ });
86
+ i.quoteDelayMs && await new Promise((e) => setTimeout(e, i.quoteDelayMs));
87
+ let r, s;
88
+ if (t.fiatAmount !== void 0) {
89
+ let e = Number(t.fiatAmount);
90
+ r = e.toFixed(2), s = m(e / o);
91
+ } else t.cryptoAmount === void 0 ? (r = "0.00", s = "0") : (r = (h(t.cryptoAmount) * o).toFixed(2), s = t.cryptoAmount);
92
+ return {
93
+ provider: a,
94
+ kind: t.kind,
95
+ fiatCurrency: t.fiatCurrency,
96
+ fiatAmount: r,
97
+ totalFee: "0.00",
98
+ cryptoAmount: s,
99
+ asset: t.asset,
100
+ expiresAt: Math.floor(Date.now() / 1e3) + 300,
101
+ paymentMethods: [{
102
+ id: "credit_debit_card",
103
+ name: "Credit/Debit Card"
104
+ }],
105
+ ...t.kind === "firm" ? { providerQuoteId: `mockq_${s}` } : {}
106
+ };
107
+ },
108
+ createOrder(e) {
109
+ let t = `mock_${e.orderId}`, n = `${f}/checkout?order=${encodeURIComponent(e.orderId)}`;
110
+ return Promise.resolve({
111
+ action: {
112
+ type: "wrapper",
113
+ url: n
114
+ },
115
+ widgetUrl: n,
116
+ allowedProviderOrigins: [f],
117
+ references: [{
118
+ provider: a,
119
+ type: "partner_order_id",
120
+ value: e.orderId
121
+ }, ...d ? [{
122
+ provider: a,
123
+ type: "order_id",
124
+ value: t
125
+ }] : []],
126
+ expiresAt: Math.floor(Date.now() / 1e3) + p
127
+ });
128
+ },
129
+ getOrderStatus(e) {
130
+ return Promise.resolve(d ? v(e) : null);
131
+ },
132
+ getOrderStatusByPartnerOrderId(e) {
133
+ return Promise.resolve(d ? v(`mock_${e}`, e) : null);
134
+ },
135
+ async verifyWebhook(e) {
136
+ if (i.webhookSecret) {
137
+ let t = e.headers["x-mock-signature"];
138
+ if (!t || !await l(i.webhookSecret, e.rawBody, t)) return null;
139
+ }
140
+ let t;
141
+ try {
142
+ t = JSON.parse(e.rawBody);
143
+ } catch {
144
+ return null;
145
+ }
146
+ let n = u[t.status];
147
+ return n ? {
148
+ providerOrderId: t.providerOrderId ?? `mock_${t.partnerOrderId}`,
149
+ partnerOrderId: t.partnerOrderId,
150
+ state: n,
151
+ rawStatus: t.status,
152
+ eventType: `MOCK_${t.status}`,
153
+ eventId: t.eventId ?? await s(e.rawBody),
154
+ ...t.deliveredAmount === void 0 ? {} : { deliveredAmount: t.deliveredAmount },
155
+ ...t.providerCryptoAmount === void 0 ? {} : { providerCryptoAmount: t.providerCryptoAmount },
156
+ ...t.transferTxHash === void 0 ? {} : { transferTxHash: t.transferTxHash },
157
+ ...t.reason === void 0 ? {} : { reason: t.reason },
158
+ ...t.walletAddress === void 0 ? {} : { walletAddress: t.walletAddress }
159
+ } : "ignore";
160
+ }
161
+ };
162
+ }
163
+ async function f(e, t) {
164
+ let n = JSON.stringify(t), r = { "content-type": "application/json" };
165
+ return e && (r["x-mock-signature"] = await o(e, n)), {
166
+ rawBody: n,
167
+ headers: r
168
+ };
169
+ }
170
+ //#endregion
171
+ export { f as createMockWebhook, d as mockProvider };
@@ -0,0 +1,28 @@
1
+ import { FiatProviderAdapter, FiatAssetRef, FiatRequestContext, ProviderOrderState } from '@tokenflight/fiat';
2
+ import { CheckResult } from './check.js';
3
+ export interface WebhookSample {
4
+ rawBody: string;
5
+ headers: Record<string, string>;
6
+ }
7
+ export interface ProviderConformanceOptions {
8
+ /** An asset the adapter can price. */
9
+ supportedAsset: FiatAssetRef;
10
+ /** Delivery wallet used for quotes/orders. */
11
+ recipient: string;
12
+ /** An asset the adapter must reject (skips the rejection check when omitted). */
13
+ unsupportedAsset?: FiatAssetRef;
14
+ /** Request context (origin / endUserIp / country) satisfying the adapter's requirements. */
15
+ request?: FiatRequestContext;
16
+ fiatCurrency?: string;
17
+ fiatAmount?: string;
18
+ /** Set false to skip order-creation checks (e.g. against credentials that create real sessions). */
19
+ createOrders?: boolean;
20
+ /** Signed webhook samples: `valid` must parse; `tampered` must be rejected with null. */
21
+ webhook?: {
22
+ valid: WebhookSample;
23
+ expectState?: ProviderOrderState;
24
+ tampered?: WebhookSample;
25
+ };
26
+ }
27
+ export declare function runProviderConformance(adapter: FiatProviderAdapter, options: ProviderConformanceOptions): Promise<CheckResult[]>;
28
+ //# sourceMappingURL=provider-conformance.d.ts.map
@@ -0,0 +1,128 @@
1
+ import { checkAssert as e, runCheck as t } from "./check.js";
2
+ //#region src/provider-conformance.ts
3
+ var n = /* @__PURE__ */ new Set([
4
+ "pending",
5
+ "processing",
6
+ "delivered",
7
+ "failed",
8
+ "canceled",
9
+ "expired",
10
+ "refunded"
11
+ ]);
12
+ function r(e) {
13
+ return /^\d+$/.test(e);
14
+ }
15
+ async function i(i, o) {
16
+ let s = o.request ?? {
17
+ origin: "https://conformance.test",
18
+ endUserIp: "192.0.2.1"
19
+ }, c = {
20
+ request: s,
21
+ now: Date.now()
22
+ }, l = o.fiatCurrency ?? "USD", u = o.fiatAmount ?? "100", d = [], f = async (e, n) => {
23
+ d.push(await t(e, "provider", n));
24
+ };
25
+ await f("capabilities are well-formed", async () => {
26
+ let t = await i.getCapabilities(c);
27
+ return e(t.quoteKinds.length > 0, "quoteKinds must be non-empty"), e(t.supportedFiatCurrencies.length > 0, "supportedFiatCurrencies must be non-empty"), e([
28
+ "static",
29
+ "dynamic",
30
+ "hybrid"
31
+ ].includes(t.supportedAssets.mode), `unknown supportedAssets.mode ${t.supportedAssets.mode}`), `kinds=[${t.quoteKinds.join(",")}], assets=${t.supportedAssets.mode}`;
32
+ }), await f("widget profile origins are absolute", async () => {
33
+ let t = i.widgetProfile();
34
+ e(t.allowedOrigins.length > 0, "allowedOrigins must be non-empty");
35
+ for (let n of t.allowedOrigins) e(new URL(n).origin === n, `allowedOrigins entry "${n}" is not a bare origin`);
36
+ }), await f("adapter declares display metadata", async () => i.display?.name ? `display.name=${i.display.name}` : { skip: "no display — quotes will fall back to the provider id (recommended: set display.name)" }), await f("canBuyAsset accepts the supported asset", async () => {
37
+ e(await i.canBuyAsset(o.supportedAsset, c), "canBuyAsset returned false for the declared supported asset");
38
+ }), await f("fiat-input quote returns RAW base units and a future expiry", async () => {
39
+ let t = await i.quote({
40
+ kind: "firm",
41
+ inputMode: "fiat",
42
+ fiatCurrency: l,
43
+ fiatAmount: u,
44
+ asset: o.supportedAsset,
45
+ recipient: o.recipient
46
+ }, c);
47
+ return e(t.provider === i.id, `quote.provider (${t.provider}) must echo the adapter id (${i.id})`), e(r(t.cryptoAmount), `cryptoAmount "${t.cryptoAmount}" must be RAW base units (base-10 integer string)`), e(BigInt(t.cryptoAmount) > 0n, "cryptoAmount must be positive"), e(!Number.isNaN(Number(t.fiatAmount)), `fiatAmount "${t.fiatAmount}" must be a decimal string`), e(t.expiresAt * 1e3 > c.now, "expiresAt (epoch seconds) must be in the future"), `cryptoAmount=${t.cryptoAmount}`;
48
+ }), await f("crypto-input quote round-trips RAW base units", async () => {
49
+ let t = await i.quote({
50
+ kind: "firm",
51
+ inputMode: "fiat",
52
+ fiatCurrency: l,
53
+ fiatAmount: u,
54
+ asset: o.supportedAsset,
55
+ recipient: o.recipient
56
+ }, c), n = await i.quote({
57
+ kind: "firm",
58
+ inputMode: "crypto",
59
+ fiatCurrency: l,
60
+ cryptoAmount: t.cryptoAmount,
61
+ asset: o.supportedAsset,
62
+ recipient: o.recipient
63
+ }, c);
64
+ e(r(n.cryptoAmount), `cryptoAmount "${n.cryptoAmount}" must be RAW base units`), e(!Number.isNaN(Number(n.fiatAmount)), `fiatAmount "${n.fiatAmount}" must be a decimal string`);
65
+ }), await f("unsupported asset is rejected with a typed error", async () => {
66
+ if (!o.unsupportedAsset) return { skip: "no unsupportedAsset fixture provided" };
67
+ try {
68
+ await i.quote({
69
+ kind: "firm",
70
+ inputMode: "fiat",
71
+ fiatCurrency: l,
72
+ fiatAmount: u,
73
+ asset: o.unsupportedAsset,
74
+ recipient: o.recipient
75
+ }, c);
76
+ } catch (t) {
77
+ let n = t.name ?? "", r = t.code ?? "";
78
+ e(n === "ProviderUnsupportedAssetError" || r === "UNSUPPORTED_ASSET", `expected ProviderUnsupportedAssetError/UNSUPPORTED_ASSET, got ${n || r || String(t)}`);
79
+ return;
80
+ }
81
+ throw Error("quote for the unsupported asset did not throw");
82
+ });
83
+ let p = `tk_order_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
84
+ return o.createOrders === !1 ? d.push({
85
+ name: "createOrder returns a well-formed action",
86
+ group: "provider",
87
+ ok: !0,
88
+ skipped: !0,
89
+ detail: "createOrders=false"
90
+ }) : (await f("createOrder returns a well-formed action", async () => {
91
+ let t = await i.getCapabilities(c), n = await i.createOrder({
92
+ orderId: p,
93
+ inputMode: "fiat",
94
+ fiatCurrency: l,
95
+ fiatAmount: u,
96
+ asset: o.supportedAsset,
97
+ recipient: o.recipient,
98
+ referrerDomain: new URL(s.origin ?? "https://conformance.test").host
99
+ }, c);
100
+ return e(t.orderActions.includes(n.action.type), `action.type ${n.action.type} is not in capabilities.orderActions`), e(Array.isArray(n.references), "references must be an array"), n.action.type === "wrapper" && e(!!n.widgetUrl, "a wrapper action must carry widgetUrl (the SDK wrapper iframes it)"), n.expiresAt !== void 0 && e(n.expiresAt * 1e3 > c.now, "expiresAt (epoch seconds) must be in the future"), `action=${n.action.type}`;
101
+ }), await f("getOrderStatusByPartnerOrderId echoes the requested partner id", async () => {
102
+ let t = await i.getOrderStatusByPartnerOrderId(p, { notOlderThan: new Date(c.now).toISOString() }, c);
103
+ return t ? (e(t.partnerOrderId === void 0 || t.partnerOrderId === p, "status.partnerOrderId must echo the requested id"), e(n.has(t.state), `unknown provider state ${t.state}`), e(t.providerOrderId.length > 0, "providerOrderId must be non-empty"), `state=${t.state}`) : { skip: "no provider order discoverable yet (acceptable right after creation)" };
104
+ })), await f("verifyWebhook never throws on garbage and rejects it", async () => {
105
+ let t = await i.verifyWebhook({
106
+ rawBody: "not json / not signed",
107
+ headers: {}
108
+ }, c);
109
+ e(t === null || t === "ignore", "garbage body must resolve to null (reject) or 'ignore', not a parsed event");
110
+ }), await f("verifyWebhook parses a valid signed sample", async () => {
111
+ if (!o.webhook) return { skip: "no webhook samples provided" };
112
+ let t = await i.verifyWebhook(o.webhook.valid, c);
113
+ return e(t !== null, "the valid sample was rejected"), e(t !== "ignore", "the valid sample was ignored"), e(t.providerOrderId.length > 0, "parsed event must carry providerOrderId"), e(n.has(t.state), `unknown provider state ${t.state}`), o.webhook.expectState && e(t.state === o.webhook.expectState, `expected state ${o.webhook.expectState}, got ${t.state}`), `state=${t.state}`;
114
+ }), await f("verifyWebhook rejects a tampered sample", async () => {
115
+ if (!o.webhook) return { skip: "no webhook samples provided" };
116
+ let t = o.webhook.tampered ?? {
117
+ ...o.webhook.valid,
118
+ rawBody: a(o.webhook.valid.rawBody)
119
+ };
120
+ e(await i.verifyWebhook(t, c) === null, "a tampered body must be rejected with null (HTTP 401), not parsed or ignored");
121
+ }), d;
122
+ }
123
+ function a(e) {
124
+ let t = Math.floor(e.length / 2), n = e[t] === "0" ? "1" : "0";
125
+ return e.slice(0, t) + n + e.slice(t + 1);
126
+ }
127
+ //#endregion
128
+ export { i as runProviderConformance };
@@ -0,0 +1,13 @@
1
+ import { FiatOrderRecord, FiatOrderWidget, FiatStateStore } from '@tokenflight/fiat';
2
+ import { CheckResult } from './check.js';
3
+ export interface StoreConformanceOptions {
4
+ /** Base timestamp for job scheduling (default Date.now()). */
5
+ now?: number;
6
+ }
7
+ /** A minimal valid order + widget pair; ids are lexicographically increasing. */
8
+ export declare function buildTestOrder(overrides?: Partial<FiatOrderRecord>): {
9
+ order: FiatOrderRecord;
10
+ orderWidget: FiatOrderWidget;
11
+ };
12
+ export declare function runStateStoreConformance(store: FiatStateStore, options?: StoreConformanceOptions): Promise<CheckResult[]>;
13
+ //# sourceMappingURL=store-conformance.d.ts.map
@@ -0,0 +1,318 @@
1
+ import { checkAssert as e, runCheck as t } from "./check.js";
2
+ import { FiatStateConflictError as n } from "@tokenflight/fiat";
3
+ //#region src/store-conformance.ts
4
+ var r = 0;
5
+ function i(e) {
6
+ return r += 1, `${e}${String(r).padStart(6, "0")}`;
7
+ }
8
+ function a(e = {}) {
9
+ let t = e.orderId ?? i("fiat_order_tk"), n = e.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(), r = {
10
+ orderId: t,
11
+ version: 0,
12
+ status: "pending",
13
+ provider: "mock",
14
+ swapStrategy: "direct",
15
+ inputMode: "fiat",
16
+ fiatCurrency: "USD",
17
+ fiatAmount: "100.00",
18
+ outputAsset: {
19
+ chainId: 8453,
20
+ address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
21
+ },
22
+ recipient: "0x1111111111111111111111111111111111111111",
23
+ providerReferences: [{
24
+ provider: "mock",
25
+ type: "partner_order_id",
26
+ value: t
27
+ }, {
28
+ provider: "mock",
29
+ type: "order_id",
30
+ value: `mock_${t}`
31
+ }],
32
+ createdAt: n,
33
+ updatedAt: n,
34
+ ...e
35
+ };
36
+ return {
37
+ order: r,
38
+ orderWidget: {
39
+ orderId: t,
40
+ provider: r.provider,
41
+ action: {
42
+ type: "wrapper",
43
+ url: `https://host/v1/fiat/widget/${t}`
44
+ },
45
+ allowedProviderOrigins: ["https://mock.widget.test"],
46
+ widgetUrl: `https://mock.widget.test/checkout?order=${t}`,
47
+ expiresAt: new Date(Date.parse(n) + 36e5).toISOString()
48
+ }
49
+ };
50
+ }
51
+ async function o(e, t, n = {}) {
52
+ let r = a(n);
53
+ return e.createOrderFromQuote({
54
+ quoteNonce: t,
55
+ reservedOrderId: r.order.orderId,
56
+ build: () => Promise.resolve({
57
+ ...r,
58
+ jobs: []
59
+ })
60
+ });
61
+ }
62
+ async function s(r, s = {}) {
63
+ let u = s.now ?? Date.now(), d = [], f = async (e, n) => {
64
+ d.push(await t(e, "store", n));
65
+ };
66
+ return await f("createOrderFromQuote persists the order and its widget", async () => {
67
+ let t = await o(r, i("nonce_"));
68
+ e(!t.existing, "first creation must report existing=false");
69
+ let n = await r.getOrder(t.order.orderId);
70
+ e(n !== null, "getOrder must return the created order"), e(n.orderId === t.order.orderId && n.status === "pending", "persisted order must round-trip core fields"), e((await r.getOrderWidget(t.order.orderId))?.widgetUrl === t.orderWidget.widgetUrl, "getOrderWidget must round-trip the widget");
71
+ }), await f("a reused quote nonce returns the existing order without re-building", async () => {
72
+ let t = i("nonce_"), n = await o(r, t), s = !1, c = await r.createOrderFromQuote({
73
+ quoteNonce: t,
74
+ reservedOrderId: i("fiat_order_tk"),
75
+ build: () => (s = !0, Promise.resolve({
76
+ ...a(),
77
+ jobs: []
78
+ }))
79
+ });
80
+ e(c.existing, "nonce replay must report existing=true"), e(c.order.orderId === n.order.orderId, "nonce replay must return the original order"), e(!s, "nonce replay must not invoke build() (no second provider session)");
81
+ }), await f("a reused reservedOrderId returns the existing order", async () => {
82
+ let t = await o(r, i("nonce_")), n = await r.createOrderFromQuote({
83
+ quoteNonce: i("nonce_"),
84
+ reservedOrderId: t.order.orderId,
85
+ build: () => Promise.resolve({
86
+ ...a({ orderId: t.order.orderId }),
87
+ jobs: []
88
+ })
89
+ });
90
+ e(n.existing, "orderId replay must report existing=true"), e(n.order.orderId === t.order.orderId, "orderId replay must return the original order");
91
+ }), await f("updateOrder persists a patch and bumps the version", async () => {
92
+ let { order: t } = await o(r, i("nonce_"));
93
+ e((await r.updateOrder({
94
+ orderId: t.orderId,
95
+ expectedVersion: t.version,
96
+ next: {
97
+ ...t,
98
+ status: "completed",
99
+ outputAmount: "990000",
100
+ transferTxHash: "0xfill"
101
+ }
102
+ })).version !== t.version, "version must change on update");
103
+ let n = await r.getOrder(t.orderId);
104
+ e(n?.status === "completed" && n.outputAmount === "990000", "patched fields must persist"), e(n.transferTxHash === "0xfill", "transferTxHash must persist through updateOrder");
105
+ }), await f("every FiatOrderRecord field round-trips through create, getOrder and updateOrder", async () => {
106
+ let t = await o(r, i("nonce_"), c), n = await r.getOrder(t.order.orderId);
107
+ e(n !== null, "getOrder returned null for the created order");
108
+ let a = l(t.order, n);
109
+ e(a.length === 0, `create/getOrder dropped or altered: ${a.join(", ")}`);
110
+ let s = {
111
+ ...n,
112
+ providerCryptoAmount: "1.010000",
113
+ requestedCryptoAmount: "990005",
114
+ providerStatusReason: "kyc_cleared",
115
+ metadata: {
116
+ integratorOrderId: "io_2",
117
+ note: "patched"
118
+ }
119
+ }, u = await r.updateOrder({
120
+ orderId: n.orderId,
121
+ expectedVersion: n.version,
122
+ next: s
123
+ }), d = await r.getOrder(n.orderId);
124
+ e(d !== null, "getOrder returned null after update");
125
+ let f = l({
126
+ ...s,
127
+ version: u.version
128
+ }, d);
129
+ e(f.length === 0, `updateOrder dropped or altered: ${f.join(", ")}`);
130
+ }), await f("updateOrder is a CAS: a stale version throws FiatStateConflictError", async () => {
131
+ let { order: t } = await o(r, i("nonce_"));
132
+ await r.updateOrder({
133
+ orderId: t.orderId,
134
+ expectedVersion: t.version,
135
+ next: {
136
+ ...t,
137
+ status: "processing"
138
+ }
139
+ });
140
+ try {
141
+ await r.updateOrder({
142
+ orderId: t.orderId,
143
+ expectedVersion: t.version,
144
+ next: {
145
+ ...t,
146
+ status: "failed"
147
+ }
148
+ });
149
+ } catch (t) {
150
+ e(t instanceof n || t.code === "STATE_CONFLICT", `expected FiatStateConflictError, got ${String(t)}`);
151
+ return;
152
+ }
153
+ throw Error("stale-version update did not throw");
154
+ }), await f("findOrderByProviderReference resolves typed references", async () => {
155
+ let { order: t } = await o(r, i("nonce_"));
156
+ e((await r.findOrderByProviderReference({
157
+ provider: "mock",
158
+ type: "order_id",
159
+ value: `mock_${t.orderId}`
160
+ }))?.orderId === t.orderId, "order_id reference must resolve"), e(await r.findOrderByProviderReference({
161
+ provider: "mock",
162
+ type: "order_id",
163
+ value: "mock_does_not_exist"
164
+ }) === null, "an unknown reference must resolve to null");
165
+ }), await f("recordWebhookReceipt deduplicates", async () => {
166
+ let t = i("dedupe_");
167
+ e((await r.recordWebhookReceipt({
168
+ dedupeKey: t,
169
+ provider: "mock",
170
+ receivedAt: new Date(u).toISOString(),
171
+ rawBodyHash: "h"
172
+ })).inserted, "first receipt must insert"), e(!(await r.recordWebhookReceipt({
173
+ dedupeKey: t,
174
+ provider: "mock",
175
+ receivedAt: new Date(u).toISOString(),
176
+ rawBodyHash: "h"
177
+ })).inserted, "a replayed receipt must report inserted=false");
178
+ }), await f("claimDueJobs leases without double-claiming; finishJob(retry) re-arms", async () => {
179
+ if (!r.enqueueJobs || !r.claimDueJobs || !r.finishJob) return { skip: "store does not implement the cron job methods" };
180
+ let { order: t } = await o(r, i("nonce_"));
181
+ await r.enqueueJobs({
182
+ orderId: t.orderId,
183
+ jobs: [{
184
+ kind: "poll_provider_status",
185
+ runAt: u
186
+ }]
187
+ });
188
+ let n = (await r.claimDueJobs({
189
+ limit: 10,
190
+ now: u + 1,
191
+ leaseSeconds: 60,
192
+ owner: "tk-a"
193
+ })).filter((e) => e.orderId === t.orderId);
194
+ e(n.length === 1, `expected exactly one claim for the order, got ${n.length}`);
195
+ let a = n[0];
196
+ e(!(await r.claimDueJobs({
197
+ limit: 10,
198
+ now: u + 2,
199
+ leaseSeconds: 60,
200
+ owner: "tk-b"
201
+ })).some((e) => e.jobId === a.jobId), "a leased job must not be claimable within its lease"), await r.finishJob({
202
+ jobId: a.jobId,
203
+ outcome: {
204
+ type: "retry",
205
+ nextRunAt: u + 1e4,
206
+ reason: "conformance"
207
+ }
208
+ }), e(!(await r.claimDueJobs({
209
+ limit: 10,
210
+ now: u + 5e3,
211
+ leaseSeconds: 60,
212
+ owner: "tk-c"
213
+ })).some((e) => e.jobId === a.jobId), "a retried job must not run before nextRunAt");
214
+ let s = (await r.claimDueJobs({
215
+ limit: 10,
216
+ now: u + 11e3,
217
+ leaseSeconds: 60,
218
+ owner: "tk-d"
219
+ })).find((e) => e.jobId === a.jobId);
220
+ e(!!s, "a retried job must become claimable at nextRunAt"), e(s.attempt === a.attempt + 1, "retry must increment the attempt counter"), await r.finishJob({
221
+ jobId: a.jobId,
222
+ outcome: { type: "done" }
223
+ }), e(!(await r.claimDueJobs({
224
+ limit: 10,
225
+ now: u + 12e4,
226
+ leaseSeconds: 60,
227
+ owner: "tk-e"
228
+ })).some((e) => e.jobId === a.jobId), "a done job must never be claimed again");
229
+ }), await f("an expired lease is stolen by the next claimer", async () => {
230
+ if (!r.enqueueJobs || !r.claimDueJobs || !r.finishJob) return { skip: "store does not implement the cron job methods" };
231
+ let { order: t } = await o(r, i("nonce_"));
232
+ await r.enqueueJobs({
233
+ orderId: t.orderId,
234
+ jobs: [{
235
+ kind: "reconcile_oda",
236
+ runAt: u
237
+ }]
238
+ });
239
+ let n = (await r.claimDueJobs({
240
+ kinds: ["reconcile_oda"],
241
+ limit: 10,
242
+ now: u + 1,
243
+ leaseSeconds: 30,
244
+ owner: "tk-crash"
245
+ })).find((e) => e.orderId === t.orderId);
246
+ e(!!n, "the enqueued job must be claimable"), e((await r.claimDueJobs({
247
+ kinds: ["reconcile_oda"],
248
+ limit: 10,
249
+ now: u + 31e3,
250
+ leaseSeconds: 30,
251
+ owner: "tk-recover"
252
+ })).some((e) => e.jobId === n.jobId), "a job whose lease expired (crashed worker) must be claimable again"), await r.finishJob({
253
+ jobId: n.jobId,
254
+ outcome: { type: "done" }
255
+ });
256
+ }), await f("listOrders pages newest-first with a status filter", async () => {
257
+ if (!r.listOrders) return { skip: "store does not implement listOrders" };
258
+ let t = await o(r, i("nonce_")), n = await o(r, i("nonce_"));
259
+ await r.updateOrder({
260
+ orderId: n.order.orderId,
261
+ expectedVersion: n.order.version,
262
+ next: {
263
+ ...n.order,
264
+ status: "completed"
265
+ }
266
+ });
267
+ let a = await r.listOrders({ limit: 1 });
268
+ e(a.orders.length === 1, "limit must bound the page size"), e(a.cursor !== void 0, "a full page must return a cursor"), e(!(await r.listOrders({
269
+ limit: 100,
270
+ cursor: a.cursor
271
+ })).orders.some((e) => e.orderId === a.orders[0].orderId), "the cursor page must not repeat prior rows");
272
+ let s = await r.listOrders({
273
+ status: ["completed"],
274
+ limit: 100
275
+ });
276
+ e(s.orders.some((e) => e.orderId === n.order.orderId), "the status filter must include the matching order"), e(!s.orders.some((e) => e.orderId === t.order.orderId), "the status filter must exclude non-matching orders");
277
+ }), d;
278
+ }
279
+ var c = {
280
+ refundTo: "0x2222222222222222222222222222222222222222",
281
+ integratorId: "itg_conformance",
282
+ tenantId: "tenant_conformance",
283
+ quotedCryptoAmount: "990001",
284
+ expectedOutputAmount: "990002",
285
+ minOutputAmount: "980000",
286
+ outputAmount: "990003",
287
+ transferTxHash: "0xffff000000000000000000000000000000000000000000000000000000000001",
288
+ providerCryptoAmount: "0.990004",
289
+ requestedCryptoAmount: "990005",
290
+ hyperstreamQuoteId: "hq_conformance",
291
+ selectedRouteId: "route_conformance",
292
+ odaDepositAddress: "0x3333333333333333333333333333333333333333",
293
+ odaRefundAddress: "0x4444444444444444444444444444444444444444",
294
+ odaIntentId: "intent_conformance",
295
+ odaDepositTxHash: "0xffff000000000000000000000000000000000000000000000000000000000002",
296
+ providerStatusRaw: "PROCESSING",
297
+ providerStatusReason: "kyc_pending",
298
+ providerPayload: { conformance: !0 },
299
+ failure: {
300
+ reason: "conformance_probe",
301
+ source: "fiat_provider",
302
+ message: "not a real failure"
303
+ },
304
+ metadata: {
305
+ integratorOrderId: "io_conformance",
306
+ note: "full-field fixture"
307
+ }
308
+ };
309
+ function l(e, t) {
310
+ let n = /* @__PURE__ */ new Set([...Object.keys(e), ...Object.keys(t)]), r = [];
311
+ for (let i of n) {
312
+ let n = JSON.stringify(e[i]), a = JSON.stringify(t[i]);
313
+ n !== a && r.push(`${i} (expected ${n}, got ${a})`);
314
+ }
315
+ return r;
316
+ }
317
+ //#endregion
318
+ export { a as buildTestOrder, s as runStateStoreConformance };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@tokenflight/fiat-testkit",
3
+ "version": "0.0.1-rc.0",
4
+ "description": "Mock provider and conformance suites for @tokenflight/fiat providers and state stores",
5
+ "homepage": "https://fiat.hyperstream.dev/docs",
6
+ "license": "SEE LICENSE IN LICENSE.md",
7
+ "author": "Khalani Labs, Ltd. (https://khalani.network)",
8
+ "files": [
9
+ "dist",
10
+ "!dist/**/*.map"
11
+ ],
12
+ "type": "module",
13
+ "sideEffects": false,
14
+ "main": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "dependencies": {
28
+ "@tokenflight/fiat": "^0.0.1-rc.0"
29
+ },
30
+ "devDependencies": {
31
+ "@cloudflare/workers-types": "^4.20260626.1",
32
+ "typescript": "^6.0.3",
33
+ "vite": "^8.1.0",
34
+ "vite-plugin-dts": "^5.0.3"
35
+ },
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "scripts": {
40
+ "build": "vite build"
41
+ }
42
+ }