Transactions Simulation on a frontend

Transactions simulation
on a frontend
WEB3 ENGINEERING
Transaction simulation provides a very convenient user's experience, but it there are multiple approaches for that.
Expected amount of SOL you'll receive is a result of simulation
Some web3 products have a relatively simple concept, but lots of implementation tricks and details. To anticipate this, UX folks did something great, they've decided: hence it is difficult to explain all of the details of our product in a reasonable easy way (and usually it really is!), and moreover, even if the concept is simple, user has to apply those concepts to his desired actions, so they have to deal with some uncertainty again. Instead, let's allow to the user to describe what they are going to do, and then show what the effect will be.
In this article we are going to cover some ways to achieve that with a real world code examples.
Transaction simulation is never a guarantee that what you see is what you get. A malicious developer can trick this system.

Client-side simulation

One of the ways to predict the expected outcome of a transaction is to read all of the state variables from the blockchain to the code, and then explicitly write the simulation code.

contract FixedRateSwap {
  uint256 rate_;
  IERC20 token_;

  function swap() notReentrant /* because of msg.value being unsafe */ {
    token_.transfer(msg.sender, msg.value * rate_);
  }
}
In the oversimplified contract above (please, don't copy it to production, it is really just a concept) user gets some token in exchange for the ether, using a fixed rate. Let's imagine how the UI code can look like for this case.

function useSimulatedTokenAmount(inputTokenAmount) {
  const [rate, setRate] = useState();
  useEffect(() => {
    const rate = await myContract.rate_();
    setRate(rate);
  }, [])
  const result = useMemo(() => {
    if (rate !== undefined) {
      return inputTokenAmount * rate;
    }
  }, [rate, inputTokenAmount]);
  return result;
}
So here, you can see, we are duplicating logic (msg.value * rate_) in the UI (inputTokenAmount * rate).
What are pros and cons of this approach? It gives an infinite freedom in what so emulate, how to do it etc. But it is very fragile, and needs to have all of the state variables exposed.

EVM: views or static calls

Let's take a look at the example below: here contract not only performs an action, but also returns the result.

contract FixedRateSwap {
  uint256 rate_;
  IERC20 token_;

  function swap() notReentrant /* because of msg.value being unsafe */ returns (uint256) {
    uint256 tokenToSend = msg.value * rate_;
    token_.transfer(msg.sender, tokenToSend);
    return tokenToSend;
  }
}
But how can you call a method to get the returned value without triggering a real action. The answer it: EVM nodes already have some simulation facilities. So, in order to use them you can try to use views (we are not covering them here, it is more or less straightforward) or static calls (we are using ethers.js syntax here, as, again, we are just copypasting real code to this article, not time to prepare nicely :), but viem, web3py and every other library also supports this, because it is a standard):

function useSimulatedTokenAmount(inputTokenAmount) {
  const [result, setResult] = useState();
  useEffect(() => {
    let detached = false;
    initiateFetching();
    return () => { detached = true; }
    async function initiateFetching() {
      const resultFromContract = await contract.swap.staticCall(inputTokenAmount);
      if (detached) return;
      setResult(resultFromContract);
    }
  }, [inputTokenAmount])
  result
}
This method is pretty nice and clear. The only problem with it is dependency on what can be simulated: only what the contract developer allowed us to. Also this doesn't allow to simulate some complex scenarios, for example put my tokens to storage, and if the rate will change +20% in 2 years, and my token vesting schedule is ... etc etc.

Simulation frameworks

To tackle the above mentioned problem, simulation frameworks were developed. Tenderly is one of them. The main concept there is a "fork" — an imaginary state of the blockchain which originates from the current, and then contains only the transactions we requested. This approach is a peak of flexibility. Here I'm going to provide a Python code, because it is what our clients asked, as they are running that from the backend. The only downside is that this is a third-party solution, which will ask you for subscription is you are doing more than 50 forks (simulation sessions) per day.

def create_fork():
    """
    Each simulation session should start from fork creation.

    :returns fork_id
    """
    response = requests.post(
        f'https://api.tenderly.co/api/v1/account/{TENDERLY_USER}/project/{TENDERLY_PROJECT}/fork',
        json={
            "network_id": '1',
            "chain_config": {
                "chain_id": 11,
                "shanghai_time": 1677557088,
              },
        },
        headers={
            'X-Access-Key': TENDERLY_ACCESS_KEY,
        },
    )
    response.raise_for_status()
    return response.json()['simulation_fork']['id']


def create_transaction_in_fork(fork_id, from_address, to_address, data, value):
    url = f'https://api.tenderly.co/api/v1/account/{TENDERLY_USER}/project/{TENDERLY_PROJECT}/fork/{fork_id}/simulate'
    resp = requests.post(url, json={
        "from": from_address,
        "to": to_address,
        "input": data,
        "save": True,
        "skip_fork_head_update": False,
        "gas": 8000000,
        "value": str(value),
    }, headers={
            'X-Access-Key': TENDERLY_ACCESS_KEY,
        },)
    resp.raise_for_status()
    

create_transaction_in_fork(fork_id,
                           "0x3E3C51857c381307e295780f0B6b5654A3717743",
                           "0x4cfF0951253cAD983b29F5ca994D4d7dc81D94Fc",
                           "0x",
                           1_000_000_000)


# now you can observe results, all of the state variables etc using the following
fork_rpc = f'https://rpc.tenderly.co/fork/{fork_id}'
If you are really expecting a high load on this type of simulations, you can use forking facilities of hardhat or anvil nodes. This will be free for you but requires some setup and maintenance efforts.

Solana

Solana's simulation facilities are much more interesting than EVM's, but not close to Tenderly. Solana allows to specify what accounts exactly you want to observe after the simulation complete. So you can see almost everything, but you are only limited to obversing results only of a single transaction.

function useExpectedUSDCbalance(transactionV0) {
    const [simulationResults, setSimulationResults]
    useEffect(() => {
        let detached = false;
        initiateSimulation();
        return () => { detached = true; }
        async function initiateSimulation() {
            const result = await connection.simulateTransaction(transactionV0, {
                accounts: {
                    addresses: [usersAccountForUSDC],
                     encoding: "base64"
                 }
             })
             if (detached) return;
             if (result.value.err) {
                 setSimulationResults({ ok: false })
             } else {
                setSimulationResults({
                    ok: true,
                    newBalances: [
                        parseBalance(result.value.accounts[0]),
                    ]
                })
            }
        }
    }, [transactionV0]);
    return simulationResults;
}
Close
Ready to take your business to the next level? Let's talk
I agree to the Terms of Service
Made on
Tilda