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:
parent
5e7e928650
commit
c0aef2f03e
@ -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'
|
||||
|
||||
|
||||
71
neode-ui/src/components/ExternalExplorerModal.vue
Normal file
71
neode-ui/src/components/ExternalExplorerModal.vue
Normal 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>
|
||||
@ -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
neode-ui/src/composables/useTxExplorer.ts
Normal file
106
neode-ui/src/composables/useTxExplorer.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user