What is Verifiable EVM Scripting? (and Why Your Dev Team Should Care)

intermediate

smart accountsevmscriptingdefimultichainaccount abstraction

I’ve been building in crypto (more specifically Ethereum/EVM) since 2016. In that time, a lot of things have changed. We’ve gotten amazing tooling such as @wevm_dev tools (viem, wagmi), Forge, Anvil, Tenderly, better simulation of RPC calls, state overrides and many, many more things which make the lives of developers building in Web3 so much easier.

But one thing has remained constant - let’s call it the “Standard dApp Development Workflow”. How does this workflow look?

  1. The smart contract devs write and deploy the “working” version of the contracts
  2. Frontend build the UI and create interfaces which interact with the contracts
  3. The contracts get audited and remediations are done
  4. Smart contract devs deploy new versions and those are connected to the frontend

Now, depending on whether or not the team used a Proxy pattern, when things need to change they’ll either:

  1. Redeploy the contracts and connect the new frontend to the new contracts. Often this will require a migration path for new developers.
  2. Upgrade the proxy contract and “silently” move everyone to a new version.

In both of those approaches, the smart contract development team has to go through the process of i) developing the contracts; ii) auditing the contracts; iii) doing remediations post-audit; iv) deploy the contracts.

This has a profound impact on the shipping speed of blockchain teams. Now let me present an alternate solution…

Thesis: We Should Write Fewer Contracts

Some things have to be smart contracts. No good way around it. For example - a token implementation. Another example might be a lock contract which holds some tokens (either for staking, or some type of vesting, etc…). There is a very clear reason for this to be a smart contract. AMMs are another example.

However, many of the things we write as smart contracts today would be much better served as simple scripts.

For example, a DeFi flow: Take my WETH, swap it to USDC and supply it to a lending protocol.

Why should this be a smart contract? Not only should it not be a smart contract, but there are numerous downsides in making that flow a smart contract. To outline a few:

  • Enabling this flow on another chain requires deploying that contract on that chain
  • Any modification to the contract requires a redeployment
  • All users use the same contract, not possible to add case-by-case bespoke logic to it

Proposal: Turn User Accounts Into Scripting Engines

What if this flow could be written entirely in TypeScript, encoded into the callData and verifiably executed on top of the users own account? This is the premise of verifiable onchain scripting!

Here is the flow! We create a Smart Contract Account for the user which has a module installed enabling it to execute composable instructions. We’ll call that account The Orchestrator Companion Account. You can think of this as being a user-owned account which has a Virtual Machine on top of it - enabling it to execute complex sets of instructions (an instruction is an EVM function call, with some bells and whistles attached)

Instructions in the same orchestration flow can span multiple chains and be asynchronously executed.

The user approves the smart account to spend certain funds and then the account pulls those funds and executes the required instructions. Then the Relayer starts executing the encoded Instructions on the Orchestrator Smart Account. After all of the Instructions have been executed, all resulting funds are returned back to the EOA.

Note one big advantage of using a Companion orchestrator account instead of executing the scripts directly on the users main account - security! The orchestrator account can only access the funds which were explicitly approved to be spent by the Orchestrator account.

One other Biconomy feature, which this article will skip over - is the ability for the user to sign the approval and the orchestration instructions in the same signature through a little bit of clever cryptography - but for the purposes of this explainer, it isn’t super important.

Composable Instruction Loop

Now, taking a things further - we will expose an execute function on the Orchestrator Smart Account that takes specially encoded EVM function calls in an array. You can think of these calls as the following data:

  • target: which contract to call
  • selector: which function to call
  • params: specially encoded parameters - in this encoding we aren’t limited to only providing fixed parameters (e.g. supply 100 USDC). We can also provide dynamically injected parameters (e.g. supply whatever the erc20BalanceOf is of USDC on this account at the time of execution)
  • storeReturnToMemory: whether or not we want to store the output of the function call to the storage slot of the smart account. If we do, then we can use it in the subsequent function call.

Every single instance of this data type is called an Instruction. Since instructions can write their outputs to memory and they can dynamically inject onchain data or outputs of previous instructions into their paramters, we have essentially created a composable scripting VM running on top of our smart account.

We can imagine writing a swap + supply flow in this way with the following pseudocode.

const approveUniswap = erc20(optimism).approve({
  token: weth,
  spender: uniswap[optimism],
  amount: 1
})
const swapUSDC = 
  uniswap(optimism).swap({
    inputToken: weth[optimism],
    outputToken: usdc[optimism],
    inputAmount: 1
  })

const approveLending = erc20(optimism).approve({
   token: usdc,
   amount: runtimeErc20BalanceOf({
     usdc[optimism],
     account: orchestratorAccount[optimism]
   })
})
const supplyToLending = 
  lendingProtocol(optimism).supply({
    token: usdc[optimism],
    amount: runtimeErc20BalanceOf({
      token: usdc[optimism],
      account: orchestratorAccount[optimism]
    })
  })
  
const execute = meeClient.execute({
  instructions: [
    approveUniswap,
    swapUSDC,
    approveLending,
    supplyToLending
  ]
})

What we have essentially achieved here is - written a full composable script - which until now could only have been written though the usage of a smart contract and executed it with a single signature!

Gas Abstraction

Since these scripts are executed by Relayers, they can pay for the ETH needed for gas, while getting paid in any ERC-20 token or even sponsoring the transaction itself.

const hash = await meeClient.execute({
  instructions: [...],
  feeToken: {
    address: usdc[optimism],
    chainId: optimism.id
  }
})

So at this point - we have not only saved the developer time on iteration cycles, we have also saved the user from unnecessary clicks (everything executed in a single signature) and have completely abstracted away gas. In fact, in this setup - you can even pay for gas cross-chain (e.g. pay for gas on Optimism with USDC on Base)

But we have more surprises…

Multichain Execution

The instructions array defining what will happen can be encoded in a special way so that we can represent an arbitrary number of instructions across an arbitrary number of chains with a single hash - by using a Merkle Tree.

This means that the user can permit any number of instructions across multiple chains - with a single signature!

At that point, our data structure starts looking a little like this:

  • Payment Instruction (Any Chain) - Defines a transfer of an ERC-20 token to the address of the Relayer if the relayer is paying for the gas of the user. This can be paid by the user (ERC-20 gas payments) or the developer (gas sponsorship)
  • Instructions Split By Atomic Bundles - Each leaf of the Merkle Tree represents an atomically executed bundle of composable instructions (wrapped into an ERC-4337 UserOp).

Asynchronous Ordering

These atomic bundles, however - don’t all have to be executed at the same time. What can happen is that e.g. the instructions on Optimism must be executed before the instructions on Base (e.g. you’re doing something on OP and then bridging to Base before proceeding execution there).

In that case, we need a way to create a logical ordering of the instructions. This is achieved with preconditions - every atomic bundle of instruction has a series of preconditions which must be met before the callData for that atomic bundle will be able to be executed onchain (if they’re not met, the Relayer can’t post the bundle onchain as it would revert).

E.g. a precondition after a bridge might be that the minimum balance of token on the account is the input amount minus the maximum acceptable bridge slippage. For example, if we want to supply to AAVE after bridging 1000 USDC, we might say that the destination chain action can’t execute until there is 999.5 USDC on the destination.

The relayers then - continuously simulate the transactions until the simulations stop reverting. After that - the relayer can post the transaction onchain and it will execute it.

In this way - we can create ordering of transactions across chains by any ruleset imaginable.

Example of a Multichain Script

const approveUniswapOP = encodeApproveUniswapOP()
const swapWETHtoUsdcOP = encodeSwapOP()
const approveAcrossOP = encodeApproveAcrossOP()
const callAcrossDepositOP = encodeCallAcrossDepositOp()
const approveAAVEBase = encodeApproveAAVEBase()
const supplyAAVEBase = encodeSupplyAAVEBase()

const hash = await meeClient.execute({
  // Execute all these instructions across 
  // all chains with a single signature
  instructions: [
    approveUniswapOP,
    swapWETHtoUsdcOP,
    approveAcrossOP,
    callAcrossDepositOP,
    approveAAVEBase,
    supplyAAVEBase 
  ],
  // Pay for gas with WETH on Optimism
  feeToken: toFeeToken(weth, optimism.id)
})

Turing Complete Scripts

This fully composable DeFi scripting is, however - just the beginning. Assuming we deploy a couple of auxilliary contracts - namely if/else and looping - we have created a Turing Complete scripting engine running gaslessly on top of the users smart account.

Now with that, we can easily imagine developers writing most of their business logic in TypeScript, Kotlin, C++, C#, Python or any other language for which an SDK has been written for this EVM scripting.

This not only opens up the world of Ethereum development to tens of millions of JavaScript/TypeScript, C#, Java, Kotlin, Python developers… but it also removes a lot of the unnecessarily slow development and iteration loops from blockchain teams.

In short, this is verifiable scripting and why your dev team should care.