@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.
- package/.github/workflows/key-server-image.yml +35 -0
- package/Dockerfile.key-server +20 -0
- package/GATEWAY_BILLING_RESEARCH.md +430 -0
- package/biome.json +2 -9
- package/dist/billing/crypto/__tests__/key-server.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/key-server.test.js +225 -0
- package/dist/billing/crypto/client.d.ts +84 -20
- package/dist/billing/crypto/client.js +76 -46
- package/dist/billing/crypto/client.test.js +76 -83
- package/dist/billing/crypto/index.d.ts +4 -2
- package/dist/billing/crypto/index.js +2 -1
- package/dist/billing/crypto/key-server-entry.d.ts +1 -0
- package/dist/billing/crypto/key-server-entry.js +43 -0
- package/dist/billing/crypto/key-server.d.ts +18 -0
- package/dist/billing/crypto/key-server.js +239 -0
- package/dist/db/schema/crypto.d.ts +247 -0
- package/dist/db/schema/crypto.js +35 -4
- package/dist/fleet/instance.d.ts +2 -0
- package/dist/fleet/instance.js +15 -0
- package/drizzle/migrations/0014_crypto_key_server.sql +60 -0
- package/drizzle/migrations/meta/_journal.json +21 -0
- package/package.json +2 -1
- package/src/billing/crypto/__tests__/key-server.test.ts +247 -0
- package/src/billing/crypto/client.test.ts +80 -96
- package/src/billing/crypto/client.ts +138 -58
- package/src/billing/crypto/index.ts +11 -2
- package/src/billing/crypto/key-server-entry.ts +52 -0
- package/src/billing/crypto/key-server.ts +315 -0
- package/src/db/schema/crypto.ts +45 -4
- package/src/fleet/instance.ts +16 -0
|
@@ -1,86 +1,166 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Crypto Key Server client — for products to call the shared service.
|
|
3
|
+
*
|
|
4
|
+
* Replaces BTCPayClient. Products set CRYPTO_SERVICE_URL instead of
|
|
5
|
+
* BTCPAY_API_KEY + BTCPAY_BASE_URL + BTCPAY_STORE_ID.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface CryptoServiceConfig {
|
|
9
|
+
/** Base URL of the crypto key server (e.g. http://10.120.0.5:3100) */
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
/** Service key for auth (reuses gateway service key) */
|
|
12
|
+
serviceKey?: string;
|
|
13
|
+
/** Tenant ID header */
|
|
14
|
+
tenantId?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DeriveAddressResult {
|
|
18
|
+
address: string;
|
|
19
|
+
index: number;
|
|
20
|
+
chain: string;
|
|
21
|
+
token: string;
|
|
22
|
+
}
|
|
2
23
|
|
|
3
|
-
export
|
|
24
|
+
export interface CreateChargeResult {
|
|
25
|
+
chargeId: string;
|
|
26
|
+
address: string;
|
|
27
|
+
chain: string;
|
|
28
|
+
token: string;
|
|
29
|
+
amountUsd: number;
|
|
30
|
+
derivationIndex: number;
|
|
31
|
+
expiresAt: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ChargeStatus {
|
|
35
|
+
chargeId: string;
|
|
36
|
+
status: string;
|
|
37
|
+
address: string | null;
|
|
38
|
+
chain: string | null;
|
|
39
|
+
token: string | null;
|
|
40
|
+
amountUsdCents: number;
|
|
41
|
+
creditedAt: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ChainInfo {
|
|
45
|
+
id: string;
|
|
46
|
+
token: string;
|
|
47
|
+
chain: string;
|
|
48
|
+
decimals: number;
|
|
49
|
+
displayName: string;
|
|
50
|
+
contractAddress: string | null;
|
|
51
|
+
confirmations: number;
|
|
52
|
+
}
|
|
4
53
|
|
|
5
54
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* Uses plain fetch — zero vendor dependencies.
|
|
9
|
-
* Auth header format: "token <apiKey>" (NOT "Bearer").
|
|
55
|
+
* Client for the shared crypto key server.
|
|
56
|
+
* Products use this instead of running local watchers + holding xpubs.
|
|
10
57
|
*/
|
|
11
|
-
export class
|
|
12
|
-
constructor(private readonly config:
|
|
58
|
+
export class CryptoServiceClient {
|
|
59
|
+
constructor(private readonly config: CryptoServiceConfig) {}
|
|
13
60
|
|
|
14
61
|
private headers(): Record<string, string> {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
62
|
+
const h: Record<string, string> = { "Content-Type": "application/json" };
|
|
63
|
+
if (this.config.serviceKey) h.Authorization = `Bearer ${this.config.serviceKey}`;
|
|
64
|
+
if (this.config.tenantId) h["X-Tenant-Id"] = this.config.tenantId;
|
|
65
|
+
return h;
|
|
19
66
|
}
|
|
20
67
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
* Returns the invoice ID and checkout link (URL to redirect the user).
|
|
25
|
-
*/
|
|
26
|
-
async createInvoice(opts: {
|
|
27
|
-
amountUsd: number;
|
|
28
|
-
orderId: string;
|
|
29
|
-
buyerEmail?: string;
|
|
30
|
-
redirectURL?: string;
|
|
31
|
-
}): Promise<{ id: string; checkoutLink: string }> {
|
|
32
|
-
const url = `${this.config.baseUrl}/api/v1/stores/${this.config.storeId}/invoices`;
|
|
33
|
-
const body = {
|
|
34
|
-
amount: String(opts.amountUsd),
|
|
35
|
-
currency: "USD",
|
|
36
|
-
metadata: {
|
|
37
|
-
orderId: opts.orderId,
|
|
38
|
-
buyerEmail: opts.buyerEmail,
|
|
39
|
-
},
|
|
40
|
-
checkout: {
|
|
41
|
-
speedPolicy: "MediumSpeed",
|
|
42
|
-
expirationMinutes: 30,
|
|
43
|
-
...(opts.redirectURL ? { redirectURL: opts.redirectURL } : {}),
|
|
44
|
-
},
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const res = await fetch(url, {
|
|
68
|
+
/** Derive the next unused address for a chain. */
|
|
69
|
+
async deriveAddress(chain: string): Promise<DeriveAddressResult> {
|
|
70
|
+
const res = await fetch(`${this.config.baseUrl}/address`, {
|
|
48
71
|
method: "POST",
|
|
49
72
|
headers: this.headers(),
|
|
50
|
-
body: JSON.stringify(
|
|
73
|
+
body: JSON.stringify({ chain }),
|
|
51
74
|
});
|
|
52
|
-
|
|
53
75
|
if (!res.ok) {
|
|
54
76
|
const text = await res.text().catch(() => "");
|
|
55
|
-
throw new Error(`
|
|
77
|
+
throw new Error(`CryptoService deriveAddress failed (${res.status}): ${text}`);
|
|
56
78
|
}
|
|
79
|
+
return (await res.json()) as DeriveAddressResult;
|
|
80
|
+
}
|
|
57
81
|
|
|
58
|
-
|
|
59
|
-
|
|
82
|
+
/** Create a payment charge — derives address, sets expiry, starts watching. */
|
|
83
|
+
async createCharge(opts: {
|
|
84
|
+
chain: string;
|
|
85
|
+
amountUsd: number;
|
|
86
|
+
callbackUrl?: string;
|
|
87
|
+
metadata?: Record<string, unknown>;
|
|
88
|
+
}): Promise<CreateChargeResult> {
|
|
89
|
+
const res = await fetch(`${this.config.baseUrl}/charges`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: this.headers(),
|
|
92
|
+
body: JSON.stringify(opts),
|
|
93
|
+
});
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
const text = await res.text().catch(() => "");
|
|
96
|
+
throw new Error(`CryptoService createCharge failed (${res.status}): ${text}`);
|
|
97
|
+
}
|
|
98
|
+
return (await res.json()) as CreateChargeResult;
|
|
60
99
|
}
|
|
61
100
|
|
|
62
|
-
/**
|
|
63
|
-
async
|
|
64
|
-
const
|
|
65
|
-
|
|
101
|
+
/** Check charge status. */
|
|
102
|
+
async getCharge(chargeId: string): Promise<ChargeStatus> {
|
|
103
|
+
const res = await fetch(`${this.config.baseUrl}/charges/${encodeURIComponent(chargeId)}`, {
|
|
104
|
+
headers: this.headers(),
|
|
105
|
+
});
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
const text = await res.text().catch(() => "");
|
|
108
|
+
throw new Error(`CryptoService getCharge failed (${res.status}): ${text}`);
|
|
109
|
+
}
|
|
110
|
+
return (await res.json()) as ChargeStatus;
|
|
111
|
+
}
|
|
66
112
|
|
|
113
|
+
/** List all enabled payment methods (for checkout UI). */
|
|
114
|
+
async listChains(): Promise<ChainInfo[]> {
|
|
115
|
+
const res = await fetch(`${this.config.baseUrl}/chains`, {
|
|
116
|
+
headers: this.headers(),
|
|
117
|
+
});
|
|
67
118
|
if (!res.ok) {
|
|
68
119
|
const text = await res.text().catch(() => "");
|
|
69
|
-
throw new Error(`
|
|
120
|
+
throw new Error(`CryptoService listChains failed (${res.status}): ${text}`);
|
|
70
121
|
}
|
|
122
|
+
return (await res.json()) as ChainInfo[];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
71
125
|
|
|
72
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Load crypto service config from environment.
|
|
128
|
+
* Returns null if CRYPTO_SERVICE_URL is not set.
|
|
129
|
+
*
|
|
130
|
+
* Also supports legacy BTCPay env vars for backwards compat during migration.
|
|
131
|
+
*/
|
|
132
|
+
export function loadCryptoConfig(): CryptoServiceConfig | null {
|
|
133
|
+
const baseUrl = process.env.CRYPTO_SERVICE_URL;
|
|
134
|
+
if (baseUrl) {
|
|
135
|
+
return {
|
|
136
|
+
baseUrl,
|
|
137
|
+
serviceKey: process.env.CRYPTO_SERVICE_KEY,
|
|
138
|
+
tenantId: process.env.TENANT_ID,
|
|
139
|
+
};
|
|
73
140
|
}
|
|
141
|
+
return null;
|
|
74
142
|
}
|
|
75
143
|
|
|
144
|
+
// Legacy type alias for backwards compat
|
|
145
|
+
export type CryptoConfig = CryptoServiceConfig;
|
|
146
|
+
|
|
76
147
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
148
|
+
* @deprecated Use CryptoServiceClient instead. BTCPay is replaced by the crypto key server.
|
|
149
|
+
* Kept for backwards compat — products still import BTCPayClient during migration.
|
|
79
150
|
*/
|
|
80
|
-
export
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
151
|
+
export class BTCPayClient {
|
|
152
|
+
constructor(_config: { apiKey: string; baseUrl: string; storeId: string }) {}
|
|
153
|
+
|
|
154
|
+
async createInvoice(_opts: {
|
|
155
|
+
amountUsd: number;
|
|
156
|
+
orderId: string;
|
|
157
|
+
buyerEmail?: string;
|
|
158
|
+
redirectURL?: string;
|
|
159
|
+
}): Promise<{ id: string; checkoutLink: string }> {
|
|
160
|
+
throw new Error("BTCPayClient is deprecated — migrate to CryptoServiceClient");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async getInvoice(_invoiceId: string): Promise<{ id: string; status: string; amount: string; currency: string }> {
|
|
164
|
+
throw new Error("BTCPayClient is deprecated — migrate to CryptoServiceClient");
|
|
165
|
+
}
|
|
86
166
|
}
|
|
@@ -2,11 +2,20 @@ 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 {
|
|
6
|
-
|
|
5
|
+
export type {
|
|
6
|
+
ChainInfo,
|
|
7
|
+
ChargeStatus,
|
|
8
|
+
CreateChargeResult,
|
|
9
|
+
CryptoConfig,
|
|
10
|
+
CryptoServiceConfig,
|
|
11
|
+
DeriveAddressResult,
|
|
12
|
+
} from "./client.js";
|
|
13
|
+
export { BTCPayClient, CryptoServiceClient, loadCryptoConfig } from "./client.js";
|
|
7
14
|
export type { IWatcherCursorStore } from "./cursor-store.js";
|
|
8
15
|
export { DrizzleWatcherCursorStore } from "./cursor-store.js";
|
|
9
16
|
export * from "./evm/index.js";
|
|
17
|
+
export type { KeyServerDeps } from "./key-server.js";
|
|
18
|
+
export { createKeyServerApp } from "./key-server.js";
|
|
10
19
|
export * from "./oracle/index.js";
|
|
11
20
|
export type { IPaymentMethodStore, PaymentMethodRecord } from "./payment-method-store.js";
|
|
12
21
|
export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
|
|
19
|
+
const PORT = Number(process.env.PORT ?? "3100");
|
|
20
|
+
const DATABASE_URL = process.env.DATABASE_URL;
|
|
21
|
+
const SERVICE_KEY = process.env.SERVICE_KEY;
|
|
22
|
+
const ADMIN_TOKEN = process.env.ADMIN_TOKEN;
|
|
23
|
+
|
|
24
|
+
if (!DATABASE_URL) {
|
|
25
|
+
console.error("DATABASE_URL is required");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function main(): Promise<void> {
|
|
30
|
+
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
|
31
|
+
|
|
32
|
+
// Run migrations FIRST, before creating schema-typed db
|
|
33
|
+
console.log("[crypto-key-server] Running migrations...");
|
|
34
|
+
await migrate(drizzle(pool), { migrationsFolder: "./drizzle/migrations" });
|
|
35
|
+
|
|
36
|
+
// Now create the schema-typed db (columns guaranteed to exist)
|
|
37
|
+
console.log("[crypto-key-server] Connecting...");
|
|
38
|
+
const db = drizzle(pool, { schema }) as unknown as import("../../db/index.js").DrizzleDb;
|
|
39
|
+
|
|
40
|
+
const chargeStore = new DrizzleCryptoChargeRepository(db);
|
|
41
|
+
const methodStore = new DrizzlePaymentMethodStore(db);
|
|
42
|
+
|
|
43
|
+
const app = createKeyServerApp({ db, chargeStore, methodStore, serviceKey: SERVICE_KEY, adminToken: ADMIN_TOKEN });
|
|
44
|
+
|
|
45
|
+
console.log(`[crypto-key-server] Listening on :${PORT}`);
|
|
46
|
+
serve({ fetch: app.fetch, port: PORT });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
main().catch((err) => {
|
|
50
|
+
console.error("[crypto-key-server] Fatal:", err);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
});
|
|
@@ -0,0 +1,315 @@
|
|
|
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 type { DrizzleDb } from "../../db/index.js";
|
|
13
|
+
import { derivedAddresses, pathAllocations, paymentMethods } from "../../db/schema/crypto.js";
|
|
14
|
+
import { deriveAddress, deriveP2pkhAddress } from "./btc/address-gen.js";
|
|
15
|
+
import type { ICryptoChargeRepository } from "./charge-store.js";
|
|
16
|
+
import { deriveDepositAddress } from "./evm/address-gen.js";
|
|
17
|
+
import type { IPaymentMethodStore } from "./payment-method-store.js";
|
|
18
|
+
|
|
19
|
+
export interface KeyServerDeps {
|
|
20
|
+
db: DrizzleDb;
|
|
21
|
+
chargeStore: ICryptoChargeRepository;
|
|
22
|
+
methodStore: IPaymentMethodStore;
|
|
23
|
+
/** Bearer token for product API routes. If unset, auth is disabled. */
|
|
24
|
+
serviceKey?: string;
|
|
25
|
+
/** Bearer token for admin routes. If unset, admin routes are disabled. */
|
|
26
|
+
adminToken?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Derive the next unused address for a chain.
|
|
31
|
+
* Atomically increments next_index and records address in a single transaction.
|
|
32
|
+
*/
|
|
33
|
+
async function deriveNextAddress(
|
|
34
|
+
db: DrizzleDb,
|
|
35
|
+
chainId: string,
|
|
36
|
+
tenantId?: string,
|
|
37
|
+
): Promise<{ address: string; index: number; chain: string; token: string }> {
|
|
38
|
+
// Wrap in transaction: if the address insert fails, next_index is not consumed.
|
|
39
|
+
return (db as unknown as { transaction: (fn: (tx: DrizzleDb) => Promise<unknown>) => Promise<unknown> }).transaction(
|
|
40
|
+
async (tx: DrizzleDb) => {
|
|
41
|
+
// Atomic increment: UPDATE ... SET next_index = next_index + 1 RETURNING *
|
|
42
|
+
const [method] = await tx
|
|
43
|
+
.update(paymentMethods)
|
|
44
|
+
.set({ nextIndex: sql`${paymentMethods.nextIndex} + 1` })
|
|
45
|
+
.where(eq(paymentMethods.id, chainId))
|
|
46
|
+
.returning();
|
|
47
|
+
|
|
48
|
+
if (!method) throw new Error(`Chain not found: ${chainId}`);
|
|
49
|
+
if (!method.xpub) throw new Error(`No xpub configured for chain: ${chainId}`);
|
|
50
|
+
|
|
51
|
+
// The index we use is the value BEFORE increment (returned value - 1)
|
|
52
|
+
const index = method.nextIndex - 1;
|
|
53
|
+
|
|
54
|
+
// Route to the right derivation function
|
|
55
|
+
let address: string;
|
|
56
|
+
if (method.type === "native" && method.chain === "dogecoin") {
|
|
57
|
+
address = deriveP2pkhAddress(method.xpub, index, "dogecoin");
|
|
58
|
+
} else if (method.type === "native" && (method.chain === "bitcoin" || method.chain === "litecoin")) {
|
|
59
|
+
address = deriveAddress(method.xpub, index, "mainnet", method.chain as "bitcoin" | "litecoin");
|
|
60
|
+
} else {
|
|
61
|
+
// EVM (all ERC20 + native ETH) — same derivation
|
|
62
|
+
address = deriveDepositAddress(method.xpub, index);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Record in immutable log (inside same transaction)
|
|
66
|
+
await tx.insert(derivedAddresses).values({
|
|
67
|
+
chainId,
|
|
68
|
+
derivationIndex: index,
|
|
69
|
+
address: address.toLowerCase(),
|
|
70
|
+
tenantId,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return { address, index, chain: method.chain, token: method.token };
|
|
74
|
+
},
|
|
75
|
+
) as Promise<{ address: string; index: number; chain: string; token: string }>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Validate Bearer token from Authorization header. */
|
|
79
|
+
function requireAuth(header: string | undefined, expected: string): boolean {
|
|
80
|
+
if (!expected) return true; // auth disabled
|
|
81
|
+
return header === `Bearer ${expected}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create the Hono app for the crypto key server.
|
|
86
|
+
* Mount this on the chain server at the root.
|
|
87
|
+
*/
|
|
88
|
+
export function createKeyServerApp(deps: KeyServerDeps): Hono {
|
|
89
|
+
const app = new Hono();
|
|
90
|
+
|
|
91
|
+
// --- Auth middleware for product routes ---
|
|
92
|
+
app.use("/address", async (c, next) => {
|
|
93
|
+
if (deps.serviceKey && !requireAuth(c.req.header("Authorization"), deps.serviceKey)) {
|
|
94
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
95
|
+
}
|
|
96
|
+
await next();
|
|
97
|
+
});
|
|
98
|
+
app.use("/charges/*", async (c, next) => {
|
|
99
|
+
if (deps.serviceKey && !requireAuth(c.req.header("Authorization"), deps.serviceKey)) {
|
|
100
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
101
|
+
}
|
|
102
|
+
await next();
|
|
103
|
+
});
|
|
104
|
+
app.use("/charges", async (c, next) => {
|
|
105
|
+
if (deps.serviceKey && !requireAuth(c.req.header("Authorization"), deps.serviceKey)) {
|
|
106
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
107
|
+
}
|
|
108
|
+
await next();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// --- Auth middleware for admin routes ---
|
|
112
|
+
app.use("/admin/*", async (c, next) => {
|
|
113
|
+
if (!deps.adminToken) return c.json({ error: "Admin API disabled" }, 403);
|
|
114
|
+
if (!requireAuth(c.req.header("Authorization"), deps.adminToken)) {
|
|
115
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
116
|
+
}
|
|
117
|
+
await next();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// --- Product API ---
|
|
121
|
+
|
|
122
|
+
/** POST /address — derive next unused address */
|
|
123
|
+
app.post("/address", async (c) => {
|
|
124
|
+
const body = await c.req.json<{ chain: string }>();
|
|
125
|
+
if (!body.chain) return c.json({ error: "chain is required" }, 400);
|
|
126
|
+
|
|
127
|
+
const tenantId = c.req.header("X-Tenant-Id");
|
|
128
|
+
const result = await deriveNextAddress(deps.db, body.chain, tenantId ?? undefined);
|
|
129
|
+
return c.json(result, 201);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/** POST /charges — create charge + derive address + start watching */
|
|
133
|
+
app.post("/charges", async (c) => {
|
|
134
|
+
const body = await c.req.json<{
|
|
135
|
+
chain: string;
|
|
136
|
+
amountUsd: number;
|
|
137
|
+
callbackUrl?: string;
|
|
138
|
+
metadata?: Record<string, unknown>;
|
|
139
|
+
}>();
|
|
140
|
+
|
|
141
|
+
if (!body.chain || typeof body.amountUsd !== "number" || !Number.isFinite(body.amountUsd) || body.amountUsd <= 0) {
|
|
142
|
+
return c.json({ error: "chain is required and amountUsd must be a positive finite number" }, 400);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const tenantId = c.req.header("X-Tenant-Id") ?? "unknown";
|
|
146
|
+
const { address, index, chain, token } = await deriveNextAddress(deps.db, body.chain, tenantId);
|
|
147
|
+
|
|
148
|
+
const amountUsdCents = Math.round(body.amountUsd * 100);
|
|
149
|
+
const referenceId = `${token.toLowerCase()}:${address.toLowerCase()}`;
|
|
150
|
+
|
|
151
|
+
await deps.chargeStore.createStablecoinCharge({
|
|
152
|
+
referenceId,
|
|
153
|
+
tenantId,
|
|
154
|
+
amountUsdCents,
|
|
155
|
+
chain,
|
|
156
|
+
token,
|
|
157
|
+
depositAddress: address,
|
|
158
|
+
derivationIndex: index,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return c.json(
|
|
162
|
+
{
|
|
163
|
+
chargeId: referenceId,
|
|
164
|
+
address,
|
|
165
|
+
chain: body.chain,
|
|
166
|
+
token,
|
|
167
|
+
amountUsd: body.amountUsd,
|
|
168
|
+
derivationIndex: index,
|
|
169
|
+
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), // 30 min
|
|
170
|
+
},
|
|
171
|
+
201,
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/** GET /charges/:id — check charge status */
|
|
176
|
+
app.get("/charges/:id", async (c) => {
|
|
177
|
+
const charge = await deps.chargeStore.getByReferenceId(c.req.param("id"));
|
|
178
|
+
if (!charge) return c.json({ error: "Charge not found" }, 404);
|
|
179
|
+
|
|
180
|
+
return c.json({
|
|
181
|
+
chargeId: charge.referenceId,
|
|
182
|
+
status: charge.status,
|
|
183
|
+
address: charge.depositAddress,
|
|
184
|
+
chain: charge.chain,
|
|
185
|
+
token: charge.token,
|
|
186
|
+
amountUsdCents: charge.amountUsdCents,
|
|
187
|
+
creditedAt: charge.creditedAt,
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/** GET /chains — list enabled payment methods (for checkout UI) */
|
|
192
|
+
app.get("/chains", async (c) => {
|
|
193
|
+
const methods = await deps.methodStore.listEnabled();
|
|
194
|
+
return c.json(
|
|
195
|
+
methods.map((m) => ({
|
|
196
|
+
id: m.id,
|
|
197
|
+
token: m.token,
|
|
198
|
+
chain: m.chain,
|
|
199
|
+
decimals: m.decimals,
|
|
200
|
+
displayName: m.displayName,
|
|
201
|
+
contractAddress: m.contractAddress,
|
|
202
|
+
confirmations: m.confirmations,
|
|
203
|
+
})),
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// --- Admin API ---
|
|
208
|
+
|
|
209
|
+
/** GET /admin/next-path — which derivation path to use for a coin type */
|
|
210
|
+
app.get("/admin/next-path", async (c) => {
|
|
211
|
+
const coinType = Number(c.req.query("coin_type"));
|
|
212
|
+
if (!Number.isInteger(coinType)) return c.json({ error: "coin_type must be an integer" }, 400);
|
|
213
|
+
|
|
214
|
+
// Find all allocations for this coin type
|
|
215
|
+
const existing = await deps.db.select().from(pathAllocations).where(eq(pathAllocations.coinType, coinType));
|
|
216
|
+
|
|
217
|
+
if (existing.length === 0) {
|
|
218
|
+
return c.json({
|
|
219
|
+
coin_type: coinType,
|
|
220
|
+
account_index: 0,
|
|
221
|
+
path: `m/44'/${coinType}'/0'`,
|
|
222
|
+
status: "available",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// If already allocated, return info about existing allocation
|
|
227
|
+
const latest = existing.sort(
|
|
228
|
+
(a: { accountIndex: number }, b: { accountIndex: number }) => b.accountIndex - a.accountIndex,
|
|
229
|
+
)[0];
|
|
230
|
+
|
|
231
|
+
// Find chains using this coin type's allocations
|
|
232
|
+
const chainIds = existing.map((a: { chainId: string | null }) => a.chainId).filter(Boolean);
|
|
233
|
+
return c.json({
|
|
234
|
+
coin_type: coinType,
|
|
235
|
+
account_index: latest.accountIndex,
|
|
236
|
+
path: `m/44'/${coinType}'/${latest.accountIndex}'`,
|
|
237
|
+
status: "allocated",
|
|
238
|
+
allocated_to: chainIds,
|
|
239
|
+
note: "xpub already registered — reuse for new chains with same key type",
|
|
240
|
+
next_available: {
|
|
241
|
+
account_index: latest.accountIndex + 1,
|
|
242
|
+
path: `m/44'/${coinType}'/${latest.accountIndex + 1}'`,
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
/** POST /admin/chains — register a new chain with its xpub */
|
|
248
|
+
app.post("/admin/chains", async (c) => {
|
|
249
|
+
const body = await c.req.json<{
|
|
250
|
+
id: string;
|
|
251
|
+
coin_type: number;
|
|
252
|
+
account_index: number;
|
|
253
|
+
network: string;
|
|
254
|
+
type: string;
|
|
255
|
+
token: string;
|
|
256
|
+
chain: string;
|
|
257
|
+
contract?: string;
|
|
258
|
+
decimals: number;
|
|
259
|
+
xpub: string;
|
|
260
|
+
rpc_url: string;
|
|
261
|
+
confirmations?: number;
|
|
262
|
+
display_name?: string;
|
|
263
|
+
oracle_address?: string;
|
|
264
|
+
}>();
|
|
265
|
+
|
|
266
|
+
if (!body.id || !body.xpub || !body.token) {
|
|
267
|
+
return c.json({ error: "id, xpub, and token are required" }, 400);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Record the path allocation (idempotent — ignore if already exists)
|
|
271
|
+
const inserted = (await deps.db
|
|
272
|
+
.insert(pathAllocations)
|
|
273
|
+
.values({
|
|
274
|
+
coinType: body.coin_type,
|
|
275
|
+
accountIndex: body.account_index,
|
|
276
|
+
chainId: body.id,
|
|
277
|
+
xpub: body.xpub,
|
|
278
|
+
})
|
|
279
|
+
.onConflictDoNothing()) as { rowCount: number };
|
|
280
|
+
|
|
281
|
+
if (inserted.rowCount === 0) {
|
|
282
|
+
return c.json(
|
|
283
|
+
{ error: "Path allocation already exists", path: `m/44'/${body.coin_type}'/${body.account_index}'` },
|
|
284
|
+
409,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Upsert the payment method
|
|
289
|
+
await deps.methodStore.upsert({
|
|
290
|
+
id: body.id,
|
|
291
|
+
type: body.type ?? "native",
|
|
292
|
+
token: body.token,
|
|
293
|
+
chain: body.chain ?? body.network,
|
|
294
|
+
contractAddress: body.contract ?? null,
|
|
295
|
+
decimals: body.decimals,
|
|
296
|
+
displayName: body.display_name ?? `${body.token} on ${body.network}`,
|
|
297
|
+
enabled: true,
|
|
298
|
+
displayOrder: 0,
|
|
299
|
+
rpcUrl: body.rpc_url,
|
|
300
|
+
oracleAddress: body.oracle_address ?? null,
|
|
301
|
+
xpub: body.xpub,
|
|
302
|
+
confirmations: body.confirmations ?? 6,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
return c.json({ id: body.id, path: `m/44'/${body.coin_type}'/${body.account_index}'` }, 201);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
/** DELETE /admin/chains/:id — soft disable */
|
|
309
|
+
app.delete("/admin/chains/:id", async (c) => {
|
|
310
|
+
await deps.methodStore.setEnabled(c.req.param("id"), false);
|
|
311
|
+
return c.body(null, 204);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
return app;
|
|
315
|
+
}
|
package/src/db/schema/crypto.ts
CHANGED
|
@@ -50,12 +50,16 @@ export const watcherCursors = pgTable("watcher_cursors", {
|
|
|
50
50
|
* Payment method registry — runtime-configurable tokens/chains.
|
|
51
51
|
* Admin inserts a row to enable a new payment method. No deploy needed.
|
|
52
52
|
* Contract addresses are immutable on-chain but configurable here.
|
|
53
|
+
*
|
|
54
|
+
* nextIndex is an atomic counter for HD derivation — never reuses an index.
|
|
55
|
+
* Increment via UPDATE ... SET next_index = next_index + 1 RETURNING next_index.
|
|
53
56
|
*/
|
|
54
57
|
export const paymentMethods = pgTable("payment_methods", {
|
|
55
|
-
id: text("id").primaryKey(), // "
|
|
56
|
-
type: text("type").notNull(), // "
|
|
57
|
-
token: text("token").notNull(), // "USDC", "ETH", "BTC"
|
|
58
|
-
chain: text("chain").notNull(), // "base", "ethereum", "bitcoin"
|
|
58
|
+
id: text("id").primaryKey(), // "btc", "base-usdc", "arb-usdc", "doge"
|
|
59
|
+
type: text("type").notNull(), // "erc20", "native", "btc"
|
|
60
|
+
token: text("token").notNull(), // "USDC", "ETH", "BTC", "DOGE"
|
|
61
|
+
chain: text("chain").notNull(), // "base", "ethereum", "bitcoin", "arbitrum"
|
|
62
|
+
network: text("network").notNull().default("mainnet"), // "mainnet", "base", "arbitrum"
|
|
59
63
|
contractAddress: text("contract_address"), // null for native (ETH, BTC)
|
|
60
64
|
decimals: integer("decimals").notNull(),
|
|
61
65
|
displayName: text("display_name").notNull(),
|
|
@@ -65,9 +69,46 @@ export const paymentMethods = pgTable("payment_methods", {
|
|
|
65
69
|
oracleAddress: text("oracle_address"), // Chainlink feed address for price (null = 1:1 stablecoin)
|
|
66
70
|
xpub: text("xpub"), // HD wallet extended public key for deposit address derivation
|
|
67
71
|
confirmations: integer("confirmations").notNull().default(1),
|
|
72
|
+
nextIndex: integer("next_index").notNull().default(0), // atomic derivation counter, never reuses
|
|
68
73
|
createdAt: text("created_at").notNull().default(sql`(now())`),
|
|
69
74
|
});
|
|
70
75
|
|
|
76
|
+
/**
|
|
77
|
+
* BIP-44 path allocation registry — tracks which derivation paths are in use.
|
|
78
|
+
* The server knows which paths are allocated so you never collide.
|
|
79
|
+
* The seed phrase never touches the server — only xpubs.
|
|
80
|
+
*/
|
|
81
|
+
export const pathAllocations = pgTable(
|
|
82
|
+
"path_allocations",
|
|
83
|
+
{
|
|
84
|
+
coinType: integer("coin_type").notNull(), // BIP44 coin type (0=BTC, 60=ETH, 3=DOGE, 501=SOL)
|
|
85
|
+
accountIndex: integer("account_index").notNull(), // m/44'/{coin_type}'/{index}'
|
|
86
|
+
chainId: text("chain_id").references(() => paymentMethods.id),
|
|
87
|
+
xpub: text("xpub").notNull(),
|
|
88
|
+
allocatedAt: text("allocated_at").notNull().default(sql`(now())`),
|
|
89
|
+
},
|
|
90
|
+
(table) => [primaryKey({ columns: [table.coinType, table.accountIndex] })],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Every address ever derived — immutable append-only log.
|
|
95
|
+
* Used for auditing and ensuring no address is ever reused.
|
|
96
|
+
*/
|
|
97
|
+
export const derivedAddresses = pgTable(
|
|
98
|
+
"derived_addresses",
|
|
99
|
+
{
|
|
100
|
+
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
|
101
|
+
chainId: text("chain_id")
|
|
102
|
+
.notNull()
|
|
103
|
+
.references(() => paymentMethods.id),
|
|
104
|
+
derivationIndex: integer("derivation_index").notNull(),
|
|
105
|
+
address: text("address").notNull().unique(),
|
|
106
|
+
tenantId: text("tenant_id"),
|
|
107
|
+
createdAt: text("created_at").notNull().default(sql`(now())`),
|
|
108
|
+
},
|
|
109
|
+
(table) => [index("idx_derived_addresses_chain").on(table.chainId)],
|
|
110
|
+
);
|
|
111
|
+
|
|
71
112
|
/** Processed transaction IDs for watchers without block cursors (e.g. BTC). */
|
|
72
113
|
export const watcherProcessed = pgTable(
|
|
73
114
|
"watcher_processed",
|