keychat-save 1.2.1 → 1.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/README.md CHANGED
@@ -19,14 +19,17 @@ This creates your identity at `~/.keychat/identity.wif` and shows your BSV addre
19
19
  ## Usage
20
20
 
21
21
  ```bash
22
- # Save content
22
+ # Save content (positional: label first, then body)
23
23
  keychat-save save "my notes" "content to save permanently"
24
24
 
25
+ # Or use --label flag explicitly
26
+ keychat-save save --label "my notes" "content to save permanently"
27
+
25
28
  # Save a file
26
- keychat-save save "backup" --file ./myfile.txt
29
+ keychat-save save --label "backup" --file ./myfile.txt
27
30
 
28
31
  # Pipe content in
29
- cat session.log | keychat-save save "session log" --stdin
32
+ cat session.log | keychat-save save --label "session log" --stdin
30
33
 
31
34
  # Load all saves
32
35
  keychat-save load
@@ -34,19 +37,67 @@ keychat-save load
34
37
  # Load most recent
35
38
  keychat-save load --last
36
39
 
40
+ # Load a specific save by txid
41
+ keychat-save load --txid <txid>
42
+
37
43
  # Search
38
44
  keychat-save load --search "keyword"
39
45
  ```
40
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
+
41
86
  ## How it works
42
87
 
43
- 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.
44
91
 
45
- 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.
46
95
 
47
96
  ## Web UI
48
97
 
49
- 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`.
50
101
 
51
102
  ## License
52
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
@@ -179,19 +179,31 @@ async function cmdSave (label, body) {
179
179
  const usable = utxos.filter(u => u.satoshis >= 125 && u.satoshis <= 500).sort((a, b) => b.satoshis - a.satoshis)
180
180
  if (!usable.length) throw new Error('No Compute UTXOs. Fund your address at keychat.pro and wait for packaging.')
181
181
 
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
-
182
+ // EVERY INPUT MUST BE COUNTED. This was `150 + cipher + 50`, which ignored the
183
+ // ~148 bytes each P2PKH input adds — so adding inputs to cover the fee grew the
184
+ // tx without growing the estimate, and the round-up to 125 sats hid it until a
185
+ // body got big enough that it couldn't. A save that lands under 100 sats/KB is
186
+ // relayed, accepted, given a txid — and then NEVER MINED. It ages out of every
187
+ // mempool hours later and the content is gone. Observed on a 20,188-byte save
188
+ // that paid 2,000 sats: 99.07 sats/KB, nineteen satoshis short, lost.
189
+ const INPUT_BYTES = 148 // 36 outpoint + 1 len + ~107 script + 4 sequence
190
+ const OUTPUT_BYTES = 34 // change output, counted even when it lands at 0
191
+ const OP_RETURN_BYTES = cipherHex.length / 2 + 60
187
192
  const picked = []
188
193
  let sum = 0
189
- for (const u of usable) {
194
+ let totalCost = 0
195
+ for (;;) {
196
+ const estBytes = 10 + picked.length * INPUT_BYTES + OP_RETURN_BYTES + OUTPUT_BYTES
197
+ totalCost = Math.ceil(Math.ceil(estBytes * 100 / 1000) / SUB_UNIT_SATS) * SUB_UNIT_SATS
198
+ if (picked.length > 0 && sum >= totalCost) break
199
+ if (picked.length >= usable.length) {
200
+ throw new Error(`Insufficient Compute. Need ${totalCost / 500}, have ${sum / 500}.`)
201
+ }
202
+ const u = usable[picked.length]
190
203
  picked.push(u)
191
204
  sum += u.satoshis
192
- if (sum >= totalCost) break
193
205
  }
194
- if (sum < totalCost) throw new Error(`Insufficient Compute. Need ${totalCost / 500}, have ${sum / 500}.`)
206
+ console.log(` Cost: ${totalCost / 500} Compute (${totalCost} sats, ${picked.length} inputs)`)
195
207
 
196
208
  const tx = new Transaction()
197
209
  for (const u of picked) {
@@ -208,7 +220,20 @@ async function cmdSave (label, body) {
208
220
  }
209
221
  await tx.sign()
210
222
 
211
- const txid = await broadcast(tx.toHex())
223
+ // Measure the SIGNED tx, not the estimate. Below 100 sats/KB the save looks
224
+ // successful and disappears hours later, so refusing here is always kinder
225
+ // than that silence.
226
+ const rawHex = tx.toHex()
227
+ const rate = (sum - changeSats) / (rawHex.length / 2 / 1000)
228
+ if (rate < 100) {
229
+ throw new Error(
230
+ `Fee too low: ${rate.toFixed(2)} sats/KB, under the 100 sats/KB mining floor. ` +
231
+ `NOT broadcast — a tx this cheap is accepted to mempool but never mined. Re-run.`
232
+ )
233
+ }
234
+ console.log(` Fee: ${sum - changeSats} sats / ${rawHex.length / 2} bytes = ${rate.toFixed(2)} sats/KB`)
235
+
236
+ const txid = await broadcast(rawHex)
212
237
 
213
238
  // Prepend new entry to local saves index
214
239
  const date = new Date().toISOString().slice(0, 10)
@@ -341,12 +366,37 @@ const [,, cmd, ...args] = process.argv
341
366
  if (cmd === 'init') {
342
367
  await cmdInit()
343
368
  } else if (cmd === 'save') {
344
- let label = args[0] || 'Untitled'
369
+ // Accept both invocation styles:
370
+ // keychat-save save "Title" "Body" (positional)
371
+ // keychat-save save --label "Title" "Body" (named)
372
+ // keychat-save save --label "Title" --file p.txt
373
+ // keychat-save save --label "Title" --stdin
374
+ // Earlier versions only handled the positional form, so users who
375
+ // typed --label got the literal "--label" as their label string.
376
+ let label = 'Untitled'
377
+ let positional = [...args]
378
+
379
+ const labelIdx = positional.indexOf('--label')
380
+ if (labelIdx >= 0 && positional[labelIdx + 1] != null) {
381
+ label = positional[labelIdx + 1]
382
+ positional.splice(labelIdx, 2)
383
+ } else if (positional[0] && !positional[0].startsWith('--')) {
384
+ label = positional[0]
385
+ positional = positional.slice(1)
386
+ }
387
+
345
388
  let body
346
- if (args[1] === '--file') { body = readFileSync(args[2], 'utf8') }
347
- else if (args[1] === '--stdin') { body = readFileSync(0, 'utf8') }
348
- else { body = args.slice(1).join(' ') }
349
- if (!body) { console.error('Usage: keychat-save save "label" "content"'); process.exit(1) }
389
+ if (positional[0] === '--file' && positional[1]) {
390
+ body = readFileSync(positional[1], 'utf8')
391
+ } else if (positional[0] === '--stdin') {
392
+ body = readFileSync(0, 'utf8')
393
+ } else {
394
+ body = positional.join(' ')
395
+ }
396
+ if (!body) {
397
+ console.error('Usage:\n keychat-save save "label" "content"\n keychat-save save --label "title" "content"\n keychat-save save --label "title" --file <path>\n keychat-save save --label "title" --stdin')
398
+ process.exit(1)
399
+ }
350
400
  await cmdSave(label, body)
351
401
  } else if (cmd === 'load') {
352
402
  // Direct txid lookup — instant, no history scan
@@ -369,10 +419,14 @@ if (cmd === 'init') {
369
419
  if (args.includes('--search')) opts.search = args[args.indexOf('--search') + 1]
370
420
  await cmdLoad(opts)
371
421
  }
422
+ } else if (cmd === 'repo' || cmd === 'repochain') {
423
+ // RepoChain — whole git repositories on chain, not just session text.
424
+ const { runRepo } = await import('./repo.mjs')
425
+ await runRepo(args)
372
426
  } else {
373
427
  console.log(`keychat-save — Save to BSV blockchain via KeyChat (keychat.pro)
374
428
 
375
- Commands:
429
+ SAVES — text, sessions, notes:
376
430
  init Create identity (~/.keychat/identity.wif)
377
431
  save "label" "content" Save content to chain
378
432
  save "label" --file path Save file to chain
@@ -382,5 +436,15 @@ Commands:
382
436
  load --search "keyword" Search saves
383
437
  load --txid <txid> Load a specific save by txid (instant, no scan)
384
438
 
439
+ REPOCHAIN — whole git repositories:
440
+ repo backup <path> Full snapshot: git history, working tree, node_modules
441
+ repo delta <path> Only what changed since the last snapshot (cheap, routine)
442
+ repo restore <txid> <dest> Rebuild the repo — replays the whole chain
443
+ repo estimate <path> Size and cost, broadcasts nothing
444
+ repo list Snapshots taken from this machine
445
+
446
+ Each snapshot is ONE transaction. A delta continues the chain automatically and
447
+ carries the ordered txids of every ancestor, so a single txid restores everything.
448
+
385
449
  Your data. Your key. Permanent on BSV.`)
386
450
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "keychat-save",
3
- "version": "1.2.1",
4
- "description": "Save and load files to BSV blockchain via KeyChat",
3
+ "version": "1.4.0",
4
+ "description": "Save sessions, files and whole git repositories to the BSV blockchain via KeyChat",
5
5
  "type": "module",
6
6
  "bin": {
7
- "keychat-save": "./index.mjs"
7
+ "keychat-save": "index.mjs"
8
8
  },
9
9
  "engines": {
10
10
  "node": ">=18"
@@ -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
+ }