Add Account framework (#5657)

This commit is contained in:
Ernesto García
2025-06-02 08:22:57 -06:00
committed by GitHub
parent 88962fb5ab
commit 83d2a247be
38 changed files with 3086 additions and 46 deletions

View File

@ -11,19 +11,8 @@ module.exports = mapValues(
verifyingContract: 'address',
salt: 'bytes32',
},
Permit: {
owner: 'address',
spender: 'address',
value: 'uint256',
nonce: 'uint256',
deadline: 'uint256',
},
Ballot: {
proposalId: 'uint256',
support: 'uint8',
voter: 'address',
nonce: 'uint256',
},
Permit: { owner: 'address', spender: 'address', value: 'uint256', nonce: 'uint256', deadline: 'uint256' },
Ballot: { proposalId: 'uint256', support: 'uint8', voter: 'address', nonce: 'uint256' },
ExtendedBallot: {
proposalId: 'uint256',
support: 'uint8',
@ -32,18 +21,8 @@ module.exports = mapValues(
reason: 'string',
params: 'bytes',
},
OverrideBallot: {
proposalId: 'uint256',
support: 'uint8',
voter: 'address',
nonce: 'uint256',
reason: 'string',
},
Delegation: {
delegatee: 'address',
nonce: 'uint256',
expiry: 'uint256',
},
OverrideBallot: { proposalId: 'uint256', support: 'uint8', voter: 'address', nonce: 'uint256', reason: 'string' },
Delegation: { delegatee: 'address', nonce: 'uint256', expiry: 'uint256' },
ForwardRequest: {
from: 'address',
to: 'address',
@ -53,6 +32,29 @@ module.exports = mapValues(
deadline: 'uint48',
data: 'bytes',
},
PackedUserOperation: {
sender: 'address',
nonce: 'uint256',
initCode: 'bytes',
callData: 'bytes',
accountGasLimits: 'bytes32',
preVerificationGas: 'uint256',
gasFees: 'bytes32',
paymasterAndData: 'bytes',
},
UserOperationRequest: {
sender: 'address',
nonce: 'uint256',
initCode: 'bytes',
callData: 'bytes',
accountGasLimits: 'bytes32',
preVerificationGas: 'uint256',
gasFees: 'bytes32',
paymasterVerificationGasLimit: 'uint256',
paymasterPostOpGasLimit: 'uint256',
validAfter: 'uint48',
validUntil: 'uint48',
},
},
formatType,
);

View File

@ -1,4 +1,4 @@
const { ethers } = require('hardhat');
const { ethers, config, entrypoint, senderCreator } = require('hardhat');
const SIG_VALIDATION_SUCCESS = '0x0000000000000000000000000000000000000000';
const SIG_VALIDATION_FAILURE = '0x0000000000000000000000000000000000000001';
@ -83,6 +83,129 @@ class UserOperation {
}
}
const parseInitCode = initCode => ({
factory: '0x' + initCode.replace(/0x/, '').slice(0, 40),
factoryData: '0x' + initCode.replace(/0x/, '').slice(40),
});
/// Global ERC-4337 environment helper.
class ERC4337Helper {
constructor() {
this.factoryAsPromise = ethers.deployContract('$Create2');
}
async wait() {
this.factory = await this.factoryAsPromise;
return this;
}
async newAccount(name, extraArgs = [], params = {}) {
const env = {
entrypoint: params.entrypoint ?? entrypoint.v08,
senderCreator: params.senderCreator ?? senderCreator.v08,
};
const { factory } = await this.wait();
const accountFactory = await ethers.getContractFactory(name);
if (params.erc7702signer) {
const delegate = await accountFactory.deploy(...extraArgs);
const instance = await params.erc7702signer.getAddress().then(address => accountFactory.attach(address));
const authorization = await params.erc7702signer.authorize({ address: delegate.target });
return new ERC7702SmartAccount(instance, authorization, env);
} else {
const initCode = await accountFactory
.getDeployTransaction(...extraArgs)
.then(tx =>
factory.interface.encodeFunctionData('$deploy', [0, params.salt ?? ethers.randomBytes(32), tx.data]),
)
.then(deployCode => ethers.concat([factory.target, deployCode]));
const instance = await ethers.provider
.call({
from: env.entrypoint,
to: env.senderCreator,
data: env.senderCreator.interface.encodeFunctionData('createSender', [initCode]),
})
.then(result => ethers.getAddress(ethers.hexlify(ethers.getBytes(result).slice(-20))))
.then(address => accountFactory.attach(address));
return new SmartAccount(instance, initCode, env);
}
}
}
/// Represent one ERC-4337 account contract.
class SmartAccount extends ethers.BaseContract {
constructor(instance, initCode, env) {
super(instance.target, instance.interface, instance.runner, instance.deployTx);
this.address = instance.target;
this.initCode = initCode;
this._env = env;
}
async deploy(account = this.runner) {
const { factory: to, factoryData: data } = parseInitCode(this.initCode);
this.deployTx = await account.sendTransaction({ to, data });
return this;
}
async createUserOp(userOp = {}) {
userOp.sender ??= this;
userOp.nonce ??= await this._env.entrypoint.getNonce(userOp.sender, 0);
if (ethers.isAddressable(userOp.paymaster)) {
userOp.paymaster = await ethers.resolveAddress(userOp.paymaster);
userOp.paymasterVerificationGasLimit ??= 100_000n;
userOp.paymasterPostOpGasLimit ??= 100_000n;
}
return new UserOperationWithContext(userOp, this._env);
}
}
class ERC7702SmartAccount extends SmartAccount {
constructor(instance, authorization, env) {
super(instance, undefined, env);
this.authorization = authorization;
}
async deploy() {
// hardhat signers from @nomicfoundation/hardhat-ethers do not support type 4 txs.
// so we rebuild it using "native" ethers
await ethers.Wallet.fromPhrase(config.networks.hardhat.accounts.mnemonic, ethers.provider).sendTransaction({
to: ethers.ZeroAddress,
authorizationList: [this.authorization],
gasLimit: 46_000n, // 21,000 base + PER_EMPTY_ACCOUNT_COST
});
return this;
}
}
class UserOperationWithContext extends UserOperation {
constructor(userOp, env) {
super(userOp);
this._sender = userOp.sender;
this._env = env;
}
addInitCode() {
if (this._sender?.initCode) {
return Object.assign(this, parseInitCode(this._sender.initCode));
} else throw new Error('No init code available for the sender of this user operation');
}
getAuthorization() {
if (this._sender?.authorization) {
return this._sender.authorization;
} else throw new Error('No EIP-7702 authorization available for the sender of this user operation');
}
hash() {
return super.hash(this._env.entrypoint);
}
}
module.exports = {
SIG_VALIDATION_SUCCESS,
SIG_VALIDATION_FAILURE,
@ -90,4 +213,5 @@ module.exports = {
packInitCode,
packPaymasterAndData,
UserOperation,
ERC4337Helper,
};

147
test/helpers/signers.js Normal file
View File

@ -0,0 +1,147 @@
const {
AbstractSigner,
Signature,
TypedDataEncoder,
assert,
assertArgument,
concat,
dataLength,
decodeBase64,
getBytes,
getBytesCopy,
hashMessage,
hexlify,
sha256,
toBeHex,
} = require('ethers');
const { secp256r1 } = require('@noble/curves/p256');
const { generateKeyPairSync, privateEncrypt } = require('crypto');
// Lightweight version of BaseWallet
class NonNativeSigner extends AbstractSigner {
#signingKey;
constructor(privateKey, provider) {
super(provider);
assertArgument(
privateKey && typeof privateKey.sign === 'function',
'invalid private key',
'privateKey',
'[ REDACTED ]',
);
this.#signingKey = privateKey;
}
get signingKey() {
return this.#signingKey;
}
get privateKey() {
return this.signingKey.privateKey;
}
async getAddress() {
throw new Error("NonNativeSigner doesn't have an address");
}
connect(provider) {
return new NonNativeSigner(this.#signingKey, provider);
}
async signTransaction(/*tx: TransactionRequest*/) {
throw new Error('NonNativeSigner cannot send transactions');
}
async signMessage(message /*: string | Uint8Array*/) /*: Promise<string>*/ {
return this.signingKey.sign(hashMessage(message)).serialized;
}
async signTypedData(
domain /*: TypedDataDomain*/,
types /*: Record<string, Array<TypedDataField>>*/,
value /*: Record<string, any>*/,
) /*: Promise<string>*/ {
// Populate any ENS names
const populated = await TypedDataEncoder.resolveNames(domain, types, value, async name => {
assert(this.provider != null, 'cannot resolve ENS names without a provider', 'UNSUPPORTED_OPERATION', {
operation: 'resolveName',
info: { name },
});
const address = await this.provider.resolveName(name);
assert(address != null, 'unconfigured ENS name', 'UNCONFIGURED_NAME', { value: name });
return address;
});
return this.signingKey.sign(TypedDataEncoder.hash(populated.domain, types, populated.value)).serialized;
}
}
class P256SigningKey {
#privateKey;
constructor(privateKey) {
this.#privateKey = getBytes(privateKey);
}
static random() {
return new this(secp256r1.utils.randomPrivateKey());
}
get privateKey() {
return hexlify(this.#privateKey);
}
get publicKey() {
const publicKeyBytes = secp256r1.getPublicKey(this.#privateKey, false);
return { qx: hexlify(publicKeyBytes.slice(0x01, 0x21)), qy: hexlify(publicKeyBytes.slice(0x21, 0x41)) };
}
sign(digest /*: BytesLike*/) /*: Signature*/ {
assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest);
const sig = secp256r1.sign(getBytesCopy(digest), getBytesCopy(this.#privateKey), { lowS: true });
return Signature.from({ r: toBeHex(sig.r, 32), s: toBeHex(sig.s, 32), v: sig.recovery ? 0x1c : 0x1b });
}
}
class RSASigningKey {
#privateKey;
#publicKey;
constructor(keyPair) {
const jwk = keyPair.publicKey.export({ format: 'jwk' });
this.#privateKey = keyPair.privateKey;
this.#publicKey = { e: decodeBase64(jwk.e), n: decodeBase64(jwk.n) };
}
static random(modulusLength = 2048) {
return new this(generateKeyPairSync('rsa', { modulusLength }));
}
get privateKey() {
return hexlify(this.#privateKey);
}
get publicKey() {
return { e: hexlify(this.#publicKey.e), n: hexlify(this.#publicKey.n) };
}
sign(digest /*: BytesLike*/) /*: Signature*/ {
assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest);
// SHA256 OID = 608648016503040201 (9 bytes) | NULL = 0500 (2 bytes) (explicit) | OCTET_STRING length (0x20) = 0420 (2 bytes)
return {
serialized: hexlify(
privateEncrypt(this.#privateKey, getBytes(concat(['0x3031300d060960864801650304020105000420', digest]))),
),
};
}
}
class RSASHA256SigningKey extends RSASigningKey {
sign(digest /*: BytesLike*/) /*: Signature*/ {
assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest);
return super.sign(sha256(getBytes(digest)));
}
}
module.exports = { NonNativeSigner, P256SigningKey, RSASigningKey, RSASHA256SigningKey };