diff options
| author | Adam Soltys <[email protected]> | 2026-09-09 23:06:35 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-09 23:06:35 -0700 |
| commit | 839ab937afbd0c68d1a4366e10145274b6f62d31 (patch) | |
| tree | 5802fb44898db984840a7eee238a4ecc5007dcd2 | |
| parent | 95cd3fa81097e288e4920bb5029cfd23f7cae98f (diff) | |
| download | coinosv3-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
| -rw-r--r-- | blossom/compose-blossom.yml | 23 | ||||
| -rw-r--r-- | blossom/server.js | 281 | ||||
| -rw-r--r-- | src/features/sync.js | 113 | ||||
| -rw-r--r-- | src/i18n.js | 7 | ||||
| -rw-r--r-- | src/sync-blob.js | 118 | ||||
| -rw-r--r-- | src/sync-outbox.js | 70 | ||||
| -rw-r--r-- | tools/sync-blob-test.js | 188 |
7 files changed, 786 insertions, 14 deletions
diff --git a/blossom/compose-blossom.yml b/blossom/compose-blossom.yml new file mode 100644 index 0000000..28acbad --- /dev/null +++ b/blossom/compose-blossom.yml @@ -0,0 +1,23 @@ +# blossom.coinos.io — Blossom blob server for oversized wallet-sync domains. +# Stores nothing but the wallet's encrypted sync envelopes (see server.js). +# Lives beside the names registrar: ~/arkswap is a coinosv3 checkout on cs. +services: + blossom: + image: oven/bun:1 + container_name: blossom + working_dir: /app + command: bun blossom/server.js + environment: + BLOSSOM_DATA: /data + BLOSSOM_PORT: "8796" + BLOSSOM_HOST: blossom.coinos.io + volumes: + - /home/adam/arkswap:/app + - /home/adam/arkswap/data-blossom:/data + ports: + - 127.0.0.1:8796:8796 + networks: [net] + restart: unless-stopped +networks: + net: + external: true diff --git a/blossom/server.js b/blossom/server.js new file mode 100644 index 0000000..9d3d631 --- /dev/null +++ b/blossom/server.js @@ -0,0 +1,281 @@ +// blossom.coinos.io — a Blossom (BUD-01/02/06/12) blob server that stores +// ONE kind of thing: encrypted wallet-sync envelopes. The wallet keeps its +// cross-device state in nostr replaceable events, but a deep Ark coin set +// outgrows a relay's event cap; those domains go here as an encrypted blob +// and the nostr event carries only its hash. Any Blossom server can hold the +// blob (content-addressed), so users can self-host or pick another provider. +// +// Abuse policy — this must never become a file host: +// * uploads, listing and deletion need a signed kind-24242 event (BUD-11) +// * a blob is stored only if it starts with the wallet's envelope magic +// ("CSB1" + 24-byte nonce): everything else is 415, so images, video and +// arbitrary files are refused at the door +// * blobs are always served as application/octet-stream, as an attachment, +// nosniff — nothing here renders in a browser or embeds anywhere +// * per-blob size cap, per-pubkey quota (bytes and count, oldest evicted), +// per-IP/pubkey rate limits, a global disk cap, and unused-blob expiry +// +// Run: bun blossom/server.js (env: BLOSSOM_DATA, BLOSSOM_PORT, BLOSSOM_HOST, +// BLOSSOM_MAX_BLOB, BLOSSOM_QUOTA_BYTES, BLOSSOM_QUOTA_COUNT, +// BLOSSOM_DISK_CAP, BLOSSOM_TTL_DAYS) + +import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, unlinkSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { verifyEvent } from 'nostr-tools/pure'; + +const DATA = process.env.BLOSSOM_DATA || join(import.meta.dir, 'data'); +const PORT = Number(process.env.BLOSSOM_PORT || 8796); +const HOST = process.env.BLOSSOM_HOST || 'blossom.coinos.io'; +const PUBLIC_BASE = process.env.BLOSSOM_PUBLIC_BASE || `https://${HOST}`; +const MAX_BLOB = Number(process.env.BLOSSOM_MAX_BLOB || 8 * 1024 * 1024); // 8 MB per blob +const QUOTA_BYTES = Number(process.env.BLOSSOM_QUOTA_BYTES || 32 * 1024 * 1024); // 32 MB per pubkey +const QUOTA_COUNT = Number(process.env.BLOSSOM_QUOTA_COUNT || 24); // blobs per pubkey +const DISK_CAP = Number(process.env.BLOSSOM_DISK_CAP || 40 * 1024 * 1024 * 1024); // 40 GB total +const TTL_DAYS = Number(process.env.BLOSSOM_TTL_DAYS || 365); // unused-blob expiry +const MAGIC = new TextEncoder().encode('CSB1'); +const ENVELOPE_MIN = MAGIC.length + 24 + 16; // magic + nonce + poly1305 tag + +const log = (...a) => console.log(new Date().toISOString(), ...a); + +// --------------------------------------------------------------------------- +// storage: blobs/<sha256> on disk, a small JSON index for ownership + times +// --------------------------------------------------------------------------- +const BLOBS = join(DATA, 'blobs'); +const INDEX = join(DATA, 'index.json'); +mkdirSync(BLOBS, { recursive: true }); +let index = (() => { + try { return JSON.parse(readFileSync(INDEX, 'utf8')); } catch { return { blobs: {} }; } +})(); +// index.blobs[sha] = { size, uploaded, touched, owners: { [pubkey]: uploadedAt } } +let persistTimer = null; +function persist() { + clearTimeout(persistTimer); + persistTimer = setTimeout(() => { + const tmp = INDEX + '.tmp'; + writeFileSync(tmp, JSON.stringify(index)); + renameSync(tmp, INDEX); + }, 200); +} +const blobPath = (sha) => join(BLOBS, sha); +const totalBytes = () => Object.values(index.blobs).reduce((n, b) => n + b.size, 0); +const ownedBy = (pk) => Object.entries(index.blobs).filter(([, b]) => b.owners[pk]).map(([sha, b]) => ({ sha, ...b })); + +function descriptor(sha, b) { + return { url: `${PUBLIC_BASE}/${sha}`, sha256: sha, size: b.size, type: 'application/octet-stream', uploaded: Math.floor(b.uploaded / 1000) }; +} +function dropBlob(sha) { + delete index.blobs[sha]; + try { unlinkSync(blobPath(sha)); } catch {} +} +// Remove a pubkey's claim; the file goes when nobody claims it. +function disown(sha, pk) { + const b = index.blobs[sha]; + if (!b) return; + delete b.owners[pk]; + if (!Object.keys(b.owners).length) dropBlob(sha); +} +// Quota: a pubkey keeps at most QUOTA_COUNT blobs / QUOTA_BYTES — the OLDEST +// go first, so a client that forgets to delete superseded snapshots keeps +// working (the newest state is what sync needs). +function enforceQuota(pk, incomingSize) { + const mine = ownedBy(pk).sort((a, b) => a.owners[pk] - b.owners[pk]); + let bytes = mine.reduce((n, b) => n + b.size, 0) + incomingSize; + let count = mine.length + 1; + for (const b of mine) { + if (count <= QUOTA_COUNT && bytes <= QUOTA_BYTES) break; + disown(b.sha, pk); + bytes -= b.size; count--; + } + return count <= QUOTA_COUNT && bytes <= QUOTA_BYTES; +} +// Blobs nobody fetched or re-uploaded in TTL_DAYS are abandoned wallets' +// snapshots (or a client that moved on) — sweep them. +function sweep() { + const cutoff = Date.now() - TTL_DAYS * 86400_000; + let n = 0; + for (const [sha, b] of Object.entries(index.blobs)) if ((b.touched || b.uploaded) < cutoff) { dropBlob(sha); n++; } + if (n) { log('swept', n, 'expired blobs'); persist(); } +} +setInterval(sweep, 6 * 3600_000); +sweep(); + +// --------------------------------------------------------------------------- +// auth (BUD-11): kind 24242, t=verb, expiration in the future, x=sha when the +// endpoint names a blob, optional server tags naming us +// --------------------------------------------------------------------------- +function checkAuth(req, verb, sha = null) { + const h = req.headers.get('authorization') || ''; + const m = h.match(/^Nostr\s+(.+)$/i); + if (!m) return { error: 'missing Nostr authorization' }; + let evt; + try { evt = JSON.parse(Buffer.from(m[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch { return { error: 'bad auth encoding' }; } + if (!evt || evt.kind !== 24242) return { error: 'auth must be kind 24242' }; + const now = Math.floor(Date.now() / 1000); + if (!(evt.created_at <= now + 60)) return { error: 'auth created in the future' }; + const tags = Array.isArray(evt.tags) ? evt.tags : []; + const tag = (k) => tags.filter((x) => x[0] === k).map((x) => x[1]); + const exp = Number(tag('expiration')[0]); + if (!exp || exp <= now) return { error: 'auth expired' }; + if (!tag('t').includes(verb)) return { error: `auth is not for ${verb}` }; + const servers = tag('server'); + if (servers.length && !servers.some((s) => String(s).toLowerCase() === HOST)) return { error: 'auth is for another server' }; + if (sha && !tag('x').includes(sha)) return { error: 'auth does not name this blob' }; + if (!verifyEvent(evt)) return { error: 'bad signature' }; + return { pubkey: evt.pubkey }; +} + +// --------------------------------------------------------------------------- +// rate limits: per IP and per pubkey, sliding minute +// --------------------------------------------------------------------------- +const rate = new Map(); +function rateOk(key, limit) { + const now = Date.now(); + const arr = (rate.get(key) || []).filter((t) => t > now - 60_000); + if (arr.length >= limit) return false; + arr.push(now); + rate.set(key, arr); + if (rate.size > 5000) for (const [k, v] of rate) { if (!v.some((t) => t > now - 60_000)) rate.delete(k); } + return true; +} + +// --------------------------------------------------------------------------- +// http +// --------------------------------------------------------------------------- +const CORS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-SHA-256, X-Content-Length, X-Content-Type, *', + 'Access-Control-Allow-Methods': 'GET, HEAD, PUT, DELETE, OPTIONS', + 'Access-Control-Expose-Headers': 'X-Reason, Content-Length', + 'Access-Control-Max-Age': '86400', +}; +const json = (obj, status = 200, extra = {}) => + new Response(JSON.stringify(obj), { status, headers: { 'content-type': 'application/json', ...CORS, ...extra } }); +const fail = (status, reason) => json({ message: reason }, status, { 'X-Reason': reason }); +const SHA_RE = /^\/([0-9a-f]{64})(\.[a-z0-9]+)?$/; + +// Envelope check: our client's sealed snapshot, nothing else. +function envelopeOk(head, size) { + if (size < ENVELOPE_MIN || size > MAX_BLOB) return false; + for (let i = 0; i < MAGIC.length; i++) if (head[i] !== MAGIC[i]) return false; + return true; +} +// BUD-06 style policy check shared by HEAD /upload and PUT /upload. +function uploadPolicy(size, head, pk) { + if (!Number.isFinite(size) || size <= 0) return fail(411, 'X-Content-Length required'); + if (size > MAX_BLOB) return fail(413, `blob exceeds ${MAX_BLOB} bytes`); + if (head && !envelopeOk(head, size)) return fail(415, 'only coinos wallet sync envelopes are stored here'); + if (totalBytes() + size > DISK_CAP) return fail(503, 'storage full'); + if (pk && !enforceQuota(pk, size)) return fail(413, 'per-key quota exceeded'); + return null; +} + +Bun.serve({ + port: PORT, + maxRequestBodySize: MAX_BLOB + 1024, + async fetch(req) { + const url = new URL(req.url); + const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local'; + if (req.method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS }); + + if (url.pathname === '/' && req.method === 'GET') { + return json({ name: HOST, about: 'coinos wallet sync storage (Blossom). Stores encrypted wallet snapshots only.', + maxBlob: MAX_BLOB, quotaBytes: QUOTA_BYTES, quotaCount: QUOTA_COUNT, blobs: Object.keys(index.blobs).length }); + } + + // ---- upload requirements (BUD-06) ---- + if (url.pathname === '/upload' && req.method === 'HEAD') { + if (!rateOk('ip:' + ip, 120)) return fail(429, 'rate limited'); + const sha = (req.headers.get('x-sha-256') || '').toLowerCase(); + const a = checkAuth(req, 'upload', /^[0-9a-f]{64}$/.test(sha) ? sha : null); + if (a.error) return fail(401, a.error); + const size = Number(req.headers.get('x-content-length')); + if (!Number.isFinite(size) || size <= 0) return fail(411, 'X-Content-Length required'); + if (size < ENVELOPE_MIN) return fail(415, 'only coinos wallet sync envelopes are stored here'); + const type = (req.headers.get('x-content-type') || '').split(';')[0].trim(); + if (type && type !== 'application/octet-stream') return fail(415, 'only application/octet-stream sync envelopes are stored here'); + // quota is not checked here: PUT evicts the key's oldest blob to make room + const bad = uploadPolicy(size, null, null); + if (bad) return bad; + return new Response(null, { status: 200, headers: CORS }); + } + + // ---- upload (BUD-02) ---- + if (url.pathname === '/upload' && req.method === 'PUT') { + if (!rateOk('ip:' + ip, 60)) return fail(429, 'rate limited'); + const bytes = new Uint8Array(await req.arrayBuffer()); + const sha = createHash('sha256').update(bytes).digest('hex'); + const a = checkAuth(req, 'upload', sha); + if (a.error) return fail(401, a.error); + if (!rateOk('pk:' + a.pubkey, 30)) return fail(429, 'rate limited'); + const want = (req.headers.get('x-sha-256') || '').toLowerCase(); + if (want && want !== sha) return fail(409, 'X-SHA-256 does not match the body'); + const type = (req.headers.get('content-type') || '').split(';')[0].trim(); + if (type && type !== 'application/octet-stream') return fail(415, 'only application/octet-stream sync envelopes are stored here'); + const existing = index.blobs[sha]; + const bad = uploadPolicy(bytes.length, bytes.subarray(0, MAGIC.length), existing?.owners[a.pubkey] ? null : a.pubkey); + if (bad) return bad; + const now = Date.now(); + if (!existing) { + writeFileSync(blobPath(sha), bytes); + index.blobs[sha] = { size: bytes.length, uploaded: now, touched: now, owners: { [a.pubkey]: now } }; + } else { + existing.touched = now; + existing.owners[a.pubkey] = existing.owners[a.pubkey] || now; + } + persist(); + log('upload', sha.slice(0, 12), bytes.length, 'b', a.pubkey.slice(0, 8), existing ? '(exists)' : ''); + return json(descriptor(sha, index.blobs[sha]), existing ? 200 : 201); + } + + // ---- list (BUD-12): only the owner sees their own blobs ---- + const lm = url.pathname.match(/^\/list\/([0-9a-f]{64})$/); + if (lm && req.method === 'GET') { + if (!rateOk('ip:' + ip, 120)) return fail(429, 'rate limited'); + const a = checkAuth(req, 'list'); + if (a.error) return fail(401, a.error); + if (a.pubkey !== lm[1]) return fail(403, 'you can only list your own blobs'); + const since = Number(url.searchParams.get('since')) || 0, until = Number(url.searchParams.get('until')) || Infinity; + const list = ownedBy(a.pubkey) + .map((b) => descriptor(b.sha, b)) + .filter((d) => d.uploaded >= since && d.uploaded <= until) + .sort((x, y) => y.uploaded - x.uploaded); + return json(list); + } + + // ---- fetch / delete a blob ---- + const bm = url.pathname.match(SHA_RE); + if (bm) { + const sha = bm[1]; + const b = index.blobs[sha]; + if (req.method === 'DELETE') { + if (!rateOk('ip:' + ip, 120)) return fail(429, 'rate limited'); + const a = checkAuth(req, 'delete', sha); + if (a.error) return fail(401, a.error); + if (!b) return fail(404, 'no such blob'); + if (!b.owners[a.pubkey]) return fail(403, 'not your blob'); + disown(sha, a.pubkey); + persist(); + log('delete', sha.slice(0, 12), a.pubkey.slice(0, 8)); + return new Response(null, { status: 204, headers: CORS }); + } + if (req.method === 'GET' || req.method === 'HEAD') { + if (!rateOk('ip:' + ip, 300)) return fail(429, 'rate limited'); + if (!b || !existsSync(blobPath(sha))) return fail(404, 'no such blob'); + b.touched = Date.now(); persist(); + const headers = { + ...CORS, + 'content-type': 'application/octet-stream', + 'content-length': String(b.size), + 'content-disposition': `attachment; filename="${sha}.bin"`, + 'x-content-type-options': 'nosniff', + 'cache-control': 'private, max-age=31536000, immutable', + }; + if (req.method === 'HEAD') return new Response(null, { status: 200, headers }); + return new Response(Bun.file(blobPath(sha)), { status: 200, headers }); + } + } + return fail(404, 'not found'); + }, +}); +log(`blossom ${HOST} on :${PORT} — ${Object.keys(index.blobs).length} blobs, ${(totalBytes() / 1e6).toFixed(1)} MB, data ${DATA}`); diff --git a/src/features/sync.js b/src/features/sync.js index 702b93a..a800ef8 100644 --- a/src/features/sync.js +++ b/src/features/sync.js @@ -12,13 +12,23 @@ import { import { t } from '../i18n.js'; import { sha256 } from '@noble/hashes/sha256'; import { bytesToHex } from '@noble/hashes/utils'; +import { base64 } from '@scure/base'; import { SyncOutbox } from '../sync-outbox.js'; +import { + sealBlob, openBlob, sha256Hex, blobAuth, uploadBlob, deleteBlob, fetchBlob, + blossomServers, getBlossomConfig, setBlossomConfig, DEFAULT_BLOSSOM_SERVERS, +} from '../sync-blob.js'; let sharedOutbox; function syncOutbox() { if (sharedOutbox) return sharedOutbox; if (typeof localStorage === 'undefined') return null; - sharedOutbox = new SyncOutbox({ storage: localStorage, send: (event, relays) => publishOn(relays, event) }); + sharedOutbox = new SyncOutbox({ + storage: localStorage, + send: (event, relays) => publishOn(relays, event), + upload: (server, bytes, auth) => uploadBlob(server, bytes, auth), + remove: (server, sha, auth) => deleteBlob(server, sha, auth), + }); if (typeof window !== 'undefined' && window.addEventListener) { window.addEventListener('online', () => sharedOutbox.wake()); window.addEventListener('pageshow', () => sharedOutbox.wake()); @@ -77,6 +87,35 @@ export function installSyncWallet(wallet, { outbox = syncOutbox() } = {}) { if (wallet.syncFromNostr) return; // already installed wallet.nostr = new NostrSync(); + // A pointer state ({ blob: { sha256, servers } }) stands in for a domain + // that lives on Blossom. Resolve it: our own outbox copy first (no network), + // else the servers the pointer names plus the configured ones, verified by + // hash and opened with the same self-key. Unresolvable pointers are dropped + // — a stale local copy beats applying nothing, and the next boot retries. + const blobCache = new Map(); // sha256 -> parsed state (a session's worth) + async function resolveStates(list) { + const out = []; + for (const s of list) { + const b = s.state && s.state.blob; + if (!b) { out.push(s); continue; } + if (!/^[0-9a-f]{64}$/.test(b.sha256 || '')) continue; + let state = blobCache.get(b.sha256); + if (!state) { + let bytes = outbox ? outbox.localBlob(b.sha256) : null; + if (!bytes && !wallet.offline) { + bytes = await fetchBlob([...new Set([...(Array.isArray(b.servers) ? b.servers : []), ...blossomServers()])], b.sha256); + } + if (!bytes || !wallet.nostr.ck) continue; + try { state = JSON.parse(openBlob(wallet.nostr.ck, bytes)); } catch { continue; } + if (!state) continue; + blobCache.set(b.sha256, state); + if (blobCache.size > 40) blobCache.delete(blobCache.keys().next().value); + } + out.push({ ...s, state: { netName: s.state.netName, savedAt: s.state.savedAt, ...state } }); + } + return out; + } + Object.assign(wallet, { // Our nostr identity (used to DM a locked gift's claim code to the recipient). nostrPubkey() { return (this.nostr && this.nostr.pk) || null; }, @@ -152,8 +191,9 @@ export function installSyncWallet(wallet, { outbox = syncOutbox() } = {}) { if (this.nostr.pk !== identity || this.netName !== network) return false; // A failed publish is still a recoverable encrypted local snapshot. // Merge it even if the relay is down or holds only the old deposits. - all = [...all, ...this.nostr.decodeStateEvents(outbox?.events(identity) || [])] - .sort((a, b) => b.created_at - a.created_at); + all = [...all, ...this.nostr.decodeStateEvents(outbox?.events(identity) || [])]; + all = (await resolveStates(all)).sort((a, b) => b.created_at - a.created_at); + if (this.nostr.pk !== identity || this.netName !== network) return false; // Each device publishes to its OWN slot now, so there can be several // snapshots for this network. Keep only ours-for-this-network: the // netName inside is the real guard against cross-network bleed (legacy @@ -217,8 +257,18 @@ export function installSyncWallet(wallet, { outbox = syncOutbox() } = {}) { if (login && /^[0-9a-f]{64}$/.test(login.pubkey || '')) pks.push(login.pubkey); } catch {} pks.push(wallet.nostr.pk); - const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DEFAULT_SYNC_RELAYS])], { kinds: [10002], authors: pks }, 3500); - const newestFor = (pk) => evs.filter((e) => e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0]; + const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DEFAULT_SYNC_RELAYS])], { kinds: [10002, 10063], authors: pks }, 3500); + const newestFor = (pk, kind = 10002) => evs.filter((e) => e.pubkey === pk && e.kind === kind).sort((a, b) => b.created_at - a.created_at)[0]; + // The user's own Blossom server list (BUD-03) leads for oversized sync + // domains, the coinos server stays as a mirror — unless they typed a list + // in Settings, which is theirs to keep. + if (!getBlossomConfig().manual) { + for (const pk of pks) { + const e = newestFor(pk, 10063); + const servers = e && e.tags.filter((x) => x[0] === 'server' && x[1]).map((x) => x[1]); + if (servers && servers.length) { setBlossomConfig({ servers: [...servers, ...DEFAULT_BLOSSOM_SERVERS], manual: false }); break; } + } + } const writeRelays = (e) => e.tags .filter((x) => x[0] === 'r' && x[1] && (!x[2] || x[2] === 'write')) .map((x) => x[1].trim()).filter((r) => /^wss?:\/\//.test(r)).slice(0, 4); @@ -255,7 +305,11 @@ export function installSyncWallet(wallet, { outbox = syncOutbox() } = {}) { if (!v.netName || v.netName !== wallet.netName) return; if (!isOurDtag(dtag, wallet.netName)) return; if (isOwnDeviceDtag(dtag, wallet.netName) && (v.savedAt || 0) === (wallet._savedAt || 0)) return; // our own echo - wallet._mergeSnapshotExtensions(v); + if (!v.blob) { wallet._mergeSnapshotExtensions(v); return; } + const pk = wallet.nostr.pk; + resolveStates([{ state: v, dtag }]).then((r) => { + if (r[0] && wallet.nostr.pk === pk && wallet.netName === r[0].state.netName) wallet._mergeSnapshotExtensions(r[0].state); + }).catch(() => {}); }); }, stop: () => { if (stateUnsub) { try { stateUnsub(); } catch {} stateUnsub = null; } }, @@ -271,23 +325,62 @@ export function installSyncWallet(wallet, { outbox = syncOutbox() } = {}) { try { const domains = splitSnapshotDomains(snap, wallet._cacheExtensions || []); domains.core = trimForRelay(domains.core); + const { sk, ck, pk } = wallet.nostr; for (const [name, obj] of Object.entries(domains)) { const { savedAt, ...data } = obj; - const digest = bytesToHex(sha256(new TextEncoder().encode(JSON.stringify(data)))); + const text = JSON.stringify(data); + const digest = bytesToHex(sha256(new TextEncoder().encode(text))); const dtag = domainDtag(snap.netName, name); - outbox.enqueue({ pubkey: wallet.nostr.pk, dtag, digest, relays: sync.relays, - sign: createdAt => wallet.nostr.stateEvent(obj, dtag, createdAt) }); + let blob = null, evtObj = obj; + if (text.length > RELAY_JSON_BUDGET) { + // Too big for a relay event (and for NIP-44): seal the whole domain + // into a Blossom envelope; the event becomes a pointer to its hash. + const servers = blossomServers(); + const bytes = sealBlob(ck, JSON.stringify(obj)); + const sha = sha256Hex(bytes); + blob = { sha256: sha, size: bytes.length, data: base64.encode(bytes), servers, + auth: blobAuth(sk, 'upload', [sha]), deleteAuthFor: (old) => blobAuth(sk, 'delete', [old]) }; + evtObj = { netName: obj.netName, savedAt: obj.savedAt, blob: { v: 1, sha256: sha, size: bytes.length, servers } }; + } + outbox.enqueue({ pubkey: pk, dtag, digest, relays: sync.relays, blob, + sign: createdAt => wallet.nostr.stateEvent(evtObj, dtag, createdAt) }); } } catch (e) { console.warn('wallet sync snapshot could not be queued:', e.message); } }); } export function syncFeature(ctx) { - const { h, ui, render, wallet } = ctx; + const { h, ui, render, wallet, toast } = ctx; installSyncWallet(wallet); + // Settings → Nostr: which Blossom servers hold this wallet's oversized + // sync domains. Self-hosters and the privacy-minded point it elsewhere. + function storageCard() { + if (ui.syncServersDraft == null) ui.syncServersDraft = blossomServers().join('\n'); + const pending = (sharedOutbox?.records() || []).filter((r) => r.blob && !(r.uploaded || []).length).length; + const save = (servers, manual) => { + const list = setBlossomConfig({ servers, manual }); + if (!list.length) { toast(t('syncStorageInvalid')); return; } + ui.syncServersDraft = list.join('\n'); + toast(t('syncStorageSaved')); + // pending blobs pick up the new list on their next save; nudge one + try { wallet.saveCache(); } catch {} + render(); + }; + return h('div', { class: 'card col', 'data-key': 'syncstorage' }, + h('h3', {}, t('syncStorageTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('syncStorageHow')), + h('textarea', { rows: 3, style: 'font-size:12px', value: ui.syncServersDraft, + onInput: (e) => { ui.syncServersDraft = e.target.value; } }), + pending ? h('div', { class: 'small faint' }, t('syncStoragePending', { n: pending })) : null, + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost small', onClick: () => save(ui.syncServersDraft.split(/\s+/), true) }, t('syncStorageSave')), + h('button', { class: 'btn-ghost small', onClick: () => save(DEFAULT_BLOSSOM_SERVERS, false) }, t('syncStorageReset')))); + } + return { id: 'sync', + nostrSettingsCards: () => [storageCard()], forgetAll: () => sharedOutbox?.clear(), }; } diff --git a/src/i18n.js b/src/i18n.js index 2177385..4d11dd8 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -329,6 +329,13 @@ const STR = { msgBadInvite: 'Not a valid invite link', msgInviteLoading: 'Fetching invite…', msgInviteNotFound: 'Invite not found on its relays', + syncStorageTitle: 'Sync storage', + syncStorageHow: 'Wallet state too large for a relay event is encrypted and stored as a blob on these Blossom servers (one per line). Any Blossom server works — run your own or use another provider.', + syncStorageSave: 'Save servers', + syncStorageReset: 'Use default', + syncStorageSaved: 'Sync storage servers saved', + syncStorageInvalid: 'Enter at least one https:// server', + syncStoragePending: '{n} snapshot(s) waiting to upload', msgInviteRevoked: 'This invite has been revoked', msgInviteExpired: 'This invite has expired', msgInviteTo: 'You are invited to {name}', diff --git a/src/sync-blob.js b/src/sync-blob.js new file mode 100644 index 0000000..c44ab30 --- /dev/null +++ b/src/sync-blob.js @@ -0,0 +1,118 @@ +// Oversized sync domains ride Blossom (nostr blob storage) instead of a relay +// event: a relay caps events at 64KB and NIP-44 caps plaintext at 64KB, but a +// deep Ark coin set is hundreds of KB. The domain is sealed into an envelope +// (magic + xchacha20poly1305 under a key derived from the wallet's own NIP-44 +// self-key), uploaded to one or more Blossom servers, and the kind-30078 +// event carries only { blob: { sha256, size, servers } }. Content addressing +// means any server can hold the blob — users can self-host or pick another +// provider in Settings → Nostr, and the coinos server accepts nothing but +// these envelopes (see blossom/server.js). + +import { xchacha20poly1305 } from '@noble/ciphers/chacha.js'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha256'; +import { bytesToHex, randomBytes } from '@noble/hashes/utils'; +import { base64 } from '@scure/base'; +import { finalizeEvent } from 'nostr-tools/pure'; + +export const BLOB_MAGIC = new TextEncoder().encode('CSB1'); +export const DEFAULT_BLOSSOM_SERVERS = ['https://blossom.coinos.io']; +const CONFIG_KEY = 'btc-wallet-blossom'; +const NONCE_LEN = 24; + +// ---- server list ---------------------------------------------------------- +export const normalizeServer = (s) => { + const u = String(s || '').trim().replace(/\/+$/, ''); + return /^https?:\/\/[^\s/]+$/i.test(u) ? u : null; +}; +export function getBlossomConfig() { + try { + const c = JSON.parse(localStorage.getItem(CONFIG_KEY) || 'null'); + if (c && Array.isArray(c.servers)) return { servers: c.servers.map(normalizeServer).filter(Boolean), manual: !!c.manual }; + } catch {} + return { servers: DEFAULT_BLOSSOM_SERVERS.slice(), manual: false }; +} +export function setBlossomConfig({ servers, manual = false }) { + const list = [...new Set((servers || []).map(normalizeServer).filter(Boolean))].slice(0, 6); + try { localStorage.setItem(CONFIG_KEY, JSON.stringify({ servers: list, manual })); } catch {} + return list; +} +// Where to upload: the configured list, else the default. +export const blossomServers = () => { + const { servers } = getBlossomConfig(); + return servers.length ? servers : DEFAULT_BLOSSOM_SERVERS.slice(); +}; + +// ---- envelope ------------------------------------------------------------ +// ck is the wallet's NIP-44 self conversation key (32 bytes, seed-derived) — +// the same secret that already protects every relay snapshot. +const blobKey = (ck) => hkdf(sha256, ck, undefined, 'coinos-sync-blob-v1', 32); +export const sha256Hex = (bytes) => bytesToHex(sha256(bytes)); + +export function sealBlob(ck, text) { + const nonce = randomBytes(NONCE_LEN); + const ct = xchacha20poly1305(blobKey(ck), nonce).encrypt(new TextEncoder().encode(text)); + const out = new Uint8Array(BLOB_MAGIC.length + NONCE_LEN + ct.length); + out.set(BLOB_MAGIC, 0); out.set(nonce, BLOB_MAGIC.length); out.set(ct, BLOB_MAGIC.length + NONCE_LEN); + return out; +} +export function openBlob(ck, bytes) { + for (let i = 0; i < BLOB_MAGIC.length; i++) if (bytes[i] !== BLOB_MAGIC[i]) throw new Error('not a sync envelope'); + const nonce = bytes.subarray(BLOB_MAGIC.length, BLOB_MAGIC.length + NONCE_LEN); + const pt = xchacha20poly1305(blobKey(ck), nonce).decrypt(bytes.subarray(BLOB_MAGIC.length + NONCE_LEN)); + return new TextDecoder().decode(pt); +} + +// ---- Blossom auth (BUD-11): kind 24242, signed up front so the durable +// outbox can deliver after logout without holding a key ------------------ +export function blobAuth(sk, verb, shas = [], { ttlSec = 7 * 86400, content } = {}) { + const now = Math.floor(Date.now() / 1000); + return finalizeEvent({ + kind: 24242, + created_at: now - 5, // a client clock slightly ahead of the server's is not a rejection + content: content || `coinos wallet sync: ${verb}`, + tags: [['t', verb], ['expiration', String(now + ttlSec)], ...shas.map((x) => ['x', x])], + }, sk); +} +export const authHeader = (evt) => 'Nostr ' + base64.encode(new TextEncoder().encode(JSON.stringify(evt))); + +// ---- transport --------------------------------------------------------- +const withTimeout = (ms) => { + const c = new AbortController(); + const t = setTimeout(() => c.abort(), ms); + return { signal: c.signal, done: () => clearTimeout(t) }; +}; +export async function uploadBlob(server, bytes, auth, { timeoutMs = 30000 } = {}) { + const base = normalizeServer(server); if (!base) return false; + const t = withTimeout(timeoutMs); + try { + const r = await fetch(base + '/upload', { + method: 'PUT', signal: t.signal, body: bytes, + headers: { Authorization: authHeader(auth), 'Content-Type': 'application/octet-stream', 'X-SHA-256': sha256Hex(bytes) }, + }); + return r.status === 200 || r.status === 201; + } catch { return false; } finally { t.done(); } +} +export async function deleteBlob(server, sha, auth, { timeoutMs = 15000 } = {}) { + const base = normalizeServer(server); if (!base) return false; + const t = withTimeout(timeoutMs); + try { + const r = await fetch(`${base}/${sha}`, { method: 'DELETE', signal: t.signal, headers: { Authorization: authHeader(auth) } }); + return r.ok || r.status === 404; + } catch { return false; } finally { t.done(); } +} +// First server that returns bytes hashing to `sha` wins; a mismatch is a +// lying (or corrupted) server and is skipped like a miss. +export async function fetchBlob(servers, sha, { timeoutMs = 30000 } = {}) { + for (const s of servers) { + const base = normalizeServer(s); if (!base) continue; + const t = withTimeout(timeoutMs); + try { + const r = await fetch(`${base}/${sha}`, { signal: t.signal }); + if (!r.ok) continue; + const bytes = new Uint8Array(await r.arrayBuffer()); + if (sha256Hex(bytes) === sha) return bytes; + } catch {} finally { t.done(); } + } + return null; +} diff --git a/src/sync-outbox.js b/src/sync-outbox.js index 3c358f1..2062d9e 100644 --- a/src/sync-outbox.js +++ b/src/sync-outbox.js @@ -1,11 +1,17 @@ // Durable, already-encrypted and signed wallet snapshots. No signing keys or // plaintext wallet state enter storage. Keep the last acknowledged copy too: // it is a local recovery source when an account cache has been cleared. +// +// A record may carry a sealed BLOB (an oversized domain bound for Blossom +// storage) beside its pointer event: the blob uploads first, to at least one +// server, then the event publishes. Its upload/delete authorizations are +// signed at enqueue time (kind 24242 with an expiry), so delivery after +// logout still needs no key. const PREFIX = 'btc-wallet-sync-outbox:'; export class SyncOutbox { - constructor({ storage, send, now = Date.now, schedule = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id), warn = (...args) => console.warn(...args) }) { - Object.assign(this, { storage, send, now, schedule, cancel, warn }); + constructor({ storage, send, upload = async () => false, remove = async () => false, now = Date.now, schedule = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id), warn = (...args) => console.warn(...args) }) { + Object.assign(this, { storage, send, upload, remove, now, schedule, cancel, warn }); this.timer = null; this.running = false; this.retryMs = 5000; @@ -26,13 +32,23 @@ export class SyncOutbox { events(pubkey) { return this.records().filter(r => r.event.pubkey === pubkey).map(r => r.event); } + // The sealed bytes behind one of our own pointer events — local recovery + // needs no server round trip. + localBlob(sha256) { + const r = this.records().find(r => r.blob?.sha256 === sha256 && r.blob.data); + return r ? b64decode(r.blob.data) : null; + } + clear() { if (this.timer != null) this.cancel(this.timer); this.timer = null; for (const { key } of this.records()) this.storage.removeItem(key); } - enqueue({ pubkey, dtag, digest, relays, sign }) { + // blob: { sha256, size, data (base64), servers, auth (upload authorization), + // deleteAuthFor?: sha => authorization } — the last lets a superseded + // blob be removed from its servers once the new one is acknowledged. + enqueue({ pubkey, dtag, digest, relays, sign, blob = null }) { const key = PREFIX + pubkey + ':' + dtag; let previous; try { previous = JSON.parse(this.storage.getItem(key)); } catch {} @@ -44,7 +60,16 @@ export class SyncOutbox { (previous?.event?.created_at || 0) + (previous?.attempted ? 1 : 0)); const event = sign(createdAt); if (!event || event.pubkey !== pubkey) throw new Error('Cannot sign wallet sync snapshot'); - this.storage.setItem(key, JSON.stringify({ event, digest, relays, attempted: false, acknowledged: false })); + // Superseded blobs still sitting on servers: carry their cleanup forward. + const cleanup = (previous?.cleanup || []).slice(); + const prevBlob = previous?.blob; + if (prevBlob?.sha256 && prevBlob.sha256 !== blob?.sha256 && (previous.uploaded || []).length && blob?.deleteAuthFor) { + try { cleanup.push({ sha256: prevBlob.sha256, servers: previous.uploaded, auth: blob.deleteAuthFor(prevBlob.sha256) }); } catch {} + } + const stored = blob ? { sha256: blob.sha256, size: blob.size, data: blob.data, servers: blob.servers, auth: blob.auth } : undefined; + // The same blob re-enqueued (e.g. only the pointer changed) keeps its uploads. + const uploaded = blob && prevBlob?.sha256 === blob.sha256 ? (previous.uploaded || []) : []; + this.storage.setItem(key, JSON.stringify({ event, digest, relays, blob: stored, uploaded, cleanup, attempted: false, acknowledged: false })); this.wake(); } @@ -64,6 +89,20 @@ export class SyncOutbox { // Re-read before writing: another tab may have queued a newer copy. const current = JSON.parse(this.storage.getItem(r.key)); if (current?.event?.id !== r.event.id) return; + // The blob must land on at least one server before its pointer goes out. + if (current.blob && !(current.uploaded || []).length) { + const bytes = b64decode(current.blob.data); + const ok = []; + await Promise.all((current.blob.servers || []).map(async s => { + try { if (await this.upload(s, bytes, current.blob.auth)) ok.push(s); } catch {} + })); + const again = JSON.parse(this.storage.getItem(r.key)); + if (again?.event?.id !== r.event.id) return; + if (!ok.length) { failed = true; this.warn('wallet sync blob upload will retry'); return; } + again.uploaded = ok; + this.storage.setItem(r.key, JSON.stringify(again)); + Object.assign(current, again); + } current.attempted = true; this.storage.setItem(r.key, JSON.stringify(current)); const ok = await this.send(r.event, r.relays); @@ -75,6 +114,7 @@ export class SyncOutbox { } } catch (e) { failed = true; this.warn('wallet sync will retry:', e.message); } })); + await this.cleanup(); } finally { this.running = false; const pending = this.records().filter(r => !r.acknowledged); @@ -85,4 +125,26 @@ export class SyncOutbox { } else this.retryMs = 5000; } } + + // Best-effort removal of superseded blobs once their replacement is + // acknowledged. A server that refuses just keeps the blob until its own + // quota or expiry drops it. + async cleanup() { + for (const r of this.records()) { + if (!r.acknowledged || !(r.cleanup || []).length) continue; + const left = []; + for (const c of r.cleanup) { + const servers = []; + for (const s of c.servers || []) { try { if (!(await this.remove(s, c.sha256, c.auth))) servers.push(s); } catch { servers.push(s); } } + if (servers.length && (c.tries || 0) < 5) left.push({ ...c, servers, tries: (c.tries || 0) + 1 }); + } + const latest = JSON.parse(this.storage.getItem(r.key)); + if (latest?.event?.id === r.event.id) { latest.cleanup = left; this.storage.setItem(r.key, JSON.stringify(latest)); } + } + } +} + +function b64decode(s) { + if (typeof atob === 'function') { const bin = atob(s); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } + return new Uint8Array(Buffer.from(s, 'base64')); } 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(); +} |
