├── gradual_funcs.png ├── foundry.toml ├── .gitignore ├── .gitmodules ├── test ├── MockERC20.sol ├── ZeremFactory.t.sol ├── EtherBridge.t.sol ├── Bridge.t.sol └── Zerem.t.sol ├── .github └── workflows │ └── test.yml ├── src ├── ZeremFactory.sol ├── IERC20.sol ├── BridgeDeposit.sol └── Zerem.sol └── README.md /gradual_funcs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hananbeer/zerem/HEAD/gradual_funcs.png -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = 'src' 3 | out = 'out' 4 | libs = ['lib'] 5 | 6 | # See more config options https://github.com/foundry-rs/foundry/tree/master/config -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiler files 2 | cache/ 3 | out/ 4 | 5 | # Ignores development broadcast logs 6 | !/broadcast 7 | /broadcast/*/31337/ 8 | /broadcast/**/dry-run/ 9 | 10 | # Dotenv file 11 | .env 12 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/forge-std"] 2 | path = lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | [submodule "lib/openzeppelin-contracts"] 5 | path = lib/openzeppelin-contracts 6 | url = https://github.com/openzeppelin/openzeppelin-contracts 7 | [submodule "lib/FixedLibrary.git"] 8 | path = lib/FixedLibrary.git 9 | url = https://github.com/finkbeca/FixedLibrary.git 10 | -------------------------------------------------------------------------------- /test/MockERC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: WTFPL 2 | pragma solidity 0.8.13; 3 | 4 | import "../lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; 5 | 6 | contract MockERC20 is ERC20 { 7 | constructor( 8 | string memory name, 9 | string memory symbol, 10 | uint256 supply 11 | ) ERC20(name, symbol) { 12 | _mint(msg.sender, supply); 13 | } 14 | } -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: workflow_dispatch 4 | 5 | env: 6 | FOUNDRY_PROFILE: ci 7 | 8 | jobs: 9 | check: 10 | strategy: 11 | fail-fast: true 12 | 13 | name: Foundry project 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | submodules: recursive 19 | 20 | - name: Install Foundry 21 | uses: foundry-rs/foundry-toolchain@v1 22 | with: 23 | version: nightly 24 | 25 | - name: Run Forge build 26 | run: | 27 | forge --version 28 | forge build --sizes 29 | id: build 30 | 31 | - name: Run Forge tests 32 | run: | 33 | forge test -vvv 34 | id: test 35 | -------------------------------------------------------------------------------- /src/ZeremFactory.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.13; 3 | 4 | import "./Zerem.sol"; 5 | 6 | contract ZeremFactory { 7 | mapping (bytes32 => address) public zerems; 8 | function deploy(address _token, uint256 _minLockAmount, uint256 _unlockDelaySec, uint256 _unlockPeriodSec, uint8 _unlockExponent) public returns (Zerem) { 9 | bytes32 id = keccak256(abi.encode(msg.sender, _token)); 10 | 11 | Zerem zerem = new Zerem{ salt: id }( 12 | _token, 13 | _minLockAmount, 14 | _unlockDelaySec, 15 | _unlockPeriodSec, 16 | _unlockExponent, 17 | address(this) 18 | ); 19 | zerems[id] = address(zerem); 20 | 21 | return zerem; 22 | } 23 | 24 | function getZerem(address deployer, address token) public view returns (address) { 25 | bytes32 id = keccak256(abi.encode(deployer, token)); 26 | return zerems[id]; 27 | } 28 | 29 | function getZerem(bytes32 id) public view returns (address) { 30 | return zerems[id]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/ZeremFactory.t.sol: -------------------------------------------------------------------------------- 1 | pragma solidity ^0.8.13; 2 | 3 | import "forge-std/Test.sol"; 4 | import "forge-std/console.sol"; 5 | import "../src/ZeremFactory.sol"; 6 | 7 | interface CheatCodes { 8 | // Gets address for a given private key, (privateKey) => (address) 9 | function addr(uint256) external returns (address); 10 | } 11 | 12 | contract ZeremFactoryTest is Test { 13 | ZeremFactory public zeremFactory; 14 | address public addr1; 15 | 16 | address underlyingToken = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; // usdc 17 | uint256 minLockAmount = 1000e18; // 1k units 18 | uint256 unlockDelaySec = 24 hours; 19 | uint256 unlockPeriodSec = 48 hours; 20 | uint8 unlockExponent = 1; 21 | 22 | CheatCodes cheats = CheatCodes(HEVM_ADDRESS); 23 | 24 | function setUp() public { 25 | zeremFactory = new ZeremFactory(); 26 | addr1 = cheats.addr(1); 27 | } 28 | 29 | function testShouldDeploy() public { 30 | Zerem addr = zeremFactory.deploy(underlyingToken, minLockAmount, unlockDelaySec, unlockPeriodSec, unlockExponent); 31 | assert(address(addr) != address(0)); 32 | } 33 | 34 | function testGetZeremFromID() public { 35 | vm.prank(addr1); 36 | 37 | bytes32 id = keccak256(abi.encode(addr1, underlyingToken)); 38 | Zerem deployed_address = zeremFactory.deploy(underlyingToken, minLockAmount, unlockDelaySec, unlockPeriodSec, unlockExponent); 39 | assert(zeremFactory.getZerem(id) == address(deployed_address)); 40 | 41 | vm.stopPrank(); 42 | } 43 | 44 | function testGetZeremFromParams() public { 45 | vm.prank(addr1); 46 | 47 | Zerem deployed_address = zeremFactory.deploy(underlyingToken, minLockAmount, unlockDelaySec, unlockPeriodSec, unlockExponent); 48 | assert(zeremFactory.getZerem(addr1, underlyingToken) == address(deployed_address)); 49 | 50 | vm.stopPrank(); 51 | } 52 | } -------------------------------------------------------------------------------- /src/IERC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // OpenZeppelin Contracts (last updated v4.5.0) (token/ERC20/IERC20.sol) 3 | 4 | pragma solidity ^0.8.0; 5 | 6 | /** 7 | * @dev Interface of the ERC20 standard as defined in the EIP. 8 | */ 9 | interface IERC20 { 10 | /** 11 | * @dev Returns the amount of tokens in existence. 12 | */ 13 | function totalSupply() external view returns (uint256); 14 | 15 | /** 16 | * @dev Returns the amount of tokens owned by `account`. 17 | */ 18 | function balanceOf(address account) external view returns (uint256); 19 | 20 | /** 21 | * @dev Moves `amount` tokens from the caller's account to `to`. 22 | * 23 | * Returns a boolean value indicating whether the operation succeeded. 24 | * 25 | * Emits a {Transfer} event. 26 | */ 27 | function transfer(address to, uint256 amount) external returns (bool); 28 | 29 | /** 30 | * @dev Returns the remaining number of tokens that `spender` will be 31 | * allowed to spend on behalf of `owner` through {transferFrom}. This is 32 | * zero by default. 33 | * 34 | * This value changes when {approve} or {transferFrom} are called. 35 | */ 36 | function allowance(address owner, address spender) external view returns (uint256); 37 | 38 | /** 39 | * @dev Sets `amount` as the allowance of `spender` over the caller's tokens. 40 | * 41 | * Returns a boolean value indicating whether the operation succeeded. 42 | * 43 | * IMPORTANT: Beware that changing an allowance with this method brings the risk 44 | * that someone may use both the old and the new allowance by unfortunate 45 | * transaction ordering. One possible solution to mitigate this race 46 | * condition is to first reduce the spender's allowance to 0 and set the 47 | * desired value afterwards: 48 | * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 49 | * 50 | * Emits an {Approval} event. 51 | */ 52 | function approve(address spender, uint256 amount) external returns (bool); 53 | 54 | /** 55 | * @dev Moves `amount` tokens from `from` to `to` using the 56 | * allowance mechanism. `amount` is then deducted from the caller's 57 | * allowance. 58 | * 59 | * Returns a boolean value indicating whether the operation succeeded. 60 | * 61 | * Emits a {Transfer} event. 62 | */ 63 | function transferFrom( 64 | address from, 65 | address to, 66 | uint256 amount 67 | ) external returns (bool); 68 | 69 | /** 70 | * @dev Emitted when `value` tokens are moved from one account (`from`) to 71 | * another (`to`). 72 | * 73 | * Note that `value` may be zero. 74 | */ 75 | event Transfer(address indexed from, address indexed to, uint256 value); 76 | 77 | /** 78 | * @dev Emitted when the allowance of a `spender` for an `owner` is set by 79 | * a call to {approve}. `value` is the new allowance. 80 | */ 81 | event Approval(address indexed owner, address indexed spender, uint256 value); 82 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zerem 2 | 3 | `Zerem` ("*Flow*" in Hebrew) is a DeFi Circuit Breaker and Funds Router that protects protocols from loss of funds due to exploits and other forms of errors. 4 | 5 | ## Installing 6 | 7 | ``` 8 | git clone https://github.com/hananbeer/zerem 9 | cd zerem 10 | forge install 11 | ``` 12 | 13 | ## Building 14 | 15 | ``` 16 | forge build 17 | ``` 18 | 19 | ## Testing 20 | 21 | Test everything with maximum verbosity: 22 | ``` 23 | forge test -vvv 24 | ``` 25 | 26 | Test a specific test suite: 27 | ``` 28 | forge test -vvv --match Zerem 29 | ``` 30 | 31 | ## Description 32 | 33 | Existing dApps (e.g. Cross-Chain Bridges) normally cannot protect from loss of funds. Often a single transaction is able to cause massive loss of funds at the moment it is committed to chain and leave no window of opportunity to take counter measures. 34 | 35 | Past events have shown that even as little as 30 minutes can have massive impact on protecting funds in case of critical protocol failures. 36 | 37 | `Zerem`'s circuit breaker works by routing funds based on predefined configuration: transfers below a certain threshold are transferred directly with no further action, however above this threshold funds are locked in a temporary vault (that is separate from the protocol's vault) and locked for the preconfigured duration. 38 | 39 | `Zerem` does not hold protocol funds, it only temporarily holds funds that have already exited the protocol, thus pose no security risk to existing funds. 40 | 41 | The locking mechanism implements a delayed gradual release which is defined by: 42 | 43 | $f(t) = (\dfrac{\Delta t - d}{p})^n$ 44 | 45 | Which gives the fraction of funds released (unlocked), where: 46 | - $\Delta t$ is how much time passed since funds were locked 47 | - $d$ is the delay period where no funds are released 48 | - $p$ is the period during which funds should be gradually released 49 | 50 | The amount of unlocked funds is: 51 | 52 | $u(x) = f(t) * x$ 53 | 54 | Where $x$ is the amount of funds locked. (note that while not shown above, $f(t)$ is clamped to the range $[0, 1]$) 55 | 56 | ![](gradual_funcs.png) 57 | 58 | The following graphs show various gradual release functions $f(t)$: 59 | 60 | - Constant (x^0 in green) 61 | - Linear (x^1 in blue) 62 | - Parabolic (x^2 in purple) 63 | - Hyper-exponential (x^255 in red) 64 | 65 | Additionally a liquidation resolver is specified - an address which receives disputed funds. It can be a governance contract, a neutral mediator multisig, etc. 66 | 67 | It is not recommended to return the funds to the originating protocol since a dispute may indicate a flaw in the protocol hence funds should be temporarily moved to a safe haven. 68 | 69 | ## Integration 70 | 71 | Integrating `Zerem` is as easy! 72 | 73 | Simply replace the target address `to` in `Token.transfer(to, amount)` or `Token.transferFrom(from, to, amount)` 74 | to the `Zerem` contract instead then call `Zerem.transferTo(to, amount)` 75 | 76 | NOTE: `Zerem` is under active development and is subject so changes. There is currently no off-chain automatic release daemon and the token factory is partially implemented. 77 | 78 | There are no production deployments on any chain at the moment. 79 | 80 | ## Security 81 | 82 | `Zerem` does not hold protocol funds. It only temporarily holds funds that have already exited the protocol, and only above a certain threshold such that for the majority of retail users there is virtually zero exposure in terms of risk and friction. Above the threshold, funds are held only temporarily with a well-defined immutable configuration, including release parameters and the liquidation resolver. 83 | 84 | `Zerem` will never keep user's funds locked beyond the configured period, will never allow modifications to this configuration or to the liquidation resolver, and 85 | 86 | ## Limitations 87 | 88 | While `Zerem` protects protocols from unwanted outflow of funds such as certain exploits, rogue governance attacks, bad configuration and many other security flaws, there is a trade-off. 89 | 90 | Since `Zerem` does not hold protocol funds, it can only protect funds that are routed through it. It does not offer protection from certain rare type of security vulnerabilities such as arbitrary calls or delegatecalls (LiFi), approval manipulation (AnySwap), direct minting (Binance) and possibly other unique flaws. 91 | -------------------------------------------------------------------------------- /src/BridgeDeposit.sol: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Bridge source code taken from: https://etherscan.io/address/0x324C7ec7fb2Bc61646aC2f22f6D06AB29B6c87a3#code 4 | * Submitted for verification at Etherscan.io on 2021-08-18 5 | */ 6 | // SPDX-License-Identifier: UNLICENSED 7 | pragma solidity ^0.8.13; 8 | 9 | import "../src/Zerem.sol"; 10 | //import "../src/ZeremFactory.sol"; 11 | 12 | contract BridgeDeposit { 13 | address private owner; 14 | uint256 private maxDepositAmount; 15 | uint256 private maxBalance; 16 | bool private canReceiveDeposit; 17 | 18 | Zerem public zerem; 19 | 20 | constructor( 21 | uint256 _maxDepositAmount, 22 | uint256 _maxBalance, 23 | bool _canReceiveDeposit 24 | ) { 25 | uint256 _minLockAmount = 10e18; 26 | uint256 _unlockDelaySec = 24 hours; 27 | uint256 _unlockPeriodSec = 48 hours; 28 | uint8 _unlockExponent = 1; 29 | zerem = new Zerem( 30 | address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE), 31 | _minLockAmount, 32 | _unlockDelaySec, 33 | _unlockPeriodSec, 34 | _unlockExponent, 35 | address(this) 36 | ); 37 | 38 | owner = msg.sender; 39 | maxDepositAmount = _maxDepositAmount; 40 | maxBalance = _maxBalance; 41 | canReceiveDeposit = _canReceiveDeposit; 42 | emit OwnerSet(address(0), msg.sender); 43 | emit MaxDepositAmountSet(0, _maxDepositAmount); 44 | emit MaxBalanceSet(0, _maxBalance); 45 | emit CanReceiveDepositSet(_canReceiveDeposit); 46 | } 47 | 48 | // Send the contract's balance to the owner 49 | function withdrawBalance(address user, uint256 amount) public isOwner { 50 | zerem.transferTo{value: amount}(user, amount); 51 | emit BalanceWithdrawn(user, amount); 52 | } 53 | 54 | function destroy() public isOwner { 55 | emit Destructed(owner, address(this).balance); 56 | selfdestruct(payable(owner)); 57 | } 58 | 59 | // Receive function which reverts if amount > maxDepositAmount and canReceiveDeposit = false 60 | receive() external payable /*isLowerThanMaxDepositAmount canReceive isLowerThanMaxBalance*/ { 61 | emit EtherReceived(msg.sender, msg.value); 62 | } 63 | 64 | // Setters 65 | function setMaxAmount(uint256 _maxDepositAmount) public isOwner { 66 | emit MaxDepositAmountSet(maxDepositAmount, _maxDepositAmount); 67 | maxDepositAmount = _maxDepositAmount; 68 | } 69 | 70 | function setOwner(address newOwner) public isOwner { 71 | emit OwnerSet(owner, newOwner); 72 | owner = newOwner; 73 | } 74 | 75 | function setCanReceiveDeposit(bool _canReceiveDeposit) public isOwner { 76 | emit CanReceiveDepositSet(_canReceiveDeposit); 77 | canReceiveDeposit = _canReceiveDeposit; 78 | } 79 | 80 | function setMaxBalance(uint256 _maxBalance) public isOwner { 81 | emit MaxBalanceSet(maxBalance, _maxBalance); 82 | maxBalance = _maxBalance; 83 | } 84 | 85 | // Getters 86 | function getMaxDepositAmount() external view returns (uint256) { 87 | return maxDepositAmount; 88 | } 89 | 90 | function getMaxBalance() external view returns (uint256) { 91 | return maxBalance; 92 | } 93 | 94 | function getOwner() external view returns (address) { 95 | return owner; 96 | } 97 | 98 | function getCanReceiveDeposit() external view returns (bool) { 99 | return canReceiveDeposit; 100 | } 101 | 102 | // Modifiers 103 | modifier isLowerThanMaxDepositAmount() { 104 | require(msg.value <= maxDepositAmount, "Deposit amount is too big"); 105 | _; 106 | } 107 | modifier isOwner() { 108 | require(msg.sender == owner, "Caller is not owner"); 109 | _; 110 | } 111 | modifier canReceive() { 112 | require(canReceiveDeposit == true, "Contract is not allowed to receive ether"); 113 | _; 114 | } 115 | modifier isLowerThanMaxBalance() { 116 | require(address(this).balance <= maxBalance, "Contract reached the max balance allowed"); 117 | _; 118 | } 119 | 120 | // Events 121 | event OwnerSet(address indexed oldOwner, address indexed newOwner); 122 | event MaxDepositAmountSet(uint256 previousAmount, uint256 newAmount); 123 | event CanReceiveDepositSet(bool canReceiveDeposit); 124 | event MaxBalanceSet(uint256 previousBalance, uint256 newBalance); 125 | event BalanceWithdrawn(address indexed owner, uint256 balance); 126 | event EtherReceived(address indexed emitter, uint256 amount); 127 | event Destructed(address indexed owner, uint256 amount); 128 | } 129 | -------------------------------------------------------------------------------- /test/EtherBridge.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.13; 3 | 4 | import "forge-std/Test.sol"; 5 | import "forge-std/console.sol"; 6 | import "../src/Zerem.sol"; 7 | import "../src/BridgeDeposit.sol"; 8 | import "./MockERC20.sol"; 9 | 10 | contract BridgeTest is Test { 11 | BridgeDeposit public bridge; 12 | bool testToken; 13 | 14 | function setUp() public { 15 | // usdc 16 | // address underlyingToken = address(new MockERC20("mock", "mock", 1e28)); 17 | // uint256 minLockAmount = 1000e18; // 1k units 18 | // uint256 unlockDelaySec = 24 hours; 19 | // uint256 unlockPeriodSec = 48 hours; 20 | 21 | bridge = new BridgeDeposit(1000e18, 1e28, true); 22 | vm.deal(address(this), 1e28); 23 | (bool success, ) = payable(bridge).call{value: 1e28}(hex""); 24 | require(success); 25 | } 26 | 27 | receive () payable external { 28 | 29 | } 30 | 31 | function testBridgeNoLock() public { 32 | uint256 amount = 1e18; 33 | vm.recordLogs(); 34 | bridge.withdrawBalance(address(this), amount); 35 | Vm.Log[] memory entries = vm.getRecordedLogs(); 36 | Vm.Log memory entry = entries[0]; 37 | assertEq(entries.length, 2); 38 | assertEq(entry.topics[0], keccak256("TransferFulfilled(address,uint256,uint256)")); 39 | assertEq(uint256(entry.topics[1]), uint256(uint160(address(this)))); 40 | 41 | (uint256 withdrawnAmount, uint256 remainingAmount) = abi.decode(entry.data, (uint256, uint256)); 42 | assertEq(withdrawnAmount, amount); 43 | assertEq(remainingAmount, uint256(0)); 44 | } 45 | 46 | function testBridgeLock() public { 47 | uint256 amount = 1000e18; 48 | 49 | vm.recordLogs(); 50 | bridge.withdrawBalance(address(this), amount); 51 | Vm.Log[] memory entries = vm.getRecordedLogs(); 52 | Vm.Log memory entry = entries[0]; 53 | assertEq(entries.length, 2); 54 | assertEq(entry.topics[0], keccak256("TransferLocked(address,uint256,uint256)")); 55 | assertEq(uint256(entry.topics[1]), uint256(uint160(address(this)))); 56 | 57 | (uint256 amountLocked, uint256 lockTimestamp) = abi.decode(entry.data, (uint256, uint256)); 58 | assertEq(amountLocked, amount); 59 | assertEq(lockTimestamp, uint256(block.timestamp)); 60 | 61 | Zerem zerem = bridge.zerem(); 62 | vm.warp(block.timestamp + 24 hours); 63 | uint256 withdrawableAmount0 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 64 | assertEq(withdrawableAmount0, 0); 65 | 66 | vm.warp(block.timestamp + 24 hours); 67 | uint256 withdrawableAmount0_5 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 68 | assertEq(withdrawableAmount0_5, amount / 2); 69 | 70 | vm.warp(block.timestamp + 24 hours); 71 | uint256 withdrawableAmount1 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 72 | assertEq(withdrawableAmount1, amount); 73 | } 74 | 75 | function testBridgeLockAndUnlock() public { 76 | uint256 amount = 1000e18; 77 | 78 | vm.recordLogs(); 79 | bridge.withdrawBalance(address(this), amount); 80 | Vm.Log[] memory entries = vm.getRecordedLogs(); 81 | Vm.Log memory entry = entries[0]; 82 | 83 | assertEq(entries.length, 2); 84 | // console.logBytes32(entry.topics[0]); 85 | // console.logAddress(address(this)); 86 | // console.logBytes32(entry.topics[1]); 87 | assertEq(entry.topics[0], keccak256("TransferLocked(address,uint256,uint256)")); 88 | assertEq(uint256(entry.topics[1]), uint256(uint160(address(this)))); 89 | 90 | (uint256 amountLocked, uint256 lockTimestamp) = abi.decode(entry.data, (uint256, uint256)); 91 | assertEq(amountLocked, amount); 92 | assertEq(lockTimestamp, uint256(block.timestamp)); 93 | 94 | Zerem zerem = bridge.zerem(); 95 | vm.warp(block.timestamp + 24 hours); 96 | uint256 withdrawableAmount0 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 97 | assertEq(withdrawableAmount0, 0); 98 | 99 | vm.warp(block.timestamp + 24 hours); 100 | uint256 withdrawableAmount0_5 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 101 | assertEq(withdrawableAmount0_5, amount / 2); 102 | 103 | uint256 balanceBefore = address(this).balance; 104 | zerem.unlockFor(address(this), lockTimestamp); 105 | uint256 balanceAfter = address(this).balance; 106 | assertEq(balanceBefore + withdrawableAmount0_5 >= balanceAfter, true); 107 | 108 | vm.warp(block.timestamp + 24 hours); 109 | uint256 withdrawableAmount1 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 110 | assertEq(withdrawableAmount1, amount - (balanceAfter - balanceBefore)); 111 | 112 | zerem.unlockFor(address(this), lockTimestamp); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /test/Bridge.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.13; 3 | 4 | import "forge-std/Test.sol"; 5 | import "forge-std/console.sol"; 6 | import "../src/Zerem.sol"; 7 | import "../src/BridgeDeposit.sol"; 8 | import "./MockERC20.sol"; 9 | 10 | contract BridgeTest is Test { 11 | BridgeDeposit public bridge; 12 | bool testToken; 13 | 14 | function setUp() public { 15 | // usdc 16 | // address underlyingToken = address(new MockERC20("mock", "mock", 1e28)); 17 | // uint256 minLockAmount = 1000e18; // 1k units 18 | // uint256 unlockDelaySec = 24 hours; 19 | // uint256 unlockPeriodSec = 48 hours; 20 | 21 | bridge = new BridgeDeposit(1000e18, 1e28, true); 22 | vm.deal(address(this), 1e28); 23 | (bool success, ) = payable(bridge).call{value: 1e28}(hex""); 24 | require(success); 25 | } 26 | 27 | receive () payable external { 28 | 29 | } 30 | 31 | function testTransferNoLock() public { 32 | uint256 amount = 1e18; 33 | vm.recordLogs(); 34 | bridge.withdrawBalance(address(this), amount); 35 | Vm.Log[] memory entries = vm.getRecordedLogs(); 36 | Vm.Log memory entry = entries[0]; 37 | assertEq(entries.length, 2); 38 | assertEq(entry.topics[0], keccak256("TransferFulfilled(address,uint256,uint256)")); 39 | assertEq(uint256(entry.topics[1]), uint256(uint160(address(this)))); 40 | 41 | (uint256 withdrawnAmount, uint256 remainingAmount) = abi.decode(entry.data, (uint256, uint256)); 42 | assertEq(withdrawnAmount, amount); 43 | assertEq(remainingAmount, uint256(0)); 44 | } 45 | /* 46 | function testTransferLock() public { 47 | uint256 amount = 1000e18; 48 | IERC20(zerem.underlyingToken()).transfer(address(zerem), amount); 49 | 50 | vm.recordLogs(); 51 | zerem.transferTo(address(this), amount); 52 | Vm.Log[] memory entries = vm.getRecordedLogs(); 53 | 54 | assertEq(entries.length, 1); 55 | console.logBytes32(entries[0].topics[0]); 56 | console.logAddress(address(this)); 57 | console.logBytes32(entries[0].topics[1]); 58 | assertEq(entries[0].topics[0], keccak256("TransferLocked(address,uint256,uint256)")); 59 | assertEq(uint256(entries[0].topics[1]), uint256(uint160(address(this)))); 60 | 61 | (uint256 amountLocked, uint256 lockTimestamp) = abi.decode(entries[0].data, (uint256, uint256)); 62 | assertEq(amountLocked, amount); 63 | assertEq(lockTimestamp, uint256(block.timestamp)); 64 | 65 | vm.warp(block.timestamp + 24 hours); 66 | uint256 withdrawableAmount0 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 67 | assertEq(withdrawableAmount0, 0); 68 | 69 | vm.warp(block.timestamp + 24 hours); 70 | uint256 withdrawableAmount0_5 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 71 | assertEq(withdrawableAmount0_5, amount / 2); 72 | 73 | vm.warp(block.timestamp + 24 hours); 74 | uint256 withdrawableAmount1 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 75 | assertEq(withdrawableAmount1, amount); 76 | } 77 | 78 | function testTransferLockAndUnlock() public { 79 | uint256 amount = 1000e18; 80 | IERC20(zerem.underlyingToken()).transfer(address(zerem), amount); 81 | 82 | vm.recordLogs(); 83 | zerem.transferTo(address(this), amount); 84 | Vm.Log[] memory entries = vm.getRecordedLogs(); 85 | 86 | assertEq(entries.length, 1); 87 | console.logBytes32(entries[0].topics[0]); 88 | console.logAddress(address(this)); 89 | console.logBytes32(entries[0].topics[1]); 90 | assertEq(entries[0].topics[0], keccak256("TransferLocked(address,uint256,uint256)")); 91 | assertEq(uint256(entries[0].topics[1]), uint256(uint160(address(this)))); 92 | 93 | (uint256 amountLocked, uint256 lockTimestamp) = abi.decode(entries[0].data, (uint256, uint256)); 94 | assertEq(amountLocked, amount); 95 | assertEq(lockTimestamp, uint256(block.timestamp)); 96 | 97 | vm.warp(block.timestamp + 24 hours); 98 | uint256 withdrawableAmount0 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 99 | assertEq(withdrawableAmount0, 0); 100 | 101 | vm.warp(block.timestamp + 24 hours); 102 | uint256 withdrawableAmount0_5 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 103 | assertEq(withdrawableAmount0_5, amount / 2); 104 | 105 | uint256 balanceBefore = IERC20(zerem.underlyingToken()).balanceOf(address(this)); 106 | zerem.unlockFor(address(this), lockTimestamp); 107 | uint256 balanceAfter = IERC20(zerem.underlyingToken()).balanceOf(address(this)); 108 | assertEq(balanceBefore + withdrawableAmount0_5 >= balanceAfter, true); 109 | 110 | vm.warp(block.timestamp + 24 hours); 111 | uint256 withdrawableAmount1 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 112 | assertEq(withdrawableAmount1, amount - (balanceAfter - balanceBefore)); 113 | 114 | zerem.unlockFor(address(this), lockTimestamp); 115 | }*/ 116 | } 117 | -------------------------------------------------------------------------------- /test/Zerem.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.13; 3 | 4 | import "forge-std/Test.sol"; 5 | import "forge-std/console.sol"; 6 | import "../src/Zerem.sol"; 7 | import "./MockERC20.sol"; 8 | 9 | contract ZeremTest is Test { 10 | Zerem public zerem; 11 | bool testToken; 12 | 13 | function setUp() public { 14 | address underlyingToken = address(new MockERC20("mock", "mock", 1e28)); 15 | uint256 minLockAmount = 1000e18; // 1k units 16 | uint256 unlockDelaySec = 24 hours; 17 | uint256 unlockPeriodSec = 48 hours; 18 | uint8 unlockExponent = 1; // linear release 19 | 20 | testToken = true; 21 | if (!testToken) 22 | underlyingToken = address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE); 23 | 24 | zerem = new Zerem(underlyingToken, minLockAmount, unlockDelaySec, unlockPeriodSec, unlockExponent, address(this)); 25 | } 26 | 27 | function testTransferNoFunds() public { 28 | vm.expectRevert("not enough tokens"); 29 | zerem.transferTo(address(this), 1234); 30 | } 31 | 32 | function testTransferNoLock() public { 33 | uint256 amount = 1e18; 34 | IERC20(zerem.underlyingToken()).transfer(address(zerem), amount); 35 | 36 | vm.recordLogs(); 37 | zerem.transferTo(address(this), amount); 38 | Vm.Log[] memory entries = vm.getRecordedLogs(); 39 | assertEq(entries.length, 2); 40 | assertEq(entries[1].topics[0], keccak256("TransferFulfilled(address,uint256,uint256)")); 41 | assertEq(uint256(entries[1].topics[1]), uint256(uint160(address(this)))); 42 | 43 | (uint256 withdrawnAmount, uint256 remainingAmount) = abi.decode(entries[1].data, (uint256, uint256)); 44 | assertEq(withdrawnAmount, amount); 45 | assertEq(remainingAmount, uint256(0)); 46 | } 47 | 48 | function testTransferLock() public { 49 | uint256 amount = 1000e18; 50 | IERC20(zerem.underlyingToken()).transfer(address(zerem), amount); 51 | 52 | vm.recordLogs(); 53 | zerem.transferTo(address(this), amount); 54 | Vm.Log[] memory entries = vm.getRecordedLogs(); 55 | 56 | assertEq(entries.length, 1); 57 | assertEq(entries[0].topics[0], keccak256("TransferLocked(address,uint256,uint256)")); 58 | assertEq(uint256(entries[0].topics[1]), uint256(uint160(address(this)))); 59 | 60 | (uint256 amountLocked, uint256 lockTimestamp) = abi.decode(entries[0].data, (uint256, uint256)); 61 | assertEq(amountLocked, amount); 62 | assertEq(lockTimestamp, uint256(block.timestamp)); 63 | 64 | vm.warp(block.timestamp + 24 hours); 65 | uint256 withdrawableAmount0 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 66 | assertEq(withdrawableAmount0, 0); 67 | 68 | vm.warp(block.timestamp + 24 hours); 69 | uint256 withdrawableAmount0_5 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 70 | assertEq(withdrawableAmount0_5, amount / 2); 71 | 72 | vm.warp(block.timestamp + 24 hours); 73 | uint256 withdrawableAmount1 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 74 | assertEq(withdrawableAmount1, amount); 75 | } 76 | 77 | function testTransferLockAndUnlock() public { 78 | uint256 amount = 1000e18; 79 | IERC20(zerem.underlyingToken()).transfer(address(zerem), amount); 80 | 81 | vm.recordLogs(); 82 | zerem.transferTo(address(this), amount); 83 | Vm.Log[] memory entries = vm.getRecordedLogs(); 84 | 85 | assertEq(entries.length, 1); 86 | assertEq(entries[0].topics[0], keccak256("TransferLocked(address,uint256,uint256)")); 87 | assertEq(uint256(entries[0].topics[1]), uint256(uint160(address(this)))); 88 | 89 | (uint256 amountLocked, uint256 lockTimestamp) = abi.decode(entries[0].data, (uint256, uint256)); 90 | assertEq(amountLocked, amount); 91 | assertEq(lockTimestamp, uint256(block.timestamp)); 92 | 93 | vm.warp(block.timestamp + 24 hours); 94 | uint256 withdrawableAmount0 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 95 | assertEq(withdrawableAmount0, 0); 96 | 97 | vm.warp(block.timestamp + 24 hours); 98 | uint256 withdrawableAmount0_5 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 99 | assertEq(withdrawableAmount0_5, amount / 2); 100 | 101 | uint256 balanceBefore = IERC20(zerem.underlyingToken()).balanceOf(address(this)); 102 | zerem.unlockFor(address(this), lockTimestamp); 103 | uint256 balanceAfter = IERC20(zerem.underlyingToken()).balanceOf(address(this)); 104 | assertEq(balanceBefore + withdrawableAmount0_5 >= balanceAfter, true); 105 | 106 | vm.warp(block.timestamp + 24 hours); 107 | uint256 withdrawableAmount1 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 108 | assertEq(withdrawableAmount1, amount - (balanceAfter - balanceBefore)); 109 | 110 | zerem.unlockFor(address(this), lockTimestamp); 111 | } 112 | 113 | function testEarlyUnlock() public { 114 | uint256 amount = 1000e18; 115 | IERC20(zerem.underlyingToken()).transfer(address(zerem), amount); 116 | 117 | vm.recordLogs(); 118 | zerem.transferTo(address(this), amount); 119 | Vm.Log[] memory entries = vm.getRecordedLogs(); 120 | 121 | assertEq(entries.length, 1); 122 | assertEq(entries[0].topics[0], keccak256("TransferLocked(address,uint256,uint256)")); 123 | assertEq(uint256(entries[0].topics[1]), uint256(uint160(address(this)))); 124 | 125 | (uint256 amountLocked, uint256 lockTimestamp) = abi.decode(entries[0].data, (uint256, uint256)); 126 | assertEq(amountLocked, amount); 127 | assertEq(lockTimestamp, uint256(block.timestamp)); 128 | 129 | vm.warp(block.timestamp + 24 hours); 130 | uint256 withdrawableAmount0 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 131 | assertEq(withdrawableAmount0, 0); 132 | 133 | vm.warp(block.timestamp + 24 hours); 134 | uint256 withdrawableAmount0_5 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 135 | assertEq(withdrawableAmount0_5, amount / 2); 136 | 137 | uint256 balanceBefore = IERC20(zerem.underlyingToken()).balanceOf(address(this)); 138 | zerem.unlockFor(address(this), lockTimestamp); 139 | uint256 balanceAfter = IERC20(zerem.underlyingToken()).balanceOf(address(this)); 140 | assertEq(balanceBefore + withdrawableAmount0_5 >= balanceAfter, true); 141 | 142 | uint256 withdrawableAmount1 = zerem.getWithdrawableAmount(address(this), lockTimestamp); 143 | console.log(withdrawableAmount1); 144 | assertEq(withdrawableAmount1, 0); 145 | vm.expectRevert("no withdrawable funds"); 146 | zerem.unlockFor(address(this), lockTimestamp); 147 | } 148 | 149 | // test written by STARZ 150 | function testTransferNoLock2() public { 151 | uint256 amount2 = 2e18; 152 | IERC20(zerem.underlyingToken()).transfer(address(zerem), amount2); 153 | 154 | vm.recordLogs(); 155 | zerem.transferTo(address(this), amount2); 156 | Vm.Log[] memory entries = vm.getRecordedLogs(); 157 | assertEq(entries.length, 2); 158 | assertEq( 159 | entries[1].topics[0], 160 | keccak256("TransferFulfilled(address,uint256,uint256)") 161 | ); 162 | assertEq( 163 | uint256(entries[1].topics[1]), 164 | uint256(uint160(address(this))) 165 | ); 166 | 167 | (uint256 withdrawnAmount, uint256 remainingAmount) = abi.decode( 168 | entries[1].data, 169 | (uint256, uint256) 170 | ); 171 | assertEq(withdrawnAmount, amount2); 172 | assertEq(remainingAmount, uint256(0)); 173 | 174 | uint256 amount = 1e18; 175 | IERC20(zerem.underlyingToken()).transfer(address(zerem), amount); 176 | 177 | vm.recordLogs(); 178 | 179 | zerem.transferTo(address(this), amount); 180 | entries = vm.getRecordedLogs(); 181 | assertEq(entries.length, 2); 182 | assertEq( 183 | entries[1].topics[0], 184 | keccak256("TransferFulfilled(address,uint256,uint256)") 185 | ); 186 | assertEq( 187 | uint256(entries[1].topics[1]), 188 | uint256(uint160(address(this))) 189 | ); 190 | 191 | (withdrawnAmount, remainingAmount) = abi.decode( 192 | entries[1].data, 193 | (uint256, uint256) 194 | ); 195 | assertEq(withdrawnAmount, amount); 196 | assertEq(remainingAmount, uint256(0)); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/Zerem.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.13; 3 | 4 | import "../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; 5 | 6 | contract Zerem { 7 | uint256 immutable public precision = 1e8; 8 | address immutable NATIVE = address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE); 9 | 10 | uint8 public immutable unlockExponent; 11 | 12 | address public immutable underlyingToken; 13 | 14 | // minimum amount before locking funds, otherwise direct transfer 15 | uint256 public immutable lockThreshold; 16 | 17 | // timeframe without unlocking, in seconds 18 | uint256 public immutable unlockDelaySec; 19 | 20 | // timeframe of gradual, linear unlock, in seconds 21 | uint256 public immutable unlockPeriodSec; 22 | 23 | address public liquidationResolver; // an address used to resolve liquidations 24 | 25 | struct TransferRecord { 26 | address sender; 27 | uint256 lockTimestamp; 28 | uint256 totalAmount; 29 | uint256 remainingAmount; 30 | bool isFrozen; 31 | } 32 | 33 | // keccak256(address user, uint256 timestamp) => Transfer 34 | mapping (bytes32 => TransferRecord) public pendingTransfers; 35 | 36 | // user => amount 37 | mapping (address => uint256) public pendingTotalBalances; 38 | 39 | uint256 public totalTokenBalance; 40 | 41 | event TransferLocked(address indexed user, uint256 amount, uint256 timestamp); 42 | event TransferFulfilled(address indexed user, uint256 amountUnlocked, uint256 amountRemaining); 43 | 44 | constructor( 45 | address _token, 46 | uint256 _lockThreshold, 47 | uint256 _unlockDelaySec, 48 | uint256 _unlockPeriodSec, 49 | uint8 _unlockExponent, 50 | address _liquidationResolver 51 | ) { 52 | underlyingToken = _token; 53 | lockThreshold = _lockThreshold; 54 | unlockDelaySec = _unlockDelaySec; 55 | unlockPeriodSec = _unlockPeriodSec; 56 | unlockExponent = _unlockExponent; 57 | liquidationResolver = _liquidationResolver; 58 | } 59 | 60 | function _getLockedBalance() internal view returns (uint256) { 61 | if (underlyingToken == NATIVE) 62 | return address(this).balance; 63 | else 64 | return IERC20(underlyingToken).balanceOf(address(this)); 65 | } 66 | 67 | function _sendFunds(address receiver, uint256 amount) internal { 68 | // TODO: high severity fix suggested by STARZ. 69 | // this fix should be sent back to be audited. 70 | totalTokenBalance -= amount; 71 | 72 | if (underlyingToken == NATIVE) { 73 | (bool success, ) = payable(receiver).call{gas: 3000, value: amount}(hex""); 74 | require(success, "sending ether failed"); 75 | } else { 76 | require(msg.value == 0, "msg.value must be zero"); 77 | IERC20(underlyingToken).transfer(receiver, amount); 78 | } 79 | } 80 | 81 | function _getTransferId(address user, uint256 lockTimestamp) internal pure returns (bytes32) { 82 | bytes32 transferId = keccak256(abi.encode(user, lockTimestamp)); 83 | return transferId; 84 | } 85 | 86 | function _getRecord(bytes32 transferId) internal view returns (TransferRecord storage) { 87 | TransferRecord storage record = pendingTransfers[transferId]; 88 | require(record.totalAmount > 0, "no such transfer record"); 89 | return record; 90 | } 91 | 92 | function _getRecord(address user, uint256 lockTimestamp) internal view returns (TransferRecord storage) { 93 | bytes32 transferId = keccak256(abi.encode(user, lockTimestamp)); 94 | return _getRecord(transferId); 95 | } 96 | 97 | function _lockFunds(address user, uint256 amount) internal { 98 | bytes32 transferId = _getTransferId(user, block.timestamp); 99 | TransferRecord storage record = pendingTransfers[transferId]; 100 | if (record.totalAmount == 0) { 101 | record.sender = msg.sender; 102 | record.lockTimestamp = block.timestamp; 103 | } else { 104 | require(record.sender == msg.sender, "multiple senders per same transfer id"); 105 | } 106 | 107 | record.totalAmount += amount; 108 | record.remainingAmount += amount; 109 | pendingTotalBalances[user] += amount; 110 | } 111 | 112 | function _unlockFor(address user, uint256 lockTimestamp, address receiver) internal { 113 | bytes32 transferId = keccak256(abi.encode(user, lockTimestamp)); 114 | uint256 amount = _getWithdrawableAmount(transferId); 115 | require(amount > 0, "no withdrawable funds"); 116 | TransferRecord storage record = pendingTransfers[transferId]; 117 | uint256 remainingAmount = record.remainingAmount - amount; 118 | record.remainingAmount = remainingAmount; 119 | pendingTotalBalances[user] -= amount; 120 | 121 | _sendFunds(receiver, amount); 122 | emit TransferFulfilled(user, amount, remainingAmount); 123 | } 124 | 125 | function _getWithdrawableAmount(bytes32 transferId) internal view returns (uint256 withdrawableAmount) { 126 | TransferRecord storage record = _getRecord(transferId); 127 | 128 | // calculate unlock function 129 | // in this case, we are using a delayed linear unlock: 130 | // f(t) = amount * delta 131 | // delta = clamp(now - lockTime + unlockDelay, 0%, 100%) 132 | // for example, start delay of 24 hours and end delay of 72 hours/ 133 | // give us initial 24 hours period with no unlock, following 48 hours period 134 | // of gradual unlocking 135 | // need to normalize between 0..1 136 | // so (deltaTime - startDelay) / (endDelay - startDelay) = (deltaDelayed / 48hr) 137 | // then clamp 0..1 138 | 139 | // t = block.timestamp 140 | // t0 = record.lockTimestamp 141 | // d = unlockDelaySec 142 | // p = unlockPeriodSec 143 | 144 | // delta time: 145 | // dt = t - t0 146 | uint256 deltaTime = block.timestamp - record.lockTimestamp; 147 | if (deltaTime < unlockDelaySec) 148 | return 0; 149 | 150 | // delta time delayed: 151 | // ddt = dt - d 152 | uint256 deltaTimeDelayed = (deltaTime - unlockDelaySec); 153 | 154 | // ensure 0 <= (ddt / p) <= 1 155 | if (deltaTimeDelayed >= unlockPeriodSec) 156 | return record.remainingAmount; 157 | 158 | // r = precision 159 | // normalized delta time: (0..1)r 160 | // ddt * r 161 | // ndt = ------- 162 | // p 163 | uint256 deltaTimeNormalized = (deltaTimeDelayed * precision) / unlockPeriodSec; 164 | 165 | // calculate the total amount unlocked amount 166 | // it should return a factor in range (0..1)r, otherwise it is clamped 167 | // f(ndt) = ndt^x where x = unlockExponent 168 | uint256 factor = deltaTimeNormalized ** unlockExponent; 169 | 170 | // clamp f(ndt) 171 | if (factor > precision) 172 | factor = precision; 173 | 174 | // a = locked totalAmount 175 | // u = totalUnlockedAmount 176 | 177 | // u = a * f(ndt) 178 | uint256 totalUnlockedAmount = (record.totalAmount * factor) / precision; 179 | 180 | // q = withdrawnAmount 181 | // subtract the already withdrawn amount from the unlocked amount 182 | uint256 withdrawnAmount = record.totalAmount - record.remainingAmount; 183 | if (totalUnlockedAmount < withdrawnAmount) 184 | return 0; 185 | 186 | // w = withdrawableAmount 187 | // w = u - q 188 | withdrawableAmount = totalUnlockedAmount - withdrawnAmount; 189 | if (withdrawableAmount > record.remainingAmount) 190 | withdrawableAmount = record.remainingAmount; 191 | } 192 | 193 | function getWithdrawableAmount(address user, uint256 lockTimestamp) public view returns (uint256 amount) { 194 | bytes32 transferId = _getTransferId(user, lockTimestamp); 195 | return _getWithdrawableAmount(transferId); 196 | } 197 | 198 | // 1. Transfer funds to Zerem 199 | // 2. Calculate funds user owns (amount < lockThreshold) 200 | // 3. Check if user can recive funds now or funds must be locked 201 | function transferTo(address user, uint256 amount) payable public { 202 | uint256 oldBalance = totalTokenBalance; 203 | totalTokenBalance = _getLockedBalance(); 204 | uint256 transferredAmount = totalTokenBalance - oldBalance; 205 | // if this requirement fails it implies calling contract failure 206 | // to transfer this contract `amount` tokens. 207 | require(transferredAmount >= amount, "not enough tokens"); 208 | 209 | if (amount < lockThreshold) { 210 | _sendFunds(user, amount); 211 | emit TransferFulfilled(user, amount, 0); 212 | } else { 213 | _lockFunds(user, amount); 214 | emit TransferLocked(user, amount, block.timestamp); 215 | } 216 | } 217 | 218 | function unlockFor(address user, uint256 lockTimestamp) public { 219 | // TOOD: send relayer fees here 220 | // (but only allow after unlockDelay + unlockPeriod + relayerGracePeriod) 221 | _unlockFor(user, lockTimestamp, user); 222 | } 223 | 224 | // allow a user to freeze his own funds 225 | function freezeFunds(address user, uint256 lockTimestamp) public { 226 | TransferRecord storage record = _getRecord(user, lockTimestamp); 227 | require(msg.sender == record.sender, "must be funds sender"); 228 | record.isFrozen = true; 229 | // TODO: emit event 230 | } 231 | 232 | // allow a user to freeze his own funds 233 | function unfreezeFunds(address user, uint256 lockTimestamp) public { 234 | TransferRecord storage record = _getRecord(user, lockTimestamp); 235 | require(msg.sender == record.sender, "must be funds sender"); 236 | record.isFrozen = false; 237 | // TODO: emit event 238 | } 239 | 240 | function liquidateFunds(address user, uint256 lockTimestamp) public { 241 | TransferRecord storage record = _getRecord(user, lockTimestamp); 242 | require(msg.sender == record.sender, "must be funds sender"); 243 | // NOTE: to avoid sender unrightfully liquidating funds right before a user unlocks 244 | // it would be redundant to check if funds are frozen since it only requires a simple additional txn 245 | // just using a multiple of two for the total lock period 246 | require(block.timestamp > lockTimestamp + 2 * (unlockDelaySec + unlockPeriodSec), "liquidation too early"); 247 | _unlockFor(user, lockTimestamp, liquidationResolver); 248 | } 249 | } 250 | --------------------------------------------------------------------------------