@wopr-network/platform-core 1.64.0 → 1.66.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 (40) hide show
  1. package/dist/billing/crypto/index.d.ts +2 -0
  2. package/dist/billing/crypto/index.js +1 -0
  3. package/dist/billing/crypto/key-server-entry.js +37 -12
  4. package/dist/billing/crypto/key-server.d.ts +12 -0
  5. package/dist/billing/crypto/key-server.js +24 -4
  6. package/dist/billing/crypto/payment-method-store.d.ts +3 -0
  7. package/dist/billing/crypto/payment-method-store.js +9 -0
  8. package/dist/billing/crypto/plugin/__tests__/integration.test.d.ts +1 -0
  9. package/dist/billing/crypto/plugin/__tests__/integration.test.js +58 -0
  10. package/dist/billing/crypto/plugin/__tests__/interfaces.test.d.ts +1 -0
  11. package/dist/billing/crypto/plugin/__tests__/interfaces.test.js +46 -0
  12. package/dist/billing/crypto/plugin/__tests__/registry.test.d.ts +1 -0
  13. package/dist/billing/crypto/plugin/__tests__/registry.test.js +49 -0
  14. package/dist/billing/crypto/plugin/index.d.ts +2 -0
  15. package/dist/billing/crypto/plugin/index.js +1 -0
  16. package/dist/billing/crypto/plugin/interfaces.d.ts +97 -0
  17. package/dist/billing/crypto/plugin/interfaces.js +2 -0
  18. package/dist/billing/crypto/plugin/registry.d.ts +8 -0
  19. package/dist/billing/crypto/plugin/registry.js +21 -0
  20. package/dist/billing/crypto/plugin-watcher-service.d.ts +32 -0
  21. package/dist/billing/crypto/plugin-watcher-service.js +113 -0
  22. package/dist/db/schema/crypto.d.ts +328 -0
  23. package/dist/db/schema/crypto.js +33 -1
  24. package/dist/db/schema/snapshots.d.ts +1 -1
  25. package/docs/superpowers/specs/2026-03-24-crypto-plugin-architecture-design.md +48 -34
  26. package/drizzle/migrations/0023_key_rings_table.sql +35 -0
  27. package/drizzle/migrations/0024_backfill_key_rings.sql +75 -0
  28. package/package.json +6 -1
  29. package/src/billing/crypto/index.ts +9 -0
  30. package/src/billing/crypto/key-server-entry.ts +48 -12
  31. package/src/billing/crypto/key-server.ts +28 -3
  32. package/src/billing/crypto/payment-method-store.ts +12 -0
  33. package/src/billing/crypto/plugin/__tests__/integration.test.ts +64 -0
  34. package/src/billing/crypto/plugin/__tests__/interfaces.test.ts +51 -0
  35. package/src/billing/crypto/plugin/__tests__/registry.test.ts +58 -0
  36. package/src/billing/crypto/plugin/index.ts +17 -0
  37. package/src/billing/crypto/plugin/interfaces.ts +106 -0
  38. package/src/billing/crypto/plugin/registry.ts +26 -0
  39. package/src/billing/crypto/plugin-watcher-service.ts +148 -0
  40. package/src/db/schema/crypto.ts +43 -1
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Plugin-driven watcher service — replaces the hardcoded watcher-service.ts.
3
+ *
4
+ * Instead of importing BtcWatcher/EvmWatcher/EthWatcher directly,
5
+ * this delegates to IChainPlugin.createWatcher() from the plugin registry.
6
+ * Adding a new chain = register a plugin + INSERT a payment_methods row.
7
+ *
8
+ * Payment flow is unchanged:
9
+ * plugin.poll() -> PaymentEvent[] -> handlePayment() -> credit + webhook
10
+ */
11
+ import { handlePayment } from "./watcher-service.js";
12
+ /** Map legacy watcher_type values to plugin IDs for backward compatibility. */
13
+ const WATCHER_TYPE_TO_PLUGIN = {
14
+ utxo: "bitcoin",
15
+ evm: "evm",
16
+ };
17
+ function resolvePlugin(registry, method) {
18
+ // Prefer explicit plugin_id, fall back to watcher_type mapping
19
+ const id = method.pluginId ?? WATCHER_TYPE_TO_PLUGIN[method.watcherType];
20
+ return id ? registry.get(id) : undefined;
21
+ }
22
+ /**
23
+ * Boot plugin-driven watchers for all enabled payment methods.
24
+ *
25
+ * Returns a cleanup function that stops all poll timers and watchers.
26
+ */
27
+ export async function startPluginWatchers(opts) {
28
+ const { db, chargeStore, methodStore, cursorStore, oracle, registry } = opts;
29
+ const pollMs = opts.pollIntervalMs ?? 15_000;
30
+ const log = opts.log ?? (() => { });
31
+ const methods = await methodStore.listEnabled();
32
+ const timers = [];
33
+ const watchers = [];
34
+ for (const method of methods) {
35
+ if (!method.rpcUrl)
36
+ continue;
37
+ const plugin = resolvePlugin(registry, method);
38
+ if (!plugin) {
39
+ log("No plugin found, skipping method", { id: method.id, chain: method.chain, watcherType: method.watcherType });
40
+ continue;
41
+ }
42
+ const watcher = plugin.createWatcher({
43
+ rpcUrl: method.rpcUrl,
44
+ rpcHeaders: JSON.parse(method.rpcHeaders ?? "{}"),
45
+ oracle,
46
+ cursorStore,
47
+ token: method.token,
48
+ chain: method.chain,
49
+ contractAddress: method.contractAddress ?? undefined,
50
+ decimals: method.decimals,
51
+ confirmations: method.confirmations,
52
+ });
53
+ try {
54
+ await watcher.init();
55
+ }
56
+ catch (err) {
57
+ log("Watcher init failed, skipping", { chain: method.chain, token: method.token, error: String(err) });
58
+ continue;
59
+ }
60
+ // Seed watched addresses from active charges
61
+ const active = await chargeStore.listActiveDepositAddresses();
62
+ const addrs = active.filter((a) => a.chain === method.chain && a.token === method.token).map((a) => a.address);
63
+ watcher.setWatchedAddresses(addrs);
64
+ watchers.push(watcher);
65
+ log(`Plugin watcher started (${method.chain}:${method.token})`, {
66
+ plugin: plugin.pluginId,
67
+ addresses: addrs.length,
68
+ });
69
+ let polling = false;
70
+ timers.push(setInterval(async () => {
71
+ if (polling)
72
+ return;
73
+ polling = true;
74
+ try {
75
+ // Refresh watched addresses each cycle
76
+ const fresh = await chargeStore.listActiveDepositAddresses();
77
+ const freshAddrs = fresh
78
+ .filter((a) => a.chain === method.chain && a.token === method.token)
79
+ .map((a) => a.address);
80
+ watcher.setWatchedAddresses(freshAddrs);
81
+ const events = await watcher.poll();
82
+ for (const ev of events) {
83
+ log("Plugin payment", {
84
+ chain: ev.chain,
85
+ token: ev.token,
86
+ to: ev.to,
87
+ txHash: ev.txHash,
88
+ confirmations: ev.confirmations,
89
+ });
90
+ await handlePayment(db, chargeStore, ev.to, ev.rawAmount, {
91
+ txHash: ev.txHash,
92
+ confirmations: ev.confirmations,
93
+ confirmationsRequired: ev.confirmationsRequired,
94
+ amountReceivedCents: ev.amountUsdCents,
95
+ }, log);
96
+ }
97
+ }
98
+ catch (err) {
99
+ log("Plugin poll error", { chain: method.chain, token: method.token, error: String(err) });
100
+ }
101
+ finally {
102
+ polling = false;
103
+ }
104
+ }, pollMs));
105
+ }
106
+ log("All plugin watchers started", { count: watchers.length, pollMs });
107
+ return () => {
108
+ for (const t of timers)
109
+ clearInterval(t);
110
+ for (const w of watchers)
111
+ w.stop();
112
+ };
113
+ }
@@ -784,6 +784,57 @@ export declare const paymentMethods: import("drizzle-orm/pg-core").PgTableWithCo
784
784
  identity: undefined;
785
785
  generated: undefined;
786
786
  }, {}, {}>;
787
+ keyRingId: import("drizzle-orm/pg-core").PgColumn<{
788
+ name: "key_ring_id";
789
+ tableName: "payment_methods";
790
+ dataType: "string";
791
+ columnType: "PgText";
792
+ data: string;
793
+ driverParam: string;
794
+ notNull: false;
795
+ hasDefault: false;
796
+ isPrimaryKey: false;
797
+ isAutoincrement: false;
798
+ hasRuntimeDefault: false;
799
+ enumValues: [string, ...string[]];
800
+ baseColumn: never;
801
+ identity: undefined;
802
+ generated: undefined;
803
+ }, {}, {}>;
804
+ encoding: import("drizzle-orm/pg-core").PgColumn<{
805
+ name: "encoding";
806
+ tableName: "payment_methods";
807
+ dataType: "string";
808
+ columnType: "PgText";
809
+ data: string;
810
+ driverParam: string;
811
+ notNull: false;
812
+ hasDefault: false;
813
+ isPrimaryKey: false;
814
+ isAutoincrement: false;
815
+ hasRuntimeDefault: false;
816
+ enumValues: [string, ...string[]];
817
+ baseColumn: never;
818
+ identity: undefined;
819
+ generated: undefined;
820
+ }, {}, {}>;
821
+ pluginId: import("drizzle-orm/pg-core").PgColumn<{
822
+ name: "plugin_id";
823
+ tableName: "payment_methods";
824
+ dataType: "string";
825
+ columnType: "PgText";
826
+ data: string;
827
+ driverParam: string;
828
+ notNull: false;
829
+ hasDefault: false;
830
+ isPrimaryKey: false;
831
+ isAutoincrement: false;
832
+ hasRuntimeDefault: false;
833
+ enumValues: [string, ...string[]];
834
+ baseColumn: never;
835
+ identity: undefined;
836
+ generated: undefined;
837
+ }, {}, {}>;
787
838
  createdAt: import("drizzle-orm/pg-core").PgColumn<{
788
839
  name: "created_at";
789
840
  tableName: "payment_methods";
@@ -1237,3 +1288,280 @@ export declare const watcherProcessed: import("drizzle-orm/pg-core").PgTableWith
1237
1288
  };
1238
1289
  dialect: "pg";
1239
1290
  }>;
1291
+ /**
1292
+ * Key rings — decouples key material (xpub/seed) from payment methods.
1293
+ * Each key ring maps to a BIP-44 coin type + account index.
1294
+ */
1295
+ export declare const keyRings: import("drizzle-orm/pg-core").PgTableWithColumns<{
1296
+ name: "key_rings";
1297
+ schema: undefined;
1298
+ columns: {
1299
+ id: import("drizzle-orm/pg-core").PgColumn<{
1300
+ name: "id";
1301
+ tableName: "key_rings";
1302
+ dataType: "string";
1303
+ columnType: "PgText";
1304
+ data: string;
1305
+ driverParam: string;
1306
+ notNull: true;
1307
+ hasDefault: false;
1308
+ isPrimaryKey: true;
1309
+ isAutoincrement: false;
1310
+ hasRuntimeDefault: false;
1311
+ enumValues: [string, ...string[]];
1312
+ baseColumn: never;
1313
+ identity: undefined;
1314
+ generated: undefined;
1315
+ }, {}, {}>;
1316
+ curve: import("drizzle-orm/pg-core").PgColumn<{
1317
+ name: "curve";
1318
+ tableName: "key_rings";
1319
+ dataType: "string";
1320
+ columnType: "PgText";
1321
+ data: string;
1322
+ driverParam: string;
1323
+ notNull: true;
1324
+ hasDefault: false;
1325
+ isPrimaryKey: false;
1326
+ isAutoincrement: false;
1327
+ hasRuntimeDefault: false;
1328
+ enumValues: [string, ...string[]];
1329
+ baseColumn: never;
1330
+ identity: undefined;
1331
+ generated: undefined;
1332
+ }, {}, {}>;
1333
+ derivationScheme: import("drizzle-orm/pg-core").PgColumn<{
1334
+ name: "derivation_scheme";
1335
+ tableName: "key_rings";
1336
+ dataType: "string";
1337
+ columnType: "PgText";
1338
+ data: string;
1339
+ driverParam: string;
1340
+ notNull: true;
1341
+ hasDefault: false;
1342
+ isPrimaryKey: false;
1343
+ isAutoincrement: false;
1344
+ hasRuntimeDefault: false;
1345
+ enumValues: [string, ...string[]];
1346
+ baseColumn: never;
1347
+ identity: undefined;
1348
+ generated: undefined;
1349
+ }, {}, {}>;
1350
+ derivationMode: import("drizzle-orm/pg-core").PgColumn<{
1351
+ name: "derivation_mode";
1352
+ tableName: "key_rings";
1353
+ dataType: "string";
1354
+ columnType: "PgText";
1355
+ data: string;
1356
+ driverParam: string;
1357
+ notNull: true;
1358
+ hasDefault: true;
1359
+ isPrimaryKey: false;
1360
+ isAutoincrement: false;
1361
+ hasRuntimeDefault: false;
1362
+ enumValues: [string, ...string[]];
1363
+ baseColumn: never;
1364
+ identity: undefined;
1365
+ generated: undefined;
1366
+ }, {}, {}>;
1367
+ keyMaterial: import("drizzle-orm/pg-core").PgColumn<{
1368
+ name: "key_material";
1369
+ tableName: "key_rings";
1370
+ dataType: "string";
1371
+ columnType: "PgText";
1372
+ data: string;
1373
+ driverParam: string;
1374
+ notNull: true;
1375
+ hasDefault: true;
1376
+ isPrimaryKey: false;
1377
+ isAutoincrement: false;
1378
+ hasRuntimeDefault: false;
1379
+ enumValues: [string, ...string[]];
1380
+ baseColumn: never;
1381
+ identity: undefined;
1382
+ generated: undefined;
1383
+ }, {}, {}>;
1384
+ coinType: import("drizzle-orm/pg-core").PgColumn<{
1385
+ name: "coin_type";
1386
+ tableName: "key_rings";
1387
+ dataType: "number";
1388
+ columnType: "PgInteger";
1389
+ data: number;
1390
+ driverParam: string | number;
1391
+ notNull: true;
1392
+ hasDefault: false;
1393
+ isPrimaryKey: false;
1394
+ isAutoincrement: false;
1395
+ hasRuntimeDefault: false;
1396
+ enumValues: undefined;
1397
+ baseColumn: never;
1398
+ identity: undefined;
1399
+ generated: undefined;
1400
+ }, {}, {}>;
1401
+ accountIndex: import("drizzle-orm/pg-core").PgColumn<{
1402
+ name: "account_index";
1403
+ tableName: "key_rings";
1404
+ dataType: "number";
1405
+ columnType: "PgInteger";
1406
+ data: number;
1407
+ driverParam: string | number;
1408
+ notNull: true;
1409
+ hasDefault: true;
1410
+ isPrimaryKey: false;
1411
+ isAutoincrement: false;
1412
+ hasRuntimeDefault: false;
1413
+ enumValues: undefined;
1414
+ baseColumn: never;
1415
+ identity: undefined;
1416
+ generated: undefined;
1417
+ }, {}, {}>;
1418
+ createdAt: import("drizzle-orm/pg-core").PgColumn<{
1419
+ name: "created_at";
1420
+ tableName: "key_rings";
1421
+ dataType: "string";
1422
+ columnType: "PgText";
1423
+ data: string;
1424
+ driverParam: string;
1425
+ notNull: true;
1426
+ hasDefault: true;
1427
+ isPrimaryKey: false;
1428
+ isAutoincrement: false;
1429
+ hasRuntimeDefault: false;
1430
+ enumValues: [string, ...string[]];
1431
+ baseColumn: never;
1432
+ identity: undefined;
1433
+ generated: undefined;
1434
+ }, {}, {}>;
1435
+ };
1436
+ dialect: "pg";
1437
+ }>;
1438
+ /**
1439
+ * Pre-derived address pool — for Ed25519 chains that need offline derivation.
1440
+ * Addresses are derived in batches and assigned on demand.
1441
+ */
1442
+ export declare const addressPool: import("drizzle-orm/pg-core").PgTableWithColumns<{
1443
+ name: "address_pool";
1444
+ schema: undefined;
1445
+ columns: {
1446
+ id: import("drizzle-orm/pg-core").PgColumn<{
1447
+ name: "id";
1448
+ tableName: "address_pool";
1449
+ dataType: "number";
1450
+ columnType: "PgSerial";
1451
+ data: number;
1452
+ driverParam: number;
1453
+ notNull: true;
1454
+ hasDefault: true;
1455
+ isPrimaryKey: true;
1456
+ isAutoincrement: false;
1457
+ hasRuntimeDefault: false;
1458
+ enumValues: undefined;
1459
+ baseColumn: never;
1460
+ identity: undefined;
1461
+ generated: undefined;
1462
+ }, {}, {}>;
1463
+ keyRingId: import("drizzle-orm/pg-core").PgColumn<{
1464
+ name: "key_ring_id";
1465
+ tableName: "address_pool";
1466
+ dataType: "string";
1467
+ columnType: "PgText";
1468
+ data: string;
1469
+ driverParam: string;
1470
+ notNull: true;
1471
+ hasDefault: false;
1472
+ isPrimaryKey: false;
1473
+ isAutoincrement: false;
1474
+ hasRuntimeDefault: false;
1475
+ enumValues: [string, ...string[]];
1476
+ baseColumn: never;
1477
+ identity: undefined;
1478
+ generated: undefined;
1479
+ }, {}, {}>;
1480
+ derivationIndex: import("drizzle-orm/pg-core").PgColumn<{
1481
+ name: "derivation_index";
1482
+ tableName: "address_pool";
1483
+ dataType: "number";
1484
+ columnType: "PgInteger";
1485
+ data: number;
1486
+ driverParam: string | number;
1487
+ notNull: true;
1488
+ hasDefault: false;
1489
+ isPrimaryKey: false;
1490
+ isAutoincrement: false;
1491
+ hasRuntimeDefault: false;
1492
+ enumValues: undefined;
1493
+ baseColumn: never;
1494
+ identity: undefined;
1495
+ generated: undefined;
1496
+ }, {}, {}>;
1497
+ publicKey: import("drizzle-orm/pg-core").PgColumn<{
1498
+ name: "public_key";
1499
+ tableName: "address_pool";
1500
+ dataType: "string";
1501
+ columnType: "PgText";
1502
+ data: string;
1503
+ driverParam: string;
1504
+ notNull: true;
1505
+ hasDefault: false;
1506
+ isPrimaryKey: false;
1507
+ isAutoincrement: false;
1508
+ hasRuntimeDefault: false;
1509
+ enumValues: [string, ...string[]];
1510
+ baseColumn: never;
1511
+ identity: undefined;
1512
+ generated: undefined;
1513
+ }, {}, {}>;
1514
+ address: import("drizzle-orm/pg-core").PgColumn<{
1515
+ name: "address";
1516
+ tableName: "address_pool";
1517
+ dataType: "string";
1518
+ columnType: "PgText";
1519
+ data: string;
1520
+ driverParam: string;
1521
+ notNull: true;
1522
+ hasDefault: false;
1523
+ isPrimaryKey: false;
1524
+ isAutoincrement: false;
1525
+ hasRuntimeDefault: false;
1526
+ enumValues: [string, ...string[]];
1527
+ baseColumn: never;
1528
+ identity: undefined;
1529
+ generated: undefined;
1530
+ }, {}, {}>;
1531
+ assignedTo: import("drizzle-orm/pg-core").PgColumn<{
1532
+ name: "assigned_to";
1533
+ tableName: "address_pool";
1534
+ dataType: "string";
1535
+ columnType: "PgText";
1536
+ data: string;
1537
+ driverParam: string;
1538
+ notNull: false;
1539
+ hasDefault: false;
1540
+ isPrimaryKey: false;
1541
+ isAutoincrement: false;
1542
+ hasRuntimeDefault: false;
1543
+ enumValues: [string, ...string[]];
1544
+ baseColumn: never;
1545
+ identity: undefined;
1546
+ generated: undefined;
1547
+ }, {}, {}>;
1548
+ createdAt: import("drizzle-orm/pg-core").PgColumn<{
1549
+ name: "created_at";
1550
+ tableName: "address_pool";
1551
+ dataType: "string";
1552
+ columnType: "PgText";
1553
+ data: string;
1554
+ driverParam: string;
1555
+ notNull: true;
1556
+ hasDefault: true;
1557
+ isPrimaryKey: false;
1558
+ isAutoincrement: false;
1559
+ hasRuntimeDefault: false;
1560
+ enumValues: [string, ...string[]];
1561
+ baseColumn: never;
1562
+ identity: undefined;
1563
+ generated: undefined;
1564
+ }, {}, {}>;
1565
+ };
1566
+ dialect: "pg";
1567
+ }>;
@@ -1,5 +1,5 @@
1
1
  import { sql } from "drizzle-orm";
2
- import { boolean, index, integer, pgTable, primaryKey, text } from "drizzle-orm/pg-core";
2
+ import { boolean, index, integer, pgTable, primaryKey, serial, text, uniqueIndex } from "drizzle-orm/pg-core";
3
3
  /**
4
4
  * Crypto payment charges — tracks the lifecycle of each payment.
5
5
  * reference_id is the charge ID (e.g. "btc:bc1q...").
@@ -82,6 +82,9 @@ export const paymentMethods = pgTable("payment_methods", {
82
82
  oracleAssetId: text("oracle_asset_id"), // CoinGecko slug (e.g. "bitcoin", "tron"). Null = stablecoin (1:1 USD) or use token symbol fallback.
83
83
  confirmations: integer("confirmations").notNull().default(1),
84
84
  nextIndex: integer("next_index").notNull().default(0), // atomic derivation counter, never reuses
85
+ keyRingId: text("key_ring_id"), // FK to key_rings.id (nullable during migration)
86
+ encoding: text("encoding"), // address encoding override (e.g. "bech32", "p2pkh", "evm")
87
+ pluginId: text("plugin_id"), // plugin identifier (e.g. "evm", "utxo", "solana")
85
88
  createdAt: text("created_at").notNull().default(sql `(now())`),
86
89
  });
87
90
  /**
@@ -134,3 +137,32 @@ export const watcherProcessed = pgTable("watcher_processed", {
134
137
  txId: text("tx_id").notNull(),
135
138
  processedAt: text("processed_at").notNull().default(sql `(now())`),
136
139
  }, (table) => [primaryKey({ columns: [table.watcherId, table.txId] })]);
140
+ /**
141
+ * Key rings — decouples key material (xpub/seed) from payment methods.
142
+ * Each key ring maps to a BIP-44 coin type + account index.
143
+ */
144
+ export const keyRings = pgTable("key_rings", {
145
+ id: text("id").primaryKey(),
146
+ curve: text("curve").notNull(), // "secp256k1" | "ed25519"
147
+ derivationScheme: text("derivation_scheme").notNull(), // "bip32" | "slip10" | "ed25519-hd"
148
+ derivationMode: text("derivation_mode").notNull().default("on-demand"), // "on-demand" | "pre-derived"
149
+ keyMaterial: text("key_material").notNull().default("{}"), // JSON: { xpub: "..." }
150
+ coinType: integer("coin_type").notNull(), // BIP-44 coin type
151
+ accountIndex: integer("account_index").notNull().default(0),
152
+ createdAt: text("created_at").notNull().default(sql `(now())`),
153
+ }, (table) => [uniqueIndex("key_rings_path_unique").on(table.coinType, table.accountIndex)]);
154
+ /**
155
+ * Pre-derived address pool — for Ed25519 chains that need offline derivation.
156
+ * Addresses are derived in batches and assigned on demand.
157
+ */
158
+ export const addressPool = pgTable("address_pool", {
159
+ id: serial("id").primaryKey(),
160
+ keyRingId: text("key_ring_id")
161
+ .notNull()
162
+ .references(() => keyRings.id),
163
+ derivationIndex: integer("derivation_index").notNull(),
164
+ publicKey: text("public_key").notNull(),
165
+ address: text("address").notNull(),
166
+ assignedTo: text("assigned_to"), // charge reference or tenant ID
167
+ createdAt: text("created_at").notNull().default(sql `(now())`),
168
+ }, (table) => [uniqueIndex("address_pool_ring_index").on(table.keyRingId, table.derivationIndex)]);
@@ -92,7 +92,7 @@ export declare const snapshots: import("drizzle-orm/pg-core").PgTableWithColumns
92
92
  tableName: "snapshots";
93
93
  dataType: "string";
94
94
  columnType: "PgText";
95
- data: "nightly" | "on-demand" | "pre-restore";
95
+ data: "on-demand" | "nightly" | "pre-restore";
96
96
  driverParam: string;
97
97
  notNull: true;
98
98
  hasDefault: true;
@@ -174,19 +174,32 @@ interface WatcherOpts {
174
174
 
175
175
  Replaced by `key_rings` unique constraint on `(coin_type, account_index)`.
176
176
 
177
- ## Plugin Packages
177
+ ## Plugin Monorepo
178
178
 
179
- Each chain is its own npm package:
179
+ Single npm package: `@wopr-network/crypto-plugins`. One version, one CI, one install.
180
180
 
181
- | Package | Curve | Chains |
182
- |---------|-------|--------|
183
- | `crypto-plugin-evm` | secp256k1 | ETH, Base, Arbitrum, Polygon, Optimism, Avalanche, BSC |
184
- | `crypto-plugin-utxo-common` | | Shared UTXO watcher, bitcoind RPC, sweep logic |
185
- | `crypto-plugin-bitcoin` | secp256k1 | BTC (depends on utxo-common) |
186
- | `crypto-plugin-litecoin` | secp256k1 | LTC (depends on utxo-common) |
187
- | `crypto-plugin-dogecoin` | secp256k1 | DOGE (depends on utxo-common) |
188
- | `crypto-plugin-tron` | secp256k1 | TRX + TRC-20 (handles T-address ↔ hex conversion internally) |
189
- | `crypto-plugin-solana` | ed25519 | SOL + SPL tokens |
181
+ ```
182
+ crypto-plugins/
183
+ src/
184
+ evm/ # ETH, Base, Arbitrum, Polygon, Optimism, Avalanche, BSC
185
+ bitcoin/ # BTC (uses shared/utxo)
186
+ litecoin/ # LTC (uses shared/utxo)
187
+ dogecoin/ # DOGE (uses shared/utxo)
188
+ tron/ # TRX + TRC-20 (T-address ↔ hex conversion)
189
+ solana/ # SOL + SPL tokens (Ed25519)
190
+ shared/ # UTXO common (bitcoind RPC, watcher, sweep), test helpers
191
+ index.ts # barrel export of all plugins
192
+ package.json # peer dep on @wopr-network/platform-core
193
+ ```
194
+
195
+ | Plugin | Curve | Subpath | Chains |
196
+ |--------|-------|---------|--------|
197
+ | `evm` | secp256k1 | `@wopr-network/crypto-plugins/evm` | ETH + all EVM L1/L2s |
198
+ | `bitcoin` | secp256k1 | `@wopr-network/crypto-plugins/bitcoin` | BTC |
199
+ | `litecoin` | secp256k1 | `@wopr-network/crypto-plugins/litecoin` | LTC |
200
+ | `dogecoin` | secp256k1 | `@wopr-network/crypto-plugins/dogecoin` | DOGE |
201
+ | `tron` | secp256k1 | `@wopr-network/crypto-plugins/tron` | TRX + TRC-20 |
202
+ | `solana` | ed25519 | `@wopr-network/crypto-plugins/solana` | SOL + SPL |
190
203
 
191
204
  Platform-core exports interfaces as a peer dependency:
192
205
  ```
@@ -198,9 +211,9 @@ Platform-core exports interfaces as a peer dependency:
198
211
  Explicit imports in the key-server entry point:
199
212
 
200
213
  ```ts
201
- import { evmPlugin } from "@wopr-network/crypto-plugin-evm";
202
- import { bitcoinPlugin } from "@wopr-network/crypto-plugin-bitcoin";
203
- import { solanaPlugin } from "@wopr-network/crypto-plugin-solana";
214
+ import { evmPlugin } from "@wopr-network/crypto-plugins/evm";
215
+ import { bitcoinPlugin } from "@wopr-network/crypto-plugins/bitcoin";
216
+ import { solanaPlugin } from "@wopr-network/crypto-plugins/solana";
204
217
 
205
218
  const registry = new PluginRegistry();
206
219
  registry.register(evmPlugin);
@@ -245,21 +258,18 @@ No per-chain env vars. RPC URLs and headers come from the chain server.
245
258
 
246
259
  ## Adding a New Chain
247
260
 
248
- ### secp256k1 chain (e.g. XRP)
249
- 1. `npm install @wopr-network/crypto-plugin-xrp`
250
- 2. Add `registry.register(xrpPlugin)` to entry point
251
- 3. Insert `key_ring` row (curve: secp256k1, derivation_mode: on-demand, coin_type: 144)
252
- 4. Insert `payment_method` row (plugin_id: "xrp", encoding: "base58-xrp", key_ring_id: "xrp-main")
253
- 5. Restart
254
-
255
- ### Ed25519 chain (e.g. Solana)
256
- 1. `npm install @wopr-network/crypto-plugin-solana`
257
- 2. Add `registry.register(solanaPlugin)` to entry point
258
- 3. Insert `key_ring` row (curve: ed25519, derivation_mode: pool, coin_type: 501)
259
- 4. Insert `payment_method` row (plugin_id: "solana", encoding: "base58-solana", key_ring_id: "sol-main")
260
- 5. Replenish pool: `openssl enc ... -d | npx @wopr-network/crypto-sweep replenish --chain solana --count 200`
261
+ ### Existing chain type (e.g. XRP — secp256k1)
262
+ 1. Add `src/xrp/` to `@wopr-network/crypto-plugins` with encoder + watcher + sweeper
263
+ 2. Add subpath export, bump version, publish
264
+ 3. Add `registry.register(xrpPlugin)` to key-server entry point
265
+ 4. `npm update @wopr-network/crypto-plugins`
266
+ 5. Insert `key_ring` row + `payment_method` row in DB
261
267
  6. Restart
262
268
 
269
+ ### New curve (e.g. Solana — Ed25519)
270
+ Same as above, plus:
271
+ - Replenish pool: `openssl enc ... -d | npx @wopr-network/crypto-sweep replenish --chain solana --count 200`
272
+
263
273
  No code changes to platform-core for either path.
264
274
 
265
275
  ## Client API
@@ -282,19 +292,23 @@ Only change: admin `POST /admin/chains` takes `key_ring_id` + `encoding` + `plug
282
292
  - Backfill: create key_ring rows from existing xpub + address_type data, set `key_ring_id` + `encoding` + `plugin_id` on existing payment_methods
283
293
  - DB migration 2: drop old columns (`xpub`, `address_type`, `watcher_type`), drop `path_allocations`
284
294
 
285
- ### Phase 2 — Extract existing chains into plugins
286
- - `crypto-plugin-evm` from current `evm/watcher.ts`, `evm/eth-watcher.ts`
287
- - `crypto-plugin-utxo-common` + bitcoin/litecoin/dogecoin — from current UTXO watcher code
288
- - `crypto-plugin-tron`from current tron code (address conversion handled internally by plugin)
295
+ ### Phase 2 — Extract existing chains into `@wopr-network/crypto-plugins` monorepo
296
+ - New repo: `wopr-network/crypto-plugins`
297
+ - `src/evm/` — from current `evm/watcher.ts`, `evm/eth-watcher.ts`
298
+ - `src/shared/utxo/`shared bitcoind RPC, UTXO watcher, sweep base
299
+ - `src/bitcoin/`, `src/litecoin/`, `src/dogecoin/` — thin configs on shared/utxo
300
+ - `src/tron/` — from current tron code (address conversion internal)
301
+ - `src/shared/test-helpers/` — mock RPC, test fixtures
289
302
  - All existing behavior preserved, just restructured
303
+ - One npm package, one version, subpath exports per chain
290
304
 
291
305
  ### Phase 3 — Unified sweep CLI
292
- - `@wopr-network/crypto-sweep` sweep + replenish modes
306
+ - Sweep + replenish modes live in `@wopr-network/crypto-plugins/sweep` (same monorepo, separate entrypoint)
293
307
  - Replaces `sweep-stablecoins.ts` and `sweep-tron.ts`
294
308
 
295
309
  ### Phase 4 — New chains
296
- - `crypto-plugin-solana` — first Ed25519 chain, proves pool model
297
- - Then TON, XRP, etc.
310
+ - `src/solana/` in crypto-plugins — first Ed25519 chain, proves pool model
311
+ - Then TON, XRP, etc. — each is a new directory + subpath export + version bump
298
312
 
299
313
  Each phase is independently deployable. Phase 1+2 is a refactor with no behavior change. Phase 3 replaces scripts. Phase 4 is new functionality.
300
314
 
@@ -0,0 +1,35 @@
1
+ -- Key rings: decouples key material from payment methods
2
+ CREATE TABLE IF NOT EXISTS "key_rings" (
3
+ "id" text PRIMARY KEY,
4
+ "curve" text NOT NULL,
5
+ "derivation_scheme" text NOT NULL,
6
+ "derivation_mode" text NOT NULL DEFAULT 'on-demand',
7
+ "key_material" text NOT NULL DEFAULT '{}',
8
+ "coin_type" integer NOT NULL,
9
+ "account_index" integer NOT NULL DEFAULT 0,
10
+ "created_at" text NOT NULL DEFAULT (now())
11
+ );
12
+ --> statement-breakpoint
13
+ CREATE UNIQUE INDEX IF NOT EXISTS "key_rings_path_unique" ON "key_rings" ("coin_type", "account_index");
14
+ --> statement-breakpoint
15
+
16
+ -- Pre-derived address pool (for Ed25519 chains)
17
+ CREATE TABLE IF NOT EXISTS "address_pool" (
18
+ "id" serial PRIMARY KEY,
19
+ "key_ring_id" text NOT NULL REFERENCES "key_rings"("id"),
20
+ "derivation_index" integer NOT NULL,
21
+ "public_key" text NOT NULL,
22
+ "address" text NOT NULL,
23
+ "assigned_to" text,
24
+ "created_at" text NOT NULL DEFAULT (now())
25
+ );
26
+ --> statement-breakpoint
27
+ CREATE UNIQUE INDEX IF NOT EXISTS "address_pool_ring_index" ON "address_pool" ("key_ring_id", "derivation_index");
28
+ --> statement-breakpoint
29
+
30
+ -- Add new columns to payment_methods
31
+ ALTER TABLE "payment_methods" ADD COLUMN IF NOT EXISTS "key_ring_id" text REFERENCES "key_rings"("id");
32
+ --> statement-breakpoint
33
+ ALTER TABLE "payment_methods" ADD COLUMN IF NOT EXISTS "encoding" text;
34
+ --> statement-breakpoint
35
+ ALTER TABLE "payment_methods" ADD COLUMN IF NOT EXISTS "plugin_id" text;