feat(ui): Ark wallet tab, balances, history badge + demo mocks

Ark tab in Wallet Settings (status, balances, receive address,
board/offboard, server config via wallet.ark-*), teal Ark badge in the
transactions modal, Ark row on the home wallet card (hidden until barkd
reports a balance), official bark icon, and wallet.ark-* mocks so the
public demo renders the tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-07-14 21:57:12 +01:00
co-authored by Claude Fable 5
parent bdb9826aba
commit 6fa0aa46a9
8 changed files with 329 additions and 6 deletions
+225 -2
View File
@@ -148,11 +148,107 @@
Joining federations lands with the Fedimint client backend.
</p>
</div>
<!-- ===================== Ark ===================== -->
<div v-show="activeTab === 'ark'">
<div class="flex items-start gap-2 mb-4">
<p class="text-white/60 text-sm flex-1">
Ark holds self-custodial off-chain bitcoin via an Ark server. Funds stay recoverable on-chain even if the server disappears.
</p>
<span v-if="arkStatus && !arkStatus.available" class="shrink-0 text-[10px] px-2 py-0.5 rounded-full font-medium bg-orange-500/15 text-orange-400">Not installed</span>
<span v-else-if="arkStatus?.config?.network && arkStatus.config.network !== 'mainnet'" class="shrink-0 text-[10px] px-2 py-0.5 rounded-full font-medium bg-teal-500/15 text-teal-400">{{ arkStatus.config.network }}</span>
</div>
<div v-if="loadingArk" class="py-6 text-center text-white/50 text-sm">Checking Ark wallet…</div>
<template v-else>
<p v-if="arkStatus && !arkStatus.available" class="text-white/40 text-sm text-center py-2 mb-4">
Install the <span class="text-white/70">Ark Wallet</span> app from the app store to enable Ark payments.
</p>
<!-- Balances -->
<div v-if="arkStatus?.available" class="grid grid-cols-3 gap-2 mb-4">
<div class="p-3 bg-white/5 rounded-lg text-center">
<p class="text-[11px] text-white/40 mb-1">Spendable</p>
<p class="text-sm text-teal-400 font-medium">{{ (arkBalance?.spendable_sats ?? 0).toLocaleString() }} sats</p>
</div>
<div class="p-3 bg-white/5 rounded-lg text-center">
<p class="text-[11px] text-white/40 mb-1">Pending</p>
<p class="text-sm text-white/70 font-medium">{{ (arkBalance?.pending_sats ?? 0).toLocaleString() }} sats</p>
</div>
<div class="p-3 bg-white/5 rounded-lg text-center">
<p class="text-[11px] text-white/40 mb-1">On-chain</p>
<p class="text-sm text-white/70 font-medium">{{ (arkBalance?.onchain_sats ?? 0).toLocaleString() }} sats</p>
</div>
</div>
<!-- Receive address -->
<div v-if="arkStatus?.available" class="mb-4">
<div class="flex gap-2">
<button @click="fetchArkAddress(false)" :disabled="arkBusy" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50">Ark address</button>
<button @click="fetchArkAddress(true)" :disabled="arkBusy" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50">On-chain (boarding) address</button>
</div>
<div v-if="arkAddress" class="mt-2 flex items-center gap-2 p-3 bg-white/5 rounded-lg">
<span class="text-xs font-mono text-white/90 break-all flex-1">{{ arkAddress }}</span>
<button @click="copyArkAddress" class="p-2 rounded-lg hover:bg-white/10 text-white/50 hover:text-white shrink-0" :title="arkCopied ? 'Copied' : 'Copy'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
<!-- Board / offboard -->
<div v-if="arkStatus?.available" class="flex gap-2 mb-4">
<button
@click="boardArk"
:disabled="arkBusy || (arkBalance?.onchain_sats ?? 0) === 0"
class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50"
title="Lift on-chain funds into Ark"
>{{ arkBoarding ? 'Boarding' : 'Board on-chain funds' }}</button>
<button
@click="offboardArk"
:disabled="arkBusy || (arkBalance?.spendable_sats ?? 0) === 0"
class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50"
title="Move all Ark funds back on-chain"
>{{ arkOffboarding ? 'Offboarding' : 'Offboard to on-chain' }}</button>
</div>
<!-- Server configuration -->
<div class="mb-3 space-y-2">
<label class="text-white/60 text-sm block">Ark server</label>
<input v-model="arkConfig.ark_server" type="text" placeholder="https://ark.signet.2nd.dev" class="w-full input-glass font-mono" />
<label class="text-white/60 text-sm block">Esplora (chain source)</label>
<input v-model="arkConfig.esplora" type="text" placeholder="https://esplora.signet.2nd.dev" class="w-full input-glass font-mono" />
<label class="text-white/60 text-sm block">Network</label>
<select v-model="arkConfig.network" class="w-full input-glass">
<option value="signet">signet</option>
<option value="mainnet">mainnet</option>
<option value="regtest">regtest</option>
</select>
<p class="text-[11px] text-white/40">
Applied when the Ark wallet is created — an existing wallet stays bound to its server.
</p>
</div>
<div v-if="arkError" class="mb-3 alert-error">{{ arkError }}</div>
<div v-if="arkOk" class="mb-3 text-xs text-green-400">{{ arkOk }}</div>
<div class="flex gap-3 mt-4">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button
@click="saveArkConfig"
:disabled="arkBusy || !arkConfig.ark_server.trim() || !arkConfig.esplora.trim()"
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>{{ savingArk ? 'Saving' : 'Save' }}</button>
</div>
</template>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
@@ -167,8 +263,9 @@ const tabs = [
{ key: 'channels' as const, label: 'Channels' },
{ key: 'cashu' as const, label: 'Cashu Mints' },
{ key: 'fedimint' as const, label: 'Fedimint Federations' },
{ key: 'ark' as const, label: 'Ark' },
]
const activeTab = ref<'channels' | 'cashu' | 'fedimint'>('channels')
const activeTab = ref<'channels' | 'cashu' | 'fedimint' | 'ark'>('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.
@@ -194,12 +291,37 @@ const joiningFed = ref(false)
const fedError = ref('')
const fedJoinedOk = ref(false)
// ---- Ark (barkd sidecar, wallet.ark-* RPCs) ----
interface ArkStatus {
available: boolean
wallet_ready: boolean
config?: { network: string; ark_server: string; esplora: string }
}
interface ArkBalance {
spendable_sats: number
pending_sats: number
onchain_sats: number
}
const arkStatus = ref<ArkStatus | null>(null)
const arkBalance = ref<ArkBalance | null>(null)
const arkConfig = ref({ network: 'signet', ark_server: '', esplora: '' })
const arkAddress = ref('')
const arkCopied = ref(false)
const loadingArk = ref(false)
const savingArk = ref(false)
const arkBoarding = ref(false)
const arkOffboarding = ref(false)
const arkError = ref('')
const arkOk = ref('')
const arkBusy = computed(() => savingArk.value || arkBoarding.value || arkOffboarding.value)
watch(
() => props.show,
(open) => {
if (open) {
loadMints()
if (fedimintBackendReady) loadFederations()
loadArk()
}
},
)
@@ -309,10 +431,111 @@ async function joinFederation() {
}
}
async function loadArk() {
loadingArk.value = true
arkError.value = ''
arkOk.value = ''
arkAddress.value = ''
try {
const status = await rpcClient.call<ArkStatus>({ method: 'wallet.ark-status' })
arkStatus.value = status
if (status.config) arkConfig.value = { ...status.config }
if (status.available) {
arkBalance.value = await rpcClient.call<ArkBalance>({ method: 'wallet.ark-balance' })
} else {
arkBalance.value = null
}
} catch {
arkStatus.value = { available: false, wallet_ready: false }
arkBalance.value = null
} finally {
loadingArk.value = false
}
}
async function fetchArkAddress(onchain: boolean) {
arkError.value = ''
arkOk.value = ''
try {
const res = await rpcClient.call<{ address: string }>({
method: 'wallet.ark-address',
params: { onchain },
})
arkAddress.value = res.address
arkCopied.value = false
} catch (err: unknown) {
arkError.value = err instanceof Error ? err.message : 'Failed to get address'
}
}
async function copyArkAddress() {
if (!arkAddress.value) return
try {
await navigator.clipboard.writeText(arkAddress.value)
arkCopied.value = true
} catch {
/* clipboard unavailable (http) — the address is selectable */
}
}
async function boardArk() {
arkBoarding.value = true
arkError.value = ''
arkOk.value = ''
try {
// Boards everything; waits on Ark round participation, so give it room.
await rpcClient.call({ method: 'wallet.ark-board', timeout: 130000 })
arkOk.value = 'Boarding started — funds appear as spendable once the round confirms.'
arkBalance.value = await rpcClient.call<ArkBalance>({ method: 'wallet.ark-balance' })
emit('changed')
} catch (err: unknown) {
arkError.value = err instanceof Error ? err.message : 'Failed to board funds'
} finally {
arkBoarding.value = false
}
}
async function offboardArk() {
arkOffboarding.value = true
arkError.value = ''
arkOk.value = ''
try {
await rpcClient.call({ method: 'wallet.ark-offboard', timeout: 130000 })
arkOk.value = 'Offboard requested — funds return on-chain with the next round.'
arkBalance.value = await rpcClient.call<ArkBalance>({ method: 'wallet.ark-balance' })
emit('changed')
} catch (err: unknown) {
arkError.value = err instanceof Error ? err.message : 'Failed to offboard funds'
} finally {
arkOffboarding.value = false
}
}
async function saveArkConfig() {
savingArk.value = true
arkError.value = ''
arkOk.value = ''
try {
const res = await rpcClient.call<{ config: ArkStatus['config'] }>({
method: 'wallet.ark-configure',
params: { ...arkConfig.value },
})
if (res.config) arkConfig.value = { ...res.config }
arkOk.value = 'Ark configuration saved.'
emit('changed')
} catch (err: unknown) {
arkError.value = err instanceof Error ? err.message : 'Failed to save Ark configuration'
} finally {
savingArk.value = false
}
}
function close() {
mintError.value = ''
mintsSavedOk.value = false
fedError.value = ''
arkError.value = ''
arkOk.value = ''
emit('close')
}
</script>