UNPKG

@biconomy/abstractjs

Version:

SDK for Biconomy integration with support for account abstraction, smart accounts, ERC-4337.

419 lines 18.4 kB
import { concatHex, encodeAbiParameters, encodePacked, keccak256, parseSignature, toHex } from "viem"; import { multicall } from "viem/actions"; import { versionIsAtLeast } from "../../../account/utils/getVersion.js"; import { MEEVersion, PERMIT_TYPEHASH } from "../../../constants/index.js"; import { TokenWithPermitAbi } from "../../../constants/abi/TokenWithPermitAbi.js"; const PERMIT_PREFIX = "0x177eee02"; export const isLegacyEIP712Domain = (name, version, verifyingContract, salt, domainSeparator) => { // Step 1: Hash the EIP712Domain type string const DOMAIN_TYPEHASH = keccak256(encodePacked(["string"], [ "EIP712Domain(string name,string version,address verifyingContract,bytes32 salt)" ])); // Step 2: Hash the name const nameHash = keccak256(encodePacked(["string"], [name])); // Step 3: Hash the version const versionHash = keccak256(encodePacked(["string"], [version])); // Step 4: Encode and hash all parameters const DOMAIN_SEPARATOR = keccak256(encodeAbiParameters([ { type: "bytes32" }, // EIP712_DOMAIN_TYPEHASH { type: "bytes32" }, // nameHash { type: "bytes32" }, // versionHash { type: "address" }, // verifyingContract { type: "bytes32" } // salt ], [DOMAIN_TYPEHASH, nameHash, versionHash, verifyingContract, salt])); return domainSeparator.toLowerCase() === DOMAIN_SEPARATOR.toLowerCase(); }; export const isDefaultEIP712Domain = (name, version, chainId, verifyingContract, domainSeparator) => { // Step 1: Hash the EIP712Domain type string const DOMAIN_TYPEHASH = keccak256(encodePacked(["string"], [ "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" ])); // Step 2: Hash the name const nameHash = keccak256(encodePacked(["string"], [name])); // Step 3: Hash the version const versionHash = keccak256(encodePacked(["string"], [version])); // Step 4: Encode and hash all parameters const DOMAIN_SEPARATOR = keccak256(encodeAbiParameters([ { type: "bytes32" }, { type: "bytes32" }, // nameHash { type: "bytes32" }, // versionHash { type: "uint256" }, // chainId { type: "address" } // verifyingContract ], [ DOMAIN_TYPEHASH, nameHash, versionHash, BigInt(chainId), verifyingContract ])); return domainSeparator.toLowerCase() === DOMAIN_SEPARATOR.toLowerCase(); }; export const isDefaultEIP712DomainWithSalt = (name, version, chainId, verifyingContract, salt, domainSeparator) => { // Step 1: Hash the EIP712Domain type string const DOMAIN_TYPEHASH = keccak256(encodePacked(["string"], [ "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)" ])); // Step 2: Hash the name const nameHash = keccak256(encodePacked(["string"], [name])); // Step 3: Hash the version const versionHash = keccak256(encodePacked(["string"], [version])); // Step 4: Encode and hash all parameters const DOMAIN_SEPARATOR = keccak256(encodeAbiParameters([ { type: "bytes32" }, // EIP712_DOMAIN_TYPEHASH { type: "bytes32" }, // nameHash { type: "bytes32" }, // versionHash { type: "uint256" }, // chainId { type: "address" }, // verifyingContract { type: "bytes32" } // salt ], [ DOMAIN_TYPEHASH, nameHash, versionHash, BigInt(chainId), verifyingContract, salt ])); return domainSeparator.toLowerCase() === DOMAIN_SEPARATOR.toLowerCase(); }; export const getEIP712DomainType = (name, version, chainId, verifyingContract, salt, domainSeparator) => { try { const isValid = isLegacyEIP712Domain(name, version, verifyingContract, salt, domainSeparator); if (isValid) { return "legacy"; } } catch { } try { const isValid = isDefaultEIP712Domain(name, version, chainId, verifyingContract, domainSeparator); if (isValid) { return "default"; } } catch { } try { const isValid = isDefaultEIP712DomainWithSalt(name, version, chainId, verifyingContract, salt, domainSeparator); if (isValid) { return "default-with-salt"; } } catch { } return "invalid"; }; /** * Prepares the payload required for signing a permit quote. * This function validates the trigger, fetches necessary token data (nonce, name, version, domain separator), * and constructs the EIP-712 signable payload for an ERC20 permit signature. * The returned object contains the signable payload and metadata required for formatting the final signed quote. * * @param quoteParams - The permit quote parameters, including the quote and trigger * @param owner - The address of the token owner (signer) * @param spender - The address that will be approved to spend the tokens * @param publicClient - The public or wallet client to interact with the token contract * @returns Promise resolving to an object containing the signable payload and metadata * * @example * ```typescript * const { signablePayload, metadata } = await prepareSignablePermitQuotePayload( * fusionQuote, * ownerAddress, * spenderAddress, * publicClient * ); * // signablePayload: EIP-712 structured data for permit * // metadata: { nonce, name, version, domainSeparator, owner, spender, amount } * ``` */ export const prepareSignablePermitQuotePayload = async (quoteParams, owner, spender, publicClient) => { const { quote, trigger } = quoteParams; // Type guard to ensure we have a TokenTrigger if (trigger.call) { throw new Error("Custom triggers are not supported for permit quotes"); } if (!trigger.amount) throw new Error("Amount is required to sign a permit quote"); // Check if we have an explicit `approvalAmount` set and error if it's smaller than the trigger amount if (trigger.approvalAmount && trigger.amount !== undefined && trigger.approvalAmount < trigger.amount) { throw new Error(`Approval amount must be bigger or equal with the amount from the trigger (triggerAmount: ${trigger.amount} amount: ${trigger.approvalAmount})`); } const amount = trigger.approvalAmount ?? trigger.amount; // If there is any error while prepare permit signature, the flow will fallback to onchain mode. // The fallbacks will happen for these errors such as, invalid permit values, domain separator mismatch, RPC issues and etc... try { // Fetch required token data for EIP-712 domain and permit using multicall const values = await multicall(publicClient, { contracts: [ { address: trigger.tokenAddress, abi: TokenWithPermitAbi, functionName: "nonces", args: [owner] }, { address: trigger.tokenAddress, abi: TokenWithPermitAbi, functionName: "name" }, { address: trigger.tokenAddress, abi: TokenWithPermitAbi, functionName: "version" }, { address: trigger.tokenAddress, abi: TokenWithPermitAbi, functionName: "DOMAIN_SEPARATOR" }, { address: trigger.tokenAddress, abi: TokenWithPermitAbi, functionName: "eip712Domain" } ] }); const [nonce, name, version, domainSeparator, eip712Domain] = values.map((value, i) => { const key = [ "nonce", "name", "version", "domainSeparator", "eip712Domain" ][i]; if (value.status === "success") { return value.result; } if (value.status === "failure") { if (key === "nonce") { // Tokens must implement the nonces function, otherwise we throw a error here throw new Error("Permit signing failed: Token does not implement nonces(). This function is required for EIP-2612 compliance."); } if (key === "domainSeparator") { // Tokens must implement the domainSeparator function, otherwise we throw a error here throw new Error("Permit signing failed: Token does not implement DOMAIN_SEPARATOR(). This function is required for EIP-712 domain separation."); } if (key === "name" || key === "version") { // Some tokens do not implement name and version; defaults to undefined return undefined; } if (key === "eip712Domain") { // Some tokens do not implement eip712Domain; default to [] return []; } } // Fallback return value instead of throwing error return undefined; }); const [, name_, version_, chainId_, verifyingContract_, salt_] = eip712Domain; // Default version will be used as fallback const defaultVersion = "1"; if (version?.length >= 0 && version_?.length >= 0) { if (version !== version_) console.warn("Warning: Mismatch between token version() and eip712Domain().version. This may cause permit signature verification to fail."); } if (name?.length >= 0 && name_?.length >= 0) { if (name !== name_) console.warn("Warning: Mismatch between token name() and eip712Domain().name. This may cause permit signature verification to fail."); } if (name === undefined && name_ === undefined) { throw new Error("Permit signing failed: Token name is missing. Neither name() nor eip712Domain().name is available."); } // chainId from eip712Domain is mostly safe and more priority is given const permitChainId = chainId_ !== undefined ? Number(chainId_) : trigger.chainId; const permitValues = { name: name_ ?? name, // name from eip712Domain is mostly safe and more priority is given version: version_ ?? version ?? defaultVersion, // version from eip712Domain is mostly safe and more priority is given chainId: permitChainId, verifyingContract: verifyingContract_ ?? trigger.tokenAddress, // verifyingContract from eip712Domain is mostly safe and more priority is given salt: salt_ ?? toHex(permitChainId, { size: 32 }), // salt from eip712Domain is mostly safe and more priority is given domainSeparator }; const eip712DomainType = getEIP712DomainType(permitValues.name, permitValues.version, permitValues.chainId, permitValues.verifyingContract, permitValues.salt, permitValues.domainSeparator); const getDomain = (eip712DomainType) => { switch (eip712DomainType) { case "default": { return { name: permitValues.name, version: permitValues.version, chainId: permitValues.chainId, verifyingContract: permitValues.verifyingContract }; } case "default-with-salt": { return { name: permitValues.name, version: permitValues.version, chainId: permitValues.chainId, verifyingContract: permitValues.verifyingContract, salt: permitValues.salt }; } case "legacy": { return { name: permitValues.name, version: permitValues.version, verifyingContract: permitValues.verifyingContract, salt: permitValues.salt }; } default: throw new Error("Permit signing failed: Domain separator mismatch, please double check the token's permit functionality"); } }; const signablePermitQuotePayload = { domain: getDomain(eip712DomainType), types: { Permit: [ { name: "owner", type: "address" }, { name: "spender", type: "address" }, { name: "value", type: "uint256" }, { name: "nonce", type: "uint256" }, { name: "deadline", type: "uint256" } ] }, primaryType: "Permit", message: { owner: owner, spender: spender, value: amount, nonce, deadline: BigInt(quote.hash) } }; const metadata = { nonce, name: permitValues.name, version: permitValues.version, domainSeparator: permitValues.domainSeparator, owner, spender, amount }; return { signablePayload: signablePermitQuotePayload, metadata }; } catch (error) { const errorMessage = error.message || "Permit signing failed"; console.warn(errorMessage); console.info("Permit signing failed, fallback to onchain mode"); return { fallbackToOnchainMode: true }; } }; /** * Formats the signed permit quote payload by encoding the signature and permit parameters, * and attaching the result to the original quote. The signature is prefixed and concatenated * as required by the MEE service for permit quotes. * Metadata is used to provide the necessary context for encoding. * * @param quoteParams - The original permit quote parameters * @param metadata - Metadata returned from prepareSignablePermitQuotePayload * @param signature - The EIP-712 signature to attach to the quote * @returns The signed permit quote payload with the signature field * * @example * ```typescript * const signedPermitQuote = formatSignedPermitQuotePayload( * fusionQuote, * metadata, * signature * ); * // signedPermitQuote: { ...quote, signature: '0x177eee02<encodedPermitSignature>' } * ``` */ export const formatSignedPermitQuotePayload = (meeVersions, quoteParams, metadata, signature) => { const { quote, trigger } = quoteParams; let encodedSignature; if (versionIsAtLeast(meeVersions[0].version.version, MEEVersion.V3_0_0)) { encodedSignature = encodeAbiParameters([ { name: "token", type: "address" }, { name: "owner", type: "address" }, { name: "spender", type: "address" }, { name: "domainSeparator", type: "bytes32" }, { name: "permitTypehash", type: "bytes32" }, { name: "amount", type: "uint256" }, { name: "chainId", type: "uint256" }, { name: "nonce", type: "uint256" }, { name: "signature", type: "bytes" } ], [ trigger.tokenAddress, metadata.owner, metadata.spender, metadata.domainSeparator, PERMIT_TYPEHASH, metadata.amount, BigInt(trigger.chainId), metadata.nonce, signature ]); } else { const sigComponents = parseSignature(signature); encodedSignature = encodeAbiParameters([ { name: "token", type: "address" }, { name: "spender", type: "address" }, { name: "domainSeparator", type: "bytes32" }, { name: "permitTypehash", type: "bytes32" }, { name: "amount", type: "uint256" }, { name: "chainId", type: "uint256" }, { name: "nonce", type: "uint256" }, { name: "v", type: "uint256" }, { name: "r", type: "bytes32" }, { name: "s", type: "bytes32" } ], [ trigger.tokenAddress, metadata.spender, metadata.domainSeparator, PERMIT_TYPEHASH, metadata.amount, BigInt(trigger.chainId), metadata.nonce, sigComponents.v, sigComponents.r, sigComponents.s ]); } return { ...quote, signature: concatHex([PERMIT_PREFIX, encodedSignature]) }; }; /** * Signs a permit quote using EIP-2612 permit signatures. This enables gasless * approvals for ERC20 tokens that implement the permit extension. * * @param client - The Mee client instance * @param parameters - Parameters for signing the permit quote * @param parameters.fusionQuote - The permit quote to sign * @param [parameters.account] - Optional account to use for signing * * @returns Promise resolving to the quote payload with permit signature * * @example * ```typescript * const { fallbackToOnchainMode, signedPermitQuotePayload } = await signPermitQuote(meeClient, { * fusionQuote: { * quote: quotePayload, * trigger: { * tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", * chainId: 1, * amount: 1000000n // 1 USDC * } * }, * account: smartAccount // Optional * }); * ``` */ export const signPermitQuote = async (parameters) => { const { owner, spender, walletClient, meeVersions } = parameters.account; const { fallbackToOnchainMode, signablePayload, metadata } = await prepareSignablePermitQuotePayload(parameters.fusionQuote, owner, spender, walletClient); if (fallbackToOnchainMode) { return { fallbackToOnchainMode }; } const signature = await walletClient.signTypedData({ ...signablePayload, account: walletClient.account }); const signedPermitQuotePayload = formatSignedPermitQuotePayload(meeVersions, parameters.fusionQuote, metadata, signature); return { signedPermitQuotePayload }; }; export default signPermitQuote; //# sourceMappingURL=signPermitQuote.js.map