@myzonerocks/pact 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
  }
@@ -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;
@@ -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,6 +236,14 @@ 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.
@@ -269,13 +278,7 @@ export class Client {
269
278
  if (!isDirect(quote)) {
270
279
  throw new Error("pact: interactive pay-in is only available on a direct corridor");
271
280
  }
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
- }
281
+ const payInLeg = this.resolvePayIn(intent, quote);
279
282
  if (!isInteractivePayInLeg(payInLeg)) {
280
283
  throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} does not offer interactive collection`);
281
284
  }
@@ -439,14 +442,29 @@ export class Client {
439
442
  };
440
443
  return this.finishAdvance(intentId, State.Settled, settlement);
441
444
  }
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({
445
+ // claimRefund reserves the refunding step before any money moves. It returns the
446
+ // recorded outcome to echo when another refund already claimed the step, or null when
447
+ // this call won it and should proceed to move funds. Both refund paths go through it,
448
+ // so a refund is issued at most once however the calls race.
449
+ claimRefund(intentId, reason) {
450
+ const { created } = this.ledger.apply({
446
451
  intentId,
447
452
  to: State.Refunding,
448
453
  payloadHash: hashString(domain("refunding"), reason),
449
454
  });
455
+ if (created)
456
+ return null;
457
+ return { settlement: lastSettlement(this.ledger.events(intentId)), state: this.ledger.state(intentId) };
458
+ }
459
+ // unwind refunds the payer from escrow when a bridged corridor cannot complete
460
+ // its pay-out, moving to refunding then refunded.
461
+ async unwind(intentId, quote, authHash, reason) {
462
+ // Claim the refund before moving money. A duplicate pay-out-failed webhook enters
463
+ // unwind twice; only the call that wins the refunding step may issue the counter-
464
+ // transfer, so the refund cannot fire a second time.
465
+ const echo = this.claimRefund(intentId, reason);
466
+ if (echo)
467
+ return echo;
450
468
  const payInLeg = this.payIn.get(quote.payInAdapterId);
451
469
  if (payInLeg) {
452
470
  const kind = payInLeg.payInCapabilities().refunds;
@@ -513,6 +531,12 @@ export class Client {
513
531
  throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot perform this refund`);
514
532
  }
515
533
  const authHash = authorizationHash(intentHash(intent), quoteHash(quote), auth.signerIdentity, auth.signedAt);
534
+ // Claim the refunding step before moving money. Two overlapping refund calls both
535
+ // pass the settled check above; only the one that wins this step issues the return,
536
+ // so the payer is never refunded twice.
537
+ const echo = this.claimRefund(intentId, reason);
538
+ if (echo)
539
+ return echo;
516
540
  await payInLeg.refundIn(intentId, kind, amount, reason);
517
541
  const settlement = {
518
542
  intentId,
package/dist/src/state.js CHANGED
@@ -51,7 +51,9 @@ const transitions = {
51
51
  [State.Held]: new Set([State.Disbursing, State.Refunding, State.Expired]),
52
52
  [State.Disbursing]: new Set([State.Settled, State.Failed, State.Refunding]),
53
53
  [State.Refunding]: new Set([State.Refunded, State.Failed]),
54
- [State.Settled]: new Set([State.Refunded]),
54
+ // A settled intent refunds through the same refunding step a bridged unwind uses,
55
+ // so the refund is claimed before any money moves and cannot fire twice.
56
+ [State.Settled]: new Set([State.Refunding, State.Refunded]),
55
57
  [State.Failed]: new Set(),
56
58
  [State.Expired]: new Set(),
57
59
  [State.Refunded]: new Set(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myzonerocks/pact",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "TypeScript SDK for the PACT payment abstraction protocol",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -74,6 +74,37 @@ export interface Erc20Config {
74
74
  // The confirmation depth a transfer must reach before it settles. Omitted uses
75
75
  // defaultMinConfirmations.
76
76
  minConfirmations?: number;
77
+ // Store persists broadcast transfers; defaults to an in-memory store. A deployment
78
+ // that must survive a restart with transfers in flight supplies a durable one.
79
+ store?: SentStore;
80
+ }
81
+
82
+ // SentRecord is the broadcast transfer a later settlement reads back: the intent, the
83
+ // transaction hash, and the recipient and amount the on-chain receipt must match.
84
+ export interface SentRecord {
85
+ intentId: string;
86
+ txHash: string;
87
+ to: string;
88
+ amount: bigint;
89
+ }
90
+
91
+ // SentStore records a broadcast transfer so its settlement can be polled after a
92
+ // restart and a repeat for the same intent returns the first transfer. The default
93
+ // keeps records in memory; a durable one survives a restart.
94
+ export interface SentStore {
95
+ save(rec: SentRecord): Promise<void>;
96
+ byIntent(intentId: string): Promise<SentRecord | undefined>;
97
+ }
98
+
99
+ // MemorySentStore is the default in-process store.
100
+ export class MemorySentStore implements SentStore {
101
+ private readonly byIntentMap = new Map<string, SentRecord>();
102
+ async save(rec: SentRecord): Promise<void> {
103
+ this.byIntentMap.set(rec.intentId, rec);
104
+ }
105
+ async byIntent(intentId: string): Promise<SentRecord | undefined> {
106
+ return this.byIntentMap.get(intentId);
107
+ }
77
108
  }
78
109
 
79
110
  // Erc20Leg settles a payment as an ERC-20 token transfer. Refunds are a
@@ -87,9 +118,9 @@ export class Erc20Leg implements PayInLeg, PayOutLeg {
87
118
  private readonly ids: () => string;
88
119
  private readonly decimals: number;
89
120
  private readonly minConf: number;
90
- // Each broadcast transfer and what it was meant to move, so a settlement can
91
- // confirm the mined transaction really matches the payment.
92
- private readonly sent = new Map<string, { txHash: string; to: string; amount: bigint }>();
121
+ // Broadcast transfers, so a settlement can confirm the mined transaction really
122
+ // matches the payment, and a repeat returns the first transfer.
123
+ private readonly store: SentStore;
93
124
 
94
125
  constructor(cfg: Erc20Config) {
95
126
  if (!cfg.token || !cfg.currency) {
@@ -108,6 +139,7 @@ export class Erc20Leg implements PayInLeg, PayOutLeg {
108
139
  this.ids = cfg.ids;
109
140
  this.decimals = cfg.decimals;
110
141
  this.minConf = cfg.minConfirmations ?? defaultMinConfirmations;
142
+ this.store = cfg.store ?? new MemorySentStore();
111
143
  }
112
144
 
113
145
  // checkScale refuses an amount whose exponent does not match the token's
@@ -138,12 +170,12 @@ export class Erc20Leg implements PayInLeg, PayOutLeg {
138
170
  const net = quote.srcAmount.sub(quote.fees);
139
171
  // A repeat for the same intent returns the transfer already broadcast rather
140
172
  // than sending the payer's tokens twice.
141
- const prev = this.sent.get(intentId);
173
+ const prev = await this.store.byIntent(intentId);
142
174
  if (prev) {
143
175
  return { providerRef: prev.txHash, received: net };
144
176
  }
145
177
  const txHash = await this.transfer(deliverTo, net.value());
146
- this.sent.set(intentId, { txHash, to: normalizeAddress(deliverTo), amount: net.value() });
178
+ await this.store.save({ intentId, txHash, to: normalizeAddress(deliverTo), amount: net.value() });
147
179
  return { providerRef: txHash, received: net };
148
180
  }
149
181
 
@@ -152,13 +184,13 @@ export class Erc20Leg implements PayInLeg, PayOutLeg {
152
184
  this.checkScale(quote.dstAmount);
153
185
  // A repeat for the same intent returns the transfer already broadcast rather
154
186
  // than delivering the recipient's tokens twice.
155
- const prev = this.sent.get(intentId);
187
+ const prev = await this.store.byIntent(intentId);
156
188
  if (prev) {
157
189
  return { providerRef: prev.txHash };
158
190
  }
159
191
  const amount = quote.dstAmount.value();
160
192
  const txHash = await this.transfer(recipientRef, amount);
161
- this.sent.set(intentId, { txHash, to: normalizeAddress(recipientRef), amount });
193
+ await this.store.save({ intentId, txHash, to: normalizeAddress(recipientRef), amount });
162
194
  return { providerRef: txHash };
163
195
  }
164
196
 
@@ -184,7 +216,7 @@ export class Erc20Leg implements PayInLeg, PayOutLeg {
184
216
  // shallow success stays submitted so the host keeps polling; a revert or a
185
217
  // mismatch fails, so a dropped, reorged, or spoofed transfer never settles.
186
218
  async settlementEvent(intentId: string): Promise<AdapterEvent> {
187
- const rec = this.sent.get(intentId);
219
+ const rec = await this.store.byIntent(intentId);
188
220
  if (!rec) {
189
221
  throw new Error("erc20: no broadcast transaction for intent");
190
222
  }
@@ -53,6 +53,10 @@ export interface B2CParams {
53
53
  phone: string;
54
54
  reference: string;
55
55
  remarks: string;
56
+ // Same across retries of one logical transfer and distinct between different
57
+ // transfers on the same intent (a payout versus a refund), so Daraja collapses a
58
+ // retry but never merges two separate movements.
59
+ idempotencyKey: string;
56
60
  }
57
61
 
58
62
  // B2CResult is what Daraja returns when a payout is accepted for delivery.
@@ -82,14 +86,40 @@ export interface DarajaApi {
82
86
  query(checkoutRequestId: string): Promise<StkQueryResult>;
83
87
  }
84
88
 
85
- // push remembers what settling an STK collection needs between initiating it and
86
- // the unsigned callback that reports its outcome: the intent, the payer to
87
- // refund, the amount, and the last state the callback recorded.
88
- interface PushRecord {
89
+ // PushRecord is what settling or refunding an M-Pesa collection needs after the STK
90
+ // push: the intent, the checkout id the unsigned callback arrives under, the payer to
91
+ // refund, and the amount authorized.
92
+ export interface PushRecord {
89
93
  intentId: string;
94
+ checkoutId: string;
90
95
  payerPhone: string;
91
96
  amount: number;
92
- state: State;
97
+ }
98
+
99
+ // PushStore holds push records between initiating a collection and the callback that
100
+ // resolves it. The default store keeps them in memory; a deployment that runs more than
101
+ // one instance, or must survive a restart with collections in flight, supplies a
102
+ // durable one so a callback never arrives to find its checkout forgotten.
103
+ export interface PushStore {
104
+ save(rec: PushRecord): Promise<void>;
105
+ byIntent(intentId: string): Promise<PushRecord | undefined>;
106
+ byCheckout(checkoutId: string): Promise<PushRecord | undefined>;
107
+ }
108
+
109
+ // MemoryPushStore is the default in-process store.
110
+ export class MemoryPushStore implements PushStore {
111
+ private readonly byIntentMap = new Map<string, PushRecord>();
112
+ private readonly byCheckoutMap = new Map<string, PushRecord>();
113
+ async save(rec: PushRecord): Promise<void> {
114
+ this.byIntentMap.set(rec.intentId, rec);
115
+ if (rec.checkoutId) this.byCheckoutMap.set(rec.checkoutId, rec);
116
+ }
117
+ async byIntent(intentId: string): Promise<PushRecord | undefined> {
118
+ return this.byIntentMap.get(intentId);
119
+ }
120
+ async byCheckout(checkoutId: string): Promise<PushRecord | undefined> {
121
+ return this.byCheckoutMap.get(checkoutId);
122
+ }
93
123
  }
94
124
 
95
125
  export interface MpesaConfig {
@@ -97,6 +127,9 @@ export interface MpesaConfig {
97
127
  api: DarajaApi;
98
128
  callbackURL: string;
99
129
  ids: () => string;
130
+ // Store persists push records; defaults to an in-memory store. A deployment that
131
+ // scales beyond one instance or must survive a restart supplies a durable one.
132
+ store?: PushStore;
100
133
  }
101
134
 
102
135
  // MpesaLeg moves mobile money over M-Pesa.
@@ -106,8 +139,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
106
139
  private readonly callbackURL: string;
107
140
  private readonly ids: () => string;
108
141
 
109
- private readonly byIntent = new Map<string, PushRecord>();
110
- private readonly byCheckout = new Map<string, PushRecord>();
142
+ private readonly store: PushStore;
111
143
 
112
144
  constructor(cfg: MpesaConfig) {
113
145
  if (!cfg.callbackURL) {
@@ -117,6 +149,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
117
149
  this.api = cfg.api;
118
150
  this.callbackURL = cfg.callbackURL;
119
151
  this.ids = cfg.ids;
152
+ this.store = cfg.store ?? new MemoryPushStore();
120
153
  }
121
154
 
122
155
  payInCapabilities(): PayInCapabilities {
@@ -140,7 +173,6 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
140
173
  if (!phone) {
141
174
  throw new Error("mpesa: collect requires the payer's phone");
142
175
  }
143
- const rec: PushRecord = { intentId, payerPhone: phone, amount, state: State.Unspecified };
144
176
  const result = await this.api.stkPush({
145
177
  amount,
146
178
  payerPhone: phone,
@@ -148,8 +180,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
148
180
  description: "payment",
149
181
  callbackURL: this.callbackURL,
150
182
  });
151
- this.byIntent.set(intentId, rec);
152
- this.byCheckout.set(result.checkoutRequestId, rec);
183
+ await this.store.save({ intentId, checkoutId: result.checkoutRequestId, payerPhone: phone, amount });
153
184
  return { providerRef: result.checkoutRequestId, received: net };
154
185
  }
155
186
 
@@ -161,7 +192,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
161
192
  if (!phone) {
162
193
  throw new Error("mpesa: disburse requires the recipient's phone");
163
194
  }
164
- const result = await this.api.b2cPayment({ amount, phone, reference: intentId, remarks: "payout" });
195
+ const result = await this.api.b2cPayment({ amount, phone, reference: intentId, remarks: "payout", idempotencyKey: `pact:payout:${intentId}` });
165
196
  return { providerRef: result.conversationId };
166
197
  }
167
198
 
@@ -173,7 +204,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
173
204
  throw new Error("mpesa: a collection can only be refunded by counter-transfer");
174
205
  }
175
206
  const shillings = wholeShillings(amount);
176
- const rec = this.byIntent.get(intentId);
207
+ const rec = await this.store.byIntent(intentId);
177
208
  if (!rec) {
178
209
  throw new Error(`mpesa: no push for intent ${intentId}`);
179
210
  }
@@ -186,6 +217,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
186
217
  phone: rec.payerPhone,
187
218
  reference: intentId,
188
219
  remarks: reason,
220
+ idempotencyKey: `pact:refund:${intentId}`,
189
221
  });
190
222
  return {
191
223
  intentId,
@@ -215,7 +247,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
215
247
  async parseWebhook(raw: Uint8Array, _headers: Record<string, string[]>): Promise<AdapterEvent[]> {
216
248
  const envelope = JSON.parse(new TextDecoder().decode(raw)) as StkCallbackEnvelope;
217
249
  const cb = envelope.Body?.stkCallback;
218
- const rec = cb ? this.byCheckout.get(cb.CheckoutRequestID) : undefined;
250
+ const rec = cb ? await this.store.byCheckout(cb.CheckoutRequestID) : undefined;
219
251
  if (!cb || !rec) {
220
252
  throw new Error(ErrUnknownCheckout);
221
253
  }
@@ -227,7 +259,6 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
227
259
  return [];
228
260
  }
229
261
  if (confirmed.resultCode !== 0) {
230
- rec.state = State.Failed;
231
262
  return [
232
263
  {
233
264
  intentId: rec.intentId,
@@ -247,7 +278,6 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
247
278
  }
248
279
 
249
280
  const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
250
- rec.state = State.Settled;
251
281
  return [
252
282
  {
253
283
  intentId: rec.intentId,
@@ -447,6 +477,7 @@ class HttpDarajaApi implements DarajaApi {
447
477
  async b2cPayment(params: B2CParams): Promise<B2CResult> {
448
478
  const token = await this.token();
449
479
  const body = {
480
+ OriginatorConversationID: params.idempotencyKey,
450
481
  InitiatorName: this.creds.shortCode,
451
482
  CommandID: "BusinessPayment",
452
483
  Amount: params.amount,
@@ -113,6 +113,37 @@ export interface PaypalConfig {
113
113
  currencies: string[];
114
114
  api: PaypalApi;
115
115
  ids: () => string;
116
+ // Store persists capture records; defaults to an in-memory store. A deployment that
117
+ // scales beyond one instance or must survive a restart supplies a durable one.
118
+ store?: CaptureStore;
119
+ }
120
+
121
+ // CaptureRecord is what settling or refunding a PayPal collection needs: the intent,
122
+ // the capture id a refund binds to (empty until a server-side capture returns one),
123
+ // and the amount the intent was quoted to collect.
124
+ export interface CaptureRecord {
125
+ intentId: string;
126
+ captureId: string;
127
+ expected: { value: string; currency: string };
128
+ }
129
+
130
+ // CaptureStore holds capture records between creating an order and the webhook that
131
+ // resolves it. The default keeps them in memory; a durable one lets a capture webhook
132
+ // resolve across a restart or on a second instance.
133
+ export interface CaptureStore {
134
+ save(rec: CaptureRecord): Promise<void>;
135
+ byIntent(intentId: string): Promise<CaptureRecord | undefined>;
136
+ }
137
+
138
+ // MemoryCaptureStore is the default in-process store.
139
+ export class MemoryCaptureStore implements CaptureStore {
140
+ private readonly byIntentMap = new Map<string, CaptureRecord>();
141
+ async save(rec: CaptureRecord): Promise<void> {
142
+ this.byIntentMap.set(rec.intentId, rec);
143
+ }
144
+ async byIntent(intentId: string): Promise<CaptureRecord | undefined> {
145
+ return this.byIntentMap.get(intentId);
146
+ }
116
147
  }
117
148
 
118
149
  // PaypalLeg moves money over PayPal, serving both sides of a corridor.
@@ -121,16 +152,14 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
121
152
  private readonly currencies: string[];
122
153
  private readonly api: PaypalApi;
123
154
  private readonly ids: () => string;
124
- private readonly captures = new Map<string, string>();
125
- // What each intent was quoted to collect, so a capture webhook can be
126
- // cross-checked: the paid amount and currency must match before it settles.
127
- private readonly expected = new Map<string, { value: string; currency: string }>();
155
+ private readonly store: CaptureStore;
128
156
 
129
157
  constructor(cfg: PaypalConfig) {
130
158
  this.id = cfg.id ?? "paypal";
131
159
  this.currencies = cfg.currencies;
132
160
  this.api = cfg.api;
133
161
  this.ids = cfg.ids;
162
+ this.store = cfg.store ?? new MemoryCaptureStore();
134
163
  }
135
164
 
136
165
  payInCapabilities(): PayInCapabilities {
@@ -156,10 +185,10 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
156
185
  payee: deliverTo,
157
186
  referenceId: referencePrefix + intentId,
158
187
  });
159
- this.captures.set(intentId, capture.captureId);
160
- this.expected.set(intentId, {
161
- value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
162
- currency: quote.srcAmount.currency,
188
+ await this.store.save({
189
+ intentId,
190
+ captureId: capture.captureId,
191
+ expected: { value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent), currency: quote.srcAmount.currency },
163
192
  });
164
193
  return { providerRef: capture.captureId, received: quote.srcAmount.sub(quote.fees) };
165
194
  }
@@ -181,9 +210,10 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
181
210
  params.platformFee = majorAmount(quote.fees.minor(), quote.fees.exponent);
182
211
  }
183
212
  const order = await this.api.createOrder(params);
184
- this.expected.set(intentId, {
185
- value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
186
- currency: quote.srcAmount.currency,
213
+ await this.store.save({
214
+ intentId,
215
+ captureId: "",
216
+ expected: { value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent), currency: quote.srcAmount.currency },
187
217
  });
188
218
  return { providerRef: order.id, clientSecret: order.id, method: "paypal" };
189
219
  }
@@ -207,10 +237,11 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
207
237
  if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
208
238
  throw new Error(`paypal: cannot perform refund kind ${kind}`);
209
239
  }
210
- const captureId = this.captures.get(intentId);
211
- if (!captureId) {
240
+ const rec = await this.store.byIntent(intentId);
241
+ if (!rec || !rec.captureId) {
212
242
  throw new Error(`paypal: no capture for intent ${intentId}`);
213
243
  }
244
+ const captureId = rec.captureId;
214
245
  // A partial refund names the amount in major units; a full refund leaves it
215
246
  // absent so PayPal returns the whole capture.
216
247
  const params: RefundParams =
@@ -255,10 +286,11 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
255
286
  // created the order, so a genuine, signed capture for a different order can
256
287
  // carry a target intent's id. Settle only when the captured amount and
257
288
  // currency match what the intent was quoted to collect.
258
- const want = this.expected.get(intentId);
259
- if (!want) {
289
+ const rec = await this.store.byIntent(intentId);
290
+ if (!rec) {
260
291
  throw new Error(`paypal: capture for unknown intent ${JSON.stringify(intentId)}`);
261
292
  }
293
+ const want = rec.expected;
262
294
  if (resource.amount?.value !== want.value || resource.amount?.currency_code !== want.currency) {
263
295
  throw new Error(
264
296
  `paypal: captured ${resource.amount?.value} ${resource.amount?.currency_code} does not match the quoted ${want.value} ${want.currency}`,
package/src/client.ts CHANGED
@@ -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,6 +302,15 @@ 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
@@ -338,13 +348,7 @@ export class Client {
338
348
  if (!isDirect(quote)) {
339
349
  throw new Error("pact: interactive pay-in is only available on a direct corridor");
340
350
  }
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
- }
351
+ const payInLeg = this.resolvePayIn(intent, quote);
348
352
  if (!isInteractivePayInLeg(payInLeg)) {
349
353
  throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} does not offer interactive collection`);
350
354
  }
@@ -521,6 +525,20 @@ export class Client {
521
525
  return this.finishAdvance(intentId, State.Settled, settlement);
522
526
  }
523
527
 
528
+ // claimRefund reserves the refunding step before any money moves. It returns the
529
+ // recorded outcome to echo when another refund already claimed the step, or null when
530
+ // this call won it and should proceed to move funds. Both refund paths go through it,
531
+ // so a refund is issued at most once however the calls race.
532
+ private claimRefund(intentId: string, reason: string): { settlement: Settlement; state: State } | null {
533
+ const { created } = this.ledger.apply({
534
+ intentId,
535
+ to: State.Refunding,
536
+ payloadHash: hashString(domain("refunding"), reason),
537
+ });
538
+ if (created) return null;
539
+ return { settlement: lastSettlement(this.ledger.events(intentId)), state: this.ledger.state(intentId) };
540
+ }
541
+
524
542
  // unwind refunds the payer from escrow when a bridged corridor cannot complete
525
543
  // its pay-out, moving to refunding then refunded.
526
544
  private async unwind(
@@ -529,11 +547,11 @@ export class Client {
529
547
  authHash: Uint8Array,
530
548
  reason: string,
531
549
  ): Promise<{ settlement: Settlement; state: State }> {
532
- this.ledger.apply({
533
- intentId,
534
- to: State.Refunding,
535
- payloadHash: hashString(domain("refunding"), reason),
536
- });
550
+ // Claim the refund before moving money. A duplicate pay-out-failed webhook enters
551
+ // unwind twice; only the call that wins the refunding step may issue the counter-
552
+ // transfer, so the refund cannot fire a second time.
553
+ const echo = this.claimRefund(intentId, reason);
554
+ if (echo) return echo;
537
555
  const payInLeg = this.payIn.get(quote.payInAdapterId);
538
556
  if (payInLeg) {
539
557
  const kind = payInLeg.payInCapabilities().refunds;
@@ -601,6 +619,11 @@ export class Client {
601
619
  throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot perform this refund`);
602
620
  }
603
621
  const authHash = authorizationHash(intentHash(intent), quoteHash(quote), auth.signerIdentity, auth.signedAt);
622
+ // Claim the refunding step before moving money. Two overlapping refund calls both
623
+ // pass the settled check above; only the one that wins this step issues the return,
624
+ // so the payer is never refunded twice.
625
+ const echo = this.claimRefund(intentId, reason);
626
+ if (echo) return echo;
604
627
  await payInLeg.refundIn(intentId, kind, amount, reason);
605
628
  const settlement: Settlement = {
606
629
  intentId,
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(),