Support storage mode for code generator (#251)

* Implement storage mode in code generator.

* Fix indexer template for eth_call.

* Add support for both eth_call and storage modes.

* Add eventsInRange, events file gen and default entites gen.

* Use static property to column maps in database.

* Process events query.

* Avoid adding duplicate events in indexer.

* Fix generated watcher query with bigint arguments.

Co-authored-by: nabarun <nabarun@deepstacksoft.com>
Co-authored-by: prathamesh <prathamesh.musale0@gmail.com>
This commit is contained in:
Ashwin Phatak 2021-09-27 10:13:50 +05:30 committed by GitHub
parent 92b7967895
commit 482be8cfaa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1147 additions and 118 deletions

View File

@ -13,27 +13,34 @@
* Run the following command to generate a watcher from a contract file:
```bash
yarn codegen --input-file <input-file-path> --contract-name <contract-name> --output-folder [output-folder] --mode [eth_call | storage] --flatten [true | false]
yarn codegen --input-file <input-file-path> --contract-name <contract-name> --output-folder [output-folder] --mode [eth_call | storage | all] --flatten [true | false]
```
* `input-file`(alias: `i`): Input contract file path or an URL (required).
* `contract-name`(alias: `c`): Main contract name (required).
* `output-folder`(alias: `o`): Output folder path. (logs output using `stdout` if not provided).
* `mode`(alias: `m`): Code generation mode (default: `storage`).
* `mode`(alias: `m`): Code generation mode (default: `all`).
* `flatten`(alias: `f`): Flatten the input contract file (default: `true`).
**Note**: When passed an *URL* as `input-file`, it is assumed that it points to an already flattened contract file.
Examples:
Generate code in both eth_call and storage mode.
```bash
yarn codegen --input-file ./test/examples/contracts/ERC20.sol --contract-name ERC20 --output-folder ../my-erc20-watcher --mode eth_call
yarn codegen --input-file ./test/examples/contracts/ERC20.sol --contract-name ERC20 --output-folder ../my-erc20-watcher --mode all
```
Generate code in eth_call mode using a contract provided by URL.
```bash
yarn codegen --input-file https://git.io/Jupci --contract-name ERC721 --output-folder ../my-erc721-watcher --mode eth_call
```
Generate code in storage mode.
```bash
yarn codegen --input-file ./test/examples/contracts/ERC721.sol --contract-name ERC721 --output-folder ../my-erc721-watcher --mode storage
```
## Demo
* Install required packages:
@ -43,7 +50,7 @@
```
* Generate a watcher from a contract file:
```bash
yarn codegen --input-file ../../node_modules/@openzeppelin/contracts/token/ERC20/ERC20.sol --contract-name ERC20 --output-folder ../demo-erc20-watcher --mode eth_call
```
@ -51,7 +58,7 @@
This will create a folder called `demo-erc20-watcher` containing the generated code at the specified path. Follow the steps in `demo-erc20-watcher/README.md` to setup and run the generated watcher.
* Generate a watcher from a flattened contract file from an URL:
```bash
yarn codegen --input-file https://git.io/Jupci --contract-name ERC721 --output-folder ../demo-erc721-watcher --mode eth_call
```
@ -65,4 +72,4 @@
## Known Issues
* Currently, `node-fetch v2.6.2` is being used to fetch from URLs as `v3.0.0` is an [ESM-only module](https://www.npmjs.com/package/node-fetch#loading-and-configuring-the-module) and `ts-node` transpiles to import it using `require`.
* Currently, `node-fetch v2.6.2` is being used to fetch from URLs as `v3.0.0` is an [ESM-only module](https://www.npmjs.com/package/node-fetch#loading-and-configuring-the-module) and `ts-node` transpiles to import it using `require`.

View File

@ -11,7 +11,6 @@ import _ from 'lodash';
import { getTsForSol } from './utils/type-mappings';
import { Param } from './utils/types';
import { capitalizeHelper } from './utils/handlebar-helpers';
const TEMPLATE_FILE = './templates/database-template.handlebars';
@ -22,8 +21,6 @@ export class Database {
constructor () {
this._queries = [];
this._templateString = fs.readFileSync(path.resolve(__dirname, TEMPLATE_FILE)).toString();
Handlebars.registerHelper('capitalize', capitalizeHelper);
}
/**

View File

@ -37,34 +37,95 @@ export class Entity {
const entityObject: any = {
// Capitalize the first letter of name.
className: `${name.charAt(0).toUpperCase()}${name.slice(1)}`,
indexOn: {},
columns: [{}],
returnType: returnType
indexOn: [],
columns: [],
imports: []
};
entityObject.indexOn.columns = params.map((param) => {
return param.name;
entityObject.imports.push(
{
toImport: ['Entity', 'PrimaryGeneratedColumn', 'Column', 'Index'],
from: 'typeorm'
}
);
const indexObject = {
columns: ['blockHash', 'contractAddress'],
unique: true
};
indexObject.columns = indexObject.columns.concat(
params.map((param) => {
return param.name;
})
);
entityObject.indexOn.push(indexObject);
entityObject.columns.push({
name: 'blockHash',
pgType: 'varchar',
tsType: 'string',
columnType: 'Column',
columnOptions: [
{
option: 'length',
value: 66
}
]
});
entityObject.indexOn.unique = true;
entityObject.columns = params.map((param) => {
const length = param.type === 'address' ? 42 : null;
const name = param.name;
const tsType = getTsForSol(param.type);
assert(tsType);
const pgType = getPgForTs(tsType);
assert(pgType);
return {
name,
pgType,
tsType,
length
};
entityObject.columns.push({
name: 'contractAddress',
pgType: 'varchar',
tsType: 'string',
columnType: 'Column',
columnOptions: [
{
option: 'length',
value: 42
}
]
});
entityObject.columns = entityObject.columns.concat(
params.map((param) => {
const name = param.name;
const tsType = getTsForSol(param.type);
assert(tsType);
const pgType = getPgForTs(tsType);
assert(pgType);
const columnOptions = [];
if (param.type === 'address') {
columnOptions.push(
{
option: 'length',
value: 42
}
);
}
// Use bigintTransformer for bigint types.
if (tsType === 'bigint') {
columnOptions.push(
{
option: 'transformer',
value: 'bigintTransformer'
}
);
}
return {
name,
pgType,
tsType,
columnType: 'Column',
columnOptions
};
})
);
const tsReturnType = getTsForSol(returnType);
assert(tsReturnType);
@ -74,7 +135,21 @@ export class Entity {
entityObject.columns.push({
name: 'value',
pgType: pgReturnType,
tsType: tsReturnType
tsType: tsReturnType,
columnType: 'Column'
});
entityObject.columns.push({
name: 'proof',
pgType: 'text',
tsType: 'string',
columnType: 'Column',
columnOptions: [
{
option: 'nullable',
value: true
}
]
});
this._entities.push(entityObject);
@ -85,6 +160,11 @@ export class Entity {
* @param entityDir Directory to write the entities to.
*/
exportEntities (entityDir: string): void {
this._addEventEntity();
this._addSyncStatusEntity();
this._addContractEntity();
this._addBlockProgressEntity();
const template = Handlebars.compile(this._templateString);
this._entities.forEach(entityObj => {
const entity = template(entityObj);
@ -94,4 +174,364 @@ export class Entity {
outStream.write(entity);
});
}
_addEventEntity (): void {
const entity: any = {
className: 'Event',
indexOn: [],
columns: [],
imports: []
};
entity.imports.push(
{
toImport: ['Entity', 'PrimaryGeneratedColumn', 'Column', 'Index', 'ManyToOne'],
from: 'typeorm'
},
{
toImport: ['BlockProgress'],
from: './BlockProgress'
}
);
entity.indexOn.push(
{
columns: ['block', 'contract']
},
{
columns: ['block', 'contract', 'eventName']
}
);
entity.columns.push({
name: 'block',
tsType: 'BlockProgress',
columnType: 'ManyToOne',
lhs: '()',
rhs: 'BlockProgress'
});
entity.columns.push({
name: 'txHash',
pgType: 'varchar',
tsType: 'string',
columnType: 'Column',
columnOptions: [
{
option: 'length',
value: 66
}
]
});
entity.columns.push({
name: 'index',
pgType: 'integer',
tsType: 'number',
columnType: 'Column'
});
entity.columns.push({
name: 'contract',
pgType: 'varchar',
tsType: 'string',
columnType: 'Column',
columnOptions: [
{
option: 'length',
value: 42
}
]
});
entity.columns.push({
name: 'eventName',
pgType: 'varchar',
tsType: 'string',
columnType: 'Column',
columnOptions: [
{
option: 'length',
value: 256
}
]
});
entity.columns.push({
name: 'eventInfo',
pgType: 'text',
tsType: 'string',
columnType: 'Column'
});
entity.columns.push({
name: 'extraInfo',
pgType: 'text',
tsType: 'string',
columnType: 'Column'
});
entity.columns.push({
name: 'proof',
pgType: 'text',
tsType: 'string',
columnType: 'Column'
});
this._entities.push(entity);
}
_addSyncStatusEntity (): void {
const entity: any = {
className: 'SyncStatus',
implements: 'SyncStatusInterface',
indexOn: [],
columns: [],
imports: []
};
entity.imports.push({
toImport: ['Entity', 'PrimaryGeneratedColumn', 'Column'],
from: 'typeorm'
});
entity.imports.push({
toImport: ['SyncStatusInterface'],
from: '@vulcanize/util'
});
entity.columns.push({
name: 'chainHeadBlockHash',
pgType: 'varchar',
tsType: 'string',
columnType: 'Column',
columnOptions: [
{
option: 'length',
value: 66
}
]
});
entity.columns.push({
name: 'chainHeadBlockNumber',
pgType: 'integer',
tsType: 'number',
columnType: 'Column'
});
entity.columns.push({
name: 'latestIndexedBlockHash',
pgType: 'varchar',
tsType: 'string',
columnType: 'Column',
columnOptions: [
{
option: 'length',
value: 66
}
]
});
entity.columns.push({
name: 'latestIndexedBlockNumber',
pgType: 'integer',
tsType: 'number',
columnType: 'Column'
});
entity.columns.push({
name: 'latestCanonicalBlockHash',
pgType: 'varchar',
tsType: 'string',
columnType: 'Column',
columnOptions: [
{
option: 'length',
value: 66
}
]
});
entity.columns.push({
name: 'latestCanonicalBlockNumber',
pgType: 'integer',
tsType: 'number',
columnType: 'Column'
});
this._entities.push(entity);
}
_addContractEntity (): void {
const entity: any = {
className: 'Contract',
indexOn: [],
columns: [],
imports: []
};
entity.imports.push({
toImport: ['Entity', 'PrimaryGeneratedColumn', 'Column', 'Index'],
from: 'typeorm'
});
entity.indexOn.push(
{
columns: ['address'],
unique: true
}
);
entity.columns.push({
name: 'address',
pgType: 'varchar',
tsType: 'string',
columnType: 'Column',
columnOptions: [
{
option: 'length',
value: 42
}
]
});
entity.columns.push({
name: 'kind',
pgType: 'varchar',
tsType: 'string',
columnType: 'Column',
columnOptions: [
{
option: 'length',
value: 8
}
]
});
entity.columns.push({
name: 'startingBlock',
pgType: 'integer',
tsType: 'number',
columnType: 'Column'
});
this._entities.push(entity);
}
_addBlockProgressEntity (): void {
const entity: any = {
className: 'BlockProgress',
implements: 'BlockProgressInterface',
indexOn: [],
columns: [],
imports: []
};
entity.imports.push({
toImport: ['Entity', 'PrimaryGeneratedColumn', 'Column', 'Index'],
from: 'typeorm'
});
entity.imports.push({
toImport: ['BlockProgressInterface'],
from: '@vulcanize/util'
});
entity.indexOn.push(
{
columns: ['blockHash'],
unique: true
},
{
columns: ['blockNumber']
},
{
columns: ['parentHash']
}
);
entity.columns.push({
name: 'blockHash',
pgType: 'varchar',
tsType: 'string',
columnType: 'Column',
columnOptions: [
{
option: 'length',
value: 66
}
]
});
entity.columns.push({
name: 'parentHash',
pgType: 'varchar',
tsType: 'string',
columnType: 'Column',
columnOptions: [
{
option: 'length',
value: 66
}
]
});
entity.columns.push({
name: 'blockNumber',
pgType: 'integer',
tsType: 'number',
columnType: 'Column'
});
entity.columns.push({
name: 'blockTimestamp',
pgType: 'integer',
tsType: 'number',
columnType: 'Column'
});
entity.columns.push({
name: 'numEvents',
pgType: 'integer',
tsType: 'number',
columnType: 'Column'
});
entity.columns.push({
name: 'numProcessedEvents',
pgType: 'integer',
tsType: 'number',
columnType: 'Column'
});
entity.columns.push({
name: 'lastProcessedEventIndex',
pgType: 'integer',
tsType: 'number',
columnType: 'Column'
});
entity.columns.push({
name: 'isComplete',
pgType: 'boolean',
tsType: 'boolean',
columnType: 'Column'
});
entity.columns.push({
name: 'isPruned',
pgType: 'boolean',
tsType: 'boolean',
columnType: 'Column',
columnOptions: [
{
option: 'default',
value: false
}
]
});
this._entities.push(entity);
}
}

View File

@ -0,0 +1,21 @@
//
// Copyright 2021 Vulcanize, Inc.
//
import fs from 'fs';
import path from 'path';
import Handlebars from 'handlebars';
import { Writable } from 'stream';
const TEMPLATE_FILE = './templates/events-template.handlebars';
/**
* Writes the events file generated from a template to a stream.
* @param outStream A writable output stream to write the events file to.
*/
export function exportEvents (outStream: Writable): void {
const templateString = fs.readFileSync(path.resolve(__dirname, TEMPLATE_FILE)).toString();
const template = Handlebars.compile(templateString);
const events = template({});
outStream.write(events);
}

View File

@ -11,6 +11,7 @@ import { flatten } from '@poanet/solidity-flattener';
import { parse, visit } from '@solidity-parser/parser';
import { MODE_ETH_CALL, MODE_STORAGE, MODE_ALL } from './utils/constants';
import { Visitor } from './visitor';
import { exportServer } from './server';
import { exportConfig } from './config';
@ -18,9 +19,8 @@ import { exportArtifacts } from './artifacts';
import { exportPackage } from './package';
import { exportTSConfig } from './tsconfig';
import { exportReadme } from './readme';
const MODE_ETH_CALL = 'eth_call';
const MODE_STORAGE = 'storage';
import { exportEvents } from './events';
import { registerHandlebarHelpers } from './utils/handlebar-helpers';
const main = async (): Promise<void> => {
const argv = await yargs(hideBin(process.argv))
@ -45,8 +45,8 @@ const main = async (): Promise<void> => {
alias: 'm',
describe: 'Code generation mode.',
type: 'string',
default: MODE_STORAGE,
choices: [MODE_ETH_CALL, MODE_STORAGE]
default: MODE_ALL,
choices: [MODE_ETH_CALL, MODE_STORAGE, MODE_ALL]
})
.option('flatten', {
alias: 'f',
@ -81,12 +81,14 @@ function parseAndVisit (data: string, visitor: Visitor, mode: string) {
// Filter out library nodes.
ast.children = ast.children.filter(child => !(child.type === 'ContractDefinition' && child.kind === 'library'));
if (mode === MODE_ETH_CALL) {
if ([MODE_ALL, MODE_ETH_CALL].some(value => value === mode)) {
visit(ast, {
FunctionDefinition: visitor.functionDefinitionVisitor.bind(visitor),
EventDefinition: visitor.eventDefinitionVisitor.bind(visitor)
});
} else {
}
if ([MODE_ALL, MODE_STORAGE].some(value => value === mode)) {
visit(ast, {
StateVariableDeclaration: visitor.stateVariableDeclarationVisitor.bind(visitor),
EventDefinition: visitor.eventDefinitionVisitor.bind(visitor)
@ -113,6 +115,8 @@ function generateWatcher (data: string, visitor: Visitor, argv: any) {
const inputFileName = path.basename(argv['input-file'], '.sol');
registerHandlebarHelpers();
let outStream = outputDir
? fs.createWriteStream(path.join(outputDir, 'src/schema.gql'))
: process.stdout;
@ -172,6 +176,11 @@ function generateWatcher (data: string, visitor: Visitor, argv: any) {
? fs.createWriteStream(path.join(outputDir, 'README.md'))
: process.stdout;
exportReadme(path.basename(outputDir), argv['contract-name'], outStream);
outStream = outputDir
? fs.createWriteStream(path.join(outputDir, 'src/events.ts'))
: process.stdout;
exportEvents(outStream);
}
main().catch(err => {

View File

@ -11,38 +11,39 @@ import _ from 'lodash';
import { getTsForSol } from './utils/type-mappings';
import { Param } from './utils/types';
import { compareHelper, capitalizeHelper } from './utils/handlebar-helpers';
import { MODE_ETH_CALL, MODE_STORAGE } from './utils/constants';
const TEMPLATE_FILE = './templates/indexer-template.handlebars';
export class Indexer {
_queries: Array<any>;
_events: Array<any>;
_templateString: string;
constructor () {
this._queries = [];
this._events = [];
this._templateString = fs.readFileSync(path.resolve(__dirname, TEMPLATE_FILE)).toString();
Handlebars.registerHelper('compare', compareHelper);
Handlebars.registerHelper('capitalize', capitalizeHelper);
}
/**
* Stores the query to be passed to the template.
* @param mode Code generation mode.
* @param name Name of the query.
* @param params Parameters to the query.
* @param returnType Return type for the query.
*/
addQuery (name: string, params: Array<Param>, returnType: string): void {
addQuery (mode: string, name: string, params: Array<Param>, returnType: string): void {
// Check if the query is already added.
if (this._queries.some(query => query.name === name)) {
return;
}
const queryObject = {
name: name,
name,
params: _.cloneDeep(params),
returnType: returnType
returnType,
mode
};
queryObject.params = queryObject.params.map((param) => {
@ -59,6 +60,18 @@ export class Indexer {
this._queries.push(queryObject);
}
addEvent (name: string, params: Array<Param>): void {
// Check if the event is already added.
if (this._events.some(event => event.name === name)) {
return;
}
this._events.push({
name,
params
});
}
/**
* Writes the indexer file generated from a template to a stream.
* @param outStream A writable output stream to write the indexer file to.
@ -66,10 +79,17 @@ export class Indexer {
*/
exportIndexer (outStream: Writable, inputFileName: string): void {
const template = Handlebars.compile(this._templateString);
const obj = {
inputFileName,
queries: this._queries
queries: this._queries,
constants: {
MODE_ETH_CALL,
MODE_STORAGE
},
events: this._events
};
const indexer = template(obj);
outStream.write(indexer);
}

View File

@ -217,6 +217,16 @@ export class Schema {
}
}
});
this._composer.Query.addFields({
eventsInRange: {
type: [this._composer.getOTC('ResultEvent').NonNull],
args: {
fromBlockNumber: 'Int!',
toBlockNumber: 'Int!'
}
}
});
}
/**

View File

@ -1,7 +1,6 @@
[server]
host = "127.0.0.1"
port = 3008
mode = "eth_call"
[database]
type = "postgres"

View File

@ -3,19 +3,24 @@
//
import assert from 'assert';
import { Connection, ConnectionOptions, DeepPartial } from 'typeorm';
import { Connection, ConnectionOptions, DeepPartial, FindConditions, QueryRunner } from 'typeorm';
import path from 'path';
import { Database as BaseDatabase } from '@vulcanize/util';
import { Contract } from './entity/Contract';
import { Event } from './entity/Event';
import { SyncStatus } from './entity/SyncStatus';
import { BlockProgress } from './entity/BlockProgress';
{{#each queries as | query |}}
import { {{capitalize query.name tillIndex=1}} } from './entity/{{capitalize query.name tillIndex=1}}';
{{/each}}
export class Database {
_config: ConnectionOptions
_conn!: Connection
_config: ConnectionOptions;
_conn!: Connection;
_baseDatabase: BaseDatabase;
_propColMaps: { [key: string]: Map<string, string>; }
constructor (config: ConnectionOptions) {
assert(config);
@ -26,10 +31,12 @@ export class Database {
};
this._baseDatabase = new BaseDatabase(this._config);
this._propColMaps = {};
}
async init (): Promise<void> {
this._conn = await this._baseDatabase.init();
this._setPropColMaps();
}
async close (): Promise<void> {
@ -41,17 +48,13 @@ export class Database {
{{~#each query.params}}, {{this.name~}} {{/each}} }: { blockHash: string, contractAddress: string
{{~#each query.params}}, {{this.name~}}: {{this.type~}} {{/each}} }): Promise<{{capitalize query.name tillIndex=1}} | undefined> {
return this._conn.getRepository({{capitalize query.name tillIndex=1}})
.createQueryBuilder('{{query.name}}')
.where(`${this._getColumn('{{capitalize query.name tillIndex=1}}', 'blockHash')} = :blockHash AND ${this._getColumn('{{capitalize query.name tillIndex=1}}', 'contractAddress')} = :contractAddress
{{~#each query.params}} AND ${this._getColumn('{{capitalize query.name tillIndex=1}}', '{{this.name}}')} = :{{this.name~}} {{/each}}`, {
.findOne({
blockHash,
contractAddress
{{~#each query.params}},
{{this.name}}
{{~/each}}
contractAddress{{#if query.params.length}},{{/if}}
{{#each query.params}}
{{this.name}}{{#unless @last}},{{/unless}}
{{/each}}
})
.getOne();
}
{{/each}}
@ -66,7 +69,124 @@ export class Database {
}
{{/each}}
_getColumn (entityName: string, propertyName: string) {
return this._conn.getMetadata(entityName).findColumnWithPropertyName(propertyName)?.databaseName
async getContract (address: string): Promise<Contract | undefined> {
const repo = this._conn.getRepository(Contract);
return this._baseDatabase.getContract(repo, address);
}
async createTransactionRunner (): Promise<QueryRunner> {
return this._baseDatabase.createTransactionRunner();
}
async getProcessedBlockCountForRange (fromBlockNumber: number, toBlockNumber: number): Promise<{ expected: number, actual: number }> {
const repo = this._conn.getRepository(BlockProgress);
return this._baseDatabase.getProcessedBlockCountForRange(repo, fromBlockNumber, toBlockNumber);
}
async getEventsInRange (fromBlockNumber: number, toBlockNumber: number): Promise<Array<Event>> {
const repo = this._conn.getRepository(Event);
return this._baseDatabase.getEventsInRange(repo, fromBlockNumber, toBlockNumber);
}
async saveEventEntity (queryRunner: QueryRunner, entity: Event): Promise<Event> {
const repo = queryRunner.manager.getRepository(Event);
return this._baseDatabase.saveEventEntity(repo, entity);
}
async getBlockEvents (blockHash: string, where: FindConditions<Event>): Promise<Event[]> {
const repo = this._conn.getRepository(Event);
return this._baseDatabase.getBlockEvents(repo, blockHash, where);
}
async saveEvents (queryRunner: QueryRunner, block: DeepPartial<BlockProgress>, events: DeepPartial<Event>[]): Promise<void> {
const blockRepo = queryRunner.manager.getRepository(BlockProgress);
const eventRepo = queryRunner.manager.getRepository(Event);
return this._baseDatabase.saveEvents(blockRepo, eventRepo, block, events);
}
async saveContract (address: string, kind: string, startingBlock: number): Promise<void> {
await this._conn.transaction(async (tx) => {
const repo = tx.getRepository(Contract);
return this._baseDatabase.saveContract(repo, address, startingBlock, kind);
});
}
async updateSyncStatusIndexedBlock (queryRunner: QueryRunner, blockHash: string, blockNumber: number): Promise<SyncStatus> {
const repo = queryRunner.manager.getRepository(SyncStatus);
return this._baseDatabase.updateSyncStatusIndexedBlock(repo, blockHash, blockNumber);
}
async updateSyncStatusCanonicalBlock (queryRunner: QueryRunner, blockHash: string, blockNumber: number): Promise<SyncStatus> {
const repo = queryRunner.manager.getRepository(SyncStatus);
return this._baseDatabase.updateSyncStatusCanonicalBlock(repo, blockHash, blockNumber);
}
async updateSyncStatusChainHead (queryRunner: QueryRunner, blockHash: string, blockNumber: number): Promise<SyncStatus> {
const repo = queryRunner.manager.getRepository(SyncStatus);
return this._baseDatabase.updateSyncStatusChainHead(repo, blockHash, blockNumber);
}
async getSyncStatus (queryRunner: QueryRunner): Promise<SyncStatus | undefined> {
const repo = queryRunner.manager.getRepository(SyncStatus);
return this._baseDatabase.getSyncStatus(repo);
}
async getEvent (id: string): Promise<Event | undefined> {
const repo = this._conn.getRepository(Event);
return this._baseDatabase.getEvent(repo, id);
}
async getBlocksAtHeight (height: number, isPruned: boolean): Promise<BlockProgress[]> {
const repo = this._conn.getRepository(BlockProgress);
return this._baseDatabase.getBlocksAtHeight(repo, height, isPruned);
}
async markBlocksAsPruned (queryRunner: QueryRunner, blocks: BlockProgress[]): Promise<void> {
const repo = queryRunner.manager.getRepository(BlockProgress);
return this._baseDatabase.markBlocksAsPruned(repo, blocks);
}
async getBlockProgress (blockHash: string): Promise<BlockProgress | undefined> {
const repo = this._conn.getRepository(BlockProgress);
return this._baseDatabase.getBlockProgress(repo, blockHash);
}
async updateBlockProgress (queryRunner: QueryRunner, blockHash: string, lastProcessedEventIndex: number): Promise<void> {
const repo = queryRunner.manager.getRepository(BlockProgress);
return this._baseDatabase.updateBlockProgress(repo, blockHash, lastProcessedEventIndex);
}
async removeEntities<Entity> (queryRunner: QueryRunner, entity: new () => Entity, findConditions?: FindConditions<Entity>): Promise<void> {
return this._baseDatabase.removeEntities(queryRunner, entity, findConditions);
}
async getAncestorAtDepth (blockHash: string, depth: number): Promise<string> {
return this._baseDatabase.getAncestorAtDepth(blockHash, depth);
}
_getPropertyColumnMapForEntity (entityName: string): Map<string, string> {
return this._conn.getMetadata(entityName).ownColumns.reduce((acc, curr) => {
return acc.set(curr.propertyName, curr.databaseName);
}, new Map<string, string>());
}
_setPropColMaps () {
{{#each queries as | query |}}
this._propColMaps['{{capitalize query.name tillIndex=1}}'] = this._getPropertyColumnMapForEntity('{{capitalize query.name tillIndex=1}}');
{{/each}}
}
}

View File

@ -2,29 +2,41 @@
// Copyright 2021 Vulcanize, Inc.
//
import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm';
{{#each imports as | import |}}
import { {{~#each import.toImport}} {{this}} {{~#unless @last}}, {{~/unless}} {{~/each}} } from '{{import.from}}';
{{/each}}
import { bigintTransformer } from '@vulcanize/util';
@Entity()
{{#if indexOn.columns}}
@Index(['blockHash', 'contractAddress'
{{~#each indexOn.columns}}, '{{this}}'
{{~/each}}], { unique: {{indexOn.unique}} })
{{#each indexOn as | index |}}
{{#if index.columns}}
@Index([
{{~#each index.columns}}'{{this}}'
{{~#unless @last}}, {{/unless}}
{{~/each}}]
{{~#if index.unique}}, { unique: true }{{/if}})
{{/if}}
export class {{className}} {
{{/each}}
export class {{className}} {{~#if implements}} implements {{implements}} {{~/if}} {
@PrimaryGeneratedColumn()
id!: number;
@Column('varchar', { length: 66 })
blockHash!: string;
@Column('varchar', { length: 42 })
contractAddress!: string;
{{#each columns as | column |}}
@Column('{{column.pgType}}' {{~#if column.length}}, { length: {{column.length}} } {{~/if}})
{{#if (compare column.columnType 'ManyToOne')}}
@{{column.columnType}}({{column.lhs}} => {{column.rhs}}
{{~else}}
@{{column.columnType}}('{{column.pgType}}'
{{~/if}}
{{~#if column.columnOptions}}, {
{{~#each column.columnOptions}} {{this.option}}: {{this.value}}
{{~#unless @last}}, {{/unless}}
{{~/each}} }
{{~/if}})
{{column.name}}!: {{column.tsType}};
{{~#unless @last}}
{{/unless}}
{{/each}}
@Column('text', { nullable: true })
proof!: string;
}

View File

@ -0,0 +1,113 @@
//
// Copyright 2021 Vulcanize, Inc.
//
import assert from 'assert';
import debug from 'debug';
import { PubSub } from 'apollo-server-express';
import { EthClient } from '@vulcanize/ipld-eth-client';
import {
JobQueue,
EventWatcher as BaseEventWatcher,
QUEUE_BLOCK_PROCESSING,
QUEUE_EVENT_PROCESSING,
UNKNOWN_EVENT_NAME
} from '@vulcanize/util';
import { Indexer } from './indexer';
import { Event } from './entity/Event';
const EVENT = 'event';
const log = debug('vulcanize:events');
export class EventWatcher {
_ethClient: EthClient
_indexer: Indexer
_subscription: ZenObservable.Subscription | undefined
_baseEventWatcher: BaseEventWatcher
_pubsub: PubSub
_jobQueue: JobQueue
constructor (ethClient: EthClient, indexer: Indexer, pubsub: PubSub, jobQueue: JobQueue) {
assert(ethClient);
assert(indexer);
this._ethClient = ethClient;
this._indexer = indexer;
this._pubsub = pubsub;
this._jobQueue = jobQueue;
this._baseEventWatcher = new BaseEventWatcher(this._ethClient, this._indexer, this._pubsub, this._jobQueue);
}
getEventIterator (): AsyncIterator<any> {
return this._pubsub.asyncIterator([EVENT]);
}
getBlockProgressEventIterator (): AsyncIterator<any> {
return this._baseEventWatcher.getBlockProgressEventIterator();
}
async start (): Promise<void> {
assert(!this._subscription, 'subscription already started');
await this.watchBlocksAtChainHead();
await this.initBlockProcessingOnCompleteHandler();
await this.initEventProcessingOnCompleteHandler();
}
async stop (): Promise<void> {
this._baseEventWatcher.stop();
}
async watchBlocksAtChainHead (): Promise<void> {
log('Started watching upstream blocks...');
this._subscription = await this._ethClient.watchBlocks(async (value) => {
await this._baseEventWatcher.blocksHandler(value);
});
}
async initBlockProcessingOnCompleteHandler (): Promise<void> {
this._jobQueue.onComplete(QUEUE_BLOCK_PROCESSING, async (job) => {
await this._baseEventWatcher.blockProcessingCompleteHandler(job);
});
}
async initEventProcessingOnCompleteHandler (): Promise<void> {
await this._jobQueue.onComplete(QUEUE_EVENT_PROCESSING, async (job) => {
const dbEvent = await this._baseEventWatcher.eventProcessingCompleteHandler(job);
const { data: { request, failed, state, createdOn } } = job;
const timeElapsedInSeconds = (Date.now() - Date.parse(createdOn)) / 1000;
log(`Job onComplete event ${request.data.id} publish ${!!request.data.publish}`);
if (!failed && state === 'completed' && request.data.publish) {
// Check for max acceptable lag time between request and sending results to live subscribers.
if (timeElapsedInSeconds <= this._jobQueue.maxCompletionLag) {
await this.publishEventToSubscribers(dbEvent, timeElapsedInSeconds);
} else {
log(`event ${request.data.id} is too old (${timeElapsedInSeconds}s), not broadcasting to live subscribers`);
}
}
});
}
async publishEventToSubscribers (dbEvent: Event, timeElapsedInSeconds: number): Promise<void> {
if (dbEvent && dbEvent.eventName !== UNKNOWN_EVENT_NAME) {
const { block: { blockHash }, contract: contractAddress } = dbEvent;
const resultEvent = this._indexer.getResultEvent(dbEvent);
log(`pushing event to GQL subscribers (${timeElapsedInSeconds}s elapsed): ${resultEvent.event.__typename}`);
// Publishing the event here will result in pushing the payload to GQL subscribers for `onEvent`.
await this._pubsub.publish(EVENT, {
onEvent: {
blockHash,
contractAddress,
event: resultEvent
}
});
}
}
}

View File

@ -5,37 +5,60 @@
import assert from 'assert';
import debug from 'debug';
import { JsonFragment } from '@ethersproject/abi';
import { DeepPartial } from 'typeorm';
import JSONbig from 'json-bigint';
import { BigNumber, ethers, Contract } from 'ethers';
import { BigNumber, ethers } from 'ethers';
import { BaseProvider } from '@ethersproject/providers';
import { EthClient } from '@vulcanize/ipld-eth-client';
import { StorageLayout } from '@vulcanize/solidity-mapper';
import { ValueResult } from '@vulcanize/util';
import { getStorageValue, GetStorageAt, StorageLayout } from '@vulcanize/solidity-mapper';
import { EventInterface, Indexer as BaseIndexer, ValueResult, UNKNOWN_EVENT_NAME } from '@vulcanize/util';
import { Database } from './database';
import { Contract } from './entity/Contract';
import { Event } from './entity/Event';
import { SyncStatus } from './entity/SyncStatus';
import { BlockProgress } from './entity/BlockProgress';
import artifacts from './artifacts/{{inputFileName}}.json';
const log = debug('vulcanize:indexer');
{{#each events as | event |}}
const {{capitalize event.name}}_EVENT = '{{event.name}}';
{{/each}}
interface ResultEvent {
block: any;
tx: any;
contract: string;
eventIndex: number;
event: any;
proof: string;
}
export class Indexer {
_db: Database
_ethClient: EthClient
_getStorageAt: GetStorageAt
_ethProvider: BaseProvider
_baseIndexer: BaseIndexer
_abi: JsonFragment[]
_storageLayout: StorageLayout
_contract: ethers.utils.Interface
_serverMode: string
constructor (db: Database, ethClient: EthClient, ethProvider: BaseProvider, serverMode: string) {
constructor (db: Database, ethClient: EthClient, ethProvider: BaseProvider) {
assert(db);
assert(ethClient);
this._db = db;
this._ethClient = ethClient;
this._ethProvider = ethProvider;
this._serverMode = serverMode;
this._getStorageAt = this._ethClient.getStorageAt.bind(this._ethClient);
this._baseIndexer = new BaseIndexer(this._db, this._ethClient);
const { abi, storageLayout } = artifacts;
@ -48,9 +71,43 @@ export class Indexer {
this._contract = new ethers.utils.Interface(this._abi);
}
getResultEvent (event: Event): ResultEvent {
const block = event.block;
const eventFields = JSON.parse(event.eventInfo);
const { tx } = JSON.parse(event.extraInfo);
return {
block: {
hash: block.blockHash,
number: block.blockNumber,
timestamp: block.blockTimestamp,
parentHash: block.parentHash
},
tx: {
hash: event.txHash,
from: tx.src,
to: tx.dst,
index: tx.index
},
contract: event.contract,
eventIndex: event.index,
event: {
__typename: `${event.eventName}Event`,
...eventFields
},
// TODO: Return proof only if requested.
proof: JSON.parse(event.proof)
};
}
{{#each queries as | query |}}
async {{query.name}} (blockHash: string, contractAddress: string
{{~#each query.params}}, {{this.name~}}: {{this.type~}} {{/each}}): Promise<ValueResult> {
const entity = await this._db.get{{capitalize query.name tillIndex=1}}({ blockHash, contractAddress
{{~#each query.params}}, {{this.name~}} {{~/each}} });
if (entity) {
@ -64,19 +121,13 @@ export class Indexer {
log('{{query.name}}: db miss, fetching from upstream server');
const contract = new Contract(contractAddress, this._abi, this._ethProvider);
let value = null;
{{~#if query.params}}
{{#if (compare query.mode @root.constants.MODE_ETH_CALL)}}
const contract = new ethers.Contract(contractAddress, this._abi, this._ethProvider);
const { block: { number } } = await this._ethClient.getBlockByHash(blockHash);
const blockNumber = BigNumber.from(number).toNumber();
value = await contract.{{query.name}}(
let value = await contract.{{query.name}}(
{{~#each query.params}}{{this.name}}, {{/each}}{ blockTag: blockNumber });
{{else}}
value = await contract.{{query.name}}();
{{/if}}
{{~#if (compare query.returnType 'bigint')}}
@ -85,14 +136,203 @@ export class Indexer {
{{/if}}
const result: ValueResult = { value };
const { proof } = result;
{{/if}}
{{~#if (compare query.mode @root.constants.MODE_STORAGE)}}
const result = await this._getStorageValue(
this._storageLayout,
blockHash,
contractAddress,
'{{query.name}}'{{#if query.params.length}},{{/if}}
{{#each query.params}}
{{this.name}}{{#unless @last}},{{/unless}}
{{/each}}
);
{{/if}}
await this._db.save{{capitalize query.name tillIndex=1}}({ blockHash, contractAddress
{{~#each query.params}}, {{this.name~}} {{/each}}, value, proof: JSONbig.stringify(proof) });
{{~#each query.params}}, {{this.name~}} {{/each}}, value: result.value, proof: JSONbig.stringify(result.proof) });
return result;
}
{{#unless @last}}
{{/unless}}
{{/each}}
async _getStorageValue (storageLayout: StorageLayout, blockHash: string, contractAddress: string, variable: string, ...mappingKeys: any[]): Promise<ValueResult> {
return getStorageValue(
storageLayout,
this._getStorageAt,
blockHash,
contractAddress,
variable,
...mappingKeys
);
}
parseEventNameAndArgs (kind: string, logObj: any): any {
let eventName = UNKNOWN_EVENT_NAME;
let eventInfo = {};
const { topics, data } = logObj;
const logDescription = this._contract.parseLog({ data, topics });
switch (logDescription.name) {
{{#each events as | event |}}
case {{capitalize event.name}}_EVENT: {
eventName = logDescription.name;
const { {{#each event.params~}} {{this.name}} {{~#unless @last}}, {{/unless}} {{~/each}} } = logDescription.args;
eventInfo = {
{{#each event.params}}
{{this.name}} {{~#unless @last}},{{/unless}}
{{/each}}
};
break;
}
{{/each}}
}
return { eventName, eventInfo };
}
async getEventsByFilter (blockHash: string, contract: string, name: string | null): Promise<Array<Event>> {
return this._baseIndexer.getEventsByFilter(blockHash, contract, name);
}
async isWatchedContract (address : string): Promise<Contract | undefined> {
return this._baseIndexer.isWatchedContract(address);
}
async getSyncStatus (): Promise<SyncStatus | undefined> {
return this._baseIndexer.getSyncStatus();
}
async updateSyncStatusIndexedBlock (blockHash: string, blockNumber: number): Promise<SyncStatus> {
return this._baseIndexer.updateSyncStatusIndexedBlock(blockHash, blockNumber);
}
async updateSyncStatusChainHead (blockHash: string, blockNumber: number): Promise<SyncStatus> {
return this._baseIndexer.updateSyncStatusChainHead(blockHash, blockNumber);
}
async updateSyncStatusCanonicalBlock (blockHash: string, blockNumber: number): Promise<SyncStatus> {
return this._baseIndexer.updateSyncStatusCanonicalBlock(blockHash, blockNumber);
}
async getBlock (blockHash: string): Promise<any> {
return this._baseIndexer.getBlock(blockHash);
}
async getEvent (id: string): Promise<Event | undefined> {
return this._baseIndexer.getEvent(id);
}
async getBlockProgress (blockHash: string): Promise<BlockProgress | undefined> {
return this._baseIndexer.getBlockProgress(blockHash);
}
async getBlocksAtHeight (height: number, isPruned: boolean): Promise<BlockProgress[]> {
return this._baseIndexer.getBlocksAtHeight(height, isPruned);
}
async getOrFetchBlockEvents (block: DeepPartial<BlockProgress>): Promise<Array<EventInterface>> {
return this._baseIndexer.getOrFetchBlockEvents(block, this._fetchAndSaveEvents.bind(this));
}
async getBlockEvents (blockHash: string): Promise<Array<Event>> {
return this._baseIndexer.getBlockEvents(blockHash);
}
async markBlocksAsPruned (blocks: BlockProgress[]): Promise<void> {
return this._baseIndexer.markBlocksAsPruned(blocks);
}
async updateBlockProgress (blockHash: string, lastProcessedEventIndex: number): Promise<void> {
return this._baseIndexer.updateBlockProgress(blockHash, lastProcessedEventIndex);
}
async getAncestorAtDepth (blockHash: string, depth: number): Promise<string> {
return this._baseIndexer.getAncestorAtDepth(blockHash, depth);
}
async _fetchAndSaveEvents ({ blockHash }: DeepPartial<BlockProgress>): Promise<void> {
assert(blockHash);
let { block, logs } = await this._ethClient.getLogs({ blockHash });
const dbEvents: Array<DeepPartial<Event>> = [];
for (let li = 0; li < logs.length; li++) {
const logObj = logs[li];
const {
topics,
data,
index: logIndex,
cid,
ipldBlock,
account: {
address
},
transaction: {
hash: txHash
},
receiptCID,
status
} = logObj;
if (status) {
let eventName = UNKNOWN_EVENT_NAME;
let eventInfo = {};
const extraInfo = { topics, data };
const contract = ethers.utils.getAddress(address);
const watchedContract = await this.isWatchedContract(contract);
if (watchedContract) {
const eventDetails = this.parseEventNameAndArgs(watchedContract.kind, logObj);
eventName = eventDetails.eventName;
eventInfo = eventDetails.eventInfo;
}
dbEvents.push({
index: logIndex,
txHash,
contract,
eventName,
eventInfo: JSONbig.stringify(eventInfo),
extraInfo: JSONbig.stringify(extraInfo),
proof: JSONbig.stringify({
data: JSONbig.stringify({
blockHash,
receiptCID,
log: {
cid,
ipldBlock
}
})
})
});
} else {
log(`Skipping event for receipt ${receiptCID} due to failed transaction.`);
}
}
const dbTx = await this._db.createTransactionRunner();
try {
block = {
blockHash,
blockNumber: block.number,
blockTimestamp: block.timestamp,
parentHash: block.parent.hash
};
await this._db.saveEvents(dbTx, block, dbEvents);
await dbTx.commitTransaction();
} catch (error) {
await dbTx.rollbackTransaction();
throw error;
} finally {
await dbTx.release();
}
}
}

View File

@ -35,12 +35,20 @@ export const createResolvers = async (indexer: Indexer): Promise<any> => {
{{~#each this.params}}, {{this.name~}} {{/each}});
return indexer.{{this.name}}(blockHash, contractAddress
{{~#each this.params}}, {{this.name~}} {{/each}});
}
{{~#unless @last}},
},
{{/unless}}
{{/each}}
events: async (_: any, { blockHash, contractAddress, name }: { blockHash: string, contractAddress: string, name: string }) => {
log('events', blockHash, contractAddress, name || '');
const block = await indexer.getBlockProgress(blockHash);
if (!block || !block.isComplete) {
throw new Error(`Block hash ${blockHash} number ${block?.blockNumber} not processed yet`);
}
const events = await indexer.getEventsByFilter(blockHash, contractAddress, name);
return events.map(event => indexer.getResultEvent(event));
}
}
};
};

View File

@ -40,7 +40,7 @@ export const main = async (): Promise<any> => {
assert(config.server, 'Missing server config');
const { host, port, mode } = config.server;
const { host, port } = config.server;
const { upstream, database: dbConfig } = config;
@ -66,7 +66,7 @@ export const main = async (): Promise<any> => {
// 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, ethProvider, mode);
const indexer = new Indexer(db, ethClient, ethProvider);
const resolvers = await createResolvers(indexer);

View File

@ -0,0 +1,7 @@
//
// Copyright 2021 Vulcanize, Inc.
//
export const MODE_ETH_CALL = 'eth_call';
export const MODE_STORAGE = 'storage';
export const MODE_ALL = 'all';

View File

@ -3,6 +3,12 @@
//
import assert from 'assert';
import Handlebars from 'handlebars';
export function registerHandlebarHelpers (): void {
Handlebars.registerHelper('compare', compareHelper);
Handlebars.registerHelper('capitalize', capitalizeHelper);
}
/**
* Helper function to compare two values using the given operator.
@ -11,7 +17,7 @@ import assert from 'assert';
* @param options Handlebars options parameter. `options.hash.operator`: operator to be used for comparison.
* @returns Result of the comparison.
*/
export function compareHelper (lvalue: string, rvalue: string, options: any): boolean {
function compareHelper (lvalue: string, rvalue: string, options: any): boolean {
assert(lvalue && rvalue, "Handlerbars Helper 'compare' needs at least 2 parameters");
const operator = options.hash.operator || '===';
@ -38,7 +44,7 @@ export function compareHelper (lvalue: string, rvalue: string, options: any): bo
* @param options Handlebars options parameter. `options.hash.tillIndex`: index till which to capitalize the string.
* @returns The modified string.
*/
export function capitalizeHelper (value: string, options: any): string {
function capitalizeHelper (value: string, options: any): string {
const tillIndex = options.hash.tillIndex || value.length;
const result = `${value.slice(0, tillIndex).toUpperCase()}${value.slice(tillIndex, value.length)}`;

View File

@ -5,11 +5,12 @@
import { Writable } from 'stream';
import { Database } from './database';
import { Param } from './utils/types';
import { MODE_ETH_CALL, MODE_STORAGE } from './utils/constants';
import { Entity } from './entity';
import { Indexer } from './indexer';
import { Resolvers } from './resolvers';
import { Schema } from './schema';
import { Param } from './utils/types';
export class Visitor {
_schema: Schema;
@ -42,7 +43,7 @@ export class Visitor {
this._schema.addQuery(name, params, returnType);
this._resolvers.addQuery(name, params, returnType);
this._indexer.addQuery(name, params, returnType);
this._indexer.addQuery(MODE_ETH_CALL, name, params, returnType);
this._entity.addQuery(name, params, returnType);
this._database.addQuery(name, params, returnType);
}
@ -55,8 +56,7 @@ export class Visitor {
stateVariableDeclarationVisitor (node: any): void {
// TODO Handle multiples variables in a single line.
// TODO Handle array types.
let name: string = node.variables[0].name;
name = name.startsWith('_') ? name.substring(1) : name;
const name: string = node.variables[0].name;
const params: Param[] = [];
@ -75,7 +75,7 @@ export class Visitor {
this._schema.addQuery(name, params, returnType);
this._resolvers.addQuery(name, params, returnType);
this._indexer.addQuery(name, params, returnType);
this._indexer.addQuery(MODE_STORAGE, name, params, returnType);
this._entity.addQuery(name, params, returnType);
this._database.addQuery(name, params, returnType);
}
@ -91,6 +91,7 @@ export class Visitor {
});
this._schema.addEventType(name, params);
this._indexer.addEvent(name, params);
}
/**

View File

@ -5,8 +5,6 @@
import { Entity, PrimaryGeneratedColumn, Column, Index, ManyToOne } from 'typeorm';
import { BlockProgress } from './BlockProgress';
export const UNKNOWN_EVENT_NAME = '__unknown__';
@Entity()
// Index to query all events for a contract efficiently.
@Index(['block', 'contract'])

View File

@ -11,11 +11,12 @@ import {
JobQueue,
EventWatcher as BaseEventWatcher,
QUEUE_BLOCK_PROCESSING,
QUEUE_EVENT_PROCESSING
QUEUE_EVENT_PROCESSING,
UNKNOWN_EVENT_NAME
} from '@vulcanize/util';
import { Indexer } from './indexer';
import { Event, UNKNOWN_EVENT_NAME } from './entity/Event';
import { Event } from './entity/Event';
const EVENT = 'event';

View File

@ -12,10 +12,10 @@ import { BaseProvider } from '@ethersproject/providers';
import { EthClient } from '@vulcanize/ipld-eth-client';
import { StorageLayout } from '@vulcanize/solidity-mapper';
import { EventInterface, Indexer as BaseIndexer, ValueResult } from '@vulcanize/util';
import { EventInterface, Indexer as BaseIndexer, ValueResult, UNKNOWN_EVENT_NAME } from '@vulcanize/util';
import { Database } from './database';
import { Event, UNKNOWN_EVENT_NAME } from './entity/Event';
import { Event } from './entity/Event';
import { fetchTokenDecimals, fetchTokenName, fetchTokenSymbol, fetchTokenTotalSupply } from './utils';
import { SyncStatus } from './entity/SyncStatus';
import artifacts from './artifacts/ERC20.json';

View File

@ -12,7 +12,7 @@ import { ValueTransformer } from 'typeorm';
export const wait = async (time: number): Promise<void> => new Promise(resolve => setTimeout(resolve, time));
/**
* Transformer used by typeorm entity for Decimal type fields
* Transformer used by typeorm entity for Decimal type fields.
*/
export const decimalTransformer: ValueTransformer = {
to: (value?: Decimal) => {
@ -30,3 +30,23 @@ export const decimalTransformer: ValueTransformer = {
return value;
}
};
/**
* Transformer used by typeorm entity for bigint type fields.
*/
export const bigintTransformer: ValueTransformer = {
to: (value?: bigint) => {
if (value) {
return value.toString();
}
return value;
},
from: (value?: string) => {
if (value) {
return BigInt(value);
}
return value;
}
};