Skip to content

Commit 380496b

Browse files
committed
Transfer confirmation page
1 parent 70501d8 commit 380496b

File tree

4 files changed

+228
-86
lines changed

4 files changed

+228
-86
lines changed
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import React, { useMemo } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import {
4+
AccountAddress,
5+
AccountTransactionType,
6+
CIS2,
7+
CIS2Contract,
8+
CcdAmount,
9+
Energy,
10+
SimpleTransferPayload,
11+
TransactionHash,
12+
} from '@concordium/web-sdk';
13+
import { useAsyncMemo } from 'wallet-common-helpers';
14+
import { useAtomValue } from 'jotai';
15+
import { useNavigate } from 'react-router-dom';
16+
17+
import Page from '@popup/popupX/shared/Page';
18+
import Text from '@popup/popupX/shared/Text';
19+
import Arrow from '@assets/svgX/arrow-right.svg';
20+
import Card from '@popup/popupX/shared/Card';
21+
import {
22+
displayNameAndSplitAddress,
23+
displaySplitAddress,
24+
useSelectedCredential,
25+
} from '@popup/shared/utils/account-helpers';
26+
import { AmountReceiveForm } from '@popup/popupX/shared/Form/TokenAmount/View';
27+
import { ensureDefined } from '@shared/utils/basic-helpers';
28+
import { CCD_METADATA } from '@shared/constants/token-metadata';
29+
import { formatCcdAmount, parseCcdAmount, parseTokenAmount } from '@popup/popupX/shared/utils/helpers';
30+
import { useTransactionSubmit } from '@popup/shared/utils/transaction-helpers';
31+
import Button from '@popup/popupX/shared/Button';
32+
import { grpcClientAtom } from '@popup/store/settings';
33+
import { logError } from '@shared/utils/log-helpers';
34+
import { submittedTransactionRoute } from '@popup/popupX/constants/routes';
35+
36+
import { CIS2_TRANSFER_NRG_OFFSET, showToken, useTokenMetadata } from './util';
37+
38+
type Props = {
39+
sender: AccountAddress.Type;
40+
values: AmountReceiveForm;
41+
fee: CcdAmount.Type;
42+
};
43+
44+
export default function SendFundsConfirm({ values, fee, sender }: Props) {
45+
const { t } = useTranslation('x', { keyPrefix: 'sendFunds' });
46+
const credential = ensureDefined(useSelectedCredential(), 'Expected selected account to be available');
47+
const tokenMetadata = useTokenMetadata(values.token, sender);
48+
const nav = useNavigate();
49+
const tokenName = useMemo(() => {
50+
if (values.token.tokenType === 'ccd') return CCD_METADATA.name;
51+
if (tokenMetadata === undefined || values.token.tokenType === undefined) return undefined;
52+
53+
return showToken(tokenMetadata, values.token.tokenAddress);
54+
}, [tokenMetadata, values.token]);
55+
const receiver = AccountAddress.fromBase58(values.receiver);
56+
const submitTransaction = useTransactionSubmit(
57+
sender,
58+
values.token.tokenType === 'ccd' ? AccountTransactionType.Transfer : AccountTransactionType.Update
59+
);
60+
const grpcClient = useAtomValue(grpcClientAtom);
61+
const contractClient = useAsyncMemo(
62+
async () => {
63+
if (values.token.tokenType !== 'cis2') {
64+
return undefined;
65+
}
66+
return CIS2Contract.create(grpcClient, values.token.tokenAddress.contract);
67+
},
68+
logError,
69+
[values.token, grpcClient]
70+
);
71+
72+
const payload = useAsyncMemo(
73+
async () => {
74+
if (values.token.tokenType === 'cis2') {
75+
if (contractClient === undefined) return undefined; // We wait for the client to be ready
76+
if (tokenMetadata === undefined) throw new Error('No metadata for token');
77+
78+
const transfer: CIS2.Transfer = {
79+
from: sender,
80+
to: receiver,
81+
tokenId: values.token.tokenAddress.id,
82+
tokenAmount: parseTokenAmount(values.amount, tokenMetadata?.decimals),
83+
};
84+
const result = await contractClient.dryRun.transfer(sender, transfer);
85+
return contractClient.createTransfer(
86+
{ energy: Energy.create(result.usedEnergy.value + CIS2_TRANSFER_NRG_OFFSET) },
87+
transfer
88+
).payload;
89+
}
90+
if (values.token.tokenType === 'ccd') {
91+
const p: SimpleTransferPayload = {
92+
amount: parseCcdAmount(values.amount),
93+
toAddress: receiver,
94+
};
95+
return p;
96+
}
97+
98+
return undefined;
99+
},
100+
logError,
101+
[values.token, sender, values.receiver, contractClient]
102+
);
103+
104+
const submit = async () => {
105+
if (payload === undefined) {
106+
throw Error('Payload could not be created...');
107+
}
108+
109+
const tx = await submitTransaction(payload, fee);
110+
nav(submittedTransactionRoute(TransactionHash.fromHexString(tx)));
111+
};
112+
113+
return (
114+
<Page className="send-funds-container">
115+
<Page.Top heading={t('confirmation.title')} />
116+
117+
<Card className="send-funds-confirm__card" type="transparent">
118+
<div className="send-funds-confirm__card_destination">
119+
<Text.MainMedium>{displayNameAndSplitAddress(credential)}</Text.MainMedium>
120+
<Arrow />
121+
<Text.MainMedium>{displaySplitAddress(values.receiver)}</Text.MainMedium>
122+
</div>
123+
<Text.Capture>
124+
{t('amount')} ({tokenName}
125+
):
126+
</Text.Capture>
127+
<Text.HeadingLarge>{values.amount}</Text.HeadingLarge>
128+
<Text.Capture>{t('estimatedFee', { fee: formatCcdAmount(fee) })}</Text.Capture>
129+
</Card>
130+
131+
<Page.Footer>
132+
<Button.Main
133+
className="button-main"
134+
onClick={submit}
135+
label={t('sendFunds')}
136+
disabled={payload === undefined}
137+
/>
138+
</Page.Footer>
139+
</Page>
140+
);
141+
}

packages/browser-wallet/src/popup/popupX/pages/SendFunds/SendFunds.tsx

Lines changed: 41 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,46 @@
1-
import React, { useMemo, useState } from 'react';
2-
import Button from '@popup/popupX/shared/Button';
3-
import { Navigate, useLocation, useNavigate, useParams } from 'react-router-dom';
4-
import { displayNameAndSplitAddress, displaySplitAddress, useCredential } from '@popup/shared/utils/account-helpers';
1+
import React, { useState } from 'react';
2+
import { Navigate, useLocation, useParams } from 'react-router-dom';
53
import { useTranslation } from 'react-i18next';
6-
import Page from '@popup/popupX/shared/Page';
7-
import Text from '@popup/popupX/shared/Text';
8-
import TokenAmount, { AmountReceiveForm } from '@popup/popupX/shared/Form/TokenAmount';
9-
import Form, { useForm } from '@popup/popupX/shared/Form';
104
import {
115
AccountAddress,
126
AccountTransactionType,
137
CIS2,
148
CIS2Contract,
159
CcdAmount,
16-
ContractAddress,
10+
Energy,
1711
SimpleTransferPayload,
18-
TransactionHash,
1912
} from '@concordium/web-sdk';
13+
import { useAsyncMemo } from 'wallet-common-helpers';
14+
import { useAtomValue } from 'jotai';
15+
16+
import Button from '@popup/popupX/shared/Button';
17+
import { displayNameAndSplitAddress, useCredential } from '@popup/shared/utils/account-helpers';
18+
import Page from '@popup/popupX/shared/Page';
19+
import Text from '@popup/popupX/shared/Text';
20+
import TokenAmount, { AmountReceiveForm } from '@popup/popupX/shared/Form/TokenAmount';
21+
import Form, { useForm } from '@popup/popupX/shared/Form';
2022
import { useAccountInfo } from '@popup/shared/AccountInfoListenerContext';
2123
import { useGetTransactionFee } from '@popup/shared/utils/transaction-helpers';
22-
import FullscreenNotice from '@popup/popupX/shared/FullscreenNotice';
23-
import Arrow from '@assets/svgX/arrow-right.svg';
24-
import { submittedTransactionRoute } from '@popup/popupX/constants/routes';
2524
import { TokenPickerVariant } from '@popup/popupX/shared/Form/TokenAmount/View';
2625
import { parseTokenAmount } from '@popup/popupX/shared/utils/helpers';
27-
import { noOp, useAsyncMemo } from 'wallet-common-helpers';
28-
import { useAtomValue } from 'jotai';
2926
import { grpcClientAtom } from '@popup/store/settings';
3027
import { logError } from '@shared/utils/log-helpers';
31-
import { useTokenInfo } from '@popup/popupX/shared/Form/TokenAmount/util';
32-
import { CCD_METADATA } from '@shared/constants/token-metadata';
33-
import Card from '@popup/popupX/shared/Card';
28+
import FullscreenNotice from '@popup/popupX/shared/FullscreenNotice';
29+
import SendFundsConfirm from './Confirm';
30+
import { CIS2_TRANSFER_NRG_OFFSET, useTokenMetadata } from './util';
3431

3532
type SendFundsProps = { address: AccountAddress.Type };
3633
export type SendFundsLocationState = TokenPickerVariant;
3734

3835
function SendFunds({ address }: SendFundsProps) {
3936
const { t } = useTranslation('x', { keyPrefix: 'sendFunds' });
4037
const { state } = useLocation() as { state: SendFundsLocationState | null };
41-
const nav = useNavigate();
4238
const credential = useCredential(address.address);
4339
const grpcClient = useAtomValue(grpcClientAtom);
4440
const form = useForm<AmountReceiveForm>({
4541
mode: 'onTouched',
4642
defaultValues: {
43+
token: state ?? { tokenType: 'ccd' },
4744
amount: '0.00',
4845
},
4946
});
@@ -56,46 +53,37 @@ function SendFunds({ address }: SendFundsProps) {
5653
}
5754
return CIS2Contract.create(grpcClient, token.tokenAddress.contract);
5855
},
59-
noOp,
56+
logError,
6057
[token, grpcClient]
6158
);
62-
const tokens = useTokenInfo(address);
63-
const tokenName = useMemo(() => {
64-
if (tokens.loading) return undefined;
65-
if (token?.tokenType === undefined) return undefined;
66-
67-
if (token.tokenType === 'ccd') {
68-
return CCD_METADATA.symbol;
69-
}
70-
71-
const { metadata } =
72-
tokens.value.find(
73-
(tk) =>
74-
tk.id === token.tokenAddress.id && ContractAddress.equals(tk.contract, token.tokenAddress.contract)
75-
) ?? {};
76-
if (metadata === undefined) return undefined;
77-
78-
const safeName =
79-
metadata.symbol ?? metadata.name ?? `${token.tokenAddress.id}@${token.tokenAddress.contract.toString()}`;
80-
return safeName;
81-
}, [tokens, token]);
8259

8360
const getFee = useGetTransactionFee();
84-
const cost = useAsyncMemo(
61+
const metadata = useTokenMetadata(token, address);
62+
const fee = useAsyncMemo(
8563
async () => {
8664
if (token?.tokenType === 'cis2') {
87-
if (contractClient === undefined) {
65+
if (contractClient === undefined || metadata === undefined) {
66+
return undefined;
67+
}
68+
69+
let tokenAmount: bigint;
70+
try {
71+
tokenAmount = parseTokenAmount(amount, metadata.decimals);
72+
} catch {
8873
return undefined;
8974
}
9075

9176
const transfer: CIS2.Transfer = {
9277
from: address,
9378
to: address,
9479
tokenId: token.tokenAddress.id,
95-
tokenAmount: parseTokenAmount(amount),
80+
tokenAmount,
9681
};
9782
const result = await contractClient.dryRun.transfer(address, transfer);
98-
const { payload } = contractClient.createTransfer({ energy: result.usedEnergy }, transfer);
83+
const { payload } = contractClient.createTransfer(
84+
{ energy: Energy.create(result.usedEnergy.value + CIS2_TRANSFER_NRG_OFFSET) },
85+
transfer
86+
);
9987
return getFee(AccountTransactionType.Update, payload);
10088
}
10189
if (token?.tokenType === 'ccd') {
@@ -115,19 +103,15 @@ function SendFunds({ address }: SendFundsProps) {
115103
const [showConfirmationPage, setShowConfirmationPage] = useState(false);
116104
const onSubmit = () => setShowConfirmationPage(true);
117105

118-
// TODO:
119-
// 1. Submit transaction (see `Delegator/TransactionFlow`)
120-
// 2. Pass the transaction hash to the route function below
121-
const navToSubmitted = () => nav(submittedTransactionRoute(TransactionHash.fromHexString('..')));
122-
123-
const receiver: string | undefined = form.watch('receiver');
124-
125106
if (accountInfo === undefined) {
126107
return null;
127108
}
128109

129110
return (
130111
<>
112+
<FullscreenNotice open={showConfirmationPage} onClose={() => setShowConfirmationPage(false)}>
113+
{fee && <SendFundsConfirm sender={address} values={form.getValues()} fee={fee} />}
114+
</FullscreenNotice>
131115
<Page className="send-funds-container">
132116
<Page.Top heading={t('sendFunds')}>
133117
<Text.Capture className="m-l-5 m-t-neg-5">
@@ -139,47 +123,23 @@ function SendFunds({ address }: SendFundsProps) {
139123
<TokenAmount
140124
buttonMaxLabel={t('sendMax')}
141125
receiver
142-
fee={cost ?? CcdAmount.zero()}
126+
fee={fee ?? CcdAmount.zero()}
143127
form={form}
144128
accountInfo={accountInfo}
145129
{...state}
146130
/>
147131
)}
148132
</Form>
149133
{/*
150-
<div className="send-funds__memo">
151-
<Plus />
152-
<span className="label__main">Add memo</span>
153-
</div>
154-
*/}
134+
<div className="send-funds__memo">
135+
<Plus />
136+
<span className="label__main">Add memo</span>
137+
</div>
138+
*/}
155139
<Page.Footer>
156140
<Button.Main className="button-main" onClick={form.handleSubmit(onSubmit)} label="Continue" />
157141
</Page.Footer>
158142
</Page>
159-
{/* Confirmation page modal */}
160-
<FullscreenNotice open={showConfirmationPage} onClose={() => setShowConfirmationPage(false)}>
161-
<Page className="send-funds-container">
162-
<Page.Top heading={t('confirmation.title')} />
163-
164-
<Card className="send-funds-confirm__card" type="transparent">
165-
<div className="send-funds-confirm__card_destination">
166-
<Text.MainMedium>{displayNameAndSplitAddress(credential)}</Text.MainMedium>
167-
<Arrow />
168-
<Text.MainMedium>{receiver && displaySplitAddress(receiver)}</Text.MainMedium>
169-
</div>
170-
<Text.Capture>
171-
{t('amount')} ({tokenName}
172-
):
173-
</Text.Capture>
174-
<Text.HeadingLarge>{form.watch('amount')}</Text.HeadingLarge>
175-
<Text.Capture>{t('estimatedFee', { fee: cost })}</Text.Capture>
176-
</Card>
177-
178-
<Page.Footer>
179-
<Button.Main className="button-main" onClick={navToSubmitted} label={t('sendFunds')} />
180-
</Page.Footer>
181-
</Page>
182-
</FullscreenNotice>
183143
</>
184144
);
185145
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { CIS2 } from '@concordium/common-sdk';
2+
import { AccountAddress, ContractAddress } from '@concordium/web-sdk';
3+
import { TokenPickerVariant } from '@popup/popupX/shared/Form/TokenAmount/View';
4+
import { useTokenInfo } from '@popup/popupX/shared/Form/TokenAmount/util';
5+
import { CCD_METADATA } from '@shared/constants/token-metadata';
6+
import { TokenMetadata } from '@shared/storage/types';
7+
8+
/**
9+
* React hook to retrieve the metadata for a specific token associated with a given account.
10+
*
11+
* @param {TokenPickerVariant} [token] - The token for which metadata is to be retrieved.
12+
* @param {AccountAddress.Type} account - The account address to fetch token information for.
13+
* @returns {TokenMetadata | undefined} - The metadata of the token if found, otherwise undefined.
14+
*/
15+
export function useTokenMetadata(
16+
token: TokenPickerVariant | undefined,
17+
account: AccountAddress.Type
18+
): TokenMetadata | undefined {
19+
const tokens = useTokenInfo(account);
20+
if (tokens.loading || token?.tokenType === undefined) return undefined;
21+
22+
if (token.tokenType === 'ccd') return CCD_METADATA;
23+
24+
return tokens.value.find(
25+
(t) => t.id === token.tokenAddress.id && ContractAddress.equals(token.tokenAddress.contract, t.contract)
26+
)?.metadata;
27+
}
28+
29+
/**
30+
* Formats the display name of a token using its metadata and address.
31+
*
32+
* @param {TokenMetadata} metadata - The metadata of the token.
33+
* @param {CIS2.TokenAddress} tokenAddress - The address of the token.
34+
* @returns {string} - The formatted display name of the token.
35+
*/
36+
export function showToken(metadata: TokenMetadata, tokenAddress: CIS2.TokenAddress): string {
37+
return metadata.symbol ?? metadata.name ?? `${tokenAddress.id}@${tokenAddress.contract.toString()}`;
38+
}
39+
40+
export const CIS2_TRANSFER_NRG_OFFSET = 100n;

0 commit comments

Comments
 (0)