diff --git a/apps/iframe/src/components/requests/WalletSendCalls.tsx b/apps/iframe/src/components/requests/WalletSendCalls.tsx new file mode 100644 index 0000000000..3caa8debfa --- /dev/null +++ b/apps/iframe/src/components/requests/WalletSendCalls.tsx @@ -0,0 +1,77 @@ +import { formatEther } from "viem" +import { getAppURL } from "#src/utils/appURL" +import DisclosureSection from "./common/DisclosureSection" +import { + FormattedDetailsLine, + Layout, + LinkToAddress, + SectionBlock, + SubsectionBlock, + SubsectionContent, + SubsectionTitle, +} from "./common/Layout" +import type { RequestConfirmationProps } from "./props" + +export function getFirstParam(params: [T, ...unknown[]] | undefined): T | undefined { + return Array.isArray(params) ? params[0] : undefined +} + +export const WalletSendCalls = ({ method, params, reject, accept }: RequestConfirmationProps<"wallet_sendCalls">) => { + const appURL = getAppURL() + const request = getFirstParam(params) + + if (!request) return null + const call = request.calls[0] + + const rawValue = call.value ? BigInt(call.value) : 0n + const formattedValue = rawValue > 0n ? `${formatEther(rawValue)} HAPPY` : null + + return ( + + {appURL} +
is requesting to send a transaction + + } + description={<>This app wants to execute a transaction on your behalf.} + actions={{ + accept: { + children: "Approve Transaction", + // biome-ignore lint/suspicious/noExplicitAny: we know the params match the method + onClick: () => accept({ method, params } as any), + }, + reject: { + children: "Go back", + onClick: reject, + }, + }} + > + + + {call.to && ( + + Receiver address + + {call.to} + + + )} + + {rawValue > 0n && ( + + Sending amount + {formattedValue} + + )} + + + + +
+ {JSON.stringify(params, null, 2)} +
+
+
+ ) +} diff --git a/apps/iframe/src/constants/requestLabels.ts b/apps/iframe/src/constants/requestLabels.ts index a8041a397c..d677d7b6e2 100644 --- a/apps/iframe/src/constants/requestLabels.ts +++ b/apps/iframe/src/constants/requestLabels.ts @@ -9,6 +9,7 @@ export const requestLabels = { wallet_requestPermissions: "Grant permissions", wallet_switchEthereumChain: "Switch chain", wallet_watchAsset: "Watch token", + wallet_sendCalls: "Batch calls", [HappyMethodNames.LOAD_ABI]: "Load ABI", [HappyMethodNames.REQUEST_SESSION_KEY]: "Approve session key", } as const diff --git a/apps/iframe/src/requests/handlers/approved.ts b/apps/iframe/src/requests/handlers/approved.ts index ca237bb5e5..d834d3fd4c 100644 --- a/apps/iframe/src/requests/handlers/approved.ts +++ b/apps/iframe/src/requests/handlers/approved.ts @@ -1,7 +1,14 @@ import { HappyMethodNames } from "@happy.tech/common" import { EIP1193SwitchChainError, EIP1474InvalidInput, type Msgs, type PopupMsgs } from "@happy.tech/wallet-common" +import type { WalletSendCallsReturnType } from "viem" import { sendBoop } from "#src/requests/utils/boop" -import { checkAndChecksumAddress, checkedTx, checkedWatchedAsset } from "#src/requests/utils/checks" +import { + checkAndChecksumAddress, + checkedTx, + checkedWalletSendCallsParams, + checkedWatchedAsset, + extractValidTxFromCall, +} from "#src/requests/utils/checks" import { sendToWalletClient } from "#src/requests/utils/sendToClient" import { installNewSessionKey } from "#src/requests/utils/sessionKeys" import { eoaSigner } from "#src/requests/utils/signers" @@ -13,6 +20,7 @@ import { addWatchedAsset } from "#src/state/watchedAssets" import { appForSourceID } from "#src/utils/appURL" import { isAddChainParams } from "#src/utils/isAddChainParam" import { reqLogger } from "#src/utils/logger" +import { happyPaymaster } from "../../constants/contracts" export async function dispatchApprovedRequest(request: PopupMsgs[Msgs.PopupApprove]) { const app = appForSourceID(request.windowId)! // checked in sendResponse @@ -71,6 +79,24 @@ export async function dispatchApprovedRequest(request: PopupMsgs[Msgs.PopupAppro return addWatchedAsset(user.address, params) } + case "wallet_sendCalls": { + const checkedParams = checkedWalletSendCallsParams(request.payload.params) + const extractedTx = extractValidTxFromCall(checkedParams) + const boopHash = await sendBoop({ + account: user.address, + tx: extractedTx, + signer: eoaSigner, + paymaster: checkedParams.capabilities?.boopPaymaster?.address, + }) + + return { + id: boopHash, + capabilities: { + boopPaymaster: happyPaymaster, + }, + } satisfies WalletSendCallsReturnType + } + case HappyMethodNames.LOAD_ABI: { return loadAbiForUser(user.address, request.payload.params) } diff --git a/apps/iframe/src/requests/utils/boop.ts b/apps/iframe/src/requests/utils/boop.ts index cfe6a0bab4..c9530b2d2d 100644 --- a/apps/iframe/src/requests/utils/boop.ts +++ b/apps/iframe/src/requests/utils/boop.ts @@ -86,6 +86,7 @@ export type SendBoopArgs = { simulation?: SimulateSuccess isSponsored?: boolean nonceTrack?: bigint + paymaster?: Address } export async function sendBoop( diff --git a/apps/iframe/src/requests/utils/checks.ts b/apps/iframe/src/requests/utils/checks.ts index a813b11dcb..bd7fcb3875 100644 --- a/apps/iframe/src/requests/utils/checks.ts +++ b/apps/iframe/src/requests/utils/checks.ts @@ -9,14 +9,29 @@ import { EIP1193UnauthorizedError, EIP1193UnsupportedMethodError, EIP1474InvalidInput, + HappyWalletCapability, permissionsLists, } from "@happy.tech/wallet-common" import { checksum } from "ox/Address" -import { type RpcTransactionRequest, type WatchAssetParameters, isAddress, isHex } from "viem" +import { + AtomicityNotSupportedError, + BundleTooLargeError, + type Hex, + type RpcTransactionRequest, + UnsupportedChainIdError, + UnsupportedNonOptionalCapabilityError, + type WalletSendCallsParameters, + type WatchAssetParameters, + isAddress, + isHex, + toHex, +} from "viem" import { getAuthState } from "#src/state/authState" +import { getCurrentChain } from "#src/state/chains.ts" import { getUser } from "#src/state/user.ts" import type { AppURL } from "#src/utils/appURL" import { checkIfRequestRequiresConfirmation } from "#src/utils/checkIfRequestRequiresConfirmation" +import { parseSendCallParams } from "#src/utils/isSendCallsParams" /** * Check if the user is authenticated with the social login provider, otherwise throws an error. @@ -131,3 +146,92 @@ export function ensureIsNotHappyMethod( `happy method unsupported by this handle: ${requestParams.method} — IMPLEMENTATION BUG`, ) } + +// ================================= EIP-5792 ================================= +const SUPPORTED_CAPABILITIES = [HappyWalletCapability.BoopPaymaster] as const + +export type ValidWalletSendCallsRequest = { + id: string + from: Address + chainId: Hex + atomicRequired: false + version: "2.0.0" + calls: [ + { + to: Address + data?: Hex + value?: Hex | bigint + capabilities?: { + [HappyWalletCapability.BoopPaymaster]?: { + address: Address + optional?: boolean + } + } + }, + ] + capabilities?: { + [HappyWalletCapability.BoopPaymaster]?: { + address: Address + optional?: boolean + } + } +} + +type BoopPaymasterCapability = { + [HappyWalletCapability.BoopPaymaster]: { + address: Address + optional?: boolean + } +} + +type WalletSendCallsParams = WalletSendCallsParameters + +export function checkedWalletSendCallsParams(params: WalletSendCallsParams | undefined): ValidWalletSendCallsRequest { + // 1474 - invalid params + const parsed = parseSendCallParams(params) + if (!parsed.success && parsed.error) + throw new EIP1474InvalidInput("Invalid wallet_sendCalls request body:", parsed.error) + // 4100 - Unauthorised + checkAuthenticated() + const [reqBody] = parsed.data + // 5710 - unsupported chain ID + if (reqBody.chainId !== getCurrentChain().chainId) throw new UnsupportedChainIdError(new Error("Not HappyChain")) + // // 5740 - bundle too large, we currently only support only one call per bundle + if (reqBody.calls.length > 1) + throw new BundleTooLargeError(new Error("Happy Wallet currently only supports 1 call per bundle")) + // 5760 - no atomicity + if (reqBody.atomicRequired) + throw new AtomicityNotSupportedError(new Error("Happy Wallet does not support atomicity (yet)")) + // 5700 - unsupported capability (global + at the call level) + validateCapabilities(reqBody.capabilities ?? {}, SUPPORTED_CAPABILITIES) + validateCapabilities(reqBody.calls[0].capabilities ?? {}, SUPPORTED_CAPABILITIES) + + return reqBody as ValidWalletSendCallsRequest +} + +export function extractValidTxFromCall(request: ValidWalletSendCallsRequest): ValidRpcTransactionRequest { + const call = request.calls[0] + + const tx: ValidRpcTransactionRequest = { + from: request.from!, + to: call.to!, + value: call.value !== undefined ? toHex(call.value) : undefined, + data: call.data, + } + + return tx +} + +function validateCapabilities( + // biome-ignore lint/suspicious/noExplicitAny: viem does it + capabilities: Record, + supported: readonly string[], +) { + for (const [key, value] of Object.entries(capabilities)) { + const isSupported = supported.includes(key) + const isOptional = typeof value === "object" && value?.optional === true + if (!isSupported && !isOptional) { + throw new UnsupportedNonOptionalCapabilityError(new Error(`Unsupported non-optional capability: ${key}`)) + } + } +} diff --git a/apps/iframe/src/routes/request.lazy.tsx b/apps/iframe/src/routes/request.lazy.tsx index 34fc6a5c5a..1fd02750f9 100644 --- a/apps/iframe/src/routes/request.lazy.tsx +++ b/apps/iframe/src/routes/request.lazy.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from "react" import { HappyLoadAbi } from "#src/components/requests/HappyLoadAbi" import { HappyRequestSessionKey } from "#src/components/requests/HappyRequestSessionKey" import { UnknownRequest } from "#src/components/requests/UnknownRequest.tsx" +import { WalletSendCalls } from "#src/components/requests/WalletSendCalls.tsx" import { DotLinearWaveLoader } from "../components/loaders/DotLinearWaveLoader" import { EthRequestAccounts } from "../components/requests/EthRequestAccounts" import { EthSendTransaction } from "../components/requests/EthSendTransaction" @@ -133,6 +134,8 @@ function Request() { return case "wallet_watchAsset": return + case "wallet_sendCalls": + return case HappyMethodNames.LOAD_ABI: return case HappyMethodNames.REQUEST_SESSION_KEY: diff --git a/apps/iframe/src/utils/isSendCallsParams.ts b/apps/iframe/src/utils/isSendCallsParams.ts new file mode 100644 index 0000000000..9f4cbe0626 --- /dev/null +++ b/apps/iframe/src/utils/isSendCallsParams.ts @@ -0,0 +1,34 @@ +import { isAddress, isHex } from "viem/utils" +import { z } from "zod" + +const addressSchema = z.string().refine((val) => isAddress(val), { message: "Invalid address" }) + +const hexSchema = z.string().refine((val) => isHex(val), { message: "Invalid hex string" }) + +const capabilitySchema = z.record(z.any()) + +const callSchema = z.object({ + to: addressSchema, + data: hexSchema.optional(), + value: z.union([z.string(), z.bigint()]).optional(), + capabilities: capabilitySchema.optional(), +}) + +const mainSchema = z.tuple([ + z.object({ + version: z.literal("2.0.0"), + id: z.string().max(4096).optional(), + from: addressSchema.optional(), + chainId: z + .string() + .refine((val) => /^0x[0-9a-fA-F]+$/.test(val), { message: "chainId must be 0x-prefixed hex" }), + + atomicRequired: z.boolean(), // atomicity not supported yet + capabilities: capabilitySchema.optional(), + calls: z.array(callSchema).nonempty({ message: "At least one call is required" }).readonly(), + }), +]) + +export function parseSendCallParams(param: unknown) { + return mainSchema.safeParse(param) +} diff --git a/demos/react/src/demo-components/WalletFunctionalityDemo.tsx b/demos/react/src/demo-components/WalletFunctionalityDemo.tsx index 2b24761c56..d594d96f2e 100644 --- a/demos/react/src/demo-components/WalletFunctionalityDemo.tsx +++ b/demos/react/src/demo-components/WalletFunctionalityDemo.tsx @@ -1,6 +1,7 @@ import { loadAbi, showSendScreen } from "@happy.tech/core" import { useHappyWallet } from "@happy.tech/react" import { toast } from "sonner" +import { parseEther } from "viem" import { walletClient } from "../clients" import { abis, deployment } from "../deployments" @@ -62,6 +63,23 @@ const WalletFunctionalityDemo = () => { } } + async function sendBatch() { + try { + const batchCalls = await walletClient?.sendCalls({ + account: user?.address, + calls: [ + { + to: "0x77e351bd9C2EF18aB7070f37a5A8108279553F91", + value: parseEther("0.001"), + }, + ], + }) + console.log(batchCalls) + } catch (error) { + console.error(error) + } + } + async function loadAbiStub() { await loadAbi(deployment.MockTokenA, abis.MockTokenA) toast.success( @@ -88,6 +106,9 @@ const WalletFunctionalityDemo = () => { + ) } diff --git a/support/wallet-common/lib/interfaces/permissions.ts b/support/wallet-common/lib/interfaces/permissions.ts index a2d3aac683..4cb6a48359 100644 --- a/support/wallet-common/lib/interfaces/permissions.ts +++ b/support/wallet-common/lib/interfaces/permissions.ts @@ -117,6 +117,8 @@ const unsafeList = new Set([ // cryptography "eth_decrypt", "eth_getEncryptionPublicKey", + // eip-5792 + "wallet_sendCalls", ]) /**