@wopr-network/platform-core 1.65.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.
@@ -8,6 +8,7 @@
8
8
  */
9
9
  /* biome-ignore-all lint/suspicious/noConsole: standalone entry point */
10
10
  import { serve } from "@hono/node-server";
11
+ import { bitcoinPlugin, dogecoinPlugin, evmPlugin, litecoinPlugin, solanaPlugin, tronPlugin, } from "@wopr-network/crypto-plugins";
11
12
  import { drizzle } from "drizzle-orm/node-postgres";
12
13
  import { migrate } from "drizzle-orm/node-postgres/migrator";
13
14
  import pg from "pg";
@@ -21,6 +22,8 @@ import { CoinGeckoOracle } from "./oracle/coingecko.js";
21
22
  import { CompositeOracle } from "./oracle/composite.js";
22
23
  import { FixedPriceOracle } from "./oracle/fixed.js";
23
24
  import { DrizzlePaymentMethodStore } from "./payment-method-store.js";
25
+ import { PluginRegistry } from "./plugin/registry.js";
26
+ import { startPluginWatchers } from "./plugin-watcher-service.js";
24
27
  import { startWatchers } from "./watcher-service.js";
25
28
  const PORT = Number(process.env.PORT ?? "3100");
26
29
  const DATABASE_URL = process.env.DATABASE_URL;
@@ -57,6 +60,15 @@ async function main() {
57
60
  }
58
61
  const coingecko = new CoinGeckoOracle({ tokenIds: dbTokenIds });
59
62
  const oracle = new CompositeOracle(chainlink, coingecko);
63
+ // Build plugin registry — one plugin per chain family
64
+ const registry = new PluginRegistry();
65
+ registry.register(bitcoinPlugin);
66
+ registry.register(litecoinPlugin);
67
+ registry.register(dogecoinPlugin);
68
+ registry.register(evmPlugin);
69
+ registry.register(tronPlugin);
70
+ registry.register(solanaPlugin);
71
+ console.log(`[crypto-key-server] Registered ${registry.list().length} chain plugins:`, registry.list().map((p) => p.pluginId));
60
72
  const app = createKeyServerApp({
61
73
  db,
62
74
  chargeStore,
@@ -64,20 +76,33 @@ async function main() {
64
76
  oracle,
65
77
  serviceKey: SERVICE_KEY,
66
78
  adminToken: ADMIN_TOKEN,
79
+ registry,
67
80
  });
68
- // Boot watchers (BTC + EVM) — polls for payments, sends webhooks
81
+ // Boot plugin-driven watchers — polls for payments, sends webhooks.
82
+ // Falls back to legacy startWatchers() if USE_LEGACY_WATCHERS=1 is set.
69
83
  const cursorStore = new DrizzleWatcherCursorStore(db);
70
- const stopWatchers = await startWatchers({
71
- db,
72
- chargeStore,
73
- methodStore,
74
- cursorStore,
75
- oracle,
76
- bitcoindUser: BITCOIND_USER,
77
- bitcoindPassword: BITCOIND_PASSWORD,
78
- serviceKey: SERVICE_KEY,
79
- log: (msg, meta) => console.log(`[watcher] ${msg}`, meta ?? ""),
80
- });
84
+ const useLegacy = process.env.USE_LEGACY_WATCHERS === "1";
85
+ const stopWatchers = useLegacy
86
+ ? await startWatchers({
87
+ db,
88
+ chargeStore,
89
+ methodStore,
90
+ cursorStore,
91
+ oracle,
92
+ bitcoindUser: BITCOIND_USER,
93
+ bitcoindPassword: BITCOIND_PASSWORD,
94
+ serviceKey: SERVICE_KEY,
95
+ log: (msg, meta) => console.log(`[watcher] ${msg}`, meta ?? ""),
96
+ })
97
+ : await startPluginWatchers({
98
+ db,
99
+ chargeStore,
100
+ methodStore,
101
+ cursorStore,
102
+ oracle,
103
+ registry,
104
+ log: (msg, meta) => console.log(`[watcher] ${msg}`, meta ?? ""),
105
+ });
81
106
  const server = serve({ fetch: app.fetch, port: PORT });
82
107
  console.log(`[crypto-key-server] Listening on :${PORT}`);
83
108
  // Graceful shutdown — stop accepting requests, drain watchers, close pool
@@ -1,8 +1,18 @@
1
+ /**
2
+ * Crypto Key Server — shared address derivation + charge management.
3
+ *
4
+ * Deploys on the chain server (pay.wopr.bot) alongside bitcoind.
5
+ * Products don't run watchers or hold xpubs. They request addresses
6
+ * and receive webhooks.
7
+ *
8
+ * ~200 lines of new code wrapping platform-core's existing crypto modules.
9
+ */
1
10
  import { Hono } from "hono";
2
11
  import type { DrizzleDb } from "../../db/index.js";
3
12
  import type { ICryptoChargeRepository } from "./charge-store.js";
4
13
  import type { IPriceOracle } from "./oracle/types.js";
5
14
  import type { IPaymentMethodStore } from "./payment-method-store.js";
15
+ import type { PluginRegistry } from "./plugin/registry.js";
6
16
  export interface KeyServerDeps {
7
17
  db: DrizzleDb;
8
18
  chargeStore: ICryptoChargeRepository;
@@ -12,6 +22,8 @@ export interface KeyServerDeps {
12
22
  serviceKey?: string;
13
23
  /** Bearer token for admin routes. If unset, admin routes are disabled. */
14
24
  adminToken?: string;
25
+ /** Plugin registry for address encoding. Falls back to address-gen.ts when absent. */
26
+ registry?: PluginRegistry;
15
27
  }
16
28
  /**
17
29
  * Create the Hono app for the crypto key server.
@@ -7,6 +7,7 @@
7
7
  *
8
8
  * ~200 lines of new code wrapping platform-core's existing crypto modules.
9
9
  */
10
+ import { HDKey } from "@scure/bip32";
10
11
  import { eq, sql } from "drizzle-orm";
11
12
  import { Hono } from "hono";
12
13
  import { derivedAddresses, pathAllocations, paymentMethods } from "../../db/schema/crypto.js";
@@ -21,7 +22,7 @@ import { AssetNotSupportedError } from "./oracle/types.js";
21
22
  * address. The unique constraint on derived_addresses.address prevents reuse.
22
23
  * On collision, we skip the index and retry (up to maxRetries).
23
24
  */
24
- async function deriveNextAddress(db, chainId, tenantId) {
25
+ async function deriveNextAddress(db, chainId, tenantId, registry) {
25
26
  const maxRetries = 10;
26
27
  const dbWithTx = db;
27
28
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -46,7 +47,23 @@ async function deriveNextAddress(db, chainId, tenantId) {
46
47
  catch {
47
48
  throw new Error(`Invalid encoding_params JSON for chain ${chainId}: ${method.encodingParams}`);
48
49
  }
49
- const address = deriveAddress(method.xpub, index, method.addressType, encodingParams);
50
+ // Plugin-driven encoding: look up the plugin, use its encoder.
51
+ // Falls back to legacy deriveAddress() when no registry or no matching plugin.
52
+ let address;
53
+ const pluginId = method.pluginId ?? (method.watcherType === "utxo" ? "bitcoin" : method.watcherType);
54
+ const plugin = registry?.get(pluginId ?? "");
55
+ const encodingKey = method.encoding ?? method.addressType;
56
+ const encoder = plugin?.encoders[encodingKey];
57
+ if (encoder) {
58
+ const master = HDKey.fromExtendedKey(method.xpub);
59
+ const child = master.deriveChild(0).deriveChild(index);
60
+ if (!child.publicKey)
61
+ throw new Error("Failed to derive public key");
62
+ address = encoder.encode(child.publicKey, encodingParams);
63
+ }
64
+ else {
65
+ address = deriveAddress(method.xpub, index, method.addressType, encodingParams);
66
+ }
50
67
  // Step 2: Record in immutable log. If this address was already derived by a
51
68
  // sibling chain (shared xpub), the unique constraint fires and we retry
52
69
  // with the next index (which is already incremented above).
@@ -121,7 +138,7 @@ export function createKeyServerApp(deps) {
121
138
  if (!body.chain)
122
139
  return c.json({ error: "chain is required" }, 400);
123
140
  const tenantId = c.req.header("X-Tenant-Id");
124
- const result = await deriveNextAddress(deps.db, body.chain, tenantId ?? undefined);
141
+ const result = await deriveNextAddress(deps.db, body.chain, tenantId ?? undefined, deps.registry);
125
142
  return c.json(result, 201);
126
143
  });
127
144
  /** POST /charges — create charge + derive address + start watching */
@@ -131,7 +148,7 @@ export function createKeyServerApp(deps) {
131
148
  return c.json({ error: "chain is required and amountUsd must be a positive finite number" }, 400);
132
149
  }
133
150
  const tenantId = c.req.header("X-Tenant-Id") ?? "unknown";
134
- const { address, index, chain, token } = await deriveNextAddress(deps.db, body.chain, tenantId);
151
+ const { address, index, chain, token } = await deriveNextAddress(deps.db, body.chain, tenantId, deps.registry);
135
152
  // Look up payment method for decimals + oracle config
136
153
  const method = await deps.methodStore.getById(body.chain);
137
154
  if (!method)
@@ -0,0 +1,32 @@
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 type { DrizzleDb } from "../../db/index.js";
12
+ import type { ICryptoChargeRepository } from "./charge-store.js";
13
+ import type { IWatcherCursorStore } from "./cursor-store.js";
14
+ import type { IPriceOracle } from "./oracle/types.js";
15
+ import type { IPaymentMethodStore } from "./payment-method-store.js";
16
+ import type { PluginRegistry } from "./plugin/registry.js";
17
+ export interface PluginWatcherServiceOpts {
18
+ db: DrizzleDb;
19
+ chargeStore: ICryptoChargeRepository;
20
+ methodStore: IPaymentMethodStore;
21
+ cursorStore: IWatcherCursorStore;
22
+ oracle: IPriceOracle;
23
+ registry: PluginRegistry;
24
+ pollIntervalMs?: number;
25
+ log?: (msg: string, meta?: Record<string, unknown>) => void;
26
+ }
27
+ /**
28
+ * Boot plugin-driven watchers for all enabled payment methods.
29
+ *
30
+ * Returns a cleanup function that stops all poll timers and watchers.
31
+ */
32
+ export declare function startPluginWatchers(opts: PluginWatcherServiceOpts): Promise<() => void>;
@@ -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
+ }
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.65.0",
3
+ "version": "1.66.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -140,6 +140,7 @@
140
140
  "@scure/base": "^2.0.0",
141
141
  "@scure/bip32": "^2.0.1",
142
142
  "@scure/bip39": "^2.0.1",
143
+ "@wopr-network/crypto-plugins": "^1.0.1",
143
144
  "handlebars": "^4.7.8",
144
145
  "js-yaml": "^4.1.1",
145
146
  "postmark": "^4.0.7",
@@ -8,6 +8,14 @@
8
8
  */
9
9
  /* biome-ignore-all lint/suspicious/noConsole: standalone entry point */
10
10
  import { serve } from "@hono/node-server";
11
+ import {
12
+ bitcoinPlugin,
13
+ dogecoinPlugin,
14
+ evmPlugin,
15
+ litecoinPlugin,
16
+ solanaPlugin,
17
+ tronPlugin,
18
+ } from "@wopr-network/crypto-plugins";
11
19
  import { drizzle } from "drizzle-orm/node-postgres";
12
20
  import { migrate } from "drizzle-orm/node-postgres/migrator";
13
21
  import pg from "pg";
@@ -21,6 +29,8 @@ import { CoinGeckoOracle } from "./oracle/coingecko.js";
21
29
  import { CompositeOracle } from "./oracle/composite.js";
22
30
  import { FixedPriceOracle } from "./oracle/fixed.js";
23
31
  import { DrizzlePaymentMethodStore } from "./payment-method-store.js";
32
+ import { PluginRegistry } from "./plugin/registry.js";
33
+ import { startPluginWatchers } from "./plugin-watcher-service.js";
24
34
  import { startWatchers } from "./watcher-service.js";
25
35
 
26
36
  const PORT = Number(process.env.PORT ?? "3100");
@@ -64,6 +74,19 @@ async function main(): Promise<void> {
64
74
  const coingecko = new CoinGeckoOracle({ tokenIds: dbTokenIds });
65
75
  const oracle = new CompositeOracle(chainlink, coingecko);
66
76
 
77
+ // Build plugin registry — one plugin per chain family
78
+ const registry = new PluginRegistry();
79
+ registry.register(bitcoinPlugin);
80
+ registry.register(litecoinPlugin);
81
+ registry.register(dogecoinPlugin);
82
+ registry.register(evmPlugin);
83
+ registry.register(tronPlugin);
84
+ registry.register(solanaPlugin);
85
+ console.log(
86
+ `[crypto-key-server] Registered ${registry.list().length} chain plugins:`,
87
+ registry.list().map((p) => p.pluginId),
88
+ );
89
+
67
90
  const app = createKeyServerApp({
68
91
  db,
69
92
  chargeStore,
@@ -71,21 +94,34 @@ async function main(): Promise<void> {
71
94
  oracle,
72
95
  serviceKey: SERVICE_KEY,
73
96
  adminToken: ADMIN_TOKEN,
97
+ registry,
74
98
  });
75
99
 
76
- // Boot watchers (BTC + EVM) — polls for payments, sends webhooks
100
+ // Boot plugin-driven watchers — polls for payments, sends webhooks.
101
+ // Falls back to legacy startWatchers() if USE_LEGACY_WATCHERS=1 is set.
77
102
  const cursorStore = new DrizzleWatcherCursorStore(db);
78
- const stopWatchers = await startWatchers({
79
- db,
80
- chargeStore,
81
- methodStore,
82
- cursorStore,
83
- oracle,
84
- bitcoindUser: BITCOIND_USER,
85
- bitcoindPassword: BITCOIND_PASSWORD,
86
- serviceKey: SERVICE_KEY,
87
- log: (msg, meta) => console.log(`[watcher] ${msg}`, meta ?? ""),
88
- });
103
+ const useLegacy = process.env.USE_LEGACY_WATCHERS === "1";
104
+ const stopWatchers = useLegacy
105
+ ? await startWatchers({
106
+ db,
107
+ chargeStore,
108
+ methodStore,
109
+ cursorStore,
110
+ oracle,
111
+ bitcoindUser: BITCOIND_USER,
112
+ bitcoindPassword: BITCOIND_PASSWORD,
113
+ serviceKey: SERVICE_KEY,
114
+ log: (msg, meta) => console.log(`[watcher] ${msg}`, meta ?? ""),
115
+ })
116
+ : await startPluginWatchers({
117
+ db,
118
+ chargeStore,
119
+ methodStore,
120
+ cursorStore,
121
+ oracle,
122
+ registry,
123
+ log: (msg, meta) => console.log(`[watcher] ${msg}`, meta ?? ""),
124
+ });
89
125
 
90
126
  const server = serve({ fetch: app.fetch, port: PORT });
91
127
  console.log(`[crypto-key-server] Listening on :${PORT}`);
@@ -7,6 +7,8 @@
7
7
  *
8
8
  * ~200 lines of new code wrapping platform-core's existing crypto modules.
9
9
  */
10
+
11
+ import { HDKey } from "@scure/bip32";
10
12
  import { eq, sql } from "drizzle-orm";
11
13
  import { Hono } from "hono";
12
14
  import type { DrizzleDb } from "../../db/index.js";
@@ -18,6 +20,7 @@ import { centsToNative } from "./oracle/convert.js";
18
20
  import type { IPriceOracle } from "./oracle/types.js";
19
21
  import { AssetNotSupportedError } from "./oracle/types.js";
20
22
  import type { IPaymentMethodStore } from "./payment-method-store.js";
23
+ import type { PluginRegistry } from "./plugin/registry.js";
21
24
 
22
25
  export interface KeyServerDeps {
23
26
  db: DrizzleDb;
@@ -28,6 +31,8 @@ export interface KeyServerDeps {
28
31
  serviceKey?: string;
29
32
  /** Bearer token for admin routes. If unset, admin routes are disabled. */
30
33
  adminToken?: string;
34
+ /** Plugin registry for address encoding. Falls back to address-gen.ts when absent. */
35
+ registry?: PluginRegistry;
31
36
  }
32
37
 
33
38
  /**
@@ -42,6 +47,7 @@ async function deriveNextAddress(
42
47
  db: DrizzleDb,
43
48
  chainId: string,
44
49
  tenantId?: string,
50
+ registry?: PluginRegistry,
45
51
  ): Promise<{ address: string; index: number; chain: string; token: string }> {
46
52
  const maxRetries = 10;
47
53
  const dbWithTx = db as unknown as { transaction: (fn: (tx: DrizzleDb) => Promise<unknown>) => Promise<unknown> };
@@ -68,7 +74,23 @@ async function deriveNextAddress(
68
74
  } catch {
69
75
  throw new Error(`Invalid encoding_params JSON for chain ${chainId}: ${method.encodingParams}`);
70
76
  }
71
- const address = deriveAddress(method.xpub, index, method.addressType, encodingParams);
77
+
78
+ // Plugin-driven encoding: look up the plugin, use its encoder.
79
+ // Falls back to legacy deriveAddress() when no registry or no matching plugin.
80
+ let address: string;
81
+ const pluginId = method.pluginId ?? (method.watcherType === "utxo" ? "bitcoin" : method.watcherType);
82
+ const plugin = registry?.get(pluginId ?? "");
83
+ const encodingKey = method.encoding ?? method.addressType;
84
+ const encoder = plugin?.encoders[encodingKey];
85
+
86
+ if (encoder) {
87
+ const master = HDKey.fromExtendedKey(method.xpub);
88
+ const child = master.deriveChild(0).deriveChild(index);
89
+ if (!child.publicKey) throw new Error("Failed to derive public key");
90
+ address = encoder.encode(child.publicKey, encodingParams as Record<string, string | undefined>);
91
+ } else {
92
+ address = deriveAddress(method.xpub, index, method.addressType, encodingParams);
93
+ }
72
94
 
73
95
  // Step 2: Record in immutable log. If this address was already derived by a
74
96
  // sibling chain (shared xpub), the unique constraint fires and we retry
@@ -146,7 +168,7 @@ export function createKeyServerApp(deps: KeyServerDeps): Hono {
146
168
  if (!body.chain) return c.json({ error: "chain is required" }, 400);
147
169
 
148
170
  const tenantId = c.req.header("X-Tenant-Id");
149
- const result = await deriveNextAddress(deps.db, body.chain, tenantId ?? undefined);
171
+ const result = await deriveNextAddress(deps.db, body.chain, tenantId ?? undefined, deps.registry);
150
172
  return c.json(result, 201);
151
173
  });
152
174
 
@@ -164,7 +186,7 @@ export function createKeyServerApp(deps: KeyServerDeps): Hono {
164
186
  }
165
187
 
166
188
  const tenantId = c.req.header("X-Tenant-Id") ?? "unknown";
167
- const { address, index, chain, token } = await deriveNextAddress(deps.db, body.chain, tenantId);
189
+ const { address, index, chain, token } = await deriveNextAddress(deps.db, body.chain, tenantId, deps.registry);
168
190
 
169
191
  // Look up payment method for decimals + oracle config
170
192
  const method = await deps.methodStore.getById(body.chain);
@@ -0,0 +1,148 @@
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
+
12
+ import type { DrizzleDb } from "../../db/index.js";
13
+ import type { ICryptoChargeRepository } from "./charge-store.js";
14
+ import type { IWatcherCursorStore } from "./cursor-store.js";
15
+ import type { IPriceOracle } from "./oracle/types.js";
16
+ import type { IPaymentMethodStore, PaymentMethodRecord } from "./payment-method-store.js";
17
+ import type { IChainPlugin, IChainWatcher } from "./plugin/interfaces.js";
18
+ import type { PluginRegistry } from "./plugin/registry.js";
19
+ import { handlePayment } from "./watcher-service.js";
20
+
21
+ export interface PluginWatcherServiceOpts {
22
+ db: DrizzleDb;
23
+ chargeStore: ICryptoChargeRepository;
24
+ methodStore: IPaymentMethodStore;
25
+ cursorStore: IWatcherCursorStore;
26
+ oracle: IPriceOracle;
27
+ registry: PluginRegistry;
28
+ pollIntervalMs?: number;
29
+ log?: (msg: string, meta?: Record<string, unknown>) => void;
30
+ }
31
+
32
+ /** Map legacy watcher_type values to plugin IDs for backward compatibility. */
33
+ const WATCHER_TYPE_TO_PLUGIN: Record<string, string> = {
34
+ utxo: "bitcoin",
35
+ evm: "evm",
36
+ };
37
+
38
+ function resolvePlugin(registry: PluginRegistry, method: PaymentMethodRecord): IChainPlugin | undefined {
39
+ // Prefer explicit plugin_id, fall back to watcher_type mapping
40
+ const id = method.pluginId ?? WATCHER_TYPE_TO_PLUGIN[method.watcherType];
41
+ return id ? registry.get(id) : undefined;
42
+ }
43
+
44
+ /**
45
+ * Boot plugin-driven watchers for all enabled payment methods.
46
+ *
47
+ * Returns a cleanup function that stops all poll timers and watchers.
48
+ */
49
+ export async function startPluginWatchers(opts: PluginWatcherServiceOpts): Promise<() => void> {
50
+ const { db, chargeStore, methodStore, cursorStore, oracle, registry } = opts;
51
+ const pollMs = opts.pollIntervalMs ?? 15_000;
52
+ const log = opts.log ?? (() => {});
53
+
54
+ const methods = await methodStore.listEnabled();
55
+ const timers: ReturnType<typeof setInterval>[] = [];
56
+ const watchers: IChainWatcher[] = [];
57
+
58
+ for (const method of methods) {
59
+ if (!method.rpcUrl) continue;
60
+
61
+ const plugin = resolvePlugin(registry, method);
62
+ if (!plugin) {
63
+ log("No plugin found, skipping method", { id: method.id, chain: method.chain, watcherType: method.watcherType });
64
+ continue;
65
+ }
66
+
67
+ const watcher = plugin.createWatcher({
68
+ rpcUrl: method.rpcUrl,
69
+ rpcHeaders: JSON.parse(method.rpcHeaders ?? "{}"),
70
+ oracle,
71
+ cursorStore,
72
+ token: method.token,
73
+ chain: method.chain,
74
+ contractAddress: method.contractAddress ?? undefined,
75
+ decimals: method.decimals,
76
+ confirmations: method.confirmations,
77
+ });
78
+
79
+ try {
80
+ await watcher.init();
81
+ } catch (err) {
82
+ log("Watcher init failed, skipping", { chain: method.chain, token: method.token, error: String(err) });
83
+ continue;
84
+ }
85
+
86
+ // Seed watched addresses from active charges
87
+ const active = await chargeStore.listActiveDepositAddresses();
88
+ const addrs = active.filter((a) => a.chain === method.chain && a.token === method.token).map((a) => a.address);
89
+ watcher.setWatchedAddresses(addrs);
90
+
91
+ watchers.push(watcher);
92
+ log(`Plugin watcher started (${method.chain}:${method.token})`, {
93
+ plugin: plugin.pluginId,
94
+ addresses: addrs.length,
95
+ });
96
+
97
+ let polling = false;
98
+ timers.push(
99
+ setInterval(async () => {
100
+ if (polling) return;
101
+ polling = true;
102
+ try {
103
+ // Refresh watched addresses each cycle
104
+ const fresh = await chargeStore.listActiveDepositAddresses();
105
+ const freshAddrs = fresh
106
+ .filter((a) => a.chain === method.chain && a.token === method.token)
107
+ .map((a) => a.address);
108
+ watcher.setWatchedAddresses(freshAddrs);
109
+
110
+ const events = await watcher.poll();
111
+ for (const ev of events) {
112
+ log("Plugin payment", {
113
+ chain: ev.chain,
114
+ token: ev.token,
115
+ to: ev.to,
116
+ txHash: ev.txHash,
117
+ confirmations: ev.confirmations,
118
+ });
119
+ await handlePayment(
120
+ db,
121
+ chargeStore,
122
+ ev.to,
123
+ ev.rawAmount,
124
+ {
125
+ txHash: ev.txHash,
126
+ confirmations: ev.confirmations,
127
+ confirmationsRequired: ev.confirmationsRequired,
128
+ amountReceivedCents: ev.amountUsdCents,
129
+ },
130
+ log,
131
+ );
132
+ }
133
+ } catch (err) {
134
+ log("Plugin poll error", { chain: method.chain, token: method.token, error: String(err) });
135
+ } finally {
136
+ polling = false;
137
+ }
138
+ }, pollMs),
139
+ );
140
+ }
141
+
142
+ log("All plugin watchers started", { count: watchers.length, pollMs });
143
+
144
+ return () => {
145
+ for (const t of timers) clearInterval(t);
146
+ for (const w of watchers) w.stop();
147
+ };
148
+ }