mirror of
https://github.com/vacp2p/linea-monorepo.git
synced 2026-01-09 04:08:01 -05:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -6,4 +6,4 @@ sgr0 := $(shell tput sgr0)
|
||||
|
||||
.PHONY: dev
|
||||
dev:
|
||||
npm run dev
|
||||
pnpm run dev
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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: "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
27
bridge-ui/src/app/transactions/page.tsx
Normal file
27
bridge-ui/src/app/transactions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
bridge-ui/src/assets/icons/bridge.svg
Normal file
22
bridge-ui/src/assets/icons/bridge.svg
Normal 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 |
13
bridge-ui/src/assets/icons/docs.svg
Normal file
13
bridge-ui/src/assets/icons/docs.svg
Normal 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 |
15
bridge-ui/src/assets/icons/faq.svg
Normal file
15
bridge-ui/src/assets/icons/faq.svg
Normal 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 |
32
bridge-ui/src/assets/icons/swap.svg
Normal file
32
bridge-ui/src/assets/icons/swap.svg
Normal 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 |
27
bridge-ui/src/assets/icons/transaction.svg
Normal file
27
bridge-ui/src/assets/icons/transaction.svg
Normal 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 |
14
bridge-ui/src/components/ConnectButton.tsx
Normal file
14
bridge-ui/src/components/ConnectButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
78
bridge-ui/src/components/layouts/MobileMenu.tsx
Normal file
78
bridge-ui/src/components/layouts/MobileMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
bridge-ui/src/components/layouts/Modal.tsx
Normal file
32
bridge-ui/src/components/layouts/Modal.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
68
bridge-ui/src/components/layouts/Sidebar.tsx
Normal file
68
bridge-ui/src/components/layouts/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
bridge-ui/src/components/layouts/header/Header.tsx
Normal file
62
bridge-ui/src/components/layouts/header/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
12
bridge-ui/src/components/transactions/NoTransaction.tsx
Normal file
12
bridge-ui/src/components/transactions/NoTransaction.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
bridge-ui/src/components/transactions/StatusText.tsx
Normal file
25
bridge-ui/src/components/transactions/StatusText.tsx
Normal 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);
|
||||
}
|
||||
78
bridge-ui/src/components/transactions/TransactionItem.tsx
Normal file
78
bridge-ui/src/components/transactions/TransactionItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
bridge-ui/src/components/transactions/TransactionProgressBar.tsx
Normal file
137
bridge-ui/src/components/transactions/TransactionProgressBar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
93
bridge-ui/src/components/transactions/Transactions.tsx
Normal file
93
bridge-ui/src/components/transactions/Transactions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
bridge-ui/src/components/transactions/index.tsx
Normal file
2
bridge-ui/src/components/transactions/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./NoTransaction";
|
||||
export * from "./Transactions";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
46
bridge-ui/src/contexts/modal.context.tsx
Normal file
46
bridge-ui/src/contexts/modal.context.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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/...`
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
52
bridge-ui/src/hooks/useLineaSDK.ts
Normal file
52
bridge-ui/src/hooks/useLineaSDK.ts
Normal 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;
|
||||
@@ -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;
|
||||
102
bridge-ui/src/hooks/useMessageStatus.ts
Normal file
102
bridge-ui/src/hooks/useMessageStatus.ts
Normal 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;
|
||||
47
bridge-ui/src/hooks/useMinimumFee.ts
Normal file
47
bridge-ui/src/hooks/useMinimumFee.ts
Normal 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;
|
||||
118
bridge-ui/src/hooks/useTransactionManagement.ts
Normal file
118
bridge-ui/src/hooks/useTransactionManagement.ts
Normal 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;
|
||||
@@ -1,6 +0,0 @@
|
||||
export interface Shortcut {
|
||||
title: string;
|
||||
description?: string;
|
||||
logo: string;
|
||||
ens_name: string;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
1
bridge-ui/src/services/index.ts
Normal file
1
bridge-ui/src/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { fetchERC20Image, fetchTokenInfo, getTokens, USDC_TYPE, CANONICAL_BRIDGED_TYPE } from "./tokenService";
|
||||
139
bridge-ui/src/services/tokenService.ts
Normal file
139
bridge-ui/src/services/tokenService.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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:
|
||||
|
||||
38
bridge-ui/src/utils/constants.ts
Normal file
38
bridge-ui/src/utils/constants.ts
Normal 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",
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
4
bridge-ui/svg.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module "*.svg" {
|
||||
import React from "react";
|
||||
export const ReactComponent: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
596
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user