diff --git a/.cspell.json b/.cspell.json index b4346836cd..7aad8feeac 100644 --- a/.cspell.json +++ b/.cspell.json @@ -138,7 +138,8 @@ "Xdai", "goquorum", "hada", - "undici" + "undici", + "ossp" ], "dictionaries": [ "typescript,node,npm,go,rust" diff --git a/packages/cactus-plugin-persistence-ethereum/Dockerfile b/packages/cactus-plugin-persistence-ethereum/Dockerfile new file mode 100644 index 0000000000..b21f3089fa --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/Dockerfile @@ -0,0 +1,14 @@ +FROM node:16 + +ENV PACKAGE_PATH=/opt/cactus-plugin-persistence-ethereum + +WORKDIR ${PACKAGE_PATH} + +# CMake is required by one of npm dependencies (install other packages in this step as well in the future) +RUN apt-get update && apt-get install -y cmake && rm -rf /var/lib/apt/lists/* + +COPY ./dist/yarn.lock ./package.json ./ +RUN yarn install --production --ignore-engines --non-interactive --cache-folder ./.yarnCache && \ + rm -rf ./.yarnCache + +COPY ./dist ./dist diff --git a/packages/cactus-plugin-persistence-ethereum/README.md b/packages/cactus-plugin-persistence-ethereum/README.md new file mode 100644 index 0000000000..e9b55cee24 --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/README.md @@ -0,0 +1,237 @@ +# `@hyperledger/cactus-plugin-persistence-ethereum` + +This plugin allows `Cactus` to persist Ethereum data into some storage (currently to a `PostgreSQL` database, but this concept can be extended further). +Data in the database can later be analyzed and viewed in a GUI tool. + +## Summary + +- [Remarks](#remarks) +- [Getting Started](#getting-started) +- [Endpoints](#endpoints) +- [Running the tests](#running-the-tests) +- [Contributing](#contributing) +- [License](#license) +- [Acknowledgments](#acknowledgments) + +## Remarks + +- This plugin was only tested with small, permissioned Ethereum ledgers. Running it to archive and monitor large ledgers (like main net) is not recommended yet. +- For now, the database schema is not considered public and can change over time (i.e., writing own application that reads data directly from the database is discouraged). +- Only `status` endpoint is available, all the methods must be called directly on the plugin instance for now. +- Monitored ERC20 tokens should be added before synchronizing the database (previous transfers will not be parsed correctly if you later add the token). + +## Getting Started + +Clone the git repository on your local machine. Follow these instructions that will get you a copy of the project up and running on your local machine for development and testing purposes. + +### Prerequisites + +In the root of the project, execute the command to install and build the dependencies. It will also build this persistence plugin: + +```sh +yarn run configure +``` + +### Usage + +Instantiate a new `PluginPersistenceEthereum` instance: + +```typescript +import { PluginPersistenceEthereum } from "@hyperledger/cactus-plugin-persistence-ethereum"; +import { v4 as uuidv4 } from "uuid"; + +const persistencePlugin = new PluginPersistenceEthereum({ + instanceId: uuidv4(), + apiClient: new SocketIOApiClient(apiConfigOptions), + logLevel: "info", + connectionString: + "postgresql://postgres:your-super-secret-and-long-postgres-password@localhost:5432/postgres", +}); + +// Initialize the connection to the DB +persistencePlugin.onPluginInit(); +``` + +Alternatively, import `PluginFactoryLedgerPersistence` from the plugin package and use it to create a plugin. + +```typescript +import { PluginFactoryLedgerPersistence } from "@hyperledger/cactus-plugin-persistence-ethereum"; +import { PluginImportType } from "@hyperledger/cactus-core-api"; +import { v4 as uuidv4 } from "uuid"; + +const factory = new PluginFactoryLedgerPersistence({ + pluginImportType: PluginImportType.Local, +}); + +const persistencePlugin = await factory.create({ + instanceId: uuidv4(), + apiClient: new SocketIOApiClient(apiConfigOptions), + logLevel: "info", + connectionString: + "postgresql://postgres:your-super-secret-and-long-postgres-password@localhost:5432/postgres", +}); + +// Initialize the connection to the DB +persistencePlugin.onPluginInit(); +``` + +You can use the persistent plugin to start monitoring token balance changes and synchronize ledger state with the database. +Here is a sample script that adds two tokens to monitor, synchronizes all currently issued ERC721 tokens and starts monitoring for new blocks: + +```typescript +// Add ERC20 token under address erc20ContractAddress, monitor all transfers. +await persistencePlugin.addTokenERC20(erc20ContractAddress); + +// Add ERC721 token as well to monitor transfers +await persistencePlugin.addTokenERC721(erc721ContractAddress); + +// Synchronize all issued ERC721 token balances that we currently monitor +await persistencePlugin.syncERC721Tokens(); + +// Start monitoring new blocks. +// Transactions in each block are parsed, token transfers update current token balances. +// Entire ledger is synchronized first with the DB (`syncAll` is called) so this operation can take a while on large ledgers! +persistencePlugin.startMonitor((err) => { + reject(err); +}); + +// Show current status of the plugin +persistencePlugin.getStatus(); +``` + +> See [plugin integration tests](./src/test/typescript/integration) for complete usage examples. + +### Building/running the container image locally + +In the Cactus project root say: + +```sh +DOCKER_BUILDKIT=1 docker build ./packages/cactus-plugin-persistence-ethereum/ -f ./packages/cactus-plugin-persistence-ethereum/Dockerfile -t cactus-plugin-persistence-ethereum +``` + +## Endpoints + +### StatusV1 (`/api/v1/plugins/@hyperledger/cactus-plugin-persistence-ethereum/status`) + +- Returns status of the plugin (latest block read, failed blocks, is monitor running, etc...) + +### Plugin Methods + +- Most of the plugin functionalities are currently not available through OpenAPI interface, please use direct method calls instead. + +#### `onPluginInit` + +- Should be called before using the plugin. + +#### `shutdown` + +- Close the connection to the DB, cleanup any allocated resources. + +#### `getStatus` + +- Get status report of this instance of persistence plugin. + +#### `refreshMonitoredTokens` + +- Fetch the metadata of all tokens to be monitored by this persistence plugin. + +#### `syncERC721Tokens` + +- Synchronize issued tokens for all ERC721 token contract monitored by this persistence plugin. + +#### `startMonitor` + +- Start the block monitoring process. New blocks from the ledger will be parsed and pushed to the database. + +#### `stopMonitor` + +- Stop the block monitoring process. + +#### `addTokenERC20` + +- Add new ERC20 token to be monitored by this plugin. + +#### `addTokenERC721` + +- Add new ERC721 token to be monitored by this plugin. + +#### `syncFailedBlocks` + +- Walk through all the blocks that could not be synchronized with the DB for some reasons and try pushing them again. + +#### `syncAll` + +- Synchronize entire ledger state with the database. + +## Running the tests + +To run all the tests for this persistence plugin to ensure it's working correctly execute the following from the root of the `cactus` project: + +```sh +npx jest cactus-plugin-persistence-ethereum +``` + +## Contributing + +We welcome contributions to Hyperledger Cactus in many forms, and there’s always plenty to do! + +Please review [CONTIRBUTING.md](../../CONTRIBUTING.md) to get started. + +### Quick plugin project walkthrough + +#### ./src/main/json/contract_abi + +- Contains reference token ABIs used to call and identify token transfers. + +#### `./src/main/json/openapi.json` + +- Contains OpenAPI definition. + +#### `./src/main/sql/schema.sql` + +- Database schema for Ethereum data. + +#### `./src/main/typescript/token-client` + +- Client used to execute methods on token contracts. + +#### `./src/main/typescript/web-services` + +- Folder that contains web service endpoint definitions. + +#### `./plugin-persistence-ethereum` + +- Main persistent plugin logic file + +#### `./src/test/typescript/integration/` + +- Integration test of various plugin functionalities. + +### Generating types from the database schema + +- Current setup assume use of Supabase that has utility for generating types from the database schema. +- We use this tool to generate type definitions and store them in `./src/main/typescript/db-client/database.types.ts` +- Upstream instructions: https://supabase.com/docs/guides/api/generating-types +- Step by step manual on updating the types (must be done after changing the database schema): + - Install `supabase` package + - Init and start development supabase server: + - `npx supabase init` + - `npx supabase start` + - Fill in current schema: + - `psql -h localhost -p 54322 -U postgres -d postgres -a -f src/main/sql/schema.sql` (password: `postgres`) + - Generate the file with type definitions: + - `npx supabase gen types typescript --schema public --local > src/main/typescript/db-client/database.types.ts` + - Cleanup: + - `npx supabase stop` + - `rm -rf ./supabase` + +#### Insert sample data + +- Can be used to test GUI applications without running entire ledger / persistence setup. +- `psql -h localhost -p 54322 -U postgres -d postgres -a -f src/test/sql/insert-test-data.sql` (password: `postgres`) + +## License + +This distribution is published under the Apache License Version 2.0 found in the [LICENSE](../../LICENSE) file. + +## Acknowledgments diff --git a/packages/cactus-plugin-persistence-ethereum/openapitools.json b/packages/cactus-plugin-persistence-ethereum/openapitools.json new file mode 100644 index 0000000000..601ac1d61f --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/openapitools.json @@ -0,0 +1,7 @@ +{ + "$schema": "node_modules/@openapitools/openapi-generator-cli/config.schema.json", + "spaces": 2, + "generator-cli": { + "version": "5.2.1" + } +} diff --git a/packages/cactus-plugin-persistence-ethereum/package.json b/packages/cactus-plugin-persistence-ethereum/package.json new file mode 100644 index 0000000000..79699d43a1 --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/package.json @@ -0,0 +1,91 @@ +{ + "name": "@hyperledger/cactus-plugin-persistence-ethereum", + "version": "1.1.3", + "description": "Persistence plugin for Ethereum ledgers to store data into a database.", + "keywords": [ + "Hyperledger", + "Cactus", + "Integration", + "Blockchain", + "Distributed Ledger Technology" + ], + "homepage": "https://github.com/hyperledger/cactus#readme", + "bugs": { + "url": "https://github.com/hyperledger/cactus/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/hyperledger/cactus.git" + }, + "license": "Apache-2.0", + "author": { + "name": "Hyperledger Cactus Contributors", + "email": "cactus@lists.hyperledger.org", + "url": "https://www.hyperledger.org/use/cactus" + }, + "contributors": [ + { + "name": "Please add yourself to the list of contributors", + "email": "your.name@example.com", + "url": "https://example.com" + }, + { + "name": "Michal Bajer", + "email": "michal.bajer@fujitsu.com", + "url": "https://www.fujitsu.com/global/" + }, + { + "name": "Tomasz Awramski", + "email": "tomasz.awramski@fujitsu.com", + "url": "https://www.fujitsu.com/global/" + } + ], + "main": "dist/lib/main/typescript/index.js", + "module": "dist/lib/main/typescript/index.js", + "types": "dist/lib/main/typescript/index.d.ts", + "files": [ + "dist/*" + ], + "scripts": { + "codegen": "run-p 'codegen:*'", + "codegen:openapi": "npm run generate-sdk", + "generate-sdk": "openapi-generator-cli generate -i ./src/main/json/openapi.json -g typescript-axios -o ./src/main/typescript/generated/openapi/typescript-axios/ --reserved-words-mappings protected=protected", + "build": "npm run build-ts && npm run build:dev:backend:postbuild", + "build-ts": "tsc", + "build:dev:backend:postbuild": "npm run copy-sql && npm run copy-yarn-lock", + "copy-sql": "cp -raf ./src/main/sql ./dist/lib/main/", + "copy-yarn-lock": "cp -af ../../yarn.lock ./dist/yarn.lock" + }, + "dependencies": { + "@ethersproject/abi": "5.7.0", + "@hyperledger/cactus-core": "1.1.3", + "@hyperledger/cactus-common": "1.1.3", + "pg": "8.8.0", + "run-time-error": "1.4.0", + "fast-safe-stringify": "2.1.1", + "sanitize-html": "2.7.0", + "web3-utils": "1.5.2", + "async-mutex": "0.4.0", + "uuid": "8.3.2" + }, + "devDependencies": { + "@hyperledger/cactus-core-api": "1.1.3", + "@hyperledger/cactus-api-client": "1.1.3", + "@hyperledger/cactus-test-tooling": "1.1.3", + "@types/express": "4.17.13", + "@types/pg": "8.6.5", + "rxjs": "7.3.0", + "web3": "1.5.2", + "web3-eth": "1.5.2", + "web3-core": "1.5.2", + "jest-extended": "2.0.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "publishConfig": { + "access": "public" + }, + "watch": {} +} diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/json/contract_abi/Erc20Token.json b/packages/cactus-plugin-persistence-ethereum/src/main/json/contract_abi/Erc20Token.json new file mode 100644 index 0000000000..1914d28850 --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/json/contract_abi/Erc20Token.json @@ -0,0 +1,306 @@ +[ + { + "constant": true, + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "addedValue", + "type": "uint256" + } + ], + "name": "increaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "subtractedValue", + "type": "uint256" + } + ], + "name": "decreaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "initialSupply", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + } +] diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/json/contract_abi/Erc721Full.json b/packages/cactus-plugin-persistence-ethereum/src/main/json/contract_abi/Erc721Full.json new file mode 100644 index 0000000000..0f6e212e01 --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/json/contract_abi/Erc721Full.json @@ -0,0 +1,472 @@ +[ + { + "constant": true, + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "getApproved", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "tokenOfOwnerByIndex", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "safeTransferFrom", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "tokenByIndex", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "ownerOf", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "baseURI", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "setApprovalForAll", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "_data", + "type": "bytes" + } + ], + "name": "safeTransferFrom", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "tokenURI", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "operator", + "type": "address" + } + ], + "name": "isApprovedForAll", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "approved", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "ApprovalForAll", + "type": "event" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "uint256", + "name": "newBondId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "issuer", + "type": "address" + }, + { + "internalType": "string", + "name": "tokenURI", + "type": "string" + } + ], + "name": "issueBond", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/json/openapi.json b/packages/cactus-plugin-persistence-ethereum/src/main/json/openapi.json new file mode 100644 index 0000000000..e9032b8036 --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/json/openapi.json @@ -0,0 +1,180 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Hyperledger Cactus Plugin - Persistence Ethereum", + "description": "Synchronizes state of an ethereum ledger into a DB that can later be viewed in GUI", + "version": "1.0.0", + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "components": { + "schemas": { + "TokenTypeV1": { + "type": "string", + "enum": [ + "erc20", + "erc721" + ], + "x-enum-descriptions": [ + "EIP-20: Token Standard", + "EIP-721: Non-Fungible Token Standard" + ], + "x-enum-varnames": [ + "ERC20", + "ERC721" + ] + }, + "MonitoredToken": { + "description": "Ethereum tokens that are being monitored by the persistence plugin.", + "type": "object", + "required": [ + "type", + "name", + "symbol" + ], + "properties": { + "type": { + "$ref": "#/components/schemas/TokenTypeV1" + }, + "name": { + "type": "string", + "nullable": false, + "description": "Token name" + }, + "symbol": { + "type": "string", + "nullable": false, + "description": "Token symbol" + } + } + }, + "TrackedOperationV1": { + "description": "Persistence plugin operation that is tracked and returned in status report.", + "type": "object", + "required": [ + "startAt", + "operation" + ], + "properties": { + "startAt": { + "type": "string", + "nullable": false, + "description": "Start time of the operation." + }, + "operation": { + "type": "string", + "nullable": false, + "description": "Operation name." + } + } + }, + "StatusResponseV1": { + "description": "Response with plugin status report.", + "type": "object", + "required": [ + "instanceId", + "connected", + "webServicesRegistered", + "monitoredTokensCount", + "operationsRunning", + "monitorRunning", + "lastSeenBlock" + ], + "properties": { + "instanceId": { + "type": "string", + "nullable": false, + "description": "Plugin instance id." + }, + "connected": { + "type": "boolean", + "nullable": false, + "description": "True if successfully connected to the database, false otherwise." + }, + "webServicesRegistered": { + "type": "boolean", + "nullable": false, + "description": "True if web services were correctly exported." + }, + "monitoredTokensCount": { + "type": "number", + "nullable": false, + "description": "Total number of tokens being monitored by the plugin." + }, + "operationsRunning": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TrackedOperationV1" + } + }, + "monitorRunning": { + "type": "boolean", + "nullable": false, + "description": "True if block monitoring is running, false otherwise." + }, + "lastSeenBlock": { + "type": "number", + "nullable": false, + "description": "Number of the last block seen by the block monitor." + } + } + }, + "ErrorExceptionResponseV1": { + "type": "object", + "required": [ + "message", + "error" + ], + "properties": { + "message": { + "type": "string", + "nullable": false + }, + "error": { + "type": "string", + "nullable": false + } + } + } + } + }, + "paths": { + "/api/v1/plugins/@hyperledger/cactus-plugin-persistence-ethereum/status": { + "get": { + "x-hyperledger-cactus": { + "http": { + "verbLowerCase": "get", + "path": "/api/v1/plugins/@hyperledger/cactus-plugin-persistence-ethereum/status" + } + }, + "operationId": "getStatusV1", + "summary": "Get the status of persistence plugin for ethereum", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/StatusResponseV1" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorExceptionResponseV1" + } + } + } + } + } + } + } + } +} diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/sql/schema.sql b/packages/cactus-plugin-persistence-ethereum/src/main/sql/schema.sql new file mode 100644 index 0000000000..5b8a0535ce --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/sql/schema.sql @@ -0,0 +1,450 @@ +-- Version: 0.3 + +---------------------------------------------------------------------------------------------------- +-- TABLES +---------------------------------------------------------------------------------------------------- + +-- Table: public.plugin_status + +-- DROP TABLE IF EXISTS public.plugin_status; + +CREATE TABLE IF NOT EXISTS public.plugin_status +( + name text COLLATE pg_catalog."default" NOT NULL, + last_instance_id text COLLATE pg_catalog."default" NOT NULL, + is_schema_initialized boolean NOT NULL, + created_at timestamp with time zone NOT NULL DEFAULT now(), + last_connected_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT plugin_status_pkey PRIMARY KEY (name), + CONSTRAINT plugin_status_name_key UNIQUE (name) +) + +TABLESPACE pg_default; + +ALTER TABLE IF EXISTS public.plugin_status + OWNER to postgres; + +GRANT ALL ON TABLE public.plugin_status TO anon; + +GRANT ALL ON TABLE public.plugin_status TO authenticated; + +GRANT ALL ON TABLE public.plugin_status TO postgres; + +GRANT ALL ON TABLE public.plugin_status TO service_role; + +-- Table: public.block + +-- DROP TABLE IF EXISTS public.block; + +CREATE TABLE IF NOT EXISTS public.block +( + "number" numeric NOT NULL, + created_at timestamp without time zone NOT NULL, + hash text COLLATE pg_catalog."default" NOT NULL, + number_of_tx numeric NOT NULL, + sync_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT block_pkey PRIMARY KEY ("number"), + CONSTRAINT block_hash_key UNIQUE (hash), + CONSTRAINT block_number_key UNIQUE ("number") +) + +TABLESPACE pg_default; + +ALTER TABLE IF EXISTS public.block + OWNER to postgres; + +GRANT ALL ON TABLE public.block TO anon; + +GRANT ALL ON TABLE public.block TO authenticated; + +GRANT ALL ON TABLE public.block TO postgres; + +GRANT ALL ON TABLE public.block TO service_role; + +-- Table: public.token_metadata_erc20 + +-- DROP TABLE IF EXISTS public."token_metadata_erc20"; + +CREATE TABLE IF NOT EXISTS public."token_metadata_erc20" +( + address text COLLATE pg_catalog."default" NOT NULL, + created_at timestamp with time zone NOT NULL DEFAULT now(), + name text COLLATE pg_catalog."default" NOT NULL, + symbol text COLLATE pg_catalog."default" NOT NULL, + total_supply numeric NOT NULL, + CONSTRAINT "token_erc20_pkey" PRIMARY KEY (address), + CONSTRAINT token_erc20_address_key UNIQUE (address) +) + +TABLESPACE pg_default; + +ALTER TABLE IF EXISTS public."token_metadata_erc20" + OWNER to postgres; + +GRANT ALL ON TABLE public."token_metadata_erc20" TO anon; + +GRANT ALL ON TABLE public."token_metadata_erc20" TO authenticated; + +GRANT ALL ON TABLE public."token_metadata_erc20" TO postgres; + +GRANT ALL ON TABLE public."token_metadata_erc20" TO service_role; + +-- Table: public.token_metadata_erc721 + +-- DROP TABLE IF EXISTS public."token_metadata_erc721"; + +CREATE TABLE IF NOT EXISTS public."token_metadata_erc721" +( + address text COLLATE pg_catalog."default" NOT NULL, + created_at timestamp with time zone NOT NULL DEFAULT now(), + name text COLLATE pg_catalog."default" NOT NULL, + symbol text COLLATE pg_catalog."default" NOT NULL, + CONSTRAINT "token_erc721_pkey" PRIMARY KEY (address), + CONSTRAINT token_erc721_address_key UNIQUE (address) +) + +TABLESPACE pg_default; + +ALTER TABLE IF EXISTS public."token_metadata_erc721" + OWNER to postgres; + +GRANT ALL ON TABLE public."token_metadata_erc721" TO anon; + +GRANT ALL ON TABLE public."token_metadata_erc721" TO authenticated; + +GRANT ALL ON TABLE public."token_metadata_erc721" TO postgres; + +GRANT ALL ON TABLE public."token_metadata_erc721" TO service_role; + +-- Table: public.token_erc721 + +-- DROP TABLE IF EXISTS public.token_erc721; + +CREATE TABLE IF NOT EXISTS public.token_erc721 +( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + account_address text COLLATE pg_catalog."default" NOT NULL, + token_address text COLLATE pg_catalog."default" NOT NULL, + uri text COLLATE pg_catalog."default" NOT NULL, + token_id numeric NOT NULL, + last_owner_change timestamp without time zone NOT NULL DEFAULT now(), + CONSTRAINT token_erc721_pkey1 PRIMARY KEY (id), + CONSTRAINT token_erc721_contract_tokens_unique UNIQUE (token_address, token_id), + CONSTRAINT token_erc721_token_address_fkey FOREIGN KEY (token_address) + REFERENCES public.token_metadata_erc721 (address) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +) + +TABLESPACE pg_default; + +ALTER TABLE IF EXISTS public.token_erc721 + OWNER to postgres; + +GRANT ALL ON TABLE public.token_erc721 TO anon; + +GRANT ALL ON TABLE public.token_erc721 TO authenticated; + +GRANT ALL ON TABLE public.token_erc721 TO postgres; + +GRANT ALL ON TABLE public.token_erc721 TO service_role; + +-- Table: public.transaction + +-- DROP TABLE IF EXISTS public.transaction; + +CREATE TABLE IF NOT EXISTS public.transaction +( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + index numeric NOT NULL, + hash text COLLATE pg_catalog."default" NOT NULL, + block_number numeric NOT NULL, + "from" text COLLATE pg_catalog."default" NOT NULL, + "to" text COLLATE pg_catalog."default" NOT NULL, + eth_value numeric NOT NULL, + method_signature text COLLATE pg_catalog."default" NOT NULL, + method_name text COLLATE pg_catalog."default" NOT NULL, + CONSTRAINT transaction_pkey PRIMARY KEY (id), + CONSTRAINT transaction_hash_key UNIQUE (hash), + CONSTRAINT transaction_block_number_fkey FOREIGN KEY (block_number) + REFERENCES public.block ("number") MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +) + +TABLESPACE pg_default; + +ALTER TABLE IF EXISTS public.transaction + OWNER to postgres; + +GRANT ALL ON TABLE public.transaction TO anon; + +GRANT ALL ON TABLE public.transaction TO authenticated; + +GRANT ALL ON TABLE public.transaction TO postgres; + +GRANT ALL ON TABLE public.transaction TO service_role; + +-- Table: public.token_transfer + +-- DROP TABLE IF EXISTS public.token_transfer; + +CREATE TABLE IF NOT EXISTS public.token_transfer +( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + transaction_id uuid NOT NULL, + sender text COLLATE pg_catalog."default" NOT NULL, + recipient text COLLATE pg_catalog."default" NOT NULL, + value numeric NOT NULL, + CONSTRAINT token_transfer_pkey PRIMARY KEY (id), + CONSTRAINT token_transfer_transaction_id_fkey FOREIGN KEY (transaction_id) + REFERENCES public.transaction (id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +) + +TABLESPACE pg_default; + +ALTER TABLE IF EXISTS public.token_transfer + OWNER to postgres; + +GRANT ALL ON TABLE public.token_transfer TO anon; + +GRANT ALL ON TABLE public.token_transfer TO authenticated; + +GRANT ALL ON TABLE public.token_transfer TO postgres; + +GRANT ALL ON TABLE public.token_transfer TO service_role; + +COMMENT ON COLUMN public.token_transfer.value + IS 'ERC20 - token quantity, ERC721 - token ID'; + +---------------------------------------------------------------------------------------------------- +-- VIEWS +---------------------------------------------------------------------------------------------------- + +-- View: public.erc20_token_history_view + +-- DROP VIEW public.erc20_token_history_view; + +CREATE OR REPLACE VIEW public.erc20_token_history_view + AS + SELECT + tx.hash AS transaction_hash, + tx."to" AS token_address, + b.created_at, + tt.sender, + tt.recipient, + tt.value + FROM transaction tx + JOIN block b ON tx.block_number = b.number + JOIN token_transfer tt ON tx.id = tt.transaction_id + JOIN token_metadata_erc20 tkn ON tx.to = tkn.address + ORDER BY b.created_at, tt.recipient; + +ALTER TABLE public.erc20_token_history_view + OWNER TO postgres; + +GRANT ALL ON TABLE public.erc20_token_history_view TO anon; +GRANT ALL ON TABLE public.erc20_token_history_view TO authenticated; +GRANT ALL ON TABLE public.erc20_token_history_view TO postgres; +GRANT ALL ON TABLE public.erc20_token_history_view TO service_role; +-- View: public.erc721_token_history_view + +-- DROP VIEW public.erc721_token_history_view; + +CREATE OR REPLACE VIEW public.erc721_token_history_view + AS + SELECT tx.hash AS transaction_hash, + tx."to" AS token_address, + b.created_at, + tt.sender, + tt.recipient, + tt.value AS token_id + FROM transaction tx + JOIN block b ON tx.block_number = b.number + JOIN token_transfer tt ON tx.id = tt.transaction_id + JOIN token_metadata_erc721 tkn ON tx."to" = tkn.address + ORDER BY b.created_at, tt.recipient; + +ALTER TABLE public.erc721_token_history_view + OWNER TO postgres; + +GRANT ALL ON TABLE public.erc721_token_history_view TO anon; +GRANT ALL ON TABLE public.erc721_token_history_view TO authenticated; +GRANT ALL ON TABLE public.erc721_token_history_view TO postgres; +GRANT ALL ON TABLE public.erc721_token_history_view TO service_role; + +-- View: public.erc721_txn_meta_view + +-- DROP VIEW public.erc721_txn_meta_view; + +CREATE OR REPLACE VIEW public.erc721_txn_meta_view + AS + SELECT token_erc721.account_address, + token_erc721.token_address, + token_erc721.uri, + token_metadata_erc721.symbol + FROM token_erc721 + LEFT JOIN token_metadata_erc721 ON token_erc721.token_address = token_metadata_erc721.address; + +ALTER TABLE public.erc721_txn_meta_view + OWNER TO postgres; + +GRANT ALL ON TABLE public.erc721_txn_meta_view TO anon; +GRANT ALL ON TABLE public.erc721_txn_meta_view TO authenticated; +GRANT ALL ON TABLE public.erc721_txn_meta_view TO postgres; +GRANT ALL ON TABLE public.erc721_txn_meta_view TO service_role; + +-- View: public.token_erc20 + +-- DROP MATERIALIZED VIEW IF EXISTS public.token_erc20; + +CREATE MATERIALIZED VIEW IF NOT EXISTS public.token_erc20 +TABLESPACE pg_default +AS + SELECT balances.account_address, + balances.token_address, + sum(balances.balance) AS balance + FROM ( SELECT erc20_token_history_view.recipient AS account_address, + erc20_token_history_view.token_address, + sum(erc20_token_history_view.value) AS balance + FROM erc20_token_history_view + GROUP BY erc20_token_history_view.token_address, erc20_token_history_view.recipient + UNION + SELECT erc20_token_history_view.sender AS account_address, + erc20_token_history_view.token_address, + - sum(erc20_token_history_view.value) AS balance + FROM erc20_token_history_view + GROUP BY erc20_token_history_view.token_address, erc20_token_history_view.sender) balances + GROUP BY balances.token_address, balances.account_address + HAVING sum(balances.balance) >= 0::numeric +WITH DATA; + +ALTER TABLE IF EXISTS public.token_erc20 + OWNER TO postgres; + +GRANT ALL ON TABLE public.token_erc20 TO anon; +GRANT ALL ON TABLE public.token_erc20 TO authenticated; +GRANT ALL ON TABLE public.token_erc20 TO postgres; +GRANT ALL ON TABLE public.token_erc20 TO service_role; + +CREATE UNIQUE INDEX token_erc20_uniq_idx + ON public.token_erc20 USING btree + (account_address COLLATE pg_catalog."default", token_address COLLATE pg_catalog."default") + TABLESPACE pg_default; + +---------------------------------------------------------------------------------------------------- +-- FUNCTIONS AND PROCEDURES +---------------------------------------------------------------------------------------------------- + +-- PROCEDURE: public.update_issued_erc721_tokens(numeric) + +-- DROP PROCEDURE IF EXISTS public.update_issued_erc721_tokens(numeric); + +CREATE OR REPLACE PROCEDURE public.update_issued_erc721_tokens(IN from_block_number numeric) +LANGUAGE 'plpgsql' +AS $BODY$ +DECLARE + current_token_entry token_erc721%ROWTYPE; + token_transfer record; + block_created_at timestamp; +BEGIN + SELECT created_at + FROM block + WHERE number = from_block_number + INTO block_created_at; + + IF NOT found THEN + raise exception 'invalid block provided: %', from_block_number + USING hint = 'ensure that given block was synchronized correctly'; + END IF; + + FOR token_transfer IN SELECT + DISTINCT ON (token_address, token_id) * + FROM erc721_token_history_view + WHERE created_at >= block_created_at + ORDER BY token_address, token_id, created_at DESC + LOOP + SELECT * FROM token_erc721 + WHERE token_id = token_transfer.token_id AND token_address = token_transfer.token_address + INTO current_token_entry; + + IF NOT found THEN + raise notice 'create entry for new token ID % on contract %', token_transfer.token_id, token_transfer.token_address; + INSERT INTO + token_erc721 + VALUES + ( + uuid_generate_v4(), + token_transfer.recipient, + token_transfer.token_address, + '', + token_transfer.token_id, + token_transfer.created_at + ); + ELSE + IF current_token_entry.last_owner_change < token_transfer.created_at THEN + raise notice 'update owner on token ID % on contract %', token_transfer.token_id, token_transfer.token_address; + + UPDATE token_erc721 + SET account_address = token_transfer.recipient, + last_owner_change = token_transfer.created_at + WHERE id = current_token_entry.id; + ELSE + raise notice 'current entry is more recent - ignore token ID % on contract %', token_transfer.token_id, token_transfer.token_address; + END IF; + END IF; + END LOOP; +END +$BODY$; + +ALTER PROCEDURE public.update_issued_erc721_tokens(numeric) + OWNER TO postgres; + +GRANT EXECUTE ON PROCEDURE public.update_issued_erc721_tokens(numeric) TO public; + +GRANT EXECUTE ON PROCEDURE public.update_issued_erc721_tokens(numeric) TO anon; + +GRANT EXECUTE ON PROCEDURE public.update_issued_erc721_tokens(numeric) TO authenticated; + +GRANT EXECUTE ON PROCEDURE public.update_issued_erc721_tokens(numeric) TO postgres; + +GRANT EXECUTE ON PROCEDURE public.update_issued_erc721_tokens(numeric) TO service_role; + +GRANT EXECUTE ON PROCEDURE public.update_issued_erc721_tokens(numeric) TO supabase_admin; + +-- FUNCTION: public.get_missing_blocks_in_range(integer, integer) + +-- DROP FUNCTION IF EXISTS public.get_missing_blocks_in_range(integer, integer); + +CREATE OR REPLACE FUNCTION public.get_missing_blocks_in_range( + start_number integer, + end_number integer) +RETURNS TABLE(block_number integer) +LANGUAGE 'plpgsql' +COST 100 +VOLATILE PARALLEL UNSAFE +ROWS 1000 +AS $BODY$ +BEGIN + RETURN query + SELECT series AS block_number + FROM generate_series(start_number, end_number, 1) series + LEFT JOIN public.block ON series = block.number + WHERE block.number IS NULL; +END; +$BODY$; + +ALTER FUNCTION public.get_missing_blocks_in_range(integer, integer) + OWNER TO postgres; + +GRANT EXECUTE ON FUNCTION public.get_missing_blocks_in_range(integer, integer) TO PUBLIC; + +GRANT EXECUTE ON FUNCTION public.get_missing_blocks_in_range(integer, integer) TO anon; + +GRANT EXECUTE ON FUNCTION public.get_missing_blocks_in_range(integer, integer) TO authenticated; + +GRANT EXECUTE ON FUNCTION public.get_missing_blocks_in_range(integer, integer) TO postgres; + +GRANT EXECUTE ON FUNCTION public.get_missing_blocks_in_range(integer, integer) TO service_role; diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/db-client/database.types.ts b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/db-client/database.types.ts new file mode 100644 index 0000000000..676eb4a928 --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/db-client/database.types.ts @@ -0,0 +1,218 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json } + | Json[]; + +export interface Database { + public: { + Tables: { + block: { + Row: { + number: number; + created_at: string; + hash: string; + number_of_tx: number; + sync_at: string; + }; + Insert: { + number: number; + created_at: string; + hash: string; + number_of_tx: number; + sync_at?: string; + }; + Update: { + number?: number; + created_at?: string; + hash?: string; + number_of_tx?: number; + sync_at?: string; + }; + }; + plugin_status: { + Row: { + name: string; + last_instance_id: string; + is_schema_initialized: boolean; + created_at: string; + last_connected_at: string; + }; + Insert: { + name: string; + last_instance_id: string; + is_schema_initialized: boolean; + created_at?: string; + last_connected_at?: string; + }; + Update: { + name?: string; + last_instance_id?: string; + is_schema_initialized?: boolean; + created_at?: string; + last_connected_at?: string; + }; + }; + token_erc721: { + Row: { + account_address: string; + token_address: string; + uri: string; + token_id: number; + id: string; + last_owner_change: string; + }; + Insert: { + account_address: string; + token_address: string; + uri: string; + token_id: number; + id?: string; + last_owner_change?: string; + }; + Update: { + account_address?: string; + token_address?: string; + uri?: string; + token_id?: number; + id?: string; + last_owner_change?: string; + }; + }; + token_metadata_erc20: { + Row: { + address: string; + name: string; + symbol: string; + total_supply: number; + created_at: string; + }; + Insert: { + address: string; + name: string; + symbol: string; + total_supply: number; + created_at?: string; + }; + Update: { + address?: string; + name?: string; + symbol?: string; + total_supply?: number; + created_at?: string; + }; + }; + token_metadata_erc721: { + Row: { + address: string; + name: string; + symbol: string; + created_at: string; + }; + Insert: { + address: string; + name: string; + symbol: string; + created_at?: string; + }; + Update: { + address?: string; + name?: string; + symbol?: string; + created_at?: string; + }; + }; + token_transfer: { + Row: { + transaction_id: string; + sender: string; + recipient: string; + value: number; + id: string; + }; + Insert: { + transaction_id: string; + sender: string; + recipient: string; + value: number; + id?: string; + }; + Update: { + transaction_id?: string; + sender?: string; + recipient?: string; + value?: number; + id?: string; + }; + }; + transaction: { + Row: { + index: number; + hash: string; + block_number: number; + from: string; + to: string; + eth_value: number; + method_signature: string; + method_name: string; + id: string; + }; + Insert: { + index: number; + hash: string; + block_number: number; + from: string; + to: string; + eth_value: number; + method_signature: string; + method_name: string; + id?: string; + }; + Update: { + index?: number; + hash?: string; + block_number?: number; + from?: string; + to?: string; + eth_value?: number; + method_signature?: string; + method_name?: string; + id?: string; + }; + }; + }; + Views: { + erc20_token_history_view: { + Row: { + transaction_hash: string | null; + token_address: string | null; + created_at: string | null; + sender: string | null; + recipient: string | null; + value: number | null; + }; + }; + erc721_token_history_view: { + Row: { + transaction_hash: string | null; + token_address: string | null; + created_at: string | null; + sender: string | null; + recipient: string | null; + token_id: number | null; + }; + }; + }; + Functions: { + get_missing_blocks_in_range: { + Args: { start_number: number; end_number: number }; + Returns: { block_number: number }[]; + }; + }; + Enums: { + [_ in never]: never; + }; + }; +} diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/db-client/db-client.ts b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/db-client/db-client.ts new file mode 100644 index 0000000000..4fed23617e --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/db-client/db-client.ts @@ -0,0 +1,499 @@ +/** + * Client class to communicate with PostgreSQL database. + */ + +import { + Checks, + Logger, + LoggerProvider, + LogLevelDesc, +} from "@hyperledger/cactus-common"; + +import { Database as DatabaseSchemaType } from "./database.types"; +import { getRuntimeErrorCause } from "../utils"; + +import fs from "fs"; +import path from "path"; +import { Client as PostgresClient } from "pg"; +import { RuntimeError } from "run-time-error"; + +////////////////////////////////// +// Helper Types +////////////////////////////////// + +type SchemaTables = DatabaseSchemaType["public"]["Tables"]; +type PluginStatusRowType = SchemaTables["plugin_status"]["Row"]; +type BlockRowType = SchemaTables["block"]["Row"]; +type BlockInsertType = SchemaTables["block"]["Insert"]; +type TransactionInsertType = SchemaTables["transaction"]["Insert"]; +type TokenTransferInsertType = SchemaTables["token_transfer"]["Insert"]; +type TokenERC72RowType = SchemaTables["token_erc721"]["Row"]; +type TokenERC72InsertType = SchemaTables["token_erc721"]["Insert"]; +type TokenMetadataERC20RowType = SchemaTables["token_metadata_erc20"]["Row"]; +type TokenMetadataERC20InsertType = SchemaTables["token_metadata_erc20"]["Insert"]; +type TokenMetadataERC721RowType = SchemaTables["token_metadata_erc721"]["Row"]; +type TokenMetadataERC721InsertType = SchemaTables["token_metadata_erc721"]["Insert"]; + +type SchemaFunctions = DatabaseSchemaType["public"]["Functions"]; +type GetMissingBlocksInRangeReturnType = SchemaFunctions["get_missing_blocks_in_range"]["Returns"]; + +// Supabase doesn't generate materialized view types +type TokenERC20RowType = { + account_address: string; + token_address: string; + balance: number; +}; + +export type BlockDataTransferInput = Omit< + TokenTransferInsertType, + "transaction_id" +>; + +export type BlockDataTransactionInput = Omit< + TransactionInsertType, + "block_number" +> & { + token_transfers: BlockDataTransferInput[]; +}; + +type InsertBlockDataInput = { + block: BlockInsertType; + transactions: BlockDataTransactionInput[]; +}; + +export interface PostgresDatabaseClientOptions { + connectionString: string; + logLevel: LogLevelDesc; +} + +////////////////////////////////// +// PostgresDatabaseClient +////////////////////////////////// + +/** + * Client class to communicate with PostgreSQL database. + * Remember to call `connect()` before using ano of the methods. + * + * @todo Use pg connection pool + */ +export default class PostgresDatabaseClient { + private log: Logger; + public static readonly CLASS_NAME = "PostgresDatabaseClient"; + public client: PostgresClient; + public isConnected = false; + + constructor(public options: PostgresDatabaseClientOptions) { + const fnTag = `${PostgresDatabaseClient.CLASS_NAME}#constructor()`; + Checks.truthy(options, `${fnTag} arg options`); + Checks.truthy( + options.connectionString, + `${fnTag} arg options.connectionString`, + ); + + const level = this.options.logLevel || "INFO"; + const label = PostgresDatabaseClient.CLASS_NAME; + this.log = LoggerProvider.getOrCreate({ level, label }); + + this.client = new PostgresClient({ + connectionString: options.connectionString, + }); + } + + /** + * Internal method that throws if postgres client is not connected yet. + */ + private assertConnected(): void { + if (!this.isConnected) { + throw new Error( + `${PostgresDatabaseClient.CLASS_NAME} method called before connecting to the DB!`, + ); + } + } + + /** + * Connect to a PostgreSQL database using connection string from the constructor. + */ + public async connect(): Promise { + this.log.info("Connect to PostgreSQL database..."); + await this.client.connect(); + this.isConnected = true; + } + + /** + * Close the connection to to a PostgreSQL database. + */ + public async shutdown(): Promise { + this.log.info("Close connection with PostgreSQL database."); + await this.client.end(); + this.isConnected = false; + } + + /** + * Read status of persistence plugin with specified name. + * @param pluginName name of the persistence plugin + * @returns status row + */ + public async getPluginStatus( + pluginName: string, + ): Promise { + this.assertConnected(); + + const queryResponse = await this.client.query( + "SELECT * FROM public.plugin_status WHERE name = $1", + [pluginName], + ); + + if (queryResponse.rows.length !== 1) { + throw new Error( + `Could not read status of plugin #${pluginName} from the DB`, + ); + } + + return queryResponse.rows[0]; + } + + /** + * Initialize / update entry for specific persistence plugin in the database. + * Create database schema for ethereum data if it was not created yet. + * @param pluginName name of the persistence plugin + * @param instanceId instance id of the persistence plugin + */ + public async initializePlugin( + pluginName: string, + instanceId: string, + ): Promise { + this.assertConnected(); + + let isSchemaInitialized = false; + + try { + const pluginStatus = await this.getPluginStatus(pluginName); + + if (pluginStatus.last_instance_id != instanceId) { + this.log.warn( + `Instance ID in DB different from this plugin id (${pluginStatus.last_instance_id} != ${instanceId})! Make sure only one persistence plugin is running at a time!`, + ); + } + + isSchemaInitialized = pluginStatus.is_schema_initialized; + } catch (error) { + this.log.info("No status in the DB for plugin", pluginName); + } + + if (!isSchemaInitialized) { + const schemaPath = path.join(__dirname, "../../sql/schema.sql"); + this.log.info( + "Path to SQL script to create a database schema:", + schemaPath, + ); + + const schemaSql = fs.readFileSync(schemaPath, "utf8"); + this.log.debug("Schema file length:", schemaSql.length); + + await this.client.query(schemaSql); + isSchemaInitialized = true; + + this.log.info("Schema DB initialized."); + } + + this.log.info( + `Update status for plugin ${pluginName} with instanceId ${instanceId}`, + ); + const updatePluginInfo = await this.client.query( + `INSERT INTO public.plugin_status("name", "last_instance_id", "is_schema_initialized") + VALUES ($1, $2, $3) + ON CONFLICT ON CONSTRAINT plugin_status_pkey + DO + UPDATE SET + last_instance_id = EXCLUDED.last_instance_id, + is_schema_initialized = EXCLUDED.is_schema_initialized, + last_connected_at=now(); + `, + [pluginName, instanceId, isSchemaInitialized], + ); + this.log.debug( + `Plugin status updated for ${updatePluginInfo.rowCount} rows.`, + ); + } + + /** + * Read all ERC20 token metadata. + * @returns ERC20 token metadata + */ + public async getTokenMetadataERC20(): Promise { + this.assertConnected(); + + const queryResponse = await this.client.query( + "SELECT * FROM public.token_metadata_erc20", + ); + this.log.debug( + `Received ${queryResponse.rowCount} rows from table token_metadata_erc20`, + ); + return queryResponse.rows; + } + + /** + * Insert new ERC20 token metadata into the database. + * @param token ERC20 token metadata + */ + public async insertTokenMetadataERC20( + token: TokenMetadataERC20InsertType, + ): Promise { + this.assertConnected(); + + this.log.debug("Insert ERC20 token metadata:", token); + const insertResponse = await this.client.query( + `INSERT INTO public.token_metadata_erc20("address", "name", "symbol", "total_supply") VALUES ($1, $2, $3, $4)`, + [token.address, token.name, token.symbol, token.total_supply], + ); + this.log.info( + `Inserted ${insertResponse.rowCount} rows into table token_metadata_erc20`, + ); + } + + /** + * Read all ERC721 token metadata. + * @returns ERC721 token metadata + */ + public async getTokenMetadataERC721(): Promise { + this.assertConnected(); + + const queryResponse = await this.client.query( + "SELECT * FROM public.token_metadata_erc721", + ); + this.log.debug( + `Received ${queryResponse.rowCount} rows from table token_metadata_erc721`, + ); + return queryResponse.rows; + } + + /** + * Insert new ERC721 token metadata into the database. + * @param token ERC721 token metadata + */ + public async insertTokenMetadataERC721( + token: TokenMetadataERC721InsertType, + ): Promise { + this.assertConnected(); + + this.log.debug("Insert ERC721 token metadata:", token); + const insertResponse = await this.client.query( + `INSERT INTO public.token_metadata_erc721("address", "name", "symbol") VALUES ($1, $2, $3)`, + [token.address, token.name, token.symbol], + ); + this.log.info( + `Inserted ${insertResponse.rowCount} rows into table token_metadata_erc721`, + ); + } + + /** + * Insert or update data of issued ERC721 token. + * @param token ERC721 token data. + */ + public async upsertTokenERC721(token: TokenERC72InsertType): Promise { + this.assertConnected(); + + this.log.debug("Insert ERC721 token if not present yet:", token); + const insertResponse = await this.client.query( + `INSERT INTO public.token_erc721("account_address", "token_address", "uri", "token_id") + VALUES ($1, $2, $3, $4) + ON CONFLICT ON CONSTRAINT token_erc721_contract_tokens_unique + DO + UPDATE SET account_address = EXCLUDED.account_address; + `, + [token.account_address, token.token_address, token.uri, token.token_id], + ); + this.log.debug( + `Inserted ${insertResponse.rowCount} rows into table token_erc721`, + ); + } + + /** + * Read all ERC20 token balances + * @returns ERC20 token balances + */ + public async getTokenERC20(): Promise { + this.assertConnected(); + + const queryResponse = await this.client.query( + "SELECT * FROM public.token_erc20", + ); + this.log.debug( + `Received ${queryResponse.rowCount} rows from table token_erc20`, + ); + return queryResponse.rows; + } + + /** + * Read all issued ERC721 tokens. + * @returns ERC721 tokens + */ + public async getTokenERC721(): Promise { + this.assertConnected(); + + const queryResponse = await this.client.query( + "SELECT * FROM public.token_erc721", + ); + this.log.debug( + `Received ${queryResponse.rowCount} rows from table token_erc721`, + ); + return queryResponse.rows; + } + + /** + * Synchronize current ERC20 token balances in the DB. + */ + public async syncTokenBalanceERC20(): Promise { + this.assertConnected(); + + await this.client.query( + "REFRESH MATERIALIZED VIEW CONCURRENTLY public.token_erc20", + ); + this.log.debug("Refreshing view public.token_erc20 done"); + } + + /** + * Synchronize current ERC721 token balances in the DB. + * @param fromBlockNumber block number from which token transfer should be checked (for performance reasons) + */ + public async syncTokenBalanceERC721(fromBlockNumber: number): Promise { + this.assertConnected(); + this.log.debug( + "Call update_issued_erc721_tokens from block", + fromBlockNumber, + ); + + await this.client.query("CALL update_issued_erc721_tokens($1);", [ + fromBlockNumber, + ]); + this.log.debug("Calling update_issued_erc721_tokens procedure done."); + } + + /** + * Read block data. Throws if block was not found. + * + * @param blockNumber ethereum block number + * @returns Block data. + */ + public async getBlock(blockNumber: number): Promise { + this.assertConnected(); + + const queryResponse = await this.client.query( + "SELECT * FROM public.block WHERE number = $1", + [blockNumber], + ); + + if (queryResponse.rows.length !== 1) { + throw new Error(`Could not read block #${blockNumber} from the DB`); + } + + return queryResponse.rows[0]; + } + + /** + * Insert entire block data into the database (the block itself, transactions and token transfers if there were any). + * Everything is committed in single atomic transaction (rollback on error). + * @param blockData new block data. + */ + public async insertBlockData(blockData: InsertBlockDataInput): Promise { + this.assertConnected(); + + this.log.debug( + "Insert block data including transactions and token transfers.", + ); + + const { block, transactions } = blockData; + + try { + await this.client.query("BEGIN"); + + this.log.debug("Insert new block", block); + const blockInsertResponse = await this.client.query( + `INSERT INTO public.block("number", "created_at", "hash", "number_of_tx") + VALUES ($1, $2, $3, $4)`, + [block.number, block.created_at, block.hash, block.number_of_tx], + ); + if (blockInsertResponse.rowCount !== 1) { + throw new Error(`Block ${block.number} was not inserted into the DB`); + } + + for (const tx of transactions) { + this.log.debug("Insert new transaction", tx); + const txInsertResponse = await this.client.query( + `INSERT INTO + public.transaction("index", "hash", "block_number", "from", "to", "eth_value", "method_signature", "method_name") + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id;`, + [ + tx.index, + tx.hash, + block.number, + tx.from, + tx.to, + tx.eth_value, + tx.method_signature, + tx.method_name, + ], + ); + if (txInsertResponse.rowCount !== 1) { + throw new Error( + `Transaction ${tx.hash} was not inserted into the DB`, + ); + } + const txId = txInsertResponse.rows[0].id; + this.log.debug("New transaction inserted with id", txId); + + for (const transfer of tx.token_transfers) { + this.log.debug("Insert new token transfer", transfer); + const transInsertResponse = await this.client.query( + `INSERT INTO + public.token_transfer("transaction_id", "sender", "recipient", "value") + VALUES ($1, $2, $3, $4)`, + [txId, transfer.sender, transfer.recipient, transfer.value], + ); + if (transInsertResponse.rowCount !== 1) { + throw new Error( + `Transfer from ${transfer.sender} to ${transfer.recipient} was not inserted into the DB`, + ); + } + } + } + + await this.client.query("COMMIT"); + } catch (err: unknown) { + await this.client.query("ROLLBACK"); + this.log.warn("insertBlockData() exception:", err); + throw new RuntimeError( + "Could not insert block data into the database - transaction reverted", + getRuntimeErrorCause(err), + ); + } + } + + /** + * Compare committed block numbers with requested range, return list of blocks that are missing. + * @param startBlockNumber block to check from (including) + * @param endBlockNumber block to check to (including) + * @returns list of missing block numbers + */ + public async getMissingBlocksInRange( + startBlockNumber: number, + endBlockNumber: number, + ): Promise { + Checks.truthy( + endBlockNumber >= startBlockNumber, + `getMissingBlocksInRange startBlockNumber larger than endBlockNumber`, + ); + this.assertConnected(); + + const queryResponse = await this.client.query( + "SELECT * FROM public.get_missing_blocks_in_range($1, $2) as block_number", + [startBlockNumber, endBlockNumber], + ); + this.log.debug( + `Found ${queryResponse.rowCount} missing blocks between ${startBlockNumber} and ${endBlockNumber}`, + ); + + return queryResponse.rows; + } +} diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/.gitignore b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/.gitignore new file mode 100644 index 0000000000..149b576547 --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/.gitignore @@ -0,0 +1,4 @@ +wwwroot/*.js +node_modules +typings +dist diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/.npmignore b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/.npmignore new file mode 100644 index 0000000000..999d88df69 --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/.npmignore @@ -0,0 +1 @@ +# empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm \ No newline at end of file diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator-ignore b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator-ignore new file mode 100644 index 0000000000..7484ee590a --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator-ignore @@ -0,0 +1,23 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator/FILES b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator/FILES new file mode 100644 index 0000000000..a80cd4f07b --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator/FILES @@ -0,0 +1,8 @@ +.gitignore +.npmignore +api.ts +base.ts +common.ts +configuration.ts +git_push.sh +index.ts diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator/VERSION b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator/VERSION new file mode 100644 index 0000000000..804440660c --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator/VERSION @@ -0,0 +1 @@ +5.2.1 \ No newline at end of file diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/api.ts b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/api.ts new file mode 100644 index 0000000000..cd1d4b3eda --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/api.ts @@ -0,0 +1,251 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Hyperledger Cactus Plugin - Persistence Ethereum + * Synchronizes state of an ethereum ledger into a DB that can later be viewed in GUI + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import { Configuration } from './configuration'; +import globalAxios, { AxiosPromise, AxiosInstance } from 'axios'; +// Some imports not used depending on template conditions +// @ts-ignore +import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from './common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, RequestArgs, BaseAPI, RequiredError } from './base'; + +/** + * + * @export + * @interface ErrorExceptionResponseV1 + */ +export interface ErrorExceptionResponseV1 { + /** + * + * @type {string} + * @memberof ErrorExceptionResponseV1 + */ + message: string; + /** + * + * @type {string} + * @memberof ErrorExceptionResponseV1 + */ + error: string; +} +/** + * Ethereum tokens that are being monitored by the persistence plugin. + * @export + * @interface MonitoredToken + */ +export interface MonitoredToken { + /** + * + * @type {TokenTypeV1} + * @memberof MonitoredToken + */ + type: TokenTypeV1; + /** + * Token name + * @type {string} + * @memberof MonitoredToken + */ + name: string; + /** + * Token symbol + * @type {string} + * @memberof MonitoredToken + */ + symbol: string; +} +/** + * Response with plugin status report. + * @export + * @interface StatusResponseV1 + */ +export interface StatusResponseV1 { + /** + * Plugin instance id. + * @type {string} + * @memberof StatusResponseV1 + */ + instanceId: string; + /** + * True if successfully connected to the database, false otherwise. + * @type {boolean} + * @memberof StatusResponseV1 + */ + connected: boolean; + /** + * True if web services were correctly exported. + * @type {boolean} + * @memberof StatusResponseV1 + */ + webServicesRegistered: boolean; + /** + * Total number of tokens being monitored by the plugin. + * @type {number} + * @memberof StatusResponseV1 + */ + monitoredTokensCount: number; + /** + * + * @type {Array} + * @memberof StatusResponseV1 + */ + operationsRunning: Array; + /** + * True if block monitoring is running, false otherwise. + * @type {boolean} + * @memberof StatusResponseV1 + */ + monitorRunning: boolean; + /** + * Number of the last block seen by the block monitor. + * @type {number} + * @memberof StatusResponseV1 + */ + lastSeenBlock: number; +} +/** + * + * @export + * @enum {string} + */ + +export enum TokenTypeV1 { + /** + * EIP-20: Token Standard + */ + ERC20 = 'erc20', + /** + * EIP-721: Non-Fungible Token Standard + */ + ERC721 = 'erc721' +} + +/** + * Persistence plugin operation that is tracked and returned in status report. + * @export + * @interface TrackedOperationV1 + */ +export interface TrackedOperationV1 { + /** + * Start time of the operation. + * @type {string} + * @memberof TrackedOperationV1 + */ + startAt: string; + /** + * Operation name. + * @type {string} + * @memberof TrackedOperationV1 + */ + operation: string; +} + +/** + * DefaultApi - axios parameter creator + * @export + */ +export const DefaultApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @summary Get the status of persistence plugin for ethereum + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getStatusV1: async (options: any = {}): Promise => { + const localVarPath = `/api/v1/plugins/@hyperledger/cactus-plugin-persistence-ethereum/status`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * DefaultApi - functional programming interface + * @export + */ +export const DefaultApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = DefaultApiAxiosParamCreator(configuration) + return { + /** + * + * @summary Get the status of persistence plugin for ethereum + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getStatusV1(options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getStatusV1(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * DefaultApi - factory interface + * @export + */ +export const DefaultApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = DefaultApiFp(configuration) + return { + /** + * + * @summary Get the status of persistence plugin for ethereum + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getStatusV1(options?: any): AxiosPromise { + return localVarFp.getStatusV1(options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * DefaultApi - object-oriented interface + * @export + * @class DefaultApi + * @extends {BaseAPI} + */ +export class DefaultApi extends BaseAPI { + /** + * + * @summary Get the status of persistence plugin for ethereum + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public getStatusV1(options?: any) { + return DefaultApiFp(this.configuration).getStatusV1(options).then((request) => request(this.axios, this.basePath)); + } +} + + diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/base.ts b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/base.ts new file mode 100644 index 0000000000..6c24950d94 --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/base.ts @@ -0,0 +1,71 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Hyperledger Cactus Plugin - Persistence Ethereum + * Synchronizes state of an ethereum ledger into a DB that can later be viewed in GUI + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import { Configuration } from "./configuration"; +// Some imports not used depending on template conditions +// @ts-ignore +import globalAxios, { AxiosPromise, AxiosInstance } from 'axios'; + +export const BASE_PATH = "http://localhost".replace(/\/+$/, ""); + +/** + * + * @export + */ +export const COLLECTION_FORMATS = { + csv: ",", + ssv: " ", + tsv: "\t", + pipes: "|", +}; + +/** + * + * @export + * @interface RequestArgs + */ +export interface RequestArgs { + url: string; + options: any; +} + +/** + * + * @export + * @class BaseAPI + */ +export class BaseAPI { + protected configuration: Configuration | undefined; + + constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) { + if (configuration) { + this.configuration = configuration; + this.basePath = configuration.basePath || this.basePath; + } + } +}; + +/** + * + * @export + * @class RequiredError + * @extends {Error} + */ +export class RequiredError extends Error { + name: "RequiredError" = "RequiredError"; + constructor(public field: string, msg?: string) { + super(msg); + } +} diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/common.ts b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/common.ts new file mode 100644 index 0000000000..c99f95a4ae --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/common.ts @@ -0,0 +1,138 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Hyperledger Cactus Plugin - Persistence Ethereum + * Synchronizes state of an ethereum ledger into a DB that can later be viewed in GUI + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import { Configuration } from "./configuration"; +import { RequiredError, RequestArgs } from "./base"; +import { AxiosInstance } from 'axios'; + +/** + * + * @export + */ +export const DUMMY_BASE_URL = 'https://example.com' + +/** + * + * @throws {RequiredError} + * @export + */ +export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) { + if (paramValue === null || paramValue === undefined) { + throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`); + } +} + +/** + * + * @export + */ +export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) { + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = typeof configuration.apiKey === 'function' + ? await configuration.apiKey(keyParamName) + : await configuration.apiKey; + object[keyParamName] = localVarApiKeyValue; + } +} + +/** + * + * @export + */ +export const setBasicAuthToObject = function (object: any, configuration?: Configuration) { + if (configuration && (configuration.username || configuration.password)) { + object["auth"] = { username: configuration.username, password: configuration.password }; + } +} + +/** + * + * @export + */ +export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) { + if (configuration && configuration.accessToken) { + const accessToken = typeof configuration.accessToken === 'function' + ? await configuration.accessToken() + : await configuration.accessToken; + object["Authorization"] = "Bearer " + accessToken; + } +} + +/** + * + * @export + */ +export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) { + if (configuration && configuration.accessToken) { + const localVarAccessTokenValue = typeof configuration.accessToken === 'function' + ? await configuration.accessToken(name, scopes) + : await configuration.accessToken; + object["Authorization"] = "Bearer " + localVarAccessTokenValue; + } +} + +/** + * + * @export + */ +export const setSearchParams = function (url: URL, ...objects: any[]) { + const searchParams = new URLSearchParams(url.search); + for (const object of objects) { + for (const key in object) { + if (Array.isArray(object[key])) { + searchParams.delete(key); + for (const item of object[key]) { + searchParams.append(key, item); + } + } else { + searchParams.set(key, object[key]); + } + } + } + url.search = searchParams.toString(); +} + +/** + * + * @export + */ +export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) { + const nonString = typeof value !== 'string'; + const needsSerialization = nonString && configuration && configuration.isJsonMime + ? configuration.isJsonMime(requestOptions.headers['Content-Type']) + : nonString; + return needsSerialization + ? JSON.stringify(value !== undefined ? value : {}) + : (value || ""); +} + +/** + * + * @export + */ +export const toPathString = function (url: URL) { + return url.pathname + url.search + url.hash +} + +/** + * + * @export + */ +export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) { + return (axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => { + const axiosRequestArgs = {...axiosArgs.options, url: (configuration?.basePath || basePath) + axiosArgs.url}; + return axios.request(axiosRequestArgs); + }; +} diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/configuration.ts b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/configuration.ts new file mode 100644 index 0000000000..4c15fdd7ee --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/configuration.ts @@ -0,0 +1,101 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Hyperledger Cactus Plugin - Persistence Ethereum + * Synchronizes state of an ethereum ledger into a DB that can later be viewed in GUI + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface ConfigurationParameters { + apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); + username?: string; + password?: string; + accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); + basePath?: string; + baseOptions?: any; + formDataCtor?: new () => any; +} + +export class Configuration { + /** + * parameter for apiKey security + * @param name security name + * @memberof Configuration + */ + apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); + /** + * parameter for basic security + * + * @type {string} + * @memberof Configuration + */ + username?: string; + /** + * parameter for basic security + * + * @type {string} + * @memberof Configuration + */ + password?: string; + /** + * parameter for oauth2 security + * @param name security name + * @param scopes oauth2 scope + * @memberof Configuration + */ + accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); + /** + * override base path + * + * @type {string} + * @memberof Configuration + */ + basePath?: string; + /** + * base options for axios calls + * + * @type {any} + * @memberof Configuration + */ + baseOptions?: any; + /** + * The FormData constructor that will be used to create multipart form data + * requests. You can inject this here so that execution environments that + * do not support the FormData class can still run the generated client. + * + * @type {new () => FormData} + */ + formDataCtor?: new () => any; + + constructor(param: ConfigurationParameters = {}) { + this.apiKey = param.apiKey; + this.username = param.username; + this.password = param.password; + this.accessToken = param.accessToken; + this.basePath = param.basePath; + this.baseOptions = param.baseOptions; + this.formDataCtor = param.formDataCtor; + } + + /** + * Check if the given MIME is a JSON MIME. + * JSON MIME examples: + * application/json + * application/json; charset=UTF8 + * APPLICATION/JSON + * application/vnd.company+json + * @param mime - MIME (Multipurpose Internet Mail Extensions) + * @return True if the given MIME is JSON, false otherwise. + */ + public isJsonMime(mime: string): boolean { + const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i'); + return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json'); + } +} diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/git_push.sh b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/git_push.sh new file mode 100644 index 0000000000..ced3be2b0c --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/git_push.sh @@ -0,0 +1,58 @@ +#!/bin/sh +# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/ +# +# Usage example: /bin/sh ./git_push.sh wing328 openapi-pestore-perl "minor update" "gitlab.com" + +git_user_id=$1 +git_repo_id=$2 +release_note=$3 +git_host=$4 + +if [ "$git_host" = "" ]; then + git_host="github.com" + echo "[INFO] No command line input provided. Set \$git_host to $git_host" +fi + +if [ "$git_user_id" = "" ]; then + git_user_id="GIT_USER_ID" + echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id" +fi + +if [ "$git_repo_id" = "" ]; then + git_repo_id="GIT_REPO_ID" + echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id" +fi + +if [ "$release_note" = "" ]; then + release_note="Minor update" + echo "[INFO] No command line input provided. Set \$release_note to $release_note" +fi + +# Initialize the local directory as a Git repository +git init + +# Adds the files in the local repository and stages them for commit. +git add . + +# Commits the tracked changes and prepares them to be pushed to a remote repository. +git commit -m "$release_note" + +# Sets the new remote +git_remote=`git remote` +if [ "$git_remote" = "" ]; then # git remote not defined + + if [ "$GIT_TOKEN" = "" ]; then + echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment." + git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git + else + git remote add origin https://${git_user_id}:${GIT_TOKEN}@${git_host}/${git_user_id}/${git_repo_id}.git + fi + +fi + +git pull origin master + +# Pushes (Forces) the changes in the local repository up to the remote repository +echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git" +git push origin master 2>&1 | grep -v 'To https' + diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/index.ts b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/index.ts new file mode 100644 index 0000000000..d06945d35f --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/generated/openapi/typescript-axios/index.ts @@ -0,0 +1,18 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Hyperledger Cactus Plugin - Persistence Ethereum + * Synchronizes state of an ethereum ledger into a DB that can later be viewed in GUI + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export * from "./api"; +export * from "./configuration"; + diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/index.ts b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/index.ts new file mode 100755 index 0000000000..87cb558397 --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/index.ts @@ -0,0 +1 @@ +export * from "./public-api"; diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/plugin-factory-persistence-ethereum.ts b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/plugin-factory-persistence-ethereum.ts new file mode 100644 index 0000000000..1b4dea3aef --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/plugin-factory-persistence-ethereum.ts @@ -0,0 +1,20 @@ +import { + IPluginFactoryOptions, + PluginFactory, +} from "@hyperledger/cactus-core-api"; +import { + IPluginPersistenceEthereumOptions, + PluginPersistenceEthereum, +} from "./plugin-persistence-ethereum"; + +export class PluginFactoryLedgerPersistence extends PluginFactory< + PluginPersistenceEthereum, + IPluginPersistenceEthereumOptions, + IPluginFactoryOptions +> { + async create( + pluginOptions: IPluginPersistenceEthereumOptions, + ): Promise { + return new PluginPersistenceEthereum(pluginOptions); + } +} diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/plugin-persistence-ethereum.ts b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/plugin-persistence-ethereum.ts new file mode 100644 index 0000000000..361656164e --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/plugin-persistence-ethereum.ts @@ -0,0 +1,960 @@ +/** + * Main logic of persistence plugin for ethereum data. + */ + +import { + Checks, + Logger, + LoggerProvider, + LogLevelDesc, +} from "@hyperledger/cactus-common"; +import type { + IPluginWebService, + IWebServiceEndpoint, + ICactusPlugin, + ICactusPluginOptions, +} from "@hyperledger/cactus-core-api"; +import type { + SocketIOApiClient, + SocketLedgerEvent, +} from "@hyperledger/cactus-api-client"; + +import ERC20_ABI from "../json/contract_abi/Erc20Token.json"; +import TokenClientERC20 from "./token-client/token-client-erc20"; +import ERC721_ABI from "../json/contract_abi/Erc721Full.json"; +import TokenClientERC721 from "./token-client/token-client-erc721"; +import OAS from "../json/openapi.json"; +import { getRuntimeErrorCause, normalizeAddress } from "./utils"; +import { StatusEndpointV1 } from "./web-services/status-endpoint-v1"; +import PostgresDatabaseClient, { + BlockDataTransferInput, + BlockDataTransactionInput, +} from "./db-client/db-client"; +import { + MonitoredToken, + StatusResponseV1, + TokenTypeV1, + TrackedOperationV1, +} from "./generated/openapi/typescript-axios"; + +import { RuntimeError } from "run-time-error"; +import { Interface as EthersInterface } from "@ethersproject/abi"; +import { Mutex } from "async-mutex"; +import { v4 as uuidv4 } from "uuid"; +import type { BlockTransactionObject } from "web3-eth"; +import type { Transaction, TransactionReceipt } from "web3-core"; +import type { Express } from "express"; +import type { Subscription } from "rxjs"; + +/** + * Constructor parameter for Ethereum persistence plugin. + */ +export interface IPluginPersistenceEthereumOptions + extends ICactusPluginOptions { + apiClient: SocketIOApiClient; + connectionString: string; + logLevel: LogLevelDesc; +} + +/** + * Cactus persistence plugin for ethereum ledgers. + * Remember to call `onPluginInit()` before using any of the plugin method, and `shutdown()` when closing the app. + */ +export class PluginPersistenceEthereum + implements ICactusPlugin, IPluginWebService { + public static readonly CLASS_NAME = "PluginPersistenceEthereum"; + public monitoredTokens = new Map(); + + private readonly instanceId: string; + private apiClient: SocketIOApiClient; + private watchBlocksSubscription: Subscription | undefined; + private dbClient: PostgresDatabaseClient; + private log: Logger; + private isConnected = false; + private isWebServicesRegistered = false; + private endpoints: IWebServiceEndpoint[] | undefined; + private ethersInterfaceERC721 = new EthersInterface(ERC721_ABI); + private ethersInterfaceERC20 = new EthersInterface(ERC20_ABI); + private pushBlockMutex = new Mutex(); + private syncBlocksMutex = new Mutex(); + private syncTokenBalancesMutex = new Mutex(); + private failedBlocks = new Set(); + private lastSeenBlock = 0; + private trackedOperations = new Map(); + + constructor(public readonly options: IPluginPersistenceEthereumOptions) { + const fnTag = `${PluginPersistenceEthereum.CLASS_NAME}#constructor()`; + Checks.truthy(options, `${fnTag} arg options`); + Checks.truthy(options.apiClient, `${fnTag} options.apiClient`); + Checks.truthy(options.instanceId, `${fnTag} options.instanceId`); + Checks.truthy( + options.connectionString, + `${fnTag} options.connectionString`, + ); + + const level = this.options.logLevel || "INFO"; + const label = PluginPersistenceEthereum.CLASS_NAME; + this.log = LoggerProvider.getOrCreate({ level, label }); + + this.instanceId = options.instanceId; + this.apiClient = options.apiClient; + + this.dbClient = new PostgresDatabaseClient({ + connectionString: options.connectionString, + logLevel: level, + }); + } + + /** + * True if all blocks were synchronized successfully, false otherwise. + */ + private get isLedgerInSync(): boolean { + return this.failedBlocks.size === 0; + } + + /** + * Add new plugin operation that will show up in status report. + * Remember to remove this operation with `removeTrackedOperation` after it's finished. + * + * @param id unique id of the operation (use `uuid`) + * @param operation operation name to show up in the status report + */ + private addTrackedOperation(id: string, operation: string): void { + if (this.trackedOperations.has(id)) { + this.log.error(`Operation with ID ${id} is already tracked!`); + return; + } + + this.trackedOperations.set(id, { + startAt: Date.now().toString(), + operation: operation, + }); + } + + /** + * Remove operation added with `addTrackedOperation`. + * If called with non-existent operation - nothing happens. + * + * @param id unique id of the operation (use `uuid`) + */ + private removeTrackedOperation(id: string): void { + this.trackedOperations.delete(id); + } + + /** + * Get `Ethers` instance of `Interface` class for specified token type. + * + * @param tokenType type of the token (ERC20, ERC721, etc...) + * @returns ethers.js `Interface` object + */ + private getEthersTokenInterface(tokenType: TokenTypeV1): EthersInterface { + switch (tokenType) { + case TokenTypeV1.ERC20: + return this.ethersInterfaceERC20; + case TokenTypeV1.ERC721: + return this.ethersInterfaceERC721; + default: + const unknownTokenType: never = tokenType; + throw new Error( + `getEthersTokenInterface(): Unknown token type: ${unknownTokenType}`, + ); + } + } + + /** + * Get transaction receipt from the ledger using the cactus connector. + * + * @param txId hash of the transaction to get. + * @returns `web3.js` transaction receipt object. + */ + private async getTransactionReceipt( + txId: string, + ): Promise { + const method = { type: "function", command: "getTransactionReceipt" }; + const args = { args: [txId] }; + const response = await this.apiClient.sendSyncRequest({}, method, args); + + if (response && response.data && response.data.txReceipt) { + return response.data.txReceipt; + } else { + throw new Error( + `Could not get transaction receipt for transaction ID ${txId}`, + ); + } + } + + /** + * Get block data from the ledger using the cactus connector. + * + * @param blockNumber number, hash or keyword description of a block to read. + * @param returnTransactionObjects boolean flag to return full transaction object or just the hashes. + * @returns block data (with transactions if `returnTransactionObjects` was true) + */ + private async getBlockFromLedger( + blockNumber: number | string, + returnTransactionObjects = false, + ): Promise { + const method = { type: "function", command: "getBlock" }; + const args = { args: [blockNumber, returnTransactionObjects] }; + const response = await this.apiClient.sendSyncRequest({}, method, args); + + if (response && response.data && response.data.blockData) { + return response.data.blockData; + } else { + throw new Error( + `Could not get block with number ${blockNumber} from the ledger`, + ); + } + } + + /** + * Check if token with specified ID exist on contract connected to `tokenClient`. + * If so, insert/update it's entry in the database. + * + * @param tokenId numeric ID of the issued token. + * @param tokenClient client with token contract details. + * @returns `true` if token ID was valid, `false` otherwise. + */ + private async syncSingleERC721Token( + tokenId: number, + tokenClient: TokenClientERC721, + ): Promise { + let ownerAddress; + try { + ownerAddress = await tokenClient.ownerOf(tokenId); + } catch (error: unknown) { + this.log.debug( + "Calling ownerOf() failed, assuming all tokens are synchronized - stop.", + ); + return false; + } + + if (parseInt(ownerAddress, 16) === 0) { + this.log.debug(`Found token ID ${tokenId} without the owner - stop.`); + return false; + } + + try { + // Add token if not present yet + const checkedOwnerAddress = normalizeAddress(ownerAddress); + const tokenUri = await tokenClient.tokenURI(tokenId); + + await this.dbClient.upsertTokenERC721({ + account_address: checkedOwnerAddress, + token_address: normalizeAddress(tokenClient.address), + uri: tokenUri, + token_id: tokenId, + }); + } catch (err) { + this.log.error(`Could not store issued ERC721 token: ID ${tokenId}`, err); + // We return true since failure here means that there might be more tokens to synchronize + } + + return true; + } + + /** + * Synchronize all the issued tokens for specified ERC721 contract. + * Method assumes the token ID are incrementing (starting from 1). + * + * @param contractAddress ERC721 token contract address. + * @returns number of token synchronized. + */ + private async syncERC721TokensForContract( + contractAddress: string, + ): Promise { + const tokenClient = new TokenClientERC721(this.apiClient, contractAddress); + + let tokenId = 1; + while (await this.syncSingleERC721Token(tokenId, tokenClient)) { + tokenId++; + } + + return tokenId - 1; + } + + /** + * Parse a new block received from the connector, parse it and push to the database. + * Gap between last seen block and current one is checked and missing blocks are filled when necessary. + * Token balances are updated in the end (if the database is in sync). + * + * @param block `web3.js` block data object. + */ + private async pushNewBlock(block: BlockTransactionObject): Promise { + // Push one block at a time, in case previous block is still being processed + // (example: filling the gap takes longer than expected) + await this.pushBlockMutex.runExclusive(async () => { + const previousBlockNumber = this.lastSeenBlock; + + try { + this.lastSeenBlock = block.number; + await this.parseBlockData(block); + } catch (error: unknown) { + this.log.warn( + `Could not add new block #${block.number}, error:`, + error, + ); + this.addFailedBlock(block.number); + } + + const isGap = block.number - previousBlockNumber > 1; + if (isGap) { + const gapFrom = previousBlockNumber + 1; + const gapTo = block.number - 1; + try { + await this.syncBlocks(gapFrom, gapTo); + } catch (error: unknown) { + this.log.warn( + `Could not sync blocks in a gap between #${gapFrom} and #${gapTo}, error:`, + error, + ); + for (let i = gapFrom; i < gapTo; i++) { + this.addFailedBlock(i); + } + } + } + + try { + await this.syncTokenBalances(previousBlockNumber); + } catch (error: unknown) { + this.log.warn( + `Could not sync token balances after adding block #${block.number}, error:`, + error, + ); + return; + } + }); + } + + /** + * Try to decode method name using known token contract definition. + * If the name could not be decoded, empty string is returned (`""`) + * + * @param tx `web.js` transaction object. + * @param tokenType type of the token we expect (ERC20, ERC721, etc...) + * @returns name of the method or an empty string + */ + private decodeTokenMethodName( + tx: Transaction, + tokenType: TokenTypeV1, + ): string { + try { + const tokenInterface = this.getEthersTokenInterface(tokenType); + const decodedTx = tokenInterface.parseTransaction({ + data: tx.input, + value: tx.value, + }); + return decodedTx.name; + } catch { + this.log.debug("Could not decode transaction with token contract"); + } + + return ""; + } + + /** + * Decode any token transfers that occurred in specified transaction. + * + * @param txReceipt `web3.js` receipt of the transaction object. + * @param tokenType type of the token we expect (ERC20, ERC721, etc...) + * @returns list of detected token transfers + */ + private decodeTokenTransfers( + txReceipt: TransactionReceipt, + tokenType: TokenTypeV1, + ): BlockDataTransferInput[] { + const tokenInterface = this.getEthersTokenInterface(tokenType); + + const transferLogs = txReceipt.logs + .map((l) => + tokenInterface.parseLog({ + data: l.data, + topics: l.topics, + }), + ) + .filter((ld) => ld.name === "Transfer"); + + return transferLogs.map((t) => { + switch (tokenType) { + case TokenTypeV1.ERC20: + return { + sender: normalizeAddress(t.args["from"]), + recipient: normalizeAddress(t.args["to"]), + value: t.args["value"].toString(), + }; + case TokenTypeV1.ERC721: + return { + sender: normalizeAddress(t.args["from"]), + recipient: normalizeAddress(t.args["to"]), + value: t.args["tokenId"].toString(), + }; + default: + const unknownTokenType: never = tokenType; + throw new Error( + `decodeTokenTransfers(): Unknown token type: ${unknownTokenType}`, + ); + } + }); + } + + /** + * Parse single transaction and possible token transfers from a block. + * + * @param tx `web3.js` transaction object. + * @returns parsed transaction data + */ + private async parseBlockTransaction( + tx: Transaction, + ): Promise { + this.log.debug("parseBlockTransaction(): Parsing ", tx.hash); + + const txReceipt = await this.getTransactionReceipt(tx.hash); + let methodName = ""; + let tokenTransfers: BlockDataTransferInput[] = []; + + const targetTokenMetadata = this.monitoredTokens.get( + normalizeAddress(txReceipt.to), + ); + if (targetTokenMetadata) { + methodName = this.decodeTokenMethodName(tx, targetTokenMetadata.type); + tokenTransfers = this.decodeTokenTransfers( + txReceipt, + targetTokenMetadata.type, + ); + } + + return { + hash: tx.hash, + index: txReceipt.transactionIndex, + from: normalizeAddress(tx.from), + to: normalizeAddress(tx.to ?? undefined), + eth_value: parseInt(tx.value, 10), + method_signature: tx.input.slice(0, 10), + method_name: methodName, + token_transfers: tokenTransfers, + }; + } + + /** + * Update the token balance tables (starting from specified block for performance). + * Operation will not run if ledger is out of sync (i.e. some blocks failed to be synchronized). + * Running this method will try to push the failed blocks again first. + * + * @param fromBlockNumber block from which to start the token balance update. + */ + private async syncTokenBalances(fromBlockNumber: number): Promise { + await this.syncTokenBalancesMutex.runExclusive(async () => { + const blocksRestored = await this.syncFailedBlocks(); + const oldestBlockRestored = Math.min(...blocksRestored, fromBlockNumber); + + if (this.isLedgerInSync) { + this.log.debug("Update token balances from block", oldestBlockRestored); + await this.dbClient.syncTokenBalanceERC20(); + await this.dbClient.syncTokenBalanceERC721(oldestBlockRestored); + } else { + this.log.warn( + "Ledger not in sync (some blocks are missing), token balance not updated!", + ); + } + }); + } + + /** + * Add a block to failed blocks list. + * This method first ensures that the block is not present in the database. + * If it's not, new block is added to failed blocks and the plugin is out of sync. + * Failed blocks can be retried again with `syncFailedBlocks()` method. + * + * @param blockNumber block number to be added to failed blocks list. + */ + private async addFailedBlock(blockNumber: number) { + try { + const block = await this.dbClient.getBlock(blockNumber); + + if (((block.number as unknown) as string) !== blockNumber.toString()) { + throw new Error("Invalid response from the DB"); + } + + this.log.debug( + `Block #${blockNumber} already present in DB - remove from the failed pool.`, + ); + this.failedBlocks.delete(blockNumber); + } catch (error: unknown) { + this.log.info( + `Block #${blockNumber} not found in the DB - add to the failed blocks pool. Message:`, + error, + ); + this.failedBlocks.add(blockNumber); + } + } + + /** + * Synchronize blocks in specified range. + * Only the blocks not already present in the database from specified range will be pushed. + * + * @warn This operation can take a long time to finish if you specify a wide range! + * + * @param startBlockNumber starting block number (including) + * @param endBlockNumber ending block number (including) + */ + private async syncBlocks( + startBlockNumber: number, + endBlockNumber: number, + ): Promise { + // Only one block synchronization can run at a time to prevent data race. + await this.syncBlocksMutex.runExclusive(async () => { + this.log.info( + "Synchronize blocks from", + startBlockNumber, + "to", + endBlockNumber, + ); + + const missingBlockNumbers = await this.dbClient.getMissingBlocksInRange( + startBlockNumber, + endBlockNumber, + ); + + for (const n of missingBlockNumbers.map((r) => r.block_number)) { + try { + const block = await this.getBlockFromLedger(n, true); + await this.parseBlockData(block); + } catch (error: unknown) { + this.log.warn(`Could not synchronize block #${n}, error:`, error); + this.addFailedBlock(n); + } + } + }); + } + + public getInstanceId(): string { + return this.instanceId; + } + + public getPackageName(): string { + return `@hyperledger/cactus-plugin-persistence-ethereum`; + } + + /** + * Get OpenAPI definition for this plugin. + * @returns OpenAPI spec object + */ + public getOpenApiSpec(): unknown { + return OAS; + } + + /** + * Should be called before using the plugin. + * Connects to the database and initializes the plugin schema and status entry. + * Fetches tokens to be monitored and stores them in local memory. + */ + public async onPluginInit(): Promise { + await this.dbClient.connect(); + await this.dbClient.initializePlugin( + PluginPersistenceEthereum.CLASS_NAME, + this.instanceId, + ); + await this.refreshMonitoredTokens(); + this.isConnected = true; + } + + /** + * Close the connection to the DB, cleanup any allocated resources. + */ + public async shutdown(): Promise { + this.apiClient.close(); + await this.dbClient.shutdown(); + this.isConnected = false; + } + + /** + * Register all the plugin endpoints on supplied `Express` server. + * + * @param app `Express.js` server. + * @returns list of registered plugin endpoints. + */ + public async registerWebServices( + app: Express, + ): Promise { + const webServices = await this.getOrCreateWebServices(); + webServices.forEach((ws) => ws.registerExpress(app)); + this.isWebServicesRegistered = true; + return webServices; + } + + /** + * Create plugin endpoints and return them. + * If method was already called, the set of endpoints created on the first run is used. + * @returns list of plugin endpoints. + */ + public async getOrCreateWebServices(): Promise { + const { log } = this; + const pkgName = this.getPackageName(); + + if (this.endpoints) { + return this.endpoints; + } + log.info(`Creating web services for plugin ${pkgName}...`); + + const endpoints: IWebServiceEndpoint[] = []; + { + const endpoint = new StatusEndpointV1({ + connector: this, + logLevel: this.options.logLevel, + }); + endpoints.push(endpoint); + } + this.endpoints = endpoints; + + log.info(`Instantiated web services for plugin ${pkgName} OK`, { + endpoints, + }); + return endpoints; + } + + /** + * Get status report of this instance of persistence plugin. + * @returns Status report object + */ + public getStatus(): StatusResponseV1 { + return { + instanceId: this.instanceId, + connected: this.isConnected, + webServicesRegistered: this.isWebServicesRegistered, + monitoredTokensCount: this.monitoredTokens.size, + operationsRunning: Array.from(this.trackedOperations.values()), + monitorRunning: this.watchBlocksSubscription !== undefined, + lastSeenBlock: this.lastSeenBlock, + }; + } + + /** + * Fetch the metadata of all tokens to be monitored by this persistence plugin. + * List is saved internally (in the plugin). + * + * @returns List of monitored tokens. + */ + public async refreshMonitoredTokens(): Promise> { + this.log.info("refreshMonitoredTokens() started..."); + const newTokensMap = new Map(); + + // Fetch ERC20 tokens + const allERC20Tokens = await this.dbClient.getTokenMetadataERC20(); + this.log.debug("Received ERC20 tokens:", allERC20Tokens); + allERC20Tokens.forEach((token) => { + newTokensMap.set(normalizeAddress(token.address), { + type: TokenTypeV1.ERC20, + name: token.name, + symbol: token.symbol, + }); + }); + + // Fetch ERC721 tokens + const allERC721Tokens = await this.dbClient.getTokenMetadataERC721(); + this.log.debug("Received ERC721 tokens:", allERC721Tokens); + allERC721Tokens.map((token) => { + newTokensMap.set(normalizeAddress(token.address), { + type: TokenTypeV1.ERC721, + name: token.name, + symbol: token.symbol, + }); + }); + + // Update tokens + this.monitoredTokens = newTokensMap; + this.log.info( + "refreshMonitoredTokens() finished. New monitored tokens count:", + this.monitoredTokens.size, + ); + this.log.debug("monitoredTokens:", this.monitoredTokens); + return this.monitoredTokens; + } + + /** + * Synchronize issued tokens for all ERC721 token contract monitored by this persistence plugin. + * + * @warn We assume the token ID increases starting from 1. + * @todo Support more ways to sync the tokens: + * - Parse all `Transfer` events emitted by the contract. + * - Support ERC721 Enumerable + * - The type of a sync method could be defined in the token definition. + * @todo Use `Multicall.js` or something similar in the connector to improve performance. + */ + public async syncERC721Tokens(): Promise { + const operationId = uuidv4(); + this.addTrackedOperation(operationId, "syncERC721Tokens"); + + try { + const tokenAddresses = []; + for (const [address, token] of this.monitoredTokens) { + if (token.type === TokenTypeV1.ERC721) { + tokenAddresses.push(normalizeAddress(address)); + } + } + this.log.info( + `Sync issued tokens for ${tokenAddresses.length} contracts.`, + ); + + for (const contractAddress of tokenAddresses) { + try { + this.log.debug( + "Synchronize issued ERC721 tokens of contract", + contractAddress, + ); + const syncTokenCount = await this.syncERC721TokensForContract( + contractAddress, + ); + this.log.info( + `Synchronized ${syncTokenCount} tokens for contract ${contractAddress}`, + ); + } catch (error: unknown) { + this.log.error( + `Token sync FAILED for contract address: ${contractAddress}, error:`, + error, + ); + } + } + } catch (error: unknown) { + this.log.error(`syncERC721Tokens failed with exception:`, error); + } finally { + this.removeTrackedOperation(operationId); + } + } + + /** + * Start the block monitoring process. New blocks from the ledger will be parsed and pushed to the database. + * Use `stopMonitor()` to cancel this operation. + * + * @warn + * Before the monitor starts, the database will be synchronized with current ledger state. + * This operation can take a while, but will ensure that the ledger archive is complete. + * + * @param onError callback method that will be called on error. + */ + public async startMonitor(onError?: (err: unknown) => void): Promise { + // Synchronize the current DB state + this.lastSeenBlock = await this.syncAll(); + + const blocksObservable = this.apiClient.watchBlocksV1({ + allBlocks: true, + }); + + if (!blocksObservable) { + throw new Error( + "Could not get a valid blocks observable in startMonitor", + ); + } + + this.watchBlocksSubscription = blocksObservable.subscribe({ + next: async (event: unknown) => { + try { + this.log.debug("Received new block."); + + const ledgerEvent = event as SocketLedgerEvent; + if (!ledgerEvent || ledgerEvent.status !== 200) { + this.log.warn("Received invalid block ledger event:", ledgerEvent); + return; + } + + const block = (ledgerEvent.blockData as unknown) as BlockTransactionObject; + await this.pushNewBlock(block); + } catch (error: unknown) { + this.log.error("Unexpected error when pushing new block:", error); + } + }, + error: (err) => { + this.log.error("Error when watching for new blocks, err:", err); + + if (onError) { + try { + onError(err); + } catch (error: unknown) { + this.log.error( + "Unexpected error in onError monitor handler:", + error, + ); + } + } + }, + complete: () => { + this.log.info("Watch completed"); + if (this.watchBlocksSubscription) { + this.watchBlocksSubscription.unsubscribe(); + } + this.watchBlocksSubscription = undefined; + }, + }); + } + + /** + * Stop the block monitoring process. + * If the monitoring wasn't running - nothing happens. + */ + public stopMonitor(): void { + if (this.watchBlocksSubscription) { + this.watchBlocksSubscription.unsubscribe(); + this.watchBlocksSubscription = undefined; + this.log.info("stopMonitor(): Done."); + } + } + + /** + * Add new ERC20 token to be monitored by this plugin. + * @param address ERC20 contract address. + */ + public async addTokenERC20(address: string): Promise { + const checkedAddress = normalizeAddress(address); + this.log.info( + "Add ERC20 token to monitor changes on it. Address:", + checkedAddress, + ); + + const tokenClient = new TokenClientERC20(this.apiClient, checkedAddress); + + try { + await this.dbClient.insertTokenMetadataERC20({ + address: checkedAddress, + name: await tokenClient.name(), + symbol: await tokenClient.symbol(), + total_supply: parseInt(await tokenClient.totalSupply(), 10), + }); + } catch (err: unknown) { + throw new RuntimeError( + `Could not store ERC20 token metadata information`, + getRuntimeErrorCause(err), + ); + } + + await this.refreshMonitoredTokens(); + } + + /** + * Add new ERC721 token to be monitored by this plugin. + * @param address ERC721 contract address. + */ + public async addTokenERC721(address: string): Promise { + const checkedAddress = normalizeAddress(address); + this.log.info( + "Add ERC721 token to monitor changes on it. Address:", + checkedAddress, + ); + + const tokenClient = new TokenClientERC721(this.apiClient, checkedAddress); + + try { + await this.dbClient.insertTokenMetadataERC721({ + address: checkedAddress, + name: await tokenClient.name(), + symbol: await tokenClient.symbol(), + }); + } catch (err: unknown) { + throw new RuntimeError( + `Could not store ERC721 token metadata information`, + getRuntimeErrorCause(err), + ); + } + + await this.refreshMonitoredTokens(); + } + + /** + * Parse entire block data, detect possible token transfer operations and store the new block data to the database. + * Note: token balances are not updated. + * + * @param block `web3.js` block object. + */ + public async parseBlockData(block: BlockTransactionObject): Promise { + try { + // Note: Use batching / synchronous loop if there are performance issues for large blocks. + const transactions = await Promise.all( + block.transactions.map((tx) => this.parseBlockTransaction(tx)), + ); + + if (typeof block.timestamp === "string") { + block.timestamp = parseInt(block.timestamp, 10); + } + const blockTimestamp = new Date(block.timestamp * 1000); + const blockCreatedAt = blockTimestamp.toUTCString(); + this.log.debug("Block created at:", blockCreatedAt); + + await this.dbClient.insertBlockData({ + block: { + number: block.number, + created_at: blockCreatedAt, + hash: block.hash, + number_of_tx: transactions.length, + }, + transactions, + }); + } catch (error: unknown) { + const message = `Parsing block #${block.number} failed: ${error}`; + this.log.error(message); + throw new RuntimeError(message, getRuntimeErrorCause(error)); + } + } + + /** + * Walk through all the blocks that could not be synchronized with the DB for some reasons and try pushing them again. + * Blocks will remain on "failed blocks" list until it's successfully pushed to the database. + * We can't calculate token balances until all failed blocks are pushed to the server (plugin will remain out of sync until then). + * + * @todo Add automatic tests for this method. + * + * @returns list of restored blocks + */ + public async syncFailedBlocks(): Promise { + const blocksRestored: number[] = []; + const operationId = uuidv4(); + this.addTrackedOperation(operationId, "syncFailedBlocks"); + + try { + for (const n of this.failedBlocks) { + try { + const block = await this.getBlockFromLedger(n, true); + await this.parseBlockData(block); + this.failedBlocks.delete(n); + blocksRestored.push(n); + } catch (error: unknown) { + this.log.warn(`Could not sync failed block #${n}, error:`, error); + } + } + + if (blocksRestored) { + this.log.info("Restored following failed blocks:", blocksRestored); + } + } finally { + this.removeTrackedOperation(operationId); + } + + return blocksRestored; + } + + /** + * Synchronize entire ledger state with the database. + * - Synchronize all blocks that failed to synchronize until now. + * - Detect any other missing blocks between the database and the ledger, push them to the DB. + * - Update the token balances if the database is in sync. + * + * @warn This operation can take a long time to finish! + * @returns latest synchronized block number. + */ + public async syncAll(): Promise { + const operationId = uuidv4(); + this.addTrackedOperation(operationId, "syncAll"); + + try { + this.log.info("syncAll() started..."); + + await this.syncFailedBlocks(); + + const block = await this.getBlockFromLedger("latest"); + await this.syncBlocks(1, block.number); + + await this.syncTokenBalances(1); + + return block.number; + } finally { + this.removeTrackedOperation(operationId); + } + } +} diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/public-api.ts b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/public-api.ts new file mode 100755 index 0000000000..12237d804f --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/public-api.ts @@ -0,0 +1,14 @@ +import type { IPluginFactoryOptions } from "@hyperledger/cactus-core-api"; +import { PluginFactoryLedgerPersistence } from "./plugin-factory-persistence-ethereum"; + +export { PluginFactoryLedgerPersistence } from "./plugin-factory-persistence-ethereum"; +export { + PluginPersistenceEthereum, + IPluginPersistenceEthereumOptions, +} from "./plugin-persistence-ethereum"; + +export async function createPluginFactory( + pluginFactoryOptions: IPluginFactoryOptions, +): Promise { + return new PluginFactoryLedgerPersistence(pluginFactoryOptions); +} diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/token-client/base-token-client.ts b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/token-client/base-token-client.ts new file mode 100644 index 0000000000..90ac7669c7 --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/token-client/base-token-client.ts @@ -0,0 +1,132 @@ +/** + * Base class for calling ethereum token contract methods. + */ + +import type { SocketIOApiClient } from "@hyperledger/cactus-api-client"; +import { + Checks, + Logger, + LoggerProvider, + LogLevelDesc, +} from "@hyperledger/cactus-common"; +import { RuntimeError } from "run-time-error"; +import { getRuntimeErrorCause } from "../utils"; + +export type EthereumContractMethodType = + | "call" + | "send" + | "estimateGas" + | "encodeABI"; + +/** + * Base class for calling ethereum token contract methods. + * Can be extended by other, token specific classes. + */ +export default class TokenClient { + private apiClient: SocketIOApiClient; + protected contract: { abi: unknown; address: string }; + protected log: Logger; + public static readonly CLASS_NAME: string = "TokenClient"; + + /** + * Get this class name + * @returns class name string + */ + getClassName(): string { + return TokenClient.CLASS_NAME; + } + + constructor( + apiClient: SocketIOApiClient, + abi: unknown, + address: string, + logLevel: LogLevelDesc = "info", + ) { + const fnTag = `${TokenClient.CLASS_NAME}#constructor()`; + Checks.truthy(apiClient, `${fnTag} arg verifierEthereum`); + Checks.truthy(abi, `${fnTag} arg abi`); + Checks.truthy(address, `${fnTag} arg address`); + + this.apiClient = apiClient; + + this.contract = { + abi, + address, + }; + + this.log = LoggerProvider.getOrCreate({ + level: logLevel, + label: this.getClassName(), + }); + } + + /** + * Call specific contract method with some args. + * Throws on error. + * + * @param type method execution type (`call`, `send`, etc...) + * @param method contract method name + * @param ...args contract method arguments (any number) + * @returns response from the method execution. + */ + protected async contractMethod( + type: EthereumContractMethodType, + method: string, + ...args: unknown[] + ): Promise { + this.log.debug(`Execute contract method ${method} using '${type}'`); + + try { + const reqMethod = { + type: "contract", + command: method, + function: type, + }; + const reqArgs = { args: args }; + + const response = await this.apiClient.sendSyncRequest( + this.contract, + reqMethod, + reqArgs, + ); + this.log.debug("Executing contract method status:", response.status); + + if (response.status != 200) { + throw new Error(response); + } + + return response.data; + } catch (err: unknown) { + throw new RuntimeError( + `Calling contract method ${method} with args ${args} failed`, + getRuntimeErrorCause(err), + ); + } + } + + /** + * Execute method on a contract using `call` + * @param method contract method name + * @param ...args contract method arguments (any number) + * @returns response from the method execution. + */ + protected async callContractMethod( + method: string, + ...args: unknown[] + ): Promise { + return await this.contractMethod("call", method, ...args); + } + + /** + * Execute method on a contract using `send` + * @param method contract method name + * @param ...args contract method arguments (any number) + * @returns response from the method execution. + */ + protected async sendContractMethod( + method: string, + ...args: unknown[] + ): Promise { + return await this.contractMethod("send", method, ...args); + } +} diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/token-client/token-client-erc20.ts b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/token-client/token-client-erc20.ts new file mode 100644 index 0000000000..b5da91f7ce --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/token-client/token-client-erc20.ts @@ -0,0 +1,95 @@ +/** + * Client for calling methods on ERC20 token contract. + */ + +import type { SocketIOApiClient } from "@hyperledger/cactus-api-client"; +import type { LogLevelDesc } from "@hyperledger/cactus-common"; +import TokenClient from "./base-token-client"; +import ERC20_ABI from "../../json/contract_abi/Erc20Token.json"; +import { RuntimeError } from "run-time-error"; + +/** + * Client for calling methods on ERC20 token contract. + */ +export default class TokenClientERC20 extends TokenClient { + public static readonly CLASS_NAME: string = "TokenClientERC20"; + + /** + * Get this class name + * @returns class name string + */ + getClassName(): string { + return TokenClientERC20.CLASS_NAME; + } + + constructor( + apiClient: SocketIOApiClient, + address: string, + logLevel: LogLevelDesc = "info", + ) { + super(apiClient, ERC20_ABI, address, logLevel); + this.log.debug("TokenClientERC20 created"); + } + + /** + * Internal method for writing consistent log message describing operation executed. + * + * @param methodName Name of the method called. + */ + private logOperation(methodName: string): void { + this.log.info( + `Call '${methodName}' on ERC20 contract at ${this.contract.address}`, + ); + } + + /** + * Get name of the token. + * @returns token name + */ + public async name(): Promise { + this.logOperation("name"); + + const response = await this.callContractMethod("name"); + if (typeof response !== "string") { + throw new RuntimeError( + `Unexpected response type received for method 'name': ${response}`, + ); + } + + return response; + } + + /** + * Get symbol of the token. + * @returns token symbol + */ + public async symbol(): Promise { + this.logOperation("symbol"); + + const response = await this.callContractMethod("symbol"); + if (typeof response !== "string") { + throw new RuntimeError( + `Unexpected response type received for method 'symbol': ${response}`, + ); + } + + return response; + } + + /** + * Get total supply of the token. + * @returns token total supply + */ + public async totalSupply(): Promise { + this.logOperation("totalSupply"); + + const response = await this.callContractMethod("totalSupply"); + if (typeof response !== "string") { + throw new RuntimeError( + `Unexpected response type received for method 'totalSupply': ${response}`, + ); + } + + return response; + } +} diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/token-client/token-client-erc721.ts b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/token-client/token-client-erc721.ts new file mode 100644 index 0000000000..742d47bff2 --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/token-client/token-client-erc721.ts @@ -0,0 +1,114 @@ +/** + * Client for calling methods on ERC721 token contract. + */ + +import type { SocketIOApiClient } from "@hyperledger/cactus-api-client"; +import type { LogLevelDesc } from "@hyperledger/cactus-common"; +import TokenClient from "./base-token-client"; +import ERC721_ABI from "../../json/contract_abi/Erc721Full.json"; +import { RuntimeError } from "run-time-error"; + +/** + * Client for calling methods on ERC721 token contract. + */ +export default class TokenClientERC721 extends TokenClient { + public static readonly CLASS_NAME: string = "TokenClientERC721"; + + /** + * Get this class name + * @returns class name string + */ + getClassName(): string { + return TokenClientERC721.CLASS_NAME; + } + + constructor( + apiClient: SocketIOApiClient, + public address: string, + logLevel: LogLevelDesc = "info", + ) { + super(apiClient, ERC721_ABI, address, logLevel); + this.log.debug("TokenClientERC721 created"); + } + + /** + * Internal method for writing consistent log message describing operation executed. + * + * @param methodName Name of the method called. + */ + private logOperation(methodName: string): void { + this.log.info( + `Call '${methodName}' on ERC721 contract at ${this.contract.address}`, + ); + } + + /** + * Get name of the token. + * @returns token name + */ + async name(): Promise { + this.logOperation("name"); + + const response = await this.callContractMethod("name"); + if (typeof response !== "string") { + throw new RuntimeError( + `Unexpected response type received for method 'name': ${response}`, + ); + } + + return response; + } + + /** + * Get symbol of the token. + * @returns token symbol + */ + async symbol(): Promise { + this.logOperation("symbol"); + + const response = await this.callContractMethod("symbol"); + if (typeof response !== "string") { + throw new RuntimeError( + `Unexpected response type received for method 'symbol': ${response}`, + ); + } + + return response; + } + + /** + * Get owner of an issued token. + * @param tokenId token id to check + * @returns address of owner of the token + */ + async ownerOf(tokenId: number): Promise { + this.logOperation("ownerOf"); + + const response = await this.callContractMethod("ownerOf", tokenId); + if (typeof response !== "string") { + throw new RuntimeError( + `Unexpected response type received for method 'ownerOf': ${response}`, + ); + } + + return response; + } + + /** + * Get URI of an issued token. + * @param tokenId token id to check + * @returns URI of the token + */ + async tokenURI(tokenId: number): Promise { + this.logOperation("tokenURI"); + + const response = await this.callContractMethod("tokenURI", tokenId); + if (typeof response !== "string") { + throw new RuntimeError( + `Unexpected response type received for method 'tokenURI': ${response}`, + ); + } + + return response; + } +} diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/utils.ts b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/utils.ts new file mode 100644 index 0000000000..57de993eaf --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/utils.ts @@ -0,0 +1,42 @@ +/** + * Helper methods + */ + +import web3Utils from "web3-utils"; +import { RuntimeError } from "run-time-error"; + +/** + * Get error cause for RuntimeError (instance of `Error`, string or undefined) + * @param err unknown error type. + * @returns valid `RuntimeError` cause + */ +export function getRuntimeErrorCause(err: unknown): Error | string | undefined { + if (err instanceof Error || typeof err === "string") { + return err; + } + + return undefined; +} + +/** + * Convert supplied address to checksum form, ensure it's a valid ethereum address afterwards. + * In case of error an exception is thrown. + * + * @param address ethereum hex address to normalize + * @returns valid checksum address + */ +export function normalizeAddress(address?: string): string { + if (!address) { + return ""; + } + + const checksumAddress = web3Utils.toChecksumAddress(address); + + if (!web3Utils.isAddress(checksumAddress)) { + throw new RuntimeError( + `Provided address ${address} is not a valid ethereum address!`, + ); + } + + return checksumAddress; +} diff --git a/packages/cactus-plugin-persistence-ethereum/src/main/typescript/web-services/status-endpoint-v1.ts b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/web-services/status-endpoint-v1.ts new file mode 100644 index 0000000000..54d759db52 --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/main/typescript/web-services/status-endpoint-v1.ts @@ -0,0 +1,127 @@ +/** + * OpenAPI endpoint (GET) for reading status of the persistence plugin. + */ + +import { + Logger, + Checks, + LogLevelDesc, + LoggerProvider, + IAsyncProvider, +} from "@hyperledger/cactus-common"; +import type { + IEndpointAuthzOptions, + IExpressRequestHandler, + IWebServiceEndpoint, +} from "@hyperledger/cactus-core-api"; +import { registerWebServiceEndpoint } from "@hyperledger/cactus-core"; + +import { PluginPersistenceEthereum } from "../plugin-persistence-ethereum"; +import OAS from "../../json/openapi.json"; + +import type { Express, Request, Response } from "express"; +import safeStringify from "fast-safe-stringify"; +import sanitizeHtml from "sanitize-html"; + +export interface IStatusEndpointV1Options { + logLevel?: LogLevelDesc; + connector: PluginPersistenceEthereum; +} + +/** + * OpenAPI endpoint (GET) for reading status of the persistence plugin. + */ +export class StatusEndpointV1 implements IWebServiceEndpoint { + public static readonly CLASS_NAME = "StatusEndpointV1"; + + private readonly log: Logger; + + public get className(): string { + return StatusEndpointV1.CLASS_NAME; + } + + constructor(public readonly options: IStatusEndpointV1Options) { + const fnTag = `${this.className}#constructor()`; + Checks.truthy(options, `${fnTag} arg options`); + Checks.truthy(options.connector, `${fnTag} arg options.connector`); + + const level = this.options.logLevel || "INFO"; + const label = this.className; + this.log = LoggerProvider.getOrCreate({ level, label }); + } + + public getOasPath(): any { + return OAS.paths[ + "/api/v1/plugins/@hyperledger/cactus-plugin-persistence-ethereum/status" + ]; + } + + public getPath(): string { + const apiPath = this.getOasPath(); + return apiPath.get["x-hyperledger-cactus"].http.path; + } + + public getVerbLowerCase(): string { + const apiPath = this.getOasPath(); + return apiPath.get["x-hyperledger-cactus"].http.verbLowerCase; + } + + public getOperationId(): string { + return this.getOasPath().get.operationId; + } + + getAuthorizationOptionsProvider(): IAsyncProvider { + // TODO: make this an injectable dependency in the constructor + return { + get: async () => ({ + isProtected: true, + requiredRoles: [], + }), + }; + } + + public async registerExpress( + expressApp: Express, + ): Promise { + await registerWebServiceEndpoint(expressApp, this); + return this; + } + + public getExpressRequestHandler(): IExpressRequestHandler { + return this.handleRequest.bind(this); + } + + public async handleRequest(req: Request, res: Response): Promise { + const reqTag = `${this.getVerbLowerCase()} - ${this.getPath()}`; + this.log.debug(reqTag); + + try { + const resBody = this.options.connector.getStatus(); + res.status(200).json(resBody); + } catch (error: unknown) { + this.log.error(`Crash while serving ${reqTag}:`, error); + + if (error instanceof Error) { + const status = 500; + const message = "Internal Server Error"; + this.log.info(`${message} [${status}]`); + res.status(status).json({ + message, + error: sanitizeHtml(error.stack || error.message, { + allowedTags: [], + allowedAttributes: {}, + }), + }); + } else { + this.log.warn("Unexpected exception that is not instance of Error!"); + res.status(500).json({ + message: "Unexpected Error", + error: sanitizeHtml(safeStringify(error), { + allowedTags: [], + allowedAttributes: {}, + }), + }); + } + } + } +} diff --git a/packages/cactus-plugin-persistence-ethereum/src/test/solidity/TestERC20.json b/packages/cactus-plugin-persistence-ethereum/src/test/solidity/TestERC20.json new file mode 100644 index 0000000000..a2c68148e8 --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/test/solidity/TestERC20.json @@ -0,0 +1,290 @@ +{ + "data": { + "bytecode": { + "object": "60806040523480156200001157600080fd5b5060405162000d0e38038062000d0e8339810160408190526200003491620002ce565b6040518060400160405280600981526020017f54657374455243323000000000000000000000000000000000000000000000008152506040518060400160405280600381526020017f54323000000000000000000000000000000000000000000000000000000000008152508160039080519060200190620000b892919062000228565b508051620000ce90600490602084019062000228565b505050620000ec3382620000f3640100000000026401000000009004565b506200037e565b600160a060020a03821662000168576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f206164647265737300604482015260640160405180910390fd5b6200017f6000838364010000000062000223810204565b8060026000828254620001939190620002e8565b9091555050600160a060020a03821660009081526020819052604081208054839290620001c2908490620002e8565b9091555050604051818152600160a060020a038316906000907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9060200160405180910390a36200021f6000838364010000000062000223810204565b5050565b505050565b828054620002369062000328565b90600052602060002090601f0160209004810192826200025a5760008555620002a5565b82601f106200027557805160ff1916838001178555620002a5565b82800160010185558215620002a5579182015b82811115620002a557825182559160200191906001019062000288565b50620002b3929150620002b7565b5090565b5b80821115620002b35760008155600101620002b8565b600060208284031215620002e157600080fd5b5051919050565b6000821982111562000323577f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b500190565b6002810460018216806200033d57607f821691505b6020821081141562000378577f4e487b7100000000000000000000000000000000000000000000000000000000600052602260045260246000fd5b50919050565b610980806200038e6000396000f3fe608060405234801561001057600080fd5b50600436106100c6576000357c010000000000000000000000000000000000000000000000000000000090048063395093511161008e578063395093511461014057806370a082311461015357806395d89b411461017c578063a457c2d714610184578063a9059cbb14610197578063dd62ed3e146101aa57600080fd5b806306fdde03146100cb578063095ea7b3146100e957806318160ddd1461010c57806323b872dd1461011e578063313ce56714610131575b600080fd5b6100d36101bd565b6040516100e09190610862565b60405180910390f35b6100fc6100f7366004610838565b61024f565b60405190151581526020016100e0565b6002545b6040519081526020016100e0565b6100fc61012c3660046107fc565b610267565b604051601281526020016100e0565b6100fc61014e366004610838565b61028b565b6101106101613660046107a7565b600160a060020a031660009081526020819052604090205490565b6100d36102ad565b6100fc610192366004610838565b6102bc565b6100fc6101a5366004610838565b610357565b6101106101b83660046107c9565b610365565b6060600380546101cc906108f6565b80601f01602080910402602001604051908101604052809291908181526020018280546101f8906108f6565b80156102455780601f1061021a57610100808354040283529160200191610245565b820191906000526020600020905b81548152906001019060200180831161022857829003601f168201915b5050505050905090565b60003361025d818585610390565b5060019392505050565b6000336102758582856104ee565b61028085858561056b565b506001949350505050565b60003361025d81858561029e8383610365565b6102a891906108b7565b610390565b6060600480546101cc906108f6565b600033816102ca8286610365565b90508381101561034a5760405160e560020a62461bcd02815260206004820152602560248201527f45524332303a2064656372656173656420616c6c6f77616e63652062656c6f7760448201527f207a65726f00000000000000000000000000000000000000000000000000000060648201526084015b60405180910390fd5b6102808286868403610390565b60003361025d81858561056b565b600160a060020a03918216600090815260016020908152604080832093909416825291909152205490565b600160a060020a03831661040e5760405160e560020a62461bcd028152602060048201526024808201527f45524332303a20617070726f76652066726f6d20746865207a65726f2061646460448201527f72657373000000000000000000000000000000000000000000000000000000006064820152608401610341565b600160a060020a03821661048d5760405160e560020a62461bcd02815260206004820152602260248201527f45524332303a20617070726f766520746f20746865207a65726f20616464726560448201527f73730000000000000000000000000000000000000000000000000000000000006064820152608401610341565b600160a060020a0383811660008181526001602090815260408083209487168084529482529182902085905590518481527f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925910160405180910390a3505050565b60006104fa8484610365565b9050600019811461056557818110156105585760405160e560020a62461bcd02815260206004820152601d60248201527f45524332303a20696e73756666696369656e7420616c6c6f77616e63650000006044820152606401610341565b6105658484848403610390565b50505050565b600160a060020a0383166105ea5760405160e560020a62461bcd02815260206004820152602560248201527f45524332303a207472616e736665722066726f6d20746865207a65726f20616460448201527f64726573730000000000000000000000000000000000000000000000000000006064820152608401610341565b600160a060020a0382166106695760405160e560020a62461bcd02815260206004820152602360248201527f45524332303a207472616e7366657220746f20746865207a65726f206164647260448201527f65737300000000000000000000000000000000000000000000000000000000006064820152608401610341565b600160a060020a038316600090815260208190526040902054818110156106fb5760405160e560020a62461bcd02815260206004820152602660248201527f45524332303a207472616e7366657220616d6f756e742065786365656473206260448201527f616c616e636500000000000000000000000000000000000000000000000000006064820152608401610341565b600160a060020a038085166000908152602081905260408082208585039055918516815290812080548492906107329084906108b7565b9250508190555082600160a060020a031684600160a060020a03167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef8460405161077e91815260200190565b60405180910390a3610565565b8035600160a060020a03811681146107a257600080fd5b919050565b6000602082840312156107b957600080fd5b6107c28261078b565b9392505050565b600080604083850312156107dc57600080fd5b6107e58361078b565b91506107f36020840161078b565b90509250929050565b60008060006060848603121561081157600080fd5b61081a8461078b565b92506108286020850161078b565b9150604084013590509250925092565b6000806040838503121561084b57600080fd5b6108548361078b565b946020939093013593505050565b600060208083528351808285015260005b8181101561088f57858101830151858201604001528201610873565b818111156108a1576000604083870101525b50601f01601f1916929092016040019392505050565b600082198211156108f1577f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b500190565b60028104600182168061090a57607f821691505b60208210811415610944577f4e487b7100000000000000000000000000000000000000000000000000000000600052602260045260246000fd5b5091905056fea264697066735822122072fe34de87afcac38ed24187228460c1c1541afba6fc93753e2af5db4b45c9a264736f6c63430008070033" + } + }, + "abi": [ + { + "inputs": [ + { + "internalType": "uint256", + "name": "initialSupply", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "subtractedValue", + "type": "uint256" + } + ], + "name": "decreaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "addedValue", + "type": "uint256" + } + ], + "name": "increaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } + ] +} \ No newline at end of file diff --git a/packages/cactus-plugin-persistence-ethereum/src/test/solidity/TestERC20.sol b/packages/cactus-plugin-persistence-ethereum/src/test/solidity/TestERC20.sol new file mode 100644 index 0000000000..89e0fc1ab8 --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/test/solidity/TestERC20.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ***************************************************************************** +// IMPORTANT: If you update this code then make sure to recompile +// it and update the .json file as well so that they +// remain in sync for consistent test executions. +// With that said, there shouldn't be any reason to recompile this, like ever... +// ***************************************************************************** + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract TestErc20Token is ERC20 { + constructor(uint256 initialSupply) ERC20("TestERC20", "T20") { + _mint(msg.sender, initialSupply); + } +} diff --git a/packages/cactus-plugin-persistence-ethereum/src/test/solidity/TestERC721.json b/packages/cactus-plugin-persistence-ethereum/src/test/solidity/TestERC721.json new file mode 100644 index 0000000000..83c807aa74 --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/test/solidity/TestERC721.json @@ -0,0 +1,414 @@ +{ + "data": { + "bytecode": { + "object": "60806040523480156200001157600080fd5b506040518060400160405280600f81526020017f54657374457263373231546f6b656e00000000000000000000000000000000008152506040518060400160405280600481526020017f543732310000000000000000000000000000000000000000000000000000000081525081600090816200008f919062000200565b5060016200009e828262000200565b505050620000cd620000be620000d3640100000000026401000000009004565b640100000000620000d7810204565b620002d3565b3390565b60068054600160a060020a03838116600160a060020a0319831681179093556040519116919082907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e090600090a35050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b6002810460018216806200016d57607f821691505b602082108103620001a7577f4e487b7100000000000000000000000000000000000000000000000000000000600052602260045260246000fd5b50919050565b601f821115620001fb576000818152602081206020601f86010481016020861015620001d65750805b6020601f860104820191505b81811015620001f757828155600101620001e2565b5050505b505050565b815167ffffffffffffffff8111156200021d576200021d62000129565b62000235816200022e845462000158565b84620001ad565b602080601f831160018114620002715760008415620002545750858301515b60028086026008870290910a6000190419821617865550620001f7565b600085815260208120601f198616915b82811015620002a25788860151825594840194600190910190840162000281565b5085821015620002c357878501516008601f88160260020a60001904191681555b5050505050600202600101905550565b61177c80620002e36000396000f3fe608060405234801561001057600080fd5b5060043610610128576000357c010000000000000000000000000000000000000000000000000000000090048063715018a6116100bf578063a22cb4651161008e578063a22cb46514610238578063b88d4fde1461024b578063c87b56dd1461025e578063e985e9c514610271578063f2fde38b1461028457600080fd5b8063715018a6146102045780638da5cb5b1461020c57806395d89b411461021d578063a14481941461022557600080fd5b806323b872dd116100fb57806323b872dd146101aa57806342842e0e146101bd5780636352211e146101d057806370a08231146101e357600080fd5b806301ffc9a71461012d57806306fdde0314610155578063081812fc1461016a578063095ea7b314610195575b600080fd5b61014061013b36600461124f565b610297565b60405190151581526020015b60405180910390f35b61015d610334565b60405161014c91906112bc565b61017d6101783660046112cf565b6103c6565b604051600160a060020a03909116815260200161014c565b6101a86101a3366004611304565b6103ed565b005b6101a86101b836600461132e565b610529565b6101a86101cb36600461132e565b61055d565b61017d6101de3660046112cf565b610578565b6101f66101f136600461136a565b6105e0565b60405190815260200161014c565b6101a861067d565b600654600160a060020a031661017d565b61015d610691565b6101a8610233366004611304565b6106a0565b6101a8610246366004611385565b6106b6565b6101a86102593660046113f0565b6106c1565b61015d61026c3660046112cf565b6106fc565b61014061027f3660046114cc565b610795565b6101a861029236600461136a565b6107c3565b6000600160e060020a031982167f80ac58cd0000000000000000000000000000000000000000000000000000000014806102fa5750600160e060020a031982167f5b5e139f00000000000000000000000000000000000000000000000000000000145b8061032e57507f01ffc9a700000000000000000000000000000000000000000000000000000000600160e060020a03198316145b92915050565b606060008054610343906114ff565b80601f016020809104026020016040519081016040528092919081815260200182805461036f906114ff565b80156103bc5780601f10610391576101008083540402835291602001916103bc565b820191906000526020600020905b81548152906001019060200180831161039f57829003601f168201915b5050505050905090565b60006103d182610856565b50600090815260046020526040902054600160a060020a031690565b60006103f882610578565b905080600160a060020a031683600160a060020a0316036104895760405160e560020a62461bcd02815260206004820152602160248201527f4552433732313a20617070726f76616c20746f2063757272656e74206f776e6560448201527f720000000000000000000000000000000000000000000000000000000000000060648201526084015b60405180910390fd5b33600160a060020a03821614806104a557506104a58133610795565b61051a5760405160e560020a62461bcd02815260206004820152603d60248201527f4552433732313a20617070726f76652063616c6c6572206973206e6f7420746f60448201527f6b656e206f776e6572206f7220617070726f76656420666f7220616c6c0000006064820152608401610480565b61052483836108bd565b505050565b6105333382610938565b6105525760405160e560020a62461bcd02815260040161048090611552565b610524838383610997565b610524838383604051806020016040528060008152506106c1565b600081815260026020526040812054600160a060020a03168061032e5760405160e560020a62461bcd02815260206004820152601860248201527f4552433732313a20696e76616c696420746f6b656e20494400000000000000006044820152606401610480565b6000600160a060020a0382166106615760405160e560020a62461bcd02815260206004820152602960248201527f4552433732313a2061646472657373207a65726f206973206e6f74206120766160448201527f6c6964206f776e657200000000000000000000000000000000000000000000006064820152608401610480565b50600160a060020a031660009081526003602052604090205490565b610685610b37565b61068f6000610b94565b565b606060018054610343906114ff565b6106a8610b37565b6106b28282610bf3565b5050565b6106b2338383610c0d565b6106cb3383610938565b6106ea5760405160e560020a62461bcd02815260040161048090611552565b6106f684848484610cde565b50505050565b606061070782610856565b600061074360408051808201909152601481527f68747470733a2f2f6578616d706c652e636f6d2f000000000000000000000000602082015290565b90506000815111610763576040518060200160405280600081525061078e565b8061076d84610d14565b60405160200161077e9291906115af565b6040516020818303038152906040525b9392505050565b600160a060020a03918216600090815260056020908152604080832093909416825291909152205460ff1690565b6107cb610b37565b600160a060020a03811661084a5760405160e560020a62461bcd02815260206004820152602660248201527f4f776e61626c653a206e6577206f776e657220697320746865207a65726f206160448201527f64647265737300000000000000000000000000000000000000000000000000006064820152608401610480565b61085381610b94565b50565b600081815260026020526040902054600160a060020a03166108535760405160e560020a62461bcd02815260206004820152601860248201527f4552433732313a20696e76616c696420746f6b656e20494400000000000000006044820152606401610480565b6000818152600460205260409020805473ffffffffffffffffffffffffffffffffffffffff1916600160a060020a03841690811790915581906108ff82610578565b600160a060020a03167f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92560405160405180910390a45050565b60008061094483610578565b905080600160a060020a031684600160a060020a0316148061096b575061096b8185610795565b8061098f575083600160a060020a0316610984846103c6565b600160a060020a0316145b949350505050565b82600160a060020a03166109aa82610578565b600160a060020a0316146109d35760405160e560020a62461bcd028152600401610480906115de565b600160a060020a038216610a515760405160e560020a62461bcd028152602060048201526024808201527f4552433732313a207472616e7366657220746f20746865207a65726f2061646460448201527f72657373000000000000000000000000000000000000000000000000000000006064820152608401610480565b610a5e8383836001610db4565b82600160a060020a0316610a7182610578565b600160a060020a031614610a9a5760405160e560020a62461bcd028152600401610480906115de565b6000818152600460209081526040808320805473ffffffffffffffffffffffffffffffffffffffff19908116909155600160a060020a0387811680865260038552838620805460001901905590871680865283862080546001019055868652600290945282852080549092168417909155905184937fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef91a4505050565b600654600160a060020a0316331461068f5760405160e560020a62461bcd02815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e65726044820152606401610480565b60068054600160a060020a0383811673ffffffffffffffffffffffffffffffffffffffff19831681179093556040519116919082907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e090600090a35050565b6106b2828260405180602001604052806000815250610e3c565b81600160a060020a031683600160a060020a031603610c715760405160e560020a62461bcd02815260206004820152601960248201527f4552433732313a20617070726f766520746f2063616c6c6572000000000000006044820152606401610480565b600160a060020a03838116600081815260056020908152604080832094871680845294825291829020805460ff191686151590811790915591519182527f17307eab39ab6107e8899845ad3d59bd9653f200f220920489ca2b5937696c31910160405180910390a3505050565b610ce9848484610997565b610cf584848484610e72565b6106f65760405160e560020a62461bcd0281526004016104809061163b565b60606000610d2183610fa8565b600101905060008167ffffffffffffffff811115610d4157610d416113c1565b6040519080825280601f01601f191660200182016040528015610d6b576020820181803683370190505b5090508181016020015b600019017f3031323334353637383961626364656600000000000000000000000000000000600a86061a8153600a8504945084610d7557509392505050565b60018111156106f657600160a060020a03841615610dfa57600160a060020a03841660009081526003602052604081208054839290610df49084906116c7565b90915550505b600160a060020a038316156106f657600160a060020a03831660009081526003602052604081208054839290610e319084906116da565b909155505050505050565b610e46838361108a565b610e536000848484610e72565b6105245760405160e560020a62461bcd0281526004016104809061163b565b6000600160a060020a0384163b15610f9d576040517f150b7a02000000000000000000000000000000000000000000000000000000008152600160a060020a0385169063150b7a0290610ecf9033908990889088906004016116ed565b6020604051808303816000875af1925050508015610f0a575060408051601f3d908101601f19168201909252610f0791810190611729565b60015b610f6a573d808015610f38576040519150601f19603f3d011682016040523d82523d6000602084013e610f3d565b606091505b508051600003610f625760405160e560020a62461bcd0281526004016104809061163b565b805181602001fd5b600160e060020a0319167f150b7a020000000000000000000000000000000000000000000000000000000014905061098f565b506001949350505050565b6000807a184f03e93ff9f4daa797ed6e38ed64bf6a1f0100000000000000008310610ff1577a184f03e93ff9f4daa797ed6e38ed64bf6a1f010000000000000000830492506040015b6d04ee2d6d415b85acef8100000000831061101d576d04ee2d6d415b85acef8100000000830492506020015b662386f26fc10000831061103b57662386f26fc10000830492506010015b6305f5e1008310611053576305f5e100830492506008015b612710831061106757612710830492506004015b60648310611079576064830492506002015b600a831061032e5760010192915050565b600160a060020a0382166110e35760405160e560020a62461bcd02815260206004820181905260248201527f4552433732313a206d696e7420746f20746865207a65726f20616464726573736044820152606401610480565b600081815260026020526040902054600160a060020a03161561114b5760405160e560020a62461bcd02815260206004820152601c60248201527f4552433732313a20746f6b656e20616c7265616479206d696e746564000000006044820152606401610480565b611159600083836001610db4565b600081815260026020526040902054600160a060020a0316156111c15760405160e560020a62461bcd02815260206004820152601c60248201527f4552433732313a20746f6b656e20616c7265616479206d696e746564000000006044820152606401610480565b600160a060020a0382166000818152600360209081526040808320805460010190558483526002909152808220805473ffffffffffffffffffffffffffffffffffffffff19168417905551839291907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef908290a45050565b600160e060020a03198116811461085357600080fd5b60006020828403121561126157600080fd5b813561078e81611239565b60005b8381101561128757818101518382015260200161126f565b50506000910152565b600081518084526112a881602086016020860161126c565b601f01601f19169290920160200192915050565b60208152600061078e6020830184611290565b6000602082840312156112e157600080fd5b5035919050565b8035600160a060020a03811681146112ff57600080fd5b919050565b6000806040838503121561131757600080fd5b611320836112e8565b946020939093013593505050565b60008060006060848603121561134357600080fd5b61134c846112e8565b925061135a602085016112e8565b9150604084013590509250925092565b60006020828403121561137c57600080fd5b61078e826112e8565b6000806040838503121561139857600080fd5b6113a1836112e8565b9150602083013580151581146113b657600080fd5b809150509250929050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b6000806000806080858703121561140657600080fd5b61140f856112e8565b935061141d602086016112e8565b925060408501359150606085013567ffffffffffffffff8082111561144157600080fd5b818701915087601f83011261145557600080fd5b813581811115611467576114676113c1565b604051601f8201601f19908116603f0116810190838211818310171561148f5761148f6113c1565b816040528281528a60208487010111156114a857600080fd5b82602086016020830137600060208483010152809550505050505092959194509250565b600080604083850312156114df57600080fd5b6114e8836112e8565b91506114f6602084016112e8565b90509250929050565b60028104600182168061151357607f821691505b60208210810361154c577f4e487b7100000000000000000000000000000000000000000000000000000000600052602260045260246000fd5b50919050565b6020808252602d908201527f4552433732313a2063616c6c6572206973206e6f7420746f6b656e206f776e6560408201527f72206f7220617070726f76656400000000000000000000000000000000000000606082015260800190565b600083516115c181846020880161126c565b8351908301906115d581836020880161126c565b01949350505050565b60208082526025908201527f4552433732313a207472616e736665722066726f6d20696e636f72726563742060408201527f6f776e6572000000000000000000000000000000000000000000000000000000606082015260800190565b60208082526032908201527f4552433732313a207472616e7366657220746f206e6f6e20455243373231526560408201527f63656976657220696d706c656d656e7465720000000000000000000000000000606082015260800190565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b8181038181111561032e5761032e611698565b8082018082111561032e5761032e611698565b6000600160a060020a0380871683528086166020840152508360408301526080606083015261171f6080830184611290565b9695505050505050565b60006020828403121561173b57600080fd5b815161078e8161123956fea264697066735822122025aa7c3accb8a761120a8ec826eb8694d1223d3972520e7935c1ea59e90e719664736f6c63430008110033" + } + }, + "abi": [ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "approved", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "ApprovalForAll", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "getApproved", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "operator", + "type": "address" + } + ], + "name": "isApprovedForAll", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "ownerOf", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "safeMint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "safeTransferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "safeTransferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "setApprovalForAll", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "tokenURI", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ] +} \ No newline at end of file diff --git a/packages/cactus-plugin-persistence-ethereum/src/test/solidity/TestERC721.sol b/packages/cactus-plugin-persistence-ethereum/src/test/solidity/TestERC721.sol new file mode 100644 index 0000000000..46ca9597c4 --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/test/solidity/TestERC721.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ***************************************************************************** +// IMPORTANT: If you update this code then make sure to recompile +// it and update the .json file as well so that they +// remain in sync for consistent test executions. +// With that said, there shouldn't be any reason to recompile this, like ever... +// ***************************************************************************** + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +contract TestErc721Token is ERC721, Ownable { + constructor() ERC721("TestErc721Token", "T721") {} + + function _baseURI() internal pure override returns (string memory) { + return "https://example.com/"; + } + + function safeMint(address to, uint256 tokenId) public onlyOwner { + _safeMint(to, tokenId); + } +} diff --git a/packages/cactus-plugin-persistence-ethereum/src/test/sql/insert-test-data.sql b/packages/cactus-plugin-persistence-ethereum/src/test/sql/insert-test-data.sql new file mode 100644 index 0000000000..04193d81ae --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/test/sql/insert-test-data.sql @@ -0,0 +1,279 @@ +-- Sample test input for ethereum GUI. Can be used to check the views manually without running the persistence plugin first. +INSERT INTO + block +VALUES + ( + '15690287', + '2022-1-1T12:00:01Z', + '0x2d9eb76055a6c0ccaac1f3d9b25f9763dc8d0b5a71e79236321bac0f478429ae', + 26, + '2022-1-1T12:00:01Z' + ), + ( + '15696528', + '2022-10-07T15:29:54.000Z', + '0x3f1d27d8d08249fde96b1ae8ba6be6d1d5692c14fc9f217168416b6617111155', + 350, + '2022-10-07T13:29:55.000Z' + ), + ( + '15696527', + '2022-10-07T15:30:54.000Z', + '0x7a6e1aec17f10f62c1dc24c72a2f8c5ec80bd912828f0e63e4bad2bbcba58f07', + 82, + '2022-10-07T13:30:40.000Z' + ), + ( + '15696526', + '2022-10-07T15:34:22.000Z', + '0xfd00e7a0d3cb75ea2260437cbd8dd447c3f66d03691432910722a86fc5da6534', + 138, + '2022-10-07T13:34:22.000Z' + ), + ( + '15745924', + '2022-10-07T15:36:22.000Z', + '0x0f6fce3f731b81b4b6f742117160eafac2d9adfb6cf4c0d4ec95ba6e036c382a', + 193, + '2022-10-07T15:36:22.000Z' + ), + ( + '15745811', + '2022-10-07T15:37:22.000Z', + '0x4c930f76139f67577e6bc98d13c447034ce2c6cd724925b114bc8f3ec9357d6e', + 44, + '2022-10-07T15:37:22.000Z' + ), + ( + '15145924', + '2022-10-07T15:39:22.000Z', + '0xfe21558fad8389d05f99daf79017f188978889ff599362ba97fe20bc36451f13', + 80, + '2022-10-07T15:39:22.000Z' + ); + +INSERT INTO + token_metadata_erc721 +VALUES + ( + '0x44D39e215C112c5AEC6a733d691B464aa62b3F85', + '2021-1-1T12:00:01Z', + 'Anthony Hopkins - The Eternal', + 'ETERNAL' + ), + ( + '0x92324A569fa793485b44DA60b6663a8Cb8fC49A9', + '2021-09-1T12:00:01Z', + 'GWEI GUYS', + 'GWEI' + ), + ( + '0x15ea13b66B3BaDb355FcfA6317C3b96567825037', + '2021-09-1T12:00:01Z', + 'TAT-TWELVE ANONYMOUS TOURNAMENTS', + 'TAT' + ), + ( + '0x74211cA76D4CDd26c143E8FFD74469a04aE4e43B', + '2021-09-1T12:00:01Z', + 'POTHEADZ by Satoshis Mom', + 'POTHEADZ' + ); + +INSERT INTO + token_metadata_erc20 +VALUES + ( + '0x514910771AF9Ca656af840dff83E8264EcF986CA', + '2022-1-1T12:00:01Z', + 'ChainLink', + 'LINK', + 1000000000 + ), + ( + '0x2b591e99afE9f32eAA6214f7B7629768c40Eeb39', + '2022-1-09T12:00:01Z', + 'HEX', + 'HEX', + 581297893809.86402784 + ), + ( + '0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2', + '2021-1-10T12:00:01Z', + 'MAKER', + 'MKR', + 977631.036950888222010062 + ), + ( + '0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE', + '2019-1-10T12:00:01Z', + 'SHIBA INU', + 'SHIB', + 999991104547780.409551281285309628 + ); + +INSERT INTO + token_erc721 +VALUES + ( + extensions.uuid_generate_v4(), + '0xCF76Abe378F0e9000B9de137c0BEdF87513d3541', + '0x44D39e215C112c5AEC6a733d691B464aa62b3F85', + 'test', + 1234 + ), + ( + extensions.uuid_generate_v4(), + '0xCF76Abe378F0e9000B9de137c0BEdF87513d3541', + '0x15ea13b66B3BaDb355FcfA6317C3b96567825037', + 'test', + 4321 + ), + ( + extensions.uuid_generate_v4(), + '0x350aa850e9c7feef38b7afe82f9d748deb3a8edf', + '0x44D39e215C112c5AEC6a733d691B464aa62b3F85', + 'test', + 11623 + ), + ( + extensions.uuid_generate_v4(), + '0x41d1c1bbdd8ce06ac70b418f1d0c26e33e13b31f', + '0x92324A569fa793485b44DA60b6663a8Cb8fC49A9', + 'test', + 5 + ); + +INSERT INTO + transaction +VALUES + ( + '4c969755-3ea2-4ea9-983f-419e7d49d36f', + 0, + '0x572b29dac53c4c23685b8ce36233cfa57165f193cd3f15f45f3d5782d2802bc8', + 15690287, + '0x690B9A9E9aa1C9dB991C7721a92d351Db4FaC990', + '0x92324A569fa793485b44DA60b6663a8Cb8fC49A9', + 0.0265, + 'transfer', + '0xa1448194' + ), + ( + '4b969755-3ea2-4ea9-983f-419e7d49d36f', + 1, + '0x66ad6ab48a9cf245d6f122c31797b7faedcc8e61bd00cb6297acc709676c13a5', + 15696528, + '0x974CaA59e49682CdA0AD2bbe82983419A2ECC400', + '0x92324A569fa793485b44DA60b6663a8Cb8fC49A9', + 12.313, + '', + '0xb2443394' + ), + ( + '116beee1-1353-45a7-b352-b4e0d6b3eebf', + 0, + '0x1db1126c2b713fc8a445517b0234c3948733015cbd6b743fa5911bd3e2d33f54', + 15696527, + '0x690B9A9E9aa1C9dB991C7721a92d351Db4FaC990', + '0x514910771AF9Ca656af840dff83E8264EcF986CA', + 0.0016, + 'transfer', + '0xa1448194' + ), + ( + '155c23eb-cb68-4fa3-92d0-b739fe31b48e', + 0, + '0x1f92a740cb307266eed8770f7883c924dc5a3e7af83e76e0fc53d64701651b1b', + 15696526, + '0x974CaA59e49682CdA0AD2bbe82983419A2ECC400', + '0x514910771AF9Ca656af840dff83E8264EcF986CA', + 0.005, + '', + '0xb2443394' + ), + ( + '31cca064-ff18-4b28-8bb7-9ce785e12bec', + 0, + '0x1e5258e553cf021a5c99c5cbd3f4b7d42ba08477795040c3acc5418480687af8', + 15745924, + '0x92324A569fa793485b44DA60b6663a8Cb8fC49A9', + '0x514910771AF9Ca656af840dff83E8264EcF986CA', + 0.0025, + 'transfer', + '0xa1448194' + ), + ( + '3cd1bde0-091f-4495-b688-352bd06237e0', + 1, + '0x270baa921f6da72864529686321a2655266f55177fa3d0ea939c92f96e34cdc8', + 15745811, + '0x125861641FC4407148b184e53291aE5EC5ebD302', + '0x514910771AF9Ca656af840dff83E8264EcF986CA', + 1, + '', + '0xb2443394' + ), + ( + 'a6b1762c-a04a-4081-9fc6-32dac1b128a3', + 0, + '0x29929aae3c14f00e5163e0a5a47ac4592f9f3616e58d8efc2c35dcac71eab390', + 15145924, + '0x0f04d77d21186Eb031195474c3E9Fa6226Ea3e64', + '0x514910771AF9Ca656af840dff83E8264EcF986CA', + 22, + 'transfer', + '0xa1448194' + ); + +INSERT INTO + token_transfer +VALUES + ( + extensions.uuid_generate_v4(), + '4c969755-3ea2-4ea9-983f-419e7d49d36f', + '0x690B9A9E9aa1C9dB991C7721a92d351Db4FaC990', + '0x125861641FC4407148b184e53291aE5EC5ebD302', + 4321 + ), + ( + extensions.uuid_generate_v4(), + '4b969755-3ea2-4ea9-983f-419e7d49d36f', + '0x974CaA59e49682CdA0AD2bbe82983419A2ECC400', + '0x125861641FC4407148b184e53291aE5EC5ebD302', + 1234 + ), + ( + extensions.uuid_generate_v4(), + '116beee1-1353-45a7-b352-b4e0d6b3eebf', + '0x690B9A9E9aa1C9dB991C7721a92d351Db4FaC990', + '0x92324A569fa793485b44DA60b6663a8Cb8fC49A9', + 25 + ), + ( + extensions.uuid_generate_v4(), + '155c23eb-cb68-4fa3-92d0-b739fe31b48e', + '0x974CaA59e49682CdA0AD2bbe82983419A2ECC400', + '0x92324A569fa793485b44DA60b6663a8Cb8fC49A9', + 15 + ), + ( + extensions.uuid_generate_v4(), + '31cca064-ff18-4b28-8bb7-9ce785e12bec', + '0x92324A569fa793485b44DA60b6663a8Cb8fC49A9', + '0x0f04d77d21186Eb031195474c3E9Fa6226Ea3e64', + 12 + ), + ( + extensions.uuid_generate_v4(), + '3cd1bde0-091f-4495-b688-352bd06237e0', + '0x125861641FC4407148b184e53291aE5EC5ebD302', + '0x92324A569fa793485b44DA60b6663a8Cb8fC49A9', + 50 + ), + ( + extensions.uuid_generate_v4(), + 'a6b1762c-a04a-4081-9fc6-32dac1b128a3', + '0x0f04d77d21186Eb031195474c3E9Fa6226Ea3e64', + '0x125861641FC4407148b184e53291aE5EC5ebD302', + 32 + ); \ No newline at end of file diff --git a/packages/cactus-plugin-persistence-ethereum/src/test/typescript/integration/persistence-ethereum-functional.test.ts b/packages/cactus-plugin-persistence-ethereum/src/test/typescript/integration/persistence-ethereum-functional.test.ts new file mode 100644 index 0000000000..9c471a8b2f --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/test/typescript/integration/persistence-ethereum-functional.test.ts @@ -0,0 +1,780 @@ +/** + * Functional test of basic operations on ethereum persistence plugin (packages/cactus-plugin-persistence-ethereum). + */ + +////////////////////////////////// +// Constants +////////////////////////////////// + +const testLogLevel: LogLevelDesc = "info"; +const sutLogLevel: LogLevelDesc = "info"; +const setupTimeout = 1000 * 60; // 1 minute timeout for setup +const testTimeout = 1000 * 60 * 3; // 3 minutes timeout for some async tests + +// Ledger settings +const imageName = "openethereum/openethereum"; +const imageVersion = "v3.3.5"; + +// Token details (read from contract) +const erc20TokenName = "TestERC20"; +const erc20TokenSymbol = "T20"; +const erc20TokenSupply = 1000; +const erc721TokenName = "TestErc721Token"; +const erc721TokenSymbol = "T721"; + +// ApiClient settings +const syncReqTimeout = 1000 * 5; // 5 seconds + +import { + LogLevelDesc, + LoggerProvider, + Logger, +} from "@hyperledger/cactus-common"; +import { + OpenEthereumTestLedger, + pruneDockerAllIfGithubAction, + SelfSignedPkiGenerator, + K_DEV_WHALE_ACCOUNT_PRIVATE_KEY, +} from "@hyperledger/cactus-test-tooling"; +import { SocketIOApiClient } from "@hyperledger/cactus-api-client"; + +import DatabaseClient from "../../../main/typescript/db-client/db-client"; +jest.mock("../../../main/typescript/db-client/db-client"); +const DatabaseClientMock = (DatabaseClient as unknown) as jest.Mock; +import { PluginPersistenceEthereum } from "../../../main/typescript"; +import TestERC20ContractJson from "../../solidity/TestERC20.json"; +import TestERC721ContractJson from "../../solidity/TestERC721.json"; + +import "jest-extended"; +import Web3 from "web3"; +import express from "express"; +import { Server as HttpsServer } from "https"; +import { Account, TransactionReceipt } from "web3-core"; +import { AbiItem } from "web3-utils"; + +// Logger setup +const log: Logger = LoggerProvider.getOrCreate({ + label: "persistence-ethereum-functional.test", + level: testLogLevel, +}); + +describe("Ethereum persistence plugin tests", () => { + let ledger: OpenEthereumTestLedger; + let web3: Web3; + let connectorCertValue: string; + let connectorPrivKeyValue: string; + let connectorServer: HttpsServer; + let constTestAcc: Account; + const constTestAccBalance = 5 * 1000000; + let instanceId: string; + let persistence: PluginPersistenceEthereum; + let dbClientInstance: any; + let defaultAccountAddress: string; + let apiClient: SocketIOApiClient; + let connectorModule: any; + let erc20ContractCreationReceipt: Required; + let erc721ContractCreationReceipt: Required; + + ////////////////////////////////// + // Helper Functions + ////////////////////////////////// + + async function deploySmartContract( + abi: AbiItem | AbiItem[], + bytecode: string, + args?: unknown[], + ): Promise> { + const txReceipt = await ledger.deployContract(abi, "0x" + bytecode, args); + expect(txReceipt.contractAddress).toBeTruthy(); + expect(txReceipt.status).toBeTrue(); + expect(txReceipt.blockHash).toBeTruthy(); + expect(txReceipt.blockNumber).toBeGreaterThan(0); + log.debug( + "Deployed test smart contract, TX on block number", + txReceipt.blockNumber, + ); + // Force response without optional fields + return txReceipt as Required; + } + + async function mintErc721Token( + targetAddress: string, + tokenId: number, + ): Promise { + log.info(`Mint ERC721 token ID ${tokenId} for address ${targetAddress}`); + + const tokenContract = new web3.eth.Contract( + TestERC721ContractJson.abi as AbiItem[], + erc721ContractCreationReceipt.contractAddress, + ); + + const mintResponse = await tokenContract.methods + .safeMint(targetAddress, tokenId) + .send({ + from: defaultAccountAddress, + gas: 8000000, + }); + log.debug("mintResponse:", mintResponse); + expect(mintResponse).toBeTruthy(); + expect(mintResponse.status).toBeTrue(); + + return mintResponse; + } + + async function transferErc721Token( + sourceAddress: string, + targetAddress: string, + tokenId: number, + ): Promise { + log.info( + `Transfer ERC721 with ID ${tokenId} from ${sourceAddress} to ${targetAddress}`, + ); + + const tokenContract = new web3.eth.Contract( + TestERC721ContractJson.abi as AbiItem[], + erc721ContractCreationReceipt.contractAddress, + ); + + const transferResponse = await tokenContract.methods + .transferFrom(sourceAddress, targetAddress, tokenId) + .send({ + from: sourceAddress, + gas: 8000000, + }); + log.debug("transferResponse:", transferResponse); + expect(transferResponse).toBeTruthy(); + expect(transferResponse.status).toBeTrue(); + + return transferResponse; + } + + /** + * Setup mocked response from the database to retrieve token metadata. + * Should be called in each test that rely on this data. + */ + function mockTokenMetadataResponse() { + (dbClientInstance.getTokenMetadataERC20 as jest.Mock).mockReturnValue([ + { + address: erc20ContractCreationReceipt.contractAddress, + name: erc20TokenName, + symbol: erc20TokenSymbol, + total_supply: erc20TokenSupply, + created_at: "2022-1-1T12:00:01Z", + }, + ]); + (dbClientInstance.getTokenMetadataERC721 as jest.Mock).mockReturnValue([ + { + address: erc721ContractCreationReceipt.contractAddress, + name: erc721TokenName, + symbol: erc721TokenSymbol, + created_at: "2022-1-1T12:00:01Z", + }, + ]); + } + + /** + * Remove all mocks setup on the test DB Client instance. + */ + function clearMockTokenMetadata() { + for (const mockMethodName in dbClientInstance) { + const mockMethod = dbClientInstance[mockMethodName]; + if ("mockClear" in mockMethod) { + mockMethod.mockClear(); + } + } + } + + ////////////////////////////////// + // Environment Setup + ////////////////////////////////// + + beforeAll(async () => { + log.info("Prune Docker..."); + await pruneDockerAllIfGithubAction({ logLevel: testLogLevel }); + + // Create test ledger + log.info(`Start Ledger ${imageName}:${imageVersion}...`); + ledger = new OpenEthereumTestLedger({ + imageName, + imageVersion, + emitContainerLogs: true, + logLevel: sutLogLevel, + }); + await ledger.start(); + const ledgerRpcUrl = await ledger.getRpcApiWebSocketHost(); + log.info(`Ledger started, RPC: ${ledgerRpcUrl}`); + + // Create Test Account + constTestAcc = await ledger.createEthTestAccount(constTestAccBalance); + + // Create Web3 provider for testing + web3 = new Web3(); + web3.setProvider(new Web3.providers.WebsocketProvider(ledgerRpcUrl)); + const account = web3.eth.accounts.privateKeyToAccount( + "0x" + K_DEV_WHALE_ACCOUNT_PRIVATE_KEY, + ); + web3.eth.accounts.wallet.add(account); + web3.eth.accounts.wallet.add(constTestAcc); + defaultAccountAddress = account.address; + web3.eth.defaultAccount = defaultAccountAddress; + + // Generate connector private key and certificate + const pkiGenerator = new SelfSignedPkiGenerator(); + const pki = pkiGenerator.create("localhost"); + connectorPrivKeyValue = pki.privateKeyPem; + connectorCertValue = pki.certificatePem; + const jwtAlgo = "RS512"; + + // Load go-ethereum connector + const connectorConfig: any = { + sslParam: { + port: 0, // random port + keyValue: connectorPrivKeyValue, + certValue: connectorCertValue, + jwtAlgo: jwtAlgo, + }, + logLevel: sutLogLevel, + ledgerUrl: ledgerRpcUrl, + }; + const configJson = JSON.stringify(connectorConfig); + log.debug("Connector Config:", configJson); + + log.info("Export connector config before loading the module..."); + process.env["NODE_CONFIG"] = configJson; + + // Load connector module + connectorModule = await import( + "@hyperledger/cactus-plugin-ledger-connector-go-ethereum-socketio" + ); + + // Run the connector + connectorServer = await connectorModule.startGoEthereumSocketIOConnector(); + expect(connectorServer).toBeTruthy(); + const connectorAddress = connectorServer.address(); + if (!connectorAddress || typeof connectorAddress === "string") { + throw new Error("Unexpected go-ethereum connector AddressInfo type"); + } + const connectorFullAddress = `${connectorAddress.address}:${connectorAddress.port}`; + log.info( + "Go-Ethereum-SocketIO Connector started on:", + connectorFullAddress, + ); + + // Create ApiClient instance + const apiConfigOptions = { + validatorID: "go-eth-socketio-test", + validatorURL: `https://localhost:${connectorAddress.port}`, + validatorKeyValue: connectorCertValue, + logLevel: sutLogLevel, + maxCounterRequestID: 1000, + syncFunctionTimeoutMillisecond: syncReqTimeout, + socketOptions: { + rejectUnauthorized: false, + reconnection: false, + timeout: syncReqTimeout * 2, + }, + }; + log.debug("ApiClient config:", apiConfigOptions); + apiClient = new SocketIOApiClient(apiConfigOptions); + + // Create Ethereum persistence plugin + instanceId = "functional-test"; + DatabaseClientMock.mockClear(); + persistence = new PluginPersistenceEthereum({ + apiClient, + logLevel: testLogLevel, + instanceId, + connectionString: "db-is-mocked", + }); + expect(DatabaseClientMock).toHaveBeenCalledTimes(1); + dbClientInstance = DatabaseClientMock.mock.instances[0]; + expect(dbClientInstance).toBeTruthy(); + + const expressApp = express(); + expressApp.use(express.json({ limit: "250mb" })); + + await persistence.registerWebServices(expressApp); + }, setupTimeout); + + afterAll(async () => { + log.info("FINISHING THE TESTS"); + + if (persistence) { + await persistence.shutdown(); + } + + if (connectorModule) { + connectorModule.shutdown(); + } + + if (connectorServer) { + log.info("Stop the ethereum connector..."); + await new Promise((resolve) => + connectorServer.close(() => resolve()), + ); + } + + if (ledger) { + log.info("Stop the ethereum ledger..."); + await ledger.stop(); + await ledger.destroy(); + } + + // SocketIOApiClient has timeout running for each request which is not cancellable at the moment. + // Wait timeout amount of seconds to make sure all handles are closed. + await new Promise((resolve) => setTimeout(resolve, syncReqTimeout * 2)); + + log.info("Prune Docker..."); + await pruneDockerAllIfGithubAction({ logLevel: testLogLevel }); + }, setupTimeout); + + beforeEach(() => { + clearMockTokenMetadata(); + }, setupTimeout); + + ////////////////////////////////// + // Tests + ////////////////////////////////// + + /** + * Simple test to see if test ethereum ledger is running correctly. + * Doesn't use apiClient or validator. + */ + test("Sanity check ledger connection", async () => { + const balance = await web3.eth.getBalance(constTestAcc.address); + expect(balance).toBeTruthy(); + expect(balance.valueOf()).toEqual(constTestAccBalance.toString()); + }); + + test("Basic methods test", async () => { + // getInstanceId() + expect(persistence.getInstanceId()).toEqual(instanceId); + + // getPackageName() + expect(persistence.getPackageName()).toEqual( + "@hyperledger/cactus-plugin-persistence-ethereum", + ); + + // getOpenApiSpec() + expect(persistence.getOpenApiSpec()).toBeTruthy(); + }); + + ////////////////////////////////// + // Basic Method Tests + ////////////////////////////////// + + describe("Basic plugin method tests", () => { + beforeAll(async () => { + // Deploy smart contracts + const erc20Abi = TestERC20ContractJson.abi as AbiItem[]; + const erc20Bytecode = TestERC20ContractJson.data.bytecode.object; + erc20ContractCreationReceipt = await deploySmartContract( + erc20Abi, + erc20Bytecode, + [erc20TokenSupply], + ); + log.info( + "ERC20 deployed contract address:", + erc20ContractCreationReceipt.contractAddress, + ); + + const erc721Abi = TestERC721ContractJson.abi as AbiItem[]; + const erc721Bytecode = TestERC721ContractJson.data.bytecode.object; + erc721ContractCreationReceipt = await deploySmartContract( + erc721Abi, + erc721Bytecode, + ); + log.info( + "ERC721 deployed contract address:", + erc721ContractCreationReceipt.contractAddress, + ); + }); + + test("onPluginInit creates DB schema and fetches the monitored tokens", async () => { + mockTokenMetadataResponse(); + await persistence.onPluginInit(); + + // DB Schema initialized + const initDBCalls = dbClientInstance.initializePlugin.mock.calls; + expect(initDBCalls.length).toBe(1); + + // Tokens refreshed + expect(persistence.monitoredTokens).toBeTruthy(); + expect(persistence.monitoredTokens.size).toEqual(2); + }); + + test("Initial plugin status is correct", async () => { + mockTokenMetadataResponse(); + await persistence.onPluginInit(); + + const status = persistence.getStatus(); + expect(status).toBeTruthy(); + expect(status.instanceId).toEqual(instanceId); + expect(status.connected).toBeTrue(); + expect(status.webServicesRegistered).toBeTrue(); + expect(status.monitoredTokensCount).toEqual(2); + expect(status.lastSeenBlock).toEqual(0); + }); + + test("Adding ERC20 tokens to the GUI test", async () => { + await persistence.addTokenERC20( + erc20ContractCreationReceipt.contractAddress, + ); + + // Check if DBClient was called + const insertCalls = dbClientInstance.insertTokenMetadataERC20.mock.calls; + expect(insertCalls.length).toBe(1); + const insertCallArgs = insertCalls[0]; + + // Check inserted token data + const token = insertCallArgs[0]; + expect(token).toBeTruthy(); + expect(token.address.toLowerCase()).toEqual( + erc20ContractCreationReceipt.contractAddress.toLowerCase(), + ); + expect(web3.utils.checkAddressChecksum(token.address)).toBeTrue(); + expect(token.name).toEqual(erc20TokenName); + expect(token.symbol).toEqual(erc20TokenSymbol); + expect(token.total_supply).toEqual(erc20TokenSupply); + }); + + test("Adding token with invalid data throws RuntimeError", async () => { + // Empty address + await expect(persistence.addTokenERC20("")).toReject(); + + // Invalid address + await expect(persistence.addTokenERC20("abc")).toReject(); + + // Wrong checksum address + await expect( + persistence.addTokenERC20("0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe0"), + ).toReject(); + + // Non existing address + await expect( + persistence.addTokenERC20("0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5"), + ).toReject(); + }); + + test("Adding ERC721 tokens to the GUI test", async () => { + await persistence.addTokenERC721( + erc721ContractCreationReceipt.contractAddress, + ); + + // Check if DBClient was called + const insertCalls = dbClientInstance.insertTokenMetadataERC721.mock.calls; + expect(insertCalls.length).toBe(1); + const insertCallArgs = insertCalls[0]; + + // Check inserted token data + const token = insertCallArgs[0]; + expect(token).toBeTruthy(); + expect(token.address.toLowerCase()).toEqual( + erc721ContractCreationReceipt.contractAddress.toLowerCase(), + ); + expect(web3.utils.checkAddressChecksum(token.address)).toBeTrue(); + expect(token.name).toEqual(erc721TokenName); + expect(token.symbol).toEqual(erc721TokenSymbol); + }); + + test("Refresh tokens updates internal state", async () => { + const tokens = await persistence.refreshMonitoredTokens(); + + expect(tokens).toBeTruthy(); + expect(tokens.size).toEqual(2); + const tokenAddresses = Array.from(tokens.keys()); + expect(tokenAddresses).toContain( + web3.utils.toChecksumAddress( + erc20ContractCreationReceipt.contractAddress, + ), + ); + expect(tokenAddresses).toContain( + web3.utils.toChecksumAddress( + erc721ContractCreationReceipt.contractAddress, + ), + ); + expect(tokens).toEqual(persistence.monitoredTokens); + }); + }); + + ////////////////////////////////// + // Data Synchronization Tests + ////////////////////////////////// + + describe("Data synchronization tests", () => { + beforeEach(async () => { + // Deploy smart contracts + const erc20Abi = TestERC20ContractJson.abi as AbiItem[]; + const erc20Bytecode = TestERC20ContractJson.data.bytecode.object; + erc20ContractCreationReceipt = await deploySmartContract( + erc20Abi, + erc20Bytecode, + [erc20TokenSupply], + ); + log.info( + "ERC20 deployed contract address:", + erc20ContractCreationReceipt.contractAddress, + ); + + const erc721Abi = TestERC721ContractJson.abi as AbiItem[]; + const erc721Bytecode = TestERC721ContractJson.data.bytecode.object; + erc721ContractCreationReceipt = await deploySmartContract( + erc721Abi, + erc721Bytecode, + ); + log.info( + "ERC721 deployed contract address:", + erc721ContractCreationReceipt.contractAddress, + ); + + mockTokenMetadataResponse(); + }); + + test("Synchronization of ERC721 finds all issued tokens", async () => { + expect(erc721ContractCreationReceipt.contractAddress).toBeTruthy(); + + await persistence.refreshMonitoredTokens(); + // Issue three test tokens + await mintErc721Token(constTestAcc.address, 1); + await mintErc721Token(constTestAcc.address, 2); + await mintErc721Token(constTestAcc.address, 3); + log.debug("Minting test ERC721 tokens done."); + + await persistence.syncERC721Tokens(); + + const upsertCalls = dbClientInstance.upsertTokenERC721.mock.calls; + expect(upsertCalls.length).toBe(3); + upsertCalls.forEach((callArgs: any[]) => { + const token = callArgs[0]; + expect([1, 2, 3]).toInclude(token.token_id); + expect(token.account_address).toBeTruthy(); + expect(token.uri).toBeTruthy(); + }); + }); + }); + + ////////////////////////////////// + // Block Parsing Tests + ////////////////////////////////// + + describe("Block parsing and monitoring tests", () => { + beforeEach(async () => { + // Deploy smart contracts + const erc20Abi = TestERC20ContractJson.abi as AbiItem[]; + const erc20Bytecode = TestERC20ContractJson.data.bytecode.object; + erc20ContractCreationReceipt = await deploySmartContract( + erc20Abi, + erc20Bytecode, + [erc20TokenSupply], + ); + log.info( + "ERC20 deployed contract address:", + erc20ContractCreationReceipt.contractAddress, + ); + + const erc721Abi = TestERC721ContractJson.abi as AbiItem[]; + const erc721Bytecode = TestERC721ContractJson.data.bytecode.object; + erc721ContractCreationReceipt = await deploySmartContract( + erc721Abi, + erc721Bytecode, + ); + log.info( + "ERC721 deployed contract address:", + erc721ContractCreationReceipt.contractAddress, + ); + + mockTokenMetadataResponse(); + }); + + test("Parse block with transaction of minting new token", async () => { + await persistence.refreshMonitoredTokens(); + + // Mint token to create a new block + const targetTokenAddress = constTestAcc.address; + const tokenId = 1; + const mintResponse: any = await mintErc721Token( + targetTokenAddress, + tokenId, + ); + expect(mintResponse.blockNumber).toBeTruthy(); + const mintTxBlock = await web3.eth.getBlock( + mintResponse.blockNumber, + true, + ); + + // Parse block data + await persistence.parseBlockData(mintTxBlock); + + // Check if DBClient was called + const insertCalls = dbClientInstance.insertBlockData.mock.calls; + expect(insertCalls.length).toBe(1); + const insertCallArgs = insertCalls[0]; + + // Check inserted block data + const insertBlockData = insertCallArgs[0]; + const blockData = insertBlockData.block; + expect(blockData).toBeTruthy(); + expect(blockData.number).toBeGreaterThan(0); + expect(blockData.created_at).toBeTruthy(); + expect(blockData.hash).toBeTruthy(); + expect(blockData.number_of_tx).toBeGreaterThan(0); + const blockTransactions = insertBlockData.transactions; + expect(blockTransactions).toBeTruthy(); + + // Find mint transaction by it's method signature + const mintTransaction = blockTransactions.find( + (tx: any) => tx.method_signature === "0xa1448194", + ); + expect(mintTransaction).toBeTruthy(); + expect(mintTransaction.from).toBeTruthy(); + expect(mintTransaction.to).toBeTruthy(); + expect(mintTransaction.token_transfers.length).toEqual(1); + const mintTransactionTransfer = mintTransaction.token_transfers[0]; + expect(mintTransactionTransfer).toBeTruthy(); + + // Check token transfer + expect(mintTransactionTransfer.sender.toLowerCase()).toEqual( + "0x0000000000000000000000000000000000000000", + ); + expect(mintTransactionTransfer.recipient.toLowerCase()).toEqual( + targetTokenAddress.toLowerCase(), + ); + expect(mintTransactionTransfer.value).toEqual(tokenId.toString()); + }); + + test("Parse block with transaction of token transfer", async () => { + await persistence.refreshMonitoredTokens(); + + // Mint and transfer token to create a new block + await mintErc721Token(constTestAcc.address, 1); + const sourceAccount = constTestAcc.address; + const targetAccount = defaultAccountAddress; + const tokenId = 1; + const tranferResponse: any = await transferErc721Token( + sourceAccount, + targetAccount, + tokenId, + ); + expect(tranferResponse.blockNumber).toBeTruthy(); + const transferTxBlock = await web3.eth.getBlock( + tranferResponse.blockNumber, + true, + ); + + // Parse block data + await persistence.parseBlockData(transferTxBlock); + + // Check if DBClient was called + const insertCalls = dbClientInstance.insertBlockData.mock.calls; + expect(insertCalls.length).toBe(1); + const insertCallArgs = insertCalls[0]; + + // Check inserted block data + const insertBlockData = insertCallArgs[0]; + + // Find transfer transaction by it's method signature + const transferTransaction = insertBlockData.transactions.find( + (tx: any) => tx.method_signature === "0x23b872dd", + ); + expect(transferTransaction).toBeTruthy(); + expect(transferTransaction.method_name).toEqual("transferFrom"); + expect(transferTransaction.token_transfers.length).toEqual(1); + const txTransfer = transferTransaction.token_transfers[0]; + + // Check token transfer + expect(txTransfer.sender.toLowerCase()).toEqual( + sourceAccount.toLowerCase(), + ); + expect(txTransfer.recipient.toLowerCase()).toEqual( + targetAccount.toLowerCase(), + ); + expect(txTransfer.value).toEqual(tokenId.toString()); + }); + + test( + "Calling syncAll adds new tracked operation that is reported in plugin status", + async () => { + // Freeze on getMissingBlocksInRange method until status is checked + let isStatusChecked = false; + (dbClientInstance.getMissingBlocksInRange as jest.Mock).mockImplementation( + async () => { + while (!isStatusChecked) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + return []; + }, + ); + + const syncAllPromise = persistence.syncAll(); + + try { + // Wait for method to be called + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Check if syncAll operation is present + const status = persistence.getStatus(); + expect(status).toBeTruthy(); + expect(status.operationsRunning.length).toEqual(1); + const trackedOperation = status.operationsRunning[0]; + expect(trackedOperation.startAt).toBeTruthy(); + expect(trackedOperation.operation).toEqual("syncAll"); + } finally { + // Always finish the syncAll call + isStatusChecked = true; + await syncAllPromise; + } + + const statusAfterFinish = persistence.getStatus(); + expect(statusAfterFinish).toBeTruthy(); + expect(statusAfterFinish.operationsRunning.length).toEqual(0); + }, + testTimeout, + ); + + test( + "Block monitoring detects new changes correctly.", + async () => { + await persistence.refreshMonitoredTokens(); + + const insertBlockPromise = new Promise((resolve, reject) => { + (dbClientInstance.getMissingBlocksInRange as jest.Mock).mockReturnValue( + [], + ); + + (dbClientInstance.insertBlockData as jest.Mock).mockImplementation( + (blockData) => resolve(blockData), + ); + + persistence.startMonitor((err) => { + reject(err); + }); + log.debug("Persistence plugin block monitoring started."); + }); + + // Wait for monitor to get started + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Trigger new block + await mintErc721Token(constTestAcc.address, 1); + log.debug("New token has been minted to trigger tx"); + + const blockData = await insertBlockPromise; + log.debug("blockData was inserted:", blockData); + expect(blockData.block).toBeTruthy(); + + // Check if status reports that monitor is running + const status = persistence.getStatus(); + expect(status).toBeTruthy(); + expect(status.monitorRunning).toBeTrue(); + + // Check if status reports monitor is not running after stopMonitor is called + persistence.stopMonitor(); + const statusAfterStop = persistence.getStatus(); + expect(statusAfterStop).toBeTruthy(); + expect(statusAfterStop.monitorRunning).toBeFalse(); + }, + testTimeout, + ); + }); +}); diff --git a/packages/cactus-plugin-persistence-ethereum/src/test/typescript/integration/persistence-ethereum-postgresql-db-client.test.ts b/packages/cactus-plugin-persistence-ethereum/src/test/typescript/integration/persistence-ethereum-postgresql-db-client.test.ts new file mode 100644 index 0000000000..524016c113 --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/src/test/typescript/integration/persistence-ethereum-postgresql-db-client.test.ts @@ -0,0 +1,757 @@ +/** + * Test for accessing data in PostgreSQL through persistence plugin PostgresDatabaseClient (packages/cactus-plugin-persistence-ethereum). + */ + +////////////////////////////////// +// Constants +////////////////////////////////// + +const postgresImageName = "postgres"; +const postgresImageVersion = "14.6-alpine"; +const testLogLevel: LogLevelDesc = "info"; +const sutLogLevel: LogLevelDesc = "info"; +const setupTimeout = 1000 * 60; // 1 minute timeout for setup + +import { + pruneDockerAllIfGithubAction, + PostgresTestContainer, +} from "@hyperledger/cactus-test-tooling"; +import { + LogLevelDesc, + LoggerProvider, + Logger, +} from "@hyperledger/cactus-common"; + +import PostgresDatabaseClient from "../../../main/typescript/db-client/db-client"; + +import "jest-extended"; + +// Logger setup +const log: Logger = LoggerProvider.getOrCreate({ + label: "persistence-ethereum-postgresql-db-client.test", + level: testLogLevel, +}); + +describe("Ethereum persistence PostgreSQL PostgresDatabaseClient tests", () => { + const testPluginName = "TestPlugin"; + const testPluginInstanceId = "testInstance"; + let postgresContainer: PostgresTestContainer; + let dbClient: PostgresDatabaseClient; + + ////////////////////////////////// + // Helper Functions + ////////////////////////////////// + + /** + * Delete all data from all tables + */ + async function clearDbSchema() { + await dbClient.client.query("DELETE FROM public.token_transfer"); + await dbClient.client.query("DELETE FROM public.transaction"); + await dbClient.client.query("DELETE FROM public.block"); + await dbClient.client.query("DELETE FROM public.token_erc721"); + await dbClient.client.query("DELETE FROM public.token_metadata_erc20"); + await dbClient.client.query("DELETE FROM public.token_metadata_erc721"); + } + + async function getDbBlocks() { + const response = await dbClient.client.query("SELECT * FROM public.block"); + return response.rows; + } + + async function getDbTransactions() { + const response = await dbClient.client.query( + "SELECT * FROM public.transaction", + ); + return response.rows; + } + + async function getDbTokenTransfers() { + const response = await dbClient.client.query( + "SELECT * FROM public.token_transfer", + ); + return response.rows; + } + + ////////////////////////////////// + // Environment Setup + ////////////////////////////////// + + beforeAll(async () => { + log.info("Prune Docker..."); + await pruneDockerAllIfGithubAction({ logLevel: testLogLevel }); + + log.info("Run PostgresTestContainer..."); + postgresContainer = new PostgresTestContainer({ + imageName: postgresImageName, + imageVersion: postgresImageVersion, + logLevel: testLogLevel, + envVars: ["POSTGRES_USER=postgres", "POSTGRES_PASSWORD=postgres"], + }); + await postgresContainer.start(); + const postgresPort = await postgresContainer.getPostgresPort(); + expect(postgresPort).toBeTruthy(); + log.info(`Postgres running at localhost:${postgresPort}`); + + log.info("Create PostgresDatabaseClient"); + dbClient = new PostgresDatabaseClient({ + connectionString: `postgresql://postgres:postgres@localhost:${postgresPort}/postgres`, + logLevel: sutLogLevel, + }); + + log.info("Connect the PostgreSQL PostgresDatabaseClient"); + await dbClient.connect(); + + log.info("Mock Supabase schema"); + // We use plain postgres for better performance, but the actual GUI will use Supabase which does it's own adjustment to the DB. + await dbClient.client.query( + `CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + CREATE ROLE anon NOLOGIN; + CREATE ROLE authenticated NOLOGIN; + CREATE ROLE service_role NOLOGIN; + CREATE ROLE supabase_admin NOLOGIN;`, + ); + + log.info("Initialize the test DB Schema"); + await dbClient.initializePlugin(testPluginName, testPluginInstanceId); + + // Assert all tables are created + const response = await dbClient.client.query( + "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'", + ); + const tableNames = response.rows.map((row) => row.table_name); + expect(tableNames.sort()).toEqual( + [ + "block", + "token_metadata_erc20", + "token_metadata_erc721", + "plugin_status", + "token_erc721", + "token_transfer", + "transaction", + "erc20_token_history_view", + "erc721_token_history_view", + "erc721_txn_meta_view", + ].sort(), + ); + + // Assert plugin status was inserted + const pluginStatus = await dbClient.getPluginStatus(testPluginName); + expect(pluginStatus).toBeTruthy(); + expect(pluginStatus.name).toEqual(testPluginName); + expect(pluginStatus.last_instance_id).toEqual(testPluginInstanceId); + expect(pluginStatus.is_schema_initialized).toBeTrue(); + expect(pluginStatus.created_at).toEqual(pluginStatus.last_connected_at); + + log.info("Ensure DB Schema is empty (in case test is re-run on same DB"); + await clearDbSchema(); + }, setupTimeout); + + afterAll(async () => { + log.info("FINISHING THE TESTS"); + + if (dbClient) { + log.info("Disconnect the PostgresDatabaseClient"); + await dbClient.shutdown(); + } + + if (postgresContainer) { + log.info("Stop PostgreSQL..."); + await postgresContainer.stop(); + } + + log.info("Prune Docker..."); + await pruneDockerAllIfGithubAction({ logLevel: testLogLevel }); + }, setupTimeout); + + afterEach(async () => { + await clearDbSchema(); + }, setupTimeout); + + ////////////////////////////////// + // Tests + ////////////////////////////////// + + test("Insert single and get all ERC20 token metadata.", async () => { + // Metadata table should be empty at first + const initTokens = await dbClient.getTokenMetadataERC20(); + expect(initTokens.length).toBe(0); + + // Insert single token + const token = { + address: "0x42EA16C9B9e529dA492909F34f416fEd2bE7c280", + name: "TestToken", + symbol: "TT", + total_supply: 1000, + }; + await dbClient.insertTokenMetadataERC20(token); + + // Metadata table should contain token we've just added + const tokensAfterInsert = await dbClient.getTokenMetadataERC20(); + expect(tokensAfterInsert.length).toBe(1); + expect(tokensAfterInsert[0]).toMatchObject({ + ...token, + total_supply: token.total_supply.toString(), + }); + + // Insert another token + await dbClient.insertTokenMetadataERC20({ + address: "0x58E719254f1564eD29A86dB7554c47FaB778F3fE", + name: "AnotherToken", + symbol: "AT", + total_supply: 999, + }); + + // Ensure new token is returned as well + const tokensFinal = await dbClient.getTokenMetadataERC20(); + expect(tokensFinal.length).toBe(2); + }); + + test("Initialize plugin can be called repeatedly and it only updates the last_connected_at", async () => { + const initPluginStatus = await dbClient.getPluginStatus(testPluginName); + expect(initPluginStatus).toBeTruthy(); + + await dbClient.initializePlugin(testPluginName, testPluginInstanceId); + + // Assert plugin status was inserted + const pluginStatus = await dbClient.getPluginStatus(testPluginName); + expect(pluginStatus).toBeTruthy(); + const lastConnectedAt = pluginStatus.last_connected_at; + delete (pluginStatus as any).last_connected_at; + expect(initPluginStatus).toMatchObject(pluginStatus); + expect(lastConnectedAt).not.toEqual(initPluginStatus.last_connected_at); + }); + + // Note: it should also print a warning but we don't assert that + test("Initialize plugin updates instance ID when it changes", async () => { + const newInstanceId = "AnotherInstance"; + const initPluginStatus = await dbClient.getPluginStatus(testPluginName); + expect(initPluginStatus).toBeTruthy(); + + await dbClient.initializePlugin(testPluginName, newInstanceId); + + // Assert plugin status was inserted + const pluginStatus = await dbClient.getPluginStatus(testPluginName); + expect(pluginStatus).toBeTruthy(); + const { last_connected_at, last_instance_id } = pluginStatus; + delete (pluginStatus as any).last_connected_at; + delete (pluginStatus as any).last_instance_id; + expect(initPluginStatus).toMatchObject(pluginStatus); + expect(last_connected_at).not.toEqual(initPluginStatus.last_connected_at); + expect(last_instance_id).toEqual(newInstanceId); + }); + + test("Insert single and get all ERC721 token metadata.", async () => { + // Metadata table should be empty at first + const initTokens = await dbClient.getTokenMetadataERC721(); + expect(initTokens.length).toBe(0); + + // Insert single token + const token = { + address: "0x42EA16C9B9e529dA492909F34f416fEd2bE7c280", + name: "TestToken", + symbol: "TT", + }; + await dbClient.insertTokenMetadataERC721(token); + + // Metadata table should contain token we've just added + const tokensAfterInsert = await dbClient.getTokenMetadataERC721(); + expect(tokensAfterInsert.length).toBe(1); + expect(tokensAfterInsert[0]).toMatchObject(token); + + // Insert another token + await dbClient.insertTokenMetadataERC721({ + address: "0x58E719254f1564eD29A86dB7554c47FaB778F3fE", + name: "AnotherToken", + symbol: "AT", + }); + + // Ensure new token is returned as well + const tokensFinal = await dbClient.getTokenMetadataERC721(); + expect(tokensFinal.length).toBe(2); + }); + + test("Upsert already issued ERC721 token into table (without duplication)", async () => { + // Insert token metadata + const contractAddress = "0x42EA16C9B9e529dA492909F34f416fEd2bE7c280"; + const token = { + address: contractAddress, + name: "TestToken", + symbol: "TT", + }; + await dbClient.insertTokenMetadataERC721(token); + + // Initially table should be empty + const initialTokens = await dbClient.getTokenERC721(); + expect(initialTokens.length).toBe(0); + + // Upsert issued token that is not present in the DB + const issuedToken = { + account_address: "0x6dfc34609a05bC22319fA4Cce1d1E2929548c0D7", + token_address: contractAddress, + uri: "test.uri", + token_id: 1, + }; + await dbClient.upsertTokenERC721(issuedToken); + + // Check if new token was added + const tokensAfterUpsert = await dbClient.getTokenERC721(); + expect(tokensAfterUpsert.length).toBe(1); + expect(tokensAfterUpsert[0]).toMatchObject({ + ...issuedToken, + token_id: issuedToken.token_id.toString(), + }); + + // Upsert the same token but with different owner + const updatedToken = { + ...issuedToken, + account_address: "0x8888", + }; + await dbClient.upsertTokenERC721(updatedToken); + + // Number of tokens should not change, only address should be updated + const tokensFinal = await dbClient.getTokenERC721(); + expect(tokensFinal.length).toBe(1); + expect(tokensFinal[0]).toMatchObject({ + ...updatedToken, + token_id: updatedToken.token_id.toString(), + }); + }); + + test("New block data is added to the DB", async () => { + const blockTimestamp = new Date(1671702925 * 1000); + const block = { + number: 18, + created_at: blockTimestamp.toUTCString(), + hash: + "0x2bdfd1957e88297b012a1dc15a51f3691371980749378d10a6186b221d6687e5", + number_of_tx: 1, + }; + + const token_transfer = { + sender: "0x0000000000000000000000000000000000000000", + recipient: "0x12b60219Ca56110E53F9E79178713C363e8aF999", + value: 1, + }; + + const transaction = { + index: 0, + hash: + "0x29a3ad97041d01ed610cfab19a091239135ee6bef6d2d7513e94dbb26f8bb1f4", + from: "0x00a329c0648769A73afAc7F9381E08FB43dBEA72", + to: "0x53F6337d308FfB2c52eDa319Be216cC7321D3725", + eth_value: 0, + method_signature: "0xa1448194", + method_name: "", + }; + + await dbClient.insertBlockData({ + block, + transactions: [ + { + ...transaction, + token_transfers: [token_transfer], + }, + ], + }); + + // Assert block + const blocksResponse = await getDbBlocks(); + expect(blocksResponse.length).toBe(1); + const dbBlock = blocksResponse[0]; + expect(dbBlock.number).toEqual(block.number.toString()); + expect(dbBlock.hash).toEqual(block.hash); + expect(dbBlock.number_of_tx).toEqual(block.number_of_tx.toString()); + expect(new Date(dbBlock.created_at)).toEqual(blockTimestamp); + + // Assert transaction + const txResponse = await getDbTransactions(); + expect(txResponse.length).toBe(1); + const dbTx = txResponse[0]; + expect(dbTx).toMatchObject({ + ...transaction, + index: transaction.index.toString(), + block_number: block.number.toString(), + eth_value: transaction.eth_value.toString(), + }); + + // Assert token transfer + const transferResponse = await getDbTokenTransfers(); + expect(transferResponse.length).toBe(1); + const dbTransfer = transferResponse[0]; + expect(dbTransfer).toMatchObject({ + ...token_transfer, + value: token_transfer.value.toString(), + }); + }); + + test("insertBlockData atomic transaction is reverted on error ", async () => { + const blockTimestamp = new Date(1671702925 * 1000); + const block = { + number: 18, + created_at: blockTimestamp.toUTCString(), + hash: + "0x2bdfd1957e88297b012a1dc15a51f3691371980749378d10a6186b221d6687e5", + number_of_tx: 1, + }; + + const token_transfer = { + sender: "0x0000000000000000000000000000000000000000", + recipient: "0x12b60219Ca56110E53F9E79178713C363e8aF999", + value: "asd" as any, // Invalid value type, should fail after already adding block and tx + }; + + const transaction = { + index: 0, + hash: + "0x29a3ad97041d01ed610cfab19a091239135ee6bef6d2d7513e94dbb26f8bb1f4", + from: "0x00a329c0648769A73afAc7F9381E08FB43dBEA72", + to: "0x53F6337d308FfB2c52eDa319Be216cC7321D3725", + eth_value: 0, + method_signature: "0xa1448194", + method_name: "", + }; + + try { + await dbClient.insertBlockData({ + block, + transactions: [ + { + ...transaction, + token_transfers: [token_transfer], + }, + ], + }); + expect(true).toBe(false); // Block insertion should fail + } catch (error: unknown) { + log.info("insertBlockData was rejected as expected"); + } + + // Assert no data was added + const blocksResponse = await getDbBlocks(); + expect(blocksResponse.length).toBe(0); + const txResponse = await getDbTransactions(); + expect(txResponse.length).toBe(0); + const transferResponse = await getDbTokenTransfers(); + expect(transferResponse.length).toBe(0); + }); + + test("ERC20 token balance is updated on new block", async () => { + // Current balance table should be empty + const currentBalance = await dbClient.getTokenERC20(); + expect(currentBalance.length).toBe(0); + + // Insert test token metadata + const contractAddr = "0x42EA16C9B9e529dA492909F34f416fEd2bE7c280"; + await dbClient.insertTokenMetadataERC20({ + address: contractAddr, + name: "TestToken", + symbol: "TT", + total_supply: 1000, + }); + + // Insert block with several transfers of our tokens + const blockTimestamp = new Date(1671702925 * 1000); + const firstAccount = "0x12b60219Ca56110E53F9E79178713C363e8aF999"; + const secondAccount = "0x4675C7e5BaAFBFFbca748158bEcBA61ef3b0a263"; + await dbClient.insertBlockData({ + block: { + number: 18, + created_at: blockTimestamp.toUTCString(), + hash: + "0x2bdfd1957e88297b012a1dc15a51f3691371980749378d10a6186b221d6687e5", + number_of_tx: 1, + }, + transactions: [ + { + index: 0, + hash: + "0x29a3ad97041d01ed610cfab19a091239135ee6bef6d2d7513e94dbb26f8bb1f4", + from: "0x00a329c0648769A73afAc7F9381E08FB43dBEA72", + to: contractAddr, + eth_value: 0, + method_signature: "0xa1448194", + method_name: "", + token_transfers: [ + { + sender: "0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5", + recipient: firstAccount, + value: 100, + }, + { + sender: firstAccount, + recipient: secondAccount, + value: 25, + }, + ], + }, + ], + }); + await dbClient.syncTokenBalanceERC20(); + + const balanceAfterInsert = await dbClient.getTokenERC20(); + log.debug("balanceAfterInsert", balanceAfterInsert); + expect(balanceAfterInsert.length).toBe(2); + + // Assert first account balance + const firstAccountBalance = balanceAfterInsert.find( + (b) => b.account_address === firstAccount, + ); + expect(firstAccountBalance).toBeTruthy(); + expect(firstAccountBalance?.balance).toEqual("75"); + + // Assert second account balance + const secondAccountBalance = balanceAfterInsert.find( + (b) => b.account_address === secondAccount, + ); + expect(secondAccountBalance).toBeTruthy(); + expect(secondAccountBalance?.balance).toEqual("25"); + }); + + test("ERC721 token balance is updated on new block", async () => { + // Current balance table should be empty + const currentBalance = await dbClient.getTokenERC721(); + expect(currentBalance.length).toBe(0); + + // Insert test token metadata + const contractAddr = "0x42EA16C9B9e529dA492909F34f416fEd2bE7c280"; + await dbClient.insertTokenMetadataERC721({ + address: contractAddr, + name: "TestToken", + symbol: "TT", + }); + + // Insert block with initial transfers + const firstAccount = "0x12b60219Ca56110E53F9E79178713C363e8aF999"; + const secondAccount = "0x4675C7e5BaAFBFFbca748158bEcBA61ef3b0a263"; + await dbClient.insertBlockData({ + block: { + number: 18, + created_at: new Date(1671702925 * 1000).toUTCString(), + hash: + "0x2bdfd1957e88297b012a1dc15a51f3691371980749378d10a6186b221d6687e5", + number_of_tx: 1, + }, + transactions: [ + { + index: 0, + hash: + "0x29a3ad97041d01ed610cfab19a091239135ee6bef6d2d7513e94dbb26f8bb1f4", + from: "0x00a329c0648769A73afAc7F9381E08FB43dBEA72", + to: contractAddr, + eth_value: 0, + method_signature: "0xa1448194", + method_name: "", + token_transfers: [ + { + sender: "0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5", + recipient: firstAccount, + value: 1, + }, + { + sender: "0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5", + recipient: firstAccount, + value: 2, + }, + ], + }, + ], + }); + await dbClient.syncTokenBalanceERC721(18); + + // Insert block with transfer to second account + await dbClient.insertBlockData({ + block: { + number: 19, + created_at: new Date(1671702999 * 1000).toUTCString(), + hash: + "0x2bdfd1957e88297b012a1dc15a51f3691371980749378d10a6186b221d6687ff", + number_of_tx: 1, + }, + transactions: [ + { + index: 0, + hash: + "0x29a3ad97041d01ed610cfab19a091239135ee6bef6d2d7513e94dbb26f8bb1aa", + from: "0x00a329c0648769A73afAc7F9381E08FB43dBEA72", + to: contractAddr, + eth_value: 0, + method_signature: "0xa1448194", + method_name: "", + token_transfers: [ + { + sender: firstAccount, + recipient: secondAccount, + value: 2, + }, + ], + }, + ], + }); + await dbClient.syncTokenBalanceERC721(19); + + const balanceAfterInsert = await dbClient.getTokenERC721(); + log.debug("balanceAfterInsert", balanceAfterInsert); + expect(balanceAfterInsert.length).toBe(2); + + // Assert first token owner + const firstToken = balanceAfterInsert.find( + (b) => ((b.token_id as unknown) as string) === "1", + ); + expect(firstToken).toBeTruthy(); + expect(firstToken?.account_address).toEqual(firstAccount); + + // Assert second token owner + const secondToken = balanceAfterInsert.find( + (b) => ((b.token_id as unknown) as string) === "2", + ); + expect(secondToken).toBeTruthy(); + expect(secondToken?.account_address).toEqual(secondAccount); + }); + + test("Only ERC721 token owner and last_owner_change is updated on already issued token", async () => { + // Insert token metadata + const contractAddress = "0x42EA16C9B9e529dA492909F34f416fEd2bE7c280"; + const token = { + address: contractAddress, + name: "TestToken", + symbol: "TT", + }; + await dbClient.insertTokenMetadataERC721(token); + + // Initially there should be no issued tokens + const initialTokens = await dbClient.getTokenERC721(); + expect(initialTokens.length).toBe(0); + + // Insert already issued token + const issuedTokenUri = "my-test-token.uri"; + const firstAccount = "0x12b60219Ca56110E53F9E79178713C363e8aF999"; + await dbClient.upsertTokenERC721({ + account_address: firstAccount, + token_address: contractAddress, + uri: issuedTokenUri, + token_id: 1, + }); + + // Transfer our token + const blockTimestamp = Date.now() + 1000 * 60 * 60 * 24 * 365; // Year from now + const secondAccount = "0x4675C7e5BaAFBFFbca748158bEcBA61ef3b0a263"; + await dbClient.insertBlockData({ + block: { + number: 18, + created_at: new Date(blockTimestamp).toUTCString(), + hash: + "0x2bdfd1957e88297b012a1dc15a51f3691371980749378d10a6186b221d6687e5", + number_of_tx: 1, + }, + transactions: [ + { + index: 0, + hash: + "0x29a3ad97041d01ed610cfab19a091239135ee6bef6d2d7513e94dbb26f8bb1f4", + from: "0x00a329c0648769A73afAc7F9381E08FB43dBEA72", + to: contractAddress, + eth_value: 0, + method_signature: "0xa1448194", + method_name: "", + token_transfers: [ + { + sender: firstAccount, + recipient: secondAccount, + value: 1, + }, + ], + }, + ], + }); + await dbClient.syncTokenBalanceERC721(18); + + const balanceAfterInsert = await dbClient.getTokenERC721(); + log.debug("balanceAfterInsert", balanceAfterInsert); + expect(balanceAfterInsert.length).toBe(1); + + // Assert only token owner and last_owner_change were updated + const updatedToken = balanceAfterInsert[0]; + expect(updatedToken).toMatchObject({ + account_address: secondAccount, // owner changed + token_address: contractAddress, + uri: issuedTokenUri, + token_id: "1", + }); + // timestamp updated + expect(new Date(updatedToken.last_owner_change).toDateString()).toEqual( + new Date(blockTimestamp).toDateString(), + ); + }); + + test("ERC721 token is not updated if if was updated after the transaction was committed (manual token sync)", async () => { + // Insert token metadata + const contractAddress = "0x42EA16C9B9e529dA492909F34f416fEd2bE7c280"; + const token = { + address: contractAddress, + name: "TestToken", + symbol: "TT", + }; + await dbClient.insertTokenMetadataERC721(token); + + // Initially there should be no issued tokens + const initialTokens = await dbClient.getTokenERC721(); + expect(initialTokens.length).toBe(0); + + // Insert already issued token + const issuedTokenUri = "my-test-token.uri"; + const firstAccount = "0x12b60219Ca56110E53F9E79178713C363e8aF999"; + await dbClient.upsertTokenERC721({ + account_address: firstAccount, + token_address: contractAddress, + uri: issuedTokenUri, + token_id: 1, + }); + + // Transfer our token + const blockTimestamp = Date.now() - 1000 * 60 * 60 * 24 * 365; // Year before now (e.g. we process old blocks) + const secondAccount = "0x4675C7e5BaAFBFFbca748158bEcBA61ef3b0a263"; + await dbClient.insertBlockData({ + block: { + number: 18, + created_at: new Date(blockTimestamp).toUTCString(), + hash: + "0x2bdfd1957e88297b012a1dc15a51f3691371980749378d10a6186b221d6687e5", + number_of_tx: 1, + }, + transactions: [ + { + index: 0, + hash: + "0x29a3ad97041d01ed610cfab19a091239135ee6bef6d2d7513e94dbb26f8bb1f4", + from: "0x00a329c0648769A73afAc7F9381E08FB43dBEA72", + to: contractAddress, + eth_value: 0, + method_signature: "0xa1448194", + method_name: "", + token_transfers: [ + { + sender: firstAccount, + recipient: secondAccount, + value: 1, + }, + ], + }, + ], + }); + await dbClient.syncTokenBalanceERC721(18); + + const balanceAfterInsert = await dbClient.getTokenERC721(); + log.debug("balanceAfterInsert", balanceAfterInsert); + expect(balanceAfterInsert.length).toBe(1); + + // Assert only token owner and last_owner_change were updated + const updatedToken = balanceAfterInsert[0]; + expect(updatedToken).toMatchObject({ + account_address: firstAccount, // owner not changed + token_address: contractAddress, + uri: issuedTokenUri, + token_id: "1", + }); + }); +}); diff --git a/packages/cactus-plugin-persistence-ethereum/tsconfig.json b/packages/cactus-plugin-persistence-ethereum/tsconfig.json new file mode 100644 index 0000000000..7671441b27 --- /dev/null +++ b/packages/cactus-plugin-persistence-ethereum/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist/lib", + "declarationDir": "./dist/lib", + "resolveJsonModule": true, + "rootDir": "./src", + "tsBuildInfoFile": "../../.build-cache/cactus-plugin-persistence-ethereum.tsbuildinfo", + }, + "include": ["./src", "./src/**/*.json", "./src/**/*.sql"], + "references": [ + { + "path": "../cactus-core/tsconfig.json" + }, + { + "path": "../cactus-common/tsconfig.json" + }, + { + "path": "../cactus-core-api/tsconfig.json" + }, + { + "path": "../cactus-api-client/tsconfig.json" + }, + { + "path": "../cactus-test-tooling/tsconfig.json" + } + ] +} diff --git a/packages/cactus-test-tooling/src/main/typescript/openethereum/openethereum-test-ledger.ts b/packages/cactus-test-tooling/src/main/typescript/openethereum/openethereum-test-ledger.ts index 074001aad1..e4a5040d12 100644 --- a/packages/cactus-test-tooling/src/main/typescript/openethereum/openethereum-test-ledger.ts +++ b/packages/cactus-test-tooling/src/main/typescript/openethereum/openethereum-test-ledger.ts @@ -291,19 +291,17 @@ export class OpenEthereumTestLedger { ): Promise { // Encode ABI const contractProxy = new this.web3.eth.Contract(abi); - const encodedDeployReq = contractProxy - .deploy({ - data: bytecode, - arguments: args, - }) - .encodeABI(); + const contractTx = contractProxy.deploy({ + data: bytecode, + arguments: args, + }); // Send TX const signedTx = await this.web3.eth.accounts.signTransaction( { from: K_DEV_WHALE_ACCOUNT_PUBLIC_KEY, - data: encodedDeployReq, - gas: 1000000, + data: contractTx.encodeABI(), + gas: 8000000, // Max possible gas nonce: await this.web3.eth.getTransactionCount( K_DEV_WHALE_ACCOUNT_PUBLIC_KEY, ), diff --git a/tsconfig.json b/tsconfig.json index 3eb5852e7c..836aa9fce1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -73,6 +73,9 @@ { "path": "./packages/cactus-verifier-client/tsconfig.json" }, + { + "path": "./packages/cactus-plugin-persistence-ethereum/tsconfig.json" + }, { "path": "./packages/cactus-test-api-client/tsconfig.json" }, diff --git a/yarn.lock b/yarn.lock index a69f684808..9af088bb5c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2541,6 +2541,21 @@ "@ethersproject/properties" "^5.0.3" "@ethersproject/strings" "^5.0.4" +"@ethersproject/abi@5.7.0", "@ethersproject/abi@^5.6.3": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/abi/-/abi-5.7.0.tgz#b3f3e045bbbeed1af3947335c247ad625a44e449" + integrity sha512-351ktp42TiRcYB3H1OP8yajPeAQstMW/yCFokj/AthP9bLHzQFPlOrxOcwYEDkUAICmOHljvN4K39OMTMUa9RA== + dependencies: + "@ethersproject/address" "^5.7.0" + "@ethersproject/bignumber" "^5.7.0" + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/constants" "^5.7.0" + "@ethersproject/hash" "^5.7.0" + "@ethersproject/keccak256" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + "@ethersproject/properties" "^5.7.0" + "@ethersproject/strings" "^5.7.0" + "@ethersproject/abi@^5.1.2": version "5.5.0" resolved "https://registry.yarnpkg.com/@ethersproject/abi/-/abi-5.5.0.tgz#fb52820e22e50b854ff15ce1647cc508d6660613" @@ -2556,21 +2571,6 @@ "@ethersproject/properties" "^5.5.0" "@ethersproject/strings" "^5.5.0" -"@ethersproject/abi@^5.6.3": - version "5.7.0" - resolved "https://registry.yarnpkg.com/@ethersproject/abi/-/abi-5.7.0.tgz#b3f3e045bbbeed1af3947335c247ad625a44e449" - integrity sha512-351ktp42TiRcYB3H1OP8yajPeAQstMW/yCFokj/AthP9bLHzQFPlOrxOcwYEDkUAICmOHljvN4K39OMTMUa9RA== - dependencies: - "@ethersproject/address" "^5.7.0" - "@ethersproject/bignumber" "^5.7.0" - "@ethersproject/bytes" "^5.7.0" - "@ethersproject/constants" "^5.7.0" - "@ethersproject/hash" "^5.7.0" - "@ethersproject/keccak256" "^5.7.0" - "@ethersproject/logger" "^5.7.0" - "@ethersproject/properties" "^5.7.0" - "@ethersproject/strings" "^5.7.0" - "@ethersproject/abstract-provider@^5.5.0": version "5.5.1" resolved "https://registry.yarnpkg.com/@ethersproject/abstract-provider/-/abstract-provider-5.5.1.tgz#2f1f6e8a3ab7d378d8ad0b5718460f85649710c5" @@ -3006,6 +3006,17 @@ resolved "https://registry.yarnpkg.com/@hutson/parse-repository-url/-/parse-repository-url-3.0.2.tgz#98c23c950a3d9b6c8f0daed06da6c3af06981340" integrity sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q== +"@hyperledger/cactus-api-client@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@hyperledger/cactus-api-client/-/cactus-api-client-1.1.2.tgz#20e2fffe1d257cd4ddb710d95885e5bf4e4514c1" + integrity sha512-acOHPwYZ2y7xa9Va7+/RfRuqgWdOvuKbAdmLqD4LbBihTrWm7Usxkw0NrAiUsLHGa1Ru9aANWLLKIaNN0l5moA== + dependencies: + "@hyperledger/cactus-common" "1.1.2" + "@hyperledger/cactus-core" "1.1.2" + "@hyperledger/cactus-core-api" "1.1.2" + "@hyperledger/cactus-plugin-consortium-manual" "1.1.2" + rxjs "7.3.0" + "@hyperledger/cactus-common@0.9.0": version "0.9.0" resolved "https://registry.yarnpkg.com/@hyperledger/cactus-common/-/cactus-common-0.9.0.tgz#b81f23289de8a7b4ecfea3cd891893a184367134" @@ -3050,6 +3061,23 @@ express-openapi-validator "4.12.12" typescript-optional "2.0.1" +"@hyperledger/cactus-plugin-consortium-manual@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@hyperledger/cactus-plugin-consortium-manual/-/cactus-plugin-consortium-manual-1.1.2.tgz#574b94a0a78ec0d474826482d1bcf33715a90827" + integrity sha512-F4QKKCsqZvHxkX8C93NCUVgwuxNj5eJqham+mi2RI4l0DPdwJ7QfOQX8Fv0t1mENOlLUJUSSF+SGaI0LuMhR0g== + dependencies: + "@hyperledger/cactus-common" "1.1.2" + "@hyperledger/cactus-core" "1.1.2" + "@hyperledger/cactus-core-api" "1.1.2" + axios "0.21.4" + body-parser "1.19.0" + express "4.17.1" + jose "4.9.2" + json-stable-stringify "1.0.1" + prom-client "13.2.0" + typescript-optional "2.0.1" + uuid "8.3.2" + "@hyperledger/cactus-test-tooling@1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@hyperledger/cactus-test-tooling/-/cactus-test-tooling-1.1.2.tgz#743d6608c40b35bc392f13792c70ea3b326f303e" @@ -5017,6 +5045,15 @@ dependencies: "@types/node" "*" +"@types/pg@8.6.5": + version "8.6.5" + resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.6.5.tgz#2dce9cb468a6a5e0f1296a59aea3ac75dd27b702" + integrity sha512-tOkGtAqRVkHa/PVZicq67zuujI4Oorfglsr2IbKofDwBSysnaqSx7W1mDqFqdkGE6Fbgh+PZAl0r/BWON/mozw== + dependencies: + "@types/node" "*" + pg-protocol "*" + pg-types "^2.2.0" + "@types/prettier@^2.1.5": version "2.4.4" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.4.4.tgz#5d9b63132df54d8909fce1c3f8ca260fdd693e17" @@ -5415,7 +5452,6 @@ "@ubiquity/ubiquity-ts-client-modified@https://github.com/RafaelAPB/ubiquity-ts-client-mirror.git": version "1.0.0" - uid "1971019809f74889e150c3eaec061e0af841f953" resolved "https://github.com/RafaelAPB/ubiquity-ts-client-mirror.git#1971019809f74889e150c3eaec061e0af841f953" dependencies: axios "0.21.1" @@ -6261,6 +6297,13 @@ async-limiter@~1.0.0: resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== +async-mutex@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/async-mutex/-/async-mutex-0.4.0.tgz#ae8048cd4d04ace94347507504b3cf15e631c25f" + integrity sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA== + dependencies: + tslib "^2.4.0" + async@^1.4.0, async@^1.5.0, async@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" @@ -7288,6 +7331,11 @@ buffer-to-arraybuffer@^0.0.5: resolved "https://registry.yarnpkg.com/buffer-to-arraybuffer/-/buffer-to-arraybuffer-0.0.5.tgz#6064a40fa76eb43c723aba9ef8f6e1216d10511a" integrity sha1-YGSkD6dutDxyOrqe+PbhIW0QURo= +buffer-writer@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/buffer-writer/-/buffer-writer-2.0.0.tgz#ce7eb81a38f7829db09c873f2fbb792c0c98ec04" + integrity sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw== + buffer-xor@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" @@ -18096,6 +18144,11 @@ package-json@^6.3.0: registry-url "^5.0.0" semver "^6.2.0" +packet-reader@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/packet-reader/-/packet-reader-1.0.0.tgz#9238e5480dedabacfe1fe3f2771063f164157d74" + integrity sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ== + pacote@12.0.3: version "12.0.3" resolved "https://registry.yarnpkg.com/pacote/-/pacote-12.0.3.tgz#b6f25868deb810e7e0ddf001be88da2bcaca57c7" @@ -18386,11 +18439,57 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= -pg-connection-string@2.5.0: +pg-connection-string@2.5.0, pg-connection-string@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.5.0.tgz#538cadd0f7e603fc09a12590f3b8a452c2c0cf34" integrity sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ== +pg-int8@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" + integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== + +pg-pool@^3.5.2: + version "3.5.2" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.5.2.tgz#ed1bed1fb8d79f1c6fd5fb1c99e990fbf9ddf178" + integrity sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w== + +pg-protocol@*, pg-protocol@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.5.0.tgz#b5dd452257314565e2d54ab3c132adc46565a6a0" + integrity sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ== + +pg-types@^2.1.0, pg-types@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3" + integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== + dependencies: + pg-int8 "1.0.1" + postgres-array "~2.0.0" + postgres-bytea "~1.0.0" + postgres-date "~1.0.4" + postgres-interval "^1.1.0" + +pg@8.8.0: + version "8.8.0" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.8.0.tgz#a77f41f9d9ede7009abfca54667c775a240da686" + integrity sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw== + dependencies: + buffer-writer "2.0.0" + packet-reader "1.0.0" + pg-connection-string "^2.5.0" + pg-pool "^3.5.2" + pg-protocol "^1.5.0" + pg-types "^2.1.0" + pgpass "1.x" + +pgpass@1.x: + version "1.0.5" + resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.5.tgz#9b873e4a564bb10fa7a7dbd55312728d422a223d" + integrity sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug== + dependencies: + split2 "^4.1.0" + picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" @@ -18810,6 +18909,28 @@ postcss@^8.3.11: picocolors "^1.0.0" source-map-js "^1.0.2" +postgres-array@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" + integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== + +postgres-bytea@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35" + integrity sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w== + +postgres-date@~1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8" + integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q== + +postgres-interval@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" + integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ== + dependencies: + xtend "^4.0.0" + prebuild-install@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.0.1.tgz#c10075727c318efe72412f333e0ef625beaf3870" @@ -21133,6 +21254,11 @@ split2@^3.0.0: dependencies: readable-stream "^3.0.0" +split2@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/split2/-/split2-4.1.0.tgz#101907a24370f85bb782f08adaabe4e281ecf809" + integrity sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ== + split@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/split/-/split-1.0.1.tgz#605bd9be303aa59fb35f9229fbea0ddec9ea07d9" @@ -22371,6 +22497,11 @@ tslib@^1.10.0, tslib@^1.13.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.4.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e" + integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA== + tslib@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a"