keychat-save 1.3.0 → 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 +48 -3
- package/check-generated.mjs +20 -0
- package/index.mjs +49 -10
- package/package.json +17 -4
- package/repo.mjs +680 -0
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
|
|
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
|
|
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
|
|
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
|
@@ -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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -394,10 +419,14 @@ if (cmd === 'init') {
|
|
|
394
419
|
if (args.includes('--search')) opts.search = args[args.indexOf('--search') + 1]
|
|
395
420
|
await cmdLoad(opts)
|
|
396
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)
|
|
397
426
|
} else {
|
|
398
427
|
console.log(`keychat-save — Save to BSV blockchain via KeyChat (keychat.pro)
|
|
399
428
|
|
|
400
|
-
|
|
429
|
+
SAVES — text, sessions, notes:
|
|
401
430
|
init Create identity (~/.keychat/identity.wif)
|
|
402
431
|
save "label" "content" Save content to chain
|
|
403
432
|
save "label" --file path Save file to chain
|
|
@@ -407,5 +436,15 @@ Commands:
|
|
|
407
436
|
load --search "keyword" Search saves
|
|
408
437
|
load --txid <txid> Load a specific save by txid (instant, no scan)
|
|
409
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
|
+
|
|
410
449
|
Your data. Your key. Permanent on BSV.`)
|
|
411
450
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keychat-save",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Save and
|
|
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
7
|
"keychat-save": "index.mjs"
|
|
@@ -12,11 +12,24 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@bsv/sdk": "^1.0.0"
|
|
14
14
|
},
|
|
15
|
-
"keywords": [
|
|
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
|
+
}
|