@myzonerocks/pact 0.1.0

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 (88) hide show
  1. package/dist/src/adapter.d.ts +18 -0
  2. package/dist/src/adapter.js +11 -0
  3. package/dist/src/adapters/erc20.d.ts +43 -0
  4. package/dist/src/adapters/erc20.js +126 -0
  5. package/dist/src/adapters/mpesa.d.ts +61 -0
  6. package/dist/src/adapters/mpesa.js +272 -0
  7. package/dist/src/adapters/paypal.d.ts +68 -0
  8. package/dist/src/adapters/paypal.js +279 -0
  9. package/dist/src/adapters/stripe.d.ts +61 -0
  10. package/dist/src/adapters/stripe.js +336 -0
  11. package/dist/src/bridge.d.ts +40 -0
  12. package/dist/src/bridge.js +99 -0
  13. package/dist/src/canonical.d.ts +14 -0
  14. package/dist/src/canonical.js +82 -0
  15. package/dist/src/client.d.ts +84 -0
  16. package/dist/src/client.js +510 -0
  17. package/dist/src/compliance.d.ts +23 -0
  18. package/dist/src/compliance.js +26 -0
  19. package/dist/src/crypto.d.ts +12 -0
  20. package/dist/src/crypto.js +50 -0
  21. package/dist/src/fake.d.ts +29 -0
  22. package/dist/src/fake.js +79 -0
  23. package/dist/src/index.d.ts +16 -0
  24. package/dist/src/index.js +16 -0
  25. package/dist/src/ledger.d.ts +47 -0
  26. package/dist/src/ledger.js +84 -0
  27. package/dist/src/leg.d.ts +34 -0
  28. package/dist/src/leg.js +4 -0
  29. package/dist/src/message.d.ts +55 -0
  30. package/dist/src/message.js +88 -0
  31. package/dist/src/money.d.ts +16 -0
  32. package/dist/src/money.js +81 -0
  33. package/dist/src/payload.d.ts +11 -0
  34. package/dist/src/payload.js +52 -0
  35. package/dist/src/policy.d.ts +26 -0
  36. package/dist/src/policy.js +83 -0
  37. package/dist/src/protocol.d.ts +4 -0
  38. package/dist/src/protocol.js +15 -0
  39. package/dist/src/router.d.ts +16 -0
  40. package/dist/src/router.js +87 -0
  41. package/dist/src/signing.d.ts +27 -0
  42. package/dist/src/signing.js +72 -0
  43. package/dist/src/state.d.ts +23 -0
  44. package/dist/src/state.js +76 -0
  45. package/dist/src/wire.d.ts +30 -0
  46. package/dist/src/wire.js +221 -0
  47. package/dist/test/erc20.test.d.ts +1 -0
  48. package/dist/test/erc20.test.js +131 -0
  49. package/dist/test/lifecycle.test.d.ts +1 -0
  50. package/dist/test/lifecycle.test.js +212 -0
  51. package/dist/test/mpesa.test.d.ts +1 -0
  52. package/dist/test/mpesa.test.js +180 -0
  53. package/dist/test/payload.test.d.ts +1 -0
  54. package/dist/test/payload.test.js +32 -0
  55. package/dist/test/paypal.test.d.ts +1 -0
  56. package/dist/test/paypal.test.js +140 -0
  57. package/dist/test/policy.test.d.ts +1 -0
  58. package/dist/test/policy.test.js +131 -0
  59. package/dist/test/stripe.test.d.ts +1 -0
  60. package/dist/test/stripe.test.js +176 -0
  61. package/dist/test/vectors.test.d.ts +1 -0
  62. package/dist/test/vectors.test.js +91 -0
  63. package/dist/test/wire.test.d.ts +1 -0
  64. package/dist/test/wire.test.js +104 -0
  65. package/package.json +50 -0
  66. package/src/adapter.ts +32 -0
  67. package/src/adapters/erc20.ts +181 -0
  68. package/src/adapters/mpesa.ts +408 -0
  69. package/src/adapters/paypal.ts +409 -0
  70. package/src/adapters/stripe.ts +478 -0
  71. package/src/bridge.ts +148 -0
  72. package/src/canonical.ts +94 -0
  73. package/src/client.ts +605 -0
  74. package/src/compliance.ts +65 -0
  75. package/src/crypto.ts +68 -0
  76. package/src/fake.ts +96 -0
  77. package/src/index.ts +106 -0
  78. package/src/ledger.ts +145 -0
  79. package/src/leg.ts +65 -0
  80. package/src/message.ts +178 -0
  81. package/src/money.ts +87 -0
  82. package/src/payload.ts +58 -0
  83. package/src/policy.ts +110 -0
  84. package/src/protocol.ts +19 -0
  85. package/src/router.ts +97 -0
  86. package/src/signing.ts +92 -0
  87. package/src/state.ts +76 -0
  88. package/src/wire.ts +248 -0
@@ -0,0 +1,131 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { Client } from "../src/client.js";
3
+ import { Money } from "../src/money.js";
4
+ import { State } from "../src/state.js";
5
+ import { isDirect } from "../src/message.js";
6
+ import { RefundKind } from "../src/adapter.js";
7
+ import { Ed25519Signer, Ed25519Verifier } from "../src/signing.js";
8
+ import { fromHex } from "../src/crypto.js";
9
+ import { Erc20Leg, transferCalldata } from "../src/adapters/erc20.js";
10
+ function counter() {
11
+ let n = 0;
12
+ return () => `0xtx${String(++n).padStart(3, "0")}`;
13
+ }
14
+ function signerVerifier(identity) {
15
+ const seed = fromHex("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff");
16
+ const signer = Ed25519Signer.fromSeed(identity, seed);
17
+ return { signer, verifier: new Ed25519Verifier(new Map([[identity, signer.publicKey]])) };
18
+ }
19
+ // FakeChain records the calls it is asked to broadcast and returns a receipt the
20
+ // test controls, so the leg runs with no chain and no key.
21
+ class FakeChain {
22
+ calls = [];
23
+ ids = counter();
24
+ status = "success";
25
+ async send(call) {
26
+ this.calls.push(call);
27
+ return this.ids();
28
+ }
29
+ async receipt(_txHash) {
30
+ return { status: this.status, blockTimestampMs: 1_700_000_005_000 };
31
+ }
32
+ }
33
+ const usdcToken = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
34
+ const recipient = "0x00000000000000000000000000000000000000aa";
35
+ describe("erc20 transfer calldata", () => {
36
+ it("encodes selector, padded address, and 32-byte amount", () => {
37
+ // 1_000_000 minor units of a 6-decimal token is one whole token: 0xf4240.
38
+ const data = transferCalldata(recipient, 1000000n);
39
+ expect(data).toBe("0xa9059cbb" +
40
+ "000000000000000000000000" + "00000000000000000000000000000000000000aa" +
41
+ "00000000000000000000000000000000000000000000000000000000000f4240");
42
+ });
43
+ it("rejects a non-address recipient and an oversized amount", () => {
44
+ expect(() => transferCalldata("0x1234", 1n)).toThrow();
45
+ expect(() => transferCalldata(recipient, 1n << 256n)).toThrow();
46
+ });
47
+ });
48
+ describe("erc20 direct corridor", () => {
49
+ const now = 1_700_000_000_000;
50
+ function build(chain) {
51
+ const { signer, verifier } = signerVerifier("alice");
52
+ const leg = new Erc20Leg({ token: usdcToken, currency: "USDC", chain, ids: counter() });
53
+ const client = new Client({ payIn: [leg], payOut: [leg], verifier, clock: () => now, idGen: counter() });
54
+ return { signer, leg, client };
55
+ }
56
+ it("settles a token transfer through the kernel", async () => {
57
+ const chain = new FakeChain();
58
+ const { signer, client } = build(chain);
59
+ const intent = client.createIntent({
60
+ senderRef: "alice",
61
+ recipientRef: "bob",
62
+ amount: Money.parse("USDC", 6, "1000000"),
63
+ expiresAt: now + 600_000,
64
+ allowedRails: ["erc20"],
65
+ metadata: { recipient_destination: recipient },
66
+ });
67
+ const quotes = await client.quoteOptions(intent, [{ payInAdapterId: "erc20", currency: "USDC", exponent: 6 }]);
68
+ const quote = await client.select(quotes, signer.identity());
69
+ expect(isDirect(quote)).toBe(true);
70
+ expect(quote.fees.minor()).toBe("0");
71
+ expect(quote.srcAmount.minor()).toBe("1000000");
72
+ const auth = await client.authorize(intent, quote, signer);
73
+ await client.initiate(intent, quote, auth);
74
+ await client.advance(intent.id, "erc20", { intentId: intent.id, state: State.Settled, providerTxRef: "", onchainTxHash: "", reason: "", settledAt: 0 });
75
+ expect(client.state(intent.id)).toBe(State.Settled);
76
+ // The broadcast call carried the correct token contract and transfer calldata.
77
+ expect(chain.calls).toHaveLength(1);
78
+ expect(chain.calls[0].to).toBe(usdcToken);
79
+ expect(chain.calls[0].data).toBe(transferCalldata(recipient, 1000000n));
80
+ });
81
+ it("only refunds by counter-transfer", async () => {
82
+ const chain = new FakeChain();
83
+ const { leg } = build(chain);
84
+ await leg.collect("intent-1", {
85
+ id: "q",
86
+ intentId: "intent-1",
87
+ payInAdapterId: "erc20",
88
+ payInRail: "erc20",
89
+ payOutAdapterId: "erc20",
90
+ payOutRail: "erc20",
91
+ bridgeId: "passthrough",
92
+ srcAmount: Money.parse("USDC", 6, "1000000"),
93
+ dstAmount: Money.parse("USDC", 6, "1000000"),
94
+ fees: Money.parse("USDC", 6, "0"),
95
+ fxRate: "1",
96
+ expiresAt: 0,
97
+ providerQuoteRef: "",
98
+ latencyEstimateMs: 0,
99
+ }, {
100
+ intentId: "intent-1",
101
+ quoteId: "q",
102
+ signerIdentity: "",
103
+ signedAt: 0,
104
+ signature: new Uint8Array(0),
105
+ }, recipient);
106
+ await expect(leg.refundIn("intent-1", RefundKind.Full, "x")).rejects.toThrow();
107
+ const settlement = await leg.refundIn("intent-1", RefundKind.CounterTransfer, "returning funds");
108
+ expect(settlement.state).toBe(State.Refunded);
109
+ });
110
+ it("fails the intent when the transaction reverts", async () => {
111
+ const chain = new FakeChain();
112
+ chain.status = "reverted";
113
+ const { signer, client, leg } = build(chain);
114
+ const intent = client.createIntent({
115
+ senderRef: "alice",
116
+ recipientRef: "bob",
117
+ amount: Money.parse("USDC", 6, "500000"),
118
+ expiresAt: now + 600_000,
119
+ allowedRails: ["erc20"],
120
+ metadata: { recipient_destination: recipient },
121
+ });
122
+ const quotes = await client.quoteOptions(intent, [{ payInAdapterId: "erc20", currency: "USDC", exponent: 6 }]);
123
+ const quote = await client.select(quotes, signer.identity());
124
+ const auth = await client.authorize(intent, quote, signer);
125
+ await client.initiate(intent, quote, auth);
126
+ // A direct corridor broadcasts on initiate; the host polls the receipt to
127
+ // learn the transaction reverted on chain.
128
+ const event = await leg.settlementEvent(intent.id);
129
+ expect(event.state).toBe(State.Failed);
130
+ });
131
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,212 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { Client } from "../src/client.js";
3
+ import { Money } from "../src/money.js";
4
+ import { State } from "../src/state.js";
5
+ import { isDirect } from "../src/message.js";
6
+ import { UsdcBridge } from "../src/bridge.js";
7
+ import { FakeLeg, FakeRates, FakeVault } from "../src/fake.js";
8
+ import { Ed25519Signer, Ed25519Verifier } from "../src/signing.js";
9
+ import { fromHex } from "../src/crypto.js";
10
+ function counter() {
11
+ let n = 0;
12
+ return () => `id-${String(++n).padStart(3, "0")}`;
13
+ }
14
+ function signerVerifier(identity) {
15
+ const seed = fromHex("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff");
16
+ const signer = Ed25519Signer.fromSeed(identity, seed);
17
+ return { signer, verifier: new Ed25519Verifier(new Map([[identity, signer.publicKey]])) };
18
+ }
19
+ const now = 1_700_000_000_000;
20
+ describe("corridor lifecycle", () => {
21
+ // A direct corridor: one provider serves both legs in one currency, so the
22
+ // pay-in collects straight to the recipient and settlement is single-phase.
23
+ it("settles a direct corridor in one phase and folds a Merkle head", async () => {
24
+ const { signer, verifier } = signerVerifier("alice");
25
+ const wallet = new FakeLeg("wallet", "wallet", "USD", counter());
26
+ const client = new Client({
27
+ payIn: [wallet],
28
+ payOut: [wallet],
29
+ verifier,
30
+ clock: () => now,
31
+ idGen: counter(),
32
+ });
33
+ let settled = false;
34
+ client.subscribe((e) => {
35
+ if (e.state === State.Settled)
36
+ settled = true;
37
+ });
38
+ const intent = client.createIntent({
39
+ senderRef: "alice",
40
+ recipientRef: "bob",
41
+ amount: Money.parse("USD", 2, "1500"),
42
+ memo: "lunch",
43
+ expiresAt: now + 600_000,
44
+ allowedRails: ["wallet"],
45
+ });
46
+ expect(client.state(intent.id)).toBe(State.Draft);
47
+ const funding = [{ payInAdapterId: "wallet", currency: "USD", exponent: 2 }];
48
+ const quotes = await client.quoteOptions(intent, funding);
49
+ const quote = await client.select(quotes, signer.identity());
50
+ expect(isDirect(quote)).toBe(true);
51
+ expect(quote.srcAmount.minor()).toBe("1500");
52
+ expect(quote.dstAmount.minor()).toBe("1500");
53
+ const auth = await client.authorize(intent, quote, signer);
54
+ expect(client.state(intent.id)).toBe(State.Authorized);
55
+ await client.initiate(intent, quote, auth);
56
+ // The pay-in provider confirms out of band; a direct corridor then settles.
57
+ await client.advance(intent.id, "wallet", { intentId: intent.id, state: State.Settled, providerTxRef: "", onchainTxHash: "", reason: "", settledAt: 0 });
58
+ expect(client.state(intent.id)).toBe(State.Settled);
59
+ expect(settled).toBe(true);
60
+ const states = client.history(intent.id).map((e) => e.state);
61
+ expect(states).toEqual([State.Draft, State.Quoted, State.Authorized, State.Submitted, State.Settled]);
62
+ expect(client.head(intent.id)).toBeDefined();
63
+ });
64
+ // A bridged corridor: the payer funds in USD by card, a USDC bridge converts to
65
+ // KES, and an M-Pesa leg delivers to the recipient.
66
+ it("settles a bridged card-USD to USDC to mpesa-KES corridor end to end", async () => {
67
+ const { signer, verifier } = signerVerifier("alice");
68
+ const card = new FakeLeg("card", "card", "USD", counter());
69
+ const mpesa = new FakeLeg("mpesa", "mpesa", "KES", counter());
70
+ const bridge = new UsdcBridge(new FakeRates({ "USD:KES": "129" }), new FakeVault(), 100n);
71
+ const client = new Client({
72
+ payIn: [card],
73
+ payOut: [mpesa],
74
+ bridges: [bridge],
75
+ verifier,
76
+ clock: () => now,
77
+ idGen: counter(),
78
+ escrowRef: "escrow",
79
+ });
80
+ // The recipient requests 1290 KES.
81
+ const intent = client.createIntent({
82
+ senderRef: "alice",
83
+ recipientRef: "bob",
84
+ amount: Money.parse("KES", 0, "1290"),
85
+ expiresAt: now + 600_000,
86
+ allowedRails: ["mpesa"],
87
+ });
88
+ const quotes = await client.quoteOptions(intent, [{ payInAdapterId: "card", currency: "USD", exponent: 2 }]);
89
+ const quote = await client.select(quotes, signer.identity());
90
+ expect(isDirect(quote)).toBe(false);
91
+ // 1290 KES at 129 KES/USD is 10.00 USD, plus a 1% bridge fee = 10.10 USD.
92
+ expect(quote.srcAmount.minor()).toBe("1010");
93
+ expect(quote.srcAmount.currency).toBe("USD");
94
+ expect(quote.dstAmount.minor()).toBe("1290");
95
+ expect(quote.dstAmount.currency).toBe("KES");
96
+ const auth = await client.authorize(intent, quote, signer);
97
+ await client.initiate(intent, quote, auth);
98
+ const confirm = { intentId: intent.id, state: State.Settled, providerTxRef: "", onchainTxHash: "", reason: "", settledAt: 0 };
99
+ // The pay-in webhook drives collecting -> held -> disbursing; the corridor now
100
+ // waits on the pay-out.
101
+ const held = await client.advance(intent.id, "card", confirm);
102
+ expect(held.state).toBe(State.Disbursing);
103
+ // The pay-out callback settles it.
104
+ const { settlement } = await client.advance(intent.id, "mpesa", confirm);
105
+ expect(settlement.state).toBe(State.Settled);
106
+ const states = client.history(intent.id).map((e) => e.state);
107
+ expect(states).toEqual([
108
+ State.Draft,
109
+ State.Quoted,
110
+ State.Authorized,
111
+ State.Collecting,
112
+ State.Held,
113
+ State.Disbursing,
114
+ State.Settled,
115
+ ]);
116
+ });
117
+ // When the pay-out cannot complete, the corridor unwinds from escrow: held ->
118
+ // refunding -> refunded, so the payer is made whole.
119
+ it("unwinds a bridged corridor to refunded when the pay-out fails", async () => {
120
+ const { signer, verifier } = signerVerifier("alice");
121
+ const card = new FakeLeg("card", "card", "USD", counter());
122
+ const mpesa = new FakeLeg("mpesa", "mpesa", "KES", counter());
123
+ mpesa.failPayout = true;
124
+ const bridge = new UsdcBridge(new FakeRates({ "USD:KES": "129" }), new FakeVault(), 0n);
125
+ const client = new Client({
126
+ payIn: [card],
127
+ payOut: [mpesa],
128
+ bridges: [bridge],
129
+ verifier,
130
+ clock: () => now,
131
+ idGen: counter(),
132
+ escrowRef: "escrow",
133
+ });
134
+ const intent = client.createIntent({
135
+ senderRef: "alice",
136
+ recipientRef: "bob",
137
+ amount: Money.parse("KES", 0, "1290"),
138
+ expiresAt: now + 600_000,
139
+ allowedRails: ["mpesa"],
140
+ });
141
+ const quotes = await client.quoteOptions(intent, [{ payInAdapterId: "card", currency: "USD", exponent: 2 }]);
142
+ const quote = await client.select(quotes, signer.identity());
143
+ const auth = await client.authorize(intent, quote, signer);
144
+ await client.initiate(intent, quote, auth);
145
+ // The pay-in confirms, but the pay-out leg fails; the corridor unwinds from
146
+ // escrow rather than erroring.
147
+ const { settlement } = await client.advance(intent.id, "card", { intentId: intent.id, state: State.Settled, providerTxRef: "", onchainTxHash: "", reason: "", settledAt: 0 });
148
+ expect(settlement.state).toBe(State.Refunded);
149
+ expect(client.state(intent.id)).toBe(State.Refunded);
150
+ // The escrow-unwind path must have been traversed.
151
+ const states = client.history(intent.id).map((e) => e.state);
152
+ expect(states).toContain(State.Held);
153
+ expect(states).toContain(State.Refunding);
154
+ expect(states[states.length - 1]).toBe(State.Refunded);
155
+ });
156
+ // A re-delivered provider webhook must not advance the corridor twice.
157
+ it("advances idempotently so a duplicate webhook settles once", async () => {
158
+ const { signer, verifier } = signerVerifier("alice");
159
+ const card = new FakeLeg("card", "card", "USD", counter());
160
+ const mpesa = new FakeLeg("mpesa", "mpesa", "KES", counter());
161
+ const bridge = new UsdcBridge(new FakeRates({ "USD:KES": "129" }), new FakeVault(), 0n);
162
+ const client = new Client({
163
+ payIn: [card],
164
+ payOut: [mpesa],
165
+ bridges: [bridge],
166
+ verifier,
167
+ clock: () => now,
168
+ idGen: counter(),
169
+ escrowRef: "escrow",
170
+ });
171
+ const intent = client.createIntent({
172
+ senderRef: "alice",
173
+ recipientRef: "bob",
174
+ amount: Money.parse("KES", 0, "1290"),
175
+ expiresAt: now + 600_000,
176
+ allowedRails: ["mpesa"],
177
+ });
178
+ const quotes = await client.quoteOptions(intent, [{ payInAdapterId: "card", currency: "USD", exponent: 2 }]);
179
+ const quote = await client.select(quotes, signer.identity());
180
+ const auth = await client.authorize(intent, quote, signer);
181
+ await client.initiate(intent, quote, auth);
182
+ const confirm = { intentId: intent.id, state: State.Settled, providerTxRef: "", onchainTxHash: "", reason: "", settledAt: 0 };
183
+ // The pay-in webhook is delivered twice; the second must be a no-op.
184
+ await client.advance(intent.id, "card", confirm);
185
+ const duplicate = await client.advance(intent.id, "card", confirm);
186
+ expect(duplicate.state).toBe(State.Disbursing);
187
+ // The pay-out callback settles it once.
188
+ await client.advance(intent.id, "mpesa", confirm);
189
+ expect(client.state(intent.id)).toBe(State.Settled);
190
+ const disbursing = client.history(intent.id).filter((e) => e.state === State.Disbursing).length;
191
+ expect(disbursing).toBe(1);
192
+ });
193
+ it("expires idempotently", () => {
194
+ const { verifier } = signerVerifier("alice");
195
+ const client = new Client({
196
+ payIn: [new FakeLeg("wallet", "wallet", "USD", counter())],
197
+ verifier,
198
+ clock: () => 1,
199
+ idGen: counter(),
200
+ });
201
+ const intent = client.createIntent({
202
+ senderRef: "a",
203
+ recipientRef: "b",
204
+ amount: Money.parse("USD", 2, "100"),
205
+ expiresAt: 0,
206
+ allowedRails: ["wallet"],
207
+ });
208
+ client.expire(intent.id);
209
+ expect(client.state(intent.id)).toBe(State.Expired);
210
+ expect(() => client.expire(intent.id)).not.toThrow();
211
+ });
212
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,180 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { Client } from "../src/client.js";
3
+ import { Money } from "../src/money.js";
4
+ import { State } from "../src/state.js";
5
+ import { isDirect } from "../src/message.js";
6
+ import { RefundKind } from "../src/adapter.js";
7
+ import { UsdcBridge } from "../src/bridge.js";
8
+ import { FakeLeg, FakeRates, FakeVault } from "../src/fake.js";
9
+ import { Ed25519Signer, Ed25519Verifier } from "../src/signing.js";
10
+ import { fromHex } from "../src/crypto.js";
11
+ import { MpesaLeg, ErrUnknownCheckout, normalizePhone, } from "../src/adapters/mpesa.js";
12
+ function counter(prefix) {
13
+ let n = 0;
14
+ return () => `${prefix}_${String(++n).padStart(3, "0")}`;
15
+ }
16
+ function signerVerifier(identity) {
17
+ const seed = fromHex("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff");
18
+ const signer = Ed25519Signer.fromSeed(identity, seed);
19
+ return { signer, verifier: new Ed25519Verifier(new Map([[identity, signer.publicKey]])) };
20
+ }
21
+ // FakeDaraja is an in-memory stand-in for Daraja so the leg runs end to end with
22
+ // no network and no credentials.
23
+ class FakeDaraja {
24
+ lastPush;
25
+ lastB2C;
26
+ ids;
27
+ constructor(ids) {
28
+ this.ids = ids;
29
+ }
30
+ async stkPush(params) {
31
+ this.lastPush = params;
32
+ return { merchantRequestId: this.ids(), checkoutRequestId: this.ids(), responseCode: "0" };
33
+ }
34
+ async b2cPayment(params) {
35
+ this.lastB2C = params;
36
+ return { conversationId: this.ids(), responseCode: "0" };
37
+ }
38
+ }
39
+ function buildLeg(api) {
40
+ return new MpesaLeg({
41
+ api,
42
+ callbackURL: "https://host.example/paylink/webhook/mpesa",
43
+ ids: counter("mp"),
44
+ });
45
+ }
46
+ function kes(whole) {
47
+ return Money.parse("KES", 0, whole);
48
+ }
49
+ function quote(fields) {
50
+ return {
51
+ id: "q",
52
+ intentId: "intent-1",
53
+ payInAdapterId: "mpesa",
54
+ payInRail: "mpesa",
55
+ payOutAdapterId: "mpesa",
56
+ payOutRail: "mpesa",
57
+ bridgeId: "passthrough",
58
+ srcAmount: kes("0"),
59
+ dstAmount: kes("0"),
60
+ fees: kes("0"),
61
+ fxRate: "1",
62
+ expiresAt: 0,
63
+ providerQuoteRef: "",
64
+ latencyEstimateMs: 0,
65
+ ...fields,
66
+ };
67
+ }
68
+ const emptyAuth = {
69
+ intentId: "intent-1",
70
+ quoteId: "q",
71
+ signerIdentity: "",
72
+ signedAt: 0,
73
+ signature: new Uint8Array(0),
74
+ };
75
+ function successCallback(checkoutId, receipt) {
76
+ const json = `{"Body":{"stkCallback":{"MerchantRequestID":"m","CheckoutRequestID":${JSON.stringify(checkoutId)},"ResultCode":0,"ResultDesc":"Success","CallbackMetadata":{"Item":[{"Name":"Amount","Value":1500},{"Name":"MpesaReceiptNumber","Value":${JSON.stringify(receipt)}},{"Name":"PhoneNumber","Value":254711000111}]}}}}`;
77
+ return new TextEncoder().encode(json);
78
+ }
79
+ function failureCallback(checkoutId) {
80
+ const json = `{"Body":{"stkCallback":{"MerchantRequestID":"m","CheckoutRequestID":${JSON.stringify(checkoutId)},"ResultCode":1032,"ResultDesc":"Request cancelled by user"}}}`;
81
+ return new TextEncoder().encode(json);
82
+ }
83
+ describe("mpesa phone normalization", () => {
84
+ it("trims the common inputs to the 2547XXXXXXXX form", () => {
85
+ expect(normalizePhone("0711000111")).toBe("254711000111");
86
+ expect(normalizePhone("+254711000111")).toBe("254711000111");
87
+ expect(normalizePhone("254711000111")).toBe("254711000111");
88
+ });
89
+ });
90
+ describe("mpesa whole-shilling guard", () => {
91
+ it("refuses a fractional destination amount", async () => {
92
+ const leg = buildLeg(new FakeDaraja(counter("tx")));
93
+ const cents = Money.parse("KES", 2, "129000");
94
+ await expect(leg.disburse("intent-2", quote({ dstAmount: cents }), "0711000111")).rejects.toThrow();
95
+ });
96
+ });
97
+ describe("mpesa unknown checkout", () => {
98
+ it("rejects a callback whose checkout id it never started", () => {
99
+ const leg = buildLeg(new FakeDaraja(counter("tx")));
100
+ expect(() => leg.parseWebhook(successCallback("never-started", "ABC"), {})).toThrow(ErrUnknownCheckout);
101
+ });
102
+ });
103
+ describe("mpesa pay-out", () => {
104
+ it("delivers a whole-shilling payout to the normalized phone", async () => {
105
+ const api = new FakeDaraja(counter("tx"));
106
+ const leg = buildLeg(api);
107
+ const result = await leg.disburse("intent-1", quote({ dstAmount: kes("1290") }), "0711000111");
108
+ expect(result.providerRef).not.toBe("");
109
+ expect(api.lastB2C.phone).toBe("254711000111");
110
+ expect(api.lastB2C.amount).toBe(1290);
111
+ });
112
+ });
113
+ describe("mpesa bridged corridor", () => {
114
+ const now = 1_700_000_000_000;
115
+ it("pays out to the recipient's phone after a card-USD to USDC to KES conversion", async () => {
116
+ const { signer, verifier } = signerVerifier("alice");
117
+ const api = new FakeDaraja(counter("tx"));
118
+ const leg = buildLeg(api);
119
+ const card = new FakeLeg("card", "card", "USD", counter("card"));
120
+ const bridge = new UsdcBridge(new FakeRates({ "USD:KES": "129" }), new FakeVault(), 100n);
121
+ const client = new Client({
122
+ payIn: [card],
123
+ payOut: [leg],
124
+ bridges: [bridge],
125
+ verifier,
126
+ clock: () => now,
127
+ idGen: counter("intent"),
128
+ escrowRef: "escrow",
129
+ });
130
+ const intent = client.createIntent({
131
+ senderRef: "alice",
132
+ recipientRef: "bob",
133
+ amount: kes("1290"),
134
+ expiresAt: now + 600_000,
135
+ allowedRails: ["mpesa"],
136
+ metadata: { recipient_destination: "0711000111" },
137
+ });
138
+ const quotes = await client.quoteOptions(intent, [{ payInAdapterId: "card", currency: "USD", exponent: 2 }]);
139
+ const chosen = await client.select(quotes, signer.identity());
140
+ expect(isDirect(chosen)).toBe(false);
141
+ const auth = await client.authorize(intent, chosen, signer);
142
+ await client.initiate(intent, chosen, auth);
143
+ const confirm = { intentId: intent.id, state: State.Settled, providerTxRef: "", onchainTxHash: "", reason: "", settledAt: 0 };
144
+ await client.advance(intent.id, "card", confirm);
145
+ const { settlement } = await client.advance(intent.id, "mpesa", confirm);
146
+ expect(settlement.state).toBe(State.Settled);
147
+ expect(client.state(intent.id)).toBe(State.Settled);
148
+ // The pay-out delivered the whole 1290 KES to the recipient's normalized phone.
149
+ expect(api.lastB2C.phone).toBe("254711000111");
150
+ expect(api.lastB2C.amount).toBe(1290);
151
+ });
152
+ });
153
+ describe("mpesa pay-in", () => {
154
+ it("pushes, settles from the callback, and refunds by counter-transfer", async () => {
155
+ const api = new FakeDaraja(counter("tx"));
156
+ const leg = buildLeg(api);
157
+ const q = quote({ srcAmount: kes("1500"), fees: kes("0") });
158
+ const collected = await leg.collect("intent-1", q, emptyAuth, "0711000111");
159
+ expect(api.lastPush.accountReference).toBe("intent-1");
160
+ expect(api.lastPush.payerPhone).toBe("254711000111");
161
+ const events = leg.parseWebhook(successCallback(collected.providerRef, "QGR7XYZ123"), {});
162
+ expect(events).toHaveLength(1);
163
+ expect(events[0].state).toBe(State.Settled);
164
+ expect(events[0].intentId).toBe("intent-1");
165
+ expect(events[0].providerTxRef).toBe("QGR7XYZ123");
166
+ // A refund is a business-to-customer counter-transfer back to the payer.
167
+ await leg.refundIn("intent-1", RefundKind.CounterTransfer, "returning funds");
168
+ expect(api.lastB2C.phone).toBe("254711000111");
169
+ expect(api.lastB2C.amount).toBe(1500);
170
+ });
171
+ it("maps a cancelled push to a failed event", async () => {
172
+ const api = new FakeDaraja(counter("tx"));
173
+ const leg = buildLeg(api);
174
+ const q = quote({ srcAmount: kes("1500"), fees: kes("0") });
175
+ const collected = await leg.collect("intent-1", q, emptyAuth, "0711000111");
176
+ const events = leg.parseWebhook(failureCallback(collected.providerRef), {});
177
+ expect(events).toHaveLength(1);
178
+ expect(events[0].state).toBe(State.Failed);
179
+ });
180
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { Money } from "../src/money.js";
3
+ import { WireKind } from "../src/wire.js";
4
+ import { encodePayload, decodePayload, isPayload, NotPayloadError } from "../src/payload.js";
5
+ const intent = {
6
+ id: "intent-1",
7
+ senderRef: "alice",
8
+ recipientRef: "bob",
9
+ amount: Money.parse("USD", 2, "1500"),
10
+ memo: "",
11
+ expiresAt: 0,
12
+ allowedRails: ["card"],
13
+ metadata: {},
14
+ };
15
+ describe("pact payload wrapper", () => {
16
+ it("round-trips a message through the self-identifying header", () => {
17
+ const payload = encodePayload({ kind: WireKind.Intent, message: intent });
18
+ expect(isPayload(payload)).toBe(true);
19
+ const decoded = decodePayload(payload);
20
+ expect(decoded.kind).toBe(WireKind.Intent);
21
+ expect(decoded.message.id).toBe(intent.id);
22
+ });
23
+ it("rejects foreign bytes", () => {
24
+ expect(isPayload(new TextEncoder().encode("hello there"))).toBe(false);
25
+ expect(() => decodePayload(new TextEncoder().encode("hello there"))).toThrow(NotPayloadError);
26
+ });
27
+ it("rejects an unknown framing version", () => {
28
+ const payload = encodePayload({ kind: WireKind.Intent, message: intent });
29
+ payload[4] = 0xff; // corrupt the version byte after the 4-byte magic
30
+ expect(() => decodePayload(payload)).toThrow();
31
+ });
32
+ });
@@ -0,0 +1 @@
1
+ export {};