@wopr-network/platform-core 1.59.0 → 1.60.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/billing/crypto/__tests__/key-server.test.js +3 -0
- package/dist/billing/crypto/key-server.js +1 -0
- package/dist/billing/crypto/oracle/coingecko.js +1 -0
- package/dist/billing/crypto/payment-method-store.d.ts +1 -0
- package/dist/billing/crypto/payment-method-store.js +3 -0
- package/dist/billing/crypto/tron/__tests__/address-convert.test.d.ts +1 -0
- package/dist/billing/crypto/tron/__tests__/address-convert.test.js +55 -0
- package/dist/billing/crypto/tron/address-convert.d.ts +14 -0
- package/dist/billing/crypto/tron/address-convert.js +83 -0
- package/dist/billing/crypto/watcher-service.js +21 -11
- package/dist/db/schema/crypto.d.ts +17 -0
- package/dist/db/schema/crypto.js +1 -0
- package/drizzle/migrations/0021_watcher_type_column.sql +3 -0
- package/package.json +2 -1
- package/src/billing/crypto/__tests__/key-server.test.ts +3 -0
- package/src/billing/crypto/key-server.ts +2 -0
- package/src/billing/crypto/oracle/coingecko.ts +1 -0
- package/src/billing/crypto/payment-method-store.ts +4 -0
- package/src/billing/crypto/tron/__tests__/address-convert.test.ts +67 -0
- package/src/billing/crypto/tron/address-convert.ts +80 -0
- package/src/billing/crypto/watcher-service.ts +24 -16
- package/src/db/schema/crypto.ts +1 -0
|
@@ -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);
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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:
|
|
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,
|
|
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:
|
|
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,
|
|
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";
|
package/dist/db/schema/crypto.js
CHANGED
|
@@ -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())`),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wopr-network/platform-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.60.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"./node-agent": "./dist/node-agent/index.js",
|
|
34
34
|
"./observability": "./dist/observability/index.js",
|
|
35
35
|
"./onboarding": "./dist/onboarding/index.js",
|
|
36
|
+
"./product-config": "./dist/product-config/index.js",
|
|
36
37
|
"./proxy": "./dist/proxy/index.js",
|
|
37
38
|
"./security": "./dist/security/index.js",
|
|
38
39
|
"./setup": "./dist/setup/index.js",
|
|
@@ -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
|
|
|
@@ -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
|
-
|
|
256
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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) });
|
package/src/db/schema/crypto.ts
CHANGED
|
@@ -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())`),
|