feat(wallet): external tx-explorer fallback with consent + On-chain settings tab

Pruned nodes can't run the Mempool app, but tx links blindly opened it
anyway. Now: local app when running; otherwise an external explorer
(default tx1138.com) behind a one-time amber consent modal that spells
out what the other server's operator learns (tx of interest + IP) and
lets the user point at their own instance (placeholder mempool.guide).
Wallet Settings gains an On-chain tab (explorer URL + don't-warn toggle);
tabs renamed Cashu/Fedi so five fit in the row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-22 17:31:48 -04:00
co-authored by Claude Fable 5
parent 5e7e928650
commit c0aef2f03e
6 changed files with 241 additions and 9 deletions
+106
View File
@@ -0,0 +1,106 @@
// Shared "open this transaction in an explorer" logic (2026-07-22).
//
// Nodes with a PRUNED bitcoin node can't run the Mempool app at all, but the
// wallet UI's tx links used to blindly open the local app anyway (blank app
// frame). Routing:
// - local Mempool app running → open it, exactly as before
// - otherwise → an EXTERNAL explorer, after a one-time
// consent modal: viewing a tx on someone else's mempool tells that
// server's operator which transaction you're interested in (plus your
// IP), so the user must knowingly opt in and may point the link at
// their own trusted instance instead.
//
// Preference + acknowledgement persist per browser in localStorage
// (`archipelago.tx-explorer.v1`); the Settings → System section edits the
// same values.
import { ref } from 'vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useContainerStore } from '@/stores/container'
export const DEFAULT_TX_EXPLORER = 'https://tx1138.com'
export const EXPLORER_PLACEHOLDER = 'https://mempool.guide'
const KEY = 'archipelago.tx-explorer.v1'
interface TxExplorerPrefs {
url: string
acknowledged: boolean
}
function loadPrefs(): TxExplorerPrefs {
const defaults: TxExplorerPrefs = { url: DEFAULT_TX_EXPLORER, acknowledged: false }
try {
return { ...defaults, ...JSON.parse(localStorage.getItem(KEY) || '{}') }
} catch {
return defaults
}
}
// Module-scope state: one source of truth shared by every caller, the global
// consent modal, and the Settings section.
const prefs = ref<TxExplorerPrefs>(loadPrefs())
/** Tx hash awaiting user consent — non-null shows ExternalExplorerModal. */
const pendingTx = ref<string | null>(null)
function savePrefs() {
localStorage.setItem(KEY, JSON.stringify(prefs.value))
}
export function useTxExplorer() {
const launcher = useAppLauncherStore()
const containers = useContainerStore()
/** Normalized explorer base URL (no trailing slash). */
function explorerUrl(): string {
return (prefs.value.url || DEFAULT_TX_EXPLORER).trim().replace(/\/+$/, '')
}
function openExternal(txHash: string) {
window.open(`${explorerUrl()}/tx/${txHash}`, '_blank', 'noopener,noreferrer')
}
/** Entry point for every "view transaction" affordance in the app. */
function openTx(txHash: string) {
if (containers.getAppState('mempool') === 'running') {
launcher.openSession('mempool', { path: `/tx/${txHash}` })
return
}
if (prefs.value.acknowledged) {
openExternal(txHash)
return
}
pendingTx.value = txHash
}
/** Consent modal confirm: persist the (possibly edited) URL + ack, open. */
function confirmPending(url: string, dontAskAgain: boolean) {
const trimmed = url.trim()
prefs.value.url = trimmed || DEFAULT_TX_EXPLORER
if (dontAskAgain) prefs.value.acknowledged = true
savePrefs()
const tx = pendingTx.value
pendingTx.value = null
if (tx) openExternal(tx)
}
function cancelPending() {
pendingTx.value = null
}
/** Settings hook: change the explorer and/or reset the consent. */
function setExplorer(url: string, acknowledged?: boolean) {
prefs.value.url = url.trim() || DEFAULT_TX_EXPLORER
if (acknowledged !== undefined) prefs.value.acknowledged = acknowledged
savePrefs()
}
return {
prefs,
pendingTx,
explorerUrl,
openTx,
confirmPending,
cancelPending,
setExplorer,
}
}