@wopr-network/platform-core 1.20.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/billing/crypto/btc/settler.js +1 -1
  2. package/dist/billing/crypto/btc/watcher.d.ts +5 -3
  3. package/dist/billing/crypto/btc/watcher.js +9 -8
  4. package/dist/billing/crypto/evm/__tests__/eth-checkout.test.d.ts +1 -0
  5. package/dist/billing/crypto/evm/__tests__/eth-checkout.test.js +49 -0
  6. package/dist/billing/crypto/evm/__tests__/eth-settler.test.d.ts +1 -0
  7. package/dist/billing/crypto/evm/__tests__/eth-settler.test.js +80 -0
  8. package/dist/billing/crypto/evm/__tests__/eth-watcher.test.d.ts +1 -0
  9. package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +134 -0
  10. package/dist/billing/crypto/evm/eth-checkout.d.ts +34 -0
  11. package/dist/billing/crypto/evm/eth-checkout.js +53 -0
  12. package/dist/billing/crypto/evm/eth-settler.d.ts +23 -0
  13. package/dist/billing/crypto/evm/eth-settler.js +52 -0
  14. package/dist/billing/crypto/evm/eth-watcher.d.ts +53 -0
  15. package/dist/billing/crypto/evm/eth-watcher.js +83 -0
  16. package/dist/billing/crypto/evm/index.d.ts +6 -0
  17. package/dist/billing/crypto/evm/index.js +3 -0
  18. package/dist/billing/crypto/evm/settler.js +1 -1
  19. package/dist/billing/crypto/index.d.ts +1 -0
  20. package/dist/billing/crypto/index.js +1 -0
  21. package/dist/billing/crypto/oracle/__tests__/chainlink.test.d.ts +1 -0
  22. package/dist/billing/crypto/oracle/__tests__/chainlink.test.js +83 -0
  23. package/dist/billing/crypto/oracle/__tests__/convert.test.d.ts +1 -0
  24. package/dist/billing/crypto/oracle/__tests__/convert.test.js +51 -0
  25. package/dist/billing/crypto/oracle/__tests__/fixed.test.d.ts +1 -0
  26. package/dist/billing/crypto/oracle/__tests__/fixed.test.js +20 -0
  27. package/dist/billing/crypto/oracle/chainlink.d.ts +26 -0
  28. package/dist/billing/crypto/oracle/chainlink.js +61 -0
  29. package/dist/billing/crypto/oracle/convert.d.ts +20 -0
  30. package/dist/billing/crypto/oracle/convert.js +38 -0
  31. package/dist/billing/crypto/oracle/fixed.d.ts +10 -0
  32. package/dist/billing/crypto/oracle/fixed.js +20 -0
  33. package/dist/billing/crypto/oracle/index.d.ts +5 -0
  34. package/dist/billing/crypto/oracle/index.js +3 -0
  35. package/dist/billing/crypto/oracle/types.d.ts +13 -0
  36. package/dist/billing/crypto/oracle/types.js +1 -0
  37. package/package.json +1 -1
  38. package/src/billing/crypto/btc/settler.ts +1 -1
  39. package/src/billing/crypto/btc/watcher.ts +12 -11
  40. package/src/billing/crypto/evm/__tests__/eth-checkout.test.ts +60 -0
  41. package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +98 -0
  42. package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +157 -0
  43. package/src/billing/crypto/evm/eth-checkout.ts +84 -0
  44. package/src/billing/crypto/evm/eth-settler.ts +71 -0
  45. package/src/billing/crypto/evm/eth-watcher.ts +129 -0
  46. package/src/billing/crypto/evm/index.ts +6 -0
  47. package/src/billing/crypto/evm/settler.ts +1 -1
  48. package/src/billing/crypto/index.ts +1 -0
  49. package/src/billing/crypto/oracle/__tests__/chainlink.test.ts +107 -0
  50. package/src/billing/crypto/oracle/__tests__/convert.test.ts +62 -0
  51. package/src/billing/crypto/oracle/__tests__/fixed.test.ts +23 -0
  52. package/src/billing/crypto/oracle/chainlink.ts +85 -0
  53. package/src/billing/crypto/oracle/convert.ts +38 -0
  54. package/src/billing/crypto/oracle/fixed.ts +23 -0
  55. package/src/billing/crypto/oracle/index.ts +5 -0
  56. package/src/billing/crypto/oracle/types.ts +15 -0
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { settleEthPayment } from "../eth-settler.js";
3
+ import type { EthPaymentEvent } from "../eth-watcher.js";
4
+
5
+ function makeEvent(overrides: Partial<EthPaymentEvent> = {}): EthPaymentEvent {
6
+ return {
7
+ chain: "base",
8
+ from: "0xsender",
9
+ to: "0xdeposit",
10
+ valueWei: "14285714285714285",
11
+ amountUsdCents: 5000,
12
+ txHash: "0xabc123",
13
+ blockNumber: 100,
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ function makeDeps(charge: { amountUsdCents: number; creditedAt: string | null } | null = null) {
19
+ return {
20
+ chargeStore: {
21
+ getByDepositAddress: vi.fn().mockResolvedValue(
22
+ charge
23
+ ? {
24
+ referenceId: "eth:base:0xdeposit",
25
+ tenantId: "t1",
26
+ amountUsdCents: charge.amountUsdCents,
27
+ creditedAt: charge.creditedAt,
28
+ }
29
+ : null,
30
+ ),
31
+ updateStatus: vi.fn().mockResolvedValue(undefined),
32
+ markCredited: vi.fn().mockResolvedValue(undefined),
33
+ },
34
+ creditLedger: {
35
+ credit: vi.fn().mockResolvedValue(undefined),
36
+ hasReferenceId: vi.fn().mockResolvedValue(false),
37
+ },
38
+ };
39
+ }
40
+
41
+ describe("settleEthPayment", () => {
42
+ it("returns Invalid for unknown deposit address", async () => {
43
+ const deps = makeDeps(null);
44
+ const result = await settleEthPayment(deps, makeEvent());
45
+ expect(result.handled).toBe(false);
46
+ expect(result.status).toBe("Invalid");
47
+ });
48
+
49
+ it("credits ledger for valid payment", async () => {
50
+ const deps = makeDeps({ amountUsdCents: 5000, creditedAt: null });
51
+ const result = await settleEthPayment(deps, makeEvent());
52
+
53
+ expect(result.handled).toBe(true);
54
+ expect(result.creditedCents).toBe(5000);
55
+ expect(deps.creditLedger.credit).toHaveBeenCalledOnce();
56
+ expect(deps.chargeStore.markCredited).toHaveBeenCalledOnce();
57
+ });
58
+
59
+ it("skips already-credited charge (charge-level idempotency)", async () => {
60
+ const deps = makeDeps({ amountUsdCents: 5000, creditedAt: "2026-01-01" });
61
+ const result = await settleEthPayment(deps, makeEvent());
62
+
63
+ expect(result.handled).toBe(true);
64
+ expect(result.creditedCents).toBe(0);
65
+ expect(deps.creditLedger.credit).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it("skips duplicate transfer (transfer-level idempotency)", async () => {
69
+ const deps = makeDeps({ amountUsdCents: 5000, creditedAt: null });
70
+ deps.creditLedger.hasReferenceId.mockResolvedValue(true);
71
+
72
+ const result = await settleEthPayment(deps, makeEvent());
73
+ expect(result.creditedCents).toBe(0);
74
+ expect(deps.creditLedger.credit).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it("rejects underpayment", async () => {
78
+ const deps = makeDeps({ amountUsdCents: 10000, creditedAt: null });
79
+ const result = await settleEthPayment(deps, makeEvent({ amountUsdCents: 5000 }));
80
+
81
+ expect(result.creditedCents).toBe(0);
82
+ expect(deps.creditLedger.credit).not.toHaveBeenCalled();
83
+ });
84
+
85
+ it("credits charge amount, not transfer amount (overpayment safe)", async () => {
86
+ const deps = makeDeps({ amountUsdCents: 5000, creditedAt: null });
87
+ const result = await settleEthPayment(deps, makeEvent({ amountUsdCents: 10000 }));
88
+
89
+ expect(result.creditedCents).toBe(5000);
90
+ });
91
+
92
+ it("uses correct creditRef format", async () => {
93
+ const deps = makeDeps({ amountUsdCents: 5000, creditedAt: null });
94
+ await settleEthPayment(deps, makeEvent({ chain: "base", txHash: "0xdef" }));
95
+
96
+ expect(deps.creditLedger.hasReferenceId).toHaveBeenCalledWith("eth:base:0xdef");
97
+ });
98
+ });
@@ -0,0 +1,157 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { EthWatcher } from "../eth-watcher.js";
3
+
4
+ function makeRpc(responses: Record<string, unknown>) {
5
+ return vi.fn(async (method: string) => responses[method]);
6
+ }
7
+
8
+ const mockOracle = { getPrice: vi.fn().mockResolvedValue({ priceCents: 350_000, updatedAt: new Date() }) };
9
+
10
+ describe("EthWatcher", () => {
11
+ it("detects native ETH transfer to watched address", async () => {
12
+ const onPayment = vi.fn();
13
+ const rpc = makeRpc({
14
+ eth_blockNumber: "0xb",
15
+ eth_getBlockByNumber: {
16
+ transactions: [
17
+ {
18
+ hash: "0xabc",
19
+ from: "0xsender",
20
+ to: "0xdeposit",
21
+ value: "0xDE0B6B3A7640000", // 1 ETH = 10^18 wei
22
+ blockNumber: "0xa",
23
+ },
24
+ ],
25
+ },
26
+ });
27
+
28
+ const watcher = new EthWatcher({
29
+ chain: "base",
30
+ rpcCall: rpc,
31
+ oracle: mockOracle,
32
+ fromBlock: 10,
33
+ onPayment,
34
+ watchedAddresses: ["0xDeposit"],
35
+ });
36
+
37
+ await watcher.poll();
38
+
39
+ expect(onPayment).toHaveBeenCalledOnce();
40
+ const event = onPayment.mock.calls[0][0];
41
+ expect(event.to).toBe("0xdeposit");
42
+ expect(event.valueWei).toBe("1000000000000000000");
43
+ expect(event.amountUsdCents).toBe(350_000); // 1 ETH × $3,500
44
+ expect(event.txHash).toBe("0xabc");
45
+ });
46
+
47
+ it("skips transactions not to watched addresses", async () => {
48
+ const onPayment = vi.fn();
49
+ const rpc = makeRpc({
50
+ eth_blockNumber: "0xb",
51
+ eth_getBlockByNumber: {
52
+ transactions: [{ hash: "0xabc", from: "0xa", to: "0xother", value: "0xDE0B6B3A7640000", blockNumber: "0xa" }],
53
+ },
54
+ });
55
+
56
+ const watcher = new EthWatcher({
57
+ chain: "base",
58
+ rpcCall: rpc,
59
+ oracle: mockOracle,
60
+ fromBlock: 10,
61
+ onPayment,
62
+ watchedAddresses: ["0xDeposit"],
63
+ });
64
+
65
+ await watcher.poll();
66
+ expect(onPayment).not.toHaveBeenCalled();
67
+ });
68
+
69
+ it("skips zero-value transactions", async () => {
70
+ const onPayment = vi.fn();
71
+ const rpc = makeRpc({
72
+ eth_blockNumber: "0xb",
73
+ eth_getBlockByNumber: {
74
+ transactions: [{ hash: "0xabc", from: "0xa", to: "0xdeposit", value: "0x0", blockNumber: "0xa" }],
75
+ },
76
+ });
77
+
78
+ const watcher = new EthWatcher({
79
+ chain: "base",
80
+ rpcCall: rpc,
81
+ oracle: mockOracle,
82
+ fromBlock: 10,
83
+ onPayment,
84
+ watchedAddresses: ["0xDeposit"],
85
+ });
86
+
87
+ await watcher.poll();
88
+ expect(onPayment).not.toHaveBeenCalled();
89
+ });
90
+
91
+ it("does not double-process same txid", async () => {
92
+ const onPayment = vi.fn();
93
+ const rpc = makeRpc({
94
+ eth_blockNumber: "0xb",
95
+ eth_getBlockByNumber: {
96
+ transactions: [{ hash: "0xabc", from: "0xa", to: "0xdeposit", value: "0xDE0B6B3A7640000", blockNumber: "0xa" }],
97
+ },
98
+ });
99
+
100
+ const watcher = new EthWatcher({
101
+ chain: "base",
102
+ rpcCall: rpc,
103
+ oracle: mockOracle,
104
+ fromBlock: 10,
105
+ onPayment,
106
+ watchedAddresses: ["0xDeposit"],
107
+ });
108
+
109
+ await watcher.poll();
110
+ // Reset cursor to re-scan same block
111
+ await watcher.poll();
112
+
113
+ expect(onPayment).toHaveBeenCalledOnce();
114
+ });
115
+
116
+ it("skips poll when no watched addresses", async () => {
117
+ const onPayment = vi.fn();
118
+ const rpc = vi.fn();
119
+
120
+ const watcher = new EthWatcher({
121
+ chain: "base",
122
+ rpcCall: rpc,
123
+ oracle: mockOracle,
124
+ fromBlock: 10,
125
+ onPayment,
126
+ watchedAddresses: [],
127
+ });
128
+
129
+ await watcher.poll();
130
+ expect(rpc).not.toHaveBeenCalled();
131
+ });
132
+
133
+ it("does not mark txid as processed if onPayment throws", async () => {
134
+ const onPayment = vi.fn().mockRejectedValueOnce(new Error("db fail")).mockResolvedValueOnce(undefined);
135
+ const rpc = makeRpc({
136
+ eth_blockNumber: "0xb",
137
+ eth_getBlockByNumber: {
138
+ transactions: [{ hash: "0xabc", from: "0xa", to: "0xdeposit", value: "0xDE0B6B3A7640000", blockNumber: "0xa" }],
139
+ },
140
+ });
141
+
142
+ const watcher = new EthWatcher({
143
+ chain: "base",
144
+ rpcCall: rpc,
145
+ oracle: mockOracle,
146
+ fromBlock: 10,
147
+ onPayment,
148
+ watchedAddresses: ["0xDeposit"],
149
+ });
150
+
151
+ await expect(watcher.poll()).rejects.toThrow("db fail");
152
+
153
+ // Retry — should process the same tx again since it wasn't marked
154
+ await watcher.poll();
155
+ expect(onPayment).toHaveBeenCalledTimes(2);
156
+ });
157
+ });
@@ -0,0 +1,84 @@
1
+ import { Credit } from "../../../credits/credit.js";
2
+ import type { ICryptoChargeRepository } from "../charge-store.js";
3
+ import { centsToNative } from "../oracle/convert.js";
4
+ import type { IPriceOracle } from "../oracle/types.js";
5
+ import { deriveDepositAddress } from "./address-gen.js";
6
+ import type { EvmChain } from "./types.js";
7
+
8
+ export const MIN_ETH_USD = 10;
9
+
10
+ export interface EthCheckoutDeps {
11
+ chargeStore: Pick<ICryptoChargeRepository, "getNextDerivationIndex" | "createStablecoinCharge">;
12
+ oracle: IPriceOracle;
13
+ xpub: string;
14
+ }
15
+
16
+ export interface EthCheckoutOpts {
17
+ tenant: string;
18
+ amountUsd: number;
19
+ chain: EvmChain;
20
+ }
21
+
22
+ export interface EthCheckoutResult {
23
+ depositAddress: `0x${string}`;
24
+ amountUsd: number;
25
+ /** Expected ETH amount in wei (BigInt as string). */
26
+ expectedWei: string;
27
+ /** ETH price in USD cents at checkout time. */
28
+ priceCents: number;
29
+ chain: EvmChain;
30
+ referenceId: string;
31
+ }
32
+
33
+ /**
34
+ * Create an ETH checkout — derive deposit address, lock price, store charge.
35
+ *
36
+ * Uses the oracle to get live ETH/USD price and compute the expected
37
+ * deposit amount in wei. The charge stores amountUsdCents (not wei) —
38
+ * settlement always credits the USD amount, not the ETH value.
39
+ *
40
+ * CRITICAL: amountUsd → integer cents via Credit.fromDollars().toCentsRounded().
41
+ */
42
+ export async function createEthCheckout(deps: EthCheckoutDeps, opts: EthCheckoutOpts): Promise<EthCheckoutResult> {
43
+ if (!Number.isFinite(opts.amountUsd) || opts.amountUsd < MIN_ETH_USD) {
44
+ throw new Error(`Minimum payment amount is $${MIN_ETH_USD}`);
45
+ }
46
+
47
+ const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
48
+ const { priceCents } = await deps.oracle.getPrice("ETH");
49
+ const expectedWei = centsToNative(amountUsdCents, priceCents, 18);
50
+ const maxRetries = 3;
51
+
52
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
53
+ const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
54
+ const depositAddress = deriveDepositAddress(deps.xpub, derivationIndex);
55
+ const referenceId = `eth:${opts.chain}:${depositAddress}`;
56
+
57
+ try {
58
+ await deps.chargeStore.createStablecoinCharge({
59
+ referenceId,
60
+ tenantId: opts.tenant,
61
+ amountUsdCents,
62
+ chain: opts.chain,
63
+ token: "ETH",
64
+ depositAddress,
65
+ derivationIndex,
66
+ });
67
+
68
+ return {
69
+ depositAddress,
70
+ amountUsd: opts.amountUsd,
71
+ expectedWei: expectedWei.toString(),
72
+ priceCents,
73
+ chain: opts.chain,
74
+ referenceId,
75
+ };
76
+ } catch (err: unknown) {
77
+ const code = (err as { code?: string }).code;
78
+ const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
79
+ if (!isConflict || attempt === maxRetries) throw err;
80
+ }
81
+ }
82
+
83
+ throw new Error("Failed to claim derivation index after retries");
84
+ }
@@ -0,0 +1,71 @@
1
+ import { Credit } from "../../../credits/credit.js";
2
+ import type { ILedger } from "../../../credits/ledger.js";
3
+ import type { ICryptoChargeRepository } from "../charge-store.js";
4
+ import type { CryptoWebhookResult } from "../types.js";
5
+ import type { EthPaymentEvent } from "./eth-watcher.js";
6
+
7
+ export interface EthSettlerDeps {
8
+ chargeStore: Pick<ICryptoChargeRepository, "getByDepositAddress" | "updateStatus" | "markCredited">;
9
+ creditLedger: Pick<ILedger, "credit" | "hasReferenceId">;
10
+ onCreditsPurchased?: (tenantId: string, ledger: ILedger) => Promise<string[]>;
11
+ }
12
+
13
+ /**
14
+ * Settle a native ETH payment — look up charge by deposit address, credit ledger.
15
+ *
16
+ * Same idempotency pattern as EVM stablecoin and BTC settlers:
17
+ * 1. Charge-level: charge.creditedAt != null → skip
18
+ * 2. Transfer-level: creditLedger.hasReferenceId → skip (atomic)
19
+ * 3. Advisory: chargeStore.markCredited
20
+ *
21
+ * Credits the CHARGE amount (not the ETH value) for overpayment safety.
22
+ *
23
+ * CRITICAL: charge.amountUsdCents is in USD cents (integer).
24
+ * Credit.fromCents() converts cents → nanodollars for the ledger.
25
+ */
26
+ export async function settleEthPayment(deps: EthSettlerDeps, event: EthPaymentEvent): Promise<CryptoWebhookResult> {
27
+ const { chargeStore, creditLedger } = deps;
28
+
29
+ const charge = await chargeStore.getByDepositAddress(event.to.toLowerCase());
30
+ if (!charge) {
31
+ return { handled: false, status: "Invalid" };
32
+ }
33
+
34
+ await chargeStore.updateStatus(charge.referenceId, "Settled");
35
+
36
+ if (charge.creditedAt != null) {
37
+ return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
38
+ }
39
+
40
+ const creditRef = `eth:${event.chain}:${event.txHash}`;
41
+ if (await creditLedger.hasReferenceId(creditRef)) {
42
+ return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
43
+ }
44
+
45
+ if (event.amountUsdCents < charge.amountUsdCents) {
46
+ return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
47
+ }
48
+
49
+ const creditCents = charge.amountUsdCents;
50
+ await creditLedger.credit(charge.tenantId, Credit.fromCents(creditCents), "purchase", {
51
+ description: `ETH credit purchase (${event.chain}, tx: ${event.txHash})`,
52
+ referenceId: creditRef,
53
+ fundingSource: "crypto",
54
+ });
55
+
56
+ await chargeStore.markCredited(charge.referenceId);
57
+
58
+ let reactivatedBots: string[] | undefined;
59
+ if (deps.onCreditsPurchased) {
60
+ reactivatedBots = await deps.onCreditsPurchased(charge.tenantId, creditLedger as ILedger);
61
+ if (reactivatedBots.length === 0) reactivatedBots = undefined;
62
+ }
63
+
64
+ return {
65
+ handled: true,
66
+ status: "Settled",
67
+ tenant: charge.tenantId,
68
+ creditedCents: creditCents,
69
+ reactivatedBots,
70
+ };
71
+ }
@@ -0,0 +1,129 @@
1
+ import { nativeToCents } from "../oracle/convert.js";
2
+ import type { IPriceOracle } from "../oracle/types.js";
3
+ import { getChainConfig } from "./config.js";
4
+ import type { EvmChain } from "./types.js";
5
+
6
+ type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
7
+
8
+ /** Event emitted when a native ETH deposit is detected and confirmed. */
9
+ export interface EthPaymentEvent {
10
+ readonly chain: EvmChain;
11
+ readonly from: string;
12
+ readonly to: string;
13
+ /** Raw value in wei (BigInt as string for serialization). */
14
+ readonly valueWei: string;
15
+ /** USD cents equivalent at detection time (integer). */
16
+ readonly amountUsdCents: number;
17
+ readonly txHash: string;
18
+ readonly blockNumber: number;
19
+ }
20
+
21
+ export interface EthWatcherOpts {
22
+ chain: EvmChain;
23
+ rpcCall: RpcCall;
24
+ oracle: IPriceOracle;
25
+ fromBlock: number;
26
+ onPayment: (event: EthPaymentEvent) => void | Promise<void>;
27
+ watchedAddresses?: string[];
28
+ }
29
+
30
+ interface RpcTransaction {
31
+ hash: string;
32
+ from: string;
33
+ to: string | null;
34
+ value: string;
35
+ blockNumber: string;
36
+ }
37
+
38
+ /**
39
+ * Native ETH transfer watcher.
40
+ *
41
+ * Unlike the ERC-20 EvmWatcher which uses eth_getLogs for Transfer events,
42
+ * this scans blocks for transactions where `to` matches a watched deposit
43
+ * address and `value > 0`.
44
+ *
45
+ * Uses the price oracle to convert wei → USD cents at detection time.
46
+ */
47
+ export class EthWatcher {
48
+ private _cursor: number;
49
+ private readonly chain: EvmChain;
50
+ private readonly rpc: RpcCall;
51
+ private readonly oracle: IPriceOracle;
52
+ private readonly onPayment: EthWatcherOpts["onPayment"];
53
+ private readonly confirmations: number;
54
+ private _watchedAddresses: Set<string>;
55
+ private readonly processedTxids = new Set<string>();
56
+
57
+ constructor(opts: EthWatcherOpts) {
58
+ this.chain = opts.chain;
59
+ this.rpc = opts.rpcCall;
60
+ this.oracle = opts.oracle;
61
+ this._cursor = opts.fromBlock;
62
+ this.onPayment = opts.onPayment;
63
+ this.confirmations = getChainConfig(opts.chain).confirmations;
64
+ this._watchedAddresses = new Set((opts.watchedAddresses ?? []).map((a) => a.toLowerCase()));
65
+ }
66
+
67
+ setWatchedAddresses(addresses: string[]): void {
68
+ this._watchedAddresses = new Set(addresses.map((a) => a.toLowerCase()));
69
+ }
70
+
71
+ get cursor(): number {
72
+ return this._cursor;
73
+ }
74
+
75
+ /**
76
+ * Poll for new native ETH transfers to watched addresses.
77
+ *
78
+ * Scans each confirmed block's transactions. Only processes txs
79
+ * where `to` is in the watched set and `value > 0`.
80
+ */
81
+ async poll(): Promise<void> {
82
+ if (this._watchedAddresses.size === 0) return;
83
+
84
+ const latestHex = (await this.rpc("eth_blockNumber", [])) as string;
85
+ const latest = Number.parseInt(latestHex, 16);
86
+ const confirmed = latest - this.confirmations;
87
+
88
+ if (confirmed < this._cursor) return;
89
+
90
+ const { priceCents } = await this.oracle.getPrice("ETH");
91
+
92
+ for (let blockNum = this._cursor; blockNum <= confirmed; blockNum++) {
93
+ const block = (await this.rpc("eth_getBlockByNumber", [`0x${blockNum.toString(16)}`, true])) as {
94
+ transactions: RpcTransaction[];
95
+ } | null;
96
+
97
+ if (!block) continue;
98
+
99
+ for (const tx of block.transactions) {
100
+ if (!tx.to) continue;
101
+ const to = tx.to.toLowerCase();
102
+ if (!this._watchedAddresses.has(to)) continue;
103
+
104
+ const valueWei = BigInt(tx.value);
105
+ if (valueWei === 0n) continue;
106
+
107
+ if (this.processedTxids.has(tx.hash)) continue;
108
+
109
+ const amountUsdCents = nativeToCents(valueWei, priceCents, 18);
110
+
111
+ const event: EthPaymentEvent = {
112
+ chain: this.chain,
113
+ from: tx.from.toLowerCase(),
114
+ to,
115
+ valueWei: valueWei.toString(),
116
+ amountUsdCents,
117
+ txHash: tx.hash,
118
+ blockNumber: blockNum,
119
+ };
120
+
121
+ await this.onPayment(event);
122
+ // Add to processed AFTER successful onPayment to avoid skipping on failure
123
+ this.processedTxids.add(tx.hash);
124
+ }
125
+ }
126
+
127
+ this._cursor = confirmed + 1;
128
+ }
129
+ }
@@ -2,6 +2,12 @@ export { deriveDepositAddress, isValidXpub } from "./address-gen.js";
2
2
  export type { StablecoinCheckoutDeps, StablecoinCheckoutResult } from "./checkout.js";
3
3
  export { createStablecoinCheckout, MIN_STABLECOIN_USD } from "./checkout.js";
4
4
  export { centsFromTokenAmount, getChainConfig, getTokenConfig, tokenAmountFromCents } from "./config.js";
5
+ export type { EthCheckoutDeps, EthCheckoutOpts, EthCheckoutResult } from "./eth-checkout.js";
6
+ export { createEthCheckout, MIN_ETH_USD } from "./eth-checkout.js";
7
+ export type { EthSettlerDeps } from "./eth-settler.js";
8
+ export { settleEthPayment } from "./eth-settler.js";
9
+ export type { EthPaymentEvent, EthWatcherOpts } from "./eth-watcher.js";
10
+ export { EthWatcher } from "./eth-watcher.js";
5
11
  export type { EvmSettlerDeps } from "./settler.js";
6
12
  export { settleEvmPayment } from "./settler.js";
7
13
  export type {
@@ -28,7 +28,7 @@ export async function settleEvmPayment(deps: EvmSettlerDeps, event: EvmPaymentEv
28
28
 
29
29
  const charge = await chargeStore.getByDepositAddress(event.to.toLowerCase());
30
30
  if (!charge) {
31
- return { handled: false, status: "Settled" };
31
+ return { handled: false, status: "Invalid" };
32
32
  }
33
33
 
34
34
  // Update charge status to Settled.
@@ -5,6 +5,7 @@ export { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
5
5
  export type { CryptoConfig } from "./client.js";
6
6
  export { BTCPayClient, loadCryptoConfig } from "./client.js";
7
7
  export * from "./evm/index.js";
8
+ export * from "./oracle/index.js";
8
9
  export type {
9
10
  CryptoBillingConfig,
10
11
  CryptoCheckoutOpts,
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { ChainlinkOracle } from "../chainlink.js";
3
+
4
+ /**
5
+ * Encode a mock latestRoundData() response.
6
+ * Chainlink returns 5 × 32-byte ABI-encoded words:
7
+ * roundId, answer, startedAt, updatedAt, answeredInRound
8
+ */
9
+ function encodeRoundData(answer: bigint, updatedAtSec: number): string {
10
+ const pad = (v: bigint) => v.toString(16).padStart(64, "0");
11
+ return (
12
+ "0x" +
13
+ pad(1n) + // roundId
14
+ pad(answer) + // answer (price × 10^8)
15
+ pad(BigInt(updatedAtSec)) + // startedAt
16
+ pad(BigInt(updatedAtSec)) + // updatedAt
17
+ pad(1n) // answeredInRound
18
+ );
19
+ }
20
+
21
+ describe("ChainlinkOracle", () => {
22
+ const nowSec = Math.floor(Date.now() / 1000);
23
+
24
+ it("decodes ETH/USD price from latestRoundData", async () => {
25
+ // ETH at $3,500.00 → answer = 3500 × 10^8 = 350_000_000_000
26
+ const rpc = vi.fn().mockResolvedValue(encodeRoundData(350_000_000_000n, nowSec));
27
+ const oracle = new ChainlinkOracle({ rpcCall: rpc });
28
+
29
+ const result = await oracle.getPrice("ETH");
30
+
31
+ expect(result.priceCents).toBe(350_000); // $3,500.00
32
+ expect(result.updatedAt).toBeInstanceOf(Date);
33
+ expect(rpc).toHaveBeenCalledWith("eth_call", [
34
+ { to: "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70", data: "0xfeaf968c" },
35
+ "latest",
36
+ ]);
37
+ });
38
+
39
+ it("decodes BTC/USD price from latestRoundData", async () => {
40
+ // BTC at $65,000.00 → answer = 65000 × 10^8 = 6_500_000_000_000
41
+ const rpc = vi.fn().mockResolvedValue(encodeRoundData(6_500_000_000_000n, nowSec));
42
+ const oracle = new ChainlinkOracle({ rpcCall: rpc });
43
+
44
+ const result = await oracle.getPrice("BTC");
45
+
46
+ expect(result.priceCents).toBe(6_500_000); // $65,000.00
47
+ });
48
+
49
+ it("handles fractional dollar prices correctly", async () => {
50
+ // ETH at $3,456.78 → answer = 345_678_000_000
51
+ const rpc = vi.fn().mockResolvedValue(encodeRoundData(345_678_000_000n, nowSec));
52
+ const oracle = new ChainlinkOracle({ rpcCall: rpc });
53
+
54
+ const result = await oracle.getPrice("ETH");
55
+
56
+ expect(result.priceCents).toBe(345_678); // $3,456.78
57
+ });
58
+
59
+ it("rejects stale prices", async () => {
60
+ const staleTime = nowSec - 7200; // 2 hours ago
61
+ const rpc = vi.fn().mockResolvedValue(encodeRoundData(350_000_000_000n, staleTime));
62
+ const oracle = new ChainlinkOracle({ rpcCall: rpc, maxStalenessMs: 3600_000 });
63
+
64
+ await expect(oracle.getPrice("ETH")).rejects.toThrow("stale");
65
+ });
66
+
67
+ it("rejects zero price", async () => {
68
+ const rpc = vi.fn().mockResolvedValue(encodeRoundData(0n, nowSec));
69
+ const oracle = new ChainlinkOracle({ rpcCall: rpc });
70
+
71
+ await expect(oracle.getPrice("ETH")).rejects.toThrow("Invalid price");
72
+ });
73
+
74
+ it("rejects malformed response", async () => {
75
+ const rpc = vi.fn().mockResolvedValue("0xdead");
76
+ const oracle = new ChainlinkOracle({ rpcCall: rpc });
77
+
78
+ await expect(oracle.getPrice("ETH")).rejects.toThrow("Malformed");
79
+ });
80
+
81
+ it("accepts custom feed addresses", async () => {
82
+ const customFeed = "0x1234567890abcdef1234567890abcdef12345678" as `0x${string}`;
83
+ const rpc = vi.fn().mockResolvedValue(encodeRoundData(350_000_000_000n, nowSec));
84
+ const oracle = new ChainlinkOracle({
85
+ rpcCall: rpc,
86
+ feedAddresses: { ETH: customFeed },
87
+ });
88
+
89
+ await oracle.getPrice("ETH");
90
+
91
+ expect(rpc).toHaveBeenCalledWith("eth_call", [{ to: customFeed, data: "0xfeaf968c" }, "latest"]);
92
+ });
93
+
94
+ it("respects custom staleness threshold", async () => {
95
+ const thirtyMinAgo = nowSec - 1800;
96
+ const rpc = vi.fn().mockResolvedValue(encodeRoundData(350_000_000_000n, thirtyMinAgo));
97
+
98
+ // 20-minute threshold → stale
99
+ const strict = new ChainlinkOracle({ rpcCall: rpc, maxStalenessMs: 20 * 60 * 1000 });
100
+ await expect(strict.getPrice("ETH")).rejects.toThrow("stale");
101
+
102
+ // 60-minute threshold → fresh
103
+ const relaxed = new ChainlinkOracle({ rpcCall: rpc, maxStalenessMs: 60 * 60 * 1000 });
104
+ const result = await relaxed.getPrice("ETH");
105
+ expect(result.priceCents).toBe(350_000);
106
+ });
107
+ });