Asset Transfers

A guide for sending L2 -> L2 asset transfers with ZKsync Connect.

Choose between using Hardhat 3 + viem, Hardhat 3 + ethers, or with a Foundry test.

  1. Create a new project folder
    mkdir hardhat-example
    cd hardhat-example
    
  1. Initialize a new Hardhat 3 project with Node Test Runner and Viem.
    npx hardhat --init
    
  1. Install the zksync-js npm package.
    npm install -D @matterlabs/zksync-js
    
  2. Install OpenZeppelin Contracts.
    npm install -D @openzeppelin/contracts
    
  3. Configure the hardhat.config.ts file with the three local chains setup in the local setup, a rich wallet, and ignition.requiredConfirmations set to 1.
      ignition: {
        requiredConfirmations: 1,
      },
      networks: {
        localZKsyncOSL1: {
          type: 'http',
          chainType: 'l1',
          url: 'http://localhost:8545',
          accounts: ['0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110'],
        },
        localZKsyncOSChain1: {
          type: 'http',
          chainType: 'generic',
          url: 'http://localhost:3050',
          accounts: ['0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110'],
        },
        localZKsyncOSChain2: {
          type: 'http',
          chainType: 'generic',
          url: 'http://localhost:3051',
          accounts: ['0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110'],
        },
      },
    
  4. Create a new file in the contracts folder called InteropToken.sol.
    touch contracts/InteropToken.sol
    
  5. Copy and paste the contract below into the InteropToken.sol file.
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.0;
    
    import "@openzeppelin/contracts/access/Ownable.sol";
    import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
    import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
    
    contract InteropToken is ERC20, Ownable, ERC20Burnable {
        constructor(string memory name, string memory symbol) ERC20(name, symbol) Ownable(msg.sender) {
            _mint(msg.sender, 100 * 10 ** decimals());
        }
    
        function mint(address to, uint256 amount) public onlyOwner {
            _mint(to, amount);
        }
    }
    

Path 1: Deploy the token on L1

  1. Create a new file in the scripts folder called interop-asset-transfer.ts.
    touch scripts/interop-asset-transfer.ts
    
  2. Copy and paste the script below into the interop-asset-transfer.ts file.
  3. Run the script.
    npx hardhat run scripts/interop-asset-transfer.ts
    

This script deploys the token on L1, bridges it to chain 6565, and then transfers it to chain 6566 with interop.

Path 2: Deploy the token on L2

  1. Create two new files in the scripts folder called interop-asset-transfer.ts and interop-asset-migration.ts.
    touch scripts/interop-asset-transfer.ts
    touch scripts/interop-asset-migration.ts
    
  2. Create a new file in the ignition/modules folder called InteropToken.ts.
    touch ignition/modules/InteropToken.ts
    
  3. Copy and paste the Ignition module below into InteropToken.ts.
    import { buildModule } from '@nomicfoundation/hardhat-ignition/modules';
    
    export default buildModule('InteropToken', (m) => {
      const interopToken = m.contract('InteropToken', ['Interop Token', 'ITK']);
    
      return { interopToken };
    });
    
  4. Compile and deploy the token contract on chain 6565.
    npx hardhat compile
    
    npx hardhat ignition deploy ignition/modules/InteropToken.ts --network localZKsyncOSChain1
    
  5. Save the deployed token address as an environment variable.
    export INTEROP_TOKEN_ADDRESS=0x...
    
  1. Copy and paste the Gateway migration script below into scripts/interop-asset-migration.ts.
  1. Run the migration script.
    npx hardhat run scripts/interop-asset-migration.ts
    
  1. Copy and paste the script below into the interop-asset-transfer.ts file.
  1. Run the script.
    npx hardhat run scripts/interop-asset-transfer.ts
    

You should see output similar to this:

Using InteropToken on localZKsyncOSChain1 at: 0x...
SourceBalance:  100000000000000000000n
✅ Created interop transaction.
✅ Bundle is finalized on source; root available on destination.
Finalize result: {
  bundleHash: '0x...',
  dstExecTxHash: '0x...'
}
Chain 6565 balance after interop: 99999999999999000000
Mapped token on chain 6566 after interop: 0x...
Chain 6566 balance after interop: 1000000
  1. Create a new project folder
    mkdir hardhat-example
    cd hardhat-example
    
  1. Initialize a new Hardhat 3 project with Mocha and Ethers.js.
    npx hardhat --init
    
  1. Install the zksync-js npm package.
    npm install -D @matterlabs/zksync-js
    
  2. Install OpenZeppelin Contracts.
    npm install -D @openzeppelin/contracts
    
  3. Configure the hardhat.config.ts file with the three local chains setup in the local setup, a rich wallet, and ignition.requiredConfirmations set to 1.
      ignition: {
        requiredConfirmations: 1,
      },
      networks: {
        localZKsyncOSL1: {
          type: 'http',
          chainType: 'l1',
          url: 'http://localhost:8545',
          accounts: ['0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110'],
        },
        localZKsyncOSChain1: {
          type: 'http',
          chainType: 'generic',
          url: 'http://localhost:3050',
          accounts: ['0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110'],
        },
        localZKsyncOSChain2: {
          type: 'http',
          chainType: 'generic',
          url: 'http://localhost:3051',
          accounts: ['0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110'],
        },
      },
    
  4. Create a new file in the contracts folder called InteropToken.sol.
    touch contracts/InteropToken.sol
    
  5. Copy and paste the contract below into the InteropToken.sol file.
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.0;
    
    import "@openzeppelin/contracts/access/Ownable.sol";
    import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
    import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
    
    contract InteropToken is ERC20, Ownable, ERC20Burnable {
        constructor(string memory name, string memory symbol) ERC20(name, symbol) Ownable(msg.sender) {
            _mint(msg.sender, 100 * 10 ** decimals());
        }
    
        function mint(address to, uint256 amount) public onlyOwner {
            _mint(to, amount);
        }
    }
    

Path 1: Deploy the token on L1

  1. Create a new file in the scripts folder called interop-asset-transfer.ts.
    touch scripts/interop-asset-transfer.ts
    
  2. Copy and paste the script below into the interop-asset-transfer.ts file.
  3. Run the script.
    npx hardhat run scripts/interop-asset-transfer.ts
    

This script deploys the token on L1, bridges it to chain 6565, and then transfers it to chain 6566 with interop.

Path 2: Deploy the token on L2

  1. Create two new files in the scripts folder called interop-asset-transfer.ts and interop-asset-migration.ts.
    touch scripts/interop-asset-transfer.ts
    touch scripts/interop-asset-migration.ts
    
  2. Create a new file in the ignition/modules folder called InteropToken.ts.
    touch ignition/modules/InteropToken.ts
    
  3. Copy and paste the Ignition module below into InteropToken.ts.
    import { buildModule } from '@nomicfoundation/hardhat-ignition/modules';
    
    export default buildModule('InteropToken', (m) => {
      const interopToken = m.contract('InteropToken', ['Interop Token', 'ITK']);
    
      return { interopToken };
    });
    
  4. Compile and deploy the token contract on chain 6565.
    npx hardhat compile
    
    npx hardhat ignition deploy ignition/modules/InteropToken.ts --network localZKsyncOSChain1
    
  5. Save the deployed token address as an environment variable.
    export INTEROP_TOKEN_ADDRESS=0x...
    
  1. Copy and paste the Gateway migration script below into scripts/interop-asset-migration.ts.
    The example helper for that flow is:
  2. Copy and paste the script below into the interop-asset-transfer.ts file.
  1. Run the script.
    npx hardhat run scripts/interop-asset-transfer.ts
    

You should see output similar to this:

Using InteropToken on localZKsyncOSChain1 at: 0x...
SourceBalance:  100000000000000000000n
✅ Created interop transaction.
✅ Bundle is finalized on source; root available on destination.
Finalize result: {
  bundleHash: '0x...',
  dstExecTxHash: '0x...'
}
Chain 6565 balance after interop: 99999999999999000000
Mapped token on chain 6566 after interop: 0x...
Chain 6566 balance after interop: 1000000
  1. Create a new Foundry project.
    forge init InteropAssetTransfer
    
  2. Change into the project directory.
    cd InteropAssetTransfer
    
  3. Install OpenZeppelin Contracts.
    forge install OpenZeppelin/openzeppelin-contracts
    

    Once installed, add a remappings.txt file and add this line:

    @openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
    
  4. Create the contract and scripts used in this guide.
    touch src/InteropToken.sol
    touch script/InteropAssetTransferSendBundle.s.sol
    touch script/InteropAssetTransferFinalizeBundle.s.sol
    touch script/get-proof-encoded.sh
    
  5. Copy the token contract below into src/InteropToken.sol.
    This is a standard ERC20 token contract that mints an initial supply to the deployer and exposes an owner-only mint function for local testing.
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.28;
    
    import "@openzeppelin/contracts/access/Ownable.sol";
    import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
    import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
    
    contract InteropToken is ERC20, Ownable, ERC20Burnable {
        constructor(string memory name, string memory symbol) ERC20(name, symbol) Ownable(msg.sender) {
            _mint(msg.sender, 100 * 10 ** decimals());
        }
    
        function mint(address to, uint256 amount) public onlyOwner {
            _mint(to, amount);
        }
    }
    
  6. Copy the bundle sender below into script/InteropAssetTransferSendBundle.s.sol.
    It registers the token as an interoperable asset, and sends an interop bundle transferring the token from chain 6565 to chain 6566.
  7. Copy the bundle finalizer below into script/InteropAssetTransferFinalizeBundle.s.sol.
    This script runs on chain 6566. It decodes the inclusion proof, executes the bundle through the InteropHandler, resolves the mapped token on the destination chain, and prints the source and destination balances after finalization.
  8. Copy the proof helper below into script/get-proof-encoded.sh.
    This helper script fetches the L2-to-L1 proof data for the asset transfer bundle and ABI-encodes it into the format expected by the Foundry finalizer script.
  9. Make the proof helper executable.
    chmod +x script/get-proof-encoded.sh
    
  10. Build the project.
    forge build
    
  11. Set the local private key used by the scripts.
    export LOCAL_PRIVATE_KEY="0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110"
    

Path 1: Deploy the token on L1

  1. Create the L1 deployment and bridge script.
    touch script/InteropAssetTransferFromL1.s.sol
    
  2. Copy the script below into script/InteropAssetTransferFromL1.s.sol.
    This script deploys the ERC20 on L1 and deposits the token into chain 6565.
  3. Deploy the ERC20 on the L1 and deposit the tokens to chain 6565.
    forge script script/InteropAssetTransferFromL1.s.sol:InteropAssetTransferFromL1 \
      --rpc-url http://localhost:8545 \
      --broadcast \
      --skip-simulation \
      --private-key "$LOCAL_PRIVATE_KEY"
    
  4. After the deposit finalizes on chain 6565, check the balance of the bridged token.
    Replace <L2_TOKEN_ADDRESS> with the predicted bridged token address output from the script.
    cast call <L2_TOKEN_ADDRESS> "balanceOf(address)(uint256)" 0x36615Cf349d7F6344891B1e7CA7C72883F5dc049 \
      --rpc-url http://localhost:3050
    
  5. After the deposit finalizes on chain 6565, send the bridged token from chain 6565 to chain 6566.
    forge script script/InteropAssetTransferSendBundle.s.sol:InteropAssetTransferSendBundle \
      --sig "run(address)" <L2_TOKEN_ADDRESS> \
      --rpc-url http://localhost:3050 \
      --broadcast \
      --skip-simulation \
      --private-key "$LOCAL_PRIVATE_KEY"
    
  6. Generate the inclusion proof from the send transaction hash.
    Replace <SEND_TX_HASH> with the last L2 transaction hash output from the script. This is the sendBundle(...) transaction hash, not the earlier approve(...) or ensureTokenIsRegistered(...) transaction hashes.
    PROOF_ENCODED_HEX=$(./script/get-proof-encoded.sh <SEND_TX_HASH> http://localhost:3050)
    
  7. Finalize the bundle on chain 6566.
    forge script script/InteropAssetTransferFinalizeBundle.s.sol:InteropAssetTransferFinalizeBundle \
      --sig "run(address,bytes)" <L2_TOKEN_ADDRESS> $PROOF_ENCODED_HEX \
      --rpc-url http://localhost:3051 \
      --broadcast \
      --skip-simulation \
      --private-key "$LOCAL_PRIVATE_KEY"
    

Path 2: Deploy the token on L2

  1. Create the L2 deployment helper.
    touch script/InteropAssetTransferDeployToken.s.sol
    
  2. Copy the deployment helper below into script/InteropAssetTransferDeployToken.s.sol.
    This script deploys the ERC20 on chain 6565 and prints the deployed token address for the later migration and interop steps.
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.28;
    
    import {console2} from "forge-std/console2.sol";
    import {Script} from "forge-std/Script.sol";
    import {InteropToken} from "../src/InteropToken.sol";
    
    contract InteropAssetTransferDeployToken is Script {
        uint256 internal constant DEFAULT_LOCAL_PRIVATE_KEY =
            0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110;
    
        event TokenDeployed(address token);
    
        function run() external returns (address tokenAddress) {
            uint256 privateKey = vm.envOr("LOCAL_PRIVATE_KEY", DEFAULT_LOCAL_PRIVATE_KEY);
    
            vm.startBroadcast(privateKey);
            InteropToken token = new InteropToken("Interop Token", "ITK");
            tokenAddress = address(token);
            vm.stopBroadcast();
    
            console2.log("InteropToken deployed on chain 6565:", tokenAddress);
            emit TokenDeployed(tokenAddress);
        }
    }
    
  3. Create the Gateway migration helpers.
    touch script/InteropAssetMigration.s.sol
    touch script/get-migration-finalize-params.sh
    
  4. Copy the Gateway migration script below into InteropAssetMigration.s.sol.
    This single script handles the full Gateway migration flow through different entrypoints: run(address) starts the migration on chain 6565, run(bytes) finalizes the migration on L1, and status(address) checks whether the token is marked as migrated on chain 6565.
  5. Copy the migration proof helper below into get-migration-finalize-params.sh.
    This helper fetches the L2-to-L1 proof for the migration initiation transaction and ABI-encodes the finalize params expected by the L1 migration script.
  6. Make the migration proof helper executable.
    chmod +x script/get-migration-finalize-params.sh
    
  7. Deploy the ERC20 on chain 6565.
    forge script script/InteropAssetTransferDeployToken.s.sol:InteropAssetTransferDeployToken \
      --rpc-url http://localhost:3050 \
      --broadcast \
      --skip-simulation \
      --private-key "$LOCAL_PRIVATE_KEY"
    
  8. Save the deployed token address as an environment variable.
    export INTEROP_TOKEN_ADDRESS=0x...
    
  9. Start the Gateway migration on chain 6565.
    forge script script/InteropAssetMigration.s.sol \
      --target-contract InteropAssetMigration \
      --sig "run(address)" $INTEROP_TOKEN_ADDRESS \
      --rpc-url http://localhost:3050 \
      --broadcast \
      --skip-simulation \
      --private-key "$LOCAL_PRIVATE_KEY"
    
  10. Generate the encoded finalize params from the migration initiation transaction hash.
    Replace <MIGRATION_TX_HASH> with the last chain 6565 transaction hash from the migration initiation script output.
    MIGRATION_FINALIZE_PARAMS=$(./script/get-migration-finalize-params.sh <MIGRATION_TX_HASH> http://localhost:3050)
    
  11. Finalize the migration on L1.
    forge script script/InteropAssetMigration.s.sol \
      --target-contract InteropAssetMigration \
      --sig "run(bytes)" $MIGRATION_FINALIZE_PARAMS \
      --rpc-url http://localhost:8545 \
      --broadcast \
      --skip-simulation \
      --private-key "$LOCAL_PRIVATE_KEY"
    
  12. Check the migration status on chain 6565. It may take a minute for the migrated status to update to true.
    forge script script/InteropAssetMigration.s.sol \
      --target-contract InteropAssetMigration \
      --sig "status(address)" $INTEROP_TOKEN_ADDRESS \
      --rpc-url http://localhost:3050
    
  13. After the status script reports Migrated: true, send part of that balance from chain 6565 to chain 6566.
    forge script script/InteropAssetTransferSendBundle.s.sol:InteropAssetTransferSendBundle \
      --sig "run(address)" $INTEROP_TOKEN_ADDRESS \
      --rpc-url http://localhost:3050 \
      --broadcast \
      --skip-simulation \
      --private-key "$LOCAL_PRIVATE_KEY"
    
  14. Generate the inclusion proof from the send transaction hash.
    Use the last L2 transaction hash from the forge script broadcast on chain 6565. This is the sendBundle(...) transaction hash, not the earlier approve(...) or ensureTokenIsRegistered(...) transaction hashes. Do not use the printed bundleHash or assetId. You can find the transaction hash in the broadcast/InteropAssetTransferSendBundle.s.sol/6565/run-latest.json file.
    PROOF_ENCODED_HEX=$(./script/get-proof-encoded.sh <SEND_TX_HASH> http://localhost:3050)
    
  15. Finalize the bundle on chain 6566.
    forge script script/InteropAssetTransferFinalizeBundle.s.sol:InteropAssetTransferFinalizeBundle \
      --sig "run(address,bytes)" $INTEROP_TOKEN_ADDRESS $PROOF_ENCODED_HEX \
      --rpc-url http://localhost:3051 \
      --broadcast \
      --skip-simulation \
      --private-key "$LOCAL_PRIVATE_KEY"
    

Made with ❤️ by the ZKsync Community