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
+51
View File
@@ -5,6 +5,7 @@ import { MINT_ORIGIN, NOTE_PATH } from '../helpers/MintMocker';
import { createFreshWallet } from '../helpers/wallet';
const AMOUNT_MSAT = 21_000; // 21 sats
const MINT_PUBKEY = `02${'aa'.repeat(32)}`;
// a syntactically valid bearer note against the mock mint - the k1 is a
// fresh random secret, so every test redeems a distinct note
@@ -58,4 +59,54 @@ test.describe('Receive bearer note', () => {
await page.keyboard.press('Escape');
await expect(page.locator('.balance-card .text-h2')).toHaveText('0');
});
test('an already trusted current-owner mint bypasses the first-contact prompt', async ({
page,
mint,
}) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY });
await mint.mockRotateOk();
await createFreshWallet(page);
const receiveDialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
await redeemNote(page, freshNoteUrl());
const trustDialog = page.locator('.q-dialog', { hasText: 'New mint' });
await expect(trustDialog).toBeVisible();
await trustDialog.getByRole('button', { name: 'Just this once' }).click();
await expect(trustDialog).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(receiveDialog).toHaveCount(0);
await redeemNote(page, freshNoteUrl());
await expect(receiveDialog.getByText('Received 21 sats')).toBeVisible();
await expect(trustDialog).toHaveCount(0);
await expect(page.locator('.balance-card .text-h2')).toHaveText('42');
});
test('trust failure after commit keeps received funds and warns against retry', async ({
page,
mint,
}) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY });
await mint.mockRotateOk();
await createFreshWallet(page);
await page.evaluate(() => {
localStorage.setItem('sattle_trusted_mints', '{"version":1,"ownerId":"malformed"}');
});
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
await redeemNote(page, freshNoteUrl());
await expect(dialog.getByText('Received 21 sats')).toBeVisible();
await expect(
page.getByText(/Funds were saved.*receive succeeded.*do not retry/i),
).toBeVisible();
await expect(dialog.locator('.q-banner')).toHaveCount(0);
const trustDialog = page.locator('.q-dialog', { hasText: 'New mint' });
await trustDialog.getByRole('button', { name: 'Just this once' }).click();
await dialog.getByRole('button', { name: 'Done' }).click();
await page.reload();
await expect(page.locator('.balance-card .text-h2')).toHaveText('21');
});
});
+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>
+46
View File
@@ -0,0 +1,46 @@
import { ref } from 'vue';
import { Notify } from 'quasar';
import { useMintsStore } from '@/stores/mints';
export const useMintTrustPrompt = () => {
const mints = useMintsStore();
const showTrust = ref(false);
const trustServer = ref('');
const trustPubkey = ref('');
const trustNodeAlias = ref('');
const openTrust = (server: string, pubkey: string, nodeAlias = ''): void => {
trustServer.value = server;
trustPubkey.value = pubkey;
trustNodeAlias.value = nodeAlias;
showTrust.value = true;
};
const trustMint = async (): Promise<void> => {
try {
await mints.trust(trustServer.value, trustPubkey.value, {
...(trustNodeAlias.value ? { nodeAlias: trustNodeAlias.value } : {}),
});
Notify.create({ type: 'positive', message: 'Mint trusted.' });
} catch (error) {
const caught = error instanceof Error ? error : new Error(String(error));
Notify.create({ type: 'negative', message: caught.message });
} finally {
showTrust.value = false;
}
};
const skipTrust = (): void => {
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.',
});
};
return {
openTrust,
showTrust,
skipTrust,
trustMint,
trustNodeAlias,
trustServer,
};
};
@@ -0,0 +1,245 @@
import { computed, ref, watch } from 'vue';
import { Notify } from 'quasar';
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 { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
import { useMintsStore } from '@/stores/mints';
import { useActivityStore } from '@/stores/activity';
import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
import { useMintTrustPrompt } from './useMintTrustPrompt';
type ReceiveLightningProps = Readonly<{ modelValue: boolean }>;
type ReceiveLightningEmit = (event: 'received') => void;
type MintOption = Readonly<{ label: string; value: string }>;
export const useReceiveLightningDialog = (
props: ReceiveLightningProps,
emit: ReceiveLightningEmit,
) => {
const wallet = useWalletStore();
const mints = useMintsStore();
const activity = useActivityStore();
const CUSTOM_MINT = '__custom__';
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : 'Something went wrong.';
const step = ref<'form' | 'invoice' | 'success'>('form');
const amountSats = ref<number | null>(null);
const mintChoice = ref('');
const customMint = ref('');
const preparing = ref(false);
const formError = ref('');
const prepared = ref<PreparedMint | null>(null);
const waiting = ref(false);
const claimError = ref('');
const receivedSats = ref(0);
const receivedServer = ref('');
const rotationWarning = ref('');
const trustPrompt = useMintTrustPrompt();
let claimRun: Promise<void> | null = null;
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);
options.push({
label: mint.nodeAlias ? `${address} (${mint.nodeAlias})` : address,
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((option) => option.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 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 onClaimed = async (
claimed: ClaimedNote,
from: PreparedMint,
ownerFence: WalletOwnerFence,
): Promise<void> => {
const server = from.server;
const wasTrusted = mints.isTrusted(server);
const notes: NewBearer[] = claimed.possibleCopy
? [claimed.note, claimed.possibleCopy]
: [claimed.note];
let trustWarning = '';
try {
await wallet.addBearers(notes, ownerFence);
} catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error;
trustWarning = error.message;
}
receivedSats.value = displaySats(claimed.note.amount);
receivedServer.value = server;
rotationWarning.value = claimed.rotationError ?? '';
await activity.log(
'mint',
`Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`,
(error) => {
trustWarning = error.message;
},
);
const nodeInfo = mintAddressCacheInfo(from.nodeInfo, from.username);
if (nodeInfo) {
try {
await mints.cacheNodeInfo(server, nodeInfo);
} catch (error) {
if (!(error instanceof Error)) throw error;
trustWarning = `Funds were saved, but mint details could not be updated: ${errorMessage(error)}`;
}
}
Notify.create({
type: 'positive',
message: `Received ${receivedSats.value.toLocaleString()} sats.`,
});
if (trustWarning) Notify.create({ type: 'warning', message: trustWarning });
emit('received');
if (props.modelValue) step.value = 'success';
if (!wasTrusted && claimed.note.mintPubkey) {
trustPrompt.openTrust(server, claimed.note.mintPubkey, from.nodeInfo?.nodeAlias ?? '');
}
};
const beginClaim = (): void => {
if (!prepared.value || claimRun) return;
waiting.value = true;
claimError.value = '';
const current = prepared.value;
claimRun = (async () => {
try {
const ownerFence = wallet.captureOwnerFence();
await onClaimed(
await claimMintedNote(current, {}, { assertOwner: ownerFence }),
current,
ownerFence,
);
} catch (error) {
if (!(error instanceof Error)) throw error;
claimError.value = `${errorMessage(error)} The invoice stays valid — you can try again.`;
Notify.create({ type: 'negative', message: errorMessage(error) });
} finally {
waiting.value = false;
}
})();
};
const createInvoice = async (): Promise<void> => {
const sats = amountSats.value;
if (!sats || preparing.value) return;
preparing.value = true;
formError.value = '';
try {
const input = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value;
const next = await prepareMint(input, satsToMsat(sats));
if (!next.verifyUrl) {
formError.value =
'This mint does not support automatic claiming, so sattle cannot receive from it. Choose a different mint.';
return;
}
prepared.value = next;
claimRun = null;
claimError.value = '';
step.value = 'invoice';
beginClaim();
} catch (error) {
if (!(error instanceof Error)) throw error;
formError.value = errorMessage(error);
Notify.create({ type: 'negative', message: formError.value });
} finally {
preparing.value = false;
}
};
const copyInvoice = async (): Promise<void> => {
if (!prepared.value) return;
try {
await writeClipboard(prepared.value.invoice);
Notify.create({ type: 'positive', message: 'Invoice copied.' });
} catch (error) {
if (!(error instanceof Error)) throw error;
Notify.create({ type: 'negative', message: errorMessage(error) });
}
};
const retryClaim = (): void => {
claimRun = null;
beginClaim();
};
const stopWaiting = (): void => {
waiting.value = false;
};
const resumeWaiting = (): void => {
if (claimRun) waiting.value = true;
};
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 = '';
},
);
return {
CUSTOM_MINT,
amountSats,
claimError,
copyInvoice,
createInvoice,
customMint,
feeSats,
formError,
formValid,
grossSats,
mintChoice,
mintOptions,
netSats,
prepared,
preparing,
receivedSats,
receivedServer,
resumeWaiting,
retryClaim,
rotationWarning,
showTrust: trustPrompt.showTrust,
skipTrust: trustPrompt.skipTrust,
step,
stopWaiting,
trustMint: trustPrompt.trustMint,
trustNodeAlias: trustPrompt.trustNodeAlias,
trustServer: trustPrompt.trustServer,
waiting,
};
};
+183
View File
@@ -0,0 +1,183 @@
import { computed, ref, watch } from 'vue';
import { Notify } from 'quasar';
import {
NoteSpentError,
NoteUnknownError,
PendingNoteError,
isValidNoteInput,
} from 'lnurlcash-kit';
import { receiveBearer } from '@/lnurlcash/ops';
import type { NewBearer } from '@/lnurlcash/types';
import { floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
import { useMintsStore } from '@/stores/mints';
import { useActivityStore } from '@/stores/activity';
import { useMintTrustPrompt } from './useMintTrustPrompt';
type ReceiveTokenProps = Readonly<{ modelValue: boolean; initialInput?: string }>;
type ReceiveTokenEmit = {
(event: 'received'): void;
};
type ReceiveErrorKind = 'spent' | 'unknown' | 'pending' | 'duplicate' | 'invalid' | 'generic' | '';
const ERROR_TEXT: Readonly<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: Readonly<Record<Exclude<ReceiveErrorKind, ''>, string>> = {
spent: 'money_off',
unknown: 'help_outline',
pending: 'hourglass_top',
duplicate: 'content_copy',
invalid: 'error_outline',
generic: 'error_outline',
};
export const useReceiveTokenDialog = (props: ReceiveTokenProps, emit: ReceiveTokenEmit) => {
const wallet = useWalletStore();
const mints = useMintsStore();
const activity = useActivityStore();
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : 'Something went wrong.';
const step = ref<'input' | 'success'>('input');
const input = ref('');
const scanning = ref(false);
const busy = ref(false);
const errorKind = ref<ReceiveErrorKind>('');
const errorMessageText = ref('');
const receivedSats = ref(0);
const receivedServer = ref('');
const unverifiedNote = ref(false);
const rotationWarning = ref('');
const trustPrompt = useMintTrustPrompt();
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],
);
const inputValid = computed(() => isValidNoteInput(input.value.trim()));
const clearError = (): void => {
errorKind.value = '';
errorMessageText.value = '';
};
const classifyError = (error: unknown): void => {
let kind: Exclude<ReceiveErrorKind, ''>;
if (error instanceof NoteSpentError) kind = 'spent';
else if (error instanceof NoteUnknownError) kind = 'unknown';
else if (error instanceof PendingNoteError) kind = 'pending';
else if (error instanceof Error && error.message.includes('already in your wallet')) {
kind = 'duplicate';
} else if (error instanceof Error && error.message.includes('Not an LNURLcash bearer note')) {
kind = 'invalid';
} else {
kind = 'generic';
errorMessageText.value = errorMessage(error);
}
errorKind.value = kind;
Notify.create({
type: 'negative',
message: kind === 'generic' ? errorMessageText.value : ERROR_TEXT[kind],
});
};
const receive = async (): Promise<void> => {
const value = input.value.trim();
if (busy.value || value === '') return;
busy.value = true;
clearError();
try {
const ownerFence = wallet.captureOwnerFence();
const claimed = await receiveBearer(value, wallet.bearers, {
assertOwner: ownerFence,
});
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];
let trustWarning = '';
try {
await wallet.addBearers(notes, ownerFence);
} catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error;
trustWarning = error.message;
}
receivedSats.value = displaySats(note.amount);
receivedServer.value = server;
unverifiedNote.value = !note.verified;
rotationWarning.value = claimed.rotationError ?? '';
await activity.log(
'receive',
`Received ${receivedSats.value.toLocaleString()} sats from ${server}.`,
(error) => Notify.create({ type: 'warning', message: error.message }),
);
Notify.create({
type: 'positive',
message: `Received ${receivedSats.value.toLocaleString()} sats.`,
});
if (trustWarning) Notify.create({ type: 'warning', message: trustWarning });
emit('received');
scanning.value = false;
step.value = 'success';
if (!wasTrusted && note.mintPubkey) {
trustPrompt.openTrust(server, note.mintPubkey);
}
} catch (error) {
classifyError(error instanceof Error ? error : new Error(String(error)));
} finally {
busy.value = false;
}
};
const onScan = (text: string): void => {
input.value = text;
scanning.value = false;
void receive();
};
const onScanError = (message: string): void => {
scanning.value = false;
Notify.create({ type: 'negative', message });
};
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 = '';
},
);
return {
busy,
errorIcon,
errorKind,
errorText,
input,
inputValid,
onScan,
onScanError,
receive,
receivedSats,
receivedServer,
rotationWarning,
scanning,
showTrust: trustPrompt.showTrust,
skipTrust: trustPrompt.skipTrust,
step,
trustMint: trustPrompt.trustMint,
trustServer: trustPrompt.trustServer,
unverifiedNote,
};
};