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';
|
import { createFreshWallet } from '../helpers/wallet';
|
||||||
|
|
||||||
const AMOUNT_MSAT = 21_000; // 21 sats
|
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
|
// a syntactically valid bearer note against the mock mint - the k1 is a
|
||||||
// fresh random secret, so every test redeems a distinct note
|
// 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 page.keyboard.press('Escape');
|
||||||
await expect(page.locator('.balance-card .text-h2')).toHaveText('0');
|
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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { Notify } from 'quasar';
|
|
||||||
|
|
||||||
import QrCode from '../QrCode.vue';
|
import QrCode from '../QrCode.vue';
|
||||||
import { writeClipboard } from '@/capabilities/clipboard';
|
import { useReceiveLightningDialog } from '@/composables/useReceiveLightningDialog';
|
||||||
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';
|
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: boolean }>();
|
const props = defineProps<{ modelValue: boolean }>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -222,232 +211,36 @@ const emit = defineEmits<{
|
|||||||
received: [];
|
received: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const wallet = useWalletStore();
|
const {
|
||||||
const mints = useMintsStore();
|
CUSTOM_MINT,
|
||||||
const activity = useActivityStore();
|
amountSats,
|
||||||
|
claimError,
|
||||||
const CUSTOM_MINT = '__custom__';
|
copyInvoice,
|
||||||
|
createInvoice,
|
||||||
// whole-sat display for received amounts (msat remainder rounded down, per
|
customMint,
|
||||||
// units.ts's floorMsatToSat)
|
feeSats,
|
||||||
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
|
formError,
|
||||||
|
formValid,
|
||||||
const errorMessage = (err: unknown): string =>
|
grossSats,
|
||||||
err instanceof Error ? err.message : 'Something went wrong.';
|
mintChoice,
|
||||||
|
mintOptions,
|
||||||
type Step = 'form' | 'invoice' | 'success';
|
netSats,
|
||||||
const step = ref<Step>('form');
|
prepared,
|
||||||
|
preparing,
|
||||||
// ---- form ----
|
receivedSats,
|
||||||
const amountSats = ref<number | null>(null);
|
receivedServer,
|
||||||
const mintChoice = ref('');
|
resumeWaiting,
|
||||||
const customMint = ref('');
|
retryClaim,
|
||||||
const preparing = ref(false);
|
rotationWarning,
|
||||||
const formError = ref('');
|
showTrust,
|
||||||
|
skipTrust,
|
||||||
type MintOption = { label: string; value: string };
|
step,
|
||||||
|
stopWaiting,
|
||||||
const mintOptions = computed<MintOption[]>(() => {
|
trustMint,
|
||||||
const options: MintOption[] = [];
|
trustNodeAlias,
|
||||||
const seen = new Set<string>();
|
trustServer,
|
||||||
for (const mint of mints.mints) {
|
waiting,
|
||||||
const address = mint.username ? `${mint.username}@${mint.server}` : `@${mint.server}`;
|
} = useReceiveLightningDialog(props, emit);
|
||||||
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 = '';
|
|
||||||
},
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -14,12 +14,7 @@
|
|||||||
|
|
||||||
<!-- input -->
|
<!-- input -->
|
||||||
<q-card-section v-if="step === 'input'" class="q-pt-sm">
|
<q-card-section v-if="step === 'input'" class="q-pt-sm">
|
||||||
<qr-scanner
|
<qr-scanner v-if="scanning" class="q-mb-md" @decode="onScan" @error="onScanError" />
|
||||||
v-if="scanning"
|
|
||||||
class="q-mb-md"
|
|
||||||
@decode="onScan"
|
|
||||||
@error="onScanError"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="input"
|
v-model="input"
|
||||||
@@ -82,16 +77,16 @@
|
|||||||
<template #avatar>
|
<template #avatar>
|
||||||
<q-icon name="info" color="info" />
|
<q-icon name="info" color="info" />
|
||||||
</template>
|
</template>
|
||||||
The mint could not be reached, so the note is stored unconfirmed at the
|
The mint could not be reached, so the note is stored unconfirmed at the sender's declared
|
||||||
sender's declared amount. Refresh it later to confirm.
|
amount. Refresh it later to confirm.
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<q-banner v-if="rotationWarning" class="sattle-card text-warning q-mb-md" rounded>
|
<q-banner v-if="rotationWarning" class="sattle-card text-warning q-mb-md" rounded>
|
||||||
<template #avatar>
|
<template #avatar>
|
||||||
<q-icon name="warning" color="warning" />
|
<q-icon name="warning" color="warning" />
|
||||||
</template>
|
</template>
|
||||||
The note is in your wallet, but it could not be fully secured yet — the
|
The note is in your wallet, but it could not be fully secured yet — the sender may still
|
||||||
sender may still hold a copy. You can secure it later.
|
hold a copy. You can secure it later.
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -114,8 +109,8 @@
|
|||||||
<strong>{{ trustServer }}</strong>
|
<strong>{{ trustServer }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-caption text-grey-5 q-mb-md">
|
<div class="text-caption text-grey-5 q-mb-md">
|
||||||
Trusting saves the mint so it is offered next time. You can manage trusted
|
Trusting saves the mint so it is offered next time. You can manage trusted mints in
|
||||||
mints in Settings.
|
Settings.
|
||||||
</div>
|
</div>
|
||||||
<div class="row q-gutter-sm justify-end">
|
<div class="row q-gutter-sm justify-end">
|
||||||
<q-btn flat no-caps color="grey-5" label="Just this once" @click="skipTrust" />
|
<q-btn flat no-caps color="grey-5" label="Just this once" @click="skipTrust" />
|
||||||
@@ -134,22 +129,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 QrScanner from '../QrScanner.vue';
|
||||||
import { receiveBearer } from '@/lnurlcash/ops';
|
import { useReceiveTokenDialog } from '@/composables/useReceiveTokenDialog';
|
||||||
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';
|
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: boolean; initialInput?: string }>();
|
const props = defineProps<{ modelValue: boolean; initialInput?: string }>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -157,193 +138,27 @@ const emit = defineEmits<{
|
|||||||
received: [];
|
received: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const wallet = useWalletStore();
|
const {
|
||||||
const mints = useMintsStore();
|
busy,
|
||||||
const activity = useActivityStore();
|
errorIcon,
|
||||||
|
errorKind,
|
||||||
// whole-sat display for received amounts (msat remainder rounded down, per
|
errorText,
|
||||||
// units.ts's floorMsatToSat)
|
input,
|
||||||
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
|
inputValid,
|
||||||
|
onScan,
|
||||||
const errMsg = (err: unknown): string =>
|
onScanError,
|
||||||
err instanceof Error ? err.message : 'Something went wrong.';
|
receive,
|
||||||
|
receivedSats,
|
||||||
// distinct, jargon-free error states for the definitive service answers
|
receivedServer,
|
||||||
type ReceiveErrorKind =
|
rotationWarning,
|
||||||
| 'spent'
|
scanning,
|
||||||
| 'unknown'
|
showTrust,
|
||||||
| 'pending'
|
skipTrust,
|
||||||
| 'duplicate'
|
step,
|
||||||
| 'invalid'
|
trustMint,
|
||||||
| 'generic'
|
trustServer,
|
||||||
| '';
|
unverifiedNote,
|
||||||
|
} = useReceiveTokenDialog(props, emit);
|
||||||
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 = '';
|
|
||||||
},
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<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