Reference · Foundations

Solidity: State, Globals & Functions

The one move: when reading any contract, sort every identifier into one of three buckets — persistent state (storage), a transaction-context global (EVM-injected, ephemeral), or a function. Most confusion about Solidity is just mis-bucketing a name.

The worked example

contract ShareRegistry {
    // STATE — persistent on-chain storage, this contract only
    mapping(address => uint256) public balances;
    address public transferAgent;   // declared; gates nothing yet

    // FUNCTION — writes state, costs gas, can revert
    function transfer(address to, uint256 amount) public {
        require(balances[msg.sender] >= amount, "insufficient");
        balances[msg.sender] -= amount;   // msg.sender = GLOBAL
        balances[to]          += amount;
    }

    // FUNCTION — view: reads only, free off-chain
    function balanceOf(address who) public view returns (uint256) {
        return balances[who];
    }
}

This is a minimal ledger / share register — the core of every token (ERC-20, ERC-3643, Gateway's ERC20F). The balances mapping is the on-chain cap table.

State vs. global — the distinction that matters

State variableContext global
Examplesbalances, transferAgentmsg.sender, msg.value, block.timestamp, tx.origin
What it isDeclared by the contractInjected by the EVM per call
LifetimePermanent, on-chainEphemeral — this call only
Writable?Yes — costs gasNo — read-only
ScopeThis contract instance (NOT chain-wide)Ambient call context
TS analogyInstance field persisted to a DBreq.user / thread-local

"Global" ≠ chain-wide. A state variable is global only within its contract; another contract's balances is a separate table. The genuinely ambient things are the EVM context globals.

Keyword quick-reference

The transfer-agent hook (why it's there)

transferAgent is declared but used by nothing in the snippet — a placeholder for the accountable party. In a regulated token it becomes load-bearing:

modifier onlyAgent() { require(msg.sender == transferAgent, "not agent"); _; }
function forceTransfer(address from, address to, uint256 amt) public onlyAgent { ... }
function freeze(address who) public onlyAgent { ... }

This encodes Marketnode's transfer-agent role / ERC-3643's agent: the party who can force-transfer, freeze, mint and burn. See ERC-3643 architecture.

Interview probes. (1) "transferAgent is declared but unused — what's missing?" → access-control modifiers + privileged functions. (2) "Difference between msg.sender and tx.origin?" → immediate caller vs. original signing EOA; using tx.origin for auth is a classic vuln. See Lesson 6.

The tx.origin phishing question (worked)

Setup: a treasury guards withdrawals with require(tx.origin == owner). The owner is tricked into calling claimReward() on an attacker's contract.

function withdraw(address to, uint256 amt) public {
    require(tx.origin == owner, "not owner");   // BUG
    payable(to).transfer(amt);
}

Attack: claimReward() calls treasury.withdraw(attacker, balance). Inside withdraw, msg.sender = the attacker's contract, but tx.origin = the owner (who signed the outer tx) → the check passes → drained.

Fix: require(msg.sender == owner) — now the check sees the attacker's contract and reverts. Rule: authorise with msg.sender; tx.origin is for "which human started this tx", never for auth, because every contract you call inherits it. Same principle protects Gateway's agent-gated functions.

Call pathsigned bymsg.sendertx.origin
EOA → treasuryEOAEOAEOA
EOA → attacker → treasuryEOA onlyattackerEOA