diff --git a/neode-ui/src/components/EcashSeedBackup.vue b/neode-ui/src/components/EcashSeedBackup.vue index 2acc272d..b45534e3 100644 --- a/neode-ui/src/components/EcashSeedBackup.vue +++ b/neode-ui/src/components/EcashSeedBackup.vue @@ -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() {
-

Ecash backup phrase

+

{{ setupOnly ? 'Set up your Cashu Lightning address' : 'Ecash backup phrase' }}

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' }} + >{{ status?.active ? 'Reveal' : (setupOnly ? 'Set up address' : 'Set up backup') }}

-
+

Restore from this phrase. @@ -284,7 +289,7 @@ async function restoreFromPhrase() {

-
+

Use a phrase from another wallet. diff --git a/neode-ui/src/components/ReceiveBitcoinModal.vue b/neode-ui/src/components/ReceiveBitcoinModal.vue index 8a43abc2..432efedd 100644 --- a/neode-ui/src/components/ReceiveBitcoinModal.vue +++ b/neode-ui/src/components/ReceiveBitcoinModal.vue @@ -77,8 +77,13 @@

{{ t('receiveBitcoin.lnAddressLoading') }}
+
+

Set up this wallet's recovery phrase once to enable its Lightning address.

+ +
{{ t('receiveBitcoin.lnAddressUnavailable') }} +
@@ -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 } diff --git a/neode-ui/src/components/__tests__/EcashSeedBackup.test.ts b/neode-ui/src/components/__tests__/EcashSeedBackup.test.ts index 1904fe5f..de57ba70 100644 --- a/neode-ui/src/components/__tests__/EcashSeedBackup.test.ts +++ b/neode-ui/src/components/__tests__/EcashSeedBackup.test.ts @@ -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('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({ diff --git a/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts b/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts index 9c27de6b..6c4dbcf0 100644 --- a/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts +++ b/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts @@ -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)