@wopr-network/platform-core 1.58.1 → 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.
Files changed (63) hide show
  1. package/dist/billing/crypto/__tests__/key-server.test.js +3 -0
  2. package/dist/billing/crypto/key-server.js +1 -0
  3. package/dist/billing/crypto/oracle/coingecko.js +1 -0
  4. package/dist/billing/crypto/payment-method-store.d.ts +1 -0
  5. package/dist/billing/crypto/payment-method-store.js +3 -0
  6. package/dist/billing/crypto/tron/__tests__/address-convert.test.d.ts +1 -0
  7. package/dist/billing/crypto/tron/__tests__/address-convert.test.js +55 -0
  8. package/dist/billing/crypto/tron/address-convert.d.ts +14 -0
  9. package/dist/billing/crypto/tron/address-convert.js +83 -0
  10. package/dist/billing/crypto/watcher-service.js +21 -11
  11. package/dist/db/schema/crypto.d.ts +17 -0
  12. package/dist/db/schema/crypto.js +1 -0
  13. package/dist/db/schema/index.d.ts +2 -0
  14. package/dist/db/schema/index.js +2 -0
  15. package/dist/db/schema/product-config.d.ts +610 -0
  16. package/dist/db/schema/product-config.js +51 -0
  17. package/dist/db/schema/products.d.ts +565 -0
  18. package/dist/db/schema/products.js +43 -0
  19. package/dist/product-config/boot.d.ts +36 -0
  20. package/dist/product-config/boot.js +30 -0
  21. package/dist/product-config/drizzle-product-config-repository.d.ts +19 -0
  22. package/dist/product-config/drizzle-product-config-repository.js +200 -0
  23. package/dist/product-config/drizzle-product-config-repository.test.d.ts +1 -0
  24. package/dist/product-config/drizzle-product-config-repository.test.js +114 -0
  25. package/dist/product-config/index.d.ts +24 -0
  26. package/dist/product-config/index.js +37 -0
  27. package/dist/product-config/repository-types.d.ts +143 -0
  28. package/dist/product-config/repository-types.js +53 -0
  29. package/dist/product-config/service.d.ts +27 -0
  30. package/dist/product-config/service.js +74 -0
  31. package/dist/product-config/service.test.d.ts +1 -0
  32. package/dist/product-config/service.test.js +107 -0
  33. package/dist/trpc/index.d.ts +1 -0
  34. package/dist/trpc/index.js +1 -0
  35. package/dist/trpc/product-config-router.d.ts +117 -0
  36. package/dist/trpc/product-config-router.js +137 -0
  37. package/docs/specs/2026-03-23-product-config-db-migration-plan.md +2260 -0
  38. package/docs/specs/2026-03-23-product-config-db-migration.md +371 -0
  39. package/drizzle/migrations/0020_product_config_tables.sql +109 -0
  40. package/drizzle/migrations/0021_watcher_type_column.sql +3 -0
  41. package/drizzle/migrations/meta/_journal.json +7 -0
  42. package/package.json +1 -1
  43. package/scripts/seed-products.ts +268 -0
  44. package/src/billing/crypto/__tests__/key-server.test.ts +3 -0
  45. package/src/billing/crypto/key-server.ts +2 -0
  46. package/src/billing/crypto/oracle/coingecko.ts +1 -0
  47. package/src/billing/crypto/payment-method-store.ts +4 -0
  48. package/src/billing/crypto/tron/__tests__/address-convert.test.ts +67 -0
  49. package/src/billing/crypto/tron/address-convert.ts +80 -0
  50. package/src/billing/crypto/watcher-service.ts +24 -16
  51. package/src/db/schema/crypto.ts +1 -0
  52. package/src/db/schema/index.ts +2 -0
  53. package/src/db/schema/product-config.ts +56 -0
  54. package/src/db/schema/products.ts +58 -0
  55. package/src/product-config/boot.ts +57 -0
  56. package/src/product-config/drizzle-product-config-repository.test.ts +132 -0
  57. package/src/product-config/drizzle-product-config-repository.ts +229 -0
  58. package/src/product-config/index.ts +62 -0
  59. package/src/product-config/repository-types.ts +222 -0
  60. package/src/product-config/service.test.ts +127 -0
  61. package/src/product-config/service.ts +105 -0
  62. package/src/trpc/index.ts +1 -0
  63. package/src/trpc/product-config-router.ts +161 -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);
@@ -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())`),
@@ -44,6 +44,8 @@ export * from "./page-contexts.js";
44
44
  export * from "./platform-api-keys.js";
45
45
  export * from "./plugin-configs.js";
46
46
  export * from "./plugin-marketplace-content.js";
47
+ export * from "./product-config.js";
48
+ export * from "./products.js";
47
49
  export * from "./promotion-redemptions.js";
48
50
  export * from "./promotions.js";
49
51
  export * from "./provider-credentials.js";
@@ -44,6 +44,8 @@ export * from "./page-contexts.js";
44
44
  export * from "./platform-api-keys.js";
45
45
  export * from "./plugin-configs.js";
46
46
  export * from "./plugin-marketplace-content.js";
47
+ export * from "./product-config.js";
48
+ export * from "./products.js";
47
49
  export * from "./promotion-redemptions.js";
48
50
  export * from "./promotions.js";
49
51
  export * from "./provider-credentials.js";