DEV Community

Cover image for A .NET Dinosaur in Web3. Day 13 β€” Access Control
Alena
Alena

Posted on • Originally published at Medium

A .NET Dinosaur in Web3. Day 13 β€” Access Control

πŸ†• New Challenge. Day 1 of 7: Access Control & Vault Management

After building two MVPs around smart contracts, I wanted to go deeper into the contracts themselves. Not just use them β€” understand them.

πŸ™ˆ 7 days.
πŸ˜‰ 7 contracts.
πŸ’ͺ 7 articles.

The Problem

By default, every function in a deployed smart contract is public. Anyone on the network can call anything. If you leave administrative functions β€” like changing rates, withdrawing funds, pausing the contract β€” without protection, anyone can call them. It's how real exploits happen.

The Contract: AccessControlledVault

The concept is straightforward: one address is the owner, set at deployment. Certain functions are restricted to that address only. Final version of this contract:

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

contract AccessControlledVault {
    // Owner address
    address public owner;

    // Rate variable
    uint256 public conversionRate;

    // Custom error for gas optimization
    error NotAnOwner();

    // Event emitted when the rate changes
    event RateChanged(uint256 newRate);

    event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

    // Constructor β€” sets the deployer as the owner
    constructor() {
        owner = msg.sender;
    }

    // Modifier using custom error instead of require
    modifier onlyOwner() {
        if (msg.sender != owner) {
            revert NotAnOwner();
        }
        _;
    }

    // Apply the modifier and emit the event
    function setRate(uint256 _newRate) public onlyOwner {
        conversionRate = _newRate;
        emit RateChanged(_newRate);
    }

    function renounceOwnership() external onlyOwner {
        emit OwnershipTransferred(owner, address(0));
        owner = address(0);
    }
}
Enter fullscreen mode Exit fullscreen mode

What Actually Clicked

Some things I'd learned before, but some I'd missed or hadn't gone deeper into. Some are just reminders, some were genuinely new.

Already knew β€” nothing new:

constructor runs exactly once at deployment. msg.sender at that moment is the deployer's address. Capture it then and it's stored permanently on-chain. There's no other moment to do this cleanly.

The .NET analogy: a static initialiser that runs before anything else, with access to who triggered the class load.

New things:

modifier is a reusable wrapper. Without modifiers, every protected function needs its own require or if check. With a modifier, the check is defined once and applied by name. onlyOwner reads like a type annotation on the function β€” the intent is immediately visible.

The _; placement matters. The underscore marks where the function body executes relative to the modifier's checks. Put _; before the check, and the function runs first β€” the check happens after. In a function that transfers funds, this is a critical security vulnerability: the money moves before the authorisation check fires.

Custom errors vs require strings. revert NotAnOwner() costs less gas than require(msg.sender == owner, "Not an owner"). The string in require gets stored and returned on every failed call. A custom error is just a 4-byte selector. At scale, the difference adds up.

Modifiers

In .NET there's no direct analogy to Solidity modifiers, but there are a couple of things that can help a .NET developer understand the concept:

[Authorize] β€” a decorator that restricts access. And Middleware β€” you can inject custom access restriction logic at any point in the runtime pipeline. _; and await _next(context) are conceptually the same thing: pass control to what comes next. That's where the similarity ends.

A Solidity modifier is the logic. It wraps the function, controls entry, can control exit. It's active behaviour, not a passive label.

Solidity modifiers are compile-time macros. The compiler takes the modifier code and physically inlines it into every function that uses it, replacing _; with the function body. There is no runtime pipeline. There is no shared instance. Just flat bytecode.

This has a practical consequence: EIP-170. Ethereum limits deployed contract bytecode to 24,576 bytes. If your modifier contains complex logic and you apply it to 15 functions, that logic is duplicated 15 times in the bytecode. Contracts can fail to deploy because they're too large.

The fix: extract heavy logic into an internal function. The modifier then inlines only a function call β€” a single jump instruction β€” not the full check:

function _validateAccess() internal view {
    require(msg.sender == owner, "Not owner");
    require(isActive, "Not active");
}

modifier onlyOwner() {
    _validateAccess(); // only the jump is inlined
    _;
}
Enter fullscreen mode Exit fullscreen mode

So: middleware is the right mental model for what modifiers do. But the how is nothing like middleware β€” it's a compiler feature.

Local Node β€” No Testnet Needed

When I first started with Web3, my testing environment was... my MetaMask wallet, hunting for Sepolia ETH from faucets, and struggling with Remix IDE. Alchemy was part of the setup too.

Turns out Hardhat has a built-in local node:

npx hardhat node
Enter fullscreen mode Exit fullscreen mode

This spins up a local blockchain with 20 test accounts, each pre-funded with 1,000 ETH. No faucets, no limits β€” very useful. The network exists only while the process runs.

To deploy inside the local network:

npx hardhat ignition deploy ignition/modules/AccessControlledVault.ts --network localhost
Enter fullscreen mode Exit fullscreen mode

Then the Hardhat console lets you interact with the live contract directly:

npx hardhat console --network localhost

const { viem } = await network.create();
const vault = await viem.getContractAt("AccessControlledVault", "0x5FbDB...");

// Read owner
await vault.read.owner();

// Change rate as owner
await vault.write.setRate([420n]);
await vault.read.conversionRate(); // β†’ 420n

// Try as non-owner β€” should revert
const [owner, addr1] = await viem.getWalletClients();
const vaultAsAddr1 = await viem.getContractAt("AccessControlledVault", "0x5FbDB...", { client: { wallet: addr1 } });
await vaultAsAddr1.write.setRate([999n]); // β†’ ContractFunctionExecutionError: NotAnOwner
Enter fullscreen mode Exit fullscreen mode

The revert fires. The custom error name appears in the output. Access control works.

One More Thing: renounceOwnership

After writing the contract, an architectural question came up: what happens to the owner role at the end of the contract's life? Can ownership be revoked permanently?

It can. And in production Web3, it often should be.

In .NET systems, there's always a super-admin β€” someone who can access the database directly in an emergency. In Web3, that kind of absolute power is a red flag for users and investors. If the contract manages significant funds and the owner holds a setRate or withdraw function, two risks exist: the private key gets stolen, or the developers pull the funds themselves.

The solution: renounceOwnership(). When the project is live and all settings are locked, the owner calls this function. It permanently overwrites the owner variable with the zero address β€” address(0). No one controls the contract after that. Not even its creator.

/**
 * @notice Leaves the contract without owner.
 * @dev It will not be possible to call `onlyOwner` functions anymore.
 * Can only be called by the current owner.
 */
function renounceOwnership() external onlyOwner {
    emit OwnershipTransferred(owner, address(0));
    owner = address(0);
}
Enter fullscreen mode Exit fullscreen mode

The event is important β€” analytics services like Etherscan use it to display that the contract has no owner.

What's Next

Day 2: Escrow & Pull-over-Push Pattern.


Repo: github.com/alena-dev-soft

Follow the journey on Telegram: t.me/dotnetToWeb3

Stage: Dinosaur πŸ¦• β€” going deeper into the bedrock. Day 1 of 7.

Top comments (0)