This commit is contained in:
minibits-cash
2025-06-16 11:43:34 +02:00
35 changed files with 2156 additions and 257 deletions
+2 -2
View File
@@ -41,7 +41,7 @@ Receive ecash
- [x] Receive ecash from another wallet over NOSTR message sent to minibits.cash address
- [x] Receive ecash in person while being offline, redeem later (MVP version)
- [x] Realtime and encrypted push notifications on receive to minibits.cash lightning address
- [ ] Display or send cashu payment requests
- [x] Display or send cashu payment requests [✨ New!]
Send ecash
- [x] Share ecash token to send through another app
@@ -143,7 +143,7 @@ Minibits wallet is in early beta and available as of now only for Android device
- [x] Download it from Google Play
- [x] Join testing program on Google Play to get early releases to test (Submit your email to get an invite on [Minibits.cash](https://www.minibits.cash))
- [x] Download .apk file from Releases page and install it on your phone
- [ ] Try on Testflight
- [x] Try on Testflight
- [ ] Download from AppStore
+1 -1
View File
@@ -1 +1 @@
019708fa-f620-71c5-a06c-f71503d945bd
01976662-f1fb-757e-9929-8c0796d93c51
@@ -31,7 +31,7 @@
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Release"
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "minibits_wallet",
"version": "0.2.2-beta.14",
"version": "0.2.2-beta.18",
"private": true,
"scripts": {
"android:clean": "cd android && ./gradlew clean",
@@ -20,7 +20,7 @@
"clean": "npx react-native-clean-project"
},
"dependencies": {
"@cashu/cashu-ts": "minibits-cash/cashu-ts#ec5ff88db34d47911e2fa3b97109243b60eee646",
"@cashu/cashu-ts": "minibits-cash/cashu-ts#b3ba36757d84ec03f1064029db02308eb3a09cc4",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
+5 -5
View File
@@ -161,27 +161,27 @@ export function BottomModal(props: ModalProps) {
FooterTextProps?.style,
]
const statusBarOnModalOpen = useThemeColor('statusBarOnModalOpen')
// const statusBarOnModalOpen = useThemeColor('statusBarOnModalOpen')
return (
<View>
<Modal
isVisible={isVisible}
statusBarTranslucent={false}
statusBarTranslucent={true}
avoidKeyboard={true}
onBackdropPress={onBackdropPress}
onBackButtonPress={onBackButtonPress}
backdropOpacity={backdropOpacity}
style={[$outerContainerBase]}
//coverScreen={true}
// coverScreen={true}
{...otherProps}
>
<StatusBar backgroundColor={isVisible ? statusBarOnModalOpen : undefined} />
{/*<StatusBar backgroundColor={isVisible ? statusBarOnModalOpen : undefined} />*/}
<View style={[$innerContainerBase, $innerContainerStyle]}>
{HeadingComponent ||
(isHeadingPresent && (
<Text
weight="bold"
weight="medium"
text={heading}
tx={headingTx}
txOptions={headingTxOptions}
+2 -2
View File
@@ -5,11 +5,11 @@ import { Text } from './Text'
import { spacing } from '../theme'
export function Loading(props: ViewProps & { statusMessage?: string, textStyle?: TextStyle, shiftedUp?: boolean }) {
const statusBarOnModalOpen = useThemeColor('statusBarOnLoading')
//const statusBarOnModalOpen = useThemeColor('statusBarOnLoading')
const loadingIndicator = useThemeColor('loadingIndicator')
return (
<View style={[StyleSheet.absoluteFillObject, $loading(props?.shiftedUp ?? false), props.style]}>
<StatusBar backgroundColor={props.style && props.style.backgroundColor ? props.style.backgroundColor : statusBarOnModalOpen } />
{/*<StatusBar backgroundColor={props.style && props.style.backgroundColor ? props.style.backgroundColor : statusBarOnModalOpen } />*/}
<ActivityIndicator color={loadingIndicator} animating size="large" />
{props.statusMessage && (<Text style={[{opacity: 1}, props.textStyle]} text={props.statusMessage}/>)}
</View>
+13 -4
View File
@@ -10,7 +10,8 @@
"amount": {
"requested": "Requested amount",
"topup": "Topup amount",
"withdraw": "Withdraw amount"
"withdraw": "Withdraw amount",
"send": "Amount to send"
},
"attention": "Attention!",
"backupScreen": {
@@ -65,6 +66,7 @@
"paste": "Paste",
"pasteEcash": "Paste ecash",
"pasteEcashToken": "Paste ecash token",
"createCashuPaymentRequest": "Create payment request",
"qr": "QR Code",
"reconnect": "Reconnect",
"reset": "Reset",
@@ -243,7 +245,7 @@
"nsecPrivatePublicKeyMismatchError": "Provided private key does not match your new profile public key.",
"numberOfBackedupProofs": "Number of stored proofs",
"onDeviceInfo": "On-device information",
"pasteEcashTokenDesc": "Paste ecash token you want to receive.",
"pasteEcashTokenDesc": "Ecash token you want to receive.",
"payCommon": {
"amountToPayLabel": "Amount to pay",
"amountZeroOrNegative": "Amount should be positive number.",
@@ -269,6 +271,7 @@
"requestPayment": "Request payment",
"send": "Send",
"withdraw": "Withdraw"
},
"payerMemo": "Memo for the payer",
"paymentRequestScreen": {
@@ -478,13 +481,18 @@
"sendRequest": "Send request",
"invoiceCreatedDescParam": "Your selected mint balance to top up is %{mintUrl}"
},
"cashuPaymentRequest": {
"to": "Receive to",
"create": "Create request"
},
"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",
@@ -537,7 +545,8 @@
"prepared": "Prepared",
"reverted": "Reverted",
"spent": "Spent",
"unspent": "Unspent"
"unspent": "Unspent",
"recovered": "Recovered"
},
"tapToRedeem": "Tap to redeem",
"unknown": "Unknown transaction",
+4
View File
@@ -37,6 +37,10 @@ import { MINIBITS_NIP05_DOMAIN } from '@env'
const c = self.contacts.find(c => c.npub === npub)
return c ? c : undefined
},
findByLud16: (lud16: string) => {
const c = self.contacts.find(c => c.lud16 === lud16)
return c ? c : undefined
},
alreadyExists(pubkey: string) {
return self.contacts.some(m => m.pubkey === pubkey) ? true : false
},
+1 -1
View File
@@ -28,7 +28,7 @@ export const PaymentRequestModel = types
status: types.frozen<PaymentRequestStatus>(),
mint: types.maybe(types.string),
mintQuote: types.maybe(types.string),
mintUnit: types.maybe(types.frozen<MintUnit>()), // TODO migration to set default unit for existing PRs
mintUnit: types.maybe(types.frozen<MintUnit>()),
mintMethod: types.optional(types.frozen<MintMethod>(), MintMethod.BOLT11),
amountToTopup: types.maybe(types.number),
encodedInvoice: types.string,
+8
View File
@@ -6,6 +6,7 @@ import { Database } from '../services'
export interface TransactionRecord {
id: number
paymentId?: string | null
type: TransactionType
amount: number
fee: number
@@ -37,6 +38,7 @@ export enum TransactionType {
RECEIVE = 'RECEIVE',
RECEIVE_OFFLINE = 'RECEIVE_OFFLINE',
RECEIVE_NOSTR = 'RECEIVE_NOSTR', // not used
RECEIVE_BY_PAYMENT_REQUEST = 'RECEIVE_BY_PAYMENT_REQUEST',
TOPUP = 'TOPUP',
TRANSFER = 'TRANSFER',
NWC_TRANSFER = 'NWC_TRANSFER',
@@ -58,6 +60,7 @@ export enum TransactionStatus {
export const TransactionModel = types
.model('Transaction', {
id: types.identifierNumber,
paymentId: types.maybe(types.maybeNull(types.string)),
type: types.frozen<TransactionType>(),
amount: types.integer,
fee: types.optional(types.integer, 0),
@@ -108,6 +111,11 @@ export const TransactionModel = types
self.fee = fee
log.trace('[setFee]', 'Transaction fee updated', {id: self.id, fee})
},
setPaymentId(paymentId: string) {
Database.updatePaymentId(self.id!, paymentId)
self.paymentId = paymentId
log.trace('[setPaymentId]', 'Transaction paymentId updated', {id: self.id, paymentId})
},
setReceivedAmount(amount: number) {
Database.updateReceivedAmount(self.id!, amount)
self.amount = amount
+30 -1
View File
@@ -110,7 +110,36 @@ import {
}
return transaction
},
},
findByPaymentId(id: string) {
let transaction = self.transactionsMap.get(id)
// Search the db and add if tx is not in the state
// Search always to retrieve full tokens in tx detail screen
if(!transaction) {
const dbTransaction = Database.getTransactionByPaymentId(id)
if(dbTransaction) {
const createdAt = new Date(dbTransaction.createdAt)
const inStoreTransaction = {...dbTransaction, createdAt}
const {id} = dbTransaction
// Shorten for performance reasons
if(inStoreTransaction.inputToken && inStoreTransaction.inputToken.length > 0) {
inStoreTransaction.inputToken = inStoreTransaction.inputToken?.slice(0, 40)
}
if(inStoreTransaction.outputToken && inStoreTransaction.outputToken.length > 0) {
inStoreTransaction.outputToken = inStoreTransaction.outputToken?.slice(0, 40)
}
self.transactionsMap.set(id, inStoreTransaction)
transaction = self.transactionsMap.get(id)
}
}
return transaction
},
pruneRecentByUnit(unit: MintUnit) {
const unitCount = self.countRecentByUnit(unit)
+90 -7
View File
@@ -470,7 +470,9 @@ export const WalletStoreModel = types
return {proofs, swapFeePaid}
} catch (e: any) {
if(!e.message.toLowerCase().includes('timeout')) {
if(!e.message.toLowerCase().includes('timeout') &&
!e.message.toLowerCase().includes('network request failed')) {
// remove in-flight request only if it was not a timeout or network error
currentCounter.removeInFlightRequest(transactionId)
}
throw new AppError(
@@ -555,9 +557,11 @@ export const WalletStoreModel = types
}
} catch (e: any) {
if(!e.message.toLowerCase().includes('timeout')) {
currentCounter.removeInFlightRequest(transactionId)
}
if(!e.message.toLowerCase().includes('timeout') &&
!e.message.toLowerCase().includes('network request failed')) {
// remove in-flight request only if it was not a timeout or network error
currentCounter.removeInFlightRequest(transactionId)
}
let message = 'Swap to prepare ecash to send has failed.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
@@ -732,9 +736,11 @@ export const WalletStoreModel = types
return proofs
} catch (e: any) {
if(!e.message.toLowerCase().includes('timeout')) {
if(!e.message.toLowerCase().includes('timeout') &&
!e.message.toLowerCase().includes('network request failed')) {
// remove in-flight request only if it was not a timeout or network error
currentCounter.removeInFlightRequest(transactionId)
}
}
let message = 'Error on request to mint new ecash.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
@@ -841,7 +847,9 @@ export const WalletStoreModel = types
return meltResponse
} catch (e: any) {
if(!e.message.toLowerCase().includes('timeout')) {
if(!e.message.toLowerCase().includes('timeout') &&
!e.message.toLowerCase().includes('network request failed')) {
// remove in-flight request only if it was not a timeout or network error
currentCounter.removeInFlightRequest(transactionId)
}
@@ -858,6 +866,81 @@ export const WalletStoreModel = types
)
}
}),
checkLightningMeltQuote: flow(function* checkLightningMeltQuote(
mintUrl: string,
quote: string,
) {
try {
const cashuMint: CashuMint = yield self.getMint(mintUrl)
const quoteResponse: MeltQuoteResponse = yield cashuMint.checkMeltQuote(
quote
)
log.info('[checkLightningMeltQuote]', {quoteResponse})
return quoteResponse
} catch (e: any) {
let message = 'The mint could not return the state of a melt quote.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(
Err.MINT_ERROR,
message,
{
message: e.message,
caller: 'checkLightningMeltQuote',
mintUrl,
}
)
}
}),
recoverMeltQuoteChange: flow(function* recoverMeltQuoteChange(
mintUrl: string,
meltQuote: MeltQuoteResponse,
) {
try {
const mintInstance = self.getMintModelInstance(mintUrl)
if(!mintInstance) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint instance', {mintUrl})
}
const cashuWallet = yield self.getWallet(
mintUrl,
'sat',
{
withSeed: true,
}
)
const currentCounter = mintInstance.getProofsCounterByKeysetId!(cashuWallet.keysetId)
const {change}: MeltProofsResponse = yield cashuWallet.recoverMeltQuoteChange(
meltQuote,
{
keysetId: cashuWallet.keysetId,
counter: currentCounter.counter
}
)
log.info('[recoverMeltQuoteChange]', {change})
return change
} catch (e: any) {
let message = 'The mint could not return change from a melt quote.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(
Err.MINT_ERROR,
message,
{
message: e.message,
caller: 'recoverMeltQuoteChange',
mintUrl,
}
)
}
}),
restore: flow(function* restore(
mintUrl: string,
seed: Uint8Array,
+7 -3
View File
@@ -36,6 +36,7 @@ import {
OwnKeysScreen,
TokenReceiveScreen,
NwcScreen,
CashuPaymentRequestScreen
} from "../screens"
import { colors, spacing, typography } from "../theme"
@@ -56,7 +57,7 @@ const $tabBarLabel: TextStyle = {
flex: 1,
}
// @ts-ignore
const WalletStack = createNativeStackNavigator({
screenOptions: {
headerShown: false,
@@ -70,7 +71,8 @@ const WalletStack = createNativeStackNavigator({
LightningPay: LightningPayScreen,
PaymentRequests: PaymentRequestsScreen,
Transfer: TransferScreen,
Topup: TopupScreen,
Topup: TopupScreen,
CashuPaymentRequest: CashuPaymentRequestScreen,
}
})
@@ -81,7 +83,7 @@ declare global {
}
}
// @ts-ignore
const TransactionsStack = createNativeStackNavigator({
screenOptions: {
headerShown: false,
@@ -100,6 +102,7 @@ declare global {
}
}
// @ts-ignore
const ContactsStack = createNativeStackNavigator({
screenOptions: {
headerShown: false,
@@ -121,6 +124,7 @@ declare global {
}
}
// @ts-ignore
const SettingsStack = createNativeStackNavigator({
screenOptions: {
headerShown: false,
+624
View File
@@ -0,0 +1,624 @@
import React, { useRef, useState, useEffect } from "react"
import { observer } from "mobx-react-lite"
import { StackActions, StaticScreenProps, useNavigation } from "@react-navigation/native"
import QuickCrypto from 'react-native-quick-crypto'
import { View, TextInput, LayoutAnimation, Keyboard, ViewStyle, TextStyle } from "react-native"
import { spacing, useThemeColor, typography, colors } from "../theme"
import { useStores } from "../models"
import { MintBalanceSelector } from "./Mints/MintBalanceSelector"
import { CurrencyAmount } from "./Wallet/CurrencyAmount"
import EventEmitter from '../utils/eventEmitter'
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"
import { infoMessage } from "../utils/utils"
import numbro from "numbro"
import {
Screen,
Card,
Button,
Loading,
InfoModal,
ErrorModal,
BottomModal,
Text,
} from "../components"
import useIsInternetReachable from "../utils/useIsInternetReachable"
import {HANDLE_RECEIVED_EVENT_TASK, log, TransactionTaskResult, WalletTask } from "../services"
import { QRCodeBlock } from "./Wallet/QRCode"
import { TranItem } from "./TranDetailScreen"
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,
mintUrl?: string,
}>
export const CashuPaymentRequestScreen = observer(function CashuPaymentRequestScreen({ route }: Props) {
const isInternetReachable = useIsInternetReachable()
const navigation = useNavigation()
const {
proofsStore,
mintsStore,
walletStore,
userSettingsStore,
} = useStores()
const amountInputRef = useRef<TextInput>(null)
const memoInputRef = useRef<TextInput>(null)
const [amountToRequest, setAmountToRequest] = useState<string>("0")
const [unit, setUnit] = useState<MintUnit>("sat")
const [memo, setMemo] = useState("")
const [availableMintBalances, setAvailableMintBalances] = useState<any[]>([])
const [mintBalanceToReceiveTo, setMintBalanceToReceiveTo] = useState<any | undefined>(undefined)
const [transactionStatus, setTransactionStatus] = useState<TransactionStatus | undefined>()
const [transactionId, setTransactionId] = useState<number | undefined>()
const [transaction, setTransaction] = useState<Transaction | undefined>()
const [isCashuPaymentRequestTaskSentToQueue, setIsCashuPaymentRequestTaskSentToQueue] = useState(false)
const [isMintSelectorVisible, setIsMintSelectorVisible] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<AppError | undefined>()
const [info, setInfo] = useState("")
const [encodedPaymentRequest, setEncodedPaymentRequest] = useState<string | undefined>()
const [resultModalInfo, setResultModalInfo] = useState<
{status: TransactionStatus; title?: string; message: string} | undefined
>()
const [isResultModalVisible, setIsResultModalVisible] = useState(false)
useEffect(() => {
const focus = () => {
amountInputRef && amountInputRef.current
? amountInputRef.current.focus()
: false
}
const timer = setTimeout(() => focus(), 400)
return () => {
clearTimeout(timer)
}
}, [])
useEffect(() => {
const setUnitAndMint = () => {
try {
const {unit, mintUrl} = route.params
if (!unit) {
throw new AppError(
Err.VALIDATION_ERROR,
translate('missingMintUnitRouteParamsError')
)
}
setUnit(unit)
if (mintUrl) {
const mintBalance = proofsStore.getMintBalance(mintUrl)
setMintBalanceToReceiveTo(mintBalance)
}
} catch (e: any) {
handleError(e)
}
}
setUnitAndMint()
return () => {}
}, [])
useEffect(() => {
const handlePayReqTaskResult = async (result: TransactionTaskResult) => {
log.trace('handlePayReqTaskResult event handler triggered')
setIsLoading(false)
setIsMintSelectorVisible(false)
const {status, id} = result.transaction as Transaction
setTransactionStatus(status) // Should be PENDING
setTransactionId(id)
if (result.encodedCashuPaymentRequest) {
setEncodedPaymentRequest(result.encodedCashuPaymentRequest)
}
if (result.error) {
setResultModalInfo({
status: result.transaction?.status as TransactionStatus,
title: result.error.params?.message
? result.error.message
: 'Error',
message: result.error.params?.message || result.error.message,
})
setIsResultModalVisible(true)
return
}
}
// Subscribe to the payment request creation event
if( isCashuPaymentRequestTaskSentToQueue) {
EventEmitter.on(`ev_${CASHU_PAYMENT_REQUEST_TASK}_result`, handlePayReqTaskResult)
}
// Unsubscribe on component unmount
return () => {
EventEmitter.off(`ev_${CASHU_PAYMENT_REQUEST_TASK}_result`, handlePayReqTaskResult)
}
}, [isCashuPaymentRequestTaskSentToQueue])
useEffect(() => {
const handleReceivedPayReqPayloadTaskResult = (result: TransactionTaskResult) => {
log.trace('[handleReceivedPayReqPayloadTaskResult] event handler triggered', {transactionId, result})
if (!transactionId) {
return
}
// Filter and handle events only for this cashuPaymentRequest transactionId
if (result.transaction?.id === transactionId) {
// Show result modal only on completed payment
if (result.transaction.status !== TransactionStatus.COMPLETED) {
return
}
log.trace(
'[handleReceivedPayReqPayloadTaskResult]',
'Payment request has been paid and new proofs received',
)
setResultModalInfo({
status: result.transaction.status,
message: result.message,
})
setTransactionStatus(TransactionStatus.COMPLETED)
setTransaction(result.transaction)
setIsResultModalVisible(true)
}
}
if(transactionId) {
EventEmitter.on(
`ev_${HANDLE_RECEIVED_EVENT_TASK}_result`,
handleReceivedPayReqPayloadTaskResult,
)
}
return () => {
EventEmitter.off(
`ev_${HANDLE_RECEIVED_EVENT_TASK}_result`,
handleReceivedPayReqPayloadTaskResult,
)
}
}, [transactionId])
const toggleResultModal = () =>
setIsResultModalVisible(previousState => !previousState)
const onAmountEndEditing = () => {
log.trace("[onAmountEndEditing] called")
if(isCashuPaymentRequestTaskSentToQueue) {
log.trace('[onAmountEndEditing] Request task already sent to queue, ignoring further edits')
return
}
try {
const precision = getCurrency(unit).precision
const mantissa = getCurrency(unit).mantissa
const amountNum = round(toNumber(amountToRequest) * precision, 0)
if (!amountNum || amountNum <= 0) {
infoMessage(translate("payCommon.amountZeroOrNegative"))
return
}
const balances = proofsStore.getMintBalancesWithUnit(unit)
if (balances.length === 0) {
infoMessage(
translate("topup.missingMintAddFirst"),
translate("topup.missingMintAddFirstDesc"),
)
return
}
setAmountToRequest(
`${numbro(amountToRequest).format({
thousandSeparated: true,
mantissa,
})}`,
)
setAvailableMintBalances(balances)
if (!mintBalanceToReceiveTo) setMintBalanceToReceiveTo(balances[0])
LayoutAnimation.easeInEaseOut()
setIsMintSelectorVisible(true)
} catch (e: any) {
setError(e)
}
}
const onMemoEndEditing = () => {
log.trace("[onMemoEndEditing] called")
LayoutAnimation.easeInEaseOut()
if (availableMintBalances.length > 0) {
setIsMintSelectorVisible(true)
}
}
const onMemoDone = () => {
if (parseInt(amountToRequest) > 0) {
memoInputRef.current?.blur()
amountInputRef.current?.blur()
onMemoEndEditing()
} else {
amountInputRef.current?.focus()
}
}
const onMintBalanceSelect = (balance: any) => {
setMintBalanceToReceiveTo(balance)
}
const onMintBalanceCancel = () => {
setIsMintSelectorVisible(false)
}
const onMintBalanceConfirm = async () => {
if (!mintBalanceToReceiveTo) return
setIsLoading(true)
try {
const mintUrl = mintBalanceToReceiveTo.mintUrl
const mint = mintsStore.findByUrl(mintUrl)
if (!mint) throw new AppError(Err.NOTFOUND_ERROR, "Mint not found")
setIsCashuPaymentRequestTaskSentToQueue(true)
const amountInt = round(
toNumber(amountToRequest) * getCurrency(unit).precision,
0,
)
WalletTask.cashuPaymentRequestQueue(
mintBalanceToReceiveTo,
amountInt,
unit,
memo,
)
} catch (e: any) {
setError(e)
} finally {
setIsLoading(false)
setIsMintSelectorVisible(false)
}
}
const resetState = () => {
setAmountToRequest("0")
setMemo("")
setMintBalanceToReceiveTo(undefined)
setEncodedPaymentRequest(undefined)
setIsResultModalVisible(false)
}
const gotoWallet = () => {
resetState()
navigation.dispatch(
StackActions.popToTop()
)
}
const handleError = function (e: AppError): void {
setIsLoading(false)
setError(e)
}
const headerBg = useThemeColor("header")
const placeholderTextColor = useThemeColor("textDim")
const amountInputColor = useThemeColor("amountInput")
const inputText = useThemeColor("text")
const convertedAmountColor = useThemeColor('headerSubTitle')
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
)
}
const isConvertedAmountVisible = function () {
return (
walletStore.exchangeRate &&
(userSettingsStore.exchangeCurrency === getCurrency(unit).code ||
unit === 'sat') &&
getConvertedAmount() !== undefined
)
}
return (
<Screen preset="fixed" contentContainerStyle={$screen}>
<MintHeader
mint={
mintBalanceToReceiveTo
? mintsStore.findByUrl(mintBalanceToReceiveTo?.mintUrl)
: undefined
}
unit={unit}
/>
<View style={[$headerContainer, { backgroundColor: headerBg }]}>
<View style={$amountContainer}>
<TextInput
ref={amountInputRef}
onChangeText={setAmountToRequest}
onEndEditing={onAmountEndEditing}
value={amountToRequest}
style={[$amountInput, { color: amountInputColor }]}
maxLength={9}
keyboardType="numeric"
editable={
transactionStatus === TransactionStatus.PENDING ? false : true
}
selectTextOnFocus={true}
returnKeyType={"done"}
/>
{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"
text={translate("amount.requested")}
style={{
color: amountInputColor,
textAlign: "center",
marginTop: isConvertedAmountVisible()
? -spacing.extraSmall
: undefined,
}}
/>
</View>
</View>
<View style={$contentContainer}>
{!encodedPaymentRequest && (
<Card
style={$memoCard}
ContentComponent={
<View style={$memoContainer}>
<TextInput
ref={memoInputRef}
onChangeText={setMemo}
onEndEditing={onMemoEndEditing}
value={memo}
style={[$memoInput, { color: inputText }]}
maxLength={200}
keyboardType="default"
selectTextOnFocus={true}
placeholder={translate("payerMemo")}
placeholderTextColor={placeholderTextColor}
/>
<Button
preset="secondary"
style={$memoButton}
text="Done"
onPress={onMemoDone}
/>
</View>
}
/>
)}
{isMintSelectorVisible && (
<MintBalanceSelector
mintBalances={availableMintBalances}
selectedMintBalance={mintBalanceToReceiveTo}
unit={unit}
title={'Mint to receive'}
confirmTitle={translate("cashuPaymentRequest.create")}
onMintBalanceSelect={onMintBalanceSelect}
onCancel={onMintBalanceCancel}
onMintBalanceConfirm={onMintBalanceConfirm}
/>
)}
{transactionStatus === TransactionStatus.PENDING &&
encodedPaymentRequest && (
<>
<QRCodeBlock
qrCodeData={encodedPaymentRequest}
title='Cashu Payment request'
type='PaymentRequest'
size={270}
/>
</>
)}
{transaction && transactionStatus === TransactionStatus.COMPLETED && (
<>
<Card
style={{padding: spacing.medium}}
ContentComponent={
<>
<TranItem
label="cashuPaymentRequest.to"
isFirst={true}
value={
mintsStore.findByUrl(transaction.mint)
?.shortname as string
}
/>
{transaction.memo && (
<TranItem
label="receiverMemo"
value={transaction?.memo as string}
/>
)}
<TranItem
label="transactionCommon.feePaid"
value={transaction.fee || 0}
unit={unit}
isCurrency={true}
/>
<TranItem
label="tranDetailScreen.status"
value={transaction.status as string}
/>
</>
}
/>
<View style={$bottomContainer}>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx='common.close'
onPress={gotoWallet}
/>
</View>
</View>
</>
)}
</View>
<BottomModal
isVisible={isResultModalVisible ? true : false}
ContentComponent={
<>
{resultModalInfo &&
transactionStatus === TransactionStatus.COMPLETED && (
<>
<ResultModalInfo
icon="faCheckCircle"
iconColor={colors.palette.success200}
title={translate('common.success')}
message={resultModalInfo?.message}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx='common.close'
onPress={gotoWallet}
/>
</View>
</>
)}
{resultModalInfo &&
transactionStatus === TransactionStatus.ERROR && (
<>
<ResultModalInfo
icon="faTriangleExclamation"
iconColor={colors.palette.angry500}
title={resultModalInfo?.title || translate('topup.failed')}
message={resultModalInfo?.message}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx='common.close'
onPress={toggleResultModal}
/>
</View>
</>
)}
</>
}
onBackButtonPress={toggleResultModal}
onBackdropPress={toggleResultModal}
/>
{error && <ErrorModal error={error} />}
{info && <InfoModal message={info} />}
{isLoading && <Loading />}
</Screen>
)
})
const $screen: ViewStyle = {
flex: 1,
}
const $headerContainer: TextStyle = {
alignItems: "center",
padding: spacing.extraSmall,
paddingTop: 0,
height: spacing.screenHeight * 0.2,
}
const $amountContainer: ViewStyle = {}
const $amountInput: TextStyle = {
borderRadius: spacing.small,
margin: 0,
padding: 0,
fontSize: 48,
fontFamily: typography.primary?.medium,
textAlign: "center",
color: "white",
}
const $contentContainer: TextStyle = {
flex: 1,
padding: spacing.extraSmall,
marginTop: -spacing.extraLarge * 1.5,
}
const $memoCard: ViewStyle = {
marginBottom: spacing.small,
minHeight: 80,
}
const $memoContainer: ViewStyle = {
flex: 1,
flexDirection: "row",
justifyContent: "center",
}
const $memoInput: TextStyle = {
flex: 1,
borderRadius: spacing.small,
fontSize: 16,
textAlignVertical: "center",
marginRight: spacing.small,
}
const $memoButton: ViewStyle = {
maxHeight: 50,
}
const $bottomModal: ViewStyle = {
alignItems: "center",
paddingVertical: spacing.large,
}
const $buttonContainer: ViewStyle = {
flexDirection: 'row',
alignSelf: 'center',
}
const $bottomContainer: ViewStyle = {
// position: 'absolute',
bottom: 0,
left: 0,
right: 0,
flex: 1,
justifyContent: 'flex-end',
marginBottom: spacing.medium,
alignSelf: 'stretch',
// opacity: 0,
}
+51 -7
View File
@@ -28,7 +28,7 @@ import {ResultModalInfo} from './Wallet/ResultModalInfo'
import {MintListItem} from './Mints/MintListItem'
import useIsInternetReachable from '../utils/useIsInternetReachable'
import { verticalScale } from '@gocodingnow/rn-size-matters'
import { CurrencyCode, MintUnit, getCurrency } from "../services/wallet/currency"
import { CurrencyCode, MintUnit, convertToFromSats, getCurrency } from "../services/wallet/currency"
import { MintHeader } from './Mints/MintHeader'
import { round, toNumber } from '../utils/number'
import numbro from 'numbro'
@@ -36,6 +36,7 @@ import { TranItem } from './TranDetailScreen'
import { translate } from '../i18n'
import { Token, getDecodedToken } from '@cashu/cashu-ts'
import { RECEIVE_OFFLINE_PREPARE_TASK, RECEIVE_TASK } from '../services/wallet/receiveTask'
import { CurrencyAmount } from './Wallet/CurrencyAmount'
export enum ReceiveOption {
SEND_PAYMENT_REQUEST = 'SEND_PAYMENT_REQUEST',
@@ -51,7 +52,7 @@ type Props = StaticScreenProps<{
export const ReceiveScreen = observer(function ReceiveScreen({ route }: Props) {
const navigation = useNavigation()
const isInternetReachable = useIsInternetReachable()
const {mintsStore, walletStore} = useStores()
const {mintsStore, walletStore, userSettingsStore} = useStores()
const [token, setToken] = useState<Token | undefined>()
const [encodedToken, setEncodedToken] = useState<string | undefined>()
@@ -177,6 +178,7 @@ export const ReceiveScreen = observer(function ReceiveScreen({ route }: Props) {
const onEncodedToken = async function (encoded: string) {
try {
//@ts-ignore
navigation.setParams({encodedToken: undefined})
const decoded = getDecodedToken(encoded)
@@ -291,6 +293,30 @@ export const ReceiveScreen = observer(function ReceiveScreen({ route }: Props) {
const iconColor = useThemeColor('textDim')
const amountInputColor = useThemeColor('amountInput')
const convertedAmountColor = useThemeColor('headerSubTitle')
const getConvertedAmount = function () {
if (!walletStore.exchangeRate) {
return undefined
}
const precision = getCurrency(unit).precision
return convertToFromSats(
round(toNumber(amountToReceive) * precision, 0) || 0,
getCurrency(unit).code,
walletStore.exchangeRate
)
}
const isConvertedAmountVisible = function () {
return (
walletStore.exchangeRate &&
(userSettingsStore.exchangeCurrency === getCurrency(unit).code ||
unit === 'sat') &&
getConvertedAmount() !== undefined
)
}
return (
<Screen preset="auto" contentContainerStyle={$screen}>
<MintHeader
@@ -306,8 +332,22 @@ export const ReceiveScreen = observer(function ReceiveScreen({ route }: Props) {
editable={false}
/>
</View>
{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'}}
/>
)}
{isP2PKLocked ? (
<View style={{flexDirection: 'row', alignItems: 'center'}}>
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginTop: isConvertedAmountVisible() ? -spacing.extraSmall : undefined
}}>
<Icon
containerStyle={$iconLockContainer}
icon={toNumber(receivedAmount) > 0 ? "faLockOpen" : "faLock"}
@@ -316,13 +356,13 @@ export const ReceiveScreen = observer(function ReceiveScreen({ route }: Props) {
/>
{isP2PKLockedToWallet ? (
<Text
size='sm'
size='xs'
tx={toNumber(receivedAmount) > 0 ? "receiveScreen.received" : "receiveScreen.lockedToWalletPK"}
style={{color: amountInputColor, textAlign: 'center'}}
/>
) : (
<Text
size='sm'
size='xs'
tx={toNumber(receivedAmount) > 0 ? "receiveScreen.received" : "receiveScreen.lockedToUnknownPK"}
style={{color: amountInputColor, textAlign: 'center'}}
/>
@@ -330,9 +370,13 @@ export const ReceiveScreen = observer(function ReceiveScreen({ route }: Props) {
</View>
) : (
<Text
size='sm'
size='xs'
tx={toNumber(receivedAmount) > 0 ? "receiveScreen.received" : "receiveScreen.toReceive"}
style={{color: amountInputColor, textAlign: 'center'}}
style={{
color: amountInputColor,
textAlign: 'center',
marginTop: isConvertedAmountVisible() ? -spacing.extraSmall : undefined
}}
/>
)}
+220 -27
View File
@@ -49,14 +49,20 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen({ r
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<AppError | undefined>()
const [info, setInfo] = useState('')
const [mintBalanceToMintFrom, setMintBalanceToMintFrom] = useState<MintBalance>(mintBalancesRef.current[0])
const [mintBalanceToRecoverFrom, setMintBalanceToRecoverFrom] = useState<MintBalance>(mintBalancesRef.current[0])
const [mintQuote, setMintQuote] = useState<string>('')
const [meltQuote, setMeltQuote] = useState<string>('')
const [mintedAmount, setMintedAmount] = useState<number>(0)
const [meltChangeAmount, setMeltChangeAmount] = useState<number>(0)
const [isSyncStateSentToQueue, setIsSyncStateSentToQueue] = useState<boolean>(false)
const [isNotificationModalVisible, setIsNotificationModalVisible] = useState(false)
const [isNotificationModalVisible, setIsNotificationModalVisible] = useState(false)
const [isMintQuoteRecoveryStarted, setIsMintQuoteRecoveryStarted] = useState(false)
const [isMeltQuoteRecoveryStarted, setIsMeltQuoteRecoveryStarted] = useState(false)
const [isMintSelectorModalVisible, setIsMintSelectorModalVisible] = useState(false)
const [isMintQuoteModalVisible, setIsMintQuoteModalVisible] = useState(false)
const [isMintQuoteResultModalVisible, setIsMintQuoteResultModalVisible] = useState(false)
const [isMintQuoteModalVisible, setIsMintQuoteModalVisible] = useState(false)
const [isMeltQuoteModalVisible, setIsMeltQuoteModalVisible] = useState(false)
const [isMintQuoteResultModalVisible, setIsMintQuoteResultModalVisible] = useState(false)
const [isMeltQuoteResultModalVisible, setIsMeltQuoteResultModalVisible] = useState(false)
useEffect(() => {
@@ -100,9 +106,15 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen({ r
setIsMintQuoteModalVisible(previousState => !previousState)
const toggleMeltQuoteModal = () =>
setIsMeltQuoteModalVisible(previousState => !previousState)
const toggleMintQuoteResultModal = () =>
setIsMintQuoteResultModalVisible(previousState => !previousState)
const toggleMeltQuoteResultModal = () =>
setIsMeltQuoteResultModalVisible(previousState => !previousState)
const gotoSeedRecovery = function () {
//@ts-ignore
@@ -156,19 +168,30 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen({ r
}
const onMintBalanceSelect = function (balance: MintBalance) {
setMintBalanceToMintFrom(balance)
setMintBalanceToRecoverFrom(balance)
}
const onMintBalanceConfirm = function () {
toggleMintSelectorModal()
if(mintBalanceToMintFrom) {
if(setMintBalanceToRecoverFrom) {
if(Platform.OS === 'ios') {
setTimeout(() => {
toggleMintQuoteModal()
if(isMeltQuoteRecoveryStarted) {
toggleMeltQuoteModal()
}
if(isMintQuoteRecoveryStarted) {
toggleMintQuoteModal()
}
}, 500)
} else {
toggleMintQuoteModal()
if(isMeltQuoteRecoveryStarted) {
toggleMeltQuoteModal()
}
if(isMintQuoteRecoveryStarted) {
toggleMintQuoteModal()
}
}
}
}
@@ -177,20 +200,29 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen({ r
toggleMintSelectorModal()
}
const onPasteQuote = async function () {
const onPasteMintQuote = async function () {
const quote = await Clipboard.getString()
if (!quote || quote.length !== 40) {
setInfo('Invalid mint quote')
return
}
setMintQuote(quote)
}
}
const onPasteMeltQuote = async function () {
const quote = await Clipboard.getString()
if (!quote || quote.length !== 40) {
setInfo('Invalid melt quote')
return
}
setMeltQuote(quote)
}
const onMintEcashFromQuote = async function () {
setIsLoading(true)
toggleMintQuoteModal()
try {
if(!mintBalanceToMintFrom) {
if(!mintBalanceToRecoverFrom) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint is not selected.')
}
@@ -199,7 +231,7 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen({ r
}
const result = await WalletTask.recoverMintQuote({
mintUrl: mintBalanceToMintFrom.mintUrl,
mintUrl: mintBalanceToRecoverFrom.mintUrl,
mintQuote
})
@@ -208,6 +240,7 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen({ r
if(result.recoveredAmount > 0) {
setMintedAmount(result.recoveredAmount)
toggleMintQuoteResultModal()
resetState()
} else {
throw new AppError(Err.MINT_ERROR, 'Could not mint ecash from provided mint quote.')
}
@@ -216,17 +249,74 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen({ r
}
}
const onRecoverChangeFromQuote = async function () {
setIsLoading(true)
toggleMeltQuoteModal()
try {
if(!mintBalanceToRecoverFrom) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint is not selected.')
}
if(meltQuote.length !== 40) {
throw new AppError(Err.VALIDATION_ERROR, 'Melt quote must have 40 characters.')
}
const result = await WalletTask.recoverMeltQuoteChange({
mintUrl: mintBalanceToRecoverFrom.mintUrl,
meltQuote
})
setIsLoading(false)
if(result.recoveredAmount > 0) {
setMeltChangeAmount(result.recoveredAmount)
toggleMeltQuoteResultModal()
resetState()
} else {
throw new AppError(Err.MINT_ERROR, 'Could not recover any change from provided melt quote.')
}
} catch (e: any) {
handleError(e)
}
}
const handleError = function (e: AppError): void {
log.error(e.name, e.message)
setIsLoading(false)
resetState()
setError(e)
}
const startMeltQuoteRecovery = function(): void {
setIsMeltQuoteRecoveryStarted(true)
toggleMintSelectorModal()
}
const startMintQuoteRecovery = function(): void {
setIsMintQuoteRecoveryStarted(true)
toggleMintSelectorModal()
}
const resetState = function () {
setIsLoading(false)
setError(undefined)
setInfo('')
setMintedAmount(0)
setMeltChangeAmount(0)
setMintQuote('')
setMeltQuote('')
setIsSyncStateSentToQueue(false)
setIsMintQuoteRecoveryStarted(false)
setIsMeltQuoteRecoveryStarted(false)
setMintBalanceToRecoverFrom(mintBalancesRef.current[0])
}
const headerBg = useThemeColor('header')
const inputBg = useThemeColor('background')
const mintsModalBg = useThemeColor('background')
const inputText = useThemeColor('text')
return (
<Screen preset="fixed" contentContainerStyle={$screen}>
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
@@ -326,10 +416,11 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen({ r
}
/>
<Card
style={$card}
HeadingComponent={
<>
<ListItem
label='Experimental tools'
style={[$card, {marginBottom: spacing.huge * 2}]}
HeadingComponent={
<>
<ListItem
text="Recover mint quote"
subText="Retry to mint ecash from an already paid mint quote."
leftIcon='faCoins'
@@ -338,18 +429,36 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen({ r
RightComponent={
<View style={$rightContainer}>
<Button
onPress={toggleMintSelectorModal}
onPress={startMintQuoteRecovery}
text='Start'
preset='secondary'
/>
</View>
}
style={$item}
/>
</>
}
/>
<ListItem
text="Recover melt quote change"
subText="Retry to receive back unspent ecash from a completed Lightning payment."
leftIcon='faArrowTurnDown'
leftIconColor={colors.palette.iconGreyBlue400}
leftIconInverse={true}
topSeparator={true}
RightComponent={
<View style={$rightContainer}>
<Button
onPress={startMeltQuoteRecovery}
text='Start'
preset='secondary'
/>
</View>
}
style={$item}
/>
</>
}
/>
</>
</>
)}
{isLoading && <Loading />}
{error && <ErrorModal error={error} />}
@@ -389,7 +498,7 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen({ r
confirmTitle={'Confirm'}
collapsible={false}
onMintBalanceSelect={onMintBalanceSelect}
selectedMintBalance={mintBalanceToMintFrom}
selectedMintBalance={mintBalanceToRecoverFrom}
onCancel={onMintBalanceCancel}
onMintBalanceConfirm={onMintBalanceConfirm}
/>
@@ -418,7 +527,38 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen({ r
tx={'common.paste'}
preset='secondary'
style={$pasteButton}
onPress={onPasteQuote}
onPress={onPasteMintQuote}
/>
</View>
<View style={[$buttonContainer, {marginTop: spacing.medium}]}>
<Button onPress={onMintEcashFromQuote} text='Mint ecash' />
</View>
</View>
}
onBackButtonPress={toggleMintQuoteModal}
onBackdropPress={toggleMintQuoteModal}
/>
<BottomModal
isVisible={isMintQuoteModalVisible ? true : false}
ContentComponent={
<View style={$quoteContainer}>
<Text text='Enter mint quote' />
<View style={{flexDirection: 'row', alignItems: 'center', marginTop: spacing.small}}>
<TextInput
ref={mintQuoteInputRef}
onChangeText={(quote) => setMintQuote(quote)}
value={mintQuote}
autoCapitalize='none'
keyboardType='default'
maxLength={40}
selectTextOnFocus={true}
style={[$quoteInput, {backgroundColor: inputBg, color: inputText}]}
/>
<Button
tx={'common.paste'}
preset='secondary'
style={$pasteButton}
onPress={onPasteMintQuote}
/>
</View>
<View style={[$buttonContainer, {marginTop: spacing.medium}]}>
@@ -451,13 +591,66 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen({ r
onBackButtonPress={toggleMintQuoteResultModal}
onBackdropPress={toggleMintQuoteResultModal}
/>
<BottomModal
isVisible={isMeltQuoteModalVisible ? true : false}
ContentComponent={
<View style={$quoteContainer}>
<Text text='Enter melt quote' />
<View style={{flexDirection: 'row', alignItems: 'center', marginTop: spacing.small}}>
<TextInput
ref={mintQuoteInputRef}
onChangeText={(quote) => setMeltQuote(quote)}
value={meltQuote}
autoCapitalize='none'
keyboardType='default'
maxLength={40}
selectTextOnFocus={true}
style={[$quoteInput, {backgroundColor: inputBg, color: inputText}]}
/>
<Button
tx={'common.paste'}
preset='secondary'
style={$pasteButton}
onPress={onPasteMeltQuote}
/>
</View>
<View style={[$buttonContainer, {marginTop: spacing.medium}]}>
<Button onPress={onRecoverChangeFromQuote} text='Recover change' />
</View>
</View>
}
onBackButtonPress={toggleMintQuoteModal}
onBackdropPress={toggleMintQuoteModal}
/>
<BottomModal
isVisible={isMeltQuoteResultModalVisible ? true : false}
ContentComponent={
<>
<ResultModalInfo
icon="faCheckCircle"
iconColor={colors.palette.success200}
title="Success!"
message={`Successfully recovered ${meltChangeAmount} SAT from the provided melt quote.`}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx={'common.close'}
onPress={toggleMeltQuoteResultModal}
/>
</View>
</>
}
onBackButtonPress={toggleMeltQuoteResultModal}
onBackdropPress={toggleMeltQuoteResultModal}
/>
</Screen>
)
},
)
const $screen: ViewStyle = {
flex: 1,
//flex: 1,
}
const $headerContainer: TextStyle = {
@@ -467,7 +660,7 @@ const $headerContainer: TextStyle = {
}
const $contentContainer: TextStyle = {
flex: 1,
//flex: 1,
marginTop: -spacing.extraLarge * 2,
padding: spacing.extraSmall,
// alignItems: 'center',
+90 -29
View File
@@ -48,7 +48,7 @@ import { Proof } from '../models/Proof'
import { Contact, ContactType } from '../models/Contact'
import { getImageSource, infoMessage } from '../utils/utils'
import { verticalScale } from '@gocodingnow/rn-size-matters'
import { CurrencyCode, MintUnit, MintUnits, formatCurrency, getCurrency } from "../services/wallet/currency"
import { CurrencyCode, MintUnit, MintUnits, convertToFromSats, formatCurrency, getCurrency } from "../services/wallet/currency"
import { MintHeader } from './Mints/MintHeader'
import { MintBalanceSelector } from './Mints/MintBalanceSelector'
import { round, toNumber } from '../utils/number'
@@ -96,7 +96,8 @@ export const SendScreen = observer(function SendScreen({ route }: Props) {
mintsStore,
relaysStore,
walletStore,
contactsStore
contactsStore,
userSettingsStore
} = useStores()
const amountInputRef = useRef<TextInput>(null)
@@ -961,8 +962,31 @@ const pubkeyInputRef = useRef<TextInput>(null) // Initialize pubkeyInputRef
navigation.navigate('Scan', {
unit: unitRef.current,
mintUrl: mintBalanceToSendFrom.mintUrl
})
})
}
const convertedAmountColor = useThemeColor('headerSubTitle')
const getConvertedAmount = function () {
if (!walletStore.exchangeRate) {
return undefined
}
const precision = getCurrency(unitRef.current).precision
return convertToFromSats(
round(toNumber(amountToSend) * precision, 0) || 0,
getCurrency(unitRef.current).code,
walletStore.exchangeRate
)
}
const isConvertedAmountVisible = function () {
return (
walletStore.exchangeRate &&
(userSettingsStore.exchangeCurrency === getCurrency(unitRef.current).code ||
unitRef.current === 'sat') &&
getConvertedAmount() !== undefined
)
}
return (
@@ -989,17 +1013,23 @@ const pubkeyInputRef = useRef<TextInput>(null) // Initialize pubkeyInputRef
}
returnKeyType={'done'}
/>
<Text
size='sm'
text="Amount to send"
style={{color: amountInputColor, textAlign: 'center'}}
/>
{lockedPubkey && (
{isConvertedAmountVisible() && (
<CurrencyAmount
amount={getConvertedAmount() ?? 0}
currencyCode={unitRef.current === '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'}}
/>
)}
{lockedPubkey ? (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center'
justifyContent: 'center',
marginTop: isConvertedAmountVisible() ? -spacing.extraSmall : undefined
}}
>
<Icon
@@ -1008,12 +1038,22 @@ const pubkeyInputRef = useRef<TextInput>(null) // Initialize pubkeyInputRef
color={amountInputColor}
/>
<Text
size='xxs'
size='xs'
text="Locked"
style={{color: amountInputColor, marginLeft: spacing.tiny}}
/>
</View>
) : (
<Text
size='xs'
tx='amount.send'
style={{
color: amountInputColor,
textAlign: 'center',
marginTop: isConvertedAmountVisible() ? -spacing.extraSmall : undefined
}}
/>
)}
</View>
</View>
@@ -1028,7 +1068,7 @@ const pubkeyInputRef = useRef<TextInput>(null) // Initialize pubkeyInputRef
onMemoEndEditing={onMemoEndEditing}
/>
)}
{isMintSelectorVisible && (
{isMintSelectorVisible && !encodedTokenToSend && (
<MintBalanceSelector
mintBalances={availableMintBalances}
selectedMintBalance={mintBalanceToSendFrom as MintBalance}
@@ -1387,6 +1427,7 @@ const SelectProofsBlock = observer(function (props: {
const {proofsStore} = useStores()
const hintColor = useThemeColor('textDim')
const statusColor = useThemeColor('header')
const onCancel = function () {
@@ -1396,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 }) => {
@@ -1424,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"
+116 -102
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,17 +108,13 @@ export const TokenReceiveScreen = function TokenReceiveScreen({ route }: Props)
})
}
/* const gotoContacts = function () {
const gotoCashuPaymentRequest = async function () {
//@ts-ignore
navigation.navigate('ContactsNavigator', {
screen: 'Contacts',
params: {
paymentOption: SendOption.LNURL_ADDRESS
}
navigation.navigate('CashuPaymentRequest', {
mintUrl: mint?.mintUrl,
unit
})
} */
}
const onConfirm = async function() {
if(!encodedToken) {
@@ -126,7 +132,6 @@ export const TokenReceiveScreen = function TokenReceiveScreen({ route }: Props)
}
}
const gotoTopup = async function () {
//@ts-ignore
navigation.navigate('Topup', {
@@ -135,7 +140,6 @@ export const TokenReceiveScreen = function TokenReceiveScreen({ route }: Props)
})
}
const handleError = function(e: AppError): void {
setError(e)
}
@@ -146,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}>
@@ -162,86 +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"
/>
<View style={{alignItems: 'center', marginTop: spacing.small}}>
<TextInput
ref={tokenInputRef}
onChangeText={data => setEncodedToken(data)}
value={encodedToken}
autoCapitalize='none'
keyboardType='default'
maxLength={5000}
numberOfLines={4}
multiline={true}
selectTextOnFocus={true}
style={[$addressInput, {backgroundColor: inputBg, color: inputText}]}
<ListItem
leftIcon='faMoneyBill1'
tx="common.pasteEcashToken"
bottomSeparator={showTokenInput}
onPress={() => setShowTokenInput((v) => !v)}
/>
</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}
/>
</>
}
style={$card}
//style={{marginBottom: spacing.medium}}
/>
<Button
tx="tokenReceiveScreen.topupWithLightning"
@@ -263,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>
)
@@ -275,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 = {
@@ -288,29 +297,34 @@ const $headerContainer: TextStyle = {
}
const $buttonContainer: ViewStyle = {
marginTop: spacing.large,
//marginTop: spacing.large,
flexDirection: 'row',
alignSelf: 'center',
}
const $addressInput: TextStyle = {
textAlignVertical: 'top' ,
borderRadius: spacing.extraSmall,
padding: spacing.extraSmall,
alignSelf: 'stretch',
height: 120,
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,
}
}
+63 -9
View File
@@ -50,10 +50,13 @@ import {LnurlClient, LnurlWithdrawResult} from '../services/lnurlService'
import {
verticalScale,
} from '@gocodingnow/rn-size-matters'
import { CurrencyAmount } from './Wallet/CurrencyAmount'
import {
CurrencyCode,
MintUnit,
getCurrency,
convertToFromSats
} from '../services/wallet/currency'
import {MintHeader} from './Mints/MintHeader'
import useIsInternetReachable from '../utils/useIsInternetReachable'
@@ -83,6 +86,8 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
walletProfileStore,
transactionsStore,
relaysStore,
walletStore,
userSettingsStore
} = useStores()
const amountInputRef = useRef<TextInput>(null)
const memoInputRef = useRef<TextInput>(null)
@@ -304,7 +309,9 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
}
// Subscribe to the 'sendCompleted' event
EventEmitter.on(`ev_${TOPUP_TASK}_result`, handleTopupTaskResult)
if(isTopupTaskSentToQueue) {
EventEmitter.on(`ev_${TOPUP_TASK}_result`, handleTopupTaskResult)
}
// Unsubscribe from the 'sendCompleted' event on component unmount
return () => {
@@ -346,10 +353,12 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
}
}
EventEmitter.on(
`ev_${HANDLE_PENDING_TOPUP_TASK}_result`,
handlePendingTopupTaskResult,
)
if(transactionId) {
EventEmitter.on(
`ev_${HANDLE_PENDING_TOPUP_TASK}_result`,
handlePendingTopupTaskResult,
)
}
return () => {
EventEmitter.off(
@@ -367,6 +376,13 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
setIsResultModalVisible(previousState => !previousState)
const onAmountEndEditing = function () {
log.trace("[onAmountEndEditing] called")
if(isTopupTaskSentToQueue) {
log.trace('[onAmountEndEditing] Topup task already sent to queue, ignoring further edits')
return
}
try {
const precision = getCurrency(unit).precision
const mantissa = getCurrency(unit).mantissa
@@ -648,6 +664,30 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
const placeholderTextColor = useThemeColor('textDim')
const inputText = useThemeColor('text')
const amountInputColor = useThemeColor('amountInput')
const convertedAmountColor = useThemeColor('headerSubTitle')
const getConvertedAmount = function () {
if (!walletStore.exchangeRate) {
return undefined
}
const precision = getCurrency(unit).precision
return convertToFromSats(
round(toNumber(amountToTopup) * precision, 0) || 0,
getCurrency(unit).code,
walletStore.exchangeRate
)
}
const isConvertedAmountVisible = function () {
return (
walletStore.exchangeRate &&
(userSettingsStore.exchangeCurrency === getCurrency(unit).code ||
unit === 'sat') &&
getConvertedAmount() !== undefined
)
}
return (
<Screen preset="fixed" contentContainerStyle={$screen}>
@@ -674,11 +714,25 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
transactionStatus === TransactionStatus.PENDING ? false : true
}
returnKeyType={'done'}
/>
/>
{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="sm"
size="xs"
text={getAmountTitle()}
style={{color: amountInputColor, textAlign: 'center'}}
style={{
color: amountInputColor,
textAlign: 'center',
marginTop: isConvertedAmountVisible() ? -spacing.extraSmall : undefined
}}
/>
</View>
</View>
@@ -751,7 +805,7 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
gotoContacts={gotoContacts}
/>
</>
)}
)}
{transaction && transactionStatus === TransactionStatus.COMPLETED && (
<Card
style={{padding: spacing.medium}}
+59 -6
View File
@@ -216,7 +216,7 @@ export const TranDetailScreen = observer(function TranDetailScreen({ route }: Pr
if (!transaction) { return '' }
switch (transaction?.type) {
case TransactionType.RECEIVE || TransactionType.RECEIVE_OFFLINE:
case TransactionType.RECEIVE || TransactionType.RECEIVE_OFFLINE || TransactionType.RECEIVE_BY_PAYMENT_REQUEST:
return `+${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
case TransactionType.SEND:
return `-${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
@@ -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
@@ -298,7 +324,7 @@ export const TranDetailScreen = observer(function TranDetailScreen({ route }: Pr
</View>
}
/>
{transaction.type === TransactionType.RECEIVE && (
{(transaction.type === TransactionType.RECEIVE || transaction.type === TransactionType.RECEIVE_BY_PAYMENT_REQUEST) && (
<ReceiveInfoBlock
transaction={transaction}
isDataParsable={isDataParsable}
@@ -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')) {
@@ -116,6 +124,8 @@ export const TransactionListItem = observer(function (props: TransactionListProp
return translate('transactionCommon.status.completed')
}
}
case TransactionStatus.RECOVERED:
return timeAgo + translate('transactionCommon.status.recovered')
case TransactionStatus.DRAFT:
return timeAgo + translate('transactionCommon.status.draft')
case TransactionStatus.ERROR:
@@ -155,7 +165,7 @@ export const TransactionListItem = observer(function (props: TransactionListProp
return (<Icon containerStyle={$txIconContainer} icon="faClock" size={spacing.medium} color={txErrorColor}/>)
}
if([TransactionType.RECEIVE, TransactionType.TOPUP].includes(tx.type)) {
if([TransactionType.RECEIVE, TransactionType.TOPUP, TransactionType.RECEIVE_BY_PAYMENT_REQUEST].includes(tx.type)) {
if(tx.profile) {
const profilePicture = getProfilePicture(tx.profile)
if(profilePicture) {
@@ -169,6 +179,15 @@ export const TransactionListItem = observer(function (props: TransactionListProp
return (<Icon containerStyle={$txIconContainer} icon="faArrowTurnDown" size={spacing.medium} color={txPendingColor}/>)
}
if([TransactionType.SEND, TransactionType.TRANSFER].includes(tx.type)) {
if(tx.profile) {
const profilePicture = getProfilePicture(tx.profile)
if(profilePicture) {
return profilePicture
}
}
}
return (<Icon containerStyle={$txIconContainer} icon="faArrowTurnUp" size={spacing.medium} color={txSendColor}/>)
}
@@ -224,9 +243,9 @@ export const TransactionListItem = observer(function (props: TransactionListProp
LeftComponent={getLeftIcon(tx)}
RightComponent={
<View style={$txContainer}>
{([TransactionType.RECEIVE, TransactionType.RECEIVE_OFFLINE].includes(tx.type)) && (
{([TransactionType.RECEIVE, TransactionType.RECEIVE_OFFLINE, TransactionType.RECEIVE_BY_PAYMENT_REQUEST].includes(tx.type)) && (
<>
{[TransactionStatus.COMPLETED].includes(tx.status) && (
{[TransactionStatus.COMPLETED, TransactionStatus.RECOVERED].includes(tx.status) && (
<CurrencyAmount
amount={tx.amount}
mintUnit={tx.unit}
+71 -9
View File
@@ -50,6 +50,7 @@ import useIsInternetReachable from '../utils/useIsInternetReachable'
import { translate } from '../i18n'
import { MemoInputCard } from '../components/MemoInputCard'
import { TRANSFER_TASK } from '../services/wallet/transferTask'
import { CurrencyAmount } from './Wallet/CurrencyAmount'
type Props = StaticScreenProps<{
unit: MintUnit,
@@ -69,7 +70,15 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
const amountInputRef = useRef<TextInput>(null)
const lnurlCommentInputRef = useRef<TextInput>(null)
const {proofsStore, mintsStore, paymentRequestsStore, walletStore, walletProfileStore} = useStores()
const {
proofsStore,
mintsStore,
paymentRequestsStore,
walletStore,
walletProfileStore,
contactsStore,
userSettingsStore
} = useStores()
// const {walletStore} = nonPersistedStores
const isInternetReachable = useIsInternetReachable()
@@ -377,9 +386,15 @@ useEffect(() => {
setTransaction(transaction)
if(transaction && lnurlPayParams && lnurlPayParams.address) {
transaction.setSentTo(
lnurlPayParams.address as string
)
const profile = contactsStore.findByLud16(lnurlPayParams.address)
if (profile) {
transaction.setSentTo(profile.nip05 || profile.name)
transaction.setProfile(JSON.stringify(profile))
} else {
transaction.setSentTo(lnurlPayParams.address)
}
}
if (error) { // This handles timed out pending payments
@@ -583,12 +598,19 @@ const onEncodedInvoice = async function (encoded: string, paymentRequestDesc: st
log.trace("[onEncodedInvoice] start", {mintUrl, unit})
try {
//@ts-ignore
navigation.setParams({encodedInvoice: undefined})
//@ts-ignore
navigation.setParams({paymentRequest: undefined})
//@ts-ignore
navigation.setParams({lnurlParams: undefined})
//@ts-ignore
navigation.setParams({paymentOption: undefined})
//@ts-ignore
navigation.setParams({fixedAmount: undefined})
//@ts-ignore
navigation.setParams({isDonation: undefined})
//@ts-ignore
navigation.setParams({donationForName: undefined})
const invoice = LightningUtils.decodeInvoice(encoded)
@@ -716,6 +738,30 @@ const headerBg = useThemeColor('header')
const iconColor = useThemeColor('textDim')
const amountInputColor = useThemeColor('amountInput')
const convertedAmountColor = useThemeColor('headerSubTitle')
const getConvertedAmount = function () {
if (!walletStore.exchangeRate) {
return undefined
}
const precision = getCurrency(unit).precision
return convertToFromSats(
round(toNumber(amountToTransfer) * precision, 0) || 0,
getCurrency(unit).code,
walletStore.exchangeRate
)
}
const isConvertedAmountVisible = function () {
return (
walletStore.exchangeRate &&
(userSettingsStore.exchangeCurrency === getCurrency(unit).code ||
unit === 'sat') &&
getConvertedAmount() !== undefined
)
}
return (
<Screen preset="fixed" contentContainerStyle={$screen}>
@@ -749,11 +795,27 @@ const amountInputColor = useThemeColor('amountInput')
finalFee={finalFee}
/>
) : (
<Text
size="sm"
tx="payCommon.amountToPayLabel"
style={{color: amountInputColor, textAlign: '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"
tx="payCommon.amountToPayLabel"
style={{
color: amountInputColor,
textAlign: 'center',
marginTop: isConvertedAmountVisible() ? -spacing.extraSmall : undefined
}}
/>
</>
)}
</View>
</View>
+1 -1
View File
@@ -13,7 +13,7 @@ import { CashuUtils, CashuProof } from "../../services/cashu/cashuUtils"
import { log } from "../../services"
import { Token, getDecodedToken, getEncodedToken } from '@cashu/cashu-ts';
export type QRCodeBlockTypes = 'EncodedV3Token' | 'EncodedV4Token' | 'Bolt11Invoice' | 'URL' | 'NWC' | 'PUBKEY'
export type QRCodeBlockTypes = 'EncodedV3Token' | 'EncodedV4Token' | 'Bolt11Invoice' | 'URL' | 'NWC' | 'PUBKEY' | 'PaymentRequest'
const ANIMATED_QR_FRAGMENT_LENGTH = 150
const ANIMATED_QR_INTERVAL = 250
+1 -1
View File
@@ -1085,7 +1085,7 @@ const $screen: ViewStyle = {
const $headerContainer: TextStyle = {
alignItems: 'center',
// padding: spacing.tiny,
height: spacing.screenHeight * 0.28,
height: spacing.screenHeight * 0.27,
}
const $tabContainer: TextStyle = {
+1
View File
@@ -17,6 +17,7 @@ export * from './TransferScreen'
export * from './TopupScreen'
export * from './LightningPayScreen'
export * from './TokenReceiveScreen'
export * from './CashuPaymentRequestScreen'
// ContactsNavigator
export * from './ContactsScreen'
+25 -3
View File
@@ -4,6 +4,7 @@ import type {
Token,
Proof as CashuProof,
PaymentRequest as CashuPaymentRequest,
PaymentRequestPayload,
} from '@cashu/cashu-ts'
import AppError, {Err} from '../../utils/AppError'
import { getDecodedToken } from '@cashu/cashu-ts'
@@ -28,7 +29,7 @@ const CASHU_TOKEN_PREFIXES = [
'cashuB'
]
const CASHU_REQUEST_PREFIXES = [
const CASHU_PAYMENT_REQUEST_PREFIXES = [
'creqA',
]
@@ -41,11 +42,31 @@ const findEncodedCashuToken = function (content: string) {
const findEncodedCashuPaymentRequest = function (content: string) {
const words = content.split(/\s+|\n+/) // Split text into words
const maybeRequest = words.find(word => CASHU_REQUEST_PREFIXES.some(pref => word.includes(pref)))
const maybeRequest = words.find(word => CASHU_PAYMENT_REQUEST_PREFIXES.some(pref => word.includes(pref)))
return maybeRequest || null
}
const findEncodedCashuPaymentRequestPayload = function (content: string) {
try {
const decoded = JSON.parse(content)
if(decoded &&
decoded.mint &&
decoded.unit &&
Array.isArray(decoded.proofs) &&
decoded.proofs.length > 0) {
return decoded as PaymentRequestPayload
}
return null
} catch (e: any) {
return null
}
}
const extractEncodedCashuToken = function (maybeToken: string): string {
log.trace('[extractEncodedCashuToken] Extract token from', {maybeToken})
@@ -84,7 +105,7 @@ const extractEncodedCashuPaymentRequest = function (maybeRequest: string): strin
let encodedRequest: string | undefined = undefined
let decoded: CashuPaymentRequest | undefined = undefined
if (maybeRequest && CASHU_REQUEST_PREFIXES.some(pref => maybeRequest.startsWith(pref))) {
if (maybeRequest && CASHU_PAYMENT_REQUEST_PREFIXES.some(pref => maybeRequest.startsWith(pref))) {
decoded = decodePaymentRequest(maybeRequest) // throws
return maybeRequest
}
@@ -322,6 +343,7 @@ const isTokenP2PKLocked = function (token: Token) {
export const CashuUtils = {
findEncodedCashuToken,
findEncodedCashuPaymentRequest,
findEncodedCashuPaymentRequestPayload,
extractEncodedCashuToken,
extractEncodedCashuPaymentRequest,
getProofsAmount,
+20
View File
@@ -14,6 +14,7 @@ import { NostrClient } from './nostrService'
export enum IncomingDataType {
CASHU = 'CASHU',
CASHU_PAYMENT_REQUEST = 'CASHU_PAYMENT_REQUEST',
CASHU_PAYMENT_REQUEST_PAYLOAD = 'CASHU_PAYMENT_REQUEST_PAYLOAD',
INVOICE = 'INVOICE',
LNURL = 'LNURL',
LNURL_ADDRESS = 'LNURL_ADDRESS',
@@ -44,6 +45,12 @@ const findAndExtract = function (
type: expectedType,
encoded
}
case IncomingDataType.CASHU_PAYMENT_REQUEST_PAYLOAD:
encoded = incomingData // stringified JSON payload
return {
type: expectedType,
encoded
}
case IncomingDataType.INVOICE:
encoded = LightningUtils.extractEncodedLightningInvoice(incomingData)
return {
@@ -115,6 +122,19 @@ const findAndExtract = function (
}
}
const maybeCashuPaymentRequestPayload = CashuUtils.findEncodedCashuPaymentRequestPayload(incomingData)
if(maybeCashuPaymentRequestPayload) {
log.trace('Got maybeCashuPaymentRequestPayload', maybeCashuPaymentRequestPayload, 'findAndExtract')
const encoded = incomingData // to be aligned with other cases we pass encoded (stringified) JSON payload
return {
type: IncomingDataType.CASHU_PAYMENT_REQUEST_PAYLOAD,
encoded
}
}
const maybeInvoice = LightningUtils.findEncodedLightningInvoice(incomingData)
if(maybeInvoice) {
+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
+17 -1
View File
@@ -8,7 +8,7 @@ import { finalizeEvent, validateEvent } from 'nostr-tools/pure'
import { normalizeURL } from 'nostr-tools/utils'
import { encrypt, decrypt } from 'nostr-tools/nip04'
import { wrapEvent, unwrapEvent } from 'nostr-tools/nip59'
import { neventEncode as nostrNeventEncode, npubEncode, decode as nip19Decode } from 'nostr-tools/nip19'
import { neventEncode as nostrNeventEncode, npubEncode, decode as nip19Decode, nprofileEncode } from 'nostr-tools/nip19'
import {SimplePool} from 'nostr-tools/pool'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
import { PrivateDirectMessage, Metadata } from 'nostr-tools/kinds'
@@ -158,6 +158,21 @@ const decodeNprofile = function (nprofile: string) {
}
}
const encodeNprofile = function (pubkey: string, relays: string[] = []): string {
try {
const encoded = nprofileEncode({
pubkey,
relays
})
log.trace('[encodeNprofile]', {encoded})
return encoded
} catch (e: any) {
throw new AppError(Err.VALIDATION_ERROR, e.message)
}
}
/* const maybeConvertNpub = function (key: string) {
// Check and convert npub to P2PK
if (key && key.startsWith("npub1")) {
@@ -562,6 +577,7 @@ export const NostrClient = { // TODO split helper functions to separate module
getHexkey,
neventEncode,
decodeNprofile,
encodeNprofile,
// maybeConvertNpub,
encryptNip04,
decryptNip04,
+59 -1
View File
@@ -15,7 +15,7 @@ import {BackupProof} from '../models/Proof'
let _db: QuickSQLiteConnection
const _dbVersion = 19 // Update this if db changes require migrations
const _dbVersion = 20 // Update this if db changes require migrations
const getInstance = function () {
if (!_db) {
@@ -47,6 +47,7 @@ const _createOrUpdateSchema = function (db: QuickSQLiteConnection) {
[
`CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY NOT NULL,
paymentId TEXT,
type TEXT,
amount INTEGER,
unit TEXT,
@@ -133,6 +134,15 @@ const _runMigrations = function (db: QuickSQLiteConnection) {
log.info(`Prepared database migrations from ${currentVersion} -> 19`)
}
if (currentVersion < 20) {
migrationQueries.push([
`ALTER TABLE transactions
ADD COLUMN paymentId TEXT`,
])
log.info(`Prepared database migrations from ${currentVersion} -> 20`)
}
// Update db version as a part of migration sqls
migrationQueries.push([
`INSERT OR REPLACE INTO dbversion (id, version, createdAt)
@@ -370,6 +380,25 @@ const getTransactionById = function (id: number) {
}
}
const getTransactionByPaymentId = function (id: string) {
try {
const query = `
SELECT * FROM transactions WHERE paymentId = ?
`
const params = [id]
const db = getInstance()
const {rows} = db.execute(query, params)
return rows?.item(0) as TransactionRecord
} catch (e: any) {
throw new AppError(Err.DATABASE_ERROR, 'Transaction not found', e.message)
}
}
const addTransactionAsync = async function (tx: Transaction): Promise<TransactionRecord> {
try {
const {type, amount, fee, unit, data, memo, mint, status} = tx
@@ -522,6 +551,33 @@ const updateBalanceAfter = function (
}
const updatePaymentId = function (id: number, paymentId: string) {
try {
const query = `
UPDATE transactions
SET paymentId = ?
WHERE id = ?
`
const params = [paymentId, id]
const db = getInstance()
db.execute(query, params)
log.debug('[updatePaymentId]', 'Transaction paymentId updated in the database', {id, paymentId})
const updatedTx = getTransactionById(id as number)
return updatedTx as TransactionRecord
} catch (e: any) {
throw new AppError(
Err.DATABASE_ERROR,
'Could not update transaction paymentId in the database',
e.message,
)
}
}
const updateFee = function (id: number, fee: number) {
try {
const query = `
@@ -1132,6 +1188,7 @@ export const Database = {
cleanAll,
getTransactionsCount,
getTransactionById,
getTransactionByPaymentId,
getRecentTransactionsByUnit,
getTransactions,
addTransactionAsync,
@@ -1140,6 +1197,7 @@ export const Database = {
updateStatusesAsync,
updateBalanceAfter,
updateFee,
updatePaymentId,
updateReceivedAmount,
updateNote,
updateSentFrom,
@@ -0,0 +1,141 @@
import {rootStoreInstance} from '../../models'
import { TransactionTaskResult, WalletTask } from '../walletService'
import { MintBalance } from '../../models/Mint'
import { poller } from '../../utils/poller'
import { Transaction, TransactionData, TransactionRecord, TransactionStatus, TransactionType } from '../../models/Transaction'
import { log } from '../logService'
import { Contact } from '../../models/Contact'
import { LightningUtils } from '../lightning/lightningUtils'
import { getSnapshot, isStateTreeNode } from 'mobx-state-tree'
import { WalletUtils } from './utils'
import { MintUnit } from './currency'
import { NostrClient, NostrEvent } from '../nostrService'
import { Err } from '../../utils/AppError'
import {
CashuMint,
CashuWallet,
MintQuoteResponse,
PaymentRequest as CashuPaymentRequest,
PaymentRequestTransport,
PaymentRequestTransportType
} from '@cashu/cashu-ts'
import QuickCrypto from 'react-native-quick-crypto'
const {
transactionsStore,
walletProfileStore,
paymentRequestsStore,
walletStore,
relaysStore
} = rootStoreInstance
// const {walletStore} = nonPersistedStores
export const CASHU_PAYMENT_REQUEST_TASK = 'cashuPaymentRequestTask'
export const cashuPaymentRequestTask = async function (
mintBalanceToReceiveTo: MintBalance,
amountToReceive: number,
unit: MintUnit,
memo: string,
) : Promise<TransactionTaskResult> {
log.info('[cashuPaymentRequestTask]', {mintBalanceToReceiveTo})
log.info('[cashuPaymentRequestTask]', {amountToReceive, unit})
// create draft transaction
const transactionData: TransactionData[] = [
{
status: TransactionStatus.DRAFT,
amountToReceive,
unit,
createdAt: new Date(),
},
]
let transaction: Transaction | undefined = undefined
const mintUrl = mintBalanceToReceiveTo.mintUrl
try {
const newTransaction = {
type: TransactionType.RECEIVE_BY_PAYMENT_REQUEST,
amount: amountToReceive,
fee: 0,
unit,
data: JSON.stringify(transactionData),
memo,
mint: mintUrl,
status: TransactionStatus.DRAFT,
}
// store tx in db and in the model
transaction = await transactionsStore.addTransaction(newTransaction)
const tags = [["n", "17"]]
const transport = [
{
type: PaymentRequestTransportType.NOSTR,
target: NostrClient.encodeNprofile(walletProfileStore.pubkey, relaysStore.allUrls),
tags,
},
] as PaymentRequestTransport[]
const cashuPrId = QuickCrypto.randomBytes(4).toString("hex")
const cashuPaymentRequest = new CashuPaymentRequest(
transport,
cashuPrId,
amountToReceive,
unit,
[mintUrl],
memo
)
const encoded = cashuPaymentRequest.toEncodedRequest()
log.trace("[cashuPaymentRequestTask] Creating cashu payment request", {cashuPaymentRequest, encoded})
transactionData.push({
status: TransactionStatus.PENDING,
cashuPaymentRequest,
createdAt: new Date()
})
transaction.setStatus(
TransactionStatus.PENDING,
JSON.stringify(transactionData),
)
transaction.setPaymentId(cashuPrId)
return {
taskFunction: CASHU_PAYMENT_REQUEST_TASK,
mintUrl,
transaction,
message: '',
cashuPaymentRequest,
encodedCashuPaymentRequest: encoded,
} as TransactionTaskResult
} catch (e: any) {
if (transaction) {
transactionData.push({
status: TransactionStatus.ERROR,
error: WalletUtils.formatError(e),
createdAt: new Date()
})
transaction.setStatus(
TransactionStatus.ERROR,
JSON.stringify(transactionData),
)
}
log.error(e.name, e.message)
return {
taskFunction: CASHU_PAYMENT_REQUEST_TASK,
transaction,
message: e.message,
error: WalletUtils.formatError(e),
} as TransactionTaskResult
}
}
+149 -2
View File
@@ -13,7 +13,7 @@ import { DEFAULT_DENOMINATION_TARGET, TransactionTaskResult } from '../walletSer
import { WalletUtils } from './utils'
import { MintUnit, formatCurrency, getCurrency } from './currency'
import { MintProofsCounter } from '../../models/Mint'
import { Token, getDecodedToken } from '@cashu/cashu-ts'
import { PaymentRequestPayload, Token, getDecodedToken } from '@cashu/cashu-ts'
import { getEncodedToken, getKeepAmounts } from '@cashu/cashu-ts/src/utils'
const {
@@ -27,6 +27,7 @@ const {
export const RECEIVE_TASK = 'receiveTask'
export const RECEIVE_OFFLINE_PREPARE_TASK = 'receiveOfflinePrepareTask'
export const RECEIVE_OFFLINE_COMPLETE_TASK = 'receiveOfflineCompleteTask'
export const RECEIVE_BY_CASHU_PAYMENT_REQUEST_TASK = 'receiveByCashuPaymentRequestTask'
export const receiveTask = async function (
token: Token,
@@ -274,6 +275,9 @@ export const receiveOfflinePrepareTask = async function (
}
export const receiveOfflineCompleteTask = async function (
transactionId: number
) {
@@ -390,9 +394,152 @@ export const receiveOfflineCompleteTask = async function (
}
export const receiveByCashuPaymentRequestTask = async function (
paymentRequestPayload: PaymentRequestPayload,
): Promise<TransactionTaskResult> {
const transactionData: TransactionData[] = []
let transaction: Transaction | undefined = undefined
const unit = paymentRequestPayload.unit as MintUnit
const mintToReceive = paymentRequestPayload.mint
const proofsToReceive = paymentRequestPayload.proofs
const paymentRequestId = paymentRequestPayload.id
try {
if (!mintToReceive || !unit || !Array.isArray(proofsToReceive) || proofsToReceive.length === 0) {
throw new AppError(
Err.VALIDATION_ERROR,
'Payment request payload is invalid.',
{paymentRequestPayload}
)
}
// Let's find transaction with related payment request
const transaction = transactionsStore.findByPaymentId(paymentRequestId)
if(!transaction) {
throw new AppError(
Err.VALIDATION_ERROR,
'Related Payment request could not be found in the wallet.',
{paymentRequestPayload}
)
}
const amountToReceive = CashuUtils.getProofsAmount(proofsToReceive)
const memo = paymentRequestPayload.memo || 'PR ' + paymentRequestId
if(transaction.unit !== unit || transaction.amount !== amountToReceive) {
throw new AppError(
Err.VALIDATION_ERROR,
'Related Payment request has different amount or unit than the incoming payment.',
{paymentRequestPayload, expctedUnit: transaction.unit, expectedAmount: transaction.amount}
)
}
// Handle blocked mint
const isBlocked = mintsStore.isBlocked(mintToReceive)
if (isBlocked) {
transactionData.push({
status: TransactionStatus.BLOCKED,
message: 'Mint is blocked in your Settings, ecash has not been received.',
})
transaction.setStatus(
TransactionStatus.BLOCKED,
JSON.stringify(transactionData),
)
return {
taskFunction: RECEIVE_BY_CASHU_PAYMENT_REQUEST_TASK,
transaction,
message: `The mint ${mintToReceive} is blocked. You can unblock it in Settings.`,
} as TransactionTaskResult
}
// TODO rework to PR payload
const {
receivedAmount,
receivedProofs,
outputToken,
swapFeePaid,
} = await receiveSync(
mintToReceive,
paymentRequestPayload,
memo,
transaction.id
)
// Update tx amount if full amount was not received
if (receivedAmount !== amountToReceive) {
transaction.setReceivedAmount(receivedAmount)
}
// Finally, update completed transaction
transactionData.push({
status: TransactionStatus.COMPLETED,
swapFeePaid,
receivedAmount,
unit,
createdAt: new Date(),
})
transaction.setStatus(
TransactionStatus.COMPLETED,
JSON.stringify(transactionData),
)
transaction.setOutputToken(outputToken)
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance!
transaction.setBalanceAfter(balanceAfter)
if(swapFeePaid > 0) {
transaction.setFee(swapFeePaid)
}
return {
taskFunction: RECEIVE_BY_CASHU_PAYMENT_REQUEST_TASK,
mintUrl: mintToReceive,
transaction,
message: `Payment request ${paymentRequestId} with amount of ${formatCurrency(receivedAmount, getCurrency(unit).code)} ${getCurrency(unit).code} has been paid.`,
receivedAmount,
receivedProofsCount: receivedProofs.length
} as TransactionTaskResult
} catch (e: any) {
if (transaction) {
transactionData.push({
status: TransactionStatus.ERROR,
error: WalletUtils.formatError(e),
errorToken: e.params?.errorToken || undefined
})
transaction.setStatus(
TransactionStatus.ERROR,
JSON.stringify(transactionData),
)
}
log.error(e.name, e.message)
return {
taskFunction: RECEIVE_BY_CASHU_PAYMENT_REQUEST_TASK,
mintUrl: mintToReceive,
transaction,
message: e.message,
error: WalletUtils.formatError(e),
} as TransactionTaskResult
}
}
export const receiveSync = async function (
mintToReceive: string,
token: Token,
token: Token | PaymentRequestPayload,
memo: string,
transactionId: number
) {
+3 -1
View File
@@ -133,11 +133,13 @@ export const topupTask = async function (
createdAt: new Date()
})
transaction.setStatus(
transaction.setStatus(
TransactionStatus.PENDING,
JSON.stringify(transactionData),
)
transaction.setPaymentId(payment_hash)
if(!nwcEvent) {
const wsMint = new CashuMint(mintUrl)
const wsWallet = new CashuWallet(wsMint)
+7 -5
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(
@@ -197,8 +200,6 @@ export const transferTask = async function (
throw e
}
}
if (meltResponse.quote.state === MeltQuoteState.PAID) {
@@ -219,7 +220,8 @@ export const transferTask = async function (
let meltFeePaid = meltFeeReserve
let returnedAmount = CashuUtils.getProofsAmount(meltResponse.change)
if(meltResponse.change.length > 0) {
if(meltResponse.change.length > 0) {
WalletUtils.addCashuProofs(
mintUrl,
meltResponse.change,
@@ -240,7 +242,7 @@ export const transferTask = async function (
totalFeePaid = totalFeePaid - returnedAmount
lightningFeePaid = totalFeePaid - meltFeeReserve
}
}
// Save final fee in db
if(totalFeePaid !== transaction.fee) {
@@ -263,7 +265,7 @@ export const transferTask = async function (
)
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance!
transaction.setBalanceAfter(balanceAfter)
transaction.setBalanceAfter(balanceAfter)
return {
taskFunction: TRANSFER_TASK,
+243 -20
View File
@@ -15,7 +15,7 @@ import {CashuProof, CashuUtils} from './cashu/cashuUtils'
import {LightningUtils} from './lightning/lightningUtils'
import AppError, {Err} from '../utils/AppError'
import {MintBalance, MintStatus} from '../models/Mint'
import {MeltQuoteResponse, MintQuoteState, Token, getDecodedToken, getEncodedToken} from '@cashu/cashu-ts'
import {MeltQuoteResponse, MeltQuoteState, MintQuoteState, PaymentRequestPayload, Token, getDecodedToken, getEncodedToken} from '@cashu/cashu-ts'
import {Mint} from '../models/Mint'
import {pollerExists, stopPolling} from '../utils/poller'
import { NostrClient, NostrEvent, NostrProfile } from './nostrService'
@@ -24,7 +24,12 @@ import { PaymentRequest, PaymentRequestStatus, PaymentRequestType } from '../mod
import { IncomingDataType, IncomingParser } from './incomingParser'
import { Contact } from '../models/Contact'
import { SyncQueue } from './syncQueueService'
import { receiveTask, receiveOfflinePrepareTask, receiveOfflineCompleteTask} from './wallet/receiveTask'
import {
receiveTask,
receiveOfflinePrepareTask,
receiveOfflineCompleteTask,
receiveByCashuPaymentRequestTask
} from './wallet/receiveTask'
import { sendTask } from './wallet/sendTask'
import { topupTask } from './wallet/topupTask'
import { transferTask } from './wallet/transferTask'
@@ -33,9 +38,10 @@ import { WalletUtils } from './wallet/utils'
import { NotificationService } from './notificationService'
import { CurrencyCode, MintUnit, formatCurrency, getCurrency } from './wallet/currency'
import { MinibitsClient } from './minibitsService'
import { KeyChain } from './keyChain'
import { UnsignedEvent } from 'nostr-tools'
import { Platform } from 'react-native'
import { cashuPaymentRequestTask } from './wallet/cashuPaymentRequestTask'
import { sumBlindSignatures } from '@cashu/cashu-ts/src/utils'
/**
* The default number of proofs per denomination to keep in a wallet.
@@ -127,6 +133,12 @@ type WalletTaskService = {
contactToSendTo?: Contact,
nwcEvent?: NostrEvent
) => Promise<void>
cashuPaymentRequestQueue: (
mintBalanceToReceiveTo: MintBalance,
amountToRequest: number,
unit: MintUnit,
memo: string,
) => Promise<void>
revertQueue: (
transaction: Transaction
) => Promise<void>
@@ -135,6 +147,10 @@ type WalletTaskService = {
mintUrl: string,
mintQuote: string
}) => Promise<{recoveredAmount: number}>
recoverMeltQuoteChange: (params: {
mintUrl: string,
meltQuote: string
}) => Promise<{recoveredAmount: number}>
}
export interface WalletTaskResult {
@@ -542,6 +558,26 @@ const topupQueue = async function (
}
const cashuPaymentRequestQueue = async function (
mintBalanceToReceiveTo: MintBalance,
amountToRequest: number,
unit: MintUnit,
memo: string,
): Promise<void> {
const now = new Date().getTime()
SyncQueue.addPrioritizedTask(
`cashuPaymentRequestTask-${now}`,
async () => await cashuPaymentRequestTask(
mintBalanceToReceiveTo,
amountToRequest,
unit,
memo,
)
)
return
}
const revertQueue = async function (
transaction: Transaction
@@ -1719,7 +1755,7 @@ const recoverMintQuote = async function (params: {mintUrl: string, mintQuote: st
switch (state) {
case MintQuoteState.UNPAID:
log.trace('[handlePendingTopupTask] Quote not paid', {mintUrl, mintQuote})
log.trace('[recoverMintQuote] Quote not paid', {mintUrl, mintQuote})
throw new AppError(Err.VALIDATION_ERROR, `Quote ${mintQuote} is not paid`)
case MintQuoteState.PAID:
const invoice = LightningUtils.decodeInvoice(encodedInvoice)
@@ -1761,7 +1797,7 @@ const recoverMintQuote = async function (params: {mintUrl: string, mintQuote: st
} catch (e: any) {
if(e.message.includes('outputs have already been signed before')) {
log.error('[handlePendingTopupTask] Increasing proofsCounter outdated values and repeating mintProofs.')
log.error('[recoverMintQuote] Increasing proofsCounter outdated values and repeating mintProofs.')
proofs = (await walletStore.mintProofs(
mintUrl as string,
@@ -1795,14 +1831,14 @@ const recoverMintQuote = async function (params: {mintUrl: string, mintQuote: st
// update related tx
const transactionDataUpdate = {
status: TransactionStatus.COMPLETED,
status: TransactionStatus.RECOVERED,
createdAt: new Date(),
}
// await for final status
await transactionsStore.updateStatuses(
[transactionId],
TransactionStatus.COMPLETED,
TransactionStatus.RECOVERED,
JSON.stringify(transactionDataUpdate),
)
@@ -1816,20 +1852,177 @@ const recoverMintQuote = async function (params: {mintUrl: string, mintQuote: st
* ISSUED
*/
case MintQuoteState.ISSUED:
log.trace('[handlePendingTopupTask] Quote already issued', {mintUrl, mintQuote})
log.trace('[recoverMintQuote] Quote already issued', {mintUrl, mintQuote})
throw new AppError(Err.VALIDATION_ERROR, `Quote ${mintQuote} already issued.`)
/*
* UNKNOWN
*/
default:
log.error(`[handlePendingTopupTask] Unknown MintQuoteState`, {state})
log.error(`[recoverMintQuote] Unknown MintQuoteState`, {state})
throw new AppError(Err.VALIDATION_ERROR, `Quote ${mintQuote} has unknown state ${state}`)
}
}
const recoverMeltQuoteChange = async function (params: {mintUrl: string, meltQuote: string}): Promise<{recoveredAmount: number}> {
const {mintUrl, meltQuote} = params
const mintInstance = mintsStore.findByUrl(mintUrl as string)
const unit = 'sat'
if(!mintInstance || !meltQuote) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint or melt quote', {mintUrl})
}
// check is quote has been paid
const meltQuoteResponse: MeltQuoteResponse = await walletStore.checkLightningMeltQuote(mintUrl, meltQuote)
const {quote, state} = meltQuoteResponse
const amountToRecover = sumBlindSignatures(meltQuoteResponse.change)
if (quote !== meltQuote) {
throw new AppError(Err.VALIDATION_ERROR, 'Returned quote is different then the one requested', {mintUrl, meltQuoteResponse, meltQuote})
}
switch (state) {
/*
* UNPAID
*/
case MeltQuoteState.UNPAID:
log.trace('[recoverMeltQuoteChange] Quote not paid', {mintUrl, meltQuote})
throw new AppError(Err.VALIDATION_ERROR, `Quote ${meltQuote} is not paid`)
/*
* PENDING
*/
case MeltQuoteState.PENDING:
log.trace('[recoverMeltQuoteChange] Quote is pending', {mintUrl, meltQuote})
throw new AppError(Err.VALIDATION_ERROR, `Quote ${meltQuote} is still pending.`)
/*
* PAID
*/
case MeltQuoteState.PAID:
if(!meltQuoteResponse.change || meltQuoteResponse.change.length === 0) {
throw new AppError(Err.VALIDATION_ERROR, `Quote ${meltQuote} has not any change to recover.`)
}
// Not sure how to find related transfer transaction, so create new one
// TODO extent transaction model to store melt quote so we can search by it
const transactionData: TransactionData[] = [
{
status: TransactionStatus.DRAFT,
amountToRecover,
unit,
meltQuoteToRecover: quote,
createdAt: new Date(),
}
]
const newTransaction = {
type: TransactionType.RECEIVE,
amount: amountToRecover,
fee: 0,
unit,
data: JSON.stringify(transactionData),
memo: 'Melt quote change recovery',
mint: mintUrl,
status: TransactionStatus.DRAFT,
}
// store tx in db and in the model
const transaction = await transactionsStore.addTransaction(newTransaction)
const transactionId = transaction.id
try {
const change = await walletStore.recoverMeltQuoteChange(
mintUrl as string,
meltQuoteResponse
)
// Force swap with the mint to make sure that change proofs are valid
const {proofsToSend, returnedProofs} = await walletStore.send(
mintUrl as string,
0,
unit,
change as Proof[],
transactionId,
{
increaseCounterBy: change.length, //if we missed to receive the change before, counter might be outdated
p2pk: undefined,
}
)
log.debug('[recoverMeltQuoteChange] Swapped proofs', {proofsToSend, returnedProofs})
if (!returnedProofs || returnedProofs.length === 0) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint did not return any proofs.')
}
const {addedAmount: recoveredAmount} = WalletUtils.addCashuProofs(
mintUrl as string,
returnedProofs,
{
unit,
transactionId,
isPending: false
}
)
if(amountToRecover !== recoveredAmount) {
transaction.setReceivedAmount(recoveredAmount)
}
// update related tx
const transactionDataUpdate = {
status: TransactionStatus.RECOVERED,
recoveredAmount,
createdAt: new Date(),
}
// await for final status
await transactionsStore.updateStatuses(
[transactionId],
TransactionStatus.RECOVERED,
JSON.stringify(transactionDataUpdate),
)
// Update tx with current total balance of topup unit/currency
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance!
const outputToken = getEncodedToken({
mint: mintUrl,
proofs: returnedProofs,
unit,
})
transaction.setBalanceAfter(balanceAfter)
transaction.setOutputToken(outputToken)
return { recoveredAmount }
} catch (e: any) {
transactionData.push({
status: TransactionStatus.ERROR,
error: WalletUtils.formatError(e),
createdAt: new Date()
})
transaction.setStatus(
TransactionStatus.ERROR,
JSON.stringify(transactionData),
)
}
/*
* UNKNOWN
*/
default:
log.error(`[recoverMeltQuoteChange] Unknown MeltQuoteState`, {state: meltQuoteResponse.state})
throw new AppError(Err.VALIDATION_ERROR, `Quote ${meltQuote} has unknown state ${meltQuoteResponse.state}`)
}
}
const handleClaimQueue = async function (): Promise<void> {
log.info('[handleClaimQueue] start')
@@ -2089,7 +2282,7 @@ const handleReceivedEventTask = async function (encryptedEvent: NostrEvent): Pro
// this is not valid for events sent from LNURL bridge, that are sent and signed by a minibits server key
// and *** do not contain sentFrom *** // LEGACY, replaced by claim api
let sentFromPubkey = directMessageEvent.pubkey
let sentFrom = NostrClient.getFirstTagValue(directMessageEvent.tags, 'from')
let sentFrom = NostrClient.getFirstTagValue(directMessageEvent.tags, 'from') as string
let sentFromNpub = NostrClient.getNpubkey(sentFromPubkey)
let contactFrom: Contact | undefined = undefined
let zapSenderProfile: NostrProfile | undefined = undefined
@@ -2195,12 +2388,7 @@ const handleReceivedEventTask = async function (encryptedEvent: NostrEvent): Pro
message: 'Incoming ecash token has been received.',
proofsCount: decoded.proofs.length,
proofsAmount: receivedAmount,
notificationInputs: {
event: directMessageEvent,
decrypted: decryptedMessage,
transaction,
receivedAmount
} // not used`
transaction
} as WalletTaskResult
}
@@ -2259,13 +2447,46 @@ const handleReceivedEventTask = async function (encryptedEvent: NostrEvent): Pro
paymentRequest
} as WalletTaskResult
}
else if(incoming.type === IncomingDataType.CASHU_PAYMENT_REQUEST) {
throw new AppError(Err.NOTFOUND_ERROR, 'CASHU_PAYMENT_REQUEST support is not yet implemented.', {caller: HANDLE_RECEIVED_EVENT_TASK})
else if(incoming.type === IncomingDataType.CASHU_PAYMENT_REQUEST_PAYLOAD) {
const decoded: PaymentRequestPayload = JSON.parse(incoming.encoded)
log.trace('[handleReceivedEventTask]', 'Decoded payment request payload', {decoded})
const {transaction, receivedAmount, message} = await receiveByCashuPaymentRequestTask(
decoded
)
// store contact or zapseder in tx details
if(transaction && sentFrom) {
if (contactFrom) {
transaction.setProfile(JSON.stringify(contactFrom))
transaction.setSentFrom(sentFrom)
}
// We do it defensively only after cash is received
// and asynchronously so we speed up queue
_sendReceiveNotification(
receivedAmount,
transaction.unit,
false,
sentFrom,
sentFromPicture
) // TODO move to task result handler
}
return {
mintUrl: decoded.mint,
taskFunction: HANDLE_RECEIVED_EVENT_TASK,
message,
proofsCount: decoded.proofs.length,
proofsAmount: receivedAmount,
transaction,
} as WalletTaskResult
}
else if (incoming.type === IncomingDataType.LNURL) {
throw new AppError(Err.NOTFOUND_ERROR, 'LNURL support is not yet implemented.', {caller: HANDLE_RECEIVED_EVENT_TASK})
} else {
throw new AppError(Err.NOTFOUND_ERROR, 'Received unknown message', incoming)
throw new AppError(Err.NOTFOUND_ERROR, 'Received unknown event message', {caller: HANDLE_RECEIVED_EVENT_TASK,incoming})
}
} catch (e: any) {
log.error('[handleReceivedEventTask]', e.name, e.message)
@@ -2364,7 +2585,9 @@ export const WalletTask: WalletTaskService = {
swapAllQueue,
transferQueue,
topupQueue,
cashuPaymentRequestQueue,
revertQueue,
testQueue,
recoverMintQuote
recoverMintQuote,
recoverMeltQuoteChange
}