@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
@@ -7,8 +7,9 @@ import { intentHash, quoteHash, authorizationHash, corridorReceipt, isDirect, BR
7
7
  import { authorize as signAuthorization, verifyAuthorization } from "./signing.js";
8
8
  import { PassThroughBridge } from "./bridge.js";
9
9
  import { railInList, isInteractivePayInLeg } from "./leg.js";
10
- import { route, PolicyKind, NoQuoteError, isExpired } from "./router.js";
11
- import { permissiveKyc, allowRisk, withinLimits } from "./compliance.js";
10
+ import { RefundKind } from "./adapter.js";
11
+ import { route, PolicyKind, NoQuoteError, isExpired, maxFundingOptions } from "./router.js";
12
+ import { permissiveKyc, allowRisk, withinLimits, strictSignerAuthorizer, } from "./compliance.js";
12
13
  import { MemoryLedger, eventReceipt } from "./ledger.js";
13
14
  export function systemClock() {
14
15
  return Date.now();
@@ -29,6 +30,7 @@ export class Client {
29
30
  verifier;
30
31
  kyc;
31
32
  risk;
33
+ signerAuth;
32
34
  clock;
33
35
  idGen;
34
36
  skew;
@@ -49,6 +51,7 @@ export class Client {
49
51
  this.verifier = cfg.verifier;
50
52
  this.kyc = cfg.kyc ?? permissiveKyc;
51
53
  this.risk = cfg.risk ?? allowRisk;
54
+ this.signerAuth = cfg.signerAuth ?? strictSignerAuthorizer;
52
55
  this.clock = cfg.clock ?? systemClock;
53
56
  this.idGen = cfg.idGen ?? randomId;
54
57
  this.skew = cfg.skew ?? DEFAULT_SKEW_MILLIS;
@@ -56,6 +59,11 @@ export class Client {
56
59
  }
57
60
  // createIntent mints an intent and records it as a draft.
58
61
  createIntent(spec) {
62
+ // A zero deadline means "never expires", which would let an intent advance
63
+ // forever and bypass the liveness bound. Require a real deadline at creation.
64
+ if (!spec.expiresAt) {
65
+ throw new Error("pact: intent requires a non-zero expiry deadline");
66
+ }
59
67
  const intent = {
60
68
  id: this.idGen(),
61
69
  senderRef: spec.senderRef,
@@ -74,6 +82,9 @@ export class Client {
74
82
  // recipient's currency and a pay-out leg that can deliver it, prices the whole
75
83
  // corridor, and returns the options for the payer to choose among.
76
84
  async quoteOptions(intent, funding) {
85
+ if (funding.length > maxFundingOptions) {
86
+ throw new Error(`pact: ${funding.length} funding options exceed the maximum of ${maxFundingOptions}`);
87
+ }
77
88
  const quotes = [];
78
89
  let lastErr;
79
90
  for (const f of funding) {
@@ -87,6 +98,9 @@ export class Client {
87
98
  continue;
88
99
  }
89
100
  try {
101
+ // Corridors are priced in order so the router sees a stable ranking, matching
102
+ // the other SDKs; the loop is bounded by the configured funding options.
103
+ // eslint-disable-next-line no-await-in-loop
90
104
  quotes.push(await this.composeQuote(intent, payInLeg, f));
91
105
  }
92
106
  catch (err) {
@@ -186,11 +200,17 @@ export class Client {
186
200
  if (isExpired(quote.expiresAt, now, this.skew)) {
187
201
  throw new Error("pact: quote has expired");
188
202
  }
189
- const status = await this.kyc.status(signer.identity());
203
+ // The signer must be allowed to act for the sender, and compliance is measured
204
+ // against the sender — the party actually funding the payment — not whoever
205
+ // holds the signing key.
206
+ if (!this.signerAuth.authorized(signer.identity(), intent.senderRef)) {
207
+ throw new Error("pact: signer is not authorized to act for the intent's sender");
208
+ }
209
+ const status = await this.kyc.status(intent.senderRef);
190
210
  if (!withinLimits(status, quote)) {
191
211
  throw new Error("pact: quote exceeds KYC limit");
192
212
  }
193
- const decision = this.risk.evaluate(signer.identity(), intent, quote);
213
+ const decision = this.risk.evaluate(intent.senderRef, intent, quote);
194
214
  if (!decision.allow) {
195
215
  throw new Error(`pact: risk hook vetoed authorization: ${decision.reason}`);
196
216
  }
@@ -206,12 +226,31 @@ export class Client {
206
226
  // driven forward by advance as provider events arrive, so no promise or
207
227
  // connection is held open per payment and durable state lives only in the
208
228
  // ledger.
209
- async initiate(intent, quote, auth) {
210
- verifyAuthorization(auth, intent, quote, this.verifier);
229
+ // resolvePayIn rejects an expired intent and returns the corridor's pay-in leg — the
230
+ // expiry-and-leg check the immediate and interactive starts share.
231
+ resolvePayIn(intent, quote) {
232
+ if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
233
+ throw new Error("pact: intent has expired");
234
+ }
211
235
  const payInLeg = this.payIn.get(quote.payInAdapterId);
212
236
  if (!payInLeg) {
213
237
  throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
214
238
  }
239
+ return payInLeg;
240
+ }
241
+ async initiate(intent, quote, auth) {
242
+ verifyAuthorization(auth, intent, quote, this.verifier);
243
+ if (!this.signerAuth.authorized(auth.signerIdentity, intent.senderRef)) {
244
+ throw new Error("pact: signer is not authorized to act for the intent's sender");
245
+ }
246
+ const payInLeg = this.resolvePayIn(intent, quote);
247
+ // A retry of an already-submitted corridor must not collect a second time. The
248
+ // pay-in leg carries its own provider idempotency for the narrow window where a
249
+ // collection completed but its record was lost.
250
+ const submitted = this.ledger.state(intent.id);
251
+ if (submitted === State.Submitted || submitted === State.Collecting) {
252
+ return submitted;
253
+ }
215
254
  // A direct corridor collects straight to the recipient; a bridged one
216
255
  // collects into escrow first.
217
256
  let deliverTo = recipientDestination(intent);
@@ -233,13 +272,13 @@ export class Client {
233
272
  // server-side into escrow, and the leg must offer interactive collection.
234
273
  async interactiveInitiate(intent, quote, auth) {
235
274
  verifyAuthorization(auth, intent, quote, this.verifier);
275
+ if (!this.signerAuth.authorized(auth.signerIdentity, intent.senderRef)) {
276
+ throw new Error("pact: signer is not authorized to act for the intent's sender");
277
+ }
236
278
  if (!isDirect(quote)) {
237
279
  throw new Error("pact: interactive pay-in is only available on a direct corridor");
238
280
  }
239
- const payInLeg = this.payIn.get(quote.payInAdapterId);
240
- if (!payInLeg) {
241
- throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
242
- }
281
+ const payInLeg = this.resolvePayIn(intent, quote);
243
282
  if (!isInteractivePayInLeg(payInLeg)) {
244
283
  throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} does not offer interactive collection`);
245
284
  }
@@ -272,14 +311,14 @@ export class Client {
272
311
  if (fromPayIn && current === State.Submitted) {
273
312
  if (failed)
274
313
  return this.recordFailure(intentId, authHash, quote, event.reason);
275
- const ref = payInRef(events);
314
+ const collected = collectResult(events);
276
315
  const settlement = {
277
316
  intentId,
278
317
  state: State.Settled,
279
318
  adapterId: quote.payInAdapterId,
280
- providerTxRef: ref,
319
+ providerTxRef: collected.providerRef,
281
320
  onchainTxHash: "",
282
- receiptHash: corridorReceipt(authHash, ref, ref, quote.fxRate, "", State.Settled),
321
+ receiptHash: corridorReceipt(authHash, collected.providerRef, collected.providerRef, collected.received, "", State.Settled),
283
322
  reason: "",
284
323
  settledAt: this.clock(),
285
324
  };
@@ -316,11 +355,32 @@ export class Client {
316
355
  throw new Error(`pact: no bridge ${JSON.stringify(quote.bridgeId)}`);
317
356
  }
318
357
  const collected = collectResult(events);
319
- this.ledger.apply({
358
+ // Claim the escrow hold before converting or disbursing. If a concurrent
359
+ // advance already claimed it, this call did not win, so abort rather than run
360
+ // the conversion and pay-out a second time.
361
+ const { created } = this.ledger.apply({
320
362
  intentId: intent.id,
321
363
  to: State.Held,
322
364
  payloadHash: hashString(domain("held"), collected.providerRef),
323
365
  });
366
+ if (!created) {
367
+ return { settlement: lastSettlement(events), state: this.ledger.state(intent.id) };
368
+ }
369
+ // An expired corridor must not convert at a stale rate; unwind the collection
370
+ // to a refund instead of driving it forward past its deadline.
371
+ if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
372
+ return this.unwind(intent.id, quote, authHash, "intent expired before conversion");
373
+ }
374
+ // The pay-in must have collected at least the source net of fees; a short
375
+ // collection unwinds rather than disbursing the full quote against it.
376
+ try {
377
+ if (collected.received.cmp(quote.srcAmount.sub(quote.fees)) < 0) {
378
+ return this.unwind(intent.id, quote, authHash, "bridged corridor collected less than the quoted source amount");
379
+ }
380
+ }
381
+ catch {
382
+ return this.unwind(intent.id, quote, authHash, "bridged corridor collected in an unexpected currency");
383
+ }
324
384
  let converted;
325
385
  try {
326
386
  converted = await bridge.convert(intent.id, collected.received, intent.amount.currency, intent.amount.exponent);
@@ -328,6 +388,17 @@ export class Client {
328
388
  catch (err) {
329
389
  return this.unwind(intent.id, quote, authHash, reasonOf(err));
330
390
  }
391
+ // Value must be conserved across the escrow: the conversion has to deliver at
392
+ // least the quoted destination amount before the pay-out is sent. A moved rate
393
+ // or a short conversion unwinds to a refund rather than draining escrow.
394
+ try {
395
+ if (converted.delivered.cmp(quote.dstAmount) < 0) {
396
+ return this.unwind(intent.id, quote, authHash, "bridged conversion delivered less than the quoted destination amount");
397
+ }
398
+ }
399
+ catch {
400
+ return this.unwind(intent.id, quote, authHash, "bridged conversion delivered in an unexpected currency");
401
+ }
331
402
  let disbursed;
332
403
  try {
333
404
  disbursed = await payOutLeg.disburse(intent.id, quote, recipientDestination(intent));
@@ -358,38 +429,122 @@ export class Client {
358
429
  // binding both legs and the FX into the linked receipt.
359
430
  settleBridged(intentId, quote, authHash, events) {
360
431
  const inRef = payInRef(events);
361
- const { payOut: outRef, bridge: bridgeRef } = disburseRefs(events);
432
+ const { payOut: outRef, bridge: bridgeRef, delivered } = disburseRefs(events);
362
433
  const settlement = {
363
434
  intentId,
364
435
  state: State.Settled,
365
436
  adapterId: quote.payOutAdapterId,
366
437
  providerTxRef: outRef,
367
438
  onchainTxHash: "",
368
- receiptHash: corridorReceipt(authHash, inRef, outRef, quote.fxRate, bridgeRef, State.Settled),
439
+ receiptHash: corridorReceipt(authHash, inRef, outRef, delivered, bridgeRef, State.Settled),
369
440
  reason: "",
370
441
  settledAt: this.clock(),
371
442
  };
372
443
  return this.finishAdvance(intentId, State.Settled, settlement);
373
444
  }
374
- // unwind refunds the payer from escrow when a bridged corridor cannot complete
375
- // its pay-out, moving to refunding then refunded.
376
- async unwind(intentId, quote, authHash, reason) {
377
- this.ledger.apply({
445
+ // claimRefund reserves the refunding step before any money moves. It returns the
446
+ // recorded outcome to echo when another refund already claimed the step, or null when
447
+ // this call won it and should proceed to move funds. Both refund paths go through it,
448
+ // so a refund is issued at most once however the calls race.
449
+ claimRefund(intentId, reason) {
450
+ const { created } = this.ledger.apply({
378
451
  intentId,
379
452
  to: State.Refunding,
380
453
  payloadHash: hashString(domain("refunding"), reason),
381
454
  });
455
+ if (created)
456
+ return null;
457
+ return { settlement: lastSettlement(this.ledger.events(intentId)), state: this.ledger.state(intentId) };
458
+ }
459
+ // unwind refunds the payer from escrow when a bridged corridor cannot complete
460
+ // its pay-out, moving to refunding then refunded.
461
+ async unwind(intentId, quote, authHash, reason) {
462
+ // Claim the refund before moving money. A duplicate pay-out-failed webhook enters
463
+ // unwind twice; only the call that wins the refunding step may issue the counter-
464
+ // transfer, so the refund cannot fire a second time.
465
+ const echo = this.claimRefund(intentId, reason);
466
+ if (echo)
467
+ return echo;
382
468
  const payInLeg = this.payIn.get(quote.payInAdapterId);
383
469
  if (payInLeg) {
384
- await payInLeg.refundIn(intentId, payInLeg.payInCapabilities().refunds, reason);
470
+ const kind = payInLeg.payInCapabilities().refunds;
471
+ // A pay-in rail that cannot refund in place leaves the corridor refunding
472
+ // for the operator to complete out of band, rather than recording a refund
473
+ // that never moved funds.
474
+ if (kind === RefundKind.None) {
475
+ throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot refund automatically; manual return required: ${reason}`);
476
+ }
477
+ await payInLeg.refundIn(intentId, kind, quote.srcAmount, reason);
385
478
  }
479
+ // The escrow unwind returns the whole collection, so the receipt attests the
480
+ // source amount the payer funded as the amount refunded.
386
481
  const settlement = {
387
482
  intentId,
388
483
  state: State.Refunded,
389
484
  adapterId: quote.payInAdapterId,
390
485
  providerTxRef: "",
391
486
  onchainTxHash: "",
392
- receiptHash: corridorReceipt(authHash, "", "", quote.fxRate, "", State.Refunded),
487
+ receiptHash: corridorReceipt(authHash, "", "", quote.srcAmount, "", State.Refunded),
488
+ reason,
489
+ settledAt: this.clock(),
490
+ };
491
+ return this.finishAdvance(intentId, State.Refunded, settlement);
492
+ }
493
+ // refund returns funds to the payer for a settled intent. The amount is
494
+ // explicit, so a host can issue a partial refund, and it must be in the source
495
+ // currency and no greater than what the payer funded. The pay-in leg must
496
+ // advertise a refund it can honour — and a partial one for a partial amount.
497
+ // Like every advance it is keyed on the target state, so a second refund for the
498
+ // same intent is a no-op.
499
+ async refund(intentId, amount, reason) {
500
+ const events = this.ledger.events(intentId);
501
+ const replayed = replay(events);
502
+ if (!replayed) {
503
+ throw new Error("pact: intent has no recorded authorization to refund");
504
+ }
505
+ const { intent, quote, authorization: auth } = replayed;
506
+ const current = this.ledger.state(intentId);
507
+ // A refund is single-occurrence: once refunded, a repeat is an idempotent
508
+ // no-op that echoes the outcome rather than moving funds again.
509
+ if (current === State.Refunded) {
510
+ return { settlement: lastSettlement(events), state: current };
511
+ }
512
+ if (current !== State.Settled) {
513
+ throw new Error("pact: only a settled intent can be refunded");
514
+ }
515
+ if (amount.currency !== quote.srcAmount.currency || amount.exponent !== quote.srcAmount.exponent) {
516
+ throw new Error("pact: refund must be in the funded source currency");
517
+ }
518
+ if (amount.value() <= 0n) {
519
+ throw new Error("pact: refund amount must be positive");
520
+ }
521
+ const over = amount.cmp(quote.srcAmount);
522
+ if (over > 0) {
523
+ throw new Error("pact: refund exceeds the amount funded");
524
+ }
525
+ const payInLeg = this.payIn.get(quote.payInAdapterId);
526
+ if (!payInLeg) {
527
+ throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
528
+ }
529
+ const kind = refundKindFor(payInLeg.payInCapabilities().refunds, over === 0);
530
+ if (kind === undefined) {
531
+ throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot perform this refund`);
532
+ }
533
+ const authHash = authorizationHash(intentHash(intent), quoteHash(quote), auth.signerIdentity, auth.signedAt);
534
+ // Claim the refunding step before moving money. Two overlapping refund calls both
535
+ // pass the settled check above; only the one that wins this step issues the return,
536
+ // so the payer is never refunded twice.
537
+ const echo = this.claimRefund(intentId, reason);
538
+ if (echo)
539
+ return echo;
540
+ await payInLeg.refundIn(intentId, kind, amount, reason);
541
+ const settlement = {
542
+ intentId,
543
+ state: State.Refunded,
544
+ adapterId: quote.payInAdapterId,
545
+ providerTxRef: "",
546
+ onchainTxHash: "",
547
+ receiptHash: corridorReceipt(authHash, "", "", amount, "", State.Refunded),
393
548
  reason,
394
549
  settledAt: this.clock(),
395
550
  };
@@ -403,7 +558,9 @@ export class Client {
403
558
  adapterId: quote.payInAdapterId,
404
559
  providerTxRef: "",
405
560
  onchainTxHash: "",
406
- receiptHash: corridorReceipt(authHash, "", "", quote.fxRate, "", State.Failed),
561
+ // A pay-in that failed before escrow moved no value, so the receipt binds a
562
+ // zero amount alongside the failure.
563
+ receiptHash: corridorReceipt(authHash, "", "", Money.zero(), "", State.Failed),
407
564
  reason,
408
565
  settledAt: this.clock(),
409
566
  };
@@ -414,12 +571,21 @@ export class Client {
414
571
  return { settlement: finished, state };
415
572
  }
416
573
  finish(intentId, state, settlement) {
417
- const event = this.ledger.apply({ intentId, to: state, payloadHash: settlement.receiptHash, settlement });
574
+ const { event } = this.ledger.apply({ intentId, to: state, payloadHash: settlement.receiptHash, settlement });
418
575
  this.notify(event);
419
576
  return settlement;
420
577
  }
421
- // expire records that an intent's deadline passed before it settled.
578
+ // expire records that an intent's deadline passed before it settled. It refuses
579
+ // to expire an intent whose deadline has not actually passed, so a caller cannot
580
+ // force a live payment to a terminal expired state.
422
581
  expire(intentId) {
582
+ const recorded = this.ledger.events(intentId).find((e) => e.intent)?.intent;
583
+ if (!recorded) {
584
+ throw new Error("pact: intent has no recorded history to expire");
585
+ }
586
+ if (!isExpired(recorded.expiresAt, this.clock(), this.skew)) {
587
+ throw new Error("pact: intent has not reached its deadline");
588
+ }
423
589
  this.ledger.apply({
424
590
  intentId,
425
591
  to: State.Expired,
@@ -475,25 +641,44 @@ function collectResult(events) {
475
641
  if (e.collect)
476
642
  return e.collect;
477
643
  }
478
- return { providerRef: "", received: Money.create("", 0, 0n) };
644
+ return { providerRef: "", received: Money.zero() };
479
645
  }
480
646
  function payInRef(events) {
481
647
  return collectResult(events).providerRef;
482
648
  }
483
- // disburseRefs returns the pay-out provider reference and the bridge receipt
484
- // reference recorded on the disbursing step.
649
+ // disburseRefs returns the pay-out provider reference, the bridge receipt
650
+ // reference, and the amount the bridge actually delivered, all recorded on the
651
+ // disbursing step.
485
652
  function disburseRefs(events) {
486
653
  let payOut = "";
487
654
  let bridge = "";
655
+ let delivered = Money.zero();
488
656
  for (const e of events) {
489
657
  if (e.state === State.Disbursing) {
490
658
  if (e.settlement)
491
659
  payOut = e.settlement.providerTxRef;
492
- if (e.bridge)
660
+ if (e.bridge) {
493
661
  bridge = e.bridge.receiptRef;
662
+ delivered = e.bridge.delivered;
663
+ }
494
664
  }
495
665
  }
496
- return { payOut, bridge };
666
+ return { payOut, bridge, delivered };
667
+ }
668
+ // refundKindFor maps a pay-in leg's advertised refund capability and whether the
669
+ // refund is for the full funded amount to the kind the leg is handed, returning
670
+ // undefined for a refund the rail cannot express.
671
+ function refundKindFor(capability, full) {
672
+ switch (capability) {
673
+ case RefundKind.CounterTransfer:
674
+ return RefundKind.CounterTransfer;
675
+ case RefundKind.Partial:
676
+ return full ? RefundKind.Full : RefundKind.Partial;
677
+ case RefundKind.Full:
678
+ return full ? RefundKind.Full : undefined;
679
+ default:
680
+ return undefined;
681
+ }
497
682
  }
498
683
  // lastSettlement returns the settlement recorded on an intent's most recent
499
684
  // event, if any, so an idempotent no-op can echo the outcome already reached.
@@ -13,6 +13,10 @@ export interface KycProvider {
13
13
  }
14
14
  export declare const permissiveKyc: KycProvider;
15
15
  export declare function withinLimits(status: KycStatus, quote: Quote): boolean;
16
+ export interface SignerAuthorizer {
17
+ authorized(signerIdentity: string, senderRef: string): boolean;
18
+ }
19
+ export declare const strictSignerAuthorizer: SignerAuthorizer;
16
20
  export interface RiskDecision {
17
21
  allow: boolean;
18
22
  reason: string;
@@ -6,8 +6,9 @@ export const permissiveKyc = {
6
6
  },
7
7
  };
8
8
  // withinLimits reports whether a quote's source amount is inside an identity's
9
- // per-payment cap. An absent cap or a currency mismatch is treated as no
10
- // applicable limit.
9
+ // per-payment cap. An absent cap means no limit. A cap that cannot be compared to
10
+ // the quote — because it is in a different currency — fails closed: the payment is
11
+ // refused rather than slipped past by funding in a currency the cap can't measure.
11
12
  export function withinLimits(status, quote) {
12
13
  const cap = status.limits.perPayment;
13
14
  if (!cap)
@@ -16,9 +17,15 @@ export function withinLimits(status, quote) {
16
17
  return quote.srcAmount.cmp(cap) <= 0;
17
18
  }
18
19
  catch {
19
- return true;
20
+ return false;
20
21
  }
21
22
  }
23
+ // strictSignerAuthorizer is the default: the signer must be the sender.
24
+ export const strictSignerAuthorizer = {
25
+ authorized(signerIdentity, senderRef) {
26
+ return signerIdentity === senderRef;
27
+ },
28
+ };
22
29
  export const allowRisk = {
23
30
  evaluate() {
24
31
  return { allow: true, reason: "" };
@@ -25,7 +25,10 @@ export function ed25519Sign(privateKey, message) {
25
25
  return new Uint8Array(nodeSign(null, Buffer.from(message), privateKey));
26
26
  }
27
27
  export function ed25519Verify(publicKeyRaw, message, signature) {
28
- if (publicKeyRaw.length !== 32) {
28
+ // A key or signature of the wrong length can never verify; reject it up front.
29
+ // The underlying verifier already rejects a non-canonical S, so a malleated
30
+ // signature never verifies.
31
+ if (publicKeyRaw.length !== 32 || signature.length !== 64) {
29
32
  return false;
30
33
  }
31
34
  const spki = Buffer.concat([spkiPublicPrefix, Buffer.from(publicKeyRaw)]);
@@ -11,6 +11,5 @@ export { type KycProvider, type KycStatus, type KycLimits, type RiskHook, type R
11
11
  export { PolicyKind, type Policy, route, isExpired, NoQuoteError } from "./router.js";
12
12
  export { MemoryLedger, eventReceipt, chainLeaf, IdempotencyConflictError, type Ledger, type LedgerEvent, type Transition, } from "./ledger.js";
13
13
  export { Client, type ClientConfig, type IntentSpec, type Funding, type Clock, type IdGen, systemClock, randomId, recipientDestination, } from "./client.js";
14
- export { FakeLeg, FakeRates, FakeVault } from "./fake.js";
15
14
  export { WireKind, type WireMessage, encodeIntent, encodeQuote, encodeAuthorization, encodeSettlement, encodeMessage, decodeMessage, decodeIntent, decodeQuote, decodeAuthorization, decodeSettlement, } from "./wire.js";
16
15
  export { PAYLOAD_VERSION, encodePayload, decodePayload, isPayload, NotPayloadError, UnsupportedPayloadVersionError, } from "./payload.js";
package/dist/src/index.js CHANGED
@@ -11,6 +11,5 @@ export { permissiveKyc, allowRisk, withinLimits, } from "./compliance.js";
11
11
  export { PolicyKind, route, isExpired, NoQuoteError } from "./router.js";
12
12
  export { MemoryLedger, eventReceipt, chainLeaf, IdempotencyConflictError, } from "./ledger.js";
13
13
  export { Client, systemClock, randomId, recipientDestination, } from "./client.js";
14
- export { FakeLeg, FakeRates, FakeVault } from "./fake.js";
15
14
  export { WireKind, encodeIntent, encodeQuote, encodeAuthorization, encodeSettlement, encodeMessage, decodeMessage, decodeIntent, decodeQuote, decodeAuthorization, decodeSettlement, } from "./wire.js";
16
15
  export { PAYLOAD_VERSION, encodePayload, decodePayload, isPayload, NotPayloadError, UnsupportedPayloadVersionError, } from "./payload.js";
@@ -30,15 +30,19 @@ export interface Transition {
30
30
  export declare class IdempotencyConflictError extends Error {
31
31
  constructor();
32
32
  }
33
+ export interface ApplyResult {
34
+ event: LedgerEvent;
35
+ created: boolean;
36
+ }
33
37
  export interface Ledger {
34
- apply(t: Transition): LedgerEvent;
38
+ apply(t: Transition): ApplyResult;
35
39
  state(intentId: string): State;
36
40
  events(intentId: string): LedgerEvent[];
37
41
  head(intentId: string): Uint8Array | undefined;
38
42
  }
39
43
  export declare class MemoryLedger implements Ledger {
40
44
  private readonly byIntent;
41
- apply(t: Transition): LedgerEvent;
45
+ apply(t: Transition): ApplyResult;
42
46
  state(intentId: string): State;
43
47
  events(intentId: string): LedgerEvent[];
44
48
  head(intentId: string): Uint8Array | undefined;
@@ -19,7 +19,7 @@ export class MemoryLedger {
19
19
  for (const e of history) {
20
20
  if (e.state === t.to) {
21
21
  if (bytesEqual(e.payloadHash, t.payloadHash))
22
- return e;
22
+ return { event: e, created: false };
23
23
  throw new IdempotencyConflictError();
24
24
  }
25
25
  }
@@ -46,7 +46,7 @@ export class MemoryLedger {
46
46
  ...(t.bridge ? { bridge: t.bridge } : {}),
47
47
  };
48
48
  this.byIntent.set(t.intentId, [...history, event]);
49
- return event;
49
+ return { event, created: true };
50
50
  }
51
51
  state(intentId) {
52
52
  const history = this.byIntent.get(intentId);
package/dist/src/leg.d.ts CHANGED
@@ -15,7 +15,7 @@ export interface PayInLeg {
15
15
  id: string;
16
16
  payInCapabilities(): PayInCapabilities;
17
17
  collect(intentId: string, quote: Quote, auth: Authorization, deliverTo: string): Promise<CollectResult>;
18
- refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
18
+ refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement>;
19
19
  }
20
20
  export interface PayInPreparation {
21
21
  providerRef: string;
@@ -52,4 +52,4 @@ export interface Settlement {
52
52
  settledAt: number;
53
53
  }
54
54
  export declare function receiptHash(authHash: Uint8Array, providerTxRef: string, state: State): Uint8Array;
55
- export declare function corridorReceipt(authHash: Uint8Array, payInProviderRef: string, payOutProviderRef: string, fxRate: string, bridgeReceiptRef: string, state: State): Uint8Array;
55
+ export declare function corridorReceipt(authHash: Uint8Array, payInProviderRef: string, payOutProviderRef: string, amount: Money, bridgeReceiptRef: string, state: State): Uint8Array;
@@ -72,16 +72,19 @@ export function receiptHash(authHash, providerTxRef, state) {
72
72
  .str(stateName[state])
73
73
  .preimage());
74
74
  }
75
- // corridorReceipt binds both legs of a bridged corridor and the FX rate into one
76
- // commitment, so the payer can prove they funded X and the recipient can prove
77
- // they received Y as a single transaction, without revealing anything else.
78
- export function corridorReceipt(authHash, payInProviderRef, payOutProviderRef, fxRate, bridgeReceiptRef, state) {
75
+ // corridorReceipt binds both legs of a bridged corridor and the amount that
76
+ // actually moved into one commitment, so the payer can prove they funded X and
77
+ // the recipient can prove they received Y as a single transaction, without
78
+ // revealing anything else. The amount is the value the corridor really delivered
79
+ // on settle or returned on refund — not the quoted rate, which the conversion may
80
+ // not have matched — so the receipt attests the outcome, not the plan.
81
+ export function corridorReceipt(authHash, payInProviderRef, payOutProviderRef, amount, bridgeReceiptRef, state) {
79
82
  return hashPreimage(new CanonicalWriter()
80
83
  .str(domain("corridor"))
81
84
  .bytes(authHash)
82
85
  .str(payInProviderRef)
83
86
  .str(payOutProviderRef)
84
- .str(fxRate)
87
+ .money(amount)
85
88
  .str(bridgeReceiptRef)
86
89
  .str(stateName[state])
87
90
  .preimage());
@@ -3,6 +3,7 @@ export declare class Money {
3
3
  readonly exponent: number;
4
4
  private readonly amount;
5
5
  private constructor();
6
+ static zero(): Money;
6
7
  static create(currency: string, exponent: number, amount: bigint): Money;
7
8
  static parse(currency: string, exponent: number, minor: string): Money;
8
9
  minor(): string;
package/dist/src/money.js CHANGED
@@ -13,6 +13,13 @@ export class Money {
13
13
  this.exponent = exponent;
14
14
  this.amount = amount;
15
15
  }
16
+ // zero is the empty-currency, zero-amount sentinel used where a corridor moved
17
+ // no value — a failure receipt, or an absent bridge result. It is not a valid
18
+ // wire amount (an empty currency never parses), only an internal placeholder,
19
+ // and it encodes canonically as minor "0", currency "", exponent 0.
20
+ static zero() {
21
+ return new Money("", 0, 0n);
22
+ }
16
23
  static create(currency, exponent, amount) {
17
24
  if (!currencyPattern.test(currency)) {
18
25
  throw new Error("pact: currency must match [A-Z0-9]{1,16}");
@@ -26,10 +33,18 @@ export class Money {
26
33
  return new Money(currency, exponent, amount);
27
34
  }
28
35
  // parse builds Money from a decimal-ASCII minor-unit string, the form used on
29
- // the wire.
36
+ // the wire. The grammar is a bare non-negative integer — no sign, no radix
37
+ // prefix, no whitespace, no leading zeros — pinned identically across every SDK
38
+ // so two participants never disagree on whether a message is valid or on the
39
+ // value it decodes to.
30
40
  static parse(currency, exponent, minor) {
31
- if (!/^\d+$/.test(minor)) {
32
- throw new Error(`pact: ${minor} is not a base-10 integer`);
41
+ // Bound the length so an untrusted string can't force a huge bigint parse
42
+ // before capability limits ever see it; eighty digits is far past any amount.
43
+ if (minor.length > 80) {
44
+ throw new Error("pact: amount has more than 80 digits");
45
+ }
46
+ if (!/^(0|[1-9][0-9]*)$/.test(minor)) {
47
+ throw new Error(`pact: ${minor} is not a canonical base-10 integer`);
33
48
  }
34
49
  return Money.create(currency, exponent, BigInt(minor));
35
50
  }
@@ -1,4 +1,5 @@
1
1
  export declare const ID = "pact";
2
2
  export declare const VERSION = "1";
3
3
  export declare function domain(kind: string): string;
4
+ export declare function idempotencyKey(ref: string, step: string): string;
4
5
  export declare const DEFAULT_SKEW_MILLIS = 120000;
@@ -10,6 +10,14 @@ const domainPrefix = `${ID}/${VERSION}/`;
10
10
  export function domain(kind) {
11
11
  return domainPrefix + kind;
12
12
  }
13
+ // idempotencyKey binds a provider mutation to one protocol step so a retry reuses
14
+ // the same key and the provider deduplicates it into a single side effect. ref is
15
+ // the stable reference the step acts on (the intent id, or a provider object id);
16
+ // step names the operation. Every adapter derives its provider idempotency key
17
+ // this way, so the same (ref, step) always maps to the same key.
18
+ export function idempotencyKey(ref, step) {
19
+ return `${ID}:${ref}:${step}`;
20
+ }
13
21
  // DEFAULT_SKEW_MILLIS is the tolerated clock difference when deciding whether an
14
22
  // intent or quote has expired.
15
23
  export const DEFAULT_SKEW_MILLIS = 120_000;
@@ -13,4 +13,6 @@ export declare class NoQuoteError extends Error {
13
13
  constructor(reason: string);
14
14
  }
15
15
  export declare function isExpired(deadline: number, now: number, skew: number): boolean;
16
+ export declare const maxQuotes = 256;
17
+ export declare const maxFundingOptions = 64;
16
18
  export declare function route(quotes: Quote[], policy: Policy, kyc: KycStatus, now: number, skew: number): Quote;