ddimaria / rust-blockchain-tutorial

This repo aims to train Rust developers on intermediate and advanced practices to help grok fundamental concepts in Ethereum blockchains. It includes functioning nodes, WASM contracts and execution runtime, and a Web3 client for interacting with the chain.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Rust Blockchain Tutorial

WORK IN PROGRESS

This repo is designed to train Rust developers on intermediate and advanced Rust development and help understand the primary concepts in Ethereum blockchain development.

Roadmap

  • Ethereum Types
  • Cryptography Primitives
  • Chain Node
  • Web3 Client
  • WASM/WASI VM for Contract Execution (wasmtime)
  • Rust Smart Contracts
    • Base Implementation
    • Fungible
    • Non Fungible
    • Multi Asset
  • P2P Networking between Nodes (libp2p)
  • PoS Consensus
  • Persistent Disk Chain State (RocksDB)
  • Full Tutorial
  • CI

Table of Contents

Introduction

When I first entered the crypto space, I had never used Rust and was unfamiliar with blockchain technology, let alone any knowledge of Ethereum concepts. This is the tutorial I wish I had back then, and will hopefully guide Rust developers along their blockchain journey.

While the concepts explored here are based on Ethereum, there are many instances where they diverge from it. For example, structs are simplified to just show general concepts. Different hashing and consensus algorithims are implemented. The most divergent area are smart contracts. We'll explore Rust-based smart contracts that run on a WASM virtual machine. I went in this direction to keep the language choice homogenious. The overall approach is similar to Solidity, though the implementation is very different.

Ethereum Primitives

Accounts

In Ethereum, Accounts are either Externally Owned Accounts or Contract Accounts. Addresses are hex encoded: 0x71562b71999873DB5b286dF957af199Ec94617F7.

type Account = ethereum_types::Address;

For a given address, data associated with the account is stored on chain:

struct AccountData {
    nonce: u64,
    balance: u64,
    code_hash: Option<Bytes>,
}

impl AccountData {
    fn is_contract(&self) -> bool {
        self.code_hash.is_some()
    }
}

Externally Owned Accounts are simply a public address. This address is a 20 byte hash (H160), and is created by applying a hash function on the public key, taking the last 20 bytes. This is how we create an Ethereum Account in Rust:

use crypto::{keypair, public_key_address};

let (private_key, public_key) = keypair();
let address = public_key_address(&public_key);

fn public_key_address(key: &PublicKey) -> H160 {
    let public_key = key.serialize_uncompressed();
    let hash = hash(&public_key[1..]);

    Address::from_slice(&hash[12..])
}

Public keys are not stored on the chain. Since we can't derive the public key from the hash, the public key is not known until a signed transaction is validated. We'll dig a bit more into this in the Transaction section.

Contract Accounts are also just an address, but have a code hash associated with them. A contract's address is created by encoding the sender's address and their current nonce. This encoding is then hashed using a hash function, taking the last 20 bytes. This process is similiar to the Externally Owned Account creation, but the input is an encoded tuple.

use crypto::{to_address};
use web3::web3;

let account = MY_ACCOUNT_ADDRESS;
let web3 = web3::Web3::new("http://127.0.0.1:8545")?;
let nonce = web3().get_transaction_count(account).await.unwrap();
let serialized: Vec<u8> = bincode::serialize(&(account, nonce)).unwrap();
let contract_address = to_address(&serialized).unwrap();

It's important to note that addresses (accounts) are iniatiated outside of a blockchain. They can be generated in many ways, though the most common is to use a wallet. In our examples, we'll sign them offline using the provided tools in the crypto crate. Accounts are stored on the chain when they are used for the first time.

Accounts are also deterministic. That is, given the same inputs, the same address is always generated.

Nonce

Accounts have an associated nonce. A nonce is an acronym for "number only used once" and is a counter of the number of processed transactions for a given account. It is to be incremented every time a new transaction is submitted. User's must keep track of their own nonces, though wallet providers can do this as well. Anyone can query the blockchain for current nonce of an account (EOA and contract), which can be helpful for determining the next nonce to use.

The main purpose of a nonce is to make a data structure unique, so that each data structure is explicit regarding otherwise identical data being effectively different. We'll discuss nonces more when breaking down transactions and how blockchain nodes use them to preserve the order of processing submitted transactions.

Transactions

Transactions are the heart of a blockchain. Without them, the chain's state would remain unchanged. Transactions drive state changes. They are submitted externally owned accounts only (i.e. not a contract account).

pub struct Transaction {
    pub from: Address,
    pub to: Option<Address>,
    pub hash: Option<H256>,
    pub nonce: Option<U256>,
    pub value: U256,
    pub data: Option<Bytes>,
    pub gas: U256,
    pub gas_price: U256,
}

While the Transaction data structure has much more fields in Ethereum than shown above, the data subset we're using is the minimum needed to understand transactions.

  • The from portion of a transaction identifies the transaction sender. This account must already exist on the blockchain.
  • The to attribute represents the receiver of the value transferred in a transaction. It can also be a contract address, where code is executed. It is optional because it is left empty (or zero or null) to signify a transaction that deploys a contract.
  • A hash attribute contains the hash of the transaction. It's optional so that it can be calculated after the other values in the transaction are populated.
  • The nonce is the sender's next account nonce. It is the existing account nonce incremented by one. Leaving this blank will let the blockchain autoincrement on behlaf of the transaction.
  • value indicates the amount of coin to transfer from the sender to the recipient. This number can be zero for non-value-transferring transactions.
  • The data attribute can hold various pieces of data. When deploying a contract, it holds bytes of the assembled contract code. When executing a function on a contract, it holds the function name and parameters. It can also be any piece of data that the sender wants to include in the transaction.
  • gas is the total number of units that the sender is offering to pay for the transaction. We'll discuss this in more detail later.
  • The gas_price is the amount of coin (eth in Ethereum) to be paid for each unit of gas.

Kinds of Transactions

There are 3 ways that transaction can be used:

pub enum TransactionKind {
    Regular(Address, Address, U256),
    ContractDeployment(Address, Bytes),
    ContractExecution(Address, Address, Bytes),
}
  • Regular transactions are ones where value is transferred from one account to another.
  • Contract deployment transactions are used to deploy contract code to the blockchain and are without a 'to' address, where the data field is used for the contract code.
  • Contract execution transactions interact with a deployed smart contract. In this case, 'to' address is the smart contract address.

The type of transaction is derived from the values in the transaction:

fn kind(self) -> Result<TransactionKind> {
    match (self.from, self.to, self.data) {
        (from, Some(to), None) => Ok(TransactionKind::Regular(from, to, self.value)),
        (from, None, Some(data)) => Ok(TransactionKind::ContractDeployment(from, data)),
        (from, Some(to), Some(data)) => Ok(TransactionKind::ContractExecution(from, to, data)),
        _ => Err(TypeError::InvalidTransaction("kind".into())),
    }
}

Transaction Hashes

Once a transaction data structure is filled in, the hash can be calculated:

let serialized = bincode::serialize(&transaction)?;
let hash: H256 = hash(&serialized).into();

We first encode/serialize the transaction and then apply a hashing function. To keep things simple, we're using Bincode to serialize and compress the data to a binary format throughout this blockchain. Ethereum uses RLP Encoding for most of it's encoding/serialization.

Blocks

Blocks essentially containers for validated transactions. Once a set of transactions are validated, they are ready to be added to a block.

struct Block {
    number: U64,
    hash: Option<H256>,
    parent_hash: H256,
    transactions: Vec<Transaction>,
    transactions_root: H256,
    state_root: H256,
}

A Block number is an incremented unsigned integer. All blocks are in order, and there are no missing blocks. Blocks start at zero (called the Genesis Block). Similar to transactions, blocks have a hash of their values:

let serialized = bincode::serialize(&block)?;
let hash: H256 = hash(&serialized).into();
block.hash = Some(hash);

The parent_hash is the hash of the previous block. This links blocks together and are critical in blockchain validation. All transactions are within the block and are needed for the block verification process.

The transaction_root is the Merkle Root of all of the transactions in the block:

fn to_trie(transactions: &[Transaction]) -> Result<EthTrie<MemoryDB>> {
    let memdb = Arc::new(MemoryDB::new(true));
    let mut trie = EthTrie::new(memdb);

    transactions.iter().try_for_each(|transaction| {
        trie.insert(
            transaction.transaction_hash()?.as_bytes(),
            bincode::serialize(&transaction)?,
        )?
    })?;

    Ok(trie)
}

fn root_hash(transactions: &[Transaction]) -> Result<H256> {
    let mut trie = Transaction::to_trie(transactions)?;
    let root_hash = trie.root_hash()?;

    Ok(H256::from_slice(root_hash.as_bytes()))
}

The state_root is the Merkle Root of all state within the blockchain. We'll detail this more when discussing Global State.

Genesis Block

The first block in a blockchain is called the genesis block. We're using a naive implementation to create an empty block with no state:

fn genesis() -> Result<Self> {
    Block::new(U64::zero(), H256::zero(), vec![], H256::zero())
}

In Ethereum, the genesis block is created using a genisis file. This file contains configuration information and details the accounts to create and the amount of Eth they each get. This is where the initial Eth comes from, though additional Eth is created for miners. The movement to Proof of Stake in Ethereum V2 transforms miners to validators, and changes the reward mechanism. We'll talk more about this in the consensus section.

Basic Cryptography

We've talked about public and private keys, addresses, and hashes, but dive a little deeper into what they are.

Public Key Cryptography

Public Key Cryptography is asymetric encryption, where there are public and private keys (key pair). This is different from symmetric encryption that uses the same key to encrypt and decrypt.

There are differnt flavors of public key cryptography. In this project we're using Secp256k1, which works with ECDSA signatures. Secp256k1 is an elliptic curve. We're not going to get into anything that complicated here, so just picture a line that's shaped like a door knob. The public and private keys are just points on the curve.

The public key is derived from the private key. A key is just a number (unsigned 256-bit in our case). While you can derive a public key from a private key, you cannot create a private key from a public one.

Here's how the encryption works. Bob wants to send Alice a secret message. He first asks Alice for her public key, which he uses to encrypt the message. He then sends the message to Alice and she uses her private key to decrypt the message. If anyone intercepts the message, they can't read it unless they have Alice's private key. Alice knows that the message was not altered and she can prove that Bob sent her a message.

use utils::crypto::keypair;

let (private_key, public_key) = keypair();
println!("{:?}", keypair());

fn keypair() -> (SecretKey, PublicKey) {
    generate_keypair(&mut rand::thread_rng())
}

Outputs:

SecretKey(#2fd3a5f2b1f52597)
PublicKey(6dfc30040b48fefe3e3cebe0ca2d5033189a93b50aeaa1876d21ea15fc756b6e3e8e48997fb1441464e254877fe898f6566488d2b0d578f2fbd31b48772be593)

Hashing

The concept of a hash is fairly straightforward. A hash function accepts any size data and outputs a number of fixed length. Anytime the input is the same, the output (e.g. the hash) will always be the same. Another key component is that you cannot derive the original data from the hash.

Hashes are used everywhere in blockchains. They allow the formal verification that the source data was used to create it. They are convenient ways of storing a proof of the input, without the burden of storing the data.

let public_key = 6dfc30040b48fefe3e3cebe0ca2d5033189a93b50aeaa1876d21ea15fc756b6e3e8e48997fb1441464e254877fe898f6566488d2b0d578f2fbd31b48772be593;
let hash = hash(&public_key[1..]);
println!("{:?}", hash);

Outputs:

[101, 35, 44, 44, 55, 13, 158, 100, 34, 209, 215, 186, 89, 105, 196, 45, 127, 154, 217, 113, 203, 127, 236, 66, 153, 233, 137, 207, 48, 140, 166, 244]

Address

We already know that an account on Ethereum is just an address, but what is an address? It's simply the trailing 20 bytes from a hash.

let public_key = 6dfc30040b48fefe3e3cebe0ca2d5033189a93b50aeaa1876d21ea15fc756b6e3e8e48997fb1441464e254877fe898f6566488d2b0d578f2fbd31b48772be593;
let hash = hash(&public_key[1..]);
let address = Address::from_slice(&hash[12..]);
println!("{:?}", address);

Outputs:

0x5969c42d7f9ad971cb7fec4299e989cf308ca6f4

Web3 Client - An Introduction

Ethereum chains expose a json-rpc interface. Here's how you get an account's balance:

curl -X POST \
     -H 'Content-Type: application/json' \
     -d '{"jsonrpc":"2.0","id":"id","method":"eth_getBalance","params":["0xfbb55f17b2926063ae3fa5647c98eb1fac88c99e"]}' \
     http://127.0.0.1:8545

That returns:

{
    "jsonrpc":"2.0",
    "id":"id",
    "result":100
}

That wasn't very hard, but we want to interact with the API in a simpler, more type-safe way.

Web3 clients are just SDKs that make it easier to interact with a blockchain. They transform the call above to:

let web3 = web3::Web3::new("http://127.0.0.1:8545")?;
let account: Account = Account::from_str("0xfbb55f17b2926063ae3fa5647c98eb1fac88c99e")?;
let balance: U256 = web3.get_balance(account).await;

Outputs:

Ok(9999870002304000000000)

The Life of a Transaction

Accounts create and sign transactions. Transactions combine to from a Block. Transactions are an essential piece of a blockchain, so let's examine how they are made and what happens to them on the blockchain.

Create a Transaction

The first step is to create a transaction.

impl Transaction {
    pub fn new(
        from: Account,
        to: Option<Account>,
        value: U256,
        nonce: U256,
        data: Option<Bytes>,
    ) -> Result<Self> {
        let mut transaction = Self {
            from,
            to,
            value,
            nonce,
            hash: None,
            data,
            gas: U256::from(10),
            gas_price: U256::from(10),
        };

        let serialized = bincode::serialize(&transaction)?;
        let hash: H256 = hash(&serialized).into();
        transaction.hash = Some(hash);

        Ok(transaction)
    }
}

let from = Account::from_str("0x4a0d457e884ebd9b9773d172ed687417caac4f14")?;
let to = Account::from_str("0xfbb55f17b2926063ae3fa5647c98eb1fac88c99e")?;
let transaction = Transaction::new(
    from,
    Some(to),
    U256::from(10),
    None,
    None,
);

In this transaction, we're sending 10 coin from the 0x4a0d457e884ebd9b9773d172ed687417caac4f14 to the 0xfbb55f17b2926063ae3fa5647c98eb1fac88c99e account.

Now that we have a transaction, let's sign it.

Transaction Signing

Transactions must be signed before they are submitted to the blockchain.

fn sign(&self, key: SecretKey) -> Result<SignedTransaction> {
    let encoded = bincode::serialize(&self)?;
    let recoverable_signature = sign_recovery(&encoded, &key)?;
    let (_, signature_bytes) = recoverable_signature.serialize_compact();
    let Signature { v, r, s } = recoverable_signature.into();
    let transaction_hash = hash(&signature_bytes).into();

    let signed_transaction = SignedTransaction {
        v,
        r,
        s,
        raw_transaction: encoded.into(),
        transaction_hash,
    };

    Ok(signed_transaction)
}

Signing is done with the account holder's private key in order to generate a recoverable signature of the transaction. We'll discuss basic cryptography in more detail later, but a recoverable signature is one where a public key can be derived from the signature and message. This is important as it's how the blockchain validates the transaction. Once the public key is recovered, it is hashed and must match the from address of the transaction.

The resulting SignedTransaction data structure is represented as:

struct SignedTransaction {
    v: u64,
    r: H256,
    s: H256,
    raw_transaction: Bytes,
    transaction_hash: H256,
}

The v, r, and s values represent the digital signature. The v attribute is the recovery id that is used to derive the account holder's public key. r and s hold values related to the signature (r is the value and s is the proof).

The transaction encoded and compressed and is stored as bytes in the raw_transaction attribute. This minimizes the footprint of the packet.

The transaction_hash will be the transaction id in the blockchain. It serves many purposes, and can be used to validate that the reconstructed transaction wasn't tampered with.

Using the handy web3 client, we can now sign the created transaction:

let secret_key;
let transaction = transaction().await;
let signed_transaction = web3().sign_transaction(transaction, secret_key);

Submitting a Transaction

With a newly created transaction that is signed, all that is left is to submit it to the blockchain.

let encoded = bincode::serialize(&signed_transaction)?;
let response = web3().send_raw(encoded.into()).await;

The chain accepts the transaction and responds with a transaction id, which is just the hash of the transaction. Let's take a look at what happens on the blockchain.

Receiving a Transaction

The chain receives the raw transaction from the json-rpc API:

fn eth_send_raw_transaction(module: &mut RpcModule<Context>) -> Result<()> {
    module.register_async_method(
        "eth_sendRawTransaction",
        move |params, blockchain| async move {
            let raw_transaction = params.one::<Bytes>()?;
            let transaction_hash = blockchain
                .lock()
                .await
                .send_raw_transaction(raw_transaction)
                .await
                .map_err(|e| Error::Custom(e.to_string()))?;

            Ok(transaction_hash)
        },
    )?;

    Ok(())
}

This function registers the eth_sendRawTransaction method, followed by a closure that handles the incomming request. Now that we have the raw transaction bytes, let's take a peek at the send_raw_transaction() function.

async fn send_raw_transaction(&mut self, transaction: Bytes) -> Result<H256> {
    let signed_transaction: SignedTransaction = bincode::deserialize(&transaction)?;
    let transaction: Transaction = signed_transaction.clone().try_into()?;
    let transaction_hash = transaction.transaction_hash()?;

    Transaction::verify(signed_transaction, transaction.from).map_err(|e| {
        ChainError::TransactionNotVerified(format!("{}: {}", transaction_hash, e))
    })?;

    self.send_transaction(transaction.into()).await
}

Verifying a Signed Transaction

The chain first deserializes the raw bytes into a SignedTransaction struct. To recap, that struct is:

struct SignedTransaction {
    v: u64,
    r: H256,
    s: H256,
    raw_transaction: Bytes,
    transaction_hash: H256,
}

With a signed transaction in place, we can now verify that the transaction is properly signed.

pub fn verify(signed_transaction: SignedTransaction, address: Address) -> Result<bool> {
    let (message, recovery_id, signature_bytes) = Transaction::recover_pieces(signed_transaction)?;
    let key = recover_public_key(&message, &signature_bytes, recovery_id.to_i32())?;
    let verified = verify(&message, &signature_bytes, &key)?;
    let addresses_match = address == public_key_address(&key);

    Ok(verified && addresses_match)
}

fn recover_pieces(signed_transaction: SignedTransaction) -> Result<(Vec<u8>, RecoveryId, [u8; 64])> {
    let message = signed_transaction.raw_transaction.to_owned();
    let signature: Signature = signed_transaction.into();
    let recoverable_signature: RecoverableSignature = signature.try_into()?;
    let (recovery_id, signature_bytes) = recoverable_signature.serialize_compact();

    Ok((message.to_vec(), recovery_id, signature_bytes))
}

We get the message, the recovery id, and the signature bytes from the signed transaction. We can then use those pieces to recover the public key. We then verify the signature is a valid ECDSA signature for message using the public key. We now invoke the companion verify() function in the crypto utility:

fn verify(message: &[u8], signature: &[u8], key: &PublicKey) -> Result<bool> {
    let message = hash_message(message)?;
    let signature = EcdsaSignature::from_compact(signature)
        .map_err(|e| UtilsError::VerifyError(e.to_string()))?;

    Ok(CONTEXT.verify_ecdsa(&message, &signature, key).is_ok())
}

We can also verify that the public key address matches the from attribute of a transaction, which completes the transaction verification process.

Add the Transaction to the Mempool

Now we can safely add the transaction to the mempool:

async fn send_transaction(
    &mut self,
    transaction_request: TransactionRequest,
) -> Result<H256> {
    let mut transaction: Transaction = transaction_request.try_into()?;
    let account = self.accounts.get_account(&transaction.from)?;
    let nonce = transaction.nonce.unwrap_or_else(|| account.nonce + 1_u64);

    transaction.nonce = Some(nonce);

    // regenerate the transaction hash with the nonce in place
    let transaction_hash = transaction.hash()?;

    // add to the transaction mempool
    self.transactions.lock().await.send_transaction(transaction);

    Ok(transaction_hash)
}

The mempool is where pending transactions are stored while they wait to be processed.

Kickoff the Transaction Processor

Transactions are processed by a timer running in a separate Tokio thread every second:

let transaction_processor = task::spawn(async move {
    let mut interval = time::interval(Duration::from_millis(1000));

    loop {
        interval.tick().await;

        if let Err(error) = blockchain_for_transaction_processor
            .lock()
            .await
            .process_transactions()
            .await
        {
            tracing::error!("Error processing transactions {}", error.to_string());
        }
    }
});

This kicks off transaction processing. We don't want our processor to panic at any point, so errors that occur while processing transactions are simply logged out.

Processing Transactions

In our naive implementation, we're ignoring gas and just processing transactions in a first in, first out (FIFO) methodology. Ethereum validators can optimize the processing order of transactions by evaluating the maximum amount of gas they can receive by processing any given transactions.

We first drain the mempool queue, which prevents the processor that runs 1 second later from competing with processing the same transactions.

async fn process_transactions(&mut self) -> Result<()> {
    let transactions = self
        .transactions
        .lock()
        .await
        .mempool
        .drain(0..)
        .collect::<VecDeque<_>>();

    if !transactions.is_empty() {
        let mut receipts: Vec<TransactionReceipt> = vec![];
        let mut processed: Vec<Transaction> = vec![];

        for mut transaction in transactions.into_iter() {
            match self.process_transaction(&mut transaction) {
                Ok((transaction, transaction_receipt)) => {
                    receipts.push(transaction_receipt);
                    processed.push(transaction.to_owned());
                }
                Err(error) => {
                    match error {
                        // The nonce is too high, add back to the mempool
                        ChainError::NonceTooHigh(_, _) => {
                            self.transactions
                                .lock()
                                .await
                                .mempool
                                .push_back(transaction);
                        }
                        _ => {},
                    }
                }
            }
        }

        // update world state
        let state_trie = self.accounts.root_hash()?;
        self.world_state.update_state_trie(state_trie);

        let block = self.new_block(processed, state_trie)?;

        // now add the block number and hash to the receipts
        for mut receipt in receipts.into_iter() {
            receipt.block_number = Some(BlockNumber(block.number));
            receipt.block_hash = block.hash;

            self.transactions
                .clone()
                .lock()
                .await
                .receipts
                .insert(receipt.transaction_hash, receipt);
        }
    }

    Ok(())
}

We'll talk about processing an individual transaction in the next section. Errors in processing a transaction are ignored and discarded except if the account nonce of a transaction is higher than the next available nonce. It's very possible that an account sent several transactions at once, and we don't want to throw that transaction away. The transaction is simple added into the back of the mempool for later processing.

Once the blockchain processes a transaction, the world state is updated. In our blockchain, the world state is simply the Merkel Root of all accounts on the blockchain. This is essentially the proof of the state transaction between blocks.

The new block is then added to the blockchain.

Processing a Single Transaction

fn process_transaction<'a>(
    &mut self,
    transaction: &'a mut Transaction,
) -> Result<(&'a mut Transaction, TransactionReceipt)> {
    let value = transaction.value;
    let mut contract_address: Option<Account> = None;
    let transaction_hash = transaction.transaction_hash()?;

    // ignore transactions without a nonce
    if let Some(nonce) = transaction.nonce {
        // create the `to` account if it doesn't exist
        if let Some(to) = transaction.to {
            self.accounts.add_empty_account(&to)?;
        }

        let kind = transaction.to_owned().kind()?;

        match kind {
            TransactionKind::Regular(from, to, value) => {
                self.accounts.transfer(&from, &to, value)
            }
            TransactionKind::ContractDeployment(from, data) => {
                contract_address = self.accounts.add_contract_account(&from, data).ok();
                Ok(())
            }
            TransactionKind::ContractExecution(_from, to, data) => {
                let code = self
                    .accounts
                    .get_account(&to)?
                    .code_hash
                    .ok_or_else(|| ChainError::NotAContractAccount(to.to_string()))?;
                let (function, params): (&str, Vec<&str>) = bincode::deserialize(&data)?;

                // call the function in the contract
                runtime::contract::call_function(&code, function, &params)
                    .map_err(|e| ChainError::RuntimeError(to.to_string(), e.to_string()))
            }
        }?;

        // update the nonce
        self.accounts.update_nonce(&transaction.from, nonce)?;

        let transaction_receipt = TransactionReceipt {
            block_hash: None,
            block_number: None,
            contract_address,
            transaction_hash,
        };

        return Ok((transaction, transaction_receipt));
    }

    Err(ChainError::MissingTransactionNonce(
        transaction_hash.to_string(),
    ))
}

The first step is to add the to account to the account storage if it doesn't already exist. The blockchain then evaluates the kind of transaction it's processing. The simplest type of a transaction is the regular one, which is just a coin transfer.

A contract deployment is fairly straightforward as well. First, a contract account is created, using the from address and the current nonce as inputs to the address hash.

pub fn add_contract_account(&mut self, key: &Account, data: Bytes) -> Result<Account> {
    let nonce = self.get_account(key)?.nonce;
    let serialized = bincode::serialize(&(key, nonce))?;
    let account = to_address(&serialized);
    let account_data = AccountData::new(Some(data));
    self.add_account(&account, &account_data)?;

    Ok(account)
}

Code received gets added to the to account's code_hash attribute.

Contract execution involves calling a function in the contract in a WASM virtual machine (Wasmtime). This sandboxing isolates contract execution from the rest of the blockchain. We first must get the executable code from account storage:

let code = self
    .accounts
    .get_account(&to)?
    .code_hash
    .ok_or_else(|| ChainError::NotAContractAccount(to.to_string()))?;

Now we just extract the function name and the function parameters from the data node in the transaction request:

let (function, params): (&str, Vec<&str>) = bincode::deserialize(&data)?;

For example, let's say we want to invoke the construct function. The function signature of construct contract function is:

fn construct(name: String, symbol: String) {}

We serialize the parameter types and values:

// ["Param 1 Type", "Param 1 Value", "Param 2 Type", "Param 2 Value"]
let params = ["String", "Rust Coin", "String", "RustCoin"];

We can now invoke the construct function:

runtime::contract::call_function(&code, "construct", &params)?;

After we've handled one of the 3 transaction types, the from account's nonce is updated. A transaction receipt is created and returned from the function.

Organization

Chain

The chain crate is a simplistic ethereum blockchain node. It currently holds state in memory (TBD on disk storage). The external json-rpc API mirrors that of Ethereum. It contains a WASM runtime for executing contracts.

Sample API: eth_blockNumber

Request
curl -X POST \
     -H 'Content-Type: application/json' \
     -d '{"jsonrpc":"2.0","id":"id","method":"eth_accounts","params":[]}' \ 
     http://127.0.0.1:8545
Response
{
    "jsonrpc":"2.0",
    "id":"id",
    "result":[
        "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
        "0x70997970c51812dc3a010c7d01b50e0d17dc79c8",
        "0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc"
    ]
}

The full API can be found in the chain README.

Runtime

The runtime crate is a wasmtime runtime for executing WASM contracts. It leverages the component model and wit-bindgen to simplify host and guest interactions.

Contracts

The contracts directory holds the WASM source code. Using wit-bindgen, we can greatly simplify dealing with complex types.

WIT

The WIT format specifies a language for generating WASM code.

default world contract {
  export erc20: interface {
    construct: func(name: string, symbol: string)
    mint: func(account: string, amount: u64)
    transfer: func(to: string, amount: u64)
  }
}

Sample Contract - Erc20

Using the magical generate! macro, we remove boilerplate glue code, so all you see is the Rust contract.

use wit_bindgen_guest_rust::*;

wit_bindgen_guest_rust::generate!({path: "../erc20/erc20.wit", world: "erc20"});

struct Erc20;

export_contract!(Erc20);

impl erc20::Erc20 for Erc20 {
    fn construct(name: String, symbol: String) {
        println!("name {}, symbol", symbol);
    }

    fn mint(account: String, amount: u64) {
        println!("account {}, amount", amount);
    }

    fn transfer(to: String, amount: u64) {
        println!("to {}, amount", amount);
    }
}

Invoking a Contract Function

This code can convert the textual representation of a contract function call to a function call within the wasmtime runtime. Parameters are listed in pairs of parameter type and paramater value.

let bytes = include_bytes!("./../../target/wasm32-unknown-unknown/release/erc20_wit.wasm");
let function_name = "construct";
let params = &["String", "Rust Coin", "String", "RustCoin"];

call_function(bytes, function_name, params)?;

Web3

The web3 crate is a naive implementation of a Web3 interface. It has been minimized to focus on learning the concepts of the blockchain. The goal will be to build out some of the most used endpoints.

Sample Usage

use web3::Web3;

let web3 = Web3::new("http://127.0.0.1:8545")?;
let all_accounts = web3.get_all_accounts().await;
let balance = web3.get_balance(all_accounts[0]).await;

let block_number = web3.get_block_number().await?;
let block = web3.get_block(*block_number).await?;

let contract =
    include_bytes!("./../../target/wasm32-unknown-unknown/release/erc20_wit.wasm").to_vec();
let tx_hash = web3.deploy(all_accounts[0], &contract).await?;
let receipt = web3.transaction_receipt(tx_hash).await?;
let code = web3.code(receipt.contract_address.unwrap(), None).await?;

More information can be found in the web3 README.

Types

The types crate holds shared types to be used by the other crates.

Crypto

The crypto crate provides functions for generating keys, hashing data, signing and verifying.

Getting Started

First, start the chain:

cd chain
RUST_LOG=info cargo run

You should see:

2023-01-25T00:58:58.382776Z  INFO chain::server: Starting server on 127.0.0.1:8545

You can now send json-rpc calls to the API.

Compiling

cargo build

Running Tests

cargo test

About

This repo aims to train Rust developers on intermediate and advanced practices to help grok fundamental concepts in Ethereum blockchains. It includes functioning nodes, WASM contracts and execution runtime, and a Web3 client for interacting with the chain.

License:Creative Commons Zero v1.0 Universal


Languages

Language:Rust 100.0%