fix(ecash): guide unseeded wallets through Lightning address setup
This commit is contained in:
@@ -3,6 +3,9 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
|
||||
|
||||
defineProps<{ setupOnly?: boolean }>()
|
||||
const emit = defineEmits<{ ready: [] }>()
|
||||
|
||||
// Ecash (Cashu) wallet backup card — the same shape as the node recovery
|
||||
// phrase and the Lightning seed cards, deliberately: a third reveal pattern
|
||||
// would be a third thing to learn.
|
||||
@@ -102,12 +105,14 @@ async function submitReveal() {
|
||||
}
|
||||
|
||||
function closeReveal() {
|
||||
const established = revealedWords.value.length > 0
|
||||
showRevealModal.value = false
|
||||
revealedWords.value = []
|
||||
revealPassword.value = ''
|
||||
revealCode.value = ''
|
||||
revealPassphrase.value = ''
|
||||
showRevealPassphrase.value = false
|
||||
if (established) emit('ready')
|
||||
}
|
||||
|
||||
async function copyRevealedWords() {
|
||||
@@ -223,7 +228,7 @@ async function restoreFromPhrase() {
|
||||
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-1">Ecash backup phrase</h2>
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ setupOnly ? 'Set up your Cashu Lightning address' : 'Ecash backup phrase' }}</h2>
|
||||
|
||||
<p v-if="status?.active && status?.source === 'node-seed'" class="text-sm text-white/60">
|
||||
Your ecash wallet has its own 24-word phrase, derived from this node's recovery
|
||||
@@ -262,10 +267,10 @@ async function restoreFromPhrase() {
|
||||
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
:class="!status?.active ? 'bg-orange-500/20 border-orange-400/30' : ''"
|
||||
@click="openReveal"
|
||||
>{{ status?.active ? 'Reveal' : 'Set up backup' }}</button>
|
||||
>{{ status?.active ? 'Reveal' : (setupOnly ? 'Set up address' : 'Set up backup') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="status?.active" class="mt-4 pt-4 border-t border-white/10">
|
||||
<div v-if="status?.active && !setupOnly" class="mt-4 pt-4 border-t border-white/10">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<p class="text-sm text-white/60 min-w-0">
|
||||
<span class="text-white/80 font-medium">Restore from this phrase.</span>
|
||||
@@ -284,7 +289,7 @@ async function restoreFromPhrase() {
|
||||
<p v-if="restoreError" role="alert" class="mt-3 text-xs alert-error px-3 py-2 rounded-lg">{{ restoreError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-white/10">
|
||||
<div v-if="!setupOnly" class="mt-4 pt-4 border-t border-white/10">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<p class="text-sm text-white/60 min-w-0">
|
||||
<span class="text-white/80 font-medium">Use a phrase from another wallet.</span>
|
||||
|
||||
@@ -77,8 +77,13 @@
|
||||
<div v-else-if="lnAddressLoading" class="mb-4 text-center text-white/50 text-sm py-4">
|
||||
{{ t('receiveBitcoin.lnAddressLoading') }}
|
||||
</div>
|
||||
<div v-else-if="lnAddressNeedsSetup" class="mb-3">
|
||||
<p class="text-sm text-white/70 mb-3">Set up this wallet's recovery phrase once to enable its Lightning address.</p>
|
||||
<EcashSeedBackup setup-only @ready="loadLnAddress" />
|
||||
</div>
|
||||
<div v-else-if="lnAddressError" class="mb-3 text-xs text-white/40">
|
||||
{{ t('receiveBitcoin.lnAddressUnavailable') }}
|
||||
<button type="button" class="glass-button rounded-lg px-3 py-2 ml-2" @click="loadLnAddress">Retry</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
@@ -132,6 +137,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import CopyButton from '@/components/CopyButton.vue'
|
||||
import EcashSeedBackup from '@/components/EcashSeedBackup.vue'
|
||||
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
|
||||
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
||||
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||
@@ -214,6 +220,7 @@ const error = ref('')
|
||||
const lnAddress = ref('')
|
||||
const lnAddressLoading = ref(false)
|
||||
const lnAddressError = ref(false)
|
||||
const lnAddressNeedsSetup = ref(false)
|
||||
// A payment the backend fetched (and so already consumed at Minibits) but
|
||||
// couldn't redeem yet — it's queued for automatic retry, not lost, but the
|
||||
// operator should see it rather than have it be a silent, unbounded wait.
|
||||
@@ -230,6 +237,7 @@ async function loadLnAddress() {
|
||||
if (lnAddress.value || lnAddressLoading.value) return
|
||||
lnAddressLoading.value = true
|
||||
lnAddressError.value = false
|
||||
lnAddressNeedsSetup.value = false
|
||||
try {
|
||||
const res = await rpcClient.call<{ address?: string }>({
|
||||
method: 'wallet.ecash-lnaddress',
|
||||
@@ -245,6 +253,16 @@ async function loadLnAddress() {
|
||||
}
|
||||
} catch {
|
||||
lnAddressError.value = true
|
||||
// A legacy wallet may hold valid proofs without having a recovery phrase.
|
||||
// Use the existing authenticated setup flow; never silently create a new
|
||||
// identity or send the user to an unexplained generic service error.
|
||||
try {
|
||||
const seedStatus = await rpcClient.call<{ active: boolean; can_activate: boolean }>({
|
||||
method: 'wallet.ecash-seed-status',
|
||||
timeout: 5000,
|
||||
})
|
||||
lnAddressNeedsSetup.value = seedStatus.active === false && seedStatus.can_activate === true
|
||||
} catch { /* Keep the retryable service error when status is unavailable. */ }
|
||||
} finally {
|
||||
lnAddressLoading.value = false
|
||||
}
|
||||
|
||||
@@ -22,6 +22,37 @@ describe('EcashSeedBackup reveal credentials (#127)', () => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('signals readiness only after authenticated setup is finished and clears the words', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'wallet.ecash-seed-status') {
|
||||
return { active: false, can_activate: true, derivable_from_node_seed: true, source: null } as never
|
||||
}
|
||||
if (method === 'wallet.ecash-seed-reveal') {
|
||||
return { words: [...Array(23).fill('abandon'), 'art'], source: 'node-seed' } as never
|
||||
}
|
||||
throw new Error('unexpected request')
|
||||
})
|
||||
wrapper = mount(EcashSeedBackup, { props: { setupOnly: true }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
await wrapper.get('button').trigger('click')
|
||||
const cancel = Array.from(document.body.querySelectorAll('button')).find(b => b.textContent === 'Cancel')!
|
||||
cancel.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('ready')).toBeUndefined()
|
||||
await wrapper.get('button').trigger('click')
|
||||
const password = document.body.querySelector<HTMLInputElement>('input[autocomplete="current-password"]')!
|
||||
password.value = 'test-password'
|
||||
password.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
document.body.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('ready')).toBeUndefined()
|
||||
Array.from(document.body.querySelectorAll('button')).find(b => b.textContent === 'Done')!.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('ready')).toEqual([[]])
|
||||
expect(document.body.querySelector('[aria-labelledby="reveal-ecash-seed-title"]')).toBeNull()
|
||||
expect(document.body.textContent).not.toContain('abandon')
|
||||
})
|
||||
|
||||
it('asks for a separate backup passphrase only after password decryption fails', async () => {
|
||||
vi.mocked(rpcClient.call)
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
|
||||
import EcashSeedBackup from '../EcashSeedBackup.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
@@ -39,6 +40,51 @@ beforeEach(() => {
|
||||
// unmounts the dialog — but the RPC-eager tab switch is exactly the kind of
|
||||
// path a future change could regress, so it's worth pinning down.
|
||||
describe('ReceiveBitcoinModal — ecash tab click', () => {
|
||||
it('offers authenticated setup for an unseeded wallet and retries the address after setup', async () => {
|
||||
let active = false
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'wallet.ecash-lnaddress') {
|
||||
if (!active) throw new Error('The ecash wallet has no seed yet')
|
||||
return { address: 'someone@minibits.cash' } as never
|
||||
}
|
||||
if (method === 'wallet.ecash-seed-status') {
|
||||
return { active, can_activate: true, derivable_from_node_seed: true, source: null } as never
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
const wrapper = mount(ReceiveBitcoinModal, { props: { show: true }, attachTo: document.body })
|
||||
const tab = Array.from(document.body.querySelectorAll('button')).find(b => b.textContent?.toLowerCase().includes('ecash'))!
|
||||
tab.click()
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('Set up your Cashu Lightning address')
|
||||
expect(document.body.textContent).not.toContain('receiveBitcoin.lnAddressUnavailable')
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([r]) => r.method === 'wallet.ecash-seed-reveal')).toBe(false)
|
||||
active = true
|
||||
wrapper.findComponent(EcashSeedBackup).vm.$emit('ready')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('someone@minibits.cash')
|
||||
expect(wrapper.emitted('close')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps a seeded wallet on the retry path during a service outage', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'wallet.ecash-seed-status') return { active: true, can_activate: true } as never
|
||||
throw new Error('service unavailable')
|
||||
})
|
||||
const wrapper = mount(ReceiveBitcoinModal, { props: { show: true }, attachTo: document.body })
|
||||
Array.from(document.body.querySelectorAll('button')).find(b => b.textContent?.toLowerCase().includes('ecash'))!.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(EcashSeedBackup).exists()).toBe(false)
|
||||
expect(document.body.textContent).toContain('receiveBitcoin.lnAddressUnavailable')
|
||||
const retry = Array.from(document.body.querySelectorAll('button')).find(b => b.textContent === 'Retry')!
|
||||
expect(retry).toBeTruthy()
|
||||
retry.click()
|
||||
await flushPromises()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([r]) => r.method === 'wallet.ecash-lnaddress')).toHaveLength(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not close/emit when the ecash tab is clicked and the RPC succeeds', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ address: 'someone@minibits.cash' } as never)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user