diff --git a/src/core/constants.ts b/src/core/constants.ts index 2f4fd7d..38f4270 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -1,4 +1,4 @@ -import { Address, toFunctionSelector } from "viem"; +import { Address, Hex, toFunctionSelector } from "viem"; import { base, baseSepolia, bscTestnet } from "viem/chains"; // --------------------------------------------------------------------------- @@ -38,6 +38,28 @@ export const SUBSCRIPTION_STATE_ADDRESSES: Record = { [base.id]: "0x52c2C68f4f7fF3C70760E3D0B9b2FA91CFE443Ad", }; +export const DELEGATE_ADDRESSES: Record = { + [baseSepolia.id]: "0xa66ded501ce4fa2d8e2b98dc86cad33ea9f57c54", +}; + +export const MODE_BATCH: Hex = + "0x0100000000000000000000000000000000000000000000000000000000000000"; + +export const EXECUTE_WITH_SIG_TYPES = { + ExecuteWithSig: [ + { name: "mode", type: "bytes32" }, + { name: "calls", type: "Call" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint48" }, + { name: "recipient", type: "address" }, + ], + Call: [ + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "data", type: "bytes" }, + ], +} as const; + export const ACP_SELECTORS = { setBudget: toFunctionSelector("setBudget(uint256,uint256,bytes)"), fund: toFunctionSelector("fund(uint256,uint256,bytes)"), diff --git a/src/core/delegationAbi.ts b/src/core/delegationAbi.ts new file mode 100644 index 0000000..9e2510e --- /dev/null +++ b/src/core/delegationAbi.ts @@ -0,0 +1,6 @@ +import { parseAbi } from "viem"; + +export const DELEGATION_ABI = parseAbi([ + "function executeWithSignature(bytes32 mode, bytes executionData, uint48 deadline, bytes signature, address recipient) payable", + "function sigNonce() view returns (uint256)", +]); diff --git a/src/providers/evm/privyAlchemyEvmProviderAdapter.ts b/src/providers/evm/privyAlchemyEvmProviderAdapter.ts index 440b780..9c3f327 100644 --- a/src/providers/evm/privyAlchemyEvmProviderAdapter.ts +++ b/src/providers/evm/privyAlchemyEvmProviderAdapter.ts @@ -1,6 +1,9 @@ import { concatHex, createWalletClient, + decodeFunctionData, + encodeFunctionData, + erc20Abi, http, LocalAccount, pad, @@ -15,6 +18,7 @@ import { type TransactionReceipt, type WalletClient, } from "viem"; +import { encodeCalls } from "viem/experimental/erc7821"; import { Attribution } from "ox/erc8021"; import { getTransactionReceipt, @@ -22,7 +26,10 @@ import { getLogs, getBlockNumber, } from "viem/actions"; -import { createEvmNetworkContext, EVM_MAINNET_CHAINS } from "../../core/chains.js"; +import { + createEvmNetworkContext, + EVM_MAINNET_CHAINS, +} from "../../core/chains.js"; import type { GetLogsParams, IEvmProviderAdapter, @@ -38,7 +45,15 @@ import { type SmartWalletClient, alchemyWalletTransport, } from "@alchemy/wallet-apis"; -import { ACP_SERVER_URL, PRIVY_APP_ID } from "../../core/constants.js"; +import { + ACP_SERVER_URL, + DELEGATE_ADDRESSES, + EXECUTE_WITH_SIG_TYPES, + MODE_BATCH, + PRIVY_APP_ID, + getAddressForChain, +} from "../../core/constants.js"; +import { DELEGATION_ABI } from "../../core/delegationAbi.js"; import { ProviderAuthClient } from "../providerAuthClient.js"; export type SignFn = (payload: Uint8Array) => Promise; @@ -189,7 +204,7 @@ function createRemoteSigner(params: { const TTypedData extends | Record | Record, - TPrimaryType extends keyof TTypedData | "EIP712Domain" = keyof TTypedData, + TPrimaryType extends keyof TTypedData | "EIP712Domain" = keyof TTypedData >( typedDataDef: TypedDataDefinition ) => { @@ -226,7 +241,11 @@ function createRemoteSigner(params: { // Map viem tx fields to Privy's snake_case format const TX_TYPE_MAP: Record = { - legacy: 0, eip2930: 1, eip1559: 2, eip4844: 3, eip7702: 4, + legacy: 0, + eip2930: 1, + eip1559: 2, + eip4844: 3, + eip7702: 4, }; const privyTx: Record = { ...(raw.to != null ? { to: raw.to } : {}), @@ -236,14 +255,32 @@ function createRemoteSigner(params: { ...(raw.nonce != null ? { nonce: raw.nonce } : {}), ...(raw.gas != null ? { gas_limit: raw.gas } : {}), ...(raw.gasPrice != null ? { gas_price: raw.gasPrice } : {}), - ...(raw.maxFeePerGas != null ? { max_fee_per_gas: raw.maxFeePerGas } : {}), - ...(raw.maxPriorityFeePerGas != null ? { max_priority_fee_per_gas: raw.maxPriorityFeePerGas } : {}), + ...(raw.maxFeePerGas != null + ? { max_fee_per_gas: raw.maxFeePerGas } + : {}), + ...(raw.maxPriorityFeePerGas != null + ? { max_priority_fee_per_gas: raw.maxPriorityFeePerGas } + : {}), ...(raw.chainId != null ? { chain_id: raw.chainId } : {}), }; if (raw.type != null) { - privyTx.type = typeof raw.type === "string" - ? (TX_TYPE_MAP[raw.type] ?? Number(raw.type)) - : raw.type; + privyTx.type = + typeof raw.type === "string" + ? TX_TYPE_MAP[raw.type] ?? Number(raw.type) + : raw.type; + } + + if (Array.isArray(raw.authorizationList)) { + privyTx.authorization_list = ( + raw.authorizationList as Array> + ).map((auth) => ({ + contract: auth.address, + chain_id: auth.chainId, + nonce: auth.nonce, + y_parity: auth.yParity, + r: auth.r, + s: auth.s, + })); } const rpcBody = { @@ -313,6 +350,21 @@ type ChainClients = { walletClient: WalletClient; }; +// Identify the semantic recipient of a call for the AccountWithSig binding. +// ERC-20 transfer → destination, ERC-20 approve → spender, otherwise → target. +function extractRecipient(call: Call): Address { + try { + const { functionName, args } = decodeFunctionData({ + abi: erc20Abi, + data: call.data ?? "0x", + }); + if (functionName === "transfer" || functionName === "approve") { + return args[0] as Address; + } + } catch {} + return call.to; +} + export function appendBuilderCodeData(data: Hex, suffix: Hex): Hex { const opDataByteLength = (data.length - 2) / 2; const suffixByteLength = (suffix.length - 2) / 2; @@ -453,12 +505,71 @@ export class PrivyAlchemyEvmProviderAdapter implements IEvmProviderAdapter { async sendTransaction(chainId: number, call: Call): Promise
{ const { walletClient } = this.getClients(chainId); + + const signedCall = { + to: call.to, + value: call.value ?? 0n, + data: call.data ?? ("0x" as Hex), + }; + const recipient = extractRecipient(call); + const executionData = encodeCalls([signedCall]); + const deadline = Math.floor(Date.now() / 1000) + 300; + + const delegateAddress = getAddressForChain( + DELEGATE_ADDRESSES, + chainId, + "delegate" + ); + + const [authorization, nonce] = await Promise.all([ + walletClient.signAuthorization({ + account: walletClient.account!, + contractAddress: delegateAddress, + executor: "self", + }), + // Read sigNonce from the EOA's slot under 7702 delegation. On a fresh + // EOA with no prior delegation the call has no code to route to and + // throws — that's the bootstrap case, slot is unwritten so nonce is 0. + ( + readContract(walletClient, { + address: this.address, + abi: DELEGATION_ABI, + functionName: "sigNonce", + }) as Promise + ).catch(() => 0n), + ]); + + const signature = (await this.signer.signTypedData({ + domain: { + name: "AccountWithSig", + version: "1", + chainId: walletClient.chain!.id, + verifyingContract: this.address, + }, + types: EXECUTE_WITH_SIG_TYPES, + primaryType: "ExecuteWithSig", + message: { + mode: MODE_BATCH, + calls: signedCall, + nonce, + deadline, + recipient, + }, + })) as Hex; + + const data = encodeFunctionData({ + abi: DELEGATION_ABI, + functionName: "executeWithSignature", + args: [MODE_BATCH, executionData, deadline, signature, recipient], + }); + return walletClient.sendTransaction({ account: walletClient.account!, chain: walletClient.chain, - to: call.to, - data: call.data, - value: call.value, + authorizationList: [authorization], + to: walletClient.account!.address, + data, + value: 0n, }); }