Feat(3102): add menu and history page (#3852)

* fix: refactor state management

* fix: update pnpm lock file and use fixed version for zustand

* feat: add sidebar menu and mobile menu + transaction history page

* feat: add side bar menu and history page

* fix: remove unused code + update TransactionClaimButton component

* fix: update dockerfile to remove warning during build
This commit is contained in:
Victorien Gauch
2024-09-10 13:08:37 +02:00
committed by GitHub
parent daba463d77
commit 7566fab773
70 changed files with 2074 additions and 1205 deletions

View File

@@ -33,7 +33,7 @@ ARG X_TAG
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED 1
ENV NEXT_TELEMETRY_DISABLED=1
USER node

View File

@@ -6,4 +6,4 @@ sgr0 := $(shell tput sgr0)
.PHONY: dev
dev:
npm run dev
pnpm run dev

View File

@@ -35,6 +35,25 @@ const nextConfig = {
fs: false,
};
config.externals.push("pino-pretty", "lokijs", "encoding");
const fileLoaderRule = config.module.rules.find((rule) => rule.test?.test?.(".svg"));
config.module.rules.push(
{
...fileLoaderRule,
test: /\.svg$/i,
resourceQuery: /url/,
},
{
test: /\.svg$/i,
issuer: fileLoaderRule.issuer,
resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] },
use: ["@svgr/webpack"],
},
);
fileLoaderRule.exclude = /\.svg$/i;
return config;
},
};

View File

@@ -30,7 +30,6 @@
"compare-versions": "6.1.1",
"date-fns": "3.6.0",
"framer-motion": "11.3.17",
"gray-matter": "4.0.3",
"joi": "17.13.3",
"loglevel": "1.9.1",
"next": "14.2.5",
@@ -50,6 +49,7 @@
},
"devDependencies": {
"@playwright/test": "1.45.3",
"@svgr/webpack": "^8.1.0",
"@synthetixio/synpress": "4.0.0-alpha.7",
"@types/fs-extra": "11.0.4",
"@types/react": "18.3.3",

View File

@@ -7,11 +7,11 @@ export default defineConfig({
maxFailures: process.env.CI ? 1 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI
? [['html', { open: 'never', outputFolder: `playwright-report-${process.env.HEADLESS ? 'headless' : 'headful'}` }]]
: 'html',
? [["html", { open: "never", outputFolder: `playwright-report-${process.env.HEADLESS ? "headless" : "headful"}` }]]
: "html",
use: {
baseURL: "http://localhost:3000",
trace: process.env.CI ? 'on' : 'retain-on-failure'
trace: process.env.CI ? "on" : "retain-on-failure",
},
projects: [
{

View File

@@ -3,35 +3,11 @@
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
/* toastify */
--toastify-color-success: #7adffd;
--toastify-color-progress-success: #7adffd;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
/*
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
*/
body {
background: #121212;
}
@@ -45,15 +21,4 @@ body {
.btn-custom {
@apply min-h-[2.5rem] h-[2.5rem] px-6;
}
.shortcut-card {
@apply rounded-sm relative transition-all duration-300 ease-in-out px-4 py-5 flex flex-col;
@apply hover:after:bg-primary hover:border-primary hover:-translate-y-0.5 bg-cardBg border border-card;
&::after {
@apply absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 w-2.5 h-2.5 bg-card rounded-full z-10 transition-all duration-300 ease-in-out;
content: "";
}
}
}

View File

@@ -1,14 +1,10 @@
import BridgeLayout from "@/components/bridge/BridgeLayout";
import { Shortcut } from "@/models/shortcut";
import matter from "gray-matter";
async function getShortcuts() {
const { data } = matter.read("src/data/shortcuts.md");
return data as Shortcut[];
}
export default async function Home() {
const shortcuts = await getShortcuts();
return <BridgeLayout shortcuts={shortcuts} />;
return (
<div className="min-w-min max-w-lg md:m-auto md:mt-32">
<h1 className="mb-6 text-4xl font-bold md:hidden">Bridge</h1>
<BridgeLayout />
</div>
);
}

View File

@@ -0,0 +1,27 @@
"use client";
import ConnectButton from "@/components/ConnectButton";
import { Transactions } from "@/components/transactions";
import { useAccount } from "wagmi";
export default function TransactionsPage() {
const { isConnected } = useAccount();
if (!isConnected) {
return (
<div className="m-auto min-w-min max-w-5xl">
<div className="flex min-h-80 flex-col items-center justify-center gap-8 rounded-lg border-2 border-card bg-cardBg p-4">
<span>Please connect your wallet.</span>
<ConnectButton />
</div>
</div>
);
}
return (
<div className="m-auto min-w-min max-w-5xl">
<h1 className="mb-6 text-4xl md:hidden">Transactions</h1>
<Transactions />
</div>
);
}

View File

@@ -0,0 +1,22 @@
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M18 21C19.6569 21 21 19.6569 21 18C21 16.3431 19.6569 15 18 15C16.3431 15 15 16.3431 15 18C15 19.6569 16.3431 21 18 21Z"
stroke="currentcolor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M6 9C7.65685 9 9 7.65685 9 6C9 4.34315 7.65685 3 6 3C4.34315 3 3 4.34315 3 6C3 7.65685 4.34315 9 6 9Z"
stroke="currentcolor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M13 6H16C16.5304 6 17.0391 6.21071 17.4142 6.58579C17.7893 6.96086 18 7.46957 18 8V15"
stroke="currentcolor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M6 9V21" stroke="currentcolor" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 810 B

View File

@@ -0,0 +1,13 @@
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M14 2H6C5.46957 2 4.96086 2.21071 4.58579 2.58579C4.21071 2.96086 4 3.46957 4 4V20C4 20.5304 4.21071 21.0391 4.58579 21.4142C4.96086 21.7893 5.46957 22 6 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V8L14 2Z"
stroke="currentcolor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M14 2V8H20" stroke="currentcolor" stroke-linecap="round" stroke-linejoin="round" />
<path d="M16 13H8" stroke="currentcolor" stroke-linecap="round" stroke-linejoin="round" />
<path d="M16 17H8" stroke="currentcolor" stroke-linecap="round" stroke-linejoin="round" />
<path d="M10 9H9H8" stroke="currentcolor" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 825 B

View File

@@ -0,0 +1,15 @@
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
stroke="currentcolor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M9.08997 8.99996C9.32507 8.33163 9.78912 7.76807 10.3999 7.40909C11.0107 7.05012 11.7289 6.9189 12.4271 7.03867C13.1254 7.15844 13.7588 7.52148 14.215 8.06349C14.6713 8.60549 14.921 9.29148 14.92 9.99996C14.92 12 11.92 13 11.92 13"
stroke="currentcolor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M12 17H12.01" stroke="currentcolor" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 749 B

View File

@@ -0,0 +1,32 @@
<svg width="100" height="100" viewBox="0 0 40 41" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="1.45947" width="39" height="39" rx="19.5" fill="#1D1D1D" />
<rect x="0.5" y="1.45947" width="39" height="39" rx="19.5" stroke="#505050" />
<path
d="M30.2217 25.6057L26.5046 29.3228L22.7876 25.6057"
stroke="#C0C0C0"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M20.9293 12.5961L22.7878 12.5961C23.7737 12.5961 24.7191 12.9877 25.4162 13.6848C26.1133 14.3818 26.5049 15.3273 26.5049 16.3131L26.5049 29.3228"
stroke="#C0C0C0"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9.77783 16.3131L13.4949 12.5961L17.2119 16.3131"
stroke="#C0C0C0"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M19.0703 29.3228L17.2118 29.3228C16.226 29.3228 15.2805 28.9311 14.5834 28.2341C13.8864 27.537 13.4948 26.5915 13.4948 25.6057L13.4948 12.5961"
stroke="#C0C0C0"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,27 @@
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M17 1L21 5L17 9"
stroke="currentcolor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M3 11V9C3 7.93913 3.42143 6.92172 4.17157 6.17157C4.92172 5.42143 5.93913 5 7 5H21"
stroke="currentcolor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M7 23L3 19L7 15"
stroke="currentcolor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M21 13V15C21 16.0609 20.5786 17.0783 19.8284 17.8284C19.0783 18.5786 18.0609 19 17 19H3"
stroke="currentcolor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 715 B

View File

@@ -0,0 +1,14 @@
import { useWeb3Modal } from "@web3modal/wagmi/react";
export default function ConnectButton() {
const { open } = useWeb3Modal();
return (
<button
id="wallet-connect-btn"
className="btn btn-primary rounded-full text-sm font-semibold uppercase md:text-[0.9375rem]"
onClick={() => open()}
>
Connect Wallet
</button>
);
}

View File

@@ -1,27 +1,7 @@
"use client";
import BridgeUI from "@/components/bridge/BridgeUI";
import Hero from "@/components/hero/Hero";
import { UIContext } from "@/contexts/ui.context";
import { Shortcut } from "@/models/shortcut";
import { createContext, useContext } from "react";
const ShortcutContext = createContext<Shortcut[]>([]);
export function useShortcuts() {
return useContext(ShortcutContext);
}
export default function BridgeLayout({ shortcuts }: { shortcuts: Shortcut[] }) {
const { showBridge } = useContext(UIContext);
if (showBridge) {
return <BridgeUI />;
}
return (
<ShortcutContext.Provider value={shortcuts}>
<Hero />
</ShortcutContext.Provider>
);
export default function BridgeLayout() {
return <BridgeUI />;
}

View File

@@ -4,7 +4,6 @@ import React from "react";
import { useAccount } from "wagmi";
import WrongNetwork from "./WrongNetwork";
import TermsModal from "../terms/TermsModal";
import History from "@/components/history/History";
import Bridge from "@/components/bridge/forms/Bridge";
import { AnimatePresence, motion } from "framer-motion";
import { NetworkType } from "@/config";
@@ -37,7 +36,6 @@ const BridgeUI: React.FC = () => {
<div className="card w-full bg-base-100 shadow-xl md:w-[500px]">
{networkType !== NetworkType.WRONG_NETWORK || !isConnected ? <Bridge /> : <WrongNetwork />}
</div>
{isConnected && <History />}
{/* <Debug /> */}
</div>
</motion.div>

View File

@@ -5,9 +5,10 @@ import { useFormContext } from "react-hook-form";
import Image from "next/image";
import { useAccount } from "wagmi";
import { formatEther, parseUnits } from "viem";
import { useBridge, useMessageService } from "@/hooks";
import { useBridge } from "@/hooks";
import { TokenType } from "@/config";
import { useChainStore } from "@/stores/chainStore";
import useMinimumFee from "@/hooks/useMinimumFee";
const MAX_AMOUNT_CHAR = 24;
const FEES_MARGIN_PERCENT = 20;
@@ -35,7 +36,7 @@ export default function Amount({ tokensModalRef }: Props) {
// Hooks
const { isConnected } = useAccount();
const { estimateGasBridge } = useBridge();
const { minimumFee } = useMessageService();
const { minimumFee } = useMinimumFee();
const compareAmountBalance = useCallback(
(_amount: string) => {

View File

@@ -7,10 +7,11 @@ import { MdInfoOutline } from "react-icons/md";
import classNames from "classnames";
import { formatEther, parseEther, parseUnits } from "viem";
import { useAccount, useBalance } from "wagmi";
import { useApprove, useMessageService, useExecutionFee } from "@/hooks";
import { useApprove, useExecutionFee } from "@/hooks";
import useBridge from "@/hooks/useBridge";
import { NetworkLayer, TokenType } from "@/config";
import { useChainStore } from "@/stores/chainStore";
import useMinimumFee from "@/hooks/useMinimumFee";
export default function Fees() {
const [estimatedGasFee, setEstimatedGasFee] = useState<bigint>();
@@ -41,7 +42,7 @@ export default function Fees() {
const balance = watch("balance", false);
// Hooks
const { minimumFee } = useMessageService();
const { minimumFee } = useMinimumFee();
const { estimateGasBridge } = useBridge();
const { estimateApprove } = useApprove();
const minFees = useExecutionFee({

View File

@@ -4,12 +4,12 @@ import { useMemo, useState } from "react";
import { isAddress, getAddress } from "viem";
import TokenDetails from "./TokenDetails";
import { NetworkType, TokenInfo, TokenType } from "@/config/config";
import fetchTokenInfo from "@/services/fetchTokenInfo";
import useERC20Storage from "@/hooks/useERC20Storage";
import { safeGetAddress } from "@/utils/format";
import { useBridge } from "@/hooks";
import { useChainStore } from "@/stores/chainStore";
import { useTokenStore } from "@/stores/tokenStore";
import { fetchTokenInfo } from "@/services";
interface Props {
tokensModalRef: React.RefObject<HTMLDialogElement>;

View File

@@ -1,38 +0,0 @@
import Link from "next/link";
import PackageJSON from "@/../package.json";
export default function Footer() {
return (
<footer className="wrapper container flex flex-col justify-between gap-3 p-6 md:flex-row md:items-center">
<div className="space-x-2 text-xs uppercase text-white">
<span>@{new Date().getFullYear()}</span>
<Link href={"https://linea.build/"} passHref target={"_blank"}>
LINEA A Consensys Formation
</Link>
<span className="text-transparent">v{PackageJSON.version}</span>
</div>
<div className="grid grid-flow-col gap-5 text-xs uppercase">
<Link
href={"https://docs.linea.build/use-mainnet/bridges-of-linea"}
passHref
target={"_blank"}
className="link-hover link text-white"
>
Tutorial
</Link>
<Link href={"https://docs.linea.build/"} passHref target={"_blank"} className="link-hover link text-white">
Documentation
</Link>
<Link
href={"https://linea.build/terms-of-service"}
passHref
target={"_blank"}
className="link-hover link text-white"
>
Terms of service
</Link>
</div>
</footer>
);
}

View File

@@ -1,55 +0,0 @@
"use client";
import { useContext } from "react";
import Image from "next/image";
import { useAccount } from "wagmi";
import Wallet from "./Wallet";
import Chains from "./Chains";
import { UIContext } from "@/contexts/ui.context";
import { NetworkType } from "@/config";
import { useChainStore } from "@/stores/chainStore";
export default function Header() {
// Hooks
const { isConnected } = useAccount();
// Context
const networkType = useChainStore((state) => state.networkType);
const { toggleShowBridge } = useContext(UIContext);
return (
<header className="container navbar py-4">
<div className="flex-1">
<button
className="btn btn-ghost w-32 -space-y-2 text-xl normal-case text-white md:w-52 md:space-y-0"
onClick={() => toggleShowBridge(false)}
>
<Image
src={"/images/logo/linea.svg"}
alt="Linea"
width={0}
height={0}
style={{ width: "215px", height: "auto" }}
priority
/>
</button>
{networkType === NetworkType.SEPOLIA && (
<div className="badge badge-primary badge-outline ml-10 gap-2">TESTNET</div>
)}
</div>
<div className="flex-none">
<ul className="menu menu-horizontal px-1">
{isConnected && (
<li>
<Chains />
</li>
)}
<li>
<Wallet />
</li>
</ul>
</div>
</header>
);
}

View File

@@ -1,60 +0,0 @@
"use client";
import React, { useContext } from "react";
import { UIContext } from "@/contexts/ui.context";
import ToolTip from "../toolTip/ToolTip";
import Shortcut from "../shortcut/Shortcut";
const Hero: React.FC = () => {
const { toggleShowBridge } = useContext(UIContext);
return (
<>
<div className="relative z-10 flex h-full flex-col items-center justify-center gap-8 pt-24 text-center">
<h1 className="text-4xl leading-tight text-white md:text-[4rem]">
How would you like <br /> to bridge your funds?
</h1>
<div className="flex flex-wrap justify-center gap-3">
<a
href="https://portfolio.metamask.io/bridge?destChain=59144"
target="_blank"
className="btn-custom btn btn-primary rounded-full text-sm font-medium uppercase md:text-[0.9375rem]"
rel="noopener"
>
Metamask bridge
</a>
<a
href="https://linea.build/apps?types=bridge"
target="_blank"
rel="noreferrer"
className="btn-custom btn btn-outline btn-primary rounded-full border-primary text-sm font-medium uppercase !text-white hover:!text-black md:text-[0.9375rem]"
>
Third-party bridges
</a>
<button
id="native-bridge-btn"
className="btn-custom btn rounded-full bg-white text-sm font-medium uppercase text-black hover:bg-primary md:text-[0.9375rem]"
onClick={() => toggleShowBridge(true)}
>
Linea Native Bridge
<ToolTip text="Slow: Use this bridge for non time-sensitive bridge transfers" className="min-w-60">
<svg width={13} height={13} viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.50006 11.9879C9.53099 11.9879 11.988 9.53086 11.988 6.49994C11.988 3.46901 9.53099 1.01196 6.50006 1.01196C3.46914 1.01196 1.01208 3.46901 1.01208 6.49994C1.01208 9.53086 3.46914 11.9879 6.50006 11.9879Z"
stroke="#121212"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M6.50006 9.20312V6.00793" stroke="#121212" strokeLinecap="round" strokeLinejoin="round" />
<path d="M6.50006 4.11108H6.50506" stroke="#121212" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</ToolTip>
</button>
</div>
</div>
<Shortcut />
</>
);
};
export default Hero;

View File

@@ -1,138 +0,0 @@
import { useEffect, useState } from "react";
import { MdCheck } from "react-icons/md";
import { OnChainMessageStatus } from "@consensys/linea-sdk";
import classNames from "classnames";
import { toast } from "react-toastify";
import { MessageWithStatus } from "@/hooks/useMessageService";
import { useMessageService, useSwitchNetwork } from "@/hooks";
import { Transaction } from "@/models";
import { TransactionHistory } from "@/models/history";
import { useWaitForTransactionReceipt } from "wagmi";
import { useChainStore } from "@/stores/chainStore";
interface Props {
message: MessageWithStatus;
transaction: TransactionHistory;
}
export default function HistoryClaim({ message, transaction }: Props) {
const [waitingTransaction, setWaitingTransaction] = useState<Transaction | undefined>();
const [isClaimingLoading, setIsClaimingLoading] = useState<boolean>(false);
const [isSuccess, setIsSuccess] = useState<boolean>(false);
// Is automatic or manual bridging
const manualBridging = message.fee === "0";
// Context
const toChain = useChainStore((state) => state.toChain);
// Hooks
const { switchChainById } = useSwitchNetwork(toChain?.id);
const { writeClaimMessage, isLoading: isTxLoading, transaction: claimTx } = useMessageService();
// Wagmi
const {
isLoading: isWaitingLoading,
isSuccess: isWaitingSuccess,
isError: isWaitingError,
} = useWaitForTransactionReceipt({
hash: waitingTransaction?.txHash,
chainId: waitingTransaction?.chainId,
});
const BridgingClaimable = ({ enabled = false }) => {
const claimBusy = isClaimingLoading || isTxLoading || isWaitingLoading || !enabled;
return (
<div className="flex flex-row items-center space-x-2">
<button
id={enabled ? "claim-funds-btn" : "claim-funds-btn-disabled"}
onClick={() => !claimBusy && onClaimMessage()}
className={classNames("btn btn-primary w-38 rounded-full btn-sm mt-1 no-animation mr-2 uppercase", {
"cursor-wait": claimBusy,
})}
type="button"
disabled={!enabled}
>
{claimBusy && <span className="loading loading-spinner loading-xs"></span>}
Claim funds
</button>
</div>
);
};
const BridgingComplete = () => (
<div className="flex flex-row items-center space-x-1">
<MdCheck className="text-2xl text-success" />
<span className="text-xs">Bridging complete</span>
</div>
);
const WaitingForTransaction = ({ loading }: { loading: boolean }) => (
<div className="flex flex-row items-center space-x-1">
{loading && <span className="loading loading-spinner mr-1"></span>}
<span className="text-xs">Please wait, your funds are being bridged</span>
</div>
);
const getClaimStatus = () => {
if (message.status === OnChainMessageStatus.CLAIMED || isSuccess) {
return <BridgingComplete />;
} else if (message.status === OnChainMessageStatus.CLAIMABLE) {
return <BridgingClaimable enabled={true} />;
} else {
if (manualBridging) {
return (
<div className="flex flex-row">
<BridgingClaimable enabled={false} />
<WaitingForTransaction loading={false} />
</div>
);
}
return <WaitingForTransaction loading={true} />;
}
};
const onClaimMessage = async () => {
if (isClaimingLoading) {
return;
}
try {
setIsClaimingLoading(true);
await switchChainById(transaction.toChain.id);
await writeClaimMessage(message, transaction);
// eslint-disable-next-line no-empty
} catch (error) {
} finally {
setIsClaimingLoading(false);
}
};
useEffect(() => {
if (claimTx) {
setWaitingTransaction({
txHash: claimTx.txHash,
chainId: transaction.toChain.id,
name: transaction.toChain.name,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [claimTx]);
useEffect(() => {
if (isWaitingSuccess) {
toast.success(`Funds claimed on ${transaction.toChain.name}.`);
setIsSuccess(true);
setWaitingTransaction(undefined);
}
}, [isWaitingSuccess, transaction]);
useEffect(() => {
if (isWaitingError) {
toast.error("Funds claiming failed.");
setWaitingTransaction(undefined);
}
}, [isWaitingError]);
return <div>{getClaimStatus()}</div>;
}

View File

@@ -7,9 +7,9 @@ import { MdOutlineArrowRightAlt } from "react-icons/md";
import { Address, formatUnits, zeroAddress } from "viem";
import ChainLogo from "@/components/widgets/ChainLogo";
import HistoryClaim from "./HistoryClaim";
import { formatAddress, safeGetAddress } from "@/utils/format";
import { TransactionHistory } from "@/models/history";
import TransactionClaimButton from "../transactions/modals/TransactionClaimButton";
interface Props {
transaction: TransactionHistory;
@@ -112,7 +112,7 @@ export default function HistoryItem({ transaction, variants }: Props) {
</Link>
</li>
{transaction.messages?.map((message) => {
return <HistoryClaim key={message.messageHash} message={message} transaction={transaction} />;
return <TransactionClaimButton key={message.messageHash} message={message} transaction={transaction} />;
})}
<li className="divider"></li>
</motion.ul>

View File

@@ -1,14 +1,13 @@
"use client";
import Image from "next/image";
import { ToastContainer } from "react-toastify";
import atypTextFont from "@/app/font/atypText";
import atypFont from "@/app/font/atyp";
import Header from "../header/Header";
import Header from "./header/Header";
import SwitchNetwork from "../widgets/SwitchNetwork";
import Footer from "../footer/Footer";
import useInitialiseChain from "@/hooks/useInitialiseChain";
import useInitialiseToken from "@/hooks/useInitialiseToken";
import Sidebar from "./Sidebar";
export function Layout({ children }: { children: React.ReactNode }) {
useInitialiseChain();
@@ -16,7 +15,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
return (
<div
className={`${atypFont.variable} ${atypTextFont.variable} ${atypFont.className} flex min-h-screen flex-col bg-hero bg-cover bg-no-repeat`}
className={`${atypFont.variable} ${atypTextFont.variable} ${atypFont.className} flex min-h-screen flex-col bg-cover bg-no-repeat`}
>
<ToastContainer
position="top-center"
@@ -25,28 +24,14 @@ export function Layout({ children }: { children: React.ReactNode }) {
pauseOnFocusLoss={false}
theme="dark"
/>
<Header />
<main className="container flex flex-1 flex-col items-center justify-center">
<Image
src={"/images/picto/overlay-vertical.svg"}
alt="Linea"
height={0}
width={0}
style={{ width: "132px", height: "auto" }}
className="pointer-events-none absolute -top-8 right-48 hidden lg:block"
/>
<Image
src={"/images/picto/overlay.svg"}
alt="Linea"
height={0}
width={0}
style={{ width: "210px", height: "auto" }}
className="4xl:-left-40 pointer-events-none absolute bottom-40 left-0 hidden lg:block"
/>
<Sidebar />
<div className="md:ml-64">
<Header />
</div>
<main className="m-0 flex-1 p-10 md:ml-64">
{children}
<SwitchNetwork />
</main>
<SwitchNetwork />
<Footer />
</div>
);
}

View File

@@ -0,0 +1,78 @@
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { MENU_ITEMS } from "@/utils/constants";
import { MdOutlineClose } from "react-icons/md";
type MobileMenuProps = {
toggleMenu: () => void;
};
export default function MobileMenu({ toggleMenu }: MobileMenuProps) {
const pathname = usePathname();
return (
<div className="fixed inset-0 z-50 bg-[#121212] p-8 md:hidden">
<div className="flex items-center justify-between">
<Image src={"/images/logo/linea.svg"} alt="Linea logo" width={95} height={45} />
<button onClick={toggleMenu} className="btn btn-circle btn-ghost">
<MdOutlineClose size="2em" />
</button>
</div>
<div className="mt-5 flex h-full flex-col justify-between overflow-y-auto py-4">
<div>
<ul className="space-y-2 font-medium">
{MENU_ITEMS.map(({ title, href, external, Icon }) => (
<li key={title}>
{external ? (
<Link
href={href}
passHref
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 py-4"
onClick={toggleMenu}
>
<Icon alt={title} width={30} height={30} />
<span>{title}</span>
</Link>
) : (
<Link
href={href}
className={`flex items-center gap-2 py-3 ${pathname === href ? "text-primary" : ""}`}
onClick={toggleMenu}
>
<Icon alt={title} width={30} height={30} />
<span>{title}</span>
</Link>
)}
</li>
))}
</ul>
</div>
<div className="space-y-2 py-8 font-medium">
<Link
className="flex items-center hover:text-primary"
href="#"
passHref
target="_blank"
rel="noopener noreferrer"
onClick={toggleMenu}
>
Contact Support
</Link>
<Link
className="flex items-center hover:text-primary"
href="https://linea.build/terms-of-service"
passHref
target="_blank"
rel="noopener noreferrer"
onClick={toggleMenu}
>
Terms of service
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
"use client";
import classNames from "classnames";
import { useContext } from "react";
import { ModalContext } from "@/contexts/modal.context";
import { MdOutlineClose } from "react-icons/md";
type ModalProps = Record<string, never>;
const Modal: React.FC<ModalProps> = () => {
const { ref, modalContent, options } = useContext(ModalContext);
const width = options?.width ? options.width : "w-screen md:w-[600px]";
return (
<dialog ref={ref} className="modal">
<div className={classNames("modal-box bg-cardBg border-card border-2", width)}>
<form method="dialog">
<button className="btn btn-circle btn-ghost btn-sm absolute right-2 top-2">
<MdOutlineClose className="text-lg" />
</button>
</form>
{modalContent}
</div>
<form method="dialog" className="modal-backdrop">
<button>Close</button>
</form>
</dialog>
);
};
export default Modal;

View File

@@ -1,8 +1,8 @@
"use client";
import { State } from "wagmi";
import { UIProvider } from "@/contexts/ui.context";
import { Web3Provider } from "@/contexts/web3.context";
import { ModalProvider } from "@/contexts/modal.context";
type ProvidersProps = {
children: JSX.Element;
@@ -11,8 +11,8 @@ type ProvidersProps = {
export function Providers({ children, initialState }: ProvidersProps) {
return (
<UIProvider>
<Web3Provider initialState={initialState}>{children}</Web3Provider>
</UIProvider>
<Web3Provider initialState={initialState}>
<ModalProvider>{children}</ModalProvider>
</Web3Provider>
);
}

View File

@@ -0,0 +1,68 @@
import { usePathname } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { MENU_ITEMS } from "@/utils/constants";
export default function Sidebar() {
const pathname = usePathname();
return (
<aside id="sidebar" className="fixed left-0 top-0 z-40 hidden h-screen w-52 md:block" aria-label="Sidebar">
<div className="flex h-full flex-col justify-between overflow-y-auto bg-cardBg py-4">
<div>
<div className="flex h-24 items-center p-4">
<Link href="/">
<Image src={"/images/logo/linea.svg"} alt="Linea logo" width={95} height={45} />
</Link>
</div>
<ul className="space-y-2 font-medium">
{MENU_ITEMS.map(({ title, href, external, Icon }) => (
<li key={title}>
{external ? (
<Link
href={href}
passHref
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-4"
>
<Icon alt={title} width={30} height={30} />
<span>{title}</span>
</Link>
) : (
<Link
href={href}
className={`flex items-center gap-2 p-3 ${pathname === href ? "border-r-2 border-primary text-primary" : ""}`}
>
<Icon alt={title} width={30} height={30} />
<span>{title}</span>
</Link>
)}
</li>
))}
</ul>
</div>
<div className="space-y-2 p-4 font-medium">
<Link
className="flex items-center hover:text-primary"
href="#"
passHref
target="_blank"
rel="noopener noreferrer"
>
Contact Support
</Link>
<Link
className="flex items-center hover:text-primary"
href="https://linea.build/terms-of-service"
passHref
target="_blank"
rel="noopener noreferrer"
>
Terms of service
</Link>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,62 @@
import { usePathname } from "next/navigation";
import { useAccount } from "wagmi";
import Image from "next/image";
import { useEffect, useState } from "react";
import { MdMenu } from "react-icons/md";
import Wallet from "./Wallet";
import Chains from "./Chains";
import MobileMenu from "../MobileMenu";
function formatPath(pathname: string): string {
switch (pathname) {
case "/":
case "":
return "Bridge";
case "/transactions":
return "Transactions";
default:
return "";
}
}
export default function Header() {
// Hooks
const { isConnected } = useAccount();
const pathname = usePathname();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const toggleMenu = () => {
setIsMenuOpen(!isMenuOpen);
};
useEffect(() => {
if (isMenuOpen) {
document.body.classList.add("overflow-hidden");
} else {
document.body.classList.remove("overflow-hidden");
}
}, [isMenuOpen]);
return (
<header className="flex items-center justify-between gap-3 p-10">
<Image src={"/images/logo/linea.svg"} alt="Linea logo" width={95} height={45} className="md:hidden" />
<h1 className="hidden text-4xl md:flex">{formatPath(pathname)}</h1>
<div className="flex items-center">
<ul className="menu menu-horizontal m-0">
{isConnected && (
<li>
<Chains />
</li>
)}
<li>
<Wallet />
</li>
</ul>
<button onClick={toggleMenu} className="btn btn-circle btn-ghost md:hidden">
<MdMenu size="2em" />
</button>
{isMenuOpen && <MobileMenu toggleMenu={toggleMenu} />}
</div>
</header>
);
}

View File

@@ -4,20 +4,14 @@ import { useEffect, useRef } from "react";
import Image from "next/image";
import { useAccount, useDisconnect } from "wagmi";
import { MdLogout } from "react-icons/md";
import classNames from "classnames";
import { formatAddress } from "@/utils/format";
import { useWeb3Modal } from "@web3modal/wagmi/react";
import ConnectButton from "@/components/ConnectButton";
type Props = {
className?: string;
};
export default function Wallet({ className = "" }: Props) {
export default function Wallet() {
const detailsRef = useRef<HTMLDetailsElement>(null);
const { address, isConnected } = useAccount();
const { disconnect } = useDisconnect();
const { open } = useWeb3Modal();
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
@@ -59,15 +53,7 @@ export default function Wallet({ className = "" }: Props) {
return (
<div className="p-0">
<button
id="wallet-connect-btn"
className={classNames(className, {
"btn btn-primary rounded-full uppercase text-sm md:text-[0.9375rem] font-semibold": !className,
})}
onClick={() => open()}
>
Connect Wallet
</button>
<ConnectButton />
</div>
);
}

View File

@@ -1,188 +0,0 @@
import Image from "next/image";
import React, { useRef, useState } from "react";
import { Swiper, SwiperRef, SwiperSlide } from "swiper/react";
import { motion } from "framer-motion";
import "swiper/css";
import classNames from "classnames";
import { Shortcut as ShortcutInterface } from "@/models/shortcut";
import { useShortcuts } from "../bridge/BridgeLayout";
const poweredBy = [
{
label: "Onthis.xyz",
url: "https://onthis.xyz/",
logo: "/images/logo/onthis.svg",
},
{
label: "Across",
url: "https://across.to/",
logo: "/images/logo/across.svg",
},
];
const Shortcut: React.FC = () => {
const [progress, setProgress] = useState(0);
const sliderRef = useRef<SwiperRef>(null);
const shortcuts = useShortcuts();
const handleClickNext = () => {
sliderRef.current?.swiper.slideNext();
};
const handleClickPrev = () => {
sliderRef.current?.swiper.slidePrev();
};
return (
<div className="shortcut mt-32 w-full">
<div className="flex items-center justify-between text-white">
<h2 className="text-3xl md:text-[2.625rem]">Shortcuts</h2>
<a
href="https://docs.linea.build/developers/tooling/cross-chain/shortcuts"
target="_blank"
className="underline decoration-primary underline-offset-8"
>
How does it work?
</a>
</div>
{shortcuts.length > 0 && (
<Swiper
slidesPerView={1}
slidesPerGroup={1}
spaceBetween={20}
mousewheel={{
forceToAxis: true,
}}
breakpoints={{
640: {
slidesPerView: 1,
},
768: {
slidesPerView: 2,
},
1024: {
slidesPerView: 3,
},
1440: {
slidesPerView: 4,
},
}}
ref={sliderRef}
onSlideChange={(swiper) => {
setProgress(swiper.progress);
}}
className="mt-6 size-full"
>
{shortcuts.map((shortcut, index) => (
<SwiperSlide key={index}>
<ShortcutCard item={shortcut} className="" index={index} />
</SwiperSlide>
))}
</Swiper>
)}
<div className="mt-8 flex flex-wrap items-center justify-center gap-8 text-white md:justify-between">
<div className="flex items-center gap-2.5">
<span>Powered by </span>
{poweredBy.map((item, index) => (
<a href={item.url} key={index} target="_blank" className="flex items-center gap-x-1.5 hover:underline">
<Image
src={item.logo}
alt={item.label}
className="size-6"
width={0}
height={0}
style={{ width: "24px", height: "auto" }}
/>
<span>{item.label}</span>
</a>
))}
</div>
<div className="mr-1 flex gap-2">
<button
onClick={handleClickPrev}
className="btn size-12 rounded bg-primary px-0 text-black hover:bg-primary disabled:bg-cardBg disabled:text-card"
disabled={progress === 0}
>
<svg
className="rotate-180"
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 40 40"
fill="none"
>
<path
d="M15.7815 30.2967L24.5276 21.5506C25.2006 20.8776 25.2006 19.7864 24.5276 19.1134L15.7815 10.3672"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</button>
<button
onClick={handleClickNext}
className="btn size-12 rounded bg-primary px-0 text-black hover:bg-primary disabled:bg-cardBg disabled:text-card"
disabled={progress === 1}
>
<svg className="" xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40" fill="none">
<path
d="M15.7815 30.2967L24.5276 21.5506C25.2006 20.8776 25.2006 19.7864 24.5276 19.1134L15.7815 10.3672"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</button>
</div>
</div>
<p className="my-14 max-w-xl">
Note: *.onlinea.eth shortcuts are curated by Linea, but provided by Ecosystem Partners and Community. They are
not canonical solutions and they include additional fees collected only by involved partners.
</p>
</div>
);
};
const ShortcutCard: React.FC<{
item: ShortcutInterface;
className?: string;
index?: number;
}> = ({ item, className, index = 0 }) => {
const { title, description, logo, ens_name } = item;
const [buttonClicked, setButtonClicked] = useState<boolean>(false);
const onButtonClick = () => {
setButtonClicked(true);
window.navigator.clipboard.writeText(ens_name);
setTimeout(() => setButtonClicked(false), 3000);
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: index * 0.1 }}
>
<div className={classNames("shortcut-card min-h-64 mt-2 mr-1", className)}>
{logo && <Image src={logo} alt={title} width={120} height={32} className="mb-3 h-8" />}
<div className="text-gray my-4 line-clamp-[10] text-lg md:mb-3.5">{description}</div>
{ens_name && (
<button
onClick={onButtonClick}
className={classNames(
"btn w-fit btn-custom bg-white text-black rounded-full mt-auto hover:bg-primary text-sm md:text-[0.9375rem] font-medium uppercase",
{ "opacity-60 normal-case": buttonClicked },
)}
>
{buttonClicked ? "Copied to Clipboard" : ens_name}
</button>
)}
</div>
</motion.div>
);
};
export default Shortcut;

View File

@@ -5,8 +5,10 @@ import { useConfigStore } from "@/stores/configStore";
export default function TermsModal() {
const termsModalRef = useRef<HTMLDivElement>(null);
const { agreeToTerms, setAgreeToTerms } = useConfigStore((state) => ({
const { agreeToTerms, rehydrated, setAgreeToTerms } = useConfigStore((state) => ({
agreeToTerms: state.agreeToTerms,
rehydrated: state.rehydrated,
setAgreeToTerms: state.setAgreeToTerms,
}));
@@ -24,14 +26,18 @@ export default function TermsModal() {
};
useEffect(() => {
if (window && isFirstTime()) {
if (rehydrated && window && isFirstTime()) {
setTimeout(() => {
setOpen(true);
setVideoEnabled(true);
}, 1000);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [rehydrated]);
if (!rehydrated) {
return null;
}
return (
<div

View File

@@ -0,0 +1,12 @@
import Link from "next/link";
export function NoTransactions() {
return (
<div className="flex min-h-80 flex-col items-center justify-center gap-8 rounded-lg border-2 border-card bg-cardBg p-4">
<span className="text-[#C0C0C0]">No bridge transactions found</span>
<Link href="/" className="btn btn-primary max-w-xs rounded-full uppercase">
Bridge assets
</Link>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { OnChainMessageStatus } from "@consensys/linea-sdk";
import { useMemo } from "react";
type StatusTextProps = {
status: OnChainMessageStatus;
};
const useFormattedStatus = (status: OnChainMessageStatus): JSX.Element => {
return useMemo(() => {
switch (status) {
case OnChainMessageStatus.CLAIMABLE:
return <span className="text-secondary">Ready to claim</span>;
case OnChainMessageStatus.UNKNOWN:
return <span className="text-primary">Pending</span>;
case OnChainMessageStatus.CLAIMED:
return <span className="text-success">Completed</span>;
default:
throw new Error(`Incorrect transaction status: ${status}`);
}
}, [status]);
};
export default function StatusText({ status }: StatusTextProps) {
return useFormattedStatus(status);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { useContext } from "react";
import { formatEther } from "viem";
import { ModalContext } from "@/contexts/modal.context";
import StatusText from "./StatusText";
import TransactionProgressBar from "./TransactionProgressBar";
import TransactionDetailsModal from "./modals/TransactionDetailsModal";
import { NETWORK_ID_TO_NAME } from "@/utils/constants";
import { getChainNetworkLayerByChainId } from "@/utils/chainsUtil";
import { TransactionHistory } from "@/models/history";
import { MessageWithStatus } from "@/hooks";
export enum TransactionStatus {
READY_TO_CLAIM = "READY_TO_CLAIM",
PENDING = "PENDING",
COMPLETED = "COMPLETED",
}
type TransactionItemProps = {
transaction: TransactionHistory;
message: MessageWithStatus;
};
export default function TransactionItem({ transaction, message }: TransactionItemProps) {
const { handleShow } = useContext(ModalContext);
return (
<div
className="grid grid-cols-1 items-center gap-0 rounded-lg bg-[#2D2D2D] p-4 text-[#C0C0C0] hover:cursor-pointer hover:outline hover:outline-1 hover:outline-primary sm:grid-cols-1 md:grid-cols-6 md:gap-4"
onClick={() => {
handleShow(<TransactionDetailsModal transaction={transaction} message={message} />);
}}
>
<div className="grid grid-cols-2 gap-4 border-b border-card py-4 md:col-span-2 md:border-none md:p-0">
<div className="px-6 md:px-0">
<div className="text-xs uppercase">Status</div>
<StatusText status={message.status} />
</div>
<div className="px-6 md:px-0">
<div className="text-xs uppercase">From</div>
<span>{NETWORK_ID_TO_NAME[transaction.fromChain.id]}</span>
</div>
</div>
<div className="hidden px-6 md:col-span-2 md:block md:border-x md:border-card">
<TransactionProgressBar
status={message.status}
transactionTimestamp={transaction.timestamp}
fromChain={getChainNetworkLayerByChainId(transaction.fromChain.id)}
/>
</div>
<div className="grid grid-cols-2 items-center gap-4 border-b border-card py-4 md:col-span-2 md:border-none md:p-0">
<div className="px-6 md:px-0">
<div className="text-xs uppercase">To</div>
<span>{NETWORK_ID_TO_NAME[transaction.toChain.id]}</span>
</div>
<div className="px-6 md:px-0">
<div className="text-xs uppercase">Amount</div>
<span className="font-bold text-white">
{formatEther(transaction.amount)} {transaction.token.symbol}
</span>
</div>
</div>
<div className="px-6 pt-4 md:hidden md:pt-0">
<TransactionProgressBar
status={message.status}
transactionTimestamp={transaction.timestamp}
fromChain={getChainNetworkLayerByChainId(transaction.fromChain.id)}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { useMemo } from "react";
import { fromUnixTime } from "date-fns";
import { OnChainMessageStatus } from "@consensys/linea-sdk";
import { NetworkLayer } from "@/config";
type TransactionProgressBarProps = {
status: OnChainMessageStatus;
transactionTimestamp: bigint;
fromChain?: NetworkLayer;
};
type TimeUnit = "seconds" | "minutes" | "hours";
const L1_L2_ESTIMATED_TIME_IN_SECONDS = 20 * 60; // 20 minutes
const L2_L1_MIN_TIME_IN_SECONDS = 8 * 60 * 60; // 8 hours
const L2_L1_MAX_TIME_IN_SECONDS = 32 * 60 * 60; // 32 hours
const getElapsedTime = (startTime: Date, currentTime: Date): number => {
return (currentTime.getTime() - startTime.getTime()) / 1000;
};
const getRemainingTime = (startTime: Date, currentTime: Date, unit: TimeUnit, fromChain: NetworkLayer): string => {
const totalTime =
fromChain === NetworkLayer.L1
? L1_L2_ESTIMATED_TIME_IN_SECONDS
: (L2_L1_MIN_TIME_IN_SECONDS + L2_L1_MAX_TIME_IN_SECONDS) / 2;
const elapsedTime = getElapsedTime(startTime, currentTime);
const remainingTimeInSeconds = Math.max(totalTime - elapsedTime, 0);
const hours = Math.floor(remainingTimeInSeconds / 3600);
const minutes = Math.floor((remainingTimeInSeconds % 3600) / 60);
const seconds = Math.floor(remainingTimeInSeconds % 60);
switch (unit) {
case "seconds":
return `${seconds} secs`;
case "minutes":
return `${minutes} min`;
case "hours":
return `${hours} hrs`;
default:
throw new Error("Invalid time unit.");
}
};
const getCompletionPercentage = (
startTime: Date,
currentTime: Date,
status: OnChainMessageStatus,
fromChain?: NetworkLayer,
): number => {
if (status === OnChainMessageStatus.CLAIMED) {
return 100;
}
if (!fromChain) {
throw new Error("Invalid network layer.");
}
const elapsedTime = getElapsedTime(startTime, currentTime);
let totalTime: number;
if (fromChain === NetworkLayer.L1) {
totalTime = L1_L2_ESTIMATED_TIME_IN_SECONDS; // Convert 20 minutes to seconds
} else if (fromChain === NetworkLayer.L2) {
totalTime = (L2_L1_MIN_TIME_IN_SECONDS + L2_L1_MAX_TIME_IN_SECONDS) / 2;
} else {
throw new Error("Invalid network layer.");
}
// Calculate the completion percentage
const completionPercentage = (elapsedTime / totalTime) * 100;
// Ensure the percentage does not exceed 100%
return Math.min(completionPercentage, 100);
};
const getProgressBarText = (
startTime: Date,
currentTime: Date,
status: OnChainMessageStatus,
fromChain?: NetworkLayer,
): string => {
if (!fromChain) {
throw new Error("Invalid network layer.");
}
if (status === OnChainMessageStatus.CLAIMABLE || status === OnChainMessageStatus.CLAIMED) {
return "Complete";
}
const unit = fromChain === NetworkLayer.L1 ? "minutes" : "hours";
return `Est time left ${getRemainingTime(startTime, currentTime, unit, fromChain)}`;
};
const useProgressBarColor = (status: OnChainMessageStatus): string => {
return useMemo(() => {
switch (status) {
case OnChainMessageStatus.CLAIMABLE:
return "progress-secondary";
case OnChainMessageStatus.UNKNOWN:
return "progress-info";
case OnChainMessageStatus.CLAIMED:
return "progress-success";
default:
throw new Error(`Incorrect transaction status: ${status}`);
}
}, [status]);
};
export default function TransactionProgressBar({
status,
transactionTimestamp,
fromChain,
}: TransactionProgressBarProps) {
const color = useProgressBarColor(status);
return (
<>
<div className="flex items-center gap-2">
{[OnChainMessageStatus.CLAIMABLE, OnChainMessageStatus.UNKNOWN].includes(status) && (
<span className="loading loading-spinner loading-xs" />
)}
<span className="text-xs uppercase">
{getProgressBarText(fromUnixTime(Number(transactionTimestamp)), new Date(), status, fromChain)}
</span>
</div>
<progress
className={`progress ${color} min-w-fit rounded-none [&::-webkit-progress-value]:rounded-none`}
value={getCompletionPercentage(fromUnixTime(Number(transactionTimestamp)), new Date(), status, fromChain)}
max={100}
></progress>
</>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import { useEffect, useMemo } from "react";
import { useBlockNumber } from "wagmi";
import { toast } from "react-toastify";
import TransactionItem from "./TransactionItem";
import { TransactionHistory } from "@/models/history";
import { formatDate, fromUnixTime } from "date-fns";
import { NoTransactions } from "./NoTransaction";
import useFetchHistory from "@/hooks/useFetchHistory";
const groupByDay = (transactions: TransactionHistory[]): Record<string, TransactionHistory[]> => {
return transactions.reduce(
(acc, transaction) => {
const date = formatDate(fromUnixTime(Number(transaction.timestamp)), "yyyy-MM-dd");
if (!acc[date]) {
acc[date] = [];
}
acc[date].push(transaction);
return acc;
},
{} as Record<string, TransactionHistory[]>,
);
};
export function Transactions() {
const { data: blockNumber } = useBlockNumber({
watch: true,
});
// Context
const { transactions, fetchHistory, isLoading, clearHistory } = useFetchHistory();
useEffect(() => {
if (blockNumber && blockNumber % 5n === 0n) {
fetchHistory();
}
}, [blockNumber, fetchHistory]);
const groupedTransactions = useMemo(() => groupByDay(transactions), [transactions]);
if (isLoading && transactions.length === 0) {
return (
<div className="flex flex-col gap-4 border-2 border-card bg-cardBg p-4">
<div className="skeleton h-4 w-full bg-card"></div>
<div className="skeleton h-4 w-full bg-card"></div>
<div className="skeleton h-4 w-full bg-card"></div>
<div className="skeleton h-4 w-full bg-card"></div>
<div className="skeleton h-4 w-full bg-card"></div>
<div className="skeleton h-4 w-full bg-card"></div>
<div className="skeleton h-4 w-full bg-card"></div>
</div>
);
}
if (transactions.length === 0) {
return <NoTransactions />;
}
return (
<div className="flex flex-col gap-8 rounded-lg border-2 border-card bg-cardBg p-4">
<div className="flex justify-end">
<button
id="reload-history-btn"
className="btn-link btn-sm font-light normal-case text-gray-200 no-underline opacity-60 hover:text-primary hover:opacity-100"
onClick={() => {
clearHistory();
toast.success("History cleared");
}}
>
Reload history
</button>
</div>
{Object.keys(groupedTransactions).map((date, groupIndex) => (
<div key={`transaction-group-${groupIndex}`} className="flex flex-col gap-2">
<span className="block text-base-content">{formatDate(date, "PPP")}</span>
{groupedTransactions[date].map((transaction, transactionIndex) => {
if (transaction.messages && transaction.messages.length > 0 && transaction.messages[0].status) {
const { messages, ...bridgingTransaction } = transaction;
return (
<TransactionItem
key={`transaction-group-${groupIndex}-item-${transactionIndex}`}
transaction={bridgingTransaction}
message={messages[0]}
/>
);
}
})}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export * from "./NoTransaction";
export * from "./Transactions";

View File

@@ -0,0 +1,93 @@
import { useEffect, useState } from "react";
import classNames from "classnames";
import { toast } from "react-toastify";
import { useSwitchNetwork } from "@/hooks";
import { Transaction } from "@/models";
import { TransactionHistory } from "@/models/history";
import { useWaitForTransactionReceipt } from "wagmi";
import { useChainStore } from "@/stores/chainStore";
import useTransactionManagement, { MessageWithStatus } from "@/hooks/useTransactionManagement";
interface Props {
message: MessageWithStatus;
transaction: TransactionHistory;
}
export default function TransactionClaimButton({ message, transaction }: Props) {
const [waitingTransaction, setWaitingTransaction] = useState<Transaction | undefined>();
const [isClaimingLoading, setIsClaimingLoading] = useState<boolean>(false);
// Context
const toChain = useChainStore((state) => state.toChain);
// Hooks
const { switchChainById } = useSwitchNetwork(toChain?.id);
const { writeClaimMessage, isLoading: isTxLoading, transaction: claimTx } = useTransactionManagement();
// Wagmi
const {
isLoading: isWaitingLoading,
isSuccess: isWaitingSuccess,
isError: isWaitingError,
} = useWaitForTransactionReceipt({
hash: waitingTransaction?.txHash,
chainId: waitingTransaction?.chainId,
});
const claimBusy = isClaimingLoading || isTxLoading || isWaitingLoading;
useEffect(() => {
if (claimTx) {
setWaitingTransaction({
txHash: claimTx.txHash,
chainId: transaction.toChain.id,
name: transaction.toChain.name,
});
}
}, [claimTx, transaction.toChain.id, transaction.toChain.name]);
useEffect(() => {
if (isWaitingSuccess) {
toast.success(`Funds claimed on ${transaction.toChain.name}.`);
setWaitingTransaction(undefined);
}
}, [isWaitingSuccess, transaction.toChain.name]);
useEffect(() => {
if (isWaitingError) {
toast.error("Funds claiming failed.");
setWaitingTransaction(undefined);
}
}, [isWaitingError]);
const onClaimMessage = async () => {
if (isClaimingLoading) {
return;
}
try {
setIsClaimingLoading(true);
await switchChainById(transaction.toChain.id);
await writeClaimMessage(message, transaction);
} catch (error) {
toast.error("Failed to claim funds. Please try again.");
} finally {
setIsClaimingLoading(false);
}
};
return (
<button
id={!claimBusy ? "claim-funds-btn" : "claim-funds-btn-disabled"}
onClick={() => !claimBusy && onClaimMessage()}
className={classNames("btn btn-primary w-full rounded-full uppercase", {
"cursor-wait": claimBusy,
})}
type="button"
disabled={claimBusy}
>
{claimBusy && <span className="loading loading-spinner loading-xs"></span>}
Claim
</button>
);
}

View File

@@ -0,0 +1,71 @@
import Link from "next/link";
import { OnChainMessageStatus } from "@consensys/linea-sdk";
import { formatHex, formatTimestamp } from "@/utils/format";
import { NETWORK_ID_TO_NAME } from "@/utils/constants";
import { MessageWithStatus } from "@/hooks";
import { TransactionHistory } from "@/models/history";
import TransactionClaimButton from "./TransactionClaimButton";
type TransactionDetailsModalProps = {
transaction: TransactionHistory;
message: MessageWithStatus;
};
const BlockExplorerLink: React.FC<{
transactionHash?: string;
blockExplorer?: string;
}> = ({ transactionHash, blockExplorer }) => {
if (!transactionHash || !blockExplorer) {
return <span>N/A</span>;
}
return (
<Link
href={`${blockExplorer}/tx/${transactionHash}`}
passHref
target="_blank"
rel="noopener noreferrer"
className="link text-primary"
>
{formatHex(transactionHash)}
</Link>
);
};
const TransactionDetailsModal: React.FC<TransactionDetailsModalProps> = ({ transaction, message }) => {
return (
<div className="flex flex-col gap-8">
<h2 className="text-xl">Transaction details</h2>
<div className="space-y-2">
<div className="flex">
<label className="w-44 text-[#C0C0C0]">Date & Time</label>
<span className="text-[#C0C0C0]">{formatTimestamp(Number(transaction.timestamp), "h:mma d MMMM yyyy")}</span>
</div>
<div className="flex">
<label className="w-44 text-[#C0C0C0]">{NETWORK_ID_TO_NAME[transaction.fromChain.id]} Tx Hash</label>
<BlockExplorerLink
blockExplorer={transaction.fromChain.blockExplorers?.default.url}
transactionHash={transaction.transactionHash}
/>
</div>
<div className="flex">
<label className="w-44 text-[#C0C0C0]">{NETWORK_ID_TO_NAME[transaction.toChain.id]} Tx Hash</label>
<BlockExplorerLink
blockExplorer={transaction.toChain.blockExplorers?.default.url}
transactionHash={message.claimingTransactionHash}
/>
</div>
<div className="flex">
<label className="w-44 text-[#C0C0C0]">Fee</label>
<span className="text-[#C0C0C0]">5 ETH ~$15000</span>
</div>
</div>
{message.status === OnChainMessageStatus.CLAIMABLE && (
<TransactionClaimButton key={message.messageHash} message={message} transaction={transaction} />
)}
</div>
);
};
export default TransactionDetailsModal;

View File

@@ -38,10 +38,8 @@ export default function SwitchNetwork() {
if (!isConnected) return null;
return (
<div className="fixed bottom-24 left-4 md:left-10">
<button id="try-network-btn" className="btn btn-info uppercase" onClick={() => switchNetworkHandler()}>
Try {networkType === NetworkType.SEPOLIA ? "Mainnet" : "Testnet"}
</button>
</div>
<button id="try-network-btn" className="btn btn-info uppercase" onClick={() => switchNetworkHandler()}>
Try {networkType === NetworkType.SEPOLIA ? "Mainnet" : "Testnet"}
</button>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import React, { createContext, ReactNode, useCallback, useRef, useState } from "react";
import Modal from "@/components/layouts/Modal";
interface ModalContextType {
ref: React.MutableRefObject<HTMLDialogElement | null>;
handleShow: (content: ReactNode, options?: ModalOptions) => void;
handleClose: () => void;
modalContent: ReactNode;
options: ModalOptions;
}
export const ModalContext = createContext<ModalContextType>({} as ModalContextType);
interface ModalProviderProps {
children: ReactNode;
}
interface ModalOptions {
width?: string;
}
export const ModalProvider: React.FC<ModalProviderProps> = ({ children }) => {
const ref = useRef<HTMLDialogElement>(null);
const [modalContent, setModalContent] = useState<ReactNode>(null);
const [options, setOptions] = useState<ModalOptions>({});
const handleShow = useCallback((content: ReactNode, options?: ModalOptions) => {
options && setOptions(options);
setModalContent(content);
ref.current?.showModal();
}, []);
const handleClose = useCallback(() => {
ref.current?.close();
setModalContent(null);
}, []);
return (
<ModalContext.Provider value={{ ref, handleShow, handleClose, modalContent, options }}>
{children}
<Modal />
</ModalContext.Provider>
);
};

View File

@@ -1,34 +0,0 @@
---
- title: 'Bridge and ReStake'
description: 'Bridge ETH from Mainnet to Linea.'
logo: '/images/logo/linea-logo.svg'
ens_name: 'bridge.onlinea.eth'
- title: 'Bridge and ReStake'
description: 'Bridge ETH from Mainnet, keep 0.005 in ETH and swap the remaining for wstETH.'
logo: '/images/logo/lido.svg'
ens_name: 'lido.onlinea.eth'
- title: 'Bridge and ReStake'
description: 'Bridge ETH from Mainnet, keep 0.005 in ETH and restake the remaining in ezETH.'
logo: '/images/logo/renzo.svg'
ens_name: 'renzo.onlinea.eth'
# - title: 'Bridge and ReStake'
# description: 'Low gas fees and low latency with high throughput backed by the security of Ethereum.'
# logo: '/images/logo/renzo.svg'
# ens_name: 'onrenzo.linea.eth'
---
Each shortcut is a block with the following structure:
```
- title: ...
description: ...
logo: ...
ens_name: ...
```
Copy and paste the block and change the values to create a new shortcut.
> **_NOTE:_** If you use internal image for `logo`, please make sure to add the image to this repository at `bridge-ui/public/images/...`

View File

@@ -4,7 +4,10 @@ export { default as useBridge } from "./useBridge";
export { default as useExecutionFee } from "./useExecutionFee";
export { default as useFetchAnchoringEvents } from "./useFetchAnchoringEvents";
export { default as useFetchBridgeTransactions } from "./useFetchBridgeTransactions";
export { default as useMessageService } from "./useMessageService";
export { default as useMinimumFee } from "./useMinimumFee";
export { default as useTransactionManagement } from "./useTransactionManagement";
export { default as useLineaSDK } from "./useLineaSDK";
export { default as useMessageStatus } from "./useMessageStatus";
export { default as useSwitchNetwork } from "./useSwitchNetwork";
export type { MessageWithStatus } from "./useMessageService";
export type { MessageWithStatus } from "./useTransactionManagement";

View File

@@ -7,13 +7,13 @@ import log from "loglevel";
import USDCBridge from "@/abis/USDCBridge.json";
import TokenBridge from "@/abis/TokenBridge.json";
import MessageService from "@/abis/MessageService.json";
import useMessageService from "./useMessageService";
import { TokenInfo, TokenType, config } from "@/config/config";
import { BridgeError, BridgeErrors, Transaction } from "@/models";
import { getChainNetworkLayer } from "@/utils/chainsUtil";
import { FieldErrors, FieldValues } from "react-hook-form";
import { wagmiConfig } from "@/config";
import { useChainStore } from "@/stores/chainStore";
import useMinimumFee from "./useMinimumFee";
type UseBridge = {
hash: Address | undefined;
@@ -45,7 +45,7 @@ const useBridge = (): UseBridge => {
toChain: state.toChain,
}));
const { minimumFee } = useMessageService();
const { minimumFee } = useMinimumFee();
const { address, isConnected } = useAccount();
const queryClient = useQueryClient();

View File

@@ -17,18 +17,17 @@ import useERC20Storage from "./useERC20Storage";
import { BlockRange, TransactionHistory } from "@/models/history";
import useFetchAnchoringEvents from "./useFetchAnchoringEvents";
import { OnChainMessageStatus } from "@consensys/linea-sdk";
import useMessageService from "./useMessageService";
import useBridge from "./useBridge";
import { getChainNetworkLayer } from "@/utils/chainsUtil";
import { useTokenStore } from "@/stores/tokenStore";
import useMessageStatus from "./useMessageStatus";
const useFetchBridgeTransactions = () => {
// Wagmi
const { address } = useAccount();
const tokensConfig = useTokenStore((state) => state.tokensConfig);
const { fetchAnchoringMessageHashes } = useFetchAnchoringEvents();
const { getMessagesStatusesByTransactionHash } = useMessageService();
const { getMessageStatuses } = useMessageStatus();
const { fetchBridgedToken, fillMissingTokenAddress } = useBridge();
const { updateOrInsertUserTokenList } = useERC20Storage();
@@ -118,7 +117,8 @@ const useFetchBridgeTransactions = () => {
updateOrInsertUserTokenList(transaction.token, networkType);
}
const newMessages = await getMessagesStatusesByTransactionHash(txHash, fromLayer);
const newMessages = await getMessageStatuses(txHash, fromLayer);
const updatedTransaction = {
...transaction,
token: {

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback } from "react";
import { useAccount } from "wagmi";
import log from "loglevel";
import { useChainStore } from "@/stores/chainStore";
@@ -11,9 +11,6 @@ import useFetchBridgeTransactions from "./useFetchBridgeTransactions";
const DEFAULT_FIRST_BLOCK = BigInt(1000);
const useFetchHistory = () => {
// Prevent double fetching
const [isFetching, setIsFetching] = useState<boolean>(false);
// Wagmi
const { address } = useAccount();
@@ -43,11 +40,11 @@ const useFetchHistory = () => {
if (!l1Chain || !l2Chain || !address) {
return;
}
// Prevent multiple call
if (isFetching) return;
if (isLoading) return;
try {
setIsFetching(true);
setIsLoading(true);
// ToBlock: get last onchain block
@@ -75,11 +72,10 @@ const useFetchHistory = () => {
} catch (error) {
log.error(error);
} finally {
setIsFetching(false);
setIsLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [address, currentNetworkType, isFetching, l1Chain, l2Chain]);
}, [address, currentNetworkType, l1Chain, l2Chain]);
const clearHistory = useCallback(() => {
// Clear local storage

View File

@@ -50,7 +50,7 @@ const useInitialiseChain = () => {
}));
useEffect(() => {
watchAccount(wagmiConfig, {
const unwatch = watchAccount(wagmiConfig, {
onChange(account) {
let networkType = NetworkType.UNKNOWN;
let networkLayer = NetworkLayer.UNKNOWN;
@@ -118,6 +118,10 @@ const useInitialiseChain = () => {
setToChain(toChain);
},
});
return () => {
unwatch();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, token?.type]);

View File

@@ -1,36 +1,13 @@
import { useEffect } from "react";
import { NetworkTokens, TokenInfo, TokenType } from "@/config";
import { Token } from "@/models/token";
import { getTokens, USDC_TYPE } from "@/services";
import { defaultTokensConfig, useTokenStore } from "@/stores/tokenStore";
import log from "loglevel";
import { useEffect } from "react";
enum NetworkTypes {
MAINNET = "MAINNET",
SEPOLIA = "SEPOLIA",
}
const CANONICAL_BRIDGED_TYPE = "canonical-bridge";
const USDC_TYPE = "USDC";
async function getTokens(networkTypes: NetworkTypes): Promise<Token[]> {
try {
// Fetch the JSON data from the URL.
let url = process.env.NEXT_PUBLIC_MAINNET_TOKEN_LIST ? (process.env.NEXT_PUBLIC_MAINNET_TOKEN_LIST as string) : "";
if (networkTypes === NetworkTypes.SEPOLIA) {
url = process.env.NEXT_PUBLIC_SEPOLIA_TOKEN_LIST ? (process.env.NEXT_PUBLIC_SEPOLIA_TOKEN_LIST as string) : "";
}
const response = await fetch(url);
const data = await response.json();
const tokens = data.tokens;
const bridgedTokens = tokens.filter(
(token: Token) => token.tokenType.includes(CANONICAL_BRIDGED_TYPE) || token.symbol === USDC_TYPE,
);
return bridgedTokens;
} catch (error) {
log.error("Error getTokens", { error });
return [];
}
}
export async function getConfig(): Promise<NetworkTokens> {
const mainnetTokens = await getTokens(NetworkTypes.MAINNET);

View File

@@ -0,0 +1,52 @@
import { useMemo } from "react";
import { LineaSDK, Network } from "@consensys/linea-sdk";
import { L1MessageServiceContract, L2MessageServiceContract } from "@consensys/linea-sdk/dist/lib/contracts";
import { NetworkType } from "@/config";
import { useChainStore } from "@/stores/chainStore";
interface LineaSDKContracts {
L1: L1MessageServiceContract;
L2: L2MessageServiceContract;
}
const useLineaSDK = () => {
const networkType = useChainStore((state) => state.networkType);
const { lineaSDK, lineaSDKContracts } = useMemo(() => {
const infuraKey = process.env.NEXT_PUBLIC_INFURA_ID;
if (!infuraKey) return { lineaSDK: null, lineaSDKContracts: null };
let l1RpcUrl;
let l2RpcUrl;
switch (networkType) {
case NetworkType.MAINNET:
l1RpcUrl = `https://mainnet.infura.io/v3/${infuraKey}`;
l2RpcUrl = `https://linea-mainnet.infura.io/v3/${infuraKey}`;
break;
case NetworkType.SEPOLIA:
l1RpcUrl = `https://sepolia.infura.io/v3/${infuraKey}`;
l2RpcUrl = `https://linea-sepolia.infura.io/v3/${infuraKey}`;
break;
default:
return { lineaSDK: null, lineaSDKContracts: null };
}
const sdk = new LineaSDK({
l1RpcUrl,
l2RpcUrl,
network: `linea-${networkType.toLowerCase()}` as Network,
mode: "read-only",
});
const newLineaSDKContracts: LineaSDKContracts = {
L1: sdk.getL1Contract(),
L2: sdk.getL2Contract(),
};
return { lineaSDK: sdk, lineaSDKContracts: newLineaSDKContracts };
}, [networkType]);
return { lineaSDK, lineaSDKContracts };
};
export default useLineaSDK;

View File

@@ -1,277 +0,0 @@
import { useState, useCallback, useMemo } from "react";
import { readContract, simulateContract, writeContract } from "@wagmi/core";
import { useAccount } from "wagmi";
import { zeroAddress } from "viem";
import log from "loglevel";
import { LineaSDK, OnChainMessageStatus } from "@consensys/linea-sdk";
import { L1MessageServiceContract, L2MessageServiceContract } from "@consensys/linea-sdk/dist/lib/contracts";
import MessageService from "@/abis/MessageService.json";
import { getChainNetworkLayer, getChainNetworkType } from "@/utils/chainsUtil";
import { NetworkLayer, NetworkType, config, wagmiConfig } from "@/config";
import { Transaction } from "@/models";
import { TransactionHistory } from "@/models/history";
import { Proof } from "@consensys/linea-sdk/dist/lib/sdk/merkleTree/types";
import { useChainStore } from "@/stores/chainStore";
interface LineaSDKContracts {
L1: L1MessageServiceContract;
L2: L2MessageServiceContract;
}
export interface MessageWithStatus {
status: OnChainMessageStatus;
messageSender: string;
destination: string;
fee: string;
value: string;
messageNonce: string;
calldata: string;
messageHash: string;
proof: Proof | undefined;
}
interface ClaimMessageWithProofParams {
proof: string[];
messageNumber: string;
leafIndex: number;
from: string;
to: string;
fee: string;
value: string;
feeRecipient: string;
merkleRoot: string;
data: string;
}
const useMessageService = () => {
const [transaction, setTransaction] = useState<Transaction | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [minimumFee, setMinimumFee] = useState(BigInt(0));
const [lineaSDK, setLineaSDK] = useState<LineaSDK | undefined>(undefined);
const [lineaSDKContracts, setLineaSDKContracts] = useState<LineaSDKContracts | undefined>(undefined);
const { messageServiceAddress, fromChain, networkType } = useChainStore((state) => ({
messageServiceAddress: state.messageServiceAddress,
fromChain: state.fromChain,
networkType: state.networkType,
}));
const { address } = useAccount();
useMemo(() => {
let _lineaSDK;
const infuraKey = process.env.NEXT_PUBLIC_INFURA_ID;
switch (networkType) {
case NetworkType.MAINNET:
_lineaSDK = new LineaSDK({
l1RpcUrl: `https://mainnet.infura.io/v3/${infuraKey}`,
l2RpcUrl: `https://linea-mainnet.infura.io/v3/${infuraKey}`,
network: "linea-mainnet",
mode: "read-only",
});
break;
case NetworkType.SEPOLIA:
_lineaSDK = new LineaSDK({
l1RpcUrl: `https://sepolia.infura.io/v3/${infuraKey}`,
l2RpcUrl: `https://linea-sepolia.infura.io/v3/${infuraKey}`,
network: "linea-sepolia",
mode: "read-only",
});
break;
}
if (!_lineaSDK) {
return;
}
const newLineaSDKContracts: LineaSDKContracts = {
L1: _lineaSDK.getL1Contract(),
L2: _lineaSDK.getL2Contract(),
};
setLineaSDKContracts(newLineaSDKContracts);
setLineaSDK(_lineaSDK);
}, [networkType]);
useMemo(() => {
const readMinimumFee = async () => {
setError(null);
setIsLoading(true);
if (!messageServiceAddress) {
return;
}
try {
let fees = BigInt(0);
if (fromChain && getChainNetworkLayer(fromChain) === NetworkLayer.L2) {
//Get the minimum to send along the message for L2
fees = (await readContract(wagmiConfig, {
address: messageServiceAddress,
abi: MessageService.abi,
functionName: "minimumFeeInWei",
chainId: fromChain.id,
})) as bigint;
}
setMinimumFee(fees);
} catch (error) {
setError(error as Error);
}
setIsLoading(false);
};
readMinimumFee();
}, [messageServiceAddress, fromChain]);
const getMessagesByTransactionHash = useCallback(
async (transactionHash: string, networkLayer: NetworkLayer) => {
if (!lineaSDKContracts || networkLayer === NetworkLayer.UNKNOWN) {
return;
}
return await lineaSDKContracts[networkLayer]?.getMessagesByTransactionHash(transactionHash);
},
[lineaSDKContracts],
);
const getMessagesStatusesByTransactionHash = useCallback(
async (transactionHash: string, networkLayer: NetworkLayer) => {
if (!lineaSDKContracts || networkLayer === NetworkLayer.UNKNOWN) {
return;
}
const messages = await getMessagesByTransactionHash(transactionHash, networkLayer);
const messagesWithStatuses: Array<MessageWithStatus> = [];
if (messages && messages.length > 0) {
const otherLayer = networkLayer === NetworkLayer.L1 ? NetworkLayer.L2 : NetworkLayer.L1;
const promises = messages.map(async (message) => {
const l1ClaimingService = lineaSDK?.getL1ClaimingService(
config.networks[networkType].L1.messageServiceAddress,
config.networks[networkType].L2.messageServiceAddress,
);
let status;
// For messages to claim on L1 we check if we need to claim with the new claiming method
// which requires the proof linked to this message
let proof;
if (otherLayer === NetworkLayer.L1) {
// Message from L2 to L1
status = (await l1ClaimingService?.getMessageStatus(message.messageHash)) || OnChainMessageStatus.UNKNOWN;
if (
status === OnChainMessageStatus.CLAIMABLE &&
(await l1ClaimingService?.isClaimingNeedingProof(message.messageHash))
) {
try {
proof = await l1ClaimingService?.getMessageProof(message.messageHash);
} catch (ex) {
// We ignore the error, the proof will stay undefined, we assume
// it's a message from the old message service
}
}
} else {
// Message from L1 to L2
status = await lineaSDKContracts.L2.getMessageStatus(message.messageHash);
}
// Convert the BigNumbers to string for serialization issue with the storage
const messageWithStatus: MessageWithStatus = {
calldata: message.calldata,
destination: message.destination,
fee: message.fee.toString(),
messageHash: message.messageHash,
messageNonce: message.messageNonce.toString(),
messageSender: message.messageSender,
status: status,
value: message.value.toString(),
proof,
};
messagesWithStatuses.push(messageWithStatus);
});
await Promise.all(promises);
}
return messagesWithStatuses;
},
[lineaSDKContracts, getMessagesByTransactionHash, lineaSDK, networkType],
);
const writeClaimMessage = useCallback(
async (message: MessageWithStatus, tx: TransactionHistory) => {
setError(null);
setIsLoading(true);
// Get the right message Service address depending on the transaction
const txNetworkLayer = getChainNetworkLayer(tx.toChain);
const txNetworkType = getChainNetworkType(tx.toChain);
if (address && txNetworkLayer && txNetworkType) {
try {
const { messageSender, destination, calldata, fee, messageNonce, value, proof } = message;
const messageServiceAddress = config.networks[txNetworkType][txNetworkLayer].messageServiceAddress;
if (messageServiceAddress === null) {
return;
}
let writeConfig;
if (!proof) {
// Claiming using old message service
writeConfig = await simulateContract(wagmiConfig, {
address: messageServiceAddress,
abi: MessageService.abi,
functionName: "claimMessage",
args: [messageSender, destination, fee, value, zeroAddress, calldata, messageNonce],
chainId: tx.toChain.id,
});
} else {
// Claiming on L1 with new message service
const params: ClaimMessageWithProofParams = {
data: calldata,
fee,
feeRecipient: zeroAddress,
from: messageSender,
to: destination,
leafIndex: proof.leafIndex,
merkleRoot: proof.root,
messageNumber: messageNonce,
proof: proof.proof,
value,
};
writeConfig = await simulateContract(wagmiConfig, {
address: messageServiceAddress,
abi: MessageService.abi,
functionName: "claimMessageWithProof",
args: [params],
chainId: tx.toChain.id,
});
}
const hash = await writeContract(wagmiConfig, writeConfig.request);
setTransaction({
txHash: hash,
chainId: tx.fromChain.id,
name: tx.fromChain.name,
});
} catch (error) {
log.error(error);
setError(error as Error);
setTransaction(null);
}
}
setIsLoading(false);
},
[address],
);
return {
isLoading,
isError: error !== null,
error,
minimumFee,
transaction,
getMessagesStatusesByTransactionHash,
writeClaimMessage,
};
};
export default useMessageService;

View File

@@ -0,0 +1,102 @@
import { useCallback } from "react";
import { OnChainMessageStatus } from "@consensys/linea-sdk";
import useLineaSDK from "./useLineaSDK";
import { config, NetworkLayer } from "@/config";
import { MessageWithStatus } from "./useTransactionManagement";
import { useChainStore } from "@/stores/chainStore";
const useMessageStatus = () => {
const { lineaSDK, lineaSDKContracts } = useLineaSDK();
const networkType = useChainStore((state) => state.networkType);
const getMessagesByTransactionHash = useCallback(
async (transactionHash: string, networkLayer: NetworkLayer) => {
if (!lineaSDKContracts || networkLayer === NetworkLayer.UNKNOWN) {
return;
}
return await lineaSDKContracts[networkLayer]?.getMessagesByTransactionHash(transactionHash);
},
[lineaSDKContracts],
);
const getMessageStatuses = useCallback(
async (transactionHash: string, networkLayer: NetworkLayer) => {
if (!lineaSDKContracts || networkLayer === NetworkLayer.UNKNOWN) {
return;
}
const messages = await getMessagesByTransactionHash(transactionHash, networkLayer);
const messagesWithStatuses: Array<MessageWithStatus> = [];
if (messages && messages.length > 0) {
const otherLayer = networkLayer === NetworkLayer.L1 ? NetworkLayer.L2 : NetworkLayer.L1;
const promises = messages.map(async (message) => {
const l1ClaimingService = lineaSDK?.getL1ClaimingService(
config.networks[networkType].L1.messageServiceAddress,
config.networks[networkType].L2.messageServiceAddress,
);
let status: OnChainMessageStatus;
let claimingTransactionHash;
// For messages to claim on L1 we check if we need to claim with the new claiming method
// which requires the proof linked to this message
let proof;
if (otherLayer === NetworkLayer.L1) {
// Message from L2 to L1
status = (await l1ClaimingService?.getMessageStatus(message.messageHash)) || OnChainMessageStatus.UNKNOWN;
if (
status === OnChainMessageStatus.CLAIMABLE &&
(await l1ClaimingService?.isClaimingNeedingProof(message.messageHash))
) {
try {
proof = await l1ClaimingService?.getMessageProof(message.messageHash);
} catch (ex) {
// We ignore the error, the proof will stay undefined, we assume
// it's a message from the old message service
}
}
if (status === OnChainMessageStatus.CLAIMED) {
const [messageClaimedEvent] = await lineaSDKContracts.L1.getEvents(
lineaSDKContracts.L1.contract.filters.MessageClaimed(message.messageHash),
);
claimingTransactionHash = messageClaimedEvent ? messageClaimedEvent.transactionHash : undefined;
}
} else {
// Message from L1 to L2
status = await lineaSDKContracts.L2.getMessageStatus(message.messageHash);
const [messageClaimedEvent] = await lineaSDKContracts.L2.getEvents(
lineaSDKContracts.L2.contract.filters.MessageClaimed(message.messageHash),
);
claimingTransactionHash = messageClaimedEvent ? messageClaimedEvent.transactionHash : undefined;
}
// Convert the BigNumbers to string for serialization issue with the storage
const messageWithStatus: MessageWithStatus = {
calldata: message.calldata,
destination: message.destination,
fee: message.fee.toString(),
messageHash: message.messageHash,
messageNonce: message.messageNonce.toString(),
messageSender: message.messageSender,
status: status,
value: message.value.toString(),
proof,
claimingTransactionHash,
};
messagesWithStatuses.push(messageWithStatus);
});
await Promise.all(promises);
}
return messagesWithStatuses;
},
[lineaSDKContracts, getMessagesByTransactionHash, lineaSDK, networkType],
);
return { getMessageStatuses };
};
export default useMessageStatus;

View File

@@ -0,0 +1,47 @@
import { useState, useEffect, useCallback } from "react";
import { readContract } from "@wagmi/core";
import { useChainStore } from "@/stores/chainStore";
import { NetworkLayer, wagmiConfig } from "@/config";
import MessageService from "@/abis/MessageService.json";
import { getChainNetworkLayer } from "@/utils/chainsUtil";
const useMinimumFee = () => {
const [minimumFee, setMinimumFee] = useState(BigInt(0));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [error, setError] = useState<any | null>(null);
const { messageServiceAddress, fromChain } = useChainStore((state) => ({
messageServiceAddress: state.messageServiceAddress,
fromChain: state.fromChain,
}));
const fetchMinimumFee = useCallback(async () => {
if (!messageServiceAddress) {
return;
}
try {
let fees = BigInt(0);
if (fromChain && getChainNetworkLayer(fromChain) === NetworkLayer.L2) {
fees = (await readContract(wagmiConfig, {
address: messageServiceAddress,
abi: MessageService.abi,
functionName: "minimumFeeInWei",
chainId: fromChain.id,
})) as bigint;
}
setMinimumFee(fees);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
setError(err);
}
}, [messageServiceAddress, fromChain]);
useEffect(() => {
fetchMinimumFee();
}, [fetchMinimumFee]);
return { minimumFee, error };
};
export default useMinimumFee;

View File

@@ -0,0 +1,118 @@
import { useState, useCallback } from "react";
import log from "loglevel";
import { simulateContract, writeContract } from "@wagmi/core";
import { zeroAddress } from "viem";
import MessageService from "@/abis/MessageService.json";
import { config, wagmiConfig } from "@/config";
import { OnChainMessageStatus } from "@consensys/linea-sdk";
import { Proof } from "@consensys/linea-sdk/dist/lib/sdk/merkleTree/types";
import { TransactionHistory } from "@/models/history";
import { getChainNetworkLayer, getChainNetworkType } from "@/utils/chainsUtil";
import { useAccount } from "wagmi";
import { Transaction } from "@/models";
export interface MessageWithStatus {
status: OnChainMessageStatus;
messageSender: string;
destination: string;
fee: string;
value: string;
messageNonce: string;
calldata: string;
messageHash: string;
proof: Proof | undefined;
claimingTransactionHash?: string;
}
interface ClaimMessageWithProofParams {
proof: string[];
messageNumber: string;
leafIndex: number;
from: string;
to: string;
fee: string;
value: string;
feeRecipient: string;
merkleRoot: string;
data: string;
}
const useTransactionManagement = () => {
const { address } = useAccount();
const [transaction, setTransaction] = useState<Transaction | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const writeClaimMessage = useCallback(
async (message: MessageWithStatus, tx: TransactionHistory) => {
setError(null);
setIsLoading(true);
// Get the right message Service address depending on the transaction
const txNetworkLayer = getChainNetworkLayer(tx.toChain);
const txNetworkType = getChainNetworkType(tx.toChain);
if (address && txNetworkLayer && txNetworkType) {
try {
const { messageSender, destination, calldata, fee, messageNonce, value, proof } = message;
const messageServiceAddress = config.networks[txNetworkType][txNetworkLayer].messageServiceAddress;
if (messageServiceAddress === null) {
return;
}
let writeConfig;
if (!proof) {
// Claiming using old message service
writeConfig = await simulateContract(wagmiConfig, {
address: messageServiceAddress,
abi: MessageService.abi,
functionName: "claimMessage",
args: [messageSender, destination, fee, value, zeroAddress, calldata, messageNonce],
chainId: tx.toChain.id,
});
} else {
// Claiming on L1 with new message service
const params: ClaimMessageWithProofParams = {
data: calldata,
fee,
feeRecipient: zeroAddress,
from: messageSender,
to: destination,
leafIndex: proof.leafIndex,
merkleRoot: proof.root,
messageNumber: messageNonce,
proof: proof.proof,
value,
};
writeConfig = await simulateContract(wagmiConfig, {
address: messageServiceAddress,
abi: MessageService.abi,
functionName: "claimMessageWithProof",
args: [params],
chainId: tx.toChain.id,
});
}
const hash = await writeContract(wagmiConfig, writeConfig.request);
setTransaction({
txHash: hash,
chainId: tx.fromChain.id,
name: tx.fromChain.name,
});
} catch (error) {
log.error(error);
setError(error as Error);
setTransaction(null);
}
}
setIsLoading(false);
},
[address],
);
return { transaction, isLoading, isError: error !== null, error, writeClaimMessage };
};
export default useTransactionManagement;

View File

@@ -1,6 +0,0 @@
export interface Shortcut {
title: string;
description?: string;
logo: string;
ens_name: string;
}

View File

@@ -1,48 +0,0 @@
import axios, { AxiosResponse } from "axios";
import log from "loglevel";
interface CoinGeckoToken {
id: string;
symbol: string;
name: string;
}
interface CoinGeckoTokenDetail {
image: {
small: string;
};
}
async function fetchERC20Image(name: string) {
try {
if (!name) {
throw new Error("Name is required");
}
const coinsResponse: AxiosResponse<CoinGeckoToken[]> = await axios.get(
"https://api.coingecko.com/api/v3/coins/list",
);
const coin = coinsResponse.data.find((coin: CoinGeckoToken) => coin.name === name);
if (!coin) {
throw new Error("Coin not found");
}
const coinId = coin.id;
const coinDataResponse: AxiosResponse<CoinGeckoTokenDetail> = await axios.get(
`https://api.coingecko.com/api/v3/coins/${coinId}`,
);
if (!coinDataResponse.data.image.small) {
throw new Error("Image not found");
}
const image = coinDataResponse.data.image.small;
return image.split("?")[0];
} catch (error) {
log.warn(error);
return "/images/logo/noTokenLogo.svg";
}
}
export default fetchERC20Image;

View File

@@ -1,67 +0,0 @@
import { Address } from "viem";
import { GetTokenReturnType, getToken } from "@wagmi/core";
import { sepolia, linea, mainnet, lineaSepolia, Chain } from "viem/chains";
import log from "loglevel";
import fetchERC20Image from "@/services/fetchERC20Image";
import { NetworkType, TokenInfo, TokenType, wagmiConfig } from "@/config";
const fetchTokenInfo = async (
tokenAddress: Address,
networkType: NetworkType,
fromChain?: Chain,
): Promise<TokenInfo | undefined> => {
let erc20: GetTokenReturnType | undefined;
let chainFound;
if (!chainFound) {
const chains: Chain[] = networkType === NetworkType.SEPOLIA ? [lineaSepolia, sepolia] : [linea, mainnet];
// Put the fromChain arg at the begining to take it as priority
if (fromChain) chains.unshift(fromChain);
for (const chain of chains) {
try {
erc20 = await getToken(wagmiConfig, {
address: tokenAddress,
chainId: chain.id,
});
if (erc20.name) {
// Found the token if no errors with fetchToken
chainFound = chain;
break;
}
} catch (err) {
continue;
}
}
}
if (!erc20 || !chainFound || !erc20.name) {
return;
}
const L1Token = chainFound.id === mainnet.id || chainFound.id === sepolia.id;
// Fetch image
const name = erc20.name;
const image = await fetchERC20Image(name);
try {
return {
name,
symbol: erc20.symbol!,
decimals: erc20.decimals,
L1: L1Token ? tokenAddress : null,
L2: !L1Token ? tokenAddress : null,
image,
type: TokenType.ERC20,
UNKNOWN: null,
isDefault: false,
};
} catch (err) {
log.error(err);
return;
}
};
export default fetchTokenInfo;

View File

@@ -0,0 +1 @@
export { fetchERC20Image, fetchTokenInfo, getTokens, USDC_TYPE, CANONICAL_BRIDGED_TYPE } from "./tokenService";

View File

@@ -0,0 +1,139 @@
import axios, { AxiosResponse } from "axios";
import log from "loglevel";
import { Address } from "viem";
import { GetTokenReturnType, getToken } from "@wagmi/core";
import { sepolia, linea, mainnet, lineaSepolia, Chain } from "viem/chains";
import { NetworkType, TokenInfo, TokenType, wagmiConfig } from "@/config";
import { Token } from "@/models/token";
interface CoinGeckoToken {
id: string;
symbol: string;
name: string;
}
interface CoinGeckoTokenDetail {
image: {
small: string;
};
}
enum NetworkTypes {
MAINNET = "MAINNET",
SEPOLIA = "SEPOLIA",
}
export const CANONICAL_BRIDGED_TYPE = "canonical-bridge";
export const USDC_TYPE = "USDC";
export async function fetchERC20Image(name: string) {
try {
if (!name) {
throw new Error("Name is required");
}
const coinsResponse: AxiosResponse<CoinGeckoToken[]> = await axios.get(
"https://api.coingecko.com/api/v3/coins/list",
);
const coin = coinsResponse.data.find((coin: CoinGeckoToken) => coin.name === name);
if (!coin) {
throw new Error("Coin not found");
}
const coinId = coin.id;
const coinDataResponse: AxiosResponse<CoinGeckoTokenDetail> = await axios.get(
`https://api.coingecko.com/api/v3/coins/${coinId}`,
);
if (!coinDataResponse.data.image.small) {
throw new Error("Image not found");
}
const image = coinDataResponse.data.image.small;
return image.split("?")[0];
} catch (error) {
log.warn(error);
return "/images/logo/noTokenLogo.svg";
}
}
export async function fetchTokenInfo(
tokenAddress: Address,
networkType: NetworkType,
fromChain?: Chain,
): Promise<TokenInfo | undefined> {
let erc20: GetTokenReturnType | undefined;
let chainFound;
if (!chainFound) {
const chains: Chain[] = networkType === NetworkType.SEPOLIA ? [lineaSepolia, sepolia] : [linea, mainnet];
// Put the fromChain arg at the begining to take it as priority
if (fromChain) chains.unshift(fromChain);
for (const chain of chains) {
try {
erc20 = await getToken(wagmiConfig, {
address: tokenAddress,
chainId: chain.id,
});
if (erc20.name) {
// Found the token if no errors with fetchToken
chainFound = chain;
break;
}
} catch (err) {
continue;
}
}
}
if (!erc20 || !chainFound || !erc20.name) {
return;
}
const L1Token = chainFound.id === mainnet.id || chainFound.id === sepolia.id;
// Fetch image
const name = erc20.name;
const image = await fetchERC20Image(name);
try {
return {
name,
symbol: erc20.symbol!,
decimals: erc20.decimals,
L1: L1Token ? tokenAddress : null,
L2: !L1Token ? tokenAddress : null,
image,
type: TokenType.ERC20,
UNKNOWN: null,
isDefault: false,
};
} catch (err) {
log.error(err);
return;
}
}
export async function getTokens(networkTypes: NetworkTypes): Promise<Token[]> {
try {
// Fetch the JSON data from the URL.
let url = process.env.NEXT_PUBLIC_MAINNET_TOKEN_LIST ? (process.env.NEXT_PUBLIC_MAINNET_TOKEN_LIST as string) : "";
if (networkTypes === NetworkTypes.SEPOLIA) {
url = process.env.NEXT_PUBLIC_SEPOLIA_TOKEN_LIST ? (process.env.NEXT_PUBLIC_SEPOLIA_TOKEN_LIST as string) : "";
}
const response = await fetch(url);
const data = await response.json();
const tokens = data.tokens;
const bridgedTokens = tokens.filter(
(token: Token) => token.tokenType.includes(CANONICAL_BRIDGED_TYPE) || token.symbol === USDC_TYPE,
);
return bridgedTokens;
} catch (error) {
log.error("Error getTokens", { error });
return [];
}
}

View File

@@ -4,6 +4,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
export type ConfigState = {
agreeToTerms: boolean;
rehydrated: boolean;
};
export type ConfigActions = {
@@ -14,6 +15,7 @@ export type ConfigStore = ConfigState & ConfigActions;
export const defaultInitState: ConfigState = {
agreeToTerms: false,
rehydrated: false,
};
export const useConfigStore = create<ConfigStore>()(
@@ -29,6 +31,11 @@ export const useConfigStore = create<ConfigStore>()(
migrate: () => {
return defaultInitState;
},
onRehydrateStorage: () => (state) => {
if (state) {
state.rehydrated = true;
}
},
},
),
);

View File

@@ -14,6 +14,19 @@ export const getChainNetworkLayer = (chain: Chain) => {
return;
};
export const getChainNetworkLayerByChainId = (chainId: number) => {
switch (chainId) {
case linea.id:
case lineaSepolia.id:
return NetworkLayer.L2;
case mainnet.id:
case sepolia.id:
return NetworkLayer.L1;
}
return;
};
export const getChainNetworkType = (chain: Chain) => {
switch (chain.id) {
case linea.id:

View File

@@ -0,0 +1,38 @@
import BridgeIcon from "@/assets/icons/bridge.svg";
import TransactionsIcon from "@/assets/icons/transaction.svg";
import DocsIcon from "@/assets/icons/docs.svg";
import FaqIcon from "@/assets/icons/faq.svg";
export const MENU_ITEMS = [
{
title: "Bridge",
href: "/",
external: false,
Icon: BridgeIcon,
},
{
title: "Transactions",
href: "/transactions",
external: false,
Icon: TransactionsIcon,
},
{
title: "Docs",
href: "https://docs.linea.build/",
external: true,
Icon: DocsIcon,
},
{
title: "FAQ",
href: "https://support.linea.build/hc/en-us/categories/13281330249371-FAQs",
external: true,
Icon: FaqIcon,
},
];
export const NETWORK_ID_TO_NAME: Record<number, string> = {
59144: "Linea",
59141: "Linea Sepolia",
1: "Ethereum",
11155111: "Sepolia",
};

View File

@@ -1,3 +1,4 @@
import { formatDate, fromUnixTime } from "date-fns";
import { Address, getAddress } from "viem";
/**
@@ -11,6 +12,17 @@ export const formatAddress = (address: string | undefined, step = 5) => {
return address.substring(0, step) + "..." + address.substring(address.length - step, address.length);
};
/**
* Formats a hexadecimal string by truncating it and adding ellipsis in the middle.
* @param hexString - The hexadecimal string to format.
* @param step - The number of characters to keep at the beginning and end of the string.
* @returns The formatted hexadecimal string.
*/
export const formatHex = (hexString: string | undefined, step = 5) => {
if (!hexString) return "N/A";
return hexString.substring(0, step) + "..." + hexString.substring(hexString.length - step, hexString.length);
};
/**
* Format balance
* @param balance
@@ -34,3 +46,13 @@ export const formatBalance = (balance: string | undefined, precision = 4) => {
export const safeGetAddress = (address: Address | null): string | null => {
return address ? getAddress(address) : null;
};
/**
* Format timestamp
* @param timestamp
* @param formatStr
* @returns
*/
export const formatTimestamp = (timestamp: number, formatStr: string) => {
return formatDate(fromUnixTime(timestamp), formatStr);
};

View File

@@ -2,7 +2,7 @@ import { Chain, PublicClient, decodeAbiParameters, getAddress } from "viem";
import log from "loglevel";
import { NetworkTokens, NetworkType } from "@/config/config";
import { ERC20Event, ERC20V2Event } from "@/models";
import fetchTokenInfo from "@/services/fetchTokenInfo";
import { fetchTokenInfo } from "@/services";
import { TransactionHistory } from "@/models/history";
import { findTokenByAddress } from "./helpers";

4
bridge-ui/svg.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module "*.svg" {
import React from "react";
export const ReactComponent: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
}

View File

@@ -11,13 +11,12 @@ const config: Config = {
],
theme: {
extend: {
backgroundImage: {
hero: "url('/bridge_bg.png')",
},
colors: {
primary: "#61DFFF",
secondary: "#FF62E6",
card: "#505050",
cardBg: "#1D1D1D",
success: "#C1FF14",
},
fontFamily: {
atypText: ["var(--font-atyp-text)"],
@@ -31,8 +30,10 @@ const config: Config = {
dark: {
...daisyuiThemes.dark,
primary: "#61DFFF",
secondary: "#FF62E6",
"primary-content": "#000000",
info: "#fff",
info: "#61DFFF",
success: "#C1FF14",
},
},
],

View File

@@ -2,8 +2,10 @@ import { MetaMask, defineWalletSetup, getExtensionId } from "@synthetixio/synpre
import { LINEA_SEPOLIA_NETWORK, METAMASK_PASSWORD, METAMASK_SEED_PHRASE, TEST_PRIVATE_KEY } from "../constants";
export default defineWalletSetup(METAMASK_PASSWORD, async (context, walletPage) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
const extensionId = await getExtensionId(context, "MetaMask");
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
const metamask = new MetaMask(context, walletPage, METAMASK_PASSWORD, extensionId);

596
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff