@relay-federation/bridge 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +753 -74
- 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 +17 -2
- 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 +963 -14
- package/package.json +4 -2
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { Transaction, Hash, PublicKey, P2PKH } from '@bsv/sdk'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* OutputParser — extracts addresses and script info from raw transactions.
|
|
6
|
+
*
|
|
7
|
+
* Parses raw transaction hex and inspects each output to determine:
|
|
8
|
+
* - Whether it's a standard P2PKH output
|
|
9
|
+
* - The hash160 (pubkey hash) it pays to
|
|
10
|
+
* - The satoshi value
|
|
11
|
+
*
|
|
12
|
+
* Also inspects inputs to determine which UTXOs are being spent.
|
|
13
|
+
*
|
|
14
|
+
* This module does NOT depend on any network calls — pure local parsing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a raw transaction hex into structured output info.
|
|
19
|
+
* @param {string} rawHex — raw transaction hex string
|
|
20
|
+
* @returns {{ txid: string, inputs: Array<{ prevTxid: string, prevVout: number }>, outputs: Array<{ vout: number, satoshis: number, scriptHex: string, hash160: string|null, isP2PKH: boolean }> }}
|
|
21
|
+
*/
|
|
22
|
+
export function parseTx (rawHex) {
|
|
23
|
+
const tx = Transaction.fromHex(rawHex)
|
|
24
|
+
const txid = tx.id('hex')
|
|
25
|
+
|
|
26
|
+
const inputs = tx.inputs.map(input => ({
|
|
27
|
+
prevTxid: typeof input.sourceTXID === 'string'
|
|
28
|
+
? input.sourceTXID
|
|
29
|
+
: Buffer.from(input.sourceTXID).toString('hex'),
|
|
30
|
+
prevVout: input.sourceOutputIndex
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
const outputs = tx.outputs.map((output, vout) => {
|
|
34
|
+
const scriptHex = output.lockingScript.toHex()
|
|
35
|
+
const satoshis = output.satoshis
|
|
36
|
+
const parsed = parseOutputScript(scriptHex)
|
|
37
|
+
return {
|
|
38
|
+
vout,
|
|
39
|
+
satoshis,
|
|
40
|
+
scriptHex,
|
|
41
|
+
scriptHash: createHash('sha256').update(Buffer.from(scriptHex, 'hex')).digest('hex'),
|
|
42
|
+
hash160: parsed.hash160,
|
|
43
|
+
isP2PKH: parsed.isP2PKH,
|
|
44
|
+
type: parsed.type,
|
|
45
|
+
data: parsed.data,
|
|
46
|
+
protocol: parsed.protocol,
|
|
47
|
+
parsed: parsed.parsed
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
return { txid, inputs, outputs }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse a locking script hex to extract hash160 if it's P2PKH.
|
|
56
|
+
*
|
|
57
|
+
* Standard P2PKH script: OP_DUP OP_HASH160 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG
|
|
58
|
+
* Hex pattern: 76a914{40 hex chars}88ac
|
|
59
|
+
*
|
|
60
|
+
* @param {string} scriptHex
|
|
61
|
+
* @returns {{ isP2PKH: boolean, hash160: string|null }}
|
|
62
|
+
*/
|
|
63
|
+
export function parseOutputScript (scriptHex) {
|
|
64
|
+
const base = { isP2PKH: false, hash160: null, type: 'unknown', data: null, protocol: null, parsed: null }
|
|
65
|
+
|
|
66
|
+
// 1. P2PKH: 76 a9 14 <20 bytes hash160> 88 ac (fast path — most common)
|
|
67
|
+
if (scriptHex.length === 50 &&
|
|
68
|
+
scriptHex.startsWith('76a914') &&
|
|
69
|
+
scriptHex.endsWith('88ac')) {
|
|
70
|
+
return { ...base, type: 'p2pkh', isP2PKH: true, hash160: scriptHex.slice(6, 46) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 2. OP_RETURN / OP_FALSE OP_RETURN
|
|
74
|
+
if (scriptHex.startsWith('6a') || scriptHex.startsWith('006a')) {
|
|
75
|
+
const opReturn = parseOpReturn(scriptHex)
|
|
76
|
+
if (opReturn) {
|
|
77
|
+
const { protocol, parsed } = detectProtocol(opReturn.pushes)
|
|
78
|
+
return { ...base, type: 'op_return', data: opReturn.pushes, protocol, parsed }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 3. Ordinal inscription (OP_FALSE OP_IF OP_PUSH3 "ord" ...)
|
|
83
|
+
if (scriptHex.includes(ORD_ENVELOPE)) {
|
|
84
|
+
const ord = parseOrdinal(scriptHex)
|
|
85
|
+
if (ord) {
|
|
86
|
+
// Extract hash160 from P2PKH wrapper if present
|
|
87
|
+
let hash160 = null
|
|
88
|
+
if (scriptHex.startsWith('76a914') && scriptHex.length > 50) {
|
|
89
|
+
hash160 = scriptHex.slice(6, 46)
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
...base,
|
|
93
|
+
type: 'ordinal',
|
|
94
|
+
isP2PKH: !!hash160,
|
|
95
|
+
hash160,
|
|
96
|
+
protocol: ord.isBsv20 ? 'bsv-20' : 'ordinal',
|
|
97
|
+
parsed: ord
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 4. P2SH: a9 14 <20 bytes> 87
|
|
103
|
+
const p2sh = parseP2SH(scriptHex)
|
|
104
|
+
if (p2sh) {
|
|
105
|
+
return { ...base, type: 'p2sh', parsed: p2sh }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 5. Bare multisig: OP_m <pubkeys> OP_n OP_CHECKMULTISIG
|
|
109
|
+
if (scriptHex.endsWith('ae')) {
|
|
110
|
+
const multi = parseMultisig(scriptHex)
|
|
111
|
+
if (multi) {
|
|
112
|
+
return { ...base, type: 'multisig', parsed: multi }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 6. Unknown script type
|
|
117
|
+
return base
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Compute the hash160 of a compressed public key hex.
|
|
122
|
+
* hash160 = RIPEMD160(SHA256(pubkey))
|
|
123
|
+
*
|
|
124
|
+
* @param {string} pubkeyHex — 33-byte compressed public key as hex
|
|
125
|
+
* @returns {string} 20-byte hash160 as hex
|
|
126
|
+
*/
|
|
127
|
+
export function pubkeyToHash160 (pubkeyHex) {
|
|
128
|
+
const pubkeyBytes = Buffer.from(pubkeyHex, 'hex')
|
|
129
|
+
const sha = Hash.sha256(pubkeyBytes)
|
|
130
|
+
const h160 = Hash.ripemd160(sha)
|
|
131
|
+
return Buffer.from(h160).toString('hex')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Convert a BSV address to its hash160 (pubkey hash).
|
|
136
|
+
* Uses P2PKH locking script to extract the hash160.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} address — BSV address (e.g. '1KhH4V...')
|
|
139
|
+
* @returns {string} 20-byte hash160 as hex
|
|
140
|
+
*/
|
|
141
|
+
export function addressToHash160 (address) {
|
|
142
|
+
const script = new P2PKH().lock(address).toHex()
|
|
143
|
+
// P2PKH script: 76a914{hash160}88ac
|
|
144
|
+
return script.slice(6, 46)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check if a transaction has any outputs paying to a set of watched hash160s.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} rawHex — raw transaction hex
|
|
151
|
+
* @param {Set<string>} watchedHash160s — set of hash160 hex strings to watch
|
|
152
|
+
* @returns {{ txid: string, matches: Array<{ vout: number, satoshis: number, scriptHex: string, hash160: string }>, spends: Array<{ prevTxid: string, prevVout: number }> }}
|
|
153
|
+
*/
|
|
154
|
+
export function checkTxForWatched (rawHex, watchedHash160s) {
|
|
155
|
+
const parsed = parseTx(rawHex)
|
|
156
|
+
|
|
157
|
+
const matches = parsed.outputs
|
|
158
|
+
.filter(o => o.isP2PKH && watchedHash160s.has(o.hash160))
|
|
159
|
+
.map(o => ({
|
|
160
|
+
vout: o.vout,
|
|
161
|
+
satoshis: o.satoshis,
|
|
162
|
+
scriptHex: o.scriptHex,
|
|
163
|
+
hash160: o.hash160
|
|
164
|
+
}))
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
txid: parsed.txid,
|
|
168
|
+
matches,
|
|
169
|
+
spends: parsed.inputs
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- Protocol constants ---
|
|
174
|
+
|
|
175
|
+
const B_PREFIX = '19HxigV4QyBv3tHpQVcUEQyq1pzZVdoAut'
|
|
176
|
+
const BCAT_PREFIX = '15DHFxWZJT58f9nhyGnsRBqrgwK4W6h4Up'
|
|
177
|
+
const BCAT_PART_PREFIX = '1ChDHzdd1H4wSjgGMHyndZm6qxEDGjqpJL'
|
|
178
|
+
const MAP_PREFIX = '1PuQa7K62MiKCtssSLKy1kh56WWU7MtUR5'
|
|
179
|
+
const METANET_MAGIC = '6d657461' // "meta" as hex
|
|
180
|
+
|
|
181
|
+
// Ordinal envelope: OP_FALSE OP_IF OP_PUSH3 "ord"
|
|
182
|
+
const ORD_ENVELOPE = '0063036f7264'
|
|
183
|
+
|
|
184
|
+
// --- Script reading utilities ---
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Read a pushdata segment from a script hex string at the given offset.
|
|
188
|
+
* Handles direct-length (1-75), OP_PUSHDATA1 (0x4c), OP_PUSHDATA2 (0x4d).
|
|
189
|
+
*
|
|
190
|
+
* @param {string} hex — script as hex string
|
|
191
|
+
* @param {number} offset — byte offset (in hex chars, so multiply by 2)
|
|
192
|
+
* @returns {{ data: string, newOffset: number }} data as hex string
|
|
193
|
+
*/
|
|
194
|
+
function readPushData (hex, offset) {
|
|
195
|
+
const opByte = parseInt(hex.slice(offset, offset + 2), 16)
|
|
196
|
+
offset += 2
|
|
197
|
+
|
|
198
|
+
let dataLen
|
|
199
|
+
if (opByte >= 1 && opByte <= 75) {
|
|
200
|
+
dataLen = opByte
|
|
201
|
+
} else if (opByte === 0x4c) { // OP_PUSHDATA1
|
|
202
|
+
dataLen = parseInt(hex.slice(offset, offset + 2), 16)
|
|
203
|
+
offset += 2
|
|
204
|
+
} else if (opByte === 0x4d) { // OP_PUSHDATA2
|
|
205
|
+
dataLen = parseInt(hex.slice(offset, offset + 2), 16) +
|
|
206
|
+
parseInt(hex.slice(offset + 2, offset + 4), 16) * 256
|
|
207
|
+
offset += 4
|
|
208
|
+
} else if (opByte === 0x4e) { // OP_PUSHDATA4
|
|
209
|
+
dataLen = parseInt(hex.slice(offset, offset + 2), 16) +
|
|
210
|
+
parseInt(hex.slice(offset + 2, offset + 4), 16) * 256 +
|
|
211
|
+
parseInt(hex.slice(offset + 4, offset + 6), 16) * 65536 +
|
|
212
|
+
parseInt(hex.slice(offset + 6, offset + 8), 16) * 16777216
|
|
213
|
+
offset += 8
|
|
214
|
+
} else {
|
|
215
|
+
return { data: null, opByte, newOffset: offset }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const dataHex = hex.slice(offset, offset + dataLen * 2)
|
|
219
|
+
return { data: dataHex, newOffset: offset + dataLen * 2 }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Extract all push data segments from an OP_RETURN script.
|
|
224
|
+
* Skips the OP_RETURN (and optional OP_FALSE) prefix.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} scriptHex
|
|
227
|
+
* @returns {{ pushes: string[], isFalseReturn: boolean }}
|
|
228
|
+
*/
|
|
229
|
+
export function parseOpReturn (scriptHex) {
|
|
230
|
+
let offset = 0
|
|
231
|
+
let isFalseReturn = false
|
|
232
|
+
|
|
233
|
+
if (scriptHex.startsWith('006a')) {
|
|
234
|
+
offset = 4 // skip OP_FALSE OP_RETURN
|
|
235
|
+
isFalseReturn = true
|
|
236
|
+
} else if (scriptHex.startsWith('6a')) {
|
|
237
|
+
offset = 2 // skip OP_RETURN
|
|
238
|
+
} else {
|
|
239
|
+
return null
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const pushes = []
|
|
243
|
+
while (offset < scriptHex.length) {
|
|
244
|
+
const byte = parseInt(scriptHex.slice(offset, offset + 2), 16)
|
|
245
|
+
|
|
246
|
+
// OP_0 pushes empty data
|
|
247
|
+
if (byte === 0x00) {
|
|
248
|
+
pushes.push('')
|
|
249
|
+
offset += 2
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
// OP_1 through OP_16 push their number
|
|
253
|
+
if (byte >= 0x51 && byte <= 0x60) {
|
|
254
|
+
pushes.push((byte - 0x50).toString(16).padStart(2, '0'))
|
|
255
|
+
offset += 2
|
|
256
|
+
continue
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const result = readPushData(scriptHex, offset)
|
|
260
|
+
if (result.data === null) break // unknown opcode, stop
|
|
261
|
+
pushes.push(result.data)
|
|
262
|
+
offset = result.newOffset
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { pushes, isFalseReturn }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Detect which protocol an OP_RETURN output uses based on its first push.
|
|
270
|
+
*
|
|
271
|
+
* @param {string[]} pushes — array of hex push data
|
|
272
|
+
* @returns {{ protocol: string|null, parsed: object|null }}
|
|
273
|
+
*/
|
|
274
|
+
function detectProtocol (pushes) {
|
|
275
|
+
if (!pushes || pushes.length === 0) return { protocol: null, parsed: null }
|
|
276
|
+
|
|
277
|
+
const firstPush = hexToUtf8(pushes[0])
|
|
278
|
+
|
|
279
|
+
if (firstPush === B_PREFIX) {
|
|
280
|
+
return { protocol: 'b', parsed: parseBProtocol(pushes) }
|
|
281
|
+
}
|
|
282
|
+
if (firstPush === BCAT_PREFIX) {
|
|
283
|
+
return { protocol: 'bcat', parsed: parseBCATLinker(pushes) }
|
|
284
|
+
}
|
|
285
|
+
if (firstPush === BCAT_PART_PREFIX) {
|
|
286
|
+
return { protocol: 'bcat-part', parsed: parseBCATPart(pushes) }
|
|
287
|
+
}
|
|
288
|
+
if (firstPush === MAP_PREFIX) {
|
|
289
|
+
return { protocol: 'map', parsed: parseMAP(pushes) }
|
|
290
|
+
}
|
|
291
|
+
if (pushes[0] === METANET_MAGIC) {
|
|
292
|
+
return { protocol: 'metanet', parsed: parseMetaNet(pushes) }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return { protocol: null, parsed: null }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- Protocol parsers ---
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Parse B:// protocol fields.
|
|
302
|
+
* Format: <B_PREFIX> <data> <mimeType> [encoding] [filename]
|
|
303
|
+
*/
|
|
304
|
+
function parseBProtocol (pushes) {
|
|
305
|
+
return {
|
|
306
|
+
data: pushes[1] || null,
|
|
307
|
+
mimeType: pushes[2] ? hexToUtf8(pushes[2]) : null,
|
|
308
|
+
encoding: pushes[3] ? hexToUtf8(pushes[3]) : null,
|
|
309
|
+
filename: pushes[4] ? hexToUtf8(pushes[4]) : null
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Parse BCAT linker fields.
|
|
315
|
+
* Format: <BCAT_PREFIX> <info> <mimeType> <charset> <filename> <flag> <txid1> <txid2> ...
|
|
316
|
+
*/
|
|
317
|
+
function parseBCATLinker (pushes) {
|
|
318
|
+
const chunkTxids = []
|
|
319
|
+
for (let i = 6; i < pushes.length; i++) {
|
|
320
|
+
if (pushes[i] && pushes[i].length === 64) {
|
|
321
|
+
chunkTxids.push(pushes[i])
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
info: pushes[1] ? hexToUtf8(pushes[1]) : null,
|
|
326
|
+
mimeType: pushes[2] ? hexToUtf8(pushes[2]) : null,
|
|
327
|
+
charset: pushes[3] ? hexToUtf8(pushes[3]) : null,
|
|
328
|
+
filename: pushes[4] ? hexToUtf8(pushes[4]) : null,
|
|
329
|
+
flag: pushes[5] ? hexToUtf8(pushes[5]) : null,
|
|
330
|
+
chunkTxids
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Parse BCAT part (chunk) fields.
|
|
336
|
+
* Format: <BCAT_PART_PREFIX> <raw data>
|
|
337
|
+
*/
|
|
338
|
+
function parseBCATPart (pushes) {
|
|
339
|
+
return {
|
|
340
|
+
data: pushes[1] || null
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Parse MAP protocol fields.
|
|
346
|
+
* Format: <MAP_PREFIX> SET <key> <value> <key> <value> ...
|
|
347
|
+
*/
|
|
348
|
+
function parseMAP (pushes) {
|
|
349
|
+
const action = pushes[1] ? hexToUtf8(pushes[1]) : null
|
|
350
|
+
const pairs = {}
|
|
351
|
+
for (let i = 2; i < pushes.length - 1; i += 2) {
|
|
352
|
+
const key = hexToUtf8(pushes[i])
|
|
353
|
+
const value = hexToUtf8(pushes[i + 1] || '')
|
|
354
|
+
if (key) pairs[key] = value
|
|
355
|
+
}
|
|
356
|
+
return { action, pairs }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Parse MetaNet protocol fields.
|
|
361
|
+
* Format: <"meta" 4 bytes> <nodeAddress> <parentTxid>
|
|
362
|
+
*/
|
|
363
|
+
function parseMetaNet (pushes) {
|
|
364
|
+
return {
|
|
365
|
+
nodeAddress: pushes[1] ? hexToUtf8(pushes[1]) : null,
|
|
366
|
+
parentTxid: pushes[2] || null
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Parse a 1Sat Ordinal inscription from a script hex.
|
|
372
|
+
* Scans for the envelope: OP_FALSE OP_IF OP_PUSH3 "ord" ... OP_ENDIF
|
|
373
|
+
*
|
|
374
|
+
* @param {string} scriptHex
|
|
375
|
+
* @returns {{ contentType: string|null, content: string|null, isBsv20: boolean, bsv20: object|null }|null}
|
|
376
|
+
*/
|
|
377
|
+
export function parseOrdinal (scriptHex) {
|
|
378
|
+
const envIdx = scriptHex.indexOf(ORD_ENVELOPE)
|
|
379
|
+
if (envIdx === -1) return null
|
|
380
|
+
|
|
381
|
+
// Start after the "ord" push: skip OP_FALSE(00) OP_IF(63) OP_PUSH3(03) "ord"(6f7264)
|
|
382
|
+
let offset = envIdx + ORD_ENVELOPE.length
|
|
383
|
+
let contentType = null
|
|
384
|
+
let content = null
|
|
385
|
+
|
|
386
|
+
while (offset < scriptHex.length) {
|
|
387
|
+
const byte = parseInt(scriptHex.slice(offset, offset + 2), 16)
|
|
388
|
+
offset += 2
|
|
389
|
+
|
|
390
|
+
if (byte === 0x68) break // OP_ENDIF
|
|
391
|
+
|
|
392
|
+
// Field tag
|
|
393
|
+
if (byte === 0x51) {
|
|
394
|
+
// OP_1 = content type field, next push is the mime type
|
|
395
|
+
const result = readPushData(scriptHex, offset)
|
|
396
|
+
if (result.data !== null) {
|
|
397
|
+
contentType = hexToUtf8(result.data)
|
|
398
|
+
offset = result.newOffset
|
|
399
|
+
}
|
|
400
|
+
} else if (byte === 0x00) {
|
|
401
|
+
// OP_0 = content body field, next push is the data
|
|
402
|
+
const result = readPushData(scriptHex, offset)
|
|
403
|
+
if (result.data !== null) {
|
|
404
|
+
content = result.data
|
|
405
|
+
offset = result.newOffset
|
|
406
|
+
}
|
|
407
|
+
} else if (byte >= 0x01 && byte <= 0x4b) {
|
|
408
|
+
// Direct push — skip this data (unknown field)
|
|
409
|
+
offset += byte * 2
|
|
410
|
+
} else if (byte === 0x4c) {
|
|
411
|
+
const len = parseInt(scriptHex.slice(offset, offset + 2), 16)
|
|
412
|
+
offset += 2 + len * 2
|
|
413
|
+
} else if (byte === 0x4d) {
|
|
414
|
+
const len = parseInt(scriptHex.slice(offset, offset + 2), 16) +
|
|
415
|
+
parseInt(scriptHex.slice(offset + 2, offset + 4), 16) * 256
|
|
416
|
+
offset += 4 + len * 2
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let isBsv20 = false
|
|
421
|
+
let bsv20 = null
|
|
422
|
+
if (contentType === 'application/bsv-20' && content) {
|
|
423
|
+
isBsv20 = true
|
|
424
|
+
try {
|
|
425
|
+
bsv20 = JSON.parse(hexToUtf8(content))
|
|
426
|
+
} catch { /* invalid JSON */ }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return { contentType, content, isBsv20, bsv20 }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Detect P2SH script: OP_HASH160 <20 bytes> OP_EQUAL
|
|
434
|
+
* Hex pattern: a914{40 hex chars}87
|
|
435
|
+
* Deprecated on BSV since Genesis (Feb 2020) but exists in history.
|
|
436
|
+
*
|
|
437
|
+
* @param {string} scriptHex
|
|
438
|
+
* @returns {{ scriptHash: string }|null}
|
|
439
|
+
*/
|
|
440
|
+
function parseP2SH (scriptHex) {
|
|
441
|
+
if (scriptHex.length === 46 &&
|
|
442
|
+
scriptHex.startsWith('a914') &&
|
|
443
|
+
scriptHex.endsWith('87')) {
|
|
444
|
+
return { scriptHash: scriptHex.slice(4, 44) }
|
|
445
|
+
}
|
|
446
|
+
return null
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Detect bare multisig: OP_m <pubkeys> OP_n OP_CHECKMULTISIG
|
|
451
|
+
*
|
|
452
|
+
* @param {string} scriptHex
|
|
453
|
+
* @returns {{ m: number, n: number, pubkeys: string[] }|null}
|
|
454
|
+
*/
|
|
455
|
+
function parseMultisig (scriptHex) {
|
|
456
|
+
// Must end with OP_CHECKMULTISIG (ae)
|
|
457
|
+
if (!scriptHex.endsWith('ae')) return null
|
|
458
|
+
|
|
459
|
+
const firstByte = parseInt(scriptHex.slice(0, 2), 16)
|
|
460
|
+
if (firstByte < 0x51 || firstByte > 0x60) return null // not OP_1..OP_16
|
|
461
|
+
const m = firstByte - 0x50
|
|
462
|
+
|
|
463
|
+
// Read public keys
|
|
464
|
+
const pubkeys = []
|
|
465
|
+
let offset = 2
|
|
466
|
+
while (offset < scriptHex.length - 4) { // -4 for OP_n + OP_CHECKMULTISIG
|
|
467
|
+
const pushLen = parseInt(scriptHex.slice(offset, offset + 2), 16)
|
|
468
|
+
if (pushLen !== 0x21 && pushLen !== 0x41) break // not 33 or 65 byte key
|
|
469
|
+
offset += 2
|
|
470
|
+
pubkeys.push(scriptHex.slice(offset, offset + pushLen * 2))
|
|
471
|
+
offset += pushLen * 2
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (pubkeys.length === 0) return null
|
|
475
|
+
|
|
476
|
+
const nByte = parseInt(scriptHex.slice(offset, offset + 2), 16)
|
|
477
|
+
if (nByte < 0x51 || nByte > 0x60) return null
|
|
478
|
+
const n = nByte - 0x50
|
|
479
|
+
|
|
480
|
+
if (n !== pubkeys.length || m > n) return null
|
|
481
|
+
|
|
482
|
+
return { m, n, pubkeys }
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// --- Utility ---
|
|
486
|
+
|
|
487
|
+
function hexToUtf8 (hex) {
|
|
488
|
+
if (!hex) return ''
|
|
489
|
+
const bytes = []
|
|
490
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
491
|
+
bytes.push(parseInt(hex.slice(i, i + 2), 16))
|
|
492
|
+
}
|
|
493
|
+
return Buffer.from(bytes).toString('utf8')
|
|
494
|
+
}
|
package/lib/peer-manager.js
CHANGED
|
@@ -71,7 +71,7 @@ export class PeerManager extends EventEmitter {
|
|
|
71
71
|
if (this.peers.has(pubkeyHex)) {
|
|
72
72
|
// Already connected — close the duplicate
|
|
73
73
|
socket.close()
|
|
74
|
-
return
|
|
74
|
+
return null
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
if (this.peers.size >= this.maxPeers) {
|
|
@@ -151,8 +151,8 @@ export class PeerManager extends EventEmitter {
|
|
|
151
151
|
this._server.on('error', (err) => reject(err))
|
|
152
152
|
|
|
153
153
|
this._server.on('connection', (ws) => {
|
|
154
|
-
// Inbound connections
|
|
155
|
-
//
|
|
154
|
+
// Inbound connections: full challenge-response handshake if available,
|
|
155
|
+
// otherwise fall back to basic hello exchange.
|
|
156
156
|
const timeout = setTimeout(() => {
|
|
157
157
|
ws.close() // No hello within 10 seconds
|
|
158
158
|
}, 10000)
|
|
@@ -161,18 +161,82 @@ export class PeerManager extends EventEmitter {
|
|
|
161
161
|
clearTimeout(timeout)
|
|
162
162
|
try {
|
|
163
163
|
const msg = JSON.parse(data.toString())
|
|
164
|
-
if (msg.type
|
|
164
|
+
if (msg.type !== 'hello' || !msg.pubkey || !msg.endpoint) {
|
|
165
|
+
ws.close()
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// If cryptographic handshake is available, use it
|
|
170
|
+
if (opts.handshake && msg.nonce && Array.isArray(msg.versions)) {
|
|
171
|
+
const isSeed = opts.seedEndpoints && opts.seedEndpoints.has(msg.endpoint)
|
|
172
|
+
const result = opts.handshake.handleHello(msg, isSeed ? null : (opts.registeredPubkeys || null))
|
|
173
|
+
if (result.error) {
|
|
174
|
+
ws.send(JSON.stringify({ type: 'error', error: result.error }))
|
|
175
|
+
ws.close()
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Send challenge_response
|
|
180
|
+
ws.send(JSON.stringify(result.message))
|
|
181
|
+
|
|
182
|
+
// Wait for verify
|
|
183
|
+
const verifyTimeout = setTimeout(() => { ws.close() }, 10000)
|
|
184
|
+
ws.once('message', (data2) => {
|
|
185
|
+
clearTimeout(verifyTimeout)
|
|
186
|
+
try {
|
|
187
|
+
const verifyMsg = JSON.parse(data2.toString())
|
|
188
|
+
const verifyResult = opts.handshake.handleVerify(verifyMsg, result.nonce, result.peerPubkey)
|
|
189
|
+
if (verifyResult.error) {
|
|
190
|
+
ws.send(JSON.stringify({ type: 'error', error: verifyResult.error }))
|
|
191
|
+
ws.close()
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Tie-break duplicate connections by pubkey
|
|
196
|
+
const existing = this.peers.get(result.peerPubkey)
|
|
197
|
+
if (existing) {
|
|
198
|
+
if (opts.pubkeyHex < result.peerPubkey) {
|
|
199
|
+
// Lower pubkey keeps outbound — reject this inbound
|
|
200
|
+
ws.close()
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
// Higher pubkey keeps inbound — drop existing outbound
|
|
204
|
+
existing._shouldReconnect = false
|
|
205
|
+
existing.destroy()
|
|
206
|
+
this.peers.delete(result.peerPubkey)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Handshake complete — accept peer
|
|
210
|
+
// Learn seed pubkeys so they pass registry check on future connections
|
|
211
|
+
if (isSeed && opts.registeredPubkeys && !opts.registeredPubkeys.has(result.peerPubkey)) {
|
|
212
|
+
opts.registeredPubkeys.add(result.peerPubkey)
|
|
213
|
+
}
|
|
214
|
+
const conn = this.acceptPeer(ws, result.peerPubkey, msg.endpoint)
|
|
215
|
+
if (conn) {
|
|
216
|
+
this.emit('peer:connect', { pubkeyHex: result.peerPubkey, endpoint: msg.endpoint })
|
|
217
|
+
conn.emit('message', msg)
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
ws.close()
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
} else {
|
|
224
|
+
// Legacy hello — accept without crypto verification
|
|
165
225
|
const conn = this.acceptPeer(ws, msg.pubkey, msg.endpoint)
|
|
166
226
|
if (conn) {
|
|
227
|
+
if (opts.pubkeyHex && opts.endpoint) {
|
|
228
|
+
ws.send(JSON.stringify({
|
|
229
|
+
type: 'hello',
|
|
230
|
+
pubkey: opts.pubkeyHex,
|
|
231
|
+
endpoint: opts.endpoint
|
|
232
|
+
}))
|
|
233
|
+
}
|
|
167
234
|
this.emit('peer:connect', { pubkeyHex: msg.pubkey, endpoint: msg.endpoint })
|
|
168
|
-
// Forward the hello as a regular message too
|
|
169
235
|
conn.emit('message', msg)
|
|
170
236
|
}
|
|
171
|
-
} else {
|
|
172
|
-
ws.close() // Invalid hello
|
|
173
237
|
}
|
|
174
238
|
} catch {
|
|
175
|
-
ws.close()
|
|
239
|
+
ws.close()
|
|
176
240
|
}
|
|
177
241
|
})
|
|
178
242
|
})
|
|
@@ -195,15 +259,20 @@ export class PeerManager extends EventEmitter {
|
|
|
195
259
|
}
|
|
196
260
|
|
|
197
261
|
_attachPeerEvents (conn) {
|
|
198
|
-
conn.on('open', () => {
|
|
199
|
-
this.emit('peer:connect', { pubkeyHex: conn.pubkeyHex, endpoint: conn.endpoint })
|
|
200
|
-
})
|
|
201
|
-
|
|
202
262
|
conn.on('message', (msg) => {
|
|
203
263
|
this.emit('peer:message', { pubkeyHex: conn.pubkeyHex, message: msg })
|
|
204
264
|
})
|
|
205
265
|
|
|
206
266
|
conn.on('close', () => {
|
|
267
|
+
// Only remove from map if the connection won't auto-reconnect.
|
|
268
|
+
// Keeping reconnecting peers in the map prevents gossip from
|
|
269
|
+
// creating duplicate PeerConnections for the same pubkey.
|
|
270
|
+
if (!conn._shouldReconnect || conn._destroyed) {
|
|
271
|
+
// Only delete if the map still holds THIS connection (not a replacement)
|
|
272
|
+
if (this.peers.get(conn.pubkeyHex) === conn) {
|
|
273
|
+
this.peers.delete(conn.pubkeyHex)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
207
276
|
this.emit('peer:disconnect', { pubkeyHex: conn.pubkeyHex, endpoint: conn.endpoint })
|
|
208
277
|
})
|
|
209
278
|
|