@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,75 @@
1
+ -- Create key rings from existing payment method xpubs
2
+ -- Each unique (coin_type via path_allocations) gets a key ring
3
+
4
+ -- EVM chains (coin type 60)
5
+ INSERT INTO "key_rings" ("id", "curve", "derivation_scheme", "derivation_mode", "key_material", "coin_type", "account_index")
6
+ SELECT DISTINCT 'evm-main', 'secp256k1', 'bip32', 'on-demand',
7
+ json_build_object('xpub', pm.xpub)::text,
8
+ pa.coin_type, pa.account_index
9
+ FROM path_allocations pa
10
+ JOIN payment_methods pm ON pm.id = pa.chain_id
11
+ WHERE pa.coin_type = 60
12
+ LIMIT 1
13
+ ON CONFLICT DO NOTHING;
14
+ --> statement-breakpoint
15
+
16
+ -- BTC (coin type 0)
17
+ INSERT INTO "key_rings" ("id", "curve", "derivation_scheme", "derivation_mode", "key_material", "coin_type", "account_index")
18
+ SELECT DISTINCT 'btc-main', 'secp256k1', 'bip32', 'on-demand',
19
+ json_build_object('xpub', pm.xpub)::text,
20
+ pa.coin_type, pa.account_index
21
+ FROM path_allocations pa
22
+ JOIN payment_methods pm ON pm.id = pa.chain_id
23
+ WHERE pa.coin_type = 0
24
+ LIMIT 1
25
+ ON CONFLICT DO NOTHING;
26
+ --> statement-breakpoint
27
+
28
+ -- LTC (coin type 2)
29
+ INSERT INTO "key_rings" ("id", "curve", "derivation_scheme", "derivation_mode", "key_material", "coin_type", "account_index")
30
+ SELECT DISTINCT 'ltc-main', 'secp256k1', 'bip32', 'on-demand',
31
+ json_build_object('xpub', pm.xpub)::text,
32
+ pa.coin_type, pa.account_index
33
+ FROM path_allocations pa
34
+ JOIN payment_methods pm ON pm.id = pa.chain_id
35
+ WHERE pa.coin_type = 2
36
+ LIMIT 1
37
+ ON CONFLICT DO NOTHING;
38
+ --> statement-breakpoint
39
+
40
+ -- DOGE (coin type 3)
41
+ INSERT INTO "key_rings" ("id", "curve", "derivation_scheme", "derivation_mode", "key_material", "coin_type", "account_index")
42
+ SELECT DISTINCT 'doge-main', 'secp256k1', 'bip32', 'on-demand',
43
+ json_build_object('xpub', pm.xpub)::text,
44
+ pa.coin_type, pa.account_index
45
+ FROM path_allocations pa
46
+ JOIN payment_methods pm ON pm.id = pa.chain_id
47
+ WHERE pa.coin_type = 3
48
+ LIMIT 1
49
+ ON CONFLICT DO NOTHING;
50
+ --> statement-breakpoint
51
+
52
+ -- TRON (coin type 195)
53
+ INSERT INTO "key_rings" ("id", "curve", "derivation_scheme", "derivation_mode", "key_material", "coin_type", "account_index")
54
+ SELECT DISTINCT 'tron-main', 'secp256k1', 'bip32', 'on-demand',
55
+ json_build_object('xpub', pm.xpub)::text,
56
+ pa.coin_type, pa.account_index
57
+ FROM path_allocations pa
58
+ JOIN payment_methods pm ON pm.id = pa.chain_id
59
+ WHERE pa.coin_type = 195
60
+ LIMIT 1
61
+ ON CONFLICT DO NOTHING;
62
+ --> statement-breakpoint
63
+
64
+ -- Backfill payment_methods with key_ring_id, encoding, plugin_id
65
+ UPDATE payment_methods SET
66
+ key_ring_id = CASE
67
+ WHEN chain IN ('arbitrum','avalanche','base','base-sepolia','bsc','optimism','polygon','sepolia') THEN 'evm-main'
68
+ WHEN chain = 'bitcoin' THEN 'btc-main'
69
+ WHEN chain = 'litecoin' THEN 'ltc-main'
70
+ WHEN chain = 'dogecoin' THEN 'doge-main'
71
+ WHEN chain = 'tron' THEN 'tron-main'
72
+ END,
73
+ encoding = address_type,
74
+ plugin_id = watcher_type
75
+ WHERE key_ring_id IS NULL;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.64.0",
3
+ "version": "1.66.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -72,6 +72,10 @@
72
72
  "./api/routes/verify-email": "./dist/api/routes/verify-email.js",
73
73
  "./api/routes/ws-auth": "./dist/api/routes/ws-auth.js",
74
74
  "./trpc": "./dist/trpc/index.js",
75
+ "./crypto-plugin": {
76
+ "import": "./dist/billing/crypto/plugin/index.js",
77
+ "types": "./dist/billing/crypto/plugin/index.d.ts"
78
+ },
75
79
  "./*": "./dist/*.js"
76
80
  },
77
81
  "scripts": {
@@ -136,6 +140,7 @@
136
140
  "@scure/base": "^2.0.0",
137
141
  "@scure/bip32": "^2.0.1",
138
142
  "@scure/bip39": "^2.0.1",
143
+ "@wopr-network/crypto-plugins": "^1.0.1",
139
144
  "handlebars": "^4.7.8",
140
145
  "js-yaml": "^4.1.1",
141
146
  "postmark": "^4.0.7",
@@ -33,6 +33,15 @@ export {
33
33
  export * from "./oracle/index.js";
34
34
  export type { IPaymentMethodStore, PaymentMethodRecord } from "./payment-method-store.js";
35
35
  export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
36
+ export type {
37
+ IAddressEncoder,
38
+ IChainPlugin,
39
+ IChainWatcher,
40
+ ICurveDeriver,
41
+ ISweepStrategy,
42
+ PaymentEvent,
43
+ } from "./plugin/index.js";
44
+ export { PluginRegistry } from "./plugin/index.js";
36
45
  export type { CryptoCharge, CryptoChargeStatus, CryptoPaymentState } from "./types.js";
37
46
  export type { UnifiedCheckoutDeps, UnifiedCheckoutResult } from "./unified-checkout.js";
38
47
  export { createUnifiedCheckout, MIN_CHECKOUT_USD as MIN_PAYMENT_USD, MIN_CHECKOUT_USD } from "./unified-checkout.js";
@@ -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);
@@ -364,6 +386,9 @@ export function createKeyServerApp(deps: KeyServerDeps): Hono {
364
386
  watcherType: body.watcher_type ?? "evm",
365
387
  oracleAssetId: body.oracle_asset_id ?? null,
366
388
  confirmations: body.confirmations ?? 6,
389
+ keyRingId: null,
390
+ encoding: null,
391
+ pluginId: null,
367
392
  });
368
393
 
369
394
  // Record the path allocation (idempotent — ignore if already exists)
@@ -22,6 +22,9 @@ export interface PaymentMethodRecord {
22
22
  watcherType: string;
23
23
  oracleAssetId: string | null;
24
24
  confirmations: number;
25
+ keyRingId: string | null;
26
+ encoding: string | null;
27
+ pluginId: string | null;
25
28
  }
26
29
 
27
30
  export interface IPaymentMethodStore {
@@ -98,6 +101,9 @@ export class DrizzlePaymentMethodStore implements IPaymentMethodStore {
98
101
  watcherType: method.watcherType,
99
102
  oracleAssetId: method.oracleAssetId,
100
103
  confirmations: method.confirmations,
104
+ keyRingId: method.keyRingId,
105
+ encoding: method.encoding,
106
+ pluginId: method.pluginId,
101
107
  })
102
108
  .onConflictDoUpdate({
103
109
  target: paymentMethods.id,
@@ -119,6 +125,9 @@ export class DrizzlePaymentMethodStore implements IPaymentMethodStore {
119
125
  watcherType: method.watcherType,
120
126
  oracleAssetId: method.oracleAssetId,
121
127
  confirmations: method.confirmations,
128
+ keyRingId: method.keyRingId,
129
+ encoding: method.encoding,
130
+ pluginId: method.pluginId,
122
131
  },
123
132
  });
124
133
  }
@@ -164,5 +173,8 @@ function toRecord(row: typeof paymentMethods.$inferSelect): PaymentMethodRecord
164
173
  watcherType: row.watcherType,
165
174
  oracleAssetId: row.oracleAssetId,
166
175
  confirmations: row.confirmations,
176
+ keyRingId: row.keyRingId,
177
+ encoding: row.encoding,
178
+ pluginId: row.pluginId,
167
179
  };
168
180
  }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { IChainPlugin, PaymentEvent, WatcherOpts } from "../interfaces.js";
3
+ import { PluginRegistry } from "../registry.js";
4
+
5
+ describe("plugin integration — registry → watcher → events", () => {
6
+ it("full lifecycle: register → create watcher → poll → events", async () => {
7
+ const mockEvent: PaymentEvent = {
8
+ chain: "test",
9
+ token: "TEST",
10
+ from: "0xsender",
11
+ to: "0xreceiver",
12
+ rawAmount: "1000",
13
+ amountUsdCents: 100,
14
+ txHash: "0xhash",
15
+ blockNumber: 42,
16
+ confirmations: 6,
17
+ confirmationsRequired: 6,
18
+ };
19
+
20
+ const plugin: IChainPlugin = {
21
+ pluginId: "test",
22
+ supportedCurve: "secp256k1",
23
+ encoders: {},
24
+ createWatcher: (_opts: WatcherOpts) => ({
25
+ init: async () => {},
26
+ poll: async () => [mockEvent],
27
+ setWatchedAddresses: () => {},
28
+ getCursor: () => 42,
29
+ stop: () => {},
30
+ }),
31
+ createSweeper: () => ({ scan: async () => [], sweep: async () => [] }),
32
+ version: 1,
33
+ };
34
+
35
+ const registry = new PluginRegistry();
36
+ registry.register(plugin);
37
+
38
+ const resolved = registry.getOrThrow("test");
39
+ const watcher = resolved.createWatcher({
40
+ rpcUrl: "http://localhost:8545",
41
+ rpcHeaders: {},
42
+ oracle: {
43
+ getPrice: async () => ({ priceMicros: 3500_000000 }),
44
+ },
45
+ cursorStore: {
46
+ get: async () => null,
47
+ save: async () => {},
48
+ getConfirmationCount: async () => null,
49
+ saveConfirmationCount: async () => {},
50
+ },
51
+ token: "TEST",
52
+ chain: "test",
53
+ decimals: 18,
54
+ confirmations: 6,
55
+ });
56
+
57
+ await watcher.init();
58
+ const events = await watcher.poll();
59
+ expect(events).toHaveLength(1);
60
+ expect(events[0].txHash).toBe("0xhash");
61
+ expect(watcher.getCursor()).toBe(42);
62
+ watcher.stop();
63
+ });
64
+ });
@@ -0,0 +1,51 @@
1
+ // src/billing/crypto/plugin/__tests__/interfaces.test.ts
2
+ import { describe, expect, it } from "vitest";
3
+ import type { EncodingParams, IAddressEncoder, IChainWatcher, ICurveDeriver, PaymentEvent } from "../interfaces.js";
4
+
5
+ describe("plugin interfaces — type contracts", () => {
6
+ it("PaymentEvent has required fields", () => {
7
+ const event: PaymentEvent = {
8
+ chain: "ethereum",
9
+ token: "ETH",
10
+ from: "0xabc",
11
+ to: "0xdef",
12
+ rawAmount: "1000000000000000000",
13
+ amountUsdCents: 350000,
14
+ txHash: "0x123",
15
+ blockNumber: 100,
16
+ confirmations: 6,
17
+ confirmationsRequired: 6,
18
+ };
19
+ expect(event.chain).toBe("ethereum");
20
+ expect(event.amountUsdCents).toBe(350000);
21
+ });
22
+
23
+ it("ICurveDeriver contract is satisfiable", () => {
24
+ const deriver: ICurveDeriver = {
25
+ derivePublicKey: (_chain: number, _index: number) => new Uint8Array(33),
26
+ getCurve: () => "secp256k1",
27
+ };
28
+ expect(deriver.getCurve()).toBe("secp256k1");
29
+ expect(deriver.derivePublicKey(0, 0)).toBeInstanceOf(Uint8Array);
30
+ });
31
+
32
+ it("IAddressEncoder contract is satisfiable", () => {
33
+ const encoder: IAddressEncoder = {
34
+ encode: (_pk: Uint8Array, _params: EncodingParams) => "bc1qtest",
35
+ encodingType: () => "bech32",
36
+ };
37
+ expect(encoder.encodingType()).toBe("bech32");
38
+ expect(encoder.encode(new Uint8Array(33), { hrp: "bc" })).toBe("bc1qtest");
39
+ });
40
+
41
+ it("IChainWatcher contract is satisfiable", () => {
42
+ const watcher: IChainWatcher = {
43
+ init: async () => {},
44
+ poll: async () => [],
45
+ setWatchedAddresses: () => {},
46
+ getCursor: () => 0,
47
+ stop: () => {},
48
+ };
49
+ expect(watcher.getCursor()).toBe(0);
50
+ });
51
+ });
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { IChainPlugin } from "../interfaces.js";
3
+ import { PluginRegistry } from "../registry.js";
4
+
5
+ function mockPlugin(id: string, curve: "secp256k1" | "ed25519" = "secp256k1"): IChainPlugin {
6
+ return {
7
+ pluginId: id,
8
+ supportedCurve: curve,
9
+ encoders: {},
10
+ createWatcher: () => ({
11
+ init: async () => {},
12
+ poll: async () => [],
13
+ setWatchedAddresses: () => {},
14
+ getCursor: () => 0,
15
+ stop: () => {},
16
+ }),
17
+ createSweeper: () => ({ scan: async () => [], sweep: async () => [] }),
18
+ version: 1,
19
+ };
20
+ }
21
+
22
+ describe("PluginRegistry", () => {
23
+ it("registers and retrieves a plugin", () => {
24
+ const reg = new PluginRegistry();
25
+ reg.register(mockPlugin("evm"));
26
+ expect(reg.get("evm")).toBeDefined();
27
+ expect(reg.get("evm")?.pluginId).toBe("evm");
28
+ });
29
+
30
+ it("throws on duplicate registration", () => {
31
+ const reg = new PluginRegistry();
32
+ reg.register(mockPlugin("evm"));
33
+ expect(() => reg.register(mockPlugin("evm"))).toThrow("already registered");
34
+ });
35
+
36
+ it("returns undefined for unknown plugin", () => {
37
+ const reg = new PluginRegistry();
38
+ expect(reg.get("unknown")).toBeUndefined();
39
+ });
40
+
41
+ it("lists all registered plugins", () => {
42
+ const reg = new PluginRegistry();
43
+ reg.register(mockPlugin("evm"));
44
+ reg.register(mockPlugin("solana", "ed25519"));
45
+ expect(reg.list()).toHaveLength(2);
46
+ expect(
47
+ reg
48
+ .list()
49
+ .map((p) => p.pluginId)
50
+ .sort(),
51
+ ).toEqual(["evm", "solana"]);
52
+ });
53
+
54
+ it("getOrThrow throws for unknown plugin", () => {
55
+ const reg = new PluginRegistry();
56
+ expect(() => reg.getOrThrow("nope")).toThrow("not registered");
57
+ });
58
+ });
@@ -0,0 +1,17 @@
1
+ export type {
2
+ DepositInfo,
3
+ EncodingParams,
4
+ IAddressEncoder,
5
+ IChainPlugin,
6
+ IChainWatcher,
7
+ ICurveDeriver,
8
+ IPriceOracle,
9
+ ISweepStrategy,
10
+ IWatcherCursorStore,
11
+ KeyPair,
12
+ PaymentEvent,
13
+ SweeperOpts,
14
+ SweepResult,
15
+ WatcherOpts,
16
+ } from "./interfaces.js";
17
+ export { PluginRegistry } from "./registry.js";
@@ -0,0 +1,106 @@
1
+ // src/billing/crypto/plugin/interfaces.ts
2
+
3
+ export interface PaymentEvent {
4
+ chain: string;
5
+ token: string;
6
+ from: string;
7
+ to: string;
8
+ rawAmount: string;
9
+ amountUsdCents: number;
10
+ txHash: string;
11
+ blockNumber: number;
12
+ confirmations: number;
13
+ confirmationsRequired: number;
14
+ }
15
+
16
+ export interface ICurveDeriver {
17
+ derivePublicKey(chainIndex: number, addressIndex: number): Uint8Array;
18
+ getCurve(): "secp256k1" | "ed25519";
19
+ }
20
+
21
+ export interface EncodingParams {
22
+ hrp?: string;
23
+ version?: string;
24
+ [key: string]: string | undefined;
25
+ }
26
+
27
+ export interface IAddressEncoder {
28
+ encode(publicKey: Uint8Array, params: EncodingParams): string;
29
+ encodingType(): string;
30
+ }
31
+
32
+ export interface KeyPair {
33
+ privateKey: Uint8Array;
34
+ publicKey: Uint8Array;
35
+ address: string;
36
+ index: number;
37
+ }
38
+
39
+ export interface DepositInfo {
40
+ index: number;
41
+ address: string;
42
+ nativeBalance: bigint;
43
+ tokenBalances: Array<{ token: string; balance: bigint; decimals: number }>;
44
+ }
45
+
46
+ export interface SweepResult {
47
+ index: number;
48
+ address: string;
49
+ token: string;
50
+ amount: string;
51
+ txHash: string;
52
+ }
53
+
54
+ export interface ISweepStrategy {
55
+ scan(keys: KeyPair[], treasury: string): Promise<DepositInfo[]>;
56
+ sweep(keys: KeyPair[], treasury: string, dryRun: boolean): Promise<SweepResult[]>;
57
+ }
58
+
59
+ export interface IPriceOracle {
60
+ getPrice(token: string, feedAddress?: string): Promise<{ priceMicros: number }>;
61
+ }
62
+
63
+ export interface IWatcherCursorStore {
64
+ get(watcherId: string): Promise<number | null>;
65
+ save(watcherId: string, cursor: number): Promise<void>;
66
+ getConfirmationCount(watcherId: string, txKey: string): Promise<number | null>;
67
+ saveConfirmationCount(watcherId: string, txKey: string, count: number): Promise<void>;
68
+ }
69
+
70
+ export interface WatcherOpts {
71
+ rpcUrl: string;
72
+ rpcHeaders: Record<string, string>;
73
+ oracle: IPriceOracle;
74
+ cursorStore: IWatcherCursorStore;
75
+ token: string;
76
+ chain: string;
77
+ contractAddress?: string;
78
+ decimals: number;
79
+ confirmations: number;
80
+ }
81
+
82
+ export interface SweeperOpts {
83
+ rpcUrl: string;
84
+ rpcHeaders: Record<string, string>;
85
+ token: string;
86
+ chain: string;
87
+ contractAddress?: string;
88
+ decimals: number;
89
+ }
90
+
91
+ export interface IChainWatcher {
92
+ init(): Promise<void>;
93
+ poll(): Promise<PaymentEvent[]>;
94
+ setWatchedAddresses(addresses: string[]): void;
95
+ getCursor(): number;
96
+ stop(): void;
97
+ }
98
+
99
+ export interface IChainPlugin {
100
+ pluginId: string;
101
+ supportedCurve: "secp256k1" | "ed25519";
102
+ encoders: Record<string, IAddressEncoder>;
103
+ createWatcher(opts: WatcherOpts): IChainWatcher;
104
+ createSweeper(opts: SweeperOpts): ISweepStrategy;
105
+ version: number;
106
+ }
@@ -0,0 +1,26 @@
1
+ import type { IChainPlugin } from "./interfaces.js";
2
+
3
+ export class PluginRegistry {
4
+ private plugins = new Map<string, IChainPlugin>();
5
+
6
+ register(plugin: IChainPlugin): void {
7
+ if (this.plugins.has(plugin.pluginId)) {
8
+ throw new Error(`Plugin "${plugin.pluginId}" is already registered`);
9
+ }
10
+ this.plugins.set(plugin.pluginId, plugin);
11
+ }
12
+
13
+ get(pluginId: string): IChainPlugin | undefined {
14
+ return this.plugins.get(pluginId);
15
+ }
16
+
17
+ getOrThrow(pluginId: string): IChainPlugin {
18
+ const plugin = this.plugins.get(pluginId);
19
+ if (!plugin) throw new Error(`Plugin "${pluginId}" is not registered`);
20
+ return plugin;
21
+ }
22
+
23
+ list(): IChainPlugin[] {
24
+ return [...this.plugins.values()];
25
+ }
26
+ }