@wopr-network/platform-core 1.59.0 → 1.60.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.
@@ -12,6 +12,7 @@ function createMockDb() {
12
12
  decimals: 8,
13
13
  addressType: "bech32",
14
14
  encodingParams: '{"hrp":"bc"}',
15
+ watcherType: "utxo",
15
16
  confirmations: 6,
16
17
  };
17
18
  const db = {
@@ -180,6 +181,7 @@ describe("key-server routes", () => {
180
181
  decimals: 18,
181
182
  addressType: "evm",
182
183
  encodingParams: "{}",
184
+ watcherType: "evm",
183
185
  confirmations: 1,
184
186
  };
185
187
  const db = {
@@ -239,6 +241,7 @@ describe("key-server routes", () => {
239
241
  decimals: 18,
240
242
  addressType: "evm",
241
243
  encodingParams: "{}",
244
+ watcherType: "evm",
242
245
  confirmations: 1,
243
246
  };
244
247
  const db = {
@@ -296,6 +296,7 @@ export function createKeyServerApp(deps) {
296
296
  xpub: body.xpub,
297
297
  addressType: body.address_type ?? "evm",
298
298
  encodingParams: JSON.stringify(body.encoding_params ?? {}),
299
+ watcherType: body.watcher_type ?? "evm",
299
300
  confirmations: body.confirmations ?? 6,
300
301
  });
301
302
  return c.json({ id: body.id, path: `m/44'/${body.coin_type}'/${body.account_index}'` }, 201);
@@ -12,6 +12,7 @@ const COINGECKO_IDS = {
12
12
  LINK: "chainlink",
13
13
  UNI: "uniswap",
14
14
  AERO: "aerodrome-finance",
15
+ TRX: "tron",
15
16
  };
16
17
  /** Default cache TTL: 60 seconds. CoinGecko free tier allows 10-30 req/min. */
17
18
  const DEFAULT_CACHE_TTL_MS = 60_000;
@@ -15,6 +15,7 @@ export interface PaymentMethodRecord {
15
15
  xpub: string | null;
16
16
  addressType: string;
17
17
  encodingParams: string;
18
+ watcherType: string;
18
19
  confirmations: number;
19
20
  }
20
21
  export interface IPaymentMethodStore {
@@ -48,6 +48,7 @@ export class DrizzlePaymentMethodStore {
48
48
  xpub: method.xpub,
49
49
  addressType: method.addressType,
50
50
  encodingParams: method.encodingParams,
51
+ watcherType: method.watcherType,
51
52
  confirmations: method.confirmations,
52
53
  })
53
54
  .onConflictDoUpdate({
@@ -67,6 +68,7 @@ export class DrizzlePaymentMethodStore {
67
68
  xpub: method.xpub,
68
69
  addressType: method.addressType,
69
70
  encodingParams: method.encodingParams,
71
+ watcherType: method.watcherType,
70
72
  confirmations: method.confirmations,
71
73
  },
72
74
  });
@@ -105,6 +107,7 @@ function toRecord(row) {
105
107
  xpub: row.xpub,
106
108
  addressType: row.addressType,
107
109
  encodingParams: row.encodingParams,
110
+ watcherType: row.watcherType,
108
111
  confirmations: row.confirmations,
109
112
  };
110
113
  }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { hexToTron, isTronAddress, tronToHex } from "../address-convert.js";
3
+ // Known Tron address / hex pair (Tron foundation address)
4
+ const TRON_ADDR = "TJCnKsPa7y5okkXvQAidZBzqx3QyQ6sxMW";
5
+ const HEX_ADDR = "0x5a523b449890854c8fc460ab602df9f31fe4293f";
6
+ describe("tronToHex", () => {
7
+ it("converts T... to 0x hex", () => {
8
+ const hex = tronToHex(TRON_ADDR);
9
+ expect(hex).toBe(HEX_ADDR);
10
+ });
11
+ it("rejects non-Tron address", () => {
12
+ expect(() => tronToHex("0x1234")).toThrow("Not a Tron address");
13
+ });
14
+ it("rejects invalid checksum", () => {
15
+ // Flip last character
16
+ const bad = `${TRON_ADDR.slice(0, -1)}X`;
17
+ expect(() => tronToHex(bad)).toThrow();
18
+ });
19
+ });
20
+ describe("hexToTron", () => {
21
+ it("converts 0x hex to T...", () => {
22
+ const tron = hexToTron(HEX_ADDR);
23
+ expect(tron).toBe(TRON_ADDR);
24
+ });
25
+ it("handles hex without 0x prefix", () => {
26
+ const tron = hexToTron(HEX_ADDR.slice(2));
27
+ expect(tron).toBe(TRON_ADDR);
28
+ });
29
+ it("rejects wrong length", () => {
30
+ expect(() => hexToTron("0x1234")).toThrow("Invalid hex address length");
31
+ });
32
+ });
33
+ describe("roundtrip", () => {
34
+ it("tronToHex → hexToTron is identity", () => {
35
+ const hex = tronToHex(TRON_ADDR);
36
+ const back = hexToTron(hex);
37
+ expect(back).toBe(TRON_ADDR);
38
+ });
39
+ it("hexToTron → tronToHex is identity", () => {
40
+ const tron = hexToTron(HEX_ADDR);
41
+ const back = tronToHex(tron);
42
+ expect(back).toBe(HEX_ADDR);
43
+ });
44
+ });
45
+ describe("isTronAddress", () => {
46
+ it("returns true for T... address", () => {
47
+ expect(isTronAddress(TRON_ADDR)).toBe(true);
48
+ });
49
+ it("returns false for 0x address", () => {
50
+ expect(isTronAddress(HEX_ADDR)).toBe(false);
51
+ });
52
+ it("returns false for BTC address", () => {
53
+ expect(isTronAddress("bc1qtest")).toBe(false);
54
+ });
55
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Convert a Tron T... address to 0x hex (20 bytes, no 0x41 prefix).
3
+ * For feeding addresses to the EVM watcher JSON-RPC filters.
4
+ */
5
+ export declare function tronToHex(tronAddr: string): string;
6
+ /**
7
+ * Convert a 0x hex address (20 bytes) back to Tron T... Base58Check.
8
+ * For converting watcher event addresses back to DB format.
9
+ */
10
+ export declare function hexToTron(hexAddr: string): string;
11
+ /**
12
+ * Check if an address is a Tron T... address.
13
+ */
14
+ export declare function isTronAddress(addr: string): boolean;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Tron address conversion — T... Base58Check ↔ 0x hex.
3
+ *
4
+ * Tron addresses are 21 bytes: 0x41 prefix + 20-byte address.
5
+ * The JSON-RPC layer strips the 0x41 and returns standard 0x-prefixed hex.
6
+ * We need to convert between the two at the watcher boundary.
7
+ */
8
+ import { sha256 } from "@noble/hashes/sha2.js";
9
+ const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
10
+ function base58decode(s) {
11
+ let num = 0n;
12
+ for (const ch of s) {
13
+ const idx = BASE58_ALPHABET.indexOf(ch);
14
+ if (idx < 0)
15
+ throw new Error(`Invalid base58 character: ${ch}`);
16
+ num = num * 58n + BigInt(idx);
17
+ }
18
+ const hex = num.toString(16).padStart(50, "0"); // 25 bytes = 50 hex chars
19
+ const bytes = new Uint8Array(25);
20
+ for (let i = 0; i < 25; i++)
21
+ bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
22
+ return bytes;
23
+ }
24
+ /**
25
+ * Convert a Tron T... address to 0x hex (20 bytes, no 0x41 prefix).
26
+ * For feeding addresses to the EVM watcher JSON-RPC filters.
27
+ */
28
+ export function tronToHex(tronAddr) {
29
+ if (!tronAddr.startsWith("T"))
30
+ throw new Error(`Not a Tron address: ${tronAddr}`);
31
+ const decoded = base58decode(tronAddr);
32
+ // decoded: [0x41, ...20 bytes address..., ...4 bytes checksum]
33
+ // Verify checksum
34
+ const payload = decoded.slice(0, 21);
35
+ const checksum = sha256(sha256(payload)).slice(0, 4);
36
+ for (let i = 0; i < 4; i++) {
37
+ if (decoded[21 + i] !== checksum[i])
38
+ throw new Error(`Invalid checksum for Tron address: ${tronAddr}`);
39
+ }
40
+ // Strip 0x41 prefix, return 20-byte hex with 0x prefix
41
+ const addrBytes = payload.slice(1);
42
+ return `0x${Array.from(addrBytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
43
+ }
44
+ /**
45
+ * Convert a 0x hex address (20 bytes) back to Tron T... Base58Check.
46
+ * For converting watcher event addresses back to DB format.
47
+ */
48
+ export function hexToTron(hexAddr) {
49
+ const hex = hexAddr.startsWith("0x") ? hexAddr.slice(2) : hexAddr;
50
+ if (hex.length !== 40)
51
+ throw new Error(`Invalid hex address length: ${hex.length}`);
52
+ // Build payload: 0x41 + 20 bytes
53
+ const payload = new Uint8Array(21);
54
+ payload[0] = 0x41;
55
+ for (let i = 0; i < 20; i++)
56
+ payload[i + 1] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
57
+ // Compute checksum
58
+ const checksum = sha256(sha256(payload)).slice(0, 4);
59
+ const full = new Uint8Array(25);
60
+ full.set(payload);
61
+ full.set(checksum, 21);
62
+ // Base58 encode
63
+ let num = 0n;
64
+ for (const byte of full)
65
+ num = num * 256n + BigInt(byte);
66
+ let encoded = "";
67
+ while (num > 0n) {
68
+ encoded = BASE58_ALPHABET[Number(num % 58n)] + encoded;
69
+ num = num / 58n;
70
+ }
71
+ for (const byte of full) {
72
+ if (byte !== 0)
73
+ break;
74
+ encoded = `1${encoded}`;
75
+ }
76
+ return encoded;
77
+ }
78
+ /**
79
+ * Check if an address is a Tron T... address.
80
+ */
81
+ export function isTronAddress(addr) {
82
+ return addr.startsWith("T") && addr.length >= 33 && addr.length <= 35;
83
+ }
@@ -16,6 +16,7 @@ import { cryptoCharges, webhookDeliveries } from "../../db/schema/crypto.js";
16
16
  import { BtcWatcher, createBitcoindRpc } from "./btc/watcher.js";
17
17
  import { EthWatcher } from "./evm/eth-watcher.js";
18
18
  import { createRpcCaller, EvmWatcher } from "./evm/watcher.js";
19
+ import { hexToTron, isTronAddress, tronToHex } from "./tron/address-convert.js";
19
20
  const MAX_DELIVERY_ATTEMPTS = 10;
20
21
  const BACKOFF_BASE_MS = 5_000;
21
22
  // --- SSRF validation ---
@@ -181,9 +182,10 @@ export async function startWatchers(opts) {
181
182
  const serviceKey = opts.serviceKey;
182
183
  const timers = [];
183
184
  const methods = await methodStore.listEnabled();
184
- const utxoMethods = methods.filter((m) => m.type === "native" && (m.chain === "bitcoin" || m.chain === "litecoin" || m.chain === "dogecoin"));
185
- const evmMethods = methods.filter((m) => m.type === "erc20" ||
186
- (m.type === "native" && m.chain !== "bitcoin" && m.chain !== "litecoin" && m.chain !== "dogecoin"));
185
+ // Route watchers by DB-driven watcherType no hardcoded chain names.
186
+ // Adding a new chain is a DB INSERT with watcher_type = "utxo" or "evm".
187
+ const utxoMethods = methods.filter((m) => m.watcherType === "utxo");
188
+ const evmMethods = methods.filter((m) => m.watcherType === "evm");
187
189
  // --- UTXO Watchers (BTC, LTC, DOGE) ---
188
190
  for (const method of utxoMethods) {
189
191
  if (!method.rpcUrl)
@@ -272,6 +274,12 @@ export async function startWatchers(opts) {
272
274
  const nativeEvmMethods = evmMethods.filter((m) => m.type === "native");
273
275
  const erc20Methods = evmMethods.filter((m) => m.type === "erc20" && m.contractAddress);
274
276
  const BACKFILL_BLOCKS = 1000; // Scan ~30min of blocks on first deploy to catch missed deposits
277
+ // Address conversion helpers for chains with non-EVM address formats (e.g. Tron T...).
278
+ // The EVM watcher uses 0x hex addresses; the DB stores native format (T... for Tron).
279
+ // Determined by addressType from the DB — not by inspecting addresses at runtime.
280
+ const needsAddrConvert = (method) => method.addressType === "p2pkh";
281
+ const toWatcherAddr = (addr, method) => needsAddrConvert(method) && isTronAddress(addr) ? tronToHex(addr) : addr;
282
+ const fromWatcherAddr = (addr, method) => needsAddrConvert(method) ? hexToTron(addr) : addr;
275
283
  for (const method of nativeEvmMethods) {
276
284
  if (!method.rpcUrl)
277
285
  continue;
@@ -289,19 +297,20 @@ export async function startWatchers(opts) {
289
297
  rpcCall,
290
298
  oracle,
291
299
  fromBlock: backfillStart,
292
- watchedAddresses: chainAddresses,
300
+ watchedAddresses: chainAddresses.map((a) => toWatcherAddr(a, method)),
293
301
  cursorStore,
294
302
  confirmations: method.confirmations,
295
303
  onPayment: async (event) => {
304
+ const dbAddr = fromWatcherAddr(event.to, method);
296
305
  log("ETH payment", {
297
306
  chain: event.chain,
298
- to: event.to,
307
+ to: dbAddr,
299
308
  txHash: event.txHash,
300
309
  valueWei: event.valueWei,
301
310
  confirmations: event.confirmations,
302
311
  confirmationsRequired: event.confirmationsRequired,
303
312
  });
304
- await handlePayment(db, chargeStore, event.to, event.valueWei, {
313
+ await handlePayment(db, chargeStore, dbAddr, event.valueWei, {
305
314
  txHash: event.txHash,
306
315
  confirmations: event.confirmations,
307
316
  confirmationsRequired: event.confirmationsRequired,
@@ -321,7 +330,7 @@ export async function startWatchers(opts) {
321
330
  const freshNative = fresh
322
331
  .filter((a) => a.chain === method.chain && a.token === method.token)
323
332
  .map((a) => a.address);
324
- watcher.setWatchedAddresses(freshNative);
333
+ watcher.setWatchedAddresses(freshNative.map((a) => toWatcherAddr(a, method)));
325
334
  await watcher.poll();
326
335
  }
327
336
  catch (err) {
@@ -346,21 +355,22 @@ export async function startWatchers(opts) {
346
355
  token: method.token,
347
356
  rpcCall,
348
357
  fromBlock: latestBlock,
349
- watchedAddresses: chainAddresses,
358
+ watchedAddresses: chainAddresses.map((a) => toWatcherAddr(a, method)),
350
359
  contractAddress: method.contractAddress,
351
360
  decimals: method.decimals,
352
361
  confirmations: method.confirmations,
353
362
  cursorStore,
354
363
  onPayment: async (event) => {
364
+ const dbAddr = fromWatcherAddr(event.to, method);
355
365
  log("EVM payment", {
356
366
  chain: event.chain,
357
367
  token: event.token,
358
- to: event.to,
368
+ to: dbAddr,
359
369
  txHash: event.txHash,
360
370
  confirmations: event.confirmations,
361
371
  confirmationsRequired: event.confirmationsRequired,
362
372
  });
363
- await handlePayment(db, chargeStore, event.to, event.rawAmount, {
373
+ await handlePayment(db, chargeStore, dbAddr, event.rawAmount, {
364
374
  txHash: event.txHash,
365
375
  confirmations: event.confirmations,
366
376
  confirmationsRequired: event.confirmationsRequired,
@@ -378,7 +388,7 @@ export async function startWatchers(opts) {
378
388
  try {
379
389
  const fresh = await chargeStore.listActiveDepositAddresses();
380
390
  const freshChain = fresh.filter((a) => a.chain === method.chain).map((a) => a.address);
381
- watcher.setWatchedAddresses(freshChain);
391
+ watcher.setWatchedAddresses(freshChain.map((a) => toWatcherAddr(a, method)));
382
392
  await watcher.poll();
383
393
  }
384
394
  catch (err) {
@@ -699,6 +699,23 @@ export declare const paymentMethods: import("drizzle-orm/pg-core").PgTableWithCo
699
699
  identity: undefined;
700
700
  generated: undefined;
701
701
  }, {}, {}>;
702
+ watcherType: import("drizzle-orm/pg-core").PgColumn<{
703
+ name: "watcher_type";
704
+ tableName: "payment_methods";
705
+ dataType: "string";
706
+ columnType: "PgText";
707
+ data: string;
708
+ driverParam: string;
709
+ notNull: true;
710
+ hasDefault: true;
711
+ isPrimaryKey: false;
712
+ isAutoincrement: false;
713
+ hasRuntimeDefault: false;
714
+ enumValues: [string, ...string[]];
715
+ baseColumn: never;
716
+ identity: undefined;
717
+ generated: undefined;
718
+ }, {}, {}>;
702
719
  confirmations: import("drizzle-orm/pg-core").PgColumn<{
703
720
  name: "confirmations";
704
721
  tableName: "payment_methods";
@@ -77,6 +77,7 @@ export const paymentMethods = pgTable("payment_methods", {
77
77
  xpub: text("xpub"), // HD wallet extended public key for deposit address derivation
78
78
  addressType: text("address_type").notNull().default("evm"), // "bech32" (BTC/LTC), "p2pkh" (DOGE), "evm" (ETH/ERC20)
79
79
  encodingParams: text("encoding_params").notNull().default("{}"), // JSON: {"hrp":"bc"}, {"version":"0x1e"}, etc.
80
+ watcherType: text("watcher_type").notNull().default("evm"), // "utxo" (BTC/LTC/DOGE) or "evm" (ETH/ERC20/TRX)
80
81
  confirmations: integer("confirmations").notNull().default(1),
81
82
  nextIndex: integer("next_index").notNull().default(0), // atomic derivation counter, never reuses
82
83
  createdAt: text("created_at").notNull().default(sql `(now())`),
@@ -0,0 +1,3 @@
1
+ ALTER TABLE "payment_methods" ADD COLUMN "watcher_type" text DEFAULT 'evm' NOT NULL;
2
+ --> statement-breakpoint
3
+ UPDATE "payment_methods" SET "watcher_type" = 'utxo' WHERE "chain" IN ('bitcoin', 'litecoin', 'dogecoin');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.59.0",
3
+ "version": "1.60.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -16,6 +16,7 @@ function createMockDb() {
16
16
  decimals: 8,
17
17
  addressType: "bech32",
18
18
  encodingParams: '{"hrp":"bc"}',
19
+ watcherType: "utxo",
19
20
  confirmations: 6,
20
21
  };
21
22
 
@@ -197,6 +198,7 @@ describe("key-server routes", () => {
197
198
  decimals: 18,
198
199
  addressType: "evm",
199
200
  encodingParams: "{}",
201
+ watcherType: "evm",
200
202
  confirmations: 1,
201
203
  };
202
204
 
@@ -261,6 +263,7 @@ describe("key-server routes", () => {
261
263
  decimals: 18,
262
264
  addressType: "evm",
263
265
  encodingParams: "{}",
266
+ watcherType: "evm",
264
267
  confirmations: 1,
265
268
  };
266
269
 
@@ -322,6 +322,7 @@ export function createKeyServerApp(deps: KeyServerDeps): Hono {
322
322
  oracle_address?: string;
323
323
  address_type?: string;
324
324
  encoding_params?: Record<string, string>;
325
+ watcher_type?: string;
325
326
  icon_url?: string;
326
327
  display_order?: number;
327
328
  }>();
@@ -375,6 +376,7 @@ export function createKeyServerApp(deps: KeyServerDeps): Hono {
375
376
  xpub: body.xpub,
376
377
  addressType: body.address_type ?? "evm",
377
378
  encodingParams: JSON.stringify(body.encoding_params ?? {}),
379
+ watcherType: body.watcher_type ?? "evm",
378
380
  confirmations: body.confirmations ?? 6,
379
381
  });
380
382
 
@@ -14,6 +14,7 @@ const COINGECKO_IDS: Record<string, string> = {
14
14
  LINK: "chainlink",
15
15
  UNI: "uniswap",
16
16
  AERO: "aerodrome-finance",
17
+ TRX: "tron",
17
18
  };
18
19
 
19
20
  /** Default cache TTL: 60 seconds. CoinGecko free tier allows 10-30 req/min. */
@@ -18,6 +18,7 @@ export interface PaymentMethodRecord {
18
18
  xpub: string | null;
19
19
  addressType: string;
20
20
  encodingParams: string;
21
+ watcherType: string;
21
22
  confirmations: number;
22
23
  }
23
24
 
@@ -91,6 +92,7 @@ export class DrizzlePaymentMethodStore implements IPaymentMethodStore {
91
92
  xpub: method.xpub,
92
93
  addressType: method.addressType,
93
94
  encodingParams: method.encodingParams,
95
+ watcherType: method.watcherType,
94
96
  confirmations: method.confirmations,
95
97
  })
96
98
  .onConflictDoUpdate({
@@ -110,6 +112,7 @@ export class DrizzlePaymentMethodStore implements IPaymentMethodStore {
110
112
  xpub: method.xpub,
111
113
  addressType: method.addressType,
112
114
  encodingParams: method.encodingParams,
115
+ watcherType: method.watcherType,
113
116
  confirmations: method.confirmations,
114
117
  },
115
118
  });
@@ -152,6 +155,7 @@ function toRecord(row: typeof paymentMethods.$inferSelect): PaymentMethodRecord
152
155
  xpub: row.xpub,
153
156
  addressType: row.addressType,
154
157
  encodingParams: row.encodingParams,
158
+ watcherType: row.watcherType,
155
159
  confirmations: row.confirmations,
156
160
  };
157
161
  }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { hexToTron, isTronAddress, tronToHex } from "../address-convert.js";
3
+
4
+ // Known Tron address / hex pair (Tron foundation address)
5
+ const TRON_ADDR = "TJCnKsPa7y5okkXvQAidZBzqx3QyQ6sxMW";
6
+ const HEX_ADDR = "0x5a523b449890854c8fc460ab602df9f31fe4293f";
7
+
8
+ describe("tronToHex", () => {
9
+ it("converts T... to 0x hex", () => {
10
+ const hex = tronToHex(TRON_ADDR);
11
+ expect(hex).toBe(HEX_ADDR);
12
+ });
13
+
14
+ it("rejects non-Tron address", () => {
15
+ expect(() => tronToHex("0x1234")).toThrow("Not a Tron address");
16
+ });
17
+
18
+ it("rejects invalid checksum", () => {
19
+ // Flip last character
20
+ const bad = `${TRON_ADDR.slice(0, -1)}X`;
21
+ expect(() => tronToHex(bad)).toThrow();
22
+ });
23
+ });
24
+
25
+ describe("hexToTron", () => {
26
+ it("converts 0x hex to T...", () => {
27
+ const tron = hexToTron(HEX_ADDR);
28
+ expect(tron).toBe(TRON_ADDR);
29
+ });
30
+
31
+ it("handles hex without 0x prefix", () => {
32
+ const tron = hexToTron(HEX_ADDR.slice(2));
33
+ expect(tron).toBe(TRON_ADDR);
34
+ });
35
+
36
+ it("rejects wrong length", () => {
37
+ expect(() => hexToTron("0x1234")).toThrow("Invalid hex address length");
38
+ });
39
+ });
40
+
41
+ describe("roundtrip", () => {
42
+ it("tronToHex → hexToTron is identity", () => {
43
+ const hex = tronToHex(TRON_ADDR);
44
+ const back = hexToTron(hex);
45
+ expect(back).toBe(TRON_ADDR);
46
+ });
47
+
48
+ it("hexToTron → tronToHex is identity", () => {
49
+ const tron = hexToTron(HEX_ADDR);
50
+ const back = tronToHex(tron);
51
+ expect(back).toBe(HEX_ADDR);
52
+ });
53
+ });
54
+
55
+ describe("isTronAddress", () => {
56
+ it("returns true for T... address", () => {
57
+ expect(isTronAddress(TRON_ADDR)).toBe(true);
58
+ });
59
+
60
+ it("returns false for 0x address", () => {
61
+ expect(isTronAddress(HEX_ADDR)).toBe(false);
62
+ });
63
+
64
+ it("returns false for BTC address", () => {
65
+ expect(isTronAddress("bc1qtest")).toBe(false);
66
+ });
67
+ });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Tron address conversion — T... Base58Check ↔ 0x hex.
3
+ *
4
+ * Tron addresses are 21 bytes: 0x41 prefix + 20-byte address.
5
+ * The JSON-RPC layer strips the 0x41 and returns standard 0x-prefixed hex.
6
+ * We need to convert between the two at the watcher boundary.
7
+ */
8
+ import { sha256 } from "@noble/hashes/sha2.js";
9
+
10
+ const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
11
+
12
+ function base58decode(s: string): Uint8Array {
13
+ let num = 0n;
14
+ for (const ch of s) {
15
+ const idx = BASE58_ALPHABET.indexOf(ch);
16
+ if (idx < 0) throw new Error(`Invalid base58 character: ${ch}`);
17
+ num = num * 58n + BigInt(idx);
18
+ }
19
+ const hex = num.toString(16).padStart(50, "0"); // 25 bytes = 50 hex chars
20
+ const bytes = new Uint8Array(25);
21
+ for (let i = 0; i < 25; i++) bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
22
+ return bytes;
23
+ }
24
+
25
+ /**
26
+ * Convert a Tron T... address to 0x hex (20 bytes, no 0x41 prefix).
27
+ * For feeding addresses to the EVM watcher JSON-RPC filters.
28
+ */
29
+ export function tronToHex(tronAddr: string): string {
30
+ if (!tronAddr.startsWith("T")) throw new Error(`Not a Tron address: ${tronAddr}`);
31
+ const decoded = base58decode(tronAddr);
32
+ // decoded: [0x41, ...20 bytes address..., ...4 bytes checksum]
33
+ // Verify checksum
34
+ const payload = decoded.slice(0, 21);
35
+ const checksum = sha256(sha256(payload)).slice(0, 4);
36
+ for (let i = 0; i < 4; i++) {
37
+ if (decoded[21 + i] !== checksum[i]) throw new Error(`Invalid checksum for Tron address: ${tronAddr}`);
38
+ }
39
+ // Strip 0x41 prefix, return 20-byte hex with 0x prefix
40
+ const addrBytes = payload.slice(1);
41
+ return `0x${Array.from(addrBytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
42
+ }
43
+
44
+ /**
45
+ * Convert a 0x hex address (20 bytes) back to Tron T... Base58Check.
46
+ * For converting watcher event addresses back to DB format.
47
+ */
48
+ export function hexToTron(hexAddr: string): string {
49
+ const hex = hexAddr.startsWith("0x") ? hexAddr.slice(2) : hexAddr;
50
+ if (hex.length !== 40) throw new Error(`Invalid hex address length: ${hex.length}`);
51
+ // Build payload: 0x41 + 20 bytes
52
+ const payload = new Uint8Array(21);
53
+ payload[0] = 0x41;
54
+ for (let i = 0; i < 20; i++) payload[i + 1] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
55
+ // Compute checksum
56
+ const checksum = sha256(sha256(payload)).slice(0, 4);
57
+ const full = new Uint8Array(25);
58
+ full.set(payload);
59
+ full.set(checksum, 21);
60
+ // Base58 encode
61
+ let num = 0n;
62
+ for (const byte of full) num = num * 256n + BigInt(byte);
63
+ let encoded = "";
64
+ while (num > 0n) {
65
+ encoded = BASE58_ALPHABET[Number(num % 58n)] + encoded;
66
+ num = num / 58n;
67
+ }
68
+ for (const byte of full) {
69
+ if (byte !== 0) break;
70
+ encoded = `1${encoded}`;
71
+ }
72
+ return encoded;
73
+ }
74
+
75
+ /**
76
+ * Check if an address is a Tron T... address.
77
+ */
78
+ export function isTronAddress(addr: string): boolean {
79
+ return addr.startsWith("T") && addr.length >= 33 && addr.length <= 35;
80
+ }
@@ -25,6 +25,7 @@ import type { EvmChain, EvmPaymentEvent, StablecoinToken } from "./evm/types.js"
25
25
  import { createRpcCaller, EvmWatcher } from "./evm/watcher.js";
26
26
  import type { IPriceOracle } from "./oracle/types.js";
27
27
  import type { IPaymentMethodStore } from "./payment-method-store.js";
28
+ import { hexToTron, isTronAddress, tronToHex } from "./tron/address-convert.js";
28
29
  import type { CryptoChargeStatus } from "./types.js";
29
30
 
30
31
  const MAX_DELIVERY_ATTEMPTS = 10;
@@ -252,14 +253,10 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
252
253
 
253
254
  const methods = await methodStore.listEnabled();
254
255
 
255
- const utxoMethods = methods.filter(
256
- (m) => m.type === "native" && (m.chain === "bitcoin" || m.chain === "litecoin" || m.chain === "dogecoin"),
257
- );
258
- const evmMethods = methods.filter(
259
- (m) =>
260
- m.type === "erc20" ||
261
- (m.type === "native" && m.chain !== "bitcoin" && m.chain !== "litecoin" && m.chain !== "dogecoin"),
262
- );
256
+ // Route watchers by DB-driven watcherType — no hardcoded chain names.
257
+ // Adding a new chain is a DB INSERT with watcher_type = "utxo" or "evm".
258
+ const utxoMethods = methods.filter((m) => m.watcherType === "utxo");
259
+ const evmMethods = methods.filter((m) => m.watcherType === "evm");
263
260
 
264
261
  // --- UTXO Watchers (BTC, LTC, DOGE) ---
265
262
  for (const method of utxoMethods) {
@@ -363,6 +360,15 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
363
360
 
364
361
  const BACKFILL_BLOCKS = 1000; // Scan ~30min of blocks on first deploy to catch missed deposits
365
362
 
363
+ // Address conversion helpers for chains with non-EVM address formats (e.g. Tron T...).
364
+ // The EVM watcher uses 0x hex addresses; the DB stores native format (T... for Tron).
365
+ // Determined by addressType from the DB — not by inspecting addresses at runtime.
366
+ const needsAddrConvert = (method: { addressType: string }): boolean => method.addressType === "p2pkh";
367
+ const toWatcherAddr = (addr: string, method: { addressType: string }): string =>
368
+ needsAddrConvert(method) && isTronAddress(addr) ? tronToHex(addr) : addr;
369
+ const fromWatcherAddr = (addr: string, method: { addressType: string }): string =>
370
+ needsAddrConvert(method) ? hexToTron(addr) : addr;
371
+
366
372
  for (const method of nativeEvmMethods) {
367
373
  if (!method.rpcUrl) continue;
368
374
 
@@ -382,13 +388,14 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
382
388
  rpcCall,
383
389
  oracle,
384
390
  fromBlock: backfillStart,
385
- watchedAddresses: chainAddresses,
391
+ watchedAddresses: chainAddresses.map((a) => toWatcherAddr(a, method)),
386
392
  cursorStore,
387
393
  confirmations: method.confirmations,
388
394
  onPayment: async (event: EthPaymentEvent) => {
395
+ const dbAddr = fromWatcherAddr(event.to, method);
389
396
  log("ETH payment", {
390
397
  chain: event.chain,
391
- to: event.to,
398
+ to: dbAddr,
392
399
  txHash: event.txHash,
393
400
  valueWei: event.valueWei,
394
401
  confirmations: event.confirmations,
@@ -397,7 +404,7 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
397
404
  await handlePayment(
398
405
  db,
399
406
  chargeStore,
400
- event.to,
407
+ dbAddr,
401
408
  event.valueWei,
402
409
  {
403
410
  txHash: event.txHash,
@@ -423,7 +430,7 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
423
430
  const freshNative = fresh
424
431
  .filter((a) => a.chain === method.chain && a.token === method.token)
425
432
  .map((a) => a.address);
426
- watcher.setWatchedAddresses(freshNative);
433
+ watcher.setWatchedAddresses(freshNative.map((a) => toWatcherAddr(a, method)));
427
434
  await watcher.poll();
428
435
  } catch (err) {
429
436
  log("ETH poll error", { chain: method.chain, error: String(err) });
@@ -450,16 +457,17 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
450
457
  token: method.token as StablecoinToken,
451
458
  rpcCall,
452
459
  fromBlock: latestBlock,
453
- watchedAddresses: chainAddresses,
460
+ watchedAddresses: chainAddresses.map((a) => toWatcherAddr(a, method)),
454
461
  contractAddress: method.contractAddress,
455
462
  decimals: method.decimals,
456
463
  confirmations: method.confirmations,
457
464
  cursorStore,
458
465
  onPayment: async (event: EvmPaymentEvent) => {
466
+ const dbAddr = fromWatcherAddr(event.to, method);
459
467
  log("EVM payment", {
460
468
  chain: event.chain,
461
469
  token: event.token,
462
- to: event.to,
470
+ to: dbAddr,
463
471
  txHash: event.txHash,
464
472
  confirmations: event.confirmations,
465
473
  confirmationsRequired: event.confirmationsRequired,
@@ -467,7 +475,7 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
467
475
  await handlePayment(
468
476
  db,
469
477
  chargeStore,
470
- event.to,
478
+ dbAddr,
471
479
  event.rawAmount,
472
480
  {
473
481
  txHash: event.txHash,
@@ -491,7 +499,7 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
491
499
  try {
492
500
  const fresh = await chargeStore.listActiveDepositAddresses();
493
501
  const freshChain = fresh.filter((a) => a.chain === method.chain).map((a) => a.address);
494
- watcher.setWatchedAddresses(freshChain);
502
+ watcher.setWatchedAddresses(freshChain.map((a) => toWatcherAddr(a, method)));
495
503
  await watcher.poll();
496
504
  } catch (err) {
497
505
  log("EVM poll error", { chain: method.chain, token: method.token, error: String(err) });
@@ -84,6 +84,7 @@ export const paymentMethods = pgTable("payment_methods", {
84
84
  xpub: text("xpub"), // HD wallet extended public key for deposit address derivation
85
85
  addressType: text("address_type").notNull().default("evm"), // "bech32" (BTC/LTC), "p2pkh" (DOGE), "evm" (ETH/ERC20)
86
86
  encodingParams: text("encoding_params").notNull().default("{}"), // JSON: {"hrp":"bc"}, {"version":"0x1e"}, etc.
87
+ watcherType: text("watcher_type").notNull().default("evm"), // "utxo" (BTC/LTC/DOGE) or "evm" (ETH/ERC20/TRX)
87
88
  confirmations: integer("confirmations").notNull().default(1),
88
89
  nextIndex: integer("next_index").notNull().default(0), // atomic derivation counter, never reuses
89
90
  createdAt: text("created_at").notNull().default(sql`(now())`),