Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,8 @@ jobs:
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
- name: 'Install the dependencies'
run: 'pnpm install --frozen-lockfile'
- name: 'Download build artifacts'
Expand Down
35 changes: 30 additions & 5 deletions crates/evm-helpers/src/retry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ use tracing::info;
const RETRY_MAX_ATTEMPTS: u32 = 3;
const RETRY_INITIAL_DELAY_MS: u64 = 2000;

fn should_retry_error(error: &str, retry_on_errors: &[&str]) -> bool {
fn should_retry_error(error: &str, decoded_error: Option<&str>, retry_on_errors: &[&str]) -> bool {
if retry_on_errors.is_empty() {
return true;
}
retry_on_errors.iter().any(|code| error.contains(code))
retry_on_errors.iter().any(|code| {
error.contains(code) || decoded_error.map_or(false, |decoded| decoded.contains(code))
})
}

pub async fn call_with_retry<F, Fut, T>(
Expand All @@ -26,6 +28,24 @@ pub async fn call_with_retry<F, Fut, T>(
where
F: Fn() -> Fut,
Fut: Future<Output = anyhow::Result<T>>,
{
call_with_retry_and_decoder(operation_name, retry_on_errors, operation_fn, |_| None).await
}

/// Like `call_with_retry`, but accepts an error decoder function that can
/// translate raw error strings (containing hex revert data) into human-readable
/// error names. The decoded name is used both for logging and for matching
/// against `retry_on_errors`.
pub async fn call_with_retry_and_decoder<F, Fut, T, D>(
operation_name: &str,
retry_on_errors: &[&str],
operation_fn: F,
decode_error: D,
) -> anyhow::Result<T>
where
F: Fn() -> Fut,
Fut: Future<Output = anyhow::Result<T>>,
D: Fn(&str) -> Option<String>,
{
let op_name = operation_name.to_string();
let retry_codes: Vec<String> = retry_on_errors.iter().map(|s| s.to_string()).collect();
Expand All @@ -35,17 +55,22 @@ where
let op_name = op_name.clone();
let retry_codes = retry_codes.clone();
let fut = operation_fn();
// Decode before entering the async block to avoid moving the closure
let decode_fn = &decode_error;
async move {
match fut.await {
Ok(value) => Ok(value),
Err(e) => {
let error_str = format!("{}", e);
let error_str = format!("{e:#}");
let decoded = decode_fn(&error_str);
let display_error = decoded.as_deref().unwrap_or(&error_str);
let retry_refs: Vec<&str> =
retry_codes.iter().map(|s| s.as_str()).collect();
if should_retry_error(&error_str, &retry_refs) {
info!("{}: error, will retry: {}", op_name, e);
if should_retry_error(&error_str, decoded.as_deref(), &retry_refs) {
info!("{}: error, will retry: {}", op_name, display_error);
Err(RetryError::Retry(e))
} else {
info!("{}: error: {}", op_name, display_error);
Err(RetryError::Failure(e))
}
}
Expand Down
26 changes: 15 additions & 11 deletions crates/evm/src/ciphernode_registry_sol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// or FITNESS FOR A PARTICULAR PURPOSE.

use crate::{
error_decoder::format_evm_error,
events::{EnclaveEvmEvent, EvmEventProcessor},
evm_parser::EvmParser,
helpers::{send_tx_with_retry, EthProvider},
Expand Down Expand Up @@ -388,7 +389,7 @@ impl<P: Provider + WalletProvider + Clone + 'static> Handler<TicketGenerated>
info!(tx=%receipt.transaction_hash, "Ticket submitted to registry");
}
Err(err) => {
error!("Failed to submit ticket: {:?}", err);
error!("Failed to submit ticket: {}", format_evm_error(&err));
bus.err(EType::Evm, err);
}
}
Expand Down Expand Up @@ -418,7 +419,7 @@ impl<P: Provider + WalletProvider + Clone + 'static> Handler<CommitteeFinalizeRe
info!(tx=%receipt.transaction_hash, "Committee finalized on registry");
}
Err(err) => {
error!("Failed to finalize committee: {:?}", err);
error!("Failed to finalize committee: {}", format_evm_error(&err));
bus.err(EType::Evm, err);
}
}
Expand Down Expand Up @@ -454,7 +455,10 @@ impl<P: Provider + WalletProvider + Clone + 'static> Handler<PublicKeyAggregated
Ok(receipt) => {
info!(tx=%receipt.transaction_hash, "Committee published to registry");
}
Err(err) => bus.err(EType::Evm, err),
Err(err) => {
error!("Failed to publish committee: {}", format_evm_error(&err));
bus.err(EType::Evm, err);
}
}
})
}
Expand All @@ -479,8 +483,7 @@ pub async fn submit_ticket_to_registry<P: Provider + WalletProvider + Clone + 's
let e3_id_u256: U256 = e3_id.try_into()?;
let ticket_number_u256 = U256::from(ticket_number);

// 0xd4c1d970 = CommitteeNotRequested()
send_tx_with_retry("submitTicket", &["0xd4c1d970"], || {
send_tx_with_retry("submitTicket", &["CommitteeNotRequested"], || {
let provider = provider.clone();
async move {
info!("Calling: contract.submitTicket(..)");
Expand Down Expand Up @@ -508,12 +511,13 @@ pub async fn finalize_committee_on_registry<P: Provider + WalletProvider + Clone
) -> Result<TransactionReceipt> {
let e3_id_u256: U256 = e3_id.try_into()?;

// 0x5e043d1a = SubmissionWindowNotClosed(),
// 0xd4c1d970 = CommitteeNotRequested()
// 0x59fa4a93 = ThresholdNotMet()
send_tx_with_retry(
"finalizeCommittee",
&["0x5e043d1a", "0xd4c1d970", "0x59fa4a93"],
&[
"SubmissionWindowNotClosed",
"CommitteeNotRequested",
"ThresholdNotMet",
],
|| {
let provider = provider.clone();
async move {
Expand Down Expand Up @@ -550,8 +554,8 @@ pub async fn publish_committee_to_registry<P: Provider + WalletProvider + Clone
.filter_map(|node| node.parse().ok())
.collect();

// 0x9e968c3e = CommitteeNotFinalized() - RPC may not have synced finalization yet
send_tx_with_retry("publishCommittee", &["0x9e968c3e"], || {
// RPC may not have synced finalization yet
send_tx_with_retry("publishCommittee", &["CommitteeNotFinalized"], || {
let provider = provider.clone();
let nodes_vec = nodes_vec.clone();
let public_key_bytes = public_key_bytes.clone();
Expand Down
38 changes: 23 additions & 15 deletions crates/evm/src/enclave_sol_writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE.

use crate::error_decoder::format_evm_error;
use crate::helpers::EthProvider;
use crate::send_tx_with_retry;
use actix::prelude::*;
Expand Down Expand Up @@ -155,7 +156,10 @@ impl<P: Provider + WalletProvider + Clone + 'static> Handler<PlaintextAggregated
Err(err) => {
bus.err(
EType::Evm,
anyhow::anyhow!("Error publishing plaintext output: {:?}", err),
anyhow::anyhow!(
"Error publishing plaintext output: {}",
format_evm_error(&err)
),
);
}
}
Expand Down Expand Up @@ -217,21 +221,25 @@ async fn publish_plaintext_output<P: Provider + WalletProvider + Clone>(
.pending()
.await?;

// 0x0cb083bc = CiphertextOutputNotPublished() - RPC may not have synced ciphertext output being published yet
send_tx_with_retry("publishPlaintextOutput", &["0x0cb083bc"], || {
info!("publishPlaintextOutput() e3_id={:?}", e3_id);
let proof = Bytes::from(vec![1]);
let decrypted_output = Bytes::from(decrypted_output.clone());
let contract = IEnclave::new(contract_address, provider.provider());
// RPC may not have synced ciphertext output being published yet
send_tx_with_retry(
"publishPlaintextOutput",
&["CiphertextOutputNotPublished"],
|| {
Comment thread
ctrlc03 marked this conversation as resolved.
info!("publishPlaintextOutput() e3_id={:?}", e3_id);
let proof = Bytes::from(vec![1]);
let decrypted_output = Bytes::from(decrypted_output.clone());
let contract = IEnclave::new(contract_address, provider.provider());

async move {
let builder = contract
.publishPlaintextOutput(e3_id, decrypted_output, proof)
.nonce(current_nonce);
let receipt = builder.send().await?.get_receipt().await?;
Ok(receipt)
}
})
async move {
let builder = contract
.publishPlaintextOutput(e3_id, decrypted_output, proof)
.nonce(current_nonce);
let receipt = builder.send().await?.get_receipt().await?;
Ok(receipt)
}
},
)
.await
}

Expand Down
148 changes: 148 additions & 0 deletions crates/evm/src/error_decoder.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// SPDX-License-Identifier: LGPL-3.0-only
//
// This file is provided WITHOUT ANY WARRANTY;
// without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE.

use alloy::sol;
use alloy::sol_types::SolInterface;

sol!(
#[derive(Debug)]
Enclave,
"../../packages/enclave-contracts/artifacts/contracts/Enclave.sol/Enclave.json"
);

sol!(
#[derive(Debug)]
#[sol(ignore_unlinked)]
CiphernodeRegistryOwnable,
"../../packages/enclave-contracts/artifacts/contracts/registry/CiphernodeRegistryOwnable.sol/CiphernodeRegistryOwnable.json"
);

sol!(
#[derive(Debug)]
SlashingManager,
"../../packages/enclave-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json"
);

/// Try to decode raw revert data into a human-readable error string.
pub fn decode_error(data: &[u8]) -> Option<String> {
if data.len() < 4 {
return None;
}

if let Ok(err) = Enclave::EnclaveErrors::abi_decode(data) {
return Some(format!("{err:?}"));
}
if let Ok(err) = CiphernodeRegistryOwnable::CiphernodeRegistryOwnableErrors::abi_decode(data) {
return Some(format!("{err:?}"));
}
if let Ok(err) = SlashingManager::SlashingManagerErrors::abi_decode(data) {
return Some(format!("{err:?}"));
}

None
}

/// Extract hex revert data from an error string and try to decode it.
/// Tries all hex blobs found in the string, returning the first that decodes
/// as a known contract error.
pub fn decode_error_from_str(error_str: &str) -> Option<String> {
for data in extract_all_hex_blobs(error_str) {
if let Some(decoded) = decode_error(&data) {
return Some(decoded);
}
}
None
}

/// Format an anyhow error, replacing raw hex revert data with decoded error if possible.
/// Returns the decoded error string if decoding succeeds, otherwise the original error.
pub fn format_evm_error(err: &anyhow::Error) -> String {
let error_str = format!("{err:?}");
decode_error_from_str(&error_str).unwrap_or(error_str)
}

/// Extract all hex blobs (0x...) with at least 4 bytes (8 hex chars) from a string.
fn extract_all_hex_blobs(error_str: &str) -> Vec<Vec<u8>> {
error_str
.match_indices("0x")
.filter_map(|(idx, _)| {
let rest = &error_str[idx + 2..];
let hex_end = rest
.find(|c: char| !c.is_ascii_hexdigit())
.unwrap_or(rest.len());
let hex_str = &rest[..hex_end];
if hex_str.len() >= 8 {
hex::decode(hex_str).ok()
} else {
None
}
})
.collect()
}

#[cfg(test)]
mod tests {
use super::*;
use alloy::sol_types::SolError;

#[test]
fn test_decode_known_errors() {
// CiphertextOutputNotPublished(uint256 e3Id) with e3Id = 1
let mut data = Enclave::CiphertextOutputNotPublished::SELECTOR.to_vec();
data.extend_from_slice(&[0u8; 31]);
data.push(1); // e3Id = 1
let decoded = decode_error(&data).unwrap();
assert!(
decoded.contains("CiphertextOutputNotPublished"),
"got: {decoded}"
);
}

#[test]
fn test_decode_parameterless_error() {
// CommitteeNotRequested()
let data = CiphernodeRegistryOwnable::CommitteeNotRequested::SELECTOR.to_vec();
let decoded = decode_error(&data).unwrap();
assert!(decoded.contains("CommitteeNotRequested"), "got: {decoded}");
}

#[test]
fn test_decode_from_error_string() {
// Simulate an alloy error string containing hex revert data
let selector = hex::encode(Enclave::CiphertextOutputNotPublished::SELECTOR);
let param = "0000000000000000000000000000000000000000000000000000000000000001";
let error_str = format!(
"server returned an error response: error code 3: execution reverted, data: \"0x{selector}{param}\""
);
let decoded = decode_error_from_str(&error_str).unwrap();
assert!(
decoded.contains("CiphertextOutputNotPublished"),
"got: {decoded}"
);
}

#[test]
fn test_decode_unknown_error() {
let data = vec![0xde, 0xad, 0xbe, 0xef];
assert!(decode_error(&data).is_none());
}

#[test]
fn test_extract_hex_blobs_too_short() {
assert!(extract_all_hex_blobs("0x1234").is_empty());
}

#[test]
fn test_short_selector_found_despite_longer_hex() {
// Error string contains a tx hash (32 bytes) AND a short 4-byte selector.
// The decoder must find the selector even though the tx hash is longer.
let selector = hex::encode(CiphernodeRegistryOwnable::CommitteeNotRequested::SELECTOR);
let tx_hash = "aabbccddee11223344556677889900aabbccddee11223344556677889900aabb";
let error_str = format!("tx 0x{tx_hash} reverted with data: 0x{selector}");
let decoded = decode_error_from_str(&error_str).unwrap();
assert!(decoded.contains("CommitteeNotRequested"), "got: {decoded}");
}
}
Loading
Loading