1/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.
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)
Guest lecturers:
Juliette Chevalier
Lead Developer relations at Aragon
Nader Dabit
Director of developer relations at EigenLayer
Ally Haire
Developer relations at Protocol Labs
Harrison
Founder at GasliteGG
Last updated on May 12, 2025
Solidity Developer
Advanced FoundryDuration: 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)
Guest lecturers:
Juliette Chevalier
Lead Developer relations at Aragon
Nader Dabit
Director of developer relations at EigenLayer
Ally Haire
Developer relations at Protocol Labs
Harrison
Founder at GasliteGG
Last updated on May 12, 2025