diff --git a/.changeset/strong-beans-pump.md b/.changeset/strong-beans-pump.md new file mode 100644 index 00000000000..e30436a8344 --- /dev/null +++ b/.changeset/strong-beans-pump.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Support ERC6492 for smart account signatures diff --git a/packages/thirdweb/src/auth/verify-hash.ts b/packages/thirdweb/src/auth/verify-hash.ts index 66c1971c59e..51f20d5201d 100644 --- a/packages/thirdweb/src/auth/verify-hash.ts +++ b/packages/thirdweb/src/auth/verify-hash.ts @@ -129,7 +129,8 @@ export async function verifyHash({ try { const result = await eth_call(rpcRequest, verificationData); return hexToBool(result); - } catch { + } catch (err) { + console.error("Error verifying ERC-6492 signature", err); // Some chains do not support the eth_call simulation and will fail, so we fall back to regular EIP1271 validation const validEip1271 = await verifyEip1271Signature({ hash, @@ -153,7 +154,7 @@ export async function verifyHash({ } const EIP_1271_MAGIC_VALUE = "0x1626ba7e"; -export async function verifyEip1271Signature({ +async function verifyEip1271Signature({ hash, signature, contract, diff --git a/packages/thirdweb/src/wallets/smart/index.ts b/packages/thirdweb/src/wallets/smart/index.ts index d28e048da73..e4014718058 100644 --- a/packages/thirdweb/src/wallets/smart/index.ts +++ b/packages/thirdweb/src/wallets/smart/index.ts @@ -284,8 +284,8 @@ async function createSmartAccount( const { deployAndSignMessage } = await import("./lib/signing.js"); return deployAndSignMessage({ - account, accountContract, + factoryContract: options.factoryContract, options, message, }); @@ -305,8 +305,8 @@ async function createSmartAccount( const { deployAndSignTypedData } = await import("./lib/signing.js"); return deployAndSignTypedData({ - account, accountContract, + factoryContract: options.factoryContract, options, typedData, }); diff --git a/packages/thirdweb/src/wallets/smart/lib/signing.ts b/packages/thirdweb/src/wallets/smart/lib/signing.ts index d87c2a783de..ff0620740f9 100644 --- a/packages/thirdweb/src/wallets/smart/lib/signing.ts +++ b/packages/thirdweb/src/wallets/smart/lib/signing.ts @@ -1,57 +1,41 @@ +import type { Hex } from "ox"; import type { SignableMessage, TypedData, TypedDataDefinition, TypedDataDomain, } from "viem"; +import { serializeErc6492Signature } from "../../../auth/serialize-erc6492-signature.js"; +import { verifyHash } from "../../../auth/verify-hash.js"; import { - verifyEip1271Signature, - verifyHash, -} from "../../../auth/verify-hash.js"; -import type { ThirdwebContract } from "../../../contract/contract.js"; + type ThirdwebContract, + getContract, +} from "../../../contract/contract.js"; +import { encode } from "../../../transaction/actions/encode.js"; import { readContract } from "../../../transaction/read-contract.js"; import { encodeAbiParameters } from "../../../utils/abi/encodeAbiParameters.js"; -import { isContractDeployed } from "../../../utils/bytecode/is-contract-deployed.js"; import { hashMessage } from "../../../utils/hashing/hashMessage.js"; import { hashTypedData } from "../../../utils/hashing/hashTypedData.js"; -import type { Account } from "../../interfaces/wallet.js"; import type { SmartAccountOptions } from "../types.js"; +import { prepareCreateAccount } from "./calls.js"; export async function deployAndSignMessage({ - account, accountContract, + factoryContract, options, message, }: { - account: Account; accountContract: ThirdwebContract; + factoryContract: ThirdwebContract; options: SmartAccountOptions; message: SignableMessage; }) { - const isDeployed = await isContractDeployed(accountContract); - if (!isDeployed) { - await _deployAccount({ - options, - account, - accountContract, - }); - // the bundler and rpc might not be in sync, so while the bundler has a transaction hash for the deployment, - // the rpc might not have it yet, so we wait until the rpc confirms the contract is deployed - await confirmContractDeployment({ - accountContract, - }); - } - const originalMsgHash = hashMessage(message); - // check if the account contract supports EIP721 domain separator or modular based signing - const is712Factory = await readContract({ - contract: accountContract, - method: - "function getMessageHash(bytes32 _hash) public view returns (bytes32)", - params: [originalMsgHash], - }) - .then((res) => res !== "0x") - .catch(() => false); + const is712Factory = await checkFor712Factory({ + factoryContract, + accountContract, + originalMsgHash, + }); let sig: `0x${string}`; if (is712Factory) { @@ -75,17 +59,36 @@ export async function deployAndSignMessage({ sig = await options.personalAccount.signMessage({ message }); } - const isValid = await verifyEip1271Signature({ - contract: accountContract, - hash: originalMsgHash, + const deployTx = prepareCreateAccount({ + factoryContract, + adminAddress: options.personalAccount.address, + accountSalt: options.overrides?.accountSalt, + createAccountOverride: options.overrides?.createAccount, + }); + if (!deployTx) { + throw new Error("Create account override not provided"); + } + const initCode = await encode(deployTx); + const erc6492Sig = serializeErc6492Signature({ + address: factoryContract.address, + data: initCode, signature: sig, }); + // check if the signature is valid + const isValid = await verifyHash({ + hash: originalMsgHash, + signature: erc6492Sig, + address: accountContract.address, + chain: accountContract.chain, + client: accountContract.client, + }); + if (isValid) { - return sig; + return erc6492Sig; } throw new Error( - "Unable to verify signature on smart account, please make sure the smart account is deployed and the signature is valid.", + "Unable to verify signature on smart account, please make sure the admin wallet has permissions and the signature is valid.", ); } @@ -93,13 +96,13 @@ export async function deployAndSignTypedData< const typedData extends TypedData | Record, primaryType extends keyof typedData | "EIP712Domain" = keyof typedData, >({ - account, accountContract, + factoryContract, options, typedData, }: { - account: Account; accountContract: ThirdwebContract; + factoryContract: ThirdwebContract; options: SmartAccountOptions; typedData: TypedDataDefinition; }) { @@ -112,38 +115,16 @@ export async function deployAndSignTypedData< return options.personalAccount.signTypedData(typedData); } - const isDeployed = await isContractDeployed(accountContract); - if (!isDeployed) { - await _deployAccount({ - options, - account, - accountContract, - }); - // the bundler and rpc might not be in sync, so while the bundler has a transaction hash for the deployment, - // the rpc might not have it yet, so we wait until the rpc confirms the contract is deployed - await confirmContractDeployment({ - accountContract, - }); - } - const originalMsgHash = hashTypedData(typedData); // check if the account contract supports EIP721 domain separator based signing - let factorySupports712 = false; - try { - // this will throw if the contract does not support it (old factories) - await readContract({ - contract: accountContract, - method: - "function getMessageHash(bytes32 _hash) public view returns (bytes32)", - params: [originalMsgHash], - }); - factorySupports712 = true; - } catch { - // ignore - } + const is712Factory = await checkFor712Factory({ + factoryContract, + accountContract, + originalMsgHash, + }); let sig: `0x${string}`; - if (factorySupports712) { + if (is712Factory) { const wrappedMessageHash = encodeAbiParameters( [{ type: "bytes32" }], [originalMsgHash], @@ -163,46 +144,39 @@ export async function deployAndSignTypedData< sig = await options.personalAccount.signTypedData(typedData); } + const deployTx = prepareCreateAccount({ + factoryContract, + adminAddress: options.personalAccount.address, + accountSalt: options.overrides?.accountSalt, + createAccountOverride: options.overrides?.createAccount, + }); + if (!deployTx) { + throw new Error("Create account override not provided"); + } + const initCode = await encode(deployTx); + const erc6492Sig = serializeErc6492Signature({ + address: factoryContract.address, + data: initCode, + signature: sig, + }); + + // check if the signature is valid const isValid = await verifyHash({ hash: originalMsgHash, - signature: sig, + signature: erc6492Sig, address: accountContract.address, - chain: options.chain, - client: options.client, + chain: accountContract.chain, + client: accountContract.client, }); if (isValid) { - return sig; + return erc6492Sig; } throw new Error( - "Unable to verify signature on smart account, please make sure the smart account is deployed and the signature is valid.", + "Unable to verify signature on smart account, please make sure the admin wallet has permissions and the signature is valid.", ); } -async function _deployAccount(args: { - options: SmartAccountOptions; - account: Account; - accountContract: ThirdwebContract; -}) { - const { options, account, accountContract } = args; - const [{ sendTransaction }, { prepareTransaction }] = await Promise.all([ - import("../../../transaction/actions/send-transaction.js"), - import("../../../transaction/prepare-transaction.js"), - ]); - const dummyTx = prepareTransaction({ - client: options.client, - chain: options.chain, - to: accountContract.address, - value: 0n, - gas: 50000n, // force gas to avoid simulation error - }); - const deployResult = await sendTransaction({ - transaction: dummyTx, - account, - }); - return deployResult; -} - export async function confirmContractDeployment(args: { accountContract: ThirdwebContract; }) { @@ -223,3 +197,37 @@ export async function confirmContractDeployment(args: { isDeployed = await isContractDeployed(accountContract); } } + +async function checkFor712Factory({ + factoryContract, + accountContract, + originalMsgHash, +}: { + factoryContract: ThirdwebContract; + accountContract: ThirdwebContract; + originalMsgHash: Hex.Hex; +}) { + try { + const implementationAccount = await readContract({ + contract: factoryContract, + method: "function accountImplementation() public view returns (address)", + }); + // check if the account contract supports EIP721 domain separator or modular based signing + const is712Factory = await readContract({ + contract: getContract({ + address: implementationAccount, + chain: accountContract.chain, + client: accountContract.client, + }), + method: + "function getMessageHash(bytes32 _hash) public view returns (bytes32)", + params: [originalMsgHash], + }) + .then((res) => res !== "0x") + .catch(() => false); + + return is712Factory; + } catch { + return false; + } +} diff --git a/packages/thirdweb/src/wallets/smart/smart-wallet-integration-v07.test.ts b/packages/thirdweb/src/wallets/smart/smart-wallet-integration-v07.test.ts index 9b86767f8af..015bb6cf8b2 100644 --- a/packages/thirdweb/src/wallets/smart/smart-wallet-integration-v07.test.ts +++ b/packages/thirdweb/src/wallets/smart/smart-wallet-integration-v07.test.ts @@ -5,6 +5,7 @@ import { verifySignature } from "../../auth/verify-signature.js"; import { type ThirdwebContract, getContract } from "../../contract/contract.js"; import { parseEventLogs } from "../../event/actions/parse-logs.js"; +import { verifyTypedData } from "../../auth/verify-typed-data.js"; import { sepolia } from "../../chains/chain-definitions/sepolia.js"; import { addAdmin, @@ -12,7 +13,6 @@ import { } from "../../exports/extensions/erc4337.js"; import { balanceOf } from "../../extensions/erc1155/__generated__/IERC1155/read/balanceOf.js"; import { claimTo } from "../../extensions/erc1155/drops/write/claimTo.js"; -import { checkContractWalletSignature } from "../../extensions/erc1271/checkContractWalletSignature.js"; import { setContractURI } from "../../extensions/marketplace/__generated__/IMarketplace/write/setContractURI.js"; import { estimateGasCost } from "../../transaction/actions/estimate-gas-cost.js"; import { sendAndConfirmTransaction } from "../../transaction/actions/send-and-confirm-transaction.js"; @@ -81,6 +81,32 @@ describe.runIf(process.env.TW_SECRET_KEY)( expect(predictedAddress).toEqual(smartWalletAddress); }); + it("can sign a msg", async () => { + const signature = await smartAccount.signMessage({ + message: "hello world", + }); + const isValid = await verifySignature({ + message: "hello world", + signature, + address: smartWalletAddress, + chain, + client, + }); + expect(isValid).toEqual(true); + }); + + it("can sign typed data", async () => { + const signature = await smartAccount.signTypedData(typedData.basic); + const isValid = await verifyTypedData({ + signature, + address: smartWalletAddress, + chain, + client, + ...typedData.basic, + }); + expect(isValid).toEqual(true); + }); + it("should revert on unsuccessful transactions", async () => { const tx = sendAndConfirmTransaction({ transaction: setContractURI({ @@ -171,19 +197,20 @@ describe.runIf(process.env.TW_SECRET_KEY)( client, }); expect(isValidV1).toEqual(true); - const isValidV2 = await checkContractWalletSignature({ - message, - signature, - contract: accountContract, - }); - expect(isValidV2).toEqual(true); // sign typed data const signatureTyped = await smartAccount.signTypedData({ ...typedData.basic, primaryType: "Mail", }); - expect(signatureTyped.length).toBe(132); + const isValidV2 = await verifyTypedData({ + signature: signatureTyped, + address: smartWalletAddress, + chain, + client, + ...typedData.basic, + }); + expect(isValidV2).toEqual(true); // add admin const newAdmin = await generateAccount({ client }); diff --git a/packages/thirdweb/src/wallets/smart/smart-wallet-integration.test.ts b/packages/thirdweb/src/wallets/smart/smart-wallet-integration.test.ts index c578ab12a9a..012b936d319 100644 --- a/packages/thirdweb/src/wallets/smart/smart-wallet-integration.test.ts +++ b/packages/thirdweb/src/wallets/smart/smart-wallet-integration.test.ts @@ -2,18 +2,17 @@ import { beforeAll, describe, expect, it } from "vitest"; import { TEST_CLIENT } from "../../../test/src/test-clients.js"; import { typedData } from "../../../test/src/typed-data.js"; import { verifySignature } from "../../auth/verify-signature.js"; +import { verifyTypedData } from "../../auth/verify-typed-data.js"; import { arbitrumSepolia } from "../../chains/chain-definitions/arbitrum-sepolia.js"; import { type ThirdwebContract, getContract } from "../../contract/contract.js"; import { parseEventLogs } from "../../event/actions/parse-logs.js"; import { baseSepolia } from "../../exports/chains.js"; - import { addAdmin, adminUpdatedEvent, } from "../../exports/extensions/erc4337.js"; import { balanceOf } from "../../extensions/erc1155/__generated__/IERC1155/read/balanceOf.js"; import { claimTo } from "../../extensions/erc1155/drops/write/claimTo.js"; -import { checkContractWalletSignature } from "../../extensions/erc1271/checkContractWalletSignature.js"; import { setContractURI } from "../../extensions/marketplace/__generated__/IMarketplace/write/setContractURI.js"; import { estimateGasCost } from "../../transaction/actions/estimate-gas-cost.js"; import { sendAndConfirmTransaction } from "../../transaction/actions/send-and-confirm-transaction.js"; @@ -80,6 +79,32 @@ describe.runIf(process.env.TW_SECRET_KEY).sequential( expect(predictedAddress).toEqual(smartWalletAddress); }); + it("can sign a msg", async () => { + const signature = await smartAccount.signMessage({ + message: "hello world", + }); + const isValid = await verifySignature({ + message: "hello world", + signature, + address: smartWalletAddress, + chain, + client, + }); + expect(isValid).toEqual(true); + }); + + it("can sign typed data", async () => { + const signature = await smartAccount.signTypedData(typedData.basic); + const isValid = await verifyTypedData({ + signature, + address: smartWalletAddress, + chain, + client, + ...typedData.basic, + }); + expect(isValid).toEqual(true); + }); + it("should revert on unsuccessful transactions", async () => { const tx = sendAndConfirmTransaction({ transaction: setContractURI({ @@ -169,19 +194,20 @@ describe.runIf(process.env.TW_SECRET_KEY).sequential( client, }); expect(isValidV1).toEqual(true); - const isValidV2 = await checkContractWalletSignature({ - message, - signature, - contract: accountContract, - }); - expect(isValidV2).toEqual(true); // sign typed data const signatureTyped = await smartAccount.signTypedData({ ...typedData.basic, primaryType: "Mail", }); - expect(signatureTyped.length).toBe(132); + const isValidV2 = await verifyTypedData({ + signature: signatureTyped, + address: smartWalletAddress, + chain, + client, + ...typedData.basic, + }); + expect(isValidV2).toEqual(true); // add admin const newAdmin = await generateAccount({ client }); @@ -230,23 +256,22 @@ describe.runIf(process.env.TW_SECRET_KEY).sequential( client, }); expect(isValidV1).toEqual(true); - const isValidV2 = await verifySignature({ - message, - signature, - address: newAccount.address, - chain, - client, - }); - expect(isValidV2).toEqual(true); // sign typed data const signatureTyped = await newAccount.signTypedData({ ...typedData.basic, primaryType: "Mail", }); - expect(signatureTyped.length).toBe(132); + const isValidV2 = await verifyTypedData({ + signature: signatureTyped, + address: newAccount.address, + chain, + client, + ...typedData.basic, + }); + expect(isValidV2).toEqual(true); - // add admin + // add admin pre-deployment const newAdmin = await generateAccount({ client }); const receipt = await sendAndConfirmTransaction({ account: newAccount, @@ -264,8 +289,8 @@ describe.runIf(process.env.TW_SECRET_KEY).sequential( events: [adminUpdatedEvent()], logs: receipt.logs, }); - expect(logs[0]?.args.signer).toBe(newAdmin.address); - expect(logs[0]?.args.isAdmin).toBe(true); + expect(logs.map((l) => l.args.signer)).toContain(newAdmin.address); + expect(logs.map((l) => l.args.isAdmin)).toContain(true); // should not be able to switch chains since factory not deployed elsewhere await expect(