aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-06 11:12:31 -0700
committerAdam Soltys <[email protected]>2026-07-06 11:12:31 -0700
commitd071a09604599831d370b06563144cf4ba028902 (patch)
treecf5974694de768a5128f044d255b399fa3eb18dd
parentef1a901314cb667bc8a7c7ff5f080550edef2815 (diff)
downloadcoinosv3-d071a09604599831d370b06563144cf4ba028902.tar.gz
coinosv3-d071a09604599831d370b06563144cf4ba028902.zip
Gift cross-device sync, snapshot size cap, reliable SW updates, gift-detail layout
- Gifts now sync across devices: gift records + reserved/reclaimed coin sets ride the encrypted snapshot as a merge-safe extension (like ark), and every gift state change (create/reclaim/revoke) triggers a saveCache so it actually publishes. A gift made on one device now shows on the others. - Cap the published snapshot at ~40KB JSON (relay rejects >64KB events, nip44 expands ~1.5x): trim oldest on-chain txs when oversized; another device backfills via rescan. Typical wallets untouched. - Service worker: register with updateViaCache:'none' so a new build is detected immediately (not held by the 4h HTTP cache), and reload once on controllerchange so the fresh app runs without a manual second refresh. - Gift detail: View/Reclaim share a row, Revoke on its own full-width line so 'Revoke now' stops wrapping. Co-Authored-By: Claude Fable 5 <[email protected]>
-rw-r--r--build.js9
-rw-r--r--src/features/gifts-wallet.js34
-rw-r--r--src/features/gifts.js13
-rw-r--r--src/features/sync.js20
-rw-r--r--src/wallet.js1
5 files changed, 70 insertions, 7 deletions
diff --git a/build.js b/build.js
index 5165669..58ca0ba 100644
--- a/build.js
+++ b/build.js
@@ -104,8 +104,15 @@ const PWA_HEAD = `<link rel="manifest" href="manifest.webmanifest">
<link rel="apple-touch-icon" href="icon-192.png">
`;
+// Register with updateViaCache:'none' so the browser always re-fetches sw.js
+// (never from HTTP cache) and detects a new build immediately. When the new SW
+// takes control (it skipWaiting + clients.claim), reload once so the fresh
+// inlined app runs — but only if a SW was already controlling this page, so a
+// first-ever visit doesn't reload.
const SW_REGISTER =
- `<script>if('serviceWorker'in navigator)addEventListener('load',function(){navigator.serviceWorker.register('sw.js').catch(function(){})});</script>`;
+ `<script>if('serviceWorker'in navigator){var _had=!!navigator.serviceWorker.controller,_ref=false;` +
+ `navigator.serviceWorker.addEventListener('controllerchange',function(){if(_had&&!_ref){_ref=true;location.reload()}});` +
+ `addEventListener('load',function(){navigator.serviceWorker.register('sw.js',{updateViaCache:'none'}).catch(function(){})})}</script>`;
// Bundle the standalone jsQR decoder (sets window.jsQR) for lazy loading.
export async function buildJsQr({ minify = true } = {}) {
diff --git a/src/features/gifts-wallet.js b/src/features/gifts-wallet.js
index 52de0d0..96e4978 100644
--- a/src/features/gifts-wallet.js
+++ b/src/features/gifts-wallet.js
@@ -66,6 +66,7 @@ export function installGiftWallet(wallet) {
this.reclaimedSet().add(id);
this._saveReserved();
this._saveReclaimed();
+ try { this.saveCache(); } catch {} // publish the gift-state change to sync
},
// Truly revoke an unclaimed gift: spend its coin back into our own wallet,
@@ -79,12 +80,13 @@ export function installGiftWallet(wallet) {
this.reclaimedSet().delete(id);
this._saveReserved();
this._saveReclaimed();
+ try { this.saveCache(); } catch {}
return txid;
},
giftLink(id) { return this.giftRecords()[id] || null; },
- markGiftRevoked(id) { const r = this.giftRecords()[id]; if (r) { r.revoked = true; this._saveGiftRecords(); } },
+ markGiftRevoked(id) { const r = this.giftRecords()[id]; if (r) { r.revoked = true; this._saveGiftRecords(); try { this.saveCache(); } catch {} } },
giftRecords() {
if (!this._giftRecords) { try { this._giftRecords = JSON.parse(localStorage.getItem(this._giftRecordsKey()) || '{}'); } catch { this._giftRecords = {}; } }
@@ -250,6 +252,7 @@ export function installGiftWallet(wallet) {
if (!outpoints || !outpoints.length) return;
this.giftRecords()[outpoints[0]] = { id: outpoints[0], code, locked: !!locked, amount, claimCode: claimCode || null, outpoints, created: Date.now(), revoked: false };
this._saveGiftRecords();
+ try { this.saveCache(); } catch {} // publish the new gift to cross-device sync
},
// Sats that creating this gift right now would lock (the selected coins'
@@ -289,6 +292,35 @@ export function installGiftWallet(wallet) {
});
// reserved gift coins are excluded from spending + the spendable balance
wallet.registerCoinLock(() => wallet.reservedSet());
+
+ // Sync gift state across devices (like ark): the records + reserved/reclaimed
+ // coin sets are per-device localStorage, so a gift made on one device was
+ // invisible on another. Ride the encrypted snapshot as a merge-safe
+ // extension: union records (revoked/claimTxid flags sticky), union both coin
+ // sets. Each device's own scan then reconciles which coins are still live.
+ wallet.registerCacheExtension({
+ mergeAlways: true,
+ save: () => ({
+ giftState: {
+ records: wallet.giftRecords(),
+ reserved: [...wallet.reservedSet()],
+ reclaimed: [...wallet.reclaimedSet()],
+ },
+ }),
+ load: (d) => {
+ if (!d.giftState) return;
+ const recs = wallet.giftRecords();
+ for (const [id, r] of Object.entries(d.giftState.records || {})) {
+ const local = recs[id];
+ if (!local) recs[id] = r;
+ else { local.revoked = local.revoked || r.revoked; if (!local.claimTxid && r.claimTxid) local.claimTxid = r.claimTxid; }
+ }
+ wallet._saveGiftRecords();
+ const res = wallet.reservedSet(); for (const id of d.giftState.reserved || []) res.add(id);
+ const rec = wallet.reclaimedSet(); for (const id of d.giftState.reclaimed || []) rec.add(id);
+ wallet._saveReserved(); wallet._saveReclaimed();
+ },
+ });
}
// Gift-link claiming (sender side is Wallet.createGift). A gift code is a
diff --git a/src/features/gifts.js b/src/features/gifts.js
index 27fea33..86bc3b6 100644
--- a/src/features/gifts.js
+++ b/src/features/gifts.js
@@ -781,10 +781,15 @@ export function giftsFeature(ctx) {
h('span', { class: 'small muted' }, g.reserved ? t('giftReclaimPrompt') : t('giftRevokeConfirm')),
ui.busy
? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' }))
- : h('div', { class: 'row gap6' },
- rec ? h('button', { class: 'grow', onClick: () => { ui.viewGift = rec; render(); } }, t('giftView')) : null,
- g.reserved ? h('button', { class: 'btn-ghost grow', onClick: () => doReclaim(g.id) }, t('giftReclaim')) : null,
- h('button', { class: 'btn-primary grow', onClick: () => doRevoke(g.id) }, t('giftRevoke')))),
+ // View + Reclaim share a row; Revoke gets its own full-width line
+ // so "Revoke now" never squishes/wraps against the others.
+ : h('div', { class: 'col', style: 'gap:8px' },
+ (rec || g.reserved)
+ ? h('div', { class: 'row gap6' },
+ rec ? h('button', { class: 'grow', onClick: () => { ui.viewGift = rec; render(); } }, t('giftView')) : null,
+ g.reserved ? h('button', { class: 'btn-ghost grow', onClick: () => doReclaim(g.id) }, t('giftReclaim')) : null)
+ : null,
+ h('button', { class: 'btn-primary btn-block', onClick: () => doRevoke(g.id) }, t('giftRevoke')))),
h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back'))
);
}
diff --git a/src/features/sync.js b/src/features/sync.js
index 1f3414d..715c789 100644
--- a/src/features/sync.js
+++ b/src/features/sync.js
@@ -6,6 +6,24 @@
import { NostrSync, getSyncConfig, setSyncConfig, npubOf, syncDtag, deviceDtag, isOurDtag } from '../nostr.js';
import { t } from '../i18n.js';
+// Keep a published snapshot under the relay's event-size cap. relay.coinos.io
+// rejects events over 64KB and nip44 expands the plaintext ~1.5x, so the JSON
+// must stay under ~40KB. The on-chain tx history is the main unbounded growth;
+// drop the oldest (this.txs is newest-first) — another device backfills the
+// rest with a rescan. Only trims oversized snapshots, so typical wallets are
+// untouched.
+const RELAY_JSON_BUDGET = 40000;
+function trimForRelay(snap) {
+ if (JSON.stringify(snap).length <= RELAY_JSON_BUDGET) return snap;
+ const s = { ...snap };
+ let txs = (s.txs || []).slice();
+ while (txs.length > 25 && JSON.stringify(s).length > RELAY_JSON_BUDGET) {
+ txs = txs.slice(0, Math.max(25, txs.length - 25));
+ s.txs = txs;
+ }
+ return s;
+}
+
export function installSyncWallet(wallet) {
if (wallet.syncFromNostr) return; // already installed
wallet.nostr = new NostrSync();
@@ -112,7 +130,7 @@ export function installSyncWallet(wallet) {
// snapshot's network even if the wallet switches networks before it fires
const dtag = deviceDtag(wallet.netName);
clearTimeout(wallet._nostrPubTimer);
- wallet._nostrPubTimer = setTimeout(() => wallet.nostr.publish(snap, dtag), 2500);
+ wallet._nostrPubTimer = setTimeout(() => wallet.nostr.publish(trimForRelay(snap), dtag), 2500);
});
wallet.registerRealtimeHook({ stop: () => clearTimeout(wallet._nostrPubTimer) });
}
diff --git a/src/wallet.js b/src/wallet.js
index ccb7b81..1c58de6 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -1203,6 +1203,7 @@ export class Wallet {
// state (ark vtxos) must flow both directions.
_mergeSnapshotExtensions(d) {
for (const e of this._cacheExtensions) { try { if (e.mergeAlways) e.load(d); } catch {} }
+ this.emit(); // a live cross-device merge (ark/gift state) should re-render
}
// Called with every saved state snapshot (e.g. to push it to sync relays).
registerCacheSavedHook(fn) { this._cacheSavedHooks.push(fn); }