How to create a blockchain project on Exonum: a quick guide

    Exonum is an open source framework for creating blockchain-based applications. It is focused on working with closed blockchains and is applicable in any field: FinTech, GovTech and LegalTech.

    Today we will conduct a short review of the solution, and also within the educational format we will figure out how to build a simple blockchain-based project on Exonum. All the code below can be found in the repository on GitHub.


    / Exonum. Your next step to blockchain / Exonum

    Exonum in a nutshell


    The Exonum framework was created specifically for the development of private blockchains. This is a system in which only a predefined group of nodes can create new blocks in the blockchain. It is based on the desire of Bitfury specialists to create a tool that would make it relatively easy to run a system similar in properties to public blockchains (reliability, data immutability, auditing, etc.), but would be more convenient to maintain and maintain.

    Unlike Ethereum, which is a virtual decentralized machine and runs simultaneously on many nodes around the world, the blockchain built on Exonum works exclusively on the computing power of validator nodes that are interested in the work of this system and will ensure its reliable operation.

    The Exonum private blockchain deployed on predetermined nodes at least excludes the possibility of its sudden hard fork, clogging of the transaction pool and other problems characteristic of open blockchains, and node operators monitor its effective operation: update transaction processing rules, etc.

    In addition, the execution of Ethereum smart contracts is highly dependent on the fluctuation of the cryptocurrency - ether rate, which makes it unpredictable for use, for example, in government agencies that cannot pay for transactions with currencies located in an unregulated gray zone. In Exonum, such dependencies are absent in principle.

    And finally, the Exonum blockchain works much faster than public blockchains (Bitcoin, Ethereum, etc.), namely, it processes several thousand transactions per second against several tens processed by the latter. The choice of strategy is due to the general tendency to create a large number of independent blockchains that would interact with each other through sidechain technologies, linking to public blockchains (anchoring), etc.

    The main components of Exonum are: Byzantine consensus, light clients, Bitcoin binding and services.

    The system uses a special Byzantine consensus algorithm to synchronize data among nodes. It guarantees data integrity and the correct execution of transactions even in the event of failure up to 1/3 nodes due to a malfunction or intentional malicious activity, without requiring block mining.

    Speaking about the advantages of Exonum over existing analogues, we can note the developed data model (storage), which is an index containing dependencies on each other (essentially a table) - they allow you to implement an effective data structure aimed at solving particular problems. Clients of such a blockchain can receive cryptographic evidence of the correctness of the downloaded data (Merkle trees), which are checked locally on the client's machine, and cannot be faked even by the operator of the Exonum node.

    Light clients are network nodes that only host a small portion of the blockchain of interest. They allow you to interact with the blockchain using mobile applications or web browsers. Clients “communicate” with one or more services on a fully functional node via API. The work of such thin clients is specific to each individual service and is implemented as difficult as a particular service requires.

    The essence of working with Exonum thin clients and building evidence is that the end user who has linked to the Bitcoin blockchain may not trust the operator of the private blockchain. But you can be sure that the data that he displays is obtained in accordance with the rules laid down in this particular private blockchain.

    The security of light clients in Exonum, comparable to that provided by the permissionless blockchain, is ensured by the aforementioned binding to bitcoin, the so-called anchoring. The service periodically sends block hashes to the public Bitcoin blockchain in the format of transaction certificates. In this case, even if the Exonum blockchain stops working, the data can still be verified. Moreover, to attack such a network, attackers have to overcome the protective mechanisms of both blockchains, which requires tremendous computing power.

    And finally, services are the foundationExonum framework. They resemble smart contracts on other platforms and contain the business logic of blockchain applications. But, unlike smart contracts, services in Exonum are not “locked up” in the virtual machine and are not containerized.

    This makes them more efficient and flexible. However, this approach requires more caution when programming (isolation of services is marked on the Exonum roadmap). Services determine the rules for processing transactions , and also provide access to data to external clients .

    Having familiarized ourselves with the main components, we can proceed to the analysis of the example.

    Creating Services in Exonum


    The release of Exonum version 0.3 took place on November 2, and the further manual is written taking into account the changes and improvements to the system (you can read about them in the repository on GitHub). We will create a blockchain with one node that implements cryptocurrency. The network will accept two types of transactions: “create a wallet” and “transfer funds from one wallet to another”.

    Exonum is written in Rust, so you need to install the compiler. To do this, you can use our guide .

    Node creation

    First, create a new crate :

    cargo new --bin cryptocurrency

    And add the necessary dependencies to the created  cargo.toml :

    [package]
    name = "cryptocurrency"
    version = "0.3.0"
    authors = ["Your Name <your@email.com>"]
    [dependencies]
    iron = "0.5.1"
    bodyparser = "0.7.0"
    router = "0.5.1"
    serde = "1.0"
    serde_json = "1.0"
    serde_derive = "1.0"
    exonum = "0.3.0"

    Import crate with the necessary types. To do this,  edit the src / main.rs file :

    externcrate serde;
    externcrate serde_json;
    #[macro_use]externcrate serde_derive;
    #[macro_use]externcrate exonum;
    externcrate router;
    externcrate bodyparser;
    externcrate iron;
    use exonum::blockchain::{Blockchain, Service, GenesisConfig,
                             ValidatorKeys, Transaction, ApiContext};
    use exonum::node::{Node, NodeConfig, NodeApiConfig, TransactionSend,
                       ApiSender };
    use exonum::messages::{RawTransaction, FromRaw, Message};
    use exonum::storage::{Fork, MemoryDB, MapIndex};
    use exonum::crypto::{PublicKey, Hash, HexValue};
    use exonum::encoding::{self, Field};
    use exonum::api::{Api, ApiError};
    use iron::prelude::*;
    use iron::Handler;
    use router::Router;
    

    Define the constants:

    // Service identifierconst SERVICE_ID: u16 = 1;
    // Identifier for wallet creation transaction typeconst TX_CREATE_WALLET_ID: u16 = 1;
    // Identifier for coins transfer transaction typeconst TX_TRANSFER_ID: u16 = 2;
    // Starting balance of a newly created walletconst INIT_BALANCE: u64 = 100;
    

    And the main function :

    fnmain() {
        exonum::helpers::init_logger().unwrap();
    }
    

    All this allows you to configure a logger that will display information about the activity of Exonum nodes in the console.

    To form the blockchain itself, you need to create a database instance (in our case, MemoryDB, but you can also use RocksDB) and declare a list of services . We put this code after initializing the logger:

    let db = MemoryDB::new();
    let services: Vec<Box<Service>> = vec![ ];
    let blockchain = Blockchain::new(Box::new(db), services);
    

    In essence, the blockchain is ready, however, it will not work with it - we still do not have a node and API to access it. The node will need to be configured . The configuration specifies a list of public keys of validators (in our case, it will be one). In fact, each node requires two pairs of public and private keys: one for interacting with other nodes in the process of reaching consensus, and the second for services. For our example, create temporary public keys with the exonum :: crypto :: gen_keypair () command and write them to the configuration file.

    let validator_keys = ValidatorKeys {
        consensus_key: consensus_public_key,
        service_key: service_public_key,
    };
    let genesis = GenesisConfig::new(vec![validator_keys].into_iter());
    

    Next, we configure the REST API for working with external web requests - for this we open port 8000. We also open port 2000 so that the full nodes of the Exonum network can communicate with each other.

    let api_address = "0.0.0.0:8000".parse().unwrap();
    let api_cfg = NodeApiConfig {
        public_api_address: Some(api_address),
        ..Default::default()
    };
    let peer_address = "0.0.0.0:2000".parse().unwrap();
    // Complete node configurationlet node_cfg = NodeConfig {
        listen_address: peer_address,
        peers: vec![],
        service_public_key,
        service_secret_key,
        consensus_public_key,
        consensus_secret_key,
        genesis,
        external_address: None,
        network: Default::default(),
        whitelist: Default::default(),
        api: api_cfg,
        mempool: Default::default(),
        services_configs: Default::default(),
    };
    let node = Node::new(blockchain, node_cfg);
    node.run().unwrap();
    

    Declare Data

    At this stage, we need to determine what data we want to store on the blockchain. In our case, this is information about the wallet and balance, the public key for checking requests from the wallet owner and the name of the owner. The structure will look like this:

    encoding_struct! {
        structWallet {
            const SIZE = 48;
            field pub_key:            &PublicKey  [00 => 32]
            field name:               &str        [32 => 40]
            field balance:            u64         [40 => 48]
        }
    }
    

    The macro encoding_struct! Helps to declare an ordered structure and mark the boundaries of value fields. We need to change the wallet balance, therefore we will add methods to Wallet :

    impl Wallet {
        pubfnincrease(self, amount: u64) -> Self {
            let balance = self.balance() + amount;
            Self::new(self.pub_key(), self.name(), balance)
        }
        pubfndecrease(self, amount: u64) -> Self {
            let balance = self.balance() - amount;
            Self::new(self.pub_key(), self.name(), balance)
        }
    }
    

    You also need to create a key-value storage in MemoryDB. To do this, we use a fork to be able to roll back all changes as a last resort.

    pubstructCurrencySchema<'a> {
        view: &'amut Fork,
    }
    

    However, the fork gives access to any information in the database. To isolate wallets, add a unique prefix and use the MapIndex abstraction map .

    impl<'a> CurrencySchema<'a> {
        pubfnwallets(&mutself) -> MapIndex<&mut Fork, PublicKey, Wallet> {
            let prefix = blockchain::gen_prefix(SERVICE_ID, 0, &());
            MapIndex::new("cryptocurrency.wallets", self.view)
        }
        // Utility method to quickly get a separate wallet from the storagepubfnwallet(&mutself, pub_key: &PublicKey) -> Option<Wallet> {
            self.wallets().get(pub_key)
        }
    }
    

    We define transactions

    As already noted, for the operation of our educational example, we will need the following types of transactions : create a wallet and add funds to it, as well as transfer them to another wallet.

    A transaction to create a wallet must contain its public key and username.

    message! {
        structTxCreateWallet {
            const TYPE = SERVICE_ID;
            const ID = TX_CREATE_WALLET_ID;
            const SIZE = 40;
            field pub_key:     &PublicKey  [00 => 32]
            field name:        &str        [32 => 40]
        }
    }
    

    Before creating a wallet, we will check its uniqueness. We also credit 100 coins to it.

    impl Transaction for TxCreateWallet {
        fnverify(&self) -> bool {
            self.verify_signature(self.pub_key())
        }
        fnexecute(&self, view: &mut Fork) {
            letmut schema = CurrencySchema { view };
            if schema.wallet(self.pub_key()).is_none() {
                let wallet = Wallet::new(self.pub_key(),
                                         self.name(),
                                         INIT_BALANCE);
                println!("Create the wallet: {:?}", wallet);
                schema.wallets().put(self.pub_key(), wallet)
            }
        }
    }
    

    A transaction to transfer money looks like this:

    message! {
        structTxTransfer {
            const TYPE = SERVICE_ID;
            const ID = TX_TRANSFER_ID;
            const SIZE = 80;
            field from:        &PublicKey  [00 => 32]
            field to:          &PublicKey  [32 => 64]
            field amount:      u64         [64 => 72]
            field seed:        u64         [72 => 80]
        }
    }
    

    Two public keys are marked in it (for both wallets) and the number of coins that are transferred. The seed field is added so that the transaction cannot be repeated. You also need to verify that the sender does not send funds to himself:

    impl Transaction for TxTransfer {
        fnverify(&self) -> bool {
             (*self.from() != *self.to()) &&
                 self.verify_signature(self.from())
        }
        fnexecute(&self, view: &mut Fork) {
            letmut schema = CurrencySchema { view };
            let sender = schema.wallet(self.from());
            let receiver = schema.wallet(self.to());
            iflet (Some(mut sender), Some(mut receiver)) = (sender, receiver) {
                let amount = self.amount();
                if sender.balance() >= amount {
                    let sender.decrease(amount);
                    let receiver.increase(amount);
                    println!("Transfer between wallets: {:?} => {:?}",
                             sender,
                             receiver);
                    letmut wallets = schema.wallets();
                    wallets.put(self.from(), sender);
                    wallets.put(self.to(), receiver);
                }
            }
        }
    }
    

    In order for transactions to be displayed correctly in the blockchain block explorer, we also need to redefine the `info ()` method . The implementation will be the same for both types of transactions and will look like this:

    impl Transaction for TxCreateWallet {
        // `verify()` and `execute()` code...fninfo(&self) -> serde_json::Value {
           serde_json::to_value(&self)
               .expect("Cannot serialize transaction to JSON")
       }
    }
    

    We implement the transaction API

    To do this, create a structure with a channel and a blockchain instance, which will be necessary to implement read requests:

    #[derive(Clone)]structCryptocurrencyApi {
        channel: ApiSender,
        blockchain: Blockchain,
    }
    

    To simplify the processing of processes, we add the TransactionRequest enum , which combines both types of transactions: “create a wallet” and “transfer funds”.

    #[serde(untagged)]#[derive(Clone, Serialize, Deserialize)]enumTransactionRequest {
        CreateWallet(TxCreateWallet),
        Transfer(TxTransfer),
    }
    implInto<Box<Transaction>> for TransactionRequest {
        fninto(self) -> Box<Transaction> {
            matchself {
                TransactionRequest::CreateWallet(trans) => Box::new(trans),
                TransactionRequest::Transfer(trans) => Box::new(trans),
            }
        }
    }
    #[derive(Serialize, Deserialize)]structTransactionResponse {
        tx_hash: Hash,
    }
    

    It remains to "make friends" our handler with the HTTP handler of the web server. To do this, we implement the wire method . In the example below, we will add a handler that converts JSON input to Transaction.

    impl Api for CryptocurrencyApi {
        fnwire(&self, router: &mut Router) {
            let self_ = self.clone();
            let tx_handler = move |req: &mut Request| -> IronResult<Response> {
                match req.get::<bodyparser::Struct<TransactionRequest>>() {
                    Ok(Some(tx)) => {
                        let tx: Box<Transaction> = tx.into();
                        let tx_hash = tx.hash();
                        self_.channel.send(tx).map_err(ApiError::from)?;
                        let json = TransactionResponse { tx_hash };
                        self_.ok_response(&serde_json::to_value(&json).unwrap())
                    }
                    Ok(None) => Err(ApiError::IncorrectRequest(
                        "Empty request body".into()))?,
                    Err(e) => Err(ApiError::IncorrectRequest(Box::new(e)))?,
                }
            };
            // (Read request processing skipped)// Bind the transaction handler to a specific route.
            router.post("/v1/wallets/transaction", transaction, "transaction");
            // (Read request binding skipped)
        }
    }
    

    We implement the API for read requests

    In order to be able to verify that the transactions are actually being executed, we implement two types of read requests: return information about all the wallets of the system and return information only about a specific wallet corresponding to the public key.

    To do this, define a couple of methods in CryptocurrencyApi that will access the blockchain field to read information from the blockchain storage.

    impl CryptocurrencyApi {
        fnget_wallet(&self, pub_key: &PublicKey) -> Option<Wallet> {
            letmut view = self.blockchain.fork();
            letmut schema = CurrencySchema { view: &mut view };
            schema.wallet(pub_key)
        }
        fnget_wallets(&self) -> Option<Vec<Wallet>> {
            letmut view = self.blockchain.fork();
            letmut schema = CurrencySchema { view: &mut view };
            let idx = schema.wallets();
            let wallets: Vec<Wallet> = idx.values().collect();
            if wallets.is_empty() {
                None
            } else {
                Some(wallets)
            }
        }
    }
    

    It is worth paying attention to the fact that in this case we use the fork method, despite the fact that it gives write and read access to data (so as not to overload the example). In real conditions, it is advisable to use a read-only access format (referring to snapshots).

    Further, as for transactions, we add request processing using the get_wallets () and get_wallet () methods in CryptocurrencyApi :: wire () .

    impl Api for CryptocurrencyApi {
        fnwire(&self, router: &mut Router) {
            let self_ = self.clone();
            // (Transaction processing skipped)// Gets status of all wallets in the database.let self_ = self.clone();
            let wallets_info = move |_: &mut Request| -> IronResult<Response> {
                ifletSome(wallets) = self_.get_wallets() {
                    self_.ok_response(&serde_json::to_value(wallets).unwrap())
                } else {
                    self_.not_found_response(
                        &serde_json::to_value("Wallets database is empty")
                            .unwrap(),
                    )
                }
            };
            // Gets status of the wallet corresponding to the public key.let self_ = self.clone();
            let wallet_info = move |req: &mut Request| -> IronResult<Response> {
                // Get the hex public key as the last URL component;// return an error if the public key cannot be parsed.let path = req.url.path();
                let wallet_key = path.last().unwrap();
                let public_key = PublicKey::from_hex(wallet_key)
                    .map_err(ApiError::FromHex)?;
                ifletSome(wallet) = self_.get_wallet(&public_key) {
                    self_.ok_response(&serde_json::to_value(wallet).unwrap())
                } else {
                    self_.not_found_response(
                        &serde_json::to_value("Wallet not found").unwrap(),
                    )
                }
            };
            // (Transaction binding skipped)// Bind read request endpoints.
            router.get("/v1/wallets", wallets_info, "wallets_info");
            router.get("/v1/wallet/:pub_key", wallet_info, "wallet_info");
        }
     

    Define the service

    To turn the CurrencyService structure into a blockchain service, we must assign the Service property to it. It has two methods: service_name , which returns the name of our service, and service_id , which returns its unique ID.
    The tx_from_raw method will be used to deserialize transactions, and the public_api_handler method will be used to  create a REST Handler  for processing web requests to the site. It will apply the logic already defined in  CryptocurrencyApi .

    impl Service for CurrencyService {
        fnservice_name(&self) -> &'staticstr { "cryptocurrency" }
        fnservice_id(&self) -> u16 { SERVICE_ID }
        fntx_from_raw(&self, raw: RawTransaction)
            -> Result<Box<Transaction>, encoding::Error> {
            let trans: Box<Transaction> = match raw.message_type() {
                TX_TRANSFER_ID => Box::new(TxTransfer::from_raw(raw)?),
                TX_CREATE_WALLET_ID => Box::new(TxCreateWallet::from_raw(raw)?),
                _ => {
                    returnErr(encoding::Error::IncorrectMessageType {
                        message_type: raw.message_type()
                    });
                },
            };
            Ok(trans)
        }
        fnpublic_api_handler(&self, ctx: &ApiContext) -> Option<Box<Handler>> {
            letmut router = Router::new();
            let api = CryptocurrencyApi {
                channel: ctx.node_channel().clone(),
                blockchain: ctx.blockchain().clone(),
            };
            api.wire(&mut router);
            Some(Box::new(router))
        }
    }
    

    We have implemented all parts of our mini blockchain. Now it remains to add  CryptocyrrencyService  to the list of blockchain services and run the demo:

    let services: Vec<Box<Service>> = vec![
        Box::new(CurrencyService),
    ];
    cargo run
    

    Service testing

    Exonum allows you to test the operation of services. To do this, use the Sandbox package - it simulates the network. We can send a request to the node and receive a response, and then observe the changes taking place in the blockchain. Sandbox instance is created by the sandbox_with_services method , which allows specifying services for testing. For example, like this:

    let s = sandbox_with_services(vec![Box::new(CurrencyService::new()),
                                       Box::new(ConfigUpdateService::new())]);
    

    In general, Sandbox can simulate the process of receiving a message by a host, checking which host sent it and what was in it. Also, the “sandbox” can work with time, for example, simulate the expiration of any time period.

    Transaction Sending

    Now let's try to send several transactions in our blockchain demo. First, create a wallet. This is what the create-wallet-1.json file will look like 
 :

    {
      "body": {
        "pub_key": "03e657ae71e51be60a45b4bd20bcf79ff52f0c037ae6da0540a0e0066132b472",
        "name": "Johnny Doe"
      },
      "network_id": 0,
      "protocol_version": 0,
      "service_id": 1,
      "message_id": 1,
      "signature": "ad5efdb52e48309df9aa582e67372bb3ae67828c5eaa1a7a5e387597174055d315eaa7879912d0509acf17f06a23b7f13f242017b354f682d85930fa28240402"
    }
    

    Use the curl command to send the transaction over HTTP:

    curl -H "Content-Type: application/json" -X POST -d @create-wallet-1.json \
        http://127.0.0.1:8000/api/services/cryptocurrency/v1/wallets/transaction

    After that, in the console we will see that the wallet was created:

    Create the wallet: Wallet { pub_key: PublicKey(3E657AE),
                                name: "Johnny Doe", balance: 100 }
    

    The second wallet is formed in the same way. After its creation, we can transfer funds. The transfer-funds.json file looks like this:

    {
      "body": {
        "from": "03e657ae71e51be60a45b4bd20bcf79ff52f0c037ae6da0540a0e0066132b472",
        "to": "d1e877472a4585d515b13f52ae7bfded1ccea511816d7772cb17e1ab20830819",
        "amount": "10",
        "seed": "12623766328194547469"
      },
      "network_id": 0,
      "protocol_version": 0,
      "service_id": 1,
      "message_id": 2,
      "signature": "2c5e9eee1b526299770b3677ffd0d727f693ee181540e1914f5a84801dfd410967fce4c22eda621701c2b9c676ed62bc48df9c973462a8514ffb32bec202f103"
    }
    

    This transaction transfers 10 coins from the first wallet to the second. Send the command to the node using curl :

    curl -H "Content-Type: application/json" -X POST -d @transfer-funds.json \
        http://127.0.0.1:8000/api/services/cryptocurrency/v1/wallets/transaction

    The node will show that the amount has been successfully transferred:

    Transfer between wallets: Wallet { pub_key: PublicKey(3E657AE),
                                       name: "Johnny Doe", balance: 90 }
                           => Wallet { pub_key: PublicKey(D1E87747),
                                       name: "Janie Roe", balance: 110 }
    


    Now let's verify that the read request processing endpoint really works. We can request the status of both wallets in the system as follows:

    curl http://127.0.0.1:8000/api/services/cryptocurrency/v1/wallets

    This request will give out information about wallets in the following form:

    [
      {
        "balance": "90",
        "name": "Johnny Doe",
        "pub_key": "03e657ae71e51be60a45b4bd20bcf79ff52f0c037ae6da0540a0e0066132b472"
      },
      {
        "balance": "110",
        "name": "Janie Roe",
        "pub_key": "d1e877472a4585d515b13f52ae7bfded1ccea511816d7772cb17e1ab20830819"
      }
    ]
    

    The second endpoint also works. We can verify this by sending the following request:

    curl "http://127.0.0.1:8000/api/services/cryptocurrency/v1/wallet/\
    03e657ae71e51be60a45b4bd20bcf79ff52f0c037ae6da0540a0e0066132b472"

    We get the answer:

    {
      "balance": "90",
      "name": "Johnny Doe",
      "pub_key": "03e657ae71e51be60a45b4bd20bcf79ff52f0c037ae6da0540a0e0066132b472"
    }
    

    Thus, as part of our educational material, we figured out how a simple blockchain works with one validator. In the following posts, we will talk more about blockchain binding, node management and consensus in Exonum.

    Also popular now: