mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
fix: surface receive post-commit trust failures
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user