The Not-So-Definitive guide to Hyperliquid Precompiles.
Imagine if Binance allowed smart contract developers to deploy on the Binance order books… this is what Hyperliquid enables.
Disclaimer
The following information has been compiled from several discussions with the Hyperliquid team and also having access to the proof of concept that the team have published. It may not be completely accurate and as such, this article will be revised accordingly. It is also important to note that the techniques discussed in this article are still only available as a proof of concept and may change in the future.
Whats in a name?
Precompiles is the name used amongst the Hyperliquid ecosystem that more broadly refers to the integration available between the Hyperliquid L1 and the HyperEVM. Precompiles in a technical sense is only one part of the integration, but more on that later.
Depending on your perspective, native accessibility from the HyperEVM to the Hyperliquid L1 is possibly the most important feature and definately still the most under-appreciated feature that the Hyperliquid stack provides.
Why? Because it allows developers to build smart contracts that have access to the perps and spot liqudity that exists on the L1. The world of decentralized finance is still relatively young, but the primitives we have today have remained largely unchanged in the last few years… and in DeFi terms, thats old. Meanwhile, in that same time, the world of Centralized Exchanges has seen an increase in trading volume, but that liquidity is not composable in DeFi.
Think of a world in which these converge and you will get Hyperliquid.
The Hyperliquid Stack
First we need to understand the basics of the Hyperliquid stack. The Hyperliquid stack consists of two chains, the Hyperliquid L1 (L1) and the HyperEVM (EVM). These two chains exist as one unified state under the same consensus, but operate as separate execution environments.
The L1 is a permissioned chain that runs native components such as the perps and spot order books. The L1 is designed to operate with the high performance required to run the native components. Programability exists on the L1 via an API whereby actions submitted to the the API need to be signed just like any transaction to an EVM chain.
The EVM is a general-purpose EVM compatible chain which supports familiar Ethereum tooling. The EVM is permissionless, meaning that anyone can deploy a smart contract but with the added avantage in that those smart contracts have access to the on-chain perps and spot liqudity on the L1.
The L1 produces faster block times than the EVM, however, these blocks are still executed sequentially. This last part is important is it allows the execution in the EVM to read the current state of the L1 from the previous block and write to the next block.
Two techniques are employed for this native integration to work, Precompiles and Events.
So, what exactly are Precompiles?
Precompiled contracts, often just refered to as Precompiles, offer a way for an Ethereum Virtual Machine (EVM) implementation to provide access to native functions. They behave like smart contracts and have a well known address, however, that’s where the similarites end. What happens when the Precompiles are called is untirely up to the EVM implementation.
Lets take a look at an example of this would work to read a users perp positions on the L1.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract PositionReader {
address constant PRECOMPILE_ADDRESS = 0x0000000000000000000000000000000000000800;
struct Position {
int64 szi;
uint32 leverage;
uint64 entryNtl;
}
function readPosition(address user, uint16 perp) external view returns (Position memory) {
(bool success, bytes memory result) = PRECOMPILE_ADDRESS.staticcall(abi.encode(user, perp));
require(success, "readPosition call failed");
return abi.decode(result, (Position));
}
}
Now it becomes trivial for any smart contract to read a perps position and given the sequential nature in which the blocks are executed, there is a guarantee that the value read will be up to date.
Events to Actions
Precompiles are only a part of the story. They allow state from the L1 to be read on the EVM, but what if a smart contract wants to perform an action on the L1? The answer to this is actually really simple; Events.
Events are a way to log information to the blockchain outside of smart contracts storage variables. The Hyperlquid execution environment transforms events that have been emitted from a specific system address of 0x3333333333333333333333333333333333333333
into transactions that are executed on the L1.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract L1Write {
event IocOrder(address indexed user, uint16 perp, bool isBuy, uint64 limitPx, uint64 sz);
function sendIocOrder(uint16 perp, bool isBuy, uint64 limitPx, uint64 sz) external {
emit IocOrder(msg.sender, perp, isBuy, limitPx, sz);
}
}
We can see from the above, that the contract deployed at 0x333…333
does nothing more than just emitting the specific events. It is important to note a difference here when reading via a Precompile; any contract can embed the functionality to read from a Precompile address, however, the events must be emitted from the 0x333…333
address in order to be converted into actions to be executed on the L1.
Precompiles are amazing, surely there’s a catch?
In short, yes. Precompiles are indeed amazing and they will open the door for a range of new DeFi primitives that can live entirely on-chain. However, they aren’t going to work exactly as you expect… lets understand why.
Atomicity and the lack of it
Atomicity in the context of a blockchain transaction is the ability to retain the integrity of the blockchain state. If something fails in the transaction, the entire transaction is rolled back as if it was never executed. Atomicity is also a very powerful tool for a developer as it removes the complexity that exists when dealing with operations that may only partially succeed.
When interacting with the system contract of 0x333…333
to send a transaction to the L1, there is only partial atomicity. Atomicity exists in the context of whether or not the event is actually emitted. For example, lets take the following scenario in a contrived example;
function createOrder() public {
L1Write(0x3333333333333333333333333333333333333333).sendIocOrder(0, true, 100, 100);
_numberOfOrdersCreated += 1;
require(_numberOfOrdersCreated < 100, "too many orders")
}
In the example above, the L1 interface will emit the IocOrder
event, however, if the numberOfOrdersCreated
exceeds 100, then the transaction will revert. This reversion will also mean that the IocOrder
event wont be emitted. Therefore, in this context, you get full atomicity.
Now, what happens if the actual order creation fails on the L1? Remember from the diagram above, the EVM can read state from the previous L1 block and write actions to the next L1 block. When these events are emitted, they are just a signal to the L1 to execute the action during its next execution. They do not actually execute within the context of the EVM block. This is the most important part to understand when working with the EVM as it has several implications.
Firstly, it is entirely possible that the order creation in the example above might fail to execute on the L1 for various reasons, ie, maybe the account doesnt have enough margin. In this case, the numberOfOrdersCreated
was updated but the order didnt actually take place.
Secondly, because the event signals an action to the L1 and not actually an execution, there is no way to get a response as to whether or not the action succeeds, nor is there a way to get any response data.
Thirdly, given the action is just a signal, you cant expect to then read the updated state on the L1 via the Precompiles in the same block. The updated state will only be available on subsequent EVM blocks.
function createOrder() public {
L1Write(0x3333333333333333333333333333333333333333).sendIocOrder(0, true, 100, 100);
// the following call to read the position will not include any orders made above,
// it will only include positions that exist prior to the block executing
PositionReader(...).readPosition(...);
}
With all that, there are still some guarantees around the ordering of the actions. They will be processed in the order in which the events are raised and they are not atomic within their own scope, so one might fail whilst others succeed.
Whilst the above limitations around atomicity may seem daunting at first, it is still entirely possible to create powerful and reliable interactions.
In reality, atomicity is a luxury that doesnt exist in highly scalable systems and once you understand the nuances that come with only having partial atomicity, it is easy to design a system accordingly.
Message sender versus tx origin
When interaction with the L1 system address to create actions on the L1, these actions are executed in the context of the msg.sender
and not the transaction origin (the account which created the transaction). This means that if you create an order, it is actually the smart contract who is creating the order on the L1 and not the user that interacted witht the smart contract. Probably the most important thing to remember in this regard is that the smart contract’s interaction with the L1 can only be via the integration layer. If an immutable/non-upgradable smart contract is deployed and allows orders to be opened but never closed, then these orders will always remain open. Put simply, if your contract performs an interaction the L1, ensure that there’s always a way to recover the funds.
Accounts must already exists on the L1
When you first perform an interaction on the L1 from a new smart contract, the action will fail silently on the L1 if the account doesnt not already exists. This must be handled manually such that once the smart contract has been deployed and the address is known, a dust amount of USDC can be sent on the L1 from any account to the address of the smart contract. This forces the account to be created on the L1 and from there everything will work accordingly.
Dude, where’s my transfer?
There’s another edge case to cover and thats the case of the missing transfer. From the EVM, tokens can be sent to the L1 via an ERC20 transfer to the system address of 0x2222222222222222222222222222222222222222
. Whilst not specifically the same interface as the 0x333…333
, this still exhibits the same limitations as above. Once transferred from the EVM (assuming the token is linked on the L1), the accounts spot balance on the L1 will be credited with the token balance. However, armed with our knew found knowledge of how the L1 and EVM blocks fit together, there is the case whereby once an ERC20 transfer is made from an account, that amount will long long show when calling ERC20(…).balanceOf(…)
and nor will it show up when reading the spot balance for that account when reading the L1 state. In esscence, the balance enters a pre-crediting state that can’t be tracked anywhere. If the protocol relies on knowing the total balance of a spot token accross both the EVM and the L1 then it will need to internally track this pre-crediting state for any ERC20 transfers that occur.
Wrapping up
Though the use of Precompiles and Events, the Hyperliquid team have provided a way in which smart contracts running on the HyperEVM can access state on the Hyperliquid L1, effectively giving smart contracts access to the perps and spot liquidity on the L1. Imagine if Binance allowed smart contract developers to deploy on the Binance order books… this is what Hyperliquid enables. This is going to enable a new era of DeFi primitives and strategies to be built and live completely on-chain.
At the moment, the team have only deployed a very limited proof of concept on the Testnet to get feedback and encourage builders to start thinking of the possibilities that this can create. We at Ambit Labs are very excited to see this progress into the future.
In subsequent articles, we will walk through ways to navigate the lack of full atomicity.