Files
client/www/ndk-worker.js
Laan Tungir a42edcf6d6 Feed upgrade Phases 3-5: zap capability indicators, rail selector, balance checks
Phase 3 — Zap Capability Indicators:
- Added resolveZapCapabilities() to zaps.mjs with 5-min TTL cache
- Checks lud16 (from profile cache) for Lightning capability
- Checks kind 10019 (via walletFetchMintList) for nutzap capability
- Computes shared mints between sender and recipient
- Added capability badges (🥜/) to zap button in post-interactions.mjs
- Badges use var(--accent-color) for available, var(--muted-color) for unavailable
- Added proactive kind 10019 batch fetch for followed users in ndk-worker.js
- New IDB store ndk-nutzap-mintlists for persistent caching (6h TTL)
- Non-blocking: badges appear after async resolution

Phase 4 — Zap Rail Selector:
- Updated promptZapDetails with rail toggle (🥜 Nutzap /  Lightning)
- Shows Cashu balance and insufficient balance warning
- Shows shared mint info when nutzap selected
- Default rail: nutzap for <1000 sats, Lightning for >=1000 sats
- Updated handleZapClick to resolve capabilities and respect user's rail choice
- Added zap rail toggle CSS to client.css (all CSS variables)
- Wired walletGetBalance through post-interactions2.mjs and post-feed.html

Phase 5 — Balance Check and Error Handling:
- Pre-flight balance check before sending (hard stop if insufficient)
- Barely-sufficient balance confirmation dialog
- Friendly error messages for melt/swap/timeout/insufficient failures
- Post-zap balance refresh via ndkWalletBalance event

Review fix: threaded walletGetMints through post-interactions2.mjs and post-feed.html
so resolveZapCapabilities can compute shared mints correctly
2026-06-25 20:48:38 -04:00

6903 lines
249 KiB
JavaScript

/**
* NDK SharedWorker
* Manages NDK instance with SQLite WASM cache across multiple tabs
* Handles user authentication, profile fetching, and relay management
*/
// Fetch version from JSON file and log when ready
let WORKER_VERSION = 'unknown';
(async () => {
try {
const response = await fetch('./js/version.json');
const versionData = await response.json();
WORKER_VERSION = versionData.VERSION_NUMBER || 'unknown';
console.log(`[Worker v${WORKER_VERSION}] SharedWorker initializing...`);
} catch (e) {
console.warn('[Worker] Could not load version:', e);
console.log(`[Worker v${WORKER_VERSION}] SharedWorker initializing...`);
}
})();
// Import scripts from same directory (relative paths)
// Only import if not already loaded (prevents double-loading in SharedWorker)
if (typeof self.NostrTools === 'undefined') {
importScripts('./nostr.bundle.js');
console.log('[Worker] nostr.bundle.js loaded, NostrTools available:', typeof self.NostrTools !== 'undefined');
}
if (typeof self.NDK === 'undefined') {
// Make sure NostrTools is available as window.NostrTools for the NDK bundle
if (typeof window === 'undefined') {
self.window = self;
}
importScripts('./ndk-core.bundle.js');
console.log('[Worker] ndk-core.bundle.js loaded, NDK available:', typeof self.NDK !== 'undefined');
}
// Get NDK exports from the bundle
const NDKExports = self.NDK || window.NDK;
// Log what's available in the NDK export
console.log('[Worker] NDK exports available:', Object.keys(NDKExports || {}));
console.log('[Worker] NDK exports type:', typeof NDKExports);
console.log('[Worker] NDK exports.default:', typeof NDKExports?.default);
// The bundle might export NDK as default or as a named export
// Try default first, then look for a class that looks like the main NDK class
let NDKConstructor = NDKExports?.default;
// If no default, the bundle itself might be the constructor (IIFE pattern)
if (!NDKConstructor && typeof NDKExports === 'function') {
NDKConstructor = NDKExports;
}
// Get other classes - check multiple possible locations
const NDKCacheBrowser = NDKExports?.NDKCacheBrowser || NDKExports?.default?.NDKCacheBrowser;
const NDKCacheAdapterDexie = NDKExports?.NDKCacheAdapterDexie || NDKExports?.default?.NDKCacheAdapterDexie;
const NDKRelaySet = NDKExports?.NDKRelaySet;
const NDKRelayAuthPolicies = NDKExports?.NDKRelayAuthPolicies || NDKExports?.default?.NDKRelayAuthPolicies;
const NDKKind = NDKExports?.NDKKind || NDKExports?.default?.NDKKind;
const NIP17Protocol = NDKExports?.NIP17Protocol || NDKExports?.default?.NIP17Protocol;
const NDKEventCtor = NDKExports?.NDKEvent || NDKExports?.default?.NDKEvent;
const NDKUserCtor = NDKExports?.NDKUser || NDKExports?.default?.NDKUser;
const ndkGiftWrap = NDKExports?.giftWrap || NDKExports?.default?.giftWrap;
const NDKCashuMintList = NDKExports?.NDKCashuMintList || NDKExports?.default?.NDKCashuMintList;
const CashuMint = NDKExports?.CashuMint || NDKExports?.default?.CashuMint;
const CashuWallet = NDKExports?.CashuWallet || NDKExports?.default?.CashuWallet;
const getEncodedTokenV4 = NDKExports?.getEncodedTokenV4 || NDKExports?.default?.getEncodedTokenV4;
const getDecodedToken = NDKExports?.getDecodedToken || NDKExports?.default?.getDecodedToken;
const nostrTools = self.NostrTools || window.NostrTools || {};
const nostrGenerateSecretKey = nostrTools.generateSecretKey;
const nostrGetPublicKey = nostrTools.getPublicKey;
console.log('[Worker] NDK constructor:', typeof NDKConstructor);
console.log('[Worker] NDKCacheBrowser:', typeof NDKCacheBrowser);
console.log('[Worker] NDKCacheAdapterDexie:', typeof NDKCacheAdapterDexie);
console.log('[Worker] NDKRelayAuthPolicies:', typeof NDKRelayAuthPolicies, NDKRelayAuthPolicies ? Object.keys(NDKRelayAuthPolicies) : 'not found');
console.log('[Worker] Checking for cache-related exports...');
const cacheExports = Object.keys(NDKExports || {}).filter(k => k.toLowerCase().includes('cache'));
console.log('[Worker] Cache-related exports:', cacheExports);
if (!NDKConstructor) {
console.error('[Worker] Available exports:', NDKExports);
throw new Error('NDK constructor not found in bundle exports');
}
if (!NDKCacheBrowser) {
console.warn('[Worker] NDKCacheBrowser not found in bundle, will use NDK without cache');
}
// Bootstrap relays (used initially to fetch user data)
const BOOTSTRAP_RELAYS = [
'wss://relay.laantungir.net',
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.primal.net'
];
const HAS_INDEXEDDB = typeof indexedDB !== 'undefined' && !!indexedDB;
let INDEXEDDB_USABLE = HAS_INDEXEDDB;
if (!HAS_INDEXEDDB) {
console.error('[Worker] IndexedDB API unavailable; persistent cache/storage features will be disabled');
}
const IDB_DIAG_TIMEOUT_MS = 5000;
function formatIdbError(error) {
if (!error) return 'unknown error';
if (typeof error === 'string') return error;
if (error?.name || error?.message) {
return `${error.name || 'Error'}: ${error.message || 'no message'}`;
}
try {
return JSON.stringify(error);
} catch (_err) {
return String(error);
}
}
function runIndexedDbOpenDiagnostic(dbName, version) {
return new Promise((resolve) => {
const startedAt = Date.now();
if (!HAS_INDEXEDDB) {
resolve({
ok: false,
dbName,
phase: 'api-missing',
elapsedMs: 0,
details: 'indexedDB global is unavailable'
});
return;
}
let settled = false;
let req;
const timeoutId = setTimeout(() => {
if (settled) return;
settled = true;
resolve({
ok: false,
dbName,
phase: 'timeout',
elapsedMs: Date.now() - startedAt,
details: `indexedDB.open did not resolve within ${IDB_DIAG_TIMEOUT_MS}ms`
});
}, IDB_DIAG_TIMEOUT_MS);
const finish = (payload) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
resolve({
dbName,
elapsedMs: Date.now() - startedAt,
...payload
});
};
try {
req = version === undefined ? indexedDB.open(dbName) : indexedDB.open(dbName, version);
} catch (openErr) {
finish({
ok: false,
phase: 'open-throw',
details: formatIdbError(openErr)
});
return;
}
req.onblocked = () => {
console.warn('[Worker] IndexedDB diagnostic blocked event', {
dbName,
oldVersion: req?.result?.version,
targetVersion: version ?? 'default'
});
};
req.onupgradeneeded = () => {
const db = req.result;
console.log('[Worker] IndexedDB diagnostic upgrade needed', {
dbName,
oldVersion: req.oldVersion,
newVersion: req.newVersion,
actualVersion: db?.version
});
};
req.onerror = () => {
finish({
ok: false,
phase: 'error',
details: formatIdbError(req.error)
});
};
req.onsuccess = () => {
const db = req.result;
const objectStores = [];
try {
for (let i = 0; i < db.objectStoreNames.length; i += 1) {
objectStores.push(db.objectStoreNames[i]);
}
} catch (_err) {
// Ignore objectStoreNames read issues in diagnostics
}
const dbVersion = db?.version;
try {
db.onversionchange = () => {
console.warn('[Worker] IndexedDB diagnostic connection got versionchange, closing', {
dbName,
dbVersion
});
db.close();
};
db.close();
} catch (_closeErr) {
// ignore close errors in diagnostics
}
finish({
ok: true,
phase: 'success',
details: `version=${dbVersion}, stores=${objectStores.join(',') || '(none)'}`
});
};
});
}
async function assertIndexedDbReadyOrThrow() {
if (!HAS_INDEXEDDB) {
throw new Error('[Worker] IndexedDB missing in this worker context');
}
const checks = [
{ name: 'ndk-shared' }
];
const results = [];
for (const check of checks) {
const result = await runIndexedDbOpenDiagnostic(check.name, check.version);
results.push(result);
console.log('[Worker] IndexedDB diagnostic result', result);
}
const failed = results.filter(r => !r.ok);
if (failed.length > 0) {
const details = failed.map(f => `${f.dbName}:${f.phase}:${f.details}`).join(' | ');
throw new Error(`[Worker] IndexedDB diagnostics failed: ${details}`);
}
}
// Worker state
let ndk = null;
let cacheAdapter = null;
let currentPubkey = null;
let cachedProfile = null;
let connectedPorts = [];
let activeSigningPort = null;
let activeSubscriptions = new Map(); // subId -> { sub, filters, opts, seenEventIds, lastEventCreatedAt }
let pendingSignRequests = new Map(); // requestId -> { resolve, reject, port }
let signRequestCounter = 0;
// Direct cashu-ts wallet state (shared across all pages/tabs)
let directWalletStatus = 'initial';
let directWalletLoaded = false;
let directMintList = [];
let directProofStore = {}; // mintUrl -> Proof[]
let directTransactions = [];
const directMintLocalUpdatedAt = new Map(); // mintUrl -> unix sec for local proof mutations
const pendingDeposits = new Map(); // quoteId -> { mintUrl, amount, timerId, startedAt }
const pendingSends = new Map(); // sendId -> { mintUrl, proofs, amount, memo, timerId, startedAt, txId }
const directWalletCache = new Map(); // mintUrl -> CashuWallet
let directNutzapPrivateKeyHex = null;
let directNutzapP2pk = null;
// Startup wallet event subscriptions (phase 1 monitoring)
let startupWalletSub = null;
let startupWalletSubPubkey = null;
let startupDownloadsPromise = null;
let startupDownloadsPubkey = null;
let startupDownloadsCompletedForPubkey = null;
let initCompletedPromise = null;
const STARTUP_DOWNLOADS_TIMEOUT_MS = 12000;
// Lightweight wallet event cache (used to hydrate direct proof store)
let lightwalletTokenEvents = new Map(); // eventId -> { mint, unit, proofTotal, proofs }
let lightwalletDeletedIds = new Set(); // eventIds deleted via kind 5 or token del fields
let lightwalletHasWallet = false;
// Spending history (kind 7376) cache
let spendingHistoryLastFetchAtMs = 0;
let spendingHistoryFetchPromise = null;
// Nutzap mint list (kind 10019) cache for followed users — proactively
// fetched on startup so resolveZapCapabilities() can hit IDB instead of
// going to relays for every followed author.
const NUTZAP_MINTLIST_DB_NAME = 'ndk-nutzap-mintlists';
const NUTZAP_MINTLIST_DB_VERSION = 1;
const NUTZAP_MINTLIST_STORE = 'mintlists'; // keyPath: pubkey
const NUTZAP_MINTLIST_PREFETCH_BATCH = 50; // max authors per fetchEvents call
const NUTZAP_MINTLIST_PREFETCH_TTL_MS = 6 * 60 * 60 * 1000; // 6h freshness guard
// Relay activity tracking
const RELAY_EVENT_LOG_LIMIT = 200;
const RELAY_EVENTS_DB_NAME = 'relay-events';
const RELAY_EVENTS_DB_VERSION = 2;
const RELAY_EVENTS_STORE = 'events';
const RELAY_EVENTS_PRUNE_INTERVAL_MS = 60000;
let relayActivityStats = new Map(); // relay.url -> { readCount, writeCount, lastReadTime, lastWriteTime }
let relaysWithListeners = new Set(); // Track which relays have event listeners attached
let relayTypes = new Map(); // relay.url -> 'read'|'write'|'both' (from kind 10002)
let latestRelayListCreatedAt = 0; // guard against stale kind 10002 events arriving out of order
let relayEventsPruneTimer = null;
let relayConnectAttemptStartedAt = new Map(); // relay.url -> timestamp ms
let relayLastDisconnectMeta = new Map(); // relay.url -> { timestamp, code, reason, wasClean }
let relayKeepaliveTimers = new Map(); // relay.url -> interval id
const RELAY_KEEPALIVE_INTERVAL_MS = 30000;
const SUBSCRIPTION_DEDUP_MAX_IDS = 5000;
const SUBSCRIPTION_DEDUP_PRUNE_COUNT = 1500;
// App-wide user settings state
const USER_SETTINGS_DB_NAME = 'ndk-shared-settings';
const USER_SETTINGS_DB_VERSION = 2;
const USER_SETTINGS_STORE = 'kv';
const USER_SETTINGS_D_TAG = 'user-settings';
const USER_SETTINGS_CACHE_KEY_PREFIX = 'user-settings:';
const SETTINGS_SCHEMA_VERSION = 2;
let userSettings = null;
let userSettingsPubkey = null;
let userSettingsHydrated = false;
let settingsPublishTimeout = null;
// Worker-side mute list state (kind 10000)
const workerPublicMutes = { p: new Set(), t: new Set(), word: new Set(), e: new Set() };
const workerPrivateMutes = { p: new Set(), t: new Set(), word: new Set(), e: new Set() };
let workerMuteListEventId = '';
let workerMuteListCreatedAt = 0;
let workerMuteListLoadedAt = 0;
function clearWorkerMuteSets(target) {
for (const key of Object.keys(target || {})) {
target[key]?.clear?.();
}
}
function parseWorkerMuteTags(tags, target) {
if (!Array.isArray(tags) || !target) return;
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) continue;
const name = String(tag[0] || '').trim();
const value = String(tag[1] || '').trim();
if (!value) continue;
if (name === 'p') {
const normalized = normalizeHexPubkey(value) || value;
target.p.add(normalized);
} else if (name === 't') {
target.t.add(value.toLowerCase());
} else if (name === 'word') {
target.word.add(value.toLowerCase());
} else if (name === 'e') {
target.e.add(value);
}
}
}
function isWorkerNip04Content(content) {
return typeof content === 'string' && content.includes('?iv=');
}
function isWorkerMutedPubkey(pubkey) {
const normalized = normalizeHexPubkey(pubkey) || String(pubkey || '').trim();
if (!normalized) return false;
return workerPublicMutes.p.has(normalized) || workerPrivateMutes.p.has(normalized);
}
function isWorkerMutedHashtag(tag) {
const value = String(tag || '').trim().toLowerCase();
if (!value) return false;
return workerPublicMutes.t.has(value) || workerPrivateMutes.t.has(value);
}
function isWorkerMutedWord(content) {
const lower = String(content || '').toLowerCase();
if (!lower) return false;
for (const word of workerPublicMutes.word) {
if (lower.includes(word)) return true;
}
for (const word of workerPrivateMutes.word) {
if (lower.includes(word)) return true;
}
return false;
}
function isWorkerMutedThread(eventId) {
const id = String(eventId || '').trim();
if (!id) return false;
return workerPublicMutes.e.has(id) || workerPrivateMutes.e.has(id);
}
function isWorkerEventMuted(event) {
if (!event || typeof event !== 'object') return false;
if (isWorkerMutedPubkey(event.pubkey)) return true;
if (isWorkerMutedThread(event.id)) return true;
if (Array.isArray(event.tags)) {
for (const tag of event.tags) {
if (!Array.isArray(tag)) continue;
if (tag[0] === 't' && tag[1] && isWorkerMutedHashtag(tag[1])) return true;
}
}
if (isWorkerMutedWord(event.content)) return true;
return false;
}
function getWorkerMuteListSnapshot() {
return {
eventId: workerMuteListEventId,
createdAt: workerMuteListCreatedAt,
loadedAt: workerMuteListLoadedAt,
public: {
p: Array.from(workerPublicMutes.p),
t: Array.from(workerPublicMutes.t),
word: Array.from(workerPublicMutes.word),
e: Array.from(workerPublicMutes.e),
},
private: {
p: Array.from(workerPrivateMutes.p),
t: Array.from(workerPrivateMutes.t),
word: Array.from(workerPrivateMutes.word),
e: Array.from(workerPrivateMutes.e),
},
};
}
function broadcastMuteListUpdated(reason = 'loaded') {
broadcast({
type: 'muteListUpdated',
data: {
reason,
...getWorkerMuteListSnapshot(),
},
});
}
function getDefaultUserSettings() {
return {
v: SETTINGS_SCHEMA_VERSION,
updatedAt: 0,
global_llm: {},
global_zaps: {},
global_ui: {},
post: {
viewed: {
scrollToMark: false
}
},
strudel: {},
global_relays: {},
global_experimental: {}
};
}
function isPlainObject(value) {
return value && typeof value === 'object' && !Array.isArray(value);
}
function deepMerge(base, patch) {
const out = isPlainObject(base) ? { ...base } : {};
if (!isPlainObject(patch)) return out;
for (const [k, v] of Object.entries(patch)) {
if (isPlainObject(v) && isPlainObject(out[k])) {
out[k] = deepMerge(out[k], v);
} else if (isPlainObject(v)) {
out[k] = deepMerge({}, v);
} else {
out[k] = v;
}
}
return out;
}
function normalizeUserSettings(input) {
const incoming = isPlainObject(input) ? { ...input } : {};
// v1 -> v2 namespace migration (retain existing v2 keys if already present)
if (!isPlainObject(incoming.global_llm) && isPlainObject(incoming.ai)) incoming.global_llm = incoming.ai;
if (!isPlainObject(incoming.global_zaps) && isPlainObject(incoming.zaps)) incoming.global_zaps = incoming.zaps;
if (!isPlainObject(incoming.global_ui) && isPlainObject(incoming.ui)) incoming.global_ui = incoming.ui;
if (!isPlainObject(incoming.global_relays) && isPlainObject(incoming.relays)) incoming.global_relays = incoming.relays;
if (!isPlainObject(incoming.global_experimental) && isPlainObject(incoming.experimental)) incoming.global_experimental = incoming.experimental;
const merged = deepMerge(getDefaultUserSettings(), incoming);
// Remove deprecated v1 aliases from normalized output.
delete merged.ai;
delete merged.zaps;
delete merged.ui;
delete merged.relays;
delete merged.experimental;
merged.v = SETTINGS_SCHEMA_VERSION;
merged.updatedAt = Number(merged.updatedAt || 0);
return merged;
}
function getSettingsCacheKey(pubkey) {
return `${USER_SETTINGS_CACHE_KEY_PREFIX}${pubkey}`;
}
async function openUserSettingsDb() {
if (!HAS_INDEXEDDB || !INDEXEDDB_USABLE) {
throw new Error('IndexedDB unavailable or unhealthy');
}
return await new Promise((resolve, reject) => {
const req = indexedDB.open(USER_SETTINGS_DB_NAME, USER_SETTINGS_DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(USER_SETTINGS_STORE)) {
db.createObjectStore(USER_SETTINGS_STORE, { keyPath: 'key' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function readCachedUserSettings(pubkey) {
const key = getSettingsCacheKey(pubkey);
try {
const db = await openUserSettingsDb();
if (!db.objectStoreNames.contains(USER_SETTINGS_STORE)) {
db.close();
return null;
}
const row = await new Promise((resolve) => {
const tx = db.transaction(USER_SETTINGS_STORE, 'readonly');
const req = tx.objectStore(USER_SETTINGS_STORE).get(key);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => resolve(null);
});
db.close();
return row?.value ? normalizeUserSettings(row.value) : null;
} catch (err) {
console.warn('[Worker] Failed reading cached user settings:', err.message);
return null;
}
}
async function writeCachedUserSettings(pubkey, settings) {
const key = getSettingsCacheKey(pubkey);
try {
const db = await openUserSettingsDb();
if (!db.objectStoreNames.contains(USER_SETTINGS_STORE)) {
db.close();
return;
}
await new Promise((resolve, reject) => {
const tx = db.transaction(USER_SETTINGS_STORE, 'readwrite');
tx.objectStore(USER_SETTINGS_STORE).put({
key,
value: normalizeUserSettings(settings),
cachedAt: Date.now()
});
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
} catch (err) {
console.warn('[Worker] Failed writing cached user settings:', err.message);
}
}
async function openRelayEventsDb() {
if (!HAS_INDEXEDDB || !INDEXEDDB_USABLE) {
throw new Error('IndexedDB unavailable or unhealthy');
}
return await new Promise((resolve, reject) => {
const req = indexedDB.open(RELAY_EVENTS_DB_NAME, RELAY_EVENTS_DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
let store;
if (!db.objectStoreNames.contains(RELAY_EVENTS_STORE)) {
store = db.createObjectStore(RELAY_EVENTS_STORE, { keyPath: 'id', autoIncrement: true });
} else {
store = req.transaction.objectStore(RELAY_EVENTS_STORE);
}
if (!store.indexNames.contains('relayUrl')) {
store.createIndex('relayUrl', 'relayUrl', { unique: false });
}
if (!store.indexNames.contains('timestamp')) {
store.createIndex('timestamp', 'timestamp', { unique: false });
}
if (!store.indexNames.contains('relayUrl_timestamp')) {
store.createIndex('relayUrl_timestamp', ['relayUrl', 'timestamp'], { unique: false });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
// -----------------------------------------------------------------------------
// Nutzap mint list (kind 10019) IDB cache for followed users
// -----------------------------------------------------------------------------
async function openNutzapMintListDb() {
if (!HAS_INDEXEDDB || !INDEXEDDB_USABLE) {
throw new Error('IndexedDB unavailable or unhealthy');
}
return await new Promise((resolve, reject) => {
const req = indexedDB.open(NUTZAP_MINTLIST_DB_NAME, NUTZAP_MINTLIST_DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(NUTZAP_MINTLIST_STORE)) {
db.createObjectStore(NUTZAP_MINTLIST_STORE, { keyPath: 'pubkey' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
/**
* Store a parsed kind 10019 mint list for a pubkey in IDB.
* @param {string} pubkey
* @param {{ hasMintList:boolean, mints:string[], relays:string[], p2pk:string|null, eventId:string|null, created_at:number }} entry
*/
async function putNutzapMintListToDb(pubkey, entry) {
if (!pubkey) return;
try {
const db = await openNutzapMintListDb();
if (!db.objectStoreNames.contains(NUTZAP_MINTLIST_STORE)) {
db.close();
return;
}
await new Promise((resolve, reject) => {
const tx = db.transaction(NUTZAP_MINTLIST_STORE, 'readwrite');
tx.objectStore(NUTZAP_MINTLIST_STORE).put({
pubkey,
...entry,
cachedAt: Date.now()
});
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
} catch (error) {
console.warn('[Worker] putNutzapMintListToDb failed', error?.message || error);
}
}
/**
* Read a cached kind 10019 mint list for a pubkey from IDB.
* Returns null when missing or stale (older than NUTZAP_MINTLIST_PREFETCH_TTL_MS).
* @param {string} pubkey
* @returns {Promise<Object|null>}
*/
async function getNutzapMintListFromDb(pubkey) {
if (!pubkey) return null;
try {
const db = await openNutzapMintListDb();
if (!db.objectStoreNames.contains(NUTZAP_MINTLIST_STORE)) {
db.close();
return null;
}
const row = await new Promise((resolve) => {
const tx = db.transaction(NUTZAP_MINTLIST_STORE, 'readonly');
const req = tx.objectStore(NUTZAP_MINTLIST_STORE).get(pubkey);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => resolve(null);
});
db.close();
if (!row) return null;
if (Date.now() - Number(row.cachedAt || 0) > NUTZAP_MINTLIST_PREFETCH_TTL_MS) {
return null; // stale
}
return row;
} catch (error) {
console.warn('[Worker] getNutzapMintListFromDb failed', error?.message || error);
return null;
}
}
function serializeRelayEventData(data) {
if (data === null || data === undefined) return null;
if (typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean') return data;
try {
return JSON.stringify(data);
} catch (_error) {
try {
return String(data);
} catch (_stringErr) {
return '[unserializable relay event data]';
}
}
}
async function writeRelayEventToDb(entry) {
if (!entry?.relayUrl || !entry?.eventType || !entry?.timestamp) return;
try {
const db = await openRelayEventsDb();
if (!db.objectStoreNames.contains(RELAY_EVENTS_STORE)) {
db.close();
return;
}
await new Promise((resolve, reject) => {
const tx = db.transaction(RELAY_EVENTS_STORE, 'readwrite');
tx.objectStore(RELAY_EVENTS_STORE).add(entry);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
} catch (err) {
console.warn('[Worker] Failed writing relay event to IndexedDB:', err?.message || err);
}
}
async function pruneRelayEventsForUrl(relayUrl) {
const normalized = normalizeRelayUrl(relayUrl);
if (!normalized) return;
try {
const db = await openRelayEventsDb();
if (!db.objectStoreNames.contains(RELAY_EVENTS_STORE)) {
db.close();
return;
}
const rows = await new Promise((resolve, reject) => {
const tx = db.transaction(RELAY_EVENTS_STORE, 'readonly');
const idx = tx.objectStore(RELAY_EVENTS_STORE).index('relayUrl');
const req = idx.getAll(IDBKeyRange.only(normalized));
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
if (rows.length <= RELAY_EVENT_LOG_LIMIT) {
db.close();
return;
}
rows.sort((a, b) => Number(a.timestamp || 0) - Number(b.timestamp || 0));
const toDelete = rows.slice(0, rows.length - RELAY_EVENT_LOG_LIMIT);
await new Promise((resolve, reject) => {
const tx = db.transaction(RELAY_EVENTS_STORE, 'readwrite');
const store = tx.objectStore(RELAY_EVENTS_STORE);
for (const row of toDelete) {
if (row?.id !== undefined) {
store.delete(row.id);
}
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
} catch (err) {
console.warn('[Worker] Failed pruning relay events:', err?.message || err);
}
}
function scheduleRelayEventPruning() {
if (relayEventsPruneTimer) return;
relayEventsPruneTimer = setInterval(async () => {
const relayUrls = new Set();
for (const [url] of relayTypes.entries()) relayUrls.add(normalizeRelayUrl(url));
for (const [url] of relayActivityStats.entries()) relayUrls.add(normalizeRelayUrl(url));
if (ndk?.pool?.relays) {
for (const relay of ndk.pool.relays.values()) {
relayUrls.add(normalizeRelayUrl(relay?.url));
}
}
for (const relayUrl of relayUrls) {
if (relayUrl) {
await pruneRelayEventsForUrl(relayUrl);
}
}
}, RELAY_EVENTS_PRUNE_INTERVAL_MS);
}
/**
* Initialize or get relay activity stats for a relay
*/
function getRelayStats(relayUrl) {
if (!relayActivityStats.has(relayUrl)) {
relayActivityStats.set(relayUrl, {
readCount: 0,
writeCount: 0,
lastReadTime: null,
lastWriteTime: null
});
}
return relayActivityStats.get(relayUrl);
}
/**
* Increment read count for a relay and broadcast activity
*/
function trackRelayRead(relayUrl) {
const stats = getRelayStats(relayUrl);
stats.readCount++;
stats.lastReadTime = Date.now();
// Broadcast activity to all connected pages
broadcast({
type: 'relayActivity',
relayUrl: relayUrl,
activity: 'read',
stats: {
readCount: stats.readCount,
writeCount: stats.writeCount,
lastReadTime: stats.lastReadTime,
lastWriteTime: stats.lastWriteTime
}
});
}
/**
* Increment write count for a relay and broadcast activity
*/
function trackRelayWrite(relayUrl) {
const stats = getRelayStats(relayUrl);
stats.writeCount++;
stats.lastWriteTime = Date.now();
// Broadcast activity to all connected pages
broadcast({
type: 'relayActivity',
relayUrl: relayUrl,
activity: 'write',
stats: {
readCount: stats.readCount,
writeCount: stats.writeCount,
lastReadTime: stats.lastReadTime,
lastWriteTime: stats.lastWriteTime
}
});
}
/**
* Get all relay activity stats
*/
function getAllRelayStats() {
const stats = {};
for (const [url, data] of relayActivityStats.entries()) {
stats[url] = { ...data };
}
return stats;
}
function normalizeRelayUrl(relayUrl) {
if (!relayUrl) return null;
try {
return new URL(relayUrl).toString();
} catch (_error) {
return String(relayUrl);
}
}
function parseRelayFrame(message) {
if (Array.isArray(message)) return message;
if (typeof message !== 'string') return null;
try {
const parsed = JSON.parse(message);
return Array.isArray(parsed) ? parsed : null;
} catch (_error) {
return null;
}
}
function isWriteOnlyRelay(relayUrl) {
const normalized = normalizeRelayUrl(relayUrl);
if (!normalized) return false;
const directType = relayTypes.get(normalized) || relayTypes.get(relayUrl);
if (directType === 'write') return true;
for (const [storedUrl, storedType] of relayTypes.entries()) {
if (storedType !== 'write') continue;
if (normalizeRelayUrl(storedUrl) === normalized) return true;
}
return false;
}
function logRelayEvent(relayUrl, eventType, data = undefined, side = 'relay', timestamp = Date.now()) {
const normalized = normalizeRelayUrl(relayUrl);
if (!normalized || !eventType) return null;
const entry = {
relayUrl: normalized,
timestamp,
eventType,
side: side === 'client' ? 'client' : 'relay',
data: serializeRelayEventData(data)
};
// Fire-and-forget persistence so relay handling is never blocked by IndexedDB I/O.
void writeRelayEventToDb(entry);
return entry;
}
function broadcastRelayEvent(relayUrl, eventType, data = undefined, side = 'relay') {
const entry = logRelayEvent(relayUrl, eventType, data, side, Date.now());
if (!entry) return;
broadcast({
type: 'relayEvent',
relayUrl: normalizeRelayUrl(relayUrl),
event: eventType,
side: entry.side,
data: entry.data,
timestamp: entry.timestamp
});
}
function markRelayConnectAttempt(relayUrl) {
const normalized = normalizeRelayUrl(relayUrl);
if (normalized) {
relayConnectAttemptStartedAt.set(normalized, Date.now());
}
}
function getRelayConnectLatencyMs(relayUrl) {
const normalized = normalizeRelayUrl(relayUrl);
if (!normalized) return null;
const startedAt = relayConnectAttemptStartedAt.get(normalized);
if (!startedAt) return null;
return Math.max(0, Date.now() - startedAt);
}
function stopRelayKeepalive(relayUrl) {
const normalized = normalizeRelayUrl(relayUrl);
if (!normalized) return;
const timer = relayKeepaliveTimers.get(normalized);
if (timer) {
clearInterval(timer);
relayKeepaliveTimers.delete(normalized);
}
}
function startRelayKeepalive(relay) {
const normalized = normalizeRelayUrl(relay?.url);
if (!normalized) return;
stopRelayKeepalive(normalized);
const timer = setInterval(() => {
try {
if (!relay || relay.status < 5) {
stopRelayKeepalive(normalized);
return;
}
const send = relay.connectivity?.send?.bind(relay.connectivity);
if (!send) {
console.warn('[Worker] Keepalive skipped, relay connectivity.send unavailable for:', relay?.url);
stopRelayKeepalive(normalized);
return;
}
const keepaliveId = `ka-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const impossibleId = 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
send(JSON.stringify(["REQ", keepaliveId, { ids: [impossibleId], limit: 1 }])).catch(() => {});
setTimeout(() => {
send(JSON.stringify(["CLOSE", keepaliveId])).catch(() => {});
}, 120);
} catch (err) {
console.warn('[Worker] Keepalive send failed for relay:', relay?.url, err?.message || err);
stopRelayKeepalive(normalized);
}
}, RELAY_KEEPALIVE_INTERVAL_MS);
relayKeepaliveTimers.set(normalized, timer);
console.log(`[Worker] Keepalive started for ${normalized} (${RELAY_KEEPALIVE_INTERVAL_MS}ms)`);
}
function normalizeSubscriptionFilters(filters) {
const filterArray = Array.isArray(filters) ? filters : [filters];
return filterArray.map(filter => ({ ...(filter || {}) }));
}
function patchFiltersWithSince(filters, sinceSec) {
if (!Number.isFinite(sinceSec) || sinceSec <= 0) return normalizeSubscriptionFilters(filters);
return normalizeSubscriptionFilters(filters).map(filter => {
const existingSince = Number(filter.since || 0);
return {
...filter,
since: existingSince > 0 ? Math.max(existingSince, sinceSec) : sinceSec
};
});
}
function pruneSeenEventIds(seenEventIds) {
if (seenEventIds.size <= SUBSCRIPTION_DEDUP_MAX_IDS) return;
const toDelete = Math.min(SUBSCRIPTION_DEDUP_PRUNE_COUNT, seenEventIds.size - SUBSCRIPTION_DEDUP_MAX_IDS + SUBSCRIPTION_DEDUP_PRUNE_COUNT);
let count = 0;
for (const id of seenEventIds) {
seenEventIds.delete(id);
count += 1;
if (count >= toDelete) break;
}
}
function wireSubscriptionHandlers(subId, state, filtersForCache) {
if (!state?.sub) return;
state.sub.on('event', async (event) => {
if (state.seenEventIds.has(event.id)) {
console.log('[Worker] Dedup dropped event for sub:', subId, 'id:', event.id);
return;
}
state.seenEventIds.add(event.id);
pruneSeenEventIds(state.seenEventIds);
const createdAt = Number(event?.created_at || 0);
if (createdAt > 0) {
state.lastEventCreatedAt = Math.max(Number(state.lastEventCreatedAt || 0), createdAt);
}
console.log('[Worker] Event received for sub:', subId, 'kind:', event.kind, 'id:', event.id);
if (isWorkerEventMuted(event)) {
console.log('[Worker][mute] Filtered muted event for sub:', subId, {
id: event?.id || null,
kind: event?.kind || null,
pubkey: event?.pubkey || null,
});
return;
}
// Track relay read activity if relay information is available
if (event.relay) {
trackRelayRead(event.relay.url);
}
// Cache the event to IndexedDB
if (ndk.cacheAdapter && typeof ndk.cacheAdapter.setEvent === 'function') {
try {
await ndk.cacheAdapter.setEvent(event, filtersForCache, event.relay);
console.log('[Worker] Cached event:', event.id, 'from relay:', event.relay?.url);
} catch (error) {
console.error('[Worker] Failed to cache event:', error);
}
}
broadcast({
type: 'event',
subId: subId,
data: event.rawEvent()
});
});
state.sub.on('eose', () => {
console.log('[Worker] EOSE for sub:', subId);
broadcast({
type: 'eose',
subId: subId
});
});
}
function recreateActiveSubscriptionsForReconnect(relayUrl, reconnectSinceSec) {
if (!ndk || activeSubscriptions.size === 0) return;
for (const [subId, state] of activeSubscriptions.entries()) {
if (!state?.sub) continue;
const fallbackSinceSec = Number(state.lastEventCreatedAt || 0);
const effectiveSinceSec = Math.max(Number(reconnectSinceSec || 0), fallbackSinceSec);
const patchedFilters = patchFiltersWithSince(state.filters, effectiveSinceSec);
try {
state.sub.stop();
} catch (_stopErr) {
// ignore stop errors during reconnect rebuild
}
state.filters = patchedFilters;
state.sub = ndk.subscribe(patchedFilters, state.opts || {});
wireSubscriptionHandlers(subId, state, patchedFilters);
console.log('[Worker] Recreated subscription after reconnect:', {
subId,
relayUrl,
since: effectiveSinceSec,
filterCount: patchedFilters.length
});
}
}
/**
* Attach event listeners to a relay for debugging
* Only attaches once per relay to avoid duplicates
*/
function attachRelayEventListeners(relay) {
// Skip if already attached
if (relaysWithListeners.has(relay.url)) {
console.log('[Worker] Event listeners already attached to:', relay.url);
return;
}
console.log('[Worker] Attaching event listeners to:', relay.url);
relaysWithListeners.add(relay.url);
const attachWsCloseListener = () => {
const ws = relay?.connectivity?.ws;
if (!ws || ws.__workerCloseListenerAttached) return;
ws.__workerCloseListenerAttached = true;
ws.addEventListener('close', (event) => {
const normalized = normalizeRelayUrl(relay.url);
if (!normalized) return;
relayLastDisconnectMeta.set(normalized, {
timestamp: Date.now(),
code: event?.code,
reason: event?.reason || '',
wasClean: Boolean(event?.wasClean)
});
});
};
attachWsCloseListener();
relay.on('connect', () => {
console.log(`[Worker] ✅ Relay connected: ${relay.url}`);
attachWsCloseListener();
const normalized = normalizeRelayUrl(relay.url);
const disconnectMeta = normalized ? relayLastDisconnectMeta.get(normalized) : null;
if (disconnectMeta?.timestamp) {
const reconnectSinceSec = Math.floor(Number(disconnectMeta.timestamp) / 1000);
console.log(`[Worker] Reconnect detected for ${relay.url}; rebuilding subscriptions with since=${reconnectSinceSec}`);
recreateActiveSubscriptionsForReconnect(relay.url, reconnectSinceSec);
relayLastDisconnectMeta.delete(normalized);
}
// startRelayKeepalive(relay); // Disabled to test pure NDK relay connection management
const connectLatencyMs = getRelayConnectLatencyMs(relay.url);
const payload = {
status: relay.status,
connectLatencyMs: Number.isFinite(connectLatencyMs) ? connectLatencyMs : null
};
broadcastRelayEvent(relay.url, 'connect', payload, 'relay');
});
relay.on('disconnect', () => {
console.log(`[Worker] ❌ Relay disconnected: ${relay.url}`);
stopRelayKeepalive(relay.url);
const normalized = normalizeRelayUrl(relay.url);
const lastClose = normalized ? relayLastDisconnectMeta.get(normalized) : null;
broadcastRelayEvent(relay.url, 'disconnect', {
status: relay.status,
...(lastClose || {})
}, 'relay');
});
relay.on('notice', (notice) => {
console.log(`[Worker] 📢 Relay notice from ${relay.url}:`, notice);
broadcastRelayEvent(relay.url, 'notice', notice, 'relay');
});
relay.on('auth', (challenge) => {
console.log(`[Worker] 🔐 Relay auth required: ${relay.url}`, challenge);
broadcastRelayEvent(relay.url, 'auth', challenge, 'relay');
});
relay.on('authed', () => {
console.log(`[Worker] ✅ Relay authenticated: ${relay.url}`);
broadcastRelayEvent(relay.url, 'authed', { status: relay.status }, 'relay');
});
relay.on('error', (error) => {
console.error(`[Worker] ⚠️ Relay error from ${relay.url}:`, error);
broadcastRelayEvent(relay.url, 'error', {
name: error?.name || null,
message: error?.message || error?.toString?.() || String(error),
code: error?.code,
status: relay.status
}, 'relay');
});
// Intercept WebSocket messages for deep debugging
// Access the underlying WebSocket connection
const originalOnMessage = relay.connectivity?._onMessage?.bind(relay.connectivity);
if (relay.connectivity && originalOnMessage) {
relay.connectivity._onMessage = function(event) {
// Call original handler without noisy raw payload logging.
return originalOnMessage(event);
};
}
// Intercept send method
const originalSend = relay.send?.bind(relay);
if (originalSend) {
relay.send = function(message) {
const frame = parseRelayFrame(message);
if (frame && frame[0] === 'REQ' && isWriteOnlyRelay(relay.url)) {
console.log(`[Worker] Blocking REQ to write-only relay: ${relay.url}`);
broadcastRelayEvent(relay.url, 'req-blocked-write-only', {
subscriptionId: frame[1] || null,
frameType: frame[0]
}, 'client');
return Promise.resolve();
}
return originalSend(message);
};
}
}
/**
* Start periodic relay health check to reconnect disconnected relays
* Runs every 120 seconds to check and reconnect any disconnected relays
*/
function startRelayHealthCheck() {
const HEALTH_CHECK_INTERVAL = 120000; // 120 seconds
setInterval(() => {
if (!ndk || !ndk.pool || !ndk.pool.relays) {
console.log("[Worker] Health check: NDK not initialized, skipping");
return;
}
const relays = Array.from(ndk.pool.relays.values());
const disconnectedRelays = relays.filter(relay => relay.status < 2); // 0=DISCONNECTED, 1=DISCONNECTING
if (disconnectedRelays.length > 0) {
console.log(`[Worker] Health check: Found ${disconnectedRelays.length} disconnected relays, attempting reconnection`);
for (const relay of disconnectedRelays) {
console.log(`[Worker] Health check: Reconnecting to ${relay.url}`);
markRelayConnectAttempt(relay.url);
broadcastRelayEvent(relay.url, 'reconnect-attempt', {
source: 'health-check',
targetUrl: normalizeRelayUrl(relay.url)
}, 'client');
relay.connect().catch(err => {
console.warn(`[Worker] Health check: Failed to reconnect to ${relay.url}:`, err.message);
});
}
}
}, HEALTH_CHECK_INTERVAL);
console.log(`[Worker] Relay health check started (interval: ${HEALTH_CHECK_INTERVAL}ms)`);
}
/**
* Message-based signer that proxies signing requests to page's window.nostr
* Implements NDK signer interface
*/
class MessageBasedSigner {
constructor() {
this.pubkey = null;
this.signedEventsBySig = new Map(); // sig -> signed raw event from signer page
}
async user() {
if (!this.pubkey) {
// Request pubkey from page
this.pubkey = await this.requestFromPage('getPubkey', {});
}
const NDKUser = NDKExports?.NDKUser || NDKExports?.default?.NDKUser;
if (NDKUser && ndk) {
return new NDKUser({ pubkey: this.pubkey });
}
return { pubkey: this.pubkey };
}
async sign(event) {
console.log('[Worker] MessageBasedSigner: Signing event kind:', event.kind);
// Request signing from page
const signedEvent = await this.requestFromPage('signEvent', { event });
const sig = String(signedEvent?.sig || '').trim();
if (!sig) {
throw new Error('signEvent returned no signature');
}
// Keep a snapshot so caller can align NDKEvent fields to exactly what was signed.
const snapshot = {
id: String(signedEvent?.id || ''),
pubkey: String(signedEvent?.pubkey || ''),
created_at: Number(signedEvent?.created_at || 0),
kind: Number(signedEvent?.kind),
content: String(signedEvent?.content || ''),
tags: Array.isArray(signedEvent?.tags)
? signedEvent.tags.map((tag) => Array.isArray(tag) ? tag.map((v) => String(v ?? '')) : [String(tag ?? '')])
: [],
sig
};
this.signedEventsBySig.set(sig, snapshot);
if (this.signedEventsBySig.size > 200) {
const oldestKey = this.signedEventsBySig.keys().next().value;
if (oldestKey) this.signedEventsBySig.delete(oldestKey);
}
console.log('[Worker] MessageBasedSigner: Event signed');
return sig;
}
applySignedSnapshotToEvent(ndkEvent) {
if (!ndkEvent) return false;
const sig = String(ndkEvent?.sig || '').trim();
if (!sig) return false;
const snapshot = this.signedEventsBySig.get(sig);
if (!snapshot) return false;
this.signedEventsBySig.delete(sig);
if (snapshot.id) ndkEvent.id = snapshot.id;
if (snapshot.pubkey) ndkEvent.pubkey = snapshot.pubkey;
if (Number.isFinite(snapshot.created_at) && snapshot.created_at > 0) ndkEvent.created_at = snapshot.created_at;
if (Number.isFinite(snapshot.kind)) ndkEvent.kind = snapshot.kind;
ndkEvent.content = String(snapshot.content || '');
ndkEvent.tags = Array.isArray(snapshot.tags)
? snapshot.tags.map((tag) => Array.isArray(tag) ? tag.map((v) => String(v ?? '')) : [String(tag ?? '')])
: [];
ndkEvent.sig = snapshot.sig;
return true;
}
async encrypt(recipient, plaintext) {
console.log('[Worker] MessageBasedSigner: Encrypting for:', recipient.pubkey);
try {
const result = await this.requestFromPage('nip44Encrypt', {
recipientPubkey: recipient.pubkey,
plaintext
});
return result.ciphertext;
} catch (error) {
console.warn('[Worker] MessageBasedSigner: nip44Encrypt failed, falling back to nip04:', error?.message || error);
const result = await this.requestFromPage('encrypt', {
recipientPubkey: recipient.pubkey,
plaintext
});
return result.ciphertext;
}
}
async decrypt(sender, ciphertext) {
// Wallet/user settings content is expected to be NIP-44; try it first.
try {
const result = await this.requestFromPage('nip44Decrypt', {
senderPubkey: sender.pubkey,
ciphertext
});
return result.plaintext;
} catch (error) {
console.warn('[Worker] MessageBasedSigner: nip44Decrypt failed, falling back to nip04:', error?.message || error);
const result = await this.requestFromPage('decrypt', {
senderPubkey: sender.pubkey,
ciphertext
});
return result.plaintext;
}
}
async nip44Encrypt(recipient, plaintext) {
console.log('[Worker] MessageBasedSigner: NIP44 encrypt for:', recipient.pubkey);
const result = await this.requestFromPage('nip44Encrypt', {
recipientPubkey: recipient.pubkey,
plaintext
});
return result.ciphertext;
}
async nip44Decrypt(sender, ciphertext) {
console.log('[Worker] MessageBasedSigner: NIP44 decrypt from:', sender.pubkey);
const result = await this.requestFromPage('nip44Decrypt', {
senderPubkey: sender.pubkey,
ciphertext
});
return result.plaintext;
}
async encryptionEnabled(scheme) {
const normalized = typeof scheme === 'string' ? scheme.toLowerCase() : '';
if (!normalized) return ['nip04', 'nip44'];
if (normalized === 'nip04' || normalized === 'nip44') return [normalized];
return [];
}
async requestFromPage(method, params) {
return new Promise((resolve, reject) => {
const requestId = `sign_${Date.now()}_${++signRequestCounter}`;
const targetPort = connectedPorts.includes(activeSigningPort)
? activeSigningPort
: connectedPorts[connectedPorts.length - 1] || null;
pendingSignRequests.set(requestId, { resolve, reject, port: targetPort });
// Set timeout
setTimeout(() => {
if (pendingSignRequests.has(requestId)) {
pendingSignRequests.delete(requestId);
reject(new Error(`${method} request timeout`));
}
}, 30000);
if (!targetPort) {
pendingSignRequests.delete(requestId);
reject(new Error(`${method} request failed: no active page port`));
return;
}
try {
targetPort.postMessage({
type: 'signRequest',
requestId,
method,
params
});
} catch (error) {
pendingSignRequests.delete(requestId);
reject(new Error(`${method} request failed: ${error?.message || String(error)}`));
}
});
}
}
// Create message-based signer instance
let messageSigner = null;
async function signEventWithMessageSigner(event, opts) {
if (!event) throw new Error('Missing event to sign');
await event.sign(messageSigner, opts);
try {
messageSigner?.applySignedSnapshotToEvent?.(event);
} catch (error) {
console.warn('[Worker] Failed applying signed snapshot to event:', error?.message || error);
}
}
// Initialize NDK with Dexie cache and message-based signer
async function initNDK() {
console.log('[Worker] Initializing NDK...');
try {
// Create message-based signer
messageSigner = new MessageBasedSigner();
console.log('[Worker] Message-based signer created');
// Verify IndexedDB is healthy before creating cache adapter, but do not fail startup if it is unhealthy.
let canUseIndexedDb = HAS_INDEXEDDB;
if (HAS_INDEXEDDB) {
try {
await assertIndexedDbReadyOrThrow();
INDEXEDDB_USABLE = true;
} catch (idbError) {
const details = idbError?.message || String(idbError);
canUseIndexedDb = false;
INDEXEDDB_USABLE = false;
console.warn('[Worker] IndexedDB diagnostics failed, continuing without persistent cache/storage:', details);
}
} else {
canUseIndexedDb = false;
INDEXEDDB_USABLE = false;
console.warn('[Worker] IndexedDB unavailable in this worker context, continuing without persistent cache/storage');
}
// Create cache adapter - will automatically use Dexie since we can't provide WASM URLs in SharedWorker
let cacheAdapter = null;
if (NDKCacheBrowser && canUseIndexedDb) {
try {
console.log('[Worker] Creating NDKCacheBrowser (will auto-fallback to Dexie)...');
cacheAdapter = new NDKCacheBrowser({
dbName: 'ndk-shared',
forceAdapter: 'dexie' // Force Dexie since we can't provide WASM URLs in SharedWorker
});
console.log('[Worker] Cache adapter created');
} catch (error) {
console.error('[Worker] Failed to create cache adapter:', error);
cacheAdapter = null;
}
} else if (!canUseIndexedDb) {
console.warn('[Worker] Skipping NDK cache adapter because IndexedDB is unavailable/unhealthy');
}
// Create NDK instance with bootstrap relays, cache, signer, and netDebug
const ndkOptions = {
explicitRelayUrls: BOOTSTRAP_RELAYS,
cacheAdapter: cacheAdapter,
signer: messageSigner,
netDebug: (msg, relay, direction) => {
// Keep persistence/live updates but suppress raw console payload spam.
const eventType = direction || 'message';
const side = direction === 'send' ? 'client' : 'relay';
broadcastRelayEvent(relay.url, eventType, msg, side);
}
};
console.log('[Worker] Creating NDK instance with Dexie cache, message-based signer, and netDebug...');
ndk = new NDKConstructor(ndkOptions);
// Set auth policy after ndk is created so it can reference the ndk instance
if (NDKRelayAuthPolicies && NDKRelayAuthPolicies.signIn) {
ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk, signer: messageSigner });
console.log('[Worker] NIP-42 auth policy set: signIn');
} else {
console.warn('[Worker] NDKRelayAuthPolicies not available, relay auth will not work');
}
// Set pubkey on signer
if (currentPubkey) {
messageSigner.pubkey = currentPubkey;
}
// NDK instance is now created — return immediately so ready signal can be sent.
// Cache initialization, relay events DB, and relay connections happen in background.
console.log('[Worker] NDK instance created, deferring cache/relay init to background');
// Background: initialize cache, relay events DB, and connect relays
(async () => {
// Initialize cache if present (with timeout so it doesn't block forever)
if (cacheAdapter && typeof cacheAdapter.initializeAsync === 'function') {
console.log('[Worker] Initializing cache adapter (background)...');
try {
await Promise.race([
cacheAdapter.initializeAsync(ndk),
new Promise((_, reject) => setTimeout(() => reject(new Error('Cache adapter initialization timeout (10s)')), 10000))
]);
console.log('[Worker] Cache adapter initialized, type:', cacheAdapter.getAdapterType?.());
} catch (cacheInitError) {
console.warn('[Worker] Cache adapter initialization failed/timed out, disabling cache adapter:', cacheInitError?.message || cacheInitError);
// Critical: if cache init hangs/fails, detach adapter from live NDK instance.
// Leaving a half-initialized adapter attached can stall ndk.fetchEvents calls.
if (ndk && ndk.cacheAdapter) {
ndk.cacheAdapter = null;
}
cacheAdapter = null;
}
}
// Ensure relay event history DB and indexes exist.
if (INDEXEDDB_USABLE) {
try {
const relayEventsDb = await openRelayEventsDb();
relayEventsDb.close();
} catch (relayEventsDbError) {
console.warn('[Worker] Failed initializing relay events DB:', relayEventsDbError?.message || relayEventsDbError);
}
}
})().catch(err => console.warn('[Worker] Background cache/DB init error:', err));
// Attach event listeners before first connect so startup events are captured.
for (const relay of ndk.pool.relays.values()) {
attachRelayEventListeners(relay);
markRelayConnectAttempt(relay.url);
broadcastRelayEvent(relay.url, 'connect-attempt', {
source: 'init',
targetUrl: normalizeRelayUrl(relay.url)
}, 'client');
}
// Connect relays in background so init/ready is not blocked by network timing.
console.log('[Worker] Starting bootstrap relay connection in background...');
ndk.connect()
.then(() => {
const connectedRelays = Array.from(ndk.pool.relays.values())
.filter(r => r.status >= 5)
.map(r => r.url);
console.log('[Worker] Background relay connect complete, connected relays:', connectedRelays);
})
.catch((error) => {
console.warn('[Worker] Background relay connect failed:', error?.message || error);
});
// Start periodic relay health check every 120 seconds
startRelayHealthCheck();
// Start periodic pruning for persisted relay event history
scheduleRelayEventPruning();
return true;
} catch (error) {
console.error('[Worker] Failed to initialize NDK:', error);
throw error;
}
}
// Fetch user profile (kind 0)
async function fetchUserProfile(pubkey) {
console.log('[Worker] Fetching profile for:', pubkey);
try {
const user = ndk.getUser({ pubkey });
await user.fetchProfile();
if (user.profile) {
console.log('[Worker] Profile fetched:', user.profile);
return user.profile;
}
return null;
} catch (error) {
console.error('[Worker] Error fetching profile:', error);
return null;
}
}
// Fetch user relay list (kind 10002)
async function fetchUserRelays(pubkey) {
console.log('[Worker] Fetching relay list for:', pubkey);
try {
const filter = {
kinds: [10002],
authors: [pubkey],
limit: 1
};
const events = await ndk.fetchEvents(filter);
if (events.size > 0) {
const relayListEvent = Array.from(events)
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
console.log('[Worker] Found relay list event:', relayListEvent);
// Parse relay tags from latest event only
const nextRelayTypes = new Map();
const relays = relayListEvent.tags
.filter(tag => tag[0] === 'r')
.map(tag => {
const url = tag[1];
const type = tag[2] || 'both'; // read, write, or both
nextRelayTypes.set(url, type);
return { url, type };
});
relayTypes = nextRelayTypes;
latestRelayListCreatedAt = relayListEvent.created_at || latestRelayListCreatedAt;
console.log('[Worker] Parsed relays:', relays);
console.log('[Worker] Stored relay types:', Array.from(relayTypes.entries()));
return relays;
}
console.log('[Worker] No relay list found, using bootstrap relays');
return null;
} catch (error) {
console.error('[Worker] Error fetching relay list:', error);
return null;
}
}
async function loadWorkerMuteList(pubkey, { reason = 'manual' } = {}) {
const author = normalizeHexPubkey(pubkey || currentPubkey);
if (!author || !ndk) {
return { loaded: false, reason: 'missing-author-or-ndk' };
}
const filter = { kinds: [10000], authors: [author], limit: 1 };
try {
const muteEvents = await ndk.fetchEvents(filter);
const latest = Array.from(muteEvents || []).sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0] || null;
clearWorkerMuteSets(workerPublicMutes);
clearWorkerMuteSets(workerPrivateMutes);
workerMuteListEventId = String(latest?.id || '');
workerMuteListCreatedAt = Number(latest?.created_at || 0);
if (latest) {
parseWorkerMuteTags(latest.tags, workerPublicMutes);
const encrypted = String(latest.content || '').trim();
if (encrypted && messageSigner) {
try {
const decryptMethod = isWorkerNip04Content(encrypted) ? 'decrypt' : 'nip44Decrypt';
const decryptResult = await messageSigner.requestFromPage(decryptMethod, {
senderPubkey: author,
ciphertext: encrypted,
});
const plaintext = typeof decryptResult === 'string'
? decryptResult
: String(decryptResult?.plaintext || '');
if (plaintext) {
const privateTags = JSON.parse(plaintext);
parseWorkerMuteTags(privateTags, workerPrivateMutes);
}
} catch (error) {
console.warn('[Worker][mute] Failed decrypting private mute tags:', error?.message || error);
}
}
}
workerMuteListLoadedAt = Date.now();
const snapshot = getWorkerMuteListSnapshot();
console.log('[Worker][mute] Loaded mute list', {
reason,
eventId: snapshot.eventId,
createdAt: snapshot.createdAt,
publicCounts: {
p: snapshot.public.p.length,
t: snapshot.public.t.length,
word: snapshot.public.word.length,
e: snapshot.public.e.length,
},
privateCounts: {
p: snapshot.private.p.length,
t: snapshot.private.t.length,
word: snapshot.private.word.length,
e: snapshot.private.e.length,
},
});
broadcastMuteListUpdated(reason);
return { loaded: true, ...snapshot };
} catch (error) {
console.warn('[Worker][mute] Failed to load mute list:', error?.message || error);
return { loaded: false, error: error?.message || String(error) };
}
}
function applyLightwalletDeletionEvent(event) {
let changed = false;
for (const tag of event?.tags || []) {
if (tag?.[0] === 'e' && tag?.[1]) {
const before = lightwalletDeletedIds.size;
lightwalletDeletedIds.add(tag[1]);
if (lightwalletDeletedIds.size !== before) changed = true;
}
}
return changed;
}
async function decryptTokenEvent(tokenEvent, pubkey) {
const decrypted = await messageSigner.requestFromPage('nip44Decrypt', {
senderPubkey: pubkey,
ciphertext: tokenEvent?.content || ''
});
const plaintext = typeof decrypted === 'string' ? decrypted : decrypted?.plaintext;
if (!plaintext) throw new Error('No plaintext returned from nip44Decrypt');
const parsed = JSON.parse(plaintext);
if (!parsed || typeof parsed !== 'object') throw new Error('Invalid token event payload');
return parsed;
}
function broadcastLightwalletBalance(reason = 'update') {
const payload = getDirectBalancePayload();
broadcast({ type: 'walletBalanceUpdated', data: payload });
broadcastStartupStatus(`Wallet balance (${reason}): ${Number(payload.balance || 0).toLocaleString()} sats`, {
stage: 'wallet-balance',
state: 'update',
reason,
balance: Number(payload.balance || 0),
source: payload.balanceSource || 'direct-proofs'
});
}
async function processLightwalletTokenEvent(tokenEvent, pubkey) {
if (!tokenEvent?.id) return false;
try {
const decrypted = await decryptTokenEvent(tokenEvent, pubkey);
for (const deletedId of (decrypted?.del || [])) {
if (deletedId) lightwalletDeletedIds.add(String(deletedId));
}
const proofTotal = Array.isArray(decrypted?.proofs)
? decrypted.proofs.reduce((sum, proof) => {
const amount = Number(proof?.amount || 0);
return sum + (Number.isFinite(amount) ? amount : 0);
}, 0)
: 0;
const proofs = Array.isArray(decrypted?.proofs) ? decrypted.proofs : [];
lightwalletTokenEvents.set(tokenEvent.id, {
mint: String(decrypted?.mint || 'unknown'),
unit: String(decrypted?.unit || 'sat'),
proofTotal,
proofs,
createdAt: Math.floor(Number(tokenEvent?.created_at || 0))
});
if (proofTotal > 0 || proofs.length > 0) {
lightwalletHasWallet = true;
}
return true;
} catch (error) {
console.warn('[Worker] Failed to decrypt/process token event:', tokenEvent?.id, error?.message || error);
return false;
}
}
let legacyWalletMigrationAttempted = false;
/**
* One-time migration: if the user has an old-format kind 17375 wallet event
* (proprietary {mints, nutzap} object instead of NIP-60 tag-array), republish
* it in the standard format so other NIP-60 clients (Amethyst, NDK) can read it.
* The migration is fire-and-forget — publishDirectProofs() writes the new
* tag-array format and NIP-09 deletes the old event.
*/
async function migrateLegacyWalletEventIfNeeded(pubkey, walletEvents = []) {
if (legacyWalletMigrationAttempted) return;
if (!pubkey || !messageSigner) return;
const events = Array.isArray(walletEvents) ? walletEvents : [];
if (events.length === 0) return;
for (const evt of events) {
if (!evt?.content) continue;
try {
const decrypted = await messageSigner.requestFromPage('nip44Decrypt', {
senderPubkey: pubkey,
ciphertext: evt.content
});
const plaintext = typeof decrypted === 'string' ? decrypted : decrypted?.plaintext;
if (!plaintext) continue;
const parsed = JSON.parse(plaintext);
// Legacy format is an object; NIP-60 standard is an array.
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
console.log('[Worker] NIP-60 migration: detected legacy wallet event, republishing in tag-array format');
legacyWalletMigrationAttempted = true;
try {
await publishDirectProofs({ traceId: 'nip60-migration' });
console.log('[Worker] NIP-60 migration: republish complete');
} catch (err) {
console.warn('[Worker] NIP-60 migration: republish failed:', err?.message || err);
}
return;
}
} catch (_error) {
// Ignore decrypt/parse failures — might be a different encryption scheme
}
}
// No legacy events found; mark as checked so we don't re-scan every startup.
legacyWalletMigrationAttempted = true;
}
async function hydrateLightwalletFromFetchedEvents(pubkey, walletEvents = [], tokenEvents = [], deletionEvents = []) {
lightwalletHasWallet = Array.isArray(walletEvents) && walletEvents.length > 0;
await hydrateNutzapKeyFromWalletEvents(pubkey, walletEvents);
for (const delEvent of (deletionEvents || [])) {
applyLightwalletDeletionEvent(delEvent);
}
for (const tokenEvent of (tokenEvents || [])) {
await processLightwalletTokenEvent(tokenEvent, pubkey);
}
hydrateDirectProofStore();
broadcastLightwalletBalance('startup-fetch');
// Fire-and-forget: migrate legacy {mints,nutzap} wallet events to NIP-60
// tag-array format so other clients (Amethyst, NDK) can read our wallet.
void migrateLegacyWalletEventIfNeeded(pubkey, walletEvents).catch((err) => {
console.warn('[Worker] NIP-60 migration check failed:', err?.message || err);
});
}
async function fetchStartupEventDownloads(pubkey) {
if (!ndk || !pubkey) return;
const startupFetches = [
{
status: 'Fetching user follows (kind 3)...',
filter: { kinds: [3], authors: [pubkey], limit: 1 },
key: 'kind3'
},
{
status: 'Fetching user wallet information (kind 17375)...',
filter: { kinds: [17375], authors: [pubkey], limit: 5 },
key: 'kind17375'
},
{
status: 'Fetching user wallet tokens (kind 7375)...',
filter: { kinds: [7375], authors: [pubkey], limit: 500 },
key: 'kind7375'
},
{
status: 'Fetching user token deletions (kind 5 for 7375)...',
filter: { kinds: [5], authors: [pubkey], '#k': ['7375'], limit: 500 },
key: 'kind5for7375'
},
{
status: 'Fetching incoming nutzaps (kind 9321)...',
filter: { kinds: [9321], '#p': [pubkey], limit: 250 },
key: 'kind9321'
}
];
const counts = {};
const eventsByKey = {};
for (const fetchSpec of startupFetches) {
broadcastStartupStatus(fetchSpec.status, { stage: fetchSpec.key, state: 'start' });
const startedAt = Date.now();
try {
const events = await ndk.fetchEvents(fetchSpec.filter);
const eventList = Array.from(events || []);
eventsByKey[fetchSpec.key] = eventList;
const count = eventList.length;
counts[fetchSpec.key] = count;
broadcastStartupStatus(`${fetchSpec.status.replace(/\.\.\.$/, '')} done (${count})`, {
stage: fetchSpec.key,
state: 'done',
count,
durationMs: Date.now() - startedAt
});
} catch (error) {
eventsByKey[fetchSpec.key] = [];
counts[fetchSpec.key] = 0;
broadcastStartupStatus(`${fetchSpec.status.replace(/\.\.\.$/, '')} failed`, {
stage: fetchSpec.key,
state: 'failed',
error: error?.message || String(error),
durationMs: Date.now() - startedAt
});
console.warn('[Worker] Startup fetch failed for', fetchSpec.key, error?.message || error);
}
}
broadcastStartupStatus('Computing lightweight wallet balance...', {
stage: 'wallet-balance',
state: 'start'
});
await hydrateLightwalletFromFetchedEvents(
pubkey,
eventsByKey.kind17375 || [],
eventsByKey.kind7375 || [],
eventsByKey.kind5for7375 || []
);
await processIncomingNutzaps(eventsByKey.kind9321 || [], { context: 'startup-fetch' });
broadcastStartupStatus('Startup downloads complete', { stage: 'startup-downloads', state: 'done', counts });
// Tier 1: proactively prefetch kind 10019 nutzap mint lists for all
// followed users so resolveZapCapabilities() can hit IDB. This runs in the
// background and must not block startup completion, so we do not await it.
try {
const kind3Events = Array.from(eventsByKey.kind3 || []);
const latestKind3 = kind3Events
.sort((a, b) => Number(b?.created_at || 0) - Number(a?.created_at || 0))[0] || null;
if (latestKind3) {
prefetchFollowedNutzapMintLists(latestKind3).catch((error) => {
console.warn('[Worker] background kind 10019 prefetch failed', error?.message || error);
});
}
} catch (prefetchError) {
console.warn('[Worker] kind 10019 prefetch scheduling failed', prefetchError?.message || prefetchError);
}
}
function ensureStartupWalletSubscriptions(pubkey) {
if (!ndk || !pubkey) return;
// Reuse existing subscription when possible
if (startupWalletSub && startupWalletSubPubkey === pubkey) {
return;
}
// Replace stale subscription if pubkey changed
if (startupWalletSub) {
try {
startupWalletSub.stop();
} catch (_error) {
// no-op
}
startupWalletSub = null;
startupWalletSubPubkey = null;
}
const walletFilters = [
{ kinds: [17375], authors: [pubkey] },
{ kinds: [7375], authors: [pubkey] },
{ kinds: [5], authors: [pubkey], '#k': ['7375'] },
{ kinds: [7376], authors: [pubkey] },
{ kinds: [9321], '#p': [pubkey] }
];
broadcastStartupStatus('Subscribing to wallet updates (kinds 17375/7375/5/7376)...', {
stage: 'startup-wallet-subscribe',
state: 'start'
});
startupWalletSub = ndk.subscribe(walletFilters, { closeOnEose: false });
startupWalletSubPubkey = pubkey;
startupWalletSub.on('event', async (event) => {
const kind = Number(event?.kind || 0);
if (kind === 17375) {
lightwalletHasWallet = true;
await hydrateNutzapKeyFromWalletEvents(pubkey, [event]);
broadcastStartupStatus('Wallet metadata update received (kind 17375)', {
stage: 'startup-wallet-subscribe',
state: 'event',
kind,
eventId: event?.id || null
});
return;
}
if (kind === 7375) {
broadcastStartupStatus('Wallet token update received (kind 7375)', {
stage: 'startup-wallet-subscribe',
state: 'event',
kind,
eventId: event?.id || null
});
const changed = await processLightwalletTokenEvent(event, pubkey);
if (changed) {
hydrateDirectProofStore();
broadcastLightwalletBalance('subscription-7375');
}
return;
}
if (kind === 5) {
broadcastStartupStatus('Wallet token deletion received (kind 5 for 7375)', {
stage: 'startup-wallet-subscribe',
state: 'event',
kind,
eventId: event?.id || null
});
const changed = applyLightwalletDeletionEvent(event);
if (changed) {
hydrateDirectProofStore();
broadcastLightwalletBalance('subscription-5');
}
return;
}
if (kind === 7376) {
const tx = await processSpendingHistoryEvent(event, pubkey);
if (tx) {
broadcast({ type: 'walletTransactionReceived', data: { latest: tx } });
}
return;
}
if (kind === 9321) {
await redeemIncomingNutzapEvent(event, { context: 'subscription' });
}
});
startupWalletSub.on('eose', () => {
broadcastStartupStatus('Wallet subscription caught up (EOSE)', {
stage: 'startup-wallet-subscribe',
state: 'eose'
});
});
broadcastStartupStatus('Wallet subscription active', {
stage: 'startup-wallet-subscribe',
state: 'done'
});
}
// Update NDK relays based on user's relay list
async function updateRelays(userRelays) {
console.log('[Worker] Updating relays...');
try {
// Normalize relay list and apply NIP-65 semantics:
// - read/both relays are managed in the main pool for subscriptions.
// - write-only relays are left to NDK outbox routing for publishing.
const normalizedRelayEntries = userRelays
.map((relay) => ({
url: normalizeRelayUrl(relay?.url),
type: relay?.type || 'both'
}))
.filter((relay) => !!relay.url);
console.log('[Worker] User relays:', normalizedRelayEntries);
// Get current relay URLs
const currentRelayUrls = Array.from(ndk.pool.relays.keys());
console.log('[Worker] Current relays:', currentRelayUrls);
const normalizedUserRelayUrls = normalizedRelayEntries.map((relay) => relay.url);
const normalizedReadableRelaySet = new Set(
normalizedRelayEntries
.filter((relay) => relay.type !== 'write')
.map((relay) => relay.url)
);
// Only keep read/both relays in the main pool.
for (const relay of ndk.pool.relays.values()) {
const normalizedCurrent = normalizeRelayUrl(relay.url);
if (!normalizedReadableRelaySet.has(normalizedCurrent)) {
console.log('[Worker] Disconnecting relay not in readable relay list:', relay.url);
relay.disconnect();
} else {
console.log('[Worker] Keeping existing connection to:', relay.url);
}
}
// Add read/both relays to the pool and attach listeners.
for (const relayEntry of normalizedRelayEntries) {
const normalizedUrl = relayEntry.url;
if (!normalizedUrl) continue;
if (relayEntry.type === 'write') {
console.log('[Worker] Skipping write-only relay in main pool:', normalizedUrl);
continue;
}
if (!ndk.pool.relays.has(normalizedUrl)) {
console.log('[Worker] Adding read/both relay:', normalizedUrl);
ndk.addExplicitRelay(normalizedUrl);
}
// Attach event listeners (will skip if already attached)
const relay = ndk.pool.relays.get(normalizedUrl);
if (relay) {
attachRelayEventListeners(relay);
}
}
// Reconnect with timeout (don't wait for all relays)
console.log('[Worker] Connecting to user relays...');
for (const relay of ndk.pool.relays.values()) {
markRelayConnectAttempt(relay.url);
broadcastRelayEvent(relay.url, 'connect-attempt', {
source: 'updateRelays',
targetUrl: normalizeRelayUrl(relay.url)
}, 'client');
}
const connectPromise = ndk.connect();
const timeoutPromise = new Promise(resolve => setTimeout(resolve, 3000));
await Promise.race([connectPromise, timeoutPromise]);
console.log('[Worker] Reconnected to user relays (or timed out)');
// Wait a bit more for connections to establish
await new Promise(resolve => setTimeout(resolve, 500));
// Return relay status
// NDK relay status: 0=DISCONNECTED, 1=DISCONNECTING, 2=RECONNECTING, 3=FLAPPING, 4=CONNECTING, 5=CONNECTED, 6=AUTH_REQUIRED, 7=AUTHENTICATING, 8=AUTHENTICATED
const relayStatus = Array.from(ndk.pool.relays.values()).map(relay => {
console.log(`[Worker] Relay ${relay.url} status: ${relay.status}`);
return {
url: relay.url,
connected: relay.status >= 5 // CONNECTED or higher
};
});
console.log('[Worker] Final relay status:', relayStatus);
return relayStatus;
} catch (error) {
console.error('[Worker] Error updating relays:', error);
throw error;
}
}
// Broadcast message to all connected ports
function broadcast(message) {
connectedPorts.forEach(port => {
try {
port.postMessage(message);
} catch (error) {
console.error('[Worker] Error broadcasting to port:', error);
}
});
}
function broadcastUserSettings() {
if (!userSettings) return;
broadcast({ type: 'userSettingsUpdated', data: userSettings });
}
function broadcastStartupStatus(message, meta = {}) {
broadcast({
type: 'startupStatus',
data: {
message: String(message || ''),
timestamp: Date.now(),
...meta
}
});
}
function getWalletRelayUrls() {
const relayTypeUrls = Array.from(relayTypes.keys()).filter(Boolean);
const poolUrls = ndk?.pool?.relays
? Array.from(ndk.pool.relays.values()).map((relay) => relay.url).filter(Boolean)
: [];
return Array.from(new Set([...relayTypeUrls, ...poolUrls]));
}
function normalizeMintUrl(mintUrl) {
return String(mintUrl || '').trim().replace(/\/+$/, '');
}
const CASHU_MINT_LOAD_TIMEOUT_MS = 12000;
const CASHU_SEND_TIMEOUT_MS = 12000;
const CASHU_CHECK_PROOFS_TIMEOUT_MS = 12000;
const CASHU_PENDING_SEND_POLL_MS = 5000;
const CASHU_PENDING_SEND_MAX_AGE_MS = 24 * 60 * 60 * 1000;
function makeTimeoutError(message) {
const error = new Error(String(message || 'Operation timed out'));
error.name = 'TimeoutError';
return error;
}
async function withTimeout(taskOrPromise, timeoutMs, timeoutMessage) {
const timeout = Math.max(1, Number(timeoutMs) || 1);
let timeoutId = null;
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
reject(makeTimeoutError(timeoutMessage || `Operation timed out after ${timeout}ms`));
}, timeout);
});
try {
const taskPromise = typeof taskOrPromise === 'function'
? Promise.resolve().then(() => taskOrPromise())
: Promise.resolve(taskOrPromise);
return await Promise.race([taskPromise, timeoutPromise]);
} finally {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
}
}
function normalizeHexKey(value) {
const hex = String(value || '').trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(hex)) return '';
return hex;
}
function normalizeHexPubkey(value) {
return normalizeHexKey(value);
}
function bytesToHex(bytes) {
if (!bytes || typeof bytes.length !== 'number') return '';
let hex = '';
for (let i = 0; i < bytes.length; i += 1) {
hex += bytes[i].toString(16).padStart(2, '0');
}
return hex;
}
function normalizeCashuP2pk(value) {
const hex = String(value || '').trim().toLowerCase();
if (/^[0-9a-f]{66}$/.test(hex)) return hex;
if (/^[0-9a-f]{64}$/.test(hex)) return `02${hex}`;
return '';
}
function deriveNutzapP2pkFromPrivkeyHex(privkeyHex) {
const normalizedPrivkey = normalizeHexKey(privkeyHex);
if (!normalizedPrivkey || typeof nostrGetPublicKey !== 'function') return '';
try {
const secretBytes = Uint8Array.from(normalizedPrivkey.match(/.{1,2}/g).map((pair) => parseInt(pair, 16)));
const pubkeyResult = nostrGetPublicKey(secretBytes, true);
if (typeof pubkeyResult === 'string') {
return normalizeCashuP2pk(pubkeyResult);
}
return normalizeCashuP2pk(bytesToHex(pubkeyResult));
} catch (_error) {
return '';
}
}
function generateNutzapPrivkeyHex() {
if (typeof nostrGenerateSecretKey !== 'function') return '';
try {
const raw = nostrGenerateSecretKey();
if (typeof raw === 'string') {
return normalizeHexKey(raw);
}
return normalizeHexKey(bytesToHex(raw));
} catch (_error) {
return '';
}
}
function ensureDirectNutzapP2pk() {
if (directNutzapPrivateKeyHex && directNutzapP2pk) {
return {
privkey: directNutzapPrivateKeyHex,
p2pk: directNutzapP2pk
};
}
if (!directNutzapPrivateKeyHex) {
const generated = generateNutzapPrivkeyHex();
if (!generated) {
throw new Error('Unable to generate nutzap private key');
}
directNutzapPrivateKeyHex = generated;
}
directNutzapPrivateKeyHex = normalizeHexKey(directNutzapPrivateKeyHex);
if (!directNutzapPrivateKeyHex) {
throw new Error('Invalid nutzap private key');
}
const p2pk = deriveNutzapP2pkFromPrivkeyHex(directNutzapPrivateKeyHex);
if (!p2pk) {
throw new Error('Unable to derive nutzap p2pk from private key');
}
directNutzapP2pk = p2pk;
return {
privkey: directNutzapPrivateKeyHex,
p2pk: directNutzapP2pk
};
}
/**
* Parse the decrypted content of a kind 17375 Cashu wallet event.
*
* NIP-60 standard format (what we now write and what Amethyst/NDK expect):
* [["mint","https://mint.example.com"],["privkey","<hex>"]]
*
* Legacy format (what this project previously wrote — kept for backwards compat):
* {"mints":["https://mint.example.com"],"nutzap":{"privkey":"<hex>","p2pk":"<pubkey>"}}
*
* Returns { mints: string[], privkey: string|null, p2pk: string|null, isLegacy: boolean }
*/
function parseWalletContent(plaintext) {
if (!plaintext) return { mints: [], privkey: null, p2pk: null, isLegacy: false };
const parsed = JSON.parse(plaintext);
if (!parsed || typeof parsed !== 'object') {
return { mints: [], privkey: null, p2pk: null, isLegacy: false };
}
// Standard NIP-60 tag-array format: [["mint",url],["privkey",hex]]
if (Array.isArray(parsed)) {
const mints = [];
let privkey = null;
let p2pk = null;
for (const tag of parsed) {
if (!Array.isArray(tag) || tag.length < 2) continue;
if (tag[0] === 'mint' && typeof tag[1] === 'string') {
mints.push(normalizeMintUrl(tag[1]));
} else if (tag[0] === 'privkey' && typeof tag[1] === 'string') {
privkey = normalizeHexKey(tag[1]);
} else if (tag[0] === 'p2pk' && typeof tag[1] === 'string') {
p2pk = normalizeCashuP2pk(tag[1]);
}
}
return { mints: mints.filter(Boolean), privkey, p2pk, isLegacy: false };
}
// Legacy object format: { mints: [...], nutzap: { privkey, p2pk } }
const mints = Array.isArray(parsed.mints)
? parsed.mints.map(normalizeMintUrl).filter(Boolean)
: [];
const privkey = normalizeHexKey([
parsed?.nutzap?.privkey,
parsed?.nutzapPrivkey,
parsed?.nutzap_private_key
].find(Boolean) || '');
const p2pk = normalizeCashuP2pk([
parsed?.nutzap?.p2pk,
parsed?.nutzapP2pk,
parsed?.nutzap_p2pk
].find(Boolean) || '');
return { mints, privkey: privkey || null, p2pk: p2pk || null, isLegacy: true };
}
async function hydrateNutzapKeyFromWalletEvents(pubkey, walletEvents = []) {
if (!messageSigner || !pubkey) return;
const sortedEvents = (Array.isArray(walletEvents) ? walletEvents : [])
.filter((evt) => evt?.content)
.sort((a, b) => Number(b?.created_at || 0) - Number(a?.created_at || 0));
for (const evt of sortedEvents) {
try {
const decrypted = await messageSigner.requestFromPage('nip44Decrypt', {
senderPubkey: pubkey,
ciphertext: evt.content
});
const plaintext = typeof decrypted === 'string' ? decrypted : decrypted?.plaintext;
if (!plaintext) continue;
const parsed = parseWalletContent(plaintext);
const candidatePrivkey = parsed.privkey;
const candidateP2pk = parsed.p2pk;
if (!candidatePrivkey && !candidateP2pk) continue;
if (candidatePrivkey) {
directNutzapPrivateKeyHex = candidatePrivkey;
directNutzapP2pk = candidateP2pk || deriveNutzapP2pkFromPrivkeyHex(candidatePrivkey);
} else if (candidateP2pk) {
directNutzapP2pk = candidateP2pk;
}
if (directNutzapPrivateKeyHex && !directNutzapP2pk) {
directNutzapP2pk = deriveNutzapP2pkFromPrivkeyHex(directNutzapPrivateKeyHex);
}
if (directNutzapP2pk) return;
} catch (_error) {
// Ignore malformed or non-wallet payloads
}
}
}
function getDirectWalletMints() {
const fromStore = Object.keys(directProofStore || {}).map(normalizeMintUrl).filter(Boolean);
const merged = Array.from(new Set([...(directMintList || []).map(normalizeMintUrl), ...fromStore]));
directMintList = merged;
return merged;
}
function getDirectHasWallet() {
return Boolean(lightwalletHasWallet) || getDirectWalletMints().length > 0;
}
function clearWalletListeners() {
for (const entry of pendingDeposits.values()) {
if (entry?.timerId) clearInterval(entry.timerId);
}
pendingDeposits.clear();
for (const entry of pendingSends.values()) {
if (entry?.timerId) clearInterval(entry.timerId);
}
pendingSends.clear();
directWalletCache.clear();
}
function broadcastWalletStatus(extra = {}) {
broadcast({
type: 'walletStatusChanged',
data: {
status: directWalletStatus,
hasWallet: getDirectHasWallet(),
...extra
}
});
}
function normalizeMintBalances(mintBalances = {}) {
if (mintBalances instanceof Map) {
return Object.fromEntries(mintBalances.entries());
}
if (Array.isArray(mintBalances)) {
return Object.fromEntries(mintBalances);
}
if (mintBalances && typeof mintBalances === 'object') {
return { ...mintBalances };
}
return {};
}
function sumMintBalances(mintBalances = {}) {
const normalized = normalizeMintBalances(mintBalances);
return Object.values(normalized).reduce((sum, amount) => {
const value = Number(amount || 0);
return sum + (Number.isFinite(value) ? value : 0);
}, 0);
}
function dedupeProofsBySecret(proofs = []) {
const seen = new Set();
const out = [];
for (const proof of (Array.isArray(proofs) ? proofs : [])) {
const secret = String(proof?.secret || '').trim();
if (!secret || seen.has(secret)) continue;
seen.add(secret);
out.push(proof);
}
return out;
}
function hydrateDirectProofStore() {
const nextStore = {};
const latestTokenByMint = new Map();
for (const [eventId, token] of lightwalletTokenEvents.entries()) {
if (lightwalletDeletedIds.has(eventId)) continue;
const mint = normalizeMintUrl(token?.mint || 'unknown');
if (!mint) continue;
const createdAt = Math.floor(Number(token?.createdAt || 0));
const prev = latestTokenByMint.get(mint);
const prevCreatedAt = Math.floor(Number(prev?.createdAt || 0));
if (!prev || createdAt >= prevCreatedAt) {
latestTokenByMint.set(mint, {
createdAt,
proofs: Array.isArray(token?.proofs) ? token.proofs : []
});
}
}
for (const [mint, token] of latestTokenByMint.entries()) {
const relayCreatedAt = Math.floor(Number(token?.createdAt || 0));
const localUpdatedAt = Math.floor(Number(directMintLocalUpdatedAt.get(mint) || 0));
if (localUpdatedAt > relayCreatedAt) {
nextStore[mint] = dedupeProofsBySecret(Array.isArray(directProofStore[mint]) ? directProofStore[mint] : []);
continue;
}
nextStore[mint] = dedupeProofsBySecret(Array.isArray(token?.proofs) ? token.proofs : []);
}
for (const mint of Object.keys(directProofStore || {})) {
if (!nextStore[mint]) {
nextStore[mint] = dedupeProofsBySecret(Array.isArray(directProofStore[mint]) ? directProofStore[mint] : []);
}
}
directProofStore = nextStore;
directMintList = Array.from(new Set([
...Object.keys(directProofStore),
...(directMintList || []).map(normalizeMintUrl).filter(Boolean)
]));
directWalletLoaded = true;
if (directWalletStatus === 'initial') {
directWalletStatus = 'ready';
}
}
async function ensureDirectWalletLoaded() {
if (!directWalletLoaded) {
hydrateDirectProofStore();
}
if (!directWalletLoaded) {
directWalletStatus = 'loading';
broadcastWalletStatus();
hydrateDirectProofStore();
}
directWalletStatus = 'ready';
return { state: 'ready' };
}
function getDirectBalancePayload() {
let total = 0;
const byMint = {};
for (const [mint, proofs] of Object.entries(directProofStore || {})) {
const mintTotal = (Array.isArray(proofs) ? proofs : []).reduce((sum, proof) => {
const amount = Number(proof?.amount || 0);
return sum + (Number.isFinite(amount) && amount > 0 ? amount : 0);
}, 0);
if (mintTotal > 0) {
byMint[mint] = mintTotal;
total += mintTotal;
}
}
return {
balance: total,
balancesByMint: byMint,
balanceSource: 'direct-proofs',
isApproximate: false,
hasWallet: getDirectHasWallet()
};
}
function getWalletBalancePayload() {
return getDirectBalancePayload();
}
function getNDKEventCtor() {
const NDKEvent = NDKExports?.NDKEvent || NDKExports?.default?.NDKEvent;
if (!NDKEvent) throw new Error('NDKEvent not found in bundle');
return NDKEvent;
}
function getProofTotal(proofs = []) {
return (Array.isArray(proofs) ? proofs : []).reduce((sum, proof) => {
const amount = Number(proof?.amount || 0);
return sum + (Number.isFinite(amount) && amount > 0 ? amount : 0);
}, 0);
}
async function getDirectWalletForMint(mintUrl) {
const normalized = normalizeMintUrl(mintUrl);
if (!normalized) throw new Error('Mint URL is required');
if (!CashuMint || !CashuWallet) throw new Error('Cashu wallet classes are not available in worker bundle');
if (!directWalletCache.has(normalized)) {
const mint = new CashuMint(normalized);
const wallet = new CashuWallet(mint);
if (typeof wallet?.loadMint === 'function') {
await withTimeout(
() => wallet.loadMint(),
CASHU_MINT_LOAD_TIMEOUT_MS,
`Mint metadata load timeout (${normalized})`
);
}
directWalletCache.set(normalized, wallet);
}
return directWalletCache.get(normalized);
}
function encodeDirectToken(mintUrl, proofs) {
if (typeof getEncodedTokenV4 === 'function') {
return getEncodedTokenV4({ mint: mintUrl, proofs: Array.isArray(proofs) ? proofs : [] });
}
throw new Error('getEncodedTokenV4 is not available in worker bundle');
}
function decodeDirectToken(token) {
if (typeof getDecodedToken === 'function') {
return getDecodedToken(token);
}
throw new Error('getDecodedToken is not available in worker bundle');
}
function upsertMintProofs(mintUrl, proofs = []) {
const normalized = normalizeMintUrl(mintUrl);
if (!normalized) return;
directProofStore[normalized] = dedupeProofsBySecret(Array.isArray(proofs) ? proofs : []);
directMintLocalUpdatedAt.set(normalized, Math.floor(Date.now() / 1000));
directMintList = Array.from(new Set([...(directMintList || []), normalized]));
}
function extractNutzapProofs(event = null) {
const proofTags = Array.isArray(event?.tags)
? event.tags.filter((tag) => tag?.[0] === 'proof' && tag?.[1])
: [];
const proofs = [];
for (const tag of proofTags) {
try {
const parsed = JSON.parse(tag[1]);
if (parsed && typeof parsed === 'object') {
proofs.push(parsed);
}
} catch (_error) {
// ignore malformed proof tags
}
}
return proofs;
}
function extractNutzapMint(event = null) {
const mintTag = Array.isArray(event?.tags)
? event.tags.find((tag) => (tag?.[0] === 'u' || tag?.[0] === 'mint') && tag?.[1])
: null;
return normalizeMintUrl(mintTag?.[1] || '');
}
function extractNutzapRecipient(event = null) {
const recipientTag = Array.isArray(event?.tags)
? event.tags.find((tag) => tag?.[0] === 'p' && tag?.[1])
: null;
return normalizeHexPubkey(recipientTag?.[1] || '');
}
function isNutzapAlreadyProcessed(eventId) {
const id = String(eventId || '').trim();
if (!id) return true;
return directTransactions.some((tx) => String(tx?.nutzapEventId || tx?.id || '') === id);
}
async function redeemIncomingNutzapEvent(event, { context = 'subscription' } = {}) {
if (!event?.id) return null;
if (isNutzapAlreadyProcessed(event.id)) return null;
const recipientPubkey = extractNutzapRecipient(event);
if (recipientPubkey && currentPubkey && recipientPubkey !== currentPubkey) {
return null;
}
const proofs = extractNutzapProofs(event);
if (!Array.isArray(proofs) || proofs.length === 0) {
return null;
}
const mintUrl = extractNutzapMint(event);
if (!mintUrl) {
return null;
}
const amount = getProofTotal(proofs);
if (!Number.isFinite(amount) || amount <= 0) {
return null;
}
const token = encodeDirectToken(mintUrl, proofs);
const wallet = await getDirectWalletForMint(mintUrl);
const receiveResult = await wallet.receive(token);
const receivedProofs = Array.isArray(receiveResult)
? receiveResult
: Array.isArray(receiveResult?.proofs)
? receiveResult.proofs
: [];
const mergedProofs = receivedProofs.length > 0 ? receivedProofs : proofs;
const existing = Array.isArray(directProofStore[mintUrl]) ? directProofStore[mintUrl] : [];
upsertMintProofs(mintUrl, [...existing, ...mergedProofs]);
await publishDirectProofs({ traceId: `nutzap-redeem:${event.id}` });
const memo = String(event?.content || '').trim() || 'Nutzap received';
const tx = appendDirectTransaction({
id: event.id,
type: 'nutzap-receive',
direction: 'in',
amount,
mint: mintUrl,
memo,
description: memo,
timestamp: Number(event?.created_at || Math.floor(Date.now() / 1000)),
nutzapEventId: event.id,
fromPubkey: normalizeHexPubkey(event?.pubkey || '') || null,
source: context
});
if (tx) {
await publishSpendingHistoryEvent(tx);
}
const payload = getWalletBalancePayload();
broadcast({ type: 'walletBalanceUpdated', data: payload });
broadcast({ type: 'walletTransactionReceived', data: { latest: tx || null, ...payload } });
return { tx, payload, mintUrl, amount, eventId: event.id };
}
async function processIncomingNutzaps(events = [], { context = 'batch' } = {}) {
const list = Array.isArray(events) ? events : [];
if (list.length === 0) return;
const ordered = [...list].sort((a, b) => Number(a?.created_at || 0) - Number(b?.created_at || 0));
for (const event of ordered) {
try {
await redeemIncomingNutzapEvent(event, { context });
} catch (error) {
console.warn('[Worker] Failed to redeem incoming nutzap event:', {
eventId: event?.id || null,
context,
message: error?.message || String(error)
});
}
}
}
function appendDirectTransaction(entry = {}) {
const normalizedMint = normalizeMintUrl(entry?.mint || '');
const amount = Number(entry?.amount || 0);
const rawTimestamp = Number(entry?.timestamp || Date.now());
const timestampSec = rawTimestamp > 1e12
? Math.floor(rawTimestamp / 1000)
: Math.floor(rawTimestamp || Date.now() / 1000);
const type = String(entry?.type || 'wallet');
const direction = entry?.direction
|| (type === 'receive' || type === 'deposit' ? 'in' : 'out');
const id = entry?.id || `tx-${timestampSec}-${Math.random().toString(36).slice(2, 10)}`;
if (directTransactions.some((tx) => tx?.id === id)) {
return null;
}
const incomingIsHistory = type === 'history';
const incomingMint = normalizedMint || '';
const incomingDescription = String(entry?.description || entry?.memo || '').trim();
const hasCrossSourceDuplicate = directTransactions.some((tx) => {
if (!tx) return false;
const existingIsHistory = String(tx?.type || '') === 'history';
if (existingIsHistory === incomingIsHistory) return false;
const sameDirection = String(tx?.direction || '') === String(direction || '');
const sameAmount = Number(tx?.amount || 0) === (Number.isFinite(amount) ? amount : 0);
const sameMint = String(tx?.mint || '') === incomingMint;
const existingDescription = String(tx?.description || tx?.memo || '').trim();
const sameDescription = existingDescription === incomingDescription;
const closeInTime = Math.abs(Number(tx?.timestamp || 0) - timestampSec) <= 30;
return sameDirection && sameAmount && sameMint && sameDescription && closeInTime;
});
if (hasCrossSourceDuplicate) {
return null;
}
const tx = {
id,
type,
direction,
amount: Number.isFinite(amount) ? amount : 0,
mint: normalizedMint || null,
memo: entry?.memo || '',
description: entry?.description || entry?.memo || '',
quoteId: entry?.quoteId || null,
preimage: entry?.preimage || null,
timestamp: timestampSec
};
directTransactions.unshift(tx);
if (directTransactions.length > 250) {
directTransactions = directTransactions.slice(0, 250);
}
return tx;
}
function syncLightwalletFromDirectProofStore() {
const payload = getDirectBalancePayload();
lightwalletHasWallet = Boolean(payload.hasWallet);
}
async function publishDirectProofs(debugContext = null) {
const startedAt = Date.now();
const traceId = debugContext?.traceId ? `${debugContext.traceId}:publishDirectProofs` : 'publishDirectProofs';
const log = (phase, extra = null) => {
if (extra === null) {
console.log(`[Worker] ${traceId} +${Date.now() - startedAt}ms ${phase}`);
} else {
console.log(`[Worker] ${traceId} +${Date.now() - startedAt}ms ${phase}`, extra);
}
};
if (!ndk || !messageSigner || !currentPubkey) {
log('skip: missing ndk/messageSigner/currentPubkey', {
hasNdk: Boolean(ndk),
hasMessageSigner: Boolean(messageSigner),
hasCurrentPubkey: Boolean(currentPubkey)
});
return;
}
const NDKEvent = getNDKEventCtor();
log('fetch existing 7375 start');
const existing7375 = await ndk.fetchEvents({
kinds: [7375],
authors: [currentPubkey],
limit: 500
});
log('fetch existing 7375 done', { count: Array.from(existing7375 || []).length });
log('fetch existing 17375 start');
const existing17375 = await ndk.fetchEvents({
kinds: [17375],
authors: [currentPubkey],
limit: 20
});
log('fetch existing 17375 done', { count: Array.from(existing17375 || []).length });
const normalizedStore = {};
for (const [mintUrl, proofs] of Object.entries(directProofStore || {})) {
const normalized = normalizeMintUrl(mintUrl);
if (!normalized) continue;
const cleanProofs = dedupeProofsBySecret(Array.isArray(proofs) ? proofs : []);
normalizedStore[normalized] = cleanProofs;
}
directProofStore = normalizedStore;
const mintEntries = Object.entries(directProofStore);
log('normalized proof store', {
mintCount: mintEntries.length,
totalProofs: mintEntries.reduce((sum, [, proofs]) => sum + (Array.isArray(proofs) ? proofs.length : 0), 0)
});
for (const [mintUrl, proofs] of mintEntries) {
if (!Array.isArray(proofs) || proofs.length === 0) continue;
log('encrypt proof payload start', { mintUrl, proofCount: proofs.length });
const plaintext = JSON.stringify({ mint: mintUrl, proofs });
const encryptedResp = await messageSigner.requestFromPage('nip44Encrypt', {
recipientPubkey: currentPubkey,
plaintext
});
const encrypted = typeof encryptedResp === 'string' ? encryptedResp : encryptedResp?.ciphertext;
if (!encrypted) {
log('encrypt proof payload empty ciphertext', { mintUrl });
continue;
}
log('encrypt proof payload done', { mintUrl, ciphertextLength: encrypted.length });
const tokenEvent = new NDKEvent(ndk, {
kind: 7375,
content: encrypted,
created_at: Math.floor(Date.now() / 1000)
});
log('sign 7375 start', { mintUrl });
await signEventWithMessageSigner(tokenEvent);
log('sign 7375 done', { mintUrl });
log('publish 7375 start', { mintUrl });
const relaySet = await tokenEvent.publish();
log('publish 7375 done', { mintUrl, relayCount: relaySet ? relaySet.size : 0 });
if (relaySet && relaySet.size > 0) {
for (const relay of relaySet) {
trackRelayWrite(relay.url);
}
}
}
log('delete old 7375 start', { count: Array.from(existing7375 || []).length });
for (const evt of Array.from(existing7375 || [])) {
if (!evt?.id) continue;
lightwalletDeletedIds.add(evt.id);
lightwalletTokenEvents.delete(evt.id);
const deletionEvent = new NDKEvent(ndk, {
kind: 5,
tags: [['e', evt.id], ['k', '7375']],
content: '',
created_at: Math.floor(Date.now() / 1000)
});
await signEventWithMessageSigner(deletionEvent);
await deletionEvent.publish();
}
log('delete old 7375 done');
// Ensure a nutzap privkey exists before building the wallet payload.
// If the user's old 17375 didn't have one (or hydration failed),
// this auto-generates one so it's always included in the republish.
try {
ensureDirectNutzapP2pk();
} catch (err) {
log('ensureDirectNutzapP2pk failed during publish', { error: err?.message || String(err) });
}
// NIP-60 standard: public "mint" tags are visible on the event;
// the privkey lives in the encrypted content as a "privkey" tag.
// The "pubkey" tag does NOT belong on kind 17375 (it belongs on
// kind 10019 NutzapInfoEvent). Removed for NIP-60 compliance.
const mintTags = getDirectWalletMints().map((mintUrl) => ['mint', mintUrl]);
// NIP-60 standard content format: a JSON array of tag arrays.
// [["mint","https://..."],["privkey","<hex>"]]
// This matches NDK's payloadForEvent() and Amethyst's
// CashuWalletEvent.build() so other NIP-60 clients can read our wallet.
const walletPayloadObj = [
...getDirectWalletMints().map((url) => ['mint', url]),
...(directNutzapPrivateKeyHex ? [['privkey', directNutzapPrivateKeyHex]] : [])
];
const walletPayload = JSON.stringify(walletPayloadObj);
log('encrypt wallet payload start', { mintTagCount: mintTags.length });
const encryptedWalletResp = await messageSigner.requestFromPage('nip44Encrypt', {
recipientPubkey: currentPubkey,
plaintext: walletPayload
});
const encryptedWallet = typeof encryptedWalletResp === 'string' ? encryptedWalletResp : encryptedWalletResp?.ciphertext;
if (encryptedWallet) {
log('encrypt wallet payload done', { ciphertextLength: encryptedWallet.length });
const walletEvent = new NDKEvent(ndk, {
kind: 17375,
tags: mintTags,
content: encryptedWallet,
created_at: Math.floor(Date.now() / 1000)
});
log('sign 17375 start');
await signEventWithMessageSigner(walletEvent);
log('sign 17375 done');
log('publish 17375 start');
const relaySet = await walletEvent.publish();
log('publish 17375 done', { relayCount: relaySet ? relaySet.size : 0 });
if (relaySet && relaySet.size > 0) {
for (const relay of relaySet) {
trackRelayWrite(relay.url);
}
}
} else {
log('encrypt wallet payload empty ciphertext');
}
log('delete old 17375 start', { count: Array.from(existing17375 || []).length });
for (const evt of Array.from(existing17375 || [])) {
if (!evt?.id) continue;
const deletionEvent = new NDKEvent(ndk, {
kind: 5,
tags: [['e', evt.id], ['k', '17375']],
content: '',
created_at: Math.floor(Date.now() / 1000)
});
await signEventWithMessageSigner(deletionEvent);
await deletionEvent.publish();
}
log('delete old 17375 done');
syncLightwalletFromDirectProofStore();
log('complete');
}
function parseSpendingHistoryTags(input) {
const tags = Array.isArray(input)
? input
: Array.isArray(input?.tags)
? input.tags
: [];
const findTagValue = (name) => {
const found = tags.find((t) => Array.isArray(t) && t[0] === name && t[1] !== undefined);
return found ? String(found[1]) : '';
};
const directionRaw = findTagValue('direction').toLowerCase();
const direction = directionRaw === 'in' ? 'in' : 'out';
const amount = Math.max(0, Math.floor(Number(findTagValue('amount') || 0)));
const mint = normalizeMintUrl(findTagValue('mint')) || null;
const description = findTagValue('description') || findTagValue('memo') || 'Spending history';
const clientTxId = findTagValue('client_tx_id') || '';
return { direction, amount, mint, description, clientTxId };
}
async function processSpendingHistoryEvent(event, pubkey, diagnostics = null) {
if (!event?.id || !event?.content || !messageSigner || !pubkey) {
if (diagnostics) diagnostics.skipped = Number(diagnostics.skipped || 0) + 1;
return null;
}
try {
const decrypted = await messageSigner.requestFromPage('nip44Decrypt', {
senderPubkey: pubkey,
ciphertext: event.content
});
const plaintext = typeof decrypted === 'string' ? decrypted : decrypted?.plaintext;
if (!plaintext) {
if (diagnostics) diagnostics.decryptEmpty = Number(diagnostics.decryptEmpty || 0) + 1;
return null;
}
const parsed = JSON.parse(plaintext);
const txData = parseSpendingHistoryTags(parsed);
if (!Number.isFinite(txData.amount)) {
if (diagnostics) diagnostics.parseInvalidAmount = Number(diagnostics.parseInvalidAmount || 0) + 1;
return null;
}
const relayTxId = String(txData.clientTxId || '').trim() || `nostr-7376-${event.id}`;
const appended = appendDirectTransaction({
id: relayTxId,
type: 'history',
direction: txData.direction,
amount: txData.amount,
mint: txData.mint,
description: txData.description,
timestamp: Number(event.created_at || Math.floor(Date.now() / 1000))
});
if (diagnostics) {
if (appended) {
diagnostics.appended = Number(diagnostics.appended || 0) + 1;
} else {
diagnostics.duplicate = Number(diagnostics.duplicate || 0) + 1;
}
}
return appended;
} catch (error) {
if (diagnostics) diagnostics.decryptOrParseError = Number(diagnostics.decryptOrParseError || 0) + 1;
console.warn('[Worker] Failed to decrypt/process spending history event:', event?.id, error?.message || error);
return null;
}
}
async function refreshSpendingHistoryFromRelays(pubkey, { force = false } = {}) {
if (!ndk || !messageSigner || !pubkey) {
console.log('[Worker] refreshSpendingHistoryFromRelays guard exit', {
hasNdk: Boolean(ndk),
hasMessageSigner: Boolean(messageSigner),
hasPubkey: Boolean(pubkey)
});
return;
}
const now = Date.now();
if (!force && spendingHistoryFetchPromise) {
console.log('[Worker] refreshSpendingHistoryFromRelays join in-flight fetch', {
pubkey: `${String(pubkey).slice(0, 8)}...`
});
await spendingHistoryFetchPromise;
return;
}
if (!force && (now - spendingHistoryLastFetchAtMs) < 4000) {
console.log('[Worker] refreshSpendingHistoryFromRelays skipped by cooldown', {
ageMs: now - spendingHistoryLastFetchAtMs,
pubkey: `${String(pubkey).slice(0, 8)}...`
});
return;
}
spendingHistoryFetchPromise = (async () => {
const startedAt = Date.now();
const txCountBefore = Array.isArray(directTransactions) ? directTransactions.length : 0;
const diagnostics = {
fetchedEvents: 0,
appended: 0,
duplicate: 0,
skipped: 0,
decryptEmpty: 0,
parseInvalidAmount: 0,
decryptOrParseError: 0
};
console.log('[Worker] refreshSpendingHistoryFromRelays start', {
force,
pubkey: `${String(pubkey).slice(0, 8)}...`,
txCountBefore
});
const events = await ndk.fetchEvents({
kinds: [7376],
authors: [pubkey],
limit: 500
});
const list = Array.from(events || [])
.sort((a, b) => Number(a?.created_at || 0) - Number(b?.created_at || 0));
diagnostics.fetchedEvents = list.length;
console.log('[Worker] refreshSpendingHistoryFromRelays fetched events', {
count: diagnostics.fetchedEvents,
newest: list.length ? list[list.length - 1]?.id || null : null,
oldest: list.length ? list[0]?.id || null : null
});
for (const evt of list) {
await processSpendingHistoryEvent(evt, pubkey, diagnostics);
}
spendingHistoryLastFetchAtMs = Date.now();
const txCountAfter = Array.isArray(directTransactions) ? directTransactions.length : 0;
console.log('[Worker] refreshSpendingHistoryFromRelays done', {
durationMs: Date.now() - startedAt,
txCountBefore,
txCountAfter,
txDelta: txCountAfter - txCountBefore,
...diagnostics
});
})();
try {
await spendingHistoryFetchPromise;
} finally {
spendingHistoryFetchPromise = null;
}
}
async function publishSpendingHistoryEvent(entry = {}) {
if (!ndk || !messageSigner || !currentPubkey) return;
const NDKEvent = getNDKEventCtor();
const direction = String(entry?.direction || '').toLowerCase() === 'in' ? 'in' : 'out';
const amount = Math.max(0, Math.floor(Number(entry?.amount || 0)));
const mint = normalizeMintUrl(entry?.mint || '');
const description = String(entry?.description || entry?.memo || '').trim();
const payloadTags = [
['direction', direction],
['amount', String(amount)],
['unit', 'sat']
];
if (mint) payloadTags.push(['mint', mint]);
if (description) payloadTags.push(['description', description]);
if (entry?.id) payloadTags.push(['client_tx_id', String(entry.id)]);
const encryptedResp = await messageSigner.requestFromPage('nip44Encrypt', {
recipientPubkey: currentPubkey,
plaintext: JSON.stringify(payloadTags)
});
const encrypted = typeof encryptedResp === 'string' ? encryptedResp : encryptedResp?.ciphertext;
if (!encrypted) return;
const historyEvent = new NDKEvent(ndk, {
kind: 7376,
content: encrypted,
created_at: Math.floor(Date.now() / 1000)
});
await signEventWithMessageSigner(historyEvent);
const relaySet = await historyEvent.publish();
if (relaySet && relaySet.size > 0) {
for (const relay of relaySet) {
trackRelayWrite(relay.url);
}
}
}
async function handleWalletPublishMintList(requestId, relays, receiveMints, port) {
try {
await ensureDirectWalletLoaded();
const walletMints = getDirectWalletMints();
if (!Array.isArray(walletMints) || walletMints.length === 0) {
throw new Error('Cannot publish mint list without at least one configured mint');
}
const requestedReceiveMints = Array.from(new Set((Array.isArray(receiveMints) ? receiveMints : [])
.map((value) => normalizeMintUrl(value))
.filter(Boolean)));
const walletMintSet = new Set(walletMints);
const selectedReceiveMints = requestedReceiveMints.filter((mintUrl) => walletMintSet.has(mintUrl));
if (requestedReceiveMints.length > 0 && selectedReceiveMints.length === 0) {
throw new Error('No valid receive mints selected; add selected mints to wallet first');
}
const mints = selectedReceiveMints.length > 0 ? selectedReceiveMints : walletMints;
const relayUrls = Array.from(new Set([
...(Array.isArray(relays) ? relays : []).map((url) => String(url || '').trim()).filter((url) => /^wss?:\/\//i.test(url)),
...getWalletRelayUrls()
]));
const nutzapKeys = ensureDirectNutzapP2pk();
const p2pk = normalizeCashuP2pk(nutzapKeys?.p2pk || '');
const NDKEvent = getNDKEventCtor();
const eventTags = [
...mints.map((mintUrl) => ['mint', mintUrl]),
...relayUrls.map((relayUrl) => ['relay', relayUrl])
];
if (p2pk) eventTags.push(['pubkey', p2pk]);
let eventId = null;
let publishedRelayCount = 0;
if (NDKCashuMintList) {
const mintListEvent = new NDKCashuMintList(ndk);
mintListEvent.mints = mints;
mintListEvent.relays = relayUrls;
if (p2pk) mintListEvent.p2pk = p2pk;
await signEventWithMessageSigner(mintListEvent);
const relaySet = await mintListEvent.publishReplaceable();
eventId = mintListEvent.id || null;
publishedRelayCount = relaySet ? relaySet.size : 0;
if (relaySet && relaySet.size > 0) {
for (const relay of relaySet) {
trackRelayWrite(relay.url);
}
}
} else {
const mintListEvent = new NDKEvent(ndk, {
kind: 10019,
tags: eventTags,
content: '',
created_at: Math.floor(Date.now() / 1000)
});
await signEventWithMessageSigner(mintListEvent);
const relaySet = await mintListEvent.publish();
eventId = mintListEvent.id || null;
publishedRelayCount = relaySet ? relaySet.size : 0;
if (relaySet && relaySet.size > 0) {
for (const relay of relaySet) {
trackRelayWrite(relay.url);
}
}
}
port.postMessage({
type: 'response',
requestId,
data: {
success: true,
eventId,
mints,
relays: relayUrls,
p2pk,
publishedRelayCount
}
});
} catch (error) {
port.postMessage({ type: 'response', requestId, error: error?.message || String(error) });
}
}
/**
* Parse a kind 3 (contact list) event into a de-duplicated array of followed
* pubkeys (the 'p' tags).
* @param {Object|null|undefined} kind3Event
* @returns {string[]}
*/
function extractFollowedPubkeysFromKind3(kind3Event) {
if (!kind3Event || !Array.isArray(kind3Event.tags)) return [];
const pubkeys = new Set();
for (const tag of kind3Event.tags) {
if (Array.isArray(tag) && tag[0] === 'p' && tag[1] && String(tag[1]).length >= 32) {
pubkeys.add(String(tag[1]));
}
}
return Array.from(pubkeys);
}
/**
* Parse a raw kind 10019 event into the mint-list shape used by the cache.
* @param {Object} event
* @returns {{ hasMintList:boolean, mints:string[], relays:string[], p2pk:string|null, eventId:string|null, created_at:number }}
*/
function parseKind10019Event(event) {
const mints = Array.from(new Set((event.tags || [])
.filter((tag) => tag?.[0] === 'mint' && tag?.[1])
.map((tag) => normalizeMintUrl(tag[1]))
.filter(Boolean)));
const relays = Array.from(new Set((event.tags || [])
.filter((tag) => tag?.[0] === 'relay' && tag?.[1])
.map((tag) => String(tag[1]).trim())
.filter((url) => /^wss?:\/\//i.test(url))));
const p2pkTag = (event.tags || []).find((tag) => tag?.[0] === 'pubkey' && tag?.[1]);
const p2pk = normalizeCashuP2pk(p2pkTag?.[1] || '') || null;
return {
hasMintList: mints.length > 0,
mints,
relays,
p2pk,
eventId: event.id || null,
created_at: Number(event.created_at || 0)
};
}
/**
* Proactively fetch kind 10019 (nutzap mint lists) for all followed pubkeys
* discovered in the user's kind 3 contact list, and cache the results in
* IndexedDB. This is the Tier 1 fetch strategy: followed users are pre-warmed
* on startup so resolveZapCapabilities() can hit IDB instead of relays.
*
* Runs in the background and never rejects — failures are logged only.
*
* @param {Object|null|undefined} kind3Event - the user's kind 3 contact list
*/
async function prefetchFollowedNutzapMintLists(kind3Event) {
if (!ndk) return;
const pubkeys = extractFollowedPubkeysFromKind3(kind3Event);
if (pubkeys.length === 0) {
broadcastStartupStatus('No followed pubkeys to prefetch nutzap mint lists for', {
stage: 'kind10019-prefetch',
state: 'skipped'
});
return;
}
broadcastStartupStatus(`Prefetching nutzap mint lists (kind 10019) for ${pubkeys.length} followed users...`, {
stage: 'kind10019-prefetch',
state: 'start',
count: pubkeys.length
});
const startedAt = Date.now();
let stored = 0;
try {
// Fetch in batches to keep relay filters reasonable.
for (let i = 0; i < pubkeys.length; i += NUTZAP_MINTLIST_PREFETCH_BATCH) {
const batch = pubkeys.slice(i, i + NUTZAP_MINTLIST_PREFETCH_BATCH);
try {
const events = await ndk.fetchEvents({
kinds: [10019],
authors: batch
});
const eventList = Array.from(events || []);
// Group events by author and keep only the latest per pubkey.
const latestByAuthor = new Map();
for (const event of eventList) {
const prev = latestByAuthor.get(event.pubkey);
if (!prev || Number(event.created_at || 0) > Number(prev.created_at || 0)) {
latestByAuthor.set(event.pubkey, event);
}
}
for (const [author, event] of latestByAuthor) {
const parsed = parseKind10019Event(event);
await putNutzapMintListToDb(author, parsed);
stored++;
}
// Record an explicit "no mint list" entry for followed pubkeys
// that returned nothing, so we can short-circuit future lookups
// within the TTL window.
for (const author of batch) {
if (!latestByAuthor.has(author)) {
await putNutzapMintListToDb(author, {
hasMintList: false,
mints: [],
relays: [],
p2pk: null,
eventId: null,
created_at: 0
});
}
}
} catch (batchError) {
console.warn('[Worker] kind 10019 prefetch batch failed', batchError?.message || batchError);
}
}
broadcastStartupStatus(`Nutzap mint list prefetch done (${stored} stored)`, {
stage: 'kind10019-prefetch',
state: 'done',
count: stored,
durationMs: Date.now() - startedAt
});
} catch (error) {
broadcastStartupStatus('Nutzap mint list prefetch failed', {
stage: 'kind10019-prefetch',
state: 'failed',
error: error?.message || String(error),
durationMs: Date.now() - startedAt
});
console.warn('[Worker] prefetchFollowedNutzapMintLists failed', error?.message || error);
}
}
async function handleWalletFetchMintList(requestId, targetPubkey, port) {
try {
if (!ndk) throw new Error('NDK not initialized');
const author = String(targetPubkey || '').trim();
if (!author || author.length < 32) {
throw new Error('Target pubkey is required');
}
// Tier 2 lazy fetch: consult the IDB cache (populated by the Tier 1
// startup prefetch) before hitting relays. A fresh cache hit avoids a
// round-trip for non-followed-but-recently-seen pubkeys too.
try {
const cached = await getNutzapMintListFromDb(author);
if (cached) {
port.postMessage({
type: 'response',
requestId,
data: {
hasMintList: Boolean(cached.hasMintList),
mints: Array.isArray(cached.mints) ? cached.mints : [],
relays: Array.isArray(cached.relays) ? cached.relays : [],
p2pk: cached.p2pk || null,
eventId: cached.eventId || null,
created_at: Number(cached.created_at || 0)
}
});
return;
}
} catch (_cacheError) {
// Fall through to relay fetch on any cache error.
}
const events = await ndk.fetchEvents({
kinds: [10019],
authors: [author],
limit: 20
});
const latest = Array.from(events || [])
.sort((a, b) => Number(b?.created_at || 0) - Number(a?.created_at || 0))[0];
if (!latest) {
const emptyEntry = {
hasMintList: false,
mints: [],
relays: [],
p2pk: null,
eventId: null,
created_at: 0
};
// Cache the negative result so repeated lookups are cheap.
putNutzapMintListToDb(author, emptyEntry).catch(() => {});
port.postMessage({ type: 'response', requestId, data: emptyEntry });
return;
}
const parsed = parseKind10019Event(latest);
// Cache the relay result for future lookups.
putNutzapMintListToDb(author, parsed).catch(() => {});
port.postMessage({
type: 'response',
requestId,
data: {
hasMintList: parsed.hasMintList,
mints: parsed.mints,
relays: parsed.relays,
p2pk: parsed.p2pk,
eventId: parsed.eventId,
created_at: parsed.created_at
}
});
} catch (error) {
port.postMessage({ type: 'response', requestId, error: error?.message || String(error) });
}
}
async function handleWalletSendNutzap(
requestId,
amount,
memo,
targetPubkey,
eventId,
mint,
recipientMints,
recipientP2pk,
recipientRelays,
port
) {
const startedAt = Date.now();
const traceId = `walletSendNutzap:${requestId}`;
const log = (phase, extra = null) => {
if (extra === null) {
console.log(`[Worker] ${traceId} +${Date.now() - startedAt}ms ${phase}`);
} else {
console.log(`[Worker] ${traceId} +${Date.now() - startedAt}ms ${phase}`, extra);
}
};
try {
await ensureDirectWalletLoaded();
const sats = Math.floor(Number(amount));
if (!Number.isFinite(sats) || sats <= 0) throw new Error('Amount must be a positive number');
const recipient = normalizeHexPubkey(targetPubkey);
if (!recipient) throw new Error('Recipient pubkey is required');
const recipientMintsNormalized = Array.from(new Set((Array.isArray(recipientMints) ? recipientMints : [])
.map((value) => normalizeMintUrl(value))
.filter(Boolean)));
const requestedMint = normalizeMintUrl(mint || '');
const knownMints = getDirectWalletMints();
const candidateMints = requestedMint
? [requestedMint, ...knownMints.filter((m) => m !== requestedMint)]
: recipientMintsNormalized.length > 0
? recipientMintsNormalized.filter((m) => knownMints.includes(m))
: [...knownMints].sort((a, b) => getProofTotal(directProofStore[b]) - getProofTotal(directProofStore[a]));
if (!Array.isArray(candidateMints) || candidateMints.length === 0) {
throw new Error('No compatible mint available for recipient nutzap');
}
const p2pk = normalizeCashuP2pk(recipientP2pk || '');
const proofTags = [['P', currentPubkey || '']];
const normalizedEventId = String(eventId || '').trim();
if (normalizedEventId) {
proofTags.push(['e', normalizedEventId]);
}
let sendProofs = null;
let keepProofs = null;
let usedMint = null;
const attemptErrors = [];
for (const mintUrl of candidateMints) {
try {
const proofs = Array.isArray(directProofStore[mintUrl]) ? directProofStore[mintUrl] : [];
const proofBalance = getProofTotal(proofs);
log('mint attempt:start', { mintUrl, proofBalance, sats, proofCount: proofs.length });
if (proofBalance < sats) {
attemptErrors.push({ mint: mintUrl, message: `Insufficient funds on mint (${proofBalance} sats)` });
continue;
}
const walletForMint = await getDirectWalletForMint(mintUrl);
const sendResult = await walletForMint.send(sats, proofs, {
offline: false,
...(p2pk ? { pubkey: p2pk } : {}),
...(proofTags.length > 0 ? { tags: proofTags } : {})
});
const keep = Array.isArray(sendResult?.keep) ? sendResult.keep : [];
const outProofs = Array.isArray(sendResult?.send) ? sendResult.send : [];
if (outProofs.length === 0) {
attemptErrors.push({ mint: mintUrl, message: 'Wallet returned no send proofs' });
continue;
}
sendProofs = outProofs;
keepProofs = keep;
usedMint = mintUrl;
break;
} catch (error) {
attemptErrors.push({ mint: mintUrl, message: error?.message || String(error) });
}
}
if (!sendProofs || !usedMint) {
const detail = attemptErrors.map((entry) => `${entry.mint || 'unknown'}: ${entry.message}`).join(' | ');
throw new Error(`Failed to create nutzap proofs. ${detail || 'No mint could send token'}`);
}
upsertMintProofs(usedMint, Array.isArray(keepProofs) ? keepProofs : []);
const recipientRelayUrls = Array.from(new Set((Array.isArray(recipientRelays) ? recipientRelays : [])
.map((url) => String(url || '').trim())
.filter((url) => /^wss?:\/\//i.test(url))));
const NDKEvent = getNDKEventCtor();
const tags = [
['p', recipient],
['u', usedMint],
['amount', String(sats)],
['unit', 'sat'],
...sendProofs.map((proof) => ['proof', JSON.stringify(proof)])
];
if (normalizedEventId) {
tags.push(['e', normalizedEventId]);
}
const nutzapEvent = new NDKEvent(ndk, {
kind: 9321,
content: String(memo || ''),
tags,
created_at: Math.floor(Date.now() / 1000)
});
await signEventWithMessageSigner(nutzapEvent);
let relaySet = null;
if (recipientRelayUrls.length > 0 && NDKRelaySet?.fromRelayUrls) {
relaySet = await nutzapEvent.publish(NDKRelaySet.fromRelayUrls(recipientRelayUrls, ndk));
} else {
relaySet = await nutzapEvent.publish();
}
if (relaySet && relaySet.size > 0) {
for (const relay of relaySet) {
trackRelayWrite(relay.url);
}
}
const txMemo = String(memo || '').trim() || 'Nutzap sent';
const tx = appendDirectTransaction({
type: 'nutzap-send',
direction: 'out',
amount: sats,
mint: usedMint,
memo: txMemo,
description: txMemo,
nutzapEventId: nutzapEvent.id || null,
toPubkey: recipient
});
const payload = getWalletBalancePayload();
// Respond immediately after local wallet state and nutzap publish are complete,
// so slow proof-history relay writes do not trigger front-end timeouts.
port.postMessage({
type: 'response',
requestId,
data: {
success: true,
amount: sats,
mint: usedMint,
p2pk: p2pk || null,
nutzapEventId: nutzapEvent.id || null,
relayCount: relaySet ? relaySet.size : 0,
...payload
}
});
broadcast({ type: 'walletBalanceUpdated', data: payload });
broadcast({ type: 'walletTransactionReceived', data: { latest: tx || null, ...payload } });
// Publish proofs and spending history in the background so relay latency
// cannot block nutzap completion in the UI.
void (async () => {
try {
await publishDirectProofs({ traceId });
} catch (publishError) {
console.warn('[Worker] walletSendNutzap background publishDirectProofs failed:', publishError?.message || publishError);
}
if (!tx) return;
try {
await publishSpendingHistoryEvent(tx);
} catch (historyError) {
console.warn('[Worker] walletSendNutzap background publishSpendingHistoryEvent failed:', historyError?.message || historyError);
}
})();
} catch (error) {
log('error', { message: error?.message || String(error), stack: error?.stack || null });
port.postMessage({ type: 'response', requestId, error: error?.message || String(error) });
}
}
async function handleWalletInit(requestId, port) {
try {
if (initCompletedPromise) {
await Promise.race([
initCompletedPromise.catch(() => null),
new Promise((resolve) => setTimeout(resolve, 2500))
]);
}
if (startupDownloadsPromise) {
await Promise.race([
startupDownloadsPromise.catch(() => null),
new Promise((resolve) => setTimeout(resolve, 2500))
]);
}
await ensureDirectWalletLoaded();
const payload = getWalletBalancePayload();
const hasWalletFallback = getDirectHasWallet();
const mintsFallback = getDirectWalletMints();
port.postMessage({
type: 'response',
requestId,
data: {
hasWallet: hasWalletFallback,
status: directWalletStatus,
deferred: false,
mints: mintsFallback,
...payload
}
});
} catch (error) {
directWalletStatus = 'failed';
broadcastWalletStatus({ error: error?.message || String(error) });
port.postMessage({ type: 'response', requestId, error: error.message });
}
}
async function handleWalletStart(requestId, _pubkeyOverride, port) {
try {
await ensureDirectWalletLoaded();
const payload = getWalletBalancePayload();
broadcastWalletStatus(payload);
port.postMessage({ type: 'response', requestId, data: { success: true, status: directWalletStatus, ...payload } });
} catch (error) {
directWalletStatus = 'failed';
broadcastWalletStatus({ error: error?.message || String(error) });
port.postMessage({ type: 'response', requestId, error: error.message });
}
}
async function handleWalletCreate(requestId, mints, _relays, port) {
try {
const mintList = Array.from(new Set((Array.isArray(mints) ? mints : []).map(normalizeMintUrl).filter(Boolean)));
if (mintList.length === 0) throw new Error('At least one mint URL is required');
directWalletStatus = 'loading';
broadcastWalletStatus();
for (const mintUrl of mintList) {
if (!Array.isArray(directProofStore[mintUrl])) directProofStore[mintUrl] = [];
await getDirectWalletForMint(mintUrl);
}
directMintList = Array.from(new Set([...(directMintList || []), ...mintList]));
directWalletLoaded = true;
directWalletStatus = 'ready';
ensureDirectNutzapP2pk();
await publishDirectProofs();
const payload = getWalletBalancePayload();
broadcastWalletStatus(payload);
port.postMessage({
type: 'response',
requestId,
data: {
success: true,
mints: getDirectWalletMints(),
...payload
}
});
} catch (error) {
directWalletStatus = 'failed';
broadcastWalletStatus({ error: error?.message || String(error) });
port.postMessage({ type: 'response', requestId, error: error.message });
}
}
async function handleWalletHasWallet(requestId, port) {
port.postMessage({ type: 'response', requestId, data: { hasWallet: getDirectHasWallet() } });
}
async function handleWalletGetBalance(requestId, port) {
try {
await ensureDirectWalletLoaded();
const payload = getWalletBalancePayload();
port.postMessage({ type: 'response', requestId, data: payload });
} catch (error) {
port.postMessage({ type: 'response', requestId, error: error.message });
}
}
async function handleWalletGetMints(requestId, port) {
try {
await ensureDirectWalletLoaded();
port.postMessage({ type: 'response', requestId, data: { mints: getDirectWalletMints() } });
} catch (error) {
port.postMessage({ type: 'response', requestId, error: error.message });
}
}
async function handleWalletReceiveToken(requestId, token, description, port) {
try {
await ensureDirectWalletLoaded();
const tokenText = String(token || '').trim();
if (!tokenText) throw new Error('Token is required');
const decoded = decodeDirectToken(tokenText);
const mintUrl = normalizeMintUrl(decoded?.mint || decoded?.token?.[0]?.mint || '');
if (!mintUrl) throw new Error('Unable to determine mint from token');
const wallet = await getDirectWalletForMint(mintUrl);
const receiveResult = await wallet.receive(tokenText);
const receivedProofs = Array.isArray(receiveResult)
? receiveResult
: Array.isArray(receiveResult?.proofs)
? receiveResult.proofs
: [];
const existing = Array.isArray(directProofStore[mintUrl]) ? directProofStore[mintUrl] : [];
upsertMintProofs(mintUrl, [...existing, ...receivedProofs]);
const amount = getProofTotal(receivedProofs);
const tx = appendDirectTransaction({
type: 'receive',
amount,
mint: mintUrl,
memo: description || 'Receive token'
});
const payload = getWalletBalancePayload();
port.postMessage({ type: 'response', requestId, data: { success: true, ...payload } });
broadcast({ type: 'walletBalanceUpdated', data: payload });
broadcast({ type: 'walletTransactionReceived', data: { latest: directTransactions[0] || null, ...payload } });
void (async () => {
try {
await publishDirectProofs();
} catch (publishError) {
console.warn('[Worker] handleWalletReceiveToken publishDirectProofs failed', {
requestId,
message: publishError?.message || String(publishError)
});
}
if (!tx) return;
try {
await publishSpendingHistoryEvent(tx);
} catch (historyError) {
console.warn('[Worker] handleWalletReceiveToken publishSpendingHistoryEvent failed', {
requestId,
txId: tx?.id || null,
message: historyError?.message || String(historyError)
});
}
})();
} catch (error) {
port.postMessage({ type: 'response', requestId, error: error.message });
}
}
async function handleWalletSendToken(requestId, amount, memo, mint, port) {
const startedAt = Date.now();
const traceId = `walletSendToken:${requestId}`;
const log = (phase, extra = null) => {
if (extra === null) {
console.log(`[Worker] ${traceId} +${Date.now() - startedAt}ms ${phase}`);
} else {
console.log(`[Worker] ${traceId} +${Date.now() - startedAt}ms ${phase}`, extra);
}
};
try {
log('start', { amount, memoLength: String(memo || '').length, mint: mint || null });
log('ensureDirectWalletLoaded:start');
await ensureDirectWalletLoaded();
log('ensureDirectWalletLoaded:done', {
directWalletLoaded,
directWalletStatus,
mintCount: getDirectWalletMints().length
});
const sats = Math.floor(Number(amount));
if (!Number.isFinite(sats) || sats <= 0) throw new Error('Amount must be a positive number');
const requestedMint = normalizeMintUrl(mint || '');
const knownMints = getDirectWalletMints();
const orderedMints = requestedMint
? [requestedMint, ...knownMints.filter((m) => m !== requestedMint)]
: [...knownMints].sort((a, b) => getProofTotal(directProofStore[b]) - getProofTotal(directProofStore[a]));
log('mints prepared', {
sats,
requestedMint: requestedMint || null,
knownMints: knownMints.length,
orderedMints
});
let tokenOut = null;
let usedMint = null;
let sentProofsForTracking = [];
const attemptErrors = [];
for (const mintUrl of orderedMints) {
try {
const proofs = Array.isArray(directProofStore[mintUrl]) ? directProofStore[mintUrl] : [];
const proofBalance = getProofTotal(proofs);
log('mint attempt:start', { mintUrl, proofCount: proofs.length, proofBalance, sats });
if (proofBalance < sats) {
const message = `Insufficient funds on mint (${proofBalance} sats)`;
attemptErrors.push({ mint: mintUrl, message });
log('mint attempt:skip insufficient funds', { mintUrl, proofBalance, sats });
continue;
}
log('getDirectWalletForMint:start', { mintUrl });
const wallet = await getDirectWalletForMint(mintUrl);
log('getDirectWalletForMint:done', { mintUrl, hasWallet: Boolean(wallet) });
log('wallet.send:start', { mintUrl, sats, proofCount: proofs.length });
const sendResult = await withTimeout(
() => wallet.send(sats, proofs, { offline: false }),
CASHU_SEND_TIMEOUT_MS,
`Mint send timeout (${mintUrl})`
);
log('wallet.send:done', {
mintUrl,
keepCount: Array.isArray(sendResult?.keep) ? sendResult.keep.length : 0,
sendCount: Array.isArray(sendResult?.send) ? sendResult.send.length : 0
});
const keep = Array.isArray(sendResult?.keep) ? sendResult.keep : [];
const sendProofs = Array.isArray(sendResult?.send) ? sendResult.send : [];
if (sendProofs.length === 0) {
const message = 'Wallet returned no send proofs';
attemptErrors.push({ mint: mintUrl, message });
log('mint attempt:empty send proofs', { mintUrl });
continue;
}
log('encodeDirectToken:start', { mintUrl, sendProofCount: sendProofs.length });
tokenOut = encodeDirectToken(mintUrl, sendProofs);
log('encodeDirectToken:done', { mintUrl, tokenLength: tokenOut ? tokenOut.length : 0 });
upsertMintProofs(mintUrl, keep);
log('upsertMintProofs:done', { mintUrl, keepCount: keep.length });
sentProofsForTracking = sendProofs;
usedMint = mintUrl;
log('mint attempt:success', { mintUrl });
break;
} catch (error) {
const message = error?.message || String(error);
attemptErrors.push({ mint: mintUrl, message });
log('mint attempt:error', { mintUrl, message, stack: error?.stack || null });
}
}
if (!tokenOut || !usedMint) {
const detail = attemptErrors.map((entry) => `${entry.mint || 'unknown'}: ${entry.message}`).join(' | ');
log('all mint attempts failed', { attemptErrors });
throw new Error(`Failed to generate token for ${sats} sats. ${detail || 'No mint could send token'}`);
}
const tx = appendDirectTransaction({
type: 'send',
amount: sats,
mint: usedMint,
memo: memo || 'Cashu send'
});
log('appendDirectTransaction:done', { hasTx: Boolean(tx), txId: tx?.id || null });
const payload = getWalletBalancePayload();
log('sending response', {
usedMint,
tokenLength: tokenOut ? tokenOut.length : 0,
balance: payload?.balance,
mintCount: Object.keys(payload?.balancesByMint || {}).length
});
port.postMessage({
type: 'response',
requestId,
data: {
token: tokenOut,
amount: sats,
mint: usedMint,
...payload
}
});
broadcast({ type: 'walletBalanceUpdated', data: payload });
if (usedMint && Array.isArray(sentProofsForTracking) && sentProofsForTracking.length > 0) {
schedulePendingSendTracking({
mintUrl: usedMint,
proofs: sentProofsForTracking,
amount: sats,
memo: memo || 'Cashu send',
txId: tx?.id || null
});
}
void (async () => {
try {
log('publishDirectProofs:start(background)');
await publishDirectProofs({ traceId });
log('publishDirectProofs:done(background)');
} catch (publishError) {
log('publishDirectProofs:error(background)', { message: publishError?.message || String(publishError) });
}
if (!tx) return;
try {
log('publishSpendingHistoryEvent:start(background)', { txId: tx?.id || null });
await publishSpendingHistoryEvent(tx);
log('publishSpendingHistoryEvent:done(background)', { txId: tx?.id || null });
} catch (historyError) {
log('publishSpendingHistoryEvent:error(background)', { message: historyError?.message || String(historyError) });
}
})();
log('complete');
} catch (error) {
log('error', { message: error?.message || String(error), stack: error?.stack || null });
port.postMessage({ type: 'response', requestId, error: error.message });
}
}
function schedulePendingSendTracking({ mintUrl, proofs, amount, memo, txId = null }) {
const normalizedMint = normalizeMintUrl(mintUrl || '');
const proofList = Array.isArray(proofs) ? dedupeProofsBySecret(proofs) : [];
if (!normalizedMint || proofList.length === 0) return;
const sendId = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
const startedAt = Date.now();
console.log('[Worker] pending-send tracking:start', {
sendId,
mint: normalizedMint,
amount: Number(amount || 0),
memo: String(memo || ''),
proofCount: proofList.length,
txId: txId || null
});
const timerId = setInterval(async () => {
const pending = pendingSends.get(sendId);
if (!pending) {
console.log('[Worker] pending-send tracking:missing pending entry, stopping timer', { sendId });
clearInterval(timerId);
return;
}
if ((Date.now() - startedAt) >= CASHU_PENDING_SEND_MAX_AGE_MS) {
console.log('[Worker] pending-send tracking:expired', {
sendId,
ageMs: Date.now() - startedAt,
maxAgeMs: CASHU_PENDING_SEND_MAX_AGE_MS
});
pendingSends.delete(sendId);
clearInterval(timerId);
return;
}
try {
const wallet = await getDirectWalletForMint(normalizedMint);
const states = await withTimeout(
() => wallet.checkProofsStates(proofList),
CASHU_CHECK_PROOFS_TIMEOUT_MS,
`Pending send proof-check timeout (${normalizedMint})`
);
const stateList = Array.isArray(states)
? states
: Array.isArray(states?.states)
? states.states
: [];
const normalizedStates = proofList.map((_proof, index) => {
const rawState = stateList[index];
return String(rawState?.state || rawState || '').toUpperCase();
});
const allSpent = proofList.length > 0 && normalizedStates.every((state) => state === 'SPENT');
console.log('[Worker] pending-send tracking:poll', {
sendId,
mint: normalizedMint,
allSpent,
states: normalizedStates,
proofCount: proofList.length
});
if (!allSpent) return;
pendingSends.delete(sendId);
clearInterval(timerId);
console.log('[Worker] pending-send tracking:redeemed', {
sendId,
mint: normalizedMint,
amount: Number(amount || 0),
txId: txId || null
});
broadcast({
type: 'walletSendRedeemed',
data: {
sendId,
mint: normalizedMint,
amount: Number(amount || 0),
memo: String(memo || ''),
proofCount: proofList.length,
txId: txId || null,
redeemedAt: Date.now()
}
});
} catch (error) {
console.warn('[Worker] pending-send tracking:poll error', {
sendId,
mint: normalizedMint,
message: error?.message || String(error)
});
// Best effort polling; do not interrupt wallet flow on transient mint errors.
}
}, CASHU_PENDING_SEND_POLL_MS);
pendingSends.set(sendId, {
mintUrl: normalizedMint,
proofs: proofList,
amount: Number(amount || 0),
memo: String(memo || ''),
timerId,
startedAt,
txId: txId || null
});
}
async function handleWalletCreateDeposit(requestId, amount, mint, port) {
try {
await ensureDirectWalletLoaded();
const sats = Math.floor(Number(amount));
if (!Number.isFinite(sats) || sats <= 0) throw new Error('Amount must be a positive number');
const mintUrl = normalizeMintUrl(mint || getDirectWalletMints()[0] || '');
if (!mintUrl) throw new Error('Mint is required for deposit');
const wallet = await getDirectWalletForMint(mintUrl);
if (typeof wallet?.createMintQuote !== 'function') {
throw new Error('Cashu wallet does not support mint quotes');
}
const quote = await wallet.createMintQuote(sats);
const quoteId = String(quote?.quote || quote?.id || '').trim();
const invoice = quote?.request || quote?.pr || quote?.payment_request || null;
if (!quoteId) {
port.postMessage({ type: 'response', requestId, data: { invoice, quoteId: null, mint: mintUrl, amount: sats } });
return;
}
const timerId = setInterval(async () => {
const pending = pendingDeposits.get(quoteId);
if (!pending) {
clearInterval(timerId);
return;
}
try {
const check = await wallet.checkMintQuote(quoteId);
const state = String(check?.state || '').toUpperCase();
if (state !== 'PAID' && state !== 'ISSUED') return;
const minted = await wallet.mintProofs(sats, quoteId);
const existing = Array.isArray(directProofStore[mintUrl]) ? directProofStore[mintUrl] : [];
upsertMintProofs(mintUrl, [...existing, ...(Array.isArray(minted) ? minted : [])]);
pendingDeposits.delete(quoteId);
clearInterval(timerId);
await publishDirectProofs();
const tx = appendDirectTransaction({
type: 'deposit',
amount: sats,
mint: mintUrl,
quoteId,
memo: 'Mint quote settled'
});
if (tx) {
await publishSpendingHistoryEvent(tx);
}
const payload = getWalletBalancePayload();
broadcast({
type: 'walletDepositConfirmed',
data: {
quoteId,
amount: sats,
mint: mintUrl,
...payload
}
});
broadcast({ type: 'walletBalanceUpdated', data: payload });
} catch (error) {
console.warn('[Worker] Deposit quote poll failed:', error?.message || error);
}
}, 4000);
pendingDeposits.set(quoteId, {
mintUrl,
amount: sats,
quoteId,
timerId,
startedAt: Date.now()
});
port.postMessage({
type: 'response',
requestId,
data: {
invoice,
quoteId,
mint: mintUrl,
amount: sats
}
});
} catch (error) {
port.postMessage({ type: 'response', requestId, error: error.message });
}
}
async function handleWalletPayInvoice(requestId, invoice, mint, port) {
try {
await ensureDirectWalletLoaded();
const pr = String(invoice || '').trim();
if (!pr) throw new Error('Lightning invoice is required');
const requestedMint = normalizeMintUrl(mint || '');
const knownMints = getDirectWalletMints();
const orderedMints = requestedMint
? [requestedMint, ...knownMints.filter((m) => m !== requestedMint)]
: [...knownMints].sort((a, b) => getProofTotal(directProofStore[b]) - getProofTotal(directProofStore[a]));
let payResult = null;
let usedMint = null;
let paidAmountSats = 0;
const attemptErrors = [];
for (const mintUrl of orderedMints) {
try {
const proofs = Array.isArray(directProofStore[mintUrl]) ? directProofStore[mintUrl] : [];
if (proofs.length === 0) {
attemptErrors.push({ mint: mintUrl, message: 'No proofs available for mint' });
continue;
}
const wallet = await getDirectWalletForMint(mintUrl);
const meltQuote = await wallet.createMeltQuote(pr);
const quoteAmount = Number(meltQuote?.amount || 0);
const quoteFeeReserve = Number(meltQuote?.fee_reserve || 0);
const required = quoteAmount + quoteFeeReserve;
if (!Number.isFinite(required) || required <= 0) {
attemptErrors.push({ mint: mintUrl, message: 'Invalid melt quote amount' });
continue;
}
const proofBalance = getProofTotal(proofs);
if (proofBalance < required) {
attemptErrors.push({ mint: mintUrl, message: `Insufficient funds on mint (${proofBalance} sats)` });
continue;
}
const sendResult = await wallet.send(required, proofs);
const keep = Array.isArray(sendResult?.keep) ? sendResult.keep : [];
const sendProofs = Array.isArray(sendResult?.send) ? sendResult.send : [];
const meltResult = await wallet.meltProofs(meltQuote, sendProofs);
const change = Array.isArray(meltResult?.change) ? meltResult.change : [];
upsertMintProofs(mintUrl, [...keep, ...change]);
const changeAmount = getProofTotal(change);
const spentAmount = Math.max(0, required - changeAmount);
paidAmountSats = Number.isFinite(quoteAmount) && quoteAmount > 0
? quoteAmount
: spentAmount;
usedMint = mintUrl;
payResult = meltResult;
break;
} catch (error) {
attemptErrors.push({ mint: mintUrl, message: error?.message || String(error) });
}
}
if (!payResult || !usedMint) {
const detail = attemptErrors.map((entry) => `${entry.mint || 'unknown'}: ${entry.message}`).join(' | ');
throw new Error(`Failed to pay invoice. ${detail || 'No mint could complete melt'}`);
}
const tx = appendDirectTransaction({
type: 'pay',
amount: Number.isFinite(paidAmountSats) && paidAmountSats > 0
? paidAmountSats
: Number(payResult?.amount || 0),
mint: usedMint,
preimage: payResult?.preimage || null,
memo: 'Lightning payment'
});
const payload = getWalletBalancePayload();
// Respond immediately after local wallet state is updated so UI does not block
// behind relay publishing, encryption, and cleanup work.
port.postMessage({
type: 'response',
requestId,
data: {
success: true,
preimage: payResult?.preimage || null,
mint: usedMint,
...payload
}
});
broadcast({ type: 'walletBalanceUpdated', data: payload });
broadcast({ type: 'walletTransactionReceived', data: { latest: tx || null, ...payload } });
// Publish proofs/spending history in the background so slow relays cannot
// delay walletPayInvoice request completion.
void (async () => {
try {
await publishDirectProofs();
} catch (publishError) {
console.warn('[Worker] walletPayInvoice background publishDirectProofs failed:', publishError?.message || publishError);
}
if (!tx) return;
try {
await publishSpendingHistoryEvent(tx);
} catch (historyError) {
console.warn('[Worker] walletPayInvoice background publishSpendingHistoryEvent failed:', historyError?.message || historyError);
}
})();
} catch (error) {
port.postMessage({ type: 'response', requestId, error: error.message });
}
}
async function handleWalletAddMint(requestId, mintUrl, port) {
try {
const normalized = normalizeMintUrl(mintUrl);
if (!normalized) throw new Error('Mint URL is required');
await ensureDirectWalletLoaded();
if (!Array.isArray(directProofStore[normalized])) {
directProofStore[normalized] = [];
}
directMintList = Array.from(new Set([...(directMintList || []), normalized]));
await getDirectWalletForMint(normalized);
await publishDirectProofs();
port.postMessage({ type: 'response', requestId, data: { mints: getDirectWalletMints() } });
} catch (error) {
port.postMessage({ type: 'response', requestId, error: error.message });
}
}
async function handleWalletRemoveMint(requestId, mintUrl, port) {
try {
const normalized = normalizeMintUrl(mintUrl);
if (!normalized) throw new Error('Mint URL is required');
await ensureDirectWalletLoaded();
const existingMints = getDirectWalletMints();
const remaining = existingMints.filter((m) => m !== normalized);
if (remaining.length === 0) throw new Error('Wallet must keep at least one mint');
delete directProofStore[normalized];
directMintList = remaining;
directWalletCache.delete(normalized);
await publishDirectProofs();
port.postMessage({ type: 'response', requestId, data: { mints: getDirectWalletMints() } });
} catch (error) {
port.postMessage({ type: 'response', requestId, error: error.message });
}
}
async function handleWalletGetTransactions(requestId, port) {
try {
const txCountBefore = Array.isArray(directTransactions) ? directTransactions.length : 0;
console.log('[Worker] handleWalletGetTransactions start', {
requestId,
hasStartupDownloadsPromise: Boolean(startupDownloadsPromise),
hasCurrentPubkey: Boolean(currentPubkey),
hasNdk: Boolean(ndk),
hasMessageSigner: Boolean(messageSigner),
txCountBefore
});
if (startupDownloadsPromise) {
await Promise.race([
startupDownloadsPromise.catch(() => null),
new Promise((resolve) => setTimeout(resolve, 2500))
]);
console.log('[Worker] handleWalletGetTransactions startup wait complete', { requestId });
}
await ensureDirectWalletLoaded();
await refreshSpendingHistoryFromRelays(currentPubkey, { force: false });
const transactions = [...directTransactions]
.filter((tx) => tx && Number.isFinite(Number(tx?.timestamp || 0)) && Number(tx.timestamp) > 0)
.sort((a, b) => Number(b?.timestamp || 0) - Number(a?.timestamp || 0))
.slice(0, 250);
console.log('[Worker] handleWalletGetTransactions done', {
requestId,
txCountAfterRefresh: Array.isArray(directTransactions) ? directTransactions.length : 0,
txReturned: transactions.length,
newestId: transactions.length ? transactions[0]?.id || null : null
});
port.postMessage({ type: 'response', requestId, data: { transactions } });
} catch (error) {
console.warn('[Worker] handleWalletGetTransactions failed', {
requestId,
message: error?.message || String(error)
});
port.postMessage({ type: 'response', requestId, error: error.message });
}
}
async function handleWalletCheckProofs(requestId, port) {
try {
await ensureDirectWalletLoaded();
let totalChecked = 0;
let totalRemoved = 0;
let mintsChecked = 0;
const mintErrors = [];
const mintJobs = getDirectWalletMints()
.map((mintUrl) => {
const proofs = Array.isArray(directProofStore[mintUrl]) ? directProofStore[mintUrl] : [];
return {
mintUrl,
proofs
};
})
.filter((entry) => entry.proofs.length > 0)
.map(async (entry) => {
const { mintUrl, proofs } = entry;
const wallet = await getDirectWalletForMint(mintUrl);
const states = await withTimeout(
() => wallet.checkProofsStates(proofs),
CASHU_CHECK_PROOFS_TIMEOUT_MS,
`Mint proof-check timeout (${mintUrl})`
);
const stateList = Array.isArray(states)
? states
: Array.isArray(states?.states)
? states.states
: [];
const validProofs = proofs.filter((_proof, index) => {
const rawState = stateList[index];
const state = String(rawState?.state || rawState || '').toUpperCase();
return state === 'UNSPENT' || state === 'AVAILABLE' || state === 'VALID';
});
return {
mintUrl,
checkedCount: proofs.length,
removedCount: Math.max(0, proofs.length - validProofs.length),
validProofs
};
});
const settled = await Promise.allSettled(mintJobs);
for (const item of settled) {
if (item.status === 'fulfilled') {
const value = item.value || {};
totalChecked += Number(value.checkedCount || 0);
totalRemoved += Number(value.removedCount || 0);
mintsChecked += 1;
upsertMintProofs(value.mintUrl, Array.isArray(value.validProofs) ? value.validProofs : []);
continue;
}
const reason = item.reason;
mintErrors.push(reason?.message || String(reason));
}
if (totalRemoved > 0) {
void publishDirectProofs().catch((publishError) => {
console.warn('[Worker] walletCheckProofs background publishDirectProofs failed:', publishError?.message || publishError);
});
} else {
syncLightwalletFromDirectProofStore();
}
const payload = getWalletBalancePayload();
const result = {
proofsChecked: totalChecked,
proofsRemoved: totalRemoved,
mintsChecked,
failedMints: mintErrors.length,
balanceAfter: Number(payload?.balance || 0),
balanceSource: payload?.balanceSource || 'direct-proofs',
errors: mintErrors.slice(0, 10)
};
port.postMessage({
type: 'response',
requestId,
data: {
...payload,
proofCheck: result
}
});
broadcast({ type: 'walletBalanceUpdated', data: payload });
broadcastWalletStatus(payload);
broadcastStartupStatus(`✓ Proof check complete (${result.proofsChecked} checked, ${result.proofsRemoved} removed, ${result.balanceAfter.toLocaleString()} sats)`);
} catch (error) {
port.postMessage({ type: 'response', requestId, error: error.message });
}
}
/**
* Lightweight republish of ONLY the kind 17375 wallet event.
*
* Kind 17375 is a replaceable event (NIP-60, d-tag = ""), so publishing a
* new one automatically supersedes the old one on relays — no NIP-09
* deletions needed. This skips the heavy publishDirectProofs() which
* rewrites all 7375 token events too (100+ signing round-trips).
*
* Just: ensure privkey → build tag-array payload → encrypt → sign → publish.
*/
async function handleWalletRepublish(requestId, port) {
try {
if (!ndk || !currentPubkey) {
port.postMessage({ type: 'response', requestId, error: 'NDK not ready' });
return;
}
await ensureDirectWalletLoaded();
// Ensure a nutzap privkey exists (auto-generate if missing)
try {
ensureDirectNutzapP2pk();
} catch (err) {
console.warn('[Worker] handleWalletRepublish: ensureDirectNutzapP2pk failed:', err?.message || err);
}
const NDKEvent = getNDKEventCtor();
if (!NDKEvent) {
port.postMessage({ type: 'response', requestId, error: 'NDKEvent constructor not available' });
return;
}
// Build NIP-60 tag-array payload: [["mint",url],["privkey",hex]]
const mintTags = getDirectWalletMints().map((mintUrl) => ['mint', mintUrl]);
const walletPayloadObj = [
...getDirectWalletMints().map((url) => ['mint', url]),
...(directNutzapPrivateKeyHex ? [['privkey', directNutzapPrivateKeyHex]] : [])
];
const walletPayload = JSON.stringify(walletPayloadObj);
// NIP-44 encrypt the payload
const encryptedWalletResp = await messageSigner.requestFromPage('nip44Encrypt', {
recipientPubkey: currentPubkey,
plaintext: walletPayload
});
const encryptedWallet = typeof encryptedWalletResp === 'string' ? encryptedWalletResp : encryptedWalletResp?.ciphertext;
if (!encryptedWallet) {
port.postMessage({ type: 'response', requestId, error: 'Failed to encrypt wallet payload' });
return;
}
// Build and publish the new 17375 event (replaceable — supersedes old one)
const walletEvent = new NDKEvent(ndk, {
kind: 17375,
tags: mintTags,
content: encryptedWallet,
created_at: Math.floor(Date.now() / 1000)
});
await signEventWithMessageSigner(walletEvent);
const relaySet = await walletEvent.publish();
if (relaySet && relaySet.size > 0) {
for (const relay of relaySet) {
trackRelayWrite(relay.url);
}
}
console.log('[Worker] handleWalletRepublish: 17375 published', {
relayCount: relaySet ? relaySet.size : 0,
mintCount: mintTags.length,
hasPrivkey: Boolean(directNutzapPrivateKeyHex)
});
const payload = getWalletBalancePayload();
port.postMessage({
type: 'response',
requestId,
data: {
success: true,
mints: getDirectWalletMints(),
relayCount: relaySet ? relaySet.size : 0,
...payload
}
});
} catch (error) {
console.warn('[Worker] handleWalletRepublish failed:', error?.message || error);
port.postMessage({ type: 'response', requestId, error: error.message || 'Republish failed' });
}
}
function handleWalletShutdown(requestId, port) {
try {
clearWalletListeners();
directProofStore = {};
directMintList = [];
directTransactions = [];
directWalletLoaded = false;
directWalletStatus = 'initial';
lightwalletHasWallet = false;
directNutzapPrivateKeyHex = null;
directNutzapP2pk = null;
directMintLocalUpdatedAt.clear();
spendingHistoryLastFetchAtMs = 0;
legacyWalletMigrationAttempted = false;
spendingHistoryFetchPromise = null;
broadcastWalletStatus();
port.postMessage({ type: 'response', requestId, data: { success: true } });
} catch (error) {
port.postMessage({ type: 'response', requestId, error: error.message });
}
}
async function fetchRelayUserSettings(pubkey) {
if (!ndk || !messageSigner) return null;
try {
const filter = {
kinds: [30078],
authors: [pubkey],
'#d': [USER_SETTINGS_D_TAG],
limit: 1
};
const events = await ndk.fetchEvents(filter);
if (!events || events.size === 0) return null;
const latest = Array.from(events).sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
if (!latest?.content) return null;
const decrypted = await messageSigner.requestFromPage('nip44Decrypt', {
senderPubkey: pubkey,
ciphertext: latest.content
});
const plaintext = typeof decrypted === 'string' ? decrypted : decrypted?.plaintext;
if (!plaintext) return null;
return normalizeUserSettings(JSON.parse(plaintext));
} catch (err) {
console.warn('[Worker] Failed fetching/decrypting relay user settings:', err.message);
return null;
}
}
async function hydrateUserSettingsForPubkey(pubkey) {
userSettingsPubkey = pubkey;
const cached = await readCachedUserSettings(pubkey);
userSettings = normalizeUserSettings(cached || getDefaultUserSettings());
userSettingsHydrated = true;
broadcastUserSettings();
const relaySettings = await fetchRelayUserSettings(pubkey);
if (!relaySettings) {
await writeCachedUserSettings(pubkey, userSettings);
return userSettings;
}
const relayUpdatedAt = Number(relaySettings.updatedAt || 0);
const localUpdatedAt = Number(userSettings.updatedAt || 0);
if (relayUpdatedAt >= localUpdatedAt) {
userSettings = normalizeUserSettings(relaySettings);
}
await writeCachedUserSettings(pubkey, userSettings);
broadcastUserSettings();
return userSettings;
}
async function publishUserSettingsNow() {
if (!ndk || !messageSigner || !currentPubkey || !userSettings) return;
const now = Math.floor(Date.now() / 1000);
const normalized = normalizeUserSettings({ ...userSettings, updatedAt: now });
const plaintext = JSON.stringify(normalized);
const encryptedResp = await messageSigner.requestFromPage('nip44Encrypt', {
recipientPubkey: currentPubkey,
plaintext
});
const encrypted = typeof encryptedResp === 'string' ? encryptedResp : encryptedResp?.ciphertext;
if (!encrypted) throw new Error('Failed to encrypt user settings');
const NDKEvent = NDKExports?.NDKEvent || NDKExports?.default?.NDKEvent;
if (!NDKEvent) throw new Error('NDKEvent not found in bundle');
const ndkEvent = new NDKEvent(ndk, {
kind: 30078,
tags: [['d', USER_SETTINGS_D_TAG]],
content: encrypted,
created_at: now
});
await signEventWithMessageSigner(ndkEvent);
const relaySet = await ndkEvent.publish();
if (relaySet && relaySet.size > 0) {
for (const relay of relaySet) {
trackRelayWrite(relay.url);
}
}
userSettings = normalized;
await writeCachedUserSettings(currentPubkey, userSettings);
broadcastUserSettings();
}
function scheduleUserSettingsPublish(delayMs = 350) {
if (settingsPublishTimeout) clearTimeout(settingsPublishTimeout);
settingsPublishTimeout = setTimeout(async () => {
settingsPublishTimeout = null;
try {
await publishUserSettingsNow();
console.log('[Worker] Published user settings');
} catch (err) {
console.warn('[Worker] Failed publishing user settings:', err.message);
}
}, delayMs);
}
async function ensureUserSettingsHydrated() {
if (!currentPubkey) {
throw new Error('No authenticated pubkey for user settings');
}
if (!userSettingsHydrated || userSettingsPubkey !== currentPubkey || !userSettings) {
await hydrateUserSettingsForPubkey(currentPubkey);
}
}
async function handleGetUserSettings(requestId, port) {
try {
await ensureUserSettingsHydrated();
port.postMessage({
type: 'userSettingsResult',
requestId,
settings: userSettings
});
} catch (err) {
port.postMessage({
type: 'userSettingsResult',
requestId,
settings: normalizeUserSettings(getDefaultUserSettings()),
error: err.message
});
}
}
async function handlePatchUserSettings(requestId, patch, options, port) {
try {
await ensureUserSettingsHydrated();
const now = Math.floor(Date.now() / 1000);
userSettings = normalizeUserSettings(deepMerge(userSettings, patch || {}));
userSettings.updatedAt = now;
await writeCachedUserSettings(currentPubkey, userSettings);
broadcastUserSettings();
if (options?.publish !== false) {
scheduleUserSettingsPublish(250);
}
port.postMessage({
type: 'userSettingsResult',
requestId,
settings: userSettings
});
} catch (err) {
port.postMessage({
type: 'userSettingsResult',
requestId,
settings: userSettings,
error: err.message
});
}
}
async function handleSetUserSettings(requestId, settings, options, port) {
try {
await ensureUserSettingsHydrated();
const now = Math.floor(Date.now() / 1000);
userSettings = normalizeUserSettings(settings || getDefaultUserSettings());
userSettings.updatedAt = now;
await writeCachedUserSettings(currentPubkey, userSettings);
broadcastUserSettings();
if (options?.publish !== false) {
scheduleUserSettingsPublish(100);
}
port.postMessage({
type: 'userSettingsResult',
requestId,
settings: userSettings
});
} catch (err) {
port.postMessage({
type: 'userSettingsResult',
requestId,
settings: userSettings,
error: err.message
});
}
}
async function handleResetUserSettings(requestId, options, port) {
try {
await ensureUserSettingsHydrated();
const now = Math.floor(Date.now() / 1000);
userSettings = normalizeUserSettings(getDefaultUserSettings());
userSettings.updatedAt = now;
await writeCachedUserSettings(currentPubkey, userSettings);
broadcastUserSettings();
if (options?.publish !== false) {
scheduleUserSettingsPublish(100);
}
port.postMessage({
type: 'userSettingsResult',
requestId,
settings: userSettings
});
} catch (err) {
port.postMessage({
type: 'userSettingsResult',
requestId,
settings: userSettings,
error: err.message
});
}
}
async function handleEncryptContent(requestId, recipientPubkey, plaintext, scheme, port) {
try {
if (!messageSigner) throw new Error('Signer not initialized');
if (!recipientPubkey) throw new Error('Missing recipient pubkey');
const mode = String(scheme || 'nip44').toLowerCase();
let ciphertext = '';
if (mode === 'nip44') {
const resp = await messageSigner.requestFromPage('nip44Encrypt', {
recipientPubkey,
plaintext: String(plaintext || '')
});
ciphertext = typeof resp === 'string' ? resp : resp?.ciphertext;
} else if (mode === 'nip04') {
const resp = await messageSigner.requestFromPage('encrypt', {
recipientPubkey,
plaintext: String(plaintext || '')
});
ciphertext = typeof resp === 'string' ? resp : resp?.ciphertext;
} else {
throw new Error(`Unsupported encryption scheme: ${scheme}`);
}
if (!ciphertext) throw new Error('Encryption produced empty ciphertext');
port.postMessage({ type: 'response', requestId, data: { ciphertext, scheme: mode } });
} catch (error) {
port.postMessage({ type: 'response', requestId, error: error?.message || String(error) });
}
}
async function handleDecryptContent(requestId, senderPubkey, ciphertext, scheme, port) {
try {
if (!messageSigner) throw new Error('Signer not initialized');
if (!senderPubkey) throw new Error('Missing sender pubkey');
const mode = String(scheme || 'auto').toLowerCase();
const text = String(ciphertext || '');
if (!text) throw new Error('Missing ciphertext');
const tryDecrypt = async (method) => {
const resp = await messageSigner.requestFromPage(method, {
senderPubkey,
ciphertext: text
});
return typeof resp === 'string' ? resp : resp?.plaintext;
};
let plaintext = '';
let usedScheme = mode;
if (mode === 'nip44') {
plaintext = await tryDecrypt('nip44Decrypt');
usedScheme = 'nip44';
} else if (mode === 'nip04') {
plaintext = await tryDecrypt('decrypt');
usedScheme = 'nip04';
} else {
try {
plaintext = await tryDecrypt('nip44Decrypt');
usedScheme = 'nip44';
} catch (_nip44Err) {
plaintext = await tryDecrypt('decrypt');
usedScheme = 'nip04';
}
}
if (typeof plaintext !== 'string') throw new Error('Decryption produced invalid plaintext');
port.postMessage({ type: 'response', requestId, data: { plaintext, scheme: usedScheme } });
} catch (error) {
port.postMessage({ type: 'response', requestId, error: error?.message || String(error) });
}
}
function getStartupDownloadsTimeoutPromise() {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error(`startup downloads timeout (${STARTUP_DOWNLOADS_TIMEOUT_MS}ms)`)), STARTUP_DOWNLOADS_TIMEOUT_MS);
});
}
function scheduleStartupDownloads(pubkey, contextLabel = 'startup') {
if (!pubkey) return;
if (startupDownloadsCompletedForPubkey === pubkey) {
console.log('[Worker] Startup downloads already complete for pubkey, skipping rerun:', pubkey);
ensureStartupWalletSubscriptions(pubkey);
broadcastStartupStatus(`✓ Startup complete (${contextLabel}, cached)`);
return;
}
if (startupDownloadsPromise && startupDownloadsPubkey === pubkey) {
console.log('[Worker] Startup downloads already in progress for pubkey, reusing in-flight task:', pubkey);
startupDownloadsPromise
.then(() => {
ensureStartupWalletSubscriptions(pubkey);
broadcastStartupStatus(`✓ Startup complete (${contextLabel}, shared)`);
})
.catch((error) => {
console.warn('[Worker] Shared startup wallet preload failed:', error);
broadcastStartupStatus(`Startup wallet preload failed (${contextLabel}, shared)`, {
phase: 'startupDownloads',
error: error?.message || String(error)
});
});
return;
}
startupDownloadsPubkey = pubkey;
const runPromise = Promise.resolve()
.then(async () => {
await Promise.race([
fetchStartupEventDownloads(pubkey),
getStartupDownloadsTimeoutPromise()
]);
startupDownloadsCompletedForPubkey = pubkey;
})
.finally(() => {
if (startupDownloadsPromise === runPromise) {
startupDownloadsPromise = null;
}
if (startupDownloadsPubkey === pubkey) {
startupDownloadsPubkey = null;
}
});
startupDownloadsPromise = runPromise;
runPromise
.then(() => {
ensureStartupWalletSubscriptions(pubkey);
broadcastStartupStatus(`✓ Startup complete (${contextLabel})`);
})
.catch((error) => {
console.warn(`[Worker] ${contextLabel} wallet preload failed:`, error);
broadcastStartupStatus(`Startup wallet preload failed (${contextLabel})`, {
phase: 'startupDownloads',
error: error?.message || String(error)
});
});
}
// Handle initialization for a user
async function handleInit(pubkey, port) {
console.log('[Worker] Handling init for pubkey:', pubkey);
currentPubkey = pubkey;
const initTask = (async () => {
try {
broadcastStartupStatus('Initializing NDK worker...');
// Check if this is the first initialization
const isFirstInit = !ndk;
// Initialize NDK if not already done
if (isFirstInit) {
broadcastStartupStatus('Connecting to relays...');
await initNDK();
} else {
broadcastStartupStatus('Reusing existing worker session...');
console.log('[Worker] NDK already initialized from another tab, reusing existing instance');
}
// Notify ready
port.postMessage({ type: 'ready' });
// Hydrate app user settings (cache first, then relay sync)
broadcastStartupStatus('Fetching user settings (kind 30078)...');
try {
await Promise.race([
hydrateUserSettingsForPubkey(pubkey),
new Promise((_, reject) => setTimeout(() => reject(new Error('user settings hydrate timeout (5s)')), 5000))
]);
port.postMessage({ type: 'userSettingsUpdated', data: userSettings });
} catch (settingsErr) {
console.warn('[Worker] User settings hydration timed out/failed, continuing init:', settingsErr?.message || settingsErr);
}
// Fetch user profile (always fetch to ensure we have latest)
broadcastStartupStatus('Fetching user profile info (kind 0)...');
try {
const profile = await Promise.race([
fetchUserProfile(pubkey),
new Promise((_, reject) => setTimeout(() => reject(new Error('profile fetch timeout (5s)')), 5000))
]);
if (profile) {
cachedProfile = profile; // Cache the profile for new tabs
broadcast({ type: 'profile', data: profile });
}
} catch (profileErr) {
console.warn('[Worker] Profile fetch timed out/failed, continuing init:', profileErr?.message || profileErr);
}
// Only fetch and update relays on first initialization
// Subsequent tabs reuse the existing relay connections
if (isFirstInit) {
broadcastStartupStatus('Fetching user relay list (kind 10002)...');
console.log('[Worker] First initialization - fetching and configuring relays');
const userRelays = await fetchUserRelays(pubkey);
if (userRelays && userRelays.length > 0) {
// Update to user's relays
const relayStatus = await updateRelays(userRelays);
broadcast({ type: 'relays', data: relayStatus });
} else {
// Use bootstrap relays
const relayStatus = Array.from(ndk.pool.relays.values()).map(relay => {
console.log(`[Worker] Bootstrap relay ${relay.url} status: ${relay.status}`);
return {
url: relay.url,
connected: relay.status >= 5 // CONNECTED or higher
};
});
console.log('[Worker] Bootstrap relay status:', relayStatus);
broadcast({ type: 'relays', data: relayStatus });
}
// Subscribe to kind 10002 events for real-time relay list updates
broadcastStartupStatus('Subscribing to relay updates (kind 10002)...');
console.log('[Worker] Subscribing to kind 10002 relay list updates for:', pubkey);
const relayListFilter = {
kinds: [10002],
authors: [pubkey]
};
const relayListSub = ndk.subscribe(relayListFilter, { closeOnEose: false });
relayListSub.on('event', (event) => {
console.log('[Worker] Received kind 10002 relay list update');
const incomingCreatedAt = event?.created_at || 0;
if (incomingCreatedAt < latestRelayListCreatedAt) {
console.log('[Worker] Ignoring stale kind 10002 event:', {
incomingCreatedAt,
latestRelayListCreatedAt
});
return;
}
// Update relayTypes Map from newest event only
const nextRelayTypes = new Map();
for (const tag of event.tags) {
if (tag[0] === 'r' && tag[1]) {
const url = tag[1];
const type = tag[2] || 'both';
nextRelayTypes.set(url, type);
}
}
relayTypes = nextRelayTypes;
latestRelayListCreatedAt = incomingCreatedAt;
console.log('[Worker] Updated relay types from subscription:', Array.from(relayTypes.entries()));
// Broadcast the update to all connected tabs
broadcast({
type: 'relay-types-updated',
relayTypes: Array.from(relayTypes.entries()).map(([url, type]) => ({ url, type }))
});
});
// Phase 1 startup downloads for wallet-related events + follows.
// Run in background so init does not block UI startup paths (e.g. walletInit).
scheduleStartupDownloads(pubkey, 'first-init');
} else {
// Send current relay status to new port without updating
console.log('[Worker] Subsequent initialization - sending existing relay status');
const relayStatus = Array.from(ndk.pool.relays.values()).map(relay => ({
url: relay.url,
connected: relay.status >= 5
}));
port.postMessage({ type: 'relays', data: relayStatus });
// Ensure wallet startup preload/subscription also runs for reused worker sessions.
scheduleStartupDownloads(pubkey, 'reused-session');
}
await loadWorkerMuteList(pubkey, { reason: 'init' });
} catch (error) {
console.error('[Worker] Error during init:', error);
port.postMessage({
type: 'error',
data: { message: error.message }
});
throw error;
}
})();
initCompletedPromise = initTask;
initTask
.catch((error) => {
console.warn('[Worker] init task failed:', error?.message || error);
})
.finally(() => {
if (initCompletedPromise === initTask) {
initCompletedPromise = null;
}
});
}
// Handle subscription request
async function handleSubscribe(subId, filters, opts, port) {
console.log('[Worker] Creating subscription:', subId, filters);
if (!ndk) {
console.error('[Worker] NDK not initialized');
port.postMessage({
type: 'error',
data: { message: 'NDK not initialized' }
});
return;
}
try {
const normalizedFilters = normalizeSubscriptionFilters(filters);
const subscriptionOpts = { ...(opts || {}) };
// If cache is unavailable, force relay-backed behavior instead of CACHE_FIRST.
const hasCacheAdapter = Boolean(ndk?.cacheAdapter);
const cacheUsage = subscriptionOpts?.cacheUsage;
if (!hasCacheAdapter && typeof cacheUsage === 'string' && cacheUsage.toUpperCase() === 'CACHE_FIRST') {
delete subscriptionOpts.cacheUsage;
console.warn('[Worker] cacheUsage=CACHE_FIRST requested without cache adapter; falling back to relay subscription', {
subId,
kinds: normalizedFilters.flatMap((f) => Array.isArray(f?.kinds) ? f.kinds : [])
});
}
// Replace existing subscription with the same id if present
const existing = activeSubscriptions.get(subId);
if (existing?.sub) {
try {
existing.sub.stop();
} catch (_stopErr) {
// ignore stop errors on replacement
}
}
const state = {
sub: ndk.subscribe(normalizedFilters, subscriptionOpts),
filters: normalizedFilters,
opts: subscriptionOpts,
seenEventIds: existing?.seenEventIds || new Set(),
lastEventCreatedAt: Number(existing?.lastEventCreatedAt || 0)
};
activeSubscriptions.set(subId, state);
wireSubscriptionHandlers(subId, state, normalizedFilters);
console.log('[Worker] Subscription created:', subId, {
filterCount: normalizedFilters.length,
dedupCacheSize: state.seenEventIds.size
});
} catch (error) {
console.error('[Worker] Error creating subscription:', error);
port.postMessage({
type: 'error',
data: { message: error.message }
});
}
}
// Handle query cache request - query Dexie DB directly without going to relays
async function handleQueryCache(requestId, filters, port) {
console.log('[Worker] Querying cache for:', filters);
if (!ndk || !ndk.cacheAdapter) {
console.log('[Worker] No cache adapter available');
port.postMessage({
type: 'queryCacheResult',
requestId: requestId,
events: [],
error: 'Cache not available'
});
return;
}
try {
// Get the underlying Dexie adapter from NDKCacheBrowser
const dexieAdapter = ndk.cacheAdapter.adapter;
if (!dexieAdapter || !dexieAdapter.events) {
console.log('[Worker] Dexie adapter or events cache not available');
port.postMessage({
type: 'queryCacheResult',
requestId: requestId,
events: []
});
return;
}
// Normalize filters to array
const filterArray = Array.isArray(filters) ? filters : [filters];
const results = [];
// Get direct Dexie DB access for persistent storage queries
const db = dexieAdapter.db;
for (const filter of filterArray) {
let events = [];
// For kind 0 (profiles), query the profiles table directly via IndexedDB — it has full profile data
if (filter.kinds && filter.kinds.length === 1 && filter.kinds[0] === 0 && filter.authors) {
for (const pubkey of filter.authors) {
// Try Dexie adapter first, fall back to raw IndexedDB (same as handleStoreProfile)
let profileRow = null;
if (db && db.profiles) {
profileRow = await db.profiles.get(pubkey);
}
if (!profileRow) {
profileRow = await new Promise((resolve) => {
const req = indexedDB.open('ndk-shared');
req.onsuccess = () => {
const idb = req.result;
if (!idb.objectStoreNames.contains('profiles')) { idb.close(); resolve(null); return; }
const tx = idb.transaction('profiles', 'readonly');
const getReq = tx.objectStore('profiles').get(pubkey);
getReq.onsuccess = () => { idb.close(); resolve(getReq.result || null); };
getReq.onerror = () => { idb.close(); resolve(null); };
};
req.onerror = () => resolve(null);
});
}
if (profileRow) {
// Reconstruct a kind 0 event from the profiles table
// profileRow.profileEvent is the full serialized Nostr event JSON string
// We need to extract the profile content (the event's .content field)
let profileContentStr;
if (profileRow.profileEvent) {
try {
const parsedEvent = typeof profileRow.profileEvent === 'string'
? JSON.parse(profileRow.profileEvent)
: profileRow.profileEvent;
// If it's a full Nostr event, extract its content field
if (parsedEvent && parsedEvent.content !== undefined) {
profileContentStr = typeof parsedEvent.content === 'string'
? parsedEvent.content
: JSON.stringify(parsedEvent.content);
} else {
// It's already the profile object
profileContentStr = JSON.stringify(parsedEvent);
}
} catch (e) {
profileContentStr = profileRow.profileEvent;
}
} else {
// Fall back to individual fields
profileContentStr = JSON.stringify({
name: profileRow.name,
display_name: profileRow.displayName,
picture: profileRow.picture,
about: profileRow.about,
website: profileRow.website,
lud16: profileRow.lud16,
nip05: profileRow.nip05
});
}
results.push({
id: profileRow.pubkey,
pubkey: profileRow.pubkey,
kind: 0,
created_at: profileRow.created_at || profileRow.cachedAt || 0,
tags: [],
content: profileContentStr,
sig: ''
});
}
}
continue; // Skip the events table query for kind 0
}
// Query events table — try in-memory cache first, fall back to Dexie DB
if (filter.ids && filter.ids.length > 0) {
for (const id of filter.ids) {
const eventId = String(id || '').trim();
if (!eventId) continue;
// Try in-memory cache first
const memEvent = dexieAdapter.events.get(eventId);
if (memEvent) {
events.push(memEvent);
continue;
}
// Fall back to Dexie DB query
if (db && db.events) {
const dbEvent = await db.events.get(eventId);
if (dbEvent) {
events.push(dbEvent);
}
}
}
} else if (filter.authors && filter.authors.length > 0) {
for (const pubkey of filter.authors) {
// Try in-memory cache first
const memEvents = Array.from(dexieAdapter.events.getFromIndex('pubkey', pubkey) || []);
if (memEvents.length > 0) {
events.push(...memEvents);
} else if (db && db.events) {
// Fall back to Dexie DB query
const dbEvents = await db.events.where('pubkey').equals(pubkey).toArray();
events.push(...dbEvents);
}
}
} else if (filter.kinds && filter.kinds.length > 0) {
for (const kind of filter.kinds) {
const memEvents = Array.from(dexieAdapter.events.getFromIndex('kind', kind) || []);
if (memEvents.length > 0) {
events.push(...memEvents);
} else if (db && db.events) {
const dbEvents = await db.events.where('kind').equals(kind).toArray();
events.push(...dbEvents);
}
}
}
// Filter by kinds if specified
if (filter.kinds && events.length > 0) {
events = events.filter(e => filter.kinds.includes(e.kind));
}
// Filter by since if specified (Nostr timestamp, seconds)
if (typeof filter.since === 'number' && Number.isFinite(filter.since) && events.length > 0) {
events = events.filter(e => (e.createdAt || 0) >= filter.since);
}
// Apply limit
if (filter.limit && events.length > filter.limit) {
// Sort by createdAt (descending) and take most recent
events.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
events = events.slice(0, filter.limit);
}
// Convert to raw nostr events
// NDK stores events as a compact array: [kind, pubkey, created_at, kind, tags, content, id]
// The wrapper object has: { id, pubkey, kind, createdAt, relay, event }
for (const eventData of events) {
if (eventData.event) {
try {
const parsed = JSON.parse(eventData.event);
let rawEvent;
if (Array.isArray(parsed)) {
// NDK compact array format: [kind, pubkey, created_at, kind, tags, content, id]
rawEvent = {
id: eventData.id,
pubkey: eventData.pubkey,
kind: eventData.kind,
created_at: eventData.createdAt,
tags: parsed[4] || [],
content: parsed[5] || '',
sig: parsed[6] || ''
};
} else {
// Standard JSON object format — supplement missing fields from wrapper
rawEvent = parsed;
if (!rawEvent.id && eventData.id) rawEvent.id = eventData.id;
if (!rawEvent.pubkey && eventData.pubkey) rawEvent.pubkey = eventData.pubkey;
if (rawEvent.kind === undefined && eventData.kind !== undefined) rawEvent.kind = eventData.kind;
if (!rawEvent.created_at && eventData.createdAt) rawEvent.created_at = eventData.createdAt;
}
results.push(rawEvent);
} catch (e) {
console.error('[Worker] Failed to parse cached event:', e);
}
}
}
}
port.postMessage({
type: 'queryCacheResult',
requestId: requestId,
events: results
});
} catch (error) {
console.error('[Worker] Cache query failed:', error);
port.postMessage({
type: 'queryCacheResult',
requestId: requestId,
events: [],
error: error.message
});
}
}
function extractCachedEventTags(row) {
if (!row) return [];
try {
const parsed = typeof row.event === 'string' ? JSON.parse(row.event) : row.event;
if (Array.isArray(parsed)) return Array.isArray(parsed[4]) ? parsed[4] : [];
return Array.isArray(parsed?.tags) ? parsed.tags : [];
} catch {
return [];
}
}
function findTagValue(tags, key) {
const list = Array.isArray(tags) ? tags : [];
for (const tag of list) {
if (Array.isArray(tag) && tag[0] === key && typeof tag[1] === 'string') {
return tag[1];
}
}
return '';
}
async function handlePurgePlaylistCache(requestId, playlistIdentifier, ownerPubkey, playlistKind, eventIds, port) {
const identifier = String(playlistIdentifier || '').trim();
const owner = String(ownerPubkey || '').trim();
const kind = Number(playlistKind || 0);
const explicitEventIds = new Set(
Array.isArray(eventIds)
? eventIds.map((id) => String(id || '').trim()).filter(Boolean)
: []
);
console.log('[Worker][purgePlaylistCache] start', {
requestId,
identifier,
owner,
kind,
explicitEventIds: Array.from(explicitEventIds),
});
if (!identifier && !explicitEventIds.size) {
console.log('[Worker][purgePlaylistCache] nothing to purge (missing identifier and eventIds)', {
requestId,
});
port.postMessage({
type: 'response',
requestId,
data: { success: true, removedIds: [] }
});
return;
}
try {
const cacheAdapter = ndk?.cacheAdapter || null;
const adapter = cacheAdapter?.adapter || null;
const db = adapter?.db || null;
const toDelete = new Set(explicitEventIds);
console.log('[Worker][purgePlaylistCache] cache adapter/db availability', {
requestId,
hasCacheAdapter: Boolean(cacheAdapter),
hasNativeDeleteEventIds: typeof cacheAdapter?.deleteEventIds === 'function',
hasAdapter: Boolean(adapter),
hasDb: Boolean(db),
hasDbEvents: Boolean(db?.events),
});
if (db?.events && identifier && owner && kind > 0) {
const byAuthor = await db.events.where('pubkey').equals(owner).toArray();
console.log('[Worker][purgePlaylistCache] fetched author rows', {
requestId,
owner,
rowCount: Array.isArray(byAuthor) ? byAuthor.length : 0,
});
for (const row of byAuthor || []) {
if (Number(row?.kind) !== kind) continue;
const tags = extractCachedEventTags(row);
const dTag = findTagValue(tags, 'd');
if (dTag === identifier && row?.id) {
toDelete.add(String(row.id));
}
}
} else {
console.log('[Worker][purgePlaylistCache] skipping DB lookup branch', {
requestId,
hasDbEvents: Boolean(db?.events),
identifier,
owner,
kind,
});
}
const removedIds = Array.from(toDelete);
console.log('[Worker][purgePlaylistCache] resolved IDs to remove', {
requestId,
removedIds,
});
if (removedIds.length > 0) {
if (typeof cacheAdapter?.deleteEventIds === 'function') {
console.log('[Worker][purgePlaylistCache] native deleteEventIds start', {
requestId,
removeCount: removedIds.length,
});
await cacheAdapter.deleteEventIds(removedIds);
console.log('[Worker][purgePlaylistCache] native deleteEventIds complete', {
requestId,
removeCount: removedIds.length,
});
} else {
console.warn('[Worker][purgePlaylistCache] native deleteEventIds unavailable; no cache deletion performed', {
requestId,
removeCount: removedIds.length,
});
}
} else {
console.log('[Worker][purgePlaylistCache] native deleteEventIds skipped (no ids)', {
requestId,
});
}
port.postMessage({
type: 'response',
requestId,
data: { success: true, removedIds }
});
console.log('[Worker][purgePlaylistCache] done', {
requestId,
removedIds,
});
} catch (error) {
console.error('[Worker][purgePlaylistCache] failed', {
requestId,
identifier,
owner,
kind,
error: error?.message || String(error),
stack: error?.stack || null,
});
port.postMessage({
type: 'response',
requestId,
error: error?.message || String(error)
});
}
}
// Get all cached profiles from the profiles table for pre-warming
async function handleGetAllCachedProfiles(requestId, port) {
try {
const profiles = [];
// Try Dexie adapter first
const adapter = ndk?.cacheAdapter?.adapter;
if (adapter?.db?.profiles) {
const allProfiles = await adapter.db.profiles.toArray();
profiles.push(...allProfiles);
} else {
// Fall back to raw IndexedDB
const allProfiles = await new Promise((resolve) => {
const req = indexedDB.open('ndk-shared');
req.onsuccess = () => {
const db = req.result;
if (!db.objectStoreNames.contains('profiles')) { db.close(); resolve([]); return; }
const tx = db.transaction('profiles', 'readonly');
const getReq = tx.objectStore('profiles').getAll();
getReq.onsuccess = () => { db.close(); resolve(getReq.result || []); };
getReq.onerror = () => { db.close(); resolve([]); };
};
req.onerror = () => resolve([]);
});
profiles.push(...allProfiles);
}
port.postMessage({
type: 'getAllCachedProfilesResult',
requestId: requestId,
profiles: profiles
});
} catch (error) {
port.postMessage({
type: 'getAllCachedProfilesResult',
requestId: requestId,
profiles: [],
error: error.message
});
}
}
// Store a fetched profile in the Dexie profiles table for future cache hits
async function handleStoreProfile(pubkey, profile) {
if (!pubkey || !profile) return;
try {
const now = Math.floor(Date.now() / 1000);
const record = {
pubkey,
profileEvent: JSON.stringify(profile),
name: profile.name || '',
displayName: profile.display_name || profile.displayName || '',
about: profile.about || '',
banner: profile.banner || '',
website: profile.website || '',
picture: profile.picture || profile.image || '',
image: profile.image || profile.picture || '',
lud16: profile.lud16 || '',
nip05: profile.nip05 || '',
created_at: profile.created_at || now,
cachedAt: now
};
// Try Dexie adapter first
const adapter = ndk?.cacheAdapter?.adapter;
if (adapter?.db?.profiles) {
await adapter.db.profiles.put(record);
return;
}
// Fall back to raw IndexedDB
await new Promise((resolve, reject) => {
const req = indexedDB.open('ndk-shared');
req.onsuccess = () => {
const db = req.result;
if (!db.objectStoreNames.contains('profiles')) { db.close(); resolve(); return; }
const tx = db.transaction('profiles', 'readwrite');
tx.objectStore('profiles').put(record);
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => { db.close(); reject(tx.error); };
};
req.onerror = () => reject(req.error);
});
} catch (e) {
console.error('[Worker] Failed to store profile for', pubkey.substring(0, 8) + '…', e);
}
}
// Handle fetch cached profile request - fast lookup from in-memory cache
async function handleFetchCachedProfile(requestId, pubkey, port) {
if (!ndk || !ndk.cacheAdapter) {
port.postMessage({
type: 'fetchCachedProfileResult',
requestId: requestId,
profile: null
});
return;
}
try {
// The NDKCacheBrowser/Dexie adapter has a fetchProfile method that reads from cache
// Access the underlying adapter's fetchProfile method if available
let profile = null;
// Try the adapter's fetchProfile method directly
if (ndk.cacheAdapter.fetchProfile) {
profile = await ndk.cacheAdapter.fetchProfile(pubkey);
}
// If not found via fetchProfile, try accessing the profiles cache handler directly
if (!profile && ndk.cacheAdapter.adapter && ndk.cacheAdapter.adapter.profiles) {
const cacheHandler = ndk.cacheAdapter.adapter.profiles;
profile = cacheHandler.get(pubkey);
}
// If still not found, try the Dexie database directly
if (!profile && ndk.cacheAdapter.adapter) {
const adapter = ndk.cacheAdapter.adapter;
// The Dexie adapter may have direct access to db.profiles
if (adapter.db && adapter.db.profiles) {
const dbProfile = await adapter.db.profiles.get(pubkey);
if (dbProfile && dbProfile.profile) {
profile = JSON.parse(dbProfile.profile);
}
}
}
port.postMessage({
type: 'fetchCachedProfileResult',
requestId: requestId,
profile: profile
});
} catch (error) {
console.error('[Worker] Failed to fetch cached profile:', error);
port.postMessage({
type: 'fetchCachedProfileResult',
requestId: requestId,
profile: null,
error: error.message
});
}
}
// Handle unsubscribe request
function handleUnsubscribe(subId) {
console.log('[Worker] Unsubscribing:', subId);
const state = activeSubscriptions.get(subId);
if (state?.sub) {
state.sub.stop();
activeSubscriptions.delete(subId);
console.log('[Worker] Subscription stopped:', subId);
}
}
// Handle publish request
async function handlePublish(requestId, event, port) {
console.log('[Worker] Publishing event kind:', event.kind);
if (!ndk) {
console.error('[Worker] NDK not initialized');
port.postMessage({
type: 'response',
requestId: requestId,
error: 'NDK not initialized'
});
return;
}
try {
// Create NDK event
const NDKEvent = NDKExports?.NDKEvent || NDKExports?.default?.NDKEvent;
if (!NDKEvent) {
throw new Error('NDKEvent not found in bundle');
}
const ndkEvent = new NDKEvent(ndk, event);
// Sign using message-based signer (will request signing from page)
console.log('[Worker] Requesting signature from page...');
await signEventWithMessageSigner(ndkEvent);
console.log('[Worker] Event signed, publishing to relays...');
// Publish to relays
const relaySet = await ndkEvent.publish();
// Get detailed relay results
const relayResults = {
successful: [],
failed: []
};
// Check which relays are in the relay set (successful)
if (relaySet && relaySet.size > 0) {
for (const relay of relaySet) {
relayResults.successful.push(relay.url);
// Track relay write activity
trackRelayWrite(relay.url);
}
}
// Check which connected relays didn't accept it
for (const relay of ndk.pool.relays.values()) {
if (relay.status >= 5 && !relayResults.successful.includes(relay.url)) {
relayResults.failed.push(relay.url);
}
}
console.log('[Worker] ✅ Published to relays:', relayResults.successful);
console.log('[Worker] ❌ Failed relays:', relayResults.failed);
// If this is a kind 10002 (relay list) event, update stored relay types
// AND sync the live relay pool so newly-added relays are actually connectable.
if (event.kind === 10002) {
console.log('[Worker] Kind 10002 published, updating relay types + syncing relay pool...');
const nextRelayTypes = new Map();
const parsedUserRelays = [];
// Parse and store new relay types
for (const tag of event.tags) {
if (tag[0] === 'r' && tag[1]) {
const url = tag[1];
const type = tag[2] || 'both';
nextRelayTypes.set(url, type);
parsedUserRelays.push({ url, type });
}
}
relayTypes = nextRelayTypes;
latestRelayListCreatedAt = event.created_at || latestRelayListCreatedAt;
console.log('[Worker] Updated relay types:', Array.from(relayTypes.entries()));
// Keep NDK pool aligned with the newly published kind 10002 list.
// Without this, added relays can appear in UI but be missing from ndk.pool,
// causing reconnect attempts to fail with "Relay not found".
try {
await updateRelays(parsedUserRelays);
console.log('[Worker] Relay pool synced after kind 10002 publish');
} catch (syncError) {
console.warn('[Worker] Failed to sync relay pool after kind 10002 publish:', syncError.message);
}
}
port.postMessage({
type: 'response',
requestId: requestId,
data: {
success: true,
relayResults: relayResults,
totalRelays: relayResults.successful.length
}
});
} catch (error) {
console.error('[Worker] Error publishing event:', error);
port.postMessage({
type: 'response',
requestId: requestId,
error: error.message
});
}
}
// Handle publish request for pre-signed raw events (no worker-side signing)
async function handlePublishRaw(requestId, event, port) {
console.log('[Worker] Publishing raw event kind:', event.kind);
if (!ndk) {
console.error('[Worker] NDK not initialized');
port.postMessage({
type: 'response',
requestId: requestId,
error: 'NDK not initialized'
});
return;
}
try {
const NDKEvent = NDKExports?.NDKEvent || NDKExports?.default?.NDKEvent;
if (!NDKEvent) {
throw new Error('NDKEvent not found in bundle');
}
const ndkEvent = new NDKEvent(ndk, event);
// Do not sign here: raw event is expected to be fully finalized already.
const relaySet = await ndkEvent.publish();
const relayResults = {
successful: [],
failed: []
};
if (relaySet && relaySet.size > 0) {
for (const relay of relaySet) {
relayResults.successful.push(relay.url);
trackRelayWrite(relay.url);
}
}
for (const relay of ndk.pool.relays.values()) {
if (relay.status >= 5 && !relayResults.successful.includes(relay.url)) {
relayResults.failed.push(relay.url);
}
}
console.log('[Worker] ✅ Published raw event to relays:', relayResults.successful);
console.log('[Worker] ❌ Failed relays for raw event:', relayResults.failed);
port.postMessage({
type: 'response',
requestId: requestId,
data: {
success: true,
relayResults: relayResults,
totalRelays: relayResults.successful.length
}
});
} catch (error) {
console.error('[Worker] Error publishing raw event:', error);
port.postMessage({
type: 'response',
requestId: requestId,
error: error.message
});
}
}
async function handlePublishRawToRelay(requestId, event, relayUrl, port) {
console.log('[Worker] Publishing raw event to single relay:', relayUrl, 'kind:', event?.kind);
if (!ndk) {
console.error('[Worker] NDK not initialized');
port.postMessage({
type: 'response',
requestId: requestId,
error: 'NDK not initialized'
});
return;
}
try {
const NDKEvent = NDKExports?.NDKEvent || NDKExports?.default?.NDKEvent;
if (!NDKEvent) {
throw new Error('NDKEvent not found in bundle');
}
const resolvedRelayUrl = String(relayUrl || '').trim();
if (!resolvedRelayUrl) {
throw new Error('Missing relayUrl');
}
if (!NDKRelaySet?.fromRelayUrls) {
throw new Error('NDKRelaySet.fromRelayUrls unavailable');
}
const ndkEvent = new NDKEvent(ndk, event);
const targetRelaySet = NDKRelaySet.fromRelayUrls([resolvedRelayUrl], ndk);
const relaySet = await ndkEvent.publish(targetRelaySet);
const relayResults = {
successful: [],
failed: []
};
if (relaySet && relaySet.size > 0) {
for (const relay of relaySet) {
relayResults.successful.push(relay.url);
trackRelayWrite(relay.url);
}
}
if (!relayResults.successful.includes(resolvedRelayUrl)) {
relayResults.failed.push(resolvedRelayUrl);
}
console.log('[Worker] ✅ Published raw event to target relay:', relayResults.successful);
console.log('[Worker] ❌ Failed target relay publish:', relayResults.failed);
port.postMessage({
type: 'response',
requestId: requestId,
data: {
success: relayResults.successful.length > 0,
relayResults: relayResults,
totalRelays: relayResults.successful.length
}
});
} catch (error) {
console.error('[Worker] Error publishing raw event to target relay:', error);
port.postMessage({
type: 'response',
requestId: requestId,
error: error.message
});
}
}
async function handleSendNip17Message(requestId, recipientPubkey, content, port) {
console.log('[Worker] NIP-17 send start:', { requestId, recipientPubkey });
if (!ndk) {
port.postMessage({
type: 'response',
requestId,
error: 'NDK not initialized'
});
return;
}
const resolveDmRelayUrlsForPubkey = async (pubkey) => {
const normalizedPubkey = normalizeHexPubkey(pubkey);
if (!normalizedPubkey) return [];
const collect = (event, tagName) => {
const out = [];
const seen = new Set();
for (const tag of (event?.tags || [])) {
if (!Array.isArray(tag) || tag[0] !== tagName || !tag[1]) continue;
const normalized = normalizeRelayUrl(tag[1]);
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
out.push(normalized);
}
return out;
};
try {
const dmRelayEvent = await ndk.fetchEvent({
kinds: [10050],
authors: [normalizedPubkey]
});
const dmRelayUrls = collect(dmRelayEvent, 'relay');
if (dmRelayUrls.length > 0) {
return dmRelayUrls;
}
} catch (error) {
console.warn('[Worker] NIP-17 relay fetch kind10050 failed:', {
pubkey: normalizedPubkey,
error: error?.message || String(error)
});
}
try {
const relayListEvent = await ndk.fetchEvent({
kinds: [10002],
authors: [normalizedPubkey]
});
const relayUrls = collect(relayListEvent, 'r');
return relayUrls.slice(0, 3);
} catch (error) {
console.warn('[Worker] NIP-17 relay fetch kind10002 failed:', {
pubkey: normalizedPubkey,
error: error?.message || String(error)
});
return [];
}
};
try {
if (!messageSigner) {
throw new Error('Message signer not initialized');
}
if (!NIP17Protocol && !ndkGiftWrap) {
throw new Error('NIP17Protocol/giftWrap not available in bundle');
}
if (!NDKEventCtor || !NDKUserCtor) {
throw new Error('NDKEvent/NDKUser not available in bundle');
}
const normalizedRecipient = normalizeHexPubkey(recipientPubkey);
if (!normalizedRecipient) {
throw new Error('Invalid recipient pubkey');
}
const plaintext = String(content || '');
if (!plaintext.trim()) {
throw new Error('Message content is empty');
}
const senderUser = await messageSigner.user();
const senderPubkey = normalizeHexPubkey(senderUser?.pubkey || currentPubkey);
if (!senderPubkey) {
throw new Error('Unable to resolve sender pubkey');
}
const rumorEvent = new NDKEventCtor(ndk, {
kind: 14,
created_at: Math.floor(Date.now() / 1000),
content: plaintext,
tags: [['p', normalizedRecipient]],
pubkey: senderPubkey
});
console.log('[Worker] NIP-17 rumor created:', {
senderPubkey,
recipientPubkey: normalizedRecipient,
kind: rumorEvent.kind
});
const recipientRelayUrls = await resolveDmRelayUrlsForPubkey(normalizedRecipient);
const senderRelayUrls = await resolveDmRelayUrlsForPubkey(senderPubkey);
const targetRelayUrls = Array.from(new Set([...(recipientRelayUrls || []), ...(senderRelayUrls || [])]));
console.log('[Worker] NIP-17 relay resolution:', {
recipientRelayCount: recipientRelayUrls.length,
senderRelayCount: senderRelayUrls.length,
targetRelayCount: targetRelayUrls.length,
targetRelayUrls
});
const recipients = Array.from(new Set([normalizedRecipient, senderPubkey]));
const wrappedEvents = [];
for (const targetPubkey of recipients) {
const targetUser = new NDKUserCtor({ pubkey: targetPubkey });
let wrappedEvent;
if (typeof ndkGiftWrap === 'function') {
wrappedEvent = await ndkGiftWrap(rumorEvent, targetUser, messageSigner, { scheme: 'nip44' });
} else {
const protocol = new NIP17Protocol(ndk, messageSigner);
wrappedEvent = await protocol.sendMessage(targetUser, plaintext);
}
wrappedEvents.push({ targetPubkey, event: wrappedEvent });
console.log('[Worker] NIP-17 wrapped event created:', {
targetPubkey,
eventId: wrappedEvent?.id || null,
kind: wrappedEvent?.kind
});
}
const relayResults = {
successful: [],
failed: []
};
for (const wrapped of wrappedEvents) {
if (targetRelayUrls.length > 0 && NDKRelaySet?.fromRelayUrls) {
for (const relayUrl of targetRelayUrls) {
try {
const singleRelaySet = NDKRelaySet.fromRelayUrls([relayUrl], ndk);
const publishedRelays = await wrapped.event.publish(singleRelaySet);
const publishedThisRelay = Array.from(publishedRelays || []).some((relay) => normalizeRelayUrl(relay?.url) === normalizeRelayUrl(relayUrl));
if (publishedThisRelay) {
relayResults.successful.push(relayUrl);
trackRelayWrite(relayUrl);
} else {
relayResults.failed.push(relayUrl);
}
console.log('[Worker] NIP-17 publish relay attempt:', {
targetPubkey: wrapped.targetPubkey,
eventId: wrapped.event?.id || null,
relayUrl,
publishedThisRelay
});
} catch (publishError) {
relayResults.failed.push(relayUrl);
console.error('[Worker] NIP-17 publish failed for relay:', {
targetPubkey: wrapped.targetPubkey,
eventId: wrapped.event?.id || null,
relayUrl,
error: publishError?.message || String(publishError)
});
}
}
} else {
try {
const publishedRelays = await wrapped.event.publish();
if (publishedRelays && publishedRelays.size > 0) {
for (const relay of publishedRelays) {
if (!relay?.url) continue;
relayResults.successful.push(relay.url);
trackRelayWrite(relay.url);
}
}
console.log('[Worker] NIP-17 publish complete (default pool):', {
targetPubkey: wrapped.targetPubkey,
eventId: wrapped.event?.id || null,
publishedRelayCount: publishedRelays?.size || 0
});
} catch (publishError) {
console.error('[Worker] NIP-17 publish failed for wrapped event:', {
targetPubkey: wrapped.targetPubkey,
eventId: wrapped.event?.id || null,
error: publishError?.message || String(publishError)
});
}
}
}
const uniqueSuccessful = Array.from(new Set(relayResults.successful));
relayResults.failed = Array.from(new Set(relayResults.failed)).filter((url) => !uniqueSuccessful.includes(url));
const success = uniqueSuccessful.length > 0;
console.log('[Worker] NIP-17 send finished:', {
requestId,
success,
successfulRelayCount: uniqueSuccessful.length,
failedRelayCount: relayResults.failed.length
});
port.postMessage({
type: 'response',
requestId,
data: {
success,
eventId: wrappedEvents[0]?.event?.id || null,
relayResults: {
successful: uniqueSuccessful,
failed: relayResults.failed
},
totalRelays: uniqueSuccessful.length,
debug: {
recipientRelayUrls,
senderRelayUrls,
wrappedEventCount: wrappedEvents.length
}
}
});
} catch (error) {
console.error('[Worker] Error sending NIP-17 message:', error);
port.postMessage({
type: 'response',
requestId,
error: error?.message || String(error)
});
}
}
// Handle sign response from page
// Handle single-relay fetchEvents request used by event-management Refresh Kind
async function handleFetchEvents(requestId, filters, relayUrl, port) {
try {
if (!ndk) {
throw new Error('NDK not initialized');
}
if (!NDKRelaySet?.fromRelayUrls) {
throw new Error('NDKRelaySet.fromRelayUrls unavailable');
}
const resolvedRelayUrl = String(relayUrl || '').trim();
if (!resolvedRelayUrl) {
throw new Error('Missing relayUrl');
}
const relaySet = NDKRelaySet.fromRelayUrls([resolvedRelayUrl], ndk);
const eventSet = await ndk.fetchEvents(filters, {}, relaySet);
const events = Array.from(eventSet || []).map((e) => {
const raw = e?.rawEvent?.() || {};
if (!raw.sig && e?.sig) raw.sig = e.sig;
return raw;
});
trackRelayRead(resolvedRelayUrl);
port.postMessage({
type: 'fetchEventsResult',
requestId,
events,
error: null
});
} catch (err) {
console.warn('[Worker] fetchEvents failed:', relayUrl, err?.message || err);
port.postMessage({
type: 'fetchEventsResult',
requestId,
events: [],
error: err?.message || String(err)
});
}
}
// Handle NDK fetchEvents request - uses NDK's internal routing and caching
async function handleNdkFetchEvents(requestId, filters, port) {
try {
if (!ndk) {
throw new Error('NDK not initialized');
}
// NDK fetchEvents returns a Set<NDKEvent>
const eventSet = await ndk.fetchEvents(filters);
const events = Array.from(eventSet).map((e) => {
const raw = e?.rawEvent?.() || {};
if (!raw.sig && e?.sig) raw.sig = e.sig;
return raw;
});
port.postMessage({
type: 'ndkFetchEventsResult',
requestId,
events,
error: null
});
} catch (err) {
console.warn('[Worker] ndkFetchEvents failed:', err?.message || err);
port.postMessage({
type: 'ndkFetchEventsResult',
requestId,
events: [],
error: err?.message || String(err)
});
}
}
function handleSignResponse(requestId, result, error) {
const pending = pendingSignRequests.get(requestId);
if (pending) {
pendingSignRequests.delete(requestId);
if (error) {
pending.reject(new Error(error));
} else {
pending.resolve(result);
}
}
}
// Handle disconnect
function handleDisconnect() {
console.log('[Worker] Handling disconnect');
if (ndk) {
for (const relay of ndk.pool.relays.values()) {
relay.disconnect();
}
}
currentPubkey = null;
cachedProfile = null; // Clear cached profile on disconnect
for (const relayUrl of relayKeepaliveTimers.keys()) {
stopRelayKeepalive(relayUrl);
}
if (settingsPublishTimeout) {
clearTimeout(settingsPublishTimeout);
settingsPublishTimeout = null;
}
userSettings = null;
userSettingsPubkey = null;
userSettingsHydrated = false;
clearWalletListeners();
directProofStore = {};
directMintList = [];
directTransactions = [];
directWalletLoaded = false;
directWalletStatus = 'initial';
lightwalletHasWallet = false;
directNutzapPrivateKeyHex = null;
directNutzapP2pk = null;
spendingHistoryLastFetchAtMs = 0;
spendingHistoryFetchPromise = null;
legacyWalletMigrationAttempted = false;
}
// Get relay data for relays page
function handleGetRelayData(requestId, port) {
// Only expose authenticated user's kind 10002 relays to UI consumers
if (!ndk || !ndk.pool) {
port.postMessage({
type: 'response',
requestId: requestId,
data: []
});
return;
}
// Build from kind 10002 relay map so table/footer/sidebar share the same scope.
// Fallback: if kind 10002 has not been fetched yet, expose current pool relays
// so relay indicators still render during early startup.
const relayEntries = relayTypes.size > 0
? Array.from(relayTypes.entries())
: Array.from(ndk.pool.relays.keys()).map((url) => [url, 'both']);
const fromRelayList = relayTypes.size > 0;
const relayData = relayEntries.map(([url, type]) => {
let normalizedUrl = url;
try {
normalizedUrl = new URL(url).toString();
} catch (error) {
// Keep original URL if parsing fails
}
let relay = ndk.pool.relays.get(normalizedUrl) || ndk.pool.relays.get(url);
// Defensive fallback: compare canonical URL strings across pool keys
if (!relay && normalizedUrl) {
for (const [poolUrl, poolRelay] of ndk.pool.relays.entries()) {
try {
if (new URL(poolUrl).toString() === normalizedUrl) {
relay = poolRelay;
break;
}
} catch (error) {
// Ignore malformed pool URL entries
}
}
}
// Try to get connection time from various possible locations
let connectedAt = null;
if (relay?.connectionStats?.connectedAt) {
connectedAt = Math.floor(relay.connectionStats.connectedAt / 1000);
} else if (relay?.connectivity?._connectionStats?.connectedAt) {
connectedAt = Math.floor(relay.connectivity._connectionStats.connectedAt / 1000);
} else if (relay?.connectedAt) {
connectedAt = Math.floor(relay.connectedAt / 1000);
}
// Try to extract relay error details so UI can display startup connection failures
let lastError = null;
const relayErrors = relay?.relayErrors;
if (Array.isArray(relayErrors) && relayErrors.length > 0) {
const latestError = relayErrors[relayErrors.length - 1];
if (typeof latestError === 'string') {
lastError = latestError;
} else if (latestError?.message) {
lastError = latestError.message;
} else {
try {
lastError = JSON.stringify(latestError);
} catch (_error) {
lastError = String(latestError);
}
}
}
if (!lastError) {
const connectivityError = relay?.connectivity?._connectionStats?.lastError;
if (connectivityError) {
if (typeof connectivityError === 'string') {
lastError = connectivityError;
} else if (connectivityError?.message) {
lastError = connectivityError.message;
} else {
lastError = String(connectivityError);
}
}
}
return {
url: relay?.url || normalizedUrl || url,
status: relay?.status ?? 0, // 0 = DISCONNECTED
connectionTime: connectedAt,
type: type || 'both',
lastError,
fromRelayList
};
});
port.postMessage({
type: 'response',
requestId: requestId,
data: relayData
});
}
/**
* Handle request for relay activity statistics
*/
function handleGetRelayStats(requestId, port) {
const stats = getAllRelayStats();
port.postMessage({
type: 'response',
requestId,
data: stats
});
}
/**
* Handle reconnect relay request
*/
function handleReconnectRelay(relayUrl, port) {
console.log('[Worker] Reconnecting relay:', relayUrl);
if (!ndk || !ndk.pool) {
port.postMessage({
type: 'error',
data: { message: 'NDK not initialized' }
});
return;
}
let normalizedRelayUrl = relayUrl;
try {
normalizedRelayUrl = new URL(relayUrl).toString();
} catch (error) {
// Keep original relayUrl when parsing fails
}
let relay = ndk.pool.relays.get(normalizedRelayUrl) || ndk.pool.relays.get(relayUrl);
// Defensive fallback: compare canonical URL strings across pool keys
if (!relay && normalizedRelayUrl) {
for (const [poolUrl, poolRelay] of ndk.pool.relays.entries()) {
try {
if (new URL(poolUrl).toString() === normalizedRelayUrl) {
relay = poolRelay;
break;
}
} catch (error) {
// Ignore malformed pool URL entries
}
}
}
if (relay) {
const resolvedRelayUrl = relay.url || normalizedRelayUrl || relayUrl;
console.log('[Worker] Found relay:', resolvedRelayUrl, 'current status:', relay.status);
// Set up one-time listeners for connection result
const onConnect = () => {
console.log('[Worker] ✅ Relay successfully connected:', resolvedRelayUrl, 'status:', relay.status);
relay.off('connect', onConnect);
relay.off('disconnect', onDisconnect);
relay.off('error', onError);
};
const onDisconnect = () => {
console.log('[Worker] ❌ Relay disconnected during reconnect:', resolvedRelayUrl);
relay.off('connect', onConnect);
relay.off('disconnect', onDisconnect);
relay.off('error', onError);
};
const onError = (error) => {
console.error('[Worker] ❌ Relay error during reconnect:', resolvedRelayUrl, error);
broadcastRelayEvent(relay.url, 'reconnect-error', {
name: error?.name || null,
message: error?.message || error?.toString?.() || String(error),
code: error?.code,
source: 'manual-reconnect'
}, 'relay');
relay.off('connect', onConnect);
relay.off('disconnect', onDisconnect);
relay.off('error', onError);
};
relay.once('connect', onConnect);
relay.once('disconnect', onDisconnect);
relay.once('error', onError);
console.log('[Worker] Disconnecting relay:', resolvedRelayUrl);
relay.disconnect();
// Wait before reconnecting
setTimeout(() => {
console.log('[Worker] Initiating reconnect for:', resolvedRelayUrl, 'status:', relay.status);
markRelayConnectAttempt(resolvedRelayUrl);
broadcastRelayEvent(resolvedRelayUrl, 'reconnect-attempt', {
source: 'manual-reconnect',
targetUrl: normalizeRelayUrl(resolvedRelayUrl)
}, 'client');
relay.connect().catch((e) => {
console.error('[Worker] ❌ Connect() threw error for:', resolvedRelayUrl, e);
});
}, 500);
} else {
console.warn('[Worker] Relay not found in pool:', relayUrl, 'normalized:', normalizedRelayUrl);
console.log('[Worker] Available relays:', Array.from(ndk.pool.relays.keys()));
}
}
// Handle new connection
self.onconnect = (event) => {
const port = event.ports[0];
console.log('[Worker] New port connected');
connectedPorts.push(port);
port.onmessage = async (e) => {
const {
type,
pubkey,
subId,
filters,
opts,
requestId,
event,
result,
error,
patch,
settings,
options,
mints,
relays,
token,
description,
amount,
memo,
mint,
mintUrl,
invoice,
targetPubkey,
eventId,
recipientMints,
recipientP2pk,
recipientRelays,
receiveMints,
relayUrl,
recipientPubkey,
content,
playlistIdentifier,
ownerPubkey,
playlistKind,
eventIds
} = e.data;
switch(type) {
case 'init':
await handleInit(pubkey, port);
break;
case 'subscribe':
await handleSubscribe(subId, filters, opts, port);
break;
case 'unsubscribe':
handleUnsubscribe(subId);
break;
case 'publish':
await handlePublish(requestId, event, port);
break;
case 'publishRaw':
await handlePublishRaw(requestId, event, port);
break;
case 'publishRawToRelay':
await handlePublishRawToRelay(requestId, event, relayUrl, port);
break;
case 'sendNip17Message':
await handleSendNip17Message(requestId, recipientPubkey, content, port);
break;
case 'fetchEvents':
await handleFetchEvents(requestId, filters, relayUrl, port);
break;
case 'ndkFetchEvents':
await handleNdkFetchEvents(requestId, filters, port);
break;
case 'setActiveSigningPort':
activeSigningPort = port;
console.log('[Worker] Active signing port set explicitly by page');
port.postMessage({ type: 'activeSigningPortSet' });
break;
case 'signResponse':
handleSignResponse(requestId, result, error);
break;
case 'getRelayData':
handleGetRelayData(requestId, port);
break;
case 'getRelayStats':
handleGetRelayStats(requestId, port);
break;
case 'reconnectRelay':
handleReconnectRelay(e.data.relayUrl, port);
break;
case 'disconnect':
handleDisconnect();
break;
case 'queryCache':
await handleQueryCache(requestId, filters, port);
break;
case 'purgePlaylistCache':
await handlePurgePlaylistCache(requestId, playlistIdentifier, ownerPubkey, playlistKind, eventIds, port);
break;
case 'syncMuteList': {
try {
const result = await loadWorkerMuteList(currentPubkey, { reason: 'sync-request' });
port.postMessage({
type: 'response',
requestId,
data: { success: true, result }
});
} catch (error) {
port.postMessage({
type: 'response',
requestId,
error: error?.message || String(error)
});
}
break;
}
case 'fetchCachedProfile':
await handleFetchCachedProfile(requestId, e.data.pubkey, port);
break;
case 'storeProfile':
await handleStoreProfile(e.data.pubkey, e.data.profile);
break;
case 'getAllCachedProfiles':
await handleGetAllCachedProfiles(requestId, port);
break;
case 'getUserSettings':
await handleGetUserSettings(requestId, port);
break;
case 'patchUserSettings':
await handlePatchUserSettings(requestId, patch, options, port);
break;
case 'setUserSettings':
await handleSetUserSettings(requestId, settings, options, port);
break;
case 'resetUserSettings':
await handleResetUserSettings(requestId, options, port);
break;
case 'encryptContent':
await handleEncryptContent(requestId, e.data.recipientPubkey, e.data.plaintext, e.data.scheme, port);
break;
case 'decryptContent':
await handleDecryptContent(requestId, e.data.senderPubkey, e.data.ciphertext, e.data.scheme, port);
break;
case 'walletPublishMintList':
await handleWalletPublishMintList(requestId, relays, receiveMints, port);
break;
case 'walletFetchMintList':
await handleWalletFetchMintList(requestId, targetPubkey, port);
break;
case 'walletInit':
await handleWalletInit(requestId, port);
break;
case 'walletStart':
await handleWalletStart(requestId, pubkey, port);
break;
case 'walletCreate':
await handleWalletCreate(requestId, mints, relays, port);
break;
case 'walletHasWallet':
await handleWalletHasWallet(requestId, port);
break;
case 'walletGetBalance':
await handleWalletGetBalance(requestId, port);
break;
case 'walletGetMints':
await handleWalletGetMints(requestId, port);
break;
case 'walletReceiveToken':
await handleWalletReceiveToken(requestId, token, description, port);
break;
case 'walletSendToken':
await handleWalletSendToken(requestId, amount, memo, mint, port);
break;
case 'walletSendNutzap':
await handleWalletSendNutzap(
requestId,
amount,
memo,
targetPubkey,
eventId,
mint,
recipientMints,
recipientP2pk,
recipientRelays,
port
);
break;
case 'walletCreateDeposit':
await handleWalletCreateDeposit(requestId, amount, mint, port);
break;
case 'walletPayInvoice':
await handleWalletPayInvoice(requestId, invoice, mint, port);
break;
case 'walletAddMint':
await handleWalletAddMint(requestId, mintUrl, port);
break;
case 'walletRemoveMint':
await handleWalletRemoveMint(requestId, mintUrl, port);
break;
case 'walletGetTransactions':
await handleWalletGetTransactions(requestId, port);
break;
case 'walletShutdown':
handleWalletShutdown(requestId, port);
break;
case 'walletCheckProofs':
await handleWalletCheckProofs(requestId, port);
break;
case 'walletRepublish':
await handleWalletRepublish(requestId, port);
break;
default:
console.warn('[Worker] Unknown message type:', type);
}
};
port.start();
// If already initialized, send current state
if (currentPubkey && ndk) {
port.postMessage({ type: 'ready' });
// Send cached profile if available
if (cachedProfile) {
console.log('[Worker] Sending cached profile to new port:', cachedProfile);
port.postMessage({ type: 'profile', data: cachedProfile });
}
if (userSettings) {
port.postMessage({ type: 'userSettingsUpdated', data: userSettings });
}
// Send current relay status
const relayStatus = Array.from(ndk.pool.relays.values()).map(relay => {
console.log(`[Worker] Existing session relay ${relay.url} status: ${relay.status}`);
return {
url: relay.url,
connected: relay.status >= 5 // CONNECTED or higher
};
});
console.log('[Worker] Sending existing relay status to new port:', relayStatus);
port.postMessage({ type: 'relays', data: relayStatus });
}
};
console.log('[Worker] SharedWorker initialized');