@wopr-network/platform-core 1.25.0 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/billing/crypto/index.d.ts +4 -0
- package/dist/billing/crypto/index.js +2 -0
- package/dist/billing/crypto/payment-method-store.d.ts +38 -0
- package/dist/billing/crypto/payment-method-store.js +82 -0
- package/dist/billing/crypto/unified-checkout.d.ts +35 -0
- package/dist/billing/crypto/unified-checkout.js +128 -0
- package/dist/db/schema/crypto.d.ts +216 -0
- package/dist/db/schema/crypto.js +20 -1
- package/drizzle/migrations/0008_payment_methods.sql +22 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/billing/crypto/index.ts +4 -0
- package/src/billing/crypto/payment-method-store.ts +117 -0
- package/src/billing/crypto/unified-checkout.ts +190 -0
- package/src/db/schema/crypto.ts +21 -1
|
@@ -8,7 +8,11 @@ export type { IWatcherCursorStore } from "./cursor-store.js";
|
|
|
8
8
|
export { DrizzleWatcherCursorStore } from "./cursor-store.js";
|
|
9
9
|
export * from "./evm/index.js";
|
|
10
10
|
export * from "./oracle/index.js";
|
|
11
|
+
export type { IPaymentMethodStore, PaymentMethodRecord } from "./payment-method-store.js";
|
|
12
|
+
export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
|
|
11
13
|
export type { CryptoBillingConfig, CryptoCheckoutOpts, CryptoPaymentState, CryptoWebhookPayload, CryptoWebhookResult, } from "./types.js";
|
|
12
14
|
export { mapBtcPayEventToStatus } from "./types.js";
|
|
15
|
+
export type { UnifiedCheckoutDeps, UnifiedCheckoutResult } from "./unified-checkout.js";
|
|
16
|
+
export { createUnifiedCheckout, MIN_CHECKOUT_USD } from "./unified-checkout.js";
|
|
13
17
|
export type { CryptoWebhookDeps } from "./webhook.js";
|
|
14
18
|
export { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
|
|
@@ -5,5 +5,7 @@ export { BTCPayClient, loadCryptoConfig } from "./client.js";
|
|
|
5
5
|
export { DrizzleWatcherCursorStore } from "./cursor-store.js";
|
|
6
6
|
export * from "./evm/index.js";
|
|
7
7
|
export * from "./oracle/index.js";
|
|
8
|
+
export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
|
|
8
9
|
export { mapBtcPayEventToStatus } from "./types.js";
|
|
10
|
+
export { createUnifiedCheckout, MIN_CHECKOUT_USD } from "./unified-checkout.js";
|
|
9
11
|
export { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { PlatformDb } from "../../db/index.js";
|
|
2
|
+
export interface PaymentMethodRecord {
|
|
3
|
+
id: string;
|
|
4
|
+
type: string;
|
|
5
|
+
token: string;
|
|
6
|
+
chain: string;
|
|
7
|
+
contractAddress: string | null;
|
|
8
|
+
decimals: number;
|
|
9
|
+
displayName: string;
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
displayOrder: number;
|
|
12
|
+
rpcUrl: string | null;
|
|
13
|
+
confirmations: number;
|
|
14
|
+
}
|
|
15
|
+
export interface IPaymentMethodStore {
|
|
16
|
+
/** List all enabled payment methods, ordered by displayOrder. */
|
|
17
|
+
listEnabled(): Promise<PaymentMethodRecord[]>;
|
|
18
|
+
/** List all payment methods (including disabled). */
|
|
19
|
+
listAll(): Promise<PaymentMethodRecord[]>;
|
|
20
|
+
/** Get a specific payment method by id. */
|
|
21
|
+
getById(id: string): Promise<PaymentMethodRecord | null>;
|
|
22
|
+
/** Get enabled methods by type (stablecoin, eth, btc). */
|
|
23
|
+
listByType(type: string): Promise<PaymentMethodRecord[]>;
|
|
24
|
+
/** Upsert a payment method (admin). */
|
|
25
|
+
upsert(method: PaymentMethodRecord): Promise<void>;
|
|
26
|
+
/** Enable or disable a payment method (admin). */
|
|
27
|
+
setEnabled(id: string, enabled: boolean): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
export declare class DrizzlePaymentMethodStore implements IPaymentMethodStore {
|
|
30
|
+
private readonly db;
|
|
31
|
+
constructor(db: PlatformDb);
|
|
32
|
+
listEnabled(): Promise<PaymentMethodRecord[]>;
|
|
33
|
+
listAll(): Promise<PaymentMethodRecord[]>;
|
|
34
|
+
getById(id: string): Promise<PaymentMethodRecord | null>;
|
|
35
|
+
listByType(type: string): Promise<PaymentMethodRecord[]>;
|
|
36
|
+
upsert(method: PaymentMethodRecord): Promise<void>;
|
|
37
|
+
setEnabled(id: string, enabled: boolean): Promise<void>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { and, eq } from "drizzle-orm";
|
|
2
|
+
import { paymentMethods } from "../../db/schema/crypto.js";
|
|
3
|
+
export class DrizzlePaymentMethodStore {
|
|
4
|
+
db;
|
|
5
|
+
constructor(db) {
|
|
6
|
+
this.db = db;
|
|
7
|
+
}
|
|
8
|
+
async listEnabled() {
|
|
9
|
+
const rows = await this.db
|
|
10
|
+
.select()
|
|
11
|
+
.from(paymentMethods)
|
|
12
|
+
.where(eq(paymentMethods.enabled, true))
|
|
13
|
+
.orderBy(paymentMethods.displayOrder);
|
|
14
|
+
return rows.map(toRecord);
|
|
15
|
+
}
|
|
16
|
+
async listAll() {
|
|
17
|
+
const rows = await this.db.select().from(paymentMethods).orderBy(paymentMethods.displayOrder);
|
|
18
|
+
return rows.map(toRecord);
|
|
19
|
+
}
|
|
20
|
+
async getById(id) {
|
|
21
|
+
const row = (await this.db.select().from(paymentMethods).where(eq(paymentMethods.id, id)))[0];
|
|
22
|
+
return row ? toRecord(row) : null;
|
|
23
|
+
}
|
|
24
|
+
async listByType(type) {
|
|
25
|
+
const rows = await this.db
|
|
26
|
+
.select()
|
|
27
|
+
.from(paymentMethods)
|
|
28
|
+
.where(and(eq(paymentMethods.type, type), eq(paymentMethods.enabled, true)))
|
|
29
|
+
.orderBy(paymentMethods.displayOrder);
|
|
30
|
+
return rows.map(toRecord);
|
|
31
|
+
}
|
|
32
|
+
async upsert(method) {
|
|
33
|
+
await this.db
|
|
34
|
+
.insert(paymentMethods)
|
|
35
|
+
.values({
|
|
36
|
+
id: method.id,
|
|
37
|
+
type: method.type,
|
|
38
|
+
token: method.token,
|
|
39
|
+
chain: method.chain,
|
|
40
|
+
contractAddress: method.contractAddress,
|
|
41
|
+
decimals: method.decimals,
|
|
42
|
+
displayName: method.displayName,
|
|
43
|
+
enabled: method.enabled,
|
|
44
|
+
displayOrder: method.displayOrder,
|
|
45
|
+
rpcUrl: method.rpcUrl,
|
|
46
|
+
confirmations: method.confirmations,
|
|
47
|
+
})
|
|
48
|
+
.onConflictDoUpdate({
|
|
49
|
+
target: paymentMethods.id,
|
|
50
|
+
set: {
|
|
51
|
+
type: method.type,
|
|
52
|
+
token: method.token,
|
|
53
|
+
chain: method.chain,
|
|
54
|
+
contractAddress: method.contractAddress,
|
|
55
|
+
decimals: method.decimals,
|
|
56
|
+
displayName: method.displayName,
|
|
57
|
+
enabled: method.enabled,
|
|
58
|
+
displayOrder: method.displayOrder,
|
|
59
|
+
rpcUrl: method.rpcUrl,
|
|
60
|
+
confirmations: method.confirmations,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async setEnabled(id, enabled) {
|
|
65
|
+
await this.db.update(paymentMethods).set({ enabled }).where(eq(paymentMethods.id, id));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function toRecord(row) {
|
|
69
|
+
return {
|
|
70
|
+
id: row.id,
|
|
71
|
+
type: row.type,
|
|
72
|
+
token: row.token,
|
|
73
|
+
chain: row.chain,
|
|
74
|
+
contractAddress: row.contractAddress,
|
|
75
|
+
decimals: row.decimals,
|
|
76
|
+
displayName: row.displayName,
|
|
77
|
+
enabled: row.enabled,
|
|
78
|
+
displayOrder: row.displayOrder,
|
|
79
|
+
rpcUrl: row.rpcUrl,
|
|
80
|
+
confirmations: row.confirmations,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ICryptoChargeRepository } from "./charge-store.js";
|
|
2
|
+
import type { IPriceOracle } from "./oracle/types.js";
|
|
3
|
+
import type { PaymentMethodRecord } from "./payment-method-store.js";
|
|
4
|
+
export declare const MIN_CHECKOUT_USD = 10;
|
|
5
|
+
export interface UnifiedCheckoutDeps {
|
|
6
|
+
chargeStore: Pick<ICryptoChargeRepository, "getNextDerivationIndex" | "createStablecoinCharge">;
|
|
7
|
+
oracle: IPriceOracle;
|
|
8
|
+
evmXpub: string;
|
|
9
|
+
btcXpub?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface UnifiedCheckoutResult {
|
|
12
|
+
depositAddress: string;
|
|
13
|
+
/** Human-readable amount to send (e.g. "50 USDC", "0.014285 ETH"). */
|
|
14
|
+
displayAmount: string;
|
|
15
|
+
amountUsd: number;
|
|
16
|
+
token: string;
|
|
17
|
+
chain: string;
|
|
18
|
+
referenceId: string;
|
|
19
|
+
/** For volatile assets: price at checkout time (USD cents per unit). */
|
|
20
|
+
priceCents?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Unified checkout — one entry point for all payment methods.
|
|
24
|
+
*
|
|
25
|
+
* Looks up the method record, routes by type:
|
|
26
|
+
* - erc20: derives EVM address, computes token amount (1:1 USD for stablecoins)
|
|
27
|
+
* - native (ETH): derives EVM address, oracle-priced
|
|
28
|
+
* - native (BTC): derives BTC address, oracle-priced
|
|
29
|
+
*
|
|
30
|
+
* CRITICAL: amountUsd → integer cents via Credit.fromDollars().toCentsRounded().
|
|
31
|
+
*/
|
|
32
|
+
export declare function createUnifiedCheckout(deps: UnifiedCheckoutDeps, method: PaymentMethodRecord, opts: {
|
|
33
|
+
tenant: string;
|
|
34
|
+
amountUsd: number;
|
|
35
|
+
}): Promise<UnifiedCheckoutResult>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Credit } from "../../credits/credit.js";
|
|
2
|
+
import { deriveDepositAddress } from "./evm/address-gen.js";
|
|
3
|
+
import { centsToNative } from "./oracle/convert.js";
|
|
4
|
+
export const MIN_CHECKOUT_USD = 10;
|
|
5
|
+
/**
|
|
6
|
+
* Unified checkout — one entry point for all payment methods.
|
|
7
|
+
*
|
|
8
|
+
* Looks up the method record, routes by type:
|
|
9
|
+
* - erc20: derives EVM address, computes token amount (1:1 USD for stablecoins)
|
|
10
|
+
* - native (ETH): derives EVM address, oracle-priced
|
|
11
|
+
* - native (BTC): derives BTC address, oracle-priced
|
|
12
|
+
*
|
|
13
|
+
* CRITICAL: amountUsd → integer cents via Credit.fromDollars().toCentsRounded().
|
|
14
|
+
*/
|
|
15
|
+
export async function createUnifiedCheckout(deps, method, opts) {
|
|
16
|
+
if (!Number.isFinite(opts.amountUsd) || opts.amountUsd < MIN_CHECKOUT_USD) {
|
|
17
|
+
throw new Error(`Minimum payment amount is $${MIN_CHECKOUT_USD}`);
|
|
18
|
+
}
|
|
19
|
+
const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
|
|
20
|
+
if (method.type === "erc20") {
|
|
21
|
+
return handleErc20(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
|
|
22
|
+
}
|
|
23
|
+
if (method.token === "ETH") {
|
|
24
|
+
return handleNativeEth(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
|
|
25
|
+
}
|
|
26
|
+
if (method.token === "BTC") {
|
|
27
|
+
return handleNativeBtc(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
|
|
28
|
+
}
|
|
29
|
+
throw new Error(`Unsupported payment method type: ${method.type}/${method.token}`);
|
|
30
|
+
}
|
|
31
|
+
async function handleErc20(deps, method, tenant, amountUsdCents, amountUsd) {
|
|
32
|
+
const depositAddress = await deriveAndStore(deps, method, tenant, amountUsdCents);
|
|
33
|
+
return {
|
|
34
|
+
depositAddress,
|
|
35
|
+
displayAmount: `${amountUsd} ${method.token}`,
|
|
36
|
+
amountUsd,
|
|
37
|
+
token: method.token,
|
|
38
|
+
chain: method.chain,
|
|
39
|
+
referenceId: `erc20:${method.chain}:${depositAddress}`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async function handleNativeEth(deps, method, tenant, amountUsdCents, amountUsd) {
|
|
43
|
+
const { priceCents } = await deps.oracle.getPrice("ETH");
|
|
44
|
+
const expectedWei = centsToNative(amountUsdCents, priceCents, 18);
|
|
45
|
+
const depositAddress = await deriveAndStore(deps, method, tenant, amountUsdCents);
|
|
46
|
+
const divisor = BigInt("1000000000000000000");
|
|
47
|
+
const whole = expectedWei / divisor;
|
|
48
|
+
const frac = (expectedWei % divisor).toString().padStart(18, "0").slice(0, 6);
|
|
49
|
+
return {
|
|
50
|
+
depositAddress,
|
|
51
|
+
displayAmount: `${whole}.${frac} ETH`,
|
|
52
|
+
amountUsd,
|
|
53
|
+
token: "ETH",
|
|
54
|
+
chain: method.chain,
|
|
55
|
+
referenceId: `eth:${method.chain}:${depositAddress}`,
|
|
56
|
+
priceCents,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async function handleNativeBtc(deps, _method, tenant, amountUsdCents, amountUsd) {
|
|
60
|
+
const { priceCents } = await deps.oracle.getPrice("BTC");
|
|
61
|
+
const expectedSats = centsToNative(amountUsdCents, priceCents, 8);
|
|
62
|
+
// BTC address derivation uses btcXpub — import from btc module
|
|
63
|
+
const { deriveBtcAddress } = await import("./btc/address-gen.js");
|
|
64
|
+
if (!deps.btcXpub)
|
|
65
|
+
throw new Error("BTC payments not configured (no BTC_XPUB)");
|
|
66
|
+
const maxRetries = 3;
|
|
67
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
68
|
+
const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
|
|
69
|
+
const depositAddress = deriveBtcAddress(deps.btcXpub, derivationIndex, "mainnet");
|
|
70
|
+
const referenceId = `btc:${depositAddress}`;
|
|
71
|
+
try {
|
|
72
|
+
await deps.chargeStore.createStablecoinCharge({
|
|
73
|
+
referenceId,
|
|
74
|
+
tenantId: tenant,
|
|
75
|
+
amountUsdCents,
|
|
76
|
+
chain: "bitcoin",
|
|
77
|
+
token: "BTC",
|
|
78
|
+
depositAddress,
|
|
79
|
+
derivationIndex,
|
|
80
|
+
});
|
|
81
|
+
const btcAmount = Number(expectedSats) / 100_000_000;
|
|
82
|
+
return {
|
|
83
|
+
depositAddress,
|
|
84
|
+
displayAmount: `${btcAmount.toFixed(8)} BTC`,
|
|
85
|
+
amountUsd,
|
|
86
|
+
token: "BTC",
|
|
87
|
+
chain: "bitcoin",
|
|
88
|
+
referenceId,
|
|
89
|
+
priceCents,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
const code = err.code;
|
|
94
|
+
const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
|
|
95
|
+
if (!isConflict || attempt === maxRetries)
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
throw new Error("Failed to claim derivation index after retries");
|
|
100
|
+
}
|
|
101
|
+
/** Derive an EVM deposit address and store the charge. Retries on unique conflict. */
|
|
102
|
+
async function deriveAndStore(deps, method, tenant, amountUsdCents) {
|
|
103
|
+
const maxRetries = 3;
|
|
104
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
105
|
+
const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
|
|
106
|
+
const depositAddress = deriveDepositAddress(deps.evmXpub, derivationIndex);
|
|
107
|
+
const referenceId = `${method.type}:${method.chain}:${depositAddress}`;
|
|
108
|
+
try {
|
|
109
|
+
await deps.chargeStore.createStablecoinCharge({
|
|
110
|
+
referenceId,
|
|
111
|
+
tenantId: tenant,
|
|
112
|
+
amountUsdCents,
|
|
113
|
+
chain: method.chain,
|
|
114
|
+
token: method.token,
|
|
115
|
+
depositAddress,
|
|
116
|
+
derivationIndex,
|
|
117
|
+
});
|
|
118
|
+
return depositAddress;
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
const code = err.code;
|
|
122
|
+
const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
|
|
123
|
+
if (!isConflict || attempt === maxRetries)
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
throw new Error("Failed to claim derivation index after retries");
|
|
128
|
+
}
|
|
@@ -296,6 +296,222 @@ export declare const watcherCursors: import("drizzle-orm/pg-core").PgTableWithCo
|
|
|
296
296
|
};
|
|
297
297
|
dialect: "pg";
|
|
298
298
|
}>;
|
|
299
|
+
/**
|
|
300
|
+
* Payment method registry — runtime-configurable tokens/chains.
|
|
301
|
+
* Admin inserts a row to enable a new payment method. No deploy needed.
|
|
302
|
+
* Contract addresses are immutable on-chain but configurable here.
|
|
303
|
+
*/
|
|
304
|
+
export declare const paymentMethods: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
305
|
+
name: "payment_methods";
|
|
306
|
+
schema: undefined;
|
|
307
|
+
columns: {
|
|
308
|
+
id: import("drizzle-orm/pg-core").PgColumn<{
|
|
309
|
+
name: "id";
|
|
310
|
+
tableName: "payment_methods";
|
|
311
|
+
dataType: "string";
|
|
312
|
+
columnType: "PgText";
|
|
313
|
+
data: string;
|
|
314
|
+
driverParam: string;
|
|
315
|
+
notNull: true;
|
|
316
|
+
hasDefault: false;
|
|
317
|
+
isPrimaryKey: true;
|
|
318
|
+
isAutoincrement: false;
|
|
319
|
+
hasRuntimeDefault: false;
|
|
320
|
+
enumValues: [string, ...string[]];
|
|
321
|
+
baseColumn: never;
|
|
322
|
+
identity: undefined;
|
|
323
|
+
generated: undefined;
|
|
324
|
+
}, {}, {}>;
|
|
325
|
+
type: import("drizzle-orm/pg-core").PgColumn<{
|
|
326
|
+
name: "type";
|
|
327
|
+
tableName: "payment_methods";
|
|
328
|
+
dataType: "string";
|
|
329
|
+
columnType: "PgText";
|
|
330
|
+
data: string;
|
|
331
|
+
driverParam: string;
|
|
332
|
+
notNull: true;
|
|
333
|
+
hasDefault: false;
|
|
334
|
+
isPrimaryKey: false;
|
|
335
|
+
isAutoincrement: false;
|
|
336
|
+
hasRuntimeDefault: false;
|
|
337
|
+
enumValues: [string, ...string[]];
|
|
338
|
+
baseColumn: never;
|
|
339
|
+
identity: undefined;
|
|
340
|
+
generated: undefined;
|
|
341
|
+
}, {}, {}>;
|
|
342
|
+
token: import("drizzle-orm/pg-core").PgColumn<{
|
|
343
|
+
name: "token";
|
|
344
|
+
tableName: "payment_methods";
|
|
345
|
+
dataType: "string";
|
|
346
|
+
columnType: "PgText";
|
|
347
|
+
data: string;
|
|
348
|
+
driverParam: string;
|
|
349
|
+
notNull: true;
|
|
350
|
+
hasDefault: false;
|
|
351
|
+
isPrimaryKey: false;
|
|
352
|
+
isAutoincrement: false;
|
|
353
|
+
hasRuntimeDefault: false;
|
|
354
|
+
enumValues: [string, ...string[]];
|
|
355
|
+
baseColumn: never;
|
|
356
|
+
identity: undefined;
|
|
357
|
+
generated: undefined;
|
|
358
|
+
}, {}, {}>;
|
|
359
|
+
chain: import("drizzle-orm/pg-core").PgColumn<{
|
|
360
|
+
name: "chain";
|
|
361
|
+
tableName: "payment_methods";
|
|
362
|
+
dataType: "string";
|
|
363
|
+
columnType: "PgText";
|
|
364
|
+
data: string;
|
|
365
|
+
driverParam: string;
|
|
366
|
+
notNull: true;
|
|
367
|
+
hasDefault: false;
|
|
368
|
+
isPrimaryKey: false;
|
|
369
|
+
isAutoincrement: false;
|
|
370
|
+
hasRuntimeDefault: false;
|
|
371
|
+
enumValues: [string, ...string[]];
|
|
372
|
+
baseColumn: never;
|
|
373
|
+
identity: undefined;
|
|
374
|
+
generated: undefined;
|
|
375
|
+
}, {}, {}>;
|
|
376
|
+
contractAddress: import("drizzle-orm/pg-core").PgColumn<{
|
|
377
|
+
name: "contract_address";
|
|
378
|
+
tableName: "payment_methods";
|
|
379
|
+
dataType: "string";
|
|
380
|
+
columnType: "PgText";
|
|
381
|
+
data: string;
|
|
382
|
+
driverParam: string;
|
|
383
|
+
notNull: false;
|
|
384
|
+
hasDefault: false;
|
|
385
|
+
isPrimaryKey: false;
|
|
386
|
+
isAutoincrement: false;
|
|
387
|
+
hasRuntimeDefault: false;
|
|
388
|
+
enumValues: [string, ...string[]];
|
|
389
|
+
baseColumn: never;
|
|
390
|
+
identity: undefined;
|
|
391
|
+
generated: undefined;
|
|
392
|
+
}, {}, {}>;
|
|
393
|
+
decimals: import("drizzle-orm/pg-core").PgColumn<{
|
|
394
|
+
name: "decimals";
|
|
395
|
+
tableName: "payment_methods";
|
|
396
|
+
dataType: "number";
|
|
397
|
+
columnType: "PgInteger";
|
|
398
|
+
data: number;
|
|
399
|
+
driverParam: string | number;
|
|
400
|
+
notNull: true;
|
|
401
|
+
hasDefault: false;
|
|
402
|
+
isPrimaryKey: false;
|
|
403
|
+
isAutoincrement: false;
|
|
404
|
+
hasRuntimeDefault: false;
|
|
405
|
+
enumValues: undefined;
|
|
406
|
+
baseColumn: never;
|
|
407
|
+
identity: undefined;
|
|
408
|
+
generated: undefined;
|
|
409
|
+
}, {}, {}>;
|
|
410
|
+
displayName: import("drizzle-orm/pg-core").PgColumn<{
|
|
411
|
+
name: "display_name";
|
|
412
|
+
tableName: "payment_methods";
|
|
413
|
+
dataType: "string";
|
|
414
|
+
columnType: "PgText";
|
|
415
|
+
data: string;
|
|
416
|
+
driverParam: string;
|
|
417
|
+
notNull: true;
|
|
418
|
+
hasDefault: false;
|
|
419
|
+
isPrimaryKey: false;
|
|
420
|
+
isAutoincrement: false;
|
|
421
|
+
hasRuntimeDefault: false;
|
|
422
|
+
enumValues: [string, ...string[]];
|
|
423
|
+
baseColumn: never;
|
|
424
|
+
identity: undefined;
|
|
425
|
+
generated: undefined;
|
|
426
|
+
}, {}, {}>;
|
|
427
|
+
enabled: import("drizzle-orm/pg-core").PgColumn<{
|
|
428
|
+
name: "enabled";
|
|
429
|
+
tableName: "payment_methods";
|
|
430
|
+
dataType: "boolean";
|
|
431
|
+
columnType: "PgBoolean";
|
|
432
|
+
data: boolean;
|
|
433
|
+
driverParam: boolean;
|
|
434
|
+
notNull: true;
|
|
435
|
+
hasDefault: true;
|
|
436
|
+
isPrimaryKey: false;
|
|
437
|
+
isAutoincrement: false;
|
|
438
|
+
hasRuntimeDefault: false;
|
|
439
|
+
enumValues: undefined;
|
|
440
|
+
baseColumn: never;
|
|
441
|
+
identity: undefined;
|
|
442
|
+
generated: undefined;
|
|
443
|
+
}, {}, {}>;
|
|
444
|
+
displayOrder: import("drizzle-orm/pg-core").PgColumn<{
|
|
445
|
+
name: "display_order";
|
|
446
|
+
tableName: "payment_methods";
|
|
447
|
+
dataType: "number";
|
|
448
|
+
columnType: "PgInteger";
|
|
449
|
+
data: number;
|
|
450
|
+
driverParam: string | number;
|
|
451
|
+
notNull: true;
|
|
452
|
+
hasDefault: true;
|
|
453
|
+
isPrimaryKey: false;
|
|
454
|
+
isAutoincrement: false;
|
|
455
|
+
hasRuntimeDefault: false;
|
|
456
|
+
enumValues: undefined;
|
|
457
|
+
baseColumn: never;
|
|
458
|
+
identity: undefined;
|
|
459
|
+
generated: undefined;
|
|
460
|
+
}, {}, {}>;
|
|
461
|
+
rpcUrl: import("drizzle-orm/pg-core").PgColumn<{
|
|
462
|
+
name: "rpc_url";
|
|
463
|
+
tableName: "payment_methods";
|
|
464
|
+
dataType: "string";
|
|
465
|
+
columnType: "PgText";
|
|
466
|
+
data: string;
|
|
467
|
+
driverParam: string;
|
|
468
|
+
notNull: false;
|
|
469
|
+
hasDefault: false;
|
|
470
|
+
isPrimaryKey: false;
|
|
471
|
+
isAutoincrement: false;
|
|
472
|
+
hasRuntimeDefault: false;
|
|
473
|
+
enumValues: [string, ...string[]];
|
|
474
|
+
baseColumn: never;
|
|
475
|
+
identity: undefined;
|
|
476
|
+
generated: undefined;
|
|
477
|
+
}, {}, {}>;
|
|
478
|
+
confirmations: import("drizzle-orm/pg-core").PgColumn<{
|
|
479
|
+
name: "confirmations";
|
|
480
|
+
tableName: "payment_methods";
|
|
481
|
+
dataType: "number";
|
|
482
|
+
columnType: "PgInteger";
|
|
483
|
+
data: number;
|
|
484
|
+
driverParam: string | number;
|
|
485
|
+
notNull: true;
|
|
486
|
+
hasDefault: true;
|
|
487
|
+
isPrimaryKey: false;
|
|
488
|
+
isAutoincrement: false;
|
|
489
|
+
hasRuntimeDefault: false;
|
|
490
|
+
enumValues: undefined;
|
|
491
|
+
baseColumn: never;
|
|
492
|
+
identity: undefined;
|
|
493
|
+
generated: undefined;
|
|
494
|
+
}, {}, {}>;
|
|
495
|
+
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
496
|
+
name: "created_at";
|
|
497
|
+
tableName: "payment_methods";
|
|
498
|
+
dataType: "string";
|
|
499
|
+
columnType: "PgText";
|
|
500
|
+
data: string;
|
|
501
|
+
driverParam: string;
|
|
502
|
+
notNull: true;
|
|
503
|
+
hasDefault: true;
|
|
504
|
+
isPrimaryKey: false;
|
|
505
|
+
isAutoincrement: false;
|
|
506
|
+
hasRuntimeDefault: false;
|
|
507
|
+
enumValues: [string, ...string[]];
|
|
508
|
+
baseColumn: never;
|
|
509
|
+
identity: undefined;
|
|
510
|
+
generated: undefined;
|
|
511
|
+
}, {}, {}>;
|
|
512
|
+
};
|
|
513
|
+
dialect: "pg";
|
|
514
|
+
}>;
|
|
299
515
|
/** Processed transaction IDs for watchers without block cursors (e.g. BTC). */
|
|
300
516
|
export declare const watcherProcessed: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
301
517
|
name: "watcher_processed";
|
package/dist/db/schema/crypto.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
|
-
import { index, integer, pgTable, primaryKey, text } from "drizzle-orm/pg-core";
|
|
2
|
+
import { boolean, index, integer, pgTable, primaryKey, text } from "drizzle-orm/pg-core";
|
|
3
3
|
/**
|
|
4
4
|
* Crypto payment charges — tracks the lifecycle of each BTCPay invoice.
|
|
5
5
|
* reference_id is the BTCPay invoice ID.
|
|
@@ -39,6 +39,25 @@ export const watcherCursors = pgTable("watcher_cursors", {
|
|
|
39
39
|
cursorBlock: integer("cursor_block").notNull(),
|
|
40
40
|
updatedAt: text("updated_at").notNull().default(sql `(now())`),
|
|
41
41
|
});
|
|
42
|
+
/**
|
|
43
|
+
* Payment method registry — runtime-configurable tokens/chains.
|
|
44
|
+
* Admin inserts a row to enable a new payment method. No deploy needed.
|
|
45
|
+
* Contract addresses are immutable on-chain but configurable here.
|
|
46
|
+
*/
|
|
47
|
+
export const paymentMethods = pgTable("payment_methods", {
|
|
48
|
+
id: text("id").primaryKey(), // "USDC:base", "ETH:base", "BTC:mainnet"
|
|
49
|
+
type: text("type").notNull(), // "stablecoin", "eth", "btc"
|
|
50
|
+
token: text("token").notNull(), // "USDC", "ETH", "BTC"
|
|
51
|
+
chain: text("chain").notNull(), // "base", "ethereum", "bitcoin"
|
|
52
|
+
contractAddress: text("contract_address"), // null for native (ETH, BTC)
|
|
53
|
+
decimals: integer("decimals").notNull(),
|
|
54
|
+
displayName: text("display_name").notNull(),
|
|
55
|
+
enabled: boolean("enabled").notNull().default(true),
|
|
56
|
+
displayOrder: integer("display_order").notNull().default(0),
|
|
57
|
+
rpcUrl: text("rpc_url"), // override per-chain RPC (null = use default)
|
|
58
|
+
confirmations: integer("confirmations").notNull().default(1),
|
|
59
|
+
createdAt: text("created_at").notNull().default(sql `(now())`),
|
|
60
|
+
});
|
|
42
61
|
/** Processed transaction IDs for watchers without block cursors (e.g. BTC). */
|
|
43
62
|
export const watcherProcessed = pgTable("watcher_processed", {
|
|
44
63
|
watcherId: text("watcher_id").notNull(),
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "payment_methods" (
|
|
2
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
3
|
+
"type" text NOT NULL,
|
|
4
|
+
"token" text NOT NULL,
|
|
5
|
+
"chain" text NOT NULL,
|
|
6
|
+
"contract_address" text,
|
|
7
|
+
"decimals" integer NOT NULL,
|
|
8
|
+
"display_name" text NOT NULL,
|
|
9
|
+
"enabled" boolean NOT NULL DEFAULT true,
|
|
10
|
+
"display_order" integer NOT NULL DEFAULT 0,
|
|
11
|
+
"rpc_url" text,
|
|
12
|
+
"confirmations" integer NOT NULL DEFAULT 1,
|
|
13
|
+
"created_at" text DEFAULT (now()) NOT NULL
|
|
14
|
+
);
|
|
15
|
+
--> statement-breakpoint
|
|
16
|
+
INSERT INTO "payment_methods" ("id", "type", "token", "chain", "contract_address", "decimals", "display_name", "display_order", "confirmations") VALUES
|
|
17
|
+
('USDC:base', 'erc20', 'USDC', 'base', '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', 6, 'USDC on Base', 0, 1),
|
|
18
|
+
('USDT:base', 'erc20', 'USDT', 'base', '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2', 6, 'USDT on Base', 1, 1),
|
|
19
|
+
('DAI:base', 'erc20', 'DAI', 'base', '0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb', 18, 'DAI on Base', 2, 1),
|
|
20
|
+
('ETH:base', 'native', 'ETH', 'base', NULL, 18, 'ETH on Base', 3, 1),
|
|
21
|
+
('BTC:mainnet', 'native', 'BTC', 'bitcoin', NULL, 8, 'Bitcoin', 10, 3)
|
|
22
|
+
ON CONFLICT ("id") DO NOTHING;
|
package/package.json
CHANGED
|
@@ -8,6 +8,8 @@ export type { IWatcherCursorStore } from "./cursor-store.js";
|
|
|
8
8
|
export { DrizzleWatcherCursorStore } from "./cursor-store.js";
|
|
9
9
|
export * from "./evm/index.js";
|
|
10
10
|
export * from "./oracle/index.js";
|
|
11
|
+
export type { IPaymentMethodStore, PaymentMethodRecord } from "./payment-method-store.js";
|
|
12
|
+
export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
|
|
11
13
|
export type {
|
|
12
14
|
CryptoBillingConfig,
|
|
13
15
|
CryptoCheckoutOpts,
|
|
@@ -16,5 +18,7 @@ export type {
|
|
|
16
18
|
CryptoWebhookResult,
|
|
17
19
|
} from "./types.js";
|
|
18
20
|
export { mapBtcPayEventToStatus } from "./types.js";
|
|
21
|
+
export type { UnifiedCheckoutDeps, UnifiedCheckoutResult } from "./unified-checkout.js";
|
|
22
|
+
export { createUnifiedCheckout, MIN_CHECKOUT_USD } from "./unified-checkout.js";
|
|
19
23
|
export type { CryptoWebhookDeps } from "./webhook.js";
|
|
20
24
|
export { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { and, eq } from "drizzle-orm";
|
|
2
|
+
import type { PlatformDb } from "../../db/index.js";
|
|
3
|
+
import { paymentMethods } from "../../db/schema/crypto.js";
|
|
4
|
+
|
|
5
|
+
export interface PaymentMethodRecord {
|
|
6
|
+
id: string;
|
|
7
|
+
type: string;
|
|
8
|
+
token: string;
|
|
9
|
+
chain: string;
|
|
10
|
+
contractAddress: string | null;
|
|
11
|
+
decimals: number;
|
|
12
|
+
displayName: string;
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
displayOrder: number;
|
|
15
|
+
rpcUrl: string | null;
|
|
16
|
+
confirmations: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface IPaymentMethodStore {
|
|
20
|
+
/** List all enabled payment methods, ordered by displayOrder. */
|
|
21
|
+
listEnabled(): Promise<PaymentMethodRecord[]>;
|
|
22
|
+
/** List all payment methods (including disabled). */
|
|
23
|
+
listAll(): Promise<PaymentMethodRecord[]>;
|
|
24
|
+
/** Get a specific payment method by id. */
|
|
25
|
+
getById(id: string): Promise<PaymentMethodRecord | null>;
|
|
26
|
+
/** Get enabled methods by type (stablecoin, eth, btc). */
|
|
27
|
+
listByType(type: string): Promise<PaymentMethodRecord[]>;
|
|
28
|
+
/** Upsert a payment method (admin). */
|
|
29
|
+
upsert(method: PaymentMethodRecord): Promise<void>;
|
|
30
|
+
/** Enable or disable a payment method (admin). */
|
|
31
|
+
setEnabled(id: string, enabled: boolean): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class DrizzlePaymentMethodStore implements IPaymentMethodStore {
|
|
35
|
+
constructor(private readonly db: PlatformDb) {}
|
|
36
|
+
|
|
37
|
+
async listEnabled(): Promise<PaymentMethodRecord[]> {
|
|
38
|
+
const rows = await this.db
|
|
39
|
+
.select()
|
|
40
|
+
.from(paymentMethods)
|
|
41
|
+
.where(eq(paymentMethods.enabled, true))
|
|
42
|
+
.orderBy(paymentMethods.displayOrder);
|
|
43
|
+
return rows.map(toRecord);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async listAll(): Promise<PaymentMethodRecord[]> {
|
|
47
|
+
const rows = await this.db.select().from(paymentMethods).orderBy(paymentMethods.displayOrder);
|
|
48
|
+
return rows.map(toRecord);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async getById(id: string): Promise<PaymentMethodRecord | null> {
|
|
52
|
+
const row = (await this.db.select().from(paymentMethods).where(eq(paymentMethods.id, id)))[0];
|
|
53
|
+
return row ? toRecord(row) : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async listByType(type: string): Promise<PaymentMethodRecord[]> {
|
|
57
|
+
const rows = await this.db
|
|
58
|
+
.select()
|
|
59
|
+
.from(paymentMethods)
|
|
60
|
+
.where(and(eq(paymentMethods.type, type), eq(paymentMethods.enabled, true)))
|
|
61
|
+
.orderBy(paymentMethods.displayOrder);
|
|
62
|
+
return rows.map(toRecord);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async upsert(method: PaymentMethodRecord): Promise<void> {
|
|
66
|
+
await this.db
|
|
67
|
+
.insert(paymentMethods)
|
|
68
|
+
.values({
|
|
69
|
+
id: method.id,
|
|
70
|
+
type: method.type,
|
|
71
|
+
token: method.token,
|
|
72
|
+
chain: method.chain,
|
|
73
|
+
contractAddress: method.contractAddress,
|
|
74
|
+
decimals: method.decimals,
|
|
75
|
+
displayName: method.displayName,
|
|
76
|
+
enabled: method.enabled,
|
|
77
|
+
displayOrder: method.displayOrder,
|
|
78
|
+
rpcUrl: method.rpcUrl,
|
|
79
|
+
confirmations: method.confirmations,
|
|
80
|
+
})
|
|
81
|
+
.onConflictDoUpdate({
|
|
82
|
+
target: paymentMethods.id,
|
|
83
|
+
set: {
|
|
84
|
+
type: method.type,
|
|
85
|
+
token: method.token,
|
|
86
|
+
chain: method.chain,
|
|
87
|
+
contractAddress: method.contractAddress,
|
|
88
|
+
decimals: method.decimals,
|
|
89
|
+
displayName: method.displayName,
|
|
90
|
+
enabled: method.enabled,
|
|
91
|
+
displayOrder: method.displayOrder,
|
|
92
|
+
rpcUrl: method.rpcUrl,
|
|
93
|
+
confirmations: method.confirmations,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async setEnabled(id: string, enabled: boolean): Promise<void> {
|
|
99
|
+
await this.db.update(paymentMethods).set({ enabled }).where(eq(paymentMethods.id, id));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function toRecord(row: typeof paymentMethods.$inferSelect): PaymentMethodRecord {
|
|
104
|
+
return {
|
|
105
|
+
id: row.id,
|
|
106
|
+
type: row.type,
|
|
107
|
+
token: row.token,
|
|
108
|
+
chain: row.chain,
|
|
109
|
+
contractAddress: row.contractAddress,
|
|
110
|
+
decimals: row.decimals,
|
|
111
|
+
displayName: row.displayName,
|
|
112
|
+
enabled: row.enabled,
|
|
113
|
+
displayOrder: row.displayOrder,
|
|
114
|
+
rpcUrl: row.rpcUrl,
|
|
115
|
+
confirmations: row.confirmations,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { Credit } from "../../credits/credit.js";
|
|
2
|
+
import type { ICryptoChargeRepository } from "./charge-store.js";
|
|
3
|
+
import { deriveDepositAddress } from "./evm/address-gen.js";
|
|
4
|
+
import { centsToNative } from "./oracle/convert.js";
|
|
5
|
+
import type { IPriceOracle } from "./oracle/types.js";
|
|
6
|
+
import type { PaymentMethodRecord } from "./payment-method-store.js";
|
|
7
|
+
|
|
8
|
+
export const MIN_CHECKOUT_USD = 10;
|
|
9
|
+
|
|
10
|
+
export interface UnifiedCheckoutDeps {
|
|
11
|
+
chargeStore: Pick<ICryptoChargeRepository, "getNextDerivationIndex" | "createStablecoinCharge">;
|
|
12
|
+
oracle: IPriceOracle;
|
|
13
|
+
evmXpub: string;
|
|
14
|
+
btcXpub?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UnifiedCheckoutResult {
|
|
18
|
+
depositAddress: string;
|
|
19
|
+
/** Human-readable amount to send (e.g. "50 USDC", "0.014285 ETH"). */
|
|
20
|
+
displayAmount: string;
|
|
21
|
+
amountUsd: number;
|
|
22
|
+
token: string;
|
|
23
|
+
chain: string;
|
|
24
|
+
referenceId: string;
|
|
25
|
+
/** For volatile assets: price at checkout time (USD cents per unit). */
|
|
26
|
+
priceCents?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Unified checkout — one entry point for all payment methods.
|
|
31
|
+
*
|
|
32
|
+
* Looks up the method record, routes by type:
|
|
33
|
+
* - erc20: derives EVM address, computes token amount (1:1 USD for stablecoins)
|
|
34
|
+
* - native (ETH): derives EVM address, oracle-priced
|
|
35
|
+
* - native (BTC): derives BTC address, oracle-priced
|
|
36
|
+
*
|
|
37
|
+
* CRITICAL: amountUsd → integer cents via Credit.fromDollars().toCentsRounded().
|
|
38
|
+
*/
|
|
39
|
+
export async function createUnifiedCheckout(
|
|
40
|
+
deps: UnifiedCheckoutDeps,
|
|
41
|
+
method: PaymentMethodRecord,
|
|
42
|
+
opts: { tenant: string; amountUsd: number },
|
|
43
|
+
): Promise<UnifiedCheckoutResult> {
|
|
44
|
+
if (!Number.isFinite(opts.amountUsd) || opts.amountUsd < MIN_CHECKOUT_USD) {
|
|
45
|
+
throw new Error(`Minimum payment amount is $${MIN_CHECKOUT_USD}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
|
|
49
|
+
|
|
50
|
+
if (method.type === "erc20") {
|
|
51
|
+
return handleErc20(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
|
|
52
|
+
}
|
|
53
|
+
if (method.token === "ETH") {
|
|
54
|
+
return handleNativeEth(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
|
|
55
|
+
}
|
|
56
|
+
if (method.token === "BTC") {
|
|
57
|
+
return handleNativeBtc(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
throw new Error(`Unsupported payment method type: ${method.type}/${method.token}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function handleErc20(
|
|
64
|
+
deps: UnifiedCheckoutDeps,
|
|
65
|
+
method: PaymentMethodRecord,
|
|
66
|
+
tenant: string,
|
|
67
|
+
amountUsdCents: number,
|
|
68
|
+
amountUsd: number,
|
|
69
|
+
): Promise<UnifiedCheckoutResult> {
|
|
70
|
+
const depositAddress = await deriveAndStore(deps, method, tenant, amountUsdCents);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
depositAddress,
|
|
74
|
+
displayAmount: `${amountUsd} ${method.token}`,
|
|
75
|
+
amountUsd,
|
|
76
|
+
token: method.token,
|
|
77
|
+
chain: method.chain,
|
|
78
|
+
referenceId: `erc20:${method.chain}:${depositAddress}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function handleNativeEth(
|
|
83
|
+
deps: UnifiedCheckoutDeps,
|
|
84
|
+
method: PaymentMethodRecord,
|
|
85
|
+
tenant: string,
|
|
86
|
+
amountUsdCents: number,
|
|
87
|
+
amountUsd: number,
|
|
88
|
+
): Promise<UnifiedCheckoutResult> {
|
|
89
|
+
const { priceCents } = await deps.oracle.getPrice("ETH");
|
|
90
|
+
const expectedWei = centsToNative(amountUsdCents, priceCents, 18);
|
|
91
|
+
const depositAddress = await deriveAndStore(deps, method, tenant, amountUsdCents);
|
|
92
|
+
|
|
93
|
+
const divisor = BigInt("1000000000000000000");
|
|
94
|
+
const whole = expectedWei / divisor;
|
|
95
|
+
const frac = (expectedWei % divisor).toString().padStart(18, "0").slice(0, 6);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
depositAddress,
|
|
99
|
+
displayAmount: `${whole}.${frac} ETH`,
|
|
100
|
+
amountUsd,
|
|
101
|
+
token: "ETH",
|
|
102
|
+
chain: method.chain,
|
|
103
|
+
referenceId: `eth:${method.chain}:${depositAddress}`,
|
|
104
|
+
priceCents,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function handleNativeBtc(
|
|
109
|
+
deps: UnifiedCheckoutDeps,
|
|
110
|
+
_method: PaymentMethodRecord,
|
|
111
|
+
tenant: string,
|
|
112
|
+
amountUsdCents: number,
|
|
113
|
+
amountUsd: number,
|
|
114
|
+
): Promise<UnifiedCheckoutResult> {
|
|
115
|
+
const { priceCents } = await deps.oracle.getPrice("BTC");
|
|
116
|
+
const expectedSats = centsToNative(amountUsdCents, priceCents, 8);
|
|
117
|
+
|
|
118
|
+
// BTC address derivation uses btcXpub — import from btc module
|
|
119
|
+
const { deriveBtcAddress } = await import("./btc/address-gen.js");
|
|
120
|
+
if (!deps.btcXpub) throw new Error("BTC payments not configured (no BTC_XPUB)");
|
|
121
|
+
|
|
122
|
+
const maxRetries = 3;
|
|
123
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
124
|
+
const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
|
|
125
|
+
const depositAddress = deriveBtcAddress(deps.btcXpub, derivationIndex, "mainnet");
|
|
126
|
+
const referenceId = `btc:${depositAddress}`;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await deps.chargeStore.createStablecoinCharge({
|
|
130
|
+
referenceId,
|
|
131
|
+
tenantId: tenant,
|
|
132
|
+
amountUsdCents,
|
|
133
|
+
chain: "bitcoin",
|
|
134
|
+
token: "BTC",
|
|
135
|
+
depositAddress,
|
|
136
|
+
derivationIndex,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const btcAmount = Number(expectedSats) / 100_000_000;
|
|
140
|
+
return {
|
|
141
|
+
depositAddress,
|
|
142
|
+
displayAmount: `${btcAmount.toFixed(8)} BTC`,
|
|
143
|
+
amountUsd,
|
|
144
|
+
token: "BTC",
|
|
145
|
+
chain: "bitcoin",
|
|
146
|
+
referenceId,
|
|
147
|
+
priceCents,
|
|
148
|
+
};
|
|
149
|
+
} catch (err: unknown) {
|
|
150
|
+
const code = (err as { code?: string }).code;
|
|
151
|
+
const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
|
|
152
|
+
if (!isConflict || attempt === maxRetries) throw err;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
throw new Error("Failed to claim derivation index after retries");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Derive an EVM deposit address and store the charge. Retries on unique conflict. */
|
|
160
|
+
async function deriveAndStore(
|
|
161
|
+
deps: UnifiedCheckoutDeps,
|
|
162
|
+
method: PaymentMethodRecord,
|
|
163
|
+
tenant: string,
|
|
164
|
+
amountUsdCents: number,
|
|
165
|
+
): Promise<string> {
|
|
166
|
+
const maxRetries = 3;
|
|
167
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
168
|
+
const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
|
|
169
|
+
const depositAddress = deriveDepositAddress(deps.evmXpub, derivationIndex);
|
|
170
|
+
const referenceId = `${method.type}:${method.chain}:${depositAddress}`;
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
await deps.chargeStore.createStablecoinCharge({
|
|
174
|
+
referenceId,
|
|
175
|
+
tenantId: tenant,
|
|
176
|
+
amountUsdCents,
|
|
177
|
+
chain: method.chain,
|
|
178
|
+
token: method.token,
|
|
179
|
+
depositAddress,
|
|
180
|
+
derivationIndex,
|
|
181
|
+
});
|
|
182
|
+
return depositAddress;
|
|
183
|
+
} catch (err: unknown) {
|
|
184
|
+
const code = (err as { code?: string }).code;
|
|
185
|
+
const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
|
|
186
|
+
if (!isConflict || attempt === maxRetries) throw err;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
throw new Error("Failed to claim derivation index after retries");
|
|
190
|
+
}
|
package/src/db/schema/crypto.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
|
-
import { index, integer, pgTable, primaryKey, text } from "drizzle-orm/pg-core";
|
|
2
|
+
import { boolean, index, integer, pgTable, primaryKey, text } from "drizzle-orm/pg-core";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Crypto payment charges — tracks the lifecycle of each BTCPay invoice.
|
|
@@ -46,6 +46,26 @@ export const watcherCursors = pgTable("watcher_cursors", {
|
|
|
46
46
|
updatedAt: text("updated_at").notNull().default(sql`(now())`),
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Payment method registry — runtime-configurable tokens/chains.
|
|
51
|
+
* Admin inserts a row to enable a new payment method. No deploy needed.
|
|
52
|
+
* Contract addresses are immutable on-chain but configurable here.
|
|
53
|
+
*/
|
|
54
|
+
export const paymentMethods = pgTable("payment_methods", {
|
|
55
|
+
id: text("id").primaryKey(), // "USDC:base", "ETH:base", "BTC:mainnet"
|
|
56
|
+
type: text("type").notNull(), // "stablecoin", "eth", "btc"
|
|
57
|
+
token: text("token").notNull(), // "USDC", "ETH", "BTC"
|
|
58
|
+
chain: text("chain").notNull(), // "base", "ethereum", "bitcoin"
|
|
59
|
+
contractAddress: text("contract_address"), // null for native (ETH, BTC)
|
|
60
|
+
decimals: integer("decimals").notNull(),
|
|
61
|
+
displayName: text("display_name").notNull(),
|
|
62
|
+
enabled: boolean("enabled").notNull().default(true),
|
|
63
|
+
displayOrder: integer("display_order").notNull().default(0),
|
|
64
|
+
rpcUrl: text("rpc_url"), // override per-chain RPC (null = use default)
|
|
65
|
+
confirmations: integer("confirmations").notNull().default(1),
|
|
66
|
+
createdAt: text("created_at").notNull().default(sql`(now())`),
|
|
67
|
+
});
|
|
68
|
+
|
|
49
69
|
/** Processed transaction IDs for watchers without block cursors (e.g. BTC). */
|
|
50
70
|
export const watcherProcessed = pgTable(
|
|
51
71
|
"watcher_processed",
|