@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.
- package/README.md +3 -3
- package/dist/src/adapter.d.ts +1 -1
- package/dist/src/adapters/erc20.d.ts +30 -4
- package/dist/src/adapters/erc20.js +105 -50
- package/dist/src/adapters/http.d.ts +2 -0
- package/dist/src/adapters/http.js +12 -0
- package/dist/src/adapters/mpesa.d.ts +30 -4
- package/dist/src/adapters/mpesa.js +106 -22
- package/dist/src/adapters/paypal.d.ts +23 -2
- package/dist/src/adapters/paypal.js +91 -29
- package/dist/src/adapters/stripe.d.ts +3 -2
- package/dist/src/adapters/stripe.js +14 -11
- package/dist/src/bridge.js +12 -5
- package/dist/src/canonical.js +5 -0
- package/dist/src/client.d.ts +10 -2
- package/dist/src/client.js +215 -30
- package/dist/src/compliance.d.ts +4 -0
- package/dist/src/compliance.js +10 -3
- package/dist/src/crypto.js +4 -1
- package/dist/src/index.d.ts +0 -1
- package/dist/src/index.js +0 -1
- package/dist/src/ledger.d.ts +6 -2
- package/dist/src/ledger.js +2 -2
- package/dist/src/leg.d.ts +1 -1
- package/dist/src/message.d.ts +1 -1
- package/dist/src/message.js +8 -5
- package/dist/src/money.d.ts +1 -0
- package/dist/src/money.js +18 -3
- package/dist/src/protocol.d.ts +1 -0
- package/dist/src/protocol.js +8 -0
- package/dist/src/router.d.ts +2 -0
- package/dist/src/router.js +45 -7
- package/dist/src/state.js +3 -1
- package/dist/src/wire.js +19 -2
- package/dist/test/erc20.test.js +95 -31
- package/dist/test/fake.d.ts +29 -0
- package/dist/test/fake.js +79 -0
- package/dist/test/lifecycle.test.js +31 -3
- package/dist/test/money.test.d.ts +1 -0
- package/dist/test/money.test.js +27 -0
- package/dist/test/mpesa.test.js +41 -8
- package/dist/test/paypal.test.js +33 -8
- package/dist/test/policy.test.js +6 -2
- package/dist/test/router.test.d.ts +1 -0
- package/dist/test/router.test.js +52 -0
- package/dist/test/stripe.test.js +5 -4
- package/dist/test/vectors.test.js +48 -2
- package/dist/test/wire.test.js +15 -0
- package/package.json +1 -1
- package/src/adapter.ts +7 -2
- package/src/adapters/erc20.ts +150 -51
- package/src/adapters/http.ts +14 -0
- package/src/adapters/mpesa.ts +148 -28
- package/src/adapters/paypal.ts +138 -28
- package/src/adapters/stripe.ts +16 -13
- package/src/bridge.ts +12 -5
- package/src/canonical.ts +5 -0
- package/src/client.ts +228 -33
- package/src/compliance.ts +20 -3
- package/src/crypto.ts +4 -1
- package/src/index.ts +0 -1
- package/src/ledger.ts +12 -4
- package/src/leg.ts +4 -1
- package/src/message.ts +8 -5
- package/src/money.ts +19 -3
- package/src/protocol.ts +9 -0
- package/src/router.ts +44 -4
- package/src/state.ts +3 -1
- package/src/wire.ts +20 -3
- 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
|
|
109
|
-
fixed intent produces a fixed canonical preimage, hash, and
|
|
110
|
-
|
|
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
|
package/dist/src/adapter.d.ts
CHANGED
|
@@ -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
|
|
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(
|
|
38
|
-
reverseOut(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
|
67
|
-
//
|
|
68
|
-
// the
|
|
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
|
|
71
|
-
if (!
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
state:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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,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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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:
|
|
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
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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),
|