The HyVM — a VM on the EVM
TLDR: If you're only interested in a technical explanation of how the HyVM is implemented, just jump to "Deep dive into the HyVM" section.
🚀 Some context
There are plenty of amazing DeFi protocols out there. DeFi-natives know about them and use them on a daily basis. BUT:
- Interacting with those requires you to learn how they work.
- Interacting with multiple protocols (if not impossible) is often tough in a single transaction.
- Once you've set up a complex strategy, it is often tedious to revert-engineer what you've done and get out of it manually.
- For non-DeFi natives, complex strategies are just out of reach, even when the scheme is explained clearly by someone they trust. There is just too much to learn. Too many platforms to understand to be confident enough.
At Nested, we aim to provide a one-stop-shop for all DeFi protocols and to abstract that complexity away.
That leads us to wonder:
How to provide maximum flexibility and features, without having to audit, deploy and maintain nightmarish smartcontracts that are essentially supposed to do *everything* ?
That is why we came up with HyVM
❓ What is the HyVM?
The HyVM is a smart-contract that acts as a virtual machine. It can dynamically execute EVM bytecode, without having to deploy it.
Of course, allowing users to run arbitrary bytecode on a contract that multiple users share is not conceivable in the least. That is why you'll need a security model based on user segregation (which prevents using such an approach for protocols requiring all users' assets to be stored in a single contract, for instance).
But when your use case allows such a configuration where users have their own private contract, you get maximum flexibility with minimum on-chain maintenance.
🔐 Nested's security model
We've achieved user assets segregation via a simple approach:
- Each user has its own copy of the HyVM deployed (which is a proxy to a single implementation but let's not bother)
- Each contract has an associated NFT
- These contracts only allow arbitrary execution when the associated NFT's owner has signed the transaction.
We call such contracts (+ their associated NFT) "vaults"… they act kind of like wallets owned by one user, which are transferable (by transferring the underlying NFT).
📚 Some prerequisites about the EVM
EVM, contracts, languages, bytecode
If you learned how to program smart-contracts, you likely did it using Solidity. But smart-contracts and Solidity ARE NOT the same things.
You might have encountered the barbaric acronym "EVM" (standing for Ethereum Virtual Machine)… that's the smart-contract execution engine (at least on EVM-compatible blockchains).
Solidity is just a language (like many others like Vyper or Huff to name a few) that is compiled to "Bytecode."
That "bytecode" is run by the EVM, which doesn't know anything about Solidity and such.
Let's not dive into how to deploy a contract (i.e.
constructorin Solidity, which executes code during the deployment)… To put it simply, to be run, this bytecode needs to be deployed at a static address… anyone calling this address later will be able to run it.
How the EVM execution works
nb: If you know how other VMs work, like the CLR (the VM behind languages like C# or F#) or the JVM (behind Java or Kotlin), you'll feel right at home: EVM bytecode is very similar to MSIL or JVM bytecode, although much more straightforward.
A bytecode is a stream of instructions, each one represented by one byte (except
push1...32 which take 2…33 bytes).
You can find a complete list of all instructions at evm.codes
Each instruction has a well-defined behavior, which can do one or many of those three things:
- Consume values from "the stack."
- Push a value on "the stack."
- Affect the environment:
- read/write data from "the memory."
- read/write data from "storage."
- read data from call data.
- call other contracts.
- get data from other sources (We won't go into too much detail).
"the stack" is, as its name suggests, just a stack of 32 bytes values that is THE thing you manipulate with instructions. You give instructions on what to do through this stack. For instance, computing
40+2 would be done via this stream of instructions:
push1 40 👉 will push the value "40" on stack
push1 2 👉 will push the value "2" on stack
add 👉 will pop two values from stack, and push the result of their addition on stack
"the memory" is just what you would expect: a linear space where you can store things to be retrieved later in the execution. Several instructions allow writing to memory.
"the storage" is a key-value pair where you can store things… it is persistent through executions.
This section not aiming to be a fully-fledged tutorial about the insights of the EVM. I'll leave it like that. It should be enough to give you hints about how it works or relate to other VMs you know about. I'd suggest digging deeper into dedicated resources if you want to know more about it.
Let's dig into the real stuff.
🔍 Deep dive into the HyVM
👉 TLDR: The main contract you'll want to explore is here.
The HyVM is a VM, written in Huff.
When dealing with VMs, we often encounter the following concepts, which will be explained in this section.
- The "host" program itself is responsible for running the virtualized environment… here, the HyVM contract (running itself inside the EVM)
- The "guest" program, is here the contract that the host runs without it being able to notice that it is not running in a "normal" environment (it won't notice that it is not a deployed contract)… here, our guest program is passed as call data to the HyVM.
- host & guest memory segregation: the host and the guest need to store things in memory, but they can't know about each other.
A word about memory:
A classical EVM contract will be able to allocate memory addresses from 0 to infinity. Meaning that when running as a guest in our VM, it MUST be able to do that if we want it to run as expected.
That said, the host also needs to store things in memory, which must not be accessible to the guest. Which begs the question: Where to put our host's memory when our guests need to have [0; +∞[ accessible?
To solve this, we'll have to know how much memory space our host needs (this must be a fixed quantity) and offset the guest's memory by that amount. In other words: The 0x00 address of the guest will, in reality, be stored at a higher address. And then, we'll fix addresses pushed to instructions accessing memory by that amount (more on that later).
Fun fact: OSs (Linux, Windows, …) isolate kernel memory from user memory using the same kind of technique.
Breaking down the HyVM codebase
It has several noticeable parts
MAIN()macro… the entry point
CONTINUE()macro… the "run the next opcode" macro
- All the instruction implementation labels (labels beginning with
FIX_MEMOFFSET()macro… responsible for fixing the memory addresses of the guest
Let's dive into this.
1️⃣ MAIN() does only two things:
- Setup the host memory loading a so-called "jump-table", which is a mapping between opcodes and their implementation labels
- Start the execution by calling the first
2️⃣ CONTINUE() is responsible for running the next operation
- It loads the "execution pointer" from memory (i.e., our execution cursor = the address of the following instructions to be executed in our bytecode)
- Then, it moves this execution pointer to the next instruction
- Finally, it jumps to the right "instruction implementation" using the jump-table loaded by
This part is where the actual guest execution will take place.
The key thing to understand is that when jumping to an execution implementaiton, the host will have left no value of its own on the stack => All the values on stack will have been pushed by the guest contract.
That ensures that no host-specific value will ever be acccesible by the guest.
Implementing most operations will be as simple as just running the corresponding EVM instruction and then jumping to the next one. That's why most operation implementations look stupidly simple and are almost always the same. For instance,
add 👈 just adds the two values on stack
CONTINUE() 👈 jump to next instruction
That said, as stated in the "a word about memory" section, all instructions that are reading from or writing to memory need to be fixed to account for the host's memory. This is done by running
FIX_MEMOFFSET()macro on all stack values that refer to a memory pointer.
FIX_MEMOFFSET() 👈 fixes the memory pointer on top of stack
mload 👈 executes the actual mload instruction on fixed address
CONTINUE() 👈 jump to next instruction
Likewise, other more anecdotal instructions like
codecopywill be implemented differently because their underlying value is virtualized (
PCaccesses the program counter, which is actually stored in memory, and
codecopy will have to load code from call data, not from actual code — which is the HyVM code)
Once you understand everything above, its implementation is pretty straightforward, so I won't dive into details.
🕷 Wrapping up: Exploits and their mitigation
Each user owns their contract. They can do literally whatever they want with it and execute whatever code they want.
Let's imagine for one second that I am an infamous user. I know I will never be able to access other users' assets. BUT … I could do something like this:
- Create a vault
- Deploy a nefarious contract that I own and which can
- Run an
approve(usdc, nefariousContract, 0xfffffffffffffff)on my vault
- Transfer this vault's ownership to some unsuspecting victim (using some fishy pretext)
- Wait for this victim to add some USDC to his newly acquired vault
- Run my nefarious contract to drain the victim's USDC from my old vault.
This requires the victim to be a bit naïve, which could lead to unwanted issues.
To mitigate this, we've built a scripting language that generates "safe" bytecode, which can be run on the HyVM. We will never leave any artifacts behind (like approves… it will automatically un-approve everything by design).
If we detect that a user runs a transaction on their vault that our scripting language has not generated, their vault will be excluded from our platform.
That still leaves the liberty of our users to do whatever they want with their assets while giving all the tools to regular users to know when a vault that has been transferred to them should not be trusted.
Author: Olivier Guimbal.