Integrate one-time dump and API for nonfungible.com (#1603)

* Add script for pulling NFT trade data from nonfungible.com

* corrections for current state of API

* change data model to match data source

* change primary key

* pull data from initial dump first, then API

* pull all supported NFT's, not just cryptokitties

* disable problematic data sources

* rename function to satisfy linter

* Rename table to nonfungible_dot_com_trades

* rename parser module to nonfungible_dot_com from non_fungible_dot_com, for consistency

* correct mistaken reference to Bloxy

* rename NonfungibleDotComTrade to ...TradeResponse

* `NftTrade` -> `NonfungibleDotComTrade`

* rename files to match prior object renaming

* use fetchAsync instead of axios

* improve fetchAsync error message: include URL

* avoid non-null contraints in API trades too, not just for trades from the one-time dump

* disable mythereum publisher
This commit is contained in:
F. Eugene Aumson
2019-02-19 19:07:42 -08:00
committed by GitHub
parent e643c13292
commit 1232a9a03d
10 changed files with 522 additions and 1 deletions

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
const nftTrades = new Table({
name: 'raw.nonfungible_dot_com_trades',
columns: [
{ name: 'publisher', type: 'varchar', isPrimary: true },
{ name: 'transaction_hash', type: 'varchar', isPrimary: true },
{ name: 'asset_id', type: 'varchar', isPrimary: true },
{ name: 'block_number', type: 'bigint', isPrimary: true },
{ name: 'log_index', type: 'integer', isPrimary: true },
{ name: 'block_timestamp', type: 'bigint' },
{ name: 'asset_descriptor', type: 'varchar' },
{ name: 'market_address', type: 'varchar(42)' },
{ name: 'total_price', type: 'numeric' },
{ name: 'usd_price', type: 'numeric' },
{ name: 'buyer_address', type: 'varchar(42)' },
{ name: 'seller_address', type: 'varchar(42)' },
{ name: 'meta', type: 'jsonb' },
],
});
export class CreateNftTrades1543540108767 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.createTable(nftTrades);
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.dropTable(nftTrades);
}
}

View File

@@ -0,0 +1,220 @@
import { stringify } from 'querystring';
import { logUtils } from '@0x/utils';
import { fetchSuccessfullyOrThrowAsync } from '../../utils';
// URL to use for getting nft trades from nonfungible.com.
export const NONFUNGIBLE_DOT_COM_URL = 'https://nonfungible.com/api/v1';
// Number of trades to get at once. This is a hard limit enforced by the API.
const MAX_TRADES_PER_QUERY = 100;
// Note(albrow): For now this will have to be manually updated by checking
// https://nonfungible.com/
export const knownPublishers = [
'axieinfinity',
// 'cryptokitties', // disabled until we get updated initial dump that isn't truncated
'cryptopunks',
'cryptovoxels',
'decentraland',
'decentraland_estate',
'etherbots',
'etheremon',
'ethtown',
// 'knownorigin', // disabled because of null characters in data being rejected by postgres
// 'mythereum', // worked at one time, but now seems dead
'superrare',
];
export interface NonfungibleDotComHistoryResponse {
data: NonfungibleDotComTradeResponse[];
}
export interface NonfungibleDotComTradeResponse {
_id: string;
transactionHash: string;
blockNumber: number;
logIndex: number;
blockTimestamp: string;
assetId: string;
assetDescriptor: string;
nftAddress: string;
marketAddress: string;
tokenTicker: string;
totalDecimalPrice: number;
totalPrice: string;
usdPrice: number;
currencyTransfer: object;
buyer: string;
seller: string;
meta: object;
image: string;
composedOf: string;
asset_link: string;
seller_address_link: string;
buyer_address_link: string;
}
/**
* Gets and returns all trades for the given publisher, starting at the given block number.
* Automatically handles pagination.
* @param publisher A valid "publisher" for the nonfungible.com API. (e.g. "cryptokitties")
* @param blockNumberStart The block number to start querying from.
*/
export async function getTradesAsync(
publisher: string,
blockNumberStart: number,
): Promise<NonfungibleDotComTradeResponse[]> {
const allTrades: NonfungibleDotComTradeResponse[] = [];
/**
* due to high data volumes and rate limiting, we procured an initial data
* dump from nonfungible.com. If the requested starting block number is
* contained in that initial dump, then pull relevant trades from there
* first. Later (below) we'll get the more recent trades from the API itself.
*/
if (blockNumberStart < highestBlockNumbersInIntialDump[publisher]) {
logUtils.log('getting trades from one-time dump');
// caller needs trades that are in the initial data dump, so get them
// from there, then later go to the API for the rest.
const initialDumpResponse: NonfungibleDotComHistoryResponse = await fetchSuccessfullyOrThrowAsync(
getInitialDumpUrl(publisher),
);
const initialDumpTrades = initialDumpResponse.data;
for (const initialDumpTrade of initialDumpTrades) {
if (!shouldProcessTrade(initialDumpTrade, allTrades)) {
continue;
}
ensureNonNull(initialDumpTrade);
allTrades.push(initialDumpTrade);
}
logUtils.log(`got ${allTrades.length} from one-time dump`);
}
const fullUrl = getFullUrlForPublisher(publisher);
/**
* API returns trades in reverse chronological order, so highest block
* numbers first. The `start` query parameter indicates how far back in
* time (in number of trades) the results should start. Here we iterate
* over both start parameter values and block numbers simultaneously.
* Start parameter values count up from zero. Block numbers count down
* until reaching the highest block number in the initial dump.
*/
const blockNumberStop = Math.max(highestBlockNumbersInIntialDump[publisher] + 1, blockNumberStart);
for (
let startParam = 0, blockNumber = Number.MAX_SAFE_INTEGER;
blockNumber > blockNumberStop;
startParam += MAX_TRADES_PER_QUERY
) {
const response = await _getTradesWithOffsetAsync(fullUrl, publisher, startParam);
const tradesFromApi = response.data;
logUtils.log(
`got ${
tradesFromApi.length
} trades from API. blockNumber=${blockNumber}. blockNumberStop=${blockNumberStop}`,
);
for (const tradeFromApi of tradesFromApi) {
if (tradeFromApi.blockNumber <= blockNumberStop) {
blockNumber = blockNumberStop;
break;
}
if (!shouldProcessTrade(tradeFromApi, allTrades)) {
continue;
}
ensureNonNull(tradeFromApi);
allTrades.push(tradeFromApi);
blockNumber = tradeFromApi.blockNumber;
}
}
return allTrades;
}
function shouldProcessTrade(
trade: NonfungibleDotComTradeResponse,
existingTrades: NonfungibleDotComTradeResponse[],
): boolean {
// check to see if this trade is already in existingTrades
const existingTradeIndex = existingTrades.findIndex(
// HACK! making assumptions about composition of primary key
e =>
e.transactionHash === trade.transactionHash &&
e.logIndex === trade.logIndex &&
e.blockNumber === trade.blockNumber,
);
if (existingTradeIndex !== -1) {
logUtils.log("we've already captured this trade. deciding whether to use the existing record or this one.");
if (trade.blockNumber > existingTrades[existingTradeIndex].blockNumber) {
logUtils.log('throwing out existing trade');
existingTrades.splice(existingTradeIndex, 1);
} else {
logUtils.log('letting existing trade stand, and skipping processing of this trade');
return false;
}
}
return true;
}
const highestBlockNumbersInIntialDump: { [publisher: string]: number } = {
axieinfinity: 7065913,
cryptokitties: 4658171,
cryptopunks: 7058897,
cryptovoxels: 7060783,
decentraland_estate: 7065181,
decentraland: 6938962,
etherbots: 5204980,
etheremon: 7065370,
ethtown: 7064126,
knownorigin: 7065160,
mythereum: 7065311,
superrare: 7065955,
};
async function _getTradesWithOffsetAsync(
url: string,
publisher: string,
offset: number,
): Promise<NonfungibleDotComHistoryResponse> {
const resp: NonfungibleDotComHistoryResponse = await fetchSuccessfullyOrThrowAsync(
`${url}?${stringify({
publisher,
start: offset,
length: MAX_TRADES_PER_QUERY,
})}`,
);
return resp;
}
function getFullUrlForPublisher(publisher: string): string {
return `${NONFUNGIBLE_DOT_COM_URL}/market/${publisher}/history`;
}
function getInitialDumpUrl(publisher: string): string {
return `https://nonfungible-dot-com-one-time-data-dump.s3.amazonaws.com/sales_summary_${publisher}.json`;
}
function ensureNonNull(trade: NonfungibleDotComTradeResponse): void {
// these fields need to be set in order to avoid non-null
// constraint exceptions upon database insertion.
if (trade.logIndex === undefined) {
// for cryptopunks
trade.logIndex = 0;
}
if (trade.assetDescriptor === undefined) {
// for cryptopunks
trade.assetDescriptor = '';
}
if (trade.meta === undefined) {
// for cryptopunks
trade.meta = {};
}
if (trade.marketAddress === null) {
// for decentraland_estate
trade.marketAddress = '';
}
}

View File

@@ -7,6 +7,7 @@ export { DexTrade } from './dex_trade';
export { ExchangeCancelEvent } from './exchange_cancel_event';
export { ExchangeCancelUpToEvent } from './exchange_cancel_up_to_event';
export { ExchangeFillEvent } from './exchange_fill_event';
export { NonfungibleDotComTrade } from './nonfungible_dot_com_trade';
export { OHLCVExternal } from './ohlcv_external';
export { Relayer } from './relayer';
export { SraOrder } from './sra_order';

View File

@@ -0,0 +1,35 @@
import { BigNumber } from '@0x/utils';
import { Column, Entity, PrimaryColumn } from 'typeorm';
import { bigNumberTransformer, numberToBigIntTransformer } from '../utils';
@Entity({ name: 'nonfungible_dot_com_trades', schema: 'raw' })
export class NonfungibleDotComTrade {
@PrimaryColumn({ name: 'transaction_hash' })
public transactionHash!: string;
@PrimaryColumn({ name: 'publisher' })
public publisher!: string;
@PrimaryColumn({ name: 'block_number', type: 'bigint', transformer: numberToBigIntTransformer })
public blockNumber!: number;
@PrimaryColumn({ name: 'log_index' })
public logIndex!: number;
@PrimaryColumn({ name: 'asset_id' })
public assetId!: string;
@Column({ name: 'block_timestamp', type: 'bigint', transformer: numberToBigIntTransformer })
public blockTimestamp!: number;
@Column({ name: 'asset_descriptor' })
public assetDescriptor!: string;
@Column({ name: 'market_address' })
public marketAddress!: string;
@Column({ name: 'total_price', type: 'numeric', transformer: bigNumberTransformer })
public totalPrice!: BigNumber;
@Column({ name: 'usd_price', type: 'numeric', transformer: bigNumberTransformer })
public usdPrice!: BigNumber;
@Column({ name: 'buyer_address' })
public buyerAddress!: string;
@Column({ name: 'seller_address' })
public sellerAddress!: string;
@Column({ type: 'jsonb' })
public meta!: object;
}

View File

@@ -12,6 +12,7 @@ import {
ExchangeCancelEvent,
ExchangeCancelUpToEvent,
ExchangeFillEvent,
NonfungibleDotComTrade,
OHLCVExternal,
Relayer,
SraOrder,
@@ -33,6 +34,7 @@ const entities = [
ExchangeCancelUpToEvent,
ExchangeFillEvent,
ERC20ApprovalEvent,
NonfungibleDotComTrade,
OHLCVExternal,
Relayer,
SraOrder,

View File

@@ -0,0 +1,42 @@
import { BigNumber } from '@0x/utils';
import * as R from 'ramda';
import { NonfungibleDotComTradeResponse } from '../../data_sources/nonfungible_dot_com';
import { NonfungibleDotComTrade } from '../../entities';
/**
* Parses a raw trades from the nonfungible.com API and returns an array of
* NonfungibleDotComTrade entities.
* @param rawTrades A raw order response from an SRA endpoint.
*/
export function parseNonFungibleDotComTrades(
rawTrades: NonfungibleDotComTradeResponse[],
publisher: string,
): NonfungibleDotComTrade[] {
return R.map(_parseNonFungibleDotComTrade.bind(null, publisher), rawTrades);
}
/**
* Converts a single trade from nonfungible.com into an NonfungibleDotComTrade entity.
* @param rawTrade A single trade from the response from the nonfungible.com API.
*/
export function _parseNonFungibleDotComTrade(
publisher: string,
rawTrade: NonfungibleDotComTradeResponse,
): NonfungibleDotComTrade {
const nonfungibleDotComTrade = new NonfungibleDotComTrade();
nonfungibleDotComTrade.assetDescriptor = rawTrade.assetDescriptor;
nonfungibleDotComTrade.assetId = rawTrade.assetId;
nonfungibleDotComTrade.blockNumber = rawTrade.blockNumber;
nonfungibleDotComTrade.blockTimestamp = new Date(rawTrade.blockTimestamp).getTime();
nonfungibleDotComTrade.buyerAddress = rawTrade.buyer;
nonfungibleDotComTrade.logIndex = rawTrade.logIndex;
nonfungibleDotComTrade.marketAddress = rawTrade.marketAddress;
nonfungibleDotComTrade.meta = rawTrade.meta;
nonfungibleDotComTrade.sellerAddress = rawTrade.seller;
nonfungibleDotComTrade.totalPrice = new BigNumber(rawTrade.totalPrice);
nonfungibleDotComTrade.transactionHash = rawTrade.transactionHash;
nonfungibleDotComTrade.usdPrice = new BigNumber(rawTrade.usdPrice);
nonfungibleDotComTrade.publisher = publisher;
return nonfungibleDotComTrade;
}

View File

@@ -0,0 +1,43 @@
// tslint:disable:no-console
import 'reflect-metadata';
import { Connection, ConnectionOptions, createConnection } from 'typeorm';
import { getTradesAsync, knownPublishers } from '../data_sources/nonfungible_dot_com';
import { NonfungibleDotComTrade } from '../entities';
import * as ormConfig from '../ormconfig';
import { parseNonFungibleDotComTrades } from '../parsers/nonfungible_dot_com';
import { handleError } from '../utils';
// Number of trades to save at once.
const BATCH_SAVE_SIZE = 1000;
let connection: Connection;
(async () => {
connection = await createConnection(ormConfig as ConnectionOptions);
await getAndSaveTradesAsync();
process.exit(0);
})().catch(handleError);
async function getAndSaveTradesAsync(): Promise<void> {
const tradesRepository = connection.getRepository(NonfungibleDotComTrade);
for (const publisher of knownPublishers) {
console.log(`Getting latest trades for NFT ${publisher}...`);
const tradeWithHighestBlockNumber = await tradesRepository
.createQueryBuilder('nonfungible_dot_com_trades')
.where('nonfungible_dot_com_trades.publisher = :publisher', { publisher })
.orderBy({ 'nonfungible_dot_com_trades.block_number': 'DESC' })
.getOne();
const highestExistingBlockNumber =
tradeWithHighestBlockNumber === undefined ? 0 : tradeWithHighestBlockNumber.blockNumber;
console.log(`Highest block number in existing trades: ${highestExistingBlockNumber}`);
const rawTrades = await getTradesAsync(publisher, highestExistingBlockNumber);
console.log(`Parsing ${rawTrades.length} trades...`);
const trades = parseNonFungibleDotComTrades(rawTrades, publisher);
console.log(`Saving ${rawTrades.length} trades...`);
await tradesRepository.save(trades, { chunk: Math.ceil(trades.length / BATCH_SAVE_SIZE) });
}
const newTotalTrades = await tradesRepository.count();
console.log(`Done saving trades. There are now ${newTotalTrades} total NFT trades.`);
}

View File

@@ -1,4 +1,4 @@
import { BigNumber } from '@0x/utils';
import { BigNumber, fetchAsync } from '@0x/utils';
export * from './transformers';
export * from './constants';
@@ -51,3 +51,16 @@ export function handleError(e: any): void {
}
process.exit(1);
}
/**
* Does fetchAsync(), and checks the status code, throwing if it doesn't indicate success.
*/
export async function fetchSuccessfullyOrThrowAsync(url: string): Promise<any> {
const response = await fetchAsync(url);
if (!response.ok) {
throw new Error(
`Failed to fetch URL ${url}. Unsuccessful HTTP status code (${response.status}): ${response.statusText}`,
);
}
return response.json();
}

View File

@@ -0,0 +1,48 @@
import { BigNumber } from '@0x/utils';
import 'mocha';
import 'reflect-metadata';
import { NonfungibleDotComTrade } from '../../src/entities';
import { createDbConnectionOnceAsync } from '../db_setup';
import { chaiSetup } from '../utils/chai_setup';
import { testSaveAndFindEntityAsync } from './util';
chaiSetup.configure();
const baseTrade: NonfungibleDotComTrade = {
assetDescriptor: 'Kitty #1002',
assetId: '1002',
blockNumber: 4608542,
blockTimestamp: 1543544083704,
buyerAddress: '0x316c55d1895a085c4b39a98ecb563f509301aaf7',
logIndex: 28,
marketAddress: '0xb1690C08E213a35Ed9bAb7B318DE14420FB57d8C',
meta: {
cattribute_body: 'munchkin',
cattribute_coloreyes: 'mintgreen',
cattribute_colorprimary: 'orangesoda',
cattribute_colorsecondary: 'coffee',
cattribute_colortertiary: 'kittencream',
cattribute_eyes: 'thicccbrowz',
cattribute_mouth: 'soserious',
cattribute_pattern: 'totesbasic',
generation: '0',
is_exclusive: false,
is_fancy: false,
},
sellerAddress: '0xba52c75764d6f594735dc735be7f1830cdf58ddf',
totalPrice: new BigNumber('9751388888888889'),
transactionHash: '0x468168419be7e442d5ff32d264fab24087b744bc2e37fdbac7024e1e74f4c6c8',
usdPrice: new BigNumber('3.71957'),
publisher: 'cryptokitties',
};
// tslint:disable:custom-no-magic-numbers
describe('NonfungibleDotComTrade entity', () => {
it('save/find', async () => {
const connection = await createDbConnectionOnceAsync();
const tradesRepository = connection.getRepository(NonfungibleDotComTrade);
await testSaveAndFindEntityAsync(tradesRepository, baseTrade);
});
});

View File

@@ -0,0 +1,86 @@
// tslint:disable:custom-no-magic-numbers
import { BigNumber } from '@0x/utils';
import * as chai from 'chai';
import 'mocha';
import { NonfungibleDotComTradeResponse } from '../../../src/data_sources/nonfungible_dot_com';
import { NonfungibleDotComTrade } from '../../../src/entities';
import { _parseNonFungibleDotComTrade } from '../../../src/parsers/nonfungible_dot_com';
import { chaiSetup } from '../../utils/chai_setup';
chaiSetup.configure();
const expect = chai.expect;
const input: NonfungibleDotComTradeResponse = {
_id: '5b4cd04244abdb5ac3a8063f',
assetDescriptor: 'Kitty #1002',
assetId: '1002',
blockNumber: 4608542,
blockTimestamp: '2017-11-23T18:50:19.000Z',
buyer: '0x316c55d1895a085c4b39a98ecb563f509301aaf7',
logIndex: 28,
nftAddress: '0xb1690C08E213a35Ed9bAb7B318DE14420FB57d8C',
marketAddress: '0xb1690C08E213a35Ed9bAb7B318DE14420FB57d8C',
tokenTicker: 'eth',
meta: {
cattribute_body: 'munchkin',
cattribute_coloreyes: 'mintgreen',
cattribute_colorprimary: 'orangesoda',
cattribute_colorsecondary: 'coffee',
cattribute_colortertiary: 'kittencream',
cattribute_eyes: 'thicccbrowz',
cattribute_mouth: 'soserious',
cattribute_pattern: 'totesbasic',
generation: '0',
is_exclusive: false,
is_fancy: false,
},
seller: '0xba52c75764d6f594735dc735be7f1830cdf58ddf',
totalDecimalPrice: 0.00975138888888889,
totalPrice: '9751388888888889',
transactionHash: '0x468168419be7e442d5ff32d264fab24087b744bc2e37fdbac7024e1e74f4c6c8',
usdPrice: 3.71957,
currencyTransfer: {},
image: '',
composedOf: '',
asset_link: '',
seller_address_link: '',
buyer_address_link: '',
};
const expected: NonfungibleDotComTrade = {
assetDescriptor: 'Kitty #1002',
assetId: '1002',
blockNumber: 4608542,
blockTimestamp: 1511463019000,
buyerAddress: '0x316c55d1895a085c4b39a98ecb563f509301aaf7',
logIndex: 28,
marketAddress: '0xb1690C08E213a35Ed9bAb7B318DE14420FB57d8C',
meta: {
cattribute_body: 'munchkin',
cattribute_coloreyes: 'mintgreen',
cattribute_colorprimary: 'orangesoda',
cattribute_colorsecondary: 'coffee',
cattribute_colortertiary: 'kittencream',
cattribute_eyes: 'thicccbrowz',
cattribute_mouth: 'soserious',
cattribute_pattern: 'totesbasic',
generation: '0',
is_exclusive: false,
is_fancy: false,
},
sellerAddress: '0xba52c75764d6f594735dc735be7f1830cdf58ddf',
totalPrice: new BigNumber('9751388888888889'),
transactionHash: '0x468168419be7e442d5ff32d264fab24087b744bc2e37fdbac7024e1e74f4c6c8',
usdPrice: new BigNumber('3.71957'),
publisher: 'cryptokitties',
};
describe('nonfungible.com', () => {
describe('_parseNonFungibleDotComTrade', () => {
it(`converts NonfungibleDotComTradeResponse to NonfungibleDotComTrade entity`, () => {
const actual = _parseNonFungibleDotComTrade(expected.publisher, input);
expect(actual).deep.equal(expected);
});
});
});