Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: resolve multiple function/event selectors in one openchain.xyz request #6719

Closed
wants to merge 2 commits into from
Closed
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
128 changes: 94 additions & 34 deletions crates/common/src/selectors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ use std::{
time::Duration,
};

static SELECTOR_DATABASE_URL: &str = "https://api.openchain.xyz/signature-database/v1/";
static SELECTOR_IMPORT_URL: &str = "https://api.openchain.xyz/signature-database/v1/import";
const SELECTOR_LOOKUP_URL: &str = "https://api.openchain.xyz/signature-database/v1/lookup";
const SELECTOR_IMPORT_URL: &str = "https://api.openchain.xyz/signature-database/v1/import";

/// The standard request timeout for API requests
const REQ_TIMEOUT: Duration = Duration::from_secs(15);
Expand Down Expand Up @@ -98,13 +98,13 @@ impl SignEthClient {
fn on_reqwest_err(&self, err: &reqwest::Error) {
fn is_connectivity_err(err: &reqwest::Error) -> bool {
if err.is_timeout() || err.is_connect() {
return true
return true;
}
// Error HTTP codes (5xx) are considered connectivity issues and will prompt retry
if let Some(status) = err.status() {
let code = status.as_u16();
if (500..600).contains(&code) {
return true
return true;
}
}
false
Expand Down Expand Up @@ -142,19 +142,51 @@ impl SignEthClient {
selector: &str,
selector_type: SelectorType,
) -> eyre::Result<Vec<String>> {
self.decode_selectors(&[selector], selector_type)
.await?
.pop() // Not returning on the previous line ensures a vector with exactly 1 element
.unwrap()
.ok_or(eyre::eyre!("No signature found"))
}

/// Decodes the given function or event selectors using https://api.openchain.xyz
pub async fn decode_selectors(
&self,
selectors: &[impl ToString],
selector_type: SelectorType,
) -> eyre::Result<Vec<Option<Vec<String>>>> {
if selectors.is_empty() {
return Ok(vec![]);
}

// exit early if spurious connection
self.ensure_not_spurious()?;

let selectors: Vec<String> = selectors
.iter()
.map(|s| s.to_string())
.map(|s| if s.starts_with("0x") { s } else { format!("0x{s}") })
.collect();

let expected_len = match selector_type {
SelectorType::Function => 10, // 0x + hex(4bytes)
SelectorType::Event => 66, // 0x + hex(32bytes)
};
if let Some(s) = selectors.iter().find(|s| s.len() != expected_len) {
eyre::bail!(
"Invalid selector {s}: expected {expected_len} characters (including 0x prefix)."
)
}

#[derive(Deserialize)]
struct Decoded {
name: String,
filtered: bool,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

filter=true by default (source), so results are already filtered on API side and this field is not in response.

}

#[derive(Deserialize)]
struct ApiResult {
event: HashMap<String, Vec<Decoded>>,
function: HashMap<String, Vec<Decoded>>,
event: HashMap<String, Option<Vec<Decoded>>>,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

api returns null for unknown selectors, we don't want to fail whole request with probably success responses

function: HashMap<String, Option<Vec<Decoded>>>,
}

#[derive(Deserialize)]
Expand All @@ -165,10 +197,14 @@ impl SignEthClient {

// using openchain.xyz signature database over 4byte
// see https://github.com/foundry-rs/foundry/issues/1672
let url = match selector_type {
SelectorType::Function => format!("{SELECTOR_DATABASE_URL}lookup?function={selector}"),
SelectorType::Event => format!("{SELECTOR_DATABASE_URL}lookup?event={selector}"),
};
let url = format!(
"{SELECTOR_LOOKUP_URL}?{ltype}={selectors_str}",
ltype = match selector_type {
SelectorType::Function => "function",
SelectorType::Event => "event",
},
selectors_str = selectors.join(",")
);

let res = self.get_text(&url).await?;
let api_response = match serde_json::from_str::<ApiResponse>(&res) {
Expand All @@ -187,27 +223,18 @@ impl SignEthClient {
SelectorType::Event => api_response.result.event,
};

Ok(decoded
.get(selector)
.ok_or_else(|| eyre::eyre!("No signature found"))?
.iter()
.filter(|&d| !d.filtered)
.map(|d| d.name.clone())
.collect::<Vec<String>>())
Ok(selectors
.into_iter()
.map(|selector| match decoded.get(&selector) {
Some(Some(r)) => Some(r.iter().map(|d| d.name.clone()).collect()),
_ => None,
})
.collect())
}

/// Fetches a function signature given the selector using https://api.openchain.xyz
pub async fn decode_function_selector(&self, selector: &str) -> eyre::Result<Vec<String>> {
let stripped_selector = selector.strip_prefix("0x").unwrap_or(selector);
let prefixed_selector = format!("0x{}", stripped_selector);
if prefixed_selector.len() != 10 {
eyre::bail!(
"Invalid selector: expected 8 characters (excluding 0x prefix), got {}.",
stripped_selector.len()
)
}

self.decode_selector(&prefixed_selector[..10], SelectorType::Function).await
self.decode_selector(selector, SelectorType::Function).await
}

/// Fetches all possible signatures and attempts to abi decode the calldata
Expand All @@ -232,11 +259,7 @@ impl SignEthClient {

/// Fetches an event signature given the 32 byte topic using https://api.openchain.xyz
pub async fn decode_event_topic(&self, topic: &str) -> eyre::Result<Vec<String>> {
let prefixed_topic = format!("0x{}", topic.strip_prefix("0x").unwrap_or(topic));
if prefixed_topic.len() != 66 {
eyre::bail!("Invalid topic: expected 64 characters (excluding 0x prefix), got {} characters (including 0x prefix).", prefixed_topic.len())
}
self.decode_selector(&prefixed_topic[..66], SelectorType::Event).await
self.decode_selector(topic, SelectorType::Event).await
}

/// Pretty print calldata and if available, fetch possible function signatures
Expand Down Expand Up @@ -382,6 +405,14 @@ pub async fn decode_selector(
SignEthClient::new()?.decode_selector(selector, selector_type).await
}

/// Decodes the given function or event selectors using https://api.openchain.xyz
pub async fn decode_selectors(
selectors: &[impl ToString],
selector_type: SelectorType,
) -> eyre::Result<Vec<Option<Vec<String>>>> {
SignEthClient::new()?.decode_selectors(selectors, selector_type).await
}

/// Fetches a function signature given the selector https://api.openchain.xyz
pub async fn decode_function_selector(selector: &str) -> eyre::Result<Vec<String>> {
SignEthClient::new()?.decode_function_selector(selector).await
Expand Down Expand Up @@ -569,7 +600,7 @@ mod tests {
.map_err(|e| {
assert_eq!(
e.to_string(),
"Invalid selector: expected 8 characters (excluding 0x prefix), got 6."
"Invalid selector 0xa9059c: expected 10 characters (including 0x prefix)."
)
})
.map(|_| panic!("Expected fourbyte error"))
Expand Down Expand Up @@ -685,4 +716,33 @@ mod tests {
.await;
assert_eq!(decoded.unwrap()[0], "canCall(address,address,bytes4)".to_string());
}

#[tokio::test(flavor = "multi_thread")]
async fn test_decode_selectors() {
let event_topics = vec![
"7e1db2a1cd12f0506ecd806dba508035b290666b84b096a87af2fd2a1516ede6",
"0xb7009613e63fb13fd59a2fa4c206a992c1f090a44e5d530be255aa17fed0b3dd",
];
let decoded = decode_selectors(&event_topics, SelectorType::Event).await;
let decoded = decoded.unwrap();
assert_eq!(
decoded,
vec![
Some(vec!["updateAuthority(address,uint8)".to_string()]),
Some(vec!["canCall(address,address,bytes4)".to_string()]),
]
);

let function_selectors = vec!["0xa9059cbb", "0x70a08231", "313ce567"];
let decoded = decode_selectors(&function_selectors, SelectorType::Function).await;
let decoded = decoded.unwrap();
assert_eq!(
decoded,
vec![
Some(vec!["transfer(address,uint256)".to_string()]),
Some(vec!["balanceOf(address)".to_string()]),
Some(vec!["decimals()".to_string()]),
]
);
}
}
25 changes: 24 additions & 1 deletion crates/evm/traces/src/decoder/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crate::{
identifier::{
AddressIdentity, LocalTraceIdentifier, SingleSignaturesIdentifier, TraceIdentifier,
},
CallTrace, CallTraceArena, DecodedCallData, DecodedCallLog, DecodedCallTrace,
CallTrace, CallTraceArena, CallTraceNode, DecodedCallData, DecodedCallLog, DecodedCallTrace,
};
use alloy_dyn_abi::{DecodedEvent, DynSolValue, EventExt, FunctionExt, JsonAbiExt};
use alloy_json_abi::{Event, Function, JsonAbi};
Expand Down Expand Up @@ -505,6 +505,29 @@ impl CallTraceDecoder {
DecodedCallLog::Raw(log)
}

/// Prefetches function and event signatures into the identifier cache
pub async fn prefetch_signatures(&self, nodes: &[CallTraceNode]) {
let Some(identifier) = &self.signature_identifier else { return };

let events_it = nodes
.iter()
.flat_map(|node| node.logs.iter().filter_map(|log| log.topics().first()))
.unique();
identifier.write().await.identify_events(events_it).await;

const DEFAULT_CREATE2_DEPLOYER_BYTES: [u8; 20] = DEFAULT_CREATE2_DEPLOYER.0 .0;
let funcs_it = nodes
.iter()
.filter_map(|n| match n.trace.address.0 .0 {
DEFAULT_CREATE2_DEPLOYER_BYTES => None,
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01..=0x0a] => None,
_ => n.trace.data.get(..SELECTOR_LEN),
})
.filter(|v| !self.functions.contains_key(*v))
.unique();
identifier.write().await.identify_functions(funcs_it).await;
}

fn apply_label(&self, value: &DynSolValue) -> String {
if let DynSolValue::Address(addr) = value {
if let Some(label) = self.labels.get(addr) {
Expand Down
84 changes: 49 additions & 35 deletions crates/evm/traces/src/identifier/signatures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ pub struct SignaturesIdentifier {
/// Location where to save `CachedSignatures`
cached_path: Option<PathBuf>,
/// Selectors that were unavailable during the session.
unavailable: HashSet<Vec<u8>>,
unavailable: HashSet<String>,
/// The API client to fetch signatures from
sign_eth_api: SignEthClient,
/// whether traces should be decoded via `sign_eth_api`
Expand Down Expand Up @@ -95,59 +95,73 @@ impl SignaturesIdentifier {
async fn identify<T>(
&mut self,
selector_type: SelectorType,
identifier: &[u8],
identifiers: impl IntoIterator<Item = impl AsRef<[u8]>>,
get_type: impl Fn(&str) -> eyre::Result<T>,
) -> Option<T> {
// Exit early if we have unsuccessfully queried it before.
if self.unavailable.contains(identifier) {
return None
}

let map = match selector_type {
) -> Vec<Option<T>> {
let cache = match selector_type {
SelectorType::Function => &mut self.cached.functions,
SelectorType::Event => &mut self.cached.events,
};

let hex_identifier = hex::encode_prefixed(identifier);

if !self.offline && !map.contains_key(&hex_identifier) {
if let Ok(signatures) =
self.sign_eth_api.decode_selector(&hex_identifier, selector_type).await
{
if let Some(signature) = signatures.into_iter().next() {
map.insert(hex_identifier.clone(), signature);
let hex_identifiers: Vec<String> =
identifiers.into_iter().map(hex::encode_prefixed).collect();

if !self.offline {
let query: Vec<_> = hex_identifiers
.iter()
.filter(|v| !cache.contains_key(v.as_str()))
.filter(|v| !self.unavailable.contains(v.as_str()))
.collect();

if let Ok(res) = self.sign_eth_api.decode_selectors(&query, selector_type).await {
for (hex_id, selector_result) in query.into_iter().zip(res.into_iter()) {
let mut found = false;
if let Some(decoded_results) = selector_result {
if let Some(decoded_result) = decoded_results.into_iter().next() {
cache.insert(hex_id.clone(), decoded_result);
found = true;
}
}
if !found {
self.unavailable.insert(hex_id.clone());
}
}
}
}

if let Some(signature) = map.get(&hex_identifier) {
return get_type(signature).ok()
}

self.unavailable.insert(identifier.to_vec());

None
hex_identifiers
.iter()
.map(|v| match cache.get(v) {
Some(v) => get_type(v).ok(),
None => None,
})
.collect()
}

/// Returns `None` if in offline mode
fn ensure_not_offline(&self) -> Option<()> {
if self.offline {
None
} else {
Some(())
}
/// Identifies `Function`s from its cache or `https://api.openchain.xyz`
pub async fn identify_functions(
&mut self,
identifiers: impl IntoIterator<Item = impl AsRef<[u8]>>,
) -> Vec<Option<Function>> {
self.identify(SelectorType::Function, identifiers, get_func).await
}

/// Identifies `Function` from its cache or `https://api.openchain.xyz`
pub async fn identify_function(&mut self, identifier: &[u8]) -> Option<Function> {
self.ensure_not_offline()?;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want to use results from loaded disk cache, network requests will be ignored in identify() function

self.identify(SelectorType::Function, identifier, get_func).await
self.identify_functions(&[identifier]).await.pop().unwrap()
}

/// Identifies `Event`s from its cache or `https://api.openchain.xyz`
pub async fn identify_events(
&mut self,
identifiers: impl IntoIterator<Item = impl AsRef<[u8]>>,
) -> Vec<Option<Event>> {
self.identify(SelectorType::Event, identifiers, get_event).await
}

/// Identifies `Event` from its cache or `https://api.openchain.xyz`
pub async fn identify_event(&mut self, identifier: &[u8]) -> Option<Event> {
self.ensure_not_offline()?;
self.identify(SelectorType::Event, identifier, get_event).await
self.identify_events(&[identifier]).await.pop().unwrap()
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/evm/traces/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ pub async fn render_trace_arena(
arena: &CallTraceArena,
decoder: &CallTraceDecoder,
) -> Result<String, std::fmt::Error> {
decoder.prefetch_signatures(arena.nodes()).await;

fn inner<'a>(
arena: &'a [CallTraceNode],
decoder: &'a CallTraceDecoder,
Expand Down