Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/fees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,43 @@ const proportionalFee = (grossMsat: number, feePpm: number): number =>
export const applyMintFee = (grossMsat: number, fee: MintFee): number =>
Math.max(0, grossMsat - fee.baseFeeMsat - proportionalFee(grossMsat, fee.feePpm))

// LUD-25 states the mint fee as `base_fee_msat` plus a ppm cut and says
// nothing at all about rounding. Two live implementations read that
// differently, and both are defensible:
//
// - dni's lnurl-mint, the reference, and what every public mint on the
// awesome list except moneyer runs, ceilings the fee to a whole sat on
// purpose, so the mint is "never short a sat". 1_040 msat becomes
// 2_000.
// - moneyer withholds the msat-exact amount.
//
// So a wallet predicting one number warns spuriously against the other:
// against lnurl-mint the note lands up to 999 msat lighter than the
// formula says, and telling a holder their mint short-changed them when
// it did exactly what it documents is worse than saying nothing.
//
// The honest prediction is a range. The formula is the most a holder can
// be credited; the sat-ceilinged fee is the least. Until the draft settles
// which is correct (lnurl/luds#301), treat anything inside as compliant.
export type MintFeeBand = {minNetMsat: number; maxNetMsat: number}

export const mintFeeBand = (grossMsat: number, fee: MintFee): MintFeeBand => {
const exactFee = fee.baseFeeMsat + proportionalFee(grossMsat, fee.feePpm)
const satCeilinged = Math.ceil(exactFee / 1000) * 1000
return {
minNetMsat: Math.max(0, grossMsat - satCeilinged),
maxNetMsat: Math.max(0, grossMsat - exactFee)
}
}

// Whether a credited note is consistent with the advertised fee under
// either reading. Use this rather than comparing against applyMintFee: an
// exact match is the best case, not the only compliant one.
export const withinMintFeeBand = (grossMsat: number, netMsat: number, fee: MintFee): boolean => {
const {minNetMsat, maxNetMsat} = mintFeeBand(grossMsat, fee)
return netMsat >= minNetMsat && netMsat <= maxNetMsat
}

// The inverse: the SMALLEST invoice amount whose note nets `netMsat` after
// the SERVICE's fee.
//
Expand Down
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,13 @@ export {
export {
parseMintFee,
applyMintFee,
mintFeeBand,
withinMintFeeBand,
grossUpForMintFee,
formatFeePercent,
describeMintFee,
type MintFee
type MintFee,
type MintFeeBand
} from './fees.js'

export {
Expand Down
45 changes: 45 additions & 0 deletions test/vectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {readFileSync} from 'node:fs'
import {createRequire} from 'node:module'
import {
applyMintFee,
mintFeeBand,
withinMintFeeBand,
decodeBolt11AmountMsat,
formatFeePercent,
fromBech32Lnurl,
Expand Down Expand Up @@ -266,3 +268,46 @@ describe('fee parsing beyond the vectors', () => {
).toBeNull()
})
})

// The fee band. LUD-25 does not say whether the fee rounds, and the two
// live implementations disagree, so a wallet that predicts one number
// warns spuriously against the other. Both numbers below were measured on
// real sats on 2026-08-21.
describe('the mint fee band', () => {
it('spans the reference mint and moneyer, which disagree', () => {
// mint.forgesworn.dev (dni's lnurl-mint): 40_000 gross at 1000 + 1000ppm
// credited 38_000, because it ceilings a 1_040 msat fee to 2_000.
const fee = {baseFeeMsat: 1000, feePpm: 1000}
const band = mintFeeBand(40_000, fee)
expect(band.maxNetMsat).toBe(38_960) // the msat-exact formula
expect(band.minNetMsat).toBe(38_000) // the sat-ceilinged fee
expect(withinMintFeeBand(40_000, 38_000, fee)).toBe(true)
expect(withinMintFeeBand(40_000, 38_960, fee)).toBe(true)
// A msat past either edge is not explained by the rounding question.
expect(withinMintFeeBand(40_000, 37_999, fee)).toBe(false)
expect(withinMintFeeBand(40_000, 38_961, fee)).toBe(false)
})

it('collapses to a point when the fee already lands on a whole sat', () => {
// moneyer.dev: 100_000 gross at 5000 + 1000ppm is exactly 5_100 msat,
// so ceilinging gives 6_000 and the band is a real range...
const moneyer = {baseFeeMsat: 5000, feePpm: 1000}
expect(mintFeeBand(100_000, moneyer)).toEqual({minNetMsat: 94_000, maxNetMsat: 94_900})
// ...but a fee that is already whole leaves nothing to round.
const whole = {baseFeeMsat: 2000, feePpm: 0}
expect(mintFeeBand(50_000, whole)).toEqual({minNetMsat: 48_000, maxNetMsat: 48_000})
})

it('never reports a negative net', () => {
const fee = {baseFeeMsat: 10_000, feePpm: 0}
expect(mintFeeBand(1_000, fee)).toEqual({minNetMsat: 0, maxNetMsat: 0})
})

it('agrees with applyMintFee at the generous edge', () => {
for (const gross of [12_000, 55_055, 100_000, 21_000_000]) {
for (const fee of [{baseFeeMsat: 0, feePpm: 0}, {baseFeeMsat: 1000, feePpm: 1000}, {baseFeeMsat: 5000, feePpm: 2500}]) {
expect(mintFeeBand(gross, fee).maxNetMsat).toBe(applyMintFee(gross, fee))
}
}
})
})