diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 111d8b411..c39cc52ee 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1095,7 +1095,7 @@ PODS: - React-perflogger (= 0.73.8) - ReactNativeCameraKit (13.0.0): - React-Core - - RealmJS (12.8.1): + - RealmJS (12.9.0): - React - rn-ldk (0.8.4): - React-Core @@ -1503,7 +1503,7 @@ SPEC CHECKSUMS: React-utils: 4cc2ba652f5df1c8f0461d4ae9e3ee474c1354ea ReactCommon: 1da3fc14d904883c46327b3322325eebf60a720a ReactNativeCameraKit: 9d46a5d7dd544ca64aa9c03c150d2348faf437eb - RealmJS: 2c7fdb3991d7655fba5f88eb288f75eaf5cb9980 + RealmJS: 7aa0e7dcc8959d28cee3ab302415a8893e50ad12 rn-ldk: 0d8749d98cc5ce67302a32831818c116b67f7643 RNCAsyncStorage: 826b603ae9c0f88b5ac4e956801f755109fa4d5c RNCClipboard: 0a720adef5ec193aa0e3de24c3977222c7e52a37 diff --git a/ios/Shared/MarketAPI.swift b/ios/Shared/MarketAPI.swift index 70580c73d..8d0c23603 100644 --- a/ios/Shared/MarketAPI.swift +++ b/ios/Shared/MarketAPI.swift @@ -1,6 +1,5 @@ // -// WidgetAPI.swift -// TodayExtension +// MarketAPI.swift // // Created by Marcos Rodriguez on 11/2/19. @@ -36,6 +35,8 @@ class MarketAPI { return "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=\(endPointKey.lowercased())" case "BNR": return "https://www.bnr.ro/nbrfxrates.xml" + case "Kraken": + return "https://api.kraken.com/0/public/Ticker?pair=XXBTZ\(endPointKey.uppercased())" default: return "https://api.coindesk.com/v1/bpi/currentprice/\(endPointKey).json" } @@ -47,7 +48,6 @@ class MarketAPI { return } - // Parse the JSON based on the source and format the response parseJSONBasedOnSource(json: json, source: source, endPointKey: endPointKey, completion: completion) } @@ -125,16 +125,26 @@ class MarketAPI { } case "BNR": - // Handle BNR source differently if needed, perhaps requiring XML parsing - // Placeholder for potential XML parsing logic or alternative JSON structure completion(nil, CurrencyError(errorDescription: "BNR data source is not yet implemented")) + case "Kraken": + if let result = json["result"] as? [String: Any], + let tickerData = result["XXBTZ\(endPointKey.uppercased())"] as? [String: Any], + let c = tickerData["c"] as? [String], + let rateString = c.first, + let rateDouble = Double(rateString) { + let lastUpdatedString = ISO8601DateFormatter().string(from: Date()) + latestRateDataStore = WidgetDataStore(rate: rateString, lastUpdate: lastUpdatedString, rateDouble: rateDouble) + completion(latestRateDataStore, nil) + } else { + completion(nil, CurrencyError(errorDescription: "Data formatting error for source: \(source)")) + } + default: completion(nil, CurrencyError(errorDescription: "Unsupported data source \(source)")) } } - // Handles XML data for BNR source private static func handleBNRData(data: Data, completion: @escaping ((WidgetDataStore?, Error?) -> Void)) { let parser = XMLParser(data: data) let delegate = BNRXMLParserDelegate() @@ -195,5 +205,3 @@ class MarketAPI { } } - - diff --git a/models/fiatUnit.ts b/models/fiatUnit.ts index 90d93c9e3..5be4985db 100644 --- a/models/fiatUnit.ts +++ b/models/fiatUnit.ts @@ -4,6 +4,7 @@ export const FiatUnitSource = { Coinbase: 'Coinbase', CoinDesk: 'CoinDesk', CoinGecko: 'CoinGecko', + Kraken: 'Kraken', Yadio: 'Yadio', YadioConvert: 'YadioConvert', Exir: 'Exir', @@ -75,6 +76,23 @@ const RateExtractors = { if (!(rate >= 0)) throw new Error(`Could not update rate from Bitstamp for ${ticker}: data is wrong`); return rate; }, + Kraken: async (ticker: string): Promise => { + let json; + try { + const res = await fetch(`https://api.kraken.com/0/public/Ticker?pair=XXBTZ${ticker.toUpperCase()}`); + json = await res.json(); + } catch (e: any) { + throw new Error(`Could not update rate from Kraken for ${ticker}: ${e.message}`); + } + + let rate = json?.result?.[`XXBTZ${ticker.toUpperCase()}`]?.c?.[0]; + + if (!rate) throw new Error(`Could not update rate from Kraken for ${ticker}: data is wrong`); + + rate = Number(rate); + if (!(rate >= 0)) throw new Error(`Could not update rate from Kraken for ${ticker}: data is wrong`); + return rate; + }, BNR: async (): Promise => { try { const response = await fetch('https://www.bnr.ro/nbrfxrates.xml'); @@ -168,7 +186,7 @@ export type TFiatUnit = { endPointKey: string; symbol: string; locale: string; - source: 'CoinDesk' | 'Yadio' | 'Exir' | 'wazirx' | 'Bitstamp'; + source: 'CoinDesk' | 'Yadio' | 'Exir' | 'wazirx' | 'Bitstamp' | 'Kraken'; }; export type TFiatUnits = { diff --git a/models/fiatUnits.json b/models/fiatUnits.json index 0001b1640..9c8ea7917 100644 --- a/models/fiatUnits.json +++ b/models/fiatUnits.json @@ -2,7 +2,7 @@ "USD": { "endPointKey": "USD", "locale": "en-US", - "source": "CoinGecko", + "source": "Kraken", "symbol": "$" }, "AED": { @@ -92,13 +92,13 @@ "EUR": { "endPointKey": "EUR", "locale": "en-IE", - "source": "CoinGecko", + "source": "Kraken", "symbol": "€" }, "GBP": { "endPointKey": "GBP", "locale": "en-GB", - "source": "CoinGecko", + "source": "Kraken", "symbol": "£" }, "HRK": { diff --git a/navigation/LazyLoadSendDetailsStack.tsx b/navigation/LazyLoadSendDetailsStack.tsx index c523955f4..f06199696 100644 --- a/navigation/LazyLoadSendDetailsStack.tsx +++ b/navigation/LazyLoadSendDetailsStack.tsx @@ -3,7 +3,7 @@ import React, { lazy, Suspense } from 'react'; import { LazyLoadingIndicator } from './LazyLoadingIndicator'; const SendDetails = lazy(() => import('../screen/send/details')); -const Confirm = lazy(() => import('../screen/send/confirm')); +const Confirm = lazy(() => import('../screen/send/Confirm')); const PsbtWithHardwareWallet = lazy(() => import('../screen/send/psbtWithHardwareWallet')); const CreateTransaction = lazy(() => import('../screen/send/create')); const PsbtMultisig = lazy(() => import('../screen/send/psbtMultisig')); diff --git a/navigation/SendDetailsStack.tsx b/navigation/SendDetailsStack.tsx index e46157742..b88361a34 100644 --- a/navigation/SendDetailsStack.tsx +++ b/navigation/SendDetailsStack.tsx @@ -1,5 +1,5 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import React from 'react'; +import React, { useMemo } from 'react'; import navigationStyle, { navigationStyleTx } from '../components/navigationStyle'; import { useTheme } from '../components/themes'; @@ -16,11 +16,13 @@ import { SuccessComponent, } from './LazyLoadSendDetailsStack'; import { SendDetailsStackParamList } from './SendDetailsStackParamList'; +import HeaderRightButton from '../components/HeaderRightButton'; const Stack = createNativeStackNavigator(); const SendDetailsStack = () => { const theme = useTheme(); + const DetailsButton = useMemo(() => , []); return ( @@ -34,7 +36,11 @@ const SendDetailsStack = () => { }))(theme)} initialParams={{ isEditable: true }} // Correctly typed now /> - + DetailsButton })(theme)} + /> void; chainType: Chain; diff --git a/screen/UnlockWith.tsx b/screen/UnlockWith.tsx index 38298213d..841a35b32 100644 --- a/screen/UnlockWith.tsx +++ b/screen/UnlockWith.tsx @@ -55,6 +55,10 @@ const UnlockWith: React.FC = () => { const { setWalletsInitialized, isStorageEncrypted, startAndDecrypt } = useStorage(); const { deviceBiometricType, unlockWithBiometrics, isBiometricUseCapableAndEnabled, isBiometricUseEnabled } = useBiometrics(); + useEffect(() => { + setWalletsInitialized(false); + }, [setWalletsInitialized]); + const successfullyAuthenticated = useCallback(() => { setWalletsInitialized(true); isUnlockingWallets.current = false; diff --git a/screen/send/confirm.js b/screen/send/Confirm.tsx similarity index 56% rename from screen/send/confirm.js rename to screen/send/Confirm.tsx index 262f15ec0..cf7dc8fac 100644 --- a/screen/send/confirm.js +++ b/screen/send/Confirm.tsx @@ -1,39 +1,88 @@ -import { useNavigation, useRoute } from '@react-navigation/native'; +import React, { useContext, useEffect, useMemo, useReducer } from 'react'; +import { ActivityIndicator, FlatList, TouchableOpacity, StyleSheet, Switch, View } from 'react-native'; +import { Text } from 'react-native-elements'; +import { PayjoinClient } from 'payjoin-client'; import BigNumber from 'bignumber.js'; import * as bitcoin from 'bitcoinjs-lib'; -import { PayjoinClient } from 'payjoin-client'; -import PropTypes from 'prop-types'; -import React, { useContext, useEffect, useState } from 'react'; -import { ActivityIndicator, FlatList, StyleSheet, Switch, TouchableOpacity, View } from 'react-native'; -import { Text } from 'react-native-elements'; - -import * as BlueElectrum from '../../blue_modules/BlueElectrum'; -import { satoshiToBTC, satoshiToLocalCurrency } from '../../blue_modules/currency'; -import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback'; +import { BlueText, BlueCard } from '../../BlueComponents'; +import { BitcoinUnit } from '../../models/bitcoinUnits'; +import loc, { formatBalance, formatBalanceWithoutSuffix } from '../../loc'; import Notifications from '../../blue_modules/notifications'; import { BlueStorageContext } from '../../blue_modules/storage-context'; -import { BlueCard, BlueText } from '../../BlueComponents'; -import PayjoinTransaction from '../../class/payjoin-transaction'; +import { useRoute, RouteProp } from '@react-navigation/native'; import presentAlert from '../../components/Alert'; -import Button from '../../components/Button'; -import SafeArea from '../../components/SafeArea'; import { useTheme } from '../../components/themes'; +import Button from '../../components/Button'; +import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback'; +import SafeArea from '../../components/SafeArea'; +import { satoshiToBTC, satoshiToLocalCurrency } from '../../blue_modules/currency'; +import * as BlueElectrum from '../../blue_modules/BlueElectrum'; import { useBiometrics } from '../../hooks/useBiometrics'; -import loc, { formatBalance, formatBalanceWithoutSuffix } from '../../loc'; -import { BitcoinUnit } from '../../models/bitcoinUnits'; +import { TWallet, CreateTransactionTarget } from '../../class/wallets/types'; +import PayjoinTransaction from '../../class/payjoin-transaction'; +import debounce from '../../blue_modules/debounce'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { SendDetailsStackParamList } from '../../navigation/SendDetailsStackParamList'; +import { useExtendedNavigation } from '../../hooks/useExtendedNavigation'; -const Confirm = () => { +enum ActionType { + SET_LOADING = 'SET_LOADING', + SET_PAYJOIN_ENABLED = 'SET_PAYJOIN_ENABLED', + SET_BUTTON_DISABLED = 'SET_BUTTON_DISABLED', +} + +type Action = + | { type: ActionType.SET_LOADING; payload: boolean } + | { type: ActionType.SET_PAYJOIN_ENABLED; payload: boolean } + | { type: ActionType.SET_BUTTON_DISABLED; payload: boolean }; + +interface State { + isLoading: boolean; + isPayjoinEnabled: boolean; + isButtonDisabled: boolean; +} + +const initialState: State = { + isLoading: false, + isPayjoinEnabled: false, + isButtonDisabled: false, +}; + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case ActionType.SET_LOADING: + return { ...state, isLoading: action.payload }; + case ActionType.SET_PAYJOIN_ENABLED: + return { ...state, isPayjoinEnabled: action.payload }; + case ActionType.SET_BUTTON_DISABLED: + return { ...state, isButtonDisabled: action.payload }; + default: + return state; + } +}; + +type ConfirmRouteProp = RouteProp; +type ConfirmNavigationProp = NativeStackNavigationProp; + +const Confirm: React.FC = () => { const { wallets, fetchAndSaveWalletTransactions, isElectrumDisabled } = useContext(BlueStorageContext); const { isBiometricUseCapableAndEnabled, unlockWithBiometrics } = useBiometrics(); - const { params } = useRoute(); - const { recipients = [], walletID, fee, memo, tx, satoshiPerByte, psbt } = params; - const [isLoading, setIsLoading] = useState(false); - const [isPayjoinEnabled, setIsPayjoinEnabled] = useState(false); - const wallet = wallets.find(w => w.getID() === walletID); - const payjoinUrl = wallet.allowPayJoin() ? params.payjoinUrl : false; + const navigation = useExtendedNavigation(); + const route = useRoute(); // Get the route and its params + const { recipients, walletID, fee, memo, tx, satoshiPerByte, psbt, payjoinUrl } = route.params; // Destructure params + + const [state, dispatch] = useReducer(reducer, initialState); + const { navigate, setOptions, goBack } = navigation; + const wallet = wallets.find((w: TWallet) => w.getID() === walletID) as TWallet; const feeSatoshi = new BigNumber(fee).multipliedBy(100000000).toNumber(); - const { navigate, setOptions } = useNavigation(); const { colors } = useTheme(); + + useEffect(() => { + if (!wallet) { + goBack(); + } + }, [wallet, goBack]); + const stylesHook = StyleSheet.create({ transactionDetailsTitle: { color: colors.foregroundColor, @@ -61,68 +110,83 @@ const Confirm = () => { }, }); + const HeaderRightButton = useMemo( + () => ( + { + if (await isBiometricUseCapableAndEnabled()) { + if (!(await unlockWithBiometrics())) { + return; + } + } + navigate('CreateTransaction', { + fee, + recipients, + memo, + tx, + satoshiPerByte, + wallet, + feeSatoshi, + }); + }} + > + {loc.send.create_details} + + ), + [ + stylesHook.txDetails, + stylesHook.valueUnit, + isBiometricUseCapableAndEnabled, + navigate, + fee, + recipients, + memo, + tx, + satoshiPerByte, + wallet, + feeSatoshi, + unlockWithBiometrics, + ], + ); + useEffect(() => { console.log('send/confirm - useEffect'); console.log('address = ', recipients); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [recipients]); useEffect(() => { setOptions({ - // eslint-disable-next-line react/no-unstable-nested-components - headerRight: () => ( - { - if (await isBiometricUseCapableAndEnabled()) { - if (!(await unlockWithBiometrics())) { - return; - } - } - - navigate('CreateTransaction', { - fee, - recipients, - memo, - tx, - satoshiPerByte, - wallet, - feeSatoshi, - }); - }} - > - {loc.send.create_details} - - ), + headerRight: () => HeaderRightButton, }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [colors, fee, feeSatoshi, memo, recipients, satoshiPerByte, tx, wallet]); + }, [HeaderRightButton, colors, fee, feeSatoshi, memo, recipients, satoshiPerByte, setOptions, tx, wallet]); - /** - * we need to look into `recipients`, find destination address and return its outputScript - * (needed for payjoin) - * - * @return {string} - */ - const getPaymentScript = () => { - return bitcoin.address.toOutputScript(recipients[0].address); + const getPaymentScript = (): Buffer | undefined => { + if (!(recipients.length > 0) || !recipients[0].address) { + return undefined; + } + return bitcoin.address.toOutputScript(recipients[0].address, bitcoin.networks.bitcoin); }; const send = async () => { - setIsLoading(true); + dispatch({ type: ActionType.SET_BUTTON_DISABLED, payload: true }); + dispatch({ type: ActionType.SET_LOADING, payload: true }); try { const txids2watch = []; - if (!isPayjoinEnabled) { + if (!state.isPayjoinEnabled) { await broadcast(tx); } else { - const payJoinWallet = new PayjoinTransaction(psbt, txHex => broadcast(txHex), wallet); + const payJoinWallet = new PayjoinTransaction(psbt, (txHex: string) => broadcast(txHex), wallet); const paymentScript = getPaymentScript(); + if (!paymentScript) { + throw new Error('Invalid payment script'); + } const payjoinClient = new PayjoinClient({ paymentScript, - wallet: payJoinWallet, - payjoinUrl, + wallet: payJoinWallet.getPayjoinPsbt(), + payjoinUrl: payjoinUrl as string, }); await payjoinClient.run(); const payjoinPsbt = payJoinWallet.getPayjoinPsbt(); @@ -134,31 +198,37 @@ const Confirm = () => { const txid = bitcoin.Transaction.fromHex(tx).getId(); txids2watch.push(txid); + // @ts-ignore: Notifications has to be TSed Notifications.majorTomToGroundControl([], [], txids2watch); let amount = 0; for (const recipient of recipients) { - amount += recipient.value; + if (recipient.value) { + amount += recipient.value; + } } - amount = formatBalanceWithoutSuffix(amount, BitcoinUnit.BTC, false); + amount = Number(formatBalanceWithoutSuffix(amount, BitcoinUnit.BTC, false)); triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess); navigate('Success', { fee: Number(fee), amount, }); - setIsLoading(false); + dispatch({ type: ActionType.SET_LOADING, payload: false }); await new Promise(resolve => setTimeout(resolve, 3000)); // sleep to make sure network propagates fetchAndSaveWalletTransactions(walletID); - } catch (error) { + } catch (error: any) { triggerHapticFeedback(HapticFeedbackTypes.NotificationError); - setIsLoading(false); + dispatch({ type: ActionType.SET_LOADING, payload: false }); + dispatch({ type: ActionType.SET_BUTTON_DISABLED, payload: false }); presentAlert({ message: error.message }); } }; - const broadcast = async transaction => { + const debouncedSend = debounce(send, 3000); + + const broadcast = async (transaction: string) => { await BlueElectrum.ping(); await BlueElectrum.waitTillConnected(); @@ -176,16 +246,18 @@ const Confirm = () => { return result; }; - const _renderItem = ({ index, item }) => { + const renderItem = ({ index, item }: { index: number; item: CreateTransactionTarget }) => { return ( <> - {satoshiToBTC(item.value)} + {item.value && satoshiToBTC(item.value)} {' ' + loc.units[BitcoinUnit.BTC]} - {satoshiToLocalCurrency(item.value)} + + {item.value && satoshiToLocalCurrency(item.value)} + {loc.send.create_to} @@ -198,10 +270,6 @@ const Confirm = () => { ); }; - _renderItem.propTypes = { - index: PropTypes.number.isRequired, - item: PropTypes.object.isRequired, - }; const renderSeparator = () => { return ; @@ -210,11 +278,11 @@ const Confirm = () => { return ( - scrollEnabled={recipients.length > 1} extraData={recipients} data={recipients} - renderItem={_renderItem} + renderItem={renderItem} keyExtractor={(_item, index) => `${index}`} ItemSeparatorComponent={renderSeparator} /> @@ -223,7 +291,11 @@ const Confirm = () => { Payjoin - + dispatch({ type: ActionType.SET_PAYJOIN_ENABLED, payload: value })} + /> @@ -234,7 +306,11 @@ const Confirm = () => { {loc.send.create_fee}: {formatBalance(feeSatoshi, BitcoinUnit.BTC)} ({satoshiToLocalCurrency(feeSatoshi)}) - {isLoading ? :