@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/cli.js +753 -73
- package/dashboard/index.html +2212 -0
- package/lib/actions.js +373 -0
- package/lib/address-scanner.js +169 -0
- package/lib/address-watcher.js +161 -0
- package/lib/bsv-node-client.js +311 -0
- package/lib/bsv-peer.js +791 -0
- package/lib/config.js +32 -4
- package/lib/data-validator.js +6 -0
- package/lib/endpoint-probe.js +45 -0
- package/lib/gossip.js +266 -0
- package/lib/header-relay.js +6 -1
- package/lib/ip-diversity.js +88 -0
- package/lib/output-parser.js +494 -0
- package/lib/peer-manager.js +81 -12
- package/lib/persistent-store.js +708 -0
- package/lib/status-server.js +965 -14
- package/package.json +4 -2
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
|
+
}
|