@wopr-network/platform-core 1.42.2 → 1.43.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.
@@ -1,100 +1,93 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { BTCPayClient, loadCryptoConfig } from "./client.js";
3
- describe("BTCPayClient", () => {
4
- it("createInvoice sends correct request and returns id + checkoutLink", async () => {
5
- const mockResponse = { id: "inv-001", checkoutLink: "https://btcpay.example.com/i/inv-001" };
6
- const fetchSpy = vi
7
- .spyOn(globalThis, "fetch")
8
- .mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
9
- const client = new BTCPayClient({
10
- apiKey: "test-key",
11
- baseUrl: "https://btcpay.example.com",
12
- storeId: "store-abc",
13
- });
14
- const result = await client.createInvoice({
15
- amountUsd: 25,
16
- orderId: "order-123",
17
- buyerEmail: "test@example.com",
18
- });
19
- expect(result.id).toBe("inv-001");
20
- expect(result.checkoutLink).toBe("https://btcpay.example.com/i/inv-001");
21
- expect(fetchSpy).toHaveBeenCalledOnce();
22
- const [url, opts] = fetchSpy.mock.calls[0];
23
- expect(url).toBe("https://btcpay.example.com/api/v1/stores/store-abc/invoices");
2
+ import { BTCPayClient, CryptoServiceClient, loadCryptoConfig } from "./client.js";
3
+ describe("CryptoServiceClient", () => {
4
+ afterEach(() => vi.restoreAllMocks());
5
+ it("deriveAddress sends POST /address with chain", async () => {
6
+ const mockResponse = { address: "bc1q...", index: 42, chain: "bitcoin", token: "BTC" };
7
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 201 }));
8
+ const client = new CryptoServiceClient({ baseUrl: "http://localhost:3100" });
9
+ const result = await client.deriveAddress("btc");
10
+ expect(result.address).toBe("bc1q...");
11
+ expect(result.index).toBe(42);
12
+ const [url, opts] = vi.mocked(fetch).mock.calls[0];
13
+ expect(url).toBe("http://localhost:3100/address");
24
14
  expect(opts?.method).toBe("POST");
15
+ expect(JSON.parse(opts?.body)).toEqual({ chain: "btc" });
16
+ });
17
+ it("createCharge sends POST /charges", async () => {
18
+ const mockResponse = {
19
+ chargeId: "btc:bc1q...",
20
+ address: "bc1q...",
21
+ chain: "btc",
22
+ token: "BTC",
23
+ amountUsd: 50,
24
+ derivationIndex: 42,
25
+ expiresAt: "2026-03-20T04:00:00Z",
26
+ };
27
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 201 }));
28
+ const client = new CryptoServiceClient({
29
+ baseUrl: "http://localhost:3100",
30
+ serviceKey: "sk-test",
31
+ tenantId: "tenant-1",
32
+ });
33
+ const result = await client.createCharge({ chain: "btc", amountUsd: 50 });
34
+ expect(result.chargeId).toBe("btc:bc1q...");
35
+ expect(result.address).toBe("bc1q...");
36
+ const [, opts] = vi.mocked(fetch).mock.calls[0];
25
37
  const headers = opts?.headers;
26
- expect(headers.Authorization).toBe("token test-key");
27
- expect(headers["Content-Type"]).toBe("application/json");
28
- const body = JSON.parse(opts?.body);
29
- expect(body.amount).toBe("25");
30
- expect(body.currency).toBe("USD");
31
- expect(body.metadata.orderId).toBe("order-123");
32
- expect(body.metadata.buyerEmail).toBe("test@example.com");
33
- expect(body.checkout.speedPolicy).toBe("MediumSpeed");
34
- fetchSpy.mockRestore();
38
+ expect(headers.Authorization).toBe("Bearer sk-test");
39
+ expect(headers["X-Tenant-Id"]).toBe("tenant-1");
35
40
  });
36
- it("createInvoice includes redirectURL when provided", async () => {
37
- const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ id: "inv-002", checkoutLink: "https://btcpay.example.com/i/inv-002" }), {
38
- status: 200,
39
- }));
40
- const client = new BTCPayClient({ apiKey: "k", baseUrl: "https://btcpay.example.com", storeId: "s" });
41
- await client.createInvoice({ amountUsd: 10, orderId: "o", redirectURL: "https://app.example.com/success" });
42
- const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body);
43
- expect(body.checkout.redirectURL).toBe("https://app.example.com/success");
44
- fetchSpy.mockRestore();
41
+ it("getCharge sends GET /charges/:id", async () => {
42
+ const mockResponse = { chargeId: "btc:bc1q...", status: "confirmed" };
43
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
44
+ const client = new CryptoServiceClient({ baseUrl: "http://localhost:3100" });
45
+ const result = await client.getCharge("btc:bc1q...");
46
+ expect(result.status).toBe("confirmed");
47
+ expect(vi.mocked(fetch).mock.calls[0][0]).toBe("http://localhost:3100/charges/btc%3Abc1q...");
45
48
  });
46
- it("createInvoice throws on non-ok response", async () => {
47
- const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Unauthorized", { status: 401 }));
48
- const client = new BTCPayClient({ apiKey: "bad-key", baseUrl: "https://btcpay.example.com", storeId: "s" });
49
- await expect(client.createInvoice({ amountUsd: 10, orderId: "o" })).rejects.toThrow("BTCPay createInvoice failed (401)");
50
- fetchSpy.mockRestore();
49
+ it("listChains sends GET /chains", async () => {
50
+ const mockResponse = [{ id: "btc", token: "BTC", chain: "bitcoin", decimals: 8 }];
51
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
52
+ const client = new CryptoServiceClient({ baseUrl: "http://localhost:3100" });
53
+ const result = await client.listChains();
54
+ expect(result).toHaveLength(1);
55
+ expect(result[0].token).toBe("BTC");
51
56
  });
52
- it("getInvoice sends correct request", async () => {
53
- const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ id: "inv-001", status: "Settled", amount: "25", currency: "USD" }), {
54
- status: 200,
55
- }));
56
- const client = new BTCPayClient({ apiKey: "k", baseUrl: "https://btcpay.example.com", storeId: "store-abc" });
57
- const result = await client.getInvoice("inv-001");
58
- expect(result.status).toBe("Settled");
59
- expect(fetchSpy.mock.calls[0][0]).toBe("https://btcpay.example.com/api/v1/stores/store-abc/invoices/inv-001");
60
- fetchSpy.mockRestore();
57
+ it("throws on non-ok response", async () => {
58
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Not found", { status: 404 }));
59
+ const client = new CryptoServiceClient({ baseUrl: "http://localhost:3100" });
60
+ await expect(client.getCharge("missing")).rejects.toThrow("CryptoService getCharge failed (404)");
61
61
  });
62
62
  });
63
- describe("loadCryptoConfig", () => {
64
- beforeEach(() => {
65
- delete process.env.BTCPAY_API_KEY;
66
- delete process.env.BTCPAY_BASE_URL;
67
- delete process.env.BTCPAY_STORE_ID;
68
- });
69
- afterEach(() => {
70
- vi.unstubAllEnvs();
63
+ describe("BTCPayClient (deprecated)", () => {
64
+ it("throws on createInvoice", async () => {
65
+ const client = new BTCPayClient({ apiKey: "k", baseUrl: "https://example.com", storeId: "s" });
66
+ await expect(client.createInvoice({ amountUsd: 10, orderId: "o" })).rejects.toThrow("deprecated");
71
67
  });
72
- it("returns null when BTCPAY_API_KEY is missing", () => {
73
- vi.stubEnv("BTCPAY_BASE_URL", "https://btcpay.test");
74
- vi.stubEnv("BTCPAY_STORE_ID", "store-1");
75
- expect(loadCryptoConfig()).toBeNull();
76
- });
77
- it("returns null when BTCPAY_BASE_URL is missing", () => {
78
- vi.stubEnv("BTCPAY_API_KEY", "test-key");
79
- vi.stubEnv("BTCPAY_STORE_ID", "store-1");
80
- expect(loadCryptoConfig()).toBeNull();
68
+ it("throws on getInvoice", async () => {
69
+ const client = new BTCPayClient({ apiKey: "k", baseUrl: "https://example.com", storeId: "s" });
70
+ await expect(client.getInvoice("inv-1")).rejects.toThrow("deprecated");
81
71
  });
82
- it("returns null when BTCPAY_STORE_ID is missing", () => {
83
- vi.stubEnv("BTCPAY_API_KEY", "test-key");
84
- vi.stubEnv("BTCPAY_BASE_URL", "https://btcpay.test");
85
- expect(loadCryptoConfig()).toBeNull();
72
+ });
73
+ describe("loadCryptoConfig", () => {
74
+ beforeEach(() => {
75
+ delete process.env.CRYPTO_SERVICE_URL;
76
+ delete process.env.CRYPTO_SERVICE_KEY;
77
+ delete process.env.TENANT_ID;
86
78
  });
87
- it("returns config when all env vars are set", () => {
88
- vi.stubEnv("BTCPAY_API_KEY", "test-key");
89
- vi.stubEnv("BTCPAY_BASE_URL", "https://btcpay.test");
90
- vi.stubEnv("BTCPAY_STORE_ID", "store-1");
79
+ afterEach(() => vi.unstubAllEnvs());
80
+ it("returns config when CRYPTO_SERVICE_URL is set", () => {
81
+ vi.stubEnv("CRYPTO_SERVICE_URL", "http://10.120.0.5:3100");
82
+ vi.stubEnv("CRYPTO_SERVICE_KEY", "sk-test");
83
+ vi.stubEnv("TENANT_ID", "tenant-1");
91
84
  expect(loadCryptoConfig()).toEqual({
92
- apiKey: "test-key",
93
- baseUrl: "https://btcpay.test",
94
- storeId: "store-1",
85
+ baseUrl: "http://10.120.0.5:3100",
86
+ serviceKey: "sk-test",
87
+ tenantId: "tenant-1",
95
88
  });
96
89
  });
97
- it("returns null when all env vars are missing", () => {
90
+ it("returns null when CRYPTO_SERVICE_URL is missing", () => {
98
91
  expect(loadCryptoConfig()).toBeNull();
99
92
  });
100
93
  });
@@ -2,11 +2,13 @@ export * from "./btc/index.js";
2
2
  export type { CryptoChargeRecord, CryptoDepositChargeInput, ICryptoChargeRepository } from "./charge-store.js";
3
3
  export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-store.js";
4
4
  export { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
5
- export type { CryptoConfig } from "./client.js";
6
- export { BTCPayClient, loadCryptoConfig } from "./client.js";
5
+ export type { ChainInfo, ChargeStatus, CreateChargeResult, CryptoConfig, CryptoServiceConfig, DeriveAddressResult, } from "./client.js";
6
+ export { BTCPayClient, CryptoServiceClient, loadCryptoConfig } from "./client.js";
7
7
  export type { IWatcherCursorStore } from "./cursor-store.js";
8
8
  export { DrizzleWatcherCursorStore } from "./cursor-store.js";
9
9
  export * from "./evm/index.js";
10
+ export type { KeyServerDeps } from "./key-server.js";
11
+ export { createKeyServerApp } from "./key-server.js";
10
12
  export * from "./oracle/index.js";
11
13
  export type { IPaymentMethodStore, PaymentMethodRecord } from "./payment-method-store.js";
12
14
  export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
@@ -1,9 +1,10 @@
1
1
  export * from "./btc/index.js";
2
2
  export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-store.js";
3
3
  export { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
4
- export { BTCPayClient, loadCryptoConfig } from "./client.js";
4
+ export { BTCPayClient, CryptoServiceClient, loadCryptoConfig } from "./client.js";
5
5
  export { DrizzleWatcherCursorStore } from "./cursor-store.js";
6
6
  export * from "./evm/index.js";
7
+ export { createKeyServerApp } from "./key-server.js";
7
8
  export * from "./oracle/index.js";
8
9
  export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
9
10
  export { mapBtcPayEventToStatus } from "./types.js";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Standalone entry point for the crypto key server.
3
+ *
4
+ * Deploys on the chain server (pay.wopr.bot:3100).
5
+ * Boots: postgres → migrations → key server routes → watchers → serve.
6
+ *
7
+ * Usage: node dist/billing/crypto/key-server-entry.js
8
+ */
9
+ /* biome-ignore-all lint/suspicious/noConsole: standalone entry point */
10
+ import { serve } from "@hono/node-server";
11
+ import { drizzle } from "drizzle-orm/node-postgres";
12
+ import { migrate } from "drizzle-orm/node-postgres/migrator";
13
+ import pg from "pg";
14
+ import * as schema from "../../db/schema/index.js";
15
+ import { DrizzleCryptoChargeRepository } from "./charge-store.js";
16
+ import { createKeyServerApp } from "./key-server.js";
17
+ import { DrizzlePaymentMethodStore } from "./payment-method-store.js";
18
+ const PORT = Number(process.env.PORT ?? "3100");
19
+ const DATABASE_URL = process.env.DATABASE_URL;
20
+ const SERVICE_KEY = process.env.SERVICE_KEY;
21
+ const ADMIN_TOKEN = process.env.ADMIN_TOKEN;
22
+ if (!DATABASE_URL) {
23
+ console.error("DATABASE_URL is required");
24
+ process.exit(1);
25
+ }
26
+ async function main() {
27
+ const pool = new pg.Pool({ connectionString: DATABASE_URL });
28
+ // Run migrations FIRST, before creating schema-typed db
29
+ console.log("[crypto-key-server] Running migrations...");
30
+ await migrate(drizzle(pool), { migrationsFolder: "./drizzle/migrations" });
31
+ // Now create the schema-typed db (columns guaranteed to exist)
32
+ console.log("[crypto-key-server] Connecting...");
33
+ const db = drizzle(pool, { schema });
34
+ const chargeStore = new DrizzleCryptoChargeRepository(db);
35
+ const methodStore = new DrizzlePaymentMethodStore(db);
36
+ const app = createKeyServerApp({ db, chargeStore, methodStore, serviceKey: SERVICE_KEY, adminToken: ADMIN_TOKEN });
37
+ console.log(`[crypto-key-server] Listening on :${PORT}`);
38
+ serve({ fetch: app.fetch, port: PORT });
39
+ }
40
+ main().catch((err) => {
41
+ console.error("[crypto-key-server] Fatal:", err);
42
+ process.exit(1);
43
+ });
@@ -0,0 +1,18 @@
1
+ import { Hono } from "hono";
2
+ import type { DrizzleDb } from "../../db/index.js";
3
+ import type { ICryptoChargeRepository } from "./charge-store.js";
4
+ import type { IPaymentMethodStore } from "./payment-method-store.js";
5
+ export interface KeyServerDeps {
6
+ db: DrizzleDb;
7
+ chargeStore: ICryptoChargeRepository;
8
+ methodStore: IPaymentMethodStore;
9
+ /** Bearer token for product API routes. If unset, auth is disabled. */
10
+ serviceKey?: string;
11
+ /** Bearer token for admin routes. If unset, admin routes are disabled. */
12
+ adminToken?: string;
13
+ }
14
+ /**
15
+ * Create the Hono app for the crypto key server.
16
+ * Mount this on the chain server at the root.
17
+ */
18
+ export declare function createKeyServerApp(deps: KeyServerDeps): Hono;
@@ -0,0 +1,239 @@
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
+ */
10
+ import { eq, sql } from "drizzle-orm";
11
+ import { Hono } from "hono";
12
+ import { derivedAddresses, pathAllocations, paymentMethods } from "../../db/schema/crypto.js";
13
+ import { deriveAddress, deriveP2pkhAddress } from "./btc/address-gen.js";
14
+ import { deriveDepositAddress } from "./evm/address-gen.js";
15
+ /**
16
+ * Derive the next unused address for a chain.
17
+ * Atomically increments next_index and records address in a single transaction.
18
+ */
19
+ async function deriveNextAddress(db, chainId, tenantId) {
20
+ // Wrap in transaction: if the address insert fails, next_index is not consumed.
21
+ return db.transaction(async (tx) => {
22
+ // Atomic increment: UPDATE ... SET next_index = next_index + 1 RETURNING *
23
+ const [method] = await tx
24
+ .update(paymentMethods)
25
+ .set({ nextIndex: sql `${paymentMethods.nextIndex} + 1` })
26
+ .where(eq(paymentMethods.id, chainId))
27
+ .returning();
28
+ if (!method)
29
+ throw new Error(`Chain not found: ${chainId}`);
30
+ if (!method.xpub)
31
+ throw new Error(`No xpub configured for chain: ${chainId}`);
32
+ // The index we use is the value BEFORE increment (returned value - 1)
33
+ const index = method.nextIndex - 1;
34
+ // Route to the right derivation function
35
+ let address;
36
+ if (method.type === "native" && method.chain === "dogecoin") {
37
+ address = deriveP2pkhAddress(method.xpub, index, "dogecoin");
38
+ }
39
+ else if (method.type === "native" && (method.chain === "bitcoin" || method.chain === "litecoin")) {
40
+ address = deriveAddress(method.xpub, index, "mainnet", method.chain);
41
+ }
42
+ else {
43
+ // EVM (all ERC20 + native ETH) — same derivation
44
+ address = deriveDepositAddress(method.xpub, index);
45
+ }
46
+ // Record in immutable log (inside same transaction)
47
+ await tx.insert(derivedAddresses).values({
48
+ chainId,
49
+ derivationIndex: index,
50
+ address: address.toLowerCase(),
51
+ tenantId,
52
+ });
53
+ return { address, index, chain: method.chain, token: method.token };
54
+ });
55
+ }
56
+ /** Validate Bearer token from Authorization header. */
57
+ function requireAuth(header, expected) {
58
+ if (!expected)
59
+ return true; // auth disabled
60
+ return header === `Bearer ${expected}`;
61
+ }
62
+ /**
63
+ * Create the Hono app for the crypto key server.
64
+ * Mount this on the chain server at the root.
65
+ */
66
+ export function createKeyServerApp(deps) {
67
+ const app = new Hono();
68
+ // --- Auth middleware for product routes ---
69
+ app.use("/address", async (c, next) => {
70
+ if (deps.serviceKey && !requireAuth(c.req.header("Authorization"), deps.serviceKey)) {
71
+ return c.json({ error: "Unauthorized" }, 401);
72
+ }
73
+ await next();
74
+ });
75
+ app.use("/charges/*", async (c, next) => {
76
+ if (deps.serviceKey && !requireAuth(c.req.header("Authorization"), deps.serviceKey)) {
77
+ return c.json({ error: "Unauthorized" }, 401);
78
+ }
79
+ await next();
80
+ });
81
+ app.use("/charges", async (c, next) => {
82
+ if (deps.serviceKey && !requireAuth(c.req.header("Authorization"), deps.serviceKey)) {
83
+ return c.json({ error: "Unauthorized" }, 401);
84
+ }
85
+ await next();
86
+ });
87
+ // --- Auth middleware for admin routes ---
88
+ app.use("/admin/*", async (c, next) => {
89
+ if (!deps.adminToken)
90
+ return c.json({ error: "Admin API disabled" }, 403);
91
+ if (!requireAuth(c.req.header("Authorization"), deps.adminToken)) {
92
+ return c.json({ error: "Unauthorized" }, 401);
93
+ }
94
+ await next();
95
+ });
96
+ // --- Product API ---
97
+ /** POST /address — derive next unused address */
98
+ app.post("/address", async (c) => {
99
+ const body = await c.req.json();
100
+ if (!body.chain)
101
+ return c.json({ error: "chain is required" }, 400);
102
+ const tenantId = c.req.header("X-Tenant-Id");
103
+ const result = await deriveNextAddress(deps.db, body.chain, tenantId ?? undefined);
104
+ return c.json(result, 201);
105
+ });
106
+ /** POST /charges — create charge + derive address + start watching */
107
+ app.post("/charges", async (c) => {
108
+ const body = await c.req.json();
109
+ if (!body.chain || typeof body.amountUsd !== "number" || !Number.isFinite(body.amountUsd) || body.amountUsd <= 0) {
110
+ return c.json({ error: "chain is required and amountUsd must be a positive finite number" }, 400);
111
+ }
112
+ const tenantId = c.req.header("X-Tenant-Id") ?? "unknown";
113
+ const { address, index, chain, token } = await deriveNextAddress(deps.db, body.chain, tenantId);
114
+ const amountUsdCents = Math.round(body.amountUsd * 100);
115
+ const referenceId = `${token.toLowerCase()}:${address.toLowerCase()}`;
116
+ await deps.chargeStore.createStablecoinCharge({
117
+ referenceId,
118
+ tenantId,
119
+ amountUsdCents,
120
+ chain,
121
+ token,
122
+ depositAddress: address,
123
+ derivationIndex: index,
124
+ });
125
+ return c.json({
126
+ chargeId: referenceId,
127
+ address,
128
+ chain: body.chain,
129
+ token,
130
+ amountUsd: body.amountUsd,
131
+ derivationIndex: index,
132
+ expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), // 30 min
133
+ }, 201);
134
+ });
135
+ /** GET /charges/:id — check charge status */
136
+ app.get("/charges/:id", async (c) => {
137
+ const charge = await deps.chargeStore.getByReferenceId(c.req.param("id"));
138
+ if (!charge)
139
+ return c.json({ error: "Charge not found" }, 404);
140
+ return c.json({
141
+ chargeId: charge.referenceId,
142
+ status: charge.status,
143
+ address: charge.depositAddress,
144
+ chain: charge.chain,
145
+ token: charge.token,
146
+ amountUsdCents: charge.amountUsdCents,
147
+ creditedAt: charge.creditedAt,
148
+ });
149
+ });
150
+ /** GET /chains — list enabled payment methods (for checkout UI) */
151
+ app.get("/chains", async (c) => {
152
+ const methods = await deps.methodStore.listEnabled();
153
+ return c.json(methods.map((m) => ({
154
+ id: m.id,
155
+ token: m.token,
156
+ chain: m.chain,
157
+ decimals: m.decimals,
158
+ displayName: m.displayName,
159
+ contractAddress: m.contractAddress,
160
+ confirmations: m.confirmations,
161
+ })));
162
+ });
163
+ // --- Admin API ---
164
+ /** GET /admin/next-path — which derivation path to use for a coin type */
165
+ app.get("/admin/next-path", async (c) => {
166
+ const coinType = Number(c.req.query("coin_type"));
167
+ if (!Number.isInteger(coinType))
168
+ return c.json({ error: "coin_type must be an integer" }, 400);
169
+ // Find all allocations for this coin type
170
+ const existing = await deps.db.select().from(pathAllocations).where(eq(pathAllocations.coinType, coinType));
171
+ if (existing.length === 0) {
172
+ return c.json({
173
+ coin_type: coinType,
174
+ account_index: 0,
175
+ path: `m/44'/${coinType}'/0'`,
176
+ status: "available",
177
+ });
178
+ }
179
+ // If already allocated, return info about existing allocation
180
+ const latest = existing.sort((a, b) => b.accountIndex - a.accountIndex)[0];
181
+ // Find chains using this coin type's allocations
182
+ const chainIds = existing.map((a) => a.chainId).filter(Boolean);
183
+ return c.json({
184
+ coin_type: coinType,
185
+ account_index: latest.accountIndex,
186
+ path: `m/44'/${coinType}'/${latest.accountIndex}'`,
187
+ status: "allocated",
188
+ allocated_to: chainIds,
189
+ note: "xpub already registered — reuse for new chains with same key type",
190
+ next_available: {
191
+ account_index: latest.accountIndex + 1,
192
+ path: `m/44'/${coinType}'/${latest.accountIndex + 1}'`,
193
+ },
194
+ });
195
+ });
196
+ /** POST /admin/chains — register a new chain with its xpub */
197
+ app.post("/admin/chains", async (c) => {
198
+ const body = await c.req.json();
199
+ if (!body.id || !body.xpub || !body.token) {
200
+ return c.json({ error: "id, xpub, and token are required" }, 400);
201
+ }
202
+ // Record the path allocation (idempotent — ignore if already exists)
203
+ const inserted = (await deps.db
204
+ .insert(pathAllocations)
205
+ .values({
206
+ coinType: body.coin_type,
207
+ accountIndex: body.account_index,
208
+ chainId: body.id,
209
+ xpub: body.xpub,
210
+ })
211
+ .onConflictDoNothing());
212
+ if (inserted.rowCount === 0) {
213
+ return c.json({ error: "Path allocation already exists", path: `m/44'/${body.coin_type}'/${body.account_index}'` }, 409);
214
+ }
215
+ // Upsert the payment method
216
+ await deps.methodStore.upsert({
217
+ id: body.id,
218
+ type: body.type ?? "native",
219
+ token: body.token,
220
+ chain: body.chain ?? body.network,
221
+ contractAddress: body.contract ?? null,
222
+ decimals: body.decimals,
223
+ displayName: body.display_name ?? `${body.token} on ${body.network}`,
224
+ enabled: true,
225
+ displayOrder: 0,
226
+ rpcUrl: body.rpc_url,
227
+ oracleAddress: body.oracle_address ?? null,
228
+ xpub: body.xpub,
229
+ confirmations: body.confirmations ?? 6,
230
+ });
231
+ return c.json({ id: body.id, path: `m/44'/${body.coin_type}'/${body.account_index}'` }, 201);
232
+ });
233
+ /** DELETE /admin/chains/:id — soft disable */
234
+ app.delete("/admin/chains/:id", async (c) => {
235
+ await deps.methodStore.setEnabled(c.req.param("id"), false);
236
+ return c.body(null, 204);
237
+ });
238
+ return app;
239
+ }