keychat-save 1.4.3 → 1.6.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
@@ -83,6 +83,56 @@ A bundle carries only committed history. It misses uncommitted work, gitignored
83
83
  and `node_modules` — which means a restore depends on the npm registry still serving every package
84
84
  at the same version. Packages get unpublished. RepoChain carries the lot.
85
85
 
86
+ ## KeyCloud — files on chain
87
+
88
+ `save` handles text and **RepoChain** handles repositories. **KeyCloud** puts a single file on
89
+ chain in one transaction, sealed to your own key. Nobody else can read it, including us.
90
+
91
+ ```bash
92
+ # Upload — one file, one transaction
93
+ keychat-save keycloud put ~/Documents/passport.pdf
94
+
95
+ # Fetch it back anywhere your identity key is. sha256 is verified before
96
+ # anything is written to disk.
97
+ keychat-save keycloud get <txid> ~/Downloads
98
+
99
+ # What you have uploaded from this machine
100
+ keychat-save keycloud list
101
+
102
+ # Publish identifiers for everything already on chain — see below
103
+ keychat-save keycloud catalog
104
+ ```
105
+
106
+ ### Listing without downloading
107
+
108
+ Metadata travels in its own encrypted push, separate from the file. A listing
109
+ therefore costs kilobytes: names, sizes and image thumbnails are read without
110
+ moving a single file body, and the bodies are fetched only when you open one.
111
+
112
+ Anything uploaded before that split has its metadata sealed inside its own body,
113
+ so listing it the old way meant downloading it. `keycloud catalog` fixes that in
114
+ one small transaction: it publishes the identifiers you already hold locally —
115
+ names, sizes, hashes, and your RepoChain snapshots too — encrypted to your key.
116
+ Any device with that key then lists your whole history for a few KB. Cataloguing
117
+ 51 items cost 2 Compute.
118
+
119
+ Bytes are encrypted directly — never base64, which would add 33% to the size and therefore 33% to
120
+ the cost. The file's sha256 travels with it and is checked on the way back, so a truncated fetch
121
+ fails loudly instead of writing a corrupt file.
122
+
123
+ ### What it costs
124
+
125
+ Storage is priced like permanence, at the 100 sats/KB mining floor:
126
+
127
+ | File | Cost |
128
+ |------|------|
129
+ | 1 MB | ~200 Compute |
130
+ | 10 MB | ~2,000 Compute |
131
+ | 100 MB | ~20,000 Compute (0.1 BSV) |
132
+
133
+ `put` refuses anything over 95 MB rather than spend on a transaction no miner will accept — miner
134
+ policy caps a single transaction at 100 MB.
135
+
86
136
  ## How it works
87
137
 
88
138
  Content is sealed with your identity key (ECIES) and broadcast as a transaction on BSV via
@@ -18,3 +18,30 @@ if (!readFileSync(new URL('./index.mjs', import.meta.url), 'utf8').includes('100
18
18
  console.error('Without it an underpaid save is accepted, never mined, and lost hours later.')
19
19
  process.exit(1)
20
20
  }
21
+
22
+ // EVERY RELATIVE IMPORT MUST RESOLVE INSIDE THE PACKAGE.
23
+ //
24
+ // 1.4.3 SHIPPED WITH REPOCHAIN COMPLETELY DEAD. repo.mjs imported
25
+ // './spent-ledger-fs.js' and '../src/wallet/spent-ledger.js' — neither was in
26
+ // the package — so every `keychat-save repo ...` exited with ERR_MODULE_NOT_FOUND
27
+ // before doing anything. The two guards above both passed: repo.mjs WAS
28
+ // generated and index.mjs DID have the floor guard. Nothing checked that the
29
+ // package could actually load.
30
+ for (const f of ['repo.mjs', 'index.mjs', 'spent-ledger.js', 'spent-ledger-fs.js']) {
31
+ let body
32
+ try { body = readFileSync(new URL(`./${f}`, import.meta.url), 'utf8') } catch { continue }
33
+ for (const m of body.matchAll(/from\s+'(\.[^']+)'/g)) {
34
+ try {
35
+ readFileSync(new URL(m[1], new URL(`./${f}`, import.meta.url)), 'utf8')
36
+ } catch {
37
+ console.error(`${f} imports '${m[1]}', which is not in the package.`)
38
+ console.error('That is what shipped broken in 1.4.3 — the CLI cannot even load.')
39
+ process.exit(1)
40
+ }
41
+ }
42
+ }
43
+
44
+ // And the guard the other two could not give: actually LOAD the modules. A
45
+ // syntax error in the generated file (a bad cut point produces one) passes
46
+ // every textual check above and still bricks the CLI.
47
+ await import('./repo.mjs')
package/index.mjs CHANGED
@@ -259,6 +259,33 @@ async function cmdSave (label, body) {
259
259
  }
260
260
  console.log(` Fee: ${sum - changeSats} sats / ${rawHex.length / 2} bytes = ${rate.toFixed(2)} sats/KB`)
261
261
 
262
+ // FRESH READ, IN THE SAME COMMAND, IMMEDIATELY BEFORE THE BROADCAST.
263
+ //
264
+ // Everything above planned against the getUnspent call at the top of this
265
+ // function, and that snapshot is now as old as building and signing took.
266
+ // Another writer on the same key — the KeyChat web app, a second terminal,
267
+ // a repo snapshot — can have taken an input in that window. Broadcasting
268
+ // anyway produces a tx that is accepted by some peers, never mined, and gone
269
+ // hours later: exactly the silent-loss failure the fee floor above guards
270
+ // against, arriving by a different route.
271
+ //
272
+ // THE CHAIN IS THE LEDGER. The bridge verifies every outpoint against the
273
+ // node's chainstate with include_mempool=true — "is this usable for a new tx
274
+ // right now" — so anything another writer has already BROADCAST is absent
275
+ // from this response. No reservation table, no shared state: just look again
276
+ // before committing.
277
+ const fresh = await getUnspent(address)
278
+ const live = new Set(fresh.map(u => `${u.txid}:${u.vout}`))
279
+ const gone = picked.filter(u => !live.has(`${u.txid}:${u.vout}`))
280
+ if (gone.length) {
281
+ throw new Error(
282
+ `${gone.length} of ${picked.length} input(s) were spent while this save was ` +
283
+ `being built (first: ${gone[0].txid.slice(0, 16)}…:${gone[0].vout}). ` +
284
+ `NOT broadcast — it would have been a dead double-spend. Re-run; the ` +
285
+ `picker will take fresh coins.`
286
+ )
287
+ }
288
+
262
289
  const txid = await broadcast(rawHex)
263
290
 
264
291
  // Prepend new entry to local saves index
@@ -449,6 +476,12 @@ if (cmd === 'init') {
449
476
  // RepoChain — whole git repositories on chain, not just session text.
450
477
  const { runRepo } = await import('./repo.mjs')
451
478
  await runRepo(args)
479
+ } else if (cmd === 'keycloud') {
480
+ // KeyCloud — a single file on chain, sealed to your own key. Shares repo.mjs
481
+ // because it shares publish(): the funding-tx pattern, the 100 sats/KB floor,
482
+ // the self-marker, and the pre-broadcast chain re-read.
483
+ const { runKeycloud } = await import('./repo.mjs')
484
+ await runKeycloud(args)
452
485
  } else {
453
486
  console.log(`keychat-save — Save to BSV blockchain via KeyChat (keychat.pro)
454
487
 
@@ -469,6 +502,14 @@ REPOCHAIN — whole git repositories:
469
502
  repo estimate <path> Size and cost, broadcasts nothing
470
503
  repo list Snapshots taken from this machine
471
504
 
505
+ KEYCLOUD — files on chain, sealed to your own key:
506
+ keycloud put <file> Upload one file in a single transaction
507
+ keycloud get <txid> <dest> Fetch it back (sha256 verified before writing)
508
+ keycloud list Files uploaded from this machine
509
+ keycloud catalog Publish identifiers for everything already on
510
+ chain, so any device can list it without
511
+ downloading a single file
512
+
472
513
  Each snapshot is ONE transaction. A delta continues the chain automatically and
473
514
  carries the ordered txids of every ancestor, so a single txid restores everything.
474
515
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keychat-save",
3
- "version": "1.4.3",
3
+ "version": "1.6.0",
4
4
  "description": "Save sessions, files and whole git repositories to the BSV blockchain via KeyChat",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "license": "MIT",
27
27
  "repository": {
28
28
  "type": "git",
29
- "url": "https://github.com/avaziri93/keychat-save"
29
+ "url": "git+https://github.com/avaziri93/keychat-save.git"
30
30
  },
31
31
  "homepage": "https://keychat.pro",
32
32
  "scripts": {
package/repo.mjs CHANGED
@@ -49,7 +49,7 @@ import fs from 'node:fs'
49
49
  import os from 'node:os'
50
50
  import path from 'node:path'
51
51
  import { installFileLedger } from './spent-ledger-fs.js'
52
- import { markSpentOutpoints, withoutSpent, pruneConfirmed, rebuildFromNode } from '../src/wallet/spent-ledger.js'
52
+ import { markSpentOutpoints, withoutSpent, pruneConfirmed, rebuildFromNode } from './spent-ledger.js'
53
53
 
54
54
  // Share one spent-ledger with keychat-save and every other Node writer on this
55
55
  // machine. A repo snapshot and a session save draw from the SAME wallet; a
@@ -115,6 +115,41 @@ async function releaseLedgerAfterFailure (address) {
115
115
  }
116
116
  }
117
117
 
118
+ /**
119
+ * Thrown when an input we signed was taken before we broadcast. Distinct type
120
+ * so the caller can rebuild on fresh coins instead of shipping a dead tx.
121
+ */
122
+ class StaleInputsError extends Error {}
123
+
124
+ /**
125
+ * LAST READ BEFORE BROADCAST — are the coins we just signed still ours?
126
+ *
127
+ * publish() reads /unspent once at the top and then does real work: planning,
128
+ * building and signing, tens of seconds for a multi-MB payload. Any other
129
+ * writer on this key can take an input in that window — most obviously the
130
+ * browser, whose spent-ledger lives in localStorage and cannot see this
131
+ * file-backed one. Nothing looked again, so a snapshot could be signed against
132
+ * coins that were already gone and broadcast dead. That is how 811d165c lost
133
+ * two of its 76 inputs.
134
+ *
135
+ * THE CHAIN IS THE LEDGER HERE, not local state. The bridge's /unspent verifies
136
+ * every outpoint against the node's chainstate with include_mempool=true —
137
+ * "is this usable for a new tx right now" — so anything another writer has
138
+ * BROADCAST is already absent from this response. That is the same read Compute
139
+ * History reconstructs the whole wallet from. No reservation table needed.
140
+ *
141
+ * skipLedger: true is REQUIRED. markSpentOutpoints(picked) has already claimed
142
+ * these coins in our own ledger, so the filtered view would report every one of
143
+ * them missing and this check would fire on every single save.
144
+ *
145
+ * Returns the outpoints that are gone; empty means clear to broadcast.
146
+ */
147
+ async function staleInputs (address, picked) {
148
+ const fresh = await getUnspent(address, { skipLedger: true })
149
+ const live = new Set(fresh.map(u => `${u.txid}:${u.vout}`))
150
+ return picked.filter(u => !live.has(`${u.txid}:${u.vout}`))
151
+ }
152
+
118
153
  async function broadcast (rawHex) {
119
154
  const res = await fetch(`${BRIDGE}/api/broadcast`, {
120
155
  method: 'POST',
@@ -398,6 +433,29 @@ function planFunding (utxos, target) {
398
433
  // delta small enough to skip it has nothing to lose.
399
434
  const DIRECT_MAX_INPUTS = 60
400
435
 
436
+ /**
437
+ * publish(), retried on fresh coins if an input was taken mid-build.
438
+ *
439
+ * Re-entering publish() re-reads /unspent from the top, so the rebuild plans
440
+ * against the current chain rather than the snapshot that just went stale. The
441
+ * payload script is unchanged — only the funding is re-planned — so a retry
442
+ * costs a rebuild and a re-sign, never a different snapshot.
443
+ *
444
+ * Bounded at 3 attempts. If coins are being taken out from under us that
445
+ * persistently, something else is wrong and looping would just burn fees.
446
+ */
447
+ async function publishWithRetry (scriptHex, k, address, lockingScript, opts = {}) {
448
+ for (let attempt = 1; ; attempt++) {
449
+ try {
450
+ return await publish(scriptHex, k, address, lockingScript, opts)
451
+ } catch (err) {
452
+ if (!(err instanceof StaleInputsError) || attempt >= 3) throw err
453
+ console.log(`\n ${err.message}`)
454
+ console.log(` rebuilding on fresh coins (attempt ${attempt + 1}/3) ...\n`)
455
+ }
456
+ }
457
+ }
458
+
401
459
  /** Broadcast one OP_RETURN payload, funding it whichever way is cheaper. */
402
460
  async function publish (scriptHex, k, address, lockingScript, { dry = false } = {}) {
403
461
  const opReturn = Script.fromHex(scriptHex)
@@ -454,6 +512,17 @@ async function publish (scriptHex, k, address, lockingScript, { dry = false } =
454
512
  console.log(`payload tx ${(bytes / 1024).toFixed(1)} KB, ${n} inputs, ` +
455
513
  `fee ${fee} sats = ${fee / SATS_PER_COMPUTE} Compute (single tx, ${rate.toFixed(2)} sats/KB)`)
456
514
  if (dry) return { txid: tx.id('hex'), funding: null, cost: fee / SATS_PER_COMPUTE, dry: true }
515
+ // Same fresh read as the two-tx path below. This path claims nothing in the
516
+ // ledger, so the inputs are simply the ones we built with.
517
+ process.stdout.write('re-checking inputs ... ')
518
+ const goneDirect = await staleInputs(address, utxos.slice(0, n))
519
+ if (goneDirect.length) {
520
+ console.log(`${goneDirect.length} of ${n} TAKEN`)
521
+ throw new StaleInputsError(
522
+ `${goneDirect.length} input(s) were spent while this tx was being built ` +
523
+ `(first: ${goneDirect[0].txid.slice(0, 16)}…:${goneDirect[0].vout})`)
524
+ }
525
+ console.log(`${n}/${n} still live`)
457
526
  process.stdout.write('broadcasting ... ')
458
527
  const txid = await broadcast(tx.toHex())
459
528
  console.log(txid)
@@ -529,6 +598,20 @@ async function publish (scriptHex, k, address, lockingScript, { dry = false } =
529
598
  await releaseLedgerAfterFailure(address)
530
599
  return { txid: payTx.id('hex'), funding: fundTx.id('hex'), cost: (fundingValue + fundFee) / SATS_PER_COMPUTE, dry: true }
531
600
  }
601
+ // FRESH READ, IN THE SAME COMMAND, IMMEDIATELY BEFORE THE BROADCAST.
602
+ // Everything above this line is planning against a snapshot that is now as
603
+ // old as the signing took. Ask the chain once more before committing.
604
+ process.stdout.write('re-checking inputs ... ')
605
+ const gone = await staleInputs(address, picked)
606
+ if (gone.length) {
607
+ console.log(`${gone.length} of ${picked.length} TAKEN`)
608
+ await releaseLedgerAfterFailure(address)
609
+ throw new StaleInputsError(
610
+ `${gone.length} input(s) were spent while this tx was being built ` +
611
+ `(first: ${gone[0].txid.slice(0, 16)}…:${gone[0].vout})`)
612
+ }
613
+ console.log(`${picked.length}/${picked.length} still live`)
614
+
532
615
  process.stdout.write('broadcasting funding tx ... ')
533
616
  let ftx
534
617
  try {
@@ -657,7 +740,7 @@ async function backup (repoPath, { dry = false } = {}) {
657
740
  sha256: sha, manifestRoot, tarSize: tar.length, at: Math.floor(Date.now() / 1000)
658
741
  }
659
742
  const scriptHex = sealPayload(meta, tar, k, pubHex)
660
- const res = await publish(scriptHex, k, address, lockingScript, { dry })
743
+ const res = await publishWithRetry(scriptHex, k, address, lockingScript, { dry })
661
744
  if (dry) {
662
745
  console.log(`\n--dry: nothing broadcast\n payload ${res.txid}` +
663
746
  (res.funding ? `\n funding ${res.funding}` : ''))
@@ -750,7 +833,7 @@ async function delta (repoPath, baseTxid, { dry = false } = {}) {
750
833
  manifestRoot: curM.root, tarSize: tar.length, at: Math.floor(Date.now() / 1000)
751
834
  }
752
835
  const scriptHex = sealPayload(meta, tar, k, pubHex)
753
- const res = await publish(scriptHex, k, address, lockingScript, { dry })
836
+ const res = await publishWithRetry(scriptHex, k, address, lockingScript, { dry })
754
837
  if (dry) {
755
838
  console.log(`\n--dry: nothing broadcast\n payload ${res.txid}`)
756
839
  return
@@ -796,6 +879,293 @@ async function restore (txid, dest) {
796
879
  console.log(`restored to ${path.join(dest, name)}`)
797
880
  }
798
881
 
882
+ // ── KeyCloud ─────────────────────────────────────────────────────
883
+ // ONE FILE, ONE TRANSACTION, SEALED TO YOUR OWN KEY.
884
+ //
885
+ // KeyCloud is personal storage, not messaging: the file is encrypted to the
886
+ // wallet's OWN public key, so only this identity can ever read it back. It
887
+ // reuses publish() wholesale — the funding-tx pattern, the 100 sats/KB floor,
888
+ // the self-marker, and the pre-broadcast chain re-read — because a second copy
889
+ // of that machinery is precisely how getTx and getRawTxHex drifted apart.
890
+ //
891
+ // KCLD, NOT KSAV OR KREP. A distinct prefix keeps files out of `load` (which
892
+ // would otherwise decrypt every upload into saves.json and then carry it in
893
+ // every future bootstrap table) and out of `repo list`. Same reason KREP was
894
+ // split from KSAV in the first place.
895
+ //
896
+ // RAW BYTES. The file is framed and encrypted directly, never base64 — that
897
+ // 33% tax is real money here: at 100 sats/KB a 10 MB file is ~2,000 Compute,
898
+ // so base64 alone would cost ~660 Compute extra per upload.
899
+ //
900
+ // SIZE. Single tx up to the miner policy ceiling — GorillaPool and TAAL both
901
+ // publish maxtxsizepolicy=100,000,000, and 33.87 MB repo snapshots already go
902
+ // on chain whole this way. Our own bitcoind runs maxscriptsizepolicy=6000000
903
+ // and will not relay past 6 MB itself; ARC carries those and the node picks
904
+ // them up when mined. Above the ceiling the file must be chunked — see the
905
+ // KACH chunk+manifest format in src/messages/attachment.js, whose receive side
906
+ // already exists. put refuses rather than guessing, so nothing is spent on a
907
+ // transaction no miner will accept.
908
+ const CLOUD_PREFIX = '4b434c44' // "KCLD"
909
+ const CLOUD_INDEX = 'keycloud.json'
910
+ // v2 SPLITS METADATA OUT OF THE FILE'S CIPHERTEXT.
911
+ //
912
+ // v1 [KCLD, 01, pub, encrypt(meta + bytes), ts]
913
+ // v2 [KCLD, 02, pub, encrypt(meta), encrypt(bytes), ts]
914
+ //
915
+ // v1 sealed the filename INSIDE the file's own ciphertext, so listing what you
916
+ // own meant fetching and decrypting every byte you had ever uploaded — ten
917
+ // 10 MB files is a 100 MB download to draw ten labels. A file browser is not
918
+ // buildable on that.
919
+ //
920
+ // In v2 the meta push is a few hundred bytes plus an optional thumbnail, so the
921
+ // bridge can index and serve it alone and the client renders a whole grid
922
+ // without touching a single file body. The bytes are fetched only when someone
923
+ // opens something.
924
+ //
925
+ // BOTH PUSHES STAY ENCRYPTED to the owner's key. Filenames never appear in
926
+ // plaintext on chain — the point is that only the holder of the key learns
927
+ // anything, and a filename is often the most revealing part of a file.
928
+ //
929
+ // v1 files stay readable: cloudGet accepts either shape.
930
+ const CLOUD_VERSION_V1 = '01'
931
+ const CLOUD_VERSION = '02'
932
+ // Policy ceiling for a single transaction. The payload is the whole tx, so this
933
+ // is deliberately conservative against the published 100 MB: envelope, ECIES
934
+ // overhead and the funding input all sit inside the same limit.
935
+ const CLOUD_MAX_SINGLE_TX = 95 * 1024 * 1024
936
+
937
+ const MIME_BY_EXT = {
938
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
939
+ '.webp': 'image/webp', '.heic': 'image/heic', '.tiff': 'image/tiff', '.bmp': 'image/bmp',
940
+ '.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown',
941
+ '.json': 'application/json', '.csv': 'text/csv', '.zip': 'application/zip',
942
+ '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.mp3': 'audio/mpeg', '.wav': 'audio/wav'
943
+ }
944
+ const mimeFor = name => MIME_BY_EXT[path.extname(name).toLowerCase()] || 'application/octet-stream'
945
+
946
+ /**
947
+ * A 256px JPEG preview, base64, for the file grid to render without ever
948
+ * fetching the original — the same reason Finder feels instant.
949
+ *
950
+ * Uses `sips`, which ships with macOS, rather than adding an image dependency
951
+ * to a package whose only dep is @bsv/sdk. Every failure path returns null and
952
+ * the grid falls back to a type icon: a missing thumbnail must never be able to
953
+ * fail an upload.
954
+ */
955
+ function makeThumb (abs, mime) {
956
+ if (!mime.startsWith('image/')) return null
957
+ const tmp = path.join(TMP, `kcthumb-${Math.random().toString(36).slice(2)}.jpg`)
958
+ try {
959
+ sh('sips', ['-Z', '256', '-s', 'format', 'jpeg', '-s', 'formatOptions', '60', abs, '--out', tmp],
960
+ { stdio: 'ignore' })
961
+ const b = fs.readFileSync(tmp)
962
+ // Sanity bound: a "thumbnail" bigger than this is not a thumbnail, and it
963
+ // would bloat the very push that exists to stay small.
964
+ return b.length > 64 * 1024 ? null : b.toString('base64')
965
+ } catch {
966
+ return null
967
+ } finally {
968
+ try { fs.unlinkSync(tmp) } catch { /* never existed */ }
969
+ }
970
+ }
971
+
972
+ function recordCloudFile (rec) {
973
+ const idx = path.join(CONFIG_DIR, CLOUD_INDEX)
974
+ let all = []
975
+ try { all = JSON.parse(fs.readFileSync(idx, 'utf8')) } catch {}
976
+ all.push(rec)
977
+ fs.writeFileSync(idx, JSON.stringify(all, null, 2), 'utf8')
978
+ }
979
+
980
+ /** Upload one file to chain, sealed to this wallet's own key. */
981
+ async function cloudPut (filePath, { dry = false } = {}) {
982
+ if (!filePath) throw new Error('usage: keycloud put <file>')
983
+ const abs = path.resolve(filePath)
984
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
985
+ throw new Error(`not a file: ${abs}`)
986
+ }
987
+ const k = key()
988
+ const address = k.toPublicKey().toAddress()
989
+ const pubHex = k.toPublicKey().toString()
990
+ const lockingScript = new P2PKH().lock(address)
991
+
992
+ const bytes = fs.readFileSync(abs)
993
+ if (bytes.length > CLOUD_MAX_SINGLE_TX) {
994
+ throw new Error(
995
+ `${(bytes.length / 1048576).toFixed(1)} MB exceeds the ${CLOUD_MAX_SINGLE_TX / 1048576} MB ` +
996
+ `single-transaction ceiling. Chunked upload (KACH manifest) is not wired into this ` +
997
+ `command yet — nothing was spent.`)
998
+ }
999
+ const sha = createHash('sha256').update(bytes).digest('hex')
1000
+ const name = path.basename(abs)
1001
+ const mime = mimeFor(name)
1002
+ const thumb = makeThumb(abs, mime)
1003
+ const meta = {
1004
+ v: 2,
1005
+ kind: 'file',
1006
+ name,
1007
+ size: bytes.length,
1008
+ sha256: sha,
1009
+ mime,
1010
+ at: Math.floor(Date.now() / 1000),
1011
+ ...(thumb ? { thumb } : {})
1012
+ }
1013
+ console.log(`${name} ${(bytes.length / 1048576).toFixed(2)} MB ${mime}` +
1014
+ (thumb ? ` thumb ${(Buffer.byteLength(thumb, 'base64') / 1024).toFixed(1)} KB` : ''))
1015
+ console.log(`sha256 ${sha}`)
1016
+
1017
+ process.stdout.write('encrypting ... ')
1018
+ const t = Date.now()
1019
+ const pub = PublicKey.fromString(pubHex)
1020
+ // TWO PUSHES. The meta push is what the bridge indexes and the file grid
1021
+ // reads; the file push is fetched only when someone opens the file. Both are
1022
+ // sealed to the same key — a filename is often the most revealing part of a
1023
+ // file, so it never travels in plaintext.
1024
+ const metaCipher = EncryptedMessage.encrypt(
1025
+ Array.from(Buffer.from(JSON.stringify(meta), 'utf8')), k, pub
1026
+ )
1027
+ const fileCipher = EncryptedMessage.encrypt(Array.from(bytes), k, pub)
1028
+ console.log(`${((Date.now() - t) / 1000).toFixed(1)}s ` +
1029
+ `meta ${(metaCipher.length / 1024).toFixed(1)} KB, file ${(fileCipher.length / 1048576).toFixed(2)} MB`)
1030
+ const timestamp = Math.floor(Date.now() / 1000).toString(16).padStart(8, '0')
1031
+ const scriptHex = buildOpReturn(
1032
+ [CLOUD_PREFIX, CLOUD_VERSION, pubHex, hex(metaCipher), hex(fileCipher), timestamp])
1033
+
1034
+ const res = await publishWithRetry(scriptHex, k, address, lockingScript, { dry })
1035
+ if (dry) {
1036
+ console.log(`\n--dry: nothing broadcast\n payload ${res.txid}` +
1037
+ (res.funding ? `\n funding ${res.funding}` : ''))
1038
+ return
1039
+ }
1040
+ recordCloudFile({ txid: res.txid, funding: res.funding, ...meta, cost: res.cost })
1041
+ console.log(`\nKEYCLOUD ${meta.name} -> ${res.txid}`)
1042
+ console.log(`get with: keychat-save keycloud get ${res.txid} <dest>`)
1043
+ }
1044
+
1045
+ /**
1046
+ * Fetch one KCLD file back and write it to dest.
1047
+ *
1048
+ * Verifies the sha256 recorded at upload before writing anything. That check is
1049
+ * not ceremony: getRawHex falls through to a third party for anything our
1050
+ * pruned node cannot serve, and a TRUNCATED body does not error — it decrypts
1051
+ * to garbage. The hash is what turns that into a failure instead of a corrupt
1052
+ * file on disk.
1053
+ */
1054
+ async function cloudGet (txid, dest) {
1055
+ if (!/^[0-9a-f]{64}$/.test(txid || '')) throw new Error('usage: keycloud get <txid> <dest>')
1056
+ const k = key()
1057
+ const pubHex = k.toPublicKey().toString()
1058
+ const rawHex = await getRawHex(txid)
1059
+ const tx = Transaction.fromHex(rawHex)
1060
+ let parts = null
1061
+ for (const o of tx.outputs) {
1062
+ const s = o.lockingScript.toHex()
1063
+ if (s.startsWith('006a') || s.startsWith('6a')) { parts = extractParts(s); break }
1064
+ }
1065
+ if (!parts || parts[0] !== CLOUD_PREFIX) throw new Error(`${txid} is not a KCLD keycloud transaction`)
1066
+ if (parts[2] !== pubHex) throw new Error(`${txid} is not encrypted to this identity`)
1067
+
1068
+ // BOTH SHAPES. v1 sealed meta and bytes in one ciphertext; v2 splits them so
1069
+ // the meta can be indexed and listed without moving the file. Files uploaded
1070
+ // before the split must keep opening — the version byte says which is which.
1071
+ const dec = p => Buffer.from(EncryptedMessage.decrypt(Array.from(Buffer.from(p, 'hex')), k))
1072
+ let meta, bytes
1073
+ if (parts[1] === CLOUD_VERSION_V1) {
1074
+ ({ meta, tar: bytes } = unframePayload(dec(parts[3])))
1075
+ } else {
1076
+ meta = JSON.parse(dec(parts[3]).toString('utf8'))
1077
+ bytes = dec(parts[4])
1078
+ }
1079
+
1080
+ const got = createHash('sha256').update(bytes).digest('hex')
1081
+ if (meta.sha256 && got !== meta.sha256) {
1082
+ throw new Error(`sha256 mismatch — expected ${meta.sha256}, got ${got}. NOT written.`)
1083
+ }
1084
+ // A bare directory means "put it back under its own name".
1085
+ let out = path.resolve(dest || '.')
1086
+ if (fs.existsSync(out) && fs.statSync(out).isDirectory()) out = path.join(out, meta.name)
1087
+ fs.writeFileSync(out, bytes)
1088
+ console.log(`${meta.name} ${(bytes.length / 1048576).toFixed(2)} MB sha256 ok`)
1089
+ console.log(`wrote ${out}`)
1090
+ }
1091
+
1092
+ /**
1093
+ * Publish a CATALOG: one small transaction carrying identifiers for things
1094
+ * already on chain.
1095
+ *
1096
+ * The backfill problem: everything uploaded before the v2 split sealed its
1097
+ * metadata inside its own body, so listing a v1 item means downloading it —
1098
+ * 34 MB to read a repo name. The bridge cannot help, because it cannot decrypt.
1099
+ * Re-uploading to get v2 metadata would cost thousands of Compute.
1100
+ *
1101
+ * Arash's fix, and it is the right one: send the IDENTIFIERS as their own
1102
+ * delta. We already hold every name, size and hash in the local indexes
1103
+ * (repos.json, keycloud.json) — the expensive part was only ever getting them
1104
+ * back OUT of chain. One catalog transaction publishes the lot, encrypted to
1105
+ * the same key, and any device can then list a full history without moving a
1106
+ * single body.
1107
+ *
1108
+ * Shipped AS a KCLD v2 envelope on purpose: the bridge already indexes those
1109
+ * and serves their meta push, so this needs no new prefix, no new endpoint and
1110
+ * no new indexer. `kind: 'catalog'` tells a reader to merge the entries rather
1111
+ * than render a file.
1112
+ */
1113
+ async function cloudCatalog ({ dry = false } = {}) {
1114
+ const k = key()
1115
+ const address = k.toPublicKey().toAddress()
1116
+ const pubHex = k.toPublicKey().toString()
1117
+ const lockingScript = new P2PKH().lock(address)
1118
+
1119
+ const read = f => { try { return JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, f), 'utf8')) } catch { return [] } }
1120
+ const entries = []
1121
+ for (const r of read('repos.json')) {
1122
+ if (!r.txid) continue
1123
+ entries.push({ t: r.txid, k: 'repo', n: r.repo, h: r.head, s: r.tarSize ?? null, a: r.at ?? null, c: r.cost ?? null })
1124
+ }
1125
+ for (const f of read(CLOUD_INDEX)) {
1126
+ if (!f.txid) continue
1127
+ entries.push({ t: f.txid, k: 'file', n: f.name, m: f.mime ?? null, s: f.size ?? null, a: f.at ?? null, c: f.cost ?? null })
1128
+ }
1129
+ if (!entries.length) throw new Error('nothing to catalog — repos.json and keycloud.json are both empty')
1130
+
1131
+ const meta = { v: 2, kind: 'catalog', at: Math.floor(Date.now() / 1000), count: entries.length, entries }
1132
+ const body = Buffer.from(JSON.stringify(meta), 'utf8')
1133
+ console.log(`catalog ${entries.length} entries ${(body.length / 1024).toFixed(1)} KB`)
1134
+ for (const kind of ['repo', 'file']) {
1135
+ const n = entries.filter(e => e.k === kind).length
1136
+ if (n) console.log(` ${n} ${kind}${n === 1 ? '' : 's'}`)
1137
+ }
1138
+
1139
+ process.stdout.write('encrypting ... ')
1140
+ const pub = PublicKey.fromString(pubHex)
1141
+ const metaCipher = EncryptedMessage.encrypt(Array.from(body), k, pub)
1142
+ // The file push is deliberately empty-but-present: a catalog has no body, and
1143
+ // keeping the six-push shape means every existing v2 reader parses it without
1144
+ // a special case.
1145
+ const fileCipher = EncryptedMessage.encrypt(Array.from(Buffer.alloc(0)), k, pub)
1146
+ console.log(`meta ${(metaCipher.length / 1024).toFixed(1)} KB`)
1147
+ const timestamp = Math.floor(Date.now() / 1000).toString(16).padStart(8, '0')
1148
+ const scriptHex = buildOpReturn(
1149
+ [CLOUD_PREFIX, CLOUD_VERSION, pubHex, hex(metaCipher), hex(fileCipher), timestamp])
1150
+
1151
+ const res = await publishWithRetry(scriptHex, k, address, lockingScript, { dry })
1152
+ if (dry) {
1153
+ console.log(`\n--dry: nothing broadcast\n payload ${res.txid}`)
1154
+ return
1155
+ }
1156
+ console.log(`\nCATALOG ${entries.length} entries -> ${res.txid} (${res.cost} Compute)`)
1157
+ }
1158
+
1159
+ function cloudList () {
1160
+ let all = []
1161
+ try { all = JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, CLOUD_INDEX), 'utf8')) } catch {}
1162
+ if (!all.length) return console.log('no keycloud files recorded locally')
1163
+ for (const r of all.sort((a, b) => b.at - a.at)) {
1164
+ console.log(`${new Date(r.at * 1000).toISOString().slice(0, 10)} ${String(r.name).padEnd(28)} ` +
1165
+ `${(r.size / 1048576).toFixed(2)} MB ${r.cost}C ${r.txid}`)
1166
+ }
1167
+ }
1168
+
799
1169
  function list () {
800
1170
  let all = []
801
1171
  try { all = JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, 'repos.json'), 'utf8')) } catch {}
@@ -828,3 +1198,26 @@ export async function runRepo (argv) {
828
1198
  console.log(' keychat-save repo list')
829
1199
  process.exitCode = 1
830
1200
  }
1201
+
1202
+ /**
1203
+ * CLI entry for the keycloud commands, called from index.mjs.
1204
+ *
1205
+ * Shares this module with runRepo on purpose: KeyCloud uses the same publish()
1206
+ * — funding-tx pattern, 100 sats/KB floor, self-marker, pre-broadcast chain
1207
+ * re-read. A separate module would mean a second copy of that machinery.
1208
+ */
1209
+ export async function runKeycloud (argv) {
1210
+ const [sub, ...rest] = argv
1211
+ const dry = rest.includes('--dry')
1212
+ const args = rest.filter(x => !x.startsWith('--'))
1213
+ if (sub === 'put') return cloudPut(args[0], { dry })
1214
+ if (sub === 'get') return cloudGet(args[0], args[1])
1215
+ if (sub === 'list') return cloudList()
1216
+ if (sub === 'catalog') return cloudCatalog({ dry })
1217
+ console.log('usage:')
1218
+ console.log(' keychat-save keycloud put <file> [--dry] one tx, sealed to your own key')
1219
+ console.log(' keychat-save keycloud get <txid> <dest>')
1220
+ console.log(' keychat-save keycloud list')
1221
+ console.log(' keychat-save keycloud catalog publish identifiers for existing on-chain items')
1222
+ process.exitCode = 1
1223
+ }
@@ -0,0 +1,49 @@
1
+ // File-backed store for the shared spent-ledger, for every Node entry point:
2
+ // the bot, keychat-save, keychat-repo.
3
+ //
4
+ // The browser keeps its ledger in localStorage; Node has no localStorage (and
5
+ // the bot's shim discards writes), so anything running outside a browser must
6
+ // install this before it spends.
7
+ //
8
+ // WHAT SHARING THIS FILE BUYS
9
+ // Every Node process on one machine reads and writes the same ledger, so a
10
+ // repo snapshot cannot pick the coin a session save just claimed, and neither
11
+ // can pick one the other is mid-broadcast on. That is the "failed saves while
12
+ // the app is also sending" case: two writers, one wallet, one pool.
13
+ //
14
+ // WHERE IT STOPS
15
+ // A browser cannot write to ~/.keychat, so the app's ledger and this one are
16
+ // separate stores. Contention BETWEEN the app and a CLI is still arbitrated by
17
+ // the node alone — which is sufficient there only because a person switching
18
+ // between the app and a terminal takes seconds, against a measured node lag of
19
+ // 10-65ms. It is the sub-100ms machine-speed case that needs a ledger, and
20
+ // that only ever happens within one process or between two Node ones.
21
+ import fs from 'node:fs'
22
+ import os from 'node:os'
23
+ import path from 'node:path'
24
+ import { setLedgerBackend } from './spent-ledger.js'
25
+
26
+ const LEDGER = process.env.KC_SPENT_LEDGER ||
27
+ path.join(os.homedir(), '.keychat', 'spent-ledger.json')
28
+
29
+ export const fileBackend = {
30
+ read () {
31
+ try { return JSON.parse(fs.readFileSync(LEDGER, 'utf8')) } catch { return {} }
32
+ },
33
+ // Temp file + rename: a crash mid-write must not leave truncated JSON, since
34
+ // a failed parse falls back to {} and would silently un-spend everything.
35
+ write (m) {
36
+ fs.mkdirSync(path.dirname(LEDGER), { recursive: true })
37
+ const tmp = `${LEDGER}.${process.pid}.tmp`
38
+ fs.writeFileSync(tmp, JSON.stringify(m))
39
+ fs.renameSync(tmp, LEDGER)
40
+ }
41
+ }
42
+
43
+ /** Point the shared ledger at the filesystem. Call once, before any spend. */
44
+ export function installFileLedger () {
45
+ setLedgerBackend(fileBackend)
46
+ return LEDGER
47
+ }
48
+
49
+ export const LEDGER_PATH = LEDGER
@@ -0,0 +1,195 @@
1
+ // Durable spent-ledger for every KeyChat picker.
2
+ //
3
+ // THE PROBLEM IT SOLVES
4
+ // Two sends fired back-to-back spend the same coin. Measured on 2026-08-28
5
+ // against the bot, which sends twice per reply (the message, then the self-CC
6
+ // copy 0.1ms later): our own node takes 10-65ms to report the first tx's
7
+ // inputs as spent. The second send asks "what can I spend?" inside that
8
+ // window, gets a stale-but-honest answer, and because every Compute piece is
9
+ // exactly 500 sats and the picker is largest-first, it deterministically picks
10
+ // THE SAME COIN. Two txs spending one input; the network keeps one, the other
11
+ // dies silently.
12
+ //
13
+ // The client hits the same thing on any paired flow — delete then
14
+ // re-introduce, accept then auto-reply — which is what caused the 2026-06-02
15
+ // "intros not broadcasting" incident.
16
+ //
17
+ // No amount of node-side correctness fixes this. The node is not wrong, it is
18
+ // BEHIND, and we ask faster than any index can update.
19
+ //
20
+ // THE FIX
21
+ // Record the spend locally AT PICK TIME, before the tx is even signed. The
22
+ // next pick reads this ledger, not the network, so the race has nowhere to
23
+ // happen. This replaces the old in-memory recentlySpent map, whose TTL was an
24
+ // arbitrary constant (10s in the client, 60s on the bot) tuned against a lag
25
+ // nobody had measured — and which was lost entirely on reload or restart.
26
+ //
27
+ // DIRECTIONALITY — this file may only ever REMOVE coins from the pool.
28
+ // Nothing enters the pool from here; the node decides what exists and what is
29
+ // confirmed. A stale flag costs one coin, which is recoverable. Resurrecting a
30
+ // coin the chain has already seen spent is a double-spend, which is not.
31
+ //
32
+ // The ONE way a flag is cleared is rebuildFromNode(), and only after a
33
+ // broadcast FAILED — see that function for why it must never run on success.
34
+ //
35
+ // STORAGE IS INJECTABLE AND MUST BE SYNCHRONOUS. Async storage (IndexedDB)
36
+ // would re-open the very window this closes: the write has to land before the
37
+ // next pick reads. Browser uses localStorage; the bot installs a file-backed
38
+ // store at boot, because its localStorage shim discards writes.
39
+
40
+ const KEY = 'keychat-spent-ledger'
41
+
42
+ const localStorageBackend = {
43
+ read () {
44
+ try { return JSON.parse(localStorage.getItem(KEY) || '{}') } catch { return {} }
45
+ },
46
+ write (m) {
47
+ try { localStorage.setItem(KEY, JSON.stringify(m)) } catch { /* quota — see prune note */ }
48
+ }
49
+ }
50
+
51
+ // A Web Worker has NO localStorage. The send worker imports sendMessage, so it
52
+ // reaches this module — and a backend that silently discards writes there would
53
+ // be WORSE than the in-memory map this replaced, which at least worked inside
54
+ // the worker's own session. So when there is no localStorage, fall back to
55
+ // memory rather than to nothing.
56
+ //
57
+ // The worker still echoes its spentOutpoints to the main thread, which mirrors
58
+ // them into the durable ledger (see workers/send-worker.js) — that is what
59
+ // makes a worker send visible to the next main-thread send. This fallback only
60
+ // has to cover picks made back-to-back INSIDE one worker.
61
+ function makeMemoryBackend () {
62
+ let store = {}
63
+ return { read: () => store, write: (m) => { store = m } }
64
+ }
65
+
66
+ function defaultBackend () {
67
+ try {
68
+ if (typeof localStorage === 'undefined') return makeMemoryBackend()
69
+ // Availability is not the same as usability — Safari private mode throws
70
+ // on setItem rather than on access. Probe once, at load.
71
+ const probe = '__kc_ledger_probe__'
72
+ localStorage.setItem(probe, '1')
73
+ localStorage.removeItem(probe)
74
+ return localStorageBackend
75
+ } catch {
76
+ return makeMemoryBackend()
77
+ }
78
+ }
79
+
80
+ let backend = defaultBackend()
81
+
82
+ /**
83
+ * Install a different synchronous store. The bot calls this with an fs-backed
84
+ * implementation before importing anything that spends. Must expose
85
+ * read(): object and write(object): void, both synchronous.
86
+ */
87
+ export function setLedgerBackend (b) { backend = b }
88
+
89
+ const op = (txid, vout) => `${txid}:${vout}`
90
+
91
+ /**
92
+ * Mark outpoints spent so no picker hands them out again. Call at PICK time,
93
+ * not after broadcast — the whole point is to beat the node, and a
94
+ * post-broadcast write is already too late for the very next send.
95
+ */
96
+ export function markSpentOutpoints (utxos) {
97
+ const m = backend.read()
98
+ const ts = Math.floor(Date.now() / 1000)
99
+ for (const u of utxos) m[op(u.txid, u.vout)] = ts
100
+ backend.write(m)
101
+ }
102
+
103
+ /** True if the ledger has recorded this outpoint as spent. */
104
+ export function isSpentOutpoint (txid, vout) {
105
+ return backend.read()[op(txid, vout)] !== undefined
106
+ }
107
+
108
+ /**
109
+ * Filter a node-supplied UTXO list down to what the ledger still considers
110
+ * unspent. One read for the whole list rather than per-entry.
111
+ */
112
+ export function withoutSpent (utxos) {
113
+ const m = backend.read()
114
+ // FAST PATH — nothing claimed means nothing to filter, so do no work at all.
115
+ // This runs on every getUnspent, which the balance poll calls every 5s, on
116
+ // the main thread, against a wallet that can hold tens of thousands of
117
+ // UTXOs. Building a key string per UTXO there stalls the tab; the ledger
118
+ // meanwhile is empty or holds a handful of in-flight outpoints. Cost must
119
+ // scale with the LEDGER, not the wallet.
120
+ if (isEmpty(m)) return utxos
121
+ return utxos.filter(u => m[op(u.txid, u.vout)] === undefined)
122
+ }
123
+
124
+ // Emptiness without allocating an Object.keys array.
125
+ function isEmpty (m) {
126
+ for (const _k in m) return false
127
+ return true
128
+ }
129
+
130
+ /**
131
+ * Drop entries the node no longer lists — they are confirmed spent, gone from
132
+ * /unspent forever, and their flags are dead weight. Runs on every load so the
133
+ * ledger stays the size of what is actually in flight (a handful of outpoints)
134
+ * rather than growing for the life of the wallet.
135
+ *
136
+ * SAFE ON THE SUCCESS PATH, unlike rebuildFromNode: this only ever forgets
137
+ * coins the node has already dropped, so it cannot hand a spent coin back to
138
+ * the picker. It never touches an entry the node still lists — that is exactly
139
+ * the in-flight case the ledger exists to remember.
140
+ *
141
+ * Writes only when something actually changed, so the common no-op load costs
142
+ * one read.
143
+ */
144
+ export function pruneConfirmed (nodeUtxos) {
145
+ const m = backend.read()
146
+ // Same reasoning as withoutSpent: with an empty ledger there is nothing to
147
+ // prune, and building a Set over every UTXO the wallet owns to discover that
148
+ // is the expensive way to do nothing.
149
+ if (isEmpty(m)) return 0
150
+ const live = new Set(nodeUtxos.map(u => op(u.txid, u.vout)))
151
+ let removed = 0
152
+ for (const k of Object.keys(m)) {
153
+ if (!live.has(k)) { delete m[k]; removed++ }
154
+ }
155
+ if (removed) backend.write(m)
156
+ return removed
157
+ }
158
+
159
+ /**
160
+ * Re-derive the ledger from a FRESH scan of the node's UTXO set.
161
+ *
162
+ * Anything the node still lists is un-marked; anything it no longer lists is
163
+ * dropped, since a confirmed spend is gone from /unspent forever and its flag
164
+ * is dead weight. So one call both releases the coins a failed send stranded
165
+ * AND keeps the ledger from growing over the wallet's lifetime.
166
+ *
167
+ * CALL THIS ONLY AFTER A BROADCAST FAILED. On the success path the node is
168
+ * still 10-65ms behind and WOULD list the inputs we just legitimately spent —
169
+ * rebuilding there hands them back to the picker and re-creates the exact
170
+ * double-spend this module exists to prevent. Failure is the only moment we
171
+ * know the node's view is the correct one.
172
+ *
173
+ * Clearing both cases is safe because the picker's pool IS the node's list and
174
+ * this ledger only subtracts from it: a coin the node has dropped cannot be
175
+ * picked whether or not we remember it. The one residual is a coin from an
176
+ * earlier SUCCESSFUL send the node has not caught up on — which is why this is
177
+ * failure-only, and why the failing broadcast's own round-trip (~86ms observed
178
+ * between paired sends, against 10-65ms of node lag) is what covers it.
179
+ *
180
+ * `nodeUtxos` must come from the node, not from any cache.
181
+ */
182
+ export function rebuildFromNode (nodeUtxos) {
183
+ const live = new Set(nodeUtxos.map(u => op(u.txid, u.vout)))
184
+ const m = backend.read()
185
+ let released = 0 // node still lists it -> our send never landed, give it back
186
+ let dropped = 0 // node no longer lists it -> confirmed spent, flag is dead
187
+ for (const k of Object.keys(m)) {
188
+ if (live.has(k)) released++; else dropped++
189
+ delete m[k]
190
+ }
191
+ backend.write(m)
192
+ return { released, dropped }
193
+ }
194
+
195
+ export function ledgerSize () { return Object.keys(backend.read()).length }