fix: surface receive post-commit trust failures

This commit is contained in:
2026-08-22 16:56:38 +02:00
parent c59521a421
commit f087ebe2f2
6 changed files with 585 additions and 452 deletions
+29 -214
View File
@@ -14,12 +14,7 @@
<!-- input -->
<q-card-section v-if="step === 'input'" class="q-pt-sm">
<qr-scanner
v-if="scanning"
class="q-mb-md"
@decode="onScan"
@error="onScanError"
/>
<qr-scanner v-if="scanning" class="q-mb-md" @decode="onScan" @error="onScanError" />
<q-input
v-model="input"
@@ -82,16 +77,16 @@
<template #avatar>
<q-icon name="info" color="info" />
</template>
The mint could not be reached, so the note is stored unconfirmed at the
sender's declared amount. Refresh it later to confirm.
The mint could not be reached, so the note is stored unconfirmed at the sender's declared
amount. Refresh it later to confirm.
</q-banner>
<q-banner v-if="rotationWarning" class="sattle-card text-warning q-mb-md" rounded>
<template #avatar>
<q-icon name="warning" color="warning" />
</template>
The note is in your wallet, but it could not be fully secured yet — the
sender may still hold a copy. You can secure it later.
The note is in your wallet, but it could not be fully secured yet — the sender may still
hold a copy. You can secure it later.
</q-banner>
<q-btn
@@ -114,8 +109,8 @@
<strong>{{ trustServer }}</strong>
</div>
<div class="text-caption text-grey-5 q-mb-md">
Trusting saves the mint so it is offered next time. You can manage trusted
mints in Settings.
Trusting saves the mint so it is offered next time. You can manage trusted mints in
Settings.
</div>
<div class="row q-gutter-sm justify-end">
<q-btn flat no-caps color="grey-5" label="Just this once" @click="skipTrust" />
@@ -134,22 +129,8 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Notify } from 'quasar';
import {
NoteSpentError,
NoteUnknownError,
PendingNoteError,
isValidNoteInput,
} from 'lnurlcash-kit';
import QrScanner from '../QrScanner.vue';
import { receiveBearer } from '@/lnurlcash/ops';
import type { NewBearer } from '@/lnurlcash/types';
import { floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet';
import { useMintsStore } from '@/stores/mints';
import { useActivityStore } from '@/stores/activity';
import { useReceiveTokenDialog } from '@/composables/useReceiveTokenDialog';
const props = defineProps<{ modelValue: boolean; initialInput?: string }>();
const emit = defineEmits<{
@@ -157,193 +138,27 @@ const emit = defineEmits<{
received: [];
}>();
const wallet = useWalletStore();
const mints = useMintsStore();
const activity = useActivityStore();
// whole-sat display for received amounts (msat remainder rounded down, per
// units.ts's floorMsatToSat)
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
const errMsg = (err: unknown): string =>
err instanceof Error ? err.message : 'Something went wrong.';
// distinct, jargon-free error states for the definitive service answers
type ReceiveErrorKind =
| 'spent'
| 'unknown'
| 'pending'
| 'duplicate'
| 'invalid'
| 'generic'
| '';
const ERROR_TEXT: Record<Exclude<ReceiveErrorKind, ''>, string> = {
spent: 'This note has already been spent.',
unknown: "The mint doesn't know this note.",
pending: 'This note is locked mid-payment — try again shortly.',
duplicate: 'This note is already in your wallet.',
invalid: 'Not a valid bearer note.',
generic: '',
};
const ERROR_ICON: Record<Exclude<ReceiveErrorKind, ''>, string> = {
spent: 'money_off',
unknown: 'help_outline',
pending: 'hourglass_top',
duplicate: 'content_copy',
invalid: 'error_outline',
generic: 'error_outline',
};
const errorText = computed(() =>
errorKind.value === 'generic'
? errorMessageText.value
: errorKind.value === ''
? ''
: ERROR_TEXT[errorKind.value],
);
const errorIcon = computed(() =>
errorKind.value === '' ? 'error_outline' : ERROR_ICON[errorKind.value],
);
type Step = 'input' | 'success';
const step = ref<Step>('input');
const input = ref('');
const scanning = ref(false);
const busy = ref(false);
const errorKind = ref<ReceiveErrorKind>('');
const errorMessageText = ref('');
const inputValid = computed(() => isValidNoteInput(input.value.trim()));
const clearError = () => {
errorKind.value = '';
errorMessageText.value = '';
};
const classifyError = (err: unknown): void => {
let kind: Exclude<ReceiveErrorKind, ''>;
if (err instanceof NoteSpentError) {
kind = 'spent';
} else if (err instanceof NoteUnknownError) {
kind = 'unknown';
} else if (err instanceof PendingNoteError) {
kind = 'pending';
} else if (err instanceof Error && err.message.includes('already in your wallet')) {
kind = 'duplicate';
} else if (err instanceof Error && err.message.includes('Not an LNURLcash bearer note')) {
kind = 'invalid';
} else {
kind = 'generic';
errorMessageText.value = errMsg(err);
}
errorKind.value = kind;
Notify.create({
type: 'negative',
message: kind === 'generic' ? errorMessageText.value : ERROR_TEXT[kind],
});
};
// re-entrancy guard: a scanner double-fire or Enter+click landing together
// must not run two receives for the same note - both would pass the
// duplicate check before either addBearers landed
const receive = async () => {
const value = input.value.trim();
if (busy.value || value === '') return;
busy.value = true;
clearError();
try {
const claimed = await receiveBearer(value, wallet.bearers);
const note = claimed.note;
const server = new URL(note.url).host;
const wasTrusted = mints.isTrusted(server);
const notes: NewBearer[] = claimed.possibleCopy
? [note, claimed.possibleCopy]
: [note];
await wallet.addBearers(notes);
receivedSats.value = displaySats(note.amount);
receivedServer.value = server;
unverifiedNote.value = !note.verified;
rotationWarning.value = claimed.rotationError ?? '';
activity.log(
'receive',
`Received ${receivedSats.value.toLocaleString()} sats from ${server}.`,
);
Notify.create({
type: 'positive',
message: `Received ${receivedSats.value.toLocaleString()} sats.`,
});
emit('received');
scanning.value = false;
step.value = 'success';
if (!wasTrusted && note.mintPubkey) {
trustServer.value = server;
trustPubkey.value = note.mintPubkey;
showTrust.value = true;
}
} catch (err) {
classifyError(err);
} finally {
busy.value = false;
}
};
const onScan = (text: string) => {
input.value = text;
scanning.value = false;
void receive();
};
const onScanError = (message: string) => {
scanning.value = false;
Notify.create({ type: 'negative', message });
};
// ---- success ----
const receivedSats = ref(0);
const receivedServer = ref('');
const unverifiedNote = ref(false);
const rotationWarning = ref('');
// ---- trust prompt ----
const showTrust = ref(false);
const trustServer = ref('');
const trustPubkey = ref('');
const trustMint = () => {
try {
mints.trust(trustServer.value, trustPubkey.value);
Notify.create({ type: 'positive', message: 'Mint trusted.' });
} catch (err) {
Notify.create({ type: 'negative', message: errMsg(err) });
} finally {
showTrust.value = false;
}
};
const skipTrust = () => {
showTrust.value = false;
Notify.create({
type: 'warning',
message: 'Note added, but this mint is not in your trusted list yet — you can review it in Settings.',
});
};
watch(
() => props.modelValue,
(open) => {
if (!open) return;
step.value = 'input';
input.value = props.initialInput ?? '';
scanning.value = false;
busy.value = false;
clearError();
unverifiedNote.value = false;
rotationWarning.value = '';
},
);
const {
busy,
errorIcon,
errorKind,
errorText,
input,
inputValid,
onScan,
onScanError,
receive,
receivedSats,
receivedServer,
rotationWarning,
scanning,
showTrust,
skipTrust,
step,
trustMint,
trustServer,
unverifiedNote,
} = useReceiveTokenDialog(props, emit);
</script>
<style lang="scss" scoped>