From e2366dbc7b48308b3205626fd386223300a2d1b9 Mon Sep 17 00:00:00 2001 From: Fuxing Loh <4266087+fuxingloh@users.noreply.github.com> Date: Tue, 9 Mar 2021 09:36:02 +0800 Subject: [PATCH] jellyfish-core protocol adapter and error handling (#41) * removed main/test net workflow * testcontainers for defichain js ecosystem * check in .idea code styles * automatically pull docker image for testing * cleanup code debt * added escape-hatch, unable to find open handles * added DeFiDRpcError for easier downstream error surfacing * removed json-bigint from container * testcontainers for defichain js ecosystem * check in .idea code styles * removed json-bigint from container * added precision test code updated jest and package config * added container adapter client with mining test * added getNetworkHashPerSecond * added jellyfish-core error handling * moved container_adapter_client.ts to root of test --- package-lock.json | 5 +- .../__tests__/category/mining.test.ts | 81 +++++++++++++++++++ .../__tests__/container_adapter_client.ts | 38 +++++++++ .../jellyfish-core/__tests__/core.test.ts | 50 +++++++++++- .../__tests__/precision.test.ts | 41 ++++++++++ packages/jellyfish-core/jest.config.js | 5 +- packages/jellyfish-core/package.json | 3 + .../jellyfish-core/src/category/mining.ts | 48 +++++++++++ packages/jellyfish-core/src/core.ts | 37 ++++++++- packages/testcontainers/package.json | 3 +- .../testcontainers/src/chains/container.ts | 42 ++++++---- 11 files changed, 325 insertions(+), 28 deletions(-) create mode 100644 packages/jellyfish-core/__tests__/category/mining.test.ts create mode 100644 packages/jellyfish-core/__tests__/container_adapter_client.ts create mode 100644 packages/jellyfish-core/__tests__/precision.test.ts create mode 100644 packages/jellyfish-core/src/category/mining.ts diff --git a/package-lock.json b/package-lock.json index 6e2ae861a3..854f0afedd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30402,6 +30402,9 @@ "devDependencies": { "@defichain/testcontainers": "0.0.0", "typescript": "^4.2.2" + }, + "peerDependencies": { + "bignumber.js": "^9.0.1" } }, "packages/jellyfish-jsonrpc": { @@ -30427,7 +30430,6 @@ "@types/dockerode": "^3.2.2", "@types/node-fetch": "^2.5.8", "dockerode": "^3.2.1", - "json-bigint": "^1.0.0", "node-fetch": "^2.6.1" }, "devDependencies": { @@ -31753,7 +31755,6 @@ "@types/dockerode": "^3.2.2", "@types/node-fetch": "^2.5.8", "dockerode": "^3.2.1", - "json-bigint": "^1.0.0", "node-fetch": "^2.6.1", "typescript": "^4.2.2" } diff --git a/packages/jellyfish-core/__tests__/category/mining.test.ts b/packages/jellyfish-core/__tests__/category/mining.test.ts new file mode 100644 index 0000000000..366403b9b6 --- /dev/null +++ b/packages/jellyfish-core/__tests__/category/mining.test.ts @@ -0,0 +1,81 @@ +import { RegTestContainer, MasterNodeRegTestContainer } from '@defichain/testcontainers' +import { ContainerAdapterClient } from '../container_adapter_client' +import waitForExpect from 'wait-for-expect' + +describe('non masternode', () => { + const container = new RegTestContainer() + const client = new ContainerAdapterClient(container) + + beforeAll(async () => { + await container.start() + await container.waitForReady() + }) + + afterAll(async () => { + await container.stop() + }) + + it('should getMintingInfo', async () => { + const info = await client.mining.getMintingInfo() + + expect(info.blocks).toBe(0) + expect(info.difficulty).toBeDefined() + expect(info.isoperator).toBe(false) + expect(info.networkhashps).toBe(0) + expect(info.pooledtx).toBe(0) + expect(info.chain).toBe('regtest') + expect(info.warnings).toBe('') + }) + + it('should getNetworkHashPerSecond', async () => { + const result = await client.mining.getNetworkHashPerSecond() + expect(result).toBe(0) + }) +}) + +describe('masternode', () => { + const container = new MasterNodeRegTestContainer() + const client = new ContainerAdapterClient(container) + + beforeAll(async () => { + await container.start() + await container.waitForReady() + }) + + afterAll(async () => { + await container.stop() + }) + + it('should getMintingInfo', async () => { + const info = await client.mining.getMintingInfo() + + await waitForExpect(async () => { + const info = await container.getMintingInfo() + expect(info.blocks).toBeGreaterThan(1) + }) + + expect(info.blocks).toBeGreaterThan(0) + + expect(info.currentblockweight).toBeGreaterThan(0) + expect(info.currentblocktx).toBe(0) + + expect(info.difficulty).toBeDefined() + expect(info.isoperator).toBe(true) + + expect(info.masternodeid).toBeDefined() + expect(info.masternodeoperator).toBeDefined() + expect(info.masternodestate).toBe('ENABLED') + expect(info.generate).toBe(true) + expect(info.mintedblocks).toBe(0) + + expect(info.networkhashps).toBeGreaterThan(0) + expect(info.pooledtx).toBe(0) + expect(info.chain).toBe('regtest') + expect(info.warnings).toBe('') + }) + + it('should getNetworkHashPerSecond', async () => { + const result = await client.mining.getNetworkHashPerSecond() + expect(result).toBeGreaterThan(0) + }) +}) diff --git a/packages/jellyfish-core/__tests__/container_adapter_client.ts b/packages/jellyfish-core/__tests__/container_adapter_client.ts new file mode 100644 index 0000000000..0c898b80d5 --- /dev/null +++ b/packages/jellyfish-core/__tests__/container_adapter_client.ts @@ -0,0 +1,38 @@ +import { JellyfishClient, JellyfishError } from '../src/core' +import { DeFiDContainer } from '@defichain/testcontainers' + +/** + * Jellyfish client adapter for container + * To be used for testing jellyfish-core protocol data binding only + */ +export class ContainerAdapterClient extends JellyfishClient { + protected readonly container: DeFiDContainer + + constructor (container: DeFiDContainer) { + super() + this.container = container + } + + /** + * Wrap the call from client to testcontainers. + */ + async call (method: string, params: any[]): Promise { + const body = JSON.stringify({ + jsonrpc: '1.0', + id: Math.floor(Math.random() * 100000000000000), + method: method, + params: params + }) + + const text = await this.container.post(body) + const response = JSON.parse(text) + + const { result, error } = response + + if (error !== null) { + throw new JellyfishError(error) + } + + return result + } +} diff --git a/packages/jellyfish-core/__tests__/core.test.ts b/packages/jellyfish-core/__tests__/core.test.ts index 4205cb7ac2..f1400e0566 100644 --- a/packages/jellyfish-core/__tests__/core.test.ts +++ b/packages/jellyfish-core/__tests__/core.test.ts @@ -1,5 +1,49 @@ -import { getName } from '../src/core' +import { MintingInfo, JellyfishError, JellyfishClient } from '../src/core' +import { ContainerAdapterClient } from './container_adapter_client' +import { RegTestContainer } from '@defichain/testcontainers' -it('should getName jellyfish-core', () => { - expect(getName()).toBe('jellyfish-core') +class TestClient extends JellyfishClient { + async call (method: string, payload: any[]): Promise { + throw new JellyfishError({ + code: 1, message: 'error message from node' + }) + } +} + +it('should export client', async () => { + const client = new TestClient() + return await expect(client.call('fail', [])) + .rejects.toThrowError(JellyfishError) +}) + +it('should export categories', async () => { + const client = new TestClient() + return await expect(async () => { + const info: MintingInfo = await client.mining.getMintingInfo() + console.log(info) + }).rejects.toThrowError(JellyfishError) +}) + +describe('JellyfishError handling', () => { + const container = new RegTestContainer() + const client = new ContainerAdapterClient(container) + + beforeAll(async () => { + await container.start() + await container.waitForReady() + }) + + afterAll(async () => { + await container.stop() + }) + + it('invalid method should throw -32601 with message as structured', async () => { + return await expect(client.call('invalid', [])) + .rejects.toThrowError(/JellyfishError from RPC: 'Method not found', code: -32601/) + }) + + it('importprivkey should throw -5 with message as structured', async () => { + return await expect(client.call('importprivkey', ['invalid-key'])) + .rejects.toThrowError(/JellyfishError from RPC: 'Invalid private key encoding', code: -5/) + }) }) diff --git a/packages/jellyfish-core/__tests__/precision.test.ts b/packages/jellyfish-core/__tests__/precision.test.ts new file mode 100644 index 0000000000..9b3cd7da27 --- /dev/null +++ b/packages/jellyfish-core/__tests__/precision.test.ts @@ -0,0 +1,41 @@ +import { BigNumber } from '../src/core' + +/** + * Why JavaScript default number should not be used as it lose precision + */ +describe('number will lose precision', () => { + it('1200000000.00000003 should not lose precision as a number but it does', () => { + const dfi = 1200000000.00000003 + + expect(() => { + expect(dfi.toString()).toBe('1200000000.00000003') + }).toThrow() + }) + + it('12.00000003 * 1000000 should be 12000000.03 but its not', () => { + const obj = JSON.parse('{"dfi": 12.00000003}') + const dfi = obj.dfi * 1000000 + + expect(() => { + expect(dfi).toBe(12000000.03) + }).toThrow() + }) +}) + +/** + * BigNumber from 'bignumber.js' implementations will not lose precision + */ +describe('BigNumber should not lose precision', () => { + it('1200000000.00000003 should not lose precision', () => { + const dfi = new BigNumber('1200000000.00000003') + + expect(dfi.toString()).toBe('1200000000.00000003') + }) + + it('12.00000003 * 1000000 should be 12000000.03', () => { + const obj = { dfi: new BigNumber('12.00000003') } + const dfi = obj.dfi.multipliedBy(1000000) + + expect(dfi.toString()).toBe('12000000.03') + }) +}) diff --git a/packages/jellyfish-core/jest.config.js b/packages/jellyfish-core/jest.config.js index 440ec8dbe9..11d9802ff4 100644 --- a/packages/jellyfish-core/jest.config.js +++ b/packages/jellyfish-core/jest.config.js @@ -1,11 +1,12 @@ module.exports = { testEnvironment: 'node', testMatch: [ - '**/__tests__/**/*.ts' + '**/__tests__/**/*.test.ts' ], transform: { '^.+\\.ts$': 'ts-jest' }, verbose: true, - clearMocks: true + clearMocks: true, + testTimeout: 120000 } diff --git a/packages/jellyfish-core/package.json b/packages/jellyfish-core/package.json index 4a481b8597..9f9814b401 100644 --- a/packages/jellyfish-core/package.json +++ b/packages/jellyfish-core/package.json @@ -34,6 +34,9 @@ "publish:next": "npm publish --tag next", "publish:latest": "npm publish --tag latest" }, + "peerDependencies": { + "bignumber.js": "^9.0.1" + }, "devDependencies": { "@defichain/testcontainers": "0.0.0", "typescript": "^4.2.2" diff --git a/packages/jellyfish-core/src/category/mining.ts b/packages/jellyfish-core/src/category/mining.ts new file mode 100644 index 0000000000..e55f4f2eb5 --- /dev/null +++ b/packages/jellyfish-core/src/category/mining.ts @@ -0,0 +1,48 @@ +import { JellyfishClient } from '../core' + +export interface MintingInfo { + blocks: number + currentblockweight?: number + currentblocktx?: number + difficulty: string + isoperator: boolean + masternodeid?: string + masternodeoperator?: string + masternodestate?: 'PRE_ENABLED' | 'ENABLED' | 'PRE_RESIGNED' | 'RESIGNED' | 'PRE_BANNED' | 'BANNED' + generate?: boolean + mintedblocks?: number + networkhashps: number + pooledtx: number + chain: 'main' | 'test' | 'regtest' | string + warnings: string +} + +/** + * Minting related RPC calls for DeFiChain + */ +export class Mining { + private readonly client: JellyfishClient + + constructor (client: JellyfishClient) { + this.client = client + } + + /** + * Returns the estimated network hashes per second + * + * @param nblocks to estimate since last difficulty change. + * @param height to estimate at the time of the given height. + * @return Promise + */ + async getNetworkHashPerSecond (nblocks: number = 120, height: number = -1): Promise { + return await this.client.call('getnetworkhashps', [nblocks, height]) + } + + /** + * Get minting-related information + * @return Promise + */ + async getMintingInfo (): Promise { + return await this.client.call('getmintinginfo', []) + } +} diff --git a/packages/jellyfish-core/src/core.ts b/packages/jellyfish-core/src/core.ts index a3b6ae3bea..731dec3c7a 100644 --- a/packages/jellyfish-core/src/core.ts +++ b/packages/jellyfish-core/src/core.ts @@ -1,3 +1,36 @@ -export function getName (): string { - return 'jellyfish-core' +import BigNumber from 'bignumber.js' +import { Mining } from './category/mining' + +// TODO(fuxingloh): might need to restructure how it's exported as this grows, will look into this soon +export * from './category/mining' +export { BigNumber } + +/** + * JellyfishClient; a protocol agnostic DeFiChain node client, RPC calls are separated into their category. + */ +export abstract class JellyfishClient { + public readonly mining = new Mining(this) + + /** + * A promise based procedure call handling + * + * @param method name + * @param params payload + * @throws JellyfishError + */ + abstract call (method: string, params: any[]): Promise +} + +/** + * JellyfishError; where jellyfish/defichain errors are encapsulated into. + */ +export class JellyfishError extends Error { + public readonly code: number + public readonly rawMessage: string + + constructor (error: { code: number, message: string }) { + super(`JellyfishError from RPC: '${error.message}', code: ${error.code}`) + this.code = error.code + this.rawMessage = error.message + } } diff --git a/packages/testcontainers/package.json b/packages/testcontainers/package.json index e84f8e3e4c..e6d2de8dd3 100644 --- a/packages/testcontainers/package.json +++ b/packages/testcontainers/package.json @@ -35,8 +35,7 @@ "@types/dockerode": "^3.2.2", "@types/node-fetch": "^2.5.8", "dockerode": "^3.2.1", - "node-fetch": "^2.6.1", - "json-bigint": "^1.0.0" + "node-fetch": "^2.6.1" }, "devDependencies": { "typescript": "^4.2.2" diff --git a/packages/testcontainers/src/chains/container.ts b/packages/testcontainers/src/chains/container.ts index a61d25b1d0..bb5255e50a 100644 --- a/packages/testcontainers/src/chains/container.ts +++ b/packages/testcontainers/src/chains/container.ts @@ -1,6 +1,5 @@ import Dockerode, { DockerOptions, Container, ContainerInfo } from 'dockerode' import fetch from 'node-fetch' -import JSONBig from 'json-bigint' /** * Types of network as per https://github.com/DeFiCh/ain/blob/bc231241/src/chainparams.cpp#L825-L836 @@ -193,32 +192,41 @@ export abstract class DeFiDContainer { } /** - * Utility rpc function for the current node, for convenience sake. - * This is not error checked, it will just return the raw result. - * @throws DeFiDRpcError for rpc call errors + * For convenience sake, utility rpc for the current node. + * JSON 'result' is parsed and returned + * @throws DeFiDRpcError is raised for RPC errors */ async call (method: string, params: any = []): Promise { - const url = await this.getCachedRpcUrl() - const response = await fetch(url, { - method: 'POST', - body: JSONBig.stringify({ - jsonrpc: '1.0', - id: Math.floor(Math.random() * 10000000000000000), - method: method, - params: params - }) + const body = JSON.stringify({ + jsonrpc: '1.0', + id: Math.floor(Math.random() * 100000000000000), + method: method, + params: params }) - const text = await response.text() - const { result, error } = JSONBig.parse(text) - // surface as DeFiDRpcError for downstream type checking + const text = await this.post(body) + const { result, error } = JSON.parse(text) + if (error !== null) { - throw new DeFiDRpcError(result) + throw new DeFiDRpcError(error) } return result } + /** + * For convenience sake, HTTP post to the RPC URL for the current node. + * Not error checked, returns the raw JSON as string. + */ + async post (body: string): Promise { + const url = await this.getCachedRpcUrl() + const response = await fetch(url, { + method: 'POST', + body: body + }) + return await response.text() + } + /** * Convenience method to getmintinginfo, typing mapping is non exhaustive */