Skip to content

Commit

Permalink
feat(tx-builder): tenderly simulation (#391)
Browse files Browse the repository at this point in the history
* feat(tx-builder): transaction simulation

* style(tx-builder): update buttons to match styling

* style(tx-builder): add Grid space for simulation result

* style(tx-builder): add close icon and fix small styles

* fix(tx-builder): allow to simulate from accounts without funds

* overwrite executor balance

Co-authored-by: Daniel Sanchez <[email protected]>
  • Loading branch information
mmv08 and Daniel Sanchez authored Apr 27, 2022
1 parent ac86af0 commit 761cef7
Show file tree
Hide file tree
Showing 11 changed files with 1,028 additions and 40 deletions.
7 changes: 6 additions & 1 deletion .github/workflows/deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ jobs:
PR_NUMBER: ${{ github.event.number }}
REVIEW_BUCKET_NAME: ${{ secrets.AWS_REVIEW_BUCKET_NAME }}
REACT_APP_RPC_TOKEN: ${{ secrets.REACT_APP_RPC_TOKEN }}
REACT_APP_TENDERLY_ORG_NAME: ${{ secrets.REACT_APP_TENDERLY_ORG_NAME }}
REACT_APP_TENDERLY_SIMULATE_ENDPOINT_URL: ${{ secrets.REACT_APP_TENDERLY_SIMULATE_ENDPOINT_URL }}
REACT_APP_TENDERLY_PROJECT_NAME: ${{ secrets.REACT_APP_TENDERLY_PROJECT_NAME }}

- name: 'PRaul: Comment PR with app URLs'
if: success() && github.event.number
Expand Down Expand Up @@ -84,6 +87,9 @@ jobs:
npx nx affected --target=build --parallel
env:
REACT_APP_RPC_TOKEN: ${{ secrets.REACT_APP_RPC_TOKEN }}
REACT_APP_TENDERLY_ORG_NAME: ${{ secrets.REACT_APP_TENDERLY_ORG_NAME }}
REACT_APP_TENDERLY_SIMULATE_ENDPOINT_URL: ${{ secrets.REACT_APP_TENDERLY_SIMULATE_ENDPOINT_URL }}
REACT_APP_TENDERLY_PROJECT_NAME: ${{ secrets.REACT_APP_TENDERLY_PROJECT_NAME }}

# Script to deploy to the dev environment
- name: 'Deploy to S3: Develop'
Expand Down Expand Up @@ -124,4 +130,3 @@ jobs:
prod-deployment-hook-url: ${{ secrets.PROD_DEPLOYMENT_HOOK_URL }}
bucket-name: ${{ secrets.STAGING_BUCKET_NAME }}/releases
react-app-rpc-token: ${{ secrets.REACT_APP_RPC_TOKEN }}

3 changes: 3 additions & 0 deletions apps/tx-builder/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
HTTPS=false
REACT_APP_RPC_TOKEN=
REACT_APP_TENDERLY_SIMULATE_ENDPOINT_URL=
REACT_APP_TENDERLY_PROJECT_NAME=
REACT_APP_TENDERLY_ORG_NAME=
13 changes: 7 additions & 6 deletions apps/tx-builder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@
"private": true,
"homepage": "/tx-builder",
"dependencies": {
"@gnosis.pm/safe-react-components": "^1.1.1",
"@gnosis.pm/safe-deployments": "^1.11.0",
"@gnosis.pm/safe-react-components": "^1.1.3",
"@material-ui/core": "^4.12.3",
"@material-ui/icons": "^4.11.0",
"@material-ui/lab": "^4.0.0-alpha.60",
"axios": "^0.24.0",
"axios": "^0.26.1",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"react-beautiful-dnd": "^13.1.0",
"react-hook-form": "^7.27.0",
"react-hook-form": "^7.28.0",
"react-router-dom": "^6.2.1",
"react-virtuoso": "^2.8.0",
"web3": "~1.7.0",
"localforage": "^1.10.0"
"react-virtuoso": "^2.8.2",
"web3": "~1.7.0"
},
"devDependencies": {
"@hookform/devtools": "^4.0.2",
Expand Down
1 change: 1 addition & 0 deletions apps/tx-builder/src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const GlobalStyle = createGlobalStyle`
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
box-shadow: 0 0 0 30px white inset !important;
-webkit-box-shadow: 0 0 0 30px white inset !important;
}
Expand Down
67 changes: 67 additions & 0 deletions apps/tx-builder/src/hooks/useSimulation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { BaseTransaction } from '@gnosis.pm/safe-apps-sdk';
import { useCallback, useState, useMemo } from 'react';
import { TenderlySimulation } from '../lib/simulation/types';
import {
getBlockMaxGasLimit,
getSimulationPayload,
getSimulation,
getSimulationLink,
} from '../lib/simulation/simulation';
import { useNetwork } from '../store/networkContext';
import { FETCH_STATUS } from '../utils';

type UseSimulationReturn =
| {
simulationRequestStatus: FETCH_STATUS.NOT_ASKED | FETCH_STATUS.ERROR | FETCH_STATUS.LOADING;
simulation: undefined;
simulateTransaction: () => void;
simulationLink: string;
}
| {
simulationRequestStatus: FETCH_STATUS.SUCCESS;
simulation: TenderlySimulation;
simulateTransaction: () => void;
simulationLink: string;
};

const useSimulation = (transactions: BaseTransaction[]): UseSimulationReturn => {
const [simulation, setSimulation] = useState<TenderlySimulation | undefined>();
const [simulationRequestStatus, setSimulationRequestStatus] = useState<FETCH_STATUS>(FETCH_STATUS.NOT_ASKED);
const simulationLink = useMemo(() => getSimulationLink(simulation?.simulation.id || ''), [simulation]);
const { safe, web3 } = useNetwork();

const simulateTransaction = useCallback(async () => {
if (!web3) return;

setSimulationRequestStatus(FETCH_STATUS.LOADING);
try {
const safeNonce = await web3.eth.getStorageAt(safe.safeAddress, `0x${'3'.padStart(64, '0')}`);
const blockGasLimit = await getBlockMaxGasLimit(web3);

const simulationPayload = getSimulationPayload({
chainId: safe.chainId.toString(),
safeAddress: safe.safeAddress,
executionOwner: safe.owners[0],
safeNonce,
transactions,
gasLimit: parseInt(blockGasLimit),
});

const simulationResponse = await getSimulation(simulationPayload);
setSimulation(simulationResponse);
setSimulationRequestStatus(FETCH_STATUS.SUCCESS);
} catch (error) {
console.error(error);
setSimulationRequestStatus(FETCH_STATUS.ERROR);
}
}, [safe, transactions, web3]);

return {
simulateTransaction,
simulationRequestStatus,
simulation,
simulationLink,
} as UseSimulationReturn;
};

export { useSimulation };
48 changes: 48 additions & 0 deletions apps/tx-builder/src/lib/simulation/multisend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Web3 from 'web3';
import { BaseTransaction } from '@gnosis.pm/safe-apps-sdk';
import { getMultiSendCallOnlyDeployment } from '@gnosis.pm/safe-deployments';

const getMultiSendCallOnlyAddress = (chainId: string): string => {
const deployment = getMultiSendCallOnlyDeployment({ network: chainId });

if (!deployment) {
throw new Error('MultiSendCallOnly deployment not found');
}

return deployment.networkAddresses[chainId];
};

const encodeMultiSendCall = (txs: BaseTransaction[]): string => {
const web3 = new Web3();

const joinedTxs = txs
.map((tx) =>
[
web3.eth.abi.encodeParameter('uint8', 0).slice(-2),
web3.eth.abi.encodeParameter('address', tx.to).slice(-40),
// if you pass wei as number, it will overflow
web3.eth.abi.encodeParameter('uint256', tx.value.toString()).slice(-64),
web3.eth.abi.encodeParameter('uint256', web3.utils.hexToBytes(tx.data).length).slice(-64),
tx.data.replace(/^0x/, ''),
].join(''),
)
.join('');

const encodedMultiSendCallData = web3.eth.abi.encodeFunctionCall(
{
name: 'multiSend',
type: 'function',
inputs: [
{
type: 'bytes',
name: 'transactions',
},
],
},
[`0x${joinedTxs}`],
);

return encodedMultiSendCallData;
};

export { encodeMultiSendCall, getMultiSendCallOnlyAddress };
207 changes: 207 additions & 0 deletions apps/tx-builder/src/lib/simulation/simulation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import axios from 'axios';
import Web3 from 'web3';
import { BaseTransaction } from '@gnosis.pm/safe-apps-sdk';
import { TenderlySimulatePayload, TenderlySimulation, StateObject } from './types';
import { encodeMultiSendCall, getMultiSendCallOnlyAddress } from './multisend';

type OptionalExceptFor<T, TRequired extends keyof T = keyof T> = Partial<Pick<T, Exclude<keyof T, TRequired>>> &
Required<Pick<T, TRequired>>;

// api docs: https://www.notion.so/Simulate-API-Documentation-6f7009fe6d1a48c999ffeb7941efc104
const TENDERLY_SIMULATE_ENDPOINT_URL = process.env.REACT_APP_TENDERLY_SIMULATE_ENDPOINT_URL || '';
const TENDERLY_PROJECT_NAME = process.env.REACT_APP_TENDERLY_PROJECT_NAME || '';
const TENDERLY_ORG_NAME = process.env.REACT_APP_TENDERLY_ORG_NAME || '';

const getSimulation = async (tx: TenderlySimulatePayload): Promise<TenderlySimulation> => {
const response = await axios.post<TenderlySimulation>(TENDERLY_SIMULATE_ENDPOINT_URL, tx);

return response.data;
};

const getSimulationLink = (simulationId: string): string => {
return `https://dashboard.tenderly.co/public/${TENDERLY_ORG_NAME}/${TENDERLY_PROJECT_NAME}/simulator/${simulationId}`;
};

/* We need to overwrite the threshold stored in smart contract storage to 1
to do a proper simulation that takes transaction guards into account.
The threshold is stored in storage slot 4 and uses full 32 bytes slot
Safe storage layout can be found here:
https://github.com/gnosis/safe-contracts/blob/main/contracts/libraries/GnosisSafeStorage.sol */
const THRESHOLD_ONE_STORAGE_OVERRIDE = {
[`0x${'4'.padStart(64, '0')}`]: `0x${'1'.padStart(64, '0')}`,
};

const getStateOverride = (
address: string,
balance?: string,
code?: string,
storage?: Record<string, string>,
): Record<string, StateObject> => ({
[address]: {
balance,
code,
storage,
},
});

interface SafeTransaction {
to: string;
value: string;
data: string;
safeTxGas: string;
baseGas: string;
gasPrice: string;
gasToken: string;
refundReceiver: string;
nonce: string;
operation: string;
}

interface SignedSafeTransaction extends SafeTransaction {
signatures: string;
}

const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';

const getBlockMaxGasLimit = async (web3: Web3): Promise<string> => {
const block = await web3.eth.getBlock('latest');
return block.gasLimit.toString();
};

const buildSafeTransaction = (template: OptionalExceptFor<SafeTransaction, 'to' | 'nonce'>): SafeTransaction => {
return {
to: template.to,
value: template.value || '0',
data: template.data || '0x',
operation: template.operation || '0',
safeTxGas: template.safeTxGas || '0',
baseGas: template.baseGas || '0',
gasPrice: template.gasPrice || '0',
gasToken: template.gasToken || ZERO_ADDRESS,
refundReceiver: template.refundReceiver || ZERO_ADDRESS,
nonce: template.nonce,
};
};

const encodeSafeExecuteTransactionCall = (tx: SignedSafeTransaction): string => {
const web3 = new Web3();

const encodedSafeExecuteTransactionCall = web3.eth.abi.encodeFunctionCall(
{
name: 'execTransaction',
type: 'function',
inputs: [
{ name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'data', type: 'bytes' },
{ name: 'operation', type: 'uint8' },
{ name: 'safeTxGas', type: 'uint256' },
{ name: 'baseGas', type: 'uint256' },
{ name: 'gasPrice', type: 'uint256' },
{ name: 'gasToken', type: 'address' },
{ name: 'refundReceiver', type: 'address' },
{ name: 'signatures', type: 'bytes' },
],
},
[
tx.to,
tx.value,
tx.data,
tx.operation,
tx.safeTxGas,
tx.baseGas,
tx.gasPrice,
tx.gasToken,
tx.refundReceiver,
tx.signatures,
],
);

return encodedSafeExecuteTransactionCall;
};

const getPreValidatedSignature = (address: string): string => {
return `0x000000000000000000000000${address.replace(
'0x',
'',
)}000000000000000000000000000000000000000000000000000000000000000001`;
};

type SimulationTxParams = {
safeAddress: string;
safeNonce: string;
executionOwner: string;
transactions: BaseTransaction[];
chainId: string;
gasLimit: number;
};

const getSingleTransactionExecutionData = (tx: SimulationTxParams): string => {
const safeTransaction = buildSafeTransaction({
to: tx.transactions[0].to,
value: tx.transactions[0].value,
data: tx.transactions[0].data,
nonce: tx.safeNonce,
operation: '0',
});
const signedSafeTransaction: SignedSafeTransaction = {
...safeTransaction,
signatures: getPreValidatedSignature(tx.executionOwner),
};

const executionTransactionData = encodeSafeExecuteTransactionCall(signedSafeTransaction);

return executionTransactionData;
};

const getMultiSendExecutionData = (tx: SimulationTxParams): string => {
const safeTransactionData = encodeMultiSendCall(tx.transactions);
const multiSendAddress = getMultiSendCallOnlyAddress(tx.chainId);
const safeTransaction = buildSafeTransaction({
to: multiSendAddress,
value: '0',
data: safeTransactionData,
nonce: tx.safeNonce,
operation: '1',
});
const signedSafeTransaction: SignedSafeTransaction = {
...safeTransaction,
signatures: getPreValidatedSignature(tx.executionOwner),
};

const executionTransactionData = encodeSafeExecuteTransactionCall(signedSafeTransaction);

return executionTransactionData;
};

const getSimulationPayload = (tx: SimulationTxParams): TenderlySimulatePayload => {
// we need separate functions for encoding single and multi send transactions because
// if there's only 1 transaction in the batch, the Safe interface doesn't route it through the multisend contract
// instead it directly calls the contract in the batch transaction
const executionData =
tx.transactions.length === 1 ? getSingleTransactionExecutionData(tx) : getMultiSendExecutionData(tx);

const safeThresholdStateOverride = getStateOverride(
tx.safeAddress,
undefined,
undefined,
THRESHOLD_ONE_STORAGE_OVERRIDE,
);

return {
network_id: tx.chainId,
from: tx.executionOwner,
to: tx.safeAddress,
input: executionData,
gas: tx.gasLimit,
// with gas price 0 account don't need token for gas
gas_price: '0',
state_objects: {
...safeThresholdStateOverride,
},
save: true,
save_if_fails: true,
};
};

export { getSimulationLink, getSimulation, getSimulationPayload, getBlockMaxGasLimit };
Loading

0 comments on commit 761cef7

Please sign in to comment.