diff --git a/src/components/AdvancedSettings.tsx b/src/components/AdvancedSettings.tsx index a5d2e6bd..cc42dcc1 100644 --- a/src/components/AdvancedSettings.tsx +++ b/src/components/AdvancedSettings.tsx @@ -20,6 +20,26 @@ const DEFAULT_AI_MODEL = 'google/gemma-4-26b'; /** Build-time default translation worker URL from the environment variable. */ const DEFAULT_TRANSLATE_WORKER_URL = import.meta.env.VITE_TRANSLATE_WORKER_URL || ''; +/** Hardcoded defaults for the Bitcoin backend fields. Used for reset buttons. */ +const DEFAULT_ESPLORA_APIS = [ + 'https://mempool.emzy.de/api', + 'https://mempool.space/api', + 'https://blockstream.info/api', +]; +const DEFAULT_BLOCKBOOK_BASE_URL = 'https://btc.trezor.io'; +const DEFAULT_BIP352_INDEXER_URL = 'https://silentpayments.dev/blindbit/mainnet'; + +/** Validate an http(s) URL with no trailing slash. */ +function isValidEndpoint(url: string): boolean { + if (!/^https?:\/\//i.test(url)) return false; + try { + new URL(url); + return true; + } catch { + return false; + } +} + /** The build-time default DSN from the environment variable. */ const DEFAULT_SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN || ''; @@ -31,6 +51,7 @@ export function AdvancedSettings() { const [systemOpen, setSystemOpen] = useState(true); const [aiOpen, setAiOpen] = useState(false); const [sentryOpen, setSentryOpen] = useState(false); + const [bitcoinOpen, setBitcoinOpen] = useState(false); const [dangerOpen, setDangerOpen] = useState(false); const [vanishDialogOpen, setVanishDialogOpen] = useState(false); const [statsPubkey, setStatsPubkey] = useState(config.nip85StatsPubkey); @@ -45,6 +66,15 @@ export function AdvancedSettings() { const [showApiKey, setShowApiKey] = useState(false); const [systemPromptDraft, setSystemPromptDraft] = useState(config.aiSystemPrompt || DEFAULT_SYSTEM_PROMPT_TEMPLATE); + // Bitcoin backend drafts. `esploraApis` is an ordered array edited as one URL per line. + const [esploraApisDraft, setEsploraApisDraft] = useState(config.esploraApis.join('\n')); + const [blockbookDraft, setBlockbookDraft] = useState(config.blockbookBaseUrl); + const [bip352Draft, setBip352Draft] = useState(config.bip352IndexerUrl); + + useEffect(() => { setEsploraApisDraft(config.esploraApis.join('\n')); }, [config.esploraApis]); + useEffect(() => { setBlockbookDraft(config.blockbookBaseUrl); }, [config.blockbookBaseUrl]); + useEffect(() => { setBip352Draft(config.bip352IndexerUrl); }, [config.bip352IndexerUrl]); + useEffect(() => { setBaseUrlDraft(config.aiBaseURL); }, [config.aiBaseURL]); useEffect(() => { setApiKeyDraft(config.aiApiKey); }, [config.aiApiKey]); useEffect(() => { setModelDraft(config.aiModel); }, [config.aiModel]); @@ -118,6 +148,67 @@ export function AdvancedSettings() { } }; + const commitEsploraApis = () => { + const urls = esploraApisDraft + .split('\n') + .map((line) => line.trim().replace(/\/+$/, '')) + .filter(Boolean); + if (urls.length === 0) { + setEsploraApisDraft(DEFAULT_ESPLORA_APIS.join('\n')); + updateConfig((current) => ({ ...current, esploraApis: DEFAULT_ESPLORA_APIS })); + toast({ title: 'Esplora endpoints reset to defaults' }); + return; + } + const invalid = urls.find((url) => !isValidEndpoint(url)); + if (invalid) { + toast({ title: 'Invalid Esplora endpoint', description: invalid, variant: 'destructive' }); + return; + } + const changed = + urls.length !== config.esploraApis.length || + urls.some((url, i) => url !== config.esploraApis[i]); + if (changed) { + updateConfig((current) => ({ ...current, esploraApis: urls })); + toast({ title: 'Esplora endpoints updated' }); + } + // Normalize the textarea to the cleaned list. + setEsploraApisDraft(urls.join('\n')); + }; + + const commitBlockbook = () => { + const trimmed = blockbookDraft.trim().replace(/\/+$/, ''); + if (!trimmed) { + setBlockbookDraft(DEFAULT_BLOCKBOOK_BASE_URL); + if (config.blockbookBaseUrl !== DEFAULT_BLOCKBOOK_BASE_URL) { + updateConfig((current) => ({ ...current, blockbookBaseUrl: DEFAULT_BLOCKBOOK_BASE_URL })); + toast({ title: 'Blockbook URL reset to default' }); + } + return; + } + if (!isValidEndpoint(trimmed)) { + toast({ title: 'Invalid Blockbook URL', variant: 'destructive' }); + return; + } + if (trimmed !== config.blockbookBaseUrl) { + updateConfig((current) => ({ ...current, blockbookBaseUrl: trimmed })); + toast({ title: 'Blockbook URL updated' }); + } + setBlockbookDraft(trimmed); + }; + + const commitBip352 = () => { + const trimmed = bip352Draft.trim().replace(/\/+$/, ''); + if (trimmed && !isValidEndpoint(trimmed)) { + toast({ title: 'Invalid indexer URL', variant: 'destructive' }); + return; + } + if (trimmed !== config.bip352IndexerUrl) { + updateConfig((current) => ({ ...current, bip352IndexerUrl: trimmed })); + toast({ title: trimmed ? 'Silent-payment indexer updated' : 'Silent-payment scanning disabled' }); + } + setBip352Draft(trimmed); + }; + return (
{/* Agent Section */} @@ -442,6 +533,143 @@ export function AdvancedSettings() {
+ {/* Bitcoin Section */} +
+ + + + + +
+ + {/* Esplora API endpoints */} +
+
+ + {esploraApisDraft.trim() !== DEFAULT_ESPLORA_APIS.join('\n') && ( + + )} +
+

+ Esplora-compatible REST roots used for on-chain zaps, donations, and Bitcoin address/tx pages. One URL per line, no trailing slash. Tried in order with failover when an endpoint is rate-limited or down. +

+