Files
bitvid/js/services/walletSyncService.js
T
thePR0M3TH3ANandClaude Opus 4.8 4186b4c4ba feat(sync): offer-to-pull encrypted settings on login (one-time, non-blocking)
Completes todo #15. On login, if an encrypted copy of the user's storage and/or
wallet settings exists on their Nostr account — and this device hasn't already
opted in — offer to restore it.

- js/nostr/encryptedSync.js: exists(dTag) — cheap list-only existence peek (NO
  decrypt, so it doesn't spend the signer budget); decryption only on accept.
- storage/walletSyncService: hasRemote() wrappers over exists().
- js/services/settingsRestorePrompt.js: one-time per-pubkey offer. Only prompts
  for items with a remote copy not already enabled locally; if nothing remote
  exists it does NOT consume the one-time offer (a note published later can still
  prompt next login). On accept, pulls + applies.
- app.js handleLoginModalSuccess: fires maybeOfferSettingsRestore(pubkey) after
  login, deferred ~2s so relays connect; best-effort, never blocks login; toasts
  on restore.

tests/settings-restore-prompt.test.mjs: offer+restore-only-remote-items, decline,
prompt-once, skip-already-enabled, don't-consume-when-empty. Mutation-verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 18:08:43 -04:00

123 lines
3.7 KiB
JavaScript

// Opt-in encrypted cross-login sync for the NWC WALLET connection (todo #15).
// Mirrors storageSyncService but for the wallet settings managed by
// NwcSettingsService.
//
// HIGHEST SENSITIVITY: the NWC URI is a bearer SPENDING secret. Anyone who can
// decrypt this note (i.e. anyone who controls the user's Nostr key) can spend
// from the connected wallet. The UI MUST gate enabling this behind an explicit
// confirmation. Off by default; never log the decrypted URI.
//
// NwcSettingsService is a per-app instance (not a singleton), so this is a
// factory — the caller injects the live service.
import { encryptedSync as defaultEncryptedSync } from "../nostr/encryptedSyncFacade.js";
import { isSyncEnabled, setSyncEnabled } from "./settingsSyncFlags.js";
import { userLogger } from "../utils/logger.js";
export const WALLET_SYNC_DTAG = "bitvid:nwc";
const SYNC_KIND = "wallet";
export function createWalletSyncService({
encryptedSync = defaultEncryptedSync,
nwcSettings,
} = {}) {
function normalizePubkey(pubkey) {
return typeof pubkey === "string" ? pubkey.trim() : "";
}
function isAvailable() {
return Boolean(encryptedSync?.isAvailable?.()) && Boolean(nwcSettings);
}
function isEnabled(pubkey) {
return isSyncEnabled(normalizePubkey(pubkey), SYNC_KIND);
}
function setEnabledFlag(pubkey, enabled) {
setSyncEnabled(normalizePubkey(pubkey), SYNC_KIND, enabled);
}
// Build the minimal syncable payload from current wallet settings.
function buildPayload() {
const settings = nwcSettings?.getActiveNwcSettings?.() || {};
const nwcUri = typeof settings.nwcUri === "string" ? settings.nwcUri.trim() : "";
const payload = { nwcUri };
if (settings.defaultZap !== undefined) {
payload.defaultZap = settings.defaultZap;
}
return payload;
}
// Cheap check (no decrypt) for whether a synced note exists — for the
// login-restore prompt.
async function hasRemote() {
if (typeof encryptedSync.exists !== "function") {
return false;
}
const result = await encryptedSync.exists(WALLET_SYNC_DTAG);
return Boolean(result?.exists);
}
async function push(pubkey) {
const key = normalizePubkey(pubkey);
if (!key) {
return { ok: false, error: "missing-pubkey" };
}
const payload = buildPayload();
if (!payload.nwcUri) {
return { ok: false, error: "nothing-to-sync" };
}
return encryptedSync.push(WALLET_SYNC_DTAG, payload);
}
async function pull(pubkey) {
const key = normalizePubkey(pubkey);
if (!key) {
return { found: false, error: "missing-pubkey" };
}
const result = await encryptedSync.pull(WALLET_SYNC_DTAG);
if (!result?.found) {
return result;
}
const payload = result.payload || {};
const nwcUri = typeof payload.nwcUri === "string" ? payload.nwcUri.trim() : "";
if (!nwcUri) {
return { ...result, imported: false, error: "empty-payload" };
}
try {
const partial = { nwcUri };
if (payload.defaultZap !== undefined) {
partial.defaultZap = payload.defaultZap;
}
await nwcSettings.updateActiveNwcSettings(partial);
} catch (error) {
userLogger.warn("[walletSync] Failed to apply pulled wallet settings:", error);
return { ...result, imported: false, error: "import-failed", cause: error };
}
return { ...result, imported: true };
}
async function enable(pubkey) {
setEnabledFlag(pubkey, true);
return push(pubkey);
}
async function disable(pubkey) {
setEnabledFlag(pubkey, false);
return encryptedSync.clear(WALLET_SYNC_DTAG);
}
return {
WALLET_SYNC_DTAG,
isAvailable,
isEnabled,
hasRemote,
push,
pull,
enable,
disable,
};
}
export default createWalletSyncService;