@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.
- package/dist/billing/crypto/index.d.ts +2 -0
- package/dist/billing/crypto/index.js +1 -0
- 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 +24 -4
- package/dist/billing/crypto/payment-method-store.d.ts +3 -0
- package/dist/billing/crypto/payment-method-store.js +9 -0
- package/dist/billing/crypto/plugin/__tests__/integration.test.d.ts +1 -0
- package/dist/billing/crypto/plugin/__tests__/integration.test.js +58 -0
- package/dist/billing/crypto/plugin/__tests__/interfaces.test.d.ts +1 -0
- package/dist/billing/crypto/plugin/__tests__/interfaces.test.js +46 -0
- package/dist/billing/crypto/plugin/__tests__/registry.test.d.ts +1 -0
- package/dist/billing/crypto/plugin/__tests__/registry.test.js +49 -0
- package/dist/billing/crypto/plugin/index.d.ts +2 -0
- package/dist/billing/crypto/plugin/index.js +1 -0
- package/dist/billing/crypto/plugin/interfaces.d.ts +97 -0
- package/dist/billing/crypto/plugin/interfaces.js +2 -0
- package/dist/billing/crypto/plugin/registry.d.ts +8 -0
- package/dist/billing/crypto/plugin/registry.js +21 -0
- package/dist/billing/crypto/plugin-watcher-service.d.ts +32 -0
- package/dist/billing/crypto/plugin-watcher-service.js +113 -0
- package/dist/db/schema/crypto.d.ts +328 -0
- package/dist/db/schema/crypto.js +33 -1
- package/dist/db/schema/snapshots.d.ts +1 -1
- package/docs/superpowers/specs/2026-03-24-crypto-plugin-architecture-design.md +48 -34
- package/drizzle/migrations/0023_key_rings_table.sql +35 -0
- package/drizzle/migrations/0024_backfill_key_rings.sql +75 -0
- package/package.json +6 -1
- package/src/billing/crypto/index.ts +9 -0
- package/src/billing/crypto/key-server-entry.ts +48 -12
- package/src/billing/crypto/key-server.ts +28 -3
- package/src/billing/crypto/payment-method-store.ts +12 -0
- package/src/billing/crypto/plugin/__tests__/integration.test.ts +64 -0
- package/src/billing/crypto/plugin/__tests__/interfaces.test.ts +51 -0
- package/src/billing/crypto/plugin/__tests__/registry.test.ts +58 -0
- package/src/billing/crypto/plugin/index.ts +17 -0
- package/src/billing/crypto/plugin/interfaces.ts +106 -0
- package/src/billing/crypto/plugin/registry.ts +26 -0
- package/src/billing/crypto/plugin-watcher-service.ts +148 -0
- 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.
|
|
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
|
|
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);
|
|
@@ -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
|
+
}
|