feat: reusable mint fee math with cached quotes, fee-aware Max in move funds

This commit is contained in:
2026-08-20 10:13:36 +02:00
parent 39261212cf
commit f4b9377d17
4 changed files with 148 additions and 4 deletions
+4 -1
View File
@@ -52,6 +52,9 @@ interface MockTargetMintOptions {
preimage: string; preimage: string;
// value of the note a claim mints, in msat // value of the note a claim mints, in msat
noteAmountMsat: number; noteAmountMsat: number;
// optional payRequest metadata advertising a receive fee, e.g.
// '[["text/plain","Mint fees: 2000,0"]]' (flat 2000 msat, 0 ppm)
mintFeeMetadata?: string;
} }
export class MintMocker { export class MintMocker {
@@ -127,7 +130,7 @@ export class MintMocker {
maxSendable: 100_000_000_000, maxSendable: 100_000_000_000,
withdrawLink: `${origin}${NOTE_PATH}`, withdrawLink: `${origin}${NOTE_PATH}`,
mintPubkey: options.mintPubkey, mintPubkey: options.mintPubkey,
metadata: '[]', metadata: options.mintFeeMetadata ?? '[]',
}); });
}, },
); );
+25
View File
@@ -73,6 +73,31 @@ test.describe('Move funds', () => {
await expect(page.getByRole('button', { name: 'Move now' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Move now' })).toBeVisible();
}); });
test('the Max helper discounts the target mint fee', async ({ page, mint }) => {
await fundSourceMint(page, mint);
// the target mint advertises a flat 2-sat receive fee (2000 msat, 0 ppm)
await mint.mockTargetMint(
{
mintPubkey: TARGET_PUBKEY,
invoice: INVOICE,
preimage: PREIMAGE,
noteAmountMsat: AMOUNT_MSAT,
mintFeeMetadata: '[["text/plain","Mint fees: 2000,0"]]',
},
MINT2_ORIGIN,
);
await page.goto('/#/settings/move');
await pickOption(page, 'From mint', 'mint.test - 50 sats available');
await pickOption(page, 'To mint', 'Another mint…');
await page.getByLabel('Target mint address').fill('@mint2.test');
// the fee quote lands (caption shown), then Max fills 50 - 2 = 48
await expect(page.getByText(/receive fee/)).toBeVisible();
await page.getByRole('button', { name: 'Max' }).click();
await expect(page.getByLabel('Amount')).toHaveValue('48');
});
test('a two-mint transfer moves the balance', async ({ page, mint }) => { test('a two-mint transfer moves the balance', async ({ page, mint }) => {
await fundSourceMint(page, mint); await fundSourceMint(page, mint);
// the target mint: hands out the invoice, reports it settled with the // the target mint: hands out the invoice, reports it settled with the
+84
View File
@@ -0,0 +1,84 @@
// Mint fee math, reusable wherever an amount crosses a mint boundary
// (transfer Max, receive-Lighting amount hints, move-funds quotes). The
// primitives come from lnurlcash-kit (applyMintFee/grossUpForMintFee);
// this module adds the wallet's whole-sat rounding conventions and a
// short-lived quote cache so forms don't refetch a mint's payRequest on
// every keystroke or page visit.
//
// Fee direction matters and is easy to get wrong:
// - netAfterMintFee: a note minted for `grossMsat` comes out worth the
// net (the mint withholds base + ppm)
// - grossForMintFee: to LAND `netMsat`, the invoice must be this gross
// (whole sats, like prepareMint's carve target)
// - maxNetForBalance: the most that can be moved out of a balance after
// the target's fee - floored to whole sats so the gross never
// overshoots (applyMintFee is monotonic, so grossUp of the floored net
// stays within the balance)
import {
applyMintFee,
fetchMintAddress,
fetchPayRequest,
grossUpForMintFee,
mintAddressUrl,
resolveMintInput,
serverOf
} from 'lnurlcash-kit'
import type {LnurlcashOptions, MintFee} from 'lnurlcash-kit'
import {ceilMsatToSat, floorMsatToSat} from './units'
export const netAfterMintFee = (grossMsat: number, fee: MintFee): number =>
applyMintFee(grossMsat, fee)
export const grossForMintFee = (netMsat: number, fee: MintFee): number =>
ceilMsatToSat(grossUpForMintFee(netMsat, fee))
export const maxNetForBalance = (balanceMsat: number, fee: MintFee | null): number =>
fee ? floorMsatToSat(applyMintFee(balanceMsat, fee)) : balanceMsat
// Fee quotes are per-mint and change rarely; cache briefly by server.
// nulls are cached too - an unreachable mint shouldn't be retried on
// every render either.
const QUOTE_TTL_MS = 60_000
const QUOTE_TIMEOUT_MS = 5_000
const quoteCache = new Map<string, {at: number; fee: MintFee | null}>()
// tests can reset the cache between cases
export const clearMintFeeQuoteCache = (): void => quoteCache.clear()
// A pre-flight read of a mint's advertised receive fee WITHOUT requesting
// an invoice - the same resolution chain as prepareMint (mint-address
// discovery first, its payLink authoritative), so a quote never disagrees
// with what a real mint/transfer would be charged. null when the mint
// advertises no fee, doesn't speak lnurlcash minting, or can't be
// reached right now.
export const quoteMintFee = async (
mintInput: string,
options: LnurlcashOptions = {}
): Promise<MintFee | null> => {
const url = resolveMintInput(mintInput)
if (!url) return null
const server = serverOf(url)
const cached = quoteCache.get(server)
if (cached && Date.now() - cached.at < QUOTE_TTL_MS) return cached.fee
const opts: LnurlcashOptions = {timeoutMs: QUOTE_TIMEOUT_MS, ...options}
let payUrl = url
const addressUrl = mintAddressUrl(url)
if (addressUrl) {
try {
payUrl = (await fetchMintAddress(addressUrl, opts)).payLink
} catch {
// no mint-address support - the plain payRequest guess still works
}
}
let fee: MintFee | null
try {
fee = (await fetchPayRequest(payUrl, opts)).mintFee ?? null
} catch {
fee = null
}
quoteCache.set(server, {at: Date.now(), fee})
return fee
}
+35 -3
View File
@@ -64,7 +64,10 @@
class="q-mb-md" class="q-mb-md"
/> />
<div class="row items-end q-gutter-sm q-mb-md"> <div
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"
@@ -87,6 +90,9 @@
@click="setMax" @click="setMax"
/> />
</div> </div>
<div v-if="targetFeeText" class="text-caption text-grey-5 q-mb-md">
{{ targetFeeText }}
</div>
<q-banner v-if="inlineError" dense class="bg-negative text-white rounded-borders q-mb-md"> <q-banner v-if="inlineError" dense class="bg-negative text-white rounded-borders q-mb-md">
{{ inlineError }} {{ inlineError }}
@@ -235,10 +241,12 @@
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';
import { noteK1, serverOf } from 'lnurlcash-kit'; import { describeMintFee, noteK1, serverOf } from 'lnurlcash-kit';
import type { MintFee } from 'lnurlcash-kit';
import { transferBetweenMints } from '@/lnurlcash/ops'; import { transferBetweenMints } from '@/lnurlcash/ops';
import type { CarveResult, TransferOutcome } from '@/lnurlcash/ops'; import type { CarveResult, TransferOutcome } from '@/lnurlcash/ops';
import { maxNetForBalance, quoteMintFee } from '@/lnurlcash/fees';
import type { NewBearer } from '@/lnurlcash/types'; import type { NewBearer } 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 { useWalletStore } from '@/stores/wallet';
@@ -331,9 +339,33 @@ const formFilled = computed(
(amountSats.value ?? 0) >= 1, (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 setMax = () => {
const msat = spendableByServerMsat.value.get(sourceServer.value) ?? 0; const msat = spendableByServerMsat.value.get(sourceServer.value) ?? 0;
amountSats.value = displaySats(msat); amountSats.value = displaySats(maxNetForBalance(msat, targetFee.value));
}; };
const proceed = () => { const proceed = () => {