L11: Building DeFi
Deploy multiple interacting contracts on Remix — an ERC-20 token and a minimal DEX — and observe how AMM mechanics work by tracking reserve changes during swaps.
Tool Overview
What you will use in this lesson
- Remix IDE — deploy and interact with multiple contracts from one interface
- Etherscan Sepolia — read emitted events, verify transactions, and inspect contract state
- MetaMask — switch accounts to simulate different users (LP, trader, owner)
Before you start: Complete L10 Activity 3 to get Sepolia test ETH, and have MetaMask connected to Sepolia. You will need at least 0.05 SepoliaETH to cover gas for multiple deployments.
Guided Activities
- Open Remix and create a new file
TokenA.sol. Paste a minimal ERC-20:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract TokenA {
string public name = "Token A";
string public symbol = "TKA";
uint8 public decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
constructor(uint256 _supply) {
totalSupply = _supply * 10**18;
balanceOf[msg.sender] = totalSupply;
emit Transfer(address(0), msg.sender, totalSupply);
}
function transfer(address to, uint256 amount) public returns (bool) {
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
function approve(address spender, uint256 amount) public returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
allowance[from][msg.sender] -= amount;
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
return true;
}
}
- Compile
TokenA.sol. Deploy it with constructor argument1000000(1 million tokens). Record the deployed address as Token A address. - Create
TokenB.sol— copy the same contract, changenameto"Token B"andsymbolto"TKB". Deploy with1000000tokens. Record as Token B address. - Verify both appear at sepolia.etherscan.io by searching each address.
Reflection question: Both tokens start with identical code. What makes one "Token A" and the other "Token B" at the protocol level? Is the name on-chain authoritative, or just cosmetic?
- Create
SimpleDEX.soland paste the following constant-product AMM:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function transferFrom(address, address, uint256) external returns (bool);
function transfer(address, uint256) external returns (bool);
function balanceOf(address) external view returns (uint256);
}
contract SimpleDEX {
IERC20 public tokenA;
IERC20 public tokenB;
uint256 public reserveA;
uint256 public reserveB;
event LiquidityAdded(address indexed provider, uint256 amountA, uint256 amountB);
event Swap(address indexed trader, address tokenIn, uint256 amountIn, uint256 amountOut);
constructor(address _tokenA, address _tokenB) {
tokenA = IERC20(_tokenA);
tokenB = IERC20(_tokenB);
}
function addLiquidity(uint256 amountA, uint256 amountB) external {
tokenA.transferFrom(msg.sender, address(this), amountA);
tokenB.transferFrom(msg.sender, address(this), amountB);
reserveA += amountA;
reserveB += amountB;
emit LiquidityAdded(msg.sender, amountA, amountB);
}
function swapAforB(uint256 amountAIn) external returns (uint256 amountBOut) {
// x * y = k => amountOut = reserveB - k / (reserveA + amountIn)
uint256 k = reserveA * reserveB;
tokenA.transferFrom(msg.sender, address(this), amountAIn);
reserveA += amountAIn;
amountBOut = reserveB - (k / reserveA);
reserveB -= amountBOut;
tokenB.transfer(msg.sender, amountBOut);
emit Swap(msg.sender, address(tokenA), amountAIn, amountBOut);
}
function swapBforA(uint256 amountBIn) external returns (uint256 amountAOut) {
uint256 k = reserveA * reserveB;
tokenB.transferFrom(msg.sender, address(this), amountBIn);
reserveB += amountBIn;
amountAOut = reserveA - (k / reserveB);
reserveA -= amountAOut;
tokenA.transfer(msg.sender, amountAOut);
emit Swap(msg.sender, address(tokenB), amountBIn, amountAOut);
}
}
- Deploy
SimpleDEXwith the two token addresses as constructor arguments: paste Token A address, then Token B address. - Record the deployed DEX address.
Reflection question: The DEX contract uses the formula
k = reserveA * reserveB. What is the mathematical invariant this enforces? What happens to the price of Token B as you buy more of it?
- To add liquidity, the DEX needs permission to pull your tokens. This requires a two-step ERC-20 approval pattern.
- In Remix, select the deployed Token A contract. Call
approvewith:
spender= DEX contract address,amount=100000000000000000000000(100,000 tokens with 18 decimals). - Do the same for Token B — approve the DEX address for 100,000 TKB.
- Now select the SimpleDEX contract. Call
addLiquiditywithamountA = 50000000000000000000000andamountB = 50000000000000000000000(50,000 of each). - Call
reserveAandreserveB— both should now show 50,000 tokens worth of reserves.
Reflection question: Why does the ERC-20 standard use a two-step
approve then transferFrom instead of a single call? What attack does this prevent?
- Before swapping, note the current values of
reserveAandreserveBby calling them on the DEX. Calculatek = reserveA * reserveB. - Approve the DEX to spend 1,000 Token A: call
approveon Token A withamount = 1000000000000000000000. - On the DEX, call
swapAforBwithamountAIn = 1000000000000000000000. - After the swap, call
reserveAandreserveBagain. Observe: reserveA increased by 1,000 and reserveB decreased. Calculate the new k — is it exactly the same? - Go to sepolia.etherscan.io, search the DEX address, click the Events tab, and find the
Swapevent. Read theamountInandamountOutfields in the event log.
Reflection question: You swapped 1,000 Token A but received slightly less than 1,000 Token B. Why? What determines exactly how much Token B you receive? How would a larger pool (higher reserves) affect your output?
- Deliberately trigger a failing transaction: try calling
swapAforBwith more tokens than you approved (e.g., approve 100, try to swap 1000). Remix will show a red error in the console. - Click the Debug button that appears next to the failed transaction in the Remix console. This opens the Remix Debugger.
- Step through the transaction with the arrow buttons. Watch the call stack — you will see the DEX calling
transferFromon Token A, which reverts because the allowance is too low. - Common cross-contract errors to look for:
- Insufficient allowance — approve step was skipped or amount was too low
- Wrong address — pasting the wrong contract address as an argument
- Integer overflow on division — if reserves are very small, AMM math can underflow
- Re-entrancy — malicious token contracts calling back into the DEX during transfer
- Fix the allowance and retry the swap successfully. Confirm the Remix console shows a green transaction receipt.
Reflection question: The Remix debugger shows every opcode execution. In production, Ethereum nodes execute the same opcodes on the real chain. How does this transparency help auditors find vulnerabilities — and how does it also help attackers?