Files
minibits_wallet/__tests__/transferMethods.test.ts
minibits-cash 183ef1cf56 Stage 6b: onchain melt UI — Pay screen, address parsing, fee picker
LightningPayScreen becomes PayScreen: it can no longer call itself Lightning
now that it also takes Bitcoin addresses. Paste, auto-paste and scan all route
through the same parser.

New OnchainTransferScreen rather than a third mode inside TransferScreen. The
lightning screen is built around a payment request that already carries its
amount and a quote it can fetch as soon as a mint is chosen; onchain has neither
(the user types the amount, and the quote returns a LIST of fee tiers). Folding
that in would have meant re-auditing every `!encodedInvoice &&` render gate on a
screen where a mistake breaks lightning payments. The SERVICE layer is still a
single shared TransferOperationApi - this is a screen, not a second
implementation.

Address handling (src/services/bitcoin/bitcoinUtils.ts):

- Checksums verified locally, so a mistyped address fails on the screen the user
  is looking at rather than one round-trip later. Witness version selects the
  checksum constant - v0 must be bech32, v1+ bech32m - and accepting either for
  both would let a corrupted address through whenever it satisfied the other.

- MAINNET ONLY, and this is the point rather than a detail. The CDK fakewallet
  hands out REGTEST deposit addresses for onchain topup quotes, so anyone testing
  this wallet has a bcrt1q... in their clipboard - the single most likely wrong
  thing to be pasted into Pay. It is decoded (so we can say "wrong network"
  rather than "unknown data") and then refused. Pinned in tests with the exact
  address the fakewallet produced.

- Unified QR: LIGHTNING WINS. A bitcoin: URI carrying a lightning= invoice
  offers both rails and lightning is cheaper and faster for everyone, so the
  invoice is taken and the address goes unused.

Fee tiers default to the MIDDLE one, rounding to the cheaper side: the user can
always choose to pay more, but a wallet must never round a fee up on their
behalf. The picker collapses to a read-only row when the mint returns one tier -
which is the common case, not the edge case. Tiers are sorted before selection
because fee_index is the mint's IDENTIFIER for a tier, not its rank.

PENDING is styled as the SUCCESS state here, not a warning: NUT-30 requires the
mint to answer PENDING and broadcast in the background, so every onchain payment
lands there. The money has left; only confirmation is outstanding. The
transaction detail surfaces the outpoint (txid:vout) once broadcast - the only
handle on the payment that does not depend on the mint - and offers a manual
status check, since the sweep only runs on the ~60s pending cadence.

87 tsc errors (unchanged baseline), 318/318 tests, i18n clean.
2026-07-14 18:05:28 +02:00

174 lines
6.8 KiB
TypeScript

/**
* The transfer-method resolver: the one place the two melt rails differ.
*
* `TransferOperationApi` reads everything rail-specific from here instead of branching
* on `method` at each site that needs a fee or an expiry, so this is where a mistake
* about a rail would actually live. Worth pinning:
*
* - where the fee reserve comes from (a field on the quote vs the SELECTED tier),
* - that an onchain transfer is never expirable,
* - that a fee_index the mint never offered is refused before it reaches the mint.
*
* @jest-environment node
*/
// Transaction.ts -> services -> logService -> @sentry/react-native (ESM, not transformed)
// and on into the MST store graph + op-sqlite. Mock the chain so the test stays pure,
// same as transactionStates.test.ts.
jest.mock('../src/services/logService', () => ({
log: {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
trace: jest.fn(),
warn: jest.fn(),
},
LogLevel: {ERROR: 'ERROR', WARN: 'WARN', INFO: 'INFO', DEBUG: 'DEBUG', TRACE: 'TRACE'},
}))
jest.mock('../src/services', () => ({
log: {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
trace: jest.fn(),
warn: jest.fn(),
},
Database: {},
}))
import {resolveTransferMethod} from '../src/services/wallet/operations/transferMethods'
import {TransactionType} from '../src/models/Transaction'
/** cashu-ts hands amounts over as Amount objects, not numbers. */
const amount = (n: number) => ({toNumber: () => n}) as any
const onchainQuote = (feeOptions: Array<[number, number, number]>) =>
({
quote: 'quote-onchain-1',
amount: amount(50000),
unit: 'sat',
state: 'UNPAID',
expiry: 1800000000,
request: 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4',
fee_options: feeOptions.map(([fee_index, fee_reserve, estimated_blocks]) => ({
fee_index,
fee_reserve: amount(fee_reserve),
estimated_blocks,
})),
selected_fee_index: null,
outpoint: null,
}) as any
describe('resolveTransferMethod — onchain', () => {
const QUOTE_EXPIRY = new Date('2027-01-01T00:00:00Z')
const ADDRESS = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'
const resolveOnchain = (feeIndex: number, feeOptions: Array<[number, number, number]>) =>
resolveTransferMethod({
method: 'onchain',
options: {
address: ADDRESS,
meltQuote: onchainQuote(feeOptions),
feeIndex,
quoteExpiry: QUOTE_EXPIRY,
},
})
it('records the onchain transaction type', () => {
const resolved = resolveOnchain(0, [[0, 400, 6]])
expect(resolved.transactionType).toBe(TransactionType.TRANSFER_ONCHAIN)
expect(resolved.method).toBe('onchain')
})
// An onchain quote has NO fee_reserve field — it has a list of tiers, and the reserve
// depends entirely on which one the user picked.
it('takes the fee reserve from the SELECTED tier', () => {
const tiers: Array<[number, number, number]> = [
[0, 400, 6],
[1, 900, 3],
[2, 2100, 1],
]
expect(resolveOnchain(0, tiers).feeReserve).toBe(400)
expect(resolveOnchain(1, tiers).feeReserve).toBe(900)
expect(resolveOnchain(2, tiers).feeReserve).toBe(2100)
})
// fee_index is the mint's identifier for a tier, not its position in the array.
it('selects by the mint\'s fee_index, not by array position', () => {
const tiers: Array<[number, number, number]> = [
[7, 2100, 1],
[3, 400, 6],
]
expect(resolveOnchain(3, tiers).feeReserve).toBe(400)
expect(resolveOnchain(7, tiers).feeReserve).toBe(2100)
})
// The mint MUST reject a fee_index it never offered, so catching it locally saves a
// round-trip and a quote that can then never be executed with a different index.
it('refuses a fee_index the mint never offered', () => {
expect(() => resolveOnchain(5, [[0, 400, 6]])).toThrow(/Fee index 5 was not offered/)
})
/**
* THE rule that keeps a real payment from being declared dead.
*
* A melt quote's expiry bounds EXECUTING the quote, not CONFIRMING the payment. Once
* the mint has broadcast, the transaction confirms on the chain's schedule and can
* easily outlive the quote it came from. If anything ever expires a pending onchain
* transfer on the strength of this date, it marks an irreversible, in-flight payment
* as failed and hides it from the user.
*/
it('is never expirable once pending', () => {
expect(resolveOnchain(0, [[0, 400, 6]]).expiresPendingTransfer).toBe(false)
})
it('carries the address as the payment request and has no payment id', () => {
const resolved = resolveOnchain(0, [[0, 400, 6]])
expect(resolved.paymentRequest).toBe(ADDRESS)
// Onchain has no preimage-style identifier. It gets an `outpoint` once broadcast.
expect(resolved.paymentId).toBeUndefined()
})
it('passes the quote id and expiry through', () => {
const resolved = resolveOnchain(0, [[0, 400, 6]])
expect(resolved.quoteId).toBe('quote-onchain-1')
expect(resolved.expiry).toBe(QUOTE_EXPIRY)
})
})
describe('resolveTransferMethod — bolt11', () => {
// BOLT11 spec test vector (2500 µBTC). Must actually decode — resolveTransferMethod
// derives the payment hash from it.
const INVOICE =
'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9srp'
const invoiceExpiry = new Date('2027-01-01T00:00:00Z')
const bolt11Quote = {
quote: 'quote-bolt11-1',
amount: amount(1000),
unit: 'sat',
state: 'UNPAID',
expiry: 1800000000,
request: INVOICE,
fee_reserve: amount(12),
payment_preimage: null,
} as any
it('takes the fee reserve straight off the quote and stays expirable', () => {
const resolved = resolveTransferMethod({
method: 'bolt11',
options: {encodedInvoice: INVOICE, meltQuote: bolt11Quote, invoiceExpiry},
})
expect(resolved.method).toBe('bolt11')
expect(resolved.transactionType).toBe(TransactionType.TRANSFER)
expect(resolved.feeReserve).toBe(12)
expect(resolved.paymentRequest).toBe(INVOICE)
// An expired invoice genuinely cannot be paid, so there IS nothing left to wait for.
expect(resolved.expiresPendingTransfer).toBe(true)
// bolt11 settles with a preimage, and the payment hash identifies it beforehand.
expect(typeof resolved.paymentId).toBe('string')
expect(resolved.paymentId).toHaveLength(64)
})
})