fix: harden node upgrades and prepare 1.9.0-alpha
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 = ''
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user