Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PoC for Safe simulateTransaction function #789

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 66 additions & 3 deletions src/utils/execution.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Signer, BigNumberish, BaseContract, ethers } from "ethers";
import { AddressZero } from "@ethersproject/constants";
import { Safe } from "../../typechain-types";
import { Safe, SimulateTxAccessor } from "../../typechain-types";

export const EIP_DOMAIN = {
EIP712Domain: [
Expand Down Expand Up @@ -148,8 +148,8 @@ export const buildSignatureBytes = (signatures: SafeSignature[]): string => {
let dynamicBytes = "";
for (const sig of signatures) {
if (sig.dynamic) {
/*
A contract signature has a static part of 65 bytes and the dynamic part that needs to be appended
/*
A contract signature has a static part of 65 bytes and the dynamic part that needs to be appended
at the end of signature bytes.
The signature format is
Signature type == 0
Expand Down Expand Up @@ -200,6 +200,69 @@ export const executeTx = async (safe: Safe, safeTx: SafeTransaction, signatures:
);
};

export const simulateTx = async (
safe: Safe,
simulateTxAccessor: SimulateTxAccessor,
safeTx: SafeTransaction,
overrides?: any,
): Promise<{
success: boolean;
gasUsed: bigint;
returndata: string;
}> => {
try {
const simulateTxAccessorAddress = await simulateTxAccessor.getAddress();
const simulateSafeTxEncodedPayload = simulateTxAccessor.interface.encodeFunctionData("simulate", [
safeTx.to,
safeTx.value,
safeTx.data,
safeTx.operation,
]);
await safe.simulateAndRevert.staticCall(simulateTxAccessorAddress, simulateSafeTxEncodedPayload, overrides || {}); // must revert
} catch (error: any) {
// Extract the revert data
const revertData = error.data;

if (revertData && revertData.length >= 194) {
// 0x + 96 bytes (32 bytes each for success flag, offset, and at least one more field)
// Decode the success flag of simulateAndRevert
const [success] = ethers.AbiCoder.defaultAbiCoder().decode(["bool"], revertData.slice(0, 66));

if (!success) {
throw new Error("simulateAndRevert failed");
}

// The actual data starts at position 128 (0x80)
const simulateReturnData = "0x" + revertData.slice(130);

// Decode the returned data from simulate function
const [estimate, simulateSuccess, returnData] = ethers.AbiCoder.defaultAbiCoder().decode(
["uint256", "bool", "bytes"],
simulateReturnData,
);

return {
success: simulateSuccess,
gasUsed: BigInt(estimate),
returndata: returnData,
};
} else if (revertData && revertData.length === 130) {
// 0x + 64 bytes (32 bytes each for success flag and empty data)
// Decode the success flag of simulateAndRevert
const [success] = ethers.AbiCoder.defaultAbiCoder().decode(["bool"], revertData.slice(0, 66));
return {
success,
gasUsed: BigInt(0),
returndata: "",
};
} else {
console.log(revertData);
throw new Error("Invalid revertData length");
}
}
throw new Error("simulateAndRevert must revert");
};

export const buildContractCall = async (
contract: BaseContract,
method: string,
Expand Down
60 changes: 46 additions & 14 deletions test/core/Safe.Execution.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect } from "chai";
import hre, { deployments, ethers } from "hardhat";
import { deployContract, getSafeWithOwners } from "../utils/setup";
import { deployContract, getSafeWithOwners, getSimulateTxAccessor } from "../utils/setup";
import {
safeApproveHash,
buildSignatureBytes,
Expand All @@ -9,6 +9,7 @@ import {
executeTx,
calculateSafeTransactionHash,
buildContractCall,
simulateTx,
} from "../../src/utils/execution";

import { chainId } from "../utils/encoding";
Expand Down Expand Up @@ -41,12 +42,16 @@ describe("Safe", () => {
}
}`;
const reverter = await deployContract(user1, reverterSource);

const accessor = await getSimulateTxAccessor();

return {
safe: await getSafeWithOwners([user1.address]),
reverter,
storageSetter,
nativeTokenReceiver,
signers,
accessor,
};
});

Expand Down Expand Up @@ -181,15 +186,21 @@ describe("Safe", () => {
});

it("should revert on unknown operation", async () => {
const { safe, signers } = await setupTests();
const { safe, signers, accessor } = await setupTests();
const [user1] = signers;
const safeAddress = await safe.getAddress();

const tx = buildSafeTransaction({ to: safeAddress, nonce: await safe.nonce(), operation: 2 });
const simulationResult = await simulateTx(safe, accessor, tx);

// expect to be reverted with no string
expect(simulationResult.success).to.be.eq(false);
expect(simulationResult.returndata).to.be.eq("");
await expect(executeTx(safe, tx, [await safeApproveHash(user1, safe, tx, true)])).to.be.reverted;
});

it("should emit payment in success event", async () => {
const { safe, signers } = await setupTests();
const { safe, signers, accessor } = await setupTests();
const [user1, user2] = signers;
const safeAddress = await safe.getAddress();
const tx = buildSafeTransaction({
Expand All @@ -206,6 +217,10 @@ describe("Safe", () => {
expect(await hre.ethers.provider.getBalance(safeAddress)).to.be.eq(ethers.parseEther("1"));

let executedTx: any;

const simulationResult = await simulateTx(safe, accessor, tx);
expect(simulationResult.success).to.be.eq(true);

await expect(
executeTx(safe, tx, [await safeApproveHash(user1, safe, tx, true)]).then((tx) => {
executedTx = tx;
Expand All @@ -230,7 +245,7 @@ describe("Safe", () => {
});

it("should emit payment in failure event", async () => {
const { safe, storageSetter, signers } = await setupTests();
const { safe, storageSetter, signers, accessor } = await setupTests();
const [user1, user2] = signers;
const safeAddress = await safe.getAddress();
const storageSetterAddress = await storageSetter.getAddress();
Expand All @@ -249,6 +264,9 @@ describe("Safe", () => {
const userBalance = await hre.ethers.provider.getBalance(user2.address);
await expect(await hre.ethers.provider.getBalance(safeAddress)).to.eq(ethers.parseEther("1"));

const simulationResult = await simulateTx(safe, accessor, tx);

expect(simulationResult.success).to.be.eq(false);
let executedTx: any;
await expect(
executeTx(safe, tx, [await safeApproveHash(user1, safe, tx, true)]).then((tx) => {
Expand All @@ -271,16 +289,16 @@ describe("Safe", () => {
});

it("should be possible to manually increase gas", async () => {
const { safe, signers } = await setupTests();
const { safe, signers, accessor } = await setupTests();
const [user1] = signers;
const safeAddress = await safe.getAddress();
const gasUserSource = `
contract GasUser {

uint256[] public data;

constructor() payable {}

function nested(uint256 level, uint256 count) external {
if (level == 0) {
for (uint256 i = 0; i < count; i++) {
Expand All @@ -290,7 +308,7 @@ describe("Safe", () => {
}
this.nested(level - 1, count);
}

function useGas(uint256 count) public {
this.nested(6, count);
this.nested(8, count);
Expand All @@ -301,18 +319,30 @@ describe("Safe", () => {
const data = gasUser.interface.encodeFunctionData("useGas", [80]);
const safeTxGas = 10000;
const tx = buildSafeTransaction({ to, data, safeTxGas, nonce: await safe.nonce() });

let simulationResult = await simulateTx(safe, accessor, tx, { gasLimit: 170000 });
expect(simulationResult.success).to.be.eq(false);
await expect(
executeTx(safe, tx, [await safeApproveHash(user1, safe, tx, true)], { gasLimit: 170000 }),
"Safe transaction should fail with low gasLimit",
).to.emit(safe, "ExecutionFailure");

await expect(
executeTx(safe, tx, [await safeApproveHash(user1, safe, tx, true)], { gasLimit: 6000000 }),
"Safe transaction should succeed with high gasLimit",
).to.emit(safe, "ExecutionSuccess");
simulationResult = await simulateTx(safe, accessor, tx, { gasLimit: 6000000 });
expect(simulationResult.success).to.be.eq(true);
const executedTxPromise = executeTx(safe, tx, [await safeApproveHash(user1, safe, tx, true)], { gasLimit: 6000000 });
const executedTxResponse = await executedTxPromise;
const receipt = await hre.ethers.provider.getTransactionReceipt(executedTxResponse.hash);
// everything else(i.e. other than the safe tx gas which is being estimated by simulateTransaction)
// includes intrinsic/base gas cost and signature validation, etc
const maxGasForEverythingElse = 60_000n; // it's reasonable that this will take the most
expect(receipt?.gasUsed).to.be.gt(simulationResult.gasUsed);
expect(receipt?.gasUsed).to.be.lt(simulationResult.gasUsed + maxGasForEverythingElse);
await expect(executedTxPromise, "Safe transaction should succeed with high gasLimit").to.emit(safe, "ExecutionSuccess");

// This should only work if the gasPrice is 0
tx.gasPrice = 1;
simulationResult = await simulateTx(safe, accessor, tx, { gasLimit: 6000000 });
expect(simulationResult.success).to.be.eq(false);
await user1.sendTransaction({ to: safeAddress, value: ethers.parseEther("1") });
await expect(
executeTx(safe, tx, [await safeApproveHash(user1, safe, tx, true)], { gasLimit: 6000000 }),
Expand All @@ -321,7 +351,7 @@ describe("Safe", () => {
});

it("should forward all the gas to the native token refund receiver", async () => {
const { safe, nativeTokenReceiver, signers } = await setupTests();
const { safe, nativeTokenReceiver, signers, accessor } = await setupTests();
const [user1] = signers;
const safeAddress = await safe.getAddress();
const nativeTokenReceiverAddress = await nativeTokenReceiver.getAddress();
Expand All @@ -338,6 +368,8 @@ describe("Safe", () => {
await user1.sendTransaction({ to: safeAddress, value: ethers.parseEther("1") });
await expect(await hre.ethers.provider.getBalance(safeAddress)).to.eq(ethers.parseEther("1"));

const simulationResult = await simulateTx(safe, accessor, tx);
expect(simulationResult.success).to.be.eq(true);
const executedTx = await executeTx(safe, tx, [await safeApproveHash(user1, safe, tx, true)], { gasLimit: 500000 });
const receipt = await hre.ethers.provider.getTransactionReceipt(executedTx.hash);
const receiptLogs = receipt?.logs ?? [];
Expand Down
Loading