diff --git a/docker-compose.yml b/docker-compose.yml index 65248fd26..2f83fe52f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,6 +79,8 @@ services: - 'anvil -b 2 --steps-tracing --order fifo --base-fee 0' environment: ANVIL_IP_ADDR: '0.0.0.0' + ports: + - '8545:8545' profiles: - evm - bridge @@ -94,9 +96,13 @@ services: - '--target-url=ws://chain-2-evm:8545' - '--network-id=2' - '--backend=evm' + - '--cctp-sender=000000000000000000000000ab5976445202ac58cdf7da9cb5797a70e6be1cdc' + - '--cctp-attestation=https://iris-api-sandbox.circle.com/attestations/' environment: RUST_LOG: 'tc_subxt=debug,chronicle=debug,tss=debug,gmp_evm=info' RUST_BACKTRACE: 1 + ports: + - '8080:8080' profiles: - evm - bridge @@ -107,6 +113,8 @@ services: - 'anvil -b 2 --steps-tracing --order fifo --base-fee 0' environment: ANVIL_IP_ADDR: '0.0.0.0' + ports: + - '8546:8545' profiles: - evm @@ -121,9 +129,13 @@ services: - '--target-url=ws://chain-3-evm:8545' - '--network-id=3' - '--backend=evm' + - '--cctp-sender=000000000000000000000000ab5976445202ac58cdf7da9cb5797a70e6be1cdc' + - '--cctp-attestation=https://iris-api-sandbox.circle.com/attestations/' environment: RUST_LOG: 'tc_subxt=debug,chronicle=debug,tss=debug,gmp_evm=info' RUST_BACKTRACE: 1 + ports: + - '8081:8080' profiles: - evm diff --git a/gmp/evm/src/lib.rs b/gmp/evm/src/lib.rs index b53dcb0d0..a21908402 100644 --- a/gmp/evm/src/lib.rs +++ b/gmp/evm/src/lib.rs @@ -55,8 +55,8 @@ pub struct Connector { wallet: Arc, backend: Adapter, url: String, - cctp_sender: Option, - cctp_attestation: String, + cctp_sender: Option>, + cctp_attestation: Option, cctp_queue: Arc>>, // Temporary fix to avoid nonce overlap wallet_guard: Arc>, @@ -288,11 +288,9 @@ impl Connector { &self, burn_hash: [u8; 32], ) -> Result { - let url = format!( - "{}/0x{}", - &self.cctp_attestation.trim_end_matches('/'), - hex::encode(burn_hash) - ); + let uri = self.cctp_attestation.as_deref().ok_or(CctpError::AttestationDisabled)?; + let uri = uri.trim_end_matches('/'); + let url = format!("{}/0x{}", uri, hex::encode(burn_hash)); let client = Client::new(); let response = client .get(&url) @@ -341,6 +339,44 @@ impl Connector { } attested_msgs } + + async fn deploy_cctp_contract( + &self, + additional_params: &[u8], + gateway: Address, + tester: &[u8], + ) -> Result { + let config: DeploymentConfig = serde_json::from_slice(additional_params)?; + let mut bytecode = extract_bytecode(tester)?; + let constructor = sol::GmpTester::constructorCall { gateway: a_addr(gateway) }; + let factory = a_addr(self.parse_address(&config.factory_address)?); + let tester_addr = compute_create2_address( + factory.into(), + config.deployment_salt, + &bytecode, + constructor.clone(), + )?; + bytecode.extend(constructor.abi_encode()); + let is_tester_deployed = + self.backend.get_code(tester_addr.0 .0.into(), AtBlock::Latest).await?; + if !is_tester_deployed.is_empty() { + Ok(tester_addr) + } else { + let call = sol::IUniversalFactory::create2_0Call { + salt: config.deployment_salt.into(), + creationCode: bytecode.into(), + } + .abi_encode(); + let (addr, _) = self.deploy_contract_with_factory(&config, call).await?; + Ok(addr) + } + } + + async fn get_gateway_nonce(&self, gateway: Address, account: Address) -> Result { + let call = sol::Gateway::nonceOfCall { account: a_addr(account) }; + let result = self.evm_call(gateway, call, 0, None, None).await?; + Ok(result.0._0) + } } #[async_trait] @@ -366,13 +402,33 @@ impl IConnectorBuilder for Connector { .await .with_context(|| "Cannot get ws client for url: {url}")?; let adapter = Adapter(client); + let cctp_sender: Result>> = params + .cctp_sender + .map(|items| { + items + .split(',') + .map(|s| s.trim()) + .map(|item| { + let clean_hex = item.strip_prefix("0x").unwrap_or(item); + let bytes = hex::decode(clean_hex) + .map_err(|e| anyhow::anyhow!("Hex decode error: {}", e))?; + let arr: Address = bytes + .try_into() + .map_err(|_| anyhow::anyhow!("Invalid address length"))?; + Ok(arr) + }) + .collect::>>() + }) + .transpose(); + let cctp_sender = cctp_sender?; + let connector = Self { network_id: params.network_id, wallet, backend: adapter, url: params.url, - cctp_sender: params.cctp_sender, - cctp_attestation: params.cctp_attestation.unwrap_or("".into()), + cctp_sender, + cctp_attestation: params.cctp_attestation, cctp_queue: Default::default(), wallet_guard: Default::default(), }; @@ -433,9 +489,6 @@ impl IChain for Connector { impl IConnector for Connector { /// Reads gmp messages from the target chain. async fn read_events(&self, gateway: Gateway, blocks: Range) -> Result> { - let cctp_sender = - self.cctp_sender.clone().map(|item| self.parse_address(&item)).transpose()?; - let contract: [u8; 20] = a_addr(gateway).0.into(); let logs = self .wallet @@ -486,16 +539,15 @@ impl IConnector for Connector { gas_cost: log.gasCost.into(), bytes: log.data.data.into(), }; - if Some(gmp_message.src) == cctp_sender { - let mut cctp_queue = self.cctp_queue.lock().await; - cctp_queue.push((gmp_message.clone(), 0)); - } else { - tracing::info!( - "gmp created: {:?}", - hex::encode(gmp_message.message_id()) - ); - events.push(GmpEvent::MessageReceived(gmp_message)); + if let Some(senders) = &self.cctp_sender { + if senders.contains(&gmp_message.src) { + let mut cctp_queue = self.cctp_queue.lock().await; + cctp_queue.push((gmp_message.clone(), 0)); + continue; + } } + tracing::info!("gmp created: {:?}", hex::encode(gmp_message.message_id())); + events.push(GmpEvent::MessageReceived(gmp_message)); break; }, sol::Gateway::GmpExecuted::SIGNATURE_HASH => { @@ -709,13 +761,22 @@ impl IConnectorAdmin for Connector { let msg_cost: u128 = result._0.try_into().unwrap(); Ok(msg_cost) } + /// Deploys a test contract. - async fn deploy_test(&self, gateway: Address, tester: &[u8]) -> Result<(Address, u64)> { + async fn deploy_test( + &self, + additional_params: &[u8], + gateway: Address, + tester: &[u8], + ) -> Result<(Address, u64)> { let bytecode = extract_bytecode(tester)?; + let cctp_contract = self.deploy_cctp_contract(additional_params, gateway, tester).await?; + tracing::info!("CCTP contract deployed at: {:?}", hex::encode(cctp_contract)); self.deploy_contract(bytecode, sol::GmpTester::constructorCall { gateway: a_addr(gateway) }) .await } - /// Sends a message using the test contract. + + // Sends a message using the test contract. async fn send_message( &self, contract: Address, @@ -739,6 +800,58 @@ impl IConnectorAdmin for Connector { let id: MessageId = *result.0._0; Ok(id) } + + async fn send_cctp_message( + &self, + gateway: Address, + contract: Address, + dest_network: NetworkId, + dest: Address, + gas_limit: u128, + gas_cost: u128, + ) -> Result { + let cctp_msg_data = "0000000000000000000000060000000000040CDD0000000000000000000000009F3B8679C73C2FEF8B59B4F3444D4E156FB70AA50000000000000000000000009F3B8679C73C2FEF8B59B4F3444D4E156FB70AA50000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001C7D4B196CB0C7B01D743FBC6116A902379C723800000000000000000000000033A2838EABD69A081CBEBE3F11DED4086C1CFC25000000000000000000000000000000000000000000000000000000000098968000000000000000000000000033A2838EABD69A081CBEBE3F11DED4086C1CFC25"; + let msg_data = + hex::decode(cctp_msg_data).expect("Unable to create msg data from dummy cctp msg"); + let mut cctp_msg = sol::CCTP { + version: 0, + localMessageTransmitter: a_addr(contract), + localMinter: a_addr([0u8; 32]), + amount: U256::from(0), + destinationDomain: dest_network as u32, + mintRecipient: dest.into(), + burnToken: a_addr([0u8; 32]), + nonce: 0, + attestation: Vec::new().into(), + message: msg_data.clone().into(), + extraData: Vec::new().into(), + }; + let nonce = self.get_gateway_nonce(gateway, contract).await?; + let mut msg = sol::GmpMessage { + srcNetwork: self.network_id, + source: contract.into(), + destNetwork: dest_network, + dest: a_addr(dest), + nonce, + gasLimit: gas_limit as _, + data: cctp_msg.clone().abi_encode().into(), + }; + let call = sol::GmpTester::sendMessageCall { msg: msg.clone() }; + let _ = self.evm_call(contract, call, gas_cost, None, None).await?; + + // Computing valid message id for CCTP requires adding attestation in the cctp struct and then compute message_id + // Since after getting attestation the message_id of the gmp message changes we get attestation in start to match the later message_id. + let burn_hash: [u8; 32] = sha3::Keccak256::digest(&msg_data).into(); + let response = self.get_cctp_attestation(burn_hash).await?; + let attestation = + response.attestation.ok_or(anyhow::anyhow!("Failed to get msg attestation"))?; + let attestation = attestation.strip_prefix("0x").unwrap_or(&attestation); + let attestation_bytes = hex::decode(attestation).unwrap(); + cctp_msg.attestation = attestation_bytes.into(); + msg.data = cctp_msg.abi_encode().into(); + let message_id = Into::::into(msg).message_id(); + Ok(message_id) + } /// Receives messages from test contract. async fn recv_messages( &self, @@ -768,7 +881,8 @@ impl IConnectorAdmin for Connector { continue; }; let log = sol::GmpTester::MessageReceived::decode_log(&log, true)?; - msgs.push(log.msg.clone().into()); + let msg: GmpMessage = log.msg.clone().into(); + msgs.push(msg); } } Ok(msgs) @@ -891,6 +1005,8 @@ struct AttestationResponse { enum CctpError { #[error("Attestation is pending.")] AttestationPending, + #[error("Attestation is disabled.")] + AttestationDisabled, #[error("Failed to get attestation from response.")] AttestationResponse, #[error("Invalid payload.")] diff --git a/gmp/evm/src/sol.rs b/gmp/evm/src/sol.rs index 9f73f1e06..244d4c45a 100644 --- a/gmp/evm/src/sol.rs +++ b/gmp/evm/src/sol.rs @@ -127,6 +127,7 @@ alloy_sol_types::sol! { function setShards(TssKey[] calldata publicKeys) external; function routes() external view returns (Route[]); function setRoute(Route calldata info) external; + function nonceOf(address account) external view returns (uint64); function estimateMessageCost(uint16 networkid, uint256 messageSize, uint256 gasLimit) external view returns (uint256); function withdraw(uint256 amount, address recipient, bytes calldata data) external returns (bytes memory output); diff --git a/gmp/grpc/src/lib.rs b/gmp/grpc/src/lib.rs index 186971cae..45191a4da 100644 --- a/gmp/grpc/src/lib.rs +++ b/gmp/grpc/src/lib.rs @@ -247,7 +247,12 @@ impl IConnectorAdmin for Connector { Ok(()) } /// Deploys a test contract. - async fn deploy_test(&self, gateway: Address, tester: &[u8]) -> Result<(Address, u64)> { + async fn deploy_test( + &self, + _additional_params: &[u8], + gateway: Address, + tester: &[u8], + ) -> Result<(Address, u64)> { let request = Request::new(proto::DeployTestRequest { gateway, tester: tester.to_vec(), @@ -311,6 +316,19 @@ impl IConnectorAdmin for Connector { let response = self.client.lock().await.send_message(request).await?.into_inner(); Ok(response.message_id) } + + async fn send_cctp_message( + &self, + _gateway: Address, + _src: Address, + _dest_network: NetworkId, + _dest: Address, + _gas_limit: u128, + _gas_cost: u128, + ) -> Result { + anyhow::bail!("Not supported") + } + /// Receives messages from test contract. async fn recv_messages( &self, diff --git a/gmp/grpc/src/main.rs b/gmp/grpc/src/main.rs index dfa10e9c1..ee14f0500 100644 --- a/gmp/grpc/src/main.rs +++ b/gmp/grpc/src/main.rs @@ -240,7 +240,7 @@ impl Gmp for ConnectorWrapper { ) -> GmpResult { let (connector, msg) = self.connector(request)?; let (address, block) = connector - .deploy_test(msg.gateway, &msg.tester) + .deploy_test(&[], msg.gateway, &msg.tester) .await .map_err(|err| Status::unknown(err.to_string()))?; Ok(Response::new(proto::DeployTestResponse { address, block })) diff --git a/gmp/rust/src/lib.rs b/gmp/rust/src/lib.rs index e25dd8bc4..71e32b3d3 100644 --- a/gmp/rust/src/lib.rs +++ b/gmp/rust/src/lib.rs @@ -432,7 +432,12 @@ impl IConnectorAdmin for Connector { Ok(()) } - async fn deploy_test(&self, gateway: Address, _path: &[u8]) -> Result<(Address, u64)> { + async fn deploy_test( + &self, + _additional_params: &[u8], + gateway: Address, + _path: &[u8], + ) -> Result<(Address, u64)> { let mut tester = [0; 32]; getrandom::getrandom(&mut tester).unwrap(); let block = block(self.genesis); @@ -509,6 +514,18 @@ impl IConnectorAdmin for Connector { Ok(id) } + async fn send_cctp_message( + &self, + _gateway: Address, + _src: Address, + _dest_network: NetworkId, + _dest: Address, + _gas_limit: u128, + _gas_cost: u128, + ) -> Result { + anyhow::bail!("Not supported") + } + async fn recv_messages(&self, addr: Address, blocks: Range) -> Result> { let tx = self.db.begin_read()?; let t = tx.open_multimap_table(EVENTS)?; @@ -641,8 +658,8 @@ mod tests { let current = chain.block_stream().next().await.unwrap(); let events = chain.read_events(gateway, block..current).await?; assert_eq!(events, vec![GmpEvent::ShardRegistered(shard.public_key())]); - let (src, _) = chain.deploy_test(gateway, "".as_ref()).await?; - let (dest, _) = chain.deploy_test(gateway, "".as_ref()).await?; + let (src, _) = chain.deploy_test("".as_ref(), gateway, "".as_ref()).await?; + let (dest, _) = chain.deploy_test("".as_ref(), gateway, "".as_ref()).await?; let payload = vec![]; let gas_limit = chain.estimate_message_gas_limit(dest, network, src, payload.clone()).await?; diff --git a/primitives/src/gmp.rs b/primitives/src/gmp.rs index 00581e1b2..f0e8da277 100644 --- a/primitives/src/gmp.rs +++ b/primitives/src/gmp.rs @@ -411,7 +411,12 @@ pub trait IConnectorAdmin: IConnector { /// Updates an entry in the gateway routing table. async fn set_route(&self, gateway: Address, route: Route) -> Result<()>; /// Deploys a test contract. - async fn deploy_test(&self, gateway: Address, tester: &[u8]) -> Result<(Address, u64)>; + async fn deploy_test( + &self, + additional_params: &[u8], + gateway: Address, + tester: &[u8], + ) -> Result<(Address, u64)>; /// Estimates the message gas limit. async fn estimate_message_gas_limit( &self, @@ -438,6 +443,16 @@ pub trait IConnectorAdmin: IConnector { gas_cost: u128, payload: Vec, ) -> Result; + /// Sends a cctp message using the test contract and returns the message id. + async fn send_cctp_message( + &self, + gateway: Address, + src: Address, + dest_network: NetworkId, + dest: Address, + gas_limit: u128, + gas_cost: u128, + ) -> Result; /// Receives messages from test contract. async fn recv_messages(&self, contract: Address, blocks: Range) -> Result>; diff --git a/tc-cli/src/lib.rs b/tc-cli/src/lib.rs index ab17a184e..a1449eeea 100644 --- a/tc-cli/src/lib.rs +++ b/tc-cli/src/lib.rs @@ -77,7 +77,11 @@ impl Tc { url: network.url.clone(), mnemonic: env.target_mnemonic.clone(), cctp_sender: None, - cctp_attestation: None, + // TODO move to config? + // This params is for checking cctp attestation with tc-cli + cctp_attestation: Some(String::from( + "https://iris-api-sandbox.circle.com/attestations/", + )), }; let connector = async move { let connector = network @@ -964,7 +968,9 @@ impl Tc { let contracts = self.config.contracts(network)?; let (connector, gateway) = self.gateway(network).await?; self.println(None, format!("deploy tester {network}")).await?; - connector.deploy_test(gateway, &contracts.tester).await + connector + .deploy_test(&contracts.additional_params, gateway, &contracts.tester) + .await } pub async fn estimate_message_gas_limit( @@ -1034,6 +1040,47 @@ impl Tc { Ok(msg_id) } + #[allow(clippy::too_many_arguments)] + pub async fn send_cctp_message( + &self, + src_network: NetworkId, + src_addr: Address, + dest_network: NetworkId, + dest_addr: Address, + gas_limit: u128, + gas_cost: u128, + ) -> Result { + let (connector, gateway) = self.gateway(src_network).await?; + let id = self + .println( + None, + format!( + "send cctp message to {} {} with {} gas for {}", + dest_network, + self.format_address(Some(dest_network), dest_addr)?, + gas_limit, + self.format_balance(Some(src_network), gas_cost)?, + ), + ) + .await?; + let msg_id = connector + .send_cctp_message(gateway, src_addr, dest_network, dest_addr, gas_limit, gas_cost) + .await?; + self.println( + Some(id), + format!( + "sent cctp message {} to {} {} with {} gas for {}", + hex::encode(msg_id), + dest_network, + self.format_address(Some(dest_network), dest_addr)?, + gas_limit, + self.format_balance(Some(src_network), gas_cost)?, + ), + ) + .await?; + Ok(msg_id) + } + pub async fn remove_task(&self, task_id: TaskId) -> Result<()> { self.runtime.remove_task(task_id).await } diff --git a/tc-cli/src/main.rs b/tc-cli/src/main.rs index 4704e6533..c7ccaa84a 100644 --- a/tc-cli/src/main.rs +++ b/tc-cli/src/main.rs @@ -6,7 +6,7 @@ use std::io::Write; use std::path::PathBuf; use std::str::FromStr; use tc_cli::{Query, Sender, Tc}; -use time_primitives::{BatchId, Hash, NetworkId, ShardId, TaskId}; +use time_primitives::{Address, BatchId, Hash, NetworkId, ShardId, TaskId}; use tracing_subscriber::filter::EnvFilter; #[derive(Clone, Debug)] @@ -176,6 +176,20 @@ enum Command { src: NetworkId, dest: NetworkId, }, + SmokeCctp { + src: NetworkId, + #[arg( + long, + default_value = "000000000000000000000000ab5976445202ac58cdf7da9cb5797a70e6be1cdc" + )] + src_address: String, + dest: NetworkId, + #[arg( + long, + default_value = "000000000000000000000000ab5976445202ac58cdf7da9cb5797a70e6be1cdc" + )] + dest_address: String, + }, WithdrawFunds { network: NetworkId, amount: u128, @@ -422,36 +436,24 @@ async fn real_main() -> Result<()> { }, Command::SmokeTest { src, dest } => { let (src_addr, dest_addr) = tc.setup_test(src, dest).await?; - let mut blocks = tc.finality_notification_stream(); - let (_, start) = blocks.next().await.context("expected block")?; - let payload = vec![]; - let gas_limit = tc - .estimate_message_gas_limit(dest, dest_addr, src, src_addr, payload.clone()) - .await?; - let gas_cost = tc.estimate_message_cost(src, dest, gas_limit, payload.clone()).await?; - let msg_id = tc - .send_message(src, src_addr, dest, dest_addr, gas_limit, gas_cost, payload.clone()) - .await?; - let mut id = None; - let (exec, end) = loop { - let (_, end) = blocks.next().await.context("expected block")?; - let trace = tc.message_trace(src, msg_id).await?; - let exec = trace.exec.as_ref().map(|t| t.task); - tracing::info!("waiting for message {}", hex::encode(msg_id)); - id = Some(tc.print_table(id, "message", vec![trace]).await?); - if let Some(exec) = exec { - break (exec, end); - } - }; - let blocks = tc.read_events_blocks(exec).await?; - let msgs = tc.messages(dest, dest_addr, blocks).await?; - let msg = msgs - .into_iter() - .find(|msg| msg.message_id() == msg_id) - .context("failed to find message")?; - tc.print_table(None, "message", vec![msg]).await?; - tc.println(None, format!("received message after {} blocks", end - start)) - .await?; + exec_smoke(tc, src, src_addr, dest, dest_addr, SmokeType::Gmp).await?; + }, + Command::SmokeCctp { + src, + src_address, + dest, + dest_address, + } => { + tc.setup_test(src, dest).await?; + let src_addr = hex::decode(src_address) + .unwrap() + .try_into() + .expect("Unable to convert src_address to bytes32"); + let dest_addr = hex::decode(dest_address) + .unwrap() + .try_into() + .expect("Unable to convert dest_address to bytes32"); + exec_smoke(tc, src, src_addr, dest, dest_addr, SmokeType::Cctp).await?; }, Command::Benchmark { src, dest, num_messages } => { let (src_addr, dest_addr) = tc.setup_test(src, dest).await?; @@ -535,3 +537,57 @@ async fn real_main() -> Result<()> { tracing::info!("executed query in {}s", now.elapsed().unwrap().as_secs()); Ok(()) } + +async fn exec_smoke( + tc: Tc, + src: NetworkId, + src_addr: Address, + dest: NetworkId, + dest_addr: Address, + smoke_type: SmokeType, +) -> Result<()> { + const CCTP_MSG_LEN: usize = 896; + let mut blocks = tc.finality_notification_stream(); + let (_, start) = blocks.next().await.context("expected block")?; + let payload = vec![0u8; CCTP_MSG_LEN]; + let gas_limit = tc + .estimate_message_gas_limit(dest, dest_addr, src, src_addr, payload.clone()) + .await?; + let gas_cost = tc.estimate_message_cost(src, dest, gas_limit, payload.clone()).await?; + let msg_id = match smoke_type { + SmokeType::Gmp => { + tc.send_message(src, src_addr, dest, dest_addr, gas_limit, gas_cost, payload.clone()) + .await? + }, + SmokeType::Cctp => { + tc.send_cctp_message(src, src_addr, dest, dest_addr, gas_limit, gas_cost) + .await? + }, + }; + let mut id = None; + let (exec, end) = loop { + let (_, end) = blocks.next().await.context("expected block")?; + let trace = tc.message_trace(src, msg_id).await?; + let exec = trace.exec.as_ref().map(|t| t.task); + tracing::info!("waiting for message {}", hex::encode(msg_id)); + id = Some(tc.print_table(id, "message", vec![trace]).await?); + if let Some(exec) = exec { + break (exec, end); + } + }; + let blocks = tc.read_events_blocks(exec).await?; + let msgs = tc.messages(dest, dest_addr, blocks).await?; + let msg = msgs + .into_iter() + .find(|msg| msg.message_id() == msg_id) + .context("failed to find message")?; + tc.print_table(None, "message", vec![msg]).await?; + tc.println(None, format!("received message after {} blocks", end - start)) + .await?; + Ok(()) +} + +enum SmokeType { + Gmp, + Cctp, +} diff --git a/tests/common.rs b/tests/common.rs index 85be60053..ece118826 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -64,22 +64,18 @@ fn build_containers() -> Result { fn docker_up() -> Result { let mut cmd = process::Command::new("docker"); - cmd.arg("compose").arg("--profile=evm").arg("up").arg("-d").arg("--wait"); let mut child = cmd.spawn().context("Error starting containers")?; - // Wait for all containers to start child.wait().map(|c| c.success()).context("Error starting containers") } fn docker_down() -> Result { let mut cmd = process::Command::new("docker"); - cmd.arg("compose").arg("--profile=evm").arg("down"); let mut child = cmd.spawn().context("Error stopping containers")?; - // Wait for all containers to start child.wait().map(|c| c.success()).context("Error stopping containers: {e}") }