@myzonerocks/pact 0.1.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.
Files changed (88) hide show
  1. package/dist/src/adapter.d.ts +18 -0
  2. package/dist/src/adapter.js +11 -0
  3. package/dist/src/adapters/erc20.d.ts +43 -0
  4. package/dist/src/adapters/erc20.js +126 -0
  5. package/dist/src/adapters/mpesa.d.ts +61 -0
  6. package/dist/src/adapters/mpesa.js +272 -0
  7. package/dist/src/adapters/paypal.d.ts +68 -0
  8. package/dist/src/adapters/paypal.js +279 -0
  9. package/dist/src/adapters/stripe.d.ts +61 -0
  10. package/dist/src/adapters/stripe.js +336 -0
  11. package/dist/src/bridge.d.ts +40 -0
  12. package/dist/src/bridge.js +99 -0
  13. package/dist/src/canonical.d.ts +14 -0
  14. package/dist/src/canonical.js +82 -0
  15. package/dist/src/client.d.ts +84 -0
  16. package/dist/src/client.js +510 -0
  17. package/dist/src/compliance.d.ts +23 -0
  18. package/dist/src/compliance.js +26 -0
  19. package/dist/src/crypto.d.ts +12 -0
  20. package/dist/src/crypto.js +50 -0
  21. package/dist/src/fake.d.ts +29 -0
  22. package/dist/src/fake.js +79 -0
  23. package/dist/src/index.d.ts +16 -0
  24. package/dist/src/index.js +16 -0
  25. package/dist/src/ledger.d.ts +47 -0
  26. package/dist/src/ledger.js +84 -0
  27. package/dist/src/leg.d.ts +34 -0
  28. package/dist/src/leg.js +4 -0
  29. package/dist/src/message.d.ts +55 -0
  30. package/dist/src/message.js +88 -0
  31. package/dist/src/money.d.ts +16 -0
  32. package/dist/src/money.js +81 -0
  33. package/dist/src/payload.d.ts +11 -0
  34. package/dist/src/payload.js +52 -0
  35. package/dist/src/policy.d.ts +26 -0
  36. package/dist/src/policy.js +83 -0
  37. package/dist/src/protocol.d.ts +4 -0
  38. package/dist/src/protocol.js +15 -0
  39. package/dist/src/router.d.ts +16 -0
  40. package/dist/src/router.js +87 -0
  41. package/dist/src/signing.d.ts +27 -0
  42. package/dist/src/signing.js +72 -0
  43. package/dist/src/state.d.ts +23 -0
  44. package/dist/src/state.js +76 -0
  45. package/dist/src/wire.d.ts +30 -0
  46. package/dist/src/wire.js +221 -0
  47. package/dist/test/erc20.test.d.ts +1 -0
  48. package/dist/test/erc20.test.js +131 -0
  49. package/dist/test/lifecycle.test.d.ts +1 -0
  50. package/dist/test/lifecycle.test.js +212 -0
  51. package/dist/test/mpesa.test.d.ts +1 -0
  52. package/dist/test/mpesa.test.js +180 -0
  53. package/dist/test/payload.test.d.ts +1 -0
  54. package/dist/test/payload.test.js +32 -0
  55. package/dist/test/paypal.test.d.ts +1 -0
  56. package/dist/test/paypal.test.js +140 -0
  57. package/dist/test/policy.test.d.ts +1 -0
  58. package/dist/test/policy.test.js +131 -0
  59. package/dist/test/stripe.test.d.ts +1 -0
  60. package/dist/test/stripe.test.js +176 -0
  61. package/dist/test/vectors.test.d.ts +1 -0
  62. package/dist/test/vectors.test.js +91 -0
  63. package/dist/test/wire.test.d.ts +1 -0
  64. package/dist/test/wire.test.js +104 -0
  65. package/package.json +50 -0
  66. package/src/adapter.ts +32 -0
  67. package/src/adapters/erc20.ts +181 -0
  68. package/src/adapters/mpesa.ts +408 -0
  69. package/src/adapters/paypal.ts +409 -0
  70. package/src/adapters/stripe.ts +478 -0
  71. package/src/bridge.ts +148 -0
  72. package/src/canonical.ts +94 -0
  73. package/src/client.ts +605 -0
  74. package/src/compliance.ts +65 -0
  75. package/src/crypto.ts +68 -0
  76. package/src/fake.ts +96 -0
  77. package/src/index.ts +106 -0
  78. package/src/ledger.ts +145 -0
  79. package/src/leg.ts +65 -0
  80. package/src/message.ts +178 -0
  81. package/src/money.ts +87 -0
  82. package/src/payload.ts +58 -0
  83. package/src/policy.ts +110 -0
  84. package/src/protocol.ts +19 -0
  85. package/src/router.ts +97 -0
  86. package/src/signing.ts +92 -0
  87. package/src/state.ts +76 -0
  88. package/src/wire.ts +248 -0
@@ -0,0 +1,478 @@
1
+ // Stripe leg for the PACT protocol. It runs server-side only: it holds the Stripe
2
+ // secret and webhook signing keys, creates and captures PaymentIntents, and
3
+ // normalizes Stripe webhooks into protocol events. Stripe serves the pay-in side
4
+ // of a corridor: it charges the payer's card and captures the funds to the
5
+ // account the corridor delivers to, be that the recipient directly or a bridge
6
+ // escrow. Card and wallet collection happens client-side against the
7
+ // PaymentIntent this leg creates; the leg never sees raw card data.
8
+ import { createHmac, timingSafeEqual } from "node:crypto";
9
+ import { Money } from "../money.js";
10
+ import { State } from "../state.js";
11
+ import type { Quote, Authorization, Settlement } from "../message.js";
12
+ import { RefundKind, type AdapterEvent } from "../adapter.js";
13
+ import type { PayInLeg, PayInCapabilities, CollectResult } from "../leg.js";
14
+
15
+ // The public base of the Stripe REST API. It is the same for every integration
16
+ // and carries no secret; tests point the leg at a local server instead.
17
+ const defaultBaseURL = "https://api.stripe.com";
18
+
19
+ // The Stripe API version this leg is written against. Pinning it keeps response
20
+ // shapes stable across Stripe's own releases.
21
+ const apiVersion = "2026-04-22.dahlia";
22
+
23
+ // The pact intent id travels on the PaymentIntent metadata under this key so a
24
+ // webhook can be tied back to the intent it settles.
25
+ const metadataIntentKey = "pact_intent_id";
26
+
27
+ // defaultToleranceSeconds is how far a webhook timestamp may drift from now
28
+ // before it is rejected as a possible replay.
29
+ const defaultToleranceSeconds = 300;
30
+
31
+ // The protocol identifier used to bind an idempotency key to one protocol step.
32
+ const idPrefix = "pact";
33
+
34
+ // PaymentIntent is the subset of Stripe's PaymentIntent this leg reads.
35
+ export interface PaymentIntent {
36
+ id: string;
37
+ status: string;
38
+ amount: number;
39
+ currency: string;
40
+ latestCharge: string;
41
+ metadata: Record<string, string>;
42
+ created: number;
43
+ }
44
+
45
+ // Refund is the subset of Stripe's Refund this leg reads.
46
+ export interface Refund {
47
+ id: string;
48
+ status: string;
49
+ amount: number;
50
+ }
51
+
52
+ // CreateIntentParams describes a PaymentIntent to create. destination is the
53
+ // account the captured funds settle to: the recipient's connected account for a
54
+ // direct corridor, the escrow account for a bridged one. An empty destination
55
+ // leaves the funds on the platform account.
56
+ export interface CreateIntentParams {
57
+ amount: number;
58
+ currency: string;
59
+ destination: string;
60
+ metadata: Record<string, string>;
61
+ }
62
+
63
+ // CreateRefundParams describes a refund to create against a PaymentIntent. A zero
64
+ // amount refunds the full captured amount.
65
+ export interface CreateRefundParams {
66
+ paymentIntentId: string;
67
+ amount: number;
68
+ reason: string;
69
+ }
70
+
71
+ // StripeApi is the surface of Stripe this leg depends on. Depending on an
72
+ // interface keeps the leg unit-testable without a network and without
73
+ // credentials.
74
+ export interface StripeApi {
75
+ createPaymentIntent(params: CreateIntentParams, idempotencyKey: string): Promise<PaymentIntent>;
76
+ capturePaymentIntent(id: string, idempotencyKey: string): Promise<PaymentIntent>;
77
+ // findPaymentIntent resolves the PaymentIntent carrying a given pact intent id
78
+ // in its metadata. Stripe is queried by metadata because the leg holds no
79
+ // mapping of its own; all durable state lives in the ledger.
80
+ findPaymentIntent(pactIntentId: string): Promise<PaymentIntent>;
81
+ createRefund(params: CreateRefundParams, idempotencyKey: string): Promise<Refund>;
82
+ }
83
+
84
+ export interface StripeConfig {
85
+ id?: string;
86
+ currencies: string[];
87
+ api: StripeApi;
88
+ webhookKey: string;
89
+ // clock returns the current time in Unix seconds, for webhook timestamp
90
+ // checks. When absent, timestamps are checked against zero.
91
+ clock?: () => number;
92
+ // tolerance is the webhook timestamp tolerance in seconds; defaults to 300.
93
+ tolerance?: number;
94
+ ids: () => string;
95
+ }
96
+
97
+ // StripeLeg collects card payments through Stripe. It exposes the wallets that
98
+ // ride on the card rail as funding methods, and answers a refund with a full or
99
+ // partial reversal.
100
+ export class StripeLeg implements PayInLeg {
101
+ readonly id: string;
102
+ private readonly currencies: string[];
103
+ private readonly api: StripeApi;
104
+ private readonly webhookKey: string;
105
+ private readonly clock: () => number;
106
+ private readonly tolerance: number;
107
+ private readonly ids: () => string;
108
+
109
+ constructor(cfg: StripeConfig) {
110
+ if (!cfg.webhookKey) {
111
+ throw new Error("stripe: config requires a webhook signing key");
112
+ }
113
+ this.id = cfg.id ?? "stripe";
114
+ this.currencies = cfg.currencies;
115
+ this.api = cfg.api;
116
+ this.webhookKey = cfg.webhookKey;
117
+ this.clock = cfg.clock ?? (() => 0);
118
+ this.tolerance = cfg.tolerance ?? defaultToleranceSeconds;
119
+ this.ids = cfg.ids;
120
+ }
121
+
122
+ // payInCapabilities advertises the card rail and the wallets that fund through
123
+ // it. The methods are informational; the corridor routes on the rail.
124
+ payInCapabilities(): PayInCapabilities {
125
+ return {
126
+ rails: ["card"],
127
+ currencies: this.currencies,
128
+ methods: ["card", "cashapp", "apple_pay", "google_pay", "amazon_pay", "link"],
129
+ refunds: RefundKind.Partial,
130
+ };
131
+ }
132
+
133
+ // collect charges the payer and captures the funds to deliverTo — the recipient
134
+ // for a direct corridor, the bridge escrow for a bridged one. It creates the
135
+ // PaymentIntent and captures it in one step, each call keyed to this intent so a
136
+ // retry reuses the original charge rather than opening a second. received is the
137
+ // net a bridge would convert: what the payer paid less the corridor fees.
138
+ async collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult> {
139
+ const amount = minorToInteger(quote.srcAmount);
140
+ const pi = await this.api.createPaymentIntent(
141
+ {
142
+ amount,
143
+ currency: quote.srcAmount.currency,
144
+ destination: deliverTo,
145
+ metadata: { [metadataIntentKey]: intentId },
146
+ },
147
+ idempotencyKey(intentId, "collect"),
148
+ );
149
+ const captured = await this.api.capturePaymentIntent(pi.id, idempotencyKey(intentId, "capture"));
150
+ return { providerRef: captured.id, received: quote.srcAmount.sub(quote.fees) };
151
+ }
152
+
153
+ // refundIn reverses a captured payment, in full or in part. Stripe supports
154
+ // both, so the leg accepts the full and partial refund kinds and rejects a
155
+ // counter-transfer it cannot express.
156
+ async refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement> {
157
+ if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
158
+ throw new Error(`stripe: cannot perform refund kind ${kind}`);
159
+ }
160
+ const pi = await this.api.findPaymentIntent(intentId);
161
+ const refund = await this.api.createRefund(
162
+ { paymentIntentId: pi.id, amount: 0, reason },
163
+ idempotencyKey(intentId, "refund"),
164
+ );
165
+ return {
166
+ intentId,
167
+ state: State.Refunded,
168
+ adapterId: this.id,
169
+ providerTxRef: refund.id,
170
+ onchainTxHash: "",
171
+ receiptHash: new Uint8Array(0),
172
+ reason,
173
+ settledAt: 0,
174
+ };
175
+ }
176
+
177
+ // parseWebhook verifies a Stripe webhook and normalizes it into protocol
178
+ // events. It reads the signature from the Stripe-Signature header and rejects
179
+ // any payload whose signature or timestamp does not check out. The timestamp is
180
+ // checked against the injected clock so the result is deterministic.
181
+ parseWebhook(raw: Uint8Array, headers: Record<string, string[]>): AdapterEvent[] {
182
+ return parseEvent(raw, signatureHeader(headers), this.webhookKey, this.clock(), this.tolerance);
183
+ }
184
+ }
185
+
186
+ // signatureHeader pulls the Stripe signature out of the request headers, matching
187
+ // the header name case-insensitively so it works regardless of how the host's
188
+ // HTTP layer canonicalizes keys.
189
+ function signatureHeader(headers: Record<string, string[]>): string {
190
+ for (const key of Object.keys(headers)) {
191
+ if (key.toLowerCase() === "stripe-signature") {
192
+ const values = headers[key];
193
+ if (values && values.length > 0) {
194
+ return values[0]!;
195
+ }
196
+ }
197
+ }
198
+ return "";
199
+ }
200
+
201
+ // idempotencyKey binds a Stripe mutation to one protocol step so a retry reuses
202
+ // the original result instead of acting twice.
203
+ function idempotencyKey(ref: string, step: string): string {
204
+ return `${idPrefix}:${ref}:${step}`;
205
+ }
206
+
207
+ // minorToInteger narrows a Money amount to the safe integer Stripe expects,
208
+ // refusing anything that would overflow. Fiat amounts fit comfortably; the guard
209
+ // exists so a token amount can never be sent to a card rail by mistake.
210
+ function minorToInteger(m: Money): number {
211
+ const amount = m.value();
212
+ if (amount > BigInt(Number.MAX_SAFE_INTEGER) || amount < 0n) {
213
+ throw new Error(`stripe: amount ${m.minor()} exceeds the card-rail range`);
214
+ }
215
+ return Number(amount);
216
+ }
217
+
218
+ // ErrNoSignature reports a webhook that arrived without a usable Stripe signature
219
+ // header.
220
+ export const ErrNoSignature = "stripe: missing signature header";
221
+ // ErrSignatureMismatch reports a webhook whose signature does not verify.
222
+ export const ErrSignatureMismatch = "stripe: signature does not verify";
223
+ // ErrTimestampOutOfTolerance reports a webhook whose timestamp is too old or too
224
+ // far in the future to trust.
225
+ export const ErrTimestampOutOfTolerance = "stripe: webhook timestamp outside tolerance";
226
+
227
+ // verifySignature checks a Stripe-Signature header against the raw payload and
228
+ // the webhook signing secret: sign "{t}.{payload}" with HMAC-SHA256 and compare
229
+ // in constant time. now and tolerance are injected so the check is deterministic
230
+ // in tests.
231
+ function verifySignature(payload: Uint8Array, header: string, secret: string, now: number, tolerance: number): void {
232
+ if (!header) {
233
+ throw new Error(ErrNoSignature);
234
+ }
235
+ let timestamp = "";
236
+ const signatures: string[] = [];
237
+ for (const part of header.split(",")) {
238
+ const kv = part.trim();
239
+ const eq = kv.indexOf("=");
240
+ if (eq < 0) {
241
+ continue;
242
+ }
243
+ const name = kv.slice(0, eq);
244
+ const value = kv.slice(eq + 1);
245
+ if (name === "t") {
246
+ timestamp = value;
247
+ } else if (name === "v1") {
248
+ signatures.push(value);
249
+ }
250
+ }
251
+ if (!timestamp || signatures.length === 0) {
252
+ throw new Error(ErrNoSignature);
253
+ }
254
+ const ts = Number(timestamp);
255
+ if (!Number.isInteger(ts)) {
256
+ throw new Error(ErrNoSignature);
257
+ }
258
+ const diff = now - ts;
259
+ if (diff > tolerance || diff < -tolerance) {
260
+ throw new Error(ErrTimestampOutOfTolerance);
261
+ }
262
+
263
+ const mac = createHmac("sha256", secret);
264
+ mac.update(timestamp);
265
+ mac.update(".");
266
+ mac.update(payload);
267
+ const expected = mac.digest();
268
+
269
+ for (const sig of signatures) {
270
+ let got: Buffer;
271
+ try {
272
+ got = Buffer.from(sig, "hex");
273
+ } catch {
274
+ continue;
275
+ }
276
+ if (got.length === expected.length && timingSafeEqual(got, expected)) {
277
+ return;
278
+ }
279
+ }
280
+ throw new Error(ErrSignatureMismatch);
281
+ }
282
+
283
+ // stripeEvent is the envelope Stripe wraps every webhook in.
284
+ interface StripeEvent {
285
+ type: string;
286
+ data: { object: unknown };
287
+ }
288
+
289
+ // parseEvent verifies the signature and maps a Stripe event onto the protocol.
290
+ // Events this leg does not act on yield no protocol event rather than an error.
291
+ function parseEvent(
292
+ payload: Uint8Array,
293
+ header: string,
294
+ secret: string,
295
+ now: number,
296
+ tolerance: number,
297
+ ): AdapterEvent[] {
298
+ verifySignature(payload, header, secret, now, tolerance);
299
+ const event = JSON.parse(new TextDecoder().decode(payload)) as StripeEvent;
300
+
301
+ switch (event.type) {
302
+ case "payment_intent.succeeded":
303
+ return oneEvent(decodePaymentIntent(event.data.object), State.Settled, "");
304
+ case "payment_intent.payment_failed":
305
+ return oneEvent(decodePaymentIntent(event.data.object), State.Failed, "payment failed");
306
+ case "charge.refunded":
307
+ return oneEvent(decodeRefundedIntent(event.data.object), State.Refunded, "refunded");
308
+ default:
309
+ return [];
310
+ }
311
+ }
312
+
313
+ interface WebhookIntent {
314
+ id: string;
315
+ metadata: Record<string, string>;
316
+ created: number;
317
+ }
318
+
319
+ function decodePaymentIntent(raw: unknown): WebhookIntent {
320
+ const obj = raw as { id?: string; metadata?: Record<string, string>; created?: number };
321
+ return { id: obj.id ?? "", metadata: obj.metadata ?? {}, created: obj.created ?? 0 };
322
+ }
323
+
324
+ // decodeRefundedIntent reads the pact intent id off a refunded charge. A charge
325
+ // carries the originating PaymentIntent id and copies its metadata.
326
+ function decodeRefundedIntent(raw: unknown): WebhookIntent {
327
+ const charge = raw as { payment_intent?: string; metadata?: Record<string, string>; created?: number };
328
+ return { id: charge.payment_intent ?? "", metadata: charge.metadata ?? {}, created: charge.created ?? 0 };
329
+ }
330
+
331
+ function oneEvent(pi: WebhookIntent, state: State, reason: string): AdapterEvent[] {
332
+ return [
333
+ {
334
+ intentId: pi.metadata[metadataIntentKey] ?? "",
335
+ state,
336
+ providerTxRef: pi.id,
337
+ onchainTxHash: "",
338
+ reason,
339
+ settledAt: pi.created * 1000,
340
+ },
341
+ ];
342
+ }
343
+
344
+ // httpStripeApi is the live Stripe REST client. The secret key is injected by the
345
+ // host from its own secrets store and never originates in this code.
346
+ class HttpStripeApi implements StripeApi {
347
+ private readonly secretKey: string;
348
+ private readonly baseURL: string;
349
+
350
+ constructor(secretKey: string, baseURL: string) {
351
+ this.secretKey = secretKey;
352
+ this.baseURL = baseURL || defaultBaseURL;
353
+ }
354
+
355
+ async createPaymentIntent(params: CreateIntentParams, idempotencyKey: string): Promise<PaymentIntent> {
356
+ const form = new URLSearchParams();
357
+ form.set("amount", String(params.amount));
358
+ form.set("currency", params.currency.toLowerCase());
359
+ form.set("capture_method", "manual");
360
+ form.set("automatic_payment_methods[enabled]", "true");
361
+ if (params.destination) {
362
+ form.set("transfer_data[destination]", params.destination);
363
+ }
364
+ for (const [k, v] of Object.entries(params.metadata)) {
365
+ form.set(`metadata[${k}]`, v);
366
+ }
367
+ const pi = await this.post<StripePaymentIntentWire>("/v1/payment_intents", form, idempotencyKey);
368
+ return normalizeIntent(pi);
369
+ }
370
+
371
+ async capturePaymentIntent(id: string, idempotencyKey: string): Promise<PaymentIntent> {
372
+ const pi = await this.post<StripePaymentIntentWire>(
373
+ `/v1/payment_intents/${encodeURIComponent(id)}/capture`,
374
+ new URLSearchParams(),
375
+ idempotencyKey,
376
+ );
377
+ return normalizeIntent(pi);
378
+ }
379
+
380
+ async findPaymentIntent(pactIntentId: string): Promise<PaymentIntent> {
381
+ const query = new URLSearchParams();
382
+ query.set("query", `metadata["${metadataIntentKey}"]:"${pactIntentId}"`);
383
+ const result = await this.get<{ data: StripePaymentIntentWire[] }>(`/v1/payment_intents/search?${query.toString()}`);
384
+ const first = result.data[0];
385
+ if (!first) {
386
+ throw new Error(`stripe: no payment intent for ${pactIntentId}`);
387
+ }
388
+ return normalizeIntent(first);
389
+ }
390
+
391
+ async createRefund(params: CreateRefundParams, idempotencyKey: string): Promise<Refund> {
392
+ const form = new URLSearchParams();
393
+ form.set("payment_intent", params.paymentIntentId);
394
+ if (params.amount > 0) {
395
+ form.set("amount", String(params.amount));
396
+ }
397
+ if (params.reason) {
398
+ form.set("metadata[reason]", params.reason);
399
+ }
400
+ const r = await this.post<StripeRefundWire>("/v1/refunds", form, idempotencyKey);
401
+ return { id: r.id ?? "", status: r.status ?? "", amount: r.amount ?? 0 };
402
+ }
403
+
404
+ private async post<T>(path: string, form: URLSearchParams, idempotencyKey: string): Promise<T> {
405
+ const headers: Record<string, string> = { "Content-Type": "application/x-www-form-urlencoded" };
406
+ if (idempotencyKey) {
407
+ headers["Idempotency-Key"] = idempotencyKey;
408
+ }
409
+ return this.send<T>(path, { method: "POST", headers, body: form.toString() });
410
+ }
411
+
412
+ private async get<T>(path: string): Promise<T> {
413
+ return this.send<T>(path, { method: "GET" });
414
+ }
415
+
416
+ private async send<T>(path: string, init: RequestInit): Promise<T> {
417
+ const headers: Record<string, string> = {
418
+ ...((init.headers as Record<string, string>) ?? {}),
419
+ Authorization: `Bearer ${this.secretKey}`,
420
+ "Stripe-Version": apiVersion,
421
+ };
422
+ const resp = await fetch(this.baseURL + path, { ...init, headers });
423
+ const body = await resp.text();
424
+ if (resp.status >= 300) {
425
+ throw new Error(`stripe: ${path} returned ${resp.status}: ${stripeErrorMessage(body)}`);
426
+ }
427
+ return JSON.parse(body) as T;
428
+ }
429
+ }
430
+
431
+ // stripeErrorMessage lifts Stripe's error message out of a response body without
432
+ // echoing anything that could carry a key.
433
+ function stripeErrorMessage(body: string): string {
434
+ try {
435
+ const envelope = JSON.parse(body) as { error?: { message?: string; code?: string } };
436
+ const message = envelope.error?.message;
437
+ if (!message) {
438
+ return "unknown error";
439
+ }
440
+ return envelope.error?.code ? `${message} (${envelope.error.code})` : message;
441
+ } catch {
442
+ return "unknown error";
443
+ }
444
+ }
445
+
446
+ interface StripePaymentIntentWire {
447
+ id?: string;
448
+ status?: string;
449
+ amount?: number;
450
+ currency?: string;
451
+ latest_charge?: string;
452
+ metadata?: Record<string, string>;
453
+ created?: number;
454
+ }
455
+
456
+ interface StripeRefundWire {
457
+ id?: string;
458
+ status?: string;
459
+ amount?: number;
460
+ }
461
+
462
+ function normalizeIntent(p: StripePaymentIntentWire): PaymentIntent {
463
+ return {
464
+ id: p.id ?? "",
465
+ status: p.status ?? "",
466
+ amount: p.amount ?? 0,
467
+ currency: (p.currency ?? "").toUpperCase(),
468
+ latestCharge: p.latest_charge ?? "",
469
+ metadata: p.metadata ?? {},
470
+ created: p.created ?? 0,
471
+ };
472
+ }
473
+
474
+ // newHttpStripeApi builds a live Stripe client. baseURL is optional and defaults
475
+ // to the public Stripe API; it exists so tests can substitute a local server.
476
+ export function newHttpStripeApi(secretKey: string, baseURL = ""): StripeApi {
477
+ return new HttpStripeApi(secretKey, baseURL);
478
+ }
package/src/bridge.ts ADDED
@@ -0,0 +1,148 @@
1
+ import { Money } from "./money.js";
2
+ import { BRIDGE_PASSTHROUGH } from "./message.js";
3
+
4
+ // The USDC bridge identifier. A stable wire value carried in a corridor quote.
5
+ export const BRIDGE_USDC = "usdc";
6
+
7
+ // BridgeQuote plans a conversion toward a target destination amount: how much the
8
+ // payer must supply in the source currency, at what rate, plus the bridge's fee.
9
+ // Planning is destination-denominated because a payment is requested in what the
10
+ // recipient should receive; execution runs the conversion forward.
11
+ export interface BridgeQuote {
12
+ srcAmount: Money; // what the payer must supply, in X, to deliver the target dst
13
+ fxRate: string; // Y per X, decimal ASCII
14
+ fee: Money; // the bridge's own fee, in X
15
+ }
16
+
17
+ // ConvertResult is the outcome of moving held value through a bridge.
18
+ export interface ConvertResult {
19
+ delivered: Money;
20
+ receiptRef: string;
21
+ }
22
+
23
+ // Bridge holds value between the pay-in and pay-out legs and converts the payer's
24
+ // currency to the recipient's. The kernel never sources the rate or the
25
+ // liquidity; a bridge is handed both through injected seams. quote plans toward a
26
+ // target destination amount (how much source is needed); convert executes the
27
+ // conversion forward on the value actually held.
28
+ export interface Bridge {
29
+ id: string;
30
+ quote(dstTarget: Money, srcCurrency: string, srcExponent: number): Promise<BridgeQuote>;
31
+ convert(intentId: string, held: Money, dstCurrency: string, dstExponent: number): Promise<ConvertResult>;
32
+ }
33
+
34
+ // RateSource yields the exchange rate to apply, expressed as destination units
35
+ // per source unit in decimal ASCII. An adopter wires it to a price feed.
36
+ export interface RateSource {
37
+ rate(from: string, to: string): Promise<string>;
38
+ }
39
+
40
+ // EscrowVault holds converted value between the legs. For the USDC bridge this is
41
+ // the token custody; an adopter supplies the implementation and, with it, the
42
+ // liquidity. The kernel only coordinates.
43
+ export interface EscrowVault {
44
+ hold(intentId: string, amount: Money): Promise<string>;
45
+ release(intentId: string): Promise<void>;
46
+ }
47
+
48
+ // PassThroughBridge is the direct-corridor bridge: it moves value unchanged when
49
+ // the payer and recipient are already in the same currency.
50
+ export class PassThroughBridge implements Bridge {
51
+ readonly id = BRIDGE_PASSTHROUGH;
52
+
53
+ async quote(dstTarget: Money, srcCurrency: string, srcExponent: number): Promise<BridgeQuote> {
54
+ if (dstTarget.currency !== srcCurrency || dstTarget.exponent !== srcExponent) {
55
+ throw new Error(`pact: pass-through bridge cannot convert ${srcCurrency} to ${dstTarget.currency}`);
56
+ }
57
+ return { srcAmount: dstTarget, fxRate: "1", fee: Money.create(srcCurrency, srcExponent, 0n) };
58
+ }
59
+
60
+ async convert(_intentId: string, held: Money, dstCurrency: string, dstExponent: number): Promise<ConvertResult> {
61
+ if (held.currency !== dstCurrency || held.exponent !== dstExponent) {
62
+ throw new Error("pact: pass-through bridge cannot convert across currencies");
63
+ }
64
+ return { delivered: held, receiptRef: "" };
65
+ }
66
+ }
67
+
68
+ // UsdcBridge converts the payer's currency to the recipient's through a USDC
69
+ // escrow. It applies a quoted rate from the injected RateSource, charges a fee in
70
+ // basis points, and parks the value in the injected EscrowVault between legs.
71
+ export class UsdcBridge implements Bridge {
72
+ readonly id = BRIDGE_USDC;
73
+
74
+ constructor(
75
+ private readonly rates: RateSource,
76
+ private readonly vault: EscrowVault,
77
+ private readonly feeBps: bigint,
78
+ ) {}
79
+
80
+ async quote(dstTarget: Money, srcCurrency: string, srcExponent: number): Promise<BridgeQuote> {
81
+ const rate = await this.rates.rate(srcCurrency, dstTarget.currency);
82
+ // Plan backwards: the source needed to deliver the target is the target at
83
+ // the inverse rate, rounded up so a rounding remainder never short-changes
84
+ // the recipient.
85
+ const src = applyInverseRate(dstTarget, rate, srcCurrency, srcExponent);
86
+ const feeMinor = (src.value() * this.feeBps) / 10_000n;
87
+ return { srcAmount: src, fxRate: rate, fee: Money.create(srcCurrency, srcExponent, feeMinor) };
88
+ }
89
+
90
+ async convert(intentId: string, held: Money, dstCurrency: string, dstExponent: number): Promise<ConvertResult> {
91
+ const ref = await this.vault.hold(intentId, held);
92
+ const rate = await this.rates.rate(held.currency, dstCurrency);
93
+ const delivered = applyRate(held, rate, dstCurrency, dstExponent);
94
+ await this.vault.release(intentId);
95
+ return { delivered, receiptRef: ref };
96
+ }
97
+ }
98
+
99
+ // applyRate converts a source amount to a destination currency at a decimal rate.
100
+ // The rate is Y per X given as an integer or a fixed-point decimal; the result is
101
+ // floored to the destination currency's minor unit. Rounding down is deliberate:
102
+ // the recipient is never credited more than the rate permits.
103
+ export function applyRate(src: Money, rate: string, dstCurrency: string, dstExponent: number): Money {
104
+ const [num, den] = parseRate(rate);
105
+ // dstMinor = srcMinor * rate * 10^(dstExponent - srcExponent)
106
+ let value = src.value() * num;
107
+ let denom = den;
108
+ if (dstExponent >= src.exponent) {
109
+ value = value * pow10(dstExponent - src.exponent);
110
+ } else {
111
+ denom = denom * pow10(src.exponent - dstExponent);
112
+ }
113
+ return Money.create(dstCurrency, dstExponent, value / denom);
114
+ }
115
+
116
+ // applyInverseRate is the planning direction: given a target destination amount
117
+ // and a rate of Y per X, it returns the source amount needed. It rounds up so a
118
+ // rounding remainder is covered by the payer, never taken from the recipient.
119
+ export function applyInverseRate(dst: Money, rate: string, srcCurrency: string, srcExponent: number): Money {
120
+ const [num, den] = parseRate(rate);
121
+ // srcMinor = ceil( dstMinor * den * 10^srcExp / (num * 10^dstExp) )
122
+ const numerator = dst.value() * den * pow10(srcExponent);
123
+ const denominator = num * pow10(dst.exponent);
124
+ let value = numerator / denominator;
125
+ if (numerator % denominator !== 0n) {
126
+ value = value + 1n;
127
+ }
128
+ return Money.create(srcCurrency, srcExponent, value);
129
+ }
130
+
131
+ // parseRate reads a decimal rate like "129.45" into a numerator and denominator,
132
+ // so the conversion stays exact integer arithmetic with no floating point.
133
+ export function parseRate(rate: string): [bigint, bigint] {
134
+ const dot = rate.indexOf(".");
135
+ const whole = dot < 0 ? rate : rate.slice(0, dot);
136
+ const frac = dot < 0 ? "" : rate.slice(dot + 1);
137
+ const digits = whole + frac;
138
+ if (!/^\d+$/.test(digits)) {
139
+ throw new Error(`pact: ${JSON.stringify(rate)} is not a decimal rate`);
140
+ }
141
+ const num = BigInt(digits);
142
+ const den = frac.length > 0 ? pow10(frac.length) : 1n;
143
+ return [num, den];
144
+ }
145
+
146
+ function pow10(n: number): bigint {
147
+ return 10n ** BigInt(n);
148
+ }