diff --git a/e2e/helpers/MintMocker.ts b/e2e/helpers/MintMocker.ts index e644b98..6b3acdc 100644 --- a/e2e/helpers/MintMocker.ts +++ b/e2e/helpers/MintMocker.ts @@ -52,6 +52,9 @@ interface MockTargetMintOptions { preimage: string; // value of the note a claim mints, in msat 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 { @@ -127,7 +130,7 @@ export class MintMocker { maxSendable: 100_000_000_000, withdrawLink: `${origin}${NOTE_PATH}`, mintPubkey: options.mintPubkey, - metadata: '[]', + metadata: options.mintFeeMetadata ?? '[]', }); }, ); diff --git a/e2e/specs/move-funds.spec.ts b/e2e/specs/move-funds.spec.ts index 966e3bc..5a0cd7a 100644 --- a/e2e/specs/move-funds.spec.ts +++ b/e2e/specs/move-funds.spec.ts @@ -73,6 +73,31 @@ test.describe('Move funds', () => { 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 }) => { await fundSourceMint(page, mint); // the target mint: hands out the invoice, reports it settled with the diff --git a/src/lnurlcash/fees.ts b/src/lnurlcash/fees.ts new file mode 100644 index 0000000..fdd318a --- /dev/null +++ b/src/lnurlcash/fees.ts @@ -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() + +// 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 => { + 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 +} diff --git a/src/pages/MoveFundsPage.vue b/src/pages/MoveFundsPage.vue index 50b27b4..615c2e1 100644 --- a/src/pages/MoveFundsPage.vue +++ b/src/pages/MoveFundsPage.vue @@ -64,7 +64,10 @@ class="q-mb-md" /> -
+
+
+ {{ targetFeeText }} +
{{ inlineError }} @@ -235,10 +241,12 @@ import { computed, ref, watch } from 'vue'; import { useRouter } from 'vue-router'; 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 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'; @@ -331,9 +339,33 @@ const formFilled = computed( (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(null); +let quoteTimer: ReturnType | 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(msat); + amountSats.value = displaySats(maxNetForBalance(msat, targetFee.value)); }; const proceed = () => {