From e6147b2a9c92369d2ca26c60275c766da1a7d0d5 Mon Sep 17 00:00:00 2001 From: Kevin Ingersoll Date: Fri, 13 Sep 2024 12:00:20 +0000 Subject: [PATCH] feat(explorer): anvil connector, connect external wallets (#3164) Co-authored-by: karooolis --- .changeset/angry-bats-drive.md | 5 ++ .../explorer/src/app/(explorer)/Providers.tsx | 14 ++-- .../explore/EditableTableCell.tsx | 16 ++-- .../interact/useContractMutation.ts | 15 ++-- packages/explorer/src/app/api/world/route.ts | 7 +- packages/explorer/src/bin/explorer.ts | 8 ++ packages/explorer/src/common.ts | 22 +++++ .../explorer/src/components/AccountSelect.tsx | 81 +++++++++++++----- .../explorer/src/components/ConnectButton.tsx | 59 +++++++++++++ .../explorer/src/components/Navigation.tsx | 4 +- .../explorer/src/components/ui/Button.tsx | 4 +- packages/explorer/src/connectors/anvil.ts | 83 +++++++++++++++++++ packages/explorer/src/consts.ts | 27 ------ .../explorer/src/store/AppStoreProvider.tsx | 21 ----- packages/explorer/src/store/createAppStore.ts | 15 ---- packages/explorer/src/store/index.ts | 3 - packages/explorer/src/store/useAppStore.ts | 13 --- 17 files changed, 268 insertions(+), 129 deletions(-) create mode 100644 .changeset/angry-bats-drive.md create mode 100644 packages/explorer/src/common.ts create mode 100644 packages/explorer/src/components/ConnectButton.tsx create mode 100644 packages/explorer/src/connectors/anvil.ts delete mode 100644 packages/explorer/src/consts.ts delete mode 100644 packages/explorer/src/store/AppStoreProvider.tsx delete mode 100644 packages/explorer/src/store/createAppStore.ts delete mode 100644 packages/explorer/src/store/index.ts delete mode 100644 packages/explorer/src/store/useAppStore.ts diff --git a/.changeset/angry-bats-drive.md b/.changeset/angry-bats-drive.md new file mode 100644 index 0000000000..08365d509a --- /dev/null +++ b/.changeset/angry-bats-drive.md @@ -0,0 +1,5 @@ +--- +"@latticexyz/explorer": patch +--- + +World Explorer now supports connecting external wallets. diff --git a/packages/explorer/src/app/(explorer)/Providers.tsx b/packages/explorer/src/app/(explorer)/Providers.tsx index 1118b046c0..b2d407c0be 100644 --- a/packages/explorer/src/app/(explorer)/Providers.tsx +++ b/packages/explorer/src/app/(explorer)/Providers.tsx @@ -6,13 +6,14 @@ import { ReactNode } from "react"; import { RainbowKitProvider, darkTheme } from "@rainbow-me/rainbowkit"; import "@rainbow-me/rainbowkit/styles.css"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { anvil } from "@wagmi/core/chains"; -import { AppStoreProvider } from "../../store"; +import { getChain } from "../../common"; +import { defaultAnvilConnectors } from "../../connectors/anvil"; const queryClient = new QueryClient(); +const chain = getChain(); export const wagmiConfig = createConfig({ - chains: [anvil], + chains: [chain], connectors: [ injected(), metaMask({ @@ -21,9 +22,10 @@ export const wagmiConfig = createConfig({ }, }), safe(), + ...defaultAnvilConnectors, ], transports: { - [anvil.id]: http(), + [chain.id]: http(), }, ssr: true, }); @@ -32,9 +34,7 @@ export function Providers({ children }: { children: ReactNode }) { return ( - - {children} - + {children} ); diff --git a/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explore/EditableTableCell.tsx b/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explore/EditableTableCell.tsx index 4b3a058a62..45f493260f 100644 --- a/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explore/EditableTableCell.tsx +++ b/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explore/EditableTableCell.tsx @@ -2,18 +2,16 @@ import { Loader } from "lucide-react"; import { useParams } from "next/navigation"; import { toast } from "sonner"; import { Hex } from "viem"; -import { privateKeyToAccount } from "viem/accounts"; -import { useChainId } from "wagmi"; +import { useAccount } from "wagmi"; import { ChangeEvent, useState } from "react"; import { encodeField, getFieldIndex } from "@latticexyz/protocol-parser/internal"; import { SchemaAbiType } from "@latticexyz/schema-type/internal"; import IBaseWorldAbi from "@latticexyz/world/out/IBaseWorld.sol/IBaseWorld.abi.json"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { waitForTransactionReceipt, writeContract } from "@wagmi/core"; +import { getChain } from "../../../../../common"; import { Checkbox } from "../../../../../components/ui/Checkbox"; -import { ACCOUNT_PRIVATE_KEYS } from "../../../../../consts"; import { camelCase, cn } from "../../../../../lib/utils"; -import { useAppStore } from "../../../../../store"; import { TableConfig } from "../../../../api/table/route"; import { wagmiConfig } from "../../../Providers"; @@ -24,11 +22,13 @@ type Props = { config: TableConfig; }; +const chain = getChain(); +const chainId = chain.id; + export function EditableTableCell({ name, config, keyTuple, value: defaultValue }: Props) { const queryClient = useQueryClient(); - const chainId = useChainId(); - const { account } = useAppStore(); const { worldAddress } = useParams(); + const account = useAccount(); const [value, setValue] = useState(defaultValue); @@ -40,11 +40,11 @@ export function EditableTableCell({ name, config, keyTuple, value: defaultValue const fieldIndex = getFieldIndex(config?.value_schema, camelCase(name)); const encodedField = encodeField(fieldType, newValue); const txHash = await writeContract(wagmiConfig, { - account: privateKeyToAccount(ACCOUNT_PRIVATE_KEYS[account]), abi: IBaseWorldAbi, address: worldAddress as Hex, functionName: "setField", args: [tableId, keyTuple, fieldIndex, encodedField], + chainId, }); const receipt = await waitForTransactionReceipt(wagmiConfig, { @@ -67,7 +67,7 @@ export function EditableTableCell({ name, config, keyTuple, value: defaultValue queryKey: [ "balance", { - address: account, + address: account.address, chainId, }, ], diff --git a/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/interact/useContractMutation.ts b/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/interact/useContractMutation.ts index 74e24845e0..b877bfb81c 100644 --- a/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/interact/useContractMutation.ts +++ b/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/interact/useContractMutation.ts @@ -1,12 +1,10 @@ import { useParams } from "next/navigation"; import { toast } from "sonner"; import { Abi, AbiFunction, Hex } from "viem"; -import { privateKeyToAccount } from "viem/accounts"; -import { useChainId } from "wagmi"; +import { useAccount } from "wagmi"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { readContract, waitForTransactionReceipt, writeContract } from "@wagmi/core"; -import { ACCOUNT_PRIVATE_KEYS } from "../../../../../consts"; -import { useAppStore } from "../../../../../store"; +import { getChain } from "../../../../../common"; import { wagmiConfig } from "../../../Providers"; import { FunctionType } from "./FunctionField"; @@ -15,11 +13,13 @@ type UseContractMutationProps = { operationType: FunctionType; }; +const chain = getChain(); +const chainId = chain.id; + export function useContractMutation({ abi, operationType }: UseContractMutationProps) { const queryClient = useQueryClient(); - const chainId = useChainId(); - const { account } = useAppStore(); const { worldAddress } = useParams(); + const account = useAccount(); return useMutation({ mutationFn: async ({ inputs, value }: { inputs: unknown[]; value?: string }) => { @@ -29,17 +29,18 @@ export function useContractMutation({ abi, operationType }: UseContractMutationP address: worldAddress as Hex, functionName: abi.name, args: inputs, + chainId, }); return { result }; } else { const txHash = await writeContract(wagmiConfig, { - account: privateKeyToAccount(ACCOUNT_PRIVATE_KEYS[account]), abi: [abi] as Abi, address: worldAddress as Hex, functionName: abi.name, args: inputs, ...(value && { value: BigInt(value) }), + chainId, }); const receipt = await waitForTransactionReceipt(wagmiConfig, { diff --git a/packages/explorer/src/app/api/world/route.ts b/packages/explorer/src/app/api/world/route.ts index 843538ef6b..a0be3b52c2 100644 --- a/packages/explorer/src/app/api/world/route.ts +++ b/packages/explorer/src/app/api/world/route.ts @@ -1,17 +1,16 @@ import { AbiFunction, Address, Hex, createWalletClient, http, parseAbi } from "viem"; import { getBlockNumber, getLogs } from "viem/actions"; -import { getRpcUrl } from "@latticexyz/common/foundry"; import { helloStoreEvent } from "@latticexyz/store"; import { helloWorldEvent } from "@latticexyz/world"; import { getWorldAbi } from "@latticexyz/world/internal"; +import { getChain } from "../../../common"; export const dynamic = "force-dynamic"; async function getClient() { - const profile = process.env.FOUNDRY_PROFILE; - const rpc = await getRpcUrl(profile); const client = createWalletClient({ - transport: http(rpc), + chain: getChain(), + transport: http(), }); return client; diff --git a/packages/explorer/src/bin/explorer.ts b/packages/explorer/src/bin/explorer.ts index 88f887ba32..8362863d61 100755 --- a/packages/explorer/src/bin/explorer.ts +++ b/packages/explorer/src/bin/explorer.ts @@ -6,6 +6,7 @@ import process from "process"; import { fileURLToPath } from "url"; import yargs from "yargs"; import { ChildProcess, spawn } from "child_process"; +import { chains } from "../common"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -55,6 +56,12 @@ const argv = yargs(process.argv.slice(2)) default: process.env.WORLD_ADDRESS, }, }) + .check((argv) => { + if (!chains[Number(argv.chainId)]) { + throw new Error(`Invalid chain ID. Supported chains are: ${Object.keys(chains).join(", ")}.`); + } + return true; + }) .parseSync(); const { port, hostname, chainId, indexerDatabase, worldsFile, dev } = argv; @@ -64,6 +71,7 @@ let explorerProcess: ChildProcess; async function startExplorer() { const env = { ...process.env, + NEXT_PUBLIC_CHAIN_ID: chainId.toString(), WORLD_ADDRESS: worldAddress?.toString(), INDEXER_DATABASE: path.join(process.cwd(), indexerDatabase), }; diff --git a/packages/explorer/src/common.ts b/packages/explorer/src/common.ts new file mode 100644 index 0000000000..22e27c0596 --- /dev/null +++ b/packages/explorer/src/common.ts @@ -0,0 +1,22 @@ +import { Chain, anvil, garnet, redstone } from "viem/chains"; + +export const chains: Partial> = { + [anvil.id]: anvil, + [redstone.id]: redstone, + [garnet.id]: garnet, +}; + +export function getChain() { + const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || anvil.id); + const chain = chains[chainId]; + if (!chain) { + throw new Error(`Chain ID ${chainId} not supported. Supported chains are: ${Object.keys(chains).join(", ")}.`); + } + + return chain; +} + +export function isAnvil() { + const chain = getChain(); + return chain.id === anvil.id; +} diff --git a/packages/explorer/src/components/AccountSelect.tsx b/packages/explorer/src/components/AccountSelect.tsx index 415b4be193..7d2f28e471 100644 --- a/packages/explorer/src/components/AccountSelect.tsx +++ b/packages/explorer/src/components/AccountSelect.tsx @@ -1,41 +1,82 @@ -import { Address } from "viem"; -import { useBalance } from "wagmi"; -import { ACCOUNTS } from "../consts"; +import { CirclePlusIcon, PlugIcon } from "lucide-react"; +import { useAccount, useBalance, useConnect, useConnectors } from "wagmi"; +import { useState } from "react"; +import { useConnectModal } from "@rainbow-me/rainbowkit"; +import { AnvilConnector, isAnvilConnector } from "../connectors/anvil"; import { formatBalance } from "../lib/utils"; -import { useAppStore } from "../store"; +import { Button } from "./ui/Button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/Select"; import { TruncatedHex } from "./ui/TruncatedHex"; -function AccountSelectItem({ address, name }: { address: Address; name: string }) { - const balance = useBalance({ +function AccountSelectItem({ connector }: { connector: AnvilConnector }) { + const address = connector.accounts[0].address; + const { data: balance } = useBalance({ address, query: { refetchInterval: 15000, + select: (data) => { + return data?.value; + }, + enabled: !!address, }, }); - const balanceValue = balance.data?.value; + return ( - - {name} - {balanceValue !== undefined && ` (${formatBalance(balanceValue)} ETH)`}{" "} - - () - + + {connector.name} + {balance !== undefined && ` (${formatBalance(balance)} ETH)`}{" "} + {address && ( + + () + + )} ); } export function AccountSelect() { - const { account, setAccount } = useAppStore(); + const [open, setOpen] = useState(false); + const { connector } = useAccount(); + const { connect } = useConnect(); + const { openConnectModal } = useConnectModal(); + const configuredConnectors = useConnectors(); + const connectors = [...configuredConnectors.filter(isAnvilConnector)]; + return ( - { + const connector = connectors.find((connector) => connector.id === connectorId); + if (connector) { + connect({ connector }); + } + }} + > + + - {ACCOUNTS.map((address, index) => { - return ; + {connectors.map((connector) => { + return ; })} + + ); diff --git a/packages/explorer/src/components/ConnectButton.tsx b/packages/explorer/src/components/ConnectButton.tsx new file mode 100644 index 0000000000..5b544254f4 --- /dev/null +++ b/packages/explorer/src/components/ConnectButton.tsx @@ -0,0 +1,59 @@ +import { PlugIcon, ZapIcon } from "lucide-react"; +import { ConnectButton as RainbowConnectButton } from "@rainbow-me/rainbowkit"; +import { isAnvil } from "../common"; +import { cn } from "../lib/utils"; +import { AccountSelect } from "./AccountSelect"; +import { Button } from "./ui/Button"; + +export function ConnectButton() { + return ( + + {({ account, chain, openAccountModal, openChainModal, openConnectModal, mounted }) => { + const connected = mounted && account && chain; + + return ( +
+ {(() => { + if (!connected) { + if (isAnvil()) { + return ; + } + + return ( + + ); + } + + if (chain.unsupported) { + return ( + + ); + } + + return ( +
+ +
+ ); + })()} +
+ ); + }} +
+ ); +} diff --git a/packages/explorer/src/components/Navigation.tsx b/packages/explorer/src/components/Navigation.tsx index e4d0d7c2b3..5b883ae503 100644 --- a/packages/explorer/src/components/Navigation.tsx +++ b/packages/explorer/src/components/Navigation.tsx @@ -8,7 +8,7 @@ import { Separator } from "../components/ui/Separator"; import { useWorldUrl } from "../hooks/useWorldUrl"; import { cn } from "../lib/utils"; import { useAbiQuery } from "../queries/useAbiQuery"; -import { AccountSelect } from "./AccountSelect"; +import { ConnectButton } from "./ConnectButton"; export function Navigation() { const pathname = usePathname(); @@ -55,7 +55,7 @@ export function Navigation() {
- +
diff --git a/packages/explorer/src/components/ui/Button.tsx b/packages/explorer/src/components/ui/Button.tsx index 151aab1e65..e79f4a694e 100644 --- a/packages/explorer/src/components/ui/Button.tsx +++ b/packages/explorer/src/components/ui/Button.tsx @@ -39,9 +39,9 @@ export type ButtonProps = React.ButtonHTMLAttributes & VariantProps & { asChild?: boolean }; const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { + ({ className, variant, size, type = "button", asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button"; - return ; + return ; }, ); Button.displayName = "Button"; diff --git a/packages/explorer/src/connectors/anvil.ts b/packages/explorer/src/connectors/anvil.ts new file mode 100644 index 0000000000..9a3a8ad66a --- /dev/null +++ b/packages/explorer/src/connectors/anvil.ts @@ -0,0 +1,83 @@ +import { EIP1193RequestFn, Transport, WalletRpcSchema, http } from "viem"; +import { Account, privateKeyToAccount } from "viem/accounts"; +import { anvil as anvilChain } from "viem/chains"; +import { Connector, createConnector } from "wagmi"; +import { isAnvil } from "../common"; + +export const defaultAnvilAccounts = ( + [ + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", + "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6", + "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a", + "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba", + "0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e", + "0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356", + "0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97", + "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6", + ] as const +).map((pk) => privateKeyToAccount(pk)); + +export type AnvilConnector = Connector & { + accounts: readonly Account[]; +}; + +export type AnvilConnectorOptions = { + id: string; + name: string; + accounts: readonly Account[]; +}; + +// We can't programmatically switch accounts within a connector, but we can switch between connectors, +// so create one anvil connector per default anvil account so users can switch between default anvil accounts. +export const defaultAnvilConnectors = defaultAnvilAccounts.map((account, i) => + anvil({ id: `anvil-${i}`, name: `Anvil #${i + 1}`, accounts: [account] }), +); + +export function isAnvilConnector(connector: Connector): connector is AnvilConnector { + return connector.type === "anvil"; +} + +export function anvil({ id, name, accounts }: AnvilConnectorOptions) { + if (!accounts.length) throw new Error("missing accounts"); + + type Provider = ReturnType>>; + + let connected = false; + return createConnector(() => ({ + id, + name, + type: "anvil", + accounts, + async connect() { + connected = true; + return { + accounts: accounts.map((a) => a.address), + chainId: anvilChain.id, + }; + }, + async disconnect() { + connected = false; + }, + async getAccounts() { + return accounts.map((a) => a.address); + }, + async getChainId() { + return anvilChain.id; + }, + async getProvider() { + return http()({ chain: anvilChain }); + }, + async isAuthorized() { + if (!isAnvil()) return false; + if (!connected) return false; + + const accounts = await this.getAccounts(); + return !!accounts.length; + }, + async onAccountsChanged() {}, + async onDisconnect() {}, + onChainChanged() {}, + })); +} diff --git a/packages/explorer/src/consts.ts b/packages/explorer/src/consts.ts deleted file mode 100644 index b6b5572fa3..0000000000 --- a/packages/explorer/src/consts.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Hex } from "viem"; -import { privateKeyToAccount } from "viem/accounts"; - -// private keys for local development testnet (anvil) -export const PRIVATE_KEYS: Hex[] = [ - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", - "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", - "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6", - "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a", - "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba", - "0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e", - "0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356", - "0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97", - "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6", -]; - -export const ACCOUNTS: Hex[] = PRIVATE_KEYS.map((key) => privateKeyToAccount(key).address); - -export const ACCOUNT_PRIVATE_KEYS: Record = PRIVATE_KEYS.reduce( - (acc, key) => { - const account = privateKeyToAccount(key).address; - acc[account] = key; - return acc; - }, - {} as Record, -); diff --git a/packages/explorer/src/store/AppStoreProvider.tsx b/packages/explorer/src/store/AppStoreProvider.tsx deleted file mode 100644 index e3b35d8df8..0000000000 --- a/packages/explorer/src/store/AppStoreProvider.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; - -import { type ReactNode, createContext, useRef } from "react"; -import { createAppStore } from "./createAppStore"; - -export type AppStore = ReturnType; - -export const AppStoreContext = createContext(undefined); - -type Props = { - children: ReactNode; -}; - -export const AppStoreProvider = ({ children }: Props) => { - const storeRef = useRef(); - if (!storeRef.current) { - storeRef.current = createAppStore(); - } - - return {children}; -}; diff --git a/packages/explorer/src/store/createAppStore.ts b/packages/explorer/src/store/createAppStore.ts deleted file mode 100644 index 05f5e41991..0000000000 --- a/packages/explorer/src/store/createAppStore.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Hex } from "viem"; -import { createStore } from "zustand"; -import { ACCOUNTS } from "../consts"; - -export type AppStoreData = { - account: Hex; - setAccount: (account: Hex) => void; -}; - -export const createAppStore = () => { - return createStore()((set) => ({ - account: ACCOUNTS[0], - setAccount: (account) => set({ account }), - })); -}; diff --git a/packages/explorer/src/store/index.ts b/packages/explorer/src/store/index.ts deleted file mode 100644 index 6a67ea3b1e..0000000000 --- a/packages/explorer/src/store/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./createAppStore"; -export * from "./useAppStore"; -export * from "./AppStoreProvider"; diff --git a/packages/explorer/src/store/useAppStore.ts b/packages/explorer/src/store/useAppStore.ts deleted file mode 100644 index 0ff4c7a992..0000000000 --- a/packages/explorer/src/store/useAppStore.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useStore } from "zustand"; -import { useContext } from "react"; -import { AppStoreContext } from "./AppStoreProvider"; - -export const useAppStore = () => { - const appStoreContext = useContext(AppStoreContext); - - if (!appStoreContext) { - throw new Error(`useAppStore must be used within AppStoreProvider`); - } - - return useStore(appStoreContext); -};