Tenderly Smart Contract Simulations

Tenderly Smart Contract Simulations

Introduction

Tenderly is an all-in-one platform for smart contract development, focused primarily on providing developers with a way to visualize & debug their smart contracts, identifying & resolving issues or vulnerabilities, in a safe & user-friendly manner. It can be used via a user interface, CLI or SDK, offering a seamless experience for building, testing, monitoring & operating smart contracts.

In this article, the focus will be on the capabilities of the Tenderly TypeScript SDK, highlighting the advantages of using Simulations.

Tenderly Key Features

Monitoring & Alerts:
With 15 million alerts & 200,000 contracts monitored per month, Tenderly provides real-time notifications, monitoring on-chain events & security issues.
Gas Profiler:
Analyze & optimize functions to reduce costs of gas usage.
Web3 Gateway:
Reliable access to production "nodes as a service".
Networks:
Supports over 30 networks built on Ethereum.
Simulations:
Simulate historical & current transactions by modifying relevant parameters & source code to observe their outcomes before executing them on the blockchain.

Simulations

Tenderly's TypeScript SDK provides a way for developers to simulate smart contract interactions & executions in a non-production environment without sending them to the blockchain. They are useful as developers can test & debug smart contract interactions in an isolated & controlled environment. They work as unsigned transactions, so you don't need to own the keys of the contract owner to work with the simulations.

One of the core benefits of this is reproducibility, producing identical outcomes so long as the payload remains the same. This ensures that developers can accurately reproduce & analyze the behavior of their dApps in different scenarios.

Single Simulations
Single-use simulations work in a "simulate & forget" manner. They are suitable for testing one function or interaction at a time.
Simulation Bundles
Bundles work by enabling the simulation of transactions consecutively, allowing developers to test & debug their smart contract flows.

Outside of these two features, some of the other capabilities of the SDK is being able to manage contracts & wallets. Contract management functions include adding, updating, removing & verifying contracts. We can also manage wallets with add, update, fetch wallet/s & remove functionality.

Use Case Example

In the following sections of this walkthrough, I will be highlighting a real-life production situation where Tenderly Simulations could have saved the developers a decent amount of time & effort.

To give context to this use case: A few members of DeveloperDAO created an NFT collection called PixelDevs. PixelDevs takes the loot style, text-based metadata from D_D membership NFTs, & creates a new NFT using the metadata to create a unique image. The project was deployed & 606 NFT's were minted. The contract itself held funds of nearly $10,000 which were to be distributed amongst the team that created the project.

The problem? Funds we're at risk of being locked due to the contract owner being a Gnosis 1/3 multi-sig, & the withdraw function reaching its gas limit, thus preventing the contract from being withdrawn.

The solution used? Transfer ownership of the contract to an EOA wallet, withdraw to that wallet & then send those funds back to the multi-sig from the EOA. To understand if this would work, the team had to deploy a replica contract & run the functions in a production environment. This approach added a lot of nuances, given that it wasn't tested against the exact contract with the funds that were deployed to mainnet.

A far more direct & viable solution would be to use Tenderly Simulations to understand the outcomes of these two function calls if they were called sequentially on the exact contract needed. Using the simulateBundle function, you can chain smart contract functions together, effectively creating the flow needed to test against the scenario above (or as desired) before interacting with the network & production contract.

The TypeScript build below will simulate the transferOwnership & withdraw functions one after the other, removing the need to deploy a dummy contract, we can verify that the flow works exactly as intended before running them against the production contract.

Getting Started

We will be creating a TypeScript project to simulate the use case highlighted above. It is recommended that you have basic knowledge of TypeScript & Solidity.

To work with simulations using the SDK, you will need to create a Tenderly account so you can use the account information to instantiate a Tenderly object. You can sign up here. Next, you will need the verified smart contract that you wish to use for simulating transactions. You can create & verify using tools like Remix or directly off your machine using Hardhat.

Set-Up

To get started, create & navigate to the tenderly-simulations directory, create a package.json, install the dependencies & create an index.ts file.

mkdir tenderly-simulations
cd tenderly-simulations && yarn init
yarn add @tenderly/sdk dotenv ethers
yarn add -D typescript
touch index.ts

Tenderly Instance
To interact with the SDK you will need to instantiate a Tenderly object using your account information & access key. To get this information, log in to your account & go to your dashboard. You will need to retrieve your account name, project name & access key.

To get your user name & project name, click on "Project Settings" in the top right of the project dashboard. You will see a URL displayed similar to https://dashboard.tenderly.co/pbillingsby/project, with the first appended value to the URL being the account name, & the second being the project name.

For your access token, click the User icon in the top right & go to "Account Settings" -> "Authorization". Here you can generate a token for creating the object.

Create a .env file & fill in the variables with the values from above. NOTE: For security purposes, if you're pushing code to GitHub, please ensure that your .env is included in .gitignore.

Here's what your .env should look like.

TENDERLY_ACCOUNT_NAME=
TENDERLY_PROJECT_NAME=
TENDERLY_ACCESS_KEY=

To minimize repeated declarations of this instance, create a utils directory & add a file named tenderlyInstance.ts. This will now be accessible in any file that relies on it.

The network selected should be the network to which the smart contract you wish to simulate is deployed, which in this case is Polygon Mainnet.

require('dotenv').config()
import { Tenderly, Network } from "@tenderly/sdk";

const tenderlyInstance: Tenderly = new Tenderly({
  accountName: process.env.TENDERLY_ACCOUNT_NAME || "",
  projectName: process.env.TENDERLY_PROJECT_NAME || "",
  accessKey: process.env.TENDERLY_ACCESS_KEY || "",
  network: Network.POLYGON
});

export default tenderlyInstance;

Simulating The Transactions

There is one more thing we need to get before creating the flow, which is the contract ABI. You can get the specific ABI from the Tenderly dashboard, etherscan (polygonscan etc), or Remix, using the contract ID as the identifier. We will be passing this ABI into the Interface class by Ethers to abstract the encoding & decoding required to interact with contracts on the Ethereum network.

import { Interface } from 'ethers';
import { TransactionParameters } from '@tenderly/sdk';
import tenderlyInstance from './utils/tenderlyInstance';

const abiInterface = (): Interface => {
  return new Interface([
    {
      "inputs": [
        {
          "internalType": "address",
          "name": "newOwner",
          "type": "address"
        }
      ],
      "name": "transferOwnership",
      "outputs": [],
      "stateMutability": "nonpayable",
      "type": "function"
    },
    {
      "inputs": [],
      "name": "withdraw",
      "outputs": [],
      "stateMutability": "nonpayable",
      "type": "function"
    }
  ]);
}

const CONTRACT_OWNER: string = '0xdd00cc906b93419814443bb913949d503b3df3c4';
const TRANSFER_OWNER: string = '0x765fEB3FB358867453B26c715a29BDbbC10Be772';
const PROXY_CONTRACT: string = '0x916B13FCa6192fE5e4E2cD58F712BA9Ade43CeD0';

(async () => {
  const transaction = await tenderlyInstance.simulator.simulateBundle({
    transactions: [transferOwnership(), withdrawFunds()],
    blockNumber: 44134585,
  })

  console.log(transaction)
})();

function transferOwnership(): TransactionParameters {
  return {
    from: CONTRACT_OWNER,
    to: PROXY_CONTRACT,
    gas: 0,
    gas_price: '0',
    value: 0,
    input: abiInterface().encodeFunctionData('transferOwnership', [TRANSFER_OWNER]),
  };
};

function withdrawFunds(): TransactionParameters {
  return {
    from: TRANSFER_OWNER,
    to: PROXY_CONTRACT,
    gas: 8000000,
    gas_price: '0',
    value: 0,
    input: abiInterface().encodeFunctionData('withdraw', []),
  };
};

In the transferOwnership function, we declare the payload to pass into the simulation. In addition to the required fields to execute a transaction such as to and from, the input field instructs the function to run, along with any parameters specified by the contract function. In this case, it requires the address of the new owner, which is being passed in as a string in an array.

For withdrawFunds, there are no specified input parameters as it withdraws funds to the contract owner, which in this simulation call will be TRANSFER_OWNER instead of the original CONTRACT_OWNER.

This is a basic example of how passing input parameters to the simulator works, though as you could probably imagine, the more complex the contract & parameters become, the more test scenarios you can run simulations against specific values that the contract needs to test against.

Now that we have our script written, we can test out the simulation by running npx ts-node index.ts.

This script will produce the result of two objects, representing the function's returned values, in an array. If the status is true it means both of the desired functions have been simulated successfully. If you change the value of CONTRACT_OWNER, the transferOwnership function will cause the withdraw function to fail. The result will indicate a status: false in each of the objects.

Conclusion

In conclusion, the use of Tenderly simulations provides significant advantages to developers & teams working with smart contracts. By employing simulations, teams can effectively test & debug their code, saving time and ensuring secure practices. Simulations offer a safe and controlled environment for replicating real-world scenarios, allowing developers to analyze contract behavior and identify potential issues before deployment.

The benefits of using Tenderly simulations include improved testing & debugging processes, enhanced security & time savings. Through simulations, teams can proactively detect bugs & vulnerabilities, reducing the risk of costly errors & exploits in live deployments. Simulations also enable the replication of complex scenarios, such as network congestion or varying gas prices, providing valuable insights into contract performance under different conditions.

Tenderly simulations are scalable & adaptable to different networks & interactions, making them suitable for a wide range of smart contract projects. As Tenderly continues to evolve, there is immense potential for further advancements in simulation capabilities, ensuring even more robust & reliable testing practices.

In summary, Tenderly simulations empower developers & teams to thoroughly test, debug, & optimize their smart contracts, ultimately leading to enhanced security, reduced development time, & improved efficiency. By harnessing the power of simulations, teams can confidently deploy secure & reliable smart contracts, building trust among users & stakeholders in the blockchain ecosystem.

Repo: github.com/PBillingsby/tenderly-simulations
Documentation: docs.tenderly.co/tenderly-sdk/intro-to-tend..