@relay-federation/bridge 0.3.11 → 0.3.13
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 +8 -0
- package/lib/address-scanner.js +4 -3
- package/lib/persistent-store.js +103 -0
- package/lib/session-relay.js +179 -0
- package/lib/status-server.js +284 -7
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { PeerManager } from './lib/peer-manager.js'
|
|
|
6
6
|
import { HeaderRelay } from './lib/header-relay.js'
|
|
7
7
|
import { TxRelay } from './lib/tx-relay.js'
|
|
8
8
|
import { DataRelay } from './lib/data-relay.js'
|
|
9
|
+
import { SessionRelay } from './lib/session-relay.js'
|
|
9
10
|
import { StatusServer } from './lib/status-server.js'
|
|
10
11
|
// network.js import removed — register/deregister now use local UTXOs + P2P broadcast
|
|
11
12
|
|
|
@@ -404,6 +405,7 @@ async function cmdStart () {
|
|
|
404
405
|
const headerRelay = new HeaderRelay(peerManager)
|
|
405
406
|
const txRelay = new TxRelay(peerManager)
|
|
406
407
|
const dataRelay = new DataRelay(peerManager)
|
|
408
|
+
const sessionRelay = new SessionRelay(peerManager, store)
|
|
407
409
|
|
|
408
410
|
// ── 2b. Phase 2: Security layer ────────────────────────────
|
|
409
411
|
const { PeerScorer } = await import('./lib/peer-scorer.js')
|
|
@@ -960,6 +962,12 @@ async function cmdStart () {
|
|
|
960
962
|
statusServer.addLog(msg)
|
|
961
963
|
})
|
|
962
964
|
|
|
965
|
+
sessionRelay.on('sessions:sync', ({ pubkeyHex, address, added, total }) => {
|
|
966
|
+
const msg = `Synced ${added} sessions for ${address.slice(0, 12)}... from ${pubkeyHex.slice(0, 16)}... (total: ${total})`
|
|
967
|
+
console.log(msg)
|
|
968
|
+
statusServer.addLog(msg)
|
|
969
|
+
})
|
|
970
|
+
|
|
963
971
|
txRelay.on('tx:new', ({ txid }) => {
|
|
964
972
|
const msg = `New tx: ${txid}`
|
|
965
973
|
console.log(msg)
|
package/lib/address-scanner.js
CHANGED
|
@@ -25,14 +25,15 @@ export async function scanAddress (address, store, onProgress = () => {}) {
|
|
|
25
25
|
// Phase 1: Fetch tx history from WhatsOnChain
|
|
26
26
|
onProgress({ phase: 'discovery', current: 0, total: 0, message: 'Fetching transaction history...' })
|
|
27
27
|
|
|
28
|
-
const historyUrl = `${WOC_BASE}/address/${address}/history`
|
|
28
|
+
const historyUrl = `${WOC_BASE}/address/${address}/confirmed/history`
|
|
29
29
|
const histRes = await fetchWithRetry(historyUrl)
|
|
30
30
|
if (!histRes.ok) {
|
|
31
31
|
throw new Error(`WhatsOnChain returned ${histRes.status} for address history`)
|
|
32
32
|
}
|
|
33
|
-
const
|
|
33
|
+
const data = await histRes.json()
|
|
34
|
+
const history = Array.isArray(data) ? data : (data.result || [])
|
|
34
35
|
|
|
35
|
-
if (
|
|
36
|
+
if (history.length === 0) {
|
|
36
37
|
onProgress({ phase: 'done', current: 0, total: 0, message: 'No transactions found for this address' })
|
|
37
38
|
return { address, txsScanned: 0, inscriptionsFound: 0, errors: 0 }
|
|
38
39
|
}
|
package/lib/persistent-store.js
CHANGED
|
@@ -42,6 +42,7 @@ export class PersistentStore extends EventEmitter {
|
|
|
42
42
|
this._txBlock = null
|
|
43
43
|
this._content = null
|
|
44
44
|
this._tokens = null
|
|
45
|
+
this._sessions = null
|
|
45
46
|
this._contentDir = join(dataDir, 'content')
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -61,6 +62,7 @@ export class PersistentStore extends EventEmitter {
|
|
|
61
62
|
this._txBlock = this.db.sublevel('txBlock', { valueEncoding: 'json' })
|
|
62
63
|
this._content = this.db.sublevel('content', { valueEncoding: 'json' })
|
|
63
64
|
this._tokens = this.db.sublevel('tokens', { valueEncoding: 'json' })
|
|
65
|
+
this._sessions = this.db.sublevel('sessions', { valueEncoding: 'json' })
|
|
64
66
|
await mkdir(this._contentDir, { recursive: true })
|
|
65
67
|
this.emit('open')
|
|
66
68
|
}
|
|
@@ -273,6 +275,107 @@ export class PersistentStore extends EventEmitter {
|
|
|
273
275
|
return matches
|
|
274
276
|
}
|
|
275
277
|
|
|
278
|
+
// ── Sessions (Indelible) ───────────────────────────────────
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Store a session metadata record with sort index.
|
|
282
|
+
* PK: s!{address}!{txId} SK: t!{address}!{revTs}!{txId}
|
|
283
|
+
* @param {object} session — must have txId and address
|
|
284
|
+
*/
|
|
285
|
+
async putSession (session) {
|
|
286
|
+
const { txId, address } = session
|
|
287
|
+
if (!txId || !address) throw new Error('txId and address required')
|
|
288
|
+
const pk = `s!${address}!${txId}`
|
|
289
|
+
const ts = session.timestamp || new Date().toISOString()
|
|
290
|
+
const revTs = String(9999999999999 - new Date(ts).getTime()).padStart(13, '0')
|
|
291
|
+
const sk = `t!${address}!${revTs}!${txId}`
|
|
292
|
+
const record = {
|
|
293
|
+
txId, address,
|
|
294
|
+
session_id: session.session_id || null,
|
|
295
|
+
prev_session_id: session.prev_session_id || null,
|
|
296
|
+
summary: session.summary || '',
|
|
297
|
+
message_count: session.message_count || 0,
|
|
298
|
+
save_type: session.save_type || 'full',
|
|
299
|
+
timestamp: ts,
|
|
300
|
+
receivedAt: new Date().toISOString()
|
|
301
|
+
}
|
|
302
|
+
await this._sessions.batch([
|
|
303
|
+
{ type: 'put', key: pk, value: record },
|
|
304
|
+
{ type: 'put', key: sk, value: txId }
|
|
305
|
+
])
|
|
306
|
+
return record
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Get sessions for an address, newest first.
|
|
311
|
+
* @param {string} address
|
|
312
|
+
* @param {number} [limit=200]
|
|
313
|
+
* @returns {Promise<Array>}
|
|
314
|
+
*/
|
|
315
|
+
async getSessions (address, limit = 200) {
|
|
316
|
+
const prefix = `t!${address}!`
|
|
317
|
+
const results = []
|
|
318
|
+
for await (const [, txId] of this._sessions.iterator({
|
|
319
|
+
gte: prefix, lt: prefix + '~', limit
|
|
320
|
+
})) {
|
|
321
|
+
const record = await this._safeGet(this._sessions, `s!${address}!${txId}`)
|
|
322
|
+
if (record) results.push(record)
|
|
323
|
+
}
|
|
324
|
+
return results
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Batch import sessions (for backfill).
|
|
329
|
+
* @param {Array} sessions — array of session objects
|
|
330
|
+
* @returns {Promise<number>} count imported
|
|
331
|
+
*/
|
|
332
|
+
async putSessionsBatch (sessions) {
|
|
333
|
+
const ops = []
|
|
334
|
+
for (const session of sessions) {
|
|
335
|
+
const { txId, address } = session
|
|
336
|
+
if (!txId || !address) continue
|
|
337
|
+
const pk = `s!${address}!${txId}`
|
|
338
|
+
const ts = session.timestamp || new Date().toISOString()
|
|
339
|
+
const revTs = String(9999999999999 - new Date(ts).getTime()).padStart(13, '0')
|
|
340
|
+
const sk = `t!${address}!${revTs}!${txId}`
|
|
341
|
+
const record = {
|
|
342
|
+
txId, address,
|
|
343
|
+
session_id: session.session_id || null,
|
|
344
|
+
prev_session_id: session.prev_session_id || null,
|
|
345
|
+
summary: session.summary || '',
|
|
346
|
+
message_count: session.message_count || 0,
|
|
347
|
+
save_type: session.save_type || 'full',
|
|
348
|
+
timestamp: ts,
|
|
349
|
+
receivedAt: new Date().toISOString()
|
|
350
|
+
}
|
|
351
|
+
ops.push({ type: 'put', key: pk, value: record })
|
|
352
|
+
ops.push({ type: 'put', key: sk, value: txId })
|
|
353
|
+
}
|
|
354
|
+
if (ops.length > 0) await this._sessions.batch(ops)
|
|
355
|
+
return ops.length / 2
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Get summary of all addresses with sessions (for peer sync announce).
|
|
360
|
+
* @returns {Promise<Array<{ address: string, count: number, latest: string }>>}
|
|
361
|
+
*/
|
|
362
|
+
async getSessionAddresses () {
|
|
363
|
+
const map = new Map() // address → { count, latest }
|
|
364
|
+
for await (const [key, value] of this._sessions.iterator({
|
|
365
|
+
gte: 's!', lt: 's!~'
|
|
366
|
+
})) {
|
|
367
|
+
const addr = key.split('!')[1]
|
|
368
|
+
const entry = map.get(addr)
|
|
369
|
+
if (!entry) {
|
|
370
|
+
map.set(addr, { count: 1, latest: value.timestamp || '' })
|
|
371
|
+
} else {
|
|
372
|
+
entry.count++
|
|
373
|
+
if (value.timestamp > entry.latest) entry.latest = value.timestamp
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return [...map].map(([address, { count, latest }]) => ({ address, count, latest }))
|
|
377
|
+
}
|
|
378
|
+
|
|
276
379
|
// ── Metadata ─────────────────────────────────────────────
|
|
277
380
|
|
|
278
381
|
/**
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SessionRelay — syncs Indelible session metadata between peers.
|
|
5
|
+
*
|
|
6
|
+
* Uses the PeerManager's message infrastructure to:
|
|
7
|
+
* - Announce session counts per address to new peers (triggered by hello)
|
|
8
|
+
* - Request missing sessions from peers that have more
|
|
9
|
+
* - Respond to session requests with batches
|
|
10
|
+
* - Re-announce to all peers after syncing new sessions
|
|
11
|
+
*
|
|
12
|
+
* Message types:
|
|
13
|
+
* sessions_announce — { type, summaries: [{ address, count, latest }] }
|
|
14
|
+
* sessions_request — { type, address, beforeTimestamp, limit }
|
|
15
|
+
* sessions — { type, address, sessions: [...], hasMore }
|
|
16
|
+
*
|
|
17
|
+
* Events:
|
|
18
|
+
* 'sessions:sync' — { pubkeyHex, address, added, total }
|
|
19
|
+
*/
|
|
20
|
+
export class SessionRelay extends EventEmitter {
|
|
21
|
+
/**
|
|
22
|
+
* @param {import('./peer-manager.js').PeerManager} peerManager
|
|
23
|
+
* @param {import('./persistent-store.js').PersistentStore} store
|
|
24
|
+
* @param {object} [opts]
|
|
25
|
+
* @param {number} [opts.maxBatch=500] — Max sessions per response
|
|
26
|
+
*/
|
|
27
|
+
constructor (peerManager, store, opts = {}) {
|
|
28
|
+
super()
|
|
29
|
+
this.peerManager = peerManager
|
|
30
|
+
this.store = store
|
|
31
|
+
this._maxBatch = opts.maxBatch || 500
|
|
32
|
+
this._syncing = new Set() // track in-progress syncs: "pubkey:address"
|
|
33
|
+
|
|
34
|
+
this.peerManager.on('peer:message', ({ pubkeyHex, message }) => {
|
|
35
|
+
this._handleMessage(pubkeyHex, message)
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @private */
|
|
40
|
+
async _announceToPeer (pubkeyHex) {
|
|
41
|
+
const conn = this.peerManager.peers.get(pubkeyHex)
|
|
42
|
+
if (!conn) return
|
|
43
|
+
try {
|
|
44
|
+
const summaries = await this.store.getSessionAddresses()
|
|
45
|
+
conn.send({ type: 'sessions_announce', summaries })
|
|
46
|
+
} catch (err) {
|
|
47
|
+
// Store not ready yet — skip announce
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @private */
|
|
52
|
+
_handleMessage (pubkeyHex, message) {
|
|
53
|
+
switch (message.type) {
|
|
54
|
+
case 'hello':
|
|
55
|
+
this._announceToPeer(pubkeyHex)
|
|
56
|
+
break
|
|
57
|
+
case 'sessions_announce':
|
|
58
|
+
this._onSessionsAnnounce(pubkeyHex, message)
|
|
59
|
+
break
|
|
60
|
+
case 'sessions_request':
|
|
61
|
+
this._onSessionsRequest(pubkeyHex, message)
|
|
62
|
+
break
|
|
63
|
+
case 'sessions':
|
|
64
|
+
this._onSessions(pubkeyHex, message)
|
|
65
|
+
break
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @private */
|
|
70
|
+
async _onSessionsAnnounce (pubkeyHex, msg) {
|
|
71
|
+
if (!Array.isArray(msg.summaries)) return
|
|
72
|
+
const ourSummaries = await this.store.getSessionAddresses()
|
|
73
|
+
const ourMap = new Map(ourSummaries.map(s => [s.address, s]))
|
|
74
|
+
|
|
75
|
+
for (const remote of msg.summaries) {
|
|
76
|
+
const local = ourMap.get(remote.address)
|
|
77
|
+
const ourCount = local ? local.count : 0
|
|
78
|
+
|
|
79
|
+
if (remote.count > ourCount) {
|
|
80
|
+
// Peer has more sessions for this address — request what we're missing
|
|
81
|
+
const syncKey = `${pubkeyHex}:${remote.address}`
|
|
82
|
+
if (this._syncing.has(syncKey)) continue // already syncing
|
|
83
|
+
this._syncing.add(syncKey)
|
|
84
|
+
|
|
85
|
+
const conn = this.peerManager.peers.get(pubkeyHex)
|
|
86
|
+
if (conn) {
|
|
87
|
+
conn.send({
|
|
88
|
+
type: 'sessions_request',
|
|
89
|
+
address: remote.address,
|
|
90
|
+
beforeTimestamp: '',
|
|
91
|
+
limit: this._maxBatch
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
} else if (remote.count < ourCount) {
|
|
95
|
+
// We have more — announce back so they can sync from us
|
|
96
|
+
this._announceToPeer(pubkeyHex)
|
|
97
|
+
break // one announce covers all addresses
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @private */
|
|
103
|
+
async _onSessionsRequest (pubkeyHex, msg) {
|
|
104
|
+
if (!msg.address) return
|
|
105
|
+
const limit = Math.min(msg.limit || this._maxBatch, this._maxBatch)
|
|
106
|
+
const allSessions = await this.store.getSessions(msg.address, 5000)
|
|
107
|
+
|
|
108
|
+
// Filter: getSessions returns newest-first, so paginate by "before" timestamp
|
|
109
|
+
let sessions = allSessions
|
|
110
|
+
if (msg.beforeTimestamp) {
|
|
111
|
+
sessions = allSessions.filter(s => s.timestamp < msg.beforeTimestamp)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Paginate
|
|
115
|
+
const batch = sessions.slice(0, limit)
|
|
116
|
+
const hasMore = sessions.length > limit
|
|
117
|
+
|
|
118
|
+
const conn = this.peerManager.peers.get(pubkeyHex)
|
|
119
|
+
if (conn) {
|
|
120
|
+
conn.send({
|
|
121
|
+
type: 'sessions',
|
|
122
|
+
address: msg.address,
|
|
123
|
+
sessions: batch,
|
|
124
|
+
hasMore
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @private */
|
|
130
|
+
async _onSessions (pubkeyHex, msg) {
|
|
131
|
+
if (!msg.address || !Array.isArray(msg.sessions)) return
|
|
132
|
+
const syncKey = `${pubkeyHex}:${msg.address}`
|
|
133
|
+
|
|
134
|
+
// Deduplicate: only import sessions we don't already have
|
|
135
|
+
const existing = await this.store.getSessions(msg.address, 5000)
|
|
136
|
+
const existingTxIds = new Set(existing.map(s => s.txId))
|
|
137
|
+
const newSessions = msg.sessions.filter(s => !existingTxIds.has(s.txId))
|
|
138
|
+
|
|
139
|
+
let added = 0
|
|
140
|
+
if (newSessions.length > 0) {
|
|
141
|
+
added = await this.store.putSessionsBatch(newSessions)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (added > 0) {
|
|
145
|
+
const total = existing.length + added
|
|
146
|
+
this.emit('sessions:sync', {
|
|
147
|
+
pubkeyHex,
|
|
148
|
+
address: msg.address,
|
|
149
|
+
added,
|
|
150
|
+
total
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// Re-announce to all peers except the source
|
|
154
|
+
const summaries = await this.store.getSessionAddresses()
|
|
155
|
+
this.peerManager.broadcast({
|
|
156
|
+
type: 'sessions_announce',
|
|
157
|
+
summaries
|
|
158
|
+
}, pubkeyHex)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// If peer has more, request next batch (oldest from current batch = cursor)
|
|
162
|
+
if (msg.hasMore && msg.sessions.length > 0) {
|
|
163
|
+
const oldest = msg.sessions.reduce((min, s) =>
|
|
164
|
+
!min || s.timestamp < min ? s.timestamp : min, ''
|
|
165
|
+
)
|
|
166
|
+
const conn = this.peerManager.peers.get(pubkeyHex)
|
|
167
|
+
if (conn) {
|
|
168
|
+
conn.send({
|
|
169
|
+
type: 'sessions_request',
|
|
170
|
+
address: msg.address,
|
|
171
|
+
beforeTimestamp: oldest,
|
|
172
|
+
limit: this._maxBatch
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
this._syncing.delete(syncKey)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
package/lib/status-server.js
CHANGED
|
@@ -415,7 +415,7 @@ export class StatusServer {
|
|
|
415
415
|
isP2PKH: o.isP2PKH,
|
|
416
416
|
hash160: o.hash160,
|
|
417
417
|
type: o.type,
|
|
418
|
-
data: o.data,
|
|
418
|
+
data: o.data ? o.data.map(d => d.length > 128 ? d.slice(0, 128) + '...' : d) : o.data,
|
|
419
419
|
protocol: o.protocol,
|
|
420
420
|
parsed: o.parsed
|
|
421
421
|
}))
|
|
@@ -793,7 +793,7 @@ export class StatusServer {
|
|
|
793
793
|
return
|
|
794
794
|
}
|
|
795
795
|
|
|
796
|
-
// GET /address/:addr/history —
|
|
796
|
+
// GET /address/:addr/history — local sessions first, WoC fallback
|
|
797
797
|
const addrMatch = path.match(/^\/address\/([13][a-km-zA-HJ-NP-Z1-9]{24,33})\/history$/)
|
|
798
798
|
if (req.method === 'GET' && addrMatch) {
|
|
799
799
|
const addr = addrMatch[1]
|
|
@@ -804,11 +804,30 @@ export class StatusServer {
|
|
|
804
804
|
return
|
|
805
805
|
}
|
|
806
806
|
try {
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
const
|
|
807
|
+
// Local sessions from LevelDB (source of truth)
|
|
808
|
+
const localSessions = await this._store.getSessions(addr, 2000)
|
|
809
|
+
const seen = new Set(localSessions.map(s => s.txId))
|
|
810
|
+
const history = localSessions.map(s => ({ tx_hash: s.txId, height: -1 }))
|
|
811
|
+
|
|
812
|
+
// WoC fallback for older txs + block heights
|
|
813
|
+
try {
|
|
814
|
+
const resp = await fetch('https://api.whatsonchain.com/v1/bsv/main/address/' + addr + '/confirmed/history', { signal: AbortSignal.timeout(10000) })
|
|
815
|
+
if (resp.ok) {
|
|
816
|
+
const data = await resp.json()
|
|
817
|
+
const wocHistory = Array.isArray(data) ? data : (data.result || [])
|
|
818
|
+
for (const entry of wocHistory) {
|
|
819
|
+
if (seen.has(entry.tx_hash)) {
|
|
820
|
+
const match = history.find(h => h.tx_hash === entry.tx_hash)
|
|
821
|
+
if (match && entry.height > 0) match.height = entry.height
|
|
822
|
+
} else {
|
|
823
|
+
history.push(entry)
|
|
824
|
+
seen.add(entry.tx_hash)
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
} catch {} // WoC failure doesn't block response
|
|
829
|
+
|
|
810
830
|
this._addressCache.set(addr, { data: history, time: Date.now() })
|
|
811
|
-
// Prune cache if it grows too large
|
|
812
831
|
if (this._addressCache.size > 100) {
|
|
813
832
|
const oldest = this._addressCache.keys().next().value
|
|
814
833
|
this._addressCache.delete(oldest)
|
|
@@ -816,7 +835,7 @@ export class StatusServer {
|
|
|
816
835
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
817
836
|
res.end(JSON.stringify({ address: addr, history, cached: false }))
|
|
818
837
|
} catch (err) {
|
|
819
|
-
res.writeHead(
|
|
838
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
820
839
|
res.end(JSON.stringify({ error: 'Failed to fetch address history: ' + err.message }))
|
|
821
840
|
}
|
|
822
841
|
return
|
|
@@ -1092,6 +1111,264 @@ export class StatusServer {
|
|
|
1092
1111
|
return
|
|
1093
1112
|
}
|
|
1094
1113
|
|
|
1114
|
+
// GET /health — MCP/CLI compatibility
|
|
1115
|
+
if (req.method === 'GET' && path === '/health') {
|
|
1116
|
+
const status = await this.getStatus()
|
|
1117
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1118
|
+
res.end(JSON.stringify({
|
|
1119
|
+
status: 'ok',
|
|
1120
|
+
headerHeight: status.headers.bestHeight,
|
|
1121
|
+
connectedPeers: status.bsvNode.peers,
|
|
1122
|
+
synced: status.headers.bestHeight > 0
|
|
1123
|
+
}))
|
|
1124
|
+
return
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// GET /api/address/:addr/unspent — UTXO lookup via GorillaPool ordinals
|
|
1128
|
+
const unspentMatch = path.match(/^\/api\/address\/([13][a-km-zA-HJ-NP-Z1-9]{24,33})\/unspent$/)
|
|
1129
|
+
if (req.method === 'GET' && unspentMatch) {
|
|
1130
|
+
const addr = unspentMatch[1]
|
|
1131
|
+
try {
|
|
1132
|
+
const resp = await fetch(
|
|
1133
|
+
`https://ordinals.gorillapool.io/api/txos/address/${addr}/unspent`,
|
|
1134
|
+
{ signal: AbortSignal.timeout(10000) }
|
|
1135
|
+
)
|
|
1136
|
+
if (!resp.ok) throw new Error(`GorillaPool ${resp.status}`)
|
|
1137
|
+
const data = await resp.json()
|
|
1138
|
+
// Transform GorillaPool format → WoC format
|
|
1139
|
+
const utxos = data.map(u => ({
|
|
1140
|
+
tx_hash: u.txid,
|
|
1141
|
+
tx_pos: u.vout,
|
|
1142
|
+
value: u.satoshis
|
|
1143
|
+
}))
|
|
1144
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1145
|
+
res.end(JSON.stringify(utxos))
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
res.writeHead(502, { 'Content-Type': 'application/json' })
|
|
1148
|
+
res.end(JSON.stringify({ error: 'UTXO fetch failed: ' + err.message }))
|
|
1149
|
+
}
|
|
1150
|
+
return
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// GET /api/tx/:txid/hex — raw transaction hex
|
|
1154
|
+
const hexMatch = path.match(/^\/api\/tx\/([0-9a-f]{64})\/hex$/)
|
|
1155
|
+
if (req.method === 'GET' && hexMatch) {
|
|
1156
|
+
const txid = hexMatch[1]
|
|
1157
|
+
let rawHex = null
|
|
1158
|
+
// Mempool first
|
|
1159
|
+
if (this._txRelay && this._txRelay.mempool.has(txid)) {
|
|
1160
|
+
rawHex = this._txRelay.mempool.get(txid)
|
|
1161
|
+
}
|
|
1162
|
+
// P2P second
|
|
1163
|
+
if (!rawHex && this._bsvNodeClient) {
|
|
1164
|
+
try {
|
|
1165
|
+
const result = await this._bsvNodeClient.getTx(txid, 5000)
|
|
1166
|
+
rawHex = result.rawHex
|
|
1167
|
+
} catch {}
|
|
1168
|
+
}
|
|
1169
|
+
// WoC fallback
|
|
1170
|
+
if (!rawHex) {
|
|
1171
|
+
try {
|
|
1172
|
+
const resp = await fetch(`https://api.whatsonchain.com/v1/bsv/main/tx/${txid}/hex`)
|
|
1173
|
+
if (resp.ok) rawHex = await resp.text()
|
|
1174
|
+
} catch {}
|
|
1175
|
+
}
|
|
1176
|
+
if (rawHex) {
|
|
1177
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
1178
|
+
res.end(rawHex)
|
|
1179
|
+
} else {
|
|
1180
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
1181
|
+
res.end(JSON.stringify({ error: 'tx not found' }))
|
|
1182
|
+
}
|
|
1183
|
+
return
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// POST /api/broadcast — MCP/CLI compatibility (accepts { rawTx } key)
|
|
1187
|
+
if (req.method === 'POST' && path === '/api/broadcast') {
|
|
1188
|
+
const body = await this._readBody(req)
|
|
1189
|
+
const rawHex = body.rawTx || body.rawHex
|
|
1190
|
+
if (!rawHex || typeof rawHex !== 'string') {
|
|
1191
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
1192
|
+
res.end(JSON.stringify({ error: 'rawTx or rawHex required' }))
|
|
1193
|
+
return
|
|
1194
|
+
}
|
|
1195
|
+
if (!/^[0-9a-fA-F]+$/.test(rawHex) || rawHex.length % 2 !== 0) {
|
|
1196
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
1197
|
+
res.end(JSON.stringify({ error: 'Invalid hex string' }))
|
|
1198
|
+
return
|
|
1199
|
+
}
|
|
1200
|
+
const buf = Buffer.from(rawHex, 'hex')
|
|
1201
|
+
const hash = createHash('sha256').update(createHash('sha256').update(buf).digest()).digest()
|
|
1202
|
+
const txid = Buffer.from(hash).reverse().toString('hex')
|
|
1203
|
+
const sent = this._txRelay ? this._txRelay.broadcastTx(txid, rawHex) : 0
|
|
1204
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1205
|
+
res.end(JSON.stringify({ txid, peers: sent }))
|
|
1206
|
+
return
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// GET /api/address/:addr/history — local sessions first, WoC fallback (web app compat)
|
|
1210
|
+
const apiHistMatch = path.match(/^\/api\/address\/([13][a-km-zA-HJ-NP-Z1-9]{24,33})\/history$/)
|
|
1211
|
+
if (req.method === 'GET' && apiHistMatch) {
|
|
1212
|
+
const addr = apiHistMatch[1]
|
|
1213
|
+
const cached = this._addressCache.get(addr)
|
|
1214
|
+
if (cached && Date.now() - cached.time < 60000) {
|
|
1215
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1216
|
+
res.end(JSON.stringify(cached.data))
|
|
1217
|
+
return
|
|
1218
|
+
}
|
|
1219
|
+
try {
|
|
1220
|
+
// Local sessions from LevelDB (source of truth)
|
|
1221
|
+
const localSessions = await this._store.getSessions(addr, 2000)
|
|
1222
|
+
const seen = new Set(localSessions.map(s => s.txId))
|
|
1223
|
+
const history = localSessions.map(s => ({ tx_hash: s.txId, height: -1 }))
|
|
1224
|
+
|
|
1225
|
+
// WoC fallback for older txs + block heights
|
|
1226
|
+
try {
|
|
1227
|
+
const resp = await fetch('https://api.whatsonchain.com/v1/bsv/main/address/' + addr + '/confirmed/history', { signal: AbortSignal.timeout(10000) })
|
|
1228
|
+
if (resp.ok) {
|
|
1229
|
+
const data = await resp.json()
|
|
1230
|
+
const wocHistory = Array.isArray(data) ? data : (data.result || [])
|
|
1231
|
+
for (const entry of wocHistory) {
|
|
1232
|
+
if (seen.has(entry.tx_hash)) {
|
|
1233
|
+
const match = history.find(h => h.tx_hash === entry.tx_hash)
|
|
1234
|
+
if (match && entry.height > 0) match.height = entry.height
|
|
1235
|
+
} else {
|
|
1236
|
+
history.push(entry)
|
|
1237
|
+
seen.add(entry.tx_hash)
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
} catch {} // WoC failure doesn't block response
|
|
1242
|
+
|
|
1243
|
+
this._addressCache.set(addr, { data: history, time: Date.now() })
|
|
1244
|
+
if (this._addressCache.size > 100) {
|
|
1245
|
+
const oldest = this._addressCache.keys().next().value
|
|
1246
|
+
this._addressCache.delete(oldest)
|
|
1247
|
+
}
|
|
1248
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1249
|
+
res.end(JSON.stringify(history))
|
|
1250
|
+
} catch (err) {
|
|
1251
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
1252
|
+
res.end(JSON.stringify({ error: 'Failed to fetch address history: ' + err.message }))
|
|
1253
|
+
}
|
|
1254
|
+
return
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// GET /api/address/:addr/balance — sum UTXOs from GorillaPool (web app compat)
|
|
1258
|
+
const apiBalMatch = path.match(/^\/api\/address\/([13][a-km-zA-HJ-NP-Z1-9]{24,33})\/balance$/)
|
|
1259
|
+
if (req.method === 'GET' && apiBalMatch) {
|
|
1260
|
+
const addr = apiBalMatch[1]
|
|
1261
|
+
try {
|
|
1262
|
+
const resp = await fetch(
|
|
1263
|
+
`https://ordinals.gorillapool.io/api/txos/address/${addr}/unspent`,
|
|
1264
|
+
{ signal: AbortSignal.timeout(10000) }
|
|
1265
|
+
)
|
|
1266
|
+
if (!resp.ok) throw new Error(`GorillaPool ${resp.status}`)
|
|
1267
|
+
const data = await resp.json()
|
|
1268
|
+
const confirmed = data.reduce((sum, u) => sum + (u.satoshis || 0), 0)
|
|
1269
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1270
|
+
res.end(JSON.stringify({ confirmed, unconfirmed: 0 }))
|
|
1271
|
+
} catch (err) {
|
|
1272
|
+
res.writeHead(502, { 'Content-Type': 'application/json' })
|
|
1273
|
+
res.end(JSON.stringify({ error: 'Balance fetch failed: ' + err.message }))
|
|
1274
|
+
}
|
|
1275
|
+
return
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// GET /api/tx/:txid — full tx JSON via WoC (web app compat)
|
|
1279
|
+
const apiTxMatch = path.match(/^\/api\/tx\/([0-9a-f]{64})$/)
|
|
1280
|
+
if (req.method === 'GET' && apiTxMatch) {
|
|
1281
|
+
const txid = apiTxMatch[1]
|
|
1282
|
+
try {
|
|
1283
|
+
const resp = await fetch(`https://api.whatsonchain.com/v1/bsv/main/tx/${txid}`, { signal: AbortSignal.timeout(10000) })
|
|
1284
|
+
if (!resp.ok) throw new Error('WoC returned ' + resp.status)
|
|
1285
|
+
const data = await resp.json()
|
|
1286
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1287
|
+
res.end(JSON.stringify(data))
|
|
1288
|
+
} catch (err) {
|
|
1289
|
+
res.writeHead(502, { 'Content-Type': 'application/json' })
|
|
1290
|
+
res.end(JSON.stringify({ error: 'tx fetch failed: ' + err.message }))
|
|
1291
|
+
}
|
|
1292
|
+
return
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// GET /api/mesh/status — alias for /status (web app compat)
|
|
1296
|
+
if (req.method === 'GET' && path === '/api/mesh/status') {
|
|
1297
|
+
const status = await this.getStatus({ authenticated })
|
|
1298
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1299
|
+
res.end(JSON.stringify(status))
|
|
1300
|
+
return
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// ── Session Storage (Indelible) ─────────────────────────
|
|
1304
|
+
|
|
1305
|
+
// POST /api/sessions/index — MCP/CLI pushes session metadata after broadcast (open, like /api/broadcast)
|
|
1306
|
+
if (req.method === 'POST' && path === '/api/sessions/index') {
|
|
1307
|
+
try {
|
|
1308
|
+
const body = await this._readBody(req)
|
|
1309
|
+
if (!body.txId || !body.address) {
|
|
1310
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
1311
|
+
res.end(JSON.stringify({ error: 'txId and address required' }))
|
|
1312
|
+
return
|
|
1313
|
+
}
|
|
1314
|
+
const record = await this._store.putSession(body)
|
|
1315
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1316
|
+
res.end(JSON.stringify({ success: true, txId: record.txId }))
|
|
1317
|
+
} catch (err) {
|
|
1318
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
1319
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
1320
|
+
}
|
|
1321
|
+
return
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// GET /api/sessions/{addr} — read session list for an address
|
|
1325
|
+
if (req.method === 'GET' && path.startsWith('/api/sessions/')) {
|
|
1326
|
+
const addr = path.slice('/api/sessions/'.length)
|
|
1327
|
+
if (!addr) {
|
|
1328
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
1329
|
+
res.end(JSON.stringify({ error: 'address required' }))
|
|
1330
|
+
return
|
|
1331
|
+
}
|
|
1332
|
+
try {
|
|
1333
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '200', 10), 2000)
|
|
1334
|
+
const sessions = await this._store.getSessions(addr, limit)
|
|
1335
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1336
|
+
res.end(JSON.stringify({ sessions, count: sessions.length }))
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
1339
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
1340
|
+
}
|
|
1341
|
+
return
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// POST /api/sessions/backfill — bulk import for migration
|
|
1345
|
+
if (req.method === 'POST' && path === '/api/sessions/backfill') {
|
|
1346
|
+
if (!authenticated) {
|
|
1347
|
+
res.writeHead(401, { 'Content-Type': 'application/json' })
|
|
1348
|
+
res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
|
|
1349
|
+
return
|
|
1350
|
+
}
|
|
1351
|
+
try {
|
|
1352
|
+
const body = await this._readBody(req)
|
|
1353
|
+
const sessions = (body.sessions || []).map(s => ({
|
|
1354
|
+
txId: s.txId, address: body.address || s.address,
|
|
1355
|
+
session_id: s.session_id || s.sessionId || null,
|
|
1356
|
+
prev_session_id: s.prev_session_id || s.prevTxId || null,
|
|
1357
|
+
summary: s.summary || '',
|
|
1358
|
+
message_count: s.message_count || s.messageCount || 0,
|
|
1359
|
+
save_type: s.save_type || s.saveType || 'full',
|
|
1360
|
+
timestamp: s.timestamp || null
|
|
1361
|
+
}))
|
|
1362
|
+
const imported = await this._store.putSessionsBatch(sessions)
|
|
1363
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1364
|
+
res.end(JSON.stringify({ success: true, imported }))
|
|
1365
|
+
} catch (err) {
|
|
1366
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
1367
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
1368
|
+
}
|
|
1369
|
+
return
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1095
1372
|
res.writeHead(404)
|
|
1096
1373
|
res.end('Not Found')
|
|
1097
1374
|
}
|