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
+31 -238
View File
@@ -202,19 +202,8 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Notify } from 'quasar';
import QrCode from '../QrCode.vue';
import { writeClipboard } from '@/capabilities/clipboard';
import { prepareMint, claimMintedNote } from '@/lnurlcash/ops';
import type { ClaimedNote, PreparedMint } from '@/lnurlcash/ops';
import type { NewBearer } from '@/lnurlcash/types';
import { msatToSats, satsToMsat, floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints';
import { useWalletStore } from '@/stores/wallet';
import { useMintsStore } from '@/stores/mints';
import { useActivityStore } from '@/stores/activity';
import { useReceiveLightningDialog } from '@/composables/useReceiveLightningDialog';
const props = defineProps<{ modelValue: boolean }>();
const emit = defineEmits<{
@@ -222,232 +211,36 @@ const emit = defineEmits<{
received: [];
}>();
const wallet = useWalletStore();
const mints = useMintsStore();
const activity = useActivityStore();
const CUSTOM_MINT = '__custom__';
// 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 errorMessage = (err: unknown): string =>
err instanceof Error ? err.message : 'Something went wrong.';
type Step = 'form' | 'invoice' | 'success';
const step = ref<Step>('form');
// ---- form ----
const amountSats = ref<number | null>(null);
const mintChoice = ref('');
const customMint = ref('');
const preparing = ref(false);
const formError = ref('');
type MintOption = { label: string; value: string };
const mintOptions = computed<MintOption[]>(() => {
const options: MintOption[] = [];
const seen = new Set<string>();
for (const mint of mints.mints) {
const address = mint.username ? `${mint.username}@${mint.server}` : `@${mint.server}`;
if (seen.has(address)) continue;
seen.add(address);
const label = mint.nodeAlias ? `${address} (${mint.nodeAlias})` : address;
options.push({ label, value: address });
}
for (const publicMint of mints.PUBLIC_MINTS) {
if (seen.has(publicMint)) continue;
seen.add(publicMint);
options.push({ label: publicMint, value: publicMint });
}
options.push({ label: 'Another mint…', value: CUSTOM_MINT });
return options;
});
const defaultChoice = (): string => {
const options = mintOptions.value;
if (mints.defaultMint) {
const match = options.find((o) => o.value.endsWith(`@${mints.defaultMint}`));
if (match) return match.value;
}
const first = options[0];
return first && first.value !== CUSTOM_MINT ? first.value : CUSTOM_MINT;
};
const formValid = computed(() => {
if (!Number.isInteger(amountSats.value) || (amountSats.value ?? 0) < 1) return false;
return mintChoice.value === CUSTOM_MINT
? customMint.value.trim() !== ''
: mintChoice.value !== '';
});
const createInvoice = async () => {
const sats = amountSats.value;
if (!sats || preparing.value) return;
preparing.value = true;
formError.value = '';
try {
const mintInput = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value;
const preparedMint = await prepareMint(mintInput, satsToMsat(sats));
if (!preparedMint.verifyUrl) {
// without a verify URL the payment can never be auto-claimed - showing
// a payable invoice here would strand the sats at the mint
formError.value =
'This mint does not support automatic claiming, so sattle cannot receive from it. Choose a different mint.';
return;
}
prepared.value = preparedMint;
claimRun = null;
claimError.value = '';
step.value = 'invoice';
beginClaim();
} catch (err) {
formError.value = errorMessage(err);
Notify.create({ type: 'negative', message: formError.value });
} finally {
preparing.value = false;
}
};
// ---- invoice ----
const prepared = ref<PreparedMint | null>(null);
const waiting = ref(false);
const claimError = ref('');
// single in-flight claim; "stop waiting" only detaches the UI from it - the
// claim itself always runs to completion so a settled payment is never
// abandoned unclaimed
let claimRun: Promise<void> | null = null;
const grossSats = computed(() => (prepared.value ? msatToSats(prepared.value.grossMsat) : 0));
const netSats = computed(() =>
prepared.value ? msatToSats(prepared.value.expectedNoteValueMsat) : 0,
);
const feeSats = computed(() => grossSats.value - netSats.value);
const copyInvoice = async () => {
if (!prepared.value) return;
try {
await writeClipboard(prepared.value.invoice);
Notify.create({ type: 'positive', message: 'Invoice copied.' });
} catch (err) {
Notify.create({ type: 'negative', message: errorMessage(err) });
}
};
const beginClaim = () => {
if (!prepared.value || claimRun) return;
waiting.value = true;
claimError.value = '';
const current = prepared.value;
claimRun = (async () => {
try {
const claimed = await claimMintedNote(current);
await onClaimed(claimed, current);
} catch (err) {
claimError.value = `${errorMessage(err)} The invoice stays valid — you can try again.`;
Notify.create({ type: 'negative', message: errorMessage(err) });
} finally {
waiting.value = false;
}
})();
};
// after a failed claim the run is over - allow a fresh attempt
const retryClaim = () => {
claimRun = null;
beginClaim();
};
const stopWaiting = () => {
waiting.value = false;
};
const resumeWaiting = () => {
if (claimRun) waiting.value = true;
};
// ---- success ----
const receivedSats = ref(0);
const receivedServer = ref('');
const rotationWarning = ref('');
const onClaimed = async (claimed: ClaimedNote, from: PreparedMint) => {
const server = from.server;
const wasTrusted = mints.isTrusted(server);
const notes: NewBearer[] = claimed.possibleCopy
? [claimed.note, claimed.possibleCopy]
: [claimed.note];
await wallet.addBearers(notes);
receivedSats.value = displaySats(claimed.note.amount);
receivedServer.value = server;
rotationWarning.value = claimed.rotationError ?? '';
activity.log(
'mint',
`Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`,
);
const nodeInfo = mintAddressCacheInfo(from.nodeInfo, from.username);
if (nodeInfo) mints.cacheNodeInfo(server, nodeInfo);
Notify.create({
type: 'positive',
message: `Received ${receivedSats.value.toLocaleString()} sats.`,
});
emit('received');
if (props.modelValue) step.value = 'success';
if (!wasTrusted && claimed.note.mintPubkey) {
trustServer.value = server;
trustPubkey.value = claimed.note.mintPubkey;
trustNodeAlias.value = from.nodeInfo?.nodeAlias ?? '';
showTrust.value = true;
}
};
// ---- trust prompt ----
const showTrust = ref(false);
const trustServer = ref('');
const trustPubkey = ref('');
const trustNodeAlias = ref('');
const trustMint = () => {
try {
mints.trust(trustServer.value, trustPubkey.value, {
...(trustNodeAlias.value ? { nodeAlias: trustNodeAlias.value } : {}),
});
Notify.create({ type: 'positive', message: 'Mint trusted.' });
} catch (err) {
Notify.create({ type: 'negative', message: errorMessage(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.',
});
};
// fresh form every time the dialog opens; a claim already in flight keeps
// running in the background regardless
watch(
() => props.modelValue,
(open) => {
if (!open) return;
step.value = 'form';
amountSats.value = null;
customMint.value = '';
mintChoice.value = defaultChoice();
preparing.value = false;
formError.value = '';
prepared.value = null;
waiting.value = false;
rotationWarning.value = '';
},
);
const {
CUSTOM_MINT,
amountSats,
claimError,
copyInvoice,
createInvoice,
customMint,
feeSats,
formError,
formValid,
grossSats,
mintChoice,
mintOptions,
netSats,
prepared,
preparing,
receivedSats,
receivedServer,
resumeWaiting,
retryClaim,
rotationWarning,
showTrust,
skipTrust,
step,
stopWaiting,
trustMint,
trustNodeAlias,
trustServer,
waiting,
} = useReceiveLightningDialog(props, emit);
</script>
<style lang="scss" scoped>
+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>