indelible-mcp 4.3.1 → 4.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "indelible-mcp",
3
- "version": "4.3.1",
3
+ "version": "4.4.0",
4
4
  "description": "Blockchain-backed memory and code storage for Claude Code. Save AI conversations and source code permanently on BSV.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -11,6 +11,7 @@
11
11
  "src/"
12
12
  ],
13
13
  "scripts": {
14
+ "test": "node --test test/*.test.js",
14
15
  "start": "node src/index.js",
15
16
  "build": "bun build --compile src/index.js --outfile dist/indelible.exe",
16
17
  "build:all": "bun build --compile --target=bun-windows-x64 src/index.js --outfile dist/indelible-win-x64.exe && bun build --compile --target=bun-linux-x64 src/index.js --outfile dist/indelible-linux-x64 && bun build --compile --target=bun-darwin-arm64 src/index.js --outfile dist/indelible-darwin-arm64"
@@ -33,7 +34,7 @@
33
34
  },
34
35
  "homepage": "https://indelible.one",
35
36
  "dependencies": {
36
- "@bsv/sdk": "1.10.1",
37
+ "@bsv/sdk": "^2.1.0",
37
38
  "cross-keychain": "^1.1.0"
38
39
  }
39
40
  }
@@ -7,9 +7,51 @@
7
7
  import { Transaction } from '@bsv/sdk'
8
8
  import { loadConfig } from './config.js'
9
9
  import * as spv from './spv.js'
10
+ import { encryptSummary } from './summary.js'
11
+ import { createAuthClient } from './wallet-brc100.js'
10
12
 
11
13
  const DEFAULT_API_URL = 'https://indelible.one'
12
14
 
15
+ // ───────────────────────────────────────────────────────────────────────────
16
+ // g-181 Phase 1 (V3.3): Tier 2 BRC-100 auto-detect with TTL cache + backoff.
17
+ // Mirrors mcp-server/lib/api.js logic exactly.
18
+ // ───────────────────────────────────────────────────────────────────────────
19
+ const TIER2_CACHE_TTL_MS = 5 * 60 * 1000
20
+ const TIER2_BACKOFF_TTL_MS = 30 * 1000
21
+ const TIER2_BACKOFF_THRESHOLD = 3
22
+
23
+ const tier2Cache = new Map()
24
+ const tier2TransientFailures = new Map()
25
+
26
+ function getTier2Cached(apiUrl) {
27
+ const e = tier2Cache.get(apiUrl)
28
+ if (!e) return null
29
+ if (Date.now() - e.ts > TIER2_CACHE_TTL_MS) { tier2Cache.delete(apiUrl); return null }
30
+ return e.supported
31
+ }
32
+
33
+ function setTier2Cached(apiUrl, supported) {
34
+ tier2Cache.set(apiUrl, { supported, ts: Date.now() })
35
+ if (supported) tier2TransientFailures.delete(apiUrl)
36
+ }
37
+
38
+ function isInTransientBackoff(apiUrl) {
39
+ const f = tier2TransientFailures.get(apiUrl)
40
+ if (!f || f.count < TIER2_BACKOFF_THRESHOLD) return false
41
+ if (Date.now() - f.since > TIER2_BACKOFF_TTL_MS) {
42
+ tier2TransientFailures.delete(apiUrl)
43
+ return false
44
+ }
45
+ return true
46
+ }
47
+
48
+ function recordTransientFailure(apiUrl) {
49
+ const f = tier2TransientFailures.get(apiUrl) || { count: 0, since: Date.now() }
50
+ f.count += 1
51
+ if (f.count === TIER2_BACKOFF_THRESHOLD) f.since = Date.now()
52
+ tier2TransientFailures.set(apiUrl, f)
53
+ }
54
+
13
55
  function getApiUrl() {
14
56
  const config = loadConfig()
15
57
  return config?.api_url || DEFAULT_API_URL
@@ -70,9 +112,72 @@ export async function register(address, apiKey) {
70
112
  /**
71
113
  * Index a session on server for web app retrieval.
72
114
  * Called after successful broadcast — best effort, doesn't block save.
115
+ *
116
+ * g-181 Phase 1 (V3.3): tries BRC-100 Tier 2 first (when wif provided +
117
+ * server supports), falls back to Bearer (Tier 1) on 404 or after 3
118
+ * consecutive transient failures (30s backoff).
119
+ *
120
+ * @param {object} sessionData - session payload to index
121
+ * @param {object} config - loaded config (api_key, auth_mode, etc.)
122
+ * @param {string} [wif] - optional WIF for Tier 2 BRC-100 auth
73
123
  */
74
- export async function indexSession(sessionData, config) {
75
- const res = await fetch(`${getApiUrl()}/api/cli/index-session`, {
124
+ export async function indexSession(sessionData, config, wif) {
125
+ const apiUrl = getApiUrl()
126
+ const mode = config?.auth_mode || 'auto'
127
+
128
+ // tier1 forced or no WIF available: skip Tier 2 entirely
129
+ if (mode === 'tier1' || !wif) return tier1IndexSession(sessionData, config, apiUrl)
130
+
131
+ const cached = getTier2Cached(apiUrl)
132
+ const inBackoff = isInTransientBackoff(apiUrl)
133
+ const shouldTryT2 = mode === 'tier2' || cached === true || (cached === null && !inBackoff)
134
+
135
+ if (shouldTryT2) {
136
+ try {
137
+ const auth = createAuthClient(wif)
138
+ const res = await auth.fetch(`${apiUrl}/api/cli/v2/index-session`, {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify(sessionData),
142
+ })
143
+ if (res.ok) {
144
+ setTier2Cached(apiUrl, true)
145
+ return res.json()
146
+ }
147
+ if (res.status === 404) {
148
+ // Pack-V2 fix (Gremlin attack 5): distinguish route-missing from handler-404
149
+ const tier2Header = res.headers.get('X-Tier2-Supported')
150
+ let bodyCode = null
151
+ try { const j = await res.clone().json(); bodyCode = j?.code || null } catch {}
152
+ const isRouteMissing = tier2Header === 'false' || (!bodyCode && tier2Header !== 'true')
153
+ if (isRouteMissing) {
154
+ setTier2Cached(apiUrl, false)
155
+ }
156
+ if (mode === 'tier2') {
157
+ throw new Error('Tier 2 forced but server returned 404')
158
+ }
159
+ } else if (res.status >= 500) {
160
+ recordTransientFailure(apiUrl)
161
+ if (mode === 'tier2') throw new Error(`Tier 2 forced but server returned ${res.status}`)
162
+ } else {
163
+ if (mode === 'tier2') throw new Error(`Tier 2 forced but server returned ${res.status}`)
164
+ }
165
+ } catch (err) {
166
+ if (mode === 'tier2') throw err
167
+ const is404 = err?.status === 404 || /404|not found/i.test(err?.message || '')
168
+ if (is404 && !err?.responseCode) {
169
+ setTier2Cached(apiUrl, false)
170
+ } else {
171
+ recordTransientFailure(apiUrl)
172
+ }
173
+ }
174
+ }
175
+
176
+ return tier1IndexSession(sessionData, config, apiUrl)
177
+ }
178
+
179
+ async function tier1IndexSession(sessionData, config, apiUrl) {
180
+ const res = await fetch(`${apiUrl}/api/cli/index-session`, {
76
181
  method: 'POST',
77
182
  headers: getHeaders(config),
78
183
  body: JSON.stringify(sessionData),
@@ -141,23 +246,37 @@ export async function commitSession(session, wif) {
141
246
  protocol: 'indelible.claude-code',
142
247
  encrypted: session.encrypted
143
248
  }
144
- const { txHex, txId, changeUtxos } = await spv.buildOpReturnTxWithChange(wif, utxos, JSON.stringify(payload))
145
- await spv.broadcastTx(txHex)
249
+ const { txHex, txId, changeUtxos, fee, txSize } = await spv.buildOpReturnTxWithChange(wif, utxos, JSON.stringify(payload))
250
+ const writeReceipt = await spv.broadcastTx(txHex) // SaveResult: { txid, via, source, federation, confirmed, bridge }
251
+
252
+ // g-110: encrypt summary with AAD=txId after broadcast (txId now known).
253
+ // Plaintext summary is dropped from the index POST entirely.
254
+ let summaryEnc = null
255
+ if (session.summary) {
256
+ try {
257
+ summaryEnc = encryptSummary(session.summary, wif, txId)
258
+ } catch {
259
+ // proceed without summary_enc — tx already on chain, indexing is best-effort
260
+ }
261
+ }
146
262
 
147
263
  // Index session for usage tracking
264
+ // g-181: pass WIF for Tier 2 BRC-100 auth (auto-detect with Bearer fallback)
148
265
  await indexSession({
149
266
  txId,
150
267
  address: session.address,
151
268
  encrypted: session.encrypted,
152
269
  session_id: session.session_id,
153
270
  prev_session_id: session.prev_session_id,
154
- summary: session.summary,
271
+ summary_enc: summaryEnc,
155
272
  message_count: session.message_count,
156
273
  save_type: session.type || 'full',
157
274
  timestamp: new Date().toISOString()
158
- }, config)
275
+ }, config, wif)
159
276
 
160
- return { success: true, txId, changeUtxos }
277
+ // g-314: the Save Receipt — the full sovereign flow, surfaced "out loud".
278
+ const receipt = buildReceipt(writeReceipt, { txId, fee, txSize })
279
+ return { success: true, txId, changeUtxos, receipt }
161
280
  }
162
281
  } catch (err) {
163
282
  if (err.message.includes('save limit')) throw err
@@ -167,6 +286,55 @@ export async function commitSession(session, wif) {
167
286
  throw new Error('No UTXOs available. Fund your wallet or check gateway connection.')
168
287
  }
169
288
 
289
+ /**
290
+ * g-314: assemble a Save Receipt from a broadcastTx write-result + build info.
291
+ * Shared by commitSession and the direct-broadcast save tools (save_file/project/...)
292
+ * so the receipt shape never drifts between save paths.
293
+ * @param {object} writeResult - the object returned by spv.broadcastTx
294
+ * @param {{txId:string, fee:number, txSize:number}} build
295
+ */
296
+ export function buildReceipt (writeResult, { txId, fee, txSize }) {
297
+ const w = writeResult || {}
298
+ return {
299
+ txid: txId,
300
+ satCost: fee,
301
+ sizeBytes: txSize,
302
+ readSource: 'bridge', // getUtxos + per-input getRawTx are bridge-only (sovereign)
303
+ writeVia: w.via || 'bridge',
304
+ source: w.source || null, // which hop the bridge's waterfall used: sovereign | arc | woc
305
+ bridge: w.bridge || null, // which federation bridge accepted it
306
+ federation: w.federation || null, // { bridges, totalBsvPeers, confirmations[] }
307
+ confirmed: !!w.confirmed,
308
+ status: 'committed'
309
+ }
310
+ }
311
+
312
+ /**
313
+ * g-314: render the Save Receipt "out loud" — the full sovereign flow as one
314
+ * block. Shows the read side + write side + which bridge/hop, the sat cost, and
315
+ * the FULL (untruncated) txid. Used by the CLI and the MCP-in-IDE save response.
316
+ * @param {object} r - the `receipt` object returned by commitSession
317
+ * @returns {string}
318
+ */
319
+ export function formatSaveReceipt (r) {
320
+ if (!r) return ''
321
+ const hop = r.source === 'sovereign' ? 'your sovereign node'
322
+ : r.source === 'arc' ? 'ARC (break-glass)'
323
+ : r.source === 'woc' ? 'WhatsOnChain (last resort)'
324
+ : (r.bridge ? `bridge ${r.bridge}` : 'federation')
325
+ const fedN = (r.federation && typeof r.federation.bridges === 'number') ? r.federation.bridges : null
326
+ const costLine = (typeof r.satCost === 'number')
327
+ ? ` cost: ${r.satCost} sats (${r.sizeBytes} bytes)` : null
328
+ return [
329
+ '📜 Save Receipt',
330
+ ' read: federation bridge → sovereign indexer',
331
+ ` write: ${hop}${fedN != null ? ` · ${fedN} bridge${fedN === 1 ? '' : 's'} confirmed` : ''}`,
332
+ costLine,
333
+ ` txid: ${r.txid}`,
334
+ ` status: ${r.confirmed ? 'confirmed' : 'broadcast'} (via ${r.writeVia})`
335
+ ].filter(Boolean).join('\n')
336
+ }
337
+
170
338
  /**
171
339
  * Get latest sessions for an address
172
340
  * Scans address history via SPV bridge — no remote fallback
@@ -0,0 +1,93 @@
1
+ /**
2
+ * g-314: the Save Log — a persistent, append-only ledger of every save (foreground
3
+ * AND background auto-save), so a save is never silent. Lives at
4
+ * ~/.indelible/save-log.jsonl, one JSON line per save. Powers:
5
+ * - background-auto-save surfacing in the IDE (post-compact-restore reads
6
+ * UNSURFACED auto entries and injects their receipts into context), and
7
+ * - the web "save-log" ledger (reads the same file).
8
+ * Every function is fail-soft — logging must NEVER break or block a save.
9
+ */
10
+ import { appendFileSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
11
+ import { join } from 'path'
12
+ import { homedir } from 'os'
13
+
14
+ const DIR = join(homedir(), '.indelible')
15
+ const LOG = join(DIR, 'save-log.jsonl')
16
+
17
+ function ensureDir () { try { if (!existsSync(DIR)) mkdirSync(DIR, { recursive: true }) } catch {} }
18
+
19
+ /**
20
+ * Append a save receipt to the ledger. Never throws.
21
+ * @param {object} receipt - the receipt from commitSession/buildReceipt
22
+ * @param {object} [meta] - { saveType, trigger:'interactive'|'auto', label }
23
+ */
24
+ export function appendReceipt (receipt, meta = {}) {
25
+ if (!receipt || !receipt.txid) return
26
+ try {
27
+ ensureDir()
28
+ const entry = {
29
+ ts: new Date().toISOString(),
30
+ txid: receipt.txid,
31
+ satCost: receipt.satCost ?? null,
32
+ source: receipt.source || null, // sovereign | arc | woc — which hop the bridge used
33
+ bridge: receipt.bridge || null,
34
+ confirmed: !!receipt.confirmed,
35
+ readSource: receipt.readSource || null,
36
+ saveType: meta.saveType || null, // full | delta | file | project | diary
37
+ trigger: meta.trigger || 'interactive', // interactive | auto (background/hook)
38
+ label: meta.label || null,
39
+ surfaced: false // has the IDE shown this entry yet?
40
+ }
41
+ appendFileSync(LOG, JSON.stringify(entry) + '\n')
42
+ } catch { /* never break a save on logging */ }
43
+ }
44
+
45
+ /** Read the whole ledger (oldest first). Returns []. */
46
+ export function readAll () {
47
+ try {
48
+ if (!existsSync(LOG)) return []
49
+ return readFileSync(LOG, 'utf8').split('\n').filter(Boolean)
50
+ .map(l => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
51
+ } catch { return [] }
52
+ }
53
+
54
+ /** Recent N entries (oldest first within the window). */
55
+ export function readRecent (n = 25) { return readAll().slice(-n) }
56
+
57
+ /** Background auto-saves the IDE hasn't shown yet. */
58
+ export function readUnsurfaced () { return readAll().filter(e => !e.surfaced && e.trigger === 'auto') }
59
+
60
+ /** Mark the given txids surfaced so the IDE doesn't re-show them. */
61
+ export function markSurfaced (txids) {
62
+ try {
63
+ const set = new Set(txids)
64
+ const all = readAll()
65
+ let changed = false
66
+ for (const e of all) { if (set.has(e.txid) && !e.surfaced) { e.surfaced = true; changed = true } }
67
+ if (changed) writeFileSync(LOG, all.map(e => JSON.stringify(e)).join('\n') + '\n')
68
+ } catch { /* non-fatal */ }
69
+ }
70
+
71
+ function hopLabel (e) {
72
+ return e.source === 'sovereign' ? 'your sovereign node'
73
+ : e.source === 'arc' ? 'ARC (break-glass)'
74
+ : e.source === 'woc' ? 'WhatsOnChain (last resort)'
75
+ : (e.bridge || 'federation')
76
+ }
77
+
78
+ /**
79
+ * Read + render any UNSURFACED background auto-saves as an IDE block, and mark
80
+ * them surfaced. Returns '' if none. Called by post-compact-restore so a save
81
+ * that happened while you were away isn't silent — it shows "out loud" on restore.
82
+ */
83
+ export function renderUnsurfacedBlock () {
84
+ const entries = readUnsurfaced()
85
+ if (!entries.length) return ''
86
+ markSurfaced(entries.map(e => e.txid))
87
+ const lines = ['# 📜 Saved while you were away (background auto-save)']
88
+ for (const e of entries) {
89
+ const cost = e.satCost != null ? `${e.satCost} sats` : '—'
90
+ lines.push(`- ${e.saveType || 'save'} · ${hopLabel(e)} · ${cost} · ${e.confirmed ? 'confirmed' : 'broadcast'} · txid ${e.txid}`)
91
+ }
92
+ return lines.join('\n')
93
+ }
package/src/lib/spv.js CHANGED
@@ -131,9 +131,18 @@ async function getBridges() {
131
131
 
132
132
  async function spvFetch(path, options = {}) {
133
133
  const bridges = await getBridges()
134
+ // Read-affinity (g-314): if we just broadcast to a bridge, try IT first — it holds the
135
+ // fresh tx in its mempool — before normal failover. Prevents the cross-bridge gossip
136
+ // race from mis-reading a just-broadcast tx as missing (false ghost).
137
+ const sticky = getStickyBridge()
138
+ let ordered = bridges
139
+ if (sticky) {
140
+ const pinned = bridges.filter(b => b.url === sticky.url)
141
+ if (pinned.length) ordered = [...pinned, ...bridges.filter(b => b.url !== sticky.url)]
142
+ }
134
143
  let lastError = null
135
144
 
136
- for (const bridge of bridges) {
145
+ for (const bridge of ordered) {
137
146
  if (!isBridgeHealthy(bridge.url)) continue
138
147
 
139
148
  try {
@@ -206,44 +215,20 @@ export async function getTx(txid) {
206
215
  }
207
216
 
208
217
  export async function getRawTx(txid) {
218
+ // Sovereign-only: the bridge serves raw hex from our node/indexer (mempool + mined).
219
+ // No WhatsOnChain fallback — the client never reads tx data from a third party.
209
220
  try {
210
221
  const res = await spvFetch(`/api/tx/${txid.trim()}/hex`)
211
222
  if (res.ok) return res.text()
212
223
  } catch {}
213
-
214
- // WoC fallback for unconfirmed/uncached txs
215
- const wocRes = await fetch(`https://api.whatsonchain.com/v1/bsv/main/tx/${txid.trim()}/hex`)
216
- if (wocRes.ok) return wocRes.text()
217
-
218
224
  throw new Error(`Failed to get raw tx hex for ${txid}`)
219
225
  }
220
226
 
221
- /**
222
- * Broadcast via ARC (GorillaPool) direct to Teranode propagation service.
223
- * Primary broadcast path. No API key needed.
224
- */
225
- async function broadcastViaArc(rawTxHex) {
226
- const t0 = Date.now()
227
- try {
228
- const res = await fetch('https://arc.gorillapool.io/v1/tx', {
229
- method: 'POST',
230
- signal: AbortSignal.timeout(10000),
231
- headers: { 'Content-Type': 'text/plain' },
232
- body: rawTxHex
233
- })
234
- const elapsed = Date.now() - t0
235
- if (res.ok) {
236
- const result = await res.json()
237
- process.stderr.write(`[MCP] ARC broadcast OK — txid: ${result.txid} (${elapsed}ms)\n`)
238
- return { txid: result.txid, via: 'arc' }
239
- }
240
- const body = await res.text().catch(() => '')
241
- throw new Error(`ARC HTTP ${res.status} — ${body.slice(0, 200)}`)
242
- } catch (err) {
243
- process.stderr.write(`[MCP] ARC broadcast FAIL: ${err.message} (${Date.now() - t0}ms)\n`)
244
- throw err
245
- }
246
- }
227
+ // ── No ARC in the client ──────────────────────────────────────────────
228
+ // The client is SOVEREIGN-ONLY: broadcastTx hands the signed tx to the federation
229
+ // bridge ONLY, and the bridge owns the ARC break-glass internally (node → P2P →
230
+ // ARC → WoC, each txStatus-gated). There is deliberately NO third-party broadcast
231
+ // endpoint in the customer client. See broadcastTx + 6-19.md.
247
232
 
248
233
  /**
249
234
  * Broadcast via federation bridges (fallback path).
@@ -268,7 +253,7 @@ async function broadcastViaBridges(rawTxHex) {
268
253
  const result = await res.json()
269
254
  recordSuccess(bridge.url)
270
255
  process.stderr.write(`[MCP] ${bridge.name} OK (${elapsed}ms)\n`)
271
- return result
256
+ return { ...result, _bridge: bridge } // tag winning bridge → sticky affinity + receipt
272
257
  }
273
258
  const body = await res.text().catch(() => '')
274
259
  recordFailure(bridge.url)
@@ -290,32 +275,59 @@ async function broadcastViaBridges(rawTxHex) {
290
275
  }
291
276
  }
292
277
 
278
+ // Read-affinity (g-314): after a broadcast lands on a bridge, pin reads to THAT bridge
279
+ // briefly so an immediate read-back hits the mempool that actually has the tx — instead
280
+ // of racing cross-bridge gossip and mis-reading a fresh tx as missing (false ghost).
281
+ let _stickyBridge = null // { url, name, until }
282
+ const STICKY_WINDOW_MS = 5000
283
+ function setStickyBridge (b) { _stickyBridge = { url: b.url, name: b.name, until: Date.now() + STICKY_WINDOW_MS } }
284
+ export function getStickyBridge () {
285
+ if (_stickyBridge && Date.now() < _stickyBridge.until) return _stickyBridge
286
+ _stickyBridge = null
287
+ return null
288
+ }
289
+
293
290
  /**
294
- * Broadcast raw transaction — ARC first (Teranode pipe), bridges as fallback.
291
+ * Broadcast a raw transaction — SOVEREIGN-FIRST, via the federation bridge ONLY.
295
292
  *
296
- * Primary: MCP ARC (GorillaPool) Teranode propagation miners
297
- * Fallback: MCP Federation bridges BSV P2P network
293
+ * The client does NOT call ARC/WhatsOnChain directly. It hands the signed tx to the
294
+ * bridge, which runs the deployed sovereign waterfall internally:
295
+ * our node → P2P → ARC (break-glass) → WoC (last resort), each verified.
296
+ * If the whole federation is unreachable we FAIL LOUD — sovereign-first means we never
297
+ * silently route around the fleet to a third party from the client (g-314 / 6-19.md).
298
+ *
299
+ * @param {string} rawTxHex - Serialized transaction hex
300
+ * @returns {Promise<{txid, via, source, federation, confirmed, bridge}>} write-side SaveResult
298
301
  */
299
302
  export async function broadcastTx(rawTxHex) {
300
- const t0 = Date.now()
301
-
302
- // Primary: ARC Teranode
303
- try {
304
- const result = await broadcastViaArc(rawTxHex)
305
- process.stderr.write(`[MCP] Broadcast complete via ARC in ${Date.now() - t0}ms\n`)
306
- return result
307
- } catch (arcErr) {
308
- process.stderr.write(`[MCP] ARC failed, falling back to bridges...\n`)
309
- }
310
-
311
- // Fallback: Federation bridges → BSV P2P
312
- try {
313
- const result = await broadcastViaBridges(rawTxHex)
314
- process.stderr.write(`[MCP] Broadcast complete via bridges in ${Date.now() - t0}ms\n`)
315
- return result
316
- } catch (bridgeErr) {
317
- throw new Error(`Broadcast failed — ARC and all bridges down: ${bridgeErr.message}`)
303
+ const MAX_RETRIES = 3
304
+ let lastErr
305
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
306
+ const t0 = Date.now()
307
+ try {
308
+ const result = await broadcastViaBridges(rawTxHex)
309
+ const winner = result._bridge
310
+ if (winner) setStickyBridge(winner) // read-affinity for the next reads
311
+ process.stderr.write(`[MCP] Broadcast complete via federation (source=${result.source || 'bridge'}) in ${Date.now() - t0}ms\n`)
312
+ return {
313
+ txid: result.txid,
314
+ via: 'bridge',
315
+ source: result.source || null, // sovereign | arc | woc — which hop the bridge's waterfall used
316
+ federation: result.federation || null, // { bridges, totalBsvPeers, confirmations[] }
317
+ confirmed: !!result.confirmed,
318
+ bridge: winner ? winner.name : null
319
+ }
320
+ } catch (bridgeErr) {
321
+ lastErr = bridgeErr
322
+ if (attempt < MAX_RETRIES) {
323
+ const delay = attempt * 2000
324
+ process.stderr.write(`[MCP] Federation unreachable (attempt ${attempt}/${MAX_RETRIES}) — retrying in ${delay}ms...\n`)
325
+ await new Promise(r => setTimeout(r, delay))
326
+ }
327
+ }
318
328
  }
329
+ // FAIL LOUD — never fall back to a third party from the client machine.
330
+ throw new Error(`Save NOT committed — federation unreachable after ${MAX_RETRIES} attempts: ${lastErr ? lastErr.message : 'unknown'}`)
319
331
  }
320
332
 
321
333
  /**
@@ -436,16 +448,22 @@ export async function buildOpReturnTxWithChange(wif, utxos, dataStr) {
436
448
  const txId = tx.id('hex')
437
449
  const txHex = tx.toHex()
438
450
 
439
- // Calculate change for UTXO chaining
451
+ // Read the change the SDK ACTUALLY wrote during tx.fee() — never recompute it. tx.fee() sizes the
452
+ // change from a PRE-sign estimate; recomputing from the post-sign txHex length diverges by the
453
+ // signature-size estimate error (~1-2 sat/input), overstating change. The returned changeUtxos.value
454
+ // would then mismatch the real on-chain output, and the NEXT chained spend signs over a wrong amount
455
+ // → ARC 461 NULLFAIL. Read tx.outputs[changeIndex].satoshis instead.
440
456
  const txSize = txHex.length / 2
441
- const fee = Math.ceil(txSize * 1)
442
- const changeValue = totalInput - fee
457
+ let changeIdx = tx.outputs.findIndex(o => o.change)
458
+ if (changeIdx < 0) changeIdx = 1 // OP_RETURN added at 0, change at 1 (deterministic build order)
459
+ const changeValue = tx.outputs[changeIdx] ? (tx.outputs[changeIdx].satoshis || 0) : 0
460
+ const fee = totalInput - tx.outputs.reduce((s, o) => s + (o.satoshis || 0), 0)
443
461
 
444
462
  const changeUtxos = []
445
463
  if (changeValue > 546) {
446
464
  changeUtxos.push({
447
465
  tx_hash: txId,
448
- tx_pos: 1,
466
+ tx_pos: changeIdx,
449
467
  value: changeValue
450
468
  })
451
469
  }
@@ -492,12 +510,15 @@ export async function buildPaymentTx(wif, utxos, payToAddress, satoshis) {
492
510
  const txId = tx.id('hex')
493
511
  const txHex = tx.toHex()
494
512
  const txSize = txHex.length / 2
495
- const fee = Math.ceil(txSize)
496
- const changeValue = totalInput - satoshis - fee
513
+ // Read the SDK's actual change/fee don't recompute from txHex length (see buildOpReturnTxWithChange).
514
+ let changeIdx = tx.outputs.findIndex(o => o.change)
515
+ if (changeIdx < 0) changeIdx = 1 // payment at 0, change at 1 (deterministic build order)
516
+ const changeValue = tx.outputs[changeIdx] ? (tx.outputs[changeIdx].satoshis || 0) : 0
517
+ const fee = totalInput - tx.outputs.reduce((s, o) => s + (o.satoshis || 0), 0)
497
518
 
498
519
  const changeUtxos = []
499
520
  if (changeValue > 546) {
500
- changeUtxos.push({ tx_hash: txId, tx_pos: 1, value: changeValue })
521
+ changeUtxos.push({ tx_hash: txId, tx_pos: changeIdx, value: changeValue })
501
522
  }
502
523
 
503
524
  return { txHex, txId, changeUtxos, fee, txSize, satoshis }
@@ -514,24 +535,30 @@ export function extractEncryptedFromTx(tx) {
514
535
  }
515
536
 
516
537
  /**
517
- * Check if a transaction was confirmed (mined into a block).
518
- * Uses WoC as the source of truth since it indexes confirmed txs.
538
+ * Check a tx's lifecycle via the bridge's SOVEREIGN /api/tx/:txid/status (mempool-aware),
539
+ * NOT a third-party indexer. Returns the full state so callers can tell mempool-pending
540
+ * from mined and never re-create a false ghost (g-314 / 6-19.md):
541
+ * blockHeight > 0 → confirmed (mined)
542
+ * blockHeight null + inMempool → pending — do NOT re-broadcast, do NOT ghost
543
+ * exists === false → never seen
519
544
  * @param {string} txid
520
- * @returns {Promise<{confirmed: boolean, blockHeight: number|null}>}
545
+ * @returns {Promise<{confirmed: boolean, blockHeight: number|null, inMempool: boolean, exists: boolean|null}>}
521
546
  */
522
547
  export async function checkConfirmation(txid) {
523
548
  try {
524
- const res = await fetch(`https://api.whatsonchain.com/v1/bsv/main/tx/${txid.trim()}`, {
525
- signal: AbortSignal.timeout(8000)
526
- })
527
- if (!res.ok) return { confirmed: false, blockHeight: null }
528
- const tx = await res.json()
529
- if (tx.blockheight && tx.blockheight > 0) {
530
- return { confirmed: true, blockHeight: tx.blockheight }
549
+ const res = await spvFetch(`/api/tx/${txid.trim()}/status`)
550
+ const s = await res.json()
551
+ const blockHeight = (s.blockHeight === undefined ? null : s.blockHeight)
552
+ return {
553
+ confirmed: blockHeight !== null && blockHeight > 0,
554
+ blockHeight,
555
+ inMempool: !!s.inMempool,
556
+ exists: !!s.exists
531
557
  }
532
- return { confirmed: false, blockHeight: null }
533
558
  } catch {
534
- return { confirmed: false, blockHeight: null }
559
+ // Federation unreachable = UNKNOWN, not "unconfirmed". Caller must not treat
560
+ // this as "never mined" and ghost/re-broadcast on it.
561
+ return { confirmed: false, blockHeight: null, inMempool: false, exists: null }
535
562
  }
536
563
  }
537
564
 
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Session summary encryption (g-110).
3
+ * AES-256-GCM with txId bound as associated data — splice attempts fail tag verify.
4
+ * Helper returns { ok, text } | { ok: false, reason } so silent fallthrough is impossible.
5
+ *
6
+ * MIRROR of mcp-server/lib/summary.js — keep in sync. Difference: imports node:crypto with prefix.
7
+ */
8
+
9
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
10
+ import { deriveKey } from './crypto.js'
11
+
12
+ const TXID_REGEX = /^[0-9a-f]{64}$/i
13
+
14
+ export const LOCK_PLACEHOLDER = {
15
+ missing: '🔒 (legacy save)',
16
+ missing_txid: '🔒 (no txid)',
17
+ no_wif: '🔒 (wallet locked)',
18
+ auth_tag: '🔒 (encrypted)',
19
+ corrupt: '🔒 (decrypt failed)',
20
+ }
21
+
22
+ export function encryptSummary(plaintext, wif, txId) {
23
+ if (!txId || typeof txId !== 'string' || !TXID_REGEX.test(txId)) {
24
+ throw new Error('encryptSummary: txId must be a 64-char hex string')
25
+ }
26
+ if (typeof plaintext !== 'string') {
27
+ throw new Error('encryptSummary: plaintext must be a string')
28
+ }
29
+ if (!wif) {
30
+ throw new Error('encryptSummary: wif required')
31
+ }
32
+
33
+ const key = deriveKey(wif)
34
+ const iv = randomBytes(12)
35
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
36
+ cipher.setAAD(Buffer.from(txId, 'utf8'))
37
+
38
+ let encrypted = cipher.update(plaintext, 'utf8', 'base64')
39
+ encrypted += cipher.final('base64')
40
+ const tag = cipher.getAuthTag()
41
+
42
+ return `${iv.toString('base64')}:${tag.toString('base64')}:${encrypted}`
43
+ }
44
+
45
+ export function decryptSummaryOrLock(summary_enc, wif, expectedTxId) {
46
+ if (!summary_enc) return { ok: false, reason: 'missing' }
47
+ if (!wif) return { ok: false, reason: 'no_wif' }
48
+ if (!expectedTxId || typeof expectedTxId !== 'string' || !TXID_REGEX.test(expectedTxId)) {
49
+ return { ok: false, reason: 'missing_txid' }
50
+ }
51
+
52
+ try {
53
+ const parts = summary_enc.split(':')
54
+ if (parts.length !== 3) return { ok: false, reason: 'corrupt' }
55
+ const [ivB64, tagB64, ciphertext] = parts
56
+
57
+ const key = deriveKey(wif)
58
+ const iv = Buffer.from(ivB64, 'base64')
59
+ const tag = Buffer.from(tagB64, 'base64')
60
+
61
+ const decipher = createDecipheriv('aes-256-gcm', key, iv)
62
+ decipher.setAAD(Buffer.from(expectedTxId, 'utf8'))
63
+ decipher.setAuthTag(tag)
64
+
65
+ let decrypted = decipher.update(ciphertext, 'base64', 'utf8')
66
+ decrypted += decipher.final('utf8')
67
+
68
+ return { ok: true, text: decrypted }
69
+ } catch (e) {
70
+ if (/auth|tag|unsupported state/i.test(e.message)) {
71
+ return { ok: false, reason: 'auth_tag' }
72
+ }
73
+ return { ok: false, reason: 'corrupt' }
74
+ }
75
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * BRC-100 client wallet for Indelible CLI (g-181 Phase 1).
3
+ *
4
+ * Mirror of mcp-server/lib/wallet-brc100.js with one difference:
5
+ * uses `ProtoWallet` instead of `CompletedProtoWallet` for compatibility
6
+ * with @bsv/sdk 1.10.1 (CLI's pinned version). `ProtoWallet` provides
7
+ * everything Phase 1 needs (getPublicKey, createSignature, verifySignature).
8
+ * `CompletedProtoWallet` (2.x+) adds methods we don't use until g-180.
9
+ *
10
+ * If the CLI's @bsv/sdk gets bumped to 2.x, this can switch to
11
+ * `CompletedProtoWallet` to match MCP exactly. No behavioral change either way
12
+ * for Phase 1 endpoints.
13
+ */
14
+ import { PrivateKey, ProtoWallet, AuthFetch } from '@bsv/sdk'
15
+
16
+ // Phase 1 assumption: single-WIF per process lifetime.
17
+ // Pack-V2 fix (Gremlin attack 4): if a different WIF is passed after
18
+ // initialization, throw loudly instead of silently returning wrong client.
19
+ let cached = null
20
+ let cachedWif = null
21
+
22
+ export function createAuthClient(wif) {
23
+ if (!wif) throw new Error('createAuthClient: wif required')
24
+ if (cached) {
25
+ if (wif !== cachedWif) {
26
+ throw new Error(
27
+ 'createAuthClient: WIF mismatch — singleton already initialized with a different key. ' +
28
+ 'Wallet-switching is not supported in Phase 1. Restart the process to use a new WIF.'
29
+ )
30
+ }
31
+ return cached
32
+ }
33
+ const wallet = new ProtoWallet(PrivateKey.fromWif(wif))
34
+ const af = new AuthFetch(wallet)
35
+ cachedWif = wif
36
+ cached = { fetch: af.fetch.bind(af), wallet }
37
+ return cached
38
+ }
39
+
40
+ export function _resetAuthClientCache() {
41
+ cached = null
42
+ }
@@ -12,7 +12,8 @@
12
12
  import { loadConfig, saveConfig, getWif } from '../lib/config.js'
13
13
  import { encrypt, sha256 } from '../lib/crypto.js'
14
14
  import * as spv from '../lib/spv.js'
15
- import { indexSession, checkProTier } from '../lib/api-client.js'
15
+ import { indexSession, checkProTier, buildReceipt, formatSaveReceipt } from '../lib/api-client.js'
16
+ import { appendReceipt } from '../lib/save-log.js'
16
17
 
17
18
  /**
18
19
  * @param {Object} options
@@ -96,6 +97,10 @@ export async function diarySave({ messages, summary }) {
96
97
  const broadcastResult = await spv.broadcastTx(txHex)
97
98
  const finalTxId = broadcastResult.txid || txId
98
99
 
100
+ // g-314: Save Receipt — surface "out loud" + log to the ledger.
101
+ const receipt = buildReceipt(broadcastResult, { txId: finalTxId, fee: null, txSize: txHex.length / 2 })
102
+ appendReceipt(receipt, { saveType: 'diary', trigger: 'interactive', label: diaryName })
103
+
99
104
  // Index on server for web app retrieval (best effort)
100
105
  try {
101
106
  await indexSession({
@@ -125,7 +130,8 @@ export async function diarySave({ messages, summary }) {
125
130
  prevSessionId,
126
131
  messageCount: messages.length,
127
132
  source: diaryName,
128
- message: `${diaryName} exchange saved! ${messages.length} messages committed to blockchain.`
133
+ receipt,
134
+ message: `${diaryName} exchange saved! ${messages.length} messages committed to blockchain.\n\n${formatSaveReceipt(receipt)}`
129
135
  }
130
136
 
131
137
  } catch (error) {
@@ -11,6 +11,8 @@ import { homedir } from 'node:os'
11
11
  import { loadConfig, saveConfig, getWif } from '../lib/config.js'
12
12
  import { encrypt, sha256 } from '../lib/crypto.js'
13
13
  import * as spv from '../lib/spv.js'
14
+ import { buildReceipt, formatSaveReceipt } from '../lib/api-client.js'
15
+ import { appendReceipt } from '../lib/save-log.js'
14
16
 
15
17
  const MAX_CHUNK_SIZE = 50000
16
18
 
@@ -57,6 +59,7 @@ export async function saveFile(filePath, options = {}) {
57
59
  try {
58
60
  let masterTxId
59
61
  let finalChangeUtxos = null
62
+ let masterReceipt = null
60
63
 
61
64
  if (encrypted.length <= MAX_CHUNK_SIZE) {
62
65
  const payload = {
@@ -79,6 +82,7 @@ export async function saveFile(filePath, options = {}) {
79
82
  cacheTx(txId, payload)
80
83
  masterTxId = result.txid || txId
81
84
  finalChangeUtxos = changeUtxos
85
+ masterReceipt = buildReceipt(result, { txId: masterTxId, fee, txSize })
82
86
  } else {
83
87
  // Chunked upload
84
88
  const chunks = []
@@ -145,6 +149,7 @@ export async function saveFile(filePath, options = {}) {
145
149
  cacheTx(txId, masterPayload)
146
150
  masterTxId = result.txid || txId
147
151
  finalChangeUtxos = masterChangeUtxos
152
+ masterReceipt = buildReceipt(result, { txId: masterTxId, fee: masterFee, txSize: masterTxSize })
148
153
  }
149
154
 
150
155
  // Track in config
@@ -161,6 +166,9 @@ export async function saveFile(filePath, options = {}) {
161
166
 
162
167
  const chunkCount = encrypted.length <= MAX_CHUNK_SIZE ? 1 : Math.ceil(encrypted.length / MAX_CHUNK_SIZE)
163
168
 
169
+ // g-314: Save Receipt — surface "out loud" + log to the ledger.
170
+ appendReceipt(masterReceipt, { saveType: 'file', trigger: 'interactive', label: filename })
171
+
164
172
  return {
165
173
  success: true,
166
174
  txId: masterTxId,
@@ -170,7 +178,8 @@ export async function saveFile(filePath, options = {}) {
170
178
  contentHash: `sha256:${contentHash}`,
171
179
  chunks: chunkCount,
172
180
  changeUtxos: finalChangeUtxos,
173
- message: `File "${filename}" saved to blockchain. txId: ${masterTxId}${chunkCount > 1 ? ` (${chunkCount} chunks)` : ''}`
181
+ receipt: masterReceipt,
182
+ message: `File "${filename}" saved to blockchain. txId: ${masterTxId}${chunkCount > 1 ? ` (${chunkCount} chunks)` : ''}\n\n${formatSaveReceipt(masterReceipt)}`
174
183
  }
175
184
  } catch (error) {
176
185
  return { success: false, error: `Failed to save file: ${error.message}` }
@@ -10,6 +10,8 @@ import { homedir } from 'node:os'
10
10
  import { loadConfig, saveConfig, getWif } from '../lib/config.js'
11
11
  import { encrypt, sha256 } from '../lib/crypto.js'
12
12
  import * as spv from '../lib/spv.js'
13
+ import { buildReceipt, formatSaveReceipt } from '../lib/api-client.js'
14
+ import { appendReceipt } from '../lib/save-log.js'
13
15
 
14
16
  const TX_CACHE_DIR = join(homedir(), '.indelible', 'tx-cache')
15
17
 
@@ -145,9 +147,13 @@ export async function saveProject(dirPath, options = {}) {
145
147
  wif, utxos, JSON.stringify(payload), 'INDELIBLE_PROJECT_BUNDLE'
146
148
  )
147
149
  process.stderr.write(`[indelible] save_project "${projectName}": ${fee} sats (${txSize} bytes)\n`)
148
- await spv.broadcastTx(txHex)
150
+ const broadcastResult = await spv.broadcastTx(txHex)
149
151
  cacheTx(txId, payload)
150
152
 
153
+ // g-314: Save Receipt — surface "out loud" + log to the ledger.
154
+ const receipt = buildReceipt(broadcastResult, { txId, fee, txSize })
155
+ appendReceipt(receipt, { saveType: 'project', trigger: 'interactive', label: projectName })
156
+
151
157
  // Track in config
152
158
  const projects = config.project_txids || []
153
159
  projects.push({
@@ -165,7 +171,8 @@ export async function saveProject(dirPath, options = {}) {
165
171
  name: projectName,
166
172
  fileCount: allFiles.length,
167
173
  totalSize,
168
- message: `Project "${projectName}" saved as single tx bundle. ${allFiles.length} files, ${totalSize} bytes. txId: ${txId}`
174
+ receipt,
175
+ message: `Project "${projectName}" saved as single tx bundle. ${allFiles.length} files, ${totalSize} bytes. txId: ${txId}\n\n${formatSaveReceipt(receipt)}`
169
176
  }
170
177
  } catch (error) {
171
178
  return {
@@ -20,9 +20,11 @@ import { homedir } from 'node:os'
20
20
  import { execSync } from 'node:child_process'
21
21
  import { loadConfig, saveConfig, getWif } from '../lib/config.js'
22
22
  import { encrypt, sha256 } from '../lib/crypto.js'
23
+ import { encryptSummary } from '../lib/summary.js'
23
24
  import { findCurrentTranscript, parseTranscript } from '../lib/transcript.js'
24
25
  import * as spv from '../lib/spv.js'
25
- import { indexSession, checkProTier } from '../lib/api-client.js'
26
+ import { indexSession, checkProTier, buildReceipt, formatSaveReceipt } from '../lib/api-client.js'
27
+ import { appendReceipt } from '../lib/save-log.js'
26
28
  import { saveFile } from './save_file.js'
27
29
 
28
30
  const CONTEXT_FILE = join(homedir(), '.indelible', 'indelible-context.jsonl')
@@ -554,6 +556,17 @@ export async function saveSession(transcriptPath, summary) {
554
556
  const broadcastResult = await spv.broadcastTx(txHex)
555
557
  const finalTxId = broadcastResult.txid || txId
556
558
 
559
+ // g-314: Save Receipt — the full sovereign flow, surfaced "out loud" + logged.
560
+ const receipt = buildReceipt(broadcastResult, { txId: finalTxId, fee, txSize })
561
+ appendReceipt(receipt, { saveType: isDelta ? 'delta' : 'full', trigger: 'interactive' })
562
+
563
+ // g-260: encrypt the summary with AAD=txId so the web can decrypt it and render a title.
564
+ // The server drops plaintext summary (g-110 zero-trust) — only summary_enc survives + is shown.
565
+ let summaryEnc = null
566
+ try {
567
+ summaryEnc = encryptSummary(session.summary, wif, finalTxId)
568
+ } catch { /* proceed without summary_enc — tx is already on chain, indexing is best-effort */ }
569
+
557
570
  // Index session on server for web app retrieval (best effort)
558
571
  try {
559
572
  await indexSession({
@@ -561,7 +574,7 @@ export async function saveSession(transcriptPath, summary) {
561
574
  address: config.address,
562
575
  session_id: sessionId,
563
576
  prev_session_id: prevSessionId,
564
- summary: session.summary,
577
+ summary_enc: summaryEnc,
565
578
  message_count: session.message_count,
566
579
  save_type: isDelta ? 'delta' : 'full',
567
580
  encrypted,
@@ -575,7 +588,7 @@ export async function saveSession(transcriptPath, summary) {
575
588
  txId: finalTxId, address: config.address,
576
589
  session_id: sessionId,
577
590
  prev_session_id: prevSessionId,
578
- summary: session.summary,
591
+ summary_enc: summaryEnc,
579
592
  message_count: session.message_count,
580
593
  save_type: isDelta ? 'delta' : 'full',
581
594
  timestamp: session.created_at
@@ -701,7 +714,8 @@ export async function saveSession(transcriptPath, summary) {
701
714
  summary: session.summary,
702
715
  memoryTxId,
703
716
  historyTxId,
704
- message: `Session saved!${deltaInfo} committed to blockchain.${costInfo}${prevSessionId ? ' Linked to previous session.' : ' (First session)'}${memInfo}`
717
+ receipt,
718
+ message: `Session saved!${deltaInfo} committed to blockchain.${costInfo}${prevSessionId ? ' Linked to previous session.' : ' (First session)'}${memInfo}\n\n${formatSaveReceipt(receipt)}`
705
719
  }
706
720
  } catch (error) {
707
721
  return { success: false, error: `Failed to commit: ${error.message}` }
@@ -14,6 +14,8 @@ import { existsSync } from 'fs'
14
14
  import { loadConfig, saveConfig, getWif } from '../lib/config.js'
15
15
  import { encrypt, sha256 } from '../lib/crypto.js'
16
16
  import * as spv from '../lib/spv.js'
17
+ import { buildReceipt, formatSaveReceipt } from '../lib/api-client.js'
18
+ import { appendReceipt } from '../lib/save-log.js'
17
19
 
18
20
  // ── Core Infrastructure Rules ────────────────────────────────────────
19
21
  // These are baked into EVERY style — presets and custom.
@@ -154,7 +156,11 @@ export async function saveStyle(rulesText, styleName, description) {
154
156
  }
155
157
 
156
158
  const { txHex, txId } = await spv.buildOpReturnTx(wif, utxos, JSON.stringify(payload))
157
- await spv.broadcastTx(txHex)
159
+ const broadcastResult = await spv.broadcastTx(txHex)
160
+
161
+ // g-314: Save Receipt — surface "out loud" + log to the ledger.
162
+ const receipt = buildReceipt(broadcastResult, { txId: broadcastResult.txid || txId, fee: null, txSize: txHex.length / 2 })
163
+ appendReceipt(receipt, { saveType: 'style', trigger: 'interactive', label: styleName })
158
164
 
159
165
  // Store style txid in config for quick access
160
166
  await saveConfig({
@@ -170,7 +176,8 @@ export async function saveStyle(rulesText, styleName, description) {
170
176
  styleId,
171
177
  styleName,
172
178
  rulesLength: rulesText.length,
173
- message: `Style "${styleName}" saved to blockchain. txId: ${txId}`
179
+ receipt,
180
+ message: `Style "${styleName}" saved to blockchain. txId: ${txId}\n\n${formatSaveReceipt(receipt)}`
174
181
  }
175
182
  } catch (error) {
176
183
  return { success: false, error: `Failed to broadcast style: ${error.message}` }
@@ -84,7 +84,7 @@ export async function setupWallet(apiUrl = DEFAULT_API_URL, importWif, pin) {
84
84
  address,
85
85
  api_key: apiKey,
86
86
  api_url: apiUrl,
87
- spv_bridges: [{ url: 'http://155.138.238.167:8080', name: 'relay-gateway' }],
87
+ spv_bridges: [], // single source of truth = getBridges' always-merged live SEED_BRIDGES (no dead-gateway seed)
88
88
  spv_api_key: '',
89
89
  created_at: new Date().toISOString()
90
90
  }