mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
1318 lines
46 KiB
TypeScript
1318 lines
46 KiB
TypeScript
import {observer} from 'mobx-react-lite'
|
|
import React, {FC, useEffect, useState, useReducer, useCallback, useRef} from 'react'
|
|
import {StackActions, StaticScreenProps, useFocusEffect, useNavigation} from '@react-navigation/native'
|
|
import {
|
|
UIManager,
|
|
Platform,
|
|
TextStyle,
|
|
View,
|
|
ViewStyle,
|
|
FlatList,
|
|
TextInput,
|
|
Keyboard,
|
|
} from 'react-native'
|
|
import {spacing, useThemeColor, colors, typography} from '../theme'
|
|
import {
|
|
Button,
|
|
Icon,
|
|
Card,
|
|
Screen,
|
|
Loading,
|
|
InfoModal,
|
|
ErrorModal,
|
|
ListItem,
|
|
BottomModal,
|
|
Text,
|
|
AmountInput,
|
|
} from '../components'
|
|
import {Transaction, TransactionStatus} from '../models/Transaction'
|
|
import {useStores} from '../models'
|
|
import {TransactionTaskResult, WalletTask} from '../services'
|
|
import {log} from '../services/logService'
|
|
import AppError, {Err} from '../utils/AppError'
|
|
import {MintBalance} from '../models/Mint'
|
|
import {ResultModalInfo} from './Wallet/ResultModalInfo'
|
|
import {addSeconds} from 'date-fns'
|
|
import { DecodedLightningInvoice, LightningUtils } from '../services/lightning/lightningUtils'
|
|
import { round, roundDown, roundUp, toNumber } from '../utils/number'
|
|
import { LnurlClient, LNURLPayParams } from '../services/lnurlService'
|
|
import { CurrencyCode, MintUnit, convertToFromSats, getCurrency } from "../services/wallet/currency"
|
|
import { FeeBadge } from './Wallet/FeeBadge'
|
|
import { MeltQuoteBolt11Response } from '@cashu/cashu-ts'
|
|
import EventEmitter from '../utils/eventEmitter'
|
|
import { MintHeader } from './Mints/MintHeader'
|
|
import { MintBalanceSelector } from './Mints/MintBalanceSelector'
|
|
import numbro from 'numbro'
|
|
import { TranItem } from './TranDetailScreen'
|
|
import useIsInternetReachable from '../utils/useIsInternetReachable'
|
|
import { translate } from '../i18n'
|
|
import { MemoInputCard } from '../components/MemoInputCard'
|
|
|
|
type Props = StaticScreenProps<{
|
|
unit: MintUnit,
|
|
encodedInvoice?: string,
|
|
draftTransactionId?: number,
|
|
lnurlParams?: LNURLPayParams,
|
|
fixedAmount?: number,
|
|
comment?: string
|
|
paymentOption?: TransferOption,
|
|
mintUrl?: string,
|
|
isDonation?: boolean,
|
|
donationForName?: string
|
|
}>
|
|
|
|
export enum TransferOption {
|
|
PASTE_OR_SCAN_INVOICE = 'PASTE_OR_SCAN_INVOICE',
|
|
LNURL_PAY = 'LNURL_PAY',
|
|
LNURL_ADDRESS = 'LNURL_ADDRESS',
|
|
}
|
|
|
|
// ─── State machine ───────────────────────────────────────────────────────────
|
|
|
|
type TransferState = {
|
|
encodedInvoice: string
|
|
invoice: DecodedLightningInvoice | undefined
|
|
invoiceExpiry: Date | undefined
|
|
lnurlPayParams: (LNURLPayParams & { address?: string }) | undefined
|
|
meltQuote: MeltQuoteBolt11Response | undefined
|
|
finalFee: number
|
|
lnurlDescription: string
|
|
lnurlPayCommentAllowed: number
|
|
donationForName: string | undefined
|
|
availableMintBalances: MintBalance[]
|
|
mintBalanceToTransferFrom: MintBalance | undefined
|
|
transactionStatus: TransactionStatus | undefined
|
|
transaction: Transaction | undefined
|
|
resultModalInfo: { status: TransactionStatus; title?: string; message: string } | undefined
|
|
isLoading: boolean
|
|
isAmountEditable: boolean
|
|
isNotEnoughFunds: boolean
|
|
isInvoiceDonation: boolean
|
|
isResultModalVisible: boolean
|
|
info: string
|
|
error: AppError | undefined
|
|
}
|
|
|
|
type TransferAction =
|
|
| { type: 'PREPARE_INVOICE'; encoded: string; invoice: DecodedLightningInvoice; invoiceExpiry: Date; mintBalance: MintBalance; offlineInfo?: string }
|
|
| { type: 'NO_MINT_BALANCE'; message: string }
|
|
| { type: 'PREPARE_LNURL_PAY'; lnurlPayParams: LNURLPayParams & { address?: string }; description: string; commentAllowed: number; isInvoiceDonation: boolean; donationForName?: string; fixedAmount?: number }
|
|
| { type: 'SET_MINT_BALANCE'; balance: MintBalance }
|
|
| { type: 'QUOTE_START' }
|
|
| { type: 'QUOTE_READY'; meltQuote: MeltQuoteBolt11Response; availableMintBalances: MintBalance[] }
|
|
| { type: 'QUOTE_INSUFFICIENT'; message: string }
|
|
| { type: 'QUOTE_FINISHED' }
|
|
| { type: 'TRANSFER_START' }
|
|
| { type: 'TRANSFER_COMPLETE'; transaction?: Transaction; transactionStatus: TransactionStatus; finalFee: number; resultModalInfo: { status: TransactionStatus; title?: string; message: string } }
|
|
| { type: 'UPDATE_RESULT_MODAL'; transactionStatus: TransactionStatus; resultModalInfo: { status: TransactionStatus; message: string } }
|
|
| { type: 'TOGGLE_RESULT_MODAL' }
|
|
| { type: 'SET_INFO'; message: string }
|
|
| { type: 'CLEAR_INFO' }
|
|
| { type: 'SET_ERROR'; error: AppError }
|
|
| { type: 'RESET' }
|
|
|
|
const INITIAL_STATE: TransferState = {
|
|
encodedInvoice: '',
|
|
invoice: undefined,
|
|
invoiceExpiry: undefined,
|
|
lnurlPayParams: undefined,
|
|
meltQuote: undefined,
|
|
finalFee: 0,
|
|
lnurlDescription: '',
|
|
lnurlPayCommentAllowed: 0,
|
|
donationForName: undefined,
|
|
availableMintBalances: [],
|
|
mintBalanceToTransferFrom: undefined,
|
|
transactionStatus: undefined,
|
|
transaction: undefined,
|
|
resultModalInfo: undefined,
|
|
isLoading: false,
|
|
isAmountEditable: true,
|
|
isNotEnoughFunds: false,
|
|
isInvoiceDonation: false,
|
|
isResultModalVisible: false,
|
|
info: '',
|
|
error: undefined,
|
|
}
|
|
|
|
function transferReducer(state: TransferState, action: TransferAction): TransferState {
|
|
switch (action.type) {
|
|
|
|
case 'PREPARE_INVOICE':
|
|
return {
|
|
...state,
|
|
encodedInvoice: action.encoded,
|
|
invoice: action.invoice,
|
|
invoiceExpiry: action.invoiceExpiry,
|
|
mintBalanceToTransferFrom: action.mintBalance,
|
|
isAmountEditable: false,
|
|
info: action.offlineInfo ?? state.info,
|
|
}
|
|
|
|
case 'NO_MINT_BALANCE':
|
|
return { ...state, info: action.message, isNotEnoughFunds: true }
|
|
|
|
case 'PREPARE_LNURL_PAY':
|
|
return {
|
|
...state,
|
|
lnurlPayParams: action.lnurlPayParams,
|
|
lnurlDescription: action.description,
|
|
lnurlPayCommentAllowed: action.commentAllowed,
|
|
isInvoiceDonation: action.isInvoiceDonation,
|
|
donationForName: action.donationForName,
|
|
isAmountEditable: !action.fixedAmount,
|
|
}
|
|
|
|
case 'SET_MINT_BALANCE':
|
|
return { ...state, mintBalanceToTransferFrom: action.balance }
|
|
|
|
case 'QUOTE_START':
|
|
return { ...state, isLoading: true, isNotEnoughFunds: false, info: '' }
|
|
|
|
case 'QUOTE_READY':
|
|
return {
|
|
...state,
|
|
isLoading: false,
|
|
meltQuote: action.meltQuote,
|
|
availableMintBalances: action.availableMintBalances,
|
|
isNotEnoughFunds: false,
|
|
}
|
|
|
|
case 'QUOTE_INSUFFICIENT':
|
|
return { ...state, isLoading: false, info: action.message, isNotEnoughFunds: true }
|
|
|
|
case 'QUOTE_FINISHED':
|
|
return { ...state, isLoading: false }
|
|
|
|
case 'TRANSFER_START':
|
|
return { ...state, isLoading: true }
|
|
|
|
case 'TRANSFER_COMPLETE':
|
|
return {
|
|
...state,
|
|
isLoading: false,
|
|
transaction: action.transaction,
|
|
transactionStatus: action.transactionStatus,
|
|
finalFee: action.finalFee,
|
|
resultModalInfo: action.resultModalInfo,
|
|
isResultModalVisible: true,
|
|
}
|
|
|
|
case 'UPDATE_RESULT_MODAL':
|
|
return {
|
|
...state,
|
|
transactionStatus: action.transactionStatus,
|
|
resultModalInfo: action.resultModalInfo,
|
|
}
|
|
|
|
case 'TOGGLE_RESULT_MODAL':
|
|
return { ...state, isResultModalVisible: !state.isResultModalVisible }
|
|
|
|
case 'SET_INFO':
|
|
return { ...state, info: action.message }
|
|
|
|
case 'CLEAR_INFO':
|
|
return { ...state, info: '' }
|
|
|
|
case 'SET_ERROR':
|
|
return { ...state, isLoading: false, error: action.error }
|
|
|
|
case 'RESET':
|
|
return { ...INITIAL_STATE }
|
|
|
|
default:
|
|
return state
|
|
}
|
|
}
|
|
|
|
// ─── Component ───────────────────────────────────────────────────────────────
|
|
|
|
export const TransferScreen = observer(function TransferScreen({ route }: Props) {
|
|
const navigation = useNavigation()
|
|
const amountInputRef = useRef<TextInput>(null)
|
|
const lnurlCommentInputRef = useRef<TextInput>(null)
|
|
const unitRef = useRef<MintUnit>('sat')
|
|
const mintUrlRef = useRef<string>('')
|
|
const draftTransactionIdRef = useRef<number>(null)
|
|
const meltQuoteRef = useRef<MeltQuoteBolt11Response | undefined>(undefined)
|
|
|
|
const {
|
|
proofsStore,
|
|
mintsStore,
|
|
walletStore,
|
|
walletProfileStore,
|
|
contactsStore,
|
|
} = useStores()
|
|
|
|
const isInternetReachable = useIsInternetReachable()
|
|
|
|
const [state, dispatch] = useReducer(transferReducer, INITIAL_STATE)
|
|
// User-input state kept separate to avoid rebuilding the full state object on every keystroke.
|
|
const [amountToTransfer, setAmountToTransfer] = useState<string>('0')
|
|
const [memo, setMemo] = useState('')
|
|
const [lnurlPayComment, setLnurlPayComment] = useState('')
|
|
|
|
const {
|
|
encodedInvoice,
|
|
invoice,
|
|
invoiceExpiry,
|
|
lnurlPayParams,
|
|
meltQuote,
|
|
finalFee,
|
|
lnurlDescription,
|
|
lnurlPayCommentAllowed,
|
|
donationForName,
|
|
availableMintBalances,
|
|
mintBalanceToTransferFrom,
|
|
transactionStatus,
|
|
transaction,
|
|
resultModalInfo,
|
|
isLoading,
|
|
isAmountEditable,
|
|
isNotEnoughFunds,
|
|
isInvoiceDonation,
|
|
isResultModalVisible,
|
|
info,
|
|
error,
|
|
} = state
|
|
|
|
|
|
useEffect(() => {
|
|
const focus = () => {
|
|
if (route.params?.paymentOption === TransferOption.LNURL_PAY) {
|
|
amountInputRef && amountInputRef.current
|
|
? amountInputRef.current.focus()
|
|
: false
|
|
}
|
|
}
|
|
|
|
const timer = setTimeout(() => focus(), 100)
|
|
|
|
return () => {
|
|
clearTimeout(timer)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const setUnitAndMint = () => {
|
|
try {
|
|
const {unit, mintUrl} = route.params
|
|
if(!unit) {
|
|
throw new AppError(Err.VALIDATION_ERROR, translate('missingMintUnitRouteParamsError'))
|
|
}
|
|
|
|
unitRef.current = unit
|
|
|
|
if(mintUrl) {
|
|
const mintBalance = proofsStore.getMintBalance(mintUrl)
|
|
mintUrlRef.current = mintUrl
|
|
if (mintBalance) {
|
|
dispatch({ type: 'SET_MINT_BALANCE', balance: mintBalance })
|
|
}
|
|
}
|
|
} catch (e: any) {
|
|
handleError(e)
|
|
}
|
|
}
|
|
|
|
setUnitAndMint()
|
|
return () => {}
|
|
}, [])
|
|
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
const {paymentOption} = route.params
|
|
log.trace('[useFocusEffect]', {paymentOption})
|
|
|
|
const handleInvoice = () => {
|
|
try {
|
|
const {encodedInvoice, draftTransactionId} = route.params
|
|
|
|
if (!encodedInvoice) {
|
|
throw new AppError(Err.VALIDATION_ERROR, 'Missing invoice.')
|
|
}
|
|
|
|
// This is a transfer initiated from invoice received over Nostr event,
|
|
// so DRAFT transaction exists
|
|
if(draftTransactionId) {
|
|
draftTransactionIdRef.current = draftTransactionId
|
|
}
|
|
|
|
log.trace('[handleInvoice] Invoice', {encodedInvoice})
|
|
|
|
onEncodedInvoice(encodedInvoice)
|
|
} catch (e: any) {
|
|
handleError(e)
|
|
}
|
|
}
|
|
|
|
const handleLnurlPay = async () => {
|
|
try {
|
|
// amountSats allows to default amount to be paid to a lightning address
|
|
const {lnurlParams, unit, fixedAmount, comment, isDonation, donationForName} = route.params
|
|
|
|
log.trace('[handleLnurlPay] start', {unit})
|
|
|
|
if (!lnurlParams) {
|
|
throw new AppError(Err.VALIDATION_ERROR, translate('missingLNURLParamsError'))
|
|
}
|
|
|
|
if(comment) {
|
|
setLnurlPayComment(comment)
|
|
}
|
|
|
|
let desc: string = ''
|
|
let commentAllowed: number = 0
|
|
|
|
const metadata = lnurlParams.decodedMetadata
|
|
|
|
if(metadata) {
|
|
let address: string = ''
|
|
|
|
for (const entry of metadata) {
|
|
if (entry[0] === "text/plain") {
|
|
desc = entry[1]
|
|
break // Exit the loop once we find the "text/plain" entry
|
|
}
|
|
}
|
|
|
|
for (const entry of metadata) {
|
|
if (entry[0] === "text/identifier" || entry[0] === "text/email") {
|
|
address = entry[1]
|
|
break
|
|
}
|
|
}
|
|
|
|
if ('commentAllowed' in lnurlParams && lnurlParams.commentAllowed > 0) {
|
|
commentAllowed = lnurlParams.commentAllowed
|
|
}
|
|
|
|
if(address) {
|
|
// overwrite sender address set by wallet with the address from the lnurl response
|
|
lnurlParams.address = address
|
|
}
|
|
}
|
|
|
|
const defaultAmount = fixedAmount || roundUp(lnurlParams.minSendable / 1000, 0)
|
|
|
|
// Set minSendable into amountToTransfer in unit currency
|
|
if (unit !== 'sat') {
|
|
const rate = await walletStore.getExchangeRate(getCurrency(unit).code)
|
|
const defaultAmountUnit = convertToFromSats(defaultAmount, CurrencyCode.SAT, rate)
|
|
|
|
log.trace('[handleLnurlPay] minSendable conversion from SAT', {defaultAmount, rate, defaultAmountUnit})
|
|
|
|
setAmountToTransfer(`${numbro(defaultAmountUnit / getCurrency(unit).precision)
|
|
.format({
|
|
thousandSeparated: true,
|
|
mantissa: getCurrency(unit).mantissa
|
|
})}`
|
|
)
|
|
} else {
|
|
setAmountToTransfer(`${numbro(defaultAmount)
|
|
.format({
|
|
thousandSeparated: true,
|
|
mantissa: 0
|
|
})}`
|
|
)
|
|
}
|
|
|
|
dispatch({
|
|
type: 'PREPARE_LNURL_PAY',
|
|
lnurlPayParams: lnurlParams,
|
|
description: desc,
|
|
commentAllowed,
|
|
isInvoiceDonation: !!isDonation,
|
|
donationForName: isDonation ? donationForName : undefined,
|
|
fixedAmount: fixedAmount && fixedAmount > 0 ? fixedAmount : undefined,
|
|
})
|
|
} catch (e: any) {
|
|
handleError(e)
|
|
}
|
|
}
|
|
|
|
if(paymentOption && paymentOption === TransferOption.PASTE_OR_SCAN_INVOICE) {
|
|
handleInvoice()
|
|
}
|
|
|
|
if(paymentOption && paymentOption === TransferOption.LNURL_PAY) {
|
|
handleLnurlPay()
|
|
}
|
|
|
|
}, [route.params?.paymentOption]),
|
|
)
|
|
|
|
const [pendingAsyncMeltId, setPendingAsyncMeltId] = useState<number | undefined>(undefined)
|
|
|
|
// async melt result listener - updates the result modal when the melt quote resolves and transaction can be completed
|
|
useEffect(() => {
|
|
if (!pendingAsyncMeltId) return
|
|
|
|
const handler = (result: { transactionId: number; status: TransactionStatus; message: string }) => {
|
|
log.trace('[TransferScreen] Received async melt result', {result, pendingAsyncMeltId, isInvoiceDonation, donationForName})
|
|
|
|
if (result.transactionId !== pendingAsyncMeltId) return
|
|
|
|
setPendingAsyncMeltId(undefined)
|
|
if (isInvoiceDonation && donationForName &&
|
|
(result.status === TransactionStatus.COMPLETED || result.status === TransactionStatus.RECOVERED)) {
|
|
|
|
walletProfileStore.updateName(donationForName)
|
|
dispatch({
|
|
type: 'UPDATE_RESULT_MODAL',
|
|
transactionStatus: result.status,
|
|
resultModalInfo: { status: result.status, message: translate('transferScreen_donationSuccessMessage', { donationForName }) },
|
|
})
|
|
} else {
|
|
dispatch({
|
|
type: 'UPDATE_RESULT_MODAL',
|
|
transactionStatus: result.status,
|
|
resultModalInfo: { status: result.status, message: result.message },
|
|
})
|
|
}
|
|
}
|
|
|
|
EventEmitter.on('ev_asyncMeltResult', handler)
|
|
return () => EventEmitter.off('ev_asyncMeltResult', handler)
|
|
}, [pendingAsyncMeltId])
|
|
|
|
const handleError = function(e: AppError): void {
|
|
dispatch({ type: 'SET_ERROR', error: e })
|
|
}
|
|
|
|
|
|
// Ref to track if a quote request is in progress
|
|
const quoteRequestRef = useRef<AbortController | null>(null)
|
|
|
|
// Stable function to create melt quote
|
|
const createMeltQuote = useCallback(async () => {
|
|
// Cancel any previous in-flight request
|
|
if (quoteRequestRef.current) {
|
|
quoteRequestRef.current.abort()
|
|
}
|
|
|
|
const controller = new AbortController()
|
|
quoteRequestRef.current = controller
|
|
|
|
try {
|
|
log.trace('[TransferScreen] createMeltQuote triggered', {
|
|
mintBalanceToTransferFrom,
|
|
encodedInvoice,
|
|
hasExistingQuote: !!meltQuoteRef.current,
|
|
})
|
|
|
|
// Guard: required data not ready yet
|
|
if (!mintBalanceToTransferFrom?.mintUrl || !encodedInvoice) {
|
|
log.trace('[createMeltQuote] Missing required data, skipping quote request')
|
|
return
|
|
}
|
|
|
|
// Prevent double quote if we already have one for the same invoice + mint
|
|
if (meltQuoteRef.current?.quote) {
|
|
log.trace('[createMeltQuote] Melt quote already exists, skipping')
|
|
return
|
|
}
|
|
|
|
dispatch({ type: 'QUOTE_START' })
|
|
|
|
const quote = await walletStore.createLightningMeltQuote(
|
|
mintBalanceToTransferFrom.mintUrl,
|
|
unitRef.current,
|
|
encodedInvoice,
|
|
)
|
|
|
|
// Ignore result if request was aborted (e.g. user switched mint)
|
|
if (controller.signal.aborted) {
|
|
log.trace('[createMeltQuote] Request was aborted, ignoring stale result')
|
|
return
|
|
}
|
|
|
|
meltQuoteRef.current = quote
|
|
|
|
// Format amount for display
|
|
const displayAmount = numbro(quote.amount.toNumber() / getCurrency(unitRef.current).precision).format({
|
|
thousandSeparated: true,
|
|
mantissa: getCurrency(unitRef.current).mantissa,
|
|
})
|
|
setAmountToTransfer(displayAmount)
|
|
|
|
// Check total required balance (amount + fee reserve)
|
|
const totalRequired = quote.amount.toNumber() + quote.fee_reserve.toNumber()
|
|
const availableBalances = proofsStore.getMintBalancesWithEnoughBalance(totalRequired, unitRef.current)
|
|
|
|
if (availableBalances.length === 0) {
|
|
dispatch({
|
|
type: 'QUOTE_INSUFFICIENT',
|
|
message: translate('transferScreen_insufficientFunds', {
|
|
currency: getCurrency(unitRef.current).code,
|
|
amount: totalRequired,
|
|
}),
|
|
})
|
|
} else {
|
|
dispatch({ type: 'QUOTE_READY', meltQuote: quote, availableMintBalances: availableBalances })
|
|
}
|
|
} catch (e: any) {
|
|
// Ignore abort errors
|
|
if (e.name === 'AbortError') {
|
|
log.trace('[createMeltQuote] Aborted')
|
|
return
|
|
}
|
|
|
|
log.error('[createMeltQuote] Failed to create melt quote', e)
|
|
handleError(e)
|
|
|
|
} finally {
|
|
// Only reset loading if this was the active request
|
|
if (quoteRequestRef.current === controller) {
|
|
dispatch({ type: 'QUOTE_FINISHED' })
|
|
quoteRequestRef.current = null
|
|
}
|
|
}
|
|
}, [
|
|
mintBalanceToTransferFrom?.mintUrl,
|
|
encodedInvoice,
|
|
])
|
|
|
|
// Trigger quote creation when mint or invoice changes
|
|
useEffect(() => {
|
|
createMeltQuote()
|
|
}, [createMeltQuote])
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (quoteRequestRef.current) {
|
|
quoteRequestRef.current.abort()
|
|
quoteRequestRef.current = null
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
|
|
const gotoContacts = function () {
|
|
resetState()
|
|
navigation.dispatch(
|
|
StackActions.popToTop()
|
|
)
|
|
//@ts-ignore
|
|
navigation.navigate('ContactsNavigator', {
|
|
screen: 'Contacts',
|
|
params: {}
|
|
})
|
|
}
|
|
|
|
|
|
const onMintBalanceCancel = async function () {
|
|
return gotoWallet()
|
|
//dispatch({ type: 'HIDE_MINT_SELECTOR' })
|
|
}
|
|
|
|
|
|
const gotoWallet = function() {
|
|
resetState()
|
|
navigation.dispatch(
|
|
StackActions.popToTop()
|
|
)
|
|
}
|
|
|
|
|
|
const resetState = function () {
|
|
dispatch({ type: 'RESET' })
|
|
setAmountToTransfer('')
|
|
setMemo('')
|
|
setLnurlPayComment('')
|
|
meltQuoteRef.current = undefined
|
|
}
|
|
|
|
const toggleResultModal = () => dispatch({ type: 'TOGGLE_RESULT_MODAL' })
|
|
|
|
const onMintBalanceSelect = function (balance: MintBalance) {
|
|
mintUrlRef.current = balance.mintUrl
|
|
meltQuoteRef.current = undefined // clear stale quote so createMeltQuote fetches fresh for the new mint
|
|
dispatch({ type: 'SET_MINT_BALANCE', balance }) // this triggers effect to get melt quote
|
|
}
|
|
|
|
|
|
// Amount is editable only in case of LNURL Pay, while invoice is not yet retrieved
|
|
const onRequestLnurlInvoice = async function () {
|
|
log.trace('[onRequestLnurlInvoice] start', {amountToTransfer, unit: unitRef.current})
|
|
try {
|
|
const {precision, code: currencyCode} = getCurrency(unitRef.current)
|
|
|
|
const amountUnit = round(toNumber(amountToTransfer) * precision, 0)
|
|
|
|
if (!amountUnit || amountUnit === 0) {
|
|
dispatch({ type: 'SET_INFO', message: translate('payCommon_amountZeroOrNegative') })
|
|
return
|
|
}
|
|
|
|
if(!lnurlPayParams) {
|
|
throw new AppError(Err.VALIDATION_ERROR, 'Missing LNURL pay parameters', {caller: 'onRequestLnurlInvoice'})
|
|
}
|
|
|
|
let amountSats = 0
|
|
|
|
if(unitRef.current !== 'sat') {
|
|
const rate = await walletStore.getExchangeRate(currencyCode)
|
|
amountSats = roundUp(convertToFromSats(amountUnit, currencyCode, rate), 0)
|
|
|
|
log.trace('[onRequestLnurlInvoice] converted amountToTransfer to SAT', {amountUnit, amountSats})
|
|
} else {
|
|
amountSats = amountUnit
|
|
}
|
|
|
|
if (lnurlPayParams.minSendable && amountSats < lnurlPayParams.minSendable / 1000) {
|
|
dispatch({ type: 'SET_INFO', message: translate('payCommon_minimumPay', {
|
|
amount: roundUp(lnurlPayParams.minSendable / 1000, 0),
|
|
currency: CurrencyCode.SAT
|
|
}) })
|
|
return
|
|
}
|
|
|
|
if (lnurlPayParams.maxSendable && amountSats > lnurlPayParams.maxSendable / 1000) {
|
|
dispatch({ type: 'SET_INFO', message: translate("payCommon_maximumPay", {
|
|
amount: roundDown(lnurlPayParams.maxSendable / 1000, 0),
|
|
currency: CurrencyCode.SAT
|
|
}) })
|
|
return
|
|
}
|
|
|
|
if (lnurlPayParams.payerData) {
|
|
throw new AppError(Err.VALIDATION_ERROR, translate("transferScreen_LUD18unsupported"))
|
|
}
|
|
|
|
dispatch({ type: 'TRANSFER_START' })
|
|
|
|
const encoded = await LnurlClient.getInvoice(
|
|
lnurlPayParams,
|
|
amountSats * 1000,
|
|
lnurlPayCommentAllowed > 0 ? lnurlPayComment : void 0
|
|
)
|
|
|
|
dispatch({ type: 'QUOTE_FINISHED' })
|
|
|
|
if (encoded) return onEncodedInvoice(encoded)
|
|
|
|
throw new AppError(Err.NOTFOUND_ERROR, `Could not get lightning invoice from ${lnurlPayParams.domain}`)
|
|
} catch (e: any) { handleError(e) }
|
|
}
|
|
|
|
const ensureCommentNotTooLong = async function () {
|
|
Keyboard.dismiss()
|
|
if (!lnurlPayCommentAllowed || lnurlPayComment.trim().length === 0) return
|
|
if (lnurlPayComment.trim().length > lnurlPayCommentAllowed) {
|
|
setLnurlPayComment(lnurlPayComment.slice(0, lnurlPayCommentAllowed))
|
|
}
|
|
}
|
|
|
|
|
|
const onEncodedInvoice = async function (encoded: string) {
|
|
log.trace("[onEncodedInvoice] start", {mintUrl: mintUrlRef.current, unit: unitRef.current})
|
|
|
|
try {
|
|
//@ts-ignore
|
|
navigation.setParams({
|
|
encodedInvoice: undefined,
|
|
lnurlParams: undefined,
|
|
paymentOption: undefined,
|
|
fixedAmount: undefined,
|
|
isDonation: undefined,
|
|
donationForName: undefined,
|
|
draftTransactionIdRef: undefined,
|
|
unit: undefined,
|
|
mintUrl: undefined
|
|
})
|
|
|
|
const invoice = LightningUtils.decodeInvoice(encoded)
|
|
const {amount, expiry, description, timestamp} = LightningUtils.getInvoiceData(invoice)
|
|
const expiresAt = addSeconds(new Date(timestamp as number * 1000), expiry as number)
|
|
|
|
if (!amount || amount === 0) {
|
|
dispatch({ type: 'SET_INFO', message: translate('payCommon_amountZeroOrNegative') })
|
|
return
|
|
}
|
|
|
|
if(description) {
|
|
setMemo(description)
|
|
}
|
|
|
|
if (lnurlPayComment) {
|
|
setMemo(lnurlPayComment)
|
|
}
|
|
|
|
// We need to retrieve the quote first to know how much is needed to settle invoice in selected currency unit
|
|
const balanceToTransferFrom = mintUrlRef.current ?
|
|
proofsStore.getMintBalance(mintUrlRef.current) :
|
|
proofsStore.getMintBalancesWithUnit(unitRef.current)[0]
|
|
|
|
log.trace('[onEncodedInvoice]', {balanceToTransferFrom})
|
|
|
|
if (!balanceToTransferFrom) {
|
|
log.warn('Not enough balance')
|
|
dispatch({ type: 'NO_MINT_BALANCE', message: translate("transferScreen_noMintWithBalance", { unit: unitRef.current }) })
|
|
return
|
|
}
|
|
|
|
meltQuoteRef.current = undefined // clear stale quote before processing new invoice
|
|
dispatch({
|
|
type: 'PREPARE_INVOICE',
|
|
encoded,
|
|
invoice,
|
|
invoiceExpiry: expiresAt,
|
|
mintBalance: balanceToTransferFrom,
|
|
offlineInfo: !isInternetReachable ? translate('commonOfflinePretty') : undefined,
|
|
})
|
|
// continues in hook that handles other mint selection by user
|
|
|
|
} catch (e: any) {
|
|
handleError(e)
|
|
}
|
|
}
|
|
|
|
const transfer = async function () {
|
|
try {
|
|
if(!meltQuote) {
|
|
throw new AppError(Err.VALIDATION_ERROR, 'Missing quote to initiate transfer transaction')
|
|
}
|
|
|
|
if (!mintBalanceToTransferFrom) {
|
|
dispatch({ type: 'SET_INFO', message: translate("transferScreen_selectMintFrom") })
|
|
return
|
|
}
|
|
|
|
dispatch({ type: 'TRANSFER_START' })
|
|
|
|
log.trace('[transfer]', {isInvoiceDonation})
|
|
|
|
const amountToTransferInt = round(toNumber(amountToTransfer) * getCurrency(unitRef.current).precision, 0)
|
|
|
|
const result = await WalletTask.transferQueueAwaitable(
|
|
mintBalanceToTransferFrom,
|
|
amountToTransferInt,
|
|
unitRef.current,
|
|
meltQuote,
|
|
memo,
|
|
invoiceExpiry as Date,
|
|
encodedInvoice,
|
|
undefined,
|
|
draftTransactionIdRef.current || undefined
|
|
)
|
|
|
|
// TODO: handle async melt case - show in progress (not pending) state in modal and update when completed
|
|
await handleTransferTaskResult(result)
|
|
|
|
} catch (e: any) {
|
|
handleError(e)
|
|
}
|
|
}
|
|
|
|
|
|
const handleTransferTaskResult = async (result: TransactionTaskResult) => {
|
|
log.trace('[TransferScreen] handleTransferTaskResult triggered', {
|
|
isInvoiceDonation,
|
|
donationForName,
|
|
})
|
|
|
|
const { transaction, error, message, finalFee } = result
|
|
|
|
// ——— Early error: no transaction object yet ———
|
|
if (!transaction && error) {
|
|
dispatch({
|
|
type: 'TRANSFER_COMPLETE',
|
|
transactionStatus: TransactionStatus.ERROR,
|
|
finalFee: 0,
|
|
resultModalInfo: {
|
|
status: TransactionStatus.ERROR,
|
|
title: translate('payCommon_failed'),
|
|
message: error.message || 'Lightning payment failed',
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
// ——— Transaction exists ———
|
|
if (transaction) {
|
|
const { status } = transaction
|
|
|
|
// ——— Link transaction to LNURL / contact profile ———
|
|
if (lnurlPayParams?.address) {
|
|
const profile = contactsStore.findByLud16(lnurlPayParams.address)
|
|
|
|
if (profile) {
|
|
transaction.update({
|
|
sentTo: profile.nip05 || profile.name || lnurlPayParams.address,
|
|
profile: JSON.stringify(profile),
|
|
})
|
|
} else {
|
|
transaction.update({
|
|
sentTo: lnurlPayParams.address,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ——— Build resultModalInfo ———
|
|
let resultModalInfo: { status: TransactionStatus; title?: string; message: string }
|
|
|
|
if (error) {
|
|
// mint returned error with proofs pending - show pending message in modal instead of error
|
|
if (status === TransactionStatus.PENDING) {
|
|
resultModalInfo = { status, message }
|
|
} else {
|
|
resultModalInfo = {
|
|
status,
|
|
title: error.params?.message ? error.message : translate('payCommon_failed'),
|
|
message: error.params?.message || error.message || 'Payment failed',
|
|
}
|
|
}
|
|
} else {
|
|
if (status === TransactionStatus.PENDING) {
|
|
// Async melt: monitor is running in background and will emit ev_asyncMeltResult
|
|
// when the quote resolves. The listener above will update the modal at that point.
|
|
setPendingAsyncMeltId(transaction.id)
|
|
}
|
|
// Success path
|
|
if (isInvoiceDonation && donationForName &&
|
|
(status === TransactionStatus.COMPLETED || status === TransactionStatus.RECOVERED)) {
|
|
await walletProfileStore.updateName(donationForName)
|
|
resultModalInfo = {
|
|
status,
|
|
message: translate('transferScreen_donationSuccessMessage', { donationForName }),
|
|
}
|
|
} else {
|
|
resultModalInfo = { status, message }
|
|
}
|
|
}
|
|
|
|
dispatch({
|
|
type: 'TRANSFER_COMPLETE',
|
|
transaction,
|
|
transactionStatus: status,
|
|
finalFee: finalFee ?? 0,
|
|
resultModalInfo,
|
|
})
|
|
}
|
|
}
|
|
|
|
|
|
const increaseProofsCounterAndRetry = async function () {
|
|
try {
|
|
const walletInstance = await walletStore.getWallet(
|
|
mintBalanceToTransferFrom?.mintUrl as string,
|
|
unitRef.current,
|
|
{withSeed: true}
|
|
)
|
|
const mintInstance = mintsStore.findByUrl(mintBalanceToTransferFrom?.mintUrl as string)
|
|
const counter = mintInstance!.getProofsCounterByKeysetId!(walletInstance.keysetId)
|
|
counter!.increaseProofsCounter(10)
|
|
|
|
// retry transfer
|
|
transfer()
|
|
} catch (e: any) {
|
|
handleError(e)
|
|
} finally {
|
|
toggleResultModal() //close
|
|
}
|
|
}
|
|
|
|
|
|
const retryAfterSpentCleaned = async function () {
|
|
try {
|
|
// retry transfer
|
|
transfer()
|
|
} catch (e: any) {
|
|
handleError(e)
|
|
} finally {
|
|
toggleResultModal() //close
|
|
}
|
|
}
|
|
|
|
const headerBg = useThemeColor('header')
|
|
const iconColor = useThemeColor('textDim')
|
|
const amountInputColor = useThemeColor('amountInput')
|
|
|
|
|
|
return (
|
|
<Screen preset="fixed" contentContainerStyle={$screen} hideTabBar>
|
|
<MintHeader
|
|
mint={
|
|
mintBalanceToTransferFrom
|
|
? mintsStore.findByUrl(mintBalanceToTransferFrom?.mintUrl)
|
|
: undefined
|
|
}
|
|
unit={unitRef.current}
|
|
/>
|
|
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
|
|
<View style={$amountContainer}>
|
|
<AmountInput
|
|
ref={amountInputRef}
|
|
value={amountToTransfer}
|
|
onChangeText={amount => setAmountToTransfer(amount)}
|
|
selectTextOnFocus={true}
|
|
unit={unitRef.current}
|
|
editable={isAmountEditable}
|
|
style={{color: amountInputColor}}
|
|
/>
|
|
</View>
|
|
{encodedInvoice && (meltQuote?.fee_reserve || finalFee) ? (
|
|
<FeeBadge
|
|
currencyCode={getCurrency(unitRef.current).code}
|
|
estimatedFee={meltQuote?.fee_reserve.toNumber() ?? 0}
|
|
finalFee={finalFee}
|
|
/>
|
|
) : (
|
|
<Text
|
|
size='xs'
|
|
tx='payCommon_amountToPayLabel'
|
|
style={{
|
|
color: amountInputColor,
|
|
textAlign: 'center',
|
|
marginTop: spacing.extraSmall
|
|
}}
|
|
/>
|
|
)}
|
|
</View>
|
|
<View style={$contentContainer}>
|
|
{transactionStatus !== TransactionStatus.COMPLETED && (
|
|
<Card
|
|
style={$memoCard}
|
|
ContentComponent={
|
|
<>
|
|
<ListItem
|
|
text={
|
|
lnurlPayParams?.address ||
|
|
memo ||
|
|
lnurlPayParams?.domain ||
|
|
translate('commonNoDescPlaceholder')
|
|
}
|
|
subText={lnurlDescription}
|
|
LeftComponent={
|
|
<Icon
|
|
containerStyle={$iconContainer}
|
|
icon="faInfoCircle"
|
|
size={spacing.medium}
|
|
color={iconColor}
|
|
/>
|
|
}
|
|
style={$item}
|
|
/>
|
|
{lnurlPayComment && encodedInvoice && (
|
|
<ListItem
|
|
text={lnurlPayComment}
|
|
topSeparator={true}
|
|
LeftComponent={
|
|
<Icon
|
|
containerStyle={$iconContainer}
|
|
icon="faPencil"
|
|
size={spacing.medium}
|
|
color={iconColor}
|
|
/>
|
|
}
|
|
style={$item}
|
|
/>
|
|
)}
|
|
</>
|
|
}
|
|
/>
|
|
)}
|
|
{!encodedInvoice && transactionStatus !== TransactionStatus.COMPLETED && lnurlPayCommentAllowed > 0 && (
|
|
<MemoInputCard
|
|
memo={lnurlPayComment}
|
|
setMemo={setLnurlPayComment}
|
|
ref={lnurlCommentInputRef}
|
|
onMemoDone={ensureCommentNotTooLong}
|
|
disabled={encodedInvoice ? true : false}
|
|
maxLength={lnurlPayCommentAllowed}
|
|
/>
|
|
)}
|
|
{!encodedInvoice && transactionStatus !== TransactionStatus.COMPLETED && (
|
|
<View style={$bottomContainer}>
|
|
<View style={$buttonContainer}>
|
|
<Button
|
|
tx="transferScreen_requestInvoice"
|
|
onPress={onRequestLnurlInvoice}
|
|
/>
|
|
</View>
|
|
</View>
|
|
)}
|
|
{isNotEnoughFunds && transactionStatus !== TransactionStatus.COMPLETED && (
|
|
<View style={$bottomContainer}>
|
|
<View style={$buttonContainer}>
|
|
<Button
|
|
tx="commonClose"
|
|
onPress={gotoWallet}
|
|
preset="secondary"
|
|
/>
|
|
</View>
|
|
</View>
|
|
)}
|
|
{availableMintBalances.length > 0 &&
|
|
transactionStatus !== TransactionStatus.COMPLETED && (
|
|
<MintBalanceSelector
|
|
mintBalances={availableMintBalances}
|
|
selectedMintBalance={mintBalanceToTransferFrom}
|
|
unit={unitRef.current}
|
|
title={translate('payCommon_payFrom')}
|
|
confirmTitle={translate('payCommon_payNow')}
|
|
// This screen pays a bolt11 invoice, so a mint that cannot melt
|
|
// bolt11 in this unit cannot be paid from. Onchain melt (NUT-30)
|
|
// will gate on its own method when it lands.
|
|
requiredCapability={mint => mint.supportsMelt!('bolt11', unitRef.current)}
|
|
unsupportedReason={translate('mintSelector_noPayoutSupport')}
|
|
onMintBalanceSelect={onMintBalanceSelect}
|
|
onCancel={onMintBalanceCancel}
|
|
onMintBalanceConfirm={transfer}
|
|
/>
|
|
)}
|
|
{transaction && transactionStatus === TransactionStatus.COMPLETED && (
|
|
<Card
|
|
style={{padding: spacing.medium}}
|
|
ContentComponent={
|
|
<>
|
|
<TranItem
|
|
label="tranDetailScreen_trasferredTo"
|
|
isFirst={true}
|
|
value={
|
|
mintsStore.findByUrl(transaction.mint)
|
|
?.shortname as string
|
|
}
|
|
/>
|
|
{transaction?.memo && (
|
|
<TranItem
|
|
label="tranDetailScreen_memoFromInvoice"
|
|
value={transaction.memo as string}
|
|
/>
|
|
)}
|
|
<TranItem
|
|
label="transactionCommon_feePaid"
|
|
value={transaction.fee || 0}
|
|
unit={unitRef.current}
|
|
isCurrency={true}
|
|
/>
|
|
<TranItem
|
|
label="tranDetailScreen_status"
|
|
value={transaction.status as string}
|
|
/>
|
|
</>
|
|
}
|
|
/>
|
|
)}
|
|
{transactionStatus === TransactionStatus.COMPLETED && (
|
|
<View style={$bottomContainer}>
|
|
<View style={$buttonContainer}>
|
|
<Button
|
|
preset="secondary"
|
|
tx={'commonClose'}
|
|
onPress={gotoWallet}
|
|
/>
|
|
</View>
|
|
</View>
|
|
)}
|
|
</View>
|
|
<BottomModal
|
|
isVisible={isResultModalVisible}
|
|
ContentComponent={
|
|
<>
|
|
{resultModalInfo &&
|
|
transactionStatus === TransactionStatus.COMPLETED && (
|
|
<>
|
|
<ResultModalInfo
|
|
icon="faCheckCircle"
|
|
iconColor={colors.palette.success200}
|
|
title={translate('payCommon_completed')}
|
|
message={resultModalInfo?.message}
|
|
/>
|
|
<View style={$buttonContainer}>
|
|
<Button
|
|
preset="secondary"
|
|
tx={'commonClose'}
|
|
onPress={() => {
|
|
if (isInvoiceDonation) {
|
|
gotoContacts()
|
|
} else {
|
|
gotoWallet()
|
|
}
|
|
}}
|
|
/>
|
|
</View>
|
|
</>
|
|
)}
|
|
{resultModalInfo &&
|
|
transactionStatus === TransactionStatus.REVERTED && (
|
|
<>
|
|
<ResultModalInfo
|
|
icon="faRotate"
|
|
iconColor={colors.palette.accent300}
|
|
title={translate('transactionCommon_reverted')}
|
|
message={resultModalInfo?.message}
|
|
/>
|
|
<View style={$buttonContainer}>
|
|
<Button
|
|
preset="secondary"
|
|
tx={'commonClose'}
|
|
onPress={toggleResultModal}
|
|
/>
|
|
</View>
|
|
</>
|
|
)}
|
|
{resultModalInfo &&
|
|
transactionStatus === TransactionStatus.ERROR && (
|
|
<>
|
|
<ResultModalInfo
|
|
icon="faTriangleExclamation"
|
|
iconColor={colors.palette.angry500}
|
|
title={
|
|
resultModalInfo?.title || translate('payCommon_failed')
|
|
}
|
|
message={resultModalInfo?.message}
|
|
/>
|
|
<View style={$buttonContainer}>
|
|
{((/already.*signed|duplicate key/i.test(resultModalInfo.message))) ? (
|
|
<Button
|
|
preset="secondary"
|
|
text={"Retry again"}
|
|
onPress={increaseProofsCounterAndRetry}
|
|
/>
|
|
) : resultModalInfo.message.toLowerCase().includes('token already spent') ? (
|
|
<Button
|
|
preset="secondary"
|
|
text={"Retry again"}
|
|
onPress={retryAfterSpentCleaned}
|
|
/>
|
|
) : (
|
|
<Button
|
|
preset="secondary"
|
|
tx={'commonClose'}
|
|
onPress={toggleResultModal}
|
|
/>
|
|
)}
|
|
</View>
|
|
</>
|
|
)}
|
|
{resultModalInfo &&
|
|
transactionStatus === TransactionStatus.PENDING &&
|
|
!pendingAsyncMeltId && (
|
|
<>
|
|
<ResultModalInfo
|
|
icon="faTriangleExclamation"
|
|
iconColor={colors.palette.iconYellow300}
|
|
title={translate('payCommon_isPending')}
|
|
message={resultModalInfo?.message}
|
|
/>
|
|
<View style={$buttonContainer}>
|
|
<Button
|
|
preset="secondary"
|
|
tx={'commonClose'}
|
|
onPress={gotoWallet}
|
|
/>
|
|
</View>
|
|
</>
|
|
)}
|
|
{resultModalInfo &&
|
|
transactionStatus === TransactionStatus.PENDING &&
|
|
!!pendingAsyncMeltId && (
|
|
<>
|
|
<ResultModalInfo
|
|
icon="faClock"
|
|
iconColor={colors.palette.iconBlue200}
|
|
title={translate('payCommon_isInProgress')}
|
|
message={resultModalInfo?.message}
|
|
/>
|
|
<View style={$buttonContainer}>
|
|
<Button
|
|
preset="secondary"
|
|
tx={'commonClose'}
|
|
onPress={gotoWallet}
|
|
/>
|
|
</View>
|
|
</>
|
|
)}
|
|
</>
|
|
}
|
|
onBackButtonPress={toggleResultModal}
|
|
onBackdropPress={toggleResultModal}
|
|
/>
|
|
{isLoading && <Loading />}
|
|
{error && <ErrorModal error={error} />}
|
|
{info && <InfoModal message={info} />}
|
|
</Screen>
|
|
)
|
|
}
|
|
)
|
|
|
|
|
|
const $screen: ViewStyle = {
|
|
flex: 1,
|
|
}
|
|
|
|
const $headerContainer: TextStyle = {
|
|
alignItems: 'center',
|
|
padding: spacing.extraSmall,
|
|
paddingTop: 0,
|
|
height: spacing.screenHeight * 0.20,
|
|
}
|
|
|
|
const $amountContainer: ViewStyle = {
|
|
marginTop: -spacing.tiny,
|
|
// height: spacing.screenHeight * 0.11,
|
|
}
|
|
|
|
|
|
const $contentContainer: TextStyle = {
|
|
flex: 1,
|
|
padding: spacing.extraSmall,
|
|
marginTop: -spacing.extraLarge * 1.5
|
|
}
|
|
|
|
const $memoCard: ViewStyle = {
|
|
marginBottom: spacing.small,
|
|
minHeight: 80,
|
|
}
|
|
|
|
const $iconContainer: ViewStyle = {
|
|
padding: spacing.extraSmall,
|
|
alignSelf: 'center',
|
|
marginRight: spacing.medium,
|
|
}
|
|
|
|
const $card: ViewStyle = {
|
|
marginBottom: spacing.small,
|
|
paddingTop: 0,
|
|
}
|
|
|
|
const $item: ViewStyle = {
|
|
paddingHorizontal: spacing.small,
|
|
paddingLeft: 0,
|
|
}
|
|
|
|
const $bottomModal: ViewStyle = {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
paddingVertical: spacing.large,
|
|
paddingHorizontal: spacing.small,
|
|
}
|
|
|
|
const $buttonContainer: ViewStyle = {
|
|
flexDirection: 'row',
|
|
alignSelf: 'center',
|
|
}
|
|
|
|
const $receiveMsg: ViewStyle = {
|
|
flexDirection: 'row',
|
|
borderRadius: spacing.large,
|
|
justifyContent: 'flex-start',
|
|
padding: spacing.small,
|
|
}
|
|
|
|
const $bottomContainer: ViewStyle = {
|
|
position: 'absolute',
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
flex: 1,
|
|
justifyContent: 'flex-end',
|
|
marginBottom: spacing.medium,
|
|
alignSelf: 'stretch',
|
|
// opacity: 0,
|
|
}
|