This repo is meant to be a proof of concept, investigating whether it's feasible to use exhaustive searching for optimal stack scheduling for EVM programs.
Stack scheduling is the process of turning a sequence of variable assignments into stack manipulating instructions. Unlike traditional, register-based ISAs, the EVMs stack based nature doesn't map very well to assignments.
Installation
- Clone repo (
git clone https://github.com/Philogy/balls.git
) cd balls
cargo install --path .
Run
balls -h
ERC20 transfer(address to, uint256 amount)
method
Code:
// EXTERNAL
extern _REQUIRE_NOT() stack(1, 0) reads(CONTROL_FLOW)
// Define actual code
fn TRANSFER<z0>(error) -> () {
// Define some variables
to = calldataload(0x04)
amount = calldataload(0x24)
// Get from balance.
from_bal = sload(caller())
// Check from balance and error.
insufficient_bal = gt(amount, from_bal)
error' = or(insufficient_bal, error)
_REQUIRE_NOT(error')
// Update from balance.
new_from_bal = sub(from_bal, amount)
sstore(caller(), new_from_bal)
// Update to balance.
to_bal = sload(to)
new_to_bal = add(to_bal, amount)
sstore(to, new_to_bal)
// Return success (1).
mstore(z0, 1)
return(z0, msize())
}
Compile with balls ./examples/transfer_ma.balls -d
(-d
tells BALLS to use the Dijkstra which is
guaranteed to result in the optimal scheduling given the constraints).
Result:
#define macro TRANSFER(z0) = takes(1) returns(0) {
// takes: [error]
caller // [error, caller()]
sload // [error, from_bal]
0x24 // [error, from_bal, 0x24]
calldataload // [error, from_bal, amount]
dup1 // [error, from_bal, amount, amount]
dup3 // [error, from_bal, amount, amount, from_bal]
sub // [error, from_bal, amount, new_from_bal]
caller // [error, from_bal, amount, new_from_bal, caller()]
sstore // [error, from_bal, amount]
0x4 // [error, from_bal, amount, 0x4]
calldataload // [error, from_bal, amount, to]
dup1 // [error, from_bal, amount, to, to]
sload // [error, from_bal, amount, to, to_bal]
dup3 // [error, from_bal, amount, to, to_bal, amount]
add // [error, from_bal, amount, to, new_to_bal]
swap1 // [error, from_bal, amount, new_to_bal, to]
sstore // [error, from_bal, amount]
gt // [error, insufficient_bal]
or // [error']
_REQUIRE_NOT() // []
0x1 // [0x1]
<z0> // [0x1, z0]
mstore // []
msize // [msize()]
<z0> // [msize(), z0]
return // []
// returns: []
}
What this code does on a high-level:
- Expects an external huff macro
_REQUIRE_NOT
that takes 0 inlined arguments, consumes 1 stack value and pushes 0 values, that's dependent on theCONTROL_FLOW
dependency meaning that it shouldn't be rearranged after e.g. astop
,revert
orreturn
- Defines a Huff macro
TRANSFER
that takes 1 inlined argumentz0
and pops 1 stack value referencing it as"error"
Default
By default BALLS will run using the "Guessoor" scheduling algorithm, it runs quite quickly even on
unconstrained schedules but is not guaranteed to result in the optimal scheduling. To tune the
likelihood the result approaches the optimal scheduling you can play around with the --guess
parameter. Lower will make the scheduler run slower but be more likely to output an optimal result,
higher values will make the scheduling run faster with worse results.
Running the Dijkstra Scheduler
The --dijkstra
flag will use the Dijstkra scheduler. Performing Dijkstra's algorithm it is
guaranteed to output the optimal scheduling given the constraints, however this mode can run very
slowly up to not completing at all on larger examples (such as
examples/permit_ma.balls
). Non-termination is especially likely when
using --dijkstra
when the search is otherwise unconstrained.
Constraining the search
To speed up any of the above searches you may constrain the max stack depth that the program is
allowed to have at any point. The default value is the EVM's max stack depth of 1024. Too low of
a value may result in a stack-too-deep error. Constraining can allow Dijkstra to terminate in
reasonable times for larger examples such as permit_ma.balls
.
Note that if the value is too low the scheduler may output a scheduling but it may not be the most optimal possible schedule.
BALLS is able to search for and create optimal stack schedules by going through and reordering
operations. To ensure that the code remains correct the system tracks "read" and "write"
dependencies. Some dependencies are quite straight forward to understand like MEMORY
and
STORAGE
. Having a "read" dependency means that you depend on it but that it does not matter in
what order it gets accessed so long as it does not get changed, "write" means that it affects the
dependency and that it's order has to remain fixed relative to other writes and to its preceding
e.g.
Original definition in code:
1. read A
2. write A
3. read A
4. read A
5. read A
6. write A
7. write A
Valid reordering:
1. read A
2. write A
+ 5. read A
+ 4. read A
+ 3. read A
6. write A
7. write A
Invalid reordering:
1. read A
- 3. read A
- 2. write A
4. read A
5. read A
- 7. write A
- 6. write A
The list of default dependencies, opcodes and their read/writes can be found under
src/transformer/std_evm.rs
.