Finalize payment request details, improve offline mode UX

This commit is contained in:
minibits-cash
2025-06-13 00:50:14 +02:00
parent 12dd4f9623
commit 4e6c6e9446
9 changed files with 260 additions and 195 deletions
+2 -1
View File
@@ -487,11 +487,12 @@
},
"tranDetailScreen": {
"amount": "Amount",
"paymentId": "Payment ID",
"balanceAfter": "Balance after this transaction",
"claim": "Claim",
"createdAt": "Created at",
"expiresAt": "Expires at",
"id": "ID",
"id": "Transaction ID",
"invoice": "Lightning invoice to pay",
"isOffline": "Redeem online",
"lightningFee": "Lightning network fee",
+32 -45
View File
@@ -8,7 +8,7 @@ import { useStores } from "../models"
import { MintBalanceSelector } from "./Mints/MintBalanceSelector"
import { CurrencyAmount } from "./Wallet/CurrencyAmount"
import EventEmitter from '../utils/eventEmitter'
import { getCurrency, MintUnit, CurrencyCode } from "../services/wallet/currency"
import { getCurrency, MintUnit, CurrencyCode, convertToFromSats } from "../services/wallet/currency"
import { round, toNumber } from "../utils/number"
import { translate } from "../i18n"
import AppError, { Err } from "../utils/AppError"
@@ -25,7 +25,6 @@ import {
Text,
} from "../components"
import useIsInternetReachable from "../utils/useIsInternetReachable"
import { PaymentRequest} from "@cashu/cashu-ts"
import {HANDLE_RECEIVED_EVENT_TASK, log, TransactionTaskResult, WalletTask } from "../services"
import { QRCodeBlock } from "./Wallet/QRCode"
import { TranItem } from "./TranDetailScreen"
@@ -33,6 +32,7 @@ import { Transaction, TransactionStatus } from "../models/Transaction"
import { CASHU_PAYMENT_REQUEST_TASK } from "../services/wallet/cashuPaymentRequestTask"
import { ResultModalInfo } from "./Wallet/ResultModalInfo"
import { MintHeader } from "./Mints/MintHeader"
import { verticalScale } from "@gocodingnow/rn-size-matters"
type Props = StaticScreenProps<{
unit: MintUnit,
@@ -327,32 +327,30 @@ const headerBg = useThemeColor("header")
const placeholderTextColor = useThemeColor("textDim")
const amountInputColor = useThemeColor("amountInput")
const inputText = useThemeColor("text")
const convertedAmountColor = useThemeColor("headerSubTitle")
const convertedAmountColor = useThemeColor('headerSubTitle')
const getConvertedAmount = () => {
if (!walletStore.exchangeRate) return undefined
const precision = getCurrency(unit).precision
return (
round(toNumber(amountToRequest) * precision, 0) &&
walletStore.exchangeRate &&
walletStore.exchangeRate[getCurrency(unit).code]
? round(
(toNumber(amountToRequest) * precision * walletStore.exchangeRate[userSettingsStore.exchangeCurrency]) /
walletStore.exchangeRate[getCurrency(unit).code],
2,
const getConvertedAmount = function () {
if (!walletStore.exchangeRate) {
return undefined
}
const precision = getCurrency(unit).precision
return convertToFromSats(
round(toNumber(amountToRequest) * precision, 0) || 0,
getCurrency(unit).code,
walletStore.exchangeRate
)
: undefined
)
}
}
const isConvertedAmountVisible = function () {
return (
walletStore.exchangeRate &&
(userSettingsStore.exchangeCurrency === getCurrency(unit).code ||
unit === 'sat') &&
getConvertedAmount() !== undefined
)
}
const isConvertedAmountVisible = () => {
return (
walletStore.exchangeRate &&
(userSettingsStore.exchangeCurrency === getCurrency(unit).code ||
unit === "sat") &&
getConvertedAmount() !== undefined
)
}
return (
<Screen preset="fixed" contentContainerStyle={$screen}>
@@ -380,26 +378,15 @@ return (
selectTextOnFocus={true}
returnKeyType={"done"}
/>
{isConvertedAmountVisible() && (
<CurrencyAmount
amount={getConvertedAmount() ?? 0}
currencyCode={
unit === "sat"
? userSettingsStore.exchangeCurrency
: CurrencyCode.SAT
}
symbolStyle={{
color: convertedAmountColor,
marginTop: spacing.tiny,
fontSize: 10,
}}
amountStyle={{
color: convertedAmountColor,
lineHeight: spacing.small,
}}
size="small"
containerStyle={{ justifyContent: "center" }}
/>
{isConvertedAmountVisible() && (
<CurrencyAmount
amount={getConvertedAmount() ?? 0}
currencyCode={unit === 'sat' ? userSettingsStore.exchangeCurrency : CurrencyCode.SAT}
symbolStyle={{color: convertedAmountColor, marginTop: spacing.tiny, fontSize: verticalScale(10)}}
amountStyle={{color: convertedAmountColor, lineHeight: spacing.small}}
size='small'
containerStyle={{justifyContent: 'center'}}
/>
)}
<Text
size="xs"
+39 -18
View File
@@ -419,7 +419,7 @@ const pubkeyInputRef = useRef<TextInput>(null) // Initialize pubkeyInputRef
// Offline send
useEffect(() => {
if(isInternetReachable) return
//if(isInternetReachable) return
log.trace('[Offline send]')
// if offline we set all non-zero mint balances as available to allow ecash selection
@@ -436,7 +436,7 @@ const pubkeyInputRef = useRef<TextInput>(null) // Initialize pubkeyInputRef
setAvailableMintBalances(availableBalances)
setMintBalanceToSendFrom(availableBalances[0])
setIsMintSelectorVisible(true)
}, [isInternetReachable])
}, [])
useEffect(() => {
@@ -1427,6 +1427,7 @@ const SelectProofsBlock = observer(function (props: {
const {proofsStore} = useStores()
const hintColor = useThemeColor('textDim')
const statusColor = useThemeColor('header')
const onCancel = function () {
@@ -1436,13 +1437,48 @@ const SelectProofsBlock = observer(function (props: {
return (
<View style={$bottomModal}>
<View
style={[
{
alignSelf: 'center',
marginTop: spacing.tiny,
paddingHorizontal: spacing.tiny,
borderRadius: spacing.tiny,
backgroundColor: colors.palette.primary200,
},
]}>
<Text
text={'OFFLINE MODE'}
style={[
{
color: statusColor,
fontSize: 10,
fontFamily: typography.primary?.light,
padding: 0,
lineHeight: 16,
}
]}
/>
</View>
<Text text='Select ecash to send' style={{marginTop: spacing.large}}/>
<Text
text='You can only send exact ecash denominations while you are offline.'
style={{color: hintColor, paddingHorizontal: spacing.small, textAlign: 'center'}}
size='xs'
/>
<CurrencyAmount
amount={CashuUtils.getProofsAmount(props.selectedProofs)}
mintUnit={props.unit}
size='extraLarge'
containerStyle={{marginTop: spacing.large, marginBottom: spacing.small, alignItems: 'center'}}
/>
<View style={{maxHeight: spacing.screenHeight * 0.4}}>
<View style={{
maxHeight: spacing.screenHeight * 0.45,
borderWidth: 1,
borderColor: hintColor,
borderRadius: spacing.medium,
marginTop: spacing.small
}}>
<FlatList<Proof>
data={proofsStore.getByMint(props.mintBalanceToSendFrom.mintUrl, {isPending: false, unit: props.unit})}
renderItem={({ item }) => {
@@ -1464,21 +1500,6 @@ const SelectProofsBlock = observer(function (props: {
/>
</View>
<View style={[$bottomContainer, {marginTop: spacing.extraLarge}]}>
<View style={[$buttonContainer, {marginBottom: spacing.medium}]}>
<CurrencyAmount
amount={CashuUtils.getProofsAmount(props.selectedProofs)}
mintUnit={props.unit}
size='large'
/>
<Button
preset={'tertiary'}
onPress={() => props.toggleIsLockedToPubkey()}
LeftAccessory={() => <Icon icon={props.isLockedToPubkey ? 'faLock' : 'faLock'}/>}
text={props.isLockedToPubkey ? 'Locked' : 'Lock'}
style={{minWidth: 80}}
/>
</View>
<View style={[$buttonContainer]}>
<Button
text="Create token"
+110 -124
View File
@@ -4,6 +4,7 @@ import {
View,
TextStyle,
TextInput,
TouchableOpacity,
} from 'react-native'
import {colors, spacing, useThemeColor} from '../theme'
import {log} from '../services/logService'
@@ -32,6 +33,13 @@ export const TokenReceiveScreen = function TokenReceiveScreen({ route }: Props)
const tokenInputRef = useRef<TextInput>(null)
const {mintsStore} = useStores()
// New: controls visibility of token input
const [showTokenInput, setShowTokenInput] = useState(false)
const [encodedToken, setEncodedToken] = useState<string | undefined>(undefined)
const [unit, setUnit] = useState<MintUnit>('sat')
const [mint, setMint] = useState<Mint | undefined>(undefined)
const [error, setError] = useState<AppError | undefined>()
async function autoPaste(setter: (text: string) => void, sideEffect: () => void) {
const clipboard = (await Clipboard.getString()).trim();
if (clipboard.length === 0) return
@@ -40,6 +48,8 @@ export const TokenReceiveScreen = function TokenReceiveScreen({ route }: Props)
const resultFromClipboard = IncomingParser.findAndExtract(clipboard, IncomingDataType.CASHU)
setter(resultFromClipboard.encoded)
sideEffect()
// Show input if autopaste sets encodedToken
setShowTokenInput(true)
} catch (e: any) {
return
}
@@ -70,12 +80,12 @@ export const TokenReceiveScreen = function TokenReceiveScreen({ route }: Props)
return () => {}
}, [])
const [encodedToken, setEncodedToken] = useState<string | undefined>(undefined)
const [unit, setUnit] = useState<MintUnit>('sat')
const [mint, setMint] = useState<Mint | undefined>(undefined)
const [error, setError] = useState<AppError | undefined>()
// If encodedToken is set (by autopaste), show input
useEffect(() => {
if (encodedToken && encodedToken.length > 0) {
setShowTokenInput(true)
}
}, [encodedToken])
const onPaste = async function() {
const clipboard = await Clipboard.getString()
@@ -86,9 +96,9 @@ export const TokenReceiveScreen = function TokenReceiveScreen({ route }: Props)
setEncodedToken(clipboard)
tokenInputRef.current?.blur()
setShowTokenInput(true)
}
const gotoScan = async function () {
tokenInputRef.current?.blur()
//@ts-ignore
@@ -98,7 +108,6 @@ export const TokenReceiveScreen = function TokenReceiveScreen({ route }: Props)
})
}
const gotoCashuPaymentRequest = async function () {
//@ts-ignore
navigation.navigate('CashuPaymentRequest', {
@@ -107,18 +116,6 @@ export const TokenReceiveScreen = function TokenReceiveScreen({ route }: Props)
})
}
/* const gotoContacts = function () {
//@ts-ignore
navigation.navigate('ContactsNavigator', {
screen: 'Contacts',
params: {
paymentOption: SendOption.LNURL_ADDRESS
}
})
} */
const onConfirm = async function() {
if(!encodedToken) {
setError({name: Err.VALIDATION_ERROR, message: translate("missingEcashTokenToReceiveError")})
@@ -135,7 +132,6 @@ export const TokenReceiveScreen = function TokenReceiveScreen({ route }: Props)
}
}
const gotoTopup = async function () {
//@ts-ignore
navigation.navigate('Topup', {
@@ -144,7 +140,6 @@ export const TokenReceiveScreen = function TokenReceiveScreen({ route }: Props)
})
}
const handleError = function(e: AppError): void {
setError(e)
}
@@ -155,7 +150,8 @@ export const TokenReceiveScreen = function TokenReceiveScreen({ route }: Props)
const inputText = useThemeColor('text')
const contactIcon = useThemeColor('button')
const headerBg = useThemeColor('header')
const headerTitle = useThemeColor('headerTitle')
const headerTitle = useThemeColor('headerTitle')
const mainButtonColor = useThemeColor('card')
return (
<Screen preset="fixed" contentContainerStyle={$screen}>
@@ -171,105 +167,72 @@ export const TokenReceiveScreen = function TokenReceiveScreen({ route }: Props)
/>
</View>
<View style={$contentContainer}>
<Card
HeadingComponent={
<ListItem
leftIcon='faMoneyBill1'
leftIconColor={colors.palette.iconViolet300}
tx="common.pasteEcashToken"
bottomSeparator={true}
/* RightComponent={
<Button
preset='tertiary'
LeftAccessory={() => <Icon color={contactIcon} containerStyle={{paddingVertical: 0}} icon='faAddressBook' />}
onPress={gotoContacts}
text='Contacts'
textStyle={{fontSize: 12, color: contactIcon}}
/>
}*/
/>
}
<Card
ContentComponent={
<>
<Text
size='xs'
style={{color: hintText, padding: spacing.extraSmall}}
tx="pasteEcashTokenDesc"
<ListItem
leftIcon='faMoneyBill1'
tx="common.pasteEcashToken"
bottomSeparator={showTokenInput}
onPress={() => setShowTokenInput((v) => !v)}
/>
<View style={{alignItems: 'center', marginTop: spacing.small}}>
<TextInput
ref={tokenInputRef}
onChangeText={data => setEncodedToken(data)}
value={encodedToken}
autoCapitalize='none'
keyboardType='default'
maxLength={5000}
numberOfLines={3}
multiline={true}
selectTextOnFocus={true}
style={[$addressInput, {backgroundColor: inputBg, color: inputText}]}
/>
</View>
{!!encodedToken && encodedToken?.length > 1 ? (
<View style={$buttonContainer}>
<Button
preset='default'
tx='common.confirm'
onPress={onConfirm}
style={{marginLeft: spacing.small}}
LeftAccessory={() => <Icon icon='faCheckCircle' color='white'/>}
{/* Token Input */}
{showTokenInput && (
<View style={{paddingVertical: spacing.extraSmall}}>
<Text
size='xs'
style={{color: hintText, padding: spacing.extraSmall}}
tx="pasteEcashTokenDesc"
/>
<View style={{alignItems: 'center', marginTop: spacing.small}}>
<TextInput
ref={tokenInputRef}
onChangeText={data => setEncodedToken(data)}
value={encodedToken}
autoCapitalize='none'
keyboardType='default'
maxLength={5000}
numberOfLines={3}
multiline={true}
selectTextOnFocus={true}
style={[$addressInput, {backgroundColor: inputBg, color: inputText}]}
/>
</View>
{!!encodedToken && encodedToken?.length > 1 ? (
<View style={[$buttonContainer, {marginTop: spacing.small}]}>
<Button
preset='default'
tx='common.confirm'
onPress={onConfirm}
// style={{marginLeft: spacing.small}}
LeftAccessory={() => <Icon icon='faCheckCircle' color='white'/>}
/>
</View>
) : (
<View style={[$buttonContainer, {marginTop: spacing.small}]}>
<Button
preset='secondary'
tx={'common.paste'}
onPress={onPaste}
LeftAccessory={() => (
<Icon icon='faPaste'/>
)}
/>
</View>
)}
</View>
) : (
<View style={$buttonContainer}>
<Button
preset='secondary'
tx={'common.paste'}
onPress={onPaste}
LeftAccessory={() => (
<Icon icon='faPaste'/>
)}
/>
<Button
preset='secondary'
tx='common.scan'
onPress={gotoScan}
style={{marginLeft: spacing.small}}
LeftAccessory={() => {
return(
<SvgXml
width={spacing.medium}
height={spacing.medium}
xml={ScanIcon}
fill={scanIcon}
style={{marginHorizontal: spacing.extraSmall}}
/>
)
}}
/>
</View>
)}
)}
{/* Create Payment Request */}
<ListItem
leftIcon='faQrcode'
tx="common.createCashuPaymentRequest"
onPress={gotoCashuPaymentRequest}
topSeparator={true}
/>
</>
}
/>
<Card
style={{marginTop: spacing.medium}}
ContentComponent={
<ListItem
leftIcon='faQrcode'
leftIconColor={colors.palette.iconYellow300}
tx="common.createCashuPaymentRequest"
RightComponent={
<Button
preset='secondary'
//LeftAccessory={() => <Icon color={contactIcon} containerStyle={{paddingVertical: 0}} icon='faAddressBook' />}
onPress={gotoCashuPaymentRequest}
tx='common.create'
// textStyle={{fontSize: 12, color: contactIcon}}
/>
}
/>
}
style={$card}
//style={{marginBottom: spacing.medium}}
/>
<Button
tx="tokenReceiveScreen.topupWithLightning"
@@ -291,8 +254,27 @@ export const TokenReceiveScreen = function TokenReceiveScreen({ route }: Props)
marginTop: spacing.medium
}}
/>
</View>
<View style={$bottomContainer}>
<View style={$buttonContainer}>
<Button
preset='tertiary'
LeftAccessory={() => (
<SvgXml
width={spacing.medium}
height={spacing.medium}
xml={ScanIcon}
fill={scanIcon}
style={{marginHorizontal: spacing.extraSmall}}
/>
)}
onPress={gotoScan}
style={{backgroundColor: mainButtonColor}}
text='Scan'
/>
</View>
</View>
{error && <ErrorModal error={error} />}
</Screen>
)
@@ -303,10 +285,9 @@ const $screen: ViewStyle = {
}
const $contentContainer: ViewStyle = {
// flex: 1,
marginTop: -spacing.extraLarge * 2,
padding: spacing.extraSmall,
// alignItems: 'center',
flex: 1
}
const $headerContainer: TextStyle = {
@@ -316,12 +297,11 @@ const $headerContainer: TextStyle = {
}
const $buttonContainer: ViewStyle = {
marginTop: spacing.large,
//marginTop: spacing.large,
flexDirection: 'row',
alignSelf: 'center',
}
const $addressInput: TextStyle = {
textAlignVertical: 'top' ,
borderRadius: spacing.extraSmall,
@@ -330,15 +310,21 @@ const $addressInput: TextStyle = {
height: verticalScale(70),
}
const $card: ViewStyle = {
marginBottom: 0,
}
const $bottomContainer: ViewStyle = {
position: 'absolute',
/*position: 'absolute',
bottom: 0,
left: 0,
right: 0,
flex: 1,
justifyContent: 'flex-end',
marginBottom: spacing.medium,
alignSelf: 'stretch',
marginBottom: spacing.medium,*/
alignSelf: 'center',
// opacity: 0,
}
}
+57 -4
View File
@@ -232,6 +232,7 @@ export const TranDetailScreen = observer(function TranDetailScreen({ route }: Pr
const colorScheme = useColorScheme()
const headerTitle = useThemeColor('headerTitle')
const inputText = useThemeColor('text')
const statusColor = useThemeColor('header')
return (
<Screen contentContainerStyle={$screen} preset="fixed">
@@ -253,7 +254,32 @@ export const TranDetailScreen = observer(function TranDetailScreen({ route }: Pr
preset="heading"
text={getFormattedAmount()}
style={[$tranAmount, {color: headerTitle}]}
/>
/>
{transaction.status !== TransactionStatus.COMPLETED && (
<View
style={[
{
alignSelf: 'center',
marginTop: spacing.tiny,
paddingHorizontal: spacing.tiny,
borderRadius: spacing.tiny,
backgroundColor: colors.palette.primary200,
},
]}>
<Text
text={transaction.status as string}
style={[
{
color: statusColor,
fontSize: 10,
fontFamily: typography.primary?.light,
padding: 0,
lineHeight: 16,
}
]}
/>
</View>
)}
</View>
<ScrollView style={$contentContainer}>
<Card
@@ -405,7 +431,8 @@ export const TranDetailScreen = observer(function TranDetailScreen({ route }: Pr
].includes(transaction.status)
) && (
<Card
label='Token tracking'
label='Token tracking'
style={$dataCard}
ContentComponent={
<>
<ListItem
@@ -722,10 +749,12 @@ const ReceiveInfoBlock = function (props: {
isCurrency={true}
isFirst={true}
/>
{transaction.memo && (
<TranItem
label="tranDetailScreen.memoFromSender"
value={transaction.memo as string}
/>
)}
{transaction.sentFrom && (
<>
{profilePicture ? (
@@ -823,6 +852,12 @@ const ReceiveInfoBlock = function (props: {
label="tranDetailScreen.createdAt"
value={(transaction.createdAt as Date).toLocaleString()}
/>
{transaction.paymentId && (
<TranItem
label="tranDetailScreen.paymentId"
value={transaction.paymentId as string}
/>
)}
<TranItem label="tranDetailScreen.id" value={`${transaction.id}`} />
</>
}
@@ -1037,6 +1072,12 @@ const ReceiveOfflineInfoBlock = function (props: {
label="tranDetailScreen.createdAt"
value={(transaction.createdAt as Date).toLocaleString()}
/>
{transaction.paymentId && (
<TranItem
label="tranDetailScreen.paymentId"
value={transaction.paymentId as string}
/>
)}
<TranItem label="tranDetailScreen.id" value={`${transaction.id}`} />
</>
}
@@ -1503,7 +1544,13 @@ const TopupInfoBlock = function (props: {
label="tranDetailScreen.expiresAt"
value={(new Date(paymentRequest.expiresAt!)).toLocaleString()}
/>
)}
)}
{transaction.paymentId && (
<TranItem
label="tranDetailScreen.paymentId"
value={transaction.paymentId as string}
/>
)}
<TranItem label="tranDetailScreen.id" value={`${transaction.id}`} />
</>
}
@@ -1748,6 +1795,12 @@ const TransferInfoBlock = function (props: {
label="tranDetailScreen.createdAt"
value={(transaction.createdAt as Date).toLocaleString()}
/>
{transaction.paymentId && (
<TranItem
label="tranDetailScreen.paymentId"
value={transaction.paymentId as string}
/>
)}
<TranItem label="tranDetailScreen.id" value={`${transaction.id}`} />
</>
}
@@ -2003,7 +2056,7 @@ const $contentContainer: TextStyle = {
const $tranAmount: TextStyle = {
fontSize: verticalScale(48),
lineHeight: verticalScale(48),
marginLeft: -20,
//marginLeft: -20,
}
const $actionCard: ViewStyle = {
@@ -62,6 +62,14 @@ export const TransactionListItem = observer(function (props: TransactionListProp
} else {
return tx.memo ? tx.memo : translate('transactionCommon.youReceived')
}
case TransactionType.RECEIVE_BY_PAYMENT_REQUEST:
if (tx.sentFrom) {
if (!tx.memo || tx.memo.includes('Sent from Minibits')) {
return translate('transactionCommon.from', {sender: getProfileName(tx.sentFrom)})
}
} else {
return tx.memo ? tx.memo : translate('transactionCommon.youReceived')
}
case TransactionType.RECEIVE_OFFLINE:
if (tx.sentFrom) {
if (!tx.memo || tx.memo.includes('Sent from Minibits')) {
+7 -1
View File
@@ -57,7 +57,13 @@ const extractEncodedLightningInvoice = function (maybeInvoice: string) {
return encodedInvoice
}
if (maybeInvoice && maybeInvoice.toLowerCase().startsWith('bitcoin:' || 'http')) {
if (
maybeInvoice &&
(
maybeInvoice.toLowerCase().startsWith('bitcoin:') ||
maybeInvoice.toLowerCase().startsWith('http')
)
) {
const url = new URL(maybeInvoice.toLowerCase())
// Use URLSearchParams to get the value of the "lightning" parameter
encodedInvoice = url.searchParams.get("lightning") as string
@@ -78,7 +78,7 @@ export const cashuPaymentRequestTask = async function (
},
] as PaymentRequestTransport[]
const cashuPrId = QuickCrypto.randomBytes(16).toString("hex")
const cashuPrId = QuickCrypto.randomBytes(4).toString("hex")
const cashuPaymentRequest = new CashuPaymentRequest(
transport,
cashuPrId,
+4 -1
View File
@@ -11,6 +11,7 @@ import { WalletUtils } from './utils'
import {isBefore} from 'date-fns'
import { MintUnit, formatCurrency, getCurrency } from './currency'
import { NostrEvent } from '../nostrService'
import { LightningUtils } from '../lightning/lightningUtils'
const {
transactionsStore,
@@ -77,6 +78,8 @@ export const transferTask = async function (
// store tx in db and in the model
transaction = await transactionsStore.addTransaction(newTransaction)
const transactionId = transaction.id
const paymentHash = LightningUtils.getInvoiceData(LightningUtils.decodeInvoice(encodedInvoice)).payment_hash
transaction.setPaymentId(paymentHash)
if (amountToTransfer + meltQuote.fee_reserve > mintBalanceToTransferFrom.balances[unit]!) {
throw new AppError(
@@ -263,7 +266,7 @@ export const transferTask = async function (
)
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance!
transaction.setBalanceAfter(balanceAfter)
transaction.setBalanceAfter(balanceAfter)
return {
taskFunction: TRANSFER_TASK,