Introduction
Manually reviewing Solidity code is slow and prone to errors. A single overlooked function can hide a costly vulnerability. Wake’s printer system automates the search for these risky patterns, turning hours of manual review into quick, reliable scans.
Wake printers combine Python’s simplicity with Wake’s Intermediate Representation (IR) to turn complex static analysis into simple scripts that highlight things like unrestricted withdrawals or missing access controls. This guide will walk you through creating custom printers that highlight security-relevant patterns in your smart contracts.
Prerequisites and Setup
For this tutorial, we’ll use the workshop repository as our example project:
git clone https://github.com/Ackee-Blockchain/2025-workshop-fuzzing
cd 2025-workshop-fuzzing
npm install
Before continuing, check that Wake compiles your project successfully by running:
wake up
Understanding Wake Printers
Wake comes with several built-in printers that showcase different kinds of analysis. You can list them with:
wake print
Run a specific printer by name:
wake print storage-layout

Built-in printers demonstrate the system’s capabilities, but the real power comes from writing custom printers tailored to your security analysis needs. Once you understand how built-in printers work, you’ll see how easy it is to extend Wake with your own analysis tools. By the end of this guide, you’ll know how to create printers that detect vulnerability patterns relevant to your auditing approach.
Tutorial 1: Creating Your First Printer – Listing Contracts
Let’s start with a simple printer that lists all contracts in your project. This example introduces the core concepts you’ll use in more complex analysis.
Creating the Printer Structure
Run this command to scaffold your first printer::
wake up printer list-contracts
Wake generates a new printer directory and a starter file with this structure:
printers/directory for all custom printerslist-contracts.pywith the basic printer structure
Understanding the Template
The generated template provides this starting structure:
from __future__ import annotations
import networkx as nx
import rich_click as click
import wake.ir as ir
import wake.ir.types as types
from rich import print
from wake.cli import SolidityName
from wake.printers import Printer, printer
class ListContractsPrinter(Printer):
def print(self) -> None:
pass
@printer.command(name="list-contracts")
def cli(self) -> None:
pass
Here’s what each part of the template does:
print(): Main execution method where analysis results are displayedcli(): Command-line interface handler for custom arguments
Implementing the Visitor Pattern
Wake uses the Visitor pattern to traverse the contract’s Abstract Syntax Tree (AST). The visitor pattern allows Wake to automatically navigate through your code’s structure, enabling you to react to specific elements—such as contract or function definitions.
To list contracts, we’ll override the visit_contract_definition method, which gets called for each contract in the codebase.
Add this method to your ListContractsPrinter class:
def visit_contract_definition(self, node: ir.ContractDefinition) -> None:
print(node.name)
Test your printer:
wake print list-contracts
This command runs your printer and prints all contract names found in your project.
Refining the Output
The basic implementation shows all contracts, including interfaces and inherited contracts. Let’s improve it to show only deployable contracts:
from __future__ import annotations
import networkx as nx
import rich_click as click
import wake.ir as ir
import wake.ir.types as types
from rich import print
from wake.cli import SolidityName
from wake.printers import Printer, printer
class ListContractsPrinter(Printer):
def visit_contract_definition(self, node: ir.ContractDefinition) -> None:
print(node.name)
def print(self) -> None:
pass
@printer.command(name="list-contracts")
def cli(self) -> None:
pass
Right now, the printer lists every contract, including interfaces and base classes. Let’s refine it to show only deployable ones.

Filtering for Deployable Contracts
Add conditions to filter out interfaces, libraries, and base contracts. This helps identify which contracts are actually deployable:
def visit_contract_definition(self, node: ir.ContractDefinition) -> None:
if len(node.child_contracts) != 0:
return
if node.kind != ir.enums.ContractKind.CONTRACT:
return
print(node.name)
The ContractDefinition class includes attributes you can use to filter your results. For complete reference, see: https://ackee.xyz/wake/docs/latest/api-reference/ir/declarations/contract-definition/
Complete Implementation
Here’s the final version with proper separation of concerns—collecting data during traversal and displaying it in the print() method:
from __future__ import annotations
import networkx as nx
import rich_click as click
import wake.ir as ir
import wake.ir.types as types
from rich import print
from wake.cli import SolidityName
from wake.printers import Printer, printer
class ListContractsPrinter(Printer):
contracts: list[ir.ContractDefinition]
def __init__(self):
self.contracts = []
def visit_contract_definition(self, node: ir.ContractDefinition) -> None:
if len(node.child_contracts) != 0:
return
if node.kind != ir.enums.ContractKind.CONTRACT:
return
self.contracts.append(node)
def print(self) -> None:
for contract in self.contracts:
print(contract.name)
@printer.command(name="list-contracts")
def cli(self) -> None:
pass
You’ve just built your first printer. It collects and prints deployable contracts—your first step toward automated contract mapping.
Tutorial 2: Analyzing Contract Functions
Understanding which functions are externally callable is crucial for security: public ‘withdraw’ or ‘transfer’ functions often define a contract’s attack surface. Let’s create a printer that maps out the attack surface by listing all public and external functions.
Setting Up the Functions Printer
Create a new printer:
wake up printer list-functions
Implementation Strategy
Now we’ll expand our printer to map each contract’s external attack surface. Our goal: List only the final, callable public/external functions for each deployable contract, excluding interfaces and overridden functions.
While we could use visit_function_definition to iterate all functions, grouping them by contract provides better context. We’ll use visit_contract_definition and access the functions attribute.
Start by collecting all contracts:
class ListFunctionsPrinter(Printer):
contracts: list[ir.ContractDefinition] = []
def visit_contract_definition(self, node: ir.ContractDefinition) -> None:
self.contracts.append(node)
Processing the Inheritance Hierarchy
In the print() method, we traverse the inheritance hierarchy from base to derived contracts, showing callable functions at each level:
def print(self) -> None:
for node in self.contracts:
# Skip if not a contract (interface, library)
if node.kind != ir.enums.ContractKind.CONTRACT:
continue
#Process leaf contracts only
if len(node.child_contracts) !=0:
continue
# Print the inheritance hierarchy (from base to derived)
for base_contract in reversed(node.linearized_base_contracts):
print(f"Contract: {base_contract.name}")
functions = self.get_callable_final_functions(base_contract)
if len(functions) > 0:
print("Functions:")
for function in functions:
print(f" {function.name}")
print("--------------------")
Filtering for Attack Surface Functions
The get_callable_final_functions helper method identifies which functions can actually be called by external actors. It checks that a function is a final implementation (not overridden by child contracts) and has public or external visibility. These are the functions that matter for security analysis since they represent the contract’s actual attack surface.
def get_callable_final_functions(self, contract: ir.ContractDefinition) -> list[ir.FunctionDefinition]:
return [
func for func in contract.functions
if len(func.child_functions) == 0 # Is final implementation
and func.visibility in [ir.enums.Visibility.PUBLIC, ir.enums.Visibility.EXTERNAL]
]
Running the Functions Printer
Execute the printer to see the inheritance hierarchy and callable functions:
wake print list-functions
Output:
Contract: Context
Contract: Ownable
Functions:
owner
renounceOwnership
transferOwnership
Contract: SingleTokenVault
Functions:
constructor
deposit
withdraw
emergencyWithdraw
balanceOf
setDepositLimits
--------------------
Contract: EIP712Example
Functions:
constructor
DOMAIN_SEPARATOR
castVoteBySignature
getVoteCounts
--------------------
Contract: Context
Contract: IERC20
Contract: IERC20Metadata
Contract: IERC20Errors
Contract: ERC20
Functions:
name
symbol
decimals
totalSupply
balanceOf
transfer
allowance
approve
transferFrom
Contract: IERC20Permit
Contract: IERC5267
Contract: EIP712
Functions:
eip712Domain
Contract: Nonces
Contract: ERC20Permit
Functions:
permit
nonces
DOMAIN_SEPARATOR
Contract: PermitToken
Functions:
constructor
--------------------
Contract: Token
Functions:
constructor
mintTokens
transfer
transferWithBytes
getBalance
--------------------
Contract: Context
Contract: IERC20
Contract: IERC20Metadata
Contract: IERC20Errors
Contract: ERC20
Functions:
name
symbol
decimals
totalSupply
balanceOf
transfer
allowance
approve
transferFrom
Contract: MockERC20
Functions:
constructor
--------------------
The output gives you a quick visual map of each contract’s inheritance and callable entry points.
Tutorial 3: Adding Command-Line Options
Real-world analysis often requires focusing on specific contracts. Let’s enhance our printer to accept command-line arguments, allowing targeted analysis of individual contracts.
Understanding CLI Integration
Wake printers can accept command-line options through the @click.option decorator. This enables dynamic analysis based on user input. We’ll add a --contract-name option to filter results for a specific contract.
Implementing the Option
First, add a class member to store the contract name, then use @click.option to capture the command-line argument:
@printer.command(name="list-functions")
@click.option("--contract-name", type=str, required=False)
def cli(self, contract_name: str | None) -> None:
self.contract_name = contract_name
Conditional Filtering Logic
The print() method now checks if a specific contract was requested. If no contract name is provided, the printer lists all deployable contracts. If a name is specified, it drills into that contract’s hierarchy only, even if it’s not a leaf contract.
Complete Implementation with CLI Options
Here’s the final printer with optional contract filtering built in.
from __future__ import annotations
import networkx as nx
import rich_click as click
import wake.ir as ir
import wake.ir.types as types
from rich import print
from wake.cli import SolidityName
from wake.printers import Printer, printer
class ListFunctionsPrinter(Printer):
contracts: list[ir.ContractDefinition] = []
contract_name: str | None = None
def get_callable_final_functions(self, contract: ir.ContractDefinition) -> list[ir.FunctionDefinition]:
return [
func for func in contract.functions
if len(func.child_functions) == 0 # Is final implementatione
and func.visibility in [ir.enums.Visibility.PUBLIC, ir.enums.Visibility.EXTERNAL]
]
def visit_contract_definition(self, node: ir.ContractDefinition) -> None:
self.contracts.append(node)
def print(self) -> None:
for node in self.contracts:
# If contract name is specified, only process that contract
if self.contract_name is not None and node.name != self.contract_name:
continue
# Skip if not a contract (e.g., interface, library)
if node.kind != ir.enums.ContractKind.CONTRACT:
continue
# If no contract name specified, only process leaf contracts
if self.contract_name is None and len(node.child_contracts) != 0:
continue
# Print the inheritance hierarchy (from base to derived)
for base_contract in reversed(node.linearized_base_contracts):
print(f"Contract: {base_contract.name}")
functions = self.get_callable_final_functions(base_contract)
if len(functions) > 0:
print("Functions:")
for function in functions:
print(f" {function.name}")
print("--------------------")
@printer.command(name="list-functions")
@click.option("--contract-name", type=str, required=False)
def cli(self, contract_name: str | None) -> None:
self.contract_name = contract_name
pass
Now your printer can analyze specific contracts on demand—a feature that makes targeted auditing fast and repeatable.
Using the Enhanced Printer
Now you can analyze specific contracts:
# Analyze all deployable contracts
wake print list-functions
# Focus on a specific contract
wake print list-functions --contract-name Token
Practical Applications for Security Auditing
With these foundational skills, you can create printers that visualize and analyze your codebase structure. Custom printers let you visualize key code relationships quickly. They don’t detect vulnerabilities directly, but they reveal patterns that guide your manual review.
Useful Analysis Patterns to Visualize
Once you’re comfortable building printers, try these advanced patterns to visualize complex security relationships.
Access Control Mapping: Create printers that list all state-changing functions alongside their access modifiers. This overview helps you quickly identify which functions might need additional protection.
Call Flow Visualization: Map out which contracts call which functions. Understanding these relationships reveals potential attack paths and helps prioritize your audit focus.
State Variable Usage: Track how storage variables are accessed across functions. This analysis helps identify complex state dependencies that warrant closer inspection.
Inheritance Hierarchies: Visualize the complete inheritance tree of your contracts. Complex inheritance can hide function implementations and create unexpected behaviors.
Building Your Analysis Toolkit
Start small. Build printers that solve your immediate needs. Each one adds to your personal toolkit. Over time, you’ll develop reusable scripts that make every audit faster.
The Wake printer system’s flexibility means you can adapt your analysis tools to different audit scenarios. Whether you’re mapping upgrade patterns, visualizing DeFi protocol interactions, or understanding storage layouts, custom printers transform hours of manual code reading into seconds of automated analysis and visualization.
Next Steps
Printers give you the map; detectors find the vulnerabilities. Together, they turn Solidity auditing from a manual grind into a structured, insightful process. Every printer you write makes complex code clearer—and strengthens the safety of the smart contracts you review.
For vulnerability detection, Wake provides a separate detector system that goes beyond visualization to identify actual security issues. Printers give you the map; detectors find the problems.
Consider contributing your printers back to the community. Analysis tools are most powerful when shared, and your custom printer might help another auditor understand complex codebases more efficiently.
For more advanced topics and complete API reference, visit: Wake static analysis documents
