@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
|
@@ -66,7 +66,12 @@ describe("EvmWatcher", () => {
|
|
|
66
66
|
|
|
67
67
|
it("skips blocks not yet confirmed", async () => {
|
|
68
68
|
const events: unknown[] = [];
|
|
69
|
-
|
|
69
|
+
// latest = 50, cursor = 50 → latest < cursor is false, but range is empty (50..50)
|
|
70
|
+
// With intermediate confirmations, we still scan the range but find no logs
|
|
71
|
+
const mockRpc = vi
|
|
72
|
+
.fn()
|
|
73
|
+
.mockResolvedValueOnce(`0x${(50).toString(16)}`) // eth_blockNumber
|
|
74
|
+
.mockResolvedValueOnce([]); // eth_getLogs (empty)
|
|
70
75
|
|
|
71
76
|
const watcher = new EvmWatcher({
|
|
72
77
|
chain: "base",
|
|
@@ -81,7 +86,6 @@ describe("EvmWatcher", () => {
|
|
|
81
86
|
|
|
82
87
|
await watcher.poll();
|
|
83
88
|
expect(events).toHaveLength(0);
|
|
84
|
-
expect(mockRpc).toHaveBeenCalledTimes(1);
|
|
85
89
|
});
|
|
86
90
|
|
|
87
91
|
it("processes multiple logs in one poll", async () => {
|
|
@@ -6,7 +6,7 @@ import type { EvmChain } from "./types.js";
|
|
|
6
6
|
|
|
7
7
|
type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
|
|
8
8
|
|
|
9
|
-
/** Event emitted
|
|
9
|
+
/** Event emitted on each confirmation increment for a native ETH deposit. */
|
|
10
10
|
export interface EthPaymentEvent {
|
|
11
11
|
readonly chain: EvmChain;
|
|
12
12
|
readonly from: string;
|
|
@@ -17,6 +17,10 @@ export interface EthPaymentEvent {
|
|
|
17
17
|
readonly amountUsdCents: number;
|
|
18
18
|
readonly txHash: string;
|
|
19
19
|
readonly blockNumber: number;
|
|
20
|
+
/** Current confirmation count (latest block - tx block). */
|
|
21
|
+
readonly confirmations: number;
|
|
22
|
+
/** Required confirmations for this chain. */
|
|
23
|
+
readonly confirmationsRequired: number;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
export interface EthWatcherOpts {
|
|
@@ -44,9 +48,9 @@ interface RpcTransaction {
|
|
|
44
48
|
* this scans blocks for transactions where `to` matches a watched deposit
|
|
45
49
|
* address and `value > 0`.
|
|
46
50
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
51
|
+
* Scans up to latest block (not just confirmed) to detect pending txs.
|
|
52
|
+
* Emits events on each confirmation increment. Only advances cursor
|
|
53
|
+
* past fully-confirmed blocks.
|
|
50
54
|
*/
|
|
51
55
|
export class EthWatcher {
|
|
52
56
|
private _cursor: number;
|
|
@@ -87,11 +91,10 @@ export class EthWatcher {
|
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
/**
|
|
90
|
-
* Poll for
|
|
94
|
+
* Poll for native ETH transfers to watched addresses, including unconfirmed blocks.
|
|
91
95
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* the cursor hasn't advanced — the entire block is retried on next poll.
|
|
96
|
+
* Scans from cursor to latest block. Emits events with current confirmation count.
|
|
97
|
+
* Re-emits on each confirmation increment. Only advances cursor past fully-confirmed blocks.
|
|
95
98
|
*/
|
|
96
99
|
async poll(): Promise<void> {
|
|
97
100
|
if (this._watchedAddresses.size === 0) return;
|
|
@@ -100,17 +103,20 @@ export class EthWatcher {
|
|
|
100
103
|
const latest = Number.parseInt(latestHex, 16);
|
|
101
104
|
const confirmed = latest - this.confirmations;
|
|
102
105
|
|
|
103
|
-
if (
|
|
106
|
+
if (latest < this._cursor) return;
|
|
104
107
|
|
|
105
108
|
const { priceCents } = await this.oracle.getPrice("ETH");
|
|
106
109
|
|
|
107
|
-
|
|
110
|
+
// Scan up to latest (not just confirmed) to detect pending txs
|
|
111
|
+
for (let blockNum = this._cursor; blockNum <= latest; blockNum++) {
|
|
108
112
|
const block = (await this.rpc("eth_getBlockByNumber", [`0x${blockNum.toString(16)}`, true])) as {
|
|
109
113
|
transactions: RpcTransaction[];
|
|
110
114
|
} | null;
|
|
111
115
|
|
|
112
116
|
if (!block) continue;
|
|
113
117
|
|
|
118
|
+
const confs = latest - blockNum;
|
|
119
|
+
|
|
114
120
|
for (const tx of block.transactions) {
|
|
115
121
|
if (!tx.to) continue;
|
|
116
122
|
const to = tx.to.toLowerCase();
|
|
@@ -119,6 +125,12 @@ export class EthWatcher {
|
|
|
119
125
|
const valueWei = BigInt(tx.value);
|
|
120
126
|
if (valueWei === 0n) continue;
|
|
121
127
|
|
|
128
|
+
// Skip if we already emitted at this confirmation count
|
|
129
|
+
if (this.cursorStore) {
|
|
130
|
+
const lastConf = await this.cursorStore.getConfirmationCount(this.watcherId, tx.hash);
|
|
131
|
+
if (lastConf !== null && confs <= lastConf) continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
122
134
|
const amountUsdCents = nativeToCents(valueWei, priceCents, 18);
|
|
123
135
|
|
|
124
136
|
const event: EthPaymentEvent = {
|
|
@@ -129,15 +141,23 @@ export class EthWatcher {
|
|
|
129
141
|
amountUsdCents,
|
|
130
142
|
txHash: tx.hash,
|
|
131
143
|
blockNumber: blockNum,
|
|
144
|
+
confirmations: confs,
|
|
145
|
+
confirmationsRequired: this.confirmations,
|
|
132
146
|
};
|
|
133
147
|
|
|
134
148
|
await this.onPayment(event);
|
|
149
|
+
|
|
150
|
+
if (this.cursorStore) {
|
|
151
|
+
await this.cursorStore.saveConfirmationCount(this.watcherId, tx.hash, confs);
|
|
152
|
+
}
|
|
135
153
|
}
|
|
136
154
|
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
155
|
+
// Only advance cursor past fully-confirmed blocks
|
|
156
|
+
if (blockNum <= confirmed) {
|
|
157
|
+
this._cursor = blockNum + 1;
|
|
158
|
+
if (this.cursorStore) {
|
|
159
|
+
await this.cursorStore.save(this.watcherId, this._cursor);
|
|
160
|
+
}
|
|
141
161
|
}
|
|
142
162
|
}
|
|
143
163
|
}
|
|
@@ -21,7 +21,7 @@ export interface TokenConfig {
|
|
|
21
21
|
readonly decimals: number;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
/** Event emitted
|
|
24
|
+
/** Event emitted on each confirmation increment for a Transfer. */
|
|
25
25
|
export interface EvmPaymentEvent {
|
|
26
26
|
readonly chain: EvmChain;
|
|
27
27
|
readonly token: StablecoinToken;
|
|
@@ -34,6 +34,10 @@ export interface EvmPaymentEvent {
|
|
|
34
34
|
readonly txHash: string;
|
|
35
35
|
readonly blockNumber: number;
|
|
36
36
|
readonly logIndex: number;
|
|
37
|
+
/** Current confirmation count (latest block - tx block). */
|
|
38
|
+
readonly confirmations: number;
|
|
39
|
+
/** Required confirmations for this chain. */
|
|
40
|
+
readonly confirmationsRequired: number;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
/** Options for creating a stablecoin checkout. */
|
|
@@ -72,7 +72,15 @@ export class EvmWatcher {
|
|
|
72
72
|
return this._cursor;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Poll for Transfer events, including pending (unconfirmed) blocks.
|
|
77
|
+
*
|
|
78
|
+
* Two-phase scan:
|
|
79
|
+
* 1. Scan cursor..latest for new/updated txs, emit with current confirmation count
|
|
80
|
+
* 2. Re-check pending txs automatically since cursor doesn't advance past unconfirmed blocks
|
|
81
|
+
*
|
|
82
|
+
* Cursor only advances past fully-confirmed blocks.
|
|
83
|
+
*/
|
|
76
84
|
async poll(): Promise<void> {
|
|
77
85
|
if (this._watchedAddresses.length === 0) return; // nothing to watch
|
|
78
86
|
|
|
@@ -80,27 +88,25 @@ export class EvmWatcher {
|
|
|
80
88
|
const latest = Number.parseInt(latestHex, 16);
|
|
81
89
|
const confirmed = latest - this.confirmations;
|
|
82
90
|
|
|
83
|
-
if (
|
|
91
|
+
if (latest < this._cursor) return;
|
|
84
92
|
|
|
85
93
|
// Filter by topic[2] (to address) when watched addresses are set.
|
|
86
|
-
// This avoids fetching ALL USDC transfers on the chain (millions/day on Base).
|
|
87
|
-
// topic[2] values are 32-byte zero-padded: 0x000000000000000000000000<address>
|
|
88
94
|
const toFilter =
|
|
89
95
|
this._watchedAddresses.length > 0
|
|
90
96
|
? this._watchedAddresses.map((a) => `0x000000000000000000000000${a.slice(2)}`)
|
|
91
97
|
: null;
|
|
92
98
|
|
|
99
|
+
// Scan from cursor to latest (not just confirmed) to detect pending txs
|
|
93
100
|
const logs = (await this.rpc("eth_getLogs", [
|
|
94
101
|
{
|
|
95
102
|
address: this.contractAddress,
|
|
96
103
|
topics: [TRANSFER_TOPIC, null, toFilter],
|
|
97
104
|
fromBlock: `0x${this._cursor.toString(16)}`,
|
|
98
|
-
toBlock: `0x${
|
|
105
|
+
toBlock: `0x${latest.toString(16)}`,
|
|
99
106
|
},
|
|
100
107
|
])) as RpcLog[];
|
|
101
108
|
|
|
102
|
-
// Group logs by block
|
|
103
|
-
// If onPayment fails mid-batch, only the current block is replayed on next poll.
|
|
109
|
+
// Group logs by block
|
|
104
110
|
const logsByBlock = new Map<number, RpcLog[]>();
|
|
105
111
|
for (const log of logs) {
|
|
106
112
|
const bn = Number.parseInt(log.blockNumber, 16);
|
|
@@ -109,10 +115,20 @@ export class EvmWatcher {
|
|
|
109
115
|
else logsByBlock.set(bn, [log]);
|
|
110
116
|
}
|
|
111
117
|
|
|
112
|
-
// Process blocks
|
|
118
|
+
// Process all blocks (including unconfirmed), emit with confirmation count
|
|
113
119
|
const blockNums = [...logsByBlock.keys()].sort((a, b) => a - b);
|
|
114
120
|
for (const blockNum of blockNums) {
|
|
121
|
+
const confs = latest - blockNum;
|
|
122
|
+
|
|
115
123
|
for (const log of logsByBlock.get(blockNum) ?? []) {
|
|
124
|
+
const txKey = `${log.transactionHash}:${log.logIndex}`;
|
|
125
|
+
|
|
126
|
+
// Skip if we already emitted at this confirmation count
|
|
127
|
+
if (this.cursorStore) {
|
|
128
|
+
const lastConf = await this.cursorStore.getConfirmationCount(this.watcherId, txKey);
|
|
129
|
+
if (lastConf !== null && confs <= lastConf) continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
116
132
|
const to = `0x${log.topics[2].slice(26)}`.toLowerCase();
|
|
117
133
|
const from = `0x${log.topics[1].slice(26)}`.toLowerCase();
|
|
118
134
|
const rawAmount = BigInt(log.data);
|
|
@@ -128,19 +144,29 @@ export class EvmWatcher {
|
|
|
128
144
|
txHash: log.transactionHash,
|
|
129
145
|
blockNumber: blockNum,
|
|
130
146
|
logIndex: Number.parseInt(log.logIndex, 16),
|
|
147
|
+
confirmations: confs,
|
|
148
|
+
confirmationsRequired: this.confirmations,
|
|
131
149
|
};
|
|
132
150
|
|
|
133
151
|
await this.onPayment(event);
|
|
152
|
+
|
|
153
|
+
// Track confirmation count
|
|
154
|
+
if (this.cursorStore) {
|
|
155
|
+
await this.cursorStore.saveConfirmationCount(this.watcherId, txKey, confs);
|
|
156
|
+
}
|
|
134
157
|
}
|
|
135
158
|
|
|
136
|
-
|
|
137
|
-
if (
|
|
138
|
-
|
|
159
|
+
// Only advance cursor past fully-confirmed blocks
|
|
160
|
+
if (blockNum <= confirmed) {
|
|
161
|
+
this._cursor = blockNum + 1;
|
|
162
|
+
if (this.cursorStore) {
|
|
163
|
+
await this.cursorStore.save(this.watcherId, this._cursor);
|
|
164
|
+
}
|
|
139
165
|
}
|
|
140
166
|
}
|
|
141
167
|
|
|
142
|
-
// Advance cursor
|
|
143
|
-
if (blockNums.length === 0) {
|
|
168
|
+
// Advance cursor if no logs found but confirmed blocks exist
|
|
169
|
+
if (blockNums.length === 0 && confirmed >= this._cursor) {
|
|
144
170
|
this._cursor = confirmed + 1;
|
|
145
171
|
if (this.cursorStore) {
|
|
146
172
|
await this.cursorStore.save(this.watcherId, this._cursor);
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export * from "./btc/index.js";
|
|
2
|
-
export type {
|
|
2
|
+
export type {
|
|
3
|
+
CryptoChargeProgressUpdate,
|
|
4
|
+
CryptoChargeRecord,
|
|
5
|
+
CryptoDepositChargeInput,
|
|
6
|
+
ICryptoChargeRepository,
|
|
7
|
+
} from "./charge-store.js";
|
|
3
8
|
export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-store.js";
|
|
4
9
|
export type {
|
|
5
10
|
ChainInfo,
|
|
@@ -20,10 +25,14 @@ export type {
|
|
|
20
25
|
KeyServerWebhookPayload as CryptoWebhookPayload,
|
|
21
26
|
KeyServerWebhookResult as CryptoWebhookResult,
|
|
22
27
|
} from "./key-server-webhook.js";
|
|
23
|
-
export {
|
|
28
|
+
export {
|
|
29
|
+
handleKeyServerWebhook,
|
|
30
|
+
handleKeyServerWebhook as handleCryptoWebhook,
|
|
31
|
+
normalizeStatus,
|
|
32
|
+
} from "./key-server-webhook.js";
|
|
24
33
|
export * from "./oracle/index.js";
|
|
25
34
|
export type { IPaymentMethodStore, PaymentMethodRecord } from "./payment-method-store.js";
|
|
26
35
|
export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
|
|
27
|
-
export type { CryptoPaymentState } from "./types.js";
|
|
36
|
+
export type { CryptoCharge, CryptoChargeStatus, CryptoPaymentState } from "./types.js";
|
|
28
37
|
export type { UnifiedCheckoutDeps, UnifiedCheckoutResult } from "./unified-checkout.js";
|
|
29
38
|
export { createUnifiedCheckout, MIN_CHECKOUT_USD as MIN_PAYMENT_USD, MIN_CHECKOUT_USD } from "./unified-checkout.js";
|
|
@@ -1,17 +1,23 @@
|
|
|
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.
|
|
@@ -20,16 +26,20 @@ import { Credit } from "../../credits/credit.js";
|
|
|
20
26
|
import type { ILedger } from "../../credits/ledger.js";
|
|
21
27
|
import type { IWebhookSeenRepository } from "../webhook-seen-repository.js";
|
|
22
28
|
import type { ICryptoChargeRepository } from "./charge-store.js";
|
|
29
|
+
import type { CryptoChargeStatus } from "./types.js";
|
|
23
30
|
|
|
24
31
|
export interface KeyServerWebhookPayload {
|
|
25
32
|
chargeId: string;
|
|
26
33
|
chain: string;
|
|
27
34
|
address: string;
|
|
28
|
-
|
|
35
|
+
/** @deprecated Use amountReceivedCents instead. Kept for one release cycle. */
|
|
36
|
+
amountUsdCents?: number;
|
|
37
|
+
amountReceivedCents?: number;
|
|
29
38
|
status: string;
|
|
30
39
|
txHash?: string;
|
|
31
40
|
amountReceived?: string;
|
|
32
41
|
confirmations?: number;
|
|
42
|
+
confirmationsRequired?: number;
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
export interface KeyServerWebhookDeps {
|
|
@@ -45,13 +55,49 @@ export interface KeyServerWebhookResult {
|
|
|
45
55
|
tenant?: string;
|
|
46
56
|
creditedCents?: number;
|
|
47
57
|
reactivatedBots?: string[];
|
|
58
|
+
status?: CryptoChargeStatus;
|
|
59
|
+
confirmations?: number;
|
|
60
|
+
confirmationsRequired?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Map legacy/watcher status strings to canonical CryptoChargeStatus.
|
|
65
|
+
* Accepts both old BTCPay-style ("Settled", "Processing") and new canonical ("confirmed", "partial").
|
|
66
|
+
*/
|
|
67
|
+
export function normalizeStatus(raw: string): CryptoChargeStatus {
|
|
68
|
+
switch (raw) {
|
|
69
|
+
case "confirmed":
|
|
70
|
+
case "Settled":
|
|
71
|
+
case "InvoiceSettled":
|
|
72
|
+
return "confirmed";
|
|
73
|
+
case "partial":
|
|
74
|
+
case "Processing":
|
|
75
|
+
case "InvoiceProcessing":
|
|
76
|
+
case "InvoiceReceivedPayment":
|
|
77
|
+
return "partial";
|
|
78
|
+
case "expired":
|
|
79
|
+
case "Expired":
|
|
80
|
+
case "InvoiceExpired":
|
|
81
|
+
return "expired";
|
|
82
|
+
case "failed":
|
|
83
|
+
case "Invalid":
|
|
84
|
+
case "InvoiceInvalid":
|
|
85
|
+
return "failed";
|
|
86
|
+
case "pending":
|
|
87
|
+
case "New":
|
|
88
|
+
case "InvoiceCreated":
|
|
89
|
+
return "pending";
|
|
90
|
+
default:
|
|
91
|
+
return "pending";
|
|
92
|
+
}
|
|
48
93
|
}
|
|
49
94
|
|
|
50
95
|
/**
|
|
51
|
-
* Process a payment
|
|
96
|
+
* Process a payment webhook from the crypto key server.
|
|
52
97
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
98
|
+
* Idempotency: deduplicate by chargeId + status + confirmations so that
|
|
99
|
+
* multiple progress updates (0→1→2→...→6 confirmations) each get through,
|
|
100
|
+
* but exact duplicates are rejected.
|
|
55
101
|
*/
|
|
56
102
|
export async function handleKeyServerWebhook(
|
|
57
103
|
deps: KeyServerWebhookDeps,
|
|
@@ -59,8 +105,15 @@ export async function handleKeyServerWebhook(
|
|
|
59
105
|
): Promise<KeyServerWebhookResult> {
|
|
60
106
|
const { chargeStore, creditLedger } = deps;
|
|
61
107
|
|
|
62
|
-
|
|
63
|
-
const
|
|
108
|
+
const status = normalizeStatus(payload.status);
|
|
109
|
+
const confirmations = payload.confirmations ?? 0;
|
|
110
|
+
const confirmationsRequired = payload.confirmationsRequired ?? 1;
|
|
111
|
+
// Support deprecated amountUsdCents field as fallback
|
|
112
|
+
const amountReceivedCents = payload.amountReceivedCents ?? payload.amountUsdCents ?? 0;
|
|
113
|
+
|
|
114
|
+
// Replay guard: deduplicate by chargeId + status + confirmations
|
|
115
|
+
// This allows multiple progress updates for the same charge
|
|
116
|
+
const dedupeKey = `ks:${payload.chargeId}:${status}:${confirmations}`;
|
|
64
117
|
if (await deps.replayGuard.isDuplicate(dedupeKey, "crypto")) {
|
|
65
118
|
return { handled: true, duplicate: true };
|
|
66
119
|
}
|
|
@@ -71,15 +124,36 @@ export async function handleKeyServerWebhook(
|
|
|
71
124
|
return { handled: false };
|
|
72
125
|
}
|
|
73
126
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
127
|
+
// Always update progress on every webhook
|
|
128
|
+
await chargeStore.updateProgress(payload.chargeId, {
|
|
129
|
+
status,
|
|
130
|
+
amountReceivedCents,
|
|
131
|
+
confirmations,
|
|
132
|
+
confirmationsRequired,
|
|
133
|
+
txHash: payload.txHash,
|
|
134
|
+
});
|
|
77
135
|
|
|
136
|
+
// Also call deprecated updateStatus for backward compat with downstream consumers
|
|
137
|
+
const legacyStatusMap: Record<CryptoChargeStatus, string> = {
|
|
138
|
+
pending: "New",
|
|
139
|
+
partial: "Processing",
|
|
140
|
+
confirmed: "Settled",
|
|
141
|
+
expired: "Expired",
|
|
142
|
+
failed: "Invalid",
|
|
143
|
+
};
|
|
144
|
+
await chargeStore.updateStatus(
|
|
145
|
+
payload.chargeId,
|
|
146
|
+
legacyStatusMap[status] as "Settled",
|
|
147
|
+
charge.token ?? undefined,
|
|
148
|
+
payload.amountReceived,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
if (status === "confirmed") {
|
|
78
152
|
// Idempotency: check ledger referenceId (atomic, same as BTCPay handler)
|
|
79
153
|
const creditRef = `crypto:${payload.chargeId}`;
|
|
80
154
|
if (await creditLedger.hasReferenceId(creditRef)) {
|
|
81
155
|
await deps.replayGuard.markSeen(dedupeKey, "crypto");
|
|
82
|
-
return { handled: true, duplicate: true, tenant: charge.tenantId };
|
|
156
|
+
return { handled: true, duplicate: true, tenant: charge.tenantId, status, confirmations, confirmationsRequired };
|
|
83
157
|
}
|
|
84
158
|
|
|
85
159
|
// Credit the original USD amount requested.
|
|
@@ -104,16 +178,13 @@ export async function handleKeyServerWebhook(
|
|
|
104
178
|
tenant: charge.tenantId,
|
|
105
179
|
creditedCents: charge.amountUsdCents,
|
|
106
180
|
reactivatedBots,
|
|
181
|
+
status,
|
|
182
|
+
confirmations,
|
|
183
|
+
confirmationsRequired,
|
|
107
184
|
};
|
|
108
185
|
}
|
|
109
186
|
|
|
110
|
-
// Non-confirmed status —
|
|
111
|
-
await chargeStore.updateStatus(
|
|
112
|
-
payload.chargeId,
|
|
113
|
-
payload.status as "Processing",
|
|
114
|
-
charge.token ?? undefined,
|
|
115
|
-
payload.amountReceived,
|
|
116
|
-
);
|
|
187
|
+
// Non-confirmed status — progress already updated above, no credit
|
|
117
188
|
await deps.replayGuard.markSeen(dedupeKey, "crypto");
|
|
118
|
-
return { handled: true, tenant: charge.tenantId };
|
|
189
|
+
return { handled: true, tenant: charge.tenantId, status, confirmations, confirmationsRequired };
|
|
119
190
|
}
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
/** BTCPay Server invoice states (Greenfield API v1). */
|
|
2
2
|
export type CryptoPaymentState = "New" | "Processing" | "Expired" | "Invalid" | "Settled";
|
|
3
3
|
|
|
4
|
+
/** Charge status for the UI-facing payment lifecycle. */
|
|
5
|
+
export type CryptoChargeStatus = "pending" | "partial" | "confirmed" | "expired" | "failed";
|
|
6
|
+
|
|
7
|
+
/** Full charge record for UI display — includes partial payment progress and confirmations. */
|
|
8
|
+
export interface CryptoCharge {
|
|
9
|
+
id: string;
|
|
10
|
+
tenantId: string;
|
|
11
|
+
chain: string;
|
|
12
|
+
status: CryptoChargeStatus;
|
|
13
|
+
amountExpectedCents: number;
|
|
14
|
+
amountReceivedCents: number;
|
|
15
|
+
confirmations: number;
|
|
16
|
+
confirmationsRequired: number;
|
|
17
|
+
txHash?: string;
|
|
18
|
+
credited: boolean;
|
|
19
|
+
createdAt: Date;
|
|
20
|
+
}
|
|
21
|
+
|
|
4
22
|
/** Options for creating a crypto payment session. */
|
|
5
23
|
export interface CryptoCheckoutOpts {
|
|
6
24
|
/** Internal tenant ID. */
|