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
48 changes: 44 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ flowchart TD
A language-specific parser walks the directory, parses each file into a tree-sitter AST, and extracts:

- **Nodes** — functions, methods, classes, structs, interfaces, traits, enums, modules, namespaces
- **Edges** — calls, inheritance, implementation, containment, imports
- **Edges** — calls, inheritance, implementation, containment, imports, persistent-state reads/writes (Solidity)
- **Metadata** — type annotations, cyclomatic complexity, branches, docstrings, exception types

### Supported Languages
Expand All @@ -53,7 +53,7 @@ A language-specific parser walks the directory, parses each file into a tree-sit
| Java | `.java` | methods, classes, interfaces, enums |
| Go | `.go` | functions, methods, structs, interfaces |
| Rust | `.rs` | functions, structs, traits, enums, impl blocks, `verus!` blocks |
| Solidity | `.sol` | contracts, interfaces, libraries, functions, modifiers, structs, enums |
| Solidity | `.sol` | contracts, interfaces, libraries, functions, modifiers, structs, enums, persistent-state reads/writes |
| Cairo | `.cairo` | functions, traits, structs, enums, impl blocks, StarkNet contracts |
| Circom | `.circom` | templates, functions, signals, components |
| Haskell | `.hs` | functions, data types, type classes, instances |
Expand Down Expand Up @@ -141,6 +141,10 @@ The `QueryEngine` provides a high-level API over the indexed graph:
| --- | --- |
| `callers_of(name)` | Direct callers of the named target |
| `callees_of(name)` | Direct callees of the named source |
| `state_read_by(name)` | Persistent-state declarations directly read by the named unit |
| `state_written_by(name)` | Persistent-state declarations directly written by the named unit |
| `readers_of(name)` | Units that directly read the named state declaration |
| `writers_of(name)` | Units that directly write the named state declaration |
| `ancestors_of(name)` | Every function that can transitively reach the target (upward slice) |
| `reachable_from(name)` | Every function transitively reachable from the source |
| `paths_between(src, dst)` | All simple call paths between two nodes |
Expand Down Expand Up @@ -223,11 +227,11 @@ classDiagram
CodeGraph "1" *-- "*" EntrypointTag
```

**Node kinds:** `function`, `method`, `class`, `module`, `struct`, `interface`, `trait`, `enum`, `namespace`, `contract`, `library`, `template`, `proxy`
**Node kinds:** `function`, `method`, `class`, `module`, `struct`, `interface`, `trait`, `enum`, `namespace`, `contract`, `state_variable`, `library`, `template`, `proxy`

**Node origins:** `source`, `proxy`, `binary`, `synthetic`

**Edge kinds:** `calls`, `inherits`, `implements`, `contains`, `imports`, `resolves_to`, `type_uses`, `specializes`, `corresponds_to`
**Edge kinds:** `calls`, `reads`, `writes`, `inherits`, `implements`, `contains`, `imports`, `resolves_to`, `type_uses`, `specializes`, `corresponds_to`

**Edge confidence:** `certain`, `inferred`, `uncertain`

Expand All @@ -238,6 +242,42 @@ parses, including single-language parses, also apply repository links and
materialize `TYPE_USES` edges. Binary analysis support imports external JSON
call graphs; Trailmark does not disassemble executables itself.

### Persistent State (Solidity)

The Solidity parser emits `state_variable` nodes for persistent storage declared
in a contract, with IDs such as `token:Token.balances`, a `declared_type`
attribute, and a `contains` edge from the owner. Constants, immutables, and
transient variables are excluded. Functions, constructors, and modifiers have
direct `reads` and `writes` edges to those declarations, with a source location
for each occurrence. Repeated accesses remain separate edges in JSON; the four
state query methods return distinct nodes, ordered by ID, and `[]` for missing
names.

Assignments and `delete` produce writes; compound assignments and increments
produce both reads and writes. Struct members, mapping entries, and array
elements roll up to their owning state declaration. Index expressions are read
separately. Direct state-array `.push()` and `.pop()` calls produce both kinds
of edge. Parameters, named returns, and local variables respect lexical scope
and shadow state names. For example:

```python
engine = QueryEngine.from_directory("./contracts", language="solidity")
engine.state_read_by("Token.transfer")
engine.state_written_by("Token.transfer")
engine.readers_of("Token.balances")
engine.writers_of("Token.balances")
```

This is syntax-based, declaration-level dependency information, not an EVM
storage-slot or mapping-key analysis. The first implementation resolves only
state declared in the same contract. It does not follow inherited state,
storage aliases, library effects, assembly, delegatecalls, or external calls.
Declaration initializers and compiler-generated public getters are not modeled.
Modifier bodies own their accesses; their effects are not propagated to the
functions using them. State edges do not change the default call traversals.
An absent edge is not proof that a function cannot access storage. Other
language parsers do not yet emit state-access edges.

### Example Graph

Given this Python code:
Expand Down
2 changes: 2 additions & 0 deletions src/trailmark/models/edges.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ class EdgeKind(Enum):
"""The kind of relationship between two code units."""

CALLS = "calls"
READS = "reads"
WRITES = "writes"
INHERITS = "inherits"
CONTAINS = "contains"
IMPORTS = "imports"
Expand Down
1 change: 1 addition & 0 deletions src/trailmark/models/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class NodeKind(Enum):
ENUM = "enum"
NAMESPACE = "namespace"
CONTRACT = "contract"
STATE_VARIABLE = "state_variable"
LIBRARY = "library"
TEMPLATE = "template"
SCHEMA = "schema"
Expand Down
240 changes: 240 additions & 0 deletions src/trailmark/parsers/solidity/_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
"""Direct, declaration-level Solidity storage dependencies.

This pass resolves names within one contract. It does not infer storage aliases,
inheritance, library effects, or accesses through assembly and external calls.
"""

from __future__ import annotations

from tree_sitter import Node

from trailmark.models.edges import CodeEdge, EdgeKind
from trailmark.models.graph import CodeGraph
from trailmark.models.nodes import CodeUnit, NodeKind
from trailmark.parsers._common import add_contains_edge, make_location, node_text

_READ = (EdgeKind.READS,)
_WRITE = (EdgeKind.WRITES,)
_READ_WRITE = (EdgeKind.READS, EdgeKind.WRITES)
_SKIP = frozenset({"type_name", "parameter", "assembly_statement"})


def extract_state_variables(
body: Node,
file_path: str,
contract_id: str,
graph: CodeGraph,
) -> dict[str, CodeUnit]:
"""Materialize persistent storage declarations before visiting functions."""
states: dict[str, CodeUnit] = {}
for child in body.named_children:
if child.type != "state_variable_declaration":
continue
if any(c.type in {"constant", "immutable"} for c in child.children):
continue
location = child.child_by_field_name("location")
if location is not None and node_text(location) == "transient":
continue
name = child.child_by_field_name("name")
declared_type = child.child_by_field_name("type")
if name is None or declared_type is None:
continue
state_name = node_text(name)
state_id = f"{contract_id}.{state_name}"
unit = CodeUnit(
id=state_id,
name=state_name,
kind=NodeKind.STATE_VARIABLE,
location=make_location(child, file_path),
attributes=(("declared_type", node_text(declared_type)),),
)
states[state_name] = unit
graph.nodes[state_id] = unit
add_contains_edge(graph, contract_id, state_id)
return states


def add_state_access_edges(
function: Node,
source_id: str,
file_path: str,
states: dict[str, CodeUnit],
graph: CodeGraph,
) -> None:
"""Record direct accesses in a function, constructor, or modifier."""
if not states:
return
shadowed: set[str] = set()
_bind_parameters(function, shadowed)
returns = function.child_by_field_name("return_type")
if returns is not None:
_bind_parameters(returns, shadowed)
collector = _AccessCollector(source_id, file_path, states, graph)
for child in function.named_children:
if child.type == "modifier_invocation":
for argument in child.named_children:
if argument.type == "call_argument":
collector.visit(argument, shadowed)
body = function.child_by_field_name("body")
if body is not None:
collector.visit(body, shadowed)


def _bind_parameters(node: Node, shadowed: set[str]) -> None:
for child in node.named_children:
if child.type == "parameter":
name = child.child_by_field_name("name")
if name is not None:
shadowed.add(node_text(name))


def _unwrap(node: Node) -> Node:
while node.type == "expression" and len(node.named_children) == 1:
node = node.named_children[0]
return node


class _AccessCollector:
def __init__(
self,
source_id: str,
file_path: str,
states: dict[str, CodeUnit],
graph: CodeGraph,
) -> None:
self.source_id = source_id
self.file_path = file_path
self.states = states
self.graph = graph

def visit(
self,
node: Node,
shadowed: set[str],
kinds: tuple[EdgeKind, ...] = _READ,
) -> None:
if node.type in _SKIP:
return
if node.type == "identifier":
name = node_text(node)
state = self.states.get(name)
if state is not None and name not in shadowed:
for kind in kinds:
self.graph.edges.append(
CodeEdge(
source_id=self.source_id,
target_id=state.id,
kind=kind,
location=make_location(node, self.file_path),
)
)
return
if node.type in {"function_body", "block_statement", "for_statement", "catch_clause"}:
shadowed = shadowed.copy()
_bind_parameters(node, shadowed)
if node.type == "catch_clause":
body = node.child_by_field_name("body")
if body is not None:
self.visit(body, shadowed)
return
if node.type == "try_statement":
attempt = node.child_by_field_name("attempt")
if attempt is not None:
self.visit(attempt, shadowed)
success_scope = shadowed.copy()
_bind_parameters(node, success_scope)
body = node.child_by_field_name("body")
if body is not None:
self.visit(body, success_scope)
for child in node.named_children:
if child.type == "catch_clause":
self.visit(child, shadowed)
return
if node.type == "variable_declaration_statement":
for child in node.named_children:
if child.type == "variable_declaration_tuple":
for declaration in child.named_children:
self._bind_local(declaration, shadowed)
elif child.type == "variable_declaration":
self._bind_local(child, shadowed)
value = node.child_by_field_name("value")
if value is not None:
self.visit(value, shadowed)
return
if node.type in {"assignment_expression", "augmented_assignment_expression"}:
left = node.child_by_field_name("left")
right = node.child_by_field_name("right")
if left is not None:
self.visit(
left,
shadowed,
_WRITE if node.type == "assignment_expression" else _READ_WRITE,
)
if right is not None:
self.visit(right, shadowed)
return
if node.type in {"update_expression", "unary_expression"}:
argument = node.child_by_field_name("argument")
if argument is not None:
if node.type == "update_expression":
kinds = _READ_WRITE
elif any(c.type == "delete" for c in node.children):
kinds = _WRITE
else:
kinds = _READ
self.visit(argument, shadowed, kinds)
return
if node.type == "member_expression":
obj = node.child_by_field_name("object")
if obj is not None:
self.visit(obj, shadowed, kinds)
return
if node.type == "array_access":
base = node.child_by_field_name("base")
index = node.child_by_field_name("index")
if base is not None:
self.visit(base, shadowed, kinds)
if index is not None:
self.visit(index, shadowed)
return
if node.type == "call_struct_argument":
value = node.child_by_field_name("value")
if value is not None:
self.visit(value, shadowed)
return
if node.type == "call_expression":
self._visit_call(node, shadowed)
return
for child in node.named_children:
self.visit(child, shadowed, kinds)

def _bind_local(self, node: Node, shadowed: set[str]) -> None:
name = node.child_by_field_name("name")
if name is not None:
shadowed.add(node_text(name))

def _visit_call(self, node: Node, shadowed: set[str]) -> None:
function = node.child_by_field_name("function")
if function is not None:
receiver = self._array_mutation_receiver(_unwrap(function), shadowed)
if receiver is not None:
self.visit(receiver, shadowed, _READ_WRITE)
else:
self.visit(function, shadowed)
for child in node.named_children:
if child.type == "call_argument":
self.visit(child, shadowed)

def _array_mutation_receiver(self, target: Node, shadowed: set[str]) -> Node | None:
obj = target.child_by_field_name("object")
prop = target.child_by_field_name("property")
if obj is None or prop is None or node_text(prop) not in {"push", "pop"}:
return None
receiver = _unwrap(obj)
name = node_text(receiver)
state = self.states.get(name)
if state is None or name in shadowed:
return None
if not str(dict(state.attributes)["declared_type"]).endswith("]"):
return None
return obj
6 changes: 6 additions & 0 deletions src/trailmark/parsers/solidity/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
node_text,
parse_directory,
)
from trailmark.parsers.solidity._state import add_state_access_edges, extract_state_variables

_BRANCH_NODE_TYPES = frozenset(
{
Expand Down Expand Up @@ -187,6 +188,7 @@ def _visit_contract_body(
body = node.child_by_field_name("body")
if body is None:
return
states = extract_state_variables(body, file_path, contract_id, graph)
for child in body.children:
if child.type in _FUNCTION_TYPES:
_extract_function(
Expand All @@ -196,6 +198,7 @@ def _visit_contract_body(
contract_id,
graph,
container_kind=graph.nodes[contract_id].kind,
state_variables=states,
)
elif child.type == "struct_declaration":
_extract_struct(child, file_path, module_id, graph)
Expand Down Expand Up @@ -255,6 +258,7 @@ def _extract_function(
graph: CodeGraph,
*,
container_kind: NodeKind | None = None,
state_variables: dict[str, CodeUnit] | None = None,
) -> None:
"""Extract a function, constructor, or modifier definition."""
func_name = _get_function_name(node)
Expand Down Expand Up @@ -295,6 +299,8 @@ def _extract_function(
add_contains_edge(graph, owner, func_id)

_add_call_edges(calls, func_id, module_id, contract_id, file_path, graph)
if state_variables:
add_state_access_edges(node, func_id, file_path, state_variables, graph)


def _get_function_name(node: Node) -> str:
Expand Down
Loading
Loading