Skip to content
Blockchain Association of Cameroon
BAC-L1-FOUND-1204 h

Smart contracts: fundamentals and limits

Solidity as a starting point, not a horizon: what a contract can do, what it will never do, and why the rest of the curriculum goes one layer down.

  • Write, test and deploy a contract on a test network
  • Identify reentrancy, overflow and broken access control
  • State what belongs to the contract and what belongs to the protocol

A smart contract is a program that the whole network executes identically and that nobody can quietly fix. This workshop teaches what a contract can do, and above all what it will never be able to do: immutability, determinism, gas, reentrancy, the oracle problem. Let us be clear from the start: this course teaches the concepts and the limits first, with neutral pseudo-code. The last lesson then shows the same registry in Solidity, with its Foundry tests and the commands to deploy on a test network, which you will practise in the independent lab (the official documentation remains the reference). The browser can neither compile nor deploy: the online exercises are readings and diagnoses. You reuse what you know about transactions, fees, signatures and interface states.

Lessons 1/4 · 20 min

What a contract is: code, state, determinism, gas

A concrete case

A cocoa cooperative in Bafoussam wants a register of lots whose rules cannot be secretly modified by a single person: who may register a lot, and a lot number can never be used twice. A smart contract is a program, with its data, stored at an address on a chain with a programmable virtual machine (Ethereum-style chains, for example). Users call it by sending transactions; every node executes the call and must arrive at the same new state.

A clarification up front: the most widespread language for these chains is Solidity, but this course does not teach its syntax. The examples below are in pseudo-code, readable without knowing any language.

contract LotRegistry:
  storage owner                     // set at creation
  storage lots: map lotId -> (holder, weightKg)

  function register(lotId, weightKg):
    require caller == owner         // access control
    require lots[lotId] does not exist
    lots[lotId] = (caller, weightKg)
    emit Registered(lotId)

require means: if the condition is false, the whole call is undone and the state stays as before. caller is the address that signed the transaction (or the contract calling it). You saw in the transactions topic that this signature is checked by the protocol; the contract decides what it allows.

Four properties that define the craft

1. Determinism. All nodes must get the same result from the same state and the same transaction. So a contract cannot draw a "truly random" number, nor call a web page (two nodes would ask at different moments and get different answers). A block timestamp exists, but it is a value set by the block producer within bounds, not a trusted clock.

2. Gas. Each operation costs a number of units of gas, and execution is metered: the sender sets a gas limit and a price per unit, and the fee paid is gas consumed × price (as in the fees workshop). If the limit is reached before the end, execution is undone, but the work performed is still paid for. This keeps infinite loops from blocking the network.

Example with a fictitious schedule: writing a new storage slot costs 20,000 units, reading a slot 200, and each transaction has a base cost of 21,000. The register call reads two slots (owner and the lot's existence) and writes one: 21,000 + 200 + 200 + 20,000 = 41,400 units (in this fictitious schedule the pair (holder, weightKg) fits in a single slot and the emit event is ignored; with two separate writes the total would be 61,400). With a limit of 40,000, the call runs out of gas: everything is undone, and the 40,000 units are paid anyway. With a limit of 45,000 it passes and only the gas actually consumed (41,400) is billed. Real schedules differ; the idea stays.

Design consequence: a function that loops over a list that grows with users eventually exceeds a block's gas limit and becomes unusable. Avoid unbounded loops.

3. Publicity. A contract's state is readable by everyone. A language's "private" keywords only stop other contracts from accessing it simply; anyone reading the storage sees the values. No secret in a contract.

4. Immutability. Once deployed, a contract's code cannot be modified in the current scheme. A bug there is permanent. One can plan a "proxy" contract that redirects to a new version, but then whoever holds the right to switch version holds an enormous power, and your users must trust them. Immutable is an advantage (the rules do not move) and a risk (neither do the errors).

What this does NOT guarantee

  • The contract guarantees that its rules are applied, not that the facts are true: if the owner records "500 kg" for a lot that weighs 50, the chain faithfully preserves a lie.
  • A contract never runs by itself: a transaction must call it. It cannot "act at midnight".
  • Deployed does not mean correct.

Recap: code and state at an address, deterministic result, execution metered in gas, public state, frozen code.

Lessons 2/4 · 25 min

Three classic flaws: access, overflow, reentrancy

Why specific flaws

Because code is public, immutable and handles value, a defect is found and exploited by strangers, often within hours, with no possible fix afterwards. Three families come up again and again (see also the OWASP Smart Contract Top 10).

1. Broken access control

Every function that modifies state must answer the question: who may call it? A faulty example:

function setOwner(newOwner):
  owner = newOwner          // no check on caller

Anyone becomes the owner. The fix is a single line (require caller == owner), but you must remember to add it. Rule: deny by default, allow explicitly, and test every sensitive function from an unauthorised account.

2. Integer overflow

A contract's integers have a fixed size. Illustration on 8 bits (values 0 to 255):

  • 250 + 10 = 260, which does not fit: the result "wraps" to 260 − 256 = 4.
  • 3 − 5 = −2, impossible without a sign: the result becomes 256 − 2 = 254.

A balance of 3 that spends 5 and ends at 254 is a catastrophe. In real contracts the integers are much larger (256 bits on Ethereum-style machines), but the principle is the same. In Solidity, since version 0.8, arithmetic cancels the call on overflow by default; before that it wrapped silently, and today the check can be turned off in a block marked unchecked. Overflow therefore remains possible in old code or deliberately unchecked code.

3. Reentrancy

This is the most famous flaw, to be understood as a concept. A contract that sends value to an address that is itself a contract hands over control: the recipient address's code runs before the starting function has finished. If that code calls the same function again, it re-enters it.

A faulty vault in pseudo-code:

function withdraw():
  amount = balances[caller]
  require amount > 0
  send(caller, amount)       // (1) hands control to the caller's code
  balances[caller] = 0       // (2) too late

A worked example. The vault holds 10 coins: 9 belong to other users, 1 to the attacker, whose recorded balance is 1. The attacker calls withdraw.

LevelBalance readSentCoins left in the vault
1119
2 (called again by the attacker)1, since (2) has not happened18
…11…
10110

The attacker received 10 coins for one deposited: 9 coins stolen from others. In reality the number of re-entries is bounded by gas and call depth, but the idea holds. The 2016 incident of The DAO is the most cited example of this pattern.

Two remedies fix the flaw, often combined, and a third habit is useful without fixing it alone:

  1. Checks, effects, interactions: update the state before the external call. Here, put balances[caller] = 0 before send (in pseudo-code, if the send fails the whole call is undone; in Solidity, a send by call returns a boolean and you must write require(ok) to get that cancellation).
  2. Lock: a flag set to true on entering the function and refused if already true.
  3. Pull payment: the contract records what is owed, each party comes to withdraw it, and nothing is ever sent in bulk. It is not a remedy for reentrancy: the faulty vault above is already a pull withdrawal. The pull model mainly keeps a failing recipient from blocking everyone else; the withdrawal function itself must update state before sending (remedy 1) or be locked (remedy 2).
function withdraw():
  amount = balances[caller]
  require amount > 0
  balances[caller] = 0       // effect first
  send(caller, amount)       // interaction after

Reentrancy depends on the virtual machine: some platforms limit it by design. The general principle (an external call can hand control back before your state is consistent) remains a good reflex.

What this does NOT guarantee

  • Knowing these three flaws does not make you an auditor: there are many others.
  • A passing test does not prove the absence of a flaw; only a test written against the attack demonstrates or refutes it.
  • A patch applied in the wrong place can seem to work: for example, limiting the gas given to the caller is not a reliable protection, since costs change from one protocol to another.

Recap: who may call? ; integer size is finite ; state first, external call after.

Lessons 3/4 · 20 min

Contract, protocol, and the oracle problem

Who guarantees what?

When something goes wrong, the first question is: is it the contract's matter, the protocol's, or neither's? Confusing them leads to false promises.

LayerWhat it guaranteesExamples
Protocol (the chain's rules, applied by every node)block validity, signature checks, nonce order, gas accounting, consensus, finality, the definition of the virtual machinea badly signed transaction is rejected before reaching any contract
Contract (code you deploy)the rules of its own statewho may register a lot, when to release a payment
Neitherreal-world facts, privacy, people's identity, legal valuethe actual weight of a lot of cocoa, whether "Mrs X" really is Mrs X

Frequent mistaken expectations:

  • Expecting a contract to cancel an already finalised transaction: that is a protocol (or governance) decision, not code.
  • Expecting it to trigger itself on a date: a transaction is needed (from a human or from a service called a "keeper").
  • Expecting it to read another chain or a web page: it has no way to without an intermediary.
  • Expecting it to keep a secret: state is public.
  • Expecting a fair ordering of transactions: the block producer chooses the order, hence the risk that someone who sees a pending transaction jumps ahead (often grouped under the acronym MEV, for maximal extractable value). This is a property of the protocol and the network.

The oracle problem

A contract sees only the chain. To react to an outside fact (the price of cocoa, the weight read by a scale, the weather), someone must write it onto the chain: that intermediary is an oracle. The problem: the chain checks that the data was written by the right address, not that it is true. The contract is therefore no more reliable than its oracle.

A worked example with three independent reporters of the price of a kilo (fictitious units): 100, 102 and 160. The mean is (100 + 102 + 160) / 3 ≈ 120.67: one faulty or corrupted reporter pulls the result. The median (the middle value once sorted) is 102: an outlier has no influence. With n reporters, the median withstands fewer than half faulty reporters: 1 of 3, 2 of 5. If 2 of the 3 are corrupted, the median is wrong too.

Other mitigations: require several independent sources, bound the changes accepted per hour, introduce a delay before acting, require a stake that the reporter loses on fault. They reduce risk; none removes the trust in the original data.

Applying this to our registry

The lot registry proves that a record was not modified afterwards and that it was made by an authorised address. It does not prove that the bag really holds 500 kg. If a sensor or an agent enters the weight, that is the oracle, and off-chain controls are needed: contradictory weighings, several agents, audit. Honest traceability says: "the chain fixes what was declared, and by whom".

What this does NOT guarantee

  • A decentralised oracle is not automatically honest: several reporters can collude or read the same faulty source.
  • The price on a very thin market can be manipulated for a few moments; taking it as truth is a classic attack.
  • Separating the layers is not an excuse: it is what lets you promise only what you can keep.

Recap: the protocol guarantees the mechanics, the contract guarantees its rules, nobody guarantees outside facts without trusting an oracle.

Lessons 4/4 · 20 min

Writing, testing, deploying on a test network

What the browser does not do, and what you will do elsewhere

This workshop's objectives include writing, testing and deploying a contract on a test network. These three acts require a compiler and a node, which do not run in this page. This lesson gives you the method; the independent lab has you practise with Solidity (official documentation) and Foundry (Foundry Book). Note your tools' versions in the README: they change, and this course fixes none. To make the method concrete, the lesson shows the same registry in Solidity, a Foundry test file and deployment commands. These are outlines written from memory and not run here: check them against the official documentation and the version installed before relying on them.

Write small

A short contract is easier to review, test and audit. Principles: little state, few functions, one rule per function, clear names, events (emit) for every important change so applications can follow them. Reusing proven code beats reinventing, provided you know what you import.

The same registry in Solidity

Here is the lesson 1 pseudo-code translated into Solidity (outline). The correspondences: require becomes require(condition, "message"), caller becomes msg.sender, and the owner is set in the constructor, code that runs exactly once, at deployment.

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

contract LotRegistry {
    address public owner;
    mapping(uint256 => address) public holder;
    mapping(uint256 => uint256) public weightKg;

    event Registered(uint256 indexed lotId, uint256 weight);

    constructor() {
        owner = msg.sender;           // the deploying address becomes the owner
    }

    function register(uint256 lotId, uint256 weight) external {
        require(msg.sender == owner, "not owner");
        require(weight > 0, "weight is zero");
        require(holder[lotId] == address(0), "lot exists");
        holder[lotId] = msg.sender;
        weightKg[lotId] = weight;
        emit Registered(lotId, weight);
    }
}

Two details to remember. First, owner = msg.sender in the constructor means the owner is the account that sends the deployment transaction: using the wrong key at deployment means the wrong owner, permanently. Second, the pseudo-code stored a pair in one slot; here two separate tables make the code simpler to read, at the price of one more write (which changes the gas cost, see lesson 1).

Test first what must fail

A test is a small program that calls your contract and checks the result. For the lot registry, the minimal list:

CaseExpected result
the owner registers lot 7 (500 kg)succeeds, the event is emitted, the lot is readable
a non-owner account registers lot 8fails (cancelled)
the owner registers lot 7 twicethe second time fails
weight 0 or enormousrefused or accepted according to the written rule, but decided
reading an absent lotthe planned answer, no invented state

Add property tests: for example "whatever the calls, a registered lot never changes weight". Fuzzing tools generate many random inputs to look for a violation. For a known flaw (reentrancy), first write a test that plays the attack: it must fail on the faulty code and pass on the fixed code. That is the proof of the fix.

With Foundry, tests are written in Solidity, in a contract that inherits from Test. Outline for the first three rows of the table:

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

import {Test} from "forge-std/Test.sol";
import {LotRegistry} from "../src/LotRegistry.sol";

contract LotRegistryTest is Test {
    LotRegistry registry;
    address other = address(0xBEEF);

    function setUp() public {
        registry = new LotRegistry();     // the test deploys, so it is the owner
    }

    function test_OwnerRegisters() public {
        registry.register(7, 500);
        assertEq(registry.weightKg(7), 500);
    }

    function test_NonOwnerReverts() public {
        vm.prank(other);                  // the next call comes from "other"
        vm.expectRevert("not owner");     // we expect this precise cancellation
        registry.register(8, 100);
    }

    function test_DuplicateReverts() public {
        registry.register(7, 500);
        vm.expectRevert("lot exists");
        registry.register(7, 600);
    }
}

vm.prank and vm.expectRevert are Foundry "cheatcodes": the first makes the next call come from another address, the second requires that call to be cancelled with the given message. Tests run with forge test. An access test that checks the refusal is worth as much as the one that checks success.

Deploy on a test network

A test network (testnet) is a chain that imitates the real one, whose tokens have no value and are obtained free from a faucet. On Ethereum-style chains, deploying is a special transaction: with no recipient, carrying the compiled code; the chain then assigns an address to the contract. It costs gas, growing with the code size. The new contract's address appears in the deployment transaction's receipt.

With Foundry, deployment is done with a command along these lines (outline: options change by version, check with forge create --help):

cast wallet import testdeployer --interactive      # stores an encrypted TEST key (keystore)
forge create src/LotRegistry.sol:LotRegistry \
  --rpc-url <TESTNET-RPC-URL> --account testdeployer --broadcast

The encrypted keystore keeps the key out of terminal history and environment variables, which is better than passing it in clear on the command line. For a reproducible deployment you rather write a versioned script (forge script) that does the same job. After deployment, two simple checks: read the contract's owner() and verify it is the intended address; call register from that account.

Habits to adopt:

  1. Rehearse the deployment on a testnet before any other network, with a versioned, reproducible script.
  2. Use a deployment key reserved for testing, never in the repository (see the Git workshop) nor in the image (see the Docker workshop).
  3. Check the constructor parameters (notably the owner address) before sending.
  4. Publish the source code on the network's explorer so everyone can compare.
  5. For a contract carrying real value: peer review, external audit, admin rights held by a multi-signer mechanism, monitoring, an incident plan.

The limits of assurance

  • Tests show the presence of defects, never their absence.
  • An audit is sampling done by humans in limited time.
  • Formal verification proves properties against a specification; if the specification is wrong, the proof does not protect.
  • A pause function or an emergency key can save funds, but it gives its holder a power your users must accept. Decide and say so.

Why the rest of the curriculum goes one layer down

What you have just seen (gas, determinism, immutability, dependence on oracles) explains why some projects choose to build their own chain or protocol module rather than a contract: more control over the rules, at the price of more responsibility. You will meet these choices again in the following workshops.

What this does NOT guarantee

  • A successful testnet deployment does not imply it will succeed on the main network: parameters, fees and addresses differ.
  • A contract verified on the explorer is readable, not thereby safe.
  • The case list above is a minimum, not a guarantee of coverage.

Recap: small, tested against failure, rehearsed on testnet, with a test key, and aware of the limits of assurance.

Guided lab

The browser neither compiles nor deploys a contract: these steps therefore train reading and reasoning on pseudo-code, which is an auditor's first act. Each excerpt is short. Writing, testing and deploying on a test network with Foundry (outlines in the last lesson) happen in the independent lab. These excerpts stay in pseudo-code: look for no Solidity syntax in the statements.

A browser is no substitute for a real terminal: this lab trains reading and reasoning; the real hands-on practice is in the independent lab.

A certified account is required

Graded activities record results in your name and are the basis for a credential. They therefore need a complete certified file and a current consent. Filling in the file does not verify your identity; the Secretariat can verify it afterwards ("Verified" badge).

  1. 1.Spot the broken access control

    Here is a small shared-pot contract (pseudo-code).

    contract Pot:
      storage owner
      storage price
    
      function setPrice(newPrice):
        price = newPrice
    
      function withdrawAll():
        require caller == owner
        send(owner, balanceOfContract)
    
      function setOwner(newOwner):
        require caller == owner
        owner = newOwner

    Answer the question in the answer grid: which function is exposed to a call by anyone, and why is it serious?

  2. 2.Predict an overflow

    An old contract stores balances on 8 bits (values 0 to 255) and does not check overflow.

    balances[alice] = 3
    function spend(amount):
      balances[caller] = balances[caller] - amount     // no check
    
    alice calls spend(5)

    Answer the question in the answer grid: what is Alice's balance after the call? (A number.)

  3. 3.Trace a reentrancy

    Take the vault from lesson 2, in its faulty version (send BEFORE zeroing the balance). The vault holds 10 coins: 9 belong to other users, 1 to the attacker (recorded balance: 1). The attacker's code, run on each receipt of coins, calls withdraw again as long as the vault is not empty. Assume gas and call depth do not limit the loop.

    Answer the question in the answer grid: how many coins belonging to other users did the attacker take? (A number.)

  4. 4.Contract, protocol or neither

    The cooperative proposes four promises for its lot registry. Only one truly belongs to the contract you write, without depending on a protocol decision or an outside fact.

    Read the grid and choose the promise a contract can keep on its own. Think about what the contract sees (its own state and the transactions sent to it), what the protocol decides for all nodes, and what nobody can verify from the chain.

Security exercise — Exploit then fix a reentrancy

The independent lab has you exploit a reentrancy in a real vulnerable contract on your machine. Here, you reason about the same pattern, in pseudo-code, before coding.

contract Vault:
  storage balances: map address -> amount

  function deposit():
    balances[caller] += amountSent

  function withdraw():
    amount = balances[caller]
    require amount > 0
    send(caller, amount)         // (1) the caller's code runs here
    balances[caller] = 0         // (2)

An attacker deposits 1 coin then calls withdraw from a contract whose code, on each receipt of coins, calls withdraw again. The vault holds 10 coins in total.

Four fixes are proposed in review:

  • A. Swap (1) and (2): zero the balance, then send; if the send fails, the whole call is undone.
  • B. Add, before the send, a check that the amount is at least 1.
  • C. Add a lock: refuse the call if a flag is already raised, raise the flag on entering withdraw, lower it on leaving.
  • D. Give very little gas to the caller's code, so it has no time to call withdraw again.

A fix is valid only if an attack replayed in a test fails after the correction.

Answer-grid question: which fixes reliably fix the flaw?

Workshop quiz

Eight questions drawn from a larger pool, 70% to pass. It opens once every lab checkpoint is passed. You can retake it: the draw changes.

A certified account is required

Graded activities record results in your name and are the basis for a credential. They therefore need a complete certified file and a current consent. Filling in the file does not verify your identity; the Secretariat can verify it afterwards ("Verified" badge).

Independent lab (outside the site)

Not graded online: do it on your own machine and publish it to your Git repository.

Do this on your own machine, outside the site. Not graded online; the final assessment is a tested contract plus a cross-audit report, marked on the reproducibility of the flaws described.

  1. Install Solidity and Foundry following their official documentation (Solidity, Foundry Book). Note in your README the versions actually installed.
  2. Write a simple traceability registry (owner, lot registration, read, event) like the one in the course, with a test suite covering: success, call by a non-owner, duplicate, boundary values, event emitted.
  3. Use only a test account and tokens obtained from a test-network faucet. Never put a key in the repository: preferably an encrypted keystore (cast wallet import), otherwise an environment variable for a throw-away key.
  4. Deploy on a test network with a reproducible script, publish the code on the network's explorer, then call the registration function with a transaction. Note the contract address and the transaction hash in the README.
  5. Exploitation, on your own local or test deployment only, never on someone else's contract or deployment: take the vulnerable vault below (deliberately faulty, never deploy it on a main network), write an attacker contract and a test that exploit the reentrancy, then fix the vault and show that the same test fails after the correction.
contract VulnerableVault {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "nothing to withdraw");
        (bool ok, ) = msg.sender.call{value: amount}("");   // (1) hands control to the caller
        require(ok, "send failed");
        balances[msg.sender] = 0;                           // (2) too late
    }
}

Hint: the attacker is a contract whose receive() function calls withdraw() again while the vault holds funds. The instructor may supply their own version.

  1. Audit: swap your contract with a peer. For each flaw you think you find, write a test that reproduces it. A report without a reproducible test is not accepted.
  2. In your report, separate what the contract guarantees, what the protocol guarantees, and what depends on an oracle or a person.

Takeaways

  • A contract is code and state at an address, executed deterministically by every node, metered in gas, public and, in principle, immutable.
  • A contract guarantees its rules, not facts: the chain faithfully fixes what was declared, true or false.
  • Three flaws to recognise: unchecked access, integer overflow, reentrancy (update state before the external call).
  • The protocol guarantees the mechanics (signatures, consensus, finality); a contract can neither trigger itself, nor read the outside world, nor keep a secret.
  • Test first what must fail, rehearse on a test network with a test key, and remember that tests and audits never prove the absence of flaws.

Common misconceptions

  • Misconception : A deployed contract is "safe by construction" since the code is public and nobody can modify it.

    In fact : Public and immutable means a defect is visible to all and permanent. Safety comes from tests, review and design, not from the mere fact of deploying.

  • Misconception : A contract can call a weather or price API when it needs one.

    In fact : Nodes would get different answers and diverge. A third party (an oracle) must write the data onto the chain, and the contract then depends on it.

  • Misconception : Reentrancy is fixed by limiting the gas given to the caller.

    In fact : Costs change from one protocol to another. The reliable fix is to update state before the external call, or to use a lock, and to prove it with an attack test.

Glossary

Smart contract
Program and its data, stored at an address on a programmable chain, executed by every node when a transaction calls it.
Gas
Unit measuring execution work; the fee paid is the gas consumed multiplied by the price per unit.
Reentrancy
Flaw where an external call hands control to code that calls the function again before the state has been updated.
Oracle
Intermediary that writes an outside-world datum onto the chain; the contract is no more reliable than it.
Test network (testnet)
Chain imitating the main network, whose valueless tokens are obtained free; used to rehearse a deployment.