Files
archy/neode-ui/src/components/BumpFeeModal.vue
T

165 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<BaseModal :show="show" title="Bump transaction" max-width="max-w-md" z-index="z-[3100]" @close="close">
<PaymentSuccessPane v-if="accepted" :amount="0"
:verb="operation?.status === 'confirmed' ? 'CONFIRMED' : 'BUMP BROADCAST'"
:method-label="operation?.quote?.method === 'rbf' ? 'Replace by fee · RBF' : 'Child pays for parent · CPFP'"
:rows="successRows"
:note="operation?.status === 'confirmed' ? 'Your fee bump is confirmed on-chain.' : 'Accepted in the node’s mempool. Awaiting confirmation; next-block inclusion is not guaranteed.'"
:again-label="operation?.status === 'confirmed' ? '' : 'Check status'"
@again="checkStatus" @done="close" />
<div v-else class="space-y-4">
<p class="text-xs font-mono text-white/45 break-all">{{ txid }}</p>
<template v-if="!operation">
<div class="flex gap-2" role="group" aria-label="Fee target">
<button v-for="option in ['next', 'custom'] as const" :key="option" type="button"
:disabled="submitting" :aria-pressed="mode === option"
class="flex-1 rounded-lg px-3 py-2 text-sm border transition-colors"
:class="mode === option ? 'bg-orange-500/20 border-orange-400/40 text-orange-200' : 'border-white/10 text-white/60'"
@click="mode = option">{{ option === 'next' ? 'Next block' : 'Custom' }}</button>
</div>
<label v-if="mode === 'custom'" class="block text-sm text-white/70">
Fee rate (sat/vB)
<input v-model.number="customRate" :disabled="submitting" type="number" min="1" max="5000" step="1"
class="mt-1 block w-full rounded-lg bg-white/5 border border-white/15 px-3 py-2 text-white" />
</label>
<p class="text-xs text-white/45">Targets faster confirmation. Next-block confirmation is not guaranteed.</p>
<p v-if="loading" role="status" class="text-sm text-white/60">Checking the transaction and current fees…</p>
<template v-if="quote">
<div class="rounded-lg bg-white/5 p-3 space-y-2">
<p class="text-sm font-medium text-white">{{ quote.method === 'rbf' ? 'RBF · Replace sweep' : 'CPFP · Spend change' }}</p>
<p class="text-xs text-white/55">{{ quote.method === 'rbf'
? 'Replaces the wallet’s fee-bump transaction with a higher-fee version. The original payment stays unchanged.'
: 'RBF is unavailable for this payment. A child transaction spends your change to accelerate the original payment.' }}</p>
<dl class="text-sm space-y-2 pt-2">
<div class="flex justify-between gap-3"><dt class="text-white/50">Recipient amount</dt><dd class="text-white">{{ sats(quote.recipient_sats) }}</dd></div>
<div class="flex justify-between gap-3"><dt class="text-white/50">Current total fee</dt><dd class="text-white">{{ sats(quote.current_fee_sats) }}</dd></div>
<div class="flex justify-between gap-3"><dt class="text-white/50">Additional fee · up to</dt><dd class="text-orange-200">{{ sats(quote.additional_fee_sats) }}</dd></div>
<div class="flex justify-between gap-3"><dt class="text-white/50">New total fee · up to</dt><dd class="text-white font-medium">{{ sats(quote.total_fee_sats) }}</dd></div>
<div class="flex justify-between gap-3"><dt class="text-white/50">Package target</dt><dd class="text-white">{{ quote.rate_sat_vb }} sat/vB</dd></div>
</dl>
</div>
<p class="text-xs text-white/45">The wallet may use the full {{ sats(quote.budget_sats) }} sweep budget by the next block. The fee cap applies only to this bump.</p>
<p v-if="expired" role="status" class="text-xs text-amber-300">Fee quote expired. Refresh before confirming.</p>
</template>
<p v-if="error" role="alert" class="text-sm text-red-300">{{ error }}</p>
<div class="flex gap-2">
<button type="button" class="flex-1 px-3 py-2 rounded-lg bg-white/10 text-white/75 text-sm" :disabled="loading || submitting" @click="getQuote">{{ quote ? 'Refresh quote' : 'Preview fee' }}</button>
<button v-if="quote" type="button" class="flex-1 px-3 py-2 rounded-lg bg-orange-500 text-white text-sm font-medium disabled:opacity-40"
:disabled="submitting || loading || expired" @click="submit">{{ submitting ? 'Submitting…' : 'Confirm bump' }}</button>
</div>
</template>
<template v-else>
<p role="status" class="text-sm text-white/80">{{ operation.message }}</p>
<p v-if="operation.bump_txid" class="text-xs text-white/50 break-all">{{ operation.quote?.method === 'rbf' ? 'Replacement' : 'Child' }}: {{ operation.bump_txid }}</p>
<p v-if="operation.actual_sweep_fee_sats !== undefined" class="text-sm text-white/70">Sweep fee: {{ sats(operation.actual_sweep_fee_sats) }}</p>
<p v-if="error" role="alert" class="text-sm text-red-300">{{ error }}</p>
<button v-if="operation.status !== 'confirmed'" type="button" class="w-full px-3 py-2 rounded-lg bg-white/10 text-white text-sm" :disabled="loading" @click="checkStatus">Check status</button>
</template>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import BaseModal from './BaseModal.vue'
import PaymentSuccessPane, { type SuccessRow } from './PaymentSuccessPane.vue'
import { rpcClient } from '@/api/rpc-client'
interface Quote {
quote_id: string; txid: string; expires_at: number; method: 'rbf' | 'cpfp'
recipient_sats: number; current_fee_sats: number; additional_fee_sats: number
total_fee_sats: number; budget_sats: number; rate_sat_vb: number
}
interface Operation {
status: 'none' | 'registered' | 'unknown' | 'mempool' | 'confirmed'
message: string; bump_txid?: string; actual_sweep_fee_sats?: number; quote?: Quote
}
const props = defineProps<{ show: boolean; txid: string }>()
const emit = defineEmits<{ close: []; updated: [] }>()
const mode = ref<'next' | 'custom'>('next')
const customRate = ref<number | ''>('')
const quote = ref<Quote | null>(null)
const operation = ref<Operation | null>(null)
const loading = ref(false)
const submitting = ref(false)
const error = ref('')
const time = ref(Date.now())
let generation = 0
let lastStatusCheck = 0
const timer = setInterval(() => {
time.value = Date.now()
if (props.show && operation.value && operation.value.status !== 'confirmed' && time.value - lastStatusCheck > 5000) void checkStatus()
}, 1000)
onUnmounted(() => { clearInterval(timer); generation++ })
const expired = computed(() => !quote.value || quote.value.expires_at * 1000 <= time.value)
const sats = (n: number) => `${n.toLocaleString()} sats`
const accepted = computed(() => operation.value?.status === 'mempool' || operation.value?.status === 'confirmed')
const successRows = computed<SuccessRow[]>(() => {
const op = operation.value
if (!op) return []
const rows: SuccessRow[] = [{ label: 'Original transaction', value: props.txid }]
if (op.bump_txid) rows.push({ label: op.quote?.method === 'rbf' ? 'Replacement transaction' : 'Child transaction', value: op.bump_txid })
if (op.actual_sweep_fee_sats !== undefined) rows.push({ label: 'Sweep fee', value: sats(op.actual_sweep_fee_sats) })
return rows
})
const message = (e: unknown) => e instanceof Error ? e.message : String(e)
function close() { if (!submitting.value) emit('close') }
watch([mode, customRate], () => { generation++; loading.value = false; quote.value = null; error.value = '' })
watch(() => [props.show, props.txid] as const, async ([show]) => {
generation++; quote.value = null; operation.value = null; error.value = ''; loading.value = false
mode.value = 'next'; customRate.value = ''
if (show && props.txid) {
// Let mode/reset watchers settle before the first request.
await Promise.resolve()
await checkStatus()
if (!operation.value && !error.value && props.show) await getQuote()
}
}, { immediate: true })
async function getQuote() {
if (loading.value || submitting.value || operation.value) return
if (mode.value === 'custom' && (!Number.isInteger(customRate.value) || Number(customRate.value) < 1 || Number(customRate.value) > 5000)) {
error.value = 'Enter a whole-number fee rate from 1 to 5000 sat/vB.'; return
}
const request = ++generation
loading.value = true; error.value = ''; quote.value = null
try {
const result = await rpcClient.call<Quote>({ method: 'lnd.bump-quote', params: { txid: props.txid, ...(mode.value === 'custom' ? { sat_per_vbyte: customRate.value } : {}) }, maxRetries: 1, timeout: 60000 })
if (request === generation) quote.value = result
} catch (e) { if (request === generation) error.value = message(e) }
finally { if (request === generation) loading.value = false }
}
async function submit() {
if (submitting.value || loading.value || !quote.value || expired.value) return
submitting.value = true; error.value = ''
const id = props.txid
try {
operation.value = await rpcClient.call<Operation>({ method: 'lnd.bump-submit', params: { txid: id, quote_id: quote.value.quote_id }, maxRetries: 1, timeout: 90000 })
emit('updated')
} catch (e) {
// An interrupted response may conceal success. A status check must happen
// before showing another quote/submit action.
operation.value = { status: 'unknown', message: 'Submission response was not confirmed. Check status before taking another action.' }
error.value = message(e)
} finally { submitting.value = false }
await checkStatus()
}
async function checkStatus() {
if (loading.value || submitting.value) return
lastStatusCheck = Date.now()
const request = ++generation
loading.value = true
try {
const result = await rpcClient.call<Operation>({ method: 'lnd.bump-status', params: { txid: props.txid }, maxRetries: 1, timeout: 60000 })
if (request !== generation) return
error.value = ''
if (result.status === 'none') {
operation.value = null
quote.value = null
} else {
operation.value = result
if (result.status === 'confirmed' || result.status === 'mempool') emit('updated')
}
} catch (e) { if (request === generation) error.value = message(e) }
finally { if (request === generation) loading.value = false }
}
</script>