Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions apps/iframe/src/components/requests/WalletSendCalls.tsx
Original file line number Diff line number Diff line change
@@ -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<T>(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 (
<Layout
headline={
<>
<span className="text-primary">{appURL}</span>
<br /> 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,
},
}}
>
<SectionBlock>
<SubsectionBlock>
{call.to && (
<SubsectionContent>
<SubsectionTitle>Receiver address</SubsectionTitle>
<FormattedDetailsLine>
<LinkToAddress address={call.to}>{call.to}</LinkToAddress>
</FormattedDetailsLine>
</SubsectionContent>
)}

{rawValue > 0n && (
<SubsectionContent>
<SubsectionTitle>Sending amount</SubsectionTitle>
<FormattedDetailsLine>{formattedValue}</FormattedDetailsLine>
</SubsectionContent>
)}
</SubsectionBlock>
</SectionBlock>

<DisclosureSection title="Raw Request">
<div className="grid gap-4 p-2">
<FormattedDetailsLine isCode>{JSON.stringify(params, null, 2)}</FormattedDetailsLine>
</div>
</DisclosureSection>
</Layout>
)
}
1 change: 1 addition & 0 deletions apps/iframe/src/constants/requestLabels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion apps/iframe/src/requests/handlers/approved.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -71,6 +79,24 @@ export async function dispatchApprovedRequest(request: PopupMsgs[Msgs.PopupAppro
return addWatchedAsset(user.address, params)
}

case "wallet_sendCalls": {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this need to be in injected as well?

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)
}
Expand Down
1 change: 1 addition & 0 deletions apps/iframe/src/requests/utils/boop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export type SendBoopArgs = {
simulation?: SimulateSuccess
isSponsored?: boolean
nonceTrack?: bigint
paymaster?: Address
}

export async function sendBoop(
Expand Down
106 changes: 105 additions & 1 deletion apps/iframe/src/requests/utils/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<BoopPaymasterCapability, Hex, Hex | bigint>

export function checkedWalletSendCallsParams(params: WalletSendCallsParams | undefined): ValidWalletSendCallsRequest {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

combed through this list to identify all the necessary errors to be thrown for accuracy

Screenshot 2025-05-22 at 7 19 11 PM

// 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<string, any>,
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}`))
}
}
}
3 changes: 3 additions & 0 deletions apps/iframe/src/routes/request.lazy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -133,6 +134,8 @@ function Request() {
return <EthRequestAccounts {...props} />
case "wallet_watchAsset":
return <WalletWatchAsset {...props} />
case "wallet_sendCalls":
return <WalletSendCalls {...props} />
case HappyMethodNames.LOAD_ABI:
return <HappyLoadAbi {...props} />
case HappyMethodNames.REQUEST_SESSION_KEY:
Expand Down
34 changes: 34 additions & 0 deletions apps/iframe/src/utils/isSendCallsParams.ts
Original file line number Diff line number Diff line change
@@ -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)
}
21 changes: 21 additions & 0 deletions demos/react/src/demo-components/WalletFunctionalityDemo.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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(
Expand All @@ -88,6 +106,9 @@ const WalletFunctionalityDemo = () => {
<button type="button" onClick={loadAbiStub} className="rounded-lg bg-sky-300 p-2 shadow-xl">
Load ABI
</button>
<button type="button" onClick={sendBatch} className="rounded-lg bg-amber-300 font-mono p-2 shadow-xl">
wallet_sendCalls
</button>
</div>
)
}
Expand Down
2 changes: 2 additions & 0 deletions support/wallet-common/lib/interfaces/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ const unsafeList = new Set([
// cryptography
"eth_decrypt",
"eth_getEncryptionPublicKey",
// eip-5792
"wallet_sendCalls",
])

/**
Expand Down