diff --git a/Cargo.lock b/Cargo.lock index b249ce91d8..71f7e15d2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5167,10 +5167,12 @@ dependencies = [ "anyhow", "bytes", "ed25519-consensus", + "hex", "rand_core", "sha2 0.10.8", "tap", "tendermint", + "tendermint-proto", "tower", "tracing", ] @@ -5711,7 +5713,6 @@ dependencies = [ "tokio-stream", "tonic", "tracing", - "tracing-subscriber 0.3.18", "url", ] diff --git a/crates/core/app/tests/app_can_define_and_delegate_to_a_validator.rs b/crates/core/app/tests/app_can_define_and_delegate_to_a_validator.rs index fe1cf55adc..0027ee1408 100644 --- a/crates/core/app/tests/app_can_define_and_delegate_to_a_validator.rs +++ b/crates/core/app/tests/app_can_define_and_delegate_to_a_validator.rs @@ -2,6 +2,7 @@ use { self::common::{BuilderExt, TestNodeExt, ValidatorDataReadExt}, anyhow::anyhow, cnidarium::TempStorage, + common::TempStorageExt as _, decaf377_rdsa::{SigningKey, SpendAuth, VerificationKey}, penumbra_app::{ genesis::{self, AppState}, @@ -32,7 +33,7 @@ const EPOCH_DURATION: u64 = 8; async fn app_can_define_and_delegate_to_a_validator() -> anyhow::Result<()> { // Install a test logger, acquire some temporary storage, and start the test node. let guard = common::set_tracing_subscriber(); - let storage = TempStorage::new().await?; + let storage = TempStorage::new_with_penumbra_prefixes().await?; // Configure an AppState with slightly shorter epochs than usual. let app_state = AppState::Content( diff --git a/crates/core/app/tests/app_can_deposit_into_community_pool.rs b/crates/core/app/tests/app_can_deposit_into_community_pool.rs index c2b69a7d04..895e61014e 100644 --- a/crates/core/app/tests/app_can_deposit_into_community_pool.rs +++ b/crates/core/app/tests/app_can_deposit_into_community_pool.rs @@ -2,6 +2,7 @@ use { self::common::BuilderExt, anyhow::anyhow, cnidarium::TempStorage, + common::TempStorageExt as _, penumbra_app::{ genesis::{self, AppState}, server::consensus::Consensus, @@ -28,7 +29,7 @@ mod common; async fn app_can_deposit_into_community_pool() -> anyhow::Result<()> { // Install a test logger, and acquire some temporary storage. let guard = common::set_tracing_subscriber(); - let storage = TempStorage::new().await?; + let storage = TempStorage::new_with_penumbra_prefixes().await?; // Define our application state, and start the test node. let mut test_node = { diff --git a/crates/core/app/tests/app_can_disable_community_pool_spends.rs b/crates/core/app/tests/app_can_disable_community_pool_spends.rs index 4cfb3c6b3a..0854e3633f 100644 --- a/crates/core/app/tests/app_can_disable_community_pool_spends.rs +++ b/crates/core/app/tests/app_can_disable_community_pool_spends.rs @@ -2,6 +2,7 @@ use { self::common::ValidatorDataReadExt, anyhow::anyhow, cnidarium::TempStorage, + common::TempStorageExt as _, decaf377_rdsa::VerificationKey, penumbra_app::{ genesis::{AppState, Content}, @@ -49,7 +50,7 @@ const PROPOSAL_VOTING_BLOCKS: u64 = 3; async fn app_can_disable_community_pool_spends() -> anyhow::Result<()> { // Install a test logger, and acquire some temporary storage. let guard = common::set_tracing_subscriber(); - let storage = TempStorage::new().await?; + let storage = TempStorage::new_with_penumbra_prefixes().await?; // Define a helper to get the current community pool balance. let pool_balance = || async { storage.latest_snapshot().community_pool_balance().await }; diff --git a/crates/core/app/tests/app_can_propose_community_pool_spends.rs b/crates/core/app/tests/app_can_propose_community_pool_spends.rs index 2b7d31be0f..d8af8d1b07 100644 --- a/crates/core/app/tests/app_can_propose_community_pool_spends.rs +++ b/crates/core/app/tests/app_can_propose_community_pool_spends.rs @@ -2,6 +2,7 @@ use { self::common::ValidatorDataReadExt, anyhow::anyhow, cnidarium::TempStorage, + common::TempStorageExt as _, decaf377_rdsa::VerificationKey, penumbra_app::{ genesis::{AppState, Content}, @@ -49,7 +50,7 @@ const PROPOSAL_VOTING_BLOCKS: u64 = 3; async fn app_can_propose_community_pool_spends() -> anyhow::Result<()> { // Install a test logger, and acquire some temporary storage. let guard = common::set_tracing_subscriber(); - let storage = TempStorage::new().await?; + let storage = TempStorage::new_with_penumbra_prefixes().await?; // Define a helper to get the current community pool balance. let pool_balance = || async { storage.latest_snapshot().community_pool_balance().await }; diff --git a/crates/core/app/tests/app_can_spend_notes_and_detect_outputs.rs b/crates/core/app/tests/app_can_spend_notes_and_detect_outputs.rs index 21b40406ba..49d7b93018 100644 --- a/crates/core/app/tests/app_can_spend_notes_and_detect_outputs.rs +++ b/crates/core/app/tests/app_can_spend_notes_and_detect_outputs.rs @@ -2,6 +2,7 @@ use { self::common::BuilderExt, anyhow::anyhow, cnidarium::TempStorage, + common::TempStorageExt as _, penumbra_app::{ genesis::{self, AppState}, server::consensus::Consensus, @@ -27,7 +28,7 @@ mod common; async fn app_can_spend_notes_and_detect_outputs() -> anyhow::Result<()> { // Install a test logger, acquire some temporary storage, and start the test node. let guard = common::set_tracing_subscriber(); - let storage = TempStorage::new().await?; + let storage = TempStorage::new_with_penumbra_prefixes().await?; let mut test_node = { let app_state = AppState::Content( genesis::Content::default().with_chain_id(TestNode::<()>::CHAIN_ID.to_string()), diff --git a/crates/core/app/tests/app_can_sweep_a_collection_of_small_notes.rs b/crates/core/app/tests/app_can_sweep_a_collection_of_small_notes.rs index c182a4cca5..dfe096bdde 100644 --- a/crates/core/app/tests/app_can_sweep_a_collection_of_small_notes.rs +++ b/crates/core/app/tests/app_can_sweep_a_collection_of_small_notes.rs @@ -1,6 +1,7 @@ use { anyhow::Context, cnidarium::TempStorage, + common::TempStorageExt as _, penumbra_app::{ genesis::{AppState, Content}, server::consensus::Consensus, @@ -37,7 +38,7 @@ const COUNT: usize = SWEEP_COUNT + 1; async fn app_can_sweep_a_collection_of_small_notes() -> anyhow::Result<()> { // Install a test logger, and acquire some temporary storage. let guard = common::set_tracing_subscriber_with_env_filter("info".into()); - let storage = TempStorage::new().await?; + let storage = TempStorage::new_with_penumbra_prefixes().await?; // Instantiate a mock tendermint proxy, which we will connect to the test node. let proxy = penumbra_mock_tendermint_proxy::TestNodeProxy::new::(); diff --git a/crates/core/app/tests/app_can_undelegate_from_a_validator.rs b/crates/core/app/tests/app_can_undelegate_from_a_validator.rs index d927fd823f..82ec8b14b6 100644 --- a/crates/core/app/tests/app_can_undelegate_from_a_validator.rs +++ b/crates/core/app/tests/app_can_undelegate_from_a_validator.rs @@ -2,6 +2,7 @@ use { self::common::{BuilderExt, TestNodeExt, ValidatorDataReadExt}, anyhow::anyhow, cnidarium::TempStorage, + common::TempStorageExt as _, decaf377_fmd::Precision, penumbra_app::{ genesis::{self, AppState}, @@ -37,7 +38,7 @@ const UNBONDING_DELAY: u64 = 4; async fn app_can_undelegate_from_a_validator() -> anyhow::Result<()> { // Install a test logger, acquire some temporary storage, and start the test node. let guard = common::set_tracing_subscriber(); - let storage = TempStorage::new().await?; + let storage = TempStorage::new_with_penumbra_prefixes().await?; // Helper function to get the latest block height. let get_latest_height = || async { diff --git a/crates/core/app/tests/app_check_dex_vcb.rs b/crates/core/app/tests/app_check_dex_vcb.rs index 266e15f4a7..85d6a87f5a 100644 --- a/crates/core/app/tests/app_check_dex_vcb.rs +++ b/crates/core/app/tests/app_check_dex_vcb.rs @@ -25,7 +25,10 @@ use std::{ops::Deref, sync::Arc}; /// This bug was fixed in #4643. async fn dex_vcb_tracks_multiswap() -> anyhow::Result<()> { let mut rng = rand_chacha::ChaChaRng::seed_from_u64(1776); - let storage = TempStorage::new().await?.apply_default_genesis().await?; + let storage = TempStorage::new_with_penumbra_prefixes() + .await? + .apply_default_genesis() + .await?; let mut state = Arc::new(StateDelta::new(storage.latest_snapshot())); // Create the first swap: diff --git a/crates/core/app/tests/app_rejects_validator_definitions_with_invalid_auth_sigs.rs b/crates/core/app/tests/app_rejects_validator_definitions_with_invalid_auth_sigs.rs index 8242d817fa..5bc71e50ed 100644 --- a/crates/core/app/tests/app_rejects_validator_definitions_with_invalid_auth_sigs.rs +++ b/crates/core/app/tests/app_rejects_validator_definitions_with_invalid_auth_sigs.rs @@ -1,6 +1,7 @@ use { self::common::{BuilderExt, ValidatorDataReadExt}, cnidarium::TempStorage, + common::TempStorageExt as _, decaf377_rdsa::{SigningKey, SpendAuth, VerificationKey}, penumbra_app::{ genesis::{self, AppState}, @@ -23,7 +24,7 @@ mod common; async fn app_rejects_validator_definitions_with_invalid_auth_sigs() -> anyhow::Result<()> { // Install a test logger, and acquire some temporary storage. let guard = common::set_tracing_subscriber(); - let storage = TempStorage::new().await?; + let storage = TempStorage::new_with_penumbra_prefixes().await?; // Start the test node. let mut node = { diff --git a/crates/core/app/tests/app_reproduce_testnet_75_vcb_close.rs b/crates/core/app/tests/app_reproduce_testnet_75_vcb_close.rs index 17c3316a74..fcf7a4e21e 100644 --- a/crates/core/app/tests/app_reproduce_testnet_75_vcb_close.rs +++ b/crates/core/app/tests/app_reproduce_testnet_75_vcb_close.rs @@ -2,18 +2,19 @@ use { self::common::BuilderExt, anyhow::anyhow, cnidarium::TempStorage, + common::TempStorageExt as _, penumbra_app::{ genesis::{self, AppState}, server::consensus::Consensus, }, penumbra_asset::{Value, STAKING_TOKEN_ASSET_ID}, - penumbra_auction::StateReadExt as _, penumbra_auction::{ auction::{ dutch::{ActionDutchAuctionEnd, ActionDutchAuctionSchedule, DutchAuctionDescription}, AuctionNft, }, component::AuctionStoreRead, + StateReadExt as _, }, penumbra_keys::test_keys, penumbra_mock_client::MockClient, @@ -65,7 +66,7 @@ async fn app_can_reproduce_tesnet_75_vcb_close() -> anyhow::Result<()> { common::set_tracing_subscriber_with_env_filter(filter) }; - let storage = TempStorage::new().await?; + let storage = TempStorage::new_with_penumbra_prefixes().await?; let app_state = AppState::Content( genesis::Content::default().with_chain_id(TestNode::<()>::CHAIN_ID.to_string()), ); diff --git a/crates/core/app/tests/app_tracks_uptime_for_genesis_validator_missing_blocks.rs b/crates/core/app/tests/app_tracks_uptime_for_genesis_validator_missing_blocks.rs index 6d55488163..4678141941 100644 --- a/crates/core/app/tests/app_tracks_uptime_for_genesis_validator_missing_blocks.rs +++ b/crates/core/app/tests/app_tracks_uptime_for_genesis_validator_missing_blocks.rs @@ -2,6 +2,7 @@ use { self::common::{BuilderExt, ValidatorDataReadExt}, anyhow::Context, cnidarium::TempStorage, + common::TempStorageExt as _, penumbra_app::{ genesis::{self, AppState}, server::consensus::Consensus, @@ -18,7 +19,7 @@ mod common; async fn app_tracks_uptime_for_genesis_validator_missing_blocks() -> anyhow::Result<()> { // Install a test logger, acquire some temporary storage, and start the test node. let guard = common::set_tracing_subscriber(); - let storage = TempStorage::new().await?; + let storage = TempStorage::new_with_penumbra_prefixes().await?; // Start the test node. let mut node = { @@ -60,7 +61,7 @@ async fn app_tracks_uptime_for_genesis_validator_missing_blocks() -> anyhow::Res let height = 4; for i in 1..=height { node.block() - .with_signatures(Default::default()) + .without_signatures() .execute() .tap(|_| trace!(%i, "executing block with no signatures")) .instrument(error_span!("executing block with no signatures", %i)) diff --git a/crates/core/app/tests/app_tracks_uptime_for_genesis_validator_signing_blocks.rs b/crates/core/app/tests/app_tracks_uptime_for_genesis_validator_signing_blocks.rs index 9685a37395..309afa4256 100644 --- a/crates/core/app/tests/app_tracks_uptime_for_genesis_validator_signing_blocks.rs +++ b/crates/core/app/tests/app_tracks_uptime_for_genesis_validator_signing_blocks.rs @@ -2,6 +2,7 @@ use { self::common::{BuilderExt, ValidatorDataReadExt}, anyhow::Context, cnidarium::TempStorage, + common::TempStorageExt as _, penumbra_app::{ genesis::{self, AppState}, server::consensus::Consensus, @@ -18,7 +19,7 @@ mod common; async fn app_tracks_uptime_for_genesis_validator_missing_blocks() -> anyhow::Result<()> { // Install a test logger, acquire some temporary storage, and start the test node. let guard = common::set_tracing_subscriber(); - let storage = TempStorage::new().await?; + let storage = TempStorage::new_with_penumbra_prefixes().await?; // Start the test node. let mut node = { diff --git a/crates/core/app/tests/app_tracks_uptime_for_validators_only_once_active.rs b/crates/core/app/tests/app_tracks_uptime_for_validators_only_once_active.rs index 19674ae33e..de687b1356 100644 --- a/crates/core/app/tests/app_tracks_uptime_for_validators_only_once_active.rs +++ b/crates/core/app/tests/app_tracks_uptime_for_validators_only_once_active.rs @@ -1,6 +1,7 @@ use { self::common::{BuilderExt, TestNodeExt, ValidatorDataReadExt}, cnidarium::TempStorage, + common::TempStorageExt as _, decaf377_rdsa::{SigningKey, SpendAuth, VerificationKey}, penumbra_app::{ genesis::{self, AppState}, @@ -32,7 +33,7 @@ async fn app_tracks_uptime_for_validators_only_once_active() -> anyhow::Result<( // Install a test logger, acquire some temporary storage, and start the test node. let guard = common::set_tracing_subscriber(); - let storage = TempStorage::new().await?; + let storage = TempStorage::new_with_penumbra_prefixes().await?; // Configure an AppState with slightly shorter epochs than usual. let app_state = AppState::Content( diff --git a/crates/core/app/tests/common/ibc_tests/mod.rs b/crates/core/app/tests/common/ibc_tests/mod.rs new file mode 100644 index 0000000000..3fbc110b57 --- /dev/null +++ b/crates/core/app/tests/common/ibc_tests/mod.rs @@ -0,0 +1,136 @@ +use { + anyhow::{anyhow, Result}, + ibc_types::{ + core::client::Height, + lightclients::tendermint::{ + consensus_state::ConsensusState, header::Header as TendermintHeader, + }, + }, +}; + +mod relayer; +#[allow(unused_imports)] +pub use relayer::MockRelayer; + +mod node; +pub use node::TestNodeWithIBC; + +// TODO: this needs to move somewhere else +#[allow(dead_code)] +pub fn create_tendermint_header( + pk: &ed25519_consensus::VerificationKey, + prev_counterparty_consensus_state: Option<(Height, ConsensusState)>, + penumbra_proto::util::tendermint_proxy::v1::GetBlockByHeightResponse{block_id: _, block}: penumbra_proto::util::tendermint_proxy::v1::GetBlockByHeightResponse, +) -> Result { + let block = block.ok_or(anyhow!("no block"))?; + let header = block.header.ok_or(anyhow!("no header"))?; + + // the tendermint SignedHeader is non_exhaustive so we + // can't use struct syntax to instantiate it and have to do + // some annoying manual construction of the pb type instead. + let h: tendermint::block::Header = header.clone().try_into().expect("bad header"); + use tendermint_proto::v0_37::types::SignedHeader as RawSignedHeader; + let rsh: RawSignedHeader = RawSignedHeader { + header: Some(tendermint_proto::v0_37::types::Header { + version: Some(tendermint_proto::v0_37::version::Consensus { + block: header.version.as_ref().expect("version").block, + app: header.version.expect("version").app, + }), + chain_id: header.chain_id, + height: header.height.into(), + time: Some(tendermint_proto::google::protobuf::Timestamp { + seconds: header.time.as_ref().expect("time").seconds, + nanos: header.time.expect("time").nanos, + }), + last_block_id: header.last_block_id.clone().map(|a| { + tendermint_proto::v0_37::types::BlockId { + hash: a.hash, + part_set_header: a.part_set_header.map(|b| { + tendermint_proto::v0_37::types::PartSetHeader { + total: b.total, + hash: b.hash, + } + }), + } + }), + last_commit_hash: header.last_commit_hash.into(), + data_hash: header.data_hash.into(), + validators_hash: header.validators_hash.into(), + next_validators_hash: header.next_validators_hash.into(), + consensus_hash: header.consensus_hash.into(), + app_hash: header.app_hash.into(), + last_results_hash: header.last_results_hash.into(), + evidence_hash: header.evidence_hash.into(), + proposer_address: header.proposer_address.into(), + }), + commit: Some(tendermint_proto::v0_37::types::Commit { + // The commit is for the current height + height: header.height.into(), + round: 0.into(), + block_id: Some(tendermint_proto::v0_37::types::BlockId { + hash: h.hash().into(), + part_set_header: Some(tendermint_proto::v0_37::types::PartSetHeader { + total: 0, + hash: vec![], + }), + }), + signatures: block + .last_commit + .map(|lc| { + lc.signatures + .iter() + .map(|a| tendermint_proto::v0_37::types::CommitSig { + block_id_flag: a.block_id_flag, + validator_address: a.validator_address.clone(), + timestamp: Some(tendermint_proto::google::protobuf::Timestamp { + seconds: a.timestamp.as_ref().expect("time").seconds, + nanos: a.timestamp.clone().expect("time").nanos, + }), + signature: a.signature.clone(), + }) + .collect() + }) + .unwrap(), + }), + }; + + let signed_header = rsh.clone().try_into()?; + + // now get a SignedHeader + let pub_key = tendermint::PublicKey::from_raw_ed25519(pk.as_bytes()).expect("pub key present"); + let proposer_address = tendermint::account::Id::new( + ::digest(pk).as_slice()[0..20] + .try_into() + .expect(""), + ); + let validator_set = tendermint::validator::Set::new( + vec![tendermint::validator::Info { + address: proposer_address.try_into()?, + pub_key, + power: 1i64.try_into()?, + name: Some("test validator".to_string()), + proposer_priority: 1i64.try_into()?, + }], + // Same validator as proposer? + Some(tendermint::validator::Info { + address: proposer_address.try_into()?, + pub_key, + power: 1i64.try_into()?, + name: Some("test validator".to_string()), + proposer_priority: 1i64.try_into()?, + }), + ); + + // now we can make the Header + Ok(TendermintHeader { + signed_header, + validator_set: validator_set.clone(), + trusted_validator_set: validator_set.clone(), + trusted_height: prev_counterparty_consensus_state + .map(|cs| cs.0) + .unwrap_or_else(|| ibc_types::core::client::Height { + revision_number: 0, + revision_height: 1, + }), + }) +} diff --git a/crates/core/app/tests/common/ibc_tests/node.rs b/crates/core/app/tests/common/ibc_tests/node.rs new file mode 100644 index 0000000000..cb001cad59 --- /dev/null +++ b/crates/core/app/tests/common/ibc_tests/node.rs @@ -0,0 +1,190 @@ +use { + crate::common::{BuilderExt as _, TempStorageExt as _}, + anyhow::{anyhow, Context as _, Result}, + cnidarium::TempStorage, + ibc_proto::ibc::core::{ + channel::v1::query_client::QueryClient as IbcChannelQueryClient, + client::v1::query_client::QueryClient as IbcClientQueryClient, + connection::v1::query_client::QueryClient as IbcConnectionQueryClient, + }, + ibc_types::{ + core::{ + client::{ClientId, ClientType, Height}, + connection::{ChainId, ConnectionEnd, ConnectionId, Counterparty, Version}, + }, + lightclients::tendermint::consensus_state::ConsensusState, + }, + penumbra_app::{ + genesis::{self, AppState}, + server::consensus::Consensus, + }, + penumbra_ibc::{component::ClientStateReadExt as _, IBC_COMMITMENT_PREFIX}, + penumbra_keys::test_keys, + penumbra_mock_client::MockClient, + penumbra_mock_consensus::TestNode, + penumbra_proto::util::tendermint_proxy::v1::{ + tendermint_proxy_service_client::TendermintProxyServiceClient, GetStatusRequest, + }, + std::error::Error, + tap::{Tap, TapFallible}, + tendermint::v0_37::abci::{ConsensusRequest, ConsensusResponse}, + tokio::time, + tonic::transport::Channel, + tower_actor::Actor, + tracing::info, +}; + +// Contains some data from a single IBC connection + client for test usage. +// This might be better off as an extension trait or additional impl on the TestNode struct. +#[allow(unused)] +pub struct TestNodeWithIBC { + pub connection_id: ConnectionId, + pub client_id: ClientId, + pub chain_id: String, + pub counterparty: Counterparty, + pub version: Version, + pub signer: String, + pub connection: Option, + pub node: TestNode>>, + pub client: MockClient, + pub storage: TempStorage, + pub ibc_client_query_client: IbcClientQueryClient, + pub ibc_connection_query_client: IbcConnectionQueryClient, + pub _ibc_channel_query_client: IbcChannelQueryClient, + pub tendermint_proxy_service_client: TendermintProxyServiceClient, +} + +#[allow(unused)] +impl TestNodeWithIBC { + pub async fn new(suffix: &str) -> Result { + let chain_id = format!("{}-{}", TestNode::<()>::CHAIN_ID, suffix); + // Use the correct substores + let storage = TempStorage::new_with_penumbra_prefixes().await?; + // Instantiate a mock tendermint proxy, which we will connect to the test node. + let proxy = penumbra_mock_tendermint_proxy::TestNodeProxy::new::(); + + let node = { + let app_state = + AppState::Content(genesis::Content::default().with_chain_id(chain_id.clone())); + let consensus = Consensus::new(storage.as_ref().clone()); + TestNode::builder() + .single_validator() + .with_penumbra_auto_app_state(app_state)? + .on_block(proxy.on_block_callback()) + .init_chain(consensus) + .await + .tap_ok(|e| tracing::info!(hash = %e.last_app_hash_hex(), "finished init chain"))? + }; + + // Sync the mock client, using the test wallet's spend key, to the latest snapshot. + let client = MockClient::new(test_keys::SPEND_KEY.clone()) + .with_sync_to_storage(&storage) + .await? + .tap(|c| info!(client.notes = %c.notes.len(), "mock client synced to test storage")); + + // TODO: hacky lol + let (_other_suffix, index) = match suffix { + "a" => ("b", 0), + "b" => ("a", 1), + _ => unreachable!("update this hack"), + }; + let grpc_url = format!("http://127.0.0.1:808{}", index) // see #4517 + .parse::()? + .tap(|url| tracing::debug!(%url, "parsed grpc url")); + + println!("spawning gRPC..."); + // Spawn the node's RPC server. + let _rpc_server = { + let make_svc = penumbra_app::rpc::router( + storage.as_ref(), + proxy, + false, /*enable_expensive_rpc*/ + )? + .into_router() + .layer(tower_http::cors::CorsLayer::permissive()) + .into_make_service() + .tap(|_| println!("initialized rpc service")); + let [addr] = grpc_url + .socket_addrs(|| None)? + .try_into() + .expect("grpc url can be turned into a socket address"); + let server = axum_server::bind(addr).serve(make_svc); + tokio::spawn(async { server.await.expect("grpc server returned an error") }) + .tap(|_| println!("grpc server is running")) + }; + + time::sleep(time::Duration::from_secs(1)).await; + // Create an RPC server for each chain to respond to IBC-related queries. + let channel = Channel::from_shared(grpc_url.to_string()) + .with_context(|| "could not parse node URI")? + .connect() + .await + .with_context(|| "could not connect to grpc server") + .tap_err(|error| tracing::error!(?error, "could not connect to grpc server"))?; + + let ibc_connection_query_client = IbcConnectionQueryClient::new(channel.clone()); + let ibc_channel_query_client = IbcChannelQueryClient::new(channel.clone()); + let ibc_client_query_client = IbcClientQueryClient::new(channel.clone()); + let tendermint_proxy_service_client = TendermintProxyServiceClient::new(channel.clone()); + + let pk = node + .keyring() + .iter() + .next() + .expect("validator key in keyring") + .0; + let proposer_address = tendermint::account::Id::new( + ::digest(pk).as_slice()[0..20] + .try_into() + .expect(""), + ); + Ok(Self { + // the test relayer supports only a single connection on each chain as of now + connection_id: ConnectionId::new(0), + node, + client, + storage, + client_id: ClientId::new(ClientType::new("07-tendermint".to_string()), 0)?, + chain_id: chain_id.clone(), + counterparty: Counterparty { + client_id: ClientId::new(ClientType::new("07-tendermint".to_string()), 0)?, + connection_id: None, + prefix: IBC_COMMITMENT_PREFIX.to_owned(), + }, + version: Version::default(), + signer: hex::encode_upper(proposer_address), + connection: None, + ibc_connection_query_client, + _ibc_channel_query_client: ibc_channel_query_client, + ibc_client_query_client, + tendermint_proxy_service_client, + }) + } + + pub async fn get_latest_height(&mut self) -> Result { + let status: penumbra_proto::util::tendermint_proxy::v1::GetStatusResponse = self + .tendermint_proxy_service_client + .get_status(GetStatusRequest {}) + .await? + .into_inner(); + Ok(Height::new( + ChainId::chain_version(&self.chain_id), + status + .sync_info + .ok_or(anyhow!("no sync info"))? + .latest_block_height, + )?) + } + + pub async fn get_prev_counterparty_consensus_state( + &self, + client_id: &ClientId, + height: &Height, + ) -> Result> { + self.storage + .clone() + .latest_snapshot() + .prev_verified_consensus_state(client_id, height) + .await + } +} diff --git a/crates/core/app/tests/common/ibc_tests/relayer.rs b/crates/core/app/tests/common/ibc_tests/relayer.rs new file mode 100644 index 0000000000..f8296ed69d --- /dev/null +++ b/crates/core/app/tests/common/ibc_tests/relayer.rs @@ -0,0 +1,759 @@ +use { + super::TestNodeWithIBC, + crate::common::ibc_tests::create_tendermint_header, + anyhow::Result, + ibc_proto::ibc::core::{ + client::v1::{QueryClientStateRequest, QueryConsensusStateRequest}, + connection::v1::QueryConnectionRequest, + }, + ibc_types::{ + core::{ + client::msgs::{MsgCreateClient, MsgUpdateClient}, + commitment::{MerkleProof, MerkleRoot}, + connection::{ + msgs::{ + MsgConnectionOpenAck, MsgConnectionOpenConfirm, MsgConnectionOpenInit, + MsgConnectionOpenTry, + }, + ConnectionEnd, Counterparty, State as ConnectionState, Version, + }, + }, + lightclients::tendermint::{client_state::AllowUpdate, TrustThreshold}, + DomainType as _, + }, + penumbra_ibc::{ + component::ConnectionStateReadExt as _, IbcRelay, IBC_COMMITMENT_PREFIX, IBC_PROOF_SPECS, + }, + penumbra_proto::{util::tendermint_proxy::v1::GetBlockByHeightRequest, DomainType}, + penumbra_transaction::{TransactionParameters, TransactionPlan}, + std::time::Duration, + tendermint::Time, +}; +#[allow(unused)] +pub struct MockRelayer { + pub chain_a_ibc: TestNodeWithIBC, + pub chain_b_ibc: TestNodeWithIBC, +} + +#[allow(unused)] +impl MockRelayer { + pub async fn get_connection_states(&mut self) -> Result<(ConnectionState, ConnectionState)> { + let connection_on_a_response = self + .chain_a_ibc + .ibc_connection_query_client + .connection(QueryConnectionRequest { + connection_id: self.chain_a_ibc.connection_id.to_string(), + }) + .await? + .into_inner(); + let connection_on_b_response = self + .chain_b_ibc + .ibc_connection_query_client + .connection(QueryConnectionRequest { + connection_id: self.chain_b_ibc.connection_id.to_string(), + }) + .await? + .into_inner(); + + Ok( + match ( + connection_on_a_response.connection, + connection_on_b_response.connection, + ) { + (Some(connection_a), Some(connection_b)) => { + let connection_a: ConnectionEnd = connection_a.try_into().unwrap(); + let connection_b: ConnectionEnd = connection_b.try_into().unwrap(); + (connection_a.state, connection_b.state) + } + (None, None) => ( + ConnectionState::Uninitialized, + ConnectionState::Uninitialized, + ), + (None, Some(connection_b)) => { + let connection_b: ConnectionEnd = connection_b.try_into().unwrap(); + (ConnectionState::Uninitialized, connection_b.state) + } + (Some(connection_a), None) => { + let connection_a: ConnectionEnd = connection_a.try_into().unwrap(); + (connection_a.state, ConnectionState::Uninitialized) + } + }, + ) + } + + pub async fn _handshake(&mut self) -> Result<(), anyhow::Error> { + // The IBC connection handshake has four steps (Init, Try, Ack, Confirm). + // https://github.com/penumbra-zone/hermes/blob/a34a11fec76de3b573b539c237927e79cb74ec00/crates/relayer/src/connection.rs#L672 + // https://github.com/cosmos/ibc/blob/main/spec/core/ics-003-connection-semantics/README.md#opening-handshake + + let (a_state, b_state) = self.get_connection_states().await?; + assert!( + a_state == ConnectionState::Uninitialized && b_state == ConnectionState::Uninitialized + ); + + // 1: send the Init message to chain A + { + tracing::info!("Send Init to chain A"); + _build_and_send_connection_open_init(&mut self.chain_a_ibc, &mut self.chain_b_ibc) + .await?; + } + + let (a_state, b_state) = self.get_connection_states().await?; + assert!(a_state == ConnectionState::Init && b_state == ConnectionState::Uninitialized); + + // 2. send the OpenTry message to chain B + { + tracing::info!("send OpenTry to chain B"); + _build_and_send_connection_open_try(&mut self.chain_a_ibc, &mut self.chain_b_ibc) + .await?; + } + + let (a_state, b_state) = self.get_connection_states().await?; + assert!(a_state == ConnectionState::Init && b_state == ConnectionState::TryOpen); + + // 3. Send the OpenAck message to chain A + { + tracing::info!("send OpenAck to chain A"); + _build_and_send_connection_open_ack(&mut self.chain_a_ibc, &mut self.chain_b_ibc) + .await?; + } + + let (a_state, b_state) = self.get_connection_states().await?; + assert!(a_state == ConnectionState::Open && b_state == ConnectionState::TryOpen); + + // 4. Send the OpenConfirm message to chain B + { + tracing::info!("send OpenConfirm to chain B"); + _build_and_send_connection_open_confirm(&mut self.chain_a_ibc, &mut self.chain_b_ibc) + .await?; + } + + let (a_state, b_state) = self.get_connection_states().await?; + assert!(a_state == ConnectionState::Open && b_state == ConnectionState::Open); + + Ok(()) + } + + pub async fn _create_clients(&mut self) -> Result<(), anyhow::Error> { + // helper function to create client for chain B on chain A + async fn _create_client_inner( + chain_a_ibc: &mut TestNodeWithIBC, + chain_b_ibc: &mut TestNodeWithIBC, + ) -> Result<()> { + let pk = chain_b_ibc + .node + .keyring() + .iter() + .next() + .expect("validator key in keyring") + .0; + let proposer_address = tendermint::account::Id::new( + ::digest(pk).as_slice()[0..20] + .try_into() + .expect(""), + ); + let pub_key = + tendermint::PublicKey::from_raw_ed25519(pk.as_bytes()).expect("pub key present"); + let validator_set = tendermint::validator::Set::new( + vec![tendermint::validator::Info { + address: proposer_address.try_into()?, + pub_key, + power: 1i64.try_into()?, + name: Some("test validator".to_string()), + proposer_priority: 1i64.try_into()?, + }], + // Same validator as proposer? + Some(tendermint::validator::Info { + address: proposer_address.try_into()?, + pub_key, + power: 1i64.try_into()?, + name: Some("test validator".to_string()), + proposer_priority: 1i64.try_into()?, + }), + ); + let validators_hash = validator_set.hash(); + // Create the client for chain B on chain A. + let plan = { + let ibc_msg = IbcRelay::CreateClient(MsgCreateClient { + // Chain B will be signing messages to chain A + signer: chain_b_ibc.signer.clone(), + client_state: ibc_types::lightclients::tendermint::client_state::ClientState { + // Chain ID of the client state is for the counterparty + chain_id: chain_b_ibc.chain_id.clone().into(), + trust_level: TrustThreshold { + numerator: 1, + denominator: 3, + }, + trusting_period: Duration::from_secs(120_000), + unbonding_period: Duration::from_secs(240_000), + max_clock_drift: Duration::from_secs(5), + // The latest_height is for chain B + latest_height: chain_b_ibc.get_latest_height().await?, + // The ICS02 validation is hardcoded to expect 2 proof specs + // (root and substore, see [`penumbra_ibc::component::ics02_validation`]). + proof_specs: IBC_PROOF_SPECS.to_vec(), + upgrade_path: vec!["upgrade".to_string(), "upgradedIBCState".to_string()], + allow_update: AllowUpdate { + after_expiry: false, + after_misbehaviour: false, + }, + frozen_height: None, + } + .into(), + consensus_state: + ibc_types::lightclients::tendermint::consensus_state::ConsensusState { + timestamp: Time::now(), + root: MerkleRoot { + hash: chain_b_ibc.node.last_app_hash().to_vec(), + }, + next_validators_hash: validators_hash.into(), + } + .into(), + }) + .into(); + TransactionPlan { + actions: vec![ibc_msg], + // Now fill out the remaining parts of the transaction needed for verification: + memo: None, + detection_data: None, // We'll set this automatically below + transaction_parameters: TransactionParameters { + chain_id: chain_a_ibc.chain_id.clone(), + ..Default::default() + }, + } + }; + let tx = chain_a_ibc.client.witness_auth_build(&plan).await?; + + // Create the client for chain B on chain A. + chain_a_ibc + .node + .block() + .with_data(vec![tx.encode_to_vec()]) + .execute() + .await?; + + Ok(()) + } + + // Each chain will need a client created corresponding to its IBC connection with the other chain: + _create_client_inner(&mut self.chain_a_ibc, &mut self.chain_b_ibc).await?; + _create_client_inner(&mut self.chain_b_ibc, &mut self.chain_a_ibc).await?; + + Ok(()) + } + + pub async fn handshake(&mut self) -> Result<(), anyhow::Error> { + // Open a connection on each chain to the other chain. + // This is accomplished by following the ICS-003 spec for connection handshakes. + + // The Clients need to be created on each chain prior to the handshake. + self._create_clients().await?; + // The handshake is a multi-step process, this call will ratchet through the steps. + self._handshake().await?; + + Ok(()) + } +} + +// helper function to build UpdateClient to send to chain A +async fn _build_and_send_update_client( + chain_a_ibc: &mut TestNodeWithIBC, + chain_b_ibc: &mut TestNodeWithIBC, +) -> Result<()> { + tracing::info!( + "send update client for chain {} to chain {}", + chain_b_ibc.chain_id, + chain_a_ibc.chain_id + ); + + // Fetch validators from chain B + // Note: since there's no real tendermint running + // and this isn't implemented in the TendermintProxyServiceClient, + // we just fake it for the test + let chain_b_height = chain_b_ibc.get_latest_height().await?; + let chain_b_latest_block: penumbra_proto::util::tendermint_proxy::v1::GetBlockByHeightResponse = + chain_b_ibc + .tendermint_proxy_service_client + .get_block_by_height(GetBlockByHeightRequest { + height: chain_b_height.revision_height.try_into()?, + }) + .await? + .into_inner(); + + // Look up the last recorded consensus state for the counterparty client on chain A + let prev_counterparty_consensus_state = chain_a_ibc + .get_prev_counterparty_consensus_state(&chain_a_ibc.client_id, &chain_b_height) + .await?; + let plan = { + let ibc_msg = IbcRelay::UpdateClient(MsgUpdateClient { + signer: chain_b_ibc.signer.clone(), + client_id: chain_a_ibc.client_id.clone(), + client_message: create_tendermint_header( + chain_b_ibc + .node + .keyring() + .iter() + .next() + .expect("validator key in keyring") + .0, + prev_counterparty_consensus_state, + chain_b_latest_block, + )? + .into(), + }) + .into(); + TransactionPlan { + actions: vec![ibc_msg], + // Now fill out the remaining parts of the transaction needed for verification: + memo: None, + detection_data: None, // We'll set this automatically below + transaction_parameters: TransactionParameters { + chain_id: chain_a_ibc.chain_id.clone(), + ..Default::default() + }, + } + }; + let tx = chain_a_ibc.client.witness_auth_build(&plan).await?; + + // Execute the transaction, applying it to the chain state. + chain_a_ibc + .node + .block() + .with_data(vec![tx.encode_to_vec()]) + .execute() + .await?; + Ok(()) +} + +// Send an ACK message to chain A +// https://github.com/penumbra-zone/hermes/blob/a34a11fec76de3b573b539c237927e79cb74ec00/crates/relayer/src/connection.rs#L1126 +async fn _build_and_send_connection_open_ack( + chain_a_ibc: &mut TestNodeWithIBC, + chain_b_ibc: &mut TestNodeWithIBC, +) -> Result<()> { + let chain_b_connection_id = chain_b_ibc.connection_id.clone(); + let chain_a_connection_id = chain_a_ibc.connection_id.clone(); + let connection_of_a_on_b_response = chain_b_ibc + .ibc_connection_query_client + .connection(QueryConnectionRequest { + connection_id: chain_a_connection_id.to_string(), + }) + .await? + .into_inner(); + + // Build message(s) for updating client on source + _build_and_send_update_client(chain_a_ibc, chain_b_ibc).await?; + + let client_state_of_a_on_b_response = chain_b_ibc + .ibc_client_query_client + .client_state(QueryClientStateRequest { + client_id: chain_a_ibc.client_id.to_string(), + }) + .await? + .into_inner(); + let consensus_state_of_a_on_b_response = chain_b_ibc + .ibc_client_query_client + .consensus_state(QueryConsensusStateRequest { + client_id: chain_a_ibc.client_id.to_string(), + revision_number: 0, + revision_height: 0, + latest_height: true, + }) + .await? + .into_inner(); + + // Build message(s) for updating client on destination + _build_and_send_update_client(chain_b_ibc, chain_a_ibc).await?; + + let plan = { + // This mocks the relayer constructing a connection open try message on behalf + // of the counterparty chain. + // we can't directly construct this because one of the struct fields is private + // and it's not from this crate, but we _can_ create the proto type and then convert it! + let proto_ack = ibc_proto::ibc::core::connection::v1::MsgConnectionOpenAck { + connection_id: chain_a_ibc.connection_id.to_string(), + counterparty_connection_id: chain_b_connection_id.to_string(), + version: Some(Version::default().into()), + client_state: Some( + client_state_of_a_on_b_response + .clone() + .client_state + .unwrap(), + ), + proof_height: Some(connection_of_a_on_b_response.clone().proof_height.unwrap()), + proof_try: connection_of_a_on_b_response.proof, + proof_client: client_state_of_a_on_b_response.clone().proof, + proof_consensus: consensus_state_of_a_on_b_response.proof, + // consensus height of a on b (the height chain b's ibc client trusts chain a at) + consensus_height: Some( + ibc_types::lightclients::tendermint::client_state::ClientState::try_from( + client_state_of_a_on_b_response + .clone() + .client_state + .unwrap(), + )? + .latest_height + .into(), + ), + signer: chain_b_ibc.signer.clone(), + // optional field, don't include + host_consensus_state_proof: vec![], + }; + let ibc_msg = + IbcRelay::ConnectionOpenAck(MsgConnectionOpenAck::try_from(proto_ack)?).into(); + TransactionPlan { + actions: vec![ibc_msg], + // Now fill out the remaining parts of the transaction needed for verification: + memo: None, + detection_data: None, // We'll set this automatically below + transaction_parameters: TransactionParameters { + chain_id: chain_a_ibc.chain_id.clone(), + ..Default::default() + }, + } + }; + let tx = chain_a_ibc.client.witness_auth_build(&plan).await?; + + // Execute the transaction, applying it to the chain state. + let pre_tx_snapshot = chain_a_ibc.storage.latest_snapshot(); + chain_a_ibc + .node + .block() + .with_data(vec![tx.encode_to_vec()]) + .execute() + .await?; + let post_tx_snapshot = chain_a_ibc.storage.latest_snapshot(); + + // validate the connection state is now "OPEN" + { + // Connection should be in INIT pre-commit + let connection = pre_tx_snapshot + .get_connection(&chain_a_ibc.connection_id) + .await? + .ok_or_else(|| { + anyhow::anyhow!( + "no connection with the specified ID {} exists", + &chain_a_ibc.connection_id + ) + })?; + + assert_eq!(connection.state, ConnectionState::Init); + + // Post-commit, the connection should be in the "OPEN" state. + let connection = post_tx_snapshot + .get_connection(&chain_a_ibc.connection_id) + .await? + .ok_or_else(|| { + anyhow::anyhow!( + "no connection with the specified ID {} exists", + &chain_a_ibc.connection_id + ) + })?; + + assert_eq!(connection.state, ConnectionState::Open); + + chain_a_ibc.connection = Some(connection); + } + + Ok(()) +} + +// sends a ConnectionOpenConfirm message to chain B +// at this point, chain A is in OPEN and B is in TRYOPEN. +// afterwards, chain A will be in OPEN and chain B will be in OPEN. +async fn _build_and_send_connection_open_confirm( + chain_a_ibc: &mut TestNodeWithIBC, + chain_b_ibc: &mut TestNodeWithIBC, +) -> Result<()> { + // https://github.com/penumbra-zone/hermes/blob/a34a11fec76de3b573b539c237927e79cb74ec00/crates/relayer/src/connection.rs#L1296 + let chain_b_connection_id = chain_b_ibc.connection_id.clone(); + let connection_of_b_on_a_response = chain_a_ibc + .ibc_connection_query_client + .connection(QueryConnectionRequest { + connection_id: chain_b_connection_id.to_string(), + }) + .await? + .into_inner(); + + // Build message(s) for updating client on destination + _build_and_send_update_client(chain_b_ibc, chain_a_ibc).await?; + + let plan = { + // This mocks the relayer constructing a connection open try message on behalf + // of the counterparty chain. + let ibc_msg = IbcRelay::ConnectionOpenConfirm(MsgConnectionOpenConfirm { + conn_id_on_b: chain_b_ibc.connection_id.clone(), + proof_conn_end_on_a: MerkleProof::decode( + connection_of_b_on_a_response.clone().proof.as_slice(), + )?, + proof_height_on_a: connection_of_b_on_a_response + .proof_height + .unwrap() + .try_into()?, + signer: chain_a_ibc.signer.clone(), + }) + .into(); + TransactionPlan { + actions: vec![ibc_msg], + // Now fill out the remaining parts of the transaction needed for verification: + memo: None, + detection_data: None, // We'll set this automatically below + transaction_parameters: TransactionParameters { + chain_id: chain_b_ibc.chain_id.clone(), + ..Default::default() + }, + } + }; + let tx = chain_b_ibc.client.witness_auth_build(&plan).await?; + + // Execute the transaction, applying it to the chain state. + let pre_tx_snapshot = chain_b_ibc.storage.latest_snapshot(); + chain_b_ibc + .node + .block() + .with_data(vec![tx.encode_to_vec()]) + .execute() + .await?; + let post_tx_snapshot = chain_b_ibc.storage.latest_snapshot(); + + // validate the connection state is now "open" + { + // Connection should be in TRYOPEN pre-commit + let connection = pre_tx_snapshot + .get_connection(&chain_b_ibc.connection_id) + .await? + .ok_or_else(|| { + anyhow::anyhow!( + "no connection with the specified ID {} exists", + &chain_b_ibc.connection_id + ) + })?; + + assert_eq!(connection.state, ConnectionState::TryOpen); + + // Post-commit, the connection should be in the "OPEN" state. + let connection = post_tx_snapshot + .get_connection(&chain_b_ibc.connection_id) + .await? + .ok_or_else(|| { + anyhow::anyhow!( + "no connection with the specified ID {} exists", + &chain_b_ibc.connection_id + ) + })?; + + assert_eq!(connection.state, ConnectionState::Open); + + chain_b_ibc.connection = Some(connection); + } + + Ok(()) +} + +// helper function to build ConnectionOpenTry to send to chain B +// at this point chain A is in INIT state and chain B has no state +// after this, chain A will be in INIT and chain B will be in TRYOPEN state. +async fn _build_and_send_connection_open_try( + chain_a_ibc: &mut TestNodeWithIBC, + chain_b_ibc: &mut TestNodeWithIBC, +) -> Result<()> { + // https://github.com/penumbra-zone/hermes/blob/a34a11fec76de3b573b539c237927e79cb74ec00/crates/relayer/src/connection.rs#L1010 + // https://github.com/penumbra-zone/hermes/blob/main/crates/relayer/src/foreign_client.rs#L1144 + // Send an update to both sides to ensure they are up to date + // Build message(s) for updating client on source + _build_and_send_update_client(chain_a_ibc, chain_b_ibc).await?; + + // all proofs for a chain need to be at the same height + let chain_a_connection_id = chain_a_ibc.connection_id.clone(); + let chain_b_connection_id = chain_b_ibc.connection_id.clone(); + let client_state_of_b_on_a_response = chain_a_ibc + .ibc_client_query_client + .client_state(QueryClientStateRequest { + client_id: chain_b_ibc.client_id.to_string(), + }) + .await? + .into_inner(); + let connection_of_b_on_a_response = chain_a_ibc + .ibc_connection_query_client + .connection(QueryConnectionRequest { + connection_id: chain_b_connection_id.to_string(), + }) + .await? + .into_inner(); + let consensus_state_of_b_on_a_response = chain_a_ibc + .ibc_client_query_client + .consensus_state(QueryConsensusStateRequest { + client_id: chain_b_ibc.client_id.to_string(), + revision_number: 0, + revision_height: 0, + latest_height: true, + }) + .await? + .into_inner(); + + // Then construct the ConnectionOpenTry message + let proof_consensus_state_of_b_on_a = + MerkleProof::decode(consensus_state_of_b_on_a_response.clone().proof.as_slice())?; + let proof_client_state_of_b_on_a = + MerkleProof::decode(client_state_of_b_on_a_response.clone().proof.as_slice())?; + let proof_conn_end_on_a = + MerkleProof::decode(connection_of_b_on_a_response.clone().proof.as_slice())?; + // TODO: too side-effecty? + chain_b_ibc.counterparty.connection_id = Some(chain_a_ibc.connection_id.clone()); + chain_a_ibc.counterparty.connection_id = Some(chain_b_ibc.connection_id.clone()); + + // Build message(s) for updating client on destination + _build_and_send_update_client(chain_b_ibc, chain_a_ibc).await?; + + let client_state_b_on_a = + ibc_types::lightclients::tendermint::client_state::ClientState::try_from( + client_state_of_b_on_a_response + .clone() + .client_state + .unwrap(), + )?; + let plan = { + // This mocks the relayer constructing a connection open try message on behalf + // of the counterparty chain. + #[allow(deprecated)] + let ibc_msg = IbcRelay::ConnectionOpenTry(MsgConnectionOpenTry { + // Counterparty is chain A. + counterparty: Counterparty { + client_id: chain_a_ibc.client_id.clone(), + connection_id: Some(chain_a_connection_id.clone()), + prefix: IBC_COMMITMENT_PREFIX.to_owned(), + }, + delay_period: Duration::from_secs(1), + signer: chain_a_ibc.signer.clone(), + client_id_on_b: chain_b_ibc.client_id.clone(), + client_state_of_b_on_a: client_state_of_b_on_a_response + .client_state + .expect("client state present"), + versions_on_a: vec![Version::default()], + proof_conn_end_on_a, + proof_client_state_of_b_on_a, + proof_consensus_state_of_b_on_a, + proofs_height_on_a: client_state_of_b_on_a_response + .proof_height + .expect("proof height") + .try_into()?, + consensus_height_of_b_on_a: client_state_b_on_a.latest_height, + // this seems to be an optional proof + proof_consensus_state_of_b: None, + // deprecated + previous_connection_id: "".to_string(), + }) + .into(); + TransactionPlan { + actions: vec![ibc_msg], + // Now fill out the remaining parts of the transaction needed for verification: + memo: None, + detection_data: None, // We'll set this automatically below + transaction_parameters: TransactionParameters { + chain_id: chain_b_ibc.chain_id.clone(), + ..Default::default() + }, + } + }; + let tx = chain_b_ibc.client.witness_auth_build(&plan).await?; + + // Execute the transaction, applying it to the chain state. + let pre_tx_snapshot = chain_b_ibc.storage.latest_snapshot(); + chain_b_ibc + .node + .block() + .with_data(vec![tx.encode_to_vec()]) + .execute() + .await?; + let post_tx_snapshot = chain_b_ibc.storage.latest_snapshot(); + + // validate the connection state is now "tryopen" + { + // Connection should not exist pre-commit + assert!(pre_tx_snapshot + .get_connection(&chain_b_ibc.connection_id) + .await? + .is_none(),); + + // Post-commit, the connection should be in the "tryopen" state. + let connection = post_tx_snapshot + .get_connection(&chain_b_ibc.connection_id) + .await? + .ok_or_else(|| { + anyhow::anyhow!( + "no connection with the specified ID {} exists", + &chain_b_ibc.connection_id + ) + })?; + + assert_eq!(connection.state, ConnectionState::TryOpen); + + chain_b_ibc.connection = Some(connection); + } + + Ok(()) +} + +// helper function to build ConnectionOpenInit to chain A +async fn _build_and_send_connection_open_init( + chain_a_ibc: &mut TestNodeWithIBC, + chain_b_ibc: &mut TestNodeWithIBC, +) -> Result<()> { + let plan = { + let ibc_msg = IbcRelay::ConnectionOpenInit(MsgConnectionOpenInit { + client_id_on_a: chain_a_ibc.client_id.clone(), + counterparty: chain_a_ibc.counterparty.clone(), + version: Some(chain_a_ibc.version.clone()), + delay_period: Duration::from_secs(1), + signer: chain_b_ibc.signer.clone(), + }) + .into(); + TransactionPlan { + actions: vec![ibc_msg], + // Now fill out the remaining parts of the transaction needed for verification: + memo: None, + detection_data: None, // We'll set this automatically below + transaction_parameters: TransactionParameters { + chain_id: chain_a_ibc.chain_id.clone(), + ..Default::default() + }, + } + }; + let tx = chain_a_ibc.client.witness_auth_build(&plan).await?; + + // Execute the transaction, applying it to the chain state. + let pre_tx_snapshot = chain_a_ibc.storage.latest_snapshot(); + chain_a_ibc + .node + .block() + .with_data(vec![tx.encode_to_vec()]) + .execute() + .await?; + let post_tx_snapshot = chain_a_ibc.storage.latest_snapshot(); + + // validate the connection state is now "init" + { + // Connection should not exist pre-commit + assert!(pre_tx_snapshot + .get_connection(&chain_a_ibc.connection_id) + .await? + .is_none(),); + + // Post-commit, the connection should be in the "init" state. + let connection = post_tx_snapshot + .get_connection(&chain_a_ibc.connection_id) + .await? + .ok_or_else(|| { + anyhow::anyhow!( + "no connection with the specified ID {} exists", + &chain_a_ibc.connection_id + ) + })?; + + assert_eq!(connection.state.clone(), ConnectionState::Init); + + chain_a_ibc.connection = Some(connection.clone()); + } + + Ok(()) +} diff --git a/crates/core/app/tests/common/mod.rs b/crates/core/app/tests/common/mod.rs index 6774c878f5..fb9162e13c 100644 --- a/crates/core/app/tests/common/mod.rs +++ b/crates/core/app/tests/common/mod.rs @@ -28,3 +28,6 @@ mod test_node_ext; /// See [`ValidatorDataRead`][penumbra_stake::component::validator_handler::ValidatorDataRead], /// and [`ValidatorDataReadExt`]. mod validator_read_ext; + +/// Methods for testing IBC functionality. +pub mod ibc_tests; diff --git a/crates/core/app/tests/common/temp_storage_ext.rs b/crates/core/app/tests/common/temp_storage_ext.rs index 8ef1233b9d..efff367ca8 100644 --- a/crates/core/app/tests/common/temp_storage_ext.rs +++ b/crates/core/app/tests/common/temp_storage_ext.rs @@ -1,7 +1,7 @@ use { async_trait::async_trait, cnidarium::TempStorage, - penumbra_app::{app::App, genesis::AppState}, + penumbra_app::{app::App, genesis::AppState, SUBSTORE_PREFIXES}, std::ops::Deref, }; @@ -9,10 +9,15 @@ use { pub trait TempStorageExt: Sized { async fn apply_genesis(self, genesis: AppState) -> anyhow::Result; async fn apply_default_genesis(self) -> anyhow::Result; + async fn new_with_penumbra_prefixes() -> anyhow::Result; } #[async_trait] impl TempStorageExt for TempStorage { + async fn new_with_penumbra_prefixes() -> anyhow::Result { + TempStorage::new_with_prefixes(SUBSTORE_PREFIXES.to_vec()).await + } + async fn apply_genesis(self, genesis: AppState) -> anyhow::Result { // Check that we haven't already applied a genesis state: if self.latest_version() != u64::MAX { diff --git a/crates/core/app/tests/common/test_node_builder_ext.rs b/crates/core/app/tests/common/test_node_builder_ext.rs index b074232cb5..ed4357851c 100644 --- a/crates/core/app/tests/common/test_node_builder_ext.rs +++ b/crates/core/app/tests/common/test_node_builder_ext.rs @@ -26,7 +26,7 @@ pub trait BuilderExt: Sized { impl BuilderExt for Builder { type Error = anyhow::Error; - fn with_penumbra_auto_app_state(self, app_state: AppState) -> Result { + fn with_penumbra_auto_app_state(mut self, app_state: AppState) -> Result { let Self { keyring, .. } = &self; let mut content = match app_state { AppState::Content(c) => c, @@ -50,6 +50,11 @@ impl BuilderExt for Builder { content.shielded_pool_content.allocations.push(allocation); } + // Set the chain ID from the content + if !content.chain_id.is_empty() { + self.chain_id = Some(content.chain_id.clone()); + } + // Serialize the app state into bytes, and add it to the builder. let app_state = AppState::Content(content); serde_json::to_vec(&app_state) diff --git a/crates/core/app/tests/ibc_handshake.rs b/crates/core/app/tests/ibc_handshake.rs new file mode 100644 index 0000000000..1d576f7e11 --- /dev/null +++ b/crates/core/app/tests/ibc_handshake.rs @@ -0,0 +1,39 @@ +use { + common::ibc_tests::{MockRelayer, TestNodeWithIBC}, + tap::Tap, +}; + +mod common; + +/// Exercises that the IBC handshake succeeds. +#[tokio::test] +async fn ibc_handshake() -> anyhow::Result<()> { + // Install a test logger, and acquire some temporary storage. + let guard = common::set_tracing_subscriber(); + + // Set up some configuration for the two different chains we'll need to keep around. + let mut chain_a_ibc = TestNodeWithIBC::new("a").await?; + let mut chain_b_ibc = TestNodeWithIBC::new("b").await?; + + // The two chains can't IBC handshake during the first block, let's fast forward + // them both a few. + for _ in 0..3 { + chain_a_ibc.node.block().execute().await?; + } + // Do them each a different # of blocks to make sure the heights don't get mixed up. + for _ in 0..42 { + chain_b_ibc.node.block().execute().await?; + } + + // The Relayer will handle IBC operations and manage state for the two test chains + let mut relayer = MockRelayer { + chain_a_ibc, + chain_b_ibc, + }; + + // Perform the IBC connection handshake between the two chains. + // TODO: some testing of failure cases of the handshake process would be good + relayer.handshake().await?; + + Ok(()).tap(|_| drop(relayer)).tap(|_| drop(guard)) +} diff --git a/crates/core/app/tests/mock_consensus_can_define_a_genesis_validator.rs b/crates/core/app/tests/mock_consensus_can_define_a_genesis_validator.rs index da66bd8575..a0077ae8f8 100644 --- a/crates/core/app/tests/mock_consensus_can_define_a_genesis_validator.rs +++ b/crates/core/app/tests/mock_consensus_can_define_a_genesis_validator.rs @@ -2,6 +2,7 @@ use { self::common::{BuilderExt, ValidatorDataReadExt}, anyhow::anyhow, cnidarium::TempStorage, + common::TempStorageExt as _, penumbra_app::{ genesis::{self, AppState}, server::consensus::Consensus, @@ -19,7 +20,7 @@ mod common; async fn mock_consensus_can_define_a_genesis_validator() -> anyhow::Result<()> { // Install a test logger, acquire some temporary storage, and start the test node. let guard = common::set_tracing_subscriber(); - let storage = TempStorage::new().await?; + let storage = TempStorage::new_with_penumbra_prefixes().await?; let test_node = { let app_state = AppState::Content( genesis::Content::default().with_chain_id(TestNode::<()>::CHAIN_ID.to_string()), diff --git a/crates/core/app/tests/mock_consensus_can_send_a_sequence_of_empty_blocks.rs b/crates/core/app/tests/mock_consensus_can_send_a_sequence_of_empty_blocks.rs index cf66c15fdc..b0461b3d69 100644 --- a/crates/core/app/tests/mock_consensus_can_send_a_sequence_of_empty_blocks.rs +++ b/crates/core/app/tests/mock_consensus_can_send_a_sequence_of_empty_blocks.rs @@ -1,6 +1,7 @@ use { self::common::BuilderExt, cnidarium::TempStorage, + common::TempStorageExt as _, penumbra_app::{ genesis::{self, AppState}, server::consensus::Consensus, @@ -18,7 +19,7 @@ mod common; async fn mock_consensus_can_send_a_sequence_of_empty_blocks() -> anyhow::Result<()> { // Install a test logger, acquire some temporary storage, and start the test node. let guard = common::set_tracing_subscriber(); - let storage = TempStorage::new().await?; + let storage = TempStorage::new_with_penumbra_prefixes().await?; let mut test_node = { let app_state = AppState::Content( genesis::Content::default().with_chain_id(TestNode::<()>::CHAIN_ID.to_string()), diff --git a/crates/core/app/tests/spend.rs b/crates/core/app/tests/spend.rs index 703c41097c..94c9f63caf 100644 --- a/crates/core/app/tests/spend.rs +++ b/crates/core/app/tests/spend.rs @@ -26,7 +26,10 @@ use tendermint::abci; async fn spend_happy_path() -> anyhow::Result<()> { let mut rng = rand_chacha::ChaChaRng::seed_from_u64(1312); - let storage = TempStorage::new().await?.apply_default_genesis().await?; + let storage = TempStorage::new_with_penumbra_prefixes() + .await? + .apply_default_genesis() + .await?; let mut state = Arc::new(StateDelta::new(storage.latest_snapshot())); let height = 1; @@ -102,7 +105,7 @@ async fn spend_happy_path() -> anyhow::Result<()> { async fn invalid_dummy_spend() { let mut rng = rand_chacha::ChaChaRng::seed_from_u64(1312); - let storage = TempStorage::new() + let storage = TempStorage::new_with_penumbra_prefixes() .await .unwrap() .apply_default_genesis() @@ -203,7 +206,7 @@ async fn invalid_dummy_spend() { async fn spend_duplicate_nullifier_previous_transaction() { let mut rng = rand_chacha::ChaChaRng::seed_from_u64(1312); - let storage = TempStorage::new() + let storage = TempStorage::new_with_penumbra_prefixes() .await .expect("can start new temp storage") .apply_default_genesis() @@ -294,7 +297,7 @@ async fn spend_duplicate_nullifier_previous_transaction() { async fn spend_duplicate_nullifier_same_transaction() { let mut rng = rand_chacha::ChaChaRng::seed_from_u64(1312); - let storage = TempStorage::new() + let storage = TempStorage::new_with_penumbra_prefixes() .await .expect("can start new temp storage") .apply_default_genesis() diff --git a/crates/core/app/tests/swap_and_swap_claim.rs b/crates/core/app/tests/swap_and_swap_claim.rs index 717f51bc4e..6cf1d8de45 100644 --- a/crates/core/app/tests/swap_and_swap_claim.rs +++ b/crates/core/app/tests/swap_and_swap_claim.rs @@ -30,7 +30,10 @@ use tendermint::abci; async fn swap_and_swap_claim() -> anyhow::Result<()> { let mut rng = rand_chacha::ChaChaRng::seed_from_u64(1312); - let storage = TempStorage::new().await?.apply_default_genesis().await?; + let storage = TempStorage::new_with_penumbra_prefixes() + .await? + .apply_default_genesis() + .await?; let mut state = Arc::new(StateDelta::new(storage.latest_snapshot())); let height = 1; @@ -141,7 +144,7 @@ async fn swap_and_swap_claim() -> anyhow::Result<()> { async fn swap_claim_duplicate_nullifier_previous_transaction() { let mut rng = rand_chacha::ChaChaRng::seed_from_u64(1312); - let storage = TempStorage::new() + let storage = TempStorage::new_with_penumbra_prefixes() .await .unwrap() .apply_default_genesis() @@ -267,7 +270,10 @@ async fn swap_claim_duplicate_nullifier_previous_transaction() { async fn swap_with_nonzero_fee() -> anyhow::Result<()> { let mut rng = rand_chacha::ChaChaRng::seed_from_u64(1312); - let storage = TempStorage::new().await?.apply_default_genesis().await?; + let storage = TempStorage::new_with_penumbra_prefixes() + .await? + .apply_default_genesis() + .await?; let mut state = Arc::new(StateDelta::new(storage.latest_snapshot())); let height = 1; diff --git a/crates/core/app/tests/view_server_can_be_served_on_localhost.rs b/crates/core/app/tests/view_server_can_be_served_on_localhost.rs index 1ba83aabc7..6ef2571ac3 100644 --- a/crates/core/app/tests/view_server_can_be_served_on_localhost.rs +++ b/crates/core/app/tests/view_server_can_be_served_on_localhost.rs @@ -2,6 +2,7 @@ use { self::common::BuilderExt, anyhow::Context, cnidarium::TempStorage, + common::TempStorageExt as _, penumbra_app::{ genesis::{self, AppState}, server::consensus::Consensus, @@ -30,7 +31,7 @@ mod common; async fn view_server_can_be_served_on_localhost() -> anyhow::Result<()> { // Install a test logger, acquire some temporary storage, and start the test node. let guard = common::set_tracing_subscriber(); - let storage = TempStorage::new().await?; + let storage = TempStorage::new_with_penumbra_prefixes().await?; // Instantiate a mock tendermint proxy, which we will connect to the test node. let proxy = penumbra_mock_tendermint_proxy::TestNodeProxy::new::(); diff --git a/crates/core/component/ibc/src/component/client.rs b/crates/core/component/ibc/src/component/client.rs index b6f6ffd327..dd6bde1aba 100644 --- a/crates/core/component/ibc/src/component/client.rs +++ b/crates/core/component/ibc/src/component/client.rs @@ -103,13 +103,14 @@ pub(crate) trait Ics2ClientExt: StateWrite { // case 1: if we have a verified consensus state previous to this header, verify that this // header's timestamp is greater than or equal to the stored consensus state's timestamp - if let Some(prev_state) = prev_consensus_state { + if let Some((_, prev_state)) = prev_consensus_state { if verified_header.signed_header.header().time < prev_state.timestamp { return ( trusted_client_state .with_header(verified_header.clone()) .expect("able to add header to client state") .with_frozen_height(ibc_types::core::client::Height { + // TODO: is this right? revision_number: 0, revision_height: 1, }), @@ -432,7 +433,7 @@ pub trait StateReadExt: StateRead + penumbra_sct::component::clock::EpochRead { &self, client_id: &ClientId, height: &Height, - ) -> Result> { + ) -> Result> { let mut verified_heights = self.get_verified_heights(client_id) .await? @@ -451,7 +452,7 @@ pub trait StateReadExt: StateRead + penumbra_sct::component::clock::EpochRead { let prev_cons_state = self .get_verified_consensus_state(prev_height, client_id) .await?; - return Ok(Some(prev_cons_state)); + return Ok(Some((*prev_height, prev_cons_state))); } else { return Ok(None); } diff --git a/crates/core/component/ibc/src/component/ics02_validation.rs b/crates/core/component/ibc/src/component/ics02_validation.rs index c117f50517..734e404d16 100644 --- a/crates/core/component/ibc/src/component/ics02_validation.rs +++ b/crates/core/component/ibc/src/component/ics02_validation.rs @@ -97,7 +97,11 @@ pub fn validate_penumbra_client_state( // https://github.com/informalsystems/ibc-rs/pull/304#discussion_r503917283 let chain_id = ChainId::from_string(chain_id); if chain_id != tm_client_state.chain_id { - anyhow::bail!("invalid client state: chain id does not match"); + anyhow::bail!( + "invalid client state: client state's chain id {} does not match current chain {}", + tm_client_state.chain_id, + chain_id + ); } // check that the revision number is the same as our chain ID's version diff --git a/crates/core/component/ibc/src/component/msg_handler/connection_open_ack.rs b/crates/core/component/ibc/src/component/msg_handler/connection_open_ack.rs index 661d9feb6c..a4ac0b095f 100644 --- a/crates/core/component/ibc/src/component/msg_handler/connection_open_ack.rs +++ b/crates/core/component/ibc/src/component/msg_handler/connection_open_ack.rs @@ -198,7 +198,11 @@ async fn consensus_height_is_correct( HI::get_block_height(&state).await?, )?; if msg.consensus_height_of_a_on_b >= current_height { - anyhow::bail!("consensus height is greater than the current block height",); + anyhow::bail!( + "consensus height {} is greater than the current block height {}", + msg.consensus_height_of_a_on_b, + current_height + ); } Ok(()) diff --git a/crates/core/component/ibc/src/component/msg_handler/connection_open_try.rs b/crates/core/component/ibc/src/component/msg_handler/connection_open_try.rs index f9b85c836e..4e85982b8d 100644 --- a/crates/core/component/ibc/src/component/msg_handler/connection_open_try.rs +++ b/crates/core/component/ibc/src/component/msg_handler/connection_open_try.rs @@ -205,7 +205,11 @@ async fn consensus_height_is_correct( HI::get_block_height(&state).await?, )?; if msg.consensus_height_of_b_on_a >= current_height { - anyhow::bail!("consensus height is greater than the current block height",); + anyhow::bail!( + "consensus height {} is greater than the current block height {}", + msg.consensus_height_of_b_on_a, + current_height + ); } Ok(()) diff --git a/crates/core/component/ibc/src/lib.rs b/crates/core/component/ibc/src/lib.rs index b45d9b64fd..172d74d2e2 100644 --- a/crates/core/component/ibc/src/lib.rs +++ b/crates/core/component/ibc/src/lib.rs @@ -18,7 +18,7 @@ pub mod params; mod version; mod prefix; -pub use prefix::{IBC_COMMITMENT_PREFIX, IBC_PROOF_SPECS, IBC_SUBSTORE_PREFIX}; +pub use prefix::{MerklePrefixExt, IBC_COMMITMENT_PREFIX, IBC_PROOF_SPECS, IBC_SUBSTORE_PREFIX}; pub use ibc_action::IbcRelay; pub use ibc_token::IbcToken; diff --git a/crates/proto/src/protobuf/tendermint_compat.rs b/crates/proto/src/protobuf/tendermint_compat.rs index 5a555d6980..661b3012ef 100644 --- a/crates/proto/src/protobuf/tendermint_compat.rs +++ b/crates/proto/src/protobuf/tendermint_compat.rs @@ -5,6 +5,7 @@ // library. accordingly, it is grouped by conversions needed for each RPC endpoint. use crate::util::tendermint_proxy::v1 as penumbra_pb; +use anyhow::anyhow; // === get_tx === @@ -323,9 +324,7 @@ impl From for crate::tendermint::crypto::Pro // === get_block_by_height === impl TryFrom for penumbra_pb::GetBlockByHeightResponse { - // TODO(kate): ideally this would not return a tonic status object, but we'll use this for - // now to avoid invasively refactoring this code. - type Error = tonic::Status; + type Error = anyhow::Error; fn try_from( tendermint_rpc::endpoint::block::Response { block, @@ -338,11 +337,8 @@ impl TryFrom for penumbra_pb::GetBloc }) } } - impl TryFrom for crate::tendermint::types::Block { - // TODO(kate): ideally this would not return a tonic status object, but we'll use this for - // now to avoid invasively refactoring this code. - type Error = tonic::Status; + type Error = anyhow::Error; fn try_from( tendermint::Block { header, @@ -356,14 +352,76 @@ impl TryFrom for crate::tendermint::types::Block { header: header.try_into().map(Some)?, data: Some(crate::tendermint::types::Data { txs: data }), evidence: evidence.try_into().map(Some)?, - last_commit: Some( - last_commit - .map(crate::tendermint::types::Commit::try_from) - .transpose()? - // TODO(kate): this probably should not panic, but this is here to preserve - // existing behavior. panic if no last commit is set. - .expect("last_commit"), - ), + last_commit: last_commit + .map(crate::tendermint::types::Commit::try_from) + .transpose()?, + }) + } +} + +impl TryFrom for tendermint::block::parts::Header { + type Error = anyhow::Error; + fn try_from( + crate::tendermint::types::PartSetHeader { total, hash }: crate::tendermint::types::PartSetHeader, + ) -> Result { + Ok(Self::new(total, hash.try_into()?)?) + } +} + +impl TryFrom for tendermint::block::Header { + type Error = anyhow::Error; + fn try_from( + crate::tendermint::types::Header { + version, + chain_id, + height, + time, + last_block_id, + last_commit_hash, + data_hash, + validators_hash, + next_validators_hash, + consensus_hash, + app_hash, + last_results_hash, + evidence_hash, + proposer_address, + }: crate::tendermint::types::Header, + ) -> Result { + Ok(Self { + version: tendermint::block::header::Version { + block: version.clone().ok_or(anyhow!("version"))?.block, + app: version.ok_or(anyhow!("version"))?.app, + }, + chain_id: tendermint::chain::Id::try_from(chain_id)?, + height: tendermint::block::Height::try_from(height)?, + time: tendermint::Time::from_unix_timestamp( + time.clone().ok_or(anyhow!("time"))?.seconds, + time.clone() + .ok_or(anyhow!("missing time"))? + .nanos + .try_into()?, + )?, + last_block_id: match last_block_id { + Some(last_block_id) => Some(tendermint::block::Id { + hash: tendermint::Hash::try_from(last_block_id.hash)?, + part_set_header: tendermint::block::parts::Header::try_from( + last_block_id + .part_set_header + .ok_or(anyhow::anyhow!("bad part set header"))?, + )?, + }), + None => None, + }, + last_commit_hash: Some(last_commit_hash.try_into()?), + data_hash: Some(data_hash.try_into()?), + validators_hash: validators_hash.try_into()?, + next_validators_hash: next_validators_hash.try_into()?, + consensus_hash: consensus_hash.try_into()?, + app_hash: app_hash.try_into()?, + last_results_hash: Some(last_results_hash.try_into()?), + evidence_hash: Some(evidence_hash.try_into()?), + proposer_address: proposer_address.try_into()?, }) } } @@ -394,7 +452,11 @@ impl TryFrom for crate::tendermint::types::Header { // around a `time::PrimitiveDateTime` however it's private so we // have to use string parsing to get to the prost type we want :( let header_time = chrono::DateTime::parse_from_rfc3339(time.to_rfc3339().as_str()) - .expect("timestamp should roundtrip to string"); + .or_else(|_| { + Err(tonic::Status::invalid_argument( + "timestamp should roundtrip to string", + )) + })?; Ok(Self { version: Some(crate::tendermint::version::Consensus { block: version.block, @@ -404,10 +466,7 @@ impl TryFrom for crate::tendermint::types::Header { height: height.into(), time: Some(pbjson_types::Timestamp { seconds: header_time.timestamp(), - nanos: header_time - .timestamp_nanos_opt() - .ok_or_else(|| tonic::Status::invalid_argument("missing header_time nanos"))? - as i32, + nanos: header_time.timestamp_subsec_nanos() as i32, }), last_block_id: last_block_id.map(|id| crate::tendermint::types::BlockId { hash: id.hash.into(), @@ -430,9 +489,7 @@ impl TryFrom for crate::tendermint::types::Header { } impl TryFrom for crate::tendermint::types::EvidenceList { - // TODO(kate): ideally this would not return a tonic status object, but we'll use this for - // now to avoid invasively refactoring this code. - type Error = tonic::Status; + type Error = anyhow::Error; fn try_from(list: tendermint::evidence::List) -> Result { list.into_vec() .into_iter() @@ -443,11 +500,9 @@ impl TryFrom for crate::tendermint::types::EvidenceL } // TODO(kate): this should be decomposed further at a later point, i am refraining from doing -// so right now. there are `Option::expect()` calls below that should be considered. +// so right now. impl TryFrom for crate::tendermint::types::Evidence { - // TODO(kate): ideally this would not return a tonic status object, but we'll use this for - // now to avoid invasively refactoring this code. - type Error = tonic::Status; + type Error = anyhow::Error; fn try_from(evidence: tendermint::evidence::Evidence) -> Result { use {chrono::DateTime, std::ops::Deref}; Ok(Self { @@ -469,21 +524,27 @@ impl TryFrom for crate::tendermint::types::Evide height: e.votes().0.height.into(), round: e.votes().0.round.into(), block_id: Some(crate::tendermint::types::BlockId { - hash: e.votes().0.block_id.expect("block id").hash.into(), + hash: e + .votes() + .0 + .block_id + .ok_or(anyhow!("block id"))? + .hash + .into(), part_set_header: Some( crate::tendermint::types::PartSetHeader { total: e .votes() .0 .block_id - .expect("block id") + .ok_or(anyhow!("block id"))? .part_set_header .total, hash: e .votes() .0 .block_id - .expect("block id") + .ok_or(anyhow!("block id"))? .part_set_header .hash .into(), @@ -492,18 +553,25 @@ impl TryFrom for crate::tendermint::types::Evide }), timestamp: Some(pbjson_types::Timestamp { seconds: DateTime::parse_from_rfc3339( - &e.votes().0.timestamp.expect("timestamp").to_rfc3339(), + &e.votes() + .0 + .timestamp + .ok_or(tonic::Status::invalid_argument( + "bad timestamp", + ))? + .to_rfc3339(), ) - .expect("timestamp should roundtrip to string") + .or_else(|_| { + Err(tonic::Status::invalid_argument("bad timestamp")) + })? .timestamp(), nanos: DateTime::parse_from_rfc3339( &e.votes().0.timestamp.expect("timestamp").to_rfc3339(), ) .expect("timestamp should roundtrip to string") - .timestamp_nanos_opt() - .ok_or_else(|| { - tonic::Status::invalid_argument("missing timestamp nanos") - })? as i32, + .timestamp_subsec_nanos() + .try_into() + .expect("good round trip timestamps"), }), validator_address: e.votes().0.validator_address.into(), validator_index: e.votes().0.validator_index.into(), @@ -558,10 +626,9 @@ impl TryFrom for crate::tendermint::types::Evide &e.votes().1.timestamp.expect("timestamp").to_rfc3339(), ) .expect("timestamp should roundtrip to string") - .timestamp_nanos_opt() - .ok_or_else(|| { - tonic::Status::invalid_argument("missing timestamp nanos") - })? as i32, + .timestamp_subsec_nanos() + .try_into() + .expect("good round trip timestamps"), }), validator_address: e.votes().1.validator_address.into(), validator_index: e.votes().1.validator_index.into(), @@ -652,12 +719,11 @@ impl TryFrom for crate::tendermint::types::CommitS .timestamp(), nanos: DateTime::parse_from_rfc3339(×tamp.to_rfc3339()) .expect("timestamp should roundtrip to string") - .timestamp_nanos_opt() - .ok_or_else(|| { - tonic::Status::invalid_argument("missing timestamp nanos") - })? as i32, + .timestamp_subsec_nanos() + .try_into() + .expect("good round trip timestamps"), }), - signature: signature.expect("signature").into(), + signature: signature.map(Into::into).unwrap_or_default(), }, tendermint::block::CommitSig::BlockIdFlagNil { validator_address, @@ -672,10 +738,9 @@ impl TryFrom for crate::tendermint::types::CommitS .timestamp(), nanos: DateTime::parse_from_rfc3339(×tamp.to_rfc3339()) .expect("timestamp should roundtrip to string") - .timestamp_nanos_opt() - .ok_or_else(|| { - tonic::Status::invalid_argument("missing timestamp nanos") - })? as i32, + .timestamp_subsec_nanos() + .try_into() + .expect("good round trip timestamps"), }), signature: signature.expect("signature").into(), }, diff --git a/crates/test/mock-consensus/Cargo.toml b/crates/test/mock-consensus/Cargo.toml index 8c75ced395..ae02cac011 100644 --- a/crates/test/mock-consensus/Cargo.toml +++ b/crates/test/mock-consensus/Cargo.toml @@ -15,9 +15,11 @@ license.workspace = true anyhow = { workspace = true } bytes = { workspace = true } ed25519-consensus = { workspace = true } +hex = { workspace = true } rand_core = { workspace = true } sha2 = { workspace = true } tap = { workspace = true } tendermint = { workspace = true, default-features = true } +tendermint-proto = { workspace = true } tower = { workspace = true, features = ["full"] } tracing = { workspace = true } diff --git a/crates/test/mock-consensus/src/abci.rs b/crates/test/mock-consensus/src/abci.rs index fd5c67f6bb..7d584115e9 100644 --- a/crates/test/mock-consensus/src/abci.rs +++ b/crates/test/mock-consensus/src/abci.rs @@ -160,6 +160,10 @@ where retain_height, } = &response; trace!(?data, ?retain_height, "received Commit response"); + + // Set the last app hash to the new block's app hash. + assert!(response.data.to_vec().len() > 0); + self.last_app_hash = response.data.to_vec(); Ok(response) } response => { diff --git a/crates/test/mock-consensus/src/block.rs b/crates/test/mock-consensus/src/block.rs index b9a116d23e..17e9455822 100644 --- a/crates/test/mock-consensus/src/block.rs +++ b/crates/test/mock-consensus/src/block.rs @@ -4,13 +4,14 @@ use { crate::TestNode, + sha2::Sha256, tap::Tap, tendermint::{ account, block::{self, header::Version, Block, Commit, Header, Round}, - chain, evidence, + evidence, v0_37::abci::{ConsensusRequest, ConsensusResponse}, - AppHash, Hash, + Hash, }, tower::{BoxError, Service}, tracing::{instrument, trace}, @@ -34,8 +35,8 @@ pub struct Builder<'e, C> { data: Vec>, /// Evidence of malfeasance. evidence: evidence::List, - /// The list of signatures. - signatures: Vec, + /// Disable producing signatures. Defaults to produce signatures. + disable_signatures: bool, } // === impl TestNode === @@ -43,16 +44,12 @@ pub struct Builder<'e, C> { impl TestNode { /// Returns a new [`Builder`]. /// - /// By default, signatures for all of the validators currently within the keyring will be - /// included in the block. Use [`Builder::with_signatures()`] to set a different set of - /// validator signatures. pub fn block(&mut self) -> Builder<'_, C> { - let signatures = self.generate_signatures().collect(); Builder { test_node: self, data: Default::default(), evidence: Default::default(), - signatures, + disable_signatures: false, } } } @@ -85,9 +82,12 @@ impl<'e, C> Builder<'e, C> { Self { evidence, ..self } } - /// Sets the [`CommitSig`][block::CommitSig] commit signatures for this block. - pub fn with_signatures(self, signatures: Vec) -> Self { - Self { signatures, ..self } + /// Disables producing commit signatures for this block. + pub fn without_signatures(self) -> Self { + Self { + disable_signatures: true, + ..self + } } } @@ -103,6 +103,10 @@ where /// Consumes this builder, executing the [`Block`] using the consensus service. /// /// Use [`TestNode::block()`] to build a new block. + /// + /// By default, signatures for all of the validators currently within the keyring will be + /// included in the block. Use [`Builder::without_signatures()`] to disable producing + /// validator signatures. #[instrument(level = "info", skip_all, fields(height, time))] pub async fn execute(self) -> Result<(), anyhow::Error> { let (test_node, block) = self.finish()?; @@ -148,9 +152,11 @@ where data, evidence, test_node, - signatures, + disable_signatures, } = self; + let time = tendermint::Time::now(); + let height = { let height = test_node.height.increment(); test_node.height = height; @@ -158,39 +164,93 @@ where height }; - let last_commit = if height.value() != 1 { + let mut last_commit = if height.value() != 1 { + // This needs to be the header hash of the last block let block_id = block::Id { - hash: Hash::None, + hash: test_node + .last_tm_hash + .expect("last tm hash should be available on subsequent blocks"), part_set_header: block::parts::Header::new(0, Hash::None)?, }; Some(Commit { height, round: Round::default(), block_id, - signatures, + // Note: the signatures are blank here, and then the signing happens below + signatures: vec![], }) } else { None // The first block has no previous commit to speak of. }; + // Set the validator set based on the current configuration. + let pk = test_node + .keyring + .iter() + .next() + .expect("validator key in keyring") + .0; + let proposer_address = account::Id::new( + ::digest(pk).as_slice()[0..20] + .try_into() + .expect(""), + ); + + let pub_key = + tendermint::PublicKey::from_raw_ed25519(pk.as_bytes()).expect("pub key present"); + + let validator_set = tendermint::validator::Set::new( + vec![tendermint::validator::Info { + address: proposer_address.try_into()?, + pub_key, + power: 1i64.try_into()?, + name: Some("test validator".to_string()), + proposer_priority: 1i64.try_into()?, + }], + // Same validator as proposer? + Some(tendermint::validator::Info { + address: proposer_address.try_into()?, + pub_key, + power: 1i64.try_into()?, + name: Some("test validator".to_string()), + proposer_priority: 1i64.try_into()?, + }), + ); + let validators_hash = validator_set.hash(); + let header = Header { version: Version { block: 1, app: 1 }, - chain_id: chain::Id::try_from("test".to_owned())?, + chain_id: tendermint::chain::Id::try_from(test_node.chain_id.clone())?, height, - time: tendermint::Time::now(), - last_block_id: None, - last_commit_hash: None, + time, + last_block_id: test_node.last_commit.as_ref().map(|c| c.block_id.clone()), + last_commit_hash: test_node.last_tm_hash, data_hash: None, - validators_hash: Hash::None, - next_validators_hash: Hash::None, + // force the header to have the hash of the validator set to pass + // the validation + validators_hash: validators_hash.into(), + next_validators_hash: validators_hash.into(), + // TODO: how to set? consensus_hash: Hash::None, - app_hash: AppHash::try_from(Vec::default())?, + app_hash: tendermint::AppHash::try_from(test_node.last_app_hash().to_vec())?, last_results_hash: None, evidence_hash: None, - proposer_address: account::Id::new([ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - ]), + proposer_address, }; + + // Immediately before committing, sign the block + let signatures: Vec = if !disable_signatures { + test_node.generate_signatures(&header).collect() + } else { + vec![] + }; + last_commit + .as_mut() + .map(|commit| commit.signatures = signatures); + + // Store the header hash in a cache for later reference + test_node.last_tm_hash = Some(header.hash()); + let block = Block::new(header, data, evidence, last_commit)?; Ok((test_node, block)) diff --git a/crates/test/mock-consensus/src/block/signature.rs b/crates/test/mock-consensus/src/block/signature.rs index 249e91e629..d454121712 100644 --- a/crates/test/mock-consensus/src/block/signature.rs +++ b/crates/test/mock-consensus/src/block/signature.rs @@ -17,11 +17,28 @@ mod sign { /// Returns a [commit signature] saying this validator voted for the block. /// /// [commit signature]: CommitSig - pub(super) fn commit(validator_address: Id) -> CommitSig { + pub(super) fn commit( + validator_address: Id, + validator_key: &ed25519_consensus::SigningKey, + canonical: &tendermint::vote::CanonicalVote, + ) -> CommitSig { + // Create a vote to be signed + // https://github.com/informalsystems/tendermint-rs/blob/14fd628e82ae51b9f15c135a6db8870219fe3c33/testgen/src/commit.rs#L214 + // https://github.com/informalsystems/tendermint-rs/blob/14fd628e82ae51b9f15c135a6db8870219fe3c33/testgen/src/commit.rs#L104 + + use tendermint_proto::v0_37::types::CanonicalVote as RawCanonicalVote; + let sign_bytes = + tendermint_proto::Protobuf::::encode_length_delimited_vec( + canonical.clone(), + ); + + // encode to stable-json deterministic JSON wire encoding, + // https://github.com/informalsystems/tendermint-rs/blob/14fd628e82ae51b9f15c135a6db8870219fe3c33/testgen/src/helpers.rs#L43C1-L44C1 + CommitSig::BlockIdFlagCommit { validator_address, - timestamp: timestamp(), - signature: None, + timestamp: canonical.timestamp.expect("timestamp should be present"), + signature: Some(validator_key.sign(sign_bytes.as_slice()).into()), } } @@ -33,6 +50,7 @@ mod sign { CommitSig::BlockIdFlagNil { validator_address, timestamp: timestamp(), + // TODO: we need a valid signature here signature: None, } } @@ -41,8 +59,10 @@ mod sign { // // TODO(kate): see https://github.com/penumbra-zone/penumbra/issues/3759, re: timestamps. // eventually, we will add hooks so that we can control these timestamps. + // TODO: a good way would be to have a fixed start time and then a hook + // on each block commit that would increase the timestamp fn timestamp() -> Time { - Time::now() + tendermint::Time::now() } } @@ -54,16 +74,40 @@ impl TestNode { // commit signatures from all of the validators. /// Returns an [`Iterator`] of signatures for validators in the keyring. - pub(super) fn generate_signatures(&self) -> impl Iterator + '_ { - self.keyring - .keys() - .map(|vk| { - ::digest(vk).as_slice()[0..20] - .try_into() - .expect("") + pub(super) fn generate_signatures( + &self, + header: &tendermint::block::Header, + ) -> impl Iterator + '_ { + let block_id = tendermint::block::Id { + hash: header.hash(), + part_set_header: tendermint::block::parts::Header::new(0, tendermint::Hash::None) + .unwrap(), + }; + let canonical = tendermint::vote::CanonicalVote { + // The mock consensus engine ONLY has precommit votes right now + vote_type: tendermint::vote::Type::Precommit, + height: tendermint::block::Height::from(self.height), + // round is always 0 + round: 0u8.into(), + block_id: Some(block_id), + // Block header time is used throughout + timestamp: Some(header.time.clone()), + // timestamp: Some(last_commit_info.timestamp), + chain_id: self.chain_id.clone(), + }; + + return self + .keyring + .iter() + .map(|(vk, sk)| { + ( + ::digest(vk).as_slice()[0..20] + .try_into() + .expect(""), + sk, + ) }) - .map(account::Id::new) - .map(self::sign::commit) + .map(move |(id, sk)| self::sign::commit(account::Id::new(id), sk, &canonical)); } } diff --git a/crates/test/mock-consensus/src/builder.rs b/crates/test/mock-consensus/src/builder.rs index dd4b19e5cb..022037dec4 100644 --- a/crates/test/mock-consensus/src/builder.rs +++ b/crates/test/mock-consensus/src/builder.rs @@ -16,6 +16,7 @@ pub struct Builder { pub app_state: Option, pub keyring: Keyring, pub on_block: Option, + pub chain_id: Option, } impl TestNode<()> { @@ -87,6 +88,7 @@ impl Builder { /// Generates consensus keys and places them in the provided keyring. fn add_key(keyring: &mut Keyring) { + // TODO: allow hardcoding key? let sk = ed25519_consensus::SigningKey::new(rand_core::OsRng); let vk = sk.verification_key(); tracing::trace!(verification_key = ?vk, "generated consensus key"); diff --git a/crates/test/mock-consensus/src/builder/init_chain.rs b/crates/test/mock-consensus/src/builder/init_chain.rs index e6852e68b8..4ab8b01075 100644 --- a/crates/test/mock-consensus/src/builder/init_chain.rs +++ b/crates/test/mock-consensus/src/builder/init_chain.rs @@ -2,7 +2,7 @@ use { super::*, anyhow::{anyhow, bail}, bytes::Bytes, - std::time, + std::{collections::BTreeMap, time}, tap::TapFallible, tendermint::{ block, @@ -39,12 +39,17 @@ impl Builder { app_state: Some(app_state), keyring, on_block, + chain_id, } = self else { bail!("builder was not fully initialized") }; - let request = Self::init_chain_request(app_state)?; + let chain_id = tendermint::chain::Id::try_from( + chain_id.unwrap_or(TestNode::<()>::CHAIN_ID.to_string()), + )?; + + let request = Self::init_chain_request(app_state, &keyring, chain_id.clone())?; let service = consensus .ready() .await @@ -69,20 +74,41 @@ impl Builder { consensus, height: block::Height::from(0_u8), last_app_hash: app_hash.as_bytes().to_owned(), + last_tm_hash: None, + last_commit: None, keyring, on_block, + chain_id, }) } - fn init_chain_request(app_state_bytes: Bytes) -> Result { + fn init_chain_request( + app_state_bytes: Bytes, + keyring: &BTreeMap, + chain_id: tendermint::chain::Id, + ) -> Result { use tendermint::v0_37::abci::request::InitChain; - let chain_id = TestNode::<()>::CHAIN_ID.to_string(); let consensus_params = Self::consensus_params(); + + let pub_keys = keyring + .iter() + .map(|(pk, _)| pk) + .map(|pk| { + tendermint::PublicKey::from_raw_ed25519(pk.as_bytes()).expect("pub key present") + }) + .collect::>(); + Ok(ConsensusRequest::InitChain(InitChain { time: tendermint::Time::now(), - chain_id, + chain_id: chain_id.into(), consensus_params, - validators: vec![], + validators: pub_keys + .into_iter() + .map(|pub_key| tendermint::validator::Update { + pub_key, + power: 1u64.try_into().unwrap(), + }) + .collect::>(), app_state_bytes, initial_height: 1_u32.into(), })) diff --git a/crates/test/mock-consensus/src/lib.rs b/crates/test/mock-consensus/src/lib.rs index b2a711a782..ce4a93dbea 100644 --- a/crates/test/mock-consensus/src/lib.rs +++ b/crates/test/mock-consensus/src/lib.rs @@ -76,6 +76,10 @@ pub struct TestNode { consensus: C, /// The last `app_hash` value. last_app_hash: Vec, + /// The last tendermint header hash value. + last_tm_hash: Option, + /// The last tendermint block header commit value. + last_commit: Option, /// The current block [`Height`][tendermint::block::Height]. height: tendermint::block::Height, /// Validators' consensus keys. @@ -84,6 +88,8 @@ pub struct TestNode { keyring: Keyring, /// A callback that will be invoked when a new block is constructed. on_block: Option, + /// The chain ID. + chain_id: tendermint::chain::Id, } /// A type alias for the `TestNode::on_block` callback. diff --git a/crates/test/mock-tendermint-proxy/src/proxy.rs b/crates/test/mock-tendermint-proxy/src/proxy.rs index f46f0ecb7b..12b9b77466 100644 --- a/crates/test/mock-tendermint-proxy/src/proxy.rs +++ b/crates/test/mock-tendermint-proxy/src/proxy.rs @@ -193,7 +193,11 @@ impl TendermintProxyService for TestNodeProxy { .get(&height) .cloned() .map(penumbra_proto::tendermint::types::Block::try_from) - .transpose()?; + .transpose() + .or_else(|e| { + tracing::warn!(?height, error = ?e, "proxy: error fetching blocks"); + Err(tonic::Status::internal("error fetching blocks")) + })?; let block_id = block .as_ref() // is this off-by-one? should we be getting the id of the last commit? .and_then(|b| b.last_commit.as_ref()) diff --git a/crates/util/tendermint-proxy/src/tendermint_proxy.rs b/crates/util/tendermint-proxy/src/tendermint_proxy.rs index fff613c409..7c902f9314 100644 --- a/crates/util/tendermint-proxy/src/tendermint_proxy.rs +++ b/crates/util/tendermint-proxy/src/tendermint_proxy.rs @@ -197,7 +197,15 @@ impl TendermintProxyService for TendermintProxy { .block(height) .await .map_err(|e| tonic::Status::unavailable(format!("error querying abci: {e}"))) - .and_then(GetBlockByHeightResponse::try_from) + .and_then(|b| { + match GetBlockByHeightResponse::try_from(b) { + Ok(b) => Ok(b), + Err(e) => { + tracing::warn!(?height, error = ?e, "proxy: error deserializing GetBlockByHeightResponse"); + Err(tonic::Status::internal("error deserializing GetBlockByHeightResponse")) + } + } + }) .map(tonic::Response::new) } } diff --git a/crates/view/Cargo.toml b/crates/view/Cargo.toml index 4f43124d18..443690c576 100644 --- a/crates/view/Cargo.toml +++ b/crates/view/Cargo.toml @@ -66,6 +66,5 @@ tokio = {workspace = true, features = ["full"]} tokio-stream = {workspace = true, features = ["sync"]} tonic = {workspace = true} tracing = {workspace = true} -tracing-subscriber = {workspace = true} url = {workspace = true} pbjson-types = { workspace = true }