@etherspot/modular-sdk
Version:
Etherspot Modular SDK - build with ERC-7579 smart accounts modules
386 lines • 15.9 kB
JavaScript
import { Exception, getUserOpHash, packUserOp } from '../common/index.js';
import { calcPreVerificationGas } from './calcPreVerificationGas.js';
import { NetworkService, SignMessageDto, validateDto } from '../index.js';
import { Context } from '../context.js';
import { parseAbi } from 'viem';
import { entryPointAbi } from '../common/abis.js';
import { resolveProperties } from '../common/utils/index.js';
import { BigNumber } from '../types/bignumber.js';
import { WalletService } from '../wallet/index.js';
import { DEFAULT_MULTIPLE_OWNER_ECDSA_VALIDATOR_ADDRESS, Networks } from '../network/index.js';
/**
* Base class for all Smart Wallet ERC-4337 Clients to implement.
* Subclass should inherit 5 methods to support a specific wallet contract:
*
* - getAccountInitCode - return the value to put into the "initCode" field, if the account is not yet deployed. should create the account instance using a factory contract.
* - getNonce - return current account's nonce value
* - encodeExecute - encode the call from entryPoint through our account to the target contract.
* - signUserOpHash - sign the hash of a UserOp.
*
* The user can use the following APIs:
* - createUnsignedUserOp - given "target" and "calldata", fill userOp to perform that operation from the account.
* - createSignedUserOp - helper to call the above createUnsignedUserOp, and then extract the userOpHash and sign it
*/
export class BaseAccountAPI {
/**
* base constructor.
* subclass SHOULD add parameters that define the owner (signer) of this wallet
*/
constructor(params) {
this.isPhantom = true;
const optionsLike = params.optionsLike;
const { chainId, //
rpcProviderUrl, factoryWallet, bundlerProvider, } = optionsLike;
this.services = {
networkService: new NetworkService(chainId),
walletService: new WalletService(params.wallet, { provider: rpcProviderUrl }, bundlerProvider.url, chainId),
};
this.context = new Context(this.services);
this.factoryUsed = factoryWallet;
// super();
this.overheads = params.overheads;
this.entryPointAddress = params.entryPointAddress;
this.accountAddress = params.accountAddress;
this.factoryAddress = params.factoryAddress;
this.publicClient = params.publicClient;
this.validatorAddress = Networks[params.optionsLike.chainId]?.contracts?.multipleOwnerECDSAValidator ?? DEFAULT_MULTIPLE_OWNER_ECDSA_VALIDATOR_ADDRESS;
}
get error$() {
return this.context.error$;
}
get supportedNetworks() {
return this.services.networkService.supportedNetworks;
}
// sdk
/**
* destroys
*/
destroy() {
this.context.destroy();
}
// wallet
/**
* signs message
* @param dto
* @return Promise<string>
*/
async signMessage(dto) {
const { message } = await validateDto(dto, SignMessageDto);
await this.require({
network: false,
});
const initCode = await this.getInitCode();
return this.services.walletService.signMessage(message, `0x${this.validatorAddress.slice(2)}`, `0x${this.factoryAddress.slice(2)}`, `0x${initCode.substring(42)}`);
}
async setPaymasterApi(paymaster) {
this.paymasterAPI = paymaster;
}
// private
async require(options = {}) {
options = {
network: true,
wallet: true,
...options,
};
}
getNetworkChainId(networkName = null) {
let result;
if (!networkName) {
({ chainId: result } = this.services.networkService);
}
else {
const network = this.supportedNetworks.find(({ name }) => name === networkName);
if (!network) {
throw new Exception('Unsupported network');
}
({ chainId: result } = network);
}
return result;
}
async validateResolveName(options = {}) {
options = {
...options,
};
const { networkService } = this.services;
if (options.network && !networkService.chainId) {
throw new Exception('Unknown network');
}
if (!options.name) {
throw new Exception('Require name');
}
}
async init() {
// check EntryPoint is deployed at given address
if ((await this.publicClient.getCode({ address: this.entryPointAddress })) === '0x') {
throw new Error(`entryPoint not deployed at ${this.entryPointAddress}`);
}
await this.getAccountAddress();
return this;
}
/**
* check if the contract is already deployed.
*/
async checkAccountPhantom() {
if (!this.isPhantom) {
// already deployed. no need to check anymore.
return this.isPhantom;
}
const accountAddress = await this.getAccountAddress();
const senderAddressCode = await this.publicClient.getCode({ address: accountAddress });
if (senderAddressCode && senderAddressCode.length > 2) {
this.isPhantom = false;
}
return this.isPhantom;
}
/**
* calculate the account address even before it is deployed
*/
async getCounterFactualAddress() {
const initCode = await this.getAccountInitCode();
// use entryPoint to query account address (factory can provide a helper method to do the same, but
// this method attempts to be generic
try {
//await this.entryPointView.callStatic.getSenderAddress(initCode);
await this.publicClient.simulateContract({
address: this.entryPointAddress,
abi: parseAbi(entryPointAbi),
functionName: 'getSenderAddress',
args: [initCode]
});
}
catch (e) {
return e.errorArgs.sender;
}
throw new Error('must handle revert');
}
/**
* return initCode value to into the UserOp.
* (either deployment code, or empty hex if contract already deployed)
*/
async getInitCode() {
if (await this.checkAccountPhantom()) {
return await this.getAccountInitCode();
}
return '0x';
}
/**
* return maximum gas used for verification.
* NOTE: createUnsignedUserOp will add to this value the cost of creation, if the contract is not yet created.
*/
async getVerificationGasLimit() {
return 100000;
}
/**
* should cover cost of putting calldata on-chain, and some overhead.
* actual overhead depends on the expected bundle size
*/
async getPreVerificationGas(userOp) {
const p = await resolveProperties(userOp);
return calcPreVerificationGas(p, this.overheads);
}
/**
* ABI-encode a user operation. used for calldata cost estimation
*/
packUserOp(userOp) {
return packUserOp(userOp, false);
}
async encodeUserOpCallDataAndGasLimit(detailsForUserOp) {
function parseNumber(a) {
if (a == null || a === '')
return null;
return BigNumber.from(a.toString());
}
const value = parseNumber(detailsForUserOp.value) ?? BigNumber.from(0);
let callData;
const data = detailsForUserOp.data;
let target = detailsForUserOp.target;
if (typeof data === 'string') {
if (typeof target !== 'string') {
throw new Error('must have target address if data is single value');
}
callData = await this.encodeExecute(target, value, data);
}
else {
if (typeof target === 'string') {
target = Array(data.length).fill(target);
}
callData = await this.encodeBatch(target, detailsForUserOp.values, data);
}
const callGasLimit = parseNumber(detailsForUserOp.gasLimit) ?? BigNumber.from(35000);
return {
callData,
callGasLimit,
};
}
/**
* return userOpHash for signing.
* This value matches entryPoint.getUserOpHash (calculated off-chain, to avoid a view call)
* @param userOp userOperation, (signature field ignored)
*/
async getUserOpHash(userOp) {
const op = await resolveProperties(userOp);
const chainId = await this.publicClient.getChainId();
return getUserOpHash(op, this.entryPointAddress, chainId);
}
/**
* return the account's address.
* this value is valid even before deploying the contract.
*/
async getAccountAddress() {
if (this.senderAddress == null) {
if (this.accountAddress != null) {
this.senderAddress = this.accountAddress;
}
else {
this.senderAddress = await this.getCounterFactualAddress();
}
}
return this.senderAddress;
}
async estimateCreationGas(initCode) {
if (initCode == null || initCode === '0x')
return 0;
const deployerAddress = initCode.substring(0, 42);
const deployerCallData = '0x' + initCode.substring(42);
const estimatedGas = await this.publicClient.estimateGas({
to: deployerAddress,
data: deployerCallData,
});
return estimatedGas ? estimatedGas : 0;
}
async getFeeData() {
const maxFeePerGasResponse = await this.publicClient.estimateFeesPerGas();
const maxPriorityFeePerGasResponse = await this.publicClient.estimateMaxPriorityFeePerGas();
const maxFeePerGas = maxFeePerGasResponse ? BigNumber.from(maxFeePerGasResponse.maxFeePerGas) : null;
const maxPriorityFeePerGas = maxPriorityFeePerGasResponse ? BigNumber.from(maxPriorityFeePerGasResponse.toString()) : null;
return { maxFeePerGas, maxPriorityFeePerGas };
}
/**
* create a UserOperation, filling all details (except signature)
* - if account is not yet created, add initCode to deploy it.
* - if gas or nonce are missing, read them from the chain (note that we can't fill gaslimit before the account is created)
* @param info
*/
async createUnsignedUserOp(info, key = BigNumber.from(0)) {
const { callData, callGasLimit } = await this.encodeUserOpCallDataAndGasLimit(info);
const factoryData = await this.getInitCode();
const initGas = await this.estimateCreationGas(factoryData);
const verificationGasLimit = BigNumber.from(await this.getVerificationGasLimit()).add(initGas);
let { maxFeePerGas, maxPriorityFeePerGas } = info;
if (maxFeePerGas == null || maxPriorityFeePerGas == null) {
let feeData = {};
try {
feeData = await this.getFeeData();
}
catch (err) {
console.warn("getGas: eth_maxPriorityFeePerGas failed, falling back to legacy gas price.");
const gas = await this.publicClient.getGasPrice();
feeData = { maxFeePerGas: gas, maxPriorityFeePerGas: gas };
}
if (maxFeePerGas == null) {
maxFeePerGas = feeData.maxFeePerGas ?? undefined;
}
if (maxPriorityFeePerGas == null) {
maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? undefined;
}
}
let partialUserOp;
if (factoryData !== '0x') {
partialUserOp = {
sender: await this.getAccountAddress(),
nonce: await this.getNonce(key),
factory: this.factoryAddress,
factoryData: '0x' + factoryData.substring(42),
callData,
callGasLimit,
verificationGasLimit,
maxFeePerGas,
maxPriorityFeePerGas,
};
}
else {
partialUserOp = {
sender: await this.getAccountAddress(),
nonce: await this.getNonce(key),
factoryData: '0x' + factoryData.substring(42),
callData,
callGasLimit,
verificationGasLimit,
maxFeePerGas,
maxPriorityFeePerGas,
};
}
let paymasterData = null;
if (this.paymasterAPI != null) {
// fill (partial) preVerificationGas (all except the cost of the generated paymasterData)
const userOpForPm = {
...partialUserOp,
preVerificationGas: this.getPreVerificationGas(partialUserOp),
};
paymasterData = (await this.paymasterAPI.getPaymasterData(userOpForPm));
partialUserOp.verificationGasLimit = paymasterData.result.verificationGasLimit;
partialUserOp.preVerificationGas = paymasterData.result.preVerificationGas;
partialUserOp.callGasLimit = paymasterData.result.callGasLimit;
partialUserOp.paymaster = paymasterData.result.paymaster;
partialUserOp.paymasterVerificationGasLimit = paymasterData.result.paymasterVerificationGasLimit;
partialUserOp.paymasterPostOpGasLimit = paymasterData.result.paymasterPostOpGasLimit;
if (paymasterData?.result.maxFeePerGas && paymasterData?.result.maxPriorityFeePerGas) {
partialUserOp.maxFeePerGas = paymasterData.result.maxFeePerGas;
partialUserOp.maxPriorityFeePerGas = paymasterData.result.maxPriorityFeePerGas;
}
}
partialUserOp.paymasterData = paymasterData ? paymasterData.result.paymasterData : '0x';
partialUserOp.preVerificationGas = paymasterData ? paymasterData.result.preVerificationGas : this.getPreVerificationGas(partialUserOp);
return {
...partialUserOp,
signature: info.dummySignature ?? '0x',
};
}
/**
* Sign the filled userOp.
* @param userOp the UserOperation to sign (with signature field ignored)
*/
async signUserOp(userOp) {
const userOpHash = await this.getUserOpHash(userOp);
const signature = await this.signUserOpHash(userOpHash);
return {
...userOp,
signature,
};
}
/**
* helper method: create and sign a user operation.
* @param info transaction details for the userOp
*/
async createSignedUserOp(info, key = BigNumber.from(0)) {
return await this.signUserOp(await this.createUnsignedUserOp(info, key));
}
/**
* get the transaction that has this userOpHash mined, or null if not found
* @param userOpHash returned by sendUserOpToBundler (or by getUserOpHash..)
* @param timeout stop waiting after this timeout
* @param interval time to wait between polls.
* @return the transactionHash this userOp was mined, or null if not found.
*/
async getUserOpReceipt(userOpHash, timeout = 30000, interval = 5000) {
const endtime = Date.now() + timeout;
while (Date.now() < endtime) {
const response = await this.publicClient.request({
method: 'eth_getUserOperationReceipt',
params: [
userOpHash
]
});
if (response && response.receipt !== undefined) {
return response.receipt.transactionHash;
}
await new Promise((resolve) => setTimeout(resolve, interval));
}
return null;
}
async signTypedData(msg) {
const initCode = await this.getInitCode();
return await this.services.walletService.signTypedData(msg, `0x${this.validatorAddress.slice(2)}`, `0x${this.factoryAddress.slice(2)}`, `0x${initCode.substring(42)}`);
}
}
//# sourceMappingURL=BaseAccountAPI.js.map