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>
This commit is contained in:
minibits-cash
2026-07-13 21:39:41 +02:00
parent ba38696bdc
commit eba3c92827
10 changed files with 451 additions and 14 deletions

View File

@@ -12,7 +12,13 @@
*
* @jest-environment node
*/
import {capMintAmount, mintableAmount} from '../src/services/wallet/operations/onchainAmounts'
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', () => {
@@ -39,6 +45,60 @@ describe('mintableAmount', () => {
})
})
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)

View File

@@ -748,5 +748,14 @@
"welcomeScreen_terms_agreeConjunction": "and",
"welcomeScreen_terms_agreePrivacy": "Privacy Policy",
"mintSelector_noTopupSupport": "Top up not supported for this currency",
"mintSelector_noPayoutSupport": "Payouts not supported for this currency"
"mintSelector_noPayoutSupport": "Payouts not supported for this currency",
"topupScreen_onchainAddress": "Bitcoin address",
"topupScreen_onchainAddressToPay": "Bitcoin address to pay",
"topupScreen_onchainAmountIsHint": "The amount is only a suggestion. Your balance updates with whatever actually arrives, whether more or less.",
"topupScreen_onchainConfirmationsNeeded": "Your ecash appears once the deposit confirms on the Bitcoin network.",
"tranDetail_checkForDeposits": "Check for deposits",
"tranDetail_onchainNoDeposits": "No new deposits found at this address yet.",
"tranDetail_onchainOutpoint": "Bitcoin transaction",
"transactionType_topupOnchain": "Topup onchain",
"transactionType_transferOnchain": "Onchain payment"
}

View File

@@ -747,5 +747,14 @@
"welcomeScreen_terms_agreeConjunction": "y la",
"welcomeScreen_terms_agreePrivacy": "Política de Privacidad",
"mintSelector_noTopupSupport": "Recargas no soportadas para esta moneda",
"mintSelector_noPayoutSupport": "Pagos no soportados para esta moneda"
"mintSelector_noPayoutSupport": "Pagos no soportados para esta moneda",
"topupScreen_onchainAddress": "Dirección Bitcoin",
"topupScreen_onchainAddressToPay": "Dirección Bitcoin a pagar",
"topupScreen_onchainAmountIsHint": "El importe es solo una sugerencia. Tu saldo se actualizará con lo que realmente llegue, sea más o menos.",
"topupScreen_onchainConfirmationsNeeded": "Tu ecash aparecerá cuando el depósito se confirme en la red Bitcoin.",
"tranDetail_checkForDeposits": "Buscar depósitos",
"tranDetail_onchainNoDeposits": "Aún no se encontraron nuevos depósitos en esta dirección.",
"tranDetail_onchainOutpoint": "Transacción Bitcoin",
"transactionType_topupOnchain": "Recarga onchain",
"transactionType_transferOnchain": "Pago onchain"
}

View File

@@ -748,5 +748,14 @@
"welcomeScreen_terms_agreeConjunction": "e a",
"welcomeScreen_terms_agreePrivacy": "Política de Privacidade",
"mintSelector_noTopupSupport": "Carregamentos não suportados para esta moeda",
"mintSelector_noPayoutSupport": "Pagamentos não suportados para esta moeda"
"mintSelector_noPayoutSupport": "Pagamentos não suportados para esta moeda",
"topupScreen_onchainAddress": "Endereço Bitcoin",
"topupScreen_onchainAddressToPay": "Endereço Bitcoin a pagar",
"topupScreen_onchainAmountIsHint": "O valor é apenas uma sugestão. O seu saldo será atualizado com o que realmente chegar, seja mais ou menos.",
"topupScreen_onchainConfirmationsNeeded": "O seu ecash aparecerá assim que o depósito for confirmado na rede Bitcoin.",
"tranDetail_checkForDeposits": "Procurar depósitos",
"tranDetail_onchainNoDeposits": "Ainda não foram encontrados novos depósitos neste endereço.",
"tranDetail_onchainOutpoint": "Transação Bitcoin",
"transactionType_topupOnchain": "Carregamento onchain",
"transactionType_transferOnchain": "Pagamento onchain"
}

View File

@@ -748,5 +748,14 @@
"welcomeScreen_terms_agreeConjunction": "a",
"welcomeScreen_terms_agreePrivacy": "Zásadami ochrany osobných údajov",
"mintSelector_noTopupSupport": "Dobitie nie je pre túto menu podporované",
"mintSelector_noPayoutSupport": "Výplaty nie sú pre túto menu podporované"
"mintSelector_noPayoutSupport": "Výplaty nie sú pre túto menu podporované",
"topupScreen_onchainAddress": "Bitcoin adresa",
"topupScreen_onchainAddressToPay": "Bitcoin adresa na zaplatenie",
"topupScreen_onchainAmountIsHint": "Suma je len návrh. Váš zostatok sa aktualizuje podľa toho, čo skutočne dorazí — viac alebo menej.",
"topupScreen_onchainConfirmationsNeeded": "Váš ecash sa objaví, keď sa vklad potvrdí v sieti Bitcoin.",
"tranDetail_checkForDeposits": "Skontrolovať vklady",
"tranDetail_onchainNoDeposits": "Na tejto adrese zatiaľ neboli nájdené žiadne nové vklady.",
"tranDetail_onchainOutpoint": "Bitcoin transakcia",
"transactionType_topupOnchain": "Dobitie onchain",
"transactionType_transferOnchain": "Onchain platba"
}

View File

@@ -62,6 +62,8 @@ import {
import {MintHeader} from './Mints/MintHeader'
import useIsInternetReachable from '../utils/useIsInternetReachable'
import {MintBalanceSelector} from './Mints/MintBalanceSelector'
import {OnchainTopupOperationApi} from '../services/wallet/operations/onchainTopupOperationApi'
import {buildBip21Uri, onchainTopupFloor} from '../services/wallet/operations/onchainAmounts'
import {QRCodeBlock} from './Wallet/QRCode'
import numbro from 'numbro'
import {TranItem} from './TranDetailScreen'
@@ -91,6 +93,11 @@ type TopupState = {
transactionId: number | undefined
transaction: Transaction | undefined
invoiceToPay: string
/**
* BIP21 URI for an onchain topup (`bitcoin:<addr>?amount=`). Mutually exclusive
* with `invoiceToPay` — a topup goes down one rail or the other.
*/
onchainUriToPay: string
lnurlWithdrawResult: LnurlWithdrawResult | undefined
resultModalInfo: { status: TransactionStatus; title?: string; message: string } | undefined
isLoading: boolean
@@ -115,6 +122,7 @@ type TopupAction =
| { type: 'HIDE_MINT_SELECTOR' }
| { type: 'TOPUP_START' }
| { type: 'INVOICE_READY'; transactionId: number; transactionStatus: TransactionStatus; encodedInvoice: string }
| { type: 'ONCHAIN_QUOTE_READY'; transactionId: number; onchainUri: string }
| { type: 'TOPUP_FAILED'; transactionStatus: TransactionStatus; resultModalInfo: { status: TransactionStatus; title?: string; message: string } }
| { type: 'TOPUP_COMPLETE'; transaction: Transaction; resultModalInfo: { status: TransactionStatus; message: string } }
| { type: 'DM_SENDING' }
@@ -141,6 +149,7 @@ const INITIAL_STATE: TopupState = {
transactionId: undefined,
transaction: undefined,
invoiceToPay: '',
onchainUriToPay: '',
lnurlWithdrawResult: undefined,
resultModalInfo: undefined,
isLoading: false,
@@ -207,6 +216,21 @@ function topupReducer(state: TopupState, action: TopupAction): TopupState {
isWithdrawModalVisible: state.paymentOption === ReceiveOption.LNURL_WITHDRAW,
}
case 'ONCHAIN_QUOTE_READY':
// Straight to PENDING: the address IS the pending state. Unlike an
// invoice there is nothing to expire on the mint's side, and no
// contact/LNURL flows apply — an onchain deposit is paid by whoever
// holds the address.
return {
...state,
isLoading: false,
transactionId: action.transactionId,
transactionStatus: TransactionStatus.PENDING,
onchainUriToPay: action.onchainUri,
invoiceToPay: '',
isMintSelectorVisible: false,
}
case 'TOPUP_FAILED':
return {
...state,
@@ -320,6 +344,7 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
transactionId,
transaction,
invoiceToPay,
onchainUriToPay,
lnurlWithdrawResult,
resultModalInfo,
isLoading,
@@ -591,6 +616,64 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
dispatch({ type: 'SET_MINT_BALANCE', balance })
}
/** The requested amount, in the unit's smallest denomination. */
const amountToTopupInt = () =>
round(toNumber(amountToTopup) * getCurrency(unitRef.current).precision, 0)
/**
* Whether to offer an onchain address for the currently selected mint.
*
* Gated on BOTH the mint advertising onchain for this unit AND the amount
* clearing the floor — see onchainTopupFloor for why we do not simply trust the
* mint's own min_amount. Below the floor the option is not shown at all, rather
* than shown-and-rejected: a sub-minimum deposit is credited to nobody and is
* unrecoverable through the quote protocol.
*/
const selectedMint = mintBalanceToTopup
? mintsStore.findByUrl(mintBalanceToTopup.mintUrl)
: undefined
const isOnchainTopupAvailable = (() => {
if (!selectedMint?.supportsMint!('onchain', unitRef.current)) return false
const floor = onchainTopupFloor(
unitRef.current,
selectedMint.mintMethodSetting!('onchain', unitRef.current)?.min_amount as number | null,
)
return amountToTopupInt() >= floor
})()
/**
* Ask the mint for an onchain deposit address instead of an invoice.
*
* No queue task and no awaitable: unlike a bolt11 topup there is nothing to wait
* for here — the mint hands back an address immediately, and the deposit is
* picked up later by the watcher (OnchainOperationService, via performChecks).
*/
const onMintBalanceConfirmOnchain = async function () {
if (!mintBalanceToTopup) return
try {
dispatch({ type: 'TOPUP_START' })
const created = await OnchainTopupOperationApi.createQuote({
mintUrl: mintBalanceToTopup.mintUrl,
unit: unitRef.current,
amountRequested: amountToTopupInt(),
memo,
})
dispatch({
type: 'ONCHAIN_QUOTE_READY',
transactionId: created.transactionId,
onchainUri: buildBip21Uri(created.address, created.amountRequested),
})
} catch (e: any) {
handleError(e)
}
}
const onMintBalanceConfirm = async function () {
if (!mintBalanceToTopup) {
return
@@ -917,10 +1000,23 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
unit={unitRef.current}
title={translate("topup_mint")}
confirmTitle={translate("commonConfirmCreateInvoice")}
// A mint that cannot mint in this unit by any method it advertises
// cannot top up at all — list it, but inert. Once onchain lands, a
// mint supporting either rail qualifies.
requiredCapability={mint => mint.supportsMint!('bolt11', unitRef.current)}
// The onchain option only appears for a mint that advertises it AND an
// amount above the floor. Below the floor it is hidden rather than
// rejected: a sub-minimum onchain deposit is credited to nobody and is
// unrecoverable through the quote protocol.
secondaryConfirmTitle={
isOnchainTopupAvailable ? translate('topupScreen_onchainAddress') : undefined
}
secondaryConfirmIcon={isOnchainTopupAvailable ? 'faBitcoin' : undefined}
onSecondaryMintBalanceSelect={
isOnchainTopupAvailable ? onMintBalanceConfirmOnchain : undefined
}
// A mint that can serve NEITHER rail for this unit cannot top up at all
// — list it, but inert.
requiredCapability={mint =>
mint.supportsMint!('bolt11', unitRef.current) ||
mint.supportsMint!('onchain', unitRef.current)
}
unsupportedReason={translate('mintSelector_noTopupSupport')}
onMintBalanceSelect={onMintBalanceSelect}
onCancel={onMintBalanceCancel}
@@ -934,7 +1030,7 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
<QRCodeBlock
qrCodeData={invoiceToPay as string}
titleTx='topupScreen_invoiceToPay'
type='Bolt11Invoice'
type='Bolt11Invoice'
size={270}
/>
<InvoiceOptionsBlock
@@ -946,6 +1042,40 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
/>
</>
)}
{transactionStatus === TransactionStatus.PENDING && onchainUriToPay && (
<>
<QRCodeBlock
qrCodeData={onchainUriToPay}
titleTx='topupScreen_onchainAddressToPay'
type='BitcoinAddress'
size={270}
/>
{/*
The amount is the one thing a user is likely to get wrong here. It is
carried in the BIP21 URI, so a scanning wallet pre-fills it — but a
sender typing the address by hand, or editing the amount, can pay
anything. Under- and overpayment both work; the balance settles to
what actually arrives. Say so, rather than letting it look broken.
*/}
<Card
style={{marginTop: spacing.small, padding: spacing.medium}}
ContentComponent={
<>
<Text
tx='topupScreen_onchainAmountIsHint'
preset='formHelper'
style={{color: placeholderTextColor}}
/>
<Text
tx='topupScreen_onchainConfirmationsNeeded'
preset='formHelper'
style={{color: placeholderTextColor, marginTop: spacing.extraSmall}}
/>
</>
}
/>
</>
)}
{transaction && transactionStatus === TransactionStatus.COMPLETED && (
<Card
style={{padding: spacing.medium}}

View File

@@ -60,6 +60,9 @@ import { MintUnit, formatCurrency, getCurrency } from "../services/wallet/curren
import { pollerExists } from '../utils/poller'
import { CommonActions, StaticScreenProps, useFocusEffect, useNavigation } from '@react-navigation/native'
import { QRCodeBlock } from './Wallet/QRCode'
import { Database } from '../services'
import { OnchainTopupOperationApi } from '../services/wallet/operations/onchainTopupOperationApi'
import { buildBip21Uri } from '../services/wallet/operations/onchainAmounts'
import { MintListItem } from './Mints/MintListItem'
import { Token, TokenMetadata, getDecodedToken, getTokenMetadata } from '@cashu/cashu-ts'
import FastImage from 'react-native-fast-image'
@@ -230,8 +233,12 @@ export const TranDetailScreen = observer(function TranDetailScreen({ route }: Pr
return `-${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
case TransactionType.TOPUP:
return `+${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
case TransactionType.TOPUP_ONCHAIN:
return `+${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
case TransactionType.TRANSFER:
return `-${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
case TransactionType.TRANSFER_ONCHAIN:
return `-${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
default:
return `${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
}
@@ -386,6 +393,13 @@ export const TranDetailScreen = observer(function TranDetailScreen({ route }: Pr
navigation={navigation}
/>
)}
{transaction.type === TransactionType.TOPUP_ONCHAIN && (
<OnchainTopupInfoBlock
transaction={transaction}
mint={mint}
navigation={navigation}
/>
)}
{transaction.type === TransactionType.TRANSFER && (
<TransferInfoBlock
transaction={transaction}
@@ -1359,6 +1373,140 @@ const SendInfoBlock = function (props: {
)
}
/**
* Detail block for an onchain (NUT-30) topup.
*
* Separate from TopupInfoBlock because the two behave differently in the one way
* that matters here: a bolt11 invoice is single-use and expires, but an onchain
* deposit address NEVER expires (the mint returns `expiry: null`). Money sent to it
* long after the wallet stopped watching is still credited and still mintable — the
* wallet simply is not looking any more.
*
* So this block offers "check for deposits": it reopens the watch window and
* re-checks the quote immediately. That is the recovery path for anything the
* watcher's 7-day window missed, and it is the reason quote rows (and their NUT-20
* counterIndex) are kept forever rather than deleted on completion.
*/
const OnchainTopupInfoBlock = function (props: {
transaction: Transaction
mint?: Mint
navigation: any
}) {
const {transaction, mint} = props
const isInternetReachable = useIsInternetReachable()
const [isChecking, setIsChecking] = useState(false)
const [checkResult, setCheckResult] = useState<string | undefined>()
const labelColor = useThemeColor('textDim')
const onCheckForDeposits = async function () {
if (!isInternetReachable || !transaction.quote) return
setIsChecking(true)
setCheckResult(undefined)
try {
// Reopen the window first: the quote may well have been archived (that is
// usually WHY the user is here), and refreshQuote alone would not put it
// back into the watcher's set for the next deposit.
Database.extendOnchainMintQuoteWatch(transaction.quote)
const result = await OnchainTopupOperationApi.refreshQuote(transaction.quote)
if (result.minted <= 0) {
setCheckResult(translate('tranDetail_onchainNoDeposits'))
}
// A successful mint settles the transaction; the observer re-renders it.
} catch (e: any) {
setCheckResult(e.message)
} finally {
setIsChecking(false)
}
}
return (
<>
<Card
label='Transaction data'
style={$dataCard}
ContentComponent={
<>
<TranItem
label="tranDetailScreen_amount"
value={transaction.amount}
unit={transaction.unit}
isCurrency={true}
isFirst={true}
/>
{transaction.memo && transaction.memo.length > 0 && (
<TranItem label="receiverMemo" value={transaction.memo as string} />
)}
<TranItem
label="tranDetailScreen_type"
value={transaction.type as string}
/>
<View style={{flexDirection: 'row', justifyContent: 'space-between'}}>
<TranItem
label="tranDetailScreen_status"
value={transaction.status as string}
/>
{isInternetReachable && transaction.quote && (
<Button
style={{marginTop: spacing.medium}}
preset="secondary"
tx="tranDetail_checkForDeposits"
onPress={onCheckForDeposits}
disabled={isChecking}
/>
)}
</View>
{checkResult && (
<Text
text={checkResult}
preset="formHelper"
style={{color: labelColor}}
/>
)}
{transaction.status === TransactionStatus.COMPLETED && (
<TranItem
label="tranDetailScreen_balanceAfter"
value={transaction.balanceAfter || 0}
unit={transaction.unit}
isCurrency={true}
/>
)}
<TranItem
label="tranDetailScreen_createdAt"
value={(transaction.createdAt as Date).toLocaleString()}
/>
<TranItem label="tranDetailScreen_id" value={`${transaction.id}`} />
</>
}
/>
{transaction.status === TransactionStatus.PENDING && transaction.paymentRequest && (
<View style={{marginBottom: spacing.small}}>
<QRCodeBlock
qrCodeData={buildBip21Uri(transaction.paymentRequest, transaction.amount)}
title={translate('topupScreen_onchainAddressToPay')}
type='BitcoinAddress'
size={spacing.screenWidth * 0.8}
/>
</View>
)}
<Card
labelTx='tranDetailScreen_topupTo'
style={$dataCard}
ContentComponent={
mint ? (
<MintListItem mint={mint} isSelectable={false} isUnitVisible={false} />
) : (
<Text text={transaction.mint} />
)
}
/>
</>
)
}
const TopupInfoBlock = function (props: {
transaction: Transaction
isDataParsable: boolean

View File

@@ -94,10 +94,16 @@ export const TransactionListItem = observer(function (props: TransactionListProp
return (tx.memo && tx.memo !== 'LNbits'
? tx.memo
: tx.sentTo
? tx.status === TransactionStatus.COMPLETED ?
? tx.status === TransactionStatus.COMPLETED ?
translate('transactionCommon_paidTo', {receiver: getProfileName(tx.sentTo)})
: translate('transactionCommon_payTo', {receiver: getProfileName(tx.sentTo)})
: translate('transactionCommon_youPaid'))
: translate('transactionCommon_youPaid'))
// Onchain has no contact/profile to name — the counterparty is an address,
// and for a topup it may not even be known (anyone can pay it).
case TransactionType.TOPUP_ONCHAIN:
return tx.memo ? tx.memo : translate('transactionType_topupOnchain')
case TransactionType.TRANSFER_ONCHAIN:
return tx.memo ? tx.memo : translate('transactionType_transferOnchain')
default:
return translate('transactionCommon_unknown')
}
@@ -167,7 +173,7 @@ export const TransactionListItem = observer(function (props: TransactionListProp
return (<Icon containerStyle={$txIconContainer} icon="faClock" size={spacing.medium} color={txErrorColor}/>)
}
if([TransactionType.RECEIVE, TransactionType.TOPUP, TransactionType.RECEIVE_BY_PAYMENT_REQUEST].includes(tx.type)) {
if([TransactionType.RECEIVE, TransactionType.TOPUP, TransactionType.TOPUP_ONCHAIN, TransactionType.RECEIVE_BY_PAYMENT_REQUEST].includes(tx.type)) {
if(tx.profile) {
const profilePicture = getProfilePicture(tx.profile)
if(profilePicture) {

View File

@@ -21,7 +21,10 @@ import { useStores } from '../../models';
import AppError, { Err } from '../../utils/AppError';
export type QRCodeBlockTypes = 'EncodedV3Token' | 'EncodedV4Token' | 'Bolt11Invoice' | 'URL' | 'NWC' | 'PUBKEY' | 'PaymentRequest'
// 'BitcoinAddress' carries a BIP21 URI (bitcoin:<addr>?amount=...), not a bare
// address, so a scanning wallet pre-fills the amount. Deliberately left out of the
// NFC-safe list below: onchain deposits are not a tap-to-pay flow.
export type QRCodeBlockTypes = 'EncodedV3Token' | 'EncodedV4Token' | 'Bolt11Invoice' | 'URL' | 'NWC' | 'PUBKEY' | 'PaymentRequest' | 'BitcoinAddress'
const ANIMATED_QR_FRAGMENT_LENGTH = 150
const ANIMATED_QR_INTERVAL = 250

View File

@@ -28,3 +28,57 @@ export const mintableAmount = (amountPaid: number, amountIssued: number): number
*/
export const capMintAmount = (mintable: number, maxAmount?: number | null): number =>
maxAmount && maxAmount > 0 ? Math.min(mintable, Number(maxAmount)) : mintable
/**
* Minibits' own minimum for offering an onchain topup, in sats.
*
* NUT-30 has a `min_amount`, but we cannot rely on it. It is evaluated PER UTXO by
* the mint — a deposit below it never increases `amount_paid`, cannot be aggregated
* with others to clear the bar, and per the spec is "not recoverable through the
* mint quote protocol". In other words, dust sent to a quote address is simply
* gone.
*
* And a mint's advertised value cannot be trusted to protect anyone: the CDK test
* mint advertised `min_amount: 1` — below Bitcoin's own 546-sat dust limit, and far
* below the fee needed to spend such an output. We watched it be wrong in two
* independent ways (frozen in its database, then inherited from the wrong config
* section). So the wallet keeps a floor of its own and takes whichever is higher.
*/
export const MINIBITS_ONCHAIN_FLOOR_SAT = 10000
/**
* The smallest amount for which onchain topup is worth offering.
*
* `max(our floor, the mint's minimum)`. The floor is denominated in sats, so it is
* only applied to sat-denominated topups; for any other unit we defer to the mint
* (in practice mints only advertise onchain for sat, and the capability check
* already hides it elsewhere).
*/
export const onchainTopupFloor = (
unit: string,
mintMinAmount?: number | null,
): number => {
const mintFloor = mintMinAmount && mintMinAmount > 0 ? Number(mintMinAmount) : 0
if (unit !== 'sat') return mintFloor
return Math.max(MINIBITS_ONCHAIN_FLOOR_SAT, mintFloor)
}
/**
* BIP21 payment URI: `bitcoin:<address>?amount=<btc>`.
*
* The amount is a HINT — the sender's wallet pre-fills it, and the sender is free
* to change or ignore it. NUT-30 does not price the quote, so under- and
* overpayment are both normal and handled downstream.
*
* BIP21 amounts are in BTC, not sats, so this converts. Formatted with toFixed(8)
* and trimmed rather than by dividing and stringifying, because `1e-8` and
* `0.000010000000000000001` are both things JS will happily hand you for small
* satoshi values, and neither is a valid BIP21 amount.
*/
export const buildBip21Uri = (address: string, amountSat?: number): string => {
if (!amountSat || amountSat <= 0) return `bitcoin:${address}`
const btc = (amountSat / 100_000_000).toFixed(8).replace(/0+$/, '').replace(/\.$/, '')
return `bitcoin:${address}?amount=${btc}`
}