@myzonerocks/pact 0.1.3 → 0.1.6

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 (70) hide show
  1. package/README.md +3 -3
  2. package/dist/src/adapter.d.ts +1 -1
  3. package/dist/src/adapters/erc20.d.ts +30 -4
  4. package/dist/src/adapters/erc20.js +105 -50
  5. package/dist/src/adapters/http.d.ts +2 -0
  6. package/dist/src/adapters/http.js +12 -0
  7. package/dist/src/adapters/mpesa.d.ts +30 -4
  8. package/dist/src/adapters/mpesa.js +106 -22
  9. package/dist/src/adapters/paypal.d.ts +23 -2
  10. package/dist/src/adapters/paypal.js +91 -29
  11. package/dist/src/adapters/stripe.d.ts +3 -2
  12. package/dist/src/adapters/stripe.js +14 -11
  13. package/dist/src/bridge.js +12 -5
  14. package/dist/src/canonical.js +5 -0
  15. package/dist/src/client.d.ts +10 -2
  16. package/dist/src/client.js +215 -30
  17. package/dist/src/compliance.d.ts +4 -0
  18. package/dist/src/compliance.js +10 -3
  19. package/dist/src/crypto.js +4 -1
  20. package/dist/src/index.d.ts +0 -1
  21. package/dist/src/index.js +0 -1
  22. package/dist/src/ledger.d.ts +6 -2
  23. package/dist/src/ledger.js +2 -2
  24. package/dist/src/leg.d.ts +1 -1
  25. package/dist/src/message.d.ts +1 -1
  26. package/dist/src/message.js +8 -5
  27. package/dist/src/money.d.ts +1 -0
  28. package/dist/src/money.js +18 -3
  29. package/dist/src/protocol.d.ts +1 -0
  30. package/dist/src/protocol.js +8 -0
  31. package/dist/src/router.d.ts +2 -0
  32. package/dist/src/router.js +45 -7
  33. package/dist/src/state.js +3 -1
  34. package/dist/src/wire.js +19 -2
  35. package/dist/test/erc20.test.js +95 -31
  36. package/dist/test/fake.d.ts +29 -0
  37. package/dist/test/fake.js +79 -0
  38. package/dist/test/lifecycle.test.js +31 -3
  39. package/dist/test/money.test.d.ts +1 -0
  40. package/dist/test/money.test.js +27 -0
  41. package/dist/test/mpesa.test.js +41 -8
  42. package/dist/test/paypal.test.js +33 -8
  43. package/dist/test/policy.test.js +6 -2
  44. package/dist/test/router.test.d.ts +1 -0
  45. package/dist/test/router.test.js +52 -0
  46. package/dist/test/stripe.test.js +5 -4
  47. package/dist/test/vectors.test.js +48 -2
  48. package/dist/test/wire.test.js +15 -0
  49. package/package.json +1 -1
  50. package/src/adapter.ts +7 -2
  51. package/src/adapters/erc20.ts +150 -51
  52. package/src/adapters/http.ts +14 -0
  53. package/src/adapters/mpesa.ts +148 -28
  54. package/src/adapters/paypal.ts +138 -28
  55. package/src/adapters/stripe.ts +16 -13
  56. package/src/bridge.ts +12 -5
  57. package/src/canonical.ts +5 -0
  58. package/src/client.ts +228 -33
  59. package/src/compliance.ts +20 -3
  60. package/src/crypto.ts +4 -1
  61. package/src/index.ts +0 -1
  62. package/src/ledger.ts +12 -4
  63. package/src/leg.ts +4 -1
  64. package/src/message.ts +8 -5
  65. package/src/money.ts +19 -3
  66. package/src/protocol.ts +9 -0
  67. package/src/router.ts +44 -4
  68. package/src/state.ts +3 -1
  69. package/src/wire.ts +20 -3
  70. package/src/fake.ts +0 -96
@@ -1,3 +1,4 @@
1
+ import type { Money } from "../money.js";
1
2
  import type { Quote, Authorization, Settlement } from "../message.js";
2
3
  import { RefundKind, type AdapterEvent } from "../adapter.js";
3
4
  import type { PayOutLeg, InteractivePayInLeg, PayInCapabilities, PayOutCapabilities, CollectResult, DisburseResult, PayInPreparation } from "../leg.js";
@@ -32,6 +33,8 @@ export interface Payout {
32
33
  export interface RefundParams {
33
34
  captureId: string;
34
35
  reason: string;
36
+ value?: string;
37
+ currencyCode?: string;
35
38
  }
36
39
  export interface Refund {
37
40
  id: string;
@@ -49,20 +52,38 @@ export interface PaypalConfig {
49
52
  currencies: string[];
50
53
  api: PaypalApi;
51
54
  ids: () => string;
55
+ store?: CaptureStore;
56
+ }
57
+ export interface CaptureRecord {
58
+ intentId: string;
59
+ captureId: string;
60
+ expected: {
61
+ value: string;
62
+ currency: string;
63
+ };
64
+ }
65
+ export interface CaptureStore {
66
+ save(rec: CaptureRecord): Promise<void>;
67
+ byIntent(intentId: string): Promise<CaptureRecord | undefined>;
68
+ }
69
+ export declare class MemoryCaptureStore implements CaptureStore {
70
+ private readonly byIntentMap;
71
+ save(rec: CaptureRecord): Promise<void>;
72
+ byIntent(intentId: string): Promise<CaptureRecord | undefined>;
52
73
  }
53
74
  export declare class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
54
75
  readonly id: string;
55
76
  private readonly currencies;
56
77
  private readonly api;
57
78
  private readonly ids;
58
- private readonly captures;
79
+ private readonly store;
59
80
  constructor(cfg: PaypalConfig);
60
81
  payInCapabilities(): PayInCapabilities;
61
82
  payOutCapabilities(): PayOutCapabilities;
62
83
  collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult>;
63
84
  prepare(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<PayInPreparation>;
64
85
  disburse(intentId: string, quote: Quote, recipientRef: string): Promise<DisburseResult>;
65
- refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
86
+ refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement>;
66
87
  reverseOut(_intentId: string, _reason: string): Promise<Settlement>;
67
88
  parseWebhook(raw: Uint8Array, headers: Record<string, string[]>): Promise<AdapterEvent[]>;
68
89
  }
@@ -6,6 +6,8 @@
6
6
  // can be refunded in full, but a delivered payout cannot be pulled back.
7
7
  import { State } from "../state.js";
8
8
  import { RefundKind } from "../adapter.js";
9
+ import { idempotencyKey } from "../protocol.js";
10
+ import { fetchWithTimeout } from "./http.js";
9
11
  // The public base of the PayPal REST API. It is the same for every live
10
12
  // integration and carries no secret; the sandbox host or a local test server is
11
13
  // injected instead.
@@ -15,25 +17,36 @@ const defaultBaseURL = "https://api-m.paypal.com";
15
17
  const referencePrefix = "pact:";
16
18
  // ErrSignatureMismatch reports a webhook PayPal could not authenticate.
17
19
  export const ErrSignatureMismatch = "paypal: webhook signature does not verify";
20
+ // MemoryCaptureStore is the default in-process store.
21
+ export class MemoryCaptureStore {
22
+ byIntentMap = new Map();
23
+ async save(rec) {
24
+ this.byIntentMap.set(rec.intentId, rec);
25
+ }
26
+ async byIntent(intentId) {
27
+ return this.byIntentMap.get(intentId);
28
+ }
29
+ }
18
30
  // PaypalLeg moves money over PayPal, serving both sides of a corridor.
19
31
  export class PaypalLeg {
20
32
  id;
21
33
  currencies;
22
34
  api;
23
35
  ids;
24
- captures = new Map();
36
+ store;
25
37
  constructor(cfg) {
26
38
  this.id = cfg.id ?? "paypal";
27
39
  this.currencies = cfg.currencies;
28
40
  this.api = cfg.api;
29
41
  this.ids = cfg.ids;
42
+ this.store = cfg.store ?? new MemoryCaptureStore();
30
43
  }
31
44
  payInCapabilities() {
32
45
  return {
33
46
  rails: ["paypal"],
34
47
  currencies: this.currencies,
35
48
  methods: ["paypal", "venmo", "card"],
36
- refunds: RefundKind.Full,
49
+ refunds: RefundKind.Partial,
37
50
  };
38
51
  }
39
52
  payOutCapabilities() {
@@ -49,7 +62,11 @@ export class PaypalLeg {
49
62
  payee: deliverTo,
50
63
  referenceId: referencePrefix + intentId,
51
64
  });
52
- this.captures.set(intentId, capture.captureId);
65
+ await this.store.save({
66
+ intentId,
67
+ captureId: capture.captureId,
68
+ expected: { value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent), currency: quote.srcAmount.currency },
69
+ });
53
70
  return { providerRef: capture.captureId, received: quote.srcAmount.sub(quote.fees) };
54
71
  }
55
72
  // prepare creates an order the payer approves and captures on their own device
@@ -69,6 +86,11 @@ export class PaypalLeg {
69
86
  params.platformFee = majorAmount(quote.fees.minor(), quote.fees.exponent);
70
87
  }
71
88
  const order = await this.api.createOrder(params);
89
+ await this.store.save({
90
+ intentId,
91
+ captureId: "",
92
+ expected: { value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent), currency: quote.srcAmount.currency },
93
+ });
72
94
  return { providerRef: order.id, clientSecret: order.id, method: "paypal" };
73
95
  }
74
96
  // disburse sends a payout of the recipient's amount to recipientRef and returns
@@ -82,18 +104,24 @@ export class PaypalLeg {
82
104
  });
83
105
  return { providerRef: payout.batchId };
84
106
  }
85
- // refundIn reverses a captured payment in full. PayPal captures the order id in
86
- // the capture id it returned from collect, so the refund binds to that
87
- // reference.
88
- async refundIn(intentId, kind, reason) {
89
- if (kind !== RefundKind.Full) {
107
+ // refundIn reverses a captured payment, in full or in part. PayPal captures the
108
+ // order id in the capture id it returned from collect, so the refund binds to
109
+ // that reference; a partial refund names the amount to return.
110
+ async refundIn(intentId, kind, amount, reason) {
111
+ if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
90
112
  throw new Error(`paypal: cannot perform refund kind ${kind}`);
91
113
  }
92
- const captureId = this.captures.get(intentId);
93
- if (!captureId) {
114
+ const rec = await this.store.byIntent(intentId);
115
+ if (!rec || !rec.captureId) {
94
116
  throw new Error(`paypal: no capture for intent ${intentId}`);
95
117
  }
96
- const refund = await this.api.refundCapture({ captureId, reason });
118
+ const captureId = rec.captureId;
119
+ // A partial refund names the amount in major units; a full refund leaves it
120
+ // absent so PayPal returns the whole capture.
121
+ const params = kind === RefundKind.Partial
122
+ ? { captureId, reason, value: majorAmount(amount.minor(), amount.exponent), currencyCode: amount.currency }
123
+ : { captureId, reason };
124
+ const refund = await this.api.refundCapture(params);
97
125
  return {
98
126
  intentId,
99
127
  state: State.Refunded,
@@ -123,8 +151,21 @@ export class PaypalLeg {
123
151
  const intentId = intentFromReference(resource.custom_id ?? "", resource.invoice_id ?? "");
124
152
  const providerRef = resource.id ?? "";
125
153
  switch (event.event_type) {
126
- case "PAYMENT.CAPTURE.COMPLETED":
154
+ case "PAYMENT.CAPTURE.COMPLETED": {
155
+ // The custom_id that ties a capture to an intent is chosen by whoever
156
+ // created the order, so a genuine, signed capture for a different order can
157
+ // carry a target intent's id. Settle only when the captured amount and
158
+ // currency match what the intent was quoted to collect.
159
+ const rec = await this.store.byIntent(intentId);
160
+ if (!rec) {
161
+ throw new Error(`paypal: capture for unknown intent ${JSON.stringify(intentId)}`);
162
+ }
163
+ const want = rec.expected;
164
+ if (resource.amount?.value !== want.value || resource.amount?.currency_code !== want.currency) {
165
+ throw new Error(`paypal: captured ${resource.amount?.value} ${resource.amount?.currency_code} does not match the quoted ${want.value} ${want.currency}`);
166
+ }
127
167
  return [oneEvent(intentId, State.Settled, providerRef, "")];
168
+ }
128
169
  case "PAYMENT.CAPTURE.DENIED":
129
170
  return [oneEvent(intentId, State.Failed, providerRef, "capture denied")];
130
171
  case "PAYMENT.CAPTURE.REFUNDED":
@@ -182,7 +223,7 @@ class HttpPaypalApi {
182
223
  return this.token;
183
224
  }
184
225
  const basic = Buffer.from(`${this.creds.clientId}:${this.creds.clientSecret}`).toString("base64");
185
- const resp = await fetch(`${this.baseURL}/v1/oauth2/token`, {
226
+ const resp = await fetchWithTimeout(`${this.baseURL}/v1/oauth2/token`, {
186
227
  method: "POST",
187
228
  headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
188
229
  body: "grant_type=client_credentials",
@@ -203,15 +244,12 @@ class HttpPaypalApi {
203
244
  if (params.payee) {
204
245
  unit.payee = { email_address: params.payee };
205
246
  }
206
- const created = await this.postJSON("/v2/checkout/orders", {
207
- intent: "CAPTURE",
208
- purchase_units: [unit],
209
- });
247
+ const created = await this.postJSON("/v2/checkout/orders", { intent: "CAPTURE", purchase_units: [unit] }, idempotencyKey(params.referenceId, "paypal-order"));
210
248
  const existing = firstCapture(created);
211
249
  if (existing) {
212
250
  return { orderId: created.id ?? "", captureId: existing.id ?? "", status: existing.status ?? "" };
213
251
  }
214
- const captured = await this.postJSON(`/v2/checkout/orders/${encodeURIComponent(created.id ?? "")}/capture`, {});
252
+ const captured = await this.postJSON(`/v2/checkout/orders/${encodeURIComponent(created.id ?? "")}/capture`, {}, idempotencyKey(params.referenceId, "paypal-capture"));
215
253
  const capture = firstCapture(captured);
216
254
  if (!capture) {
217
255
  throw new Error(`paypal: order ${created.id} captured without a capture id`);
@@ -235,10 +273,7 @@ class HttpPaypalApi {
235
273
  platform_fees: [{ amount: { currency_code: params.currencyCode, value: params.platformFee } }],
236
274
  };
237
275
  }
238
- const created = await this.postJSON("/v2/checkout/orders", {
239
- intent: "CAPTURE",
240
- purchase_units: [unit],
241
- });
276
+ const created = await this.postJSON("/v2/checkout/orders", { intent: "CAPTURE", purchase_units: [unit] }, idempotencyKey(params.referenceId, "paypal-order"));
242
277
  let approve = "";
243
278
  for (const link of created.links ?? []) {
244
279
  if (link.rel === "approve" || link.rel === "payer-action") {
@@ -267,7 +302,11 @@ class HttpPaypalApi {
267
302
  if (params.reason) {
268
303
  body.note_to_payer = params.reason;
269
304
  }
270
- const out = await this.postJSON(`/v2/payments/captures/${encodeURIComponent(params.captureId)}/refund`, body);
305
+ // A partial refund names the amount; an absent value refunds the full capture.
306
+ if (params.value) {
307
+ body.amount = { value: params.value, currency_code: params.currencyCode };
308
+ }
309
+ const out = await this.postJSON(`/v2/payments/captures/${encodeURIComponent(params.captureId)}/refund`, body, idempotencyKey(params.captureId, "paypal-refund"));
271
310
  return { id: out.id ?? "", status: out.status ?? "" };
272
311
  }
273
312
  // verifyWebhook forwards the signature headers and the raw body to PayPal's
@@ -275,23 +314,46 @@ class HttpPaypalApi {
275
314
  // binds a payload to this integration; PayPal reports whether the signature is
276
315
  // authentic.
277
316
  async verifyWebhook(headers, body) {
278
- const out = await this.postJSON("/v1/notifications/verify-webhook-signature", {
317
+ // PayPal's signature covers the exact bytes of the event it delivered, so the
318
+ // event is forwarded verbatim. Parsing and re-serializing it would reorder
319
+ // keys or restyle numbers and make an authentic webhook fail verification.
320
+ const rawEvent = new TextDecoder().decode(body);
321
+ const sentinel = "__pact_raw_webhook_event__";
322
+ const envelope = JSON.stringify({
279
323
  webhook_id: this.creds.webhookId,
280
324
  transmission_id: header(headers, "PayPal-Transmission-Id"),
281
325
  transmission_time: header(headers, "PayPal-Transmission-Time"),
282
326
  transmission_sig: header(headers, "PayPal-Transmission-Sig"),
283
327
  cert_url: header(headers, "PayPal-Cert-Url"),
284
328
  auth_algo: header(headers, "PayPal-Auth-Algo"),
285
- webhook_event: JSON.parse(new TextDecoder().decode(body)),
329
+ webhook_event: sentinel,
286
330
  });
331
+ const payload = envelope.replace(`"${sentinel}"`, rawEvent);
332
+ const out = await this.postSerialized("/v1/notifications/verify-webhook-signature", payload);
287
333
  return out.verification_status === "SUCCESS";
288
334
  }
289
- async postJSON(path, body) {
335
+ // requestId, when set, is sent as PayPal-Request-Id. PayPal deduplicates a
336
+ // mutation that carries a request id it has already seen, so a retry after a
337
+ // lost response reuses the first order, capture, or refund rather than creating
338
+ // a second.
339
+ async postJSON(path, body, requestId) {
340
+ return this.postSerialized(path, JSON.stringify(body), requestId);
341
+ }
342
+ // postSerialized posts an already-serialized JSON payload, so a caller that must
343
+ // control the exact bytes on the wire — a webhook forwarded verbatim — can do so.
344
+ async postSerialized(path, payload, requestId) {
290
345
  const token = await this.accessToken();
291
- const resp = await fetch(this.baseURL + path, {
346
+ const headers = {
347
+ "Content-Type": "application/json",
348
+ Authorization: `Bearer ${token}`,
349
+ };
350
+ if (requestId) {
351
+ headers["PayPal-Request-Id"] = requestId;
352
+ }
353
+ const resp = await fetchWithTimeout(this.baseURL + path, {
292
354
  method: "POST",
293
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
294
- body: JSON.stringify(body),
355
+ headers,
356
+ body: payload,
295
357
  });
296
358
  if (resp.status >= 300) {
297
359
  throw new Error(`paypal: ${path} returned ${resp.status}`);
@@ -1,3 +1,4 @@
1
+ import { Money } from "../money.js";
1
2
  import type { Quote, Authorization, Settlement } from "../message.js";
2
3
  import { RefundKind, type AdapterEvent } from "../adapter.js";
3
4
  import type { InteractivePayInLeg, PayInCapabilities, CollectResult, PayInPreparation } from "../leg.js";
@@ -45,7 +46,7 @@ export interface StripeConfig {
45
46
  currencies: string[];
46
47
  api: StripeApi;
47
48
  webhookKey: string;
48
- clock?: () => number;
49
+ clock: () => number;
49
50
  tolerance?: number;
50
51
  ids: () => string;
51
52
  methods?: string[];
@@ -63,7 +64,7 @@ export declare class StripeLeg implements InteractivePayInLeg {
63
64
  payInCapabilities(): PayInCapabilities;
64
65
  collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult>;
65
66
  prepare(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<PayInPreparation>;
66
- refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
67
+ refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement>;
67
68
  parseWebhook(raw: Uint8Array, headers: Record<string, string[]>): AdapterEvent[];
68
69
  }
69
70
  export declare const ErrNoSignature = "stripe: missing signature header";
@@ -8,6 +8,8 @@
8
8
  import { createHmac, timingSafeEqual } from "node:crypto";
9
9
  import { State } from "../state.js";
10
10
  import { RefundKind } from "../adapter.js";
11
+ import { idempotencyKey } from "../protocol.js";
12
+ import { fetchWithTimeout } from "./http.js";
11
13
  // The public base of the Stripe REST API. It is the same for every integration
12
14
  // and carries no secret; tests point the leg at a local server instead.
13
15
  const defaultBaseURL = "https://api.stripe.com";
@@ -20,8 +22,6 @@ const metadataIntentKey = "pact_intent_id";
20
22
  // defaultToleranceSeconds is how far a webhook timestamp may drift from now
21
23
  // before it is rejected as a possible replay.
22
24
  const defaultToleranceSeconds = 300;
23
- // The protocol identifier used to bind an idempotency key to one protocol step.
24
- const idPrefix = "pact";
25
25
  // Capture selects when the charge is captured. Manual creates the intent already
26
26
  // carrying a payment method for the server to capture immediately — the flow an
27
27
  // agent or a saved-card charge uses. OnConfirmation defers capture to the moment
@@ -48,11 +48,16 @@ export class StripeLeg {
48
48
  if (!cfg.webhookKey) {
49
49
  throw new Error("stripe: config requires a webhook signing key");
50
50
  }
51
+ // Without a clock every webhook timestamp reads as far in the past and
52
+ // silently fails the tolerance check, so a real webhook never verifies.
53
+ if (!cfg.clock) {
54
+ throw new Error("stripe: config requires a clock for webhook timestamp checks");
55
+ }
51
56
  this.id = cfg.id ?? "stripe";
52
57
  this.currencies = cfg.currencies;
53
58
  this.api = cfg.api;
54
59
  this.webhookKey = cfg.webhookKey;
55
- this.clock = cfg.clock ?? (() => 0);
60
+ this.clock = cfg.clock;
56
61
  this.tolerance = cfg.tolerance ?? defaultToleranceSeconds;
57
62
  this.ids = cfg.ids;
58
63
  this.methods = cfg.methods;
@@ -106,12 +111,15 @@ export class StripeLeg {
106
111
  // refundIn reverses a captured payment, in full or in part. Stripe supports
107
112
  // both, so the leg accepts the full and partial refund kinds and rejects a
108
113
  // counter-transfer it cannot express.
109
- async refundIn(intentId, kind, reason) {
114
+ async refundIn(intentId, kind, amount, reason) {
110
115
  if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
111
116
  throw new Error(`stripe: cannot perform refund kind ${kind}`);
112
117
  }
113
118
  const pi = await this.api.findPaymentIntent(intentId);
114
- const refund = await this.api.createRefund({ paymentIntentId: pi.id, amount: 0, reason }, idempotencyKey(intentId, "refund"));
119
+ // Stripe refunds the full capture when no amount is set, so a full refund
120
+ // leaves amount zero and a partial one carries the exact minor units to return.
121
+ const minor = kind === RefundKind.Partial ? minorToInteger(amount) : 0;
122
+ const refund = await this.api.createRefund({ paymentIntentId: pi.id, amount: minor, reason }, idempotencyKey(intentId, "refund"));
115
123
  return {
116
124
  intentId,
117
125
  state: State.Refunded,
@@ -145,11 +153,6 @@ function signatureHeader(headers) {
145
153
  }
146
154
  return "";
147
155
  }
148
- // idempotencyKey binds a Stripe mutation to one protocol step so a retry reuses
149
- // the original result instead of acting twice.
150
- function idempotencyKey(ref, step) {
151
- return `${idPrefix}:${ref}:${step}`;
152
- }
153
156
  // minorToInteger narrows a Money amount to the safe integer Stripe expects,
154
157
  // refusing anything that would overflow. Fiat amounts fit comfortably; the guard
155
158
  // exists so a token amount can never be sent to a card rail by mistake.
@@ -342,7 +345,7 @@ class HttpStripeApi {
342
345
  Authorization: `Bearer ${this.secretKey}`,
343
346
  "Stripe-Version": apiVersion,
344
347
  };
345
- const resp = await fetch(this.baseURL + path, { ...init, headers });
348
+ const resp = await fetchWithTimeout(this.baseURL + path, { ...init, headers });
346
349
  const body = await resp.text();
347
350
  if (resp.status >= 300) {
348
351
  throw new Error(`stripe: ${path} returned ${resp.status}: ${stripeErrorMessage(body)}`);
@@ -83,14 +83,21 @@ export function applyInverseRate(dst, rate, srcCurrency, srcExponent) {
83
83
  // parseRate reads a decimal rate like "129.45" into a numerator and denominator,
84
84
  // so the conversion stays exact integer arithmetic with no floating point.
85
85
  export function parseRate(rate) {
86
+ // Bound the length so an untrusted rate can't force a huge bigint parse and
87
+ // exponentiation in the FX math.
88
+ if (rate.length > 80) {
89
+ throw new Error("pact: rate has more than 80 characters");
90
+ }
91
+ // A non-negative integer part with no leading zeros and an optional fractional
92
+ // part; no sign, no radix prefix, no whitespace, matching the amount grammar so
93
+ // the SDKs never disagree on a rate's validity or value.
94
+ if (!/^(0|[1-9][0-9]*)(\.[0-9]+)?$/.test(rate)) {
95
+ throw new Error(`pact: ${JSON.stringify(rate)} is not a canonical decimal rate`);
96
+ }
86
97
  const dot = rate.indexOf(".");
87
98
  const whole = dot < 0 ? rate : rate.slice(0, dot);
88
99
  const frac = dot < 0 ? "" : rate.slice(dot + 1);
89
- const digits = whole + frac;
90
- if (!/^\d+$/.test(digits)) {
91
- throw new Error(`pact: ${JSON.stringify(rate)} is not a decimal rate`);
92
- }
93
- const num = BigInt(digits);
100
+ const num = BigInt(whole + frac);
94
101
  const den = frac.length > 0 ? pow10(frac.length) : 1n;
95
102
  return [num, den];
96
103
  }
@@ -20,6 +20,11 @@ export class CanonicalWriter {
20
20
  this.push(b);
21
21
  return this;
22
22
  }
23
+ // str writes a string as its raw UTF-8 bytes. No Unicode normalization is
24
+ // applied: the bytes are hashed as given, so two participants that compose the
25
+ // same text in different Unicode forms produce different hashes. Callers that
26
+ // need them to agree must normalize before building a message; string fields are
27
+ // otherwise treated as opaque bytes.
23
28
  str(s) {
24
29
  return this.bytes(encoder.encode(s));
25
30
  }
@@ -4,9 +4,9 @@ import { type Intent, type Quote, type Authorization, type Settlement } from "./
4
4
  import { type Signer, type Verifier } from "./signing.js";
5
5
  import { type Bridge } from "./bridge.js";
6
6
  import { type PayInLeg, type PayOutLeg, type PayInPreparation } from "./leg.js";
7
- import type { AdapterEvent } from "./adapter.js";
7
+ import { type AdapterEvent } from "./adapter.js";
8
8
  import { type Policy } from "./router.js";
9
- import { type KycProvider, type RiskHook } from "./compliance.js";
9
+ import { type KycProvider, type RiskHook, type SignerAuthorizer } from "./compliance.js";
10
10
  import { type Ledger, type LedgerEvent } from "./ledger.js";
11
11
  export type Clock = () => number;
12
12
  export type IdGen = () => string;
@@ -21,6 +21,7 @@ export interface ClientConfig {
21
21
  verifier: Verifier;
22
22
  kyc?: KycProvider;
23
23
  risk?: RiskHook;
24
+ signerAuth?: SignerAuthorizer;
24
25
  clock?: Clock;
25
26
  idGen?: IdGen;
26
27
  skew?: number;
@@ -49,6 +50,7 @@ export declare class Client {
49
50
  private readonly verifier;
50
51
  private readonly kyc;
51
52
  private readonly risk;
53
+ private readonly signerAuth;
52
54
  private readonly clock;
53
55
  private readonly idGen;
54
56
  private readonly skew;
@@ -63,6 +65,7 @@ export declare class Client {
63
65
  private pickBridge;
64
66
  select(quotes: Quote[], identity: string): Promise<Quote>;
65
67
  authorize(intent: Intent, quote: Quote, signer: Signer): Promise<Authorization>;
68
+ private resolvePayIn;
66
69
  initiate(intent: Intent, quote: Quote, auth: Authorization): Promise<State>;
67
70
  interactiveInitiate(intent: Intent, quote: Quote, auth: Authorization): Promise<{
68
71
  preparation: PayInPreparation;
@@ -74,7 +77,12 @@ export declare class Client {
74
77
  }>;
75
78
  private holdAndDisburse;
76
79
  private settleBridged;
80
+ private claimRefund;
77
81
  private unwind;
82
+ refund(intentId: string, amount: Money, reason: string): Promise<{
83
+ settlement: Settlement;
84
+ state: State;
85
+ }>;
78
86
  private recordFailure;
79
87
  private finishAdvance;
80
88
  private finish;