mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
feat(composables): route fund dialogs through the durable seam
This commit is contained in:
@@ -113,6 +113,7 @@
|
|||||||
{{ claimError }}
|
{{ claimError }}
|
||||||
</q-banner>
|
</q-banner>
|
||||||
<q-btn
|
<q-btn
|
||||||
|
v-if="claimRetryable"
|
||||||
unelevated
|
unelevated
|
||||||
color="primary"
|
color="primary"
|
||||||
text-color="dark"
|
text-color="dark"
|
||||||
@@ -215,6 +216,7 @@ const {
|
|||||||
CUSTOM_MINT,
|
CUSTOM_MINT,
|
||||||
amountSats,
|
amountSats,
|
||||||
claimError,
|
claimError,
|
||||||
|
claimRetryable,
|
||||||
copyInvoice,
|
copyInvoice,
|
||||||
createInvoice,
|
createInvoice,
|
||||||
customMint,
|
customMint,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// allow: SIZE_OK — cohesive move-funds UI state machine spanning quote through durable outcome.
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
@@ -7,9 +8,10 @@ import type { MintFee } from 'lnurlcash-kit';
|
|||||||
import { transferBetweenMints } from '@/lnurlcash/ops';
|
import { transferBetweenMints } from '@/lnurlcash/ops';
|
||||||
import type { TransferOutcome } from '@/lnurlcash/ops';
|
import type { TransferOutcome } from '@/lnurlcash/ops';
|
||||||
import { maxNetForBalance, quoteMintFee } from '@/lnurlcash/fees';
|
import { maxNetForBalance, quoteMintFee } from '@/lnurlcash/fees';
|
||||||
import type { Bearer, NewBearer } from '@/lnurlcash/types';
|
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
|
||||||
|
import type { Bearer } from '@/lnurlcash/types';
|
||||||
import { floorMsatToSat, msatToSats, satsToMsat, MSAT_PER_SAT } from '@/lnurlcash/units';
|
import { floorMsatToSat, msatToSats, satsToMsat, MSAT_PER_SAT } from '@/lnurlcash/units';
|
||||||
import { useWalletStore } from '@/stores/wallet';
|
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
|
||||||
import { useMintsStore } from '@/stores/mints';
|
import { useMintsStore } from '@/stores/mints';
|
||||||
import { useActivityStore } from '@/stores/activity';
|
import { useActivityStore } from '@/stores/activity';
|
||||||
import { addCommittedBearers, commitCarve } from './walletCarveCommit';
|
import { addCommittedBearers, commitCarve } from './walletCarveCommit';
|
||||||
@@ -132,28 +134,54 @@ export const useMoveFundsPage = () => {
|
|||||||
if (!sats) return;
|
if (!sats) return;
|
||||||
step.value = 'working';
|
step.value = 'working';
|
||||||
stage.value = 'Asking the target mint for an invoice…';
|
stage.value = 'Asking the target mint for an invoice…';
|
||||||
|
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
|
||||||
try {
|
try {
|
||||||
const ownerFence = wallet.captureOwnerFence();
|
fundOperation = wallet.beginFundOperation();
|
||||||
|
const ownerFence = fundOperation.ownerFence;
|
||||||
|
const ownerId = wallet.pubkey ?? undefined;
|
||||||
const commitContext = { ownerFence, warn: warnCommitted };
|
const commitContext = { ownerFence, warn: warnCommitted };
|
||||||
|
let stagedOutput: Bearer | undefined;
|
||||||
|
let carvedOutput: Bearer | undefined;
|
||||||
// the carve commits the moment it lands server-side (onCarve), not
|
// the carve commits the moment it lands server-side (onCarve), not
|
||||||
// after the melt + settlement wait - an abort mid-wait can then
|
// after the melt + settlement wait - an abort mid-wait can then
|
||||||
// never leave burned inputs looking spendable or strand the outputs
|
// never leave burned inputs looking spendable or strand the outputs
|
||||||
const carveState: { committed?: Bearer } = {};
|
const carveState: { committed?: Bearer } = {};
|
||||||
const transfer = await transferBetweenMints(
|
const transfer = await transferBetweenMints(
|
||||||
wallet.bearers,
|
wallet.bearers.filter((bearer) => serverOf(bearer.url) === sourceServer.value),
|
||||||
satsToMsat(sats),
|
satsToMsat(sats),
|
||||||
targetInput.value,
|
targetInput.value,
|
||||||
{
|
{
|
||||||
assertOwner: ownerFence,
|
assertOwner: ownerFence,
|
||||||
|
allocateOutputSecrets: (server, count) =>
|
||||||
|
wallet.allocateOutputSecrets(server, count, ownerFence),
|
||||||
|
mintSignatureKeys: (server) => getTrustedMintVerificationKeys(server, ownerId),
|
||||||
|
persistOutput: async (note) => {
|
||||||
|
[stagedOutput] = await addCommittedBearers(wallet, [note], commitContext);
|
||||||
|
},
|
||||||
onCarve: async (carve) => {
|
onCarve: async (carve) => {
|
||||||
carveState.committed = await commitCarve(wallet, carve, commitContext);
|
carveState.committed = await commitCarve(wallet, carve, commitContext);
|
||||||
},
|
},
|
||||||
|
onMeltReady: async (carve, sourceRecoverySecret) => {
|
||||||
|
if (!stagedOutput) throw new Error('The pending target note was not persisted.');
|
||||||
|
carvedOutput =
|
||||||
|
carveState.committed ??
|
||||||
|
wallet.bearers.find((bearer) => bearer.url === carve.note.url);
|
||||||
|
if (!carvedOutput) throw new Error('The carved note was not tracked.');
|
||||||
|
await wallet.reserveStagedMintSource(
|
||||||
|
stagedOutput.id,
|
||||||
|
carvedOutput.id,
|
||||||
|
sourceRecoverySecret,
|
||||||
|
ownerFence,
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (!stagedOutput) throw new Error('The pending target note was not persisted.');
|
||||||
stage.value = 'Confirming the result…';
|
stage.value = 'Confirming the result…';
|
||||||
// a carve that mutated nothing fires no hook - the note is already
|
// a carve that mutated nothing fires no hook - the note is already
|
||||||
// tracked; look it up
|
// tracked; look it up
|
||||||
const carved =
|
const carved =
|
||||||
|
carvedOutput ??
|
||||||
carveState.committed ??
|
carveState.committed ??
|
||||||
wallet.bearers.find((bearer) => bearer.url === transfer.carve.note.url);
|
wallet.bearers.find((bearer) => bearer.url === transfer.carve.note.url);
|
||||||
if (!carved) throw new Error('The carved note was not tracked.');
|
if (!carved) throw new Error('The carved note was not tracked.');
|
||||||
@@ -162,13 +190,14 @@ export const useMoveFundsPage = () => {
|
|||||||
}
|
}
|
||||||
const feeSats = msatToSats(transfer.quote.targetMintFeeMsat);
|
const feeSats = msatToSats(transfer.quote.targetMintFeeMsat);
|
||||||
if (transfer.outcome === 'settled') {
|
if (transfer.outcome === 'settled') {
|
||||||
await wallet.markSpent(carved.id, ownerFence);
|
|
||||||
const claimed = transfer.mintedAtTarget;
|
const claimed = transfer.mintedAtTarget;
|
||||||
if (claimed) {
|
if (claimed) {
|
||||||
const notes: NewBearer[] = claimed.possibleCopy
|
try {
|
||||||
? [claimed.note, claimed.possibleCopy]
|
await wallet.finalizeStagedMintOutput(stagedOutput.id, claimed.note, ownerFence);
|
||||||
: [claimed.note];
|
} catch (error) {
|
||||||
await addCommittedBearers(wallet, notes, commitContext);
|
if (!(error instanceof TrustedMintPostCommitError)) throw error;
|
||||||
|
warnCommitted(error.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await activity.log(
|
await activity.log(
|
||||||
'transfer',
|
'transfer',
|
||||||
@@ -178,11 +207,11 @@ export const useMoveFundsPage = () => {
|
|||||||
toast('positive', `Moved ${sats.toLocaleString()} sats.`);
|
toast('positive', `Moved ${sats.toLocaleString()} sats.`);
|
||||||
} else if (transfer.outcome === 'failed-funds-returned') {
|
} else if (transfer.outcome === 'failed-funds-returned') {
|
||||||
if (transfer.rotatedNote) {
|
if (transfer.rotatedNote) {
|
||||||
// the source-probe rotate re-secured the returned funds at a
|
await wallet.restoreStagedMintSource(
|
||||||
// fresh secret, burning the carved note the checkpoint already
|
stagedOutput.id,
|
||||||
// committed - add the new note BEFORE marking the old one spent
|
transfer.rotatedNote,
|
||||||
await addCommittedBearers(wallet, [transfer.rotatedNote], commitContext);
|
ownerFence,
|
||||||
await wallet.markSpent(carved.id, ownerFence);
|
);
|
||||||
}
|
}
|
||||||
await activity.log(
|
await activity.log(
|
||||||
'transfer',
|
'transfer',
|
||||||
@@ -191,22 +220,13 @@ export const useMoveFundsPage = () => {
|
|||||||
);
|
);
|
||||||
} else if (transfer.outcome === 'unknown-still-pending') {
|
} else if (transfer.outcome === 'unknown-still-pending') {
|
||||||
await wallet.markSpent(carved.id, ownerFence);
|
await wallet.markSpent(carved.id, ownerFence);
|
||||||
// a named target's claim material carries the note at the wallet's
|
|
||||||
// own secret - claimable the moment the melt settles, so it is
|
|
||||||
// tracked unverified rather than lost with the result object
|
|
||||||
if (transfer.claimMaterial?.note) {
|
|
||||||
await addCommittedBearers(wallet, [transfer.claimMaterial.note], commitContext);
|
|
||||||
}
|
|
||||||
await activity.log(
|
await activity.log(
|
||||||
'transfer',
|
'transfer',
|
||||||
`A move of ${sats.toLocaleString()} sats to ${transfer.targetServer} is still in flight - the note is locked.`,
|
`A move of ${sats.toLocaleString()} sats to ${transfer.targetServer} is still in flight - the note is locked.`,
|
||||||
(error) => warnCommitted(error.message),
|
(error) => warnCommitted(error.message),
|
||||||
);
|
);
|
||||||
} else if (transfer.outcome === 'settled-claim-failed') {
|
} else if (transfer.outcome === 'settled-claim-failed') {
|
||||||
await wallet.markSpent(carved.id, ownerFence);
|
await wallet.finalizeSpentStagedMintOutput(stagedOutput.id, ownerFence);
|
||||||
if (transfer.claimMaterial?.note) {
|
|
||||||
await addCommittedBearers(wallet, [transfer.claimMaterial.note], commitContext);
|
|
||||||
}
|
|
||||||
await activity.log(
|
await activity.log(
|
||||||
'transfer',
|
'transfer',
|
||||||
`${sats.toLocaleString()} sats arrived at ${transfer.targetServer} but claiming the note failed - it is saved unverified.`,
|
`${sats.toLocaleString()} sats arrived at ${transfer.targetServer} but claiming the note failed - it is saved unverified.`,
|
||||||
@@ -220,7 +240,7 @@ export const useMoveFundsPage = () => {
|
|||||||
(error) => warnCommitted(error.message),
|
(error) => warnCommitted(error.message),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const claimNote = transfer.claimMaterial?.note ?? null;
|
const claimNote = transfer.claimMaterial?.note ?? stagedOutput;
|
||||||
result.value = {
|
result.value = {
|
||||||
outcome: transfer.outcome,
|
outcome: transfer.outcome,
|
||||||
requestedSats: sats,
|
requestedSats: sats,
|
||||||
@@ -237,6 +257,8 @@ export const useMoveFundsPage = () => {
|
|||||||
: message;
|
: message;
|
||||||
toast('negative', inlineError.value);
|
toast('negative', inlineError.value);
|
||||||
step.value = 'form';
|
step.value = 'form';
|
||||||
|
} finally {
|
||||||
|
fundOperation?.complete();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
import { decodeBolt11AmountMsat, isBolt11Invoice, resolveLnurlInput } from 'lnurlcash-kit';
|
import {
|
||||||
|
decodeBolt11AmountMsat,
|
||||||
|
isBolt11Invoice,
|
||||||
|
resolveLnurlInput,
|
||||||
|
withNewK1,
|
||||||
|
} from 'lnurlcash-kit';
|
||||||
|
|
||||||
import { readClipboard } from '@/capabilities/clipboard';
|
import { readClipboard } from '@/capabilities/clipboard';
|
||||||
import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops';
|
import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops';
|
||||||
import type { PayOutcome } from '@/lnurlcash/ops';
|
import type { PayOutcome } from '@/lnurlcash/ops';
|
||||||
|
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
|
||||||
import type { Bearer } from '@/lnurlcash/types';
|
import type { Bearer } from '@/lnurlcash/types';
|
||||||
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
|
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
|
||||||
import { useWalletStore } from '@/stores/wallet';
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
@@ -143,31 +149,72 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit
|
|||||||
step.value = 'working';
|
step.value = 'working';
|
||||||
stage.value = 'Preparing the exact amount and sending the payment…';
|
stage.value = 'Preparing the exact amount and sending the payment…';
|
||||||
let ownerFence: WalletOwnerFence | undefined;
|
let ownerFence: WalletOwnerFence | undefined;
|
||||||
|
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
|
||||||
try {
|
try {
|
||||||
ownerFence = wallet.captureOwnerFence();
|
fundOperation = wallet.beginFundOperation();
|
||||||
const commitContext = { ownerFence, warn: warnCommitted };
|
ownerFence = fundOperation.ownerFence;
|
||||||
|
const fence = ownerFence;
|
||||||
|
const ownerId = wallet.pubkey ?? undefined;
|
||||||
|
const commitContext = { ownerFence: fence, warn: warnCommitted };
|
||||||
// the carve commits the moment it lands server-side (onCarve), not
|
// the carve commits the moment it lands server-side (onCarve), not
|
||||||
// after the settlement wait - an abort mid-wait can then never leave
|
// after the settlement wait - an abort mid-wait can then never leave
|
||||||
// burned inputs looking spendable or strand the fresh outputs
|
// burned inputs looking spendable or strand the fresh outputs
|
||||||
const carveState: { committed?: Bearer } = {};
|
const carveState: { committed?: Bearer } = {};
|
||||||
|
let returnStage: Bearer | undefined;
|
||||||
const onCarve = async (carve: Parameters<typeof commitCarve>[1]): Promise<void> => {
|
const onCarve = async (carve: Parameters<typeof commitCarve>[1]): Promise<void> => {
|
||||||
carveState.committed = await commitCarve(wallet, carve, commitContext);
|
carveState.committed = await commitCarve(wallet, carve, commitContext);
|
||||||
};
|
};
|
||||||
const paid = await payWithBearers(
|
const allocateOutputSecrets = (server: string, count: number) =>
|
||||||
wallet.bearers,
|
wallet.allocateOutputSecrets(server, count, fence);
|
||||||
payment.input,
|
const mintSignatureKeys = (server: string) =>
|
||||||
payment.kind === 'address'
|
getTrustedMintVerificationKeys(server, ownerId);
|
||||||
? { amountMsat: payment.amountMsat, assertOwner: ownerFence, onCarve }
|
// the pay-return recovery output: staged durably (allocated secret,
|
||||||
: { assertOwner: ownerFence, onCarve },
|
// zero value, pendingMint) and linked to the carved source BEFORE the
|
||||||
|
// classification rotate can burn it
|
||||||
|
const onReturnReady = async (
|
||||||
|
carve: Parameters<typeof commitCarve>[1],
|
||||||
|
recoverySecret: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const source =
|
||||||
|
carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === carve.note.url);
|
||||||
|
if (!source) throw new Error('The carved note was not tracked.');
|
||||||
|
[returnStage] = await addCommittedBearers(
|
||||||
|
wallet,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
url: withNewK1(carve.note.url, recoverySecret, carve.note.amount),
|
||||||
|
callback: '',
|
||||||
|
amount: 0,
|
||||||
|
verified: false,
|
||||||
|
pendingMint: { retireAfter: Math.floor(Date.now() / 1000) + 14 * 24 * 60 * 60 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
commitContext,
|
||||||
);
|
);
|
||||||
|
if (!returnStage) throw new Error('The payment recovery output was not persisted.');
|
||||||
|
await wallet.reserveStagedMintSource(returnStage.id, source.id, recoverySecret, fence);
|
||||||
|
};
|
||||||
|
const paid = await payWithBearers(wallet.bearers, payment.input, {
|
||||||
|
...(payment.kind === 'address' ? { amountMsat: payment.amountMsat } : {}),
|
||||||
|
assertOwner: fence,
|
||||||
|
onCarve,
|
||||||
|
allocateOutputSecrets,
|
||||||
|
mintSignatureKeys,
|
||||||
|
onReturnReady,
|
||||||
|
});
|
||||||
stage.value = 'Confirming the result…';
|
stage.value = 'Confirming the result…';
|
||||||
// a carve that mutated nothing (one note already held the exact
|
// a carve that mutated nothing (one note already held the exact
|
||||||
// amount) fires no hook - the note is already tracked; look it up
|
// amount) fires no hook - the note is already tracked; look it up
|
||||||
const committed =
|
const committed =
|
||||||
carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === paid.carve.note.url);
|
carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === paid.carve.note.url);
|
||||||
if (!committed) throw new Error('The carved note was not tracked.');
|
if (!committed) throw new Error('The carved note was not tracked.');
|
||||||
if (paid.rescuedNote) {
|
if (returnStage) {
|
||||||
await addCommittedBearers(wallet, [paid.rescuedNote], commitContext);
|
const returned = paid.rotatedNote ?? paid.rescuedNote;
|
||||||
|
if (returned) {
|
||||||
|
await wallet.finalizeStagedMintOutput(returnStage.id, returned, fence);
|
||||||
|
} else if (paid.outcome !== 'unknown-still-pending') {
|
||||||
|
await wallet.finalizeSpentStagedMintOutput(returnStage.id, fence);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const sats = formatSats(msatToSats(paid.amountMsat));
|
const sats = formatSats(msatToSats(paid.amountMsat));
|
||||||
if (paid.outcome === 'settled') {
|
if (paid.outcome === 'settled') {
|
||||||
@@ -178,13 +225,6 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit
|
|||||||
toast('positive', `Paid ${sats} sats.`);
|
toast('positive', `Paid ${sats} sats.`);
|
||||||
emit('sent');
|
emit('sent');
|
||||||
} else if (paid.outcome === 'failed-funds-returned') {
|
} else if (paid.outcome === 'failed-funds-returned') {
|
||||||
if (paid.rotatedNote) {
|
|
||||||
// the classification rotate re-secured the returned funds at a
|
|
||||||
// fresh secret, burning the carved note the checkpoint already
|
|
||||||
// committed - add the new note BEFORE marking the old one spent
|
|
||||||
await addCommittedBearers(wallet, [paid.rotatedNote], commitContext);
|
|
||||||
await wallet.markSpent(committed.id, ownerFence);
|
|
||||||
}
|
|
||||||
await activity.log(
|
await activity.log(
|
||||||
'transfer',
|
'transfer',
|
||||||
`A ${sats} sat payment failed - funds are back in your wallet.`,
|
`A ${sats} sat payment failed - funds are back in your wallet.`,
|
||||||
@@ -227,6 +267,8 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit
|
|||||||
toast('negative', inlineError.value);
|
toast('negative', inlineError.value);
|
||||||
}
|
}
|
||||||
step.value = 'input';
|
step.value = 'input';
|
||||||
|
} finally {
|
||||||
|
fundOperation?.complete();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const closeResult = (): void => {
|
const closeResult = (): void => {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { computed, ref, watch } from 'vue';
|
|||||||
import { Notify } from 'quasar';
|
import { Notify } from 'quasar';
|
||||||
|
|
||||||
import { writeClipboard } from '@/capabilities/clipboard';
|
import { writeClipboard } from '@/capabilities/clipboard';
|
||||||
import { prepareMint, claimMintedNote } from '@/lnurlcash/ops';
|
import { MintedNoteSpentError, prepareMint, claimMintedNote } from '@/lnurlcash/ops';
|
||||||
import type { ClaimedNote, PreparedMint } from '@/lnurlcash/ops';
|
import type { ClaimedNote, PreparedMint } from '@/lnurlcash/ops';
|
||||||
import type { NewBearer } from '@/lnurlcash/types';
|
import type { Bearer } from '@/lnurlcash/types';
|
||||||
import { msatToSats, satsToMsat, floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
|
import { msatToSats, satsToMsat, floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
|
||||||
import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints';
|
import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints';
|
||||||
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
|
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
|
||||||
@@ -35,8 +35,10 @@ export const useReceiveLightningDialog = (
|
|||||||
const preparing = ref(false);
|
const preparing = ref(false);
|
||||||
const formError = ref('');
|
const formError = ref('');
|
||||||
const prepared = ref<PreparedMint | null>(null);
|
const prepared = ref<PreparedMint | null>(null);
|
||||||
|
const stagedOutput = ref<Bearer | null>(null);
|
||||||
const waiting = ref(false);
|
const waiting = ref(false);
|
||||||
const claimError = ref('');
|
const claimError = ref('');
|
||||||
|
const claimRetryable = ref(true);
|
||||||
const receivedSats = ref(0);
|
const receivedSats = ref(0);
|
||||||
const receivedServer = ref('');
|
const receivedServer = ref('');
|
||||||
const rotationWarning = ref('');
|
const rotationWarning = ref('');
|
||||||
@@ -85,23 +87,21 @@ export const useReceiveLightningDialog = (
|
|||||||
const onClaimed = async (
|
const onClaimed = async (
|
||||||
claimed: ClaimedNote,
|
claimed: ClaimedNote,
|
||||||
from: PreparedMint,
|
from: PreparedMint,
|
||||||
|
staged: Bearer,
|
||||||
ownerFence: WalletOwnerFence,
|
ownerFence: WalletOwnerFence,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const server = from.server;
|
const server = from.server;
|
||||||
const wasTrusted = mints.isTrusted(server);
|
const wasTrusted = mints.isTrusted(server);
|
||||||
const notes: NewBearer[] = claimed.possibleCopy
|
|
||||||
? [claimed.note, claimed.possibleCopy]
|
|
||||||
: [claimed.note];
|
|
||||||
let trustWarning = '';
|
let trustWarning = '';
|
||||||
try {
|
try {
|
||||||
await wallet.addBearers(notes, ownerFence);
|
await wallet.finalizeStagedMintOutput(staged.id, claimed.note, ownerFence);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof TrustedMintPostCommitError)) throw error;
|
if (!(error instanceof TrustedMintPostCommitError)) throw error;
|
||||||
trustWarning = error.message;
|
trustWarning = error.message;
|
||||||
}
|
}
|
||||||
receivedSats.value = displaySats(claimed.note.amount);
|
receivedSats.value = displaySats(claimed.note.amount);
|
||||||
receivedServer.value = server;
|
receivedServer.value = server;
|
||||||
rotationWarning.value = claimed.rotationError ?? '';
|
rotationWarning.value = '';
|
||||||
await activity.log(
|
await activity.log(
|
||||||
'mint',
|
'mint',
|
||||||
`Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`,
|
`Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`,
|
||||||
@@ -130,23 +130,35 @@ export const useReceiveLightningDialog = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const beginClaim = (): void => {
|
const beginClaim = (): void => {
|
||||||
if (!prepared.value || claimRun) return;
|
if (!prepared.value || !stagedOutput.value || claimRun) return;
|
||||||
waiting.value = true;
|
waiting.value = true;
|
||||||
claimError.value = '';
|
claimError.value = '';
|
||||||
|
claimRetryable.value = true;
|
||||||
const current = prepared.value;
|
const current = prepared.value;
|
||||||
|
const staged = stagedOutput.value;
|
||||||
claimRun = (async () => {
|
claimRun = (async () => {
|
||||||
|
const fundOperation = wallet.beginFundOperation();
|
||||||
try {
|
try {
|
||||||
const ownerFence = wallet.captureOwnerFence();
|
const ownerFence = fundOperation.ownerFence;
|
||||||
await onClaimed(
|
await onClaimed(
|
||||||
await claimMintedNote(current, {}, { assertOwner: ownerFence }),
|
await claimMintedNote(current, {}, { assertOwner: ownerFence }),
|
||||||
current,
|
current,
|
||||||
|
staged,
|
||||||
ownerFence,
|
ownerFence,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof Error)) throw error;
|
if (!(error instanceof Error)) throw error;
|
||||||
|
if (error instanceof MintedNoteSpentError) {
|
||||||
|
await wallet.finalizeSpentStagedMintOutput(staged.id, fundOperation.ownerFence);
|
||||||
|
claimRetryable.value = false;
|
||||||
|
claimError.value = errorMessage(error);
|
||||||
|
Notify.create({ type: 'negative', message: claimError.value });
|
||||||
|
return;
|
||||||
|
}
|
||||||
claimError.value = `${errorMessage(error)} The invoice stays valid — you can try again.`;
|
claimError.value = `${errorMessage(error)} The invoice stays valid — you can try again.`;
|
||||||
Notify.create({ type: 'negative', message: errorMessage(error) });
|
Notify.create({ type: 'negative', message: errorMessage(error) });
|
||||||
} finally {
|
} finally {
|
||||||
|
fundOperation.complete();
|
||||||
waiting.value = false;
|
waiting.value = false;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -156,20 +168,26 @@ export const useReceiveLightningDialog = (
|
|||||||
if (!sats || preparing.value) return;
|
if (!sats || preparing.value) return;
|
||||||
preparing.value = true;
|
preparing.value = true;
|
||||||
formError.value = '';
|
formError.value = '';
|
||||||
|
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
|
||||||
try {
|
try {
|
||||||
const input = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value;
|
const input = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value;
|
||||||
const next = await prepareMint(input, satsToMsat(sats));
|
fundOperation = wallet.beginFundOperation();
|
||||||
// named mints claim through the wallet's own secret and may
|
const ownerFence = fundOperation.ownerFence;
|
||||||
// legitimately serve no verify URL; unnamed mints need one (the
|
let persisted: Bearer | undefined;
|
||||||
// preimage it reveals IS the claim)
|
const next = await prepareMint(input, satsToMsat(sats), {
|
||||||
if (next.mode === 'unnamed' && !next.verifyUrl) {
|
assertOwner: ownerFence,
|
||||||
formError.value =
|
allocateOutputSecrets: (server, count) =>
|
||||||
'This mint does not support automatic claiming, so sattle cannot receive from it. Choose a different mint.';
|
wallet.allocateOutputSecrets(server, count, ownerFence),
|
||||||
return;
|
persistOutput: async (note) => {
|
||||||
}
|
[persisted] = await wallet.addBearers([note], ownerFence);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!persisted) throw new Error('The pending mint output was not persisted.');
|
||||||
|
stagedOutput.value = persisted;
|
||||||
prepared.value = next;
|
prepared.value = next;
|
||||||
claimRun = null;
|
claimRun = null;
|
||||||
claimError.value = '';
|
claimError.value = '';
|
||||||
|
claimRetryable.value = true;
|
||||||
step.value = 'invoice';
|
step.value = 'invoice';
|
||||||
beginClaim();
|
beginClaim();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -177,6 +195,7 @@ export const useReceiveLightningDialog = (
|
|||||||
formError.value = errorMessage(error);
|
formError.value = errorMessage(error);
|
||||||
Notify.create({ type: 'negative', message: formError.value });
|
Notify.create({ type: 'negative', message: formError.value });
|
||||||
} finally {
|
} finally {
|
||||||
|
fundOperation?.complete();
|
||||||
preparing.value = false;
|
preparing.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -211,6 +230,7 @@ export const useReceiveLightningDialog = (
|
|||||||
preparing.value = false;
|
preparing.value = false;
|
||||||
formError.value = '';
|
formError.value = '';
|
||||||
prepared.value = null;
|
prepared.value = null;
|
||||||
|
stagedOutput.value = null;
|
||||||
waiting.value = false;
|
waiting.value = false;
|
||||||
rotationWarning.value = '';
|
rotationWarning.value = '';
|
||||||
},
|
},
|
||||||
@@ -219,6 +239,7 @@ export const useReceiveLightningDialog = (
|
|||||||
CUSTOM_MINT,
|
CUSTOM_MINT,
|
||||||
amountSats,
|
amountSats,
|
||||||
claimError,
|
claimError,
|
||||||
|
claimRetryable,
|
||||||
copyInvoice,
|
copyInvoice,
|
||||||
createInvoice,
|
createInvoice,
|
||||||
customMint,
|
customMint,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { toBech32Lnurl } from 'lnurlcash-kit';
|
|||||||
import { writeClipboard } from '@/capabilities/clipboard';
|
import { writeClipboard } from '@/capabilities/clipboard';
|
||||||
import { canShareText, shareText } from '@/capabilities/share';
|
import { canShareText, shareText } from '@/capabilities/share';
|
||||||
import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops';
|
import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops';
|
||||||
|
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
|
||||||
import type { Bearer } from '@/lnurlcash/types';
|
import type { Bearer } from '@/lnurlcash/types';
|
||||||
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
|
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
|
||||||
import { useWalletStore } from '@/stores/wallet';
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
@@ -80,15 +81,21 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
|
|||||||
preparing.value = true;
|
preparing.value = true;
|
||||||
errorMessage.value = null;
|
errorMessage.value = null;
|
||||||
let ownerFence: WalletOwnerFence | undefined;
|
let ownerFence: WalletOwnerFence | undefined;
|
||||||
|
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
|
||||||
try {
|
try {
|
||||||
ownerFence = wallet.captureOwnerFence();
|
fundOperation = wallet.beginFundOperation();
|
||||||
|
ownerFence = fundOperation.ownerFence;
|
||||||
const fence = ownerFence;
|
const fence = ownerFence;
|
||||||
|
const ownerId = wallet.pubkey ?? undefined;
|
||||||
// the carve commits the moment it lands server-side (onCarve), so
|
// the carve commits the moment it lands server-side (onCarve), so
|
||||||
// even a crash right after can never strand the fresh outputs or
|
// even a crash right after can never strand the fresh outputs or
|
||||||
// leave burned inputs looking spendable
|
// leave burned inputs looking spendable
|
||||||
const carveState: { committed?: Bearer } = {};
|
const carveState: { committed?: Bearer } = {};
|
||||||
const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats), {
|
const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats), {
|
||||||
assertOwner: fence,
|
assertOwner: fence,
|
||||||
|
allocateOutputSecrets: (server, count) =>
|
||||||
|
wallet.allocateOutputSecrets(server, count, fence),
|
||||||
|
mintSignatureKeys: (server) => getTrustedMintVerificationKeys(server, ownerId),
|
||||||
onCarve: async (landed) => {
|
onCarve: async (landed) => {
|
||||||
carveState.committed = await commitCarve(wallet, landed, {
|
carveState.committed = await commitCarve(wallet, landed, {
|
||||||
ownerFence: fence,
|
ownerFence: fence,
|
||||||
@@ -141,6 +148,7 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
|
|||||||
: message;
|
: message;
|
||||||
toast('negative', errorMessage.value);
|
toast('negative', errorMessage.value);
|
||||||
} finally {
|
} finally {
|
||||||
|
fundOperation?.complete();
|
||||||
preparing.value = false;
|
preparing.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -165,8 +173,10 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
|
|||||||
const note = prepared.value;
|
const note = prepared.value;
|
||||||
if (!note) return;
|
if (!note) return;
|
||||||
removing.value = true;
|
removing.value = true;
|
||||||
|
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
|
||||||
try {
|
try {
|
||||||
await wallet.markSpent(note.id, wallet.captureOwnerFence());
|
fundOperation = wallet.beginFundOperation();
|
||||||
|
await wallet.markSpent(note.id, fundOperation.ownerFence);
|
||||||
await activity.log(
|
await activity.log(
|
||||||
'spent',
|
'spent',
|
||||||
`Handed over a ${formatSats(msatToSats(note.amount))} sat note.`,
|
`Handed over a ${formatSats(msatToSats(note.amount))} sat note.`,
|
||||||
@@ -178,6 +188,7 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast('negative', error instanceof Error ? error.message : 'Something went wrong.');
|
toast('negative', error instanceof Error ? error.message : 'Something went wrong.');
|
||||||
} finally {
|
} finally {
|
||||||
|
fundOperation?.complete();
|
||||||
removing.value = false;
|
removing.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user