ERC20 watcher based on eth_call (#165)

* Implement eth_call for ERC20 totalSupply.

* Use eth_call for erc20-watcher.

* Implement fallback for ERC20 symbol method call.

* Implement fallback for ERC20 name and totalSupply.

* implement fallback for erc20 decimals method.

* Lint fixes.

Co-authored-by: nabarun <nabarun@deepstacksoft.com>
This commit is contained in:
Ashwin Phatak 2021-07-28 10:04:07 +05:30 committed by GitHub
parent fd1ab0780c
commit c677e5942c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 353 additions and 38 deletions

View File

@ -1,6 +1,7 @@
[server]
host = "127.0.0.1"
port = 3001
mode = "eth_call"
[database]
type = "postgres"
@ -25,6 +26,7 @@
[upstream.ethServer]
gqlApiEndpoint = "http://127.0.0.1:8082/graphql"
gqlPostgraphileEndpoint = "http://127.0.0.1:5000/graphql"
rpcProviderEndpoint = "http://127.0.0.1:8545"
[upstream.cache]
name = "requests"

View File

@ -0,0 +1,17 @@
[
{
"constant": true,
"inputs": [],
"name": "name",
"outputs": [
{
"internalType": "bytes32",
"name": "",
"type": "bytes32"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
}
]

View File

@ -0,0 +1,17 @@
[
{
"constant": true,
"inputs": [],
"name": "symbol",
"outputs": [
{
"internalType": "bytes32",
"name": "",
"type": "bytes32"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
}
]

View File

@ -21,6 +21,6 @@ export class Allowance {
@Column('numeric')
value!: bigint;
@Column('text')
@Column('text', { nullable: true })
proof!: string;
}

View File

@ -18,6 +18,6 @@ export class Balance {
@Column('numeric')
value!: bigint;
@Column('text')
@Column('text', { nullable: true })
proof!: string;
}

View File

@ -39,11 +39,11 @@ export class EventWatcher {
if (isWatchedContract) {
// TODO: Move processing to background task runner.
const { ethTransactionCidByTxId: { ethHeaderCidByHeaderId: { blockHash } } } = receipt;
const { ethTransactionCidByTxId: { ethHeaderCidByHeaderId: { blockHash, blockNumber } } } = receipt;
await this._indexer.getEvents(blockHash, contractAddress, null);
// Trigger other indexer methods based on event topic.
await this._indexer.processEvent(blockHash, contractAddress, receipt, logIndex);
await this._indexer.processEvent(blockHash, blockNumber, contractAddress, receipt, logIndex);
}
}
}

View File

@ -4,7 +4,8 @@ import { invert } from 'lodash';
import { JsonFragment } from '@ethersproject/abi';
import { DeepPartial } from 'typeorm';
import JSONbig from 'json-bigint';
import { ethers } from 'ethers';
import { BigNumber, ethers } from 'ethers';
import { BaseProvider } from '@ethersproject/providers';
import { PubSub } from 'apollo-server-express';
import { EthClient, topictoAddress } from '@vulcanize/ipld-eth-client';
@ -12,9 +13,12 @@ import { getEventNameTopics, getStorageValue, GetStorageAt, StorageLayout } from
import { Database } from './database';
import { Event } from './entity/Event';
import { fetchTokenDecimals, fetchTokenName, fetchTokenSymbol, fetchTokenTotalSupply } from './utils';
const log = debug('vulcanize:indexer');
const ETH_CALL_MODE = 'eth_call';
interface Artifacts {
abi: JsonFragment[];
storageLayout: StorageLayout;
@ -22,7 +26,7 @@ interface Artifacts {
export interface ValueResult {
value: string | bigint;
proof: {
proof?: {
data: string;
}
}
@ -36,7 +40,7 @@ type EventsResult = Array<{
value?: BigInt;
__typename: string;
}
proof: string;
proof?: string;
}>
export class Indexer {
@ -44,12 +48,14 @@ export class Indexer {
_ethClient: EthClient
_pubsub: PubSub
_getStorageAt: GetStorageAt
_ethProvider: BaseProvider
_abi: JsonFragment[]
_storageLayout: StorageLayout
_contract: ethers.utils.Interface
_serverMode: string
constructor (db: Database, ethClient: EthClient, pubsub: PubSub, artifacts: Artifacts) {
constructor (db: Database, ethClient: EthClient, ethProvider: BaseProvider, pubsub: PubSub, artifacts: Artifacts, serverMode: string) {
assert(db);
assert(ethClient);
assert(pubsub);
@ -62,8 +68,10 @@ export class Indexer {
this._db = db;
this._ethClient = ethClient;
this._ethProvider = ethProvider;
this._pubsub = pubsub;
this._getStorageAt = this._ethClient.getStorageAt.bind(this._ethClient);
this._serverMode = serverMode;
this._abi = abi;
this._storageLayout = storageLayout;
@ -76,10 +84,18 @@ export class Indexer {
}
async totalSupply (blockHash: string, token: string): Promise<ValueResult> {
const result = await this._getStorageValue(blockHash, token, '_totalSupply');
let result: ValueResult;
if (this._serverMode === ETH_CALL_MODE) {
const value = await fetchTokenTotalSupply(this._ethProvider, token);
result = { value };
} else {
result = await this._getStorageValue(blockHash, token, '_totalSupply');
}
// https://github.com/GoogleChromeLabs/jsbi/issues/30#issuecomment-521460510
// log(JSONbig.stringify(result, null, 2));
log(JSONbig.stringify(result, null, 2));
return result;
}
@ -96,9 +112,25 @@ export class Indexer {
}
log('balanceOf: db miss, fetching from upstream server');
const result = await this._getStorageValue(blockHash, token, '_balances', owner);
let result: ValueResult;
// log(JSONbig.stringify(result, null, 2));
if (this._serverMode === ETH_CALL_MODE) {
const contract = new ethers.Contract(token, this._abi, this._ethProvider);
const { block } = await this._ethClient.getBlockByHash(blockHash);
const { number } = block;
const blockNumber = BigNumber.from(number).toNumber();
// eth_call doesnt support calling method by blockHash https://eth.wiki/json-rpc/API#the-default-block-parameter
const value = await contract.balanceOf(owner, { blockTag: blockNumber });
result = {
value: BigInt(value.toString())
};
} else {
result = await this._getStorageValue(blockHash, token, '_balances', owner);
}
log(JSONbig.stringify(result, null, 2));
const { value, proof } = result;
await this._db.saveBalance({ blockHash, token, owner, value: BigInt(value), proof: JSONbig.stringify(proof) });
@ -118,7 +150,21 @@ export class Indexer {
}
log('allowance: db miss, fetching from upstream server');
const result = await this._getStorageValue(blockHash, token, '_allowances', owner, spender);
let result: ValueResult;
if (this._serverMode === ETH_CALL_MODE) {
const contract = new ethers.Contract(token, this._abi, this._ethProvider);
const { block } = await this._ethClient.getBlockByHash(blockHash);
const { number } = block;
const blockNumber = BigNumber.from(number).toNumber();
const value = await contract.allowance(owner, spender, { blockTag: blockNumber });
result = {
value: BigInt(value.toString())
};
} else {
result = await this._getStorageValue(blockHash, token, '_allowances', owner, spender);
}
// log(JSONbig.stringify(result, null, 2));
@ -129,7 +175,15 @@ export class Indexer {
}
async name (blockHash: string, token: string): Promise<ValueResult> {
const result = await this._getStorageValue(blockHash, token, '_name');
let result: ValueResult;
if (this._serverMode === ETH_CALL_MODE) {
const value = await fetchTokenName(this._ethProvider, token);
result = { value };
} else {
result = await this._getStorageValue(blockHash, token, '_name');
}
// log(JSONbig.stringify(result, null, 2));
@ -137,20 +191,37 @@ export class Indexer {
}
async symbol (blockHash: string, token: string): Promise<ValueResult> {
const result = await this._getStorageValue(blockHash, token, '_symbol');
let result: ValueResult;
if (this._serverMode === ETH_CALL_MODE) {
const value = await fetchTokenSymbol(this._ethProvider, token);
result = { value };
} else {
result = await this._getStorageValue(blockHash, token, '_symbol');
}
// log(JSONbig.stringify(result, null, 2));
return result;
}
async decimals (): Promise<void> {
async decimals (blockHash: string, token: string): Promise<ValueResult> {
let result: ValueResult;
if (this._serverMode === ETH_CALL_MODE) {
const value = await fetchTokenDecimals(this._ethProvider, token);
result = { value };
} else {
// Not a state variable, uses hardcoded return value in contract function.
// See https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol#L86
throw new Error('Not implemented.');
}
return result;
}
async getEvents (blockHash: string, token: string, name: string | null): Promise<EventsResult> {
const didSyncEvents = await this._db.didSyncEvents({ blockHash, token });
if (!didSyncEvents) {
@ -206,7 +277,7 @@ export class Indexer {
return result;
}
async triggerIndexingOnEvent (blockHash: string, token: string, receipt: any, logIndex: number): Promise<void> {
async triggerIndexingOnEvent (blockHash: string, blockNumber: number, token: string, receipt: any, logIndex: number): Promise<void> {
const topics = [];
// We only care about the event type for now.
@ -257,9 +328,9 @@ export class Indexer {
});
}
async processEvent (blockHash: string, token: string, receipt: any, logIndex: number): Promise<void> {
async processEvent (blockHash: string, blockNumber: number, token: string, receipt: any, logIndex: number): Promise<void> {
// Trigger indexing of data based on the event.
await this.triggerIndexingOnEvent(blockHash, token, receipt, logIndex);
await this.triggerIndexingOnEvent(blockHash, blockNumber, token, receipt, logIndex);
// Also trigger downstream event watcher subscriptions.
await this.publishEventToSubscribers(blockHash, token, logIndex);

View File

@ -62,7 +62,7 @@ export const createResolvers = async (indexer: Indexer): Promise<any> => {
decimals: (_: any, { blockHash, token }: { blockHash: string, token: string }) => {
log('decimals', blockHash, token);
return indexer.decimals();
return indexer.decimals(blockHash, token);
},
events: async (_: any, { blockHash, token, name }: { blockHash: string, token: string, name: string }) => {

View File

@ -7,6 +7,7 @@ import { hideBin } from 'yargs/helpers';
import debug from 'debug';
import 'graphql-import-node';
import { createServer } from 'http';
import { getDefaultProvider } from 'ethers';
import { getCache } from '@vulcanize/cache';
import { EthClient } from '@vulcanize/ipld-eth-client';
@ -37,7 +38,7 @@ export const main = async (): Promise<any> => {
assert(config.server, 'Missing server config');
const { host, port } = config.server;
const { host, port, mode } = config.server;
const { upstream, database: dbConfig } = config;
@ -47,7 +48,7 @@ export const main = async (): Promise<any> => {
await db.init();
assert(upstream, 'Missing upstream config');
const { ethServer: { gqlApiEndpoint, gqlPostgraphileEndpoint }, cache: cacheConfig } = upstream;
const { ethServer: { gqlApiEndpoint, gqlPostgraphileEndpoint, rpcProviderEndpoint }, cache: cacheConfig } = upstream;
assert(gqlApiEndpoint, 'Missing upstream ethServer.gqlApiEndpoint');
assert(gqlPostgraphileEndpoint, 'Missing upstream ethServer.gqlPostgraphileEndpoint');
@ -58,10 +59,12 @@ export const main = async (): Promise<any> => {
cache
});
const ethProvider = getDefaultProvider(rpcProviderEndpoint);
// Note: In-memory pubsub works fine for now, as each watcher is a single process anyway.
// Later: https://www.apollographql.com/docs/apollo-server/data/subscriptions/#production-pubsub-libraries
const pubsub = new PubSub();
const indexer = new Indexer(db, ethClient, pubsub, artifacts);
const indexer = new Indexer(db, ethClient, ethProvider, pubsub, artifacts, mode);
const eventWatcher = new EventWatcher(ethClient, indexer);
await eventWatcher.start();

View File

@ -0,0 +1,110 @@
import { Contract, utils } from 'ethers';
import { BaseProvider } from '@ethersproject/providers';
import { abi } from '../artifacts/ERC20.json';
import ERC20SymbolBytesABI from '../artifacts/ERC20SymbolBytes.json';
import ERC20NameBytesABI from '../artifacts/ERC20NameBytes.json';
import { StaticTokenDefinition } from './static-token-definition';
export const fetchTokenSymbol = async (ethProvider: BaseProvider, tokenAddress: string): Promise<string> => {
const contract = new Contract(tokenAddress, abi, ethProvider);
const contractSymbolBytes = new Contract(tokenAddress, ERC20SymbolBytesABI, ethProvider);
let symbolValue = 'unknown';
// Try types string and bytes32 for symbol.
try {
const result = await contract.symbol();
symbolValue = result;
} catch (error) {
try {
const result = await contractSymbolBytes.symbol();
// For broken pairs that have no symbol function exposed.
if (!isNullEthValue(result)) {
symbolValue = utils.parseBytes32String(result);
} else {
// Try with the static definition.
const staticTokenDefinition = StaticTokenDefinition.fromAddress(tokenAddress);
if (staticTokenDefinition !== null) {
symbolValue = staticTokenDefinition.symbol;
}
}
} catch (error) {
// symbolValue is unknown if the calls revert.
}
}
return symbolValue;
};
export const fetchTokenName = async (ethProvider: BaseProvider, tokenAddress: string): Promise<string> => {
const contract = new Contract(tokenAddress, abi, ethProvider);
const contractNameBytes = new Contract(tokenAddress, ERC20NameBytesABI, ethProvider);
let nameValue = 'unknown';
// Try types string and bytes32 for name.
try {
const result = await contract.name();
nameValue = result;
} catch (error) {
try {
const result = await contractNameBytes.name();
// For broken pairs that have no name function exposed.
if (!isNullEthValue(result)) {
nameValue = utils.parseBytes32String(result);
} else {
// Try with the static definition.
const staticTokenDefinition = StaticTokenDefinition.fromAddress(tokenAddress);
if (staticTokenDefinition !== null) {
nameValue = staticTokenDefinition.name;
}
}
} catch (error) {
// nameValue is unknown if the calls revert.
}
}
return nameValue;
};
export const fetchTokenTotalSupply = async (ethProvider: BaseProvider, tokenAddress: string): Promise<bigint> => {
const contract = new Contract(tokenAddress, abi, ethProvider);
let totalSupplyValue = null;
try {
const result = await contract.totalSupply();
totalSupplyValue = result.toString();
} catch (error) {
totalSupplyValue = 0;
}
return BigInt(totalSupplyValue);
};
export const fetchTokenDecimals = async (ethProvider: BaseProvider, tokenAddress: string): Promise<bigint> => {
const contract = new Contract(tokenAddress, abi, ethProvider);
// Try types uint8 for decimals.
let decimalValue = null;
try {
const result = await contract.decimals();
decimalValue = result.toString();
} catch (error) {
// Try with the static definition.
const staticTokenDefinition = StaticTokenDefinition.fromAddress(tokenAddress);
if (staticTokenDefinition != null) {
return staticTokenDefinition.decimals;
}
}
return BigInt(decimalValue);
};
const isNullEthValue = (value: string): boolean => {
return value === '0x0000000000000000000000000000000000000000000000000000000000000001';
};

View File

@ -0,0 +1,94 @@
import { utils } from 'ethers';
// Initialize a Token Definition with the attributes.
export class StaticTokenDefinition {
address : string
symbol: string
name: string
decimals: bigint
// Initialize a Token Definition with its attributes.
constructor (address: string, symbol: string, name: string, decimals: bigint) {
this.address = address;
this.symbol = symbol;
this.name = name;
this.decimals = decimals;
}
// Get all tokens with a static defintion
static getStaticDefinitions (): Array<StaticTokenDefinition> {
const staticDefinitions = new Array<StaticTokenDefinition>(6);
// Add DGD.
const tokenDGD = new StaticTokenDefinition(
utils.getAddress('0xe0b7927c4af23765cb51314a0e0521a9645f0e2a'),
'DGD',
'DGD',
BigInt(9)
);
staticDefinitions.push(tokenDGD);
// Add AAVE.
const tokenAAVE = new StaticTokenDefinition(
utils.getAddress('0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9'),
'AAVE',
'Aave Token',
BigInt(18)
);
staticDefinitions.push(tokenAAVE);
// Add LIF.
const tokenLIF = new StaticTokenDefinition(
utils.getAddress('0xeb9951021698b42e4399f9cbb6267aa35f82d59d'),
'LIF',
'Lif',
BigInt(18)
);
staticDefinitions.push(tokenLIF);
// Add SVD.
const tokenSVD = new StaticTokenDefinition(
utils.getAddress('0xbdeb4b83251fb146687fa19d1c660f99411eefe3'),
'SVD',
'savedroid',
BigInt(18)
);
staticDefinitions.push(tokenSVD);
// Add TheDAO.
const tokenTheDAO = new StaticTokenDefinition(
utils.getAddress('0xbb9bc244d798123fde783fcc1c72d3bb8c189413'),
'TheDAO',
'TheDAO',
BigInt(16)
);
staticDefinitions.push(tokenTheDAO);
// Add HPB.
const tokenHPB = new StaticTokenDefinition(
utils.getAddress('0x38c6a68304cdefb9bec48bbfaaba5c5b47818bb2'),
'HPB',
'HPBCoin',
BigInt(18)
);
staticDefinitions.push(tokenHPB);
return staticDefinitions;
}
// Helper for hardcoded tokens.
static fromAddress (tokenAddress: string) : StaticTokenDefinition | null {
const staticDefinitions = this.getStaticDefinitions();
// Search the definition using the address.
for (let i = 0; i < staticDefinitions.length; i++) {
const staticDefinition = staticDefinitions[i];
if (utils.getAddress(staticDefinition.address) === utils.getAddress(tokenAddress)) {
return staticDefinition;
}
}
// If not found, return null.
return null;
}
}

View File

@ -56,15 +56,14 @@ query allEthHeaderCids($blockNumber: BigInt) {
`;
export const getBlockByHash = gql`
query allEthHeaderCids($blockHash: Bytes32) {
allEthHeaderCids(condition: { blockHash: $blockHash }) {
nodes {
cid
blockNumber
blockHash
parentHash
timestamp
query block($blockHash: Bytes32) {
block(hash: $blockHash) {
number
hash
parent {
hash
}
timestamp
}
}
`;

View File

@ -648,7 +648,7 @@ export class Database {
async _getPrevEntityVersion<Entity> (repo: Repository<Entity>, findOptions: { [key: string]: any }): Promise<Entity | undefined> {
assert(findOptions.order.blockNumber);
const { canonicalBlockNumber, blockHashes } = await this._getBranchInfo(findOptions.where.blockHash);
findOptions.where.blockHash = In(blockHashes)
findOptions.where.blockHash = In(blockHashes);
let entity = await repo.findOne(findOptions);
if (!entity) {

View File

@ -47,7 +47,7 @@ export class EventWatcher {
await this.initEventProcessingOnCompleteHandler();
}
async watchBlocksAtChainHead () {
async watchBlocksAtChainHead (): Promise<void> {
log('Started watching upstream blocks...');
this._subscription = await this._ethClient.watchBlocks(async (value) => {
const { blockHash, blockNumber, parentHash } = _.get(value, 'data.listen.relatedNode');
@ -59,7 +59,7 @@ export class EventWatcher {
});
}
async initBlockProcessingOnCompleteHandler () {
async initBlockProcessingOnCompleteHandler (): Promise<void> {
this._jobQueue.onComplete(QUEUE_BLOCK_PROCESSING, async (job) => {
const { data: { request: { data: { blockHash, blockNumber } } } } = job;
log(`Job onComplete block ${blockHash} ${blockNumber}`);
@ -70,7 +70,7 @@ export class EventWatcher {
});
}
async initEventProcessingOnCompleteHandler () {
async initEventProcessingOnCompleteHandler (): Promise<void> {
this._jobQueue.onComplete(QUEUE_EVENT_PROCESSING, async (job) => {
const { data: { request, failed, state, createdOn } } = job;

View File

@ -12,6 +12,7 @@ export interface Config {
server: {
host: string;
port: number;
mode: string;
};
database: ConnectionOptions;
upstream: {
@ -19,6 +20,7 @@ export interface Config {
ethServer: {
gqlApiEndpoint: string;
gqlPostgraphileEndpoint: string;
rpcProviderEndpoint: string
}
traceProviderEndpoint: string;
uniWatcher: {