@myzonerocks/pact 0.1.3 → 0.1.4
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/dist/src/adapter.d.ts +1 -1
- package/dist/src/adapters/erc20.d.ts +13 -3
- package/dist/src/adapters/erc20.js +93 -49
- package/dist/src/adapters/http.d.ts +2 -0
- package/dist/src/adapters/http.js +12 -0
- package/dist/src/adapters/mpesa.d.ts +9 -2
- package/dist/src/adapters/mpesa.js +82 -12
- package/dist/src/adapters/paypal.d.ts +5 -1
- package/dist/src/adapters/paypal.js +76 -25
- 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 +8 -2
- package/dist/src/client.js +181 -20
- 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/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 +118 -51
- package/src/adapters/http.ts +14 -0
- package/src/adapters/mpesa.ts +102 -13
- package/src/adapters/paypal.ts +102 -24
- package/src/adapters/stripe.ts +16 -13
- package/src/bridge.ts +12 -5
- package/src/canonical.ts +5 -0
- package/src/client.ts +194 -22
- 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/wire.ts +20 -3
- package/src/fake.ts +0 -96
package/dist/src/router.js
CHANGED
|
@@ -17,31 +17,52 @@ export function isExpired(deadline, now, skew) {
|
|
|
17
17
|
return false;
|
|
18
18
|
return deadline + skew <= now;
|
|
19
19
|
}
|
|
20
|
+
// maxQuotes and maxFundingOptions bound the lists the router ranks and the client
|
|
21
|
+
// quotes over, so a caller (or an adapter-supplied list forwarded through one)
|
|
22
|
+
// cannot force unbounded work. The limits are generous next to any real corridor.
|
|
23
|
+
export const maxQuotes = 256;
|
|
24
|
+
export const maxFundingOptions = 64;
|
|
20
25
|
// route selects one quote deterministically. It first discards quotes that are
|
|
21
26
|
// expired against now or that exceed the identity's KYC limit, then ranks the
|
|
22
27
|
// survivors by the policy. Ties break by pay-in adapter, then pay-out adapter,
|
|
23
28
|
// then quote id, so the choice is total and stable and never depends on input
|
|
24
29
|
// order or randomness.
|
|
25
30
|
export function route(quotes, policy, kyc, now, skew) {
|
|
31
|
+
if (quotes.length > maxQuotes) {
|
|
32
|
+
throw new NoQuoteError(`quote set of ${quotes.length} exceeds the maximum of ${maxQuotes}`);
|
|
33
|
+
}
|
|
26
34
|
const eligible = quotes.filter((q) => !isExpired(q.expiresAt, now, skew) && withinLimits(kyc, q));
|
|
27
35
|
if (eligible.length === 0) {
|
|
28
36
|
throw new NoQuoteError("all quotes expired or over limit");
|
|
29
37
|
}
|
|
38
|
+
// Ranking by cost compares source amounts, which is meaningful only in one
|
|
39
|
+
// currency: the router holds no rate to weigh a dollar against a shilling. A
|
|
40
|
+
// mixed-currency set would otherwise fall through to the id tie-break and
|
|
41
|
+
// quietly crown a costlier quote, so it is refused rather than mis-ranked.
|
|
42
|
+
if (policy.kind === PolicyKind.Cheapest && spansSourceCurrencies(eligible)) {
|
|
43
|
+
throw new NoQuoteError("cannot rank by cost across source currencies without a common rate");
|
|
44
|
+
}
|
|
30
45
|
const rank = rankFn(policy);
|
|
31
46
|
eligible.sort((a, b) => {
|
|
32
47
|
const primary = rank(a, b);
|
|
33
48
|
if (primary !== 0)
|
|
34
49
|
return primary;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
return
|
|
41
|
-
return
|
|
50
|
+
const byPayIn = compareUtf8(a.payInAdapterId, b.payInAdapterId);
|
|
51
|
+
if (byPayIn !== 0)
|
|
52
|
+
return byPayIn;
|
|
53
|
+
const byPayOut = compareUtf8(a.payOutAdapterId, b.payOutAdapterId);
|
|
54
|
+
if (byPayOut !== 0)
|
|
55
|
+
return byPayOut;
|
|
56
|
+
return compareUtf8(a.id, b.id);
|
|
42
57
|
});
|
|
43
58
|
return eligible[0];
|
|
44
59
|
}
|
|
60
|
+
// spansSourceCurrencies reports whether the quotes carry more than one distinct
|
|
61
|
+
// source currency, the case a cost ranking cannot resolve without a rate.
|
|
62
|
+
function spansSourceCurrencies(quotes) {
|
|
63
|
+
const first = quotes[0].srcAmount.currency;
|
|
64
|
+
return quotes.some((q) => q.srcAmount.currency !== first);
|
|
65
|
+
}
|
|
45
66
|
function rankFn(policy) {
|
|
46
67
|
switch (policy.kind) {
|
|
47
68
|
case PolicyKind.Fastest:
|
|
@@ -71,6 +92,23 @@ function railRank(preferred) {
|
|
|
71
92
|
function cmpNumber(a, b) {
|
|
72
93
|
return a < b ? -1 : a > b ? 1 : 0;
|
|
73
94
|
}
|
|
95
|
+
// compareUtf8 orders two strings by their UTF-8 byte encoding, so the tie-break
|
|
96
|
+
// agrees with the Go SDK. A plain string comparison orders by UTF-16 code unit,
|
|
97
|
+
// which disagrees for characters above U+FFFF and would break determinism across
|
|
98
|
+
// languages on a non-ASCII adapter id.
|
|
99
|
+
const utf8Encoder = new TextEncoder();
|
|
100
|
+
function compareUtf8(a, b) {
|
|
101
|
+
if (a === b)
|
|
102
|
+
return 0;
|
|
103
|
+
const ab = utf8Encoder.encode(a);
|
|
104
|
+
const bb = utf8Encoder.encode(b);
|
|
105
|
+
const n = Math.min(ab.length, bb.length);
|
|
106
|
+
for (let i = 0; i < n; i++) {
|
|
107
|
+
if (ab[i] !== bb[i])
|
|
108
|
+
return ab[i] < bb[i] ? -1 : 1;
|
|
109
|
+
}
|
|
110
|
+
return ab.length < bb.length ? -1 : ab.length > bb.length ? 1 : 0;
|
|
111
|
+
}
|
|
74
112
|
// cmpMoney orders two quotes by source amount. Amounts in different currencies
|
|
75
113
|
// are treated as incomparable and reported equal, deferring to the tie-break so
|
|
76
114
|
// the router never throws on a mixed-currency quote set.
|
package/dist/src/wire.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Money } from "./money.js";
|
|
2
|
+
import { stateName } from "./state.js";
|
|
2
3
|
import { CanonicalWriter } from "./canonical.js";
|
|
3
4
|
// The wire codec serializes messages for transport. It is distinct from the
|
|
4
5
|
// canonical signing preimage: signing binds a fixed subset of fields under a
|
|
@@ -126,7 +127,14 @@ class WireReader {
|
|
|
126
127
|
}
|
|
127
128
|
const view = new DataView(this.buf.buffer, this.buf.byteOffset + this.pos, 8);
|
|
128
129
|
this.pos += 8;
|
|
129
|
-
|
|
130
|
+
const value = view.getBigUint64(0, false);
|
|
131
|
+
// A value above 2^53 cannot be held exactly in a JS number, so reject it
|
|
132
|
+
// rather than decode a Go or Dart value into a silently different one. Every
|
|
133
|
+
// real field here — millisecond timestamps, ordinals, latency — fits easily.
|
|
134
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
135
|
+
throw new Error("pact: u64 field exceeds the exact-integer range");
|
|
136
|
+
}
|
|
137
|
+
return Number(value);
|
|
130
138
|
}
|
|
131
139
|
money() {
|
|
132
140
|
const minor = this.str();
|
|
@@ -204,11 +212,20 @@ export function decodeAuthorization(b) {
|
|
|
204
212
|
r.requireDone();
|
|
205
213
|
return auth;
|
|
206
214
|
}
|
|
215
|
+
// stateFromOrdinal rejects an ordinal outside the known states rather than pass
|
|
216
|
+
// an out-of-range number through as a State, so a corrupt wire byte cannot decode
|
|
217
|
+
// to a nonexistent state.
|
|
218
|
+
function stateFromOrdinal(n) {
|
|
219
|
+
if (!(n in stateName)) {
|
|
220
|
+
throw new Error(`pact: unknown settlement state ordinal ${n}`);
|
|
221
|
+
}
|
|
222
|
+
return n;
|
|
223
|
+
}
|
|
207
224
|
export function decodeSettlement(b) {
|
|
208
225
|
const r = new WireReader(b);
|
|
209
226
|
const settlement = {
|
|
210
227
|
intentId: r.str(),
|
|
211
|
-
state: r.u64(),
|
|
228
|
+
state: stateFromOrdinal(r.u64()),
|
|
212
229
|
adapterId: r.str(),
|
|
213
230
|
providerTxRef: r.str(),
|
|
214
231
|
onchainTxHash: r.str(),
|
package/dist/test/erc20.test.js
CHANGED
|
@@ -16,22 +16,39 @@ function signerVerifier(identity) {
|
|
|
16
16
|
const signer = Ed25519Signer.fromSeed(identity, seed);
|
|
17
17
|
return { signer, verifier: new Ed25519Verifier(new Map([[identity, signer.publicKey]])) };
|
|
18
18
|
}
|
|
19
|
+
const usdcToken = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
|
20
|
+
const recipient = "0x00000000000000000000000000000000000000aa";
|
|
19
21
|
// 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.
|
|
22
|
+
// test controls, so the leg runs with no chain and no key. The receipt defaults
|
|
23
|
+
// to a deep, matching transfer so a settlement confirms; a test overrides fields
|
|
24
|
+
// to model a revert, a shallow success, or a mismatched transfer.
|
|
21
25
|
class FakeChain {
|
|
22
26
|
calls = [];
|
|
23
27
|
ids = counter();
|
|
24
28
|
status = "success";
|
|
29
|
+
head = 100;
|
|
30
|
+
minedBlock = 1;
|
|
31
|
+
token = usdcToken;
|
|
32
|
+
to = recipient;
|
|
33
|
+
amount = 1000000n;
|
|
25
34
|
async send(call) {
|
|
26
35
|
this.calls.push(call);
|
|
27
36
|
return this.ids();
|
|
28
37
|
}
|
|
29
38
|
async receipt(_txHash) {
|
|
30
|
-
return {
|
|
39
|
+
return {
|
|
40
|
+
status: this.status,
|
|
41
|
+
blockNumber: this.minedBlock,
|
|
42
|
+
blockTimestampMs: 1_700_000_005_000,
|
|
43
|
+
tokenAddress: this.token,
|
|
44
|
+
to: this.to,
|
|
45
|
+
amount: this.amount,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
async blockNumber() {
|
|
49
|
+
return this.head;
|
|
31
50
|
}
|
|
32
51
|
}
|
|
33
|
-
const usdcToken = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
|
34
|
-
const recipient = "0x00000000000000000000000000000000000000aa";
|
|
35
52
|
describe("erc20 transfer calldata", () => {
|
|
36
53
|
it("encodes selector, padded address, and 32-byte amount", () => {
|
|
37
54
|
// 1_000_000 minor units of a 6-decimal token is one whole token: 0xf4240.
|
|
@@ -49,7 +66,7 @@ describe("erc20 direct corridor", () => {
|
|
|
49
66
|
const now = 1_700_000_000_000;
|
|
50
67
|
function build(chain) {
|
|
51
68
|
const { signer, verifier } = signerVerifier("alice");
|
|
52
|
-
const leg = new Erc20Leg({ token: usdcToken, currency: "USDC", chain, ids: counter() });
|
|
69
|
+
const leg = new Erc20Leg({ token: usdcToken, currency: "USDC", chain, ids: counter(), decimals: 6 });
|
|
53
70
|
const client = new Client({ payIn: [leg], payOut: [leg], verifier, clock: () => now, idGen: counter() });
|
|
54
71
|
return { signer, leg, client };
|
|
55
72
|
}
|
|
@@ -78,34 +95,12 @@ describe("erc20 direct corridor", () => {
|
|
|
78
95
|
expect(chain.calls[0].to).toBe(usdcToken);
|
|
79
96
|
expect(chain.calls[0].data).toBe(transferCalldata(recipient, 1000000n));
|
|
80
97
|
});
|
|
81
|
-
it("
|
|
98
|
+
it("advertises no refund and refuses to refund or reverse", async () => {
|
|
82
99
|
const chain = new FakeChain();
|
|
83
100
|
const { leg } = build(chain);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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);
|
|
101
|
+
expect(leg.payInCapabilities().refunds).toBe(RefundKind.None);
|
|
102
|
+
await expect(leg.refundIn("intent-1", RefundKind.CounterTransfer, Money.parse("USDC", 6, "1000000"), "x")).rejects.toThrow();
|
|
103
|
+
await expect(leg.reverseOut("intent-1", "x")).rejects.toThrow();
|
|
109
104
|
});
|
|
110
105
|
it("fails the intent when the transaction reverts", async () => {
|
|
111
106
|
const chain = new FakeChain();
|
|
@@ -129,3 +124,72 @@ describe("erc20 direct corridor", () => {
|
|
|
129
124
|
expect(event.state).toBe(State.Failed);
|
|
130
125
|
});
|
|
131
126
|
});
|
|
127
|
+
describe("erc20 on-chain confirmation", () => {
|
|
128
|
+
it("settles a deep, matching transfer", async () => {
|
|
129
|
+
const chain = new FakeChain();
|
|
130
|
+
chain.head = 100;
|
|
131
|
+
chain.minedBlock = 90;
|
|
132
|
+
const leg = new Erc20Leg({ token: usdcToken, currency: "USDC", chain, ids: counter(), decimals: 6, minConfirmations: 3 });
|
|
133
|
+
const quote = {
|
|
134
|
+
id: "q", intentId: "intent-1", payInAdapterId: "erc20", payInRail: "erc20",
|
|
135
|
+
payOutAdapterId: "erc20", payOutRail: "erc20", bridgeId: "passthrough",
|
|
136
|
+
srcAmount: Money.parse("USDC", 6, "1000000"), dstAmount: Money.parse("USDC", 6, "1000000"),
|
|
137
|
+
fees: Money.parse("USDC", 6, "0"), fxRate: "1", expiresAt: 0, providerQuoteRef: "", latencyEstimateMs: 0,
|
|
138
|
+
};
|
|
139
|
+
await leg.disburse("intent-1", quote, recipient);
|
|
140
|
+
const event = await leg.settlementEvent("intent-1");
|
|
141
|
+
expect(event.state).toBe(State.Settled);
|
|
142
|
+
});
|
|
143
|
+
it("stays submitted until the transfer is deep enough", async () => {
|
|
144
|
+
const chain = new FakeChain();
|
|
145
|
+
chain.head = 91;
|
|
146
|
+
chain.minedBlock = 90; // only two blocks deep, below the three required
|
|
147
|
+
const leg = new Erc20Leg({ token: usdcToken, currency: "USDC", chain, ids: counter(), decimals: 6, minConfirmations: 3 });
|
|
148
|
+
const quote = {
|
|
149
|
+
id: "q", intentId: "intent-1", payInAdapterId: "erc20", payInRail: "erc20",
|
|
150
|
+
payOutAdapterId: "erc20", payOutRail: "erc20", bridgeId: "passthrough",
|
|
151
|
+
srcAmount: Money.parse("USDC", 6, "1000000"), dstAmount: Money.parse("USDC", 6, "1000000"),
|
|
152
|
+
fees: Money.parse("USDC", 6, "0"), fxRate: "1", expiresAt: 0, providerQuoteRef: "", latencyEstimateMs: 0,
|
|
153
|
+
};
|
|
154
|
+
await leg.disburse("intent-1", quote, recipient);
|
|
155
|
+
const event = await leg.settlementEvent("intent-1");
|
|
156
|
+
expect(event.state).toBe(State.Submitted);
|
|
157
|
+
});
|
|
158
|
+
it("does not settle a transfer to the wrong recipient", async () => {
|
|
159
|
+
const chain = new FakeChain();
|
|
160
|
+
chain.head = 100;
|
|
161
|
+
chain.minedBlock = 90;
|
|
162
|
+
chain.to = "0x00000000000000000000000000000000000000bb";
|
|
163
|
+
const leg = new Erc20Leg({ token: usdcToken, currency: "USDC", chain, ids: counter(), decimals: 6, minConfirmations: 3 });
|
|
164
|
+
const quote = {
|
|
165
|
+
id: "q", intentId: "intent-1", payInAdapterId: "erc20", payInRail: "erc20",
|
|
166
|
+
payOutAdapterId: "erc20", payOutRail: "erc20", bridgeId: "passthrough",
|
|
167
|
+
srcAmount: Money.parse("USDC", 6, "1000000"), dstAmount: Money.parse("USDC", 6, "1000000"),
|
|
168
|
+
fees: Money.parse("USDC", 6, "0"), fxRate: "1", expiresAt: 0, providerQuoteRef: "", latencyEstimateMs: 0,
|
|
169
|
+
};
|
|
170
|
+
await leg.disburse("intent-1", quote, recipient);
|
|
171
|
+
const event = await leg.settlementEvent("intent-1");
|
|
172
|
+
expect(event.state).toBe(State.Failed);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
describe("erc20 token scale", () => {
|
|
176
|
+
const wrongScaleQuote = {
|
|
177
|
+
id: "q", intentId: "intent-1", payInAdapterId: "erc20", payInRail: "erc20",
|
|
178
|
+
payOutAdapterId: "erc20", payOutRail: "erc20", bridgeId: "passthrough",
|
|
179
|
+
// Priced in cents against a six-decimal token: the wrong scale entirely.
|
|
180
|
+
srcAmount: Money.parse("USDC", 2, "500"), dstAmount: Money.parse("USDC", 2, "500"),
|
|
181
|
+
fees: Money.parse("USDC", 2, "0"), fxRate: "1", expiresAt: 0, providerQuoteRef: "", latencyEstimateMs: 0,
|
|
182
|
+
};
|
|
183
|
+
it("refuses a collect whose exponent does not match the token decimals", async () => {
|
|
184
|
+
const chain = new FakeChain();
|
|
185
|
+
const leg = new Erc20Leg({ token: usdcToken, currency: "USDC", chain, ids: counter(), decimals: 6 });
|
|
186
|
+
await expect(leg.collect("intent-1", wrongScaleQuote, { signerRef: "", signature: "", nonce: "" }, recipient)).rejects.toThrow();
|
|
187
|
+
expect(chain.calls.length).toBe(0);
|
|
188
|
+
});
|
|
189
|
+
it("refuses a disburse whose exponent does not match the token decimals", async () => {
|
|
190
|
+
const chain = new FakeChain();
|
|
191
|
+
const leg = new Erc20Leg({ token: usdcToken, currency: "USDC", chain, ids: counter(), decimals: 6 });
|
|
192
|
+
await expect(leg.disburse("intent-1", wrongScaleQuote, recipient)).rejects.toThrow();
|
|
193
|
+
expect(chain.calls.length).toBe(0);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { RefundKind } from "../src/adapter.js";
|
|
2
|
+
import type { Money } from "../src/money.js";
|
|
3
|
+
import type { Quote, Authorization, Settlement } from "../src/message.js";
|
|
4
|
+
import type { RateSource, EscrowVault } from "../src/bridge.js";
|
|
5
|
+
import type { PayInLeg, PayOutLeg, PayInCapabilities, PayOutCapabilities, CollectResult, DisburseResult } from "../src/leg.js";
|
|
6
|
+
export declare class FakeLeg implements PayInLeg, PayOutLeg {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
private readonly rail;
|
|
9
|
+
private readonly currency;
|
|
10
|
+
private readonly ids;
|
|
11
|
+
failPayout: boolean;
|
|
12
|
+
constructor(id: string, rail: string, currency: string, ids: () => string);
|
|
13
|
+
payInCapabilities(): PayInCapabilities;
|
|
14
|
+
collect(_intentId: string, quote: Quote, _auth: Authorization, _deliverTo: string): Promise<CollectResult>;
|
|
15
|
+
refundIn(intentId: string, _kind: RefundKind, _amount: Money, reason: string): Promise<Settlement>;
|
|
16
|
+
payOutCapabilities(): PayOutCapabilities;
|
|
17
|
+
disburse(_intentId: string, _quote: Quote, _recipientRef: string): Promise<DisburseResult>;
|
|
18
|
+
reverseOut(intentId: string, reason: string): Promise<Settlement>;
|
|
19
|
+
private terminal;
|
|
20
|
+
}
|
|
21
|
+
export declare class FakeRates implements RateSource {
|
|
22
|
+
private readonly table;
|
|
23
|
+
constructor(table: Record<string, string>);
|
|
24
|
+
rate(from: string, to: string): Promise<string>;
|
|
25
|
+
}
|
|
26
|
+
export declare class FakeVault implements EscrowVault {
|
|
27
|
+
hold(intentId: string, _amount: Money): Promise<string>;
|
|
28
|
+
release(_intentId: string): Promise<void>;
|
|
29
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { State } from "../src/state.js";
|
|
2
|
+
import { RefundKind } from "../src/adapter.js";
|
|
3
|
+
// FakeLeg is a deterministic, in-memory pay-in and pay-out leg. It settles
|
|
4
|
+
// instantly with no network and implements both sides so a test can build a
|
|
5
|
+
// direct corridor from one leg or a bridged corridor from two. failPayout makes
|
|
6
|
+
// its disburse fail so a test can exercise the escrow-unwind path.
|
|
7
|
+
export class FakeLeg {
|
|
8
|
+
id;
|
|
9
|
+
rail;
|
|
10
|
+
currency;
|
|
11
|
+
ids;
|
|
12
|
+
failPayout = false;
|
|
13
|
+
constructor(id, rail, currency, ids) {
|
|
14
|
+
this.id = id;
|
|
15
|
+
this.rail = rail;
|
|
16
|
+
this.currency = currency;
|
|
17
|
+
this.ids = ids;
|
|
18
|
+
}
|
|
19
|
+
payInCapabilities() {
|
|
20
|
+
return { rails: [this.rail], currencies: [this.currency], refunds: RefundKind.Full };
|
|
21
|
+
}
|
|
22
|
+
// collect takes the payer's funds. received is the net the bridge converts —
|
|
23
|
+
// the source the payer paid less the corridor fees.
|
|
24
|
+
async collect(_intentId, quote, _auth, _deliverTo) {
|
|
25
|
+
return { providerRef: this.ids(), received: quote.srcAmount.sub(quote.fees) };
|
|
26
|
+
}
|
|
27
|
+
async refundIn(intentId, _kind, _amount, reason) {
|
|
28
|
+
return this.terminal(intentId, reason);
|
|
29
|
+
}
|
|
30
|
+
payOutCapabilities() {
|
|
31
|
+
return { rails: [this.rail], currencies: [this.currency], reversible: false };
|
|
32
|
+
}
|
|
33
|
+
async disburse(_intentId, _quote, _recipientRef) {
|
|
34
|
+
if (this.failPayout) {
|
|
35
|
+
throw new Error("fake: payout failed");
|
|
36
|
+
}
|
|
37
|
+
return { providerRef: this.ids() };
|
|
38
|
+
}
|
|
39
|
+
async reverseOut(intentId, reason) {
|
|
40
|
+
return this.terminal(intentId, reason);
|
|
41
|
+
}
|
|
42
|
+
terminal(intentId, reason) {
|
|
43
|
+
return {
|
|
44
|
+
intentId,
|
|
45
|
+
state: State.Refunded,
|
|
46
|
+
adapterId: this.id,
|
|
47
|
+
providerTxRef: this.ids(),
|
|
48
|
+
onchainTxHash: "",
|
|
49
|
+
receiptHash: new Uint8Array(0),
|
|
50
|
+
reason,
|
|
51
|
+
settledAt: 0,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// FakeRates is an in-memory rate source keyed by "FROM:TO", standing in for a
|
|
56
|
+
// price feed so a bridge can be tested without a live market.
|
|
57
|
+
export class FakeRates {
|
|
58
|
+
table;
|
|
59
|
+
constructor(table) {
|
|
60
|
+
this.table = table;
|
|
61
|
+
}
|
|
62
|
+
async rate(from, to) {
|
|
63
|
+
const rate = this.table[`${from}:${to}`];
|
|
64
|
+
if (rate === undefined) {
|
|
65
|
+
throw new Error(`fake: no rate for ${from}:${to}`);
|
|
66
|
+
}
|
|
67
|
+
return rate;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// FakeVault is an in-memory escrow that records holds and releases, standing in
|
|
71
|
+
// for the custody a real bridge relies on.
|
|
72
|
+
export class FakeVault {
|
|
73
|
+
async hold(intentId, _amount) {
|
|
74
|
+
return `escrow-${intentId}`;
|
|
75
|
+
}
|
|
76
|
+
async release(_intentId) {
|
|
77
|
+
// Nothing to release in memory.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -4,7 +4,7 @@ import { Money } from "../src/money.js";
|
|
|
4
4
|
import { State } from "../src/state.js";
|
|
5
5
|
import { isDirect } from "../src/message.js";
|
|
6
6
|
import { UsdcBridge } from "../src/bridge.js";
|
|
7
|
-
import { FakeLeg, FakeRates, FakeVault } from "
|
|
7
|
+
import { FakeLeg, FakeRates, FakeVault } from "./fake.js";
|
|
8
8
|
import { Ed25519Signer, Ed25519Verifier } from "../src/signing.js";
|
|
9
9
|
import { fromHex } from "../src/crypto.js";
|
|
10
10
|
function counter() {
|
|
@@ -61,6 +61,33 @@ describe("corridor lifecycle", () => {
|
|
|
61
61
|
expect(states).toEqual([State.Draft, State.Quoted, State.Authorized, State.Submitted, State.Settled]);
|
|
62
62
|
expect(client.head(intent.id)).toBeDefined();
|
|
63
63
|
});
|
|
64
|
+
it("refunds a settled intent with an explicit amount and refuses an over-refund", async () => {
|
|
65
|
+
const { signer, verifier } = signerVerifier("alice");
|
|
66
|
+
const wallet = new FakeLeg("wallet", "wallet", "USD", counter());
|
|
67
|
+
const client = new Client({ payIn: [wallet], payOut: [wallet], verifier, clock: () => now, idGen: counter() });
|
|
68
|
+
const intent = client.createIntent({
|
|
69
|
+
senderRef: "alice",
|
|
70
|
+
recipientRef: "bob",
|
|
71
|
+
amount: Money.parse("USD", 2, "1500"),
|
|
72
|
+
expiresAt: now + 600_000,
|
|
73
|
+
allowedRails: ["wallet"],
|
|
74
|
+
});
|
|
75
|
+
const quotes = await client.quoteOptions(intent, [{ payInAdapterId: "wallet", currency: "USD", exponent: 2 }]);
|
|
76
|
+
const quote = await client.select(quotes, signer.identity());
|
|
77
|
+
const auth = await client.authorize(intent, quote, signer);
|
|
78
|
+
await client.initiate(intent, quote, auth);
|
|
79
|
+
await client.advance(intent.id, "wallet", { intentId: intent.id, state: State.Settled, providerTxRef: "", onchainTxHash: "", reason: "", settledAt: 0 });
|
|
80
|
+
// A refund over the funded source amount is refused.
|
|
81
|
+
await expect(client.refund(intent.id, Money.parse("USD", 2, "9999"), "too much")).rejects.toThrow();
|
|
82
|
+
// The wallet leg refunds only in full, so a partial refund is refused.
|
|
83
|
+
await expect(client.refund(intent.id, Money.parse("USD", 2, "700"), "partial")).rejects.toThrow();
|
|
84
|
+
// A full refund settles the intent to refunded.
|
|
85
|
+
const { state } = await client.refund(intent.id, quote.srcAmount, "changed mind");
|
|
86
|
+
expect(state).toBe(State.Refunded);
|
|
87
|
+
// A second refund is an idempotent no-op that echoes the refunded state.
|
|
88
|
+
const again = await client.refund(intent.id, quote.srcAmount, "again");
|
|
89
|
+
expect(again.state).toBe(State.Refunded);
|
|
90
|
+
});
|
|
64
91
|
// A bridged corridor: the payer funds in USD by card, a USDC bridge converts to
|
|
65
92
|
// KES, and an M-Pesa leg delivers to the recipient.
|
|
66
93
|
it("settles a bridged card-USD to USDC to mpesa-KES corridor end to end", async () => {
|
|
@@ -195,14 +222,15 @@ describe("corridor lifecycle", () => {
|
|
|
195
222
|
const client = new Client({
|
|
196
223
|
payIn: [new FakeLeg("wallet", "wallet", "USD", counter())],
|
|
197
224
|
verifier,
|
|
198
|
-
clock
|
|
225
|
+
// The clock sits well past the intent's deadline, so it is genuinely expired.
|
|
226
|
+
clock: () => 2_000_000,
|
|
199
227
|
idGen: counter(),
|
|
200
228
|
});
|
|
201
229
|
const intent = client.createIntent({
|
|
202
230
|
senderRef: "a",
|
|
203
231
|
recipientRef: "b",
|
|
204
232
|
amount: Money.parse("USD", 2, "100"),
|
|
205
|
-
expiresAt:
|
|
233
|
+
expiresAt: 1000,
|
|
206
234
|
allowedRails: ["wallet"],
|
|
207
235
|
});
|
|
208
236
|
client.expire(intent.id);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { Money } from "../src/money.js";
|
|
3
|
+
import { parseRate } from "../src/bridge.js";
|
|
4
|
+
describe("money parse grammar", () => {
|
|
5
|
+
it("rejects hex, signed, whitespaced, and zero-padded amounts", () => {
|
|
6
|
+
for (const bad of ["0x10", " 5", "5 ", "+5", "-5", "007", "", "1.5", "1e3"]) {
|
|
7
|
+
expect(() => Money.parse("USD", 2, bad)).toThrow();
|
|
8
|
+
}
|
|
9
|
+
});
|
|
10
|
+
it("accepts a bare non-negative integer", () => {
|
|
11
|
+
for (const ok of ["0", "5", "1000000000000000000000"]) {
|
|
12
|
+
expect(Money.parse("USD", 2, ok).minor()).toBe(ok);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe("rate parse grammar", () => {
|
|
17
|
+
it("rejects a sign, a radix prefix, whitespace, and leading zeros", () => {
|
|
18
|
+
for (const bad of ["0x10", " 1.5", "+1", "-1", "01", "1.", ".5", ""]) {
|
|
19
|
+
expect(() => parseRate(bad)).toThrow();
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
it("accepts an integer or fixed-point rate, including a leading-zero decimal", () => {
|
|
23
|
+
for (const ok of ["0", "129", "1.45", "0.5"]) {
|
|
24
|
+
expect(() => parseRate(ok)).not.toThrow();
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
});
|
package/dist/test/mpesa.test.js
CHANGED
|
@@ -5,7 +5,7 @@ import { State } from "../src/state.js";
|
|
|
5
5
|
import { isDirect } from "../src/message.js";
|
|
6
6
|
import { RefundKind } from "../src/adapter.js";
|
|
7
7
|
import { UsdcBridge } from "../src/bridge.js";
|
|
8
|
-
import { FakeLeg, FakeRates, FakeVault } from "
|
|
8
|
+
import { FakeLeg, FakeRates, FakeVault } from "./fake.js";
|
|
9
9
|
import { Ed25519Signer, Ed25519Verifier } from "../src/signing.js";
|
|
10
10
|
import { fromHex } from "../src/crypto.js";
|
|
11
11
|
import { MpesaLeg, ErrUnknownCheckout, normalizePhone, } from "../src/adapters/mpesa.js";
|
|
@@ -19,22 +19,34 @@ function signerVerifier(identity) {
|
|
|
19
19
|
return { signer, verifier: new Ed25519Verifier(new Map([[identity, signer.publicKey]])) };
|
|
20
20
|
}
|
|
21
21
|
// FakeDaraja is an in-memory stand-in for Daraja so the leg runs end to end with
|
|
22
|
-
// no network and no credentials.
|
|
22
|
+
// no network and no credentials. queryResults maps a checkout id to the outcome
|
|
23
|
+
// Daraja would report back; a checkout with no entry answers as still processing,
|
|
24
|
+
// standing in for a push the payer never approved.
|
|
23
25
|
class FakeDaraja {
|
|
24
26
|
lastPush;
|
|
25
27
|
lastB2C;
|
|
28
|
+
queryResults = new Map();
|
|
26
29
|
ids;
|
|
27
30
|
constructor(ids) {
|
|
28
31
|
this.ids = ids;
|
|
29
32
|
}
|
|
30
33
|
async stkPush(params) {
|
|
31
34
|
this.lastPush = params;
|
|
32
|
-
|
|
35
|
+
const checkout = this.ids();
|
|
36
|
+
// By default a push confirms as a success, so the happy path settles; a test
|
|
37
|
+
// overrides queryResults to model other outcomes.
|
|
38
|
+
if (!this.queryResults.has(checkout)) {
|
|
39
|
+
this.queryResults.set(checkout, { resultCode: 0, resultDesc: "", pending: false });
|
|
40
|
+
}
|
|
41
|
+
return { merchantRequestId: this.ids(), checkoutRequestId: checkout, responseCode: "0" };
|
|
33
42
|
}
|
|
34
43
|
async b2cPayment(params) {
|
|
35
44
|
this.lastB2C = params;
|
|
36
45
|
return { conversationId: this.ids(), responseCode: "0" };
|
|
37
46
|
}
|
|
47
|
+
async query(checkoutRequestId) {
|
|
48
|
+
return this.queryResults.get(checkoutRequestId) ?? { resultCode: 0, resultDesc: "", pending: true };
|
|
49
|
+
}
|
|
38
50
|
}
|
|
39
51
|
function buildLeg(api) {
|
|
40
52
|
return new MpesaLeg({
|
|
@@ -95,9 +107,9 @@ describe("mpesa whole-shilling guard", () => {
|
|
|
95
107
|
});
|
|
96
108
|
});
|
|
97
109
|
describe("mpesa unknown checkout", () => {
|
|
98
|
-
it("rejects a callback whose checkout id it never started", () => {
|
|
110
|
+
it("rejects a callback whose checkout id it never started", async () => {
|
|
99
111
|
const leg = buildLeg(new FakeDaraja(counter("tx")));
|
|
100
|
-
expect(
|
|
112
|
+
await expect(leg.parseWebhook(successCallback("never-started", "ABC"), {})).rejects.toThrow(ErrUnknownCheckout);
|
|
101
113
|
});
|
|
102
114
|
});
|
|
103
115
|
describe("mpesa pay-out", () => {
|
|
@@ -158,13 +170,13 @@ describe("mpesa pay-in", () => {
|
|
|
158
170
|
const collected = await leg.collect("intent-1", q, emptyAuth, "0711000111");
|
|
159
171
|
expect(api.lastPush.accountReference).toBe("intent-1");
|
|
160
172
|
expect(api.lastPush.payerPhone).toBe("254711000111");
|
|
161
|
-
const events = leg.parseWebhook(successCallback(collected.providerRef, "QGR7XYZ123"), {});
|
|
173
|
+
const events = await leg.parseWebhook(successCallback(collected.providerRef, "QGR7XYZ123"), {});
|
|
162
174
|
expect(events).toHaveLength(1);
|
|
163
175
|
expect(events[0].state).toBe(State.Settled);
|
|
164
176
|
expect(events[0].intentId).toBe("intent-1");
|
|
165
177
|
expect(events[0].providerTxRef).toBe("QGR7XYZ123");
|
|
166
178
|
// A refund is a business-to-customer counter-transfer back to the payer.
|
|
167
|
-
await leg.refundIn("intent-1", RefundKind.CounterTransfer, "returning funds");
|
|
179
|
+
await leg.refundIn("intent-1", RefundKind.CounterTransfer, Money.parse("KES", 0, "1500"), "returning funds");
|
|
168
180
|
expect(api.lastB2C.phone).toBe("254711000111");
|
|
169
181
|
expect(api.lastB2C.amount).toBe(1500);
|
|
170
182
|
});
|
|
@@ -173,8 +185,29 @@ describe("mpesa pay-in", () => {
|
|
|
173
185
|
const leg = buildLeg(api);
|
|
174
186
|
const q = quote({ srcAmount: kes("1500"), fees: kes("0") });
|
|
175
187
|
const collected = await leg.collect("intent-1", q, emptyAuth, "0711000111");
|
|
176
|
-
|
|
188
|
+
// The payer cancelled: the authenticated query is what reports the failure.
|
|
189
|
+
api.queryResults.set(collected.providerRef, { resultCode: 1032, resultDesc: "Request cancelled by user", pending: false });
|
|
190
|
+
const events = await leg.parseWebhook(failureCallback(collected.providerRef), {});
|
|
177
191
|
expect(events).toHaveLength(1);
|
|
178
192
|
expect(events[0].state).toBe(State.Failed);
|
|
179
193
|
});
|
|
194
|
+
it("settles nothing for a forged success on a push the payer never approved", async () => {
|
|
195
|
+
const api = new FakeDaraja(counter("tx"));
|
|
196
|
+
const leg = buildLeg(api);
|
|
197
|
+
const q = quote({ srcAmount: kes("1500"), fees: kes("0") });
|
|
198
|
+
const collected = await leg.collect("intent-1", q, emptyAuth, "0711000111");
|
|
199
|
+
// The payer has not approved, so Daraja is still processing this checkout.
|
|
200
|
+
api.queryResults.set(collected.providerRef, { resultCode: 0, resultDesc: "", pending: true });
|
|
201
|
+
const events = await leg.parseWebhook(successCallback(collected.providerRef, "FORGEDRCPT"), {});
|
|
202
|
+
expect(events).toHaveLength(0);
|
|
203
|
+
});
|
|
204
|
+
it("settles nothing when the callback amount differs from the authorized quote", async () => {
|
|
205
|
+
const api = new FakeDaraja(counter("tx"));
|
|
206
|
+
const leg = buildLeg(api);
|
|
207
|
+
const q = quote({ srcAmount: kes("5000"), fees: kes("0") });
|
|
208
|
+
const collected = await leg.collect("intent-1", q, emptyAuth, "0711000111");
|
|
209
|
+
// Daraja confirms success, but the callback body claims only 1500 was paid.
|
|
210
|
+
api.queryResults.set(collected.providerRef, { resultCode: 0, resultDesc: "", pending: false });
|
|
211
|
+
await expect(leg.parseWebhook(successCallback(collected.providerRef, "QGR7XYZ123"), {})).rejects.toThrow();
|
|
212
|
+
});
|
|
180
213
|
});
|