This is the second post of a tutorial series on how to implement a Tezos protocol.
In the first post, we saw how to write, compile, register, activate and use an extremely simple protocol. We also looked at the interface between the protocol and the shell.
In this post, we consider a new protocol called demo_counter
which extends
demo_noops
from the first post in several ways.
- Blocks can contain simple operations, whose effects update the blockchain state.
- It is parameterized by protocol parameters passed at activation time.
- It defines REST services (a.k.a. RPCs), in addition to the generic ones already available from the shell.
- It defines a client library, extending
tezos-client
with protocol-specific commands.
A large part of this post is devoted to the client library. While this library is not part of the protocol per se, it is needed if we want to communicate with the node in any meaningful way.
This protocol and the client library also make use of additional libraries, such as command-line parsing tools, error monads, RPCs… Describing them in detail would be beyond the scope of this post, but we try to provide enough information to keep the post self-contained.
demo_counter
can be found in revision 254be33
of the master
branch on the
Tezos repository. demo_counter
is located in src/proto_demo_counter/
.
We refer to the first post for compilation instructions. In most cases, it should be enough to run
make build-deps
make
Protocol
The protocol is referred to by the hash
ProtoDemoCounterDemoCounterDemoCounterDemoCou4LSpdT
. We can check it is
indeed known by the node.
# tezos-admin-client list protocols
ProtoALphaALphaALphaALphaALphaALphaALphaALphaDdp3zK
ProtoDemoCounterDemoCounterDemoCounterDemoCou4LSpdT
ProtoDemoNoopsDemoNoopsDemoNoopsDemoNoopsDemo6XBoYp
ProtoGenesisGenesisGenesisGenesisGenesisGenesk612im
It is defined by several modules:
ls src/proto_demo_counter/lib_protocol/*.mli
lib_protocol/apply.mli lib_protocol/main.mli lib_protocol/receipt.mli
lib_protocol/error.mli lib_protocol/proto_operation.mli lib_protocol/services.mli
lib_protocol/header.mli lib_protocol/proto_params.mli lib_protocol/state.mli
Most protocol-specific types required in Main
are now
defined in separate modules.
block_header_data
is defined asHeader.t
,operation_receipt
is defined asReceipt.t
,operation_data
is defined asProto_operation.t
.
As for demo_noops
, block_header_data
is still a string, and fitness
is defined as the height of the chain.
More interesting are the protocol operations and the operation receipt.
The protocol defines three operations in Proto_operation.t
, which act on a
state State.t
stored in the protocol
context. As seen in the first post, in the Tezos model each operation is
applied to a context and can produce a new context, the context is a map
that can be seen as the current state of the blockchain. For proto_counter
,
the context maps a key "state"
to a serialized form of State.t
.
State
is simply a couple of nonnegative counters (which we can also view
as the balances of two accounts).
type t = { a : int32; b : int32 }
Operations are defined in Proto_operation
as
type t =
| IncrA
| IncrB
| Transfer of Int32.t (* transfer from A to B, possibly a negative amount *)
The module Apply
defines a function
val apply : State.t -> Proto_operation.t -> State.t option
that applies the operation as expected. Some operations may be invalid (and in this case apply
returns None
).
For instance, transfer requires that both counters stay nonnegative, and
increment operations require that counters don’t overflow.
Operation application is defined by the function Main.apply_operation
.
val apply_operation : validation_state -> operation ->
(validation_state * operation_receipt) tzresult Lwt.t
let apply_operation validation_state operation =
Logging.log_notice "apply_operation";
let { context ; fitness } = validation_state in
State.get_state context >>= fun state ->
match Apply.apply state operation.protocol_data with
| None -> Error_monad.fail Error.Invalid_operation
| Some state ->
let receipt = Receipt.create "operation applied successfully" in
State.update_state context state >>= fun context ->
return ({ context ; fitness }, receipt)
This is quite straightforward. If the application succeeds, fitness is left
unchanged and the resulting context contains the updated state. This function
also returns a receipt that describes the effect of the operation. In
this protocol, the receipt is simply a string, but it could be more
descriptive. If the application fails, an error is returned via an error monad. All protocol errors are
registered in Error
.
Protocol parameters
We saw in the first post that when a protocol is activated, we can
pass to it initialization parameters through a JSON value. This value
is provided by a user through a file argument,
e.g., protocol_parameters.json
, to the activation command.
The demo_noops
protocol did not take advantage of this feature, but demo_counter
uses a
JSON value of the form {'init_a': A, 'init_b': B}
, where A
and B
are the initial values of the counters.
The type of the protocol parameters and their encoding are defined in
Proto_params
.
By convention, the protocol parameters are stored in the context under
the key "protocol_parameters"
. The activation operation of the
genesis
protocol sets the parameters under this key, and they are
retrieved by demo_counter
in Main.init
.
RPC Services
The protocol implements two services.
/chains/main/blocks/head/counter/a
returns the value of countera
/chains/main/blocks/head/counter/b
returns the value of counterb
Services rely on the RPC_*
modules accessible through the
protocol environment.
Ultimately, these modules are implemented by the tezos-rpc
library.
Services are registered by the function Services.rpc_services
,
which is called by Main.rpc_services
at protocol activation.
val rpc_services : Updater.rpc_context RPC_directory.t
let rpc_services = Services.rpc_services
Compilation
Two libraries are compiled from the protocol code.
tezos-protocol-demo-counter
is linked to the client library (see below),tezos-embedded-protocol-demo-counter
is linked to the node (seesrc/bin_node/dune
).
Recall from the first post that the protocol hash and modules are
given in TEZOS_PROTOCOL
.
> cat src/proto_demo_counter/lib_protocol/TEZOS_PROTOCOL
{
"hash": "ProtoDemoCounterDemoCounterDemoCounterDemoCou4LSpdT",
"modules": ["Error", "Proto_params", "Header", "State",
"Proto_operation", "Receipt", "Apply", "Services", "Main"]
}
The compilation steps are given in a generic dune
file common to all
protocols, and a protocol-specific file dune.inc
that can be generated from
the TEZOS_PROTOCOL
file. We will discuss compilation at greater length in
the next blog post.
Client library
The client and the node interact using RPCs. In theory, we could write a
client for demo_counter
from scratch in any language, but it is convenient
to simply extend tezos-client
with a protocol-specific client library.
Hence, we can keep using generic features of tezos-client
, such as wallet
management, and simply add new commands specific to the new protocol.
Moreover, we can use well-tested OCaml libraries to conveniently call shell
RPCs, and we can access to the protocol code from the client, for instance
to access some of its datatypes or services.
For demo_counter
, the client library is tezos-client-demo-counter
and it is
defined in src/proto_demo_counter/lib_client/
. It is linked to
tezos-client
(see bin_client/dune
). It is composed of four modules
Client_proto_args
, Client_proto_commands
, Client_proto_main
, Protocol_client_context
which we will describe as we go.
We can check that demo_counter
is indeed known to the client.
# tezos-admin-client list understood protocols
ProtoDemoCou
ProtoALphaAL
ProtoGenesis
Note that Proto_demo_noops
isn’t in the list since it doesn’t have a client library.
User interface
demo_counter
adds a few new commands.
# tezos-client -p ProtoDemoCou man
...
Commands for protocol Demo_counter:
bake <message>
Bake a block
<message>: message in block header
incra
Increment A
incrb
Increment B
transfer <amount>
transfer from A to B
<amount>: amount taken from A and given to B (possibly negative)
get_a
Get A counter
get_b
Get B counter
...
Client/Node interaction
Typically, the client library interacts with the node in two ways.
- using shell services (through the
tezos-shell-services
library), - using protocol services (through the protocol library, i.e.
tezos-protocol-demo-counter
).
Let see how to use these libraries in practice.
Library tezos-protocol-demo-counter
The library tezos-protocol-demo-counter
contains the protocol code and
its environment. The modules of the protocol are grouped in a module
Protocol
. The environment is accessible from module Protocol.Environment
.
Typically, we want to use the datatypes defined in the protocol. For instance,
to build blocks, we need access to the type of operations and block header
data. We also want to access protocol-defined services. In our case,
we can get the counter values using the client stub Services.get_counter
.
Remark that although it is feasible, the client code should not use protocol functions who read or write the protocol context.
Library tezos-shell-services
This library defines client stubs to call shell RPC services. The
demo_counter
client library uses injection services and block services.
As an example, consider the (slightly simplified) function
Shell_services.Injection.block
defined in module Injection_services
in
lib_shell_services/
.
val block:
#RPC_context.simple -> MBytes.t -> Operation.t list list
-> Block_hash.t tzresult Lwt.t
RPC_context.simple
is the RPC context, whic identifies the server, deals with the networking aspects of the call, and serializes the transmitted values.Mbytes.t
contains the encoded block header.Operation.t
is the generic shell operation type.Block_hash.t
is the hash of the injected block.
This function calls the service /injection/block
and serializes
the parameters and the returned value as expected.
The client library uses two injection services.
Shell_services.Injection.block
to call/injection/block
,Shell_services.Injection.operation
to call/injection/operation
.
The other family of services, block services, is defined as a functor
parameterized by a protocol type Block_services.PROTO
defined in
lib_shell_services
. This allows the stubs to deserialize the JSON values
returned by the services, and to return the actual protocol types to the client.
In client_proto_commands.ml
, we instantiate the functor
Block_services.Make
with the Protocol
module (there are two occurrences because we need to provide a module for both the current and the next protocol).
module Demo_block_services = Block_services.Make(Protocol)(Protocol)
The client library uses the following stubs:
Demo_block_services.hash
to call/chains/main/blocks/head/hash
Demo_block_services.Mempool.pending_operations
to call/chains/main/mempool/pending_operations
Demo_block_services.Helpers.Preapply.block
to call/chains/main/blocks/head/helpers/preapply/block
Demo_block_services.Helpers.Preapply.operations
to call/chains/main/blocks/head/helpers/preapply/operations/
Commands implementation
Commands are implemented with the help of several libraries.
tezos-clic
is a command-line parsing library.tezos-client-commands
provides the registration function for new commands.client-base
defines notably theClient_context.full
class, which contains the client context (e.g. wallet, printing facilities, RPC context…).
The commands’ syntax is defined in the modules Client_proto_main
and
Client_proto_args
. Commands are registered using the function
Client_command.register
.
Commands behavior is implemented in module Client_proto_commands
. A command implementation
may use a value of type Client_context.full
, which is provided by
the registration function. Through this object, commands can access
the client and RPC contexts.
More precisely, our command implementations use a
Protocol_client_context.full
object, which is a specialized version of
Client_context.full
, defined in Protocol_client_context
.
Let us have a closer look to the block baking command Client_proto_commands.bake
.
let bake (cctxt : Protocol_client_context.full) message : unit tzresult Lwt.t =
Demo_block_services.Mempool.pending_operations cctxt ()
>>=? fun { applied; _} ->
let operations = List.map snd applied in
let block_header_data = Header.create message in
Demo_block_services.Helpers.Preapply.block cctxt [operations] ~protocol_data:block_header_data
>>=? fun (shell, preapply_result) ->
let block_header_data_encoded =
Data_encoding.Binary.to_bytes_exn Header.encoding block_header_data in
let header : Block_header.t = { shell ; protocol_data = block_header_data_encoded } in
let header_encoded = Data_encoding.Binary.to_bytes_exn Block_header.encoding header in
let preapply_result = List.hd preapply_result in
let operations = [List.map snd preapply_result.applied] in
Shell_services.Injection.block cctxt header_encoded operations
>>=? fun block_hash ->
cctxt#message "Injected block %a" Block_hash.pp_short block_hash
>>= fun () ->
return_unit
First, it retrieves the applied operations from the mempool using
Demo_block_services.Mempool.pending_operations
. It then uses
the pre-apply service and ask the node to build a block based on
the proposed operations and block header data. The block is then
encoded and sent to the node through the injection service.
Sample execution of the protocol
First we activate the protocol using protocol_parameters.json
defined as
{'init_a': 100, 'init_b': 100}
# tezos-client -block genesis activate protocol ProtoDemoCounterDemoCounterDemoCounterDemoCou4LSpdT with fitness 1 and key activator and parameters protocol_parameters.json --timestamp 2019-07-05T14:30:35Z
Injected BLf2cXRZKsby
This bakes block of level 1
, running protocol genesis
, with demo_counter
scheduled for the next block.
# tezos-client rpc get /chains/main/blocks/head/metadata
{ "protocol": "ProtoGenesisGenesisGenesisGenesisGenesisGenesk612im",
"next_protocol": "ProtoDemoCounterDemoCounterDemoCounterDemoCou4LSpdT",
"test_chain_status": { "status": "not_running" }, "max_operations_ttl": 0,
"max_operation_data_length": 100, "max_block_header_length": 100,
"max_operation_list_length": [ { "max_size": 1000 } ] }
Although the head is a genesis
block, demo_counter
has already been
activated and we can bake an empty block using the bake
command
from demo_counter
client library.
# tezos-client bake '"This is block 2"'
Injected block BLrQqbn13Vrb
We can check that the block was baked properly, in particular
the block header data has been set as expected. We can also
see the protocol state State.t
in the block metadata encoded
as specified by State.encoding
.
# tezos-client rpc get /chains/main/blocks/head/
{ "protocol": "ProtoDemoCounterDemoCounterDemoCounterDemoCou4LSpdT",
"chain_id": "NetXdQprcVkpaWU",
"hash": "BLrQqbn13VrbzUprxQypzAg6fc7YmsHaZvGwGrHJk8a4eG6e11B",
"header":
{ "level": 2, "proto": 1,
"predecessor": "BLf2cXRZKsbygWJdtf1PBbrSg8yHkNK39bgoApvdbYBd1EX9ung",
"timestamp": "2019-07-05T14:30:36Z", "validation_pass": 1,
"operations_hash":
"LLoaGLRPRx3Zf8kB4ACtgku8F4feeBiskeb41J1ciwfcXB3KzHKXc",
"fitness": [ "01", "0000000000000002" ],
"context": "CoVpDgKDiWZ9xcodUFng1C8oGvfXEqBCD5XxQjB8Jrwptkx3vHUB",
"demo_block_header_data": "This is block 2" },
"metadata":
{ "protocol": "ProtoDemoCounterDemoCounterDemoCounterDemoCou4LSpdT",
"next_protocol": "ProtoDemoCounterDemoCounterDemoCounterDemoCou4LSpdT",
"test_chain_status": { "status": "not_running" },
"max_operations_ttl": 0, "max_operation_data_length": 100,
"max_block_header_length": 100,
"max_operation_list_length": [ { "max_size": 1000 } ], "demo_a": 100,
"demo_b": 100 }, "operations": [ [] ] }
We now inject three operations using client commands.
tezos-client incra
Operation receipt: operation applied successfully
Injected: op5gBsE7EMi7
# tezos-client incrb
Operation receipt: operation applied successfully
Injected: oo2YhBbAY8Vr
# tezos-client transfer 10
Operation receipt: operation applied successfully
Injected: opJFLuHR98tf
The operations are known to the node, they appear as applied in the node mempool.
# tezos-client rpc get /chains/main/mempool/pending_operations
{ "applied":
[ { "hash": "op45sL79jASRf41kpL5NDDbAUnQeTfwgZpVnZi1sXy4Cj5x18m9",
"branch": "BLa7SnHxjHqPTsGSE2fi8sHBm39u9g6Psd9qPZm4rJCqhzHdkSp",
"IncrA": {} },
{ "hash": "opV6ZMR2z2ZZUSjetzPTknPisjN6x5eFCQnAWhuKNic6GsiRLW7",
"branch": "BLa7SnHxjHqPTsGSE2fi8sHBm39u9g6Psd9qPZm4rJCqhzHdkSp",
"IncrB": {} },
{ "hash": "oo1a3gwKnXFqaHuhpgMb5x69wm3mwbid4hn8Ry1iX8jbvXzQQs7",
"branch": "BLa7SnHxjHqPTsGSE2fi8sHBm39u9g6Psd9qPZm4rJCqhzHdkSp",
"Transfer": 10 } ], "refused": [], "branch_refused": [],
"branch_delayed": [], "unprocessed": [] }
We bake the third block.
tezos-client bake '"This is block 3"'
Injected block BLz4SrcTnBQU
We can see now that the three operations appear in the operations
section of the block, encoded as specified by Proto_operation.encoding
.
The receipt for each operation also appears in this section.
tezos-client rpc get /chains/main/blocks/head/
{ "protocol": "ProtoDemoCounterDemoCounterDemoCounterDemoCou4LSpdT",
"chain_id": "NetXdQprcVkpaWU",
"hash": "BLz4SrcTnBQUiXXks1FTzGR9d5MsX6F1mhZ2g23bHTcaQwJbk3S",
"header":
{ "level": 3, "proto": 1,
"predecessor": "BLrQqbn13VrbzUprxQypzAg6fc7YmsHaZvGwGrHJk8a4eG6e11B",
"timestamp": "2019-07-05T14:30:38Z", "validation_pass": 1,
"operations_hash":
"LLoZctr62cmk2pvVu2dqX5nv8rA7PHi3xRNGe6mbqmtAQPKtwHuKK",
"fitness": [ "01", "0000000000000003" ],
"context": "CoVXzytYqZcw4RQknAZJpK3RAFeLrzcZG2zDMdzuacpDPjX7YSor",
"demo_block_header_data": "This is block 3" },
"metadata":
{ "protocol": "ProtoDemoCounterDemoCounterDemoCounterDemoCou4LSpdT",
"next_protocol": "ProtoDemoCounterDemoCounterDemoCounterDemoCou4LSpdT",
"test_chain_status": { "status": "not_running" },
"max_operations_ttl": 0, "max_operation_data_length": 100,
"max_block_header_length": 100,
"max_operation_list_length": [ { "max_size": 1000 } ], "demo_a": 91,
"demo_b": 111 },
"operations":
[ [ { "protocol": "ProtoDemoCounterDemoCounterDemoCounterDemoCou4LSpdT",
"chain_id": "NetXdQprcVkpaWU",
"hash": "op5gBsE7EMi7gsR3xtSMMQms9XN8Pka5N1pT8XGuN1iP2siizkx",
"branch": "BLrQqbn13VrbzUprxQypzAg6fc7YmsHaZvGwGrHJk8a4eG6e11B",
"data": { "IncrA": {} },
"receipt":
{ "demo_operation_receipt": "operation applied successfully" } },
{ "protocol": "ProtoDemoCounterDemoCounterDemoCounterDemoCou4LSpdT",
"chain_id": "NetXdQprcVkpaWU",
"hash": "oo2YhBbAY8Vr2ASXj5k3PggXoxDVYWAQGvK1Sm22GhPFcXdvjQq",
"branch": "BLrQqbn13VrbzUprxQypzAg6fc7YmsHaZvGwGrHJk8a4eG6e11B",
"data": { "IncrB": {} },
"receipt":
{ "demo_operation_receipt": "operation applied successfully" } },
{ "protocol": "ProtoDemoCounterDemoCounterDemoCounterDemoCou4LSpdT",
"chain_id": "NetXdQprcVkpaWU",
"hash": "opJFLuHR98tfTTXGhsnMMjg6KxgVW8qx7LZ5q2nrhoySXySqZSS",
"branch": "BLrQqbn13VrbzUprxQypzAg6fc7YmsHaZvGwGrHJk8a4eG6e11B",
"data": { "Transfer": 10 },
"receipt":
{ "demo_operation_receipt": "operation applied successfully" } } ] ] }
We can finally test our two RPCs to query the counter values.
tezos-client rpc get /chains/main/blocks/head/counter/a
91
tezos-client rpc get /chains/main/blocks/head/counter/b
111
The node’s trace is similar to the one presented in the previous blog post. What we see in addition are three chunks of output of the form:
Jul 8 14:27:20 - demo-counter: begin_construction (mempool): pred_fitness = 01::0000000000000002 constructed fitness = 01::0000000000000003
Jul 8 14:27:20 - demo-counter: apply_operation
Jul 8 14:27:20 - demo-counter: finalize_block: fitness = 01::0000000000000003
Jul 8 14:27:20 - demo-counter: apply_operation
Jul 8 14:27:20 - demo-counter: apply_operation
Jul 8 14:27:20 - prevalidator.NetXdQprcVkpa.ProtoDemoCou_1: injecting operation op25ATABm3GS2AxZr8QFAaz9qor6JSqjYuYx4MSVXYtVZ86LaxF
Jul 8 14:27:20 - prevalidator.NetXdQprcVkpa.ProtoDemoCou_1: Pushed: 2019-07-08T12:27:20-00:00, Treated: 2019-07-08T12:27:20-00:00, Completed: 2019-07-08T12:27:20-00:00
The first 3 lines correspond to the call Demo_block_services.Helpers.Preapply.operations cctxt [op]
and the next four lines to the call Shell_services.Injection.operation
(both calls triggered by Client_proto_commands.inject_op
).
Finally, when the last block is created, we see the following output:
Jul 8 14:27:22 - demo-counter: begin_construction (block): pred_fitness = 01::0000000000000002 constructed fitness = 01::0000000000000003
Jul 8 14:27:22 - demo-counter: apply_operation
Jul 8 14:27:22 - demo-counter: apply_operation
Jul 8 14:27:22 - demo-counter: apply_operation
Jul 8 14:27:22 - demo-counter: finalize_block: fitness = 01::0000000000000003
Jul 8 14:27:22 - demo-counter: begin_application: pred_fitness = 01::0000000000000002 block_fitness = 01::0000000000000003
Jul 8 14:27:22 - demo-counter: apply_operation
Jul 8 14:27:22 - demo-counter: apply_operation
Jul 8 14:27:22 - demo-counter: apply_operation
Jul 8 14:27:22 - demo-counter: finalize_block: fitness = 01::0000000000000003
Jul 8 14:27:22 - validator.block: Block BMQFowYxF9WS6FjVRL2hMDEcV3LrViLFe9v4yo4W9tSPcyUq7Md successfully validated
Jul 8 14:27:22 - validator.block: Pushed: 2019-07-08T12:27:22-00:00, Treated: 2019-07-08T12:27:22-00:00, Completed: 2019-07-08T12:27:22-00:00
Here again, the chunks can be seen as being composed of two parts, one for Demo_block_services.Helpers.Preapply.block
and one for Shell_services.Injection.block
. In each of the parts apply_operation
is called three times, once for each operation included in the block.
This scenario can be reproduced using the following python test (this requires to install Python tools and libraries, as described here. It launches a node, and runs the client commands to activate the protocol and interact with it.
cd tests_python
mkdir tmp
pytest -s tests/test_proto_demo_counter.py --log-dir=tmp
The node trace appears in tmp/node0_0.txt
.
Conclusion
We presented a simple protocol demo_counter
which explores further the
interface between the shell and the protocol, and uses more features available
to the protocol developer such as RPC services. Besides, this protocol comes
with a library that extends tezos-client
with new commands to
interact with the protocol.
In the next post, we will present demo_account
, an account-based protocol,
where transactions and blocks must be signed in order to be valid.