mirror of
https://github.com/luxfi/eoa-contracts.git
synced 2026-10-30 16:58:32 +00:00
600 lines
16 KiB
TypeScript
600 lines
16 KiB
TypeScript
import {
|
|
arrayify,
|
|
defaultAbiCoder,
|
|
hexConcat,
|
|
hexDataSlice,
|
|
hexValue,
|
|
keccak256,
|
|
hexZeroPad,
|
|
} from "ethers/lib/utils";
|
|
import { BigNumber, Contract, Signer, Wallet } from "ethers";
|
|
import { ethers } from "hardhat";
|
|
import {
|
|
AddressZero,
|
|
callDataCost,
|
|
HashZero,
|
|
rethrow,
|
|
} from "../utils/testUtils";
|
|
import {
|
|
ecsign,
|
|
toRpcSig,
|
|
keccak256 as keccak256Buffer,
|
|
} from "ethereumjs-util";
|
|
import { EntryPoint } from "../../typechain";
|
|
import { UserOperation } from "./userOperation";
|
|
import { Create2Factory } from "../../src/Create2Factory";
|
|
import { MerkleTree } from "merkletreejs";
|
|
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
|
|
|
|
export function packUserOp(op: UserOperation, forSignature = true): string {
|
|
if (forSignature) {
|
|
return defaultAbiCoder.encode(
|
|
[
|
|
"address",
|
|
"uint256",
|
|
"bytes32",
|
|
"bytes32",
|
|
"uint256",
|
|
"uint256",
|
|
"uint256",
|
|
"uint256",
|
|
"uint256",
|
|
"bytes32",
|
|
],
|
|
[
|
|
op.sender,
|
|
op.nonce,
|
|
keccak256(op.initCode),
|
|
keccak256(op.callData),
|
|
op.callGasLimit,
|
|
op.verificationGasLimit,
|
|
op.preVerificationGas,
|
|
op.maxFeePerGas,
|
|
op.maxPriorityFeePerGas,
|
|
keccak256(op.paymasterAndData),
|
|
]
|
|
);
|
|
} else {
|
|
// for the purpose of calculating gas cost encode also signature (and no keccak of bytes)
|
|
return defaultAbiCoder.encode(
|
|
[
|
|
"address",
|
|
"uint256",
|
|
"bytes",
|
|
"bytes",
|
|
"uint256",
|
|
"uint256",
|
|
"uint256",
|
|
"uint256",
|
|
"uint256",
|
|
"bytes",
|
|
"bytes",
|
|
],
|
|
[
|
|
op.sender,
|
|
op.nonce,
|
|
op.initCode,
|
|
op.callData,
|
|
op.callGasLimit,
|
|
op.verificationGasLimit,
|
|
op.preVerificationGas,
|
|
op.maxFeePerGas,
|
|
op.maxPriorityFeePerGas,
|
|
op.paymasterAndData,
|
|
op.signature,
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
export function packUserOp1(op: UserOperation): string {
|
|
return defaultAbiCoder.encode(
|
|
[
|
|
"address", // sender
|
|
"uint256", // nonce
|
|
"bytes32", // initCode
|
|
"bytes32", // callData
|
|
"uint256", // callGasLimit
|
|
"uint256", // verificationGasLimit
|
|
"uint256", // preVerificationGas
|
|
"uint256", // maxFeePerGas
|
|
"uint256", // maxPriorityFeePerGas
|
|
"bytes32", // paymasterAndData
|
|
],
|
|
[
|
|
op.sender,
|
|
op.nonce,
|
|
keccak256(op.initCode),
|
|
keccak256(op.callData),
|
|
op.callGasLimit,
|
|
op.verificationGasLimit,
|
|
op.preVerificationGas,
|
|
op.maxFeePerGas,
|
|
op.maxPriorityFeePerGas,
|
|
keccak256(op.paymasterAndData),
|
|
]
|
|
);
|
|
}
|
|
|
|
export function getUserOpHash(
|
|
op: UserOperation,
|
|
entryPoint: string,
|
|
chainId: number
|
|
): string {
|
|
const userOpHash = keccak256(packUserOp(op, true));
|
|
const enc = defaultAbiCoder.encode(
|
|
["bytes32", "address", "uint256"],
|
|
[userOpHash, entryPoint, chainId]
|
|
);
|
|
return keccak256(enc);
|
|
}
|
|
|
|
export const DefaultsForUserOp: UserOperation = {
|
|
sender: AddressZero,
|
|
nonce: 0,
|
|
initCode: "0x",
|
|
callData: "0x",
|
|
callGasLimit: 0,
|
|
verificationGasLimit: 250000, // default verification gas. will add create2 cost (3200+200*length) if initCode exists
|
|
preVerificationGas: 21000, // should also cover calldata cost.
|
|
maxFeePerGas: 0,
|
|
maxPriorityFeePerGas: 1e9,
|
|
paymasterAndData: "0x",
|
|
signature: "0x",
|
|
};
|
|
|
|
export function signUserOp(
|
|
op: UserOperation,
|
|
signer: Wallet,
|
|
entryPoint: string,
|
|
chainId: number
|
|
): UserOperation {
|
|
const message = getUserOpHash(op, entryPoint, chainId);
|
|
const msg1 = Buffer.concat([
|
|
Buffer.from("\x19Ethereum Signed Message:\n32", "ascii"),
|
|
Buffer.from(arrayify(message)),
|
|
]);
|
|
|
|
const sig = ecsign(
|
|
keccak256Buffer(msg1),
|
|
Buffer.from(arrayify(signer.privateKey))
|
|
);
|
|
// that's equivalent of: await signer.signMessage(message);
|
|
// (but without "async"
|
|
const signedMessage1 = toRpcSig(sig.v, sig.r, sig.s);
|
|
return {
|
|
...op,
|
|
signature: signedMessage1,
|
|
};
|
|
}
|
|
|
|
export function fillUserOpDefaults(
|
|
op: Partial<UserOperation>,
|
|
defaults = DefaultsForUserOp
|
|
): UserOperation {
|
|
const partial: any = { ...op };
|
|
// we want "item:undefined" to be used from defaults, and not override defaults, so we must explicitly
|
|
// remove those so "merge" will succeed.
|
|
for (const key in partial) {
|
|
if (partial[key] == null) {
|
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
|
delete partial[key];
|
|
}
|
|
}
|
|
const filled = { ...defaults, ...partial };
|
|
return filled;
|
|
}
|
|
|
|
// helper to fill structure:
|
|
// - default callGasLimit to estimate call from entryPoint to account (TODO: add overhead)
|
|
// if there is initCode:
|
|
// - calculate sender by eth_call the deployment code
|
|
// - default verificationGasLimit estimateGas of deployment code plus default 100000
|
|
// no initCode:
|
|
// - update nonce from account.getNonce()
|
|
// entryPoint param is only required to fill in "sender address when specifying "initCode"
|
|
// nonce: assume contract as "getNonce()" function, and fill in.
|
|
// sender - only in case of construction: fill sender from initCode.
|
|
// callGasLimit: VERY crude estimation (by estimating call to account, and add rough entryPoint overhead
|
|
// verificationGasLimit: hard-code default at 100k. should add "create2" cost
|
|
export async function fillUserOp(
|
|
op: Partial<UserOperation>,
|
|
entryPoint?: EntryPoint,
|
|
getNonceFunction = "nonce",
|
|
useNonceKey = true,
|
|
nonceKey = 0
|
|
): Promise<UserOperation> {
|
|
const op1 = { ...op };
|
|
const provider = entryPoint?.provider;
|
|
if (op.initCode != null) {
|
|
const initAddr = hexDataSlice(op1.initCode!, 0, 20);
|
|
const initCallData = hexDataSlice(op1.initCode!, 20);
|
|
if (op1.nonce == null) op1.nonce = 0;
|
|
if (op1.sender == null) {
|
|
// hack: if the init contract is our known deployer, then we know what the address would be, without a view call
|
|
if (
|
|
initAddr.toLowerCase() === Create2Factory.contractAddress.toLowerCase()
|
|
) {
|
|
const ctr = hexDataSlice(initCallData, 32);
|
|
const salt = hexDataSlice(initCallData, 0, 32);
|
|
op1.sender = Create2Factory.getDeployedAddress(ctr, salt);
|
|
} else {
|
|
// console.log('\t== not our deployer. our=', Create2Factory.contractAddress, 'got', initAddr)
|
|
if (provider == null) throw new Error("no entrypoint/provider");
|
|
op1.sender = await entryPoint!.callStatic
|
|
.getSenderAddress(op1.initCode!)
|
|
.catch((e) => e.errorArgs.sender);
|
|
}
|
|
}
|
|
if (op1.verificationGasLimit == null) {
|
|
if (provider == null) throw new Error("no entrypoint/provider");
|
|
let initEstimate;
|
|
try {
|
|
initEstimate = await provider.estimateGas({
|
|
from: entryPoint?.address,
|
|
to: initAddr,
|
|
data: initCallData,
|
|
gasLimit: 10e6,
|
|
});
|
|
} catch (error) {
|
|
initEstimate = 1_000_000;
|
|
}
|
|
op1.verificationGasLimit = BigNumber.from(
|
|
DefaultsForUserOp.verificationGasLimit
|
|
).add(initEstimate);
|
|
}
|
|
}
|
|
if (op1.nonce == null) {
|
|
if (provider == null)
|
|
throw new Error("must have entryPoint to autofill nonce");
|
|
// Review/TODO: if someone passes 'nonce' as nonceFunction. or change the default
|
|
|
|
if (useNonceKey) {
|
|
const c = new Contract(
|
|
op.sender!,
|
|
[`function nonce(uint192) view returns(uint256)`],
|
|
provider
|
|
);
|
|
op1.nonce = await c.nonce(nonceKey).catch(rethrow());
|
|
} else {
|
|
const c = new Contract(
|
|
op.sender!,
|
|
[`function ${getNonceFunction}() view returns(uint256)`],
|
|
provider
|
|
);
|
|
op1.nonce = await c[getNonceFunction]().catch(rethrow());
|
|
}
|
|
}
|
|
if (op1.callGasLimit == null && op.callData != null) {
|
|
if (provider == null)
|
|
throw new Error("must have entryPoint for callGasLimit estimate");
|
|
let gasEstimated;
|
|
try {
|
|
gasEstimated = await provider.estimateGas({
|
|
from: entryPoint?.address,
|
|
to: op1.sender,
|
|
data: op1.callData,
|
|
});
|
|
} catch (error) {
|
|
// to handle the case when we need to build an userOp that is expected to fail
|
|
gasEstimated = 3_000_000;
|
|
}
|
|
|
|
// console.log('estim', op1.sender,'len=', op1.callData!.length, 'res=', gasEstimated)
|
|
// estimateGas assumes direct call from entryPoint. add wrapper cost.
|
|
op1.callGasLimit = gasEstimated; // .add(55000)
|
|
}
|
|
if (op1.maxFeePerGas == null) {
|
|
if (provider == null)
|
|
throw new Error("must have entryPoint to autofill maxFeePerGas");
|
|
const block = await provider.getBlock("latest");
|
|
op1.maxFeePerGas = block.baseFeePerGas!.add(
|
|
op1.maxPriorityFeePerGas ?? DefaultsForUserOp.maxPriorityFeePerGas
|
|
);
|
|
}
|
|
// TODO: this is exactly what fillUserOp below should do - but it doesn't.
|
|
// adding this manually
|
|
if (op1.maxPriorityFeePerGas == null) {
|
|
op1.maxPriorityFeePerGas = DefaultsForUserOp.maxPriorityFeePerGas;
|
|
}
|
|
const op2 = fillUserOpDefaults(op1);
|
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
|
if (op2.preVerificationGas.toString() === "0") {
|
|
// TODO: we don't add overhead, which is ~21000 for a single TX, but much lower in a batch.
|
|
op2.preVerificationGas = callDataCost(packUserOp(op2, false));
|
|
}
|
|
return op2;
|
|
}
|
|
|
|
export async function fillAndSign(
|
|
op: Partial<UserOperation>,
|
|
signer: Wallet | Signer,
|
|
entryPoint?: EntryPoint,
|
|
getNonceFunction = "nonce",
|
|
useNonceKey = true,
|
|
nonceKey = 0,
|
|
extraPreVerificationGas = 0
|
|
): Promise<UserOperation> {
|
|
const provider = entryPoint?.provider;
|
|
const op2 = await fillUserOp(
|
|
op,
|
|
entryPoint,
|
|
getNonceFunction,
|
|
useNonceKey,
|
|
nonceKey
|
|
);
|
|
op2.preVerificationGas =
|
|
Number(op2.preVerificationGas) + extraPreVerificationGas;
|
|
|
|
const chainId = await provider!.getNetwork().then((net) => net.chainId);
|
|
const message = arrayify(getUserOpHash(op2, entryPoint!.address, chainId));
|
|
|
|
return {
|
|
...op2,
|
|
signature: await signer.signMessage(message),
|
|
};
|
|
}
|
|
|
|
export async function makeEcdsaModuleUserOp(
|
|
functionName: string,
|
|
functionParams: any,
|
|
userOpSender: string,
|
|
userOpSigner: Signer,
|
|
entryPoint: EntryPoint,
|
|
moduleAddress: string,
|
|
options?: {
|
|
preVerificationGas?: number;
|
|
},
|
|
nonceKey = 0
|
|
): Promise<UserOperation> {
|
|
const SmartAccount = await ethers.getContractFactory("SmartAccount");
|
|
|
|
const txnDataAA1 = SmartAccount.interface.encodeFunctionData(
|
|
functionName,
|
|
functionParams
|
|
);
|
|
|
|
const userOp = await fillAndSign(
|
|
{
|
|
sender: userOpSender,
|
|
callData: txnDataAA1,
|
|
...options,
|
|
},
|
|
userOpSigner,
|
|
entryPoint,
|
|
"nonce",
|
|
true,
|
|
nonceKey,
|
|
0
|
|
);
|
|
|
|
// add validator module address to the signature
|
|
const signatureWithModuleAddress = ethers.utils.defaultAbiCoder.encode(
|
|
["bytes", "address"],
|
|
[userOp.signature, moduleAddress]
|
|
);
|
|
|
|
userOp.signature = signatureWithModuleAddress;
|
|
return userOp;
|
|
}
|
|
|
|
export async function makeEcdsaModuleUserOpWithPaymaster(
|
|
functionName: string,
|
|
functionParams: any,
|
|
userOpSender: string,
|
|
userOpSigner: Signer,
|
|
entryPoint: EntryPoint,
|
|
moduleAddress: string,
|
|
paymaster: Contract,
|
|
verifiedSigner: Wallet | SignerWithAddress,
|
|
validUntil: number,
|
|
validAfter: number,
|
|
options?: {
|
|
preVerificationGas?: number;
|
|
},
|
|
nonceKey = 0
|
|
): Promise<UserOperation> {
|
|
const SmartAccount = await ethers.getContractFactory("SmartAccount");
|
|
|
|
const txnDataAA1 = SmartAccount.interface.encodeFunctionData(
|
|
functionName,
|
|
functionParams
|
|
);
|
|
|
|
const userOp = await fillAndSign(
|
|
{
|
|
sender: userOpSender,
|
|
callData: txnDataAA1,
|
|
...options,
|
|
},
|
|
userOpSigner,
|
|
entryPoint,
|
|
"nonce",
|
|
true,
|
|
nonceKey,
|
|
0
|
|
);
|
|
|
|
const hash = await paymaster.getHash(
|
|
userOp,
|
|
verifiedSigner.address,
|
|
validUntil,
|
|
validAfter
|
|
);
|
|
const paymasterSig = await verifiedSigner.signMessage(arrayify(hash));
|
|
const userOpWithPaymasterData = await fillAndSign(
|
|
{
|
|
// eslint-disable-next-line node/no-unsupported-features/es-syntax
|
|
...userOp,
|
|
paymasterAndData: hexConcat([
|
|
paymaster.address,
|
|
ethers.utils.defaultAbiCoder.encode(
|
|
["address", "uint48", "uint48", "bytes"],
|
|
[verifiedSigner.address, validUntil, validAfter, paymasterSig]
|
|
),
|
|
]),
|
|
},
|
|
userOpSigner,
|
|
entryPoint,
|
|
"nonce",
|
|
true,
|
|
nonceKey,
|
|
0
|
|
);
|
|
|
|
// add validator module address to the signature
|
|
const signatureWithModuleAddress = ethers.utils.defaultAbiCoder.encode(
|
|
["bytes", "address"],
|
|
[userOpWithPaymasterData.signature, moduleAddress]
|
|
);
|
|
|
|
userOpWithPaymasterData.signature = signatureWithModuleAddress;
|
|
|
|
return userOpWithPaymasterData;
|
|
}
|
|
|
|
export async function makeSARegistryModuleUserOp(
|
|
functionName: string,
|
|
functionParams: any,
|
|
userOpSender: string,
|
|
userOpSigner: Signer,
|
|
entryPoint: EntryPoint,
|
|
saRegistryModuleAddress: string,
|
|
ecdsaModuleAddress: string,
|
|
options?: {
|
|
preVerificationGas?: number;
|
|
},
|
|
nonceKey = 0
|
|
): Promise<UserOperation> {
|
|
const SmartAccount = await ethers.getContractFactory("SmartAccount");
|
|
|
|
const txnDataAA1 = SmartAccount.interface.encodeFunctionData(
|
|
functionName,
|
|
functionParams
|
|
);
|
|
|
|
const userOp = await fillAndSign(
|
|
{
|
|
sender: userOpSender,
|
|
callData: txnDataAA1,
|
|
...options,
|
|
},
|
|
userOpSigner,
|
|
entryPoint,
|
|
"nonce",
|
|
true,
|
|
nonceKey,
|
|
0
|
|
);
|
|
|
|
const signatureForSAOwnershipRegistry = ethers.utils.defaultAbiCoder.encode(
|
|
["bytes", "address"],
|
|
[userOp.signature, ecdsaModuleAddress]
|
|
);
|
|
|
|
const signatureForECDSAOwnershipRegistry =
|
|
ethers.utils.defaultAbiCoder.encode(
|
|
["bytes", "address"],
|
|
[signatureForSAOwnershipRegistry, saRegistryModuleAddress]
|
|
);
|
|
|
|
userOp.signature = signatureForECDSAOwnershipRegistry;
|
|
return userOp;
|
|
}
|
|
|
|
export async function makeMultichainEcdsaModuleUserOp(
|
|
functionName: string,
|
|
functionParams: any,
|
|
userOpSender: string,
|
|
userOpSigner: Signer,
|
|
entryPoint: EntryPoint,
|
|
moduleAddress: string,
|
|
leaves: string[],
|
|
options?: {
|
|
preVerificationGas?: number;
|
|
},
|
|
validUntil = 0,
|
|
validAfter = 0,
|
|
nonceKey = 0
|
|
): Promise<UserOperation> {
|
|
const SmartAccount = await ethers.getContractFactory("SmartAccount");
|
|
|
|
const txnDataAA1 = SmartAccount.interface.encodeFunctionData(
|
|
functionName,
|
|
functionParams
|
|
);
|
|
|
|
const userOp = await fillAndSign(
|
|
{
|
|
sender: userOpSender,
|
|
callData: txnDataAA1,
|
|
...options,
|
|
},
|
|
userOpSigner,
|
|
entryPoint,
|
|
"nonce",
|
|
true,
|
|
nonceKey,
|
|
0
|
|
);
|
|
|
|
const leafOfThisUserOp = hexConcat([
|
|
hexZeroPad(ethers.utils.hexlify(validUntil), 6),
|
|
hexZeroPad(ethers.utils.hexlify(validAfter), 6),
|
|
hexZeroPad(await entryPoint.getUserOpHash(userOp), 32),
|
|
]);
|
|
|
|
leaves.push(leafOfThisUserOp);
|
|
leaves = leaves.map((x) => ethers.utils.keccak256(x));
|
|
|
|
const chainMerkleTree = new MerkleTree(leaves, keccak256, {
|
|
sortPairs: true,
|
|
});
|
|
|
|
// user only signs once
|
|
const multichainSignature = await userOpSigner.signMessage(
|
|
ethers.utils.arrayify(chainMerkleTree.getHexRoot())
|
|
);
|
|
|
|
// but still required to pad the signature with the required data (unsigned) for every chain
|
|
// this is done by dapp automatically
|
|
const merkleProof = chainMerkleTree.getHexProof(leaves[leaves.length - 1]);
|
|
const moduleSignature = defaultAbiCoder.encode(
|
|
["uint48", "uint48", "bytes32", "bytes32[]", "bytes"],
|
|
[
|
|
validUntil,
|
|
validAfter,
|
|
chainMerkleTree.getHexRoot(),
|
|
merkleProof,
|
|
multichainSignature,
|
|
]
|
|
);
|
|
|
|
// add validator module address to the signature
|
|
const signatureWithModuleAddress = defaultAbiCoder.encode(
|
|
["bytes", "address"],
|
|
[moduleSignature, moduleAddress]
|
|
);
|
|
|
|
// =================== put signature into userOp and execute ===================
|
|
userOp.signature = signatureWithModuleAddress;
|
|
|
|
return userOp;
|
|
}
|
|
|
|
export function serializeUserOp(op: UserOperation) {
|
|
return {
|
|
sender: op.sender,
|
|
nonce: hexValue(op.nonce),
|
|
initCode: op.initCode,
|
|
callData: op.callData,
|
|
callGasLimit: hexValue(op.callGasLimit),
|
|
verificationGasLimit: hexValue(op.verificationGasLimit),
|
|
preVerificationGas: hexValue(op.preVerificationGas),
|
|
maxFeePerGas: hexValue(op.maxFeePerGas),
|
|
maxPriorityFeePerGas: hexValue(op.maxPriorityFeePerGas),
|
|
paymasterAndData: op.paymasterAndData,
|
|
signature: op.signature,
|
|
};
|
|
}
|