@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.
- 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/dist/db/schema/index.d.ts +2 -0
- package/dist/db/schema/index.js +2 -0
- package/dist/db/schema/product-config.d.ts +610 -0
- package/dist/db/schema/product-config.js +51 -0
- package/dist/db/schema/products.d.ts +565 -0
- package/dist/db/schema/products.js +43 -0
- package/dist/product-config/boot.d.ts +36 -0
- package/dist/product-config/boot.js +30 -0
- package/dist/product-config/drizzle-product-config-repository.d.ts +19 -0
- package/dist/product-config/drizzle-product-config-repository.js +200 -0
- package/dist/product-config/drizzle-product-config-repository.test.d.ts +1 -0
- package/dist/product-config/drizzle-product-config-repository.test.js +114 -0
- package/dist/product-config/index.d.ts +24 -0
- package/dist/product-config/index.js +37 -0
- package/dist/product-config/repository-types.d.ts +143 -0
- package/dist/product-config/repository-types.js +53 -0
- package/dist/product-config/service.d.ts +27 -0
- package/dist/product-config/service.js +74 -0
- package/dist/product-config/service.test.d.ts +1 -0
- package/dist/product-config/service.test.js +107 -0
- package/dist/trpc/index.d.ts +1 -0
- package/dist/trpc/index.js +1 -0
- package/dist/trpc/product-config-router.d.ts +117 -0
- package/dist/trpc/product-config-router.js +137 -0
- package/docs/specs/2026-03-23-product-config-db-migration-plan.md +2260 -0
- package/docs/specs/2026-03-23-product-config-db-migration.md +371 -0
- package/drizzle/migrations/0020_product_config_tables.sql +109 -0
- package/drizzle/migrations/0021_watcher_type_column.sql +3 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/scripts/seed-products.ts +268 -0
- 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
- package/src/db/schema/index.ts +2 -0
- package/src/db/schema/product-config.ts +56 -0
- package/src/db/schema/products.ts +58 -0
- package/src/product-config/boot.ts +57 -0
- package/src/product-config/drizzle-product-config-repository.test.ts +132 -0
- package/src/product-config/drizzle-product-config-repository.ts +229 -0
- package/src/product-config/index.ts +62 -0
- package/src/product-config/repository-types.ts +222 -0
- package/src/product-config/service.test.ts +127 -0
- package/src/product-config/service.ts +105 -0
- package/src/trpc/index.ts +1 -0
- 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
|
|
|
@@ -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())`),
|
package/src/db/schema/index.ts
CHANGED
|
@@ -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";
|