[Blockchain Watcher] Separating get and poll actions (#843)

* adding more overrides via env var

* simplifying default cfg files

* shared evm repo

* move get evm logs to new action

* move get solana txs to new action

* minor folder cleanup

* smaller docker image

* add chain to evm block repo logs
This commit is contained in:
Matías Martínez 2023-12-04 09:47:02 -03:00 committed by GitHub
parent b458213b0c
commit 0e4efd6673
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 373 additions and 307 deletions

View File

@ -1,3 +0,0 @@
{
"printWidth": 100
}

View File

@ -1,12 +1,4 @@
# syntax=docker.io/docker/dockerfile:1.3@sha256:42399d4635eddd7a9b8a24be879d2f9a930d0ed040a61324cfdf59ef1357b3b2
FROM node:19.6.1-slim@sha256:a1ba21bf0c92931d02a8416f0a54daad66cb36a85d2b73af9d73b044f5f57cfc as builder
# npm wants to clone random Git repositories - lovely.
# RUN apk add git python make build-base
# RUN apk update && apk add bash
RUN apt-get update && apt-get -y install \
git python make curl netcat
FROM node:18.19.0-alpine3.18@sha256:c0a5f02df6e631b75ee3037bd4389ac1f91e591c5c1e30a0007a7d0babcd4cd3 as builder
USER 1000
RUN mkdir -p /home/node/app
@ -14,35 +6,20 @@ RUN mkdir -p /home/node/.npm
WORKDIR /home/node/app
# Fix git ssh error
RUN git config --global url."https://".insteadOf ssh://
# Node
ENV NODE_EXTRA_CA_CERTS=/certs/cert.pem
ENV NODE_OPTIONS=--use-openssl-ca
# npm
RUN if [ -e /certs/cert.pem ]; then npm config set cafile /certs/cert.pem; fi
# git
RUN if [ -e /certs/cert.pem ]; then git config --global http.sslCAInfo /certs/cert.pem; fi
COPY --chown=node:node . .
RUN npm ci
RUN npm run build
RUN npm run build:ncc
FROM node:19.6.1-slim@sha256:a1ba21bf0c92931d02a8416f0a54daad66cb36a85d2b73af9d73b044f5f57cfc as runner
FROM node:18.19.0-alpine3.18@sha256:c0a5f02df6e631b75ee3037bd4389ac1f91e591c5c1e30a0007a7d0babcd4cd3 as runner
COPY --from=builder /home/node/app/config /home/node/app/config
COPY --from=builder /home/node/app/lib /home/node/app/lib
WORKDIR /home/node/app
COPY package.json .
COPY package-lock.json .
RUN npm install --omit=dev
CMD [ "npm", "start" ]
CMD [ "npm", "run", "start:ncc" ]

View File

@ -1,18 +0,0 @@
Each blockchain has three watchers,
- A websocket watcher for low latency
- A querying watcher
- And a sequence gap watcher
These three watchers all invoke the same handler callback.
The handler callback is responsible for:
- Providing the watchers with the necessary query filter information
- parsing the event into a persistence object
- invoking the persistence manager
The persistence manager is responsible for:
- Inserting records into the database in a safe manner, which takes into account that items will be seen multiple times.
- Last write wins should be the approach taken here.

View File

@ -3,6 +3,10 @@
"port": "PORT",
"logLevel": "LOG_LEVEL",
"dryRun": "DRY_RUN_ENABLED",
"supportedChains": {
"__name": "SUPPORTED_CHAINS",
"__format": "json"
},
"jobs": {
"dir": "JOBS_DIR"
},
@ -10,17 +14,44 @@
"topicArn": "SNS_TOPIC_ARN",
"region": "SNS_REGION"
},
"platforms": {
"chains": {
"ethereum": {
"network": "ETHEREUM_NETWORK",
"rpcs": {
"__name": "ETHEREUM_RPCS",
"__format": "json"
}
},
"solana": {
"network": "SOLANA_NETWORK",
"rpcs": {
"__name": "SOLANA_RPCS",
"__format": "json"
},
"rateLimit": {
"period": "SOLANA_RATE_LIMIT_PERIOD",
"limit": "SOLANA_RATE_LIMIT_LIMIT"
}
},
"karura": {
"network": "KARURA_NETWORK",
"rpcs": {
"__name": "KARURA_RPCS",
"__format": "json"
}
},
"fantom": {
"network": "FANTOM_NETWORK",
"rpcs": {
"__name": "FANTOM_RPCS",
"__format": "json"
}
},
"acala": {
"network": "FANTOM_NETWORK",
"rpcs": {
"__name": "FANTOM_RPCS",
"__format": "json"
}
}
}

View File

@ -16,7 +16,7 @@
"jobs": {
"dir": "metadata-repo/jobs"
},
"platforms": {
"chains": {
"solana": {
"name": "solana",
"network": "devnet",

View File

@ -1,7 +1,7 @@
{
"platforms": {
"chains": {
"solana": {
"network": "mainnet",
"network": "mainnet-beta",
"rpcs": ["https://api.mainnet-beta.solana.com"]
},
"ethereum": {

View File

@ -1,46 +0,0 @@
{
"platforms": {
"solana": {
"name": "solana",
"network": "mainnet-beta",
"chainId": 1,
"rpcs": ["https://api.mainnet-beta.solana.com"],
"timeout": 10000
},
"ethereum": {
"name": "ethereum",
"network": "mainnet",
"chainId": 2,
"rpcs": ["https://rpc.ankr.com/eth"],
"timeout": 10000
},
"avalanche": {
"name": "avalanche",
"network": "mainnet",
"chainId": 6,
"rpcs": ["https://api.avax.network/ext/bc/C/rpc"],
"timeout": 10000
},
"fantom": {
"name": "fantom",
"network": "mainnet",
"chainId": 10,
"rpcs": ["https://rpcapi.fantom.network"],
"timeout": 10000
},
"karura": {
"name": "karura",
"network": "mainnet",
"chainId": 11,
"rpcs": ["https://eth-rpc-karura.aca-api.network"],
"timeout": 10000
},
"acala": {
"name": "acala",
"network": "mainnet",
"chainId": 12,
"rpcs": ["https://eth-rpc-acala.aca-api.network"],
"timeout": 10000
}
}
}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1,19 +0,0 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
moduleFileExtensions: ["js", "json", "ts"],
setupFiles: ["<rootDir>/src/infrastructure/log.ts"],
roots: ["test", "src"],
testRegex: ".*\\.test\\.ts$",
transform: {
"^.+\\.(t|j)s$": "ts-jest",
},
collectCoverage: true,
collectCoverageFrom: ["**/*.(t|j)s"],
coveragePathIgnorePatterns: ["node_modules", "test"],
coverageDirectory: "./coverage",
coverageThreshold: {
global: {
lines: 63.9,
},
},
};

View File

@ -10,12 +10,11 @@
"license": "ISC",
"dependencies": {
"@aws-sdk/client-sns": "^3.445.0",
"@certusone/wormhole-sdk": "^0.9.21-beta.0",
"@certusone/wormhole-sdk": "0.10.5",
"@types/config": "^3.3.3",
"axios": "^1.6.0",
"bs58": "^5.0.0",
"config": "^3.3.9",
"dotenv": "^16.3.1",
"ethers": "^5",
"mollitia": "^0.1.0",
"prom-client": "^15.0.0",
@ -27,6 +26,7 @@
"@types/koa-router": "^7.4.4",
"@types/uuid": "^9.0.6",
"@types/yargs": "^17.0.23",
"@vercel/ncc": "^0.38.1",
"jest": "^29.7.0",
"nock": "^13.3.8",
"prettier": "^2.8.7",
@ -1190,9 +1190,9 @@
"dev": true
},
"node_modules/@certusone/wormhole-sdk": {
"version": "0.9.21-beta.0",
"resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk/-/wormhole-sdk-0.9.21-beta.0.tgz",
"integrity": "sha512-44oa/bvVKEtFnIwkg+NdGfVjfyiLdguRBVe4g1EK3Y/Yd6VcPtnETwJU5z5SXJvoV2KnfpnSmK8B+tMKfQ6kbg==",
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk/-/wormhole-sdk-0.10.5.tgz",
"integrity": "sha512-wKONuigkakoFx9HplBt2Jh5KPxc7xgtDJVrIb2/SqYWbFrdpiZrMC4H6kTZq2U4+lWtqaCa1aJ1q+3GOTNx2CQ==",
"dependencies": {
"@certusone/wormhole-sdk-proto-web": "0.0.6",
"@certusone/wormhole-sdk-wasm": "^0.0.1",
@ -1203,7 +1203,7 @@
"@solana/web3.js": "^1.66.2",
"@terra-money/terra.js": "3.1.9",
"@xpla/xpla.js": "^0.2.1",
"algosdk": "^1.15.0",
"algosdk": "^2.4.0",
"aptos": "1.5.0",
"axios": "^0.24.0",
"bech32": "^2.0.0",
@ -5054,6 +5054,15 @@
"integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==",
"dev": true
},
"node_modules/@vercel/ncc": {
"version": "0.38.1",
"resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.1.tgz",
"integrity": "sha512-IBBb+iI2NLu4VQn3Vwldyi2QwaXt5+hTyh58ggAMoCGE6DJmPvwL3KPBWcJl1m9LYPChBLE980Jw+CS4Wokqxw==",
"dev": true,
"bin": {
"ncc": "dist/ncc/cli.js"
}
},
"node_modules/@wry/context": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.3.tgz",
@ -5176,13 +5185,12 @@
}
},
"node_modules/algosdk": {
"version": "1.24.1",
"resolved": "https://registry.npmjs.org/algosdk/-/algosdk-1.24.1.tgz",
"integrity": "sha512-9moZxdqeJ6GdE4N6fA/GlUP4LrbLZMYcYkt141J4Ss68OfEgH9qW0wBuZ3ZOKEx/xjc5bg7mLP2Gjg7nwrkmww==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/algosdk/-/algosdk-2.7.0.tgz",
"integrity": "sha512-sBE9lpV7bup3rZ+q2j3JQaFAE9JwZvjWKX00vPlG8e9txctXbgLL56jZhSWZndqhDI9oI+0P4NldkuQIWdrUyg==",
"dependencies": {
"algo-msgpack-with-bigint": "^2.1.1",
"buffer": "^6.0.2",
"cross-fetch": "^3.1.5",
"buffer": "^6.0.3",
"hi-base32": "^0.5.1",
"js-sha256": "^0.9.0",
"js-sha3": "^0.8.0",
@ -5192,7 +5200,7 @@
"vlq": "^2.0.4"
},
"engines": {
"node": ">=14.0.0"
"node": ">=18.0.0"
}
},
"node_modules/ansi-escapes": {
@ -6415,17 +6423,6 @@
"tslib": "^2.0.3"
}
},
"node_modules/dotenv": {
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz",
"integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/motdotla/dotenv?sponsor=1"
}
},
"node_modules/drbg.js": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/drbg.js/-/drbg.js-1.0.1.tgz",
@ -12543,9 +12540,9 @@
"dev": true
},
"@certusone/wormhole-sdk": {
"version": "0.9.21-beta.0",
"resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk/-/wormhole-sdk-0.9.21-beta.0.tgz",
"integrity": "sha512-44oa/bvVKEtFnIwkg+NdGfVjfyiLdguRBVe4g1EK3Y/Yd6VcPtnETwJU5z5SXJvoV2KnfpnSmK8B+tMKfQ6kbg==",
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk/-/wormhole-sdk-0.10.5.tgz",
"integrity": "sha512-wKONuigkakoFx9HplBt2Jh5KPxc7xgtDJVrIb2/SqYWbFrdpiZrMC4H6kTZq2U4+lWtqaCa1aJ1q+3GOTNx2CQ==",
"requires": {
"@certusone/wormhole-sdk-proto-web": "0.0.6",
"@certusone/wormhole-sdk-wasm": "^0.0.1",
@ -12559,7 +12556,7 @@
"@solana/web3.js": "^1.66.2",
"@terra-money/terra.js": "3.1.9",
"@xpla/xpla.js": "^0.2.1",
"algosdk": "^1.15.0",
"algosdk": "^2.4.0",
"aptos": "1.5.0",
"axios": "^0.24.0",
"bech32": "^2.0.0",
@ -15523,6 +15520,12 @@
"integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==",
"dev": true
},
"@vercel/ncc": {
"version": "0.38.1",
"resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.1.tgz",
"integrity": "sha512-IBBb+iI2NLu4VQn3Vwldyi2QwaXt5+hTyh58ggAMoCGE6DJmPvwL3KPBWcJl1m9LYPChBLE980Jw+CS4Wokqxw==",
"dev": true
},
"@wry/context": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.3.tgz",
@ -15620,13 +15623,12 @@
"integrity": "sha512-F1tGh056XczEaEAqu7s+hlZUDWwOBT70Eq0lfMpBP2YguSQVyxRbprLq5rELXKQOyOaixTWYhMeMQMzP0U5FoQ=="
},
"algosdk": {
"version": "1.24.1",
"resolved": "https://registry.npmjs.org/algosdk/-/algosdk-1.24.1.tgz",
"integrity": "sha512-9moZxdqeJ6GdE4N6fA/GlUP4LrbLZMYcYkt141J4Ss68OfEgH9qW0wBuZ3ZOKEx/xjc5bg7mLP2Gjg7nwrkmww==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/algosdk/-/algosdk-2.7.0.tgz",
"integrity": "sha512-sBE9lpV7bup3rZ+q2j3JQaFAE9JwZvjWKX00vPlG8e9txctXbgLL56jZhSWZndqhDI9oI+0P4NldkuQIWdrUyg==",
"requires": {
"algo-msgpack-with-bigint": "^2.1.1",
"buffer": "^6.0.2",
"cross-fetch": "^3.1.5",
"buffer": "^6.0.3",
"hi-base32": "^0.5.1",
"js-sha256": "^0.9.0",
"js-sha3": "^0.8.0",
@ -16580,11 +16582,6 @@
"tslib": "^2.0.3"
}
},
"dotenv": {
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz",
"integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ=="
},
"drbg.js": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/drbg.js/-/drbg.js-1.0.1.tgz",

View File

@ -5,9 +5,11 @@
"main": "index.js",
"scripts": {
"start": "node lib/start.js",
"start:ncc": "node lib/index.js",
"test": "jest",
"test:coverage": "jest --collectCoverage=true",
"build": "tsc",
"build:ncc": "ncc build src/start.ts -o lib",
"dev": "ts-node src/start.ts",
"dev:debug": "ts-node src/start.ts start --debug --watch",
"prettier": "prettier --write ."
@ -16,12 +18,11 @@
"license": "ISC",
"dependencies": {
"@aws-sdk/client-sns": "^3.445.0",
"@certusone/wormhole-sdk": "^0.9.21-beta.0",
"@certusone/wormhole-sdk": "0.10.5",
"@types/config": "^3.3.3",
"axios": "^1.6.0",
"bs58": "^5.0.0",
"config": "^3.3.9",
"dotenv": "^16.3.1",
"ethers": "^5",
"mollitia": "^0.1.0",
"prom-client": "^15.0.0",
@ -33,6 +34,7 @@
"@types/koa-router": "^7.4.4",
"@types/uuid": "^9.0.6",
"@types/yargs": "^17.0.23",
"@vercel/ncc": "^0.38.1",
"jest": "^29.7.0",
"nock": "^13.3.8",
"prettier": "^2.8.7",
@ -43,5 +45,40 @@
},
"engines": {
"node": ">=18.0.0"
},
"prettier": {
"printWidth": 100
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"setupFiles": [
"<rootDir>/src/infrastructure/log.ts"
],
"roots": [
"test",
"src"
],
"testRegex": ".*\\.test\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverage": true,
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coveragePathIgnorePatterns": [
"node_modules",
"test"
],
"coverageDirectory": "./coverage",
"coverageThreshold": {
"global": {
"lines": 70.85
}
}
}
}

View File

@ -0,0 +1,49 @@
import { EvmLog } from "../../entities";
import { EvmBlockRepository } from "../../repositories";
import winston from "winston";
export class GetEvmLogs {
private readonly blockRepo: EvmBlockRepository;
protected readonly logger: winston.Logger;
constructor(blockRepo: EvmBlockRepository) {
this.blockRepo = blockRepo;
this.logger = winston.child({ module: "GetEvmLogs" });
}
async execute(range: Range, opts: GetEvmLogsOpts): Promise<EvmLog[]> {
if (range.fromBlock > range.toBlock) {
this.logger.info(
`[exec] Invalid range [fromBlock: ${range.fromBlock} - toBlock: ${range.toBlock}]`
);
return [];
}
const logs = await this.blockRepo.getFilteredLogs(opts.chain, {
fromBlock: range.fromBlock,
toBlock: range.toBlock,
addresses: opts.addresses ?? [], // Works when sending multiple addresses, but not multiple topics.
topics: opts.topics ?? [],
});
const blockNumbers = new Set(logs.map((log) => log.blockNumber));
const blocks = await this.blockRepo.getBlocks(opts.chain, blockNumbers);
logs.forEach((log) => {
const block = blocks[log.blockHash];
log.blockTime = block.timestamp;
});
return logs;
}
}
type Range = {
fromBlock: bigint;
toBlock: bigint;
};
export interface GetEvmLogsOpts {
addresses?: string[];
topics?: string[];
chain: string;
}

View File

@ -1,5 +1,6 @@
import { EvmLog } from "../../entities";
import { RunPollingJob } from "../RunPollingJob";
import { GetEvmLogs } from "./GetEvmLogs";
import { EvmBlockRepository, MetadataRepository, StatRepository } from "../../repositories";
import winston from "winston";
@ -14,6 +15,7 @@ export class PollEvmLogs extends RunPollingJob {
private readonly blockRepo: EvmBlockRepository;
private readonly metadataRepo: MetadataRepository<PollEvmLogsMetadata>;
private readonly statsRepository: StatRepository;
private readonly getEvmLogs: GetEvmLogs;
private cfg: PollEvmLogsConfig;
private latestBlockHeight?: bigint;
@ -31,6 +33,7 @@ export class PollEvmLogs extends RunPollingJob {
this.metadataRepo = metadataRepo;
this.statsRepository = statsRepository;
this.cfg = cfg;
this.getEvmLogs = new GetEvmLogs(blockRepo);
this.logger = winston.child({ module: "PollEvmLogs", label: this.cfg.id });
}
@ -55,29 +58,17 @@ export class PollEvmLogs extends RunPollingJob {
protected async get(): Promise<EvmLog[]> {
this.report();
this.latestBlockHeight = await this.blockRepo.getBlockHeight(this.cfg.getCommitment());
this.latestBlockHeight = await this.blockRepo.getBlockHeight(
this.cfg.chain,
this.cfg.getCommitment()
);
const range = this.getBlockRange(this.latestBlockHeight);
if (range.fromBlock > this.latestBlockHeight) {
this.logger.info(
`[get] Next range is after latest block height [fromBlock: ${range.fromBlock} - latestBlock: ${this.latestBlockHeight}], waiting...`
);
return [];
}
const logs = await this.blockRepo.getFilteredLogs({
fromBlock: range.fromBlock,
toBlock: range.toBlock,
addresses: this.cfg.addresses, // Works when sending multiple addresses, but not multiple topics.
topics: [], // this.cfg.topics => will be applied by handlers
});
const blockNumbers = new Set(logs.map((log) => log.blockNumber));
const blocks = await this.blockRepo.getBlocks(blockNumbers);
logs.forEach((log) => {
const block = blocks[log.blockHash];
log.blockTime = block.timestamp;
const logs = await this.getEvmLogs.execute(range, {
chain: this.cfg.chain,
addresses: this.cfg.addresses,
topics: this.cfg.topics,
});
this.lastRange = range;
@ -151,13 +142,13 @@ export interface PollEvmLogsConfigProps {
addresses: string[];
topics: string[];
id?: string;
chain?: string;
chain: string;
}
export class PollEvmLogsConfig {
private props: PollEvmLogsConfigProps;
constructor(props: PollEvmLogsConfigProps = { addresses: [], topics: [] }) {
constructor(props: PollEvmLogsConfigProps) {
if (props.fromBlock && props.toBlock && props.fromBlock > props.toBlock) {
throw new Error("fromBlock must be less than or equal to toBlock");
}
@ -213,9 +204,7 @@ export class PollEvmLogsConfig {
return this.props.chain;
}
static fromBlock(fromBlock: bigint) {
const cfg = new PollEvmLogsConfig();
cfg.props.fromBlock = fromBlock;
return cfg;
static fromBlock(chain: string, fromBlock: bigint) {
return new PollEvmLogsConfig({ chain, fromBlock, addresses: [], topics: [] });
}
}

View File

@ -1,5 +1,7 @@
export * from "./evm/HandleEvmLogs";
export * from "./evm/GetEvmLogs";
export * from "./evm/PollEvmLogs";
export * from "./solana/GetSolanaTransactions";
export * from "./solana/PollSolanaTransactions";
export * from "./RunPollingJob";
export * from "./StartJobs";

View File

@ -0,0 +1,79 @@
import winston from "winston";
import { RunPollingJob } from "../RunPollingJob";
import { MetadataRepository, SolanaSlotRepository, StatRepository } from "../../repositories";
import { solana } from "../../entities";
export class GetSolanaTransactions {
private slotRepository: SolanaSlotRepository;
protected logger: winston.Logger;
constructor(slotRepo: SolanaSlotRepository) {
this.slotRepository = slotRepo;
this.logger = winston.child({ module: "GetSolanaTransactions" });
}
async execute(
programId: string,
range: Range,
opts: GetSolanaTxsOpts
): Promise<solana.Transaction[]> {
if (
!range.fromBlock.blockTime ||
!range.toBlock.blockTime ||
range.fromBlock.blockTime > range.toBlock.blockTime
) {
throw new Error(
`Invalid slot range: fromSlotBlockTime=${range.fromBlock.blockTime} toSlotBlockTime=${range.toBlock.blockTime}`
);
}
// signatures for address goes back from current sig
const afterSignature = range.fromBlock.transactions[0]?.transaction.signatures[0];
let beforeSignature: string | undefined =
range.toBlock.transactions[range.toBlock.transactions.length - 1]?.transaction.signatures[0];
if (!afterSignature || !beforeSignature) {
throw new Error(
`No signature presents in transactions. After: ${afterSignature}. Before: ${beforeSignature} [slots: ${range.fromBlock.blockTime} - ${range.toBlock.blockTime}]`
);
}
let currentSignaturesCount = opts.signaturesLimit;
let results: solana.Transaction[] = [];
while (currentSignaturesCount === opts.signaturesLimit && beforeSignature != undefined) {
const sigs: solana.ConfirmedSignatureInfo[] =
await this.slotRepository.getSignaturesForAddress(
programId,
beforeSignature,
afterSignature,
opts.signaturesLimit,
opts.commitment
);
this.logger.debug(
`Got ${sigs.length} signatures for address ${programId} [blocks: ${
range.fromBlock.blockTime
} - ${range.toBlock.blockTime}][sigs: ${afterSignature.substring(
0,
5
)} - ${beforeSignature.substring(0, 5)}]]`
);
const txs = await this.slotRepository.getTransactions(sigs, opts.commitment);
results.push(...txs);
currentSignaturesCount = sigs.length;
beforeSignature = sigs.at(-1)?.signature;
}
return results;
}
}
export type GetSolanaTxsOpts = {
commitment: string;
signaturesLimit: number;
};
type Range = {
fromBlock: solana.Block;
toBlock: solana.Block;
};

View File

@ -1,5 +1,6 @@
import winston from "winston";
import { RunPollingJob } from "../RunPollingJob";
import { GetSolanaTransactions } from "./GetSolanaTransactions";
import { MetadataRepository, SolanaSlotRepository, StatRepository } from "../../repositories";
import { solana } from "../../entities";
@ -8,6 +9,7 @@ export class PollSolanaTransactions extends RunPollingJob {
private metadataRepo: MetadataRepository<PollSolanaTransactionsMetadata>;
private slotRepository: SolanaSlotRepository;
private statsRepo: StatRepository;
private getSolanaTransactions: GetSolanaTransactions;
private latestSlot?: number;
private slotCursor?: number;
private lastRange?: Range;
@ -23,6 +25,7 @@ export class PollSolanaTransactions extends RunPollingJob {
this.metadataRepo = metadataRepo;
this.slotRepository = slotRepo;
this.getSolanaTransactions = new GetSolanaTransactions(slotRepo);
this.statsRepo = statsRepo;
this.cfg = cfg;
this.logger = winston.child({ module: "PollSolanaTransactions", label: this.cfg.id });
@ -78,32 +81,14 @@ export class PollSolanaTransactions extends RunPollingJob {
);
}
let currentSignaturesCount = this.cfg.signaturesLimit;
let results: solana.Transaction[] = [];
while (currentSignaturesCount === this.cfg.signaturesLimit && beforeSignature != undefined) {
const sigs: solana.ConfirmedSignatureInfo[] =
await this.slotRepository.getSignaturesForAddress(
this.cfg.programId,
beforeSignature,
afterSignature,
this.cfg.signaturesLimit,
this.cfg.commitment
);
this.logger.debug(
`Got ${sigs.length} signatures for address ${this.cfg.programId} [slots: ${
range.fromSlot
} - ${range.toSlot}][sigs: ${afterSignature.substring(0, 5)} - ${beforeSignature.substring(
0,
5
)}]]`
);
const txs = await this.slotRepository.getTransactions(sigs, this.cfg.commitment);
results.push(...txs);
currentSignaturesCount = sigs.length;
beforeSignature = sigs.at(-1)?.signature;
}
const results: solana.Transaction[] = await this.getSolanaTransactions.execute(
this.cfg.programId,
{ fromBlock, toBlock },
{
commitment: this.cfg.commitment,
signaturesLimit: this.cfg.signaturesLimit,
}
);
this.lastRange = range;
return results;

View File

@ -4,9 +4,9 @@ import { ConfirmedSignatureInfo } from "./entities/solana";
import { Fallible, SolanaFailure } from "./errors";
export interface EvmBlockRepository {
getBlockHeight(finality: string): Promise<bigint>;
getBlocks(blockNumbers: Set<bigint>): Promise<Record<string, EvmBlock>>;
getFilteredLogs(filter: EvmLogFilter): Promise<EvmLog[]>;
getBlockHeight(chain: string, finality: string): Promise<bigint>;
getBlocks(chain: string, blockNumbers: Set<bigint>): Promise<Record<string, EvmBlock>>;
getFilteredLogs(chain: string, filter: EvmLogFilter): Promise<EvmLog[]>;
}
export interface SolanaSlotRepository {

View File

@ -13,11 +13,11 @@ export type Config = {
jobs: {
dir: string;
};
platforms: Record<string, PlatformConfig>;
chains: Record<string, ChainRPCConfig>;
supportedChains: string[];
};
export type PlatformConfig = {
export type ChainRPCConfig = {
name: string;
network: string;
chainId: number;
@ -49,6 +49,6 @@ export const configuration = {
jobs: {
dir: config.get<string>("jobs.dir"),
},
platforms: config.get<Record<string, PlatformConfig>>("platforms"),
chains: config.get<Record<string, ChainRPCConfig>>("chains"),
supportedChains: config.get<string[]>("supportedChains"),
} as Config;

View File

@ -5,7 +5,7 @@ import { solana, LogFoundEvent, LogMessagePublished } from "../../domain/entitie
import { CompiledInstruction, MessageCompiledInstruction } from "../../domain/entities/solana";
import { configuration } from "../config";
const connection = new Connection(configuration.platforms.solana.rpcs[0]); // TODO: should be better to inject this to improve testability
const connection = new Connection(configuration.chains.solana.rpcs[0]); // TODO: should be better to inject this to improve testability
export const solanaLogMessagePublishedMapper = async (
tx: solana.Transaction,

View File

@ -1,8 +1,9 @@
import { EvmBlock, EvmLogFilter, EvmLog, EvmTag } from "../../domain/entities";
import { EvmBlockRepository } from "../../domain/repositories";
import winston from "../log";
import { HttpClient } from "../http/HttpClient";
import { HttpClient } from "../rpc/http/HttpClient";
import { HttpClientError } from "../errors/HttpClientError";
import { ChainRPCConfig } from "../config";
/**
* EvmJsonRPCBlockRepository is a repository that uses a JSON RPC endpoint to fetch blocks.
@ -13,20 +14,19 @@ const HEXADECIMAL_PREFIX = "0x";
export class EvmJsonRPCBlockRepository implements EvmBlockRepository {
private httpClient: HttpClient;
private chainId: number;
private rpc: URL;
private cfg: EvmJsonRPCBlockRepositoryCfg;
private readonly logger;
constructor(cfg: EvmJsonRPCBlockRepositoryCfg, httpClient: HttpClient) {
this.httpClient = httpClient;
this.chainId = cfg.chainId;
this.rpc = new URL(cfg.rpc);
this.logger = winston.child({ module: "EvmJsonRPCBlockRepository", chain: cfg.chain });
this.logger.info(`Using RPC node ${this.rpc.hostname}`);
this.cfg = cfg;
this.logger = winston.child({ module: "EvmJsonRPCBlockRepository" });
this.logger.info(`Created for ${Object.keys(this.cfg.chains)}`);
}
async getBlockHeight(finality: EvmTag): Promise<bigint> {
const block: EvmBlock = await this.getBlock(finality);
async getBlockHeight(chain: string, finality: EvmTag): Promise<bigint> {
const block: EvmBlock = await this.getBlock(chain, finality);
return block.number;
}
@ -35,7 +35,7 @@ export class EvmJsonRPCBlockRepository implements EvmBlockRepository {
* @param blockNumbers
* @returns a record of block hash -> EvmBlock
*/
async getBlocks(blockNumbers: Set<bigint>): Promise<Record<string, EvmBlock>> {
async getBlocks(chain: string, blockNumbers: Set<bigint>): Promise<Record<string, EvmBlock>> {
if (!blockNumbers.size) return {};
const reqs: any[] = [];
@ -51,11 +51,12 @@ export class EvmJsonRPCBlockRepository implements EvmBlockRepository {
});
}
const chainCfg = this.getCurrentChain(chain);
let results: (undefined | { id: string; result?: EvmBlock; error?: ErrorBlock })[];
try {
results = await this.httpClient.post<typeof results>(this.rpc.href, reqs);
results = await this.httpClient.post<typeof results>(chainCfg.rpc.href, reqs);
} catch (e: HttpClientError | any) {
this.handleError(e, "eth_getBlockByNumber");
this.handleError(chain, e, "eth_getBlockByNumber");
throw e;
}
@ -72,7 +73,6 @@ export class EvmJsonRPCBlockRepository implements EvmBlockRepository {
(response && response.result === null) ||
(response?.error && response.error?.code && response.error.code === 6969)
) {
this.logger.warn;
return {
hash: "",
number: BigInt(response.id),
@ -92,14 +92,16 @@ export class EvmJsonRPCBlockRepository implements EvmBlockRepository {
};
}
const msg = `[getBlocks] Got error ${
const msg = `[${chain}][getBlocks] Got error ${
response?.error?.message
} for eth_getBlockByNumber for ${response?.id ?? reqs[idx].id} on ${this.rpc.hostname}`;
} for eth_getBlockByNumber for ${response?.id ?? reqs[idx].id} on ${
chainCfg.rpc.hostname
}`;
this.logger.error(msg);
throw new Error(
`Unable to parse result of eth_getBlockByNumber for ${
`Unable to parse result of eth_getBlockByNumber[${chain}] for ${
response?.id ?? reqs[idx].id
}: ${msg}`
);
@ -114,11 +116,11 @@ export class EvmJsonRPCBlockRepository implements EvmBlockRepository {
throw new Error(
`Unable to parse ${
results?.length ?? 0
} blocks for eth_getBlockByNumber for numbers ${blockNumbers} on ${this.rpc.hostname}`
} blocks for eth_getBlockByNumber for numbers ${blockNumbers} on ${chainCfg.rpc.hostname}`
);
}
async getFilteredLogs(filter: EvmLogFilter): Promise<EvmLog[]> {
async getFilteredLogs(chain: string, filter: EvmLogFilter): Promise<EvmLog[]> {
const parsedFilters = {
topics: filter.topics,
address: filter.addresses,
@ -126,31 +128,32 @@ export class EvmJsonRPCBlockRepository implements EvmBlockRepository {
toBlock: `${HEXADECIMAL_PREFIX}${filter.toBlock.toString(16)}`,
};
const chainCfg = this.getCurrentChain(chain);
let response: { result: Log[]; error?: ErrorBlock };
try {
response = await this.httpClient.post<typeof response>(this.rpc.href, {
response = await this.httpClient.post<typeof response>(chainCfg.rpc.href, {
jsonrpc: "2.0",
method: "eth_getLogs",
params: [parsedFilters],
id: 1,
});
} catch (e: HttpClientError | any) {
this.handleError(e, "eth_getLogs");
this.handleError(chain, e, "eth_getLogs");
throw e;
}
const logs = response?.result;
this.logger.info(
`[getFilteredLogs] Got ${logs?.length} logs for ${this.describeFilter(filter)} from ${
this.rpc.hostname
}`
`[${chain}][getFilteredLogs] Got ${logs?.length} logs for ${this.describeFilter(
filter
)} from ${chainCfg.rpc.hostname}`
);
return logs.map((log) => ({
...log,
blockNumber: BigInt(log.blockNumber),
transactionIndex: log.transactionIndex.toString(),
chainId: this.chainId,
chainId: chainCfg.chainId,
}));
}
@ -161,17 +164,18 @@ export class EvmJsonRPCBlockRepository implements EvmBlockRepository {
/**
* Loosely based on the wormhole-dashboard implementation (minus some specially crafted blocks when null result is obtained)
*/
private async getBlock(blockNumberOrTag: EvmTag): Promise<EvmBlock> {
private async getBlock(chain: string, blockNumberOrTag: EvmTag): Promise<EvmBlock> {
const chainCfg = this.getCurrentChain(chain);
let response: { result?: EvmBlock; error?: ErrorBlock };
try {
response = await this.httpClient.post<typeof response>(this.rpc.href, {
response = await this.httpClient.post<typeof response>(chainCfg.rpc.href, {
jsonrpc: "2.0",
method: "eth_getBlockByNumber",
params: [blockNumberOrTag, false], // this means we'll get a light block (no txs)
id: 1,
});
} catch (e: HttpClientError | any) {
this.handleError(e, "eth_getBlockByNumber");
this.handleError(chain, e, "eth_getBlockByNumber");
throw e;
}
@ -186,28 +190,36 @@ export class EvmJsonRPCBlockRepository implements EvmBlockRepository {
};
}
throw new Error(
`Unable to parse result of eth_getBlockByNumber for ${blockNumberOrTag} on ${this.rpc}`
`Unable to parse result of eth_getBlockByNumber for ${blockNumberOrTag} on ${chainCfg.rpc}`
);
}
private handleError(e: any, method: string) {
private handleError(chain: string, e: any, method: string) {
const chainCfg = this.getCurrentChain(chain);
if (e instanceof HttpClientError) {
this.logger.error(
`[getBlock] Got ${e.status} from ${this.rpc.hostname}/${method}. ${
`[${chain}][getBlock] Got ${e.status} from ${chainCfg.rpc.hostname}/${method}. ${
e?.message ?? `${e?.message}`
}`
);
} else {
this.logger.error(`[getBlock] Got error ${e} from ${this.rpc.hostname}/${method}`);
this.logger.error(
`[${chain}][getBlock] Got error ${e} from ${chainCfg.rpc.hostname}/${method}`
);
}
}
private getCurrentChain(chain: string) {
const cfg = this.cfg.chains[chain];
return {
chainId: cfg.chainId,
rpc: new URL(cfg.rpcs[0]),
};
}
}
export type EvmJsonRPCBlockRepositoryCfg = {
rpc: string;
timeout?: number;
chain: string;
chainId: number;
chains: Record<string, ChainRPCConfig>;
};
type ErrorBlock = {

View File

@ -1,6 +1,6 @@
import { SNSClient, SNSClientConfig } from "@aws-sdk/client-sns";
import { Connection } from "@solana/web3.js";
import { Config } from "./config";
import { Config } from "../config";
import {
SnsEventRepository,
EvmJsonRPCBlockRepository,
@ -10,9 +10,9 @@ import {
StaticJobRepository,
Web3SolanaSlotRepository,
RateLimitedSolanaSlotRepository,
} from "./repositories";
import { HttpClient } from "./http/HttpClient";
import { JobRepository } from "../domain/repositories";
} from ".";
import { HttpClient } from "../rpc/http/HttpClient";
import { JobRepository } from "../../domain/repositories";
const SOLANA_CHAIN = "solana";
const EVM_CHAINS = ["ethereum", "avalanche", "fantom", "karura", "acala"];
@ -37,10 +37,10 @@ export class RepositoriesBuilder {
this.repositories.set("metadata", new FileMetadataRepository(this.cfg.metadata.dir));
this.cfg.supportedChains.forEach((chain) => {
if (!this.cfg.platforms[chain]) throw new Error(`No config for chain ${chain}`);
if (!this.cfg.chains[chain]) throw new Error(`No config for chain ${chain}`);
if (chain === SOLANA_CHAIN) {
const cfg = this.cfg.platforms[chain];
const cfg = this.cfg.chains[chain];
const solanaSlotRepository = new RateLimitedSolanaSlotRepository(
new Web3SolanaSlotRepository(
new Connection(cfg.rpcs[0], { disableRetryOnRateLimit: true })
@ -50,18 +50,12 @@ export class RepositoriesBuilder {
this.repositories.set("solana-slotRepo", solanaSlotRepository);
}
if (EVM_CHAINS.includes(chain)) {
const httpClient = this.createHttpClient(this.cfg.platforms[chain].timeout);
if (!this.repositories.has("evmRepo")) {
const httpClient = this.createHttpClient(this.cfg.chains[chain].timeout);
const repoCfg: EvmJsonRPCBlockRepositoryCfg = {
chain,
chainId: this.cfg.platforms[chain].chainId,
rpc: this.cfg.platforms[chain].rpcs[0],
timeout: this.cfg.platforms[chain].timeout,
chains: this.cfg.chains,
};
this.repositories.set(
`${chain}-evmRepo`,
new EvmJsonRPCBlockRepository(repoCfg, httpClient)
);
this.repositories.set("evmRepo", new EvmJsonRPCBlockRepository(repoCfg, httpClient));
}
});
@ -82,7 +76,8 @@ export class RepositoriesBuilder {
}
public getEvmBlockRepository(chain: string): EvmJsonRPCBlockRepository {
return this.getRepo(`${chain}-evmRepo`);
if (!EVM_CHAINS.includes(chain)) throw new Error(`Chain ${chain} not supported`);
return this.getRepo("evmRepo");
}
public getSnsEventRepository(): SnsEventRepository {

View File

@ -1,4 +1,4 @@
import { StatRepository } from "../../domain/repositories";
import { StatRepository } from "../../../domain/repositories";
export class HealthController {
private readonly statsRepo: StatRepository;

View File

@ -1,6 +1,6 @@
import axios, { AxiosError, AxiosInstance } from "axios";
import { setTimeout } from "timers/promises";
import { HttpClientError } from "../errors/HttpClientError";
import { HttpClientError } from "../../errors/HttpClientError";
/**
* A simple HTTP client with exponential backoff retries and 429 handling.

View File

@ -1,7 +1,7 @@
import http from "http";
import url from "url";
import { HealthController } from "./HealthController";
import log from "../log";
import log from "../../log";
export class WebServer {
private server: http.Server;

View File

@ -1,8 +1,8 @@
import { configuration } from "./infrastructure/config";
import { RepositoriesBuilder } from "./infrastructure/RepositoriesBuilder";
import { RepositoriesBuilder } from "./infrastructure/repositories/RepositoriesBuilder";
import log from "./infrastructure/log";
import { WebServer } from "./infrastructure/rpc/Server";
import { HealthController } from "./infrastructure/rpc/HealthController";
import { WebServer } from "./infrastructure/rpc/http/Server";
import { HealthController } from "./infrastructure/rpc/http/HealthController";
import { StartJobs } from "./domain/actions";
let repos: RepositoriesBuilder;

View File

@ -12,7 +12,7 @@ import {
} from "../../../../src/domain/repositories";
import { EvmBlock, EvmLog } from "../../../../src/domain/entities";
let cfg = PollEvmLogsConfig.fromBlock(0n);
let cfg = PollEvmLogsConfig.fromBlock("acala", 0n);
let getBlocksSpy: jest.SpiedFunction<EvmBlockRepository["getBlocks"]>;
let getLogsSpy: jest.SpiedFunction<EvmBlockRepository["getFilteredLogs"]>;
@ -46,9 +46,13 @@ describe("PollEvmLogs", () => {
await thenWaitForAssertion(
() => expect(getBlocksSpy).toHaveReturnedTimes(1),
() => expect(getBlocksSpy).toHaveBeenCalledWith(new Set([currentHeight, currentHeight + 1n])),
() =>
expect(getLogsSpy).toBeCalledWith({
expect(getBlocksSpy).toHaveBeenCalledWith(
"acala",
new Set([currentHeight, currentHeight + 1n])
),
() =>
expect(getLogsSpy).toBeCalledWith("acala", {
addresses: cfg.addresses,
topics: cfg.topics,
fromBlock: currentHeight + blocksAhead,
@ -73,7 +77,7 @@ describe("PollEvmLogs", () => {
new Set([lastExtractedBlock, lastExtractedBlock + 1n])
),
() =>
expect(getLogsSpy).toBeCalledWith({
expect(getLogsSpy).toBeCalledWith("acala", {
addresses: cfg.addresses,
topics: cfg.topics,
fromBlock: lastExtractedBlock + 1n,

View File

@ -3,9 +3,10 @@ import { EvmJsonRPCBlockRepository } from "../../../src/infrastructure/repositor
import axios from "axios";
import nock from "nock";
import { EvmLogFilter, EvmTag } from "../../../src/domain/entities";
import { HttpClient } from "../../../src/infrastructure/http/HttpClient";
import { HttpClient } from "../../../src/infrastructure/rpc/http/HttpClient";
axios.defaults.adapter = "http"; // needed by nock
const eth = "ethereum";
const rpc = "http://localhost";
const address = "0x98f3c9e6e3face36baad05fe09d375ef1464288b";
const topic = "0x6eb224fb001ed210e379b335e35efe88672a8ce935d981a6896b27ffdf52a3b2";
@ -27,7 +28,7 @@ describe("EvmJsonRPCBlockRepository", () => {
givenARepo();
givenBlockHeightIs(expectedHeight, "latest");
const result = await repo.getBlockHeight("latest");
const result = await repo.getBlockHeight(eth, "latest");
expect(result).toBe(expectedHeight);
});
@ -37,7 +38,7 @@ describe("EvmJsonRPCBlockRepository", () => {
givenARepo();
givenBlocksArePresent(blockNumbers);
const result = await repo.getBlocks(new Set(blockNumbers));
const result = await repo.getBlocks(eth, new Set(blockNumbers));
expect(Object.keys(result)).toHaveLength(blockNumbers.length);
blockNumbers.forEach((blockNumber) => {
@ -55,7 +56,7 @@ describe("EvmJsonRPCBlockRepository", () => {
givenLogsPresent(filter);
const logs = await repo.getFilteredLogs(filter);
const logs = await repo.getFilteredLogs(eth, filter);
expect(logs).toHaveLength(1);
expect(logs[0].blockNumber).toBe(1n);
@ -66,7 +67,11 @@ describe("EvmJsonRPCBlockRepository", () => {
const givenARepo = () => {
repo = new EvmJsonRPCBlockRepository(
{ rpc, timeout: 100, chain: "ethereum", chainId: 2 },
{
chains: {
ethereum: { rpcs: [rpc], timeout: 100, name: "ethereum", network: "mainnet", chainId: 2 },
},
},
new HttpClient()
);
};

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "@jest/globals";
import { RepositoriesBuilder } from "../../../src/infrastructure/RepositoriesBuilder";
import { RepositoriesBuilder } from "../../../src/infrastructure/repositories/RepositoriesBuilder";
import { configMock } from "../../mocks/configMock";
import {
EvmJsonRPCBlockRepository,

View File

@ -1,8 +1,8 @@
import { SnsConfig } from "../../src/infrastructure/repositories";
import { Config, PlatformConfig } from "../../src/infrastructure/config";
import { Config, ChainRPCConfig } from "../../src/infrastructure/config";
export const configMock = (chains: string[] = []): Config => {
const platformRecord: Record<string, PlatformConfig> = {
const platformRecord: Record<string, ChainRPCConfig> = {
solana: {
name: "solana",
network: "devnet",
@ -71,7 +71,7 @@ export const configMock = (chains: string[] = []): Config => {
jobs: {
dir: "./metadata-repo/jobs",
},
platforms: platformRecord,
chains: platformRecord,
supportedChains: chains,
};

View File

@ -1,4 +1,4 @@
NODE_ENV=prod-mainnet
NODE_ENV=mainnet
BLOCKCHAIN_ENV=mainnet
NAMESPACE=wormscan
NAME=blockchain-watcher

View File

@ -1,4 +1,4 @@
NODE_ENV=prod-testnet
NODE_ENV=testnet
BLOCKCHAIN_ENV=testnet
NAMESPACE=wormscan-testnet
NAME=blockchain-watcher

View File

@ -1,4 +1,4 @@
NODE_ENV=staging-mainnet
NODE_ENV=mainnet
BLOCKCHAIN_ENV=mainnet
NAMESPACE=wormscan
NAME=blockchain-watcher

View File

@ -47,8 +47,7 @@ data:
"commitment": "latest",
"interval": 15000,
"addresses": ["0x706abc4E45D419950511e474C7B9Ed348A4a716c"],
"chain": "ethereum",
"topics": []
"chain": "ethereum"
}
},
"handlers": [
@ -78,8 +77,7 @@ data:
"addresses": [
"0xE4eacc10990ba3308DdCC72d985f2a27D20c7d03"
],
"chain": "karura",
"topics": []
"chain": "karura"
}
},
"handlers": [
@ -113,8 +111,7 @@ data:
"addresses": [
"0x1BB3B4119b7BA9dfad76B0545fb3F531383c3bB7"
],
"chain": "fantom",
"topics": []
"chain": "fantom"
}
},
"handlers": [
@ -148,8 +145,7 @@ data:
"addresses": [
"0x4377B49d559c0a9466477195C6AdC3D433e265c0"
],
"chain": "acala",
"topics": []
"chain": "acala"
}
},
"handlers": [
@ -219,8 +215,7 @@ data:
"commitment": "latest",
"interval": 5000,
"addresses": ["0x98f3c9e6E3fAce36bAAd05FE09d375Ef1464288B"],
"chain": "ethereum",
"topics": []
"chain": "ethereum"
}
},
"handlers": [
@ -250,8 +245,7 @@ data:
"addresses": [
"0xa321448d90d4e5b0A732867c18eA198e75CAC48E"
],
"chain": "karura",
"topics": []
"chain": "karura"
}
},
"handlers": [
@ -285,8 +279,7 @@ data:
"addresses": [
"0x126783A6Cb203a3E35344528B26ca3a0489a1485"
],
"chain": "fantom",
"topics": []
"chain": "fantom"
}
},
"handlers": [
@ -320,8 +313,7 @@ data:
"addresses": [
"0xa321448d90d4e5b0A732867c18eA198e75CAC48E"
],
"chain": "acala",
"topics": []
"chain": "acala"
}
},
"handlers": [