The Dark Side of Crypto: zkSync Recovery Operation

Last week, zkSync announced a new airdrop event. Shortly after this announcement, many users reached out to me on Twitter, reporting that their wallets had been hacked. They were curious about what they could do in this situation and how they could recover their assets. In this article, I will detail how hacked wallets were recovered during zkSync and LayerZero airdrops and the technical challenges encountered in this process.

Initial Steps and Goals

My primary goal was to help users receive their airdrops as quickly and securely as possible. To achieve this, I needed to process over 50 wallets in parallel and send prepared transactions to the network swiftly. I followed several critical steps to manage this process successfully.

First and foremost, setting up a reliable and fast node was essential. Node setup was crucial for transaction validation and synchronization with the network.

Research and Preparation Process

Initially, I continuously scraped the ZKNation (airdrop page) site. Any data added in real-time could significantly contribute to my work. At first, I only found the token address on the site and began examining the wallets that created this token address. During this process, I noticed that a Merkle Distributor proxy contract had been deployed recently and saw that the contract codes were open.

Upon examining the contract in detail, I discovered that tokens could be claimed using two functions, "claim" and "claimOnBehalf". The "claim" function required three input parameters: index, amount, and merkleProof. I realized that I could access these input parameters through the zkSync API. However, knowing that I currently did not have access to these proofs, I focused on constructing the general code structure.

Technical Details and Technologies Used

Given the need to process more than 50 wallets, performance and parallelism were of critical importance to me. Therefore, I decided to write my code in Rust. Rust is known for its high performance and memory safety features. It is also highly effective in handling parallel processing. While writing my code, I used the Alloy-rs library developed by Paradigm. Alloy-rs allowed me to manage blockchain transactions more efficiently and securely. This library offered significant advantages, especially in low-level transaction management and optimization.

Another reason for using Rust and Alloy-rs was to enhance the overall efficiency of the system and minimize errors. Rust’s memory safety and prevention of race conditions helped minimize potential errors when processing multiple wallets simultaneously. Alloy-rs played a crucial role in the success of my project by providing flexibility in transaction validation and data management processes.

Using the Paymaster feature on the zkSync network gave me a strategic advantage. Typically, hacked wallets had no ETH (due to a sweeper bot), requiring an extra transfer transaction. On a different network, the claim procedure would be: Transfer + claim + transfer. However, thanks to Paymaster, I simplified this process to just Claim + Transfer. This allowed me to execute transactions faster and more cost-effectively.

Paymaster usage was particularly advantageous for executing transactions in wallets without ETH. This minimized transaction time and costs by avoiding extra transfer transactions when claiming tokens from hacked wallets.

General Code Structure

The main structure of my code was built around parallel processing and error management. Here are the important parts of the code and their functions:

1. Parallel Processing Management:

This section creates a separate asynchronous task for each wallet. By using tokio::spawn, I can process the transactions for each wallet in parallel. This enables me to handle more than 50 wallets simultaneously.

    let tasks: Vec<_> = config
        .private_keys
        .into_iter()
        .map(|private_key| {
            let signer: LocalWallet = private_key.parse().unwrap();
            let signer_address = signer.address().to_string();

            if let Some(user_data_array) = airdrop_data.get(&signer_address) {
                let user_data = &user_data_array[0];
                let index = user_data["merkleIndex"].as_str().unwrap().parse::<u64>().unwrap();
                let amount = user_data["tokenAmount"].as_str().unwrap().parse::<u128>().unwrap();
                let merkle_proof: Vec<String> = user_data["merkleProof"]
                    .as_array()
                    .unwrap()
                    .iter()
                    .map(|v| v.as_str().unwrap().to_string())
                    .collect();

                let provider = Arc::clone(&provider);
                let params = WalletProcessParams::new(
                    provider,
                    private_key,
                    chain_id,
                    index,
                    amount,
                    merkle_proof,
                );
                tokio::spawn(async move { process_wallet(params).await })
            } else {
                tokio::spawn(async { Err(eyre::eyre!("User data not found")) })
            }
        })
        .collect();

2. Loading and Processing Data:

This function loads the airdrop data I prepared beforehand. By minimizing API calls, it enhances processing speed.

let airdrop_data = load_airdrop_data("airdrop_data.json").await?;

3. Wallet Processing Function:

This function manages the claim and transfer transactions for each wallet. The tokio::join! macro allows me to run these two transactions concurrently.

async fn process_wallet(params: WalletProcessParams) -> Result<(), eyre::Report> {
    let WalletProcessParams {
        provider,
        private_key,
        chain_id,
        claim_contract_address,
        token_contract_address,
        recipient_address,
        index,
        amount,
        merkle_proof,
    } = params;

    let signer: LocalWallet = private_key.parse().unwrap();
    let signer = Arc::new(signer);
    let provider = Arc::clone(&provider);
    let merkle_proof = Arc::new(merkle_proof);

    loop {
        let claim_future = {
            let provider = Arc::clone(&provider);
            let signer = Arc::clone(&signer);
            let merkle_proof = Arc::clone(&merkle_proof);
            async move {
                match perform_claim(
                    provider.clone(),
                    &signer,
                    chain_id,
                    claim_contract_address,
                    index,
                    amount,
                    merkle_proof.to_vec(),
                )
                .await
                {
                    Ok(_) => {
                        println!("Claim transaction succeeded.");
                        Ok(())
                    }
                    Err(e) => {
                        eprintln!("Claim transaction failed: {:?}", e);
                        Err(e)
                    }
                }
            }
        };

        let transfer_future = {
            let provider = Arc::clone(&provider);
            let signer = Arc::clone(&signer);
            async move {
                match perform_transfer(
                    provider.clone(),
                    &signer,
                    chain_id,
                    token_contract_address,
                    recipient_address,
                    amount,
                )
                .await
                {
                    Ok(_) => {
                        println!("Transfer transaction succeeded.");
                        Ok(())
                    }
                    Err(e) => {
                        eprintln!("Transfer transaction failed: {:?}", e);
                        Err(e)
                    }
                }
            }
        };

4. Error Management and Retry Mechanism:

This structure checks the results of the claim and transfer transactions and retries the transactions in case of errors. This ensures transaction completion even in the face of network issues or other temporary errors.

let (claim_result, transfer_result) = tokio::join!(claim_future, transfer_future);

match (claim_result, transfer_result) {
    (Ok(_), Ok(_)) => return Ok(()),
    (Err(claim_err), Ok(_)) => {
        eprintln!("Retrying claim transaction due to error: {:?}", claim_err);
    }
    (Ok(_), Err(transfer_err)) => {
        eprintln!("Retrying transfer transaction due to error: {:?}", transfer_err);
    }
    (Err(claim_err), Err(transfer_err)) => {
        eprintln!(
            "Retrying both transactions due to errors: claim - {:?}, transfer - {:?}",
            claim_err, transfer_err
        );
    }
}

5. Paymaster Integration:

Paymaster usage was integrated into the perform_claim and perform_transfer functions. These functions use the Paymaster contract to perform gasless transactions.

Conclusion

Thanks to this code structure, I was able to process multiple wallets in parallel, manage errors effectively, and perform gasless transactions using Paymaster, ensuring a fast and efficient airdrop recovery operation.

Recovered ZK tokens
Recovered ZK tokens

As a result, with this technical approach and code structure, I successfully recovered 90% of the wallets sent to me. The remaining 10% of failures were mostly due to external factors such as network issues preventing transaction propagation.

Source Code :

Contact:


Twitter : twitter.com/codeesura
Github: github.com/codeesura

Subscribe to armutbey
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.