Deploying the same contract to Ethereum, Polygon and BSC — what actually differs
-
The bytecode compiles once. That is where the similarity ends. Here is what bites when you take a contract that works on one EVM chain and deploy it to another, in roughly the order it causes problems.
Opcode support is not uniform
This is the one that fails loudest and confuses people most, because the contract compiled fine.
Solidity 0.8.20 and later emit the PUSH0 opcode by default when targeting Shanghai or newer. Chains that have not adopted that EVM version reject the bytecode outright — deployment fails, or worse, deploys and reverts on first call. The fix is to set the EVM version explicitly in your compiler config rather than letting it default:
settings: { evmVersion: "paris" }
Compile for the oldest EVM version across your target chains, not the newest. You lose a trivial amount of gas efficiency and gain a single artifact that deploys everywhere.
Hardcoded addresses do not travel
Obvious when stated, missed constantly. Router addresses, oracle feeds, stablecoin contracts, multicall helpers, WETH — all different per chain, and some do not exist at all on the chain you are porting to.
Anything external belongs in the constructor or an immutable set at deploy time, never a constant in the source. If you already have constants in a deployed contract, that is a redeploy, not a configuration change.
Worth checking specifically: oracle feed addresses and their heartbeat and deviation parameters. The same feed name on two chains can have very different update frequencies, and a liquidation or pricing function tuned for one will behave differently on the other.
Block time and finality assumptions
Any logic that counts blocks rather than seconds changes meaning when block time changes. A lock period written as block.number + 40320 means roughly a week on a 15-second chain and roughly a day and a half on a 2-second chain.
Use block.timestamp for anything a human will perceive as a duration. Use block numbers only where you genuinely mean blocks.
Finality matters separately. The number of confirmations your off-chain indexer should wait before treating a transaction as settled is a per-chain decision, and getting it wrong shows up as phantom balances in your backend after a reorg — not as a contract bug, which makes it painful to trace.
Gas economics change the design, not just the cost
On an expensive chain you batch aggressively, pack storage tightly, and push computation off-chain. On a cheap chain those optimisations stop paying for themselves and start costing you readability.
The trap is the reverse direction: a contract designed for a cheap chain, with generous storage writes and per-item loops, becomes unusable when deployed to an expensive one. Unbounded loops are the usual culprit — fine at 2 gwei, a denial of service at 40.
If the same contract must live on both, design for the expensive chain and accept slight over-engineering on the cheap one.
Chain ID is inside your signatures
Any EIP-712 signature, permit, or meta-transaction scheme includes the chain ID in its domain separator. That is deliberate — it prevents a signature valid on one chain from being replayed on another.
Two consequences. First, if you cache the domain separator at construction and the chain later forks, your separator is stale; recomputing when block.chainid differs from the stored value is the standard guard. Second, your frontend and backend must both build the domain for the chain the user is actually connected to, not a default. Mismatched domains produce signature verification failures that look like wallet problems.
CREATE2 gives you the same address, with conditions
Deterministic deployment across chains is possible and useful — same address everywhere simplifies documentation, integrations and user trust. It requires the same deployer address, the same salt, and identical init code, which includes constructor arguments.
That last part catches people: if you pass a chain-specific address as a constructor argument, the init code differs and so does the resulting address. If you want identical addresses, pass nothing chain-specific to the constructor and set those values in a separate initialise call afterwards.
A porting checklist
Before deploying to a new chain:
Pin evmVersion to the lowest common denominator across targets.
Grep the source for hardcoded addresses. Move every one to constructor or initialiser.
Grep for block.number. Confirm each use genuinely means blocks.
Re-check oracle feed addresses and their parameters on the new chain.
Decide the confirmation depth for your indexer on that chain specifically.
Fork-test against the new chain’s live state, not a clean local node. Contracts you depend on may be deployed at different versions.
Verify the contract on that chain’s explorer immediately after deploy. Explorers differ in their verification API, and doing it later with a lost build artifact is avoidable pain.
Question for the forum
For those deploying across several of the chains supported here — do you keep one artifact compiled for the oldest EVM version, or maintain per-chain builds? And has anyone found a clean way to handle per-chain constructor arguments while keeping CREATE2 addresses identical, beyond the separate-initialiser pattern?