Research journal · Security research
Three 31-Byte EVM Smart Contracts Inspired by Assembly Tanka
A tip vault, an eventful counter, and a delegatecall proxy: three callable EVM contracts in 31 bytes of 5/7/5/7/7-byte phrases, tested with Wake.
Assembly tanka is a programming challenge inspired by Japanese poetry: write a working program in exactly 31 bytes, divided into five phrases of 5, 7, 5, 7, and 7 bytes. Each phrase can contain several instructions, but no instruction may cross a phrase boundary.
So let’s try it on the EVM. We have room for a tip vault, a counter that emits events, and even a delegatecall proxy. All three fit into 31 bytes of deployed code, without a Solidity function dispatcher.
Only the deployed runtime counts toward the limit. The constructor, creation bytecode, and calldata are outside the poem.
Assembly tanka is a near-future cultural pastime: writing a program in machine code of thirty-one bytes, in phrases of five, seven, five, seven, and seven bytes.
— KOZOS, Assembly Tanka (translated excerpt)
A little help from the EVM
An EVM message call enters a contract’s bytecode with the program counter at 0, not directly at a Solidity function’s body. Ethereum.org’s execution overview lists that initial value and describes the program counter as the “address of the next instruction to execute”; the stack starts empty. Solidity normally dispatches to the selected function from this entry point, but our poems have no dispatcher. Their phrase boundaries don’t affect execution; jumps and halting instructions do.
Each EVM opcode occupies one byte, though PUSH1 through PUSH32 carry additional immediate bytes. Instructions such as ADD take their operands from the stack and push the result, so they do not need to encode register names. In x86, an instruction such as ADD also encodes which registers or memory operands to use:
| Machine | Assembly | Encoded bytes | Effect |
|---|---|---|---|
| x86 (Intel syntax) | add eax, ebx | 01 d8 | Add EBX to EAX, storing the result in EAX. |
| EVM | ADD | 01 | Pop two 256-bit stack words and push their sum. |
Both examples assume the inputs are already in place. Loading constants costs bytes too: PUSH1 0x0a PUSH1 0x03 ADD encodes as 60 0a 60 03 01, leaving 13 on the stack for a total of five bytes.
This isn’t a fair fight between architectures: the x86 example uses 32-bit registers, while the EVM works with 256-bit words. Nor does shorter code necessarily use less gas. The useful part for this exercise is that the EVM already has single-byte opcodes for storage, logs, and calls. We don’t have to build those ourselves.
Let’s start with a pooled tip vault. Tips go in; the beneficiary takes the whole balance. There are no individual depositor balances or refunds.
Poem one: the tip vault
The hex below is the deployed code, with one phrase per row. The instruction column shows what those bytes do. Our line breaks fall at byte offsets 5, 12, 17, 24, and 31.
| Bytes | Hex | EVM instructions |
|---|---|---|
| 5 | 34 15 60 0f 57 | CALLVALUE ISZERO PUSH1 0x0f JUMPI |
| 7 | 34 5f 52 42 33 60 20 | CALLVALUE PUSH0 MSTORE TIMESTAMP CALLER PUSH1 0x20 |
| 5 | 5f a2 00 5b 5f | PUSH0 LOG2 STOP JUMPDEST PUSH0 |
| 7 | 5f 5f 5f 47 5f 54 5a | PUSH0 PUSH0 PUSH0 SELFBALANCE PUSH0 SLOAD GAS |
| 7 | f1 5f 52 60 20 5f f3 | CALL PUSH0 MSTORE PUSH1 0x20 PUSH0 RETURN |
The complete runtime is:
0x3415600f57345f52423360205fa2005b5f5f5f5f475f545af15f5260205ff3
All three PUSH1 instructions fit inside their phrases, immediate bytes included. There’s no padding: every opcode runs on either the deposit path or the sweep path, though neither path runs all 31 bytes in one call. For this two-action poem, that’s our interpretation of the form. The one-byte zero, PUSH0, comes from EIP-3855; you’ll need an EVM revision that supports it.
What the bytes do
- Deposit: A call with positive
msg.valuefollows the second and third phrases. It writes the value as a 32-byte word at memory offset zero, then usesLOG2to emit an anonymous log. Topic 0 is the caller, topic 1 is the block timestamp, and the 32-byte data field is the deposited amount. The ETH remains in the contract. - Sweep: A zero-value call jumps to byte
0x0f. Four zeros prepare the empty input and output buffers forCALL;SELFBALANCEsupplies the amount,SLOAD(0)supplies the beneficiary, andGASsupplies the gas. The result is returned as a 32-byte Boolean. Anyone may trigger this call, but the ETH always goes to the beneficiary. If that beneficiary rejects the transfer,CALLreturns zero and the balance stays in the vault.
The log has no Solidity event-signature topic. We can still describe it in an ABI as an anonymous Deposited(address indexed caller, uint256 indexed timestamp, uint256 amount) event. The Ethereum opcode reference describes the stack order of LOG2 and CALL.
A constructor sets up storage slot zero with the deployer’s address, then returns these 31 bytes as the contract’s code. We’ll write that constructor below. After deployment, callers go straight to the vault: send ETH to tip, or call with zero value to sweep.
Poem two: the eventful counter
Next, a counter that announces each update. Its entire deployed code is:
| Bytes | Hex | EVM instructions |
|---|---|---|
| 5 | 5f 60 20 36 14 | PUSH0 PUSH1 0x20 CALLDATASIZE EQ |
| 7 | 60 0a 57 80 fd 5b 5f | PUSH1 0x0a JUMPI DUP1 REVERT JUMPDEST PUSH0 |
| 5 | 54 5f 35 01 80 | SLOAD PUSH0 CALLDATALOAD ADD DUP1 |
| 7 | 5f 55 80 5f 52 33 42 | PUSH0 SSTORE DUP1 PUSH0 MSTORE CALLER TIMESTAMP |
| 7 | 60 20 5f a2 59 5f f3 | PUSH1 0x20 PUSH0 LOG2 MSIZE PUSH0 RETURN |
Send exactly 32 bytes of calldata: one unsigned word telling the counter how much to add. Any other length reverts. The counter adds it to storage slot zero, logs the new total with timestamp and caller topics, and returns that total as a 32-byte word.
MSIZE saves us a byte here. After MSTORE expands memory to 32 bytes, MSIZE gives us the return length without a two-byte PUSH1 0x20. The constructor doesn’t need to set up storage; slot zero starts at zero. Every opcode gets used on either the valid-input or revert path.
The arithmetic wraps modulo 2²⁵⁶, and there’s no withdrawal code. It will accept ETH, but won’t give it back, so call this one with zero value.
Poem three: the delegatecall proxy
The proxy needs an implementation address in storage slot zero. Its constructor stores that address and returns this runtime:
| Bytes | Hex | EVM instructions |
|---|---|---|
| 5 | 33 34 5f 5f a2 | CALLER CALLVALUE PUSH0 PUSH0 LOG2 |
| 7 | 36 3d 3d 37 3d 3d 3d | CALLDATASIZE RETURNDATASIZE RETURNDATASIZE CALLDATACOPY RETURNDATASIZE RETURNDATASIZE RETURNDATASIZE |
| 5 | 36 3d 5f 54 5a | CALLDATASIZE RETURNDATASIZE PUSH0 SLOAD GAS |
| 7 | f4 3d 82 80 3e 90 3d | DELEGATECALL RETURNDATASIZE DUP3 DUP1 RETURNDATACOPY SWAP1 RETURNDATASIZE |
| 7 | 91 60 1d 57 fd 5b f3 | SWAP2 PUSH1 0x1d JUMPI REVERT JUMPDEST RETURN |
It emits an anonymous call log with value and caller topics, forwards all calldata to the implementation, then copies and returns its output or bubbles its revert data. A reverted delegated call also rolls back the initial log. EIP-7 specifies that DELEGATECALL preserves the original sender and value. The small implementation in the Wake example below writes slot one, emits an event, echoes variable-length data, and can revert. Its state and event appear at the proxy address.
If this looks like ERC-1167, there’s a catch. ERC-1167 embeds the destination address in the runtime. This poem loads it from storage with SLOAD(0) instead, leaving room for the log. It’s not an ERC-1167 clone.
The implementation must leave slot zero alone: delegated code can overwrite it and change where the proxy sends future calls. There’s no upgrade control or storage-collision protection hiding in these 31 bytes. Use a trusted implementation that reserves that slot.
The log costs us something else. LOG2 runs on every call, and EIP-214 forbids logs during static execution. That means STATICCALL fails even for a view function. The Wake test checks this too. A proxy with a little too much to say.
Deploying the poems
The byte tables are the poems; Solidity just gets them onto the chain. Save these three wrappers as src/Tanka.sol. Their constructors run once, and return(0, 31) makes those 31 bytes the deployed code. The trailing 00 in each mstore literal pads the memory word to 32 bytes; it isn’t returned.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract TankaVault {
event Deposited(address indexed caller, uint256 indexed timestamp, uint256 amount) anonymous;
constructor() payable {
assembly {
sstore(0, caller())
mstore(0, 0x3415600f57345f52423360205fa2005b5f5f5f5f475f545af15f5260205ff300)
return(0, 31)
}
}
}
contract TankaCounter {
event Counted(uint256 indexed timestamp, address indexed caller, uint256 total) anonymous;
constructor() {
assembly {
mstore(0, 0x5f60203614600a5780fd5b5f545f3501805f55805f52334260205fa2595ff300)
return(0, 31)
}
}
}
contract TankaProxy {
event Forwarded(uint256 indexed value, address indexed caller) anonymous;
constructor(address implementation) {
require(implementation.code.length != 0, "implementation has no code");
assembly {
sstore(0, implementation)
mstore(0, 0x33345f5fa2363d3d373d3d3d363d5f545af43d82803e903d91601d57fd5bf300)
return(0, 31)
}
}
}
These event declarations give tools an ABI for decoding logs. They don’t add a Solidity dispatcher to the runtime: the constructors return our bytes instead of Solidity’s generated function code.
Compiling the poems from Yul
So far we’ve picked every byte ourselves. Can solc do the counting for us? Yul, the intermediate language that solc also compiles Solidity through, has no JUMP or JUMPDEST. We write if, function calls, and expressions; solc chooses the jumps and byte layout.
Here are the same three runtimes in Yul. Save each object in its own file: TankaVault.yul, TankaCounter.yul, and TankaProxy.yul. These objects contain runtime code only, not constructors. The vault and proxy still need slot zero initialized during deployment.
object "TankaVault" {
code {
if iszero(callvalue()) {
mstore(0, call(gas(), sload(0), selfbalance(), 0, 0, 0, 0))
return(0, 0x20)
}
mstore(0, callvalue())
log2(0, 0x20, caller(), timestamp())
}
}
object "TankaCounter" {
code {
if sub(calldatasize(), 0x20) { revert(0, 0) }
let total := add(sload(0), calldataload(0))
sstore(0, total)
mstore(0, total)
log2(0, 0x20, timestamp(), caller())
return(0, msize())
}
}
object "TankaProxy" {
code {
log2(0, 0, callvalue(), caller())
calldatacopy(0, 0, calldatasize())
let ok := delegatecall(gas(), sload(0), 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
if ok { return(0, returndatasize()) }
revert(0, returndatasize())
}
}
With solc 0.8.34, compile each file with the optimizer on and Cancun as the target:
solc --strict-assembly --optimize --evm-version cancun --bin TankaVault.yul
solc --strict-assembly --optimize --evm-version cancun --bin TankaCounter.yul
solc --strict-assembly --optimize --evm-version cancun --bin TankaProxy.yul
The resulting runtime bytes are:
TankaVault 3415600f57345f52423360205fa2005b5f8080804781545af15f5260205ff3
TankaCounter 36601f1901601b575f355f5401805f555f52334260205fa2595ff35b5f80fd
TankaProxy 33345f80a2365f80375f80368180545af43d5f803e601b573d5ffd5b3d5ff3
All three come out at 31 bytes, with no instruction crossing the breaks at 5, 12, 17, and 24. The compiler has its own way of getting there:
- Vault. Only offsets
0x11–0x15differ. For repeated zeros solc writesPUSH0 DUP1 DUP1 DUP1and laterDUP2, where the poem writesPUSH0five times. The size and the behavior are the same. - Counter. The obvious check,
if iszero(eq(calldatasize(), 0x20)), also compiles to 31 bytes, but its firstPUSH1crosses the break at byte 5. Writingif sub(calldatasize(), 0x20)gets the optimizer to emitCALLDATASIZE PUSH1 0x1f NOT ADD:n + ~0x1fequalsn − 32, which is nonzero unless the length is exactly 32. It also puts the revert branch at the end. This layout fits, but finding it took over a hundred phrasings of the same check. Apparently the compiler is a picky poet. - Proxy. solc uses
PUSH0andDUPwhere the poem usesRETURNDATASIZEas a zero, and it places the return branch after the revert branch instead of before it.
Don’t expect another solc version or optimizer setting to keep the same line breaks. The compiler owns the layout and can move an instruction across a boundary. That’s why the hand-written bytes are the reference poems. Yul’s verbatim builtin could embed them exactly, but that would skip the compiler puzzle.
To deploy the compiler’s versions, replace each Solidity wrapper’s mstore literal with its Yul output plus a trailing 00. Keep return(0, 31) and the storage setup unchanged. The Wake example below works with either set of bytes.
Running the poems with Wake
We can try all three in a fresh directory. This example uses Wake 5.0.0rc2 and its local revm backend; a Python virtual environment keeps the installation separate:
python3 -m venv .venv
source .venv/bin/activate
pip install eth-wake==5.0.0rc2
mkdir -p src tests
Save the deployment wrappers above as src/Tanka.sol. For the proxy, save this small implementation as src/TankaProxyTarget.sol. It keeps its counter in slot one so it doesn’t overwrite the proxy’s destination.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract TankaProxyTarget {
event Recorded(address indexed caller, uint256 value, address context);
function record(uint256 n) external payable returns (uint256) {
assembly { sstore(1, n) }
emit Recorded(msg.sender, msg.value, address(this));
return n + 1;
}
function read() external view returns (uint256 n) {
assembly { n := sload(1) }
}
function readThrough(address proxy) external view returns (uint256) {
return TankaProxyTarget(proxy).read();
}
function echo(bytes calldata input) external pure returns (bytes memory) {
return input;
}
function fail() external pure {
revert("target reverted");
}
}
Save this as wake.toml. The compiler target supports PUSH0, and revm runs the tests locally without a separate node.
[compiler.solc]
target_version = "0.8.24"
evm_version = "cancun"
[testing]
cmd = "revm"
Now save tests/test_tanka.py:
from wake.testing import chain, must_revert
from pytypes.src.Tanka import TankaVault, TankaCounter, TankaProxy
from pytypes.src.TankaProxyTarget import TankaProxyTarget
@chain.connect()
def test_tanka():
owner, alice, bob = chain.accounts[:3]
alice.balance = 10**18
vault = TankaVault.deploy(from_=owner)
counter = TankaCounter.deploy(from_=owner)
target = TankaProxyTarget.deploy(from_=owner)
proxy = TankaProxy.deploy(target.address, from_=owner)
# Count deployed bytes and skip PUSH immediates to check the line breaks.
for contract in (vault, counter, proxy):
code = contract.code
assert len(code) == 31
starts, pc = {31}, 0
while pc < len(code):
starts.add(pc)
opcode = code[pc]
pc += 1 + (opcode - 0x5f if 0x60 <= opcode <= 0x7f else 0)
assert pc == 31 and {5, 12, 17, 24, 31} <= starts
# Tip, then let someone other than the beneficiary trigger the sweep.
deposit = vault.transact(value=123, from_=alice)
log = deposit.raw_events[0]
assert int.from_bytes(log.topics[0], "big") == int(alice.address)
assert int.from_bytes(log.topics[1], "big") == deposit.block.timestamp
assert int.from_bytes(log.data, "big") == 123
assert vault.balance == 123
assert vault.call(from_=bob) == (1).to_bytes(32, "big")
before = owner.balance
vault.transact(from_=bob)
assert vault.balance == 0 and owner.balance == before + 123
# Add twice, and reject calldata that isn't one word.
counter.transact(data=(5).to_bytes(32, "big"), from_=alice)
update = counter.transact(data=(7).to_bytes(32, "big"), from_=alice)
assert int.from_bytes(update.raw_events[0].data, "big") == 12
assert counter.call(data=bytes(32)) == (12).to_bytes(32, "big")
with must_revert():
counter.transact(data=bytes(31), from_=alice)
# Call the implementation's ABI at the proxy address.
proxied = TankaProxyTarget(proxy.address)
recorded = proxied.record(41, value=123, from_=alice)
assert proxied.read() == 41 and target.read() == 0
assert proxy.balance == 123 and target.balance == 0
event = recorded.raw_events[1]
assert event.origin.address == proxy.address
assert int.from_bytes(event.topics[1], "big") == int(alice.address)
assert int.from_bytes(event.data[:32], "big") == 123
assert int.from_bytes(event.data[32:], "big") == int(proxy.address)
assert proxied.echo(b"assembly tanka") == b"assembly tanka"
with must_revert("target reverted"):
proxied.fail(request_type="tx", from_=alice)
with must_revert():
target.readThrough(proxy.address)
Generate the Python bindings and run the test from that directory:
wake up pytypes
wake test tests/test_tanka.py -v
The test checks the three byte layouts, a tip and sweep, counter updates and invalid input, and calls through the proxy. readThrough is the static-call trap: the Solidity implementation uses STATICCALL to read from the proxy, whose opening LOG2 makes the call revert. A direct Python .read() uses eth_call, which isn’t the same thing.
Passing tests don’t make these production contracts. These runs use the local Wake revm backend, not every EVM fork. The vault’s beneficiary is fixed at deployment, with no per-depositor accounting or ownership recovery. If that address can’t receive ETH, tips stay stuck until it can accept a later sweep. And the proxy shares slot zero with its implementation. Keep real funds out of the poetry exercise.
More experiments
A few ideas for the next round. None of these has a 31-byte implementation yet:
| Idea | EVM primitives | Question to settle |
|---|---|---|
| Hash receipt | CALLDATACOPY, KECCAK256, LOG1 | Can it validate an input shape and return the hash in 31 bytes? |
| Per-caller memo | CALLER, CALLDATALOAD, SSTORE, LOG | Can it preserve an old value while logging the new one? |
| Time-locked tip vault | TIMESTAMP, LT, JUMPI, CALL | Which event or return data must be traded for the deadline check? |
| Individual escrow | Caller-keyed SLOAD/SSTORE and CALL | Can it clear credit before transfer and handle failed calls within 31 bytes? |
Pick one and try fitting it into five lines. Getting down to 31 bytes is only half the game; one PUSH1 across a line break means rearranging the code again. The constructors and Wake example above are enough to try another poem without cloning a repository.
Vocabulary
Terms used in this article.
-
EVM
The Ethereum Virtual Machine is the deterministic execution environment that applies smart contract bytecode and state changes consistently across Ethereum nodes.
-
Solidity
Solidity is a statically typed, object-oriented language for writing smart contracts that compile to Ethereum Virtual Machine bytecode.
-
Wake
A Python-based development and testing framework for Solidity with fuzzing, debugging, static analysis, and editor tooling.