@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
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Seed product config tables with data for all 4 products.
3
+ *
4
+ * Usage:
5
+ * DATABASE_URL=postgres://... npx tsx scripts/seed-products.ts
6
+ * DATABASE_URL=postgres://... npx tsx scripts/seed-products.ts --slug=paperclip
7
+ */
8
+ import { eq } from "drizzle-orm";
9
+ import { drizzle } from "drizzle-orm/node-postgres";
10
+ import pg from "pg";
11
+ import { productFeatures, productFleetConfig } from "../src/db/schema/product-config.js";
12
+ import { productNavItems, products } from "../src/db/schema/products.js";
13
+
14
+ interface NavItem {
15
+ label: string;
16
+ href: string;
17
+ sortOrder: number;
18
+ requiresRole?: string;
19
+ }
20
+
21
+ interface FleetPreset {
22
+ containerImage: string;
23
+ lifecycle: "managed" | "ephemeral";
24
+ billingModel: "monthly" | "per_use" | "none";
25
+ maxInstances: number;
26
+ }
27
+
28
+ interface ProductPreset {
29
+ brandName: string;
30
+ productName: string;
31
+ tagline: string;
32
+ domain: string;
33
+ appDomain: string;
34
+ cookieDomain: string;
35
+ companyLegal: string;
36
+ priceLabel: string;
37
+ defaultImage: string;
38
+ emailSupport: string;
39
+ emailPrivacy: string;
40
+ emailLegal: string;
41
+ fromEmail: string;
42
+ homePath: string;
43
+ storagePrefix: string;
44
+ navItems: NavItem[];
45
+ fleet: FleetPreset;
46
+ }
47
+
48
+ const PRESETS: Record<string, ProductPreset> = {
49
+ wopr: {
50
+ brandName: "WOPR",
51
+ productName: "WOPR Bot",
52
+ tagline: "A $5/month supercomputer that manages your business.",
53
+ domain: "wopr.bot",
54
+ appDomain: "app.wopr.bot",
55
+ cookieDomain: ".wopr.bot",
56
+ companyLegal: "WOPR Network Inc.",
57
+ priceLabel: "$5/month",
58
+ defaultImage: "ghcr.io/wopr-network/wopr:latest",
59
+ emailSupport: "support@wopr.bot",
60
+ emailPrivacy: "privacy@wopr.bot",
61
+ emailLegal: "legal@wopr.bot",
62
+ fromEmail: "noreply@wopr.bot",
63
+ homePath: "/marketplace",
64
+ storagePrefix: "wopr",
65
+ navItems: [
66
+ { label: "Dashboard", href: "/dashboard", sortOrder: 0 },
67
+ { label: "Chat", href: "/chat", sortOrder: 1 },
68
+ { label: "Marketplace", href: "/marketplace", sortOrder: 2 },
69
+ { label: "Channels", href: "/channels", sortOrder: 3 },
70
+ { label: "Plugins", href: "/plugins", sortOrder: 4 },
71
+ { label: "Instances", href: "/instances", sortOrder: 5 },
72
+ { label: "Changesets", href: "/changesets", sortOrder: 6 },
73
+ { label: "Network", href: "/dashboard/network", sortOrder: 7 },
74
+ { label: "Fleet Health", href: "/fleet/health", sortOrder: 8 },
75
+ { label: "Credits", href: "/billing/credits", sortOrder: 9 },
76
+ { label: "Billing", href: "/billing/plans", sortOrder: 10 },
77
+ { label: "Settings", href: "/settings/profile", sortOrder: 11 },
78
+ { label: "Admin", href: "/admin/tenants", sortOrder: 12, requiresRole: "platform_admin" },
79
+ ],
80
+ fleet: {
81
+ containerImage: "ghcr.io/wopr-network/wopr:latest",
82
+ lifecycle: "managed",
83
+ billingModel: "monthly",
84
+ maxInstances: 5,
85
+ },
86
+ },
87
+ paperclip: {
88
+ brandName: "Paperclip",
89
+ productName: "Paperclip",
90
+ tagline: "AI agents that run your business.",
91
+ domain: "runpaperclip.com",
92
+ appDomain: "app.runpaperclip.com",
93
+ cookieDomain: ".runpaperclip.com",
94
+ companyLegal: "Paperclip AI Inc.",
95
+ priceLabel: "$5/month",
96
+ defaultImage: "ghcr.io/wopr-network/paperclip:managed",
97
+ emailSupport: "support@runpaperclip.com",
98
+ emailPrivacy: "privacy@runpaperclip.com",
99
+ emailLegal: "legal@runpaperclip.com",
100
+ fromEmail: "noreply@runpaperclip.com",
101
+ homePath: "/instances",
102
+ storagePrefix: "paperclip",
103
+ navItems: [
104
+ { label: "Instances", href: "/instances", sortOrder: 0 },
105
+ { label: "Credits", href: "/billing/credits", sortOrder: 1 },
106
+ { label: "Settings", href: "/settings/profile", sortOrder: 2 },
107
+ { label: "Admin", href: "/admin/tenants", sortOrder: 3, requiresRole: "platform_admin" },
108
+ ],
109
+ fleet: {
110
+ containerImage: "ghcr.io/wopr-network/paperclip:managed",
111
+ lifecycle: "managed",
112
+ billingModel: "monthly",
113
+ maxInstances: 5,
114
+ },
115
+ },
116
+ holyship: {
117
+ brandName: "Holy Ship",
118
+ productName: "Holy Ship",
119
+ tagline: "Ship it.",
120
+ domain: "holyship.wtf",
121
+ appDomain: "app.holyship.wtf",
122
+ cookieDomain: ".holyship.wtf",
123
+ companyLegal: "WOPR Network Inc.",
124
+ priceLabel: "",
125
+ defaultImage: "ghcr.io/wopr-network/holyship:latest",
126
+ emailSupport: "support@holyship.wtf",
127
+ emailPrivacy: "privacy@holyship.wtf",
128
+ emailLegal: "legal@holyship.wtf",
129
+ fromEmail: "noreply@holyship.wtf",
130
+ homePath: "/dashboard",
131
+ storagePrefix: "holyship",
132
+ navItems: [
133
+ { label: "Dashboard", href: "/dashboard", sortOrder: 0 },
134
+ { label: "Ship", href: "/ship", sortOrder: 1 },
135
+ { label: "Approvals", href: "/approvals", sortOrder: 2 },
136
+ { label: "Connect", href: "/connect", sortOrder: 3 },
137
+ { label: "Credits", href: "/billing/credits", sortOrder: 4 },
138
+ { label: "Settings", href: "/settings/profile", sortOrder: 5 },
139
+ { label: "Admin", href: "/admin/tenants", sortOrder: 6, requiresRole: "platform_admin" },
140
+ ],
141
+ fleet: {
142
+ containerImage: "ghcr.io/wopr-network/holyship:latest",
143
+ lifecycle: "ephemeral",
144
+ billingModel: "none",
145
+ maxInstances: 50,
146
+ },
147
+ },
148
+ nemoclaw: {
149
+ brandName: "NemoPod",
150
+ productName: "NemoPod",
151
+ tagline: "NVIDIA NeMo, one click away",
152
+ domain: "nemopod.com",
153
+ appDomain: "app.nemopod.com",
154
+ cookieDomain: ".nemopod.com",
155
+ companyLegal: "WOPR Network Inc.",
156
+ priceLabel: "$5 free credits",
157
+ defaultImage: "ghcr.io/wopr-network/nemoclaw:latest",
158
+ emailSupport: "support@nemopod.com",
159
+ emailPrivacy: "privacy@nemopod.com",
160
+ emailLegal: "legal@nemopod.com",
161
+ fromEmail: "noreply@nemopod.com",
162
+ homePath: "/instances",
163
+ storagePrefix: "nemopod",
164
+ navItems: [
165
+ { label: "NemoClaws", href: "/instances", sortOrder: 0 },
166
+ { label: "Credits", href: "/billing/credits", sortOrder: 1 },
167
+ { label: "Settings", href: "/settings/profile", sortOrder: 2 },
168
+ { label: "Admin", href: "/admin/tenants", sortOrder: 3, requiresRole: "platform_admin" },
169
+ ],
170
+ fleet: {
171
+ containerImage: "ghcr.io/wopr-network/nemoclaw:latest",
172
+ lifecycle: "managed",
173
+ billingModel: "monthly",
174
+ maxInstances: 5,
175
+ },
176
+ },
177
+ };
178
+
179
+ async function seed(): Promise<void> {
180
+ const dbUrl = process.env.DATABASE_URL;
181
+ if (!dbUrl) {
182
+ console.error("DATABASE_URL is required");
183
+ process.exit(1);
184
+ }
185
+
186
+ const slugArg = process.argv.find((a) => a.startsWith("--slug="))?.split("=")[1];
187
+ const slugs = slugArg ? [slugArg] : Object.keys(PRESETS);
188
+
189
+ const pool = new pg.Pool({ connectionString: dbUrl });
190
+ const db = drizzle(pool);
191
+
192
+ for (const slug of slugs) {
193
+ const preset = PRESETS[slug];
194
+ if (!preset) {
195
+ console.error(`Unknown product: ${slug}. Valid: ${Object.keys(PRESETS).join(", ")}`);
196
+ continue;
197
+ }
198
+
199
+ console.log(`Seeding ${slug}...`);
200
+ const { navItems, fleet, ...productData } = preset;
201
+
202
+ // Upsert product
203
+ const [product] = await db
204
+ .insert(products)
205
+ .values({ slug, ...productData })
206
+ .onConflictDoUpdate({
207
+ target: products.slug,
208
+ set: { ...productData, updatedAt: new Date() },
209
+ })
210
+ .returning();
211
+
212
+ if (!product) {
213
+ throw new Error(`Failed to upsert product: ${slug}`);
214
+ }
215
+
216
+ // Replace nav items
217
+ await db.delete(productNavItems).where(eq(productNavItems.productId, product.id));
218
+ if (navItems.length > 0) {
219
+ await db.insert(productNavItems).values(
220
+ navItems.map((item) => ({
221
+ productId: product.id,
222
+ label: item.label,
223
+ href: item.href,
224
+ sortOrder: item.sortOrder,
225
+ requiresRole: item.requiresRole ?? null,
226
+ enabled: true,
227
+ })),
228
+ );
229
+ }
230
+
231
+ // Upsert fleet config
232
+ await db
233
+ .insert(productFleetConfig)
234
+ .values({
235
+ productId: product.id,
236
+ containerImage: fleet.containerImage,
237
+ lifecycle: fleet.lifecycle,
238
+ billingModel: fleet.billingModel,
239
+ maxInstances: fleet.maxInstances,
240
+ })
241
+ .onConflictDoUpdate({
242
+ target: productFleetConfig.productId,
243
+ set: {
244
+ containerImage: fleet.containerImage,
245
+ lifecycle: fleet.lifecycle,
246
+ billingModel: fleet.billingModel,
247
+ maxInstances: fleet.maxInstances,
248
+ updatedAt: new Date(),
249
+ },
250
+ });
251
+
252
+ // Upsert default features (no-op if already exists)
253
+ await db
254
+ .insert(productFeatures)
255
+ .values({ productId: product.id })
256
+ .onConflictDoNothing();
257
+
258
+ console.log(` done (${navItems.length} nav items, fleet: ${fleet.lifecycle}/${fleet.billingModel})`);
259
+ }
260
+
261
+ await pool.end();
262
+ console.log("Seed complete.");
263
+ }
264
+
265
+ seed().catch((err: unknown) => {
266
+ console.error("Seed failed:", err);
267
+ process.exit(1);
268
+ });
@@ -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())`),
@@ -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";