Files
minibits_wallet/__tests__/onchainTopupOperation.test.ts
T
minibits-cashandClaude Opus 4.8 eba3c92827 Add onchain topup UI (Stage 5 of onchain)
Onchain topup is now reachable: RECEIVE > Bitcoin > enter amount > pick mint >
"Bitcoin address" > scan the BIP21 QR. The deposit is picked up by the Stage 4
watcher and minted automatically.

The rail choice rides on MintBalanceSelector's existing secondary-confirm slot
(SendScreen already uses it for "Lock"), so no new screen or modal: primary
stays "Create invoice", and a second button appears only when the SELECTED
mint advertises onchain for this unit AND the amount clears the floor. Below
the floor the option is hidden rather than shown-and-rejected — a sub-minimum
onchain deposit increases amount_paid for nobody and, per NUT-30, is "not
recoverable through the mint quote protocol".

onchainTopupFloor is max(our floor, the mint's min_amount), because the mint's
number cannot be trusted to protect anyone: the CDK test mint advertised
min_amount: 1, below Bitcoin's own 546-sat dust limit, and we watched it be
wrong in two independent ways. NUT-30 evaluates min_amount PER UTXO and such
deposits cannot be aggregated to clear the bar, so dust is simply gone. Our
floor is 10k sats.

buildBip21Uri converts sats to BTC via toFixed(8) rather than dividing and
stringifying: naive division yields 0.000010000000000000001 for 1000 sats and
exponent form (1e-8) for small values, neither of which is a valid BIP21
amount. Tests pin both cases.

The amount in the URI is only a hint — a sender can edit or ignore it — so the
QR is captioned to say the balance settles to whatever actually arrives, and
that ecash appears only once the deposit confirms. Without that, an
underpayment looks like a bug.

TranDetailScreen gets an OnchainTopupInfoBlock with "check for deposits". This
is not a nicety: an onchain address never expires (the mint returns expiry:
null), so money sent after the 7-day watch window closes is still credited and
still mintable — the wallet just is not looking. The action reopens the watch
window and re-checks immediately, which is the recovery path for anything the
watcher missed, and the reason quote rows (and their NUT-20 counterIndex) are
kept forever.

254/254 tests pass; typecheck introduces no new errors; all tx keys resolve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:39:41 +02:00

128 lines
5.1 KiB
TypeScript

/**
* Onchain (NUT-30) mint-amount arithmetic.
*
* Same split as the other operation-lifecycle tests: jest pins what it can verify
* without bringing up MST stores / cashu-ts / SQLite, and runtime orchestration is
* verified on device.
*
* What IS worth pinning here is the arithmetic, because it decides how much money
* to mint. Both functions exist to refuse a mint response we did not expect — a
* negative mintable balance, or a deposit larger than the mint will issue in one
* operation — rather than passing it into a mint request.
*
* @jest-environment node
*/
import {
buildBip21Uri,
capMintAmount,
mintableAmount,
onchainTopupFloor,
MINIBITS_ONCHAIN_FLOOR_SAT,
} from '../src/services/wallet/operations/onchainAmounts'
describe('mintableAmount', () => {
it('is what the mint has credited but not yet issued', () => {
expect(mintableAmount(50000, 0)).toBe(50000)
})
it('is zero for a fully drained quote', () => {
expect(mintableAmount(50000, 50000)).toBe(0)
})
it('is the remainder for a partially drained quote', () => {
expect(mintableAmount(70000, 50000)).toBe(20000)
})
it('is zero before any deposit confirms', () => {
expect(mintableAmount(0, 0)).toBe(0)
})
it('clamps to zero if a mint ever reports issued > paid', () => {
// Should not happen. But an unclamped negative would flow into a mint
// request as a negative amount, so refuse it explicitly rather than by
// accident.
expect(mintableAmount(10000, 20000)).toBe(0)
})
})
describe('onchainTopupFloor', () => {
it('takes the wallet floor when the mint asks for less', () => {
// The CDK test mint really did advertise min_amount: 1 — below Bitcoin's own
// 546-sat dust limit. NUT-30 evaluates min_amount PER UTXO and says such a
// deposit is "not recoverable through the mint quote protocol", i.e. the money
// is gone. So the mint's number can never lower our bar.
expect(onchainTopupFloor('sat', 1)).toBe(MINIBITS_ONCHAIN_FLOOR_SAT)
})
it('takes the mint floor when it is higher than ours', () => {
expect(onchainTopupFloor('sat', 50000)).toBe(50000)
})
it('falls back to the wallet floor when the mint advertises none', () => {
expect(onchainTopupFloor('sat', null)).toBe(MINIBITS_ONCHAIN_FLOOR_SAT)
expect(onchainTopupFloor('sat', undefined)).toBe(MINIBITS_ONCHAIN_FLOOR_SAT)
expect(onchainTopupFloor('sat', 0)).toBe(MINIBITS_ONCHAIN_FLOOR_SAT)
})
it('defers to the mint for non-sat units (our floor is denominated in sats)', () => {
expect(onchainTopupFloor('usd', 500)).toBe(500)
expect(onchainTopupFloor('usd', null)).toBe(0)
})
})
describe('buildBip21Uri', () => {
it('encodes the amount in BTC, not sats', () => {
expect(buildBip21Uri('bc1qtest', 50000)).toBe('bitcoin:bc1qtest?amount=0.0005')
})
it('does not emit scientific notation for small amounts', () => {
// 1000 / 1e8 is 0.00001 in JS, but smaller values reach 1e-8 and stringify as
// exponent form, which is not a valid BIP21 amount.
expect(buildBip21Uri('bc1qtest', 1)).toBe('bitcoin:bc1qtest?amount=0.00000001')
expect(buildBip21Uri('bc1qtest', 10)).toBe('bitcoin:bc1qtest?amount=0.0000001')
})
it('does not leak float error into the amount', () => {
// naive (amountSat / 1e8).toString() gives 0.000010000000000000001 here
expect(buildBip21Uri('bc1qtest', 1000)).toBe('bitcoin:bc1qtest?amount=0.00001')
})
it('trims trailing zeros but keeps a whole-BTC amount valid', () => {
expect(buildBip21Uri('bc1qtest', 100_000_000)).toBe('bitcoin:bc1qtest?amount=1')
expect(buildBip21Uri('bc1qtest', 150_000_000)).toBe('bitcoin:bc1qtest?amount=1.5')
})
it('omits the amount entirely when there is none', () => {
// A bare address is still a valid BIP21 URI, and the amount is only a hint.
expect(buildBip21Uri('bc1qtest')).toBe('bitcoin:bc1qtest')
expect(buildBip21Uri('bc1qtest', 0)).toBe('bitcoin:bc1qtest')
})
})
describe('capMintAmount', () => {
it('leaves a mintable balance under the cap alone', () => {
expect(capMintAmount(50000, 500000)).toBe(50000)
})
it('caps a deposit larger than the mint will issue in one operation', () => {
// Asking for more than max_amount fails the whole request, which would
// strand a deposit we could take in instalments. The remainder stays
// credited on the quote and the next sweep collects it.
expect(capMintAmount(750000, 500000)).toBe(500000)
})
it('is a no-op when the mint advertises no maximum', () => {
expect(capMintAmount(50000, null)).toBe(50000)
expect(capMintAmount(50000, undefined)).toBe(50000)
})
it('ignores a nonsensical zero or negative cap', () => {
expect(capMintAmount(50000, 0)).toBe(50000)
expect(capMintAmount(50000, -1)).toBe(50000)
})
it('caps exactly at the boundary', () => {
expect(capMintAmount(500000, 500000)).toBe(500000)
})
})