//SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
interface IERC20 {
function balanceOf(address account) external view returns (uint256);
function transferFrom(address from, address to, uint256 value) external returns (bool);
function transfer(address to, uint256 value) external returns (bool);
}
contract Escrow{
address public admin;
address public newAdmin;
address public feeRecipient;
uint256 public timePerTweet;
uint256 public depositFee;
uint256 public claimFee;
uint256 public constant TIMELOCK = 1 days;
uint256 public updateAdminStart;
struct Deal {
uint256 id;
uint256 numOfTweets;
uint256 amountPerUser;
uint256 claimedAmount;
uint256 unlockedAmount;
uint256 acceptTimer;
uint256 completeTimer;
address token;
address creator;
}
mapping(uint256 id => Deal) public deals;
mapping(uint256 id => mapping(address recipient => bool)) public isDealRecipient;
mapping(address recipient => mapping(uint256 id => Deal deal)) public recipientDeals;
mapping(address => bool) public whitelist;
event NewDeal(uint256 id, uint256 numOfTweets, uint256 amountPerUser, uint256 acceptTimer, address token, address creator);
event DealAccepted(uint256 id, address recipient);
event DealUpdated(uint256 id, address recipient, uint256 newNumOfTweets);
event Claim(uint256 id, uint256 amount, address recipient);
event Refund(uint256, address creator, address recipient);
modifier onlyAuth() {
require(msg.sender == admin, "ERROR: not authorized");
_;
}
constructor(address _feeRecipient, uint256 _timePerTweet, uint256 _depositFee, uint256 _claimFee) {
admin = msg.sender;
feeRecipient = _feeRecipient;
timePerTweet = _timePerTweet;
depositFee = _depositFee;
claimFee = _claimFee;
}
function createDeal(uint256 id, uint256 numOfTweets, uint256 amountPerUser, uint256 acceptTimer, address token, address[] memory recipients) external {
uint256 amountToTransfer = amountPerUser * recipients.length;
require(amountToTransfer > 0, "ERROR: amountPerUser must be positive");
uint256 feeAmount = amountToTransfer * depositFee / 1000;
amountPerUser = amountPerUser * (1000 - depositFee) / 1000;
amountToTransfer -= feeAmount;
IERC20(token).transferFrom(msg.sender, feeRecipient, feeAmount);
IERC20(token).transferFrom(msg.sender, address(this), amountToTransfer);
require(IERC20(token).balanceOf(address(this)) >= amountToTransfer, "ERROR: insufficient balance");
Deal memory deal = Deal(id, numOfTweets, amountPerUser, 0, 0, acceptTimer, 0, token, msg.sender);
deals[id] = deal;
uint256 size = recipients.length;
for(uint256 i = 0; i < size; i++){
address recipient = recipients[i];
recipientDeals[recipient][id] = deal;
isDealRecipient[id][recipient] = true;
}
emit NewDeal(id, numOfTweets, amountPerUser, acceptTimer, token, msg.sender);
}
function acceptDeal(uint256 id) external {
bool isRecipient = isDealRecipient[id][msg.sender];
require(isRecipient, "ERROR: invalid recipient");
Deal memory deal = recipientDeals[msg.sender][id];
require(deal.acceptTimer >= block.timestamp, "ERROR: acceptTimer expired");
uint256 totTime = deal.numOfTweets * timePerTweet;
recipientDeals[msg.sender][id].completeTimer = block.timestamp + totTime;
emit DealAccepted(id, msg.sender);
}
function claim(uint256 id) external {
Deal memory deal = recipientDeals[msg.sender][id];
require(deal.unlockedAmount > 0, "ERROR: nothing to claim");
require(IERC20(deal.token).balanceOf(address(this)) >= deal.unlockedAmount, "ERROR: insufficient balance");
recipientDeals[msg.sender][id].unlockedAmount = 0;
recipientDeals[msg.sender][id].claimedAmount += deal.unlockedAmount;
deals[id].claimedAmount += deal.unlockedAmount;
uint256 amountToTransfer = deal.unlockedAmount;
if(whitelist[msg.sender] == false) {
uint256 feeAmount = deal.unlockedAmount * claimFee / 1000;
IERC20(deal.token).transfer(feeRecipient, feeAmount);
amountToTransfer = deal.unlockedAmount - feeAmount;
}
IERC20(deal.token).transfer(msg.sender, amountToTransfer);
emit Claim(id, deal.unlockedAmount, msg.sender);
}
function refund(uint256 id, address recipient) external {
Deal memory deal = recipientDeals[recipient][id];
require(deal.creator == msg.sender, "ERROR: must be the creator");
recipientDeals[recipient][id].amountPerUser = 0;
if(deal.amountPerUser == 0) revert("ERROR: nothing to claim back");
// EXPIRED ACCEPTANCE --> refund everything
if(deal.acceptTimer < block.timestamp && deal.completeTimer == 0){
IERC20(deal.token).transfer(msg.sender, deal.amountPerUser);
}
// EXPIRED COMPLETION --> refund only what has not been done
else if(deal.completeTimer > 0 && deal.completeTimer < block.timestamp) {
uint256 remainingAmount = deal.amountPerUser - deal.unlockedAmount - deal.claimedAmount;
if(remainingAmount > 0){
IERC20(deal.token).transfer(msg.sender, remainingAmount);
}
else revert("ERROR: nothing to claim back");
}
else revert("ERROR: can't claim back now");
emit Refund(id, msg.sender, recipient);
}
function updateDeal(uint256 id, address recipient, uint256 newNumOfTweets) external onlyAuth {
bool isRecipient = isDealRecipient[id][recipient];
require(isRecipient, "ERROR: invalid recipient");
Deal memory deal = recipientDeals[recipient][id];
uint256 amountPerTweet = deal.amountPerUser / deal.numOfTweets;
uint256 newUnlockedAmount = newNumOfTweets * amountPerTweet;
require(deal.claimedAmount + newUnlockedAmount <= deal.amountPerUser, "ERROR: exceeding amountPerUser");
recipientDeals[recipient][id].unlockedAmount += newUnlockedAmount;
deals[id].unlockedAmount += newUnlockedAmount;
emit DealUpdated(id, recipient, newNumOfTweets);
}
function updateWhitelist(address[] memory users, bool status) external onlyAuth{
uint256 size = users.length;
for(uint256 i = 0; i < size; i++){
address user = users[i];
whitelist[user] = status;
}
}
function updateTimePerTweet(uint256 _newTimePerTweet) external onlyAuth{
timePerTweet = _newTimePerTweet;
}
function updateFees(uint256 _depositFee, uint256 _claimFee) external onlyAuth{
depositFee = _depositFee;
claimFee = _claimFee;
}
function updateDealsRecipients(uint256[] memory dealsIds, address oldRecipient, address newRecipient) external onlyAuth {
uint256 size = dealsIds.length;
for(uint256 i = 0; i < size; i++){
uint256 id = dealsIds[i];
require(isDealRecipient[id][oldRecipient] == true, "ERROR: oldRecipient not present");
require(isDealRecipient[id][newRecipient] == false, "ERROR: newRecipient already present");
isDealRecipient[id][oldRecipient] = false;
isDealRecipient[id][newRecipient] = true;
recipientDeals[newRecipient][id] = recipientDeals[oldRecipient][id];
delete recipientDeals[oldRecipient][id];
}
}
function recoverTokens(address token, address recipient, uint256 amount) external onlyAuth{
require(IERC20(token).balanceOf(address(this)) > amount, "ERROR: insufficient balance");
IERC20(token).transfer(recipient, amount);
}
function updateAdmin(address _newAdmin) external onlyAuth {
require(updateAdminStart == 0, "ERROR: update already in progress");
updateAdminStart = block.timestamp;
newAdmin = _newAdmin;
}
function stopAdminUpdate() external onlyAuth{
require(updateAdminStart != 0, "ERROR: no update in progress");
newAdmin = address(0);
updateAdminStart = 0;
}
function finalizeAdminUpdate() external {
require(updateAdminStart != 0, "ERROR: no update in progress");
require(updateAdminStart + TIMELOCK < block.timestamp, "ERROR: timelock not expired yet");
require(msg.sender == newAdmin || msg.sender == admin, "ERROR: not authorized");
updateAdminStart = 0;
admin = newAdmin;
newAdmin = address(0);
}
}
{
"compilationTarget": {
"Escrow.sol": "Escrow"
},
"evmVersion": "cancun",
"libraries": {},
"metadata": {
"bytecodeHash": "ipfs"
},
"optimizer": {
"enabled": false,
"runs": 200
},
"remappings": []
}
[{"inputs":[{"internalType":"address","name":"_feeRecipient","type":"address"},{"internalType":"uint256","name":"_timePerTweet","type":"uint256"},{"internalType":"uint256","name":"_depositFee","type":"uint256"},{"internalType":"uint256","name":"_claimFee","type":"uint256"}],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"uint256","name":"id","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"},{"indexed":false,"internalType":"address","name":"recipient","type":"address"}],"name":"Claim","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"uint256","name":"id","type":"uint256"},{"indexed":false,"internalType":"address","name":"recipient","type":"address"}],"name":"DealAccepted","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"uint256","name":"id","type":"uint256"},{"indexed":false,"internalType":"address","name":"recipient","type":"address"},{"indexed":false,"internalType":"uint256","name":"newNumOfTweets","type":"uint256"}],"name":"DealUpdated","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"uint256","name":"id","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"numOfTweets","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"amountPerUser","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"acceptTimer","type":"uint256"},{"indexed":false,"internalType":"address","name":"token","type":"address"},{"indexed":false,"internalType":"address","name":"creator","type":"address"}],"name":"NewDeal","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"uint256","name":"","type":"uint256"},{"indexed":false,"internalType":"address","name":"creator","type":"address"},{"indexed":false,"internalType":"address","name":"recipient","type":"address"}],"name":"Refund","type":"event"},{"inputs":[],"name":"TIMELOCK","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"id","type":"uint256"}],"name":"acceptDeal","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"admin","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"id","type":"uint256"}],"name":"claim","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"claimFee","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"id","type":"uint256"},{"internalType":"uint256","name":"numOfTweets","type":"uint256"},{"internalType":"uint256","name":"amountPerUser","type":"uint256"},{"internalType":"uint256","name":"acceptTimer","type":"uint256"},{"internalType":"address","name":"token","type":"address"},{"internalType":"address[]","name":"recipients","type":"address[]"}],"name":"createDeal","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint256","name":"id","type":"uint256"}],"name":"deals","outputs":[{"internalType":"uint256","name":"id","type":"uint256"},{"internalType":"uint256","name":"numOfTweets","type":"uint256"},{"internalType":"uint256","name":"amountPerUser","type":"uint256"},{"internalType":"uint256","name":"claimedAmount","type":"uint256"},{"internalType":"uint256","name":"unlockedAmount","type":"uint256"},{"internalType":"uint256","name":"acceptTimer","type":"uint256"},{"internalType":"uint256","name":"completeTimer","type":"uint256"},{"internalType":"address","name":"token","type":"address"},{"internalType":"address","name":"creator","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"depositFee","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"feeRecipient","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"finalizeAdminUpdate","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint256","name":"id","type":"uint256"},{"internalType":"address","name":"recipient","type":"address"}],"name":"isDealRecipient","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"newAdmin","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"id","type":"uint256"}],"name":"recipientDeals","outputs":[{"internalType":"uint256","name":"id","type":"uint256"},{"internalType":"uint256","name":"numOfTweets","type":"uint256"},{"internalType":"uint256","name":"amountPerUser","type":"uint256"},{"internalType":"uint256","name":"claimedAmount","type":"uint256"},{"internalType":"uint256","name":"unlockedAmount","type":"uint256"},{"internalType":"uint256","name":"acceptTimer","type":"uint256"},{"internalType":"uint256","name":"completeTimer","type":"uint256"},{"internalType":"address","name":"token","type":"address"},{"internalType":"address","name":"creator","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"token","type":"address"},{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"recoverTokens","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint256","name":"id","type":"uint256"},{"internalType":"address","name":"recipient","type":"address"}],"name":"refund","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"stopAdminUpdate","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"timePerTweet","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"_newAdmin","type":"address"}],"name":"updateAdmin","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"updateAdminStart","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"id","type":"uint256"},{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"newNumOfTweets","type":"uint256"}],"name":"updateDeal","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint256[]","name":"dealsIds","type":"uint256[]"},{"internalType":"address","name":"oldRecipient","type":"address"},{"internalType":"address","name":"newRecipient","type":"address"}],"name":"updateDealsRecipients","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint256","name":"_depositFee","type":"uint256"},{"internalType":"uint256","name":"_claimFee","type":"uint256"}],"name":"updateFees","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint256","name":"_newTimePerTweet","type":"uint256"}],"name":"updateTimePerTweet","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address[]","name":"users","type":"address[]"},{"internalType":"bool","name":"status","type":"bool"}],"name":"updateWhitelist","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"whitelist","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"}]