The escrow problem
Ravi, in Bangalore, wants to hire Meera, a freelance developer in Pune, to build a landing page for ₹40,000. Neither has worked with the other before. If Ravi pays upfront, Meera might vanish. If Meera builds first and asks to be paid after, Ravi might vanish. This is not a hypothetical — it is the reason freelance platforms, real-estate brokers, and even UPI itself exist: someone has to sit in the middle and hold the money until both sides have done their part.
UPI solves this by trusting NPCI and the banks behind it. When Ravi pays Meera via UPI, the banks debit one account and credit the other in a settlement system both parties implicitly trust because it is regulated, audited, and backed by the RBI. That trust is centralized: it lives in an institution, not in anything either party can inspect line by line.
A smart contract solves the same problem differently. Instead of a bank holding the ₹40,000, a small program holds it. The program's rules — when to release the money, when to refund it — are visible to both Ravi and Meera before either of them commits a rupee, and every computer on the network that runs the program is forced to enforce those rules identically. The trust moves from an institution to a piece of code and the network that runs it. That shift, not any notion of artificial intelligence, is what "smart" means here.
What a smart contract actually is, formally
You've already seen that a blockchain is a chain of blocks linked by hashes, and that nodes reach consensus on a single valid chain. A smart contract sits on top of that machinery. Formally, Ethereum (the blockchain this chapter uses for examples) maintains two kinds of accounts. An Externally Owned Account (EOA) is controlled by a private key — this is Ravi's wallet. A contract account has no private key at all; instead it holds two things: immutable bytecode (the compiled program) and mutable storage — a persistent key-value store, addressed by 256-bit slot numbers, that survives between transactions the way a row in a database survives between queries.
A smart contract only does anything when an EOA (or another contract) sends it a transaction naming a function to call and any arguments. Every full node on the network takes that transaction, loads the contract's bytecode, runs it against the contract's current storage inside the Ethereum Virtual Machine (EVM), and computes a new storage state. Because the bytecode is deterministic — same input state plus same transaction always yields the same output state — every honest node computes the identical result independently. That agreement, replicated thousands of times over, is what lets the network treat the outcome as final without any single node being trusted specially.
Building the escrow contract
Here is a minimal version of the contract Ravi and Meera could actually use, written in Solidity, the dominant language for Ethereum contracts:
pragma solidity ^0.8.19;
contract Escrow {
address public buyer;
address public seller;
uint public amount;
uint public deadline;
enum State { Created, Funded, Released, Refunded }
State public state;
constructor(address _seller, uint _deadline) {
buyer = msg.sender;
seller = _seller;
deadline = _deadline;
state = State.Created;
}
function deposit() external payable {
require(state == State.Created, "already funded");
require(msg.sender == buyer, "only buyer");
amount = msg.value;
state = State.Funded;
}
function releaseToSeller() external {
require(state == State.Funded, "not funded");
require(msg.sender == buyer, "only buyer");
state = State.Released; // effect first
payable(seller).transfer(amount); // interaction last
}
function refundIfExpired() external {
require(state == State.Funded, "not funded");
require(block.timestamp > deadline, "not expired");
state = State.Refunded;
payable(buyer).transfer(amount);
}
}
Trace it the way Ravi and Meera would actually use it. Ravi deploys the contract, naming Meera's address as _seller and a deadline seven days out; the constructor runs once, setting buyer = Ravi and state = Created. Ravi then calls deposit(), attaching ₹40,000 worth of ETH (about 0.2 ETH at an illustrative rate of ₹2,00,000/ETH) as msg.value. Both require checks pass — the contract is freshly created and the caller is the buyer — so amount is set to the deposited value and state flips to Funded. The ETH now sits in the contract's own balance, not Ravi's and not Meera's. When Meera delivers the site and Ravi is satisfied, he calls releaseToSeller(): the checks pass again, state becomes Released, and the contract transfers the held ETH to Meera. If Ravi instead disappears or stalls past the deadline, anyone can call refundIfExpired() and the funds return to Ravi automatically — no dispute process required, because the rule was fixed before either party ever committed money.
How execution actually happens
The diagram below shows both halves of the mechanism together: the pipeline that gets a transaction from Ravi's wallet into a state change on the ledger, and the finite-state machine that the escrow contract itself walks through as functions are called.
Gas: computation with a price tag
Running a program on thousands of independent machines costs real electricity and real time, and a malicious or buggy contract could otherwise loop forever, stalling every node that tries to validate it. Ethereum's answer is gas: every EVM operation — arithmetic, a storage write, a call to another contract — has a fixed gas cost, the sender specifies a gas price they're willing to pay per unit, and the transaction is aborted (with state changes reverted) if it runs out of gas before finishing. This is a resource-metering model, conceptually similar to bounding an algorithm's running time, except the "clock" here has a real rupee cost attached to every tick.
Work out what Ravi's deposit() call actually costs, using simplified but representative unit costs (the exact schedule has shifted across several Ethereum protocol upgrades, so treat these as illustrative order-of-magnitude figures rather than today's live numbers):
Gas ledger for deposit()
1. Base transaction cost 21,000 gas
2. SSTORE: amount (zero → non-zero slot) 20,000 gas
3. SSTORE: state (Created → Funded) 20,000 gas
4. require() checks + arithmetic 50 gas
---------------------------------------------------------
Total 61,050 gas ≈ 61,000 gas
Cost in ETH = 61,000 gas × 20 gwei/gas
= 61,000 × 0.00000002 ETH
= 0.00122 ETH
Cost in ₹ = 0.00122 ETH × ₹2,00,000/ETH (illustrative rate)
= ₹244 (approx.)
Check that arithmetic a second way: 61,000 gas at 20 gwei is 1,220,000 gwei, and since 1 ETH = 1,000,000,000 gwei, that's 1,220,000 ÷ 1,000,000,000 = 0.00122 ETH — matching the first calculation. At ₹2,00,000 per ETH, 0.00122 ETH is ₹244. Contrast this with UPI: Ravi pays zero marginal fee to send Meera money over UPI, because the cost of running NPCI's infrastructure is absorbed elsewhere in the banking system. On a public blockchain, that infrastructure cost is instead itemized and billed, in real time, to whoever triggers the computation. Removing a trusted intermediary doesn't make execution free — it makes its cost explicit and metered per operation.
Complexity has consequences: the unbounded-loop trap
Gas pricing turns ordinary algorithmic complexity into something that can permanently break a contract, not just slow it down. Suppose a voting contract needs to check whether an address has already voted. One tempting implementation stores every voter in a dynamic array and loops through it:
address[] public voters;
function hasVotedLoop(address who) public view returns (bool) {
for (uint i = 0; i < voters.length; i++) {
if (voters[i] == who) return true;
}
return false;
}
// versus an O(1) alternative:
mapping(address => bool) public hasVoted;
function markVoted(address who) external {
hasVoted[who] = true;
}
The loop is O(n) in the number of voters; the mapping lookup is O(1) regardless of how many addresses have voted. That distinction is the same one you'd analyze for any array-scan versus hash-lookup, but here it has a hard wall attached. Suppose each loop iteration costs roughly 2,200 gas (a storage read plus loop bookkeeping — again illustrative) and the network's per-block gas ceiling is on the order of 30,000,000 gas (the real figure drifts over time as validators vote to adjust it, but tens of millions is the right order of magnitude). The size at which the loop alone exceeds what any block can hold is:
n_max = 30,000,000 ÷ 2,200 ≈ 13,636
Check n = 1,000: 1,000 × 2,200 = 2,200,000 gas (≈7% of the block limit — fine)
Check n = 15,000: 15,000 × 2,200 = 33,000,000 gas (exceeds 30,000,000 — cannot be mined)
Past roughly 13,600 voters, no transaction calling hasVotedLoop can fit in any block, ever — the function becomes permanently uncallable, not merely slow. This exact pattern (an unbounded loop over on-chain storage) is a well-documented smart-contract failure class, catalogued in security registries as a denial-of-service risk, precisely because "just wait for it to finish" isn't an option when a hard per-block ceiling exists. The fix isn't a faster loop; it's choosing an O(1) data structure — a mapping — from the start.
Common misconception: "self-executing" does not mean autonomous
The name of this chapter contains the trap. Students reasonably read "self-executing" as "runs by itself, on its own schedule" — as if the escrow contract will notice, unprompted, that seven days have passed and refund Ravi automatically. It will not. Look again at refundIfExpired(): nothing in the EVM has a clock interrupt or a background scheduler. A contract's code only runs in response to an incoming transaction naming one of its functions. If nobody sends that transaction, the deadline can pass by a year and the contract's storage will sit unchanged forever, still reporting state = Funded. In practice, someone — Ravi himself, Meera, or an automated "keeper" service that watches deadlines and gets paid a small fee to submit the call — has to actually trigger it. "Self-executing" describes what happens once a transaction arrives: execution is deterministic and not subject to negotiation or discretion. It says nothing about the contract initiating anything on its own.
The same limitation explains why a contract can't natively settle a bet on, say, an IPL match score. Reading real-world data requires every validating node to agree on that data, but nodes have no shared, deterministic way to query an external API — one node's request might time out while another's succeeds, breaking the very determinism the whole system depends on. Real-world facts have to be pushed on-chain by an oracle, a separate service (trusted, or itself decentralized) that becomes an additional point of trust sitting outside the "trustless" contract logic. The oracle problem is a direct consequence of the same event-driven, deterministic-execution model that makes the misconception above worth correcting.
When code has bugs: reentrancy and the DAO
The order of operations inside a function is not a style preference — it's the difference between a working contract and one that can be drained. Compare a naive withdrawal function to the pattern the Escrow contract already used above:
// VulnerableVault.sol — illustrates the reentrancy pattern
// behind the 2016 DAO hack. Do not deploy.
pragma solidity ^0.8.19;
contract VulnerableVault {
mapping(address => uint) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdrawVulnerable() external {
uint bal = balances[msg.sender];
require(bal > 0, "no balance");
(bool ok, ) = msg.sender.call{value: bal}("");
require(ok, "transfer failed");
balances[msg.sender] = 0; // updated AFTER the external call
}
}
contract Attacker {
VulnerableVault public vault;
constructor(address _vault) {
vault = VulnerableVault(_vault);
}
function attack() external payable {
vault.deposit{value: 1 ether}();
vault.withdrawVulnerable();
}
receive() external payable {
if (address(vault).balance >= 1 ether) {
vault.withdrawVulnerable();
}
}
}
Trace the attack step by step. Suppose the vault already holds 4 ETH deposited by other, honest users. The attacker deposits 1 ETH, bringing the vault's total balance to 5 ETH, with balances[Attacker] = 1 ETH.
Step 1: Attacker calls withdrawVulnerable().
bal = 1 ETH > 0, check passes.
Sends 1 ETH to Attacker. Vault: 5 → 4 ETH.
(balances[Attacker] is still 1 ETH — not yet zeroed.)
This triggers Attacker's receive(): vault balance 4 ≥ 1 → re-enters.
Step 2: withdrawVulnerable() runs again, mid-transaction.
bal is still read as 1 ETH (storage not yet updated) → check passes.
Sends another 1 ETH. Vault: 4 → 3 ETH. receive(): 3 ≥ 1 → re-enters.
Step 3: Vault: 3 → 2 ETH. receive(): 2 ≥ 1 → re-enters.
Step 4: Vault: 2 → 1 ETH. receive(): 1 ≥ 1 → re-enters.
Step 5: Vault: 1 → 0 ETH. receive(): 0 ≥ 1 is false → recursion stops.
Unwind: each of the 5 stacked calls finally executes
balances[Attacker] = 0 (redundant but harmless).
Total extracted: 5 × 1 ETH = 5 ETH.
Attacker's own deposit: 1 ETH.
Net stolen from the other depositors: 5 − 1 = 4 ETH — exactly what they had.
The bug is the ordering: withdrawVulnerable sends ETH to the caller before setting balances[msg.sender] = 0, and sending ETH to a contract address hands that contract's receive() function control before the original call has finished — the recursive call sees stale storage. In June 2016, an attacker exploited exactly this pattern in a contract called The DAO, draining roughly 3.6 million ETH — worth approximately $50–60 million at the time — before the Ethereum community intervened with a hard fork that reversed the theft, an event so disruptive it split the network permanently into Ethereum (ETH) and Ethereum Classic (ETC), the chain that declined to rewrite history.
The fix is the pattern the Escrow contract used from the start: checks-effects-interactions. Verify the required conditions, update all of the contract's own storage, and only then make any external call or ETH transfer — in that fixed order. Look back at releaseToSeller(): state = State.Released is set before payable(seller).transfer(amount) runs. If that transfer somehow triggered a reentrant call back into releaseToSeller(), the very first check, require(state == State.Funded), would immediately fail, because the state was already flipped to Released before the money moved. The vulnerable vault breaks this rule; the escrow contract, written correctly the first time, does not.
Active recall
Attempt each question before reading its answer.
- In
releaseToSeller(), why doesstate = State.Released;come beforepayable(seller).transfer(amount);? - A classmate says: "Since it's called a self-executing contract, the moment the seven-day deadline passes, Ravi automatically gets his refund with no one doing anything." Is this correct?
- Suppose the vault in the reentrancy example starts with 8 ETH from other depositors (not 4), the attacker still deposits 1 ETH, and the same "recurse while vault balance ≥ 1 ETH" rule applies. How much ETH does the attacker extract in total, and how much is stolen from the other depositors?
- In the O(n) voting-loop example, suppose a future protocol upgrade cuts the per-iteration cost to 500 gas (from 2,200), while the block gas limit stays at 30,000,000 gas. What is the new maximum voter-list size before
hasVotedLoopbecomes uncallable, and does this actually fix the underlying design flaw? - Why can't a smart contract reliably determine, entirely on its own, the outcome of a real-world event like a cricket match score to settle a bet?
- Gas price triples to 60 gwei due to network congestion, but the ETH/₹ rate simultaneously falls to ₹1,20,000 (a market crash). Does Ravi's original
deposit()call now cost more or less in rupees than the ₹244 computed earlier? Compute the new cost.
Worked answers
1. This is the checks-effects-interactions pattern. Finalizing the contract's own state (the "effect") before making any external transfer (the "interaction") means that if the transfer somehow triggers a call back into the contract, the reentrant call finds state already set to Released and immediately fails the require(state == State.Funded) check — closing off the exact reentrancy path used in the vulnerable vault example.
2. No. Nothing in the EVM runs code without an incoming transaction — there is no internal clock or scheduler. Passing the deadline changes nothing in storage by itself; someone (Ravi, Meera, or an automated keeper) must actively send a transaction calling refundIfExpired() for the refund to occur. "Self-executing" describes deterministic execution once triggered, not autonomous initiation.
3. Total vault balance before the attack: 8 ETH (others) + 1 ETH (attacker) = 9 ETH. The recursion drains 1 ETH per call while balance ≥ 1 ETH, following the sequence 9 → 8 → 7 → 6 → 5 → 4 → 3 → 2 → 1 → 0, which is 9 successful withdrawals of 1 ETH each. Total extracted: 9 ETH. The attacker's own deposit was 1 ETH, so net profit is 9 − 1 = 8 ETH stolen — exactly the 8 ETH the other depositors held, fully drained. Doubling the honest depositors' balance (4 → 8 ETH) doubled both the total extracted and the amount stolen, because the attack drains the vault to zero regardless of its starting size.
4. n_max = 30,000,000 ÷ 500 = 60,000, compared to the original 13,636 — roughly a 4.4× increase (matching 2,200 ÷ 500 = 4.4), consistent with two independent ways of computing the ratio. This delays the failure but does not fix it: the function is still O(n), so it will eventually hit the new ceiling as the voter list keeps growing. The actual fix is switching to the O(1) mapping(address => bool) pattern shown earlier, which has no size-dependent cost at all — optimizing the constant factor is not a substitute for fixing the asymptotic complexity.
5. Every validating node must independently compute the identical result to stay in consensus. Blockchains can only natively read their own deterministic ledger state — they have no built-in way to fetch external data, because different nodes querying an external API at different times could get different answers (one API call might time out, another might succeed, scores might update mid-query), which would break agreement. Real-world facts must be fed in through an oracle, a separate data-feed mechanism that becomes an additional trust assumption layered outside the contract's own trustless execution.
6. The gas used by deposit() is unchanged at 61,000 gas — gas usage depends on what the code does, not on market prices. New ETH cost = 61,000 × 60 gwei = 61,000 × 0.00000006 ETH = 0.00366 ETH. New ₹ cost = 0.00366 ETH × ₹1,20,000/ETH = ₹439.20. So despite ETH's rupee price falling by 40%, the transaction now costs more (₹244 → ₹439.20, roughly +80%), because the gas price tripling outweighs the price crash. The two variables move independently, and only combining them correctly — not intuiting which "wins" — gives the right answer.
Think About It
Think about this: How would you explain smart contracts: self-executing agreements on the blockchain to a friend who has never seen a computer? What real-world analogy would you use? Imagine you had to build a system using these concepts — what would be your first step? Try this: before moving on, write down three things you learned and one question you still have.
Key Takeaways — Summary and Recap
Let us recap what we covered: the core ideas behind smart contracts: self-executing agreements on the blockchain, how they connect to real-world applications, and why they matter for your journey in computer science. Remember these key points as you move forward. For competitive exam preparation (CBSE, JEE, BITSAT), focus on understanding the WHY behind each concept, not just the WHAT.