@wopr-network/platform-core 1.42.3 → 1.44.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.js +240 -0
- package/dist/billing/crypto/btc/watcher.d.ts +2 -0
- package/dist/billing/crypto/btc/watcher.js +1 -1
- package/dist/billing/crypto/charge-store.d.ts +7 -1
- package/dist/billing/crypto/charge-store.js +7 -1
- package/dist/billing/crypto/client.d.ts +68 -30
- package/dist/billing/crypto/client.js +63 -46
- package/dist/billing/crypto/client.test.js +66 -83
- package/dist/billing/crypto/index.d.ts +8 -8
- package/dist/billing/crypto/index.js +4 -5
- package/dist/billing/crypto/key-server-entry.js +84 -0
- package/dist/billing/crypto/key-server-webhook.d.ts +33 -0
- package/dist/billing/crypto/key-server-webhook.js +73 -0
- package/dist/billing/crypto/key-server.d.ts +20 -0
- package/dist/billing/crypto/key-server.js +263 -0
- package/dist/billing/crypto/watcher-service.d.ts +33 -0
- package/dist/billing/crypto/watcher-service.js +295 -0
- package/dist/billing/index.js +1 -1
- package/dist/db/schema/crypto.d.ts +464 -2
- package/dist/db/schema/crypto.js +60 -6
- package/dist/monetization/crypto/__tests__/webhook.test.js +57 -92
- package/dist/monetization/crypto/index.d.ts +4 -4
- package/dist/monetization/crypto/index.js +2 -2
- package/dist/monetization/crypto/webhook.d.ts +13 -14
- package/dist/monetization/crypto/webhook.js +12 -83
- package/dist/monetization/index.d.ts +2 -2
- package/dist/monetization/index.js +1 -1
- package/drizzle/migrations/0014_crypto_key_server.sql +60 -0
- package/drizzle/migrations/0015_callback_url.sql +32 -0
- package/drizzle/migrations/meta/_journal.json +28 -0
- package/package.json +2 -1
- package/src/billing/crypto/__tests__/key-server.test.ts +262 -0
- package/src/billing/crypto/btc/watcher.ts +3 -1
- package/src/billing/crypto/charge-store.ts +13 -1
- package/src/billing/crypto/client.test.ts +70 -98
- package/src/billing/crypto/client.ts +118 -59
- package/src/billing/crypto/index.ts +19 -14
- package/src/billing/crypto/key-server-entry.ts +96 -0
- package/src/billing/crypto/key-server-webhook.ts +119 -0
- package/src/billing/crypto/key-server.ts +343 -0
- package/src/billing/crypto/watcher-service.ts +381 -0
- package/src/billing/index.ts +1 -1
- package/src/db/schema/crypto.ts +75 -6
- package/src/monetization/crypto/__tests__/webhook.test.ts +61 -104
- package/src/monetization/crypto/index.ts +9 -11
- package/src/monetization/crypto/webhook.ts +25 -99
- package/src/monetization/index.ts +3 -7
- package/dist/billing/crypto/checkout.d.ts +0 -18
- package/dist/billing/crypto/checkout.js +0 -35
- package/dist/billing/crypto/checkout.test.js +0 -71
- package/dist/billing/crypto/webhook.d.ts +0 -34
- package/dist/billing/crypto/webhook.js +0 -107
- package/dist/billing/crypto/webhook.test.js +0 -266
- package/src/billing/crypto/checkout.test.ts +0 -93
- package/src/billing/crypto/checkout.ts +0 -48
- package/src/billing/crypto/webhook.test.ts +0 -340
- package/src/billing/crypto/webhook.ts +0 -136
- /package/dist/billing/crypto/{checkout.test.d.ts → __tests__/key-server.test.d.ts} +0 -0
- /package/dist/billing/crypto/{webhook.test.d.ts → key-server-entry.d.ts} +0 -0
|
@@ -1,86 +1,145 @@
|
|
|
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
|
+
*/
|
|
2
7
|
|
|
3
|
-
export
|
|
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
|
+
}
|
|
23
|
+
|
|
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
|
}
|
|
57
|
-
|
|
58
|
-
const data = (await res.json()) as { id: string; checkoutLink: string };
|
|
59
|
-
return { id: data.id, checkoutLink: data.checkoutLink };
|
|
79
|
+
return (await res.json()) as DeriveAddressResult;
|
|
60
80
|
}
|
|
61
81
|
|
|
62
|
-
/**
|
|
63
|
-
async
|
|
64
|
-
|
|
65
|
-
|
|
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;
|
|
99
|
+
}
|
|
66
100
|
|
|
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
|
+
});
|
|
67
106
|
if (!res.ok) {
|
|
68
107
|
const text = await res.text().catch(() => "");
|
|
69
|
-
throw new Error(`
|
|
108
|
+
throw new Error(`CryptoService getCharge failed (${res.status}): ${text}`);
|
|
70
109
|
}
|
|
110
|
+
return (await res.json()) as ChargeStatus;
|
|
111
|
+
}
|
|
71
112
|
|
|
72
|
-
|
|
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
|
+
});
|
|
118
|
+
if (!res.ok) {
|
|
119
|
+
const text = await res.text().catch(() => "");
|
|
120
|
+
throw new Error(`CryptoService listChains failed (${res.status}): ${text}`);
|
|
121
|
+
}
|
|
122
|
+
return (await res.json()) as ChainInfo[];
|
|
73
123
|
}
|
|
74
124
|
}
|
|
75
125
|
|
|
76
126
|
/**
|
|
77
|
-
* Load
|
|
78
|
-
* Returns null if
|
|
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.
|
|
79
131
|
*/
|
|
80
|
-
export function loadCryptoConfig():
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
86
142
|
}
|
|
143
|
+
|
|
144
|
+
// Legacy type alias for backwards compat
|
|
145
|
+
export type CryptoConfig = CryptoServiceConfig;
|
|
@@ -1,24 +1,29 @@
|
|
|
1
1
|
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
|
-
export {
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
export type {
|
|
5
|
+
ChainInfo,
|
|
6
|
+
ChargeStatus,
|
|
7
|
+
CreateChargeResult,
|
|
8
|
+
CryptoConfig,
|
|
9
|
+
CryptoServiceConfig,
|
|
10
|
+
DeriveAddressResult,
|
|
11
|
+
} from "./client.js";
|
|
12
|
+
export { CryptoServiceClient, loadCryptoConfig } from "./client.js";
|
|
7
13
|
export type { IWatcherCursorStore } from "./cursor-store.js";
|
|
8
14
|
export { DrizzleWatcherCursorStore } from "./cursor-store.js";
|
|
9
15
|
export * from "./evm/index.js";
|
|
16
|
+
export type { KeyServerDeps } from "./key-server.js";
|
|
17
|
+
export { createKeyServerApp } from "./key-server.js";
|
|
18
|
+
export type {
|
|
19
|
+
KeyServerWebhookDeps as CryptoWebhookDeps,
|
|
20
|
+
KeyServerWebhookPayload as CryptoWebhookPayload,
|
|
21
|
+
KeyServerWebhookResult as CryptoWebhookResult,
|
|
22
|
+
} from "./key-server-webhook.js";
|
|
23
|
+
export { handleKeyServerWebhook, handleKeyServerWebhook as handleCryptoWebhook } from "./key-server-webhook.js";
|
|
10
24
|
export * from "./oracle/index.js";
|
|
11
25
|
export type { IPaymentMethodStore, PaymentMethodRecord } from "./payment-method-store.js";
|
|
12
26
|
export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
|
|
13
|
-
export type {
|
|
14
|
-
CryptoBillingConfig,
|
|
15
|
-
CryptoCheckoutOpts,
|
|
16
|
-
CryptoPaymentState,
|
|
17
|
-
CryptoWebhookPayload,
|
|
18
|
-
CryptoWebhookResult,
|
|
19
|
-
} from "./types.js";
|
|
20
|
-
export { mapBtcPayEventToStatus } from "./types.js";
|
|
27
|
+
export type { CryptoPaymentState } from "./types.js";
|
|
21
28
|
export type { UnifiedCheckoutDeps, UnifiedCheckoutResult } from "./unified-checkout.js";
|
|
22
|
-
export { createUnifiedCheckout, MIN_CHECKOUT_USD } from "./unified-checkout.js";
|
|
23
|
-
export type { CryptoWebhookDeps } from "./webhook.js";
|
|
24
|
-
export { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
|
|
29
|
+
export { createUnifiedCheckout, MIN_CHECKOUT_USD as MIN_PAYMENT_USD, MIN_CHECKOUT_USD } from "./unified-checkout.js";
|
|
@@ -0,0 +1,96 @@
|
|
|
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 { DrizzleWatcherCursorStore } from "./cursor-store.js";
|
|
17
|
+
import { createRpcCaller } from "./evm/watcher.js";
|
|
18
|
+
import { createKeyServerApp } from "./key-server.js";
|
|
19
|
+
import { ChainlinkOracle } from "./oracle/chainlink.js";
|
|
20
|
+
import { FixedPriceOracle } from "./oracle/fixed.js";
|
|
21
|
+
import { DrizzlePaymentMethodStore } from "./payment-method-store.js";
|
|
22
|
+
import { startWatchers } from "./watcher-service.js";
|
|
23
|
+
|
|
24
|
+
const PORT = Number(process.env.PORT ?? "3100");
|
|
25
|
+
const DATABASE_URL = process.env.DATABASE_URL;
|
|
26
|
+
const SERVICE_KEY = process.env.SERVICE_KEY;
|
|
27
|
+
const ADMIN_TOKEN = process.env.ADMIN_TOKEN;
|
|
28
|
+
const BITCOIND_USER = process.env.BITCOIND_USER ?? "btcpay";
|
|
29
|
+
const BITCOIND_PASSWORD = process.env.BITCOIND_PASSWORD ?? "";
|
|
30
|
+
const BASE_RPC_URL = process.env.BASE_RPC_URL ?? "https://mainnet.base.org";
|
|
31
|
+
|
|
32
|
+
if (!DATABASE_URL) {
|
|
33
|
+
console.error("DATABASE_URL is required");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function main(): Promise<void> {
|
|
38
|
+
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
|
39
|
+
|
|
40
|
+
// Run migrations FIRST, before creating schema-typed db
|
|
41
|
+
console.log("[crypto-key-server] Running migrations...");
|
|
42
|
+
await migrate(drizzle(pool), { migrationsFolder: "./drizzle/migrations" });
|
|
43
|
+
|
|
44
|
+
// Now create the schema-typed db (columns guaranteed to exist)
|
|
45
|
+
console.log("[crypto-key-server] Connecting...");
|
|
46
|
+
const db = drizzle(pool, { schema }) as unknown as import("../../db/index.js").DrizzleDb;
|
|
47
|
+
|
|
48
|
+
const chargeStore = new DrizzleCryptoChargeRepository(db);
|
|
49
|
+
const methodStore = new DrizzlePaymentMethodStore(db);
|
|
50
|
+
|
|
51
|
+
// Chainlink on-chain oracle for volatile assets (BTC, ETH).
|
|
52
|
+
const oracle = BASE_RPC_URL
|
|
53
|
+
? new ChainlinkOracle({ rpcCall: createRpcCaller(BASE_RPC_URL) })
|
|
54
|
+
: new FixedPriceOracle();
|
|
55
|
+
|
|
56
|
+
const app = createKeyServerApp({
|
|
57
|
+
db,
|
|
58
|
+
chargeStore,
|
|
59
|
+
methodStore,
|
|
60
|
+
oracle,
|
|
61
|
+
serviceKey: SERVICE_KEY,
|
|
62
|
+
adminToken: ADMIN_TOKEN,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Boot watchers (BTC + EVM) — polls for payments, sends webhooks
|
|
66
|
+
const cursorStore = new DrizzleWatcherCursorStore(db);
|
|
67
|
+
const stopWatchers = await startWatchers({
|
|
68
|
+
db,
|
|
69
|
+
chargeStore,
|
|
70
|
+
methodStore,
|
|
71
|
+
cursorStore,
|
|
72
|
+
oracle,
|
|
73
|
+
bitcoindUser: BITCOIND_USER,
|
|
74
|
+
bitcoindPassword: BITCOIND_PASSWORD,
|
|
75
|
+
log: (msg, meta) => console.log(`[watcher] ${msg}`, meta ?? ""),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const server = serve({ fetch: app.fetch, port: PORT });
|
|
79
|
+
console.log(`[crypto-key-server] Listening on :${PORT}`);
|
|
80
|
+
|
|
81
|
+
// Graceful shutdown — stop accepting requests, drain watchers, close pool
|
|
82
|
+
const shutdown = async () => {
|
|
83
|
+
console.log("[crypto-key-server] Shutting down...");
|
|
84
|
+
stopWatchers();
|
|
85
|
+
server.close();
|
|
86
|
+
await pool.end();
|
|
87
|
+
process.exit(0);
|
|
88
|
+
};
|
|
89
|
+
process.on("SIGTERM", shutdown);
|
|
90
|
+
process.on("SIGINT", shutdown);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
main().catch((err) => {
|
|
94
|
+
console.error("[crypto-key-server] Fatal:", err);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key Server webhook handler — processes payment confirmations from the
|
|
3
|
+
* centralized crypto key server.
|
|
4
|
+
*
|
|
5
|
+
* Payload shape (from watcher-service.ts):
|
|
6
|
+
* {
|
|
7
|
+
* chargeId: "btc:bc1q...",
|
|
8
|
+
* chain: "bitcoin",
|
|
9
|
+
* address: "bc1q...",
|
|
10
|
+
* amountUsdCents: 5000,
|
|
11
|
+
* status: "confirmed",
|
|
12
|
+
* txHash: "abc123...",
|
|
13
|
+
* amountReceived: "50000 sats",
|
|
14
|
+
* confirmations: 6
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* Replaces handleCryptoWebhook() for products using the key server.
|
|
18
|
+
*/
|
|
19
|
+
import { Credit } from "../../credits/credit.js";
|
|
20
|
+
import type { ILedger } from "../../credits/ledger.js";
|
|
21
|
+
import type { IWebhookSeenRepository } from "../webhook-seen-repository.js";
|
|
22
|
+
import type { ICryptoChargeRepository } from "./charge-store.js";
|
|
23
|
+
|
|
24
|
+
export interface KeyServerWebhookPayload {
|
|
25
|
+
chargeId: string;
|
|
26
|
+
chain: string;
|
|
27
|
+
address: string;
|
|
28
|
+
amountUsdCents: number;
|
|
29
|
+
status: string;
|
|
30
|
+
txHash?: string;
|
|
31
|
+
amountReceived?: string;
|
|
32
|
+
confirmations?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface KeyServerWebhookDeps {
|
|
36
|
+
chargeStore: ICryptoChargeRepository;
|
|
37
|
+
creditLedger: ILedger;
|
|
38
|
+
replayGuard: IWebhookSeenRepository;
|
|
39
|
+
onCreditsPurchased?: (tenantId: string, ledger: ILedger) => Promise<string[]>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface KeyServerWebhookResult {
|
|
43
|
+
handled: boolean;
|
|
44
|
+
duplicate?: boolean;
|
|
45
|
+
tenant?: string;
|
|
46
|
+
creditedCents?: number;
|
|
47
|
+
reactivatedBots?: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Process a payment confirmation from the crypto key server.
|
|
52
|
+
*
|
|
53
|
+
* Credits the ledger when status is "confirmed".
|
|
54
|
+
* Idempotency: ledger referenceId + replay guard (same pattern as Stripe handler).
|
|
55
|
+
*/
|
|
56
|
+
export async function handleKeyServerWebhook(
|
|
57
|
+
deps: KeyServerWebhookDeps,
|
|
58
|
+
payload: KeyServerWebhookPayload,
|
|
59
|
+
): Promise<KeyServerWebhookResult> {
|
|
60
|
+
const { chargeStore, creditLedger } = deps;
|
|
61
|
+
|
|
62
|
+
// Replay guard: deduplicate by chargeId
|
|
63
|
+
const dedupeKey = `ks:${payload.chargeId}`;
|
|
64
|
+
if (await deps.replayGuard.isDuplicate(dedupeKey, "crypto")) {
|
|
65
|
+
return { handled: true, duplicate: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Look up the charge to find the tenant + amount
|
|
69
|
+
const charge = await chargeStore.getByReferenceId(payload.chargeId);
|
|
70
|
+
if (!charge) {
|
|
71
|
+
return { handled: false };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (payload.status === "confirmed") {
|
|
75
|
+
// Only settle when payment is confirmed
|
|
76
|
+
await chargeStore.updateStatus(payload.chargeId, "Settled", charge.token ?? undefined, payload.amountReceived);
|
|
77
|
+
|
|
78
|
+
// Idempotency: check ledger referenceId (atomic, same as BTCPay handler)
|
|
79
|
+
const creditRef = `crypto:${payload.chargeId}`;
|
|
80
|
+
if (await creditLedger.hasReferenceId(creditRef)) {
|
|
81
|
+
await deps.replayGuard.markSeen(dedupeKey, "crypto");
|
|
82
|
+
return { handled: true, duplicate: true, tenant: charge.tenantId };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Credit the original USD amount requested.
|
|
86
|
+
// charge.amountUsdCents is integer cents. Credit.fromCents() → nanodollars.
|
|
87
|
+
await creditLedger.credit(charge.tenantId, Credit.fromCents(charge.amountUsdCents), "purchase", {
|
|
88
|
+
description: `Crypto payment confirmed (${payload.chain}, tx: ${payload.txHash ?? "unknown"})`,
|
|
89
|
+
referenceId: creditRef,
|
|
90
|
+
fundingSource: "crypto",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await chargeStore.markCredited(payload.chargeId);
|
|
94
|
+
|
|
95
|
+
let reactivatedBots: string[] | undefined;
|
|
96
|
+
if (deps.onCreditsPurchased) {
|
|
97
|
+
reactivatedBots = await deps.onCreditsPurchased(charge.tenantId, creditLedger);
|
|
98
|
+
if (reactivatedBots.length === 0) reactivatedBots = undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await deps.replayGuard.markSeen(dedupeKey, "crypto");
|
|
102
|
+
return {
|
|
103
|
+
handled: true,
|
|
104
|
+
tenant: charge.tenantId,
|
|
105
|
+
creditedCents: charge.amountUsdCents,
|
|
106
|
+
reactivatedBots,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Non-confirmed status — update status but don't settle or credit
|
|
111
|
+
await chargeStore.updateStatus(
|
|
112
|
+
payload.chargeId,
|
|
113
|
+
payload.status as "Processing",
|
|
114
|
+
charge.token ?? undefined,
|
|
115
|
+
payload.amountReceived,
|
|
116
|
+
);
|
|
117
|
+
await deps.replayGuard.markSeen(dedupeKey, "crypto");
|
|
118
|
+
return { handled: true, tenant: charge.tenantId };
|
|
119
|
+
}
|