crypto-janitor
Version:
The Crypto Janitor provides a simple interface for fetching and cleaning crypto account data.
325 lines • 14.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
/* eslint-disable new-cap */
/* eslint-disable max-len */
const base_1 = require("../target.types/base");
const ccxt_1 = require("../target.types/ccxt");
const _ = require("lodash");
const web3 = require("web3");
const etherscan = require("etherscan-api");
/**
* @description A Etherscan Implementation of BaseConnection
*/
class Etherscan extends base_1.default {
/**
* @description Create Ledger instance
* @param {string} address - CVS file with transactions
* @param {string} apiKey - Etherscan API key
* @param {string} marketDataSource - Name of implmented
*/
constructor(address, apiKey, marketDataSource) {
super("etherscan", "address", { requireSymbols: false });
this.availableDataSources = {
coinbase: new ccxt_1.default("coinbase"),
coinbasepro: new ccxt_1.default("coinbasepro"),
kucoin: new ccxt_1.default("kucoin"),
bittrex: new ccxt_1.default("bittrex"),
gateio: new ccxt_1.default("gateio"),
bitstamp: new ccxt_1.default("bitstamp"),
ftx: new ccxt_1.default("ftx"),
hitbtc: new ccxt_1.default("hitbtc"),
huobipro: new ccxt_1.default("huobipro"),
kraken: new ccxt_1.default("kraken"),
okcoin: new ccxt_1.default("okcoin"),
poloniex: new ccxt_1.default("poloniex"),
};
this.address = address;
this.transactions = [];
this.rawTransactions = [];
this.fallbackDataSources = Object.keys(this.availableDataSources);
this.currentDataSource = new ccxt_1.default(marketDataSource ? marketDataSource : this.fallbackDataSources[0]);
this.client = etherscan.init(apiKey);
}
/**
* @description JSON object representing connection
* @override BaseConnection.toJSON
* @return {any}
*/
toJSON() {
const newJSON = super.toJSON();
const updatedParams = newJSON.params;
updatedParams.address = this.address;
updatedParams.chain = "ethereum";
newJSON.params = updatedParams;
newJSON.name = `Ethereum Address (
${this.address.slice(0, 6)}...${this.address.slice(-4)})`;
return newJSON;
}
/**
* HELPER
* @description Parse out transactions and order from ledger entry group
* @param {string} symbol - Currency symbol
* @param {Array<string>} exclude - An array of connection names to exclude from the check
* @return {any} Market data source (ccxt public connection)
*/
async findMarketForQuote(symbol, exclude = []) {
const attempted = [];
// check if the currentDataSource is excluded
if (!exclude.includes(this.currentDataSource.name)) {
attempted.push(this.currentDataSource.name);
if (this.currentDataSource.getQuoteConversion(symbol).length > 0) {
// currentDataSource has a market for desired symbol
return { dataSource: this.currentDataSource, attempted };
}
exclude.push(this.currentDataSource.name);
}
const fallbackSources = this.fallbackDataSources.filter((src) => !exclude.includes(src));
const fallbacks = fallbackSources.map((fallback) => this.availableDataSources[fallback]);
for (const fallback of fallbacks) {
attempted.push(fallback.name);
await fallback.initialize();
const quotes = await fallback.getQuoteConversion(symbol);
if (quotes.length > 0) {
return {
dataSource: fallback,
attempted,
};
}
else {
console.log(`MARKET NOT FOUND: Could not find ${symbol} on ${fallback.name}.`);
}
}
return { dataSource: null, attempted };
}
/**
* HELPER
* @description Parse out transactions and order from ledger entry group
* @param {any} entryGroup - Unformatted ledger transaction
* @return {Promise<Transaction | Order>} Formatted transaction
*/
async _parseEntryGroup(entryGroup) {
const quoteCoins = ["USD", "USDC", "USDT", "BTC"];
entryGroup.map((x) => {
x.from = web3.utils.toChecksumAddress(x.from);
x.to = web3.utils.toChecksumAddress(x.to);
return x;
});
const formattedTx = {
id: entryGroup[0].hash,
timestamp: new Date(entryGroup[0].timeStamp * 1000),
type: "",
baseCurrency: "",
baseQuantity: 0,
baseUsdPrice: 0,
feeCurrency: "ETH",
feeQuantity: web3.utils.toBN(entryGroup[0].gasUsed) *
web3.utils.fromWei(entryGroup[0].gasPrice),
feeUsdPrice: 0,
feeTotal: 0,
subTotal: 0,
total: 0,
};
if (entryGroup.length === 1) {
const tx = entryGroup[0];
const value = parseInt(tx.value) * 1;
if (value === 0) {
// Transaction that approves dapp to use this.address
formattedTx.type = "send";
formattedTx.baseCurrency = "ETH";
formattedTx.baseQuantity = 0;
}
else if (value > 0) {
// Could be sending/receiving eth or receiving a token
formattedTx.type =
tx.from === this.address ? "send" : "receive";
formattedTx.feeCurrency =
formattedTx.type === "receive"
? "USDC"
: formattedTx.feeCurrency;
formattedTx.feeQuantity =
formattedTx.type === "receive"
? 0
: formattedTx.feeQuantity;
formattedTx.baseCurrency = tx.hasOwnProperty("tokenSymbol")
? tx.tokenSymbol
: "ETH";
formattedTx.baseQuantity =
web3.utils.fromWei(tx.value, tx.tokenDecimal === "6" ? "picoether" : "ether") * 1;
}
}
else if (entryGroup.length === 2) {
// Could be buying/selling token using eth or
// sending a token and paying gas fees using eth
let tokenTx = entryGroup[0];
let ethTx = entryGroup[1];
if (!tokenTx.hasOwnProperty("tokenSymbol")) {
tokenTx = entryGroup[1];
ethTx = entryGroup[0];
}
formattedTx.baseCurrency = tokenTx.tokenSymbol;
formattedTx.baseQuantity =
web3.utils.fromWei(tokenTx.value, tokenTx.tokenDecimal === "6" ? "picoether" : "ether") * 1;
if (ethTx.value > 0) {
// buy or sell token with eth
formattedTx.type = ethTx.from === this.address ? "buy" : "sell";
formattedTx.quoteCurrency = "ETH";
formattedTx.quoteQuantity = web3.utils.fromWei(ethTx.value) * 1;
formattedTx.quoteUsdPrice = 0;
formattedTx.quotePrice =
formattedTx.quoteQuantity / formattedTx.baseQuantity;
}
else {
// send token and pay gas with eth or claim airdrop token
formattedTx.type =
tokenTx.to === this.address ? "receive" : "send";
}
}
else if (entryGroup.length === 3) {
// buy/sell token using another token with high
// volume liquidity pool converter (Ex: WBTC, WETH, etc)
const tokens = entryGroup.filter((x) => x.tokenSymbol);
const tokenFromTx = tokens[1].from === this.address ? tokens[1] : tokens[0];
const tokenToTx = tokens[0].to === this.address ? tokens[0] : tokens[1];
formattedTx.type = "buy";
formattedTx.baseCurrency = tokenToTx.tokenSymbol;
formattedTx.baseQuantity =
web3.utils.fromWei(tokenToTx.value, tokenToTx.tokenDecimal === "6" ? "picoether" : "ether") * 1;
formattedTx.quoteCurrency = tokenFromTx.tokenSymbol;
formattedTx.quoteQuantity =
web3.utils.fromWei(tokenFromTx.value, tokenFromTx.tokenDecimal === "6" ? "picoether" : "ether") * 1;
// attempted to swap base and quote currencies to refelect a sell to stablecoin or BTC
if (quoteCoins.includes(tokenToTx.tokenSymbol)) {
formattedTx.type = "sell";
const tempCurrency = formattedTx.baseCurrency;
const tempQuantity = formattedTx.baseQuantity;
formattedTx.baseCurrency = formattedTx.quoteCurrency;
formattedTx.baseQuantity = formattedTx.quoteQuantity;
formattedTx.quoteCurrency = tempCurrency;
formattedTx.quoteQuantity = tempQuantity;
}
formattedTx.quotePrice =
formattedTx.quoteQuantity / formattedTx.baseQuantity;
}
else {
console.log(`UNPARSABLE TX: Received tx group with ${entryGroup.length}`, entryGroup);
}
if (formattedTx.type === "buy" || formattedTx.type === "sell") {
// Quote currencies should almost always (fingers crossed) exist on
// any market so no need to check for existing price data.
const market = await this.findMarketForQuote(formattedTx.quoteCurrency);
const result = await market.dataSource.quoteOrder(formattedTx);
// return attempted swap version
return this._attemptedSwapConversion(result);
}
else {
// Must use this loop to make sure a market is found and that market returns a valid
// price for the provided timestamp. Once a valid market and price are found apply the
// prices to the transaction
let market = await this.findMarketForQuote(formattedTx.baseCurrency);
let attempted = [];
while (market.attempted.length < this.fallbackDataSources.length) {
attempted = _.uniq(attempted.concat(market.attempted));
if (market.dataSource) {
const prices = await market.dataSource.getCommonPrices(formattedTx.timestamp, formattedTx.baseCurrency, formattedTx.feeCurrency);
if (prices.baseUsdPrice) {
const result = await market.dataSource.quoteTransaction(formattedTx, prices);
return result;
}
market = await this.findMarketForQuote(formattedTx.baseCurrency, attempted);
}
}
}
// only get here if no market is found for a transaction's baseCurrency (hopefully its rare)
return formattedTx;
}
/**
* @description Fetch and clean transactions
* @override BaseConnection.initialize
* @param {boolean} forceReload - (Optional) Additional paramaters
* @return {Promise<void>}
*/
async initialize(forceReload = false) {
if (!this.initialized || forceReload) {
await this.currentDataSource.initialize();
const transactions = await Promise.all([
this.client.account.txlist(this.address, 1, "latest", 1, 100, "asc"),
this.client.account.tokentx(this.address, "", 1, "latest", 1, 100, "asc"),
]);
this.rawTransactions = Object.values(_.groupBy(_.flatten(transactions.map((x) => x.result)), "hash"));
this.initialized = true;
}
}
/**
* Fetch Account transactions by type, symbol, and/or since a certain time
* @param {string | Array<string>} type (Optional) Transaction type(s)
* @param {number} since (Optional) Timestamp to get transactions since
* @return {Promise<Array<any>>} Array of deposit objects
*/
async filterTransactions(type, since) {
if (this.transactions.length < 1) {
const cleanedTxs = this.rawTransactions.map(async (x) => await this._parseEntryGroup(x));
this.transactions = await Promise.all(cleanedTxs);
this.transactions = _.sortBy(this.transactions, "timestamp");
}
let filteredTransactions = this.transactions;
if (type) {
if (typeof type === "string") {
filteredTransactions = filteredTransactions.filter((transaction) => transaction.type === type);
}
else if (Array.isArray(type)) {
filteredTransactions = filteredTransactions.filter((transaction) => type.includes(transaction.type));
}
}
if (since) {
filteredTransactions = filteredTransactions.filter((transaction) => transaction.timestamp.getTime() > since);
}
return filteredTransactions;
}
/**
* @description Filter for address withdrawals
* @override BaseConnection.getWithdrawals
* @param {number} since (Optional) Timestamp to get transactions since
* @return {Promise<Array<any>>} Array of withdrawal objects
*/
async getWithdrawals(since) {
return await this.filterTransactions("send", since);
}
/**
* @description Filter for address deposits
* @override BaseConnection.getDeposits
* @param {number} since (Optional) Timestamp to get transactions since
* @return {Promise<Array<any>>} Array of deposit objects
*/
async getDeposits(since) {
return await this.filterTransactions("receive", since);
}
/**
* @description Filter for address orders
* @override BaseConnection.getOrders
* @param {number} since (Optional) Timestamp to get transactions since
* @return {Promise<Array<any>>} Array of deposit objects
*/
async getOrders(since) {
return await this.filterTransactions(["buy", "sell", "swap"], since);
}
/**
* @description Get address transactions (withdrawals, deposits, and orders)
* @override BaseConnection.getTransactions
* @param {number} since (Optional) Timestamp to get transactions since
* @return {Promise<Array<any>>} Array of withdrawal objects
*/
async getTransactions(since) {
return await this.filterTransactions(undefined, since);
}
/**
* @description Get all address transactions (withdrawals, deposits, and interest)
* @override BaseConnection.getAllTransactions
* @param {number} since (Optional) Timestamp to get transactions since
* @return {Promise<Array<any>>} Array of transaction objects
*/
async getAllTransactions(since) {
return await this.filterTransactions(undefined, since);
}
}
exports.default = Etherscan;
//# sourceMappingURL=etherscan.js.map