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
+2
View File
@@ -39,6 +39,7 @@
<!-- Global "mesh device detected" setup flow (fires on any page) -->
<MeshDeviceSetupModal />
<ExternalExplorerModal />
<!-- Nudge to back up the Lightning seed once a wallet exists (any page) -->
<LndSeedBackupPrompt />
@@ -95,6 +96,7 @@ import Screensaver from './components/Screensaver.vue'
import HelpGuideModal from './components/HelpGuideModal.vue'
import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue'
import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue'
import ExternalExplorerModal from './components/ExternalExplorerModal.vue'
import LndSeedBackupPrompt from './components/LndSeedBackupPrompt.vue'
import { useMeshStore } from './stores/mesh'
@@ -0,0 +1,71 @@
<template>
<BaseModal
:show="!!explorer.pendingTx.value"
title="Open on an external explorer?"
max-width="max-w-md"
@close="explorer.cancelPending()"
>
<!-- Same visual language as the uninstall keep-your-data warning: amber
caution card, plain words about exactly what is shared, explicit
choice never a silent hand-off to a third-party server. -->
<div class="p-3 rounded-lg border border-amber-400/25 bg-amber-500/10 text-amber-200/90 text-sm">
<p class="font-medium mb-1"> This node doesn't run its own Mempool explorer</p>
<p class="text-amber-200/75 text-xs leading-relaxed">
(Pruned Bitcoin nodes can't index the full chain.) Your transaction will open on
<span class="font-medium">another node's mempool</span> — that server's operator can see
which transaction you looked up, along with your IP address. Use a server you trust,
or your own mempool instance if you have one elsewhere.
</p>
</div>
<div class="mt-4">
<label class="block text-sm text-white/80 mb-1">Explorer to use</label>
<input
v-model="url"
:placeholder="EXPLORER_PLACEHOLDER"
spellcheck="false"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm font-mono focus:outline-none focus:border-orange-400/60"
/>
<p class="text-[11px] text-white/40 mt-1">
Defaults to tx1138.com. Any Mempool-compatible instance works you can change this
any time in Settings System.
</p>
</div>
<label class="flex items-center gap-2 mt-4 text-sm text-white/70 cursor-pointer select-none">
<input type="checkbox" v-model="dontAskAgain" class="accent-orange-400" />
Remember my choice and don't ask again
</label>
<div class="flex gap-2 mt-6">
<button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="explorer.cancelPending()">
Cancel
</button>
<button
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
@click="explorer.confirmPending(url, dontAskAgain)"
>
Open Explorer
</button>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import BaseModal from '@/components/BaseModal.vue'
import { useTxExplorer, EXPLORER_PLACEHOLDER } from '@/composables/useTxExplorer'
const explorer = useTxExplorer()
const url = ref(explorer.prefs.value.url)
const dontAskAgain = ref(false)
// Re-seed the input each time the modal opens (prefs may have changed in
// Settings since the last time).
watch(explorer.pendingTx, (tx) => {
if (tx) {
url.value = explorer.prefs.value.url
dontAskAgain.value = false
}
})
</script>
@@ -100,7 +100,7 @@
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseModal from '@/components/BaseModal.vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useTxExplorer } from '@/composables/useTxExplorer'
interface WalletTransaction {
tx_hash: string
@@ -167,10 +167,12 @@ function kindLabel(tx: WalletTransaction): string {
return ''
}
const txExplorer = useTxExplorer()
function openInMempool(tx: WalletTransaction) {
if (!isOnchain(tx)) return
// Overlay the explorer above the current page — never navigate away.
useAppLauncherStore().openSession('mempool', { path: `/tx/${tx.tx_hash}` })
// Local Mempool app when running (overlaid above this modal); external
// explorer with consent otherwise (pruned nodes can't run Mempool).
txExplorer.openTx(tx.tx_hash)
}
function formatTxTime(timestamp: number): string {
@@ -149,6 +149,41 @@
</p>
</div>
<!-- ===================== On-chain ===================== -->
<div v-show="activeTab === 'onchain'">
<p class="text-white/60 text-sm mb-4">
Where "view transaction" links open. When the Mempool app is installed and running
on this node, it is always used — private, no third parties. Without it (pruned
Bitcoin nodes can't run Mempool), links open on the explorer below.
</p>
<label class="block text-sm text-white/80 mb-1">Transaction explorer</label>
<input
v-model="explorerUrlInput"
:placeholder="EXPLORER_PLACEHOLDER"
spellcheck="false"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm font-mono focus:outline-none focus:border-orange-400/60"
@change="saveExplorer"
/>
<p class="text-[11px] text-white/40 mt-1">
Any Mempool-compatible instance works. Default: tx1138.com.
</p>
<div class="mt-3 p-3 rounded-lg border border-amber-400/25 bg-amber-500/10 text-amber-200/80 text-xs leading-relaxed">
Opening a transaction on an external explorer tells that server's operator which
transaction you're interested in, plus your IP address. Use a server you trust.
</div>
<label class="flex items-center gap-2 mt-4 text-sm text-white/70 cursor-pointer select-none">
<input type="checkbox" v-model="explorerAcknowledged" class="accent-orange-400" @change="saveExplorer" />
Don't warn me each time before opening the external explorer
</label>
<div class="flex gap-3 mt-6">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
</div>
</div>
<!-- ===================== Ark ===================== -->
<div v-show="activeTab === 'ark'">
<div class="flex items-start gap-2 mb-4">
@@ -253,24 +288,38 @@ import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
import LightningChannelsPanel from '@/components/LightningChannelsPanel.vue'
import { useTxExplorer, EXPLORER_PLACEHOLDER } from '@/composables/useTxExplorer'
const { t } = useI18n()
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; changed: [] }>()
// Short labels on purpose — five tabs share one row ("Fedi", not
// "Fedimint Federations") so On-chain fits.
const tabs = [
{ key: 'channels' as const, label: 'Channels' },
{ key: 'cashu' as const, label: 'Cashu Mints' },
{ key: 'fedimint' as const, label: 'Fedimint Federations' },
{ key: 'cashu' as const, label: 'Cashu' },
{ key: 'fedimint' as const, label: 'Fedi' },
{ key: 'ark' as const, label: 'Ark' },
{ key: 'onchain' as const, label: 'On-chain' },
]
const activeTab = ref<'channels' | 'cashu' | 'fedimint' | 'ark'>('channels')
const activeTab = ref<'channels' | 'cashu' | 'fedimint' | 'ark' | 'onchain'>('channels')
// Backed by wallet.fedimint-list / -join / -leave (fedimint-clientd HTTP bridge).
// Join degrades gracefully with a clear error if the Fedimint client app isn't installed.
const fedimintBackendReady = true
// ---- On-chain: external transaction explorer ----
const txExplorer = useTxExplorer()
const explorerUrlInput = ref(txExplorer.prefs.value.url)
const explorerAcknowledged = ref(txExplorer.prefs.value.acknowledged)
function saveExplorer() {
txExplorer.setExplorer(explorerUrlInput.value, explorerAcknowledged.value)
// Reflect normalization (empty input falls back to the default).
explorerUrlInput.value = txExplorer.prefs.value.url
}
// ---- Cashu mints ----
const mints = ref<string[]>([])
const newMint = ref('')
+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,
}
}
+5 -3
View File
@@ -151,7 +151,7 @@ import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatTxTime } from './utils'
import type { WalletTransaction } from './types'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useTxExplorer } from '@/composables/useTxExplorer'
const { t } = useI18n()
@@ -177,8 +177,10 @@ defineEmits<{
openReceive: []
}>()
const txExplorer = useTxExplorer()
function openInMempool(txHash: string) {
// Overlay the explorer above the current page — never navigate away.
useAppLauncherStore().openSession('mempool', { path: `/tx/${txHash}` })
// Local Mempool app when running; external explorer with consent
// otherwise (pruned nodes can't run Mempool).
txExplorer.openTx(txHash)
}
</script>