@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.
Files changed (34) hide show
  1. package/dist/billing/crypto/__tests__/key-server.test.js +4 -0
  2. package/dist/billing/crypto/charge-store.d.ts +2 -0
  3. package/dist/billing/crypto/charge-store.js +6 -2
  4. package/dist/billing/crypto/client.d.ts +1 -0
  5. package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +6 -0
  6. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.js +15 -0
  7. package/dist/billing/crypto/evm/__tests__/watcher.test.js +18 -0
  8. package/dist/billing/crypto/evm/eth-watcher.d.ts +2 -0
  9. package/dist/billing/crypto/evm/eth-watcher.js +1 -2
  10. package/dist/billing/crypto/evm/types.d.ts +1 -1
  11. package/dist/billing/crypto/evm/watcher.d.ts +6 -0
  12. package/dist/billing/crypto/evm/watcher.js +4 -6
  13. package/dist/billing/crypto/key-server.js +16 -1
  14. package/dist/billing/crypto/payment-method-store.d.ts +12 -0
  15. package/dist/billing/crypto/payment-method-store.js +16 -0
  16. package/dist/billing/crypto/watcher-service.js +78 -4
  17. package/dist/db/schema/crypto.d.ts +17 -0
  18. package/dist/db/schema/crypto.js +1 -0
  19. package/drizzle/migrations/0019_icon_url_column.sql +4 -0
  20. package/drizzle/migrations/meta/_journal.json +7 -0
  21. package/package.json +1 -1
  22. package/src/billing/crypto/__tests__/key-server.test.ts +4 -0
  23. package/src/billing/crypto/charge-store.ts +11 -4
  24. package/src/billing/crypto/client.ts +1 -0
  25. package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +6 -0
  26. package/src/billing/crypto/evm/__tests__/watcher-confirmations.test.ts +15 -0
  27. package/src/billing/crypto/evm/__tests__/watcher.test.ts +18 -0
  28. package/src/billing/crypto/evm/eth-watcher.ts +3 -2
  29. package/src/billing/crypto/evm/types.ts +1 -1
  30. package/src/billing/crypto/evm/watcher.ts +10 -6
  31. package/src/billing/crypto/key-server.ts +24 -1
  32. package/src/billing/crypto/payment-method-store.ts +24 -0
  33. package/src/billing/crypto/watcher-service.ts +92 -4
  34. 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({ chain: cryptoCharges.chain, address: cryptoCharges.depositAddress })
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() {
@@ -46,6 +46,7 @@ export interface ChainInfo {
46
46
  displayName: string;
47
47
  contractAddress: string | null;
48
48
  confirmations: number;
49
+ iconUrl: string | null;
49
50
  }
50
51
  /**
51
52
  * Client for the shared crypto key server.
@@ -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 = getChainConfig(opts.chain).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, getChainConfig, getTokenConfig } from "./config.js";
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
- const chainCfg = getChainConfig(opts.chain);
25
- const tokenCfg = getTokenConfig(opts.token, opts.chain);
26
- this.confirmations = chainCfg.confirmations;
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
- // --- EVM Watchers ---
267
- for (const method of evmMethods) {
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; // Prevent overlapping polls
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", { utxo: utxoMethods.length, evm: evmMethods.length, pollMs, deliveryMs });
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";
@@ -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
@@ -0,0 +1,4 @@
1
+ -- Add icon_url column to payment_methods.
2
+ -- Stores URL for chain/token icon displayed in checkout UI.
3
+
4
+ ALTER TABLE "payment_methods" ADD COLUMN IF NOT EXISTS "icon_url" text;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.49.4",
3
+ "version": "1.50.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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({ chain: cryptoCharges.chain, address: cryptoCharges.depositAddress })
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((r): r is { chain: string; address: string } => r.chain !== null && r.address !== null);
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). */
@@ -51,6 +51,7 @@ export interface ChainInfo {
51
51
  displayName: string;
52
52
  contractAddress: string | null;
53
53
  confirmations: number;
54
+ iconUrl: string | null;
54
55
  }
55
56
 
56
57
  /**
@@ -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 = getChainConfig(opts.chain).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, getChainConfig, getTokenConfig } from "./config.js";
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
- const chainCfg = getChainConfig(opts.chain);
53
- const tokenCfg = getTokenConfig(opts.token, opts.chain);
54
- this.confirmations = chainCfg.confirmations;
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
- // --- EVM Watchers ---
353
- for (const method of evmMethods) {
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; // Prevent overlapping polls
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", { utxo: utxoMethods.length, evm: evmMethods.length, pollMs, deliveryMs });
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);
@@ -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