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 10 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/
146 changes: 146 additions & 0 deletions src/Collateralization.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import "openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Burnable.sol";

/// The state associated with a slashable, potentially time-locked deposit of tokens. 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 value 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 {
Theodus marked this conversation as resolved.
Show resolved Hide resolved
// creator of the deposit, has ability to withdraw when the deposit is unlocked
address depositor;
// authority to slash deposit value, when the deposit is locked
address arbiter;
// token amount associated with deposit
uint256 value;
Theodus marked this conversation as resolved.
Show resolved Hide resolved
// 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 (unlock == 0) │ │ │
// ─────────────────────>│ │ │
// │ │ │
// deposit (unlock != 0) │ │
// ───────────────────────────────────────────────────────>│ │
// │ │ │
// │ lock (block.timestamp < _unlock)│ │
// │ ───────────────────────────────>│ │
// │ │ │
// │ (block.timestamp >= unlock) │ │
// │ <─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│ │
// │ │ │
// │ withdraw │ │
// │ ───────────────────────────────────────────────────>│
// ┌───┴────┐ ┌──┴───┐ ┌────┴────┐
// │unlocked│ │locked│ │withdrawn│
// └────────┘ └──────┘ └─────────┘

/// This contract manages Deposits as described above.
contract Collateralization {
Theodus marked this conversation as resolved.
Show resolved Hide resolved
event Deposit(uint128 indexed id, address indexed arbiter, uint256 value, uint64 unlock);
Theodus marked this conversation as resolved.
Show resolved Hide resolved
event Lock(uint128 indexed id, uint64 unlock);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit/suggestion: Event names could be a bit more verbose, e.g. DepositCreated, DepositLocked, etc.

event Slash(uint128 indexed id, uint256 amount);
event Withdraw(uint128 indexed id);

/// Burnable ERC-20 token held by this contract.
ERC20Burnable public token;
/// Mapping of deposit IDs to deposits.
mapping(uint128 => DepositState) public deposits;
/// Counter for assigning new deposit IDs.
uint128 public lastID;

/// @param _token the burnable ERC-20 token held by this contract.
constructor(ERC20Burnable _token) {
// TODO: use a constant
Theodus marked this conversation as resolved.
Show resolved Hide resolved
token = _token;
}

/// Create a new deposit, returning its associated ID.
/// @param _arbiter Arbiter of the new deposit.
/// @param _value Initial token value 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 _value, uint64 _unlock) public returns (uint128) {
Theodus marked this conversation as resolved.
Show resolved Hide resolved
lastID += 1;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking this way to create IDs is unpredictable, since the resulting ID can be larger than expected if someone creates a deposit in the same block. So it might be a good idea to instead use a per-sender predictable ID e.g. keccak256(msg.sender, nonces[msg.sender]) so that a caller can batch a deposit + lock or getDeposit or some other action knowing what the ID will be.

Copy link
Member

@pcarranzav pcarranzav Jul 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And a depositFrom function could be useful for users to create deposits on behalf of other depositors (as long as they have a token approval)

Edit: actually, I think the opposite may be more useful: a depositFor or depositTo where a third party starts a deposit on behalf of someone else, providing the tokens. We have a stakeTo in the Staking contract that serves this purpose. This could be useful, among other things, if a service provider has tokens on several accounts but uses a single address to provide services.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Echoing Pablo's comments on IDs, I would also add block.chainId to the id generation/derivation.

I can't think of any potential problems now, but since we will be building on top of this I think it's better to add it and rule out any silly cross-chain replay attacks.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aaah good point, making deposit IDs unique cross-chain could prove useful in the future

deposits[lastID] = DepositState({
depositor: msg.sender,
arbiter: _arbiter,
value: _value,
unlock: _unlock,
start: uint64(block.timestamp),
end: 0
});
bool _transferSuccess = token.transferFrom(msg.sender, address(this), _value);
require(_transferSuccess, "transfer failed");
emit Deposit(lastID, _arbiter, _value, _unlock);
return lastID;
}

/// Lock the deposit associated with the given ID. This makes the deposit slashable until it is unlocked.
/// @param _id ID of the associated deposit.
/// @param _unlock Unlock timestamp of deposit, in seconds.
function lock(uint128 _id, uint64 _unlock) public {
DepositState memory _deposit = getDeposit(_id);
require(msg.sender == _deposit.arbiter, "sender not arbiter");
require(_deposit.end == 0, "deposit withdrawn");
if (_deposit.unlock == _unlock) {
Theodus marked this conversation as resolved.
Show resolved Hide resolved
return;
}
require(_deposit.unlock == 0, "deposit locked");
deposits[_id].unlock = _unlock;
emit Lock(_id, _unlock);
}

/// Burn some amount of the deposit value 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) public {
DepositState memory _deposit = getDeposit(_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.value, "amount too large");
deposits[_id].value -= _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) public {
DepositState memory _deposit = getDeposit(_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.value);
require(_transferSuccess, "transfer failed");
emit Withdraw(_id);
}

/// Return the deposit state associated with the given ID.
/// @param _id ID of the associated deposit.
function getDeposit(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.
Theodus marked this conversation as resolved.
Show resolved Hide resolved
/// @param _id ID of the associated deposit.
function isSlashable(uint128 _id) public view returns (bool) {
DepositState memory _deposit = getDeposit(_id);
return (block.timestamp < _deposit.unlock);
}
}
63 changes: 63 additions & 0 deletions src/examples/DataService.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol";
import {Collateralization, DepositState} from "../Collateralization.sol";
import {IDataService} from "./LoanAggregator.sol";

contract DataService is Ownable, IDataService {
struct ProviderState {
uint128 deposit;
uint128 payment;
}

Collateralization public collateralization;
mapping(address => ProviderState) public providers;
uint64 public disputePeriod;

constructor(Collateralization _collateralization, uint64 _disputePeriod) {
collateralization = _collateralization;
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});
collateralization.token().transferFrom(msg.sender, address(this), _payment);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you elaborate what this is/does and why it's onlyOwner?

}

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);
collateralization.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);
DepositState memory _deposit = collateralization.getDeposit(_depositID);

uint256 minCollateral = uint256(_provider.payment) * 10;
require(_deposit.value >= minCollateral, "collateral below minimum");
uint128 disputePeriodEnd = uint128(block.timestamp + disputePeriod);
require(_unlock >= disputePeriodEnd, "collateral unlock before end of dispute period");

providers[_providerAddr].deposit = _depositID;
collateralization.lock(_depositID, _unlock);
collateralization.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 maxValue;
uint64 maxDuration;
}

contract Lender is Ownable, ILender {
struct LoanState {
address borrower;
uint256 initialValue;
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.collateralization().token().transfer(owner(), _amount);
}

function borrow(uint256 _value, uint256 _collateral, uint256 _payment, uint64 _unlock)
public
returns (LoanCommitment memory)
{
require(_collateral <= _value, "collateral > value");
uint64 _duration = SafeCast.toUint64(_unlock - block.timestamp);
require(_duration <= limits.maxDuration, "duration over maximum");
require(_value <= limits.maxValue, "value over maximum");
require(_payment >= expectedPayment(_value, _duration), "payment below expected");
uint256 _transferAmount = _collateral + _payment;
agg.collateralization().token().transferFrom(msg.sender, address(this), _transferAmount);

uint96 _loanIndex = uint96(loans.length);
loans.push(LoanState({borrower: msg.sender, initialValue: _value, borrowerCollateral: _collateral}));
agg.collateralization().token().approve(address(agg), _value);
return LoanCommitment({
loan: AggregatedLoan({lender: this, lenderData: _loanIndex, value: _value}),
signature: "siggy"
});
}

/// Return the expected payment for a loan, based on its value and duration.
/// @param _value Deposit value.
/// @param _duration Deposit duration from the block at which the deposit is funded, in seconds.
function expectedPayment(uint256 _value, 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 _value, uint96 _lenderData) public {
LoanState memory _loan = loans[_lenderData];
delete loans[_lenderData];
uint256 _loss = _loan.initialValue - _value;
if (_loss < _loan.borrowerCollateral) {
agg.collateralization().token().transfer(_loan.borrower, _loan.borrowerCollateral - _loss);
}
}
}
Loading