@relay-federation/bridge 0.3.15 → 0.3.16

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.
@@ -1,883 +1,896 @@
1
- import { Level } from 'level'
2
- import { EventEmitter } from 'node:events'
3
- import { createHash } from 'node:crypto'
4
- import { join } from 'node:path'
5
- import { mkdir, writeFile, readFile } from 'node:fs/promises'
6
-
7
- /**
8
- * PersistentStore — LevelDB-backed storage for bridge state.
9
- *
10
- * Stores headers, transactions, and arbitrary metadata in sublevel
11
- * namespaces. Replaces the in-memory Maps used by HeaderRelay and
12
- * TxRelay with durable storage that survives restarts.
13
- *
14
- * Sublevels:
15
- * headers — height → { height, hash, prevHash }
16
- * txs — txid → rawHex
17
- * utxos — txid:vout → { txid, vout, satoshis, scriptHex, address, spent }
18
- * meta — key → value (bestHeight, bestHash, etc.)
19
- * watched — txid → { txid, address, direction, timestamp }
20
- *
21
- * Events:
22
- * 'open' — store ready
23
- * 'error' — LevelDB error
24
- */
25
- export class PersistentStore extends EventEmitter {
26
- /**
27
- * @param {string} dataDir — directory for the LevelDB database
28
- */
29
- constructor (dataDir) {
30
- super()
31
- this.dbPath = join(dataDir, 'bridge.db')
32
- this.db = null
33
- this._headers = null
34
- this._txs = null
35
- this._utxos = null
36
- this._meta = null
37
- this._watched = null
38
- this._hashIndex = null
39
- this._inscriptions = null
40
- this._inscriptionIdx = null
41
- this._txStatus = null
42
- this._txBlock = null
43
- this._content = null
44
- this._tokens = null
45
- this._sessions = null
46
- this._paymentReceipts = null
47
- this._contentDir = join(dataDir, 'content')
48
- }
49
-
50
- /** Open the database and create sublevels. */
51
- async open () {
52
- this.db = new Level(this.dbPath, { valueEncoding: 'json' })
53
- await this.db.open()
54
- this._headers = this.db.sublevel('headers', { valueEncoding: 'json' })
55
- this._txs = this.db.sublevel('txs', { valueEncoding: 'utf8' })
56
- this._utxos = this.db.sublevel('utxos', { valueEncoding: 'json' })
57
- this._meta = this.db.sublevel('meta', { valueEncoding: 'json' })
58
- this._watched = this.db.sublevel('watched', { valueEncoding: 'json' })
59
- this._hashIndex = this.db.sublevel('hashIndex', { valueEncoding: 'json' })
60
- this._inscriptions = this.db.sublevel('inscriptions', { valueEncoding: 'json' })
61
- this._inscriptionIdx = this.db.sublevel('inscIdx', { valueEncoding: 'json' })
62
- this._txStatus = this.db.sublevel('txStatus', { valueEncoding: 'json' })
63
- this._txBlock = this.db.sublevel('txBlock', { valueEncoding: 'json' })
64
- this._content = this.db.sublevel('content', { valueEncoding: 'json' })
65
- this._tokens = this.db.sublevel('tokens', { valueEncoding: 'json' })
66
- this._sessions = this.db.sublevel('sessions', { valueEncoding: 'json' })
67
- this._paymentReceipts = this.db.sublevel('payment_receipts', { valueEncoding: 'json' })
68
- await mkdir(this._contentDir, { recursive: true })
69
- this.emit('open')
70
- }
71
-
72
- /** Close the database. */
73
- async close () {
74
- if (this.db) await this.db.close()
75
- }
76
-
77
- // ── Headers ──────────────────────────────────────────────
78
-
79
- /**
80
- * Store a header by height (with hash index).
81
- * @param {{ height: number, hash: string, prevHash: string, merkleRoot?: string, timestamp?: number, bits?: number, nonce?: number, version?: number }} header
82
- */
83
- async putHeader (header) {
84
- await this._headers.put(String(header.height), header)
85
- if (header.hash) {
86
- await this._hashIndex.put(header.hash, header.height)
87
- }
88
- }
89
-
90
- /**
91
- * Store multiple headers in a batch (with hash index).
92
- * @param {Array<{ height: number, hash: string, prevHash: string, merkleRoot?: string, timestamp?: number, bits?: number, nonce?: number, version?: number }>} headers
93
- */
94
- async putHeaders (headers) {
95
- const headerOps = headers.map(h => ({
96
- type: 'put',
97
- key: String(h.height),
98
- value: h
99
- }))
100
- await this._headers.batch(headerOps)
101
- const hashOps = headers.filter(h => h.hash).map(h => ({
102
- type: 'put',
103
- key: h.hash,
104
- value: h.height
105
- }))
106
- if (hashOps.length > 0) {
107
- await this._hashIndex.batch(hashOps)
108
- }
109
- }
110
-
111
- /**
112
- * Get a header by height.
113
- * @param {number} height
114
- * @returns {Promise<{ height: number, hash: string, prevHash: string }|null>}
115
- */
116
- async getHeader (height) {
117
- const val = await this._headers.get(String(height))
118
- return val !== undefined ? val : null
119
- }
120
-
121
- /**
122
- * Get a header by block hash.
123
- * @param {string} hash
124
- * @returns {Promise<object|null>}
125
- */
126
- async getHeaderByHash (hash) {
127
- const height = await this._hashIndex.get(hash)
128
- if (height === undefined) return null
129
- return this.getHeader(height)
130
- }
131
-
132
- /**
133
- * Verify a merkle proof against a stored block header.
134
- * @param {string} txHash — transaction hash (hex, display order)
135
- * @param {string[]} merkleProof — sibling hashes in the merkle path
136
- * @param {number} txIndex — transaction index in the block
137
- * @param {string} blockHash — block hash to verify against
138
- * @returns {Promise<{ verified: boolean, blockHeight: number, blockTimestamp: number }>}
139
- */
140
- async verifyMerkleProof (txHash, merkleProof, txIndex, blockHash) {
141
- const header = await this.getHeaderByHash(blockHash)
142
- if (!header) {
143
- throw new Error(`Block ${blockHash} not found in header chain`)
144
- }
145
- if (!header.merkleRoot) {
146
- throw new Error(`Header at height ${header.height} has no merkleRoot stored`)
147
- }
148
-
149
- // Compute merkle root from proof
150
- let hash = Buffer.from(txHash, 'hex').reverse()
151
- let index = txIndex
152
-
153
- for (const proofHash of merkleProof) {
154
- const sibling = Buffer.from(proofHash, 'hex').reverse()
155
- const combined = (index % 2 === 0)
156
- ? Buffer.concat([hash, sibling])
157
- : Buffer.concat([sibling, hash])
158
- hash = doubleSha256(combined)
159
- index = Math.floor(index / 2)
160
- }
161
-
162
- const calculatedRoot = hash.reverse().toString('hex')
163
-
164
- if (calculatedRoot !== header.merkleRoot) {
165
- throw new Error('Merkle proof verification failed')
166
- }
167
-
168
- return {
169
- verified: true,
170
- blockHash: header.hash,
171
- blockHeight: header.height,
172
- blockTimestamp: header.timestamp
173
- }
174
- }
175
-
176
- // ── Transactions ─────────────────────────────────────────
177
-
178
- /**
179
- * Store a raw transaction.
180
- * @param {string} txid
181
- * @param {string} rawHex
182
- */
183
- async putTx (txid, rawHex) {
184
- await this._txs.put(txid, rawHex)
185
- }
186
-
187
- /**
188
- * Get a raw transaction by txid.
189
- * @param {string} txid
190
- * @returns {Promise<string|null>} rawHex or null
191
- */
192
- async getTx (txid) {
193
- const val = await this._txs.get(txid)
194
- return val !== undefined ? val : null
195
- }
196
-
197
- /**
198
- * Check if a transaction exists.
199
- * @param {string} txid
200
- * @returns {Promise<boolean>}
201
- */
202
- async hasTx (txid) {
203
- return (await this.getTx(txid)) !== null
204
- }
205
-
206
- // ── UTXOs ────────────────────────────────────────────────
207
-
208
- /**
209
- * Store a UTXO.
210
- * @param {{ txid: string, vout: number, satoshis: number, scriptHex: string, address: string }} utxo
211
- */
212
- async putUtxo (utxo) {
213
- const key = `${utxo.txid}:${utxo.vout}`
214
- await this._utxos.put(key, { ...utxo, spent: false })
215
- }
216
-
217
- /**
218
- * Mark a UTXO as spent.
219
- * @param {string} txid
220
- * @param {number} vout
221
- */
222
- async spendUtxo (txid, vout) {
223
- const key = `${txid}:${vout}`
224
- const utxo = await this._utxos.get(key)
225
- if (utxo === undefined) return
226
- utxo.spent = true
227
- await this._utxos.put(key, utxo)
228
- }
229
-
230
- /**
231
- * Get all unspent UTXOs.
232
- * @returns {Promise<Array<{ txid: string, vout: number, satoshis: number, scriptHex: string, address: string }>>}
233
- */
234
- async getUnspentUtxos () {
235
- const utxos = []
236
- for await (const [, utxo] of this._utxos.iterator()) {
237
- if (!utxo.spent) utxos.push(utxo)
238
- }
239
- return utxos
240
- }
241
-
242
- /**
243
- * Get total unspent balance in satoshis.
244
- * @returns {Promise<number>}
245
- */
246
- async getBalance () {
247
- let total = 0
248
- for await (const [, utxo] of this._utxos.iterator()) {
249
- if (!utxo.spent) total += utxo.satoshis
250
- }
251
- return total
252
- }
253
-
254
- // ── Watched address matches ──────────────────────────────
255
-
256
- /**
257
- * Store a watched-address match (a tx that touched a watched address).
258
- * @param {{ txid: string, address: string, direction: 'in'|'out', timestamp: number }} match
259
- */
260
- async putWatchedTx (match) {
261
- const key = `${match.address}:${match.txid}`
262
- await this._watched.put(key, match)
263
- }
264
-
265
- /**
266
- * Get all watched-address matches for an address.
267
- * @param {string} address
268
- * @returns {Promise<Array>}
269
- */
270
- async getWatchedTxs (address) {
271
- const matches = []
272
- for await (const [key, value] of this._watched.iterator()) {
273
- if (key.startsWith(`${address}:`)) {
274
- matches.push(value)
275
- }
276
- }
277
- return matches
278
- }
279
-
280
- // ── Sessions (Indelible) ───────────────────────────────────
281
-
282
- /**
283
- * Store a session metadata record with sort index.
284
- * PK: s!{address}!{txId} SK: t!{address}!{revTs}!{txId}
285
- * @param {object} session must have txId and address
286
- */
287
- async putSession (session) {
288
- const { txId, address } = session
289
- if (!txId || !address) throw new Error('txId and address required')
290
- const pk = `s!${address}!${txId}`
291
- const ts = session.timestamp || new Date().toISOString()
292
- const revTs = String(9999999999999 - new Date(ts).getTime()).padStart(13, '0')
293
- const sk = `t!${address}!${revTs}!${txId}`
294
- const record = {
295
- txId, address,
296
- session_id: session.session_id || null,
297
- prev_session_id: session.prev_session_id || null,
298
- summary: session.summary || '',
299
- message_count: session.message_count || 0,
300
- save_type: session.save_type || 'full',
301
- timestamp: ts,
302
- receivedAt: new Date().toISOString()
303
- }
304
- await this._sessions.batch([
305
- { type: 'put', key: pk, value: record },
306
- { type: 'put', key: sk, value: txId }
307
- ])
308
- return record
309
- }
310
-
311
- /**
312
- * Get sessions for an address, newest first.
313
- * @param {string} address
314
- * @param {number} [limit=200]
315
- * @returns {Promise<Array>}
316
- */
317
- async getSessions (address, limit = 200) {
318
- const prefix = `t!${address}!`
319
- const results = []
320
- for await (const [, txId] of this._sessions.iterator({
321
- gte: prefix, lt: prefix + '~', limit
322
- })) {
323
- const record = await this._safeGet(this._sessions, `s!${address}!${txId}`)
324
- if (record) results.push(record)
325
- }
326
- return results
327
- }
328
-
329
- /**
330
- * Batch import sessions (for backfill).
331
- * @param {Array} sessions — array of session objects
332
- * @returns {Promise<number>} count imported
333
- */
334
- async putSessionsBatch (sessions) {
335
- const ops = []
336
- for (const session of sessions) {
337
- const { txId, address } = session
338
- if (!txId || !address) continue
339
- const pk = `s!${address}!${txId}`
340
- const ts = session.timestamp || new Date().toISOString()
341
- const revTs = String(9999999999999 - new Date(ts).getTime()).padStart(13, '0')
342
- const sk = `t!${address}!${revTs}!${txId}`
343
- const record = {
344
- txId, address,
345
- session_id: session.session_id || null,
346
- prev_session_id: session.prev_session_id || null,
347
- summary: session.summary || '',
348
- message_count: session.message_count || 0,
349
- save_type: session.save_type || 'full',
350
- timestamp: ts,
351
- receivedAt: new Date().toISOString()
352
- }
353
- ops.push({ type: 'put', key: pk, value: record })
354
- ops.push({ type: 'put', key: sk, value: txId })
355
- }
356
- if (ops.length > 0) await this._sessions.batch(ops)
357
- return ops.length / 2
358
- }
359
-
360
- /**
361
- * Get summary of all addresses with sessions (for peer sync announce).
362
- * @returns {Promise<Array<{ address: string, count: number, latest: string }>>}
363
- */
364
- async getSessionAddresses () {
365
- const map = new Map() // address → { count, latest }
366
- for await (const [key, value] of this._sessions.iterator({
367
- gte: 's!', lt: 's!~'
368
- })) {
369
- const addr = key.split('!')[1]
370
- const entry = map.get(addr)
371
- if (!entry) {
372
- map.set(addr, { count: 1, latest: value.timestamp || '' })
373
- } else {
374
- entry.count++
375
- if (value.timestamp > entry.latest) entry.latest = value.timestamp
376
- }
377
- }
378
- return [...map].map(([address, { count, latest }]) => ({ address, count, latest }))
379
- }
380
-
381
- // ── Metadata ─────────────────────────────────────────────
382
-
383
- /**
384
- * Store a metadata value.
385
- * @param {string} key
386
- * @param {*} value — any JSON-serializable value
387
- */
388
- async putMeta (key, value) {
389
- await this._meta.put(key, value)
390
- }
391
-
392
- /**
393
- * Get a metadata value.
394
- * @param {string} key
395
- * @param {*} [defaultValue=null]
396
- * @returns {Promise<*>}
397
- */
398
- async getMeta (key, defaultValue = null) {
399
- const val = await this._meta.get(key)
400
- return val !== undefined ? val : defaultValue
401
- }
402
- // ── Tx Status + Block Mapping ───────────────────────────
403
-
404
- /**
405
- * Set or update tx lifecycle state.
406
- * @param {string} txid
407
- * @param {'mempool'|'confirmed'|'orphaned'|'dropped'} state
408
- * @param {object} [meta] — optional fields: blockHash, height, source
409
- */
410
- async updateTxStatus (txid, state, meta = {}) {
411
- const key = `s!${txid}`
412
- const now = Date.now()
413
- let existing = null
414
- try {
415
- const val = await this._txStatus.get(key)
416
- if (val !== undefined) existing = val
417
- } catch {}
418
-
419
- const record = existing || { firstSeen: now }
420
- record.state = state
421
- record.lastSeen = now
422
- record.updatedAt = now
423
- if (meta.blockHash) record.blockHash = meta.blockHash
424
- if (meta.height !== undefined) record.height = meta.height
425
- if (meta.source) record.source = meta.source
426
-
427
- const batch = [{ type: 'put', key, value: record }]
428
-
429
- // Maintain mempool secondary index
430
- if (state === 'mempool') {
431
- batch.push({ type: 'put', key: `mempool!${txid}`, value: 1 })
432
- } else if (existing?.state === 'mempool') {
433
- batch.push({ type: 'del', key: `mempool!${txid}` })
434
- }
435
-
436
- await this._txStatus.batch(batch)
437
- return record
438
- }
439
-
440
- /**
441
- * Get tx lifecycle state.
442
- * @param {string} txid
443
- * @returns {Promise<object|null>}
444
- */
445
- async getTxStatus (txid) {
446
- try {
447
- const val = await this._txStatus.get(`s!${txid}`)
448
- return val !== undefined ? val : null
449
- } catch { return null }
450
- }
451
-
452
- /**
453
- * Confirm a tx — atomic batch: txBlock + reverse index + txStatus update.
454
- * @param {string} txid
455
- * @param {string} blockHash
456
- * @param {number} height
457
- * @param {{ nodes: string[], index: number }|null} proof
458
- */
459
- async confirmTx (txid, blockHash, height, proof = null) {
460
- const now = Date.now()
461
- const blockRecord = { blockHash, height, confirmedAt: now, verified: !!proof }
462
- if (proof) blockRecord.proof = proof
463
-
464
- // Atomic batch across txBlock + txStatus
465
- const txBlockBatch = [
466
- { type: 'put', key: `tx!${txid}`, value: blockRecord },
467
- { type: 'put', key: `block!${blockHash}!tx!${txid}`, value: 1 }
468
- ]
469
- await this._txBlock.batch(txBlockBatch)
470
-
471
- await this.updateTxStatus(txid, 'confirmed', { blockHash, height })
472
- this.emit('tx:confirmed', { txid, blockHash, height })
473
- }
474
-
475
- /**
476
- * Get tx block placement.
477
- * @param {string} txid
478
- * @returns {Promise<object|null>}
479
- */
480
- async getTxBlock (txid) {
481
- try {
482
- const val = await this._txBlock.get(`tx!${txid}`)
483
- return val !== undefined ? val : null
484
- } catch { return null }
485
- }
486
-
487
- /**
488
- * Handle reorg — mark all txs in disconnected block as orphaned.
489
- * @param {string} blockHash — the disconnected block hash
490
- * @returns {Promise<string[]>} list of affected txids
491
- */
492
- async handleReorg (blockHash) {
493
- const affected = []
494
- const prefix = `block!${blockHash}!tx!`
495
-
496
- // Find all txids in this block via reverse index
497
- for await (const [key] of this._txBlock.iterator({ gte: prefix, lt: prefix + '~' })) {
498
- const txid = key.slice(prefix.length)
499
- affected.push(txid)
500
- }
501
-
502
- // Mark each as orphaned + clean up block associations
503
- for (const txid of affected) {
504
- await this.updateTxStatus(txid, 'orphaned', { blockHash })
505
- await this._txBlock.del(`tx!${txid}`)
506
- await this._txBlock.del(`block!${blockHash}!tx!${txid}`)
507
- }
508
-
509
- return affected
510
- }
511
-
512
- // ── Content-Addressed Storage ───────────────────────────
513
-
514
- static CAS_THRESHOLD = 4096 // 4KB — below this, inline in LevelDB
515
-
516
- /**
517
- * Store content bytes via CAS. Small content inline, large to filesystem.
518
- * @param {string} hexContent — hex-encoded content bytes
519
- * @param {string} [mime] — content type
520
- * @returns {Promise<{ contentHash: string, contentLen: number, contentPath: string|null, inline: boolean }>}
521
- */
522
- async putContent (hexContent, mime) {
523
- const buf = Buffer.from(hexContent, 'hex')
524
- const contentHash = createHash('sha256').update(buf).digest('hex')
525
- const contentLen = buf.length
526
- const inline = contentLen < PersistentStore.CAS_THRESHOLD
527
-
528
- const record = { len: contentLen, mime: mime || null, createdAt: Date.now() }
529
-
530
- if (inline) {
531
- record.inline = hexContent
532
- record.path = null
533
- } else {
534
- const dir = join(this._contentDir, contentHash.slice(0, 2))
535
- const filePath = join(dir, contentHash)
536
- await mkdir(dir, { recursive: true })
537
- await writeFile(filePath, buf)
538
- record.path = filePath
539
- }
540
-
541
- await this._content.put(`c!${contentHash}`, record)
542
- return { contentHash, contentLen, contentPath: record.path, inline }
543
- }
544
-
545
- /**
546
- * Get content bytes by hash.
547
- * @param {string} contentHash
548
- * @returns {Promise<Buffer|null>}
549
- */
550
- async getContentBytes (contentHash) {
551
- let record
552
- try {
553
- const val = await this._content.get(`c!${contentHash}`)
554
- if (val === undefined) return null
555
- record = val
556
- } catch { return null }
557
-
558
- if (record.inline) {
559
- return Buffer.from(record.inline, 'hex')
560
- }
561
- if (record.path) {
562
- try { return await readFile(record.path) } catch { return null }
563
- }
564
- return null
565
- }
566
-
567
- /**
568
- * Get content metadata by hash.
569
- * @param {string} contentHash
570
- * @returns {Promise<object|null>}
571
- */
572
- async getContentMeta (contentHash) {
573
- try {
574
- const val = await this._content.get(`c!${contentHash}`)
575
- return val !== undefined ? val : null
576
- } catch { return null }
577
- }
578
-
579
- // ── Token Tracking (BSV-20) ─────────────────────────────
580
-
581
- /**
582
- * Process a BSV-20 token operation (confirmed-only).
583
- * Uses atomic batch() for all writes. Keyed by scriptHash for owner identity.
584
- * @param {{ op: string, tick: string, amt: string, ownerScriptHash: string, address: string|null, txid: string, height: number, blockHash: string }} params
585
- * @returns {Promise<{ valid: boolean, reason?: string }>}
586
- */
587
- async processTokenOp ({ op, tick, amt, ownerScriptHash, address, txid, height, blockHash }) {
588
- const tickNorm = tick.toLowerCase().trim()
589
-
590
- if (op === 'deploy') {
591
- // Only first deploy counts (chain-ordered by height)
592
- const existing = await this._safeGet(this._tokens, `tick!${tickNorm}`)
593
- if (existing) return { valid: false, reason: 'already deployed' }
594
-
595
- const parsed = typeof amt === 'object' ? amt : {}
596
- const batch = [
597
- { type: 'put', key: `tick!${tickNorm}`, value: {
598
- tick: tickNorm, max: parsed.max || '0', lim: parsed.lim || '0',
599
- dec: parsed.dec || '0', deployer: ownerScriptHash, deployerAddr: address,
600
- deployTxid: txid, deployHeight: height, totalMinted: '0'
601
- }},
602
- { type: 'put', key: `op!${String(height).padStart(10, '0')}!${txid}!deploy`, value: {
603
- tick: tickNorm, op: 'deploy', ownerScriptHash, valid: true
604
- }}
605
- ]
606
- await this._tokens.batch(batch)
607
- return { valid: true }
608
- }
609
-
610
- if (op === 'mint') {
611
- const deploy = await this._safeGet(this._tokens, `tick!${tickNorm}`)
612
- if (!deploy) return { valid: false, reason: 'token not deployed' }
613
-
614
- const mintAmt = BigInt(amt || '0')
615
- if (mintAmt <= 0n) return { valid: false, reason: 'invalid amount' }
616
- if (deploy.lim !== '0' && mintAmt > BigInt(deploy.lim)) return { valid: false, reason: 'exceeds mint limit' }
617
-
618
- const newTotal = BigInt(deploy.totalMinted) + mintAmt
619
- if (deploy.max !== '0' && newTotal > BigInt(deploy.max)) return { valid: false, reason: 'exceeds max supply' }
620
-
621
- // Credit owner balance
622
- const balKey = `bal!${tickNorm}!owner!${ownerScriptHash}`
623
- const existing = await this._safeGet(this._tokens, balKey) || { confirmed: '0' }
624
- const newBal = (BigInt(existing.confirmed) + mintAmt).toString()
625
-
626
- const batch = [
627
- { type: 'put', key: `tick!${tickNorm}`, value: { ...deploy, totalMinted: newTotal.toString() } },
628
- { type: 'put', key: balKey, value: { confirmed: newBal, updatedAt: Date.now() } },
629
- { type: 'put', key: `op!${String(height).padStart(10, '0')}!${txid}!mint`, value: {
630
- tick: tickNorm, op: 'mint', amt: amt, ownerScriptHash, valid: true
631
- }}
632
- ]
633
- await this._tokens.batch(batch)
634
- return { valid: true }
635
- }
636
-
637
- // Transfers deferred to Phase 2
638
- return { valid: false, reason: 'transfers not yet supported' }
639
- }
640
-
641
- /**
642
- * Get token deploy info.
643
- * @param {string} tick
644
- * @returns {Promise<object|null>}
645
- */
646
- async getToken (tick) {
647
- return this._safeGet(this._tokens, `tick!${tick.toLowerCase().trim()}`)
648
- }
649
-
650
- /**
651
- * Get token balance for an owner.
652
- * @param {string} tick
653
- * @param {string} ownerScriptHash
654
- * @returns {Promise<string>} balance as string
655
- */
656
- async getTokenBalance (tick, ownerScriptHash) {
657
- const record = await this._safeGet(this._tokens, `bal!${tick.toLowerCase().trim()}!owner!${ownerScriptHash}`)
658
- return record ? record.confirmed : '0'
659
- }
660
-
661
- /**
662
- * List all deployed tokens.
663
- * @returns {Promise<Array>}
664
- */
665
- async listTokens () {
666
- const tokens = []
667
- const prefix = 'tick!'
668
- for await (const [key, value] of this._tokens.iterator({ gte: prefix, lt: prefix + '~' })) {
669
- tokens.push(value)
670
- }
671
- return tokens
672
- }
673
-
674
- /** Safe get — returns null instead of throwing for missing keys. */
675
- async _safeGet (sublevel, key) {
676
- try {
677
- const val = await sublevel.get(key)
678
- return val !== undefined ? val : null
679
- } catch { return null }
680
- }
681
-
682
- // ── Inscriptions ─────────────────────────────────────────
683
-
684
- /**
685
- * Store an inscription record with secondary indexes.
686
- * @param {{ txid: string, vout: number, contentType: string, contentSize: number, isBsv20: boolean, bsv20: object|null, timestamp: number, address: string|null }} record
687
- */
688
- async putInscription (record) {
689
- const key = `${record.txid}:${record.vout}`
690
- const suffix = `${record.txid}:${record.vout}`
691
-
692
- // Purge ALL stale secondary index entries pointing to this key
693
- try {
694
- const delBatch = []
695
- for await (const [idxKey, val] of this._inscriptionIdx.iterator()) {
696
- if (val === key && idxKey.endsWith(suffix)) delBatch.push({ type: 'del', key: idxKey })
697
- }
698
- if (delBatch.length) await this._inscriptionIdx.batch(delBatch)
699
- } catch {}
700
-
701
- // Route content through CAS
702
- if (record.content) {
703
- try {
704
- const cas = await this.putContent(record.content, record.contentType)
705
- record.contentHash = cas.contentHash
706
- record.contentLen = cas.contentLen
707
- // Strip raw content from inscription record if large (stored on filesystem)
708
- if (!cas.inline) {
709
- delete record.content
710
- }
711
- } catch {}
712
- }
713
-
714
- await this._inscriptions.put(key, record)
715
-
716
- const ts = String(record.timestamp).padStart(15, '0')
717
- const batch = [{ type: 'put', key: `time:${ts}:${suffix}`, value: key }]
718
- if (record.contentType) {
719
- batch.push({ type: 'put', key: `mime:${record.contentType}:${ts}:${suffix}`, value: key })
720
- }
721
- if (record.address) {
722
- batch.push({ type: 'put', key: `addr:${record.address}:${ts}:${suffix}`, value: key })
723
- }
724
- await this._inscriptionIdx.batch(batch)
725
- }
726
-
727
- /**
728
- * Query inscriptions with optional filters.
729
- * @param {{ mime?: string, address?: string, limit?: number }} opts
730
- * @returns {Promise<Array>}
731
- */
732
- async getInscriptions ({ mime, address, limit = 50 } = {}) {
733
- const results = []
734
- let prefix
735
- if (address) {
736
- prefix = `addr:${address}:`
737
- } else if (mime) {
738
- prefix = `mime:${mime}:`
739
- } else {
740
- prefix = 'time:'
741
- }
742
-
743
- for await (const [, primaryKey] of this._inscriptionIdx.iterator({
744
- gte: prefix, lt: prefix + '~', reverse: true, limit
745
- })) {
746
- try {
747
- const record = await this._inscriptions.get(primaryKey)
748
- if (record) {
749
- // Strip content from list results (can be 400KB+ per image)
750
- const { content, ...meta } = record
751
- results.push(meta)
752
- }
753
- } catch {}
754
- }
755
- return results
756
- }
757
-
758
- /**
759
- * Rebuild inscription secondary indexes from primary records.
760
- * Clears all index entries and re-creates from source of truth.
761
- * @returns {Promise<number>} count of inscriptions re-indexed
762
- */
763
- async rebuildInscriptionIndex () {
764
- // Clear entire index
765
- for await (const [key] of this._inscriptionIdx.iterator()) {
766
- await this._inscriptionIdx.del(key)
767
- }
768
- // Re-create from primary records
769
- let count = 0
770
- for await (const [, record] of this._inscriptions.iterator()) {
771
- const ts = String(record.timestamp).padStart(15, '0')
772
- const suffix = `${record.txid}:${record.vout}`
773
- const key = suffix
774
- const batch = [{ type: 'put', key: `time:${ts}:${suffix}`, value: key }]
775
- if (record.contentType) batch.push({ type: 'put', key: `mime:${record.contentType}:${ts}:${suffix}`, value: key })
776
- if (record.address) batch.push({ type: 'put', key: `addr:${record.address}:${ts}:${suffix}`, value: key })
777
- await this._inscriptionIdx.batch(batch)
778
- count++
779
- }
780
- return count
781
- }
782
-
783
- /**
784
- * Get a single inscription record (with content) by txid:vout.
785
- * @param {string} txid
786
- * @param {number} vout
787
- * @returns {Promise<object|null>}
788
- */
789
- async getInscription (txid, vout) {
790
- try {
791
- return await this._inscriptions.get(`${txid}:${vout}`)
792
- } catch {
793
- return null
794
- }
795
- }
796
-
797
- /**
798
- * Get total inscription count.
799
- * @returns {Promise<number>}
800
- */
801
- async getInscriptionCount () {
802
- let count = 0
803
- for await (const _ of this._inscriptions.keys()) count++
804
- return count
805
- }
806
-
807
- // ── x402 Payment Receipts ──────────────────────────────
808
-
809
- /**
810
- * Atomic claim — put-if-absent. Returns { ok: true } if claimed,
811
- * { ok: false } if txid already exists (replay blocked).
812
- */
813
- async claimTxid (txid, { routeKey, price, createdAt }) {
814
- const key = `u!${txid}`
815
- try {
816
- await this._paymentReceipts.put(key,
817
- { status: 'claimed', routeKey, price, createdAt },
818
- { ifNotExists: true })
819
- return { ok: true }
820
- } catch (err) {
821
- if (err.code !== 'LEVEL_KEY_EXISTS' && err?.cause?.code !== 'LEVEL_KEY_EXISTS')
822
- console.error(`[x402] unexpected claimTxid error for ${txid}:`, err.message)
823
- return { ok: false }
824
- }
825
- }
826
-
827
- /**
828
- * Release a claim (verification failed). Only deletes if status is 'claimed'.
829
- * Never deletes receipts — finalized payments are permanent.
830
- */
831
- async releaseClaim (txid) {
832
- const key = `u!${txid}`
833
- try {
834
- const val = await this._paymentReceipts.get(key)
835
- if (val && val.status === 'claimed') await this._paymentReceipts.del(key)
836
- } catch {}
837
- }
838
-
839
- /**
840
- * Promote claim to permanent receipt. Overwrites in-place — key is
841
- * NEVER deleted after this, blocking replay permanently.
842
- */
843
- async finalizePayment (txid, receipt) {
844
- await this._paymentReceipts.put(`u!${txid}`, { ...receipt, status: 'receipt' })
845
- }
846
-
847
- /**
848
- * Startup sweep delete stale claims older than maxAgeMs (default 5 min).
849
- * Only touches status === 'claimed' keys. Receipts are untouched.
850
- */
851
- async cleanupStaleClaims (maxAgeMs = 300000) {
852
- const now = Date.now()
853
- for await (const [key, val] of this._paymentReceipts.iterator({ gte: 'u!', lt: 'u~' })) {
854
- if (val.status !== 'claimed') continue
855
- if (!val.createdAt || (now - val.createdAt) > maxAgeMs)
856
- await this._paymentReceipts.del(key)
857
- }
858
- }
859
-
860
- /**
861
- * Prune old receipts chunked batch deletes for receipts older than N months.
862
- */
863
- async pruneOldReceipts (monthsToKeep = 6) {
864
- const cutoffMs = Date.now() - (monthsToKeep * 30 * 24 * 60 * 60 * 1000)
865
- const CHUNK = 500
866
- let ops = []
867
- for await (const [key, val] of this._paymentReceipts.iterator({ gte: 'u!', lt: 'u~' })) {
868
- if (val.status !== 'receipt') continue
869
- if (val.createdAt && val.createdAt < cutoffMs) {
870
- ops.push({ type: 'del', key })
871
- if (ops.length >= CHUNK) { await this._paymentReceipts.batch(ops); ops = [] }
872
- }
873
- }
874
- if (ops.length > 0) await this._paymentReceipts.batch(ops)
875
- }
876
- }
877
-
878
- /** Double SHA-256 (Bitcoin standard) */
879
- function doubleSha256 (data) {
880
- return createHash('sha256').update(
881
- createHash('sha256').update(data).digest()
882
- ).digest()
883
- }
1
+ import { Level } from 'level'
2
+ import { EventEmitter } from 'node:events'
3
+ import { createHash } from 'node:crypto'
4
+ import { join } from 'node:path'
5
+ import { mkdir, writeFile, readFile } from 'node:fs/promises'
6
+
7
+ /**
8
+ * PersistentStore — LevelDB-backed storage for bridge state.
9
+ *
10
+ * Stores headers, transactions, and arbitrary metadata in sublevel
11
+ * namespaces. Replaces the in-memory Maps used by HeaderRelay and
12
+ * TxRelay with durable storage that survives restarts.
13
+ *
14
+ * Sublevels:
15
+ * headers — height → { height, hash, prevHash }
16
+ * txs — txid → rawHex
17
+ * utxos — txid:vout → { txid, vout, satoshis, scriptHex, address, spent }
18
+ * meta — key → value (bestHeight, bestHash, etc.)
19
+ * watched — txid → { txid, address, direction, timestamp }
20
+ *
21
+ * Events:
22
+ * 'open' — store ready
23
+ * 'error' — LevelDB error
24
+ */
25
+ export class PersistentStore extends EventEmitter {
26
+ /**
27
+ * @param {string} dataDir — directory for the LevelDB database
28
+ */
29
+ constructor (dataDir) {
30
+ super()
31
+ this.dbPath = join(dataDir, 'bridge.db')
32
+ this.db = null
33
+ this._headers = null
34
+ this._txs = null
35
+ this._utxos = null
36
+ this._meta = null
37
+ this._watched = null
38
+ this._hashIndex = null
39
+ this._inscriptions = null
40
+ this._inscriptionIdx = null
41
+ this._txStatus = null
42
+ this._txBlock = null
43
+ this._content = null
44
+ this._tokens = null
45
+ this._sessions = null
46
+ this._paymentReceipts = null
47
+ this._contentDir = join(dataDir, 'content')
48
+ }
49
+
50
+ /** Open the database and create sublevels. */
51
+ async open () {
52
+ this.db = new Level(this.dbPath, { valueEncoding: 'json' })
53
+ await this.db.open()
54
+ this._headers = this.db.sublevel('headers', { valueEncoding: 'json' })
55
+ this._txs = this.db.sublevel('txs', { valueEncoding: 'utf8' })
56
+ this._utxos = this.db.sublevel('utxos', { valueEncoding: 'json' })
57
+ this._meta = this.db.sublevel('meta', { valueEncoding: 'json' })
58
+ this._watched = this.db.sublevel('watched', { valueEncoding: 'json' })
59
+ this._hashIndex = this.db.sublevel('hashIndex', { valueEncoding: 'json' })
60
+ this._inscriptions = this.db.sublevel('inscriptions', { valueEncoding: 'json' })
61
+ this._inscriptionIdx = this.db.sublevel('inscIdx', { valueEncoding: 'json' })
62
+ this._txStatus = this.db.sublevel('txStatus', { valueEncoding: 'json' })
63
+ this._txBlock = this.db.sublevel('txBlock', { valueEncoding: 'json' })
64
+ this._content = this.db.sublevel('content', { valueEncoding: 'json' })
65
+ this._tokens = this.db.sublevel('tokens', { valueEncoding: 'json' })
66
+ this._sessions = this.db.sublevel('sessions', { valueEncoding: 'json' })
67
+ this._paymentReceipts = this.db.sublevel('payment_receipts', { valueEncoding: 'json' })
68
+ await mkdir(this._contentDir, { recursive: true })
69
+ this.emit('open')
70
+ }
71
+
72
+ /** Close the database. */
73
+ async close () {
74
+ if (this.db) await this.db.close()
75
+ }
76
+
77
+ // ── Headers ──────────────────────────────────────────────
78
+
79
+ /**
80
+ * Store a header by height (with hash index).
81
+ * @param {{ height: number, hash: string, prevHash: string, merkleRoot?: string, timestamp?: number, bits?: number, nonce?: number, version?: number }} header
82
+ */
83
+ async putHeader (header) {
84
+ await this._headers.put(String(header.height), header)
85
+ if (header.hash) {
86
+ await this._hashIndex.put(header.hash, header.height)
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Store multiple headers in a batch (with hash index).
92
+ * @param {Array<{ height: number, hash: string, prevHash: string, merkleRoot?: string, timestamp?: number, bits?: number, nonce?: number, version?: number }>} headers
93
+ */
94
+ async putHeaders (headers) {
95
+ const headerOps = headers.map(h => ({
96
+ type: 'put',
97
+ key: String(h.height),
98
+ value: h
99
+ }))
100
+ await this._headers.batch(headerOps)
101
+ const hashOps = headers.filter(h => h.hash).map(h => ({
102
+ type: 'put',
103
+ key: h.hash,
104
+ value: h.height
105
+ }))
106
+ if (hashOps.length > 0) {
107
+ await this._hashIndex.batch(hashOps)
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Get a header by height.
113
+ * @param {number} height
114
+ * @returns {Promise<{ height: number, hash: string, prevHash: string }|null>}
115
+ */
116
+ async getHeader (height) {
117
+ const val = await this._headers.get(String(height))
118
+ return val !== undefined ? val : null
119
+ }
120
+
121
+ /**
122
+ * Get a header by block hash.
123
+ * @param {string} hash
124
+ * @returns {Promise<object|null>}
125
+ */
126
+ async getHeaderByHash (hash) {
127
+ const height = await this._hashIndex.get(hash)
128
+ if (height === undefined) return null
129
+ return this.getHeader(height)
130
+ }
131
+
132
+ /**
133
+ * Verify a merkle proof against a stored block header.
134
+ * @param {string} txHash — transaction hash (hex, display order)
135
+ * @param {string[]} merkleProof — sibling hashes in the merkle path
136
+ * @param {number} txIndex — transaction index in the block
137
+ * @param {string} blockHash — block hash to verify against
138
+ * @returns {Promise<{ verified: boolean, blockHeight: number, blockTimestamp: number }>}
139
+ */
140
+ async verifyMerkleProof (txHash, merkleProof, txIndex, blockHash) {
141
+ const header = await this.getHeaderByHash(blockHash)
142
+ if (!header) {
143
+ throw new Error(`Block ${blockHash} not found in header chain`)
144
+ }
145
+ if (!header.merkleRoot) {
146
+ throw new Error(`Header at height ${header.height} has no merkleRoot stored`)
147
+ }
148
+
149
+ // Compute merkle root from proof
150
+ let hash = Buffer.from(txHash, 'hex').reverse()
151
+ let index = txIndex
152
+
153
+ for (const proofHash of merkleProof) {
154
+ const sibling = Buffer.from(proofHash, 'hex').reverse()
155
+ const combined = (index % 2 === 0)
156
+ ? Buffer.concat([hash, sibling])
157
+ : Buffer.concat([sibling, hash])
158
+ hash = doubleSha256(combined)
159
+ index = Math.floor(index / 2)
160
+ }
161
+
162
+ const calculatedRoot = hash.reverse().toString('hex')
163
+
164
+ if (calculatedRoot !== header.merkleRoot) {
165
+ throw new Error('Merkle proof verification failed')
166
+ }
167
+
168
+ return {
169
+ verified: true,
170
+ blockHash: header.hash,
171
+ blockHeight: header.height,
172
+ blockTimestamp: header.timestamp
173
+ }
174
+ }
175
+
176
+ // ── Transactions ─────────────────────────────────────────
177
+
178
+ /**
179
+ * Store a raw transaction.
180
+ * @param {string} txid
181
+ * @param {string} rawHex
182
+ */
183
+ async putTx (txid, rawHex) {
184
+ await this._txs.put(txid, rawHex)
185
+ }
186
+
187
+ /**
188
+ * Get a raw transaction by txid.
189
+ * @param {string} txid
190
+ * @returns {Promise<string|null>} rawHex or null
191
+ */
192
+ async getTx (txid) {
193
+ const val = await this._txs.get(txid)
194
+ return val !== undefined ? val : null
195
+ }
196
+
197
+ /**
198
+ * Check if a transaction exists.
199
+ * @param {string} txid
200
+ * @returns {Promise<boolean>}
201
+ */
202
+ async hasTx (txid) {
203
+ return (await this.getTx(txid)) !== null
204
+ }
205
+
206
+ // ── UTXOs ────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Store a UTXO.
210
+ * @param {{ txid: string, vout: number, satoshis: number, scriptHex: string, address: string }} utxo
211
+ */
212
+ async putUtxo (utxo) {
213
+ const key = `${utxo.txid}:${utxo.vout}`
214
+ await this._utxos.put(key, { ...utxo, spent: false })
215
+ }
216
+
217
+ /**
218
+ * Mark a UTXO as spent.
219
+ * @param {string} txid
220
+ * @param {number} vout
221
+ */
222
+ async spendUtxo (txid, vout) {
223
+ const key = `${txid}:${vout}`
224
+ const utxo = await this._utxos.get(key)
225
+ if (utxo === undefined) return
226
+ utxo.spent = true
227
+ await this._utxos.put(key, utxo)
228
+ }
229
+
230
+ /**
231
+ * Get all unspent UTXOs.
232
+ * @returns {Promise<Array<{ txid: string, vout: number, satoshis: number, scriptHex: string, address: string }>>}
233
+ */
234
+ async getUnspentUtxos () {
235
+ const utxos = []
236
+ for await (const [, utxo] of this._utxos.iterator()) {
237
+ if (!utxo.spent) utxos.push(utxo)
238
+ }
239
+ return utxos
240
+ }
241
+
242
+ /**
243
+ * Get unspent UTXOs for a specific address.
244
+ * @param {string} address
245
+ * @returns {Promise<Array<{ txid: string, vout: number, satoshis: number, scriptHex: string, address: string }>>}
246
+ */
247
+ async getUnspentByAddress (address) {
248
+ const utxos = []
249
+ for await (const [, utxo] of this._utxos.iterator()) {
250
+ if (!utxo.spent && utxo.address === address) utxos.push(utxo)
251
+ }
252
+ return utxos
253
+ }
254
+
255
+ /**
256
+ * Get total unspent balance in satoshis.
257
+ * @returns {Promise<number>}
258
+ */
259
+ async getBalance () {
260
+ let total = 0
261
+ for await (const [, utxo] of this._utxos.iterator()) {
262
+ if (!utxo.spent) total += utxo.satoshis
263
+ }
264
+ return total
265
+ }
266
+
267
+ // ── Watched address matches ──────────────────────────────
268
+
269
+ /**
270
+ * Store a watched-address match (a tx that touched a watched address).
271
+ * @param {{ txid: string, address: string, direction: 'in'|'out', timestamp: number }} match
272
+ */
273
+ async putWatchedTx (match) {
274
+ const key = `${match.address}:${match.txid}`
275
+ await this._watched.put(key, match)
276
+ }
277
+
278
+ /**
279
+ * Get all watched-address matches for an address.
280
+ * @param {string} address
281
+ * @returns {Promise<Array>}
282
+ */
283
+ async getWatchedTxs (address) {
284
+ const matches = []
285
+ for await (const [key, value] of this._watched.iterator()) {
286
+ if (key.startsWith(`${address}:`)) {
287
+ matches.push(value)
288
+ }
289
+ }
290
+ return matches
291
+ }
292
+
293
+ // ── Sessions (Indelible) ───────────────────────────────────
294
+
295
+ /**
296
+ * Store a session metadata record with sort index.
297
+ * PK: s!{address}!{txId} SK: t!{address}!{revTs}!{txId}
298
+ * @param {object} session must have txId and address
299
+ */
300
+ async putSession (session) {
301
+ const { txId, address } = session
302
+ if (!txId || !address) throw new Error('txId and address required')
303
+ const pk = `s!${address}!${txId}`
304
+ const ts = session.timestamp || new Date().toISOString()
305
+ const revTs = String(9999999999999 - new Date(ts).getTime()).padStart(13, '0')
306
+ const sk = `t!${address}!${revTs}!${txId}`
307
+ const record = {
308
+ txId, address,
309
+ session_id: session.session_id || null,
310
+ prev_session_id: session.prev_session_id || null,
311
+ summary: session.summary || '',
312
+ message_count: session.message_count || 0,
313
+ save_type: session.save_type || 'full',
314
+ timestamp: ts,
315
+ receivedAt: new Date().toISOString()
316
+ }
317
+ await this._sessions.batch([
318
+ { type: 'put', key: pk, value: record },
319
+ { type: 'put', key: sk, value: txId }
320
+ ])
321
+ return record
322
+ }
323
+
324
+ /**
325
+ * Get sessions for an address, newest first.
326
+ * @param {string} address
327
+ * @param {number} [limit=200]
328
+ * @returns {Promise<Array>}
329
+ */
330
+ async getSessions (address, limit = 200) {
331
+ const prefix = `t!${address}!`
332
+ const results = []
333
+ for await (const [, txId] of this._sessions.iterator({
334
+ gte: prefix, lt: prefix + '~', limit
335
+ })) {
336
+ const record = await this._safeGet(this._sessions, `s!${address}!${txId}`)
337
+ if (record) results.push(record)
338
+ }
339
+ return results
340
+ }
341
+
342
+ /**
343
+ * Batch import sessions (for backfill).
344
+ * @param {Array} sessions — array of session objects
345
+ * @returns {Promise<number>} count imported
346
+ */
347
+ async putSessionsBatch (sessions) {
348
+ const ops = []
349
+ for (const session of sessions) {
350
+ const { txId, address } = session
351
+ if (!txId || !address) continue
352
+ const pk = `s!${address}!${txId}`
353
+ const ts = session.timestamp || new Date().toISOString()
354
+ const revTs = String(9999999999999 - new Date(ts).getTime()).padStart(13, '0')
355
+ const sk = `t!${address}!${revTs}!${txId}`
356
+ const record = {
357
+ txId, address,
358
+ session_id: session.session_id || null,
359
+ prev_session_id: session.prev_session_id || null,
360
+ summary: session.summary || '',
361
+ message_count: session.message_count || 0,
362
+ save_type: session.save_type || 'full',
363
+ timestamp: ts,
364
+ receivedAt: new Date().toISOString()
365
+ }
366
+ ops.push({ type: 'put', key: pk, value: record })
367
+ ops.push({ type: 'put', key: sk, value: txId })
368
+ }
369
+ if (ops.length > 0) await this._sessions.batch(ops)
370
+ return ops.length / 2
371
+ }
372
+
373
+ /**
374
+ * Get summary of all addresses with sessions (for peer sync announce).
375
+ * @returns {Promise<Array<{ address: string, count: number, latest: string }>>}
376
+ */
377
+ async getSessionAddresses () {
378
+ const map = new Map() // address → { count, latest }
379
+ for await (const [key, value] of this._sessions.iterator({
380
+ gte: 's!', lt: 's!~'
381
+ })) {
382
+ const addr = key.split('!')[1]
383
+ const entry = map.get(addr)
384
+ if (!entry) {
385
+ map.set(addr, { count: 1, latest: value.timestamp || '' })
386
+ } else {
387
+ entry.count++
388
+ if (value.timestamp > entry.latest) entry.latest = value.timestamp
389
+ }
390
+ }
391
+ return [...map].map(([address, { count, latest }]) => ({ address, count, latest }))
392
+ }
393
+
394
+ // ── Metadata ─────────────────────────────────────────────
395
+
396
+ /**
397
+ * Store a metadata value.
398
+ * @param {string} key
399
+ * @param {*} value — any JSON-serializable value
400
+ */
401
+ async putMeta (key, value) {
402
+ await this._meta.put(key, value)
403
+ }
404
+
405
+ /**
406
+ * Get a metadata value.
407
+ * @param {string} key
408
+ * @param {*} [defaultValue=null]
409
+ * @returns {Promise<*>}
410
+ */
411
+ async getMeta (key, defaultValue = null) {
412
+ const val = await this._meta.get(key)
413
+ return val !== undefined ? val : defaultValue
414
+ }
415
+ // ── Tx Status + Block Mapping ───────────────────────────
416
+
417
+ /**
418
+ * Set or update tx lifecycle state.
419
+ * @param {string} txid
420
+ * @param {'mempool'|'confirmed'|'orphaned'|'dropped'} state
421
+ * @param {object} [meta] — optional fields: blockHash, height, source
422
+ */
423
+ async updateTxStatus (txid, state, meta = {}) {
424
+ const key = `s!${txid}`
425
+ const now = Date.now()
426
+ let existing = null
427
+ try {
428
+ const val = await this._txStatus.get(key)
429
+ if (val !== undefined) existing = val
430
+ } catch {}
431
+
432
+ const record = existing || { firstSeen: now }
433
+ record.state = state
434
+ record.lastSeen = now
435
+ record.updatedAt = now
436
+ if (meta.blockHash) record.blockHash = meta.blockHash
437
+ if (meta.height !== undefined) record.height = meta.height
438
+ if (meta.source) record.source = meta.source
439
+
440
+ const batch = [{ type: 'put', key, value: record }]
441
+
442
+ // Maintain mempool secondary index
443
+ if (state === 'mempool') {
444
+ batch.push({ type: 'put', key: `mempool!${txid}`, value: 1 })
445
+ } else if (existing?.state === 'mempool') {
446
+ batch.push({ type: 'del', key: `mempool!${txid}` })
447
+ }
448
+
449
+ await this._txStatus.batch(batch)
450
+ return record
451
+ }
452
+
453
+ /**
454
+ * Get tx lifecycle state.
455
+ * @param {string} txid
456
+ * @returns {Promise<object|null>}
457
+ */
458
+ async getTxStatus (txid) {
459
+ try {
460
+ const val = await this._txStatus.get(`s!${txid}`)
461
+ return val !== undefined ? val : null
462
+ } catch { return null }
463
+ }
464
+
465
+ /**
466
+ * Confirm a tx — atomic batch: txBlock + reverse index + txStatus update.
467
+ * @param {string} txid
468
+ * @param {string} blockHash
469
+ * @param {number} height
470
+ * @param {{ nodes: string[], index: number }|null} proof
471
+ */
472
+ async confirmTx (txid, blockHash, height, proof = null) {
473
+ const now = Date.now()
474
+ const blockRecord = { blockHash, height, confirmedAt: now, verified: !!proof }
475
+ if (proof) blockRecord.proof = proof
476
+
477
+ // Atomic batch across txBlock + txStatus
478
+ const txBlockBatch = [
479
+ { type: 'put', key: `tx!${txid}`, value: blockRecord },
480
+ { type: 'put', key: `block!${blockHash}!tx!${txid}`, value: 1 }
481
+ ]
482
+ await this._txBlock.batch(txBlockBatch)
483
+
484
+ await this.updateTxStatus(txid, 'confirmed', { blockHash, height })
485
+ this.emit('tx:confirmed', { txid, blockHash, height })
486
+ }
487
+
488
+ /**
489
+ * Get tx block placement.
490
+ * @param {string} txid
491
+ * @returns {Promise<object|null>}
492
+ */
493
+ async getTxBlock (txid) {
494
+ try {
495
+ const val = await this._txBlock.get(`tx!${txid}`)
496
+ return val !== undefined ? val : null
497
+ } catch { return null }
498
+ }
499
+
500
+ /**
501
+ * Handle reorg — mark all txs in disconnected block as orphaned.
502
+ * @param {string} blockHash the disconnected block hash
503
+ * @returns {Promise<string[]>} list of affected txids
504
+ */
505
+ async handleReorg (blockHash) {
506
+ const affected = []
507
+ const prefix = `block!${blockHash}!tx!`
508
+
509
+ // Find all txids in this block via reverse index
510
+ for await (const [key] of this._txBlock.iterator({ gte: prefix, lt: prefix + '~' })) {
511
+ const txid = key.slice(prefix.length)
512
+ affected.push(txid)
513
+ }
514
+
515
+ // Mark each as orphaned + clean up block associations
516
+ for (const txid of affected) {
517
+ await this.updateTxStatus(txid, 'orphaned', { blockHash })
518
+ await this._txBlock.del(`tx!${txid}`)
519
+ await this._txBlock.del(`block!${blockHash}!tx!${txid}`)
520
+ }
521
+
522
+ return affected
523
+ }
524
+
525
+ // ── Content-Addressed Storage ───────────────────────────
526
+
527
+ static CAS_THRESHOLD = 4096 // 4KB — below this, inline in LevelDB
528
+
529
+ /**
530
+ * Store content bytes via CAS. Small content inline, large to filesystem.
531
+ * @param {string} hexContent — hex-encoded content bytes
532
+ * @param {string} [mime] — content type
533
+ * @returns {Promise<{ contentHash: string, contentLen: number, contentPath: string|null, inline: boolean }>}
534
+ */
535
+ async putContent (hexContent, mime) {
536
+ const buf = Buffer.from(hexContent, 'hex')
537
+ const contentHash = createHash('sha256').update(buf).digest('hex')
538
+ const contentLen = buf.length
539
+ const inline = contentLen < PersistentStore.CAS_THRESHOLD
540
+
541
+ const record = { len: contentLen, mime: mime || null, createdAt: Date.now() }
542
+
543
+ if (inline) {
544
+ record.inline = hexContent
545
+ record.path = null
546
+ } else {
547
+ const dir = join(this._contentDir, contentHash.slice(0, 2))
548
+ const filePath = join(dir, contentHash)
549
+ await mkdir(dir, { recursive: true })
550
+ await writeFile(filePath, buf)
551
+ record.path = filePath
552
+ }
553
+
554
+ await this._content.put(`c!${contentHash}`, record)
555
+ return { contentHash, contentLen, contentPath: record.path, inline }
556
+ }
557
+
558
+ /**
559
+ * Get content bytes by hash.
560
+ * @param {string} contentHash
561
+ * @returns {Promise<Buffer|null>}
562
+ */
563
+ async getContentBytes (contentHash) {
564
+ let record
565
+ try {
566
+ const val = await this._content.get(`c!${contentHash}`)
567
+ if (val === undefined) return null
568
+ record = val
569
+ } catch { return null }
570
+
571
+ if (record.inline) {
572
+ return Buffer.from(record.inline, 'hex')
573
+ }
574
+ if (record.path) {
575
+ try { return await readFile(record.path) } catch { return null }
576
+ }
577
+ return null
578
+ }
579
+
580
+ /**
581
+ * Get content metadata by hash.
582
+ * @param {string} contentHash
583
+ * @returns {Promise<object|null>}
584
+ */
585
+ async getContentMeta (contentHash) {
586
+ try {
587
+ const val = await this._content.get(`c!${contentHash}`)
588
+ return val !== undefined ? val : null
589
+ } catch { return null }
590
+ }
591
+
592
+ // ── Token Tracking (BSV-20) ─────────────────────────────
593
+
594
+ /**
595
+ * Process a BSV-20 token operation (confirmed-only).
596
+ * Uses atomic batch() for all writes. Keyed by scriptHash for owner identity.
597
+ * @param {{ op: string, tick: string, amt: string, ownerScriptHash: string, address: string|null, txid: string, height: number, blockHash: string }} params
598
+ * @returns {Promise<{ valid: boolean, reason?: string }>}
599
+ */
600
+ async processTokenOp ({ op, tick, amt, ownerScriptHash, address, txid, height, blockHash }) {
601
+ const tickNorm = tick.toLowerCase().trim()
602
+
603
+ if (op === 'deploy') {
604
+ // Only first deploy counts (chain-ordered by height)
605
+ const existing = await this._safeGet(this._tokens, `tick!${tickNorm}`)
606
+ if (existing) return { valid: false, reason: 'already deployed' }
607
+
608
+ const parsed = typeof amt === 'object' ? amt : {}
609
+ const batch = [
610
+ { type: 'put', key: `tick!${tickNorm}`, value: {
611
+ tick: tickNorm, max: parsed.max || '0', lim: parsed.lim || '0',
612
+ dec: parsed.dec || '0', deployer: ownerScriptHash, deployerAddr: address,
613
+ deployTxid: txid, deployHeight: height, totalMinted: '0'
614
+ }},
615
+ { type: 'put', key: `op!${String(height).padStart(10, '0')}!${txid}!deploy`, value: {
616
+ tick: tickNorm, op: 'deploy', ownerScriptHash, valid: true
617
+ }}
618
+ ]
619
+ await this._tokens.batch(batch)
620
+ return { valid: true }
621
+ }
622
+
623
+ if (op === 'mint') {
624
+ const deploy = await this._safeGet(this._tokens, `tick!${tickNorm}`)
625
+ if (!deploy) return { valid: false, reason: 'token not deployed' }
626
+
627
+ const mintAmt = BigInt(amt || '0')
628
+ if (mintAmt <= 0n) return { valid: false, reason: 'invalid amount' }
629
+ if (deploy.lim !== '0' && mintAmt > BigInt(deploy.lim)) return { valid: false, reason: 'exceeds mint limit' }
630
+
631
+ const newTotal = BigInt(deploy.totalMinted) + mintAmt
632
+ if (deploy.max !== '0' && newTotal > BigInt(deploy.max)) return { valid: false, reason: 'exceeds max supply' }
633
+
634
+ // Credit owner balance
635
+ const balKey = `bal!${tickNorm}!owner!${ownerScriptHash}`
636
+ const existing = await this._safeGet(this._tokens, balKey) || { confirmed: '0' }
637
+ const newBal = (BigInt(existing.confirmed) + mintAmt).toString()
638
+
639
+ const batch = [
640
+ { type: 'put', key: `tick!${tickNorm}`, value: { ...deploy, totalMinted: newTotal.toString() } },
641
+ { type: 'put', key: balKey, value: { confirmed: newBal, updatedAt: Date.now() } },
642
+ { type: 'put', key: `op!${String(height).padStart(10, '0')}!${txid}!mint`, value: {
643
+ tick: tickNorm, op: 'mint', amt: amt, ownerScriptHash, valid: true
644
+ }}
645
+ ]
646
+ await this._tokens.batch(batch)
647
+ return { valid: true }
648
+ }
649
+
650
+ // Transfers deferred to Phase 2
651
+ return { valid: false, reason: 'transfers not yet supported' }
652
+ }
653
+
654
+ /**
655
+ * Get token deploy info.
656
+ * @param {string} tick
657
+ * @returns {Promise<object|null>}
658
+ */
659
+ async getToken (tick) {
660
+ return this._safeGet(this._tokens, `tick!${tick.toLowerCase().trim()}`)
661
+ }
662
+
663
+ /**
664
+ * Get token balance for an owner.
665
+ * @param {string} tick
666
+ * @param {string} ownerScriptHash
667
+ * @returns {Promise<string>} balance as string
668
+ */
669
+ async getTokenBalance (tick, ownerScriptHash) {
670
+ const record = await this._safeGet(this._tokens, `bal!${tick.toLowerCase().trim()}!owner!${ownerScriptHash}`)
671
+ return record ? record.confirmed : '0'
672
+ }
673
+
674
+ /**
675
+ * List all deployed tokens.
676
+ * @returns {Promise<Array>}
677
+ */
678
+ async listTokens () {
679
+ const tokens = []
680
+ const prefix = 'tick!'
681
+ for await (const [key, value] of this._tokens.iterator({ gte: prefix, lt: prefix + '~' })) {
682
+ tokens.push(value)
683
+ }
684
+ return tokens
685
+ }
686
+
687
+ /** Safe get — returns null instead of throwing for missing keys. */
688
+ async _safeGet (sublevel, key) {
689
+ try {
690
+ const val = await sublevel.get(key)
691
+ return val !== undefined ? val : null
692
+ } catch { return null }
693
+ }
694
+
695
+ // ── Inscriptions ─────────────────────────────────────────
696
+
697
+ /**
698
+ * Store an inscription record with secondary indexes.
699
+ * @param {{ txid: string, vout: number, contentType: string, contentSize: number, isBsv20: boolean, bsv20: object|null, timestamp: number, address: string|null }} record
700
+ */
701
+ async putInscription (record) {
702
+ const key = `${record.txid}:${record.vout}`
703
+ const suffix = `${record.txid}:${record.vout}`
704
+
705
+ // Purge ALL stale secondary index entries pointing to this key
706
+ try {
707
+ const delBatch = []
708
+ for await (const [idxKey, val] of this._inscriptionIdx.iterator()) {
709
+ if (val === key && idxKey.endsWith(suffix)) delBatch.push({ type: 'del', key: idxKey })
710
+ }
711
+ if (delBatch.length) await this._inscriptionIdx.batch(delBatch)
712
+ } catch {}
713
+
714
+ // Route content through CAS
715
+ if (record.content) {
716
+ try {
717
+ const cas = await this.putContent(record.content, record.contentType)
718
+ record.contentHash = cas.contentHash
719
+ record.contentLen = cas.contentLen
720
+ // Strip raw content from inscription record if large (stored on filesystem)
721
+ if (!cas.inline) {
722
+ delete record.content
723
+ }
724
+ } catch {}
725
+ }
726
+
727
+ await this._inscriptions.put(key, record)
728
+
729
+ const ts = String(record.timestamp).padStart(15, '0')
730
+ const batch = [{ type: 'put', key: `time:${ts}:${suffix}`, value: key }]
731
+ if (record.contentType) {
732
+ batch.push({ type: 'put', key: `mime:${record.contentType}:${ts}:${suffix}`, value: key })
733
+ }
734
+ if (record.address) {
735
+ batch.push({ type: 'put', key: `addr:${record.address}:${ts}:${suffix}`, value: key })
736
+ }
737
+ await this._inscriptionIdx.batch(batch)
738
+ }
739
+
740
+ /**
741
+ * Query inscriptions with optional filters.
742
+ * @param {{ mime?: string, address?: string, limit?: number }} opts
743
+ * @returns {Promise<Array>}
744
+ */
745
+ async getInscriptions ({ mime, address, limit = 50 } = {}) {
746
+ const results = []
747
+ let prefix
748
+ if (address) {
749
+ prefix = `addr:${address}:`
750
+ } else if (mime) {
751
+ prefix = `mime:${mime}:`
752
+ } else {
753
+ prefix = 'time:'
754
+ }
755
+
756
+ for await (const [, primaryKey] of this._inscriptionIdx.iterator({
757
+ gte: prefix, lt: prefix + '~', reverse: true, limit
758
+ })) {
759
+ try {
760
+ const record = await this._inscriptions.get(primaryKey)
761
+ if (record) {
762
+ // Strip content from list results (can be 400KB+ per image)
763
+ const { content, ...meta } = record
764
+ results.push(meta)
765
+ }
766
+ } catch {}
767
+ }
768
+ return results
769
+ }
770
+
771
+ /**
772
+ * Rebuild inscription secondary indexes from primary records.
773
+ * Clears all index entries and re-creates from source of truth.
774
+ * @returns {Promise<number>} count of inscriptions re-indexed
775
+ */
776
+ async rebuildInscriptionIndex () {
777
+ // Clear entire index
778
+ for await (const [key] of this._inscriptionIdx.iterator()) {
779
+ await this._inscriptionIdx.del(key)
780
+ }
781
+ // Re-create from primary records
782
+ let count = 0
783
+ for await (const [, record] of this._inscriptions.iterator()) {
784
+ const ts = String(record.timestamp).padStart(15, '0')
785
+ const suffix = `${record.txid}:${record.vout}`
786
+ const key = suffix
787
+ const batch = [{ type: 'put', key: `time:${ts}:${suffix}`, value: key }]
788
+ if (record.contentType) batch.push({ type: 'put', key: `mime:${record.contentType}:${ts}:${suffix}`, value: key })
789
+ if (record.address) batch.push({ type: 'put', key: `addr:${record.address}:${ts}:${suffix}`, value: key })
790
+ await this._inscriptionIdx.batch(batch)
791
+ count++
792
+ }
793
+ return count
794
+ }
795
+
796
+ /**
797
+ * Get a single inscription record (with content) by txid:vout.
798
+ * @param {string} txid
799
+ * @param {number} vout
800
+ * @returns {Promise<object|null>}
801
+ */
802
+ async getInscription (txid, vout) {
803
+ try {
804
+ return await this._inscriptions.get(`${txid}:${vout}`)
805
+ } catch {
806
+ return null
807
+ }
808
+ }
809
+
810
+ /**
811
+ * Get total inscription count.
812
+ * @returns {Promise<number>}
813
+ */
814
+ async getInscriptionCount () {
815
+ let count = 0
816
+ for await (const _ of this._inscriptions.keys()) count++
817
+ return count
818
+ }
819
+
820
+ // ── x402 Payment Receipts ──────────────────────────────
821
+
822
+ /**
823
+ * Atomic claim — put-if-absent. Returns { ok: true } if claimed,
824
+ * { ok: false } if txid already exists (replay blocked).
825
+ */
826
+ async claimTxid (txid, { routeKey, price, createdAt }) {
827
+ const key = `u!${txid}`
828
+ try {
829
+ await this._paymentReceipts.put(key,
830
+ { status: 'claimed', routeKey, price, createdAt },
831
+ { ifNotExists: true })
832
+ return { ok: true }
833
+ } catch (err) {
834
+ if (err.code !== 'LEVEL_KEY_EXISTS' && err?.cause?.code !== 'LEVEL_KEY_EXISTS')
835
+ console.error(`[x402] unexpected claimTxid error for ${txid}:`, err.message)
836
+ return { ok: false }
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Release a claim (verification failed). Only deletes if status is 'claimed'.
842
+ * Never deletes receipts — finalized payments are permanent.
843
+ */
844
+ async releaseClaim (txid) {
845
+ const key = `u!${txid}`
846
+ try {
847
+ const val = await this._paymentReceipts.get(key)
848
+ if (val && val.status === 'claimed') await this._paymentReceipts.del(key)
849
+ } catch {}
850
+ }
851
+
852
+ /**
853
+ * Promote claim to permanent receipt. Overwrites in-place key is
854
+ * NEVER deleted after this, blocking replay permanently.
855
+ */
856
+ async finalizePayment (txid, receipt) {
857
+ await this._paymentReceipts.put(`u!${txid}`, { ...receipt, status: 'receipt' })
858
+ }
859
+
860
+ /**
861
+ * Startup sweepdelete stale claims older than maxAgeMs (default 5 min).
862
+ * Only touches status === 'claimed' keys. Receipts are untouched.
863
+ */
864
+ async cleanupStaleClaims (maxAgeMs = 300000) {
865
+ const now = Date.now()
866
+ for await (const [key, val] of this._paymentReceipts.iterator({ gte: 'u!', lt: 'u~' })) {
867
+ if (val.status !== 'claimed') continue
868
+ if (!val.createdAt || (now - val.createdAt) > maxAgeMs)
869
+ await this._paymentReceipts.del(key)
870
+ }
871
+ }
872
+
873
+ /**
874
+ * Prune old receipts chunked batch deletes for receipts older than N months.
875
+ */
876
+ async pruneOldReceipts (monthsToKeep = 6) {
877
+ const cutoffMs = Date.now() - (monthsToKeep * 30 * 24 * 60 * 60 * 1000)
878
+ const CHUNK = 500
879
+ let ops = []
880
+ for await (const [key, val] of this._paymentReceipts.iterator({ gte: 'u!', lt: 'u~' })) {
881
+ if (val.status !== 'receipt') continue
882
+ if (val.createdAt && val.createdAt < cutoffMs) {
883
+ ops.push({ type: 'del', key })
884
+ if (ops.length >= CHUNK) { await this._paymentReceipts.batch(ops); ops = [] }
885
+ }
886
+ }
887
+ if (ops.length > 0) await this._paymentReceipts.batch(ops)
888
+ }
889
+ }
890
+
891
+ /** Double SHA-256 (Bitcoin standard) */
892
+ function doubleSha256 (data) {
893
+ return createHash('sha256').update(
894
+ createHash('sha256').update(data).digest()
895
+ ).digest()
896
+ }