diff --git a/apps/erc7715/.env.example b/apps/erc7715/.env.example new file mode 100644 index 00000000..fa0aad61 --- /dev/null +++ b/apps/erc7715/.env.example @@ -0,0 +1,6 @@ +# Optional defaults for the permission request form (wired in `lib/env.ts` via `next.config.ts`). +# Where you want to send the funds to +SESSION_ACCOUNT_ADDRESS= + +# ERC20 Token Address +TOKEN_ADDRESS= diff --git a/apps/erc7715/.gitignore b/apps/erc7715/.gitignore new file mode 100644 index 00000000..7b8da95f --- /dev/null +++ b/apps/erc7715/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/erc7715/README.md b/apps/erc7715/README.md new file mode 100644 index 00000000..46aaa11e --- /dev/null +++ b/apps/erc7715/README.md @@ -0,0 +1,48 @@ +# ERC-7715 Execution Permissions + +A Next.js web app for exploring [ERC-7715](https://eips.ethereum.org/EIPS/eip-7715) wallet execution permissions on Berachain. Connect a MetaMask wallet, probe supported permission types, request scoped execution permissions (native token or ERC-20), and redeem delegations on-chain via the returned `delegationManager`. + +![ERC7715](./README/erc7715.png) + +**How it works:** + +1. **Probe** — calls `wallet_getSupportedExecutionPermissions` to discover which permission types, chains, and rule types the connected wallet supports. +2. **Request** — builds a `PermissionRequest` and calls `wallet_requestExecutionPermissions`, returning a `PermissionResponse` with a `context` and `delegationManager` address. +3. **Redeem** — submits a `redeemDelegations` transaction to the `delegationManager` contract (per ERC-7710), encoding either a native transfer or an ERC-20 `transfer`. + +## Requirements + +- [Node.js](https://nodejs.org/) v18+ (or [Bun](https://bun.sh/)) +- A browser with [MetaMask](https://metamask.io/) installed (must support ERC-7715 — e.g. MetaMask Flask) + +## Quickstart + +Install dependencies: + +```bash +bun install +``` + +Copy the example env file and fill in your values: + +```bash +cp .env.example .env +``` + +``` +# Where you want to send the funds to (session account) +SESSION_ACCOUNT_ADDRESS=0x... + +# ERC-20 token address used as the default in the request form +TOKEN_ADDRESS=0x... +``` + +Both variables are optional — they pre-populate form fields in the UI. + +Start the dev server: + +```bash +bun run dev +``` + +The app runs at [http://localhost:3002](http://localhost:3002). diff --git a/apps/erc7715/README/erc7715.png b/apps/erc7715/README/erc7715.png new file mode 100644 index 00000000..f1f986b2 Binary files /dev/null and b/apps/erc7715/README/erc7715.png differ diff --git a/apps/erc7715/app/favicon.ico b/apps/erc7715/app/favicon.ico new file mode 100644 index 00000000..718d6fea Binary files /dev/null and b/apps/erc7715/app/favicon.ico differ diff --git a/apps/erc7715/app/globals.css b/apps/erc7715/app/globals.css new file mode 100644 index 00000000..7513eb62 --- /dev/null +++ b/apps/erc7715/app/globals.css @@ -0,0 +1,28 @@ +@import "tailwindcss"; + +:root { + --background: #0a0a0a; + --foreground: #fafafa; + --bera-gold: #f5a623; + --bera-gold-hover: #ffb84d; + --card: #1a1a1a; + --border: #2a2a2a; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-dm-sans); + --font-mono: var(--font-jetbrains-mono); +} + +body { + background: var(--background); + color: var(--foreground); + font-family: var(--font-dm-sans), sans-serif; +} + +.font-mono { + font-variant-ligatures: none; + font-feature-settings: "liga" 0; +} diff --git a/apps/erc7715/app/layout.tsx b/apps/erc7715/app/layout.tsx new file mode 100644 index 00000000..57dae68a --- /dev/null +++ b/apps/erc7715/app/layout.tsx @@ -0,0 +1,47 @@ +import type { Metadata } from "next"; +import { DM_Sans, JetBrains_Mono } from "next/font/google"; +import { headers } from "next/headers"; +import { cookieToInitialState } from "wagmi"; +import { Providers } from "./providers"; +import { config } from "@/lib/wagmi"; +import "./globals.css"; + +const dmSans = DM_Sans({ + variable: "--font-dm-sans", + subsets: ["latin"], + weight: ["400", "500", "700"], +}); + +const jetBrainsMono = JetBrains_Mono({ + variable: "--font-jetbrains-mono", + subsets: ["latin"], + weight: ["400", "500", "700"], +}); + +export const metadata: Metadata = { + title: "ERC-7715 wallet support", + description: + "Check whether your wallet supports ERC-7715 execution permissions", +}; + +export default async function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const initialState = cookieToInitialState( + config, + (await headers()).get("cookie") ?? undefined, + ); + + return ( + + + {children} + + + ); +} diff --git a/apps/erc7715/app/page.tsx b/apps/erc7715/app/page.tsx new file mode 100644 index 00000000..c9be30c0 --- /dev/null +++ b/apps/erc7715/app/page.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useCallback } from "react"; +import { RedeemSection } from "@/components/RedeemSection"; +import { RequestPermissionsSection } from "@/components/RequestPermissionsSection"; +import { SupportedPermissions } from "@/components/SupportedPermissions"; +import { UnsupportedBanner } from "@/components/UnsupportedBanner"; +import { + useStoredPermission, + type StoredGrant, +} from "@/hooks/useStoredPermission"; +import { useWalletSupport } from "@/hooks/useWalletSupport"; +import { useConnect, useConnection, useDisconnect } from "wagmi"; + +export default function HomePage() { + const { address, isConnected, status } = useConnection(); + const { connect, connectors, isPending: isConnectPending } = useConnect(); + const { disconnect } = useDisconnect(); + const support = useWalletSupport(); + + const { grant, setGrant, clear: clearGrant } = useStoredPermission(); + + const handleGrantChange = useCallback( + (next: StoredGrant | null) => setGrant(next), + [setGrant], + ); + + const metaMask = connectors.find((c) => c.type === "metaMask"); + + const showPhase2Request = + isConnected && + status === "connected" && + !support.isLoading && + !support.isUnsupported && + support.data !== undefined; + + return ( +
+
+
+
+

+ ERC-7715 execution permissions +

+

+ Probe the connected wallet for{" "} + + wallet_getSupportedExecutionPermissions + +

+
+
+ {!isConnected ? ( + + ) : ( + <> + + {address} + + + + )} +
+
+
+ +
+ {!isConnected || status !== "connected" ? ( +

+ Connect MetaMask to automatically check execution permission support + on Berachain networks. +

+ ) : ( + <> + {support.isUnsupported && } + + {support.isLoading && ( +

+ Querying the wallet for supported execution permissions… +

+ )} + + {support.isError && !support.isUnsupported && ( +
+

+ Could not read supported permissions +

+ {support.errorMessage && ( +

+ {support.errorMessage} +

+ )} + {support.errorCode !== undefined && ( +

+ RPC code: {support.errorCode} +

+ )} +
+ )} + + {support.data && support.rawResponse !== undefined && ( + + )} + + {showPhase2Request && support.data ? ( + + ) : null} + + + + )} +
+
+ ); +} diff --git a/apps/erc7715/app/providers.tsx b/apps/erc7715/app/providers.tsx new file mode 100644 index 00000000..5a4b9629 --- /dev/null +++ b/apps/erc7715/app/providers.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { type ReactNode, useState } from "react"; +import { type State, WagmiProvider } from "wagmi"; +import { config } from "@/lib/wagmi"; + +type ProvidersProps = { + children: ReactNode; + initialState?: State | undefined; +}; + +export function Providers({ children, initialState }: ProvidersProps) { + const [queryClient] = useState(() => new QueryClient()); + + return ( + + {children} + + ); +} diff --git a/apps/erc7715/bun.lockb b/apps/erc7715/bun.lockb new file mode 100755 index 00000000..5e6b4c3d Binary files /dev/null and b/apps/erc7715/bun.lockb differ diff --git a/apps/erc7715/components/PermissionSequenceDiagram.tsx b/apps/erc7715/components/PermissionSequenceDiagram.tsx new file mode 100644 index 00000000..9616ada5 --- /dev/null +++ b/apps/erc7715/components/PermissionSequenceDiagram.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useId } from "react"; + +/** + * Phase 2 sequence: DApp → provider → wallet → user approval → response. + * Inline SVG (~300px tall) for use beside the request form. + */ +export function PermissionSequenceDiagram() { + const rid = useId().replace(/:/g, ""); + const arrowId = `${rid}-arrowhead`; + + return ( +
+ + + + + + + + + DApp + + + + + window.ethereum + + + + + Wallet + + + + + User + + + + + + request() + + + method: wallet_requestExecutionPermissions + + + params: PermissionRequest[] (JSON-RPC array) + + + + + forward JSON-RPC + + + + + prompt: rules + permission + to + + + + + approve / reject (4001) + + + + + result: PermissionResponse[] + + + + + Promise resolves + + + + context, delegationManager, dependencies[] + + + + Payload shape per ERC-7715 (chainId, to, permission, rules?) + + +
+ ); +} diff --git a/apps/erc7715/components/RedeemPermissionForm.tsx b/apps/erc7715/components/RedeemPermissionForm.tsx new file mode 100644 index 00000000..6da48d2c --- /dev/null +++ b/apps/erc7715/components/RedeemPermissionForm.tsx @@ -0,0 +1,385 @@ +"use client"; + +import { type FormEvent, useEffect, useMemo, useState } from "react"; +import { getAddress, isAddress, parseEther, parseUnits } from "viem"; +import { berachain, berachainTestnetbArtio } from "viem/chains"; +import { useRedeemPermission } from "@/hooks/useRedeemPermission"; +import type { PermissionResponse } from "@/types/erc7715"; + +const ERC20_DECIMALS = 18; + +const SUPPORTED_CHAINS = [berachain, berachainTestnetbArtio]; + +function getExplorerTxUrl(chainIdHex: string, txHash: string): string | null { + const chainId = Number(chainIdHex); + const chain = SUPPORTED_CHAINS.find((c) => c.id === chainId); + const baseUrl = chain?.blockExplorers?.default?.url; + if (!baseUrl) return null; + return `${baseUrl}/tx/${txHash}`; +} + +function formatTimestamp(ts: number): string { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "medium", + }).format(new Date(ts * 1000)); +} + +function truncateHex(hex: string, leading = 6, trailing = 4): string { + if (!hex.startsWith("0x") || hex.length <= leading + trailing + 2) return hex; + return `${hex.slice(0, leading + 2)}…${hex.slice(-trailing)}`; +} + +function isPermissionResponse(value: unknown): value is PermissionResponse { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return ( + typeof v.context === "string" && + typeof v.delegationManager === "string" && + Array.isArray(v.dependencies) && + typeof v.chainId === "string" && + typeof v.to === "string" && + typeof v.permission === "object" && + v.permission !== null + ); +} + +export type RedeemPermissionFormProps = { + initialResponse?: PermissionResponse | null; + onClear?: () => void; +}; + +export function RedeemPermissionForm({ + initialResponse, + onClear, +}: RedeemPermissionFormProps) { + const redeem = useRedeemPermission(); + + const [responseJson, setResponseJson] = useState(""); + const [targetAddress, setTargetAddress] = useState(""); + const [amountInput, setAmountInput] = useState("0.001"); + + useEffect(() => { + if (initialResponse) { + setResponseJson(JSON.stringify(initialResponse, null, 2)); + } + }, [initialResponse]); + + const { parsed, parseError } = useMemo<{ + parsed: PermissionResponse | null; + parseError: string | null; + }>(() => { + const trimmed = responseJson.trim(); + if (!trimmed) return { parsed: null, parseError: null }; + try { + const obj = JSON.parse(trimmed); + if (!isPermissionResponse(obj)) { + return { + parsed: null, + parseError: + "Missing required fields: context, delegationManager, dependencies, chainId, to, permission.", + }; + } + return { parsed: obj, parseError: null }; + } catch { + return { + parsed: null, + parseError: "Invalid JSON — paste a PermissionResponse object.", + }; + } + }, [responseJson]); + + const isErc20Type = + parsed?.permission.type.startsWith("erc20-token-") ?? false; + + const amountValid = useMemo(() => { + try { + if (isErc20Type) { + parseUnits(amountInput.trim() || "0", ERC20_DECIMALS); + } else { + parseEther(amountInput.trim() || "0"); + } + return true; + } catch { + return false; + } + }, [amountInput, isErc20Type]); + + const data = (parsed?.permission.data ?? {}) as Record; + const tokenAddress = + typeof data.tokenAddress === "string" ? data.tokenAddress : null; + const periodAmount = + typeof data.periodAmount === "string" ? data.periodAmount : null; + const allowance = typeof data.allowance === "string" ? data.allowance : null; + const isNativeType = + parsed?.permission.type.startsWith("native-token-") ?? false; + + const expiryRule = parsed?.rules?.find((r) => r.type === "expiry"); + const expiryTs = (expiryRule?.data as { timestamp?: number } | undefined) + ?.timestamp; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (!parsed) return; + if (!isAddress(targetAddress)) return; + if (!amountValid) return; + + const recipient = getAddress(targetAddress) as `0x${string}`; + + if (isErc20Type && tokenAddress && isAddress(tokenAddress)) { + redeem.mutate({ + kind: "erc20", + response: parsed, + tokenAddress: getAddress(tokenAddress) as `0x${string}`, + recipient, + amount: parseUnits(amountInput.trim(), ERC20_DECIMALS), + }); + } else { + redeem.mutate({ + kind: "native", + response: parsed, + recipient, + value: parseEther(amountInput.trim()), + }); + } + }; + + const explorerUrl = + redeem.hash && parsed + ? getExplorerTxUrl(parsed.chainId, redeem.hash) + : null; + + return ( +
+