Skip to content

Commit

Permalink
Side-by-side support for Tendermint 0.34 and 0.37 (#1193)
Browse files Browse the repository at this point in the history
* proto-compiler: Improve error reporting

Print out the prost-build error to display protoc output line by line
rather than as an unreadable Debug dump.

* Generate proto from tendermint 0.37.0-alpha.1

Also adapted the tendermint-abci crate as necessary.
Committing this helps take stock of the protocol changes since 0.34,
before multi-version support can be implemented.

* proto: sort includes in generated tendermint.rs

This normalizes the content of the module for protocol updates.

* proto: generate structs for 0.34 and 0.37

Modify proto-compiler to generate protobuf modules for both versions
of the Tendermint protocol. The different versions are disambiguated
by module paths, presently tendermint::v0_34 and tendermint::v0_37,
with the latest supported version reimported as tendermint::*.

* tendermint: Remove Evidence::ConflictingHeaders

This variant does not get expressed in RPC or protobuf,
seems like this protocol change was not taken as per
https://github.com/tendermint/tendermint/blob/91fba07e49cee43048fd761c8b2c2ec3c017acc8/docs/architecture/adr-047-handling-evidence-from-light-client.md

* Add serializers::allow_null

This should be used in preference to nullable where `nil` in the format
could be met as a quirk admitted by the Go implementation,
but otherwise the preferred form is some, possibly default value.

* Adapt domain types to v0.37 and add multi-version conversions.

* Multi-version protobuf support for request types

* Add ABCI response types for PrepareProposal, ProcessProposal

* Multi-version protobuf conversions for response types

* Fix conversion from account::Id to Bytes

* Fix compiler warnings

* light-client: Restore Supervisor::report_evidence

Keep it as a stub to avoid more extensive changes.

* Restore serialization of Block and Evidence

Define hand-written Serialize/Deserialize impls for Evidence.

* Fix clippy lints

* proto-compiler: Improve copying of generated files

- Filter out the empty non-Tendermint files.
- Improve use of the WalkDir iterator.
- Use OS-agnostic path construction.

* proto: Regenerate v0_34 with v0.34.22

* Add domain type for RemoteSignerError

With multi-protocol stuff, it should be done properly and conversions
defined from/to proto stuff.

* Define more multi-protocol conversions

Temporarily remove the import of crate::v0_37::* to the root of
tendermint-proto.

* p2p: version-qualify dependencies on protobuf

Where there are direct dependencies on the proto definitions,
clarify them with the protocol version for later audit.

* Make clippy happy

* Derive Eq where clippy suggests

* tendermint: version-specific Request and Response

Separate Request and Response definitions pertaining to
protocol versions v0.34 and v0.37, namespaced in top-level
modules v0_34 and v0_37 respectively.

* Remove last version aliases of Request, Response

Need to check where we depend in downstream branches.

* Re-generate protos with prost-build 0.11.4

Also update v0_34 to 0.34.24.

* rpc: add endpoints header and header_by_hash

Provided by v0.37 nodes.

* rpc: Separate Client traits for v0.34 and v0.37

Add new endpoints /header and /header_by_hash to
the v0_37::Client trait, and re-export it as new crate::Client.

* rpc: feature-gate the Client traits

The same way as the old Client trait was.

* xla/multi-tc-versionsupport/fix (#1254)

* tendermint: post-merge fix

* Add missing ConsensusRequest and ConsensusResponse mappings.

* abci: change serialization to unsigned varint

As the protocol used by tendermint-abci has been updated
to 0.37, (with no compat mode for 0.34 at the moment), the encoding
is changed to match.

* abci: changelog notice about varint encoding

This is a breaking change.

* rpc: multi-version support through generics

Introduce Dialect trait and data structures in modules dialect,
v0_34::dialect, v0_37::dialect, to realize differences in serialization
between RPC versions. Parameterize data structures that need to be
differently serialized by generic parameters containing the differences.

* rpc: remove generic default from request::Wrapper

* Implement both Client dialect traits for websocket

* Rework Event serialization with helper types

DialectEvent and its sub-field structs take over the dialectal generics,
while Event and its inner structs remain public types free from
serialization concerns.

* rpc: Refactor subscriptions to support dialects

* rpc: fix the fixture tests

Have to make DialectEvent and friends public for these
"integration" tests.

* rpc: fix websocket tests

* tendermint: Fix test_sign_bytes_compatibility

Badly merged code broke the test.

* Versioned type aliases for WebSocketClient

Also for WebSocketClientDriver.
The specializations for v0_37 are also re-exported under crate root,
providing some backward compatibility.

* Fix tools build

* Remove dialect parameter for SubscriptionClient

The trait does not need it and the Subscription public API is erased.
Only WebSocketClient needs the dialect parameter.

* Derive PartialEq, Eq on ProdCommitValidator

Also add test code that ensures ProdVerifier supports the common derived
traits.

* tendermint: restore Serialize impl on Event

Also on ABCI domain types containing Event instances.
This is needed for CLI.

* rpc: de-genericized result types

Add associated type Output to SimpleRequest to designate the
output type produced from the response. Implement so that the
output type is a generics-free Response, while the Result type for
the Request trait is a DialectResponse where it needs to be.

* rpc: make the client module public

The purpose is to expose the generic WebSocketClient and
WebSocketClientDriver types, though not under the crate root
that's reserved for the type aliases specialized for the
latest protocol version.

* rpc: rename DefaultDialect to LatestDialect

* rpc: dynamic compat mode for HttpClient

Instead of using statically selected, but mostly identical
Client traits to specify the protocol version, add the dynamic
compatibility mode parameter with enum type CompatMode,
and implement v0.34 RPC compatibility mode with its different
JSON serialization if the parameter specifies so.

* rpc: eliminate dialect Client traits

Get back to the single Client trait, with the clients supporting
protocol dialects through the compatibility mode selected at runtime.
Add CompatMode support to WebSocketClient and make the web socket
client types back non-generic.

HttpClient gets a set_compat_mode method to update the mode
on the fly. No such thing is provided for WebSocketClient, though,
because the compat mode is set into the subscription driver
and cannot be updated.

* rpc: unit test for CompatMode version parsing

* rpc: make WebSocketConfig struct more usable

Make the fileds public, derive Debug.

* rpc: expose CompatMode::from_version

* rpc: debug received events in WebSocketTransport

* Quick fix for running `rpc-probe` against a Comet 0.37 node

* Fix doc for overriding env variable in rpc-probe README

* rpc: added kvstore test fixtures for 0.37

Obtained with rpc-probe against a Docker image cometbft/cometbft:v0.37.x
(ID 4ab97039d4c42dd67e62ecfe72307e1552f9b9c0e48ec15958197d637f1fdde9).

The fixtures for 0.34 have been moved alongside.

* rpc: adjust some v0_37 tests to match fixtures

Some of the fixtures needed adjusting as well, due to
time-dependent assumptions made by tests, or randomness
that could not be guessed ahead of time (incoming/block_by_hash).

* rpc: fudge data in subscribe_newblock_1

Same as was done in the v0_34 fixtures in the past, it lets us test
parsing of the struct containing events.

* rpc: adjust kvstore_fixtures tests for v0_37

* rpc: fixed and expanded unit tests using fixtures

Use the versioned path to the fixture files,
and add a copy of tests for v0_37 which was previously not covered.

* abci: more sensible default impl of ABCI++ methods

Returning blank responses does not make a good migration path
for applications that are yet unaware of the ABCI++ proposal handling
phases.
Implement the simplest sensible behaviors by default for
Application::prepare_proposal and Application::process_proposal
to fulfill the specification.

* abci: overflow-proof impl of prepare_proposal

* rpc: improve CompatMode enum

Instead of Latest as a variant, have only the explicit list of supported
protocol versions as the enum variants. Get the latest via the associated const fn
CompatMode::latest().

* rpc: builder API for clients

Replace the *Config structs with a proper builder API for better
extensibility.

* Add missing serde bounds on WebSocketClientUrl

* Add missing Display impl to WebSocketClientUrl

* rpc: do version discovery in CLI

---------

Co-authored-by: xla <[email protected]>
Co-authored-by: Akosh Farkash <[email protected]>
Co-authored-by: Romain Ruetschi <[email protected]>
  • Loading branch information
4 people authored Mar 2, 2023
1 parent 6379c82 commit c017fe1
Show file tree
Hide file tree
Showing 334 changed files with 18,970 additions and 5,552 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
- [`tendermint`] Version-specific definitions for ABCI `Request` and `Response`
enums under `v0_34::abci` and `v0_37::abci`, containing only the method variants
present in each of the respective protocol versions.
`Request` and `Response` defined under `v0_37` are re-exported under
the non-versioned `abci` module name, but the `SetOption` variant is not present
in these latest versions of the enums.
([#1193](https://github.com/informalsystems/tendermint-rs/pull/1193))
- [`tendermint-abci`] Change the frame length encoding in the ABCI wire protocol
to unsigned varint, to correspond to the changes in Tendermint Core 0.37.
No compatibility with 0.34 is provided at the moment.
([#1193](https://github.com/informalsystems/tendermint-rs/pull/1193))
- [`tendermint-rpc`] Changed the signature of `WebSocketClient::new_with_config`
to accept a `WebSocketConfig` struct value rather than an `Option`.
([#1193](https://github.com/informalsystems/tendermint-rs/pull/1193))
- [`tendermint-proto`] The `serializers::evidence` module has been made private.
([#1193](https://github.com/informalsystems/tendermint-rs/pull/1193))
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
- [`tendermint-proto`] Generate prost bindings for Tendermint 0.34 and 0.37 side by side.
The version-specific structs are placed under the `tendermint::v0_34` and
`tendermint::v0_37` module namespaces, respectively. The names under
`tendermint::v0_37` are also re-exported under `tendermint`.
([#1193](https://github.com/informalsystems/tendermint-rs/pull/1193))
- [`tendermint`] New and updated ABCI domain types for Tendermint Core v0.37
([#1193](https://github.com/informalsystems/tendermint-rs/pull/1193)).
- [`tendermint`] Protobuf conversions provided for both `v0_34` and `v0_37`
versions of the generated [`tendermint-proto`] structs, where applicable.
([#1193](https://github.com/informalsystems/tendermint-rs/pull/1193)).
- [`tendermint-rpc`] Introduce `client::CompatMode`, enumerating protocol
compatibility modes specifying the RPC data encoding used by the client.
An `HttpClient` can be created with a selected mode specified in the new
`builder` API, or have the mode changed afterwards (usually after
version discovery) by the added `set_compat_mode` method.
For `WebSocketClient`, the mode can only be specified at creation via the new
`builder` API.
([#1193](https://github.com/informalsystems/tendermint-rs/pull/1193))
- [`tendermint-abci`] Port ABCI application support to 0.37 Tendermint Core API.
No legacy support for 0.34 is provided at the moment.
([#1193](https://github.com/informalsystems/tendermint-rs/pull/1193)).
72 changes: 63 additions & 9 deletions abci/src/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ pub mod echo;
#[cfg(feature = "kvstore-app")]
pub mod kvstore;

use tendermint_proto::abci::{
request::Value, response, Request, RequestApplySnapshotChunk, RequestBeginBlock,
RequestCheckTx, RequestDeliverTx, RequestEcho, RequestEndBlock, RequestInfo, RequestInitChain,
RequestLoadSnapshotChunk, RequestOfferSnapshot, RequestQuery, Response,
ResponseApplySnapshotChunk, ResponseBeginBlock, ResponseCheckTx, ResponseCommit,
ResponseDeliverTx, ResponseEcho, ResponseEndBlock, ResponseFlush, ResponseInfo,
ResponseInitChain, ResponseListSnapshots, ResponseLoadSnapshotChunk, ResponseOfferSnapshot,
ResponseQuery,
use tendermint_proto::v0_37::abci::{
request::Value, response, response_process_proposal, Request, RequestApplySnapshotChunk,
RequestBeginBlock, RequestCheckTx, RequestDeliverTx, RequestEcho, RequestEndBlock, RequestInfo,
RequestInitChain, RequestLoadSnapshotChunk, RequestOfferSnapshot, RequestPrepareProposal,
RequestProcessProposal, RequestQuery, Response, ResponseApplySnapshotChunk, ResponseBeginBlock,
ResponseCheckTx, ResponseCommit, ResponseDeliverTx, ResponseEcho, ResponseEndBlock,
ResponseFlush, ResponseInfo, ResponseInitChain, ResponseListSnapshots,
ResponseLoadSnapshotChunk, ResponseOfferSnapshot, ResponsePrepareProposal,
ResponseProcessProposal, ResponseQuery,
};

/// An ABCI application.
Expand Down Expand Up @@ -98,6 +99,54 @@ pub trait Application: Send + Clone + 'static {
) -> ResponseApplySnapshotChunk {
Default::default()
}

/// A stage where the application can modify the list of transactions
/// in the preliminary proposal.
///
/// The default implementation implements the required behavior in a
/// very naive way, removing transactions off the end of the list
/// until the limit on the total size of the transaction is met as
/// specified in the `max_tx_bytes` field of the request, or there are
/// no more transactions. It's up to the application to implement
/// more elaborate removal strategies.
///
/// This method is introduced in ABCI++.
fn prepare_proposal(&self, request: RequestPrepareProposal) -> ResponsePrepareProposal {
// Per the ABCI++ spec: if the size of RequestPrepareProposal.txs is
// greater than RequestPrepareProposal.max_tx_bytes, the Application
// MUST remove transactions to ensure that the
// RequestPrepareProposal.max_tx_bytes limit is respected by those
// transactions returned in ResponsePrepareProposal.txs.
let RequestPrepareProposal {
mut txs,
max_tx_bytes,
..
} = request;
let max_tx_bytes: usize = max_tx_bytes.try_into().unwrap_or(0);
let mut total_tx_bytes: usize = txs
.iter()
.map(|tx| tx.len())
.fold(0, |acc, len| acc.saturating_add(len));
while total_tx_bytes > max_tx_bytes {
if let Some(tx) = txs.pop() {
total_tx_bytes = total_tx_bytes.saturating_sub(tx.len());
} else {
break;
}
}
ResponsePrepareProposal { txs }
}

/// A stage where the application can accept or reject the proposed block.
///
/// The default implementation returns the status value of `ACCEPT`.
///
/// This method is introduced in ABCI++.
fn process_proposal(&self, _request: RequestProcessProposal) -> ResponseProcessProposal {
ResponseProcessProposal {
status: response_process_proposal::ProposalStatus::Accept as i32,
}
}
}

/// Provides a mechanism for the [`Server`] to execute incoming requests while
Expand Down Expand Up @@ -134,7 +183,12 @@ impl<A: Application> RequestDispatcher for A {
Value::ApplySnapshotChunk(req) => {
response::Value::ApplySnapshotChunk(self.apply_snapshot_chunk(req))
},
Value::SetOption(_) => response::Value::SetOption(Default::default()),
Value::PrepareProposal(req) => {
response::Value::PrepareProposal(self.prepare_proposal(req))
},
Value::ProcessProposal(req) => {
response::Value::ProcessProposal(self.process_proposal(req))
},
}),
}
}
Expand Down
14 changes: 7 additions & 7 deletions abci/src/application/kvstore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::{
};

use bytes::BytesMut;
use tendermint_proto::abci::{
use tendermint_proto::v0_37::abci::{
Event, EventAttribute, RequestCheckTx, RequestDeliverTx, RequestInfo, RequestQuery,
ResponseCheckTx, ResponseCommit, ResponseDeliverTx, ResponseInfo, ResponseQuery,
};
Expand Down Expand Up @@ -199,18 +199,18 @@ impl Application for KeyValueStoreApp {
r#type: "app".to_string(),
attributes: vec![
EventAttribute {
key: "key".to_string().into_bytes().into(),
value: key.to_string().into_bytes().into(),
key: "key".to_owned(),
value: key.to_owned(),
index: true,
},
EventAttribute {
key: "index_key".to_string().into_bytes().into(),
value: "index is working".to_string().into_bytes().into(),
key: "index_key".to_owned(),
value: "index is working".to_owned(),
index: true,
},
EventAttribute {
key: "noindex_key".to_string().into_bytes().into(),
value: "index is working".to_string().into_bytes().into(),
key: "noindex_key".to_owned(),
value: "index is working".to_owned(),
index: false,
},
],
Expand Down
19 changes: 5 additions & 14 deletions abci/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

use std::net::{TcpStream, ToSocketAddrs};

use tendermint_proto::abci::{
use tendermint_proto::v0_37::abci::{
request, response, Request, RequestApplySnapshotChunk, RequestBeginBlock, RequestCheckTx,
RequestCommit, RequestDeliverTx, RequestEcho, RequestEndBlock, RequestFlush, RequestInfo,
RequestInitChain, RequestListSnapshots, RequestLoadSnapshotChunk, RequestOfferSnapshot,
RequestQuery, RequestSetOption, ResponseApplySnapshotChunk, ResponseBeginBlock,
ResponseCheckTx, ResponseCommit, ResponseDeliverTx, ResponseEcho, ResponseEndBlock,
ResponseFlush, ResponseInfo, ResponseInitChain, ResponseListSnapshots,
ResponseLoadSnapshotChunk, ResponseOfferSnapshot, ResponseQuery, ResponseSetOption,
RequestQuery, ResponseApplySnapshotChunk, ResponseBeginBlock, ResponseCheckTx, ResponseCommit,
ResponseDeliverTx, ResponseEcho, ResponseEndBlock, ResponseFlush, ResponseInfo,
ResponseInitChain, ResponseListSnapshots, ResponseLoadSnapshotChunk, ResponseOfferSnapshot,
ResponseQuery,
};

use crate::{codec::ClientCodec, Error};
Expand Down Expand Up @@ -113,15 +113,6 @@ impl Client {
perform!(self, Commit, RequestCommit {})
}

/// Request that the application set an option to a particular value.
///
/// This request lacks specification and should not be used.
/// It will be removed in Tendermint Core v0.37.
#[deprecated(note = "The set_option ABCI method will be removed in Tendermint Core v0.37")]
pub fn set_option(&mut self, req: RequestSetOption) -> Result<ResponseSetOption, Error> {
perform!(self, SetOption, req)
}

/// Used during state sync to discover available snapshots on peers.
pub fn list_snapshots(&mut self) -> Result<ResponseListSnapshots, Error> {
perform!(self, ListSnapshots, RequestListSnapshots {})
Expand Down
19 changes: 4 additions & 15 deletions abci/src/codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use std::{

use bytes::{Buf, BufMut, BytesMut};
use prost::Message;
use tendermint_proto::abci::{Request, Response};
use tendermint_proto::v0_37::abci::{Request, Response};

use crate::error::Error;

Expand Down Expand Up @@ -130,7 +130,7 @@ where
message.encode(&mut buf).map_err(Error::encode)?;

let buf = buf.freeze();
encode_varint(buf.len() as u64, &mut dst);
prost::encoding::encode_varint(buf.len() as u64, &mut dst);
dst.put(buf);
Ok(())
}
Expand All @@ -142,11 +142,11 @@ where
{
let src_len = src.len();
let mut tmp = src.clone().freeze();
let encoded_len = match decode_varint(&mut tmp) {
let encoded_len = match prost::encoding::decode_varint(&mut tmp) {
Ok(len) => len,
// We've potentially only received a partial length delimiter
Err(_) if src_len <= MAX_VARINT_LENGTH => return Ok(None),
Err(e) => return Err(e),
Err(e) => return Err(Error::decode(e)),
};
let remaining = tmp.remaining() as u64;
if remaining < encoded_len {
Expand All @@ -164,14 +164,3 @@ where
Ok(Some(res))
}
}

// encode_varint and decode_varint will be removed once
// https://github.com/tendermint/tendermint/issues/5783 lands in Tendermint.
pub fn encode_varint<B: BufMut>(val: u64, mut buf: &mut B) {
prost::encoding::encode_varint(val << 1, &mut buf);
}

pub fn decode_varint<B: Buf>(mut buf: &mut B) -> Result<u64, Error> {
let len = prost::encoding::decode_varint(&mut buf).map_err(Error::decode)?;
Ok(len >> 1)
}
2 changes: 1 addition & 1 deletion abci/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! tendermint-abci errors

use flex_error::{define_error, DisplayError};
use tendermint_proto::abci::response::Value;
use tendermint_proto::v0_37::abci::response::Value;

define_error! {
Error {
Expand Down
2 changes: 1 addition & 1 deletion abci/tests/echo_app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
#[cfg(all(feature = "client", feature = "echo-app"))]
mod echo_app_integration {
use tendermint_abci::{ClientBuilder, EchoApp, ServerBuilder};
use tendermint_proto::abci::RequestEcho;
use tendermint_proto::v0_37::abci::RequestEcho;

#[test]
fn echo() {
Expand Down
2 changes: 1 addition & 1 deletion abci/tests/kvstore_app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ mod kvstore_app_integration {
use std::thread;

use tendermint_abci::{ClientBuilder, KeyValueStoreApp, ServerBuilder};
use tendermint_proto::abci::{RequestDeliverTx, RequestEcho, RequestQuery};
use tendermint_proto::v0_37::abci::{RequestDeliverTx, RequestEcho, RequestQuery};

#[test]
fn happy_path() {
Expand Down
14 changes: 5 additions & 9 deletions light-client/src/supervisor.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Supervisor and Handle implementation.

use crossbeam_channel as channel;
use tendermint::evidence::{ConflictingHeadersEvidence, Evidence};
use tendermint::evidence::Evidence;

use crate::{
errors::Error,
Expand Down Expand Up @@ -284,19 +284,15 @@ impl Supervisor {
}

/// Report the given evidence of a fork.
// TODO: rework to supply LightClientAttackEvidence data
fn report_evidence(
&mut self,
provider: PeerId,
primary: &LightBlock,
witness: &LightBlock,
_primary: &LightBlock,
_witness: &LightBlock,
) -> Result<(), Error> {
let evidence = ConflictingHeadersEvidence::new(
primary.signed_header.clone(),
witness.signed_header.clone(),
);

self.evidence_reporter
.report(Evidence::ConflictingHeaders(Box::new(evidence)), provider)
.report(Evidence::LightClientAttackEvidence, provider)
.map_err(Error::io)?;

Ok(())
Expand Down
2 changes: 1 addition & 1 deletion p2p/src/secret_connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use chacha20poly1305::{
use merlin::Transcript;
use rand_core::OsRng;
use subtle::ConstantTimeEq;
use tendermint_proto as proto;
use tendermint_proto::v0_37 as proto;
use tendermint_std_ext::TryClone;
use x25519_dalek::{EphemeralSecret, PublicKey as EphemeralPublic};

Expand Down
4 changes: 2 additions & 2 deletions p2p/src/secret_connection/amino_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use core::convert::TryFrom;
use prost_derive::Message;
use tendermint_proto as proto;
use tendermint_proto::v0_37 as proto;

use crate::error::Error;

Expand Down Expand Up @@ -36,7 +36,7 @@ impl AuthSigMessage {
}
}

impl TryFrom<AuthSigMessage> for tendermint_proto::p2p::AuthSigMessage {
impl TryFrom<AuthSigMessage> for proto::p2p::AuthSigMessage {
type Error = Error;

fn try_from(amino_msg: AuthSigMessage) -> Result<Self, Error> {
Expand Down
2 changes: 1 addition & 1 deletion p2p/src/secret_connection/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use std::convert::TryInto;

use prost::Message as _;
use tendermint_proto as proto;
use tendermint_proto::v0_37 as proto;
use x25519_dalek::PublicKey as EphemeralPublic;

#[cfg(feature = "amino")]
Expand Down
Loading

0 comments on commit c017fe1

Please sign in to comment.