Files
client/www/ndk-worker.js

7915 lines
296 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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
// When false (default), broadcastRelayEvent skips the cross-page broadcast()
// and logRelayEvent skips the IndexedDB write. trackRelayRead/trackRelayWrite
// (which emit relayActivity events for footer/sidenav carrots) are NOT gated
// by this flag and always run. Toggled on by the relays page via the
// setRelayEventLogging RPC when the user opens connection history.
let relayEventLoggingEnabled = false;
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
// Broadcast relay state (kind 10088 — NIP-51 private list, NIP-44 encrypted content).
// Mirrors Amethyst's BroadcastRelayListEvent. Active relays are unioned into every
// public publish via handlePublish; skip-marked relays are kept in the list for
// visibility but excluded from the publish relay set.
let broadcastRelayUrls = new Set(); // active "r" tags from kind 10088
let skippedRelayUrls = new Map(); // url -> { reason, timestamp } ("x" tags)
let latest10088Event = null; // raw event for update-preserving saves
let skipMarkSaveTimer = null; // debounce timer for skip-mark saves
let latest10088CreatedAt = 0; // guard against stale kind 10088 events
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)
};
// Only persist to IndexedDB when relay event logging is enabled.
// When the relays page (connection history) is closed, this avoids
// IndexedDB write contention from the high-frequency relay event stream.
if (relayEventLoggingEnabled) {
// 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') {
// Skip the cross-page broadcast entirely when relay event logging is off.
// This eliminates relayEvent message spam to every open tab/page when the
// connection-history UI is not visible. trackRelayRead/trackRelayWrite
// (relayActivity events) are intentionally NOT gated and still broadcast.
if (!relayEventLoggingEnabled) return;
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) };
}
}
// ---------------------------------------------------------------------------
// Broadcast relays (kind 10088) — NIP-51 private list, NIP-44 encrypted content.
// Active relays ("r" tags) are unioned into every public publish; skip-marked
// relays ("x" tags) are kept in the list for visibility but excluded from the
// publish relay set. Coexists with Amethyst, which only reads/writes "r" tags.
// ---------------------------------------------------------------------------
/** Kinds that should be broadcast to the user's broadcast relay set. */
const BROADCAST_KINDS = new Set([1, 6, 7, 30023, 30024, 30315, 31123, 31124, 10088]);
/**
* Returns true if the given event kind should be unioned with the broadcast
* relay set on publish. Mirrors Amethyst's Account.kt:1217 gate — public
* posts/reactions/reposts get the broadcast set; DMs, app settings, wallet
* events, and gift-wrapped events do NOT.
*/
function wantsBroadcastRelays(event) {
if (!event) return false;
return BROADCAST_KINDS.has(Number(event.kind));
}
/**
* Returns the active broadcast relay URLs (r-tags minus x-tag skip-marks).
* This is the set that handlePublish unions into the outbox write relays.
*/
function getActiveBroadcastUrls() {
return Array.from(broadcastRelayUrls).filter(u => !skippedRelayUrls.has(u));
}
/**
* Parse a kind 10088 event into broadcastRelayUrls / skippedRelayUrls.
* Public ["r", url] tags are read first, then the NIP-44-encrypted content is
* decrypted (reusing the same messageSigner plumbing as the mute list) and the
* private tags are merged: ["r", url] → broadcastRelayUrls,
* ["x", url, reason, timestamp] → skippedRelayUrls.
*
* @param {object} event - raw kind 10088 event (NDKEvent or plain object)
* @param {string} author - hex pubkey of the author (for NIP-44 decrypt)
* @returns {Promise<boolean>} true if decryption succeeded (or was unnecessary)
*/
async function parse10088Event(event, author) {
if (!event) {
broadcastRelayUrls = new Set();
skippedRelayUrls = new Map();
latest10088Event = null;
latest10088CreatedAt = 0;
return true;
}
const nextR = new Set();
const nextX = new Map();
// Public r-tags (rare for kind 10088, but supported by the NIP).
for (const tag of (event.tags || [])) {
if (tag[0] === 'r' && tag[1]) {
nextR.add(tag[1]);
} else if (tag[0] === 'x' && tag[1]) {
nextX.set(tag[1], {
reason: tag[2] || 'unknown',
timestamp: Number(tag[3]) || 0,
});
}
}
// Private tags live in the NIP-44-encrypted content blob.
const encrypted = String(event.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);
if (Array.isArray(privateTags)) {
for (const tag of privateTags) {
if (!Array.isArray(tag)) continue;
if (tag[0] === 'r' && tag[1]) {
nextR.add(tag[1]);
} else if (tag[0] === 'x' && tag[1]) {
nextX.set(tag[1], {
reason: tag[2] || 'unknown',
timestamp: Number(tag[3]) || 0,
});
}
}
}
}
} catch (error) {
console.warn('[Worker][broadcast] Failed decrypting kind 10088 content:', error?.message || error);
return false;
}
}
broadcastRelayUrls = nextR;
skippedRelayUrls = nextX;
latest10088Event = event;
latest10088CreatedAt = Number(event.created_at || 0);
return true;
}
/**
* Hydrate broadcast relay state from the user's latest kind 10088 event.
* Called once during first init (after fetchUserRelays). Leaves both sets
* empty if no event exists or decryption fails.
*/
async function loadBroadcastRelays(pubkey) {
const author = normalizeHexPubkey(pubkey || currentPubkey);
if (!author || !ndk) {
return { loaded: false, reason: 'missing-author-or-ndk' };
}
try {
const filter = { kinds: [10088], authors: [author], limit: 1 };
const events = await ndk.fetchEvents(filter);
const latest = events && events.size > 0
? Array.from(events).sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
: null;
if (!latest) {
broadcastRelayUrls = new Set();
skippedRelayUrls = new Map();
latest10088Event = null;
latest10088CreatedAt = 0;
console.log('[Worker][broadcast] No kind 10088 event found, broadcast relays empty');
return { loaded: true, active: 0, skipped: 0 };
}
await parse10088Event(latest, author);
console.log('[Worker][broadcast] Loaded kind 10088', {
eventId: latest.id,
active: broadcastRelayUrls.size,
skipped: skippedRelayUrls.size,
});
return {
loaded: true,
active: broadcastRelayUrls.size,
skipped: skippedRelayUrls.size,
};
} catch (error) {
console.warn('[Worker][broadcast] Failed to load kind 10088:', error?.message || error);
return { loaded: false, error: error?.message || String(error) };
}
}
/**
* Build, sign, and publish a kind 10088 event preserving existing r-tags
* (union with `activeUrls`) and all existing x-tags (skip-marks). The private
* tags (r + x) are NIP-44-encrypted as the content blob; public tags are left
* empty so Amethyst's `create()` pattern is mirrored.
*
* @param {string[]} activeUrls - the new active r-tag list (unioned with existing)
* @returns {Promise<boolean>} true if published successfully
*/
async function saveBroadcastRelaysInternal(activeUrls) {
if (!ndk || !currentPubkey) {
console.warn('[Worker][broadcast] Cannot save kind 10088 — ndk/pubkey missing');
return false;
}
const NDKEvent = NDKExports?.NDKEvent || NDKExports?.default?.NDKEvent;
if (!NDKEvent) {
console.warn('[Worker][broadcast] NDKEvent unavailable, cannot save kind 10088');
return false;
}
// Preserve existing r-tags from the latest event (so we don't clobber
// Amethyst's list) and union with the new active list.
const existingRTags = latest10088Event
? (latest10088Event.tags || []).filter(t => t[0] === 'r' && t[1])
: [];
const mergedRUrls = new Set([
...existingRTags.map(t => t[1]),
...(Array.isArray(activeUrls) ? activeUrls : []),
]);
// Preserve existing x-tags (skip-marks) — never touch them on a save.
const existingXTags = latest10088Event
? (latest10088Event.tags || []).filter(t => t[0] === 'x' && t[1])
: [];
// Also include any in-memory skip-marks not yet persisted.
const mergedXEntries = new Map();
for (const tag of existingXTags) {
mergedXEntries.set(tag[1], [tag[2] || 'unknown', Number(tag[3]) || 0]);
}
for (const [url, info] of skippedRelayUrls.entries()) {
mergedXEntries.set(url, [info.reason || 'unknown', Number(info.timestamp) || 0]);
}
const privateTags = [
...Array.from(mergedRUrls).map(u => ['r', u]),
...Array.from(mergedXEntries.entries()).map(([url, [reason, ts]]) => ['x', url, reason, ts]),
];
// NIP-44-encrypt the private tags as the content blob.
let encrypted = '';
try {
const encryptedResp = await messageSigner.requestFromPage('nip44Encrypt', {
recipientPubkey: currentPubkey,
plaintext: JSON.stringify(privateTags),
});
encrypted = typeof encryptedResp === 'string' ? encryptedResp : encryptedResp?.ciphertext;
} catch (error) {
console.warn('[Worker][broadcast] NIP-44 encrypt failed, cannot save kind 10088:', error?.message || error);
return false;
}
if (!encrypted) {
console.warn('[Worker][broadcast] Empty ciphertext from nip44Encrypt, aborting save');
return false;
}
const event = new NDKEvent(ndk, {
kind: 10088,
content: encrypted,
tags: [], // public tags empty — keep private like Amethyst's create()
created_at: Math.floor(Date.now() / 1000),
});
try {
await signEventWithMessageSigner(event);
await event.publish();
} catch (error) {
console.warn('[Worker][broadcast] Failed to sign/publish kind 10088:', error?.message || error);
return false;
}
// Update local state from the freshly published event.
latest10088Event = event;
latest10088CreatedAt = event.created_at || Math.floor(Date.now() / 1000);
broadcastRelayUrls = mergedRUrls;
// Refresh skippedRelayUrls from the merged set so newly-added skip-marks persist.
const nextSkipped = new Map();
for (const [url, [reason, ts]] of mergedXEntries.entries()) {
nextSkipped.set(url, { reason, timestamp: ts });
}
skippedRelayUrls = nextSkipped;
broadcast({ type: 'broadcastRelaysUpdated' });
console.log('[Worker][broadcast] Saved kind 10088', {
active: broadcastRelayUrls.size,
skipped: skippedRelayUrls.size,
});
return true;
}
/**
* Debounced (5s) persistence of skip-marks to kind 10088. Called by
* handlePublish after failure marking. Multiple failures in one publish (or
* several publishes close together) produce a single republish.
*/
function scheduleSkipMarkSave() {
if (skipMarkSaveTimer) clearTimeout(skipMarkSaveTimer);
skipMarkSaveTimer = setTimeout(async () => {
skipMarkSaveTimer = null;
try {
// Preserve current r-tags; only the x-tags change.
await saveBroadcastRelaysInternal(Array.from(broadcastRelayUrls));
} catch (error) {
console.warn('[Worker][broadcast] Debounced skip-mark save failed:', error?.message || error);
}
}, 5000);
}
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) {
const startedAt = Date.now();
const traceId = `walletPayInvoice:${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 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]));
log('mint-selection', {
requestedMint: requestedMint || null,
orderedMints: orderedMints.map((m) => ({ mint: m, proofBalance: getProofTotal(directProofStore[m]) }))
});
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] : [];
const proofBalance = getProofTotal(proofs);
log('mint attempt:start', { mintUrl, proofBalance, proofCount: proofs.length });
if (proofs.length === 0) {
log('mint attempt:no-proofs', { mintUrl });
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);
// The mint's quoted fee_reserve is an estimate; the actual melt fee can be
// slightly higher, causing "not enough inputs provided for melt" errors.
// Add a safety buffer — any overpayment is returned as change proofs.
const FEE_RESERVE_BUFFER_SATS = 10;
const required = quoteAmount + quoteFeeReserve;
const sendAmount = required + FEE_RESERVE_BUFFER_SATS;
log('mint attempt:quote', { mintUrl, quoteAmount, quoteFeeReserve, required, sendAmount });
if (!Number.isFinite(required) || required <= 0) {
log('mint attempt:invalid-quote', { mintUrl, required });
attemptErrors.push({ mint: mintUrl, message: 'Invalid melt quote amount' });
continue;
}
if (proofBalance < sendAmount) {
log('mint attempt:insufficient', { mintUrl, proofBalance, sendAmount });
attemptErrors.push({ mint: mintUrl, message: `Insufficient funds on mint (${proofBalance} sats)` });
continue;
}
const sendResult = await wallet.send(sendAmount, proofs);
const keep = Array.isArray(sendResult?.keep) ? sendResult.keep : [];
const sendProofs = Array.isArray(sendResult?.send) ? sendResult.send : [];
log('mint attempt:send-split', { mintUrl, keepCount: keep.length, sendCount: sendProofs.length, sendAmount });
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, sendAmount - changeAmount);
paidAmountSats = Number.isFinite(quoteAmount) && quoteAmount > 0
? quoteAmount
: spentAmount;
log('mint attempt:success', { mintUrl, paidAmountSats, changeAmount, spentAmount, preimage: meltResult?.preimage || null });
usedMint = mintUrl;
payResult = meltResult;
break;
} catch (error) {
log('mint attempt:error', { mintUrl, message: error?.message || String(error) });
attemptErrors.push({ mint: mintUrl, message: error?.message || String(error) });
}
}
if (!payResult || !usedMint) {
const detail = attemptErrors.map((entry) => `${entry.mint || 'unknown'}: ${entry.message}`).join(' | ');
log('all-mints-failed', { attemptErrors });
throw new Error(`Failed to pay invoice. ${detail || 'No mint could complete melt'}`);
}
log('paid', { usedMint, paidAmountSats, preimage: payResult?.preimage || null });
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) {
log('error', { message: error?.message || String(error), stack: error?.stack || null });
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 }))
});
});
// Hydrate broadcast relay state (kind 10088) — active r-tags +
// skip-marked x-tags. Non-fatal: leaves both sets empty on failure.
broadcastStartupStatus('Fetching broadcast relay list (kind 10088)...');
try {
await loadBroadcastRelays(pubkey);
} catch (err) {
console.warn('[Worker] Broadcast relay hydration failed, continuing init:', err?.message || err);
}
// Subscribe to kind 10088 for live updates from other tabs/clients
// (including Amethyst). Re-parses into broadcastRelayUrls /
// skippedRelayUrls and broadcasts broadcastRelaysUpdated to all ports.
try {
console.log('[Worker] Subscribing to kind 10088 broadcast relay updates for:', pubkey);
const broadcastFilter = { kinds: [10088], authors: [pubkey] };
const broadcastSub = ndk.subscribe(broadcastFilter, { closeOnEose: false });
broadcastSub.on('event', async (event) => {
const incomingCreatedAt = event?.created_at || 0;
if (incomingCreatedAt < latest10088CreatedAt) {
console.log('[Worker] Ignoring stale kind 10088 event:', {
incomingCreatedAt,
latest10088CreatedAt,
});
return;
}
const author = normalizeHexPubkey(pubkey || currentPubkey);
const ok = await parse10088Event(event, author);
if (!ok) return;
console.log('[Worker] Updated broadcast relays from subscription:', {
active: broadcastRelayUrls.size,
skipped: skippedRelayUrls.size,
});
broadcast({ type: 'broadcastRelaysUpdated' });
});
} catch (subErr) {
console.warn('[Worker] Kind 10088 subscription failed:', subErr?.message || subErr);
}
// 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...');
// --- Broadcast relay union -------------------------------------------------
// If this event kind is broadcast-worthy and the user has active broadcast
// relays (kind 10088 r-tags minus x-tag skip-marks), union them with the
// user's outbox write relays and publish to the combined set via a
// temporary NDKRelaySet. NDK's fromRelayUrls uses pool.useTemporaryRelay
// so hundreds of broadcast relays connect transiently without polluting
// the read subscription pool.
let targetRelaySet = null;
const activeBroadcastUrls = getActiveBroadcastUrls();
const isBroadcast = wantsBroadcastRelays(event) && activeBroadcastUrls.length > 0;
let outboxWriteUrls = [];
if (isBroadcast) {
outboxWriteUrls = Array.from(relayTypes.entries())
.filter(([, t]) => t === 'write' || t === 'both')
.map(([url]) => url);
const allUrls = [...new Set([...outboxWriteUrls, ...activeBroadcastUrls])];
if (NDKRelaySet?.fromRelayUrls) {
targetRelaySet = NDKRelaySet.fromRelayUrls(allUrls, ndk);
// Set the connection timeout on all relays in the set to match
// the per-relay publish timeout (5s). NDK's default
// connectionTimeout is 4400ms — we set it to 5000ms so the
// connection attempt and publish share the same 5s budget.
if (targetRelaySet?.relays) {
for (const relay of targetRelaySet.relays) {
try {
if (relay.connectionTimeout !== undefined) {
relay.connectionTimeout = 5000;
}
} catch (_) { /* relay may be read-only */ }
}
}
console.log(`[Worker] Broadcasting to ${allUrls.length} relays ` +
`(${activeBroadcastUrls.length} broadcast + ${outboxWriteUrls.length} outbox, ` +
`${skippedRelayUrls.size} skipped)`);
} else {
console.warn('[Worker] NDKRelaySet.fromRelayUrls unavailable, falling back to default outbox');
}
}
// --- Live broadcast progress streaming ------------------------------------
// NDK's NDKEvent emits 'relay:published' and 'relay:publish:failed' per
// relay as each completes (see ndk-core.bundle.js:8530 and :8552). We
// attach listeners BEFORE publish() and stream progress to all pages via
// broadcast() so footers can show "📡 42/150 relays · relay.example.com".
const publishSessionId = `pub_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const publishedSoFar = new Set();
const failedSoFar = new Set(); // explicit rejections (auth-required, blocked, etc.)
const timedOutSoFar = new Set(); // timeouts — NOT skip-marked (could be slow connect)
const totalTarget = targetRelaySet && targetRelaySet.relays
? targetRelaySet.relays.size
: (targetRelaySet ? (targetRelaySet.size || 0) : 0);
if (isBroadcast && totalTarget > 0) {
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
eventKind: event.kind,
eventId: ndkEvent.id || null,
phase: 'start',
total: totalTarget,
successful: 0,
failed: 0,
latestRelay: null,
});
ndkEvent.on('relay:published', (relay) => {
const url = relay?.url || String(relay || '');
if (!url) return;
publishedSoFar.add(url);
trackRelayWrite(url);
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
phase: 'progress',
total: totalTarget,
successful: publishedSoFar.size,
failed: failedSoFar.size,
latestRelay: url,
});
});
ndkEvent.on('relay:publish:failed', (relay, err) => {
const url = relay?.url || String(relay || '');
if (!url) return;
const errMsg = err?.message || String(err) || '';
// Distinguish timeouts from explicit rejections. Timeouts with
// hundreds of relays often just mean the relay was slow to connect,
// not that it's broken — so we track them separately and do NOT
// skip-mark them. Only explicit rejections go into failedSoFar.
const isTimeout = /timeout/i.test(errMsg);
if (isTimeout) {
timedOutSoFar.add(url);
} else {
failedSoFar.add(url);
}
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
phase: 'progress',
total: totalTarget,
successful: publishedSoFar.size,
failed: failedSoFar.size + timedOutSoFar.size,
latestRelay: url,
latestError: errMsg,
});
});
}
// Publish to relays (with broadcast relay set if broadcasting, else default outbox).
// Per-relay timeout for broadcasts: 5 seconds. This covers both the WebSocket
// connection attempt and the publish OK response for each relay. NDK runs all
// relay publishes in parallel via Promise.all, so with 600 relays the overall
// time is ~5s (the slowest relay), not 600×5s.
// requiredRelayCount=1 so NDK doesn't throw if only 1 of 600 relays succeeds.
const BROADCAST_PER_RELAY_TIMEOUT_MS = 5000;
const publishTimeoutMs = isBroadcast ? BROADCAST_PER_RELAY_TIMEOUT_MS : undefined;
const publishRequiredCount = isBroadcast ? 1 : undefined;
let relaySet;
if (isBroadcast) {
// For broadcasts, race the publish against an overall deadline.
// NDK's publish() uses Promise.all internally, so it waits for ALL
// relays to resolve. The overall deadline is totalRelays × perRelayTimeout
// as a safety cap — in practice all relays run in parallel so the publish
// should complete in ~perRelayTimeout, but the deadline ensures we never
// hang indefinitely if something goes wrong.
const BROADCAST_OVERALL_DEADLINE_MS = Math.max(30000, totalTarget * BROADCAST_PER_RELAY_TIMEOUT_MS);
const publishPromise = ndkEvent.publish(targetRelaySet, publishTimeoutMs, publishRequiredCount)
.catch((err) => {
// NDKPublishError is expected when not all relays succeed —
// the live listeners already captured the successes.
console.warn('[Worker] Broadcast publish() resolved with error (expected):', err?.message || err);
return null;
});
const deadlinePromise = new Promise((resolve) => {
setTimeout(() => resolve('__DEADLINE__'), BROADCAST_OVERALL_DEADLINE_MS);
});
const result = await Promise.race([publishPromise, deadlinePromise]);
if (result === '__DEADLINE__') {
console.log(`[Worker] Broadcast overall deadline (${BROADCAST_OVERALL_DEADLINE_MS}ms) reached — ` +
`${publishedSoFar.size} succeeded, ${failedSoFar.size + timedOutSoFar.size} failed/timeout, ` +
`${totalTarget - publishedSoFar.size - failedSoFar.size - timedOutSoFar.size} still pending`);
}
relaySet = result && result !== '__DEADLINE__' ? result : null;
} else {
// Normal publish — await the full result as before.
try {
relaySet = await ndkEvent.publish(targetRelaySet, publishTimeoutMs, publishRequiredCount);
} catch (publishError) {
console.warn('[Worker] publish() threw:', publishError?.message || publishError);
relaySet = null;
}
}
// Final broadcast progress message with complete results.
if (isBroadcast && totalTarget > 0) {
const totalFailed = failedSoFar.size + timedOutSoFar.size;
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
eventId: ndkEvent.id || null,
eventKind: event.kind,
phase: 'done',
total: totalTarget,
successful: publishedSoFar.size,
failed: totalFailed,
latestRelay: null,
relayUrls: Array.from(publishedSoFar),
});
// Clean up listeners so the NDKEvent can be garbage-collected.
try {
ndkEvent.removeAllListeners('relay:published');
ndkEvent.removeAllListeners('relay:publish:failed');
} catch (_cleanupErr) {
// removeAllListeners may not exist on all EventEmitter impls; ignore.
}
}
// For non-broadcast publishes, emit a simplified broadcastProgress 'done'
// event so post cards can still show the relay count (e.g., "3r - 1h").
// We use the relaySet result to count successful relays.
if (!isBroadcast) {
const successCount = relaySet ? relaySet.size : 0;
const successUrls = relaySet
? Array.from(relaySet).map(r => r?.url || '').filter(Boolean)
: [];
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
eventId: ndkEvent.id || null,
eventKind: event.kind,
phase: 'done',
total: successCount,
successful: successCount,
failed: 0,
latestRelay: null,
relayUrls: successUrls,
});
}
// 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 (also tracked live above for broadcasts)
if (!isBroadcast) trackRelayWrite(relay.url);
}
}
// For broadcast publishes, use the live-tracked sets from the
// relay:published / relay:publish:failed events as the source of truth
// (they cover temporary relays that may not be in ndk.pool). For normal
// publishes, fall back to the pool-scan logic.
if (isBroadcast) {
relayResults.successful = Array.from(publishedSoFar);
// Include both explicit rejections and timeouts in the failed list
// for display purposes, but keep them distinguishable for skip-marking.
relayResults.failed = [...Array.from(failedSoFar), ...Array.from(timedOutSoFar)];
} else {
// 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.length, relayResults.successful.slice(0, 10), relayResults.successful.length > 10 ? `... +${relayResults.successful.length - 10} more` : '');
console.log('[Worker] ❌ Failed relays:', relayResults.failed.length);
// --- Failure marking for broadcast relays ---------------------------------
// Persist skip-marks ONLY for broadcast relays that EXPLICITLY rejected
// the publish (auth-required, blocked, invalid, etc.). We do NOT skip-mark
// relays that timed out — with hundreds of relays, a timeout often means
// the relay was slow to connect, not that it's broken. The relay:publish:failed
// listener above separates timeouts (timedOutSoFar) from explicit rejections
// (failedSoFar) by checking the error message for "timeout".
if (isBroadcast) {
const newSkips = Array.from(failedSoFar)
.filter(url => broadcastRelayUrls.has(url) && !skippedRelayUrls.has(url));
if (newSkips.length > 0) {
for (const url of newSkips) {
skippedRelayUrls.set(url, {
reason: 'publish-rejected',
timestamp: Math.floor(Date.now() / 1000),
});
}
scheduleSkipMarkSave();
console.log(`[Worker] Skip-marked ${newSkips.length} rejected broadcast relays ` +
`(${timedOutSoFar.size} timeouts excluded from skip-marking)`);
} else if (timedOutSoFar.size > 0) {
console.log(`[Worker] ${timedOutSoFar.size} broadcast relays timed out (not skip-marked — will retry next publish)`);
}
}
// 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)
});
}
}
// Handle outbox pre-warm request — proactively resolves each follow's kind
// 10002 relay list via ndk.outboxTracker.trackUsers so that subsequent
// short-lived fetchEvents subscriptions route to the correct write relays
// instead of racing the tracker. Pre-warming is best-effort: this handler
// never throws; it always responds with an ok/skipped/error payload.
async function handleWarmOutbox(requestId, pubkeys, port) {
try {
if (!ndk?.outboxTracker) {
port.postMessage({
type: 'warmOutboxResult',
requestId,
ok: true,
count: 0,
skipped: true
});
return;
}
const list = Array.isArray(pubkeys) ? pubkeys.filter(Boolean) : [];
if (list.length === 0) {
port.postMessage({
type: 'warmOutboxResult',
requestId,
ok: true,
count: 0,
skipped: true
});
return;
}
await ndk.outboxTracker.trackUsers(list);
port.postMessage({
type: 'warmOutboxResult',
requestId,
ok: true,
count: list.length
});
} catch (err) {
console.warn('[Worker] warmOutbox failed:', err?.message || err);
port.postMessage({
type: 'warmOutboxResult',
requestId,
ok: false,
error: err?.message || String(err)
});
}
}
// Handle relay event logging toggle. When enabled, broadcastRelayEvent will
// emit relayEvent messages to all ports and logRelayEvent will persist entries
// to IndexedDB. When disabled (default), both are skipped to avoid cross-page
// broadcast spam and IndexedDB write contention. trackRelayRead/trackRelayWrite
// are NOT affected by this flag.
async function handleSetRelayEventLogging(requestId, enabled, port) {
try {
relayEventLoggingEnabled = !!enabled;
port.postMessage({
type: 'setRelayEventLoggingResult',
requestId,
ok: true,
enabled: relayEventLoggingEnabled
});
} catch (err) {
console.warn('[Worker] setRelayEventLogging failed:', err?.message || err);
port.postMessage({
type: 'setRelayEventLoggingResult',
requestId,
ok: false,
error: err?.message || String(err),
enabled: relayEventLoggingEnabled
});
}
}
// Handle getDiscoveredRelays request — returns relays currently in the NDK
// pool that are NOT part of the user's own kind 10002 relay list (relayTypes).
// These are temporary outbox relays NDK connected to in order to fetch events
// from followed authors who write to relays the user does not subscribe to.
// For each discovered relay we also report which followed pubkeys it serves,
// by inverting ndk.outboxTracker.data (pubkey -> OutboxItem{writeRelays}).
function shortHexPubkey(hex) {
if (!hex || hex.length < 16) return hex || '-';
return `${hex.slice(0, 8)}${hex.slice(-4)}`;
}
// Cache for discovered relays — rebuilt only when the contact list changes.
// The version suffix forces a rebuild when the handler code changes (e.g.
// adding servingNames).
let discoveredRelaysCache = null;
let discoveredRelaysCacheKey = '';
const DISCOVERED_RELAYS_CACHE_VERSION = 'v3-names-k0';
async function handleGetDiscoveredRelays(requestId, port) {
try {
if (!ndk) {
port.postMessage({ type: 'getDiscoveredRelaysResult', requestId, relays: [] });
return;
}
// Build the set of the user's own relay URLs (normalized) from
// relayTypes (kind 10002). These are the "configured" relays.
const ownRelays = new Set();
for (const url of relayTypes.keys()) {
const normalized = normalizeRelayUrl(url);
if (normalized) ownRelays.add(normalized);
}
// Get the user's kind 3 contact list from the Dexie cache to
// determine the followed pubkeys. Use the contact list's created_at
// as a cache key so we only rebuild when it changes.
const kind3Events = await ndk.fetchEvents(
{ kinds: [3], authors: [currentPubkey], limit: 1 },
{ cacheUsage: 'ONLY_CACHE' }
);
const latestKind3 = kind3Events && kind3Events.size > 0
? Array.from(kind3Events).sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
: null;
const followedPubkeys = latestKind3 ? extractFollowedPubkeysFromKind3(latestKind3) : [];
const cacheKey = `${DISCOVERED_RELAYS_CACHE_VERSION}_${latestKind3?.id || 'none'}_${latestKind3?.created_at || 0}`;
// Rebuild the relay→[pubkeys] mapping from follows' kind 10002 events
// in the Dexie cache if the contact list has changed.
if (discoveredRelaysCacheKey !== cacheKey) {
discoveredRelaysCacheKey = cacheKey;
discoveredRelaysCache = null; // invalidate
if (followedPubkeys.length > 0) {
// Query all follows' kind 10002 events from the Dexie cache.
// This is the persistent data source — no 2-minute TTL like
// the outbox tracker.
const relayServesPubkeys = new Map(); // normalizedUrl -> Set<pubkey>
// Process in batches of 400 to avoid filter size limits.
for (let i = 0; i < followedPubkeys.length; i += 400) {
const batch = followedPubkeys.slice(i, i + 400);
try {
const k10002Events = await ndk.fetchEvents(
{ kinds: [10002], authors: batch },
{ cacheUsage: 'ONLY_CACHE' }
);
if (k10002Events && k10002Events.size > 0) {
// For each author, keep only the latest kind 10002.
const latestByAuthor = new Map();
for (const evt of k10002Events) {
const existing = latestByAuthor.get(evt.pubkey);
if (!existing || (evt.created_at || 0) > (existing.created_at || 0)) {
latestByAuthor.set(evt.pubkey, evt);
}
}
// Parse 'r' tags from each author's latest kind 10002.
for (const [pubkey, evt] of latestByAuthor) {
if (!Array.isArray(evt.tags)) continue;
for (const tag of evt.tags) {
if (tag[0] === 'r' && tag[1]) {
const relayType = tag[2] || 'both';
// Include 'write' and 'both' relays — these are
// where the follow posts content.
if (relayType === 'write' || relayType === 'both') {
const normalized = normalizeRelayUrl(tag[1]);
if (!normalized) continue;
if (!relayServesPubkeys.has(normalized)) {
relayServesPubkeys.set(normalized, new Set());
}
relayServesPubkeys.get(normalized).add(pubkey);
}
}
}
}
}
} catch (_) {}
}
// Look up profile names for all serving pubkeys from the Dexie
// profiles table so we can display usernames instead of pubkeys.
// Look up profile names for all serving pubkeys.
const pubkeyToName = new Map();
const allServingPubkeys = new Set();
for (const servingSet of relayServesPubkeys.values()) {
for (const pk of servingSet) allServingPubkeys.add(pk);
}
// Strategy 1: Try Dexie profiles table.
try {
const adapter = ndk?.cacheAdapter?.adapter;
if (adapter?.db?.profiles) {
for (const pk of allServingPubkeys) {
try {
const row = await adapter.db.profiles.get(pk);
if (row) {
const name = row.displayName || row.name || '';
if (name) pubkeyToName.set(pk, name);
}
} catch (_) {}
}
}
} catch (_) {}
// Strategy 2: If Dexie profiles didn't have names for everyone,
// query kind 0 events from the NDK cache for the missing pubkeys.
const missingPubkeys = Array.from(allServingPubkeys).filter(pk => !pubkeyToName.has(pk));
if (missingPubkeys.length > 0) {
try {
// Query in batches of 400.
for (let i = 0; i < missingPubkeys.length; i += 400) {
const batch = missingPubkeys.slice(i, i + 400);
const k0Events = await ndk.fetchEvents(
{ kinds: [0], authors: batch },
{ cacheUsage: 'ONLY_CACHE' }
);
if (k0Events && k0Events.size > 0) {
// For each author, keep only the latest kind 0.
const latestByAuthor = new Map();
for (const evt of k0Events) {
const existing = latestByAuthor.get(evt.pubkey);
if (!existing || (evt.created_at || 0) > (existing.created_at || 0)) {
latestByAuthor.set(evt.pubkey, evt);
}
}
for (const [pubkey, evt] of latestByAuthor) {
try {
const profile = typeof evt.content === 'string'
? JSON.parse(evt.content) : evt.content;
const name = profile?.display_name || profile?.name || '';
if (name) pubkeyToName.set(pubkey, name);
} catch (_) {}
}
}
}
} catch (_) {}
}
console.log('[Worker] getDiscoveredRelays: profile names resolved for', pubkeyToName.size, 'of', allServingPubkeys.size, 'pubkeys');
// Build the discovered relays list: relays that are NOT in the
// user's own kind 10002 but ARE in follows' kind 10002.
const relays = [];
for (const [relayUrl, servingSet] of relayServesPubkeys) {
if (ownRelays.has(relayUrl)) continue; // skip user's own relays
// Check live connection status from the pool.
const poolRelay = ndk.pool?.relays?.get(relayUrl);
const pubkeys = Array.from(servingSet);
// Map pubkeys to names, falling back to truncated pubkey.
const names = pubkeys.map(pk =>
pubkeyToName.get(pk) || shortHexPubkey(pk)
);
relays.push({
url: relayUrl,
status: poolRelay?.status || 0,
connected: poolRelay ? poolRelay.status >= 5 : false,
servingPubkeys: pubkeys,
servingNames: names
});
}
// Sort by serving count (most follows first), then by URL.
relays.sort((a, b) => {
const countDiff = b.servingPubkeys.length - a.servingPubkeys.length;
if (countDiff !== 0) return countDiff;
return a.url.localeCompare(b.url);
});
discoveredRelaysCache = relays;
}
}
// If we have cached data, update the live connection status from the
// pool without rebuilding the entire mapping.
let relays = discoveredRelaysCache || [];
if (relays.length > 0 && ndk.pool?.relays) {
relays = relays.map(r => {
const poolRelay = ndk.pool.relays.get(r.url);
return {
...r,
status: poolRelay?.status || r.status || 0,
connected: poolRelay ? poolRelay.status >= 5 : r.connected
};
});
}
port.postMessage({
type: 'getDiscoveredRelaysResult',
requestId,
relays
});
} catch (err) {
console.warn('[Worker] getDiscoveredRelays failed:', err?.message || err);
port.postMessage({
type: 'getDiscoveredRelaysResult',
requestId,
relays: []
});
}
}
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 = (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,
pubkeys,
enabled
} = e.data;
switch(type) {
case 'init':
void handleInit(pubkey, port);
break;
case 'subscribe':
void handleSubscribe(subId, filters, opts, port);
break;
case 'unsubscribe':
handleUnsubscribe(subId);
break;
case 'publish':
void handlePublish(requestId, event, port);
break;
case 'publishRaw':
void handlePublishRaw(requestId, event, port);
break;
case 'publishRawToRelay':
void handlePublishRawToRelay(requestId, event, relayUrl, port);
break;
case 'sendNip17Message':
void handleSendNip17Message(requestId, recipientPubkey, content, port);
break;
case 'fetchEvents':
void handleFetchEvents(requestId, filters, relayUrl, port);
break;
case 'ndkFetchEvents':
void handleNdkFetchEvents(requestId, filters, port);
break;
case 'warmOutbox':
// Don't await — trackUsers involves network I/O to outbox pool
// relays and would block the entire message dispatch loop,
// starving getRelayData/getRelayStats messages.
void handleWarmOutbox(requestId, pubkeys, port);
break;
case 'setRelayEventLogging':
void handleSetRelayEventLogging(requestId, enabled, port);
break;
case 'getDiscoveredRelays':
// Don't await — run fire-and-forget so this doesn't block
// the message queue (getRelayData/getRelayStats must stay responsive).
void handleGetDiscoveredRelays(requestId, port);
break;
case 'getBroadcastRelays':
port.postMessage({
type: 'response',
requestId,
data: {
active: Array.from(broadcastRelayUrls),
skipped: Array.from(skippedRelayUrls.entries()).map(
([url, info]) => ({ url, ...info })
),
},
});
break;
case 'saveBroadcastRelays': {
const urlsToSave = Array.isArray(e.data.urls) ? e.data.urls : [];
void saveBroadcastRelaysInternal(urlsToSave)
.then((ok) => {
port.postMessage({
type: 'response',
requestId,
data: { success: !!ok },
});
})
.catch((error) => {
port.postMessage({
type: 'response',
requestId,
error: error?.message || String(error),
});
});
break;
}
case 'seedBroadcastRelays': {
// One-click "Add all discovered relays": union the discovered
// relays cache with the existing active r-tags, excluding any
// skip-marked relays, then persist.
try {
const discovered = Array.isArray(discoveredRelaysCache) ? discoveredRelaysCache : [];
const discoveredUrls = discovered
.map(r => r?.url)
.filter(Boolean);
const newUrls = discoveredUrls.filter(u =>
!broadcastRelayUrls.has(u) && !skippedRelayUrls.has(u)
);
if (newUrls.length === 0) {
port.postMessage({ type: 'response', requestId, data: { added: 0 } });
break;
}
for (const u of newUrls) broadcastRelayUrls.add(u);
void saveBroadcastRelaysInternal(Array.from(broadcastRelayUrls))
.then(() => {
port.postMessage({
type: 'response',
requestId,
data: { added: newUrls.length },
});
})
.catch((error) => {
port.postMessage({
type: 'response',
requestId,
error: error?.message || String(error),
});
});
} catch (error) {
port.postMessage({
type: 'response',
requestId,
error: error?.message || String(error),
});
}
break;
}
case 'unskipBroadcastRelay': {
const unskipUrl = e.data.url;
if (unskipUrl) {
skippedRelayUrls.delete(unskipUrl);
// No need to add to broadcastRelayUrls — skipped relays stay
// in broadcastRelayUrls, just filtered out by getActiveBroadcastUrls.
void saveBroadcastRelaysInternal(Array.from(broadcastRelayUrls))
.then(() => {
broadcast({ type: 'broadcastRelaysUpdated' });
port.postMessage({
type: 'response',
requestId,
data: { success: true },
});
})
.catch((error) => {
port.postMessage({
type: 'response',
requestId,
error: error?.message || String(error),
});
});
} else {
port.postMessage({
type: 'response',
requestId,
error: 'Missing url for unskipBroadcastRelay',
});
}
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':
void handleQueryCache(requestId, filters, port);
break;
case 'purgePlaylistCache':
void handlePurgePlaylistCache(requestId, playlistIdentifier, ownerPubkey, playlistKind, eventIds, port);
break;
case 'syncMuteList':
void loadWorkerMuteList(currentPubkey, { reason: 'sync-request' })
.then((result) => {
port.postMessage({ type: 'response', requestId, data: { success: true, result } });
})
.catch((error) => {
port.postMessage({ type: 'response', requestId, error: error?.message || String(error) });
});
break;
case 'fetchCachedProfile':
void handleFetchCachedProfile(requestId, e.data.pubkey, port);
break;
case 'storeProfile':
void handleStoreProfile(e.data.pubkey, e.data.profile);
break;
case 'getAllCachedProfiles':
void handleGetAllCachedProfiles(requestId, port);
break;
case 'getUserSettings':
void handleGetUserSettings(requestId, port);
break;
case 'patchUserSettings':
void handlePatchUserSettings(requestId, patch, options, port);
break;
case 'setUserSettings':
void handleSetUserSettings(requestId, settings, options, port);
break;
case 'resetUserSettings':
void handleResetUserSettings(requestId, options, port);
break;
case 'encryptContent':
void handleEncryptContent(requestId, e.data.recipientPubkey, e.data.plaintext, e.data.scheme, port);
break;
case 'decryptContent':
void handleDecryptContent(requestId, e.data.senderPubkey, e.data.ciphertext, e.data.scheme, port);
break;
case 'walletPublishMintList':
void handleWalletPublishMintList(requestId, relays, receiveMints, port);
break;
case 'walletFetchMintList':
void handleWalletFetchMintList(requestId, targetPubkey, port);
break;
case 'walletInit':
void handleWalletInit(requestId, port);
break;
case 'walletStart':
void handleWalletStart(requestId, pubkey, port);
break;
case 'walletCreate':
void handleWalletCreate(requestId, mints, relays, port);
break;
case 'walletHasWallet':
void handleWalletHasWallet(requestId, port);
break;
case 'walletGetBalance':
void handleWalletGetBalance(requestId, port);
break;
case 'walletGetMints':
void handleWalletGetMints(requestId, port);
break;
case 'walletReceiveToken':
void handleWalletReceiveToken(requestId, token, description, port);
break;
case 'walletSendToken':
void handleWalletSendToken(requestId, amount, memo, mint, port);
break;
case 'walletSendNutzap':
void handleWalletSendNutzap(
requestId,
amount,
memo,
targetPubkey,
eventId,
mint,
recipientMints,
recipientP2pk,
recipientRelays,
port
);
break;
case 'walletCreateDeposit':
void handleWalletCreateDeposit(requestId, amount, mint, port);
break;
case 'walletPayInvoice':
void handleWalletPayInvoice(requestId, invoice, mint, port);
break;
case 'walletAddMint':
void handleWalletAddMint(requestId, mintUrl, port);
break;
case 'walletRemoveMint':
void handleWalletRemoveMint(requestId, mintUrl, port);
break;
case 'walletGetTransactions':
void handleWalletGetTransactions(requestId, port);
break;
case 'walletShutdown':
handleWalletShutdown(requestId, port);
break;
case 'walletCheckProofs':
void handleWalletCheckProofs(requestId, port);
break;
case 'walletRepublish':
void 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');