@wopr-network/platform-core 1.23.0 → 1.25.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/btc/watcher.d.ts +5 -1
- package/dist/billing/crypto/btc/watcher.js +8 -4
- package/dist/billing/crypto/cursor-store.d.ts +28 -0
- package/dist/billing/crypto/cursor-store.js +43 -0
- package/dist/billing/crypto/evm/eth-watcher.d.ts +12 -4
- package/dist/billing/crypto/evm/eth-watcher.js +23 -9
- package/dist/billing/crypto/evm/watcher.d.ts +6 -0
- package/dist/billing/crypto/evm/watcher.js +54 -17
- package/dist/billing/crypto/index.d.ts +2 -0
- package/dist/billing/crypto/index.js +1 -0
- package/dist/db/schema/crypto.d.ts +121 -0
- package/dist/db/schema/crypto.js +16 -1
- package/dist/fleet/__tests__/init-fleet-updater.test.d.ts +1 -0
- package/dist/fleet/__tests__/init-fleet-updater.test.js +93 -0
- package/dist/fleet/index.d.ts +1 -0
- package/dist/fleet/index.js +1 -0
- package/dist/fleet/init-fleet-updater.d.ts +58 -0
- package/dist/fleet/init-fleet-updater.js +88 -0
- package/drizzle/migrations/0007_watcher_cursors.sql +12 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/billing/crypto/btc/watcher.ts +11 -4
- package/src/billing/crypto/cursor-store.ts +61 -0
- package/src/billing/crypto/evm/eth-watcher.ts +25 -10
- package/src/billing/crypto/evm/watcher.ts +57 -19
- package/src/billing/crypto/index.ts +2 -0
- package/src/db/schema/crypto.ts +22 -1
- package/src/fleet/__tests__/init-fleet-updater.test.ts +129 -0
- package/src/fleet/index.ts +1 -0
- package/src/fleet/init-fleet-updater.ts +134 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { IWatcherCursorStore } from "../cursor-store.js";
|
|
1
2
|
import type { IPriceOracle } from "../oracle/types.js";
|
|
2
3
|
import type { BitcoindConfig, BtcPaymentEvent } from "./types.js";
|
|
3
4
|
type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
|
|
@@ -9,6 +10,8 @@ export interface BtcWatcherOpts {
|
|
|
9
10
|
onPayment: (event: BtcPaymentEvent) => void | Promise<void>;
|
|
10
11
|
/** Price oracle for BTC/USD conversion. */
|
|
11
12
|
oracle: IPriceOracle;
|
|
13
|
+
/** Required — BTC has no block cursor, so txid dedup must be persisted. */
|
|
14
|
+
cursorStore: IWatcherCursorStore;
|
|
12
15
|
}
|
|
13
16
|
export declare class BtcWatcher {
|
|
14
17
|
private readonly rpc;
|
|
@@ -16,7 +19,8 @@ export declare class BtcWatcher {
|
|
|
16
19
|
private readonly onPayment;
|
|
17
20
|
private readonly minConfirmations;
|
|
18
21
|
private readonly oracle;
|
|
19
|
-
private readonly
|
|
22
|
+
private readonly cursorStore;
|
|
23
|
+
private readonly watcherId;
|
|
20
24
|
constructor(opts: BtcWatcherOpts);
|
|
21
25
|
/** Update the set of watched addresses. */
|
|
22
26
|
setWatchedAddresses(addresses: string[]): void;
|
|
@@ -4,13 +4,16 @@ export class BtcWatcher {
|
|
|
4
4
|
onPayment;
|
|
5
5
|
minConfirmations;
|
|
6
6
|
oracle;
|
|
7
|
-
|
|
7
|
+
cursorStore;
|
|
8
|
+
watcherId;
|
|
8
9
|
constructor(opts) {
|
|
9
10
|
this.rpc = opts.rpcCall;
|
|
10
11
|
this.addresses = new Set(opts.watchedAddresses);
|
|
11
12
|
this.onPayment = opts.onPayment;
|
|
12
13
|
this.minConfirmations = opts.config.confirmations;
|
|
13
14
|
this.oracle = opts.oracle;
|
|
15
|
+
this.cursorStore = opts.cursorStore;
|
|
16
|
+
this.watcherId = `btc:${opts.config.network}`;
|
|
14
17
|
}
|
|
15
18
|
/** Update the set of watched addresses. */
|
|
16
19
|
setWatchedAddresses(addresses) {
|
|
@@ -37,7 +40,8 @@ export class BtcWatcher {
|
|
|
37
40
|
if (!this.addresses.has(entry.address))
|
|
38
41
|
continue;
|
|
39
42
|
for (const txid of entry.txids) {
|
|
40
|
-
|
|
43
|
+
// Skip already-processed txids (persisted to DB, survives restart)
|
|
44
|
+
if (await this.cursorStore.hasProcessedTx(this.watcherId, txid))
|
|
41
45
|
continue;
|
|
42
46
|
// Get transaction details for the exact amount sent to this address
|
|
43
47
|
const tx = (await this.rpc("gettransaction", [txid, true]));
|
|
@@ -55,8 +59,8 @@ export class BtcWatcher {
|
|
|
55
59
|
confirmations: tx.confirmations,
|
|
56
60
|
};
|
|
57
61
|
await this.onPayment(event);
|
|
58
|
-
//
|
|
59
|
-
this.
|
|
62
|
+
// Persist AFTER successful onPayment — survives restart, no unbounded memory
|
|
63
|
+
await this.cursorStore.markProcessedTx(this.watcherId, txid);
|
|
60
64
|
}
|
|
61
65
|
}
|
|
62
66
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PlatformDb } from "../../db/index.js";
|
|
2
|
+
export interface IWatcherCursorStore {
|
|
3
|
+
/** Get persisted block cursor for a watcher. */
|
|
4
|
+
get(watcherId: string): Promise<number | null>;
|
|
5
|
+
/** Save block cursor after processing a range. */
|
|
6
|
+
save(watcherId: string, cursorBlock: number): Promise<void>;
|
|
7
|
+
/** Check if a specific tx has been processed (for watchers without block cursors). */
|
|
8
|
+
hasProcessedTx(watcherId: string, txId: string): Promise<boolean>;
|
|
9
|
+
/** Mark a tx as processed (for watchers without block cursors). */
|
|
10
|
+
markProcessedTx(watcherId: string, txId: string): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Persists watcher state to PostgreSQL.
|
|
14
|
+
*
|
|
15
|
+
* Two patterns:
|
|
16
|
+
* - Block cursor (EVM watchers): save/get cursor block number
|
|
17
|
+
* - Processed txids (BTC watcher): hasProcessedTx/markProcessedTx
|
|
18
|
+
*
|
|
19
|
+
* Eliminates all in-memory watcher state. Clean restart recovery.
|
|
20
|
+
*/
|
|
21
|
+
export declare class DrizzleWatcherCursorStore implements IWatcherCursorStore {
|
|
22
|
+
private readonly db;
|
|
23
|
+
constructor(db: PlatformDb);
|
|
24
|
+
get(watcherId: string): Promise<number | null>;
|
|
25
|
+
save(watcherId: string, cursorBlock: number): Promise<void>;
|
|
26
|
+
hasProcessedTx(watcherId: string, txId: string): Promise<boolean>;
|
|
27
|
+
markProcessedTx(watcherId: string, txId: string): Promise<void>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
2
|
+
import { watcherCursors, watcherProcessed } from "../../db/schema/crypto.js";
|
|
3
|
+
/**
|
|
4
|
+
* Persists watcher state to PostgreSQL.
|
|
5
|
+
*
|
|
6
|
+
* Two patterns:
|
|
7
|
+
* - Block cursor (EVM watchers): save/get cursor block number
|
|
8
|
+
* - Processed txids (BTC watcher): hasProcessedTx/markProcessedTx
|
|
9
|
+
*
|
|
10
|
+
* Eliminates all in-memory watcher state. Clean restart recovery.
|
|
11
|
+
*/
|
|
12
|
+
export class DrizzleWatcherCursorStore {
|
|
13
|
+
db;
|
|
14
|
+
constructor(db) {
|
|
15
|
+
this.db = db;
|
|
16
|
+
}
|
|
17
|
+
async get(watcherId) {
|
|
18
|
+
const row = (await this.db
|
|
19
|
+
.select({ cursorBlock: watcherCursors.cursorBlock })
|
|
20
|
+
.from(watcherCursors)
|
|
21
|
+
.where(eq(watcherCursors.watcherId, watcherId)))[0];
|
|
22
|
+
return row?.cursorBlock ?? null;
|
|
23
|
+
}
|
|
24
|
+
async save(watcherId, cursorBlock) {
|
|
25
|
+
await this.db
|
|
26
|
+
.insert(watcherCursors)
|
|
27
|
+
.values({ watcherId, cursorBlock })
|
|
28
|
+
.onConflictDoUpdate({
|
|
29
|
+
target: watcherCursors.watcherId,
|
|
30
|
+
set: { cursorBlock, updatedAt: sql `(now())` },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
async hasProcessedTx(watcherId, txId) {
|
|
34
|
+
const row = (await this.db
|
|
35
|
+
.select({ txId: watcherProcessed.txId })
|
|
36
|
+
.from(watcherProcessed)
|
|
37
|
+
.where(and(eq(watcherProcessed.watcherId, watcherId), eq(watcherProcessed.txId, txId))))[0];
|
|
38
|
+
return row !== undefined;
|
|
39
|
+
}
|
|
40
|
+
async markProcessedTx(watcherId, txId) {
|
|
41
|
+
await this.db.insert(watcherProcessed).values({ watcherId, txId }).onConflictDoNothing();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { IWatcherCursorStore } from "../cursor-store.js";
|
|
1
2
|
import type { IPriceOracle } from "../oracle/types.js";
|
|
2
3
|
import type { EvmChain } from "./types.js";
|
|
3
4
|
type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
|
|
@@ -20,6 +21,7 @@ export interface EthWatcherOpts {
|
|
|
20
21
|
fromBlock: number;
|
|
21
22
|
onPayment: (event: EthPaymentEvent) => void | Promise<void>;
|
|
22
23
|
watchedAddresses?: string[];
|
|
24
|
+
cursorStore?: IWatcherCursorStore;
|
|
23
25
|
}
|
|
24
26
|
/**
|
|
25
27
|
* Native ETH transfer watcher.
|
|
@@ -28,7 +30,9 @@ export interface EthWatcherOpts {
|
|
|
28
30
|
* this scans blocks for transactions where `to` matches a watched deposit
|
|
29
31
|
* address and `value > 0`.
|
|
30
32
|
*
|
|
31
|
-
*
|
|
33
|
+
* Processes one block at a time and persists cursor after each block.
|
|
34
|
+
* On restart, resumes from the last committed cursor — no replay, no
|
|
35
|
+
* unbounded in-memory state.
|
|
32
36
|
*/
|
|
33
37
|
export declare class EthWatcher {
|
|
34
38
|
private _cursor;
|
|
@@ -37,16 +41,20 @@ export declare class EthWatcher {
|
|
|
37
41
|
private readonly oracle;
|
|
38
42
|
private readonly onPayment;
|
|
39
43
|
private readonly confirmations;
|
|
44
|
+
private readonly cursorStore?;
|
|
45
|
+
private readonly watcherId;
|
|
40
46
|
private _watchedAddresses;
|
|
41
|
-
private readonly processedTxids;
|
|
42
47
|
constructor(opts: EthWatcherOpts);
|
|
48
|
+
/** Load cursor from DB. Call once at startup before first poll. */
|
|
49
|
+
init(): Promise<void>;
|
|
43
50
|
setWatchedAddresses(addresses: string[]): void;
|
|
44
51
|
get cursor(): number;
|
|
45
52
|
/**
|
|
46
53
|
* Poll for new native ETH transfers to watched addresses.
|
|
47
54
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
55
|
+
* Processes one block at a time. After each block is fully processed,
|
|
56
|
+
* the cursor is persisted to the DB. If onPayment fails mid-block,
|
|
57
|
+
* the cursor hasn't advanced — the entire block is retried on next poll.
|
|
50
58
|
*/
|
|
51
59
|
poll(): Promise<void>;
|
|
52
60
|
}
|
|
@@ -7,7 +7,9 @@ import { getChainConfig } from "./config.js";
|
|
|
7
7
|
* this scans blocks for transactions where `to` matches a watched deposit
|
|
8
8
|
* address and `value > 0`.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
10
|
+
* Processes one block at a time and persists cursor after each block.
|
|
11
|
+
* On restart, resumes from the last committed cursor — no replay, no
|
|
12
|
+
* unbounded in-memory state.
|
|
11
13
|
*/
|
|
12
14
|
export class EthWatcher {
|
|
13
15
|
_cursor;
|
|
@@ -16,8 +18,9 @@ export class EthWatcher {
|
|
|
16
18
|
oracle;
|
|
17
19
|
onPayment;
|
|
18
20
|
confirmations;
|
|
21
|
+
cursorStore;
|
|
22
|
+
watcherId;
|
|
19
23
|
_watchedAddresses;
|
|
20
|
-
processedTxids = new Set();
|
|
21
24
|
constructor(opts) {
|
|
22
25
|
this.chain = opts.chain;
|
|
23
26
|
this.rpc = opts.rpcCall;
|
|
@@ -25,8 +28,18 @@ export class EthWatcher {
|
|
|
25
28
|
this._cursor = opts.fromBlock;
|
|
26
29
|
this.onPayment = opts.onPayment;
|
|
27
30
|
this.confirmations = getChainConfig(opts.chain).confirmations;
|
|
31
|
+
this.cursorStore = opts.cursorStore;
|
|
32
|
+
this.watcherId = `eth:${opts.chain}`;
|
|
28
33
|
this._watchedAddresses = new Set((opts.watchedAddresses ?? []).map((a) => a.toLowerCase()));
|
|
29
34
|
}
|
|
35
|
+
/** Load cursor from DB. Call once at startup before first poll. */
|
|
36
|
+
async init() {
|
|
37
|
+
if (!this.cursorStore)
|
|
38
|
+
return;
|
|
39
|
+
const saved = await this.cursorStore.get(this.watcherId);
|
|
40
|
+
if (saved !== null)
|
|
41
|
+
this._cursor = saved;
|
|
42
|
+
}
|
|
30
43
|
setWatchedAddresses(addresses) {
|
|
31
44
|
this._watchedAddresses = new Set(addresses.map((a) => a.toLowerCase()));
|
|
32
45
|
}
|
|
@@ -36,8 +49,9 @@ export class EthWatcher {
|
|
|
36
49
|
/**
|
|
37
50
|
* Poll for new native ETH transfers to watched addresses.
|
|
38
51
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
52
|
+
* Processes one block at a time. After each block is fully processed,
|
|
53
|
+
* the cursor is persisted to the DB. If onPayment fails mid-block,
|
|
54
|
+
* the cursor hasn't advanced — the entire block is retried on next poll.
|
|
41
55
|
*/
|
|
42
56
|
async poll() {
|
|
43
57
|
if (this._watchedAddresses.size === 0)
|
|
@@ -61,8 +75,6 @@ export class EthWatcher {
|
|
|
61
75
|
const valueWei = BigInt(tx.value);
|
|
62
76
|
if (valueWei === 0n)
|
|
63
77
|
continue;
|
|
64
|
-
if (this.processedTxids.has(tx.hash))
|
|
65
|
-
continue;
|
|
66
78
|
const amountUsdCents = nativeToCents(valueWei, priceCents, 18);
|
|
67
79
|
const event = {
|
|
68
80
|
chain: this.chain,
|
|
@@ -74,10 +86,12 @@ export class EthWatcher {
|
|
|
74
86
|
blockNumber: blockNum,
|
|
75
87
|
};
|
|
76
88
|
await this.onPayment(event);
|
|
77
|
-
|
|
78
|
-
|
|
89
|
+
}
|
|
90
|
+
// Block fully processed — persist cursor so we never re-scan it.
|
|
91
|
+
this._cursor = blockNum + 1;
|
|
92
|
+
if (this.cursorStore) {
|
|
93
|
+
await this.cursorStore.save(this.watcherId, this._cursor);
|
|
79
94
|
}
|
|
80
95
|
}
|
|
81
|
-
this._cursor = confirmed + 1;
|
|
82
96
|
}
|
|
83
97
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { IWatcherCursorStore } from "../cursor-store.js";
|
|
1
2
|
import type { EvmChain, EvmPaymentEvent, StablecoinToken } from "./types.js";
|
|
2
3
|
type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
|
|
3
4
|
export interface EvmWatcherOpts {
|
|
@@ -8,6 +9,7 @@ export interface EvmWatcherOpts {
|
|
|
8
9
|
onPayment: (event: EvmPaymentEvent) => void | Promise<void>;
|
|
9
10
|
/** Active deposit addresses to watch. Filters eth_getLogs by topic[2] (to address). */
|
|
10
11
|
watchedAddresses?: string[];
|
|
12
|
+
cursorStore?: IWatcherCursorStore;
|
|
11
13
|
}
|
|
12
14
|
export declare class EvmWatcher {
|
|
13
15
|
private _cursor;
|
|
@@ -18,8 +20,12 @@ export declare class EvmWatcher {
|
|
|
18
20
|
private readonly confirmations;
|
|
19
21
|
private readonly contractAddress;
|
|
20
22
|
private readonly decimals;
|
|
23
|
+
private readonly cursorStore?;
|
|
24
|
+
private readonly watcherId;
|
|
21
25
|
private _watchedAddresses;
|
|
22
26
|
constructor(opts: EvmWatcherOpts);
|
|
27
|
+
/** Load cursor from DB. Call once at startup before first poll. */
|
|
28
|
+
init(): Promise<void>;
|
|
23
29
|
/** Update the set of watched deposit addresses (e.g. after a new checkout). */
|
|
24
30
|
setWatchedAddresses(addresses: string[]): void;
|
|
25
31
|
get cursor(): number;
|
|
@@ -9,6 +9,8 @@ export class EvmWatcher {
|
|
|
9
9
|
confirmations;
|
|
10
10
|
contractAddress;
|
|
11
11
|
decimals;
|
|
12
|
+
cursorStore;
|
|
13
|
+
watcherId;
|
|
12
14
|
_watchedAddresses;
|
|
13
15
|
constructor(opts) {
|
|
14
16
|
this.chain = opts.chain;
|
|
@@ -16,6 +18,8 @@ export class EvmWatcher {
|
|
|
16
18
|
this.rpc = opts.rpcCall;
|
|
17
19
|
this._cursor = opts.fromBlock;
|
|
18
20
|
this.onPayment = opts.onPayment;
|
|
21
|
+
this.cursorStore = opts.cursorStore;
|
|
22
|
+
this.watcherId = `evm:${opts.chain}:${opts.token}`;
|
|
19
23
|
this._watchedAddresses = (opts.watchedAddresses ?? []).map((a) => a.toLowerCase());
|
|
20
24
|
const chainCfg = getChainConfig(opts.chain);
|
|
21
25
|
const tokenCfg = getTokenConfig(opts.token, opts.chain);
|
|
@@ -23,6 +27,14 @@ export class EvmWatcher {
|
|
|
23
27
|
this.contractAddress = tokenCfg.contractAddress.toLowerCase();
|
|
24
28
|
this.decimals = tokenCfg.decimals;
|
|
25
29
|
}
|
|
30
|
+
/** Load cursor from DB. Call once at startup before first poll. */
|
|
31
|
+
async init() {
|
|
32
|
+
if (!this.cursorStore)
|
|
33
|
+
return;
|
|
34
|
+
const saved = await this.cursorStore.get(this.watcherId);
|
|
35
|
+
if (saved !== null)
|
|
36
|
+
this._cursor = saved;
|
|
37
|
+
}
|
|
26
38
|
/** Update the set of watched deposit addresses (e.g. after a new checkout). */
|
|
27
39
|
setWatchedAddresses(addresses) {
|
|
28
40
|
this._watchedAddresses = addresses.map((a) => a.toLowerCase());
|
|
@@ -53,25 +65,50 @@ export class EvmWatcher {
|
|
|
53
65
|
toBlock: `0x${confirmed.toString(16)}`,
|
|
54
66
|
},
|
|
55
67
|
]));
|
|
68
|
+
// Group logs by block for incremental cursor checkpointing.
|
|
69
|
+
// If onPayment fails mid-batch, only the current block is replayed on next poll.
|
|
70
|
+
const logsByBlock = new Map();
|
|
56
71
|
for (const log of logs) {
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
const bn = Number.parseInt(log.blockNumber, 16);
|
|
73
|
+
const arr = logsByBlock.get(bn);
|
|
74
|
+
if (arr)
|
|
75
|
+
arr.push(log);
|
|
76
|
+
else
|
|
77
|
+
logsByBlock.set(bn, [log]);
|
|
78
|
+
}
|
|
79
|
+
// Process blocks in order, checkpoint after each.
|
|
80
|
+
const blockNums = [...logsByBlock.keys()].sort((a, b) => a - b);
|
|
81
|
+
for (const blockNum of blockNums) {
|
|
82
|
+
for (const log of logsByBlock.get(blockNum) ?? []) {
|
|
83
|
+
const to = `0x${log.topics[2].slice(26)}`.toLowerCase();
|
|
84
|
+
const from = `0x${log.topics[1].slice(26)}`.toLowerCase();
|
|
85
|
+
const rawAmount = BigInt(log.data);
|
|
86
|
+
const amountUsdCents = centsFromTokenAmount(rawAmount, this.decimals);
|
|
87
|
+
const event = {
|
|
88
|
+
chain: this.chain,
|
|
89
|
+
token: this.token,
|
|
90
|
+
from,
|
|
91
|
+
to,
|
|
92
|
+
rawAmount: rawAmount.toString(),
|
|
93
|
+
amountUsdCents,
|
|
94
|
+
txHash: log.transactionHash,
|
|
95
|
+
blockNumber: blockNum,
|
|
96
|
+
logIndex: Number.parseInt(log.logIndex, 16),
|
|
97
|
+
};
|
|
98
|
+
await this.onPayment(event);
|
|
99
|
+
}
|
|
100
|
+
this._cursor = blockNum + 1;
|
|
101
|
+
if (this.cursorStore) {
|
|
102
|
+
await this.cursorStore.save(this.watcherId, this._cursor);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Advance cursor even if no logs were found in the range.
|
|
106
|
+
if (blockNums.length === 0) {
|
|
107
|
+
this._cursor = confirmed + 1;
|
|
108
|
+
if (this.cursorStore) {
|
|
109
|
+
await this.cursorStore.save(this.watcherId, this._cursor);
|
|
110
|
+
}
|
|
73
111
|
}
|
|
74
|
-
this._cursor = confirmed + 1;
|
|
75
112
|
}
|
|
76
113
|
}
|
|
77
114
|
/** Create an RPC caller for a given URL (plain JSON-RPC over fetch). */
|
|
@@ -4,6 +4,8 @@ export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-
|
|
|
4
4
|
export { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
|
|
5
5
|
export type { CryptoConfig } from "./client.js";
|
|
6
6
|
export { BTCPayClient, loadCryptoConfig } from "./client.js";
|
|
7
|
+
export type { IWatcherCursorStore } from "./cursor-store.js";
|
|
8
|
+
export { DrizzleWatcherCursorStore } from "./cursor-store.js";
|
|
7
9
|
export * from "./evm/index.js";
|
|
8
10
|
export * from "./oracle/index.js";
|
|
9
11
|
export type { CryptoBillingConfig, CryptoCheckoutOpts, CryptoPaymentState, CryptoWebhookPayload, CryptoWebhookResult, } from "./types.js";
|
|
@@ -2,6 +2,7 @@ export * from "./btc/index.js";
|
|
|
2
2
|
export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-store.js";
|
|
3
3
|
export { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
|
|
4
4
|
export { BTCPayClient, loadCryptoConfig } from "./client.js";
|
|
5
|
+
export { DrizzleWatcherCursorStore } from "./cursor-store.js";
|
|
5
6
|
export * from "./evm/index.js";
|
|
6
7
|
export * from "./oracle/index.js";
|
|
7
8
|
export { mapBtcPayEventToStatus } from "./types.js";
|
|
@@ -234,3 +234,124 @@ export declare const cryptoCharges: import("drizzle-orm/pg-core").PgTableWithCol
|
|
|
234
234
|
};
|
|
235
235
|
dialect: "pg";
|
|
236
236
|
}>;
|
|
237
|
+
/**
|
|
238
|
+
* Watcher cursor persistence — tracks the last processed block per watcher.
|
|
239
|
+
* Eliminates in-memory processedTxids and enables clean restart recovery.
|
|
240
|
+
*/
|
|
241
|
+
export declare const watcherCursors: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
242
|
+
name: "watcher_cursors";
|
|
243
|
+
schema: undefined;
|
|
244
|
+
columns: {
|
|
245
|
+
watcherId: import("drizzle-orm/pg-core").PgColumn<{
|
|
246
|
+
name: "watcher_id";
|
|
247
|
+
tableName: "watcher_cursors";
|
|
248
|
+
dataType: "string";
|
|
249
|
+
columnType: "PgText";
|
|
250
|
+
data: string;
|
|
251
|
+
driverParam: string;
|
|
252
|
+
notNull: true;
|
|
253
|
+
hasDefault: false;
|
|
254
|
+
isPrimaryKey: true;
|
|
255
|
+
isAutoincrement: false;
|
|
256
|
+
hasRuntimeDefault: false;
|
|
257
|
+
enumValues: [string, ...string[]];
|
|
258
|
+
baseColumn: never;
|
|
259
|
+
identity: undefined;
|
|
260
|
+
generated: undefined;
|
|
261
|
+
}, {}, {}>;
|
|
262
|
+
cursorBlock: import("drizzle-orm/pg-core").PgColumn<{
|
|
263
|
+
name: "cursor_block";
|
|
264
|
+
tableName: "watcher_cursors";
|
|
265
|
+
dataType: "number";
|
|
266
|
+
columnType: "PgInteger";
|
|
267
|
+
data: number;
|
|
268
|
+
driverParam: string | number;
|
|
269
|
+
notNull: true;
|
|
270
|
+
hasDefault: false;
|
|
271
|
+
isPrimaryKey: false;
|
|
272
|
+
isAutoincrement: false;
|
|
273
|
+
hasRuntimeDefault: false;
|
|
274
|
+
enumValues: undefined;
|
|
275
|
+
baseColumn: never;
|
|
276
|
+
identity: undefined;
|
|
277
|
+
generated: undefined;
|
|
278
|
+
}, {}, {}>;
|
|
279
|
+
updatedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
280
|
+
name: "updated_at";
|
|
281
|
+
tableName: "watcher_cursors";
|
|
282
|
+
dataType: "string";
|
|
283
|
+
columnType: "PgText";
|
|
284
|
+
data: string;
|
|
285
|
+
driverParam: string;
|
|
286
|
+
notNull: true;
|
|
287
|
+
hasDefault: true;
|
|
288
|
+
isPrimaryKey: false;
|
|
289
|
+
isAutoincrement: false;
|
|
290
|
+
hasRuntimeDefault: false;
|
|
291
|
+
enumValues: [string, ...string[]];
|
|
292
|
+
baseColumn: never;
|
|
293
|
+
identity: undefined;
|
|
294
|
+
generated: undefined;
|
|
295
|
+
}, {}, {}>;
|
|
296
|
+
};
|
|
297
|
+
dialect: "pg";
|
|
298
|
+
}>;
|
|
299
|
+
/** Processed transaction IDs for watchers without block cursors (e.g. BTC). */
|
|
300
|
+
export declare const watcherProcessed: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
301
|
+
name: "watcher_processed";
|
|
302
|
+
schema: undefined;
|
|
303
|
+
columns: {
|
|
304
|
+
watcherId: import("drizzle-orm/pg-core").PgColumn<{
|
|
305
|
+
name: "watcher_id";
|
|
306
|
+
tableName: "watcher_processed";
|
|
307
|
+
dataType: "string";
|
|
308
|
+
columnType: "PgText";
|
|
309
|
+
data: string;
|
|
310
|
+
driverParam: string;
|
|
311
|
+
notNull: true;
|
|
312
|
+
hasDefault: false;
|
|
313
|
+
isPrimaryKey: false;
|
|
314
|
+
isAutoincrement: false;
|
|
315
|
+
hasRuntimeDefault: false;
|
|
316
|
+
enumValues: [string, ...string[]];
|
|
317
|
+
baseColumn: never;
|
|
318
|
+
identity: undefined;
|
|
319
|
+
generated: undefined;
|
|
320
|
+
}, {}, {}>;
|
|
321
|
+
txId: import("drizzle-orm/pg-core").PgColumn<{
|
|
322
|
+
name: "tx_id";
|
|
323
|
+
tableName: "watcher_processed";
|
|
324
|
+
dataType: "string";
|
|
325
|
+
columnType: "PgText";
|
|
326
|
+
data: string;
|
|
327
|
+
driverParam: string;
|
|
328
|
+
notNull: true;
|
|
329
|
+
hasDefault: false;
|
|
330
|
+
isPrimaryKey: false;
|
|
331
|
+
isAutoincrement: false;
|
|
332
|
+
hasRuntimeDefault: false;
|
|
333
|
+
enumValues: [string, ...string[]];
|
|
334
|
+
baseColumn: never;
|
|
335
|
+
identity: undefined;
|
|
336
|
+
generated: undefined;
|
|
337
|
+
}, {}, {}>;
|
|
338
|
+
processedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
339
|
+
name: "processed_at";
|
|
340
|
+
tableName: "watcher_processed";
|
|
341
|
+
dataType: "string";
|
|
342
|
+
columnType: "PgText";
|
|
343
|
+
data: string;
|
|
344
|
+
driverParam: string;
|
|
345
|
+
notNull: true;
|
|
346
|
+
hasDefault: true;
|
|
347
|
+
isPrimaryKey: false;
|
|
348
|
+
isAutoincrement: false;
|
|
349
|
+
hasRuntimeDefault: false;
|
|
350
|
+
enumValues: [string, ...string[]];
|
|
351
|
+
baseColumn: never;
|
|
352
|
+
identity: undefined;
|
|
353
|
+
generated: undefined;
|
|
354
|
+
}, {}, {}>;
|
|
355
|
+
};
|
|
356
|
+
dialect: "pg";
|
|
357
|
+
}>;
|
package/dist/db/schema/crypto.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
|
-
import { index, integer, pgTable, text } from "drizzle-orm/pg-core";
|
|
2
|
+
import { 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.
|
|
@@ -30,3 +30,18 @@ export const cryptoCharges = pgTable("crypto_charges", {
|
|
|
30
30
|
// Unique indexes use WHERE IS NOT NULL partial indexes (declared in migration SQL).
|
|
31
31
|
// Enforced via migration: CREATE UNIQUE INDEX.
|
|
32
32
|
]);
|
|
33
|
+
/**
|
|
34
|
+
* Watcher cursor persistence — tracks the last processed block per watcher.
|
|
35
|
+
* Eliminates in-memory processedTxids and enables clean restart recovery.
|
|
36
|
+
*/
|
|
37
|
+
export const watcherCursors = pgTable("watcher_cursors", {
|
|
38
|
+
watcherId: text("watcher_id").primaryKey(),
|
|
39
|
+
cursorBlock: integer("cursor_block").notNull(),
|
|
40
|
+
updatedAt: text("updated_at").notNull().default(sql `(now())`),
|
|
41
|
+
});
|
|
42
|
+
/** Processed transaction IDs for watchers without block cursors (e.g. BTC). */
|
|
43
|
+
export const watcherProcessed = pgTable("watcher_processed", {
|
|
44
|
+
watcherId: text("watcher_id").notNull(),
|
|
45
|
+
txId: text("tx_id").notNull(),
|
|
46
|
+
processedAt: text("processed_at").notNull().default(sql `(now())`),
|
|
47
|
+
}, (table) => [primaryKey({ columns: [table.watcherId, table.txId] })]);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { initFleetUpdater } from "../init-fleet-updater.js";
|
|
3
|
+
function mockDocker() {
|
|
4
|
+
return {};
|
|
5
|
+
}
|
|
6
|
+
function mockFleet() {
|
|
7
|
+
return {};
|
|
8
|
+
}
|
|
9
|
+
function mockStore() {
|
|
10
|
+
return {
|
|
11
|
+
list: vi.fn(async () => []),
|
|
12
|
+
get: vi.fn(async () => undefined),
|
|
13
|
+
save: vi.fn(async () => { }),
|
|
14
|
+
delete: vi.fn(async () => { }),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function mockRepo(profiles = []) {
|
|
18
|
+
return {
|
|
19
|
+
list: vi.fn(async () => profiles),
|
|
20
|
+
get: vi.fn(async () => null),
|
|
21
|
+
save: vi.fn(async (p) => p),
|
|
22
|
+
delete: vi.fn(async () => true),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
describe("initFleetUpdater", () => {
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.restoreAllMocks();
|
|
28
|
+
});
|
|
29
|
+
it("returns a handle with all components", async () => {
|
|
30
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo());
|
|
31
|
+
expect(handle.poller).toBeDefined();
|
|
32
|
+
expect(handle.updater).toBeDefined();
|
|
33
|
+
expect(handle.orchestrator).toBeDefined();
|
|
34
|
+
expect(handle.snapshotManager).toBeDefined();
|
|
35
|
+
expect(handle.stop).toBeTypeOf("function");
|
|
36
|
+
await handle.stop();
|
|
37
|
+
});
|
|
38
|
+
it("wires poller.onUpdateAvailable to orchestrator", async () => {
|
|
39
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo());
|
|
40
|
+
expect(handle.poller.onUpdateAvailable).toBeTypeOf("function");
|
|
41
|
+
await handle.stop();
|
|
42
|
+
});
|
|
43
|
+
it("accepts custom strategy config", async () => {
|
|
44
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo(), {
|
|
45
|
+
strategy: "immediate",
|
|
46
|
+
snapshotDir: "/tmp/snapshots",
|
|
47
|
+
});
|
|
48
|
+
expect(handle.orchestrator).toBeDefined();
|
|
49
|
+
await handle.stop();
|
|
50
|
+
});
|
|
51
|
+
it("accepts callbacks", async () => {
|
|
52
|
+
const onBotUpdated = vi.fn();
|
|
53
|
+
const onRolloutComplete = vi.fn();
|
|
54
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo(), {
|
|
55
|
+
onBotUpdated,
|
|
56
|
+
onRolloutComplete,
|
|
57
|
+
});
|
|
58
|
+
expect(handle.orchestrator).toBeDefined();
|
|
59
|
+
await handle.stop();
|
|
60
|
+
});
|
|
61
|
+
it("stop() stops the poller", async () => {
|
|
62
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo());
|
|
63
|
+
const stopSpy = vi.spyOn(handle.poller, "stop");
|
|
64
|
+
await handle.stop();
|
|
65
|
+
expect(stopSpy).toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
it("filters manual-policy bots from updatable profiles", async () => {
|
|
68
|
+
const repo = mockRepo([
|
|
69
|
+
{ id: "b1", updatePolicy: "nightly" },
|
|
70
|
+
{ id: "b2", updatePolicy: "manual" },
|
|
71
|
+
{ id: "b3", updatePolicy: "on-push" },
|
|
72
|
+
]);
|
|
73
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), repo, {
|
|
74
|
+
strategy: "immediate",
|
|
75
|
+
});
|
|
76
|
+
const rolloutResult = await handle.orchestrator.rollout();
|
|
77
|
+
// b2 (manual) should be filtered out, b1 and b3 included
|
|
78
|
+
expect(rolloutResult.totalBots).toBe(2);
|
|
79
|
+
await handle.stop();
|
|
80
|
+
});
|
|
81
|
+
it("uses profileRepo for updatable profiles, not profileStore", async () => {
|
|
82
|
+
const store = mockStore();
|
|
83
|
+
const repo = mockRepo([{ id: "b1", updatePolicy: "nightly" }]);
|
|
84
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), store, repo, {
|
|
85
|
+
strategy: "immediate",
|
|
86
|
+
});
|
|
87
|
+
await handle.orchestrator.rollout();
|
|
88
|
+
// profileRepo.list() was called for updatable profiles
|
|
89
|
+
expect(repo.list).toHaveBeenCalled();
|
|
90
|
+
// profileStore.list() may also be called by ImagePoller — that's expected
|
|
91
|
+
await handle.stop();
|
|
92
|
+
});
|
|
93
|
+
});
|
package/dist/fleet/index.d.ts
CHANGED
package/dist/fleet/index.js
CHANGED