@relay-federation/bridge 0.1.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/actions.js ADDED
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Shared action logic for register, deregister, and fund.
3
+ * Used by both CLI (cli.js) and StatusServer (status-server.js).
4
+ *
5
+ * Each function accepts a pluggable logger: log(type, message, data?)
6
+ * type: 'step' | 'done' | 'error'
7
+ */
8
+
9
+ import { join } from 'node:path'
10
+
11
+ /**
12
+ * Register this bridge on the relay mesh.
13
+ * Builds stake bond + registration tx, broadcasts via BSV P2P.
14
+ *
15
+ * @param {object} opts
16
+ * @param {object} opts.config - Bridge config (wif, pubkeyHex, endpoint, meshId, capabilities, dataDir)
17
+ * @param {object} opts.store - Open PersistentStore instance
18
+ * @param {function} opts.log - Logger: (type, message, data?) => void
19
+ * @returns {object} { stakeTxid, registrationTxid }
20
+ */
21
+ export async function runRegister ({ config, store, log }) {
22
+ log('step', `Pubkey: ${config.pubkeyHex}`)
23
+ log('step', `Endpoint: ${config.endpoint}`)
24
+ log('step', `Mesh: ${config.meshId}`)
25
+ log('step', `Capabilities: ${config.capabilities.join(', ')}`)
26
+
27
+ const { buildRegistrationTx } = await import('../../registry/lib/registration.js')
28
+ const { BSVNodeClient } = await import('./bsv-node-client.js')
29
+
30
+ // Get UTXOs from local store
31
+ const localUtxos = await store.getUnspentUtxos()
32
+
33
+ if (!localUtxos.length) {
34
+ throw new Error('No UTXOs found. Fund your bridge first: relay-bridge fund <rawTxHex>')
35
+ }
36
+
37
+ // Map local UTXO format to what buildRegistrationTx expects
38
+ const utxos = []
39
+ for (const u of localUtxos) {
40
+ const rawHex = await store.getTx(u.txid)
41
+ if (!rawHex) {
42
+ log('step', `Warning: No source tx for UTXO ${u.txid}:${u.vout}, skipping`)
43
+ continue
44
+ }
45
+ utxos.push({ tx_hash: u.txid, tx_pos: u.vout, value: u.satoshis, rawHex })
46
+ }
47
+
48
+ if (!utxos.length) {
49
+ throw new Error('No usable UTXOs (missing source transactions).')
50
+ }
51
+
52
+ // Connect to BSV P2P node for broadcasting
53
+ log('step', 'Connecting to BSV network...')
54
+ const bsvNode = new BSVNodeClient()
55
+
56
+ await new Promise((resolve, reject) => {
57
+ const timeout = setTimeout(() => {
58
+ bsvNode.disconnect()
59
+ reject(new Error('BSV node connection timeout (15s)'))
60
+ }, 15000)
61
+ bsvNode.on('handshake', () => { clearTimeout(timeout); resolve() })
62
+ bsvNode.on('error', (err) => { clearTimeout(timeout); reject(err) })
63
+ bsvNode.connect()
64
+ })
65
+
66
+ try {
67
+ // Step 1: Build and broadcast stake bond tx
68
+ const { buildStakeBondTx } = await import('../../registry/lib/stake-bond.js')
69
+ const { MIN_STAKE_SATS } = await import('@relay-federation/common/protocol')
70
+
71
+ log('step', `Building stake bond (${MIN_STAKE_SATS} sats)...`)
72
+ const stakeBond = await buildStakeBondTx({ wif: config.wif, utxos })
73
+
74
+ bsvNode.broadcastTx(stakeBond.txHex)
75
+ log('step', `Stake bond txid: ${stakeBond.txid}`)
76
+
77
+ // Brief wait for stake tx to propagate
78
+ await new Promise(r => setTimeout(r, 1000))
79
+
80
+ // Step 2: Build registration tx using real stake bond txid
81
+ const stakeTxid = new Uint8Array(Buffer.from(stakeBond.txid, 'hex'))
82
+
83
+ // Use the stake bond tx's change output (index 1) as funding for registration
84
+ const { Transaction } = await import('@bsv/sdk')
85
+ const stakeParsed = Transaction.fromHex(stakeBond.txHex)
86
+ const changeOutput = stakeParsed.outputs[1]
87
+ const regUtxos = []
88
+ if (changeOutput && changeOutput.satoshis > 0) {
89
+ regUtxos.push({
90
+ tx_hash: stakeBond.txid,
91
+ tx_pos: 1,
92
+ value: changeOutput.satoshis,
93
+ rawHex: stakeBond.txHex
94
+ })
95
+ }
96
+
97
+ if (!regUtxos.length) {
98
+ throw new Error('Stake bond consumed all funds. No UTXOs left for registration tx.')
99
+ }
100
+
101
+ const { txHex, txid } = await buildRegistrationTx({
102
+ wif: config.wif,
103
+ utxos: regUtxos,
104
+ endpoint: config.endpoint,
105
+ capabilities: config.capabilities,
106
+ versions: ['1.0'],
107
+ networkVersion: '1.0',
108
+ stakeTxid,
109
+ meshId: config.meshId
110
+ })
111
+
112
+ bsvNode.broadcastTx(txHex)
113
+ log('done', `Registration broadcast successful! txid: ${txid}`, { stakeTxid: stakeBond.txid, registrationTxid: txid })
114
+
115
+ // Brief wait for tx to propagate
116
+ await new Promise(r => setTimeout(r, 1000))
117
+
118
+ return { stakeTxid: stakeBond.txid, registrationTxid: txid }
119
+ } finally {
120
+ bsvNode.disconnect()
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Deregister this bridge from the relay mesh.
126
+ * Builds deregistration tx, broadcasts via BSV P2P.
127
+ *
128
+ * @param {object} opts
129
+ * @param {object} opts.config - Bridge config (wif, pubkeyHex)
130
+ * @param {object} opts.store - Open PersistentStore instance
131
+ * @param {string} opts.reason - Deregistration reason (default: 'shutdown')
132
+ * @param {function} opts.log - Logger
133
+ * @returns {object} { txid }
134
+ */
135
+ export async function runDeregister ({ config, store, reason = 'shutdown', log }) {
136
+ log('step', `Pubkey: ${config.pubkeyHex}`)
137
+ log('step', `Reason: ${reason}`)
138
+
139
+ const { buildDeregistrationTx } = await import('../../registry/lib/registration.js')
140
+ const { BSVNodeClient } = await import('./bsv-node-client.js')
141
+
142
+ // Get UTXOs from local store
143
+ const localUtxos = await store.getUnspentUtxos()
144
+
145
+ if (!localUtxos.length) {
146
+ throw new Error('No UTXOs found. Fund your bridge first: relay-bridge fund <rawTxHex>')
147
+ }
148
+
149
+ // Map local UTXO format
150
+ const utxos = []
151
+ for (const u of localUtxos) {
152
+ const rawHex = await store.getTx(u.txid)
153
+ if (!rawHex) {
154
+ log('step', `Warning: No source tx for UTXO ${u.txid}:${u.vout}, skipping`)
155
+ continue
156
+ }
157
+ utxos.push({ tx_hash: u.txid, tx_pos: u.vout, value: u.satoshis, rawHex })
158
+ }
159
+
160
+ if (!utxos.length) {
161
+ throw new Error('No usable UTXOs (missing source transactions).')
162
+ }
163
+
164
+ // Build deregistration tx
165
+ const { txHex, txid } = await buildDeregistrationTx({
166
+ wif: config.wif,
167
+ utxos,
168
+ reason
169
+ })
170
+
171
+ // Broadcast via BSV P2P
172
+ log('step', 'Connecting to BSV network...')
173
+ const bsvNode = new BSVNodeClient()
174
+
175
+ await new Promise((resolve, reject) => {
176
+ const timeout = setTimeout(() => {
177
+ bsvNode.disconnect()
178
+ reject(new Error('BSV node connection timeout (15s)'))
179
+ }, 15000)
180
+ bsvNode.on('handshake', () => { clearTimeout(timeout); resolve() })
181
+ bsvNode.on('error', (err) => { clearTimeout(timeout); reject(err) })
182
+ bsvNode.connect()
183
+ })
184
+
185
+ try {
186
+ bsvNode.broadcastTx(txHex)
187
+ log('done', `Deregistration broadcast successful! txid: ${txid}`, { txid })
188
+
189
+ await new Promise(r => setTimeout(r, 1000))
190
+ return { txid }
191
+ } finally {
192
+ bsvNode.disconnect()
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Fund this bridge by storing a raw transaction's outputs.
198
+ * No BSV P2P needed — just parses the tx and stores matching UTXOs.
199
+ *
200
+ * @param {object} opts
201
+ * @param {object} opts.config - Bridge config (pubkeyHex)
202
+ * @param {object} opts.store - Open PersistentStore instance
203
+ * @param {string} opts.rawHex - Raw transaction hex
204
+ * @param {function} opts.log - Logger
205
+ * @returns {object} { stored, balance }
206
+ */
207
+ export async function runFund ({ config, store, rawHex, log }) {
208
+ const { pubkeyToHash160, checkTxForWatched } = await import('./output-parser.js')
209
+
210
+ const hash160 = pubkeyToHash160(config.pubkeyHex)
211
+ const result = checkTxForWatched(rawHex, new Set([hash160]))
212
+
213
+ if (result.matches.length === 0) {
214
+ throw new Error(`No outputs found paying to this bridge address. Bridge hash160: ${hash160}`)
215
+ }
216
+
217
+ log('step', `Found ${result.matches.length} output(s) for this bridge`)
218
+
219
+ for (const match of result.matches) {
220
+ await store.putUtxo({
221
+ txid: result.txid,
222
+ vout: match.vout,
223
+ satoshis: match.satoshis,
224
+ scriptHex: match.scriptHex,
225
+ address: config.pubkeyHex
226
+ })
227
+ log('step', `UTXO stored: ${result.txid}:${match.vout} (${match.satoshis} sat)`)
228
+ }
229
+
230
+ await store.putTx(result.txid, rawHex)
231
+ const balance = await store.getBalance()
232
+ log('done', `Total balance: ${balance} satoshis`, { stored: result.matches.length, balance })
233
+
234
+ return { stored: result.matches.length, balance }
235
+ }
236
+
237
+ /**
238
+ * Send BSV from this bridge's wallet to a destination address.
239
+ * Builds a P2PKH tx, broadcasts via BSV P2P.
240
+ *
241
+ * @param {object} opts
242
+ * @param {object} opts.config - Bridge config (wif, pubkeyHex)
243
+ * @param {object} opts.store - Open PersistentStore instance
244
+ * @param {string} opts.toAddress - Destination BSV address
245
+ * @param {number} opts.amount - Amount in satoshis to send
246
+ * @param {function} opts.log - Logger
247
+ * @returns {object} { txid, sent, change }
248
+ */
249
+ export async function runSend ({ config, store, toAddress, amount, log }) {
250
+ const { Transaction, P2PKH, PrivateKey, SatoshisPerKilobyte } = await import('@bsv/sdk')
251
+
252
+ if (!toAddress || typeof toAddress !== 'string') {
253
+ throw new Error('Destination address is required.')
254
+ }
255
+ if (!amount || amount < 546) {
256
+ throw new Error('Amount must be at least 546 satoshis (dust limit).')
257
+ }
258
+
259
+ // Get UTXOs from local store
260
+ const localUtxos = await store.getUnspentUtxos()
261
+ if (!localUtxos.length) {
262
+ throw new Error('No UTXOs available. Wallet is empty.')
263
+ }
264
+
265
+ // Map local UTXO format and gather enough to cover amount + fee
266
+ const utxos = []
267
+ let gathered = 0
268
+ for (const u of localUtxos) {
269
+ const rawHex = await store.getTx(u.txid)
270
+ if (!rawHex) continue
271
+ utxos.push({ tx_hash: u.txid, tx_pos: u.vout, value: u.satoshis, rawHex })
272
+ gathered += u.satoshis
273
+ if (gathered >= amount + 1000) break // rough fee estimate
274
+ }
275
+
276
+ if (gathered < amount + 546) {
277
+ throw new Error(`Insufficient funds. Have ${gathered} sats, need ${amount} + fee.`)
278
+ }
279
+
280
+ log('step', `Sending ${amount} sats to ${toAddress}`)
281
+
282
+ const privateKey = PrivateKey.fromWif(config.wif)
283
+ const selfAddress = privateKey.toPublicKey().toAddress()
284
+ const p2pkh = new P2PKH()
285
+ const selfLockingScript = p2pkh.lock(selfAddress)
286
+
287
+ const tx = new Transaction()
288
+
289
+ for (const utxo of utxos) {
290
+ const sourceTransaction = Transaction.fromHex(utxo.rawHex)
291
+ tx.addInput({
292
+ sourceTransaction,
293
+ sourceOutputIndex: utxo.tx_pos,
294
+ unlockingScriptTemplate: p2pkh.unlock(
295
+ privateKey,
296
+ 'all',
297
+ false,
298
+ utxo.value,
299
+ selfLockingScript
300
+ )
301
+ })
302
+ }
303
+
304
+ // Output 0: payment to destination
305
+ tx.addOutput({
306
+ lockingScript: p2pkh.lock(toAddress),
307
+ satoshis: amount
308
+ })
309
+
310
+ // Output 1: change back to self
311
+ tx.addOutput({
312
+ lockingScript: p2pkh.lock(selfAddress),
313
+ change: true
314
+ })
315
+
316
+ await tx.fee(new SatoshisPerKilobyte(1000))
317
+ await tx.sign()
318
+
319
+ const txHex = tx.toHex()
320
+ const txid = tx.id('hex')
321
+
322
+ // Broadcast via BSV P2P
323
+ log('step', 'Connecting to BSV network...')
324
+ const { BSVNodeClient } = await import('./bsv-node-client.js')
325
+ const bsvNode = new BSVNodeClient()
326
+
327
+ await new Promise((resolve, reject) => {
328
+ const timeout = setTimeout(() => {
329
+ bsvNode.disconnect()
330
+ reject(new Error('BSV node connection timeout (15s)'))
331
+ }, 15000)
332
+ bsvNode.on('handshake', () => { clearTimeout(timeout); resolve() })
333
+ bsvNode.on('error', (err) => { clearTimeout(timeout); reject(err) })
334
+ bsvNode.connect()
335
+ })
336
+
337
+ try {
338
+ bsvNode.broadcastTx(txHex)
339
+ log('step', `Broadcast txid: ${txid}`)
340
+
341
+ // Mark spent UTXOs in store
342
+ for (const utxo of utxos) {
343
+ await store.spendUtxo(utxo.tx_hash, utxo.tx_pos)
344
+ }
345
+
346
+ // Store the new tx and change UTXO
347
+ await store.putTx(txid, txHex)
348
+ const parsedTx = Transaction.fromHex(txHex)
349
+ const changeOutput = parsedTx.outputs[1]
350
+ if (changeOutput && changeOutput.satoshis > 0) {
351
+ const { pubkeyToHash160, checkTxForWatched } = await import('./output-parser.js')
352
+ const hash160 = pubkeyToHash160(config.pubkeyHex)
353
+ const result = checkTxForWatched(txHex, new Set([hash160]))
354
+ for (const match of result.matches) {
355
+ await store.putUtxo({
356
+ txid,
357
+ vout: match.vout,
358
+ satoshis: match.satoshis,
359
+ scriptHex: match.scriptHex,
360
+ address: config.pubkeyHex
361
+ })
362
+ }
363
+ }
364
+
365
+ await new Promise(r => setTimeout(r, 1000))
366
+ const balance = await store.getBalance()
367
+ log('done', `Sent ${amount} sats to ${toAddress}. Remaining balance: ${balance} sats`, { txid, sent: amount, balance })
368
+
369
+ return { txid, sent: amount, balance }
370
+ } finally {
371
+ bsvNode.disconnect()
372
+ }
373
+ }
@@ -0,0 +1,169 @@
1
+ import { parseTx } from './output-parser.js'
2
+
3
+ const WOC_BASE = 'https://api.whatsonchain.com/v1/bsv/main'
4
+ const BATCH_SIZE = 5 // concurrent fetches per batch
5
+ const BATCH_DELAY_MS = 400 // pause between batches (~12 req/s burst, ~5/400ms avg)
6
+ const PROGRESS_INTERVAL = 10 // emit progress every N txs (or on inscription find)
7
+
8
+ /**
9
+ * AddressScanner — fetches all txids for an address from WhatsOnChain,
10
+ * retrieves raw tx hex, parses each one, and indexes any inscriptions
11
+ * found into the persistent store.
12
+ *
13
+ * Uses batched parallel fetching for speed while respecting WoC rate limits.
14
+ * Emits progress callbacks so callers can stream status to clients.
15
+ */
16
+
17
+ /**
18
+ * Scan an address for inscriptions.
19
+ * @param {string} address — BSV address to scan
20
+ * @param {import('./persistent-store.js').PersistentStore} store — persistent store instance
21
+ * @param {function} [onProgress] — called with { phase, current, total, txid, found } on each step
22
+ * @returns {Promise<{ address: string, txsScanned: number, inscriptionsFound: number, errors: number }>}
23
+ */
24
+ export async function scanAddress (address, store, onProgress = () => {}) {
25
+ // Phase 1: Fetch tx history from WhatsOnChain
26
+ onProgress({ phase: 'discovery', current: 0, total: 0, message: 'Fetching transaction history...' })
27
+
28
+ const historyUrl = `${WOC_BASE}/address/${address}/history`
29
+ const histRes = await fetchWithRetry(historyUrl)
30
+ if (!histRes.ok) {
31
+ throw new Error(`WhatsOnChain returned ${histRes.status} for address history`)
32
+ }
33
+ const history = await histRes.json()
34
+
35
+ if (!Array.isArray(history) || history.length === 0) {
36
+ onProgress({ phase: 'done', current: 0, total: 0, message: 'No transactions found for this address' })
37
+ return { address, txsScanned: 0, inscriptionsFound: 0, errors: 0 }
38
+ }
39
+
40
+ const txids = history.map(h => h.tx_hash)
41
+ onProgress({ phase: 'scanning', current: 0, total: txids.length, message: `Found ${txids.length} transactions. Scanning...` })
42
+
43
+ // Phase 2: Check cache — split into cached vs uncached
44
+ const uncached = []
45
+ const cached = []
46
+ for (const txid of txids) {
47
+ const rawHex = await store.getTx(txid)
48
+ if (rawHex) {
49
+ cached.push({ txid, rawHex })
50
+ } else {
51
+ uncached.push(txid)
52
+ }
53
+ }
54
+
55
+ onProgress({
56
+ phase: 'scanning',
57
+ current: cached.length,
58
+ total: txids.length,
59
+ message: `${cached.length} cached, ${uncached.length} to fetch from network...`
60
+ })
61
+
62
+ let inscriptionsFound = 0
63
+ let errors = 0
64
+ let processed = 0
65
+
66
+ // Phase 3: Process cached txs instantly (no network, no delay)
67
+ for (const { txid, rawHex } of cached) {
68
+ const found = await parseTxAndIndex(txid, rawHex, store)
69
+ inscriptionsFound += found
70
+ processed++
71
+ if (found > 0 || processed % PROGRESS_INTERVAL === 0) {
72
+ onProgress({ phase: 'scanning', current: processed, total: txids.length, txid, found })
73
+ }
74
+ }
75
+
76
+ // Phase 4: Fetch uncached txs in parallel batches
77
+ for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
78
+ const batch = uncached.slice(i, i + BATCH_SIZE)
79
+
80
+ // Fetch batch concurrently
81
+ const results = await Promise.allSettled(
82
+ batch.map(txid => fetchTxHex(txid))
83
+ )
84
+
85
+ // Process batch results
86
+ for (let j = 0; j < results.length; j++) {
87
+ const txid = batch[j]
88
+ const result = results[j]
89
+ processed++
90
+
91
+ if (result.status === 'rejected' || result.value === null) {
92
+ errors++
93
+ if (processed % PROGRESS_INTERVAL === 0) {
94
+ onProgress({ phase: 'scanning', current: processed, total: txids.length, txid, error: result.reason?.message || 'fetch failed' })
95
+ }
96
+ continue
97
+ }
98
+
99
+ const rawHex = result.value
100
+ await store.putTx(txid, rawHex)
101
+ const found = await parseTxAndIndex(txid, rawHex, store)
102
+ inscriptionsFound += found
103
+
104
+ if (found > 0 || processed % PROGRESS_INTERVAL === 0) {
105
+ onProgress({ phase: 'scanning', current: processed, total: txids.length, txid, found })
106
+ }
107
+ }
108
+
109
+ // Rate limit between batches (only if more batches remain)
110
+ if (i + BATCH_SIZE < uncached.length) {
111
+ await sleep(BATCH_DELAY_MS)
112
+ }
113
+ }
114
+
115
+ // Final progress
116
+ onProgress({
117
+ phase: 'done',
118
+ current: txids.length,
119
+ total: txids.length,
120
+ message: `Scan complete. ${inscriptionsFound} inscriptions found in ${txids.length} transactions (${cached.length} cached, ${uncached.length} fetched).`
121
+ })
122
+
123
+ return { address, txsScanned: txids.length, inscriptionsFound, errors }
124
+ }
125
+
126
+ /** Fetch raw tx hex from WhatsOnChain. Returns hex string or null on error. */
127
+ async function fetchTxHex (txid) {
128
+ const res = await fetchWithRetry(`${WOC_BASE}/tx/${txid}/hex`)
129
+ if (!res.ok) return null
130
+ return res.text()
131
+ }
132
+
133
+ /** Parse a tx and index any inscriptions. Returns count of inscriptions found. */
134
+ async function parseTxAndIndex (txid, rawHex, store) {
135
+ const parsed = parseTx(rawHex)
136
+ let count = 0
137
+ for (const output of parsed.outputs) {
138
+ if (output.type === 'ordinal' && output.parsed) {
139
+ await store.putInscription({
140
+ txid,
141
+ vout: output.vout,
142
+ contentType: output.parsed.contentType || null,
143
+ contentSize: output.parsed.content ? output.parsed.content.length / 2 : 0,
144
+ content: output.parsed.content || null,
145
+ isBsv20: output.parsed.isBsv20 || false,
146
+ bsv20: output.parsed.bsv20 || null,
147
+ timestamp: Date.now(),
148
+ address: output.hash160 || null
149
+ })
150
+ count++
151
+ }
152
+ }
153
+ return count
154
+ }
155
+
156
+ function sleep (ms) {
157
+ return new Promise(resolve => setTimeout(resolve, ms))
158
+ }
159
+
160
+ async function fetchWithRetry (url, maxAttempts = 3) {
161
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
162
+ const res = await fetch(url)
163
+ if (res.status === 429 && attempt < maxAttempts) {
164
+ await sleep(1000 * Math.pow(2, attempt - 1))
165
+ continue
166
+ }
167
+ return res
168
+ }
169
+ }
@@ -0,0 +1,161 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { checkTxForWatched, pubkeyToHash160 } from './output-parser.js'
3
+
4
+ /**
5
+ * AddressWatcher — monitors transactions for outputs to watched addresses.
6
+ *
7
+ * Listens to TxRelay's 'tx:new' events, parses each transaction, and
8
+ * checks outputs against a set of watched hash160s. When a match is
9
+ * found, it:
10
+ * 1. Stores the UTXO in PersistentStore
11
+ * 2. Checks inputs for spent UTXOs and marks them
12
+ * 3. Records the watched-address match
13
+ * 4. Emits events for upstream consumers
14
+ *
15
+ * This replaces the HTTP-based address queries (fetchUtxos,
16
+ * fetchAddressHistory) with pure local P2P-based tracking.
17
+ *
18
+ * Events:
19
+ * 'utxo:received' — { txid, vout, satoshis, address, hash160 }
20
+ * 'utxo:spent' — { txid, vout, spentByTxid }
21
+ * 'tx:watched' — { txid, address, direction, matches }
22
+ */
23
+ export class AddressWatcher extends EventEmitter {
24
+ /**
25
+ * @param {import('./tx-relay.js').TxRelay} txRelay
26
+ * @param {import('./persistent-store.js').PersistentStore} store
27
+ */
28
+ constructor (txRelay, store) {
29
+ super()
30
+ this.txRelay = txRelay
31
+ this.store = store
32
+ /** @type {Map<string, string>} hash160 → address (human-readable) */
33
+ this._watched = new Map()
34
+ /** @type {Set<string>} hash160s for fast lookup */
35
+ this._hash160Set = new Set()
36
+
37
+ this.txRelay.on('tx:new', ({ txid, rawHex }) => {
38
+ this._processTx(txid, rawHex).catch(err => {
39
+ this.emit('error', err)
40
+ })
41
+ })
42
+ }
43
+
44
+ /**
45
+ * Watch an address by its compressed public key hex.
46
+ * @param {string} pubkeyHex — 33-byte compressed public key
47
+ * @param {string} [label] — optional human-readable label
48
+ */
49
+ watchPubkey (pubkeyHex, label) {
50
+ const hash160 = pubkeyToHash160(pubkeyHex)
51
+ this._watched.set(hash160, label || pubkeyHex)
52
+ this._hash160Set.add(hash160)
53
+ }
54
+
55
+ /**
56
+ * Watch an address by its hash160 directly.
57
+ * @param {string} hash160 — 20-byte hash160 as hex
58
+ * @param {string} [label] — optional human-readable label
59
+ */
60
+ watchHash160 (hash160, label) {
61
+ this._watched.set(hash160, label || hash160)
62
+ this._hash160Set.add(hash160)
63
+ }
64
+
65
+ /**
66
+ * Stop watching an address.
67
+ * @param {string} hash160
68
+ */
69
+ unwatch (hash160) {
70
+ this._watched.delete(hash160)
71
+ this._hash160Set.delete(hash160)
72
+ }
73
+
74
+ /**
75
+ * Get all watched hash160s.
76
+ * @returns {Array<{ hash160: string, label: string }>}
77
+ */
78
+ getWatched () {
79
+ const result = []
80
+ for (const [hash160, label] of this._watched) {
81
+ result.push({ hash160, label })
82
+ }
83
+ return result
84
+ }
85
+
86
+ /**
87
+ * Manually process a raw transaction (e.g., from fund command).
88
+ * @param {string} rawHex
89
+ */
90
+ async processTxManual (rawHex) {
91
+ const { checkTxForWatched: check } = await import('./output-parser.js')
92
+ const result = check(rawHex, this._hash160Set)
93
+ await this._handleResult(result, rawHex)
94
+ }
95
+
96
+ /** @private */
97
+ async _processTx (txid, rawHex) {
98
+ if (this._hash160Set.size === 0) return
99
+
100
+ const result = checkTxForWatched(rawHex, this._hash160Set)
101
+ await this._handleResult(result, rawHex)
102
+ }
103
+
104
+ /** @private */
105
+ async _handleResult (result, rawHex) {
106
+ // Check inputs — are any of our UTXOs being spent?
107
+ for (const spend of result.spends) {
108
+ const utxoKey = `${spend.prevTxid}:${spend.prevVout}`
109
+ // Check if this input spends one of our tracked UTXOs
110
+ const existing = await this.store._utxos.get(utxoKey)
111
+ if (existing !== undefined && !existing.spent) {
112
+ await this.store.spendUtxo(spend.prevTxid, spend.prevVout)
113
+ this.emit('utxo:spent', {
114
+ txid: spend.prevTxid,
115
+ vout: spend.prevVout,
116
+ spentByTxid: result.txid
117
+ })
118
+ }
119
+ }
120
+
121
+ // Check outputs — any paying to our watched addresses?
122
+ if (result.matches.length > 0) {
123
+ // Store the raw tx for future reference
124
+ await this.store.putTx(result.txid, rawHex)
125
+
126
+ for (const match of result.matches) {
127
+ const address = this._watched.get(match.hash160) || match.hash160
128
+
129
+ // Store as UTXO
130
+ await this.store.putUtxo({
131
+ txid: result.txid,
132
+ vout: match.vout,
133
+ satoshis: match.satoshis,
134
+ scriptHex: match.scriptHex,
135
+ address
136
+ })
137
+
138
+ // Record watched-address match
139
+ await this.store.putWatchedTx({
140
+ txid: result.txid,
141
+ address,
142
+ direction: 'in',
143
+ timestamp: Date.now()
144
+ })
145
+
146
+ this.emit('utxo:received', {
147
+ txid: result.txid,
148
+ vout: match.vout,
149
+ satoshis: match.satoshis,
150
+ address,
151
+ hash160: match.hash160
152
+ })
153
+ }
154
+
155
+ this.emit('tx:watched', {
156
+ txid: result.txid,
157
+ matches: result.matches.length
158
+ })
159
+ }
160
+ }
161
+ }