@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/README.md CHANGED
@@ -105,9 +105,9 @@ webhooks, so run them on a server, never in a browser bundle.
105
105
 
106
106
  ## Conformance
107
107
 
108
- The Go, TypeScript, and Dart SDKs are held to one wire format by shared vectors: a
109
- fixed intent produces a fixed canonical preimage, hash, and signature that all
110
- three reproduce byte-for-byte.
108
+ The Go, TypeScript, Dart, Swift, and Kotlin SDKs are held to one wire format by
109
+ shared vectors: a fixed intent produces a fixed canonical preimage, hash, and
110
+ signature that every one of them reproduces byte-for-byte.
111
111
 
112
112
  ```
113
113
  bun run test
@@ -28,6 +28,22 @@ export interface Erc20Config {
28
28
  ids: () => string;
29
29
  decimals: number;
30
30
  minConfirmations?: number;
31
+ store?: SentStore;
32
+ }
33
+ export interface SentRecord {
34
+ intentId: string;
35
+ txHash: string;
36
+ to: string;
37
+ amount: bigint;
38
+ }
39
+ export interface SentStore {
40
+ save(rec: SentRecord): Promise<void>;
41
+ byIntent(intentId: string): Promise<SentRecord | undefined>;
42
+ }
43
+ export declare class MemorySentStore implements SentStore {
44
+ private readonly byIntentMap;
45
+ save(rec: SentRecord): Promise<void>;
46
+ byIntent(intentId: string): Promise<SentRecord | undefined>;
31
47
  }
32
48
  export declare class Erc20Leg implements PayInLeg, PayOutLeg {
33
49
  readonly id: string;
@@ -38,7 +54,7 @@ export declare class Erc20Leg implements PayInLeg, PayOutLeg {
38
54
  private readonly ids;
39
55
  private readonly decimals;
40
56
  private readonly minConf;
41
- private readonly sent;
57
+ private readonly store;
42
58
  constructor(cfg: Erc20Config);
43
59
  private checkScale;
44
60
  payInCapabilities(): PayInCapabilities;
@@ -14,6 +14,16 @@ const transferSelector = "a9059cbb";
14
14
  // success can still be reorged out, so a settlement waits for enough blocks on
15
15
  // top of it.
16
16
  const defaultMinConfirmations = 12;
17
+ // MemorySentStore is the default in-process store.
18
+ export class MemorySentStore {
19
+ byIntentMap = new Map();
20
+ async save(rec) {
21
+ this.byIntentMap.set(rec.intentId, rec);
22
+ }
23
+ async byIntent(intentId) {
24
+ return this.byIntentMap.get(intentId);
25
+ }
26
+ }
17
27
  // Erc20Leg settles a payment as an ERC-20 token transfer. Refunds are a
18
28
  // counter-transfer only, because a token transfer is irreversible.
19
29
  export class Erc20Leg {
@@ -25,9 +35,9 @@ export class Erc20Leg {
25
35
  ids;
26
36
  decimals;
27
37
  minConf;
28
- // Each broadcast transfer and what it was meant to move, so a settlement can
29
- // confirm the mined transaction really matches the payment.
30
- sent = new Map();
38
+ // Broadcast transfers, so a settlement can confirm the mined transaction really
39
+ // matches the payment, and a repeat returns the first transfer.
40
+ store;
31
41
  constructor(cfg) {
32
42
  if (!cfg.token || !cfg.currency) {
33
43
  throw new Error("erc20: config requires a token address and currency");
@@ -45,6 +55,7 @@ export class Erc20Leg {
45
55
  this.ids = cfg.ids;
46
56
  this.decimals = cfg.decimals;
47
57
  this.minConf = cfg.minConfirmations ?? defaultMinConfirmations;
58
+ this.store = cfg.store ?? new MemorySentStore();
48
59
  }
49
60
  // checkScale refuses an amount whose exponent does not match the token's
50
61
  // decimals, so a quote priced at the wrong scale never moves the wrong number
@@ -71,12 +82,12 @@ export class Erc20Leg {
71
82
  const net = quote.srcAmount.sub(quote.fees);
72
83
  // A repeat for the same intent returns the transfer already broadcast rather
73
84
  // than sending the payer's tokens twice.
74
- const prev = this.sent.get(intentId);
85
+ const prev = await this.store.byIntent(intentId);
75
86
  if (prev) {
76
87
  return { providerRef: prev.txHash, received: net };
77
88
  }
78
89
  const txHash = await this.transfer(deliverTo, net.value());
79
- this.sent.set(intentId, { txHash, to: normalizeAddress(deliverTo), amount: net.value() });
90
+ await this.store.save({ intentId, txHash, to: normalizeAddress(deliverTo), amount: net.value() });
80
91
  return { providerRef: txHash, received: net };
81
92
  }
82
93
  // disburse delivers the recipient's tokens.
@@ -84,13 +95,13 @@ export class Erc20Leg {
84
95
  this.checkScale(quote.dstAmount);
85
96
  // A repeat for the same intent returns the transfer already broadcast rather
86
97
  // than delivering the recipient's tokens twice.
87
- const prev = this.sent.get(intentId);
98
+ const prev = await this.store.byIntent(intentId);
88
99
  if (prev) {
89
100
  return { providerRef: prev.txHash };
90
101
  }
91
102
  const amount = quote.dstAmount.value();
92
103
  const txHash = await this.transfer(recipientRef, amount);
93
- this.sent.set(intentId, { txHash, to: normalizeAddress(recipientRef), amount });
104
+ await this.store.save({ intentId, txHash, to: normalizeAddress(recipientRef), amount });
94
105
  return { providerRef: txHash };
95
106
  }
96
107
  // refundIn refuses: an ERC-20 collection is irreversible and the leg holds no
@@ -113,7 +124,7 @@ export class Erc20Leg {
113
124
  // shallow success stays submitted so the host keeps polling; a revert or a
114
125
  // mismatch fails, so a dropped, reorged, or spoofed transfer never settles.
115
126
  async settlementEvent(intentId) {
116
- const rec = this.sent.get(intentId);
127
+ const rec = await this.store.byIntent(intentId);
117
128
  if (!rec) {
118
129
  throw new Error("erc20: no broadcast transaction for intent");
119
130
  }
@@ -20,6 +20,7 @@ export interface B2CParams {
20
20
  phone: string;
21
21
  reference: string;
22
22
  remarks: string;
23
+ idempotencyKey: string;
23
24
  }
24
25
  export interface B2CResult {
25
26
  conversationId: string;
@@ -35,19 +36,37 @@ export interface DarajaApi {
35
36
  b2cPayment(params: B2CParams): Promise<B2CResult>;
36
37
  query(checkoutRequestId: string): Promise<StkQueryResult>;
37
38
  }
39
+ export interface PushRecord {
40
+ intentId: string;
41
+ checkoutId: string;
42
+ payerPhone: string;
43
+ amount: number;
44
+ }
45
+ export interface PushStore {
46
+ save(rec: PushRecord): Promise<void>;
47
+ byIntent(intentId: string): Promise<PushRecord | undefined>;
48
+ byCheckout(checkoutId: string): Promise<PushRecord | undefined>;
49
+ }
50
+ export declare class MemoryPushStore implements PushStore {
51
+ private readonly byIntentMap;
52
+ private readonly byCheckoutMap;
53
+ save(rec: PushRecord): Promise<void>;
54
+ byIntent(intentId: string): Promise<PushRecord | undefined>;
55
+ byCheckout(checkoutId: string): Promise<PushRecord | undefined>;
56
+ }
38
57
  export interface MpesaConfig {
39
58
  id?: string;
40
59
  api: DarajaApi;
41
60
  callbackURL: string;
42
61
  ids: () => string;
62
+ store?: PushStore;
43
63
  }
44
64
  export declare class MpesaLeg implements PayInLeg, PayOutLeg {
45
65
  readonly id: string;
46
66
  private readonly api;
47
67
  private readonly callbackURL;
48
68
  private readonly ids;
49
- private readonly byIntent;
50
- private readonly byCheckout;
69
+ private readonly store;
51
70
  constructor(cfg: MpesaConfig);
52
71
  payInCapabilities(): PayInCapabilities;
53
72
  payOutCapabilities(): PayOutCapabilities;
@@ -9,14 +9,29 @@ const defaultBaseURL = "https://api.safaricom.co.ke";
9
9
  // checkout id to one we started is the authentication: an unrecognized id is
10
10
  // rejected rather than acted on.
11
11
  export const ErrUnknownCheckout = "mpesa: callback for an unknown checkout request";
12
+ // MemoryPushStore is the default in-process store.
13
+ export class MemoryPushStore {
14
+ byIntentMap = new Map();
15
+ byCheckoutMap = new Map();
16
+ async save(rec) {
17
+ this.byIntentMap.set(rec.intentId, rec);
18
+ if (rec.checkoutId)
19
+ this.byCheckoutMap.set(rec.checkoutId, rec);
20
+ }
21
+ async byIntent(intentId) {
22
+ return this.byIntentMap.get(intentId);
23
+ }
24
+ async byCheckout(checkoutId) {
25
+ return this.byCheckoutMap.get(checkoutId);
26
+ }
27
+ }
12
28
  // MpesaLeg moves mobile money over M-Pesa.
13
29
  export class MpesaLeg {
14
30
  id;
15
31
  api;
16
32
  callbackURL;
17
33
  ids;
18
- byIntent = new Map();
19
- byCheckout = new Map();
34
+ store;
20
35
  constructor(cfg) {
21
36
  if (!cfg.callbackURL) {
22
37
  throw new Error("mpesa: config requires a callback URL");
@@ -25,6 +40,7 @@ export class MpesaLeg {
25
40
  this.api = cfg.api;
26
41
  this.callbackURL = cfg.callbackURL;
27
42
  this.ids = cfg.ids;
43
+ this.store = cfg.store ?? new MemoryPushStore();
28
44
  }
29
45
  payInCapabilities() {
30
46
  return { rails: ["mpesa"], currencies: ["KES"], refunds: RefundKind.CounterTransfer };
@@ -45,7 +61,6 @@ export class MpesaLeg {
45
61
  if (!phone) {
46
62
  throw new Error("mpesa: collect requires the payer's phone");
47
63
  }
48
- const rec = { intentId, payerPhone: phone, amount, state: State.Unspecified };
49
64
  const result = await this.api.stkPush({
50
65
  amount,
51
66
  payerPhone: phone,
@@ -53,8 +68,7 @@ export class MpesaLeg {
53
68
  description: "payment",
54
69
  callbackURL: this.callbackURL,
55
70
  });
56
- this.byIntent.set(intentId, rec);
57
- this.byCheckout.set(result.checkoutRequestId, rec);
71
+ await this.store.save({ intentId, checkoutId: result.checkoutRequestId, payerPhone: phone, amount });
58
72
  return { providerRef: result.checkoutRequestId, received: net };
59
73
  }
60
74
  // disburse delivers the recipient's shillings by a business-to-customer payout
@@ -65,7 +79,7 @@ export class MpesaLeg {
65
79
  if (!phone) {
66
80
  throw new Error("mpesa: disburse requires the recipient's phone");
67
81
  }
68
- const result = await this.api.b2cPayment({ amount, phone, reference: intentId, remarks: "payout" });
82
+ const result = await this.api.b2cPayment({ amount, phone, reference: intentId, remarks: "payout", idempotencyKey: `pact:payout:${intentId}` });
69
83
  return { providerRef: result.conversationId };
70
84
  }
71
85
  // refundIn answers a collected payment with a business-to-customer payout back
@@ -76,7 +90,7 @@ export class MpesaLeg {
76
90
  throw new Error("mpesa: a collection can only be refunded by counter-transfer");
77
91
  }
78
92
  const shillings = wholeShillings(amount);
79
- const rec = this.byIntent.get(intentId);
93
+ const rec = await this.store.byIntent(intentId);
80
94
  if (!rec) {
81
95
  throw new Error(`mpesa: no push for intent ${intentId}`);
82
96
  }
@@ -89,6 +103,7 @@ export class MpesaLeg {
89
103
  phone: rec.payerPhone,
90
104
  reference: intentId,
91
105
  remarks: reason,
106
+ idempotencyKey: `pact:refund:${intentId}`,
92
107
  });
93
108
  return {
94
109
  intentId,
@@ -116,7 +131,7 @@ export class MpesaLeg {
116
131
  async parseWebhook(raw, _headers) {
117
132
  const envelope = JSON.parse(new TextDecoder().decode(raw));
118
133
  const cb = envelope.Body?.stkCallback;
119
- const rec = cb ? this.byCheckout.get(cb.CheckoutRequestID) : undefined;
134
+ const rec = cb ? await this.store.byCheckout(cb.CheckoutRequestID) : undefined;
120
135
  if (!cb || !rec) {
121
136
  throw new Error(ErrUnknownCheckout);
122
137
  }
@@ -127,7 +142,6 @@ export class MpesaLeg {
127
142
  return [];
128
143
  }
129
144
  if (confirmed.resultCode !== 0) {
130
- rec.state = State.Failed;
131
145
  return [
132
146
  {
133
147
  intentId: rec.intentId,
@@ -146,7 +160,6 @@ export class MpesaLeg {
146
160
  throw new Error(`mpesa: confirmed amount ${paid} does not match the authorized ${rec.amount}`);
147
161
  }
148
162
  const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
149
- rec.state = State.Settled;
150
163
  return [
151
164
  {
152
165
  intentId: rec.intentId,
@@ -301,6 +314,7 @@ class HttpDarajaApi {
301
314
  async b2cPayment(params) {
302
315
  const token = await this.token();
303
316
  const body = {
317
+ OriginatorConversationID: params.idempotencyKey,
304
318
  InitiatorName: this.creds.shortCode,
305
319
  CommandID: "BusinessPayment",
306
320
  Amount: params.amount,
@@ -52,14 +52,31 @@ export interface PaypalConfig {
52
52
  currencies: string[];
53
53
  api: PaypalApi;
54
54
  ids: () => string;
55
+ store?: CaptureStore;
56
+ }
57
+ export interface CaptureRecord {
58
+ intentId: string;
59
+ captureId: string;
60
+ expected: {
61
+ value: string;
62
+ currency: string;
63
+ };
64
+ }
65
+ export interface CaptureStore {
66
+ save(rec: CaptureRecord): Promise<void>;
67
+ byIntent(intentId: string): Promise<CaptureRecord | undefined>;
68
+ }
69
+ export declare class MemoryCaptureStore implements CaptureStore {
70
+ private readonly byIntentMap;
71
+ save(rec: CaptureRecord): Promise<void>;
72
+ byIntent(intentId: string): Promise<CaptureRecord | undefined>;
55
73
  }
56
74
  export declare class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
57
75
  readonly id: string;
58
76
  private readonly currencies;
59
77
  private readonly api;
60
78
  private readonly ids;
61
- private readonly captures;
62
- private readonly expected;
79
+ private readonly store;
63
80
  constructor(cfg: PaypalConfig);
64
81
  payInCapabilities(): PayInCapabilities;
65
82
  payOutCapabilities(): PayOutCapabilities;
@@ -17,21 +17,29 @@ const defaultBaseURL = "https://api-m.paypal.com";
17
17
  const referencePrefix = "pact:";
18
18
  // ErrSignatureMismatch reports a webhook PayPal could not authenticate.
19
19
  export const ErrSignatureMismatch = "paypal: webhook signature does not verify";
20
+ // MemoryCaptureStore is the default in-process store.
21
+ export class MemoryCaptureStore {
22
+ byIntentMap = new Map();
23
+ async save(rec) {
24
+ this.byIntentMap.set(rec.intentId, rec);
25
+ }
26
+ async byIntent(intentId) {
27
+ return this.byIntentMap.get(intentId);
28
+ }
29
+ }
20
30
  // PaypalLeg moves money over PayPal, serving both sides of a corridor.
21
31
  export class PaypalLeg {
22
32
  id;
23
33
  currencies;
24
34
  api;
25
35
  ids;
26
- captures = new Map();
27
- // What each intent was quoted to collect, so a capture webhook can be
28
- // cross-checked: the paid amount and currency must match before it settles.
29
- expected = new Map();
36
+ store;
30
37
  constructor(cfg) {
31
38
  this.id = cfg.id ?? "paypal";
32
39
  this.currencies = cfg.currencies;
33
40
  this.api = cfg.api;
34
41
  this.ids = cfg.ids;
42
+ this.store = cfg.store ?? new MemoryCaptureStore();
35
43
  }
36
44
  payInCapabilities() {
37
45
  return {
@@ -54,10 +62,10 @@ export class PaypalLeg {
54
62
  payee: deliverTo,
55
63
  referenceId: referencePrefix + intentId,
56
64
  });
57
- this.captures.set(intentId, capture.captureId);
58
- this.expected.set(intentId, {
59
- value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
60
- currency: quote.srcAmount.currency,
65
+ await this.store.save({
66
+ intentId,
67
+ captureId: capture.captureId,
68
+ expected: { value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent), currency: quote.srcAmount.currency },
61
69
  });
62
70
  return { providerRef: capture.captureId, received: quote.srcAmount.sub(quote.fees) };
63
71
  }
@@ -78,9 +86,10 @@ export class PaypalLeg {
78
86
  params.platformFee = majorAmount(quote.fees.minor(), quote.fees.exponent);
79
87
  }
80
88
  const order = await this.api.createOrder(params);
81
- this.expected.set(intentId, {
82
- value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
83
- currency: quote.srcAmount.currency,
89
+ await this.store.save({
90
+ intentId,
91
+ captureId: "",
92
+ expected: { value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent), currency: quote.srcAmount.currency },
84
93
  });
85
94
  return { providerRef: order.id, clientSecret: order.id, method: "paypal" };
86
95
  }
@@ -102,10 +111,11 @@ export class PaypalLeg {
102
111
  if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
103
112
  throw new Error(`paypal: cannot perform refund kind ${kind}`);
104
113
  }
105
- const captureId = this.captures.get(intentId);
106
- if (!captureId) {
114
+ const rec = await this.store.byIntent(intentId);
115
+ if (!rec || !rec.captureId) {
107
116
  throw new Error(`paypal: no capture for intent ${intentId}`);
108
117
  }
118
+ const captureId = rec.captureId;
109
119
  // A partial refund names the amount in major units; a full refund leaves it
110
120
  // absent so PayPal returns the whole capture.
111
121
  const params = kind === RefundKind.Partial
@@ -146,10 +156,11 @@ export class PaypalLeg {
146
156
  // created the order, so a genuine, signed capture for a different order can
147
157
  // carry a target intent's id. Settle only when the captured amount and
148
158
  // currency match what the intent was quoted to collect.
149
- const want = this.expected.get(intentId);
150
- if (!want) {
159
+ const rec = await this.store.byIntent(intentId);
160
+ if (!rec) {
151
161
  throw new Error(`paypal: capture for unknown intent ${JSON.stringify(intentId)}`);
152
162
  }
163
+ const want = rec.expected;
153
164
  if (resource.amount?.value !== want.value || resource.amount?.currency_code !== want.currency) {
154
165
  throw new Error(`paypal: captured ${resource.amount?.value} ${resource.amount?.currency_code} does not match the quoted ${want.value} ${want.currency}`);
155
166
  }
@@ -232,8 +232,15 @@ function parseEvent(payload, header, secret, now, tolerance) {
232
232
  verifySignature(payload, header, secret, now, tolerance);
233
233
  const event = JSON.parse(new TextDecoder().decode(payload));
234
234
  switch (event.type) {
235
- case "payment_intent.succeeded":
236
- return oneEvent(decodePaymentIntent(event.data.object), State.Settled, "");
235
+ case "payment_intent.succeeded": {
236
+ // The webhook is signed by Stripe, so its amounts are authentic. A capture for
237
+ // less than the authorized amount must not settle the corridor as fully paid.
238
+ const pi = decodePaymentIntent(event.data.object);
239
+ if (pi.amountReceived < pi.amount) {
240
+ return oneEvent(pi, State.Failed, "captured amount is less than the authorized amount");
241
+ }
242
+ return oneEvent(pi, State.Settled, "");
243
+ }
237
244
  case "payment_intent.payment_failed":
238
245
  return oneEvent(decodePaymentIntent(event.data.object), State.Failed, "payment failed");
239
246
  case "charge.refunded":
@@ -244,13 +251,19 @@ function parseEvent(payload, header, secret, now, tolerance) {
244
251
  }
245
252
  function decodePaymentIntent(raw) {
246
253
  const obj = raw;
247
- return { id: obj.id ?? "", metadata: obj.metadata ?? {}, created: obj.created ?? 0 };
254
+ return {
255
+ id: obj.id ?? "",
256
+ metadata: obj.metadata ?? {},
257
+ created: obj.created ?? 0,
258
+ amount: obj.amount ?? 0,
259
+ amountReceived: obj.amount_received ?? 0,
260
+ };
248
261
  }
249
262
  // decodeRefundedIntent reads the pact intent id off a refunded charge. A charge
250
263
  // carries the originating PaymentIntent id and copies its metadata.
251
264
  function decodeRefundedIntent(raw) {
252
265
  const charge = raw;
253
- return { id: charge.payment_intent ?? "", metadata: charge.metadata ?? {}, created: charge.created ?? 0 };
266
+ return { id: charge.payment_intent ?? "", metadata: charge.metadata ?? {}, created: charge.created ?? 0, amount: 0, amountReceived: 0 };
254
267
  }
255
268
  function oneEvent(pi, state, reason) {
256
269
  return [
@@ -341,7 +354,7 @@ class HttpStripeApi {
341
354
  }
342
355
  async send(path, init) {
343
356
  const headers = {
344
- ...(init.headers ?? {}),
357
+ ...init.headers,
345
358
  Authorization: `Bearer ${this.secretKey}`,
346
359
  "Stripe-Version": apiVersion,
347
360
  };
@@ -51,6 +51,8 @@ export class CanonicalWriter {
51
51
  // byte value so the encoding never depends on insertion order. The sort is on
52
52
  // encoded bytes, not on UTF-16 code units, to match the other SDKs exactly.
53
53
  stringMap(kv) {
54
+ // Object.keys returns a fresh array, so sorting it in place mutates nothing shared.
55
+ // oxlint-disable-next-line unicorn/no-array-sort
54
56
  const keys = Object.keys(kv).sort(compareUtf8);
55
57
  this.u64(keys.length);
56
58
  for (const k of keys) {
@@ -65,6 +65,7 @@ export declare class Client {
65
65
  private pickBridge;
66
66
  select(quotes: Quote[], identity: string): Promise<Quote>;
67
67
  authorize(intent: Intent, quote: Quote, signer: Signer): Promise<Authorization>;
68
+ private resolvePayIn;
68
69
  initiate(intent: Intent, quote: Quote, auth: Authorization): Promise<State>;
69
70
  interactiveInitiate(intent: Intent, quote: Quote, auth: Authorization): Promise<{
70
71
  preparation: PayInPreparation;
@@ -76,6 +77,7 @@ export declare class Client {
76
77
  }>;
77
78
  private holdAndDisburse;
78
79
  private settleBridged;
80
+ private claimRefund;
79
81
  private unwind;
80
82
  refund(intentId: string, amount: Money, reason: string): Promise<{
81
83
  settlement: Settlement;
@@ -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";
@@ -98,6 +98,9 @@ export class Client {
98
98
  continue;
99
99
  }
100
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
101
104
  quotes.push(await this.composeQuote(intent, payInLeg, f));
102
105
  }
103
106
  catch (err) {
@@ -223,11 +226,9 @@ export class Client {
223
226
  // driven forward by advance as provider events arrive, so no promise or
224
227
  // connection is held open per payment and durable state lives only in the
225
228
  // ledger.
226
- async initiate(intent, quote, auth) {
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
- }
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) {
231
232
  if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
232
233
  throw new Error("pact: intent has expired");
233
234
  }
@@ -235,12 +236,26 @@ export class Client {
235
236
  if (!payInLeg) {
236
237
  throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
237
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);
238
247
  // A retry of an already-submitted corridor must not collect a second time. The
239
248
  // pay-in leg carries its own provider idempotency for the narrow window where a
240
249
  // 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;
250
+ const current = this.ledger.state(intent.id);
251
+ if (current === State.Submitted || current === State.Collecting) {
252
+ return current;
253
+ }
254
+ // Collection may only start from an authorized intent. Without this a replayed
255
+ // or out-of-order call would charge the payer through collect before the ledger
256
+ // rejected the transition, so the guard is enforced before any money moves.
257
+ if (current !== State.Authorized) {
258
+ throw new Error(`pact: cannot initiate an intent in state ${stateName[current]}; it must be authorized first`);
244
259
  }
245
260
  // A direct corridor collects straight to the recipient; a bridged one
246
261
  // collects into escrow first.
@@ -269,16 +284,16 @@ export class Client {
269
284
  if (!isDirect(quote)) {
270
285
  throw new Error("pact: interactive pay-in is only available on a direct corridor");
271
286
  }
272
- if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
273
- throw new Error("pact: intent has expired");
274
- }
275
- const payInLeg = this.payIn.get(quote.payInAdapterId);
276
- if (!payInLeg) {
277
- throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
278
- }
287
+ const payInLeg = this.resolvePayIn(intent, quote);
279
288
  if (!isInteractivePayInLeg(payInLeg)) {
280
289
  throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} does not offer interactive collection`);
281
290
  }
291
+ // The provider intent may only be prepared from an authorized intent, so a
292
+ // replayed or out-of-order call cannot open a second collection.
293
+ const current = this.ledger.state(intent.id);
294
+ if (current !== State.Authorized) {
295
+ throw new Error(`pact: cannot initiate an intent in state ${stateName[current]}; it must be authorized first`);
296
+ }
282
297
  const preparation = await payInLeg.prepare(intent.id, quote, auth, recipientDestination(intent));
283
298
  // Record the same shape a direct collection would, so the confirming webhook
284
299
  // advances the corridor to settled through the unchanged pay-in path.
@@ -335,6 +350,31 @@ export class Client {
335
350
  return this.unwind(intentId, quote, authHash, event.reason);
336
351
  return this.settleBridged(intentId, quote, authHash, events);
337
352
  }
353
+ // A refund reported out of band — an operator refunds in the provider's
354
+ // dashboard, say — is recorded against the settled intent so the ledger matches
355
+ // where the money actually is. The provider has already returned the funds, so
356
+ // this only claims the refunding step (once, so a duplicate webhook is a no-op)
357
+ // and records the refunded outcome; it moves no money itself.
358
+ if (event.state === State.Refunded && current === State.Settled) {
359
+ const { created } = this.ledger.apply({
360
+ intentId,
361
+ to: State.Refunding,
362
+ payloadHash: hashString(domain("refunding"), event.reason),
363
+ });
364
+ if (!created)
365
+ return { settlement: lastSettlement(events), state: this.ledger.state(intentId) };
366
+ const settlement = {
367
+ intentId,
368
+ state: State.Refunded,
369
+ adapterId,
370
+ providerTxRef: event.providerTxRef,
371
+ onchainTxHash: "",
372
+ receiptHash: corridorReceipt(authHash, "", event.providerTxRef, quote.srcAmount, "", State.Refunded),
373
+ reason: event.reason,
374
+ settledAt: this.clock(),
375
+ };
376
+ return this.finishAdvance(intentId, State.Refunded, settlement);
377
+ }
338
378
  // Any other event — a duplicate webhook, or one for a phase already past — is
339
379
  // a no-op. This is what makes a re-delivered provider callback settle once.
340
380
  return { settlement: lastSettlement(events), state: current };
@@ -439,14 +479,29 @@ export class Client {
439
479
  };
440
480
  return this.finishAdvance(intentId, State.Settled, settlement);
441
481
  }
442
- // unwind refunds the payer from escrow when a bridged corridor cannot complete
443
- // its pay-out, moving to refunding then refunded.
444
- async unwind(intentId, quote, authHash, reason) {
445
- this.ledger.apply({
482
+ // claimRefund reserves the refunding step before any money moves. It returns the
483
+ // recorded outcome to echo when another refund already claimed the step, or null when
484
+ // this call won it and should proceed to move funds. Both refund paths go through it,
485
+ // so a refund is issued at most once however the calls race.
486
+ claimRefund(intentId, reason) {
487
+ const { created } = this.ledger.apply({
446
488
  intentId,
447
489
  to: State.Refunding,
448
490
  payloadHash: hashString(domain("refunding"), reason),
449
491
  });
492
+ if (created)
493
+ return null;
494
+ return { settlement: lastSettlement(this.ledger.events(intentId)), state: this.ledger.state(intentId) };
495
+ }
496
+ // unwind refunds the payer from escrow when a bridged corridor cannot complete
497
+ // its pay-out, moving to refunding then refunded.
498
+ async unwind(intentId, quote, authHash, reason) {
499
+ // Claim the refund before moving money. A duplicate pay-out-failed webhook enters
500
+ // unwind twice; only the call that wins the refunding step may issue the counter-
501
+ // transfer, so the refund cannot fire a second time.
502
+ const echo = this.claimRefund(intentId, reason);
503
+ if (echo)
504
+ return echo;
450
505
  const payInLeg = this.payIn.get(quote.payInAdapterId);
451
506
  if (payInLeg) {
452
507
  const kind = payInLeg.payInCapabilities().refunds;
@@ -513,6 +568,12 @@ export class Client {
513
568
  throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot perform this refund`);
514
569
  }
515
570
  const authHash = authorizationHash(intentHash(intent), quoteHash(quote), auth.signerIdentity, auth.signedAt);
571
+ // Claim the refunding step before moving money. Two overlapping refund calls both
572
+ // pass the settled check above; only the one that wins this step issues the return,
573
+ // so the payer is never refunded twice.
574
+ const echo = this.claimRefund(intentId, reason);
575
+ if (echo)
576
+ return echo;
516
577
  await payInLeg.refundIn(intentId, kind, amount, reason);
517
578
  const settlement = {
518
579
  intentId,
@@ -1,5 +1,6 @@
1
1
  import { Money } from "./money.js";
2
2
  import { State } from "./state.js";
3
+ import { CanonicalWriter } from "./canonical.js";
3
4
  export interface Intent {
4
5
  id: string;
5
6
  senderRef: string;
@@ -29,6 +30,7 @@ export interface Quote {
29
30
  providerQuoteRef: string;
30
31
  latencyEstimateMs: number;
31
32
  }
33
+ export declare function writeQuoteFields(w: CanonicalWriter, q: Quote): CanonicalWriter;
32
34
  export declare function quotePreimage(q: Quote): Uint8Array;
33
35
  export declare function quoteHash(q: Quote): Uint8Array;
34
36
  export declare function isDirect(q: Quote): boolean;