diff --git a/services/delegator/.env.example b/services/delegator/.env.example index 4df2cf2..c21d60e 100644 --- a/services/delegator/.env.example +++ b/services/delegator/.env.example @@ -1,3 +1,8 @@ PROVIDER_IPC_PATH= -SLACK_WEBHOOK= \ No newline at end of file +SLACK_WEBHOOK= + +COINBASE_ENDPOINT= +CB_ACCESS_KEY= +CB_ACCESS_SECRET= +CB_ACCESS_PASSPHRASE= \ No newline at end of file diff --git a/services/delegator/package.json b/services/delegator/package.json index ab6647e..ae1fec8 100644 --- a/services/delegator/package.json +++ b/services/delegator/package.json @@ -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" @@ -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", diff --git a/services/delegator/src/CoinbaseReporter.ts b/services/delegator/src/CoinbaseReporter.ts new file mode 100644 index 0000000..9ad1bf8 --- /dev/null +++ b/services/delegator/src/CoinbaseReporter.ts @@ -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 { + 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, + }; + } +} diff --git a/services/delegator/src/ComptrollerStateful.ts b/services/delegator/src/ComptrollerStateful.ts deleted file mode 100644 index e69de29..0000000 diff --git a/services/delegator/src/PriceLedger.ts b/services/delegator/src/PriceLedger.ts new file mode 100644 index 0000000..34d324e --- /dev/null +++ b/services/delegator/src/PriceLedger.ts @@ -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); + } +} diff --git a/services/delegator/src/StatefulComptroller.ts b/services/delegator/src/StatefulComptroller.ts new file mode 100644 index 0000000..4b2a14b --- /dev/null +++ b/services/delegator/src/StatefulComptroller.ts @@ -0,0 +1,195 @@ +import { EventData } from 'web3-eth-contract'; +import Web3 from 'web3'; + +import { Big } from '@goldenagellc/web3-blocks'; + +import { Comptroller } from './contracts/Comptroller'; +import { CTokens, CTokenSymbol, cTokenSymbols } from './types/CTokens'; + +interface BlockchainNumber { + value: Big; + block: number; + logIndex: number; +} + +export default class StatefulComptroller { + private readonly provider: Web3; + private readonly comptroller: Comptroller; + + private closeFactor: BlockchainNumber | null = null; + private liquidationIncentive: BlockchainNumber | null = null; + private collateralFactors: { -readonly [_ in CTokenSymbol]: BlockchainNumber | null } = { + cBAT: null, + cCOMP: null, + cDAI: null, + cETH: null, + cREP: null, + cSAI: null, + cUNI: null, + cUSDC: null, + cUSDT: null, + cWBTC: null, + cZRX: null, + }; + + constructor(provider: Web3, comptroller: Comptroller) { + this.provider = provider; + this.comptroller = comptroller; + } + + public async init(): Promise { + const block = await this.provider.eth.getBlockNumber(); + + const promises: Promise[] = []; + promises.push( + ...this.fetchCollateralFactors(block), + this.fetchCloseFactor(block), + this.fetchLiquidationIncentive(block), + ); + await Promise.all(promises); + + this.subscribeToCloseFactor(block); + this.subscribeToLiquidationIncentive(block); + this.subscribeToCollateralFactors(block); + } + + public getCloseFactor(): Big | null { + return this.closeFactor?.value; + } + + public getLiquidationIncentive(): Big | null { + return this.liquidationIncentive?.value; + } + + public getCollateralFactor(symbol: CTokenSymbol): Big | null { + return this.collateralFactors[symbol]?.value; + } + + private async fetchCloseFactor(block: number): Promise { + this.closeFactor = { + value: await this.comptroller.closeFactor()(this.provider, block), + block: block, + logIndex: 0, + }; + } + + private async fetchLiquidationIncentive(block: number): Promise { + this.liquidationIncentive = { + value: await this.comptroller.liquidationIncentive()(this.provider, block), + block: block, + logIndex: 0, + }; + } + + private fetchCollateralFactors(block: number): Promise[] { + return cTokenSymbols.map(async (symbol) => { + this.collateralFactors[symbol] = { + value: await this.comptroller.collateralFactorOf(CTokens[symbol])(this.provider, block), + block: block, + logIndex: 0, + }; + }); + } + + private static shouldAllowData(ev: EventData, prop: BlockchainNumber): boolean { + return ev.blockNumber > prop.block || (ev.blockNumber == prop.block && ev.logIndex > prop.logIndex); + } + + private static shouldAllowDataChange(ev: EventData, prop: BlockchainNumber): boolean { + return ev.blockNumber < prop.block || (ev.blockNumber == prop.block && ev.logIndex < prop.logIndex); + } + + private subscribeToCloseFactor(block: number): void { + this.comptroller + .bindTo(this.provider) + .subscribeTo.NewCloseFactor(block) + .on('connected', (id: string) => { + console.log(`StatefulComptroller: Bound close factor to ${id}`); + }) + .on('data', (ev: EventData) => { + if (!StatefulComptroller.shouldAllowData(ev, this.closeFactor!)) return; + + this.closeFactor = { + value: Big(ev.returnValues.newCloseFactorMantissa), + block: ev.blockNumber, + logIndex: ev.logIndex, + }; + }) + .on('changed', (ev: EventData) => { + if (!StatefulComptroller.shouldAllowDataChange(ev, this.closeFactor!)) return; + + this.closeFactor = { + value: Big(ev.returnValues.oldCloseFactorMantissa), + block: ev.blockNumber, + logIndex: ev.logIndex, + }; + }) + .on('error', console.log); + } + + private subscribeToLiquidationIncentive(block: number): void { + this.comptroller + .bindTo(this.provider) + .subscribeTo.NewLiquidationIncentive(block) + .on('connected', (id: string) => { + console.log(`StatefulComptroller: Bound liquidation incentive to ${id}`); + }) + .on('data', (ev: EventData) => { + if (!StatefulComptroller.shouldAllowData(ev, this.liquidationIncentive!)) return; + + this.liquidationIncentive = { + value: Big(ev.returnValues.newLiquidationIncentiveMantissa), + block: ev.blockNumber, + logIndex: ev.logIndex, + }; + }) + .on('changed', (ev: EventData) => { + if (!StatefulComptroller.shouldAllowDataChange(ev, this.liquidationIncentive!)) return; + + this.liquidationIncentive = { + value: Big(ev.returnValues.oldLiquidationIncentiveMantissa), + block: ev.blockNumber, + logIndex: ev.logIndex, + }; + }) + .on('error', console.log); + } + + private subscribeToCollateralFactors(block: number): void { + this.comptroller + .bindTo(this.provider) + .subscribeTo.NewCollateralFactor(block) + .on('connected', (id: string) => { + console.log(`StatefulComptroller: Bound collateral factors to ${id}`); + }) + .on('data', (ev: EventData) => { + const address: string = ev.returnValues.cToken; + + cTokenSymbols.forEach((symbol) => { + if (CTokens[symbol] === address) { + const collateralFactor = this.collateralFactors[symbol]!; + if (!StatefulComptroller.shouldAllowData(ev, collateralFactor)) return; + + collateralFactor.value = Big(ev.returnValues.newCollateralFactorMantissa); + collateralFactor.block = ev.blockNumber; + collateralFactor.logIndex = ev.logIndex; + } + }); + }) + .on('changed', (ev: EventData) => { + const address: string = ev.returnValues.cToken; + + cTokenSymbols.forEach((symbol) => { + if (CTokens[symbol] === address) { + const collateralFactor = this.collateralFactors[symbol]!; + if (!StatefulComptroller.shouldAllowDataChange(ev, collateralFactor)) return; + + collateralFactor.value = Big(ev.returnValues.oldCollateralFactorMantissa); + collateralFactor.block = ev.blockNumber; + collateralFactor.logIndex = ev.logIndex; + } + }); + }) + .on('error', console.log); + } +} diff --git a/services/delegator/src/StatefulPricesCoinbase.ts b/services/delegator/src/StatefulPricesCoinbase.ts new file mode 100644 index 0000000..95952db --- /dev/null +++ b/services/delegator/src/StatefulPricesCoinbase.ts @@ -0,0 +1,64 @@ +import { FetchError } from 'node-fetch'; +import winston from 'winston'; + +import { Big } from '@goldenagellc/web3-blocks'; + +import { CoinbaseKey, coinbaseKeyMap } from './types/CoinbaseKeys'; +import IPrice from './types/IPrice'; +import CoinbaseReporter from './CoinbaseReporter'; +import PriceLedger from './PriceLedger'; + +export default class StatefulPricesCoinbase extends CoinbaseReporter { + private readonly ledger: PriceLedger; + private fetchHandle: NodeJS.Timeout | null = null; + + constructor( + ledger: PriceLedger, + coinbaseEndpoint: string, + coinbaseKey: string, + coinbaseSecret: string, + coinbasePassphrase: string, + ) { + super(coinbaseEndpoint, coinbaseKey, coinbaseSecret, coinbasePassphrase); + this.ledger = ledger; + } + + public async init(fetchInterval = 120000): Promise { + await this.update(); + if (this.fetchHandle !== null) clearInterval(this.fetchHandle); + this.fetchHandle = setInterval(this.update.bind(this), fetchInterval); + } + + private async update(): Promise { + const updatedKeys = await this.fetch(); + + // TODO: trigger callbacks + if (updatedKeys.length > 0) winston.info(this.ledger.summaryText); + } + + private async fetch(): Promise { + try { + const updatedKeys: CoinbaseKey[] = []; + + const report = await this.fetchCoinbasePrices(); + for (let i = 0; i < report.messages.length; i += 1) { + const message = report.messages[i]; + const signature = report.signatures[i]; + const { timestamp, key, price: value } = StatefulPricesCoinbase.decode(message); + + // Skip if symbol is unknown + if (!Object.keys(coinbaseKeyMap).includes(key)) continue; + const knownKey = key as CoinbaseKey; + + // Store + const price: IPrice = { value: Big(value), timestamp: timestamp }; + if (this.ledger.append(knownKey, price, message, signature)) updatedKeys.push(knownKey); + } + return updatedKeys; + } catch (e) { + if (e instanceof FetchError) console.log('Coinbase fetch failed. Connection probably timed out'); + else console.log(e); + return []; + } + } +} diff --git a/services/delegator/src/StatefulPricesOnChain.ts b/services/delegator/src/StatefulPricesOnChain.ts new file mode 100644 index 0000000..abf0cd6 --- /dev/null +++ b/services/delegator/src/StatefulPricesOnChain.ts @@ -0,0 +1,121 @@ +import { EventData } from 'web3-eth-contract'; +import Web3 from 'web3'; +import winston from 'winston'; + +import { Big } from '@goldenagellc/web3-blocks'; + +import { OpenOraclePriceData } from './contracts/OpenOraclePriceData'; +import { UniswapAnchoredView } from './contracts/UniswapAnchoredView'; +import { CoinbaseKey, coinbaseKeyMap } from './types/CoinbaseKeys'; +import { CTokens, CTokenUnderlyingDecimals as decimals } from './types/CTokens'; +import IPrice from './types/IPrice'; +import PriceLedger from './PriceLedger'; + +interface IOnChainPrice extends IPrice { + block: number; + logIndex: number; +} + +export default class StatefulPricesOnChain { + private readonly provider: Web3; + private readonly ledger: PriceLedger; + private readonly openOraclePriceData: OpenOraclePriceData; + private readonly uniswapAnchoredView: UniswapAnchoredView; + + private prices: { [_ in CoinbaseKey]: IOnChainPrice[] } = { + BAT: [], + COMP: [], + DAI: [], + ETH: [], + REP: [], + UNI: [], + BTC: [], + ZRX: [], + }; + + constructor( + provider: Web3, + ledger: PriceLedger, + openOraclePriceData: OpenOraclePriceData, + uniswapAnchoredView: UniswapAnchoredView, + ) { + this.provider = provider; + this.ledger = ledger; + this.openOraclePriceData = openOraclePriceData; + this.uniswapAnchoredView = uniswapAnchoredView; + } + + public async init(): Promise { + const block = await this.provider.eth.getBlockNumber(); + await Promise.all(this.fetchPrices(block)); + + this.subscribeToPrices(block); + } + + private fetchPrices(block: number): Promise[] { + return Object.keys(coinbaseKeyMap).map(async (key) => { + const knownKey = key as CoinbaseKey; + const symbol = coinbaseKeyMap[knownKey]; + + const price = await this.uniswapAnchoredView.getUnderlyingPrice(CTokens[symbol])(this.provider, block); + this.prices[knownKey].push({ + value: price.div(`1e+${(36 - 6 - decimals[symbol]).toFixed(0)}`), + timestamp: '0', + block: block, + logIndex: 0, + }); + }); + } + + private subscribeToPrices(block: number): void { + this.openOraclePriceData + .bindTo(this.provider) + .subscribeTo.Write(block) + .on('connected', (id: string) => { + console.log(`StatefulPriceFeed: Bound prices to ${id}`); + }) + .on('data', (ev: EventData) => { + if (!Object.keys(coinbaseKeyMap).includes(ev.returnValues.key)) return; + const knownKey = ev.returnValues.key as CoinbaseKey; + + // Store the new price + const newPrice = { + value: Big(ev.returnValues.value), + timestamp: ev.returnValues.timestamp, + block: ev.blockNumber, + logIndex: ev.logIndex, + }; + this.prices[knownKey].push(newPrice); + // Sort in-place, most recent block first (in case events come out-of-order) + this.prices[knownKey].sort((a, b) => b.block - a.block); + // Assume chain won't reorder more than 12 blocks, and trim prices array accordingly... + // BUT always maintain at least 2 items in the array (new price and 1 other price) + // in case the new price gets removed from the chain later on (need fallback) + const idx = this.prices[knownKey].findIndex((p) => newPrice.block - p.block > 12); + if (idx !== -1) this.prices[knownKey].splice(Math.max(idx, 2)); + + this.propogateToLedger(knownKey); + winston.info(`📈 ${knownKey} price posted to chain!\n${this.ledger.summaryTextFor(knownKey)}`); + }) + .on('changed', (ev: EventData) => { + if (!Object.keys(coinbaseKeyMap).includes(ev.returnValues.key)) return; + const knownKey = ev.returnValues.key as CoinbaseKey; + + const idx = this.prices[knownKey].findIndex((p) => p.block === ev.blockNumber && p.logIndex === ev.logIndex); + if (idx !== -1) this.prices[knownKey].splice(idx, 1); + + this.propogateToLedger(knownKey); + winston.info(`⚠️ ${knownKey} price suffered chain reorganization!\n${this.ledger.summaryTextFor(knownKey)}`); + }) + .on('error', console.log); + } + + private propogateToLedger(key: CoinbaseKey): void { + const len = this.prices[key].length; + this.ledger.cleanHistory( + key, + this.prices[key][len - 1].timestamp, // delete anything older than prices in oldest block + this.prices[key][0].timestamp, // mask anything older than prices in newest block (don't delete; reorg possible) + ); + } +} diff --git a/services/delegator/src/contracts/OpenOraclePriceData.ts b/services/delegator/src/contracts/OpenOraclePriceData.ts new file mode 100644 index 0000000..48bb3f5 --- /dev/null +++ b/services/delegator/src/contracts/OpenOraclePriceData.ts @@ -0,0 +1,19 @@ +import Web3Utils from 'web3-utils'; + +import { BindableContract } from '@goldenagellc/web3-blocks'; + +import abi from './abis/openoraclepricedata.json'; + +export enum OpenOraclePriceDataEvents { + Write = 'Write', +} + +export class OpenOraclePriceData extends BindableContract { + constructor(address: string, creationBlock: number) { + super(address, abi as Web3Utils.AbiItem[], OpenOraclePriceDataEvents, creationBlock); + } +} + +const openOraclePriceData = new OpenOraclePriceData('0xc629C26dcED4277419CDe234012F8160A0278a79', 10551018); + +export default openOraclePriceData; diff --git a/services/delegator/src/contracts/PriceFeed.ts b/services/delegator/src/contracts/UniswapAnchoredView.ts similarity index 74% rename from services/delegator/src/contracts/PriceFeed.ts rename to services/delegator/src/contracts/UniswapAnchoredView.ts index 776bf45..db0aa15 100644 --- a/services/delegator/src/contracts/PriceFeed.ts +++ b/services/delegator/src/contracts/UniswapAnchoredView.ts @@ -6,15 +6,15 @@ import { CTokens } from '../types/CTokens'; import abi from './abis/uniswapanchoredview.json'; -export enum PriceFeedEvents { +export enum UniswapAnchoredViewEvents { AnchorPriceUpdated = 'AnchorPriceUpdated', PriceUpdated = 'PriceUpdated', UniswapWindowUpdated = 'UniswapWindowUpdated', } -export class PriceFeed extends BindableContract { +export class UniswapAnchoredView extends BindableContract { constructor(address: string, creationBlock: number) { - super(address, abi as Web3Utils.AbiItem[], PriceFeedEvents, creationBlock); + super(address, abi as Web3Utils.AbiItem[], UniswapAnchoredViewEvents, creationBlock); } public anchorPeriod(): ContractCaller { @@ -38,6 +38,6 @@ export class PriceFeed extends BindableContract { } } -const priceFeed = new PriceFeed('0x922018674c12a7F0D394ebEEf9B58F186CdE13c1', 10921522); +const uniswapAnchoredView = new UniswapAnchoredView('0x922018674c12a7F0D394ebEEf9B58F186CdE13c1', 10921522); -export default priceFeed; +export default uniswapAnchoredView; diff --git a/services/delegator/src/contracts/abis/openoraclepricedata.json b/services/delegator/src/contracts/abis/openoraclepricedata.json new file mode 100644 index 0000000..bbdcbf3 --- /dev/null +++ b/services/delegator/src/contracts/abis/openoraclepricedata.json @@ -0,0 +1,66 @@ +[ + { + "anonymous": false, + "inputs": [ + { "indexed": false, "internalType": "uint64", "name": "priorTimestamp", "type": "uint64" }, + { "indexed": false, "internalType": "uint256", "name": "messageTimestamp", "type": "uint256" }, + { "indexed": false, "internalType": "uint256", "name": "blockTimestamp", "type": "uint256" } + ], + "name": "NotWritten", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "address", "name": "source", "type": "address" }, + { "indexed": false, "internalType": "string", "name": "key", "type": "string" }, + { "indexed": false, "internalType": "uint64", "name": "timestamp", "type": "uint64" }, + { "indexed": false, "internalType": "uint64", "name": "value", "type": "uint64" } + ], + "name": "Write", + "type": "event" + }, + { + "inputs": [ + { "internalType": "address", "name": "source", "type": "address" }, + { "internalType": "string", "name": "key", "type": "string" } + ], + "name": "get", + "outputs": [ + { "internalType": "uint64", "name": "", "type": "uint64" }, + { "internalType": "uint64", "name": "", "type": "uint64" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "source", "type": "address" }, + { "internalType": "string", "name": "key", "type": "string" } + ], + "name": "getPrice", + "outputs": [{ "internalType": "uint64", "name": "", "type": "uint64" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "bytes", "name": "message", "type": "bytes" }, + { "internalType": "bytes", "name": "signature", "type": "bytes" } + ], + "name": "put", + "outputs": [{ "internalType": "string", "name": "", "type": "string" }], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "bytes", "name": "message", "type": "bytes" }, + { "internalType": "bytes", "name": "signature", "type": "bytes" } + ], + "name": "source", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "pure", + "type": "function" + } +] diff --git a/services/delegator/src/logging/SlackHook.ts b/services/delegator/src/logging/SlackHook.ts new file mode 100644 index 0000000..dd4d8ca --- /dev/null +++ b/services/delegator/src/logging/SlackHook.ts @@ -0,0 +1,25 @@ +import nfetch from 'node-fetch'; +import winston from 'winston'; +import Transport from 'winston-transport'; + +export default class SlackHook extends Transport { + private webhookURL: string; + + constructor(webhookURL: string, opts: winston.transport.TransportStreamOptions | undefined = undefined) { + super(opts); + this.webhookURL = webhookURL; + } + + public log(info: any, callback: () => void): void { + const payload = { mrkdwn: true, text: info.message }; + + const params = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }; + nfetch(this.webhookURL, params).then(() => callback()); + } +} + +module.exports = SlackHook; diff --git a/services/delegator/src/start.ts b/services/delegator/src/start.ts index a7dec2c..49b8780 100644 --- a/services/delegator/src/start.ts +++ b/services/delegator/src/start.ts @@ -1,12 +1,21 @@ import ipc from 'node-ipc'; import { EventData } from 'web3-eth-contract'; +import winston from 'winston'; -import { Big, providerFor } from '@goldenagellc/web3-blocks'; +import { providerFor } from '@goldenagellc/web3-blocks'; -import ICompoundBorrower from './types/ICompoundBorrower'; import { CTokens } from './types/CTokens'; -import cTokens from './contracts/CToken'; + +import SlackHook from './logging/SlackHook'; + import comptroller from './contracts/Comptroller'; +import openOraclePriceData from './contracts/OpenOraclePriceData'; +import uniswapAnchoredView from './contracts/UniswapAnchoredView'; + +import PriceLedger from './PriceLedger'; +import StatefulComptroller from './StatefulComptroller'; +import StatefulPricesOnChain from './StatefulPricesOnChain'; +import StatefulPricesCoinbase from './StatefulPricesCoinbase'; require('dotenv-safe').config(); @@ -16,110 +25,45 @@ const provider = providerFor('mainnet', { envKeyPath: 'PROVIDER_IPC_PATH', }); +// configure winston +winston.configure({ + format: winston.format.combine(winston.format.splat(), winston.format.simple()), + transports: [ + new winston.transports.Console({ handleExceptions: true }), + new winston.transports.File({ + level: 'debug', + filename: 'delegator.log', + maxsize: 100000, + }), + new SlackHook(process.env.SLACK_WEBHOOK!, { level: 'info' }), + ], + exitOnError: false, +}); + const symbols: (keyof typeof CTokens)[] = <(keyof typeof CTokens)[]>Object.keys(CTokens); import addressesJSON from './_borrowers.json'; const addressesList = new Set([...addressesJSON.high_value, ...addressesJSON.previously_liquidated]); -let closeFactor: Big | null = null; -let liquidationIncentive: Big | null = null; -const collateralFactors: { -readonly [d in keyof typeof CTokens]: Big | null } = { - cBAT: null, - cCOMP: null, - cDAI: null, - cETH: null, - cREP: null, - cSAI: null, - cUNI: null, - cUSDC: null, - cUSDT: null, - cWBTC: null, - cZRX: null, -}; +const priceLedger = new PriceLedger(); + +const statefulComptroller = new StatefulComptroller(provider, comptroller); +const statefulPricesOnChain = new StatefulPricesOnChain(provider, priceLedger, openOraclePriceData, uniswapAnchoredView); +const statefulPricesCoinbase = new StatefulPricesCoinbase( + priceLedger, + process.env.COINBASE_ENDPOINT!, + process.env.CB_ACCESS_KEY!, + process.env.CB_ACCESS_SECRET!, + process.env.CB_ACCESS_PASSPHRASE!, +); async function start() { - // CLOSE FACTOR - comptroller - .bindTo(provider) - .subscribeTo.NewCloseFactor('latest') - .on('connected', async (_id: string) => { - const x = await comptroller.closeFactor()(provider); - if (closeFactor === null) { - closeFactor = x; - console.log(`Fetch: close factor set to ${closeFactor.toFixed(0)}`); - } - }) - .on('data', async (ev: EventData) => { - const x = ev.returnValues.newCloseFactorMantissa; - closeFactor = Big(x); - console.log(`Event: close factor set to ${x}`); - }) - .on('changed', async (ev: EventData) => { - const x = ev.returnValues.oldCloseFactorMantissa; - closeFactor = Big(x); - console.log(`Event: close factor reverted to ${x}`); - }) - .on('error', console.log); - - // LIQUIDATION INCENTIVE - comptroller - .bindTo(provider) - .subscribeTo.NewLiquidationIncentive('latest') - .on('connected', async (_id: string) => { - const x = await comptroller.liquidationIncentive()(provider); - if (liquidationIncentive === null) { - liquidationIncentive = x; - console.log(`Fetch: liquidation incentive set to ${x.toFixed(0)}`); - } - }) - .on('data', (ev: EventData) => { - const x = ev.returnValues.newLiquidationIncentiveMantissa; - liquidationIncentive = Big(x); - console.log(`Event: liquidation incentive set to ${x}`); - }) - .on('changed', (ev: EventData) => { - const x = ev.returnValues.oldLiquidationIncentiveMantissa; - liquidationIncentive = Big(x); - console.log(`Event: liquidation incentive reverted to ${x}`); - }) - .on('error', console.log); - - // COLLATERAL FACTORS - comptroller - .bindTo(provider) - .subscribeTo.NewCollateralFactor('latest') - .on('connected', (_id: string) => { - symbols.forEach(async (symbol) => { - const x = await comptroller.collateralFactorOf(CTokens[symbol])(provider); - if (collateralFactors[symbol] === null) { - collateralFactors[symbol] = x; - console.log(`Fetch: ${symbol} collateral factor set to ${x.toFixed(0)}`); - } - }); - }) - .on('data', (ev: EventData) => { - const address: string = ev.returnValues.cToken; - const x = ev.returnValues.newCollateralFactorMantissa; - - symbols.forEach((symbol) => { - if (CTokens[symbol] === address) { - collateralFactors[symbol] = Big(x); - console.log(`Fetch: ${symbol} collateral factor set to ${x.toFixed(0)}`); - } - }); - }) - .on('changed', (ev: EventData) => { - const address: string = ev.returnValues.cToken; - const x = ev.returnValues.oldCollateralFactorMantissa; - - symbols.forEach((symbol) => { - if (CTokens[symbol] === address) { - collateralFactors[symbol] = Big(x); - console.log(`Fetch: ${symbol} collateral factor set to ${x.toFixed(0)}`); - } - }); - }) - .on('error', console.log); + await statefulComptroller.init(); + await statefulPricesOnChain.init(); + await statefulPricesCoinbase.init(4000); + + console.log(statefulComptroller.getCloseFactor().toFixed(0)); + console.log(statefulComptroller.getLiquidationIncentive().toFixed(0)); } // const borrowers: ICompoundBorrower[] = []; @@ -129,15 +73,15 @@ async function start() { start(); -symbols.forEach((symbol) => { - const accrueInterestEmitter = cTokens[symbol].bindTo(provider).subscribeTo.AccrueInterest('latest'); +// symbols.forEach((symbol) => { +// const accrueInterestEmitter = cTokens[symbol].bindTo(provider).subscribeTo.AccrueInterest('latest'); - accrueInterestEmitter - // .on('connected', (id: string) => console.log(`Connected ${symbol} at ${id}`)) - // .on('data', console.log) - .on('changed', console.log); - // .on('error', console.log); -}); +// accrueInterestEmitter +// .on('connected', (id: string) => console.log(`Connected ${symbol} at ${id}`)) +// .on('data', console.log) +// .on('changed', console.log) +// .on('error', console.log); +// }); // ipc.config.appspace = 'newbedford.'; // ipc.config.id = 'delegator'; diff --git a/services/delegator/src/types/CTokens.ts b/services/delegator/src/types/CTokens.ts index 134489f..375daf8 100644 --- a/services/delegator/src/types/CTokens.ts +++ b/services/delegator/src/types/CTokens.ts @@ -12,7 +12,10 @@ export enum CTokens { cZRX = '0xB3319f5D18Bc0D84dD1b4825Dcde5d5f7266d407', } -export const CTokenCreationBlocks: { [d in keyof typeof CTokens]: number } = { +export type CTokenSymbol = keyof typeof CTokens; +export const cTokenSymbols = Object.keys(CTokens); + +export const CTokenCreationBlocks: { [_ in CTokenSymbol]: number } = { cBAT: 7710735, cCOMP: 10960099, cDAI: 8983575, @@ -25,3 +28,17 @@ export const CTokenCreationBlocks: { [d in keyof typeof CTokens]: number } = { cWBTC: 8163813, cZRX: 7710733, }; + +export const CTokenUnderlyingDecimals: { [_ in CTokenSymbol]: number } = { + cBAT: 18, + cCOMP: 18, + cDAI: 18, + cETH: 18, + cREP: 18, + cSAI: 18, + cUNI: 18, + cUSDC: 6, + cUSDT: 6, + cWBTC: 8, + cZRX: 18, +}; diff --git a/services/delegator/src/types/CoinbaseKeys.ts b/services/delegator/src/types/CoinbaseKeys.ts new file mode 100644 index 0000000..bc958c6 --- /dev/null +++ b/services/delegator/src/types/CoinbaseKeys.ts @@ -0,0 +1,14 @@ +import { CTokenSymbol } from './CTokens'; + +export type CoinbaseKey = 'BAT' | 'COMP' | 'DAI' | 'ETH' | 'REP' | 'UNI' | 'BTC' | 'ZRX'; + +export const coinbaseKeyMap: { [i in CoinbaseKey]: CTokenSymbol } = { + BAT: 'cBAT', + COMP: 'cCOMP', + DAI: 'cDAI', + ETH: 'cETH', + REP: 'cREP', + UNI: 'cUNI', + BTC: 'cWBTC', + ZRX: 'cZRX', +}; diff --git a/services/delegator/src/types/IPrice.ts b/services/delegator/src/types/IPrice.ts new file mode 100644 index 0000000..d0f1d72 --- /dev/null +++ b/services/delegator/src/types/IPrice.ts @@ -0,0 +1,6 @@ +import { Big } from '@goldenagellc/web3-blocks' + +export default interface IPrice { + value: Big; + timestamp: string; +} diff --git a/services/delegator/src/types/IPriceRange.ts b/services/delegator/src/types/IPriceRange.ts new file mode 100644 index 0000000..e9bea58 --- /dev/null +++ b/services/delegator/src/types/IPriceRange.ts @@ -0,0 +1,6 @@ +import IPrice from './IPrice'; + +export default interface IPriceRange { + min: IPrice; + max: IPrice; +} diff --git a/services/delegator/yarn.lock b/services/delegator/yarn.lock index e0e6b4b..652c216 100644 --- a/services/delegator/yarn.lock +++ b/services/delegator/yarn.lock @@ -239,8 +239,8 @@ "@goldenagellc/web3-blocks@^0.0.7": version "0.0.7" - resolved "https://npm.pkg.github.com/download/@goldenagellc/web3-blocks/0.0.7/4dc54d8b155b401213eb5810269529947084c0ea1535322cef1b3ce377958d26#b636db3c1af24ab982afea6492581ce6fc3a69cd" - integrity sha512-pp4eM8GsDMum2/D3pVNdcVeqpLJ1G2xvyejzc00Vb5RbH3bVUfuFvxLDynw8xL4yLawzryY2ZmPV6HI0W36KLw== + resolved "https://npm.pkg.github.com/download/@goldenagellc/web3-blocks/0.0.7/7efe24d20a4b90a791b3b52f635720e959bb733b5ac1a787f5dcba7251250942#81f5f1e517fa916be3c5e5fd309eadb19b98792b" + integrity sha512-nu9SakXKtaA5t2ORIyp42uxQ503lE7vczyrJWRNRzgFrv8c2i+z2IcfyeJ5q3rlFCPhUeep4fHfo9nobSAn52Q== dependencies: "@ethereumjs/common" "^2.0.0" "@ethereumjs/tx" "^3.0.0" @@ -343,6 +343,14 @@ resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.0.4.tgz#b840c2dce46bacf286e237bfb59a29e843399148" integrity sha512-M4BwiTJjHmLq6kjON7ZoI2JMlBvpY3BYSdiP6s/qCT3jb1s9/DeJF0JELpAxiVSIxXDzfNKe+r7yedMIoLbknQ== +"@types/node-fetch@^2.5.8": + version "2.5.8" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.8.tgz#e199c835d234c7eb0846f6618012e558544ee2fb" + integrity sha512-fbjI6ja0N5ZA8TV53RUqzsKNkl9fv8Oj3T7zxW7FGv1GSH7gwJaNF8dzCjrqKaxKeUpTz4yT1DaJFq/omNpGfw== + dependencies: + "@types/node" "*" + form-data "^3.0.0" + "@types/node-ipc@^9.1.3": version "9.1.3" resolved "https://registry.yarnpkg.com/@types/node-ipc/-/node-ipc-9.1.3.tgz#5381fbc910071083b28dd43225727877c108b361" @@ -1472,7 +1480,7 @@ colorspace@1.1.x: color "3.0.x" text-hex "1.0.x" -combined-stream@^1.0.6, combined-stream@~1.0.6: +combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== @@ -2650,6 +2658,15 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= +form-data@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f" + integrity sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + form-data@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" @@ -4378,7 +4395,7 @@ node-environment-flags@1.0.6: object.getownpropertydescriptors "^2.0.3" semver "^5.7.0" -node-fetch@^2.3.0: +node-fetch@^2.3.0, node-fetch@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== diff --git a/services/txmanager/src/_competitors.json b/services/txmanager/src/_competitors.json index a3eaab7..cb5d737 100644 --- a/services/txmanager/src/_competitors.json +++ b/services/txmanager/src/_competitors.json @@ -15,7 +15,6 @@ "0x124b2f94deBaDfC05ce54E746C2A07B9E56e72E2", "0x13013a68529BA319077B2CFe1bf65f282956E1B3", "0x138b03DA5c5F7403Fb1a9E9a272767468Cd60571", - "0x158079Ee67Fce2f58472A96584A73C7Ab9AC95c1", "0x17d25a67781122E89bCaeba36A5c72A4aa809E4E", "0x1857AE37Ba3005113F9809B0e19a2A6563ee0D3B", "0x1a56E2C9749a3E07018406198af55Fc1Eb03855b", @@ -43,7 +42,6 @@ "0x38F2cDD0Ea4b38dBed40A36530313A5D89638858", "0x39610340570b25E3Fa140C796B89B64a13F19648", "0x398eC7346DcD622eDc5ae82352F02bE94C62d119", - "0x39AA39c021dfbaE8faC545936693aC917d5E7563", "0x3bb50C2Ad3Bbb69A4205E440f34Bc3ee9D18e6e6", "0x402a75f3500CA1FbA17741Ec916F07a0c9DB195D", "0x40fbE29900F1AD4f8c774A05188DE8Bbd43dd08d", @@ -66,7 +64,6 @@ "0x681Bd23F6128dB3F9b8914595d1a63830a6212fA", "0x6a0c50788E462f322959A2458687096994d66316", "0x6bfdfCC0169C3cFd7b5DC51c8E563063Df059097", - "0x6C8c6b02E7b2BE14d4fA6022Dfd6d75921D90E4E", "0x6D0f24E1870D1Ff381888cB90de769a4e8923D29", "0x6E5F89bE6C322D5177ec30e29976345C2bF0c772", "0x6e8f4ca00F09f05Aa157De2661b5F25Ab76A8BC1", @@ -107,7 +104,6 @@ "0xb00ba6778cF84100da676101e011B3d229458270", "0xb00bA6E641a3129b8C515Bb14A4c1bBA32d2E8dF", "0xB1340B0CE8Af2dEf925C39cAD3058167a0f36953", - "0xB3319f5D18Bc0D84dD1b4825Dcde5d5f7266d407", "0xB76d0b700B0BC5054240cbF1882af24bc1ff43a4", "0xB781779eda55667cB65bc41CAD92C0bD34A5878d", "0xB9850b58e0Dc35c7fE72eF1eCAbC3b38a419C08a", @@ -118,7 +114,6 @@ "0xbd08B0A4A6e591a7705238c5b3cC9fc5382fbB30", "0xBd48939cccd34f006b049B26bba34a68E918EC5d", "0xC10360E60F45A4BcDB6e7DF2176F4b0417D68568", - "0xC11b1268C1A384e55C48c2391d8d480264A3A7F4", "0xc1def82F71cD625c173DbD05f2d9788Bb865bCa9", "0xc1f6F8Eae3Eae9283Ae70Ef34f8cB9099BBb52EF", "0xC2d0aA52890Fac841c258433A0a260d29d2a98AD", @@ -144,7 +139,6 @@ "0xf2b7B6a05D2e065754d5e1F56eC5F11d2154ECBC", "0xf2fA954E033e92ba3c76fD4372d479C99f614Ae3", "0xf43d5bDfbC71FaF780AF70E72631aFe9D0DADcAf", - "0xF5DCe57282A584D2746FaF1593d3121Fcac444dC", "0xF78B62f3Cc6E3551Cb87cBDcC62FeC959061E05c", "0xF8341bf53e42DAc7b3E3516E0c6ef6003646DD6D", "0xFb9284715e9B0d08153caA34C35bfAed76edC31F", diff --git a/services/txmanager/src/logging/SlackHook.ts b/services/txmanager/src/logging/SlackHook.ts index bb584df..dd4d8ca 100644 --- a/services/txmanager/src/logging/SlackHook.ts +++ b/services/txmanager/src/logging/SlackHook.ts @@ -1,4 +1,4 @@ -import nfetch, { Response } from 'node-fetch'; +import nfetch from 'node-fetch'; import winston from 'winston'; import Transport from 'winston-transport'; diff --git a/services/txmanager/src/start.ts b/services/txmanager/src/start.ts index ddb0384..8e83ca5 100644 --- a/services/txmanager/src/start.ts +++ b/services/txmanager/src/start.ts @@ -39,7 +39,7 @@ winston.configure({ filename: 'txmanager.log', maxsize: 100000, }), - new SlackHook(String(process.env.SLACK_WEBHOOK), { level: 'info' }), + new SlackHook(process.env.SLACK_WEBHOOK!, { level: 'info' }), ], exitOnError: false, }); @@ -56,8 +56,8 @@ ethSub.register(latencyWatch); // create queue const wallet = new Wallet( provider, - String(process.env.ACCOUNT_ADDRESS_CALLER), - String(process.env.ACCOUNT_SECRET_CALLER), + process.env.ACCOUNT_ADDRESS_CALLER!, + process.env.ACCOUNT_SECRET_CALLER!, ); const queue = new IncognitoQueue(wallet); ethSub.register(queue); diff --git a/services/txmanager/src/types/CTokens.ts b/services/txmanager/src/types/CTokens.ts index 140db59..375daf8 100644 --- a/services/txmanager/src/types/CTokens.ts +++ b/services/txmanager/src/types/CTokens.ts @@ -11,3 +11,34 @@ export enum CTokens { cWBTC = '0xC11b1268C1A384e55C48c2391d8d480264A3A7F4', cZRX = '0xB3319f5D18Bc0D84dD1b4825Dcde5d5f7266d407', } + +export type CTokenSymbol = keyof typeof CTokens; +export const cTokenSymbols = Object.keys(CTokens); + +export const CTokenCreationBlocks: { [_ in CTokenSymbol]: number } = { + cBAT: 7710735, + cCOMP: 10960099, + cDAI: 8983575, + cETH: 7710758, + cREP: 7710755, + cSAI: 7710752, + cUNI: 10921410, + cUSDC: 7710760, + cUSDT: 9879363, + cWBTC: 8163813, + cZRX: 7710733, +}; + +export const CTokenUnderlyingDecimals: { [_ in CTokenSymbol]: number } = { + cBAT: 18, + cCOMP: 18, + cDAI: 18, + cETH: 18, + cREP: 18, + cSAI: 18, + cUNI: 18, + cUSDC: 6, + cUSDT: 6, + cWBTC: 8, + cZRX: 18, +};