ZKsync Era runs Solidity, uses Ethereum addresses and speaks the Ethereum JSON-RPC, so it's easy to assume it's the EVM. For contracts compiled the usual ZKsync way, it isn't. They run on EraVM, a different virtual machine designed for proving, and a contract that is correct on Ethereum can compute wrong addresses, fail to deploy children, reject users or lock funds there. Most of the differences fall into a handful of categories.
zksolc and EraVM, not solc and the EVM
When you build for ZKsync with forge build --zksync or hardhat-zksync, zksolc uses solc as a front end, takes its Yul or EVM assembly output, and compiles that through LLVM into EraVM bytecode. EraVM is register-based, has its own gas schedule, and represents some EVM features as calls to system contracts (the deployer, nonce holder, ETH balance and so on).
A few EVM features therefore don't exist or behave differently. SELFDESTRUCT, CALLCODE and EXTCODECOPY are not supported and fail at compile time, and assembly that inspects or copies bytecode generally won't work.
ZKsync has also shipped an EVM bytecode interpreter that lets unmodified EVM bytecode run on Era, at a higher gas cost than native EraVM code, and its newer ZKsync OS stack is designed around EVM execution. Which of these your target chain supports, and how completely, depends on its protocol version, so check the current docs. Everything below applies to contracts compiled to EraVM with zksolc, which is still what the ZKsync-specific toolchains produce.
CREATE and CREATE2 addresses are different
On Ethereum, CREATE2 gives keccak256(0xff ++ deployer ++ salt ++ keccak256(initCode)). On EraVM the formula uses a different prefix, a ZKsync-specific bytecode hash (a versioned hash of the EraVM bytecode, not keccak256 of init code) and the hash of the constructor input separately:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
library ZkCreate2 {
bytes32 private constant CREATE2_PREFIX = keccak256("zksyncCreate2");
function computeAddress(
address deployer,
bytes32 salt,
bytes32 bytecodeHash,
bytes memory constructorInput
) internal pure returns (address) {
bytes32 h = keccak256(
bytes.concat(
CREATE2_PREFIX,
bytes32(uint256(uint160(deployer))),
salt,
bytecodeHash,
keccak256(constructorInput)
)
);
return address(uint160(uint256(h)));
}
}
CREATE is also derived differently, from a zksyncCreate prefix, the deployer and its deployment nonce, which is tracked separately from the transaction nonce. Any code or script that precomputes addresses with the Ethereum formulas, such as ethers' getCreateAddress and getCreate2Address, gets the wrong answer. Off-chain, use the utils.createAddress / utils.create2Address helpers in zksync-ethers, and get the bytecode hash from the compiled artifact. The same contract deployed with the same salt also won't land at the same address on Ethereum and ZKsync, which matters for cross-chain "same address everywhere" setups.
Factory dependencies
EraVM deploys contracts through the ContractDeployer system contract by bytecode hash, and the bytecode must already be known to the chain. When your factory does new Pair(), zksolc knows Pair at compile time and records it as a factory dependency, which is published with the factory's deployment. That works.
What doesn't work is deploying bytecode the compiler can't see: building init code in memory and calling create in assembly, deploying bytecode passed in calldata, or EIP-1167 minimal proxies like OpenZeppelin's Clones, which assemble EVM bytecode at runtime. Use new, or a ZKsync-aware implementation of the pattern.
Native account abstraction
On ZKsync Era, every account is a smart account. Addresses without deployed code are handled by a built-in DefaultAccount implementation that behaves like an EOA, but users can also have custom accounts with multisig, passkeys or session keys, and paymasters can pay fees for them natively. That breaks some Ethereum habits:
msg.sender == tx.originorextcodesize(msg.sender) == 0as "is a human" checks reject custom accounts, and they were never a security boundary anyway.ecrecover-only signature checks fail for accounts that don't sign with a single secp256k1 key. Verify signatures with ERC-1271 as well, e.g. OpenZeppelin'sSignatureChecker.isValidSignatureNow, which triesecrecoverfirst and falls back to calling the account.
Gas and pubdata
Fees have two parts: execution, and pubdata, the state diffs that must be published to Ethereum. Pubdata is charged in gas, and each transaction sets a gasPerPubdataByteLimit (the EIP-712 gasPerPubdata field), the most gas it will pay per byte of pubdata.
Storage writes, which produce pubdata, dominate the cost, while writing the same slot several times within a transaction is charged roughly once, for its final value. The price of pubdata also moves with Ethereum's data costs. Gas numbers, gasleft() thresholds and hard-coded gas limits from Ethereum don't carry over. Always estimate against ZKsync itself, and leave headroom in any forwarded gas amounts.
Block values and opcodes
block.numberis the L2 block number, and L2 blocks come far more often than Ethereum's 12 seconds. Durations measured in blocks will be wrong; useblock.timestamp.block.timestampis the L2 block's timestamp. Keep tolerance for small skews, as on any rollup.block.coinbasereturns the bootloader system contract, andblock.prevrandaoreturns a constant. Neither is a source of randomness.address(this).balancereturns the balance of the chain's base token. On Era that's ETH, but other ZK Stack chains can use a different base token.- Precompiles.
ecrecover,sha256,keccak256and the common curve operations are implemented, some as system contracts with different gas costs. Not every Ethereum precompile has always been available, so check the current list before depending on one.
.transfer and .send
Solidity's .transfer and .send forward a fixed 2300-gas stipend. EraVM's gas costs are different, and a recipient that is a contract, including a smart account, may need more than 2300 gas to receive funds. The call then fails: .transfer reverts and .send returns false. In 2023, a token sale contract on Era locked about 921 ETH because it used .transfer, until the ZKsync team helped recover it. Use a checked low-level call, with a reentrancy guard:
(bool ok, ) = payable(recipient).call{value: amount}("");
require(ok, "ETH transfer failed");
Deploying and testing with ZKsync tools
foundry-zksync is a fork of Foundry, installed with foundryup-zksync, that adds a --zksync flag:
forge build --zksync
forge test --zksync
forge create src/Counter.sol:Counter --zksync \
--rpc-url https://sepolia.era.zksync.dev \
--account deployer \
--broadcast
Run your test suite with --zksync specifically: tests that pass on the EVM can fail on EraVM because of the differences above.
hardhat-zksync configures zksolc and ZKsync networks in Hardhat:
import { HardhatUserConfig } from "hardhat/config";
import "@matterlabs/hardhat-zksync";
const config: HardhatUserConfig = {
zksolc: { version: "latest", settings: {} },
solidity: "0.8.28",
defaultNetwork: "zkSyncSepoliaTestnet",
networks: {
zkSyncSepoliaTestnet: {
url: "https://sepolia.era.zksync.dev",
ethNetwork: "sepolia",
zksync: true,
},
},
};
export default config;
Compiled output goes to artifacts-zk/, separate from EVM artifacts. Deploy with the plugin's deployer rather than plain ethers factories, so factory dependencies and the EIP-712 transaction format are handled for you.
Checklist
| Area | On ZKsync Era (EraVM) | What to do |
|---|---|---|
| Compiler | zksolc produces EraVM bytecode | Build and test with --zksync / hardhat-zksync |
| Unsupported opcodes | SELFDESTRUCT, CALLCODE, EXTCODECOPY | Remove them; avoid bytecode introspection |
| CREATE / CREATE2 | Different formulas, bytecode hash, deployment nonce | Use zksync-ethers helpers or the formula above |
| Factories | Bytecode must be a known factory dependency | Use new; avoid runtime-built bytecode and EIP-1167 clones |
| Accounts | Every account is a smart account | Drop EOA checks; support ERC-1271 signatures |
| Gas | Pubdata priced separately via gasPerPubdata | Estimate on ZKsync; no hard-coded gas limits |
| Blocks | Frequent L2 blocks | Use block.timestamp for durations |
| Randomness | prevrandao is constant | Use an oracle such as a VRF |
| ETH transfers | 2300-gas stipend may be too little | Use call{value: ...} and check the result |
| Base token | May not be ETH on other ZK Stack chains | Don't assume balance is ETH |
If a contract has to run on both Ethereum and ZKsync, treat ZKsync as its own deployment target: separate build, separate tests, and separately computed addresses.
Get the weekly commit
New blockchain deep dives every week.