Skip to content
Open
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
14 changes: 13 additions & 1 deletion backend/crates/atlas-common/src/types.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use bigdecimal::BigDecimal;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Serialize, Serializer};
use sqlx::FromRow;

/// Block data as stored in the database
Expand Down Expand Up @@ -40,12 +40,20 @@ pub struct Transaction {
pub value: BigDecimal,
pub gas_price: BigDecimal,
pub gas_used: i64,
#[serde(serialize_with = "serialize_bytes_as_hex")]
pub input_data: Vec<u8>,
pub status: bool,
pub contract_created: Option<String>,
pub timestamp: i64,
}

fn serialize_bytes_as_hex<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&format!("0x{}", hex::encode(bytes)))
}
Comment on lines +50 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add in-file unit tests for serialize_bytes_as_hex.

This introduces new serialization logic but the file has no #[cfg(test)] mod tests coverage. Please add unit tests here for at least empty bytes ("0x") and a known byte vector ("0x...") to lock the contract.

Proposed test block
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use serde::Serialize;
+
+    #[derive(Serialize)]
+    struct Wrap<'a> {
+        #[serde(serialize_with = "serialize_bytes_as_hex")]
+        bytes: &'a [u8],
+    }
+
+    #[test]
+    fn serialize_bytes_as_hex_empty() {
+        let v = Wrap { bytes: &[] };
+        let s = serde_json::to_string(&v).unwrap();
+        assert_eq!(s, r#"{"bytes":"0x"}"#);
+    }
+
+    #[test]
+    fn serialize_bytes_as_hex_non_empty() {
+        let v = Wrap { bytes: &[0xde, 0xad, 0xbe, 0xef] };
+        let s = serde_json::to_string(&v).unwrap();
+        assert_eq!(s, r#"{"bytes":"0xdeadbeef"}"#);
+    }
+}

As per coding guidelines, "backend/**/*.rs: Add unit tests for new logic in a #[cfg(test)] mod tests block in the same file" and "backend/crates/**/*.rs: Add unit tests for new logic in a #[cfg(test)] mod tests block in the same file; run with cargo test --workspace".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn serialize_bytes_as_hex<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&format!("0x{}", hex::encode(bytes)))
}
fn serialize_bytes_as_hex<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&format!("0x{}", hex::encode(bytes)))
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Serialize;
#[derive(Serialize)]
struct Wrap<'a> {
#[serde(serialize_with = "serialize_bytes_as_hex")]
bytes: &'a [u8],
}
#[test]
fn serialize_bytes_as_hex_empty() {
let v = Wrap { bytes: &[] };
let s = serde_json::to_string(&v).unwrap();
assert_eq!(s, r#"{"bytes":"0x"}"#);
}
#[test]
fn serialize_bytes_as_hex_non_empty() {
let v = Wrap { bytes: &[0xde, 0xad, 0xbe, 0xef] };
let s = serde_json::to_string(&v).unwrap();
assert_eq!(s, r#"{"bytes":"0xdeadbeef"}"#);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/crates/atlas-common/src/types.rs` around lines 50 - 55, The
serialize_bytes_as_hex function lacks unit test coverage. Add a #[cfg(test)] mod
tests block at the end of the same file containing unit tests for the
serialize_bytes_as_hex function. Create at least two test cases: one testing
serialization of empty bytes (which should produce the output "0x") and another
testing serialization of a known byte vector (which should produce "0x" followed
by the hex-encoded representation of those bytes). Use serde_json or an
appropriate serializer in the test to verify the function produces the expected
hexadecimal string output.

Source: Coding guidelines


/// Address data as stored in the database
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Address {
Expand Down Expand Up @@ -170,6 +178,10 @@ pub struct EventLog {
pub data: Vec<u8>,
pub block_number: i64,
pub decoded: Option<serde_json::Value>,
pub decode_status: String,
pub decoded_at: Option<DateTime<Utc>>,
pub decode_attempted_at: Option<DateTime<Utc>>,
pub decode_source: Option<String>,
}

/// Known event signature for decoding
Expand Down
40 changes: 32 additions & 8 deletions backend/crates/atlas-server/src/api/handlers/contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use tokio::fs;

use crate::api::error::ApiResult;
use crate::api::AppState;
use crate::event_log_decode::enqueue_jobs_for_verified_contract_tx;
use atlas_common::{AtlasError, FullContractAbi};

// ── Request / Response types ──────────────────────────────────────────────────
Expand Down Expand Up @@ -220,18 +221,19 @@ pub async fn verify_contract(
// Compile the submitted source
let compiled_contract = compile_source(&solc_path, &req).await?;

// Strip CBOR metadata from both sides before comparing
// Solc reports immutable offsets against the full deployed bytecode, so
// zero those ranges before stripping the trailing CBOR metadata blob.
let deployed_bytes = decode_hex_bytecode(&deployed_hex)?;
let deployed_stripped = strip_metadata(&deployed_bytes);
let compiled_stripped = strip_metadata(&compiled_contract.bytecode);
let deployed_cmp = normalize_bytecode_for_comparison(
deployed_stripped,
let deployed_normalized = normalize_bytecode_for_comparison(
&deployed_bytes,
&compiled_contract.immutable_references,
)?;
let compiled_cmp = normalize_bytecode_for_comparison(
compiled_stripped,
let compiled_normalized = normalize_bytecode_for_comparison(
&compiled_contract.bytecode,
&compiled_contract.immutable_references,
)?;
let deployed_cmp = strip_metadata(&deployed_normalized).to_vec();
let compiled_cmp = strip_metadata(&compiled_normalized).to_vec();

// eth_getCode returns deployed runtime bytecode, so constructor args are not
// part of the bytecode comparison. We still parse and persist them as metadata.
Expand All @@ -255,6 +257,7 @@ pub async fn verify_contract(
Some(constructor_bytes)
};

let mut tx = state.pool.begin().await?;
let insert_result = sqlx::query(
"INSERT INTO contract_abis
(address, abi, source_code, compiler_version, optimization_used, runs,
Expand All @@ -275,13 +278,16 @@ pub async fn verify_contract(
.bind(&req.license_type)
.bind(stored_sources.is_multi_file)
.bind(&stored_sources.source_files)
.execute(&state.pool)
.execute(&mut *tx)
.await?;

if insert_result.rows_affected() == 0 {
return Err(AtlasError::Verification(format!("{address} is already verified")).into());
}

enqueue_jobs_for_verified_contract_tx(&mut tx, &address).await?;
tx.commit().await?;

Ok((
StatusCode::OK,
Json(VerifyResponse {
Expand Down Expand Up @@ -1093,6 +1099,24 @@ mod tests {
assert!(matches!(err, AtlasError::Compilation(_)));
}

#[test]
fn normalize_then_strip_metadata_preserves_immutable_offsets() {
let bytecode = vec![
0xaa, 0xbb, 0x11, 0x22, 0xcc, 0xdd, 0x01, 0x02, 0x03, 0x00, 0x03,
];
let normalized = normalize_bytecode_for_comparison(
&bytecode,
&[ImmutableReference {
start: 2,
length: 2,
}],
)
.unwrap();
let stripped = strip_metadata(&normalized);

assert_eq!(stripped, &[0xaa, 0xbb, 0x00, 0x00, 0xcc, 0xdd]);
}

#[test]
fn extract_immutable_references_parses_multiple_entries() {
let refs = extract_immutable_references(&serde_json::json!({
Expand Down
131 changes: 67 additions & 64 deletions backend/crates/atlas-server/src/api/handlers/logs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ use axum::{
Json,
};
use serde::Deserialize;
use std::collections::{BTreeSet, HashMap};
use std::sync::Arc;

use crate::api::error::ApiResult;
use crate::api::AppState;
use crate::event_log_decode::EventLogApiResponse;
use atlas_common::{EventLog, PaginatedResponse};

/// Pagination for transaction log endpoints.
Expand Down Expand Up @@ -63,32 +65,15 @@ pub async fn get_transaction_logs(
State(state): State<Arc<AppState>>,
Path(hash): Path<String>,
Query(query): Query<TransactionLogsQuery>,
) -> ApiResult<Json<PaginatedResponse<EventLog>>> {
) -> ApiResult<Json<PaginatedResponse<EventLogApiResponse>>> {
let hash = normalize_hash(&hash);

let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM event_logs WHERE tx_hash = $1")
.bind(&hash)
.fetch_one(&state.pool)
.await?;

let logs: Vec<EventLog> = sqlx::query_as(
"SELECT id, tx_hash, log_index, address, topic0, topic1, topic2, topic3, data, block_number, decoded
FROM event_logs
WHERE tx_hash = $1
ORDER BY log_index ASC
LIMIT $2 OFFSET $3",
)
.bind(&hash)
.bind(query.limit())
.bind(query.offset())
.fetch_all(&state.pool)
.await?;
let (total, logs) = load_transaction_logs(&state, &hash, &query).await?;

Ok(Json(PaginatedResponse::new(
logs,
logs.iter().map(EventLogApiResponse::from).collect(),
query.page,
query.clamped_limit(),
total.0,
total,
)))
}

Expand All @@ -97,7 +82,7 @@ pub async fn get_address_logs(
State(state): State<Arc<AppState>>,
Path(address): Path<String>,
Query(query): Query<LogsQuery>,
) -> ApiResult<Json<PaginatedResponse<EventLog>>> {
) -> ApiResult<Json<PaginatedResponse<EventLogApiResponse>>> {
let address = normalize_address(&address);

let (total, logs) = if let Some(topic0) = &query.topic0 {
Expand All @@ -111,7 +96,8 @@ pub async fn get_address_logs(
.await?;

let logs: Vec<EventLog> = sqlx::query_as(
"SELECT id, tx_hash, log_index, address, topic0, topic1, topic2, topic3, data, block_number, decoded
"SELECT id, tx_hash, log_index, address, topic0, topic1, topic2, topic3, data, block_number,
decoded, decode_status, decoded_at, decode_attempted_at, decode_source
FROM event_logs
WHERE address = $1 AND topic0 = $2
ORDER BY block_number DESC, log_index DESC
Expand All @@ -132,7 +118,8 @@ pub async fn get_address_logs(
.await?;

let logs: Vec<EventLog> = sqlx::query_as(
"SELECT id, tx_hash, log_index, address, topic0, topic1, topic2, topic3, data, block_number, decoded
"SELECT id, tx_hash, log_index, address, topic0, topic1, topic2, topic3, data, block_number,
decoded, decode_status, decoded_at, decode_attempted_at, decode_source
FROM event_logs
WHERE address = $1
ORDER BY block_number DESC, log_index DESC
Expand All @@ -148,85 +135,101 @@ pub async fn get_address_logs(
};

Ok(Json(PaginatedResponse::new(
logs,
logs.iter().map(EventLogApiResponse::from).collect(),
query.page,
query.clamped_limit(),
total,
)))
}

/// Enriched log with event name
#[derive(Debug, Clone, serde::Serialize)]
pub struct EnrichedEventLog {
#[serde(flatten)]
pub log: EventLog,
pub event_name: Option<String>,
pub event_signature: Option<String>,
}

/// GET /api/transactions/:hash/logs/decoded - Get decoded logs for a transaction
pub async fn get_transaction_logs_decoded(
State(state): State<Arc<AppState>>,
Path(hash): Path<String>,
Query(query): Query<TransactionLogsQuery>,
) -> ApiResult<Json<PaginatedResponse<EnrichedEventLog>>> {
) -> ApiResult<Json<PaginatedResponse<EventLogApiResponse>>> {
let hash = normalize_hash(&hash);
let (total, logs) = load_transaction_logs(&state, &hash, &query).await?;
let enriched = enrich_decoded_logs_with_known_signatures(&state, &logs).await?;

Ok(Json(PaginatedResponse::new(
enriched,
query.page,
query.clamped_limit(),
total,
)))
}

async fn load_transaction_logs(
state: &Arc<AppState>,
hash: &str,
query: &TransactionLogsQuery,
) -> Result<(i64, Vec<EventLog>), sqlx::Error> {
let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM event_logs WHERE tx_hash = $1")
.bind(&hash)
.bind(hash)
.fetch_one(&state.pool)
.await?;

let logs: Vec<EventLog> = sqlx::query_as(
"SELECT id, tx_hash, log_index, address, topic0, topic1, topic2, topic3, data, block_number, decoded
"SELECT id, tx_hash, log_index, address, topic0, topic1, topic2, topic3, data, block_number,
decoded, decode_status, decoded_at, decode_attempted_at, decode_source
FROM event_logs
WHERE tx_hash = $1
ORDER BY log_index ASC
LIMIT $2 OFFSET $3",
)
.bind(&hash)
.bind(hash)
.bind(query.limit())
.bind(query.offset())
.fetch_all(&state.pool)
.await?;

// Collect unique topic0 values for signature lookup
let topic0s: Vec<String> = logs.iter().map(|l| l.topic0.clone()).collect();
Ok((total.0, logs))
}

async fn enrich_decoded_logs_with_known_signatures(
state: &Arc<AppState>,
logs: &[EventLog],
) -> Result<Vec<EventLogApiResponse>, sqlx::Error> {
let mut responses: Vec<EventLogApiResponse> =
logs.iter().map(EventLogApiResponse::from).collect();

let unresolved_topic0s: Vec<String> = responses
.iter()
.filter(|log| log.event_name.is_none())
.map(|log| log.topic0.to_lowercase())
.collect::<BTreeSet<_>>()
.into_iter()
.collect();

if unresolved_topic0s.is_empty() {
return Ok(responses);
}

// Fetch known event signatures
let signatures: Vec<(String, String, String)> = sqlx::query_as(
"SELECT signature, name, full_signature FROM event_signatures WHERE signature = ANY($1)",
)
.bind(&topic0s)
.bind(&unresolved_topic0s)
.fetch_all(&state.pool)
.await?;

let sig_map: std::collections::HashMap<String, (String, String)> = signatures
let signature_map: HashMap<String, (String, String)> = signatures
.into_iter()
.map(|(sig, name, full)| (sig.to_lowercase(), (name, full)))
.map(|(signature, name, full_signature)| (signature.to_lowercase(), (name, full_signature)))
.collect();

let enriched: Vec<EnrichedEventLog> = logs
.into_iter()
.map(|log| {
let (event_name, event_signature) = sig_map
.get(&log.topic0.to_lowercase())
.map(|(n, s)| (Some(n.clone()), Some(s.clone())))
.unwrap_or((None, None));
EnrichedEventLog {
log,
event_name,
event_signature,
}
})
.collect();
for response in &mut responses {
if response.event_name.is_some() {
continue;
}

Ok(Json(PaginatedResponse::new(
enriched,
query.page,
query.clamped_limit(),
total.0,
)))
if let Some((name, full_signature)) = signature_map.get(&response.topic0.to_lowercase()) {
response.event_name = Some(name.clone());
response.event_signature = Some(full_signature.clone());
}
}

Ok(responses)
}

fn default_page() -> u32 {
Expand Down
Loading
Loading