indelible-mcp 4.3.0 → 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/LICENSE +57 -0
- package/package.json +4 -3
- package/src/index.js +2 -2
- package/src/lib/api-client.js +176 -8
- package/src/lib/save-log.js +93 -0
- package/src/lib/spv.js +100 -73
- package/src/lib/summary.js +75 -0
- package/src/lib/wallet-brc100.js +42 -0
- package/src/tools/diary_save.js +8 -2
- package/src/tools/save_file.js +10 -1
- package/src/tools/save_project.js +9 -2
- package/src/tools/save_session.js +18 -4
- package/src/tools/save_style.js +9 -2
- package/src/tools/setup_wallet.js +1 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Business Source License 1.1
|
|
2
|
+
|
|
3
|
+
Parameters
|
|
4
|
+
|
|
5
|
+
Licensor: Indelible Federation (zcooL)
|
|
6
|
+
Licensed Work: Indelible CLI / MCP
|
|
7
|
+
The Licensed Work is (c) 2026 Indelible Federation
|
|
8
|
+
Additional Use Grant: You may make use of the Licensed Work for personal,
|
|
9
|
+
non-commercial, educational, and evaluation purposes.
|
|
10
|
+
You may NOT use the Licensed Work to offer a competing
|
|
11
|
+
commercial blockchain storage, session saving, or
|
|
12
|
+
encrypted vault service without written permission
|
|
13
|
+
from the Licensor.
|
|
14
|
+
Change Date: April 30, 2030
|
|
15
|
+
Change License: MIT License
|
|
16
|
+
|
|
17
|
+
Terms
|
|
18
|
+
|
|
19
|
+
The Licensor hereby grants you the right to copy, modify, create derivative
|
|
20
|
+
works, redistribute, and make non-production use of the Licensed Work. The
|
|
21
|
+
Licensor may make an Additional Use Grant, above, permitting limited
|
|
22
|
+
production use.
|
|
23
|
+
|
|
24
|
+
Effective on the Change Date, or the fourth anniversary of the first publicly
|
|
25
|
+
available distribution of a specific version of the Licensed Work under this
|
|
26
|
+
License, whichever comes first, the Licensor hereby grants you rights under
|
|
27
|
+
the terms of the Change License, and the rights granted in the paragraph
|
|
28
|
+
above terminate.
|
|
29
|
+
|
|
30
|
+
If your use of the Licensed Work does not comply with the requirements
|
|
31
|
+
currently in effect as described in this License, you must purchase a
|
|
32
|
+
commercial license from the Licensor, its affiliated entities, or authorized
|
|
33
|
+
resellers, or you must refrain from using the Licensed Work.
|
|
34
|
+
|
|
35
|
+
All copies of the original and modified Licensed Work, and derivative works
|
|
36
|
+
of the Licensed Work, are subject to this License. This License applies
|
|
37
|
+
separately for each version of the Licensed Work and the Change Date may
|
|
38
|
+
vary for each version of the Licensed Work released by Licensor.
|
|
39
|
+
|
|
40
|
+
You must conspicuously display this License on each original or modified copy
|
|
41
|
+
of the Licensed Work. If you receive the Licensed Work in original or
|
|
42
|
+
modified form from a third party, the terms and conditions set forth in this
|
|
43
|
+
License apply to your use of that work.
|
|
44
|
+
|
|
45
|
+
Any use of the Licensed Work in violation of this License will automatically
|
|
46
|
+
terminate your rights under this License for the current and all other
|
|
47
|
+
versions of the Licensed Work.
|
|
48
|
+
|
|
49
|
+
This License does not grant you any right in any trademark or logo of
|
|
50
|
+
Licensor or its affiliates (provided that you may use a trademark or logo of
|
|
51
|
+
Licensor as expressly required by this License).
|
|
52
|
+
|
|
53
|
+
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
|
54
|
+
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
|
55
|
+
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
|
56
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
|
57
|
+
TITLE.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "indelible-mcp",
|
|
3
|
-
"version": "4.
|
|
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"
|
|
@@ -26,14 +27,14 @@
|
|
|
26
27
|
"code-vault",
|
|
27
28
|
"encrypted-storage"
|
|
28
29
|
],
|
|
29
|
-
"license": "
|
|
30
|
+
"license": "BSL-1.1",
|
|
30
31
|
"repository": {
|
|
31
32
|
"type": "git",
|
|
32
33
|
"url": "git+https://github.com/indelibleai/indelible-mcp.git"
|
|
33
34
|
},
|
|
34
35
|
"homepage": "https://indelible.one",
|
|
35
36
|
"dependencies": {
|
|
36
|
-
"@bsv/sdk": "1.
|
|
37
|
+
"@bsv/sdk": "^2.1.0",
|
|
37
38
|
"cross-keychain": "^1.1.0"
|
|
38
39
|
}
|
|
39
40
|
}
|
package/src/index.js
CHANGED
|
@@ -272,7 +272,7 @@ Commands:
|
|
|
272
272
|
|
|
273
273
|
function printHelp() {
|
|
274
274
|
console.log(`
|
|
275
|
-
Indelible MCP — Blockchain memory for Claude Code (v4.3.
|
|
275
|
+
Indelible MCP — Blockchain memory for Claude Code (v4.3.1)
|
|
276
276
|
|
|
277
277
|
Setup:
|
|
278
278
|
indelible-mcp setup --wif=KEY --pin=PIN Import and encrypt your private key
|
|
@@ -493,7 +493,7 @@ function readStdin() {
|
|
|
493
493
|
|
|
494
494
|
const SERVER_INFO = {
|
|
495
495
|
name: 'indelible',
|
|
496
|
-
version: '4.3.
|
|
496
|
+
version: '4.3.1',
|
|
497
497
|
description: 'Blockchain-backed memory and code storage for Claude Code'
|
|
498
498
|
}
|
|
499
499
|
|
package/src/lib/api-client.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -237,7 +405,7 @@ export async function checkProTier(address) {
|
|
|
237
405
|
})
|
|
238
406
|
if (res.ok) {
|
|
239
407
|
const data = await res.json()
|
|
240
|
-
if (data.plan === 'admin' || data.plan === 'pro' || data.plan === 'developer') {
|
|
408
|
+
if (data.active !== false && (data.plan === 'admin' || data.plan === 'pro' || data.plan === 'developer')) {
|
|
241
409
|
return { ok: true, plan: data.plan }
|
|
242
410
|
}
|
|
243
411
|
return { ok: false, plan: data.plan || 'free', error: 'Pro tier required for Diary AI companion. Upgrade at indelible.one/pricing' }
|
|
@@ -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
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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 —
|
|
291
|
+
* Broadcast a raw transaction — SOVEREIGN-FIRST, via the federation bridge ONLY.
|
|
295
292
|
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
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
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
442
|
-
|
|
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:
|
|
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
|
-
|
|
496
|
-
|
|
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:
|
|
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
|
|
518
|
-
*
|
|
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
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/tools/diary_save.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/tools/save_file.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}` }
|
package/src/tools/save_style.js
CHANGED
|
@@ -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
|
-
|
|
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: [
|
|
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
|
}
|