@wopr-network/platform-core 1.48.0 → 1.49.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__/unified-checkout.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/unified-checkout.test.js +63 -0
- package/dist/billing/crypto/__tests__/watcher-service.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/watcher-service.test.js +174 -0
- package/dist/billing/crypto/__tests__/webhook-confirmations.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/webhook-confirmations.test.js +304 -0
- package/dist/billing/crypto/btc/__tests__/settler.test.js +1 -0
- package/dist/billing/crypto/btc/__tests__/watcher.test.d.ts +1 -0
- package/dist/billing/crypto/btc/__tests__/watcher.test.js +170 -0
- package/dist/billing/crypto/btc/types.d.ts +3 -1
- package/dist/billing/crypto/btc/watcher.d.ts +6 -1
- package/dist/billing/crypto/btc/watcher.js +20 -6
- package/dist/billing/crypto/charge-store.d.ts +27 -2
- package/dist/billing/crypto/charge-store.js +67 -1
- package/dist/billing/crypto/charge-store.test.js +180 -1
- package/dist/billing/crypto/client.d.ts +2 -0
- package/dist/billing/crypto/cursor-store.d.ts +10 -3
- package/dist/billing/crypto/cursor-store.js +21 -1
- package/dist/billing/crypto/evm/__tests__/eth-settler.test.js +2 -0
- package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +31 -4
- package/dist/billing/crypto/evm/__tests__/settler.test.js +2 -0
- package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.d.ts +1 -0
- package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.js +144 -0
- package/dist/billing/crypto/evm/__tests__/watcher.test.js +6 -2
- package/dist/billing/crypto/evm/eth-watcher.d.ts +11 -8
- package/dist/billing/crypto/evm/eth-watcher.js +27 -13
- package/dist/billing/crypto/evm/types.d.ts +5 -1
- package/dist/billing/crypto/evm/watcher.d.ts +9 -1
- package/dist/billing/crypto/evm/watcher.js +36 -13
- package/dist/billing/crypto/index.d.ts +3 -3
- package/dist/billing/crypto/index.js +1 -1
- package/dist/billing/crypto/key-server-webhook.d.ts +17 -4
- package/dist/billing/crypto/key-server-webhook.js +76 -15
- package/dist/billing/crypto/types.d.ts +16 -0
- package/dist/billing/crypto/unified-checkout.d.ts +8 -17
- package/dist/billing/crypto/unified-checkout.js +17 -131
- package/dist/billing/crypto/watcher-service.d.ts +22 -2
- package/dist/billing/crypto/watcher-service.js +71 -30
- package/dist/db/schema/crypto.d.ts +68 -0
- package/dist/db/schema/crypto.js +8 -0
- package/dist/monetization/crypto/__tests__/webhook.test.js +2 -1
- package/drizzle/migrations/0016_charge_progress_columns.sql +4 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/billing/crypto/__tests__/unified-checkout.test.ts +83 -0
- package/src/billing/crypto/__tests__/watcher-service.test.ts +242 -0
- package/src/billing/crypto/__tests__/webhook-confirmations.test.ts +367 -0
- package/src/billing/crypto/btc/__tests__/settler.test.ts +1 -0
- package/src/billing/crypto/btc/__tests__/watcher.test.ts +201 -0
- package/src/billing/crypto/btc/types.ts +3 -1
- package/src/billing/crypto/btc/watcher.ts +22 -6
- package/src/billing/crypto/charge-store.test.ts +204 -1
- package/src/billing/crypto/charge-store.ts +86 -2
- package/src/billing/crypto/client.ts +2 -0
- package/src/billing/crypto/cursor-store.ts +31 -3
- package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +2 -0
- package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +31 -4
- package/src/billing/crypto/evm/__tests__/settler.test.ts +2 -0
- package/src/billing/crypto/evm/__tests__/watcher-confirmations.test.ts +176 -0
- package/src/billing/crypto/evm/__tests__/watcher.test.ts +6 -2
- package/src/billing/crypto/evm/eth-watcher.ts +34 -14
- package/src/billing/crypto/evm/types.ts +5 -1
- package/src/billing/crypto/evm/watcher.ts +39 -13
- package/src/billing/crypto/index.ts +12 -3
- package/src/billing/crypto/key-server-webhook.ts +92 -21
- package/src/billing/crypto/types.ts +18 -0
- package/src/billing/crypto/unified-checkout.ts +20 -179
- package/src/billing/crypto/watcher-service.ts +85 -32
- package/src/db/schema/crypto.ts +8 -0
- package/src/monetization/crypto/__tests__/webhook.test.ts +2 -1
|
@@ -1,32 +1,76 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Key Server webhook handler — processes payment
|
|
2
|
+
* Key Server webhook handler — processes payment events from the
|
|
3
3
|
* centralized crypto key server.
|
|
4
4
|
*
|
|
5
|
+
* Called on EVERY status update (not just terminal):
|
|
6
|
+
* - "partial" / "Processing" → update progress, no credit
|
|
7
|
+
* - "confirmed" / "Settled" → update progress + credit ledger
|
|
8
|
+
* - "expired" / "failed" → update progress, no credit
|
|
9
|
+
*
|
|
5
10
|
* Payload shape (from watcher-service.ts):
|
|
6
11
|
* {
|
|
7
12
|
* chargeId: "btc:bc1q...",
|
|
8
13
|
* chain: "bitcoin",
|
|
9
14
|
* address: "bc1q...",
|
|
10
|
-
*
|
|
15
|
+
* amountReceivedCents: 5000,
|
|
11
16
|
* status: "confirmed",
|
|
12
17
|
* txHash: "abc123...",
|
|
13
18
|
* amountReceived: "50000 sats",
|
|
14
|
-
* confirmations: 6
|
|
19
|
+
* confirmations: 6,
|
|
20
|
+
* confirmationsRequired: 6
|
|
15
21
|
* }
|
|
16
22
|
*
|
|
17
23
|
* Replaces handleCryptoWebhook() for products using the key server.
|
|
18
24
|
*/
|
|
19
25
|
import { Credit } from "../../credits/credit.js";
|
|
20
26
|
/**
|
|
21
|
-
*
|
|
27
|
+
* Map legacy/watcher status strings to canonical CryptoChargeStatus.
|
|
28
|
+
* Accepts both old BTCPay-style ("Settled", "Processing") and new canonical ("confirmed", "partial").
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeStatus(raw) {
|
|
31
|
+
switch (raw) {
|
|
32
|
+
case "confirmed":
|
|
33
|
+
case "Settled":
|
|
34
|
+
case "InvoiceSettled":
|
|
35
|
+
return "confirmed";
|
|
36
|
+
case "partial":
|
|
37
|
+
case "Processing":
|
|
38
|
+
case "InvoiceProcessing":
|
|
39
|
+
case "InvoiceReceivedPayment":
|
|
40
|
+
return "partial";
|
|
41
|
+
case "expired":
|
|
42
|
+
case "Expired":
|
|
43
|
+
case "InvoiceExpired":
|
|
44
|
+
return "expired";
|
|
45
|
+
case "failed":
|
|
46
|
+
case "Invalid":
|
|
47
|
+
case "InvoiceInvalid":
|
|
48
|
+
return "failed";
|
|
49
|
+
case "pending":
|
|
50
|
+
case "New":
|
|
51
|
+
case "InvoiceCreated":
|
|
52
|
+
return "pending";
|
|
53
|
+
default:
|
|
54
|
+
return "pending";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Process a payment webhook from the crypto key server.
|
|
22
59
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
60
|
+
* Idempotency: deduplicate by chargeId + status + confirmations so that
|
|
61
|
+
* multiple progress updates (0→1→2→...→6 confirmations) each get through,
|
|
62
|
+
* but exact duplicates are rejected.
|
|
25
63
|
*/
|
|
26
64
|
export async function handleKeyServerWebhook(deps, payload) {
|
|
27
65
|
const { chargeStore, creditLedger } = deps;
|
|
28
|
-
|
|
29
|
-
const
|
|
66
|
+
const status = normalizeStatus(payload.status);
|
|
67
|
+
const confirmations = payload.confirmations ?? 0;
|
|
68
|
+
const confirmationsRequired = payload.confirmationsRequired ?? 1;
|
|
69
|
+
// Support deprecated amountUsdCents field as fallback
|
|
70
|
+
const amountReceivedCents = payload.amountReceivedCents ?? payload.amountUsdCents ?? 0;
|
|
71
|
+
// Replay guard: deduplicate by chargeId + status + confirmations
|
|
72
|
+
// This allows multiple progress updates for the same charge
|
|
73
|
+
const dedupeKey = `ks:${payload.chargeId}:${status}:${confirmations}`;
|
|
30
74
|
if (await deps.replayGuard.isDuplicate(dedupeKey, "crypto")) {
|
|
31
75
|
return { handled: true, duplicate: true };
|
|
32
76
|
}
|
|
@@ -35,14 +79,29 @@ export async function handleKeyServerWebhook(deps, payload) {
|
|
|
35
79
|
if (!charge) {
|
|
36
80
|
return { handled: false };
|
|
37
81
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
82
|
+
// Always update progress on every webhook
|
|
83
|
+
await chargeStore.updateProgress(payload.chargeId, {
|
|
84
|
+
status,
|
|
85
|
+
amountReceivedCents,
|
|
86
|
+
confirmations,
|
|
87
|
+
confirmationsRequired,
|
|
88
|
+
txHash: payload.txHash,
|
|
89
|
+
});
|
|
90
|
+
// Also call deprecated updateStatus for backward compat with downstream consumers
|
|
91
|
+
const legacyStatusMap = {
|
|
92
|
+
pending: "New",
|
|
93
|
+
partial: "Processing",
|
|
94
|
+
confirmed: "Settled",
|
|
95
|
+
expired: "Expired",
|
|
96
|
+
failed: "Invalid",
|
|
97
|
+
};
|
|
98
|
+
await chargeStore.updateStatus(payload.chargeId, legacyStatusMap[status], charge.token ?? undefined, payload.amountReceived);
|
|
99
|
+
if (status === "confirmed") {
|
|
41
100
|
// Idempotency: check ledger referenceId (atomic, same as BTCPay handler)
|
|
42
101
|
const creditRef = `crypto:${payload.chargeId}`;
|
|
43
102
|
if (await creditLedger.hasReferenceId(creditRef)) {
|
|
44
103
|
await deps.replayGuard.markSeen(dedupeKey, "crypto");
|
|
45
|
-
return { handled: true, duplicate: true, tenant: charge.tenantId };
|
|
104
|
+
return { handled: true, duplicate: true, tenant: charge.tenantId, status, confirmations, confirmationsRequired };
|
|
46
105
|
}
|
|
47
106
|
// Credit the original USD amount requested.
|
|
48
107
|
// charge.amountUsdCents is integer cents. Credit.fromCents() → nanodollars.
|
|
@@ -64,10 +123,12 @@ export async function handleKeyServerWebhook(deps, payload) {
|
|
|
64
123
|
tenant: charge.tenantId,
|
|
65
124
|
creditedCents: charge.amountUsdCents,
|
|
66
125
|
reactivatedBots,
|
|
126
|
+
status,
|
|
127
|
+
confirmations,
|
|
128
|
+
confirmationsRequired,
|
|
67
129
|
};
|
|
68
130
|
}
|
|
69
|
-
// Non-confirmed status —
|
|
70
|
-
await chargeStore.updateStatus(payload.chargeId, payload.status, charge.token ?? undefined, payload.amountReceived);
|
|
131
|
+
// Non-confirmed status — progress already updated above, no credit
|
|
71
132
|
await deps.replayGuard.markSeen(dedupeKey, "crypto");
|
|
72
|
-
return { handled: true, tenant: charge.tenantId };
|
|
133
|
+
return { handled: true, tenant: charge.tenantId, status, confirmations, confirmationsRequired };
|
|
73
134
|
}
|
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
/** BTCPay Server invoice states (Greenfield API v1). */
|
|
2
2
|
export type CryptoPaymentState = "New" | "Processing" | "Expired" | "Invalid" | "Settled";
|
|
3
|
+
/** Charge status for the UI-facing payment lifecycle. */
|
|
4
|
+
export type CryptoChargeStatus = "pending" | "partial" | "confirmed" | "expired" | "failed";
|
|
5
|
+
/** Full charge record for UI display — includes partial payment progress and confirmations. */
|
|
6
|
+
export interface CryptoCharge {
|
|
7
|
+
id: string;
|
|
8
|
+
tenantId: string;
|
|
9
|
+
chain: string;
|
|
10
|
+
status: CryptoChargeStatus;
|
|
11
|
+
amountExpectedCents: number;
|
|
12
|
+
amountReceivedCents: number;
|
|
13
|
+
confirmations: number;
|
|
14
|
+
confirmationsRequired: number;
|
|
15
|
+
txHash?: string;
|
|
16
|
+
credited: boolean;
|
|
17
|
+
createdAt: Date;
|
|
18
|
+
}
|
|
3
19
|
/** Options for creating a crypto payment session. */
|
|
4
20
|
export interface CryptoCheckoutOpts {
|
|
5
21
|
/** Internal tenant ID. */
|
|
@@ -1,14 +1,7 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { IPriceOracle } from "./oracle/types.js";
|
|
3
|
-
import type { PaymentMethodRecord } from "./payment-method-store.js";
|
|
1
|
+
import type { CryptoServiceClient } from "./client.js";
|
|
4
2
|
export declare const MIN_CHECKOUT_USD = 10;
|
|
5
3
|
export interface UnifiedCheckoutDeps {
|
|
6
|
-
|
|
7
|
-
oracle: IPriceOracle;
|
|
8
|
-
evmXpub: string;
|
|
9
|
-
btcXpub?: string;
|
|
10
|
-
/** UTXO network override (auto-detected from node in production). Default: "mainnet". */
|
|
11
|
-
utxoNetwork?: "mainnet" | "testnet" | "regtest";
|
|
4
|
+
cryptoService: CryptoServiceClient;
|
|
12
5
|
}
|
|
13
6
|
export interface UnifiedCheckoutResult {
|
|
14
7
|
depositAddress: string;
|
|
@@ -22,16 +15,14 @@ export interface UnifiedCheckoutResult {
|
|
|
22
15
|
priceCents?: number;
|
|
23
16
|
}
|
|
24
17
|
/**
|
|
25
|
-
* Unified checkout —
|
|
18
|
+
* Unified checkout — delegates to CryptoServiceClient.createCharge().
|
|
26
19
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* - native (BTC): derives BTC address, oracle-priced
|
|
31
|
-
*
|
|
32
|
-
* CRITICAL: amountUsd → integer cents via Credit.fromDollars().toCentsRounded().
|
|
20
|
+
* The pay server handles xpub management, address derivation, and charge
|
|
21
|
+
* creation. This function is a thin wrapper that validates the amount
|
|
22
|
+
* and maps the response to `UnifiedCheckoutResult`.
|
|
33
23
|
*/
|
|
34
|
-
export declare function createUnifiedCheckout(deps: UnifiedCheckoutDeps,
|
|
24
|
+
export declare function createUnifiedCheckout(deps: UnifiedCheckoutDeps, chain: string, opts: {
|
|
35
25
|
tenant: string;
|
|
36
26
|
amountUsd: number;
|
|
27
|
+
callbackUrl?: string;
|
|
37
28
|
}): Promise<UnifiedCheckoutResult>;
|
|
@@ -1,141 +1,27 @@
|
|
|
1
|
-
import { Credit } from "../../credits/credit.js";
|
|
2
|
-
import { deriveAddress, deriveP2pkhAddress } from "./btc/address-gen.js";
|
|
3
|
-
import { deriveDepositAddress } from "./evm/address-gen.js";
|
|
4
|
-
import { centsToNative } from "./oracle/convert.js";
|
|
5
1
|
export const MIN_CHECKOUT_USD = 10;
|
|
6
2
|
/**
|
|
7
|
-
* Unified checkout —
|
|
3
|
+
* Unified checkout — delegates to CryptoServiceClient.createCharge().
|
|
8
4
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* - native (BTC): derives BTC address, oracle-priced
|
|
13
|
-
*
|
|
14
|
-
* CRITICAL: amountUsd → integer cents via Credit.fromDollars().toCentsRounded().
|
|
5
|
+
* The pay server handles xpub management, address derivation, and charge
|
|
6
|
+
* creation. This function is a thin wrapper that validates the amount
|
|
7
|
+
* and maps the response to `UnifiedCheckoutResult`.
|
|
15
8
|
*/
|
|
16
|
-
export async function createUnifiedCheckout(deps,
|
|
9
|
+
export async function createUnifiedCheckout(deps, chain, opts) {
|
|
17
10
|
if (!Number.isFinite(opts.amountUsd) || opts.amountUsd < MIN_CHECKOUT_USD) {
|
|
18
11
|
throw new Error(`Minimum payment amount is $${MIN_CHECKOUT_USD}`);
|
|
19
12
|
}
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return handleNativeEvm(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
|
|
26
|
-
}
|
|
27
|
-
if (method.type === "native") {
|
|
28
|
-
return handleNativeUtxo(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
|
|
29
|
-
}
|
|
30
|
-
throw new Error(`Unsupported payment method type: ${method.type}/${method.token}`);
|
|
31
|
-
}
|
|
32
|
-
async function handleErc20(deps, method, tenant, amountUsdCents, amountUsd) {
|
|
33
|
-
const depositAddress = await deriveAndStore(deps, method, tenant, amountUsdCents);
|
|
34
|
-
return {
|
|
35
|
-
depositAddress,
|
|
36
|
-
displayAmount: `${amountUsd} ${method.token}`,
|
|
37
|
-
amountUsd,
|
|
38
|
-
token: method.token,
|
|
39
|
-
chain: method.chain,
|
|
40
|
-
referenceId: `erc20:${method.chain}:${depositAddress}`,
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
async function handleNativeEvm(deps, method, tenant, amountUsdCents, amountUsd) {
|
|
44
|
-
const { priceCents } = await deps.oracle.getPrice("ETH");
|
|
45
|
-
const expectedWei = centsToNative(amountUsdCents, priceCents, 18);
|
|
46
|
-
const depositAddress = await deriveAndStore(deps, method, tenant, amountUsdCents);
|
|
47
|
-
const divisor = BigInt("1000000000000000000");
|
|
48
|
-
const whole = expectedWei / divisor;
|
|
49
|
-
const frac = (expectedWei % divisor).toString().padStart(18, "0").slice(0, 6);
|
|
13
|
+
const result = await deps.cryptoService.createCharge({
|
|
14
|
+
chain,
|
|
15
|
+
amountUsd: opts.amountUsd,
|
|
16
|
+
callbackUrl: opts.callbackUrl,
|
|
17
|
+
});
|
|
50
18
|
return {
|
|
51
|
-
depositAddress,
|
|
52
|
-
displayAmount: `${
|
|
53
|
-
amountUsd,
|
|
54
|
-
token:
|
|
55
|
-
chain:
|
|
56
|
-
referenceId:
|
|
57
|
-
priceCents,
|
|
19
|
+
depositAddress: result.address,
|
|
20
|
+
displayAmount: result.displayAmount ?? `${opts.amountUsd} ${result.token}`,
|
|
21
|
+
amountUsd: opts.amountUsd,
|
|
22
|
+
token: result.token,
|
|
23
|
+
chain: result.chain,
|
|
24
|
+
referenceId: result.chargeId,
|
|
25
|
+
priceCents: result.priceCents,
|
|
58
26
|
};
|
|
59
27
|
}
|
|
60
|
-
/**
|
|
61
|
-
* Handle native UTXO coins (BTC, LTC, DOGE, BCH, etc.).
|
|
62
|
-
* Uses the xpub from the payment method record (DB-driven).
|
|
63
|
-
* Derives bech32 addresses for BTC/LTC, Base58 P2PKH for DOGE.
|
|
64
|
-
*/
|
|
65
|
-
async function handleNativeUtxo(deps, method, tenant, amountUsdCents, amountUsd) {
|
|
66
|
-
const xpub = method.xpub ?? deps.btcXpub;
|
|
67
|
-
if (!xpub)
|
|
68
|
-
throw new Error(`${method.token} payments not configured (no xpub)`);
|
|
69
|
-
const { priceCents } = await deps.oracle.getPrice(method.token);
|
|
70
|
-
const rawAmount = centsToNative(amountUsdCents, priceCents, method.decimals);
|
|
71
|
-
const maxRetries = 3;
|
|
72
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
73
|
-
const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
|
|
74
|
-
// Derive address by chain type
|
|
75
|
-
let depositAddress;
|
|
76
|
-
if (method.chain === "dogecoin") {
|
|
77
|
-
depositAddress = deriveP2pkhAddress(xpub, derivationIndex, "dogecoin");
|
|
78
|
-
}
|
|
79
|
-
else {
|
|
80
|
-
depositAddress = deriveAddress(xpub, derivationIndex, deps.utxoNetwork ?? "mainnet", method.chain);
|
|
81
|
-
}
|
|
82
|
-
const referenceId = `${method.token.toLowerCase()}:${depositAddress}`;
|
|
83
|
-
try {
|
|
84
|
-
await deps.chargeStore.createStablecoinCharge({
|
|
85
|
-
referenceId,
|
|
86
|
-
tenantId: tenant,
|
|
87
|
-
amountUsdCents,
|
|
88
|
-
chain: method.chain,
|
|
89
|
-
token: method.token,
|
|
90
|
-
depositAddress,
|
|
91
|
-
derivationIndex,
|
|
92
|
-
});
|
|
93
|
-
const divisor = 10 ** method.decimals;
|
|
94
|
-
const displayAmt = (Number(rawAmount) / divisor).toFixed(method.decimals);
|
|
95
|
-
return {
|
|
96
|
-
depositAddress,
|
|
97
|
-
displayAmount: `${displayAmt} ${method.token}`,
|
|
98
|
-
amountUsd,
|
|
99
|
-
token: method.token,
|
|
100
|
-
chain: method.chain,
|
|
101
|
-
referenceId,
|
|
102
|
-
priceCents,
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
catch (err) {
|
|
106
|
-
const code = err.code;
|
|
107
|
-
const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
|
|
108
|
-
if (!isConflict || attempt === maxRetries)
|
|
109
|
-
throw err;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
throw new Error("Failed to claim derivation index after retries");
|
|
113
|
-
}
|
|
114
|
-
/** Derive an EVM deposit address and store the charge. Retries on unique conflict. */
|
|
115
|
-
async function deriveAndStore(deps, method, tenant, amountUsdCents) {
|
|
116
|
-
const maxRetries = 3;
|
|
117
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
118
|
-
const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
|
|
119
|
-
const depositAddress = deriveDepositAddress(deps.evmXpub, derivationIndex);
|
|
120
|
-
const referenceId = `${method.type}:${method.chain}:${depositAddress}`;
|
|
121
|
-
try {
|
|
122
|
-
await deps.chargeStore.createStablecoinCharge({
|
|
123
|
-
referenceId,
|
|
124
|
-
tenantId: tenant,
|
|
125
|
-
amountUsdCents,
|
|
126
|
-
chain: method.chain,
|
|
127
|
-
token: method.token,
|
|
128
|
-
depositAddress,
|
|
129
|
-
derivationIndex,
|
|
130
|
-
});
|
|
131
|
-
return depositAddress;
|
|
132
|
-
}
|
|
133
|
-
catch (err) {
|
|
134
|
-
const code = err.code;
|
|
135
|
-
const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
|
|
136
|
-
if (!isConflict || attempt === maxRetries)
|
|
137
|
-
throw err;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
throw new Error("Failed to claim derivation index after retries");
|
|
141
|
-
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Payment flow:
|
|
5
5
|
* 1. Watcher detects payment → handlePayment()
|
|
6
6
|
* 2. Accumulate native amount (supports partial payments)
|
|
7
|
-
* 3. When totalReceived >= expectedAmount →
|
|
8
|
-
* 4. Every payment
|
|
7
|
+
* 3. When totalReceived >= expectedAmount AND confirmations >= required → confirmed + credit
|
|
8
|
+
* 4. Every payment/confirmation change enqueues a webhook delivery
|
|
9
9
|
* 5. Outbox processor retries failed deliveries with exponential backoff
|
|
10
10
|
*
|
|
11
11
|
* Amount comparison is ALWAYS in native crypto units (sats, wei, token base units).
|
|
@@ -30,4 +30,24 @@ export interface WatcherServiceOpts {
|
|
|
30
30
|
/** Allowed callback URL prefixes. Default: ["https://"] — enforces HTTPS. */
|
|
31
31
|
allowedCallbackPrefixes?: string[];
|
|
32
32
|
}
|
|
33
|
+
export interface PaymentPayload {
|
|
34
|
+
txHash: string;
|
|
35
|
+
confirmations: number;
|
|
36
|
+
confirmationsRequired: number;
|
|
37
|
+
amountReceivedCents: number;
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Handle a payment event. Accumulates partial payments in native units.
|
|
42
|
+
* Fires webhook on every payment/confirmation change with canonical statuses.
|
|
43
|
+
*
|
|
44
|
+
* 3-phase webhook lifecycle:
|
|
45
|
+
* 1. Tx first seen -> status: "partial", confirmations: 0
|
|
46
|
+
* 2. Each new block -> status: "partial", confirmations: current
|
|
47
|
+
* 3. Threshold reached + full payment -> status: "confirmed"
|
|
48
|
+
*
|
|
49
|
+
* @param nativeAmount — received amount in native base units (sats for BTC/DOGE, raw token units for ERC20).
|
|
50
|
+
* Pass "0" for confirmation-only updates (no new payment, just more confirmations).
|
|
51
|
+
*/
|
|
52
|
+
export declare function handlePayment(db: DrizzleDb, chargeStore: ICryptoChargeRepository, address: string, nativeAmount: string, payload: PaymentPayload, log: (msg: string, meta?: Record<string, unknown>) => void): Promise<void>;
|
|
33
53
|
export declare function startWatchers(opts: WatcherServiceOpts): Promise<() => void>;
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Payment flow:
|
|
5
5
|
* 1. Watcher detects payment → handlePayment()
|
|
6
6
|
* 2. Accumulate native amount (supports partial payments)
|
|
7
|
-
* 3. When totalReceived >= expectedAmount →
|
|
8
|
-
* 4. Every payment
|
|
7
|
+
* 3. When totalReceived >= expectedAmount AND confirmations >= required → confirmed + credit
|
|
8
|
+
* 4. Every payment/confirmation change enqueues a webhook delivery
|
|
9
9
|
* 5. Outbox processor retries failed deliveries with exponential backoff
|
|
10
10
|
*
|
|
11
11
|
* Amount comparison is ALWAYS in native crypto units (sats, wei, token base units).
|
|
@@ -90,14 +90,19 @@ async function processDeliveries(db, allowedPrefixes, log) {
|
|
|
90
90
|
}
|
|
91
91
|
return delivered;
|
|
92
92
|
}
|
|
93
|
-
// --- Payment handling (partial + full) ---
|
|
94
93
|
/**
|
|
95
94
|
* Handle a payment event. Accumulates partial payments in native units.
|
|
96
|
-
*
|
|
95
|
+
* Fires webhook on every payment/confirmation change with canonical statuses.
|
|
97
96
|
*
|
|
98
|
-
*
|
|
97
|
+
* 3-phase webhook lifecycle:
|
|
98
|
+
* 1. Tx first seen -> status: "partial", confirmations: 0
|
|
99
|
+
* 2. Each new block -> status: "partial", confirmations: current
|
|
100
|
+
* 3. Threshold reached + full payment -> status: "confirmed"
|
|
101
|
+
*
|
|
102
|
+
* @param nativeAmount — received amount in native base units (sats for BTC/DOGE, raw token units for ERC20).
|
|
103
|
+
* Pass "0" for confirmation-only updates (no new payment, just more confirmations).
|
|
99
104
|
*/
|
|
100
|
-
async function handlePayment(db, chargeStore, address, nativeAmount, payload, log) {
|
|
105
|
+
export async function handlePayment(db, chargeStore, address, nativeAmount, payload, log) {
|
|
101
106
|
const charge = await chargeStore.getByDepositAddress(address);
|
|
102
107
|
if (!charge) {
|
|
103
108
|
log("Payment to unknown address", { address });
|
|
@@ -106,39 +111,59 @@ async function handlePayment(db, chargeStore, address, nativeAmount, payload, lo
|
|
|
106
111
|
if (charge.creditedAt) {
|
|
107
112
|
return; // Already fully paid and credited
|
|
108
113
|
}
|
|
109
|
-
|
|
114
|
+
const { confirmations, confirmationsRequired, amountReceivedCents, txHash } = payload;
|
|
115
|
+
// Accumulate: add this payment to the running total (if nativeAmount > 0)
|
|
110
116
|
const prevReceived = BigInt(charge.receivedAmount ?? "0");
|
|
111
117
|
const thisPayment = BigInt(nativeAmount);
|
|
112
118
|
const totalReceived = (prevReceived + thisPayment).toString();
|
|
113
119
|
const expected = BigInt(charge.expectedAmount ?? "0");
|
|
114
120
|
const isFull = expected > 0n && BigInt(totalReceived) >= expected;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
const isConfirmed = isFull && confirmations >= confirmationsRequired;
|
|
122
|
+
// Update received_amount in DB (only when there's a new payment)
|
|
123
|
+
if (thisPayment > 0n) {
|
|
124
|
+
await db
|
|
125
|
+
.update(cryptoCharges)
|
|
126
|
+
.set({ receivedAmount: totalReceived, filledAmount: totalReceived })
|
|
127
|
+
.where(eq(cryptoCharges.referenceId, charge.referenceId));
|
|
128
|
+
}
|
|
129
|
+
// Determine canonical status
|
|
130
|
+
const status = isConfirmed ? "confirmed" : "partial";
|
|
131
|
+
// Update progress via new API
|
|
132
|
+
await chargeStore.updateProgress(charge.referenceId, {
|
|
133
|
+
status,
|
|
134
|
+
amountReceivedCents,
|
|
135
|
+
confirmations,
|
|
136
|
+
confirmationsRequired,
|
|
137
|
+
txHash,
|
|
138
|
+
});
|
|
139
|
+
if (isConfirmed) {
|
|
123
140
|
await chargeStore.markCredited(charge.referenceId);
|
|
124
|
-
log("Charge
|
|
141
|
+
log("Charge confirmed", {
|
|
142
|
+
chargeId: charge.referenceId,
|
|
143
|
+
confirmations,
|
|
144
|
+
confirmationsRequired,
|
|
145
|
+
});
|
|
125
146
|
}
|
|
126
147
|
else {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
148
|
+
log("Payment progress", {
|
|
149
|
+
chargeId: charge.referenceId,
|
|
150
|
+
confirmations,
|
|
151
|
+
confirmationsRequired,
|
|
152
|
+
received: totalReceived,
|
|
153
|
+
});
|
|
130
154
|
}
|
|
131
|
-
// Webhook on every
|
|
155
|
+
// Webhook on every event — product shows confirmation progress to user
|
|
132
156
|
if (charge.callbackUrl) {
|
|
133
157
|
await enqueueWebhook(db, charge.referenceId, charge.callbackUrl, {
|
|
134
158
|
chargeId: charge.referenceId,
|
|
135
159
|
chain: charge.chain,
|
|
136
160
|
address: charge.depositAddress,
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
161
|
+
amountExpectedCents: charge.amountUsdCents,
|
|
162
|
+
amountReceivedCents,
|
|
163
|
+
confirmations,
|
|
164
|
+
confirmationsRequired,
|
|
165
|
+
txHash,
|
|
166
|
+
status,
|
|
142
167
|
});
|
|
143
168
|
}
|
|
144
169
|
}
|
|
@@ -181,11 +206,19 @@ export async function startWatchers(opts) {
|
|
|
181
206
|
oracle,
|
|
182
207
|
cursorStore,
|
|
183
208
|
onPayment: async (event) => {
|
|
184
|
-
log("UTXO payment", {
|
|
185
|
-
|
|
209
|
+
log("UTXO payment", {
|
|
210
|
+
chain: method.chain,
|
|
211
|
+
address: event.address,
|
|
212
|
+
txid: event.txid,
|
|
213
|
+
sats: event.amountSats,
|
|
214
|
+
confirmations: event.confirmations,
|
|
215
|
+
confirmationsRequired: event.confirmationsRequired,
|
|
216
|
+
});
|
|
186
217
|
await handlePayment(db, chargeStore, event.address, String(event.amountSats), {
|
|
187
218
|
txHash: event.txid,
|
|
188
219
|
confirmations: event.confirmations,
|
|
220
|
+
confirmationsRequired: event.confirmationsRequired,
|
|
221
|
+
amountReceivedCents: event.amountUsdCents,
|
|
189
222
|
}, log);
|
|
190
223
|
},
|
|
191
224
|
});
|
|
@@ -247,11 +280,19 @@ export async function startWatchers(opts) {
|
|
|
247
280
|
watchedAddresses: chainAddresses,
|
|
248
281
|
cursorStore,
|
|
249
282
|
onPayment: async (event) => {
|
|
250
|
-
log("EVM payment", {
|
|
251
|
-
|
|
283
|
+
log("EVM payment", {
|
|
284
|
+
chain: event.chain,
|
|
285
|
+
token: event.token,
|
|
286
|
+
to: event.to,
|
|
287
|
+
txHash: event.txHash,
|
|
288
|
+
confirmations: event.confirmations,
|
|
289
|
+
confirmationsRequired: event.confirmationsRequired,
|
|
290
|
+
});
|
|
252
291
|
await handlePayment(db, chargeStore, event.to, event.rawAmount, {
|
|
253
292
|
txHash: event.txHash,
|
|
254
|
-
confirmations:
|
|
293
|
+
confirmations: event.confirmations,
|
|
294
|
+
confirmationsRequired: event.confirmationsRequired,
|
|
295
|
+
amountReceivedCents: event.amountUsdCents,
|
|
255
296
|
}, log);
|
|
256
297
|
},
|
|
257
298
|
});
|
|
@@ -282,6 +282,74 @@ export declare const cryptoCharges: import("drizzle-orm/pg-core").PgTableWithCol
|
|
|
282
282
|
identity: undefined;
|
|
283
283
|
generated: undefined;
|
|
284
284
|
}, {}, {}>;
|
|
285
|
+
confirmations: import("drizzle-orm/pg-core").PgColumn<{
|
|
286
|
+
name: "confirmations";
|
|
287
|
+
tableName: "crypto_charges";
|
|
288
|
+
dataType: "number";
|
|
289
|
+
columnType: "PgInteger";
|
|
290
|
+
data: number;
|
|
291
|
+
driverParam: string | number;
|
|
292
|
+
notNull: true;
|
|
293
|
+
hasDefault: true;
|
|
294
|
+
isPrimaryKey: false;
|
|
295
|
+
isAutoincrement: false;
|
|
296
|
+
hasRuntimeDefault: false;
|
|
297
|
+
enumValues: undefined;
|
|
298
|
+
baseColumn: never;
|
|
299
|
+
identity: undefined;
|
|
300
|
+
generated: undefined;
|
|
301
|
+
}, {}, {}>;
|
|
302
|
+
confirmationsRequired: import("drizzle-orm/pg-core").PgColumn<{
|
|
303
|
+
name: "confirmations_required";
|
|
304
|
+
tableName: "crypto_charges";
|
|
305
|
+
dataType: "number";
|
|
306
|
+
columnType: "PgInteger";
|
|
307
|
+
data: number;
|
|
308
|
+
driverParam: string | number;
|
|
309
|
+
notNull: true;
|
|
310
|
+
hasDefault: true;
|
|
311
|
+
isPrimaryKey: false;
|
|
312
|
+
isAutoincrement: false;
|
|
313
|
+
hasRuntimeDefault: false;
|
|
314
|
+
enumValues: undefined;
|
|
315
|
+
baseColumn: never;
|
|
316
|
+
identity: undefined;
|
|
317
|
+
generated: undefined;
|
|
318
|
+
}, {}, {}>;
|
|
319
|
+
txHash: import("drizzle-orm/pg-core").PgColumn<{
|
|
320
|
+
name: "tx_hash";
|
|
321
|
+
tableName: "crypto_charges";
|
|
322
|
+
dataType: "string";
|
|
323
|
+
columnType: "PgText";
|
|
324
|
+
data: string;
|
|
325
|
+
driverParam: string;
|
|
326
|
+
notNull: false;
|
|
327
|
+
hasDefault: false;
|
|
328
|
+
isPrimaryKey: false;
|
|
329
|
+
isAutoincrement: false;
|
|
330
|
+
hasRuntimeDefault: false;
|
|
331
|
+
enumValues: [string, ...string[]];
|
|
332
|
+
baseColumn: never;
|
|
333
|
+
identity: undefined;
|
|
334
|
+
generated: undefined;
|
|
335
|
+
}, {}, {}>;
|
|
336
|
+
amountReceivedCents: import("drizzle-orm/pg-core").PgColumn<{
|
|
337
|
+
name: "amount_received_cents";
|
|
338
|
+
tableName: "crypto_charges";
|
|
339
|
+
dataType: "number";
|
|
340
|
+
columnType: "PgInteger";
|
|
341
|
+
data: number;
|
|
342
|
+
driverParam: string | number;
|
|
343
|
+
notNull: true;
|
|
344
|
+
hasDefault: true;
|
|
345
|
+
isPrimaryKey: false;
|
|
346
|
+
isAutoincrement: false;
|
|
347
|
+
hasRuntimeDefault: false;
|
|
348
|
+
enumValues: undefined;
|
|
349
|
+
baseColumn: never;
|
|
350
|
+
identity: undefined;
|
|
351
|
+
generated: undefined;
|
|
352
|
+
}, {}, {}>;
|
|
285
353
|
};
|
|
286
354
|
dialect: "pg";
|
|
287
355
|
}>;
|
package/dist/db/schema/crypto.js
CHANGED
|
@@ -27,6 +27,14 @@ export const cryptoCharges = pgTable("crypto_charges", {
|
|
|
27
27
|
expectedAmount: text("expected_amount"),
|
|
28
28
|
/** Running total of received crypto in native units. Accumulates across partial payments. */
|
|
29
29
|
receivedAmount: text("received_amount"),
|
|
30
|
+
/** Number of blockchain confirmations observed so far. */
|
|
31
|
+
confirmations: integer("confirmations").notNull().default(0),
|
|
32
|
+
/** Required confirmations for settlement (copied from payment method at creation). */
|
|
33
|
+
confirmationsRequired: integer("confirmations_required").notNull().default(1),
|
|
34
|
+
/** Blockchain transaction hash for the payment. */
|
|
35
|
+
txHash: text("tx_hash"),
|
|
36
|
+
/** Amount received so far in USD cents (integer). Converted from crypto at time of receipt. */
|
|
37
|
+
amountReceivedCents: integer("amount_received_cents").notNull().default(0),
|
|
30
38
|
}, (table) => [
|
|
31
39
|
index("idx_crypto_charges_tenant").on(table.tenantId),
|
|
32
40
|
index("idx_crypto_charges_status").on(table.status),
|
|
@@ -120,7 +120,8 @@ describe("handleCryptoWebhook (monetization layer)", () => {
|
|
|
120
120
|
it("updates charge status on every webhook call", async () => {
|
|
121
121
|
await handleCryptoWebhook(deps, makePayload({ status: "partial" }));
|
|
122
122
|
const charge = await chargeStore.getByReferenceId("chg-test-001");
|
|
123
|
-
|
|
123
|
+
// DB stores legacy status values; "partial" maps to "Processing" internally
|
|
124
|
+
expect(charge?.status).toBe("Processing");
|
|
124
125
|
});
|
|
125
126
|
it("settles charge when status is confirmed", async () => {
|
|
126
127
|
await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
ALTER TABLE "crypto_charges" ADD COLUMN "confirmations" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
|
2
|
+
ALTER TABLE "crypto_charges" ADD COLUMN "confirmations_required" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
|
|
3
|
+
ALTER TABLE "crypto_charges" ADD COLUMN "tx_hash" text;--> statement-breakpoint
|
|
4
|
+
ALTER TABLE "crypto_charges" ADD COLUMN "amount_received_cents" integer DEFAULT 0 NOT NULL;
|