Cartouche.Assembly (Cartouche v0.3.0)

Copy Markdown View Source

A small EVM assembler and disassembler with a lisp-like front-end.

You describe a script as a tree of operations — nested opcode tuples, integers, and binaries — and the module lowers it to EVM bytecode:

compile/1   tree of operations  ->  flat list of opcodes
assemble/1  flat list of opcodes  ->  bytecode binary
build/1     tree of operations  ->  bytecode binary   (compile |> assemble)
disassemble/1                       bytecode binary  ->  list of opcodes

compile/1 handles operand ordering (operands are emitted so they land on the stack in source order), {:if, …} branching with unique jumpi/ jump_dest labels, and {:push, …} synthesis for binaries/integers.

This is not a toy: Cartouche.VM assembles its EVM scaffolding through assemble/1, and the assembler builds the small scripts cartouche injects during simulation (e.g. the eth_estimateGas guard that reverts when tx.origin is zero). It is also genuinely handy for hand-writing and inspecting short scripts in tests.

Usage

Build EVM bytecode from a tree of operations:

Cartouche.Assembly.build([
  {:log1, 0, 0, 55}
])

produces 0x603760006000a1, which decompiles to log(memory[0x00:0x00], [0x37]), i.e. the assembly:

0000    60  PUSH1 0x37
0002    60  PUSH1 0x00
0004    60  PUSH1 0x00
0006    A1  LOG1

Scripts can branch — e.g. revert if tx.origin is zero (as during an eth_estimateGas):

Cartouche.Assembly.build([
  {:mstore, 0, 0x01020304},
  {:if, :origin, {:revert, 28, 4}, {:return, 0, 0}}
])

Summary

Functions

Assmbles opcodes into raw evm bytecode

Compiles and assembles assembly operations.

Compiles a lisp-like assembly tree into a flat list of low-level opcodes (which assemble/1 then turns into bytecode).

Returns a simple EVM program that returns the input code as the output of an Ethereum "initCode" constructor.

Disassembles opcodes from raw evm bytecode to opcodes.

Returns a textual representation of the given operation.

Types

opcode()

@type opcode() ::
  atom()
  | {:push, non_neg_integer(), binary()}
  | {:dup, non_neg_integer()}
  | {:swap, non_neg_integer()}
  | {:invalid, binary()}
  | {:jump_ptr, integer()}
  | {:jump_dest, integer()}

Functions

assemble(opcodes)

@spec assemble([term()]) :: binary()

Assmbles opcodes into raw evm bytecode

Examples

iex> [{:push, 0, ""}, {:push, 4, <<0x11, 0x22, 0x33, 0x44>>}, :mstore, {:push, 1, <<4>>}, {:push, 1, <<28>>}, :revert]
...> |> Cartouche.Assembly.assemble()
<<95, 99, 17, 34, 51, 68, 82, 96, 4, 96, 28, 253>>

iex> [
...>   {:push, 2, <<0x01, 0x02>>},
...>   {:push, 1, <<0>>},
...>   :mstore,
...>   :callvalue,
...>   {:push, 1, <<0>>},
...>   :sub,
...>   {:jump_ptr, 0},
...>   :jumpi,
...>   {:push, 1, <<2>>},
...>   {:push, 1, <<30>>},
...>   :revert,
...>   {:jump_dest, 0},
...>   {:push, 1, <<2>>},
...>   {:push, 1, <<31>>},
...>   :revert
...> ]
...> |> Cartouche.Assembly.assemble()
...> |> Cartouche.Hex.to_hex()
"0x6101026000523460000362000014576002601efd5b6002601ffd"

iex> [
...>   {:dup, 2},
...>   {:swap, 3},
...>   {:invalid, ~h[0x010203]}
...> ]
...> |> Cartouche.Assembly.assemble()
...> |> Cartouche.Hex.to_hex()
"0x8192fe010203"

build(operations)

@spec build([term()]) :: binary()

Compiles and assembles assembly operations.

Examples

iex> use Cartouche.Hex
...> [
...>   {:mstore, 0, ~h[0x11223344]},
...>   {:revert, 28, 4}
...> ]
...> |> Cartouche.Assembly.build()
...> |> to_hex()
"0x63112233446000526004601cfd"

compile(op)

@spec compile(term()) :: [opcode()] | opcode()

Compiles a lisp-like assembly tree into a flat list of low-level opcodes (which assemble/1 then turns into bytecode).

Accepts any of:

  • a list of operations — each is compiled and the results concatenated;
  • an operand-bearing opcode tuple {opcode, arg1, …, argN} — every argument is compiled and emitted in reverse order (so operands land on the stack in source order), followed by the opcode. N must equal the opcode's declared operand count in @opcodes;
  • an {:if, cond, non_zero, zero} tuple — compiled to a jumpi/jump_dest branch with a unique label;
  • a binary of ≤ 32 bytes — emitted as {:push, byte_size, bytes};
  • a non-negative integer — encoded big-endian via :binary.encode_unsigned/1, then pushed;
  • a no-operand opcode atom (e.g. :add, :stop) or :self_code_sz — returned unchanged (the one scalar-return case).

Raises InvalidAssembly on an unknown or wrong-arity opcode tuple, a binary larger than 32 bytes, or any other unrecognized term.

Examples

iex> use Cartouche.Hex
...> [
...>   {:mstore, 0, ~h[0x11223344]},
...>   {:revert, 4, 28}
...> ]
...> |> Cartouche.Assembly.compile()
[{:push, 4, ~h[0x11223344]}, {:push, 1, <<0>>}, :mstore, {:push, 1, <<28>>}, {:push, 1, <<0x04>>}, :revert]

constructor(code)

@spec constructor(binary()) :: binary()

Returns a simple EVM program that returns the input code as the output of an Ethereum "initCode" constructor.

Examples

iex> use Cartouche.Hex
...> Cartouche.Assembly.constructor(~h[0xaabbcc])
...> |> to_hex()
"0x60036200000e60003960036000f3aabbcc"

disassemble(bytes)

@spec disassemble(binary()) :: [term()]

Disassembles opcodes from raw evm bytecode to opcodes.

Examples

iex> Cartouche.Assembly.disassemble(~h[0x6101026000523460000362000014576002601efd5b6002601ffd])
[
  {:push, 2, <<0x01, 0x02>>},
  {:push, 1, <<0>>},
  :mstore,
  :callvalue,
  {:push, 1, <<0>>},
  :sub,
  {:push, 3, <<0, 0, 20>>},
  :jumpi,
  {:push, 1, <<2>>},
  {:push, 1, <<30>>},
  :revert,
  :jumpdest,
  {:push, 1, <<2>>},
  {:push, 1, <<31>>},
  :revert
]

iex> Cartouche.Assembly.disassemble(~h[0x8192fe010203])
[
  {:dup, 2},
  {:swap, 3},
  {:invalid, ~h[0x010203]}
]

show_opcode(op)

@spec show_opcode(term()) :: String.t()

Returns a textual representation of the given operation.

Examples

iex> Cartouche.Assembly.show_opcode(:add)
"ADD"

iex> Cartouche.Assembly.show_opcode({:push, 5, <<1,2,3,4,5>>})
"PUSH5 0x0102030405"