Skip to content

Codegen for fully working ERC20 demo along with everything that was blocking it#1178

Merged
sbillig merged 11 commits intoargotorg:masterfrom
cburgdorf:storage_map
Dec 12, 2025
Merged

Codegen for fully working ERC20 demo along with everything that was blocking it#1178
sbillig merged 11 commits intoargotorg:masterfrom
cburgdorf:storage_map

Conversation

@cburgdorf
Copy link
Collaborator

@cburgdorf cburgdorf commented Dec 10, 2025

This PR introduces several foundational features that collectively enable compiling a complete, working ERC20 token contract to EVM bytecode.

Highlights

A fully working ERC20 token is now compilable end-to-end. The demo contract includes:

  • Balance tracking and transfers
  • Allowance management (approve/transferFrom)
    • minor annoyance is that we don't yet support tuples so allowance key can't be (address, address)
  • Minting with owner authorization
  • Standard ERC20 view functions (name, symbol, decimals, balanceOf, totalSupply)

New Features

StorageMap

A new StorageMap<K, V> type in the core library provides key-value storage backed by Ethereum's persistent storage.
@sbillig This isn't yet the nice Map abstraction that we eventually want but I didn't want to increase the scope of this PR any further.

New Intrinsics

  • keccak: Compute Keccak-256 hashes
  • caller(): Access msg.sender equivalent for authorization checks
  • revert: Abort execution and revert state changes
  • addr_of: Extract the underlying address (memory offset or storage slot) from a pointer value

Compiler Improvements

Pointer Type Monomorphization

Storage vs. memory pointer decisions are now resolved during monomorphization rather than at codegen time. This produces cleaner, more predictable code paths and enables proper handling of storage-backed data structures like StorageMap.

Nested Struct Support

The MIR layer now correctly handles nested structs through FieldPtr operations that compute byte offsets for accessing inner fields. This allows expressions like outer.inner.value to compile correctly by performing pointer arithmetic without loading intermediate struct values. This is probably not what we want long term but since this is touching the topic of references I wanted to keep it simple and leave the rest to @sbillig

Bug Fixes

  • Fixed a bug in YUL emission for instruction evaluation that could produce incorrect code

Testing

The ERC20 contract compiles to valid bytecode and passes integration tests that exercise the full contract lifecycle including deployment, minting, transfers, and allowance management.

TLDR

struct Erc20 {
balances: StorageMap<u256, u256>,
allowances: StorageMap<u256, u256>,
supply: u256,
owner: u256,
}
impl Erc20 {
fn balance_of(self, addr: u256) -> u256 {
self.balances.get(key: addr)
}
fn allowance(self, owner: u256, spender: u256) -> u256 {
self.allowances.get(key: allowance_key(owner, spender))
}
fn approve(mut self, owner: u256, spender: u256, value: u256) {
self.allowances.set(key: allowance_key(owner, spender), value: value)
}
fn total_supply(self) -> u256 {
self.supply
}
fn set_owner_once(mut self, owner: u256) {
if self.owner != 0 {
revert(0, 0)
}
self.owner = owner
}
fn transfer(mut self, from: u256, to: u256, amount: u256) {
let from_bal = self.balance_of(addr: from)
if from_bal < amount {
revert(0, 0)
}
let to_bal = self.balance_of(addr: to)
self.balances.set(key: from, value: from_bal - amount)
self.balances.set(key: to, value: to_bal + amount)
}
fn transfer_from(mut self, owner: u256, to: u256, amount: u256) {
let spender = caller()
let allowed = self.allowance(owner: owner, spender: spender)
if allowed < amount {
revert(0, 0)
}
self.transfer(from: owner, to: to, amount: amount)
self.allowances.set(key: allowance_key(owner, spender), value: allowed - amount)
}
fn mint(mut self, to: u256, amount: u256) {
if caller() != self.owner {
revert(0, 0)
}
let bal = self.balance_of(addr: to)
self.balances.set(key: to, value: bal + amount)
self.supply = self.supply + amount
}
}
// Entry points ----------------------------------------------------------
#[contract_init(Erc20Contract)]
fn init()
uses (mut erc20: Erc20)
{
erc20.set_owner_once(owner: caller())
let len = code_region_len(runtime)
let offset = code_region_offset(runtime)
codecopy(dest: 0, offset, len)
return_data(0, len)
}
#[contract_runtime(Erc20Contract)]
fn runtime()
uses (mut erc20: Erc20)
{
let selector = calldataload(0) >> 224
match selector {
0x70a08231 => { // balanceOf(address)
let owner = calldataload(4)
abi_encode_u256(value: erc20.balance_of(addr: owner))
}
0xdd62ed3e => { // allowance(address,address)
let owner = calldataload(4)
let spender = calldataload(36)
abi_encode_u256(value: erc20.allowance(owner: owner, spender: spender))
}
0x06fdde03 => { // name()
abi_encode_string(
word: 0x4665546f6b656e00000000000000000000000000000000000000000000000000,
len: 7,
)
}
0x95d89b41 => { // symbol()
abi_encode_string(
word: 0x4645540000000000000000000000000000000000000000000000000000000000,
len: 3,
)
}
0x313ce567 => { // decimals()
abi_encode_u256(value: 18)
}
0xa9059cbb => { // transfer(address,uint256)
let to = calldataload(4)
let amount = calldataload(36)
erc20.transfer(from: caller(), to: to, amount: amount)
abi_encode_u256(value: 1)
}
0x095ea7b3 => { // approve(address,uint256)
let spender = calldataload(4)
let amount = calldataload(36)
erc20.approve(owner: caller(), spender: spender, value: amount)
abi_encode_u256(value: 1)
}
0x23b872dd => { // transferFrom(address,address,uint256)
let from = calldataload(4)
let to = calldataload(36)
let amount = calldataload(68)
erc20.transfer_from(owner: from, to: to, amount: amount)
abi_encode_u256(value: 1)
}
0x40c10f19 => { // mint(address,uint256)
let to = calldataload(4)
let amount = calldataload(36)
erc20.mint(to: to, amount: amount)
abi_encode_u256(value: 1)
}
_ => revert(0, 0)
}
}

@cburgdorf cburgdorf changed the title WIP Codegen for fully working ERC20 demo along with everything that was blocking it Dec 11, 2025
@cburgdorf cburgdorf requested review from Copilot and sbillig December 11, 2025 17:45
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces comprehensive support for compiling a fully working ERC20 token contract to EVM bytecode. The implementation includes a new StorageMap type for key-value storage, several new intrinsics (keccak, caller, revert, addr_of), and significant compiler improvements to handle pointer type monomorphization and nested struct support.

Key Changes:

  • Added StorageMap<K, V> for Ethereum persistent storage with standard Solidity mapping layout
  • Implemented new intrinsics: keccak256, caller(), revert(), and addr_of()
  • Refactored pointer handling to use MemPtr/StorPtr marker types instead of runtime AddressSpace enum
  • Added FieldPtr for nested struct field access via pointer arithmetic
  • Implemented receiver address space tracking during monomorphization for proper method specialization

Reviewed changes

Copilot reviewed 51 out of 51 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
library/core/src/storage_map.fe New StorageMap type for persistent key-value storage
library/core/src/ptr.fe Refactored to Ptr trait with MemPtr/StorPtr marker types
library/core/src/option.fe Fixed enum variant qualification (Self::None)
library/core/src/intrinsic.fe Added keccak, caller, revert, addr_of intrinsics
library/core/src/enum_repr.fe Updated to use Ptr trait with type parameters
crates/mir/src/monomorphize.rs Added receiver_space tracking for method monomorphization
crates/mir/src/lower/*.rs Implemented FieldPtr for nested structs and aggregate copying
crates/mir/src/ir.rs Added AddressSpaceKind, FieldPtrOrigin, Revert terminator
crates/mir/src/dedup.rs Fixed helper deduplication to only dedupe single instances
crates/codegen/src/yul/emitter/*.rs Added codegen for new intrinsics and FieldPtr
crates/contract-harness/src/lib.rs Added deploy() method for init bytecode execution
Test fixtures Comprehensive tests for ERC20, nested structs, storage maps

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

}

fn storage_slot(self, key: K) -> u256 {
// keccak256(key ++ slot) - standard Solidity mapping layout
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment describes the Solidity mapping layout as keccak256(key ++ slot), but the implementation actually does keccak256(key ++ addr_of(self)) where addr_of(self) is written at position key_len instead of being concatenated. This appears correct for the Solidity layout, but the comment could be clearer. The actual layout is: the key is written at position 0, then the slot/address is written immediately after at position key_len, and then keccak256 is computed over the full key_len + 32 bytes.

Copilot uses AI. Check for mistakes.
Comment on lines +139 to +144
let mut iterations: usize = 0;
while let Some(func_idx) = self.worklist.pop_front() {
iterations += 1;
if iterations > 100_000 {
panic!("monomorphization worklist exceeded 100k iterations; possible cycle");
}
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The infinite loop protection using a hardcoded iteration limit of 100,000 is a reasonable safeguard, but the panic message could be more helpful by including diagnostic information such as the number of instances created or the last function being processed. This would help developers debug what caused the cycle.

Copilot uses AI. Check for mistakes.
self.allowances.get(key: allowance_key(owner, spender))
}

fn approve(mut self, owner: u256, spender: u256, value: u256) {
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approve implementation updates self.allowances directly to value without considering the existing allowance, which exposes the standard ERC20 allowance front‑running issue. An attacker who can front‑run a transaction that intends to change an allowance (e.g., from 10 to 20) can first spend the old allowance via transfer_from, after which the victim’s approve still sets the new higher value, effectively allowing the attacker to spend more than intended. To mitigate this, either require that existing allowances are first set to zero before a non‑zero update, or provide increase_allowance/decrease_allowance style APIs that adjust the current allowance instead of overwriting it blindly.

Suggested change
fn approve(mut self, owner: u256, spender: u256, value: u256) {
fn approve(mut self, owner: u256, spender: u256, value: u256) {
let current = self.allowances.get(key: allowance_key(owner, spender))
if value != 0 && current != 0 {
revert(0, 0)
}

Copilot uses AI. Check for mistakes.
@cburgdorf cburgdorf marked this pull request as ready for review December 11, 2025 21:59
Copy link
Collaborator

@sbillig sbillig left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀

@sbillig sbillig merged commit 41cbe03 into argotorg:master Dec 12, 2025
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants