-
- 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) })}
+
+
+
+ {/*
+
-
-
Receiver address
-
- bc1qxy2kgdygq2...0wlh
- Address Book
-
-
-
-
-
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 =
;
- 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 =
;
+ 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,