@cogcoin/client 1.1.16 → 1.2.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.
@@ -0,0 +1,451 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { COG_PREFIX } from "../cogop/constants.js";
5
+ import { writeJsonFileAtomic } from "../fs/atomic.js";
6
+ import { extractOpReturnPayloadFromScriptHex } from "../tx/register.js";
7
+ const MINING_MEMPOOL_INDEX_SCHEMA_VERSION = 1;
8
+ const MINING_MEMPOOL_INDEX_RAW_TX_FETCH_CONCURRENCY = 8;
9
+ const MINING_MEMPOOL_INDEX_PROGRESS_REPORT_EVERY = 25;
10
+ const indexStates = new Map();
11
+ const rawTxSubscribers = new Map();
12
+ export function resolveMiningMempoolIndexCachePath(paths) {
13
+ return join(paths.miningRoot, "mempool-index.json");
14
+ }
15
+ export function resolveMiningMempoolServiceIdentity(options) {
16
+ return [
17
+ options.dataDir,
18
+ options.pid ?? "unknown-pid",
19
+ options.zmqEndpoint,
20
+ options.rawTxTopic ?? "no-rawtx",
21
+ ].join("|");
22
+ }
23
+ function cacheKey(walletRootId, serviceIdentity, cachePath) {
24
+ return `${walletRootId}\n${serviceIdentity}\n${cachePath}`;
25
+ }
26
+ function isCogPayload(payload) {
27
+ return payload !== null
28
+ && payload.length >= 3
29
+ && payload[0] === COG_PREFIX[0]
30
+ && payload[1] === COG_PREFIX[1]
31
+ && payload[2] === COG_PREFIX[2];
32
+ }
33
+ function serializeContext(context) {
34
+ return {
35
+ txid: context.txid,
36
+ senderScriptHex: context.senderScriptHex,
37
+ inputTxids: [...context.inputTxids],
38
+ payloadHex: context.payload === null ? null : Buffer.from(context.payload).toString("hex"),
39
+ };
40
+ }
41
+ function deserializeContext(context) {
42
+ if (typeof context.txid !== "string"
43
+ || (context.senderScriptHex !== null && typeof context.senderScriptHex !== "string")
44
+ || !Array.isArray(context.inputTxids)
45
+ || (context.payloadHex !== null && typeof context.payloadHex !== "string")) {
46
+ return null;
47
+ }
48
+ return {
49
+ txid: context.txid,
50
+ senderScriptHex: context.senderScriptHex,
51
+ inputTxids: context.inputTxids.filter((txid) => typeof txid === "string"),
52
+ payload: context.payloadHex === null ? null : Buffer.from(context.payloadHex, "hex"),
53
+ };
54
+ }
55
+ async function loadStateFromDisk(state) {
56
+ if (state.loaded) {
57
+ return;
58
+ }
59
+ state.loaded = true;
60
+ const raw = await readFile(state.cachePath, "utf8").catch(() => null);
61
+ if (raw === null) {
62
+ return;
63
+ }
64
+ let parsed;
65
+ try {
66
+ parsed = JSON.parse(raw);
67
+ }
68
+ catch {
69
+ return;
70
+ }
71
+ if (parsed.schemaVersion !== MINING_MEMPOOL_INDEX_SCHEMA_VERSION
72
+ || parsed.walletRootId !== state.walletRootId
73
+ || parsed.serviceIdentity !== state.serviceIdentity
74
+ || !Array.isArray(parsed.contexts)
75
+ || !Array.isArray(parsed.negativeTxids)) {
76
+ return;
77
+ }
78
+ for (const persistedContext of parsed.contexts) {
79
+ const context = deserializeContext(persistedContext);
80
+ if (context !== null && isCogPayload(context.payload)) {
81
+ state.contexts.set(context.txid, context);
82
+ }
83
+ }
84
+ for (const txid of parsed.negativeTxids) {
85
+ if (typeof txid === "string") {
86
+ state.negativeTxids.add(txid);
87
+ }
88
+ }
89
+ }
90
+ function getOrCreateState(options) {
91
+ const key = cacheKey(options.walletRootId, options.serviceIdentity, options.cachePath);
92
+ const existing = indexStates.get(key);
93
+ if (existing !== undefined) {
94
+ return existing;
95
+ }
96
+ const created = {
97
+ walletRootId: options.walletRootId,
98
+ serviceIdentity: options.serviceIdentity,
99
+ cachePath: options.cachePath,
100
+ contexts: new Map(),
101
+ negativeTxids: new Set(),
102
+ loaded: false,
103
+ savePromise: Promise.resolve(),
104
+ };
105
+ indexStates.set(key, created);
106
+ return created;
107
+ }
108
+ async function saveState(state) {
109
+ const payload = {
110
+ schemaVersion: MINING_MEMPOOL_INDEX_SCHEMA_VERSION,
111
+ walletRootId: state.walletRootId,
112
+ serviceIdentity: state.serviceIdentity,
113
+ contexts: [...state.contexts.values()]
114
+ .sort((left, right) => left.txid.localeCompare(right.txid))
115
+ .map(serializeContext),
116
+ negativeTxids: [...state.negativeTxids].sort(),
117
+ };
118
+ state.savePromise = state.savePromise.catch(() => undefined).then(async () => {
119
+ await mkdir(dirname(state.cachePath), { recursive: true });
120
+ await writeJsonFileAtomic(state.cachePath, payload, { mode: 0o600 });
121
+ });
122
+ await state.savePromise;
123
+ }
124
+ function pruneStateToVisibleTxids(state, visibleTxids) {
125
+ const visibleSet = new Set(visibleTxids);
126
+ let changed = false;
127
+ for (const txid of [...state.contexts.keys()]) {
128
+ if (!visibleSet.has(txid)) {
129
+ state.contexts.delete(txid);
130
+ changed = true;
131
+ }
132
+ }
133
+ for (const txid of [...state.negativeTxids]) {
134
+ if (!visibleSet.has(txid)) {
135
+ state.negativeTxids.delete(txid);
136
+ changed = true;
137
+ }
138
+ }
139
+ return changed;
140
+ }
141
+ function createContextFromRpcTransaction(txid, tx) {
142
+ const payloadHex = tx.vout.find((entry) => entry.scriptPubKey?.hex?.startsWith("6a") === true)?.scriptPubKey?.hex;
143
+ const payload = payloadHex === undefined ? null : extractOpReturnPayloadFromScriptHex(payloadHex);
144
+ if (!isCogPayload(payload)) {
145
+ return null;
146
+ }
147
+ return {
148
+ txid,
149
+ senderScriptHex: tx.vin[0]?.prevout?.scriptPubKey?.hex ?? null,
150
+ inputTxids: tx.vin.map((input) => input.txid).filter((inputTxid) => inputTxid !== undefined),
151
+ payload,
152
+ };
153
+ }
154
+ async function maybeYieldDuringIndexHydration(options) {
155
+ const yieldEvery = options.cooperativeYieldEvery ?? MINING_MEMPOOL_INDEX_PROGRESS_REPORT_EVERY;
156
+ if (yieldEvery <= 0 || options.iteration === 0 || (options.iteration % yieldEvery) !== 0) {
157
+ return;
158
+ }
159
+ await (options.cooperativeYield ?? (() => new Promise((resolve) => {
160
+ setImmediate(resolve);
161
+ })))();
162
+ }
163
+ async function hydrateUnknownTxids(options) {
164
+ if (options.unknownTxids.length === 0) {
165
+ return;
166
+ }
167
+ let completed = 0;
168
+ let nextIndex = 0;
169
+ let lastReportedProcessed = -1;
170
+ let reportPromise = Promise.resolve();
171
+ const reportProgress = async (processed, force = false) => {
172
+ if (options.onWarmupProgress === undefined) {
173
+ return;
174
+ }
175
+ if (!force && processed !== options.unknownTxids.length && (processed % MINING_MEMPOOL_INDEX_PROGRESS_REPORT_EVERY) !== 0) {
176
+ return;
177
+ }
178
+ if (processed === lastReportedProcessed) {
179
+ return;
180
+ }
181
+ lastReportedProcessed = processed;
182
+ reportPromise = reportPromise.then(async () => {
183
+ await options.onWarmupProgress?.({
184
+ processed,
185
+ total: options.unknownTxids.length,
186
+ });
187
+ });
188
+ await reportPromise;
189
+ };
190
+ await reportProgress(0, true);
191
+ let failed = false;
192
+ const workerCount = Math.min(MINING_MEMPOOL_INDEX_RAW_TX_FETCH_CONCURRENCY, options.unknownTxids.length);
193
+ const workers = Array.from({ length: workerCount }, async () => {
194
+ while (true) {
195
+ const iteration = nextIndex;
196
+ if (iteration >= options.unknownTxids.length) {
197
+ return;
198
+ }
199
+ nextIndex += 1;
200
+ await maybeYieldDuringIndexHydration({
201
+ iteration,
202
+ cooperativeYield: options.cooperativeYield,
203
+ cooperativeYieldEvery: options.cooperativeYieldEvery,
204
+ });
205
+ options.throwIfStopping?.();
206
+ const txid = options.unknownTxids[iteration];
207
+ const tx = await options.rpc.getRawTransaction(txid, true).catch(() => null);
208
+ options.throwIfStopping?.();
209
+ if (tx === null) {
210
+ failed = true;
211
+ }
212
+ else {
213
+ const context = createContextFromRpcTransaction(txid, tx);
214
+ if (context === null) {
215
+ options.state.negativeTxids.add(txid);
216
+ }
217
+ else {
218
+ options.state.contexts.set(txid, context);
219
+ options.state.negativeTxids.delete(txid);
220
+ }
221
+ }
222
+ completed += 1;
223
+ await reportProgress(completed, completed === options.unknownTxids.length);
224
+ }
225
+ });
226
+ await Promise.all(workers);
227
+ if (failed) {
228
+ throw new Error("mining_mempool_index_hydration_incomplete");
229
+ }
230
+ }
231
+ export async function hydrateMiningMempoolIndex(options) {
232
+ const state = getOrCreateState({
233
+ walletRootId: options.walletRootId,
234
+ serviceIdentity: options.serviceIdentity,
235
+ cachePath: options.cachePath,
236
+ });
237
+ await loadStateFromDisk(state);
238
+ let changed = pruneStateToVisibleTxids(state, options.visibleTxids);
239
+ const unknownTxids = options.visibleTxids.filter((txid) => !state.contexts.has(txid) && !state.negativeTxids.has(txid));
240
+ if (unknownTxids.length > 0) {
241
+ await hydrateUnknownTxids({
242
+ state,
243
+ rpc: options.rpc,
244
+ unknownTxids,
245
+ cooperativeYield: options.cooperativeYield,
246
+ cooperativeYieldEvery: options.cooperativeYieldEvery,
247
+ throwIfStopping: options.throwIfStopping,
248
+ onWarmupProgress: options.onWarmupProgress,
249
+ });
250
+ changed = true;
251
+ }
252
+ if (changed) {
253
+ await saveState(state);
254
+ }
255
+ return {
256
+ contexts: new Map(state.contexts),
257
+ cacheStatus: unknownTxids.length === 0 ? "indexed" : "index-warming",
258
+ hydratedCount: unknownTxids.length,
259
+ };
260
+ }
261
+ function readVarInt(bytes, offset) {
262
+ const first = bytes[offset];
263
+ if (first === undefined) {
264
+ return null;
265
+ }
266
+ if (first < 0xfd) {
267
+ return { value: first, nextOffset: offset + 1, raw: bytes.subarray(offset, offset + 1) };
268
+ }
269
+ if (first === 0xfd && (offset + 3) <= bytes.length) {
270
+ return {
271
+ value: bytes.readUInt16LE(offset + 1),
272
+ nextOffset: offset + 3,
273
+ raw: bytes.subarray(offset, offset + 3),
274
+ };
275
+ }
276
+ if (first === 0xfe && (offset + 5) <= bytes.length) {
277
+ return {
278
+ value: bytes.readUInt32LE(offset + 1),
279
+ nextOffset: offset + 5,
280
+ raw: bytes.subarray(offset, offset + 5),
281
+ };
282
+ }
283
+ if (first === 0xff && (offset + 9) <= bytes.length) {
284
+ const value = bytes.readBigUInt64LE(offset + 1);
285
+ if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
286
+ return null;
287
+ }
288
+ return {
289
+ value: Number(value),
290
+ nextOffset: offset + 9,
291
+ raw: bytes.subarray(offset, offset + 9),
292
+ };
293
+ }
294
+ return null;
295
+ }
296
+ function doubleSha256(bytes) {
297
+ return createHash("sha256").update(createHash("sha256").update(bytes).digest()).digest();
298
+ }
299
+ function parseRawTransactionForIndex(rawHex) {
300
+ const bytes = Buffer.from(rawHex, "hex");
301
+ if (bytes.length < 10) {
302
+ return null;
303
+ }
304
+ let offset = 4;
305
+ const txidParts = [bytes.subarray(0, 4)];
306
+ const segwit = bytes[offset] === 0x00 && bytes[offset + 1] !== undefined && bytes[offset + 1] !== 0x00;
307
+ if (segwit) {
308
+ offset += 2;
309
+ }
310
+ const inputCount = readVarInt(bytes, offset);
311
+ if (inputCount === null) {
312
+ return null;
313
+ }
314
+ txidParts.push(inputCount.raw);
315
+ offset = inputCount.nextOffset;
316
+ const inputTxids = [];
317
+ for (let index = 0; index < inputCount.value; index += 1) {
318
+ if ((offset + 36) > bytes.length) {
319
+ return null;
320
+ }
321
+ const prevoutHash = bytes.subarray(offset, offset + 32);
322
+ inputTxids.push(Buffer.from(prevoutHash).reverse().toString("hex"));
323
+ const inputFixed = bytes.subarray(offset, offset + 36);
324
+ txidParts.push(inputFixed);
325
+ offset += 36;
326
+ const scriptLength = readVarInt(bytes, offset);
327
+ if (scriptLength === null || (scriptLength.nextOffset + scriptLength.value + 4) > bytes.length) {
328
+ return null;
329
+ }
330
+ txidParts.push(scriptLength.raw);
331
+ offset = scriptLength.nextOffset;
332
+ txidParts.push(bytes.subarray(offset, offset + scriptLength.value + 4));
333
+ offset += scriptLength.value + 4;
334
+ }
335
+ const outputCount = readVarInt(bytes, offset);
336
+ if (outputCount === null) {
337
+ return null;
338
+ }
339
+ txidParts.push(outputCount.raw);
340
+ offset = outputCount.nextOffset;
341
+ let payload = null;
342
+ for (let index = 0; index < outputCount.value; index += 1) {
343
+ if ((offset + 8) > bytes.length) {
344
+ return null;
345
+ }
346
+ txidParts.push(bytes.subarray(offset, offset + 8));
347
+ offset += 8;
348
+ const scriptLength = readVarInt(bytes, offset);
349
+ if (scriptLength === null || (scriptLength.nextOffset + scriptLength.value) > bytes.length) {
350
+ return null;
351
+ }
352
+ txidParts.push(scriptLength.raw);
353
+ offset = scriptLength.nextOffset;
354
+ const script = bytes.subarray(offset, offset + scriptLength.value);
355
+ txidParts.push(script);
356
+ payload ??= extractOpReturnPayloadFromScriptHex(script.toString("hex"));
357
+ offset += scriptLength.value;
358
+ }
359
+ if (segwit) {
360
+ for (let index = 0; index < inputCount.value; index += 1) {
361
+ const witnessCount = readVarInt(bytes, offset);
362
+ if (witnessCount === null) {
363
+ return null;
364
+ }
365
+ offset = witnessCount.nextOffset;
366
+ for (let witnessIndex = 0; witnessIndex < witnessCount.value; witnessIndex += 1) {
367
+ const witnessLength = readVarInt(bytes, offset);
368
+ if (witnessLength === null || (witnessLength.nextOffset + witnessLength.value) > bytes.length) {
369
+ return null;
370
+ }
371
+ offset = witnessLength.nextOffset + witnessLength.value;
372
+ }
373
+ }
374
+ }
375
+ if ((offset + 4) !== bytes.length) {
376
+ return null;
377
+ }
378
+ txidParts.push(bytes.subarray(offset, offset + 4));
379
+ return {
380
+ txid: Buffer.from(doubleSha256(Buffer.concat(txidParts))).reverse().toString("hex"),
381
+ inputTxids,
382
+ payload,
383
+ };
384
+ }
385
+ function normalizeZmqFrames(frames) {
386
+ if (Array.isArray(frames)) {
387
+ return frames.filter(Buffer.isBuffer);
388
+ }
389
+ return Buffer.isBuffer(frames) ? [frames] : [];
390
+ }
391
+ async function loadZeroMqModule() {
392
+ return await import("zeromq");
393
+ }
394
+ export async function ensureMiningMempoolRawTxSubscriber(options) {
395
+ if (options.rawTxTopic !== "rawtx") {
396
+ return false;
397
+ }
398
+ const key = cacheKey(options.walletRootId, options.serviceIdentity, options.cachePath);
399
+ if (rawTxSubscribers.has(key)) {
400
+ return true;
401
+ }
402
+ const state = getOrCreateState({
403
+ walletRootId: options.walletRootId,
404
+ serviceIdentity: options.serviceIdentity,
405
+ cachePath: options.cachePath,
406
+ });
407
+ await loadStateFromDisk(state);
408
+ let subscriber;
409
+ try {
410
+ const zeroMq = options.loadZeroMq === undefined ? await loadZeroMqModule() : await options.loadZeroMq();
411
+ subscriber = new zeroMq.Subscriber();
412
+ subscriber.connect(options.zmqEndpoint);
413
+ subscriber.subscribe(options.rawTxTopic);
414
+ }
415
+ catch {
416
+ return false;
417
+ }
418
+ const loop = (async () => {
419
+ try {
420
+ for await (const frames of subscriber) {
421
+ const normalized = normalizeZmqFrames(frames);
422
+ if (normalized.length < 2 || normalized[0]?.toString() !== options.rawTxTopic) {
423
+ continue;
424
+ }
425
+ const parsed = parseRawTransactionForIndex(normalized[1].toString("hex"));
426
+ if (parsed === null || isCogPayload(parsed.payload)) {
427
+ continue;
428
+ }
429
+ state.negativeTxids.add(parsed.txid);
430
+ void saveState(state).catch(() => undefined);
431
+ }
432
+ }
433
+ catch {
434
+ // The index remains conservative; the gate hydrates unknown txids by RPC.
435
+ }
436
+ })();
437
+ rawTxSubscribers.set(key, { key, subscriber, loop });
438
+ return true;
439
+ }
440
+ export async function closeMiningMempoolIndexSubscribersForTesting() {
441
+ const subscribers = [...rawTxSubscribers.values()];
442
+ rawTxSubscribers.clear();
443
+ for (const state of subscribers) {
444
+ state.subscriber.close();
445
+ await state.loop.catch(() => undefined);
446
+ }
447
+ }
448
+ export function clearMiningMempoolIndexCacheForTesting() {
449
+ indexStates.clear();
450
+ }
451
+ export const parseRawTransactionForMiningMempoolIndexTesting = parseRawTransactionForIndex;
@@ -36,6 +36,7 @@ import { createMiningSentenceRequestLimits } from "./sentence-protocol.js";
36
36
  import { generateMiningSentences, MiningProviderRequestError } from "./sentences.js";
37
37
  import { MiningFollowVisualizer, } from "./visualizer.js";
38
38
  import { createIndexedMiningFollowVisualizerState, findRecentMiningWin, loadMiningVisibleFollowBlockTimes, resolveFundingDisplaySats, resolveSettledBoard, syncMiningUiForCurrentTip, syncMiningVisualizerBalances, syncMiningVisualizerBlockTimes, } from "./visualizer-sync.js";
39
+ import { ensureMiningMempoolRawTxSubscriber, resolveMiningMempoolIndexCachePath, resolveMiningMempoolServiceIdentity, } from "./mempool-index.js";
39
40
  const BEST_BLOCK_POLL_INTERVAL_MS = 500;
40
41
  const MINING_SUSPEND_HEARTBEAT_INTERVAL_MS = 1_000;
41
42
  class MiningSuspendDetectedError extends Error {
@@ -237,6 +238,24 @@ async function performMiningCycle(options) {
237
238
  throwIfStopping();
238
239
  throwIfMiningSuspendDetected(options.suspendDetector);
239
240
  const rpc = options.rpcFactory(service.rpc);
241
+ const serviceZmq = service.zmq;
242
+ const mempoolIndexCachePath = resolveMiningMempoolIndexCachePath(options.paths);
243
+ const mempoolIndexServiceIdentity = resolveMiningMempoolServiceIdentity({
244
+ dataDir: service.dataDir ?? options.dataDir,
245
+ pid: service.pid,
246
+ zmqEndpoint: serviceZmq?.endpoint ?? "unknown-zmq-endpoint",
247
+ rawTxTopic: serviceZmq?.rawTxTopic,
248
+ });
249
+ const mempoolIndexRawTxSupported = serviceZmq?.rawTxTopic === "rawtx";
250
+ if (mempoolIndexRawTxSupported && serviceZmq?.endpoint !== undefined) {
251
+ await ensureMiningMempoolRawTxSubscriber({
252
+ walletRootId: readContext.localState.state.walletRootId,
253
+ serviceIdentity: mempoolIndexServiceIdentity,
254
+ cachePath: mempoolIndexCachePath,
255
+ zmqEndpoint: serviceZmq.endpoint,
256
+ rawTxTopic: serviceZmq.rawTxTopic,
257
+ }).catch(() => false);
258
+ }
240
259
  const reconciliation = await reconcileLiveMiningState({
241
260
  state: readContext.localState.state,
242
261
  rpc,
@@ -437,6 +456,11 @@ async function performMiningCycle(options) {
437
456
  assaySentencesImpl: options.assaySentencesImpl,
438
457
  cooperativeYieldImpl: options.cooperativeYieldImpl,
439
458
  cooperativeYieldEvery: options.cooperativeYieldEvery,
459
+ mempoolIndex: {
460
+ rawTxSupported: mempoolIndexRawTxSupported,
461
+ cachePath: mempoolIndexCachePath,
462
+ serviceIdentity: mempoolIndexServiceIdentity,
463
+ },
440
464
  nowImpl: now,
441
465
  saveCycleStatus: async (context, overrides) => await saveCycleStatus(context, overrides),
442
466
  appendEvent: async (event) => await appendEvent(options.paths, event),
@@ -109,7 +109,7 @@ export interface MiningRuntimeStatusV1 {
109
109
  higherRankedCompetitorDomainCount: number | null;
110
110
  dedupedCompetitorDomainCount: number | null;
111
111
  competitivenessGateIndeterminate: boolean | null;
112
- mempoolSequenceCacheStatus: "reused" | "refreshed" | null;
112
+ mempoolSequenceCacheStatus: "reused" | "refreshed" | "indexed" | "index-warming" | "fallback-scan" | null;
113
113
  currentPublishDecision: string | null;
114
114
  lastMempoolSequence: string | null;
115
115
  lastCompetitivenessGateAtUnixMs: number | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cogcoin/client",
3
- "version": "1.1.16",
3
+ "version": "1.2.0",
4
4
  "description": "Store-backed Cogcoin client with wallet flows, SQLite persistence, and managed Bitcoin Core integration.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -65,7 +65,7 @@
65
65
  "dependencies": {
66
66
  "@cogcoin/bitcoin": "30.2.0",
67
67
  "@cogcoin/genesis": "1.0.0",
68
- "@cogcoin/indexer": "1.0.1",
68
+ "@cogcoin/indexer": "1.0.2",
69
69
  "@cogcoin/scoring": "1.0.0",
70
70
  "@noble/hashes": "2.0.1",
71
71
  "@scure/base": "^2.0.0",
@@ -75,7 +75,7 @@
75
75
  "zeromq": "6.5.0"
76
76
  },
77
77
  "devDependencies": {
78
- "@cogcoin/vectors": "1.0.0",
78
+ "@cogcoin/vectors": "1.0.1",
79
79
  "@types/node": "^25.5.0",
80
80
  "typescript": "^6.0.2"
81
81
  }