Skip to content

Deploying and Calling a Contract: Bytecode That Remembers

The previous page gave us the last safety guarantee we were missing: every VM step costs gas, so a public machine can run an untrusted stranger’s code without that code ever running forever. We now have all the parts on the bench — accounts, the state-transition function, the state root, the stack VM, and gas — but they are still just parts. Nothing yet has put code onto the chain and then run it there.

This page assembles them. We will walk the two transaction kinds that turn a ledger into a computer — Deploy (put code into a new account at a derived address) and Call (run that code against the account’s storage with a gas budget) — then run the whole thing: deploy the counter, call it several times watching the number and the state root climb, and finally starve a call of gas to see a transaction that is reverted but still included. That last scenario is the entire thesis of the part in one output.

A contract is just an account that happens to hold code. So “deploying a contract” means one thing: create a new account, put bytecode in its code field, and tell the sender where it landed. The only real question is where — what address does the new account get?

It cannot be random. Every node applies the same transactions in the same order and must independently arrive at the same address for the new contract, or their world states diverge on the very next line. So the address has to be a deterministic function of information every node already has. Ethereum’s answer — and ours — is to derive it from the creator’s address and the creator’s nonce:

/// Derive a new contract's address from its creator and the creator's nonce.
/// Real Ethereum computes keccak256(rlp([sender, nonce]))[12:]. We use
/// sha256(sender ‖ nonce)[12:] — same 20-byte width, same property.
pub fn contract_address(creator: Address, nonce: u64) -> Address {
let mut hasher = Sha256::new();
hasher.update(creator.0);
hasher.update(nonce.to_be_bytes());
let digest = hasher.finalize();
let mut bytes = [0u8; 20];
bytes.copy_from_slice(&digest[12..32]);
Address(bytes)
}

Two properties fall out of this, and both matter:

  • Deterministic. The same creator deploying at the same nonce always yields the same address. Every node computes it; nobody has to tell anyone the address. It is implied by the transaction.
  • Collision-resistant across deploys. Because the nonce is part of the input, the same account deploying twice gets two different addresses — its nonce advances between deploys. You cannot accidentally deploy two contracts on top of each other.

Notice which nonce we hash: the sender’s nonce at the moment of signing, the same number the transaction claimed. That is why the state-transition function passes tx.nonce in explicitly rather than reading the account’s current nonce (which has already been bumped by the time we execute). Get this wrong and every deployed address shifts by one.

With the address in hand, deploying is almost anticlimactic:

TxKind::Deploy { code, value } => {
let addr = contract_address(tx.from, tx.nonce);
// Optionally endow the new contract with some starting balance.
if *value > 0 {
self.transfer_value(tx.from, addr, *value);
}
// The one line that makes it a contract: give the account code.
self.account_mut(addr).code = code.clone();
Ok(Receipt {
status: ExecStatus::Success,
gas_used: 0,
return_value: None,
created: Some(addr), // <- tell the caller where it landed
})
}

Fund the account (if the deploy carried value), write the bytecode into code, and return the address in the receipt’s created field so the sender can call it later. The account now satisfies is_contract() — which, recall, is nothing more exotic than !code.is_empty(). That single non-empty field is the whole difference between a wallet and a program.

before Deploy after Deploy
───────────── ────────────
addr 0x…a1 { code: [] } addr 0x…a1 { code: [] } (alice, unchanged but nonce++)
addr 0x…c7 { code: [SLOAD…], (new contract account,
storage: {} } derived from alice + nonce)

Deploy puts code on the chain. Call is where the code finally runs. A call names a target, optionally moves value to it, and — this is the branch that makes the whole part worth building — if the target has code, executes it:

TxKind::Call { to, value, gas_limit } => {
if *value > 0 {
self.transfer_value(tx.from, *to, *value);
}
// A call to a codeless account is just a value transfer.
let target = self.account_mut(*to);
if !target.is_contract() {
return Ok(Receipt { status: ExecStatus::Success, gas_used: 0, .. });
}
// Run the contract against a COPY of its storage, with the gas budget.
let code = target.code.clone();
let storage = target.storage.clone();
match vm::run(&code, &storage, *gas_limit) {
Ok(exec) => {
self.account_mut(*to).storage = exec.storage; // commit
Ok(Receipt {
status: ExecStatus::Success,
gas_used: exec.gas_used,
return_value: exec.return_value,
created: None,
})
}
Err(reason) => Err(Reverted {
gas_used: *gas_limit, // an exceptional halt burns the whole budget
reason,
}),
}
}

Read the shape carefully, because it encodes three ideas we built on earlier pages:

  1. The codeless branch. Send a call to a plain wallet and is_contract() is false, so it is a pure value transfer — no VM, no gas, status Success. The exact same transaction sent to an account with code runs a program. One if is the boundary between ledger and computer.
  2. Run against a clone, commit on success. We hand vm::run a copy of the target’s storage. The VM mutates the copy; only if it halts cleanly (STOP/RETURN) do we write exec.storage back to the account. This is the state root page’s all-or-nothing rule made mechanical: a call either commits every write or none of them.
  3. A throw becomes a revert. If the VM returns Err — out of gas, stack underflow, a bad jump — we never touch the account’s real storage. We propagate Reverted upward, carrying gas_used: gas_limit because an exceptional halt burns the entire budget. The caller still paid; the work was undone.
Call to a contract
──────────────────
target.storage ──clone──▶ working copy ──vm::run──▶ Ok(new storage) ──commit──▶ target.storage'
│ └──▶ Err(reason) ──drop──▶ target.storage (unchanged)
(never mutated directly — the clone is the revert boundary)

The counter: the smallest program that remembers

Section titled “The counter: the smallest program that remembers”

We keep returning to the same tiny contract because it is the minimum viable proof that a chain is a computer and not a calculator. Its entire job: keep a number in storage slot 0 and add one to it every time it is called.

pub const COUNTER_SLOT: u64 = 0;
/// Each call does storage[0] = storage[0] + 1.
pub fn counter_code() -> Vec<u8> {
assemble(&[
Op::Push(COUNTER_SLOT), // key
Op::SLoad, // stack: [ count ]
Op::Push(1),
Op::Add, // stack: [ count + 1 ]
Op::Push(COUNTER_SLOT), // key (on top, the way SSTORE wants it)
Op::SStore, // storage[0] = count + 1
Op::Stop,
])
}

Seven instructions, and every one earns its place. SLOAD reaches into the account’s persistent storage and pushes what it finds (0 if the slot has never been written). ADD computes the successor. SSTORE writes it back. STOP halts cleanly, which is the signal to commit the working copy.

The load-bearing insight is what SLOAD reads on the second call. It does not read a variable in this Rust process — that vanished when the first call returned. It reads a number that was written into the chain’s world state and has been sitting there ever since, identical on every node that replayed the same transactions. The count survives between calls because it lives in the ledger, not in any one machine. That is the definition of a world computer, compressed into seven opcodes.

Because these opcode numbers match real EVM (SLOAD is 0x54, SSTORE is 0x55, ADD is 0x01, STOP is 0x00), the bytecode this assembles to is not a toy dialect — it is a legal, if minimal, EVM program.

The receipt: what a transaction leaves behind

Section titled “The receipt: what a transaction leaves behind”

Every apply returns a Receipt — the permanent record of what the transaction did:

pub struct Receipt {
pub status: ExecStatus, // Success or Reverted
pub gas_used: u64, // what the caller pays for
pub return_value: Option<u64>, // the word a RETURN produced, if any
pub created: Option<Address>, // for a Deploy: the new contract's address
}

Four fields, one per question a caller asks. Did it work (status)? What did it cost (gas_used)? Did it hand anything back (return_value)? If I deployed, where did it land (created)? Real Ethereum receipts carry more — logs, cumulative gas, a bloom filter — but every one of those is an addition to this same idea: a receipt is the transaction’s outcome, made durable so anyone can audit it without re-running the chain.

Reject vs. revert: the rule that trips everyone up

Section titled “Reject vs. revert: the rule that trips everyone up”

ExecStatus has two values, and the distinction between them — and a third outcome that is not a status at all — is the single most important rule on this page.

outcome nonce consumed? state changes? tx included in chain?
─────── ─────────────── ────────────── ─────────────────────
Err(EthError) no none NO — rejected, never happened
Reverted YES undone YES — included, work rolled back
Success YES committed YES — included, applied in full
  • A rejection (Err(EthError): unknown sender, wrong nonce, can’t afford the value) means the transaction was never valid to include. The world is left exactly as it was — nonce and all. It is as if the transaction was never submitted.
  • A revert (Ok(Receipt { status: Reverted, .. })) means the transaction was valid and is included — but its contract call threw. Its writes are rolled back, yet its nonce is consumed.

That last clause is the one to internalize. Why must a reverted transaction still burn its nonce? Because the nonce is replay protection. If a reverted transaction left the nonce untouched, the identical signed transaction could be resubmitted forever — and worse, an attacker could replay it. Consuming the nonce even on failure is what makes “I tried and it failed” a permanent, un-repeatable fact. The failure is recorded, once, and can never be replayed.

Here is how apply enforces exactly that, with a snapshot-and-restore that undoes everything except the nonce bump:

// The tx is valid to include. Snapshot, in case we must revert.
let snapshot = self.accounts.clone();
// The nonce is consumed no matter what happens next.
self.account_mut(tx.from).nonce += 1;
match self.execute(tx) {
Ok(receipt) => Ok(receipt),
Err(reverted) => {
// Roll the whole world back, then re-apply ONLY the nonce bump.
let bumped = self.account(&tx.from).map(|a| a.nonce).unwrap_or(0);
self.accounts = snapshot;
self.account_mut(tx.from).nonce = bumped;
Ok(Receipt { status: ExecStatus::Reverted, gas_used: reverted.gas_used, .. })
}
}

Restore the snapshot, then re-stamp the advanced nonce on top. Everything the call touched is gone; the fact that the sender spent this nonce is permanent.

Now watch all of it move together. The ethmini binary funds two accounts, deploys the counter, calls it three times, then starves a call of gas. Run it yourself:

Terminal window
cd rust/ethmini && cargo run

The deploy hands back an address; each call reports its gas and the current count read straight out of the chain’s storage:

// Deploy the counter.
let deploy = Transaction::deploy(alice, state.nonce(&alice), counter_code(), 0);
let counter = state.apply(&deploy)?.created.expect("deploy creates an address");
// Call it three times. The count lives in the chain, not in this program.
for _ in 0..3 {
let call = Transaction::call(alice, state.nonce(&alice), counter, 0, 100_000);
let r = state.apply(&call)?;
println!("status {:?}, gas used {}, count now {}",
r.status, r.gas_used, state.storage(&counter, COUNTER_SLOT));
}

The output tells the story: the count climbs 1 → 2 → 3, every call burns the same gas, and the state root changes after each one because each SSTORE moved the world.

== call counter x3 ==
status Success, gas used 312, count now 1
status Success, gas used 312, count now 2
status Success, gas used 312, count now 3
state root 0x… (a different 32 bytes than before the calls)

Then the demo does the crucial thing — it starves a call of gas:

let nonce_before = state.nonce(&alice);
let starved = Transaction::call(alice, nonce_before, counter, 0, 50); // not enough
let r = state.apply(&starved).expect("tx is still included");
// -> status Reverted, count UNCHANGED, nonce advanced.

Fifty gas cannot pay for a SLOAD (100) let alone a SSTORE (200), so the VM halts out of gas and returns Err. The call reverts. And here is the payoff, all three facts at once:

  • status is Reverted — the transaction was included, not rejected;
  • the count is still 3 — the storage write was rolled back;
  • alice’s nonce still advanced — the failed attempt is permanent and can never be replayed.
== call counter with too little gas ==
status Reverted (gas used 50)
count unchanged: 3 | nonce advanced: 5 -> 6

That single scenario is the whole part. A stranger’s program ran on your machine, changed shared state that every node agrees on, priced every step in gas, and — when it ran out of budget — failed safely: writes undone, nonce spent, the world consistent. Untrusting strangers agreed on the state of a shared world computer, and you watched exactly what it cost to run one.

Under the hood — why revert-but-include is not optional

Section titled “Under the hood — why revert-but-include is not optional”

It is tempting to think a failed transaction should simply not happen — vanish, refund, no trace. Both of the alternatives to “revert but include” break the chain:

  • If a revert consumed no nonce, the transaction could be replayed indefinitely, and ordering guarantees collapse: a sender could never be sure a failed transaction was truly finished. Nonce-on-revert makes failure a one-time, ordered event.
  • If a revert were free, an attacker would flood the network with calls engineered to throw, consuming validators’ execution time at zero cost. Charging gas for reverted work — up to the whole budget on an exceptional halt — prices the attack out.

So “reverted transactions are still included, their nonce consumed and their gas charged, their writes undone” is not an accident of implementation. It is the only rule that keeps replay protection, ordering, and denial-of-service resistance all true at once. ethmini enforces it in a dozen lines; real Ethereum enforces the identical rule at planet scale.

  • Why does it exist? Deploy and call are the two operations that turn a ledger into a computer: deploy writes a program to a durable, agreed-on address, and call runs that program against state every node shares. Without them you have accounts that can only move value.
  • What problem does it solve? It lets untrusting strangers put code onto a shared machine and invoke it, with a deterministic address so nobody has to be told where the code lives, and a gas budget so nobody can weaponize the call.
  • What are the trade-offs? Running a stranger’s code on every node is expensive and must be metered, snapshotted, and revert-safe — far more machinery than a plain transfer. And derived addresses mean deployment order matters: the same code deployed at a different nonce gets a different address.
  • When should I avoid it? When plain value movement suffices, do not deploy a contract — a codeless call is cheaper, simpler, and cannot revert on execution. Contracts earn their cost only when you need state that remembers and logic that runs.
  • What breaks if I remove it? Remove deploy and no program can ever reach the chain; remove call and code can be stored but never executed; remove the revert-but-include rule and either replay protection or denial-of-service resistance fails. Each is load-bearing.
  1. A contract’s address is derived from contract_address(creator, nonce). Why must it be a deterministic function of information every node already has, rather than random — and which nonce (signing-time or post-bump) is hashed?
  2. Trace what Call does when the target account has empty code, versus when it holds the counter bytecode. What single check decides between the two paths?
  3. The counter is PUSH 0; SLOAD; PUSH 1; ADD; PUSH 0; SSTORE; STOP. On the second call, where does SLOAD read the count from, and why does that location prove the chain is a computer rather than a calculator?
  4. Distinguish a rejected transaction (Err(EthError)) from a reverted one (status: Reverted). For each, say whether the nonce is consumed, whether state changes persist, and whether the transaction is included in the chain.
  5. In the end-to-end demo, the fourth call is given only 50 gas and reverts. Name the three things you observe afterward, and explain why a reverted transaction must still consume its nonce.
Show answers
  1. Every node applies the same transactions in the same order and must independently compute the same address for the new contract, or their world states diverge immediately — a random address could not be reproduced. Deriving it from creator + nonce makes it deterministic (implied by the transaction, so nobody needs to be told it) and collision-resistant across deploys (the nonce advances, so the same account deploying twice gets two different addresses). The nonce hashed is the signing-time nonce (tx.nonce), the same one the transaction claimed — not the post-bump value, which is why execute passes it in explicitly.
  2. If the target has empty code, is_contract() is false and the call is a pure value transfer: no VM, no gas, status Success. If the target holds the counter bytecode, is_contract() is true, so the VM runs the code against a clone of the account’s storage with the gas budget, committing the new storage on a clean halt. The deciding check is is_contract(), which is simply !code.is_empty().
  3. SLOAD reads storage slot 0 out of the contract account’s persistent storage in the world state — not from any variable in the Rust process, which vanished when the first call returned. Because the count was committed to the ledger and read back identically on the next call (and would read back the same on any node replaying the same transactions), the state survives between calls and lives in the chain. That persistence-across-calls is exactly what makes it a computer rather than a calculator.
  4. Rejected (Err): the transaction was never valid (unknown sender, wrong nonce, unaffordable value); the nonce is not consumed, no state changes, and the transaction is not included — the world is untouched. Reverted (status: Reverted): the transaction was valid and is included; its nonce is consumed and its gas is charged, but its state changes are rolled back. The difference is include-or-not: a rejection never happened; a revert happened, failed, and is recorded.
  5. After the starved call you observe: (a) status is Reverted — it was included, not rejected; (b) the count is unchanged (still 3) — the storage write was undone; (c) alice’s nonce still advanced — the failure is permanent. It must consume its nonce because the nonce is replay protection: if a reverted transaction left the nonce untouched, the identical signed transaction could be resubmitted or replayed forever. Burning the nonce on failure makes “I tried and it failed” a one-time, un-repeatable, ordered fact.