mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
fix: commit carved wallet funds atomically
This commit is contained in:
@@ -0,0 +1,235 @@
|
|||||||
|
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 { transferBetweenMints } from '@/lnurlcash/ops';
|
||||||
|
import type { 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';
|
||||||
|
import { addCommittedBearers, commitCarve } from './walletCarveCommit';
|
||||||
|
|
||||||
|
type Option = Readonly<{ label: string; value: string }>;
|
||||||
|
type TransferResult = Readonly<{
|
||||||
|
outcome: TransferOutcome;
|
||||||
|
requestedSats: number;
|
||||||
|
feeSats: number;
|
||||||
|
sourceServer: string;
|
||||||
|
targetServer: string;
|
||||||
|
claimNoteValueSats?: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const useMoveFundsPage = () => {
|
||||||
|
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 => {
|
||||||
|
if (typeof $q.notify === 'function') {
|
||||||
|
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const warnCommitted = (message: string): void => toast('warning', message);
|
||||||
|
watch(
|
||||||
|
() => wallet.state,
|
||||||
|
(state) => {
|
||||||
|
if (state !== 'unlocked') void router.replace('/');
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
const CUSTOM_TARGET = '__custom__';
|
||||||
|
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
|
||||||
|
const spendableByServerMsat = computed(() => {
|
||||||
|
const byServer = new Map<string, number>();
|
||||||
|
for (const bearer of wallet.bearers) {
|
||||||
|
if (bearer.spent || bearer.callback === '' || bearer.deviceId || !noteK1(bearer.url))
|
||||||
|
continue;
|
||||||
|
const server = serverOf(bearer.url);
|
||||||
|
byServer.set(server, (byServer.get(server) ?? 0) + bearer.amount);
|
||||||
|
}
|
||||||
|
return byServer;
|
||||||
|
});
|
||||||
|
const sourceOptions = computed<Option[]>(() =>
|
||||||
|
[...spendableByServerMsat.value.entries()].map(([server, msat]) => ({
|
||||||
|
label: `${server} - ${displaySats(msat).toLocaleString()} sats available`,
|
||||||
|
value: server,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const step = ref<'form' | 'confirm' | 'working' | 'result'>('form');
|
||||||
|
const sourceServer = ref('');
|
||||||
|
const targetChoice = ref('');
|
||||||
|
const customTarget = ref('');
|
||||||
|
const amountSats = ref<number | null>(null);
|
||||||
|
const inlineError = ref('');
|
||||||
|
const stage = ref('');
|
||||||
|
const result = ref<TransferResult | null>(null);
|
||||||
|
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}`;
|
||||||
|
options.push({
|
||||||
|
label: mint.nodeAlias ? `${address} (${mint.nodeAlias})` : address,
|
||||||
|
value: address,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
options.push({ label: 'Another mint…', value: CUSTOM_TARGET });
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
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) => {
|
||||||
|
if (targetInput.value === input) targetFee.value = fee;
|
||||||
|
});
|
||||||
|
}, 400);
|
||||||
|
});
|
||||||
|
const targetFeeText = computed(() =>
|
||||||
|
targetFee.value
|
||||||
|
? `This mint charges a receive fee (${describeMintFee(targetFee.value)}) - Max already accounts for it.`
|
||||||
|
: '',
|
||||||
|
);
|
||||||
|
const setMax = (): void => {
|
||||||
|
const msat = spendableByServerMsat.value.get(sourceServer.value) ?? 0;
|
||||||
|
amountSats.value = displaySats(maxNetForBalance(msat, targetFee.value));
|
||||||
|
};
|
||||||
|
const proceed = (): void => {
|
||||||
|
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';
|
||||||
|
};
|
||||||
|
const move = async (): Promise<void> => {
|
||||||
|
const sats = amountSats.value;
|
||||||
|
if (!sats) return;
|
||||||
|
step.value = 'working';
|
||||||
|
stage.value = 'Asking the target mint for an invoice…';
|
||||||
|
try {
|
||||||
|
const ownerFence = wallet.captureOwnerFence();
|
||||||
|
const commitContext = { ownerFence, warn: warnCommitted };
|
||||||
|
const transfer = await transferBetweenMints(
|
||||||
|
wallet.bearers,
|
||||||
|
satsToMsat(sats),
|
||||||
|
targetInput.value,
|
||||||
|
{ assertOwner: ownerFence },
|
||||||
|
);
|
||||||
|
stage.value = 'Confirming the result…';
|
||||||
|
const carved = await commitCarve(wallet, transfer.carve, commitContext);
|
||||||
|
if (transfer.rescuedNote) {
|
||||||
|
await addCommittedBearers(wallet, [transfer.rescuedNote], commitContext);
|
||||||
|
}
|
||||||
|
const feeSats = msatToSats(transfer.quote.targetMintFeeMsat);
|
||||||
|
if (transfer.outcome === 'settled') {
|
||||||
|
await wallet.markSpent(carved.id, ownerFence);
|
||||||
|
const claimed = transfer.mintedAtTarget;
|
||||||
|
if (claimed) {
|
||||||
|
const notes: NewBearer[] = claimed.possibleCopy
|
||||||
|
? [claimed.note, claimed.possibleCopy]
|
||||||
|
: [claimed.note];
|
||||||
|
await addCommittedBearers(wallet, notes, commitContext);
|
||||||
|
}
|
||||||
|
await activity.log(
|
||||||
|
'transfer',
|
||||||
|
`Moved ${sats.toLocaleString()} sats from ${transfer.sourceServer} to ${transfer.targetServer}.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
toast('positive', `Moved ${sats.toLocaleString()} sats.`);
|
||||||
|
} else if (transfer.outcome === 'failed-funds-returned') {
|
||||||
|
await activity.log(
|
||||||
|
'transfer',
|
||||||
|
`A ${sats.toLocaleString()} sat move to ${transfer.targetServer} failed - funds are back in your wallet.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
} else if (transfer.outcome === 'unknown-still-pending') {
|
||||||
|
await wallet.markSpent(carved.id, ownerFence);
|
||||||
|
await activity.log(
|
||||||
|
'transfer',
|
||||||
|
`A move of ${sats.toLocaleString()} sats to ${transfer.targetServer} is still in flight - the note is locked.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
} else if (transfer.outcome === 'settled-claim-failed') {
|
||||||
|
await wallet.markSpent(carved.id, ownerFence);
|
||||||
|
if (transfer.claimMaterial?.note) {
|
||||||
|
await addCommittedBearers(wallet, [transfer.claimMaterial.note], commitContext);
|
||||||
|
}
|
||||||
|
await activity.log(
|
||||||
|
'transfer',
|
||||||
|
`${sats.toLocaleString()} sats arrived at ${transfer.targetServer} but claiming the note failed - it is saved unverified.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await wallet.markSpent(carved.id, ownerFence);
|
||||||
|
await activity.log(
|
||||||
|
'spent',
|
||||||
|
`A ${sats.toLocaleString()} sat note was already spent at ${transfer.sourceServer}.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 (error) {
|
||||||
|
const message = error instanceof Error ? error.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';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
CUSTOM_TARGET,
|
||||||
|
amountSats,
|
||||||
|
customTarget,
|
||||||
|
formFilled,
|
||||||
|
inlineError,
|
||||||
|
move,
|
||||||
|
proceed,
|
||||||
|
result,
|
||||||
|
router,
|
||||||
|
setMax,
|
||||||
|
sourceOptions,
|
||||||
|
sourceServer,
|
||||||
|
stage,
|
||||||
|
step,
|
||||||
|
targetChoice,
|
||||||
|
targetFeeText,
|
||||||
|
targetInput,
|
||||||
|
targetOptions,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
import { buildNoteUrl } from 'lnurlcash-kit';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { deriveBearerAesKey } from '@/lnurlcash/keys';
|
||||||
|
import type { CarveResult } from '@/lnurlcash/ops';
|
||||||
|
import { loadBearers } from '@/lnurlcash/storage';
|
||||||
|
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||||
|
import type { NewBearer } from '@/lnurlcash/types';
|
||||||
|
import { commitCarve } from './walletCarveCommit';
|
||||||
|
import { useWalletStore } from '../stores/wallet';
|
||||||
|
|
||||||
|
const note = (secret: string): NewBearer => ({
|
||||||
|
url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000),
|
||||||
|
callback: 'https://mint.example/w/cb',
|
||||||
|
amount: 21_000,
|
||||||
|
verified: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const failOnBearerWrite = (occurrence: number): void => {
|
||||||
|
const setItem = localStorage.setItem.bind(localStorage);
|
||||||
|
let writes = 0;
|
||||||
|
vi.spyOn(localStorage, 'setItem').mockImplementation((key, value) => {
|
||||||
|
if (key === 'sattle_bearers') {
|
||||||
|
writes += 1;
|
||||||
|
if (writes === occurrence) throw new Error('bearer storage unavailable');
|
||||||
|
}
|
||||||
|
setItem(key, value);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.stubGlobal('navigator', {});
|
||||||
|
stubLocalStorage();
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('commitCarve', () => {
|
||||||
|
it('commits the carve additions and spent marks in one bearer write', async () => {
|
||||||
|
// Given a wallet holding the carve's input note
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
await wallet.create();
|
||||||
|
const ownerFence = wallet.captureOwnerFence();
|
||||||
|
const [input] = await wallet.addBearers([note('aa')], ownerFence);
|
||||||
|
if (!input) throw new Error('Expected the input bearer.');
|
||||||
|
const carve: CarveResult = {
|
||||||
|
note: note('bb'),
|
||||||
|
change: note('cc'),
|
||||||
|
consumed: [input],
|
||||||
|
};
|
||||||
|
const writes = vi.spyOn(localStorage, 'setItem');
|
||||||
|
|
||||||
|
// When the carve is committed
|
||||||
|
const committed = await commitCarve(wallet, carve, {
|
||||||
|
ownerFence,
|
||||||
|
warn: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then the whole rotation landed as ONE durable write
|
||||||
|
expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1);
|
||||||
|
expect(committed.url).toBe(carve.note.url);
|
||||||
|
expect(wallet.bearers).toHaveLength(3);
|
||||||
|
expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
|
||||||
|
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
|
||||||
|
const persisted = await loadBearers(key);
|
||||||
|
expect(persisted).toHaveLength(3);
|
||||||
|
expect(persisted.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a failure that would have hit the old split commit second write', async () => {
|
||||||
|
// Given a wallet holding the carve's input note, with the second
|
||||||
|
// sattle_bearers write poisoned (the old add-then-markSpent split wrote
|
||||||
|
// twice; the single-write commit never reaches a second write)
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
await wallet.create();
|
||||||
|
const ownerFence = wallet.captureOwnerFence();
|
||||||
|
const [input] = await wallet.addBearers([note('aa')], ownerFence);
|
||||||
|
if (!input) throw new Error('Expected the input bearer.');
|
||||||
|
failOnBearerWrite(2);
|
||||||
|
const carve: CarveResult = {
|
||||||
|
note: note('bb'),
|
||||||
|
change: note('cc'),
|
||||||
|
consumed: [input],
|
||||||
|
};
|
||||||
|
|
||||||
|
// When the carve is committed
|
||||||
|
const committed = await commitCarve(wallet, carve, {
|
||||||
|
ownerFence,
|
||||||
|
warn: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then the rotation committed completely: additions tracked, input spent
|
||||||
|
expect(committed.url).toBe(carve.note.url);
|
||||||
|
expect(wallet.bearers).toHaveLength(3);
|
||||||
|
expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
|
||||||
|
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
|
||||||
|
const persisted = await loadBearers(key);
|
||||||
|
expect(persisted).toHaveLength(3);
|
||||||
|
expect(persisted.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves no partial carve behind when the commit write itself fails', async () => {
|
||||||
|
// Given bearer storage that fails the very next write
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
await wallet.create();
|
||||||
|
const ownerFence = wallet.captureOwnerFence();
|
||||||
|
const [input] = await wallet.addBearers([note('aa')], ownerFence);
|
||||||
|
if (!input) throw new Error('Expected the input bearer.');
|
||||||
|
failOnBearerWrite(1);
|
||||||
|
|
||||||
|
// When the carve commit fails
|
||||||
|
await expect(
|
||||||
|
commitCarve(
|
||||||
|
wallet,
|
||||||
|
{ note: note('bb'), change: note('cc'), consumed: [input] },
|
||||||
|
{ ownerFence, warn: () => undefined },
|
||||||
|
),
|
||||||
|
).rejects.toThrow('bearer storage unavailable');
|
||||||
|
|
||||||
|
// Then nothing moved: not in storage, not in the reactive list
|
||||||
|
expect(wallet.bearers).toHaveLength(1);
|
||||||
|
expect(wallet.bearers[0]?.spent).toBeUndefined();
|
||||||
|
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
|
||||||
|
const persisted = await loadBearers(key);
|
||||||
|
expect(persisted).toHaveLength(1);
|
||||||
|
expect(persisted[0]?.spent).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { CarveResult } from '@/lnurlcash/ops';
|
||||||
|
import type { BearerChangeset } from '@/lnurlcash/storage';
|
||||||
|
import type { Bearer, NewBearer } from '@/lnurlcash/types';
|
||||||
|
import { TrustedMintPostCommitError } from '@/stores/wallet';
|
||||||
|
import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
|
||||||
|
|
||||||
|
export type CarveWallet = {
|
||||||
|
readonly bearers: readonly Bearer[];
|
||||||
|
readonly addBearers: (notes: NewBearer[], ownerFence: WalletOwnerFence) => Promise<Bearer[]>;
|
||||||
|
readonly applyChangeset: (
|
||||||
|
changeset: BearerChangeset,
|
||||||
|
ownerFence: WalletOwnerFence,
|
||||||
|
) => Promise<Bearer[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CarveCommitContext = Readonly<{
|
||||||
|
ownerFence: WalletOwnerFence;
|
||||||
|
warn: (message: string) => void;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const addCommittedBearers = async (
|
||||||
|
wallet: CarveWallet,
|
||||||
|
notes: NewBearer[],
|
||||||
|
context: CarveCommitContext,
|
||||||
|
): Promise<Bearer[]> => {
|
||||||
|
try {
|
||||||
|
return await wallet.addBearers(notes, context.ownerFence);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof TrustedMintPostCommitError)) throw error;
|
||||||
|
context.warn(error.message);
|
||||||
|
return error.committedBearers;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// A carve is ONE logical rotation: the fresh notes (target + change) and the
|
||||||
|
// spent marks of the burned inputs must land together or not at all - the
|
||||||
|
// mint already destroyed the inputs server-side, so a partial commit (added
|
||||||
|
// but not spent, or vice versa) would strand or double-show money. Hence a
|
||||||
|
// single changeset through the wallet's one-write boundary, never an
|
||||||
|
// add-then-markSpent sequence of separate writes.
|
||||||
|
export const commitCarve = async (
|
||||||
|
wallet: CarveWallet,
|
||||||
|
carve: CarveResult,
|
||||||
|
context: CarveCommitContext,
|
||||||
|
): Promise<Bearer> => {
|
||||||
|
const existing = wallet.bearers.find((bearer) => bearer.url === carve.note.url);
|
||||||
|
const additions: NewBearer[] = [];
|
||||||
|
if (!existing) additions.push(carve.note);
|
||||||
|
if (carve.change) additions.push(carve.change);
|
||||||
|
let added: Bearer[];
|
||||||
|
try {
|
||||||
|
added = await wallet.applyChangeset(
|
||||||
|
{
|
||||||
|
add: additions,
|
||||||
|
markSpent: carve.consumed.map((bearer) => bearer.id),
|
||||||
|
},
|
||||||
|
context.ownerFence,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof TrustedMintPostCommitError)) throw error;
|
||||||
|
context.warn(error.message);
|
||||||
|
added = error.committedBearers;
|
||||||
|
}
|
||||||
|
const committed = existing ?? added[0];
|
||||||
|
if (!committed) throw new Error('The carved note was not tracked.');
|
||||||
|
return committed;
|
||||||
|
};
|
||||||
+22
-264
@@ -64,10 +64,7 @@
|
|||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div class="row items-end q-gutter-sm" :class="targetFeeText ? 'q-mb-xs' : 'q-mb-md'">
|
||||||
class="row items-end q-gutter-sm"
|
|
||||||
:class="targetFeeText ? 'q-mb-xs' : 'q-mb-md'"
|
|
||||||
>
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model.number="amountSats"
|
v-model.number="amountSats"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -238,265 +235,26 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { useMoveFundsPage } from '@/composables/useMoveFundsPage';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { useQuasar } from 'quasar';
|
|
||||||
import { describeMintFee, noteK1, serverOf } from 'lnurlcash-kit';
|
|
||||||
import type { MintFee } from 'lnurlcash-kit';
|
|
||||||
|
|
||||||
import { transferBetweenMints } from '@/lnurlcash/ops';
|
const {
|
||||||
import type { CarveResult, TransferOutcome } from '@/lnurlcash/ops';
|
CUSTOM_TARGET,
|
||||||
import { maxNetForBalance, quoteMintFee } from '@/lnurlcash/fees';
|
amountSats,
|
||||||
import type { NewBearer } from '@/lnurlcash/types';
|
customTarget,
|
||||||
import { floorMsatToSat, msatToSats, satsToMsat, MSAT_PER_SAT } from '@/lnurlcash/units';
|
formFilled,
|
||||||
import { useWalletStore } from '@/stores/wallet';
|
inlineError,
|
||||||
import { useMintsStore } from '@/stores/mints';
|
move,
|
||||||
import { useActivityStore } from '@/stores/activity';
|
proceed,
|
||||||
|
result,
|
||||||
const router = useRouter();
|
router,
|
||||||
const $q = useQuasar();
|
setMax,
|
||||||
const wallet = useWalletStore();
|
sourceOptions,
|
||||||
const mints = useMintsStore();
|
sourceServer,
|
||||||
const activity = useActivityStore();
|
stage,
|
||||||
|
step,
|
||||||
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
|
targetChoice,
|
||||||
// guarded: the Notify plugin registration lives in quasar.config, outside
|
targetFeeText,
|
||||||
// this component's control - a missing registration must not break a flow
|
targetInput,
|
||||||
if (typeof $q.notify === 'function') {
|
targetOptions,
|
||||||
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
} = useMoveFundsPage();
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user