diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue
index 0dcb8b52..165cff25 100644
--- a/neode-ui/src/App.vue
+++ b/neode-ui/src/App.vue
@@ -39,6 +39,7 @@
+
@@ -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'
diff --git a/neode-ui/src/components/ExternalExplorerModal.vue b/neode-ui/src/components/ExternalExplorerModal.vue
new file mode 100644
index 00000000..00542a44
--- /dev/null
+++ b/neode-ui/src/components/ExternalExplorerModal.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
⚠️ This node doesn't run its own Mempool explorer
+
+ (Pruned Bitcoin nodes can't index the full chain.) Your transaction will open on
+ another node's mempool — 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.
+
+
+
+
+
+
+
+ Defaults to tx1138.com. Any Mempool-compatible instance works — you can change this
+ any time in Settings → System.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/neode-ui/src/components/TransactionsModal.vue b/neode-ui/src/components/TransactionsModal.vue
index 6a0d0818..9f8424db 100644
--- a/neode-ui/src/components/TransactionsModal.vue
+++ b/neode-ui/src/components/TransactionsModal.vue
@@ -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 {
diff --git a/neode-ui/src/components/WalletSettingsModal.vue b/neode-ui/src/components/WalletSettingsModal.vue
index 57d403fe..580108bc 100644
--- a/neode-ui/src/components/WalletSettingsModal.vue
+++ b/neode-ui/src/components/WalletSettingsModal.vue
@@ -149,6 +149,41 @@
+
+
+
+ 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.
+
+
+
+
+
+ Any Mempool-compatible instance works. Default: tx1138.com.
+
+
+
+ ⚠️ 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.
+
+
+
+
+
+
+
+
+
@@ -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([])
const newMint = ref('')
diff --git a/neode-ui/src/composables/useTxExplorer.ts b/neode-ui/src/composables/useTxExplorer.ts
new file mode 100644
index 00000000..efecc879
--- /dev/null
+++ b/neode-ui/src/composables/useTxExplorer.ts
@@ -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(loadPrefs())
+/** Tx hash awaiting user consent — non-null shows ExternalExplorerModal. */
+const pendingTx = ref(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,
+ }
+}
diff --git a/neode-ui/src/views/web5/Web5Wallet.vue b/neode-ui/src/views/web5/Web5Wallet.vue
index 5d8cc6cb..00195014 100644
--- a/neode-ui/src/views/web5/Web5Wallet.vue
+++ b/neode-ui/src/views/web5/Web5Wallet.vue
@@ -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)
}