Advanced Cryptocurrency Tutorial: Service with Data Proofs¶
This tutorial is an extended version of the Cryptocurrency Tutorial. It shows how to create cryptographic proofs for data in Exonum and how to organize the corresponding data layout.
In this Exonum service we implement a cryptocurrency, which allows the following operations:
- creating a wallet
- replenishing the wallet balance
- transferring money between wallets
- obtaining cryptographic proofs of executed transactions
- reviewing wallets history.
You can view and download the full source code of this tutorial here.
Tip
We suggest that you try to launch the simpler service first before proceeding with this tutorial as some steps are omitted here for the sake of smooth exposition.
Unlike its predecessor, the tutorial contains a client part, which allows to interact with the service via any web browser.
Create Rust Project¶
To build the service you must the Rust compiler installed. For the frontend part, you will also require Node.js.
Create a crate with the exonum crate as a dependency.
cargo new cryptocurrency-advanced --lib
Add necessary dependencies to Cargo.toml
in the project directory:
[dependencies]
exonum = "1.0.0"
exonum-derive = "1.0.0"
exonum-merkledb = "1.0.0"
exonum-proto = "1.0.0"
exonum-cli = "1.0.0"
exonum-rust-runtime = "1.0.0"
failure = "0.1.5"
protobuf = "2.8.0"
serde = "1.0.0"
serde_derive = "1.0.0"
# `dev-dependencies` skipped for brevity...
[build-dependencies]
exonum-build = "1.0.0"
For convenience reasons we decided to divide the code into five submodules. Four
of them correspond to a certain part of the service business logic and one
describes Protobuf structures. Let’s announce them in the crate root (lib.rs
):
pub mod api;
pub mod proto;
pub mod schema;
pub mod transactions;
pub mod wallet;
Constants¶
Let’s define the constants to be used further:
/// Initial balance of the wallet.
pub const INITIAL_BALANCE: u64 = 100;
Declare Persistent Data¶
Similarly to a simple cryptocurrency, we need to declare the data that we
will store in our blockchain, i.e. the Wallet
type. Compared to the simple
demo, it will have additional fields:
message Wallet {
exonum.crypto.Hash owner = 1;
string name = 2;
uint64 balance = 3;
// Additional fields:
// Number of transactions related to the wallet.
uint64 history_len = 4;
// `Hash` of the Merkelized list of the transactions
// related to the wallet.
exonum.crypto.Hash history_hash = 5;
}
These additional fields allow to prove to a light client that specific transactions (and no other transactions) are related to the wallet, that is, have changed its balance.
Note that the owner
field has a different type compared to the simple
cryptocurrency. This is because we will use the general way to associate
wallets with the authorization info –
addresses,
which act similarly to addresses in Ethereum.
Since an address is the SHA-256 hash
of the authorization info, it is represented as Hash
in Protobuf.
After that we provide the Wallet
description in Rust in
src/wallet.rs
. The service will require this Rust definition to
validate
the type generated from the above Protobuf declaration:
use exonum::{crypto::Hash, runtime::CallerAddress as Address};
use exonum_derive::{BinaryValue, ObjectHash};
use exonum_proto::ProtobufConvert;
use crate::proto;
/// Wallet information stored in the database.
#[derive(Clone, Debug, ProtobufConvert, BinaryValue, ObjectHash)]
#[protobuf_convert(source = "proto::Wallet", serde_pb_convert)]
pub struct Wallet {
pub owner: Address,
pub name: String,
pub balance: u64,
pub history_len: u64,
pub history_hash: Hash,
}
We added serde_pb_convert
to have JSON representation of our structure
similar to Protobuf declarations, it helps the light client handle proofs
that contain the Wallet
structure.
We also implement a couple of auxiliary methods for Wallet
: a constructor
and a balance setter.
Wallet methods
impl Wallet {
/// Creates a new wallet.
pub fn new(
owner: Address,
name: &str,
balance: u64,
history_len: u64,
&history_hash: &Hash,
) -> Self {
Self {
owner,
name: name.to_owned(),
balance,
history_len,
history_hash,
}
}
/// Returns a copy of this wallet with updated balance.
pub fn set_balance(self, balance: u64, history_hash: &Hash) -> Self {
Self::new(
self.owner,
&self.name,
balance,
self.history_len + 1,
history_hash,
)
}
}
The setter is immutable; it consumes the old instance of the wallet and
produces a new instance with the
modified balance
field. It is called within mutable methods allowing
manipulations with the wallet that will be specified below.
Similar to the simple tutorial, we need to add Protobuf code generation to
our project. Therefore, in proto/mod.rs
we integrate the Protobuf-generated
files to the proto
module of our project:
#![allow(bare_trait_objects)]
pub use self::service::{CreateWallet, Issue, Transfer, Wallet};
include!(concat!(env!("OUT_DIR"), "/protobuf_mod.rs"));
use exonum::crypto::proto::*;
Next, we generate the corresponding Rust files. For this we add the following
code in the build.rs
script for the crate:
use exonum_build::ProtobufGenerator;
fn main() {
ProtobufGenerator::with_mod_name("protobuf_mod.rs")
.with_input_dir("src/proto")
.with_crypto()
.generate();
}
Create Schema¶
As we already mentioned in the simple cryptocurrency tutorial, schema is a structured view of the key-value storage used in Exonum. For this tutorial, we will be production-aware and divide the schema into 2 parts:
- private part, which will be available only to the crate
- public part, which will be exported from the crate and thus accessible to other services.
This split is similar to an idiomatic interface / implementation separation in Java. See Service Interaction for the reasoning behind this split.
#[derive(Debug, FromAccess)]
pub(crate) struct SchemaImpl<T: Access> {
/// Public part of the schema.
#[from_access(flatten)]
pub public: Schema<T>,
/// History for specific wallets.
pub wallet_history: Group<T, Address, ProofListIndex<T::Base, Hash>>,
}
/// Public part of the cryptocurrency schema.
#[derive(Debug, FromAccess, RequireArtifact)]
pub struct Schema<T: Access> {
/// Map of wallet keys to information about the corresponding account.
pub wallets: RawProofMapIndex<T::Base, Address, Wallet>,
}
Several things to note here:
from_access(flatten)
attribute acts likeserde(flatten)
, allowing to embed the public schema fields directly into the private schema. This ensures that public and private schemas cannot diverge.Group
declares an index group. In our case, indexes in the group are keyed by theAddress
(previously mentioned as unified authorization info).RawProofMapIndex
denotes that the index does not transform its keys, which is appropriate forAddress
keys because they are essentially hash digests.
We also declare some helper methods to access schema data more efficiently:
impl<T: Access> SchemaImpl<T> {
pub fn new(access: T) -> Self {
Self::from_root(access).unwrap()
}
pub fn wallet(&self, address: Address) -> Option<Wallet> {
self.public.wallets.get(&address)
}
}
Besides the new
constructor copied from the previous tutorial,
we define the wallet
getter.
Finally, we define some methods to modify schema data:
impl<T> SchemaImpl<T>
where
T: Access,
T::Base: RawAccessMut,
{
/// Increases balance of the wallet and appends new record to its history.
pub fn increase_wallet_balance(
&mut self,
wallet: Wallet,
amount: u64,
transaction: Hash,
) {
// actual implementation skipped
}
/// Decreases balance of the wallet and appends new record to its history.
pub fn decrease_wallet_balance(
&mut self,
wallet: Wallet,
amount: u64,
transaction: Hash,
) {
// actual implementation skipped
}
/// Creates a new wallet and appends the first record to its history.
pub fn create_wallet(
&mut self,
key: Address,
name: &str,
transaction: Hash,
) {
// actual implementation skipped
}
}
Note the T::Base: RawAccessMut
bound. T::Base
denotes the base
or raw access to the storage, which
underpins Access
. The bound expresses the requirement that this underlying
access (and thus, T
itself) is mutable.
Define Transactions¶
Define Transaction Structures¶
We need three types of transactions; apart from the old ones (“create a new wallet” and “transfer money between wallets”) we add a new transaction type that reimburses a wallet balance. We start with describing these transactions in Protobuf:
message Transfer {
exonum.crypto.Hash to = 1;
uint64 amount = 2;
uint64 seed = 3;
}
message Issue {
uint64 amount = 1;
uint64 seed = 2;
}
message CreateWallet {
string name = 1;
}
Like in Wallet.owner
, we use Hash
in Transfer.to
.
Based on the above Protobuf descriptions we prepare the corresponding
transaction descriptions in Rust.
We need to add serde_pb_convert
to Transfer
transaction derive attributes
to have JSON representation of this structure similar to Protobuf declarations.
Just as with the Wallet
structure, it helps the light client handle proofs
that contain Transfer
transaction.
Rust transaction declarations
#[derive(Clone, Debug)]
#[derive(ProtobufConvert, BinaryValue, ObjectHash)]
#[protobuf_convert(source = "proto::Transfer", serde_pb_convert)]
pub struct Transfer {
pub to: Address,
pub amount: u64,
pub seed: u64,
}
#[derive(Clone, Debug)]
#[derive(Serialize, Deserialize)]
#[derive(ProtobufConvert, BinaryValue, ObjectHash)]
#[protobuf_convert(source = "proto::Issue")]
pub struct Issue {
pub amount: u64,
pub seed: u64,
}
#[derive(Clone, Debug)]
#[derive(Serialize, Deserialize)]
#[derive(ProtobufConvert, BinaryValue, ObjectHash)]
#[protobuf_convert(source = "proto::CreateWallet")]
pub struct CreateWallet {
pub name: String,
}
Like in the simple tutorial, we aggregate transactions into a single interface, which our service will implement:
#[exonum_interface]
pub trait CryptocurrencyInterface<Ctx> {
type Output;
#[interface_method(id = 0)]
fn transfer(&self, ctx: Ctx, arg: Transfer) -> Self::Output;
#[interface_method(id = 1)]
fn issue(&self, ctx: Ctx, arg: Issue) -> Self::Output;
#[interface_method(id = 2)]
fn create_wallet(&self, ctx: Ctx, arg: CreateWallet) -> Self::Output;
}
Reporting Errors¶
Before implementing transaction logic we define the types of errors that might occur during their execution. The code is identical to the one in the simple cryptocurrency tutorial.
Error definitions
/// Error codes emitted by wallet transactions during execution.
#[derive(Debug, ExecutionFail)]
pub enum Error {
/// Wallet already exists.
WalletAlreadyExists = 0,
/// Sender doesn't exist.
SenderNotFound = 1,
/// Receiver doesn't exist.
ReceiverNotFound = 2,
/// Insufficient currency amount.
InsufficientCurrencyAmount = 3,
/// Sender are same as receiver.
SenderSameAsReceiver = 4,
}
Transaction Execution¶
The principle of transaction execution remains the
same
as in the previous tutorial. Namely,
we implement the service interface (CryptocurrencyInterface
)
for our service. The service type is declared in lib.rs
:
/// Cryptocurrency service implementation.
#[derive(Debug, ServiceDispatcher, ServiceFactory)]
#[service_dispatcher(implements("CryptocurrencyInterface"))]
#[service_factory(proto_sources = "proto")]
pub struct CryptocurrencyService;
impl Service for CryptocurrencyService {
// empty for now
}
The verification logic for CreateWallet
and Transfer
transactions
is similar to their predecessors.
CreateWallet transaction
fn create_wallet(
&self,
context: ExecutionContext<'_>,
arg: CreateWallet,
) -> Self::Output {
let (from, tx_hash) = extract_info(&context)?;
let mut schema = SchemaImpl::new(context.service_data());
if schema.wallet(from).is_none() {
let name = &arg.name;
schema.create_wallet(from, name, tx_hash);
Ok(())
} else {
Err(Error::WalletAlreadyExists.into())
}
}
The helper method extract_info
extracts address and the transaction hash
from the call. It is defined as follows:
fn extract_info(
context: &ExecutionContext<'_>,
) -> Result<(Address, Hash), ExecutionError> {
let tx_hash = context
.transaction_hash()
.ok_or(CommonError::UnauthorizedCaller)?;
let from = context.caller().address();
Ok((from, tx_hash))
}
Transfer transaction
fn transfer(
&self,
context: ExecutionContext<'_>,
arg: Transfer,
) -> Self::Output {
let (from, tx_hash) = extract_info(&context)?;
let mut schema = SchemaImpl::new(context.service_data());
let amount = arg.amount;
if from == arg.to {
return Err(Error::SenderSameAsReceiver.into());
}
let sender = schema.wallet(from).ok_or(Error::SenderNotFound)?;
let receiver = schema.wallet(arg.to).ok_or(Error::ReceiverNotFound)?;
if sender.balance < amount {
Err(Error::InsufficientCurrencyAmount.into())
} else {
schema.decrease_wallet_balance(sender, amount, tx_hash);
schema.increase_wallet_balance(receiver, amount, tx_hash);
Ok(())
}
}
Note that we no longer extract a public key from context.caller()
and panic
if the caller is not authenticated by a key.
Instead, we convert the caller to an address. This approach will never panic
and thus is applicable to any kind of authorization (e.g., via a service).
The remaining transaction, Issue
, is responsible for replenishment
of the wallet balance. We use increase_wallet_balance
to put money to the wallet and record a new wallet instance in
the blockchain state:
fn issue(&self, context: ExecutionContext<'_>, arg: Issue) -> Self::Output {
let (from, tx_hash) = extract_info(&context)?;
let mut schema = SchemaImpl::new(context.service_data());
if let Some(wallet) = schema.wallet(from) {
let amount = arg.amount;
schema.increase_wallet_balance(wallet, amount, tx_hash);
Ok(())
} else {
Err(Error::ReceiverNotFound.into())
}
}
Implement API¶
Next, we need to implement the node API. The API will allow us not only to send and obtain the data stored in the blockchain but also will provide proofs of the correctness of the returned data.
Data Structures¶
First, we list the structures used by API. We need to define WalletQuery
structure which describes what information we
need to pass to the node to get response with information about specific wallet:
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct WalletQuery {
/// Public key of the queried wallet.
pub pub_key: PublicKey,
}
Besides this we also declare structures that will be used for processing users’ requests:
#[derive(Debug, Serialize, Deserialize)]
pub struct WalletProof {
/// Proof of the whole wallets table.
pub to_table: MapProof<String, Hash>,
/// Proof of the specific wallet in this table.
pub to_wallet: MapProof<Address, Wallet, Raw>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WalletHistory {
/// Proof of the list of transaction hashes.
pub proof: ListProof<Hash>,
/// List of above transactions.
pub transactions: Vec<Verified<AnyTx>>,
}
WalletProof.to_table
is a proof of the wallets
index
from the service schema into
the aggregated MerkleDB state.
Due to how service storage is organized, the proven key will have
the form ${service_name}.wallets
, where service_name
is
the name of the cryptocurrency service.
The following structure is what a user receives as a response to his request. It is based on the previous auxiliary structures and contains the information on the wallet together with the proofs of existence of the wallet and the correctness of its history.
#[derive(Debug, Serialize, Deserialize)]
pub struct WalletInfo {
pub block_proof: BlockProof,
pub wallet_proof: WalletProof,
pub wallet_history: Option<WalletHistory>,
}
Retrieving Proof for a Wallet¶
Now let’s define the method that will allow us to obtain information on a particular wallet together with cryptographic proof of its existence. The proofs also allow to confirm existence of a particular transaction in the wallet history.
/// Public service API description.
#[derive(Debug, Clone, Copy)]
pub struct PublicApi;
impl PublicApi {
pub fn wallet_info(
state: &ServiceApiState<'_>,
query: WalletQuery,
) -> api::Result<WalletInfo> {
// Implementation is presented below.
}
}
We first get a proof for the wallets
table in the service schema:
let IndexProof {
block_proof,
index_proof,
..
} = state.data().proof_for_service_index("wallets").unwrap();
Note that you do not need to prefix the table name with the service name here – it is done automatically.
Next, we fetch a proof of existence of a particular wallet inside
the wallets
table and include both parts of the proof
into the WalletProof
structure:
let currency_schema = SchemaImpl::new(state.service_data());
let address = Address::from_key(pub_key);
let to_wallet = currency_schema.public.wallets.get_proof(address);
let wallet_proof = WalletProof {
to_table: index_proof,
to_wallet,
};
As the final step, we extract the proof for the wallet history. Note that the proof contains transaction hashes rather than the transactions themselves. The transactions are stored separately and are returned together with the proof for user’s reference. This allows user to check correctness of the provided proof.
We obtain the wallet history and the proof for all transaction hashes in it:
let wallet_history = wallet.map(|_| {
let history = currency_schema.wallet_history.get(&address);
let proof = history.get_range_proof(..);
Next, we obtain transaction data for each history hash and output transactions in an array:
let transactions = state.data().for_core().transactions();
let transactions = history
.iter()
.map(|tx_hash| transactions.get(&tx_hash).unwrap())
.collect();
WalletHistory {
proof,
transactions,
}
});
Ok(WalletInfo {
block_proof,
wallet_proof,
wallet_history,
})
Here, state.data().for_core()
returns the core schema (that is, schema
defined and managed in the core itself).
In this schema, transactions()
method gets the map between transaction hashes
and respective transactions.
We now have a complete proof for availability of a block in the blockchain, of a
certain wallet in the database and
said wallet’s history aggregated under the WalletInfo
structure.
Initialize Service Data¶
The endpoint handler above relies on the fact that the wallets
index
exists, which is reflected in the unwrap()
here:
state.data().proof_for_service_index("wallets").unwrap()
However, the index will not exist if no transactions of the service were executed! Without the index, we cannot retrieve a proof for its existence. We could return a proof of absence of the index from the endpoint handler, but this would complicate the endpoint design and the corresponding client checks.
We will use another option: initialize the index in the service constructor,
which is a part of the Service
trait.
impl Service for CryptocurrencyService {
fn initialize(
&self,
context: ExecutionContext<'_>,
_params: Vec<u8>,
) -> Result<(), ExecutionError> {
SchemaImpl::new(context.service_data());
Ok(())
}
}
With this explicit constructor, the wallets
index is guaranteed to exist
during API calls.
Wire API¶
We implement the wire
method in PublicApi
and define a single endpoint
within it:
impl PublicApi {
pub fn wire(builder: &mut ServiceApiBuilder) {
builder
.public_scope()
.endpoint("v1/wallets/info", Self::wallet_info);
}
}
Finally, we need to modify a previously empty Service
implementation
for our service to actually wire the API when the service is started:
impl Service for CryptocurrencyService {
// `initilize` method snipped...
fn wire_api(&self, builder: &mut ServiceApiBuilder) {
CryptocurrencyApi::wire(builder);
}
}
Default Instantiation Params¶
Similar to the previous tutorial, we define default identifiers for the service to aid with its instantiation:
impl DefaultInstance for CryptocurrencyService {
const INSTANCE_ID: InstanceId = 3;
const INSTANCE_NAME: &'static str = "crypto";
}
Running Service¶
We have now described all the structural parts of our demo. The last step is to
create a binary target via creating a main.rs
file and introduce the main
function
that will launch the blockchain with our service artifact:
use exonum_cli::NodeBuilder;
use exonum_cryptocurrency_advanced as cryptocurrency;
fn main() -> Result<(), failure::Error> {
exonum::helpers::init_logger().unwrap();
NodeBuilder::new()
.with_default_rust_service(CryptocurrencyService)
.run()
}
As soon as you launch the demo with the cargo run
command, the NodeBuilder
will start to configure the network,
i.e. generate nodes, create their public and private keys, exchange keys between
nodes, etc. The service will be instantiated with the default identifiers
defined as per DefaultInstance
implementation.
Note that unlike the previous tutorial, we use NodeBuilder::new()
rather
than NodeBuilder::development_node()
. The new
constructor takes arguments
from the command line to determine what exactly the node should do: run,
create initial node configuration, etc. That is, NodeBuilder::new()
is
more flexible and fit for full-scale applications. For example, it can be used
to set up and launch a multi-node network.
Tip
To run a single-node development network, launch the executable with
the run-dev
command:
cargo run -- run-dev
Use the --help
option to find out other commands, and <command> --help
to
get help on a specific command.
Conclusion¶
Good job! You have set up, described and launched an extended version of a fully functional Exonum service!
Next you can see how to interact with the service with the help of the Exonum light client.