diff --git a/MyToken/Escrow.s.sol b/MyToken/Escrow.s.sol new file mode 100644 index 0000000..f6bab3a --- /dev/null +++ b/MyToken/Escrow.s.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {Escrow} from "../src/Escrow.sol"; +import {MyToken} from "../src/MyToken.sol"; + +/// @notice Deploys the (single, shared) Escrow contract. Because Escrow +/// supports unlimited simultaneous deals, you deploy it once and then open +/// as many `deposit()` calls against it as you like — you do NOT redeploy +/// per deal. This script optionally opens one example deposit if +/// TOKEN_ADDRESS / RECIPIENT / AMOUNT env vars are set, to prove the wiring +/// end-to-end; the contract itself works the same with zero, one, or many +/// open deals. +/// +/// TOKEN_ADDRESS=0x... RECIPIENT=0x... AMOUNT=1000000000000000000 \ +/// forge script script/Escrow.s.sol:EscrowScript \ +/// --rpc-url sepolia --account deployer --broadcast --verify +contract EscrowScript is Script { + function run() external returns (Escrow escrow) { + vm.startBroadcast(); + + escrow = new Escrow(); + + vm.stopBroadcast(); + + console.log("Escrow deployed at:", address(escrow)); + + // Optional: open one example deal against the token you deployed + // separately with MyToken.s.sol, using the deployer as both + // depositor and arbiter (release-to-self-controlled deal). + try vm.envAddress("TOKEN_ADDRESS") returns (address tokenAddress) { + address recipient = vm.envAddress("RECIPIENT"); + uint256 amount = vm.envUint("AMOUNT"); + + vm.startBroadcast(); + MyToken(tokenAddress).approve(address(escrow), amount); + uint256 escrowId = escrow.deposit(tokenAddress, recipient, msg.sender, amount); + vm.stopBroadcast(); + + console.log("Opened escrow id: ", escrowId); + } catch { + console.log("No TOKEN_ADDRESS/RECIPIENT/AMOUNT set — skipped opening an example deal."); + } + } +} diff --git a/MyToken/Escrow.sol b/MyToken/Escrow.sol new file mode 100644 index 0000000..bd0a2fc --- /dev/null +++ b/MyToken/Escrow.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IERC20Min { + function transferFrom(address from, address to, uint256 value) external returns (bool); + function transfer(address to, uint256 value) external returns (bool); +} + +/// @title Escrow +/// @notice Holds ERC-20 tokens on behalf of many depositors at once. Each +/// call to `deposit` opens its own independent, numbered escrow — the +/// contract is a shared ledger of simultaneous deals, not a single-use +/// vault. Any number of deposits can be open concurrently, for the same +/// token or different ones, each tracked and settled separately. +contract Escrow { + // --------------------------------------------------------------------- + // Errors + // --------------------------------------------------------------------- + error ZeroAddress(); + error ZeroAmount(); + error EscrowNotFound(uint256 escrowId); + error NotArbiter(); + error NotDepositor(); + error AlreadySettled(); + error TokenTransferFailed(); + + // --------------------------------------------------------------------- + // Storage + // --------------------------------------------------------------------- + enum Status { + Active, + Released, + Refunded + } + + struct Deal { + address token; + address depositor; + address recipient; + address arbiter; + uint256 amount; + Status status; + } + + /// @dev Every open or settled deal lives here, keyed by its own id — + /// deposits never overwrite or block each other. + uint256 public nextEscrowId; + mapping(uint256 => Deal) public deals; + + // --------------------------------------------------------------------- + // Events + // --------------------------------------------------------------------- + event Deposited( + uint256 indexed escrowId, + address indexed token, + address indexed depositor, + address recipient, + address arbiter, + uint256 amount + ); + event Released(uint256 indexed escrowId, address indexed recipient, uint256 amount); + event Refunded(uint256 indexed escrowId, address indexed depositor, uint256 amount); + + // --------------------------------------------------------------------- + // Deposit — opens a brand-new, independent escrow every time + // --------------------------------------------------------------------- + + /// @notice Pulls `amount` of `token` from the caller into escrow #N and + /// records who can release it (`arbiter`) and where it goes + /// (`recipient`). Caller must have already `approve`'d this contract — + /// funds are pulled with `transferFrom`, never pushed in raw. + /// @param arbiter The address allowed to call `release`. Pass the + /// depositor's own address to keep release decisions in their hands, + /// or a third party to act as a neutral condition-checker. + function deposit(address token, address recipient, address arbiter, uint256 amount) + external + returns (uint256 escrowId) + { + if (token == address(0) || recipient == address(0) || arbiter == address(0)) revert ZeroAddress(); + if (amount == 0) revert ZeroAmount(); + + escrowId = nextEscrowId++; + deals[escrowId] = Deal({ + token: token, + depositor: msg.sender, + recipient: recipient, + arbiter: arbiter, + amount: amount, + status: Status.Active + }); + + bool ok = IERC20Min(token).transferFrom(msg.sender, address(this), amount); + if (!ok) revert TokenTransferFailed(); + + emit Deposited(escrowId, token, msg.sender, recipient, arbiter, amount); + } + + // --------------------------------------------------------------------- + // Settlement — each escrow settles independently of every other one + // --------------------------------------------------------------------- + + /// @notice Releases escrow #`escrowId` to its recipient. Only that + /// deal's arbiter can call this, and only once. + function release(uint256 escrowId) external { + Deal storage deal = deals[escrowId]; + if (deal.depositor == address(0)) revert EscrowNotFound(escrowId); + if (msg.sender != deal.arbiter) revert NotArbiter(); + if (deal.status != Status.Active) revert AlreadySettled(); + + deal.status = Status.Released; // effects before interaction + + bool ok = IERC20Min(deal.token).transfer(deal.recipient, deal.amount); + if (!ok) revert TokenTransferFailed(); + + emit Released(escrowId, deal.recipient, deal.amount); + } + + /// @notice Refunds escrow #`escrowId` back to its depositor. Only the + /// original depositor can call this, and only before it's released. + function refund(uint256 escrowId) external { + Deal storage deal = deals[escrowId]; + if (deal.depositor == address(0)) revert EscrowNotFound(escrowId); + if (msg.sender != deal.depositor) revert NotDepositor(); + if (deal.status != Status.Active) revert AlreadySettled(); + + deal.status = Status.Refunded; // effects before interaction + + bool ok = IERC20Min(deal.token).transfer(deal.depositor, deal.amount); + if (!ok) revert TokenTransferFailed(); + + emit Refunded(escrowId, deal.depositor, deal.amount); + } +} diff --git a/MyToken/Escrow.t.sol b/MyToken/Escrow.t.sol new file mode 100644 index 0000000..832a069 --- /dev/null +++ b/MyToken/Escrow.t.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {MyToken} from "../src/MyToken.sol"; +import {Escrow} from "../src/Escrow.sol"; + +contract EscrowTest is Test { + MyToken token; + Escrow escrow; + + address depositor1 = address(0xD1); + address depositor2 = address(0xD2); + address recipient1 = address(0xR1); + address recipient2 = address(0xR2); + address arbiter = address(0xA12); + + function setUp() public { + token = new MyToken("MyToken", "MTK", 18, 1_000_000 ether); + escrow = new Escrow(); + + token.transfer(depositor1, 1_000 ether); + token.transfer(depositor2, 1_000 ether); + } + + function _openDeal(address depositor, address recipient, uint256 amount) internal returns (uint256 id) { + vm.startPrank(depositor); + token.approve(address(escrow), amount); + id = escrow.deposit(address(token), recipient, arbiter, amount); + vm.stopPrank(); + } + + // ----------------------------------------------------------------- + // Core single-deal behavior + // ----------------------------------------------------------------- + function testDepositPullsTokensIntoEscrow() public { + uint256 id = _openDeal(depositor1, recipient1, 100 ether); + assertEq(token.balanceOf(address(escrow)), 100 ether); + (,,,, uint256 amount, Escrow.Status status) = escrow.deals(id); + assertEq(amount, 100 ether); + assertEq(uint256(status), uint256(Escrow.Status.Active)); + } + + function testOnlyArbiterCanRelease() public { + uint256 id = _openDeal(depositor1, recipient1, 100 ether); + + vm.prank(depositor1); + vm.expectRevert(Escrow.NotArbiter.selector); + escrow.release(id); + + vm.prank(arbiter); + escrow.release(id); + assertEq(token.balanceOf(recipient1), 100 ether); + } + + function testOnlyDepositorCanRefund() public { + uint256 id = _openDeal(depositor1, recipient1, 100 ether); + + vm.prank(recipient1); + vm.expectRevert(Escrow.NotDepositor.selector); + escrow.refund(id); + + vm.prank(depositor1); + escrow.refund(id); + assertEq(token.balanceOf(depositor1), 1_000 ether); // full 1000 back + } + + function testCannotReleaseTwice() public { + uint256 id = _openDeal(depositor1, recipient1, 100 ether); + + vm.prank(arbiter); + escrow.release(id); + + vm.prank(arbiter); + vm.expectRevert(Escrow.AlreadySettled.selector); + escrow.release(id); + } + + function testCannotRefundAfterRelease() public { + uint256 id = _openDeal(depositor1, recipient1, 100 ether); + + vm.prank(arbiter); + escrow.release(id); + + vm.prank(depositor1); + vm.expectRevert(Escrow.AlreadySettled.selector); + escrow.refund(id); + } + + function testCannotRefundTwice() public { + uint256 id = _openDeal(depositor1, recipient1, 100 ether); + + vm.prank(depositor1); + escrow.refund(id); + + vm.prank(depositor1); + vm.expectRevert(Escrow.AlreadySettled.selector); + escrow.refund(id); + } + + function testActionsOnNonexistentEscrowRevert() public { + vm.expectRevert(abi.encodeWithSelector(Escrow.EscrowNotFound.selector, 999)); + escrow.release(999); + } + + // ----------------------------------------------------------------- + // The whole point: many deals run at once, fully independently + // ----------------------------------------------------------------- + function testMultipleSimultaneousDealsDoNotInterfere() public { + uint256 idA = _openDeal(depositor1, recipient1, 100 ether); + uint256 idB = _openDeal(depositor2, recipient2, 250 ether); + uint256 idC = _openDeal(depositor1, recipient2, 50 ether); + + // all three are pooled in the same contract balance... + assertEq(token.balanceOf(address(escrow)), 400 ether); + + // ...but settle completely independently, in any order. + vm.prank(depositor2); // refund B + escrow.refund(idB); + assertEq(token.balanceOf(depositor2), 1_000 ether); + + vm.prank(arbiter); // release A + escrow.release(idA); + assertEq(token.balanceOf(recipient1), 100 ether); + + // C is still untouched and active while A and B are already settled + (,,,,, Escrow.Status statusC) = escrow.deals(idC); + assertEq(uint256(statusC), uint256(Escrow.Status.Active)); + + vm.prank(arbiter); // release C + escrow.release(idC); + assertEq(token.balanceOf(recipient2), 50 ether); + + assertEq(token.balanceOf(address(escrow)), 0); + } + + function testDepositRevertsWithoutApproval() public { + vm.prank(depositor1); // no approve() call first + vm.expectRevert(); // ERC20 will reject the pull, not a custom Escrow error + escrow.deposit(address(token), recipient1, arbiter, 100 ether); + } + + function testZeroAmountDepositReverts() public { + vm.startPrank(depositor1); + token.approve(address(escrow), 0); + vm.expectRevert(Escrow.ZeroAmount.selector); + escrow.deposit(address(token), recipient1, arbiter, 0); + vm.stopPrank(); + } +} diff --git a/MyToken/MyToken.s.sol b/MyToken/MyToken.s.sol new file mode 100644 index 0000000..2ecafd4 --- /dev/null +++ b/MyToken/MyToken.s.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {MyToken} from "../src/MyToken.sol"; + +contract MyTokenScript is Script { + string constant NAME = "MyToken"; + string constant SYMBOL = "MTK"; + uint8 constant DECIMALS = 18; + uint256 constant INITIAL_SUPPLY = 1_000_000 * 10 ** 18; + + function run() external returns (MyToken token) { + vm.startBroadcast(); + + token = new MyToken(NAME, SYMBOL, DECIMALS, INITIAL_SUPPLY); + + vm.stopBroadcast(); + + console.log("Deployed at: ", address(token)); + console.log("Name: ", token.name()); + console.log("Symbol: ", token.symbol()); + console.log("Initial supply: ", token.totalSupply()); + } +} diff --git a/MyToken/MyToken.sol b/MyToken/MyToken.sol new file mode 100644 index 0000000..19b0e1e --- /dev/null +++ b/MyToken/MyToken.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IERC20 { + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + function name() external view returns (string memory); + function symbol() external view returns (string memory); + function decimals() external view returns (uint8); + function totalSupply() external view returns (uint256); + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 value) external returns (bool); + function allowance(address owner, address spender) external view returns (uint256); + function approve(address spender, uint256 value) external returns (bool); + function transferFrom(address from, address to, uint256 value) external returns (bool); +} + +/// @title MyToken +/// @notice A hand-written ERC-20 implementation (no external libraries). +contract MyToken is IERC20 { + // --------------------------------------------------------------------- + // Errors + // --------------------------------------------------------------------- + error ZeroAddress(); + error InsufficientBalance(address from, uint256 balance, uint256 needed); + error InsufficientAllowance(address spender, uint256 allowance, uint256 needed); + + // --------------------------------------------------------------------- + // Storage + // --------------------------------------------------------------------- + string private _name; + string private _symbol; + uint8 private _decimals; + uint256 private _totalSupply; + + mapping(address => uint256) private _balances; + mapping(address => mapping(address => uint256)) private _allowances; + + constructor(string memory name_, string memory symbol_, uint8 decimals_, uint256 initialSupply_) { + _name = name_; + _symbol = symbol_; + _decimals = decimals_; + + _totalSupply = initialSupply_; + _balances[msg.sender] = initialSupply_; + emit Transfer(address(0), msg.sender, initialSupply_); + } + + // --------------------------------------------------------------------- + // Views + // --------------------------------------------------------------------- + function name() external view returns (string memory) { + return _name; + } + + function symbol() external view returns (string memory) { + return _symbol; + } + + function decimals() external view returns (uint8) { + return _decimals; + } + + function totalSupply() external view returns (uint256) { + return _totalSupply; + } + + function balanceOf(address account) public view returns (uint256) { + return _balances[account]; + } + + function allowance(address owner, address spender) public view returns (uint256) { + return _allowances[owner][spender]; + } + + // --------------------------------------------------------------------- + // Mutating functions + // --------------------------------------------------------------------- + function transfer(address to, uint256 value) external returns (bool) { + _transfer(msg.sender, to, value); + return true; + } + + /// @dev approve only ever *sets* an allowance — it never checks the + /// owner's balance. That check belongs in transferFrom, not here. + function approve(address spender, uint256 value) external returns (bool) { + if (spender == address(0)) revert ZeroAddress(); + _allowances[msg.sender][spender] = value; + emit Approval(msg.sender, spender, value); + return true; + } + + function transferFrom(address from, address to, uint256 value) external returns (bool) { + _spendAllowance(from, msg.sender, value); + _transfer(from, to, value); + return true; + } + + // --------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------- + function _transfer(address from, address to, uint256 value) internal { + if (from == address(0) || to == address(0)) revert ZeroAddress(); + + uint256 fromBalance = _balances[from]; + if (fromBalance < value) revert InsufficientBalance(from, fromBalance, value); + + unchecked { + _balances[from] = fromBalance - value; + _balances[to] += value; + } + + emit Transfer(from, to, value); + } + + /// @dev Spends the *caller's* allowance from `owner`, never the + /// contract's own allowance — mixing that up is how approvals get + /// drained by anyone. Infinite approvals (type(uint256).max) are never + /// decremented, preserving the standard "approve once" gas pattern. + function _spendAllowance(address owner, address spender, uint256 value) internal { + uint256 currentAllowance = _allowances[owner][spender]; + if (currentAllowance != type(uint256).max) { + if (currentAllowance < value) revert InsufficientAllowance(spender, currentAllowance, value); + unchecked { + _allowances[owner][spender] = currentAllowance - value; + } + emit Approval(owner, spender, _allowances[owner][spender]); + } + } +} diff --git a/MyToken/MyToken.t.sol b/MyToken/MyToken.t.sol new file mode 100644 index 0000000..3ad97d0 --- /dev/null +++ b/MyToken/MyToken.t.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {MyToken} from "../src/MyToken.sol"; + +contract MyTokenTest is Test { + MyToken token; + address alice = address(0xA11CE); + address bob = address(0xB0B); + + uint256 constant SUPPLY = 1_000_000 ether; + + function setUp() public { + token = new MyToken("MyToken", "MTK", 18, SUPPLY); + } + + function testInitialSupplyMintedToDeployer() public view { + assertEq(token.totalSupply(), SUPPLY); + assertEq(token.balanceOf(address(this)), SUPPLY); + } + + function testTransfer() public { + token.transfer(alice, 100 ether); + assertEq(token.balanceOf(alice), 100 ether); + assertEq(token.balanceOf(address(this)), SUPPLY - 100 ether); + } + + function testTransferToZeroAddressReverts() public { + vm.expectRevert(MyToken.ZeroAddress.selector); + token.transfer(address(0), 1 ether); + } + + function testTransferInsufficientBalanceReverts() public { + vm.prank(alice); // alice has 0 balance + vm.expectRevert(abi.encodeWithSelector(MyToken.InsufficientBalance.selector, alice, 0, 1 ether)); + token.transfer(bob, 1 ether); + } + + /// @dev approve must never revert for lack of balance — that check + /// belongs to transferFrom only. + function testApproveDoesNotCheckBalance() public { + vm.prank(alice); // alice has zero balance + token.approve(bob, 1_000_000 ether); // must not revert + assertEq(token.allowance(alice, bob), 1_000_000 ether); + } + + function testTransferFromSpendsCallerAllowanceNotContracts() public { + token.approve(alice, 100 ether); + + vm.prank(alice); + token.transferFrom(address(this), bob, 40 ether); + + assertEq(token.allowance(address(this), alice), 60 ether); + assertEq(token.balanceOf(bob), 40 ether); + } + + function testTransferFromInsufficientAllowanceReverts() public { + token.approve(alice, 10 ether); + + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(MyToken.InsufficientAllowance.selector, alice, 10 ether, 20 ether)); + token.transferFrom(address(this), bob, 20 ether); + } + + function testInfiniteApprovalIsNeverDecremented() public { + token.approve(alice, type(uint256).max); + + vm.prank(alice); + token.transferFrom(address(this), bob, 500 ether); + + assertEq(token.allowance(address(this), alice), type(uint256).max); + } +}