@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/test/paypal.test.js
CHANGED
|
@@ -80,18 +80,38 @@ describe("paypal major amount", () => {
|
|
|
80
80
|
});
|
|
81
81
|
});
|
|
82
82
|
describe("paypal webhook verification", () => {
|
|
83
|
-
it("
|
|
83
|
+
it("settles a matching capture, refuses an under-payment, and rejects an unverifiable one", async () => {
|
|
84
84
|
const api = new FakePaypal(counter("tx"));
|
|
85
85
|
const leg = buildLeg(api);
|
|
86
|
-
|
|
87
|
-
const
|
|
86
|
+
// Register what the intent was quoted to collect so the capture can be checked.
|
|
87
|
+
const zero = Money.parse("USD", 2, "0");
|
|
88
|
+
await leg.collect("intent-1", {
|
|
89
|
+
id: "q",
|
|
90
|
+
intentId: "intent-1",
|
|
91
|
+
payInAdapterId: "paypal",
|
|
92
|
+
payInRail: "paypal",
|
|
93
|
+
payOutAdapterId: "paypal",
|
|
94
|
+
payOutRail: "paypal",
|
|
95
|
+
bridgeId: "passthrough",
|
|
96
|
+
srcAmount: Money.parse("USD", 2, "1500"),
|
|
97
|
+
dstAmount: Money.parse("USD", 2, "1500"),
|
|
98
|
+
fees: zero,
|
|
99
|
+
fxRate: "1",
|
|
100
|
+
expiresAt: 0,
|
|
101
|
+
providerQuoteRef: "",
|
|
102
|
+
latencyEstimateMs: 0,
|
|
103
|
+
}, { intentId: "intent-1", quoteId: "q", signerIdentity: "", signedAt: 0, signature: new Uint8Array(0) }, "merchant@example.com");
|
|
104
|
+
const capture = (value, currency) => new TextEncoder().encode(`{"event_type":"PAYMENT.CAPTURE.COMPLETED","resource":{"id":"capture_1","custom_id":"pact:intent-1","amount":{"currency_code":"${currency}","value":"${value}"}}}`);
|
|
105
|
+
const events = await leg.parseWebhook(capture("15.00", "USD"), { "PayPal-Transmission-Id": ["t1"] });
|
|
88
106
|
expect(events).toHaveLength(1);
|
|
89
107
|
expect(events[0].state).toBe(State.Settled);
|
|
90
108
|
expect(events[0].intentId).toBe("intent-1");
|
|
91
109
|
expect(events[0].providerTxRef).toBe("capture_1");
|
|
110
|
+
// A signed capture for a smaller amount, riding a stolen custom_id, must not settle.
|
|
111
|
+
await expect(leg.parseWebhook(capture("0.01", "USD"), { "PayPal-Transmission-Id": ["t2"] })).rejects.toThrow();
|
|
92
112
|
// PayPal reports the signature does not verify, so the payload is rejected.
|
|
93
113
|
api.verify = false;
|
|
94
|
-
await expect(leg.parseWebhook(
|
|
114
|
+
await expect(leg.parseWebhook(capture("15.00", "USD"), {})).rejects.toThrow(ErrSignatureMismatch);
|
|
95
115
|
});
|
|
96
116
|
});
|
|
97
117
|
describe("paypal direct corridor", () => {
|
|
@@ -124,11 +144,16 @@ describe("paypal direct corridor", () => {
|
|
|
124
144
|
// The order was created to the recipient payee for the gross amount.
|
|
125
145
|
expect(api.lastOrder.payee).toBe("merchant@example.com");
|
|
126
146
|
expect(api.lastOrder.value).toBe("15.00");
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
await leg.refundIn(intent.id, RefundKind.Full, "changed mind");
|
|
147
|
+
// PayPal supports a full and a partial refund. A full refund names no amount.
|
|
148
|
+
await leg.refundIn(intent.id, RefundKind.Full, Money.parse("USD", 2, "1500"), "changed mind");
|
|
130
149
|
expect(api.lastRefund.captureId).toBe(settlement.providerTxRef);
|
|
131
|
-
|
|
150
|
+
expect(api.lastRefund.value).toBeUndefined();
|
|
151
|
+
// A partial refund names the amount to return in major units.
|
|
152
|
+
await leg.refundIn(intent.id, RefundKind.Partial, Money.parse("USD", 2, "700"), "half");
|
|
153
|
+
expect(api.lastRefund.value).toBe("7.00");
|
|
154
|
+
expect(api.lastRefund.currencyCode).toBe("USD");
|
|
155
|
+
// A counter-transfer is a shape the card rail does not express.
|
|
156
|
+
await expect(leg.refundIn(intent.id, RefundKind.CounterTransfer, Money.parse("USD", 2, "1500"), "wrong kind")).rejects.toThrow();
|
|
132
157
|
});
|
|
133
158
|
});
|
|
134
159
|
describe("paypal interactive pay-in", () => {
|
package/dist/test/policy.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { Client } from "../src/client.js";
|
|
3
3
|
import { Money } from "../src/money.js";
|
|
4
|
-
import { FakeLeg } from "
|
|
4
|
+
import { FakeLeg } from "./fake.js";
|
|
5
5
|
import { Ed25519Signer, Ed25519Verifier } from "../src/signing.js";
|
|
6
6
|
import { fromHex } from "../src/crypto.js";
|
|
7
7
|
import { Erc20Leg } from "../src/adapters/erc20.js";
|
|
@@ -31,7 +31,10 @@ class OkChain {
|
|
|
31
31
|
return "0xtx";
|
|
32
32
|
}
|
|
33
33
|
async receipt() {
|
|
34
|
-
return { status: "success", blockTimestampMs: 0 };
|
|
34
|
+
return { status: "success", blockNumber: 0, blockTimestampMs: 0, tokenAddress: "", to: "", amount: 0n };
|
|
35
|
+
}
|
|
36
|
+
async blockNumber() {
|
|
37
|
+
return 0;
|
|
35
38
|
}
|
|
36
39
|
}
|
|
37
40
|
const now = 1_700_000_000_000;
|
|
@@ -74,6 +77,7 @@ describe("tiered kyc and window risk policy", () => {
|
|
|
74
77
|
currency: "USD",
|
|
75
78
|
chain: new OkChain(),
|
|
76
79
|
ids: counter(),
|
|
80
|
+
decimals: 2,
|
|
77
81
|
});
|
|
78
82
|
const client = new Client({
|
|
79
83
|
payIn: [leg],
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { route, PolicyKind, NoQuoteError } from "../src/router.js";
|
|
3
|
+
import { Money } from "../src/money.js";
|
|
4
|
+
const now = 1_700_000_000_000;
|
|
5
|
+
const future = now + 1_000_000;
|
|
6
|
+
const noKyc = { tier: "unverified", jurisdiction: "", limits: {} };
|
|
7
|
+
function quote(id, adapter, rail, src) {
|
|
8
|
+
return {
|
|
9
|
+
id,
|
|
10
|
+
intentId: "i",
|
|
11
|
+
payInAdapterId: adapter,
|
|
12
|
+
payInRail: rail,
|
|
13
|
+
payOutAdapterId: adapter,
|
|
14
|
+
payOutRail: rail,
|
|
15
|
+
bridgeId: "passthrough",
|
|
16
|
+
srcAmount: src,
|
|
17
|
+
dstAmount: src,
|
|
18
|
+
fees: Money.create(src.currency, src.exponent, 0n),
|
|
19
|
+
fxRate: "1",
|
|
20
|
+
expiresAt: future,
|
|
21
|
+
providerQuoteRef: "",
|
|
22
|
+
latencyEstimateMs: 0,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
describe("router cross-currency cost", () => {
|
|
26
|
+
const usd = quote("q-usd", "card", "card", Money.create("USD", 2, 1500n));
|
|
27
|
+
const kes = quote("q-kes", "mpesa", "mpesa", Money.create("KES", 0, 200000n));
|
|
28
|
+
it("refuses to rank by cost across source currencies", () => {
|
|
29
|
+
// The router holds no rate to weigh 1500 USD against 200000 KES, so it
|
|
30
|
+
// refuses rather than letting the id tie-break crown a costlier quote.
|
|
31
|
+
expect(() => route([usd, kes], { kind: PolicyKind.Cheapest }, noKyc, now, 0)).toThrow(NoQuoteError);
|
|
32
|
+
});
|
|
33
|
+
it("ranks a single-currency set by cost", () => {
|
|
34
|
+
const chosen = route([usd], { kind: PolicyKind.Cheapest }, noKyc, now, 0);
|
|
35
|
+
expect(chosen.id).toBe("q-usd");
|
|
36
|
+
});
|
|
37
|
+
it("ranks across currencies under a currency-agnostic policy", () => {
|
|
38
|
+
const chosen = route([usd, kes], { kind: PolicyKind.Fastest }, noKyc, now, 0);
|
|
39
|
+
expect(chosen).toBeDefined();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe("router tie-break byte ordering", () => {
|
|
43
|
+
// U+F900 and U+10000 order one way by code point (UTF-8) and the opposite way
|
|
44
|
+
// by UTF-16 code unit; the tie-break must order by UTF-8 to match the Go SDK.
|
|
45
|
+
it("orders adapter ids by their UTF-8 bytes", () => {
|
|
46
|
+
const lo = quote("q-lo", "豈", "card", Money.create("USD", 2, 1500n));
|
|
47
|
+
const hi = quote("q-hi", "\u{10000}", "card", Money.create("USD", 2, 1500n));
|
|
48
|
+
const chosen = route([hi, lo], { kind: PolicyKind.Cheapest }, noKyc, now, 0);
|
|
49
|
+
// U+F900 < U+10000 by code point, so its adapter wins under UTF-8 ordering.
|
|
50
|
+
expect(chosen.payInAdapterId).toBe("豈");
|
|
51
|
+
});
|
|
52
|
+
});
|
package/dist/test/stripe.test.js
CHANGED
|
@@ -173,10 +173,11 @@ describe("stripe direct card corridor", () => {
|
|
|
173
173
|
// The captured intent carries the pact intent id and reached a captured status.
|
|
174
174
|
const pi = await api.findPaymentIntent(intent.id);
|
|
175
175
|
expect(pi.status).toBe("succeeded");
|
|
176
|
-
// Stripe supports
|
|
177
|
-
//
|
|
178
|
-
await leg.refundIn(intent.id, RefundKind.Full, "changed mind");
|
|
179
|
-
await
|
|
176
|
+
// Stripe supports a full and a partial reversal; a counter-transfer is a shape
|
|
177
|
+
// the card rail does not express.
|
|
178
|
+
await leg.refundIn(intent.id, RefundKind.Full, Money.parse("USD", 2, "1500"), "changed mind");
|
|
179
|
+
await leg.refundIn(intent.id, RefundKind.Partial, Money.parse("USD", 2, "500"), "partial");
|
|
180
|
+
await expect(leg.refundIn(intent.id, RefundKind.CounterTransfer, Money.parse("USD", 2, "1500"), "wrong kind")).rejects.toThrow();
|
|
180
181
|
});
|
|
181
182
|
it("prepares a payer-confirmed intent and settles on the confirming webhook", async () => {
|
|
182
183
|
const { signer, verifier } = signerVerifier("alice");
|
|
@@ -3,8 +3,9 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { Money } from "../src/money.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { parseRate } from "../src/bridge.js";
|
|
7
|
+
import { stateName, canTransition } from "../src/state.js";
|
|
8
|
+
import { intentPreimage, intentHash, quotePreimage, quoteHash, signingPreimage, authorizationHash, receiptHash, corridorReceipt, } from "../src/message.js";
|
|
8
9
|
import { eventReceipt, chainLeaf } from "../src/ledger.js";
|
|
9
10
|
import { Ed25519Signer, Ed25519Verifier } from "../src/signing.js";
|
|
10
11
|
import { toHex, fromHex } from "../src/crypto.js";
|
|
@@ -45,6 +46,41 @@ function quoteFrom(j) {
|
|
|
45
46
|
}
|
|
46
47
|
const nameToState = Object.fromEntries(Object.entries(stateName).map(([s, name]) => [name, Number(s)]));
|
|
47
48
|
describe("shared conformance vectors", () => {
|
|
49
|
+
it("matches the shared state transition table exactly", () => {
|
|
50
|
+
const spec = vectors.transitions;
|
|
51
|
+
expect(Object.keys(spec).length).toBeGreaterThan(0);
|
|
52
|
+
const names = Object.keys(nameToState);
|
|
53
|
+
// Every listed edge must be legal and every pair the spec omits must be
|
|
54
|
+
// refused, so the lifecycle cannot silently gain or lose a transition.
|
|
55
|
+
for (const fromName of Object.keys(spec)) {
|
|
56
|
+
const allowed = new Set(spec[fromName]);
|
|
57
|
+
for (const toName of names) {
|
|
58
|
+
expect(canTransition(nameToState[fromName], nameToState[toName])).toBe(allowed.has(toName));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
it("accepts and rejects the shared boundary amounts and rates", () => {
|
|
63
|
+
for (const c of vectors.boundaries.money) {
|
|
64
|
+
let ok = true;
|
|
65
|
+
try {
|
|
66
|
+
Money.parse(c.currency, c.exponent, c.minor);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
ok = false;
|
|
70
|
+
}
|
|
71
|
+
expect(ok, `money ${c.note}`).toBe(c.valid);
|
|
72
|
+
}
|
|
73
|
+
for (const c of vectors.boundaries.rate) {
|
|
74
|
+
let ok = true;
|
|
75
|
+
try {
|
|
76
|
+
parseRate(c.rate);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
ok = false;
|
|
80
|
+
}
|
|
81
|
+
expect(ok, `rate ${c.note}`).toBe(c.valid);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
48
84
|
it("reproduces every intent preimage and hash", () => {
|
|
49
85
|
for (const entry of vectors.intents) {
|
|
50
86
|
const intent = intentFrom(entry.intent);
|
|
@@ -71,12 +107,22 @@ describe("shared conformance vectors", () => {
|
|
|
71
107
|
expect(toHex(signature)).toBe(a.signature_hex);
|
|
72
108
|
const verifier = new Ed25519Verifier(new Map([[a.signer_identity, signer.publicKey]]));
|
|
73
109
|
expect(verifier.verify(a.signer_identity, preimage, signature)).toBe(true);
|
|
110
|
+
// The same message signed with an S shifted by the group order is a valid
|
|
111
|
+
// Ed25519 forgery unless the verifier rejects a non-canonical S. An
|
|
112
|
+
// authorization signature is a unique commitment, so the twin must be refused.
|
|
113
|
+
expect(verifier.verify(a.signer_identity, preimage, fromHex(a.malleable_signature_hex))).toBe(false);
|
|
74
114
|
});
|
|
75
115
|
it("reproduces the settlement receipt hash", () => {
|
|
76
116
|
const r = vectors.receipt;
|
|
77
117
|
const hash = receiptHash(fromHex(r.auth_hash_hex), r.provider_tx_ref, nameToState[r.state]);
|
|
78
118
|
expect(toHex(hash)).toBe(r.receipt_hash_hex);
|
|
79
119
|
});
|
|
120
|
+
it("reproduces the corridor receipt binding the delivered amount", () => {
|
|
121
|
+
const c = vectors.corridor_receipt;
|
|
122
|
+
const amount = Money.parse(c.amount.currency, c.amount.exponent, c.amount.amount);
|
|
123
|
+
const hash = corridorReceipt(fromHex(c.auth_hash_hex), c.pay_in_provider_ref, c.pay_out_provider_ref, amount, c.bridge_receipt_ref, nameToState[c.state]);
|
|
124
|
+
expect(toHex(hash)).toBe(c.receipt_hash_hex);
|
|
125
|
+
});
|
|
80
126
|
it("reproduces every ledger leaf and the Merkle head", () => {
|
|
81
127
|
const l = vectors.ledger;
|
|
82
128
|
let prevLeaf = new Uint8Array(32);
|
package/dist/test/wire.test.js
CHANGED
|
@@ -90,6 +90,21 @@ describe("wire codec conformance and round-trips", () => {
|
|
|
90
90
|
expect(decoded.state).toBe(State.Settled);
|
|
91
91
|
expect(toHex(decoded.receiptHash)).toBe(w.receipt_hash_hex);
|
|
92
92
|
});
|
|
93
|
+
it("rejects a settlement carrying an unknown state ordinal", () => {
|
|
94
|
+
const forged = {
|
|
95
|
+
intentId: "intent-0001",
|
|
96
|
+
state: 200,
|
|
97
|
+
adapterId: "stripe",
|
|
98
|
+
providerTxRef: "",
|
|
99
|
+
onchainTxHash: "",
|
|
100
|
+
receiptHash: new Uint8Array(),
|
|
101
|
+
reason: "",
|
|
102
|
+
settledAt: 0,
|
|
103
|
+
};
|
|
104
|
+
// Encoding accepts the out-of-range ordinal; decoding must refuse it rather
|
|
105
|
+
// than pass a nonexistent state through.
|
|
106
|
+
expect(() => decodeSettlement(encodeSettlement(forged))).toThrow();
|
|
107
|
+
});
|
|
93
108
|
it("dispatches a tagged frame back to its type", () => {
|
|
94
109
|
const intent = intentFrom(vectors.intents[0].intent);
|
|
95
110
|
const frame = encodeMessage({ kind: WireKind.Intent, message: intent });
|
package/package.json
CHANGED
package/src/adapter.ts
CHANGED
|
@@ -26,7 +26,12 @@ export interface AdapterEvent {
|
|
|
26
26
|
// WebhookParser is implemented by legs that receive provider callbacks. It is
|
|
27
27
|
// server-side only and turns a raw payload plus its request headers into
|
|
28
28
|
// normalized events. The headers carry the provider's signature, so the parser
|
|
29
|
-
// can reject a payload it cannot authenticate.
|
|
29
|
+
// can reject a payload it cannot authenticate. A leg whose callback is unsigned
|
|
30
|
+
// may need an out-of-band confirmation call to authenticate it, so the result may
|
|
31
|
+
// be a promise; a signed-webhook leg returns synchronously.
|
|
30
32
|
export interface WebhookParser {
|
|
31
|
-
parseWebhook(
|
|
33
|
+
parseWebhook(
|
|
34
|
+
raw: Uint8Array,
|
|
35
|
+
headers: Record<string, string[]>,
|
|
36
|
+
): AdapterEvent[] | Promise<AdapterEvent[]>;
|
|
32
37
|
}
|
package/src/adapters/erc20.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// moves the payer's tokens to a destination, as a pay-out leg it delivers tokens
|
|
7
7
|
// to the recipient.
|
|
8
8
|
import { State } from "../state.js";
|
|
9
|
+
import type { Money } from "../money.js";
|
|
9
10
|
import type { Quote, Authorization, Settlement } from "../message.js";
|
|
10
11
|
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
11
12
|
import type {
|
|
@@ -27,10 +28,17 @@ export interface Call {
|
|
|
27
28
|
data: string;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
// ChainReceipt is
|
|
31
|
+
// ChainReceipt is a mined transaction: whether it succeeded, the block it landed
|
|
32
|
+
// in and when, and the ERC-20 Transfer it emitted. The Transfer fields let a
|
|
33
|
+
// settlement re-derive that the expected token, amount, and recipient actually
|
|
34
|
+
// moved, rather than trusting the status flag alone.
|
|
31
35
|
export interface ChainReceipt {
|
|
32
36
|
status: "success" | "reverted" | "pending";
|
|
37
|
+
blockNumber: number;
|
|
33
38
|
blockTimestampMs: number;
|
|
39
|
+
tokenAddress: string; // the contract that emitted the Transfer
|
|
40
|
+
to: string; // the Transfer recipient
|
|
41
|
+
amount: bigint; // the Transfer amount
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
// ChainClient is the on-chain surface the leg depends on. On a real client it
|
|
@@ -42,8 +50,16 @@ export interface ChainClient {
|
|
|
42
50
|
// hash. Signing happens inside the client, never in the leg.
|
|
43
51
|
send(call: Call): Promise<string>;
|
|
44
52
|
receipt(txHash: string): Promise<ChainReceipt>;
|
|
53
|
+
// blockNumber reports the current chain head, so a settlement can measure how
|
|
54
|
+
// many confirmations a transfer has.
|
|
55
|
+
blockNumber(): Promise<number>;
|
|
45
56
|
}
|
|
46
57
|
|
|
58
|
+
// The confirmation depth a transfer must reach before it settles. A shallow
|
|
59
|
+
// success can still be reorged out, so a settlement waits for enough blocks on
|
|
60
|
+
// top of it.
|
|
61
|
+
const defaultMinConfirmations = 12;
|
|
62
|
+
|
|
47
63
|
export interface Erc20Config {
|
|
48
64
|
id?: string;
|
|
49
65
|
token: string; // the ERC-20 contract address
|
|
@@ -51,6 +67,13 @@ export interface Erc20Config {
|
|
|
51
67
|
rail?: string;
|
|
52
68
|
chain: ChainClient;
|
|
53
69
|
ids: () => string;
|
|
70
|
+
// decimals is the token's on-chain decimal places. A transferred amount's
|
|
71
|
+
// exponent must equal it, so a quote priced at the wrong scale (cents against a
|
|
72
|
+
// six-decimal token) is refused rather than moving a wildly wrong amount.
|
|
73
|
+
decimals: number;
|
|
74
|
+
// The confirmation depth a transfer must reach before it settles. Omitted uses
|
|
75
|
+
// defaultMinConfirmations.
|
|
76
|
+
minConfirmations?: number;
|
|
54
77
|
}
|
|
55
78
|
|
|
56
79
|
// Erc20Leg settles a payment as an ERC-20 token transfer. Refunds are a
|
|
@@ -62,22 +85,45 @@ export class Erc20Leg implements PayInLeg, PayOutLeg {
|
|
|
62
85
|
private readonly rail: string;
|
|
63
86
|
private readonly chain: ChainClient;
|
|
64
87
|
private readonly ids: () => string;
|
|
65
|
-
private readonly
|
|
88
|
+
private readonly decimals: number;
|
|
89
|
+
private readonly minConf: number;
|
|
90
|
+
// Each broadcast transfer and what it was meant to move, so a settlement can
|
|
91
|
+
// confirm the mined transaction really matches the payment.
|
|
92
|
+
private readonly sent = new Map<string, { txHash: string; to: string; amount: bigint }>();
|
|
66
93
|
|
|
67
94
|
constructor(cfg: Erc20Config) {
|
|
68
95
|
if (!cfg.token || !cfg.currency) {
|
|
69
96
|
throw new Error("erc20: config requires a token address and currency");
|
|
70
97
|
}
|
|
98
|
+
// The token address is fixed for the leg's lifetime, so validate it once here
|
|
99
|
+
// rather than discover a malformed contract address at the first transfer.
|
|
100
|
+
if (!/^[0-9a-f]{40}$/.test(normalizeAddress(cfg.token))) {
|
|
101
|
+
throw new Error(`erc20: token ${JSON.stringify(cfg.token)} is not a 20-byte address`);
|
|
102
|
+
}
|
|
71
103
|
this.id = cfg.id ?? "erc20";
|
|
72
104
|
this.token = cfg.token;
|
|
73
105
|
this.currency = cfg.currency;
|
|
74
106
|
this.rail = cfg.rail ?? "erc20";
|
|
75
107
|
this.chain = cfg.chain;
|
|
76
108
|
this.ids = cfg.ids;
|
|
109
|
+
this.decimals = cfg.decimals;
|
|
110
|
+
this.minConf = cfg.minConfirmations ?? defaultMinConfirmations;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// checkScale refuses an amount whose exponent does not match the token's
|
|
114
|
+
// decimals, so a quote priced at the wrong scale never moves the wrong number
|
|
115
|
+
// of tokens.
|
|
116
|
+
private checkScale(m: Money): void {
|
|
117
|
+
if (m.exponent !== this.decimals) {
|
|
118
|
+
throw new Error(`erc20: amount exponent ${m.exponent} does not match the token's ${this.decimals} decimals`);
|
|
119
|
+
}
|
|
77
120
|
}
|
|
78
121
|
|
|
79
122
|
payInCapabilities(): PayInCapabilities {
|
|
80
|
-
|
|
123
|
+
// A token collection is irreversible and the leg holds no custody to send the
|
|
124
|
+
// payer back, so it advertises no refund rather than a capability it cannot
|
|
125
|
+
// honour. An adopter that wires refund custody supplies a leg that offers one.
|
|
126
|
+
return { rails: [this.rail], currencies: [this.currency], refunds: RefundKind.None };
|
|
81
127
|
}
|
|
82
128
|
|
|
83
129
|
payOutCapabilities(): PayOutCapabilities {
|
|
@@ -88,68 +134,90 @@ export class Erc20Leg implements PayInLeg, PayOutLeg {
|
|
|
88
134
|
// direct corridor, the escrow for a bridged one. received is the net a bridge
|
|
89
135
|
// would convert: the source the payer paid less the corridor fees.
|
|
90
136
|
async collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult> {
|
|
137
|
+
this.checkScale(quote.srcAmount);
|
|
91
138
|
const net = quote.srcAmount.sub(quote.fees);
|
|
139
|
+
// A repeat for the same intent returns the transfer already broadcast rather
|
|
140
|
+
// than sending the payer's tokens twice.
|
|
141
|
+
const prev = this.sent.get(intentId);
|
|
142
|
+
if (prev) {
|
|
143
|
+
return { providerRef: prev.txHash, received: net };
|
|
144
|
+
}
|
|
92
145
|
const txHash = await this.transfer(deliverTo, net.value());
|
|
93
|
-
this.sent.set(intentId, txHash);
|
|
146
|
+
this.sent.set(intentId, { txHash, to: normalizeAddress(deliverTo), amount: net.value() });
|
|
94
147
|
return { providerRef: txHash, received: net };
|
|
95
148
|
}
|
|
96
149
|
|
|
97
150
|
// disburse delivers the recipient's tokens.
|
|
98
151
|
async disburse(intentId: string, quote: Quote, recipientRef: string): Promise<DisburseResult> {
|
|
99
|
-
|
|
100
|
-
|
|
152
|
+
this.checkScale(quote.dstAmount);
|
|
153
|
+
// A repeat for the same intent returns the transfer already broadcast rather
|
|
154
|
+
// than delivering the recipient's tokens twice.
|
|
155
|
+
const prev = this.sent.get(intentId);
|
|
156
|
+
if (prev) {
|
|
157
|
+
return { providerRef: prev.txHash };
|
|
158
|
+
}
|
|
159
|
+
const amount = quote.dstAmount.value();
|
|
160
|
+
const txHash = await this.transfer(recipientRef, amount);
|
|
161
|
+
this.sent.set(intentId, { txHash, to: normalizeAddress(recipientRef), amount });
|
|
101
162
|
return { providerRef: txHash };
|
|
102
163
|
}
|
|
103
164
|
|
|
104
|
-
// refundIn
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return this.terminal(intentId, reason);
|
|
165
|
+
// refundIn refuses: an ERC-20 collection is irreversible and the leg holds no
|
|
166
|
+
// custody to counter-transfer the payer back, so it will not record a refund
|
|
167
|
+
// that moves no tokens. An adopter that wires refund custody supplies a leg
|
|
168
|
+
// that advertises and honours a refund.
|
|
169
|
+
async refundIn(_intentId: string, _kind: RefundKind, _amount: Money, _reason: string): Promise<Settlement> {
|
|
170
|
+
throw new Error("erc20: a token collection cannot be refunded in place; wire refund custody to return the payer");
|
|
111
171
|
}
|
|
112
172
|
|
|
113
|
-
|
|
114
|
-
|
|
173
|
+
// reverseOut refuses, reporting the truth of the rail: a delivered token
|
|
174
|
+
// transfer cannot be pulled back.
|
|
175
|
+
async reverseOut(_intentId: string, _reason: string): Promise<Settlement> {
|
|
176
|
+
throw new Error("erc20: a delivered token transfer cannot be reversed");
|
|
115
177
|
}
|
|
116
178
|
|
|
117
|
-
// settlementEvent reads
|
|
118
|
-
//
|
|
119
|
-
// the
|
|
179
|
+
// settlementEvent reads a broadcast transfer back from chain and reports its
|
|
180
|
+
// outcome. A token transfer has no webhook, so a host polls this. It settles
|
|
181
|
+
// only when the transaction is final: mined successfully, buried under the
|
|
182
|
+
// required confirmations, and carrying a Transfer of the configured token, in
|
|
183
|
+
// the amount that was sent, to the recipient it was sent to. A pending or
|
|
184
|
+
// shallow success stays submitted so the host keeps polling; a revert or a
|
|
185
|
+
// mismatch fails, so a dropped, reorged, or spoofed transfer never settles.
|
|
120
186
|
async settlementEvent(intentId: string): Promise<AdapterEvent> {
|
|
121
|
-
const
|
|
122
|
-
if (!
|
|
187
|
+
const rec = this.sent.get(intentId);
|
|
188
|
+
if (!rec) {
|
|
123
189
|
throw new Error("erc20: no broadcast transaction for intent");
|
|
124
190
|
}
|
|
125
|
-
const receipt = await this.chain.receipt(txHash);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
state:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
191
|
+
const receipt = await this.chain.receipt(rec.txHash);
|
|
192
|
+
const base = { intentId, providerTxRef: rec.txHash, onchainTxHash: rec.txHash, settledAt: 0 };
|
|
193
|
+
if (receipt.status === "reverted") {
|
|
194
|
+
return { ...base, state: State.Failed, reason: "transaction reverted" };
|
|
195
|
+
}
|
|
196
|
+
if (receipt.status !== "success") {
|
|
197
|
+
return { ...base, state: State.Submitted, reason: "" };
|
|
198
|
+
}
|
|
199
|
+
const head = await this.chain.blockNumber();
|
|
200
|
+
if (receipt.blockNumber === 0 || head < receipt.blockNumber || head - receipt.blockNumber + 1 < this.minConf) {
|
|
201
|
+
return { ...base, state: State.Submitted, reason: "" };
|
|
202
|
+
}
|
|
203
|
+
if (
|
|
204
|
+
!sameAddress(receipt.tokenAddress, this.token) ||
|
|
205
|
+
!sameAddress(receipt.to, rec.to) ||
|
|
206
|
+
receipt.amount !== rec.amount
|
|
207
|
+
) {
|
|
208
|
+
return {
|
|
209
|
+
...base,
|
|
210
|
+
state: State.Failed,
|
|
211
|
+
reason: "on-chain transfer does not match the expected token, recipient, or amount",
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return { ...base, state: State.Settled, reason: "", settledAt: receipt.blockTimestampMs };
|
|
134
215
|
}
|
|
135
216
|
|
|
136
217
|
private async transfer(to: string, amount: bigint): Promise<string> {
|
|
137
218
|
return this.chain.send({ to: this.token, data: transferCalldata(to, amount) });
|
|
138
219
|
}
|
|
139
220
|
|
|
140
|
-
private terminal(intentId: string, reason: string): Settlement {
|
|
141
|
-
const txHash = this.sent.get(intentId) ?? "";
|
|
142
|
-
return {
|
|
143
|
-
intentId,
|
|
144
|
-
state: State.Refunded,
|
|
145
|
-
adapterId: this.id,
|
|
146
|
-
providerTxRef: txHash,
|
|
147
|
-
onchainTxHash: txHash,
|
|
148
|
-
receiptHash: new Uint8Array(0),
|
|
149
|
-
reason,
|
|
150
|
-
settledAt: 0,
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
221
|
}
|
|
154
222
|
|
|
155
223
|
// transferCalldata builds the ERC-20 transfer calldata: the selector, the
|
|
@@ -169,13 +237,12 @@ export function transferCalldata(recipient: string, amount: bigint): string {
|
|
|
169
237
|
return "0x" + transferSelector + address.padStart(64, "0") + amountHex.padStart(64, "0");
|
|
170
238
|
}
|
|
171
239
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
240
|
+
// normalizeAddress lowercases an EVM address and drops any 0x prefix, so two
|
|
241
|
+
// spellings of the same address compare equal.
|
|
242
|
+
function normalizeAddress(addr: string): string {
|
|
243
|
+
return addr.toLowerCase().replace(/^0x/, "");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function sameAddress(a: string, b: string): boolean {
|
|
247
|
+
return normalizeAddress(a) === normalizeAddress(b);
|
|
181
248
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
|
|
5
|
+
// httpTimeoutMs bounds how long an outbound provider request may run before it is
|
|
6
|
+
// aborted. It matches the 30-second ceiling the Go adapters use.
|
|
7
|
+
export const httpTimeoutMs = 30_000;
|
|
8
|
+
|
|
9
|
+
// fetchWithTimeout issues a fetch that aborts once httpTimeoutMs elapses, so a
|
|
10
|
+
// stalled provider surfaces as an error rather than an unbounded wait. A caller
|
|
11
|
+
// that already supplies a signal is left untouched.
|
|
12
|
+
export function fetchWithTimeout(url: string, init: RequestInit = {}): Promise<Response> {
|
|
13
|
+
return fetch(url, { signal: AbortSignal.timeout(httpTimeoutMs), ...init });
|
|
14
|
+
}
|