Skip to content

Commit

Permalink
Stateful delegator (#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
haydenshively authored Mar 13, 2021
1 parent f7a00ed commit 5fbc198
Show file tree
Hide file tree
Showing 25 changed files with 693 additions and 713 deletions.
1 change: 1 addition & 0 deletions services/delegator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
},
"devDependencies": {
"@tsconfig/node14": "^1.0.0",
"@types/big.js": "^6.0.2",
"@types/chai": "^4.2.14",
"@types/dotenv-safe": "^8.1.1",
"@types/mocha": "^8.0.4",
Expand Down
136 changes: 136 additions & 0 deletions services/delegator/src/Borrower.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import Web3 from 'web3';

import { Big } from '@goldenagellc/web3-blocks';

import { CTokenSymbol, cTokenSymbols } from './types/CTokens';
import { CToken } from './contracts/CToken';
import PriceLedger from './PriceLedger';
import StatefulComptroller from './StatefulComptroller';

export interface IBorrowerPosition {
supply: Big;
borrow: Big;
borrowIndex: Big;
}

interface ILiquidity {
liquidity: Big;
shortfall: Big;
symbols: CTokenSymbol[];
edges: ('min' | 'max')[];
}

export default class Borrower {
public readonly address: string;
protected readonly positions: { readonly [_ in CTokenSymbol]: IBorrowerPosition };

constructor(address: string) {
this.address = address;
this.positions = Object.fromEntries(
cTokenSymbols.map((symbol) => [symbol, { supply: new Big('0'), borrow: Big('0'), borrowIndex: Big('0') }]),
) as { [_ in CTokenSymbol]: IBorrowerPosition };
}

public async verify(
provider: Web3,
cTokens: { [_ in CTokenSymbol]: CToken },
borrowIndices: { [_ in CTokenSymbol]: Big },
threshold: number,
): Promise<boolean> {
for (let symbol of cTokenSymbols) {
const snapshot = await cTokens[symbol].getAccountSnapshot(this.address)(provider);
if (snapshot.error !== '0') {
console.error(`Failed to get account snapshot for ${this.address}: ${snapshot.error}`);
return false;
}

const position = this.positions[symbol];
if (position.borrowIndex.eq('0')) {
console.error(`${this.address} invalud due to 0 borrow index`);
return false;
}
const supply = position.supply;
const borrow = position.borrow.times(borrowIndices[symbol]).div(position.borrowIndex);

if (supply.eq('0')) {
if (!snapshot.cTokenBalance.eq('0')) {
console.error(`${this.address} invalid due to 0 supply mismatch`);
return false;
}
} else {
const supplyError = supply.minus(snapshot.cTokenBalance).div(snapshot.cTokenBalance).abs();
if (supplyError.toNumber() > threshold) {
console.error(`${this.address} invalid due to high supply error (${supplyError.toFixed(5)})`);
return false;
}
}

if (borrow.eq('0')) {
if (!snapshot.borrowBalance.eq('0')) {
console.error(`${this.address} invalid due to 0 borrow mismatch`);
return false;
}
} else {
const borrowError = borrow.minus(snapshot.borrowBalance).div(snapshot.borrowBalance).abs();
if (borrowError.toNumber() > threshold) {
console.error(`${this.address} invalid due to high borrow error (${borrowError.toFixed(5)})`);
return false;
}
}
}
return true;
}

public liquidity(
comptroller: StatefulComptroller,
priceLedger: PriceLedger,
exchangeRates: { [_ in CTokenSymbol]: Big },
borrowIndices: { [_ in CTokenSymbol]: Big },
): ILiquidity | null {
let collat: Big = new Big('0');
let borrow: Big = new Big('0');
const symbols: CTokenSymbol[] = [];
const edges: ('min' | 'max')[] = [];

for (let symbol of cTokenSymbols) {
const position = this.positions[symbol];
if (position.supply.eq('0') || position.borrow.eq('0') || position.borrowIndex.eq('0')) continue;

const collateralFactor = comptroller.getCollateralFactor(symbol);
const pricesUSD = priceLedger.getPrices(symbol);
if (collateralFactor === null || pricesUSD.min === null || pricesUSD.max === null) return null;

const edge: 'min' | 'max' = position.supply.gt('0') ? 'min' : 'max';
collat = collat.plus(
position.supply
.times(exchangeRates[symbol])
.div('1e+18')
.times(collateralFactor)
.div('1e+18')
.times(pricesUSD[edge]!),
);
borrow = borrow.plus(
position.borrow.times(borrowIndices[symbol]).div(position.borrowIndex).times(pricesUSD[edge]!),
);
symbols.push(symbol);
edges.push(edge);
}

let liquidity: Big;
let shortfall: Big;
if (collat.gt(borrow)) {
liquidity = collat.minus(borrow);
shortfall = new Big('0');
} else {
liquidity = new Big('0');
shortfall = borrow.minus(collat);
}

return {
liquidity: liquidity,
shortfall: shortfall,
symbols: symbols,
edges: edges,
};
}
}
79 changes: 79 additions & 0 deletions services/delegator/src/CompoundAPI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import nfetch from 'node-fetch';

type CompoundAPIRequestValue = { value: string };

interface ICompoundAPIRequest {
addresses?: string[];
block_number?: number;
max_health?: CompoundAPIRequestValue;
min_borrow_value_in_eth: CompoundAPIRequestValue;
page_number?: number;
page_size?: number;
network?: string;
}

const fetch = async (r: ICompoundAPIRequest) => {
const method = 'GET';
const headers = {
'Content-Type': 'application/json',
Accept: 'application/json',
};

const path = 'https://api.compound.finance/api/v2/account?';
const params = Object.keys(r)
.map((key) => {
const knownKey = key as keyof ICompoundAPIRequest;
let uri;
switch (knownKey) {
case 'max_health':
case 'min_borrow_value_in_eth':
uri = `${knownKey}[value]=${r[knownKey]!.value}`;
return encodeURIComponent(uri).replace('%3D', '=');
case 'addresses':
uri = `${knownKey}=${r[knownKey]!.join(',')}`;
return encodeURIComponent(uri);
default:
return `${knownKey}=${r[knownKey]}`;
}
})
.join('&');

const res = await nfetch(path + params, {
method: method,
headers: headers,
});
return res.json();
};

async function sleep(millis: number) {
return new Promise((resolve) => setTimeout(resolve, millis));
}

const getBorrowers = async (minBorrow_Eth: string) => {
let borrowers = <string[]>[];

let i = 1;
let pageCount = 0;

let result;
do {
result = await fetch({
min_borrow_value_in_eth: { value: minBorrow_Eth },
page_size: 100,
page_number: i,
});
if (result.error) {
console.warn(result.error.toString());
continue;
}
borrowers = borrowers.concat(result.accounts.map((account: any) => account.address));
pageCount = result.pagination_summary.total_pages;
i++;

await sleep(100); // Avoid rate limiting
} while (i <= pageCount);

return borrowers;
};

export default getBorrowers;
72 changes: 52 additions & 20 deletions services/delegator/src/PriceLedger.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Big } from '@goldenagellc/web3-blocks';

import { CoinbaseKey } from './types/CoinbaseKeys';
import { CTokenSymbol } from './types/CTokens';
import { CTokenSymbol, CTokenCoinbaseKeys } from './types/CTokens';
import IPostablePriceFormat from './types/IPostablePriceFormat';
import IPrice from './types/IPrice';
import IPriceRange from './types/IPriceRange';

Expand All @@ -13,7 +14,7 @@ interface PostableDatum {

type TimestampMap = { [i: string]: PostableDatum };

const USD_VALUE: Big = Big('1000000');
const USD_VALUE: Big = new Big('1000000');
const SAI_PER_ETH = '0.005285';

export default class PriceLedger {
Expand Down Expand Up @@ -75,42 +76,73 @@ export default class PriceLedger {
return `*${key}:*\n\tmin: $${min} (${minAge} min ago)\n\tmax: $${max} (${maxAge} min ago)`;
}

public getPostableFormat(symbols: CTokenSymbol[], edges: ('min' | 'max')[]): IPostablePriceFormat | null {
let didFindNull = false;

const formatted: IPostablePriceFormat = {
messages: [],
signatures: [],
symbols: [],
};

symbols.forEach((symbol, i) => {
const key = CTokenCoinbaseKeys[symbol];
if (key === null) return;

const prices = this.prices[key];
if (prices === null) {
didFindNull = true;
return;
}

const timestamp = prices[edges[i]].timestamp;
const postableData = this.postableData[key][timestamp];

formatted.messages.push(postableData.message);
formatted.signatures.push(postableData.signature);
formatted.signatures.push(postableData.key); // should equal local `key`
});

if (didFindNull) return null;
return formatted;
}

public getPrices(symbol: CTokenSymbol): { min: Big | null; max: Big | null } {
switch (symbol) {
case 'cBAT':
return {
min: this.prices.BAT?.min.value,
max: this.prices.BAT?.max.value,
min: this.prices.BAT?.min.value || null,
max: this.prices.BAT?.max.value || null,
};
case 'cCOMP':
return {
min: this.prices.COMP?.min.value,
max: this.prices.COMP?.max.value,
min: this.prices.COMP?.min.value || null,
max: this.prices.COMP?.max.value || null,
};
case 'cDAI':
return {
min: this.prices.DAI?.min.value,
max: this.prices.DAI?.max.value,
min: this.prices.DAI?.min.value || null,
max: this.prices.DAI?.max.value || null,
};
case 'cETH':
return {
min: this.prices.ETH?.min.value,
max: this.prices.ETH?.max.value,
min: this.prices.ETH?.min.value || null,
max: this.prices.ETH?.max.value || null,
};
case 'cREP':
return {
min: this.prices.REP?.min.value,
max: this.prices.REP?.max.value,
min: this.prices.REP?.min.value || null,
max: this.prices.REP?.max.value || null,
};
case 'cSAI':
return {
min: this.prices.ETH?.min.value.mul(SAI_PER_ETH),
max: this.prices.ETH?.max.value.mul(SAI_PER_ETH),
min: this.prices.ETH?.min.value.mul(SAI_PER_ETH) || null,
max: this.prices.ETH?.max.value.mul(SAI_PER_ETH) || null,
};
case 'cUNI':
return {
min: this.prices.UNI?.min.value,
max: this.prices.UNI?.max.value,
min: this.prices.UNI?.min.value || null,
max: this.prices.UNI?.max.value || null,
};
case 'cUSDC':
case 'cUSDT':
Expand All @@ -120,13 +152,13 @@ export default class PriceLedger {
};
case 'cWBTC':
return {
min: this.prices.BTC?.min.value,
max: this.prices.BTC?.max.value,
min: this.prices.BTC?.min.value || null,
max: this.prices.BTC?.max.value || null,
};
case 'cZRX':
return {
min: this.prices.ZRX?.min.value,
max: this.prices.ZRX?.max.value,
min: this.prices.ZRX?.min.value || null,
max: this.prices.ZRX?.max.value || null,
};
}
}
Expand Down
Loading

0 comments on commit 5fbc198

Please sign in to comment.