@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
@@ -10,6 +10,8 @@ import { Money } from "../money.js";
10
10
  import { State } from "../state.js";
11
11
  import type { Quote, Authorization, Settlement } from "../message.js";
12
12
  import { RefundKind, type AdapterEvent } from "../adapter.js";
13
+ import { idempotencyKey } from "../protocol.js";
14
+ import { fetchWithTimeout } from "./http.js";
13
15
  import type { InteractivePayInLeg, PayInCapabilities, CollectResult, PayInPreparation } from "../leg.js";
14
16
 
15
17
  // The public base of the Stripe REST API. It is the same for every integration
@@ -29,7 +31,6 @@ const metadataIntentKey = "pact_intent_id";
29
31
  const defaultToleranceSeconds = 300;
30
32
 
31
33
  // The protocol identifier used to bind an idempotency key to one protocol step.
32
- const idPrefix = "pact";
33
34
 
34
35
  // PaymentIntent is the subset of Stripe's PaymentIntent this leg reads.
35
36
  // clientSecret is present only on a freshly created intent and is the single
@@ -108,9 +109,8 @@ export interface StripeConfig {
108
109
  currencies: string[];
109
110
  api: StripeApi;
110
111
  webhookKey: string;
111
- // clock returns the current time in Unix seconds, for webhook timestamp
112
- // checks. When absent, timestamps are checked against zero.
113
- clock?: () => number;
112
+ // clock returns the current time in Unix seconds, for webhook timestamp checks.
113
+ clock: () => number;
114
114
  // tolerance is the webhook timestamp tolerance in seconds; defaults to 300.
115
115
  tolerance?: number;
116
116
  ids: () => string;
@@ -136,11 +136,16 @@ export class StripeLeg implements InteractivePayInLeg {
136
136
  if (!cfg.webhookKey) {
137
137
  throw new Error("stripe: config requires a webhook signing key");
138
138
  }
139
+ // Without a clock every webhook timestamp reads as far in the past and
140
+ // silently fails the tolerance check, so a real webhook never verifies.
141
+ if (!cfg.clock) {
142
+ throw new Error("stripe: config requires a clock for webhook timestamp checks");
143
+ }
139
144
  this.id = cfg.id ?? "stripe";
140
145
  this.currencies = cfg.currencies;
141
146
  this.api = cfg.api;
142
147
  this.webhookKey = cfg.webhookKey;
143
- this.clock = cfg.clock ?? (() => 0);
148
+ this.clock = cfg.clock;
144
149
  this.tolerance = cfg.tolerance ?? defaultToleranceSeconds;
145
150
  this.ids = cfg.ids;
146
151
  this.methods = cfg.methods;
@@ -204,13 +209,16 @@ export class StripeLeg implements InteractivePayInLeg {
204
209
  // refundIn reverses a captured payment, in full or in part. Stripe supports
205
210
  // both, so the leg accepts the full and partial refund kinds and rejects a
206
211
  // counter-transfer it cannot express.
207
- async refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement> {
212
+ async refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement> {
208
213
  if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
209
214
  throw new Error(`stripe: cannot perform refund kind ${kind}`);
210
215
  }
211
216
  const pi = await this.api.findPaymentIntent(intentId);
217
+ // Stripe refunds the full capture when no amount is set, so a full refund
218
+ // leaves amount zero and a partial one carries the exact minor units to return.
219
+ const minor = kind === RefundKind.Partial ? minorToInteger(amount) : 0;
212
220
  const refund = await this.api.createRefund(
213
- { paymentIntentId: pi.id, amount: 0, reason },
221
+ { paymentIntentId: pi.id, amount: minor, reason },
214
222
  idempotencyKey(intentId, "refund"),
215
223
  );
216
224
  return {
@@ -249,11 +257,6 @@ function signatureHeader(headers: Record<string, string[]>): string {
249
257
  return "";
250
258
  }
251
259
 
252
- // idempotencyKey binds a Stripe mutation to one protocol step so a retry reuses
253
- // the original result instead of acting twice.
254
- function idempotencyKey(ref: string, step: string): string {
255
- return `${idPrefix}:${ref}:${step}`;
256
- }
257
260
 
258
261
  // minorToInteger narrows a Money amount to the safe integer Stripe expects,
259
262
  // refusing anything that would overflow. Fiat amounts fit comfortably; the guard
@@ -484,7 +487,7 @@ class HttpStripeApi implements StripeApi {
484
487
  Authorization: `Bearer ${this.secretKey}`,
485
488
  "Stripe-Version": apiVersion,
486
489
  };
487
- const resp = await fetch(this.baseURL + path, { ...init, headers });
490
+ const resp = await fetchWithTimeout(this.baseURL + path, { ...init, headers });
488
491
  const body = await resp.text();
489
492
  if (resp.status >= 300) {
490
493
  throw new Error(`stripe: ${path} returned ${resp.status}: ${stripeErrorMessage(body)}`);
package/src/bridge.ts CHANGED
@@ -131,14 +131,21 @@ export function applyInverseRate(dst: Money, rate: string, srcCurrency: string,
131
131
  // parseRate reads a decimal rate like "129.45" into a numerator and denominator,
132
132
  // so the conversion stays exact integer arithmetic with no floating point.
133
133
  export function parseRate(rate: string): [bigint, bigint] {
134
+ // Bound the length so an untrusted rate can't force a huge bigint parse and
135
+ // exponentiation in the FX math.
136
+ if (rate.length > 80) {
137
+ throw new Error("pact: rate has more than 80 characters");
138
+ }
139
+ // A non-negative integer part with no leading zeros and an optional fractional
140
+ // part; no sign, no radix prefix, no whitespace, matching the amount grammar so
141
+ // the SDKs never disagree on a rate's validity or value.
142
+ if (!/^(0|[1-9][0-9]*)(\.[0-9]+)?$/.test(rate)) {
143
+ throw new Error(`pact: ${JSON.stringify(rate)} is not a canonical decimal rate`);
144
+ }
134
145
  const dot = rate.indexOf(".");
135
146
  const whole = dot < 0 ? rate : rate.slice(0, dot);
136
147
  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);
148
+ const num = BigInt(whole + frac);
142
149
  const den = frac.length > 0 ? pow10(frac.length) : 1n;
143
150
  return [num, den];
144
151
  }
package/src/canonical.ts CHANGED
@@ -26,6 +26,11 @@ export class CanonicalWriter {
26
26
  return this;
27
27
  }
28
28
 
29
+ // str writes a string as its raw UTF-8 bytes. No Unicode normalization is
30
+ // applied: the bytes are hashed as given, so two participants that compose the
31
+ // same text in different Unicode forms produce different hashes. Callers that
32
+ // need them to agree must normalize before building a message; string fields are
33
+ // otherwise treated as opaque bytes.
29
34
  str(s: string): this {
30
35
  return this.bytes(encoder.encode(s));
31
36
  }
package/src/client.ts CHANGED
@@ -18,9 +18,17 @@ import {
18
18
  import { authorize as signAuthorization, verifyAuthorization, type Signer, type Verifier } from "./signing.js";
19
19
  import { PassThroughBridge, type Bridge, type ConvertResult } from "./bridge.js";
20
20
  import { railInList, isInteractivePayInLeg, type PayInLeg, type PayOutLeg, type CollectResult, type PayInPreparation } from "./leg.js";
21
- import type { AdapterEvent } from "./adapter.js";
22
- import { route, type Policy, PolicyKind, NoQuoteError, isExpired } from "./router.js";
23
- import { permissiveKyc, allowRisk, withinLimits, type KycProvider, type RiskHook } from "./compliance.js";
21
+ import { RefundKind, type AdapterEvent } from "./adapter.js";
22
+ import { route, type Policy, PolicyKind, NoQuoteError, isExpired, maxFundingOptions } from "./router.js";
23
+ import {
24
+ permissiveKyc,
25
+ allowRisk,
26
+ withinLimits,
27
+ strictSignerAuthorizer,
28
+ type KycProvider,
29
+ type RiskHook,
30
+ type SignerAuthorizer,
31
+ } from "./compliance.js";
24
32
  import { MemoryLedger, eventReceipt, type Ledger, type LedgerEvent } from "./ledger.js";
25
33
 
26
34
  // Clock returns the current time in Unix milliseconds; IdGen returns a fresh
@@ -50,6 +58,7 @@ export interface ClientConfig {
50
58
  verifier: Verifier;
51
59
  kyc?: KycProvider;
52
60
  risk?: RiskHook;
61
+ signerAuth?: SignerAuthorizer;
53
62
  clock?: Clock;
54
63
  idGen?: IdGen;
55
64
  skew?: number;
@@ -87,6 +96,7 @@ export class Client {
87
96
  private readonly verifier: Verifier;
88
97
  private readonly kyc: KycProvider;
89
98
  private readonly risk: RiskHook;
99
+ private readonly signerAuth: SignerAuthorizer;
90
100
  private readonly clock: Clock;
91
101
  private readonly idGen: IdGen;
92
102
  private readonly skew: number;
@@ -105,6 +115,7 @@ export class Client {
105
115
  this.verifier = cfg.verifier;
106
116
  this.kyc = cfg.kyc ?? permissiveKyc;
107
117
  this.risk = cfg.risk ?? allowRisk;
118
+ this.signerAuth = cfg.signerAuth ?? strictSignerAuthorizer;
108
119
  this.clock = cfg.clock ?? systemClock;
109
120
  this.idGen = cfg.idGen ?? randomId;
110
121
  this.skew = cfg.skew ?? DEFAULT_SKEW_MILLIS;
@@ -113,6 +124,11 @@ export class Client {
113
124
 
114
125
  // createIntent mints an intent and records it as a draft.
115
126
  createIntent(spec: IntentSpec): Intent {
127
+ // A zero deadline means "never expires", which would let an intent advance
128
+ // forever and bypass the liveness bound. Require a real deadline at creation.
129
+ if (!spec.expiresAt) {
130
+ throw new Error("pact: intent requires a non-zero expiry deadline");
131
+ }
116
132
  const intent: Intent = {
117
133
  id: this.idGen(),
118
134
  senderRef: spec.senderRef,
@@ -132,6 +148,9 @@ export class Client {
132
148
  // recipient's currency and a pay-out leg that can deliver it, prices the whole
133
149
  // corridor, and returns the options for the payer to choose among.
134
150
  async quoteOptions(intent: Intent, funding: Funding[]): Promise<Quote[]> {
151
+ if (funding.length > maxFundingOptions) {
152
+ throw new Error(`pact: ${funding.length} funding options exceed the maximum of ${maxFundingOptions}`);
153
+ }
135
154
  const quotes: Quote[] = [];
136
155
  let lastErr: unknown;
137
156
  for (const f of funding) {
@@ -145,6 +164,9 @@ export class Client {
145
164
  continue;
146
165
  }
147
166
  try {
167
+ // Corridors are priced in order so the router sees a stable ranking, matching
168
+ // the other SDKs; the loop is bounded by the configured funding options.
169
+ // eslint-disable-next-line no-await-in-loop
148
170
  quotes.push(await this.composeQuote(intent, payInLeg, f));
149
171
  } catch (err) {
150
172
  lastErr = err;
@@ -242,11 +264,17 @@ export class Client {
242
264
  if (isExpired(quote.expiresAt, now, this.skew)) {
243
265
  throw new Error("pact: quote has expired");
244
266
  }
245
- const status = await this.kyc.status(signer.identity());
267
+ // The signer must be allowed to act for the sender, and compliance is measured
268
+ // against the sender — the party actually funding the payment — not whoever
269
+ // holds the signing key.
270
+ if (!this.signerAuth.authorized(signer.identity(), intent.senderRef)) {
271
+ throw new Error("pact: signer is not authorized to act for the intent's sender");
272
+ }
273
+ const status = await this.kyc.status(intent.senderRef);
246
274
  if (!withinLimits(status, quote)) {
247
275
  throw new Error("pact: quote exceeds KYC limit");
248
276
  }
249
- const decision = this.risk.evaluate(signer.identity(), intent, quote);
277
+ const decision = this.risk.evaluate(intent.senderRef, intent, quote);
250
278
  if (!decision.allow) {
251
279
  throw new Error(`pact: risk hook vetoed authorization: ${decision.reason}`);
252
280
  }
@@ -264,12 +292,33 @@ export class Client {
264
292
  // driven forward by advance as provider events arrive, so no promise or
265
293
  // connection is held open per payment and durable state lives only in the
266
294
  // ledger.
267
- async initiate(intent: Intent, quote: Quote, auth: Authorization): Promise<State> {
268
- verifyAuthorization(auth, intent, quote, this.verifier);
295
+ // resolvePayIn rejects an expired intent and returns the corridor's pay-in leg — the
296
+ // expiry-and-leg check the immediate and interactive starts share.
297
+ private resolvePayIn(intent: Intent, quote: Quote): PayInLeg {
298
+ if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
299
+ throw new Error("pact: intent has expired");
300
+ }
269
301
  const payInLeg = this.payIn.get(quote.payInAdapterId);
270
302
  if (!payInLeg) {
271
303
  throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
272
304
  }
305
+ return payInLeg;
306
+ }
307
+
308
+ async initiate(intent: Intent, quote: Quote, auth: Authorization): Promise<State> {
309
+ verifyAuthorization(auth, intent, quote, this.verifier);
310
+ if (!this.signerAuth.authorized(auth.signerIdentity, intent.senderRef)) {
311
+ throw new Error("pact: signer is not authorized to act for the intent's sender");
312
+ }
313
+ const payInLeg = this.resolvePayIn(intent, quote);
314
+
315
+ // A retry of an already-submitted corridor must not collect a second time. The
316
+ // pay-in leg carries its own provider idempotency for the narrow window where a
317
+ // collection completed but its record was lost.
318
+ const submitted = this.ledger.state(intent.id);
319
+ if (submitted === State.Submitted || submitted === State.Collecting) {
320
+ return submitted;
321
+ }
273
322
 
274
323
  // A direct corridor collects straight to the recipient; a bridged one
275
324
  // collects into escrow first.
@@ -293,13 +342,13 @@ export class Client {
293
342
  // server-side into escrow, and the leg must offer interactive collection.
294
343
  async interactiveInitiate(intent: Intent, quote: Quote, auth: Authorization): Promise<{ preparation: PayInPreparation; state: State }> {
295
344
  verifyAuthorization(auth, intent, quote, this.verifier);
345
+ if (!this.signerAuth.authorized(auth.signerIdentity, intent.senderRef)) {
346
+ throw new Error("pact: signer is not authorized to act for the intent's sender");
347
+ }
296
348
  if (!isDirect(quote)) {
297
349
  throw new Error("pact: interactive pay-in is only available on a direct corridor");
298
350
  }
299
- const payInLeg = this.payIn.get(quote.payInAdapterId);
300
- if (!payInLeg) {
301
- throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
302
- }
351
+ const payInLeg = this.resolvePayIn(intent, quote);
303
352
  if (!isInteractivePayInLeg(payInLeg)) {
304
353
  throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} does not offer interactive collection`);
305
354
  }
@@ -333,14 +382,14 @@ export class Client {
333
382
  // A direct corridor's pay-in confirms and it settles.
334
383
  if (fromPayIn && current === State.Submitted) {
335
384
  if (failed) return this.recordFailure(intentId, authHash, quote, event.reason);
336
- const ref = payInRef(events);
385
+ const collected = collectResult(events);
337
386
  const settlement: Settlement = {
338
387
  intentId,
339
388
  state: State.Settled,
340
389
  adapterId: quote.payInAdapterId,
341
- providerTxRef: ref,
390
+ providerTxRef: collected.providerRef,
342
391
  onchainTxHash: "",
343
- receiptHash: corridorReceipt(authHash, ref, ref, quote.fxRate, "", State.Settled),
392
+ receiptHash: corridorReceipt(authHash, collected.providerRef, collected.providerRef, collected.received, "", State.Settled),
344
393
  reason: "",
345
394
  settledAt: this.clock(),
346
395
  };
@@ -384,11 +433,32 @@ export class Client {
384
433
  throw new Error(`pact: no bridge ${JSON.stringify(quote.bridgeId)}`);
385
434
  }
386
435
  const collected = collectResult(events);
387
- this.ledger.apply({
436
+ // Claim the escrow hold before converting or disbursing. If a concurrent
437
+ // advance already claimed it, this call did not win, so abort rather than run
438
+ // the conversion and pay-out a second time.
439
+ const { created } = this.ledger.apply({
388
440
  intentId: intent.id,
389
441
  to: State.Held,
390
442
  payloadHash: hashString(domain("held"), collected.providerRef),
391
443
  });
444
+ if (!created) {
445
+ return { settlement: lastSettlement(events), state: this.ledger.state(intent.id) };
446
+ }
447
+ // An expired corridor must not convert at a stale rate; unwind the collection
448
+ // to a refund instead of driving it forward past its deadline.
449
+ if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
450
+ return this.unwind(intent.id, quote, authHash, "intent expired before conversion");
451
+ }
452
+
453
+ // The pay-in must have collected at least the source net of fees; a short
454
+ // collection unwinds rather than disbursing the full quote against it.
455
+ try {
456
+ if (collected.received.cmp(quote.srcAmount.sub(quote.fees)) < 0) {
457
+ return this.unwind(intent.id, quote, authHash, "bridged corridor collected less than the quoted source amount");
458
+ }
459
+ } catch {
460
+ return this.unwind(intent.id, quote, authHash, "bridged corridor collected in an unexpected currency");
461
+ }
392
462
 
393
463
  let converted: ConvertResult;
394
464
  try {
@@ -396,6 +466,16 @@ export class Client {
396
466
  } catch (err) {
397
467
  return this.unwind(intent.id, quote, authHash, reasonOf(err));
398
468
  }
469
+ // Value must be conserved across the escrow: the conversion has to deliver at
470
+ // least the quoted destination amount before the pay-out is sent. A moved rate
471
+ // or a short conversion unwinds to a refund rather than draining escrow.
472
+ try {
473
+ if (converted.delivered.cmp(quote.dstAmount) < 0) {
474
+ return this.unwind(intent.id, quote, authHash, "bridged conversion delivered less than the quoted destination amount");
475
+ }
476
+ } catch {
477
+ return this.unwind(intent.id, quote, authHash, "bridged conversion delivered in an unexpected currency");
478
+ }
399
479
  let disbursed;
400
480
  try {
401
481
  disbursed = await payOutLeg.disburse(intent.id, quote, recipientDestination(intent));
@@ -431,20 +511,34 @@ export class Client {
431
511
  events: LedgerEvent[],
432
512
  ): { settlement: Settlement; state: State } {
433
513
  const inRef = payInRef(events);
434
- const { payOut: outRef, bridge: bridgeRef } = disburseRefs(events);
514
+ const { payOut: outRef, bridge: bridgeRef, delivered } = disburseRefs(events);
435
515
  const settlement: Settlement = {
436
516
  intentId,
437
517
  state: State.Settled,
438
518
  adapterId: quote.payOutAdapterId,
439
519
  providerTxRef: outRef,
440
520
  onchainTxHash: "",
441
- receiptHash: corridorReceipt(authHash, inRef, outRef, quote.fxRate, bridgeRef, State.Settled),
521
+ receiptHash: corridorReceipt(authHash, inRef, outRef, delivered, bridgeRef, State.Settled),
442
522
  reason: "",
443
523
  settledAt: this.clock(),
444
524
  };
445
525
  return this.finishAdvance(intentId, State.Settled, settlement);
446
526
  }
447
527
 
528
+ // claimRefund reserves the refunding step before any money moves. It returns the
529
+ // recorded outcome to echo when another refund already claimed the step, or null when
530
+ // this call won it and should proceed to move funds. Both refund paths go through it,
531
+ // so a refund is issued at most once however the calls race.
532
+ private claimRefund(intentId: string, reason: string): { settlement: Settlement; state: State } | null {
533
+ const { created } = this.ledger.apply({
534
+ intentId,
535
+ to: State.Refunding,
536
+ payloadHash: hashString(domain("refunding"), reason),
537
+ });
538
+ if (created) return null;
539
+ return { settlement: lastSettlement(this.ledger.events(intentId)), state: this.ledger.state(intentId) };
540
+ }
541
+
448
542
  // unwind refunds the payer from escrow when a bridged corridor cannot complete
449
543
  // its pay-out, moving to refunding then refunded.
450
544
  private async unwind(
@@ -453,22 +547,91 @@ export class Client {
453
547
  authHash: Uint8Array,
454
548
  reason: string,
455
549
  ): Promise<{ settlement: Settlement; state: State }> {
456
- this.ledger.apply({
457
- intentId,
458
- to: State.Refunding,
459
- payloadHash: hashString(domain("refunding"), reason),
460
- });
550
+ // Claim the refund before moving money. A duplicate pay-out-failed webhook enters
551
+ // unwind twice; only the call that wins the refunding step may issue the counter-
552
+ // transfer, so the refund cannot fire a second time.
553
+ const echo = this.claimRefund(intentId, reason);
554
+ if (echo) return echo;
461
555
  const payInLeg = this.payIn.get(quote.payInAdapterId);
462
556
  if (payInLeg) {
463
- await payInLeg.refundIn(intentId, payInLeg.payInCapabilities().refunds, reason);
557
+ const kind = payInLeg.payInCapabilities().refunds;
558
+ // A pay-in rail that cannot refund in place leaves the corridor refunding
559
+ // for the operator to complete out of band, rather than recording a refund
560
+ // that never moved funds.
561
+ if (kind === RefundKind.None) {
562
+ throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot refund automatically; manual return required: ${reason}`);
563
+ }
564
+ await payInLeg.refundIn(intentId, kind, quote.srcAmount, reason);
565
+ }
566
+ // The escrow unwind returns the whole collection, so the receipt attests the
567
+ // source amount the payer funded as the amount refunded.
568
+ const settlement: Settlement = {
569
+ intentId,
570
+ state: State.Refunded,
571
+ adapterId: quote.payInAdapterId,
572
+ providerTxRef: "",
573
+ onchainTxHash: "",
574
+ receiptHash: corridorReceipt(authHash, "", "", quote.srcAmount, "", State.Refunded),
575
+ reason,
576
+ settledAt: this.clock(),
577
+ };
578
+ return this.finishAdvance(intentId, State.Refunded, settlement);
579
+ }
580
+
581
+ // refund returns funds to the payer for a settled intent. The amount is
582
+ // explicit, so a host can issue a partial refund, and it must be in the source
583
+ // currency and no greater than what the payer funded. The pay-in leg must
584
+ // advertise a refund it can honour — and a partial one for a partial amount.
585
+ // Like every advance it is keyed on the target state, so a second refund for the
586
+ // same intent is a no-op.
587
+ async refund(intentId: string, amount: Money, reason: string): Promise<{ settlement: Settlement; state: State }> {
588
+ const events = this.ledger.events(intentId);
589
+ const replayed = replay(events);
590
+ if (!replayed) {
591
+ throw new Error("pact: intent has no recorded authorization to refund");
464
592
  }
593
+ const { intent, quote, authorization: auth } = replayed;
594
+ const current = this.ledger.state(intentId);
595
+ // A refund is single-occurrence: once refunded, a repeat is an idempotent
596
+ // no-op that echoes the outcome rather than moving funds again.
597
+ if (current === State.Refunded) {
598
+ return { settlement: lastSettlement(events), state: current };
599
+ }
600
+ if (current !== State.Settled) {
601
+ throw new Error("pact: only a settled intent can be refunded");
602
+ }
603
+ if (amount.currency !== quote.srcAmount.currency || amount.exponent !== quote.srcAmount.exponent) {
604
+ throw new Error("pact: refund must be in the funded source currency");
605
+ }
606
+ if (amount.value() <= 0n) {
607
+ throw new Error("pact: refund amount must be positive");
608
+ }
609
+ const over = amount.cmp(quote.srcAmount);
610
+ if (over > 0) {
611
+ throw new Error("pact: refund exceeds the amount funded");
612
+ }
613
+ const payInLeg = this.payIn.get(quote.payInAdapterId);
614
+ if (!payInLeg) {
615
+ throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
616
+ }
617
+ const kind = refundKindFor(payInLeg.payInCapabilities().refunds, over === 0);
618
+ if (kind === undefined) {
619
+ throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot perform this refund`);
620
+ }
621
+ const authHash = authorizationHash(intentHash(intent), quoteHash(quote), auth.signerIdentity, auth.signedAt);
622
+ // Claim the refunding step before moving money. Two overlapping refund calls both
623
+ // pass the settled check above; only the one that wins this step issues the return,
624
+ // so the payer is never refunded twice.
625
+ const echo = this.claimRefund(intentId, reason);
626
+ if (echo) return echo;
627
+ await payInLeg.refundIn(intentId, kind, amount, reason);
465
628
  const settlement: Settlement = {
466
629
  intentId,
467
630
  state: State.Refunded,
468
631
  adapterId: quote.payInAdapterId,
469
632
  providerTxRef: "",
470
633
  onchainTxHash: "",
471
- receiptHash: corridorReceipt(authHash, "", "", quote.fxRate, "", State.Refunded),
634
+ receiptHash: corridorReceipt(authHash, "", "", amount, "", State.Refunded),
472
635
  reason,
473
636
  settledAt: this.clock(),
474
637
  };
@@ -488,7 +651,9 @@ export class Client {
488
651
  adapterId: quote.payInAdapterId,
489
652
  providerTxRef: "",
490
653
  onchainTxHash: "",
491
- receiptHash: corridorReceipt(authHash, "", "", quote.fxRate, "", State.Failed),
654
+ // A pay-in that failed before escrow moved no value, so the receipt binds a
655
+ // zero amount alongside the failure.
656
+ receiptHash: corridorReceipt(authHash, "", "", Money.zero(), "", State.Failed),
492
657
  reason,
493
658
  settledAt: this.clock(),
494
659
  };
@@ -501,13 +666,22 @@ export class Client {
501
666
  }
502
667
 
503
668
  private finish(intentId: string, state: State, settlement: Settlement): Settlement {
504
- const event = this.ledger.apply({ intentId, to: state, payloadHash: settlement.receiptHash, settlement });
669
+ const { event } = this.ledger.apply({ intentId, to: state, payloadHash: settlement.receiptHash, settlement });
505
670
  this.notify(event);
506
671
  return settlement;
507
672
  }
508
673
 
509
- // expire records that an intent's deadline passed before it settled.
674
+ // expire records that an intent's deadline passed before it settled. It refuses
675
+ // to expire an intent whose deadline has not actually passed, so a caller cannot
676
+ // force a live payment to a terminal expired state.
510
677
  expire(intentId: string): void {
678
+ const recorded = this.ledger.events(intentId).find((e) => e.intent)?.intent;
679
+ if (!recorded) {
680
+ throw new Error("pact: intent has no recorded history to expire");
681
+ }
682
+ if (!isExpired(recorded.expiresAt, this.clock(), this.skew)) {
683
+ throw new Error("pact: intent has not reached its deadline");
684
+ }
511
685
  this.ledger.apply({
512
686
  intentId,
513
687
  to: State.Expired,
@@ -565,25 +739,46 @@ function collectResult(events: LedgerEvent[]): CollectResult {
565
739
  for (const e of events) {
566
740
  if (e.collect) return e.collect;
567
741
  }
568
- return { providerRef: "", received: Money.create("", 0, 0n) };
742
+ return { providerRef: "", received: Money.zero() };
569
743
  }
570
744
 
571
745
  function payInRef(events: LedgerEvent[]): string {
572
746
  return collectResult(events).providerRef;
573
747
  }
574
748
 
575
- // disburseRefs returns the pay-out provider reference and the bridge receipt
576
- // reference recorded on the disbursing step.
577
- function disburseRefs(events: LedgerEvent[]): { payOut: string; bridge: string } {
749
+ // disburseRefs returns the pay-out provider reference, the bridge receipt
750
+ // reference, and the amount the bridge actually delivered, all recorded on the
751
+ // disbursing step.
752
+ function disburseRefs(events: LedgerEvent[]): { payOut: string; bridge: string; delivered: Money } {
578
753
  let payOut = "";
579
754
  let bridge = "";
755
+ let delivered = Money.zero();
580
756
  for (const e of events) {
581
757
  if (e.state === State.Disbursing) {
582
758
  if (e.settlement) payOut = e.settlement.providerTxRef;
583
- if (e.bridge) bridge = e.bridge.receiptRef;
759
+ if (e.bridge) {
760
+ bridge = e.bridge.receiptRef;
761
+ delivered = e.bridge.delivered;
762
+ }
584
763
  }
585
764
  }
586
- return { payOut, bridge };
765
+ return { payOut, bridge, delivered };
766
+ }
767
+
768
+ // refundKindFor maps a pay-in leg's advertised refund capability and whether the
769
+ // refund is for the full funded amount to the kind the leg is handed, returning
770
+ // undefined for a refund the rail cannot express.
771
+ function refundKindFor(capability: RefundKind, full: boolean): RefundKind | undefined {
772
+ switch (capability) {
773
+ case RefundKind.CounterTransfer:
774
+ return RefundKind.CounterTransfer;
775
+ case RefundKind.Partial:
776
+ return full ? RefundKind.Full : RefundKind.Partial;
777
+ case RefundKind.Full:
778
+ return full ? RefundKind.Full : undefined;
779
+ default:
780
+ return undefined;
781
+ }
587
782
  }
588
783
 
589
784
  // lastSettlement returns the settlement recorded on an intent's most recent
package/src/compliance.ts CHANGED
@@ -32,18 +32,35 @@ export const permissiveKyc: KycProvider = {
32
32
  };
33
33
 
34
34
  // withinLimits reports whether a quote's source amount is inside an identity's
35
- // per-payment cap. An absent cap or a currency mismatch is treated as no
36
- // applicable limit.
35
+ // per-payment cap. An absent cap means no limit. A cap that cannot be compared to
36
+ // the quote — because it is in a different currency — fails closed: the payment is
37
+ // refused rather than slipped past by funding in a currency the cap can't measure.
37
38
  export function withinLimits(status: KycStatus, quote: Quote): boolean {
38
39
  const cap = status.limits.perPayment;
39
40
  if (!cap) return true;
40
41
  try {
41
42
  return quote.srcAmount.cmp(cap) <= 0;
42
43
  } catch {
43
- return true;
44
+ return false;
44
45
  }
45
46
  }
46
47
 
48
+ // SignerAuthorizer decides whether the identity that signed an authorization is
49
+ // allowed to act for the intent's sender. The default requires them to be the
50
+ // same party. A custodial host that signs for its users on one platform key
51
+ // supplies its own, so it can attest that its signer speaks for a given sender —
52
+ // and so compliance is measured against the party actually being charged.
53
+ export interface SignerAuthorizer {
54
+ authorized(signerIdentity: string, senderRef: string): boolean;
55
+ }
56
+
57
+ // strictSignerAuthorizer is the default: the signer must be the sender.
58
+ export const strictSignerAuthorizer: SignerAuthorizer = {
59
+ authorized(signerIdentity: string, senderRef: string): boolean {
60
+ return signerIdentity === senderRef;
61
+ },
62
+ };
63
+
47
64
  // RiskDecision is the outcome of a RiskHook consultation.
48
65
  export interface RiskDecision {
49
66
  allow: boolean;
package/src/crypto.ts CHANGED
@@ -42,7 +42,10 @@ export function ed25519Sign(privateKey: KeyObject, message: Uint8Array): Uint8Ar
42
42
  }
43
43
 
44
44
  export function ed25519Verify(publicKeyRaw: Uint8Array, message: Uint8Array, signature: Uint8Array): boolean {
45
- if (publicKeyRaw.length !== 32) {
45
+ // A key or signature of the wrong length can never verify; reject it up front.
46
+ // The underlying verifier already rejects a non-canonical S, so a malleated
47
+ // signature never verifies.
48
+ if (publicKeyRaw.length !== 32 || signature.length !== 64) {
46
49
  return false;
47
50
  }
48
51
  const spki = Buffer.concat([spkiPublicPrefix, Buffer.from(publicKeyRaw)]);
package/src/index.ts CHANGED
@@ -84,7 +84,6 @@ export {
84
84
  randomId,
85
85
  recipientDestination,
86
86
  } from "./client.js";
87
- export { FakeLeg, FakeRates, FakeVault } from "./fake.js";
88
87
  export {
89
88
  WireKind,
90
89
  type WireMessage,