diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..09880b1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: test + +on: workflow_dispatch + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + strategy: + fail-fast: true + + name: Foundry project + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Run Forge build + run: | + forge --version + forge build --sizes + id: build + + - name: Run Forge tests + run: | + forge test -vvv + id: test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85198aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..690924b --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/README.md b/README.md index 0e8626c..f0e5749 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # horizon Contracts & Specs for Graph Horizon + +## Setup + +- Install [Foundry](https://book.getfoundry.sh/getting-started/installation) diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..11c67c0 --- /dev/null +++ b/foundry.toml @@ -0,0 +1,11 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] + +# See more config options https://github.com/foundry-rs/foundry/tree/master/config + +[invariant] +fail_on_revert = false +depth = 10 +runs = 1000 diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 0000000..e8a047e --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit e8a047e3f40f13fa37af6fe14e6e06283d9a060e diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..e50c24f --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit e50c24f5839db17f46991478384bfda14acfb830 diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..3f2cd54 --- /dev/null +++ b/remappings.txt @@ -0,0 +1,5 @@ +ds-test/=lib/forge-std/lib/ds-test/src/ +erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/ +forge-std/=lib/forge-std/src/ +openzeppelin-contracts/=lib/openzeppelin-contracts/ +openzeppelin/=lib/openzeppelin-contracts/contracts/ diff --git a/src/HorizonCore.sol b/src/HorizonCore.sol new file mode 100644 index 0000000..56051f2 --- /dev/null +++ b/src/HorizonCore.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Burnable.sol"; +import "openzeppelin-contracts/contracts/utils/Multicall.sol"; + +/// This contract manages collateral in the form of slashable, potentially time-locked token deposits. +contract HorizonCore is Multicall { + event Deposit( + uint128 indexed id, address indexed depositor, address indexed arbiter, uint256 amount, uint64 unlock + ); + event Lock(uint128 indexed id, uint64 unlock); + event Slash(uint128 indexed id, uint256 amount); + event Withdraw(uint128 indexed id); + + /// The state associated with a deposit. When a deposit is locked (`block.timestamp < unlock`) it has the following + /// properties: + /// - A deposit may only be withdrawn when the deposit is unlocked (`block.timestamp >= unlock`). Withdrawal returns + /// the deposit's token amount to the depositor. + /// - The arbiter has authority to slash the deposit before unlock, which burns a given amount tokens. A slash also + /// reduces the tokens available to withdraw by the same amount. + struct DepositState { + // creator of the deposit, has ability to withdraw when the deposit is unlocked + address depositor; + // authority to slash deposit amount, when the deposit is locked + address arbiter; + // token amount associated with deposit + uint256 amount; + // timestamp when deposit is no longer locked + uint64 unlock; + // timestamp of deposit creation + uint64 start; + // timestamp of withdrawal, 0 until withdrawn + uint64 end; + } + + // ┌────────┐ ┌──────┐ ┌─────────┐ + // │unlocked│ │locked│ │withdrawn│ + // └───┬────┘ └──┬───┘ └────┬────┘ + // deposit (block.timestamp >= unlock) │ │ │ + // ───────────────────────────────────>│ │ │ + // │ │ │ + // deposit (block.timestamp < unlock) │ │ │ + // ───────────────────────────────────────────────────────────────────────>│ │ + // │ │ │ + // │ lock (block.timestamp < unlock) │ │ + // │ ─────────────────────────────────>│ │ + // │ │ │ + // │ (block.timestamp >= unlock) │ │ + // │ <─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│ │ + // │ │ │ + // │ withdraw │ │ + // │ ─────────────────────────────────────────────────────>│ + // ┌───┴────┐ ┌──┴───┐ ┌────┴────┐ + // │unlocked│ │locked│ │withdrawn│ + // └────────┘ └──────┘ └─────────┘ + + /// Burnable ERC-20 token held by this contract. + ERC20Burnable public immutable token; + /// Mapping of deposit IDs to deposits. + mapping(uint128 => DepositState) public deposits; + /// Counters for generating new deposit IDs. + mapping(address => uint64) public nonces; + + /// @param _token the burnable ERC-20 token held by this contract. + constructor(ERC20Burnable _token) { + token = _token; + } + + /// Create a new deposit, returning its associated ID. + /// @param _arbiter Arbiter of the new deposit. + /// @param _amount Initial token amount of the new deposit. + /// @param _unlock Unlock timestamp of the new deposit, in seconds. Set to a nonzero value to lock deposit. + /// @return id Unique ID associated with the new deposit. + function deposit(address _arbiter, uint256 _amount, uint64 _unlock) external returns (uint128) { + uint64 _nonce = nonces[msg.sender]++; + uint128 _id = uint128(bytes16(keccak256(abi.encode(block.chainid, msg.sender, _nonce)))); + deposits[_id] = DepositState({ + depositor: msg.sender, + arbiter: _arbiter, + amount: _amount, + unlock: _unlock, + start: uint64(block.timestamp), + end: 0 + }); + bool _transferSuccess = token.transferFrom(msg.sender, address(this), _amount); + require(_transferSuccess, "transfer failed"); + emit Deposit(_id, msg.sender, _arbiter, _amount, _unlock); + return _id; + } + + /// Lock the deposit associated with the given ID. This makes the deposit slashable until it is unlocked. This + /// modification to a deposit can only made when its unlock timestamp is unset (has a value of zero). + /// @param _id ID of the associated deposit. + /// @param _unlock Unlock timestamp of deposit, in seconds. + function lock(uint128 _id, uint64 _unlock) external { + DepositState memory _deposit = getDepositState(_id); + require(msg.sender == _deposit.arbiter, "sender not arbiter"); + require(_deposit.end == 0, "deposit withdrawn"); + require(_deposit.unlock == 0, "deposit locked"); + deposits[_id].unlock = _unlock; + emit Lock(_id, _unlock); + } + + /// Burn some of the deposit amount while it's locked. This action can only be performed by the arbiter of + /// the deposit associated with the given ID. + /// @param _id ID of the associated deposit. + /// @param _amount Amount of remaining deposit tokens to burn. + function slash(uint128 _id, uint256 _amount) external { + DepositState memory _deposit = getDepositState(_id); + require(msg.sender == _deposit.arbiter, "sender not arbiter"); + require(_deposit.end == 0, "deposit withdrawn"); + require(block.timestamp < _deposit.unlock, "deposit unlocked"); + require(_amount <= _deposit.amount, "amount too large"); + deposits[_id].amount -= _amount; + token.burn(_amount); + emit Slash(_id, _amount); + } + + /// Collect remaining tokens associated with a deposit. + /// @param _id ID of the associated deposit. + function withdraw(uint128 _id) external { + DepositState memory _deposit = getDepositState(_id); + require(_deposit.depositor == msg.sender, "sender not depositor"); + require(_deposit.end == 0, "deposit withdrawn"); + require(block.timestamp >= _deposit.unlock, "deposit locked"); + deposits[_id].end = uint64(block.timestamp); + bool _transferSuccess = token.transfer(_deposit.depositor, _deposit.amount); + require(_transferSuccess, "transfer failed"); + emit Withdraw(_id); + } + + /// Return the deposit state associated with the given ID. + /// @param _id ID of the associated deposit. + function getDepositState(uint128 _id) public view returns (DepositState memory) { + DepositState memory _deposit = deposits[_id]; + require(_deposit.depositor != address(0), "deposit not found"); + return _deposit; + } + + /// Return true if the deposit associated with the given ID is slashable, false otherwise. A slashable deposit is + /// locked (`block.timestamp < unlock`). As the name suggests, a slashable deposit may be slashed, and cannot be + /// withdrawn by the depositor until it is unlocked (`block.timestamp >= unlock`). + /// @param _id ID of the associated deposit. + function isSlashable(uint128 _id) external view returns (bool) { + DepositState memory _deposit = getDepositState(_id); + return (block.timestamp < _deposit.unlock); + } + + /// Return the next deposit ID for the given depositor address. + function nextID(address _depositor) external view returns (uint128) { + uint64 _nonce = nonces[_depositor]; + return uint128(bytes16(keccak256(abi.encode(block.chainid, msg.sender, _nonce)))); + } +} diff --git a/src/examples/DataService.sol b/src/examples/DataService.sol new file mode 100644 index 0000000..9275073 --- /dev/null +++ b/src/examples/DataService.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; +import {HorizonCore} from "../HorizonCore.sol"; +import {IDataService} from "./LoanAggregator.sol"; + +contract DataService is Ownable, IDataService { + struct ProviderState { + uint128 deposit; + uint128 payment; + } + + HorizonCore public core; + mapping(address => ProviderState) public providers; + uint64 public disputePeriod; + + constructor(HorizonCore _core, uint64 _disputePeriod) { + core = _core; + disputePeriod = _disputePeriod; + } + + /// Add provider and fund their future payment. + function addProvider(address _provider, uint128 _payment) public onlyOwner { + require(_payment > 0); + require(providers[_provider].payment == 0, "provider exists"); + providers[_provider] = ProviderState({deposit: 0, payment: _payment}); + core.token().transferFrom(msg.sender, address(this), _payment); + } + + function removeProvider(address _provider) public onlyOwner { + ProviderState memory _state = getProviderState(_provider); + require(_state.deposit == 0, "payment already made"); + delete providers[_provider]; + } + + /// Slash the provider's deposit. + function slash(address _provider, uint256 _amount) public onlyOwner { + ProviderState memory _state = getProviderState(_provider); + core.slash(_state.deposit, _amount); + } + + /// Called by data service provider to receive payment. This locks the given deposit to begin a dispute period. + function remitPayment(address _providerAddr, uint128 _depositID, uint64 _unlock) public { + ProviderState memory _provider = getProviderState(_providerAddr); + HorizonCore.DepositState memory _deposit = core.getDepositState(_depositID); + + uint256 minCollateral = uint256(_provider.payment) * 10; + require(_deposit.amount >= minCollateral, "collateral below minimum"); + uint128 disputePeriodEnd = uint128(block.timestamp + disputePeriod); + require(_unlock >= disputePeriodEnd, "collateral unlock before end of dispute period"); + + providers[_providerAddr].deposit = _depositID; + if (_deposit.unlock == 0) { + core.lock(_depositID, _unlock); + } + core.token().transfer(_providerAddr, _provider.payment); + } + + function getProviderState(address _provider) public view returns (ProviderState memory) { + ProviderState memory _state = providers[_provider]; + require(_state.payment != 0, "provider not found"); + return _state; + } +} diff --git a/src/examples/Lender.sol b/src/examples/Lender.sol new file mode 100644 index 0000000..824358a --- /dev/null +++ b/src/examples/Lender.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; +import {SafeCast} from "openzeppelin-contracts/contracts/utils/math/SafeCast.sol"; +import {AggregatedLoan, ILender, LoanAggregator, LoanCommitment} from "./LoanAggregator.sol"; + +struct Limits { + uint256 maxAmount; + uint64 maxDuration; +} + +contract Lender is Ownable, ILender { + struct LoanState { + address borrower; + uint256 initialAmount; + uint256 borrowerCollateral; + } + + LoanAggregator public agg; + Limits public limits; + LoanState[] public loans; + + constructor(LoanAggregator _agg, Limits memory _limits) { + agg = _agg; + limits = _limits; + } + + function collect(uint256 _amount) public onlyOwner returns (bool) { + return agg.core().token().transfer(owner(), _amount); + } + + function borrow(uint256 _amount, uint256 _collateral, uint256 _payment, uint64 _unlock) + public + returns (LoanCommitment memory) + { + require(_collateral <= _amount, "collateral > amount"); + uint64 _duration = SafeCast.toUint64(_unlock - block.timestamp); + require(_duration <= limits.maxDuration, "duration over maximum"); + require(_amount <= limits.maxAmount, "amount over maximum"); + require(_payment >= expectedPayment(_amount, _duration), "payment below expected"); + uint256 _transferAmount = _collateral + _payment; + agg.core().token().transferFrom(msg.sender, address(this), _transferAmount); + + uint96 _loanIndex = uint96(loans.length); + loans.push(LoanState({borrower: msg.sender, initialAmount: _amount, borrowerCollateral: _collateral})); + agg.core().token().approve(address(agg), _amount); + return LoanCommitment({ + loan: AggregatedLoan({lender: this, lenderData: _loanIndex, amount: _amount}), + signature: "siggy" + }); + } + + /// Return the expected payment for a loan, based on its amount and duration. + /// @param _amount Deposit amount. + /// @param _duration Deposit duration from the block at which the deposit is funded, in seconds. + function expectedPayment(uint256 _amount, uint64 _duration) public view returns (uint256) { + // TODO: Now for the tricky bit! + // Ideally the owner would be able to set an expected annualized return. However, I did not + // figure out an obvious way to calculate the annualized return given the loan duration + // using standard Solidity math operations or those provided by OpenZeppelin. And using a + // table would not be ideal, since storage space is more difficult to justify than compute. + return 1; + } + + function onCollateralWithraw(uint256 _amount, uint96 _lenderData) public { + LoanState memory _loan = loans[_lenderData]; + delete loans[_lenderData]; + uint256 _loss = _loan.initialAmount - _amount; + if (_loss < _loan.borrowerCollateral) { + agg.core().token().transfer(_loan.borrower, _loan.borrowerCollateral - _loss); + } + } +} diff --git a/src/examples/LoanAggregator.sol b/src/examples/LoanAggregator.sol new file mode 100644 index 0000000..a8fa719 --- /dev/null +++ b/src/examples/LoanAggregator.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {HorizonCore} from "../HorizonCore.sol"; + +interface IDataService { + function remitPayment(address _provider, uint128 _deposit, uint64 _unlock) external; +} + +interface ILender { + function onCollateralWithraw(uint256 _amount, uint96 _lenderData) external; +} + +struct LoanCommitment { + AggregatedLoan loan; + bytes signature; +} + +struct AggregatedLoan { + ILender lender; + uint96 lenderData; + uint256 amount; +} + +contract LoanAggregator { + HorizonCore public core; + mapping(uint128 => AggregatedLoan[]) public loans; + + constructor(HorizonCore _core) { + core = _core; + } + + function remitPayment(IDataService _arbiter, uint64 _unlock, LoanCommitment[] calldata _loanCommitments) + public + returns (uint128) + { + uint256 _index = 0; + uint256 _amount = 0; + while (_index < _loanCommitments.length) { + LoanCommitment memory _commitment = _loanCommitments[_index]; + // TODO: verify signature of (lender, amount, arbiter, unlock) + _amount += _commitment.loan.amount; + core.token().transferFrom(address(_commitment.loan.lender), address(this), _commitment.loan.amount); + _index += 1; + } + core.token().approve(address(core), _amount); + uint128 _deposit = core.deposit(address(_arbiter), _amount, _unlock); + _index = 0; + while (_index < _loanCommitments.length) { + loans[_deposit].push(_loanCommitments[_index].loan); + _index += 1; + } + _arbiter.remitPayment(msg.sender, _deposit, _unlock); + return _deposit; + } + + function withdraw(uint128 _depositID) public { + HorizonCore.DepositState memory _deposit = core.getDepositState(_depositID); + core.withdraw(_depositID); + // calculate original deposit amount + uint256 _index = 0; + uint256 _initialAmount = 0; + while (_index < loans[_depositID].length) { + _initialAmount += loans[_depositID][_index].amount; + _index += 1; + } + // distribute remaining deposit amount back to lenders + _index = 0; + while (_index < loans[_depositID].length) { + AggregatedLoan memory _loan = loans[_depositID][_index]; + uint256 _lenderReturn = (_loan.amount * _deposit.amount) / _initialAmount; + core.token().transfer(address(_loan.lender), _lenderReturn); + _loan.lender.onCollateralWithraw(_lenderReturn, _loan.lenderData); + _index += 1; + } + delete loans[_depositID]; + } +} diff --git a/test/Example.t.sol b/test/Example.t.sol new file mode 100644 index 0000000..98d7e63 --- /dev/null +++ b/test/Example.t.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {ERC20Burnable} from "openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Burnable.sol"; +import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import {HorizonCore} from "../src/HorizonCore.sol"; +import {DataService} from "../src/examples/DataService.sol"; +import {Lender, Limits} from "../src/examples/Lender.sol"; +import { + AggregatedLoan, IDataService, ILender, LoanAggregator, LoanCommitment +} from "../src/examples/LoanAggregator.sol"; + +contract TestToken is ERC20Burnable { + constructor(uint256 _initialSupply) ERC20("MockCoin", "MOCK") { + _mint(msg.sender, _initialSupply); + } +} + +contract HorizonCoreUnitTests is Test, ILender { + TestToken public token; + HorizonCore public core; + DataService public dataService; + LoanAggregator public aggregator; + Lender public lender; + + function onCollateralWithraw(uint256 _amount, uint96 _lenderData) public {} + + function setUp() public { + token = new TestToken(1_000); + core = new HorizonCore(token); + aggregator = new LoanAggregator(core); + dataService = new DataService(core, 20 days); + lender = new Lender(aggregator, Limits({maxAmount: 100, maxDuration: 30 days})); + token.transfer(address(lender), 80); + } + + function test_Example() public { + // Add this contract as a data service provider to receive 10 tokens in payment. + token.approve(address(dataService), 10); + dataService.addProvider(address(this), 10); + + uint256 _initialBalance = token.balanceOf(address(this)); + uint256 _initialLenderBalance = token.balanceOf(address(lender)); + + // Data service requires 10x of payment (100 tokens) in a HorizonCore deposit. Fund deposit with a 20 token + // loan from self, and a 80 token loan from lender. + LoanCommitment[] memory _loanCommitments = new LoanCommitment[](2); + token.approve(address(aggregator), 20); + _loanCommitments[0] = + LoanCommitment({loan: AggregatedLoan({lender: this, lenderData: 0, amount: 20}), signature: "siggy"}); + // Send lender 5 tokens in collateral and 1 token in payment (6 total) for a 80 token loan. + token.approve(address(lender), 6); + _loanCommitments[1] = lender.borrow(80, 5, 1, dataService.disputePeriod()); + // Receive 10 token payment and start dispute period. + uint64 _unlock = uint64(block.timestamp) + dataService.disputePeriod(); + uint128 _deposit = aggregator.remitPayment(DataService(dataService), _unlock, _loanCommitments); + + assertEq(token.balanceOf(address(this)), _initialBalance + 10 - 26); + assertEq(token.balanceOf(address(lender)), _initialLenderBalance + 6 - 80); + + // Withdraw deposit at end of dispute period. + vm.warp(block.number + dataService.disputePeriod()); + aggregator.withdraw(_deposit); + assertEq(token.balanceOf(address(this)), _initialBalance + 10 - 1); + assertEq(token.balanceOf(address(lender)), _initialLenderBalance + 1); + } + + function test_ExampleSlash() public { + // Add this contract as a data service provider to receive 10 tokens in payment. + token.approve(address(dataService), 10); + dataService.addProvider(address(this), 10); + + uint256 _initialBalance = token.balanceOf(address(this)); + uint256 _initialLenderBalance = token.balanceOf(address(lender)); + + // Data service requires 10x of payment (100 tokens) in a HorizonCore deposit. Fund deposit with a 20 token + // loan from self, and a 80 token loan from lender. + LoanCommitment[] memory _loanCommitments = new LoanCommitment[](2); + token.approve(address(aggregator), 20); + _loanCommitments[0] = + LoanCommitment({loan: AggregatedLoan({lender: this, lenderData: 0, amount: 20}), signature: "siggy"}); + // Send lender 5 tokens in collateral and 1 token in payment (6 total) for a 80 token loan. + token.approve(address(lender), 6); + _loanCommitments[1] = lender.borrow(80, 5, 1, dataService.disputePeriod()); + // Receive 10 token payment and start dispute period. + uint64 _unlock = uint64(block.timestamp) + dataService.disputePeriod(); + uint128 _deposit = aggregator.remitPayment(DataService(dataService), _unlock, _loanCommitments); + + assertEq(token.balanceOf(address(this)), _initialBalance + 10 - 26); + assertEq(token.balanceOf(address(lender)), _initialLenderBalance + 6 - 80); + + // Warp to one block before disoute period end, and slash 80 tokens (80%) of deposit. + vm.warp(block.number + dataService.disputePeriod() - 1); + dataService.slash(address(this), 80); + // Warp to end of dispute period, and withdraw remaining tokens. + vm.warp(block.number + dataService.disputePeriod()); + aggregator.withdraw(_deposit); + assertEq(token.balanceOf(address(this)), _initialBalance + 10 - 22); + assertEq(token.balanceOf(address(lender)), _initialLenderBalance + 6 - 64); + } +} diff --git a/test/HorizonCore.t.sol b/test/HorizonCore.t.sol new file mode 100644 index 0000000..d311dac --- /dev/null +++ b/test/HorizonCore.t.sol @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {CommonBase} from "forge-std/Base.sol"; +import {StdUtils} from "forge-std/StdUtils.sol"; +import {Test} from "forge-std/Test.sol"; +import {ERC20Burnable} from "openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Burnable.sol"; +import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import {HorizonCore} from "../src/HorizonCore.sol"; + +contract TestToken is ERC20Burnable { + constructor(uint256 _initialSupply) ERC20("MockCoin", "MOCK") { + _mint(msg.sender, _initialSupply); + } +} + +contract HorizonCoreHandler is CommonBase, StdUtils { + HorizonCore public core; + uint256 tokenSupply; + uint128[] depositIDs; + address[] actors; + + constructor() { + tokenSupply = 900; + core = new HorizonCore(new TestToken(tokenSupply)); + actors = [address(1), address(2), address(3)]; + + uint256 _index = 0; + while (_index < actors.length) { + core.token().transfer(actors[_index], tokenSupply / (actors.length)); + _index += 1; + } + } + + function warp(uint256 blocks) public { + vm.warp(block.timestamp + bound(blocks, 1, 10)); + } + + function deposit(uint256 __sender, uint256 __arbiter, uint256 __amount, uint256 __unlock) + public + returns (uint128) + { + address _depositor = _genActor(__sender); + uint256 _amount = bound(__amount, 1, core.token().balanceOf(_depositor)); + vm.startPrank(_depositor); + core.token().approve(address(core), _amount); + uint128 _id = core.deposit(_genActor(__arbiter), _amount, _genTimestamp(__unlock)); + vm.stopPrank(); + depositIDs.push(_id); + return _id; + } + + function lock(uint256 __sender, uint256 __id, uint256 __unlock) public { + vm.prank(_genActor(__sender)); + core.lock(_genID(__id), _genTimestamp(__unlock)); + } + + function withdraw(uint256 __sender, uint256 __id) public { + uint128 _id = _genID(__id); + vm.prank(_genActor(__sender)); + core.withdraw(_id); + _removeDepositID(_id); + } + + function slash(uint256 __sender, uint256 __id, uint256 __amount) public { + uint128 _id = _genID(__id); + vm.prank(_genActor(__sender)); + core.slash(_id, bound(__amount, 0, core.getDepositState(_id).amount)); + assert(core.isSlashable(_id)); + _removeDepositID(_id); + } + + function depositTotal() public view returns (uint256) { + uint256 total = 0; + uint64 _index = 0; + while (_index < depositIDs.length) { + uint128 _id = depositIDs[_index]; + HorizonCore.DepositState memory _deposit = core.getDepositState(_id); + if (_deposit.depositor != address(0)) { + total += _deposit.amount; + } + _index += 1; + } + return total; + } + + function _removeDepositID(uint128 _id) internal { + uint256 _index = 0; + while (_index < depositIDs.length) { + if (depositIDs[_index] == _id) { + depositIDs[_index] = depositIDs[depositIDs.length - 1]; + depositIDs.pop(); + return; + } + _index += 1; + } + } + + function _genID(uint256 _seed) internal view returns (uint128) { + return depositIDs[bound(_seed, 0, depositIDs.length - 1)]; + } + + function _genActor(uint256 _seed) internal view returns (address) { + return actors[bound(_seed, 0, actors.length - 1)]; + } + + function _genTimestamp(uint256 _seed) internal view returns (uint64) { + return uint64(bound(_seed, 1, 20)); + } +} + +contract HorizonCoreInvariants is Test { + HorizonCoreHandler public handler; + ERC20Burnable public token; + + function setUp() public { + handler = new HorizonCoreHandler(); + token = handler.core().token(); + targetContract(address(handler)); + } + + function invariant_depositBalance() public { + assertEq(token.balanceOf(address(handler.core())), handler.depositTotal()); + } +} + +contract HorizonCoreUnitTests is Test { + TestToken public token; + HorizonCore public core; + + function setUp() public { + token = new TestToken(1_000); + core = new HorizonCore(token); + } + + function test_UnlockedDeposit() public { + uint256 _initialBalance = token.balanceOf(address(this)); + token.approve(address(core), 1); + uint128 _next_id = core.nextID(address(this)); + uint128 _id = core.deposit(address(0), 1, 0); + assertEq(_id, _next_id); + assertEq(token.balanceOf(address(this)), _initialBalance - 1); + assertEq(token.balanceOf(address(core)), 1); + assertEq(core.getDepositState(_id).depositor, address(this)); + assertEq(core.isSlashable(_id), false); + } + + function test_LockedDeposit() public { + uint64 _unlock = uint64(block.timestamp) + 1; + uint256 _initialBalance = token.balanceOf(address(this)); + token.approve(address(core), 1); + uint128 _id = core.deposit(address(0), 1, _unlock); + assertEq(token.balanceOf(address(this)), _initialBalance - 1); + assertEq(token.balanceOf(address(core)), 1); + assertEq(core.getDepositState(_id).depositor, address(this)); + assertEq(core.isSlashable(_id), true); + } + + function test_DepositUniqueID() public { + token.approve(address(core), 2); + uint128 _id1 = core.deposit(address(0), 1, 0); + uint128 _id2 = core.deposit(address(0), 1, 0); + assertNotEq(_id1, _id2); + } + + function test_Lock() public { + uint64 _unlock = uint64(block.timestamp) + 1; + token.approve(address(core), 1); + uint128 _id = core.deposit(address(this), 1, 0); + assertEq(core.isSlashable(_id), false); + core.lock(_id, _unlock); + assertEq(core.isSlashable(_id), true); + } + + function testFail_LockLocked() public { + uint64 _unlock = uint64(block.timestamp) + 1; + token.approve(address(core), 1); + uint128 _id = core.deposit(address(this), 1, _unlock); + core.lock(_id, _unlock); + } + + function testFail_LockLockedModify() public { + uint64 _unlock = uint64(block.timestamp) + 1; + token.approve(address(core), 1); + uint128 _id = core.deposit(address(this), 1, _unlock); + core.lock(_id, _unlock - 1); + } + + function testFail_LockAfterUnlock() public { + uint64 _unlock = uint64(block.timestamp) + 1; + token.approve(address(core), 1); + uint128 _id = core.deposit(address(this), 1, _unlock); + vm.warp(_unlock + 1); + core.lock(_id, _unlock + 1); + } + + function testFail_getDepositNoDeposit() public view { + core.getDepositState(0); + } + + function test_Slash() public { + uint64 _unlock = uint64(block.timestamp) + 3; + address _arbiter = 0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF; + uint256 _initialSupply = token.totalSupply(); + token.approve(address(core), 1); + uint128 _id = core.deposit(_arbiter, 1, _unlock); + vm.warp(_unlock - 1); + vm.prank(_arbiter); + core.slash(_id, 1); + assertEq(token.totalSupply(), _initialSupply - 1); + } + + function testFail_SlashAtUnlock() public { + uint64 _unlock = uint64(block.timestamp) + 3; + token.approve(address(core), 1); + uint128 _id = core.deposit(address(this), 1, _unlock); + vm.warp(_unlock); + core.slash(_id, 1); + } + + function testFail_SlashAfterUnlock() public { + uint64 _unlock = uint64(block.timestamp) + 3; + token.approve(address(core), 1); + uint128 _id = core.deposit(address(this), 1, _unlock); + vm.warp(_unlock + 1); + core.slash(_id, 1); + } + + function testFail_SlashUnlocked() public { + uint64 _unlock = uint64(block.timestamp) + 3; + token.approve(address(core), 1); + uint128 _id = core.deposit(address(this), 1, _unlock); + vm.warp(_unlock + 1); + core.slash(_id, 1); + } + + function testFail_SlashFromNonArbiter() public { + uint64 _unlock = uint64(block.timestamp) + 3; + address _arbiter = 0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF; + token.approve(address(core), 1); + uint128 _id = core.deposit(_arbiter, 1, _unlock); + vm.warp(_unlock - 1); + core.slash(_id, 1); + } + + function test_WithdrawAtUnlock() public { + uint64 _unlock = uint64(block.timestamp) + 1; + uint256 _initialBalance = token.balanceOf(address(this)); + token.approve(address(core), 1); + uint128 _id = core.deposit(address(this), 1, _unlock); + vm.warp(_unlock); + core.withdraw(_id); + assertEq(token.balanceOf(address(this)), _initialBalance); + assertEq(token.balanceOf(address(core)), 0); + } + + function test_WithdrawAfterUnlock() public { + uint64 _unlock = uint64(block.timestamp) + 1; + uint256 _initialBalance = token.balanceOf(address(this)); + token.approve(address(core), 1); + uint128 _id = core.deposit(address(this), 1, _unlock); + vm.warp(_unlock + 1); + core.withdraw(_id); + assertEq(token.balanceOf(address(this)), _initialBalance); + assertEq(token.balanceOf(address(core)), 0); + } + + function testFail_WithdrawBeforeUnlock() public { + token.approve(address(core), 1); + uint64 _unlock = uint64(block.timestamp) + 3; + uint128 _id = core.deposit(address(this), 1, _unlock); + vm.warp(_unlock - 1); + core.withdraw(_id); + } + + function test_WithdrawLocked() public { + uint64 _unlock = uint64(block.timestamp) + 1; + uint256 _initialBalance = token.balanceOf(address(this)); + token.approve(address(core), 1); + uint128 _id = core.deposit(address(this), 1, _unlock); + vm.warp(_unlock); + core.withdraw(_id); + assertEq(token.balanceOf(address(this)), _initialBalance); + assertEq(token.balanceOf(address(core)), 0); + } + + function testFail_WithdrawTwice() public { + uint64 _unlock = uint64(block.timestamp) + 1; + token.approve(address(core), 2); + uint128 _id = core.deposit(address(this), 2, _unlock); + vm.warp(_unlock); + core.withdraw(_id); + core.withdraw(_id); + } + + function testFail_WithdrawFromNonDepositor() public { + uint64 _unlock = uint64(block.timestamp) + 1; + uint256 _initialBalance = token.balanceOf(address(this)); + address _other = 0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF; + token.approve(address(core), 1); + uint128 _id = core.deposit(address(this), 1, _unlock); + vm.warp(_unlock); + vm.prank(_other); + core.withdraw(_id); + assertEq(token.balanceOf(address(this)), _initialBalance); + } +}