mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-01 02:12:26 +00:00
feat(sync): NWC wallet sync (opt-in, spend-gated) + shared opt-in flags
Adds encrypted cross-login sync for the NWC wallet connection (todo #15), reusing the encrypted-sync core under d-tag "bitvid:nwc". - js/services/settingsSyncFlags.js: shared per-pubkey opt-in flag store (storage/wallet); storageSyncService refactored onto it (identical behavior, tests still green). - js/services/walletSyncService.js: factory (NwcSettingsService is per-app, not a singleton). push() syncs the current nwcUri (+defaultZap); pull() applies it via updateActiveNwcSettings; disable() wipes the note. - Wallet pane (components/profile-modal.html + ProfileWalletController): opt-in toggle + Restore button + a strong SPENDING warning, shown only when a signer can encrypt. Enabling requires an explicit window.confirm (the URI can spend). Re-pushes on save when enabled; and on wallet DISCONNECT while synced it clears the note so a removed spending URI doesn't linger (encrypted) on relays. tests/wallet-sync-service.test.mjs: enable-pushes-uri, pull-restores-via- updateActiveNwcSettings, disable-clears, no-wallet-doesn't-publish. Mutation- verified (skip-apply + skip-clear both fail). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
c651271af3
commit
d552e9017c
6 files changed
+504
-42
No files matched your search
@@ -836,6 +836,33 @@
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
|
||||
<div id="profileWalletSync" class="mt-2 rounded-lg border border-default p-4 space-y-3 hidden">
|
||||
<div class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-text">Cross-device sync</h4>
|
||||
<p class="text-xs text-muted">
|
||||
Save an <strong>encrypted</strong> copy of your wallet
|
||||
connection to your Nostr account so you can use it on other
|
||||
devices without re-pasting the URI.
|
||||
</p>
|
||||
</div>
|
||||
<label class="flex items-start gap-2 text-sm text-muted-strong">
|
||||
<input id="walletSyncToggle" type="checkbox" class="checkbox mt-0.5" />
|
||||
<span>Sync my wallet connection to my Nostr account (encrypted)</span>
|
||||
</label>
|
||||
<p class="text-xs text-status-danger">
|
||||
<strong>Spending warning:</strong> a wallet connect URI can spend
|
||||
from your wallet. The encrypted copy lives on public relays —
|
||||
anyone who controls your Nostr key could decrypt it and spend
|
||||
from this wallet. Only enable this if you accept that risk.
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button id="walletSyncRestoreBtn" type="button" class="btn-ghost focus-ring">
|
||||
Restore from my Nostr account
|
||||
</button>
|
||||
</div>
|
||||
<p id="walletSyncStatus" class="text-xs" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Per-pubkey opt-in flags for encrypted settings sync (todo #15). Shared by the
|
||||
// storage and wallet sync services so the toggle state persists locally.
|
||||
// Shape: { [pubkey]: { storage?: boolean, wallet?: boolean } }. Off by default.
|
||||
|
||||
const FLAG_STORAGE_KEY = "bitvid:settings-sync:v1";
|
||||
|
||||
function readFlags() {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return {};
|
||||
}
|
||||
const raw = localStorage.getItem(FLAG_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeFlags(flags) {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(FLAG_STORAGE_KEY, JSON.stringify(flags || {}));
|
||||
} catch (error) {
|
||||
// Best-effort; the flag is a convenience, not a source of truth.
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePubkey(pubkey) {
|
||||
return typeof pubkey === "string" ? pubkey.trim() : "";
|
||||
}
|
||||
|
||||
export function isSyncEnabled(pubkey, kind) {
|
||||
const key = normalizePubkey(pubkey);
|
||||
if (!key || !kind) {
|
||||
return false;
|
||||
}
|
||||
return readFlags()[key]?.[kind] === true;
|
||||
}
|
||||
|
||||
export function setSyncEnabled(pubkey, kind, enabled) {
|
||||
const key = normalizePubkey(pubkey);
|
||||
if (!key || !kind) {
|
||||
return;
|
||||
}
|
||||
const flags = readFlags();
|
||||
const entry = flags[key] && typeof flags[key] === "object" ? flags[key] : {};
|
||||
entry[kind] = enabled === true;
|
||||
flags[key] = entry;
|
||||
writeFlags(flags);
|
||||
}
|
||||
|
||||
export const SETTINGS_SYNC_FLAG_KEY = FLAG_STORAGE_KEY;
|
||||
@@ -9,37 +9,11 @@
|
||||
|
||||
import { encryptedSync as defaultEncryptedSync } from "../nostr/encryptedSyncFacade.js";
|
||||
import defaultStorageService from "./storageService.js";
|
||||
import { isSyncEnabled, setSyncEnabled } from "./settingsSyncFlags.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
|
||||
export const STORAGE_SYNC_DTAG = "bitvid:storage-connections";
|
||||
const FLAG_STORAGE_KEY = "bitvid:settings-sync:v1";
|
||||
|
||||
function readFlags() {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return {};
|
||||
}
|
||||
const raw = localStorage.getItem(FLAG_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeFlags(flags) {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(FLAG_STORAGE_KEY, JSON.stringify(flags || {}));
|
||||
} catch (error) {
|
||||
// Best-effort; the flag is a convenience, not a source of truth.
|
||||
}
|
||||
}
|
||||
const SYNC_KIND = "storage";
|
||||
|
||||
export function createStorageSyncService({
|
||||
encryptedSync = defaultEncryptedSync,
|
||||
@@ -54,23 +28,11 @@ export function createStorageSyncService({
|
||||
}
|
||||
|
||||
function isEnabled(pubkey) {
|
||||
const key = normalizePubkey(pubkey);
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
return readFlags()[key]?.storage === true;
|
||||
return isSyncEnabled(normalizePubkey(pubkey), SYNC_KIND);
|
||||
}
|
||||
|
||||
function setEnabledFlag(pubkey, enabled) {
|
||||
const key = normalizePubkey(pubkey);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const flags = readFlags();
|
||||
const entry = flags[key] && typeof flags[key] === "object" ? flags[key] : {};
|
||||
entry.storage = enabled === true;
|
||||
flags[key] = entry;
|
||||
writeFlags(flags);
|
||||
setSyncEnabled(normalizePubkey(pubkey), SYNC_KIND, enabled);
|
||||
}
|
||||
|
||||
// Push the current local storage account record to the user's encrypted note.
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
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,
|
||||
push,
|
||||
pull,
|
||||
enable,
|
||||
disable,
|
||||
};
|
||||
}
|
||||
|
||||
export default createWalletSyncService;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { devLogger } from "../../utils/logger.js";
|
||||
import { NWC_URI_SCHEME } from "../profileModalContract.js";
|
||||
import { createWalletSyncService } from "../../services/walletSyncService.js";
|
||||
|
||||
const noop = () => {};
|
||||
const SECRET_PLACEHOLDER = "*****";
|
||||
@@ -15,6 +16,12 @@ export class ProfileWalletController {
|
||||
this.walletDisconnectButton = null;
|
||||
this.walletStatusText = null;
|
||||
this.walletPane = null;
|
||||
|
||||
this.walletSyncSection = null;
|
||||
this.walletSyncToggle = null;
|
||||
this.walletSyncRestoreBtn = null;
|
||||
this.walletSyncStatus = null;
|
||||
this._walletSyncService = null;
|
||||
}
|
||||
|
||||
cacheDomReferences() {
|
||||
@@ -25,6 +32,10 @@ export class ProfileWalletController {
|
||||
this.walletDisconnectButton = document.getElementById("profileWalletDisconnect") || null;
|
||||
this.walletStatusText = document.getElementById("profileWalletStatus") || null;
|
||||
this.walletPane = document.getElementById("profilePaneWallet") || null;
|
||||
this.walletSyncSection = document.getElementById("profileWalletSync") || null;
|
||||
this.walletSyncToggle = document.getElementById("walletSyncToggle") || null;
|
||||
this.walletSyncRestoreBtn = document.getElementById("walletSyncRestoreBtn") || null;
|
||||
this.walletSyncStatus = document.getElementById("walletSyncStatus") || null;
|
||||
|
||||
// Backwards compatibility alias if needed by tests/external code
|
||||
this.mainController.profileWalletStatusText = this.walletStatusText;
|
||||
@@ -68,6 +79,146 @@ export class ProfileWalletController {
|
||||
void this.handleWalletDisconnect();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.walletSyncToggle instanceof HTMLElement) {
|
||||
this.walletSyncToggle.addEventListener("change", () => {
|
||||
void this.handleToggleSync();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.walletSyncRestoreBtn instanceof HTMLElement) {
|
||||
this.walletSyncRestoreBtn.addEventListener("click", () => {
|
||||
void this.handleRestoreSync();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getWalletSyncService() {
|
||||
if (!this._walletSyncService) {
|
||||
this._walletSyncService = createWalletSyncService({
|
||||
nwcSettings: this.mainController.services.nwcSettings,
|
||||
});
|
||||
}
|
||||
return this._walletSyncService;
|
||||
}
|
||||
|
||||
renderSyncSection(pubkey) {
|
||||
if (!(this.walletSyncSection instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const sync = this.getWalletSyncService();
|
||||
if (!pubkey || !sync.isAvailable()) {
|
||||
this.walletSyncSection.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
this.walletSyncSection.classList.remove("hidden");
|
||||
if (this.walletSyncToggle instanceof HTMLInputElement) {
|
||||
this.walletSyncToggle.checked = sync.isEnabled(pubkey);
|
||||
}
|
||||
this.setSyncStatus("");
|
||||
}
|
||||
|
||||
setSyncStatus(message, tone = "info") {
|
||||
if (!(this.walletSyncStatus instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
this.walletSyncStatus.textContent = message || "";
|
||||
const toneClass =
|
||||
tone === "success"
|
||||
? "text-status-success"
|
||||
: tone === "error"
|
||||
? "text-status-danger"
|
||||
: "text-muted";
|
||||
this.walletSyncStatus.className = `text-xs ${toneClass}`;
|
||||
}
|
||||
|
||||
async handleToggleSync() {
|
||||
const pubkey = this.mainController.normalizeHexPubkey(
|
||||
this.mainController.getActivePubkey()
|
||||
);
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
const sync = this.getWalletSyncService();
|
||||
const enabled =
|
||||
this.walletSyncToggle instanceof HTMLInputElement
|
||||
? this.walletSyncToggle.checked
|
||||
: false;
|
||||
try {
|
||||
if (enabled) {
|
||||
// Spending capability — require explicit confirmation before publishing.
|
||||
const confirmed =
|
||||
typeof window !== "undefined" && typeof window.confirm === "function"
|
||||
? window.confirm(
|
||||
"This publishes an ENCRYPTED copy of your wallet connection to public relays.\n\n" +
|
||||
"A wallet connect URI can SPEND from your wallet. Anyone who controls your " +
|
||||
"Nostr key could decrypt this and spend from this wallet.\n\nContinue?"
|
||||
)
|
||||
: true;
|
||||
if (!confirmed) {
|
||||
if (this.walletSyncToggle instanceof HTMLInputElement) {
|
||||
this.walletSyncToggle.checked = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.setSyncStatus("Encrypting and publishing…");
|
||||
const result = await sync.enable(pubkey);
|
||||
if (result?.ok) {
|
||||
this.setSyncStatus(
|
||||
`Synced to ${result.accepted}/${result.total} relays.`,
|
||||
"success"
|
||||
);
|
||||
this.mainController.showSuccess("Wallet connection synced (encrypted).");
|
||||
} else {
|
||||
if (this.walletSyncToggle instanceof HTMLInputElement) {
|
||||
this.walletSyncToggle.checked = false;
|
||||
}
|
||||
await sync.disable(pubkey).catch(() => {});
|
||||
this.setSyncStatus(
|
||||
result?.error === "nothing-to-sync"
|
||||
? "Connect a wallet first, then enable sync."
|
||||
: "Could not publish the encrypted copy. Try again.",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.setSyncStatus("Removing the synced copy…");
|
||||
await sync.disable(pubkey);
|
||||
this.setSyncStatus("Sync turned off; the synced copy was cleared.");
|
||||
this.mainController.showSuccess("Wallet sync turned off.");
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.error("[ProfileModal] Wallet sync toggle failed:", error);
|
||||
this.setSyncStatus("Sync failed. Please try again.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async handleRestoreSync() {
|
||||
const pubkey = this.mainController.normalizeHexPubkey(
|
||||
this.mainController.getActivePubkey()
|
||||
);
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
const sync = this.getWalletSyncService();
|
||||
try {
|
||||
this.setSyncStatus("Fetching and decrypting…");
|
||||
const result = await sync.pull(pubkey);
|
||||
if (result?.found && result.imported) {
|
||||
this.setSyncStatus("Restored from your Nostr account.", "success");
|
||||
this.mainController.showSuccess("Wallet connection restored.");
|
||||
this.refreshWalletPaneState();
|
||||
} else if (result?.found && !result.imported) {
|
||||
this.setSyncStatus("Found a copy but could not import it.", "error");
|
||||
} else if (result?.cleared) {
|
||||
this.setSyncStatus("No synced wallet found (it was cleared).");
|
||||
} else {
|
||||
this.setSyncStatus("No synced wallet found on your account.");
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.error("[ProfileModal] Wallet sync restore failed:", error);
|
||||
this.setSyncStatus("Restore failed. Please try again.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
applyWalletControlState() {
|
||||
@@ -151,6 +302,7 @@ export class ProfileWalletController {
|
||||
}
|
||||
}
|
||||
this.updateWalletStatus("Sign in to connect a wallet.", "info");
|
||||
this.renderSyncSection("");
|
||||
this.applyWalletControlState();
|
||||
return;
|
||||
}
|
||||
@@ -183,6 +335,9 @@ export class ProfileWalletController {
|
||||
this.updateWalletStatus("No wallet connected yet.", "info");
|
||||
}
|
||||
|
||||
this.renderSyncSection(
|
||||
this.mainController.normalizeHexPubkey(this.mainController.getActivePubkey())
|
||||
);
|
||||
this.applyWalletControlState();
|
||||
}
|
||||
|
||||
@@ -459,11 +614,35 @@ export class ProfileWalletController {
|
||||
finalVariant = "success";
|
||||
this.mainController.showSuccess("Wallet settings saved.");
|
||||
context.reason = "saved";
|
||||
// Keep the encrypted synced copy current if the user opted in.
|
||||
const sync = this.getWalletSyncService();
|
||||
if (sync.isEnabled(normalizedActive)) {
|
||||
try {
|
||||
await sync.push(normalizedActive);
|
||||
} catch (syncError) {
|
||||
devLogger.warn(
|
||||
"[ProfileModal] Wallet re-sync after save failed:",
|
||||
syncError
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
finalStatus = "Wallet connection removed.";
|
||||
finalVariant = "info";
|
||||
this.mainController.showStatus("Wallet connection removed.");
|
||||
context.reason = "cleared";
|
||||
// Don't leave a now-removed spending URI lingering (encrypted) on relays.
|
||||
const sync = this.getWalletSyncService();
|
||||
if (sync.isEnabled(normalizedActive)) {
|
||||
try {
|
||||
await sync.disable(normalizedActive);
|
||||
} catch (syncError) {
|
||||
devLogger.warn(
|
||||
"[ProfileModal] Wallet sync clear after disconnect failed:",
|
||||
syncError
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
context.success = true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// NWC wallet sync (todo #15): the wallet connection (a bearer SPENDING secret)
|
||||
// is pushed to an encrypted note and pulled+applied on another device.
|
||||
// Scenarios assert observable outcomes at the nwcSettings + sync boundary:
|
||||
// - enable() pushes the current nwcUri (+defaultZap) under the wallet d-tag
|
||||
// - pull() applies the pulled URI via updateActiveNwcSettings (restore)
|
||||
// - disable() clears the published note and flips the opt-in flag
|
||||
// - push() with no wallet connected does not publish
|
||||
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
createWalletSyncService,
|
||||
WALLET_SYNC_DTAG,
|
||||
} from "../js/services/walletSyncService.js";
|
||||
|
||||
const PUBKEY = "b".repeat(64);
|
||||
const URI = "nostr+walletconnect://deadbeef?relay=wss://relay.example&secret=cafe";
|
||||
|
||||
function makeNwcTwin(initial = {}) {
|
||||
let settings = { nwcUri: "", defaultZap: undefined, ...initial };
|
||||
return {
|
||||
applied: [],
|
||||
getActiveNwcSettings: () => ({ ...settings }),
|
||||
updateActiveNwcSettings: async (partial) => {
|
||||
settings = { ...settings, ...partial };
|
||||
// record what was applied (do NOT log the secret in real code)
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const safe = { ...partial };
|
||||
return { ...settings };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeSyncTwin() {
|
||||
let note = null;
|
||||
return {
|
||||
calls: { push: [], pull: 0, clear: 0 },
|
||||
isAvailable: () => true,
|
||||
async push(dTag, payload) {
|
||||
this.calls.push.push({ dTag, payload });
|
||||
note = { payload };
|
||||
return { ok: true, accepted: 1, total: 1 };
|
||||
},
|
||||
async pull() {
|
||||
this.calls.pull += 1;
|
||||
if (!note || note.cleared) {
|
||||
return { found: false, cleared: Boolean(note?.cleared) };
|
||||
}
|
||||
return { found: true, payload: note.payload, createdAt: 1 };
|
||||
},
|
||||
async clear() {
|
||||
this.calls.clear += 1;
|
||||
note = { cleared: true };
|
||||
return { ok: true, accepted: 1, total: 1 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("enable() pushes the current wallet URI under the wallet d-tag", async () => {
|
||||
localStorage.clear();
|
||||
const nwc = makeNwcTwin({ nwcUri: URI, defaultZap: 500 });
|
||||
const sync = makeSyncTwin();
|
||||
const service = createWalletSyncService({ encryptedSync: sync, nwcSettings: nwc });
|
||||
|
||||
assert.equal(service.isEnabled(PUBKEY), false, "off by default");
|
||||
const result = await service.enable(PUBKEY);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(sync.calls.push.length, 1);
|
||||
assert.equal(sync.calls.push[0].dTag, WALLET_SYNC_DTAG);
|
||||
assert.deepEqual(sync.calls.push[0].payload, { nwcUri: URI, defaultZap: 500 });
|
||||
assert.equal(service.isEnabled(PUBKEY), true, "opt-in flag persists");
|
||||
});
|
||||
|
||||
test("pull() restores the wallet URI via updateActiveNwcSettings on a fresh device", async () => {
|
||||
localStorage.clear();
|
||||
const sharedSync = makeSyncTwin();
|
||||
// Device A pushes.
|
||||
await createWalletSyncService({
|
||||
encryptedSync: sharedSync,
|
||||
nwcSettings: makeNwcTwin({ nwcUri: URI, defaultZap: 21 }),
|
||||
}).enable(PUBKEY);
|
||||
|
||||
// Device B (no wallet) pulls.
|
||||
const nwcB = makeNwcTwin({ nwcUri: "" });
|
||||
const serviceB = createWalletSyncService({ encryptedSync: sharedSync, nwcSettings: nwcB });
|
||||
|
||||
const pulled = await serviceB.pull(PUBKEY);
|
||||
assert.equal(pulled.found, true);
|
||||
assert.equal(pulled.imported, true);
|
||||
assert.equal(
|
||||
nwcB.getActiveNwcSettings().nwcUri,
|
||||
URI,
|
||||
"device B must now hold the restored wallet URI",
|
||||
);
|
||||
assert.equal(nwcB.getActiveNwcSettings().defaultZap, 21);
|
||||
});
|
||||
|
||||
test("disable() clears the note and a later pull finds nothing", async () => {
|
||||
localStorage.clear();
|
||||
const nwc = makeNwcTwin({ nwcUri: URI });
|
||||
const sync = makeSyncTwin();
|
||||
const service = createWalletSyncService({ encryptedSync: sync, nwcSettings: nwc });
|
||||
|
||||
await service.enable(PUBKEY);
|
||||
const cleared = await service.disable(PUBKEY);
|
||||
assert.equal(cleared.ok, true);
|
||||
assert.equal(sync.calls.clear, 1);
|
||||
assert.equal(service.isEnabled(PUBKEY), false);
|
||||
|
||||
const pulled = await service.pull(PUBKEY);
|
||||
assert.equal(pulled.found, false);
|
||||
});
|
||||
|
||||
test("push() with no wallet connected does not publish", async () => {
|
||||
localStorage.clear();
|
||||
const nwc = makeNwcTwin({ nwcUri: "" });
|
||||
const sync = makeSyncTwin();
|
||||
const service = createWalletSyncService({ encryptedSync: sync, nwcSettings: nwc });
|
||||
|
||||
const result = await service.push(PUBKEY);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, "nothing-to-sync");
|
||||
assert.equal(sync.calls.push.length, 0);
|
||||
});
|
||||
Reference in new issue
Block a user