@alchemy/aa-core
Version:
viem based SDK that enables interactions with ERC-4337 Smart Accounts. ABIs are based off the definitions generated in @account-abstraction/contracts
416 lines (365 loc) • 13 kB
text/typescript
import type { Address } from "abitype";
import {
getContract,
http,
trim,
type GetContractReturnType,
type Hash,
type Hex,
type HttpTransport,
type PublicClient,
type Transport,
} from "viem";
import { EntryPointAbi_v6 as EntryPointAbi } from "../abis/EntryPointAbi_v6.js";
import {
createBundlerClient,
type BundlerClient,
} from "../client/bundlerClient.js";
import { getEntryPoint } from "../entrypoint/index.js";
import {
BatchExecutionNotSupportedError,
FailedToGetStorageSlotError,
GetCounterFactualAddressError,
UpgradeToAndCallNotSupportedError,
} from "../errors/account.js";
import { InvalidRpcUrlError } from "../errors/client.js";
import { Logger } from "../logger.js";
import type { SmartAccountSigner } from "../signer/types.js";
import { wrapSignatureWith6492 } from "../signer/utils.js";
import type { BatchUserOperationCallData, NullAddress } from "../types.js";
import { createBaseSmartAccountParamsSchema } from "./schema.js";
import type {
BaseSmartAccountParams,
ISmartContractAccount,
SignTypedDataParams,
} from "./types.js";
export enum DeploymentState {
UNDEFINED = "0x0",
NOT_DEPLOYED = "0x1",
DEPLOYED = "0x2",
}
/**
* @deprecated use `toSmartContractAccount` instead for creating SmartAccountInstances
*/
export abstract class BaseSmartContractAccount<
TTransport extends Transport = Transport,
TSigner extends SmartAccountSigner = SmartAccountSigner
> implements ISmartContractAccount<TTransport, TSigner>
{
protected factoryAddress: Address;
protected deploymentState: DeploymentState = DeploymentState.UNDEFINED;
protected accountAddress?: Address;
protected accountInitCode?: Hex;
protected signer: TSigner;
protected entryPoint: GetContractReturnType<
typeof EntryPointAbi,
PublicClient
>;
protected entryPointAddress: Address;
readonly rpcProvider:
| BundlerClient<TTransport>
| BundlerClient<HttpTransport>;
constructor(params_: BaseSmartAccountParams<TTransport, TSigner>) {
const params = createBaseSmartAccountParamsSchema<
TTransport,
TSigner
>().parse(params_);
this.entryPointAddress =
params.entryPointAddress ?? getEntryPoint(params.chain).address;
const rpcUrl =
typeof params.rpcClient === "string"
? params.rpcClient
: params.rpcClient.transport.type === "http"
? (
params.rpcClient.transport as ReturnType<HttpTransport>["config"] &
ReturnType<HttpTransport>["value"]
).url || params.chain.rpcUrls.default.http[0]
: undefined;
const fetchOptions =
typeof params.rpcClient === "string"
? undefined
: params.rpcClient.transport.type === "http"
? (
params.rpcClient.transport as ReturnType<HttpTransport>["config"] &
ReturnType<HttpTransport>["value"]
).fetchOptions
: undefined;
this.rpcProvider = rpcUrl
? createBundlerClient({
chain: params.chain,
transport: http(rpcUrl, {
fetchOptions: {
...fetchOptions,
headers: {
...fetchOptions?.headers,
...(rpcUrl.toLowerCase().indexOf("alchemy") > -1
? {
"Alchemy-Aa-Sdk-Signer":
params.signer?.signerType || "unknown",
"Alchemy-Aa-Sdk-Factory-Address": params.factoryAddress,
}
: undefined),
},
},
}),
})
: (params.rpcClient as BundlerClient<TTransport>);
this.accountAddress = params.accountAddress;
this.factoryAddress = params.factoryAddress;
this.signer = params.signer as TSigner;
this.accountInitCode = params.initCode as Hex;
this.entryPoint = getContract({
address: this.entryPointAddress,
abi: EntryPointAbi,
client: this.rpcProvider as PublicClient,
});
}
//#region abstract-methods
/**
* This method should return a signature that will not `revert` during validation.
* It does not have to pass validation, just not cause the contract to revert.
* This is required for gas estimation so that the gas estimate are accurate.
*
*/
abstract getDummySignature(): Hex | Promise<Hex>;
/**
* this method should return the abi encoded function data for a call to your contract's `execute` method
*
* @param target -- equivalent to `to` in a normal transaction
* @param value -- equivalent to `value` in a normal transaction
* @param data -- equivalent to `data` in a normal transaction
* @returns abi encoded function data for a call to your contract's `execute` method
*/
abstract encodeExecute(
target: string,
value: bigint,
data: string
): Promise<Hash>;
/**
* this should return an ERC-191 compliant message and is used to sign UO Hashes
*
* @param msg -- the message to sign
*/
abstract signMessage(msg: string | Uint8Array): Promise<Hash>;
/**
* this should return the init code that will be used to create an account if one does not exist.
* This is the concatenation of the account's factory address and the abi encoded function data of the account factory's `createAccount` method.
* https://github.com/eth-infinitism/account-abstraction/blob/abff2aca61a8f0934e533d0d352978055fddbd96/contracts/core/SenderCreator.sol#L12
*/
protected abstract getAccountInitCode(): Promise<Hash>;
//#endregion abstract-methods
//#region optional-methods
/**
* If your account handles 1271 signatures of personal_sign differently
* than it does UserOperations, you can implement two different approaches to signing
*
* @param uoHash -- The hash of the UserOperation to sign
* @returns the signature of the UserOperation
*/
async signUserOperationHash(uoHash: Hash): Promise<Hash> {
return this.signMessage(uoHash);
}
/**
* If your contract supports signing and verifying typed data,
* you should implement this method.
*
* @param _params -- Typed Data params to sign
*/
async signTypedData(_params: SignTypedDataParams): Promise<`0x${string}`> {
throw new Error("signTypedData not supported");
}
/**
* This method should wrap the result of `signMessage` as per
* [EIP-6492](https://eips.ethereum.org/EIPS/eip-6492)
*
* @param msg -- the message to sign
* @returns the signature wrapped in 6492 format
*/
async signMessageWith6492(msg: string | Uint8Array): Promise<`0x${string}`> {
const [isDeployed, signature] = await Promise.all([
this.isAccountDeployed(),
this.signMessage(msg),
]);
return this.create6492Signature(isDeployed, signature);
}
/**
* Similar to the signMessageWith6492 method above,
* this method should wrap the result of `signTypedData` as per
* [EIP-6492](https://eips.ethereum.org/EIPS/eip-6492)
*
* @param params -- Typed Data params to sign
* @returns the signature wrapped in 6492 format
*/
async signTypedDataWith6492(
params: SignTypedDataParams
): Promise<`0x${string}`> {
const [isDeployed, signature] = await Promise.all([
this.isAccountDeployed(),
this.signTypedData(params),
]);
return this.create6492Signature(isDeployed, signature);
}
/**
* Not all contracts support batch execution.
* If your contract does, this method should encode a list of
* transactions into the call data that will be passed to your
* contract's batch execution method.
*
* @param _txs -- the transactions to batch execute
*/
async encodeBatchExecute(
_txs: BatchUserOperationCallData
): Promise<`0x${string}`> {
throw new BatchExecutionNotSupportedError("BaseAccount");
}
/**
* If your contract supports UUPS, you can implement this method which can be
* used to upgrade the implementation of the account.
*
* @param _upgradeToImplAddress -- the implementation address of the contract you want to upgrade to
* @param _upgradeToInitData -- the initialization data required by that account
*/
encodeUpgradeToAndCall = async (
_upgradeToImplAddress: Address,
_upgradeToInitData: Hex
): Promise<Hex> => {
throw new UpgradeToAndCallNotSupportedError("BaseAccount");
};
//#endregion optional-methods
// Extra implementations
async getNonce(): Promise<bigint> {
if (!(await this.isAccountDeployed())) {
return 0n;
}
const address = await this.getAddress();
return this.entryPoint.read.getNonce([address, BigInt(0)]);
}
async getInitCode(): Promise<Hex> {
if (this.deploymentState === DeploymentState.DEPLOYED) {
return "0x";
}
const contractCode = await this.rpcProvider.getBytecode({
address: await this.getAddress(),
});
if ((contractCode?.length ?? 0) > 2) {
this.deploymentState = DeploymentState.DEPLOYED;
return "0x";
} else {
this.deploymentState = DeploymentState.NOT_DEPLOYED;
}
return this._getAccountInitCode();
}
async getAddress(): Promise<Address> {
if (!this.accountAddress) {
const initCode = await this._getAccountInitCode();
Logger.verbose(
"[BaseSmartContractAccount](getAddress) initCode: ",
initCode
);
try {
await this.entryPoint.simulate.getSenderAddress([initCode]);
} catch (err: any) {
Logger.verbose(
"[BaseSmartContractAccount](getAddress) getSenderAddress err: ",
err
);
if (err.cause?.data?.errorName === "SenderAddressResult") {
this.accountAddress = err.cause.data.args[0] as Address;
Logger.verbose(
"[BaseSmartContractAccount](getAddress) entryPoint.getSenderAddress result:",
this.accountAddress
);
return this.accountAddress;
}
if (err.details === "Invalid URL") {
throw new InvalidRpcUrlError();
}
}
throw new GetCounterFactualAddressError();
}
return this.accountAddress;
}
extend = <R>(fn: (self: this) => R): this & R => {
const extended = fn(this) as any;
// this should make it so extensions can't overwrite the base methods
for (const key in this) {
delete extended[key];
}
return Object.assign(this, extended);
};
getSigner(): TSigner {
return this.signer;
}
getFactoryAddress(): Address {
return this.factoryAddress;
}
getEntryPointAddress(): Address {
return this.entryPointAddress;
}
async isAccountDeployed(): Promise<boolean> {
return (await this.getDeploymentState()) === DeploymentState.DEPLOYED;
}
async getDeploymentState(): Promise<DeploymentState> {
if (this.deploymentState === DeploymentState.UNDEFINED) {
const initCode = await this.getInitCode();
return initCode === "0x"
? DeploymentState.DEPLOYED
: DeploymentState.NOT_DEPLOYED;
} else {
return this.deploymentState;
}
}
/**
* https://eips.ethereum.org/EIPS/eip-4337#first-time-account-creation
* The initCode field (if non-zero length) is parsed as a 20-byte address,
* followed by calldata to pass to this address.
* The factory address is the first 40 char after the 0x, and the callData is the rest.
*
* @returns [factoryAddress, factoryCalldata]
*/
protected async parseFactoryAddressFromAccountInitCode(): Promise<
[Address, Hex]
> {
const initCode = await this._getAccountInitCode();
const factoryAddress: Address = `0x${initCode.substring(2, 42)}`;
const factoryCalldata: Hex = `0x${initCode.substring(42)}`;
return [factoryAddress, factoryCalldata];
}
protected async getImplementationAddress(): Promise<NullAddress | Address> {
const accountAddress = await this.getAddress();
const storage = await this.rpcProvider.getStorageAt({
address: accountAddress,
// This is the default slot for the implementation address for Proxies
slot: "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc",
});
if (storage == null) {
throw new FailedToGetStorageSlotError(
"0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc",
"Proxy Implementation Address"
);
}
return trim(storage);
}
private async _getAccountInitCode(): Promise<Hash> {
return this.accountInitCode ?? this.getAccountInitCode();
}
private async create6492Signature(
isDeployed: boolean,
signature: Hash
): Promise<Hash> {
if (isDeployed) {
return signature;
}
const [factoryAddress, factoryCalldata] =
await this.parseFactoryAddressFromAccountInitCode();
Logger.verbose(
`[BaseSmartContractAccount](create6492Signature)\
factoryAddress: ${factoryAddress}, factoryCalldata: ${factoryCalldata}`
);
return wrapSignatureWith6492({
factoryAddress,
factoryCalldata,
signature,
});
}
}