feat(wallet): ecash gets the real payment success screen, with copyable proof
Demo images / Build & push demo images (push) Failing after 2m37s
Demo images / Build & push demo images (push) Failing after 2m37s
Redeeming ecash reported success as one line of small green text, while an on-chain or Lightning payment got the full moment — amount, verb, and the identifiers you can copy. That asymmetry matters most for ecash: it leaves no public ledger entry, so if the payment is ever questioned there is nothing to look up afterwards. Whatever isn't copyable at that instant is simply gone. The success pane is extracted from SendBitcoinModal into a shared PaymentSuccessPane so Cashu and Fedimint show the *same* screen rather than a lookalike, and the copyable-row treatment is defined once. Each caller passes the identifiers its protocol actually has; ecash receive now shows the issuing mint (newly returned by wallet.ecash-receive) and the redeemed token itself, clamped so a long token doesn't flood the pane. Also: the test-ecash switch is a proper toggle (role="switch", keyboard focusable) rather than a checkbox — it selects which purse the wallet is looking at, so it should read as a mode you are in. SendBitcoinModal still carries its own copy of the markup; consolidating it onto the shared component is a follow-up, deliberately not done in the same change as the money-path wiring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ffec7d3114
commit
f4a1c47429
@@ -206,10 +206,17 @@ impl RpcHandler {
|
|||||||
// (redeemed at the mint) or Fedimint notes (reissued via the fmcd
|
// (redeemed at the mint) or Fedimint notes (reissued via the fmcd
|
||||||
// sidecar). Detect by prefix and route accordingly.
|
// sidecar). Detect by prefix and route accordingly.
|
||||||
if is_cashu_token(token) {
|
if is_cashu_token(token) {
|
||||||
|
// Which mint issued it, for the success screen. Ecash leaves no
|
||||||
|
// public trace once redeemed, so the mint URL is the only thing a
|
||||||
|
// person can quote later if the payment is ever disputed.
|
||||||
|
let mint_url = crate::wallet::cashu::CashuToken::deserialize(token)
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.mint_urls().first().map(|m| m.to_string()));
|
||||||
let amount = ecash::receive_token(&self.config.data_dir, token).await?;
|
let amount = ecash::receive_token(&self.config.data_dir, token).await?;
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"received_sats": amount,
|
"received_sats": amount,
|
||||||
"kind": "cashu",
|
"kind": "cashu",
|
||||||
|
"mint_url": mint_url,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="send-success-badge mx-auto mb-6">
|
||||||
|
<ScreensaverRing size="badge" />
|
||||||
|
<div class="send-success-burst">
|
||||||
|
<div class="burst-core">
|
||||||
|
<svg class="w-14 h-14 text-green-400 burst-check" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="amount > 0" class="text-5xl font-black text-green-400 mb-1">
|
||||||
|
{{ amount.toLocaleString() }}<span class="text-2xl font-bold text-green-400/70"> sats</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold tracking-widest text-white mb-1">{{ verb }}</div>
|
||||||
|
<p v-if="methodLabel" class="text-sm text-white/50 mb-6">{{ methodLabel }}</p>
|
||||||
|
|
||||||
|
<!-- Everything a person needs to hand to whoever can help them if this
|
||||||
|
payment is later disputed or goes missing. Ecash has no public
|
||||||
|
ledger to look anything up in afterwards, so if it isn't copyable
|
||||||
|
here it is gone. -->
|
||||||
|
<div v-if="rows.length || note" class="p-4 bg-white/5 rounded-xl text-left space-y-4 mb-6">
|
||||||
|
<div v-for="row in rows" :key="row.label">
|
||||||
|
<p class="text-xs text-white/50 mb-1">{{ row.label }}</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="flex-1 text-xs font-mono text-white/80 break-all" :class="row.truncate ? 'line-clamp-3' : ''">{{ row.value }}</p>
|
||||||
|
<CopyButton class="shrink-0" :value="row.value" />
|
||||||
|
</div>
|
||||||
|
<p v-if="row.hint" class="text-[11px] text-white/40 mt-1">{{ row.hint }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="note" class="text-xs text-white/60">{{ note }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
v-if="againLabel"
|
||||||
|
type="button"
|
||||||
|
class="flex-1 glass-button px-4 py-3 rounded-xl text-sm font-medium"
|
||||||
|
@click="emit('again')"
|
||||||
|
>{{ againLabel }}</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 glass-button glass-button-warning px-4 py-3 rounded-xl text-sm font-semibold"
|
||||||
|
@click="emit('done')"
|
||||||
|
>{{ t('common.done') || 'Done' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* The payment "moment" screen, shared by every money flow.
|
||||||
|
*
|
||||||
|
* Extracted from SendBitcoinModal so Cashu and Fedimint show the *same*
|
||||||
|
* screen rather than a lookalike, and so the copyable-identifier row is
|
||||||
|
* defined once. Each caller supplies whatever identifiers its protocol has
|
||||||
|
* — a payment hash, a txid, a mint URL, a quote id, or the token itself.
|
||||||
|
*/
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import CopyButton from '@/components/CopyButton.vue'
|
||||||
|
import ScreensaverRing from '@/components/ScreensaverRing.vue'
|
||||||
|
|
||||||
|
export interface SuccessRow {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
/** Extra context under the value, e.g. why someone would need it. */
|
||||||
|
hint?: string
|
||||||
|
/** Clamp very long values (a whole ecash token) instead of flooding the pane. */
|
||||||
|
truncate?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
amount: number
|
||||||
|
/** SENT / RECEIVED / REDEEMED … */
|
||||||
|
verb: string
|
||||||
|
methodLabel?: string
|
||||||
|
rows?: SuccessRow[]
|
||||||
|
note?: string
|
||||||
|
againLabel?: string
|
||||||
|
}>(),
|
||||||
|
{ methodLabel: '', rows: () => [], note: '', againLabel: '' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{ again: []; done: [] }>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Success badge (FED-06) — the branded ScreensaverRing carries the motion,
|
||||||
|
with the emerald pop-in check centred over it. */
|
||||||
|
.send-success-badge {
|
||||||
|
position: relative;
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.send-success-badge {
|
||||||
|
width: 192px;
|
||||||
|
height: 192px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.send-success-burst {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 7rem;
|
||||||
|
height: 7rem;
|
||||||
|
}
|
||||||
|
.burst-core {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
box-shadow: 0 0 48px rgba(16, 185, 129, 0.3);
|
||||||
|
animation: burst-pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.4) both;
|
||||||
|
}
|
||||||
|
.burst-check {
|
||||||
|
stroke-dasharray: 32;
|
||||||
|
stroke-dashoffset: 32;
|
||||||
|
animation: burst-draw 0.45s ease-out 0.25s forwards;
|
||||||
|
}
|
||||||
|
@keyframes burst-pop {
|
||||||
|
from { transform: scale(0.3); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes burst-draw {
|
||||||
|
to { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.burst-core, .burst-check { animation: none; }
|
||||||
|
.burst-check { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -36,16 +36,26 @@
|
|||||||
: 'Real, spendable sats.' }}
|
: 'Real, spendable sats.' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label class="flex items-center gap-2 cursor-pointer shrink-0">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<span class="text-white/60 text-xs">Test mode</span>
|
<span class="text-white/60 text-xs">Test mode</span>
|
||||||
<input
|
<!-- Switch, not a checkbox: this changes which purse the wallet
|
||||||
type="checkbox"
|
is looking at, so it should read as a mode you are in. -->
|
||||||
:checked="ecashIsTest"
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="ecashIsTest"
|
||||||
|
aria-label="Test mode"
|
||||||
:disabled="switchingNetwork"
|
:disabled="switchingNetwork"
|
||||||
class="h-4 w-4 accent-orange-500"
|
class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-orange-400/60"
|
||||||
@change="setEcashNetwork(($event.target as HTMLInputElement).checked)"
|
:class="ecashIsTest ? 'bg-orange-500' : 'bg-white/15'"
|
||||||
/>
|
@click="setEcashNetwork(!ecashIsTest)"
|
||||||
</label>
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform"
|
||||||
|
:class="ecashIsTest ? 'translate-x-6' : 'translate-x-1'"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="ecashIsTest" class="mt-2 text-xs text-orange-200">
|
<p v-if="ecashIsTest" class="mt-2 text-xs text-orange-200">
|
||||||
Your real ecash balance is safe and untouched — it reappears when you turn test mode off.
|
Your real ecash balance is safe and untouched — it reappears when you turn test mode off.
|
||||||
|
|||||||
@@ -116,6 +116,23 @@
|
|||||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="receive-bitcoin-title">
|
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="receive-bitcoin-title">
|
||||||
<h2 id="receive-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.receiveBitcoinTitle') }}</h2>
|
<h2 id="receive-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.receiveBitcoinTitle') }}</h2>
|
||||||
|
|
||||||
|
<!-- The payment's moment — the same screen the on-chain and Lightning
|
||||||
|
flows use, so ecash isn't a second-class receipt. Replaces the form
|
||||||
|
until dismissed, and carries the identifiers a person would need if
|
||||||
|
the payment is ever questioned. -->
|
||||||
|
<PaymentSuccessPane
|
||||||
|
v-if="ecashSuccess"
|
||||||
|
:amount="ecashSuccess.amount"
|
||||||
|
:verb="ecashSuccess.verb"
|
||||||
|
:method-label="ecashSuccess.methodLabel"
|
||||||
|
:rows="ecashSuccess.rows"
|
||||||
|
again-label="Receive another"
|
||||||
|
@again="ecashSuccess = null"
|
||||||
|
@done="closeUnifiedReceiveModal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
|
||||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||||
<button
|
<button
|
||||||
v-for="m in (['onchain', 'lightning', 'ecash'] as const)"
|
v-for="m in (['onchain', 'lightning', 'ecash'] as const)"
|
||||||
@@ -171,6 +188,7 @@
|
|||||||
{{ unifiedReceiveProcessing ? 'Processing...' : receiveMethod === 'onchain' ? 'Generate Address' : receiveMethod === 'lightning' ? 'Create Invoice' : 'Receive' }}
|
{{ unifiedReceiveProcessing ? 'Processing...' : receiveMethod === 'onchain' ? 'Generate Address' : receiveMethod === 'lightning' ? 'Create Invoice' : 'Receive' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -180,6 +198,7 @@
|
|||||||
import { ref, computed, nextTick } from 'vue'
|
import { ref, computed, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
|
||||||
import { useLightningRequired } from '@/composables/useLightningRequired'
|
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||||
import { useTransportStore } from '@/stores/transport'
|
import { useTransportStore } from '@/stores/transport'
|
||||||
import { useMeshStore } from '@/stores/mesh'
|
import { useMeshStore } from '@/stores/mesh'
|
||||||
@@ -228,6 +247,14 @@ const unifiedReceiveProcessing = ref(false)
|
|||||||
const unifiedReceiveError = ref('')
|
const unifiedReceiveError = ref('')
|
||||||
const ecashReceiveToken = ref('')
|
const ecashReceiveToken = ref('')
|
||||||
const ecashReceiveResult = ref('')
|
const ecashReceiveResult = ref('')
|
||||||
|
// Details of the last successful ecash receive, for the success screen.
|
||||||
|
// Null = nothing to celebrate yet, so the form shows.
|
||||||
|
const ecashSuccess = ref<{
|
||||||
|
amount: number
|
||||||
|
verb: string
|
||||||
|
methodLabel: string
|
||||||
|
rows: SuccessRow[]
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
const effectiveSendMethod = computed(() => {
|
const effectiveSendMethod = computed(() => {
|
||||||
if (sendMethod.value !== 'auto') return sendMethod.value
|
if (sendMethod.value !== 'auto') return sendMethod.value
|
||||||
@@ -258,6 +285,7 @@ function closeUnifiedReceiveModal() {
|
|||||||
receiveOnchainAddress.value = ''
|
receiveOnchainAddress.value = ''
|
||||||
ecashReceiveToken.value = ''
|
ecashReceiveToken.value = ''
|
||||||
ecashReceiveResult.value = ''
|
ecashReceiveResult.value = ''
|
||||||
|
ecashSuccess.value = null
|
||||||
unifiedReceiveError.value = ''
|
unifiedReceiveError.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,12 +521,24 @@ async function unifiedReceive() {
|
|||||||
unifiedReceiveError.value = t('web5.pasteEcashToken')
|
unifiedReceiveError.value = t('web5.pasteEcashToken')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const res = await rpcClient.call<{ received_sats: number; kind?: string }>({
|
const res = await rpcClient.call<{ received_sats: number; kind?: string; mint_url?: string }>({
|
||||||
method: 'wallet.ecash-receive',
|
method: 'wallet.ecash-receive',
|
||||||
params: { token: ecashReceiveToken.value.trim() },
|
params: { token: ecashReceiveToken.value.trim() },
|
||||||
})
|
})
|
||||||
const label = res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'
|
const label = res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'
|
||||||
ecashReceiveResult.value = `Received ${res.received_sats} sats (${label})!`
|
ecashReceiveResult.value = `Received ${res.received_sats} sats (${label})!`
|
||||||
|
// Ecash leaves no public ledger entry behind, so the issuer and the
|
||||||
|
// redeemed token are the only things a person can quote later if the
|
||||||
|
// payment is ever questioned. Capture them before clearing the box.
|
||||||
|
const rows: SuccessRow[] = []
|
||||||
|
if (res.mint_url) rows.push({ label: 'Mint', value: res.mint_url })
|
||||||
|
rows.push({
|
||||||
|
label: label === 'Fedimint' ? 'Notes redeemed' : 'Token redeemed',
|
||||||
|
value: ecashReceiveToken.value.trim(),
|
||||||
|
hint: 'Keep this if you ever need to show what you redeemed.',
|
||||||
|
truncate: true,
|
||||||
|
})
|
||||||
|
ecashSuccess.value = { amount: res.received_sats, verb: 'RECEIVED', methodLabel: label, rows }
|
||||||
ecashReceiveToken.value = ''
|
ecashReceiveToken.value = ''
|
||||||
emit('balancesChanged')
|
emit('balancesChanged')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user