@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 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)
@@ -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 history = await histRes.json()
33
+ const data = await histRes.json()
34
+ const history = Array.isArray(data) ? data : (data.result || [])
34
35
 
35
- if (!Array.isArray(history) || history.length === 0) {
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
  }
@@ -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
+ }
@@ -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 — transaction history for an address (via WoC)
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
- const resp = await fetch('https://api.whatsonchain.com/v1/bsv/main/address/' + addr + '/history', { signal: AbortSignal.timeout(10000) })
808
- if (!resp.ok) throw new Error('WoC returned ' + resp.status)
809
- const history = await resp.json()
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(502, { 'Content-Type': 'application/json' })
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relay-federation/bridge",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "description": "Bridge server — WebSocket peering, header sync, tx relay, CLI",
5
5
  "type": "module",
6
6
  "bin": {