From e2d4251035b393b43dbda3770a22595af14143cc Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 4 Jul 2026 08:56:05 -0400 Subject: [PATCH] sync/zaps: enabled devices auto-pull newer wallet/storage notes at login; zap errors name the failing address (#15 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from live testing: - "NWC doesn't sync unless I click Restore": devices with sync ENABLED only ever pushed-on-save — edits from another device sat unseen until the manual Restore click. encryptedSyncItem.autoPullIfNewer pulls the remote note at login when its created_at is newer than this device's last-synced marker (set on push AND now after a successful pull, so the same note is never re-pulled and the next conflict check stays honest). Wired into the login restore flow ahead of the one-time offer; disabled items are never touched. - "Could not get user information" on a known-good creator address: that reason string comes from the LNURL SERVER, and the failing fetch was the PLATFORM-FEE address, not the creator's. prepareLightningContext now wraps both metadata fetches so the toast names which address failed (e.g. "Platform fee Lightning address (…): Could not get user information") instead of blaming the creator. Co-Authored-By: Claude Fable 5 --- js/services/encryptedSyncItem.js | 38 +++++++- js/services/settingsRestorePrompt.js | 26 ++++++ js/ui/zapController.js | 24 ++++- tests/settings-sync-auto-pull.test.mjs | 121 +++++++++++++++++++++++++ 4 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 tests/settings-sync-auto-pull.test.mjs diff --git a/js/services/encryptedSyncItem.js b/js/services/encryptedSyncItem.js index 5307fcb6..50cace7b 100644 --- a/js/services/encryptedSyncItem.js +++ b/js/services/encryptedSyncItem.js @@ -16,7 +16,12 @@ // logLabel short label for warnings import { encryptedSync as defaultEncryptedSync } from "../nostr/encryptedSyncFacade.js"; -import { isSyncEnabled, setSyncEnabled } from "./settingsSyncFlags.js"; +import { + isSyncEnabled, + setSyncEnabled, + getSyncPushedAt, + setSyncPushedAt, +} from "./settingsSyncFlags.js"; import { pushWithConflictCheck } from "./syncConflict.js"; import { userLogger } from "../utils/logger.js"; @@ -100,6 +105,36 @@ export function createEncryptedSyncItem({ return { ...result, imported: true }; } + // #15 follow-up ("doesn't sync unless I click Restore"): a device with sync + // ENABLED auto-pulls a NEWER remote note at login. Previously enabled devices + // only pushed-on-save — edits from another device sat unseen until the manual + // Restore click. The per-device freshness marker doubles as "local state + // matches note X": it's recorded on push AND after a successful pull, so the + // next conflict check stays honest and the same note is never re-pulled. + async function autoPullIfNewer(pubkey) { + const key = normalizePubkey(pubkey); + if (!key || !isAvailable() || !isEnabled(key)) { + return { pulled: false, reason: "not-enabled" }; + } + let remoteCreatedAt = 0; + try { + const peek = await encryptedSync.exists(dtag); + remoteCreatedAt = Number(peek?.createdAt) || 0; + } catch (error) { + return { pulled: false, reason: "peek-failed", cause: error }; + } + const lastSynced = getSyncPushedAt(key, id); + if (!remoteCreatedAt || remoteCreatedAt <= lastSynced) { + return { pulled: false, reason: "up-to-date" }; + } + const result = await pull(key); + if (result?.imported) { + setSyncPushedAt(key, id, remoteCreatedAt); + return { pulled: true, remoteCreatedAt }; + } + return { pulled: false, reason: result?.error || "pull-failed" }; + } + async function enable(pubkey, options = {}) { setEnabledFlag(pubkey, true); return push(pubkey, options); @@ -118,6 +153,7 @@ export function createEncryptedSyncItem({ hasRemote, push, pull, + autoPullIfNewer, enable, disable, }; diff --git a/js/services/settingsRestorePrompt.js b/js/services/settingsRestorePrompt.js index 24110bb4..e4eca292 100644 --- a/js/services/settingsRestorePrompt.js +++ b/js/services/settingsRestorePrompt.js @@ -102,6 +102,32 @@ export function createSettingsRestorePrompt({ return { offered: false, reason: "no-pubkey" }; } + // Items ALREADY enabled on this device sync silently: pull the remote note + // when it's newer than what this device last pushed/pulled (i.e. another + // device changed it). No prompt — the user opted in when enabling sync. + // Without this, enabled devices only ever pushed, and edits from another + // device sat unseen until the manual Restore click ("doesn't sync"). + const autoPulled = []; + for (const [kind, service] of [ + ["storage", storageSync], + ["wallet", walletSync], + ]) { + if (typeof service?.autoPullIfNewer !== "function") { + continue; + } + try { + const result = await service.autoPullIfNewer(key); + if (result?.pulled) { + autoPulled.push(kind); + } + } catch (error) { + logger?.warn?.(`[settingsRestore] ${kind} auto-pull failed`, error); + } + } + if (autoPulled.length && typeof onRestored === "function") { + onRestored(autoPulled); + } + const candidates = await collectCandidates(key); if (!candidates.length) { // Don't mark offered — a note may be published later from another device; diff --git a/js/ui/zapController.js b/js/ui/zapController.js index 34425ae8..04754682 100644 --- a/js/ui/zapController.js +++ b/js/ui/zapController.js @@ -790,7 +790,19 @@ export default class ZapController { throw new Error("Enter a zap amount greater than zero."); } - const creatorEntry = await fetchLightningMetadata(lightningAddress); + // Name WHICH address failed: LNURL servers return opaque reasons like + // "Could not get user information", and without the address the creator's + // (fine) address gets blamed for a broken platform-fee address. + let creatorEntry; + try { + creatorEntry = await fetchLightningMetadata(lightningAddress); + } catch (error) { + const detail = + error?.message || "Unable to load this creator's Lightning info."; + throw new Error( + `Creator Lightning address (${lightningAddress}): ${detail}`, + ); + } if (shares.creatorShare > 0) { try { validateInvoiceAmount(creatorEntry.metadata, shares.creatorShare); @@ -814,7 +826,15 @@ export default class ZapController { throw new Error("Platform Lightning address is unavailable."); } - platformEntry = await fetchLightningMetadata(platformAddress); + try { + platformEntry = await fetchLightningMetadata(platformAddress); + } catch (error) { + const detail = + error?.message || "Unable to load the platform's Lightning info."; + throw new Error( + `Platform fee Lightning address (${platformAddress}): ${detail}`, + ); + } try { validateInvoiceAmount(platformEntry.metadata, shares.platformShare); } catch (error) { diff --git a/tests/settings-sync-auto-pull.test.mjs b/tests/settings-sync-auto-pull.test.mjs new file mode 100644 index 00000000..10baef72 --- /dev/null +++ b/tests/settings-sync-auto-pull.test.mjs @@ -0,0 +1,121 @@ +// #15 follow-up ("NWC doesn't sync unless I click Restore"): a device with +// sync ENABLED must auto-pull a NEWER remote note at login. Previously enabled +// devices only pushed-on-save, so wallet/storage changes made on another +// device sat unseen until the manual Restore click. Covers the shared +// encryptedSyncItem.autoPullIfNewer and its wiring in the login restore flow. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-enabled-device-auto-pull +// given: "sync enabled locally; a remote note newer/older than the device's last-synced marker" +// when: "autoPullIfNewer runs at login (via settingsRestorePrompt.maybeOffer)" +// then: "newer remote -> pulled + applied + marker advanced (no prompt); older/equal -> untouched; disabled -> untouched" +// observable_outcomes: +// - "applyPayload called exactly when the remote is newer" +// - "second login does not re-pull the same note" +// - "disabled items still go through the one-time offer, not auto-pull" +// determinism_controls: +// - "in-memory localStorage; scripted encryptedSync double; no timers" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false + +import "./test-helpers/setup-localstorage.mjs"; +import test, { beforeEach } from "node:test"; +import { strict as assert } from "node:assert"; +import { createEncryptedSyncItem } from "../js/services/encryptedSyncItem.js"; +import { createSettingsRestorePrompt } from "../js/services/settingsRestorePrompt.js"; +import { setSyncEnabled, setSyncPushedAt } from "../js/services/settingsSyncFlags.js"; + +const PUB = "a".repeat(64); + +beforeEach(() => { + localStorage.clear(); +}); + +function makeItem({ + remoteCreatedAt, + id = "wallet", + payload = { nwcUri: "nostr+walletconnect://x" }, +}) { + const applied = []; + const item = createEncryptedSyncItem({ + id, + dtag: "bitvid:nwc", + encryptedSync: { + isAvailable: () => true, + exists: async () => ({ exists: remoteCreatedAt > 0, createdAt: remoteCreatedAt }), + pull: async () => ({ found: true, payload }), + push: async () => ({ ok: true, createdAt: remoteCreatedAt + 1 }), + clear: async () => ({ ok: true }), + }, + buildPayload: () => payload, + applyPayload: async (pubkey, incoming) => { + applied.push({ pubkey, incoming }); + }, + }); + return { item, applied }; +} + +test("enabled + newer remote → pulled, applied, marker advanced (no re-pull)", async () => { + const { item, applied } = makeItem({ remoteCreatedAt: 500 }); + setSyncEnabled(PUB, "wallet", true); + setSyncPushedAt(PUB, "wallet", 100); // this device last synced an older note + + const first = await item.autoPullIfNewer(PUB); + assert.equal(first.pulled, true); + assert.equal(applied.length, 1, "remote payload applied"); + assert.equal(applied[0].incoming.nwcUri, "nostr+walletconnect://x"); + + const second = await item.autoPullIfNewer(PUB); + assert.equal(second.pulled, false); + assert.equal(second.reason, "up-to-date", "marker advanced — same note not re-pulled"); + assert.equal(applied.length, 1); +}); + +test("enabled + remote not newer → untouched; disabled → untouched", async () => { + const { item, applied } = makeItem({ remoteCreatedAt: 100 }); + setSyncEnabled(PUB, "wallet", true); + setSyncPushedAt(PUB, "wallet", 100); // this device pushed that very note + + assert.equal((await item.autoPullIfNewer(PUB)).pulled, false); + assert.equal(applied.length, 0, "own note never re-applied"); + + // A DIFFERENT sync kind whose flag was never enabled on this device (the + // wallet flag set above is per pubkey+kind and must not bleed over). + const { item: disabledItem, applied: disabledApplied } = makeItem({ + remoteCreatedAt: 9999, + id: "storage", + }); + const result = await disabledItem.autoPullIfNewer(PUB); + assert.equal(result.pulled, false); + assert.equal(result.reason, "not-enabled"); + assert.equal(disabledApplied.length, 0); +}); + +test("login flow: enabled wallet auto-pulls silently; no prompt is shown for it", async () => { + const { item, applied } = makeItem({ remoteCreatedAt: 700 }); + setSyncEnabled(PUB, "wallet", true); + setSyncPushedAt(PUB, "wallet", 100); + + let confirmCalls = 0; + const restoredBatches = []; + const prompt = createSettingsRestorePrompt({ + storageSync: { isAvailable: () => false, isEnabled: () => false }, + walletSync: item, + confirm: async () => { + confirmCalls += 1; + return true; + }, + }); + + await prompt.maybeOffer(PUB, { + onRestored: (items) => restoredBatches.push(items), + }); + + assert.equal(applied.length, 1, "newer note pulled during login"); + assert.deepEqual(restoredBatches, [["wallet"]], "user notified of the silent sync"); + assert.equal(confirmCalls, 0, "no prompt for an already-enabled item"); +});