fix: commit carved wallet funds atomically

This commit is contained in:
2026-08-22 16:56:38 +02:00
parent 3f6010b312
commit c59521a421
4 changed files with 454 additions and 264 deletions
+22 -264
View File
@@ -64,10 +64,7 @@
class="q-mb-md"
/>
<div
class="row items-end q-gutter-sm"
:class="targetFeeText ? 'q-mb-xs' : 'q-mb-md'"
>
<div class="row items-end q-gutter-sm" :class="targetFeeText ? 'q-mb-xs' : 'q-mb-md'">
<q-input
v-model.number="amountSats"
type="number"
@@ -238,265 +235,26 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import { describeMintFee, noteK1, serverOf } from 'lnurlcash-kit';
import type { MintFee } from 'lnurlcash-kit';
import { useMoveFundsPage } from '@/composables/useMoveFundsPage';
import { transferBetweenMints } from '@/lnurlcash/ops';
import type { CarveResult, TransferOutcome } from '@/lnurlcash/ops';
import { maxNetForBalance, quoteMintFee } from '@/lnurlcash/fees';
import type { NewBearer } from '@/lnurlcash/types';
import { floorMsatToSat, msatToSats, satsToMsat, MSAT_PER_SAT } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet';
import { useMintsStore } from '@/stores/mints';
import { useActivityStore } from '@/stores/activity';
const router = useRouter();
const $q = useQuasar();
const wallet = useWalletStore();
const mints = useMintsStore();
const activity = useActivityStore();
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
// guarded: the Notify plugin registration lives in quasar.config, outside
// this component's control - a missing registration must not break a flow
if (typeof $q.notify === 'function') {
$q.notify({ type, message, position: 'top', timeout: 3000 });
}
};
// a locked wallet holds no spendable notes (and the transfer needs the AES
// key to apply its changeset) - this page only makes sense unlocked
watch(
() => wallet.state,
(state) => {
if (state !== 'unlocked') void router.replace('/');
},
{ immediate: true },
);
const CUSTOM_TARGET = '__custom__';
// whole-sat display for msat amounts (remainder rounded down, per
// units.ts's floorMsatToSat)
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
// ---- source mints: only those with spendable, verified balance ----
// same eligibility the transfer op applies: not spent, verified (callback
// known), holding a real k1 (device-backed mirrors can't be melted here)
const spendableByServerMsat = computed(() => {
const byServer = new Map<string, number>();
for (const b of wallet.bearers) {
if (b.spent || b.callback === '' || b.deviceId || !noteK1(b.url)) continue;
const server = serverOf(b.url);
byServer.set(server, (byServer.get(server) ?? 0) + b.amount);
}
return byServer;
});
type Option = { label: string; value: string };
const sourceOptions = computed<Option[]>(() =>
[...spendableByServerMsat.value.entries()].map(([server, msat]) => ({
label: `${server} - ${displaySats(msat).toLocaleString()} sats available`,
value: server,
})),
);
const targetOptions = computed<Option[]>(() => {
const options: Option[] = [];
for (const mint of mints.mints) {
if (mint.server === sourceServer.value) continue;
const address = mint.username ? `${mint.username}@${mint.server}` : `@${mint.server}`;
const label = mint.nodeAlias ? `${address} (${mint.nodeAlias})` : address;
options.push({ label, value: address });
}
options.push({ label: 'Another mint…', value: CUSTOM_TARGET });
return options;
});
// ---- form ----
type Step = 'form' | 'confirm' | 'working' | 'result';
const step = ref<Step>('form');
const sourceServer = ref('');
const targetChoice = ref('');
const customTarget = ref('');
const amountSats = ref<number | null>(null);
const inlineError = ref('');
const stage = ref('');
const targetInput = computed(() =>
targetChoice.value === CUSTOM_TARGET ? customTarget.value.trim() : targetChoice.value,
);
const formFilled = computed(
() =>
sourceServer.value !== '' &&
targetInput.value !== '' &&
Number.isInteger(amountSats.value) &&
(amountSats.value ?? 0) >= 1,
);
// the target mint's advertised receive fee (lnurlcash/fees.ts), quoted
// live: a transfer carves the GROSS (net + fee), so a Max that ignored
// the fee would always overshoot the balance and fail the carve
const targetFee = ref<MintFee | null>(null);
let quoteTimer: ReturnType<typeof setTimeout> | null = null;
watch(targetInput, (input) => {
targetFee.value = null;
if (quoteTimer) clearTimeout(quoteTimer);
if (input === '') return;
quoteTimer = setTimeout(() => {
void quoteMintFee(input).then((fee) => {
// a slow quote must not land on a target the user has since changed
if (targetInput.value === input) targetFee.value = fee;
});
}, 400);
});
const targetFeeText = computed(() => {
const fee = targetFee.value;
return fee
? `This mint charges a receive fee (${describeMintFee(fee)}) - Max already accounts for it.`
: '';
});
const setMax = () => {
const msat = spendableByServerMsat.value.get(sourceServer.value) ?? 0;
amountSats.value = displaySats(maxNetForBalance(msat, targetFee.value));
};
const proceed = () => {
inlineError.value = '';
const sats = amountSats.value;
if (!sats || !Number.isInteger(sats) || sats < 1) {
inlineError.value = 'Enter how many sats to move.';
return;
}
const sourceMsat = spendableByServerMsat.value.get(sourceServer.value) ?? 0;
if (satsToMsat(sats) > sourceMsat) {
inlineError.value = `That's more than the ${displaySats(sourceMsat).toLocaleString()} sats spendable at ${sourceServer.value}.`;
return;
}
step.value = 'confirm';
};
// ---- outcome ----
type Result = {
outcome: TransferOutcome;
requestedSats: number;
feeSats: number;
sourceServer: string;
targetServer: string;
// value of the note still claimable at the target, when one is known
claimNoteValueSats?: number;
};
const result = ref<Result | null>(null);
// Applies the transfer's source-side changeset in the only safe order (same
// as PayInvoiceDialog): fresh notes into the wallet BEFORE the consumed
// inputs are marked spent. Returns the wallet id of the carved note.
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);
}
const kept = existing ?? added[0];
if (!kept) throw new Error('The carved note was not tracked.');
return kept.id;
};
const move = async () => {
const sats = amountSats.value;
if (!sats) return;
step.value = 'working';
stage.value = 'Asking the target mint for an invoice…';
try {
const transfer = await transferBetweenMints(
wallet.bearers,
satsToMsat(sats),
targetInput.value,
);
stage.value = 'Confirming the result…';
const carvedId = await applyCarve(transfer.carve);
if (transfer.rescuedNote) {
await wallet.addBearers([transfer.rescuedNote]);
}
const feeSats = msatToSats(transfer.quote.targetMintFeeMsat);
if (transfer.outcome === 'settled') {
await wallet.markSpent(carvedId);
const claimed = transfer.mintedAtTarget;
if (claimed) {
const notes: NewBearer[] = claimed.possibleCopy
? [claimed.note, claimed.possibleCopy]
: [claimed.note];
await wallet.addBearers(notes);
}
activity.log(
'transfer',
`Moved ${sats.toLocaleString()} sats from ${transfer.sourceServer} to ${transfer.targetServer}.`,
);
toast('positive', `Moved ${sats.toLocaleString()} sats.`);
} else if (transfer.outcome === 'failed-funds-returned') {
// the melt provably never happened - the (re-secured) carved note
// stays in the wallet, deliberately NOT marked spent
activity.log(
'transfer',
`A ${sats.toLocaleString()} sat move to ${transfer.targetServer} failed - funds are back in your wallet.`,
);
} else if (transfer.outcome === 'unknown-still-pending') {
// neither side confirmed - lock the carved note locally until a
// refresh reconciles
await wallet.markSpent(carvedId);
activity.log(
'transfer',
`A move of ${sats.toLocaleString()} sats to ${transfer.targetServer} is still in flight - the note is locked.`,
);
} else if (transfer.outcome === 'settled-claim-failed') {
// the money arrived at the target but claiming failed - the preimage
// note (when known) is tracked unverified so the sats are never lost
await wallet.markSpent(carvedId);
if (transfer.claimMaterial?.note) {
await wallet.addBearers([transfer.claimMaterial.note]);
}
activity.log(
'transfer',
`${sats.toLocaleString()} sats arrived at ${transfer.targetServer} but claiming the note failed - it is saved unverified.`,
);
} else {
// note-already-spent: the mint says the note was already gone; lock
// it locally so it can't be tried again
await wallet.markSpent(carvedId);
activity.log(
'spent',
`A ${sats.toLocaleString()} sat note was already spent at ${transfer.sourceServer}.`,
);
}
const claimNote = transfer.claimMaterial?.note ?? null;
result.value = {
outcome: transfer.outcome,
requestedSats: sats,
feeSats,
sourceServer: transfer.sourceServer,
targetServer: transfer.targetServer,
...(claimNote ? { claimNoteValueSats: displaySats(claimNote.amount) } : {}),
};
step.value = 'result';
} catch (err) {
// thrown before the carve (bad target, unreachable mint, amount out of
// range, no source cover) - every source note is untouched
const message = err instanceof Error ? err.message : 'Something went wrong.';
inlineError.value = message.startsWith('No mint holds enough')
? 'Not enough spendable balance at the source mint to cover that move.'
: message;
toast('negative', inlineError.value);
step.value = 'form';
}
};
const {
CUSTOM_TARGET,
amountSats,
customTarget,
formFilled,
inlineError,
move,
proceed,
result,
router,
setMax,
sourceOptions,
sourceServer,
stage,
step,
targetChoice,
targetFeeText,
targetInput,
targetOptions,
} = useMoveFundsPage();
</script>