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
13 changes: 13 additions & 0 deletions rayan/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copy this to .env and fill in real values. .env is git-ignored — never commit it.
# No private key belongs in this file. Keys live only in the encrypted
# keystore created via `cast wallet import`.

RPC_URL="<your-sepolia-rpc-url>"
ETHERSCAN_API_KEY="<your-etherscan-api-key>"

# Used by script/TokenEscrow.s.sol — fill these in *after* the token is deployed.
TOKEN_ADDRESS="<deployed-MyToken-address>"
RECIPIENT="<address-that-should-receive-funds-on-release>"
ARBITER="<address-allowed-to-call-release()-optional-defaults-to-deployer>"
ESCROW_AMOUNT="1000000000000000000000" # 1000 tokens, 18 decimals
ESCROW_DURATION="86400" # seconds until refund deadline (1 day)
5 changes: 5 additions & 0 deletions rayan/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.env
cache/
out/
broadcast/
lib/
25 changes: 25 additions & 0 deletions rayan/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());
}
}
115 changes: 115 additions & 0 deletions rayan/MyToken.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @title MyToken
/// @notice Minimal, standards-compliant ERC-20 implementation.
contract MyToken {
// ─────────────────────────────────────────────────────────────
// Metadata
// ─────────────────────────────────────────────────────────────
string public name;
string public symbol;
uint8 public decimals;

// ─────────────────────────────────────────────────────────────
// Accounting
// ─────────────────────────────────────────────────────────────
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;

// ─────────────────────────────────────────────────────────────
// Events (must match the ERC-20 interface exactly)
// ─────────────────────────────────────────────────────────────
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);

// ─────────────────────────────────────────────────────────────
// Custom errors (cheaper than require+string, and required by spec)
// ─────────────────────────────────────────────────────────────
error ZeroAddress();
error InsufficientBalance(address from, uint256 balance, uint256 needed);
error InsufficientAllowance(address owner, address spender, uint256 allowance_, uint256 needed);

constructor(
string memory _name,
string memory _symbol,
uint8 _decimals,
uint256 _initialSupply
) {
name = _name;
symbol = _symbol;
decimals = _decimals;

totalSupply = _initialSupply;
balanceOf[msg.sender] = _initialSupply;

// ERC-20 spec: minting should still emit a Transfer event from the zero address.
emit Transfer(address(0), msg.sender, _initialSupply);
}

// ─────────────────────────────────────────────────────────────
// Core ERC-20
// ─────────────────────────────────────────────────────────────

function transfer(address to, uint256 value) external returns (bool) {
_transfer(msg.sender, to, value);
return true;
}

function approve(address spender, uint256 value) external returns (bool) {
// NOTE: approve only ever *sets* the allowance. It must never revert
// because msg.sender lacks balance — that check belongs in transferFrom.
if (spender == address(0)) revert ZeroAddress();

allowance[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 = balanceOf[from];
if (fromBalance < value) revert InsufficientBalance(from, fromBalance, value);

unchecked {
balanceOf[from] = fromBalance - value;
balanceOf[to] += value;
}

emit Transfer(from, to, value);
}

function _spendAllowance(address owner, address spender, uint256 value) internal {
uint256 currentAllowance = allowance[owner][spender];

// Infinite approval convention: an allowance of type(uint256).max is
// treated as "unlimited" and must never be decremented. Silently
// burning down a max allowance breaks the standard "approve once,
// transferFrom many times" gas-saving pattern that integrators rely on.
if (currentAllowance == type(uint256).max) {
return;
}

if (currentAllowance < value) {
revert InsufficientAllowance(owner, spender, currentAllowance, value);
}

unchecked {
allowance[owner][spender] = currentAllowance - value;
}

emit Approval(owner, spender, allowance[owner][spender]);
}
}
49 changes: 49 additions & 0 deletions rayan/TokenEscrow.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Script, console} from "forge-std/Script.sol";
import {TokenEscrow} from "../src/TokenEscrow.sol";

/// @dev Deployment parameters that aren't secrets but still shouldn't be
/// hardcoded are pulled from env vars (set in .env alongside RPC_URL /
/// ETHERSCAN_API_KEY). None of these are private keys.
///
/// TOKEN_ADDRESS - address of the already-deployed MyToken
/// RECIPIENT - who receives funds on release
/// ARBITER - who is allowed to call release() (defaults to the deployer if unset)
/// ESCROW_AMOUNT - amount of tokens (in wei units) the depositor will lock up
/// ESCROW_DURATION - seconds from now until the refund deadline
contract TokenEscrowScript is Script {
function run() external returns (TokenEscrow escrow) {
address tokenAddress = vm.envAddress("TOKEN_ADDRESS");
address recipient = vm.envAddress("RECIPIENT");

// Depositor is whoever broadcasts this script (--account deployer).
address depositor = msg.sender;

address arbiter;
try vm.envAddress("ARBITER") returns (address a) {
arbiter = a;
} catch {
arbiter = depositor;
}

uint256 amount = vm.envUint("ESCROW_AMOUNT");
uint256 duration = vm.envUint("ESCROW_DURATION");
uint256 deadline = block.timestamp + duration;

vm.startBroadcast();

escrow = new TokenEscrow(tokenAddress, depositor, recipient, arbiter, amount, deadline);

vm.stopBroadcast();

console.log("Escrow deployed at:", address(escrow));
console.log("Token: ", tokenAddress);
console.log("Depositor: ", depositor);
console.log("Recipient: ", recipient);
console.log("Arbiter: ", arbiter);
console.log("Amount: ", amount);
console.log("Deadline (unix): ", deadline);
}
}
119 changes: 119 additions & 0 deletions rayan/TokenEscrow.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
function transferFrom(address from, address to, uint256 value) external returns (bool);
function transfer(address to, uint256 value) external returns (bool);
}

/// @title TokenEscrow
/// @notice Holds a fixed amount of an ERC-20 token deposited by `depositor`.
/// An `arbiter` can release the funds to `recipient` at any time.
/// If the arbiter has not released by `deadline`, the depositor may
/// reclaim (refund) their own deposit instead.
contract TokenEscrow {
enum State {
AwaitingDeposit,
Funded,
Released,
Refunded
}

IERC20 public immutable token;
address public immutable depositor;
address public immutable recipient;
address public immutable arbiter;
uint256 public immutable amount;
uint256 public immutable deadline; // unix timestamp after which refund is allowed

State public state;

event Deposited(address indexed depositor, uint256 amount);
event Released(address indexed recipient, uint256 amount);
event Refunded(address indexed depositor, uint256 amount);

error ZeroAddress();
error ZeroAmount();
error DeadlineInPast();
error NotDepositor();
error NotArbiter();
error WrongState(State expected, State actual);
error DeadlineNotReached(uint256 deadline, uint256 nowTs);
error TransferFailed();

modifier onlyDepositor() {
if (msg.sender != depositor) revert NotDepositor();
_;
}

modifier onlyArbiter() {
if (msg.sender != arbiter) revert NotArbiter();
_;
}

modifier inState(State expected) {
if (state != expected) revert WrongState(expected, state);
_;
}

constructor(
address _token,
address _depositor,
address _recipient,
address _arbiter,
uint256 _amount,
uint256 _deadline
) {
if (_token == address(0) || _depositor == address(0) || _recipient == address(0) || _arbiter == address(0)) {
revert ZeroAddress();
}
if (_amount == 0) revert ZeroAmount();
if (_deadline <= block.timestamp) revert DeadlineInPast();

token = IERC20(_token);
depositor = _depositor;
recipient = _recipient;
arbiter = _arbiter;
amount = _amount;
deadline = _deadline;

state = State.AwaitingDeposit;
}

/// @notice Pulls `amount` of the token from the depositor into escrow.
/// @dev Depositor must have called token.approve(escrowAddress, amount) first.
function deposit() external onlyDepositor inState(State.AwaitingDeposit) {
state = State.Funded; // effects before interaction (checks-effects-interactions)

bool ok = token.transferFrom(depositor, address(this), amount);
if (!ok) revert TransferFailed();

emit Deposited(depositor, amount);
}

/// @notice Arbiter confirms the condition was met; funds go to recipient.
/// Can only ever succeed once (state guard) and only the arbiter
/// can call it — the depositor cannot self-release.
function release() external onlyArbiter inState(State.Funded) {
state = State.Released;

bool ok = token.transfer(recipient, amount);
if (!ok) revert TransferFailed();

emit Released(recipient, amount);
}

/// @notice If the arbiter hasn't released by `deadline`, the depositor
/// can pull their own funds back. Only the depositor can call it,
/// only once, and only after the deadline has passed.
function refund() external onlyDepositor inState(State.Funded) {
if (block.timestamp < deadline) revert DeadlineNotReached(deadline, block.timestamp);

state = State.Refunded;

bool ok = token.transfer(depositor, amount);
if (!ok) revert TransferFailed();

emit Refunded(depositor, amount);
}
}
Loading