From 1e5fa94e6e149a1d09343f506580f5fc598ec1c0 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Thu, 2 Nov 2023 07:31:34 +0100 Subject: [PATCH 1/4] feat: new deposit status route (#893) * refactor: new consistent button component * remove redundant style * refactor: use px instead of rem for consistency * feat(bridge): new deposit confirmation route * refactor: use px instead of rem * refactor: use COLORS constant * refactor: remove fallback values * refactor: make status page params more readable * feat: gracefully invalid tx hashes --- src/Routes.tsx | 2 + .../bg-banners/action-card-teal-banner.svg | 14 + src/assets/check-star-ring-filled.svg | 4 - .../check-star-ring-opaque-depositing.svg | 16 + src/assets/check-star-ring-opaque-fill.svg | 4 - src/assets/check-star-ring-opaque-filled.svg | 6 +- src/assets/check-star-ring-opaque-filling.svg | 16 + src/assets/check.svg | 3 + src/assets/loading.svg | 17 + src/components/Badge/Badge.tsx | 33 ++ src/components/Badge/index.tsx | 1 + src/components/BreadcrumbV2/BreadcrumbV2.tsx | 1 + src/components/Button/Button.tsx | 3 + src/components/Text/Text.tsx | 2 +- src/components/Text/index.ts | 2 +- src/components/index.ts | 2 + src/hooks/useDeposits.ts | 40 +- src/hooks/useLocalPendingDeposits.ts | 42 -- src/stories/Badge.stories.tsx | 31 ++ src/utils/constants.ts | 2 +- src/utils/deposits.ts | 93 ++++ src/utils/index.ts | 1 + src/utils/local-deposits.ts | 39 ++ src/utils/time.ts | 2 +- src/utils/transactions.ts | 4 + src/utils/typechain.ts | 5 +- src/utils/wait.ts | 3 + src/views/Bridge/Bridge.tsx | 73 +-- src/views/Bridge/components/BridgeForm.tsx | 1 - .../Bridge/components/DepositConfirmation.tsx | 493 ------------------ .../Bridge/components/EstimatedTable.tsx | 1 - src/views/Bridge/hooks/useBridge.ts | 60 +-- src/views/Bridge/hooks/useBridgeAction.ts | 112 +--- .../Bridge/hooks/useBridgeDepositTracking.ts | 68 --- src/views/Bridge/hooks/useTransferQuote.ts | 1 - src/views/DepositStatus/DepositStatus.tsx | 106 ++++ .../DepositStatus/components/Breadcrumb.tsx | 29 ++ .../components/DepositStatusLowerCard.tsx | 79 +++ .../components/DepositStatusUpperCard.tsx | 374 +++++++++++++ .../components/DepositTimesCard.tsx | 211 ++++++++ .../components/EarnActionCard.tsx | 63 +++ .../components/EarnByLpAndStakingCard.tsx | 91 ++++ .../DepositStatus/components/ElapsedTime.tsx | 44 ++ .../DepositStatus/hooks/useDepositTracking.ts | 148 ++++++ .../DepositStatus/hooks/useElapsedSeconds.ts | 37 ++ src/views/DepositStatus/index.tsx | 1 + src/views/DepositStatus/types.ts | 15 + src/views/DepositStatus/utils.ts | 92 ++++ 48 files changed, 1670 insertions(+), 817 deletions(-) create mode 100644 src/assets/bg-banners/action-card-teal-banner.svg delete mode 100644 src/assets/check-star-ring-filled.svg create mode 100644 src/assets/check-star-ring-opaque-depositing.svg delete mode 100644 src/assets/check-star-ring-opaque-fill.svg create mode 100644 src/assets/check-star-ring-opaque-filling.svg create mode 100644 src/assets/check.svg create mode 100644 src/assets/loading.svg create mode 100644 src/components/Badge/Badge.tsx create mode 100644 src/components/Badge/index.tsx delete mode 100644 src/hooks/useLocalPendingDeposits.ts create mode 100644 src/stories/Badge.stories.tsx create mode 100644 src/utils/local-deposits.ts create mode 100644 src/utils/wait.ts delete mode 100644 src/views/Bridge/components/DepositConfirmation.tsx delete mode 100644 src/views/Bridge/hooks/useBridgeDepositTracking.ts create mode 100644 src/views/DepositStatus/DepositStatus.tsx create mode 100644 src/views/DepositStatus/components/Breadcrumb.tsx create mode 100644 src/views/DepositStatus/components/DepositStatusLowerCard.tsx create mode 100644 src/views/DepositStatus/components/DepositStatusUpperCard.tsx create mode 100644 src/views/DepositStatus/components/DepositTimesCard.tsx create mode 100644 src/views/DepositStatus/components/EarnActionCard.tsx create mode 100644 src/views/DepositStatus/components/EarnByLpAndStakingCard.tsx create mode 100644 src/views/DepositStatus/components/ElapsedTime.tsx create mode 100644 src/views/DepositStatus/hooks/useDepositTracking.ts create mode 100644 src/views/DepositStatus/hooks/useElapsedSeconds.ts create mode 100644 src/views/DepositStatus/index.tsx create mode 100644 src/views/DepositStatus/types.ts create mode 100644 src/views/DepositStatus/utils.ts diff --git a/src/Routes.tsx b/src/Routes.tsx index ba4b4ff66..9c09a3cbe 100644 --- a/src/Routes.tsx +++ b/src/Routes.tsx @@ -51,6 +51,7 @@ const AllTransactions = lazyWithRetry( const Staking = lazyWithRetry( () => import(/* webpackChunkName: "RewardStaking" */ "./views/Staking") ); +const DepositStatus = lazyWithRetry(() => import("./views/DepositStatus")); const warningMessage = ` We noticed that you have connected from a contract address. @@ -168,6 +169,7 @@ const Routes: React.FC = () => { }} /> + diff --git a/src/assets/bg-banners/action-card-teal-banner.svg b/src/assets/bg-banners/action-card-teal-banner.svg new file mode 100644 index 000000000..7e21fccec --- /dev/null +++ b/src/assets/bg-banners/action-card-teal-banner.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/assets/check-star-ring-filled.svg b/src/assets/check-star-ring-filled.svg deleted file mode 100644 index c1acce71c..000000000 --- a/src/assets/check-star-ring-filled.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/assets/check-star-ring-opaque-depositing.svg b/src/assets/check-star-ring-opaque-depositing.svg new file mode 100644 index 000000000..573fa0d2c --- /dev/null +++ b/src/assets/check-star-ring-opaque-depositing.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/check-star-ring-opaque-fill.svg b/src/assets/check-star-ring-opaque-fill.svg deleted file mode 100644 index 8b9f2e2a6..000000000 --- a/src/assets/check-star-ring-opaque-fill.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/assets/check-star-ring-opaque-filled.svg b/src/assets/check-star-ring-opaque-filled.svg index 8b9f2e2a6..cfb3dc440 100644 --- a/src/assets/check-star-ring-opaque-filled.svg +++ b/src/assets/check-star-ring-opaque-filled.svg @@ -1,4 +1,6 @@ - - + + + + diff --git a/src/assets/check-star-ring-opaque-filling.svg b/src/assets/check-star-ring-opaque-filling.svg new file mode 100644 index 000000000..3d4a3d21d --- /dev/null +++ b/src/assets/check-star-ring-opaque-filling.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/check.svg b/src/assets/check.svg new file mode 100644 index 000000000..c99232552 --- /dev/null +++ b/src/assets/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/loading.svg b/src/assets/loading.svg new file mode 100644 index 000000000..3b50314ea --- /dev/null +++ b/src/assets/loading.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx new file mode 100644 index 000000000..cf6658458 --- /dev/null +++ b/src/components/Badge/Badge.tsx @@ -0,0 +1,33 @@ +import styled from "@emotion/styled"; + +import { COLORS } from "utils"; + +export type BadgeColor = keyof typeof COLORS; + +type BadgeProps = { + borderColor?: BadgeColor; + textColor?: BadgeColor; +}; + +export const Badge = styled.div` + display: flex; + height: 20px; + padding: 8px 5px 10px 5px; + justify-content: center; + align-items: center; + + font-variant-numeric: lining-nums tabular-nums; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: normal; + letter-spacing: 0.48px; + text-transform: uppercase; + + border-radius: 6px; + border: 1px solid; + border-color: ${({ borderColor, textColor }) => + COLORS[borderColor || textColor || "white-100"]}; + color: ${({ borderColor, textColor }) => + COLORS[textColor || borderColor || "white-100"]}; +`; diff --git a/src/components/Badge/index.tsx b/src/components/Badge/index.tsx new file mode 100644 index 000000000..5c7042709 --- /dev/null +++ b/src/components/Badge/index.tsx @@ -0,0 +1 @@ +export { Badge } from "./Badge"; diff --git a/src/components/BreadcrumbV2/BreadcrumbV2.tsx b/src/components/BreadcrumbV2/BreadcrumbV2.tsx index 8e73238b8..1e5142a8d 100644 --- a/src/components/BreadcrumbV2/BreadcrumbV2.tsx +++ b/src/components/BreadcrumbV2/BreadcrumbV2.tsx @@ -80,6 +80,7 @@ const ActiveLink = styled(Link)` const ActiveLinkText = styled(Text)` color: #9daab2; + text-transform: capitalize; `; const InactiveLink = styled(Text)` diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index c94feafd1..dd726ebaf 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -18,6 +18,7 @@ type SecondaryButtonProps = BaseButtonProps & { textColor?: ButtonColor; borderColor?: ButtonColor; hoveredBorderColor?: ButtonColor; + backgroundColor?: ButtonColor; }; const sizeMap: Record< @@ -131,6 +132,8 @@ export const SecondaryButton = styled(BaseButton)` COLORS[textColor || borderColor]}; border: 1px solid; border-color: ${({ borderColor = "aqua" }) => COLORS[borderColor]}; + background-color: ${({ backgroundColor }) => + backgroundColor ? COLORS[backgroundColor] : "transparent"}; &:hover:not(:disabled) { border-color: ${({ hoveredBorderColor, borderColor = "aqua" }) => diff --git a/src/components/Text/Text.tsx b/src/components/Text/Text.tsx index 39bcc6a39..7d279f3b0 100644 --- a/src/components/Text/Text.tsx +++ b/src/components/Text/Text.tsx @@ -2,7 +2,7 @@ import styled from "@emotion/styled"; import { QUERIESV2, COLORS } from "utils"; -type TextSize = +export type TextSize = | "4xl" | "3.5xl" | "3xl" diff --git a/src/components/Text/index.ts b/src/components/Text/index.ts index c53caa876..d98e65bd2 100644 --- a/src/components/Text/index.ts +++ b/src/components/Text/index.ts @@ -1,2 +1,2 @@ export { Text } from "./Text"; -export type { TextColor } from "./Text"; +export type { TextColor, TextSize } from "./Text"; diff --git a/src/components/index.ts b/src/components/index.ts index 72137c81a..0e9816201 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -10,7 +10,9 @@ export { default as Selector } from "./Selector"; export { default as InputWithMaxButton } from "./InputWithMaxButton"; export { Text } from "./Text"; export { WrongNetworkHeader } from "./WrongNetworkHeader"; +export { default as CardWrapper } from "./CardWrapper"; export * from "./Button"; +export * from "./Badge"; export * from "./ExternalLink"; export * from "./ErrorBoundary"; diff --git a/src/hooks/useDeposits.ts b/src/hooks/useDeposits.ts index 96e1190d1..feeebedd5 100644 --- a/src/hooks/useDeposits.ts +++ b/src/hooks/useDeposits.ts @@ -7,8 +7,7 @@ import { userDepositsQueryKey, defaultRefetchInterval, } from "utils"; - -import { useLocalPendingDeposits } from "./useLocalPendingDeposits"; +import { getLocalDeposits, removeLocalDeposits } from "../utils/local-deposits"; export type DepositStatus = "pending" | "filled"; @@ -37,6 +36,16 @@ export type Deposit = { depositRelayerFeePct: string; initialRelayerFeePct?: string; suggestedRelayerFeePct?: string; + feeBreakdown?: { + bridgeFee: { + pct: string; + usd: string; + }; + destinationGasFee: { + pct: string; + usd: string; + }; + }; }; export type Pagination = { @@ -68,9 +77,6 @@ export function useUserDeposits( offset: number = 0, userAddress?: string ) { - const { getLocalPendingDeposits, removeLocalPendingDeposits } = - useLocalPendingDeposits(); - return useQuery( userDepositsQueryKey(userAddress!, status, limit, offset), async () => { @@ -85,12 +91,13 @@ export function useUserDeposits( }; } - // To provide a better UX, we take optimistically updated local pending deposits + // To provide a better UX, we take optimistically updated local deposits // into account to show on the "My Transactions" page. - const localPendingUserDeposits = getLocalPendingDeposits().filter( + const localUserDeposits = getLocalDeposits().filter( (deposit) => - deposit.depositorAddr === userAddress || - deposit.recipientAddr === userAddress + deposit.status === status && + (deposit.depositorAddr === userAddress || + deposit.recipientAddr === userAddress) ); const { deposits, pagination } = await getDeposits({ address: userAddress, @@ -102,27 +109,24 @@ export function useUserDeposits( deposits.map((d) => d.depositTxHash) ); - // If the Scraper API indexed the optimistically added pending deposit, + // If the Scraper API indexed the optimistically added deposit, // then we need to remove it from local storage. - const indexedLocalPendingDeposits = localPendingUserDeposits.filter( + const indexedLocalDeposits = localUserDeposits.filter( (localPendingDeposit) => indexedDepositTxHashes.has(localPendingDeposit.depositTxHash) ); - removeLocalPendingDeposits( - indexedLocalPendingDeposits.map((deposit) => deposit.depositTxHash) + removeLocalDeposits( + indexedLocalDeposits.map((deposit) => deposit.depositTxHash) ); // If the Scraper API is still a few blocks behind and didn't index // the optimistically added deposits, then we merge them to provide instant // visibility of a deposit after a user performed a transaction. - const notIndexedLocalPendingDeposits = localPendingUserDeposits.filter( + const notIndexedLocalDeposits = localUserDeposits.filter( (localPendingDeposit) => !indexedDepositTxHashes.has(localPendingDeposit.depositTxHash) ); - const mergedDeposits = - status === "pending" - ? [...notIndexedLocalPendingDeposits, ...deposits] - : deposits; + const mergedDeposits = [...notIndexedLocalDeposits, ...deposits]; return { deposits: mergedDeposits, diff --git a/src/hooks/useLocalPendingDeposits.ts b/src/hooks/useLocalPendingDeposits.ts deleted file mode 100644 index 5841b7d1f..000000000 --- a/src/hooks/useLocalPendingDeposits.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Deposit } from "./useDeposits"; - -const LOCAL_PENDING_DEPOSITS_KEY = "local-pending-deposits"; - -export function useLocalPendingDeposits() { - const addLocalPendingDeposit = (newDeposit: Deposit) => { - const localPendingDeposits = getLocalPendingDeposits(); - const filteredLocalPendingDeposits = localPendingDeposits.filter( - (deposit) => deposit.depositTxHash !== newDeposit.depositTxHash - ); - window.localStorage.setItem( - LOCAL_PENDING_DEPOSITS_KEY, - JSON.stringify([newDeposit, ...filteredLocalPendingDeposits]) - ); - }; - - const getLocalPendingDeposits = () => { - const localPendingDeposits = window.localStorage.getItem( - LOCAL_PENDING_DEPOSITS_KEY - ); - return ( - localPendingDeposits ? JSON.parse(localPendingDeposits) : [] - ) as Deposit[]; - }; - - const removeLocalPendingDeposits = (depositTxHashes: string[]) => { - const localPendingDeposits = getLocalPendingDeposits(); - const filteredLocalPendingDeposits = localPendingDeposits.filter( - (deposit) => !depositTxHashes.includes(deposit.depositTxHash) - ); - window.localStorage.setItem( - LOCAL_PENDING_DEPOSITS_KEY, - JSON.stringify(filteredLocalPendingDeposits) - ); - }; - - return { - addLocalPendingDeposit, - getLocalPendingDeposits, - removeLocalPendingDeposits, - }; -} diff --git a/src/stories/Badge.stories.tsx b/src/stories/Badge.stories.tsx new file mode 100644 index 000000000..c958194ff --- /dev/null +++ b/src/stories/Badge.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { Badge } from "../components/Badge"; + +const meta: Meta = { + component: Badge, + argTypes: { + borderColor: { + control: { + type: "select", + }, + }, + textColor: { + control: { + type: "select", + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const DefaultBadge: Story = { + render: (args) => ( + <> + 1 / 2 + + ), +}; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index ec04c6eba..940b2d73d 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -588,7 +588,7 @@ export const COLORS = { brand: "var(--color-interface-aqua)", error: "var(--color-interface-red)", warning: "var(--color-interface-yellow)", - white: "var(--tints-shades-white-100)", + white: "var(--color-interface-white)", "dark-grey": "var(--color-neutrals-black-800)", }; diff --git a/src/utils/deposits.ts b/src/utils/deposits.ts index 95a4fc806..786a4546d 100644 --- a/src/utils/deposits.ts +++ b/src/utils/deposits.ts @@ -1,5 +1,17 @@ +import { getConfig } from "./config"; +import { getProvider } from "./providers"; import { SpokePool__factory } from "./typechain"; +const config = getConfig(); + +export class NoFundsDepositedLogError extends Error { + constructor(depositTxHash: string, chainId: number) { + super( + `Could not parse log FundsDeposited in tx ${depositTxHash} on chain ${chainId}` + ); + } +} + export function parseFundsDepositedLog( logs: Array<{ topics: string[]; @@ -16,3 +28,84 @@ export function parseFundsDepositedLog( }); return parsedLogs.find((log) => log.name === "FundsDeposited"); } + +export async function getDepositByTxHash( + depositTxHash: string, + fromChainId: number +) { + const fromProvider = getProvider(fromChainId); + + const depositTxReceipt = await fromProvider.getTransactionReceipt( + depositTxHash + ); + + if (!depositTxReceipt) { + throw new Error( + `Could not fetch tx receipt for ${depositTxHash} on chain ${fromChainId}` + ); + } + + const parsedDepositLog = parseFundsDepositedLog(depositTxReceipt.logs); + + if (!parsedDepositLog) { + throw new NoFundsDepositedLogError(depositTxHash, fromChainId); + } + + const block = await fromProvider.getBlock(depositTxReceipt.blockNumber); + + return { + depositTxReceipt, + parsedDepositLog, + depositTimestamp: block.timestamp, + }; +} + +export async function getFillByDepositTxHash( + depositTxHash: string, + fromChainId: number, + toChainId: number, + depositByTxHash: Awaited> +) { + if (!depositByTxHash) { + throw new Error( + `Could not fetch deposit by tx hash ${depositTxHash} on chain ${fromChainId}` + ); + } + + const { parsedDepositLog } = depositByTxHash; + + const depositId = Number(parsedDepositLog.args.depositId); + const depositor = String(parsedDepositLog.args.depositor); + const destinationSpokePool = config.getSpokePool(toChainId); + const filledRelayEvents = await destinationSpokePool.queryFilter( + destinationSpokePool.filters.FilledRelay( + undefined, + undefined, + undefined, + undefined, + fromChainId, + undefined, + undefined, + undefined, + depositId, + undefined, + undefined, + depositor + ) + ); + + if (!filledRelayEvents.length) { + throw new Error( + `Could not find FilledRelay events for depositId ${depositId} on chain ${toChainId}` + ); + } + + const filledRelayEvent = filledRelayEvents[0]; + const fillTxBlock = await filledRelayEvent.getBlock(); + + return { + fillTxHashes: filledRelayEvents.map((event) => event.transactionHash), + fillTxTimestamp: fillTxBlock.timestamp, + depositByTxHash, + }; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index cf27a561a..bb78cb67c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -17,3 +17,4 @@ export * from "./rewards"; export * from "./time"; export * from "./amplitude"; export * from "./deposits"; +export * from "./wait"; diff --git a/src/utils/local-deposits.ts b/src/utils/local-deposits.ts new file mode 100644 index 000000000..a2358b807 --- /dev/null +++ b/src/utils/local-deposits.ts @@ -0,0 +1,39 @@ +import { Deposit } from "../hooks/useDeposits"; + +const LOCAL_DEPOSITS_KEY = "local-deposits"; + +export function addLocalDeposit(newDeposit: Deposit) { + const localPendingDeposits = getLocalDeposits(); + const filteredLocalPendingDeposits = localPendingDeposits.filter( + (deposit) => deposit.depositTxHash !== newDeposit.depositTxHash + ); + window.localStorage.setItem( + LOCAL_DEPOSITS_KEY, + JSON.stringify([newDeposit, ...filteredLocalPendingDeposits]) + ); +} + +export function getLocalDeposits() { + const localPendingDeposits = window.localStorage.getItem(LOCAL_DEPOSITS_KEY); + return ( + localPendingDeposits ? JSON.parse(localPendingDeposits) : [] + ) as Deposit[]; +} + +export function getLocalDepositByTxHash(depositTxHash: string) { + const localPendingDeposits = getLocalDeposits(); + return localPendingDeposits.find( + (deposit) => deposit.depositTxHash === depositTxHash + ); +} + +export function removeLocalDeposits(depositTxHashes: string[]) { + const localPendingDeposits = getLocalDeposits(); + const filteredLocalPendingDeposits = localPendingDeposits.filter( + (deposit) => !depositTxHashes.includes(deposit.depositTxHash) + ); + window.localStorage.setItem( + LOCAL_DEPOSITS_KEY, + JSON.stringify(filteredLocalPendingDeposits) + ); +} diff --git a/src/utils/time.ts b/src/utils/time.ts index d053f2178..f38342084 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -17,7 +17,7 @@ export function formatSeconds(seconds?: number): string | undefined { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); - const secondsLeft = seconds % 60; + const secondsLeft = Math.floor(seconds % 60); return `${hours.toString().padStart(2, "0")}h ${minutes .toString() diff --git a/src/utils/transactions.ts b/src/utils/transactions.ts index 23a28456d..b7617acaa 100644 --- a/src/utils/transactions.ts +++ b/src/utils/transactions.ts @@ -80,3 +80,7 @@ export function sendWithPaddedGas( }; return fn; } + +export function isValidTxHash(txHash: string) { + return new RegExp(/^0x([A-Fa-f0-9]{64})$/).test(txHash); +} diff --git a/src/utils/typechain.ts b/src/utils/typechain.ts index 2dce307b8..346228419 100644 --- a/src/utils/typechain.ts +++ b/src/utils/typechain.ts @@ -13,7 +13,10 @@ export { ClaimAndStake__factory } from "@across-protocol/across-token/dist/typec export type { AcrossMerkleDistributor } from "@across-protocol/contracts-v2/dist/typechain/contracts/merkle-distributor/AcrossMerkleDistributor"; export type { HubPool } from "@across-protocol/contracts-v2/dist/typechain/contracts/HubPool"; -export type { SpokePool } from "@across-protocol/contracts-v2/dist/typechain/contracts/SpokePool.sol/SpokePool"; +export type { + SpokePool, + FundsDepositedEvent, +} from "@across-protocol/contracts-v2/dist/typechain/contracts/SpokePool.sol/SpokePool"; export type { SpokePoolVerifier } from "@across-protocol/contracts-v2/dist/typechain/contracts/SpokePoolVerifier"; export type { AcceleratingDistributor } from "@across-protocol/across-token/dist/typechain/AcceleratingDistributor"; export type { ClaimAndStake } from "@across-protocol/across-token/dist/typechain/ClaimAndStake"; diff --git a/src/utils/wait.ts b/src/utils/wait.ts new file mode 100644 index 000000000..4e532a464 --- /dev/null +++ b/src/utils/wait.ts @@ -0,0 +1,3 @@ +export function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/views/Bridge/Bridge.tsx b/src/views/Bridge/Bridge.tsx index 1feb8cab6..2372e3bee 100644 --- a/src/views/Bridge/Bridge.tsx +++ b/src/views/Bridge/Bridge.tsx @@ -5,7 +5,6 @@ import { Wrapper } from "./Bridge.styles"; import Breadcrumb from "./components/Breadcrumb"; import BridgeForm from "./components/BridgeForm"; import ChangeAccountModal from "./components/ChangeAccountModal"; -import DepositConfirmation from "./components/DepositConfirmation"; import { useBridge } from "./hooks/useBridge"; const Bridge = () => { @@ -28,13 +27,6 @@ const Bridge = () => { estimatedTimeString, toAccount, setCustomToAddress, - trackingTxHash, - transactionPending, - handleClickNewTx, - explorerUrl, - transactionElapsedTimeAsFormattedString, - isCurrentTokenMaxApyLoading, - currentTokenMaxApy, handleChangeAmountInput, handleClickMaxBalance, handleSelectToken, @@ -56,48 +48,29 @@ const Bridge = () => { - {trackingTxHash ? ( - - ) : ( - setDisplayChangeAccount(true)} - fees={fees} - estimatedTimeString={estimatedTimeString} - isConnected={isConnected} - isWrongChain={isWrongChain} - buttonLabel={buttonLabel} - isBridgeDisabled={isBridgeDisabled} - validationError={amountValidationError} - balance={balance} - /> - )} + setDisplayChangeAccount(true)} + fees={fees} + estimatedTimeString={estimatedTimeString} + isConnected={isConnected} + isWrongChain={isWrongChain} + buttonLabel={buttonLabel} + isBridgeDisabled={isBridgeDisabled} + validationError={amountValidationError} + balance={balance} + /> diff --git a/src/views/Bridge/components/BridgeForm.tsx b/src/views/Bridge/components/BridgeForm.tsx index 1788f9d63..653a6eaa5 100644 --- a/src/views/Bridge/components/BridgeForm.tsx +++ b/src/views/Bridge/components/BridgeForm.tsx @@ -152,7 +152,6 @@ const BridgeForm = ({ : undefined } token={getToken(selectedRoute.fromTokenSymbol)} - dataLoaded={isConnected} receiveToken={getToken( getReceiveTokenSymbol( selectedRoute.toChain, diff --git a/src/views/Bridge/components/DepositConfirmation.tsx b/src/views/Bridge/components/DepositConfirmation.tsx deleted file mode 100644 index 1db756772..000000000 --- a/src/views/Bridge/components/DepositConfirmation.tsx +++ /dev/null @@ -1,493 +0,0 @@ -import styled from "@emotion/styled"; -import { useHistory } from "react-router-dom"; -import BgBanner from "assets/bg-banners/deposit-banner.svg"; -import { Text } from "components"; -import { ReactComponent as CheckStarIcon } from "assets/check-star-ring-opaque-filled.svg"; -import { - ChainId, - GetBridgeFeesResult, - QUERIESV2, - formatWeiPct, - getToken, - receiveAmount, -} from "utils"; -import { ReactComponent as ExternalLinkIcon } from "assets/icons/arrow-external-link-16.svg"; -import EstimatedTable from "./EstimatedTable"; -import { BigNumber } from "ethers"; -import { UnstyledButton } from "components/Button"; -import { keyframes } from "@emotion/react"; -import { ReactComponent as EthereumGrayscaleLogo } from "assets/grayscale-logos/eth.svg"; -import { ReactComponent as PolygonGrayscaleLogo } from "assets/grayscale-logos/polygon.svg"; -import { ReactComponent as ArbitrumGrayscaleLogo } from "assets/grayscale-logos/arbitrum.svg"; -import { ReactComponent as OptimismGrayscaleLogo } from "assets/grayscale-logos/optimism.svg"; -import { ReactComponent as ZkSyncGrayscaleLogo } from "assets/grayscale-logos/zksync.svg"; -import { ReactComponent as BaseGrayscaleLogo } from "assets/grayscale-logos/base.svg"; -import { ReactComponent as ArrowStarRingIcon } from "assets/arrow-star-ring.svg"; -import { ReactComponent as ArrowRightIcon } from "assets/icons/arrow-right-16.svg"; -import { getReceiveTokenSymbol } from "../utils"; -import { ToAccount } from "../hooks/useToAccount"; -import { useAmplitude } from "hooks/useAmplitude"; -import { ampli } from "ampli"; - -type DepositConfirmationProps = { - currentFromRoute: number | undefined; - currentToRoute: number | undefined; - currentToken: string; - toAccount?: ToAccount; - - fees: GetBridgeFeesResult | undefined; - amountToBridge: BigNumber | undefined; - estimatedTime: string | undefined; - - isConnected: boolean; - transactionPending: boolean; - onClickNewTx: () => void; - - explorerLink?: string; - elapsedTimeFromDeposit?: string; - - currentTokenMaxApy?: BigNumber; - isCurrentTokenMaxApyLoading?: boolean; -}; - -const logoMapping: { - [key: number]: JSX.Element; -} = { - [ChainId.ARBITRUM]: , - [ChainId.POLYGON]: , - [ChainId.OPTIMISM]: , - [ChainId.MAINNET]: , - [ChainId.ZK_SYNC]: , - [ChainId.BASE]: , - // testnets - [ChainId.GOERLI]: , - [ChainId.ARBITRUM_GOERLI]: , - [ChainId.MUMBAI]: , - [ChainId.ZK_SYNC_GOERLI]: , - [ChainId.BASE_GOERLI]: , -}; - -const DepositConfirmation = ({ - currentFromRoute, - currentToRoute, - currentToken, - toAccount, - fees, - amountToBridge, - estimatedTime, - isConnected, - transactionPending, - onClickNewTx, - explorerLink: _explorerLink, - elapsedTimeFromDeposit, - currentTokenMaxApy, -}: DepositConfirmationProps) => { - const explorerLink = _explorerLink ?? "https://etherscan.io"; - - const history = useHistory(); - const { addToAmpliQueue } = useAmplitude(); - - return ( - - - - - - {logoMapping[currentFromRoute ?? 1]} - - - - - - - - {logoMapping[currentToRoute ?? 1]} - - - - {transactionPending ? ( - - - {elapsedTimeFromDeposit ?? "00h 00m 00s"} - - - Deposit in progress - - - ) : ( - - - Deposit successful! - - - Finished in{" "} - - {elapsedTimeFromDeposit ?? "00h 00m 00s"} - - - - )} - - - { - const tokenSymbol = ["USDC.e", "USDbC"].includes(currentToken) - ? "USDC" - : currentToken; - history.push(`/pool?symbol=${tokenSymbol.toLowerCase()}`); - addToAmpliQueue(() => { - ampli.earnByAddingLiquidityClicked({ - action: "onClick", - element: "earnByAddingLiquidityAndStakingLink", - page: "bridgePage", - section: "depositConfirmation", - }); - }); - }} - > - - - - - - Earn{" "} - - {currentTokenMaxApy ? formatWeiPct(currentTokenMaxApy, 3) : "-"} - % - {" "} - by adding liquidity and staking - - - - - - - - - - - Monitor progress - - - Transactions page - - - { - addToAmpliQueue(() => { - ampli.monitorDepositProgressClicked({ - action: "onClick", - element: "monitorDepositProgressLink", - page: "bridgePage", - section: "depositConfirmation", - }); - }); - }} - > - - - - - - - Track in Explorer - - - {new URL(explorerLink).hostname} - - - { - addToAmpliQueue(() => { - ampli.trackInExplorerClicked({ - action: "onClick", - element: "trackInExplorerLink", - page: "bridgePage", - section: "depositConfirmation", - }); - }); - }} - > - - - - - - - - - ); -}; - -export default DepositConfirmation; - -const AnimationFadeInBottom = keyframes` - from { - opacity: 0; - transform: translateY(20%); - } - to { opacity: 1 } -`; - -const Wrapper = styled.div` - display: flex; - flex-direction: column; - align-items: center; - padding: 0px 24px 24px; - gap: 24px; - - border: 1px solid #3e4047; - border-radius: 16px; - - overflow: clip; - background: #34353b; - - animation-name: ${AnimationFadeInBottom}; - animation-duration: 1s; -`; - -const TopWrapper = styled.div` - display: flex; - flex-direction: column; - gap: 30px; - - background-image: url(${BgBanner}); - background-color: #2d2e33; - border-bottom: 1px solid #3e4047; - - width: calc(100% + 48px); - margin: 0 -24px; - padding: 45px 24px 34px; -`; - -const TopWrapperTitleWrapper = styled.div` - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - padding: 0px; - gap: 8px; - - width: 100%; -`; - -const AnimatedTopWrapperTitleWrapper = styled(TopWrapperTitleWrapper)` - animation-name: ${AnimationFadeInBottom}; - animation-duration: 1s; -`; - -const TopWrapperAnimationWrapper = styled.div` - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - padding: 0px; - gap: 8px; -`; - -const StyledCheckStarIcon = styled(CheckStarIcon)<{ completed?: boolean }>` - & * { - stroke: ${({ completed }) => (completed ? "#6cf9d8" : "#9daab3")}; - transition: stroke 0.5s ease-in-out; - } - flex-shrink: 0; - - @media ${QUERIESV2.sm.andDown} { - width: 52px; - height: 52px; - } -`; - -const AnimatedDivider = styled.div<{ completed?: boolean }>` - width: ${({ completed }) => (completed ? "22px" : "50px")}; - height: 1px; - background: ${({ completed }) => (completed ? "#6cf9d8" : "#9daab3")}; - flex-shrink: 0; - - transition: width 0.5s ease-in-out, background-color 0.5s ease-in-out; -`; - -const AnimatedLogoWrapper = styled.div<{ completed?: boolean }>` - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - gap: 8px; - - width: 60px; - height: 60px; - - background: #2d2e33; - - border: 1px solid; - border-color: ${({ completed }) => (completed ? "#6cf9d8" : "#9daab3")}; - transition: border-color 0.5s ease-in-out; - - border-radius: 100px; - - flex-shrink: 0; - - @media ${QUERIESV2.sm.andDown} { - width: 40px; - height: 40px; - } -`; - -const ActionCardContainer = styled.div` - display: flex; - flex-direction: row; - align-items: flex-start; - padding: 0px; - gap: 16px; - width: 100%; - - @media ${QUERIESV2.sm.andDown} { - flex-direction: column; - } -`; - -const ActionCard = styled.div<{ isClickable?: boolean }>` - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - padding: 16px; - gap: 8px; - - height: 64px; - width: 100%; - - background: #3e4047; - border: 1px solid #4c4e57; - border-radius: 8px; - - cursor: ${({ isClickable }) => (isClickable ? "pointer" : "default")}; -`; - -const ActionCardTitleWrapper = styled.div` - display: flex; - flex-direction: column; - align-items: flex-start; - padding: 0px; -`; - -const StyledExternalLinkIcon = styled(ExternalLinkIcon)` - height: 32px; - width: 32px; - flex-shrink: 0; - cursor: pointer; - & * { - stroke: #9daab3; - } -`; - -const ExternalContainerIconAnchor = styled.a` - height: 32px; - width: 32px; -`; - -const Divider = styled.div` - width: 100%; - height: 1px; - background: #3e4047; -`; - -const Button = styled(UnstyledButton)<{ disabled?: boolean }>` - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - - background: transparent; - border-radius: 32px; - border: 1px solid ${({ disabled }) => (disabled ? "#9daab3" : "#6cf9d8")}; - height: 64px; - width: 100%; - - transition: border-color 0.5s ease-in-out; - - > * { - transition: color 0.5s ease-in-out; - } -`; - -const WhiteSpanText = styled.span` - color: #ffffff; -`; - -const AnimatedLogo = styled.div<{ - completed?: boolean; -}>` - width: 48px; - height: 48px; - & svg { - width: 48px; - height: 48px; - border-radius: 100%; - & rect, - circle, - #path-to-animate { - transition: fill 1s ease-in-out; - fill: ${({ completed }) => (completed ? "#6cf9d8" : "#9daab3")}; - } - } - - @media ${QUERIESV2.sm.andDown} { - width: 32px; - height: 32px; - - & svg { - width: 32px; - height: 32px; - } - } -`; - -const LPInfoIconContainer = styled.div` - margin-left: -16px; - margin-top: 16px; -`; - -const LPInfoIconAndTextWrapper = styled.div` - display: flex; - flex-direction: row; - align-items: center; - gap: 8px; -`; diff --git a/src/views/Bridge/components/EstimatedTable.tsx b/src/views/Bridge/components/EstimatedTable.tsx index f1a767455..8d6ff79d4 100644 --- a/src/views/Bridge/components/EstimatedTable.tsx +++ b/src/views/Bridge/components/EstimatedTable.tsx @@ -22,7 +22,6 @@ type EstimatedTableProps = { bridgeFee?: BigNumber; totalReceived?: BigNumber; token: TokenInfo; - dataLoaded: boolean; receiveToken: TokenInfo; }; diff --git a/src/views/Bridge/hooks/useBridge.ts b/src/views/Bridge/hooks/useBridge.ts index e82d03787..ae4cd0a6d 100644 --- a/src/views/Bridge/hooks/useBridge.ts +++ b/src/views/Bridge/hooks/useBridge.ts @@ -1,13 +1,11 @@ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { utils } from "@across-protocol/sdk-v2"; import { useConnection, useIsWrongNetwork, useAmplitude } from "hooks"; import useReferrer from "hooks/useReferrer"; -import { useStakingPool } from "hooks/useStakingPool"; import { ampli } from "ampli"; import { useBridgeAction } from "./useBridgeAction"; -import { useBridgeDepositTracking } from "./useBridgeDepositTracking"; import { useToAccount } from "./useToAccount"; import { useSelectRoute } from "./useSelectRoute"; import { useTransferQuote } from "./useTransferQuote"; @@ -32,22 +30,9 @@ export function useBridge() { handleSelectToken, } = useSelectRoute(); - const { - handleTxHashChange, - trackingTxHash, - transactionPending, - explorerUrl, - transactionElapsedTimeAsFormattedString, - handleTransactionCompleted, - } = useBridgeDepositTracking(); - - const { data: selectedRoutePool, isLoading: isSelectedRoutePoolLoading } = - useStakingPool(selectedRoute.l1TokenAddress); - const { handleChangeAmountInput, handleClickMaxBalance, - clearInput, userAmountInput, parsedAmount, balance, @@ -56,7 +41,11 @@ export function useBridge() { const { toAccount, setCustomToAddress } = useToAccount(selectedRoute.toChain); - const { data: transferQuote, isLoading: isQuoteLoading } = useTransferQuote( + const { + data: transferQuote, + isLoading: isQuoteLoading, + isFetching: isQuoteFetching, + } = useTransferQuote( selectedRoute, parsedAmount?.gt(0) ? parsedAmount : utils.bnZero, account, @@ -72,6 +61,9 @@ export function useBridge() { estimatedTime, } = usedTransferQuote || {}; + const isQuoteUpdating = + shouldUpdateQuote && (isQuoteLoading || isQuoteFetching); + const { amountValidationError, isAmountValid } = useValidAmount( parsedAmount, quotedFees?.isAmountTooLow, @@ -86,7 +78,7 @@ export function useBridge() { } = useIsWrongNetwork(selectedRoute.fromChain); const bridgeAction = useBridgeAction( - isQuoteLoading, + isQuoteUpdating, isAmountValid && parsedAmount && quotedFees && quotedLimits ? { amount: parsedAmount, @@ -101,8 +93,6 @@ export function useBridge() { } : undefined, selectedRoute.fromTokenSymbol, - handleTxHashChange, - handleTransactionCompleted, quote, initialQuoteTime, quotePriceUSD @@ -113,7 +103,7 @@ export function useBridge() { }, [selectedRoute.fromChain, isConnected, checkWrongNetworkHandler]); useEffect(() => { - if (shouldUpdateQuote && !isQuoteLoading) { + if (!isQuoteUpdating && shouldUpdateQuote) { setUsedTransferQuote(transferQuote); if (transferQuote?.quote) { @@ -122,13 +112,10 @@ export function useBridge() { }); } } - }, [transferQuote, shouldUpdateQuote, isQuoteLoading, addToAmpliQueue]); + }, [transferQuote, shouldUpdateQuote, isQuoteUpdating, addToAmpliQueue]); useEffect(() => { - if ( - shouldUpdateQuote && - (bridgeAction.isButtonActionLoading || trackingTxHash) - ) { + if (shouldUpdateQuote && bridgeAction.isButtonActionLoading) { setShouldUpdateQuote(false); } else if (bridgeAction.didActionError && !shouldUpdateQuote) { setShouldUpdateQuote(true); @@ -136,20 +123,12 @@ export function useBridge() { }, [ shouldUpdateQuote, bridgeAction.isButtonActionLoading, - trackingTxHash, bridgeAction.didActionError, ]); - const handleClickNewTx = useCallback(() => { - clearInput(); - setShouldUpdateQuote(true); - handleTxHashChange(undefined); - }, [clearInput, handleTxHashChange]); - - const estimatedTimeString = - isQuoteLoading && !trackingTxHash - ? "loading..." - : estimatedTime?.formattedString; + const estimatedTimeString = isQuoteLoading + ? "loading..." + : estimatedTime?.formattedString; return { ...bridgeAction, @@ -167,11 +146,6 @@ export function useBridge() { isBridgeDisabled: isConnected && bridgeAction.buttonDisabled, amountToBridge: parsedAmount, estimatedTimeString, - trackingTxHash, - transactionPending, - explorerUrl, - handleClickNewTx, - transactionElapsedTimeAsFormattedString, handleChangeAmountInput, handleClickMaxBalance, userAmountInput, @@ -179,7 +153,5 @@ export function useBridge() { handleSelectFromChain, handleSelectToChain, handleSelectToken, - isCurrentTokenMaxApyLoading: isSelectedRoutePoolLoading, - currentTokenMaxApy: selectedRoutePool?.apyData.maxApy, }; } diff --git a/src/views/Bridge/hooks/useBridgeAction.ts b/src/views/Bridge/hooks/useBridgeAction.ts index 0123fde52..0b8c66257 100644 --- a/src/views/Bridge/hooks/useBridgeAction.ts +++ b/src/views/Bridge/hooks/useBridgeAction.ts @@ -1,26 +1,20 @@ import { ampli, TransferQuoteReceivedProperties } from "ampli"; -import { BigNumber, ContractTransaction, utils } from "ethers"; -import { DateTime } from "luxon"; +import { BigNumber } from "ethers"; import { useConnection, useApprove, useIsWrongNetwork, useAmplitude, } from "hooks"; -import { useLocalPendingDeposits } from "hooks/useLocalPendingDeposits"; import { cloneDeep } from "lodash"; import { useMutation } from "react-query"; +import { useHistory } from "react-router-dom"; import { AcrossDepositArgs, - generateDepositConfirmed, generateTransferSigned, generateTransferSubmitted, getConfig, - getToken, - recordTransferUserProperties, sendAcrossDeposit, - waitOnTransaction, - parseFundsDepositedLog, } from "utils"; const config = getConfig(); @@ -29,19 +23,17 @@ export function useBridgeAction( dataLoading: boolean, payload?: AcrossDepositArgs, tokenSymbol?: string, - onTransactionComplete?: (hash: string) => void, - onDepositResolved?: (success: boolean) => void, recentQuote?: TransferQuoteReceivedProperties, recentInitialQuoteTime?: number, tokenPrice?: BigNumber ) { - const { isConnected, connect, signer, notify, account } = useConnection(); + const { isConnected, connect, signer, account } = useConnection(); + const history = useHistory(); const { isWrongNetwork, isWrongNetworkHandler } = useIsWrongNetwork( payload?.fromChain ); - const { addLocalPendingDeposit } = useLocalPendingDeposits(); const approveHandler = useApprove(payload?.fromChain); const { addToAmpliQueue } = useAmplitude(); @@ -87,9 +79,6 @@ export function useBridgeAction( }); } - let succeeded = false; - let timeSigned: number | undefined = undefined; - let tx: ContractTransaction | undefined = undefined; addToAmpliQueue(() => { // Instrument amplitude before sending the transaction for the submit button. ampli.transferSubmitted( @@ -98,7 +87,7 @@ export function useBridgeAction( }); const timeSubmitted = Date.now(); - tx = await sendAcrossDeposit( + const tx = await sendAcrossDeposit( signer, frozenPayload, (networkMismatchProperties) => { @@ -108,76 +97,31 @@ export function useBridgeAction( } ); - try { - // Instrument amplitude after signing the transaction for the submit button. - timeSigned = Date.now(); - addToAmpliQueue(() => { - ampli.transferSigned( - generateTransferSigned(frozenQuote, referrer, timeSubmitted, tx!.hash) - ); - }); - - if (onTransactionComplete) { - onTransactionComplete(tx.hash); - } - await waitOnTransaction(frozenPayload.fromChain, tx, notify); - const receipt = await tx.wait(1); - - // Optimistically add deposit to local storage for instant visibility on the - // "My Transactions" page. See `src/hooks/useDeposits.ts` for details. - addLocalPendingDeposit({ - depositId: parseFundsDepositedLog(receipt.logs)?.args["depositId"] || 0, - depositTime: DateTime.now().toSeconds(), - status: "pending", - filled: "0", - sourceChainId: frozenPayload.fromChain, - destinationChainId: frozenPayload.toChain, - assetAddr: frozenPayload.tokenAddress, - depositorAddr: utils.getAddress(frozenAccount), - recipientAddr: frozenPayload.toAddress, - message: frozenPayload.message || "0x", - amount: frozenPayload.amount.toString(), - depositTxHash: tx.hash, - fillTxs: [], - speedUps: [], - depositRelayerFeePct: frozenPayload.relayerFeePct.toString(), - initialRelayerFeePct: frozenPayload.relayerFeePct.toString(), - suggestedRelayerFeePct: frozenPayload.relayerFeePct.toString(), - }); + addToAmpliQueue(() => { + ampli.transferSigned( + generateTransferSigned(frozenQuote, referrer, timeSubmitted, tx.hash) + ); + }); - if (onDepositResolved) { - onDepositResolved(true); - } - succeeded = true; - } catch (e) { - console.error(e); - if (onDepositResolved) { - onDepositResolved(false); + const statusPageSearchParams = new URLSearchParams({ + originChainId: String(frozenPayload.fromChain), + destinationChainId: String(frozenPayload.toChain), + bridgeTokenSymbol: tokenSymbol, + }).toString(); + history.push( + `/bridge/${tx.hash}?${statusPageSearchParams}`, + // This state is stored in session storage and therefore persist + // after a refresh of the deposit status page. + { + fromBridgePagePayload: { + sendDepositArgs: frozenPayload, + quote: frozenQuote, + referrer, + account: frozenAccount, + timeSigned: Date.now(), + tokenPrice, + }, } - } - if (timeSigned && tx) { - addToAmpliQueue(() => { - ampli.transferDepositCompleted( - generateDepositConfirmed( - frozenQuote, - referrer, - timeSigned!, - tx!.hash, - succeeded, - tx!.timestamp! - ) - ); - }); - } - // Call recordTransferUserProperties to update the user's properties in Amplitude. - recordTransferUserProperties( - frozenPayload.amount, - frozenTokenPrice, - getToken(frozenQuote.tokenSymbol).decimals, - frozenQuote.tokenSymbol.toLowerCase(), - Number(frozenQuote.fromChainId), - Number(frozenQuote.toChainId), - frozenQuote.fromChainName ); }); diff --git a/src/views/Bridge/hooks/useBridgeDepositTracking.ts b/src/views/Bridge/hooks/useBridgeDepositTracking.ts deleted file mode 100644 index ea04da289..000000000 --- a/src/views/Bridge/hooks/useBridgeDepositTracking.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { useConnection } from "hooks"; -import { useCallback, useEffect, useState } from "react"; -import { formatSeconds, getChainInfo } from "utils"; - -export function useBridgeDepositTracking() { - const [txHash, setTxHash] = useState(undefined); - const [startDate, setStartDate] = useState(undefined); - const [depositFinishedDate, setDepositFinishedDate] = useState< - Date | undefined - >(undefined); - const [internalChainId, setInternalChainId] = useState(); - const { chainId } = useConnection(); - - const explorerUrl = - txHash && internalChainId - ? getChainInfo(internalChainId).constructExplorerLink(txHash) - : undefined; - - useEffect(() => { - setStartDate(txHash ? new Date() : undefined); - setInternalChainId(txHash ? chainId : undefined); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [txHash]); - - const handleTransactionCompleted = (success: boolean) => { - setDepositFinishedDate(success ? new Date() : undefined); - }; - - const [elapsedSeconds, setElapsedSeconds] = useState(); - - useEffect(() => { - if (startDate) { - const interval = setInterval(() => { - setElapsedSeconds( - ((depositFinishedDate ?? new Date()).getTime() - - startDate.getTime()) / - 1000 - ); - }, 100); - return () => clearInterval(interval); - } else { - setElapsedSeconds(undefined); - } - }, [startDate, depositFinishedDate]); - - const trackingTxHash = !!txHash; - const elapsedTimeAsFormattedString = formatSeconds( - Math.floor(elapsedSeconds ?? 0) - ); - - const depositFinished = !!depositFinishedDate; - - const handleTxHashChange = useCallback((txHash?: string) => { - setTxHash(txHash); - setDepositFinishedDate(undefined); - }, []); - - return { - txHash, - handleTxHashChange, - explorerUrl, - trackingTxHash, - transactionPending: !depositFinished, - transactionElapsedSeconds: elapsedSeconds, - transactionElapsedTimeAsFormattedString: elapsedTimeAsFormattedString, - handleTransactionCompleted, - }; -} diff --git a/src/views/Bridge/hooks/useTransferQuote.ts b/src/views/Bridge/hooks/useTransferQuote.ts index 187543b18..608fcbf76 100644 --- a/src/views/Bridge/hooks/useTransferQuote.ts +++ b/src/views/Bridge/hooks/useTransferQuote.ts @@ -39,7 +39,6 @@ export function useTransferQuote( return useQuery({ queryKey: [ "quote", - feesQuery.fees?.quoteBlock.toString(), selectedRoute.fromChain, selectedRoute.toChain, selectedRoute.fromTokenSymbol, diff --git a/src/views/DepositStatus/DepositStatus.tsx b/src/views/DepositStatus/DepositStatus.tsx new file mode 100644 index 000000000..f205eafec --- /dev/null +++ b/src/views/DepositStatus/DepositStatus.tsx @@ -0,0 +1,106 @@ +import { useMemo } from "react"; +import { useParams, useLocation } from "react-router-dom"; +import styled from "@emotion/styled"; +import { keyframes } from "@emotion/react"; + +import { QUERIESV2 } from "utils"; +import { isValidTxHash } from "utils/transactions"; +import { LayoutV2 } from "components"; +import NotFound from "views/NotFound"; + +import { Breadcrumb } from "./components/Breadcrumb"; +import { DepositStatusUpperCard } from "./components/DepositStatusUpperCard"; +import { DepositStatusLowerCard } from "./components/DepositStatusLowerCard"; +import { FromBridgePagePayload } from "./types"; + +export default function DepositStatus() { + const { depositTxHash } = useParams< + Partial<{ + depositTxHash: string; + }> + >(); + const { search, state = {} } = useLocation<{ + fromBridgePagePayload?: FromBridgePagePayload; + }>(); + const queryParams = useMemo(() => new URLSearchParams(search), [search]); + + const originChainId = queryParams.get("originChainId"); + const destinationChainId = queryParams.get("destinationChainId"); + const bridgeTokenSymbol = queryParams.get("bridgeTokenSymbol"); + + if ( + !depositTxHash || + !originChainId || + !destinationChainId || + !bridgeTokenSymbol + ) { + return ; + } + + if (!isValidTxHash(depositTxHash)) { + return ; + } + + return ( + + + + + + + + + + ); +} + +const OuterWrapper = styled.div` + background-color: transparent; + + width: 100%; + + margin: 48px auto 20px; + display: flex; + flex-direction: column; + gap: 24px; + + @media ${QUERIESV2.sm.andDown} { + margin: 16px auto; + gap: 16px; + } +`; + +const AnimationFadeInBottom = keyframes` + from { + opacity: 0; + transform: translateY(20%); + } + to { opacity: 1 } +`; + +const InnerWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + padding: 0px 24px 24px; + gap: 24px; + + border: 1px solid #3e4047; + border-radius: 16px; + + overflow: clip; + background: #34353b; + + animation-name: ${AnimationFadeInBottom}; + animation-duration: 1s; +`; diff --git a/src/views/DepositStatus/components/Breadcrumb.tsx b/src/views/DepositStatus/components/Breadcrumb.tsx new file mode 100644 index 000000000..fff83d47e --- /dev/null +++ b/src/views/DepositStatus/components/Breadcrumb.tsx @@ -0,0 +1,29 @@ +import styled from "@emotion/styled"; + +import BreadcrumbV2 from "components/BreadcrumbV2"; +import { Text } from "components/Text"; +import { shortenString } from "utils"; + +type Props = { + depositTxHash: string; +}; + +export function Breadcrumb({ depositTxHash }: Props) { + return ( + + {shortenString(depositTxHash, "..", 4)} + + } + /> + ); +} + +const Wrapper = styled.div` + display: flex; + flex-direction: row; + align-items: center; + padding: 0px; + gap: 12px; +`; diff --git a/src/views/DepositStatus/components/DepositStatusLowerCard.tsx b/src/views/DepositStatus/components/DepositStatusLowerCard.tsx new file mode 100644 index 000000000..7b6e8889d --- /dev/null +++ b/src/views/DepositStatus/components/DepositStatusLowerCard.tsx @@ -0,0 +1,79 @@ +import styled from "@emotion/styled"; +import { utils } from "ethers"; +import { useHistory } from "react-router-dom"; + +import { SecondaryButton } from "components/Button"; +import EstimatedTable from "views/Bridge/components/EstimatedTable"; +import { getReceiveTokenSymbol } from "views/Bridge/utils"; +import { getToken, COLORS } from "utils"; +import { useIsContractAddress } from "hooks/useIsContractAddress"; + +import { EarnByLpAndStakingCard } from "./EarnByLpAndStakingCard"; +import { FromBridgePagePayload } from "../types"; + +type Props = { + fromChainId: number; + toChainId: number; + bridgeTokenSymbol: string; + fromBridgePagePayload?: FromBridgePagePayload; +}; + +export function DepositStatusLowerCard({ + fromChainId, + toChainId, + bridgeTokenSymbol, + fromBridgePagePayload, +}: Props) { + const { quote } = fromBridgePagePayload || {}; + + const isReceiverContract = useIsContractAddress(quote?.recipient); + const history = useHistory(); + + const tokenInfo = getToken(bridgeTokenSymbol); + + const FeesTable = quote ? ( + + ) : null; + + return ( + <> + + {fromBridgePagePayload && ( + <> + + {FeesTable} + + )} + + + + ); +} + +const Divider = styled.div` + width: 100%; + height: 1px; + background: ${COLORS["grey-600"]}; +`; + +const Button = styled(SecondaryButton)` + width: 100%; +`; diff --git a/src/views/DepositStatus/components/DepositStatusUpperCard.tsx b/src/views/DepositStatus/components/DepositStatusUpperCard.tsx new file mode 100644 index 000000000..80d28a89c --- /dev/null +++ b/src/views/DepositStatus/components/DepositStatusUpperCard.tsx @@ -0,0 +1,374 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { keyframes } from "@emotion/react"; + +import BgBanner from "assets/bg-banners/deposit-banner.svg"; +import { ReactComponent as CheckStarDepositingIcon } from "assets/check-star-ring-opaque-depositing.svg"; +import { ReactComponent as CheckStarFillingIcon } from "assets/check-star-ring-opaque-filling.svg"; +import { ReactComponent as CheckStarFilledIcon } from "assets/check-star-ring-opaque-filled.svg"; +import { ReactComponent as EthereumGrayscaleLogo } from "assets/grayscale-logos/eth.svg"; +import { ReactComponent as PolygonGrayscaleLogo } from "assets/grayscale-logos/polygon.svg"; +import { ReactComponent as ArbitrumGrayscaleLogo } from "assets/grayscale-logos/arbitrum.svg"; +import { ReactComponent as OptimismGrayscaleLogo } from "assets/grayscale-logos/optimism.svg"; +import { ReactComponent as ZkSyncGrayscaleLogo } from "assets/grayscale-logos/zksync.svg"; +import { ReactComponent as BaseGrayscaleLogo } from "assets/grayscale-logos/base.svg"; +import { Text, Badge } from "components"; + +import { ChainId, QUERIESV2, COLORS, NoFundsDepositedLogError } from "utils"; + +import { useElapsedSeconds } from "../hooks/useElapsedSeconds"; +import { useDepositTracking } from "../hooks/useDepositTracking"; +import { DepositTimesCard } from "./DepositTimesCard"; +import { ElapsedTime } from "./ElapsedTime"; +import { DepositStatus, FromBridgePagePayload } from "../types"; + +const grayscaleLogos: Record = { + [ChainId.ARBITRUM]: , + [ChainId.POLYGON]: , + [ChainId.OPTIMISM]: , + [ChainId.MAINNET]: , + [ChainId.ZK_SYNC]: , + [ChainId.BASE]: , + // testnets + [ChainId.GOERLI]: , + [ChainId.ARBITRUM_GOERLI]: , + [ChainId.MUMBAI]: , + [ChainId.ZK_SYNC_GOERLI]: , + [ChainId.BASE_GOERLI]: , +}; + +type Props = { + depositTxHash: string; + fromChainId: number; + toChainId: number; + fromBridgePagePayload?: FromBridgePagePayload; +}; + +export function DepositStatusUpperCard({ + depositTxHash, + fromChainId, + toChainId, + fromBridgePagePayload, +}: Props) { + const { depositQuery, fillQuery } = useDepositTracking( + depositTxHash, + fromChainId, + toChainId, + fromBridgePagePayload + ); + + const depositTxSentTime = fromBridgePagePayload?.timeSigned; + const depositTxCompletedTime = depositQuery.data?.depositTimestamp; + const fillTxCompletedTime = fillQuery.data?.fillTxTimestamp; + + const { elapsedSeconds: depositTxElapsedSeconds } = useElapsedSeconds( + depositTxSentTime ? Math.floor(depositTxSentTime / 1000) : undefined, + depositTxCompletedTime + ); + const { elapsedSeconds: fillTxElapsedSeconds } = useElapsedSeconds( + depositTxCompletedTime, + fillTxCompletedTime + ); + + const status = !depositTxCompletedTime + ? "depositing" + : !fillTxCompletedTime + ? "filling" + : "filled"; + + // This error indicates that the used deposit tx hash does not originate from + // an Across SpokePool contract. + if (depositQuery.error instanceof NoFundsDepositedLogError) { + return ( + + + + Invalid deposit tx hash + + + + ); + } + + return ( + + + + + {grayscaleLogos[fromChainId]} + + + + {status === "depositing" ? ( + + ) : status === "filling" ? ( + + ) : ( + + )} + + + + {grayscaleLogos[toChainId]} + + + + {status !== "filled" ? ( + + + + + {status === "depositing" + ? "Depositing on source chain..." + : "Filling on destination chain..."} + + + + {status === "depositing" ? "1 / 2" : "2 / 2"} + + + + + ) : ( + + + + Transfer successful! + + + )} + + + ); +} + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + gap: 30px; + + background-image: url(${BgBanner}); + background-color: ${COLORS["black-800"]}; + border-bottom: 1px solid ${COLORS["grey-600"]}; + + width: calc(100% + 48px); + margin: 0 -24px; + padding: 45px 24px 34px; +`; + +const TopWrapperTitleWrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 0px; + gap: 8px; + + width: 100%; +`; + +const SubTitleWrapper = styled.div` + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 8px; +`; + +const AnimationFadeInBottom = keyframes` + from { + opacity: 0; + transform: translateY(20%); + } + to { opacity: 1 } +`; + +const AnimatedTopWrapperTitleWrapper = styled(TopWrapperTitleWrapper)` + animation-name: ${AnimationFadeInBottom}; + animation-duration: 1s; +`; + +const StyledCheckStarDepositingIcon = styled(CheckStarDepositingIcon)` + & * { + transition: stroke 0.5s ease-in-out; + } + flex-shrink: 0; + + @media ${QUERIESV2.sm.andDown} { + width: 52px; + height: 52px; + } +`; +const StyledCheckStarFillingIcon = styled(CheckStarFillingIcon)` + & * { + transition: stroke 0.5s ease-in-out; + } + flex-shrink: 0; + + @media ${QUERIESV2.sm.andDown} { + width: 52px; + height: 52px; + } +`; +const StyledCheckStarFilledIcon = styled(CheckStarFilledIcon)` + & * { + transition: stroke 0.5s ease-in-out; + } + flex-shrink: 0; + + @media ${QUERIESV2.sm.andDown} { + width: 52px; + height: 52px; + } +`; + +const TopWrapperAnimationWrapper = styled.div` + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding: 0px; + gap: 8px; +`; + +const AnimatedDivider = styled.div<{ status: DepositStatus }>` + width: ${({ status }) => (status === "filled" ? "22px" : "50px")}; + height: 1px; + flex-shrink: 0; + + transition: width 0.5s ease-in-out, background-color 0.5s ease-in-out; +`; + +const AnimatedDividerFromChain = styled(AnimatedDivider)<{ + status: DepositStatus; +}>` + background: ${({ status }) => + status === "depositing" ? COLORS.white : COLORS.aqua}; +`; + +const AnimatedDividerToChain = styled(AnimatedDivider)<{ + status: DepositStatus; +}>` + background: ${({ status }) => + status === "depositing" + ? COLORS["grey-400"] + : status === "filling" + ? COLORS.white + : COLORS.aqua}; +`; + +const AnimatedLogoWrapper = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 8px; + + width: 60px; + height: 60px; + + background: ${COLORS["black-800"]}; + + border: 1px solid; + transition: border-color 0.5s ease-in-out; + + border-radius: 100px; + + flex-shrink: 0; + + @media ${QUERIESV2.sm.andDown} { + width: 40px; + height: 40px; + } +`; + +const AnimatedLogoWrapperFromChain = styled(AnimatedLogoWrapper)<{ + status: DepositStatus; +}>` + border-color: ${({ status }) => + status === "depositing" ? COLORS.white : COLORS.aqua}; +`; + +const AnimatedLogoWrapperToChain = styled(AnimatedLogoWrapper)<{ + status: DepositStatus; +}>` + border-color: ${({ status }) => + status === "depositing" + ? COLORS["grey-400"] + : status === "filling" + ? COLORS.white + : COLORS.aqua}; +`; + +const AnimatedLogo = styled.div<{ + completed?: boolean; +}>` + width: 48px; + height: 48px; + & svg { + width: 48px; + height: 48px; + border-radius: 100%; + & rect, + circle, + #path-to-animate { + transition: fill 1s ease-in-out; + } + } + + @media ${QUERIESV2.sm.andDown} { + width: 32px; + height: 32px; + + & svg { + width: 32px; + height: 32px; + } + } +`; + +const AnimatedLogoFromChain = styled(AnimatedLogo)<{ status: DepositStatus }>` + & svg { + & rect, + circle, + #path-to-animate { + fill: ${({ status }) => + status === "depositing" ? "COLORS.white" : COLORS.aqua}; + } + } +`; + +const AnimatedLogoToChain = styled(AnimatedLogo)<{ status: DepositStatus }>` + & svg { + & rect, + circle, + #path-to-animate { + fill: ${({ status }) => + status === "depositing" + ? COLORS["grey-400"] + : status === "filling" + ? COLORS.white + : COLORS.aqua}; + } + } +`; diff --git a/src/views/DepositStatus/components/DepositTimesCard.tsx b/src/views/DepositStatus/components/DepositTimesCard.tsx new file mode 100644 index 000000000..d687760aa --- /dev/null +++ b/src/views/DepositStatus/components/DepositTimesCard.tsx @@ -0,0 +1,211 @@ +import styled from "@emotion/styled"; +import { keyframes } from "@emotion/react"; +import { DateTime } from "luxon"; + +import { ReactComponent as CheckIcon } from "assets/check.svg"; +import { ReactComponent as LoadingIcon } from "assets/loading.svg"; +import { ReactComponent as ExternalLinkIcon } from "assets/icons/external-link-16.svg"; + +import { Text, CardWrapper } from "components"; +import { getChainInfo, COLORS } from "utils"; +import { useAmplitude } from "hooks"; +import { ampli } from "ampli"; + +import { ElapsedTime } from "./ElapsedTime"; +import { DepositStatus } from "../types"; + +type Props = { + status: DepositStatus; + depositTxCompletedTimestampSeconds?: number; + depositTxElapsedSeconds?: number; + fillTxElapsedSeconds?: number; + fillTxHash?: string; + depositTxHash?: string; + fromChainId: number; + toChainId: number; +}; + +export function DepositTimesCard({ + status, + depositTxCompletedTimestampSeconds, + depositTxElapsedSeconds, + fillTxElapsedSeconds, + fillTxHash, + depositTxHash, + fromChainId, + toChainId, +}: Props) { + const isDepositing = status === "depositing"; + const isFilled = status === "filled"; + + const { addToAmpliQueue } = useAmplitude(); + + return ( + + + Deposit time + {isDepositing ? ( + } + /> + ) : depositTxElapsedSeconds !== undefined ? ( + + } + /> + ) : ( + + + {depositTxCompletedTimestampSeconds + ? DateTime.fromSeconds( + depositTxCompletedTimestampSeconds + ).toFormat("d MMM yyyy - t") + : "-"} + + + + )} + + + Fill time + {isDepositing ? ( + - + ) : ( + + ) : ( + + ) + } + /> + )} + + + + Total time + {!isFilled ? ( + - + ) : ( + } + /> + )} + + { + addToAmpliQueue(() => { + ampli.monitorDepositProgressClicked({ + action: "onClick", + element: "monitorDepositProgressLink", + page: "bridgePage", + section: "depositConfirmation", + }); + }); + }} + > + + View on{" "} + + Transactions page + + + + + + ); +} + +function CheckIconExplorerLink({ + txHash, + chainId, +}: { + txHash?: string; + chainId: number; +}) { + const chainInfo = getChainInfo(chainId); + + if (!txHash) { + return ; + } + + return ( + + + + ); +} + +const Row = styled.div` + display: flex; + width: 100%; + flex-direction: row; + align-items: center; + justify-content: space-between; +`; + +const Divider = styled.div` + width: 100%; + height: 1px; + background: ${COLORS["grey-600"]}; +`; + +const DateWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +`; + +const TransactionsPageLinkWrapper = styled.a` + display: flex; + flex-direction: row; + width: 100%; + align-items: center; + justify-content: space-between; + border: 1px solid ${COLORS["grey-500"]}; + border-radius: 8px; + padding: 16px; + text-decoration: none; +`; + +const RotationKeyframes = keyframes` + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +`; + +const StyledLoadingIcon = styled(LoadingIcon)` + animation: ${RotationKeyframes} 2.5s linear infinite; +`; diff --git a/src/views/DepositStatus/components/EarnActionCard.tsx b/src/views/DepositStatus/components/EarnActionCard.tsx new file mode 100644 index 000000000..3bc634ff5 --- /dev/null +++ b/src/views/DepositStatus/components/EarnActionCard.tsx @@ -0,0 +1,63 @@ +import styled from "@emotion/styled"; +import { ReactNode } from "react"; + +import TealBannerUrl from "assets/bg-banners/action-card-teal-banner.svg"; + +import { Text } from "components/Text"; +import { COLORS } from "utils"; + +type Props = { + title: ReactNode; + subTitle: string; + LeftIcon: ReactNode; + ActionButton: ReactNode; +}; + +export function EarnActionCard({ + title, + subTitle, + LeftIcon, + ActionButton, +}: Props) { + return ( + + + {LeftIcon} + + {title} + {subTitle} + + {ActionButton} + + + ); +} + +const Wrapper = styled.div` + border-radius: 0.5rem; + + background-image: linear-gradient( + 90deg, + rgba(40, 160, 240, 0.05) 0%, + rgba(40, 160, 240, 0) 100% + ), + url(${TealBannerUrl}); + background-size: cover; +`; + +const InnerWrapper = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + gap: 12px; + padding: 1.5rem 1rem; + + border: 1px solid ${COLORS["teal-15"]}; + border-radius: 0.5rem; +`; + +const TextWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 4px; +`; diff --git a/src/views/DepositStatus/components/EarnByLpAndStakingCard.tsx b/src/views/DepositStatus/components/EarnByLpAndStakingCard.tsx new file mode 100644 index 000000000..9c31f33c7 --- /dev/null +++ b/src/views/DepositStatus/components/EarnByLpAndStakingCard.tsx @@ -0,0 +1,91 @@ +import styled from "@emotion/styled"; +import { useHistory } from "react-router-dom"; + +import { ReactComponent as ArrowStarRingIcon } from "assets/arrow-star-ring.svg"; +import { Text } from "components/Text"; +import { SecondaryButton } from "components/Button"; +import { useStakingPool } from "hooks/useStakingPool"; +import { useAmplitude } from "hooks"; +import { formatWeiPct } from "utils"; +import { ampli } from "ampli"; + +import { EarnActionCard } from "./EarnActionCard"; + +type Props = { + l1TokenAddress: string; + bridgeTokenSymbol: string; +}; + +export function EarnByLpAndStakingCard({ + l1TokenAddress, + bridgeTokenSymbol, +}: Props) { + const { data: selectedRoutePool } = useStakingPool(l1TokenAddress); + const { addToAmpliQueue } = useAmplitude(); + const history = useHistory(); + + return ( + + Earn{" "} + + {selectedRoutePool + ? formatWeiPct(selectedRoutePool.apyData.maxApy, 3) + : "-"} + % + {" "} + by adding liquidity and staking + + } + subTitle="Add liquidity to any pool on Across and then stake that liquidity to earn a yield" + LeftIcon={ + + + + } + ActionButton={ + + + + } + /> + ); +} + +const LogoWrapper = styled.div` + margin-left: -8px; + margin-right: -8px; +`; + +const ButtonWrapper = styled.div` + display: flex; + align-items: center; +`; + +const Button = styled(SecondaryButton)` + white-space: nowrap; +`; diff --git a/src/views/DepositStatus/components/ElapsedTime.tsx b/src/views/DepositStatus/components/ElapsedTime.tsx new file mode 100644 index 000000000..6503bd6b4 --- /dev/null +++ b/src/views/DepositStatus/components/ElapsedTime.tsx @@ -0,0 +1,44 @@ +import styled from "@emotion/styled"; + +import { Text, TextSize } from "components/Text"; + +import { formatSeconds } from "utils"; + +type Props = { + elapsedSeconds?: number; + maxSeconds?: number; + MaxSecondsFallback?: React.ReactNode; + textColor?: string; + StatusIcon?: React.ReactNode; + isCompleted?: boolean; + textSize?: TextSize; +}; + +export function ElapsedTime({ + elapsedSeconds = 0, + maxSeconds = Infinity, + MaxSecondsFallback = <>, + StatusIcon, + isCompleted, + textSize, +}: Props) { + if (elapsedSeconds >= maxSeconds && !isCompleted) { + return <>{MaxSecondsFallback}; + } + + return ( + + + {formatSeconds(elapsedSeconds) ?? "00h 00m 00s"} + + {StatusIcon && StatusIcon} + + ); +} + +const Wrapper = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +`; diff --git a/src/views/DepositStatus/hooks/useDepositTracking.ts b/src/views/DepositStatus/hooks/useDepositTracking.ts new file mode 100644 index 000000000..869b5e2f1 --- /dev/null +++ b/src/views/DepositStatus/hooks/useDepositTracking.ts @@ -0,0 +1,148 @@ +import { useQuery } from "react-query"; +import { useState } from "react"; + +import { useAmplitude } from "hooks"; +import { + generateDepositConfirmed, + getToken, + recordTransferUserProperties, + wait, + getDepositByTxHash, + getFillByDepositTxHash, + NoFundsDepositedLogError, +} from "utils"; +import { + getLocalDepositByTxHash, + addLocalDeposit, + removeLocalDeposits, +} from "utils/local-deposits"; +import { ampli } from "ampli"; + +import { convertForDepositQuery, convertForFillQuery } from "../utils"; +import { FromBridgePagePayload } from "../types"; + +export function useDepositTracking( + depositTxHash: string, + fromChainId: number, + toChainId: number, + fromBridgePagePayload?: FromBridgePagePayload +) { + const [shouldRetryDepositQuery, setShouldRetryDepositQuery] = useState(false); + + const { addToAmpliQueue } = useAmplitude(); + + const depositQuery = useQuery( + ["deposit", depositTxHash, fromChainId], + async () => { + // On some L2s the tx is mined too fast for the animation to show, so we add a delay + await wait(1_000); + + try { + const deposit = await getDepositByTxHash(depositTxHash, fromChainId); + return deposit; + } catch (e) { + // If the error NoFundsDepositedLogError is thrown, this implies that the used + // tx hash is valid and mined but the origin is not a SpokePool contract. So we + // should not retry the query and throw the error. + if (e instanceof NoFundsDepositedLogError) { + setShouldRetryDepositQuery(false); + } + throw e; + } + }, + { + staleTime: Infinity, + retry: shouldRetryDepositQuery, + onSuccess: (data) => { + if (!fromBridgePagePayload) { + return; + } + + const localDepositByTxHash = getLocalDepositByTxHash(depositTxHash); + if (!localDepositByTxHash) { + // Optimistically add deposit to local storage for instant visibility on the + // "My Transactions" page. See `src/hooks/useDeposits.ts` for details. + addLocalDeposit(convertForDepositQuery(data, fromBridgePagePayload)); + } + + addToAmpliQueue(() => { + ampli.transferDepositCompleted( + generateDepositConfirmed( + fromBridgePagePayload.quote, + fromBridgePagePayload.referrer, + fromBridgePagePayload.timeSigned, + data.depositTxReceipt.transactionHash, + true, + data.depositTimestamp + ) + ); + }); + }, + } + ); + + const fillQuery = useQuery( + ["fill-by-deposit-tx-hash", depositTxHash, fromChainId, toChainId], + async () => { + if (!depositQuery.data) { + throw new Error( + `Could not fetch deposit by tx hash ${depositTxHash} on chain ${fromChainId}` + ); + } + + return getFillByDepositTxHash( + depositTxHash, + fromChainId, + toChainId, + depositQuery.data + ); + }, + { + staleTime: Infinity, + retry: true, + enabled: !!depositQuery.data, + onSuccess: (data) => { + if (!fromBridgePagePayload) { + return; + } + + const localDepositByTxHash = getLocalDepositByTxHash(depositTxHash); + if (localDepositByTxHash) { + removeLocalDeposits([depositTxHash]); + } + + // Optimistically add deposit to local storage for instant visibility on the + // "My Transactions" page. See `src/hooks/useDeposits.ts` for details. + addLocalDeposit(convertForFillQuery(data, fromBridgePagePayload)); + + const { referrer, quote, timeSigned, sendDepositArgs, tokenPrice } = + fromBridgePagePayload; + + addToAmpliQueue(() => { + ampli.transferDepositCompleted( + generateDepositConfirmed( + quote, + referrer, + timeSigned, + data.fillTxHashes[0], + true, + data.fillTxTimestamp + ) + ); + }); + + recordTransferUserProperties( + sendDepositArgs.amount, + tokenPrice, + getToken(quote.tokenSymbol).decimals, + quote.tokenSymbol.toLowerCase(), + Number(quote.fromChainId), + Number(quote.toChainId), + quote.fromChainName + ); + }, + } + ); + + return { depositQuery, fillQuery }; +} diff --git a/src/views/DepositStatus/hooks/useElapsedSeconds.ts b/src/views/DepositStatus/hooks/useElapsedSeconds.ts new file mode 100644 index 000000000..1634047cb --- /dev/null +++ b/src/views/DepositStatus/hooks/useElapsedSeconds.ts @@ -0,0 +1,37 @@ +import { useState, useEffect } from "react"; + +import { formatSeconds } from "utils"; + +export function useElapsedSeconds( + startDateTimestampInSeconds?: number, + endDateTimestampInSeconds?: number +) { + const [elapsedSeconds, setElapsedSeconds] = useState(); + + useEffect(() => { + let interval: NodeJS.Timeout; + if (startDateTimestampInSeconds && endDateTimestampInSeconds) { + setElapsedSeconds( + Math.max(endDateTimestampInSeconds - startDateTimestampInSeconds, 0) + ); + } else if (startDateTimestampInSeconds) { + interval = setInterval(() => { + setElapsedSeconds( + Math.max(new Date().getTime() / 1000 - startDateTimestampInSeconds, 0) + ); + }, 1000); + } else { + setElapsedSeconds(undefined); + } + return () => clearInterval(interval); + }, [startDateTimestampInSeconds, endDateTimestampInSeconds]); + + const elapsedTimeAsFormattedString = formatSeconds( + Math.floor(elapsedSeconds || 0) + ); + + return { + elapsedSeconds, + elapsedTimeAsFormattedString, + }; +} diff --git a/src/views/DepositStatus/index.tsx b/src/views/DepositStatus/index.tsx new file mode 100644 index 000000000..075365b0c --- /dev/null +++ b/src/views/DepositStatus/index.tsx @@ -0,0 +1 @@ +export { default } from "./DepositStatus"; diff --git a/src/views/DepositStatus/types.ts b/src/views/DepositStatus/types.ts new file mode 100644 index 000000000..2d39d6ca3 --- /dev/null +++ b/src/views/DepositStatus/types.ts @@ -0,0 +1,15 @@ +import { BigNumber } from "ethers"; + +import { TransferQuoteReceivedProperties } from "ampli"; +import { AcrossDepositArgs } from "utils"; + +export type DepositStatus = "depositing" | "filling" | "filled"; + +export type FromBridgePagePayload = { + sendDepositArgs: AcrossDepositArgs; + quote: TransferQuoteReceivedProperties; + referrer: string; + account: string; + timeSigned: number; + tokenPrice: BigNumber; +}; diff --git a/src/views/DepositStatus/utils.ts b/src/views/DepositStatus/utils.ts new file mode 100644 index 000000000..2b1d8b429 --- /dev/null +++ b/src/views/DepositStatus/utils.ts @@ -0,0 +1,92 @@ +import { utils, BigNumber } from "ethers"; + +import { getDepositByTxHash, getFillByDepositTxHash } from "utils"; + +import { FromBridgePagePayload } from "./types"; + +export function convertForDepositQuery( + data: Awaited>, + fromBridgePagePayload: FromBridgePagePayload +) { + const { sendDepositArgs, quote } = fromBridgePagePayload; + + return { + depositId: Number(data.parsedDepositLog.args.depositId), + depositTime: data.depositTimestamp || Math.floor(Date.now() / 1000), + status: "pending" as const, + filled: "0", + sourceChainId: sendDepositArgs.fromChain, + destinationChainId: sendDepositArgs.toChain, + assetAddr: sendDepositArgs.tokenAddress, + depositorAddr: utils.getAddress(fromBridgePagePayload.account), + recipientAddr: sendDepositArgs.toAddress, + message: sendDepositArgs.message || "0x", + amount: BigNumber.from(sendDepositArgs.amount).toString(), + depositTxHash: data.depositTxReceipt.transactionHash, + fillTxs: [], + speedUps: [], + depositRelayerFeePct: BigNumber.from( + sendDepositArgs.relayerFeePct + ).toString(), + initialRelayerFeePct: BigNumber.from( + sendDepositArgs.relayerFeePct + ).toString(), + suggestedRelayerFeePct: BigNumber.from( + sendDepositArgs.relayerFeePct + ).toString(), + feeBreakdown: { + bridgeFee: { + pct: quote.totalBridgeFeePct, + usd: quote.totalBridgeFeeUsd, + }, + destinationGasFee: { + pct: quote.relayGasFeePct, + usd: quote.relayGasFeeTotalUsd, + }, + }, + }; +} + +export function convertForFillQuery( + data: Awaited>, + fromBridgePagePayload: FromBridgePagePayload +) { + const { sendDepositArgs, quote } = fromBridgePagePayload; + + return { + depositId: Number(data.depositByTxHash.parsedDepositLog.args.depositId), + depositTime: + data.depositByTxHash.depositTimestamp || Math.floor(Date.now() / 1000), + status: "filled" as const, + filled: BigNumber.from(sendDepositArgs.amount).toString(), + sourceChainId: sendDepositArgs.fromChain, + destinationChainId: sendDepositArgs.toChain, + assetAddr: sendDepositArgs.tokenAddress, + depositorAddr: utils.getAddress(fromBridgePagePayload.account), + recipientAddr: sendDepositArgs.toAddress, + message: sendDepositArgs.message || "0x", + amount: BigNumber.from(sendDepositArgs.amount).toString(), + depositTxHash: data.depositByTxHash.depositTxReceipt.transactionHash, + fillTxs: data.fillTxHashes || [], + speedUps: [], + depositRelayerFeePct: BigNumber.from( + sendDepositArgs.relayerFeePct + ).toString(), + initialRelayerFeePct: BigNumber.from( + sendDepositArgs.relayerFeePct + ).toString(), + suggestedRelayerFeePct: BigNumber.from( + sendDepositArgs.relayerFeePct + ).toString(), + feeBreakdown: { + bridgeFee: { + pct: quote.totalBridgeFeePct, + usd: quote.totalBridgeFeeUsd, + }, + destinationGasFee: { + pct: quote.relayGasFeePct, + usd: quote.relayGasFeeTotalUsd, + }, + }, + }; +} From 15a2a84f2d4699f6854e4edc003171d2c82f56a0 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Thu, 2 Nov 2023 16:45:51 +0100 Subject: [PATCH 2/4] fix: max button responsiveness (#897) --- .../InputWithMaxButton/InputWithMaxButton.styles.tsx | 1 + src/views/Bridge/components/AmountInput.tsx | 7 +++++++ .../StakingInputBlock/StakingInputBlock.styles.tsx | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/src/components/InputWithMaxButton/InputWithMaxButton.styles.tsx b/src/components/InputWithMaxButton/InputWithMaxButton.styles.tsx index be9375d65..20d92613c 100644 --- a/src/components/InputWithMaxButton/InputWithMaxButton.styles.tsx +++ b/src/components/InputWithMaxButton/InputWithMaxButton.styles.tsx @@ -73,6 +73,7 @@ export const MaxButton = styled(UnstyledButton)` @media ${QUERIESV2.sm.andDown} { height: 24px; padding: 0px 10px; + font-size: 12px; } `; diff --git a/src/views/Bridge/components/AmountInput.tsx b/src/views/Bridge/components/AmountInput.tsx index 100f503e4..9de940514 100644 --- a/src/views/Bridge/components/AmountInput.tsx +++ b/src/views/Bridge/components/AmountInput.tsx @@ -170,6 +170,13 @@ const MaxButtonWrapper = styled(UnstyledButton)` color: #e0f3ff; border-color: #e0f3ff; } + + @media ${QUERIESV2.sm.andDown} { + padding: 0 10px; + height: 24px; + font-size: 12px; + line-height: 14px; + } `; const AmountInnerWrapperTextStack = styled.div` diff --git a/src/views/Staking/components/StakingInputBlock/StakingInputBlock.styles.tsx b/src/views/Staking/components/StakingInputBlock/StakingInputBlock.styles.tsx index 41b1e6061..652478003 100644 --- a/src/views/Staking/components/StakingInputBlock/StakingInputBlock.styles.tsx +++ b/src/views/Staking/components/StakingInputBlock/StakingInputBlock.styles.tsx @@ -96,6 +96,7 @@ export const MaxButton = styled(UnstyledButton)` @media ${QUERIESV2.sm.andDown} { height: 24px; padding: 0px 10px; + font-size: 12px; } `; @@ -108,6 +109,9 @@ export const ButtonWrapper = styled.div` export const StakeButton = styled(PrimaryButton)` text-transform: capitalize; + @media ${QUERIESV2.sm.andDown} { + width: 100%; + } `; export const StakeButtonContentWrapper = styled.div` From 2fc157d3e14190b424ba7b5d491e0ac6b3969aaa Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Thu, 2 Nov 2023 16:46:51 +0100 Subject: [PATCH 3/4] fix: throw speed up mutation if depositor does not match signer (#899) --- src/views/Transactions/hooks/useSpeedUp.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/views/Transactions/hooks/useSpeedUp.tsx b/src/views/Transactions/hooks/useSpeedUp.tsx index 5b4c0b9b8..60f89720d 100644 --- a/src/views/Transactions/hooks/useSpeedUp.tsx +++ b/src/views/Transactions/hooks/useSpeedUp.tsx @@ -11,7 +11,7 @@ import type { Deposit } from "hooks/useDeposits"; const config = getConfig(); export function useSpeedUp(transfer: Deposit, token: Token) { - const { signer, notify } = useConnection(); + const { signer, notify, account } = useConnection(); const { isWrongNetwork, isWrongNetworkHandler } = useIsWrongNetwork( transfer.sourceChainId ); @@ -42,8 +42,12 @@ export function useSpeedUp(transfer: Deposit, token: Token) { newRecipient: string; }>; }) => { - if (!signer) { - return; + if (!signer || !account) { + throw new Error("No wallet connected"); + } + + if (transfer.depositorAddr.toLowerCase() !== account.toLowerCase()) { + throw new Error("Speed up not possible for this deposit"); } if (isWrongNetwork) { From cbc0db09b8222bc4a99384442cd063a437bc5365 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Thu, 2 Nov 2023 16:48:22 +0100 Subject: [PATCH 4/4] fix: quote update after user connected wallet via bridge action button (#898) * fix: make quote query dependent on block * fix: update quote after user connected wallet * fixup --- src/views/Bridge/hooks/useBridge.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/views/Bridge/hooks/useBridge.ts b/src/views/Bridge/hooks/useBridge.ts index ae4cd0a6d..36f99701b 100644 --- a/src/views/Bridge/hooks/useBridge.ts +++ b/src/views/Bridge/hooks/useBridge.ts @@ -117,7 +117,10 @@ export function useBridge() { useEffect(() => { if (shouldUpdateQuote && bridgeAction.isButtonActionLoading) { setShouldUpdateQuote(false); - } else if (bridgeAction.didActionError && !shouldUpdateQuote) { + } else if ( + (bridgeAction.didActionError || !bridgeAction.isButtonActionLoading) && + !shouldUpdateQuote + ) { setShouldUpdateQuote(true); } }, [