β Zipped Contracts
Compressed contracts that automatically self-extract when called. Useful for cheaply deploying contracts that are always called off-chain in an eth_call
context. There are many examples of these contracts used in modern protocols/dapps: "lens"-type helper contracts, quoters, NFT metadata, query helpers, etc.
There's also a companion web app which lets you deploy zipped contracts from the browser: bytecode.zip
Installation (foundry)
$> forge install merklejerk/zipped-contracts
Overview
Zipped contracts are essentially normal contracts that are compressed off-chain using zlib DEFLATE then deployed on-chain inside of a self-extracting wrapper. Any call to the wrapper contract will trigger the wrapper's minimal fallback that forwards the call to the canonical Z
runtime contract. The runtime contract decompresses the zipped contract, deploys it, then forwards the original call to the deployed instance. The result is bubbled up inside of a revert()
payload to undo the deployment and avoid permanently modifying state.
All this witchcraft means that, from an eth_call
context, interacting with a zipped contract is very similar to any other contract!
Case Studies
Most contracts can expect to see ~50% size/deployment cost reduction, and better for text-heavy applications. I applied this technology to some known off-chain contracts for comparison. No modifications were made to them and they still function as the original does.
contract | current bytecode size | zipped bytecode size | savings/reduction |
---|---|---|---|
Uniswap V3 Quoter | 4631 |
2241 |
51% |
shields.build SVG Metadata | 23732 |
8206 |
65% |
Interacting with Zipped Contracts
The self-extracting wrapper will perform the just-in-time decompression under the hood, so you can usually directly call a function on the zipped contract as if it were the unzipped contract. However, there are some things to be mindful of:
- Decompressing is a very expensive operation (upwards of 23M gas), so you should only call these contracts in the context of an
eth_call
where gas does not matter, i.e., not in a transaction that will be mined. - Zipped contracts do not support
payable
functions. - Off-chain helper and metadata contracts tend to have their functions written as read-only (
view
orpure
). But since zipped contracts must be deployed just-in-time before the call can be made, top-level calls from another contract into the zipped contract cannot be made from inside astaticcall()
context. You should cast the contract's interface to one with non-static functions to prevent the compiler from implictly generating astaticcall()
when making calls. - To emulate static guarantees of typical off-chain helper contracts, all zipped contracts will have their state changes undone by a revert on the top-level call. However, this only applies to the top-level call. Any calls beneath it that reenter the zipped contract can temporarily persist state, but will eventually be undone when the top-level call returns.
- Zipped contracts cannot have their source/ABI verified on etherscan at the moment. If you want users to be able to interact with zipped contracts through etherscan, consider deploying a minimal contract with the same interface that forwards calls to the zipped version (example).
ZCALL vs ZRUN Contracts
There are two types of zipped contracts supported by the runtime. The simpler, and probably more popular, choice is ZCALL, which follows the flow described earlier. The primary purpose of ZCALL contracts is to facilitate cheaper deployments. You usually don't have to do anything special to write ZCALL contracts; they often just workβ’.
ZRUN contracts, on the other-hand, are designed to bypass maximum bytecode size constraints. There is a well-known ~24KB bytecode size limit for deployable contracts on Ethereum that many projects bump into. ZRUN contracts artificially extend this ceiling, but to accomplish this, your contract must be written very deliberately:
- You must perform all your logic inside the constructor.
- You must manually ABI-encode and
return()
your return data in the constructor.
This means ZRUN contracts only have one entry-point/function, which is their constructor. They also cannot support callbacks (directly) because they will never have code at their deployed address.
Deploying Zipped Contracts
There are foundry scripts and examples included in this repo that you can use to deploy your contracts as self-extracting zipped contracts.
If you add this repo as a foundry dependency (forge install zipped-contracts
), you can inherit from ZDeployBase
in your deploy script and let it handle deployment like so:
import "zipped-contracts/script/ZDeployBase.sol";
import "src/MyContract.sol";
contract Deploy is ZDeployBase {
function run() external {
Z z = _getOrDeployZ();
vm.startBroadcast();
// Deploy as a ZCALL contract:
_zcallDeploy(type(MyContract).creationCode, z);
// or deploy as a ZRUN contract:
_zrunDeploy(type(MyContract).creationCode, z);
}
}
Note that the scripts in this repo require the ffi=true
option in your foundry.toml
and a python3
to be accessible via env
.
Writing ZCALL Contracts
Most query-oriented contracts work out of the box as a ZCALL contract, with little to no modification. However, if your contract depends on the value of msg.sender
or uses callbacks, there are some quirks to keep in mind.
Addresses
address(this)
will be the deterministic, temporary deployment address of your unzipped contract, which is different from the zipped contract address. If a call is made to your contract through the zipped contract, msg.sender
will be the zipped contract address, and the original caller of the zipped contract will be appended to your calldata as a full word (32 bytes). You can call isZippedContract()
on the Z
runtime to detect if an address is your zipped contract:
// Get the caller of the zipped contract.
address caller = Z.isZippedContract(msg.sender, address(this))
? abi.decode(msg.data[msg.data.length-32:], (address))
: msg.sender;
}
Callbacks and Reentrancy
You can reenter your unzipped contract either directly, via this.xxx()
, or indirectly through the zipped contract's forwarder ZIPPED_CONTRACT.xxx()
. But bear in mind the meaning of msg.sender
in the latter case, as described previously.
Delegatecalls
Yup, you can delegatecall()
into a zipped contract and it will (eventually) delegatecall into the unzipped logic.
Writing ZRUN Contracts
ZRUN contracts only have a single entry point and must be specially crafted to perform all their logic and explicitly return its result in its constructor. This can soften deployment bytecode size limits because only the result of the computation (not code) is deposited at the deployment address. The zipped version of your logic must still fit within the maximum deployment size, however.
Addresses
address(this)
will always be the deterministic, temporary deployment address of your unzipped contract, which is different from the zipped contract address. msg.sender
will always be the zipped contract. You can recover the original caller of the zipped contract by decoding the last 32 bytes of your contract's initcode (accessible via codecopy()
in assembly).
Z
Runtime Deployed Addresses
This is the canonical runtime for zipped contracts, which handles decompression, execution, and cleanup. You probably won't need to interact with this contract directly if you're using the self-extracting wrapper.
Callbacks and Reentrancy
ZRUN contracts cannot reenter or utilize callbacks because they run entirely inside of a constructor.
ZRUN Example
Your ZRUN contract should explicitly return()
its abi-encoded result in its constructor. You can call your own internal
/public
functions, but not external
functions. From outside, calling any function on a zipped ZRUN contract will result in only the constructor being called.
contract AddTwoNumbers {
constructor(uint256 a, uint256 b) {
uint256 c = a + b;
bytes memory result = abi.encode(c);
assembly { return(add(result, 0x20), mload(result)) }
}
// Dummy function to trigger the constructor. The body and name of this function does
// not matter.
function exec(uint256 a, uint256 b) external returns (uint256) {}
}
// ...
// Deploy and call the ZRUN contract.
// Prints "1337"
console.log(AddTwoNumbers(_zrunDeploy(type(AddTwoNumbers).creationCode)).exec(1300, 37));
Deployed Addresses
These are the canonical runtime deployments. These will change with each subsequent release. Contracts deployed under older runtimes should continue to function without. You should try to interact only with the version your contract was deployed with.
network | address |
---|---|
Ethereum mainnet | 0xf1EeADEba1E05578d0e08635Fc7356aeDBEB33d1 |
Goerli | 0xcA64D4225804F2Ae069760CB5fF2F1D8BaC1C2f9 |
Sepolia | 0xcA64D4225804F2Ae069760CB5fF2F1D8BaC1C2f9 |
Project Setup and Test
It's a foundry project. You know the drill.
$> git clone [email protected]:merklejerk/zipped-contracts.git && cd zipped-contracts
$> forge install
$> forge test -vvv
Kudos
Thanks to @adlerjohn for inflate-sol, which is the basis for the decompression used here.
Disclaimer
This project is not endorsed or affiliated with Dragonfly π«’.