UNPKG

@biconomy/abstractjs

Version:

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

448 lines 20.9 kB
import { zeroAddress } from "viem"; import { buildComposable } from "../../../account/decorators/index.js"; import { addressEquals } from "../../../account/utils/Utils.js"; import { LARGE_DEFAULT_GAS_LIMIT } from "../../../account/utils/getMultichainContract.js"; import { resolveInstructions } from "../../../account/utils/resolveInstructions.js"; import { SMART_SESSIONS_ADDRESS } from "../../../constants/index.js"; import { greaterThanOrEqualTo, runtimeERC20BalanceOf, runtimeNonceOf } from "../../../modules/utils/composabilityCalls.js"; import createHttpClient, {} from "../../createHttpClient.js"; import { DEFAULT_MEE_SPONSORSHIP_CHAIN_ID, DEFAULT_MEE_SPONSORSHIP_PAYMASTER_ACCOUNT, DEFAULT_MEE_SPONSORSHIP_TOKEN_ADDRESS, DEFAULT_PATHFINDER_URL, DEFAULT_STAGING_PATHFINDER_URL } from "../../createMeeClient.js"; export const USEROP_MIN_EXEC_WINDOW_DURATION = 180; export const CLEANUP_USEROP_EXTENDED_EXEC_WINDOW_DURATION = USEROP_MIN_EXEC_WINDOW_DURATION / 2; export const DEFAULT_GAS_LIMIT = 75000n; export const DEFAULT_VERIFICATION_GAS_LIMIT = 150000n; /** * Requests a quote from the MEE service for executing a set of instructions. * This function handles the complexity of creating a supertransaction quote * that can span multiple chains. * * @param client - MEE client instance used to make the request * @param parameters - Parameters for the quote request * @returns Promise resolving to a committed supertransaction quote * * @example * ```typescript * const quote = await getQuote(meeClient, { * instructions: [{ * calls: [{ * to: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", * data: "0x...", * value: 0n * }], * chainId: 1 // Ethereum Mainnet * }], * feeToken: { * address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC * chainId: 1 * } * }); * ``` * * @throws Will throw an error if: * - The account is not deployed on required chains * - The fee token is not supported * - The chain(s) are not supported by the node */ export const getQuote = async (client, parameters) => { const { account: account_ = client.account, instructions, cleanUps, feePayer, path = "quote", lowerBoundTimestamp: lowerBoundTimestamp_ = Math.floor(Date.now() / 1000), upperBoundTimestamp: upperBoundTimestamp_ = lowerBoundTimestamp_ + USEROP_MIN_EXEC_WINDOW_DURATION, delegate = false, authorization, moduleAddress, shortEncodingSuperTxn = false, sponsorship = false, sponsorshipOptions } = parameters; const resolvedInstructions = await resolveInstructions(instructions); // if feePayer is provided, we need to use the /quote-permit path let pathToQuery = path; if (feePayer) { pathToQuery = "/quote-permit"; } const validUserOps = resolvedInstructions.every((userOp) => account_.deploymentOn(userOp.chainId) && client.info.supportedChains .map(({ chainId }) => +chainId) .includes(userOp.chainId)); if (!validUserOps) { throw Error(`User operation chain(s) not supported by the node: ${resolvedInstructions .map((x) => x.chainId) .join(", ")}`); } const hasProcessedInitData = []; const paymentVerificationGasLimit = resolvePaymentUserOpVerificationGasLimit({ moduleAddress, sponsorship }); const { paymentInfo, isInitDataProcessed } = await preparePaymentInfo(client, { ...parameters, paymentVerificationGasLimit }); if (isInitDataProcessed) hasProcessedInitData.push(paymentInfo.chainId); const preparedUserOps = await prepareUserOps(account_, resolvedInstructions, false, moduleAddress); // If cleanup is configured, the cleanup userops will be appended to the existing userops // Every cleanup is a separate user op and will be executed if certain conditions met if (cleanUps && cleanUps.length > 0) { const userOpsNonceInfo = preparedUserOps.map(([, { nonceKey, nonce }]) => ({ nonce, nonceKey })); const cleanUpUserOps = await prepareCleanUpUserOps(account_, userOpsNonceInfo, cleanUps, moduleAddress); preparedUserOps.push(...cleanUpUserOps); } // complete the userOps including cleanup ones const indexPerChainId = new Map(); const userOps = await Promise.all(preparedUserOps.map(async ([callData, { nonce }, isAccountDeployed, initCode, sender, callGasLimit, chainId, isCleanUpUserOp, nexusAccount, shortEncoding]) => { let initDataOrUndefined = undefined; if (!indexPerChainId.has(chainId)) { indexPerChainId.set(chainId, 0); } const shouldContainInitData = !hasProcessedInitData.includes(chainId) && !isAccountDeployed; if (shouldContainInitData) { hasProcessedInitData.push(chainId); initDataOrUndefined = delegate ? { eip7702Auth: await nexusAccount.toDelegation({ authorization }) } : { initCode }; } const verificationGasLimit = resolveVerificationGasLimit({ moduleAddress, sponsorship, index: indexPerChainId.get(chainId), paymentChainId: paymentInfo.chainId, currentChainId: chainId }); indexPerChainId.set(chainId, indexPerChainId.get(chainId) + 1); return { lowerBoundTimestamp: lowerBoundTimestamp_, upperBoundTimestamp: isCleanUpUserOp ? upperBoundTimestamp_ + CLEANUP_USEROP_EXTENDED_EXEC_WINDOW_DURATION : upperBoundTimestamp_, sender, callData, callGasLimit, nonce: nonce.toString(), chainId, isCleanUpUserOp, ...initDataOrUndefined, ...verificationGasLimit, shortEncoding: shortEncodingSuperTxn || shortEncoding }; })); const quoteRequest = { userOps, paymentInfo }; let quote = await client.request({ path: pathToQuery, body: quoteRequest }); if (sponsorship && sponsorshipOptions) { const isSelfHostedSponsorship = ![ DEFAULT_PATHFINDER_URL, DEFAULT_STAGING_PATHFINDER_URL ].includes(sponsorshipOptions.url); if (isSelfHostedSponsorship) { const selfHostedClient = createHttpClient(sponsorshipOptions.url); quote = await selfHostedClient.request({ path: `sponsorship/sign/${sponsorshipOptions.gasTank.chainId}/${sponsorshipOptions.gasTank.address}`, method: "POST", body: quote, ...(sponsorshipOptions.customHeaders ? { headers: sponsorshipOptions.customHeaders } : {}) }); } } return quote; }; const preparePaymentInfo = async (client, parameters) => { const { account: account_ = client.account, eoa, feeToken, feePayer, delegate = false, gasLimit, verificationGasLimit, authorization, sponsorship, sponsorshipOptions, shortEncodingSuperTxn, moduleAddress = zeroAddress, paymentVerificationGasLimit } = parameters; let paymentInfo = undefined; let isInitDataProcessed = false; const eoaOrFeePayer = feePayer || eoa; if (sponsorship) { // For sponsorship, the sender should be the sponsorship SCA which will bare the gas payment for developers let sender = DEFAULT_MEE_SPONSORSHIP_PAYMASTER_ACCOUNT; let token = DEFAULT_MEE_SPONSORSHIP_TOKEN_ADDRESS; let chainId = DEFAULT_MEE_SPONSORSHIP_CHAIN_ID; let sponsorshipUrl = DEFAULT_PATHFINDER_URL; if (sponsorshipOptions) { sender = sponsorshipOptions.gasTank.address; token = sponsorshipOptions.gasTank.token; chainId = sponsorshipOptions.gasTank.chainId; sponsorshipUrl = sponsorshipOptions.url; } const sponsorshipClient = createHttpClient(sponsorshipUrl); const { nonce } = await sponsorshipClient.request({ path: `sponsorship/nonce/${chainId}/${sender}`, method: "GET", ...(sponsorshipOptions?.customHeaders ? { headers: sponsorshipOptions.customHeaders } : {}) }); paymentInfo = { sponsored: true, sender, token, nonce, callGasLimit: gasLimit || DEFAULT_GAS_LIMIT, verificationGasLimit: verificationGasLimit || DEFAULT_VERIFICATION_GAS_LIMIT, chainId: chainId.toString(), sponsorshipUrl, ...(eoaOrFeePayer ? { eoa: eoaOrFeePayer } : {}), // For sponsorship, the sponsorship paymaster EOA is always assumed to be deployed and funded already // So initCode will be always undefined initCode: undefined // no short encodings }; // Init code / authorization list will not be added to payment userOp in the case of sponsorship. It will be added in the // first developer defined userOp. To make this happen, this field should be false isInitDataProcessed = false; } else { if (!feeToken) throw Error("Fee token should be configured"); const validPaymentAccount = account_.deploymentOn(feeToken.chainId); if (!validPaymentAccount) { throw Error(`Account is not deployed on necessary chain(s) ${feeToken.chainId}`); } // TODO: Check the correctness of this while testing. This is a old logic const validFeeToken = validPaymentAccount && client.info.supportedGasTokens .map(({ chainId }) => +chainId) .includes(feeToken.chainId); if (!validFeeToken) { throw Error(`Fee token ${feeToken.address} is not supported on this chain: ${feeToken.chainId}`); } const [nonce, isAccountDeployed, initCode] = await Promise.all([ validPaymentAccount.getNonceWithKey(validPaymentAccount.address, { moduleAddress }), validPaymentAccount.isDeployed(), validPaymentAccount.getInitCode() ]); // Do authorization only if required as it requires signing const initData = isAccountDeployed ? undefined : delegate ? { eip7702Auth: await validPaymentAccount.toDelegation({ authorization }) } : { initCode }; paymentInfo = { sponsored: false, sender: validPaymentAccount.address, token: feeToken.address, nonce: nonce.nonce.toString(), callGasLimit: gasLimit || DEFAULT_GAS_LIMIT, verificationGasLimit: verificationGasLimit || DEFAULT_VERIFICATION_GAS_LIMIT, chainId: feeToken.chainId.toString(), ...(eoaOrFeePayer ? { eoa: eoaOrFeePayer } : {}), ...initData, shortEncoding: shortEncodingSuperTxn, ...paymentVerificationGasLimit }; // Init code / authorization list will added to payment userOp. To prevent adding the init code / authList // in developer defined userOps, this field must be true isInitDataProcessed = true; } if (!paymentInfo) throw new Error("Failed to generate payment info"); return { paymentInfo, isInitDataProcessed }; }; const prepareUserOps = async (account, instructions, isCleanUpUserOps = false, moduleAddress) => { return await Promise.all(instructions.map((userOp) => { const deployment = account.deploymentOn(userOp.chainId, true); const accountAddress = account.addressOn(userOp.chainId, true); let callsPromise; if (userOp.isComposable) { callsPromise = deployment.encodeExecuteComposable(userOp.calls); } else { callsPromise = userOp.calls.length > 1 ? deployment.encodeExecuteBatch(userOp.calls) : deployment.encodeExecute(userOp.calls[0]); } // This is the place to set the short encoding flag // It can be based on the module address or on the instruction type // Currently instructions are for the userOps only // That's why this function is called prepareUserOps // However it is possible that a superTxn consists not of the userOps only // So for example signed EIP-712 data structs or // ERC-7683 Cross-chain intents can be included in the superTxn // And this function will have to convert them out of instructions // Such 'off-chain' entities will have to be used with short encoding flag // For we just set it to false for now const shortEncoding = false; return Promise.all([ callsPromise, deployment.getNonceWithKey(accountAddress, { moduleAddress }), deployment.isDeployed(), deployment.getInitCode(), deployment.address, userOp.calls .map((uo) => uo?.gasLimit ?? LARGE_DEFAULT_GAS_LIMIT) .reduce((curr, acc) => curr + acc, 0n) .toString(), userOp.chainId.toString(), isCleanUpUserOps, deployment, shortEncoding ]); })); }; export const userOp = (userOpIndex) => { if (userOpIndex <= 0) throw new Error("UserOp index should be greater than zero"); // During the userop building, the payment user ops is not available. But the slot 1 is always reserved for payment userop // as standard practise. Hence, the userOp will indirectly implies this by -1 which yields the first userop defined by devs return userOpIndex - 1; }; const prepareCleanUpUserOps = async (account, userOpsNonceInfo, cleanUps, moduleAddress) => { const cleanUpInstructions = await Promise.all(cleanUps.map(async (cleanUp) => { let amount = cleanUp.amount ?? 0n; // If there is no amount specified ? Runtime amount will be cleaned up by default if (amount === 0n) { amount = runtimeERC20BalanceOf({ targetAddress: account.addressOn(cleanUp.chainId, true), tokenAddress: cleanUp.tokenAddress, constraints: [greaterThanOrEqualTo(1n)] // Cleanup will only happen if there is atleast 1 wei }); } const [cleanUpTransferInstruction] = await buildComposable({ account: account, currentInstructions: [] }, { type: "transfer", data: { recipient: cleanUp.recipientAddress, tokenAddress: cleanUp.tokenAddress, amount, chainId: cleanUp.chainId, ...(cleanUp.gasLimit ? { gasLimit: cleanUp.gasLimit } : {}) } }); const nonceDependencies = []; if (cleanUp.dependsOn && cleanUp.dependsOn.length > 0) { for (const userOpIndex of cleanUp.dependsOn) { const userOpNonceInfo = userOpsNonceInfo[userOpIndex]; if (!userOpNonceInfo) throw new Error("Invalid UserOp dependency, please check the dependsOn configuration"); const { nonce, nonceKey } = userOpNonceInfo; const nonceOf = runtimeNonceOf({ smartAccountAddress: account.addressOn(cleanUp.chainId, true), nonceKey: nonceKey, constraints: [greaterThanOrEqualTo(nonce + 1n)] }); nonceDependencies.push(nonceOf); } } else { const lastUserOp = userOpsNonceInfo[userOpsNonceInfo.length - 1]; const { nonce, nonceKey } = lastUserOp; const nonceOf = runtimeNonceOf({ smartAccountAddress: account.addressOn(cleanUp.chainId, true), nonceKey: nonceKey, constraints: [greaterThanOrEqualTo(nonce + 1n)] }); nonceDependencies.push(nonceOf); } const nonceDependencyInputParams = nonceDependencies.flatMap((dep) => dep.inputParams); cleanUpTransferInstruction.calls = cleanUpTransferInstruction.calls.map((call) => { call.inputParams.push(...nonceDependencyInputParams); return call; }); return cleanUpTransferInstruction; })); const cleanUpUserOps = await prepareUserOps(account, cleanUpInstructions, true, moduleAddress); return cleanUpUserOps; }; /** * Returns the verification gas limit for the userOp * @param parameters - The parameters for the resolveVerificationGasLimit function * @returns The verification gas limit for the userOp/paymentInfo * returns undefined if there's no special gas limit required for a given case * 'undefined' means the node will apply the default verification gas limit */ const resolveVerificationGasLimit = (parameters) => { const { moduleAddress, sponsorship, index, paymentChainId, currentChainId } = parameters; if (currentChainId === paymentChainId) { return resolveVerificationGasLimitForPaymentChain({ moduleAddress, sponsorship, index }); } return resolveVerificationGasLimitForNonPaymentChain({ moduleAddress, index }); }; /** * Returns the verification gas limit for the userOp on the payment chain * @param parameters - The parameters for the resolveVerificationGasLimit function * @returns The verification gas limit for the userOp * returns undefined if there's no special gas limit required for a given case * 'undefined' means the node will apply the default verification gas limit */ const resolveVerificationGasLimitForPaymentChain = (parameters) => { const { moduleAddress, sponsorship, index } = parameters; // if module address is not provided, the default verification gas limit will be applied if (!moduleAddress) { return undefined; } if (addressEquals(moduleAddress, SMART_SESSIONS_ADDRESS)) { if (sponsorship) { if (index === 0) { // return increased verification gas limit for the first userOp // as it this userOp will be enabling the permission => requires more gas return { verificationGasLimit: 1000000n }; } } // return slighly increased verification gas limit // for USE session userOps return { verificationGasLimit: 250000n }; } return undefined; }; /** * Returns the verification gas limit for the userOp on a non-payment chain * @param parameters - The parameters for the resolveVerificationGasLimit function * @returns The verification gas limit for the userOp * returns undefined if there's no special gas limit required for a given case * 'undefined' means the node will apply the default verification gas limit */ const resolveVerificationGasLimitForNonPaymentChain = (parameters) => { const { moduleAddress, index } = parameters; // if module address is not provided, the default verification gas limit will be applied if (!moduleAddress) { return undefined; } if (addressEquals(moduleAddress, SMART_SESSIONS_ADDRESS)) { if (index === 0) { // return increased verification gas limit for payment userOp // in a non-sponsored superTxn return { verificationGasLimit: 1000000n }; } // for all other userOps, return USE session verification gas limit return { verificationGasLimit: 250000n }; } return undefined; }; /** * Returns the verification gas limit for the payment userOp * @param parameters - The parameters for the resolveVerificationGasLimit function * @returns The verification gas limit for the payment userOp * returns undefined if there's no special gas limit required for a given case * 'undefined' means the node will apply the default verification gas limit */ const resolvePaymentUserOpVerificationGasLimit = (parameters) => { const { moduleAddress, sponsorship } = parameters; // if module address is not provided, the default verification gas limit will be applied if (!moduleAddress) { return undefined; } if (addressEquals(moduleAddress, SMART_SESSIONS_ADDRESS)) { if (!sponsorship) { // return increased verification gas limit for payment userOp // in a non-sponsored superTxn return { verificationGasLimit: 1000000n }; } // if it is sponsorship, the payment userOp won't even use Smart Sessions Module // so doesn't need any custom verification gas limit } return undefined; }; // ==================================================== export default getQuote; //# sourceMappingURL=getQuote.js.map