introduction
Manually reviewing Solidity code is slow and error-prone. A single overlooked feature can hide costly vulnerabilities. Wake’s printer system automates the search for these dangerous patterns, turning hours of manual review into fast, reliable scans.
Wake Printer combines the simplicity of Python with Wake’s intermediate representation (IR), turning complex static analysis into simple scripts that highlight things like unrestricted withdrawals or missing access controls. This guide walks you through creating a custom printer that highlights security-related patterns in smart contracts.
Prerequisites and setup
This tutorial uses the Workshop repository as an example project.
git clone https://github.com/Ackee-Blockchain/2025-workshop-fuzzing
cd 2025-workshop-fuzzing
npm installBefore continuing, make sure Wake compiles your project successfully by running:
wake upUnderstanding Wake Printers
Wake comes with several built-in printers that show different types of analysis. You can list them using:
wake printLaunches a specific printer by name.
wake print storage-layoutThe built-in printer demonstrates the capabilities of the system, but the real power comes from creating a custom printer tailored to your security analysis requirements. Once you understand how the built-in printer works, you’ll see how easy it is to extend Wake using your own analysis tools. By the end of this guide, you will know how to create a printer that detects vulnerability patterns related to your auditing approach.
Tutorial 1: Creating your first printer – listing contracts
Let’s start with a simple printer that lists all the contracts in a project. This example introduces key concepts that will be used in more complex analyses.
Creating the printer structure
To scaffold your first printer, run the following command:
wake up printer list-contracts
Wake creates a new printer directory and startup file with the following structure:
printers/Directory for all custom printerslist-contracts.pyWith basic printer structure
Understanding Templates
The generated template provides the following 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:
passHere’s what each part of the template does:
print(): Main execution method in which analysis results are displayedcli(): Command line interface handler for user-defined arguments
Visitor pattern implementation
Wake uses the Visitor pattern to traverse the abstract syntax tree (AST) of the contract. The Visitor pattern allows Wake to automatically navigate your code structure and react to specific elements, such as contracts or function definitions.
to list contracts visit_contract_definition A method called for each contract in the codebase.
this way ListContractsPrinter class:
def visit_contract_definition(self, node: ir.ContractDefinition) -> None:
print(node.name)Test your printer.
wake print list-contractsThis command runs the printer and prints all contract names found in the project.
Improved output
The default implementation displays all contracts, including interfaces and inherited contracts. Let’s improve this to only show 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:
passCurrently the printer lists all contracts, including interfaces and base classes. Let’s improve this to only show deployable items.

Filter deployable contracts
Add conditions to filter interfaces, libraries, and default contracts. This will help you identify contracts that 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)that contract definition The class contains properties that can be used to filter results. For full reference, see https://ackee.xyz/wake/docs/latest/api-reference/ir/declarations/contract-definition/.
complete implementation
Here’s the final version, properly separating the issues of collecting data and displaying it during navigation. 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:
passI just created my first printer. Collect and print deployable contracts, the first step toward automated contract mapping.
Tutorial 2: Analyzing contract functionality
Understanding which functions can be called externally is very important for security. A public ‘revoke’ or ‘transfer’ function often defines the attack surface of a contract. Let’s create a printer that maps the attack surface by listing all public and external functions.
Function Printer Settings
Create a new printer.
wake up printer list-functionsExecution Strategy
Now let’s extend the printer to map the external attack surface of each contract. Our goal: List only the final public/external functions callable for each deployable contract, excluding interfaces and overridden functions.
while we can use it visit_function_definition If you want to iterate through all the functionality, grouping them by contract will provide better context. we will use visit_contract_definition and access functions It is caused by
Start by collecting all your contracts.
class ListFunctionsPrinter(Printer):
contracts: list(ir.ContractDefinition) = ()
def visit_contract_definition(self, node: ir.ContractDefinition) -> None:
self.contracts.append(node)Inheritance hierarchy handling
at print() Use the method to traverse the inheritance hierarchy from the base contract to the derived contract, displaying the 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("--------------------")Attack surface feature filtering
that get_callable_final_functions A helper method identifies a function that can actually be called by an external actor. Ensure that the function is the final implementation (not overridden by subcontracts) and has public or external visibility. This is an important feature for security analysis because it represents the actual attack surface of a contract.
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)
)Functions Printer execution
Run the printer to see the inheritance hierarchy and callable functions.
wake print list-functionscalculation:
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 provides a quick visual map of each contract’s inheritance and callable entry points.
Tutorial 3: Adding command line options
Practical analysis often requires a focus on specific contracts. Let’s improve the printer to accept command line arguments so that we can target analysis for individual contracts.
Understanding CLI Integration
Wake printers can accept command line options via: @click.option Decorator. This allows dynamic analysis based on user input. we are --contract-name Option to filter results for specific contracts.
Option Implementation
First add a class member to store the contract name, then @click.option To capture command line arguments:
@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_nameConditional filtering logic
that print() Now the method checks if a specific contract has been requested. If no contract name is provided, the printer lists all deployable contracts. Once a name is specified, it will only drill into the hierarchy of that contract, even if it is not a leaf contract.
Complete implementation with CLI option
The final printers with optional contract filtering built-in are:
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
passPrinters can now analyze specific contracts as needed. This is a feature that makes targeted audits fast and repeatable.
Use advanced printers
You can now analyze specific contracts.
# Analyze all deployable contracts
wake print list-functions
# Focus on a specific contract
wake print list-functions --contract-name TokenPractical Applications for Security Audits
Using these basic techniques, you can create a printer that visualizes and analyzes your codebase structure. Custom printers allow you to quickly visualize key code relationships. It doesn’t detect vulnerabilities directly, but shows patterns to guide manual review.
Analysis patterns useful for visualization
As you become more comfortable with creating printers, try using these advanced patterns to visualize complex security relationships.
Access Control Mapping: Creates a printer that lists all state change functions along with their access modifiers. This overview will help you quickly identify features that require additional protection.
Call flow visualization: Figure out which contract calls which function. Understanding these relationships helps identify potential attack vectors and prioritize audit focus.
How to use state variables: Tracks how storage variables are accessed throughout the function. This analysis helps identify complex state dependencies that require closer investigation.
inheritance hierarchy: Visualize the entire inheritance tree of a contract. Complex inheritance can hide function implementations and produce unexpected behavior.
Building an analysis toolkit
Start small. Build a printer that solves your immediate needs. Each adds to your personal toolkit. Over time, you will develop reusable scripts that will allow you to perform all your audits faster.
The flexibility of the Wake printer system means that the analysis tools can be applied to a variety of audit scenarios. Whether mapping upgrade patterns, visualizing DeFi protocol interactions, or understanding storage layouts, custom printers transform manual code reading time into seconds of automated analysis and visualization.
next steps
The printer provides maps. Detectors find vulnerabilities. Together, they transform Solidity audits from a manual task to a structured and insightful process. Every printer you write makes complex code clearer and makes smart contracts more secure for review.
For vulnerability detection, Wake provides a separate detection system that goes beyond visualization to identify actual security issues. The printer provides maps. The detector finds the problem.
Donate your printer back to your community. Analysis tools are most powerful when shared, and custom printers can help other auditors understand complex codebases more efficiently.
For advanced topics and full API reference, visit the Wake Static Analysis documentation.
You can also read our beginner’s guide to manually guided fuzzing.
