Add Account framework (#5657)
This commit is contained in:
@ -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,
|
||||
);
|
||||
|
||||
@ -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
147
test/helpers/signers.js
Normal 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 };
|
||||
Reference in New Issue
Block a user