@wopr-network/platform-core 1.43.0 → 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/dist/billing/crypto/__tests__/key-server.test.js +16 -1
- 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 +0 -26
- package/dist/billing/crypto/client.js +0 -13
- package/dist/billing/crypto/client.test.js +1 -11
- package/dist/billing/crypto/index.d.ts +5 -7
- package/dist/billing/crypto/index.js +3 -5
- package/dist/billing/crypto/key-server-entry.js +43 -2
- 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 +2 -0
- package/dist/billing/crypto/key-server.js +25 -1
- 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 +217 -2
- package/dist/db/schema/crypto.js +25 -2
- 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/0015_callback_url.sql +32 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/billing/crypto/__tests__/key-server.test.ts +16 -1
- 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 +1 -13
- package/src/billing/crypto/client.ts +0 -21
- package/src/billing/crypto/index.ts +9 -13
- package/src/billing/crypto/key-server-entry.ts +46 -2
- package/src/billing/crypto/key-server-webhook.ts +119 -0
- package/src/billing/crypto/key-server.ts +29 -1
- package/src/billing/crypto/watcher-service.ts +381 -0
- package/src/billing/index.ts +1 -1
- package/src/db/schema/crypto.ts +30 -2
- 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.d.ts +0 -1
- 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.d.ts +0 -1
- 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
|
@@ -78,7 +78,21 @@ function mockDeps() {
|
|
|
78
78
|
},
|
|
79
79
|
]),
|
|
80
80
|
listAll: vi.fn(),
|
|
81
|
-
getById: vi.fn()
|
|
81
|
+
getById: vi.fn().mockResolvedValue({
|
|
82
|
+
id: "btc",
|
|
83
|
+
type: "native",
|
|
84
|
+
token: "BTC",
|
|
85
|
+
chain: "bitcoin",
|
|
86
|
+
decimals: 8,
|
|
87
|
+
displayName: "Bitcoin",
|
|
88
|
+
contractAddress: null,
|
|
89
|
+
confirmations: 6,
|
|
90
|
+
oracleAddress: "0x64c911996D3c6aC71f9b455B1E8E7266BcbD848F",
|
|
91
|
+
xpub: null,
|
|
92
|
+
displayOrder: 0,
|
|
93
|
+
enabled: true,
|
|
94
|
+
rpcUrl: null,
|
|
95
|
+
}),
|
|
82
96
|
listByType: vi.fn(),
|
|
83
97
|
upsert: vi.fn().mockResolvedValue(undefined),
|
|
84
98
|
setEnabled: vi.fn().mockResolvedValue(undefined),
|
|
@@ -87,6 +101,7 @@ function mockDeps() {
|
|
|
87
101
|
db: createMockDb(),
|
|
88
102
|
chargeStore: chargeStore,
|
|
89
103
|
methodStore: methodStore,
|
|
104
|
+
oracle: { getPrice: vi.fn().mockResolvedValue({ priceCents: 6_500_000, updatedAt: new Date() }) },
|
|
90
105
|
};
|
|
91
106
|
}
|
|
92
107
|
describe("key-server routes", () => {
|
|
@@ -12,6 +12,8 @@ export interface BtcWatcherOpts {
|
|
|
12
12
|
oracle: IPriceOracle;
|
|
13
13
|
/** Required — BTC has no block cursor, so txid dedup must be persisted. */
|
|
14
14
|
cursorStore: IWatcherCursorStore;
|
|
15
|
+
/** Override chain identity for cursor namespace (default: config.network). Prevents txid collisions across BTC/LTC/DOGE. */
|
|
16
|
+
chainId?: string;
|
|
15
17
|
}
|
|
16
18
|
export declare class BtcWatcher {
|
|
17
19
|
private readonly rpc;
|
|
@@ -13,7 +13,7 @@ export class BtcWatcher {
|
|
|
13
13
|
this.minConfirmations = opts.config.confirmations;
|
|
14
14
|
this.oracle = opts.oracle;
|
|
15
15
|
this.cursorStore = opts.cursorStore;
|
|
16
|
-
this.watcherId = `btc:${opts.config.network}`;
|
|
16
|
+
this.watcherId = `btc:${opts.chainId ?? opts.config.network}`;
|
|
17
17
|
}
|
|
18
18
|
/** Update the set of watched addresses. */
|
|
19
19
|
setWatchedAddresses(addresses) {
|
|
@@ -14,6 +14,9 @@ export interface CryptoChargeRecord {
|
|
|
14
14
|
token: string | null;
|
|
15
15
|
depositAddress: string | null;
|
|
16
16
|
derivationIndex: number | null;
|
|
17
|
+
callbackUrl: string | null;
|
|
18
|
+
expectedAmount: string | null;
|
|
19
|
+
receivedAmount: string | null;
|
|
17
20
|
}
|
|
18
21
|
export interface CryptoDepositChargeInput {
|
|
19
22
|
referenceId: string;
|
|
@@ -23,6 +26,9 @@ export interface CryptoDepositChargeInput {
|
|
|
23
26
|
token: string;
|
|
24
27
|
depositAddress: string;
|
|
25
28
|
derivationIndex: number;
|
|
29
|
+
callbackUrl?: string;
|
|
30
|
+
/** Expected crypto amount in native base units (sats for BTC, base units for ERC20). */
|
|
31
|
+
expectedAmount?: string;
|
|
26
32
|
}
|
|
27
33
|
export interface ICryptoChargeRepository {
|
|
28
34
|
create(referenceId: string, tenantId: string, amountUsdCents: number): Promise<void>;
|
|
@@ -42,7 +48,7 @@ export interface ICryptoChargeRepository {
|
|
|
42
48
|
/**
|
|
43
49
|
* Manages crypto charge records in PostgreSQL.
|
|
44
50
|
*
|
|
45
|
-
* Each charge maps a
|
|
51
|
+
* Each charge maps a deposit address to a tenant and tracks
|
|
46
52
|
* the payment lifecycle (New → Processing → Settled/Expired/Invalid).
|
|
47
53
|
*
|
|
48
54
|
* amountUsdCents stores the requested amount in USD cents (integer).
|
|
@@ -3,7 +3,7 @@ import { cryptoCharges } from "../../db/schema/crypto.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* Manages crypto charge records in PostgreSQL.
|
|
5
5
|
*
|
|
6
|
-
* Each charge maps a
|
|
6
|
+
* Each charge maps a deposit address to a tenant and tracks
|
|
7
7
|
* the payment lifecycle (New → Processing → Settled/Expired/Invalid).
|
|
8
8
|
*
|
|
9
9
|
* amountUsdCents stores the requested amount in USD cents (integer).
|
|
@@ -46,6 +46,9 @@ export class DrizzleCryptoChargeRepository {
|
|
|
46
46
|
token: row.token ?? null,
|
|
47
47
|
depositAddress: row.depositAddress ?? null,
|
|
48
48
|
derivationIndex: row.derivationIndex ?? null,
|
|
49
|
+
callbackUrl: row.callbackUrl ?? null,
|
|
50
|
+
expectedAmount: row.expectedAmount ?? null,
|
|
51
|
+
receivedAmount: row.receivedAmount ?? null,
|
|
49
52
|
};
|
|
50
53
|
}
|
|
51
54
|
/** Update charge status and payment details from webhook. */
|
|
@@ -89,6 +92,9 @@ export class DrizzleCryptoChargeRepository {
|
|
|
89
92
|
token: input.token,
|
|
90
93
|
depositAddress: input.depositAddress.toLowerCase(),
|
|
91
94
|
derivationIndex: input.derivationIndex,
|
|
95
|
+
callbackUrl: input.callbackUrl,
|
|
96
|
+
expectedAmount: input.expectedAmount,
|
|
97
|
+
receivedAmount: "0",
|
|
92
98
|
});
|
|
93
99
|
}
|
|
94
100
|
/** Look up a charge by its deposit address. */
|
|
@@ -75,29 +75,3 @@ export declare class CryptoServiceClient {
|
|
|
75
75
|
*/
|
|
76
76
|
export declare function loadCryptoConfig(): CryptoServiceConfig | null;
|
|
77
77
|
export type CryptoConfig = CryptoServiceConfig;
|
|
78
|
-
/**
|
|
79
|
-
* @deprecated Use CryptoServiceClient instead. BTCPay is replaced by the crypto key server.
|
|
80
|
-
* Kept for backwards compat — products still import BTCPayClient during migration.
|
|
81
|
-
*/
|
|
82
|
-
export declare class BTCPayClient {
|
|
83
|
-
constructor(_config: {
|
|
84
|
-
apiKey: string;
|
|
85
|
-
baseUrl: string;
|
|
86
|
-
storeId: string;
|
|
87
|
-
});
|
|
88
|
-
createInvoice(_opts: {
|
|
89
|
-
amountUsd: number;
|
|
90
|
-
orderId: string;
|
|
91
|
-
buyerEmail?: string;
|
|
92
|
-
redirectURL?: string;
|
|
93
|
-
}): Promise<{
|
|
94
|
-
id: string;
|
|
95
|
-
checkoutLink: string;
|
|
96
|
-
}>;
|
|
97
|
-
getInvoice(_invoiceId: string): Promise<{
|
|
98
|
-
id: string;
|
|
99
|
-
status: string;
|
|
100
|
-
amount: string;
|
|
101
|
-
currency: string;
|
|
102
|
-
}>;
|
|
103
|
-
}
|
|
@@ -87,16 +87,3 @@ export function loadCryptoConfig() {
|
|
|
87
87
|
}
|
|
88
88
|
return null;
|
|
89
89
|
}
|
|
90
|
-
/**
|
|
91
|
-
* @deprecated Use CryptoServiceClient instead. BTCPay is replaced by the crypto key server.
|
|
92
|
-
* Kept for backwards compat — products still import BTCPayClient during migration.
|
|
93
|
-
*/
|
|
94
|
-
export class BTCPayClient {
|
|
95
|
-
constructor(_config) { }
|
|
96
|
-
async createInvoice(_opts) {
|
|
97
|
-
throw new Error("BTCPayClient is deprecated — migrate to CryptoServiceClient");
|
|
98
|
-
}
|
|
99
|
-
async getInvoice(_invoiceId) {
|
|
100
|
-
throw new Error("BTCPayClient is deprecated — migrate to CryptoServiceClient");
|
|
101
|
-
}
|
|
102
|
-
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { CryptoServiceClient, loadCryptoConfig } from "./client.js";
|
|
3
3
|
describe("CryptoServiceClient", () => {
|
|
4
4
|
afterEach(() => vi.restoreAllMocks());
|
|
5
5
|
it("deriveAddress sends POST /address with chain", async () => {
|
|
@@ -60,16 +60,6 @@ describe("CryptoServiceClient", () => {
|
|
|
60
60
|
await expect(client.getCharge("missing")).rejects.toThrow("CryptoService getCharge failed (404)");
|
|
61
61
|
});
|
|
62
62
|
});
|
|
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");
|
|
67
|
-
});
|
|
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");
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
63
|
describe("loadCryptoConfig", () => {
|
|
74
64
|
beforeEach(() => {
|
|
75
65
|
delete process.env.CRYPTO_SERVICE_URL;
|
|
@@ -1,20 +1,18 @@
|
|
|
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 { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
|
|
5
4
|
export type { ChainInfo, ChargeStatus, CreateChargeResult, CryptoConfig, CryptoServiceConfig, DeriveAddressResult, } from "./client.js";
|
|
6
|
-
export {
|
|
5
|
+
export { CryptoServiceClient, loadCryptoConfig } from "./client.js";
|
|
7
6
|
export type { IWatcherCursorStore } from "./cursor-store.js";
|
|
8
7
|
export { DrizzleWatcherCursorStore } from "./cursor-store.js";
|
|
9
8
|
export * from "./evm/index.js";
|
|
10
9
|
export type { KeyServerDeps } from "./key-server.js";
|
|
11
10
|
export { createKeyServerApp } from "./key-server.js";
|
|
11
|
+
export type { KeyServerWebhookDeps as CryptoWebhookDeps, KeyServerWebhookPayload as CryptoWebhookPayload, KeyServerWebhookResult as CryptoWebhookResult, } from "./key-server-webhook.js";
|
|
12
|
+
export { handleKeyServerWebhook, handleKeyServerWebhook as handleCryptoWebhook } from "./key-server-webhook.js";
|
|
12
13
|
export * from "./oracle/index.js";
|
|
13
14
|
export type { IPaymentMethodStore, PaymentMethodRecord } from "./payment-method-store.js";
|
|
14
15
|
export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
|
|
15
|
-
export type {
|
|
16
|
-
export { mapBtcPayEventToStatus } from "./types.js";
|
|
16
|
+
export type { CryptoPaymentState } from "./types.js";
|
|
17
17
|
export type { UnifiedCheckoutDeps, UnifiedCheckoutResult } from "./unified-checkout.js";
|
|
18
|
-
export { createUnifiedCheckout, MIN_CHECKOUT_USD } from "./unified-checkout.js";
|
|
19
|
-
export type { CryptoWebhookDeps } from "./webhook.js";
|
|
20
|
-
export { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
|
|
18
|
+
export { createUnifiedCheckout, MIN_CHECKOUT_USD as MIN_PAYMENT_USD, MIN_CHECKOUT_USD } from "./unified-checkout.js";
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
export * from "./btc/index.js";
|
|
2
2
|
export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-store.js";
|
|
3
|
-
export {
|
|
4
|
-
export { BTCPayClient, CryptoServiceClient, loadCryptoConfig } from "./client.js";
|
|
3
|
+
export { CryptoServiceClient, loadCryptoConfig } from "./client.js";
|
|
5
4
|
export { DrizzleWatcherCursorStore } from "./cursor-store.js";
|
|
6
5
|
export * from "./evm/index.js";
|
|
7
6
|
export { createKeyServerApp } from "./key-server.js";
|
|
7
|
+
export { handleKeyServerWebhook, handleKeyServerWebhook as handleCryptoWebhook } from "./key-server-webhook.js";
|
|
8
8
|
export * from "./oracle/index.js";
|
|
9
9
|
export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
|
|
10
|
-
export {
|
|
11
|
-
export { createUnifiedCheckout, MIN_CHECKOUT_USD } from "./unified-checkout.js";
|
|
12
|
-
export { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
|
|
10
|
+
export { createUnifiedCheckout, MIN_CHECKOUT_USD as MIN_PAYMENT_USD, MIN_CHECKOUT_USD } from "./unified-checkout.js";
|
|
@@ -13,12 +13,20 @@ import { migrate } from "drizzle-orm/node-postgres/migrator";
|
|
|
13
13
|
import pg from "pg";
|
|
14
14
|
import * as schema from "../../db/schema/index.js";
|
|
15
15
|
import { DrizzleCryptoChargeRepository } from "./charge-store.js";
|
|
16
|
+
import { DrizzleWatcherCursorStore } from "./cursor-store.js";
|
|
17
|
+
import { createRpcCaller } from "./evm/watcher.js";
|
|
16
18
|
import { createKeyServerApp } from "./key-server.js";
|
|
19
|
+
import { ChainlinkOracle } from "./oracle/chainlink.js";
|
|
20
|
+
import { FixedPriceOracle } from "./oracle/fixed.js";
|
|
17
21
|
import { DrizzlePaymentMethodStore } from "./payment-method-store.js";
|
|
22
|
+
import { startWatchers } from "./watcher-service.js";
|
|
18
23
|
const PORT = Number(process.env.PORT ?? "3100");
|
|
19
24
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
20
25
|
const SERVICE_KEY = process.env.SERVICE_KEY;
|
|
21
26
|
const ADMIN_TOKEN = process.env.ADMIN_TOKEN;
|
|
27
|
+
const BITCOIND_USER = process.env.BITCOIND_USER ?? "btcpay";
|
|
28
|
+
const BITCOIND_PASSWORD = process.env.BITCOIND_PASSWORD ?? "";
|
|
29
|
+
const BASE_RPC_URL = process.env.BASE_RPC_URL ?? "https://mainnet.base.org";
|
|
22
30
|
if (!DATABASE_URL) {
|
|
23
31
|
console.error("DATABASE_URL is required");
|
|
24
32
|
process.exit(1);
|
|
@@ -33,9 +41,42 @@ async function main() {
|
|
|
33
41
|
const db = drizzle(pool, { schema });
|
|
34
42
|
const chargeStore = new DrizzleCryptoChargeRepository(db);
|
|
35
43
|
const methodStore = new DrizzlePaymentMethodStore(db);
|
|
36
|
-
|
|
44
|
+
// Chainlink on-chain oracle for volatile assets (BTC, ETH).
|
|
45
|
+
const oracle = BASE_RPC_URL
|
|
46
|
+
? new ChainlinkOracle({ rpcCall: createRpcCaller(BASE_RPC_URL) })
|
|
47
|
+
: new FixedPriceOracle();
|
|
48
|
+
const app = createKeyServerApp({
|
|
49
|
+
db,
|
|
50
|
+
chargeStore,
|
|
51
|
+
methodStore,
|
|
52
|
+
oracle,
|
|
53
|
+
serviceKey: SERVICE_KEY,
|
|
54
|
+
adminToken: ADMIN_TOKEN,
|
|
55
|
+
});
|
|
56
|
+
// Boot watchers (BTC + EVM) — polls for payments, sends webhooks
|
|
57
|
+
const cursorStore = new DrizzleWatcherCursorStore(db);
|
|
58
|
+
const stopWatchers = await startWatchers({
|
|
59
|
+
db,
|
|
60
|
+
chargeStore,
|
|
61
|
+
methodStore,
|
|
62
|
+
cursorStore,
|
|
63
|
+
oracle,
|
|
64
|
+
bitcoindUser: BITCOIND_USER,
|
|
65
|
+
bitcoindPassword: BITCOIND_PASSWORD,
|
|
66
|
+
log: (msg, meta) => console.log(`[watcher] ${msg}`, meta ?? ""),
|
|
67
|
+
});
|
|
68
|
+
const server = serve({ fetch: app.fetch, port: PORT });
|
|
37
69
|
console.log(`[crypto-key-server] Listening on :${PORT}`);
|
|
38
|
-
|
|
70
|
+
// Graceful shutdown — stop accepting requests, drain watchers, close pool
|
|
71
|
+
const shutdown = async () => {
|
|
72
|
+
console.log("[crypto-key-server] Shutting down...");
|
|
73
|
+
stopWatchers();
|
|
74
|
+
server.close();
|
|
75
|
+
await pool.end();
|
|
76
|
+
process.exit(0);
|
|
77
|
+
};
|
|
78
|
+
process.on("SIGTERM", shutdown);
|
|
79
|
+
process.on("SIGINT", shutdown);
|
|
39
80
|
}
|
|
40
81
|
main().catch((err) => {
|
|
41
82
|
console.error("[crypto-key-server] Fatal:", err);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ILedger } from "../../credits/ledger.js";
|
|
2
|
+
import type { IWebhookSeenRepository } from "../webhook-seen-repository.js";
|
|
3
|
+
import type { ICryptoChargeRepository } from "./charge-store.js";
|
|
4
|
+
export interface KeyServerWebhookPayload {
|
|
5
|
+
chargeId: string;
|
|
6
|
+
chain: string;
|
|
7
|
+
address: string;
|
|
8
|
+
amountUsdCents: number;
|
|
9
|
+
status: string;
|
|
10
|
+
txHash?: string;
|
|
11
|
+
amountReceived?: string;
|
|
12
|
+
confirmations?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface KeyServerWebhookDeps {
|
|
15
|
+
chargeStore: ICryptoChargeRepository;
|
|
16
|
+
creditLedger: ILedger;
|
|
17
|
+
replayGuard: IWebhookSeenRepository;
|
|
18
|
+
onCreditsPurchased?: (tenantId: string, ledger: ILedger) => Promise<string[]>;
|
|
19
|
+
}
|
|
20
|
+
export interface KeyServerWebhookResult {
|
|
21
|
+
handled: boolean;
|
|
22
|
+
duplicate?: boolean;
|
|
23
|
+
tenant?: string;
|
|
24
|
+
creditedCents?: number;
|
|
25
|
+
reactivatedBots?: string[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Process a payment confirmation from the crypto key server.
|
|
29
|
+
*
|
|
30
|
+
* Credits the ledger when status is "confirmed".
|
|
31
|
+
* Idempotency: ledger referenceId + replay guard (same pattern as Stripe handler).
|
|
32
|
+
*/
|
|
33
|
+
export declare function handleKeyServerWebhook(deps: KeyServerWebhookDeps, payload: KeyServerWebhookPayload): Promise<KeyServerWebhookResult>;
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
/**
|
|
21
|
+
* Process a payment confirmation from the crypto key server.
|
|
22
|
+
*
|
|
23
|
+
* Credits the ledger when status is "confirmed".
|
|
24
|
+
* Idempotency: ledger referenceId + replay guard (same pattern as Stripe handler).
|
|
25
|
+
*/
|
|
26
|
+
export async function handleKeyServerWebhook(deps, payload) {
|
|
27
|
+
const { chargeStore, creditLedger } = deps;
|
|
28
|
+
// Replay guard: deduplicate by chargeId
|
|
29
|
+
const dedupeKey = `ks:${payload.chargeId}`;
|
|
30
|
+
if (await deps.replayGuard.isDuplicate(dedupeKey, "crypto")) {
|
|
31
|
+
return { handled: true, duplicate: true };
|
|
32
|
+
}
|
|
33
|
+
// Look up the charge to find the tenant + amount
|
|
34
|
+
const charge = await chargeStore.getByReferenceId(payload.chargeId);
|
|
35
|
+
if (!charge) {
|
|
36
|
+
return { handled: false };
|
|
37
|
+
}
|
|
38
|
+
if (payload.status === "confirmed") {
|
|
39
|
+
// Only settle when payment is confirmed
|
|
40
|
+
await chargeStore.updateStatus(payload.chargeId, "Settled", charge.token ?? undefined, payload.amountReceived);
|
|
41
|
+
// Idempotency: check ledger referenceId (atomic, same as BTCPay handler)
|
|
42
|
+
const creditRef = `crypto:${payload.chargeId}`;
|
|
43
|
+
if (await creditLedger.hasReferenceId(creditRef)) {
|
|
44
|
+
await deps.replayGuard.markSeen(dedupeKey, "crypto");
|
|
45
|
+
return { handled: true, duplicate: true, tenant: charge.tenantId };
|
|
46
|
+
}
|
|
47
|
+
// Credit the original USD amount requested.
|
|
48
|
+
// charge.amountUsdCents is integer cents. Credit.fromCents() → nanodollars.
|
|
49
|
+
await creditLedger.credit(charge.tenantId, Credit.fromCents(charge.amountUsdCents), "purchase", {
|
|
50
|
+
description: `Crypto payment confirmed (${payload.chain}, tx: ${payload.txHash ?? "unknown"})`,
|
|
51
|
+
referenceId: creditRef,
|
|
52
|
+
fundingSource: "crypto",
|
|
53
|
+
});
|
|
54
|
+
await chargeStore.markCredited(payload.chargeId);
|
|
55
|
+
let reactivatedBots;
|
|
56
|
+
if (deps.onCreditsPurchased) {
|
|
57
|
+
reactivatedBots = await deps.onCreditsPurchased(charge.tenantId, creditLedger);
|
|
58
|
+
if (reactivatedBots.length === 0)
|
|
59
|
+
reactivatedBots = undefined;
|
|
60
|
+
}
|
|
61
|
+
await deps.replayGuard.markSeen(dedupeKey, "crypto");
|
|
62
|
+
return {
|
|
63
|
+
handled: true,
|
|
64
|
+
tenant: charge.tenantId,
|
|
65
|
+
creditedCents: charge.amountUsdCents,
|
|
66
|
+
reactivatedBots,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// Non-confirmed status — update status but don't settle or credit
|
|
70
|
+
await chargeStore.updateStatus(payload.chargeId, payload.status, charge.token ?? undefined, payload.amountReceived);
|
|
71
|
+
await deps.replayGuard.markSeen(dedupeKey, "crypto");
|
|
72
|
+
return { handled: true, tenant: charge.tenantId };
|
|
73
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import type { DrizzleDb } from "../../db/index.js";
|
|
3
3
|
import type { ICryptoChargeRepository } from "./charge-store.js";
|
|
4
|
+
import type { IPriceOracle } from "./oracle/types.js";
|
|
4
5
|
import type { IPaymentMethodStore } from "./payment-method-store.js";
|
|
5
6
|
export interface KeyServerDeps {
|
|
6
7
|
db: DrizzleDb;
|
|
7
8
|
chargeStore: ICryptoChargeRepository;
|
|
8
9
|
methodStore: IPaymentMethodStore;
|
|
10
|
+
oracle: IPriceOracle;
|
|
9
11
|
/** Bearer token for product API routes. If unset, auth is disabled. */
|
|
10
12
|
serviceKey?: string;
|
|
11
13
|
/** Bearer token for admin routes. If unset, admin routes are disabled. */
|
|
@@ -12,6 +12,7 @@ import { Hono } from "hono";
|
|
|
12
12
|
import { derivedAddresses, pathAllocations, paymentMethods } from "../../db/schema/crypto.js";
|
|
13
13
|
import { deriveAddress, deriveP2pkhAddress } from "./btc/address-gen.js";
|
|
14
14
|
import { deriveDepositAddress } from "./evm/address-gen.js";
|
|
15
|
+
import { centsToNative } from "./oracle/convert.js";
|
|
15
16
|
/**
|
|
16
17
|
* Derive the next unused address for a chain.
|
|
17
18
|
* Atomically increments next_index and records address in a single transaction.
|
|
@@ -111,7 +112,23 @@ export function createKeyServerApp(deps) {
|
|
|
111
112
|
}
|
|
112
113
|
const tenantId = c.req.header("X-Tenant-Id") ?? "unknown";
|
|
113
114
|
const { address, index, chain, token } = await deriveNextAddress(deps.db, body.chain, tenantId);
|
|
115
|
+
// Look up payment method for decimals + oracle config
|
|
116
|
+
const method = await deps.methodStore.getById(body.chain);
|
|
117
|
+
if (!method)
|
|
118
|
+
return c.json({ error: `Unknown chain: ${body.chain}` }, 400);
|
|
114
119
|
const amountUsdCents = Math.round(body.amountUsd * 100);
|
|
120
|
+
// Compute expected crypto amount in native base units.
|
|
121
|
+
// Price is locked NOW — this is what the user must send.
|
|
122
|
+
let expectedAmount;
|
|
123
|
+
if (method.oracleAddress) {
|
|
124
|
+
// Volatile asset (BTC, ETH, DOGE) — oracle-priced
|
|
125
|
+
const { priceCents } = await deps.oracle.getPrice(token);
|
|
126
|
+
expectedAmount = centsToNative(amountUsdCents, priceCents, method.decimals);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
// Stablecoin (1:1 USD) — e.g. $50 USDC = 50_000_000 base units (6 decimals)
|
|
130
|
+
expectedAmount = (BigInt(amountUsdCents) * 10n ** BigInt(method.decimals)) / 100n;
|
|
131
|
+
}
|
|
115
132
|
const referenceId = `${token.toLowerCase()}:${address.toLowerCase()}`;
|
|
116
133
|
await deps.chargeStore.createStablecoinCharge({
|
|
117
134
|
referenceId,
|
|
@@ -121,13 +138,20 @@ export function createKeyServerApp(deps) {
|
|
|
121
138
|
token,
|
|
122
139
|
depositAddress: address,
|
|
123
140
|
derivationIndex: index,
|
|
141
|
+
callbackUrl: body.callbackUrl,
|
|
142
|
+
expectedAmount: expectedAmount.toString(),
|
|
124
143
|
});
|
|
144
|
+
// Format display amount for the client
|
|
145
|
+
const divisor = 10 ** method.decimals;
|
|
146
|
+
const displayAmount = `${(Number(expectedAmount) / divisor).toFixed(Math.min(method.decimals, 8))} ${token}`;
|
|
125
147
|
return c.json({
|
|
126
148
|
chargeId: referenceId,
|
|
127
149
|
address,
|
|
128
|
-
chain
|
|
150
|
+
chain,
|
|
129
151
|
token,
|
|
130
152
|
amountUsd: body.amountUsd,
|
|
153
|
+
expectedAmount: expectedAmount.toString(),
|
|
154
|
+
displayAmount,
|
|
131
155
|
derivationIndex: index,
|
|
132
156
|
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), // 30 min
|
|
133
157
|
}, 201);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watcher Service — boots chain watchers and sends webhook callbacks.
|
|
3
|
+
*
|
|
4
|
+
* Payment flow:
|
|
5
|
+
* 1. Watcher detects payment → handlePayment()
|
|
6
|
+
* 2. Accumulate native amount (supports partial payments)
|
|
7
|
+
* 3. When totalReceived >= expectedAmount → settle + credit
|
|
8
|
+
* 4. Every payment (partial or full) enqueues a webhook delivery
|
|
9
|
+
* 5. Outbox processor retries failed deliveries with exponential backoff
|
|
10
|
+
*
|
|
11
|
+
* Amount comparison is ALWAYS in native crypto units (sats, wei, token base units).
|
|
12
|
+
* The exchange rate is locked at charge creation — no live price comparison.
|
|
13
|
+
*/
|
|
14
|
+
import type { DrizzleDb } from "../../db/index.js";
|
|
15
|
+
import type { ICryptoChargeRepository } from "./charge-store.js";
|
|
16
|
+
import type { IWatcherCursorStore } from "./cursor-store.js";
|
|
17
|
+
import type { IPriceOracle } from "./oracle/types.js";
|
|
18
|
+
import type { IPaymentMethodStore } from "./payment-method-store.js";
|
|
19
|
+
export interface WatcherServiceOpts {
|
|
20
|
+
db: DrizzleDb;
|
|
21
|
+
chargeStore: ICryptoChargeRepository;
|
|
22
|
+
methodStore: IPaymentMethodStore;
|
|
23
|
+
cursorStore: IWatcherCursorStore;
|
|
24
|
+
oracle: IPriceOracle;
|
|
25
|
+
bitcoindUser?: string;
|
|
26
|
+
bitcoindPassword?: string;
|
|
27
|
+
pollIntervalMs?: number;
|
|
28
|
+
deliveryIntervalMs?: number;
|
|
29
|
+
log?: (msg: string, meta?: Record<string, unknown>) => void;
|
|
30
|
+
/** Allowed callback URL prefixes. Default: ["https://"] — enforces HTTPS. */
|
|
31
|
+
allowedCallbackPrefixes?: string[];
|
|
32
|
+
}
|
|
33
|
+
export declare function startWatchers(opts: WatcherServiceOpts): Promise<() => void>;
|