diff --git a/packages/thirdweb/src/contract/verification/publisher.ts b/packages/thirdweb/src/contract/verification/publisher.ts index 7117d97b36d..ccda0b4c3c5 100644 --- a/packages/thirdweb/src/contract/verification/publisher.ts +++ b/packages/thirdweb/src/contract/verification/publisher.ts @@ -1,6 +1,6 @@ import type { ThirdwebClient } from "../../client/client.js"; import { download } from "../../storage/download.js"; -import { readContract } from "../../transaction/index.js"; +import { readContract } from "../../transaction/read-contract.js"; import { extractIPFSUri, resolveImplementation } from "../../utils/index.js"; import { getContract, type ThirdwebContract } from "../contract.js"; diff --git a/packages/thirdweb/src/event/actions/get-events.ts b/packages/thirdweb/src/event/actions/get-events.ts index 4deeb7a20a1..fa23ba73e07 100644 --- a/packages/thirdweb/src/event/actions/get-events.ts +++ b/packages/thirdweb/src/event/actions/get-events.ts @@ -1,7 +1,7 @@ import type { Abi, AbiEvent } from "abitype"; import type { BlockTag } from "viem"; import { resolveAbiEvent } from "./resolve-abi.js"; -import type { ContractEvent } from "../event.js"; +import type { ContractEvent, EventLog } from "../event.js"; import { resolveContractAbi, type ThirdwebContract, @@ -75,10 +75,10 @@ export async function getEvents< abi.filter((item) => item.type === "event"), ) as Promise)); - return await eth_getLogs(rpcRequest, { + return (await eth_getLogs(rpcRequest, { fromBlock: options.fromBlock, toBlock: options.toBlock, address: options.contract.address, events: parsedEvents, - }); + })) as EventLog[]; } diff --git a/packages/thirdweb/src/event/actions/watch-events.ts b/packages/thirdweb/src/event/actions/watch-events.ts index 1097e87fbe9..c5931f655e8 100644 --- a/packages/thirdweb/src/event/actions/watch-events.ts +++ b/packages/thirdweb/src/event/actions/watch-events.ts @@ -1,15 +1,13 @@ import type { Abi, AbiEvent } from "abitype"; import { resolveAbiEvent } from "./resolve-abi.js"; import type { ContractEvent, EventLog } from "../event.js"; -import { - resolveContractAbi, - type ThirdwebContract, -} from "../../contract/index.js"; +import { resolveContractAbi } from "../../contract/actions/resolve-abi.js"; import { eth_getLogs, getRpcClient, watchBlockNumber, } from "../../rpc/index.js"; +import type { ThirdwebContract } from "../../contract/contract.js"; export type WatchContractEventsOptions< abi extends Abi, diff --git a/packages/thirdweb/src/extensions/erc20/write/transfer.ts b/packages/thirdweb/src/extensions/erc20/write/transfer.ts index 45280c16376..3c08ee60314 100644 --- a/packages/thirdweb/src/extensions/erc20/write/transfer.ts +++ b/packages/thirdweb/src/extensions/erc20/write/transfer.ts @@ -30,7 +30,7 @@ export type TransferParams = { to: string } & ( */ export function transfer(options: BaseTransactionOptions) { return prepareContractCall({ - ...options, + contract: options.contract, method: "function transfer(address to, uint256 value)", params: async () => { let amount: bigint; diff --git a/packages/thirdweb/src/gas/fee-data.ts b/packages/thirdweb/src/gas/fee-data.ts index 7f56808e52a..c8f04fd2fa6 100644 --- a/packages/thirdweb/src/gas/fee-data.ts +++ b/packages/thirdweb/src/gas/fee-data.ts @@ -66,14 +66,6 @@ export async function getDefaultGasOverrides( client: ThirdwebClient, chain: Chain, ) { - /** - * TODO: do we want to re-enable this? - */ - // If we're running in the browser, let users configure gas price in their wallet UI - // if (isBrowser()) { - // return {}; - // } - const feeData = await getDynamicFeeData(client, chain); if (feeData.maxFeePerGas && feeData.maxPriorityFeePerGas) { return { @@ -137,6 +129,11 @@ async function getDynamicFeeData( // good article on the subject: https://www.blocknative.com/blog/eip-1559-fees maxFeePerGas = baseBlockFee * 2n + maxPriorityFeePerGas_; + // special cased for Celo gas fees + if (chainId === 42220n || chainId === 44787n || chainId === 62320n) { + maxPriorityFeePerGas_ = maxFeePerGas; + } + return { maxFeePerGas, maxPriorityFeePerGas: maxPriorityFeePerGas_, diff --git a/packages/thirdweb/src/react/hooks/contract/useContractEvents.ts b/packages/thirdweb/src/react/hooks/contract/useContractEvents.ts index b3092f12dd0..d557f420fb8 100644 --- a/packages/thirdweb/src/react/hooks/contract/useContractEvents.ts +++ b/packages/thirdweb/src/react/hooks/contract/useContractEvents.ts @@ -43,7 +43,7 @@ export function useContractEvents< const contractEvents extends ContractEvent[], >( options: UseContractEventsOptions, -): UseQueryResult[], Error> { +): UseQueryResult[], Error> { const { contract, events, diff --git a/packages/thirdweb/src/react/hooks/contract/useWaitForReceipt.ts b/packages/thirdweb/src/react/hooks/contract/useWaitForReceipt.ts index d67b5e7573b..c305eb506e9 100644 --- a/packages/thirdweb/src/react/hooks/contract/useWaitForReceipt.ts +++ b/packages/thirdweb/src/react/hooks/contract/useWaitForReceipt.ts @@ -1,14 +1,10 @@ import { useQuery, type UseQueryResult } from "@tanstack/react-query"; import type { TransactionReceipt } from "viem"; -import type { TransactionOrUserOpHash } from "../../../transaction/types.js"; import { getChainIdFromChain } from "../../../chain/index.js"; -import { waitForReceipt } from "../../../transaction/actions/wait-for-tx-receipt.js"; -import type { PreparedTransaction } from "../../../transaction/index.js"; - -export type TransactionHashOptions = TransactionOrUserOpHash & { - transaction: PreparedTransaction; - transactionHash?: string; -}; +import { + waitForReceipt, + type WaitForReceiptOptions, +} from "../../../transaction/actions/wait-for-tx-receipt.js"; /** * A hook to wait for a transaction receipt. @@ -21,7 +17,7 @@ export type TransactionHashOptions = TransactionOrUserOpHash & { * ``` */ export function useWaitForReceipt( - options: TransactionHashOptions | undefined, + options: WaitForReceiptOptions | undefined, ): UseQueryResult { // TODO: here contract can be undfined so we go to a `-1` chain but this feels wrong const chainId = getChainIdFromChain( @@ -29,16 +25,18 @@ export function useWaitForReceipt( ).toString(); return useQuery({ // eslint-disable-next-line @tanstack/query/exhaustive-deps - queryKey: ["waitForReceipt", chainId, options?.transactionHash] as const, + queryKey: [ + "waitForReceipt", + chainId, + options?.transactionHash || options?.userOpHash, + ] as const, queryFn: async () => { - if (!options?.transactionHash) { - throw new Error("No transaction hash"); + if (!options?.transactionHash && !options?.userOpHash) { + throw new Error("No transaction hash or user op hash provided"); } - return waitForReceipt({ - transaction: options.transaction, - transactionHash: options.transactionHash, - }); + return waitForReceipt(options); }, - enabled: !!options?.transactionHash, + enabled: !!options?.transactionHash || !!options?.userOpHash, + retry: false, }); } diff --git a/packages/thirdweb/src/transaction/actions/estimate-gas.ts b/packages/thirdweb/src/transaction/actions/estimate-gas.ts index 1de79bdfc11..3b9aa8f7ecc 100644 --- a/packages/thirdweb/src/transaction/actions/estimate-gas.ts +++ b/packages/thirdweb/src/transaction/actions/estimate-gas.ts @@ -29,6 +29,7 @@ export async function estimateGas( options: EstimateGasOptions, ): Promise { if (cache.has(options.transaction)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return cache.get(options.transaction)!; } const promise = (async () => { @@ -38,6 +39,11 @@ export async function estimateGas( return predefinedGas; } + // if the account itself overrides the estimateGas function, use that + if (options.account && options.account.estimateGas) { + return await options.account.estimateGas(options.transaction); + } + // load up encode function if we need it const { encode } = await import("./encode.js"); const [encodedData, toAddress] = await Promise.all([ @@ -45,15 +51,6 @@ export async function estimateGas( resolvePromisedValue(options.transaction.to), ]); - // if the account itself overrides the estimateGas function, use that - if ( - options.account && - options.account.wallet && - options.account.wallet.estimateGas - ) { - return await options.account.wallet.estimateGas(options.transaction); - } - // load up the rpc client and the estimateGas function if we need it const [{ getRpcClient }, { eth_estimateGas }] = await Promise.all([ import("../../rpc/rpc.js"), diff --git a/packages/thirdweb/src/transaction/actions/send-transaction.ts b/packages/thirdweb/src/transaction/actions/send-transaction.ts index 8830bbd2828..a49b034fe88 100644 --- a/packages/thirdweb/src/transaction/actions/send-transaction.ts +++ b/packages/thirdweb/src/transaction/actions/send-transaction.ts @@ -8,6 +8,7 @@ import type { PreparedTransaction } from "../prepare-transaction.js"; type SendTransactionOptions = { transaction: PreparedTransaction; account: Account; + gasless?: boolean; }; /** diff --git a/packages/thirdweb/src/transaction/actions/wait-for-tx-receipt.ts b/packages/thirdweb/src/transaction/actions/wait-for-tx-receipt.ts index 3757298b29b..9281c45e409 100644 --- a/packages/thirdweb/src/transaction/actions/wait-for-tx-receipt.ts +++ b/packages/thirdweb/src/transaction/actions/wait-for-tx-receipt.ts @@ -6,6 +6,7 @@ import { getRpcClient, watchBlockNumber, } from "../../rpc/index.js"; +import { getUserOpEventFromEntrypoint } from "../../wallets/smart/lib/receipts.js"; import type { PreparedTransaction } from "../prepare-transaction.js"; const MAX_BLOCKS_WAIT_TIME = 10; @@ -44,7 +45,7 @@ export function waitForReceipt( } const promise = new Promise((resolve, reject) => { // TODO: handle useropHash - if (!transactionHash) { + if (!transactionHash && !userOpHash) { reject( new Error("Transaction has no txHash to wait for, did you execute it?"), ); @@ -54,25 +55,62 @@ export function waitForReceipt( // start at -1 because the first block doesn't count let blocksWaited = -1; + let lastBlockNumber: bigint | undefined; const unwatch = watchBlockNumber({ client: transaction.client, chain: transaction.chain, - onNewBlockNumber: async () => { + onNewBlockNumber: async (blockNumber) => { blocksWaited++; if (blocksWaited >= MAX_BLOCKS_WAIT_TIME) { unwatch(); reject(new Error("Transaction not found after 10 blocks")); + return; } try { - const receipt = await eth_getTransactionReceipt(request, { - hash: transactionHash as Hex, - }); + if (transactionHash) { + const receipt = await eth_getTransactionReceipt(request, { + hash: transactionHash as Hex, + }); - // stop the polling - unwatch(); - // resolve the top level promise with the receipt - resolve(receipt); + // stop the polling + unwatch(); + // resolve the top level promise with the receipt + resolve(receipt); + } else if (userOpHash) { + let event; + try { + event = await getUserOpEventFromEntrypoint({ + blockNumber: blockNumber, + blockRange: lastBlockNumber ? 2n : 2000n, // query backwards further on first tick + chain: transaction.chain, + client: transaction.client, + userOpHash: userOpHash, + }); + } catch (e) { + console.error(e); + // stop the polling + unwatch(); + // userOp reverted + reject(e); + return; + } + + lastBlockNumber = blockNumber; + if (event) { + console.log("event", event); + const receipt = await eth_getTransactionReceipt(request, { + hash: event.transactionHash, + }); + + // TODO check if the event has success = false and decode the revert reason + + // stop the polling + unwatch(); + // resolve the top level promise with the receipt + resolve(receipt); + } + } } catch { // noop, we'll try again on the next blocks } diff --git a/packages/thirdweb/src/utils/bytecode/extractIPFS.ts b/packages/thirdweb/src/utils/bytecode/extractIPFS.ts index 2f871732047..a1c8a1022d2 100644 --- a/packages/thirdweb/src/utils/bytecode/extractIPFS.ts +++ b/packages/thirdweb/src/utils/bytecode/extractIPFS.ts @@ -1,5 +1,5 @@ import { hexToBytes } from "@noble/hashes/utils"; -import { encode } from "bs58"; +import bs58 from "bs58"; import { decode } from "./cbor-decode.js"; /** @@ -33,7 +33,7 @@ export function extractIPFSUri(bytecode: string): string | undefined { const cborData = decode(bytecodeBuffer); if ("ipfs" in cborData) { - return `ipfs://${encode(cborData["ipfs"])}`; + return `ipfs://${bs58.encode(cborData["ipfs"])}`; } return undefined; diff --git a/packages/thirdweb/src/wallets/index.ts b/packages/thirdweb/src/wallets/index.ts index 0fafd86facf..a82c51d862b 100644 --- a/packages/thirdweb/src/wallets/index.ts +++ b/packages/thirdweb/src/wallets/index.ts @@ -62,3 +62,6 @@ export { } from "./wallet-connect/index.js"; export type { WalletConnectConnectionOptions } from "./wallet-connect/types.js"; + +// smart +export { smartWallet } from "./smart/index.js"; diff --git a/packages/thirdweb/src/wallets/injected/index.ts b/packages/thirdweb/src/wallets/injected/index.ts index 77df77ad34e..bc8f255f543 100644 --- a/packages/thirdweb/src/wallets/injected/index.ts +++ b/packages/thirdweb/src/wallets/injected/index.ts @@ -284,8 +284,12 @@ export class InjectedWallet implements Wallet { } const messageToSign = (() => { - if (typeof message === "string") return stringToHex(message); - if (message.raw instanceof Uint8Array) return toHex(message.raw); + if (typeof message === "string") { + return stringToHex(message); + } + if (message.raw instanceof Uint8Array) { + return toHex(message.raw); + } return message.raw; })(); diff --git a/packages/thirdweb/src/wallets/interfaces/wallet.ts b/packages/thirdweb/src/wallets/interfaces/wallet.ts index 710cb642246..da963298231 100644 --- a/packages/thirdweb/src/wallets/interfaces/wallet.ts +++ b/packages/thirdweb/src/wallets/interfaces/wallet.ts @@ -26,7 +26,6 @@ export type Wallet = { // OPTIONAL chainId?: bigint; - estimateGas?: (transaction: PreparedTransaction) => Promise; events?: { addListener: WalletEventListener; @@ -53,6 +52,7 @@ export type Account = { // OPTIONAL signTransaction?: (tx: TransactionSerializable) => Promise; + estimateGas?: (tx: PreparedTransaction) => Promise; // TODO: figure out a path to remove this (or reduce it to the minimum possible interface) /** diff --git a/packages/thirdweb/src/wallets/smart/index.ts b/packages/thirdweb/src/wallets/smart/index.ts new file mode 100644 index 00000000000..25a27185ada --- /dev/null +++ b/packages/thirdweb/src/wallets/smart/index.ts @@ -0,0 +1,113 @@ +import type { + Account, + SendTransactionOption, + Wallet, +} from "../interfaces/wallet.js"; +import type { SmartWalletOptions } from "./types.js"; +import { createUnsignedUserOp, signUserOp } from "./lib/userop.js"; +import { bundleUserOp } from "./lib/bundler.js"; +import { getContract, type ThirdwebContract } from "../../contract/contract.js"; +import { toHex } from "viem"; +import { readContract } from "../../transaction/read-contract.js"; + +/** + * Creates a smart wallet. + * @param options - The options for the smart wallet. + * @returns The created wallet. + * @example + * ```ts + * import { smartWallet } from "thirdweb"; + * const wallet = await smartWallet({ + * client, + * chain, + * personalAccount: myAccount, + * gasless: true, + * }); + * ``` + */ +export function smartWallet(options: SmartWalletOptions): Wallet { + const wallet = { + metadata: { + name: "SmartWallet", + id: "smart-wallet", + iconUrl: "", + }, + async connect(): Promise { + return smartAccount(wallet, options); + }, + async autoConnect(): Promise { + throw new Error("Method not implemented."); + }, + async disconnect(): Promise { + // TODO + }, + }; + return wallet; +} + +async function smartAccount( + wallet: Wallet, + options: SmartWalletOptions, +): Promise { + const factoryContract = getContract({ + client: options.client, + address: options.factoryAddress, + chain: options.chain, + }); + const accountAddress = await predictAddress(factoryContract, options); + const accountContract = getContract({ + client: options.client, + address: accountAddress, + chain: options.chain, + }); + return { + wallet, + address: accountAddress, + async sendTransaction(tx: SendTransactionOption) { + const unsignedUserOp = await createUnsignedUserOp({ + factoryContract, + accountContract, + transaction: tx, + options, + }); + const signedUserOp = await signUserOp({ + options, + userOp: unsignedUserOp, + }); + const userOpHash = await bundleUserOp({ + options, + userOp: signedUserOp, + }); + return { + userOpHash, + }; + }, + async estimateGas() { + // TODO break down the process so estimate gas does the userOp estimation without doing double work + return 0n; + }, + async signMessage({ message }) { + // TODO optionally deploy on sign + return options.personalAccount.signMessage({ message }); + }, + async signTypedData(typedData) { + // TODO optionally deploy on sign + return options.personalAccount.signTypedData(typedData); + }, + }; +} + +// TODO ppl should be able to override this +async function predictAddress( + factoryContract: ThirdwebContract, + options: SmartWalletOptions, +): Promise { + const accountAddress = + options.accountAddress || options.personalAccount.address; + const extraData = toHex(options.accountExtradata || ""); + return readContract({ + contract: factoryContract, + method: "function getAddress(address, bytes) returns (address)", + params: [accountAddress, extraData], + }); +} diff --git a/packages/thirdweb/src/wallets/smart/lib/bundler.ts b/packages/thirdweb/src/wallets/smart/lib/bundler.ts new file mode 100644 index 00000000000..4c95d282e2d --- /dev/null +++ b/packages/thirdweb/src/wallets/smart/lib/bundler.ts @@ -0,0 +1,94 @@ +import { hexToBigInt, type Hex } from "viem"; +import { getClientFetch } from "../../../utils/fetch.js"; +import type { + EstimationResult, + SmartWalletOptions, + UserOperationStruct, +} from "../types.js"; +import { + DEBUG, + ENTRYPOINT_ADDRESS, + getDefaultBundlerUrl, +} from "./constants.js"; +import { hexlifyUserOp } from "./utils.js"; + +/** + * @internal + */ +export async function bundleUserOp(args: { + userOp: UserOperationStruct; + options: SmartWalletOptions; +}): Promise { + return sendBundlerRequest({ + ...args, + operation: "eth_sendUserOperation", + }); +} + +/** + * @internal + */ +export async function estimateUserOpGas(args: { + userOp: UserOperationStruct; + options: SmartWalletOptions; +}): Promise { + const res = await sendBundlerRequest({ + ...args, + operation: "eth_estimateUserOperationGas", + }); + + return { + preVerificationGas: hexToBigInt(res.preVerificationGas), + verificationGas: hexToBigInt(res.verificationGas), + verificationGasLimit: hexToBigInt(res.verificationGasLimit), + callGasLimit: hexToBigInt(res.callGasLimit), + }; +} + +async function sendBundlerRequest(args: { + userOp: UserOperationStruct; + options: SmartWalletOptions; + operation: "eth_estimateUserOperationGas" | "eth_sendUserOperation"; +}) { + const { userOp, options, operation } = args; + const hexifiedUserOp = await hexlifyUserOp(userOp); + const jsonRequestData: [UserOperationStruct, string] = [ + hexifiedUserOp, + options.entrypointAddress ?? ENTRYPOINT_ADDRESS, + ]; + const bundlerUrl = options.bundlerUrl ?? getDefaultBundlerUrl(options.chain); + const fetchWithHeaders = getClientFetch(options.client); + const response = await fetchWithHeaders(bundlerUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: operation, + params: jsonRequestData, + }), + }); + const res = await response.json(); + + if (!response.ok || !res.result) { + let error = res.error || response.statusText; + if (typeof error === "object") { + error = JSON.stringify(error); + } + const code = res.code || "UNKNOWN"; + + throw new Error( + `${operation} error: ${error} +Status: ${response.status} +Code: ${code}`, + ); + } + + if (DEBUG) { + console.debug(`${operation} result:`, res); + } + + return res.result; +} diff --git a/packages/thirdweb/src/wallets/smart/lib/constants.ts b/packages/thirdweb/src/wallets/smart/lib/constants.ts new file mode 100644 index 00000000000..739de5259c2 --- /dev/null +++ b/packages/thirdweb/src/wallets/smart/lib/constants.ts @@ -0,0 +1,63 @@ +import { getChainIdFromChain, type Chain } from "../../../chain/index.js"; + +// dev only +export const DEBUG = true; + +export const DUMMY_SIGNATURE = + "0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c"; + +export const ENTRYPOINT_ADDRESS = "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"; // v0.6 + +/** + * @internal + */ +export const getDefaultBundlerUrl = (chain: Chain) => { + const chainId = getChainIdFromChain(chain); + return `https://${chainId}.bundler.thirdweb.com/`; +}; + +/** + * @internal + */ +export const getDefaultPaymasterUrl = (chain: Chain) => { + const chainId = getChainIdFromChain(chain); + return `https://${chainId}.bundler.thirdweb.com/v2`; +}; + +/** + * @internal + */ +export const USER_OP_EVENT_ABI = { + type: "event", + name: "UserOperationEvent", + inputs: [ + { + name: "userOpHash", + type: "bytes32", + indexed: true, + internalType: "bytes32", + }, + { name: "sender", type: "address", indexed: true, internalType: "address" }, + { + name: "paymaster", + type: "address", + indexed: true, + internalType: "address", + }, + { name: "nonce", type: "uint256", indexed: false, internalType: "uint256" }, + { name: "success", type: "bool", indexed: false, internalType: "bool" }, + { + name: "actualGasCost", + type: "uint256", + indexed: false, + internalType: "uint256", + }, + { + name: "actualGasUsed", + type: "uint256", + indexed: false, + internalType: "uint256", + }, + ], + anonymous: false, +} as const; diff --git a/packages/thirdweb/src/wallets/smart/lib/paymaster.ts b/packages/thirdweb/src/wallets/smart/lib/paymaster.ts new file mode 100644 index 00000000000..a10c48591f7 --- /dev/null +++ b/packages/thirdweb/src/wallets/smart/lib/paymaster.ts @@ -0,0 +1,86 @@ +import { hexToBigInt } from "viem/utils"; +import { getClientFetch } from "../../../utils/fetch.js"; +import type { + PaymasterResult, + SmartWalletOptions, + UserOperationStruct, +} from "../types.js"; +import { + DEBUG, + ENTRYPOINT_ADDRESS, + getDefaultPaymasterUrl, +} from "./constants.js"; +import { hexlifyUserOp } from "./utils.js"; + +/** + * TODO Docs + * @internal + */ +export async function getPaymasterAndData(args: { + userOp: UserOperationStruct; + options: SmartWalletOptions; +}): Promise { + const { userOp, options } = args; + const headers: Record = { + "Content-Type": "application/json", + }; + + const client = options.client; + const paymasterUrl = getDefaultPaymasterUrl(options.chain); + const entrypoint = options.entrypointAddress ?? ENTRYPOINT_ADDRESS; + + // Ask the paymaster to sign the transaction and return a valid paymasterAndData value. + const fetchWithHeaders = getClientFetch(client); + const response = await fetchWithHeaders(paymasterUrl, { + method: "POST", + headers, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "pm_sponsorUserOperation", + params: [await hexlifyUserOp(userOp), entrypoint], + }), + }); + const res = await response.json(); + + if (!response.ok) { + const error = res.error || response.statusText; + const code = res.code || "UNKNOWN"; + + throw new Error( + `Paymaster error: ${error} +Status: ${response.status} +Code: ${code}`, + ); + } + + if (DEBUG) { + console.debug("Paymaster result:", res); + } + + if (res.result) { + // some paymasters return a string, some return an object with more data + if (typeof res.result === "string") { + return { + paymasterAndData: res.result, + }; + } else { + return { + paymasterAndData: res.result.paymasterAndData, + verificationGasLimit: res.result.verificationGasLimit + ? hexToBigInt(res.result.verificationGasLimit) + : undefined, + preVerificationGas: res.result.preVerificationGas + ? hexToBigInt(res.result.preVerificationGas) + : undefined, + callGasLimit: res.result.callGasLimit + ? hexToBigInt(res.result.callGasLimit) + : undefined, + }; + } + } else { + const error = + res.error?.message || res.error || response.statusText || "unknown error"; + throw new Error(`Paymaster error from ${paymasterUrl}: ${error}`); + } +} diff --git a/packages/thirdweb/src/wallets/smart/lib/receipts.ts b/packages/thirdweb/src/wallets/smart/lib/receipts.ts new file mode 100644 index 00000000000..b3ef51483e7 --- /dev/null +++ b/packages/thirdweb/src/wallets/smart/lib/receipts.ts @@ -0,0 +1,64 @@ +import { decodeAbiParameters } from "viem"; +import type { Chain } from "../../../chain/index.js"; +import type { ThirdwebClient } from "../../../client/client.js"; +import { getContract } from "../../../contract/contract.js"; +import { getEvents, prepareEvent } from "../../../event/index.js"; +import { ENTRYPOINT_ADDRESS, USER_OP_EVENT_ABI } from "./constants.js"; + +/** + * @internal + */ +export async function getUserOpEventFromEntrypoint(args: { + blockNumber: bigint; + blockRange: bigint; + client: ThirdwebClient; + chain: Chain; + userOpHash: string; +}) { + const { blockNumber, blockRange, chain, userOpHash, client } = args; + console.log("getUserOpEventFromEntrypoint", blockNumber, blockRange); + const fromBlock = + blockNumber > blockRange ? blockNumber - blockRange : blockNumber; + const entryPointContract = getContract({ + address: ENTRYPOINT_ADDRESS, + chain: chain, + client: client, + }); + const userOpEvent = prepareEvent({ + contract: entryPointContract, + event: USER_OP_EVENT_ABI, + }); + const events = await getEvents({ + contract: entryPointContract, + events: [userOpEvent], + fromBlock, + }); + // FIXME typing + const event = events.find((e) => (e.args as any).userOpHash === userOpHash); + // UserOp can revert, so we need to surface revert reason + if ((event?.args as any)?.success === false) { + const revertOpEvent = prepareEvent({ + contract: entryPointContract, + event: "UserOperationRevertReason", + }); + const revertEvent = await getEvents({ + contract: entryPointContract, + events: [revertOpEvent], + fromBlock: event?.blockNumber, + toBlock: event?.blockNumber, + }); + if (revertEvent && revertEvent.length > 0) { + let message: string = (revertEvent[0]?.args as any)?.revertReason; + if (message.startsWith("0x08c379a0")) { + message = decodeAbiParameters( + [{ type: "string" }], + `0x${message.substring(10)}`, + )[0]; + } + throw new Error(`UserOp failed with reason: ${message}`); + } else { + throw new Error("UserOp failed with unknown reason"); + } + } + return event; +} diff --git a/packages/thirdweb/src/wallets/smart/lib/userop.ts b/packages/thirdweb/src/wallets/smart/lib/userop.ts new file mode 100644 index 00000000000..3599b0887a7 --- /dev/null +++ b/packages/thirdweb/src/wallets/smart/lib/userop.ts @@ -0,0 +1,239 @@ +import { keccak256, concat, type Hex, toHex, encodeAbiParameters } from "viem"; +import type { SmartWalletOptions, UserOperationStruct } from "../types.js"; +import type { SendTransactionOption } from "../../interfaces/wallet.js"; +import { isContractDeployed } from "../../../utils/bytecode/is-contract-deployed.js"; +import type { ThirdwebContract } from "../../../contract/contract.js"; +import { encode } from "../../../transaction/actions/encode.js"; +import { getDefaultGasOverrides } from "../../../gas/fee-data.js"; +import { getChainIdFromChain, prepareContractCall } from "../../../index.js"; +import { DUMMY_SIGNATURE, ENTRYPOINT_ADDRESS } from "./constants.js"; +import { getPaymasterAndData } from "./paymaster.js"; +import { estimateUserOpGas } from "./bundler.js"; +import { randomNonce } from "./utils.js"; + +/** + * Create an unsigned user operation + * @internal + */ +/** + * Creates an unsigned user operation. + * @internal + */ +export async function createUnsignedUserOp(args: { + factoryContract: ThirdwebContract; + accountContract: ThirdwebContract; + transaction: SendTransactionOption; + options: SmartWalletOptions; +}): Promise { + const { factoryContract, accountContract, transaction, options } = args; + const isDeployed = await isContractDeployed(accountContract); + const initCode = isDeployed + ? "0x" + : await getAccountInitCode({ + factoryContract, + options, + }); + const executeTx = prepareExecute( + accountContract, + transaction.to || "", // TODO check if this works with direct deploys + transaction.value || 0n, + transaction.data || "0x", + ); + const callData = await encode(executeTx); + let { maxFeePerGas, maxPriorityFeePerGas } = transaction; + if (!maxFeePerGas || !maxPriorityFeePerGas) { + const feeData = await getDefaultGasOverrides( + factoryContract.client, + factoryContract.chain, + ); + if (!maxPriorityFeePerGas) { + maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? undefined; + } + if (!maxFeePerGas) { + maxFeePerGas = feeData.maxFeePerGas ?? undefined; + } + } + + //const nonce = BigInt(transaction.nonce || randomNonce()); + const nonce = randomNonce(); // FIXME getNonce should be overrideable by the wallet + + const partialOp: UserOperationStruct = { + sender: accountContract.address, + nonce, + initCode, + callData, + maxFeePerGas: maxFeePerGas ?? 0n, + maxPriorityFeePerGas: maxPriorityFeePerGas ?? 0n, + callGasLimit: 0n, + verificationGasLimit: 0n, + preVerificationGas: 0n, + paymasterAndData: "0x", + signature: DUMMY_SIGNATURE, + }; + + const gasless = options.gasless; + if (gasless) { + const paymasterResult = await getPaymasterAndData({ + userOp: partialOp, + options, + }); + console.log("PM", paymasterResult); + const paymasterAndData = paymasterResult.paymasterAndData; + if (paymasterAndData && paymasterAndData !== "0x") { + partialOp.paymasterAndData = paymasterAndData; + } + // paymaster can have the gas limits in the response + if ( + paymasterResult.callGasLimit && + paymasterResult.verificationGasLimit && + paymasterResult.preVerificationGas + ) { + partialOp.callGasLimit = paymasterResult.callGasLimit; + partialOp.verificationGasLimit = paymasterResult.verificationGasLimit; + partialOp.preVerificationGas = paymasterResult.preVerificationGas; + } else { + // otherwise fallback to bundler for gas limits + const estimates = await estimateUserOpGas({ + userOp: partialOp, + options, + }); + partialOp.callGasLimit = estimates.callGasLimit; + partialOp.verificationGasLimit = estimates.verificationGasLimit; + partialOp.preVerificationGas = estimates.preVerificationGas; + // need paymaster to re-sign after estimates + if (paymasterAndData && paymasterAndData !== "0x") { + const paymasterResult2 = await getPaymasterAndData({ + userOp: partialOp, + options, + }); + if ( + paymasterResult2.paymasterAndData && + paymasterResult2.paymasterAndData !== "0x" + ) { + partialOp.paymasterAndData = paymasterResult2.paymasterAndData; + } + } + } + } else { + // not gasless, so we just need to estimate gas limits + const estimates = await estimateUserOpGas({ + userOp: partialOp, + options, + }); + partialOp.callGasLimit = estimates.callGasLimit; + partialOp.verificationGasLimit = estimates.verificationGasLimit; + partialOp.preVerificationGas = estimates.preVerificationGas; + } + + return { + ...partialOp, + signature: "0x", + }; +} + +/** + * Sign the filled userOp. + * @param userOp - The UserOperation to sign (with signature field ignored) + * @internal + */ +export async function signUserOp(args: { + userOp: UserOperationStruct; + options: SmartWalletOptions; +}): Promise { + const { userOp, options } = args; + const userOpHash = getUserOpHash({ + userOp, + entryPoint: options.entrypointAddress || ENTRYPOINT_ADDRESS, + chainId: getChainIdFromChain(options.chain), + }); + if (options.personalAccount.signMessage) { + const signature = await options.personalAccount.signMessage({ + message: { + raw: userOpHash, + }, + }); + return { + ...userOp, + signature, + }; + } else { + throw new Error("signMessage not implemented in signingAccount"); + } +} + +async function getAccountInitCode(args: { + factoryContract: ThirdwebContract; + options: SmartWalletOptions; +}): Promise { + const { factoryContract, options } = args; + const accountAddress = + options.accountAddress || options.personalAccount.address; + const extraData = toHex(options.accountExtradata || ""); + const deployTx = prepareContractCall({ + contract: factoryContract, + method: "function createAccount(address, bytes) public returns (address)", + params: [accountAddress, extraData], + }); + return concat([factoryContract.address as Hex, await encode(deployTx)]); +} + +// TODO should be able to be overriden in options +function prepareExecute( + accountContract: ThirdwebContract, + target: string, + value: bigint, + data: Hex, +) { + const tx = prepareContractCall({ + contract: accountContract, + method: "function execute(address, uint256, bytes)", + params: [target, value, data], + }); + return tx; +} + +/** + * @internal + */ +export function getUserOpHash(args: { + userOp: UserOperationStruct; + entryPoint: string; + chainId: bigint; +}): Hex { + const { userOp, entryPoint, chainId } = args; + const hashedInitCode = keccak256(userOp.initCode); + const hashedCallData = keccak256(userOp.callData); + const hashedPaymasterAndData = keccak256(userOp.paymasterAndData); + + const packedUserOp = encodeAbiParameters( + [ + { type: "address" }, + { type: "uint256" }, + { type: "bytes32" }, + { type: "bytes32" }, + { type: "uint256" }, + { type: "uint256" }, + { type: "uint256" }, + { type: "uint256" }, + { type: "uint256" }, + { type: "bytes32" }, + ], + [ + userOp.sender, + userOp.nonce, + hashedInitCode, + hashedCallData, + userOp.callGasLimit, + userOp.verificationGasLimit, + userOp.preVerificationGas, + userOp.maxFeePerGas, + userOp.maxPriorityFeePerGas, + hashedPaymasterAndData, + ], + ); + const encoded = encodeAbiParameters( + [{ type: "bytes32" }, { type: "address" }, { type: "uint256" }], + [keccak256(packedUserOp), entryPoint, chainId], + ); + return keccak256(encoded); +} diff --git a/packages/thirdweb/src/wallets/smart/lib/utils.ts b/packages/thirdweb/src/wallets/smart/lib/utils.ts new file mode 100644 index 00000000000..00cdcfb704f --- /dev/null +++ b/packages/thirdweb/src/wallets/smart/lib/utils.ts @@ -0,0 +1,47 @@ +import { concat, toHex } from "viem"; +import type { UserOperationStruct } from "../types.js"; + +const generateRandomUint192 = (): bigint => { + const rand1 = BigInt(Math.floor(Math.random() * 0x100000000)); + const rand2 = BigInt(Math.floor(Math.random() * 0x100000000)); + const rand3 = BigInt(Math.floor(Math.random() * 0x100000000)); + const rand4 = BigInt(Math.floor(Math.random() * 0x100000000)); + const rand5 = BigInt(Math.floor(Math.random() * 0x100000000)); + const rand6 = BigInt(Math.floor(Math.random() * 0x100000000)); + return ( + (rand1 << BigInt(160)) | + (rand2 << BigInt(128)) | + (rand3 << BigInt(96)) | + (rand4 << BigInt(64)) | + (rand5 << BigInt(32)) | + rand6 + ); +}; + +/** + * @internal + */ +export const randomNonce = () => { + return BigInt(concat([toHex(generateRandomUint192()), "0x0000000000000000"])); +}; + +/** + * @internal + */ +export async function hexlifyUserOp(userOp: UserOperationStruct): Promise { + return Object.keys(userOp) + .map((key) => { + let val = (userOp as any)[key]; + if (typeof val !== "string" || !val.startsWith("0x")) { + val = toHex(val); + } + return [key, val]; + }) + .reduce( + (set, [k, v]) => ({ + ...set, + [k]: v, + }), + {}, + ); +} diff --git a/packages/thirdweb/src/wallets/smart/types.ts b/packages/thirdweb/src/wallets/smart/types.ts new file mode 100644 index 00000000000..456e7c83d19 --- /dev/null +++ b/packages/thirdweb/src/wallets/smart/types.ts @@ -0,0 +1,44 @@ +import type { Chain } from "../../chain/index.js"; +import type { ThirdwebClient } from "../../client/client.js"; +import type { Account } from "../interfaces/wallet.js"; +import type { Address, Hex } from "viem"; + +export type SmartWalletOptions = { + client: ThirdwebClient; + personalAccount: Account; + chain: Chain; + gasless: boolean; + factoryAddress: string; // TODO make this optional + accountExtradata?: string; + accountAddress?: string; + entrypointAddress?: string; + bundlerUrl?: string; +}; + +export type UserOperationStruct = { + sender: Address; + nonce: bigint; + initCode: Hex | Uint8Array; + callData: Hex | Uint8Array; + callGasLimit: bigint; + verificationGasLimit: bigint; + preVerificationGas: bigint; + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; + paymasterAndData: Hex | Uint8Array; + signature: Hex | Uint8Array; +}; + +export type PaymasterResult = { + paymasterAndData: Hex; + preVerificationGas?: bigint; + verificationGasLimit?: bigint; + callGasLimit?: bigint; +}; + +export type EstimationResult = { + preVerificationGas: bigint; + verificationGas: bigint; + verificationGasLimit: bigint; + callGasLimit: bigint; +};