@solard/cli 0.2.19 → 0.2.20

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.
@@ -0,0 +1,11 @@
1
+ import { runLaunchWatchCommand } from "./launch-watch-command.ts";
2
+
3
+ type Flags = Map<string, string>;
4
+ type Emit = (value: string) => void;
5
+
6
+ export async function runPumpWatchCommand(args: {
7
+ flags: Flags;
8
+ emit: Emit;
9
+ }): Promise<void> {
10
+ await runLaunchWatchCommand({ ...args, forcedVenues: ["pump"] });
11
+ }
@@ -0,0 +1,335 @@
1
+ import bs58 from "bs58";
2
+ import { PublicKey, TransactionInstruction } from "@solana/web3.js";
3
+ import { findExternalContact, type Solard } from "@solard/core";
4
+
5
+ type Flags = Map<string, string>;
6
+ type Emit = (value: string) => void;
7
+ type LoaderKind = "buffer" | "program";
8
+
9
+ type LoaderRow = {
10
+ kind: LoaderKind;
11
+ walletName: string;
12
+ authority: string;
13
+ address: string;
14
+ closeAddress: string;
15
+ programDataAddress: string | null;
16
+ lamports: bigint;
17
+ };
18
+
19
+ const LOADER = new PublicKey("BPFLoaderUpgradeab1e11111111111111111111111");
20
+ const CLOSE_DISCRIMINANT = 5;
21
+ const STATE_BUFFER = bs58.encode(Buffer.from([1, 0, 0, 0]));
22
+ const STATE_PROGRAM = bs58.encode(Buffer.from([2, 0, 0, 0]));
23
+ const STATE_PROGRAM_DATA = bs58.encode(Buffer.from([3, 0, 0, 0]));
24
+
25
+ function csv(value: string | undefined): string[] {
26
+ return (value ?? "")
27
+ .split(",")
28
+ .map((item) => item.trim())
29
+ .filter(Boolean);
30
+ }
31
+
32
+ function json(value: unknown): string {
33
+ return JSON.stringify(
34
+ value,
35
+ (_, item) => (typeof item === "bigint" ? item.toString() : item),
36
+ 2,
37
+ );
38
+ }
39
+
40
+ function short(value: string): string {
41
+ return value.length <= 16 ? value : `${value.slice(0, 7)}…${value.slice(-6)}`;
42
+ }
43
+
44
+ function sol(lamports: bigint): string {
45
+ const whole = lamports / 1_000_000_000n;
46
+ const fraction = (lamports % 1_000_000_000n)
47
+ .toString()
48
+ .padStart(9, "0")
49
+ .replace(/0+$/, "");
50
+ return `${whole}${fraction ? `.${fraction}` : ""}`;
51
+ }
52
+
53
+ function selectedWallets(slrd: Solard, flags: Flags) {
54
+ const all = flags.has("all-wallets");
55
+ const wallet = flags.get("wallet");
56
+ const wallets = csv(flags.get("wallets"));
57
+ const group = flags.get("group");
58
+ const selectors = [
59
+ all,
60
+ Boolean(wallet),
61
+ wallets.length > 0,
62
+ Boolean(group),
63
+ ].filter(Boolean).length;
64
+ if (selectors !== 1) {
65
+ throw new Error(
66
+ "Select exactly one of --all-wallets, --wallet <wallet>, --wallets <a,b>, or --group <group>.",
67
+ );
68
+ }
69
+ const allRows = slrd.wallets.list();
70
+ let addresses: Set<string>;
71
+ if (all) {
72
+ addresses = new Set(allRows.map((row) => row.address));
73
+ } else if (wallet) {
74
+ addresses = new Set([slrd.resolveWallet(wallet).address.toBase58()]);
75
+ } else if (wallets.length) {
76
+ addresses = new Set(
77
+ wallets.map((ref) => slrd.resolveWallet(ref).address.toBase58()),
78
+ );
79
+ } else {
80
+ addresses = new Set(
81
+ slrd
82
+ .groupWallets(group!)
83
+ .map((ref) => slrd.resolveWallet(ref).address.toBase58()),
84
+ );
85
+ }
86
+ return allRows.filter((row) => addresses.has(row.address));
87
+ }
88
+
89
+ async function scanAuthority(
90
+ slrd: Solard,
91
+ wallet: { name: string; address: string },
92
+ ): Promise<LoaderRow[]> {
93
+ const connection = slrd.connection();
94
+ const buffers = await connection.getProgramAccounts(LOADER, {
95
+ commitment: "confirmed",
96
+ filters: [
97
+ { memcmp: { offset: 0, bytes: STATE_BUFFER } },
98
+ { memcmp: { offset: 5, bytes: wallet.address } },
99
+ ],
100
+ dataSlice: { offset: 0, length: 37 },
101
+ });
102
+ const programData = await connection.getProgramAccounts(LOADER, {
103
+ commitment: "confirmed",
104
+ filters: [
105
+ { memcmp: { offset: 0, bytes: STATE_PROGRAM_DATA } },
106
+ { memcmp: { offset: 13, bytes: wallet.address } },
107
+ ],
108
+ dataSlice: { offset: 0, length: 45 },
109
+ });
110
+
111
+ const rows: LoaderRow[] = buffers.map((entry) => ({
112
+ kind: "buffer",
113
+ walletName: wallet.name,
114
+ authority: wallet.address,
115
+ address: entry.pubkey.toBase58(),
116
+ closeAddress: entry.pubkey.toBase58(),
117
+ programDataAddress: null,
118
+ lamports: BigInt(entry.account.lamports),
119
+ }));
120
+
121
+ for (const entry of programData) {
122
+ const programDataAddress = entry.pubkey.toBase58();
123
+ const programs = await connection.getProgramAccounts(LOADER, {
124
+ commitment: "confirmed",
125
+ filters: [
126
+ { memcmp: { offset: 0, bytes: STATE_PROGRAM } },
127
+ { memcmp: { offset: 4, bytes: programDataAddress } },
128
+ ],
129
+ dataSlice: { offset: 0, length: 36 },
130
+ });
131
+ for (const program of programs) {
132
+ rows.push({
133
+ kind: "program",
134
+ walletName: wallet.name,
135
+ authority: wallet.address,
136
+ address: program.pubkey.toBase58(),
137
+ closeAddress: programDataAddress,
138
+ programDataAddress,
139
+ lamports: BigInt(entry.account.lamports),
140
+ });
141
+ }
142
+ }
143
+ return rows;
144
+ }
145
+
146
+ function resolveDestination(
147
+ slrd: Solard,
148
+ value: string | undefined,
149
+ ): string | null {
150
+ if (!value || value === "true") return null;
151
+ try {
152
+ return slrd.resolveWallet(value).address.toBase58();
153
+ } catch {}
154
+ const contact = findExternalContact(value);
155
+ if (contact) return contact.address;
156
+ return new PublicKey(value).toBase58();
157
+ }
158
+
159
+ function closeInstruction(
160
+ row: LoaderRow,
161
+ destination: PublicKey,
162
+ ): TransactionInstruction {
163
+ const data = Buffer.alloc(4);
164
+ data.writeUInt32LE(CLOSE_DISCRIMINANT, 0);
165
+ const keys = [
166
+ {
167
+ pubkey: new PublicKey(row.closeAddress),
168
+ isSigner: false,
169
+ isWritable: true,
170
+ },
171
+ { pubkey: destination, isSigner: false, isWritable: true },
172
+ { pubkey: new PublicKey(row.authority), isSigner: true, isWritable: false },
173
+ ];
174
+ if (row.kind === "program") {
175
+ keys.push({
176
+ pubkey: new PublicKey(row.address),
177
+ isSigner: false,
178
+ isWritable: true,
179
+ });
180
+ }
181
+ return new TransactionInstruction({ programId: LOADER, keys, data });
182
+ }
183
+
184
+ async function verifyClosed(slrd: Solard, address: string): Promise<boolean> {
185
+ for (let attempt = 0; attempt < 6; attempt += 1) {
186
+ const account = await slrd
187
+ .connection()
188
+ .getAccountInfo(new PublicKey(address), "confirmed");
189
+ if (!account || account.lamports === 0) return true;
190
+ if (attempt < 5) await new Promise((resolve) => setTimeout(resolve, 500));
191
+ }
192
+ return false;
193
+ }
194
+
195
+ export async function runReclaimCommand(args: {
196
+ slrd: Solard;
197
+ values: string[];
198
+ flags: Flags;
199
+ emit: Emit;
200
+ }): Promise<void> {
201
+ const action = (args.values[0] ?? "inspect").toLowerCase();
202
+ if (!["inspect", "buffers", "programs", "all"].includes(action)) {
203
+ throw new Error(
204
+ "Usage: slrd reclaim <inspect|buffers|programs|all> --all-wallets [--simulate | --live]",
205
+ );
206
+ }
207
+ if (args.flags.has("simulate") && args.flags.has("live"))
208
+ throw new Error("Use either --simulate or --live, not both.");
209
+
210
+ const wallets = selectedWallets(args.slrd, args.flags);
211
+ const rows: LoaderRow[] = [];
212
+ const scanErrors: Array<{ wallet: string; address: string; error: string }> =
213
+ [];
214
+ for (const wallet of wallets) {
215
+ try {
216
+ rows.push(...(await scanAuthority(args.slrd, wallet)));
217
+ } catch (error) {
218
+ scanErrors.push({
219
+ wallet: wallet.name,
220
+ address: wallet.address,
221
+ error: error instanceof Error ? error.message : String(error),
222
+ });
223
+ }
224
+ }
225
+
226
+ const selected = rows.filter((row) =>
227
+ action === "inspect" || action === "all"
228
+ ? true
229
+ : action === "buffers"
230
+ ? row.kind === "buffer"
231
+ : row.kind === "program",
232
+ );
233
+ selected.sort(
234
+ (a, b) =>
235
+ a.walletName.localeCompare(b.walletName) || a.kind.localeCompare(b.kind),
236
+ );
237
+ const total = selected.reduce((sum, row) => sum + row.lamports, 0n);
238
+
239
+ if (
240
+ args.flags.has("json") &&
241
+ !args.flags.has("live") &&
242
+ !args.flags.has("simulate")
243
+ ) {
244
+ args.emit(
245
+ `${json({ wallets: wallets.length, rows: selected, scanErrors, totalLamports: total })}\n`,
246
+ );
247
+ return;
248
+ }
249
+
250
+ args.emit(
251
+ `RECLAIM wallets=${wallets.length} buffers=${selected.filter((row) => row.kind === "buffer").length} ` +
252
+ `programs=${selected.filter((row) => row.kind === "program").length} total≈${sol(total)} SOL\n`,
253
+ );
254
+ for (const row of selected) {
255
+ args.emit(
256
+ `${row.kind === "buffer" ? "BUFFER " : "PROGRAM"} @${row.walletName} ${short(row.address)} ${sol(row.lamports)} SOL\n`,
257
+ );
258
+ }
259
+ for (const error of scanErrors) {
260
+ args.emit(`SCANERR @${error.wallet} ${error.address} ${error.error}\n`);
261
+ }
262
+
263
+ if (
264
+ action === "inspect" ||
265
+ (!args.flags.has("simulate") && !args.flags.has("live"))
266
+ ) {
267
+ args.emit("No accounts closed. Use --simulate, then --live.\n");
268
+ return;
269
+ }
270
+ if (scanErrors.length) {
271
+ throw new Error(
272
+ `Loader scan was incomplete for ${scanErrors.length} wallet(s); refusing reclaim execution.`,
273
+ );
274
+ }
275
+ if (
276
+ args.flags.has("live") &&
277
+ selected.some((row) => row.kind === "program") &&
278
+ !args.flags.has("confirm-program-close")
279
+ ) {
280
+ throw new Error(
281
+ "Program closure is irreversible. Re-run with --confirm-program-close together with --live.",
282
+ );
283
+ }
284
+
285
+ const explicitDestination = resolveDestination(
286
+ args.slrd,
287
+ args.flags.get("destination"),
288
+ );
289
+ const via = (args.flags.get("sender") ?? "rpc") as any;
290
+ let ok = 0;
291
+ let failed = 0;
292
+ for (let index = 0; index < selected.length; index += 1) {
293
+ const row = selected[index]!;
294
+ const destination = new PublicKey(explicitDestination ?? row.authority);
295
+ const ix = closeInstruction(row, destination);
296
+ try {
297
+ const composer = args.slrd.tx(row.walletName).add(ix, {
298
+ kind: `close-loader-${row.kind}`,
299
+ recipient: destination,
300
+ meta: { address: row.address, closeAddress: row.closeAddress },
301
+ });
302
+ if (args.flags.has("simulate")) {
303
+ const plan = await composer.build();
304
+ const simulation = await args.slrd.simulatePlan(plan);
305
+ if (!simulation.success) throw new Error("simulation failed");
306
+ args.emit(
307
+ `SIM ${index + 1}/${selected.length} ${row.kind} ${row.address}\n`,
308
+ );
309
+ } else {
310
+ const receipt = await composer.send({
311
+ via,
312
+ kind: `reclaim:${row.kind}`,
313
+ });
314
+ if (!(await verifyClosed(args.slrd, row.closeAddress))) {
315
+ throw new Error(
316
+ `close transaction ${receipt.signature} landed but ${row.closeAddress} still exists`,
317
+ );
318
+ }
319
+ args.emit(
320
+ `OK ${index + 1}/${selected.length} ${row.kind} ${row.address} reclaimed≈${sol(row.lamports)} SOL\n`,
321
+ );
322
+ }
323
+ ok += 1;
324
+ } catch (error) {
325
+ failed += 1;
326
+ args.emit(
327
+ `FAIL ${index + 1}/${selected.length} ${row.kind} ${row.address} ${error instanceof Error ? error.message : String(error)}\n`,
328
+ );
329
+ }
330
+ }
331
+ args.emit(
332
+ `DONE ok=${ok} failed=${failed} planned≈${sol(total)} SOL mode=${args.flags.has("simulate") ? "simulate" : "live"}\n`,
333
+ );
334
+ if (failed) process.exitCode = 1;
335
+ }
@@ -0,0 +1,331 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, resolve } from "node:path";
4
+ import { Database } from "bun:sqlite";
5
+ import { PublicKey, type ParsedTransactionWithMeta } from "@solana/web3.js";
6
+
7
+ export type ResearchHistorySignature = {
8
+ mint: string;
9
+ signature: string;
10
+ slot: number;
11
+ blockTime: number | null;
12
+ scanKind: "curve" | "pool";
13
+ scanAddress: string;
14
+ localChronologicalOrder: number;
15
+ };
16
+
17
+ export type ResearchHistoryMeta = {
18
+ version: 1;
19
+ mint: string;
20
+ decimals: number;
21
+ supplyUi: number;
22
+ quoteMint: string | null;
23
+ bondingCurve: string | null;
24
+ pool: string | null;
25
+ fromCreation: boolean;
26
+ indexedSignatures: number;
27
+ indexedAtMs: number;
28
+ };
29
+
30
+ type Encoded =
31
+ null | boolean | number | string | Encoded[] | { [key: string]: Encoded };
32
+
33
+ function cachePath(): string {
34
+ return resolve(
35
+ process.env.SLRD_RESEARCH_DB_PATH?.trim() ||
36
+ process.env.SOLARD_RESEARCH_DB_PATH?.trim() ||
37
+ resolve(homedir(), ".solard", "research.sqlite"),
38
+ );
39
+ }
40
+
41
+ function open(): Database {
42
+ const path = cachePath();
43
+ mkdirSync(dirname(path), { recursive: true });
44
+ const db = new Database(path, { create: true });
45
+ db.exec("PRAGMA journal_mode=WAL");
46
+ db.exec("PRAGMA synchronous=NORMAL");
47
+ db.exec(`
48
+ CREATE TABLE IF NOT EXISTS token_history_signatures (
49
+ mint TEXT NOT NULL,
50
+ signature TEXT NOT NULL,
51
+ slot INTEGER NOT NULL,
52
+ block_time INTEGER,
53
+ scan_kind TEXT NOT NULL,
54
+ scan_address TEXT NOT NULL,
55
+ local_order INTEGER NOT NULL,
56
+ PRIMARY KEY (mint, signature)
57
+ );
58
+ CREATE INDEX IF NOT EXISTS token_history_signatures_time
59
+ ON token_history_signatures (mint, block_time, slot, local_order);
60
+ CREATE TABLE IF NOT EXISTS token_history_meta (
61
+ mint TEXT PRIMARY KEY,
62
+ json TEXT NOT NULL,
63
+ updated_at_ms INTEGER NOT NULL
64
+ );
65
+ CREATE TABLE IF NOT EXISTS raw_transactions (
66
+ signature TEXT PRIMARY KEY,
67
+ slot INTEGER NOT NULL,
68
+ block_time_ms INTEGER,
69
+ confidence TEXT NOT NULL,
70
+ transaction_json TEXT NOT NULL,
71
+ fetched_at_ms INTEGER NOT NULL
72
+ );
73
+ `);
74
+ return db;
75
+ }
76
+
77
+ function encode(value: unknown): Encoded {
78
+ if (
79
+ value == null ||
80
+ typeof value === "boolean" ||
81
+ typeof value === "number" ||
82
+ typeof value === "string"
83
+ )
84
+ return value;
85
+ if (typeof value === "bigint")
86
+ return { $bigint: value.toString() } as Encoded;
87
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array)
88
+ return { $bytes: Buffer.from(value).toString("base64") } as Encoded;
89
+ if (value instanceof PublicKey)
90
+ return { $publicKey: value.toBase58() } as Encoded;
91
+ if (Array.isArray(value)) return value.map(encode);
92
+ if (typeof value === "object") {
93
+ const toBase58 = (value as { toBase58?: unknown }).toBase58;
94
+ if (typeof toBase58 === "function")
95
+ return {
96
+ $publicKey: (value as { toBase58(): string }).toBase58(),
97
+ } as Encoded;
98
+ const out: Record<string, Encoded> = {};
99
+ for (const [key, item] of Object.entries(value as Record<string, unknown>))
100
+ out[key] = encode(item);
101
+ return out;
102
+ }
103
+ throw new Error(`Unsupported research cache value: ${typeof value}`);
104
+ }
105
+
106
+ function decode(value: Encoded): unknown {
107
+ if (value == null || typeof value !== "object") return value;
108
+ if (Array.isArray(value)) return value.map(decode);
109
+ if ("$publicKey" in value) return new PublicKey(String(value.$publicKey));
110
+ if ("$bytes" in value) return Buffer.from(String(value.$bytes), "base64");
111
+ if ("$bigint" in value) return BigInt(String(value.$bigint));
112
+ const out: Record<string, unknown> = {};
113
+ for (const [key, item] of Object.entries(value))
114
+ out[key] = decode(item as Encoded);
115
+ return out;
116
+ }
117
+
118
+ export function researchHistoryCachePath(): string {
119
+ return cachePath();
120
+ }
121
+
122
+ export function replaceResearchHistoryIndex(args: {
123
+ mint: string;
124
+ rows: ResearchHistorySignature[];
125
+ meta: ResearchHistoryMeta;
126
+ }): void {
127
+ const db = open();
128
+ try {
129
+ const remove = db.prepare(
130
+ "DELETE FROM token_history_signatures WHERE mint = ?",
131
+ );
132
+ const insert = db.prepare(`
133
+ INSERT OR REPLACE INTO token_history_signatures
134
+ (mint, signature, slot, block_time, scan_kind, scan_address, local_order)
135
+ VALUES (?, ?, ?, ?, ?, ?, ?)
136
+ `);
137
+ const writeMeta = db.prepare(`
138
+ INSERT INTO token_history_meta (mint, json, updated_at_ms)
139
+ VALUES (?, ?, ?)
140
+ ON CONFLICT(mint) DO UPDATE SET json=excluded.json, updated_at_ms=excluded.updated_at_ms
141
+ `);
142
+ const tx = db.transaction(() => {
143
+ remove.run(args.mint);
144
+ for (const row of args.rows) {
145
+ insert.run(
146
+ args.mint,
147
+ row.signature,
148
+ row.slot,
149
+ row.blockTime,
150
+ row.scanKind,
151
+ row.scanAddress,
152
+ row.localChronologicalOrder,
153
+ );
154
+ }
155
+ writeMeta.run(args.mint, JSON.stringify(args.meta), Date.now());
156
+ });
157
+ tx();
158
+ } finally {
159
+ db.close();
160
+ }
161
+ }
162
+
163
+ export function researchHistoryMeta(mint: string): ResearchHistoryMeta | null {
164
+ const db = open();
165
+ try {
166
+ const row = db
167
+ .prepare("SELECT json FROM token_history_meta WHERE mint = ?")
168
+ .get(mint) as { json?: string } | null;
169
+ return row?.json ? (JSON.parse(row.json) as ResearchHistoryMeta) : null;
170
+ } finally {
171
+ db.close();
172
+ }
173
+ }
174
+
175
+ export function loadResearchHistoryIndex(
176
+ mint: string,
177
+ options: { fromMs?: number; toMs?: number } = {},
178
+ ): ResearchHistorySignature[] {
179
+ const db = open();
180
+ try {
181
+ const rows = db
182
+ .prepare(
183
+ `
184
+ SELECT mint, signature, slot, block_time, scan_kind, scan_address, local_order
185
+ FROM token_history_signatures
186
+ WHERE mint = ?
187
+ ORDER BY COALESCE(block_time, 0), slot, local_order, signature
188
+ `,
189
+ )
190
+ .all(mint) as Array<{
191
+ mint: string;
192
+ signature: string;
193
+ slot: number;
194
+ block_time: number | null;
195
+ scan_kind: "curve" | "pool";
196
+ scan_address: string;
197
+ local_order: number;
198
+ }>;
199
+ return rows
200
+ .map((row) => ({
201
+ mint: row.mint,
202
+ signature: row.signature,
203
+ slot: Number(row.slot),
204
+ blockTime: row.block_time == null ? null : Number(row.block_time),
205
+ scanKind: row.scan_kind,
206
+ scanAddress: row.scan_address,
207
+ localChronologicalOrder: Number(row.local_order),
208
+ }))
209
+ .filter(
210
+ (row) =>
211
+ options.fromMs == null ||
212
+ row.blockTime == null ||
213
+ row.blockTime * 1_000 >= options.fromMs,
214
+ )
215
+ .filter(
216
+ (row) =>
217
+ options.toMs == null ||
218
+ row.blockTime == null ||
219
+ row.blockTime * 1_000 <= options.toMs,
220
+ );
221
+ } finally {
222
+ db.close();
223
+ }
224
+ }
225
+
226
+ export function sampleResearchHistoryIndex(
227
+ rows: readonly ResearchHistorySignature[],
228
+ sampleMs: number,
229
+ ): ResearchHistorySignature[] {
230
+ if (!(sampleMs > 0)) return [...rows];
231
+ const selected = new Map<string, ResearchHistorySignature>();
232
+ const buckets = new Map<number, ResearchHistorySignature>();
233
+ const add = (row: ResearchHistorySignature | undefined) => {
234
+ if (row) selected.set(row.signature, row);
235
+ };
236
+ add(rows[0]);
237
+ add(rows.at(-1));
238
+ add(rows.find((row) => row.scanKind === "curve"));
239
+ add(rows.find((row) => row.scanKind === "pool"));
240
+ for (const row of rows) {
241
+ if (row.blockTime == null) continue;
242
+ const bucket = Math.floor((row.blockTime * 1_000) / sampleMs);
243
+ const previous = buckets.get(bucket);
244
+ if (
245
+ !previous ||
246
+ row.slot > previous.slot ||
247
+ (row.slot === previous.slot &&
248
+ row.localChronologicalOrder > previous.localChronologicalOrder)
249
+ )
250
+ buckets.set(bucket, row);
251
+ }
252
+ for (const row of buckets.values()) add(row);
253
+ return [...selected.values()].sort(
254
+ (a, b) =>
255
+ (a.blockTime ?? 0) - (b.blockTime ?? 0) ||
256
+ a.slot - b.slot ||
257
+ a.localChronologicalOrder - b.localChronologicalOrder ||
258
+ a.signature.localeCompare(b.signature),
259
+ );
260
+ }
261
+
262
+ export function cachedResearchTransactions(
263
+ signatures: readonly string[],
264
+ ): Map<string, ParsedTransactionWithMeta> {
265
+ const out = new Map<string, ParsedTransactionWithMeta>();
266
+ if (!signatures.length) return out;
267
+ const db = open();
268
+ try {
269
+ const get = db.prepare(
270
+ "SELECT transaction_json FROM raw_transactions WHERE signature = ?",
271
+ );
272
+ for (const signature of signatures) {
273
+ const row = get.get(signature) as { transaction_json?: string } | null;
274
+ if (!row?.transaction_json) continue;
275
+ out.set(
276
+ signature,
277
+ decode(
278
+ JSON.parse(row.transaction_json) as Encoded,
279
+ ) as ParsedTransactionWithMeta,
280
+ );
281
+ }
282
+ return out;
283
+ } finally {
284
+ db.close();
285
+ }
286
+ }
287
+
288
+ export function cacheResearchTransactions(
289
+ rows: ReadonlyMap<string, ParsedTransactionWithMeta>,
290
+ confidence: "confirmed" | "finalized",
291
+ ): void {
292
+ if (!rows.size) return;
293
+ const db = open();
294
+ try {
295
+ const insert = db.prepare(`
296
+ INSERT INTO raw_transactions
297
+ (signature, slot, block_time_ms, confidence, transaction_json, fetched_at_ms)
298
+ VALUES (?, ?, ?, ?, ?, ?)
299
+ ON CONFLICT(signature) DO UPDATE SET
300
+ slot=excluded.slot,
301
+ block_time_ms=excluded.block_time_ms,
302
+ confidence=excluded.confidence,
303
+ transaction_json=excluded.transaction_json,
304
+ fetched_at_ms=excluded.fetched_at_ms
305
+ `);
306
+ const now = Date.now();
307
+ const tx = db.transaction(() => {
308
+ for (const [signature, transaction] of rows) {
309
+ insert.run(
310
+ signature,
311
+ transaction.slot,
312
+ transaction.blockTime == null ? null : transaction.blockTime * 1_000,
313
+ confidence,
314
+ JSON.stringify(encode(transaction)),
315
+ now,
316
+ );
317
+ }
318
+ });
319
+ tx();
320
+ } finally {
321
+ db.close();
322
+ }
323
+ }
324
+
325
+ export function countCachedResearchTransactions(
326
+ signatures: readonly string[],
327
+ ): number {
328
+ if (!signatures.length) return 0;
329
+ const cached = cachedResearchTransactions(signatures);
330
+ return cached.size;
331
+ }