diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/ark/manager.js | 14 | ||||
| -rw-r--r-- | src/features/ark.js | 62 |
2 files changed, 61 insertions, 15 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index fc6262d..4814a26 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -54,6 +54,7 @@ import { validateVtxo, VtxoValidationError } from './validate.js'; const EMPTY_STATE = () => ({ v: 1, + serverPubkey: null, // which ASP this state belongs to (guarded in init) mailboxCheckpoint: 0, nextKeyIndex: 1, // 0 is the receive-address key vtxos: [], // { id, bytes, keyIndex, amountSat, expiryHeight, state } @@ -101,6 +102,19 @@ export class ArkManager { const hs = await handshake(this.arkUrl).catch(() => null); this.psa = hs?.psa; this.serverPub = hex.decode(this.info.serverPubkey); + + // A vtxo is only meaningful against the server that cosigned it. State + // carried over from a different ASP (a provider switch, or a snapshot + // adopted from the pre-namespacing key) would show phantom balance and + // try to resume actions this server has never heard of — so drop it. + // Nothing is lost: the other ASP's state lives under its own key. + if (this.state.serverPubkey && this.state.serverPubkey !== this.info.serverPubkey) { + this.state = EMPTY_STATE(); + } + if (!this.state.serverPubkey) { + this.state.serverPubkey = this.info.serverPubkey; + this._save(); + } return this; } diff --git a/src/features/ark.js b/src/features/ark.js index 58254cd..27dd871 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -33,12 +33,32 @@ export function installArkWallet(wallet) { // ---- Ark support ------------------------------------------------------- // Persisted ArkManager state (vtxos, in-flight action checkpoints, movement // history — no secrets: vtxo keys are re-derived from the seed). - _arkKey() { return this._cacheKey() + ':ark'; }, - loadArkState() { - try { return JSON.parse(localStorage.getItem(this._arkKey()) || 'null'); } catch { return null; } + // Ark state is per WALLET *and* per ASP: vtxos are cosigned by one + // specific server, so showing another server's coins (or resuming its + // in-flight board) is meaningless and alarming. Switching providers must + // therefore switch state, and switching back must restore it. + _arkNs(url) { + // short, stable id for an ASP endpoint + let h = 5381; + for (const c of String(url || '')) h = ((h * 33) ^ c.charCodeAt(0)) >>> 0; + return h.toString(36); }, - saveArkState(state) { - try { localStorage.setItem(this._arkKey(), JSON.stringify(state)); } catch {} + _arkKey(url) { return this._cacheKey() + ':ark:' + this._arkNs(url); }, + _arkLegacyKey() { return this._cacheKey() + ':ark'; }, + loadArkState(url) { + try { + const cur = localStorage.getItem(this._arkKey(url)); + if (cur) return JSON.parse(cur); + // One-time adoption of pre-namespacing state. The manager verifies the + // server pubkey on connect and discards it if it belongs elsewhere, + // so a wrong guess here is self-correcting; the legacy key is left in + // place so the rightful ASP can still claim it. + const legacy = localStorage.getItem(this._arkLegacyKey()); + return legacy ? JSON.parse(legacy) : null; + } catch { return null; } + }, + saveArkState(state, url) { + try { localStorage.setItem(this._arkKey(url), JSON.stringify(state)); } catch {} }, }); } @@ -54,7 +74,7 @@ export function slimArkForSync(s) { if (!s) return null; const HEAVY = ['bytes', 'destBytes', 'changeBytes', 'vtxoBytes', 'txHex', 'fundingTxHex', 'outputVtxos']; return { - v: s.v, mailboxCheckpoint: s.mailboxCheckpoint, nextKeyIndex: s.nextKeyIndex, receiveAckTs: s.receiveAckTs, + v: s.v, serverPubkey: s.serverPubkey, mailboxCheckpoint: s.mailboxCheckpoint, nextKeyIndex: s.nextKeyIndex, receiveAckTs: s.receiveAckTs, vtxos: (s.vtxos || []).map((v) => v.state === 'spent' ? { id: v.id, amountSat: v.amountSat, state: 'spent', keyIndex: v.keyIndex, expiryHeight: v.expiryHeight } : v), @@ -73,6 +93,8 @@ export function slimArkForSync(s) { export function mergeArkStates(a, b) { if (!a) return b; if (!b) return a; + // never fuse state from two different ASPs + if (a.serverPubkey && b.serverPubkey && a.serverPubkey !== b.serverPubkey) return a; const out = { ...a }; // ADDITIVE union only: bring in vtxos this device is missing (the whole // point — board/change coins that live only where they were created), but @@ -102,6 +124,7 @@ export function mergeArkStates(a, b) { out.nextKeyIndex = Math.max(a.nextKeyIndex || 1, b.nextKeyIndex || 1); out.nextLnRecvIndex = Math.max(a.nextLnRecvIndex || 0, b.nextLnRecvIndex || 0); out.receiveAckTs = Math.max(a.receiveAckTs || 0, b.receiveAckTs || 0); + out.serverPubkey = a.serverPubkey || b.serverPubkey || null; return out; } @@ -120,14 +143,21 @@ export function arkFeature(ctx) { wallet.registerCacheExtension({ mergeAlways: true, // load() is a commutative merge — apply older snapshots too save: () => { - const s = wallet.loadArkState(); - return s ? { arkState: slimArkForSync(s) } : {}; + const cfg = getArkConfig(); + const s = cfg && wallet.loadArkState(cfg.ark); + // tag the snapshot with the ASP it belongs to (see load()) + return s ? { arkState: slimArkForSync(s), arkServer: wallet._arkNs(cfg.ark) } : {}; }, load: (d) => { if (!d.arkState) return; - const local = wallet.loadArkState(); + const cfg = getArkConfig(); + if (!cfg) return; + // only merge a snapshot from the same ASP — another server's vtxos are + // not ours to show (older snapshots carry no tag; accept those) + if (d.arkServer && d.arkServer !== wallet._arkNs(cfg.ark)) return; + const local = wallet.loadArkState(cfg.ark); const merged = mergeArkStates(local, d.arkState); - wallet.saveArkState(merged); + wallet.saveArkState(merged, cfg.ark); const mergedN = (merged.vtxos || []).length; const remoteN = (d.arkState.vtxos || []).length; const localN = (local && local.vtxos || []).length; @@ -166,7 +196,8 @@ export function arkFeature(ctx) { } function arkWanted() { - const st = wallet.loadArkState(); + const cfg = getArkConfig(); + const st = cfg && wallet.loadArkState(cfg.ark); return !!(st && ((st.vtxos || []).length || (st.movements || []).length || (st.actions || []).some((a) => !['done', 'failed'].includes(a.step)))); } @@ -186,7 +217,7 @@ export function arkFeature(ctx) { const mgr = new ArkManager({ account: wallet.account(), storage: { - load: () => wallet.loadArkState(), + load: () => wallet.loadArkState(cfg.ark), // merge-on-save: the MANAGER is authoritative for the state of the // vtxos it tracks (its reconcile/spends must persist), so its state // is the first arg and wins; storage only contributes vtxos the @@ -194,7 +225,7 @@ export function arkFeature(ctx) { // between init and the next reinit). saveCache() then carries ark // state into the snapshot -> debounced nostr publish. save: (s) => { - wallet.saveArkState(mergeArkStates(s, wallet.loadArkState())); + wallet.saveArkState(mergeArkStates(s, wallet.loadArkState(cfg.ark)), cfg.ark); try { wallet.saveCache(); } catch {} }, }, @@ -247,8 +278,9 @@ export function arkFeature(ctx) { // watch-only (seed not loaded this session): the balance/history should // show read-only, just like the on-chain balance does. Acting on it // (send/exit) still requires the seed and is gated separately. - if (!getArkConfig()) return null; - return wallet.loadArkState(); + const cfg = getArkConfig(); + if (!cfg) return null; + return wallet.loadArkState(cfg.ark); } function arkBalance() { |
