@wopr-network/platform-core 1.48.0 → 1.49.1
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/billing/crypto/__tests__/key-server.test.js +1 -1
- package/dist/billing/crypto/__tests__/unified-checkout.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/unified-checkout.test.js +63 -0
- package/dist/billing/crypto/__tests__/watcher-service.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/watcher-service.test.js +174 -0
- package/dist/billing/crypto/__tests__/webhook-confirmations.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/webhook-confirmations.test.js +304 -0
- package/dist/billing/crypto/btc/__tests__/settler.test.js +1 -0
- package/dist/billing/crypto/btc/__tests__/watcher.test.d.ts +1 -0
- package/dist/billing/crypto/btc/__tests__/watcher.test.js +170 -0
- package/dist/billing/crypto/btc/types.d.ts +3 -1
- package/dist/billing/crypto/btc/watcher.d.ts +6 -1
- package/dist/billing/crypto/btc/watcher.js +24 -8
- package/dist/billing/crypto/charge-store.d.ts +27 -2
- package/dist/billing/crypto/charge-store.js +67 -1
- package/dist/billing/crypto/charge-store.test.js +180 -1
- package/dist/billing/crypto/client.d.ts +2 -0
- package/dist/billing/crypto/cursor-store.d.ts +10 -3
- package/dist/billing/crypto/cursor-store.js +21 -1
- package/dist/billing/crypto/evm/__tests__/eth-checkout.test.js +3 -3
- package/dist/billing/crypto/evm/__tests__/eth-settler.test.js +2 -0
- package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +33 -6
- package/dist/billing/crypto/evm/__tests__/settler.test.js +2 -0
- package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.d.ts +1 -0
- package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.js +144 -0
- package/dist/billing/crypto/evm/__tests__/watcher.test.js +6 -2
- package/dist/billing/crypto/evm/eth-checkout.d.ts +2 -2
- package/dist/billing/crypto/evm/eth-checkout.js +3 -3
- package/dist/billing/crypto/evm/eth-watcher.d.ts +11 -8
- package/dist/billing/crypto/evm/eth-watcher.js +29 -15
- package/dist/billing/crypto/evm/types.d.ts +5 -1
- package/dist/billing/crypto/evm/watcher.d.ts +9 -1
- package/dist/billing/crypto/evm/watcher.js +36 -13
- package/dist/billing/crypto/index.d.ts +3 -3
- package/dist/billing/crypto/index.js +1 -1
- package/dist/billing/crypto/key-server-entry.js +7 -2
- package/dist/billing/crypto/key-server-webhook.d.ts +17 -4
- package/dist/billing/crypto/key-server-webhook.js +76 -15
- package/dist/billing/crypto/key-server.js +18 -7
- package/dist/billing/crypto/oracle/__tests__/chainlink.test.js +4 -4
- package/dist/billing/crypto/oracle/__tests__/coingecko.test.d.ts +1 -0
- package/dist/billing/crypto/oracle/__tests__/coingecko.test.js +65 -0
- package/dist/billing/crypto/oracle/__tests__/composite.test.d.ts +1 -0
- package/dist/billing/crypto/oracle/__tests__/composite.test.js +48 -0
- package/dist/billing/crypto/oracle/__tests__/convert.test.js +27 -17
- package/dist/billing/crypto/oracle/__tests__/fixed.test.js +5 -5
- package/dist/billing/crypto/oracle/chainlink.d.ts +2 -2
- package/dist/billing/crypto/oracle/chainlink.js +11 -10
- package/dist/billing/crypto/oracle/coingecko.d.ts +22 -0
- package/dist/billing/crypto/oracle/coingecko.js +67 -0
- package/dist/billing/crypto/oracle/composite.d.ts +14 -0
- package/dist/billing/crypto/oracle/composite.js +34 -0
- package/dist/billing/crypto/oracle/convert.d.ts +17 -7
- package/dist/billing/crypto/oracle/convert.js +26 -13
- package/dist/billing/crypto/oracle/fixed.d.ts +2 -2
- package/dist/billing/crypto/oracle/fixed.js +9 -7
- package/dist/billing/crypto/oracle/index.d.ts +4 -0
- package/dist/billing/crypto/oracle/index.js +3 -0
- package/dist/billing/crypto/oracle/types.d.ts +12 -3
- package/dist/billing/crypto/oracle/types.js +7 -1
- package/dist/billing/crypto/types.d.ts +16 -0
- package/dist/billing/crypto/unified-checkout.d.ts +10 -19
- package/dist/billing/crypto/unified-checkout.js +17 -131
- package/dist/billing/crypto/watcher-service.d.ts +22 -2
- package/dist/billing/crypto/watcher-service.js +71 -30
- package/dist/db/schema/crypto.d.ts +68 -0
- package/dist/db/schema/crypto.js +8 -0
- package/dist/monetization/crypto/__tests__/webhook.test.js +2 -1
- package/drizzle/migrations/0016_charge_progress_columns.sql +4 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/billing/crypto/__tests__/key-server.test.ts +1 -1
- package/src/billing/crypto/__tests__/unified-checkout.test.ts +83 -0
- package/src/billing/crypto/__tests__/watcher-service.test.ts +242 -0
- package/src/billing/crypto/__tests__/webhook-confirmations.test.ts +367 -0
- package/src/billing/crypto/btc/__tests__/settler.test.ts +1 -0
- package/src/billing/crypto/btc/__tests__/watcher.test.ts +201 -0
- package/src/billing/crypto/btc/types.ts +3 -1
- package/src/billing/crypto/btc/watcher.ts +26 -8
- package/src/billing/crypto/charge-store.test.ts +204 -1
- package/src/billing/crypto/charge-store.ts +86 -2
- package/src/billing/crypto/client.ts +2 -0
- package/src/billing/crypto/cursor-store.ts +31 -3
- package/src/billing/crypto/evm/__tests__/eth-checkout.test.ts +3 -3
- package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +2 -0
- package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +33 -6
- package/src/billing/crypto/evm/__tests__/settler.test.ts +2 -0
- package/src/billing/crypto/evm/__tests__/watcher-confirmations.test.ts +176 -0
- package/src/billing/crypto/evm/__tests__/watcher.test.ts +6 -2
- package/src/billing/crypto/evm/eth-checkout.ts +5 -5
- package/src/billing/crypto/evm/eth-watcher.ts +36 -16
- package/src/billing/crypto/evm/types.ts +5 -1
- package/src/billing/crypto/evm/watcher.ts +39 -13
- package/src/billing/crypto/index.ts +12 -3
- package/src/billing/crypto/key-server-entry.ts +7 -2
- package/src/billing/crypto/key-server-webhook.ts +92 -21
- package/src/billing/crypto/key-server.ts +17 -7
- package/src/billing/crypto/oracle/__tests__/chainlink.test.ts +4 -4
- package/src/billing/crypto/oracle/__tests__/coingecko.test.ts +75 -0
- package/src/billing/crypto/oracle/__tests__/composite.test.ts +61 -0
- package/src/billing/crypto/oracle/__tests__/convert.test.ts +29 -17
- package/src/billing/crypto/oracle/__tests__/fixed.test.ts +5 -5
- package/src/billing/crypto/oracle/chainlink.ts +11 -10
- package/src/billing/crypto/oracle/coingecko.ts +92 -0
- package/src/billing/crypto/oracle/composite.ts +35 -0
- package/src/billing/crypto/oracle/convert.ts +28 -13
- package/src/billing/crypto/oracle/fixed.ts +9 -7
- package/src/billing/crypto/oracle/index.ts +4 -0
- package/src/billing/crypto/oracle/types.ts +16 -3
- package/src/billing/crypto/types.ts +18 -0
- package/src/billing/crypto/unified-checkout.ts +22 -181
- package/src/billing/crypto/watcher-service.ts +85 -32
- package/src/db/schema/crypto.ts +8 -0
- package/src/monetization/crypto/__tests__/webhook.test.ts +2 -1
|
@@ -3,9 +3,10 @@ import { watcherCursors, watcherProcessed } from "../../db/schema/crypto.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* Persists watcher state to PostgreSQL.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* Three patterns:
|
|
7
7
|
* - Block cursor (EVM watchers): save/get cursor block number
|
|
8
8
|
* - Processed txids (BTC watcher): hasProcessedTx/markProcessedTx
|
|
9
|
+
* - Confirmation counts (all watchers): getConfirmationCount/saveConfirmationCount
|
|
9
10
|
*
|
|
10
11
|
* Eliminates all in-memory watcher state. Clean restart recovery.
|
|
11
12
|
*/
|
|
@@ -40,4 +41,23 @@ export class DrizzleWatcherCursorStore {
|
|
|
40
41
|
async markProcessedTx(watcherId, txId) {
|
|
41
42
|
await this.db.insert(watcherProcessed).values({ watcherId, txId }).onConflictDoNothing();
|
|
42
43
|
}
|
|
44
|
+
async getConfirmationCount(watcherId, txId) {
|
|
45
|
+
// Store confirmation counts as synthetic cursor entries: "watcherId:conf:txId" -> count
|
|
46
|
+
const key = `${watcherId}:conf:${txId}`;
|
|
47
|
+
const row = (await this.db
|
|
48
|
+
.select({ cursorBlock: watcherCursors.cursorBlock })
|
|
49
|
+
.from(watcherCursors)
|
|
50
|
+
.where(eq(watcherCursors.watcherId, key)))[0];
|
|
51
|
+
return row?.cursorBlock ?? null;
|
|
52
|
+
}
|
|
53
|
+
async saveConfirmationCount(watcherId, txId, count) {
|
|
54
|
+
const key = `${watcherId}:conf:${txId}`;
|
|
55
|
+
await this.db
|
|
56
|
+
.insert(watcherCursors)
|
|
57
|
+
.values({ watcherId: key, cursorBlock: count })
|
|
58
|
+
.onConflictDoUpdate({
|
|
59
|
+
target: watcherCursors.watcherId,
|
|
60
|
+
set: { cursorBlock: count, updatedAt: sql `(now())` },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
43
63
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createEthCheckout, MIN_ETH_USD } from "../eth-checkout.js";
|
|
3
|
-
const mockOracle = { getPrice: vi.fn().mockResolvedValue({
|
|
3
|
+
const mockOracle = { getPrice: vi.fn().mockResolvedValue({ priceMicros: 3_500_000_000, updatedAt: new Date() }) };
|
|
4
4
|
function makeDeps(derivationIndex = 0) {
|
|
5
5
|
return {
|
|
6
6
|
chargeStore: {
|
|
@@ -16,9 +16,9 @@ describe("createEthCheckout", () => {
|
|
|
16
16
|
const deps = makeDeps();
|
|
17
17
|
const result = await createEthCheckout(deps, { tenant: "t1", amountUsd: 50, chain: "base" });
|
|
18
18
|
expect(result.amountUsd).toBe(50);
|
|
19
|
-
expect(result.
|
|
19
|
+
expect(result.priceMicros).toBe(3_500_000_000);
|
|
20
20
|
expect(result.chain).toBe("base");
|
|
21
|
-
// $50 = 5000 cents
|
|
21
|
+
// $50 = 5000 cents × 10000 micros/cent × 10^18 / 3_500_000_000 micros = 14285714285714285n
|
|
22
22
|
expect(result.expectedWei).toBe("14285714285714285");
|
|
23
23
|
expect(result.depositAddress).toMatch(/^0x/);
|
|
24
24
|
expect(result.referenceId).toMatch(/^eth:base:0x/);
|
|
@@ -3,12 +3,13 @@ import { EthWatcher } from "../eth-watcher.js";
|
|
|
3
3
|
function makeRpc(responses) {
|
|
4
4
|
return vi.fn(async (method) => responses[method]);
|
|
5
5
|
}
|
|
6
|
-
const mockOracle = { getPrice: vi.fn().mockResolvedValue({
|
|
6
|
+
const mockOracle = { getPrice: vi.fn().mockResolvedValue({ priceMicros: 3_500_000_000, updatedAt: new Date() }) };
|
|
7
7
|
describe("EthWatcher", () => {
|
|
8
8
|
it("detects native ETH transfer to watched address", async () => {
|
|
9
9
|
const onPayment = vi.fn();
|
|
10
|
+
// latest = 0xa (10), fromBlock = 10 → scans exactly block 10
|
|
10
11
|
const rpc = makeRpc({
|
|
11
|
-
eth_blockNumber: "
|
|
12
|
+
eth_blockNumber: "0xa",
|
|
12
13
|
eth_getBlockByNumber: {
|
|
13
14
|
transactions: [
|
|
14
15
|
{
|
|
@@ -34,8 +35,10 @@ describe("EthWatcher", () => {
|
|
|
34
35
|
const event = onPayment.mock.calls[0][0];
|
|
35
36
|
expect(event.to).toBe("0xdeposit");
|
|
36
37
|
expect(event.valueWei).toBe("1000000000000000000");
|
|
37
|
-
expect(event.amountUsdCents).toBe(350_000); // 1 ETH × $3,500
|
|
38
|
+
expect(event.amountUsdCents).toBe(350_000); // 1 ETH × $3,500 = $3,500 = 350,000 cents
|
|
38
39
|
expect(event.txHash).toBe("0xabc");
|
|
40
|
+
expect(event.confirmations).toBe(0);
|
|
41
|
+
expect(event.confirmationsRequired).toBe(1);
|
|
39
42
|
});
|
|
40
43
|
it("skips transactions not to watched addresses", async () => {
|
|
41
44
|
const onPayment = vi.fn();
|
|
@@ -77,6 +80,19 @@ describe("EthWatcher", () => {
|
|
|
77
80
|
});
|
|
78
81
|
it("does not double-process same txid", async () => {
|
|
79
82
|
const onPayment = vi.fn();
|
|
83
|
+
const confirmations = new Map();
|
|
84
|
+
const cursorStore = {
|
|
85
|
+
get: vi.fn().mockResolvedValue(null),
|
|
86
|
+
save: vi.fn().mockResolvedValue(undefined),
|
|
87
|
+
hasProcessedTx: vi.fn().mockResolvedValue(false),
|
|
88
|
+
markProcessedTx: vi.fn().mockResolvedValue(undefined),
|
|
89
|
+
getConfirmationCount: vi
|
|
90
|
+
.fn()
|
|
91
|
+
.mockImplementation(async (_, txId) => confirmations.get(txId) ?? null),
|
|
92
|
+
saveConfirmationCount: vi.fn().mockImplementation(async (_, txId, count) => {
|
|
93
|
+
confirmations.set(txId, count);
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
80
96
|
const rpc = makeRpc({
|
|
81
97
|
eth_blockNumber: "0xb",
|
|
82
98
|
eth_getBlockByNumber: {
|
|
@@ -90,9 +106,10 @@ describe("EthWatcher", () => {
|
|
|
90
106
|
fromBlock: 10,
|
|
91
107
|
onPayment,
|
|
92
108
|
watchedAddresses: ["0xDeposit"],
|
|
109
|
+
cursorStore,
|
|
93
110
|
});
|
|
94
111
|
await watcher.poll();
|
|
95
|
-
//
|
|
112
|
+
// Second poll — same block, same confirmations → no duplicate emission
|
|
96
113
|
await watcher.poll();
|
|
97
114
|
expect(onPayment).toHaveBeenCalledOnce();
|
|
98
115
|
});
|
|
@@ -112,8 +129,17 @@ describe("EthWatcher", () => {
|
|
|
112
129
|
});
|
|
113
130
|
it("does not mark txid as processed if onPayment throws", async () => {
|
|
114
131
|
const onPayment = vi.fn().mockRejectedValueOnce(new Error("db fail")).mockResolvedValueOnce(undefined);
|
|
132
|
+
const cursorStore = {
|
|
133
|
+
get: vi.fn().mockResolvedValue(null),
|
|
134
|
+
save: vi.fn().mockResolvedValue(undefined),
|
|
135
|
+
hasProcessedTx: vi.fn().mockResolvedValue(false),
|
|
136
|
+
markProcessedTx: vi.fn().mockResolvedValue(undefined),
|
|
137
|
+
getConfirmationCount: vi.fn().mockResolvedValue(null),
|
|
138
|
+
saveConfirmationCount: vi.fn().mockResolvedValue(undefined),
|
|
139
|
+
};
|
|
140
|
+
// latest = 0xa (10) = fromBlock → exactly one block to scan
|
|
115
141
|
const rpc = makeRpc({
|
|
116
|
-
eth_blockNumber: "
|
|
142
|
+
eth_blockNumber: "0xa",
|
|
117
143
|
eth_getBlockByNumber: {
|
|
118
144
|
transactions: [{ hash: "0xabc", from: "0xa", to: "0xdeposit", value: "0xDE0B6B3A7640000", blockNumber: "0xa" }],
|
|
119
145
|
},
|
|
@@ -125,9 +151,10 @@ describe("EthWatcher", () => {
|
|
|
125
151
|
fromBlock: 10,
|
|
126
152
|
onPayment,
|
|
127
153
|
watchedAddresses: ["0xDeposit"],
|
|
154
|
+
cursorStore,
|
|
128
155
|
});
|
|
129
156
|
await expect(watcher.poll()).rejects.toThrow("db fail");
|
|
130
|
-
// Retry — should process the same tx again since
|
|
157
|
+
// Retry — should process the same tx again since confirmationCount wasn't saved (error before save)
|
|
131
158
|
await watcher.poll();
|
|
132
159
|
expect(onPayment).toHaveBeenCalledTimes(2);
|
|
133
160
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { EvmWatcher } from "../watcher.js";
|
|
3
|
+
const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
|
4
|
+
function mockTransferLog(to, amount, blockNumber) {
|
|
5
|
+
return {
|
|
6
|
+
address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
7
|
+
topics: [
|
|
8
|
+
TRANSFER_TOPIC,
|
|
9
|
+
`0x${"00".repeat(12)}${"ab".repeat(20)}`,
|
|
10
|
+
`0x${"00".repeat(12)}${to.slice(2).toLowerCase()}`,
|
|
11
|
+
],
|
|
12
|
+
data: `0x${amount.toString(16).padStart(64, "0")}`,
|
|
13
|
+
blockNumber: `0x${blockNumber.toString(16)}`,
|
|
14
|
+
transactionHash: `0x${"ff".repeat(32)}`,
|
|
15
|
+
logIndex: "0x0",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function makeCursorStore() {
|
|
19
|
+
const cursors = new Map();
|
|
20
|
+
return {
|
|
21
|
+
get: vi.fn().mockImplementation(async (id) => cursors.get(id) ?? null),
|
|
22
|
+
save: vi.fn().mockImplementation(async (id, val) => {
|
|
23
|
+
cursors.set(id, val);
|
|
24
|
+
}),
|
|
25
|
+
hasProcessedTx: vi.fn().mockResolvedValue(false),
|
|
26
|
+
markProcessedTx: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
getConfirmationCount: vi.fn().mockResolvedValue(null),
|
|
28
|
+
saveConfirmationCount: vi.fn().mockResolvedValue(undefined),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
describe("EvmWatcher — intermediate confirmations", () => {
|
|
32
|
+
it("emits events with confirmation count", async () => {
|
|
33
|
+
const toAddr = `0x${"cc".repeat(20)}`;
|
|
34
|
+
const events = [];
|
|
35
|
+
// Base has confirmations: 1. Latest block is 105. Log at block 103 -> 2 confirmations.
|
|
36
|
+
const mockRpc = vi
|
|
37
|
+
.fn()
|
|
38
|
+
.mockResolvedValueOnce(`0x${(105).toString(16)}`) // eth_blockNumber
|
|
39
|
+
.mockResolvedValueOnce([mockTransferLog(toAddr, 10000000n, 103)]); // eth_getLogs
|
|
40
|
+
const watcher = new EvmWatcher({
|
|
41
|
+
chain: "base",
|
|
42
|
+
token: "USDC",
|
|
43
|
+
rpcCall: mockRpc,
|
|
44
|
+
fromBlock: 100,
|
|
45
|
+
watchedAddresses: [toAddr],
|
|
46
|
+
cursorStore: makeCursorStore(),
|
|
47
|
+
onPayment: (evt) => {
|
|
48
|
+
events.push(evt);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
await watcher.poll();
|
|
52
|
+
expect(events).toHaveLength(1);
|
|
53
|
+
expect(events[0].confirmations).toBe(2); // 105 - 103
|
|
54
|
+
expect(events[0].confirmationsRequired).toBe(1); // Base chain config
|
|
55
|
+
});
|
|
56
|
+
it("skips event when confirmation count unchanged", async () => {
|
|
57
|
+
const toAddr = `0x${"cc".repeat(20)}`;
|
|
58
|
+
const events = [];
|
|
59
|
+
const cursorStore = makeCursorStore();
|
|
60
|
+
cursorStore.getConfirmationCount.mockResolvedValue(2);
|
|
61
|
+
const mockRpc = vi
|
|
62
|
+
.fn()
|
|
63
|
+
.mockResolvedValueOnce(`0x${(105).toString(16)}`)
|
|
64
|
+
.mockResolvedValueOnce([mockTransferLog(toAddr, 10000000n, 103)]);
|
|
65
|
+
const watcher = new EvmWatcher({
|
|
66
|
+
chain: "base",
|
|
67
|
+
token: "USDC",
|
|
68
|
+
rpcCall: mockRpc,
|
|
69
|
+
fromBlock: 100,
|
|
70
|
+
watchedAddresses: [toAddr],
|
|
71
|
+
cursorStore,
|
|
72
|
+
onPayment: (evt) => {
|
|
73
|
+
events.push(evt);
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
await watcher.poll();
|
|
77
|
+
expect(events).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
it("re-emits when confirmations increase", async () => {
|
|
80
|
+
const toAddr = `0x${"cc".repeat(20)}`;
|
|
81
|
+
const events = [];
|
|
82
|
+
const cursorStore = makeCursorStore();
|
|
83
|
+
cursorStore.getConfirmationCount.mockResolvedValue(1);
|
|
84
|
+
const mockRpc = vi
|
|
85
|
+
.fn()
|
|
86
|
+
.mockResolvedValueOnce(`0x${(105).toString(16)}`)
|
|
87
|
+
.mockResolvedValueOnce([mockTransferLog(toAddr, 10000000n, 103)]);
|
|
88
|
+
const watcher = new EvmWatcher({
|
|
89
|
+
chain: "base",
|
|
90
|
+
token: "USDC",
|
|
91
|
+
rpcCall: mockRpc,
|
|
92
|
+
fromBlock: 100,
|
|
93
|
+
watchedAddresses: [toAddr],
|
|
94
|
+
cursorStore,
|
|
95
|
+
onPayment: (evt) => {
|
|
96
|
+
events.push(evt);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
await watcher.poll();
|
|
100
|
+
expect(events).toHaveLength(1);
|
|
101
|
+
expect(events[0].confirmations).toBe(2);
|
|
102
|
+
});
|
|
103
|
+
it("includes confirmationsRequired from chain config", async () => {
|
|
104
|
+
const toAddr = `0x${"cc".repeat(20)}`;
|
|
105
|
+
const events = [];
|
|
106
|
+
const mockRpc = vi
|
|
107
|
+
.fn()
|
|
108
|
+
.mockResolvedValueOnce(`0x${(110).toString(16)}`)
|
|
109
|
+
.mockResolvedValueOnce([mockTransferLog(toAddr, 10000000n, 105)]);
|
|
110
|
+
const watcher = new EvmWatcher({
|
|
111
|
+
chain: "base",
|
|
112
|
+
token: "USDC",
|
|
113
|
+
rpcCall: mockRpc,
|
|
114
|
+
fromBlock: 100,
|
|
115
|
+
watchedAddresses: [toAddr],
|
|
116
|
+
cursorStore: makeCursorStore(),
|
|
117
|
+
onPayment: (evt) => {
|
|
118
|
+
events.push(evt);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
await watcher.poll();
|
|
122
|
+
expect(events).toHaveLength(1);
|
|
123
|
+
expect(events[0].confirmationsRequired).toBe(1); // Base chain config
|
|
124
|
+
});
|
|
125
|
+
it("saves confirmation count after emitting", async () => {
|
|
126
|
+
const toAddr = `0x${"cc".repeat(20)}`;
|
|
127
|
+
const cursorStore = makeCursorStore();
|
|
128
|
+
const mockRpc = vi
|
|
129
|
+
.fn()
|
|
130
|
+
.mockResolvedValueOnce(`0x${(105).toString(16)}`)
|
|
131
|
+
.mockResolvedValueOnce([mockTransferLog(toAddr, 10000000n, 103)]);
|
|
132
|
+
const watcher = new EvmWatcher({
|
|
133
|
+
chain: "base",
|
|
134
|
+
token: "USDC",
|
|
135
|
+
rpcCall: mockRpc,
|
|
136
|
+
fromBlock: 100,
|
|
137
|
+
watchedAddresses: [toAddr],
|
|
138
|
+
cursorStore,
|
|
139
|
+
onPayment: () => { },
|
|
140
|
+
});
|
|
141
|
+
await watcher.poll();
|
|
142
|
+
expect(cursorStore.saveConfirmationCount).toHaveBeenCalledWith(expect.any(String), expect.stringContaining("0x"), 2);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -56,7 +56,12 @@ describe("EvmWatcher", () => {
|
|
|
56
56
|
});
|
|
57
57
|
it("skips blocks not yet confirmed", async () => {
|
|
58
58
|
const events = [];
|
|
59
|
-
|
|
59
|
+
// latest = 50, cursor = 50 → latest < cursor is false, but range is empty (50..50)
|
|
60
|
+
// With intermediate confirmations, we still scan the range but find no logs
|
|
61
|
+
const mockRpc = vi
|
|
62
|
+
.fn()
|
|
63
|
+
.mockResolvedValueOnce(`0x${(50).toString(16)}`) // eth_blockNumber
|
|
64
|
+
.mockResolvedValueOnce([]); // eth_getLogs (empty)
|
|
60
65
|
const watcher = new EvmWatcher({
|
|
61
66
|
chain: "base",
|
|
62
67
|
token: "USDC",
|
|
@@ -69,7 +74,6 @@ describe("EvmWatcher", () => {
|
|
|
69
74
|
});
|
|
70
75
|
await watcher.poll();
|
|
71
76
|
expect(events).toHaveLength(0);
|
|
72
|
-
expect(mockRpc).toHaveBeenCalledTimes(1);
|
|
73
77
|
});
|
|
74
78
|
it("processes multiple logs in one poll", async () => {
|
|
75
79
|
const addr1 = `0x${"aa".repeat(20)}`;
|
|
@@ -17,8 +17,8 @@ export interface EthCheckoutResult {
|
|
|
17
17
|
amountUsd: number;
|
|
18
18
|
/** Expected ETH amount in wei (BigInt as string). */
|
|
19
19
|
expectedWei: string;
|
|
20
|
-
/** ETH price in
|
|
21
|
-
|
|
20
|
+
/** ETH price in microdollars at checkout time (10^-6 USD). */
|
|
21
|
+
priceMicros: number;
|
|
22
22
|
chain: EvmChain;
|
|
23
23
|
referenceId: string;
|
|
24
24
|
}
|
|
@@ -16,8 +16,8 @@ export async function createEthCheckout(deps, opts) {
|
|
|
16
16
|
throw new Error(`Minimum payment amount is $${MIN_ETH_USD}`);
|
|
17
17
|
}
|
|
18
18
|
const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
|
|
19
|
-
const {
|
|
20
|
-
const expectedWei = centsToNative(amountUsdCents,
|
|
19
|
+
const { priceMicros } = await deps.oracle.getPrice("ETH");
|
|
20
|
+
const expectedWei = centsToNative(amountUsdCents, priceMicros, 18);
|
|
21
21
|
const maxRetries = 3;
|
|
22
22
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
23
23
|
const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
|
|
@@ -37,7 +37,7 @@ export async function createEthCheckout(deps, opts) {
|
|
|
37
37
|
depositAddress,
|
|
38
38
|
amountUsd: opts.amountUsd,
|
|
39
39
|
expectedWei: expectedWei.toString(),
|
|
40
|
-
|
|
40
|
+
priceMicros,
|
|
41
41
|
chain: opts.chain,
|
|
42
42
|
referenceId,
|
|
43
43
|
};
|
|
@@ -2,7 +2,7 @@ import type { IWatcherCursorStore } from "../cursor-store.js";
|
|
|
2
2
|
import type { IPriceOracle } from "../oracle/types.js";
|
|
3
3
|
import type { EvmChain } from "./types.js";
|
|
4
4
|
type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
|
|
5
|
-
/** Event emitted
|
|
5
|
+
/** Event emitted on each confirmation increment for a native ETH deposit. */
|
|
6
6
|
export interface EthPaymentEvent {
|
|
7
7
|
readonly chain: EvmChain;
|
|
8
8
|
readonly from: string;
|
|
@@ -13,6 +13,10 @@ export interface EthPaymentEvent {
|
|
|
13
13
|
readonly amountUsdCents: number;
|
|
14
14
|
readonly txHash: string;
|
|
15
15
|
readonly blockNumber: number;
|
|
16
|
+
/** Current confirmation count (latest block - tx block). */
|
|
17
|
+
readonly confirmations: number;
|
|
18
|
+
/** Required confirmations for this chain. */
|
|
19
|
+
readonly confirmationsRequired: number;
|
|
16
20
|
}
|
|
17
21
|
export interface EthWatcherOpts {
|
|
18
22
|
chain: EvmChain;
|
|
@@ -30,9 +34,9 @@ export interface EthWatcherOpts {
|
|
|
30
34
|
* this scans blocks for transactions where `to` matches a watched deposit
|
|
31
35
|
* address and `value > 0`.
|
|
32
36
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
37
|
+
* Scans up to latest block (not just confirmed) to detect pending txs.
|
|
38
|
+
* Emits events on each confirmation increment. Only advances cursor
|
|
39
|
+
* past fully-confirmed blocks.
|
|
36
40
|
*/
|
|
37
41
|
export declare class EthWatcher {
|
|
38
42
|
private _cursor;
|
|
@@ -50,11 +54,10 @@ export declare class EthWatcher {
|
|
|
50
54
|
setWatchedAddresses(addresses: string[]): void;
|
|
51
55
|
get cursor(): number;
|
|
52
56
|
/**
|
|
53
|
-
* Poll for
|
|
57
|
+
* Poll for native ETH transfers to watched addresses, including unconfirmed blocks.
|
|
54
58
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* the cursor hasn't advanced — the entire block is retried on next poll.
|
|
59
|
+
* Scans from cursor to latest block. Emits events with current confirmation count.
|
|
60
|
+
* Re-emits on each confirmation increment. Only advances cursor past fully-confirmed blocks.
|
|
58
61
|
*/
|
|
59
62
|
poll(): Promise<void>;
|
|
60
63
|
}
|
|
@@ -7,9 +7,9 @@ import { getChainConfig } from "./config.js";
|
|
|
7
7
|
* this scans blocks for transactions where `to` matches a watched deposit
|
|
8
8
|
* address and `value > 0`.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Scans up to latest block (not just confirmed) to detect pending txs.
|
|
11
|
+
* Emits events on each confirmation increment. Only advances cursor
|
|
12
|
+
* past fully-confirmed blocks.
|
|
13
13
|
*/
|
|
14
14
|
export class EthWatcher {
|
|
15
15
|
_cursor;
|
|
@@ -47,11 +47,10 @@ export class EthWatcher {
|
|
|
47
47
|
return this._cursor;
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
50
|
-
* Poll for
|
|
50
|
+
* Poll for native ETH transfers to watched addresses, including unconfirmed blocks.
|
|
51
51
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* the cursor hasn't advanced — the entire block is retried on next poll.
|
|
52
|
+
* Scans from cursor to latest block. Emits events with current confirmation count.
|
|
53
|
+
* Re-emits on each confirmation increment. Only advances cursor past fully-confirmed blocks.
|
|
55
54
|
*/
|
|
56
55
|
async poll() {
|
|
57
56
|
if (this._watchedAddresses.size === 0)
|
|
@@ -59,13 +58,15 @@ export class EthWatcher {
|
|
|
59
58
|
const latestHex = (await this.rpc("eth_blockNumber", []));
|
|
60
59
|
const latest = Number.parseInt(latestHex, 16);
|
|
61
60
|
const confirmed = latest - this.confirmations;
|
|
62
|
-
if (
|
|
61
|
+
if (latest < this._cursor)
|
|
63
62
|
return;
|
|
64
|
-
const {
|
|
65
|
-
|
|
63
|
+
const { priceMicros } = await this.oracle.getPrice("ETH");
|
|
64
|
+
// Scan up to latest (not just confirmed) to detect pending txs
|
|
65
|
+
for (let blockNum = this._cursor; blockNum <= latest; blockNum++) {
|
|
66
66
|
const block = (await this.rpc("eth_getBlockByNumber", [`0x${blockNum.toString(16)}`, true]));
|
|
67
67
|
if (!block)
|
|
68
68
|
continue;
|
|
69
|
+
const confs = latest - blockNum;
|
|
69
70
|
for (const tx of block.transactions) {
|
|
70
71
|
if (!tx.to)
|
|
71
72
|
continue;
|
|
@@ -75,7 +76,13 @@ export class EthWatcher {
|
|
|
75
76
|
const valueWei = BigInt(tx.value);
|
|
76
77
|
if (valueWei === 0n)
|
|
77
78
|
continue;
|
|
78
|
-
|
|
79
|
+
// Skip if we already emitted at this confirmation count
|
|
80
|
+
if (this.cursorStore) {
|
|
81
|
+
const lastConf = await this.cursorStore.getConfirmationCount(this.watcherId, tx.hash);
|
|
82
|
+
if (lastConf !== null && confs <= lastConf)
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const amountUsdCents = nativeToCents(valueWei, priceMicros, 18);
|
|
79
86
|
const event = {
|
|
80
87
|
chain: this.chain,
|
|
81
88
|
from: tx.from.toLowerCase(),
|
|
@@ -84,13 +91,20 @@ export class EthWatcher {
|
|
|
84
91
|
amountUsdCents,
|
|
85
92
|
txHash: tx.hash,
|
|
86
93
|
blockNumber: blockNum,
|
|
94
|
+
confirmations: confs,
|
|
95
|
+
confirmationsRequired: this.confirmations,
|
|
87
96
|
};
|
|
88
97
|
await this.onPayment(event);
|
|
98
|
+
if (this.cursorStore) {
|
|
99
|
+
await this.cursorStore.saveConfirmationCount(this.watcherId, tx.hash, confs);
|
|
100
|
+
}
|
|
89
101
|
}
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
102
|
+
// Only advance cursor past fully-confirmed blocks
|
|
103
|
+
if (blockNum <= confirmed) {
|
|
104
|
+
this._cursor = blockNum + 1;
|
|
105
|
+
if (this.cursorStore) {
|
|
106
|
+
await this.cursorStore.save(this.watcherId, this._cursor);
|
|
107
|
+
}
|
|
94
108
|
}
|
|
95
109
|
}
|
|
96
110
|
}
|
|
@@ -17,7 +17,7 @@ export interface TokenConfig {
|
|
|
17
17
|
readonly contractAddress: `0x${string}`;
|
|
18
18
|
readonly decimals: number;
|
|
19
19
|
}
|
|
20
|
-
/** Event emitted
|
|
20
|
+
/** Event emitted on each confirmation increment for a Transfer. */
|
|
21
21
|
export interface EvmPaymentEvent {
|
|
22
22
|
readonly chain: EvmChain;
|
|
23
23
|
readonly token: StablecoinToken;
|
|
@@ -30,6 +30,10 @@ export interface EvmPaymentEvent {
|
|
|
30
30
|
readonly txHash: string;
|
|
31
31
|
readonly blockNumber: number;
|
|
32
32
|
readonly logIndex: number;
|
|
33
|
+
/** Current confirmation count (latest block - tx block). */
|
|
34
|
+
readonly confirmations: number;
|
|
35
|
+
/** Required confirmations for this chain. */
|
|
36
|
+
readonly confirmationsRequired: number;
|
|
33
37
|
}
|
|
34
38
|
/** Options for creating a stablecoin checkout. */
|
|
35
39
|
export interface StablecoinCheckoutOpts {
|
|
@@ -29,7 +29,15 @@ export declare class EvmWatcher {
|
|
|
29
29
|
/** Update the set of watched deposit addresses (e.g. after a new checkout). */
|
|
30
30
|
setWatchedAddresses(addresses: string[]): void;
|
|
31
31
|
get cursor(): number;
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Poll for Transfer events, including pending (unconfirmed) blocks.
|
|
34
|
+
*
|
|
35
|
+
* Two-phase scan:
|
|
36
|
+
* 1. Scan cursor..latest for new/updated txs, emit with current confirmation count
|
|
37
|
+
* 2. Re-check pending txs automatically since cursor doesn't advance past unconfirmed blocks
|
|
38
|
+
*
|
|
39
|
+
* Cursor only advances past fully-confirmed blocks.
|
|
40
|
+
*/
|
|
33
41
|
poll(): Promise<void>;
|
|
34
42
|
}
|
|
35
43
|
/** Create an RPC caller for a given URL (plain JSON-RPC over fetch). */
|
|
@@ -42,31 +42,37 @@ export class EvmWatcher {
|
|
|
42
42
|
get cursor() {
|
|
43
43
|
return this._cursor;
|
|
44
44
|
}
|
|
45
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Poll for Transfer events, including pending (unconfirmed) blocks.
|
|
47
|
+
*
|
|
48
|
+
* Two-phase scan:
|
|
49
|
+
* 1. Scan cursor..latest for new/updated txs, emit with current confirmation count
|
|
50
|
+
* 2. Re-check pending txs automatically since cursor doesn't advance past unconfirmed blocks
|
|
51
|
+
*
|
|
52
|
+
* Cursor only advances past fully-confirmed blocks.
|
|
53
|
+
*/
|
|
46
54
|
async poll() {
|
|
47
55
|
if (this._watchedAddresses.length === 0)
|
|
48
56
|
return; // nothing to watch
|
|
49
57
|
const latestHex = (await this.rpc("eth_blockNumber", []));
|
|
50
58
|
const latest = Number.parseInt(latestHex, 16);
|
|
51
59
|
const confirmed = latest - this.confirmations;
|
|
52
|
-
if (
|
|
60
|
+
if (latest < this._cursor)
|
|
53
61
|
return;
|
|
54
62
|
// Filter by topic[2] (to address) when watched addresses are set.
|
|
55
|
-
// This avoids fetching ALL USDC transfers on the chain (millions/day on Base).
|
|
56
|
-
// topic[2] values are 32-byte zero-padded: 0x000000000000000000000000<address>
|
|
57
63
|
const toFilter = this._watchedAddresses.length > 0
|
|
58
64
|
? this._watchedAddresses.map((a) => `0x000000000000000000000000${a.slice(2)}`)
|
|
59
65
|
: null;
|
|
66
|
+
// Scan from cursor to latest (not just confirmed) to detect pending txs
|
|
60
67
|
const logs = (await this.rpc("eth_getLogs", [
|
|
61
68
|
{
|
|
62
69
|
address: this.contractAddress,
|
|
63
70
|
topics: [TRANSFER_TOPIC, null, toFilter],
|
|
64
71
|
fromBlock: `0x${this._cursor.toString(16)}`,
|
|
65
|
-
toBlock: `0x${
|
|
72
|
+
toBlock: `0x${latest.toString(16)}`,
|
|
66
73
|
},
|
|
67
74
|
]));
|
|
68
|
-
// Group logs by block
|
|
69
|
-
// If onPayment fails mid-batch, only the current block is replayed on next poll.
|
|
75
|
+
// Group logs by block
|
|
70
76
|
const logsByBlock = new Map();
|
|
71
77
|
for (const log of logs) {
|
|
72
78
|
const bn = Number.parseInt(log.blockNumber, 16);
|
|
@@ -76,10 +82,18 @@ export class EvmWatcher {
|
|
|
76
82
|
else
|
|
77
83
|
logsByBlock.set(bn, [log]);
|
|
78
84
|
}
|
|
79
|
-
// Process blocks
|
|
85
|
+
// Process all blocks (including unconfirmed), emit with confirmation count
|
|
80
86
|
const blockNums = [...logsByBlock.keys()].sort((a, b) => a - b);
|
|
81
87
|
for (const blockNum of blockNums) {
|
|
88
|
+
const confs = latest - blockNum;
|
|
82
89
|
for (const log of logsByBlock.get(blockNum) ?? []) {
|
|
90
|
+
const txKey = `${log.transactionHash}:${log.logIndex}`;
|
|
91
|
+
// Skip if we already emitted at this confirmation count
|
|
92
|
+
if (this.cursorStore) {
|
|
93
|
+
const lastConf = await this.cursorStore.getConfirmationCount(this.watcherId, txKey);
|
|
94
|
+
if (lastConf !== null && confs <= lastConf)
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
83
97
|
const to = `0x${log.topics[2].slice(26)}`.toLowerCase();
|
|
84
98
|
const from = `0x${log.topics[1].slice(26)}`.toLowerCase();
|
|
85
99
|
const rawAmount = BigInt(log.data);
|
|
@@ -94,16 +108,25 @@ export class EvmWatcher {
|
|
|
94
108
|
txHash: log.transactionHash,
|
|
95
109
|
blockNumber: blockNum,
|
|
96
110
|
logIndex: Number.parseInt(log.logIndex, 16),
|
|
111
|
+
confirmations: confs,
|
|
112
|
+
confirmationsRequired: this.confirmations,
|
|
97
113
|
};
|
|
98
114
|
await this.onPayment(event);
|
|
115
|
+
// Track confirmation count
|
|
116
|
+
if (this.cursorStore) {
|
|
117
|
+
await this.cursorStore.saveConfirmationCount(this.watcherId, txKey, confs);
|
|
118
|
+
}
|
|
99
119
|
}
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
|
|
120
|
+
// Only advance cursor past fully-confirmed blocks
|
|
121
|
+
if (blockNum <= confirmed) {
|
|
122
|
+
this._cursor = blockNum + 1;
|
|
123
|
+
if (this.cursorStore) {
|
|
124
|
+
await this.cursorStore.save(this.watcherId, this._cursor);
|
|
125
|
+
}
|
|
103
126
|
}
|
|
104
127
|
}
|
|
105
|
-
// Advance cursor
|
|
106
|
-
if (blockNums.length === 0) {
|
|
128
|
+
// Advance cursor if no logs found but confirmed blocks exist
|
|
129
|
+
if (blockNums.length === 0 && confirmed >= this._cursor) {
|
|
107
130
|
this._cursor = confirmed + 1;
|
|
108
131
|
if (this.cursorStore) {
|
|
109
132
|
await this.cursorStore.save(this.watcherId, this._cursor);
|