5/5
## Constructing the zkSync Era Transaction Struct for Smart Account Interaction This lesson guides you through the process of building the `Transaction` struct, a fundamental component for interacting with zkSync Era smart contract accounts, particularly within a Foundry testing environment. We'll explore why this struct is necessary, how to implement a helper function to create it, and how to utilize it in your tests. ## The zkSync `Transaction` Struct: A Necessary Component When working with zkSync Era smart contract accounts, such as the `ZkMinimalAccount.sol` contract, a key difference from standard Ethereum account abstraction (like ERC-4337) emerges. While a typical Ethereum AA setup might involve a simple `execute` function call with parameters like `destination`, `value`, and `functionData`, zkSync's `executeTransaction` function demands a more comprehensive `Transaction memory _transaction` object as input. Think of this `Transaction` struct as zkSync's equivalent to the `PackedUserOperation` struct used in ERC-4337 on Ethereum. It serves to bundle all essential information for a transaction into a single, cohesive object. In previous Ethereum examples, a script (e.g., `SendPackedUserOp.s.sol`) might assist in creating the `UserOperation`. However, in our current zkSync test setup using Foundry, we'll need to craft a helper function directly within our test file (`ZkMinimalAccountTest.t.sol`) to construct this `Transaction` object. ## Implementing the `_createUnsignedTransaction` Helper Function The core of our task is to build a helper function that assembles an *unsigned* transaction struct. This unsigned transaction is suitable for testing scenarios where an action is initiated directly by the account owner. **1. Importing the `Transaction` Struct Definition** The `Transaction` struct is not a native Solidity type nor is it inherently part of standard Foundry. It must be imported from the `foundry-era-contracts` library. This library provides system contracts and helper utilities for zkSync Era development. Add the following import statement to your test file (e.g., `ZkMinimalAccountTest.t.sol`): ```solidity import {Transaction} from "lib/foundry-era-contracts/src/system-contracts/contracts/libraries/MemoryTransactionHelper.sol"; ``` You can navigate to `MemoryTransactionHelper.sol` within the `foundry-era-contracts` library to inspect the detailed definition of the `Transaction` struct. **2. Defining the Helper Function** We'll place our helper function under a comment block for organization, typically something like `/*//////////// HELPERS ////////////*/`. The function signature will be: ```solidity function _createUnsignedTransaction( address from, uint8 transactionType, address to, uint256 value, bytes memory data ) internal view returns (Transaction memory) { // Implementation will go here } ``` **3. Understanding the `Transaction` Struct Fields** The `Transaction` struct, as defined in `MemoryTransactionHelper.sol`, contains numerous fields. Here's an overview of the key ones we'll be populating: * `txType`: (uint8) Specifies the type of transaction. For zkSync native Account Abstraction, we'll use type `113` (or `0x71`). * `from`: (uint256) The address initiating the transaction. Note: This is a `uint256`, not an `address` type. * `to`: (uint256) The target contract address. Also a `uint256`. * `gasLimit`: (uint256) The gas limit for the transaction. * `gasPerPubdataByteLimit`: (uint256) The maximum gas price per byte of pubdata. This is relevant for the cost of publishing data from L2 (zkSync) to L1 (Ethereum). * `maxFeePerGas`: (uint256) Similar to EIP-1559, the maximum fee per gas. * `maxPriorityFeePerGas`: (uint256) Similar to EIP-1559, the maximum priority fee per gas. * `paymaster`: (uint256) The address of the paymaster (0 if no paymaster is used). * `nonce`: (uint256) The transaction nonce for the `from` address. * `value`: (uint256) The amount of ETH (or native currency) being transferred with the transaction. * `data`: (bytes) The calldata for the transaction (e.g., function signature and arguments). * `reserved`: (uint256[4]) An array reserved for future protocol use. * `signature`: (bytes) The transaction signature. For our unsigned transaction, this will be empty. * `factoryDeps`: (bytes32[]) Hashes of contract bytecode needed for deployments initiated by this transaction. Empty if not deploying contracts. * `paymasterInput`: (bytes) Input data for the paymaster, if one is used. * `reservedDynamic`: (bytes) A dynamically-sized field reserved for future use. **4. Populating the Struct in the Helper Function** Now, let's fill in the implementation of `_createUnsignedTransaction`: ```solidity function _createUnsignedTransaction( address from, uint8 transactionType, address to, uint256 value, bytes memory data ) internal view returns (Transaction memory) { // Fetch the nonce for the 'minimalAccount' (our smart contract account) // Note: vm.getNonce is a Foundry cheatcode. In a real zkSync environment, // you'd query the NonceHolder system contract. uint256 nonce = vm.getNonce(address(minimalAccount)); // Initialize an empty array for factory dependencies bytes32[] memory factoryDeps = new bytes32[](0); return Transaction({ txType: transactionType, // e.g., 113 for zkSync AA from: uint256(uint160(from)), // Cast 'from' address to uint256 to: uint256(uint160(to)), // Cast 'to' address to uint256 gasLimit: 16777216, // Placeholder value (adjust as needed) gasPerPubdataByteLimit: 16777216, // Placeholder value maxFeePerGas: 16777216, // Placeholder value maxPriorityFeePerGas: 16777216, // Placeholder value paymaster: 0, // No paymaster for this example nonce: nonce, // Use the fetched nonce value: value, // Value to be transferred reserved: [uint256(0), uint256(0), uint256(0), uint256(0)], // Default empty data: data, // Transaction calldata signature: hex"", // Empty signature for an unsigned transaction factoryDeps: factoryDeps, // Empty factory dependencies paymasterInput: hex"", // No paymaster input reservedDynamic: hex"" // Empty reserved dynamic field }); } ``` Several placeholder values (e.g., for `gasLimit`, `gasPerPubdataByteLimit`) are used. In a real-world scenario, you would need to estimate these more accurately. For our testing purposes, these large values suffice as precise gas estimation is not the current focus. ## Key Considerations for zkSync Transactions When constructing zkSync `Transaction` structs, keep the following points in mind: * **Transaction Types (`txType`):** For zkSync native Account Abstraction, the `txType` is **113** (hex `0x71`). zkSync supports other transaction types (e.g., Legacy, EIP-2930, EIP-1559), but `113` is specific to AA. * **Address Casting (`uint256(uint160(address))`):** A critical detail is that the `from` and `to` fields in the zkSync `Transaction` struct are `uint256`, not the standard `address` type. The conversion `uint256(uint160(someAddress))` is the standard way to cast an `address` (which is 160 bits) to a `uint256`. This distinction arises from lower-level data representation, a topic often explored in depth in areas like EVM assembly or formal verification. * **Nonce Handling (`vm.getNonce`):** In our Foundry test, we use the `vm.getNonce(address)` cheatcode to retrieve the nonce for the `minimalAccount`. It's important to recognize that this is a simplification provided by Foundry. In a live zkSync Era environment, nonces are managed by a dedicated `NonceHolder` system contract. For robust, off-Foundry applications, you would interact with this `NonceHolder` contract to get the correct nonce. * **Pubdata (`gasPerPubdataByteLimit`):** This field accounts for the cost associated with publishing data from zkSync (L2) back to Ethereum (L1). This is a distinct cost factor in zkEVM rollups, reflecting the L1 data availability requirements. * **Unsigned Transaction:** Our helper function is named `_createUnsignedTransaction` because we explicitly set the `signature` field to an empty byte string (`hex""`). This is suitable for owner-initiated actions within tests where the signature verification path might be bypassed or handled differently. * **Factory Dependencies (`factoryDeps`):** This field is crucial if your transaction intends to deploy new contracts *through* the smart contract account. It holds the bytecode hashes of these new contracts. If your transaction only calls existing contracts, this array can be empty. * **Paymaster Fields (`paymaster`, `paymasterInput`):** These fields are used when implementing sponsored transactions via a paymaster. If you're not using a paymaster, `paymaster` should be the zero address, and `paymasterInput` can be empty. * **Reserved Fields:** These are populated with default empty/zero values as they are designated for future protocol enhancements or features. ## Utilizing the Helper Function in Your Tests With the `_createUnsignedTransaction` helper function in place, let's see how to use it within a test function, for example, `testZkOwnerCanExecuteCommands`. **1. Arrange Phase:** Your test will likely already have code to set up the destination address, value, and function data for the intended call. For instance, preparing a call to a USDC contract's `mint` function: ```solidity // Example setup (values may vary based on your specific test) address dest = address(usdc); // Target contract (USDC) uint256 value = 0; // No ETH transferred in this specific call bytes memory functionData = abi.encodeWithSignature("mint(address,uint256)", address(minimalAccount), AMOUNT); ``` **2. Create the Transaction Object:** Now, call your new helper function to construct the `Transaction` struct: ```solidity // Inside your test function, before the "Act" phase Transaction memory transaction = _createUnsignedTransaction( minimalAccount.owner(), // 'from' is the owner of the smart account 113, // Transaction type for zkSync AA (0x71) dest, // 'to' is the USDC contract address value, // 'value' is 0 for this mint call functionData // 'data' is the encoded mint function call ); ``` Here, `minimalAccount.owner()` provides the `from` address, signifying that the owner is initiating this action. **3. Act Phase:** To execute the transaction, you'll interact with your `minimalAccount` contract. * **Simulating the Caller (`vm.prank`):** Since we are testing an owner-initiated action, and the `executeTransaction` function on `ZkMinimalAccount.sol` likely checks that `msg.sender` is the owner (or the bootloader), we use Foundry's `vm.prank` cheatcode. This makes the subsequent call appear as if it originated from the `minimalAccount.owner()`. ```solidity vm.prank(minimalAccount.owner()); ``` * **Executing the Transaction:** Call the `executeTransaction` function on your `minimalAccount` instance, passing the constructed `transaction` object. ```solidity // Define EMPTY_BYTES32 if not already defined: // bytes32 constant EMPTY_BYTES32 = bytes32(0); minimalAccount.executeTransaction(EMPTY_BYTES32, EMPTY_BYTES32, transaction); ``` The first two arguments to `executeTransaction` (`_txHash` and `_suggestedSignedHash`) are passed as `EMPTY_BYTES32`. These are typically provided by the bootloader in a full zkSync transaction flow. However, for an owner-initiated transaction where the `requireFromBootloaderOrOwner` modifier (or similar logic) is satisfied by the `msg.sender == owner` condition, these hashes may not be strictly validated for this specific execution path, allowing us to use placeholders. **4. Assert Phase:** Finally, assert the expected outcome of the transaction. For example, if the transaction was a USDC mint, verify that the `minimalAccount`'s USDC balance has increased: ```solidity assertEq(usdc.balanceOf(address(minimalAccount)), AMOUNT, "USDC balance should increase"); ``` ## Conclusion You have now learned how to identify the necessity for the zkSync `Transaction` struct, import its definition from the `foundry-era-contracts` library, and implement a crucial helper function, `_createUnsignedTransaction`. This function allows you to systematically populate the `Transaction` struct with the required details, including the correct transaction type, address casting, nonce management considerations, and placeholders for gas and unused fields. By integrating this helper with Foundry's `vm.prank`, you can effectively test owner-initiated actions on your zkSync Era smart contract accounts, calling `executeTransaction` and verifying the results, thereby enhancing your testing capabilities for zkSync dApps.
This lesson guides you through the process of building the Transaction
struct, a fundamental component for interacting with zkSync Era smart contract accounts, particularly within a Foundry testing environment. We'll explore why this struct is necessary, how to implement a helper function to create it, and how to utilize it in your tests.
Transaction
Struct: A Necessary ComponentWhen working with zkSync Era smart contract accounts, such as the ZkMinimalAccount.sol
contract, a key difference from standard Ethereum account abstraction (like ERC-4337) emerges. While a typical Ethereum AA setup might involve a simple execute
function call with parameters like destination
, value
, and functionData
, zkSync's executeTransaction
function demands a more comprehensive Transaction memory _transaction
object as input.
Think of this Transaction
struct as zkSync's equivalent to the PackedUserOperation
struct used in ERC-4337 on Ethereum. It serves to bundle all essential information for a transaction into a single, cohesive object. In previous Ethereum examples, a script (e.g., SendPackedUserOp.s.sol
) might assist in creating the UserOperation
. However, in our current zkSync test setup using Foundry, we'll need to craft a helper function directly within our test file (ZkMinimalAccountTest.t.sol
) to construct this Transaction
object.
_createUnsignedTransaction
Helper FunctionThe core of our task is to build a helper function that assembles an unsigned transaction struct. This unsigned transaction is suitable for testing scenarios where an action is initiated directly by the account owner.
1. Importing the Transaction
Struct Definition
The Transaction
struct is not a native Solidity type nor is it inherently part of standard Foundry. It must be imported from the foundry-era-contracts
library. This library provides system contracts and helper utilities for zkSync Era development.
Add the following import statement to your test file (e.g., ZkMinimalAccountTest.t.sol
):
You can navigate to MemoryTransactionHelper.sol
within the foundry-era-contracts
library to inspect the detailed definition of the Transaction
struct.
2. Defining the Helper Function
We'll place our helper function under a comment block for organization, typically something like /*//////////// HELPERS ////////////*/
.
The function signature will be:
3. Understanding the Transaction
Struct Fields
The Transaction
struct, as defined in MemoryTransactionHelper.sol
, contains numerous fields. Here's an overview of the key ones we'll be populating:
txType
: (uint8) Specifies the type of transaction. For zkSync native Account Abstraction, we'll use type 113
(or 0x71
).
from
: (uint256) The address initiating the transaction. Note: This is a uint256
, not an address
type.
to
: (uint256) The target contract address. Also a uint256
.
gasLimit
: (uint256) The gas limit for the transaction.
gasPerPubdataByteLimit
: (uint256) The maximum gas price per byte of pubdata. This is relevant for the cost of publishing data from L2 (zkSync) to L1 (Ethereum).
maxFeePerGas
: (uint256) Similar to EIP-1559, the maximum fee per gas.
maxPriorityFeePerGas
: (uint256) Similar to EIP-1559, the maximum priority fee per gas.
paymaster
: (uint256) The address of the paymaster (0 if no paymaster is used).
nonce
: (uint256) The transaction nonce for the from
address.
value
: (uint256) The amount of ETH (or native currency) being transferred with the transaction.
data
: (bytes) The calldata for the transaction (e.g., function signature and arguments).
reserved
: (uint256[4]) An array reserved for future protocol use.
signature
: (bytes) The transaction signature. For our unsigned transaction, this will be empty.
factoryDeps
: (bytes32[]) Hashes of contract bytecode needed for deployments initiated by this transaction. Empty if not deploying contracts.
paymasterInput
: (bytes) Input data for the paymaster, if one is used.
reservedDynamic
: (bytes) A dynamically-sized field reserved for future use.
4. Populating the Struct in the Helper Function
Now, let's fill in the implementation of _createUnsignedTransaction
:
Several placeholder values (e.g., for gasLimit
, gasPerPubdataByteLimit
) are used. In a real-world scenario, you would need to estimate these more accurately. For our testing purposes, these large values suffice as precise gas estimation is not the current focus.
When constructing zkSync Transaction
structs, keep the following points in mind:
Transaction Types (txType
): For zkSync native Account Abstraction, the txType
is 113 (hex 0x71
). zkSync supports other transaction types (e.g., Legacy, EIP-2930, EIP-1559), but 113
is specific to AA.
Address Casting (uint256(uint160(address))
): A critical detail is that the from
and to
fields in the zkSync Transaction
struct are uint256
, not the standard address
type. The conversion uint256(uint160(someAddress))
is the standard way to cast an address
(which is 160 bits) to a uint256
. This distinction arises from lower-level data representation, a topic often explored in depth in areas like EVM assembly or formal verification.
Nonce Handling (vm.getNonce
): In our Foundry test, we use the vm.getNonce(address)
cheatcode to retrieve the nonce for the minimalAccount
. It's important to recognize that this is a simplification provided by Foundry. In a live zkSync Era environment, nonces are managed by a dedicated NonceHolder
system contract. For robust, off-Foundry applications, you would interact with this NonceHolder
contract to get the correct nonce.
Pubdata (gasPerPubdataByteLimit
): This field accounts for the cost associated with publishing data from zkSync (L2) back to Ethereum (L1). This is a distinct cost factor in zkEVM rollups, reflecting the L1 data availability requirements.
Unsigned Transaction: Our helper function is named _createUnsignedTransaction
because we explicitly set the signature
field to an empty byte string (hex""
). This is suitable for owner-initiated actions within tests where the signature verification path might be bypassed or handled differently.
Factory Dependencies (factoryDeps
): This field is crucial if your transaction intends to deploy new contracts through the smart contract account. It holds the bytecode hashes of these new contracts. If your transaction only calls existing contracts, this array can be empty.
Paymaster Fields (paymaster
, paymasterInput
): These fields are used when implementing sponsored transactions via a paymaster. If you're not using a paymaster, paymaster
should be the zero address, and paymasterInput
can be empty.
Reserved Fields: These are populated with default empty/zero values as they are designated for future protocol enhancements or features.
With the _createUnsignedTransaction
helper function in place, let's see how to use it within a test function, for example, testZkOwnerCanExecuteCommands
.
1. Arrange Phase:
Your test will likely already have code to set up the destination address, value, and function data for the intended call. For instance, preparing a call to a USDC contract's mint
function:
2. Create the Transaction Object:
Now, call your new helper function to construct the Transaction
struct:
Here, minimalAccount.owner()
provides the from
address, signifying that the owner is initiating this action.
3. Act Phase:
To execute the transaction, you'll interact with your minimalAccount
contract.
Simulating the Caller (vm.prank
): Since we are testing an owner-initiated action, and the executeTransaction
function on ZkMinimalAccount.sol
likely checks that msg.sender
is the owner (or the bootloader), we use Foundry's vm.prank
cheatcode. This makes the subsequent call appear as if it originated from the minimalAccount.owner()
.
Executing the Transaction: Call the executeTransaction
function on your minimalAccount
instance, passing the constructed transaction
object.
The first two arguments to executeTransaction
(_txHash
and _suggestedSignedHash
) are passed as EMPTY_BYTES32
. These are typically provided by the bootloader in a full zkSync transaction flow. However, for an owner-initiated transaction where the requireFromBootloaderOrOwner
modifier (or similar logic) is satisfied by the msg.sender == owner
condition, these hashes may not be strictly validated for this specific execution path, allowing us to use placeholders.
4. Assert Phase:
Finally, assert the expected outcome of the transaction. For example, if the transaction was a USDC mint, verify that the minimalAccount
's USDC balance has increased:
You have now learned how to identify the necessity for the zkSync Transaction
struct, import its definition from the foundry-era-contracts
library, and implement a crucial helper function, _createUnsignedTransaction
. This function allows you to systematically populate the Transaction
struct with the required details, including the correct transaction type, address casting, nonce management considerations, and placeholders for gas and unused fields. By integrating this helper with Foundry's vm.prank
, you can effectively test owner-initiated actions on your zkSync Era smart contract accounts, calling executeTransaction
and verifying the results, thereby enhancing your testing capabilities for zkSync dApps.
A developer's deep-dive to zkSync Transaction Structs for Smart Account Foundry Tests - Explore building the zkSync `Transaction` struct with a helper function in Foundry, vital for smart contract account testing. Master its fields, address casting, nonce handling, and execute owner-initiated transactions via `executeTransaction`.
Previous lesson
Previous
Next lesson
Next
Give us feedback
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Web3 engineer, educator, and Cyfrin co-founder. Patrick's smart contract development and security courses have helped hundreds of thousands of engineers kickstarting their careers into web3.
Guest lecturers:
Last updated on July 10, 2025
Duration: 36min
Duration: 3h 06min
Duration: 5h 02min
Duration: 6h 02min
Duration: 2h 47min
Duration: 1h 23min
Duration: 4h 28min
Duration: 1h 19min
Duration: 1h 10min
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Web3 engineer, educator, and Cyfrin co-founder. Patrick's smart contract development and security courses have helped hundreds of thousands of engineers kickstarting their careers into web3.
Guest lecturers:
Last updated on July 10, 2025