Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add deposit retry UI and error messages #1475

Merged
merged 3 commits into from
Jan 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/components/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
DiscordIcon,
DownloadIcon,
EarthIcon,
ErrorExclamationIcon,
EtherscanIcon,
ExportKeysIcon,
FastForwardIcon,
Expand Down Expand Up @@ -75,6 +76,7 @@ import {
PrivacyIcon,
QrIcon,
QuestionMarkIcon,
RefreshIcon,
RewardStarIcon,
RocketIcon,
RoundedArrowIcon,
Expand Down Expand Up @@ -137,6 +139,7 @@ export enum IconName {
Download = 'Download',
Earth = 'Earth',
Etherscan = 'Etherscan',
ErrorExclamation = 'ErrorExclamation',
ExportKeys = 'ExportKeys',
FastForward = 'FastForward',
Feedback = 'Feedback',
Expand Down Expand Up @@ -182,6 +185,7 @@ export enum IconName {
Privacy = 'Privacy',
Qr = 'Qr',
QuestionMark = 'QuestionMark',
Refresh = 'Refresh',
RewardStar = 'RewardStar',
Rocket = 'Rocket',
RoundedArrow = 'RoundedArrow',
Expand Down Expand Up @@ -241,6 +245,7 @@ const icons = {
[IconName.Discord]: DiscordIcon,
[IconName.Download]: DownloadIcon,
[IconName.Earth]: EarthIcon,
[IconName.ErrorExclamation]: ErrorExclamationIcon,
[IconName.Etherscan]: EtherscanIcon,
[IconName.ExportKeys]: ExportKeysIcon,
[IconName.FastForward]: FastForwardIcon,
Expand Down Expand Up @@ -286,6 +291,7 @@ const icons = {
[IconName.Privacy]: PrivacyIcon,
[IconName.Qr]: QrIcon,
[IconName.QuestionMark]: QuestionMarkIcon,
[IconName.Refresh]: RefreshIcon,
[IconName.RewardStar]: RewardStarIcon,
[IconName.Rocket]: RocketIcon,
[IconName.RoundedArrow]: RoundedArrowIcon,
Expand Down
3 changes: 3 additions & 0 deletions src/icons/error-exclamation.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export { default as DepthChartIcon } from './depth-chart.svg';
export { default as DiscordIcon } from './discord.svg';
export { default as DownloadIcon } from './download.svg';
export { default as EarthIcon } from './earth.svg';
export { default as ErrorExclamationIcon } from './error-exclamation.svg';
export { default as ExportKeysIcon } from './export-keys.svg';
export { default as FastForwardIcon } from './fast-forward.svg';
export { default as FeedbackIcon } from './feedback.svg';
Expand Down Expand Up @@ -65,6 +66,7 @@ export { default as PrivacyIcon } from './privacy.svg';
export { default as ProfileIcon } from './profile.svg';
export { default as QrIcon } from './qr.svg';
export { default as QuestionMarkIcon } from './question-mark.svg';
export { default as RefreshIcon } from './refresh.svg';
export { default as RewardStarIcon } from './reward-star.svg';
export { default as RocketIcon } from './rocket.svg';
export { default as RoundedArrowIcon } from './rounded-arrow.svg';
Expand Down
3 changes: 3 additions & 0 deletions src/icons/refresh.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 15 additions & 10 deletions src/views/dialogs/DepositDialog2/DepositForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export const DepositForm = ({

const [depositSteps, setDepositSteps] = useState<DepositStep[]>();
const [currentStep, setCurrentStep] = useState(0);
const [showRetryCurrentStep, setShowRetryCurrentStep] = useState(false);
const [currentStepError, setCurrentStepError] = useState<string>();
const [awaitingWalletAction, setAwaitingWalletAction] = useState(false);

// Helpers for fetching updated values within the useEffect for autoPromptStep
Expand All @@ -116,19 +116,24 @@ export const DepositForm = ({

useEffect(() => {
async function autoPromptStep() {
if (!depositSteps || !depositSteps.length || !walletClientRef.current) {
if (
!depositSteps ||
!depositSteps.length ||
!walletClientRef.current ||
!depositSteps[currentStep]
) {
return;
}

const success = await depositSteps[currentStep]?.executeStep(
const { success, errorMessage } = await depositSteps[currentStep].executeStep(
walletClientRef.current,
skipClientRef.current
);
if (success && currentStep < depositSteps.length - 1) {
setCurrentStep((prev) => prev + 1);
}
if (!success) {
setShowRetryCurrentStep(true);
setCurrentStepError(errorMessage);
}
}

Expand All @@ -140,15 +145,15 @@ export const DepositForm = ({
setDepositSteps(undefined);
setCurrentStep(0);
setAwaitingWalletAction(false);
setShowRetryCurrentStep(false);
setCurrentStepError(undefined);
}, [token, debouncedAmount, selectedRoute]);

const onDepositClick = async () => {
if (depositDisabled || !steps || !walletClient) return;

setAwaitingWalletAction(true);
if (steps.length === 1) {
const success = await steps[0]?.executeStep(walletClient, skipClient);
const { success } = await steps[0]!.executeStep(walletClient, skipClient);
if (!success) setAwaitingWalletAction(false);
} else {
setDepositSteps(steps);
Expand All @@ -159,13 +164,13 @@ export const DepositForm = ({
const step = depositSteps?.[currentStep];
if (!step || !walletClient) return;

setShowRetryCurrentStep(false);
setCurrentStepError(undefined);

const success = await step.executeStep(walletClient, skipClient);
const { success, errorMessage } = await step.executeStep(walletClient, skipClient);
if (success) {
setCurrentStep((prev) => prev + 1);
} else {
setShowRetryCurrentStep(true);
setCurrentStepError(errorMessage);
}
};

Expand Down Expand Up @@ -237,7 +242,7 @@ export const DepositForm = ({
<DepositSteps
steps={depositSteps}
currentStep={currentStep}
showRetry={showRetryCurrentStep}
currentStepError={currentStepError}
onRetry={retryCurrentStep}
/>
</div>
Expand Down
72 changes: 53 additions & 19 deletions src/views/dialogs/DepositDialog2/DepositSteps.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { ReactNode } from 'react';
import { ReactNode, useEffect, useState } from 'react';

import tw from 'twin.macro';

import { ErrorExclamationIcon } from '@/icons';
import ConnectingLine from '@/icons/connecting-line.svg';

import { Icon, IconName } from '@/components/Icon';
Expand All @@ -10,33 +13,52 @@ import { DepositStep } from './utils';
type DepositStepsProps = {
steps: DepositStep[];
currentStep: number;
showRetry: boolean;
currentStepError?: string;
onRetry: () => void;
};

const STEP_TYPE_TO_INFO: { [type: string]: { title: string; icon: ReactNode } } = {
network: {
// TODO(deposit2.0): localization
title: 'Switch networks',
icon: <Icon size="1.25rem" iconName={IconName.Switch} />,
icon: <Icon size="1.5rem" iconName={IconName.Switch} />,
},
approve: {
title: 'Approve USDC',
icon: <Icon size="1.25rem" iconName={IconName.Usdc} />,
icon: <Icon size="1.5rem" iconName={IconName.Usdc} />,
},
deposit: {
title: 'Confirm deposit',
icon: <Icon size="1.25rem" iconName={IconName.Deposit} />,
icon: <Icon size="1.5rem" iconName={IconName.Deposit} />,
},
};

export const DepositSteps = ({ steps, currentStep, showRetry, onRetry }: DepositStepsProps) => {
const SHAKE_DURATION = 1000;

export const DepositSteps = ({
steps,
currentStep,
currentStepError,
onRetry,
}: DepositStepsProps) => {
const [isShaking, setIsShaking] = useState(false);
useEffect(() => {
if (!currentStepError) {
setIsShaking(false);
return;
}

setIsShaking(true);
setTimeout(() => setIsShaking(false), SHAKE_DURATION);
}, [currentStepError]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in theory should return () => clearTimeout(that timeout thing)


return (
<div tw="flex flex-col">
{steps.map((step, i) => {
const stepInfo = STEP_TYPE_TO_INFO[step.type]!;
const error = i === currentStep ? currentStepError : undefined;
return (
<div key={step.type} tw="flex flex-col items-start">
<div key={step.type} tw="flex flex-col">
<div tw="flex items-center gap-0.125">
<div tw="relative p-0.5">
<LoadingSpinner
Expand All @@ -45,31 +67,43 @@ export const DepositSteps = ({ steps, currentStep, showRetry, onRetry }: Deposit
strokeWidth="2"
stroke=""
tw="absolute left-0 top-0 flex h-full w-full items-center justify-center text-color-accent"
style={{ visibility: i === currentStep && !showRetry ? undefined : 'hidden' }}
css={(i !== currentStep || currentStepError) && tw`invisible`}
/>
<div tw="flex items-center justify-center rounded-4 bg-color-layer-5 p-0.5">
<div
css={i === currentStep && tw`text-color-text-2`}
tw="relative flex items-center justify-center rounded-4 bg-color-layer-5 p-0.375"
>
{stepInfo.icon}
{error && (
<ErrorExclamationIcon tw="absolute right-[-2px] top-[-2px] h-[12px] w-[12px] text-color-error" />
)}
</div>
</div>
<div tw="flex items-center gap-0.5">
<div
tw="opacity-50 transition-all duration-300"
style={{ opacity: i === currentStep ? '100%' : undefined }}
>
{stepInfo.title}
<div tw="flex flex-1 items-center justify-between gap-0.5">
<div tw="flex flex-col gap-0.125">
<div
tw="transition-all duration-300"
css={[i === currentStep ? tw`opacity-100` : tw`opacity-50`]}
>
{stepInfo.title}
</div>
{error && <div tw="text-small text-color-error">{error}</div>}
</div>
{showRetry && i === currentStep && (
{error && (
<button
tw="rounded-0.5 border border-solid border-color-accent p-0.125 text-color-accent"
css={isShaking && tw`animate-shake`}
tw="flex items-center gap-0.375 rounded-0.5 border border-solid border-color-accent p-0.375 text-color-accent hover:bg-color-layer-4"
type="button"
onClick={onRetry}
>
Retry
<Icon iconName={IconName.Refresh} />
{/* TODO(deposit2.0): localization */}
<div>Retry</div>
</button>
)}
</div>
</div>
{i !== steps.length - 1 && <ConnectingLine tw="ml-[25px]" />}
{i !== steps.length - 1 && <ConnectingLine tw="ml-[25px] self-start" />}
</div>
);
})}
Expand Down
59 changes: 50 additions & 9 deletions src/views/dialogs/DepositDialog2/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { OfflineSigner } from '@cosmjs/proto-signing';
import { ERC20Approval, RouteResponse, SkipClient, UserAddress } from '@skip-go/client';
import { useQuery } from '@tanstack/react-query';
import { Address, maxUint256, WalletClient } from 'viem';
import {
Address,
ChainMismatchError,
maxUint256,
UserRejectedRequestError,
WalletClient,
} from 'viem';
import { useChainId } from 'wagmi';

import ERC20ABI from '@/abi/erc20.json';
Expand Down Expand Up @@ -65,18 +71,22 @@ export function getUserAddressesForRoute(
});
}

type StepResult =
| { success: true; errorMessage: undefined }
| { success: false; errorMessage: string };

export type DepositStep =
| {
type: 'network' | 'approve';
executeStep: (signer: WalletClient) => Promise<boolean>;
executeStep: (signer: WalletClient) => Promise<StepResult>;
}
| {
type: 'deposit';
// TODO(deposit2.0): add solana signer type support too;
executeStep: (
signer: WalletClient | OfflineSigner,
skipClient: SkipClient
) => Promise<boolean>;
) => Promise<StepResult>;
};

// Prepares all the steps the user needs to take in their wallet to complete their deposit
Expand Down Expand Up @@ -111,9 +121,12 @@ export function useDepositSteps({
executeStep: async (signer: WalletClient) => {
try {
await signer.switchChain({ id: Number(depositToken.chainId) });
return true;
return { success: true };
} catch (e) {
return false;
return {
success: false,
errorMessage: parseError(e, 'There was an error changing wallet networks.'),
};
}
},
});
Expand Down Expand Up @@ -171,9 +184,19 @@ export function useDepositSteps({
});
const receipt = await viemClient.waitForTransactionReceipt({ hash: txHash });
// TODO future improvement: also check to see if approval amount is sufficient here
return receipt.status === 'success';
// TODO(deposit2.0): localization
const isOnChainSuccess = receipt.status === 'success';
return {
success: isOnChainSuccess,
errorMessage: isOnChainSuccess
? undefined
: 'Your approval has failed. Please try again.',
} as StepResult;
} catch (e) {
return false;
return {
success: false,
errorMessage: parseError(e, 'There was an error with your approval.'),
};
}
},
});
Expand All @@ -196,9 +219,12 @@ export function useDepositSteps({
onDeposit({ txHash, chainId: chainID });
},
});
return true;
return { success: true };
} catch (e) {
return false;
return {
success: false,
errorMessage: parseError(e, 'Your deposit has failed. Please try again.'),
};
}
},
});
Expand All @@ -213,6 +239,7 @@ export function useDepositSteps({
depositRoute?.amountIn,
depositRoute?.sourceAssetChainID,
depositRoute?.sourceAssetDenom,
walletChainId,
],
queryFn: getStepsQuery,
});
Expand All @@ -236,3 +263,17 @@ function userAddressHelper(route: RouteResponse, userAddresses: UserAddress[]) {
}
return addressList;
}

// TODO(deposit2.0): localization
// TODO(deposit2.0): Add final copy for each error message
function parseError(e: Error, fallbackMessage: string) {
if ('code' in e && e.code === UserRejectedRequestError.code) {
return 'User rejected request.';
}

if ('name' in e && e.name === ChainMismatchError.name) {
return 'Please change your wallet network and try again.';
}

return fallbackMessage;
}
Loading
Loading