@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
@@ -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) {
@@ -186,11 +197,17 @@ export class Client {
186
197
  if (isExpired(quote.expiresAt, now, this.skew)) {
187
198
  throw new Error("pact: quote has expired");
188
199
  }
189
- const status = await this.kyc.status(signer.identity());
200
+ // The signer must be allowed to act for the sender, and compliance is measured
201
+ // against the sender — the party actually funding the payment — not whoever
202
+ // holds the signing key.
203
+ if (!this.signerAuth.authorized(signer.identity(), intent.senderRef)) {
204
+ throw new Error("pact: signer is not authorized to act for the intent's sender");
205
+ }
206
+ const status = await this.kyc.status(intent.senderRef);
190
207
  if (!withinLimits(status, quote)) {
191
208
  throw new Error("pact: quote exceeds KYC limit");
192
209
  }
193
- const decision = this.risk.evaluate(signer.identity(), intent, quote);
210
+ const decision = this.risk.evaluate(intent.senderRef, intent, quote);
194
211
  if (!decision.allow) {
195
212
  throw new Error(`pact: risk hook vetoed authorization: ${decision.reason}`);
196
213
  }
@@ -208,10 +225,23 @@ export class Client {
208
225
  // ledger.
209
226
  async initiate(intent, quote, auth) {
210
227
  verifyAuthorization(auth, intent, quote, this.verifier);
228
+ if (!this.signerAuth.authorized(auth.signerIdentity, intent.senderRef)) {
229
+ throw new Error("pact: signer is not authorized to act for the intent's sender");
230
+ }
231
+ if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
232
+ throw new Error("pact: intent has expired");
233
+ }
211
234
  const payInLeg = this.payIn.get(quote.payInAdapterId);
212
235
  if (!payInLeg) {
213
236
  throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
214
237
  }
238
+ // A retry of an already-submitted corridor must not collect a second time. The
239
+ // pay-in leg carries its own provider idempotency for the narrow window where a
240
+ // collection completed but its record was lost.
241
+ const submitted = this.ledger.state(intent.id);
242
+ if (submitted === State.Submitted || submitted === State.Collecting) {
243
+ return submitted;
244
+ }
215
245
  // A direct corridor collects straight to the recipient; a bridged one
216
246
  // collects into escrow first.
217
247
  let deliverTo = recipientDestination(intent);
@@ -233,9 +263,15 @@ export class Client {
233
263
  // server-side into escrow, and the leg must offer interactive collection.
234
264
  async interactiveInitiate(intent, quote, auth) {
235
265
  verifyAuthorization(auth, intent, quote, this.verifier);
266
+ if (!this.signerAuth.authorized(auth.signerIdentity, intent.senderRef)) {
267
+ throw new Error("pact: signer is not authorized to act for the intent's sender");
268
+ }
236
269
  if (!isDirect(quote)) {
237
270
  throw new Error("pact: interactive pay-in is only available on a direct corridor");
238
271
  }
272
+ if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
273
+ throw new Error("pact: intent has expired");
274
+ }
239
275
  const payInLeg = this.payIn.get(quote.payInAdapterId);
240
276
  if (!payInLeg) {
241
277
  throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
@@ -272,14 +308,14 @@ export class Client {
272
308
  if (fromPayIn && current === State.Submitted) {
273
309
  if (failed)
274
310
  return this.recordFailure(intentId, authHash, quote, event.reason);
275
- const ref = payInRef(events);
311
+ const collected = collectResult(events);
276
312
  const settlement = {
277
313
  intentId,
278
314
  state: State.Settled,
279
315
  adapterId: quote.payInAdapterId,
280
- providerTxRef: ref,
316
+ providerTxRef: collected.providerRef,
281
317
  onchainTxHash: "",
282
- receiptHash: corridorReceipt(authHash, ref, ref, quote.fxRate, "", State.Settled),
318
+ receiptHash: corridorReceipt(authHash, collected.providerRef, collected.providerRef, collected.received, "", State.Settled),
283
319
  reason: "",
284
320
  settledAt: this.clock(),
285
321
  };
@@ -316,11 +352,32 @@ export class Client {
316
352
  throw new Error(`pact: no bridge ${JSON.stringify(quote.bridgeId)}`);
317
353
  }
318
354
  const collected = collectResult(events);
319
- this.ledger.apply({
355
+ // Claim the escrow hold before converting or disbursing. If a concurrent
356
+ // advance already claimed it, this call did not win, so abort rather than run
357
+ // the conversion and pay-out a second time.
358
+ const { created } = this.ledger.apply({
320
359
  intentId: intent.id,
321
360
  to: State.Held,
322
361
  payloadHash: hashString(domain("held"), collected.providerRef),
323
362
  });
363
+ if (!created) {
364
+ return { settlement: lastSettlement(events), state: this.ledger.state(intent.id) };
365
+ }
366
+ // An expired corridor must not convert at a stale rate; unwind the collection
367
+ // to a refund instead of driving it forward past its deadline.
368
+ if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
369
+ return this.unwind(intent.id, quote, authHash, "intent expired before conversion");
370
+ }
371
+ // The pay-in must have collected at least the source net of fees; a short
372
+ // collection unwinds rather than disbursing the full quote against it.
373
+ try {
374
+ if (collected.received.cmp(quote.srcAmount.sub(quote.fees)) < 0) {
375
+ return this.unwind(intent.id, quote, authHash, "bridged corridor collected less than the quoted source amount");
376
+ }
377
+ }
378
+ catch {
379
+ return this.unwind(intent.id, quote, authHash, "bridged corridor collected in an unexpected currency");
380
+ }
324
381
  let converted;
325
382
  try {
326
383
  converted = await bridge.convert(intent.id, collected.received, intent.amount.currency, intent.amount.exponent);
@@ -328,6 +385,17 @@ export class Client {
328
385
  catch (err) {
329
386
  return this.unwind(intent.id, quote, authHash, reasonOf(err));
330
387
  }
388
+ // Value must be conserved across the escrow: the conversion has to deliver at
389
+ // least the quoted destination amount before the pay-out is sent. A moved rate
390
+ // or a short conversion unwinds to a refund rather than draining escrow.
391
+ try {
392
+ if (converted.delivered.cmp(quote.dstAmount) < 0) {
393
+ return this.unwind(intent.id, quote, authHash, "bridged conversion delivered less than the quoted destination amount");
394
+ }
395
+ }
396
+ catch {
397
+ return this.unwind(intent.id, quote, authHash, "bridged conversion delivered in an unexpected currency");
398
+ }
331
399
  let disbursed;
332
400
  try {
333
401
  disbursed = await payOutLeg.disburse(intent.id, quote, recipientDestination(intent));
@@ -358,14 +426,14 @@ export class Client {
358
426
  // binding both legs and the FX into the linked receipt.
359
427
  settleBridged(intentId, quote, authHash, events) {
360
428
  const inRef = payInRef(events);
361
- const { payOut: outRef, bridge: bridgeRef } = disburseRefs(events);
429
+ const { payOut: outRef, bridge: bridgeRef, delivered } = disburseRefs(events);
362
430
  const settlement = {
363
431
  intentId,
364
432
  state: State.Settled,
365
433
  adapterId: quote.payOutAdapterId,
366
434
  providerTxRef: outRef,
367
435
  onchainTxHash: "",
368
- receiptHash: corridorReceipt(authHash, inRef, outRef, quote.fxRate, bridgeRef, State.Settled),
436
+ receiptHash: corridorReceipt(authHash, inRef, outRef, delivered, bridgeRef, State.Settled),
369
437
  reason: "",
370
438
  settledAt: this.clock(),
371
439
  };
@@ -381,15 +449,78 @@ export class Client {
381
449
  });
382
450
  const payInLeg = this.payIn.get(quote.payInAdapterId);
383
451
  if (payInLeg) {
384
- await payInLeg.refundIn(intentId, payInLeg.payInCapabilities().refunds, reason);
452
+ const kind = payInLeg.payInCapabilities().refunds;
453
+ // A pay-in rail that cannot refund in place leaves the corridor refunding
454
+ // for the operator to complete out of band, rather than recording a refund
455
+ // that never moved funds.
456
+ if (kind === RefundKind.None) {
457
+ throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot refund automatically; manual return required: ${reason}`);
458
+ }
459
+ await payInLeg.refundIn(intentId, kind, quote.srcAmount, reason);
385
460
  }
461
+ // The escrow unwind returns the whole collection, so the receipt attests the
462
+ // source amount the payer funded as the amount refunded.
386
463
  const settlement = {
387
464
  intentId,
388
465
  state: State.Refunded,
389
466
  adapterId: quote.payInAdapterId,
390
467
  providerTxRef: "",
391
468
  onchainTxHash: "",
392
- receiptHash: corridorReceipt(authHash, "", "", quote.fxRate, "", State.Refunded),
469
+ receiptHash: corridorReceipt(authHash, "", "", quote.srcAmount, "", State.Refunded),
470
+ reason,
471
+ settledAt: this.clock(),
472
+ };
473
+ return this.finishAdvance(intentId, State.Refunded, settlement);
474
+ }
475
+ // refund returns funds to the payer for a settled intent. The amount is
476
+ // explicit, so a host can issue a partial refund, and it must be in the source
477
+ // currency and no greater than what the payer funded. The pay-in leg must
478
+ // advertise a refund it can honour — and a partial one for a partial amount.
479
+ // Like every advance it is keyed on the target state, so a second refund for the
480
+ // same intent is a no-op.
481
+ async refund(intentId, amount, reason) {
482
+ const events = this.ledger.events(intentId);
483
+ const replayed = replay(events);
484
+ if (!replayed) {
485
+ throw new Error("pact: intent has no recorded authorization to refund");
486
+ }
487
+ const { intent, quote, authorization: auth } = replayed;
488
+ const current = this.ledger.state(intentId);
489
+ // A refund is single-occurrence: once refunded, a repeat is an idempotent
490
+ // no-op that echoes the outcome rather than moving funds again.
491
+ if (current === State.Refunded) {
492
+ return { settlement: lastSettlement(events), state: current };
493
+ }
494
+ if (current !== State.Settled) {
495
+ throw new Error("pact: only a settled intent can be refunded");
496
+ }
497
+ if (amount.currency !== quote.srcAmount.currency || amount.exponent !== quote.srcAmount.exponent) {
498
+ throw new Error("pact: refund must be in the funded source currency");
499
+ }
500
+ if (amount.value() <= 0n) {
501
+ throw new Error("pact: refund amount must be positive");
502
+ }
503
+ const over = amount.cmp(quote.srcAmount);
504
+ if (over > 0) {
505
+ throw new Error("pact: refund exceeds the amount funded");
506
+ }
507
+ const payInLeg = this.payIn.get(quote.payInAdapterId);
508
+ if (!payInLeg) {
509
+ throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
510
+ }
511
+ const kind = refundKindFor(payInLeg.payInCapabilities().refunds, over === 0);
512
+ if (kind === undefined) {
513
+ throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot perform this refund`);
514
+ }
515
+ const authHash = authorizationHash(intentHash(intent), quoteHash(quote), auth.signerIdentity, auth.signedAt);
516
+ await payInLeg.refundIn(intentId, kind, amount, reason);
517
+ const settlement = {
518
+ intentId,
519
+ state: State.Refunded,
520
+ adapterId: quote.payInAdapterId,
521
+ providerTxRef: "",
522
+ onchainTxHash: "",
523
+ receiptHash: corridorReceipt(authHash, "", "", amount, "", State.Refunded),
393
524
  reason,
394
525
  settledAt: this.clock(),
395
526
  };
@@ -403,7 +534,9 @@ export class Client {
403
534
  adapterId: quote.payInAdapterId,
404
535
  providerTxRef: "",
405
536
  onchainTxHash: "",
406
- receiptHash: corridorReceipt(authHash, "", "", quote.fxRate, "", State.Failed),
537
+ // A pay-in that failed before escrow moved no value, so the receipt binds a
538
+ // zero amount alongside the failure.
539
+ receiptHash: corridorReceipt(authHash, "", "", Money.zero(), "", State.Failed),
407
540
  reason,
408
541
  settledAt: this.clock(),
409
542
  };
@@ -414,12 +547,21 @@ export class Client {
414
547
  return { settlement: finished, state };
415
548
  }
416
549
  finish(intentId, state, settlement) {
417
- const event = this.ledger.apply({ intentId, to: state, payloadHash: settlement.receiptHash, settlement });
550
+ const { event } = this.ledger.apply({ intentId, to: state, payloadHash: settlement.receiptHash, settlement });
418
551
  this.notify(event);
419
552
  return settlement;
420
553
  }
421
- // expire records that an intent's deadline passed before it settled.
554
+ // expire records that an intent's deadline passed before it settled. It refuses
555
+ // to expire an intent whose deadline has not actually passed, so a caller cannot
556
+ // force a live payment to a terminal expired state.
422
557
  expire(intentId) {
558
+ const recorded = this.ledger.events(intentId).find((e) => e.intent)?.intent;
559
+ if (!recorded) {
560
+ throw new Error("pact: intent has no recorded history to expire");
561
+ }
562
+ if (!isExpired(recorded.expiresAt, this.clock(), this.skew)) {
563
+ throw new Error("pact: intent has not reached its deadline");
564
+ }
423
565
  this.ledger.apply({
424
566
  intentId,
425
567
  to: State.Expired,
@@ -475,25 +617,44 @@ function collectResult(events) {
475
617
  if (e.collect)
476
618
  return e.collect;
477
619
  }
478
- return { providerRef: "", received: Money.create("", 0, 0n) };
620
+ return { providerRef: "", received: Money.zero() };
479
621
  }
480
622
  function payInRef(events) {
481
623
  return collectResult(events).providerRef;
482
624
  }
483
- // disburseRefs returns the pay-out provider reference and the bridge receipt
484
- // reference recorded on the disbursing step.
625
+ // disburseRefs returns the pay-out provider reference, the bridge receipt
626
+ // reference, and the amount the bridge actually delivered, all recorded on the
627
+ // disbursing step.
485
628
  function disburseRefs(events) {
486
629
  let payOut = "";
487
630
  let bridge = "";
631
+ let delivered = Money.zero();
488
632
  for (const e of events) {
489
633
  if (e.state === State.Disbursing) {
490
634
  if (e.settlement)
491
635
  payOut = e.settlement.providerTxRef;
492
- if (e.bridge)
636
+ if (e.bridge) {
493
637
  bridge = e.bridge.receiptRef;
638
+ delivered = e.bridge.delivered;
639
+ }
494
640
  }
495
641
  }
496
- return { payOut, bridge };
642
+ return { payOut, bridge, delivered };
643
+ }
644
+ // refundKindFor maps a pay-in leg's advertised refund capability and whether the
645
+ // refund is for the full funded amount to the kind the leg is handed, returning
646
+ // undefined for a refund the rail cannot express.
647
+ function refundKindFor(capability, full) {
648
+ switch (capability) {
649
+ case RefundKind.CounterTransfer:
650
+ return RefundKind.CounterTransfer;
651
+ case RefundKind.Partial:
652
+ return full ? RefundKind.Full : RefundKind.Partial;
653
+ case RefundKind.Full:
654
+ return full ? RefundKind.Full : undefined;
655
+ default:
656
+ return undefined;
657
+ }
497
658
  }
498
659
  // lastSettlement returns the settlement recorded on an intent's most recent
499
660
  // 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;