7872 lines
293 KiB
JavaScript
7872 lines
293 KiB
JavaScript
/**
|
|
* NDK SharedWorker
|
|
|
|
* Manages NDK instance with SQLite WASM cache across multiple tabs
|
|
* Handles user authentication, profile fetching, and relay management
|
|
*/
|
|
|
|
// Fetch version from JSON file and log when ready
|
|
let WORKER_VERSION = 'unknown';
|
|
(async () => {
|
|
try {
|
|
const response = await fetch('./js/version.json');
|
|
const versionData = await response.json();
|
|
WORKER_VERSION = versionData.VERSION_NUMBER || 'unknown';
|
|
console.log(`[Worker v${WORKER_VERSION}] SharedWorker initializing...`);
|
|
} catch (e) {
|
|
console.warn('[Worker] Could not load version:', e);
|
|
console.log(`[Worker v${WORKER_VERSION}] SharedWorker initializing...`);
|
|
}
|
|
})();
|
|
|
|
// Import scripts from same directory (relative paths)
|
|
// Only import if not already loaded (prevents double-loading in SharedWorker)
|
|
if (typeof self.NostrTools === 'undefined') {
|
|
importScripts('./nostr.bundle.js');
|
|
console.log('[Worker] nostr.bundle.js loaded, NostrTools available:', typeof self.NostrTools !== 'undefined');
|
|
}
|
|
|
|
if (typeof self.NDK === 'undefined') {
|
|
// Make sure NostrTools is available as window.NostrTools for the NDK bundle
|
|
if (typeof window === 'undefined') {
|
|
self.window = self;
|
|
}
|
|
importScripts('./ndk-core.bundle.js');
|
|
console.log('[Worker] ndk-core.bundle.js loaded, NDK available:', typeof self.NDK !== 'undefined');
|
|
}
|
|
|
|
// Get NDK exports from the bundle
|
|
const NDKExports = self.NDK || window.NDK;
|
|
|
|
// Log what's available in the NDK export
|
|
console.log('[Worker] NDK exports available:', Object.keys(NDKExports || {}));
|
|
console.log('[Worker] NDK exports type:', typeof NDKExports);
|
|
console.log('[Worker] NDK exports.default:', typeof NDKExports?.default);
|
|
|
|
// The bundle might export NDK as default or as a named export
|
|
// Try default first, then look for a class that looks like the main NDK class
|
|
let NDKConstructor = NDKExports?.default;
|
|
|
|
// If no default, the bundle itself might be the constructor (IIFE pattern)
|
|
if (!NDKConstructor && typeof NDKExports === 'function') {
|
|
NDKConstructor = NDKExports;
|
|
}
|
|
|
|
// Get other classes - check multiple possible locations
|
|
const NDKCacheBrowser = NDKExports?.NDKCacheBrowser || NDKExports?.default?.NDKCacheBrowser;
|
|
const NDKCacheAdapterDexie = NDKExports?.NDKCacheAdapterDexie || NDKExports?.default?.NDKCacheAdapterDexie;
|
|
const NDKRelaySet = NDKExports?.NDKRelaySet;
|
|
const NDKRelayAuthPolicies = NDKExports?.NDKRelayAuthPolicies || NDKExports?.default?.NDKRelayAuthPolicies;
|
|
const NDKKind = NDKExports?.NDKKind || NDKExports?.default?.NDKKind;
|
|
const NIP17Protocol = NDKExports?.NIP17Protocol || NDKExports?.default?.NIP17Protocol;
|
|
const NDKEventCtor = NDKExports?.NDKEvent || NDKExports?.default?.NDKEvent;
|
|
const NDKUserCtor = NDKExports?.NDKUser || NDKExports?.default?.NDKUser;
|
|
const ndkGiftWrap = NDKExports?.giftWrap || NDKExports?.default?.giftWrap;
|
|
const NDKCashuMintList = NDKExports?.NDKCashuMintList || NDKExports?.default?.NDKCashuMintList;
|
|
const CashuMint = NDKExports?.CashuMint || NDKExports?.default?.CashuMint;
|
|
const CashuWallet = NDKExports?.CashuWallet || NDKExports?.default?.CashuWallet;
|
|
const getEncodedTokenV4 = NDKExports?.getEncodedTokenV4 || NDKExports?.default?.getEncodedTokenV4;
|
|
const getDecodedToken = NDKExports?.getDecodedToken || NDKExports?.default?.getDecodedToken;
|
|
|
|
const nostrTools = self.NostrTools || window.NostrTools || {};
|
|
const nostrGenerateSecretKey = nostrTools.generateSecretKey;
|
|
const nostrGetPublicKey = nostrTools.getPublicKey;
|
|
|
|
console.log('[Worker] NDK constructor:', typeof NDKConstructor);
|
|
console.log('[Worker] NDKCacheBrowser:', typeof NDKCacheBrowser);
|
|
console.log('[Worker] NDKCacheAdapterDexie:', typeof NDKCacheAdapterDexie);
|
|
console.log('[Worker] NDKRelayAuthPolicies:', typeof NDKRelayAuthPolicies, NDKRelayAuthPolicies ? Object.keys(NDKRelayAuthPolicies) : 'not found');
|
|
console.log('[Worker] Checking for cache-related exports...');
|
|
const cacheExports = Object.keys(NDKExports || {}).filter(k => k.toLowerCase().includes('cache'));
|
|
console.log('[Worker] Cache-related exports:', cacheExports);
|
|
|
|
if (!NDKConstructor) {
|
|
console.error('[Worker] Available exports:', NDKExports);
|
|
throw new Error('NDK constructor not found in bundle exports');
|
|
}
|
|
|
|
if (!NDKCacheBrowser) {
|
|
console.warn('[Worker] NDKCacheBrowser not found in bundle, will use NDK without cache');
|
|
}
|
|
|
|
// Bootstrap relays (used initially to fetch user data)
|
|
const BOOTSTRAP_RELAYS = [
|
|
'wss://relay.laantungir.net',
|
|
'wss://relay.damus.io',
|
|
'wss://nos.lol',
|
|
'wss://relay.primal.net'
|
|
];
|
|
|
|
const HAS_INDEXEDDB = typeof indexedDB !== 'undefined' && !!indexedDB;
|
|
let INDEXEDDB_USABLE = HAS_INDEXEDDB;
|
|
if (!HAS_INDEXEDDB) {
|
|
console.error('[Worker] IndexedDB API unavailable; persistent cache/storage features will be disabled');
|
|
}
|
|
|
|
const IDB_DIAG_TIMEOUT_MS = 5000;
|
|
|
|
function formatIdbError(error) {
|
|
if (!error) return 'unknown error';
|
|
if (typeof error === 'string') return error;
|
|
if (error?.name || error?.message) {
|
|
return `${error.name || 'Error'}: ${error.message || 'no message'}`;
|
|
}
|
|
try {
|
|
return JSON.stringify(error);
|
|
} catch (_err) {
|
|
return String(error);
|
|
}
|
|
}
|
|
|
|
function runIndexedDbOpenDiagnostic(dbName, version) {
|
|
return new Promise((resolve) => {
|
|
const startedAt = Date.now();
|
|
|
|
if (!HAS_INDEXEDDB) {
|
|
resolve({
|
|
ok: false,
|
|
dbName,
|
|
phase: 'api-missing',
|
|
elapsedMs: 0,
|
|
details: 'indexedDB global is unavailable'
|
|
});
|
|
return;
|
|
}
|
|
|
|
let settled = false;
|
|
let req;
|
|
const timeoutId = setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve({
|
|
ok: false,
|
|
dbName,
|
|
phase: 'timeout',
|
|
elapsedMs: Date.now() - startedAt,
|
|
details: `indexedDB.open did not resolve within ${IDB_DIAG_TIMEOUT_MS}ms`
|
|
});
|
|
}, IDB_DIAG_TIMEOUT_MS);
|
|
|
|
const finish = (payload) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timeoutId);
|
|
resolve({
|
|
dbName,
|
|
elapsedMs: Date.now() - startedAt,
|
|
...payload
|
|
});
|
|
};
|
|
|
|
try {
|
|
req = version === undefined ? indexedDB.open(dbName) : indexedDB.open(dbName, version);
|
|
} catch (openErr) {
|
|
finish({
|
|
ok: false,
|
|
phase: 'open-throw',
|
|
details: formatIdbError(openErr)
|
|
});
|
|
return;
|
|
}
|
|
|
|
req.onblocked = () => {
|
|
console.warn('[Worker] IndexedDB diagnostic blocked event', {
|
|
dbName,
|
|
oldVersion: req?.result?.version,
|
|
targetVersion: version ?? 'default'
|
|
});
|
|
};
|
|
|
|
req.onupgradeneeded = () => {
|
|
const db = req.result;
|
|
console.log('[Worker] IndexedDB diagnostic upgrade needed', {
|
|
dbName,
|
|
oldVersion: req.oldVersion,
|
|
newVersion: req.newVersion,
|
|
actualVersion: db?.version
|
|
});
|
|
};
|
|
|
|
req.onerror = () => {
|
|
finish({
|
|
ok: false,
|
|
phase: 'error',
|
|
details: formatIdbError(req.error)
|
|
});
|
|
};
|
|
|
|
req.onsuccess = () => {
|
|
const db = req.result;
|
|
const objectStores = [];
|
|
try {
|
|
for (let i = 0; i < db.objectStoreNames.length; i += 1) {
|
|
objectStores.push(db.objectStoreNames[i]);
|
|
}
|
|
} catch (_err) {
|
|
// Ignore objectStoreNames read issues in diagnostics
|
|
}
|
|
|
|
const dbVersion = db?.version;
|
|
try {
|
|
db.onversionchange = () => {
|
|
console.warn('[Worker] IndexedDB diagnostic connection got versionchange, closing', {
|
|
dbName,
|
|
dbVersion
|
|
});
|
|
db.close();
|
|
};
|
|
db.close();
|
|
} catch (_closeErr) {
|
|
// ignore close errors in diagnostics
|
|
}
|
|
|
|
finish({
|
|
ok: true,
|
|
phase: 'success',
|
|
details: `version=${dbVersion}, stores=${objectStores.join(',') || '(none)'}`
|
|
});
|
|
};
|
|
});
|
|
}
|
|
|
|
async function assertIndexedDbReadyOrThrow() {
|
|
if (!HAS_INDEXEDDB) {
|
|
throw new Error('[Worker] IndexedDB missing in this worker context');
|
|
}
|
|
|
|
const checks = [
|
|
{ name: 'ndk-shared' }
|
|
];
|
|
|
|
const results = [];
|
|
for (const check of checks) {
|
|
const result = await runIndexedDbOpenDiagnostic(check.name, check.version);
|
|
results.push(result);
|
|
console.log('[Worker] IndexedDB diagnostic result', result);
|
|
}
|
|
|
|
const failed = results.filter(r => !r.ok);
|
|
if (failed.length > 0) {
|
|
const details = failed.map(f => `${f.dbName}:${f.phase}:${f.details}`).join(' | ');
|
|
throw new Error(`[Worker] IndexedDB diagnostics failed: ${details}`);
|
|
}
|
|
}
|
|
|
|
// Worker state
|
|
let ndk = null;
|
|
let cacheAdapter = null;
|
|
let currentPubkey = null;
|
|
let cachedProfile = null;
|
|
let connectedPorts = [];
|
|
let activeSigningPort = null;
|
|
let activeSubscriptions = new Map(); // subId -> { sub, filters, opts, seenEventIds, lastEventCreatedAt }
|
|
let pendingSignRequests = new Map(); // requestId -> { resolve, reject, port }
|
|
let signRequestCounter = 0;
|
|
|
|
// Direct cashu-ts wallet state (shared across all pages/tabs)
|
|
let directWalletStatus = 'initial';
|
|
let directWalletLoaded = false;
|
|
let directMintList = [];
|
|
let directProofStore = {}; // mintUrl -> Proof[]
|
|
let directTransactions = [];
|
|
const directMintLocalUpdatedAt = new Map(); // mintUrl -> unix sec for local proof mutations
|
|
const pendingDeposits = new Map(); // quoteId -> { mintUrl, amount, timerId, startedAt }
|
|
const pendingSends = new Map(); // sendId -> { mintUrl, proofs, amount, memo, timerId, startedAt, txId }
|
|
const directWalletCache = new Map(); // mintUrl -> CashuWallet
|
|
let directNutzapPrivateKeyHex = null;
|
|
let directNutzapP2pk = null;
|
|
|
|
// Startup wallet event subscriptions (phase 1 monitoring)
|
|
let startupWalletSub = null;
|
|
let startupWalletSubPubkey = null;
|
|
let startupDownloadsPromise = null;
|
|
let startupDownloadsPubkey = null;
|
|
let startupDownloadsCompletedForPubkey = null;
|
|
let initCompletedPromise = null;
|
|
const STARTUP_DOWNLOADS_TIMEOUT_MS = 12000;
|
|
|
|
// Lightweight wallet event cache (used to hydrate direct proof store)
|
|
let lightwalletTokenEvents = new Map(); // eventId -> { mint, unit, proofTotal, proofs }
|
|
let lightwalletDeletedIds = new Set(); // eventIds deleted via kind 5 or token del fields
|
|
let lightwalletHasWallet = false;
|
|
|
|
// Spending history (kind 7376) cache
|
|
let spendingHistoryLastFetchAtMs = 0;
|
|
let spendingHistoryFetchPromise = null;
|
|
|
|
// Nutzap mint list (kind 10019) cache for followed users — proactively
|
|
// fetched on startup so resolveZapCapabilities() can hit IDB instead of
|
|
// going to relays for every followed author.
|
|
const NUTZAP_MINTLIST_DB_NAME = 'ndk-nutzap-mintlists';
|
|
const NUTZAP_MINTLIST_DB_VERSION = 1;
|
|
const NUTZAP_MINTLIST_STORE = 'mintlists'; // keyPath: pubkey
|
|
const NUTZAP_MINTLIST_PREFETCH_BATCH = 50; // max authors per fetchEvents call
|
|
const NUTZAP_MINTLIST_PREFETCH_TTL_MS = 6 * 60 * 60 * 1000; // 6h freshness guard
|
|
|
|
// Relay activity tracking
|
|
// 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);
|
|
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).
|
|
// When broadcasting to many relays (potentially hundreds), use a much longer
|
|
// timeout than NDK's default 4400ms — temporary relays need time to establish
|
|
// WebSocket connections. Use 30s for broadcasts, default for normal publishes.
|
|
// requiredRelayCount=1 so NDK doesn't throw if only 1 of 600 relays succeeds.
|
|
const BROADCAST_TIMEOUT_MS = 30000;
|
|
const publishTimeoutMs = isBroadcast ? BROADCAST_TIMEOUT_MS : undefined;
|
|
const publishRequiredCount = isBroadcast ? 1 : undefined;
|
|
|
|
let relaySet;
|
|
try {
|
|
relaySet = await ndkEvent.publish(targetRelaySet, publishTimeoutMs, publishRequiredCount);
|
|
} catch (publishError) {
|
|
// NDK throws NDKPublishError if requiredRelayCount isn't met. With
|
|
// requiredRelayCount=1 this shouldn't happen for broadcasts, but handle
|
|
// it gracefully — the live progress listeners already captured whatever
|
|
// succeeded. Log and continue so we still report results to the page.
|
|
console.warn('[Worker] publish() threw (some relays may still have succeeded):', 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,
|
|
});
|
|
// 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;
|
|
broadcast({
|
|
type: 'broadcastProgress',
|
|
sessionId: publishSessionId,
|
|
eventId: ndkEvent.id || null,
|
|
eventKind: event.kind,
|
|
phase: 'done',
|
|
total: successCount,
|
|
successful: successCount,
|
|
failed: 0,
|
|
latestRelay: null,
|
|
});
|
|
}
|
|
|
|
// 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');
|