diff --git a/CHANGELOG.md b/CHANGELOG.md index 21485f964..bd8d4ac80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ runtime - Add TDX test network chainspec ([#1204](https://github.com/entropyxyz/entropy-core/pull/1204)) - Test CLI command to retrieve quote and change endpoint / TSS account in one command ([#1198](https://github.com/entropyxyz/entropy-core/pull/1198)) - On-chain unresponsiveness reporting [(#1215)](https://github.com/entropyxyz/entropy-core/pull/1215) +- Report unstable peers from TSS [(#1228)](https://github.com/entropyxyz/entropy-core/pull/1228) - Add cli options for adding validator [(#1242)](https://github.com/entropyxyz/entropy-core/pull/1242) - Add no hash option [(#1266)](https://github.com/entropyxyz/entropy-core/pull/1266) diff --git a/crates/protocol/src/listener.rs b/crates/protocol/src/listener.rs index fd38d64e7..f999a4f33 100644 --- a/crates/protocol/src/listener.rs +++ b/crates/protocol/src/listener.rs @@ -29,7 +29,7 @@ use tokio::sync::{broadcast, mpsc, oneshot}; pub type ListenerResult = Result; /// Tracks which validators we are connected to for a particular protocol execution -/// and sets up channels for exchaning protocol messages +/// and sets up channels for exchanging protocol messages #[derive(Debug)] pub struct Listener { /// Endpoint to create subscriptions diff --git a/crates/protocol/tests/helpers/mod.rs b/crates/protocol/tests/helpers/mod.rs index cae40306a..670775e37 100644 --- a/crates/protocol/tests/helpers/mod.rs +++ b/crates/protocol/tests/helpers/mod.rs @@ -255,6 +255,7 @@ async fn open_protocol_connections( // Check the response as to whether they accepted our SubscribeMessage let response_message = encrypted_connection.recv().await?; let subscribe_response: Result<(), String> = bincode::deserialize(&response_message)?; + if let Err(error_message) = subscribe_response { return Err(anyhow!(error_message)); } diff --git a/crates/testing-utils/src/constants.rs b/crates/testing-utils/src/constants.rs index ab937e67a..d678fe6ff 100644 --- a/crates/testing-utils/src/constants.rs +++ b/crates/testing-utils/src/constants.rs @@ -17,12 +17,18 @@ use hex_literal::hex; use subxt::utils::AccountId32; lazy_static! { - pub static ref ALICE_STASH_ADDRESS: AccountId32 = hex!["be5ddb1579b72e84524fc29e78609e3caf42e85aa118ebfe0b0ad404b5bdd25f"].into(); + pub static ref ALICE_STASH_ADDRESS: AccountId32 = + hex!["be5ddb1579b72e84524fc29e78609e3caf42e85aa118ebfe0b0ad404b5bdd25f"].into(); // subkey inspect //Alice//stash + pub static ref BOB_STASH_ADDRESS: AccountId32 = + hex!["fe65717dad0447d715f660a0a58411de509b42e6efb8375f562f58a554d5860e"].into(); // subkey inspect //Bob//stash + pub static ref CHARLIE_STASH_ADDRESS: AccountId32 = + hex!["1e07379407fecc4b89eb7dbd287c2c781cfb1907a96947a3eb18e4f8e7198625"].into(); // subkey inspect //Charlie//stash + pub static ref DAVE_STASH_ADDRESS: AccountId32 = + hex!["e860f1b1c7227f7c22602f53f15af80747814dffd839719731ee3bba6edc126c"].into(); // subkey inspect //Dave//stash + pub static ref RANDOM_ACCOUNT: AccountId32 = hex!["8676839ca1e196624106d17c56b1efbb90508a86d8053f7d4fcd21127a9f7565"].into(); pub static ref VALIDATOR_1_STASH_ID: AccountId32 = hex!["be5ddb1579b72e84524fc29e78609e3caf42e85aa118ebfe0b0ad404b5bdd25f"].into(); // alice stash; - pub static ref BOB_STASH_ADDRESS: AccountId32 = - hex!["fe65717dad0447d715f660a0a58411de509b42e6efb8375f562f58a554d5860e"].into(); // subkey inspect //Bob//stash // See entropy_tss::helpers::validator::get_signer_and_x25519_secret for how these are derived pub static ref TSS_ACCOUNTS: Vec = vec![ diff --git a/crates/threshold-signature-server/src/helpers/signing.rs b/crates/threshold-signature-server/src/helpers/signing.rs index 609dde2f2..7c6a4e4cc 100644 --- a/crates/threshold-signature-server/src/helpers/signing.rs +++ b/crates/threshold-signature-server/src/helpers/signing.rs @@ -92,9 +92,16 @@ pub async fn do_signing( .await?; let channels = { - let ready = timeout(Duration::from_secs(SETUP_TIMEOUT_SECONDS), rx_ready).await?; - let broadcast_out = ready??; - Channels(broadcast_out, rx_from_others) + match timeout(Duration::from_secs(SETUP_TIMEOUT_SECONDS), rx_ready).await { + Ok(ready) => { + let broadcast_out = ready??; + Channels(broadcast_out, rx_from_others) + }, + Err(e) => { + let unsubscribed_peers = app_state.unsubscribed_peers(&session_id)?; + return Err(ProtocolErr::Timeout { source: e, inactive_peers: unsubscribed_peers }); + }, + } }; let result = signing_service diff --git a/crates/threshold-signature-server/src/helpers/user.rs b/crates/threshold-signature-server/src/helpers/user.rs index fcc639518..c66142228 100644 --- a/crates/threshold-signature-server/src/helpers/user.rs +++ b/crates/threshold-signature-server/src/helpers/user.rs @@ -87,6 +87,7 @@ pub async fn do_dkg( x25519_secret_key, ) .await?; + let channels = { let ready = timeout(Duration::from_secs(SETUP_TIMEOUT_SECONDS), rx_ready).await?; let broadcast_out = ready??; diff --git a/crates/threshold-signature-server/src/lib.rs b/crates/threshold-signature-server/src/lib.rs index 5cd01cb49..d943dc1f3 100644 --- a/crates/threshold-signature-server/src/lib.rs +++ b/crates/threshold-signature-server/src/lib.rs @@ -208,6 +208,19 @@ impl AppState { pub fn new(configuration: Configuration, kv_store: KvManager) -> Self { Self { listener_state: ListenerState::default(), configuration, kv_store } } + + /// Gets the list of peers who haven't yet subscribed to us for this particular session. + pub fn unsubscribed_peers( + &self, + session_id: &entropy_protocol::SessionId, + ) -> Result, crate::signing_client::ProtocolErr> { + self.listener_state.unsubscribed_peers(session_id).map_err(|_| { + crate::signing_client::ProtocolErr::SessionError(format!( + "Unable to get unsubscribed peers for `SessionId` {:?}", + session_id, + )) + }) + } } pub fn app(app_state: AppState) -> Router { diff --git a/crates/threshold-signature-server/src/signing_client/api.rs b/crates/threshold-signature-server/src/signing_client/api.rs index eb55d12a3..f7f27619b 100644 --- a/crates/threshold-signature-server/src/signing_client/api.rs +++ b/crates/threshold-signature-server/src/signing_client/api.rs @@ -138,7 +138,6 @@ pub async fn ws_handler( async fn handle_socket_result(socket: WebSocket, app_state: AppState) { if let Err(err) = handle_socket(socket, app_state).await { tracing::warn!("Websocket connection closed unexpectedly {:?}", err); - // TODO here we should inform the chain that signing failed }; } @@ -293,7 +292,19 @@ pub async fn get_channels( ) .await?; - let ready = timeout(Duration::from_secs(SETUP_TIMEOUT_SECONDS), rx_ready).await?; - let broadcast_out = ready??; - Ok(Channels(broadcast_out, rx_from_others)) + match timeout(Duration::from_secs(SETUP_TIMEOUT_SECONDS), rx_ready).await { + Ok(ready) => { + let broadcast_out = ready??; + Ok(Channels(broadcast_out, rx_from_others)) + }, + Err(e) => { + let unsubscribed_peers = state.unsubscribed_peers(session_id).map_err(|_| { + ProtocolErr::SessionError(format!( + "Unable to get unsubscribed peers for `SessionId` {:?}", + session_id, + )) + })?; + Err(ProtocolErr::Timeout { source: e, inactive_peers: unsubscribed_peers }) + }, + } } diff --git a/crates/threshold-signature-server/src/signing_client/errors.rs b/crates/threshold-signature-server/src/signing_client/errors.rs index 4079c0162..cc7f23416 100644 --- a/crates/threshold-signature-server/src/signing_client/errors.rs +++ b/crates/threshold-signature-server/src/signing_client/errors.rs @@ -22,6 +22,7 @@ use axum::{ }; use entropy_kvdb::kv_manager::error::InnerKvError; use entropy_protocol::errors::ProtocolExecutionErr; +use subxt::utils::AccountId32; use thiserror::Error; use tokio::sync::oneshot::error::RecvError; @@ -46,8 +47,8 @@ pub enum ProtocolErr { Deserialization(String), #[error("Oneshot timeout error: {0}")] OneshotTimeout(#[from] RecvError), - #[error("Subscribe API error: {0}")] - Subscribe(#[from] SubscribeErr), + #[error("Subscribe API error: {source} by TSS Account `{account_id:?}`")] + Subscribe { source: SubscribeErr, account_id: AccountId32 }, #[error("reqwest error: {0}")] Reqwest(#[from] reqwest::Error), #[error("Utf8Error: {0:?}")] @@ -70,18 +71,21 @@ pub enum ProtocolErr { UserError(String), #[error("Validation Error: {0}")] ValidationErr(#[from] crate::validation::errors::ValidationErr), - #[error("Subscribe message rejected: {0}")] - BadSubscribeMessage(String), + #[error("Subscribe message rejected: {message} by TSS Account `{account_id}`")] + BadSubscribeMessage { message: String, account_id: AccountId32 }, #[error("From Hex Error: {0}")] FromHex(#[from] hex::FromHexError), #[error("Conversion Error: {0}")] Conversion(&'static str), - #[error("Could not open ws connection: {0}")] - ConnectionError(#[from] tokio_tungstenite::tungstenite::Error), - #[error("Timed out waiting for remote party")] - Timeout(#[from] tokio::time::error::Elapsed), - #[error("Encrypted connection error {0}")] - EncryptedConnection(String), + #[error("Could not open ws connection: {source} with the TSS Account `{account_id:?}`")] + ConnectionError { source: tokio_tungstenite::tungstenite::Error, account_id: AccountId32 }, + #[error("Timed out while waiting for peer(s): {:?}", inactive_peers)] + Timeout { source: tokio::time::error::Elapsed, inactive_peers: Vec }, + #[error("Encrypted connection error {source:?} with the TSS Account `{account_id:?}`")] + EncryptedConnection { + source: entropy_protocol::protocol_transport::errors::EncryptedConnectionErr, + account_id: AccountId32, + }, #[error("Program error: {0}")] ProgramError(#[from] crate::user::errors::ProgramError), #[error("Invalid length for converting address")] @@ -143,6 +147,8 @@ pub enum SubscribeErr { VersionMismatch( #[from] entropy_protocol::protocol_transport::errors::ProtocolVersionMismatchError, ), + #[error("Unable to find `Listener` with `SessionId`: {0:?}")] + NoSessionId(entropy_protocol::SessionId), } impl IntoResponse for SubscribeErr { diff --git a/crates/threshold-signature-server/src/signing_client/mod.rs b/crates/threshold-signature-server/src/signing_client/mod.rs index 414e56091..a29056d27 100644 --- a/crates/threshold-signature-server/src/signing_client/mod.rs +++ b/crates/threshold-signature-server/src/signing_client/mod.rs @@ -48,4 +48,20 @@ impl ListenerState { .map_err(|e| SubscribeErr::LockError(e.to_string()))? .contains_key(session_id)) } + + /// Gets the list of peers who haven't yet subscribed to us for this particular session. + pub fn unsubscribed_peers( + &self, + session_id: &SessionId, + ) -> Result, SubscribeErr> { + let listeners = + self.listeners.lock().map_err(|e| SubscribeErr::LockError(e.to_string()))?; + let listener = + listeners.get(session_id).ok_or(SubscribeErr::NoSessionId(session_id.clone()))?; + + let unsubscribed_peers = + listener.validators.keys().map(|id| subxt::utils::AccountId32(*id)).collect(); + + Ok(unsubscribed_peers) + } } diff --git a/crates/threshold-signature-server/src/signing_client/protocol_transport.rs b/crates/threshold-signature-server/src/signing_client/protocol_transport.rs index 4912e2dce..e98d44381 100644 --- a/crates/threshold-signature-server/src/signing_client/protocol_transport.rs +++ b/crates/threshold-signature-server/src/signing_client/protocol_transport.rs @@ -55,14 +55,31 @@ pub async fn open_protocol_connections( let connect_to_validators = validators_info .iter() .filter(|validators_info| { - // Decide whether to initiate a connection by comparing accound ids - // otherwise, we wait for them to connect to us - signer.public().0 > validators_info.tss_account.0 + // Decide whether to initiate a connection by comparing account IDs, otherwise we wait + // for them to connect to us + let initiate_connection = signer.public().0 > validators_info.tss_account.0; + if !initiate_connection { + tracing::debug!( + "Waiting for {:?} to open a connection with us.", + validators_info.tss_account.0 + ); + } + + initiate_connection }) .map(|validator_info| async move { + tracing::debug!( + "Attempting to open protocol connections with {:?}", + validator_info.tss_account.0.clone() + ); + // Open a ws connection let ws_endpoint = format!("ws://{}/ws", validator_info.ip_address); - let (ws_stream, _response) = connect_async(ws_endpoint).await?; + let (ws_stream, _response) = + connect_async(ws_endpoint).await.map_err(|e| ProtocolErr::ConnectionError { + source: e, + account_id: validator_info.tss_account.clone(), + })?; // Send a SubscribeMessage in the payload of the final handshake message let subscribe_message_vec = @@ -75,32 +92,50 @@ pub async fn open_protocol_connections( subscribe_message_vec, ) .await - .map_err(|e| ProtocolErr::EncryptedConnection(e.to_string()))?; + .map_err(|e| ProtocolErr::EncryptedConnection { + source: e, + account_id: validator_info.tss_account.clone(), + })?; // Check the response as to whether they accepted our SubscribeMessage - let response_message = encrypted_connection - .recv() - .await - .map_err(|e| ProtocolErr::EncryptedConnection(e.to_string()))?; + let response_message = encrypted_connection.recv().await.map_err(|e| { + ProtocolErr::EncryptedConnection { + source: e, + account_id: validator_info.tss_account.clone(), + } + })?; + let subscribe_response: Result<(), String> = bincode::deserialize(&response_message)?; if let Err(error_message) = subscribe_response { // In future versions, we can check here if the error is VersionTooNew(version) // and if possible the downgrade protocol messages used to be backward compatible - return Err(ProtocolErr::BadSubscribeMessage(error_message)); + return Err(ProtocolErr::BadSubscribeMessage { + message: error_message, + account_id: validator_info.tss_account.clone(), + }); } // Setup channels - let ws_channels = get_ws_channels(state, session_id, &validator_info.tss_account)?; + let ws_channels = get_ws_channels(state, session_id, &validator_info.tss_account) + .map_err(|e| ProtocolErr::Subscribe { + source: e, + account_id: validator_info.tss_account.clone(), + })?; let remote_party_id = PartyId::new(validator_info.tss_account.clone()); + let account_id = validator_info.tss_account.clone(); // Handle protocol messages tokio::spawn(async move { - if let Err(err) = - ws_to_channels(encrypted_connection, ws_channels, remote_party_id).await - { - tracing::warn!("{:?}", err); - }; + ws_to_channels(encrypted_connection, ws_channels, remote_party_id).await.map_err( + |err| { + tracing::warn!("{:?}", err); + Err::<(), ProtocolErr>(ProtocolErr::EncryptedConnection { + source: err.into(), + account_id, + }) + }, + ) }); Ok::<_, ProtocolErr>(()) diff --git a/crates/threshold-signature-server/src/user/api.rs b/crates/threshold-signature-server/src/user/api.rs index 4b29a9cac..cdf8d8db7 100644 --- a/crates/threshold-signature-server/src/user/api.rs +++ b/crates/threshold-signature-server/src/user/api.rs @@ -41,6 +41,7 @@ use x25519_dalek::StaticSecret; use super::UserErr; use crate::chain_api::entropy::runtime_types::pallet_registry::pallet::RegisteredInfo; +use crate::signing_client::ProtocolErr; use crate::{ chain_api::{entropy, get_api, get_rpc, EntropyConfig}, helpers::{ @@ -351,14 +352,15 @@ pub async fn sign_tx( request_limit, derivation_path, ) - .await - .map(|signature| { - ( + .await; + + let signing_protocol_output = match signing_protocol_output { + Ok(signature) => Ok(( BASE64_STANDARD.encode(signature.to_rsv_bytes()), signer.signer().sign(&signature.to_rsv_bytes()), - ) - }) - .map_err(|error| error.to_string()); + )), + Err(e) => Err(handle_protocol_errors(&api, &rpc, &signer, e).await.unwrap_err()), + }; // This response chunk is sent later with the result of the signing protocol if response_tx.try_send(serde_json::to_string(&signing_protocol_output)).is_err() { @@ -370,6 +372,56 @@ pub async fn sign_tx( Ok((StatusCode::OK, Body::from_stream(response_rx))) } +/// Helper for handling different protocol errors. +/// +/// If the error is of the reportable type (e.g an offence from another peer) it will be reported +/// on-chain by this helper. +async fn handle_protocol_errors( + api: &OnlineClient, + rpc: &LegacyRpcMethods, + signer: &PairSigner, + error: ProtocolErr, +) -> Result<(), String> { + let peers_to_report: Vec = match &error { + ProtocolErr::ConnectionError { account_id, .. } + | ProtocolErr::EncryptedConnection { account_id, .. } + | ProtocolErr::BadSubscribeMessage { account_id, .. } + | ProtocolErr::Subscribe { account_id, .. } => vec![account_id.clone()], + + ProtocolErr::Timeout { inactive_peers, .. } => inactive_peers.clone(), + _ => vec![], + }; + + // This is a non-reportable error, so we don't do any further processing with the error + if peers_to_report.is_empty() { + return Err(error.to_string()); + } + + tracing::debug!("Reporting `{:?}` for `{}`", peers_to_report.clone(), error.to_string()); + + let mut failed_reports = Vec::new(); + for peer in peers_to_report { + let report_unstable_peer_tx = + entropy::tx().staking_extension().report_unstable_peer(peer.clone()); + + if let Err(tx_error) = + submit_transaction(api, rpc, signer, &report_unstable_peer_tx, None).await + { + failed_reports.push(format!("{}", tx_error)); + } + } + + if failed_reports.is_empty() { + Err(error.to_string()) + } else { + Err(format!( + "Failed to report peers for `{}` due to `{}`)", + error, + failed_reports.join(", ") + )) + } +} + /// HTTP POST endpoint called by the off-chain worker (Propagation pallet) during the network /// jumpstart. /// diff --git a/crates/threshold-signature-server/src/user/tests.rs b/crates/threshold-signature-server/src/user/tests.rs index 9d2af8453..971213f6c 100644 --- a/crates/threshold-signature-server/src/user/tests.rs +++ b/crates/threshold-signature-server/src/user/tests.rs @@ -36,10 +36,10 @@ use entropy_testing_utils::{ entropy::runtime_types::pallet_registry::pallet::ProgramInstance as OtherProgramInstance, }, constants::{ - AUXILARY_DATA_SHOULD_SUCCEED, FAUCET_PROGRAM, FERDIE_X25519_SECRET_KEY, - PREIMAGE_SHOULD_SUCCEED, TEST_BASIC_TRANSACTION, TEST_INFINITE_LOOP_BYTECODE, - TEST_ORACLE_BYTECODE, TEST_PROGRAM_CUSTOM_HASH, TEST_PROGRAM_WASM_BYTECODE, - X25519_PUBLIC_KEYS, + AUXILARY_DATA_SHOULD_SUCCEED, BOB_STASH_ADDRESS, CHARLIE_STASH_ADDRESS, FAUCET_PROGRAM, + FERDIE_X25519_SECRET_KEY, PREIMAGE_SHOULD_SUCCEED, TEST_BASIC_TRANSACTION, + TEST_INFINITE_LOOP_BYTECODE, TEST_ORACLE_BYTECODE, TEST_PROGRAM_CUSTOM_HASH, + TEST_PROGRAM_WASM_BYTECODE, TSS_ACCOUNTS, X25519_PUBLIC_KEYS, }, helpers::spawn_tss_nodes_and_start_chain, substrate_context::{test_context_stationary, testing_context}, @@ -680,16 +680,15 @@ async fn test_fails_to_sign_if_non_signing_group_participants_are_used() { initialize_test_logger().await; clean_tests(); - let one = AccountKeyring::One; - let two = AccountKeyring::Two; + let user = AccountKeyring::One; + let deployer = AccountKeyring::Two; let (_ctx, entropy_api, rpc, _validator_ips, _validator_ids) = spawn_tss_nodes_and_start_chain(ChainSpecType::IntegrationJumpStarted).await; - let non_signer = ValidatorName::Dave; // Register the user with a test program let (verifying_key, _program_hash) = - store_program_and_register(&entropy_api, &rpc, &one.pair(), &two.pair()).await; + store_program_and_register(&entropy_api, &rpc, &user.pair(), &deployer.pair()).await; let (_validators_info, signature_request, validator_ips_and_keys) = get_sign_tx_data(&entropy_api, &rpc, hex::encode(PREIMAGE_SHOULD_SUCCEED), verifying_key) @@ -697,6 +696,7 @@ async fn test_fails_to_sign_if_non_signing_group_participants_are_used() { let message_hash = Hasher::keccak(PREIMAGE_SHOULD_SUCCEED); + let non_signer = ValidatorName::Dave; let mnemonic = development_mnemonic(&Some(non_signer)); let (tss_signer, _static_secret) = get_signer_and_x25519_secret_from_mnemonic(&mnemonic.to_string()).unwrap(); @@ -710,11 +710,9 @@ async fn test_fails_to_sign_if_non_signing_group_participants_are_used() { }); // Test attempting to connect over ws by someone who is not in the signing group - let validator_ip_and_key: (String, [u8; 32], subxtAccountId32) = ( - validator_ips_and_keys[0].clone().0, - validator_ips_and_keys[0].clone().1, - one.to_account_id().into(), - ); + let validator_ip_and_key: (String, [u8; 32]) = + (validator_ips_and_keys[0].clone().0, validator_ips_and_keys[0].clone().1); + let connection_attempt_handle = tokio::spawn(async move { // Wait for the "user" to submit the signing request tokio::time::sleep(Duration::from_millis(500)).await; @@ -748,6 +746,7 @@ async fn test_fails_to_sign_if_non_signing_group_participants_are_used() { // returns true if this part of the test passes encrypted_connection.recv().await.is_err() }); + let validator_ip_and_key: (String, [u8; 32]) = (validator_ips_and_keys[0].clone().0, validator_ips_and_keys[0].clone().1); @@ -768,6 +767,210 @@ async fn test_fails_to_sign_if_non_signing_group_participants_are_used() { clean_tests(); } +#[tokio::test] +#[serial] +async fn test_reports_peer_if_they_reject_our_signing_protocol_connection() { + initialize_test_logger().await; + clean_tests(); + + // Setup: We first spin up the chain nodes, TSS servers, and register an account + let user = AccountKeyring::One; + let deployer = AccountKeyring::Two; + + let (_ctx, entropy_api, rpc, validator_ips, _validator_ids) = + spawn_tss_nodes_and_start_chain(ChainSpecType::IntegrationJumpStarted).await; + + // Register the user with a test program + let (verifying_key, _program_hash) = + store_program_and_register(&entropy_api, &rpc, &user.pair(), &deployer.pair()).await; + + let (_validators_info, signature_request, _validator_ips_and_keys) = + get_sign_tx_data(&entropy_api, &rpc, hex::encode(PREIMAGE_SHOULD_SUCCEED), verifying_key) + .await; + + // TSS Setup: We want Alice to be the TSS server which starts the signing protocol, so let's + // get her set up + let signer = ValidatorName::Alice; + + let mnemonic = development_mnemonic(&Some(signer)); + let (tss_signer, _static_secret) = + get_signer_and_x25519_secret_from_mnemonic(&mnemonic.to_string()).unwrap(); + + let validator_ip_and_key: (String, [u8; 32]) = + (validator_ips[0].clone(), X25519_PUBLIC_KEYS[0]); + + // The other signer can rotate between Bob and Charlie, but we want to always test with Bob + // since we know that: + // - As Alice, we will initiate a connection with him + // - He won't respond to our request (never got his `/sign_tx` endpoint triggered) + let signers = { + let alice = ValidatorInfo { + ip_address: validator_ips[0].clone(), + x25519_public_key: X25519_PUBLIC_KEYS[0], + tss_account: TSS_ACCOUNTS[0].clone(), + }; + + let bob = ValidatorInfo { + ip_address: validator_ips[1].clone(), + x25519_public_key: X25519_PUBLIC_KEYS[1], + tss_account: TSS_ACCOUNTS[1].clone(), + }; + + vec![alice, bob] + }; + + // Before starting the test, we want to ensure that Bob has no outstanding reports against him. + let bob_report_query = + entropy::storage().slashing().failed_registrations(BOB_STASH_ADDRESS.clone()); + let reports = query_chain(&entropy_api, &rpc, bob_report_query.clone(), None).await.unwrap(); + assert!(reports.is_none()); + + // Test: Now, we want to initiate a signing session _without_ going through the relayer. So we + // skip that step using this helper. + let test_user_bad_connection_res = submit_transaction_sign_tx_requests( + &entropy_api, + &rpc, + validator_ip_and_key, + signature_request.clone(), + tss_signer.signer().clone(), + Some(signers), + ) + .await; + + // Check: We expect that the signature request will have failed because we were unable to + // connect to Bob. + assert!(test_user_bad_connection_res + .unwrap() + .text() + .await + .unwrap() + .contains("Subscribe message rejected")); + + // We expect that a `NoteReport` event want found + let report_event_found = tokio::time::timeout( + std::time::Duration::from_secs(30), + subscribe_to_report_event(&entropy_api), + ) + .await + .expect("Timed out while waiting for `NoteReport` event."); + + assert!(report_event_found); + + // We expect that the offence count for Bob has gone up + let reports = query_chain(&entropy_api, &rpc, bob_report_query, None).await.unwrap(); + assert!(matches!(reports, Some(1))); + + clean_tests(); +} + +#[tokio::test] +#[serial] +async fn test_reports_peer_if_they_dont_initiate_a_signing_session() { + initialize_test_logger().await; + clean_tests(); + + // Setup: We first spin up the chain nodes, TSS servers, and register an account + let user = AccountKeyring::One; + let deployer = AccountKeyring::Two; + + let (_ctx, entropy_api, rpc, validator_ips, _validator_ids) = + spawn_tss_nodes_and_start_chain(ChainSpecType::IntegrationJumpStarted).await; + + // Register the user with a test program + let (verifying_key, _program_hash) = + store_program_and_register(&entropy_api, &rpc, &user.pair(), &deployer.pair()).await; + + let (_validators_info, signature_request, _validator_ips_and_keys) = + get_sign_tx_data(&entropy_api, &rpc, hex::encode(PREIMAGE_SHOULD_SUCCEED), verifying_key) + .await; + + // TSS Setup: We want Alice to be the TSS server which starts the signing protocol, so let's + // get her set up + let signer = ValidatorName::Alice; + + let mnemonic = development_mnemonic(&Some(signer)); + let (tss_signer, _static_secret) = + get_signer_and_x25519_secret_from_mnemonic(&mnemonic.to_string()).unwrap(); + + let validator_ip_and_key: (String, [u8; 32]) = + (validator_ips[0].clone(), X25519_PUBLIC_KEYS[0]); + + // The other signer can rotate between Bob and Charlie, but we want to always test with Charlie + // since we know that: + // - As Alice, Charlie will initiate a connection with us + // - He won't initate a request (never got his `/sign_tx` endpoint triggered) + let signers = { + let alice = ValidatorInfo { + ip_address: validator_ips[0].clone(), + x25519_public_key: X25519_PUBLIC_KEYS[0], + tss_account: TSS_ACCOUNTS[0].clone(), + }; + + let charlie = ValidatorInfo { + ip_address: validator_ips[2].clone(), + x25519_public_key: X25519_PUBLIC_KEYS[2], + tss_account: TSS_ACCOUNTS[2].clone(), + }; + + vec![alice, charlie] + }; + + // Before starting the test, we want to ensure that Charlie has no outstanding reports against him. + let charlie_report_query = + entropy::storage().slashing().failed_registrations(CHARLIE_STASH_ADDRESS.clone()); + let reports = + query_chain(&entropy_api, &rpc, charlie_report_query.clone(), None).await.unwrap(); + assert!(reports.is_none()); + + // Test: Now, we want to initiate a signing session _without_ going through the relayer. So we + // skip that step using this helper. + let test_user_bad_connection_res = submit_transaction_sign_tx_requests( + &entropy_api, + &rpc, + validator_ip_and_key, + signature_request.clone(), + tss_signer.signer().clone(), + Some(signers), + ) + .await; + + // Check: We expect that the signature request will have failed because Charlie never initated a + // connection with us. + assert!(test_user_bad_connection_res.unwrap().text().await.unwrap().contains("Timed out")); + + // We expect that a `NoteReport` event want found + let report_event_found = tokio::time::timeout( + std::time::Duration::from_secs(30), + subscribe_to_report_event(&entropy_api), + ) + .await + .expect("Timed out while waiting for `NoteReport` event."); + + assert!(report_event_found); + + // We expect that the offence count for Charlie has gone up + let reports = query_chain(&entropy_api, &rpc, charlie_report_query, None).await.unwrap(); + assert!(matches!(reports, Some(1))); + + clean_tests(); +} + +/// Helper for subscribing to the `NoteReport` event from the Slashing pallet. +async fn subscribe_to_report_event(api: &OnlineClient) -> bool { + let mut blocks_sub = api.blocks().subscribe_best().await.unwrap(); + + while let Some(block) = blocks_sub.next().await { + let block = block.unwrap(); + let events = block.events().await.unwrap(); + + if events.has::().unwrap() { + return true; + } + } + + false +} + #[tokio::test] #[serial] async fn test_program_with_config() {