Access control: locking specific doors, not the whole building#

← Back to Week 5 (Blockchain track, Vyper): Guided build

The problem this solves#

Anyone with the network's RPC endpoint (which, on a public testnet, is anyone at all) can attempt to call any function on AssetRegistry. The contract does not run on your machine, behind your firewall, reachable only by people you trust. It runs on a public network, and its functions are, by default, callable by anybody who sends a correctly formatted transaction.

That's a very different starting posture from a Python function you write and only you ever call. If register_holder did nothing to check who was calling it, any address on the network could register itself as an eligible holder with any balance it wanted, or overwrite someone else's eligibility flag out of spite. The contract has to defend itself, in its own code, every single time, because there is no outer layer (no login page, no firewall, no private network) doing that job for it.

msg.sender: an identity you can actually trust#

Every function call arrives with a special, built-in value: msg.sender, the address that sent the transaction (or, for an internal call, the address that made the call). This is worth pausing on, because it's not like an HTTP header a client could simply lie about. msg.sender is derived from the cryptographic signature on the transaction, verified by the network's own consensus process before your contract code ever runs. By the time your assert checks it, the network has already confirmed that whoever holds the private key for that address really did authorize this exact call. You don't have to verify the signature yourself; the platform did that for you. You only have to decide what to do with the identity it hands you.

The analogy: one shared building, some locked doors#

Think of the whole contract as a building anyone can walk into. Some rooms (the @view functions like get_holder, and any function with no access restriction) have open doors: anyone can walk in and look around, and no harm is done because nothing changes. Other rooms (register_holder, update_balance, set_eligibility) have real consequences if the wrong person walks in and starts moving furniture, so they get a locked door with exactly one key: the owner's.

In code, that lock is an assert statement checking msg.sender against the address you've designated as the owner:

owner: public(address)

@deploy
def __init__():
    self.owner = msg.sender

@external
def register_holder(holder: address, eligible: bool, initial_balance: uint256):
    assert msg.sender == self.owner, "AssetRegistry: caller is not the owner"
    # ... the rest of the function runs only after this line passes

__init__, decorated @deploy, runs exactly once, at deployment, and its msg.sender is whoever deployed the contract, that is, you, or in a real system, your backend's own deploying account. That's how owner gets set in the first place: not by a special admin panel, just by recording who happened to send the deployment transaction.

Why there's no modifier, and what that trades away#

If you've looked at the Solidity track's version of this note, you'll notice the whole first half of it is about modifier, a piece of Solidity syntax that lets you write an access check once and attach it to every function that needs it, so you never retype the same require line by hand. Vyper does not have that feature. There is no keyword for "a reusable pre-check you attach to a function's declaration." If three functions each need "only the owner may call this," you write assert msg.sender == self.owner, "AssetRegistry: caller is not the owner" as a real, separate line inside each of the three functions, word for word, every time.

This is a real, deliberate design choice on Vyper's part, not a missing feature waiting to be added. Vyper's philosophy leans toward keeping a function's entire behavior visible in its own body, with nothing invisibly injected from a decorator-like abstraction sitting somewhere else in the file. The tradeoff is honest: you type more, and you have to be disciplined about actually adding the line to every new function that needs it, since there's no single place you can attach a protection and know every user of it is automatically covered.

If a contract does end up needing the exact same check in several places and repeating it starts to feel like real duplication, Vyper's own answer isn't a modifier, it's a plain internal function:

@internal
@view
def _assert_only_owner():
    assert msg.sender == self.owner, "AssetRegistry: caller is not the owner"

called as self._assert_only_owner() at the top of each function that needs it. That's still not a modifier: it doesn't wrap around a function's execution, it doesn't have a _;-style placeholder marking "now run the actual body," it's just an ordinary function call like any other, which happens to raise if the caller is wrong. It's a smaller abstraction than Solidity's, on purpose, and AssetRegistry itself doesn't even reach for it, since three short, identical, explicit lines are arguably clearer than a level of indirection for a check this simple.

Why this specific contract needs it#

Every function in AssetRegistry that changes state (register_holder, update_balance, set_eligibility) starts with assert msg.sender == self.owner, .... Concretely, this is protecting exactly the fields the Week 8 acceptance-gate capstone will care about: eligibility and holder status. If anyone could flip their own eligible flag to True, the registry would be worthless as a source of truth. Access control isn't decoration here; it's the entire reason the registry can be trusted by anything reading it later, including your own future backend service.

A mistake worth naming now: partial protection#

A subtler version of "forgot the check entirely" is protecting some sensitive functions and missing a sibling one. Imagine a contract that correctly guards register_holder and update_balance with the owner assert, but the person writing it adds set_eligibility later, in a hurry, and forgets to add the same line. The contract looks protected at a glance (two out of three functions have the check), but it has exactly the same real-world consequence as having no access control at all for the one function that matters most to a compliance-style use case: anyone can grant or revoke their own eligibility.

The habit to build, and the one the failure-modes note leans on directly, is this: for every single function that changes state, explicitly decide and write down who is allowed to call it. Don't assume a function is safe because a similar-looking function nearby is protected. Check each one on its own. Vyper's lack of a modifier doesn't remove this risk; if anything, it removes one specific version of the mistake ("forgot to attach the modifier to this one function") while leaving the underlying one ("forgot to write the check at all") exactly as real as it was in Solidity.

What access control is not#

It's worth being precise about what assert msg.sender == self.owner, ... does and doesn't buy you. It restricts who can call specific functions. It does nothing about:

  • what happens if the owner's private key is lost or stolen (there's no recovery mechanism here; a real system might add one, like transferring ownership to a new address, but that's beyond this contract's scope),
  • whether the owner itself makes a good decision (the contract will happily let the owner register an obviously wrong balance; access control stops the wrong caller, not a wrong decision by the right caller), or
  • anything about the order in which operations happen within a single function, which is exactly what the next note, on failure modes, covers.

Access control answers "who." The next note answers "what can still go wrong even when the 'who' is correct."

TL;DR#

This page explains how a contract decides who is allowed to call which of its functions, and why Vyper makes you write that check out in full every single time instead of reaching for a shared abstraction.

  • Every function call carries msg.sender, the address that actually sent it. The network verifies this before your code ever runs, so you can trust it like a real ID check, not like a header a caller could fake.
  • Vyper has no modifier keyword at all. There is nothing to attach to a function; the check itself, assert msg.sender == self.owner, "message", is written as a plain line inside every function that needs it.
  • That assert only stops the wrong caller. It does nothing to stop the right caller (the owner) from calling a function with a careless or bad value.
  • Missing the assert on even one sensitive function is just as exploitable as having no access control at all, especially for a function added later that never got the check copied over.
owner: public(address)
limit: public(uint256)
paused: public(bool)

@deploy
def __init__():
    self.owner = msg.sender

@external
def set_limit(new_limit: uint256):
    assert msg.sender == self.owner, "Vault: not the owner"  # msg.sender is already verified by the network
    self.limit = new_limit                                   # the assert stops the wrong caller, not a careless value from the right one

@external
def pause():           # added later, in a hurry: forgot the assert entirely
    self.paused = True  # anyone at all can call this, which defeats the check above