diff --git a/apps/wallet-mobile/.storybook/storybook.requires.js b/apps/wallet-mobile/.storybook/storybook.requires.js index 7046d09d9b..a41e78585f 100644 --- a/apps/wallet-mobile/.storybook/storybook.requires.js +++ b/apps/wallet-mobile/.storybook/storybook.requires.js @@ -173,7 +173,6 @@ const getStories = () => { "./src/features/Scan/useCases/ShowCameraPermissionDeniedScreen/OpenDeviceAppSettingsButton.stories.tsx": require("../src/features/Scan/useCases/ShowCameraPermissionDeniedScreen/OpenDeviceAppSettingsButton.stories.tsx"), "./src/features/Scan/useCases/ShowCameraPermissionDeniedScreen/ShowCameraPermissionDeniedScreen.stories.tsx": require("../src/features/Scan/useCases/ShowCameraPermissionDeniedScreen/ShowCameraPermissionDeniedScreen.stories.tsx"), "./src/features/Send/common/ButtonGroup/ButtonGroup.stories.tsx": require("../src/features/Send/common/ButtonGroup/ButtonGroup.stories.tsx"), - "./src/features/Send/useCases/ConfirmTx/ConfirmTxScreen.stories.tsx": require("../src/features/Send/useCases/ConfirmTx/ConfirmTxScreen.stories.tsx"), "./src/features/Send/useCases/ConfirmTx/FailedTx/FailedTxScreen.stories.tsx": require("../src/features/Send/useCases/ConfirmTx/FailedTx/FailedTxScreen.stories.tsx"), "./src/features/Send/useCases/ConfirmTx/SubmittedTx/SubmittedTxScreen.stories.tsx": require("../src/features/Send/useCases/ConfirmTx/SubmittedTx/SubmittedTxScreen.stories.tsx"), "./src/features/Send/useCases/ListAmountsToSend/AddToken/AddToken.stories.tsx": require("../src/features/Send/useCases/ListAmountsToSend/AddToken/AddToken.stories.tsx"), diff --git a/apps/wallet-mobile/src/WalletNavigator.tsx b/apps/wallet-mobile/src/WalletNavigator.tsx index b67c47d16a..c1d3857a7f 100644 --- a/apps/wallet-mobile/src/WalletNavigator.tsx +++ b/apps/wallet-mobile/src/WalletNavigator.tsx @@ -24,6 +24,7 @@ import {useLinksShowActionResult} from './features/Links/common/useLinksShowActi import {MenuNavigator} from './features/Menu/Menu' import {PortfolioNavigator} from './features/Portfolio/PortfolioNavigator' import {CatalystNavigator} from './features/RegisterCatalyst/CatalystNavigator' +import {ReviewTxNavigator} from './features/ReviewTx/ReviewTxNavigator' import {SearchProvider} from './features/Search/SearchContext' import {SettingsScreenNavigator} from './features/Settings' import {NetworkTag} from './features/Settings/ChangeNetwork/NetworkTag' @@ -258,6 +259,8 @@ export const WalletNavigator = () => { + + { divider: color.gray_200, } - return {colors, styles} + return {colors, styles} as const } const messages = defineMessages({ diff --git a/apps/wallet-mobile/src/components/Icon/Direction.tsx b/apps/wallet-mobile/src/components/Icon/Direction.tsx index 3f803c624d..7d1e4f714b 100644 --- a/apps/wallet-mobile/src/components/Icon/Direction.tsx +++ b/apps/wallet-mobile/src/components/Icon/Direction.tsx @@ -1,6 +1,6 @@ import {ThemedPalette, useTheme} from '@yoroi/theme' import React from 'react' -import {StyleSheet, View} from 'react-native' +import {StyleSheet, View, ViewStyle} from 'react-native' import {TransactionDirection, TransactionInfo} from '../../yoroi-wallets/types/other' import {Received} from '../Icon/Received' @@ -11,9 +11,10 @@ import {MultiParty} from './MultiParty' type Props = { transaction: TransactionInfo size?: number + containerStyle?: ViewStyle } -export const Direction = ({transaction, size = defaultSize}: Props) => { +export const Direction = ({transaction, size = defaultSize, containerStyle}: Props) => { const {color} = useTheme() const {direction} = transaction @@ -21,7 +22,7 @@ export const Direction = ({transaction, size = defaultSize}: Props) => { const IconComponent = iconMap[direction] return ( - + ) diff --git a/apps/wallet-mobile/src/components/Info/Info.tsx b/apps/wallet-mobile/src/components/Info/Info.tsx new file mode 100644 index 0000000000..b4272cc14e --- /dev/null +++ b/apps/wallet-mobile/src/components/Info/Info.tsx @@ -0,0 +1,47 @@ +import {useTheme} from '@yoroi/theme' +import React, {ReactNode} from 'react' +import {StyleSheet, Text, View} from 'react-native' + +import {Icon} from '../Icon' +import {Space} from '../Space/Space' + +type Props = { + content: ReactNode + iconSize?: number +} + +export const Info = ({content, iconSize = 30}: Props) => { + const {styles, colors} = useStyles() + + return ( + + + + + + {content} + + ) +} + +const useStyles = () => { + const {color, atoms} = useTheme() + const styles = StyleSheet.create({ + notice: { + backgroundColor: color.sys_cyan_100, + ...atoms.p_md, + ...atoms.rounded_sm, + }, + text: { + ...atoms.body_2_md_regular, + color: color.text_gray_max, + }, + }) + + const colors = { + yellow: color.sys_orange_500, + blue: color.primary_500, + } + + return {colors, styles} as const +} diff --git a/apps/wallet-mobile/src/components/Warning/Warning.tsx b/apps/wallet-mobile/src/components/Warning/Warning.tsx index 320ee76732..ce1a959985 100644 --- a/apps/wallet-mobile/src/components/Warning/Warning.tsx +++ b/apps/wallet-mobile/src/components/Warning/Warning.tsx @@ -5,7 +5,10 @@ import {StyleSheet, Text, View} from 'react-native' import {Icon} from '../Icon' import {Space} from '../Space/Space' -type Props = {content: ReactNode; iconSize?: number} +type Props = { + content: ReactNode + iconSize?: number +} export const Warning = ({content, iconSize = 30}: Props) => { const {styles, colors} = useStyles() @@ -26,8 +29,8 @@ const useStyles = () => { const styles = StyleSheet.create({ notice: { backgroundColor: color.sys_yellow_100, - padding: 12, - borderRadius: 8, + ...atoms.p_md, + ...atoms.rounded_sm, }, text: { ...atoms.body_2_md_regular, @@ -39,5 +42,5 @@ const useStyles = () => { yellow: color.sys_orange_500, } - return {colors, styles} + return {colors, styles} as const } diff --git a/apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/ReviewTransaction.tsx b/apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/ReviewTransaction.tsx index bfbf280cd5..2bd93f55f2 100644 --- a/apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/ReviewTransaction.tsx +++ b/apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/ReviewTransaction.tsx @@ -447,7 +447,7 @@ const useConnectorPromptRootKey = () => { }, [promptRootKey]) } -const useSignTxWithHW = () => { +export const useSignTxWithHW = () => { const {confirmHWConnection, closeModal} = useConfirmHWConnectionModal() const {wallet, meta} = useSelectedWallet() diff --git a/apps/wallet-mobile/src/features/ReviewTx/ReviewTxNavigator.tsx b/apps/wallet-mobile/src/features/ReviewTx/ReviewTxNavigator.tsx new file mode 100644 index 0000000000..4f28322aff --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/ReviewTxNavigator.tsx @@ -0,0 +1,36 @@ +import {createStackNavigator} from '@react-navigation/stack' +import {Atoms, ThemedPalette, useTheme} from '@yoroi/theme' +import React from 'react' + +import {Boundary} from '../../components/Boundary/Boundary' +import {defaultStackNavigationOptions, ReviewTxRoutes} from '../../kernel/navigation' +import {useStrings} from './common/hooks/useStrings' +import {ReviewTxScreen} from './useCases/ReviewTxScreen/ReviewTxScreen' + +export const Stack = createStackNavigator() + +export const ReviewTxNavigator = () => { + const {atoms, color} = useTheme() + const strings = useStrings() + + return ( + + + {() => ( + + + + )} + + + ) +} + +const screenOptions = (atoms: Atoms, color: ThemedPalette) => ({ + ...defaultStackNavigationOptions(atoms, color), + gestureEnabled: true, +}) diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/Address.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/Address.tsx new file mode 100644 index 0000000000..5279b07df9 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/Address.tsx @@ -0,0 +1,73 @@ +import {useTheme} from '@yoroi/theme' +import * as React from 'react' +import {StyleSheet, Text, TextStyle, TouchableOpacity, View} from 'react-native' + +import {Icon} from '../../../components/Icon' +import {Space} from '../../../components/Space/Space' +import {useCopy} from '../../../hooks/useCopy' + +export const Address = ({ + address, + index, + textStyle, + multiline = false, +}: { + address: string + index?: number + textStyle?: TextStyle + multiline?: boolean +}) => { + const {styles, colors} = useStyles() + const [, copy] = useCopy() + + return ( + + + {address} + + + {index !== undefined && ( + <> + + + {`#${index}`} + + + + )} + + copy(address)} activeOpacity={0.5}> + + + + ) +} + +const useStyles = () => { + const {atoms, color} = useTheme() + const styles = StyleSheet.create({ + address: { + ...atoms.flex_row, + ...atoms.justify_between, + }, + addressText: { + ...atoms.flex_1, + ...atoms.body_2_md_regular, + color: color.text_gray_medium, + }, + index: { + ...atoms.body_2_md_medium, + color: color.text_gray_medium, + }, + }) + + const colors = { + copy: color.gray_900, + } + + return {styles, colors} as const +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/CollapsibleSection.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/CollapsibleSection.tsx new file mode 100644 index 0000000000..048ac41d0e --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/CollapsibleSection.tsx @@ -0,0 +1,71 @@ +import {useTheme} from '@yoroi/theme' +import * as React from 'react' +import {Animated, LayoutAnimation, StyleSheet, Text, TouchableOpacity, View} from 'react-native' + +import {Icon} from '../../../components/Icon' + +export const CollapsibleSection = ({label, children}: {label: string; children: React.ReactNode}) => { + const {styles, colors} = useStyles() + const [isOpen, setIsOpen] = React.useState(false) + const animatedHeight = React.useRef(new Animated.Value(0)).current + + const toggleSection = () => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setIsOpen(!isOpen) + Animated.timing(animatedHeight, { + toValue: isOpen ? 0 : 1, + duration: 300, + useNativeDriver: false, + }).start() + } + + return ( + <> + + {label} + + + + + + + + {children} + + + ) +} + +const useStyles = () => { + const {atoms, color} = useTheme() + const styles = StyleSheet.create({ + sectionHeader: { + ...atoms.flex_row, + ...atoms.justify_between, + }, + sectionHeaderText: { + ...atoms.body_1_lg_medium, + color: color.text_gray_medium, + }, + childrenContainer: { + overflow: 'hidden', + }, + }) + + const colors = { + chevron: color.gray_900, + } + + return {styles, colors} as const +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/Divider.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/Divider.tsx new file mode 100644 index 0000000000..1733930fda --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/Divider.tsx @@ -0,0 +1,31 @@ +import {SpacingSize, useTheme} from '@yoroi/theme' +import * as React from 'react' +import {StyleSheet, View} from 'react-native' + +import {Space} from '../../../components/Space/Space' + +export const Divider = ({verticalSpace = 'none'}: {verticalSpace?: SpacingSize}) => { + const {styles} = useStyles() + return ( + <> + + + + + + + ) +} + +const useStyles = () => { + const {atoms, color} = useTheme() + const styles = StyleSheet.create({ + divider: { + height: 1, + ...atoms.align_stretch, + backgroundColor: color.gray_200, + }, + }) + + return {styles} as const +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/TokenItem.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/TokenItem.tsx new file mode 100644 index 0000000000..751a3bbd56 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/TokenItem.tsx @@ -0,0 +1,76 @@ +import {useTheme} from '@yoroi/theme' +import * as React from 'react' +import {StyleSheet, Text, View} from 'react-native' + +export const TokenItem = ({ + isPrimaryToken = true, + isSent = true, + label, +}: { + isPrimaryToken?: boolean + isSent?: boolean + label: string +}) => { + const {styles} = useStyles() + + if (!isSent) + return ( + + + {label} + + + ) + + return ( + + {label} + + ) +} + +const useStyles = () => { + const {atoms, color} = useTheme() + const styles = StyleSheet.create({ + sentTokenItem: { + ...atoms.flex, + ...atoms.flex_row, + ...atoms.align_center, + ...atoms.py_xs, + ...atoms.px_md, + borderRadius: 8, + backgroundColor: color.primary_500, + }, + receivedTokenItem: { + ...atoms.flex, + ...atoms.flex_row, + ...atoms.align_center, + ...atoms.py_xs, + ...atoms.px_md, + borderRadius: 8, + backgroundColor: color.secondary_300, + }, + tokenSentItemText: { + ...atoms.body_2_md_regular, + color: color.white_static, + }, + tokenReceivedItemText: { + ...atoms.body_2_md_regular, + color: color.text_gray_max, + }, + notPrimarySentTokenItem: { + backgroundColor: color.primary_100, + }, + notPrimaryReceivedTokenItem: { + backgroundColor: color.secondary_100, + }, + notPrimarySentTokenItemText: { + color: color.text_primary_medium, + }, + notPrimaryReceivedTokenItemText: { + color: color.secondary_700, + }, + }) + + return {styles} as const +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useAddressType.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useAddressType.tsx new file mode 100644 index 0000000000..6e054df944 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useAddressType.tsx @@ -0,0 +1,12 @@ +import {useQuery} from 'react-query' + +import {getAddressType} from '../../../../yoroi-wallets/cardano/utils' + +export const useAddressType = (address: string) => { + const query = useQuery(['useAddressType', address], () => getAddressType(address), { + suspense: true, + }) + + if (query.data === undefined) throw new Error('invalid address type') + return query.data +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useFormattedTx.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useFormattedTx.tsx new file mode 100644 index 0000000000..08a8d1ebfe --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useFormattedTx.tsx @@ -0,0 +1,226 @@ +import {invalid, isNonNullable} from '@yoroi/common' +import {infoExtractName} from '@yoroi/portfolio' +import {Portfolio} from '@yoroi/types' +import * as _ from 'lodash' +import {useQuery} from 'react-query' + +import {YoroiWallet} from '../../../../yoroi-wallets/cardano/types' +import {wrappedCsl} from '../../../../yoroi-wallets/cardano/wrappedCsl' +import {formatTokenWithText} from '../../../../yoroi-wallets/utils/format' +import {asQuantity} from '../../../../yoroi-wallets/utils/utils' +import {usePortfolioTokenInfos} from '../../../Portfolio/common/hooks/usePortfolioTokenInfos' +import {useSelectedWallet} from '../../../WalletManager/common/hooks/useSelectedWallet' +import { + FormattedFee, + FormattedInputs, + FormattedOutputs, + TransactionBody, + TransactionInputs, + TransactionOutputs, +} from '../types' + +export type FormattedTx = ReturnType +export const useFormattedTx = (data: TransactionBody) => { + const {wallet} = useSelectedWallet() + + const inputs = data?.inputs ?? [] + const outputs = data?.outputs ?? [] + + const inputTokenIds = inputs.flatMap((i) => { + const receiveUTxO = getUtxoByTxIdAndIndex(wallet, i.transaction_id, i.index) + return receiveUTxO?.assets.map((a) => `${a.policyId}.${a.assetId}` as Portfolio.Token.Id) ?? [] + }) + + const outputTokenIds = outputs.flatMap((o) => { + if (!o.amount.multiasset) return [] + const policyIds = Object.keys(o.amount.multiasset) + const tokenIds = policyIds.flatMap((policyId) => { + const assetIds = Object.keys(o.amount.multiasset?.[policyId] ?? {}) + return assetIds.map((assetId) => `${policyId}.${assetId}` as Portfolio.Token.Id) + }) + return tokenIds + }) + + const tokenIds = _.uniq([...inputTokenIds, ...outputTokenIds]) + const portfolioTokenInfos = usePortfolioTokenInfos({wallet, tokenIds}, {suspense: true}) + + const formattedInputs = useFormattedInputs(wallet, inputs, portfolioTokenInfos) + const formattedOutputs = useFormattedOutputs(wallet, outputs, portfolioTokenInfos) + const formattedFee = formatFee(wallet, data) + + return {inputs: formattedInputs, outputs: formattedOutputs, fee: formattedFee} +} + +export const useFormattedInputs = ( + wallet: YoroiWallet, + inputs: TransactionInputs, + tokenInfosResult: ReturnType, +) => { + const query = useQuery( + ['useFormattedInputs', inputs], + async () => formatInputs(wallet, inputs, tokenInfosResult), + { + suspense: true, + }, + ) + + if (!query.data) throw new Error('invalid formatted inputs') + return query.data +} + +export const useFormattedOutputs = ( + wallet: YoroiWallet, + outputs: TransactionOutputs, + portfolioTokenInfos: ReturnType, +) => { + const query = useQuery( + ['useFormattedOutputs', outputs], + () => formatOutputs(wallet, outputs, portfolioTokenInfos), + { + suspense: true, + }, + ) + + if (!query.data) throw new Error('invalid formatted outputs') + return query.data +} + +const formatInputs = async ( + wallet: YoroiWallet, + inputs: TransactionInputs, + portfolioTokenInfos: ReturnType, +): Promise => { + return Promise.all( + inputs.map(async (input) => { + const receiveUTxO = getUtxoByTxIdAndIndex(wallet, input.transaction_id, input.index) + const address = receiveUTxO?.receiver + const rewardAddress = + address !== undefined ? await deriveRewardAddressFromAddress(address, wallet.networkManager.chainId) : null + const coin = receiveUTxO?.amount != null ? asQuantity(receiveUTxO.amount) : null + + const primaryAssets = + coin != null + ? [ + { + name: wallet.portfolioPrimaryTokenInfo.name, + label: formatTokenWithText(coin, wallet.portfolioPrimaryTokenInfo), + quantity: coin, + isPrimary: true, + }, + ] + : [] + + const multiAssets = + receiveUTxO?.assets + .map((a) => { + const tokenInfo = portfolioTokenInfos.tokenInfos?.get(a.assetId as Portfolio.Token.Id) + if (!tokenInfo) return null + const quantity = asQuantity(a.amount) + + return { + name: infoExtractName(tokenInfo), + label: formatTokenWithText(quantity, tokenInfo), + quantity: quantity, + isPrimary: false, + } + }) + .filter(Boolean) ?? [] + + return { + assets: [...primaryAssets, ...multiAssets].filter(isNonNullable), + address, + rewardAddress, + ownAddress: address != null && isOwnedAddress(wallet, address), + txIndex: input.index, + txHash: input.transaction_id, + } + }), + ) +} + +const formatOutputs = async ( + wallet: YoroiWallet, + outputs: TransactionOutputs, + portfolioTokenInfos: ReturnType, +): Promise => { + return Promise.all( + outputs.map(async (output) => { + const address = output.address + const rewardAddress = await deriveRewardAddressFromAddress(address, wallet.networkManager.chainId) + const coin = asQuantity(output.amount.coin) + + const primaryAssets = [ + { + name: wallet.portfolioPrimaryTokenInfo.name, + label: formatTokenWithText(coin, wallet.portfolioPrimaryTokenInfo), + quantity: coin, + isPrimary: true, + }, + ] + + const multiAssets = output.amount.multiasset + ? Object.entries(output.amount.multiasset).flatMap(([policyId, assets]) => { + return Object.entries(assets).map(([assetId, amount]) => { + const tokenInfo = portfolioTokenInfos.tokenInfos?.get(`${policyId}.${assetId}`) + if (tokenInfo == null) return null + const quantity = asQuantity(amount) + + return { + name: infoExtractName(tokenInfo), + label: formatTokenWithText(quantity, tokenInfo), + quantity, + isPrimary: false, + } + }) + }) + : [] + + const assets = [...primaryAssets, ...multiAssets].filter(isNonNullable) + + return { + assets, + address, + rewardAddress, + ownAddress: isOwnedAddress(wallet, address), + } + }), + ) +} + +export const formatFee = (wallet: YoroiWallet, data: TransactionBody): FormattedFee => { + const fee = asQuantity(data?.fee ?? '0') + + return { + name: wallet.portfolioPrimaryTokenInfo.name, + label: formatTokenWithText(fee, wallet.portfolioPrimaryTokenInfo), + quantity: fee, + isPrimary: true, + } +} + +export const deriveRewardAddressFromAddress = async (address: string, chainId: number): Promise => { + const {csl, release} = wrappedCsl() + + try { + const result = await csl.Address.fromBech32(address) + .then((address) => csl.BaseAddress.fromAddress(address)) + .then((baseAddress) => baseAddress?.stakeCred() ?? invalid('invalid base address')) + .then((stakeCredential) => csl.RewardAddress.new(chainId, stakeCredential)) + .then((rewardAddress) => rewardAddress.toAddress()) + .then((rewardAddrAsAddress) => rewardAddrAsAddress.toBech32(undefined)) + .catch((error) => error) + + if (typeof result !== 'string') throw new Error('Its not possible to derive reward address') + return result + } finally { + release() + } +} + +const getUtxoByTxIdAndIndex = (wallet: YoroiWallet, txId: string, index: number) => { + return wallet.utxos.find((u) => u.tx_hash === txId && u.tx_index === index) +} + +const isOwnedAddress = (wallet: YoroiWallet, bech32Address: string) => { + return wallet.internalAddresses.includes(bech32Address) || wallet.externalAddresses.includes(bech32Address) +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useOnConfirm.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useOnConfirm.tsx new file mode 100644 index 0000000000..dfb076eab5 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useOnConfirm.tsx @@ -0,0 +1,71 @@ +import * as React from 'react' + +import {ConfirmTxWithHwModal} from '../../../../components/ConfirmTxWithHwModal/ConfirmTxWithHwModal' +import {ConfirmTxWithOsModal} from '../../../../components/ConfirmTxWithOsModal/ConfirmTxWithOsModal' +import {ConfirmTxWithSpendingPasswordModal} from '../../../../components/ConfirmTxWithSpendingPasswordModal/ConfirmTxWithSpendingPasswordModal' +import {useModal} from '../../../../components/Modal/ModalContext' +import {YoroiSignedTx, YoroiUnsignedTx} from '../../../../yoroi-wallets/types/yoroi' +import {useSelectedWallet} from '../../../WalletManager/common/hooks/useSelectedWallet' +import {useStrings} from './useStrings' + +// TODO: make it compatible with CBOR signing +export const useOnConfirm = ({ + unsignedTx, + onSuccess, + onError, + onNotSupportedCIP1694, +}: { + onSuccess: (txId: YoroiSignedTx) => void + onError: () => void + cbor?: string + unsignedTx?: YoroiUnsignedTx + onNotSupportedCIP1694?: () => void +}) => { + if (unsignedTx === undefined) throw new Error('useOnConfirm: unsignedTx missing') + + const {meta} = useSelectedWallet() + const {openModal, closeModal} = useModal() + const strings = useStrings() + + const onConfirm = () => { + if (meta.isHW) { + openModal( + strings.signTransaction, + onSuccess(signedTx)} + onNotSupportedCIP1694={onNotSupportedCIP1694} + />, + 400, + ) + return + } + + if (!meta.isHW && !meta.isEasyConfirmationEnabled) { + openModal( + strings.signTransaction, + onSuccess(signedTx)} + onError={onError} + />, + ) + return + } + + if (!meta.isHW && meta.isEasyConfirmationEnabled) { + openModal( + strings.signTransaction, + onSuccess(signedTx)} + onError={onError} + />, + ) + return + } + } + + return {onConfirm} +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useStrings.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useStrings.tsx new file mode 100644 index 0000000000..54d8581849 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useStrings.tsx @@ -0,0 +1,84 @@ +import {defineMessages, useIntl} from 'react-intl' + +import {txLabels} from '../../../../kernel/i18n/global-messages' + +export const useStrings = () => { + const intl = useIntl() + + return { + signTransaction: intl.formatMessage(txLabels.signingTx), + confirm: intl.formatMessage(messages.confirm), + title: intl.formatMessage(messages.title), + utxosTab: intl.formatMessage(messages.utxosTab), + overviewTab: intl.formatMessage(messages.overviewTab), + walletLabel: intl.formatMessage(messages.walletLabel), + feeLabel: intl.formatMessage(messages.feeLabel), + myWalletLabel: intl.formatMessage(messages.myWalletLabel), + sendLabel: intl.formatMessage(messages.sendLabel), + receiveToLabel: intl.formatMessage(messages.receiveToLabel), + receiveToScriptLabel: intl.formatMessage(messages.receiveToScriptLabel), + utxosInputsLabel: intl.formatMessage(messages.utxosInputsLabel), + utxosOutputsLabel: intl.formatMessage(messages.utxosOutputsLabel), + utxosYourAddressLabel: intl.formatMessage(messages.utxosYourAddressLabel), + utxosForeignAddressLabel: intl.formatMessage(messages.utxosForeignAddressLabel), + } +} + +const messages = defineMessages({ + confirm: { + id: 'txReview.confirm', + defaultMessage: '!!!Confirm', + }, + title: { + id: 'txReview.title', + defaultMessage: '!!!UTxOs', + }, + utxosTab: { + id: 'txReview.tabLabel.utxos', + defaultMessage: '!!!UTxOs', + }, + overviewTab: { + id: 'txReview.tabLabel.overview', + defaultMessage: '!!!Overview', + }, + walletLabel: { + id: 'txReview.overview.wallet', + defaultMessage: '!!!Wallet', + }, + feeLabel: { + id: 'txReview.fee', + defaultMessage: '!!!Fee', + }, + myWalletLabel: { + id: 'txReview.overview.myWalletLabel', + defaultMessage: '!!!Your Wallet', + }, + sendLabel: { + id: 'txReview.overview.sendLabel', + defaultMessage: '!!!Send', + }, + receiveToLabel: { + id: 'txReview.overview.receiveToLabel', + defaultMessage: '!!!receiveToLabel', + }, + receiveToScriptLabel: { + id: 'txReview.overview.receiveToScriptLabel', + defaultMessage: '!!!To script', + }, + utxosInputsLabel: { + id: 'txReview.utxos.utxosInputsLabel', + defaultMessage: '!!!Inputs', + }, + utxosOutputsLabel: { + id: 'txReview.utxos.utxosOutputsLabel', + defaultMessage: '!!!Outputs', + }, + utxosYourAddressLabel: { + id: 'txReview.utxos.utxosYourAddressLabel', + defaultMessage: '!!!Your address', + }, + utxosForeignAddressLabel: { + id: 'txReview.utxos.utxosForeignAddressLabel', + defaultMessage: '!!!Foreign address', + }, +}) diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useTxBody.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useTxBody.tsx new file mode 100644 index 0000000000..9de33bb075 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useTxBody.tsx @@ -0,0 +1,44 @@ +import {useQuery} from 'react-query' + +import {wrappedCsl} from '../../../../yoroi-wallets/cardano/wrappedCsl' +import {YoroiUnsignedTx} from '../../../../yoroi-wallets/types/yoroi' + +export const useTxBody = ({cbor, unsignedTx}: {cbor?: string; unsignedTx?: YoroiUnsignedTx}) => { + const query = useQuery( + ['useTxBody', cbor, unsignedTx], + async () => { + if (cbor !== undefined) { + return getCborTxBody(cbor) + } else if (unsignedTx !== undefined) { + return getUnsignedTxTxBody(unsignedTx) + } else { + throw new Error('useTxBody: missing cbor and unsignedTx') + } + }, + { + useErrorBoundary: true, + suspense: true, + }, + ) + + if (query.data === undefined) throw new Error('useTxBody: cannot extract txBody') + return query.data +} +const getCborTxBody = async (cbor: string) => { + const {csl, release} = wrappedCsl() + try { + const tx = await csl.Transaction.fromHex(cbor) + const jsonString = await tx.toJson() + return JSON.parse(jsonString).body + } finally { + release() + } +} + +const getUnsignedTxTxBody = async (unsignedTx: YoroiUnsignedTx) => { + const { + unsignedTx: {txBody}, + } = unsignedTx + const txBodyjson = await txBody.toJson() + return JSON.parse(txBodyjson) +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/mocks.ts b/apps/wallet-mobile/src/features/ReviewTx/common/mocks.ts new file mode 100644 index 0000000000..12fbe9274a --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/mocks.ts @@ -0,0 +1,148 @@ +import {TransactionBody} from './types' + +export const adaTransactionSingleReceiver: TransactionBody = { + inputs: [ + { + transaction_id: '46fe71d85a733d970fe7bb8e6586624823803936d18c7e14601713d05b5b287a', + index: 0, + }, + { + transaction_id: '9638640d421875f068d10a0125023601bbd7e83e7f17b721c9c06c97cc29ff66', + index: 1, + }, + ], + outputs: [ + { + address: + 'addr1qyf4x8lvcyrwcxzkyz3lykyzfu7s7x307dlafgsu89qzge8lfl229ahk888cgakug24y86qtduvn065c3gw7dg5002cqdskm74', + amount: { + coin: '12000000', + multiasset: null, + }, + plutus_data: null, + script_ref: null, + }, + { + address: + 'addr1q9xy5p0cz2zsjrzpg4tl59mltjmfh07yc28alchxjlanygk0ppwv8x4ylafdu84xqmh9sx4vrk4czekksv884xmvanwqde82xg', + amount: { + coin: '23464562', + multiasset: null, + }, + plutus_data: null, + script_ref: null, + }, + ], + fee: '174345', + ttl: '220373661', + certs: null, + withdrawals: null, + update: null, + auxiliary_data_hash: null, + validity_start_interval: null, + mint: null, + script_data_hash: null, + collateral: null, + required_signers: null, + network_id: null, + collateral_return: null, + total_collateral: null, + reference_inputs: null, + voting_procedures: null, + voting_proposals: null, + donation: null, + current_treasury_value: null, +} + +export const multiAssetsTransactionOneReceiver: TransactionBody = { + inputs: [ + { + transaction_id: '46fe71d85a733d970fe7bb8e6586624823803936d18c7e14601713d05b5b287a', + index: 0, + }, + { + transaction_id: 'bddd3e0b43b9b93f6d49190a9d4d55c3cd28e3d270b0f1bbc0f83b8ecc3e373a', + index: 1, + }, + ], + outputs: [ + { + address: + 'addr1qyf4x8lvcyrwcxzkyz3lykyzfu7s7x307dlafgsu89qzge8lfl229ahk888cgakug24y86qtduvn065c3gw7dg5002cqdskm74', + amount: { + coin: '10000000', + multiasset: { + cdaaee586376139ee8c3cc4061623968810d177ca5c300afb890b48a: { + '43415354': '5', + }, + f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a: { + '000de1406a6176696275656e6f': '1', + }, + }, + }, + plutus_data: null, + script_ref: null, + }, + { + address: + 'addr1q9xy5p0cz2zsjrzpg4tl59mltjmfh07yc28alchxjlanygk0ppwv8x4ylafdu84xqmh9sx4vrk4czekksv884xmvanwqde82xg', + amount: { + coin: '2228270', + multiasset: { + '2441ab3351c3b80213a98f4e09ddcf7dabe4879c3c94cc4e7205cb63': { + '46495245': '2531', + }, + '279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3f': { + '534e454b': '204', + }, + '4cb48d60d1f7823d1307c61b9ecf472ff78cf22d1ccc5786d59461f8': { + '4144414d4f4f4e': '4983996', + }, + a0028f350aaabe0545fdcb56b039bfb08e4bb4d8c4d7c3c7d481c235: { + '484f534b59': '115930085', + }, + cdaaee586376139ee8c3cc4061623968810d177ca5c300afb890b48a: { + '43415354': '4503', + }, + e0c4c2d7c4a0ed2cf786753fd845dee82c45512cee03e92adfd3fb8d: { + '6a6176696275656e6f2e616461': '1', + }, + fc411f546d01e88a822200243769bbc1e1fbdde8fa0f6c5179934edb: { + '6a6176696275656e6f': '1', + }, + }, + }, + plutus_data: null, + script_ref: null, + }, + { + address: + 'addr1q9xy5p0cz2zsjrzpg4tl59mltjmfh07yc28alchxjlanygk0ppwv8x4ylafdu84xqmh9sx4vrk4czekksv884xmvanwqde82xg', + amount: { + coin: '2300311', + multiasset: null, + }, + plutus_data: null, + script_ref: null, + }, + ], + fee: '189349', + ttl: '93045', + certs: null, + withdrawals: null, + update: null, + auxiliary_data_hash: null, + validity_start_interval: null, + mint: null, + script_data_hash: null, + collateral: null, + required_signers: null, + network_id: null, + collateral_return: null, + total_collateral: null, + reference_inputs: null, + voting_procedures: null, + voting_proposals: null, + donation: null, + current_treasury_value: null, +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/types.ts b/apps/wallet-mobile/src/features/ReviewTx/common/types.ts new file mode 100644 index 0000000000..935f333b56 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/types.ts @@ -0,0 +1,891 @@ +import {Balance} from '@yoroi/types' + +export type TransactionDetails = { + id: string + walletPlate: React.ReactNode + walletName: string + createdBy: string | null + fee: string + txBody: TransactionBody +} + +export type Address = string +export type URL = string + +export interface Anchor { + anchor_data_hash: string + anchor_url: URL +} +export type AnchorDataHash = string +export type AssetName = string +export type AssetNames = string[] +export interface Assets { + [k: string]: string +} +export type NativeScript = + | { + ScriptPubkey: ScriptPubkey + } + | { + ScriptAll: ScriptAll + } + | { + ScriptAny: ScriptAny + } + | { + ScriptNOfK: ScriptNOfK + } + | { + TimelockStart: TimelockStart + } + | { + TimelockExpiry: TimelockExpiry + } +export type NativeScripts = NativeScript[] +export type PlutusScripts = string[] + +export interface AuxiliaryData { + metadata?: { + [k: string]: string + } | null + native_scripts?: NativeScripts | null + plutus_scripts?: PlutusScripts | null + prefer_alonzo_format: boolean +} +export interface ScriptPubkey { + addr_keyhash: string +} +export interface ScriptAll { + native_scripts: NativeScripts +} +export interface ScriptAny { + native_scripts: NativeScripts +} +export interface ScriptNOfK { + n: number + native_scripts: NativeScripts +} +export interface TimelockStart { + slot: string +} +export interface TimelockExpiry { + slot: string +} +export type AuxiliaryDataHash = string +export interface AuxiliaryDataSet { + [k: string]: AuxiliaryData +} +export type BigInt = string +export type BigNum = string +export type Vkey = string +export type HeaderLeaderCertEnum = + | { + /** + * @minItems 2 + * @maxItems 2 + */ + NonceAndLeader: [VRFCert, VRFCert] + } + | { + VrfResult: VRFCert + } +export type Certificate = + | { + StakeRegistration: StakeRegistration + } + | { + StakeDeregistration: StakeDeregistration + } + | { + StakeDelegation: StakeDelegation + } + | { + PoolRegistration: PoolRegistration + } + | { + PoolRetirement: PoolRetirement + } + | { + GenesisKeyDelegation: GenesisKeyDelegation + } + | { + MoveInstantaneousRewardsCert: MoveInstantaneousRewardsCert + } + | { + CommitteeHotAuth: CommitteeHotAuth + } + | { + CommitteeColdResign: CommitteeColdResign + } + | { + DRepDeregistration: DRepDeregistration + } + | { + DRepRegistration: DRepRegistration + } + | { + DRepUpdate: DRepUpdate + } + | { + StakeAndVoteDelegation: StakeAndVoteDelegation + } + | { + StakeRegistrationAndDelegation: StakeRegistrationAndDelegation + } + | { + StakeVoteRegistrationAndDelegation: StakeVoteRegistrationAndDelegation + } + | { + VoteDelegation: VoteDelegation + } + | { + VoteRegistrationAndDelegation: VoteRegistrationAndDelegation + } +export type CredType = + | { + Key: string + } + | { + Script: string + } +export type Relay = + | { + SingleHostAddr: SingleHostAddr + } + | { + SingleHostName: SingleHostName + } + | { + MultiHostName: MultiHostName + } +/** + * @minItems 4 + * @maxItems 4 + */ +export type Ipv4 = [number, number, number, number] +/** + * @minItems 16 + * @maxItems 16 + */ +export type Ipv6 = [ + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, +] +export type DNSRecordAorAAAA = string +export type DNSRecordSRV = string +export type Relays = Relay[] +export type MIRPot = 'Reserves' | 'Treasury' +export type MIREnum = + | { + ToOtherPot: string + } + | { + ToStakeCredentials: StakeToCoin[] + } +export type DRep = + | ('AlwaysAbstain' | 'AlwaysNoConfidence') + | { + KeyHash: string + } + | { + ScriptHash: string + } +export type DataOption = + | { + DataHash: string + } + | { + Data: string + } +export type ScriptRef = + | { + NativeScript: NativeScript + } + | { + PlutusScript: string + } +export type Mint = [string, MintAssets][] +export type NetworkId = 'Testnet' | 'Mainnet' +export type TransactionOutputs = TransactionOutput[] +export type CostModel = string[] +export type Voter = + | { + ConstitutionalCommitteeHotCred: CredType + } + | { + DRep: CredType + } + | { + StakingPool: string + } +export type VoteKind = 'No' | 'Yes' | 'Abstain' +export type GovernanceAction = + | { + ParameterChangeAction: ParameterChangeAction + } + | { + HardForkInitiationAction: HardForkInitiationAction + } + | { + TreasuryWithdrawalsAction: TreasuryWithdrawalsAction + } + | { + NoConfidenceAction: NoConfidenceAction + } + | { + UpdateCommitteeAction: UpdateCommitteeAction + } + | { + NewConstitutionAction: NewConstitutionAction + } + | { + InfoAction: InfoAction + } +/** + * @minItems 0 + * @maxItems 0 + */ +export type InfoAction = [] +export type TransactionBodies = TransactionBody[] +export type RedeemerTag = 'Spend' | 'Mint' | 'Cert' | 'Reward' | 'Vote' | 'VotingProposal' +export type TransactionWitnessSets = TransactionWitnessSet[] + +export interface Block { + auxiliary_data_set: { + [k: string]: AuxiliaryData + } + header: Header + invalid_transactions: number[] + transaction_bodies: TransactionBodies + transaction_witness_sets: TransactionWitnessSets +} +export interface Header { + body_signature: string + header_body: HeaderBody +} +export interface HeaderBody { + block_body_hash: string + block_body_size: number + block_number: number + issuer_vkey: Vkey + leader_cert: HeaderLeaderCertEnum + operational_cert: OperationalCert + prev_hash?: string | null + protocol_version: ProtocolVersion + slot: string + vrf_vkey: string +} +export interface VRFCert { + output: number[] + proof: number[] +} +export interface OperationalCert { + hot_vkey: string + kes_period: number + sequence_number: number + sigma: string +} +export interface ProtocolVersion { + major: number + minor: number +} +export interface TransactionBody { + auxiliary_data_hash?: string | null + certs?: Certificate[] | null + collateral?: TransactionInput[] | null + collateral_return?: TransactionOutput | null + current_treasury_value?: string | null + donation?: string | null + fee: string + inputs: TransactionInput[] + mint?: Mint | null + network_id?: NetworkId | null + outputs: TransactionOutputs + reference_inputs?: TransactionInput[] | null + required_signers?: string[] | null + script_data_hash?: string | null + total_collateral?: string | null + ttl?: string | null + update?: Update | null + validity_start_interval?: string | null + voting_procedures?: VoterVotes[] | null + voting_proposals?: VotingProposal[] | null + withdrawals?: { + [k: string]: string + } | null +} +export interface StakeRegistration { + coin?: string | null + stake_credential: CredType +} +export interface StakeDeregistration { + coin?: string | null + stake_credential: CredType +} +export interface StakeDelegation { + pool_keyhash: string + stake_credential: CredType +} +export interface PoolRegistration { + pool_params: PoolParams +} +export interface PoolParams { + cost: string + margin: UnitInterval + operator: string + pledge: string + pool_metadata?: PoolMetadata | null + pool_owners: string[] + relays: Relays + reward_account: string + vrf_keyhash: string +} +export interface UnitInterval { + denominator: string + numerator: string +} +export interface PoolMetadata { + pool_metadata_hash: string + url: URL +} +export interface SingleHostAddr { + ipv4?: Ipv4 | null + ipv6?: Ipv6 | null + port?: number | null +} +export interface SingleHostName { + dns_name: DNSRecordAorAAAA + port?: number | null +} +export interface MultiHostName { + dns_name: DNSRecordSRV +} +export interface PoolRetirement { + epoch: number + pool_keyhash: string +} +export interface GenesisKeyDelegation { + genesis_delegate_hash: string + genesishash: string + vrf_keyhash: string +} +export interface MoveInstantaneousRewardsCert { + move_instantaneous_reward: MoveInstantaneousReward +} +export interface MoveInstantaneousReward { + pot: MIRPot + variant: MIREnum +} +export interface StakeToCoin { + amount: string + stake_cred: CredType +} +export interface CommitteeHotAuth { + committee_cold_credential: CredType + committee_hot_credential: CredType +} +export interface CommitteeColdResign { + anchor?: Anchor | null + committee_cold_credential: CredType +} +export interface DRepDeregistration { + coin: string + voting_credential: CredType +} +export interface DRepRegistration { + anchor?: Anchor | null + coin: string + voting_credential: CredType +} +export interface DRepUpdate { + anchor?: Anchor | null + voting_credential: CredType +} +export interface StakeAndVoteDelegation { + drep: DRep + pool_keyhash: string + stake_credential: CredType +} +export interface StakeRegistrationAndDelegation { + coin: string + pool_keyhash: string + stake_credential: CredType +} +export interface StakeVoteRegistrationAndDelegation { + coin: string + drep: DRep + pool_keyhash: string + stake_credential: CredType +} +export interface VoteDelegation { + drep: DRep + stake_credential: CredType +} +export interface VoteRegistrationAndDelegation { + coin: string + drep: DRep + stake_credential: CredType +} +export interface TransactionInput { + index: number + transaction_id: string +} +export interface TransactionOutput { + address: string + amount: Value + plutus_data?: DataOption | null + script_ref?: ScriptRef | null +} +export interface Value { + coin: string + multiasset?: MultiAsset | null +} +export interface MultiAsset { + [k: string]: Assets +} +export interface MintAssets { + [k: string]: string +} +export interface Update { + epoch: number + proposed_protocol_parameter_updates: { + [k: string]: ProtocolParamUpdate + } +} +export interface ProtocolParamUpdate { + ada_per_utxo_byte?: string | null + collateral_percentage?: number | null + committee_term_limit?: number | null + cost_models?: Costmdls | null + d?: UnitInterval | null + drep_deposit?: string | null + drep_inactivity_period?: number | null + drep_voting_thresholds?: DRepVotingThresholds | null + execution_costs?: ExUnitPrices | null + expansion_rate?: UnitInterval | null + extra_entropy?: Nonce | null + governance_action_deposit?: string | null + governance_action_validity_period?: number | null + key_deposit?: string | null + max_block_body_size?: number | null + max_block_ex_units?: ExUnits | null + max_block_header_size?: number | null + max_collateral_inputs?: number | null + max_epoch?: number | null + max_tx_ex_units?: ExUnits | null + max_tx_size?: number | null + max_value_size?: number | null + min_committee_size?: number | null + min_pool_cost?: string | null + minfee_a?: string | null + minfee_b?: string | null + n_opt?: number | null + pool_deposit?: string | null + pool_pledge_influence?: UnitInterval | null + pool_voting_thresholds?: PoolVotingThresholds | null + protocol_version?: ProtocolVersion | null + ref_script_coins_per_byte?: UnitInterval | null + treasury_growth_rate?: UnitInterval | null +} +export interface Costmdls { + [k: string]: CostModel +} +export interface DRepVotingThresholds { + committee_no_confidence: UnitInterval + committee_normal: UnitInterval + hard_fork_initiation: UnitInterval + motion_no_confidence: UnitInterval + pp_economic_group: UnitInterval + pp_governance_group: UnitInterval + pp_network_group: UnitInterval + pp_technical_group: UnitInterval + treasury_withdrawal: UnitInterval + update_constitution: UnitInterval +} +export interface ExUnitPrices { + mem_price: UnitInterval + step_price: UnitInterval +} +export interface Nonce { + /** + * @minItems 32 + * @maxItems 32 + */ + hash?: + | [ + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + ] + | null +} +export interface ExUnits { + mem: string + steps: string +} +export interface PoolVotingThresholds { + committee_no_confidence: UnitInterval + committee_normal: UnitInterval + hard_fork_initiation: UnitInterval + motion_no_confidence: UnitInterval + security_relevant_threshold: UnitInterval +} +export interface VoterVotes { + voter: Voter + votes: Vote[] +} +export interface Vote { + action_id: GovernanceActionId + voting_procedure: VotingProcedure +} +export interface GovernanceActionId { + index: number + transaction_id: string +} +export interface VotingProcedure { + anchor?: Anchor | null + vote: VoteKind +} +export interface VotingProposal { + anchor: Anchor + deposit: string + governance_action: GovernanceAction + reward_account: string +} +export interface ParameterChangeAction { + gov_action_id?: GovernanceActionId | null + policy_hash?: string | null + protocol_param_updates: ProtocolParamUpdate +} +export interface HardForkInitiationAction { + gov_action_id?: GovernanceActionId | null + protocol_version: ProtocolVersion +} +export interface TreasuryWithdrawalsAction { + policy_hash?: string | null + withdrawals: TreasuryWithdrawals +} +export interface TreasuryWithdrawals { + [k: string]: string +} +export interface NoConfidenceAction { + gov_action_id?: GovernanceActionId | null +} +export interface UpdateCommitteeAction { + committee: Committee + gov_action_id?: GovernanceActionId | null + members_to_remove: CredType[] +} +export interface Committee { + members: CommitteeMember[] + quorum_threshold: UnitInterval +} +export interface CommitteeMember { + stake_credential: CredType + term_limit: number +} +export interface NewConstitutionAction { + constitution: Constitution + gov_action_id?: GovernanceActionId | null +} +export interface Constitution { + anchor: Anchor + script_hash?: string | null +} +export interface TransactionWitnessSet { + bootstraps?: BootstrapWitness[] | null + native_scripts?: NativeScripts | null + plutus_data?: PlutusList | null + plutus_scripts?: PlutusScripts | null + redeemers?: Redeemer[] | null + vkeys?: Vkeywitness[] | null +} +export interface BootstrapWitness { + attributes: number[] + chain_code: number[] + signature: string + vkey: Vkey +} +export interface PlutusList { + definite_encoding?: boolean | null + elems: string[] +} +export interface Redeemer { + data: string + ex_units: ExUnits + index: string + tag: RedeemerTag +} +export interface Vkeywitness { + signature: string + vkey: Vkey +} +export type BlockHash = string +export type BootstrapWitnesses = BootstrapWitness[] + +export type CertificateEnum = + | { + StakeRegistration: StakeRegistration + } + | { + StakeDeregistration: StakeDeregistration + } + | { + StakeDelegation: StakeDelegation + } + | { + PoolRegistration: PoolRegistration + } + | { + PoolRetirement: PoolRetirement + } + | { + GenesisKeyDelegation: GenesisKeyDelegation + } + | { + MoveInstantaneousRewardsCert: MoveInstantaneousRewardsCert + } + | { + CommitteeHotAuth: CommitteeHotAuth + } + | { + CommitteeColdResign: CommitteeColdResign + } + | { + DRepDeregistration: DRepDeregistration + } + | { + DRepRegistration: DRepRegistration + } + | { + DRepUpdate: DRepUpdate + } + | { + StakeAndVoteDelegation: StakeAndVoteDelegation + } + | { + StakeRegistrationAndDelegation: StakeRegistrationAndDelegation + } + | { + StakeVoteRegistrationAndDelegation: StakeVoteRegistrationAndDelegation + } + | { + VoteDelegation: VoteDelegation + } + | { + VoteRegistrationAndDelegation: VoteRegistrationAndDelegation + } +export type Certificates = Certificate[] + +export type Credential = CredType +export type Credentials = CredType[] +export type DRepEnum = + | ('AlwaysAbstain' | 'AlwaysNoConfidence') + | { + KeyHash: string + } + | { + ScriptHash: string + } +export type DataHash = string +export type Ed25519KeyHash = string +export type Ed25519KeyHashes = string[] +export type Ed25519Signature = string +export interface GeneralTransactionMetadata { + [k: string]: string +} +export type GenesisDelegateHash = string +export type GenesisHash = string +export type GenesisHashes = string[] +export type GovernanceActionEnum = + | { + ParameterChangeAction: ParameterChangeAction + } + | { + HardForkInitiationAction: HardForkInitiationAction + } + | { + TreasuryWithdrawalsAction: TreasuryWithdrawalsAction + } + | { + NoConfidenceAction: NoConfidenceAction + } + | { + UpdateCommitteeAction: UpdateCommitteeAction + } + | { + NewConstitutionAction: NewConstitutionAction + } + | { + InfoAction: InfoAction + } +export type GovernanceActionIds = GovernanceActionId[] + +export type Int = string +/** + * @minItems 4 + * @maxItems 4 + */ +export type KESVKey = string +export type Language = LanguageKind +export type LanguageKind = 'PlutusV1' | 'PlutusV2' | 'PlutusV3' +export type Languages = Language[] +export type MIRToStakeCredentials = StakeToCoin[] + +export type MintsAssets = MintAssets[] + +export type NetworkIdKind = 'Testnet' | 'Mainnet' +export type PlutusScript = string +export type PoolMetadataHash = string +export interface ProposedProtocolParameterUpdates { + [k: string]: ProtocolParamUpdate +} +export type PublicKey = string +export type RedeemerTagKind = 'Spend' | 'Mint' | 'Cert' | 'Reward' | 'Vote' | 'VotingProposal' +export type Redeemers = Redeemer[] + +export type RelayEnum = + | { + SingleHostAddr: SingleHostAddr + } + | { + SingleHostName: SingleHostName + } + | { + MultiHostName: MultiHostName + } +/** + * @minItems 4 + * @maxItems 4 + */ +export type RewardAddress = string +export type RewardAddresses = string[] +export type ScriptDataHash = string +export type ScriptHash = string +export type ScriptHashes = string[] +export type ScriptRefEnum = + | { + NativeScript: NativeScript + } + | { + PlutusScript: string + } +export interface Transaction { + auxiliary_data?: AuxiliaryData | null + body: TransactionBody + is_valid: boolean + witness_set: TransactionWitnessSet +} +export type TransactionHash = string +export type TransactionInputs = TransactionInput[] + +export type TransactionMetadatum = string +export interface TransactionUnspentOutput { + input: TransactionInput + output: TransactionOutput +} +export type TransactionUnspentOutputs = TransactionUnspentOutput[] + +export type VRFKeyHash = string +export type VRFVKey = string +export interface VersionedBlock { + block: Block + era_code: number +} +export type Vkeywitnesses = Vkeywitness[] + +export type VoterEnum = + | { + ConstitutionalCommitteeHotCred: CredType + } + | { + DRep: CredType + } + | { + StakingPool: string + } +export type Voters = Voter[] +export type VotingProcedures = VoterVotes[] + +export type VotingProposals = VotingProposal[] + +export interface Withdrawals { + [k: string]: string +} + +export type FormattedInput = { + assets: Array<{ + name: string + label: string + quantity: Balance.Quantity + isPrimary: boolean + }> + address: string | undefined + rewardAddress: string | null + ownAddress: boolean + txIndex: number + txHash: string +} + +export type FormattedInputs = Array +export type FormattedOutput = { + assets: Array<{ + name: string + label: string + quantity: Balance.Quantity + isPrimary: boolean + }> + address: string + rewardAddress: string | null + ownAddress: boolean +} +export type FormattedOutputs = Array +export type FormattedFee = { + name: string + label: string + quantity: Balance.Quantity + isPrimary: boolean +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/Overview/OverviewTab.tsx b/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/Overview/OverviewTab.tsx new file mode 100644 index 0000000000..eed42fd24a --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/Overview/OverviewTab.tsx @@ -0,0 +1,355 @@ +// 🚧 TODO: grouping by staking address 🚧 + +import {Blockies} from '@yoroi/identicon' +import {useTheme} from '@yoroi/theme' +import * as React from 'react' +import {Linking, StyleSheet, Text, TouchableOpacity, View} from 'react-native' + +import {Icon} from '../../../../../components/Icon' +import {Info} from '../../../../../components/Info/Info' +import {Space} from '../../../../../components/Space/Space' +import {formatTokenWithText} from '../../../../../yoroi-wallets/utils/format' +import {Quantities} from '../../../../../yoroi-wallets/utils/utils' +import {useSelectedWallet} from '../../../../WalletManager/common/hooks/useSelectedWallet' +import {useWalletManager} from '../../../../WalletManager/context/WalletManagerProvider' +import {Address} from '../../../common/Address' +import {CollapsibleSection} from '../../../common/CollapsibleSection' +import {Divider} from '../../../common/Divider' +import {useAddressType} from '../../../common/hooks/useAddressType' +import {FormattedTx} from '../../../common/hooks/useFormattedTx' +import {useStrings} from '../../../common/hooks/useStrings' +import {TokenItem} from '../../../common/TokenItem' +import {FormattedOutputs} from '../../../common/types' + +export const OverviewTab = ({tx}: {tx: FormattedTx}) => { + const {styles} = useStyles() + + const notOwnedOutputs = React.useMemo(() => tx.outputs.filter((output) => !output.ownAddress), [tx.outputs]) + const ownedOutputs = React.useMemo(() => tx.outputs.filter((output) => output.ownAddress), [tx.outputs]) + + return ( + + + + + + + + + + ) +} + +const WalletInfoSection = ({tx}: {tx: FormattedTx}) => { + const {styles} = useStyles() + const strings = useStrings() + const {wallet, meta} = useSelectedWallet() + const {walletManager} = useWalletManager() + const {plate, seed} = walletManager.checksum(wallet.publicKeyHex) + const seedImage = new Blockies({seed}).asBase64() + + return ( + <> + + {strings.walletLabel} + + + + + + + {`${plate} | ${meta.name}`} + + + + + + + + ) +} + +const FeeInfoItem = ({fee}: {fee: string}) => { + const {styles} = useStyles() + const strings = useStrings() + + return ( + + {strings.feeLabel} + + {fee} + + ) +} + +const SenderSection = ({ + tx, + notOwnedOutputs, + ownedOutputs, +}: { + tx: FormattedTx + notOwnedOutputs: FormattedOutputs + ownedOutputs: FormattedOutputs +}) => { + const strings = useStrings() + const address = ownedOutputs[0]?.rewardAddress ?? ownedOutputs[0]?.address + + return ( + + + +
+ + + + + + {notOwnedOutputs.length === 1 && } + + ) +} + +// 🚧 TODO: ADD MULTIRECEIVER SUPPORT 🚧 +const SenderTokens = ({tx, notOwnedOutputs}: {tx: FormattedTx; notOwnedOutputs: FormattedOutputs}) => { + const {styles} = useStyles() + + const {wallet} = useSelectedWallet() + + const totalPrimaryTokenSent = React.useMemo( + () => + notOwnedOutputs + .flatMap((output) => output.assets.filter((asset) => asset.isPrimary)) + .reduce((previous, current) => Quantities.sum([previous, current.quantity]), Quantities.zero), + [notOwnedOutputs], + ) + const totalPrimaryTokenSpent = React.useMemo( + () => Quantities.sum([totalPrimaryTokenSent, tx.fee.quantity]), + [totalPrimaryTokenSent, tx.fee.quantity], + ) + const totalPrimaryTokenSpentLabel = formatTokenWithText(totalPrimaryTokenSpent, wallet.portfolioPrimaryTokenInfo) + + const notPrimaryTokenSent = React.useMemo( + () => notOwnedOutputs.flatMap((output) => output.assets.filter((asset) => !asset.isPrimary)), + [notOwnedOutputs], + ) + + return ( + + + + + + + + + {notPrimaryTokenSent.map((token) => ( + + ))} + + + ) +} + +const SenderSectionLabel = () => { + const {styles, colors} = useStyles() + const strings = useStrings() + + return ( + + + + + + {strings.sendLabel} + + ) +} + +const ReceiverSection = ({notOwnedOutputs}: {notOwnedOutputs: FormattedOutputs}) => { + const address = notOwnedOutputs[0]?.rewardAddress ?? notOwnedOutputs[0]?.address + const {styles} = useStyles() + const strings = useStrings() + const addressType = useAddressType(address) + const isScriptAddress = addressType === 'script' + + return ( + <> + + + + {isScriptAddress ? strings.receiveToScriptLabel : strings.receiveToLabel}: + +
+ + + ) +} + +const useStyles = () => { + const {atoms, color} = useTheme() + const styles = StyleSheet.create({ + root: { + ...atoms.flex_1, + ...atoms.px_lg, + backgroundColor: color.bg_color_max, + }, + infoItem: { + ...atoms.flex_row, + ...atoms.justify_between, + }, + infoLabel: { + ...atoms.body_2_md_regular, + color: color.gray_600, + }, + walletInfoText: { + ...atoms.body_2_md_medium, + color: color.text_primary_medium, + }, + plate: { + ...atoms.flex_row, + ...atoms.align_center, + }, + fee: { + color: color.gray_900, + ...atoms.body_2_md_regular, + }, + link: { + color: color.text_primary_medium, + ...atoms.body_2_md_medium, + }, + receiverAddress: { + ...atoms.flex_row, + ...atoms.align_center, + ...atoms.flex_row, + ...atoms.justify_between, + }, + tokenSectionLabel: { + ...atoms.body_2_md_regular, + color: color.gray_900, + }, + senderTokenItems: { + ...atoms.flex_wrap, + ...atoms.flex_row, + ...atoms.justify_end, + ...atoms.flex_1, + ...atoms.gap_sm, + }, + tokensSection: { + ...atoms.flex_row, + ...atoms.justify_between, + }, + tokensSectionLabel: { + ...atoms.flex_row, + ...atoms.align_center, + }, + walletChecksum: { + width: 24, + height: 24, + }, + receiverSectionAddress: { + maxWidth: 260, + }, + }) + + const colors = { + send: color.primary_500, + received: color.green_static, + } + + return {styles, colors} as const +} + +// 🚧 WORK IN PROGRESS BELOW 🚧 + +// 🚧 TODO: WIP 🚧 +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const CreatedByInfoItem = () => { + const {styles} = useStyles() + + return ( + + Created By + + + {/* */} + + + + Linking.openURL('https://google.com')}> + dapp.org + + + + ) +} + +// 🚧 TODO: WIP 🚧 +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const ReceiverTokensSectionMultiReceiver = () => { + const {styles} = useStyles() + + return ( + <> + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +// 🚧 TODO: WIP 🚧 +const ReceiverSectionLabel = () => { + const {styles, colors} = useStyles() + + return ( + + + + + + Receive + + ) +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/ReviewTxScreen.tsx b/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/ReviewTxScreen.tsx new file mode 100644 index 0000000000..6216f4c12f --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/ReviewTxScreen.tsx @@ -0,0 +1,143 @@ +import {createMaterialTopTabNavigator, MaterialTopTabBarProps} from '@react-navigation/material-top-tabs' +import {useTheme} from '@yoroi/theme' +import * as React from 'react' +import {FlatList, StyleSheet, Text, TouchableOpacity, TouchableOpacityProps, View} from 'react-native' + +import {Button} from '../../../../components/Button/Button' +import {SafeArea} from '../../../../components/SafeArea' +import {ReviewTxRoutes, useUnsafeParams} from '../../../../kernel/navigation' +import {useFormattedTx} from '../../common/hooks/useFormattedTx' +import {useOnConfirm} from '../../common/hooks/useOnConfirm' +import {useStrings} from '../../common/hooks/useStrings' +import {useTxBody} from '../../common/hooks/useTxBody' +import {OverviewTab} from './Overview/OverviewTab' +import {UTxOsTab} from './UTxOs/UTxOsTab' + +const MaterialTab = createMaterialTopTabNavigator() + +export const ReviewTxScreen = () => { + const {styles} = useStyles() + const strings = useStrings() + + // TODO: move this to a context + const params = useUnsafeParams() + const {onConfirm} = useOnConfirm({ + unsignedTx: params.unsignedTx, + onSuccess: params.onSuccess, + onError: params.onError, + }) + + // TODO: add cbor arguments + const txBody = useTxBody({unsignedTx: params.unsignedTx}) + const formatedTx = useFormattedTx(txBody) + + const OverViewTabMemo = React.memo(() => ) + const UTxOsTabMemo = React.memo(() => ) + + return ( + + + + + + + + +