Skip to content

Commit

Permalink
Stateful Comptroller and price subscribers for delegator (#6)
Browse files Browse the repository at this point in the history
* Add stateful comptroller

* Add StatefulPriceFeed with anchor values

* Add PriceData contract

* Finish first pass impl of stateful prices sub

* Remove CTokens from competitors and fix underlying symbol mapping

* Add stateful Coinbase reporter

* Extract price data structures from stateful reporter

* Rename oracle contracts to their actual names

* Direct StatefulPriceFeed updates to the PriceLedger as well

* Rename StatefulXXX that deal with prices

* Add fancy logging with winston
  • Loading branch information
haydenshively authored Mar 10, 2021
1 parent ae060f7 commit f7a00ed
Show file tree
Hide file tree
Showing 22 changed files with 933 additions and 129 deletions.
7 changes: 6 additions & 1 deletion services/delegator/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
PROVIDER_IPC_PATH=

SLACK_WEBHOOK=
SLACK_WEBHOOK=

COINBASE_ENDPOINT=
CB_ACCESS_KEY=
CB_ACCESS_SECRET=
CB_ACCESS_PASSPHRASE=
2 changes: 2 additions & 0 deletions services/delegator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@grpc/grpc-js": "^1.2.2",
"big.js": "^5.2.2",
"dotenv-safe": "^8.2.0",
"node-fetch": "^2.6.1",
"node-ipc": "^9.1.4",
"web3": "^1.3.4",
"winston": "^3.3.3"
Expand All @@ -24,6 +25,7 @@
"@types/dotenv-safe": "^8.1.1",
"@types/mocha": "^8.0.4",
"@types/node": "^14.14.10",
"@types/node-fetch": "^2.5.8",
"@types/node-ipc": "^9.1.3",
"@typescript-eslint/eslint-plugin": "^4.9.0",
"@typescript-eslint/parser": "^4.9.0",
Expand Down
88 changes: 88 additions & 0 deletions services/delegator/src/CoinbaseReporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import crypto from 'crypto';
import nfetch from 'node-fetch';

/* eslint-disable @typescript-eslint/no-var-requires */
const AbiCoder = require('web3-eth-abi');
/* eslint-enable @typescript-eslint/no-var-requires */

interface DecodedMessage {
key: string;
price: string;
timestamp: string;
}

interface LocalSignature {
hash: string;
timestamp: string;
}

interface CoinbaseReport {
timestamp: string;
messages: string[];
signatures: string[];
prices: { [i: string]: string };
}

export default class CoinbaseReporter {
private readonly coinbaseEndpoint: string;
private readonly coinbaseKey: string;
private readonly coinbaseSecret: string;
private readonly coinbasePassphrase: string;

constructor(coinbaseEndpoint: string, coinbaseKey: string, coinbaseSecret: string, coinbasePassphrase: string) {
this.coinbaseEndpoint = coinbaseEndpoint;
this.coinbaseKey = coinbaseKey;
this.coinbaseSecret = coinbaseSecret;
this.coinbasePassphrase = coinbasePassphrase;
}

public static decode(message: string): DecodedMessage {
const {
// 0: kind,
1: timestamp,
2: key,
3: price,
} = AbiCoder.decodeParameters(['string', 'uint64', 'string', 'uint64'], message);

return {
key: key,
price: price,
timestamp: timestamp,
};
}

protected async fetchCoinbasePrices(): Promise<CoinbaseReport> {
const path = '/oracle';
const method = 'GET';

const sig = this.localSignature(path, method);
const headers = {
'Content-Type': 'application/json',
Accept: 'application/json',
'CB-ACCESS-KEY': this.coinbaseKey,
'CB-ACCESS-SIGN': sig.hash,
'CB-ACCESS-TIMESTAMP': sig.timestamp,
'CB-ACCESS-PASSPHRASE': this.coinbasePassphrase,
};

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

private localSignature(path = '/oracle', method = 'GET', body = ''): LocalSignature {
const timestamp = (Date.now() / 1000).toFixed(0);
const prehash = timestamp + method.toUpperCase() + path + body;
const hash = crypto
.createHmac('sha256', Buffer.from(this.coinbaseSecret, 'base64'))
.update(prehash)
.digest('base64');

return {
hash: hash,
timestamp: timestamp,
};
}
}
Empty file.
190 changes: 190 additions & 0 deletions services/delegator/src/PriceLedger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { Big } from '@goldenagellc/web3-blocks';

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

interface PostableDatum {
key: CoinbaseKey;
message: string;
signature: string;
}

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

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

export default class PriceLedger {
private readonly prices: { [_ in CoinbaseKey]: IPriceRange | null } = {
BAT: null,
COMP: null,
DAI: null,
ETH: null,
REP: null,
UNI: null,
BTC: null,
ZRX: null,
};
private readonly priceHistories: { readonly [_ in CoinbaseKey]: IPrice[] } = {
BAT: [],
COMP: [],
DAI: [],
ETH: [],
REP: [],
UNI: [],
BTC: [],
ZRX: [],
};
private readonly postableData: { readonly [_ in CoinbaseKey]: TimestampMap } = {
BAT: {},
COMP: {},
DAI: {},
ETH: {},
REP: {},
UNI: {},
BTC: {},
ZRX: {},
};

public get summaryText(): string {
return this.summaryTextForAll(Object.keys(this.prices) as CoinbaseKey[]);
}

public summaryTextForAll(keys: CoinbaseKey[]): string {
const texts: string[] = [];
keys.forEach((key) => {
const text = this.summaryTextFor(key);
if (text !== null) texts.push(text);
});
return texts.join('\n');
}

public summaryTextFor(key: CoinbaseKey): string | null {
const price = this.prices[key];
if (price === null) return null;

const min = price.min.value.div('1e+6').toFixed(2);
const max = price.max.value.div('1e+6').toFixed(2);

const now = Date.now() / 1000;
const minAge = ((now - Number(price.min.timestamp)) / 60).toFixed(0);
const maxAge = ((now - Number(price.max.timestamp)) / 60).toFixed(0);

return `*${key}:*\n\tmin: $${min} (${minAge} min ago)\n\tmax: $${max} (${maxAge} min ago)`;
}

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,
};
case 'cCOMP':
return {
min: this.prices.COMP?.min.value,
max: this.prices.COMP?.max.value,
};
case 'cDAI':
return {
min: this.prices.DAI?.min.value,
max: this.prices.DAI?.max.value,
};
case 'cETH':
return {
min: this.prices.ETH?.min.value,
max: this.prices.ETH?.max.value,
};
case 'cREP':
return {
min: this.prices.REP?.min.value,
max: this.prices.REP?.max.value,
};
case 'cSAI':
return {
min: this.prices.ETH?.min.value.mul(SAI_PER_ETH),
max: this.prices.ETH?.max.value.mul(SAI_PER_ETH),
};
case 'cUNI':
return {
min: this.prices.UNI?.min.value,
max: this.prices.UNI?.max.value,
};
case 'cUSDC':
case 'cUSDT':
return {
min: USD_VALUE,
max: USD_VALUE,
};
case 'cWBTC':
return {
min: this.prices.BTC?.min.value,
max: this.prices.BTC?.max.value,
};
case 'cZRX':
return {
min: this.prices.ZRX?.min.value,
max: this.prices.ZRX?.max.value,
};
}
}

public append(key: CoinbaseKey, price: IPrice, message: string, signature: string): boolean {
// Check to make sure price is actually new
const len = this.priceHistories[key].length;
if (len > 0 && this.priceHistories[key][len - 1].timestamp === price.timestamp) return false;

this.priceHistories[key].push(price);
this.postableData[key][price.timestamp!] = {
key: key,
message: message,
signature: signature,
};

return this.updateMinMax(key, price);
}

private updateMinMax(key: CoinbaseKey, price: IPrice): boolean {
let didUpdate = false;

const priceOld = this.prices[key];
if (priceOld === null) {
this.prices[key] = {
min: price,
max: price,
};
didUpdate = true;
} else if (price.value.lte(priceOld.min.value)) {
this.prices[key]!.min = price;
didUpdate = true;
} else if (price.value.gte(priceOld.max.value)) {
this.prices[key]!.max = price;
didUpdate = true;
}

return didUpdate;
}

private resetPrices(key: CoinbaseKey, maskToTimestamp: string): void {
this.prices[key] = null;
this.priceHistories[key].forEach((price) => {
if (Number(price.timestamp) < Number(maskToTimestamp)) return;
this.updateMinMax(key, price);
});
}

public cleanHistory(key: CoinbaseKey, delToTimestamp: string, maskToTimestamp: string): void {
let i: number;
for (i = 0; i < this.priceHistories[key].length; i += 1) {
const ts = this.priceHistories[key][i].timestamp;
if (Number(ts) >= Number(delToTimestamp)) break;

delete this.postableData[key][delToTimestamp];
}
this.priceHistories[key].splice(0, i);

this.resetPrices(key, maskToTimestamp);
}
}
Loading

0 comments on commit f7a00ed

Please sign in to comment.