@myzonerocks/pact 0.1.2 → 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.
Files changed (67) hide show
  1. package/dist/src/adapter.d.ts +1 -1
  2. package/dist/src/adapters/erc20.d.ts +13 -3
  3. package/dist/src/adapters/erc20.js +93 -49
  4. package/dist/src/adapters/http.d.ts +2 -0
  5. package/dist/src/adapters/http.js +12 -0
  6. package/dist/src/adapters/mpesa.d.ts +9 -2
  7. package/dist/src/adapters/mpesa.js +82 -12
  8. package/dist/src/adapters/paypal.d.ts +15 -3
  9. package/dist/src/adapters/paypal.js +121 -21
  10. package/dist/src/adapters/stripe.d.ts +6 -2
  11. package/dist/src/adapters/stripe.js +25 -12
  12. package/dist/src/bridge.js +12 -5
  13. package/dist/src/canonical.js +5 -0
  14. package/dist/src/client.d.ts +8 -2
  15. package/dist/src/client.js +181 -20
  16. package/dist/src/compliance.d.ts +4 -0
  17. package/dist/src/compliance.js +10 -3
  18. package/dist/src/crypto.js +4 -1
  19. package/dist/src/index.d.ts +0 -1
  20. package/dist/src/index.js +0 -1
  21. package/dist/src/ledger.d.ts +6 -2
  22. package/dist/src/ledger.js +2 -2
  23. package/dist/src/leg.d.ts +1 -1
  24. package/dist/src/message.d.ts +1 -1
  25. package/dist/src/message.js +8 -5
  26. package/dist/src/money.d.ts +1 -0
  27. package/dist/src/money.js +18 -3
  28. package/dist/src/protocol.d.ts +1 -0
  29. package/dist/src/protocol.js +8 -0
  30. package/dist/src/router.d.ts +2 -0
  31. package/dist/src/router.js +45 -7
  32. package/dist/src/wire.js +19 -2
  33. package/dist/test/erc20.test.js +95 -31
  34. package/dist/test/fake.d.ts +29 -0
  35. package/dist/test/fake.js +79 -0
  36. package/dist/test/lifecycle.test.js +31 -3
  37. package/dist/test/money.test.d.ts +1 -0
  38. package/dist/test/money.test.js +27 -0
  39. package/dist/test/mpesa.test.js +41 -8
  40. package/dist/test/paypal.test.js +54 -8
  41. package/dist/test/policy.test.js +6 -2
  42. package/dist/test/router.test.d.ts +1 -0
  43. package/dist/test/router.test.js +52 -0
  44. package/dist/test/stripe.test.js +5 -4
  45. package/dist/test/vectors.test.js +48 -2
  46. package/dist/test/wire.test.js +15 -0
  47. package/package.json +1 -1
  48. package/src/adapter.ts +7 -2
  49. package/src/adapters/erc20.ts +118 -51
  50. package/src/adapters/http.ts +14 -0
  51. package/src/adapters/mpesa.ts +102 -13
  52. package/src/adapters/paypal.ts +168 -22
  53. package/src/adapters/stripe.ts +16 -13
  54. package/src/bridge.ts +12 -5
  55. package/src/canonical.ts +5 -0
  56. package/src/client.ts +194 -22
  57. package/src/compliance.ts +20 -3
  58. package/src/crypto.ts +4 -1
  59. package/src/index.ts +0 -1
  60. package/src/ledger.ts +12 -4
  61. package/src/leg.ts +4 -1
  62. package/src/message.ts +8 -5
  63. package/src/money.ts +19 -3
  64. package/src/protocol.ts +9 -0
  65. package/src/router.ts +44 -4
  66. package/src/wire.ts +20 -3
  67. package/src/fake.ts +0 -96
@@ -33,6 +33,10 @@ class FakePaypal {
33
33
  const id = this.ids();
34
34
  return { orderId: `order_${id}`, captureId: `capture_${id}`, status: "COMPLETED" };
35
35
  }
36
+ async createOrder(params) {
37
+ this.lastOrder = params;
38
+ return { id: `order_${this.ids()}`, status: "CREATED", approveUrl: "https://paypal.test/approve" };
39
+ }
36
40
  async sendPayout(params) {
37
41
  this.lastPayout = params;
38
42
  return { batchId: `batch_${this.ids()}`, status: "PENDING" };
@@ -76,18 +80,38 @@ describe("paypal major amount", () => {
76
80
  });
77
81
  });
78
82
  describe("paypal webhook verification", () => {
79
- it("maps an authentic capture to a settled event and rejects an unverifiable one", async () => {
83
+ it("settles a matching capture, refuses an under-payment, and rejects an unverifiable one", async () => {
80
84
  const api = new FakePaypal(counter("tx"));
81
85
  const leg = buildLeg(api);
82
- const payload = new TextEncoder().encode(`{"event_type":"PAYMENT.CAPTURE.COMPLETED","resource":{"id":"capture_1","custom_id":"pact:intent-1"}}`);
83
- const events = await leg.parseWebhook(payload, { "PayPal-Transmission-Id": ["t1"] });
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"] });
84
106
  expect(events).toHaveLength(1);
85
107
  expect(events[0].state).toBe(State.Settled);
86
108
  expect(events[0].intentId).toBe("intent-1");
87
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();
88
112
  // PayPal reports the signature does not verify, so the payload is rejected.
89
113
  api.verify = false;
90
- await expect(leg.parseWebhook(payload, {})).rejects.toThrow(ErrSignatureMismatch);
114
+ await expect(leg.parseWebhook(capture("15.00", "USD"), {})).rejects.toThrow(ErrSignatureMismatch);
91
115
  });
92
116
  });
93
117
  describe("paypal direct corridor", () => {
@@ -120,11 +144,33 @@ describe("paypal direct corridor", () => {
120
144
  // The order was created to the recipient payee for the gross amount.
121
145
  expect(api.lastOrder.payee).toBe("merchant@example.com");
122
146
  expect(api.lastOrder.value).toBe("15.00");
123
- // A full refund is accepted and binds to the capture id; a partial refund is a
124
- // shape PayPal does not express here.
125
- 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");
126
149
  expect(api.lastRefund.captureId).toBe(settlement.providerTxRef);
127
- await expect(leg.refundIn(intent.id, RefundKind.Partial, "half")).rejects.toThrow();
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();
157
+ });
158
+ });
159
+ describe("paypal interactive pay-in", () => {
160
+ it("prepares an order to the recipient payee with our cut as a platform fee", async () => {
161
+ const api = new FakePaypal(counter("tx"));
162
+ const leg = buildLeg(api);
163
+ const prep = await leg.prepare("intent-1", quote({ srcAmount: Money.parse("USD", 2, "1500"), fees: Money.parse("USD", 2, "45") }), { intentId: "intent-1", quoteId: "q", signerIdentity: "alice", signature: new Uint8Array(0), signedAt: 0 }, "merchant@example.com");
164
+ expect(prep.providerRef).not.toBe("");
165
+ expect(prep.clientSecret).toBe(prep.providerRef);
166
+ expect(prep.method).toBe("paypal");
167
+ expect(api.lastOrder.payee).toBe("merchant@example.com");
168
+ expect(api.lastOrder.value).toBe("15.00");
169
+ expect(api.lastOrder.platformFee).toBe("0.45");
170
+ expect(api.lastOrder.referenceId).toBe("pact:intent-1");
171
+ // A zero fee omits the platform fee rather than sending "0.00".
172
+ await leg.prepare("intent-2", quote({ srcAmount: Money.parse("USD", 2, "1500"), fees: Money.parse("USD", 2, "0") }), { intentId: "intent-2", quoteId: "q", signerIdentity: "alice", signature: new Uint8Array(0), signedAt: 0 }, "merchant@example.com");
173
+ expect(api.lastOrder.platformFee).toBeUndefined();
128
174
  });
129
175
  });
130
176
  describe("paypal pay-out", () => {
@@ -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 "../src/fake.js";
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
+ });
@@ -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 partial reversal, so a full refund is accepted and a
177
- // counter-transfer is a shape the card rail does not express.
178
- await leg.refundIn(intent.id, RefundKind.Full, "changed mind");
179
- await expect(leg.refundIn(intent.id, RefundKind.CounterTransfer, "wrong kind")).rejects.toThrow();
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 { stateName } from "../src/state.js";
7
- import { intentPreimage, intentHash, quotePreimage, quoteHash, signingPreimage, authorizationHash, receiptHash, } from "../src/message.js";
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);
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myzonerocks/pact",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "TypeScript SDK for the PACT payment abstraction protocol",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
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(raw: Uint8Array, headers: Record<string, string[]>): AdapterEvent[];
33
+ parseWebhook(
34
+ raw: Uint8Array,
35
+ headers: Record<string, string[]>,
36
+ ): AdapterEvent[] | Promise<AdapterEvent[]>;
32
37
  }
@@ -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 the outcome of a broadcast transaction once mined.
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 sent = new Map<string, string>();
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
- return { rails: [this.rail], currencies: [this.currency], refunds: RefundKind.CounterTransfer };
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
- const txHash = await this.transfer(recipientRef, quote.dstAmount.value());
100
- this.sent.set(intentId, txHash);
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 and reverseOut both answer with a counter-transfer, the only refund
105
- // an irreversible token movement supports.
106
- async refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement> {
107
- if (kind !== RefundKind.CounterTransfer) {
108
- throw new Error("erc20: a token transfer can only be refunded by counter-transfer");
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
- async reverseOut(intentId: string, reason: string): Promise<Settlement> {
114
- return this.terminal(intentId, reason);
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 the receipt for an intent's transaction and produces the
118
- // event a host feeds back once the transfer confirms. Crypto has no webhook, so
119
- // the host polls this instead.
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 txHash = this.sent.get(intentId);
122
- if (!txHash) {
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
- return {
127
- intentId,
128
- state: receiptState(receipt),
129
- providerTxRef: txHash,
130
- onchainTxHash: txHash,
131
- reason: receipt.status === "reverted" ? "transaction reverted" : "",
132
- settledAt: receipt.blockTimestampMs,
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
- function receiptState(receipt: ChainReceipt): State {
173
- switch (receipt.status) {
174
- case "success":
175
- return State.Settled;
176
- case "reverted":
177
- return State.Failed;
178
- default:
179
- return State.Submitted;
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
+ }