Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions MyToken/Escrow.s.sol
Original file line number Diff line number Diff line change
@@ -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.");
}
}
}
133 changes: 133 additions & 0 deletions MyToken/Escrow.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
150 changes: 150 additions & 0 deletions MyToken/Escrow.t.sol
Original file line number Diff line number Diff line change
@@ -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();
}
}
25 changes: 25 additions & 0 deletions MyToken/MyToken.s.sol
Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading