aboutsummaryrefslogtreecommitdiffstats
path: root/tools/sync-blob-test.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-09 23:06:35 -0700
committerAdam Soltys <[email protected]>2026-09-09 23:06:35 -0700
commit839ab937afbd0c68d1a4366e10145274b6f62d31 (patch)
tree5802fb44898db984840a7eee238a4ecc5007dcd2 /tools/sync-blob-test.js
parent95cd3fa81097e288e4920bb5029cfd23f7cae98f (diff)
downloadcoinosv3-839ab937afbd0c68d1a4366e10145274b6f62d31.tar.gz
coinosv3-839ab937afbd0c68d1a4366e10145274b6f62d31.zip
Sync: oversized domains ride Blossom blobs — plus a coinos-hosted server.
A wallet with deep Ark coins had a 633KB ark sync domain against the relay's 40KB budget: the publish silently failed forever, the slot stayed stale, and every boot retried. Relays can't carry it (64KB event cap) and neither can NIP-44 (64KB plaintext), so a domain over budget now goes to Blossom (nostr blob storage, BUD-01/02/06/11/12): sealed into an envelope ("CSB1" + xchacha20poly1305 under an HKDF of the wallet's NIP-44 self-key), uploaded to the configured servers, and the kind-30078 event carries only { blob: { sha256, size, servers } }. Readers resolve pointers from their own outbox copy first, else from the pointer's servers plus the configured list, hash-verified. Small domains are unchanged. The outbox uploads before it publishes (upload/delete authorizations are kind-24242 events signed at enqueue time, so delivery after logout still holds no key), keeps a local copy for recovery, and deletes superseded blobs once the replacement is acknowledged. Servers: Settings → Nostr → Sync storage (any Blossom server; a typed list is kept), else the user's BUD-03 list (kind 10063) with the coinos server as mirror, else blossom.coinos.io. blossom/server.js is that default: a Bun Blossom server that stores ONLY these envelopes (magic check → 415 for anything else), serves every blob as an octet-stream attachment with nosniff, requires signed auth for upload/list/delete, and enforces per-blob size, per-key quota (oldest evicted), rate limits, a disk cap and unused-blob expiry — it cannot serve as a file host. tools/sync-blob-test.js spawns it and covers the policy, the outbox flow and a fresh device resolving a pointer. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
Diffstat (limited to 'tools/sync-blob-test.js')
-rw-r--r--tools/sync-blob-test.js188
1 files changed, 188 insertions, 0 deletions
diff --git a/tools/sync-blob-test.js b/tools/sync-blob-test.js
new file mode 100644
index 0000000..ee1c60a
--- /dev/null
+++ b/tools/sync-blob-test.js
@@ -0,0 +1,188 @@
+// Oversized sync domains on Blossom: the envelope, the server's policy, the
+// outbox's upload-then-publish flow, and a fresh device resolving a pointer.
+// Spawns blossom/server.js on a free port. Run: bun tools/sync-blob-test.js
+import assert from 'node:assert/strict';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { createHash } from 'node:crypto';
+import { SyncOutbox } from '../src/sync-outbox.js';
+import { NostrSync } from '../src/nostr.js';
+import { installSyncWallet } from '../src/features/sync.js';
+import { mergeArkStates } from '../src/features/ark.js';
+import {
+ sealBlob, openBlob, sha256Hex, blobAuth, authHeader, uploadBlob, deleteBlob, fetchBlob, setBlossomConfig, blossomServers,
+} from '../src/sync-blob.js';
+
+class Storage {
+ data = new Map();
+ get length() { return this.data.size; }
+ key(i) { return [...this.data.keys()][i] ?? null; }
+ getItem(k) { return this.data.get(k) ?? null; }
+ setItem(k, v) { this.data.set(k, String(v)); }
+ removeItem(k) { this.data.delete(k); }
+}
+globalThis.localStorage = new Storage();
+const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
+const signer = (passphrase = '') => { const n = new NostrSync(); n.load(mnemonic, passphrase); return n; };
+const a = signer(), b = signer('someone else');
+
+// ---- the server ------------------------------------------------------------
+const port = 20000 + Math.floor(Math.random() * 10000);
+const dataDir = mkdtempSync(join(tmpdir(), 'blossom-test-'));
+const server = Bun.spawn(['bun', new URL('../blossom/server.js', import.meta.url).pathname], {
+ env: { ...process.env, BLOSSOM_DATA: dataDir, BLOSSOM_PORT: String(port), BLOSSOM_HOST: 'localhost', BLOSSOM_QUOTA_COUNT: '2', BLOSSOM_MAX_BLOB: String(200_000) },
+ stdout: 'ignore', stderr: 'inherit',
+});
+const base = `http://localhost:${port}`;
+for (let i = 0; i < 50; i++) { try { if ((await fetch(base + '/')).ok) break; } catch {} await Bun.sleep(100); }
+const cleanupServer = () => { try { server.kill(); } catch {} rmSync(dataDir, { recursive: true, force: true }); };
+process.on('exit', cleanupServer);
+
+try {
+ // ---- envelope -------------------------------------------------------------
+ const big = { netName: 'mainnet', savedAt: 1, arkState: { vtxos: Array.from({ length: 400 }, (_, i) => ({ id: 'v' + i, amountSat: i, state: 'spendable', bytes: 'ab'.repeat(80) })) } };
+ const text = JSON.stringify(big);
+ assert.ok(text.length > 40000, 'fixture must exceed the relay budget');
+ const sealed = sealBlob(a.ck, text);
+ assert.equal(new TextDecoder().decode(sealed.subarray(0, 4)), 'CSB1');
+ assert.equal(openBlob(a.ck, sealed), text);
+ assert.throws(() => openBlob(b.ck, sealed), 'another wallet cannot open it');
+ assert.throws(() => openBlob(a.ck, new Uint8Array(sealed.length)), 'no magic, no envelope');
+ assert.ok(!sealed.includes || !new TextDecoder().decode(sealed).includes('spendable'));
+ console.log('✓ envelope seals to the wallet self-key and refuses other keys');
+
+ // ---- server policy ----------------------------------------------------------
+ const sha = sha256Hex(sealed);
+ let r = await fetch(base + '/upload', { method: 'PUT', body: sealed });
+ assert.equal(r.status, 401, 'no auth, no upload');
+ r = await fetch(base + '/upload', { method: 'PUT', body: sealed, headers: { Authorization: authHeader(blobAuth(a.sk, 'delete', [sha])) } });
+ assert.equal(r.status, 401, 'wrong verb');
+ r = await fetch(base + '/upload', { method: 'PUT', body: sealed, headers: { Authorization: authHeader(blobAuth(a.sk, 'upload', ['0'.repeat(64)])) } });
+ assert.equal(r.status, 401, 'auth must name the blob');
+ const pngish = new Uint8Array(5000); pngish.set([0x89, 0x50, 0x4e, 0x47]);
+ r = await fetch(base + '/upload', { method: 'PUT', body: pngish, headers: { Authorization: authHeader(blobAuth(a.sk, 'upload', [sha256Hex(pngish)])) } });
+ assert.equal(r.status, 415, 'an image is refused even with valid auth');
+ r = await fetch(base + '/upload', { method: 'PUT', body: sealed, headers: { Authorization: authHeader(blobAuth(a.sk, 'upload', [sha])), 'Content-Type': 'image/png' } });
+ assert.equal(r.status, 415, 'a media content-type is refused');
+ r = await fetch(base + '/upload', { method: 'HEAD', headers: { Authorization: authHeader(blobAuth(a.sk, 'upload', [sha])), 'X-SHA-256': sha, 'X-Content-Length': String(sealed.length), 'X-Content-Type': 'application/octet-stream' } });
+ assert.equal(r.status, 200, 'BUD-06 requirements check');
+ assert.equal(await uploadBlob(base, sealed, blobAuth(a.sk, 'upload', [sha])), true);
+ r = await fetch(`${base}/${sha}`);
+ assert.equal(r.status, 200);
+ assert.equal(r.headers.get('content-type'), 'application/octet-stream');
+ assert.equal(r.headers.get('x-content-type-options'), 'nosniff');
+ assert.match(r.headers.get('content-disposition') || '', /attachment/);
+ assert.equal(sha256Hex(new Uint8Array(await r.arrayBuffer())), sha);
+ const got = await fetchBlob(['http://localhost:1', base], sha);
+ assert.equal(openBlob(a.ck, got), text, 'fetch skips a dead server and verifies the hash');
+ r = await fetch(`${base}/list/${a.pk}`);
+ assert.equal(r.status, 401, 'listing needs auth');
+ r = await fetch(`${base}/list/${a.pk}`, { headers: { Authorization: authHeader(blobAuth(b.sk, 'list')) } });
+ assert.equal(r.status, 403, 'only the owner lists their blobs');
+ r = await fetch(`${base}/list/${a.pk}`, { headers: { Authorization: authHeader(blobAuth(a.sk, 'list')) } });
+ assert.deepEqual((await r.json()).map((d) => d.sha256), [sha]);
+ assert.equal(await deleteBlob(base, sha, blobAuth(b.sk, 'delete', [sha])), false, 'not yours to delete');
+ assert.equal((await fetch(`${base}/${sha}`)).status, 200);
+ console.log('✓ server stores only signed sync envelopes and serves them as opaque attachments');
+
+ // quota: the third blob evicts the oldest
+ const shas = [];
+ for (let i = 0; i < 3; i++) {
+ const bytes = sealBlob(a.ck, text + i); const h = sha256Hex(bytes); shas.push(h);
+ assert.equal(await uploadBlob(base, bytes, blobAuth(a.sk, 'upload', [h])), true);
+ }
+ assert.equal((await fetch(`${base}/${sha}`)).status, 404, 'oldest evicted under the per-key count quota');
+ assert.equal((await fetch(`${base}/${shas[2]}`)).status, 200);
+ assert.equal(await deleteBlob(base, shas[2], blobAuth(a.sk, 'delete', [shas[2]])), true);
+ assert.equal((await fetch(`${base}/${shas[2]}`)).status, 404);
+ console.log('✓ per-key quota evicts oldest; owner delete works');
+
+ // ---- outbox: upload before publish, cleanup after ---------------------------
+ setBlossomConfig({ servers: [base], manual: true });
+ assert.deepEqual(blossomServers(), [base]);
+ const storage = new Storage();
+ let now = 1788758000000;
+ const sent = [];
+ const deps = { storage, now: () => now, schedule: () => 0, cancel: () => {}, warn: () => {},
+ send: async (e) => { sent.push(e); return true; }, upload: uploadBlob, remove: deleteBlob };
+ const dead = new SyncOutbox({ ...deps, upload: async () => false });
+ const bytes1 = sealBlob(a.ck, text); const h1 = sha256Hex(bytes1);
+ const enqueueBlob = (box, bytes, h, savedAt) => box.enqueue({
+ pubkey: a.pk, dtag: 'bitcoin-wallet:x:test:ark', digest: createHash('sha256').update(String(savedAt)).digest('hex'), relays: ['wss://test.invalid'],
+ blob: { sha256: h, size: bytes.length, data: Buffer.from(bytes).toString('base64'), servers: [base], auth: blobAuth(a.sk, 'upload', [h]), deleteAuthFor: (old) => blobAuth(a.sk, 'delete', [old]) },
+ sign: (ts) => a.stateEvent({ netName: 'mainnet', savedAt, blob: { v: 1, sha256: h, size: bytes.length, servers: [base] } }, 'bitcoin-wallet:x:test:ark', ts),
+ });
+ enqueueBlob(dead, bytes1, h1, 1);
+ await dead.flush();
+ assert.equal(sent.length, 0, 'pointer is not published while the blob has no home');
+ assert.equal(dead.records()[0].acknowledged, false);
+ const live = new SyncOutbox(deps);
+ await live.flush();
+ assert.equal(sent.length, 1);
+ assert.deepEqual(live.records()[0].uploaded, [base]);
+ assert.equal(live.records()[0].acknowledged, true);
+ assert.equal((await fetch(`${base}/${h1}`)).status, 200);
+ assert.equal(sha256Hex(live.localBlob(h1)), h1, 'local recovery copy');
+ const raw = [...storage.data.values()].join('');
+ assert.ok(!raw.includes('spendable') && !raw.includes(mnemonic), 'storage holds only sealed bytes');
+ // a newer blob: the old one is deleted from its server after the new one is acknowledged
+ now += 2000;
+ const bytes2 = sealBlob(a.ck, text + '2'); const h2 = sha256Hex(bytes2);
+ enqueueBlob(live, bytes2, h2, 2);
+ assert.equal(live.records()[0].cleanup.length, 1);
+ await live.flush();
+ assert.equal(sent.length, 2);
+ assert.equal((await fetch(`${base}/${h2}`)).status, 200);
+ assert.equal((await fetch(`${base}/${h1}`)).status, 404, 'superseded blob removed');
+ assert.equal(live.records()[0].cleanup.length, 0);
+ console.log('✓ outbox uploads before publishing, keeps a local copy, cleans up superseded blobs');
+
+ // ---- feature: oversized domain becomes a pointer; a fresh device resolves it ----
+ function walletStub() {
+ const hooks = { saved: [], realtime: [] };
+ return {
+ hooks, mnemonic, offline: false, netName: 'mainnet', _savedAt: now, txs: [], _cacheExtensions: [],
+ registerCacheSavedHook: (fn) => hooks.saved.push(fn), registerLoadHook: () => {}, registerRealtimeHook: (h) => hooks.realtime.push(h),
+ saveCache: () => {}, emit: () => {}, _sortTxs: () => {},
+ _mergeSnapshotExtensions(s) { if (s.arkState) this.arkState = mergeArkStates(this.arkState, s.arkState); },
+ _applySnapshot: () => {},
+ };
+ }
+ const fStorage = new Storage();
+ const fBox = new SyncOutbox({ ...deps, storage: fStorage });
+ const w = walletStub();
+ installSyncWallet(w, { outbox: fBox });
+ w.nostr.load(mnemonic);
+ w._cacheExtensions = [{ domain: 'ark', save: () => ({ arkState: big.arkState, arkServer: 'asp' }) }];
+ w.hooks.saved[0]({ netName: 'mainnet', savedAt: now, txs: [], utxos: [] });
+ const evs = fBox.events(a.pk);
+ const ptr = a.decodeStateEvents(evs).find((s) => s.dtag.endsWith(':ark'));
+ assert.ok(ptr.state.blob && ptr.state.blob.sha256, 'ark domain publishes as a pointer');
+ assert.ok(!ptr.state.arkState, 'no plaintext domain in the event');
+ const core = a.decodeStateEvents(evs).find((s) => s.dtag.endsWith(':core'));
+ assert.ok(!core.state.blob, 'small domains stay inline');
+ await fBox.flush();
+ assert.equal((await fetch(`${base}/${ptr.state.blob.sha256}`)).status, 200);
+ // a fresh device with an empty outbox: only the relay's pointer event to go on
+ const fresh = walletStub();
+ installSyncWallet(fresh, { outbox: new SyncOutbox({ ...deps, storage: new Storage() }) });
+ fresh.nostr.load(mnemonic);
+ fresh.nostr.fetchAllStates = async () => a.decodeStateEvents(evs);
+ assert.equal(await fresh.syncFromNostr(), true);
+ assert.equal(fresh.arkState.vtxos.length, 400, 'pointer resolved from the server and merged');
+ // an unreachable server: the pointer is skipped, nothing breaks
+ const cut = walletStub();
+ installSyncWallet(cut, { outbox: new SyncOutbox({ ...deps, storage: new Storage() }) });
+ cut.nostr.load(mnemonic);
+ const withDeadServer = a.decodeStateEvents(evs).map((s) => s.state.blob ? { ...s, state: { ...s.state, blob: { ...s.state.blob, servers: ['http://localhost:1'] } } } : s);
+ setBlossomConfig({ servers: ['http://localhost:1'], manual: true });
+ cut.nostr.fetchAllStates = async () => withDeadServer;
+ assert.equal(await cut.syncFromNostr(), true);
+ assert.equal(cut.arkState, undefined, 'unresolvable pointer is dropped, not applied');
+ console.log('✓ feature routes oversized domains through Blossom; fresh devices resolve pointers');
+
+ console.log('\n✅ blossom sync behaves');
+} finally {
+ cleanupServer();
+}