@myzonerocks/pact 0.1.2 → 0.1.4

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 (67) hide show
  1. package/dist/src/adapter.d.ts +1 -1
  2. package/dist/src/adapters/erc20.d.ts +13 -3
  3. package/dist/src/adapters/erc20.js +93 -49
  4. package/dist/src/adapters/http.d.ts +2 -0
  5. package/dist/src/adapters/http.js +12 -0
  6. package/dist/src/adapters/mpesa.d.ts +9 -2
  7. package/dist/src/adapters/mpesa.js +82 -12
  8. package/dist/src/adapters/paypal.d.ts +15 -3
  9. package/dist/src/adapters/paypal.js +121 -21
  10. package/dist/src/adapters/stripe.d.ts +6 -2
  11. package/dist/src/adapters/stripe.js +25 -12
  12. package/dist/src/bridge.js +12 -5
  13. package/dist/src/canonical.js +5 -0
  14. package/dist/src/client.d.ts +8 -2
  15. package/dist/src/client.js +181 -20
  16. package/dist/src/compliance.d.ts +4 -0
  17. package/dist/src/compliance.js +10 -3
  18. package/dist/src/crypto.js +4 -1
  19. package/dist/src/index.d.ts +0 -1
  20. package/dist/src/index.js +0 -1
  21. package/dist/src/ledger.d.ts +6 -2
  22. package/dist/src/ledger.js +2 -2
  23. package/dist/src/leg.d.ts +1 -1
  24. package/dist/src/message.d.ts +1 -1
  25. package/dist/src/message.js +8 -5
  26. package/dist/src/money.d.ts +1 -0
  27. package/dist/src/money.js +18 -3
  28. package/dist/src/protocol.d.ts +1 -0
  29. package/dist/src/protocol.js +8 -0
  30. package/dist/src/router.d.ts +2 -0
  31. package/dist/src/router.js +45 -7
  32. package/dist/src/wire.js +19 -2
  33. package/dist/test/erc20.test.js +95 -31
  34. package/dist/test/fake.d.ts +29 -0
  35. package/dist/test/fake.js +79 -0
  36. package/dist/test/lifecycle.test.js +31 -3
  37. package/dist/test/money.test.d.ts +1 -0
  38. package/dist/test/money.test.js +27 -0
  39. package/dist/test/mpesa.test.js +41 -8
  40. package/dist/test/paypal.test.js +54 -8
  41. package/dist/test/policy.test.js +6 -2
  42. package/dist/test/router.test.d.ts +1 -0
  43. package/dist/test/router.test.js +52 -0
  44. package/dist/test/stripe.test.js +5 -4
  45. package/dist/test/vectors.test.js +48 -2
  46. package/dist/test/wire.test.js +15 -0
  47. package/package.json +1 -1
  48. package/src/adapter.ts +7 -2
  49. package/src/adapters/erc20.ts +118 -51
  50. package/src/adapters/http.ts +14 -0
  51. package/src/adapters/mpesa.ts +102 -13
  52. package/src/adapters/paypal.ts +168 -22
  53. package/src/adapters/stripe.ts +16 -13
  54. package/src/bridge.ts +12 -5
  55. package/src/canonical.ts +5 -0
  56. package/src/client.ts +194 -22
  57. package/src/compliance.ts +20 -3
  58. package/src/crypto.ts +4 -1
  59. package/src/index.ts +0 -1
  60. package/src/ledger.ts +12 -4
  61. package/src/leg.ts +4 -1
  62. package/src/message.ts +8 -5
  63. package/src/money.ts +19 -3
  64. package/src/protocol.ts +9 -0
  65. package/src/router.ts +44 -4
  66. package/src/wire.ts +20 -3
  67. package/src/fake.ts +0 -96
@@ -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.
@@ -22,6 +24,9 @@ export class PaypalLeg {
22
24
  api;
23
25
  ids;
24
26
  captures = new Map();
27
+ // What each intent was quoted to collect, so a capture webhook can be
28
+ // cross-checked: the paid amount and currency must match before it settles.
29
+ expected = new Map();
25
30
  constructor(cfg) {
26
31
  this.id = cfg.id ?? "paypal";
27
32
  this.currencies = cfg.currencies;
@@ -33,7 +38,7 @@ export class PaypalLeg {
33
38
  rails: ["paypal"],
34
39
  currencies: this.currencies,
35
40
  methods: ["paypal", "venmo", "card"],
36
- refunds: RefundKind.Full,
41
+ refunds: RefundKind.Partial,
37
42
  };
38
43
  }
39
44
  payOutCapabilities() {
@@ -50,8 +55,35 @@ export class PaypalLeg {
50
55
  referenceId: referencePrefix + intentId,
51
56
  });
52
57
  this.captures.set(intentId, capture.captureId);
58
+ this.expected.set(intentId, {
59
+ value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
60
+ currency: quote.srcAmount.currency,
61
+ });
53
62
  return { providerRef: capture.captureId, received: quote.srcAmount.sub(quote.fees) };
54
63
  }
64
+ // prepare creates an order the payer approves and captures on their own device
65
+ // through the PayPal or Venmo flow — the interactive pay-in path — so nothing is
66
+ // captured here. The order names the recipient as payee and our cut as a platform
67
+ // fee; the buyer's capture then fires PAYMENT.CAPTURE.COMPLETED, which advances
68
+ // the corridor through the same pay-in path as a server-side collection. The
69
+ // order id is the token the payer's PayPal buttons need.
70
+ async prepare(intentId, quote, _auth, deliverTo) {
71
+ const params = {
72
+ value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
73
+ currencyCode: quote.srcAmount.currency,
74
+ payee: deliverTo,
75
+ referenceId: referencePrefix + intentId,
76
+ };
77
+ if (!quote.fees.isZero()) {
78
+ params.platformFee = majorAmount(quote.fees.minor(), quote.fees.exponent);
79
+ }
80
+ const order = await this.api.createOrder(params);
81
+ this.expected.set(intentId, {
82
+ value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
83
+ currency: quote.srcAmount.currency,
84
+ });
85
+ return { providerRef: order.id, clientSecret: order.id, method: "paypal" };
86
+ }
55
87
  // disburse sends a payout of the recipient's amount to recipientRef and returns
56
88
  // the batch id.
57
89
  async disburse(intentId, quote, recipientRef) {
@@ -63,18 +95,23 @@ export class PaypalLeg {
63
95
  });
64
96
  return { providerRef: payout.batchId };
65
97
  }
66
- // refundIn reverses a captured payment in full. PayPal captures the order id in
67
- // the capture id it returned from collect, so the refund binds to that
68
- // reference.
69
- async refundIn(intentId, kind, reason) {
70
- if (kind !== RefundKind.Full) {
98
+ // refundIn reverses a captured payment, in full or in part. PayPal captures the
99
+ // order id in the capture id it returned from collect, so the refund binds to
100
+ // that reference; a partial refund names the amount to return.
101
+ async refundIn(intentId, kind, amount, reason) {
102
+ if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
71
103
  throw new Error(`paypal: cannot perform refund kind ${kind}`);
72
104
  }
73
105
  const captureId = this.captures.get(intentId);
74
106
  if (!captureId) {
75
107
  throw new Error(`paypal: no capture for intent ${intentId}`);
76
108
  }
77
- const refund = await this.api.refundCapture({ captureId, reason });
109
+ // A partial refund names the amount in major units; a full refund leaves it
110
+ // absent so PayPal returns the whole capture.
111
+ const params = kind === RefundKind.Partial
112
+ ? { captureId, reason, value: majorAmount(amount.minor(), amount.exponent), currencyCode: amount.currency }
113
+ : { captureId, reason };
114
+ const refund = await this.api.refundCapture(params);
78
115
  return {
79
116
  intentId,
80
117
  state: State.Refunded,
@@ -104,8 +141,20 @@ export class PaypalLeg {
104
141
  const intentId = intentFromReference(resource.custom_id ?? "", resource.invoice_id ?? "");
105
142
  const providerRef = resource.id ?? "";
106
143
  switch (event.event_type) {
107
- case "PAYMENT.CAPTURE.COMPLETED":
144
+ case "PAYMENT.CAPTURE.COMPLETED": {
145
+ // The custom_id that ties a capture to an intent is chosen by whoever
146
+ // created the order, so a genuine, signed capture for a different order can
147
+ // carry a target intent's id. Settle only when the captured amount and
148
+ // currency match what the intent was quoted to collect.
149
+ const want = this.expected.get(intentId);
150
+ if (!want) {
151
+ throw new Error(`paypal: capture for unknown intent ${JSON.stringify(intentId)}`);
152
+ }
153
+ if (resource.amount?.value !== want.value || resource.amount?.currency_code !== want.currency) {
154
+ throw new Error(`paypal: captured ${resource.amount?.value} ${resource.amount?.currency_code} does not match the quoted ${want.value} ${want.currency}`);
155
+ }
108
156
  return [oneEvent(intentId, State.Settled, providerRef, "")];
157
+ }
109
158
  case "PAYMENT.CAPTURE.DENIED":
110
159
  return [oneEvent(intentId, State.Failed, providerRef, "capture denied")];
111
160
  case "PAYMENT.CAPTURE.REFUNDED":
@@ -163,7 +212,7 @@ class HttpPaypalApi {
163
212
  return this.token;
164
213
  }
165
214
  const basic = Buffer.from(`${this.creds.clientId}:${this.creds.clientSecret}`).toString("base64");
166
- const resp = await fetch(`${this.baseURL}/v1/oauth2/token`, {
215
+ const resp = await fetchWithTimeout(`${this.baseURL}/v1/oauth2/token`, {
167
216
  method: "POST",
168
217
  headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
169
218
  body: "grant_type=client_credentials",
@@ -184,21 +233,45 @@ class HttpPaypalApi {
184
233
  if (params.payee) {
185
234
  unit.payee = { email_address: params.payee };
186
235
  }
187
- const created = await this.postJSON("/v2/checkout/orders", {
188
- intent: "CAPTURE",
189
- purchase_units: [unit],
190
- });
236
+ const created = await this.postJSON("/v2/checkout/orders", { intent: "CAPTURE", purchase_units: [unit] }, idempotencyKey(params.referenceId, "paypal-order"));
191
237
  const existing = firstCapture(created);
192
238
  if (existing) {
193
239
  return { orderId: created.id ?? "", captureId: existing.id ?? "", status: existing.status ?? "" };
194
240
  }
195
- const captured = await this.postJSON(`/v2/checkout/orders/${encodeURIComponent(created.id ?? "")}/capture`, {});
241
+ const captured = await this.postJSON(`/v2/checkout/orders/${encodeURIComponent(created.id ?? "")}/capture`, {}, idempotencyKey(params.referenceId, "paypal-capture"));
196
242
  const capture = firstCapture(captured);
197
243
  if (!capture) {
198
244
  throw new Error(`paypal: order ${created.id} captured without a capture id`);
199
245
  }
200
246
  return { orderId: captured.id ?? "", captureId: capture.id ?? "", status: capture.status ?? "" };
201
247
  }
248
+ // createOrder creates an order the buyer approves and captures on their own
249
+ // device. It carries custom_id so the capture webhook ties back to the intent,
250
+ // and a platform fee via payment_instruction when one is set.
251
+ async createOrder(params) {
252
+ const unit = {
253
+ reference_id: params.referenceId,
254
+ custom_id: params.referenceId,
255
+ amount: { currency_code: params.currencyCode, value: params.value },
256
+ };
257
+ if (params.payee) {
258
+ unit.payee = { email_address: params.payee };
259
+ }
260
+ if (params.platformFee) {
261
+ unit.payment_instruction = {
262
+ platform_fees: [{ amount: { currency_code: params.currencyCode, value: params.platformFee } }],
263
+ };
264
+ }
265
+ const created = await this.postJSON("/v2/checkout/orders", { intent: "CAPTURE", purchase_units: [unit] }, idempotencyKey(params.referenceId, "paypal-order"));
266
+ let approve = "";
267
+ for (const link of created.links ?? []) {
268
+ if (link.rel === "approve" || link.rel === "payer-action") {
269
+ approve = link.href ?? "";
270
+ break;
271
+ }
272
+ }
273
+ return { id: created.id ?? "", status: created.status ?? "", approveUrl: approve };
274
+ }
202
275
  async sendPayout(params) {
203
276
  const out = await this.postJSON("/v1/payments/payouts", {
204
277
  sender_batch_header: { sender_batch_id: params.referenceId },
@@ -218,7 +291,11 @@ class HttpPaypalApi {
218
291
  if (params.reason) {
219
292
  body.note_to_payer = params.reason;
220
293
  }
221
- const out = await this.postJSON(`/v2/payments/captures/${encodeURIComponent(params.captureId)}/refund`, body);
294
+ // A partial refund names the amount; an absent value refunds the full capture.
295
+ if (params.value) {
296
+ body.amount = { value: params.value, currency_code: params.currencyCode };
297
+ }
298
+ const out = await this.postJSON(`/v2/payments/captures/${encodeURIComponent(params.captureId)}/refund`, body, idempotencyKey(params.captureId, "paypal-refund"));
222
299
  return { id: out.id ?? "", status: out.status ?? "" };
223
300
  }
224
301
  // verifyWebhook forwards the signature headers and the raw body to PayPal's
@@ -226,23 +303,46 @@ class HttpPaypalApi {
226
303
  // binds a payload to this integration; PayPal reports whether the signature is
227
304
  // authentic.
228
305
  async verifyWebhook(headers, body) {
229
- const out = await this.postJSON("/v1/notifications/verify-webhook-signature", {
306
+ // PayPal's signature covers the exact bytes of the event it delivered, so the
307
+ // event is forwarded verbatim. Parsing and re-serializing it would reorder
308
+ // keys or restyle numbers and make an authentic webhook fail verification.
309
+ const rawEvent = new TextDecoder().decode(body);
310
+ const sentinel = "__pact_raw_webhook_event__";
311
+ const envelope = JSON.stringify({
230
312
  webhook_id: this.creds.webhookId,
231
313
  transmission_id: header(headers, "PayPal-Transmission-Id"),
232
314
  transmission_time: header(headers, "PayPal-Transmission-Time"),
233
315
  transmission_sig: header(headers, "PayPal-Transmission-Sig"),
234
316
  cert_url: header(headers, "PayPal-Cert-Url"),
235
317
  auth_algo: header(headers, "PayPal-Auth-Algo"),
236
- webhook_event: JSON.parse(new TextDecoder().decode(body)),
318
+ webhook_event: sentinel,
237
319
  });
320
+ const payload = envelope.replace(`"${sentinel}"`, rawEvent);
321
+ const out = await this.postSerialized("/v1/notifications/verify-webhook-signature", payload);
238
322
  return out.verification_status === "SUCCESS";
239
323
  }
240
- async postJSON(path, body) {
324
+ // requestId, when set, is sent as PayPal-Request-Id. PayPal deduplicates a
325
+ // mutation that carries a request id it has already seen, so a retry after a
326
+ // lost response reuses the first order, capture, or refund rather than creating
327
+ // a second.
328
+ async postJSON(path, body, requestId) {
329
+ return this.postSerialized(path, JSON.stringify(body), requestId);
330
+ }
331
+ // postSerialized posts an already-serialized JSON payload, so a caller that must
332
+ // control the exact bytes on the wire — a webhook forwarded verbatim — can do so.
333
+ async postSerialized(path, payload, requestId) {
241
334
  const token = await this.accessToken();
242
- const resp = await fetch(this.baseURL + path, {
335
+ const headers = {
336
+ "Content-Type": "application/json",
337
+ Authorization: `Bearer ${token}`,
338
+ };
339
+ if (requestId) {
340
+ headers["PayPal-Request-Id"] = requestId;
341
+ }
342
+ const resp = await fetchWithTimeout(this.baseURL + path, {
243
343
  method: "POST",
244
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
245
- body: JSON.stringify(body),
344
+ headers,
345
+ body: payload,
246
346
  });
247
347
  if (resp.status >= 300) {
248
348
  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";
@@ -26,6 +27,7 @@ export interface CreateIntentParams {
26
27
  destination: string;
27
28
  applicationFee?: number;
28
29
  capture?: Capture;
30
+ methods?: string[];
29
31
  metadata: Record<string, string>;
30
32
  }
31
33
  export interface CreateRefundParams {
@@ -44,9 +46,10 @@ export interface StripeConfig {
44
46
  currencies: string[];
45
47
  api: StripeApi;
46
48
  webhookKey: string;
47
- clock?: () => number;
49
+ clock: () => number;
48
50
  tolerance?: number;
49
51
  ids: () => string;
52
+ methods?: string[];
50
53
  }
51
54
  export declare class StripeLeg implements InteractivePayInLeg {
52
55
  readonly id: string;
@@ -56,11 +59,12 @@ export declare class StripeLeg implements InteractivePayInLeg {
56
59
  private readonly clock;
57
60
  private readonly tolerance;
58
61
  private readonly ids;
62
+ private readonly methods;
59
63
  constructor(cfg: StripeConfig);
60
64
  payInCapabilities(): PayInCapabilities;
61
65
  collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult>;
62
66
  prepare(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<PayInPreparation>;
63
- refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
67
+ refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement>;
64
68
  parseWebhook(raw: Uint8Array, headers: Record<string, string[]>): AdapterEvent[];
65
69
  }
66
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
@@ -43,17 +43,24 @@ export class StripeLeg {
43
43
  clock;
44
44
  tolerance;
45
45
  ids;
46
+ methods;
46
47
  constructor(cfg) {
47
48
  if (!cfg.webhookKey) {
48
49
  throw new Error("stripe: config requires a webhook signing key");
49
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
+ }
50
56
  this.id = cfg.id ?? "stripe";
51
57
  this.currencies = cfg.currencies;
52
58
  this.api = cfg.api;
53
59
  this.webhookKey = cfg.webhookKey;
54
- this.clock = cfg.clock ?? (() => 0);
60
+ this.clock = cfg.clock;
55
61
  this.tolerance = cfg.tolerance ?? defaultToleranceSeconds;
56
62
  this.ids = cfg.ids;
63
+ this.methods = cfg.methods;
57
64
  }
58
65
  // payInCapabilities advertises the card rail and the wallets that fund through
59
66
  // it. The methods are informational; the corridor routes on the rail.
@@ -96,6 +103,7 @@ export class StripeLeg {
96
103
  destination: deliverTo,
97
104
  applicationFee: minorToInteger(quote.fees),
98
105
  capture: Capture.OnConfirmation,
106
+ ...(this.methods ? { methods: this.methods } : {}),
99
107
  metadata: { [metadataIntentKey]: intentId },
100
108
  }, idempotencyKey(intentId, "prepare"));
101
109
  return { providerRef: pi.id, clientSecret: pi.clientSecret, method: "card" };
@@ -103,12 +111,15 @@ export class StripeLeg {
103
111
  // refundIn reverses a captured payment, in full or in part. Stripe supports
104
112
  // both, so the leg accepts the full and partial refund kinds and rejects a
105
113
  // counter-transfer it cannot express.
106
- async refundIn(intentId, kind, reason) {
114
+ async refundIn(intentId, kind, amount, reason) {
107
115
  if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
108
116
  throw new Error(`stripe: cannot perform refund kind ${kind}`);
109
117
  }
110
118
  const pi = await this.api.findPaymentIntent(intentId);
111
- 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"));
112
123
  return {
113
124
  intentId,
114
125
  state: State.Refunded,
@@ -142,11 +153,6 @@ function signatureHeader(headers) {
142
153
  }
143
154
  return "";
144
155
  }
145
- // idempotencyKey binds a Stripe mutation to one protocol step so a retry reuses
146
- // the original result instead of acting twice.
147
- function idempotencyKey(ref, step) {
148
- return `${idPrefix}:${ref}:${step}`;
149
- }
150
156
  // minorToInteger narrows a Money amount to the safe integer Stripe expects,
151
157
  // refusing anything that would overflow. Fiat amounts fit comfortably; the guard
152
158
  // exists so a token amount can never be sent to a card rail by mistake.
@@ -277,7 +283,14 @@ class HttpStripeApi {
277
283
  if ((params.capture ?? Capture.Manual) === Capture.Manual) {
278
284
  form.set("capture_method", "manual");
279
285
  }
280
- form.set("automatic_payment_methods[enabled]", "true");
286
+ // Pinning the method types offers exactly those and nothing else; without a
287
+ // pin, dynamic payment methods surface whatever the dashboard has enabled.
288
+ if (params.methods && params.methods.length > 0) {
289
+ params.methods.forEach((m, i) => form.set(`payment_method_types[${i}]`, m));
290
+ }
291
+ else {
292
+ form.set("automatic_payment_methods[enabled]", "true");
293
+ }
281
294
  if (params.destination) {
282
295
  form.set("transfer_data[destination]", params.destination);
283
296
  }
@@ -332,7 +345,7 @@ class HttpStripeApi {
332
345
  Authorization: `Bearer ${this.secretKey}`,
333
346
  "Stripe-Version": apiVersion,
334
347
  };
335
- const resp = await fetch(this.baseURL + path, { ...init, headers });
348
+ const resp = await fetchWithTimeout(this.baseURL + path, { ...init, headers });
336
349
  const body = await resp.text();
337
350
  if (resp.status >= 300) {
338
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;
@@ -75,6 +77,10 @@ export declare class Client {
75
77
  private holdAndDisburse;
76
78
  private settleBridged;
77
79
  private unwind;
80
+ refund(intentId: string, amount: Money, reason: string): Promise<{
81
+ settlement: Settlement;
82
+ state: State;
83
+ }>;
78
84
  private recordFailure;
79
85
  private finishAdvance;
80
86
  private finish;