Files
bitvid/tests/wallet-sync-service.test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 d552e9017c 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]>
2026-06-21 17:57:39 -04:00

126 lines
4.4 KiB
JavaScript

// 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);
});