mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
fix: fence send and pay flows
This commit is contained in:
@@ -7,18 +7,8 @@
|
|||||||
//
|
//
|
||||||
// Fund-safety order when applying the carve: fresh notes are added to the
|
// Fund-safety order when applying the carve: fresh notes are added to the
|
||||||
// wallet BEFORE consumed inputs are marked spent.
|
// wallet BEFORE consumed inputs are marked spent.
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { useQuasar } from 'quasar';
|
|
||||||
import { decodeBolt11AmountMsat, isBolt11Invoice, resolveLnurlInput } from 'lnurlcash-kit';
|
|
||||||
|
|
||||||
import QrScanner from '@/components/QrScanner.vue';
|
import QrScanner from '@/components/QrScanner.vue';
|
||||||
import { readClipboard } from '@/capabilities/clipboard';
|
import { usePayInvoiceDialog } from '@/composables/usePayInvoiceDialog';
|
||||||
import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops';
|
|
||||||
import type { CarveResult, PayOutcome } from '@/lnurlcash/ops';
|
|
||||||
import type { NewBearer } from '@/lnurlcash/types';
|
|
||||||
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
|
|
||||||
import { useWalletStore } from '@/stores/wallet';
|
|
||||||
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<{
|
||||||
@@ -26,231 +16,28 @@ const emit = defineEmits<{
|
|||||||
sent: [];
|
sent: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const $q = useQuasar();
|
const {
|
||||||
const wallet = useWalletStore();
|
addressAmountSats,
|
||||||
const activity = useActivityStore();
|
closeResult,
|
||||||
|
formatSats,
|
||||||
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
|
inlineError,
|
||||||
// guarded: the Notify plugin registration lives in quasar.config, outside
|
input,
|
||||||
// this component's control - a missing registration must not break a flow
|
msatToSats,
|
||||||
if (typeof $q.notify === 'function') {
|
onScan,
|
||||||
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
onScanError,
|
||||||
}
|
paste,
|
||||||
};
|
pay,
|
||||||
|
pendingPayment,
|
||||||
const show = computed({
|
proceed,
|
||||||
get: () => props.modelValue,
|
result,
|
||||||
set: (value: boolean) => emit('update:modelValue', value),
|
resultAmountSats,
|
||||||
});
|
show,
|
||||||
|
showScanner,
|
||||||
type Step = 'input' | 'confirm' | 'working' | 'result';
|
stage,
|
||||||
type TargetKind = 'invoice' | 'address';
|
step,
|
||||||
|
targetKind,
|
||||||
type PendingPayment = {
|
truncatedInput,
|
||||||
kind: TargetKind;
|
} = usePayInvoiceDialog(props, emit);
|
||||||
input: string;
|
|
||||||
amountMsat: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Result = {
|
|
||||||
outcome: PayOutcome;
|
|
||||||
amountMsat: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const step = ref<Step>('input');
|
|
||||||
const input = ref('');
|
|
||||||
const addressAmountSats = ref('');
|
|
||||||
const showScanner = ref(false);
|
|
||||||
const inlineError = ref<string | null>(null);
|
|
||||||
const stage = ref('');
|
|
||||||
const pendingPayment = ref<PendingPayment | null>(null);
|
|
||||||
const result = ref<Result | null>(null);
|
|
||||||
|
|
||||||
const formatSats = (sats: number): string =>
|
|
||||||
sats.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
|
||||||
|
|
||||||
const classify = (value: string): TargetKind | null => {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
if (isBolt11Invoice(trimmed)) return 'invoice';
|
|
||||||
// resolveLnurlInput also accepts Lightning Addresses, bech32 LNURLs and
|
|
||||||
// lightning= deep links - anything it resolves can be paid
|
|
||||||
if (resolveLnurlInput(trimmed) !== null) return 'address';
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const targetKind = computed<TargetKind | null>(() => classify(input.value));
|
|
||||||
|
|
||||||
const truncatedInput = computed(() => {
|
|
||||||
const p = pendingPayment.value;
|
|
||||||
if (!p) return '';
|
|
||||||
if (p.kind === 'address') return p.input;
|
|
||||||
return p.input.length > 30 ? `${p.input.slice(0, 18)}…${p.input.slice(-8)}` : p.input;
|
|
||||||
});
|
|
||||||
|
|
||||||
const resultAmountSats = computed(() =>
|
|
||||||
result.value ? formatSats(msatToSats(result.value.amountMsat)) : '',
|
|
||||||
);
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
step.value = 'input';
|
|
||||||
input.value = props.initialInput ?? '';
|
|
||||||
addressAmountSats.value = '';
|
|
||||||
showScanner.value = false;
|
|
||||||
inlineError.value = null;
|
|
||||||
stage.value = '';
|
|
||||||
pendingPayment.value = null;
|
|
||||||
result.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(open) => {
|
|
||||||
if (open) reset();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const onScan = (text: string) => {
|
|
||||||
input.value = text.replace(/^lightning:/i, '').trim();
|
|
||||||
showScanner.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onScanError = (message: string) => {
|
|
||||||
showScanner.value = false;
|
|
||||||
toast('negative', message);
|
|
||||||
};
|
|
||||||
|
|
||||||
const paste = async () => {
|
|
||||||
try {
|
|
||||||
const text = await readClipboard();
|
|
||||||
if (text) input.value = text.trim();
|
|
||||||
} catch {
|
|
||||||
toast('negative', "Couldn't read the clipboard - paste manually.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// input -> confirm: classify and validate everything that can be checked
|
|
||||||
// before any network call (amount present, within balance)
|
|
||||||
const proceed = () => {
|
|
||||||
inlineError.value = null;
|
|
||||||
const value = input.value.trim();
|
|
||||||
if (!value) {
|
|
||||||
inlineError.value = 'Paste an invoice or a Lightning Address first.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const kind = classify(value);
|
|
||||||
if (kind === null) {
|
|
||||||
inlineError.value = "That doesn't look like a Lightning invoice or address.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let amountMsat: number;
|
|
||||||
if (kind === 'invoice') {
|
|
||||||
const decoded = decodeBolt11AmountMsat(value);
|
|
||||||
if (decoded === null || decoded <= 0) {
|
|
||||||
inlineError.value = "This invoice doesn't have an amount, which this wallet can't pay yet.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
amountMsat = decoded;
|
|
||||||
} else {
|
|
||||||
const sats = Number(addressAmountSats.value);
|
|
||||||
if (!Number.isInteger(sats) || sats <= 0) {
|
|
||||||
inlineError.value = 'Enter how many sats to send to this address.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
amountMsat = satsToMsat(sats);
|
|
||||||
}
|
|
||||||
if (amountMsat > wallet.balanceMsat) {
|
|
||||||
inlineError.value = `That's more than your spendable balance (${formatSats(wallet.balanceSats)} sats).`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pendingPayment.value = { kind, input: value, amountMsat };
|
|
||||||
step.value = 'confirm';
|
|
||||||
};
|
|
||||||
|
|
||||||
const friendlyError = (err: unknown): string => {
|
|
||||||
const message = err instanceof Error ? err.message : 'Something went wrong.';
|
|
||||||
if (message.startsWith('No mint holds enough')) {
|
|
||||||
return 'Not enough spendable balance to cover that payment.';
|
|
||||||
}
|
|
||||||
return message;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Applies a carve to the wallet in the only safe order: add the fresh
|
|
||||||
// note(s) first, mark the consumed inputs spent after. Returns the wallet
|
|
||||||
// id of the carved note (for the exact-match path the note already exists
|
|
||||||
// in the wallet, so it is found by url instead of re-added).
|
|
||||||
const applyCarve = async (carve: CarveResult): Promise<string> => {
|
|
||||||
const existing = wallet.bearers.find((b) => b.url === carve.note.url);
|
|
||||||
const toAdd: NewBearer[] = [];
|
|
||||||
if (!existing) toAdd.push(carve.note);
|
|
||||||
if (carve.change) toAdd.push(carve.change);
|
|
||||||
const added = toAdd.length > 0 ? await wallet.addBearers(toAdd) : [];
|
|
||||||
for (const consumed of carve.consumed) {
|
|
||||||
await wallet.markSpent(consumed.id);
|
|
||||||
}
|
|
||||||
return existing ? existing.id : added[0].id;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pay = async () => {
|
|
||||||
const p = pendingPayment.value;
|
|
||||||
if (!p) return;
|
|
||||||
step.value = 'working';
|
|
||||||
stage.value = 'Preparing the exact amount and sending the payment…';
|
|
||||||
try {
|
|
||||||
const payResult = await payWithBearers(
|
|
||||||
wallet.bearers,
|
|
||||||
p.input,
|
|
||||||
p.kind === 'address' ? { amountMsat: p.amountMsat } : {},
|
|
||||||
);
|
|
||||||
stage.value = 'Confirming the result…';
|
|
||||||
const noteId = await applyCarve(payResult.carve);
|
|
||||||
if (payResult.rescuedNote) {
|
|
||||||
await wallet.addBearers([payResult.rescuedNote]);
|
|
||||||
}
|
|
||||||
const sats = formatSats(msatToSats(payResult.amountMsat));
|
|
||||||
if (payResult.outcome === 'settled') {
|
|
||||||
await wallet.markSpent(noteId);
|
|
||||||
activity.log('melt', `Paid ${sats} sats over Lightning.`);
|
|
||||||
toast('positive', `Paid ${sats} sats.`);
|
|
||||||
emit('sent');
|
|
||||||
} else if (payResult.outcome === 'failed-funds-returned') {
|
|
||||||
// the payment never happened and the note is spendable again - it
|
|
||||||
// stays in the wallet, deliberately NOT marked spent
|
|
||||||
activity.log('transfer', `A ${sats} sat payment failed - funds are back in your wallet.`);
|
|
||||||
toast('warning', 'Payment failed - funds are back in your wallet.');
|
|
||||||
} else if (payResult.outcome === 'unknown-still-pending') {
|
|
||||||
await wallet.markSpent(noteId);
|
|
||||||
activity.log('melt', `Payment of ${sats} sats is still in flight - the note is locked.`);
|
|
||||||
emit('sent');
|
|
||||||
} else {
|
|
||||||
// note-already-spent: the mint says the note is gone; nothing was
|
|
||||||
// paid. Lock it locally so it can't be tried again.
|
|
||||||
await wallet.markSpent(noteId);
|
|
||||||
activity.log('spent', `A ${sats} sat note was already spent at the mint.`);
|
|
||||||
}
|
|
||||||
result.value = { outcome: payResult.outcome, amountMsat: payResult.amountMsat };
|
|
||||||
step.value = 'result';
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof UncertainOutcomeError) {
|
|
||||||
await wallet.addBearers(err.possibleOutputs);
|
|
||||||
activity.log(
|
|
||||||
'transfer',
|
|
||||||
'A payment preparation could not be confirmed - possible notes stored unverified.',
|
|
||||||
);
|
|
||||||
inlineError.value =
|
|
||||||
"Couldn't confirm with the mint. Your original notes are untouched, and the possible new notes are stored unverified - refresh your wallet later to reconcile.";
|
|
||||||
toast('warning', 'Payment preparation uncertain - see the notice in the dialog.');
|
|
||||||
} else {
|
|
||||||
inlineError.value = friendlyError(err);
|
|
||||||
toast('negative', inlineError.value);
|
|
||||||
}
|
|
||||||
step.value = 'input';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeResult = () => {
|
|
||||||
show.value = false;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -7,19 +7,8 @@
|
|||||||
// Fund-safety order when applying the carve: the replacement notes are
|
// Fund-safety order when applying the carve: the replacement notes are
|
||||||
// added to the wallet BEFORE the consumed ones are marked spent, so a crash
|
// added to the wallet BEFORE the consumed ones are marked spent, so a crash
|
||||||
// mid-way strands a duplicate, never a secret.
|
// mid-way strands a duplicate, never a secret.
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { useQuasar } from 'quasar';
|
|
||||||
import { toBech32Lnurl } from 'lnurlcash-kit';
|
|
||||||
|
|
||||||
import QrCode from '@/components/QrCode.vue';
|
import QrCode from '@/components/QrCode.vue';
|
||||||
import { writeClipboard } from '@/capabilities/clipboard';
|
import { useSendTokenDialog } from '@/composables/useSendTokenDialog';
|
||||||
import { canShareText, shareText } from '@/capabilities/share';
|
|
||||||
import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops';
|
|
||||||
import type { CarveResult } from '@/lnurlcash/ops';
|
|
||||||
import type { Bearer, NewBearer } from '@/lnurlcash/types';
|
|
||||||
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
|
|
||||||
import { useWalletStore } from '@/stores/wallet';
|
|
||||||
import { useActivityStore } from '@/stores/activity';
|
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: boolean }>();
|
const props = defineProps<{ modelValue: boolean }>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -27,170 +16,28 @@ const emit = defineEmits<{
|
|||||||
sent: [];
|
sent: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const $q = useQuasar();
|
const {
|
||||||
const wallet = useWalletStore();
|
amountError,
|
||||||
const activity = useActivityStore();
|
amountSats,
|
||||||
|
canPrepare,
|
||||||
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
|
canShare,
|
||||||
// guarded: the Notify plugin registration lives in quasar.config, outside
|
copyNote,
|
||||||
// this component's control - a missing registration must not break a flow
|
errorMessage,
|
||||||
if (typeof $q.notify === 'function') {
|
finishKeep,
|
||||||
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
finishRemove,
|
||||||
}
|
formatSats,
|
||||||
};
|
msatToSats,
|
||||||
|
noteDisplayValue,
|
||||||
const show = computed({
|
prepare,
|
||||||
get: () => props.modelValue,
|
prepared,
|
||||||
set: (value: boolean) => emit('update:modelValue', value),
|
preparing,
|
||||||
});
|
removing,
|
||||||
|
revealed,
|
||||||
type Step = 'amount' | 'ready';
|
shareNote,
|
||||||
const step = ref<Step>('amount');
|
show,
|
||||||
const amountSats = ref('');
|
step,
|
||||||
const preparing = ref(false);
|
wallet,
|
||||||
const removing = ref(false);
|
} = useSendTokenDialog(props, emit);
|
||||||
const errorMessage = ref<string | null>(null);
|
|
||||||
const prepared = ref<Bearer | null>(null);
|
|
||||||
const revealed = ref(false);
|
|
||||||
|
|
||||||
const formatSats = (sats: number): string =>
|
|
||||||
sats.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
|
||||||
|
|
||||||
const parsedAmount = computed<number | null>(() => {
|
|
||||||
const n = Number(amountSats.value);
|
|
||||||
return Number.isInteger(n) && n > 0 ? n : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const amountError = computed<string | null>(() => {
|
|
||||||
if (parsedAmount.value === null) return null;
|
|
||||||
if (satsToMsat(parsedAmount.value) > wallet.balanceMsat) {
|
|
||||||
return `That's more than your spendable balance (${formatSats(wallet.balanceSats)} sats).`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const canPrepare = computed(
|
|
||||||
() => parsedAmount.value !== null && amountError.value === null && !preparing.value,
|
|
||||||
);
|
|
||||||
|
|
||||||
const noteDisplayValue = computed(() => (prepared.value ? toBech32Lnurl(prepared.value.url) : ''));
|
|
||||||
|
|
||||||
const canShare = canShareText();
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
step.value = 'amount';
|
|
||||||
amountSats.value = '';
|
|
||||||
preparing.value = false;
|
|
||||||
removing.value = false;
|
|
||||||
errorMessage.value = null;
|
|
||||||
prepared.value = null;
|
|
||||||
revealed.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(open) => {
|
|
||||||
if (open) reset();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Applies a carve to the wallet in the only safe order: add the fresh
|
|
||||||
// note(s) first, mark the consumed inputs spent after. Returns the wallet
|
|
||||||
// id of the carved note (for the exact-match path the note already exists
|
|
||||||
// in the wallet, so it is found by url instead of re-added).
|
|
||||||
const applyCarve = async (carve: CarveResult): Promise<Bearer> => {
|
|
||||||
const existing = wallet.bearers.find((b) => b.url === carve.note.url);
|
|
||||||
const toAdd: NewBearer[] = [];
|
|
||||||
if (!existing) toAdd.push(carve.note);
|
|
||||||
if (carve.change) toAdd.push(carve.change);
|
|
||||||
const added = toAdd.length > 0 ? await wallet.addBearers(toAdd) : [];
|
|
||||||
for (const consumed of carve.consumed) {
|
|
||||||
await wallet.markSpent(consumed.id);
|
|
||||||
}
|
|
||||||
return existing ?? added[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
const prepare = async () => {
|
|
||||||
const sats = parsedAmount.value;
|
|
||||||
if (sats === null || amountError.value !== null) return;
|
|
||||||
preparing.value = true;
|
|
||||||
errorMessage.value = null;
|
|
||||||
try {
|
|
||||||
const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats));
|
|
||||||
const note = await applyCarve(carve);
|
|
||||||
if (carve.change) {
|
|
||||||
activity.log('split', `Prepared a ${formatSats(sats)} sat note to hand over.`);
|
|
||||||
} else if (carve.consumed.length > 1) {
|
|
||||||
activity.log('combine', `Combined notes into a ${formatSats(sats)} sat note.`);
|
|
||||||
}
|
|
||||||
prepared.value = note;
|
|
||||||
revealed.value = false;
|
|
||||||
step.value = 'ready';
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof UncertainOutcomeError) {
|
|
||||||
// the mutation may have landed - the possible outputs carry fresh
|
|
||||||
// secrets and must be tracked alongside the (kept) originals
|
|
||||||
await wallet.addBearers(err.possibleOutputs);
|
|
||||||
activity.log(
|
|
||||||
'transfer',
|
|
||||||
'A note preparation could not be confirmed - possible notes stored unverified.',
|
|
||||||
);
|
|
||||||
errorMessage.value =
|
|
||||||
"Couldn't confirm with the mint. Your original notes are untouched, and the possible new notes are stored unverified - refresh your wallet later to reconcile.";
|
|
||||||
toast('warning', 'Preparation uncertain - see the notice in the dialog.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const message = err instanceof Error ? err.message : 'Something went wrong.';
|
|
||||||
errorMessage.value = message.startsWith('No mint holds enough')
|
|
||||||
? 'Not enough spendable balance to cover that amount.'
|
|
||||||
: message;
|
|
||||||
toast('negative', errorMessage.value);
|
|
||||||
} finally {
|
|
||||||
preparing.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyNote = async () => {
|
|
||||||
try {
|
|
||||||
await writeClipboard(noteDisplayValue.value);
|
|
||||||
toast('positive', 'Note copied to clipboard.');
|
|
||||||
} catch {
|
|
||||||
toast('negative', "Couldn't copy - reveal the note and copy it manually.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const shareNote = async () => {
|
|
||||||
try {
|
|
||||||
await shareText('sattle bearer note', noteDisplayValue.value);
|
|
||||||
} catch (err) {
|
|
||||||
// the user dismissing the share sheet is not an error
|
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
||||||
await copyNote();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const finishRemove = async () => {
|
|
||||||
const note = prepared.value;
|
|
||||||
if (!note) return;
|
|
||||||
removing.value = true;
|
|
||||||
try {
|
|
||||||
await wallet.markSpent(note.id);
|
|
||||||
activity.log('spent', `Handed over a ${formatSats(msatToSats(note.amount))} sat note.`);
|
|
||||||
toast('positive', 'Removed from your balance.');
|
|
||||||
emit('sent');
|
|
||||||
show.value = false;
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Something went wrong.';
|
|
||||||
toast('negative', message);
|
|
||||||
} finally {
|
|
||||||
removing.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const finishKeep = () => {
|
|
||||||
toast('info', 'Note kept in your wallet.');
|
|
||||||
show.value = false;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { decodeBolt11AmountMsat, isBolt11Invoice, resolveLnurlInput } from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import { readClipboard } from '@/capabilities/clipboard';
|
||||||
|
import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops';
|
||||||
|
import type { PayOutcome } from '@/lnurlcash/ops';
|
||||||
|
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
|
||||||
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
import { useActivityStore } from '@/stores/activity';
|
||||||
|
import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
|
||||||
|
import { addCommittedBearers, commitCarve } from './walletCarveCommit';
|
||||||
|
|
||||||
|
type PayInvoiceProps = Readonly<{ modelValue: boolean; initialInput?: string }>;
|
||||||
|
type PayInvoiceEmit = {
|
||||||
|
(event: 'update:modelValue', value: boolean): void;
|
||||||
|
(event: 'sent'): void;
|
||||||
|
};
|
||||||
|
type TargetKind = 'invoice' | 'address';
|
||||||
|
type PendingPayment = Readonly<{ kind: TargetKind; input: string; amountMsat: number }>;
|
||||||
|
type PaymentResult = Readonly<{ outcome: PayOutcome; amountMsat: number }>;
|
||||||
|
|
||||||
|
export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit) => {
|
||||||
|
const $q = useQuasar();
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const activity = useActivityStore();
|
||||||
|
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
|
||||||
|
if (typeof $q.notify === 'function') {
|
||||||
|
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const warnCommitted = (message: string): void => toast('warning', message);
|
||||||
|
const show = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: boolean) => emit('update:modelValue', value),
|
||||||
|
});
|
||||||
|
const step = ref<'input' | 'confirm' | 'working' | 'result'>('input');
|
||||||
|
const input = ref('');
|
||||||
|
const addressAmountSats = ref('');
|
||||||
|
const showScanner = ref(false);
|
||||||
|
const inlineError = ref<string | null>(null);
|
||||||
|
const stage = ref('');
|
||||||
|
const pendingPayment = ref<PendingPayment | null>(null);
|
||||||
|
const result = ref<PaymentResult | null>(null);
|
||||||
|
const formatSats = (sats: number): string =>
|
||||||
|
sats.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||||
|
const classify = (value: string): TargetKind | null => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (isBolt11Invoice(trimmed)) return 'invoice';
|
||||||
|
return resolveLnurlInput(trimmed) === null ? null : 'address';
|
||||||
|
};
|
||||||
|
const targetKind = computed<TargetKind | null>(() => classify(input.value));
|
||||||
|
const truncatedInput = computed(() => {
|
||||||
|
const payment = pendingPayment.value;
|
||||||
|
if (!payment) return '';
|
||||||
|
if (payment.kind === 'address') return payment.input;
|
||||||
|
return payment.input.length > 30
|
||||||
|
? `${payment.input.slice(0, 18)}…${payment.input.slice(-8)}`
|
||||||
|
: payment.input;
|
||||||
|
});
|
||||||
|
const resultAmountSats = computed(() =>
|
||||||
|
result.value ? formatSats(msatToSats(result.value.amountMsat)) : '',
|
||||||
|
);
|
||||||
|
const reset = (): void => {
|
||||||
|
step.value = 'input';
|
||||||
|
input.value = props.initialInput ?? '';
|
||||||
|
addressAmountSats.value = '';
|
||||||
|
showScanner.value = false;
|
||||||
|
inlineError.value = null;
|
||||||
|
stage.value = '';
|
||||||
|
pendingPayment.value = null;
|
||||||
|
result.value = null;
|
||||||
|
};
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (open) reset();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const onScan = (text: string): void => {
|
||||||
|
input.value = text.replace(/^lightning:/i, '').trim();
|
||||||
|
showScanner.value = false;
|
||||||
|
};
|
||||||
|
const onScanError = (message: string): void => {
|
||||||
|
showScanner.value = false;
|
||||||
|
toast('negative', message);
|
||||||
|
};
|
||||||
|
const paste = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const text = await readClipboard();
|
||||||
|
if (text) input.value = text.trim();
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error)) throw error;
|
||||||
|
toast('negative', "Couldn't read the clipboard - paste manually.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const proceed = (): void => {
|
||||||
|
inlineError.value = null;
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (!value) {
|
||||||
|
inlineError.value = 'Paste an invoice or a Lightning Address first.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const kind = classify(value);
|
||||||
|
if (kind === null) {
|
||||||
|
inlineError.value = "That doesn't look like a Lightning invoice or address.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let amountMsat: number;
|
||||||
|
if (kind === 'invoice') {
|
||||||
|
const decoded = decodeBolt11AmountMsat(value);
|
||||||
|
if (decoded === null || decoded <= 0) {
|
||||||
|
inlineError.value = "This invoice doesn't have an amount, which this wallet can't pay yet.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
amountMsat = decoded;
|
||||||
|
} else {
|
||||||
|
const sats = Number(addressAmountSats.value);
|
||||||
|
if (!Number.isInteger(sats) || sats <= 0) {
|
||||||
|
inlineError.value = 'Enter how many sats to send to this address.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
amountMsat = satsToMsat(sats);
|
||||||
|
}
|
||||||
|
if (amountMsat > wallet.balanceMsat) {
|
||||||
|
inlineError.value = `That's more than your spendable balance (${formatSats(wallet.balanceSats)} sats).`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingPayment.value = { kind, input: value, amountMsat };
|
||||||
|
step.value = 'confirm';
|
||||||
|
};
|
||||||
|
const friendlyError = (error: unknown): string => {
|
||||||
|
const message = error instanceof Error ? error.message : 'Something went wrong.';
|
||||||
|
return message.startsWith('No mint holds enough')
|
||||||
|
? 'Not enough spendable balance to cover that payment.'
|
||||||
|
: message;
|
||||||
|
};
|
||||||
|
const pay = async (): Promise<void> => {
|
||||||
|
const payment = pendingPayment.value;
|
||||||
|
if (!payment) return;
|
||||||
|
step.value = 'working';
|
||||||
|
stage.value = 'Preparing the exact amount and sending the payment…';
|
||||||
|
let ownerFence: WalletOwnerFence | undefined;
|
||||||
|
try {
|
||||||
|
ownerFence = wallet.captureOwnerFence();
|
||||||
|
const commitContext = { ownerFence, warn: warnCommitted };
|
||||||
|
const paid = await payWithBearers(
|
||||||
|
wallet.bearers,
|
||||||
|
payment.input,
|
||||||
|
payment.kind === 'address'
|
||||||
|
? { amountMsat: payment.amountMsat, assertOwner: ownerFence }
|
||||||
|
: { assertOwner: ownerFence },
|
||||||
|
);
|
||||||
|
stage.value = 'Confirming the result…';
|
||||||
|
const committed = await commitCarve(wallet, paid.carve, commitContext);
|
||||||
|
if (paid.rescuedNote) {
|
||||||
|
await addCommittedBearers(wallet, [paid.rescuedNote], commitContext);
|
||||||
|
}
|
||||||
|
const sats = formatSats(msatToSats(paid.amountMsat));
|
||||||
|
if (paid.outcome === 'settled') {
|
||||||
|
await wallet.markSpent(committed.id, ownerFence);
|
||||||
|
await activity.log('melt', `Paid ${sats} sats over Lightning.`, (error) =>
|
||||||
|
warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
toast('positive', `Paid ${sats} sats.`);
|
||||||
|
emit('sent');
|
||||||
|
} else if (paid.outcome === 'failed-funds-returned') {
|
||||||
|
await activity.log(
|
||||||
|
'transfer',
|
||||||
|
`A ${sats} sat payment failed - funds are back in your wallet.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
toast('warning', 'Payment failed - funds are back in your wallet.');
|
||||||
|
} else if (paid.outcome === 'unknown-still-pending') {
|
||||||
|
await wallet.markSpent(committed.id, ownerFence);
|
||||||
|
await activity.log(
|
||||||
|
'melt',
|
||||||
|
`Payment of ${sats} sats is still in flight - the note is locked.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
emit('sent');
|
||||||
|
} else {
|
||||||
|
await wallet.markSpent(committed.id, ownerFence);
|
||||||
|
await activity.log('spent', `A ${sats} sat note was already spent at the mint.`, (error) =>
|
||||||
|
warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result.value = { outcome: paid.outcome, amountMsat: paid.amountMsat };
|
||||||
|
step.value = 'result';
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UncertainOutcomeError) {
|
||||||
|
if (!ownerFence) throw error;
|
||||||
|
await addCommittedBearers(wallet, error.possibleOutputs, {
|
||||||
|
ownerFence,
|
||||||
|
warn: warnCommitted,
|
||||||
|
});
|
||||||
|
await activity.log(
|
||||||
|
'transfer',
|
||||||
|
'A payment preparation could not be confirmed - possible notes stored unverified.',
|
||||||
|
(activityError) => warnCommitted(activityError.message),
|
||||||
|
);
|
||||||
|
inlineError.value =
|
||||||
|
"Couldn't confirm with the mint. Your original notes are untouched, and the possible new notes are stored unverified - refresh your wallet later to reconcile.";
|
||||||
|
toast('warning', 'Payment preparation uncertain - see the notice in the dialog.');
|
||||||
|
} else {
|
||||||
|
inlineError.value = friendlyError(error);
|
||||||
|
toast('negative', inlineError.value);
|
||||||
|
}
|
||||||
|
step.value = 'input';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const closeResult = (): void => {
|
||||||
|
show.value = false;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
addressAmountSats,
|
||||||
|
closeResult,
|
||||||
|
formatSats,
|
||||||
|
inlineError,
|
||||||
|
input,
|
||||||
|
msatToSats,
|
||||||
|
onScan,
|
||||||
|
onScanError,
|
||||||
|
paste,
|
||||||
|
pay,
|
||||||
|
pendingPayment,
|
||||||
|
proceed,
|
||||||
|
result,
|
||||||
|
resultAmountSats,
|
||||||
|
show,
|
||||||
|
showScanner,
|
||||||
|
stage,
|
||||||
|
step,
|
||||||
|
targetKind,
|
||||||
|
truncatedInput,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { toBech32Lnurl } from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import { writeClipboard } from '@/capabilities/clipboard';
|
||||||
|
import { canShareText, shareText } from '@/capabilities/share';
|
||||||
|
import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops';
|
||||||
|
import type { Bearer } from '@/lnurlcash/types';
|
||||||
|
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
|
||||||
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
import { useActivityStore } from '@/stores/activity';
|
||||||
|
import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
|
||||||
|
import { addCommittedBearers, commitCarve } from './walletCarveCommit';
|
||||||
|
|
||||||
|
type SendTokenProps = Readonly<{ modelValue: boolean }>;
|
||||||
|
type SendTokenEmit = {
|
||||||
|
(event: 'update:modelValue', value: boolean): void;
|
||||||
|
(event: 'sent'): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) => {
|
||||||
|
const $q = useQuasar();
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const activity = useActivityStore();
|
||||||
|
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
|
||||||
|
if (typeof $q.notify === 'function') {
|
||||||
|
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const warnCommitted = (message: string): void => toast('warning', message);
|
||||||
|
const show = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: boolean) => emit('update:modelValue', value),
|
||||||
|
});
|
||||||
|
const step = ref<'amount' | 'ready'>('amount');
|
||||||
|
const amountSats = ref('');
|
||||||
|
const preparing = ref(false);
|
||||||
|
const removing = ref(false);
|
||||||
|
const errorMessage = ref<string | null>(null);
|
||||||
|
const prepared = ref<Bearer | null>(null);
|
||||||
|
const revealed = ref(false);
|
||||||
|
const formatSats = (sats: number): string =>
|
||||||
|
sats.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||||
|
const parsedAmount = computed<number | null>(() => {
|
||||||
|
const amount = Number(amountSats.value);
|
||||||
|
return Number.isInteger(amount) && amount > 0 ? amount : null;
|
||||||
|
});
|
||||||
|
const amountError = computed<string | null>(() => {
|
||||||
|
if (parsedAmount.value === null) return null;
|
||||||
|
if (satsToMsat(parsedAmount.value) > wallet.balanceMsat) {
|
||||||
|
return `That's more than your spendable balance (${formatSats(wallet.balanceSats)} sats).`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const canPrepare = computed(
|
||||||
|
() => parsedAmount.value !== null && amountError.value === null && !preparing.value,
|
||||||
|
);
|
||||||
|
const noteDisplayValue = computed(() =>
|
||||||
|
prepared.value ? toBech32Lnurl(prepared.value.url) : '',
|
||||||
|
);
|
||||||
|
const canShare = canShareText();
|
||||||
|
const reset = (): void => {
|
||||||
|
step.value = 'amount';
|
||||||
|
amountSats.value = '';
|
||||||
|
preparing.value = false;
|
||||||
|
removing.value = false;
|
||||||
|
errorMessage.value = null;
|
||||||
|
prepared.value = null;
|
||||||
|
revealed.value = false;
|
||||||
|
};
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (open) reset();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const prepare = async (): Promise<void> => {
|
||||||
|
const sats = parsedAmount.value;
|
||||||
|
if (sats === null || amountError.value !== null) return;
|
||||||
|
preparing.value = true;
|
||||||
|
errorMessage.value = null;
|
||||||
|
let ownerFence: WalletOwnerFence | undefined;
|
||||||
|
try {
|
||||||
|
ownerFence = wallet.captureOwnerFence();
|
||||||
|
const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats), {
|
||||||
|
assertOwner: ownerFence,
|
||||||
|
});
|
||||||
|
const note = await commitCarve(wallet, carve, { ownerFence, warn: warnCommitted });
|
||||||
|
if (carve.change) {
|
||||||
|
await activity.log(
|
||||||
|
'split',
|
||||||
|
`Prepared a ${formatSats(sats)} sat note to hand over.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
} else if (carve.consumed.length > 1) {
|
||||||
|
await activity.log(
|
||||||
|
'combine',
|
||||||
|
`Combined notes into a ${formatSats(sats)} sat note.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
prepared.value = note;
|
||||||
|
revealed.value = false;
|
||||||
|
step.value = 'ready';
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UncertainOutcomeError) {
|
||||||
|
if (!ownerFence) throw error;
|
||||||
|
await addCommittedBearers(wallet, error.possibleOutputs, {
|
||||||
|
ownerFence,
|
||||||
|
warn: warnCommitted,
|
||||||
|
});
|
||||||
|
await activity.log(
|
||||||
|
'transfer',
|
||||||
|
'A note preparation could not be confirmed - possible notes stored unverified.',
|
||||||
|
(activityError) => warnCommitted(activityError.message),
|
||||||
|
);
|
||||||
|
errorMessage.value =
|
||||||
|
"Couldn't confirm with the mint. Your original notes are untouched, and the possible new notes are stored unverified - refresh your wallet later to reconcile.";
|
||||||
|
toast('warning', 'Preparation uncertain - see the notice in the dialog.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : 'Something went wrong.';
|
||||||
|
errorMessage.value = message.startsWith('No mint holds enough')
|
||||||
|
? 'Not enough spendable balance to cover that amount.'
|
||||||
|
: message;
|
||||||
|
toast('negative', errorMessage.value);
|
||||||
|
} finally {
|
||||||
|
preparing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const copyNote = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await writeClipboard(noteDisplayValue.value);
|
||||||
|
toast('positive', 'Note copied to clipboard.');
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error)) throw error;
|
||||||
|
toast('negative', "Couldn't copy - reveal the note and copy it manually.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const shareNote = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await shareText('sattle bearer note', noteDisplayValue.value);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||||
|
await copyNote();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const finishRemove = async (): Promise<void> => {
|
||||||
|
const note = prepared.value;
|
||||||
|
if (!note) return;
|
||||||
|
removing.value = true;
|
||||||
|
try {
|
||||||
|
await wallet.markSpent(note.id, wallet.captureOwnerFence());
|
||||||
|
await activity.log(
|
||||||
|
'spent',
|
||||||
|
`Handed over a ${formatSats(msatToSats(note.amount))} sat note.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
toast('positive', 'Removed from your balance.');
|
||||||
|
emit('sent');
|
||||||
|
show.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
toast('negative', error instanceof Error ? error.message : 'Something went wrong.');
|
||||||
|
} finally {
|
||||||
|
removing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const finishKeep = (): void => {
|
||||||
|
toast('info', 'Note kept in your wallet.');
|
||||||
|
show.value = false;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
amountError,
|
||||||
|
amountSats,
|
||||||
|
canPrepare,
|
||||||
|
canShare,
|
||||||
|
copyNote,
|
||||||
|
errorMessage,
|
||||||
|
finishKeep,
|
||||||
|
finishRemove,
|
||||||
|
formatSats,
|
||||||
|
msatToSats,
|
||||||
|
noteDisplayValue,
|
||||||
|
prepare,
|
||||||
|
prepared,
|
||||||
|
preparing,
|
||||||
|
removing,
|
||||||
|
revealed,
|
||||||
|
shareNote,
|
||||||
|
show,
|
||||||
|
step,
|
||||||
|
wallet,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user