keychat-save 1.3.0 → 1.4.1

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/README.md CHANGED
@@ -44,15 +44,60 @@ keychat-save load --txid <txid>
44
44
  keychat-save load --search "keyword"
45
45
  ```
46
46
 
47
+ ## RepoChain — whole git repositories
48
+
49
+ `save` handles text. **RepoChain** puts an entire repository on chain: git history, working tree,
50
+ untracked and gitignored files, and `node_modules`. Restoring needs nothing but your key — no
51
+ GitHub, no npm registry, no cloud account.
52
+
53
+ ```bash
54
+ # Full snapshot — git history + working tree + node_modules, in ONE transaction
55
+ keychat-save repo backup ~/my-project
56
+
57
+ # Routine save: only what changed since the last snapshot. Cheap.
58
+ keychat-save repo delta ~/my-project
59
+
60
+ # Rebuild it anywhere — replays the full chain and verifies every file
61
+ keychat-save repo restore <txid> ./restored
62
+
63
+ # Size and cost, broadcasts nothing
64
+ keychat-save repo estimate ~/my-project
65
+
66
+ keychat-save repo list
67
+ ```
68
+
69
+ **Take a delta after every commit.** A full snapshot of a 34 MB repo costs ~7,300 Compute; a delta
70
+ of the same repo after a few commits is ~20 Compute and one transaction. Reach for `backup` only
71
+ when there is no snapshot yet, the chain has grown long, or `node_modules` churned.
72
+
73
+ Every delta embeds the ordered txids of all its ancestors, so **one txid restores everything** —
74
+ `restore` replays the root and each delta in order, then verifies the rebuilt tree against a hash
75
+ of its full file manifest.
76
+
77
+ Using an AI agent with terminal access? Just tell it **"save git repo to repochain"** and it runs
78
+ the delta for you.
79
+
80
+ ### Why a whole tarball, not `git bundle`
81
+
82
+ A bundle carries only committed history. It misses uncommitted work, gitignored files, the stash,
83
+ and `node_modules` — which means a restore depends on the npm registry still serving every package
84
+ at the same version. Packages get unpublished. RepoChain carries the lot.
85
+
47
86
  ## How it works
48
87
 
49
- Content is sealed with your identity key (ECIES) and broadcast as a transaction on BSV via [KeyChat](https://keychat.pro). Only your key can decrypt it. Saves are permanent and portable — load them on any device with your identity key.
88
+ Content is sealed with your identity key (ECIES) and broadcast as a transaction on BSV via
89
+ [KeyChat](https://keychat.pro). Only your key can decrypt it. Saves are permanent and portable —
90
+ load them on any device with your identity key.
50
91
 
51
- Cost: 0.25 Compute (125 sats) minimum, scales with content size.
92
+ Cost: 0.25 Compute (125 sats) minimum, scales with size. Miners charge 100 satoshis per KB, and the
93
+ tool refuses to broadcast anything below that floor — a transaction under it is relayed and
94
+ accepted, then never mined, and disappears hours later with no error.
52
95
 
53
96
  ## Web UI
54
97
 
55
- You can also save and load from the browser at [keychat.pro](https://keychat.pro) — click **KeyChain** in the sidebar.
98
+ You can also browse this from the browser at [keychat.pro](https://keychat.pro): **KeyChain** in the
99
+ sidebar for saves, **RepoChain** for repositories — including a one-click download that replays the
100
+ whole chain into a `.tar.gz`.
56
101
 
57
102
  ## License
58
103
 
@@ -0,0 +1,20 @@
1
+ // Pre-publish guard: refuse to ship a hand-edited repo.mjs.
2
+ //
3
+ // The canonical source is sovereign-messenger/scripts/keychat-repo.mjs, and
4
+ // this file is generated from it by that repo's scripts/sync-npm-package.mjs.
5
+ // A stale copy here means publishing code we have already fixed upstream —
6
+ // which is exactly how a known bug reaches users of `npx keychat-save`.
7
+ import { readFileSync } from 'node:fs'
8
+
9
+ const marker = 'GENERATED by sovereign-messenger'
10
+ if (!readFileSync(new URL('./repo.mjs', import.meta.url), 'utf8').includes(marker)) {
11
+ console.error('repo.mjs is not the generated copy.')
12
+ console.error('Run `npm run sync:npm` in sovereign-messenger, then publish again.')
13
+ process.exit(1)
14
+ }
15
+
16
+ if (!readFileSync(new URL('./index.mjs', import.meta.url), 'utf8').includes('100 sats/KB mining floor')) {
17
+ console.error('index.mjs is missing the 100 sats/KB floor guard.')
18
+ console.error('Without it an underpaid save is accepted, never mined, and lost hours later.')
19
+ process.exit(1)
20
+ }
package/index.mjs CHANGED
@@ -70,10 +70,36 @@ async function getUnspent (address) {
70
70
  height: u.height
71
71
  }))
72
72
  }
73
+ // ONE endpoint, TWO response contracts — normalise here so no caller has to know.
74
+ //
75
+ // The bridge answers in its own shape when our node can serve the tx:
76
+ // { outputs: [ { satoshis, script, index } ] }
77
+ // When it CANNOT — the node is pruned with no -txindex, so getrawtransaction fails for
78
+ // any confirmed tx in a discarded block — the web proxy falls back to WhatsOnChain and
79
+ // returns WoC's body untouched:
80
+ // { vout: [ { value, n, scriptPubKey: { hex } } ] }
81
+ //
82
+ // Callers looked only at `tx.outputs`, so on the WoC shape they iterated an empty array
83
+ // and reported "No OP_RETURN in tx." — a claim about the transaction, when the data was
84
+ // right there under a different field name. Measured 2026-08-26: 37 of 66 real saves
85
+ // unreadable. The share only grows, because a save falls to the WoC path once its block
86
+ // is pruned OR once a later save spends its change.
87
+ //
88
+ // Diagnosed 2026-08-24 (pt2) and hot-patched in the npx cache; the patch was never
89
+ // upstreamed, so publishing 1.4.0 replaced the cache with a clean copy and the bug came
90
+ // back. Fix it HERE, in the shared fetch, not at the call sites.
73
91
  async function getTx (txid) {
74
92
  const res = await fetch(`${BRIDGE}/api/tx/${txid}`)
75
93
  if (!res.ok) throw new Error(`tx ${res.status}`)
76
- return res.json()
94
+ const tx = await res.json()
95
+ if (!Array.isArray(tx.outputs) && Array.isArray(tx.vout)) {
96
+ tx.outputs = tx.vout.map(o => ({
97
+ satoshis: Math.round((o.value || 0) * 100_000_000),
98
+ script: o.scriptPubKey?.hex || '',
99
+ index: o.n
100
+ }))
101
+ }
102
+ return tx
77
103
  }
78
104
  async function getRawHex (txid) {
79
105
  const res = await fetch(`${BRIDGE}/api/tx/${txid}/hex`)
@@ -179,19 +205,31 @@ async function cmdSave (label, body) {
179
205
  const usable = utxos.filter(u => u.satoshis >= 125 && u.satoshis <= 500).sort((a, b) => b.satoshis - a.satoshis)
180
206
  if (!usable.length) throw new Error('No Compute UTXOs. Fund your address at keychat.pro and wait for packaging.')
181
207
 
182
- const estBytes = 150 + cipherHex.length / 2 + 50
183
- const rawFee = Math.ceil(estBytes * 100 / 1000)
184
- const totalCost = Math.ceil(rawFee / SUB_UNIT_SATS) * SUB_UNIT_SATS
185
- console.log(` Cost: ${totalCost / 500} Compute (${totalCost} sats)`)
186
-
208
+ // EVERY INPUT MUST BE COUNTED. This was `150 + cipher + 50`, which ignored the
209
+ // ~148 bytes each P2PKH input adds — so adding inputs to cover the fee grew the
210
+ // tx without growing the estimate, and the round-up to 125 sats hid it until a
211
+ // body got big enough that it couldn't. A save that lands under 100 sats/KB is
212
+ // relayed, accepted, given a txid — and then NEVER MINED. It ages out of every
213
+ // mempool hours later and the content is gone. Observed on a 20,188-byte save
214
+ // that paid 2,000 sats: 99.07 sats/KB, nineteen satoshis short, lost.
215
+ const INPUT_BYTES = 148 // 36 outpoint + 1 len + ~107 script + 4 sequence
216
+ const OUTPUT_BYTES = 34 // change output, counted even when it lands at 0
217
+ const OP_RETURN_BYTES = cipherHex.length / 2 + 60
187
218
  const picked = []
188
219
  let sum = 0
189
- for (const u of usable) {
220
+ let totalCost = 0
221
+ for (;;) {
222
+ const estBytes = 10 + picked.length * INPUT_BYTES + OP_RETURN_BYTES + OUTPUT_BYTES
223
+ totalCost = Math.ceil(Math.ceil(estBytes * 100 / 1000) / SUB_UNIT_SATS) * SUB_UNIT_SATS
224
+ if (picked.length > 0 && sum >= totalCost) break
225
+ if (picked.length >= usable.length) {
226
+ throw new Error(`Insufficient Compute. Need ${totalCost / 500}, have ${sum / 500}.`)
227
+ }
228
+ const u = usable[picked.length]
190
229
  picked.push(u)
191
230
  sum += u.satoshis
192
- if (sum >= totalCost) break
193
231
  }
194
- if (sum < totalCost) throw new Error(`Insufficient Compute. Need ${totalCost / 500}, have ${sum / 500}.`)
232
+ console.log(` Cost: ${totalCost / 500} Compute (${totalCost} sats, ${picked.length} inputs)`)
195
233
 
196
234
  const tx = new Transaction()
197
235
  for (const u of picked) {
@@ -208,7 +246,20 @@ async function cmdSave (label, body) {
208
246
  }
209
247
  await tx.sign()
210
248
 
211
- const txid = await broadcast(tx.toHex())
249
+ // Measure the SIGNED tx, not the estimate. Below 100 sats/KB the save looks
250
+ // successful and disappears hours later, so refusing here is always kinder
251
+ // than that silence.
252
+ const rawHex = tx.toHex()
253
+ const rate = (sum - changeSats) / (rawHex.length / 2 / 1000)
254
+ if (rate < 100) {
255
+ throw new Error(
256
+ `Fee too low: ${rate.toFixed(2)} sats/KB, under the 100 sats/KB mining floor. ` +
257
+ `NOT broadcast — a tx this cheap is accepted to mempool but never mined. Re-run.`
258
+ )
259
+ }
260
+ console.log(` Fee: ${sum - changeSats} sats / ${rawHex.length / 2} bytes = ${rate.toFixed(2)} sats/KB`)
261
+
262
+ const txid = await broadcast(rawHex)
212
263
 
213
264
  // Prepend new entry to local saves index
214
265
  const date = new Date().toISOString().slice(0, 10)
@@ -394,10 +445,14 @@ if (cmd === 'init') {
394
445
  if (args.includes('--search')) opts.search = args[args.indexOf('--search') + 1]
395
446
  await cmdLoad(opts)
396
447
  }
448
+ } else if (cmd === 'repo' || cmd === 'repochain') {
449
+ // RepoChain — whole git repositories on chain, not just session text.
450
+ const { runRepo } = await import('./repo.mjs')
451
+ await runRepo(args)
397
452
  } else {
398
453
  console.log(`keychat-save — Save to BSV blockchain via KeyChat (keychat.pro)
399
454
 
400
- Commands:
455
+ SAVES — text, sessions, notes:
401
456
  init Create identity (~/.keychat/identity.wif)
402
457
  save "label" "content" Save content to chain
403
458
  save "label" --file path Save file to chain
@@ -407,5 +462,15 @@ Commands:
407
462
  load --search "keyword" Search saves
408
463
  load --txid <txid> Load a specific save by txid (instant, no scan)
409
464
 
465
+ REPOCHAIN — whole git repositories:
466
+ repo backup <path> Full snapshot: git history, working tree, node_modules
467
+ repo delta <path> Only what changed since the last snapshot (cheap, routine)
468
+ repo restore <txid> <dest> Rebuild the repo — replays the whole chain
469
+ repo estimate <path> Size and cost, broadcasts nothing
470
+ repo list Snapshots taken from this machine
471
+
472
+ Each snapshot is ONE transaction. A delta continues the chain automatically and
473
+ carries the ordered txids of every ancestor, so a single txid restores everything.
474
+
410
475
  Your data. Your key. Permanent on BSV.`)
411
476
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "keychat-save",
3
- "version": "1.3.0",
4
- "description": "Save and load files to BSV blockchain via KeyChat",
3
+ "version": "1.4.1",
4
+ "description": "Save sessions, files and whole git repositories to the BSV blockchain via KeyChat",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "keychat-save": "index.mjs"
@@ -12,11 +12,24 @@
12
12
  "dependencies": {
13
13
  "@bsv/sdk": "^1.0.0"
14
14
  },
15
- "keywords": ["bsv", "blockchain", "keychat", "save", "encrypt", "bitcoin"],
15
+ "keywords": [
16
+ "bsv",
17
+ "blockchain",
18
+ "keychat",
19
+ "save",
20
+ "encrypt",
21
+ "bitcoin",
22
+ "git",
23
+ "backup",
24
+ "repochain"
25
+ ],
16
26
  "license": "MIT",
17
27
  "repository": {
18
28
  "type": "git",
19
29
  "url": "https://github.com/avaziri93/keychat-save"
20
30
  },
21
- "homepage": "https://keychat.pro"
31
+ "homepage": "https://keychat.pro",
32
+ "scripts": {
33
+ "prepublishOnly": "node check-generated.mjs"
34
+ }
22
35
  }
package/repo.mjs ADDED
@@ -0,0 +1,680 @@
1
+ // WHOLE-REPO SNAPSHOTS TO CHAIN — history, working tree and node_modules, in ONE transaction.
2
+ //
3
+ // node scripts/keychat-repo.mjs delta ~/dxs-bot # DEFAULT — continues the chain
4
+ // node scripts/keychat-repo.mjs backup ~/dxs-bot # full snapshot, milestones only
5
+ // node scripts/keychat-repo.mjs estimate ~/dxs-bot # sizes and cost, broadcasts nothing
6
+ // node scripts/keychat-repo.mjs restore <txid> /tmp/restored
7
+ // node scripts/keychat-repo.mjs list
8
+ //
9
+ // DELTA IS THE ROUTINE SAVE. `delta` with no base txid continues the repo's chain from its
10
+ // newest entry in ~/.keychat/repos.json. Full snapshot of sovereign-messenger is 33.87 MB and
11
+ // 7,320 Compute; a delta of the same repo is ~96 KB and ~20 Compute in one transaction. Reach
12
+ // for `backup` at milestones, or when a delta chain gets long or node_modules churns.
13
+ //
14
+ // WHY A TARBALL OF THE WHOLE DIRECTORY AND NOT `git bundle`. A bundle carries only committed
15
+ // history. It misses (a) anything uncommitted, and these repos deploy straight from the working
16
+ // tree, (b) gitignored files — `whitepaper.md` was invisible to git for four months, and (c) the
17
+ // stash, which `--all` does not include. Tarring the directory captures .git, the worktree,
18
+ // node_modules and the stash in one artifact.
19
+ //
20
+ // WHY node_modules IS INCLUDED. Arash's call, and it follows the sovereign-data-path rule:
21
+ // needing `npm install` to rebuild puts the npm registry in the recovery path. Packages get
22
+ // unpublished. Carrying them costs ~$2 a snapshot, which is not a reason to depend on a third
23
+ // party.
24
+ //
25
+ // ONE TRANSACTION, NO CHUNKING. Genesis restored the protocol limits; there is no size cap to
26
+ // design around. GorillaPool and TAAL both publish maxtxsizepolicy=100,000,000, and the bridge
27
+ // fans every broadcast out to both in parallel, so a 34 MB repo goes to a miner whole. Our own
28
+ // bitcoind still runs maxscriptsizepolicy=6000000 — a leftover sized for 5 MB attachment chunks —
29
+ // so it will not relay these itself; ARC carries them and the node picks them up when mined.
30
+ // Raising the node to match is a config edit plus a restart, deliberately not done here because
31
+ // a bitcoind restart costs hours of message-relay lag.
32
+ //
33
+ // NO base64. keychat-save's encrypt path is UTF-8 only, which forces binary through base64 at
34
+ // +33%. Here the tar bytes are encrypted directly, so the chain carries the archive itself.
35
+ //
36
+ // A REPO TX IS NOT A SESSION SAVE. It uses its own KREP prefix, so `keychat-save load` skips it:
37
+ // otherwise every load would decrypt 34 MB into ~/.keychat/saves.json and every future save would
38
+ // carry the snapshot in its bootstrap table.
39
+ //
40
+ // FUNDING IS A SEPARATE TX ON PURPOSE. The fee for a 34 MB tx is ~3.5M sats, which is ~7,100
41
+ // Compute UTXOs at 500 sats each. Signing is O(inputs x output bytes) in the SDK — it rehashes
42
+ // the whole output set per input — so paying that fee directly costs ~55 minutes of signing.
43
+ // Consolidating into one exact-value output first and spending that makes the repo tx a
44
+ // single-input tx: two broadcasts, ~30 seconds. The payload is never split.
45
+ import { PrivateKey, Transaction, P2PKH, Script, EncryptedMessage, PublicKey, Utils } from '@bsv/sdk'
46
+ import { execFileSync } from 'node:child_process'
47
+ import { createHash } from 'node:crypto'
48
+ import fs from 'node:fs'
49
+ import os from 'node:os'
50
+ import path from 'node:path'
51
+
52
+ const CONFIG_DIR = path.join(os.homedir(), '.keychat')
53
+ const WIF_PATH = path.join(CONFIG_DIR, 'identity.wif')
54
+ const BRIDGE = 'https://www.keychat.pro/api/bridge'
55
+ const REPO_PREFIX = '4b524550' // "KREP" — deliberately not KSAV
56
+ const VERSION = '01'
57
+ const SATS_PER_COMPUTE = 500
58
+ const SUB_UNIT = SATS_PER_COMPUTE / 4 // 125 sats = 0.25 Compute
59
+ const FEE_PER_KB = 100
60
+ const TMP = process.env.TMPDIR || '/tmp'
61
+
62
+ const sh = (cmd, args, opts = {}) =>
63
+ execFileSync(cmd, args, { encoding: 'utf8', maxBuffer: 1 << 30, ...opts })
64
+
65
+ const hex = bytes => Buffer.from(bytes).toString('hex')
66
+ const feeFor = bytes => Math.ceil((bytes * FEE_PER_KB) / 1000)
67
+ const roundToSubUnit = sats => Math.ceil(sats / SUB_UNIT) * SUB_UNIT
68
+
69
+ function key () {
70
+ if (!fs.existsSync(WIF_PATH)) {
71
+ console.error(`no identity at ${WIF_PATH}`)
72
+ process.exit(1)
73
+ }
74
+ return PrivateKey.fromWif(fs.readFileSync(WIF_PATH, 'utf8').trim())
75
+ }
76
+
77
+ // ── bridge ───────────────────────────────────────────────────────
78
+ async function getUnspent (address) {
79
+ const res = await fetch(`${BRIDGE}/api/address/${address}/unspent`)
80
+ if (!res.ok) throw new Error(`unspent ${res.status}`)
81
+ return (await res.json()).map(u => ({
82
+ txid: u.tx_hash || u.txid,
83
+ vout: u.tx_pos ?? u.vout,
84
+ satoshis: u.value ?? u.satoshis
85
+ }))
86
+ }
87
+
88
+ async function broadcast (rawHex) {
89
+ const res = await fetch(`${BRIDGE}/api/broadcast`, {
90
+ method: 'POST',
91
+ headers: { 'Content-Type': 'application/json' },
92
+ body: JSON.stringify({ rawTx: rawHex })
93
+ })
94
+ const text = await res.text()
95
+ if (!res.ok) throw new Error(`broadcast ${res.status}: ${text.slice(0, 300)}`)
96
+ try {
97
+ const p = JSON.parse(text)
98
+ return p.txid || p.hash || text
99
+ } catch { return text.replace(/"/g, '').trim() }
100
+ }
101
+
102
+ // `?raw=1` first: server.js caches /api/tx/:txid/hex FOREVER in memory, and pinning
103
+ // 68 MB of hex in the live web server to satisfy a restore is not worth it. A query
104
+ // string makes its isTxBody regex miss, so the request passes straight through.
105
+ //
106
+ // But that same regex miss also skips its WhatsOnChain fallback, which is the only
107
+ // second source we have once a tx is mined. So fall back to the plain path on
108
+ // failure — retrievability beats keeping the proxy cache tidy.
109
+ async function getRawHex (txid) {
110
+ // Three sources, in order of sovereignty:
111
+ // 1. bridge ?raw=1 — our node, bypassing the web proxy's forever-cache.
112
+ // 2. bridge plain — same node, but with the proxy's WoC fallback behind it.
113
+ // 3. WoC direct — last resort, and it earns its place: our node is PRUNED
114
+ // with no -txindex, so getrawtransaction CANNOT return a confirmed tx at
115
+ // all. Every mined snapshot is therefore unfetchable from us, and with
116
+ // only the first two sources a rate-limited proxy made the whole delta
117
+ // chain unreplayable — which breaks restore, not just new deltas.
118
+ // 429 gets a backoff rather than being treated as absence; hammering a closed
119
+ // limiter is what turned a transient failure into a hard one.
120
+ const urls = [
121
+ `${BRIDGE}/api/tx/${txid}/hex?raw=1`,
122
+ `${BRIDGE}/api/tx/${txid}/hex`,
123
+ `https://api.whatsonchain.com/v1/bsv/main/tx/${txid}/hex`
124
+ ]
125
+ let wait = 500
126
+ for (let attempt = 0; attempt < 3; attempt++) {
127
+ for (const url of urls) {
128
+ let res
129
+ try { res = await fetch(url) } catch { continue }
130
+ if (res.status === 429) { await new Promise(r => setTimeout(r, wait)); continue }
131
+ if (!res.ok) continue
132
+ const ct = res.headers.get('content-type') || ''
133
+ const data = ct.includes('json') ? await res.json() : await res.text()
134
+ const h = (typeof data === 'string' ? data.replace(/"/g, '').trim() : data.hex)
135
+ if (h && /^[0-9a-f]+$/i.test(h)) return h
136
+ }
137
+ wait = Math.min(wait * 4, 8000)
138
+ await new Promise(r => setTimeout(r, wait))
139
+ }
140
+ throw new Error(`cannot fetch raw tx ${txid} from node, bridge or WoC`)
141
+ }
142
+
143
+ // ── OP_RETURN ────────────────────────────────────────────────────
144
+ function buildOpReturn (parts) {
145
+ let s = '006a'
146
+ for (const part of parts) {
147
+ const len = part.length / 2
148
+ if (len < 76) s += len.toString(16).padStart(2, '0')
149
+ else if (len < 256) s += '4c' + len.toString(16).padStart(2, '0')
150
+ else if (len < 65536) {
151
+ s += '4d' + (len & 0xff).toString(16).padStart(2, '0') +
152
+ ((len >> 8) & 0xff).toString(16).padStart(2, '0')
153
+ } else {
154
+ s += '4e' +
155
+ (len & 0xff).toString(16).padStart(2, '0') +
156
+ ((len >> 8) & 0xff).toString(16).padStart(2, '0') +
157
+ ((len >> 16) & 0xff).toString(16).padStart(2, '0') +
158
+ ((len >>> 24) & 0xff).toString(16).padStart(2, '0')
159
+ }
160
+ s += part
161
+ }
162
+ return s
163
+ }
164
+
165
+ function extractParts (scriptHex) {
166
+ let pos = scriptHex.startsWith('006a') ? 4 : scriptHex.startsWith('6a') ? 2 : -1
167
+ if (pos < 0) return null
168
+ const parts = []
169
+ while (pos < scriptHex.length) {
170
+ const op = parseInt(scriptHex.substr(pos, 2), 16)
171
+ pos += 2
172
+ let len
173
+ if (op <= 75) len = op
174
+ else if (op === 0x4c) { len = parseInt(scriptHex.substr(pos, 2), 16); pos += 2 } else if (op === 0x4d) {
175
+ len = parseInt(scriptHex.substr(pos, 2), 16) |
176
+ (parseInt(scriptHex.substr(pos + 2, 2), 16) << 8)
177
+ pos += 4
178
+ } else if (op === 0x4e) {
179
+ len = parseInt(scriptHex.substr(pos, 2), 16) |
180
+ (parseInt(scriptHex.substr(pos + 2, 2), 16) << 8) |
181
+ (parseInt(scriptHex.substr(pos + 4, 2), 16) << 16) |
182
+ (parseInt(scriptHex.substr(pos + 6, 2), 16) << 24)
183
+ pos += 8
184
+ } else break
185
+ parts.push(scriptHex.substr(pos, len * 2))
186
+ pos += len * 2
187
+ }
188
+ return parts
189
+ }
190
+
191
+ // ── payload ──────────────────────────────────────────────────────
192
+ function tarball (repoPath) {
193
+ const abs = path.resolve(repoPath.replace(/^~/, os.homedir()))
194
+ if (!fs.existsSync(abs)) throw new Error(`no such repo: ${abs}`)
195
+ const name = path.basename(abs)
196
+ const out = path.join(TMP, `keychat-repo-${name}.tgz`)
197
+ // -C so the archive holds `<name>/...` and restores into a directory, never loose files.
198
+ // COPYFILE_DISABLE keeps macOS from larding the archive with ._ AppleDouble entries.
199
+ sh('tar', ['czf', out, '-C', path.dirname(abs), name], {
200
+ env: { ...process.env, COPYFILE_DISABLE: '1' }
201
+ })
202
+ return { out, name, abs }
203
+ }
204
+
205
+ /** Refuse to inscribe anything secret-shaped — a repo tx is permanent and public. */
206
+ function assertNoSecrets (abs) {
207
+ const bad = sh('find', [abs, '-maxdepth', '4', '-type', 'f',
208
+ '(', '-name', '*.wif', '-o', '-name', '*.pem', '-o', '-name', '.env',
209
+ '-o', '-name', 'seed.txt', '-o', '-name', '*.p12', '-o', '-name', '.platform-wallet.key',
210
+ '-o', '-name', '.testwallet.key', '-o', '-name', '.recipient.key', ')',
211
+ '-not', '-path', '*/node_modules/*'])
212
+ .split('\n').filter(Boolean)
213
+ if (bad.length) {
214
+ console.error('REFUSING TO SNAPSHOT — secret-shaped files inside the repo:')
215
+ for (const f of bad) console.error(' ' + f)
216
+ console.error('Secrets live in ~/.dxs-bot and ~/.keychat. Move them out and retry.')
217
+ process.exit(1)
218
+ }
219
+ }
220
+
221
+ /** [4-byte BE meta length][meta JSON][tar bytes] — meta rides inside the encryption. */
222
+ function framePayload (meta, tar) {
223
+ const metaBuf = Buffer.from(JSON.stringify(meta), 'utf8')
224
+ const len = Buffer.alloc(4)
225
+ len.writeUInt32BE(metaBuf.length, 0)
226
+ return Buffer.concat([len, metaBuf, tar])
227
+ }
228
+
229
+ function unframePayload (buf) {
230
+ const metaLen = buf.readUInt32BE(0)
231
+ const meta = JSON.parse(buf.subarray(4, 4 + metaLen).toString('utf8'))
232
+ return { meta, tar: buf.subarray(4 + metaLen) }
233
+ }
234
+
235
+ // ── manifests ────────────────────────────────────────────────────
236
+ // A manifest is `mode sha256 path` per file, sorted by path. It is NEVER stored
237
+ // on chain: sovereign-messenger has 7,934 files, so a full manifest is ~1.01 MB —
238
+ // recording a 3.6 KB change would cost ~200 Compute in overhead alone. Only the
239
+ // sha256 OF the manifest travels (manifestRoot, 32 bytes), and restore recomputes
240
+ // the manifest from the tree it rebuilt to check it. That verifies every file,
241
+ // not just the archive, and catches a delta applied out of order.
242
+ function buildManifest (dir) {
243
+ const entries = []
244
+ const walk = rel => {
245
+ const abs = path.join(dir, rel)
246
+ for (const name of fs.readdirSync(abs)) {
247
+ const r = rel ? path.join(rel, name) : name
248
+ const st = fs.lstatSync(path.join(abs, name))
249
+ if (st.isDirectory()) walk(r)
250
+ else if (st.isSymbolicLink()) entries.push([r, 'l', createHash('sha256').update(fs.readlinkSync(path.join(abs, name))).digest('hex')])
251
+ else if (st.isFile()) {
252
+ entries.push([r, (st.mode & 0o111) ? 'x' : 'f',
253
+ createHash('sha256').update(fs.readFileSync(path.join(abs, name))).digest('hex')])
254
+ }
255
+ }
256
+ }
257
+ walk('')
258
+ entries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
259
+ const map = new Map(entries.map(([p, m, s]) => [p, m + ' ' + s]))
260
+ const root = createHash('sha256')
261
+ .update(entries.map(([p, m, s]) => `${m} ${s} ${p}`).join('\n')).digest('hex')
262
+ return { map, root }
263
+ }
264
+
265
+ function diffManifest (base, cur) {
266
+ const changed = []
267
+ const deleted = []
268
+ for (const [p, v] of cur) if (base.get(p) !== v) changed.push(p)
269
+ for (const p of base.keys()) if (!cur.has(p)) deleted.push(p)
270
+ return { changed: changed.sort(), deleted: deleted.sort() }
271
+ }
272
+
273
+ // ── tx building ──────────────────────────────────────────────────
274
+ // Inputs carry sourceTXID + explicit satoshis/lockingScript instead of a
275
+ // sourceTransaction. Fetching parents would mean one bridge round-trip per input,
276
+ // and 19,865 of this wallet's UTXOs share a single ~6.8 MB 200k-output splitter tx —
277
+ // re-downloading it thousands of times is what made the first version unrunnable.
278
+ function addP2PKHInput (tx, k, lockingScript, utxo) {
279
+ tx.addInput({
280
+ sourceTXID: utxo.txid,
281
+ sourceOutputIndex: utxo.vout,
282
+ unlockingScriptTemplate: new P2PKH().unlock(k, 'all', false, utxo.satoshis, lockingScript)
283
+ })
284
+ }
285
+
286
+ const INPUT_BYTES = 148 // 36 outpoint + 1 len + ~107 script + 4 sequence
287
+ const OUTPUT_P2PKH_BYTES = 34
288
+
289
+ /**
290
+ * Consolidate small Compute UTXOs into one output worth exactly `target` sats.
291
+ * Its own miner fee grows with each input added, so the pick converges rather
292
+ * than being solved in one shot.
293
+ */
294
+ function planFunding (utxos, target) {
295
+ let n = 0
296
+ let sum = 0
297
+ for (;;) {
298
+ const bytes = 10 + n * INPUT_BYTES + OUTPUT_P2PKH_BYTES * 2
299
+ const need = target + roundToSubUnit(feeFor(bytes))
300
+ if (sum >= need && n > 0) return { picked: utxos.slice(0, n), sum, need }
301
+ if (n >= utxos.length) {
302
+ throw new Error(`insufficient Compute: need ~${(need / SATS_PER_COMPUTE).toFixed(2)}, ` +
303
+ `have ${(sum / SATS_PER_COMPUTE).toFixed(2)}`)
304
+ }
305
+ sum += utxos[n].satoshis
306
+ n++
307
+ }
308
+ }
309
+
310
+ // Above this many inputs, signing the payload tx directly gets expensive — the SDK
311
+ // rehashes the whole output set per input — so the fee moves to a funding tx and the
312
+ // payload tx becomes single-input. Below it, one tx is simpler AND safer: the funding
313
+ // output is the only raceable step in the design (see the splitter incident), so a
314
+ // delta small enough to skip it has nothing to lose.
315
+ const DIRECT_MAX_INPUTS = 60
316
+
317
+ /** Broadcast one OP_RETURN payload, funding it whichever way is cheaper. */
318
+ async function publish (scriptHex, k, address, lockingScript, { dry = false } = {}) {
319
+ const opReturn = Script.fromHex(scriptHex)
320
+ const scriptBytes = scriptHex.length / 2
321
+ const utxos = (await getUnspent(address))
322
+ .filter(u => u.satoshis >= SUB_UNIT && u.satoshis <= SATS_PER_COMPUTE)
323
+ .sort((a, b) => b.satoshis - a.satoshis)
324
+
325
+ // Try single-tx first: n inputs, the OP_RETURN, and change.
326
+ let n = 0
327
+ let sum = 0
328
+ let fee = 0
329
+ while (n < DIRECT_MAX_INPUTS) {
330
+ fee = roundToSubUnit(feeFor(10 + n * INPUT_BYTES + 9 + scriptBytes + OUTPUT_P2PKH_BYTES))
331
+ if (sum >= fee && n > 0) break
332
+ if (n >= utxos.length) throw new Error('insufficient Compute')
333
+ sum += utxos[n].satoshis
334
+ n++
335
+ }
336
+
337
+ if (sum >= fee && n > 0 && n < DIRECT_MAX_INPUTS) {
338
+ const tx = new Transaction()
339
+ for (const u of utxos.slice(0, n)) addP2PKHInput(tx, k, lockingScript, u)
340
+ tx.addOutput({ lockingScript: opReturn, satoshis: 0 })
341
+ if (sum - fee > 0) tx.addOutput({ lockingScript, satoshis: sum - fee })
342
+ await tx.sign()
343
+ console.log(`payload tx ${(tx.toHex().length / 2 / 1024).toFixed(1)} KB, ${n} inputs, ` +
344
+ `fee ${fee} sats = ${fee / SATS_PER_COMPUTE} Compute (single tx)`)
345
+ if (dry) return { txid: tx.id('hex'), funding: null, cost: fee / SATS_PER_COMPUTE, dry: true }
346
+ process.stdout.write('broadcasting ... ')
347
+ const txid = await broadcast(tx.toHex())
348
+ console.log(txid)
349
+ return { txid, funding: null, cost: fee / SATS_PER_COMPUTE }
350
+ }
351
+
352
+ // Too many inputs to sign against this payload — consolidate the fee first.
353
+ //
354
+ // This output is exactly the payload's fee, on a clean 125-sat boundary. An earlier
355
+ // version made it fee+1 to push it off that boundary, on the theory that isComputeUtxo()
356
+ // (`satoshis % 125 === 0`) would hide it from the splitter. That was wrong: the splitter
357
+ // triggers on `satoshis >= 1000` and never consulted isComputeUtxo, so the extra satoshi
358
+ // bought nothing and it ate two repo snapshots anyway. What actually protects this output
359
+ // is the splitter being deposits-only (src/messages/split.js) — it reads the parent tx,
360
+ // sees we signed it, and leaves our change alone.
361
+ const payloadFee = roundToSubUnit(feeFor(10 + INPUT_BYTES + 9 + scriptBytes))
362
+ const fundingValue = payloadFee
363
+ const { picked, sum: fsum, need } = planFunding(utxos, fundingValue)
364
+ const fundFee = need - fundingValue
365
+ console.log(`payload tx ${((10 + INPUT_BYTES + 9 + scriptBytes) / 1048576).toFixed(2)} MB, ` +
366
+ `fee ${payloadFee} sats = ${payloadFee / SATS_PER_COMPUTE} Compute`)
367
+ console.log(`funding tx ${picked.length} inputs, fee ${fundFee} sats`)
368
+ console.log(`total cost ${(fundingValue + fundFee) / SATS_PER_COMPUTE} Compute\n`)
369
+
370
+ const fundTx = new Transaction()
371
+ for (const u of picked) addP2PKHInput(fundTx, k, lockingScript, u)
372
+ fundTx.addOutput({ lockingScript, satoshis: fundingValue })
373
+ if (fsum - fundingValue - fundFee > 0) {
374
+ fundTx.addOutput({ lockingScript, satoshis: fsum - fundingValue - fundFee })
375
+ }
376
+ process.stdout.write('signing funding tx ... ')
377
+ let t = Date.now()
378
+ await fundTx.sign()
379
+ console.log(`${((Date.now() - t) / 1000).toFixed(1)}s`)
380
+
381
+ const payTx = new Transaction()
382
+ addP2PKHInput(payTx, k, lockingScript, { txid: fundTx.id('hex'), vout: 0, satoshis: fundingValue })
383
+ payTx.addOutput({ lockingScript: opReturn, satoshis: 0 })
384
+ process.stdout.write('signing payload tx ... ')
385
+ t = Date.now()
386
+ await payTx.sign()
387
+ console.log(`${((Date.now() - t) / 1000).toFixed(1)}s`)
388
+
389
+ if (dry) {
390
+ return { txid: payTx.id('hex'), funding: fundTx.id('hex'), cost: (fundingValue + fundFee) / SATS_PER_COMPUTE, dry: true }
391
+ }
392
+ process.stdout.write('broadcasting funding tx ... ')
393
+ const ftx = await broadcast(fundTx.toHex())
394
+ console.log(ftx)
395
+ process.stdout.write('broadcasting payload tx ... ')
396
+ const ptx = await broadcast(payTx.toHex())
397
+ console.log(ptx)
398
+ return { txid: ptx, funding: ftx, cost: (fundingValue + fundFee) / SATS_PER_COMPUTE }
399
+ }
400
+
401
+ function recordSnapshot (rec) {
402
+ const idx = path.join(CONFIG_DIR, 'repos.json')
403
+ let all = []
404
+ try { all = JSON.parse(fs.readFileSync(idx, 'utf8')) } catch {}
405
+ all.push(rec)
406
+ fs.writeFileSync(idx, JSON.stringify(all, null, 2), 'utf8')
407
+ }
408
+
409
+ // ── commands ─────────────────────────────────────────────────────
410
+ function measure (repoPath) {
411
+ const { out, name, abs } = tarball(repoPath)
412
+ const tar = fs.readFileSync(out)
413
+ fs.unlinkSync(out)
414
+ // ECIES adds an ephemeral pubkey, IV, PKCS#7 padding and an HMAC.
415
+ const cipherBytes = tar.length + 200
416
+ const scriptBytes = cipherBytes + 60
417
+ const repoTxBytes = 10 + INPUT_BYTES + 9 + scriptBytes
418
+ const repoFee = roundToSubUnit(feeFor(repoTxBytes))
419
+ return { name, abs, tar, repoTxBytes, repoFee }
420
+ }
421
+
422
+ function estimate (repoPath) {
423
+ const m = measure(repoPath)
424
+ console.log(m.name)
425
+ console.log(` tar ${(m.tar.length / 1048576).toFixed(2)} MB`)
426
+ console.log(` repo tx ${(m.repoTxBytes / 1048576).toFixed(2)} MB (1 transaction, no chunking)`)
427
+ console.log(` fee ${m.repoFee} sats = ${m.repoFee / SATS_PER_COMPUTE} Compute`)
428
+ console.log(` miner policy 100 MB — headroom ${(100 - m.repoTxBytes / 1048576).toFixed(1)} MB`)
429
+ }
430
+
431
+ function gitState (abs) {
432
+ let head = 'nogit'
433
+ let dirty = 0
434
+ try { head = sh('git', ['-C', abs, 'rev-parse', 'HEAD']).trim().slice(0, 12) } catch {}
435
+ try { dirty = sh('git', ['-C', abs, 'status', '--short']).split('\n').filter(Boolean).length } catch {}
436
+ return { head, dirty }
437
+ }
438
+
439
+ function sealPayload (meta, tar, k, pubHex) {
440
+ process.stdout.write('encrypting ... ')
441
+ const t = Date.now()
442
+ const cipher = EncryptedMessage.encrypt(
443
+ Array.from(framePayload(meta, tar)), k, PublicKey.fromString(pubHex)
444
+ )
445
+ console.log(`${((Date.now() - t) / 1000).toFixed(1)}s`)
446
+ const timestamp = Math.floor(Date.now() / 1000).toString(16).padStart(8, '0')
447
+ return buildOpReturn([REPO_PREFIX, VERSION, pubHex, hex(cipher), timestamp])
448
+ }
449
+
450
+ /** Fetch one KREP tx and return its decrypted { meta, tar }. */
451
+ async function fetchPayload (txid, k, pubHex) {
452
+ const rawHex = await getRawHex(txid)
453
+ const tx = Transaction.fromHex(rawHex)
454
+ let parts = null
455
+ for (const o of tx.outputs) {
456
+ const s = o.lockingScript.toHex()
457
+ if (s.startsWith('006a') || s.startsWith('6a')) { parts = extractParts(s); break }
458
+ }
459
+ if (!parts || parts[0] !== REPO_PREFIX) throw new Error(`${txid} is not a KREP repo transaction`)
460
+ if (parts[2] !== pubHex) throw new Error(`${txid} is not encrypted to this identity`)
461
+ const plain = Buffer.from(EncryptedMessage.decrypt(Array.from(Buffer.from(parts[3], 'hex')), k))
462
+ return unframePayload(plain)
463
+ }
464
+
465
+ /**
466
+ * Every delta carries the ORDERED txid list of all its ancestors, oldest first —
467
+ * the same trick keychat-save's bootstrap table uses. One txid therefore yields
468
+ * the whole history in a single fetch. Walking back link-by-link was the
469
+ * alternative and it dead-ends the moment one link predates the format.
470
+ */
471
+ function chainOf (txid, meta) {
472
+ return (meta.kind === 'delta') ? [...meta.chain, txid] : [txid]
473
+ }
474
+
475
+ /** Untar a payload over `dest`, then remove whatever the delta deleted. */
476
+ function applyPayload (meta, tar, dest, repoName) {
477
+ const tmp = path.join(TMP, `apply-${meta.repo}-${Math.random().toString(36).slice(2)}.tgz`)
478
+ fs.writeFileSync(tmp, tar)
479
+ sh('tar', ['xzf', tmp, '-C', dest])
480
+ fs.unlinkSync(tmp)
481
+ for (const rel of (meta.deleted || [])) {
482
+ const p = path.join(dest, repoName, rel)
483
+ try { fs.rmSync(p, { force: true }) } catch {}
484
+ }
485
+ }
486
+
487
+ async function backup (repoPath, { dry = false } = {}) {
488
+ const k = key()
489
+ const address = k.toPublicKey().toAddress()
490
+ const pubHex = k.toPublicKey().toString()
491
+ const lockingScript = new P2PKH().lock(address)
492
+
493
+ const { out, name, abs } = tarball(repoPath)
494
+ assertNoSecrets(abs)
495
+ const tar = fs.readFileSync(out)
496
+ fs.unlinkSync(out)
497
+ const sha = createHash('sha256').update(tar).digest('hex')
498
+ const { root: manifestRoot } = buildManifest(abs)
499
+ const { head, dirty } = gitState(abs)
500
+
501
+ console.log(`${name} ${(tar.length / 1048576).toFixed(2)} MB tar HEAD ${head} ${dirty} uncommitted`)
502
+ console.log(`sha256 ${sha}`)
503
+ console.log(`manifest ${manifestRoot}`)
504
+
505
+ const meta = {
506
+ v: 2, kind: 'full', repo: name, head, dirty,
507
+ sha256: sha, manifestRoot, tarSize: tar.length, at: Math.floor(Date.now() / 1000)
508
+ }
509
+ const scriptHex = sealPayload(meta, tar, k, pubHex)
510
+ const res = await publish(scriptHex, k, address, lockingScript, { dry })
511
+ if (dry) {
512
+ console.log(`\n--dry: nothing broadcast\n payload ${res.txid}` +
513
+ (res.funding ? `\n funding ${res.funding}` : ''))
514
+ return
515
+ }
516
+ recordSnapshot({ txid: res.txid, funding: res.funding, ...meta, cost: res.cost })
517
+ console.log(`\nSNAPSHOT ${name} @ ${head} -> ${res.txid}`)
518
+ console.log(`restore with: node scripts/keychat-repo.mjs restore ${res.txid} <dest>`)
519
+ }
520
+
521
+ /**
522
+ * Record only what changed since <baseTxid>. Cost tracks the change, not the repo:
523
+ * a code-only edit to sovereign-messenger is single-digit KB against 33.87 MB for
524
+ * a full snapshot.
525
+ *
526
+ * The base tree is rebuilt from chain rather than trusted from disk — that is the
527
+ * whole point. Diffing against the local working tree would record a delta against
528
+ * whatever this Mac happens to hold, which is exactly the assumption a backup
529
+ * exists to avoid.
530
+ */
531
+ async function delta (repoPath, baseTxid, { dry = false } = {}) {
532
+ const k = key()
533
+ const address = k.toPublicKey().toAddress()
534
+ const pubHex = k.toPublicKey().toString()
535
+ const lockingScript = new P2PKH().lock(address)
536
+
537
+ const abs = path.resolve(repoPath.replace(/^~/, os.homedir()))
538
+ const name = path.basename(abs)
539
+ assertNoSecrets(abs)
540
+
541
+ // No base given: continue this repo's chain from its newest recorded snapshot.
542
+ // Routine saves are deltas, so needing to look a txid up by hand every time is
543
+ // friction on the path we want taken by default.
544
+ if (!baseTxid) {
545
+ let all = []
546
+ try { all = JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, 'repos.json'), 'utf8')) } catch {}
547
+ const mine = all.filter(r => r.repo === name).sort((a, b) => b.at - a.at)
548
+ if (!mine.length) {
549
+ throw new Error(`no snapshot of ${name} in ~/.keychat/repos.json — run \`backup\` first`)
550
+ }
551
+ baseTxid = mine[0].txid
552
+ console.log(`base (latest ${name}) ${baseTxid}`)
553
+ }
554
+
555
+ process.stdout.write(`base ${baseTxid.slice(0, 16)}… `)
556
+ const basePayload = await fetchPayload(baseTxid, k, pubHex)
557
+ if (basePayload.meta.repo !== name) {
558
+ throw new Error(`base is a snapshot of ${basePayload.meta.repo}, not ${name}`)
559
+ }
560
+ const chain = chainOf(baseTxid, basePayload.meta)
561
+ console.log(`${basePayload.meta.repo} @ ${basePayload.meta.head}, chain of ${chain.length}`)
562
+
563
+ // Materialise the base tree by replaying its whole chain.
564
+ const work = fs.mkdtempSync(path.join(TMP, 'keychat-base-'))
565
+ for (const [i, t] of chain.entries()) {
566
+ const p = (t === baseTxid) ? basePayload : await fetchPayload(t, k, pubHex)
567
+ applyPayload(p.meta, p.tar, work, name)
568
+ console.log(` replayed ${i + 1}/${chain.length} ${t.slice(0, 12)}… (${p.meta.kind || 'full'})`)
569
+ }
570
+
571
+ const baseM = buildManifest(path.join(work, name))
572
+ const curM = buildManifest(abs)
573
+ const { changed, deleted } = diffManifest(baseM.map, curM.map)
574
+ fs.rmSync(work, { recursive: true, force: true })
575
+
576
+ if (!changed.length && !deleted.length) {
577
+ console.log('no changes since base — nothing to save')
578
+ return
579
+ }
580
+ console.log(`${changed.length} changed/added, ${deleted.length} deleted`)
581
+
582
+ const listFile = path.join(TMP, `keychat-delta-${name}.list`)
583
+ fs.writeFileSync(listFile, changed.map(p => path.join(name, p)).join('\n') + '\n')
584
+ const out = path.join(TMP, `keychat-delta-${name}.tgz`)
585
+ sh('tar', ['czf', out, '-C', path.dirname(abs), '-T', listFile],
586
+ { env: { ...process.env, COPYFILE_DISABLE: '1' } })
587
+ const tar = fs.readFileSync(out)
588
+ fs.unlinkSync(out)
589
+ fs.unlinkSync(listFile)
590
+
591
+ const { head, dirty } = gitState(abs)
592
+ console.log(`delta ${(tar.length / 1024).toFixed(1)} KB HEAD ${head} ${dirty} uncommitted`)
593
+ console.log(`manifest ${curM.root}`)
594
+
595
+ const meta = {
596
+ v: 2, kind: 'delta', repo: name, head, dirty,
597
+ base: baseTxid, chain,
598
+ deleted, changedCount: changed.length,
599
+ sha256: createHash('sha256').update(tar).digest('hex'),
600
+ manifestRoot: curM.root, tarSize: tar.length, at: Math.floor(Date.now() / 1000)
601
+ }
602
+ const scriptHex = sealPayload(meta, tar, k, pubHex)
603
+ const res = await publish(scriptHex, k, address, lockingScript, { dry })
604
+ if (dry) {
605
+ console.log(`\n--dry: nothing broadcast\n payload ${res.txid}`)
606
+ return
607
+ }
608
+ recordSnapshot({ txid: res.txid, funding: res.funding, ...meta, cost: res.cost })
609
+ console.log(`\nDELTA ${name} @ ${head} -> ${res.txid} (chain of ${chain.length + 1})`)
610
+ console.log(`restore with: node scripts/keychat-repo.mjs restore ${res.txid} <dest>`)
611
+ }
612
+
613
+ async function restore (txid, dest) {
614
+ const k = key()
615
+ const pubHex = k.toPublicKey().toString()
616
+
617
+ process.stdout.write(`fetching ${txid.slice(0, 16)}… `)
618
+ const head = await fetchPayload(txid, k, pubHex)
619
+ const name = head.meta.repo
620
+ console.log(`${name} @ ${head.meta.head} (${head.meta.kind || 'full'})`)
621
+
622
+ const chain = chainOf(txid, head.meta)
623
+ fs.mkdirSync(dest, { recursive: true })
624
+ for (const [i, t] of chain.entries()) {
625
+ const p = (t === txid) ? head : await fetchPayload(t, k, pubHex)
626
+ const got = createHash('sha256').update(p.tar).digest('hex')
627
+ if (p.meta.sha256 && got !== p.meta.sha256) {
628
+ throw new Error(`SHA MISMATCH on ${t} — expected ${p.meta.sha256}, got ${got}`)
629
+ }
630
+ applyPayload(p.meta, p.tar, dest, name)
631
+ console.log(` applied ${i + 1}/${chain.length} ${t.slice(0, 12)}… ` +
632
+ `(${p.meta.kind || 'full'}, ${(p.tar.length / 1024).toFixed(0)} KB)`)
633
+ }
634
+
635
+ // The manifest is never stored, so recompute it and compare roots. This checks
636
+ // every file in the rebuilt tree, and fails loudly if a delta went on out of order.
637
+ if (head.meta.manifestRoot) {
638
+ const got = buildManifest(path.join(dest, name)).root
639
+ if (got !== head.meta.manifestRoot) {
640
+ throw new Error(`MANIFEST MISMATCH — expected ${head.meta.manifestRoot}, got ${got}`)
641
+ }
642
+ console.log(`manifest verified (${head.meta.manifestRoot.slice(0, 16)}…)`)
643
+ } else {
644
+ console.log('manifest not recorded (v1 snapshot) — tar sha256 verified only')
645
+ }
646
+ console.log(`restored to ${path.join(dest, name)}`)
647
+ }
648
+
649
+ function list () {
650
+ let all = []
651
+ try { all = JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, 'repos.json'), 'utf8')) } catch {}
652
+ if (!all.length) return console.log('no snapshots recorded locally')
653
+ for (const r of all.sort((a, b) => b.at - a.at)) {
654
+ console.log(`${new Date(r.at * 1000).toISOString().slice(0, 10)} ${r.repo.padEnd(20)} ` +
655
+ `${r.head} ${(r.tarSize / 1048576).toFixed(1)} MB ${r.cost}C ${r.txid}`)
656
+ }
657
+ }
658
+
659
+ /**
660
+ * CLI entry for the repo/repochain commands, called from index.mjs.
661
+ *
662
+ * GENERATED by sovereign-messenger/scripts/sync-npm-package.mjs — do not edit
663
+ * this file. Edit scripts/keychat-repo.mjs and re-run the sync.
664
+ */
665
+ export async function runRepo (argv) {
666
+ const [sub, a, ...rest] = argv
667
+ const dry = rest.includes('--dry') || a === '--dry'
668
+ if (sub === 'estimate') return estimate(a)
669
+ if (sub === 'backup') return backup(a, { dry })
670
+ if (sub === 'delta') return delta(a, rest.find(x => /^[0-9a-f]{64}$/.test(x)), { dry })
671
+ if (sub === 'restore') return restore(a, rest.find(x => !x.startsWith('--')) || '.')
672
+ if (sub === 'list') return list()
673
+ console.log('usage:')
674
+ console.log(' keychat-save repo backup <repoPath> full snapshot')
675
+ console.log(' keychat-save repo delta <repoPath> [baseTxid] routine save')
676
+ console.log(' keychat-save repo restore <txid> <dest> replays the whole chain')
677
+ console.log(' keychat-save repo estimate <repoPath>')
678
+ console.log(' keychat-save repo list')
679
+ process.exitCode = 1
680
+ }