diff --git a/scripts/FraxMultisigWallet-abi.json b/scripts/FraxMultisigWallet-abi.json new file mode 100644 index 0000000..ec3950e --- /dev/null +++ b/scripts/FraxMultisigWallet-abi.json @@ -0,0 +1,236 @@ +[ + { + "inputs": [ + { "internalType": "address[]", "name": "_owners", "type": "address[]" }, + { "internalType": "uint256", "name": "_required", "type": "uint256" } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "address", "name": "sender", "type": "address" }, + { "indexed": true, "internalType": "uint256", "name": "transactionId", "type": "uint256" } + ], + "name": "Confirmation", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": true, "internalType": "uint256", "name": "transactionId", "type": "uint256" }], + "name": "Execution", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": true, "internalType": "uint256", "name": "transactionId", "type": "uint256" }], + "name": "ExecutionFailure", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": true, "internalType": "address", "name": "owner", "type": "address" }], + "name": "OwnerAddition", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": true, "internalType": "address", "name": "owner", "type": "address" }], + "name": "OwnerRemoval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": false, "internalType": "uint256", "name": "required", "type": "uint256" }], + "name": "RequirementChange", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "address", "name": "sender", "type": "address" }, + { "indexed": true, "internalType": "uint256", "name": "transactionId", "type": "uint256" } + ], + "name": "Revocation", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": true, "internalType": "uint256", "name": "transactionId", "type": "uint256" }], + "name": "Submission", + "type": "event" + }, + { "stateMutability": "payable", "type": "fallback" }, + { + "inputs": [], + "name": "MAX_OWNER_COUNT", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "owner", "type": "address" }], + "name": "addOwner", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "uint256", "name": "_required", "type": "uint256" }], + "name": "changeRequirement", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "uint256", "name": "transactionId", "type": "uint256" }], + "name": "confirmTransaction", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "", "type": "uint256" }, + { "internalType": "address", "name": "", "type": "address" } + ], + "name": "confirmations", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "uint256", "name": "transactionId", "type": "uint256" }], + "name": "executeTransaction", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "uint256", "name": "transactionId", "type": "uint256" }], + "name": "getConfirmationCount", + "outputs": [{ "internalType": "uint256", "name": "count", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "uint256", "name": "transactionId", "type": "uint256" }], + "name": "getConfirmations", + "outputs": [{ "internalType": "address[]", "name": "_confirmations", "type": "address[]" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getOwners", + "outputs": [{ "internalType": "address[]", "name": "", "type": "address[]" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "bool", "name": "pending", "type": "bool" }, + { "internalType": "bool", "name": "executed", "type": "bool" } + ], + "name": "getTransactionCount", + "outputs": [{ "internalType": "uint256", "name": "count", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "from", "type": "uint256" }, + { "internalType": "uint256", "name": "to", "type": "uint256" }, + { "internalType": "bool", "name": "pending", "type": "bool" }, + { "internalType": "bool", "name": "executed", "type": "bool" } + ], + "name": "getTransactionIds", + "outputs": [{ "internalType": "uint256[]", "name": "_transactionIds", "type": "uint256[]" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "uint256", "name": "transactionId", "type": "uint256" }], + "name": "isConfirmed", + "outputs": [{ "internalType": "bool", "name": "yes", "type": "bool" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "", "type": "address" }], + "name": "isOwner", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "name": "owners", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "owner", "type": "address" }], + "name": "removeOwner", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "owner", "type": "address" }, + { "internalType": "address", "name": "newOwner", "type": "address" } + ], + "name": "replaceOwner", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "required", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "uint256", "name": "transactionId", "type": "uint256" }], + "name": "revokeConfirmation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "destination", "type": "address" }, + { "internalType": "uint256", "name": "value", "type": "uint256" }, + { "internalType": "bytes", "name": "data", "type": "bytes" } + ], + "name": "submitTransaction", + "outputs": [{ "internalType": "uint256", "name": "transactionId", "type": "uint256" }], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "transactionCount", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "name": "transactions", + "outputs": [ + { "internalType": "address", "name": "destination", "type": "address" }, + { "internalType": "uint256", "name": "value", "type": "uint256" }, + { "internalType": "bytes", "name": "data", "type": "bytes" }, + { "internalType": "bool", "name": "executed", "type": "bool" } + ], + "stateMutability": "view", + "type": "function" + }, + { "stateMutability": "payable", "type": "receive" } +] diff --git a/scripts/msig-txs.js b/scripts/msig-txs.js new file mode 100644 index 0000000..8458c2c --- /dev/null +++ b/scripts/msig-txs.js @@ -0,0 +1,65 @@ +const BigNumber = require("bignumber.js"); +const TronWeb = require("tronweb"); +const { ethers } = require("ethers"); + +// NOTE: "@noble/secp256k1" needs to be @ "1.7.1" +// TODO: RM PK +const tronWeb = new TronWeb({ + fullHost: "https://api.trongrid.io", + headers: { "TRON-PRO-API-KEY": process.env.TRONGRID_API_KEY }, + privateKey: process.env.TRON_PK, // as hex string without the 0x +}); + +// let fraxAbi = require("./ERC20PermissionedMint-abi.json"); +let msigAbi = require("./FraxMultisigWallet-abi.json"); + +let fraxAddress = "TQZTkTMbkC9923LtVHZcSrdqcW5rVhkZHP"; +let msigAddress = "TWtG5rwf67UUkywcZvF1c24MXXRD8LDBjf"; +let deployerAddress = "TLNe6KF1dUSYBcZ4fzTstoKB8bkzQewz42"; + +async function load() { + // let frax = await tronWeb.contract(fraxAbi, fraxAddress); + let msig = await tronWeb.contract(msigAbi, msigAddress); + return msig; +} + +async function transfer() { + let msig = await load(); + let abiCoder = ethers.AbiCoder.defaultAbiCoder(); + let selector = "0xa9059cbb"; + + let args = process.argv.slice(2); + if (args.length != 3) { + console.log("arguments passed should be `{token symbol} {amount} {decimals}`"); + return; + } + + let erc20; + let recipient; + + let token = args[0].toUpperCase(); + if (token === "FRAX") { + erc20 = fraxAddress; + recipient = deployerAddress; // TODO: should be fraxFerry + } else { + console.log("argument 0 should be one of ['frax', 'sfrx', 'fxs']"); + return; + } + let recipientEncoded = "0000000000000000000000".concat(tronWeb.address.toHex(deployerAddress)); + + // imported values + let amount = parseInt(args[1], 10); + let numZeros = parseInt(args[2], 10); + let amountWithZeros = amount * 10 ** numZeros; + + let amountEncoded = abiCoder.encode(["uint"], [amountWithZeros.toString()]).substr(2); // rm the "0x" + + let payload = selector.concat(recipientEncoded).concat(amountEncoded); + + console.log(`Submit Transfer of ${amount} * 10**${numZeros} ${token} to FraxFerry ${recipient}`); + await msig.submitTransaction(erc20, 0, payload).send(); +} + +(async function () { + await transfer(); +})(); diff --git a/src/contracts/FraxFerry.sol b/src/contracts/FraxFerry.sol index d137565..97ffe89 100644 --- a/src/contracts/FraxFerry.sol +++ b/src/contracts/FraxFerry.sol @@ -18,349 +18,371 @@ pragma solidity >=0.8.0; // Dennis: https://github.com/denett /* -** Modus operandi: -** - User sends tokens to the contract. This transaction is stored in the contract. -** - Captain queries the source chain for transactions to ship. -** - Captain sends batch (start, end, hash) to start the trip, -** - Crewmembers check the batch and can dispute it if it is invalid. -** - Non disputed batches can be executed by the first officer by providing the transactions as calldata. -** - Hash of the transactions must be equal to the hash in the batch. User receives their tokens on the other chain. -** - In case there was a fraudulent transaction (a hacker for example), the owner can cancel a single transaction, such that it will not be executed. -** - The owner can manually manage the tokens in the contract and must make sure it has enough funds. -** -** What must happen for a false batch to be executed: -** - Captain is tricked into proposing a batch with a false hash -** - All crewmembers bots are offline/censured/compromised and no one disputes the proposal -** -** Other risks: -** - Reorgs on the source chain. Avoided, by only returning the transactions on the source chain that are at least one hour old. -** - Rollbacks of optimistic rollups. Avoided by running a node. -** - Operators do not have enough time to pause the chain after a fake proposal. Avoided by requiring a minimal amount of time between sending the proposal and executing it. -*/ + ** Modus operandi: + ** - User sends tokens to the contract. This transaction is stored in the contract. + ** - Captain queries the source chain for transactions to ship. + ** - Captain sends batch (start, end, hash) to start the trip, + ** - Crewmembers check the batch and can dispute it if it is invalid. + ** - Non disputed batches can be executed by the first officer by providing the transactions as calldata. + ** - Hash of the transactions must be equal to the hash in the batch. User receives their tokens on the other chain. + ** - In case there was a fraudulent transaction (a hacker for example), the owner can cancel a single transaction, such that it will not be executed. + ** - The owner can manually manage the tokens in the contract and must make sure it has enough funds. + ** + ** What must happen for a false batch to be executed: + ** - Captain is tricked into proposing a batch with a false hash + ** - All crewmembers bots are offline/censured/compromised and no one disputes the proposal + ** + ** Other risks: + ** - Reorgs on the source chain. Avoided, by only returning the transactions on the source chain that are at least one hour old. + ** - Rollbacks of optimistic rollups. Avoided by running a node. + ** - Operators do not have enough time to pause the chain after a fake proposal. Avoided by requiring a minimal amount of time between sending the proposal and executing it. + */ import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; import "@openzeppelin/contracts/utils/math/Math.sol"; import "@uniswap/v3-periphery/contracts/libraries/TransferHelper.sol"; contract Fraxferry { - IERC20 immutable public token; - IERC20 immutable public targetToken; - uint immutable public chainid; - uint immutable public targetChain; - - address public owner; - address public nominatedOwner; - address public captain; - address public firstOfficer; - mapping(address => bool) public crewmembers; - mapping(address => bool) public fee_exempt_addrs; - - bool public paused; - - uint public MIN_WAIT_PERIOD_ADD=3600; // Minimal 1 hour waiting - uint public MIN_WAIT_PERIOD_EXECUTE=79200; // Minimal 22 hour waiting - uint public FEE_RATE=10; // 0.1% fee - uint public FEE_MIN=5*1e18; // 5 token min fee - uint public FEE_MAX=100*1e18; // 100 token max fee - - uint constant MAX_FEE_RATE=100; // Max fee rate is 1% - uint constant MAX_FEE_MIN=100e18; // Max minimum fee is 100 tokens - uint constant MAX_FEE_MAX=1000e18; // Max fee is 1000 tokens - - uint constant public REDUCED_DECIMALS=1e10; - - Transaction[] public transactions; - mapping(uint => bool) public cancelled; - uint public executeIndex; - Batch[] public batches; - - struct Transaction { - address user; - uint64 amount; - uint32 timestamp; - } - - struct Batch { - uint64 start; - uint64 end; - uint64 departureTime; - uint64 status; - bytes32 hash; - } - - struct BatchData { - uint startTransactionNo; - Transaction[] transactions; - } - - constructor(address _token, uint _chainid, address _targetToken, uint _targetChain) { - //require (block.chainid==_chainid,"Wrong chain"); - chainid=_chainid; - token = IERC20(_token); - targetToken = IERC20(_targetToken); - owner = msg.sender; - targetChain = _targetChain; - } - - - // ############## Events ############## - - event Embark(address indexed sender, uint index, uint amount, uint amountAfterFee, uint timestamp); - event Disembark(uint start, uint end, bytes32 hash); - event Depart(uint batchNo,uint start,uint end,bytes32 hash); - event RemoveBatch(uint batchNo); - event DisputeBatch(uint batchNo, bytes32 hash); - event Cancelled(uint index, bool cancel); - event Pause(bool paused); - event OwnerNominated(address indexed newOwner); - event OwnerChanged(address indexed previousOwner,address indexed newOwner); - event SetCaptain(address indexed previousCaptain, address indexed newCaptain); - event SetFirstOfficer(address indexed previousFirstOfficer, address indexed newFirstOfficer); - event SetCrewmember(address indexed crewmember,bool set); - event SetFee(uint previousFeeRate, uint feeRate,uint previousFeeMin, uint feeMin,uint previousFeeMax, uint feeMax); - event SetMinWaitPeriods(uint previousMinWaitAdd,uint previousMinWaitExecute,uint minWaitAdd,uint minWaitExecute); - event FeeExemptToggled(address addr,bool is_fee_exempt); - - - // ############## Modifiers ############## - - modifier isOwner() { - require (msg.sender==owner,"Not owner"); - _; - } - - modifier isCaptain() { - require (msg.sender==captain,"Not captain"); - _; - } - - modifier isFirstOfficer() { - require (msg.sender==firstOfficer,"Not first officer"); - _; - } - - modifier isCrewmember() { - require (crewmembers[msg.sender] || msg.sender==owner || msg.sender==captain || msg.sender==firstOfficer,"Not crewmember"); - _; - } - - modifier notPaused() { - require (!paused,"Paused"); - _; - } - - // ############## Ferry actions ############## - - function embarkWithRecipient(uint amount, address recipient) public notPaused { - amount = (amount/REDUCED_DECIMALS)*REDUCED_DECIMALS; // Round amount to fit in data structure - uint fee; - if(fee_exempt_addrs[msg.sender]) fee = 0; - else { - fee = Math.min(Math.max(FEE_MIN,amount*FEE_RATE/10000),FEE_MAX); - } - require (amount>fee,"Amount too low"); - require (amount/REDUCED_DECIMALS<=type(uint64).max,"Amount too high"); - TransferHelper.safeTransferFrom(address(token),msg.sender,address(this),amount); - uint64 amountAfterFee = uint64((amount-fee)/REDUCED_DECIMALS); - emit Embark(recipient,transactions.length,amount,amountAfterFee*REDUCED_DECIMALS,block.timestamp); - transactions.push(Transaction(recipient,amountAfterFee,uint32(block.timestamp))); - } - - function embark(uint amount) public { - embarkWithRecipient(amount, msg.sender) ; - } - - function embarkWithSignature( - uint256 _amount, - address recipient, - uint256 deadline, - bool approveMax, - uint8 v, - bytes32 r, - bytes32 s - ) public { - uint amount = approveMax ? type(uint256).max : _amount; - IERC20Permit(address(token)).permit(msg.sender, address(this), amount, deadline, v, r, s); - embarkWithRecipient(amount,recipient); - } - - function depart(uint start, uint end, bytes32 hash) external notPaused isCaptain { - require ((batches.length==0 && start==0) || (batches.length>0 && start==batches[batches.length-1].end+1),"Wrong start"); - require (end>=start && end=MIN_WAIT_PERIOD_EXECUTE,"Too soon"); - - bytes32 hash = keccak256(abi.encodePacked(targetChain, targetToken, chainid, token, batch.start)); - for (uint i=0;ibatchNo) batches.pop(); - emit RemoveBatch(batchNo); - } - - function disputeBatch(uint batchNo, bytes32 hash) external isCrewmember { - require (batches[batchNo].hash==hash,"Wrong hash"); - require (executeIndex<=batchNo,"Batch already executed"); - require (batches[batchNo].status==0,"Batch already disputed"); - batches[batchNo].status=1; // Set status on disputed - _pause(true); - emit DisputeBatch(batchNo,hash); - } - - function pause() external isCrewmember { - _pause(true); - } - - function unPause() external isOwner { - _pause(false); - } - - function _pause(bool _paused) internal { - paused=_paused; - emit Pause(_paused); - } - - function _jettison(uint index, bool cancel) internal { - require (executeIndex==0 || index>batches[executeIndex-1].end,"Transaction already executed"); - cancelled[index]=cancel; - emit Cancelled(index,cancel); - } - - function jettison(uint index, bool cancel) external isOwner { - _jettison(index,cancel); - } - - function jettisonGroup(uint[] calldata indexes, bool cancel) external isOwner { - for (uint i=0;i=3600 && _MIN_WAIT_PERIOD_EXECUTE>=3600,"Period too short"); - emit SetMinWaitPeriods(MIN_WAIT_PERIOD_ADD, MIN_WAIT_PERIOD_EXECUTE,_MIN_WAIT_PERIOD_ADD, _MIN_WAIT_PERIOD_EXECUTE); - MIN_WAIT_PERIOD_ADD=_MIN_WAIT_PERIOD_ADD; - MIN_WAIT_PERIOD_EXECUTE=_MIN_WAIT_PERIOD_EXECUTE; - } - - // ############## Roles management ############## - - function nominateNewOwner(address newOwner) external isOwner { - nominatedOwner = newOwner; - emit OwnerNominated(newOwner); - } - - function acceptOwnership() external { - require(msg.sender == nominatedOwner, "You must be nominated before you can accept ownership"); - emit OwnerChanged(owner, nominatedOwner); - owner = nominatedOwner; - nominatedOwner = address(0); - } - - function setCaptain(address newCaptain) external isOwner { - emit SetCaptain(captain,newCaptain); - captain=newCaptain; - } - - function setFirstOfficer(address newFirstOfficer) external isOwner { - emit SetFirstOfficer(firstOfficer,newFirstOfficer); - firstOfficer=newFirstOfficer; - } - - function setCrewmember(address crewmember, bool set) external isOwner { - crewmembers[crewmember]=set; - emit SetCrewmember(crewmember,set); - } - - function toggleFeeExemptAddr(address addr) external isOwner { - fee_exempt_addrs[addr] = !fee_exempt_addrs[addr]; - emit FeeExemptToggled(addr,fee_exempt_addrs[addr]); - } - - - // ############## Token management ############## - - function sendTokens(address receiver, uint amount) external isOwner { - require (receiver!=address(0),"Zero address not allowed"); - TransferHelper.safeTransfer(address(token),receiver,amount); - } - - // Generic proxy - function execute(address _to, uint256 _value, bytes calldata _data) external isOwner returns (bool, bytes memory) { - require(_data.length==0 || _to.code.length>0,"Can not call a function on a EOA"); - (bool success, bytes memory result) = _to.call{value:_value}(_data); - return (success, result); - } - - // ############## Views ############## - function getNextBatch(uint _start, uint max) public view returns (uint start, uint end, bytes32 hash) { - uint cutoffTime = block.timestamp-MIN_WAIT_PERIOD_ADD; - if (_start=transactions.length) end=transactions.length-1; - while(transactions[end].timestamp>=cutoffTime) end--; - hash = getTransactionsHash(start,end); - } - } - - function getBatchData(uint start, uint end) public view returns (BatchData memory data) { - data.startTransactionNo = start; - data.transactions = new Transaction[](end-start+1); - for (uint i=start;i<=end;++i) { - data.transactions[i-start]=transactions[i]; - } - } - - function getBatchAmount(uint start, uint end) public view returns (uint totalAmount) { - for (uint i=start;i<=end;++i) { - totalAmount+=transactions[i].amount; - } - totalAmount*=REDUCED_DECIMALS; - } - - function getTransactionsHash(uint start, uint end) public view returns (bytes32) { - bytes32 result = keccak256(abi.encodePacked(chainid, token, targetChain, targetToken, uint64(start))); - for (uint i=start;i<=end;++i) { - result = keccak256(abi.encodePacked(result, transactions[i].user,transactions[i].amount)); - } - return result; - } - - function noTransactions() public view returns (uint) { - return transactions.length; - } - - function noBatches() public view returns (uint) { - return batches.length; - } -} \ No newline at end of file + IERC20 public immutable token; + IERC20 public immutable targetToken; + uint256 public immutable chainid; + uint256 public immutable targetChain; + + address public owner; + address public nominatedOwner; + address public captain; + address public firstOfficer; + mapping(address => bool) public crewmembers; + mapping(address => bool) public fee_exempt_addrs; + + bool public paused; + + uint256 public MIN_WAIT_PERIOD_ADD = 3600; // Minimal 1 hour waiting + uint256 public MIN_WAIT_PERIOD_EXECUTE = 79_200; // Minimal 22 hour waiting + uint256 public FEE_RATE = 10; // 0.1% fee + uint256 public FEE_MIN = 5 * 1e18; // 5 token min fee + uint256 public FEE_MAX = 100 * 1e18; // 100 token max fee + + uint256 constant MAX_FEE_RATE = 100; // Max fee rate is 1% + uint256 constant MAX_FEE_MIN = 100e18; // Max minimum fee is 100 tokens + uint256 constant MAX_FEE_MAX = 1000e18; // Max fee is 1000 tokens + + uint256 public constant REDUCED_DECIMALS = 1e10; + + Transaction[] public transactions; + mapping(uint256 => bool) public cancelled; + uint256 public executeIndex; + Batch[] public batches; + + struct Transaction { + address user; + uint64 amount; + uint32 timestamp; + } + + struct Batch { + uint64 start; + uint64 end; + uint64 departureTime; + uint64 status; + bytes32 hash; + } + + struct BatchData { + uint256 startTransactionNo; + Transaction[] transactions; + } + + constructor(address _token, uint256 _chainid, address _targetToken, uint256 _targetChain) { + //require (block.chainid==_chainid,"Wrong chain"); + chainid = _chainid; + token = IERC20(_token); + targetToken = IERC20(_targetToken); + owner = msg.sender; + targetChain = _targetChain; + } + + // ############## Events ############## + + event Embark(address indexed sender, uint256 index, uint256 amount, uint256 amountAfterFee, uint256 timestamp); + event Disembark(uint256 start, uint256 end, bytes32 hash); + event Depart(uint256 batchNo, uint256 start, uint256 end, bytes32 hash); + event RemoveBatch(uint256 batchNo); + event DisputeBatch(uint256 batchNo, bytes32 hash); + event Cancelled(uint256 index, bool cancel); + event Pause(bool paused); + event OwnerNominated(address indexed newOwner); + event OwnerChanged(address indexed previousOwner, address indexed newOwner); + event SetCaptain(address indexed previousCaptain, address indexed newCaptain); + event SetFirstOfficer(address indexed previousFirstOfficer, address indexed newFirstOfficer); + event SetCrewmember(address indexed crewmember, bool set); + event SetFee( + uint256 previousFeeRate, + uint256 feeRate, + uint256 previousFeeMin, + uint256 feeMin, + uint256 previousFeeMax, + uint256 feeMax + ); + event SetMinWaitPeriods( + uint256 previousMinWaitAdd, + uint256 previousMinWaitExecute, + uint256 minWaitAdd, + uint256 minWaitExecute + ); + event FeeExemptToggled(address addr, bool is_fee_exempt); + + // ############## Modifiers ############## + + modifier isOwner() { + require(msg.sender == owner, "Not owner"); + _; + } + + modifier isCaptain() { + require(msg.sender == captain, "Not captain"); + _; + } + + modifier isFirstOfficer() { + require(msg.sender == firstOfficer, "Not first officer"); + _; + } + + modifier isCrewmember() { + require( + crewmembers[msg.sender] || msg.sender == owner || msg.sender == captain || msg.sender == firstOfficer, + "Not crewmember" + ); + _; + } + + modifier notPaused() { + require(!paused, "Paused"); + _; + } + + // ############## Ferry actions ############## + + function embarkWithRecipient(uint256 amount, address recipient) public notPaused { + amount = (amount / REDUCED_DECIMALS) * REDUCED_DECIMALS; // Round amount to fit in data structure + uint256 fee; + if (fee_exempt_addrs[msg.sender]) fee = 0; + else fee = Math.min(Math.max(FEE_MIN, (amount * FEE_RATE) / 10_000), FEE_MAX); + require(amount > fee, "Amount too low"); + require(amount / REDUCED_DECIMALS <= type(uint64).max, "Amount too high"); + TransferHelper.safeTransferFrom(address(token), msg.sender, address(this), amount); + uint64 amountAfterFee = uint64((amount - fee) / REDUCED_DECIMALS); + emit Embark(recipient, transactions.length, amount, amountAfterFee * REDUCED_DECIMALS, block.timestamp); + transactions.push(Transaction(recipient, amountAfterFee, uint32(block.timestamp))); + } + + function embark(uint256 amount) public { + embarkWithRecipient(amount, msg.sender); + } + + function embarkWithSignature( + uint256 _amount, + address recipient, + uint256 deadline, + bool approveMax, + uint8 v, + bytes32 r, + bytes32 s + ) public { + uint256 amount = approveMax ? type(uint256).max : _amount; + IERC20Permit(address(token)).permit(msg.sender, address(this), amount, deadline, v, r, s); + embarkWithRecipient(amount, recipient); + } + + function depart(uint256 start, uint256 end, bytes32 hash) external notPaused isCaptain { + require( + (batches.length == 0 && start == 0) || (batches.length > 0 && start == batches[batches.length - 1].end + 1), + "Wrong start" + ); + require(end >= start && end < type(uint64).max, "Wrong end"); + batches.push(Batch(uint64(start), uint64(end), uint64(block.timestamp), 0, hash)); + emit Depart(batches.length - 1, start, end, hash); + } + + function disembark(BatchData calldata batchData) external notPaused isFirstOfficer { + Batch memory batch = batches[executeIndex++]; + require(batch.status == 0, "Batch disputed"); + require(batch.start == batchData.startTransactionNo, "Wrong start"); + require(batch.start + batchData.transactions.length - 1 == batch.end, "Wrong size"); + require(block.timestamp - batch.departureTime >= MIN_WAIT_PERIOD_EXECUTE, "Too soon"); + + bytes32 hash = keccak256(abi.encodePacked(targetChain, targetToken, chainid, token, batch.start)); + for (uint256 i = 0; i < batchData.transactions.length; ++i) { + if (!cancelled[batch.start + i]) { + TransferHelper.safeTransfer( + address(token), + batchData.transactions[i].user, + batchData.transactions[i].amount * REDUCED_DECIMALS + ); + } + hash = keccak256(abi.encodePacked(hash, batchData.transactions[i].user, batchData.transactions[i].amount)); + } + require(batch.hash == hash, "Wrong hash"); + emit Disembark(batch.start, batch.end, hash); + } + + function removeBatches(uint256 batchNo) external isOwner { + require(executeIndex <= batchNo, "Batch already executed"); + while (batches.length > batchNo) batches.pop(); + emit RemoveBatch(batchNo); + } + + function disputeBatch(uint256 batchNo, bytes32 hash) external isCrewmember { + require(batches[batchNo].hash == hash, "Wrong hash"); + require(executeIndex <= batchNo, "Batch already executed"); + require(batches[batchNo].status == 0, "Batch already disputed"); + batches[batchNo].status = 1; // Set status on disputed + _pause(true); + emit DisputeBatch(batchNo, hash); + } + + function pause() external isCrewmember { + _pause(true); + } + + function unPause() external isOwner { + _pause(false); + } + + function _pause(bool _paused) internal { + paused = _paused; + emit Pause(_paused); + } + + function _jettison(uint256 index, bool cancel) internal { + require(executeIndex == 0 || index > batches[executeIndex - 1].end, "Transaction already executed"); + cancelled[index] = cancel; + emit Cancelled(index, cancel); + } + + function jettison(uint256 index, bool cancel) external isOwner { + _jettison(index, cancel); + } + + function jettisonGroup(uint256[] calldata indexes, bool cancel) external isOwner { + for (uint256 i = 0; i < indexes.length; ++i) { + _jettison(indexes[i], cancel); + } + } + + // ############## Parameters management ############## + + function setFee(uint256 _FEE_RATE, uint256 _FEE_MIN, uint256 _FEE_MAX) external isOwner { + require(_FEE_RATE < MAX_FEE_RATE); + require(_FEE_MIN < MAX_FEE_MIN); + require(_FEE_MAX < MAX_FEE_MAX); + emit SetFee(FEE_RATE, _FEE_RATE, FEE_MIN, _FEE_MIN, FEE_MAX, _FEE_MAX); + FEE_RATE = _FEE_RATE; + FEE_MIN = _FEE_MIN; + FEE_MAX = _FEE_MAX; + } + + function setMinWaitPeriods(uint256 _MIN_WAIT_PERIOD_ADD, uint256 _MIN_WAIT_PERIOD_EXECUTE) external isOwner { + require(_MIN_WAIT_PERIOD_ADD >= 3600 && _MIN_WAIT_PERIOD_EXECUTE >= 3600, "Period too short"); + emit SetMinWaitPeriods( + MIN_WAIT_PERIOD_ADD, + MIN_WAIT_PERIOD_EXECUTE, + _MIN_WAIT_PERIOD_ADD, + _MIN_WAIT_PERIOD_EXECUTE + ); + MIN_WAIT_PERIOD_ADD = _MIN_WAIT_PERIOD_ADD; + MIN_WAIT_PERIOD_EXECUTE = _MIN_WAIT_PERIOD_EXECUTE; + } + + // ############## Roles management ############## + + function nominateNewOwner(address newOwner) external isOwner { + nominatedOwner = newOwner; + emit OwnerNominated(newOwner); + } + + function acceptOwnership() external { + require(msg.sender == nominatedOwner, "You must be nominated before you can accept ownership"); + emit OwnerChanged(owner, nominatedOwner); + owner = nominatedOwner; + nominatedOwner = address(0); + } + + function setCaptain(address newCaptain) external isOwner { + emit SetCaptain(captain, newCaptain); + captain = newCaptain; + } + + function setFirstOfficer(address newFirstOfficer) external isOwner { + emit SetFirstOfficer(firstOfficer, newFirstOfficer); + firstOfficer = newFirstOfficer; + } + + function setCrewmember(address crewmember, bool set) external isOwner { + crewmembers[crewmember] = set; + emit SetCrewmember(crewmember, set); + } + + function toggleFeeExemptAddr(address addr) external isOwner { + fee_exempt_addrs[addr] = !fee_exempt_addrs[addr]; + emit FeeExemptToggled(addr, fee_exempt_addrs[addr]); + } + + // ############## Token management ############## + + function sendTokens(address receiver, uint256 amount) external isOwner { + require(receiver != address(0), "Zero address not allowed"); + TransferHelper.safeTransfer(address(token), receiver, amount); + } + + // Generic proxy + function execute(address _to, uint256 _value, bytes calldata _data) external isOwner returns (bool, bytes memory) { + require(_data.length == 0 || _to.code.length > 0, "Can not call a function on a EOA"); + (bool success, bytes memory result) = _to.call{ value: _value }(_data); + return (success, result); + } + + // ############## Views ############## + function getNextBatch(uint256 _start, uint256 max) public view returns (uint256 start, uint256 end, bytes32 hash) { + uint256 cutoffTime = block.timestamp - MIN_WAIT_PERIOD_ADD; + if (_start < transactions.length && transactions[_start].timestamp < cutoffTime) { + start = _start; + end = start + max - 1; + if (end >= transactions.length) end = transactions.length - 1; + while (transactions[end].timestamp >= cutoffTime) end--; + hash = getTransactionsHash(start, end); + } + } + + function getBatchData(uint256 start, uint256 end) public view returns (BatchData memory data) { + data.startTransactionNo = start; + data.transactions = new Transaction[](end - start + 1); + for (uint256 i = start; i <= end; ++i) { + data.transactions[i - start] = transactions[i]; + } + } + + function getBatchAmount(uint256 start, uint256 end) public view returns (uint256 totalAmount) { + for (uint256 i = start; i <= end; ++i) { + totalAmount += transactions[i].amount; + } + totalAmount *= REDUCED_DECIMALS; + } + + function getTransactionsHash(uint256 start, uint256 end) public view returns (bytes32) { + bytes32 result = keccak256(abi.encodePacked(chainid, token, targetChain, targetToken, uint64(start))); + for (uint256 i = start; i <= end; ++i) { + result = keccak256(abi.encodePacked(result, transactions[i].user, transactions[i].amount)); + } + return result; + } + + function noTransactions() public view returns (uint256) { + return transactions.length; + } + + function noBatches() public view returns (uint256) { + return batches.length; + } +} diff --git a/src/contracts/FraxMultiSigWallet.sol b/src/contracts/FraxMultiSigWallet.sol new file mode 100644 index 0000000..eb45d0d --- /dev/null +++ b/src/contracts/FraxMultiSigWallet.sol @@ -0,0 +1,365 @@ +pragma solidity ^0.8.20; + +/// @title Multisignature wallet - Allows multiple parties to agree on transactions before execution. +/// @author Stefan George - +/// @author Frax Finance - https://github.com/FraxFinance +contract FraxMultiSigWallet { + /* + * Events + */ + event Confirmation(address indexed sender, uint256 indexed transactionId); + event Revocation(address indexed sender, uint256 indexed transactionId); + event Submission(uint256 indexed transactionId); + event Execution(uint256 indexed transactionId); + event ExecutionFailure(uint256 indexed transactionId); + event OwnerAddition(address indexed owner); + event OwnerRemoval(address indexed owner); + event RequirementChange(uint256 required); + + /* + * Constants + */ + uint256 public constant MAX_OWNER_COUNT = 50; + + /* + * Storage + */ + mapping(uint256 => Transaction) public transactions; + mapping(uint256 => mapping(address => bool)) public confirmations; + mapping(address => bool) public isOwner; + address[] public owners; + uint256 public required; + uint256 public transactionCount; + + struct Transaction { + address destination; + uint256 value; + bytes data; + bool executed; + } + + /* + * Modifiers + */ + modifier onlyWallet() { + require(msg.sender == address(this), "oW"); + _; + } + + modifier ownerDoesNotExist(address owner) { + require(!isOwner[owner], "oDNE"); + _; + } + + modifier ownerExists(address owner) { + require(isOwner[owner], "oE"); + _; + } + + modifier transactionExists(uint256 transactionId) { + require(transactions[transactionId].destination != address(0), "tE"); + _; + } + + modifier confirmed(uint256 transactionId, address owner) { + require(confirmations[transactionId][owner], "c"); + _; + } + + modifier notConfirmed(uint256 transactionId, address owner) { + require(!confirmations[transactionId][owner], "nC"); + _; + } + + modifier notExecuted(uint256 transactionId) { + require(!transactions[transactionId].executed, "nE"); + _; + } + + modifier notNull(address _address) { + require(_address != address(0), "nN"); + _; + } + + modifier validRequirement(uint256 ownerCount, uint256 _required) { + require(ownerCount <= MAX_OWNER_COUNT && _required <= ownerCount && _required != 0 && ownerCount != 0, "vR"); + _; + } + + receive() external payable {} + + fallback() external payable {} + + /* + * Public functions + */ + /// @dev Contract constructor sets initial owners and required number of confirmations. + /// @param _owners List of initial owners. + /// @param _required Number of required confirmations. + constructor(address[] memory _owners, uint256 _required) { + require( + _owners.length <= MAX_OWNER_COUNT && _required <= _owners.length && _required != 0 && _owners.length != 0, + "vR" + ); + for (uint256 i = 0; i < _owners.length; ) { + require(!isOwner[_owners[i]] && _owners[i] != address(0)); + isOwner[_owners[i]] = true; + unchecked { + ++i; + } + } + owners = _owners; + required = _required; + } + + /// @dev Allows to add a new owner. Transaction has to be sent by wallet. + /// @param owner Address of new owner. + function addOwner( + address owner + ) public onlyWallet ownerDoesNotExist(owner) notNull(owner) validRequirement(owners.length + 1, required) { + isOwner[owner] = true; + owners.push(owner); + emit OwnerAddition(owner); + } + + /// @dev Allows to remove an owner. Transaction has to be sent by wallet. + /// @param owner Address of owner. + function removeOwner(address owner) public onlyWallet ownerExists(owner) { + isOwner[owner] = false; + for (uint256 i = 0; i < owners.length - 1; ) { + if (owners[i] == owner) { + owners[i] = owners[owners.length - 1]; + owners.pop(); + break; + } + unchecked { + ++i; + } + } + if (required > owners.length) { + changeRequirement(owners.length); + } + emit OwnerRemoval(owner); + } + + /// @dev Allows to replace an owner with a new owner. Transaction has to be sent by wallet. + /// @param owner Address of owner to be replaced. + /// @param newOwner Address of new owner. + function replaceOwner( + address owner, + address newOwner + ) public onlyWallet ownerExists(owner) ownerDoesNotExist(newOwner) { + for (uint256 i = 0; i < owners.length; ) { + if (owners[i] == owner) { + owners[i] = newOwner; + break; + } + unchecked { + ++i; + } + } + isOwner[owner] = false; + isOwner[newOwner] = true; + emit OwnerRemoval(owner); + emit OwnerAddition(newOwner); + } + + /// @dev Allows to change the number of required confirmations. Transaction has to be sent by wallet. + /// @param _required Number of required confirmations. + function changeRequirement(uint256 _required) public onlyWallet validRequirement(owners.length, _required) { + required = _required; + emit RequirementChange(_required); + } + + /// @dev Allows an owner to submit and confirm a transaction. + /// @param destination Transaction target address. + /// @param value Transaction ether value. + /// @param data Transaction data payload. + /// @return transactionId Returns transaction ID. + function submitTransaction( + address destination, + uint256 value, + bytes calldata data + ) public returns (uint256 transactionId) { + transactionId = addTransaction(destination, value, data); + confirmTransaction(transactionId); + } + + /// @dev Allows an owner to confirm a transaction. + /// @param transactionId Transaction ID. + function confirmTransaction( + uint256 transactionId + ) public ownerExists(msg.sender) transactionExists(transactionId) notConfirmed(transactionId, msg.sender) { + confirmations[transactionId][msg.sender] = true; + emit Confirmation(msg.sender, transactionId); + executeTransaction(transactionId); + } + + /// @dev Allows an owner to revoke a confirmation for a transaction. + /// @param transactionId Transaction ID. + function revokeConfirmation( + uint256 transactionId + ) public ownerExists(msg.sender) confirmed(transactionId, msg.sender) notExecuted(transactionId) { + confirmations[transactionId][msg.sender] = false; + emit Revocation(msg.sender, transactionId); + } + + /// @dev Allows anyone to execute a confirmed transaction. + /// @param transactionId Transaction ID. + function executeTransaction( + uint256 transactionId + ) public ownerExists(msg.sender) confirmed(transactionId, msg.sender) notExecuted(transactionId) { + if (isConfirmed(transactionId)) { + Transaction storage txn = transactions[transactionId]; + txn.executed = true; + (bool success, ) = txn.destination.call{ value: txn.value }(txn.data); + if (success) { + emit Execution(transactionId); + } else { + emit ExecutionFailure(transactionId); + txn.executed = false; + } + } + } + + /// @dev Returns the confirmation status of a transaction. + /// @param transactionId Transaction ID. + /// @return yes Confirmation status. + function isConfirmed(uint256 transactionId) public view returns (bool yes) { + uint256 count = 0; + uint256 length = owners.length; + for (uint256 i = 0; i < length; ) { + if (confirmations[transactionId][owners[i]]) { + count += 1; + } + if (count == required) { + yes = true; + break; + } + + unchecked { + ++i; + } + } + } + + /* + * Internal functions + */ + /// @dev Adds a new transaction to the transaction mapping, if transaction does not exist yet. + /// @param destination Transaction target address. + /// @param value Transaction ether value. + /// @param data Transaction data payload. + /// @return transactionId + function addTransaction( + address destination, + uint256 value, + bytes calldata data + ) internal notNull(destination) returns (uint256 transactionId) { + transactionId = transactionCount; + transactions[transactionId] = Transaction({ + destination: destination, + value: value, + data: data, + executed: false + }); + transactionCount += 1; + emit Submission(transactionId); + } + + /* + * Web3 call functions + */ + /// @dev Returns number of confirmations of a transaction. + /// @param transactionId Transaction ID. + /// @return count Number of confirmations. + function getConfirmationCount(uint256 transactionId) public view returns (uint256 count) { + for (uint256 i = 0; i < owners.length; ) { + if (confirmations[transactionId][owners[i]]) { + count += 1; + } + unchecked { + ++i; + } + } + } + + /// @dev Returns total number of transactions after filers are applied. + /// @param pending Include pending transactions. + /// @param executed Include executed transactions. + /// @return count Total number of transactions after filters are applied. + function getTransactionCount(bool pending, bool executed) public view returns (uint256 count) { + for (uint256 i = 0; i < transactionCount; ) { + if ((pending && !transactions[i].executed) || (executed && transactions[i].executed)) { + count += 1; + } + unchecked { + ++i; + } + } + } + + /// @dev Returns list of owners. + /// @return List of owner addresses. + function getOwners() public view returns (address[] memory) { + return owners; + } + + /// @dev Returns array with owner addresses, which confirmed transaction. + /// @param transactionId Transaction ID. + /// @return _confirmations Returns array of owner addresses. + function getConfirmations(uint256 transactionId) public view returns (address[] memory _confirmations) { + address[] memory confirmationsTemp = new address[](owners.length); + uint256 count = 0; + uint256 i; + for (i = 0; i < owners.length; ) { + if (confirmations[transactionId][owners[i]]) { + confirmationsTemp[count] = owners[i]; + count += 1; + } + unchecked { + ++i; + } + } + _confirmations = new address[](count); + for (i = 0; i < count; ) { + _confirmations[i] = confirmationsTemp[i]; + unchecked { + ++i; + } + } + } + + /// @dev Returns list of transaction IDs in defined range. + /// @param from Index start position of transaction array. + /// @param to Index end position of transaction array. + /// @param pending Include pending transactions. + /// @param executed Include executed transactions. + /// @return _transactionIds Returns array of transaction IDs. + function getTransactionIds( + uint256 from, + uint256 to, + bool pending, + bool executed + ) public view returns (uint256[] memory _transactionIds) { + uint256[] memory transactionIdsTemp = new uint256[](transactionCount); + uint256 count = 0; + uint256 i; + for (i = 0; i < transactionCount; ) { + if ((pending && !transactions[i].executed) || (executed && transactions[i].executed)) { + transactionIdsTemp[count] = i; + count += 1; + } + unchecked { + ++i; + } + } + _transactionIds = new uint256[](to - from); + for (i = from; i < to; ) { + _transactionIds[i - from] = transactionIdsTemp[i]; + unchecked { + ++i; + } + } + } +}