@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
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) {
@@ -242,11 +261,17 @@ export class Client {
242
261
  if (isExpired(quote.expiresAt, now, this.skew)) {
243
262
  throw new Error("pact: quote has expired");
244
263
  }
245
- const status = await this.kyc.status(signer.identity());
264
+ // The signer must be allowed to act for the sender, and compliance is measured
265
+ // against the sender — the party actually funding the payment — not whoever
266
+ // holds the signing key.
267
+ if (!this.signerAuth.authorized(signer.identity(), intent.senderRef)) {
268
+ throw new Error("pact: signer is not authorized to act for the intent's sender");
269
+ }
270
+ const status = await this.kyc.status(intent.senderRef);
246
271
  if (!withinLimits(status, quote)) {
247
272
  throw new Error("pact: quote exceeds KYC limit");
248
273
  }
249
- const decision = this.risk.evaluate(signer.identity(), intent, quote);
274
+ const decision = this.risk.evaluate(intent.senderRef, intent, quote);
250
275
  if (!decision.allow) {
251
276
  throw new Error(`pact: risk hook vetoed authorization: ${decision.reason}`);
252
277
  }
@@ -266,11 +291,25 @@ export class Client {
266
291
  // ledger.
267
292
  async initiate(intent: Intent, quote: Quote, auth: Authorization): Promise<State> {
268
293
  verifyAuthorization(auth, intent, quote, this.verifier);
294
+ if (!this.signerAuth.authorized(auth.signerIdentity, intent.senderRef)) {
295
+ throw new Error("pact: signer is not authorized to act for the intent's sender");
296
+ }
297
+ if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
298
+ throw new Error("pact: intent has expired");
299
+ }
269
300
  const payInLeg = this.payIn.get(quote.payInAdapterId);
270
301
  if (!payInLeg) {
271
302
  throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
272
303
  }
273
304
 
305
+ // A retry of an already-submitted corridor must not collect a second time. The
306
+ // pay-in leg carries its own provider idempotency for the narrow window where a
307
+ // collection completed but its record was lost.
308
+ const submitted = this.ledger.state(intent.id);
309
+ if (submitted === State.Submitted || submitted === State.Collecting) {
310
+ return submitted;
311
+ }
312
+
274
313
  // A direct corridor collects straight to the recipient; a bridged one
275
314
  // collects into escrow first.
276
315
  let deliverTo = recipientDestination(intent);
@@ -293,9 +332,15 @@ export class Client {
293
332
  // server-side into escrow, and the leg must offer interactive collection.
294
333
  async interactiveInitiate(intent: Intent, quote: Quote, auth: Authorization): Promise<{ preparation: PayInPreparation; state: State }> {
295
334
  verifyAuthorization(auth, intent, quote, this.verifier);
335
+ if (!this.signerAuth.authorized(auth.signerIdentity, intent.senderRef)) {
336
+ throw new Error("pact: signer is not authorized to act for the intent's sender");
337
+ }
296
338
  if (!isDirect(quote)) {
297
339
  throw new Error("pact: interactive pay-in is only available on a direct corridor");
298
340
  }
341
+ if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
342
+ throw new Error("pact: intent has expired");
343
+ }
299
344
  const payInLeg = this.payIn.get(quote.payInAdapterId);
300
345
  if (!payInLeg) {
301
346
  throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
@@ -333,14 +378,14 @@ export class Client {
333
378
  // A direct corridor's pay-in confirms and it settles.
334
379
  if (fromPayIn && current === State.Submitted) {
335
380
  if (failed) return this.recordFailure(intentId, authHash, quote, event.reason);
336
- const ref = payInRef(events);
381
+ const collected = collectResult(events);
337
382
  const settlement: Settlement = {
338
383
  intentId,
339
384
  state: State.Settled,
340
385
  adapterId: quote.payInAdapterId,
341
- providerTxRef: ref,
386
+ providerTxRef: collected.providerRef,
342
387
  onchainTxHash: "",
343
- receiptHash: corridorReceipt(authHash, ref, ref, quote.fxRate, "", State.Settled),
388
+ receiptHash: corridorReceipt(authHash, collected.providerRef, collected.providerRef, collected.received, "", State.Settled),
344
389
  reason: "",
345
390
  settledAt: this.clock(),
346
391
  };
@@ -384,11 +429,32 @@ export class Client {
384
429
  throw new Error(`pact: no bridge ${JSON.stringify(quote.bridgeId)}`);
385
430
  }
386
431
  const collected = collectResult(events);
387
- this.ledger.apply({
432
+ // Claim the escrow hold before converting or disbursing. If a concurrent
433
+ // advance already claimed it, this call did not win, so abort rather than run
434
+ // the conversion and pay-out a second time.
435
+ const { created } = this.ledger.apply({
388
436
  intentId: intent.id,
389
437
  to: State.Held,
390
438
  payloadHash: hashString(domain("held"), collected.providerRef),
391
439
  });
440
+ if (!created) {
441
+ return { settlement: lastSettlement(events), state: this.ledger.state(intent.id) };
442
+ }
443
+ // An expired corridor must not convert at a stale rate; unwind the collection
444
+ // to a refund instead of driving it forward past its deadline.
445
+ if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
446
+ return this.unwind(intent.id, quote, authHash, "intent expired before conversion");
447
+ }
448
+
449
+ // The pay-in must have collected at least the source net of fees; a short
450
+ // collection unwinds rather than disbursing the full quote against it.
451
+ try {
452
+ if (collected.received.cmp(quote.srcAmount.sub(quote.fees)) < 0) {
453
+ return this.unwind(intent.id, quote, authHash, "bridged corridor collected less than the quoted source amount");
454
+ }
455
+ } catch {
456
+ return this.unwind(intent.id, quote, authHash, "bridged corridor collected in an unexpected currency");
457
+ }
392
458
 
393
459
  let converted: ConvertResult;
394
460
  try {
@@ -396,6 +462,16 @@ export class Client {
396
462
  } catch (err) {
397
463
  return this.unwind(intent.id, quote, authHash, reasonOf(err));
398
464
  }
465
+ // Value must be conserved across the escrow: the conversion has to deliver at
466
+ // least the quoted destination amount before the pay-out is sent. A moved rate
467
+ // or a short conversion unwinds to a refund rather than draining escrow.
468
+ try {
469
+ if (converted.delivered.cmp(quote.dstAmount) < 0) {
470
+ return this.unwind(intent.id, quote, authHash, "bridged conversion delivered less than the quoted destination amount");
471
+ }
472
+ } catch {
473
+ return this.unwind(intent.id, quote, authHash, "bridged conversion delivered in an unexpected currency");
474
+ }
399
475
  let disbursed;
400
476
  try {
401
477
  disbursed = await payOutLeg.disburse(intent.id, quote, recipientDestination(intent));
@@ -431,14 +507,14 @@ export class Client {
431
507
  events: LedgerEvent[],
432
508
  ): { settlement: Settlement; state: State } {
433
509
  const inRef = payInRef(events);
434
- const { payOut: outRef, bridge: bridgeRef } = disburseRefs(events);
510
+ const { payOut: outRef, bridge: bridgeRef, delivered } = disburseRefs(events);
435
511
  const settlement: Settlement = {
436
512
  intentId,
437
513
  state: State.Settled,
438
514
  adapterId: quote.payOutAdapterId,
439
515
  providerTxRef: outRef,
440
516
  onchainTxHash: "",
441
- receiptHash: corridorReceipt(authHash, inRef, outRef, quote.fxRate, bridgeRef, State.Settled),
517
+ receiptHash: corridorReceipt(authHash, inRef, outRef, delivered, bridgeRef, State.Settled),
442
518
  reason: "",
443
519
  settledAt: this.clock(),
444
520
  };
@@ -460,15 +536,79 @@ export class Client {
460
536
  });
461
537
  const payInLeg = this.payIn.get(quote.payInAdapterId);
462
538
  if (payInLeg) {
463
- await payInLeg.refundIn(intentId, payInLeg.payInCapabilities().refunds, reason);
539
+ const kind = payInLeg.payInCapabilities().refunds;
540
+ // A pay-in rail that cannot refund in place leaves the corridor refunding
541
+ // for the operator to complete out of band, rather than recording a refund
542
+ // that never moved funds.
543
+ if (kind === RefundKind.None) {
544
+ throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot refund automatically; manual return required: ${reason}`);
545
+ }
546
+ await payInLeg.refundIn(intentId, kind, quote.srcAmount, reason);
547
+ }
548
+ // The escrow unwind returns the whole collection, so the receipt attests the
549
+ // source amount the payer funded as the amount refunded.
550
+ const settlement: Settlement = {
551
+ intentId,
552
+ state: State.Refunded,
553
+ adapterId: quote.payInAdapterId,
554
+ providerTxRef: "",
555
+ onchainTxHash: "",
556
+ receiptHash: corridorReceipt(authHash, "", "", quote.srcAmount, "", State.Refunded),
557
+ reason,
558
+ settledAt: this.clock(),
559
+ };
560
+ return this.finishAdvance(intentId, State.Refunded, settlement);
561
+ }
562
+
563
+ // refund returns funds to the payer for a settled intent. The amount is
564
+ // explicit, so a host can issue a partial refund, and it must be in the source
565
+ // currency and no greater than what the payer funded. The pay-in leg must
566
+ // advertise a refund it can honour — and a partial one for a partial amount.
567
+ // Like every advance it is keyed on the target state, so a second refund for the
568
+ // same intent is a no-op.
569
+ async refund(intentId: string, amount: Money, reason: string): Promise<{ settlement: Settlement; state: State }> {
570
+ const events = this.ledger.events(intentId);
571
+ const replayed = replay(events);
572
+ if (!replayed) {
573
+ throw new Error("pact: intent has no recorded authorization to refund");
574
+ }
575
+ const { intent, quote, authorization: auth } = replayed;
576
+ const current = this.ledger.state(intentId);
577
+ // A refund is single-occurrence: once refunded, a repeat is an idempotent
578
+ // no-op that echoes the outcome rather than moving funds again.
579
+ if (current === State.Refunded) {
580
+ return { settlement: lastSettlement(events), state: current };
581
+ }
582
+ if (current !== State.Settled) {
583
+ throw new Error("pact: only a settled intent can be refunded");
584
+ }
585
+ if (amount.currency !== quote.srcAmount.currency || amount.exponent !== quote.srcAmount.exponent) {
586
+ throw new Error("pact: refund must be in the funded source currency");
464
587
  }
588
+ if (amount.value() <= 0n) {
589
+ throw new Error("pact: refund amount must be positive");
590
+ }
591
+ const over = amount.cmp(quote.srcAmount);
592
+ if (over > 0) {
593
+ throw new Error("pact: refund exceeds the amount funded");
594
+ }
595
+ const payInLeg = this.payIn.get(quote.payInAdapterId);
596
+ if (!payInLeg) {
597
+ throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
598
+ }
599
+ const kind = refundKindFor(payInLeg.payInCapabilities().refunds, over === 0);
600
+ if (kind === undefined) {
601
+ throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot perform this refund`);
602
+ }
603
+ const authHash = authorizationHash(intentHash(intent), quoteHash(quote), auth.signerIdentity, auth.signedAt);
604
+ await payInLeg.refundIn(intentId, kind, amount, reason);
465
605
  const settlement: Settlement = {
466
606
  intentId,
467
607
  state: State.Refunded,
468
608
  adapterId: quote.payInAdapterId,
469
609
  providerTxRef: "",
470
610
  onchainTxHash: "",
471
- receiptHash: corridorReceipt(authHash, "", "", quote.fxRate, "", State.Refunded),
611
+ receiptHash: corridorReceipt(authHash, "", "", amount, "", State.Refunded),
472
612
  reason,
473
613
  settledAt: this.clock(),
474
614
  };
@@ -488,7 +628,9 @@ export class Client {
488
628
  adapterId: quote.payInAdapterId,
489
629
  providerTxRef: "",
490
630
  onchainTxHash: "",
491
- receiptHash: corridorReceipt(authHash, "", "", quote.fxRate, "", State.Failed),
631
+ // A pay-in that failed before escrow moved no value, so the receipt binds a
632
+ // zero amount alongside the failure.
633
+ receiptHash: corridorReceipt(authHash, "", "", Money.zero(), "", State.Failed),
492
634
  reason,
493
635
  settledAt: this.clock(),
494
636
  };
@@ -501,13 +643,22 @@ export class Client {
501
643
  }
502
644
 
503
645
  private finish(intentId: string, state: State, settlement: Settlement): Settlement {
504
- const event = this.ledger.apply({ intentId, to: state, payloadHash: settlement.receiptHash, settlement });
646
+ const { event } = this.ledger.apply({ intentId, to: state, payloadHash: settlement.receiptHash, settlement });
505
647
  this.notify(event);
506
648
  return settlement;
507
649
  }
508
650
 
509
- // expire records that an intent's deadline passed before it settled.
651
+ // expire records that an intent's deadline passed before it settled. It refuses
652
+ // to expire an intent whose deadline has not actually passed, so a caller cannot
653
+ // force a live payment to a terminal expired state.
510
654
  expire(intentId: string): void {
655
+ const recorded = this.ledger.events(intentId).find((e) => e.intent)?.intent;
656
+ if (!recorded) {
657
+ throw new Error("pact: intent has no recorded history to expire");
658
+ }
659
+ if (!isExpired(recorded.expiresAt, this.clock(), this.skew)) {
660
+ throw new Error("pact: intent has not reached its deadline");
661
+ }
511
662
  this.ledger.apply({
512
663
  intentId,
513
664
  to: State.Expired,
@@ -565,25 +716,46 @@ function collectResult(events: LedgerEvent[]): CollectResult {
565
716
  for (const e of events) {
566
717
  if (e.collect) return e.collect;
567
718
  }
568
- return { providerRef: "", received: Money.create("", 0, 0n) };
719
+ return { providerRef: "", received: Money.zero() };
569
720
  }
570
721
 
571
722
  function payInRef(events: LedgerEvent[]): string {
572
723
  return collectResult(events).providerRef;
573
724
  }
574
725
 
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 } {
726
+ // disburseRefs returns the pay-out provider reference, the bridge receipt
727
+ // reference, and the amount the bridge actually delivered, all recorded on the
728
+ // disbursing step.
729
+ function disburseRefs(events: LedgerEvent[]): { payOut: string; bridge: string; delivered: Money } {
578
730
  let payOut = "";
579
731
  let bridge = "";
732
+ let delivered = Money.zero();
580
733
  for (const e of events) {
581
734
  if (e.state === State.Disbursing) {
582
735
  if (e.settlement) payOut = e.settlement.providerTxRef;
583
- if (e.bridge) bridge = e.bridge.receiptRef;
736
+ if (e.bridge) {
737
+ bridge = e.bridge.receiptRef;
738
+ delivered = e.bridge.delivered;
739
+ }
584
740
  }
585
741
  }
586
- return { payOut, bridge };
742
+ return { payOut, bridge, delivered };
743
+ }
744
+
745
+ // refundKindFor maps a pay-in leg's advertised refund capability and whether the
746
+ // refund is for the full funded amount to the kind the leg is handed, returning
747
+ // undefined for a refund the rail cannot express.
748
+ function refundKindFor(capability: RefundKind, full: boolean): RefundKind | undefined {
749
+ switch (capability) {
750
+ case RefundKind.CounterTransfer:
751
+ return RefundKind.CounterTransfer;
752
+ case RefundKind.Partial:
753
+ return full ? RefundKind.Full : RefundKind.Partial;
754
+ case RefundKind.Full:
755
+ return full ? RefundKind.Full : undefined;
756
+ default:
757
+ return undefined;
758
+ }
587
759
  }
588
760
 
589
761
  // 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,
package/src/ledger.ts CHANGED
@@ -49,11 +49,19 @@ export class IdempotencyConflictError extends Error {
49
49
  }
50
50
  }
51
51
 
52
+ // ApplyResult carries the appended event and whether this call actually appended
53
+ // it (created) or found the step already recorded and returned the existing one,
54
+ // so a caller can claim a step before a side effect and abort if it did not win.
55
+ export interface ApplyResult {
56
+ event: LedgerEvent;
57
+ created: boolean;
58
+ }
59
+
52
60
  // Ledger is the append-only store of payment history. The kernel ships an
53
61
  // in-memory reference; durable backends implement the same interface and must
54
62
  // preserve the same event order, receipts, and Merkle head.
55
63
  export interface Ledger {
56
- apply(t: Transition): LedgerEvent;
64
+ apply(t: Transition): ApplyResult;
57
65
  state(intentId: string): State;
58
66
  events(intentId: string): LedgerEvent[];
59
67
  head(intentId: string): Uint8Array | undefined;
@@ -65,14 +73,14 @@ const zeroLeaf = new Uint8Array(32);
65
73
  export class MemoryLedger implements Ledger {
66
74
  private readonly byIntent = new Map<string, LedgerEvent[]>();
67
75
 
68
- apply(t: Transition): LedgerEvent {
76
+ apply(t: Transition): ApplyResult {
69
77
  const history = this.byIntent.get(t.intentId) ?? [];
70
78
 
71
79
  // A repeat of the same step is a no-op returning the first result; the same
72
80
  // step with a different payload is a conflict.
73
81
  for (const e of history) {
74
82
  if (e.state === t.to) {
75
- if (bytesEqual(e.payloadHash, t.payloadHash)) return e;
83
+ if (bytesEqual(e.payloadHash, t.payloadHash)) return { event: e, created: false };
76
84
  throw new IdempotencyConflictError();
77
85
  }
78
86
  }
@@ -102,7 +110,7 @@ export class MemoryLedger implements Ledger {
102
110
  ...(t.bridge ? { bridge: t.bridge } : {}),
103
111
  };
104
112
  this.byIntent.set(t.intentId, [...history, event]);
105
- return event;
113
+ return { event, created: true };
106
114
  }
107
115
 
108
116
  state(intentId: string): State {
package/src/leg.ts CHANGED
@@ -33,7 +33,10 @@ export interface PayInLeg {
33
33
  id: string;
34
34
  payInCapabilities(): PayInCapabilities;
35
35
  collect(intentId: string, quote: Quote, auth: Authorization, deliverTo: string): Promise<CollectResult>;
36
- refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
36
+ // refundIn returns amount to the payer. The amount is explicit so a leg can
37
+ // honour a partial refund, not only a full one; a leg that can refund at all
38
+ // must return exactly it.
39
+ refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement>;
37
40
  }
38
41
 
39
42
  // PayInPreparation is what an interactive pay-in leg hands back so the payer can
package/src/message.ts CHANGED
@@ -153,14 +153,17 @@ export function receiptHash(authHash: Uint8Array, providerTxRef: string, state:
153
153
  );
154
154
  }
155
155
 
156
- // corridorReceipt binds both legs of a bridged corridor and the FX rate into one
157
- // commitment, so the payer can prove they funded X and the recipient can prove
158
- // they received Y as a single transaction, without revealing anything else.
156
+ // corridorReceipt binds both legs of a bridged corridor and the amount that
157
+ // actually moved into one commitment, so the payer can prove they funded X and
158
+ // the recipient can prove they received Y as a single transaction, without
159
+ // revealing anything else. The amount is the value the corridor really delivered
160
+ // on settle or returned on refund — not the quoted rate, which the conversion may
161
+ // not have matched — so the receipt attests the outcome, not the plan.
159
162
  export function corridorReceipt(
160
163
  authHash: Uint8Array,
161
164
  payInProviderRef: string,
162
165
  payOutProviderRef: string,
163
- fxRate: string,
166
+ amount: Money,
164
167
  bridgeReceiptRef: string,
165
168
  state: State,
166
169
  ): Uint8Array {
@@ -170,7 +173,7 @@ export function corridorReceipt(
170
173
  .bytes(authHash)
171
174
  .str(payInProviderRef)
172
175
  .str(payOutProviderRef)
173
- .str(fxRate)
176
+ .money(amount)
174
177
  .str(bridgeReceiptRef)
175
178
  .str(stateName[state])
176
179
  .preimage(),
package/src/money.ts CHANGED
@@ -12,6 +12,14 @@ export class Money {
12
12
  private readonly amount: bigint,
13
13
  ) {}
14
14
 
15
+ // zero is the empty-currency, zero-amount sentinel used where a corridor moved
16
+ // no value — a failure receipt, or an absent bridge result. It is not a valid
17
+ // wire amount (an empty currency never parses), only an internal placeholder,
18
+ // and it encodes canonically as minor "0", currency "", exponent 0.
19
+ static zero(): Money {
20
+ return new Money("", 0, 0n);
21
+ }
22
+
15
23
  static create(currency: string, exponent: number, amount: bigint): Money {
16
24
  if (!currencyPattern.test(currency)) {
17
25
  throw new Error("pact: currency must match [A-Z0-9]{1,16}");
@@ -26,10 +34,18 @@ export class Money {
26
34
  }
27
35
 
28
36
  // parse builds Money from a decimal-ASCII minor-unit string, the form used on
29
- // the wire.
37
+ // the wire. The grammar is a bare non-negative integer — no sign, no radix
38
+ // prefix, no whitespace, no leading zeros — pinned identically across every SDK
39
+ // so two participants never disagree on whether a message is valid or on the
40
+ // value it decodes to.
30
41
  static parse(currency: string, exponent: number, minor: string): Money {
31
- if (!/^\d+$/.test(minor)) {
32
- throw new Error(`pact: ${minor} is not a base-10 integer`);
42
+ // Bound the length so an untrusted string can't force a huge bigint parse
43
+ // before capability limits ever see it; eighty digits is far past any amount.
44
+ if (minor.length > 80) {
45
+ throw new Error("pact: amount has more than 80 digits");
46
+ }
47
+ if (!/^(0|[1-9][0-9]*)$/.test(minor)) {
48
+ throw new Error(`pact: ${minor} is not a canonical base-10 integer`);
33
49
  }
34
50
  return Money.create(currency, exponent, BigInt(minor));
35
51
  }
package/src/protocol.ts CHANGED
@@ -14,6 +14,15 @@ export function domain(kind: string): string {
14
14
  return domainPrefix + kind;
15
15
  }
16
16
 
17
+ // idempotencyKey binds a provider mutation to one protocol step so a retry reuses
18
+ // the same key and the provider deduplicates it into a single side effect. ref is
19
+ // the stable reference the step acts on (the intent id, or a provider object id);
20
+ // step names the operation. Every adapter derives its provider idempotency key
21
+ // this way, so the same (ref, step) always maps to the same key.
22
+ export function idempotencyKey(ref: string, step: string): string {
23
+ return `${ID}:${ref}:${step}`;
24
+ }
25
+
17
26
  // DEFAULT_SKEW_MILLIS is the tolerated clock difference when deciding whether an
18
27
  // intent or quote has expired.
19
28
  export const DEFAULT_SKEW_MILLIS = 120_000;