diff options
| author | Adam Soltys <[email protected]> | 2026-07-06 11:12:31 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-06 11:12:31 -0700 |
| commit | d071a09604599831d370b06563144cf4ba028902 (patch) | |
| tree | cf5974694de768a5128f044d255b399fa3eb18dd /src | |
| parent | ef1a901314cb667bc8a7c7ff5f080550edef2815 (diff) | |
| download | coinosv3-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]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/gifts-wallet.js | 34 | ||||
| -rw-r--r-- | src/features/gifts.js | 13 | ||||
| -rw-r--r-- | src/features/sync.js | 20 | ||||
| -rw-r--r-- | src/wallet.js | 1 |
4 files changed, 62 insertions, 6 deletions
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); } |
