@myzonerocks/pact 0.1.4 → 0.1.7

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.
package/src/client.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { State } from "./state.js";
2
+ import { State, stateName } from "./state.js";
3
3
  import { Money } from "./money.js";
4
4
  import { DEFAULT_SKEW_MILLIS, domain } from "./protocol.js";
5
5
  import { CanonicalWriter, hashPreimage } from "./canonical.js";
@@ -164,6 +164,9 @@ export class Client {
164
164
  continue;
165
165
  }
166
166
  try {
167
+ // Corridors are priced in order so the router sees a stable ranking, matching
168
+ // the other SDKs; the loop is bounded by the configured funding options.
169
+ // eslint-disable-next-line no-await-in-loop
167
170
  quotes.push(await this.composeQuote(intent, payInLeg, f));
168
171
  } catch (err) {
169
172
  lastErr = err;
@@ -289,11 +292,9 @@ export class Client {
289
292
  // driven forward by advance as provider events arrive, so no promise or
290
293
  // connection is held open per payment and durable state lives only in the
291
294
  // ledger.
292
- async initiate(intent: Intent, quote: Quote, auth: Authorization): Promise<State> {
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
- }
295
+ // resolvePayIn rejects an expired intent and returns the corridor's pay-in leg — the
296
+ // expiry-and-leg check the immediate and interactive starts share.
297
+ private resolvePayIn(intent: Intent, quote: Quote): PayInLeg {
297
298
  if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
298
299
  throw new Error("pact: intent has expired");
299
300
  }
@@ -301,13 +302,28 @@ export class Client {
301
302
  if (!payInLeg) {
302
303
  throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
303
304
  }
305
+ return payInLeg;
306
+ }
307
+
308
+ async initiate(intent: Intent, quote: Quote, auth: Authorization): Promise<State> {
309
+ verifyAuthorization(auth, intent, quote, this.verifier);
310
+ if (!this.signerAuth.authorized(auth.signerIdentity, intent.senderRef)) {
311
+ throw new Error("pact: signer is not authorized to act for the intent's sender");
312
+ }
313
+ const payInLeg = this.resolvePayIn(intent, quote);
304
314
 
305
315
  // A retry of an already-submitted corridor must not collect a second time. The
306
316
  // pay-in leg carries its own provider idempotency for the narrow window where a
307
317
  // 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;
318
+ const current = this.ledger.state(intent.id);
319
+ if (current === State.Submitted || current === State.Collecting) {
320
+ return current;
321
+ }
322
+ // Collection may only start from an authorized intent. Without this a replayed
323
+ // or out-of-order call would charge the payer through collect before the ledger
324
+ // rejected the transition, so the guard is enforced before any money moves.
325
+ if (current !== State.Authorized) {
326
+ throw new Error(`pact: cannot initiate an intent in state ${stateName[current]}; it must be authorized first`);
311
327
  }
312
328
 
313
329
  // A direct corridor collects straight to the recipient; a bridged one
@@ -338,16 +354,16 @@ export class Client {
338
354
  if (!isDirect(quote)) {
339
355
  throw new Error("pact: interactive pay-in is only available on a direct corridor");
340
356
  }
341
- if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
342
- throw new Error("pact: intent has expired");
343
- }
344
- const payInLeg = this.payIn.get(quote.payInAdapterId);
345
- if (!payInLeg) {
346
- throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
347
- }
357
+ const payInLeg = this.resolvePayIn(intent, quote);
348
358
  if (!isInteractivePayInLeg(payInLeg)) {
349
359
  throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} does not offer interactive collection`);
350
360
  }
361
+ // The provider intent may only be prepared from an authorized intent, so a
362
+ // replayed or out-of-order call cannot open a second collection.
363
+ const current = this.ledger.state(intent.id);
364
+ if (current !== State.Authorized) {
365
+ throw new Error(`pact: cannot initiate an intent in state ${stateName[current]}; it must be authorized first`);
366
+ }
351
367
  const preparation = await payInLeg.prepare(intent.id, quote, auth, recipientDestination(intent));
352
368
  // Record the same shape a direct collection would, so the confirming webhook
353
369
  // advances the corridor to settled through the unchanged pay-in path.
@@ -406,6 +422,31 @@ export class Client {
406
422
  return this.settleBridged(intentId, quote, authHash, events);
407
423
  }
408
424
 
425
+ // A refund reported out of band — an operator refunds in the provider's
426
+ // dashboard, say — is recorded against the settled intent so the ledger matches
427
+ // where the money actually is. The provider has already returned the funds, so
428
+ // this only claims the refunding step (once, so a duplicate webhook is a no-op)
429
+ // and records the refunded outcome; it moves no money itself.
430
+ if (event.state === State.Refunded && current === State.Settled) {
431
+ const { created } = this.ledger.apply({
432
+ intentId,
433
+ to: State.Refunding,
434
+ payloadHash: hashString(domain("refunding"), event.reason),
435
+ });
436
+ if (!created) return { settlement: lastSettlement(events), state: this.ledger.state(intentId) };
437
+ const settlement: Settlement = {
438
+ intentId,
439
+ state: State.Refunded,
440
+ adapterId,
441
+ providerTxRef: event.providerTxRef,
442
+ onchainTxHash: "",
443
+ receiptHash: corridorReceipt(authHash, "", event.providerTxRef, quote.srcAmount, "", State.Refunded),
444
+ reason: event.reason,
445
+ settledAt: this.clock(),
446
+ };
447
+ return this.finishAdvance(intentId, State.Refunded, settlement);
448
+ }
449
+
409
450
  // Any other event — a duplicate webhook, or one for a phase already past — is
410
451
  // a no-op. This is what makes a re-delivered provider callback settle once.
411
452
  return { settlement: lastSettlement(events), state: current };
@@ -521,6 +562,20 @@ export class Client {
521
562
  return this.finishAdvance(intentId, State.Settled, settlement);
522
563
  }
523
564
 
565
+ // claimRefund reserves the refunding step before any money moves. It returns the
566
+ // recorded outcome to echo when another refund already claimed the step, or null when
567
+ // this call won it and should proceed to move funds. Both refund paths go through it,
568
+ // so a refund is issued at most once however the calls race.
569
+ private claimRefund(intentId: string, reason: string): { settlement: Settlement; state: State } | null {
570
+ const { created } = this.ledger.apply({
571
+ intentId,
572
+ to: State.Refunding,
573
+ payloadHash: hashString(domain("refunding"), reason),
574
+ });
575
+ if (created) return null;
576
+ return { settlement: lastSettlement(this.ledger.events(intentId)), state: this.ledger.state(intentId) };
577
+ }
578
+
524
579
  // unwind refunds the payer from escrow when a bridged corridor cannot complete
525
580
  // its pay-out, moving to refunding then refunded.
526
581
  private async unwind(
@@ -529,11 +584,11 @@ export class Client {
529
584
  authHash: Uint8Array,
530
585
  reason: string,
531
586
  ): Promise<{ settlement: Settlement; state: State }> {
532
- this.ledger.apply({
533
- intentId,
534
- to: State.Refunding,
535
- payloadHash: hashString(domain("refunding"), reason),
536
- });
587
+ // Claim the refund before moving money. A duplicate pay-out-failed webhook enters
588
+ // unwind twice; only the call that wins the refunding step may issue the counter-
589
+ // transfer, so the refund cannot fire a second time.
590
+ const echo = this.claimRefund(intentId, reason);
591
+ if (echo) return echo;
537
592
  const payInLeg = this.payIn.get(quote.payInAdapterId);
538
593
  if (payInLeg) {
539
594
  const kind = payInLeg.payInCapabilities().refunds;
@@ -601,6 +656,11 @@ export class Client {
601
656
  throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot perform this refund`);
602
657
  }
603
658
  const authHash = authorizationHash(intentHash(intent), quoteHash(quote), auth.signerIdentity, auth.signedAt);
659
+ // Claim the refunding step before moving money. Two overlapping refund calls both
660
+ // pass the settled check above; only the one that wins this step issues the return,
661
+ // so the payer is never refunded twice.
662
+ const echo = this.claimRefund(intentId, reason);
663
+ if (echo) return echo;
604
664
  await payInLeg.refundIn(intentId, kind, amount, reason);
605
665
  const settlement: Settlement = {
606
666
  intentId,
package/src/message.ts CHANGED
@@ -60,9 +60,11 @@ export interface Quote {
60
60
  latencyEstimateMs: number;
61
61
  }
62
62
 
63
- export function quotePreimage(q: Quote): Uint8Array {
64
- return new CanonicalWriter()
65
- .str(domain("quote"))
63
+ // writeQuoteFields appends a quote's fields to a writer in the one canonical order
64
+ // shared by the signing preimage and the wire codec. The two callers differ only in
65
+ // what frames the fields: the preimage prepends a domain tag, the wire form does not.
66
+ export function writeQuoteFields(w: CanonicalWriter, q: Quote): CanonicalWriter {
67
+ return w
66
68
  .str(q.id)
67
69
  .str(q.intentId)
68
70
  .str(q.payInAdapterId)
@@ -76,8 +78,12 @@ export function quotePreimage(q: Quote): Uint8Array {
76
78
  .str(q.fxRate)
77
79
  .u64(q.expiresAt)
78
80
  .str(q.providerQuoteRef)
79
- .u64(q.latencyEstimateMs)
80
- .preimage();
81
+ .u64(q.latencyEstimateMs);
82
+ }
83
+
84
+ export function quotePreimage(q: Quote): Uint8Array {
85
+ const w = new CanonicalWriter().str(domain("quote"));
86
+ return writeQuoteFields(w, q).preimage();
81
87
  }
82
88
 
83
89
  export function quoteHash(q: Quote): Uint8Array {
package/src/state.ts CHANGED
@@ -52,7 +52,9 @@ const transitions: Record<State, ReadonlySet<State>> = {
52
52
  [State.Held]: new Set([State.Disbursing, State.Refunding, State.Expired]),
53
53
  [State.Disbursing]: new Set([State.Settled, State.Failed, State.Refunding]),
54
54
  [State.Refunding]: new Set([State.Refunded, State.Failed]),
55
- [State.Settled]: new Set([State.Refunded]),
55
+ // A settled intent refunds through the same refunding step a bridged unwind uses,
56
+ // so the refund is claimed before any money moves and cannot fire twice.
57
+ [State.Settled]: new Set([State.Refunding, State.Refunded]),
56
58
  [State.Failed]: new Set(),
57
59
  [State.Expired]: new Set(),
58
60
  [State.Refunded]: new Set(),
package/src/wire.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Money } from "./money.js";
2
2
  import { State, stateName } from "./state.js";
3
3
  import { CanonicalWriter } from "./canonical.js";
4
+ import { writeQuoteFields } from "./message.js";
4
5
  import type { Intent, Quote, Authorization, Settlement } from "./message.js";
5
6
 
6
7
  // The wire codec serializes messages for transport. It is distinct from the
@@ -32,22 +33,7 @@ export function encodeIntent(i: Intent): Uint8Array {
32
33
  }
33
34
 
34
35
  export function encodeQuote(q: Quote): Uint8Array {
35
- return new CanonicalWriter()
36
- .str(q.id)
37
- .str(q.intentId)
38
- .str(q.payInAdapterId)
39
- .str(q.payInRail)
40
- .str(q.payOutAdapterId)
41
- .str(q.payOutRail)
42
- .str(q.bridgeId)
43
- .money(q.srcAmount)
44
- .money(q.dstAmount)
45
- .money(q.fees)
46
- .str(q.fxRate)
47
- .u64(q.expiresAt)
48
- .str(q.providerQuoteRef)
49
- .u64(q.latencyEstimateMs)
50
- .preimage();
36
+ return writeQuoteFields(new CanonicalWriter(), q).preimage();
51
37
  }
52
38
 
53
39
  export function encodeAuthorization(a: Authorization): Uint8Array {