mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-08-09 08:14:44 +00:00
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.
174 lines
6.8 KiB
TypeScript
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)
|
|
})
|
|
})
|