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
20 changes: 20 additions & 0 deletions assignments/Class/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Node modules
/node_modules

# Compilation output
/dist

# pnpm deploy output
/bundle

# Hardhat Build Artifacts
/artifacts

# Hardhat compilation (v2) support directory
/cache

# Typechain output
/types

# Hardhat coverage reports
/coverage
30 changes: 30 additions & 0 deletions assignments/Class/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
## MilestoneEscrow

A simple and secure milestone-based escrow smart contract that enables a client to fund a project upfront and release payments to a freelancer per milestone as work is completed and approved.
This contract is designed for freelance or contractor workflows where payments are tied to deliverables instead of a single lump-sum transfer.

Contract Address: 0x5DBE332243125b5E8E71F8A59bEEC7C9EccF49Fc

Etherscan Verification:
(etherscan_link)[https://sepolia.etherscan.io/address/0x5DBE332243125b5E8E71F8A59bEEC7C9EccF49Fc]

### Overview

MilestoneEscrow allows:
A client to deposit the full project payment upfront
A freelancer to mark milestones as completed
The client (or anyone after timeout) to approve and release payments
Automatic approval after a timeout period
Cancellation with refund of remaining funds
Dispute signaling via events
Each milestone has a fixed payment amount, and funds are released incrementally.

### Roles

- Client
Deploys and funds the contract
Approves milestones
Can cancel the contract and reclaim remaining funds
- Freelancer
Marks milestones as completed
Receives milestone payments
76 changes: 76 additions & 0 deletions assignments/Class/contracts/Factory.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@

// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import "./MilestoneEscrow.sol";

contract MilestoneEscrowFactory {

address[] public allEscrows;

// user => escrows they are involved in
mapping(address => address[]) public userEscrows;

event EscrowCreated(
address indexed client,
address indexed freelancer,
address escrow,
uint256 milestones,
uint256 amountPerMilestone
);

function createEscrow(
address _freelancer,
uint256 _milestoneCount,
uint256 _amountPerMilestone
) external payable returns (address) {

require(_freelancer != address(0), "Invalid freelancer");
require(_freelancer != msg.sender, "Self-hire not allowed");
require(_milestoneCount > 0, "Milestones = 0");
require(_amountPerMilestone > 0, "Amount = 0");

uint256 totalCost = _milestoneCount * _amountPerMilestone;
require(msg.value == totalCost, "Wrong ETH sent");

MilestoneEscrow escrow = new MilestoneEscrow{value: msg.value}(
_freelancer,
_milestoneCount,
_amountPerMilestone
);

address escrowAddr = address(escrow);

allEscrows.push(escrowAddr);

userEscrows[msg.sender].push(escrowAddr);
userEscrows[_freelancer].push(escrowAddr);

emit EscrowCreated(
msg.sender,
_freelancer,
escrowAddr,
_milestoneCount,
_amountPerMilestone
);

return escrowAddr;
}

function getUserEscrows(address user)
external
view
returns (address[] memory)
{
return userEscrows[user];
}

function getAllEscrows()
external
view
returns (address[] memory)
{
return allEscrows;
}
}

198 changes: 198 additions & 0 deletions assignments/Class/contracts/MilestoneEscrow.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

contract MilestoneEscrow {
address public immutable client;
address public immutable freelancer;

uint256 public immutable milestoneCount;
uint256 public immutable amountPerMilestone;

uint256 public approvedMilestones;
uint256 public releasedMilestones;

mapping(uint256 => bool) public completed;
mapping(uint256 => bool) public approved;
mapping(uint256 => uint256) public completionTime;

bool public cancelled;

uint256 public constant AUTO_APPROVE_TIMEOUT = 14 days;

event Funded(
address indexed client,
address indexed freelancer,
uint256 totalAmount,
uint256 milestoneCount,
uint256 amountPerMilestone
);

event MilestoneCompleted(
uint256 indexed milestoneId,
address indexed freelancer,
uint256 timestamp
);

event MilestoneApproved(
uint256 indexed milestoneId,
address indexed approver,
uint256 amountReleased
);


event MilestoneAutoApproved(
uint256 indexed milestoneId,
uint256 timeoutTimestamp,
uint256 amountReleased
);


event ContractCancelled(
address indexed caller,
uint256 refundAmount,
uint256 timestamp
);


event DisputeRaised(
uint256 indexed milestoneId,
address indexed raiser,
string reason
);


event AllMilestonesReleased(
address indexed freelancer,
uint256 totalReleased
);

constructor(
address _freelancer,
uint256 _milestoneCount,
uint256 _amountPerMilestone
) payable {
require(_freelancer != address(0), "Invalid freelancer address");
require(_freelancer != msg.sender, "Client cannot be freelancer");
require(_milestoneCount > 0, "At least one milestone required");
require(_amountPerMilestone > 0, "Amount per milestone must be > 0");

uint256 expectedDeposit = _milestoneCount * _amountPerMilestone;
require(msg.value == expectedDeposit, "Must fund all milestones");

client = msg.sender;
freelancer = _freelancer;

milestoneCount = _milestoneCount;
amountPerMilestone = _amountPerMilestone;

emit Funded(
msg.sender,
_freelancer,
msg.value,
_milestoneCount,
_amountPerMilestone
);
}

function markCompleted(uint256 id) public {
require(msg.sender == freelancer, "Only freelancer can mark complete");
require(id < milestoneCount, "Invalid milestone id");
require(!completed[id], "Already marked as completed");
require(!cancelled, "Contract is cancelled");

completed[id] = true;
completionTime[id] = block.timestamp;

emit MilestoneCompleted(id, msg.sender, block.timestamp);
}


function approveMilestone(uint256 id) external {
_requireCanApprove(id);

approved[id] = true;
approvedMilestones++;
releasedMilestones++;

_safeTransfer(freelancer, amountPerMilestone);

emit MilestoneApproved(id, msg.sender, amountPerMilestone);

if (releasedMilestones == milestoneCount) {
emit AllMilestonesReleased(freelancer, address(this).balance);
}
}


function autoApprove(uint256 id) external {
_requireCanApprove(id);

require(
block.timestamp >= completionTime[id] + AUTO_APPROVE_TIMEOUT,
"Timeout not reached yet"
);

approved[id] = true;
approvedMilestones++;
releasedMilestones++;

_safeTransfer(freelancer, amountPerMilestone);

emit MilestoneAutoApproved(id, block.timestamp, amountPerMilestone);
emit MilestoneApproved(id, address(0), amountPerMilestone);

if (releasedMilestones == milestoneCount) {
emit AllMilestonesReleased(freelancer, address(this).balance);
}
}


function cancel() external {
require(msg.sender == client, "Only client can cancel");
require(!cancelled, "Already cancelled");

cancelled = true;

uint256 remaining = address(this).balance;
if (remaining > 0) {
_safeTransfer(client, remaining);
}

emit ContractCancelled(msg.sender, remaining, block.timestamp);
}

//TODO: Internal Helpers
function _requireCanApprove(uint256 id) internal view {
require(id < milestoneCount, "Invalid milestone id");
require(completed[id], "Milestone not completed yet");
require(!approved[id], "Already approved");
require(!cancelled, "Contract is cancelled");
}


function _safeTransfer(address to, uint256 value) internal {
(bool success, ) = to.call{value: value}("");
require(success, "ETH transfer failed");
}

function isFullyReleased() external view returns (bool) {
return releasedMilestones == milestoneCount;
}

function getRemainingBalance() external view returns (uint256) {
return address(this).balance;
}

function canAutoApprove(uint256 id) external view returns (bool) {
if (id >= milestoneCount || !completed[id] || approved[id]) return false;
return block.timestamp >= completionTime[id] + AUTO_APPROVE_TIMEOUT;
}

function raiseDispute(uint256 id, string calldata reason) external {
require(msg.sender == client || msg.sender == freelancer);
require(completed[id], "Not completed yet");
require(!approved[id], "Already approved");

emit DisputeRaised(id, msg.sender, reason);
}
}
44 changes: 44 additions & 0 deletions assignments/Class/hardhat.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import hardhatToolboxMochaEthersPlugin from "@nomicfoundation/hardhat-toolbox-mocha-ethers";
import { configVariable, defineConfig } from "hardhat/config";
import "dotenv/config";

export default defineConfig({
plugins: [hardhatToolboxMochaEthersPlugin],
solidity: {
profiles: {
default: {
version: "0.8.28",
},
production: {
version: "0.8.28",
settings: {
optimizer: {
enabled: true,
runs: 200,
},
},
},
},
},
networks: {
hardhatMainnet: {
type: "edr-simulated",
chainType: "l1",
},
hardhatOp: {
type: "edr-simulated",
chainType: "op",
},
sepolia: {
type: "http",
chainType: "l1",
url: configVariable("SEPOLIA_RPC_URL"),
accounts: [configVariable("SEPOLIA_PRIVATE_KEY")],
},
},
verify: {
etherscan: {
apiKey: configVariable("ETHERSCAN_API_KEY"),
},
},
});
Loading