Skip to content

Commit 40ebeab

Browse files
committed
feat: implement Milestone 2 Protocol Adapters and Uniswap v4 Hook memory extraction
1 parent 8db62dc commit 40ebeab

180 files changed

Lines changed: 33080 additions & 19 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/atupa-adapters/src/lib.rs

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,71 @@
11
pub trait ProtocolAdapter {
2+
/// The name of the protocol (e.g., "Uniswap v4").
23
fn name(&self) -> &str;
3-
fn process(&self, trace: String) -> anyhow::Result<String>;
4+
5+
/// Resolves a combination of target address and function selector into a human-readable label.
6+
fn resolve_label(&self, address: Option<&str>, selector: Option<&str>) -> Option<String>;
47
}
58

9+
/// Adapter specifically for identifying Uniswap v4 Hooks
610
pub struct UniswapV4Adapter;
11+
712
impl ProtocolAdapter for UniswapV4Adapter {
8-
fn name(&self) -> &str { "Uniswap v4" }
9-
fn process(&self, trace: String) -> anyhow::Result<String> {
10-
// Implementation for Uniswap v4 Hook profiling will go here
11-
Ok(format!("Processing Uniswap v4 trace: {}", trace))
13+
fn name(&self) -> &str {
14+
"Uniswap v4"
15+
}
16+
17+
fn resolve_label(&self, _address: Option<&str>, selector: Option<&str>) -> Option<String> {
18+
let sel = selector?;
19+
// Uniswap v4 Hook standard interface selectors
20+
let label = match sel {
21+
"0x18a9d381" => "beforeInitialize",
22+
"0x999dea5d" => "afterInitialize",
23+
"0x910746f2" => "beforeAddLiquidity",
24+
"0xefd81287" => "afterAddLiquidity",
25+
"0xd7386be3" => "beforeRemoveLiquidity",
26+
"0x1efe5f9e" => "afterRemoveLiquidity",
27+
"0xe82c3b75" => "beforeSwap",
28+
"0x14d6eaec" => "afterSwap",
29+
"0xa3d03227" => "beforeDonate",
30+
"0x0df2d576" => "afterDonate",
31+
_ => return None,
32+
};
33+
34+
Some(format!("Uniswapv4: {}", label))
35+
}
36+
}
37+
38+
/// The registry holding all known protocol adapters.
39+
pub struct AdapterRegistry {
40+
adapters: Vec<Box<dyn ProtocolAdapter>>,
41+
}
42+
43+
impl AdapterRegistry {
44+
/// Initialize a new registry pre-loaded with all supported adapters.
45+
pub fn new() -> Self {
46+
let mut registry = Self { adapters: Vec::new() };
47+
registry.register(Box::new(UniswapV4Adapter));
48+
registry
49+
}
50+
51+
/// Register a custom adapter
52+
pub fn register(&mut self, adapter: Box<dyn ProtocolAdapter>) {
53+
self.adapters.push(adapter);
54+
}
55+
56+
/// Iterates through adapters to find a descriptive label for the call.
57+
pub fn resolve(&self, address: Option<&str>, selector: Option<&str>) -> Option<String> {
58+
for adapter in &self.adapters {
59+
if let Some(label) = adapter.resolve_label(address, selector) {
60+
return Some(label);
61+
}
62+
}
63+
None
64+
}
65+
}
66+
67+
impl Default for AdapterRegistry {
68+
fn default() -> Self {
69+
Self::new()
1270
}
1371
}

crates/atupa-core/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ pub struct CollapsedStack {
5151
#[serde(default)]
5252
pub target_address: Option<String>,
5353
#[serde(default)]
54+
pub resolved_label: Option<String>,
55+
#[serde(default)]
5456
pub reverted: bool,
5557
}
5658

crates/atupa-output/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ impl SvgGenerator {
4343
let leaf_name = stack.stack.split(';').last().unwrap_or("unknown");
4444

4545
let mut label = format!("{} ({} gas)", leaf_name, stack.weight);
46-
if let Some(addr) = &stack.target_address {
46+
if let Some(r_label) = &stack.resolved_label {
47+
label = format!("{} ({} gas)", r_label, stack.weight);
48+
} else if let Some(addr) = &stack.target_address {
4749
label = format!("{} [{}] ({} gas)", leaf_name, addr, stack.weight);
4850
}
4951
if stack.reverted {

crates/atupa-parser/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ categories = { workspace = true }
1515
[dependencies]
1616
atupa-core = { workspace = true }
1717
atupa-rpc = { workspace = true }
18+
atupa-adapters = { workspace = true }
1819
serde = { workspace = true }
1920
serde_json = { workspace = true }
2021
anyhow = { workspace = true }

crates/atupa-parser/src/aggregator.rs

Lines changed: 111 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ impl Aggregator {
1515
pub fn build_collapsed_stacks(steps: &[TraceStep]) -> Vec<CollapsedStack> {
1616
debug!("Building collapsed stacks from {} execution steps", steps.len());
1717

18-
// Map to aggregate stacks: stack_string -> (total_gas, last_pc, target_address, reverted)
19-
let mut stack_map: HashMap<String, (u64, u64, Option<String>, bool)> = HashMap::new();
18+
let registry = atupa_adapters::AdapterRegistry::new();
19+
20+
// Map to aggregate stacks: stack_string -> (total_gas, last_pc, target_address, resolved_label, reverted)
21+
let mut stack_map: HashMap<String, (u64, u64, Option<String>, Option<String>, bool)> = HashMap::new();
2022

2123
// Current call stack
2224
let mut call_stack: Vec<String> = Vec::new();
@@ -35,21 +37,75 @@ impl Aggregator {
3537
call_stack.push("CALL".to_string());
3638
}
3739

38-
// Extract Target Address if this is a Call opcode
40+
// Extract Target Address & Parse Function Selector if this is a Call opcode
3941
let mut target_address = None;
42+
let mut resolved_label = None;
43+
4044
if operation == "CALL" || operation == "STATICCALL" || operation == "DELEGATECALL" || operation == "CALLCODE" {
4145
if let Some(stack) = &step.stack {
4246
if stack.len() >= 2 {
43-
// In Geth/Anvil trace stack array, the end of the array is the top of the stack.
44-
// CALL takes: gas, address, value, argsOffset, argsLength, retOffset, retLength
47+
// Extract target address (second item from top)
4548
let hex_addr = &stack[stack.len() - 2];
4649
let clean_hex = hex_addr.trim_start_matches("0x");
47-
// EVM addresses are exactly 40 chars, padded to 64 chars in stack elements
4850
if clean_hex.len() >= 40 {
4951
let extracted = &clean_hex[clean_hex.len() - 40..];
5052
target_address = Some(format!("0x{}", extracted));
5153
}
5254
}
55+
56+
// Attempt to extract the 4-byte selector from Memory using Offset & Length
57+
let mut args_offset_idx = None;
58+
let mut args_length_idx = None;
59+
60+
if operation == "CALL" || operation == "CALLCODE" {
61+
if stack.len() >= 5 {
62+
args_offset_idx = Some(stack.len() - 4);
63+
args_length_idx = Some(stack.len() - 5);
64+
}
65+
} else if (operation == "DELEGATECALL" || operation == "STATICCALL") && stack.len() >= 4 {
66+
args_offset_idx = Some(stack.len() - 3);
67+
args_length_idx = Some(stack.len() - 4);
68+
}
69+
70+
if let (Some(off_idx), Some(len_idx)) = (args_offset_idx, args_length_idx) {
71+
let offset_str = stack[off_idx].trim_start_matches("0x");
72+
let len_str = stack[len_idx].trim_start_matches("0x");
73+
74+
if let (Ok(offset), Ok(length)) = (
75+
usize::from_str_radix(offset_str, 16),
76+
usize::from_str_radix(len_str, 16)
77+
) {
78+
if length >= 4 {
79+
if let Some(mem) = &step.memory {
80+
let word_idx = offset / 32;
81+
let byte_offset = offset % 32;
82+
let hex_offset = byte_offset * 2; // Each byte is 2 hex chars
83+
84+
if let Some(word) = mem.get(word_idx) {
85+
let clean_word = word.trim_start_matches("0x");
86+
let selector_opt = if clean_word.len() >= hex_offset + 8 {
87+
let selector = &clean_word[hex_offset..hex_offset + 8];
88+
Some(format!("0x{}", selector))
89+
} else if word_idx + 1 < mem.len() {
90+
// The 4-byte selector spans across two memory boundary words
91+
let p1 = &clean_word[hex_offset..];
92+
let needed = 8 - p1.len();
93+
let next_word = mem[word_idx + 1].trim_start_matches("0x");
94+
if next_word.len() >= needed {
95+
let p2 = &next_word[..needed];
96+
Some(format!("0x{}{}", p1, p2))
97+
} else { None }
98+
} else { None };
99+
100+
// Try resolving the label
101+
if let Some(sel) = selector_opt {
102+
resolved_label = registry.resolve(target_address.as_deref(), Some(&sel));
103+
}
104+
}
105+
}
106+
}
107+
}
108+
}
53109
}
54110
}
55111

@@ -61,27 +117,28 @@ impl Aggregator {
61117
};
62118

63119
// Accumulate gas cost and flags
64-
let entry = stack_map.entry(stack_str).or_insert((0, 0, None, false));
120+
let entry = stack_map.entry(stack_str).or_insert((0, 0, None, None, false));
65121
entry.0 += step.gas_cost;
66122
entry.1 = step.pc;
67123
if target_address.is_some() {
68124
entry.2 = target_address;
69125
}
126+
if resolved_label.is_some() {
127+
entry.3 = resolved_label;
128+
}
70129
if step.reverted {
71-
entry.3 = true;
130+
entry.4 = true;
72131
}
73-
74-
// NOTE: Reverts naturally bubble up visually because if an internal call hits REVERT,
75-
// the specific reverting stack path gets the `entry.3 = true` flag.
76132
}
77133

78134
let mut stacks: Vec<CollapsedStack> = stack_map
79135
.into_iter()
80-
.map(|(stack, (weight, pc, target_address, reverted))| CollapsedStack {
136+
.map(|(stack, (weight, pc, target_address, resolved_label, reverted))| CollapsedStack {
81137
stack,
82138
weight,
83139
last_pc: Some(pc),
84140
target_address,
141+
resolved_label,
85142
reverted,
86143
})
87144
.collect();
@@ -144,5 +201,46 @@ mod tests {
144201
assert!(revert_stack.reverted);
145202
assert_eq!(revert_stack.weight, 200);
146203
}
147-
}
148204

205+
#[test]
206+
fn test_aggregator_memory_selector_extraction() {
207+
// Stack for CALL:
208+
// gas, address, value, argsOffset, argsLength, retOffset, retLength
209+
// Top of stack is at the end.
210+
// We want argsOffset to be "0x20" (32 bytes), argsLength to be "0x04" (4 bytes)
211+
// stack[len-4] = argsOffset
212+
// stack[len-5] = argsLength
213+
214+
let stack = vec![
215+
"0x0".to_string(), // retLength
216+
"0x0".to_string(), // retOffset
217+
"0x4".to_string(), // argsLength
218+
"0x20".to_string(), // argsOffset (byte 32)
219+
"0x0".to_string(), // value
220+
"0x0000000000000000000000001111111111111111111111111111111111111111".to_string(), // target address
221+
"0x1000".to_string(), // gas
222+
];
223+
224+
// Memory array (32-byte chunks as 64-char hex strings)
225+
// We set argsOffset = 32, so it looks in mem[1].
226+
// "beforeInitialize" selector is 0x18a9d381. We'll pad the rest with zeroes.
227+
let memory = vec![
228+
"0000000000000000000000000000000000000000000000000000000000000000".to_string(), // word 0
229+
"18a9d38100000000000000000000000000000000000000000000000000000000".to_string(), // word 1
230+
];
231+
232+
let steps = vec![
233+
TraceStep { pc: 0, op: "CALL".into(), gas: 1000, gas_cost: 50, depth: 1, stack: Some(stack), memory: Some(memory), error: None, reverted: false },
234+
TraceStep { pc: 1, op: "STOP".into(), gas: 900, gas_cost: 0, depth: 1, stack: None, memory: None, error: None, reverted: false },
235+
];
236+
237+
let stacks = Aggregator::build_collapsed_stacks(&steps);
238+
let call_stack = stacks.iter().find(|s| s.stack == "CALL;CALL").expect("Should find CALL");
239+
240+
// Ensure that the target address was resolved successfully
241+
assert_eq!(call_stack.target_address.as_deref(), Some("0x1111111111111111111111111111111111111111"));
242+
243+
// Ensure that the specific Uniswap v4 Hook was decoded
244+
assert_eq!(call_stack.resolved_label.as_deref(), Some("Uniswapv4: beforeInitialize"));
245+
}
246+
}

node_modules/.bin/node-gyp-build

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

node_modules/.bin/node-gyp-build-optional

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

node_modules/.bin/node-gyp-build-test

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

node_modules/.package-lock.json

Lines changed: 93 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)