From 36d11134b74e9285ae36229fdaa3f33876bf3cee Mon Sep 17 00:00:00 2001 From: Fuxing Loh Date: Tue, 2 Mar 2021 10:22:02 +0800 Subject: [PATCH] monorepo development rfc --- packages/jellyfish-core/src/api.ts | 17 ++ packages/jellyfish-core/src/core.ts | 20 ++ .../jellyfish-core/src/methods/blockchain.ts | 13 ++ packages/jellyfish-core/src/methods/index.ts | 6 + packages/jellyfish-core/src/methods/mining.ts | 23 +++ .../jellyfish-core/src/methods/network.ts | 18 ++ packages/jellyfish-core/src/methods/wallet.ts | 18 ++ .../jellyfish-jsonrpc/__tests__/json.test.ts | 10 + .../__tests__/jsonrpc.test.ts | 48 +++++ packages/jellyfish-jsonrpc/src/jsonrpc.ts | 50 +++++ .../__tests__/jsonrpc/blockchain.test.ts | 24 +++ .../__tests__/jsonrpc/mining.test.ts | 24 +++ packages/jellyfish/src/jellyfish.ts | 14 ++ .../testcontainers/__tests__/index.test.ts | 64 ++++++ packages/testcontainers/src/docker.ts | 192 ++++++++++++++++++ packages/testcontainers/src/index.ts | 4 + packages/testcontainers/src/main_net.ts | 12 ++ packages/testcontainers/src/reg_test.ts | 18 ++ packages/testcontainers/src/test_net.ts | 16 ++ 19 files changed, 591 insertions(+) create mode 100644 packages/jellyfish-core/src/api.ts create mode 100644 packages/jellyfish-core/src/core.ts create mode 100644 packages/jellyfish-core/src/methods/blockchain.ts create mode 100644 packages/jellyfish-core/src/methods/index.ts create mode 100644 packages/jellyfish-core/src/methods/mining.ts create mode 100644 packages/jellyfish-core/src/methods/network.ts create mode 100644 packages/jellyfish-core/src/methods/wallet.ts create mode 100644 packages/jellyfish-jsonrpc/__tests__/json.test.ts create mode 100644 packages/jellyfish-jsonrpc/__tests__/jsonrpc.test.ts create mode 100644 packages/jellyfish-jsonrpc/src/jsonrpc.ts create mode 100644 packages/jellyfish/__tests__/jsonrpc/blockchain.test.ts create mode 100644 packages/jellyfish/__tests__/jsonrpc/mining.test.ts create mode 100644 packages/jellyfish/src/jellyfish.ts create mode 100644 packages/testcontainers/__tests__/index.test.ts create mode 100644 packages/testcontainers/src/docker.ts create mode 100644 packages/testcontainers/src/index.ts create mode 100644 packages/testcontainers/src/main_net.ts create mode 100644 packages/testcontainers/src/reg_test.ts create mode 100644 packages/testcontainers/src/test_net.ts diff --git a/packages/jellyfish-core/src/api.ts b/packages/jellyfish-core/src/api.ts new file mode 100644 index 0000000000..2f0fa63b10 --- /dev/null +++ b/packages/jellyfish-core/src/api.ts @@ -0,0 +1,17 @@ +/** + * Payload to send to DeFiChain node + */ +/* eslint @typescript-eslint/no-explicit-any: off */ +export type Payload = any + +export type Call = (method: string, payload: Payload) => Promise +export type Client = {call: Call} + +export class JellyfishError extends Error { + readonly status?: number + + constructor(message: string, status?: number) { + super(message) + this.status = status + } +} diff --git a/packages/jellyfish-core/src/core.ts b/packages/jellyfish-core/src/core.ts new file mode 100644 index 0000000000..8de331117f --- /dev/null +++ b/packages/jellyfish-core/src/core.ts @@ -0,0 +1,20 @@ +import * as methods from './methods' +import {Client, Payload, JellyfishError} from './api' +export {methods, Client, Payload, JellyfishError} + +/** + * Protocol agnostic DeFiChain node client, APIs separated into their category. + */ +export abstract class JellyfishClient implements Client { + readonly blockchain = new methods.Blockchain(this) + readonly mining = new methods.Mining(this) + readonly network = new methods.Network(this) + readonly wallet = new methods.Wallet(this) + + // TODO(fuxingloh): all rpc categories to be implemented after RFC + + /** + * Abstracted isomorphic promise based procedure call handling + */ + abstract call(method: string, payload: Payload): Promise +} diff --git a/packages/jellyfish-core/src/methods/blockchain.ts b/packages/jellyfish-core/src/methods/blockchain.ts new file mode 100644 index 0000000000..0b32d29c8e --- /dev/null +++ b/packages/jellyfish-core/src/methods/blockchain.ts @@ -0,0 +1,13 @@ +import {Client} from '../api' + +export class Blockchain { + private readonly client: Client + + constructor(client: Client) { + this.client = client + } + + async getBestBlockHash(): Promise { + return await this.client.call('getbestblockhash', []) + } +} diff --git a/packages/jellyfish-core/src/methods/index.ts b/packages/jellyfish-core/src/methods/index.ts new file mode 100644 index 0000000000..6a9ef14e3d --- /dev/null +++ b/packages/jellyfish-core/src/methods/index.ts @@ -0,0 +1,6 @@ +import {Blockchain} from './blockchain' +import {Mining} from './mining' +import {Network} from './network' +import {Wallet} from './wallet' + +export {Blockchain, Mining, Network, Wallet} diff --git a/packages/jellyfish-core/src/methods/mining.ts b/packages/jellyfish-core/src/methods/mining.ts new file mode 100644 index 0000000000..7e48ace9dc --- /dev/null +++ b/packages/jellyfish-core/src/methods/mining.ts @@ -0,0 +1,23 @@ +import {Client} from '../api' + +export interface MintingInfo { + blocks: number + difficulty: number + isoperator: boolean + networkhashps: number + pooledtx: number + chain: 'main' | 'test' | 'regtest' + warnings: string +} + +export class Mining { + private readonly client: Client + + constructor(client: Client) { + this.client = client + } + + async getMintingInfo(): Promise { + return this.client.call('getmintinginfo', []) + } +} diff --git a/packages/jellyfish-core/src/methods/network.ts b/packages/jellyfish-core/src/methods/network.ts new file mode 100644 index 0000000000..22e200755b --- /dev/null +++ b/packages/jellyfish-core/src/methods/network.ts @@ -0,0 +1,18 @@ +import {Client} from '../api' + +export class Network { + private readonly client: Client + + constructor(client: Client) { + this.client = client + } + + /** + * Requests that a ping be sent to all other nodes, to measure ping time. + * Results provided in getpeerinfo, pingtime and pingwait fields are decimal seconds. + * Ping command is handled in queue with all other commands, so it measures processing backlog, not just network ping. + */ + async ping(): Promise { + return this.client.call('ping', []) + } +} diff --git a/packages/jellyfish-core/src/methods/wallet.ts b/packages/jellyfish-core/src/methods/wallet.ts new file mode 100644 index 0000000000..f8e042a1f1 --- /dev/null +++ b/packages/jellyfish-core/src/methods/wallet.ts @@ -0,0 +1,18 @@ +import {Client} from '../api' + +interface AddressInfo { + address: string + hex?: string +} + +export class Wallet { + private readonly client: Client + + constructor(client: Client) { + this.client = client + } + + async getAddressInfo(address: string): Promise { + return this.client.call('getaddressinfo', [address]) + } +} diff --git a/packages/jellyfish-jsonrpc/__tests__/json.test.ts b/packages/jellyfish-jsonrpc/__tests__/json.test.ts new file mode 100644 index 0000000000..3d1089d61a --- /dev/null +++ b/packages/jellyfish-jsonrpc/__tests__/json.test.ts @@ -0,0 +1,10 @@ +import {JellyfishJsonRpc} from '../src/jsonrpc' + +describe('JSON-RPC 1.0', () => { + it('should have different ids', () => { + // TODO(fuxingloh): AOP; intercept instead of direct testing + const first = JellyfishJsonRpc.stringify('diffid', []) + const second = JellyfishJsonRpc.stringify('diffid', []) + expect(first).not.toBe(second) + }) +}) diff --git a/packages/jellyfish-jsonrpc/__tests__/jsonrpc.test.ts b/packages/jellyfish-jsonrpc/__tests__/jsonrpc.test.ts new file mode 100644 index 0000000000..200027387b --- /dev/null +++ b/packages/jellyfish-jsonrpc/__tests__/jsonrpc.test.ts @@ -0,0 +1,48 @@ +import {JellyfishJsonRpc} from '../src/jsonrpc' +import {RegTestDocker} from '@defichain/testcontainers' + +describe('error handling', () => { + const node = new RegTestDocker() + + beforeAll(async () => { + await node.start({ + user: 'foo', + password: 'bar', + }) + await node.ready() + }) + + afterAll(async () => { + await node.stop() + }) + + it('should 401 unauthorized', async () => { + const port = await node.getRpcPort() + const rpc = new JellyfishJsonRpc(`http://foo:foo@127.0.0.1:${port}`) + const call = rpc.call('getmintinginfo', []) + return expect(call).rejects.toThrow(/Unauthorized/) + }) +}) + +describe('as expected', () => { + const node = new RegTestDocker() + + beforeAll(async () => { + await node.start({ + user: 'foo', + password: 'bar', + }) + await node.ready() + }) + + afterAll(async () => { + await node.stop() + }) + + it('should getmintinginfo', async () => { + const rpc = new JellyfishJsonRpc(await node.getRpcUrl()) + const result: any = await rpc.call('getmintinginfo', []) + expect(result.blocks).toBe(0) + expect(result.chain).toBe('regtest') + }) +}) diff --git a/packages/jellyfish-jsonrpc/src/jsonrpc.ts b/packages/jellyfish-jsonrpc/src/jsonrpc.ts new file mode 100644 index 0000000000..3ac1301d5b --- /dev/null +++ b/packages/jellyfish-jsonrpc/src/jsonrpc.ts @@ -0,0 +1,50 @@ +import {JellyfishClient, JellyfishError, Payload} from '@defichain/jellyfish-core' +import fetch from 'cross-fetch' +import JSONBig from 'json-bigint' + +/** + * A JSON-RPC client implementation for connecting to a DeFiChain node. + */ +export class JellyfishJsonRpc extends JellyfishClient { + private readonly url: string + + /** + * Construct a Jellyfish client to connect to a DeFiChain node via JSON-RPC. + * + * @param url + */ + constructor(url: string) { + super() + this.url = url + } + + async call(method: string, payload: Payload): Promise { + const body = JellyfishJsonRpc.stringify(method, payload) + const response = await fetch(this.url, { + method: 'POST', + body: body, + }) + + return await JellyfishJsonRpc.parse(response) + } + + static async parse(response: Response): Promise { + if (response.status !== 200) { + throw new JellyfishError(response.statusText, response.status) + } + + // TODO(fuxingloh): validation? + const text = await response.text() + const data = JSONBig.parse(text) + return data.result + } + + static stringify(method: string, payload: Payload): string { + return JSONBig.stringify({ + jsonrpc: '1.0', + id: Math.floor(Math.random() * 10000000000000000), + method: method, + params: payload, + }) + } +} diff --git a/packages/jellyfish/__tests__/jsonrpc/blockchain.test.ts b/packages/jellyfish/__tests__/jsonrpc/blockchain.test.ts new file mode 100644 index 0000000000..b9aadb07ba --- /dev/null +++ b/packages/jellyfish/__tests__/jsonrpc/blockchain.test.ts @@ -0,0 +1,24 @@ +import Jellyfish from '../../src/jellyfish' +import {RegTestDocker} from "@defichain/testcontainers"; + +describe('Blockchain', () => { + const node = new RegTestDocker() + + beforeAll(async () => { + await node.start({ + user: 'foo', + password: 'bar', + }) + await node.ready() + }) + + afterAll(async () => { + await node.stop() + }) + + it('getBestBlockHash', async () => { + const client = Jellyfish.jsonrpc(await node.getRpcUrl()) + const result = await client.blockchain.getBestBlockHash() + console.log(result) + }) +}) diff --git a/packages/jellyfish/__tests__/jsonrpc/mining.test.ts b/packages/jellyfish/__tests__/jsonrpc/mining.test.ts new file mode 100644 index 0000000000..fd3f8b6bdb --- /dev/null +++ b/packages/jellyfish/__tests__/jsonrpc/mining.test.ts @@ -0,0 +1,24 @@ +import Jellyfish from '../../src/jellyfish' +import {RegTestDocker} from "@defichain/testcontainers"; + +describe('Mining', () => { + const node = new RegTestDocker() + + beforeAll(async () => { + await node.start({ + user: 'foo', + password: 'bar', + }) + await node.ready() + }) + + afterAll(async () => { + await node.stop() + }) + + it('getMintingInfo', async () => { + const client = Jellyfish.jsonrpc(await node.getRpcUrl()) + const result = await client.mining.getMintingInfo() + expect(result.chain).toBe('regtest') + }) +}) diff --git a/packages/jellyfish/src/jellyfish.ts b/packages/jellyfish/src/jellyfish.ts new file mode 100644 index 0000000000..8062b94623 --- /dev/null +++ b/packages/jellyfish/src/jellyfish.ts @@ -0,0 +1,14 @@ +import {JellyfishJsonRpc} from '@defichain/jellyfish-jsonrpc' + +export const Jellyfish = { + /** + * Initialize a DeFiChain client and use jsonrpc for interfacing + * + * @param url + */ + jsonrpc(url: string): JellyfishJsonRpc { + return new JellyfishJsonRpc(url) + }, +} + +export default Jellyfish diff --git a/packages/testcontainers/__tests__/index.test.ts b/packages/testcontainers/__tests__/index.test.ts new file mode 100644 index 0000000000..00dca31bf5 --- /dev/null +++ b/packages/testcontainers/__tests__/index.test.ts @@ -0,0 +1,64 @@ +import {MainNetDocker, RegTestDocker, TestNetDocker} from '../src' + +describe('reg test', () => { + const node = new RegTestDocker() + + beforeEach(async () => { + await node.start({ + user: 'foo', + password: 'bar', + }) + await node.ready() + }) + + afterEach(async () => { + await node.stop() + }) + + it('should getmintinginfo and chain should be regtest', async () => { + const result = await node.call('getmintinginfo', []) + expect(result.result.chain).toBe('regtest') + }) +}) + +describe('test net', () => { + const node = new TestNetDocker() + + beforeEach(async () => { + await node.start({ + user: 'foo', + password: 'bar', + }) + await node.ready() + }) + + afterEach(async () => { + await node.stop() + }) + + it('should getmintinginfo and chain should be regtest', async () => { + const result = await node.call('getmintinginfo', []) + expect(result.result.chain).toBe('test') + }) +}) + +describe('main net', () => { + const node = new MainNetDocker() + + beforeEach(async () => { + await node.start({ + user: 'foo', + password: 'bar', + }) + await node.ready() + }) + + afterEach(async () => { + await node.stop() + }) + + it('should getmintinginfo and chain should be regtest', async () => { + const result = await node.call('getmintinginfo', []) + expect(result.result.chain).toBe('main') + }) +}) diff --git a/packages/testcontainers/src/docker.ts b/packages/testcontainers/src/docker.ts new file mode 100644 index 0000000000..e7f6d1ceb7 --- /dev/null +++ b/packages/testcontainers/src/docker.ts @@ -0,0 +1,192 @@ +import Dockerode, {DockerOptions, Container} from 'dockerode' +import fetch from 'node-fetch' +import JSONBig from 'json-bigint' + +export type Network = 'mainnet' | 'testnet' | 'regtest' + +export interface StartOptions { + // TODO(fuxingloh): change to cookie based auth soon + user: string + password: string +} + +/** + * DeFiChain defid node managed in docker + */ +export abstract class DeFiChainDocker { + protected static readonly PREFIX = 'defichain-testcontainers-' + protected readonly docker: Dockerode + protected readonly image = 'defi/defichain:1.5.0' + protected readonly network: Network + + protected container?: Container + protected startOptions?: StartOptions + + protected constructor(network: Network, options?: DockerOptions) { + this.docker = new Dockerode(options) + this.network = network + } + + /** + * Generate a name for a new docker container with network type and random number + */ + protected generateName(): string { + const rand = Math.floor(Math.random() * 10000000) + return `${DeFiChainDocker.PREFIX}-${this.network}-${rand}` + } + + protected getCmd(opts: StartOptions): string[] { + return [ + 'defid', + '-printtoconsole', + '-rpcallowip=172.17.0.0/16', + '-rpcbind=0.0.0.0', + `-rpcuser=${opts.user}`, + `-rpcpassword=${opts.password}`, + ] + } + + /** + * Start defid node on docker + * + * @param startOptions + */ + async start(startOptions: StartOptions): Promise { + this.startOptions = startOptions + this.container = await this.docker.createContainer({ + name: this.generateName(), + Image: this.image, + Tty: true, + Cmd: this.getCmd(startOptions), + HostConfig: { + PublishAllPorts: true, + }, + }) + await this.container.start() + } + + /** + * Get host machine port + * + * @param name of ExposedPorts e.g. '80/tcp' + */ + async getPort(name: string): Promise { + return new Promise((resolve, reject) => { + this.container!.inspect(function (err, data) { + if (err) { + reject(err) + } else { + resolve(data!.NetworkSettings.Ports[name][0].HostPort) + } + }) + }) + } + + /** + * Get host machine port used for defid rpc + */ + public abstract getRpcPort(): Promise + + /** + * Get host machine url used for defid rpc calls with auth + */ + async getRpcUrl() { + const port = await this.getRpcPort() + const user = this.startOptions?.user + const password = this.startOptions?.password + return `http://${user}:${password}@127.0.0.1:${port}/` + } + + /** + * Utility rpc function for the current node. + * This is not error checked, it will just return the raw result. + * + * @param method + * @param params + */ + async call(method: string, params: any): Promise { + const url = await this.getRpcUrl() + const response = await fetch(url, { + method: 'POST', + body: JSONBig.stringify({ + jsonrpc: '1.0', + id: Math.floor(Math.random() * 10000000000000000), + method: method, + params: params, + }), + }) + const text = await response.text() + return JSONBig.parse(text) + } + + /** + * @param timeout millis, default to 15000 ms + * @param interval millis, default to 200ms + */ + async ready(timeout = 15000, interval = 200): Promise { + const expiredAt = Date.now() + timeout + + return new Promise((resolve, reject) => { + const checkReady = async () => { + try { + const result = await this.call('getmintinginfo', []) + if (result?.result) { + return resolve() + } + } catch (err) {} + + if (expiredAt < Date.now()) { + return reject(new Error(`DeFiChain docker not ready within given timeout of ${timeout}ms`)) + } + + setTimeout(() => { + checkReady() + }, interval) + } + + checkReady() + }) + } + + /** + * Stop the current node and also automatically stop nodes that are stale. + * Stale nodes are nodes that are running for 2 hours + */ + async stop(): Promise { + await this.container?.stop() + await this.container?.remove() + + return new Promise((resolve, reject) => { + this.docker.listContainers({all: 1}, (error, result) => { + if (error) { + reject(error) + return + } + if (!result) { + return + } + + const promises = result + .filter((containerInfo) => { + // filter docker container with the same prefix + return containerInfo.Names.filter((value) => value.startsWith(DeFiChainDocker.PREFIX)) + }) + .filter((containerInfo) => { + // filter docker container that are created 2 hours ago + return containerInfo.Created + 60 * 60 * 2 < Date.now() / 1000 + }) + .map( + async (containerInfo): Promise => { + const container = this.docker.getContainer(containerInfo.Id) + if (containerInfo.State === 'running') { + await container.stop() + } + await container.remove() + } + ) + + Promise.all(promises).finally(resolve) + }) + }) + } +} diff --git a/packages/testcontainers/src/index.ts b/packages/testcontainers/src/index.ts new file mode 100644 index 0000000000..a4c9075dd6 --- /dev/null +++ b/packages/testcontainers/src/index.ts @@ -0,0 +1,4 @@ +export {StartOptions, DeFiChainDocker} from './docker' +export {RegTestDocker} from './reg_test' +export {TestNetDocker} from './test_net' +export {MainNetDocker} from './main_net' diff --git a/packages/testcontainers/src/main_net.ts b/packages/testcontainers/src/main_net.ts new file mode 100644 index 0000000000..871c55b6ee --- /dev/null +++ b/packages/testcontainers/src/main_net.ts @@ -0,0 +1,12 @@ +import {DockerOptions} from 'dockerode' +import {DeFiChainDocker} from './docker' + +export class MainNetDocker extends DeFiChainDocker { + constructor(options?: DockerOptions) { + super('mainnet', options) + } + + async getRpcPort(): Promise { + return this.getPort('8554/tcp') + } +} diff --git a/packages/testcontainers/src/reg_test.ts b/packages/testcontainers/src/reg_test.ts new file mode 100644 index 0000000000..1b15f7ff5c --- /dev/null +++ b/packages/testcontainers/src/reg_test.ts @@ -0,0 +1,18 @@ +import {DockerOptions} from 'dockerode' +import {DeFiChainDocker, StartOptions} from './docker' + +export class RegTestDocker extends DeFiChainDocker { + constructor(options?: DockerOptions) { + super('regtest', options) + } + + protected getCmd(opts: StartOptions): string[] { + return [...super.getCmd(opts), '-regtest=1'] + } + + // TODO(fuxingloh): add ability to mint token for reg test + + async getRpcPort(): Promise { + return this.getPort('19554/tcp') + } +} diff --git a/packages/testcontainers/src/test_net.ts b/packages/testcontainers/src/test_net.ts new file mode 100644 index 0000000000..6ab4c46a18 --- /dev/null +++ b/packages/testcontainers/src/test_net.ts @@ -0,0 +1,16 @@ +import {DockerOptions} from 'dockerode' +import {DeFiChainDocker, StartOptions} from './docker' + +export class TestNetDocker extends DeFiChainDocker { + constructor(options?: DockerOptions) { + super('testnet', options) + } + + protected getCmd(opts: StartOptions): string[] { + return [...super.getCmd(opts), '-testnet=1'] + } + + async getRpcPort(): Promise { + return this.getPort('18554/tcp') + } +}