ZKsync deeper dive bridging stuff back (a.k.a withdrawals)

Assuming that you have completed part 1 and part 2 already, we can bridge the tokens back by simply calling the zksync-cli:

npx zksync-cli bridge withdraw --chain=dockerized-node

And providing the account name (public address) and private key.

Afterward, by using web3 tools, we can quickly check that funds were transferred back to L1. And you discover that they didn’t - what happened?

Actually we’ll have to run one additional step:

npx zksync-cli bridge withdraw-finalize --chain=dockerized-node

and pass the transaction that we received from the first call, into the withdraw-finalize call.

Note: This is not needed on testnet - as we (MatterLabs) - are running an automatic tool that confirms withdrawals.

Looking deeper

But let’s take a look what happened under the hood.

Let’s start by looking at the output of our zksync-cli:

Withdrawing 7ETH to 0x618263CE921F7dd5F4f40C29f6c524Aaf97b9bbd on localnet
Transaction submitted 💸💸💸
L2: tx/0xe2c8a7beaf8879cb197555592c6eb4b6e4c39a772c3b54d1b93da14e419f4683
Your funds will be available in L1 in a couple of minutes.

important - your transaction id will be different - make sure that you use it in the methods below.

The tool created the withdraw transaction and it sent it directly to our server (so this is a L2 transaction). The zk server has received it, and added it into its database. You can check it by querying the transactions table:

# select * from transactions where hash = '\x<YOUR_L2_TRANSACTION_ID_FROM_ABOVE>`
select * from transactions where hash = '\xe2c8a7beaf8879cb197555592c6eb4b6e4c39a772c3b54d1b93da14e419f4683';

This will print a lot of columns, but let’s start by looking at the data column:

{
  "value": "0x6124fee993bc0000",
  "calldata": "0x51cff8d9000000000000000000000000618263ce921f7dd5f4f40c29f6c524aaf97b9bbd",
  "factoryDeps": null,
  "contractAddress": "0x000000000000000000000000000000000000800a"
}

We can use the ABI decoder tool https://calldata-decoder.apoorv.xyz/ to see what this call data means:

{
  "function": "withdraw(address)",
  "params": ["0x618263CE921F7dd5F4f40C29f6c524Aaf97b9bbd"]
}

(and the 0x6124fee993bc0000 in the value is 7000000000000000000 == 7 ETH that we wanted to send).

So the last question is – what is the ‘magic’ contract address: 0x800a ?

/// @dev The address of the eth token system contract
address constant L2_BASE_TOKEN_SYSTEM_CONTRACT_ADDR = address(0x800a);

System contracts (on L2)

This is a good opportunity to talk about system contracts that are automatically deployed on L2. You can find the full list here in github

This is the place where we specify that bootloader is at address 0x8001, NonceHolder at 0x8003 etc.

This brings us to L2BaseToken.sol that has the implementation of the L2 Eth.

When we look inside, we can see:

// Send the L2 log, a user could use it as proof of the withdrawal
bytes memory message = _getL1WithdrawMessage(_l1Receiver, amount);
L1_MESSENGER_CONTRACT.sendToL1(message);

And L1MessengerContract (that is deployed at 0x8008).

Committing to L1

And how do these messages get into the L1? The eth_sender class from our server is taking care of this. You can see the details of the transactions that it posts to L1 in our database in eth_txs table.

If you look at the tx_type column (in psql), you can see that we have 3 different transaction types:

zksync_local=# select contract_address, tx_type from eth_txs;
              contract_address              |          tx_type
--------------------------------------------+---------------------------
 0x54e8159f006750466084913d5bd288d4afb1ee9a | CommitBlocks
 0x54e8159f006750466084913d5bd288d4afb1ee9a | PublishProofBlocksOnchain
 0x54e8159f006750466084913d5bd288d4afb1ee9a | ExecuteBlocks
 0x54e8159f006750466084913d5bd288d4afb1ee9a | CommitBlocks
 0x54e8159f006750466084913d5bd288d4afb1ee9a | PublishProofBlocksOnchain
 0x54e8159f006750466084913d5bd288d4afb1ee9a | ExecuteBlocks

BTW - all the transactions are sent to the 0x54e address - which is the DiamondProxy deployed on L1 (this address will be different on your local node - see previous tutorial for more info) .

And inside, all three methods above belong to Executor.sol facet and you can look at README to see the details of what each method does.

The short description is:

  • ‘CommitBlocks’ - is verifying the block metadata and stores the hash into the L1 contract storage.
  • ‘PublishProof’ - gets the proof, checks that the proof is correct and that it is a proof for the block hash that was stored in commit blocks. (IMPORTANT: in testnet/localnet we allow empty proofs - so that you don’t have to run the full prover locally)
  • ‘ExecuteBlocks’ - is the final call, that stores the root hashes in L1 storage. This allows other calls (like finalizeWithdrawal) to work.

So to sum it up - after these 3 calls, the L1 contract has a root hash of a merkle tree, that contains the ‘message’ about the withdrawal.

Final step - finalizing withdrawal

Now we’re ready to actually claim our ETH on L1. We do this by calling a finalizeEthWithdrawal function on the DiamondProxy contract (Mailbox.sol to be exact).

To prove that we actually can withdraw the money, we have to say in which L2 block the withdrawal happened, and provide the merkle proof from our withdrawal log, to the root that is stored in the L1 contract.