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.
- 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
- Introduction
- Ethereum Primitives
- Basic Cryptography
- Web3 Client - An Introduction
- The Life of a Transaction
- Organization
- Getting Started
- Compiling
- Running Tests
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.
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.
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 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 ofcoin
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 ofcoin
(eth in Ethereum) to be paid for each unit ofgas
.
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())),
}
}
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 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.
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.
We've talked about public and private keys, addresses, and hashes, but dive a little deeper into what they are.
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)
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]
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
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)
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.
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.
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);
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.
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
}
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.
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.
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.
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.
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, ¶ms)
.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", ¶ms)?;
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.
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.
curl -X POST \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":"id","method":"eth_accounts","params":[]}' \
http://127.0.0.1:8545
{
"jsonrpc":"2.0",
"id":"id",
"result":[
"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
"0x70997970c51812dc3a010c7d01b50e0d17dc79c8",
"0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc"
]
}
The full API can be found in the chain README.
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.
The contracts directory holds the WASM source code. Using wit-bindgen, we can greatly simplify dealing with complex types.
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)
}
}
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);
}
}
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)?;
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.
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.
The types crate holds shared types to be used by the other crates.
The crypto crate provides functions for generating keys, hashing data, signing and verifying.
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.
cargo build
cargo test