mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-07-22 07:48:28 +00:00
Polish onchain topup UI and conform it to the transaction lifecycle
First iteration of onchain minting is working end to end against a fakewallet
CDK mint (quotes signed, deposits detected, ecash issued). This is the cleanup
that came out of driving it on device.
Transaction lifecycle (onchainTopupOperationApi)
createQuote() went straight to PENDING, skipping DRAFT and PREPARED. It now
runs DRAFT -> PREPARED -> PENDING like every other operation.
DRAFT was a real gap, not a formality: if the mint refused the quote there was
no transaction record at all, and a NUT-20 index had already been burned with
nothing to explain the hole in the sequence. The row is now opened before the
mint is contacted and marked ERROR on failure, mirroring how topupTask handles
a failed bolt11 prepare/execute.
PREPARED is transient here — but it is transient for bolt11 too (topupTask
calls prepare() and execute() back to back), so it is a crash marker rather
than a state a user sits in. Onchain has no "arm the watcher" step to separate
it from PENDING, because the watcher is driven by the quote row: persisting the
quote IS the registration.
Waiting-for-a-deposit deliberately stays PENDING rather than becoming a
long-lived PREPARED, because pendingHistory filters on PENDING alone — anything
else drops the topup out of the pending list, which is exactly where a user
goes looking for it.
_findOrCreateTransaction now also reuses a PREPARED row. A crash between the
PREPARED and PENDING writes would otherwise have settled the deposit onto a
second transaction, leaving two rows for one deposit.
UI
TransactionListItem rendered no amount for either onchain type: amounts are
only drawn for explicitly enumerated types, and neither was in a list, so they
fell through to nothing. Both are now handled (topup positive, transfer
negative).
QRCodeBlock takes an optional `hints` prop: a question-mark button joins the
action row and opens the explanation in a bottom sheet. The onchain warnings
("the amount is only a suggestion", "ecash appears once the deposit confirms")
were in a card below the QR, which is below the fold on the tallest element of
the screen — so nobody read them. They are not decorative: without them an
underpayment reads as the wallet losing money.
TranDetailScreen gains a Payment request row in both topup cards (bolt11 and
onchain), one line with a middle ellipsis and a Copy button. Middle, not tail:
for an address or invoice the head and tail are what a user checks against a
block explorer, and the middle is noise.
Mint names in the selector truncate instead of wrapping, so rows keep an even
height. Passing an ellipsize mode is what makes ListItem apply numberOfLines=1.
Mint-selector buttons become short parallel nouns with rail icons (Invoice /
Address). The non-en locales still carried the long form, which would have
blown the button width out in those languages.
260/260 tests pass; typecheck introduces no new errors; 747/747 i18n keys.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -749,13 +749,16 @@
|
||||
"welcomeScreen_terms_agreePrivacy": "Privacy Policy",
|
||||
"mintSelector_noTopupSupport": "Top up not supported for this currency",
|
||||
"mintSelector_noPayoutSupport": "Payouts not supported for this currency",
|
||||
"topupScreen_onchainAddress": "Bitcoin address",
|
||||
"topupScreen_onchainAddress": "Address",
|
||||
"topupScreen_ligtningInvoice": "Invoice",
|
||||
"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.",
|
||||
"topupScreen_onchainAmountIsHint": "This is Bitcoin onchain address to be used to topup your wallet. 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. Number of confirmations depends on the mint.",
|
||||
"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"
|
||||
"transactionType_transferOnchain": "Onchain payment",
|
||||
"qr_hintHeading": "How does this work?",
|
||||
"tranDetailScreen_paymentRequest": "Payment request"
|
||||
}
|
||||
|
||||
@@ -748,7 +748,7 @@
|
||||
"welcomeScreen_terms_agreePrivacy": "Política de Privacidad",
|
||||
"mintSelector_noTopupSupport": "Recargas no soportadas para esta moneda",
|
||||
"mintSelector_noPayoutSupport": "Pagos no soportados para esta moneda",
|
||||
"topupScreen_onchainAddress": "Dirección Bitcoin",
|
||||
"topupScreen_onchainAddress": "Dirección",
|
||||
"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.",
|
||||
@@ -756,5 +756,8 @@
|
||||
"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"
|
||||
"transactionType_transferOnchain": "Pago onchain",
|
||||
"qr_hintHeading": "¿Cómo funciona esto?",
|
||||
"tranDetailScreen_paymentRequest": "Solicitud de pago",
|
||||
"topupScreen_ligtningInvoice": "Factura"
|
||||
}
|
||||
|
||||
@@ -749,7 +749,7 @@
|
||||
"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",
|
||||
"topupScreen_onchainAddress": "Endereço Bitcoin",
|
||||
"topupScreen_onchainAddress": "Endereço",
|
||||
"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.",
|
||||
@@ -757,5 +757,8 @@
|
||||
"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"
|
||||
"transactionType_transferOnchain": "Pagamento onchain",
|
||||
"qr_hintHeading": "Como é que isto funciona?",
|
||||
"tranDetailScreen_paymentRequest": "Pedido de pagamento",
|
||||
"topupScreen_ligtningInvoice": "Fatura"
|
||||
}
|
||||
|
||||
@@ -749,7 +749,7 @@
|
||||
"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é",
|
||||
"topupScreen_onchainAddress": "Bitcoin adresa",
|
||||
"topupScreen_onchainAddress": "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.",
|
||||
@@ -757,5 +757,8 @@
|
||||
"tranDetail_onchainNoDeposits": "Na tejto adrese zatiaľ neboli nájdené žiadne nové vklady.",
|
||||
"tranDetail_onchainOutpoint": "Bitcoin transakcia",
|
||||
"transactionType_topupOnchain": "Dobitie onchain",
|
||||
"transactionType_transferOnchain": "Onchain platba"
|
||||
"transactionType_transferOnchain": "Onchain platba",
|
||||
"qr_hintHeading": "Ako to funguje?",
|
||||
"tranDetailScreen_paymentRequest": "Platobná požiadavka",
|
||||
"topupScreen_ligtningInvoice": "Faktúra"
|
||||
}
|
||||
|
||||
@@ -55,9 +55,17 @@ export const MintListItem = observer(function(props: {
|
||||
<ListItem
|
||||
key={mint.mintUrl}
|
||||
text={mint.shortname}
|
||||
// Keep the name (and the line below it) to a single line, truncating with
|
||||
// an ellipsis — long mint names would otherwise wrap and push the row's
|
||||
// height around, which is very visible in a list of them. Passing an
|
||||
// ellipsize mode is what makes ListItem apply numberOfLines={1}; the
|
||||
// same pairing TransactionListItem uses.
|
||||
textStyle={$oneLine}
|
||||
textEllipsizeMode='tail'
|
||||
// when disabled, the reason replaces the hostname: it is the one thing
|
||||
// the user needs to read on this row
|
||||
subText={isDisabled && disabledReason ? disabledReason : mint.hostname}
|
||||
subTextEllipsizeMode='tail'
|
||||
leftIcon={isSelectable ? isSelected ? 'faCheckCircle' : 'faCircle' : undefined}
|
||||
leftIconColor={isSelected ? iconSelectedColor as string : iconColor as string}
|
||||
rightIcon={isBlocked ? 'faShieldHalved' : mint.status === MintStatus.OFFLINE ? 'faTriangleExclamation' : undefined}
|
||||
@@ -78,6 +86,11 @@ export const MintListItem = observer(function(props: {
|
||||
/>
|
||||
)})
|
||||
|
||||
/** Truncate rather than wrap, so every row keeps the same height. */
|
||||
const $oneLine: TextStyle = {
|
||||
overflow: 'hidden',
|
||||
}
|
||||
|
||||
/** Dim an unusable mint without hiding it. */
|
||||
const $disabled: ViewStyle = {
|
||||
opacity: 0.45,
|
||||
|
||||
@@ -1029,7 +1029,8 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
|
||||
selectedMintBalance={mintBalanceToTopup as MintBalance}
|
||||
unit={unitRef.current}
|
||||
title={translate("topup_mint")}
|
||||
confirmTitle={translate("commonConfirmCreateInvoice")}
|
||||
confirmIcon='faBolt'
|
||||
confirmTitle={translate("topupScreen_ligtningInvoice")}
|
||||
// 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
|
||||
@@ -1073,38 +1074,27 @@ 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}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
/*
|
||||
The amount is the one thing a user is likely to get wrong here. It rides
|
||||
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 and the balance settles to whatever arrives, so this
|
||||
has to be said, or an underpayment reads as a bug.
|
||||
|
||||
It lives behind the QR's help button rather than in a card below it: the
|
||||
QR is the tallest thing on this screen and anything under it fell below
|
||||
the fold, which is exactly why nobody saw this warning.
|
||||
*/
|
||||
<QRCodeBlock
|
||||
qrCodeData={onchainUriToPay}
|
||||
titleTx='topupScreen_onchainAddressToPay'
|
||||
type='BitcoinAddress'
|
||||
size={270}
|
||||
hints={[
|
||||
'topupScreen_onchainAmountIsHint',
|
||||
'topupScreen_onchainConfirmationsNeeded',
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{transaction && transactionStatus === TransactionStatus.COMPLETED && (
|
||||
<Card
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import Clipboard from '@react-native-clipboard/clipboard'
|
||||
import {infoMessage} from '../utils/utils'
|
||||
import JSONTree from 'react-native-json-tree'
|
||||
import Animated, {
|
||||
Extrapolation,
|
||||
@@ -1373,6 +1374,43 @@ const SendInfoBlock = function (props: {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The request the payer was given — a bolt11 invoice for a lightning topup, a
|
||||
* Bitcoin address for an onchain one.
|
||||
*
|
||||
* Kept to one line with a MIDDLE ellipsis rather than a tail one: for an address or
|
||||
* an invoice, the head and tail are the parts worth seeing (they are what a user
|
||||
* eyeballs against a block explorer or a sender's screen), while the middle is
|
||||
* noise. Truncating the tail would hide the half that actually distinguishes it.
|
||||
*/
|
||||
const PaymentRequestItem = function (props: {transaction: Transaction}) {
|
||||
const {transaction} = props
|
||||
|
||||
if (!transaction.paymentRequest) return null
|
||||
|
||||
const onCopy = function () {
|
||||
try {
|
||||
Clipboard.setString(transaction.paymentRequest as string)
|
||||
} catch (e: any) {
|
||||
infoMessage(translate('commonCopyFailParam', {param: e.message}))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
tx='tranDetailScreen_paymentRequest'
|
||||
subText={transaction.paymentRequest}
|
||||
subTextEllipsizeMode='middle'
|
||||
topSeparator={true}
|
||||
RightComponent={
|
||||
<View style={$rightContainer}>
|
||||
<Button preset='secondary' onPress={onCopy} tx='commonCopy' />
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail block for an onchain (NUT-30) topup.
|
||||
*
|
||||
@@ -1504,11 +1542,14 @@ const OnchainTopupInfoBlock = function (props: {
|
||||
labelTx='tranDetailScreen_topupTo'
|
||||
style={$dataCard}
|
||||
ContentComponent={
|
||||
mint ? (
|
||||
<MintListItem mint={mint} isSelectable={false} isUnitVisible={false} />
|
||||
) : (
|
||||
<Text text={transaction.mint} />
|
||||
)
|
||||
<>
|
||||
{mint ? (
|
||||
<MintListItem mint={mint} isSelectable={false} isUnitVisible={false} />
|
||||
) : (
|
||||
<Text text={transaction.mint} />
|
||||
)}
|
||||
<PaymentRequestItem transaction={transaction} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
@@ -1710,19 +1751,22 @@ const TopupInfoBlock = function (props: {
|
||||
labelTx='tranDetailScreen_topupTo'
|
||||
style={$dataCard}
|
||||
ContentComponent={
|
||||
mint ? (
|
||||
<MintListItem
|
||||
mint={mint}
|
||||
isSelectable={false}
|
||||
isUnitVisible={false}
|
||||
/>
|
||||
) : (
|
||||
<Text text={transaction.mint} />
|
||||
)
|
||||
<>
|
||||
{mint ? (
|
||||
<MintListItem
|
||||
mint={mint}
|
||||
isSelectable={false}
|
||||
isUnitVisible={false}
|
||||
/>
|
||||
) : (
|
||||
<Text text={transaction.mint} />
|
||||
)}
|
||||
<PaymentRequestItem transaction={transaction} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
/>
|
||||
<BottomModal
|
||||
isVisible={isResultModalVisible ? true : false}
|
||||
isVisible={isResultModalVisible ? true : false}
|
||||
ContentComponent={
|
||||
<>
|
||||
{(resultModalInfo?.status === TransactionStatus.COMPLETED) && (
|
||||
|
||||
@@ -271,7 +271,7 @@ export const TransactionListItem = observer(function (props: TransactionListProp
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{([TransactionType.TOPUP].includes(tx.type)) && (
|
||||
{([TransactionType.TOPUP, TransactionType.TOPUP_ONCHAIN].includes(tx.type)) && (
|
||||
<>
|
||||
{[TransactionStatus.PENDING, TransactionStatus.EXPIRED].includes(tx.status) && (
|
||||
<CurrencyAmount
|
||||
@@ -299,8 +299,8 @@ export const TransactionListItem = observer(function (props: TransactionListProp
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{([TransactionType.SEND, TransactionType.TRANSFER].includes(tx.type)) && (
|
||||
<CurrencyAmount
|
||||
{([TransactionType.SEND, TransactionType.TRANSFER, TransactionType.TRANSFER_ONCHAIN].includes(tx.type)) && (
|
||||
<CurrencyAmount
|
||||
amount={-1 * tx.amount}
|
||||
mintUnit={tx.unit}
|
||||
size='medium'
|
||||
@@ -308,7 +308,7 @@ export const TransactionListItem = observer(function (props: TransactionListProp
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
}
|
||||
topSeparator={props.isFirst ? false : true}
|
||||
style={$item}
|
||||
onPress={onPress}
|
||||
|
||||
@@ -4,11 +4,11 @@ import { HCESession, NFCTagType4NDEFContentType, NFCTagType4 } from 'react-nativ
|
||||
import NfcManager from 'react-native-nfc-manager'
|
||||
import { UR, UREncoder } from '@gandlaf21/bc-ur';
|
||||
import { infoMessage } from "../../utils/utils"
|
||||
import { Button, Card, Icon, ListItem } from "../../components"
|
||||
import { BottomModal, Button, Card, Icon, ListItem, Text } from "../../components"
|
||||
import QRCode from "react-native-qrcode-svg"
|
||||
import Clipboard from "@react-native-clipboard/clipboard"
|
||||
import { verticalScale } from "@gocodingnow/rn-size-matters"
|
||||
import { colors, spacing, ThemeCode } from "../../theme"
|
||||
import { colors, spacing, ThemeCode, useThemeColor } from "../../theme"
|
||||
import { translate, TxKeyPath } from "../../i18n"
|
||||
import { log } from "../../services"
|
||||
import { MMKVStorage } from "../../services/mmkvStorage"
|
||||
@@ -36,12 +36,27 @@ export const QRCodeBlock = function (props: {
|
||||
type: QRCodeBlockTypes
|
||||
size?: number
|
||||
startNfcOnLoad?: boolean
|
||||
/**
|
||||
* Explanatory paragraphs for this QR code. When present, a question-mark button
|
||||
* joins the action row below the code and opens them in a bottom sheet.
|
||||
*
|
||||
* The QR card sits on a white background and is usually the tallest thing on the
|
||||
* screen, so a caption placed under it tends to fall below the fold and simply
|
||||
* never gets read — which is how the onchain "the amount is only a hint" warning
|
||||
* went unseen. Putting the explanation behind a button in the action row keeps it
|
||||
* next to the thing it describes and always reachable.
|
||||
*/
|
||||
hints?: TxKeyPath[]
|
||||
/** Heading for the hint sheet. Defaults to a generic "How does this work?". */
|
||||
hintTitleTx?: TxKeyPath
|
||||
}
|
||||
) {
|
||||
|
||||
const { qrCodeData, title, titleTx, type, size, startNfcOnLoad = false } = props
|
||||
const { qrCodeData, title, titleTx, type, size, startNfcOnLoad = false, hints, hintTitleTx } = props
|
||||
const {mintsStore} = useStores()
|
||||
|
||||
|
||||
const [isHintVisible, setIsHintVisible] = useState(false)
|
||||
const hintTextColor = useThemeColor('textDim')
|
||||
const [qrError, setQrError] = useState<Error | undefined>()
|
||||
const [encodedV3Token, setEncodedV3Token] = useState<string | undefined>()
|
||||
const [decodedToken, setDecodedToken] = useState<Token>()
|
||||
@@ -290,6 +305,7 @@ export const QRCodeBlock = function (props: {
|
||||
: require('../../../android/app/src/main/res/mipmap-xhdpi/ic_launcher.png')
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
heading={title}
|
||||
headingTx={titleTx}
|
||||
@@ -374,8 +390,8 @@ export const QRCodeBlock = function (props: {
|
||||
preset="tertiary"
|
||||
onPress={isNfcEnabled ? toggleNFC : () => Alert.alert('Enable NFC in device settings')}
|
||||
LeftAccessory={() => (
|
||||
<PulsingContactlessIcon
|
||||
nfcBroadcast={nfcBroadcast}
|
||||
<PulsingContactlessIcon
|
||||
nfcBroadcast={nfcBroadcast}
|
||||
/>
|
||||
)}
|
||||
pressedStyle={{ backgroundColor: colors.light.buttonTertiaryPressed }}
|
||||
@@ -385,9 +401,43 @@ export const QRCodeBlock = function (props: {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{hints && hints.length > 0 && (
|
||||
<Button
|
||||
preset="tertiary"
|
||||
onPress={() => setIsHintVisible(true)}
|
||||
LeftAccessory={() => <Icon icon='faCircleQuestion' size={spacing.medium} color={colors.light.text} />}
|
||||
textStyle={{color: colors.light.text, fontSize: 14}}
|
||||
pressedStyle={{backgroundColor: colors.light.buttonTertiaryPressed}}
|
||||
style={{ minHeight: verticalScale(40), paddingVertical: verticalScale(spacing.tiny) }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
{/*
|
||||
Outside the Card on purpose: the card is forced white (it is a QR code
|
||||
background), so its contents are hardcoded to light colours. The sheet is a
|
||||
normal themed surface and must not inherit that.
|
||||
*/}
|
||||
<BottomModal
|
||||
isVisible={isHintVisible}
|
||||
headingTx={hintTitleTx ?? 'qr_hintHeading'}
|
||||
ContentComponent={
|
||||
<View style={{marginTop: spacing.small}}>
|
||||
{hints?.map(hint => (
|
||||
<Text
|
||||
key={hint}
|
||||
tx={hint}
|
||||
preset='formHelper'
|
||||
style={{marginBottom: spacing.small, color: hintTextColor}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
}
|
||||
onBackButtonPress={() => setIsHintVisible(false)}
|
||||
onBackdropPress={() => setIsHintVisible(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,14 +24,31 @@
|
||||
*
|
||||
* Lifecycle:
|
||||
*
|
||||
* createQuote() → PENDING transaction + persisted quote (address to show)
|
||||
* createQuote() → DRAFT → PREPARED → PENDING, plus the persisted quote whose
|
||||
* address the user is shown.
|
||||
* refreshQuote() → re-check the mint; if anything is credited but not yet
|
||||
* minted, mint it and settle a transaction. Called by the
|
||||
* watcher (onchainOperations) and by the user's manual
|
||||
* "check for deposits".
|
||||
* minted, mint it and settle the transaction to COMPLETED.
|
||||
* Called by the watcher (onchainOperations) and by the user's
|
||||
* manual "check for deposits".
|
||||
*
|
||||
* There is no PREPARED step and no cancel-to-reclaim: nothing local is locked
|
||||
* while we wait, because the "lock" is a mint-side address.
|
||||
* The states mean what they mean elsewhere in the wallet:
|
||||
*
|
||||
* DRAFT — row exists, mint not yet contacted. Carries the failure if the
|
||||
* quote request is refused, so a burned NUT-20 index is never left
|
||||
* unexplained.
|
||||
* PREPARED — the mint answered and the quote is persisted. Transient, exactly
|
||||
* as for bolt11 (topupTask calls prepare() and execute() back to
|
||||
* back): a crash marker, not a state a user sits in.
|
||||
* PENDING — the address is live and we are waiting to be paid. This has to be
|
||||
* PENDING and not PREPARED, because pendingHistory filters on
|
||||
* PENDING alone — anything else drops the topup out of the pending
|
||||
* list, which is precisely where the user looks for it.
|
||||
* COMPLETED — a deposit confirmed and was minted.
|
||||
*
|
||||
* Unlike bolt11 there is no "arm the watcher" step between PREPARED and PENDING:
|
||||
* the watcher is driven by the QUOTE row, so persisting the quote IS the
|
||||
* registration. And there is no cancel-to-reclaim — nothing local is locked while
|
||||
* we wait, because the "lock" is a mint-side address.
|
||||
*/
|
||||
import {log} from '../../logService'
|
||||
import {MintError, ValidationError} from '../../../utils/AppError'
|
||||
@@ -92,51 +109,22 @@ async function createQuote(input: {
|
||||
})
|
||||
}
|
||||
|
||||
// Burn a NUT-20 index and derive the quote-locking key. The index is committed
|
||||
// to the database before it is used, so a failure below can only SKIP an index,
|
||||
// never reuse one. The privkey is NOT persisted — only the index is, and the key
|
||||
// is re-derived from the seed when it is time to sign.
|
||||
const seed: Uint8Array = await walletStore.getCachedSeed()
|
||||
const {index: counterIndex, pubkey} = allocateQuoteKeypair(seed)
|
||||
|
||||
const quoteResponse = await walletStore.createOnchainMintQuote(mintUrl, unit, pubkey)
|
||||
const address = quoteResponse.request
|
||||
|
||||
// Persist the quote BEFORE showing the address. If the app dies between the two,
|
||||
// the row (with its counterIndex) is already safe, so a deposit to that address
|
||||
// stays mintable. Losing it would make the money unspendable.
|
||||
Database.addOnchainMintQuote({
|
||||
quote: quoteResponse.quote,
|
||||
mintUrl,
|
||||
unit,
|
||||
address,
|
||||
counterIndex,
|
||||
pubkey,
|
||||
amountRequested,
|
||||
expiry: quoteResponse.expiry ?? null,
|
||||
})
|
||||
|
||||
const persisted = Database.getOnchainMintQuote(quoteResponse.quote)
|
||||
if (!persisted) {
|
||||
throw new MintError('Onchain quote could not be persisted', {quote: quoteResponse.quote})
|
||||
}
|
||||
const watchUntil = new Date(persisted.watchUntil)
|
||||
|
||||
// ── DRAFT: a record BEFORE the mint is contacted ─────────────────────
|
||||
//
|
||||
// This exists so a failure below leaves a trace. Without it, a mint that refuses
|
||||
// the quote produced no transaction at all — and a NUT-20 index had already been
|
||||
// burned with nothing to explain the gap. Same reason bolt11's prepare() opens
|
||||
// with a DRAFT row.
|
||||
const transactionData: TransactionData[] = [
|
||||
{
|
||||
status: TransactionStatus.PENDING,
|
||||
status: TransactionStatus.DRAFT,
|
||||
amountToTopup: amountRequested,
|
||||
unit,
|
||||
quote: quoteResponse.quote,
|
||||
address,
|
||||
method: 'onchain',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]
|
||||
|
||||
// Straight to PENDING: unlike bolt11 there is no PREPARED step, because nothing
|
||||
// is reserved locally while we wait — the address IS the pending state. The
|
||||
// amount is the user's hint and will be overwritten by what actually arrives.
|
||||
const transaction = await transactionsStore.addTransaction({
|
||||
type: TransactionType.TOPUP_ONCHAIN,
|
||||
amount: amountRequested,
|
||||
@@ -145,32 +133,117 @@ async function createQuote(input: {
|
||||
data: JSON.stringify(transactionData),
|
||||
memo,
|
||||
mint: mintUrl,
|
||||
status: TransactionStatus.PENDING,
|
||||
status: TransactionStatus.DRAFT,
|
||||
})
|
||||
if (!transaction) {
|
||||
throw new ValidationError('Failed to create onchain topup transaction')
|
||||
}
|
||||
|
||||
transaction.update({
|
||||
quote: quoteResponse.quote,
|
||||
paymentRequest: address,
|
||||
expiresAt: watchUntil,
|
||||
})
|
||||
try {
|
||||
// Burn a NUT-20 index and derive the quote-locking key. The index is committed
|
||||
// to the database before it is used, so a failure below can only SKIP an index,
|
||||
// never reuse one. The privkey is NOT persisted — only the index is, and the key
|
||||
// is re-derived from the seed when it is time to sign.
|
||||
const seed: Uint8Array = await walletStore.getCachedSeed()
|
||||
const {index: counterIndex, pubkey} = allocateQuoteKeypair(seed)
|
||||
|
||||
log.debug('[OnchainTopupOperationApi.createQuote]', {
|
||||
transactionId: transaction.id,
|
||||
quote: quoteResponse.quote,
|
||||
address,
|
||||
amountRequested,
|
||||
})
|
||||
const quoteResponse = await walletStore.createOnchainMintQuote(mintUrl, unit, pubkey)
|
||||
const address = quoteResponse.request
|
||||
|
||||
return {
|
||||
transactionId: transaction.id,
|
||||
tx: transaction,
|
||||
address,
|
||||
quote: quoteResponse.quote,
|
||||
amountRequested,
|
||||
watchUntil,
|
||||
// Persist the quote BEFORE showing the address. If the app dies between the two,
|
||||
// the row (with its counterIndex) is already safe, so a deposit to that address
|
||||
// stays mintable. Losing it would make the money unspendable.
|
||||
Database.addOnchainMintQuote({
|
||||
quote: quoteResponse.quote,
|
||||
mintUrl,
|
||||
unit,
|
||||
address,
|
||||
counterIndex,
|
||||
pubkey,
|
||||
amountRequested,
|
||||
expiry: quoteResponse.expiry ?? null,
|
||||
})
|
||||
|
||||
const persisted = Database.getOnchainMintQuote(quoteResponse.quote)
|
||||
if (!persisted) {
|
||||
throw new MintError('Onchain quote could not be persisted', {
|
||||
quote: quoteResponse.quote,
|
||||
})
|
||||
}
|
||||
const watchUntil = new Date(persisted.watchUntil)
|
||||
|
||||
// ── PREPARED: the mint has answered and the quote is safely stored ───
|
||||
//
|
||||
// Transient, exactly as it is for bolt11 (topupTask calls prepare() and
|
||||
// execute() back to back). It is a crash marker, not a state a user sits in:
|
||||
// it says the address exists and is persisted, but the transaction is not yet
|
||||
// the one the watcher will settle onto.
|
||||
//
|
||||
// Onchain has no "arm the watcher" step to separate PREPARED from PENDING the
|
||||
// way bolt11 does — the watcher is driven by the quote row, so persisting the
|
||||
// quote IS the registration. The state is kept anyway so the lifecycle reads
|
||||
// the same across every operation, and so a crash here is legible.
|
||||
transactionData.push({
|
||||
status: TransactionStatus.PREPARED,
|
||||
quote: quoteResponse.quote,
|
||||
address,
|
||||
counterIndex,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
transaction.update({
|
||||
status: TransactionStatus.PREPARED,
|
||||
quote: quoteResponse.quote,
|
||||
paymentRequest: address,
|
||||
expiresAt: watchUntil,
|
||||
data: JSON.stringify(transactionData),
|
||||
})
|
||||
|
||||
// ── PENDING: the address is live and we are waiting to be paid ───────
|
||||
//
|
||||
// PENDING, not PREPARED, is what "waiting for a deposit" must be: pendingHistory
|
||||
// filters on PENDING alone, so anything else would drop this topup out of the
|
||||
// pending list — the exact place a user goes looking for it.
|
||||
transactionData.push({
|
||||
status: TransactionStatus.PENDING,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
transaction.update({
|
||||
status: TransactionStatus.PENDING,
|
||||
data: JSON.stringify(transactionData),
|
||||
})
|
||||
|
||||
log.debug('[OnchainTopupOperationApi.createQuote]', {
|
||||
transactionId: transaction.id,
|
||||
quote: quoteResponse.quote,
|
||||
address,
|
||||
amountRequested,
|
||||
})
|
||||
|
||||
return {
|
||||
transactionId: transaction.id,
|
||||
tx: transaction,
|
||||
address,
|
||||
quote: quoteResponse.quote,
|
||||
amountRequested,
|
||||
watchUntil,
|
||||
}
|
||||
} catch (e: any) {
|
||||
// Leave the failure on the record rather than dropping it. Mirrors how
|
||||
// topupTask marks a failed bolt11 prepare/execute.
|
||||
transactionData.push({
|
||||
status: TransactionStatus.ERROR,
|
||||
error: WalletUtils.formatError(e),
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
transaction.update({
|
||||
status: TransactionStatus.ERROR,
|
||||
data: JSON.stringify(transactionData),
|
||||
})
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,10 +438,15 @@ async function _findOrCreateTransaction(
|
||||
): Promise<Transaction> {
|
||||
const last = transactionsStore.findLastBy({quote: row.quote})
|
||||
|
||||
// PREPARED counts as reusable, not just PENDING. createQuote passes through it on
|
||||
// the way to PENDING, so a crash in that window leaves a PREPARED row for this
|
||||
// quote — and settling onto a NEW transaction instead would leave the user with two
|
||||
// rows for one deposit.
|
||||
if (
|
||||
last &&
|
||||
last.type === TransactionType.TOPUP_ONCHAIN &&
|
||||
last.status === TransactionStatus.PENDING
|
||||
(last.status === TransactionStatus.PENDING ||
|
||||
last.status === TransactionStatus.PREPARED)
|
||||
) {
|
||||
return last
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user