Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add collateralization & examples #1

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Compiler files
cache/
out/

# Ignores development broadcast logs
!/broadcast
/broadcast/*/31337/
/broadcast/**/dry-run/

# Docs
docs/

# Dotenv file
.env
6 changes: 6 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
# horizon
Contracts & Specs for Graph Horizon

## Setup

- Install [Foundry](https://book.getfoundry.sh/getting-started/installation)
11 changes: 11 additions & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions lib/forge-std
Submodule forge-std added at e8a047
1 change: 1 addition & 0 deletions lib/openzeppelin-contracts
Submodule openzeppelin-contracts added at e50c24
5 changes: 5 additions & 0 deletions remappings.txt
Original file line number Diff line number Diff line change
@@ -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/
155 changes: 155 additions & 0 deletions src/HorizonCore.sol
Original file line number Diff line number Diff line change
@@ -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))));
}
}
65 changes: 65 additions & 0 deletions src/examples/DataService.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
74 changes: 74 additions & 0 deletions src/examples/Lender.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading