Skip to main content

neo_decompiler/decompiler/analysis/
call_graph.rs

1//! Call graph construction for Neo N3 scripts.
2
3// Bytecode offset arithmetic requires isize↔usize casts for signed jump deltas.
4// NEF scripts are bounded (~1 MB), so these conversions are structurally safe.
5#![allow(
6    clippy::cast_possible_truncation,
7    clippy::cast_possible_wrap,
8    clippy::cast_sign_loss
9)]
10
11use std::collections::BTreeMap;
12
13use serde::Serialize;
14
15use crate::instruction::{Instruction, OpCode, Operand, OperandEncoding};
16use crate::manifest::ContractManifest;
17use crate::nef::NefFile;
18use crate::{syscalls, util};
19
20use super::{MethodRef, MethodTable};
21
22/// A resolved call target extracted from the instruction stream.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
24#[non_exhaustive]
25pub enum CallTarget {
26    /// Direct call into the same script (CALL/CALL_L).
27    Internal {
28        /// Callee method resolved from the target offset.
29        method: MethodRef,
30    },
31    /// Call to an entry in the NEF method-token table (CALLT).
32    MethodToken {
33        /// Index into the NEF `method_tokens` table.
34        index: u16,
35        /// Script hash (little-endian) for the called contract.
36        hash_le: String,
37        /// Script hash (big-endian) for the called contract.
38        hash_be: String,
39        /// Target method name.
40        method: String,
41        /// Declared parameter count.
42        parameters_count: u16,
43        /// Whether the target method has a return value.
44        has_return_value: bool,
45        /// Call flags bitfield.
46        call_flags: u8,
47    },
48    /// System call (SYSCALL).
49    Syscall {
50        /// Syscall identifier (little-endian u32).
51        hash: u32,
52        /// Resolved syscall name when known.
53        name: Option<String>,
54        /// Whether the syscall is known to push a value.
55        returns_value: bool,
56    },
57    /// Indirect call (e.g., CALLA) where the destination cannot be resolved statically.
58    Indirect {
59        /// Opcode mnemonic (`CALLA` or similar).
60        opcode: String,
61        /// Optional operand value (when present).
62        operand: Option<u16>,
63    },
64    /// A CALL/CALL_L target that could not be resolved to a valid offset.
65    UnresolvedInternal {
66        /// Computed target offset (may be negative when malformed).
67        target: isize,
68    },
69}
70
71/// One call edge in the call graph.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
73pub struct CallEdge {
74    /// Caller method containing the call instruction.
75    pub caller: MethodRef,
76    /// Bytecode offset of the call instruction.
77    pub call_offset: usize,
78    /// Opcode mnemonic of the call instruction (e.g., `CALL_L`, `SYSCALL`).
79    pub opcode: String,
80    /// Resolved target.
81    pub target: CallTarget,
82}
83
84/// Call graph for a decompiled script.
85#[derive(Debug, Clone, Default, Serialize)]
86pub struct CallGraph {
87    /// Known methods (manifest-defined plus synthetic internal targets).
88    pub methods: Vec<MethodRef>,
89    /// Call edges extracted from the instruction stream.
90    pub edges: Vec<CallEdge>,
91}
92
93/// Build a call graph for the provided instruction stream.
94#[must_use]
95pub fn build_call_graph(
96    nef: &NefFile,
97    instructions: &[Instruction],
98    manifest: Option<&ContractManifest>,
99) -> CallGraph {
100    let table = MethodTable::new(instructions, manifest);
101    let mut methods: BTreeMap<usize, MethodRef> = table
102        .spans()
103        .iter()
104        .map(|span| (span.method.offset, span.method.clone()))
105        .collect();
106
107    let mut edges = Vec::new();
108    for (index, instr) in instructions.iter().enumerate() {
109        match instr.opcode {
110            OpCode::Syscall => {
111                let Some(Operand::Syscall(hash)) = instr.operand else {
112                    continue;
113                };
114                let info = syscalls::lookup(hash);
115                edges.push(CallEdge {
116                    caller: table.method_for_offset(instr.offset),
117                    call_offset: instr.offset,
118                    opcode: instr.opcode.to_string(),
119                    target: CallTarget::Syscall {
120                        hash,
121                        name: info.map(|i| i.name.to_string()),
122                        returns_value: info.map(|i| i.returns_value).unwrap_or(true),
123                    },
124                });
125            }
126            OpCode::Call | OpCode::Call_L => {
127                let caller = table.method_for_offset(instr.offset);
128                match relative_target_isize(instructions, index, instr) {
129                    Some(target) if target >= 0 => {
130                        let target = target as usize;
131                        let callee = table.resolve_internal_target(target);
132                        methods.insert(callee.offset, callee.clone());
133                        edges.push(CallEdge {
134                            caller,
135                            call_offset: instr.offset,
136                            opcode: instr.opcode.to_string(),
137                            target: CallTarget::Internal { method: callee },
138                        });
139                    }
140                    Some(target) => edges.push(CallEdge {
141                        caller,
142                        call_offset: instr.offset,
143                        opcode: instr.opcode.to_string(),
144                        target: CallTarget::UnresolvedInternal { target },
145                    }),
146                    None => edges.push(CallEdge {
147                        caller,
148                        call_offset: instr.offset,
149                        opcode: instr.opcode.to_string(),
150                        target: CallTarget::UnresolvedInternal { target: -1 },
151                    }),
152                }
153            }
154            OpCode::CallT => {
155                let Some(Operand::U16(index)) = instr.operand else {
156                    continue;
157                };
158                let token = nef.method_tokens.get(index as usize);
159                if let Some(token) = token {
160                    edges.push(CallEdge {
161                        caller: table.method_for_offset(instr.offset),
162                        call_offset: instr.offset,
163                        opcode: instr.opcode.to_string(),
164                        target: CallTarget::MethodToken {
165                            index,
166                            hash_le: util::format_hash(&token.hash),
167                            hash_be: util::format_hash_be(&token.hash),
168                            method: token.method.clone(),
169                            parameters_count: token.parameters_count,
170                            has_return_value: token.has_return_value,
171                            call_flags: token.call_flags,
172                        },
173                    });
174                } else {
175                    edges.push(CallEdge {
176                        caller: table.method_for_offset(instr.offset),
177                        call_offset: instr.offset,
178                        opcode: instr.opcode.to_string(),
179                        target: CallTarget::Indirect {
180                            opcode: instr.opcode.to_string(),
181                            operand: Some(index),
182                        },
183                    });
184                }
185            }
186            OpCode::CallA => {
187                // CALLA takes no operand — it pops a Pointer from the stack.
188                edges.push(CallEdge {
189                    caller: table.method_for_offset(instr.offset),
190                    call_offset: instr.offset,
191                    opcode: instr.opcode.to_string(),
192                    target: CallTarget::Indirect {
193                        opcode: instr.opcode.to_string(),
194                        operand: None,
195                    },
196                });
197            }
198            _ => {}
199        }
200    }
201
202    CallGraph {
203        methods: methods.into_values().collect(),
204        edges,
205    }
206}
207
208fn relative_target_isize(
209    instructions: &[Instruction],
210    index: usize,
211    instr: &Instruction,
212) -> Option<isize> {
213    let delta = match &instr.operand {
214        Some(Operand::Jump(v)) => *v as isize,
215        Some(Operand::Jump32(v)) => *v as isize,
216        _ => return None,
217    };
218    let base = instructions
219        .get(index + 1)
220        .map(|ins| ins.offset)
221        .unwrap_or_else(|| instr.offset + instr_len_fallback(instr));
222    Some(base as isize + delta)
223}
224
225fn instr_len_fallback(instr: &Instruction) -> usize {
226    match instr.opcode.operand_encoding() {
227        OperandEncoding::None => 1,
228        OperandEncoding::I8 | OperandEncoding::U8 | OperandEncoding::Jump8 => 2,
229        OperandEncoding::I16 | OperandEncoding::U16 => 3,
230        OperandEncoding::I32
231        | OperandEncoding::U32
232        | OperandEncoding::Jump32
233        | OperandEncoding::Syscall => 5,
234        OperandEncoding::I64 => 9,
235        OperandEncoding::Bytes(n) => 1 + n,
236        OperandEncoding::Data1 => 1 + 1 + bytes_len(instr),
237        OperandEncoding::Data2 => 1 + 2 + bytes_len(instr),
238        OperandEncoding::Data4 => 1 + 4 + bytes_len(instr),
239    }
240}
241
242fn bytes_len(instr: &Instruction) -> usize {
243    match &instr.operand {
244        Some(Operand::Bytes(bytes)) => bytes.len(),
245        _ => 0,
246    }
247}