diff --git a/packages/browser-wallet/src/popup/popupX/constants/routes.ts b/packages/browser-wallet/src/popup/popupX/constants/routes.ts index f8f9cbb1b..0674e6d30 100644 --- a/packages/browser-wallet/src/popup/popupX/constants/routes.ts +++ b/packages/browser-wallet/src/popup/popupX/constants/routes.ts @@ -67,14 +67,8 @@ export const relativeRoutes = { hideBackArrow: true, showAccountSelector: true, }, - send: { - path: 'send', - confirmation: { - path: 'confirmation', - config: { - backTitle: 'to Send Funds form', - }, - }, + sendFunds: { + path: 'account/:account/send-funds', }, receive: { path: 'receive', @@ -347,6 +341,9 @@ export const transactionDetailsRoute = (account: AccountAddress.Type, tx: Transa export const submittedTransactionRoute = (tx: TransactionHash.Type) => generatePath(absoluteRoutes.home.submittedTransaction.path, { transactionHash: TransactionHash.toHexString(tx) }); +export const sendFundsRoute = (account: AccountAddress.Type) => + generatePath(absoluteRoutes.home.sendFunds.path, { account: account.address }); + /** * Given two absolute routes, returns the relative route between them. * Note: fromPath should be a prefix of toPath. diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Result/DelegationResult.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Result/DelegationResult.tsx index a758db491..9bc48ccbf 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Result/DelegationResult.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Result/DelegationResult.tsx @@ -33,7 +33,7 @@ export default function DelegationResult() { }; const nav = useNavigate(); const { t } = useTranslation('x', { keyPrefix: 'earn.delegator' }); - const getCost = useGetTransactionFee(AccountTransactionType.ConfigureDelegation); + const getCost = useGetTransactionFee(); const accountInfo = ensureDefined(useSelectedAccountInfo(), 'No account selected'); const parametersV1 = useBlockChainParametersAboveV0(); @@ -75,7 +75,7 @@ export default function DelegationResult() { return ; } - const fee = getCost(state.payload); + const fee = getCost(AccountTransactionType.ConfigureDelegation, state.payload); const submit = async () => { if (fee === undefined) { throw Error('Fee could not be calculated'); diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Stake/DelegatorStake.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Stake/DelegatorStake.tsx index 3c9649b54..1dfbde658 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Stake/DelegatorStake.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/Stake/DelegatorStake.tsx @@ -112,7 +112,7 @@ export default function DelegatorStake({ title, target, initialValues, existingV const [highStakeWarning, setHighStakeWarning] = useState(false); const values = form.watch(); - const getCost = useGetTransactionFee(AccountTransactionType.ConfigureDelegation); + const getCost = useGetTransactionFee(); const fee = useMemo(() => { let payload: ConfigureDelegationPayload; try { @@ -131,7 +131,7 @@ export default function DelegatorStake({ title, target, initialValues, existingV existingValues ); } - return getCost(payload); + return getCost(AccountTransactionType.ConfigureDelegation, payload); }, [target, values, getCost]); useEffect(() => { diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/util.ts b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/util.ts index 23a9080a4..98433d348 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/util.ts +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Delegator/util.ts @@ -11,7 +11,7 @@ export type DelegationTypeForm = { }; /** The form values for delegator stake configuration step */ -export type DelegatorStakeForm = AmountForm & { +export type DelegatorStakeForm = Omit & { /** Whether to add rewards to the stake or not */ redelegate: boolean; }; diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.tsx index e95a8c8c2..5fa25a87a 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Result/ValidationResult.tsx @@ -40,7 +40,7 @@ export default function ValidationResult() { }; const nav = useNavigate(); const { t } = useTranslation('x', { keyPrefix: 'earn.validator' }); - const getCost = useGetTransactionFee(AccountTransactionType.ConfigureBaker); + const getCost = useGetTransactionFee(); const accountInfo = ensureDefined(useSelectedAccountInfo(), 'No account selected'); const [error, setError] = useState(); @@ -84,7 +84,7 @@ export default function ValidationResult() { return null; } - const fee = getCost(state.payload); + const fee = getCost(AccountTransactionType.ConfigureBaker, state.payload); const submit = async () => { if (fee === undefined) { throw Error('Fee could not be calculated'); diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx index ca4a27b2a..cc92fccae 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/Stake/ValidatorStake.tsx @@ -89,10 +89,10 @@ export default function ValidatorStake({ title, initialValues, existingValues, o const [highStakeWarning, setHighStakeWarning] = useState(false); const values = form.watch(); - const getCost = useGetTransactionFee(AccountTransactionType.ConfigureBaker); + const getCost = useGetTransactionFee(); const fee = useMemo(() => { if (existingValues === undefined) { - return getCost(PAYLOAD_MAX); + return getCost(AccountTransactionType.ConfigureBaker, PAYLOAD_MAX); } try { @@ -106,7 +106,7 @@ export default function ValidatorStake({ title, initialValues, existingValues, o } const payload: ConfigureBakerPayload = { stake, restakeEarnings: restake }; - return getCost(payload); + return getCost(AccountTransactionType.ConfigureBaker, payload); } catch { // We failed to parse the amount return undefined; @@ -118,7 +118,7 @@ export default function ValidatorStake({ title, initialValues, existingValues, o return undefined; // We know the cost as we don't depend on values set later in the flow } - return getCost(PAYLOAD_MIN); + return getCost(AccountTransactionType.ConfigureBaker, PAYLOAD_MIN); }, [getCost, existingValues]); useEffect(() => { diff --git a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts index ad97ecc6a..9a923840b 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts +++ b/packages/browser-wallet/src/popup/popupX/pages/EarningRewards/Validator/util.ts @@ -7,6 +7,7 @@ import { OpenStatus, OpenStatusText, } from '@concordium/web-sdk'; +import { AmountForm } from '@popup/popupX/shared/Form/TokenAmount'; import { formatCcdAmount, parseCcdAmount } from '@popup/popupX/shared/utils/helpers'; import i18n from '@popup/shell/i18n'; @@ -45,8 +46,7 @@ export function showCommissionRate(fraction: number): string { export const isRange = (range: CommissionRange) => range.min !== range.max; /** The form data for specifying validator stake */ -export type ValidatorStakeForm = { amount: string; restake: boolean }; - +export type ValidatorStakeForm = Omit & { restake: boolean }; export type ValidatorFormUpdateStake = { stake: ValidatorStakeForm }; export type ValidatorFormUpdateKeys = { keys: GenerateBakerKeysOutput }; export type ValidatorFormUpdateSettings = { diff --git a/packages/browser-wallet/src/popup/popupX/pages/MainPage/MainPage.tsx b/packages/browser-wallet/src/popup/popupX/pages/MainPage/MainPage.tsx index a4dc8446a..55c8eed00 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/MainPage/MainPage.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/MainPage/MainPage.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, useMemo } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { generatePath, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useAtomValue } from 'jotai'; import { displayAsCcd } from 'wallet-common-helpers'; @@ -118,7 +118,7 @@ function MainPageConfirmedAccount({ credential }: MainPageConfirmedAccountProps) const { t } = useTranslation('x', { keyPrefix: 'mainPage' }); const nav = useNavigate(); - const navToSend = () => nav(relativeRoutes.home.send.path); + const navToSend = () => nav(generatePath(absoluteRoutes.home.sendFunds.path, { account: credential.address })); const navToReceive = () => nav(relativeRoutes.home.receive.path); const navToTransactionLog = () => nav(relativeRoutes.home.transactionLog.path.replace(':account', credential.address)); diff --git a/packages/browser-wallet/src/popup/popupX/pages/SendFunds/Confirm.tsx b/packages/browser-wallet/src/popup/popupX/pages/SendFunds/Confirm.tsx new file mode 100644 index 000000000..92f266425 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/SendFunds/Confirm.tsx @@ -0,0 +1,145 @@ +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + AccountAddress, + AccountTransactionType, + CIS2, + CIS2Contract, + CcdAmount, + Energy, + SimpleTransferPayload, + TransactionHash, +} from '@concordium/web-sdk'; +import { useAsyncMemo } from 'wallet-common-helpers'; +import { useAtomValue } from 'jotai'; +import { useNavigate } from 'react-router-dom'; + +import Page from '@popup/popupX/shared/Page'; +import Text from '@popup/popupX/shared/Text'; +import Arrow from '@assets/svgX/arrow-right.svg'; +import Card from '@popup/popupX/shared/Card'; +import { + displayNameAndSplitAddress, + displaySplitAddress, + useSelectedCredential, +} from '@popup/shared/utils/account-helpers'; +import { AmountReceiveForm } from '@popup/popupX/shared/Form/TokenAmount/View'; +import { ensureDefined } from '@shared/utils/basic-helpers'; +import { CCD_METADATA } from '@shared/constants/token-metadata'; +import { formatCcdAmount, parseCcdAmount, parseTokenAmount } from '@popup/popupX/shared/utils/helpers'; +import { useTransactionSubmit } from '@popup/shared/utils/transaction-helpers'; +import Button from '@popup/popupX/shared/Button'; +import { grpcClientAtom } from '@popup/store/settings'; +import { logError } from '@shared/utils/log-helpers'; +import { submittedTransactionRoute } from '@popup/popupX/constants/routes'; + +import { CIS2_TRANSFER_NRG_OFFSET, showToken, useTokenMetadata } from './util'; +import { UpdateContractSubmittedLocationState } from '../SubmittedTransaction/SubmittedTransaction'; + +type Props = { + sender: AccountAddress.Type; + values: AmountReceiveForm; + fee: CcdAmount.Type; +}; + +export default function SendFundsConfirm({ values, fee, sender }: Props) { + const { t } = useTranslation('x', { keyPrefix: 'sendFunds' }); + const credential = ensureDefined(useSelectedCredential(), 'Expected selected account to be available'); + const tokenMetadata = useTokenMetadata(values.token, sender); + const nav = useNavigate(); + const tokenName = useMemo(() => { + if (values.token.tokenType === 'ccd') return CCD_METADATA.name; + if (tokenMetadata === undefined || values.token.tokenType === undefined) return undefined; + + return showToken(tokenMetadata, values.token.tokenAddress); + }, [tokenMetadata, values.token]); + const receiver = AccountAddress.fromBase58(values.receiver); + const submitTransaction = useTransactionSubmit( + sender, + values.token.tokenType === 'ccd' ? AccountTransactionType.Transfer : AccountTransactionType.Update + ); + const grpcClient = useAtomValue(grpcClientAtom); + const contractClient = useAsyncMemo( + async () => { + if (values.token.tokenType !== 'cis2') { + return undefined; + } + return CIS2Contract.create(grpcClient, values.token.tokenAddress.contract); + }, + logError, + [values.token, grpcClient] + ); + + const payload = useAsyncMemo( + async () => { + if (values.token.tokenType === 'cis2') { + if (contractClient === undefined) return undefined; // We wait for the client to be ready + if (tokenMetadata === undefined) throw new Error('No metadata for token'); + + const transfer: CIS2.Transfer = { + from: sender, + to: receiver, + tokenId: values.token.tokenAddress.id, + tokenAmount: parseTokenAmount(values.amount, tokenMetadata?.decimals), + }; + const result = await contractClient.dryRun.transfer(sender, transfer); + return contractClient.createTransfer( + { energy: Energy.create(result.usedEnergy.value + CIS2_TRANSFER_NRG_OFFSET) }, + transfer + ).payload; + } + if (values.token.tokenType === 'ccd') { + const p: SimpleTransferPayload = { + amount: parseCcdAmount(values.amount), + toAddress: receiver, + }; + return p; + } + + return undefined; + }, + logError, + [values.token, sender, values.receiver, contractClient] + ); + + const submit = async () => { + if (payload === undefined || tokenName === undefined) { + throw Error('Payload could not be created...'); + } + + const tx = await submitTransaction(payload, fee); + const state: UpdateContractSubmittedLocationState = { type: 'cis2.transfer', amount: values.amount, tokenName }; + nav(submittedTransactionRoute(TransactionHash.fromHexString(tx)), { + state, + }); + }; + + return ( + + + + +
+ {displayNameAndSplitAddress(credential)} + + {displaySplitAddress(values.receiver)} +
+ + {t('amount')} ({tokenName} + ): + + {values.amount} + {t('estimatedFee', { fee: formatCcdAmount(fee) })} +
+ + + + +
+ ); +} diff --git a/packages/browser-wallet/src/popup/popupX/pages/SendFunds/SendConfirm.tsx b/packages/browser-wallet/src/popup/popupX/pages/SendFunds/SendConfirm.tsx deleted file mode 100644 index a930e37dd..000000000 --- a/packages/browser-wallet/src/popup/popupX/pages/SendFunds/SendConfirm.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import Arrow from '@assets/svgX/arrow-right.svg'; -import Button from '@popup/popupX/shared/Button'; -import { useNavigate } from 'react-router-dom'; -import { submittedTransactionRoute } from '@popup/popupX/constants/routes'; -import { TransactionHash } from '@concordium/web-sdk'; - -export default function SendConfirm() { - const nav = useNavigate(); - // TODO: - // 1. Submit transaction (see `Delegator/TransactionFlow`) - // 2. Pass the transaction hash to the route function below - const navToConfirmed = () => nav(submittedTransactionRoute(TransactionHash.fromHexString('..'))); - return ( -
-
- Confirmation -
-
-
- 6gk...Fk7o - - bc1q...0wlh -
- Amount (CCD): - 12,600.00 - Est. fee: 0.03614 CCD -
- - navToConfirmed()} label="Send funds" /> -
- ); -} diff --git a/packages/browser-wallet/src/popup/popupX/pages/SendFunds/SendFunds.scss b/packages/browser-wallet/src/popup/popupX/pages/SendFunds/SendFunds.scss index 017a9055f..bd5095b96 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/SendFunds/SendFunds.scss +++ b/packages/browser-wallet/src/popup/popupX/pages/SendFunds/SendFunds.scss @@ -5,89 +5,6 @@ padding-bottom: rem(32px); .send-funds { - &__title { - display: flex; - align-items: baseline; - justify-content: space-between; - } - - &__card { - display: flex; - flex-direction: column; - border-radius: rem(16px); - background: $gradient-card-bg; - margin-top: rem(16px); - padding: rem(20px) rem(16px); - - .text__main_medium { - color: $color-black; - } - - .capture__additional_small { - padding: rem(4px); - border-radius: rem(4px); - color: $color-black; - background: $secondary-button-bg; - } - - &_token, - &_amount, - &_receiver { - display: flex; - flex-direction: column; - } - - &_token { - .token-selector { - display: flex; - align-items: center; - margin-top: rem(12px); - padding-bottom: rem(12px); - border-bottom: 1px solid rgba($color-black, 0.1); - - .text__additional_small { - margin-left: auto; - } - - .token-icon { - display: flex; - padding: rem(5px); - margin-right: rem(8px); - border-radius: rem(6px); - background: $color-grey-1; - - svg { - width: rem(14px); - height: rem(14px); - } - } - } - } - - &_amount { - margin-top: rem(24px); - - .amount-selector { - display: flex; - align-items: flex-end; - justify-content: space-between; - padding: rem(8px) 0; - margin-bottom: rem(4px); - border-bottom: 1px solid rgba($color-black, 0.1); - } - } - - &_receiver { - margin-top: rem(24px); - - .address-selector { - display: flex; - justify-content: space-between; - margin-top: rem(12px); - } - } - } - &__memo { display: flex; margin-top: rem(16px); @@ -100,13 +17,9 @@ .send-funds-confirm { &__card { - display: flex; - flex-direction: column; align-items: center; margin-top: rem(16px); padding: rem(40px) 0; - border: 1px solid rgba($color-grey-4, 0.4); - border-radius: rem(12px); &_destination { display: flex; @@ -138,39 +51,4 @@ } } } - - .send-funds-success { - &__card { - display: flex; - flex-direction: column; - align-items: center; - margin-top: rem(16px); - padding: rem(24px) 0 rem(36px) 0; - border: 1px solid rgba($color-grey-4, 0.4); - border-radius: rem(12px); - - .capture__main_small { - color: $color-white; - } - - .heading_large { - margin: rem(8px) 0; - } - - svg { - margin-bottom: rem(30px); - } - } - - &__details { - display: flex; - justify-content: center; - margin-top: rem(24px); - - .label__regular { - color: $color-white; - margin-right: rem(8px); - } - } - } } diff --git a/packages/browser-wallet/src/popup/popupX/pages/SendFunds/SendFunds.tsx b/packages/browser-wallet/src/popup/popupX/pages/SendFunds/SendFunds.tsx index e11850543..64f73ba94 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/SendFunds/SendFunds.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/SendFunds/SendFunds.tsx @@ -1,53 +1,154 @@ -import React from 'react'; -import ConcordiumLogo from '@assets/svgX/concordium-logo.svg'; -import Plus from '@assets/svgX/plus.svg'; -import SideArrow from '@assets/svgX/side-arrow.svg'; +import React, { useState } from 'react'; +import { Navigate, useLocation, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + AccountAddress, + AccountTransactionType, + CIS2, + CIS2Contract, + CcdAmount, + Energy, + SimpleTransferPayload, +} from '@concordium/web-sdk'; +import { useAsyncMemo } from 'wallet-common-helpers'; +import { useAtomValue } from 'jotai'; + import Button from '@popup/popupX/shared/Button'; -import { useNavigate } from 'react-router-dom'; -import { relativeRoutes } from '@popup/popupX/constants/routes'; +import { displayNameAndSplitAddress, useCredential } from '@popup/shared/utils/account-helpers'; +import Page from '@popup/popupX/shared/Page'; +import Text from '@popup/popupX/shared/Text'; +import TokenAmount, { AmountReceiveForm } from '@popup/popupX/shared/Form/TokenAmount'; +import Form, { useForm } from '@popup/popupX/shared/Form'; +import { useAccountInfo } from '@popup/shared/AccountInfoListenerContext'; +import { useGetTransactionFee } from '@popup/shared/utils/transaction-helpers'; +import { TokenPickerVariant } from '@popup/popupX/shared/Form/TokenAmount/View'; +import { parseTokenAmount } from '@popup/popupX/shared/utils/helpers'; +import { grpcClientAtom } from '@popup/store/settings'; +import { logError } from '@shared/utils/log-helpers'; +import FullscreenNotice from '@popup/popupX/shared/FullscreenNotice'; +import SendFundsConfirm from './Confirm'; +import { CIS2_TRANSFER_NRG_OFFSET, useTokenMetadata } from './util'; + +type SendFundsProps = { address: AccountAddress.Type }; +export type SendFundsLocationState = TokenPickerVariant; + +function SendFunds({ address }: SendFundsProps) { + const { t } = useTranslation('x', { keyPrefix: 'sendFunds' }); + const { state } = useLocation() as { state: SendFundsLocationState | null }; + const credential = useCredential(address.address); + const grpcClient = useAtomValue(grpcClientAtom); + const form = useForm({ + mode: 'onTouched', + defaultValues: { + token: state ?? { tokenType: 'ccd' }, + amount: '0.00', + }, + }); + const accountInfo = useAccountInfo(credential); + const [token, amount] = form.watch(['token', 'amount']); + const contractClient = useAsyncMemo( + async () => { + if (token?.tokenType !== 'cis2') { + return undefined; + } + return CIS2Contract.create(grpcClient, token.tokenAddress.contract); + }, + logError, + [token, grpcClient] + ); + + const getFee = useGetTransactionFee(); + const metadata = useTokenMetadata(token, address); + const fee = useAsyncMemo( + async () => { + if (token?.tokenType === 'cis2') { + if (contractClient === undefined || metadata === undefined) { + return undefined; + } + + let tokenAmount: bigint; + try { + tokenAmount = parseTokenAmount(amount, metadata.decimals); + } catch { + return undefined; + } + + const transfer: CIS2.Transfer = { + from: address, + to: address, + tokenId: token.tokenAddress.id, + tokenAmount, + }; + const result = await contractClient.dryRun.transfer(address, transfer); + const { payload } = contractClient.createTransfer( + { energy: Energy.create(result.usedEnergy.value + CIS2_TRANSFER_NRG_OFFSET) }, + transfer + ); + return getFee(AccountTransactionType.Update, payload); + } + if (token?.tokenType === 'ccd') { + const payload: SimpleTransferPayload = { + amount: CcdAmount.zero(), + toAddress: AccountAddress.fromBuffer(new Uint8Array(32)), + }; + return getFee(AccountTransactionType.Transfer, payload); + } + + return undefined; + }, + logError, + [token, address, amount, contractClient] + ); + + const [showConfirmationPage, setShowConfirmationPage] = useState(false); + const onSubmit = () => setShowConfirmationPage(true); + + if (accountInfo === undefined) { + return null; + } -export default function SendFunds() { - const nav = useNavigate(); - const navToConfirm = () => nav(relativeRoutes.home.send.confirmation.path); return ( -
-
- Send funds - from Account 1 / 6gk...Fk7o -
-
-
- Token -
-
- -
- CCD - - 17,800 CCD available -
-
-
- Amount -
- 12,600.00 - Send max. -
- Estimated transaction fee: 0.03614 CCD + <> + setShowConfirmationPage(false)}> + {fee && } + + + + + {t('from', { name: displayNameAndSplitAddress(credential) })} + + +
+ {() => ( + + )} + + {/* +
+ + Add memo
-
- Receiver address -
- bc1qxy2kgdygq2...0wlh - Address Book -
-
-
-
- - Add memo -
- navToConfirm()} label="Continue" /> -
+ */} + + + + + ); } + +export default function Loader() { + const params = useParams(); + if (params.account === undefined) { + // No account address passed in the url. + return ; + } + return ; +} diff --git a/packages/browser-wallet/src/popup/popupX/pages/SendFunds/i18n/en.ts b/packages/browser-wallet/src/popup/popupX/pages/SendFunds/i18n/en.ts new file mode 100644 index 000000000..879774e82 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/SendFunds/i18n/en.ts @@ -0,0 +1,13 @@ +const t = { + sendFunds: 'Send funds', + from: 'from {{name}}', + token: 'Token', + amount: 'Amount', + sendMax: 'Send max.', + estimatedFee: 'Est. fee: {{fee}} CCD', + confirmation: { + title: 'Confirmation', + }, +}; + +export default t; diff --git a/packages/browser-wallet/src/popup/popupX/pages/SendFunds/index.ts b/packages/browser-wallet/src/popup/popupX/pages/SendFunds/index.ts index eb40e6ac0..44458c6a3 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/SendFunds/index.ts +++ b/packages/browser-wallet/src/popup/popupX/pages/SendFunds/index.ts @@ -1,2 +1 @@ export { default as SendFunds } from './SendFunds'; -export { default as SendConfirm } from './SendConfirm'; diff --git a/packages/browser-wallet/src/popup/popupX/pages/SendFunds/util.ts b/packages/browser-wallet/src/popup/popupX/pages/SendFunds/util.ts new file mode 100644 index 000000000..f73baa6df --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/SendFunds/util.ts @@ -0,0 +1,40 @@ +import { CIS2 } from '@concordium/common-sdk'; +import { AccountAddress, ContractAddress } from '@concordium/web-sdk'; +import { TokenPickerVariant } from '@popup/popupX/shared/Form/TokenAmount/View'; +import { useTokenInfo } from '@popup/popupX/shared/Form/TokenAmount/util'; +import { CCD_METADATA } from '@shared/constants/token-metadata'; +import { TokenMetadata } from '@shared/storage/types'; + +/** + * React hook to retrieve the metadata for a specific token associated with a given account. + * + * @param {TokenPickerVariant} [token] - The token for which metadata is to be retrieved. + * @param {AccountAddress.Type} account - The account address to fetch token information for. + * @returns {TokenMetadata | undefined} - The metadata of the token if found, otherwise undefined. + */ +export function useTokenMetadata( + token: TokenPickerVariant | undefined, + account: AccountAddress.Type +): TokenMetadata | undefined { + const tokens = useTokenInfo(account); + if (tokens.loading || token?.tokenType === undefined) return undefined; + + if (token.tokenType === 'ccd') return CCD_METADATA; + + return tokens.value.find( + (t) => t.id === token.tokenAddress.id && ContractAddress.equals(token.tokenAddress.contract, t.contract) + )?.metadata; +} + +/** + * Formats the display name of a token using its metadata and address. + * + * @param {TokenMetadata} metadata - The metadata of the token. + * @param {CIS2.TokenAddress} tokenAddress - The address of the token. + * @returns {string} - The formatted display name of the token. + */ +export function showToken(metadata: TokenMetadata, tokenAddress: CIS2.TokenAddress): string { + return metadata.symbol ?? metadata.name ?? `${tokenAddress.id}@${tokenAddress.contract.toString()}`; +} + +export const CIS2_TRANSFER_NRG_OFFSET = 100n; diff --git a/packages/browser-wallet/src/popup/popupX/pages/SubmittedTransaction/SubmittedTransaction.tsx b/packages/browser-wallet/src/popup/popupX/pages/SubmittedTransaction/SubmittedTransaction.tsx index cbd8bf9e6..32a4e29fb 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/SubmittedTransaction/SubmittedTransaction.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/SubmittedTransaction/SubmittedTransaction.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import { Location, Navigate, useLocation, useNavigate, useParams } from 'react-router-dom'; import CheckCircle from '@assets/svgX/check-circle.svg'; import Cross from '@assets/svgX/close.svg'; @@ -26,6 +26,7 @@ import { ConfigureBakerSummary, BakerStakeChangedEvent, BakerEvent, + TransferSummary, } from '@concordium/web-sdk'; import { useAtomValue } from 'jotai'; import { grpcClientAtom } from '@popup/store/settings'; @@ -91,6 +92,41 @@ function ValidatorBody({ events }: ValidatorBodyProps) { return {t('updated')}; } +type TransferBodyProps = BaseAccountTransactionSummary & TransferSummary; +function TransferBody({ transfer }: TransferBodyProps) { + const { t } = useTranslation('x', { keyPrefix: 'submittedTransaction.success.transfer' }); + return ( + <> + {t('label')} + {formatCcdAmount(transfer.amount)} + CCD + + ); +} + +export type UpdateContractSubmittedLocationState = { + type: 'cis2.transfer'; + /** formatted amount */ + amount: string; + tokenName: string; +}; +function UpdateContractBody() { + const { t } = useTranslation('x', { keyPrefix: 'submittedTransaction.success' }); + const { state } = useLocation() as Location & { state: UpdateContractSubmittedLocationState }; + switch (state.type) { + case 'cis2.transfer': + return ( + <> + {t('transfer.label')} + {state.amount} + {state.tokenName} + + ); + default: + throw new Error('Unsupported'); + } +} + type SuccessSummary = Exclude; type FailureSummary = BaseAccountTransactionSummary & FailedTransactionSummary; @@ -104,19 +140,13 @@ type SuccessProps = { }; function Success({ tx }: SuccessProps) { - const { t } = useTranslation('x', { keyPrefix: 'submittedTransaction' }); return ( <> - {tx.transactionType === TransactionKindString.Transfer && ( - <> - {t('success.transfer.label')} - {formatCcdAmount(tx.transfer.amount)} - CCD - - )} + {tx.transactionType === TransactionKindString.Transfer && } {tx.transactionType === TransactionKindString.ConfigureDelegation && } {tx.transactionType === TransactionKindString.ConfigureBaker && } + {tx.transactionType === TransactionKindString.Update && } ); } diff --git a/packages/browser-wallet/src/popup/popupX/pages/TokenDetails/TokenDetails.tsx b/packages/browser-wallet/src/popup/popupX/pages/TokenDetails/TokenDetails.tsx index 70cabb7f3..bacdaeee8 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/TokenDetails/TokenDetails.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/TokenDetails/TokenDetails.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { relativeRoutes } from '@popup/popupX/constants/routes'; +import { relativeRoutes, sendFundsRoute } from '@popup/popupX/constants/routes'; import Page from '@popup/popupX/shared/Page'; import Text from '@popup/popupX/shared/Text'; import Button from '@popup/popupX/shared/Button'; @@ -18,6 +18,8 @@ import Arrow from '@assets/svgX/arrow-right.svg'; import FileText from '@assets/svgX/file-text.svg'; import Notebook from '@assets/svgX/notebook.svg'; import Eye from '@assets/svgX/eye-slash.svg'; +import { AccountAddress, ContractAddress } from '@concordium/web-sdk'; +import { SendFundsLocationState } from '../SendFunds/SendFunds'; const SUB_INDEX = '0'; @@ -37,7 +39,13 @@ function TokenDetails({ credential }: { credential: WalletCredential }) { const remove = useUpdateAtom(removeTokenFromCurrentAccountAtom); const nav = useNavigate(); - const navToSend = () => nav(`../${relativeRoutes.home.send.path}`); + const navToSend = () => + nav(sendFundsRoute(AccountAddress.fromBase58(credential.address)), { + state: { + tokenType: 'cis2', + tokenAddress: { id, contract: ContractAddress.create(BigInt(contractIndex), 0) }, + } as SendFundsLocationState, + }); const navToReceive = () => nav(`../${relativeRoutes.home.receive.path}`); const navToTransactionLog = () => nav(`../${relativeRoutes.home.transactionLog.path}`); const navToRaw = () => nav(relativeRoutes.home.token.details.raw.path); diff --git a/packages/browser-wallet/src/popup/popupX/pages/TokenDetails/TokenDetailsCcd.tsx b/packages/browser-wallet/src/popup/popupX/pages/TokenDetails/TokenDetailsCcd.tsx index 7697a62ee..a851b9269 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/TokenDetails/TokenDetailsCcd.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/TokenDetails/TokenDetailsCcd.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { AccountInfoType } from '@concordium/web-sdk'; -import { relativeRoutes, absoluteRoutes } from '@popup/popupX/constants/routes'; +import { AccountAddress, AccountInfoType } from '@concordium/web-sdk'; +import { relativeRoutes, absoluteRoutes, sendFundsRoute } from '@popup/popupX/constants/routes'; import Page from '@popup/popupX/shared/Page'; import Text from '@popup/popupX/shared/Text'; import Button from '@popup/popupX/shared/Button'; @@ -14,6 +14,7 @@ import { withSelectedCredential } from '@popup/popupX/shared/utils/hoc'; import Arrow from '@assets/svgX/arrow-right.svg'; import FileText from '@assets/svgX/file-text.svg'; import Plant from '@assets/svgX/plant.svg'; +import { TokenPickerVariant } from '@popup/popupX/shared/Form/TokenAmount/View'; const zeroBalance: Omit = { total: 0n, @@ -64,7 +65,10 @@ function TokenDetailsCcd({ credential }: { credential: WalletCredential }) { const tokenDetails = useCcdInfo(credential); const nav = useNavigate(); - const navToSend = () => nav(`../${relativeRoutes.home.send.path}`); + const navToSend = () => + nav(sendFundsRoute(AccountAddress.fromBase58(credential.address)), { + state: { tokenType: 'ccd' } as TokenPickerVariant, + }); const navToReceive = () => nav(`../${relativeRoutes.home.receive.path}`); const navToTransactionLog = () => nav(`../${relativeRoutes.home.transactionLog.path}`); const navToEarn = () => nav(absoluteRoutes.settings.earn.path); diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx index ca72059ef..0600a7b38 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx @@ -66,7 +66,6 @@ export const OnlyAmount: Story = { receiver: false, tokens, balance: 17004000000n, - onSelectToken: console.log, }, }; @@ -77,7 +76,6 @@ export const WithReceiver: Story = { receiver: true, tokens, balance: 17004000000n, - onSelectToken: console.log, }, }; @@ -90,6 +88,5 @@ export const TokenWithReceiver: Story = { receiver: true, tokens, balance: 17004000000n, - onSelectToken: console.log, }, }; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx index 59bef513e..11949f391 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import { atomFamily, selectAtom, useAtomValue } from 'jotai/utils'; import { AccountAddress, AccountInfo, ContractAddress, CIS2 } from '@concordium/web-sdk'; import { atom } from 'jotai'; @@ -33,7 +33,7 @@ const balanceAtomFamily = atomFamily( AccountAddress.equals(aa.accountAddress, ab.accountAddress) && ba === bb && tokenAddressEq(ta, tb) ); -type Props = Omit & { +type Props = Omit & { /** The account info of the account to take the amount from */ accountInfo: AccountInfo; /** The ccd balance to use. Defaults to 'available' */ @@ -80,8 +80,10 @@ type Props = Omit * /> */ export default function TokenAmount({ accountInfo, ccdBalance = 'available', ...props }: Props) { + const { token } = props.form.watch(); + const tokenAddress = token?.tokenType === 'cis2' ? token.tokenAddress : null; + const tokenInfo = useTokenInfo(accountInfo.accountAddress); - const [tokenAddress, setTokenAddress] = useState(null); const tokenBalance = useAtomValue(balanceAtomFamily([accountInfo, ccdBalance, tokenAddress])); if (tokenInfo.loading) { @@ -92,8 +94,8 @@ export default function TokenAmount({ accountInfo, ccdBalance = 'available', ... ); } diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx index cf1e15c88..a7b984917 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx @@ -1,20 +1,23 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable react/destructuring-assignment */ -import React, { InputHTMLAttributes, ReactNode, forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { InputHTMLAttributes, ReactNode, forwardRef, useCallback, useEffect, useMemo } from 'react'; import { UseFormReturn, Validate } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import clsx from 'clsx'; +import { ClassName, displayAsCcd } from 'wallet-common-helpers'; import { CIS2, CcdAmount, ContractAddress } from '@concordium/web-sdk'; + import { CCD_METADATA } from '@shared/constants/token-metadata'; import { ensureDefined } from '@shared/utils/basic-helpers'; import SideArrow from '@assets/svgX/side-arrow.svg'; import ConcordiumLogo from '@assets/svgX/concordium-logo.svg'; import { validateAccountAddress, validateTransferAmount } from '@popup/shared/utils/transaction-helpers'; +import { TokenMetadata } from '@shared/storage/types'; import Img, { DEFAULT_FAILED } from '@popup/shared/Img'; -import { ClassName, displayAsCcd } from 'wallet-common-helpers'; import Text from '@popup/popupX/shared/Text'; -import { RequiredUncontrolledFieldProps } from '../common/types'; -import { makeUncontrolled } from '../common/utils'; + +import { RequiredControlledFieldProps, RequiredUncontrolledFieldProps } from '../common/types'; +import { makeControlled, makeUncontrolled } from '../common/utils'; import Button from '../../Button'; import { formatTokenAmount, parseTokenAmount, removeNumberGrouping } from '../../utils/helpers'; import ErrorMessage from '../ErrorMessage'; @@ -49,7 +52,7 @@ const FormInputClear = makeUncontrolled(InputClear); type ReceiverInputProps = Pick< InputHTMLAttributes, - 'className' | 'value' | 'onChange' | 'onBlur' | 'autoFocus' + 'className' | 'value' | 'onChange' | 'onBlur' | 'autoFocus' | 'placeholder' > & RequiredUncontrolledFieldProps; @@ -71,31 +74,30 @@ const ReceiverInput = forwardRef(({ err const FormReceiverInput = makeUncontrolled(ReceiverInput); -const parseTokenSelectorId = (value: string): null | CIS2.TokenAddress => { +const parseTokenSelectorId = (value: string): Exclude => { if (value.startsWith('ccd')) { - return null; + return { tokenType: 'ccd' }; } const [, index, subindex, id] = value.split(':'); - return { id, contract: ContractAddress.create(BigInt(index), BigInt(subindex)) }; + return { + tokenType: 'cis2', + tokenAddress: { id, contract: ContractAddress.create(BigInt(index), BigInt(subindex)) }, + }; }; -const formatTokenSelectorId = (address: null | CIS2.TokenAddress) => { - if (address == null) { - return 'ccd'; +const formatTokenSelectorId = (token: TokenPickerVariant) => { + if (token?.tokenType === 'cis2') { + return `cis2:${token.tokenAddress.contract.index}:${token.tokenAddress.contract.subindex}:${token.tokenAddress.id}`; } - return `cis2:${address.contract.index}:${address.contract.subindex}:${address.id}`; + return 'ccd'; }; const DEFAULT_TOKEN_THUMBNAIL = DEFAULT_FAILED; -type TokenPickerProps = { - /** null == CCD */ - selectedToken: null | TokenInfo; +type TokenPickerProps = RequiredControlledFieldProps & { /** The set of tokens available for the account specified by `accountInfo` */ tokens: TokenInfo[]; - /** Callback invoked when a token is selected */ - onSelect(value: null | CIS2.TokenAddress): void; /** Whether to enable selection */ canSelect?: boolean; /** The balance of the selected token */ @@ -105,53 +107,60 @@ type TokenPickerProps = { }; function TokenPicker({ - selectedToken, tokens, - onSelect, + onChange, + value, + onBlur, canSelect = false, selectedTokenBalance, formatAmount, }: TokenPickerProps) { const { t } = useTranslation('x', { keyPrefix: 'sharedX' }); - const token: { - name: string; - icon: ReactNode; - decimals: number; - type: 'ccd' | 'cis2'; - address: null | CIS2.TokenAddress; - } = useMemo(() => { - if (selectedToken !== null) { - const { - metadata: { symbol, name, decimals = 0, thumbnail }, - id, - contract, - } = ensureDefined( - tokens.find( - (tk) => tk.id === selectedToken.id && ContractAddress.equals(tk.contract, selectedToken.contract) - ), - 'Expected the token specified to be available in the set of tokens given' - ); - const safeName = symbol ?? name ?? `${selectedToken.id}@${selectedToken.contract.toString()}`; - const tokenImage = thumbnail?.url ?? DEFAULT_TOKEN_THUMBNAIL; - const icon = {name}; - return { name: safeName, icon, decimals, type: 'cis2', address: { id, contract } }; + const token: + | { + name: string; + icon: ReactNode; + decimals: number; + type: 'ccd' | 'cis2'; + address: null | CIS2.TokenAddress; + } + | undefined = useMemo(() => { + if (value?.tokenType === undefined) return undefined; + if (value.tokenType === 'ccd') { + const name = 'CCD'; + const icon = ; + return { name, icon, decimals: 6, type: 'ccd', address: null }; } - const name = 'CCD'; - const icon = ; - return { name, icon, decimals: 6, type: 'ccd', address: null }; - }, [selectedToken]); + + const { + metadata: { symbol, name, decimals = 0, thumbnail }, + id, + contract, + } = ensureDefined( + tokens.find( + (tk) => + tk.id === value.tokenAddress.id && ContractAddress.equals(tk.contract, value.tokenAddress.contract) + ), + 'Expected the token specified to be available in the set of tokens given' + ); + const safeName = symbol ?? name ?? `${value.tokenAddress.id}@${value.tokenAddress.contract.toString()}`; + const tokenImage = thumbnail?.url ?? DEFAULT_TOKEN_THUMBNAIL; + const icon = {name}; + return { name: safeName, icon, decimals, type: 'cis2', address: { id, contract } }; + }, [value]); return (
{selectedTokenBalance !== undefined && ( @@ -175,10 +184,13 @@ function TokenPicker({ ); } -type TokenVariant = +const FormTokenPicker = makeControlled(TokenPicker); + +/** Possible values of the token picker */ +export type TokenPickerVariant = | { /** The token type. If undefined, a token picker is rendered */ - tokenType?: 'ccd'; + tokenType: 'ccd'; } | { /** The token type. If undefined, a token picker is rendered */ @@ -186,6 +198,7 @@ type TokenVariant = /** The token address */ tokenAddress: CIS2.TokenAddress; }; +type TokenPickerVariantProps = { tokenType?: undefined } | TokenPickerVariant; /** * @description @@ -194,6 +207,8 @@ type TokenVariant = export type AmountForm = { /** The amount to be transferred */ amount: string; + /** The token to transfer */ + token: TokenPickerVariant; }; /** @@ -205,7 +220,7 @@ export type AmountReceiveForm = AmountForm & { receiver: string; }; -type ValueVariant = +type ValueVariantProps = | { /** Whether it should be possible to specify a receiver. Defaults to false */ receiver?: false; @@ -219,10 +234,9 @@ type ValueVariant = form: UseFormReturn; }; -/** The event emitted when a token is selected internally. `null` is used when CCD is selected. */ -export type TokenSelectEvent = null | CIS2.TokenAddress; - export type TokenAmountViewProps = { + /** The CCD balance used to check transaction fee coverage */ + ccdBalance: CcdAmount.Type; /** The label used for the button setting the amount to the maximum possible */ buttonMaxLabel: string; /** The fee associated with the transaction */ @@ -233,15 +247,10 @@ export type TokenAmountViewProps = { tokens: TokenInfo[]; /** The token balance. `undefined` should be used to indicate that the balance is not yet available. */ balance: bigint | undefined; - /** - * Callback invoked when the user selects a token. This is also invoked when the component renders initially. - * `null` is used to communicate the native token (CCD) is selected. - */ - onSelectToken(event: TokenSelectEvent): void; /** Custom validation for the amount */ validateAmount?: Validate; -} & ValueVariant & - TokenVariant & +} & ValueVariantProps & + TokenPickerVariantProps & ClassName; /** @@ -256,60 +265,54 @@ export default function TokenAmountView(props: TokenAmountViewProps) { fee, tokens, balance, - onSelectToken, className, + ccdBalance, formatFee = (f) => displayAsCcd(f, false, true), validateAmount: customValidateAmount, } = props; - const [selectedToken, setSelectedToken] = useState(() => { + const defaultToken: TokenPickerVariant = useMemo(() => { switch (props.tokenType) { + case 'ccd': + case undefined: + return { tokenType: 'ccd' }; + case 'cis2': + return { tokenType: 'cis2', tokenAddress: props.tokenAddress }; + default: + throw new Error('Unreachable'); + } + }, [props.tokenType]); + const { token = defaultToken } = props.form.watch(); + const selectedTokenMetadata = useMemo(() => { + switch (token.tokenType) { case 'cis2': { return ensureDefined( tokens.find( (tk) => - tk.id === props.tokenAddress.id && - ContractAddress.equals(tk.contract, props.tokenAddress.contract) + tk.id === token.tokenAddress.id && + ContractAddress.equals(tk.contract, token.tokenAddress.contract) ), 'Expected the token specified to be available in the set of tokens given' - ); + ).metadata; } case 'ccd': - case undefined: { - return null; - } + case undefined: + return CCD_METADATA; default: throw new Error('Unreachable'); } - }); - - const handleTokenSelect = useCallback( - (value: null | CIS2.TokenAddress) => { - if (value === null) { - setSelectedToken(value); - } else { - const selected = ensureDefined( - tokens.find((tk) => tk.id === value.id && ContractAddress.equals(tk.contract, value.contract)), - 'Expected the token specified to be available in the set of tokens given' - ); - setSelectedToken(selected); - } - }, - [tokens, setSelectedToken] - ); - - const tokenDecimals = useMemo(() => { - if (selectedToken === null) { - return CCD_METADATA.decimals; - } - return selectedToken.metadata.decimals ?? 0; - }, [selectedToken]); + }, [token]); useEffect(() => { - onSelectToken(selectedToken); - }, [selectedToken]); + const form = props.form as UseFormReturn; + form.setValue('token', defaultToken); + }, []); + + const tokenDecimals = useMemo(() => { + return selectedTokenMetadata.decimals ?? 0; + }, [selectedTokenMetadata]); const formatAmount = useCallback( - (amountValue: bigint) => formatTokenAmount(amountValue, tokenDecimals, 2), + (amountValue: bigint) => formatTokenAmount(amountValue, tokenDecimals, Math.min(2, tokenDecimals)), [tokenDecimals] ); const parseAmount = useCallback( @@ -318,11 +321,11 @@ export default function TokenAmountView(props: TokenAmountViewProps) { ); const availableAmount: bigint | undefined = useMemo(() => { - if (balance === undefined) { + if (balance === undefined || token === undefined) { return undefined; } - return selectedToken === null ? balance - fee.microCcdAmount : balance; - }, [selectedToken, fee, balance]); + return token.tokenType === 'ccd' ? balance - fee.microCcdAmount : balance; + }, [token, fee, balance]); const setMax = useCallback(() => { if (availableAmount === undefined) return; @@ -355,38 +358,28 @@ export default function TokenAmountView(props: TokenAmountViewProps) { ); const validateAmount: Validate = useCallback( - (value) => - validateTransferAmount( - removeNumberGrouping(value), - balance, - tokenDecimals, - selectedToken === null ? fee.microCcdAmount : 0n - ), - [balance, tokenDecimals, selectedToken, fee] + (value) => { + const sanitizedValue = removeNumberGrouping(value); + if (token.tokenType === 'cis2' && ccdBalance.microCcdAmount < fee.microCcdAmount) { + return t('form.tokenAmount.validation.insufficientCcd'); + } + return validateTransferAmount(sanitizedValue, balance, tokenDecimals, fee.microCcdAmount); + }, + [balance, tokenDecimals, token, fee] ); return (
{t('form.tokenAmount.token.label')} - {props.tokenType !== undefined ? ( - - ) : ( - - )} + ).control} + name="token" + tokens={tokens} + canSelect={props.tokenType === undefined} + selectedTokenBalance={balance} + formatAmount={formatAmount} + />
Amount @@ -420,6 +413,7 @@ export default function TokenAmountView(props: TokenAmountViewProps) { className="text__main" register={(props.form as UseFormReturn).register} name="receiver" + placeholder={t('form.tokenAmount.address.placeholder')} rules={{ required: t('utils.address.required'), validate: validateAccountAddress, diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/common/types.ts b/packages/browser-wallet/src/popup/popupX/shared/Form/common/types.ts index 5d20cbd6b..22411328e 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/common/types.ts +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/common/types.ts @@ -11,8 +11,10 @@ export type RequiredFormFieldProps = { */ valid?: boolean; // TODO: in practice this is a number, either 0 or 1??? }; -export type RequiredControlledFieldProps = RequiredFormFieldProps & - Omit, 'ref' | 'onBlur'> & { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type RequiredControlledFieldProps = RequiredFormFieldProps & + Omit, 'ref' | 'onBlur' | 'value'> & { + value: V; onBlur?: () => void; }; export type RequiredUncontrolledFieldProps = RequiredFormFieldProps & diff --git a/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts b/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts index 7ffe726c1..836d10e87 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts +++ b/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts @@ -20,6 +20,10 @@ const t = { }, address: { label: 'Receiver address', + placeholder: 'Enter receiver address here', + }, + validation: { + insufficientCcd: 'Not enough CCD in account to cover transaction fee', }, }, }, diff --git a/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts b/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts index 08658ef4c..1cf49296c 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts +++ b/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts @@ -16,6 +16,10 @@ export const removeNumberGrouping = (amount: string) => amount.replace(/,/g, '') /** Display a token amount with a number of decimals + number groupings (thousand separators) */ export function formatTokenAmount(amount: bigint, decimals = 0, minDecimals = 2) { const padded = amount.toString().padStart(decimals + 1, '0'); // Ensure the string length is minimum decimals + 1 characters. For CCD, this would mean minimum 7 characters long + if (decimals === 0) { + return amount.toString(); + } + const integer = padded.slice(0, -decimals); const fraction = padded.slice(-decimals); const balanceFormatter = new Intl.NumberFormat('en-US', { diff --git a/packages/browser-wallet/src/popup/popupX/shell/Routes.tsx b/packages/browser-wallet/src/popup/popupX/shell/Routes.tsx index 53b475972..932e1bef4 100644 --- a/packages/browser-wallet/src/popup/popupX/shell/Routes.tsx +++ b/packages/browser-wallet/src/popup/popupX/shell/Routes.tsx @@ -3,7 +3,7 @@ import { Route, Routes as ReactRoutes } from 'react-router-dom'; import { relativeRoutes, routePrefix } from '@popup/popupX/constants/routes'; import MainLayout from '@popup/popupX/page-layouts/MainLayout'; import MainPage from '@popup/popupX/pages/MainPage'; -import { SendConfirm, SendFunds } from '@popup/popupX/pages/SendFunds'; +import { SendFunds } from '@popup/popupX/pages/SendFunds'; import ReceiveFunds from '@popup/popupX/pages/ReceiveFunds'; import TransactionLog from '@popup/popupX/pages/TransactionLog'; import TransactionDetails from '@popup/popupX/pages/TransactionDetails'; @@ -65,12 +65,7 @@ export default function Routes({ messagePromptHandlers }: { messagePromptHandler } path={relativeRoutes.home.path}> } /> - - } /> - - } /> - - + } /> } path={relativeRoutes.home.receive.path} /> } /> diff --git a/packages/browser-wallet/src/popup/shared/utils/transaction-helpers.ts b/packages/browser-wallet/src/popup/shared/utils/transaction-helpers.ts index 78bdbfdd2..b494ea205 100644 --- a/packages/browser-wallet/src/popup/shared/utils/transaction-helpers.ts +++ b/packages/browser-wallet/src/popup/shared/utils/transaction-helpers.ts @@ -255,18 +255,18 @@ export function getTransactionAmount(type: AccountTransactionType, payload: Acco } } /** Hook which exposes a function for getting the transaction fee for a given transaction type */ -export function useGetTransactionFee(type: AccountTransactionType) { +export function useGetTransactionFee() { const cp = useBlockChainParameters(); return useCallback( - (payload: AccountTransactionPayload) => { + (type: AccountTransactionType, payload: AccountTransactionPayload) => { if (cp === undefined) { return undefined; } const energy = getEnergyCost(type, payload); return convertEnergyToMicroCcd(energy, cp); }, - [cp, type] + [cp] ); } diff --git a/packages/browser-wallet/src/popup/shell/i18n/locales/en.ts b/packages/browser-wallet/src/popup/shell/i18n/locales/en.ts index b684910b0..3d44715c8 100644 --- a/packages/browser-wallet/src/popup/shell/i18n/locales/en.ts +++ b/packages/browser-wallet/src/popup/shell/i18n/locales/en.ts @@ -31,6 +31,7 @@ import viewSeedPhrase from '@popup/pages/ViewSeedPhrase/i18n/en'; // Wallet-X locales import onboarding from '@popup/popupX/pages/Onboarding/i18n/en'; import receiveFunds from '@popup/popupX/pages/ReceiveFunds/i18n/en'; +import sendFunds from '@popup/popupX/pages/SendFunds/i18n/en'; import idCards from '@popup/popupX/pages/IdCards/i18n/en'; import accounts from '@popup/popupX/pages/Accounts/i18n/en'; import createAccount from '@popup/popupX/pages/CreateAccount/i18n/en'; @@ -87,6 +88,7 @@ const t = { x: { onboarding, receiveFunds, + sendFunds, idCards, idIssuance, accounts,