fix: harden node upgrades and prepare 1.9.0-alpha

This commit is contained in:
archipelago
2026-10-05 12:43:49 -04:00
parent 138a541d01
commit daac47cac4
129 changed files with 9910 additions and 794 deletions
+164
View File
@@ -0,0 +1,164 @@
<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>
@@ -553,7 +553,7 @@ const defaultOpenForm = () => ({
peerUri: '',
amount: 100000,
private: false,
feePreset: 'standard' as FeePreset,
feePreset: 'fast' as FeePreset,
customConfTarget: null as number | null,
customSatPerVbyte: null as number | null,
})
@@ -624,7 +624,7 @@ function feeParams(
setError: (message: string) => void = message => { openError.value = message },
): { target_conf?: number; sat_per_vbyte?: number } | null {
if (form.feePreset !== 'custom') {
return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 6 }
return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 1 }
}
const rate = form.customSatPerVbyte
const conf = form.customConfTarget
@@ -673,7 +673,7 @@ async function openChannel() {
}
}
const defaultCloseForm = () => ({ feePreset: 'standard' as FeePreset, customConfTarget: null as number | null, customSatPerVbyte: null as number | null })
const defaultCloseForm = () => ({ feePreset: 'fast' as FeePreset, customConfTarget: null as number | null, customSatPerVbyte: null as number | null })
const closeForm = ref(defaultCloseForm())
function confirmClose(ch: Channel) {
+11 -6
View File
@@ -337,7 +337,7 @@ watch(() => props.show, (shown) => {
successInfo.value = null
sendAll.value = false
onchainBalance.value = null
feePreset.value = 'standard'
feePreset.value = 'fast'
customConfTarget.value = null
customSatPerVbyte.value = null
resolvedFeeParams.value = {}
@@ -359,7 +359,7 @@ const onchainFeePresets: { key: OnchainFeePreset; label: string; hint?: string;
{ key: 'custom', label: 'Custom' },
]
const feePreset = ref<OnchainFeePreset>('standard')
const feePreset = ref<OnchainFeePreset>('fast')
const customConfTarget = ref<number | null>(null)
const customSatPerVbyte = ref<number | null>(null)
// Resolved at review time so confirm + send use the same params.
@@ -367,16 +367,16 @@ const resolvedFeeParams = ref<{ target_conf?: number; sat_per_vbyte?: number }>(
function onchainFeeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
if (feePreset.value !== 'custom') {
return { target_conf: onchainFeePresets.find(p => p.key === feePreset.value)?.confTarget ?? 6 }
return { target_conf: onchainFeePresets.find(p => p.key === feePreset.value)?.confTarget ?? 1 }
}
const rate = customSatPerVbyte.value
const conf = customConfTarget.value
if (rate != null && rate !== 0) {
if (rate < 1 || rate > 5000) { error.value = 'Sats per vByte must be between 1 and 5000'; return null }
if (!Number.isInteger(rate) || rate < 1 || rate > 5000) { error.value = 'Sats per vByte must be a whole number between 1 and 5000'; return null }
return { sat_per_vbyte: Math.floor(rate) }
}
if (conf != null && conf !== 0) {
if (conf < 1 || conf > 1008) { error.value = 'Target blocks must be between 1 and 1008'; return null }
if (!Number.isInteger(conf) || conf < 1 || conf > 1008) { error.value = 'Target blocks must be a whole number between 1 and 1008'; return null }
return { target_conf: Math.floor(conf) }
}
error.value = 'Custom fee requires target blocks or sats per vByte'
@@ -409,7 +409,7 @@ async function loadFeeEstimate() {
try {
const res = await rpcClient.call<{ fee_sat: number; sat_per_vbyte: number }>({
method: 'lnd.estimatefee',
params: { addr, amount: amt, target_conf: resolvedFeeParams.value.target_conf ?? 6 },
params: { addr, amount: amt, target_conf: resolvedFeeParams.value.target_conf ?? 1 },
timeout: 10000,
})
if (res.fee_sat > 0) feeEstimate.value = res
@@ -569,6 +569,11 @@ function sendAnother() {
dest.value = ''
amount.value = 0
sendAll.value = false
feePreset.value = 'fast'
customConfTarget.value = null
customSatPerVbyte.value = null
resolvedFeeParams.value = {}
feeEstimate.value = null
error.value = ''
}
+26 -6
View File
@@ -61,9 +61,9 @@
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<span
class="text-sm font-medium"
class="text-sm font-medium whitespace-nowrap"
:class="tx.direction === 'incoming' ? 'text-green-400' : 'text-red-400'"
>
{{ tx.direction === 'incoming' ? '+' : '-' }}{{ displayAmount(tx).toLocaleString() }} sats
@@ -92,10 +92,22 @@
<span v-if="feeFor(tx)" class="text-[10px] text-white/35 shrink-0">fee {{ feeFor(tx).toLocaleString() }} sats</span>
<span v-if="tx.label" class="text-[10px] text-white/30 shrink-0">{{ tx.label }}</span>
</div>
<details v-if="tx.fee_bump_history?.length" class="mt-1 text-[11px] text-white/50" @click.stop>
<summary class="cursor-pointer">Fee history</summary>
<p>Original fee {{ tx.total_fees.toLocaleString() }} sats</p>
<button v-for="bump in tx.fee_bump_history" :key="bump.tx_hash" type="button"
class="block max-w-full truncate text-left hover:text-white/80"
@click.stop="txExplorer.openTx(bump.tx_hash)">
{{ bump.status === 'replaced' ? 'Replaced bump' : 'Fee bump' }} · {{ bump.fee_sats.toLocaleString() }} sats · {{ bump.status }} · {{ bump.tx_hash }}
</button>
</details>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
<button v-if="isOnchain(tx) && tx.direction === 'outgoing' && (tx.num_confirmations === 0 || (tx.fee_bump_txid && tx.fee_bump_confirmations === 0))" type="button"
class="px-2 py-1 rounded-md border border-orange-400/25 text-orange-200 text-[11px] hover:bg-orange-500/15"
:aria-label="`Bump transaction ${tx.tx_hash}`" @click.stop="bumpTxid = tx.fee_bump_txid || tx.tx_hash">Bump</button>
<span class="hidden sm:inline text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
<svg v-if="isOnchain(tx)" class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
@@ -103,12 +115,14 @@
</div>
</div>
</BaseModal>
<BumpFeeModal :show="show && !!bumpTxid" :txid="bumpTxid" @close="bumpTxid = ''" @updated="emit('updated')" />
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseModal from '@/components/BaseModal.vue'
import BumpFeeModal from '@/components/BumpFeeModal.vue'
import { useTxExplorer } from '@/composables/useTxExplorer'
interface WalletTransaction {
@@ -118,6 +132,10 @@ interface WalletTransaction {
num_confirmations: number
time_stamp: number
total_fees: number
bump_fee_sats?: number
fee_bump_txid?: string
fee_bump_confirmations?: number
fee_bump_history?: Array<{ tx_hash: string; fee_sats: number; status: string }>
dest_addresses: string[]
label: string
block_height: number
@@ -130,7 +148,8 @@ const props = defineProps<{
transactions: WalletTransaction[]
}>()
const emit = defineEmits<{ close: [] }>()
const emit = defineEmits<{ close: []; updated: [] }>()
const bumpTxid = ref('')
const { t } = useI18n()
type FilterKey = 'all' | 'onchain' | 'lightning' | 'ecash' | 'ark'
@@ -160,6 +179,7 @@ function countFor(f: FilterKey): number {
}
function close() {
bumpTxid.value = ''
emit('close')
}
@@ -169,14 +189,14 @@ function isOnchain(tx: WalletTransaction): boolean {
}
function feeFor(tx: WalletTransaction): number {
return tx.direction === 'outgoing' ? (tx.total_fees || 0) : 0
return tx.direction === 'outgoing' ? (tx.total_fees || 0) + (tx.bump_fee_sats || 0) : 0
}
/** Outgoing rows show the amount the RECIPIENT got (gross minus fee); the fee
* itself is broken out on its own tag. Incoming rows are untouched. */
function displayAmount(tx: WalletTransaction): number {
const gross = Math.abs(tx.amount_sats)
const fee = feeFor(tx)
const fee = tx.direction === 'outgoing' ? (tx.total_fees || 0) : 0
return fee > 0 && gross > fee ? gross - fee : gross
}
@@ -0,0 +1,123 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BumpFeeModal from '../BumpFeeModal.vue'
import TransactionsModal from '../TransactionsModal.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
const explorer = vi.hoisted(() => vi.fn())
vi.mock('@/composables/useTxExplorer', () => ({ useTxExplorer: () => ({ openTx: explorer }) }))
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (s: string) => s }) }))
const quote = { quote_id: 'q', txid: 'a'.repeat(64), expires_at: Math.floor(Date.now() / 1000) + 60, method: 'cpfp', recipient_sats: 161650, current_fee_sats: 144, additional_fee_sats: 618, total_fee_sats: 762, budget_sats: 618, rate_sat_vb: 3 }
const stubs = { BaseModal: { template: '<div><slot /></div>' } }
function open() {
const wrapper = mount(BumpFeeModal, { props: { show: true, txid: quote.txid }, global: { stubs } })
return { wrapper, vm: (wrapper.vm as any).$.setupState }
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'lnd.bump-status') return { status: 'none' } as never
return { ...quote } as never
})
})
describe('Bump review', () => {
it('defaults to next block and clearly explains CPFP with additional/total preview', async () => {
const { wrapper } = open(); await flushPromises()
expect(wrapper.text()).toContain('CPFP · Spend change')
expect(wrapper.text()).toContain('RBF is unavailable')
expect(wrapper.text()).toContain('618 sats')
expect(wrapper.text()).toContain('762 sats')
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.bump-submit' }))
wrapper.unmount()
})
it('shows RBF only for a backend-supported sweep', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => (method === 'lnd.bump-status' ? { status: 'none' } : { ...quote, method: 'rbf' }) as never)
const { wrapper } = open(); await flushPromises()
expect(wrapper.text()).toContain('RBF · Replace sweep')
expect(wrapper.text()).not.toContain('RBF is unavailable')
wrapper.unmount()
})
it('invalidates the old quote when custom settings change', async () => {
const { wrapper, vm } = open(); await flushPromises()
vm.mode = 'custom'; await flushPromises()
expect(vm.quote).toBeNull()
vm.customRate = 0.5; await flushPromises(); await vm.getQuote()
expect(vm.error).toContain('whole-number')
vm.customRate = 10; await flushPromises(); await vm.getQuote()
expect(rpcClient.call).toHaveBeenLastCalledWith(expect.objectContaining({ method: 'lnd.bump-quote', params: { txid: quote.txid, sat_per_vbyte: 10 } }))
wrapper.unmount()
})
it('blocks expired quotes and duplicate clicks; never retries a mutation', async () => {
const { wrapper, vm } = open(); await flushPromises()
vm.quote.expires_at = 1; await vm.submit()
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.bump-submit' }))
vm.quote.expires_at = Math.floor(Date.now()/1000) + 60
let finish!: (value: any) => void
vi.mocked(rpcClient.call).mockImplementation(({ method }) => method === 'lnd.bump-submit' ? new Promise(resolve => { finish = resolve }) as never : Promise.resolve({ status: 'registered', message: 'Waiting for broadcast' }) as never)
const pending = vm.submit(); await vm.submit()
expect(vi.mocked(rpcClient.call).mock.calls.filter(([r]) => r.method === 'lnd.bump-submit')).toHaveLength(1)
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.bump-submit', maxRetries: 1 }))
finish({ status: 'registered', message: 'Waiting for broadcast' }); await pending
expect(wrapper.text()).toContain('Waiting for broadcast')
wrapper.unmount()
})
it('keeps unknown submission outcome from becoming a second payment', async () => {
const { wrapper, vm } = open(); await flushPromises()
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Network timeout'))
await vm.submit()
expect(vm.operation.status).toBe('unknown')
expect(wrapper.text()).not.toContain('Confirm bump')
expect(wrapper.text()).toContain('Check status')
wrapper.unmount()
})
it('reconciles a previous submission when reopening', async () => {
vi.mocked(rpcClient.call).mockResolvedValue({ status: 'mempool', message: 'Accepted, awaiting confirmation', bump_txid: 'b'.repeat(64), actual_sweep_fee_sats: 618 } as never)
const { wrapper } = open(); await flushPromises()
expect(wrapper.text()).toContain('BUMP BROADCAST')
expect(wrapper.text()).toContain('Awaiting confirmation')
expect(rpcClient.call).toHaveBeenCalledTimes(1)
expect(wrapper.text()).not.toContain('Confirm bump')
wrapper.unmount()
})
it('uses the existing green success animation only after verified acceptance', async () => {
const { wrapper, vm } = open(); await flushPromises()
vm.operation = { status: 'registered', message: 'Waiting for broadcast' }; await flushPromises()
expect(wrapper.find('.send-success-badge').exists()).toBe(false)
vm.operation = { status: 'mempool', message: 'Accepted', bump_txid: 'b', quote: { method: 'rbf' } }; await flushPromises()
expect(wrapper.find('.send-success-badge').exists()).toBe(true)
expect(wrapper.text()).toContain('BUMP BROADCAST')
expect(wrapper.text()).toContain('Awaiting confirmation')
vm.operation.status = 'confirmed'; await flushPromises()
expect(wrapper.text()).toContain('CONFIRMED')
expect(wrapper.text()).not.toContain('Awaiting confirmation')
wrapper.unmount()
})
it('offers Bump only on pending on-chain rows and stops explorer navigation', async () => {
const tx = { tx_hash: quote.txid, amount_sats: 161794, direction: 'outgoing', num_confirmations: 0, time_stamp: 1, total_fees: 144, dest_addresses: [], label: '', block_height: 0 }
const wrapper = mount(TransactionsModal, { props: { show: true, transactions: [tx, { ...tx, tx_hash: 'b', num_confirmations: 1 }, { ...tx, tx_hash: 'c', kind: 'lightning' }] as any }, global: { stubs: { ...stubs, BumpFeeModal: true } } })
const buttons = wrapper.findAll('button').filter(b => b.text() === 'Bump')
expect(buttons).toHaveLength(1)
await buttons[0]!.trigger('click')
expect(explorer).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'BumpFeeModal' }).props('txid')).toBe(quote.txid)
wrapper.unmount()
})
})
describe('Grouped fee-bump history', () => {
it('preserves recipient amount, counts only active extra fees, and opens the replacement bump', async () => {
const parent = { tx_hash: 'a'.repeat(64), amount_sats: 100144, total_fees: 144, direction: 'outgoing' as const,
num_confirmations: 0, time_stamp: 1, dest_addresses: [], label: '', block_height: 0,
bump_fee_sats: 600, fee_bump_txid: 'c'.repeat(64), fee_bump_confirmations: 0,
fee_bump_history: [{ tx_hash: 'b'.repeat(64), fee_sats: 300, status: 'replaced' },
{ tx_hash: 'c'.repeat(64), fee_sats: 600, status: 'mempool' }] }
const wrapper = mount(TransactionsModal, { props: { show: true, transactions: [parent] }, global: { stubs } })
expect(wrapper.text()).toContain('-100,000 sats')
expect(wrapper.text()).toContain('fee 744 sats')
expect(wrapper.text()).toContain('Replaced bump')
await wrapper.get(`button[aria-label="Bump transaction ${parent.tx_hash}"]`).trigger('click')
await flushPromises()
expect(wrapper.findComponent(BumpFeeModal).props('txid')).toBe(parent.fee_bump_txid)
wrapper.unmount()
})
})
@@ -17,6 +17,20 @@ function open() {
}
beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockResolvedValue({ success: true } as never) })
describe('channel closing fee choice', () => {
it('defaults to next block, preserves explicit choices, and resets a new close to Fast', async () => {
const { wrapper, vm } = open()
expect(vm.closeForm.feePreset).toBe('fast')
await vm.closeChannel()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, target_conf: 1 } }))
vm.confirmClose(channel)
vm.closeForm.feePreset = 'standard'
await vm.closeChannel()
expect(rpcClient.call).toHaveBeenLastCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, target_conf: 6 } }))
vm.confirmClose(channel)
expect(vm.closeForm.feePreset).toBe('fast')
expect(vm.openForm.feePreset).toBe('fast')
wrapper.unmount()
})
it.each([['standard', 6], ['medium', 3], ['fast', 1]])('forwards %s target and never automatically retries the mutation', async (preset, target) => {
const { wrapper, vm } = open(); vm.closeForm.feePreset = preset
await vm.closeChannel()
@@ -0,0 +1,62 @@
import { mount, flushPromises } from '@vue/test-utils'
import { reactive } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MeshDeviceSetupModal from '../mesh/MeshDeviceSetupModal.vue'
const state = reactive({
flashFlowPath: null as string | null,
undismissedDetectedDevices: [] as string[],
status: {
device_type: 'meshcore',
detected_device_info: [{ path: '/dev/fixture', vid: '10c4', pid: 'ea60' }],
},
flashDevice: vi.fn(),
flashStatus: vi.fn(),
closeFlashFlow: vi.fn(),
})
vi.mock('@/stores/mesh', () => ({ useMeshStore: () => state }))
vi.mock('@/stores/app', () => ({ useAppStore: () => ({ serverName: 'Fixture' }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
async function open() {
const wrapper = mount(MeshDeviceSetupModal, {
global: { stubs: { BaseModal: { template: '<div><slot /></div>' } } },
})
state.flashFlowPath = '/dev/fixture'
await flushPromises()
return wrapper
}
describe('LoRa firmware board selection', () => {
beforeEach(() => {
state.flashFlowPath = null
state.flashDevice.mockReset()
})
it('does not infer Heltec V3 from a generic CP2102 adapter', async () => {
const wrapper = await open()
const board = wrapper.findAll('select')[1]!
expect((board.element as HTMLSelectElement).value).toBe('')
await wrapper.find('input[type="checkbox"]').setValue(true)
const flash = wrapper.findAll('button').find(button => button.text().includes('Erase & Flash Now'))!
expect((flash.element as HTMLButtonElement).disabled).toBe(true)
expect(state.flashDevice).not.toHaveBeenCalled()
await board.setValue('heltec-v3')
await flash.trigger('click')
expect(state.flashDevice).toHaveBeenCalledExactlyOnceWith('/dev/fixture', 'meshcore', 'heltec-v3')
wrapper.unmount()
})
it('shows a failed tool preflight without entering flashing progress', async () => {
state.flashDevice.mockRejectedValueOnce(new Error('Radio flashing tools are missing'))
const wrapper = await open()
await wrapper.findAll('select')[1]!.setValue('heltec-v4')
await wrapper.find('input[type="checkbox"]').setValue(true)
await wrapper.findAll('button').find(button => button.text().includes('Erase & Flash Now'))!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Radio flashing tools are missing')
expect(wrapper.text()).toContain('Erase & Flash Now')
expect(state.flashStatus).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -0,0 +1,56 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SendBitcoinModal from '../SendBitcoinModal.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (s: string) => s }) }))
vi.mock('@/composables/useLightningRequired', () => ({ useLightningRequired: () => ({}) }))
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'lnd.getinfo') return { balance_sats: 100000 } as never
if (method === 'lnd.estimatefee') return { fee_sat: 500, sat_per_vbyte: 3 } as never
return { txid: 'a'.repeat(64) } as never
})
})
function open() {
const wrapper = mount(SendBitcoinModal, { props: { show: true }, global: { stubs: { BaseModal: { template: '<div><slot /></div>' } } } })
const vm = (wrapper.vm as any).$.setupState
vm.sendMethod = 'onchain'; vm.dest = 'bc1qtestaddress'; vm.amount = 1000
return { wrapper, vm }
}
describe('on-chain fee defaults', () => {
it.each([NaN, Infinity, 0.5, -1, 5001])('rejects invalid custom rate %s before preview or submission', async rate => {
const { wrapper, vm } = open()
vm.feePreset = 'custom'; vm.customSatPerVbyte = rate
await vm.review()
expect(vm.confirming).toBe(false)
expect(vm.error).toContain('whole number')
expect(rpcClient.call).not.toHaveBeenCalled()
wrapper.unmount()
})
it.each([['fast', 1], ['standard', 6], ['slow', 144]])('uses %s consistently in preview and submission', async (preset, target) => {
const { wrapper, vm } = open()
expect(vm.feePreset).toBe('fast')
vm.feePreset = preset
await vm.review(); await flushPromises()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.estimatefee', params: expect.objectContaining({ target_conf: target }) }))
await vm.send()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.sendcoins', params: expect.objectContaining({ target_conf: target }) }))
wrapper.unmount()
})
it('preserves an explicit custom rate and resets reopened/new sends to Fast', async () => {
const { wrapper, vm } = open()
vm.feePreset = 'custom'; vm.customSatPerVbyte = 17
await vm.review(); await flushPromises(); await vm.send()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.sendcoins', params: expect.objectContaining({ sat_per_vbyte: 17 }) }))
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.estimatefee' }))
vm.sendAnother(); expect(vm.feePreset).toBe('fast'); expect(vm.customSatPerVbyte).toBeNull()
vm.feePreset = 'slow'
await wrapper.setProps({ show: false }); await wrapper.setProps({ show: true })
expect(vm.feePreset).toBe('fast'); expect(vm.resolvedFeeParams).toEqual({})
wrapper.unmount()
})
})
@@ -1,5 +1,5 @@
<template>
<div v-if="task" :class="floating ? 'fixed z-50 top-20 left-4 right-4 md:left-auto md:w-96' : 'mb-3 shrink-0'">
<div v-if="task && route.fullPath === task.originRoute" class="mb-3 shrink-0">
<div class="glass-card relative overflow-hidden h-11 px-3 flex items-center gap-2" role="status" aria-live="polite">
<div v-if="task.active" class="absolute inset-y-0 left-0 bg-emerald-400/10 transition-[width] duration-200 pointer-events-none" :style="{ width: `${percent}%` }" />
<div v-if="task.active" class="absolute bottom-0 left-0 h-0.5 bg-emerald-400 transition-[width] duration-200" :style="{ width: `${percent}%` }" role="progressbar" :aria-valuenow="percent" aria-valuemin="0" aria-valuemax="100" :aria-label="`Uploading ${task.filename}`" />
@@ -13,8 +13,9 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useCloudStore } from '@/stores/cloud'
defineProps<{ floating?: boolean }>()
const route = useRoute()
const store = useCloudStore()
const task = computed(() => store.upload)
const percent = computed(() => !task.value ? 0 : task.value.total ? Math.min(100, Math.floor(task.value.sent * 100 / task.value.total)) : task.value.active ? 0 : 100)
@@ -23,7 +24,8 @@ const label = computed(() => {
if (!t) return ''
if (t.error) return t.error
if (t.cancelled) return `Upload stopped · ${t.completed}/${t.count} saved`
if (!t.active) return `${t.count === 1 ? t.filename : `${t.count} files`} uploaded`
if (!t.active) return `Complete · ${t.count === 1 ? t.filename : `${t.count} files`}`
if (t.paused) return `Connection interrupted · resuming ${t.filename}`
return `${percent.value === 100 ? 'Saving' : 'Uploading'} ${t.filename}${t.count > 1 ? ` · ${t.completed + 1}/${t.count}` : ''}`
})
</script>
@@ -149,9 +149,8 @@
<option value="heltec-v3">Heltec LoRa 32 V3</option>
<option value="heltec-v4">Heltec LoRa 32 V4</option>
</select>
<p v-if="!boardAutoDetected" class="text-[11px] text-amber-400/80 mt-1">
Couldn't confirm the board automatically — double check before flashing.
Flashing the wrong board's image can brick it.
<p class="text-[11px] text-amber-400/80 mt-1">
Select the model printed on your board. USB adapters are shared across models.
</p>
</div>
</div>
@@ -300,7 +299,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import BaseModal from '@/components/BaseModal.vue'
import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams, type FlashFirmwareFamily, type FlashBoard, type FlashJobStatus } from '@/stores/mesh'
@@ -536,31 +535,11 @@ const starting = ref(false)
const flashJob = ref<FlashJobStatus | null>(null)
let flashPollTimer: ReturnType<typeof setInterval> | null = null
const detectedInfo = computed(() =>
mesh.status?.detected_device_info?.find(d => d.path === devicePath.value)
)
// Mirrors mesh::flash::resolve_flash_board (core/archipelago/src/mesh/flash.rs)
// exactly — matching on the display label was wrong: a Heltec V3's CP2102
// bridge chip reports "CP2102 USB to UART Bridge Controller" in its USB
// strings, not "Heltec", so meshDeviceImages.ts falls back to a generic
// "LoRa radio (CP2102 serial)" label that never matched /v3/i, showing the
// "couldn't confirm automatically" warning even though the backend CAN
// safely auto-detect V3 via vid:pid. Heltec V4 deliberately has no entry
// here, same reasoning as the backend: its vid:pid (303a:1001) is the
// ESP32-S3's generic native-USB descriptor, not V4-specific, so it can't be
// safely auto-matched and always requires manual selection.
const resolvedFlashBoard = computed<FlashBoard | ''>(() => {
const info = detectedInfo.value
if (info?.vid?.toLowerCase() === '10c4' && info?.pid?.toLowerCase() === 'ea60') return 'heltec-v3'
return ''
})
const boardAutoDetected = computed(() => !!resolvedFlashBoard.value)
const flashStageLabel = computed(() => {
switch (flashJob.value?.stage) {
case 'downloading': return 'Downloading firmware…'
case 'preparing': return 'Preparing radio…'
case 'erasing': return 'Erasing chip…'
case 'writing': return 'Writing firmware…'
case 'autoinstalling': return 'Installing (rnodeconf)…'
@@ -572,7 +551,7 @@ const flashStageLabel = computed(() => {
function openFlashStep() {
flashFamily.value = (probe.value?.kind as FlashFirmwareFamily) ?? ''
flashBoard.value = resolvedFlashBoard.value
flashBoard.value = ''
flashConfirmed.value = false
flashJob.value = null
error.value = ''
@@ -635,6 +614,11 @@ async function cancelFlash() {
}
}
onBeforeUnmount(() => {
stopFlashPoll()
stopProbeProgress()
})
function closeFlashStep() {
stopFlashPoll()
if (mesh.flashFlowPath) {