sync/zaps: enabled devices auto-pull newer wallet/storage notes at login; zap errors name the failing address (#15 follow-up)

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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-07-04 08:56:05 -04:00
1 parent 86d7db4b1a
commit e2d4251035
4 files changed
+206 -3

No files matched your search

+37 -1
View File
@@ -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,
};
+26
View File
@@ -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;
+22 -2
View File
@@ -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) {
+121
View File
@@ -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");
});