On Starknet, transactions represent the execution of programs written in the Cairo programming language. Each transaction (or program execution) is identified by a unique transaction hash, making it traceable and distinguishable. For instance, running the same program twenty times will result in twenty unique transactions, each with its own transaction hash. Like any program execution, the behavior of these transactions can be analyzed through a Call Trace, a detailed log that captures the functions invoked, the arguments passed, the outputs returned, and more. For developers, Call Traces are essential for understanding program execution, identifying issues, and optimizing performance. In this post, we will go through an example Call Trace of a Starknet Transaction line by line to better explain how it works in detail.
Specifics of Starknet Call Traces
As mentioned in the introduction, Starknet transaction execution can be represented using a traditional Call Trace, as it reflects the execution of a program. Unlike traditional program execution, where all function calls are treated uniformly, there are differences that result in the structure of the Call Trace. These differences arise from the existence of multiple types of calls within a transaction. These include calls made within a single smart contract and calls to other smart contracts. To distinguish between these, the Call Trace component in Walnut includes Chips that specify the type of each call (also referred to as frames). A Chip labeled “Call” represents a call to a smart contract, while a Chip labeled “Function” denotes a regular function call within a contract. For a complete reference of call types, see the Call Trace Types documentation.
Understanding a Starknet Call Trace Step by Step
💡 Note: in this example, we use an INVOKE transaction, which is the most common type of transaction on Starknet. There are other types of transactions, such as DEPLOY_ACCOUNT or DECLARE, where the initial function calls are different. For more details on other types of transactions, please refer to the official documentation.
Let's now walk through a real-world example of a transaction on Starknet. You can see a full Call Trace in the screenshot above. Below, we break the trace down into multiple screenshots, each representing a logical part of the trace, and explain each part step by step. If you want to explore the trace yourself, you can open it on Walnut with this link.
💡 Note: each line in a Call Trace is called a “Frame”. That's the term we will use throughout the following post.
Initial Frames: Argent Account Interaction
Every INVOKE transaction on Starknet starts from an Account Contract, thanks for Starknet's implementation of native account abstraction.
The very first function the account calls is the __validate__ function.
Validation Frame: __validate__
The __validate__ function is the initial validation frame of each INVOKE transaction on Starknet. This function is designed to ensure that the transaction meets the criteria set by the account contract, before it proceeds to the actual program execution.
In our transaction example, as shown in the Call Trace above, the __validate__ function performs the following:
- Calldata Deserialization: The transaction data, or calldata, is deserialized to prepare it for further validation and processing. Calldata is the information sent from an external caller to the contract, and it contains all the necessary parameters required for executing the transaction.
- Signature Validation: The function verifies the caller's signature, ensuring that only authorized accounts can initiate the transaction.
It's worth noting that the exact implementation of __validate__ is flexible and up to the Account provider (in our example, Argent), and permissions or signature validation may not always be required. The purpose of this function is to confirm that the transaction aligns with the account's defined rules. Each Account Contract on Starknet is required to implement the __validate__ method. You can read more here.
If __validate__ asserts, the transaction is REJECTED and does not proceed further.
Execution Frame: __execute__
Once __validate__ passes, the __execute__ function is invoked with the same Calldata. This is the stage where the core actions of transaction are performed:
- Calldata Deserialization: Just as in the validation phase, calldata is prepared for use in the core transaction logic.
- Multicall Execution: After deserialization phase, it moves on to multicall. Starknet supports native multicall functionality through __execute__, allowing a single transaction to perform multiple actions within the same contract or across different contracts. This capability is unique to Starknet, as Ethereum requires separate contracts to implement similar functionality. For more information on how multicalls work in Starknet, refer to Starknet documentation.
Execution Flow of get_beer():
After successfully passing the validation phases, get_beer() is called through Starknet's multicall functionality. The multicall process is managed by the execute_multicall() function, which iterates over each call provided in the transaction's calldata. For each call, execute_multicall() uses the call_contract_syscall() function to pass the target contract address, the entrypoint selector, and any parameters required by the function - in this case, the get_beer() function on the IBeer2 contract.
An entrypoint is a function in a contract that can be called externally, allowing external entities to interact with the contract. For a deeper dive into entrypoints, you can refer to the definition.
An entrypoint selector (or function selector) is a unique identifier that specifies the exact function to call within a contract.
💡 In the Call Trace on Walnut, it's common to see both an entrypoint Call (in green) and a corresponding Function call (in purple) for the same operation:
IBeer2.get_beer(age_roof)->()
represents the external Call made to theIBeer2
contract.IBeer2Impl::get_beer(ContractState: None, AgeProof: [3224123, 29]) -> (PanicResult<(ContractState, ())>: [0, 0, 0])
represents the actual logic that is executed internally within the contract.
This distinction helps to trace the flow from external requests to internal execution, ensuring full transparency. The entrypoint initiates the call, while the function implements the actual contract logic.
Nested Contract Calls
Next up, the get_beer() calls another contract, IVerifier via its verify() entrypoint. This verify() method is part of an internal logic of the IBeer2 contract, and you can see the full source code here.
Similar to the get_beer() flow, this contract call involves both an entrypoint and a function call internally.
Function calls inside verify()
Inside the verify() call, you'll notice multiple function calls, such as:
Each of this function frames represent the specific operations executed as part of the verify() function logic, showcasing the detailed internal operations.
Further Nested Contract Calls: Proof Calculation
The calculation_proof() entrypoint on the IVerificationHelper contract is invoked to calculate the proof. Internally, the corresponding function handles the actual calculation logic, ensuring the correctness of the proof before the transaction continues.
Here, the calculation_proof() function is actually a contract call. While it looks like a simple function call, it's interacting with the IVerificationHelper contract, which contains logic to calculate and verify the provided proof.
Since calculation_proof() is a contract call, it means:
- This implementations lives in another contract (IVerificationHelper).
- The proof calculation happens within this helper contract, ensuring the integrity of the result before proceeding.
Final Frame: Token Transfer
The trace ends with a token transfer operation:
Function calls inside send_token()
Inside the send_token() contract call, the trace shows several internal operations:
These operations handle mathematical calculations (e.g., for token balances) and state updates (e.g., updating the recipient's token balance in storage).
Summary
This transaction walkthrough illustrated how each phase relies on a combination of entrypoints and internal function calls. The __validate__ and __execute__ functions ensure the transaction's security and execution, while subsequent entrypoints handle specific contract operations. Entrypoints manage the initial external interactions, and function calls handle the detailed internal logic. This structure provides a complete, traceable path from the start of a request to the completion of its internal execution.
Conclusion
A Call Trace is a detailed record of a program's execution, providing a comprehensive view of how a transaction unfolds within the network. Call Traces help developers and auditors understand exactly what happened during a transaction. Here's why they matter:
- Debugging Made Easy: If a transaction fails, the Call Trace shows which function failed and why - making it easier to fix bugs.
- Transparency and Security: Every function call, input, and result is logged, making it easier to audit transactions and ensure compliance with the expected logic.
Call Traces are the key to understanding what happens inside Starknet transactions. They tell the complete story - from __validate__ to __execute__.
Whether you're debugging a transaction, or auditing your smart contracts, Call Traces provide the insights you need to dive deep and understand every step of the process.
Next time you're working on a Starknet project, don’t forget to check the Walnut - it's your roadmap to everything happening under the hood.
With 🖤 by Walnut