@wopr-network/platform-core 1.65.0 → 1.66.1

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.
@@ -2,11 +2,11 @@
2
2
  -- Adds atomic derivation counter + path registry + address log.
3
3
 
4
4
  -- 1. Add network column to payment_methods (parallel to chain)
5
- ALTER TABLE "payment_methods" ADD COLUMN "network" text NOT NULL DEFAULT 'mainnet';
5
+ ALTER TABLE "payment_methods" ADD COLUMN IF NOT EXISTS "network" text NOT NULL DEFAULT 'mainnet';
6
6
  --> statement-breakpoint
7
7
 
8
8
  -- 2. Add next_index atomic counter to payment_methods
9
- ALTER TABLE "payment_methods" ADD COLUMN "next_index" integer NOT NULL DEFAULT 0;
9
+ ALTER TABLE "payment_methods" ADD COLUMN IF NOT EXISTS "next_index" integer NOT NULL DEFAULT 0;
10
10
  --> statement-breakpoint
11
11
 
12
12
  -- 3. BIP-44 path allocation registry
@@ -1,15 +1,15 @@
1
1
  -- Watcher service schema additions: webhook outbox + charge amount tracking.
2
2
 
3
3
  -- 1. callback_url for webhook delivery
4
- ALTER TABLE "crypto_charges" ADD COLUMN "callback_url" text;
4
+ ALTER TABLE "crypto_charges" ADD COLUMN IF NOT EXISTS "callback_url" text;
5
5
  --> statement-breakpoint
6
6
 
7
7
  -- 2. Expected crypto amount in native base units (locked at charge creation)
8
- ALTER TABLE "crypto_charges" ADD COLUMN "expected_amount" text;
8
+ ALTER TABLE "crypto_charges" ADD COLUMN IF NOT EXISTS "expected_amount" text;
9
9
  --> statement-breakpoint
10
10
 
11
11
  -- 3. Running total of received crypto in native base units (partial payments)
12
- ALTER TABLE "crypto_charges" ADD COLUMN "received_amount" text;
12
+ ALTER TABLE "crypto_charges" ADD COLUMN IF NOT EXISTS "received_amount" text;
13
13
  --> statement-breakpoint
14
14
 
15
15
  -- 4. Webhook delivery outbox — durable retry for payment callbacks
@@ -1,4 +1,4 @@
1
- ALTER TABLE "crypto_charges" ADD COLUMN "confirmations" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
2
- ALTER TABLE "crypto_charges" ADD COLUMN "confirmations_required" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
3
- ALTER TABLE "crypto_charges" ADD COLUMN "tx_hash" text;--> statement-breakpoint
4
- ALTER TABLE "crypto_charges" ADD COLUMN "amount_received_cents" integer DEFAULT 0 NOT NULL;
1
+ ALTER TABLE "crypto_charges" ADD COLUMN IF NOT EXISTS "confirmations" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE "crypto_charges" ADD COLUMN IF NOT EXISTS "confirmations_required" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
3
+ ALTER TABLE "crypto_charges" ADD COLUMN IF NOT EXISTS "tx_hash" text;--> statement-breakpoint
4
+ ALTER TABLE "crypto_charges" ADD COLUMN IF NOT EXISTS "amount_received_cents" integer DEFAULT 0 NOT NULL;
@@ -1,4 +1,4 @@
1
- ALTER TABLE "payment_methods" ADD COLUMN "encoding_params" text DEFAULT '{}' NOT NULL;
1
+ ALTER TABLE "payment_methods" ADD COLUMN IF NOT EXISTS "encoding_params" text DEFAULT '{}' NOT NULL;
2
2
  --> statement-breakpoint
3
3
  UPDATE "payment_methods" SET "encoding_params" = '{"hrp":"bc"}' WHERE "address_type" = 'bech32' AND "chain" = 'bitcoin';
4
4
  --> statement-breakpoint
@@ -1,3 +1,3 @@
1
- ALTER TABLE "payment_methods" ADD COLUMN "watcher_type" text DEFAULT 'evm' NOT NULL;
1
+ ALTER TABLE "payment_methods" ADD COLUMN IF NOT EXISTS "watcher_type" text DEFAULT 'evm' NOT NULL;
2
2
  --> statement-breakpoint
3
3
  UPDATE "payment_methods" SET "watcher_type" = 'utxo' WHERE "chain" IN ('bitcoin', 'litecoin', 'dogecoin');
@@ -1,4 +1,4 @@
1
- ALTER TABLE "payment_methods" ADD COLUMN "oracle_asset_id" text;
1
+ ALTER TABLE "payment_methods" ADD COLUMN IF NOT EXISTS "oracle_asset_id" text;
2
2
  --> statement-breakpoint
3
3
  UPDATE "payment_methods" SET "oracle_asset_id" = 'bitcoin' WHERE "token" = 'BTC';
4
4
  --> statement-breakpoint
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.1",
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
+ }