From ee1a1b666c124751add3332ae6fe863ed33c6d8c Mon Sep 17 00:00:00 2001 From: mozluk <160273088+mozluk@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:11:54 +0300 Subject: [PATCH] Fix: Ensure cross-network Web3 integrity, handle async wallet transitions, and enforce strict TypeScript boundaries ### Description This PR addresses cross-network configuration bugs, unhandled asynchronous wallet states, and loose TypeScript boundaries within the Etherlink `bridge` repository. **Vulnerabilities & Security Defects Remediated:** * **Cross-Network Web3 Integrity (`src/lib/beacon/beacon.ts`):** Beacon permissions and wallet connections previously used a hardcoded Ghostnet RPC regardless of the selected network. Both flows have been updated to dynamically use the selected network and the `NEXT_PUBLIC_NODE_URL` environment variable, with a documented Ghostnet fallback. * **Async Error Propagation (`src/contexts/TezosContext/TezosContext.tsx`, `src/components/Header/Header.tsx`):** Wallet connections, disconnections, and network transitions previously detached promises, leaving rejected operations unhandled. The context provider now explicitly awaits cleanup and transition operations, and the header component intercepts rejected wallet actions to present a user-visible notification via Mantine. * **Strict TypeScript Boundaries (`src/components/Faucet/Faucet.tsx`, `src/contexts/TezosContext/TezosContext.tsx`, `src/lib/types/type-aliases.ts`):** Removed explicit `any` types from caught exceptions and Michelson map-key arrays. Caught errors are now typed as `unknown` and explicitly narrowed (`error instanceof Error`) before use. Map keys now use `Array`. Additionally, the Faucet component has been updated to report errors safely and reference the correct form field (address instead of email). --- src/components/Faucet/Faucet.tsx | 84 +++++++++------ src/components/Header/Header.tsx | 91 +++++++++++----- src/contexts/TezosContext/TezosContext.tsx | 119 +++++++++++---------- src/lib/beacon/beacon.ts | 27 ++--- src/lib/types/type-aliases.ts | 61 ++++++----- 5 files changed, 228 insertions(+), 154 deletions(-) diff --git a/src/components/Faucet/Faucet.tsx b/src/components/Faucet/Faucet.tsx index 2c7b1ec..fc053c5 100644 --- a/src/components/Faucet/Faucet.tsx +++ b/src/components/Faucet/Faucet.tsx @@ -1,49 +1,65 @@ -import axios from 'axios'; -import { TextInput, Text, Paper, Button, Divider, Anchor, Stack, Alert } from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { notifications } from '@mantine/notifications'; -import { IconAlertCircle, IconExternalLink, IconCheck, IconX } from '@tabler/icons-react'; +import axios from "axios"; +import { + TextInput, + Text, + Paper, + Button, + Divider, + Anchor, + Stack, + Alert, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { notifications } from "@mantine/notifications"; +import { + IconAlertCircle, + IconExternalLink, + IconCheck, + IconX, +} from "@tabler/icons-react"; -import { useConnection } from '@/contexts/TezosContext/TezosContext'; -import { WalletButton } from '@/components/WalletButton/WalletButton'; -import { TezosIcon } from '@/icons/TezosIcon/TezosIcon'; +import { useConnection } from "@/contexts/TezosContext/TezosContext"; +import { WalletButton } from "@/components/WalletButton/WalletButton"; +import { TezosIcon } from "@/icons/TezosIcon/TezosIcon"; export function Faucet() { const { address, connect } = useConnection(); const form = useForm({ initialValues: { - address: '', + address: "", }, }); const getTokens = async (targetAddress: string) => { try { notifications.show({ - id: 'faucet', - title: 'Sending...', - message: 'Your tokens are on the way! 🚀', + id: "faucet", + title: "Sending...", + message: "Your tokens are on the way! 🚀", loading: true, autoClose: false, }); await axios.get( - `https://faucet-bot.marigold.dev/network/ghostnet/getmoney/CTEZ/${targetAddress}` + `https://faucet-bot.marigold.dev/network/ghostnet/getmoney/CTEZ/${targetAddress}`, ); notifications.update({ - id: 'faucet', - title: 'Success!', - message: 'Check your wallet !', + id: "faucet", + title: "Success!", + message: "Check your wallet !", icon: , - color: 'teal', + color: "teal", }); - } catch (err: any) { + } catch (error: unknown) { notifications.update({ - id: 'faucet', - title: 'Error!', - message: `Something went wrong: ${(err as Error).message}`, + id: "faucet", + title: "Error!", + message: `Something went wrong: ${ + error instanceof Error ? error.message : "Unknown request error" + }`, icon: , - color: 'red', + color: "red", }); } }; @@ -53,7 +69,7 @@ export function Faucet() { } mb="1.5rem"> This faucet is provided by Marigold.
- {' '} + {" "} Check it out.
@@ -67,13 +83,19 @@ export function Faucet() { } - onClick={async () => getTokens(address)} + onClick={() => { + void getTokens(address); + }} > Request ctez tokens for {address} ) : ( - } onClick={connect}> + } + onClick={connect} + > Connect )} @@ -86,9 +108,7 @@ export function Faucet() {
{ - (async () => { - await getTokens(values.address); - })(); + void getTokens(values.address); })} > @@ -97,8 +117,10 @@ export function Faucet() { label="Tezos address" placeholder="tz1..." value={form.values.address} - onChange={(event) => form.setFieldValue('address', event.currentTarget.value)} - error={form.errors.email && 'Invalid address'} + onChange={(event) => + form.setFieldValue("address", event.currentTarget.value) + } + error={form.errors.address && "Invalid address"} radius="xl" size="lg" /> diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 7c8f77c..fe45e39 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -10,7 +10,7 @@ import { Button, useMantineColorScheme, Badge, -} from '@mantine/core'; +} from "@mantine/core"; import { IconSun, IconMoonStars, @@ -18,46 +18,53 @@ import { IconChevronDown, IconSwitchHorizontal, IconNetwork, -} from '@tabler/icons-react'; -import { NetworkType } from '@airgap/beacon-types'; +} from "@tabler/icons-react"; +import { NetworkType } from "@airgap/beacon-types"; +import { notifications } from "@mantine/notifications"; -import { useConnection } from '@/contexts/TezosContext/TezosContext'; -import { TezosIcon } from '@/icons/TezosIcon/TezosIcon'; -import { WalletButton } from '@/components/WalletButton/WalletButton'; -import { shortAddress } from '@/lib/utils/utils'; +import { useConnection } from "@/contexts/TezosContext/TezosContext"; +import { TezosIcon } from "@/icons/TezosIcon/TezosIcon"; +import { WalletButton } from "@/components/WalletButton/WalletButton"; +import { shortAddress } from "@/lib/utils/utils"; const useStyles = createStyles((theme) => ({ inner: { height: rem(56), - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', + display: "flex", + justifyContent: "space-between", + alignItems: "center", }, links: { - [theme.fn.smallerThan('sm')]: { - display: 'none', + [theme.fn.smallerThan("sm")]: { + display: "none", }, }, burger: { - [theme.fn.largerThan('sm')]: { - display: 'none', + [theme.fn.largerThan("sm")]: { + display: "none", }, }, link: { - display: 'block', + display: "block", lineHeight: 1, padding: `${rem(8)} ${rem(12)}`, borderRadius: theme.radius.sm, - textDecoration: 'none', - color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.colors.gray[7], + textDecoration: "none", + color: + theme.colorScheme === "dark" + ? theme.colors.dark[0] + : theme.colors.gray[7], fontSize: theme.fontSizes.sm, fontWeight: 500, - '&:hover': { - backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0], + "&:hover": { + backgroundColor: + theme.colorScheme === "dark" + ? theme.colors.dark[6] + : theme.colors.gray[0], }, }, @@ -71,6 +78,18 @@ export function Header() { const { classes } = useStyles(); const { address, connect, disconnect, network } = useConnection(); + const runWalletAction = (action: () => Promise): void => { + void action().catch((error: unknown) => { + notifications.show({ + id: "wallet-action-error", + title: "Wallet action failed", + message: + error instanceof Error ? error.message : "Unknown wallet error", + color: "red", + }); + }); + }; + return ( @@ -78,7 +97,7 @@ export function Header() { Etherlink Bridge - {network === NetworkType.MAINNET ? 'Mainnet' : 'Testnet'} + {network === NetworkType.MAINNET ? "Mainnet" : "Testnet"} @@ -88,7 +107,9 @@ export function Header() { variant="default" color="gray" radius="xl" - leftIcon={} + leftIcon={ + + } rightIcon={} disabled > @@ -110,20 +131,27 @@ export function Header() { - } onClick={connect}> + } + onClick={() => runWalletAction(connect)} + > Switch Account } - onClick={disconnect} + onClick={() => runWalletAction(disconnect)} > Disconnect ) : ( - } onClick={connect}> + } + onClick={() => runWalletAction(connect)} + > Connect )} @@ -133,11 +161,20 @@ export function Header() { radius="xl" sx={(theme) => ({ backgroundColor: - theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0], - color: theme.colorScheme === 'dark' ? theme.colors.yellow[4] : theme.colors.blue[6], + theme.colorScheme === "dark" + ? theme.colors.dark[6] + : theme.colors.gray[0], + color: + theme.colorScheme === "dark" + ? theme.colors.yellow[4] + : theme.colors.blue[6], })} > - {colorScheme === 'dark' ? : } + {colorScheme === "dark" ? ( + + ) : ( + + )} diff --git a/src/contexts/TezosContext/TezosContext.tsx b/src/contexts/TezosContext/TezosContext.tsx index ddf49d6..3ae6151 100644 --- a/src/contexts/TezosContext/TezosContext.tsx +++ b/src/contexts/TezosContext/TezosContext.tsx @@ -9,86 +9,89 @@ * All modifications are licensed under: MIT */ -// todo: this shouldnt know about nextjs -// the email modal should live in @kanvas/client/ui -// and we should simply handle it here -import { Context, createContext, useContext, useEffect, useState } from 'react'; -// import { useCookies } from 'react-cookie' -import { setCookie } from 'cookies-next'; -import { NetworkType } from '@airgap/beacon-types'; +import { createContext, useContext, useEffect, useState } from "react"; +import type { ReactNode } from "react"; +import { setCookie } from "cookies-next"; +import { NetworkType } from "@airgap/beacon-types"; -import { connectBeacon } from '@/lib/beacon/beacon'; -import { WalletApi } from '@/lib/beacon/beacon-types'; +import { connectBeacon } from "@/lib/beacon/beacon"; +import { WalletApi } from "@/lib/beacon/beacon-types"; interface ConnectionContextType extends Partial { connect: () => Promise; - network: string; - setNetwork: (network: NetworkType) => void; + network: NetworkType; + setNetwork: (network: NetworkType) => Promise; } const ConnectionContext = createContext(null); -export const ConnectionProvider = ({ children }: { children: any }) => { +export const ConnectionProvider = ({ children }: { children: ReactNode }) => { const [wallet, setWallet] = useState(); - const [network, setNetwork] = useState(NetworkType.GHOSTNET); + const [network, setNetworkState] = useState( + NetworkType.GHOSTNET, + ); - const setWalletCookie = (address: string | undefined) => { - setCookie('viewer-address', address, { + const setWalletCookie = (address: string | undefined): void => { + setCookie("viewer-address", address, { maxAge: 60 * 60 * 24 * 30, }); }; - useEffect(() => { - setWalletCookie(wallet?.address); - }, [wallet?.address]); + const disconnect = async (): Promise => { + const activeWallet = wallet; + setWallet(undefined); + setWalletCookie(undefined); + await activeWallet?.disconnect(); + }; useEffect(() => { - connectBeacon(false, network) - .then(setWallet) + let cancelled = false; + + void connectBeacon(false, network) + .then((nextWallet) => { + if (!cancelled) { + setWallet(nextWallet); + } + }) .catch(() => { - console.log('no existing beacon connection'); + if (!cancelled) { + console.info("No existing Beacon connection was found."); + } }); - }, []); - const onInitialConnectionComplete = async (walletApi: WalletApi): Promise => { - setWallet(walletApi); - // const { address, connection } = wallet; - // return connection; - }; + return () => { + cancelled = true; + }; + }, [network]); - // eslint-disable-next-line @typescript-eslint/require-await - const disconnect = async function () { - console.log('disconnecting'); - setWallet(undefined); - setWalletCookie(undefined); + useEffect(() => { + setWalletCookie(wallet?.address); + }, [wallet?.address]); + + const connect = async (): Promise => { + try { + const connectedWallet = await connectBeacon(true, network); + setWallet(connectedWallet); + } catch (error: unknown) { + await disconnect(); + const reason = + error instanceof Error ? error.message : "Unknown connection error"; + throw new Error( + `Error connecting to wallet. Please try again later: ${reason}`, + ); + } }; return ( - connectBeacon(true, network) - .then(onInitialConnectionComplete) - .catch((err) => { - disconnect(); - throw new Error(`Error connecting to wallet, please try again later ${err.message}`); - }), + connect, ...wallet, - disconnect: async () => { - await disconnect(); - await wallet?.disconnect(); - }, + disconnect, network, - setNetwork: async (newNetwork: NetworkType) => { - setNetwork(newNetwork); + setNetwork: async (newNetwork: NetworkType): Promise => { await disconnect(); - await wallet?.disconnect(); - connectBeacon(true, newNetwork) - .then(onInitialConnectionComplete) - .catch(() => { - disconnect(); - throw new Error('Error connecting to wallet, please try again later'); - }); + setNetworkState(newNetwork); }, }} > @@ -97,13 +100,13 @@ export const ConnectionProvider = ({ children }: { children: any }) => { ); }; -export type NotNothing = T extends null | undefined ? never : T; - /** - * Managing wallet connects and disconnects with kukai and beacon - * and registering the connection with kanvas + * Provides access to the active wallet connection and network controls. */ export const useConnection = (): ConnectionContextType => { - if (!ConnectionContext) throw new Error('WalletContext not initialized'); - return useContext(ConnectionContext as NotNothing>); + const context = useContext(ConnectionContext); + if (!context) { + throw new Error("Wallet context is not initialized."); + } + return context; }; diff --git a/src/lib/beacon/beacon.ts b/src/lib/beacon/beacon.ts index a4f585b..75ee0d7 100644 --- a/src/lib/beacon/beacon.ts +++ b/src/lib/beacon/beacon.ts @@ -9,20 +9,22 @@ * All modifications are licensed under: MIT */ /* eslint-disable @typescript-eslint/no-use-before-define */ -import { BeaconWallet } from '@taquito/beacon-wallet'; -import { PermissionScope, NetworkType } from '@airgap/beacon-types'; +import { BeaconWallet } from "@taquito/beacon-wallet"; +import { PermissionScope, NetworkType } from "@airgap/beacon-types"; -import { MichelCodecPacker, TezosToolkit } from '@taquito/taquito'; -import { ConnectFn } from './beacon-types'; +import { MichelCodecPacker, TezosToolkit } from "@taquito/taquito"; +import { ConnectFn } from "./beacon-types"; const createBeaconWallet = (network: NetworkType): BeaconWallet | undefined => - typeof window === 'undefined' + typeof window === "undefined" ? undefined : new BeaconWallet({ - name: 'Etherlink Bridge', + name: "Etherlink Bridge", network: { type: network, - rpcUrl: 'https://rpc.ghostnet.teztnets.com', + rpcUrl: + process.env.NEXT_PUBLIC_NODE_URL || + "https://rpc.ghostnet.teztnets.com", }, // featuredWallets: ['kukai', 'trust', 'temple', 'umami'], }); @@ -40,7 +42,7 @@ export const connectBeacon: ConnectFn = async (isNew, network) => { address: acc.address, connection: { imageUrl: undefined, - connectionType: 'beacon', + connectionType: "beacon", name: undefined, }, // callcontract: callContractBeaconFn(existingWallet), @@ -54,13 +56,14 @@ export const connectBeacon: ConnectFn = async (isNew, network) => { tezosToolkit.setWalletProvider(beaconWallet); if (!beaconWallet) { - throw new Error('Tried to connect on the server'); + throw new Error("Tried to connect on the server"); } const response = await beaconWallet.client.requestPermissions({ network: { - type: NetworkType.GHOSTNET, - rpcUrl: process.env.NEXT_PUBLIC_NODE_URL, + type: network, + rpcUrl: + process.env.NEXT_PUBLIC_NODE_URL || "https://rpc.ghostnet.teztnets.com", }, scopes: [PermissionScope.OPERATION_REQUEST], }); @@ -79,6 +82,6 @@ export const connectBeacon: ConnectFn = async (isNew, network) => { }; export const tezosToolkit = new TezosToolkit( - process.env.NEXT_PUBLIC_NODE_URL || 'https://rpc.ghostnet.teztnets.com' + process.env.NEXT_PUBLIC_NODE_URL || "https://rpc.ghostnet.teztnets.com", ); tezosToolkit.setPackerProvider(new MichelCodecPacker()); diff --git a/src/lib/types/type-aliases.ts b/src/lib/types/type-aliases.ts index e2b6f5d..5667748 100644 --- a/src/lib/types/type-aliases.ts +++ b/src/lib/types/type-aliases.ts @@ -1,36 +1,42 @@ -import { assertMichelsonInstruction, Expr, MichelsonCode } from '@taquito/michel-codec'; -import { MichelsonMap } from '@taquito/taquito'; -import { BigNumber } from 'bignumber.js'; +import { + assertMichelsonInstruction, + Expr, + MichelsonCode, +} from "@taquito/michel-codec"; +import { MichelsonMap } from "@taquito/taquito"; +import { BigNumber } from "bignumber.js"; export type Instruction = MichelsonCode; -export type unit = (true | undefined) & { __type: 'unit' }; +export type unit = (true | undefined) & { __type: "unit" }; -export type address = string & { __type: 'address' }; -export type bytes = string & { __type: 'bytes' }; -export type contract = string & { __type: 'contract' }; -export type operation = string & { __type: 'operation' }; -export type key = string & { __type: 'key' }; -export type key_hash = string & { __type: 'key_hash' }; -export type signature = string & { __type: 'signature' }; -export type ticket = string & { __type: 'ticket' }; +export type address = string & { __type: "address" }; +export type bytes = string & { __type: "bytes" }; +export type contract = string & { __type: "contract" }; +export type operation = string & { __type: "operation" }; +export type key = string & { __type: "key" }; +export type key_hash = string & { __type: "key_hash" }; +export type signature = string & { __type: "signature" }; +export type ticket = string & { __type: "ticket" }; -export type timestamp = string & { __type: 'timestamp' }; +export type timestamp = string & { __type: "timestamp" }; -export type int = BigNumber & { __type: 'int' }; -export type nat = BigNumber & { __type: 'nat' }; +export type int = BigNumber & { __type: "int" }; +export type nat = BigNumber & { __type: "nat" }; -export type mutez = BigNumber & { __type: 'mutez' }; -export type tez = BigNumber & { __type: 'tez' }; +export type mutez = BigNumber & { __type: "mutez" }; +export type tez = BigNumber & { __type: "tez" }; -type MapKey = Array | object | string | boolean | number; -export type MMap = Omit, 'get'> & { get: (key: K) => V }; -export type BigMap = Omit, 'get'> & { +type MapKey = Array | object | string | boolean | number; +export type MMap = Omit, "get"> & { + get: (key: K) => V; +}; +export type BigMap = Omit, "get"> & { get: (key: K) => Promise; }; -export type chest = string & { __type: 'chest' }; -export type chest_key = string & { __type: 'chest_key' }; +export type chest = string & { __type: "chest" }; +export type chest_key = string & { __type: "chest_key" }; const createStringTypeTas = () => @@ -46,8 +52,8 @@ const createBigNumberTypeTas = type asMapParamOf = K extends string ? { [key: string]: V } | Array<{ key: K; value: V }> : K extends number - ? { [key: number]: V } | Array<{ key: K; value: V }> - : Array<{ key: K; value: V }>; + ? { [key: number]: V } | Array<{ key: K; value: V }> + : Array<{ key: K; value: V }>; function asMap(value: asMapParamOf): MMap { const m = new MichelsonMap(); @@ -56,7 +62,9 @@ function asMap(value: asMapParamOf): MMap { vArray.forEach((x) => m.set(x.key, x.value)); } else { const vObject = value as { [key: string]: V }; - Object.keys(vObject).forEach((key) => m.set(key as unknown as K, vObject[key])); + Object.keys(vObject).forEach((key) => + m.set(key as unknown as K, vObject[key]), + ); } return m as MMap; } @@ -82,7 +90,8 @@ export const tas = { contract: createStringTypeTas(), chest: createStringTypeTas(), chest_key: createStringTypeTas(), - timestamp: (value: string | Date): timestamp => new Date(value).toISOString() as timestamp, + timestamp: (value: string | Date): timestamp => + new Date(value).toISOString() as timestamp, int: createBigNumberTypeTas(), nat: createBigNumberTypeTas(),