@wopr-network/platform-core 1.49.4 → 1.50.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.
- package/dist/billing/crypto/__tests__/key-server.test.js +4 -0
- package/dist/billing/crypto/charge-store.d.ts +2 -0
- package/dist/billing/crypto/charge-store.js +6 -2
- package/dist/billing/crypto/client.d.ts +1 -0
- package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +6 -0
- package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.js +15 -0
- package/dist/billing/crypto/evm/__tests__/watcher.test.js +18 -0
- package/dist/billing/crypto/evm/eth-watcher.d.ts +2 -0
- package/dist/billing/crypto/evm/eth-watcher.js +1 -2
- package/dist/billing/crypto/evm/types.d.ts +1 -1
- package/dist/billing/crypto/evm/watcher.d.ts +6 -0
- package/dist/billing/crypto/evm/watcher.js +4 -6
- package/dist/billing/crypto/key-server.js +16 -1
- package/dist/billing/crypto/payment-method-store.d.ts +12 -0
- package/dist/billing/crypto/payment-method-store.js +16 -0
- package/dist/billing/crypto/watcher-service.js +78 -4
- package/dist/db/schema/crypto.d.ts +17 -0
- package/dist/db/schema/crypto.js +1 -0
- package/drizzle/migrations/0019_icon_url_column.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 +4 -0
- package/src/billing/crypto/charge-store.ts +11 -4
- package/src/billing/crypto/client.ts +1 -0
- package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +6 -0
- package/src/billing/crypto/evm/__tests__/watcher-confirmations.test.ts +15 -0
- package/src/billing/crypto/evm/__tests__/watcher.test.ts +18 -0
- package/src/billing/crypto/evm/eth-watcher.ts +3 -2
- package/src/billing/crypto/evm/types.ts +1 -1
- package/src/billing/crypto/evm/watcher.ts +10 -6
- package/src/billing/crypto/key-server.ts +24 -1
- package/src/billing/crypto/payment-method-store.ts +24 -0
- package/src/billing/crypto/watcher-service.ts +92 -4
- package/src/db/schema/crypto.ts +1 -0
|
@@ -67,6 +67,7 @@ function mockDeps() {
|
|
|
67
67
|
displayName: "Bitcoin",
|
|
68
68
|
contractAddress: null,
|
|
69
69
|
confirmations: 6,
|
|
70
|
+
iconUrl: null,
|
|
70
71
|
},
|
|
71
72
|
{
|
|
72
73
|
id: "base-usdc",
|
|
@@ -76,6 +77,7 @@ function mockDeps() {
|
|
|
76
77
|
displayName: "USDC on Base",
|
|
77
78
|
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
78
79
|
confirmations: 12,
|
|
80
|
+
iconUrl: null,
|
|
79
81
|
},
|
|
80
82
|
]),
|
|
81
83
|
listAll: vi.fn(),
|
|
@@ -91,12 +93,14 @@ function mockDeps() {
|
|
|
91
93
|
oracleAddress: "0x64c911996D3c6aC71f9b455B1E8E7266BcbD848F",
|
|
92
94
|
xpub: null,
|
|
93
95
|
displayOrder: 0,
|
|
96
|
+
iconUrl: null,
|
|
94
97
|
enabled: true,
|
|
95
98
|
rpcUrl: null,
|
|
96
99
|
}),
|
|
97
100
|
listByType: vi.fn(),
|
|
98
101
|
upsert: vi.fn().mockResolvedValue(undefined),
|
|
99
102
|
setEnabled: vi.fn().mockResolvedValue(undefined),
|
|
103
|
+
patchMetadata: vi.fn().mockResolvedValue(true),
|
|
100
104
|
};
|
|
101
105
|
return {
|
|
102
106
|
db: createMockDb(),
|
|
@@ -59,6 +59,7 @@ export interface ICryptoChargeRepository {
|
|
|
59
59
|
listActiveDepositAddresses(): Promise<{
|
|
60
60
|
chain: string;
|
|
61
61
|
address: string;
|
|
62
|
+
token: string;
|
|
62
63
|
}[]>;
|
|
63
64
|
}
|
|
64
65
|
/**
|
|
@@ -102,6 +103,7 @@ export declare class DrizzleCryptoChargeRepository implements ICryptoChargeRepos
|
|
|
102
103
|
listActiveDepositAddresses(): Promise<{
|
|
103
104
|
chain: string;
|
|
104
105
|
address: string;
|
|
106
|
+
token: string;
|
|
105
107
|
}[]>;
|
|
106
108
|
/** Get the next available HD derivation index (max + 1, or 0 if empty). */
|
|
107
109
|
getNextDerivationIndex(): Promise<number>;
|
|
@@ -173,10 +173,14 @@ export class DrizzleCryptoChargeRepository {
|
|
|
173
173
|
/** List deposit addresses with pending (uncredited) charges. */
|
|
174
174
|
async listActiveDepositAddresses() {
|
|
175
175
|
const rows = await this.db
|
|
176
|
-
.select({
|
|
176
|
+
.select({
|
|
177
|
+
chain: cryptoCharges.chain,
|
|
178
|
+
address: cryptoCharges.depositAddress,
|
|
179
|
+
token: cryptoCharges.token,
|
|
180
|
+
})
|
|
177
181
|
.from(cryptoCharges)
|
|
178
182
|
.where(and(isNull(cryptoCharges.creditedAt), isNotNull(cryptoCharges.depositAddress), isNotNull(cryptoCharges.chain)));
|
|
179
|
-
return rows.filter((r) => r.chain !== null && r.address !== null);
|
|
183
|
+
return rows.filter((r) => r.chain !== null && r.address !== null && r.token !== null);
|
|
180
184
|
}
|
|
181
185
|
/** Get the next available HD derivation index (max + 1, or 0 if empty). */
|
|
182
186
|
async getNextDerivationIndex() {
|
|
@@ -27,6 +27,7 @@ describe("EthWatcher", () => {
|
|
|
27
27
|
rpcCall: rpc,
|
|
28
28
|
oracle: mockOracle,
|
|
29
29
|
fromBlock: 10,
|
|
30
|
+
confirmations: 1,
|
|
30
31
|
onPayment,
|
|
31
32
|
watchedAddresses: ["0xDeposit"],
|
|
32
33
|
});
|
|
@@ -53,6 +54,7 @@ describe("EthWatcher", () => {
|
|
|
53
54
|
rpcCall: rpc,
|
|
54
55
|
oracle: mockOracle,
|
|
55
56
|
fromBlock: 10,
|
|
57
|
+
confirmations: 1,
|
|
56
58
|
onPayment,
|
|
57
59
|
watchedAddresses: ["0xDeposit"],
|
|
58
60
|
});
|
|
@@ -72,6 +74,7 @@ describe("EthWatcher", () => {
|
|
|
72
74
|
rpcCall: rpc,
|
|
73
75
|
oracle: mockOracle,
|
|
74
76
|
fromBlock: 10,
|
|
77
|
+
confirmations: 1,
|
|
75
78
|
onPayment,
|
|
76
79
|
watchedAddresses: ["0xDeposit"],
|
|
77
80
|
});
|
|
@@ -104,6 +107,7 @@ describe("EthWatcher", () => {
|
|
|
104
107
|
rpcCall: rpc,
|
|
105
108
|
oracle: mockOracle,
|
|
106
109
|
fromBlock: 10,
|
|
110
|
+
confirmations: 1,
|
|
107
111
|
onPayment,
|
|
108
112
|
watchedAddresses: ["0xDeposit"],
|
|
109
113
|
cursorStore,
|
|
@@ -121,6 +125,7 @@ describe("EthWatcher", () => {
|
|
|
121
125
|
rpcCall: rpc,
|
|
122
126
|
oracle: mockOracle,
|
|
123
127
|
fromBlock: 10,
|
|
128
|
+
confirmations: 1,
|
|
124
129
|
onPayment,
|
|
125
130
|
watchedAddresses: [],
|
|
126
131
|
});
|
|
@@ -149,6 +154,7 @@ describe("EthWatcher", () => {
|
|
|
149
154
|
rpcCall: rpc,
|
|
150
155
|
oracle: mockOracle,
|
|
151
156
|
fromBlock: 10,
|
|
157
|
+
confirmations: 1,
|
|
152
158
|
onPayment,
|
|
153
159
|
watchedAddresses: ["0xDeposit"],
|
|
154
160
|
cursorStore,
|
|
@@ -40,6 +40,9 @@ describe("EvmWatcher — intermediate confirmations", () => {
|
|
|
40
40
|
const watcher = new EvmWatcher({
|
|
41
41
|
chain: "base",
|
|
42
42
|
token: "USDC",
|
|
43
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
44
|
+
decimals: 6,
|
|
45
|
+
confirmations: 1,
|
|
43
46
|
rpcCall: mockRpc,
|
|
44
47
|
fromBlock: 100,
|
|
45
48
|
watchedAddresses: [toAddr],
|
|
@@ -65,6 +68,9 @@ describe("EvmWatcher — intermediate confirmations", () => {
|
|
|
65
68
|
const watcher = new EvmWatcher({
|
|
66
69
|
chain: "base",
|
|
67
70
|
token: "USDC",
|
|
71
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
72
|
+
decimals: 6,
|
|
73
|
+
confirmations: 1,
|
|
68
74
|
rpcCall: mockRpc,
|
|
69
75
|
fromBlock: 100,
|
|
70
76
|
watchedAddresses: [toAddr],
|
|
@@ -88,6 +94,9 @@ describe("EvmWatcher — intermediate confirmations", () => {
|
|
|
88
94
|
const watcher = new EvmWatcher({
|
|
89
95
|
chain: "base",
|
|
90
96
|
token: "USDC",
|
|
97
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
98
|
+
decimals: 6,
|
|
99
|
+
confirmations: 1,
|
|
91
100
|
rpcCall: mockRpc,
|
|
92
101
|
fromBlock: 100,
|
|
93
102
|
watchedAddresses: [toAddr],
|
|
@@ -110,6 +119,9 @@ describe("EvmWatcher — intermediate confirmations", () => {
|
|
|
110
119
|
const watcher = new EvmWatcher({
|
|
111
120
|
chain: "base",
|
|
112
121
|
token: "USDC",
|
|
122
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
123
|
+
decimals: 6,
|
|
124
|
+
confirmations: 1,
|
|
113
125
|
rpcCall: mockRpc,
|
|
114
126
|
fromBlock: 100,
|
|
115
127
|
watchedAddresses: [toAddr],
|
|
@@ -132,6 +144,9 @@ describe("EvmWatcher — intermediate confirmations", () => {
|
|
|
132
144
|
const watcher = new EvmWatcher({
|
|
133
145
|
chain: "base",
|
|
134
146
|
token: "USDC",
|
|
147
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
148
|
+
decimals: 6,
|
|
149
|
+
confirmations: 1,
|
|
135
150
|
rpcCall: mockRpc,
|
|
136
151
|
fromBlock: 100,
|
|
137
152
|
watchedAddresses: [toAddr],
|
|
@@ -26,6 +26,9 @@ describe("EvmWatcher", () => {
|
|
|
26
26
|
const watcher = new EvmWatcher({
|
|
27
27
|
chain: "base",
|
|
28
28
|
token: "USDC",
|
|
29
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
30
|
+
decimals: 6,
|
|
31
|
+
confirmations: 1,
|
|
29
32
|
rpcCall: mockRpc,
|
|
30
33
|
fromBlock: 99,
|
|
31
34
|
watchedAddresses: [toAddr],
|
|
@@ -46,6 +49,9 @@ describe("EvmWatcher", () => {
|
|
|
46
49
|
const watcher = new EvmWatcher({
|
|
47
50
|
chain: "base",
|
|
48
51
|
token: "USDC",
|
|
52
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
53
|
+
decimals: 6,
|
|
54
|
+
confirmations: 1,
|
|
49
55
|
rpcCall: mockRpc,
|
|
50
56
|
fromBlock: 100,
|
|
51
57
|
watchedAddresses: ["0xdeadbeef"],
|
|
@@ -65,6 +71,9 @@ describe("EvmWatcher", () => {
|
|
|
65
71
|
const watcher = new EvmWatcher({
|
|
66
72
|
chain: "base",
|
|
67
73
|
token: "USDC",
|
|
74
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
75
|
+
decimals: 6,
|
|
76
|
+
confirmations: 1,
|
|
68
77
|
rpcCall: mockRpc,
|
|
69
78
|
fromBlock: 50,
|
|
70
79
|
watchedAddresses: ["0xdeadbeef"],
|
|
@@ -86,6 +95,9 @@ describe("EvmWatcher", () => {
|
|
|
86
95
|
const watcher = new EvmWatcher({
|
|
87
96
|
chain: "base",
|
|
88
97
|
token: "USDC",
|
|
98
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
99
|
+
decimals: 6,
|
|
100
|
+
confirmations: 1,
|
|
89
101
|
rpcCall: mockRpc,
|
|
90
102
|
fromBlock: 100,
|
|
91
103
|
watchedAddresses: [addr1, addr2],
|
|
@@ -103,6 +115,9 @@ describe("EvmWatcher", () => {
|
|
|
103
115
|
const watcher = new EvmWatcher({
|
|
104
116
|
chain: "base",
|
|
105
117
|
token: "USDC",
|
|
118
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
119
|
+
decimals: 6,
|
|
120
|
+
confirmations: 1,
|
|
106
121
|
rpcCall: mockRpc,
|
|
107
122
|
fromBlock: 100,
|
|
108
123
|
watchedAddresses: ["0xdeadbeef"],
|
|
@@ -117,6 +132,9 @@ describe("EvmWatcher", () => {
|
|
|
117
132
|
const watcher = new EvmWatcher({
|
|
118
133
|
chain: "base",
|
|
119
134
|
token: "USDC",
|
|
135
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
136
|
+
decimals: 6,
|
|
137
|
+
confirmations: 1,
|
|
120
138
|
rpcCall: mockRpc,
|
|
121
139
|
fromBlock: 0,
|
|
122
140
|
onPayment: vi.fn(),
|
|
@@ -26,6 +26,8 @@ export interface EthWatcherOpts {
|
|
|
26
26
|
onPayment: (event: EthPaymentEvent) => void | Promise<void>;
|
|
27
27
|
watchedAddresses?: string[];
|
|
28
28
|
cursorStore?: IWatcherCursorStore;
|
|
29
|
+
/** Required confirmations (from DB). */
|
|
30
|
+
confirmations: number;
|
|
29
31
|
}
|
|
30
32
|
/**
|
|
31
33
|
* Native ETH transfer watcher.
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { nativeToCents } from "../oracle/convert.js";
|
|
2
|
-
import { getChainConfig } from "./config.js";
|
|
3
2
|
/**
|
|
4
3
|
* Native ETH transfer watcher.
|
|
5
4
|
*
|
|
@@ -27,7 +26,7 @@ export class EthWatcher {
|
|
|
27
26
|
this.oracle = opts.oracle;
|
|
28
27
|
this._cursor = opts.fromBlock;
|
|
29
28
|
this.onPayment = opts.onPayment;
|
|
30
|
-
this.confirmations =
|
|
29
|
+
this.confirmations = opts.confirmations;
|
|
31
30
|
this.cursorStore = opts.cursorStore;
|
|
32
31
|
this.watcherId = `eth:${opts.chain}`;
|
|
33
32
|
this._watchedAddresses = new Set((opts.watchedAddresses ?? []).map((a) => a.toLowerCase()));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Supported EVM chains. */
|
|
2
|
-
export type EvmChain = "base" | "ethereum" | "arbitrum" | "polygon";
|
|
2
|
+
export type EvmChain = "base" | "ethereum" | "arbitrum" | "polygon" | (string & {});
|
|
3
3
|
/** Supported stablecoin tokens. */
|
|
4
4
|
export type StablecoinToken = "USDC" | "USDT" | "DAI";
|
|
5
5
|
/** Chain configuration. */
|
|
@@ -10,6 +10,12 @@ export interface EvmWatcherOpts {
|
|
|
10
10
|
/** Active deposit addresses to watch. Filters eth_getLogs by topic[2] (to address). */
|
|
11
11
|
watchedAddresses?: string[];
|
|
12
12
|
cursorStore?: IWatcherCursorStore;
|
|
13
|
+
/** Contract address for the ERC20 token (from DB). */
|
|
14
|
+
contractAddress: string;
|
|
15
|
+
/** Token decimals (from DB). */
|
|
16
|
+
decimals: number;
|
|
17
|
+
/** Required confirmations (from DB). */
|
|
18
|
+
confirmations: number;
|
|
13
19
|
}
|
|
14
20
|
export declare class EvmWatcher {
|
|
15
21
|
private _cursor;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { centsFromTokenAmount
|
|
1
|
+
import { centsFromTokenAmount } from "./config.js";
|
|
2
2
|
const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
|
3
3
|
export class EvmWatcher {
|
|
4
4
|
_cursor;
|
|
@@ -21,11 +21,9 @@ export class EvmWatcher {
|
|
|
21
21
|
this.cursorStore = opts.cursorStore;
|
|
22
22
|
this.watcherId = `evm:${opts.chain}:${opts.token}`;
|
|
23
23
|
this._watchedAddresses = (opts.watchedAddresses ?? []).map((a) => a.toLowerCase());
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
this.
|
|
27
|
-
this.contractAddress = tokenCfg.contractAddress.toLowerCase();
|
|
28
|
-
this.decimals = tokenCfg.decimals;
|
|
24
|
+
this.confirmations = opts.confirmations;
|
|
25
|
+
this.contractAddress = opts.contractAddress.toLowerCase();
|
|
26
|
+
this.decimals = opts.decimals;
|
|
29
27
|
}
|
|
30
28
|
/** Load cursor from DB. Call once at startup before first poll. */
|
|
31
29
|
async init() {
|
|
@@ -218,6 +218,7 @@ export function createKeyServerApp(deps) {
|
|
|
218
218
|
displayName: m.displayName,
|
|
219
219
|
contractAddress: m.contractAddress,
|
|
220
220
|
confirmations: m.confirmations,
|
|
221
|
+
iconUrl: m.iconUrl,
|
|
221
222
|
})));
|
|
222
223
|
});
|
|
223
224
|
// --- Admin API ---
|
|
@@ -282,7 +283,8 @@ export function createKeyServerApp(deps) {
|
|
|
282
283
|
decimals: body.decimals,
|
|
283
284
|
displayName: body.display_name ?? `${body.token} on ${body.network}`,
|
|
284
285
|
enabled: true,
|
|
285
|
-
displayOrder: 0,
|
|
286
|
+
displayOrder: body.display_order ?? 0,
|
|
287
|
+
iconUrl: body.icon_url ?? null,
|
|
286
288
|
rpcUrl: body.rpc_url,
|
|
287
289
|
oracleAddress: body.oracle_address ?? null,
|
|
288
290
|
xpub: body.xpub,
|
|
@@ -291,6 +293,19 @@ export function createKeyServerApp(deps) {
|
|
|
291
293
|
});
|
|
292
294
|
return c.json({ id: body.id, path: `m/44'/${body.coin_type}'/${body.account_index}'` }, 201);
|
|
293
295
|
});
|
|
296
|
+
/** PATCH /admin/chains/:id — update metadata (icon_url, display_order, display_name) */
|
|
297
|
+
app.patch("/admin/chains/:id", async (c) => {
|
|
298
|
+
const id = c.req.param("id");
|
|
299
|
+
const body = await c.req.json();
|
|
300
|
+
const updated = await deps.methodStore.patchMetadata(id, {
|
|
301
|
+
iconUrl: body.icon_url,
|
|
302
|
+
displayOrder: body.display_order,
|
|
303
|
+
displayName: body.display_name,
|
|
304
|
+
});
|
|
305
|
+
if (!updated)
|
|
306
|
+
return c.json({ id, updated: false }, 200);
|
|
307
|
+
return c.json({ id, updated: true });
|
|
308
|
+
});
|
|
294
309
|
/** DELETE /admin/chains/:id — soft disable */
|
|
295
310
|
app.delete("/admin/chains/:id", async (c) => {
|
|
296
311
|
await deps.methodStore.setEnabled(c.req.param("id"), false);
|
|
@@ -9,6 +9,7 @@ export interface PaymentMethodRecord {
|
|
|
9
9
|
displayName: string;
|
|
10
10
|
enabled: boolean;
|
|
11
11
|
displayOrder: number;
|
|
12
|
+
iconUrl: string | null;
|
|
12
13
|
rpcUrl: string | null;
|
|
13
14
|
oracleAddress: string | null;
|
|
14
15
|
xpub: string | null;
|
|
@@ -28,6 +29,12 @@ export interface IPaymentMethodStore {
|
|
|
28
29
|
upsert(method: PaymentMethodRecord): Promise<void>;
|
|
29
30
|
/** Enable or disable a payment method (admin). */
|
|
30
31
|
setEnabled(id: string, enabled: boolean): Promise<void>;
|
|
32
|
+
/** Partial update of metadata fields (no read-modify-write needed). */
|
|
33
|
+
patchMetadata(id: string, patch: {
|
|
34
|
+
iconUrl?: string | null;
|
|
35
|
+
displayOrder?: number;
|
|
36
|
+
displayName?: string;
|
|
37
|
+
}): Promise<boolean>;
|
|
31
38
|
}
|
|
32
39
|
export declare class DrizzlePaymentMethodStore implements IPaymentMethodStore {
|
|
33
40
|
private readonly db;
|
|
@@ -38,4 +45,9 @@ export declare class DrizzlePaymentMethodStore implements IPaymentMethodStore {
|
|
|
38
45
|
listByType(type: string): Promise<PaymentMethodRecord[]>;
|
|
39
46
|
upsert(method: PaymentMethodRecord): Promise<void>;
|
|
40
47
|
setEnabled(id: string, enabled: boolean): Promise<void>;
|
|
48
|
+
patchMetadata(id: string, patch: {
|
|
49
|
+
iconUrl?: string | null;
|
|
50
|
+
displayOrder?: number;
|
|
51
|
+
displayName?: string;
|
|
52
|
+
}): Promise<boolean>;
|
|
41
53
|
}
|
|
@@ -42,6 +42,7 @@ export class DrizzlePaymentMethodStore {
|
|
|
42
42
|
displayName: method.displayName,
|
|
43
43
|
enabled: method.enabled,
|
|
44
44
|
displayOrder: method.displayOrder,
|
|
45
|
+
iconUrl: method.iconUrl,
|
|
45
46
|
rpcUrl: method.rpcUrl,
|
|
46
47
|
oracleAddress: method.oracleAddress,
|
|
47
48
|
xpub: method.xpub,
|
|
@@ -59,6 +60,7 @@ export class DrizzlePaymentMethodStore {
|
|
|
59
60
|
displayName: method.displayName,
|
|
60
61
|
enabled: method.enabled,
|
|
61
62
|
displayOrder: method.displayOrder,
|
|
63
|
+
iconUrl: method.iconUrl,
|
|
62
64
|
rpcUrl: method.rpcUrl,
|
|
63
65
|
oracleAddress: method.oracleAddress,
|
|
64
66
|
xpub: method.xpub,
|
|
@@ -70,6 +72,19 @@ export class DrizzlePaymentMethodStore {
|
|
|
70
72
|
async setEnabled(id, enabled) {
|
|
71
73
|
await this.db.update(paymentMethods).set({ enabled }).where(eq(paymentMethods.id, id));
|
|
72
74
|
}
|
|
75
|
+
async patchMetadata(id, patch) {
|
|
76
|
+
const set = {};
|
|
77
|
+
if (patch.iconUrl !== undefined)
|
|
78
|
+
set.iconUrl = patch.iconUrl;
|
|
79
|
+
if (patch.displayOrder !== undefined)
|
|
80
|
+
set.displayOrder = patch.displayOrder;
|
|
81
|
+
if (patch.displayName !== undefined)
|
|
82
|
+
set.displayName = patch.displayName;
|
|
83
|
+
if (Object.keys(set).length === 0)
|
|
84
|
+
return false;
|
|
85
|
+
const result = (await this.db.update(paymentMethods).set(set).where(eq(paymentMethods.id, id)));
|
|
86
|
+
return result.rowCount > 0;
|
|
87
|
+
}
|
|
73
88
|
}
|
|
74
89
|
function toRecord(row) {
|
|
75
90
|
return {
|
|
@@ -82,6 +97,7 @@ function toRecord(row) {
|
|
|
82
97
|
displayName: row.displayName,
|
|
83
98
|
enabled: row.enabled,
|
|
84
99
|
displayOrder: row.displayOrder,
|
|
100
|
+
iconUrl: row.iconUrl,
|
|
85
101
|
rpcUrl: row.rpcUrl,
|
|
86
102
|
oracleAddress: row.oracleAddress,
|
|
87
103
|
xpub: row.xpub,
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { and, eq, isNull, lte, or } from "drizzle-orm";
|
|
15
15
|
import { cryptoCharges, webhookDeliveries } from "../../db/schema/crypto.js";
|
|
16
16
|
import { BtcWatcher, createBitcoindRpc } from "./btc/watcher.js";
|
|
17
|
+
import { EthWatcher } from "./evm/eth-watcher.js";
|
|
17
18
|
import { createRpcCaller, EvmWatcher } from "./evm/watcher.js";
|
|
18
19
|
const MAX_DELIVERY_ATTEMPTS = 10;
|
|
19
20
|
const BACKOFF_BASE_MS = 5_000;
|
|
@@ -263,8 +264,72 @@ export async function startWatchers(opts) {
|
|
|
263
264
|
}
|
|
264
265
|
}, pollMs));
|
|
265
266
|
}
|
|
266
|
-
// ---
|
|
267
|
-
|
|
267
|
+
// --- Native ETH Watchers (block-scanning for value transfers) ---
|
|
268
|
+
const nativeEvmMethods = evmMethods.filter((m) => m.type === "native");
|
|
269
|
+
const erc20Methods = evmMethods.filter((m) => m.type === "erc20" && m.contractAddress);
|
|
270
|
+
const BACKFILL_BLOCKS = 1000; // Scan ~30min of blocks on first deploy to catch missed deposits
|
|
271
|
+
for (const method of nativeEvmMethods) {
|
|
272
|
+
if (!method.rpcUrl)
|
|
273
|
+
continue;
|
|
274
|
+
const rpcCall = createRpcCaller(method.rpcUrl);
|
|
275
|
+
const latestHex = (await rpcCall("eth_blockNumber", []));
|
|
276
|
+
const latestBlock = Number.parseInt(latestHex, 16);
|
|
277
|
+
const backfillStart = Math.max(0, latestBlock - BACKFILL_BLOCKS);
|
|
278
|
+
const activeAddresses = await chargeStore.listActiveDepositAddresses();
|
|
279
|
+
// Only watch addresses for native charges on this chain (not ERC20 charges)
|
|
280
|
+
const chainAddresses = activeAddresses
|
|
281
|
+
.filter((a) => a.chain === method.chain && a.token === method.token)
|
|
282
|
+
.map((a) => a.address);
|
|
283
|
+
const watcher = new EthWatcher({
|
|
284
|
+
chain: method.chain,
|
|
285
|
+
rpcCall,
|
|
286
|
+
oracle,
|
|
287
|
+
fromBlock: backfillStart,
|
|
288
|
+
watchedAddresses: chainAddresses,
|
|
289
|
+
cursorStore,
|
|
290
|
+
confirmations: method.confirmations,
|
|
291
|
+
onPayment: async (event) => {
|
|
292
|
+
log("ETH payment", {
|
|
293
|
+
chain: event.chain,
|
|
294
|
+
to: event.to,
|
|
295
|
+
txHash: event.txHash,
|
|
296
|
+
valueWei: event.valueWei,
|
|
297
|
+
confirmations: event.confirmations,
|
|
298
|
+
confirmationsRequired: event.confirmationsRequired,
|
|
299
|
+
});
|
|
300
|
+
await handlePayment(db, chargeStore, event.to, event.valueWei, {
|
|
301
|
+
txHash: event.txHash,
|
|
302
|
+
confirmations: event.confirmations,
|
|
303
|
+
confirmationsRequired: event.confirmationsRequired,
|
|
304
|
+
amountReceivedCents: event.amountUsdCents,
|
|
305
|
+
}, log);
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
await watcher.init();
|
|
309
|
+
log(`ETH watcher started (${method.chain}:${method.token})`, { addresses: chainAddresses.length });
|
|
310
|
+
let ethPolling = false;
|
|
311
|
+
timers.push(setInterval(async () => {
|
|
312
|
+
if (ethPolling)
|
|
313
|
+
return;
|
|
314
|
+
ethPolling = true;
|
|
315
|
+
try {
|
|
316
|
+
const fresh = await chargeStore.listActiveDepositAddresses();
|
|
317
|
+
const freshNative = fresh
|
|
318
|
+
.filter((a) => a.chain === method.chain && a.token === method.token)
|
|
319
|
+
.map((a) => a.address);
|
|
320
|
+
watcher.setWatchedAddresses(freshNative);
|
|
321
|
+
await watcher.poll();
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
log("ETH poll error", { chain: method.chain, error: String(err) });
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
ethPolling = false;
|
|
328
|
+
}
|
|
329
|
+
}, pollMs));
|
|
330
|
+
}
|
|
331
|
+
// --- ERC20 Watchers (log-based Transfer event scanning) ---
|
|
332
|
+
for (const method of erc20Methods) {
|
|
268
333
|
if (!method.rpcUrl || !method.contractAddress)
|
|
269
334
|
continue;
|
|
270
335
|
const rpcCall = createRpcCaller(method.rpcUrl);
|
|
@@ -278,6 +343,9 @@ export async function startWatchers(opts) {
|
|
|
278
343
|
rpcCall,
|
|
279
344
|
fromBlock: latestBlock,
|
|
280
345
|
watchedAddresses: chainAddresses,
|
|
346
|
+
contractAddress: method.contractAddress,
|
|
347
|
+
decimals: method.decimals,
|
|
348
|
+
confirmations: method.confirmations,
|
|
281
349
|
cursorStore,
|
|
282
350
|
onPayment: async (event) => {
|
|
283
351
|
log("EVM payment", {
|
|
@@ -301,7 +369,7 @@ export async function startWatchers(opts) {
|
|
|
301
369
|
let evmPolling = false;
|
|
302
370
|
timers.push(setInterval(async () => {
|
|
303
371
|
if (evmPolling)
|
|
304
|
-
return;
|
|
372
|
+
return;
|
|
305
373
|
evmPolling = true;
|
|
306
374
|
try {
|
|
307
375
|
const fresh = await chargeStore.listActiveDepositAddresses();
|
|
@@ -328,7 +396,13 @@ export async function startWatchers(opts) {
|
|
|
328
396
|
log("Delivery loop error", { error: String(err) });
|
|
329
397
|
}
|
|
330
398
|
}, deliveryMs));
|
|
331
|
-
log("All watchers started", {
|
|
399
|
+
log("All watchers started", {
|
|
400
|
+
utxo: utxoMethods.length,
|
|
401
|
+
evm: erc20Methods.length,
|
|
402
|
+
eth: nativeEvmMethods.length,
|
|
403
|
+
pollMs,
|
|
404
|
+
deliveryMs,
|
|
405
|
+
});
|
|
332
406
|
return () => {
|
|
333
407
|
for (const t of timers)
|
|
334
408
|
clearInterval(t);
|
|
@@ -597,6 +597,23 @@ export declare const paymentMethods: import("drizzle-orm/pg-core").PgTableWithCo
|
|
|
597
597
|
identity: undefined;
|
|
598
598
|
generated: undefined;
|
|
599
599
|
}, {}, {}>;
|
|
600
|
+
iconUrl: import("drizzle-orm/pg-core").PgColumn<{
|
|
601
|
+
name: "icon_url";
|
|
602
|
+
tableName: "payment_methods";
|
|
603
|
+
dataType: "string";
|
|
604
|
+
columnType: "PgText";
|
|
605
|
+
data: string;
|
|
606
|
+
driverParam: string;
|
|
607
|
+
notNull: false;
|
|
608
|
+
hasDefault: false;
|
|
609
|
+
isPrimaryKey: false;
|
|
610
|
+
isAutoincrement: false;
|
|
611
|
+
hasRuntimeDefault: false;
|
|
612
|
+
enumValues: [string, ...string[]];
|
|
613
|
+
baseColumn: never;
|
|
614
|
+
identity: undefined;
|
|
615
|
+
generated: undefined;
|
|
616
|
+
}, {}, {}>;
|
|
600
617
|
rpcUrl: import("drizzle-orm/pg-core").PgColumn<{
|
|
601
618
|
name: "rpc_url";
|
|
602
619
|
tableName: "payment_methods";
|
package/dist/db/schema/crypto.js
CHANGED
|
@@ -71,6 +71,7 @@ export const paymentMethods = pgTable("payment_methods", {
|
|
|
71
71
|
displayName: text("display_name").notNull(),
|
|
72
72
|
enabled: boolean("enabled").notNull().default(true),
|
|
73
73
|
displayOrder: integer("display_order").notNull().default(0),
|
|
74
|
+
iconUrl: text("icon_url"),
|
|
74
75
|
rpcUrl: text("rpc_url"), // chain node RPC endpoint
|
|
75
76
|
oracleAddress: text("oracle_address"), // Chainlink feed address for price (null = 1:1 stablecoin)
|
|
76
77
|
xpub: text("xpub"), // HD wallet extended public key for deposit address derivation
|
|
@@ -134,6 +134,13 @@
|
|
|
134
134
|
"when": 1743177600000,
|
|
135
135
|
"tag": "0018_address_type_column",
|
|
136
136
|
"breakpoints": true
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"idx": 19,
|
|
140
|
+
"version": "7",
|
|
141
|
+
"when": 1743264000000,
|
|
142
|
+
"tag": "0019_icon_url_column",
|
|
143
|
+
"breakpoints": true
|
|
137
144
|
}
|
|
138
145
|
]
|
|
139
146
|
}
|
package/package.json
CHANGED
|
@@ -76,6 +76,7 @@ function mockDeps(): KeyServerDeps & {
|
|
|
76
76
|
displayName: "Bitcoin",
|
|
77
77
|
contractAddress: null,
|
|
78
78
|
confirmations: 6,
|
|
79
|
+
iconUrl: null,
|
|
79
80
|
},
|
|
80
81
|
{
|
|
81
82
|
id: "base-usdc",
|
|
@@ -85,6 +86,7 @@ function mockDeps(): KeyServerDeps & {
|
|
|
85
86
|
displayName: "USDC on Base",
|
|
86
87
|
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
87
88
|
confirmations: 12,
|
|
89
|
+
iconUrl: null,
|
|
88
90
|
},
|
|
89
91
|
]),
|
|
90
92
|
listAll: vi.fn(),
|
|
@@ -100,12 +102,14 @@ function mockDeps(): KeyServerDeps & {
|
|
|
100
102
|
oracleAddress: "0x64c911996D3c6aC71f9b455B1E8E7266BcbD848F",
|
|
101
103
|
xpub: null,
|
|
102
104
|
displayOrder: 0,
|
|
105
|
+
iconUrl: null,
|
|
103
106
|
enabled: true,
|
|
104
107
|
rpcUrl: null,
|
|
105
108
|
}),
|
|
106
109
|
listByType: vi.fn(),
|
|
107
110
|
upsert: vi.fn().mockResolvedValue(undefined),
|
|
108
111
|
setEnabled: vi.fn().mockResolvedValue(undefined),
|
|
112
|
+
patchMetadata: vi.fn().mockResolvedValue(true),
|
|
109
113
|
};
|
|
110
114
|
return {
|
|
111
115
|
db: createMockDb() as never,
|
|
@@ -67,7 +67,7 @@ export interface ICryptoChargeRepository {
|
|
|
67
67
|
getByDepositAddress(address: string): Promise<CryptoChargeRecord | null>;
|
|
68
68
|
getNextDerivationIndex(): Promise<number>;
|
|
69
69
|
/** List deposit addresses with pending (uncredited) charges, grouped by chain. */
|
|
70
|
-
listActiveDepositAddresses(): Promise<{ chain: string; address: string }[]>;
|
|
70
|
+
listActiveDepositAddresses(): Promise<{ chain: string; address: string; token: string }[]>;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
/**
|
|
@@ -255,14 +255,21 @@ export class DrizzleCryptoChargeRepository implements ICryptoChargeRepository {
|
|
|
255
255
|
}
|
|
256
256
|
|
|
257
257
|
/** List deposit addresses with pending (uncredited) charges. */
|
|
258
|
-
async listActiveDepositAddresses(): Promise<{ chain: string; address: string }[]> {
|
|
258
|
+
async listActiveDepositAddresses(): Promise<{ chain: string; address: string; token: string }[]> {
|
|
259
259
|
const rows = await this.db
|
|
260
|
-
.select({
|
|
260
|
+
.select({
|
|
261
|
+
chain: cryptoCharges.chain,
|
|
262
|
+
address: cryptoCharges.depositAddress,
|
|
263
|
+
token: cryptoCharges.token,
|
|
264
|
+
})
|
|
261
265
|
.from(cryptoCharges)
|
|
262
266
|
.where(
|
|
263
267
|
and(isNull(cryptoCharges.creditedAt), isNotNull(cryptoCharges.depositAddress), isNotNull(cryptoCharges.chain)),
|
|
264
268
|
);
|
|
265
|
-
return rows.filter(
|
|
269
|
+
return rows.filter(
|
|
270
|
+
(r): r is { chain: string; address: string; token: string } =>
|
|
271
|
+
r.chain !== null && r.address !== null && r.token !== null,
|
|
272
|
+
);
|
|
266
273
|
}
|
|
267
274
|
|
|
268
275
|
/** Get the next available HD derivation index (max + 1, or 0 if empty). */
|
|
@@ -31,6 +31,7 @@ describe("EthWatcher", () => {
|
|
|
31
31
|
rpcCall: rpc,
|
|
32
32
|
oracle: mockOracle,
|
|
33
33
|
fromBlock: 10,
|
|
34
|
+
confirmations: 1,
|
|
34
35
|
onPayment,
|
|
35
36
|
watchedAddresses: ["0xDeposit"],
|
|
36
37
|
});
|
|
@@ -61,6 +62,7 @@ describe("EthWatcher", () => {
|
|
|
61
62
|
rpcCall: rpc,
|
|
62
63
|
oracle: mockOracle,
|
|
63
64
|
fromBlock: 10,
|
|
65
|
+
confirmations: 1,
|
|
64
66
|
onPayment,
|
|
65
67
|
watchedAddresses: ["0xDeposit"],
|
|
66
68
|
});
|
|
@@ -83,6 +85,7 @@ describe("EthWatcher", () => {
|
|
|
83
85
|
rpcCall: rpc,
|
|
84
86
|
oracle: mockOracle,
|
|
85
87
|
fromBlock: 10,
|
|
88
|
+
confirmations: 1,
|
|
86
89
|
onPayment,
|
|
87
90
|
watchedAddresses: ["0xDeposit"],
|
|
88
91
|
});
|
|
@@ -118,6 +121,7 @@ describe("EthWatcher", () => {
|
|
|
118
121
|
rpcCall: rpc,
|
|
119
122
|
oracle: mockOracle,
|
|
120
123
|
fromBlock: 10,
|
|
124
|
+
confirmations: 1,
|
|
121
125
|
onPayment,
|
|
122
126
|
watchedAddresses: ["0xDeposit"],
|
|
123
127
|
cursorStore,
|
|
@@ -139,6 +143,7 @@ describe("EthWatcher", () => {
|
|
|
139
143
|
rpcCall: rpc,
|
|
140
144
|
oracle: mockOracle,
|
|
141
145
|
fromBlock: 10,
|
|
146
|
+
confirmations: 1,
|
|
142
147
|
onPayment,
|
|
143
148
|
watchedAddresses: [],
|
|
144
149
|
});
|
|
@@ -170,6 +175,7 @@ describe("EthWatcher", () => {
|
|
|
170
175
|
rpcCall: rpc,
|
|
171
176
|
oracle: mockOracle,
|
|
172
177
|
fromBlock: 10,
|
|
178
|
+
confirmations: 1,
|
|
173
179
|
onPayment,
|
|
174
180
|
watchedAddresses: ["0xDeposit"],
|
|
175
181
|
cursorStore,
|
|
@@ -46,6 +46,9 @@ describe("EvmWatcher — intermediate confirmations", () => {
|
|
|
46
46
|
const watcher = new EvmWatcher({
|
|
47
47
|
chain: "base",
|
|
48
48
|
token: "USDC",
|
|
49
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
50
|
+
decimals: 6,
|
|
51
|
+
confirmations: 1,
|
|
49
52
|
rpcCall: mockRpc,
|
|
50
53
|
fromBlock: 100,
|
|
51
54
|
watchedAddresses: [toAddr],
|
|
@@ -76,6 +79,9 @@ describe("EvmWatcher — intermediate confirmations", () => {
|
|
|
76
79
|
const watcher = new EvmWatcher({
|
|
77
80
|
chain: "base",
|
|
78
81
|
token: "USDC",
|
|
82
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
83
|
+
decimals: 6,
|
|
84
|
+
confirmations: 1,
|
|
79
85
|
rpcCall: mockRpc,
|
|
80
86
|
fromBlock: 100,
|
|
81
87
|
watchedAddresses: [toAddr],
|
|
@@ -104,6 +110,9 @@ describe("EvmWatcher — intermediate confirmations", () => {
|
|
|
104
110
|
const watcher = new EvmWatcher({
|
|
105
111
|
chain: "base",
|
|
106
112
|
token: "USDC",
|
|
113
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
114
|
+
decimals: 6,
|
|
115
|
+
confirmations: 1,
|
|
107
116
|
rpcCall: mockRpc,
|
|
108
117
|
fromBlock: 100,
|
|
109
118
|
watchedAddresses: [toAddr],
|
|
@@ -131,6 +140,9 @@ describe("EvmWatcher — intermediate confirmations", () => {
|
|
|
131
140
|
const watcher = new EvmWatcher({
|
|
132
141
|
chain: "base",
|
|
133
142
|
token: "USDC",
|
|
143
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
144
|
+
decimals: 6,
|
|
145
|
+
confirmations: 1,
|
|
134
146
|
rpcCall: mockRpc,
|
|
135
147
|
fromBlock: 100,
|
|
136
148
|
watchedAddresses: [toAddr],
|
|
@@ -158,6 +170,9 @@ describe("EvmWatcher — intermediate confirmations", () => {
|
|
|
158
170
|
const watcher = new EvmWatcher({
|
|
159
171
|
chain: "base",
|
|
160
172
|
token: "USDC",
|
|
173
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
174
|
+
decimals: 6,
|
|
175
|
+
confirmations: 1,
|
|
161
176
|
rpcCall: mockRpc,
|
|
162
177
|
fromBlock: 100,
|
|
163
178
|
watchedAddresses: [toAddr],
|
|
@@ -30,6 +30,9 @@ describe("EvmWatcher", () => {
|
|
|
30
30
|
const watcher = new EvmWatcher({
|
|
31
31
|
chain: "base",
|
|
32
32
|
token: "USDC",
|
|
33
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
34
|
+
decimals: 6,
|
|
35
|
+
confirmations: 1,
|
|
33
36
|
rpcCall: mockRpc,
|
|
34
37
|
fromBlock: 99,
|
|
35
38
|
watchedAddresses: [toAddr],
|
|
@@ -54,6 +57,9 @@ describe("EvmWatcher", () => {
|
|
|
54
57
|
const watcher = new EvmWatcher({
|
|
55
58
|
chain: "base",
|
|
56
59
|
token: "USDC",
|
|
60
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
61
|
+
decimals: 6,
|
|
62
|
+
confirmations: 1,
|
|
57
63
|
rpcCall: mockRpc,
|
|
58
64
|
fromBlock: 100,
|
|
59
65
|
watchedAddresses: ["0xdeadbeef"],
|
|
@@ -76,6 +82,9 @@ describe("EvmWatcher", () => {
|
|
|
76
82
|
const watcher = new EvmWatcher({
|
|
77
83
|
chain: "base",
|
|
78
84
|
token: "USDC",
|
|
85
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
86
|
+
decimals: 6,
|
|
87
|
+
confirmations: 1,
|
|
79
88
|
rpcCall: mockRpc,
|
|
80
89
|
fromBlock: 50,
|
|
81
90
|
watchedAddresses: ["0xdeadbeef"],
|
|
@@ -100,6 +109,9 @@ describe("EvmWatcher", () => {
|
|
|
100
109
|
const watcher = new EvmWatcher({
|
|
101
110
|
chain: "base",
|
|
102
111
|
token: "USDC",
|
|
112
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
113
|
+
decimals: 6,
|
|
114
|
+
confirmations: 1,
|
|
103
115
|
rpcCall: mockRpc,
|
|
104
116
|
fromBlock: 100,
|
|
105
117
|
watchedAddresses: [addr1, addr2],
|
|
@@ -121,6 +133,9 @@ describe("EvmWatcher", () => {
|
|
|
121
133
|
const watcher = new EvmWatcher({
|
|
122
134
|
chain: "base",
|
|
123
135
|
token: "USDC",
|
|
136
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
137
|
+
decimals: 6,
|
|
138
|
+
confirmations: 1,
|
|
124
139
|
rpcCall: mockRpc,
|
|
125
140
|
fromBlock: 100,
|
|
126
141
|
watchedAddresses: ["0xdeadbeef"],
|
|
@@ -138,6 +153,9 @@ describe("EvmWatcher", () => {
|
|
|
138
153
|
const watcher = new EvmWatcher({
|
|
139
154
|
chain: "base",
|
|
140
155
|
token: "USDC",
|
|
156
|
+
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
157
|
+
decimals: 6,
|
|
158
|
+
confirmations: 1,
|
|
141
159
|
rpcCall: mockRpc,
|
|
142
160
|
fromBlock: 0,
|
|
143
161
|
onPayment: vi.fn(),
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { IWatcherCursorStore } from "../cursor-store.js";
|
|
2
2
|
import { nativeToCents } from "../oracle/convert.js";
|
|
3
3
|
import type { IPriceOracle } from "../oracle/types.js";
|
|
4
|
-
import { getChainConfig } from "./config.js";
|
|
5
4
|
import type { EvmChain } from "./types.js";
|
|
6
5
|
|
|
7
6
|
type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
|
|
@@ -31,6 +30,8 @@ export interface EthWatcherOpts {
|
|
|
31
30
|
onPayment: (event: EthPaymentEvent) => void | Promise<void>;
|
|
32
31
|
watchedAddresses?: string[];
|
|
33
32
|
cursorStore?: IWatcherCursorStore;
|
|
33
|
+
/** Required confirmations (from DB). */
|
|
34
|
+
confirmations: number;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
interface RpcTransaction {
|
|
@@ -69,7 +70,7 @@ export class EthWatcher {
|
|
|
69
70
|
this.oracle = opts.oracle;
|
|
70
71
|
this._cursor = opts.fromBlock;
|
|
71
72
|
this.onPayment = opts.onPayment;
|
|
72
|
-
this.confirmations =
|
|
73
|
+
this.confirmations = opts.confirmations;
|
|
73
74
|
this.cursorStore = opts.cursorStore;
|
|
74
75
|
this.watcherId = `eth:${opts.chain}`;
|
|
75
76
|
this._watchedAddresses = new Set((opts.watchedAddresses ?? []).map((a) => a.toLowerCase()));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Supported EVM chains. */
|
|
2
|
-
export type EvmChain = "base" | "ethereum" | "arbitrum" | "polygon";
|
|
2
|
+
export type EvmChain = "base" | "ethereum" | "arbitrum" | "polygon" | (string & {});
|
|
3
3
|
|
|
4
4
|
/** Supported stablecoin tokens. */
|
|
5
5
|
export type StablecoinToken = "USDC" | "USDT" | "DAI";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IWatcherCursorStore } from "../cursor-store.js";
|
|
2
|
-
import { centsFromTokenAmount
|
|
2
|
+
import { centsFromTokenAmount } from "./config.js";
|
|
3
3
|
import type { EvmChain, EvmPaymentEvent, StablecoinToken } from "./types.js";
|
|
4
4
|
|
|
5
5
|
const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
|
@@ -15,6 +15,12 @@ export interface EvmWatcherOpts {
|
|
|
15
15
|
/** Active deposit addresses to watch. Filters eth_getLogs by topic[2] (to address). */
|
|
16
16
|
watchedAddresses?: string[];
|
|
17
17
|
cursorStore?: IWatcherCursorStore;
|
|
18
|
+
/** Contract address for the ERC20 token (from DB). */
|
|
19
|
+
contractAddress: string;
|
|
20
|
+
/** Token decimals (from DB). */
|
|
21
|
+
decimals: number;
|
|
22
|
+
/** Required confirmations (from DB). */
|
|
23
|
+
confirmations: number;
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
interface RpcLog {
|
|
@@ -49,11 +55,9 @@ export class EvmWatcher {
|
|
|
49
55
|
this.watcherId = `evm:${opts.chain}:${opts.token}`;
|
|
50
56
|
this._watchedAddresses = (opts.watchedAddresses ?? []).map((a) => a.toLowerCase());
|
|
51
57
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
this.
|
|
55
|
-
this.contractAddress = tokenCfg.contractAddress.toLowerCase();
|
|
56
|
-
this.decimals = tokenCfg.decimals;
|
|
58
|
+
this.confirmations = opts.confirmations;
|
|
59
|
+
this.contractAddress = opts.contractAddress.toLowerCase();
|
|
60
|
+
this.decimals = opts.decimals;
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
/** Load cursor from DB. Call once at startup before first poll. */
|
|
@@ -266,6 +266,7 @@ export function createKeyServerApp(deps: KeyServerDeps): Hono {
|
|
|
266
266
|
displayName: m.displayName,
|
|
267
267
|
contractAddress: m.contractAddress,
|
|
268
268
|
confirmations: m.confirmations,
|
|
269
|
+
iconUrl: m.iconUrl,
|
|
269
270
|
})),
|
|
270
271
|
);
|
|
271
272
|
});
|
|
@@ -328,6 +329,8 @@ export function createKeyServerApp(deps: KeyServerDeps): Hono {
|
|
|
328
329
|
display_name?: string;
|
|
329
330
|
oracle_address?: string;
|
|
330
331
|
address_type?: string;
|
|
332
|
+
icon_url?: string;
|
|
333
|
+
display_order?: number;
|
|
331
334
|
}>();
|
|
332
335
|
|
|
333
336
|
if (!body.id || !body.xpub || !body.token) {
|
|
@@ -362,7 +365,8 @@ export function createKeyServerApp(deps: KeyServerDeps): Hono {
|
|
|
362
365
|
decimals: body.decimals,
|
|
363
366
|
displayName: body.display_name ?? `${body.token} on ${body.network}`,
|
|
364
367
|
enabled: true,
|
|
365
|
-
displayOrder: 0,
|
|
368
|
+
displayOrder: body.display_order ?? 0,
|
|
369
|
+
iconUrl: body.icon_url ?? null,
|
|
366
370
|
rpcUrl: body.rpc_url,
|
|
367
371
|
oracleAddress: body.oracle_address ?? null,
|
|
368
372
|
xpub: body.xpub,
|
|
@@ -373,6 +377,25 @@ export function createKeyServerApp(deps: KeyServerDeps): Hono {
|
|
|
373
377
|
return c.json({ id: body.id, path: `m/44'/${body.coin_type}'/${body.account_index}'` }, 201);
|
|
374
378
|
});
|
|
375
379
|
|
|
380
|
+
/** PATCH /admin/chains/:id — update metadata (icon_url, display_order, display_name) */
|
|
381
|
+
app.patch("/admin/chains/:id", async (c) => {
|
|
382
|
+
const id = c.req.param("id");
|
|
383
|
+
const body = await c.req.json<{
|
|
384
|
+
icon_url?: string | null;
|
|
385
|
+
display_order?: number;
|
|
386
|
+
display_name?: string;
|
|
387
|
+
}>();
|
|
388
|
+
|
|
389
|
+
const updated = await deps.methodStore.patchMetadata(id, {
|
|
390
|
+
iconUrl: body.icon_url,
|
|
391
|
+
displayOrder: body.display_order,
|
|
392
|
+
displayName: body.display_name,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
if (!updated) return c.json({ id, updated: false }, 200);
|
|
396
|
+
return c.json({ id, updated: true });
|
|
397
|
+
});
|
|
398
|
+
|
|
376
399
|
/** DELETE /admin/chains/:id — soft disable */
|
|
377
400
|
app.delete("/admin/chains/:id", async (c) => {
|
|
378
401
|
await deps.methodStore.setEnabled(c.req.param("id"), false);
|
|
@@ -12,6 +12,7 @@ export interface PaymentMethodRecord {
|
|
|
12
12
|
displayName: string;
|
|
13
13
|
enabled: boolean;
|
|
14
14
|
displayOrder: number;
|
|
15
|
+
iconUrl: string | null;
|
|
15
16
|
rpcUrl: string | null;
|
|
16
17
|
oracleAddress: string | null;
|
|
17
18
|
xpub: string | null;
|
|
@@ -32,6 +33,11 @@ export interface IPaymentMethodStore {
|
|
|
32
33
|
upsert(method: PaymentMethodRecord): Promise<void>;
|
|
33
34
|
/** Enable or disable a payment method (admin). */
|
|
34
35
|
setEnabled(id: string, enabled: boolean): Promise<void>;
|
|
36
|
+
/** Partial update of metadata fields (no read-modify-write needed). */
|
|
37
|
+
patchMetadata(
|
|
38
|
+
id: string,
|
|
39
|
+
patch: { iconUrl?: string | null; displayOrder?: number; displayName?: string },
|
|
40
|
+
): Promise<boolean>;
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
export class DrizzlePaymentMethodStore implements IPaymentMethodStore {
|
|
@@ -78,6 +84,7 @@ export class DrizzlePaymentMethodStore implements IPaymentMethodStore {
|
|
|
78
84
|
displayName: method.displayName,
|
|
79
85
|
enabled: method.enabled,
|
|
80
86
|
displayOrder: method.displayOrder,
|
|
87
|
+
iconUrl: method.iconUrl,
|
|
81
88
|
rpcUrl: method.rpcUrl,
|
|
82
89
|
oracleAddress: method.oracleAddress,
|
|
83
90
|
xpub: method.xpub,
|
|
@@ -95,6 +102,7 @@ export class DrizzlePaymentMethodStore implements IPaymentMethodStore {
|
|
|
95
102
|
displayName: method.displayName,
|
|
96
103
|
enabled: method.enabled,
|
|
97
104
|
displayOrder: method.displayOrder,
|
|
105
|
+
iconUrl: method.iconUrl,
|
|
98
106
|
rpcUrl: method.rpcUrl,
|
|
99
107
|
oracleAddress: method.oracleAddress,
|
|
100
108
|
xpub: method.xpub,
|
|
@@ -107,6 +115,21 @@ export class DrizzlePaymentMethodStore implements IPaymentMethodStore {
|
|
|
107
115
|
async setEnabled(id: string, enabled: boolean): Promise<void> {
|
|
108
116
|
await this.db.update(paymentMethods).set({ enabled }).where(eq(paymentMethods.id, id));
|
|
109
117
|
}
|
|
118
|
+
|
|
119
|
+
async patchMetadata(
|
|
120
|
+
id: string,
|
|
121
|
+
patch: { iconUrl?: string | null; displayOrder?: number; displayName?: string },
|
|
122
|
+
): Promise<boolean> {
|
|
123
|
+
const set: Record<string, unknown> = {};
|
|
124
|
+
if (patch.iconUrl !== undefined) set.iconUrl = patch.iconUrl;
|
|
125
|
+
if (patch.displayOrder !== undefined) set.displayOrder = patch.displayOrder;
|
|
126
|
+
if (patch.displayName !== undefined) set.displayName = patch.displayName;
|
|
127
|
+
if (Object.keys(set).length === 0) return false;
|
|
128
|
+
const result = (await this.db.update(paymentMethods).set(set).where(eq(paymentMethods.id, id))) as {
|
|
129
|
+
rowCount: number;
|
|
130
|
+
};
|
|
131
|
+
return result.rowCount > 0;
|
|
132
|
+
}
|
|
110
133
|
}
|
|
111
134
|
|
|
112
135
|
function toRecord(row: typeof paymentMethods.$inferSelect): PaymentMethodRecord {
|
|
@@ -120,6 +143,7 @@ function toRecord(row: typeof paymentMethods.$inferSelect): PaymentMethodRecord
|
|
|
120
143
|
displayName: row.displayName,
|
|
121
144
|
enabled: row.enabled,
|
|
122
145
|
displayOrder: row.displayOrder,
|
|
146
|
+
iconUrl: row.iconUrl,
|
|
123
147
|
rpcUrl: row.rpcUrl,
|
|
124
148
|
oracleAddress: row.oracleAddress,
|
|
125
149
|
xpub: row.xpub,
|
|
@@ -19,6 +19,8 @@ import type { BtcPaymentEvent } from "./btc/types.js";
|
|
|
19
19
|
import { BtcWatcher, createBitcoindRpc } from "./btc/watcher.js";
|
|
20
20
|
import type { ICryptoChargeRepository } from "./charge-store.js";
|
|
21
21
|
import type { IWatcherCursorStore } from "./cursor-store.js";
|
|
22
|
+
import type { EthPaymentEvent } from "./evm/eth-watcher.js";
|
|
23
|
+
import { EthWatcher } from "./evm/eth-watcher.js";
|
|
22
24
|
import type { EvmChain, EvmPaymentEvent, StablecoinToken } from "./evm/types.js";
|
|
23
25
|
import { createRpcCaller, EvmWatcher } from "./evm/watcher.js";
|
|
24
26
|
import type { IPriceOracle } from "./oracle/types.js";
|
|
@@ -349,8 +351,85 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
|
|
|
349
351
|
);
|
|
350
352
|
}
|
|
351
353
|
|
|
352
|
-
// ---
|
|
353
|
-
|
|
354
|
+
// --- Native ETH Watchers (block-scanning for value transfers) ---
|
|
355
|
+
const nativeEvmMethods = evmMethods.filter((m) => m.type === "native");
|
|
356
|
+
const erc20Methods = evmMethods.filter((m) => m.type === "erc20" && m.contractAddress);
|
|
357
|
+
|
|
358
|
+
const BACKFILL_BLOCKS = 1000; // Scan ~30min of blocks on first deploy to catch missed deposits
|
|
359
|
+
|
|
360
|
+
for (const method of nativeEvmMethods) {
|
|
361
|
+
if (!method.rpcUrl) continue;
|
|
362
|
+
|
|
363
|
+
const rpcCall = createRpcCaller(method.rpcUrl);
|
|
364
|
+
const latestHex = (await rpcCall("eth_blockNumber", [])) as string;
|
|
365
|
+
const latestBlock = Number.parseInt(latestHex, 16);
|
|
366
|
+
const backfillStart = Math.max(0, latestBlock - BACKFILL_BLOCKS);
|
|
367
|
+
|
|
368
|
+
const activeAddresses = await chargeStore.listActiveDepositAddresses();
|
|
369
|
+
// Only watch addresses for native charges on this chain (not ERC20 charges)
|
|
370
|
+
const chainAddresses = activeAddresses
|
|
371
|
+
.filter((a) => a.chain === method.chain && a.token === method.token)
|
|
372
|
+
.map((a) => a.address);
|
|
373
|
+
|
|
374
|
+
const watcher = new EthWatcher({
|
|
375
|
+
chain: method.chain as EvmChain,
|
|
376
|
+
rpcCall,
|
|
377
|
+
oracle,
|
|
378
|
+
fromBlock: backfillStart,
|
|
379
|
+
watchedAddresses: chainAddresses,
|
|
380
|
+
cursorStore,
|
|
381
|
+
confirmations: method.confirmations,
|
|
382
|
+
onPayment: async (event: EthPaymentEvent) => {
|
|
383
|
+
log("ETH payment", {
|
|
384
|
+
chain: event.chain,
|
|
385
|
+
to: event.to,
|
|
386
|
+
txHash: event.txHash,
|
|
387
|
+
valueWei: event.valueWei,
|
|
388
|
+
confirmations: event.confirmations,
|
|
389
|
+
confirmationsRequired: event.confirmationsRequired,
|
|
390
|
+
});
|
|
391
|
+
await handlePayment(
|
|
392
|
+
db,
|
|
393
|
+
chargeStore,
|
|
394
|
+
event.to,
|
|
395
|
+
event.valueWei,
|
|
396
|
+
{
|
|
397
|
+
txHash: event.txHash,
|
|
398
|
+
confirmations: event.confirmations,
|
|
399
|
+
confirmationsRequired: event.confirmationsRequired,
|
|
400
|
+
amountReceivedCents: event.amountUsdCents,
|
|
401
|
+
},
|
|
402
|
+
log,
|
|
403
|
+
);
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
await watcher.init();
|
|
408
|
+
log(`ETH watcher started (${method.chain}:${method.token})`, { addresses: chainAddresses.length });
|
|
409
|
+
|
|
410
|
+
let ethPolling = false;
|
|
411
|
+
timers.push(
|
|
412
|
+
setInterval(async () => {
|
|
413
|
+
if (ethPolling) return;
|
|
414
|
+
ethPolling = true;
|
|
415
|
+
try {
|
|
416
|
+
const fresh = await chargeStore.listActiveDepositAddresses();
|
|
417
|
+
const freshNative = fresh
|
|
418
|
+
.filter((a) => a.chain === method.chain && a.token === method.token)
|
|
419
|
+
.map((a) => a.address);
|
|
420
|
+
watcher.setWatchedAddresses(freshNative);
|
|
421
|
+
await watcher.poll();
|
|
422
|
+
} catch (err) {
|
|
423
|
+
log("ETH poll error", { chain: method.chain, error: String(err) });
|
|
424
|
+
} finally {
|
|
425
|
+
ethPolling = false;
|
|
426
|
+
}
|
|
427
|
+
}, pollMs),
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// --- ERC20 Watchers (log-based Transfer event scanning) ---
|
|
432
|
+
for (const method of erc20Methods) {
|
|
354
433
|
if (!method.rpcUrl || !method.contractAddress) continue;
|
|
355
434
|
|
|
356
435
|
const rpcCall = createRpcCaller(method.rpcUrl);
|
|
@@ -366,6 +445,9 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
|
|
|
366
445
|
rpcCall,
|
|
367
446
|
fromBlock: latestBlock,
|
|
368
447
|
watchedAddresses: chainAddresses,
|
|
448
|
+
contractAddress: method.contractAddress,
|
|
449
|
+
decimals: method.decimals,
|
|
450
|
+
confirmations: method.confirmations,
|
|
369
451
|
cursorStore,
|
|
370
452
|
onPayment: async (event: EvmPaymentEvent) => {
|
|
371
453
|
log("EVM payment", {
|
|
@@ -398,7 +480,7 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
|
|
|
398
480
|
let evmPolling = false;
|
|
399
481
|
timers.push(
|
|
400
482
|
setInterval(async () => {
|
|
401
|
-
if (evmPolling) return;
|
|
483
|
+
if (evmPolling) return;
|
|
402
484
|
evmPolling = true;
|
|
403
485
|
try {
|
|
404
486
|
const fresh = await chargeStore.listActiveDepositAddresses();
|
|
@@ -426,7 +508,13 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
|
|
|
426
508
|
}, deliveryMs),
|
|
427
509
|
);
|
|
428
510
|
|
|
429
|
-
log("All watchers started", {
|
|
511
|
+
log("All watchers started", {
|
|
512
|
+
utxo: utxoMethods.length,
|
|
513
|
+
evm: erc20Methods.length,
|
|
514
|
+
eth: nativeEvmMethods.length,
|
|
515
|
+
pollMs,
|
|
516
|
+
deliveryMs,
|
|
517
|
+
});
|
|
430
518
|
|
|
431
519
|
return () => {
|
|
432
520
|
for (const t of timers) clearInterval(t);
|
package/src/db/schema/crypto.ts
CHANGED
|
@@ -78,6 +78,7 @@ export const paymentMethods = pgTable("payment_methods", {
|
|
|
78
78
|
displayName: text("display_name").notNull(),
|
|
79
79
|
enabled: boolean("enabled").notNull().default(true),
|
|
80
80
|
displayOrder: integer("display_order").notNull().default(0),
|
|
81
|
+
iconUrl: text("icon_url"),
|
|
81
82
|
rpcUrl: text("rpc_url"), // chain node RPC endpoint
|
|
82
83
|
oracleAddress: text("oracle_address"), // Chainlink feed address for price (null = 1:1 stablecoin)
|
|
83
84
|
xpub: text("xpub"), // HD wallet extended public key for deposit address derivation
|