@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.
- package/dist/billing/crypto/key-server-entry.js +37 -12
- package/dist/billing/crypto/key-server.d.ts +12 -0
- package/dist/billing/crypto/key-server.js +21 -4
- package/dist/billing/crypto/plugin-watcher-service.d.ts +32 -0
- package/dist/billing/crypto/plugin-watcher-service.js +113 -0
- package/docs/superpowers/specs/2026-03-24-crypto-plugin-architecture-design.md +48 -34
- package/package.json +2 -1
- package/src/billing/crypto/key-server-entry.ts +48 -12
- package/src/billing/crypto/key-server.ts +25 -3
- package/src/billing/crypto/plugin-watcher-service.ts +148 -0
|
@@ -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
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
177
|
+
## Plugin Monorepo
|
|
178
178
|
|
|
179
|
-
|
|
179
|
+
Single npm package: `@wopr-network/crypto-plugins`. One version, one CI, one install.
|
|
180
180
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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-
|
|
202
|
-
import { bitcoinPlugin } from "@wopr-network/crypto-
|
|
203
|
-
import { solanaPlugin } from "@wopr-network/crypto-
|
|
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
|
-
###
|
|
249
|
-
1. `
|
|
250
|
-
2. Add
|
|
251
|
-
3.
|
|
252
|
-
4.
|
|
253
|
-
5.
|
|
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
|
-
-
|
|
287
|
-
- `
|
|
288
|
-
- `
|
|
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`
|
|
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-
|
|
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.
|
|
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
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
+
}
|