diff --git a/Cargo.lock b/Cargo.lock index edf3f894d..da4814964 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -270,6 +270,7 @@ dependencies = [ "serde_with 3.8.1", "service-registry", "sha3", + "stellar", "stellar-rs", "stellar-xdr", "sui-gateway", diff --git a/ampd/Cargo.toml b/ampd/Cargo.toml index fb7bf6fdd..840134453 100644 --- a/ampd/Cargo.toml +++ b/ampd/Cargo.toml @@ -50,6 +50,7 @@ serde_json = { workspace = true } serde_with = "3.2.0" service-registry = { workspace = true } sha3 = { workspace = true } +stellar = { workspace = true } stellar-rs = "0.3.2" stellar-xdr = { workspace = true, features = ["serde_json"] } sui-gateway = { workspace = true } diff --git a/ampd/src/config.rs b/ampd/src/config.rs index 076688b61..8bc2a3c42 100644 --- a/ampd/src/config.rs +++ b/ampd/src/config.rs @@ -125,6 +125,11 @@ mod tests { type = 'StellarMsgVerifier' cosmwasm_contract = '{}' http_url = 'http://localhost:8000' + + [[handlers]] + type = 'StellarVerifierSetVerifier' + cosmwasm_contract = '{}' + http_url = 'http://localhost:8000' ", TMAddress::random(PREFIX), TMAddress::random(PREFIX), @@ -135,10 +140,11 @@ mod tests { TMAddress::random(PREFIX), TMAddress::random(PREFIX), TMAddress::random(PREFIX), + TMAddress::random(PREFIX), ); let cfg: Config = toml::from_str(config_str.as_str()).unwrap(); - assert_eq!(cfg.handlers.len(), 9); + assert_eq!(cfg.handlers.len(), 10); } #[test] @@ -336,6 +342,12 @@ mod tests { ), http_url: Url::from_str("http://127.0.0.1").unwrap(), }, + HandlerConfig::StellarVerifierSetVerifier { + cosmwasm_contract: TMAddress::from( + AccountId::new("axelar", &[0u8; 32]).unwrap(), + ), + http_url: Url::from_str("http://127.0.0.1").unwrap(), + }, ], ..Config::default() } diff --git a/ampd/src/handlers/config.rs b/ampd/src/handlers/config.rs index a923782e2..14510ba83 100644 --- a/ampd/src/handlers/config.rs +++ b/ampd/src/handlers/config.rs @@ -59,6 +59,10 @@ pub enum Config { cosmwasm_contract: TMAddress, http_url: Url, }, + StellarVerifierSetVerifier { + cosmwasm_contract: TMAddress, + http_url: Url, + }, } fn validate_multisig_signer_config<'de, D>(configs: &[Config]) -> Result<(), D::Error> @@ -202,6 +206,22 @@ where } } +fn validate_stellar_verifier_set_verifier_config<'de, D>(configs: &[Config]) -> Result<(), D::Error> +where + D: Deserializer<'de>, +{ + match configs + .iter() + .filter(|config| matches!(config, Config::StellarMsgVerifier { .. })) + .count() + { + count if count > 1 => Err(de::Error::custom( + "only one Stellar verifier set verifier config is allowed", + )), + _ => Ok(()), + } +} + pub fn deserialize_handler_configs<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, @@ -216,6 +236,7 @@ where validate_mvx_msg_verifier_config::(&configs)?; validate_mvx_worker_set_verifier_config::(&configs)?; validate_stellar_msg_verifier_config::(&configs)?; + validate_stellar_verifier_set_verifier_config::(&configs)?; Ok(configs) } diff --git a/ampd/src/handlers/mod.rs b/ampd/src/handlers/mod.rs index 72276b63e..1f8868164 100644 --- a/ampd/src/handlers/mod.rs +++ b/ampd/src/handlers/mod.rs @@ -6,6 +6,7 @@ pub mod multisig; pub mod mvx_verify_msg; pub mod mvx_verify_verifier_set; pub(crate) mod stellar_verify_msg; +pub(crate) mod stellar_verify_verifier_set; pub mod sui_verify_msg; pub mod sui_verify_verifier_set; diff --git a/ampd/src/handlers/stellar_verify_verifier_set.rs b/ampd/src/handlers/stellar_verify_verifier_set.rs new file mode 100644 index 000000000..82b4ff4a5 --- /dev/null +++ b/ampd/src/handlers/stellar_verify_verifier_set.rs @@ -0,0 +1,300 @@ +use std::convert::TryInto; + +use async_trait::async_trait; +use axelar_wasm_std::voting::{PollId, Vote}; +use cosmrs::cosmwasm::MsgExecuteContract; +use cosmrs::tx::Msg; +use error_stack::ResultExt; +use events::Error::EventTypeMismatch; +use events::Event; +use events_derive::try_from; +use multisig::verifier_set::VerifierSet; +use prost_types::Any; +use serde::Deserialize; +use serde_with::{serde_as, DisplayFromStr}; +use stellar_xdr::curr::ScAddress; +use tokio::sync::watch::Receiver; +use tracing::{info, info_span}; +use valuable::Valuable; +use voting_verifier::msg::ExecuteMsg; + +use crate::event_processor::EventHandler; +use crate::handlers::errors::Error; +use crate::handlers::errors::Error::DeserializeEvent; +use crate::stellar::http_client::Client; +use crate::stellar::verifier::verify_verifier_set; +use crate::types::TMAddress; + +#[derive(Deserialize, Debug)] +pub struct VerifierSetConfirmation { + pub tx_id: String, + pub event_index: u32, + pub verifier_set: VerifierSet, +} + +#[serde_as] +#[derive(Deserialize, Debug)] +#[try_from("wasm-verifier_set_poll_started")] +struct PollStartedEvent { + poll_id: PollId, + #[serde_as(as = "DisplayFromStr")] + source_gateway_address: ScAddress, + verifier_set: VerifierSetConfirmation, + participants: Vec, + expires_at: u64, +} + +pub struct Handler { + verifier: TMAddress, + voting_verifier_contract: TMAddress, + http_client: Client, + latest_block_height: Receiver, +} + +impl Handler { + pub fn new( + verifier: TMAddress, + voting_verifier_contract: TMAddress, + http_client: Client, + latest_block_height: Receiver, + ) -> Self { + Self { + verifier, + voting_verifier_contract, + http_client, + latest_block_height, + } + } + + fn vote_msg(&self, poll_id: PollId, votes: Vec) -> MsgExecuteContract { + MsgExecuteContract { + sender: self.verifier.as_ref().clone(), + contract: self.voting_verifier_contract.as_ref().clone(), + msg: serde_json::to_vec(&ExecuteMsg::Vote { poll_id, votes }) + .expect("vote msg should serialize"), + funds: vec![], + } + } +} + +#[async_trait] +impl EventHandler for Handler { + type Err = Error; + + async fn handle(&self, event: &Event) -> error_stack::Result, Self::Err> { + if !event.is_from_contract(self.voting_verifier_contract.as_ref()) { + return Ok(vec![]); + } + + let PollStartedEvent { + poll_id, + source_gateway_address, + verifier_set, + expires_at, + participants, + } = match event.try_into() as error_stack::Result<_, _> { + Err(report) if matches!(report.current_context(), EventTypeMismatch(_)) => { + return Ok(vec![]) + } + event => event.change_context(DeserializeEvent)?, + }; + + if !participants.contains(&self.verifier) { + return Ok(vec![]); + } + + if *self.latest_block_height.borrow() >= expires_at { + info!(poll_id = poll_id.to_string(), "skipping expired poll"); + return Ok(vec![]); + } + + let transaction_response = self + .http_client + .transaction_response(verifier_set.tx_id.clone()) + .await + .change_context(Error::TxReceipts)?; + + let vote = info_span!( + "verify a new verifier set", + poll_id = poll_id.to_string(), + id = format!("{}-{}", verifier_set.tx_id, verifier_set.event_index), + ) + .in_scope(|| { + info!("ready to verify verifier set in poll",); + + let vote = transaction_response.map_or(Vote::NotFound, |tx_receipt| { + verify_verifier_set(&source_gateway_address, &tx_receipt, &verifier_set) + }); + + info!( + vote = vote.as_value(), + "ready to vote for a new verifier set in poll" + ); + + vote + }); + + Ok(vec![self + .vote_msg(poll_id, vec![vote]) + .into_any() + .expect("vote msg should serialize")]) + } +} + +#[cfg(test)] +mod tests { + use std::convert::TryInto; + + use cosmrs::cosmwasm::MsgExecuteContract; + use cosmrs::tx::Msg; + use error_stack::Result; + use events::Error::{DeserializationFailed, EventTypeMismatch}; + use events::Event; + use multisig::key::KeyType; + use multisig::test::common::{build_verifier_set, ed25519_test_data}; + use stellar_xdr::curr::ScAddress; + use tokio::sync::watch; + use tokio::test as async_test; + use voting_verifier::events::{PollMetadata, PollStarted, VerifierSetConfirmation}; + + use super::PollStartedEvent; + use crate::event_processor::EventHandler; + use crate::handlers::tests::{into_structured_event, participants}; + use crate::stellar::http_client::Client; + use crate::types::{Hash, TMAddress}; + use crate::PREFIX; + + #[test] + fn should_not_deserialize_incorrect_event() { + // incorrect event type + let mut event: Event = into_structured_event( + poll_started_event(participants(5, None), 100), + &TMAddress::random(PREFIX), + ); + match event { + Event::Abci { + ref mut event_type, .. + } => { + *event_type = "incorrect".into(); + } + _ => panic!("incorrect event type"), + } + let event: Result = (&event).try_into(); + + assert!(matches!( + event.unwrap_err().current_context(), + EventTypeMismatch(_) + )); + + // invalid field + let mut event: Event = into_structured_event( + poll_started_event(participants(5, None), 100), + &TMAddress::random(PREFIX), + ); + match event { + Event::Abci { + ref mut attributes, .. + } => { + attributes.insert("source_gateway_address".into(), "invalid".into()); + } + _ => panic!("incorrect event type"), + } + + let event: Result = (&event).try_into(); + + assert!(matches!( + event.unwrap_err().current_context(), + DeserializationFailed(_, _) + )); + } + + #[test] + fn should_deserialize_correct_event() { + let event: Event = into_structured_event( + poll_started_event(participants(5, None), 100), + &TMAddress::random(PREFIX), + ); + let event: Result = event.try_into(); + assert!(event.is_ok()); + } + + #[async_test] + async fn contract_is_not_voting_verifier() { + let event = into_structured_event( + poll_started_event(participants(5, None), 100), + &TMAddress::random(PREFIX), + ); + + let handler = super::Handler::new( + TMAddress::random(PREFIX), + TMAddress::random(PREFIX), + Client::faux(), + watch::channel(0).1, + ); + + assert_eq!(handler.handle(&event).await.unwrap(), vec![]); + } + + #[async_test] + async fn verifier_is_not_a_participant() { + let voting_verifier = TMAddress::random(PREFIX); + let event = into_structured_event( + poll_started_event(participants(5, None), 100), + &voting_verifier, + ); + + let handler = super::Handler::new( + TMAddress::random(PREFIX), + voting_verifier, + Client::faux(), + watch::channel(0).1, + ); + + assert_eq!(handler.handle(&event).await.unwrap(), vec![]); + } + + #[async_test] + async fn should_vote_correctly() { + let mut client = Client::faux(); + faux::when!(client.transaction_response).then(|_| Ok(None)); + + let voting_verifier = TMAddress::random(PREFIX); + let verifier = TMAddress::random(PREFIX); + let event = into_structured_event( + poll_started_event(participants(5, Some(verifier.clone())), 100), + &voting_verifier, + ); + + let handler = super::Handler::new(verifier, voting_verifier, client, watch::channel(0).1); + + let actual = handler.handle(&event).await.unwrap(); + assert_eq!(actual.len(), 1); + assert!(MsgExecuteContract::from_any(actual.first().unwrap()).is_ok()); + } + + fn poll_started_event(participants: Vec, expires_at: u64) -> PollStarted { + PollStarted::VerifierSet { + metadata: PollMetadata { + poll_id: "100".parse().unwrap(), + source_chain: "stellar".parse().unwrap(), + source_gateway_address: ScAddress::Contract(stellar_xdr::curr::Hash::from( + Hash::random().0, + )) + .to_string() + .try_into() + .unwrap(), + confirmation_height: 15, + expires_at, + participants: participants + .into_iter() + .map(|addr| cosmwasm_std::Addr::unchecked(addr.to_string())) + .collect(), + }, + verifier_set: VerifierSetConfirmation { + tx_id: format!("{:x}", Hash::random()).parse().unwrap(), + event_index: 0, + verifier_set: build_verifier_set(KeyType::Ed25519, &ed25519_test_data::signers()), + }, + } + } +} diff --git a/ampd/src/lib.rs b/ampd/src/lib.rs index 0a5842fb4..b9183adac 100644 --- a/ampd/src/lib.rs +++ b/ampd/src/lib.rs @@ -371,6 +371,22 @@ where ), event_processor_config.clone(), ), + handlers::config::Config::StellarVerifierSetVerifier { + cosmwasm_contract, + http_url, + } => self.create_handler_task( + "stellar-verifier-set-verifier", + handlers::stellar_verify_verifier_set::Handler::new( + verifier.clone(), + cosmwasm_contract, + stellar::http_client::Client::new( + http_url.to_string().trim_end_matches('/').into(), + ) + .change_context(Error::Connection)?, + self.block_height_monitor.latest_block_height(), + ), + event_processor_config.clone(), + ), }; self.event_processor = self.event_processor.add_task(task); } diff --git a/ampd/src/stellar/http_client.rs b/ampd/src/stellar/http_client.rs index d900a3e2c..8711c2970 100644 --- a/ampd/src/stellar/http_client.rs +++ b/ampd/src/stellar/http_client.rs @@ -105,4 +105,17 @@ impl Client { }) .collect::>()) } + + pub async fn transaction_response(&self, tx_hash: String) -> Result, Error> { + let tx_hash = SingleTransactionRequest::new() + .set_transaction_hash(tx_hash) + .map_err(|err_str| report!(Error::TxHash).attach_printable(err_str))?; + + Ok(self + .0 + .get_single_transaction(&tx_hash) + .await + .map(|tx_response| Some(tx_response.into())) + .unwrap_or_default()) + } } diff --git a/ampd/src/stellar/verifier.rs b/ampd/src/stellar/verifier.rs index 409d01534..fa4280bb7 100644 --- a/ampd/src/stellar/verifier.rs +++ b/ampd/src/stellar/verifier.rs @@ -1,12 +1,15 @@ use std::str::FromStr; use axelar_wasm_std::voting::Vote; +use stellar::WeightedSigners; use stellar_xdr::curr::{ContractEventBody, ScAddress, ScSymbol, ScVal, StringM}; use crate::handlers::stellar_verify_msg::Message; +use crate::handlers::stellar_verify_verifier_set::VerifierSetConfirmation; use crate::stellar::http_client::TxResponse; const TOPIC_CALLED: &str = "called"; +const TOPIC_ROTATED: &str = "rotated"; impl PartialEq for Message { fn eq(&self, event: &ContractEventBody) -> bool { @@ -41,22 +44,78 @@ impl PartialEq for Message { } } -pub fn verify_message(gateway_address: &ScAddress, tx_receipt: &TxResponse, msg: &Message) -> Vote { - if tx_receipt.has_failed() { - return Vote::FailedOnChain; +impl PartialEq for VerifierSetConfirmation { + fn eq(&self, event: &ContractEventBody) -> bool { + let ContractEventBody::V0(body) = event; + + if body.topics.len() != 3 { + return false; + } + + let [symbol, _, signer_hash] = &body.topics[..] else { + return false; + }; + + let expected_topic: ScVal = + ScSymbol(StringM::from_str(TOPIC_ROTATED).expect("must convert str to ScSymbol")) + .into(); + + WeightedSigners::try_from(&self.verifier_set) + .ok() + .and_then(|signers| signers.hash().ok()) + .and_then(|hash| ScVal::try_from(hash).ok()) + .map_or(false, |hash| { + symbol == &expected_topic && signer_hash == &hash + }) } +} + +pub fn verify_message(gateway_address: &ScAddress, tx_receipt: &TxResponse, msg: &Message) -> Vote { + verify( + gateway_address, + tx_receipt, + msg, + msg.tx_id.clone(), + msg.event_index, + ) +} - if msg.tx_id != tx_receipt.transaction_hash { +pub fn verify_verifier_set( + gateway_address: &ScAddress, + tx_receipt: &TxResponse, + verifier_set_confirmation: &VerifierSetConfirmation, +) -> Vote { + verify( + gateway_address, + tx_receipt, + verifier_set_confirmation, + verifier_set_confirmation.tx_id.clone(), + verifier_set_confirmation.event_index, + ) +} + +fn verify<'a>( + gateway_address: &ScAddress, + tx_receipt: &'a TxResponse, + to_verify: impl PartialEq<&'a ContractEventBody>, + expected_tx_id: String, + expected_event_index: u32, +) -> Vote { + if expected_tx_id != tx_receipt.transaction_hash { return Vote::NotFound; } - match tx_receipt.event(msg.event_index) { + if tx_receipt.has_failed() { + return Vote::FailedOnChain; + } + + match tx_receipt.event(expected_event_index) { Some(event) if event .clone() .contract_id .is_some_and(|hash| ScAddress::Contract(hash) == *gateway_address) - && msg == &event.body => + && to_verify == &event.body => { Vote::SucceededOnChain } @@ -70,17 +129,26 @@ mod test { use axelar_wasm_std::voting::Vote; use cosmrs::tx::MessageExt; + use cosmwasm_std::{Addr, HexBinary, Uint128}; use ed25519_dalek::SigningKey; + use multisig::key::KeyType; + use multisig::msg::Signer; + use multisig::verifier_set::VerifierSet; use rand::rngs::OsRng; + use stellar::WeightedSigners; use stellar_xdr::curr::{ AccountId, BytesM, ContractEvent, ContractEventBody, ContractEventType, ContractEventV0, PublicKey, ScAddress, ScBytes, ScString, ScSymbol, ScVal, StringM, Uint256, }; use crate::handlers::stellar_verify_msg::Message; + use crate::handlers::stellar_verify_verifier_set::VerifierSetConfirmation; use crate::stellar::http_client::TxResponse; - use crate::stellar::verifier::{verify_message, TOPIC_CALLED}; + use crate::stellar::verifier::{ + verify_message, verify_verifier_set, TOPIC_CALLED, TOPIC_ROTATED, + }; use crate::types::{EVMAddress, Hash}; + use crate::PREFIX; #[test] fn should_not_verify_msg_if_tx_id_does_not_match() { @@ -109,8 +177,7 @@ mod test { let (gateway_address, tx_response, mut msg) = matching_msg_and_tx_block(); // Generate a different source address - let mut csprng = OsRng; - let signing_key = SigningKey::generate(&mut csprng); + let signing_key = SigningKey::generate(&mut OsRng); let account_id = AccountId(PublicKey::PublicKeyTypeEd25519(Uint256::from( signing_key.verifying_key().to_bytes(), ))); @@ -167,12 +234,63 @@ mod test { ); } + #[test] + fn should_not_verify_verifier_set_if_tx_id_does_not_match() { + let (gateway_address, tx_response, mut confirmation) = matching_verifier_set_and_tx_block(); + confirmation.tx_id = "different_tx_hash".to_string(); + + assert_eq!( + verify_verifier_set(&gateway_address, &tx_response, &confirmation), + Vote::NotFound + ); + } + + #[test] + fn should_not_verify_verifier_set_if_event_index_does_not_match() { + let (gateway_address, tx_response, mut confirmation) = matching_verifier_set_and_tx_block(); + confirmation.event_index = 1; + + assert_eq!( + verify_verifier_set(&gateway_address, &tx_response, &confirmation), + Vote::NotFound + ); + } + + #[test] + fn should_not_verify_verifier_set_if_signer_hash_does_not_match() { + let (gateway_address, tx_response, mut confirmation) = matching_verifier_set_and_tx_block(); + + let signers = vec![random_signer(), random_signer(), random_signer()]; + confirmation.verifier_set = VerifierSet { + signers: signers + .iter() + .map(|signer| (signer.address.to_string(), signer.clone())) + .collect(), + threshold: Uint128::new(2u128), + created_at: rand::random(), + }; + + assert_eq!( + verify_verifier_set(&gateway_address, &tx_response, &confirmation), + Vote::NotFound + ); + } + + #[test] + fn should_verify_verifier_set_if_correct() { + let (gateway_address, tx_response, confirmation) = matching_verifier_set_and_tx_block(); + + assert_eq!( + verify_verifier_set(&gateway_address, &tx_response, &confirmation), + Vote::SucceededOnChain + ); + } + fn matching_msg_and_tx_block() -> (ScAddress, TxResponse, Message) { let account_id = stellar_xdr::curr::Hash::from(Hash::random().0); let gateway_address = ScAddress::Contract(account_id.clone()); - let mut csprng = OsRng; - let signing_key = SigningKey::generate(&mut csprng); + let signing_key = SigningKey::generate(&mut OsRng); let msg = Message { tx_id: Hash::random().to_string(), @@ -223,4 +341,76 @@ mod test { (gateway_address, tx_response, msg) } + + fn matching_verifier_set_and_tx_block() -> (ScAddress, TxResponse, VerifierSetConfirmation) { + let account_id = stellar_xdr::curr::Hash::from(Hash::random().0); + let gateway_address = ScAddress::Contract(account_id.clone()); + + let signers = vec![random_signer(), random_signer(), random_signer()]; + let created_at = rand::random(); + let threshold = Uint128::new(2u128); + + let verifier_set_confirmation = VerifierSetConfirmation { + tx_id: Hash::random().to_string(), + event_index: 0, + verifier_set: VerifierSet { + signers: signers + .iter() + .map(|signer| (signer.address.to_string(), signer.clone())) + .collect(), + threshold, + created_at, + }, + }; + + let weighted_signers = + WeightedSigners::try_from(&verifier_set_confirmation.verifier_set).unwrap(); + let signer_hash = weighted_signers.hash().unwrap(); + + let event_body = ContractEventBody::V0(ContractEventV0 { + topics: vec![ + ScVal::Symbol(ScSymbol(StringM::from_str(TOPIC_ROTATED).unwrap())), + ScVal::Bytes(ScBytes( + BytesM::try_from(Hash::random().to_fixed_bytes()).unwrap(), + )), + ScVal::try_from(signer_hash).unwrap(), + ] + .try_into() + .unwrap(), + data: ScVal::Vec(None), + }); + + let event = ContractEvent { + ext: stellar_xdr::curr::ExtensionPoint::V0, + contract_id: Some(account_id), + type_: ContractEventType::Contract, + body: event_body, + }; + + let tx_response = TxResponse { + transaction_hash: verifier_set_confirmation.tx_id.clone(), + source_address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519( + Uint256::from(SigningKey::generate(&mut OsRng).verifying_key().to_bytes()), + ))), + successful: true, + contract_events: Some(vec![event].try_into().unwrap()), + }; + + (gateway_address, tx_response, verifier_set_confirmation) + } + + pub fn random_signer() -> Signer { + let priv_key: ecdsa::SigningKey = ecdsa::SigningKey::random(&mut OsRng); + let pub_key: cosmrs::crypto::PublicKey = priv_key.verifying_key().into(); + + let ed25519_pub_key = SigningKey::generate(&mut OsRng).verifying_key().to_bytes(); + + Signer { + address: Addr::unchecked(pub_key.account_id(PREFIX).unwrap()), + weight: Uint128::one(), + pub_key: (KeyType::Ed25519, HexBinary::from(ed25519_pub_key)) + .try_into() + .unwrap(), + } + } } diff --git a/ampd/src/tests/config_template.toml b/ampd/src/tests/config_template.toml index 4eac79264..4e6828eb6 100644 --- a/ampd/src/tests/config_template.toml +++ b/ampd/src/tests/config_template.toml @@ -77,6 +77,11 @@ type = 'StellarMsgVerifier' cosmwasm_contract = 'axelar1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqecnww6' http_url = 'http://127.0.0.1/' +[[handlers]] +type = 'StellarVerifierSetVerifier' +cosmwasm_contract = 'axelar1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqecnww6' +http_url = 'http://127.0.0.1/' + [tofnd_config] url = 'http://localhost:50051/' party_uid = 'ampd'