aboutsummaryrefslogtreecommitdiffstats
path: root/src
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 /src
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 'src')
-rw-r--r--src/features/sync.js113
-rw-r--r--src/i18n.js7
-rw-r--r--src/sync-blob.js118
-rw-r--r--src/sync-outbox.js70
4 files changed, 294 insertions, 14 deletions
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'));
}