DEV Community

Cover image for A .NET Dinosaur in Web3. Day 19 - Upgradeable Contracts
Olena
Olena

Posted on • Originally published at Medium

A .NET Dinosaur in Web3. Day 19 - Upgradeable Contracts

🏁 Day 7 of 7: Proxy Patterns, EVM Assembly, and Hot-Swapping Logic in Production

Day 7 was the one I didn't expect to enjoy this much.

Assembly code. Low-level EVM opcodes. A pattern that lets you upgrade a deployed smart contract without changing its address, without migrating data, and without breaking a single integration. All in one session.

βœ… The 7-day challenge is officially done. Here's how it ended.

The Problem: Immutability Is Not Always a Feature

By default, every smart contract on Ethereum is immutable. Once deployed, the bytecode cannot be changed. If you find a critical bug six months later β€” or the business logic needs to change β€” you can't patch it. You can't roll out a new version to the same address.

In .NET, this is solved trivially: deploy a new DLL, update a config, restart the service. On a blockchain, there's no restart. There's no config file. There's only the bytecode sitting permanently at an address.

The Proxy Pattern solves this by splitting one logical contract into two physical ones.

The Proxy Pattern: Splitting State From Logic

Proxy Contract β€” holds the state. All balances, mappings, variables. Users always interact with this address. It never changes.

Implementation Contract β€” holds the logic. Just functions, no state of its own. Stateless by design.

When a user calls a function on the Proxy, the Proxy uses delegatecall to execute code from the Implementation β€” but in the context of the Proxy's own storage. The logic is borrowed. The memory is the Proxy's.

The .NET analogy: AssemblyLoadContext β€” dynamically loading an external DLL into the host process. The code is external, but the runtime memory and local state belong to the host.

To upgrade: deploy a new Implementation, call upgradeTo(newAddress) on the Proxy. Same address for users. New logic underneath.

The Critical Trap: Storage Slot Collisions

In Solidity, state variables are stored sequentially in 32-byte slots starting from Slot 0.

address public implementation; // Slot 0
address public admin;          // Slot 1
uint256 public storedValue;    // Slot 2

Enter fullscreen mode Exit fullscreen mode

If V2 adds a new variable at the top of the list, it shifts everything down:

uint256 public newCounter;     // Slot 0 ← COLLISION
address public implementation; // Slot 1 ← was Slot 0
address public admin;          // Slot 2 ← was Slot 1

Enter fullscreen mode Exit fullscreen mode

The Proxy's storage hasn't changed. But V2's bytecode now reads admin from Slot 2 β€” which in the Proxy holds storedValue. The owner address is silently overwritten. The contract is permanently broken.

The rule: new variables can only be appended at the end. Never insert, never reorder.

The Contracts

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

contract MiniProxy {
    address public implementation; // Slot 0
    address public admin;          // Slot 1

    constructor(address _implementation) {
        implementation = _implementation;
        admin = msg.sender;
    }

    function upgradeTo(address _newImplementation) external {
        require(msg.sender == admin, "Only admin");
        implementation = _newImplementation;
    }

    fallback() external payable {
        address _impl = implementation;
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), _impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }

    receive() external payable {}
}

contract VaultV1 {
    address public implementation; // Slot 0 β€” mirrors Proxy
    address public admin;          // Slot 1 β€” mirrors Proxy
    uint256 public storedValue;    // Slot 2

    function setValue(uint256 _newValue) external {
        storedValue = _newValue;
    }
}

contract VaultV2 {
    address public implementation; // Slot 0 β€” unchanged
    address public admin;          // Slot 1 β€” unchanged
    uint256 public storedValue;    // Slot 2 β€” unchanged
    uint256 public transactionCount; // Slot 3 β€” new, appended at the end

    function setValue(uint256 _newValue) external {
        storedValue = _newValue;
        transactionCount += 1;
    }
}

Enter fullscreen mode Exit fullscreen mode

The fallback function is the heart of the proxy. Every call that doesn't match a function in MiniProxy lands here. The assembly block copies the calldata, fires delegatecall to the implementation, copies the return data, and routes based on success or failure.

This is raw EVM. No abstractions. No safety net. Exactly what you want when you need to understand what's actually happening.

Console: Hot Upgrade in Action

npx hardhat ignition deploy ignition/modules/UpgradeableVault.ts --network localhost --reset
npx hardhat console --network localhost

Enter fullscreen mode Exit fullscreen mode
const { viem } = await network.create();
const cViem = require("viem");

const proxyAddress = "0x9A676e781A523b5d0C0e43731313A708CB607508";
const v2Address = "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82";

const proxy = await viem.getContractAt("MiniProxy", proxyAddress);

// Step 1: use V1 interface on the Proxy address
const vaultAsV1 = await viem.getContractAt("VaultV1", proxyAddress);
await vaultAsV1.write.setValue([42n]);
console.log("V1 stored value:", await vaultAsV1.read.storedValue()); // 42n

// Step 2: upgrade
await proxy.write.upgradeTo([v2Address]);
console.log("Upgrade successful.");

// Step 3: use V2 interface on the SAME Proxy address
const vaultAsV2 = await viem.getContractAt("VaultV2", proxyAddress);
console.log("Value preserved after upgrade:", await vaultAsV2.read.storedValue()); // 42n βœ…

// Step 4: call V2 logic
await vaultAsV2.write.setValue([100n]);
console.log("New stored value:", await vaultAsV2.read.storedValue()); // 100n
console.log("Transaction count:", await vaultAsV2.read.transactionCount()); // 1n

Enter fullscreen mode Exit fullscreen mode

The output:

  • 42n survived the upgrade without collision
  • transactionCount initialised at zero and incremented correctly
  • The Proxy address never changed

A hot upgrade of production logic with zero downtime and zero data migration.

7 Days. 7 Contracts. What Actually Happened.

Day Contract Core concept
1 AccessControlledVault constructor, modifier, custom errors, renounceOwnership
2 SimpleEscrow Pull-over-Push, Bulkhead pattern, CEI, death of .transfer()
3 SimpleVotingDAO O(1) storage design, Gas Limit DoS, block.timestamp in PoS
4 MyToken & ICO ERC-20 standard, fixed-point math, approve race condition
5 SimpleStaking Lazy Update pattern, time travel testing, IERC20 vs ERC20
6 SimpleAMM xΒ·y=k invariant, slippage, internal reserves
7 UpgradeableVault delegatecall, Proxy pattern, storage slot collisions

What transferred directly from .NET: transaction thinking, CEI as a guard clause pattern, interface segregation, defensive architecture, separation of concerns.

What was genuinely new: gas as a design constraint, immutability as a default, price as an emergent property of math, and the fact that a 7-line assembly block can replace an entire upgrade infrastructure.

What's Next

The challenge covered maybe 5% of what's possible in Solidity. The plan now: take WishList Chain and Smart Money Tracker from MVP to v1, and go deeper into security β€” reentrancy patterns, audit techniques, real attack vectors.

✨ If you have ideas for the next challenge β€” drop them in the comments. πŸ‘‡


Repo: github.com/alena-dev-soft

Follow the journey on Telegram: t.me/dotnetToWeb3

Stage: Hybrid πŸ¦•βš‘ β€” understands both worlds. Challenge complete.

Top comments (0)