← Back to Blog

Solidity Immutable and Constant Security: Hardcoded Addresses, Deployment Pitfalls, and Upgrade Risks

2026-04-18 immutable constant hardcoded address constructor solidity security deployment

immutable and constant feel like safe choices — these values don't change, and the compiler enforces it. But that guarantee cuts both ways. Once deployed, a wrong immutable is permanent. A constant that made sense at launch can become an operational liability as conditions shift. And hardcoded addresses that work on mainnet become silent failures on every other chain.

This post covers six vulnerability classes from misuse of immutable and constant — each with a vulnerable example, impact explanation, and hardened fix.


1. Hardcoded Token Address on the Wrong Chain

Solidity developers often hardcode token and protocol addresses in source files or at the top of contracts. On Ethereum mainnet, USDC lives at 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48. On Arbitrum, it's 0xaf88d065e77c8cC2239327C5EDb3A432268e5831. On Base, it's 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913. These are not the same contract.

// VULNERABLE: hardcoded mainnet address used on Arbitrum deployment
contract YieldVault {
    IERC20 public constant USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);

    function deposit(uint256 amount) external {
        // On Arbitrum, this calls a non-existent or wrong contract
        USDC.transferFrom(msg.sender, address(this), amount);
    }
}

When this contract is deployed to Arbitrum, calls to USDC.transferFrom target the mainnet USDC address — which either does not exist on Arbitrum, or is a completely different contract. The call may fail silently (returning false instead of reverting), leaving the vault broken with no obvious error.

The impact is severe: funds cannot enter or exit. There is no way to update the address post-deployment since constant values are baked into bytecode.

Fix: Accept addresses as constructor parameters and validate them

contract YieldVault {
    IERC20 public immutable USDC;

    constructor(address usdcAddress) {
        require(usdcAddress != address(0), "Zero address");
        // Optionally verify it responds correctly
        require(IERC20(usdcAddress).totalSupply() > 0, "Not a valid ERC20");
        USDC = IERC20(usdcAddress);
    }
}

The deployment script, not the source code, is responsible for providing the correct address per chain. This makes multi-chain deployment safe and auditable.


2. Immutable Not Set in Constructor (Stays Zero)

The immutable keyword in Solidity means the variable must be assigned exactly once — during the constructor — and cannot be changed afterward. What happens when a constructor bug causes the assignment to be skipped?

// VULNERABLE: immutable owner never assigned
contract AdminVault {
    address public immutable owner;

    constructor(address _owner) {
        // Bug: parameter name clash or copy-paste error
        // The intended assignment is missing
        if (_owner == address(0)) {
            // early return branch — but the else branch was forgotten
            revert("Zero owner");
        }
        // owner = _owner; <-- this line was accidentally deleted
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    function withdraw(uint256 amount) external onlyOwner {
        payable(msg.sender).transfer(amount);
    }
}

In Solidity, if an immutable variable is never assigned in the constructor, it remains at the zero value — address(0) for addresses, 0 for integers. The compiler does not error on this in older versions. Post-deployment, owner is permanently address(0), and the onlyOwner modifier rejects every caller, including legitimate admins. Funds locked in the vault are permanently inaccessible.

Fix: Validate the immutable immediately after assignment

contract AdminVault {
    address public immutable owner;

    constructor(address _owner) {
        require(_owner != address(0), "Zero address for owner");
        owner = _owner;
        // Double-check: reading back and verifying is defensive but clear
        require(owner == _owner, "Immutable assignment failed");
    }
}

The require check after assignment is an explicit safety net. Deployment scripts should also read owner() immediately after deployment and assert it matches the intended address before broadcasting further transactions.


3. Immutable Set to Wrong Value via Delegatecall

Proxy patterns are ubiquitous in upgradeable contract architectures. A common pattern deploys a logic contract and a proxy contract, with the proxy delegating all calls to the logic contract's code while maintaining its own storage. A subtle trap exists when logic contracts use immutable variables.

// Logic contract with immutable
contract LogicV1 {
    address public immutable trustedForwarder;

    constructor(address _forwarder) {
        trustedForwarder = _forwarder;
    }

    function execute(address target, bytes calldata data) external {
        require(msg.sender == trustedForwarder, "Not forwarder");
        (bool ok,) = target.call(data);
        require(ok);
    }
}

// Proxy attempts to configure the logic contract via delegatecall
contract Proxy {
    address public implementation;

    constructor(address _impl, address _forwarder) {
        implementation = _impl;
        // WRONG: calling initialize via delegatecall cannot set immutables
        (bool ok,) = _impl.delegatecall(
            abi.encodeWithSignature("initialize(address)", _forwarder)
        );
        require(ok, "Init failed");
    }
}

immutable variables are not part of contract storage — they are written directly into the deployed bytecode of the logic contract at deploy time. delegatecall executes the logic contract's code in the caller's storage context, but it cannot modify the logic contract's bytecode. The trustedForwarder immutable was set when LogicV1 was deployed, using whatever _forwarder was passed to LogicV1's own constructor. Any attempt to reconfigure it through the proxy is silently ignored.

The result: every execution through the proxy uses the forwarder address that was hardcoded into the logic contract at deploy time, not the address the proxy operator intended. If the logic contract was deployed with address(0) as a placeholder, all calls through the proxy will fail the trustedForwarder check permanently.

Fix: Do not use immutables in proxy logic contracts; use initialized storage variables instead

contract LogicV1 {
    address public trustedForwarder; // storage, not immutable
    bool private initialized;

    function initialize(address _forwarder) external {
        require(!initialized, "Already initialized");
        require(_forwarder != address(0), "Zero forwarder");
        trustedForwarder = _forwarder;
        initialized = true;
    }
}

Storage variables are part of the proxy's state, so delegatecall-based initialization works correctly. The initialized guard prevents re-initialization attacks.


4. Magic Number Constants Without Explanation

Constants that encode domain-specific values without documentation create two distinct problems: auditability and correctness verification. A reviewer who cannot decode a magic number cannot assess whether it is correct.

// VULNERABLE: unexplained magic numbers
contract ProtocolFee {
    uint256 public constant FEE = 300;
    uint256 public constant MAX_TOKENS = 50;
    uint256 public constant LOCK_PERIOD = 604800;

    function calculateFee(uint256 amount) external pure returns (uint256) {
        return amount * FEE / 10000;
    }
}

Is FEE = 300 a 3% fee, a 0.3% fee, or 300 basis points (also 3%)? The division by 10000 suggests basis points — 300 bps = 3% — but nothing in the code states this. A developer adding a new fee tier might use 100 assuming 1% when the convention requires 1000 for 1% using a different denominator, introducing a 10x error. Similarly, 604800 is one week in seconds, but a reader must reach for a calculator to confirm it.

This is a security problem in audits: reviewers cannot verify whether constants encode the intended values, and errors only manifest at runtime.

Fix: Name constants descriptively and annotate their units

contract ProtocolFee {
    /// @dev Fee in basis points (1 bp = 0.01%). 300 bps = 3%.
    uint256 public constant FEE_BPS = 300;

    /// @dev Denominator for basis point calculations
    uint256 public constant BPS_DENOMINATOR = 10_000;

    /// @dev Maximum tokens per position
    uint256 public constant MAX_TOKENS_PER_POSITION = 50;

    /// @dev Lock period in seconds: 7 days = 7 * 24 * 60 * 60
    uint256 public constant LOCK_PERIOD_SECONDS = 7 days; // Solidity time units

    function calculateFee(uint256 amount) external pure returns (uint256) {
        return amount * FEE_BPS / BPS_DENOMINATOR;
    }
}

Solidity's built-in time units (1 days, 1 weeks) eliminate second-count magic numbers. Name and comment together make each value self-documenting.


5. Constant That Should Be Configurable

When a protocol hardcodes operational parameters as constant, it loses the ability to respond to changing conditions. What was a safe parameter at launch may become dangerous as market dynamics shift.

// VULNERABLE: slippage tolerance that cannot adapt to market conditions
contract SwapRouter {
    // 1% max slippage, hardcoded forever
    uint256 public constant MAX_SLIPPAGE_BPS = 100;

    function swap(
        address tokenIn,
        address tokenOut,
        uint256 amountIn,
        uint256 minAmountOut
    ) external returns (uint256 amountOut) {
        amountOut = _executeSwap(tokenIn, tokenOut, amountIn);
        uint256 minAcceptable = amountIn * (BPS_DENOMINATOR - MAX_SLIPPAGE_BPS) / BPS_DENOMINATOR;
        require(amountOut >= minAmountOut, "Slippage too high");
        require(amountOut >= minAcceptable, "Exceeds max slippage");
        return amountOut;
    }
}

During high volatility — a market crash or liquidity crisis — price spreads routinely exceed 1% per block. Every swap through this router fails the MAX_SLIPPAGE_BPS check, users cannot exit positions, and the protocol grinds to a halt with no governance mechanism to loosen the parameter short of a full redeployment.

Conversely, setting the constant too loosely allows excessive value loss on every trade under normal conditions.

Fix: Use governance-updatable parameters with enforced bounds and a timelock

contract SwapRouter {
    uint256 public maxSlippageBps;

    uint256 public constant MIN_SLIPPAGE_BPS = 10;   // 0.1% floor
    uint256 public constant MAX_SLIPPAGE_BPS_CAP = 1000; // 10% ceiling
    uint256 public constant UPDATE_DELAY = 2 days;

    address public governance;
    uint256 public pendingSlippage;
    uint256 public pendingSlippageEta;

    event SlippageUpdateQueued(uint256 newValue, uint256 eta);
    event SlippageUpdated(uint256 oldValue, uint256 newValue);

    constructor(uint256 _initialSlippageBps, address _governance) {
        require(_initialSlippageBps >= MIN_SLIPPAGE_BPS, "Below floor");
        require(_initialSlippageBps <= MAX_SLIPPAGE_BPS_CAP, "Above ceiling");
        maxSlippageBps = _initialSlippageBps;
        governance = _governance;
    }

    function queueSlippageUpdate(uint256 newSlippageBps) external {
        require(msg.sender == governance, "Not governance");
        require(newSlippageBps >= MIN_SLIPPAGE_BPS, "Below floor");
        require(newSlippageBps <= MAX_SLIPPAGE_BPS_CAP, "Above ceiling");
        pendingSlippage = newSlippageBps;
        pendingSlippageEta = block.timestamp + UPDATE_DELAY;
        emit SlippageUpdateQueued(newSlippageBps, pendingSlippageEta);
    }

    function applySlippageUpdate() external {
        require(block.timestamp >= pendingSlippageEta, "Timelock active");
        emit SlippageUpdated(maxSlippageBps, pendingSlippage);
        maxSlippageBps = pendingSlippage;
    }
}

The bounds constants remain truly constant — they are invariants the protocol must never violate. The operational parameter is mutable through a governance-controlled, timelocked process.


6. Multi-Chain Deployment with Inconsistent Constructor Arguments

Protocols deploying the same contract across multiple chains often pass different constructor arguments per chain. The resulting deployed bytecodes differ — different token addresses, different thresholds, different admin addresses. Any system relying on bytecode hash equality across chains — cross-chain proofs, bridge verification, governance — will silently fail or be exploitable.

// Contract deployed on Ethereum with one set of args
// and on Arbitrum with different args — bytecodes differ
contract BridgedVault {
    address public immutable localToken;
    uint256 public immutable depositCap;

    constructor(address _token, uint256 _cap) {
        require(_token != address(0));
        localToken = _token;
        depositCap = _cap;
    }
}

If a cross-chain proof system verifies message origin by comparing codehash, it will reject valid messages from the Arbitrum instance because its initcode differed. An attacker aware of this mismatch might craft a scenario where the Ethereum deployment accepts state transitions that should only be valid on Arbitrum.

Fix: Use CREATE2 with deterministic initcode and chain-specific configuration post-deployment

// Deployer contract — same on every chain
contract VaultFactory {
    // salt is fixed — same across all chains
    bytes32 public constant DEPLOY_SALT = keccak256("BridgedVault.v1");

    // initcode is identical across all chains
    // chain-specific config is set post-deployment via initialize()
    function deployVault(address token, uint256 cap)
        external
        returns (address vault)
    {
        bytes memory initcode = type(BridgedVault).creationCode;
        // No constructor args — initcode is identical everywhere
        assembly {
            vault := create2(0, add(initcode, 0x20), mload(initcode), DEPLOY_SALT)
        }
        require(vault != address(0), "Deploy failed");
        BridgedVault(vault).initialize(token, cap);
    }
}

contract BridgedVault {
    address public localToken;
    uint256 public depositCap;
    bool private initialized;

    function initialize(address _token, uint256 _cap) external {
        require(!initialized, "Already initialized");
        require(_token != address(0), "Zero token");
        localToken = _token;
        depositCap = _cap;
        initialized = true;
    }
}

The CREATE2 address is determined by (deployer, salt, keccak256(initcode)). With a fixed salt and identical initcode, the deployed address and codehash are identical across every chain. Chain-specific configuration goes into storage via initialize(), not into bytecode via the constructor.


What ContractScan Detects

Vulnerability Detection Method Severity
Hardcoded token/protocol address Cross-reference known per-chain addresses; flag constant address literals that differ across chains High
Immutable not set in constructor Static analysis of all immutable declarations vs. constructor assignment paths, including conditional branches High
Immutable set via delegatecall context Identify proxy patterns and flag immutable usage in logic contracts with delegatecall initialization High
Magic number constants Flag unexplained numeric literals in constant declarations without NatSpec annotations Medium
Constant that should be configurable Identify operational parameters (fees, caps, timeouts) marked constant with no update path Medium
Inconsistent multi-chain constructor args Detect constructor argument patterns that encode chain-specific values, baking them into bytecode High

Scan your contracts at ContractScan to catch these issues before deployment. Deployment-time bugs are among the hardest to fix — remediation typically requires a full redeployment with state and fund migration.



Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →