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
storage layout output

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 printers
  • list-contracts.py with 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 displayed
  • cli(): 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

Also read The Beginners Guide for Manually Guided Fuzzing