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:30:47 -04:00
parent 5e7e928650
commit c0aef2f03e
6 changed files with 241 additions and 9 deletions

View File

@ -39,6 +39,7 @@
<!-- Global "mesh device detected" setup flow (fires on any page) --> <!-- Global "mesh device detected" setup flow (fires on any page) -->
<MeshDeviceSetupModal /> <MeshDeviceSetupModal />
<ExternalExplorerModal />
<!-- Nudge to back up the Lightning seed once a wallet exists (any page) --> <!-- Nudge to back up the Lightning seed once a wallet exists (any page) -->
<LndSeedBackupPrompt /> <LndSeedBackupPrompt />
@ -95,6 +96,7 @@ import Screensaver from './components/Screensaver.vue'
import HelpGuideModal from './components/HelpGuideModal.vue' import HelpGuideModal from './components/HelpGuideModal.vue'
import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue' import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue'
import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue' import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue'
import ExternalExplorerModal from './components/ExternalExplorerModal.vue'
import LndSeedBackupPrompt from './components/LndSeedBackupPrompt.vue' import LndSeedBackupPrompt from './components/LndSeedBackupPrompt.vue'
import { useMeshStore } from './stores/mesh' import { useMeshStore } from './stores/mesh'

View File

@ -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>

View File

@ -100,7 +100,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import BaseModal from '@/components/BaseModal.vue' import BaseModal from '@/components/BaseModal.vue'
import { useAppLauncherStore } from '@/stores/appLauncher' import { useTxExplorer } from '@/composables/useTxExplorer'
interface WalletTransaction { interface WalletTransaction {
tx_hash: string tx_hash: string
@ -167,10 +167,12 @@ function kindLabel(tx: WalletTransaction): string {
return '' return ''
} }
const txExplorer = useTxExplorer()
function openInMempool(tx: WalletTransaction) { function openInMempool(tx: WalletTransaction) {
if (!isOnchain(tx)) return if (!isOnchain(tx)) return
// Overlay the explorer above the current page never navigate away. // Local Mempool app when running (overlaid above this modal); external
useAppLauncherStore().openSession('mempool', { path: `/tx/${tx.tx_hash}` }) // explorer with consent otherwise (pruned nodes can't run Mempool).
txExplorer.openTx(tx.tx_hash)
} }
function formatTxTime(timestamp: number): string { function formatTxTime(timestamp: number): string {

View File

@ -149,6 +149,41 @@
</p> </p>
</div> </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 ===================== --> <!-- ===================== Ark ===================== -->
<div v-show="activeTab === 'ark'"> <div v-show="activeTab === 'ark'">
<div class="flex items-start gap-2 mb-4"> <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 { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue' import BaseModal from '@/components/BaseModal.vue'
import LightningChannelsPanel from '@/components/LightningChannelsPanel.vue' import LightningChannelsPanel from '@/components/LightningChannelsPanel.vue'
import { useTxExplorer, EXPLORER_PLACEHOLDER } from '@/composables/useTxExplorer'
const { t } = useI18n() const { t } = useI18n()
const props = defineProps<{ show: boolean }>() const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; changed: [] }>() const emit = defineEmits<{ close: []; changed: [] }>()
// Short labels on purpose five tabs share one row ("Fedi", not
// "Fedimint Federations") so On-chain fits.
const tabs = [ const tabs = [
{ key: 'channels' as const, label: 'Channels' }, { key: 'channels' as const, label: 'Channels' },
{ key: 'cashu' as const, label: 'Cashu Mints' }, { key: 'cashu' as const, label: 'Cashu' },
{ key: 'fedimint' as const, label: 'Fedimint Federations' }, { key: 'fedimint' as const, label: 'Fedi' },
{ key: 'ark' as const, label: 'Ark' }, { 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). // 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. // Join degrades gracefully with a clear error if the Fedimint client app isn't installed.
const fedimintBackendReady = true 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 ---- // ---- Cashu mints ----
const mints = ref<string[]>([]) const mints = ref<string[]>([])
const newMint = ref('') const newMint = ref('')

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,
}
}

View File

@ -151,7 +151,7 @@ import { ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { formatTxTime } from './utils' import { formatTxTime } from './utils'
import type { WalletTransaction } from './types' import type { WalletTransaction } from './types'
import { useAppLauncherStore } from '@/stores/appLauncher' import { useTxExplorer } from '@/composables/useTxExplorer'
const { t } = useI18n() const { t } = useI18n()
@ -177,8 +177,10 @@ defineEmits<{
openReceive: [] openReceive: []
}>() }>()
const txExplorer = useTxExplorer()
function openInMempool(txHash: string) { function openInMempool(txHash: string) {
// Overlay the explorer above the current page never navigate away. // Local Mempool app when running; external explorer with consent
useAppLauncherStore().openSession('mempool', { path: `/tx/${txHash}` }) // otherwise (pruned nodes can't run Mempool).
txExplorer.openTx(txHash)
} }
</script> </script>