@myzonerocks/pact 0.1.3 → 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.
Files changed (70) hide show
  1. package/README.md +3 -3
  2. package/dist/src/adapter.d.ts +1 -1
  3. package/dist/src/adapters/erc20.d.ts +30 -4
  4. package/dist/src/adapters/erc20.js +105 -50
  5. package/dist/src/adapters/http.d.ts +2 -0
  6. package/dist/src/adapters/http.js +12 -0
  7. package/dist/src/adapters/mpesa.d.ts +30 -4
  8. package/dist/src/adapters/mpesa.js +106 -22
  9. package/dist/src/adapters/paypal.d.ts +23 -2
  10. package/dist/src/adapters/paypal.js +91 -29
  11. package/dist/src/adapters/stripe.d.ts +3 -2
  12. package/dist/src/adapters/stripe.js +14 -11
  13. package/dist/src/bridge.js +12 -5
  14. package/dist/src/canonical.js +5 -0
  15. package/dist/src/client.d.ts +10 -2
  16. package/dist/src/client.js +215 -30
  17. package/dist/src/compliance.d.ts +4 -0
  18. package/dist/src/compliance.js +10 -3
  19. package/dist/src/crypto.js +4 -1
  20. package/dist/src/index.d.ts +0 -1
  21. package/dist/src/index.js +0 -1
  22. package/dist/src/ledger.d.ts +6 -2
  23. package/dist/src/ledger.js +2 -2
  24. package/dist/src/leg.d.ts +1 -1
  25. package/dist/src/message.d.ts +1 -1
  26. package/dist/src/message.js +8 -5
  27. package/dist/src/money.d.ts +1 -0
  28. package/dist/src/money.js +18 -3
  29. package/dist/src/protocol.d.ts +1 -0
  30. package/dist/src/protocol.js +8 -0
  31. package/dist/src/router.d.ts +2 -0
  32. package/dist/src/router.js +45 -7
  33. package/dist/src/state.js +3 -1
  34. package/dist/src/wire.js +19 -2
  35. package/dist/test/erc20.test.js +95 -31
  36. package/dist/test/fake.d.ts +29 -0
  37. package/dist/test/fake.js +79 -0
  38. package/dist/test/lifecycle.test.js +31 -3
  39. package/dist/test/money.test.d.ts +1 -0
  40. package/dist/test/money.test.js +27 -0
  41. package/dist/test/mpesa.test.js +41 -8
  42. package/dist/test/paypal.test.js +33 -8
  43. package/dist/test/policy.test.js +6 -2
  44. package/dist/test/router.test.d.ts +1 -0
  45. package/dist/test/router.test.js +52 -0
  46. package/dist/test/stripe.test.js +5 -4
  47. package/dist/test/vectors.test.js +48 -2
  48. package/dist/test/wire.test.js +15 -0
  49. package/package.json +1 -1
  50. package/src/adapter.ts +7 -2
  51. package/src/adapters/erc20.ts +150 -51
  52. package/src/adapters/http.ts +14 -0
  53. package/src/adapters/mpesa.ts +148 -28
  54. package/src/adapters/paypal.ts +138 -28
  55. package/src/adapters/stripe.ts +16 -13
  56. package/src/bridge.ts +12 -5
  57. package/src/canonical.ts +5 -0
  58. package/src/client.ts +228 -33
  59. package/src/compliance.ts +20 -3
  60. package/src/crypto.ts +4 -1
  61. package/src/index.ts +0 -1
  62. package/src/ledger.ts +12 -4
  63. package/src/leg.ts +4 -1
  64. package/src/message.ts +8 -5
  65. package/src/money.ts +19 -3
  66. package/src/protocol.ts +9 -0
  67. package/src/router.ts +44 -4
  68. package/src/state.ts +3 -1
  69. package/src/wire.ts +20 -3
  70. package/src/fake.ts +0 -96
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
@@ -14,5 +14,5 @@ export interface AdapterEvent {
14
14
  settledAt: number;
15
15
  }
16
16
  export interface WebhookParser {
17
- parseWebhook(raw: Uint8Array, headers: Record<string, string[]>): AdapterEvent[];
17
+ parseWebhook(raw: Uint8Array, headers: Record<string, string[]>): AdapterEvent[] | Promise<AdapterEvent[]>;
18
18
  }
@@ -1,3 +1,4 @@
1
+ import type { Money } from "../money.js";
1
2
  import type { Quote, Authorization, Settlement } from "../message.js";
2
3
  import { RefundKind, type AdapterEvent } from "../adapter.js";
3
4
  import type { PayInLeg, PayOutLeg, PayInCapabilities, PayOutCapabilities, CollectResult, DisburseResult } from "../leg.js";
@@ -7,11 +8,16 @@ export interface Call {
7
8
  }
8
9
  export interface ChainReceipt {
9
10
  status: "success" | "reverted" | "pending";
11
+ blockNumber: number;
10
12
  blockTimestampMs: number;
13
+ tokenAddress: string;
14
+ to: string;
15
+ amount: bigint;
11
16
  }
12
17
  export interface ChainClient {
13
18
  send(call: Call): Promise<string>;
14
19
  receipt(txHash: string): Promise<ChainReceipt>;
20
+ blockNumber(): Promise<number>;
15
21
  }
16
22
  export interface Erc20Config {
17
23
  id?: string;
@@ -20,6 +26,24 @@ export interface Erc20Config {
20
26
  rail?: string;
21
27
  chain: ChainClient;
22
28
  ids: () => string;
29
+ decimals: number;
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>;
23
47
  }
24
48
  export declare class Erc20Leg implements PayInLeg, PayOutLeg {
25
49
  readonly id: string;
@@ -28,16 +52,18 @@ export declare class Erc20Leg implements PayInLeg, PayOutLeg {
28
52
  private readonly rail;
29
53
  private readonly chain;
30
54
  private readonly ids;
31
- private readonly sent;
55
+ private readonly decimals;
56
+ private readonly minConf;
57
+ private readonly store;
32
58
  constructor(cfg: Erc20Config);
59
+ private checkScale;
33
60
  payInCapabilities(): PayInCapabilities;
34
61
  payOutCapabilities(): PayOutCapabilities;
35
62
  collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult>;
36
63
  disburse(intentId: string, quote: Quote, recipientRef: string): Promise<DisburseResult>;
37
- refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
38
- reverseOut(intentId: string, reason: string): Promise<Settlement>;
64
+ refundIn(_intentId: string, _kind: RefundKind, _amount: Money, _reason: string): Promise<Settlement>;
65
+ reverseOut(_intentId: string, _reason: string): Promise<Settlement>;
39
66
  settlementEvent(intentId: string): Promise<AdapterEvent>;
40
67
  private transfer;
41
- private terminal;
42
68
  }
43
69
  export declare function transferCalldata(recipient: string, amount: bigint): string;
@@ -10,6 +10,20 @@ import { RefundKind } from "../adapter.js";
10
10
  // The 4-byte selector for the ERC-20 transfer(address,uint256) call. It is a
11
11
  // fixed constant of the standard, so building the calldata needs no hashing.
12
12
  const transferSelector = "a9059cbb";
13
+ // The confirmation depth a transfer must reach before it settles. A shallow
14
+ // success can still be reorged out, so a settlement waits for enough blocks on
15
+ // top of it.
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
+ }
13
27
  // Erc20Leg settles a payment as an ERC-20 token transfer. Refunds are a
14
28
  // counter-transfer only, because a token transfer is irreversible.
15
29
  export class Erc20Leg {
@@ -19,20 +33,43 @@ export class Erc20Leg {
19
33
  rail;
20
34
  chain;
21
35
  ids;
22
- sent = new Map();
36
+ decimals;
37
+ minConf;
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;
23
41
  constructor(cfg) {
24
42
  if (!cfg.token || !cfg.currency) {
25
43
  throw new Error("erc20: config requires a token address and currency");
26
44
  }
45
+ // The token address is fixed for the leg's lifetime, so validate it once here
46
+ // rather than discover a malformed contract address at the first transfer.
47
+ if (!/^[0-9a-f]{40}$/.test(normalizeAddress(cfg.token))) {
48
+ throw new Error(`erc20: token ${JSON.stringify(cfg.token)} is not a 20-byte address`);
49
+ }
27
50
  this.id = cfg.id ?? "erc20";
28
51
  this.token = cfg.token;
29
52
  this.currency = cfg.currency;
30
53
  this.rail = cfg.rail ?? "erc20";
31
54
  this.chain = cfg.chain;
32
55
  this.ids = cfg.ids;
56
+ this.decimals = cfg.decimals;
57
+ this.minConf = cfg.minConfirmations ?? defaultMinConfirmations;
58
+ this.store = cfg.store ?? new MemorySentStore();
59
+ }
60
+ // checkScale refuses an amount whose exponent does not match the token's
61
+ // decimals, so a quote priced at the wrong scale never moves the wrong number
62
+ // of tokens.
63
+ checkScale(m) {
64
+ if (m.exponent !== this.decimals) {
65
+ throw new Error(`erc20: amount exponent ${m.exponent} does not match the token's ${this.decimals} decimals`);
66
+ }
33
67
  }
34
68
  payInCapabilities() {
35
- return { rails: [this.rail], currencies: [this.currency], refunds: RefundKind.CounterTransfer };
69
+ // A token collection is irreversible and the leg holds no custody to send the
70
+ // payer back, so it advertises no refund rather than a capability it cannot
71
+ // honour. An adopter that wires refund custody supplies a leg that offers one.
72
+ return { rails: [this.rail], currencies: [this.currency], refunds: RefundKind.None };
36
73
  }
37
74
  payOutCapabilities() {
38
75
  return { rails: [this.rail], currencies: [this.currency], reversible: false };
@@ -41,62 +78,82 @@ export class Erc20Leg {
41
78
  // direct corridor, the escrow for a bridged one. received is the net a bridge
42
79
  // would convert: the source the payer paid less the corridor fees.
43
80
  async collect(intentId, quote, _auth, deliverTo) {
81
+ this.checkScale(quote.srcAmount);
44
82
  const net = quote.srcAmount.sub(quote.fees);
83
+ // A repeat for the same intent returns the transfer already broadcast rather
84
+ // than sending the payer's tokens twice.
85
+ const prev = await this.store.byIntent(intentId);
86
+ if (prev) {
87
+ return { providerRef: prev.txHash, received: net };
88
+ }
45
89
  const txHash = await this.transfer(deliverTo, net.value());
46
- this.sent.set(intentId, txHash);
90
+ await this.store.save({ intentId, txHash, to: normalizeAddress(deliverTo), amount: net.value() });
47
91
  return { providerRef: txHash, received: net };
48
92
  }
49
93
  // disburse delivers the recipient's tokens.
50
94
  async disburse(intentId, quote, recipientRef) {
51
- const txHash = await this.transfer(recipientRef, quote.dstAmount.value());
52
- this.sent.set(intentId, txHash);
95
+ this.checkScale(quote.dstAmount);
96
+ // A repeat for the same intent returns the transfer already broadcast rather
97
+ // than delivering the recipient's tokens twice.
98
+ const prev = await this.store.byIntent(intentId);
99
+ if (prev) {
100
+ return { providerRef: prev.txHash };
101
+ }
102
+ const amount = quote.dstAmount.value();
103
+ const txHash = await this.transfer(recipientRef, amount);
104
+ await this.store.save({ intentId, txHash, to: normalizeAddress(recipientRef), amount });
53
105
  return { providerRef: txHash };
54
106
  }
55
- // refundIn and reverseOut both answer with a counter-transfer, the only refund
56
- // an irreversible token movement supports.
57
- async refundIn(intentId, kind, reason) {
58
- if (kind !== RefundKind.CounterTransfer) {
59
- throw new Error("erc20: a token transfer can only be refunded by counter-transfer");
60
- }
61
- return this.terminal(intentId, reason);
107
+ // refundIn refuses: an ERC-20 collection is irreversible and the leg holds no
108
+ // custody to counter-transfer the payer back, so it will not record a refund
109
+ // that moves no tokens. An adopter that wires refund custody supplies a leg
110
+ // that advertises and honours a refund.
111
+ async refundIn(_intentId, _kind, _amount, _reason) {
112
+ throw new Error("erc20: a token collection cannot be refunded in place; wire refund custody to return the payer");
62
113
  }
63
- async reverseOut(intentId, reason) {
64
- return this.terminal(intentId, reason);
114
+ // reverseOut refuses, reporting the truth of the rail: a delivered token
115
+ // transfer cannot be pulled back.
116
+ async reverseOut(_intentId, _reason) {
117
+ throw new Error("erc20: a delivered token transfer cannot be reversed");
65
118
  }
66
- // settlementEvent reads the receipt for an intent's transaction and produces the
67
- // event a host feeds back once the transfer confirms. Crypto has no webhook, so
68
- // the host polls this instead.
119
+ // settlementEvent reads a broadcast transfer back from chain and reports its
120
+ // outcome. A token transfer has no webhook, so a host polls this. It settles
121
+ // only when the transaction is final: mined successfully, buried under the
122
+ // required confirmations, and carrying a Transfer of the configured token, in
123
+ // the amount that was sent, to the recipient it was sent to. A pending or
124
+ // shallow success stays submitted so the host keeps polling; a revert or a
125
+ // mismatch fails, so a dropped, reorged, or spoofed transfer never settles.
69
126
  async settlementEvent(intentId) {
70
- const txHash = this.sent.get(intentId);
71
- if (!txHash) {
127
+ const rec = await this.store.byIntent(intentId);
128
+ if (!rec) {
72
129
  throw new Error("erc20: no broadcast transaction for intent");
73
130
  }
74
- const receipt = await this.chain.receipt(txHash);
75
- return {
76
- intentId,
77
- state: receiptState(receipt),
78
- providerTxRef: txHash,
79
- onchainTxHash: txHash,
80
- reason: receipt.status === "reverted" ? "transaction reverted" : "",
81
- settledAt: receipt.blockTimestampMs,
82
- };
131
+ const receipt = await this.chain.receipt(rec.txHash);
132
+ const base = { intentId, providerTxRef: rec.txHash, onchainTxHash: rec.txHash, settledAt: 0 };
133
+ if (receipt.status === "reverted") {
134
+ return { ...base, state: State.Failed, reason: "transaction reverted" };
135
+ }
136
+ if (receipt.status !== "success") {
137
+ return { ...base, state: State.Submitted, reason: "" };
138
+ }
139
+ const head = await this.chain.blockNumber();
140
+ if (receipt.blockNumber === 0 || head < receipt.blockNumber || head - receipt.blockNumber + 1 < this.minConf) {
141
+ return { ...base, state: State.Submitted, reason: "" };
142
+ }
143
+ if (!sameAddress(receipt.tokenAddress, this.token) ||
144
+ !sameAddress(receipt.to, rec.to) ||
145
+ receipt.amount !== rec.amount) {
146
+ return {
147
+ ...base,
148
+ state: State.Failed,
149
+ reason: "on-chain transfer does not match the expected token, recipient, or amount",
150
+ };
151
+ }
152
+ return { ...base, state: State.Settled, reason: "", settledAt: receipt.blockTimestampMs };
83
153
  }
84
154
  async transfer(to, amount) {
85
155
  return this.chain.send({ to: this.token, data: transferCalldata(to, amount) });
86
156
  }
87
- terminal(intentId, reason) {
88
- const txHash = this.sent.get(intentId) ?? "";
89
- return {
90
- intentId,
91
- state: State.Refunded,
92
- adapterId: this.id,
93
- providerTxRef: txHash,
94
- onchainTxHash: txHash,
95
- receiptHash: new Uint8Array(0),
96
- reason,
97
- settledAt: 0,
98
- };
99
- }
100
157
  }
101
158
  // transferCalldata builds the ERC-20 transfer calldata: the selector, the
102
159
  // recipient address left-padded to 32 bytes, and the amount as a 32-byte word.
@@ -114,13 +171,11 @@ export function transferCalldata(recipient, amount) {
114
171
  }
115
172
  return "0x" + transferSelector + address.padStart(64, "0") + amountHex.padStart(64, "0");
116
173
  }
117
- function receiptState(receipt) {
118
- switch (receipt.status) {
119
- case "success":
120
- return State.Settled;
121
- case "reverted":
122
- return State.Failed;
123
- default:
124
- return State.Submitted;
125
- }
174
+ // normalizeAddress lowercases an EVM address and drops any 0x prefix, so two
175
+ // spellings of the same address compare equal.
176
+ function normalizeAddress(addr) {
177
+ return addr.toLowerCase().replace(/^0x/, "");
178
+ }
179
+ function sameAddress(a, b) {
180
+ return normalizeAddress(a) === normalizeAddress(b);
126
181
  }
@@ -0,0 +1,2 @@
1
+ export declare const httpTimeoutMs = 30000;
2
+ export declare function fetchWithTimeout(url: string, init?: RequestInit): Promise<Response>;
@@ -0,0 +1,12 @@
1
+ // Shared HTTP settings for the network adapters. A provider call that never
2
+ // answers must not hang the corridor forever, so every outbound request carries
3
+ // the same deadline. The Go adapters bound their http.Client the same way.
4
+ // httpTimeoutMs bounds how long an outbound provider request may run before it is
5
+ // aborted. It matches the 30-second ceiling the Go adapters use.
6
+ export const httpTimeoutMs = 30_000;
7
+ // fetchWithTimeout issues a fetch that aborts once httpTimeoutMs elapses, so a
8
+ // stalled provider surfaces as an error rather than an unbounded wait. A caller
9
+ // that already supplies a signal is left untouched.
10
+ export function fetchWithTimeout(url, init = {}) {
11
+ return fetch(url, { signal: AbortSignal.timeout(httpTimeoutMs), ...init });
12
+ }
@@ -1,3 +1,4 @@
1
+ import { Money } from "../money.js";
1
2
  import type { Quote, Authorization, Settlement } from "../message.js";
2
3
  import { RefundKind, type AdapterEvent } from "../adapter.js";
3
4
  import type { PayInLeg, PayOutLeg, PayInCapabilities, PayOutCapabilities, CollectResult, DisburseResult } from "../leg.js";
@@ -19,36 +20,61 @@ export interface B2CParams {
19
20
  phone: string;
20
21
  reference: string;
21
22
  remarks: string;
23
+ idempotencyKey: string;
22
24
  }
23
25
  export interface B2CResult {
24
26
  conversationId: string;
25
27
  responseCode: string;
26
28
  }
29
+ export interface StkQueryResult {
30
+ resultCode: number;
31
+ resultDesc: string;
32
+ pending: boolean;
33
+ }
27
34
  export interface DarajaApi {
28
35
  stkPush(params: StkPushParams): Promise<StkPushResult>;
29
36
  b2cPayment(params: B2CParams): Promise<B2CResult>;
37
+ query(checkoutRequestId: string): Promise<StkQueryResult>;
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>;
30
56
  }
31
57
  export interface MpesaConfig {
32
58
  id?: string;
33
59
  api: DarajaApi;
34
60
  callbackURL: string;
35
61
  ids: () => string;
62
+ store?: PushStore;
36
63
  }
37
64
  export declare class MpesaLeg implements PayInLeg, PayOutLeg {
38
65
  readonly id: string;
39
66
  private readonly api;
40
67
  private readonly callbackURL;
41
68
  private readonly ids;
42
- private readonly byIntent;
43
- private readonly byCheckout;
69
+ private readonly store;
44
70
  constructor(cfg: MpesaConfig);
45
71
  payInCapabilities(): PayInCapabilities;
46
72
  payOutCapabilities(): PayOutCapabilities;
47
73
  collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult>;
48
74
  disburse(intentId: string, quote: Quote, recipientRef: string): Promise<DisburseResult>;
49
- refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
75
+ refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement>;
50
76
  reverseOut(_intentId: string, _reason: string): Promise<Settlement>;
51
- parseWebhook(raw: Uint8Array, _headers: Record<string, string[]>): AdapterEvent[];
77
+ parseWebhook(raw: Uint8Array, _headers: Record<string, string[]>): Promise<AdapterEvent[]>;
52
78
  }
53
79
  export declare function normalizePhone(phone: string): string;
54
80
  export interface Credentials {
@@ -1,5 +1,6 @@
1
1
  import { State } from "../state.js";
2
2
  import { RefundKind } from "../adapter.js";
3
+ import { fetchWithTimeout } from "./http.js";
3
4
  // The public Daraja host. It is the same for every integration and holds no
4
5
  // secret; tests point the leg at a local server instead.
5
6
  const defaultBaseURL = "https://api.safaricom.co.ke";
@@ -8,14 +9,29 @@ const defaultBaseURL = "https://api.safaricom.co.ke";
8
9
  // checkout id to one we started is the authentication: an unrecognized id is
9
10
  // rejected rather than acted on.
10
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
+ }
11
28
  // MpesaLeg moves mobile money over M-Pesa.
12
29
  export class MpesaLeg {
13
30
  id;
14
31
  api;
15
32
  callbackURL;
16
33
  ids;
17
- byIntent = new Map();
18
- byCheckout = new Map();
34
+ store;
19
35
  constructor(cfg) {
20
36
  if (!cfg.callbackURL) {
21
37
  throw new Error("mpesa: config requires a callback URL");
@@ -24,6 +40,7 @@ export class MpesaLeg {
24
40
  this.api = cfg.api;
25
41
  this.callbackURL = cfg.callbackURL;
26
42
  this.ids = cfg.ids;
43
+ this.store = cfg.store ?? new MemoryPushStore();
27
44
  }
28
45
  payInCapabilities() {
29
46
  return { rails: ["mpesa"], currencies: ["KES"], refunds: RefundKind.CounterTransfer };
@@ -44,7 +61,6 @@ export class MpesaLeg {
44
61
  if (!phone) {
45
62
  throw new Error("mpesa: collect requires the payer's phone");
46
63
  }
47
- const rec = { intentId, payerPhone: phone, amount, state: State.Unspecified };
48
64
  const result = await this.api.stkPush({
49
65
  amount,
50
66
  payerPhone: phone,
@@ -52,8 +68,7 @@ export class MpesaLeg {
52
68
  description: "payment",
53
69
  callbackURL: this.callbackURL,
54
70
  });
55
- this.byIntent.set(intentId, rec);
56
- this.byCheckout.set(result.checkoutRequestId, rec);
71
+ await this.store.save({ intentId, checkoutId: result.checkoutRequestId, payerPhone: phone, amount });
57
72
  return { providerRef: result.checkoutRequestId, received: net };
58
73
  }
59
74
  // disburse delivers the recipient's shillings by a business-to-customer payout
@@ -64,25 +79,31 @@ export class MpesaLeg {
64
79
  if (!phone) {
65
80
  throw new Error("mpesa: disburse requires the recipient's phone");
66
81
  }
67
- 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}` });
68
83
  return { providerRef: result.conversationId };
69
84
  }
70
85
  // refundIn answers a collected payment with a business-to-customer payout back
71
86
  // to the payer. An STK collection cannot be reversed in place, so a
72
87
  // counter-transfer is the only refund this rail supports.
73
- async refundIn(intentId, kind, reason) {
88
+ async refundIn(intentId, kind, amount, reason) {
74
89
  if (kind !== RefundKind.CounterTransfer) {
75
90
  throw new Error("mpesa: a collection can only be refunded by counter-transfer");
76
91
  }
77
- const rec = this.byIntent.get(intentId);
92
+ const shillings = wholeShillings(amount);
93
+ const rec = await this.store.byIntent(intentId);
78
94
  if (!rec) {
79
95
  throw new Error(`mpesa: no push for intent ${intentId}`);
80
96
  }
97
+ // A refund cannot return more shillings than the push collected.
98
+ if (shillings > rec.amount) {
99
+ throw new Error(`mpesa: refund of ${shillings} exceeds the ${rec.amount} collected`);
100
+ }
81
101
  const result = await this.api.b2cPayment({
82
- amount: rec.amount,
102
+ amount: shillings,
83
103
  phone: rec.payerPhone,
84
104
  reference: intentId,
85
105
  remarks: reason,
106
+ idempotencyKey: `pact:refund:${intentId}`,
86
107
  });
87
108
  return {
88
109
  intentId,
@@ -101,32 +122,44 @@ export class MpesaLeg {
101
122
  throw new Error("mpesa: a delivered payout cannot be reversed");
102
123
  }
103
124
  // parseWebhook reads an STK callback and normalizes it into a protocol event.
104
- // The callback is unsigned, so it is authenticated by matching its checkout id
105
- // to a push this leg started; an unrecognized id is refused. The headers are
106
- // accepted for interface symmetry and for a host that adds its own IP or
107
- // shared-secret gate on top.
108
- parseWebhook(raw, _headers) {
125
+ // The callback is unsigned and its CheckoutRequestID is a value we hand back to
126
+ // the caller — not a secret so the callback body is treated only as a nudge.
127
+ // The real outcome is read back from Daraja with our own credentials, and a
128
+ // settlement is emitted only when that authenticated query confirms success and
129
+ // the amount Daraja paid equals the amount we authorized. The headers are
130
+ // accepted for interface symmetry and for a host that adds its own gate on top.
131
+ async parseWebhook(raw, _headers) {
109
132
  const envelope = JSON.parse(new TextDecoder().decode(raw));
110
133
  const cb = envelope.Body?.stkCallback;
111
- const rec = cb ? this.byCheckout.get(cb.CheckoutRequestID) : undefined;
134
+ const rec = cb ? await this.store.byCheckout(cb.CheckoutRequestID) : undefined;
112
135
  if (!cb || !rec) {
113
136
  throw new Error(ErrUnknownCheckout);
114
137
  }
115
- if (cb.ResultCode !== 0) {
116
- rec.state = State.Failed;
138
+ const confirmed = await this.api.query(cb.CheckoutRequestID);
139
+ // No outcome yet — wait for a later callback rather than settling or failing
140
+ // on an unconfirmed body.
141
+ if (confirmed.pending) {
142
+ return [];
143
+ }
144
+ if (confirmed.resultCode !== 0) {
117
145
  return [
118
146
  {
119
147
  intentId: rec.intentId,
120
148
  state: State.Failed,
121
149
  providerTxRef: cb.CheckoutRequestID,
122
150
  onchainTxHash: "",
123
- reason: cb.ResultDesc ?? "",
151
+ reason: confirmed.resultDesc,
124
152
  settledAt: 0,
125
153
  },
126
154
  ];
127
155
  }
156
+ // The amount Daraja collected must equal the amount we authorized; a partial
157
+ // or tampered collection settles nothing.
158
+ const paid = metadataInt(cb.CallbackMetadata?.Item ?? [], "Amount");
159
+ if (paid !== rec.amount) {
160
+ throw new Error(`mpesa: confirmed amount ${paid} does not match the authorized ${rec.amount}`);
161
+ }
128
162
  const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
129
- rec.state = State.Settled;
130
163
  return [
131
164
  {
132
165
  intentId: rec.intentId,
@@ -152,6 +185,19 @@ function wholeShillings(m) {
152
185
  }
153
186
  return Number(amount);
154
187
  }
188
+ // metadataInt pulls a named numeric value out of the callback metadata items,
189
+ // used to read the paid Amount. M-Pesa amounts are whole shillings but may arrive
190
+ // as a number with a fractional part, so it is floored to the shilling.
191
+ function metadataInt(items, name) {
192
+ for (const item of items) {
193
+ if (item.Name !== name) {
194
+ continue;
195
+ }
196
+ const n = Number(item.Value);
197
+ return Number.isFinite(n) ? Math.floor(n) : 0;
198
+ }
199
+ return 0;
200
+ }
155
201
  // metadataString pulls a named string value out of the callback metadata items.
156
202
  function metadataString(items, name) {
157
203
  for (const item of items) {
@@ -183,14 +229,21 @@ class HttpDarajaApi {
183
229
  creds;
184
230
  baseURL;
185
231
  now;
232
+ cachedToken = "";
233
+ tokenExpiryMs = 0;
186
234
  constructor(creds, now) {
187
235
  this.creds = creds;
188
236
  this.baseURL = creds.baseURL || defaultBaseURL;
189
237
  this.now = now;
190
238
  }
191
239
  async token() {
240
+ // Reuse the cached token until it is within a minute of expiry, so a burst of
241
+ // pushes does not re-authenticate against Daraja on every call.
242
+ if (this.cachedToken && this.now().getTime() < this.tokenExpiryMs - 60_000) {
243
+ return this.cachedToken;
244
+ }
192
245
  const basic = Buffer.from(`${this.creds.consumerKey}:${this.creds.consumerSecret}`).toString("base64");
193
- const resp = await fetch(`${this.baseURL}/oauth/v1/generate?grant_type=client_credentials`, {
246
+ const resp = await fetchWithTimeout(`${this.baseURL}/oauth/v1/generate?grant_type=client_credentials`, {
194
247
  method: "GET",
195
248
  headers: { Authorization: `Basic ${basic}` },
196
249
  });
@@ -198,7 +251,11 @@ class HttpDarajaApi {
198
251
  throw new Error(`mpesa: /oauth/v1/generate returned ${resp.status}`);
199
252
  }
200
253
  const out = (await resp.json());
201
- return out.access_token ?? "";
254
+ // Daraja tokens live an hour; fall back to that if the field is absent.
255
+ const ttlSeconds = Number(out.expires_in) > 0 ? Number(out.expires_in) : 3599;
256
+ this.cachedToken = out.access_token ?? "";
257
+ this.tokenExpiryMs = this.now().getTime() + ttlSeconds * 1000;
258
+ return this.cachedToken;
202
259
  }
203
260
  // password is the base64 of shortcode+passkey+timestamp Daraja requires on each
204
261
  // STK push.
@@ -228,9 +285,36 @@ class HttpDarajaApi {
228
285
  responseCode: out.ResponseCode ?? "",
229
286
  };
230
287
  }
288
+ async query(checkoutRequestId) {
289
+ const token = await this.token();
290
+ const timestamp = formatTimestamp(this.now());
291
+ const body = {
292
+ BusinessShortCode: this.creds.shortCode,
293
+ Password: this.password(timestamp),
294
+ Timestamp: timestamp,
295
+ CheckoutRequestID: checkoutRequestId,
296
+ };
297
+ const resp = await fetchWithTimeout(this.baseURL + "/mpesa/stkpushquery/v1/query", {
298
+ method: "POST",
299
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
300
+ body: JSON.stringify(body),
301
+ });
302
+ const out = (await resp.json());
303
+ // Daraja answers a query for a push it is still processing with an error code
304
+ // rather than a result; treat that as pending so the outcome is confirmed on a
305
+ // later query rather than mistaken for a failure.
306
+ if (out.errorCode === "500.001.1001") {
307
+ return { resultCode: 0, resultDesc: "", pending: true };
308
+ }
309
+ if (resp.status >= 300) {
310
+ throw new Error(`mpesa: stk query returned ${resp.status}`);
311
+ }
312
+ return { resultCode: Number(out.ResultCode ?? -1), resultDesc: out.ResultDesc ?? "", pending: false };
313
+ }
231
314
  async b2cPayment(params) {
232
315
  const token = await this.token();
233
316
  const body = {
317
+ OriginatorConversationID: params.idempotencyKey,
234
318
  InitiatorName: this.creds.shortCode,
235
319
  CommandID: "BusinessPayment",
236
320
  Amount: params.amount,
@@ -243,7 +327,7 @@ class HttpDarajaApi {
243
327
  return { conversationId: out.ConversationID ?? "", responseCode: out.ResponseCode ?? "" };
244
328
  }
245
329
  async postJSON(token, path, body) {
246
- const resp = await fetch(this.baseURL + path, {
330
+ const resp = await fetchWithTimeout(this.baseURL + path, {
247
331
  method: "POST",
248
332
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
249
333
  body: JSON.stringify(body),