diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1256917ba..96e0f5914a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,3 @@ -# Shift-left testing to mitigation upstream risk name: CI on: @@ -9,7 +8,7 @@ on: jobs: build: - name: Build + name: Build & Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 @@ -21,50 +20,5 @@ jobs: - run: npm run build - run: npm run standard - - run: npx --no-install jest --ci --coverage + - run: npx --no-install jest --ci --coverage --forceExit - run: npx codecov - - reg-net: - name: RegNet - runs-on: ubuntu-latest - needs: [ build ] - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: '15' - - # TODO(fuxingloh): reg net testing - - run: | - echo "::error::not yet implemented" - exit 1 - - test-net: - name: TestNet - runs-on: ubuntu-latest - needs: [ build ] - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: '15' - - # TODO(fuxingloh): test net testing - - run: | - echo "::error::not yet implemented" - exit 1 - - main-net: - name: MainNet - runs-on: ubuntu-latest - needs: [ reg-net, test-net ] - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: '15' - - # TODO(fuxingloh): main net testing? - - run: | - echo "::error::not yet implemented" - exit 1 diff --git a/.gitignore b/.gitignore index 979b6f1edd..a4bf8054dd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,11 @@ .idea/* !.idea/inspectionProfiles/ !.idea/dictionaries/ +!.idea/codeStyles/ !.idea/vcs.xml !.idea/modules.xml !.idea/*.iml +!.idea/README.md # Yarn .yarn/* diff --git a/.idea/README.md b/.idea/README.md new file mode 100644 index 0000000000..d5f97872b5 --- /dev/null +++ b/.idea/README.md @@ -0,0 +1,21 @@ +# `.idea` + +If you are reading this and wondering why `.idea/` files are commited to GitHub. Well... + +### `.idea/dictionaries/*` + +> IntelliJ IDEA helps you make sure that all your source code, including variable names, textual strings, comments, literals, and commit messages, is spelt correctly. For this purpose, IntelliJ IDEA provides a dedicated Typo inspection which is enabled by default. + +1. Spellcheck everything, code are no expections +2. Adopt the principles of fixing all red/orange/yellow/wavy errors. +3. Treat all text as code, because they are and can introduces bugs. `client.call('generatetoaddress')` + vs `client.call('generatetoadress')` +4. Lastly, share that same improved developer experience with all IntelliJ IDEA users. + +### `.idea/codeStyles/*` + +Although ts-standard --fix the code styles, the provided code style is provided to match ts-standard defaults. + +### `.idea/*.xml` + +For convenience and better developer onboarding experience as suggested by IntelliJ and @fuxingloh. diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000000..c90e46612b --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,12 @@ + + + + diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000000..79ee123c2b --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/dictionaries/fuxing.xml b/.idea/dictionaries/fuxing.xml index b8b0a53530..d230bca44d 100644 --- a/.idea/dictionaries/fuxing.xml +++ b/.idea/dictionaries/fuxing.xml @@ -1,3 +1,57 @@ - - \ No newline at end of file + + + accounttoutxos + amkheight + bayfrontgardensheight + bayfrontheight + bayfrontmarinaheight + bech + clarkequayheight + createmasternode + dakotaheight + debugexclude + defi + defichain + defid + devnet + dockerode + dummypos + fuxingloh + generatetoaddress + getaddressinfo + getbalance + getblockcount + getblockhash + getmintinginfo + getnewaddress + getreceivedbyaddress + gettransaction + getunconfirmedbalance + importprivkey + jsonrpc + libevent + listaccounts + logtimemicros + mainnet + masternode + nblocks + nmasternode + nospv + printtoconsole + priv + regtest + rewardaddress + rpcallowip + rpcbind + rpcpassword + rpcuser + segwit + sendtoaddress + testcontainers + txnotokens + uacomment + utxostoaccount + + + diff --git a/README.md b/README.md index 002dcf473c..10b75491e2 100644 --- a/README.md +++ b/README.md @@ -64,18 +64,29 @@ npm install ### Testing `jest.config.js` is set up at the root project level as well as at each sub module. You can run jest at root to test all -modules or individually at each sub module. If you use IntelliJ IDEA, you can right click any file to test it -individually and have it reported to the IDE. +modules or individually at each sub module. By default, only regtest chain are used for normal testing. If you use +IntelliJ IDEA, you can right click any file to test it individually and have it reported to the IDE. Docker is required to run the tests as [`@defichain/testcontainers`](./packages/testcontainers) will automatically spin -up `regtest` instances for testing. +up `regtest` instances for testing. The number of containers it will spin up concurrently is dependent on your +jest `--maxConcurrency` count. -Coverage is collected at merge with `codecov`; more testing 🚀 less 🐛 = 😎 +Coverage is collected at pr merge with `codecov`; more testing 🚀 less 🐛 = 😎 ```shell jest ``` +### Publishing + +`"version": "0.0.0"` is used because publishing will be done automatically +by [GitHub releases](https://github.com/DeFiCh/jellyfish/releases) with connected workflows. On +release `types: [ published, prereleased ]`, GitHub Action will automatically build all packages in this repo and +publish it into npm. + +* release are tagged as `@latest` +* prerelease are tagged as `@next` (please use this cautiously) + ### IntelliJ IDEA IntelliJ IDEA is the IDE of choice for writing and maintaining this library. IntelliJ's files are included for diff --git a/jest.config.js b/jest.config.js index 9814ca2f36..bac1eacb35 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,6 @@ module.exports = { projects: [ '/packages/*' - ] + ], + testTimeout: 240000 } diff --git a/package-lock.json b/package-lock.json index 17f5ad6ed4..59d3ae6673 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "size-limit": "^4.9.2", "ts-jest": "^26.5.2", "ts-standard": "^10.0.0", - "typescript": "^4.2.2" + "typescript": "^4.2.2", + "wait-for-expect": "^3.0.2" } }, "node_modules/@babel/code-frame": { @@ -29922,6 +29923,12 @@ "node": ">=10" } }, + "node_modules/wait-for-expect": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/wait-for-expect/-/wait-for-expect-3.0.2.tgz", + "integrity": "sha512-cfS1+DZxuav1aBYbaO/kE06EOS8yRw7qOFoD3XtjTkYvCvh3zUvNST8DXK/nPaeqIzIv3P3kL3lRJn8iwOiSag==", + "dev": true + }, "node_modules/walker": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz", @@ -54286,6 +54293,12 @@ "xml-name-validator": "^3.0.0" } }, + "wait-for-expect": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/wait-for-expect/-/wait-for-expect-3.0.2.tgz", + "integrity": "sha512-cfS1+DZxuav1aBYbaO/kE06EOS8yRw7qOFoD3XtjTkYvCvh3zUvNST8DXK/nPaeqIzIv3P3kL3lRJn8iwOiSag==", + "dev": true + }, "walker": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz", diff --git a/package.json b/package.json index a3feb120dc..3950df7915 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "size-limit": "^4.9.2", "ts-jest": "^26.5.2", "ts-standard": "^10.0.0", - "typescript": "^4.2.2" + "typescript": "^4.2.2", + "wait-for-expect": "^3.0.2" }, "lint-staged": { "*.{ts,js}": [ diff --git a/packages/testcontainers/README.md b/packages/testcontainers/README.md index c93c88c4f7..9051d9f9ca 100644 --- a/packages/testcontainers/README.md +++ b/packages/testcontainers/README.md @@ -1,3 +1,115 @@ # @defichain/testcontainers - +Similar to [testcontainers](https://www.testcontainers.org/) in the Java ecosystem, this package provides a lightweight, +throwaway instances of `regtest`, `testnet` or `mainnet` provisioned automatically in Docker container. +`@defichain/testcontainers` encapsulate on top of `defi/defichain:v1.x` and directly interface with the Docker REST API. + +With `@defichain/testcontainers`, it allows the JS developers to: + +1. End-to-end test their application without hassle of setting up the toolchain +2. Run parallel tests as port number and container are dynamically generated on demand +3. Supercharge our CI workflow; run locally, anywhere or CI (as long as it has Docker installed) +4. Supercharge your `@defichain/jellyfish` implementation with 100% day 1 compatibility (mono repo!) +5. Bring quality and reliability to dApps on the DeFiChain JS ecosystem + +## Usage Example + +Install as dev only as you don't need this in production. **Please don't use this in production!** + +```shell +npm i -D @defichain/testcontainers +``` + +Use your favourite jest runner and start building dApps! + +### Basic `RegTestContainer` setup + +```js +import { RegTestDocker } from '@defichain/testcontainers' +import { getClient } from 'somewhere' + +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 url = await node.getRpcUrl() + const client = getClient(url) + const result = await client.call('getmintinginfo', []) + expect(result.chain).toBe('regtest') + }) +}) +``` + +### `MasterNodeRegTestContainer` with auto-minting + +```js +import { MasterNodeRegTestContainer } from '@defichain/testcontainers' +import waitForExpect from "wait-for-expect"; + +describe('master node pos minting', () => { + const container = new MasterNodeRegTestContainer() + + beforeEach(async () => { + await container.start() + await container.waitForReady() + }) + + afterEach(async () => { + await container.stop() + }) + + it('should wait until coinbase maturity with spendable balance', async () => { + await container.generate(100) + + await waitForExpect(async () => { + const info = await container.getMintingInfo() + expect(info.blocks).toBeGreaterThan(100) + }) + + // perform utxostoaccount rpc + const address = await container.getNewAddress() + const payload: { [key: string]: string } = {} + payload[address] = "100@0" + await container.call("utxostoaccount", [payload]) + }) +}) +``` + +### Endpoint? + +```js +const container = new MasterNodeRegTestContainer() +const rpcURL = await container.getCachedRpcUrl() + +// they are dynmaically assigned to host, you can run multiple concurrent tests! +const port = await container.getPort('8555/tcp') +``` + +### Included `container.call('method', [])` for convenience RPC calls + +```js +const container = new MasterNodeRegTestContainer() +await container.start() +await container.waitForReady() + +// raw universal calls +const { blocks } = await container.call('getmintinginfo') +const address = await container.call('getnewaddress', ['label', 'legacy']) + +// basic included types +const count = await container.getBlockCount() +const info = await container.getMintingInfo() +const newAddress = await container.getNewAddress() +``` diff --git a/packages/testcontainers/__tests__/chains/container.test.ts b/packages/testcontainers/__tests__/chains/container.test.ts new file mode 100644 index 0000000000..ce1d61e956 --- /dev/null +++ b/packages/testcontainers/__tests__/chains/container.test.ts @@ -0,0 +1,58 @@ +import { RegTestContainer } from '../../src/chains/reg_test_container' +import { DeFiDContainer, DeFiDRpcError, StartOptions } from '../../src/chains/container' + +describe('container error handling', () => { + let container: DeFiDContainer + + afterEach(async () => { + try { + await container?.stop() + } catch (ignored) { + } + }) + + it('should error immediately if required container is not yet started', async () => { + container = new RegTestContainer() + return await expect(container.getRpcPort()) + .rejects.toThrow(/container not yet started/) + }) + + it('should error rpc as DeFiDRpcError', async () => { + container = new RegTestContainer() + await container.start() + await container.waitForReady() + return await expect(container.call('invalid')) + .rejects.toThrowError(DeFiDRpcError) + }) + + it('should get error: container might have crashed if invalid Cmd is present', async () => { + class InvalidCmd extends RegTestContainer { + public getCmd (opts: StartOptions): string[] { + return [ + ...super.getCmd(opts), + '-invalid=123' + ] + } + } + + container = new InvalidCmd() + await container.start() + return expect(container.waitForReady(3000)) + .rejects.toThrow(/Unable to find rpc port, the container might have crashed/) + }) + + it('should get error: container not found if container is stopped', async () => { + container = new RegTestContainer() + await container.start() + await container.stop() + return await expect(container.getRpcPort()) + .rejects.toThrow(/\(HTTP code 404\) no such container - No such container:/) + }) + + it('should fail fast if wait for is set to 100ms', async () => { + container = new RegTestContainer() + await container.start() + await expect(container.waitForReady(100)) + .rejects.toThrow(/DeFiDContainer docker not ready within given timeout of/) + }) +}) diff --git a/packages/testcontainers/__tests__/chains/reg_test_container.test.ts b/packages/testcontainers/__tests__/chains/reg_test_container.test.ts new file mode 100644 index 0000000000..82d2796e01 --- /dev/null +++ b/packages/testcontainers/__tests__/chains/reg_test_container.test.ts @@ -0,0 +1,94 @@ +import { + MasterNodeRegTestContainer, + RegTestContainer +} from '../../src/chains/reg_test_container' +import waitForExpect from 'wait-for-expect' +import { GenesisKeys } from '../../src/testkeys' + +describe('regtest', () => { + const container = new RegTestContainer() + + beforeAll(async () => { + await container.start() + await container.waitForReady() + }) + + afterAll(async () => { + await container.stop() + }) + + it('should be block 0 for getmintinginfo rpc', async () => { + const info = await container.getMintingInfo() + expect(info.blocks).toBe(0) + expect(info.chain).toBe('regtest') + }) + + it('should be block 0 for getblockcount rpc', async () => { + const count = await container.getBlockCount() + expect(count).toBe(0) + }) + + describe('address', () => { + it('should be able to getnewaddress', async () => { + const address = await container.getNewAddress() + expect(address.length).toBe(44) + }) + + it('should be able to getnewaddress with label and as p2sh-segwit', async () => { + const address = await container.getNewAddress('not-default', 'p2sh-segwit') + expect(address.length).toBe(35) + }) + + it('should be able to getnewaddress with label and as legacy', async () => { + const address = await container.getNewAddress('not-default', 'legacy') + expect(address.length).toBe(34) + }) + }) +}) + +describe('master node pos minting', () => { + const container = new MasterNodeRegTestContainer() + + beforeEach(async () => { + await container.start() + await container.waitForReady() + }) + + afterEach(async () => { + await container.stop() + }) + + it('should auto generate coin in master node mode', async () => { + await waitForExpect(async () => { + const info = await container.getMintingInfo() + expect(info.blocks).toBeGreaterThan(3) + }) + }) + + it('should wait until coinbase maturity with spendable balance', async () => { + const key = GenesisKeys[2].operator + await container.generate(10, key.address) + await container.generate(100) + + await waitForExpect(async () => { + const info = await container.getMintingInfo() + expect(info.blocks).toBeGreaterThan(100) + }) + + await container.generate(3) + + await waitForExpect(async () => { + const balance = await container.call('getbalance') + expect(balance).toBeGreaterThan(150) + }) + }) + + it('should be able to perform amk rpc feature', async () => { + await container.generate(105) + + const address = await container.getNewAddress() + const payload: { [key: string]: string } = {} + payload[address] = '100@0' + await container.call('utxostoaccount', [payload]) + }) +}) diff --git a/packages/testcontainers/__tests__/testcontainers.test.ts b/packages/testcontainers/__tests__/testcontainers.test.ts index a5e9970f50..bd44f6f608 100644 --- a/packages/testcontainers/__tests__/testcontainers.test.ts +++ b/packages/testcontainers/__tests__/testcontainers.test.ts @@ -1,5 +1,55 @@ -import { getName } from '../src/testcontainers' +import { MainNetContainer, TestNetContainer, RegTestContainer } from '../src/testcontainers' -it('should getName testcontainers', () => { - expect(getName()).toBe('testcontainers') +describe('main', () => { + const container = new MainNetContainer() + + beforeEach(async () => { + await container.start() + await container.waitForReady() + }) + + afterEach(async () => { + await container.stop() + }) + + it('should be able to getmintinginfo and chain should be main', async () => { + const { chain } = await container.getMintingInfo() + expect(chain).toBe('main') + }) +}) + +describe('test', () => { + const container = new TestNetContainer() + + beforeEach(async () => { + await container.start() + await container.waitForReady() + }) + + afterEach(async () => { + await container.stop() + }) + + it('should be able to getmintinginfo and chain should be test', async () => { + const { chain } = await container.getMintingInfo() + expect(chain).toBe('test') + }) +}) + +describe('regtest', () => { + const container = new RegTestContainer() + + beforeEach(async () => { + await container.start() + await container.waitForReady() + }) + + afterEach(async () => { + await container.stop() + }) + + it('should be able to getmintinginfo and chain should be regtest', async () => { + const { chain } = await container.getMintingInfo() + expect(chain).toBe('regtest') + }) }) diff --git a/packages/testcontainers/__tests__/testkeys.test.ts b/packages/testcontainers/__tests__/testkeys.test.ts new file mode 100644 index 0000000000..cd2b904841 --- /dev/null +++ b/packages/testcontainers/__tests__/testkeys.test.ts @@ -0,0 +1,27 @@ +import { RegTestContainer } from '../src/chains/reg_test_container' +import { GenesisKeys } from '../src/testkeys' + +describe('genesis keys', () => { + const container = new RegTestContainer() + + beforeEach(async () => { + await container.start() + await container.waitForReady() + }) + + afterEach(async () => { + await container.stop() + }) + + it('should be able to import all priv key with valid address', async () => { + for (const key of GenesisKeys) { + await container.call('importprivkey', [key.operator.privKey]) + await container.call('importprivkey', [key.owner.privKey]) + } + + for (const key of GenesisKeys) { + await container.call('getaddressinfo', [key.operator.address]) + await container.call('getaddressinfo', [key.owner.address]) + } + }) +}) diff --git a/packages/testcontainers/jest.config.js b/packages/testcontainers/jest.config.js index 440ec8dbe9..b9f5804e1e 100644 --- a/packages/testcontainers/jest.config.js +++ b/packages/testcontainers/jest.config.js @@ -7,5 +7,6 @@ module.exports = { '^.+\\.ts$': 'ts-jest' }, verbose: true, - clearMocks: true + clearMocks: true, + testTimeout: 120000 } diff --git a/packages/testcontainers/src/chains/container.ts b/packages/testcontainers/src/chains/container.ts new file mode 100644 index 0000000000..a61d25b1d0 --- /dev/null +++ b/packages/testcontainers/src/chains/container.ts @@ -0,0 +1,316 @@ +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 + */ +type Network = 'mainnet' | 'testnet' | 'devnet' | 'regtest' + +/** + * Mandatory options to start defid with + */ +export interface StartOptions { + // TODO(fuxingloh): change to cookie based auth soon + user?: string + password?: string +} + +const defaultStartOptions = { + user: 'testcontainers-user', + password: 'testcontainers-password' +} + +/** + * Generate a name for a new docker container with network type and random number + */ +function generateName (network: Network): string { + const rand = Math.floor(Math.random() * 10000000) + return `${DeFiDContainer.PREFIX}-${network}-${rand}` +} + +/** + * Clean up stale nodes are nodes that are running for 1 hour + */ +async function cleanUpStale (docker: Dockerode): Promise { + /** + * Same prefix and created more than 1 hour ago + */ + const isStale = (containerInfo: ContainerInfo): boolean => { + if (containerInfo.Names.filter((value) => value.startsWith(DeFiDContainer.PREFIX)).length > 0) { + return containerInfo.Created + 60 * 60 < Date.now() / 1000 + } + + return false + } + + /** + * Stop container that are running, remove them after + */ + const tryStopRemove = async (containerInfo: ContainerInfo): Promise => { + const container = docker.getContainer(containerInfo.Id) + if (containerInfo.State === 'running') { + await container.stop() + } + await container.remove() + } + + return await new Promise((resolve, reject) => { + docker.listContainers({ all: 1 }, (error, result) => { + if (error instanceof Error) { + return reject(error) + } + + const promises = (result ?? []) + .filter(isStale) + .map(tryStopRemove) + + Promise.all(promises).finally(resolve) + }) + }) +} + +/** + * Pull DeFiDContainer.image from DockerHub + */ +async function pullImage (docker: Dockerode): Promise { + return await new Promise((resolve, reject) => { + docker.pull(DeFiDContainer.image, {}, (error, result) => { + if (error instanceof Error) { + reject(error) + return + } + docker.modem.followProgress(result, () => { + resolve() + }) + }) + }) +} + +/** + * DeFiChain defid node managed in docker + */ +export abstract class DeFiDContainer { + /* eslint-disable @typescript-eslint/no-non-null-assertion, no-void */ + public static readonly PREFIX = 'defichain-testcontainers-' + public static readonly image = 'defi/defichain:1.5.0' + + protected readonly docker: Dockerode + protected readonly network: Network + + protected container?: Container + protected startOptions?: StartOptions + protected cachedRpcUrl?: string + + protected constructor (network: Network, options?: DockerOptions) { + this.docker = new Dockerode(options) + this.network = network + } + + /** + * Require container, else error exceptionally. + * Not a clean design, but it keep the complexity of this implementation low. + */ + protected requireContainer (): Container { + if (this.container !== undefined) { + return this.container + } + throw new Error('container not yet started') + } + + /** + * Convenience Cmd builder with StartOptions + */ + 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!}` + ] + } + + /** + * Always pull a version of DeFiDContainer.image, + * Create container and start it immediately + */ + async start (startOptions: StartOptions = {}): Promise { + await pullImage(this.docker) + this.startOptions = Object.assign(defaultStartOptions, startOptions) + this.container = await this.docker.createContainer({ + name: generateName(this.network), + Image: DeFiDContainer.image, + Tty: true, + Cmd: this.getCmd(this.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 { + const container = this.requireContainer() + + return await new Promise((resolve, reject) => { + container.inspect(function (err, data) { + if (err instanceof Error) { + return reject(err) + } + + if (data?.NetworkSettings.Ports[name] !== undefined) { + return resolve(data.NetworkSettings.Ports[name][0].HostPort) + } + + return reject(new Error('Unable to find rpc port, the container might have crashed')) + }) + }) + } + + /** + * Get host machine port used for defid rpc + */ + public abstract getRpcPort (): Promise + + /** + * Get host machine url used for defid rpc calls with auth + */ + async getCachedRpcUrl (): Promise { + if (this.cachedRpcUrl === undefined) { + const port = await this.getRpcPort() + const user = this.startOptions!.user! + const password = this.startOptions!.password! + this.cachedRpcUrl = `http://${user}:${password}@127.0.0.1:${port}/` + } + return this.cachedRpcUrl + } + + /** + * 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 + */ + 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 text = await response.text() + const { result, error } = JSONBig.parse(text) + + // surface as DeFiDRpcError for downstream type checking + if (error !== null) { + throw new DeFiDRpcError(result) + } + + return result + } + + /** + * Convenience method to getmintinginfo, typing mapping is non exhaustive + */ + async getMintingInfo (): Promise<{ blocks: number, chain: string }> { + return await this.call('getmintinginfo', []) + } + + /** + * Convenience method to getblockcount, typing mapping is non exhaustive + */ + async getBlockCount (): Promise { + return await this.call('getblockcount', []) + } + + /** + * Wait for rpc to be ready, default to 15000ms + */ + private async waitForRpc (timeout = 15000): Promise { + const expiredAt = Date.now() + timeout + + return await new Promise((resolve, reject) => { + const checkReady = (): void => { + this.cachedRpcUrl = undefined + this.getMintingInfo().then(() => { + resolve() + }).catch(err => { + if (expiredAt < Date.now()) { + reject(new Error(`DeFiDContainer docker not ready within given timeout of ${timeout}ms.\n${err.message as string}`)) + } + + setTimeout(() => void checkReady(), 200) + }) + } + + checkReady() + }) + } + + /** + * Wait for everything to be ready, override for additional hooks + */ + async waitForReady (timeout = 15000): Promise { + return await this.waitForRpc(timeout) + } + + /** + * tty into docker + */ + async exec (opts: { Cmd: string[] }): Promise { + return await new Promise((resolve, reject) => { + const container = this.requireContainer() + container.exec({ + Cmd: opts.Cmd + }, (error, exec) => { + if (error instanceof Error) { + reject(error) + } else { + exec?.start({}) + .then(() => resolve()) + .catch(reject) + } + }) + }) + } + + /** + * Stop and remove the current node. + * + * This method will also automatically stop and removes nodes that are stale. + * Stale nodes are nodes that are running for more than 1 hour + */ + async stop (): Promise { + try { + await this.container?.stop() + } finally { + try { + await this.container?.remove() + } finally { + await cleanUpStale(this.docker) + } + } + } +} + +/** + * RPC error from container + */ +export class DeFiDRpcError extends Error { + readonly payload: any + + constructor (payload: any) { + super('DeFiD RPC error from container') + this.payload = payload + } +} diff --git a/packages/testcontainers/src/chains/main_net_container.ts b/packages/testcontainers/src/chains/main_net_container.ts new file mode 100644 index 0000000000..440877dd93 --- /dev/null +++ b/packages/testcontainers/src/chains/main_net_container.ts @@ -0,0 +1,12 @@ +import { DockerOptions } from 'dockerode' +import { DeFiDContainer } from './container' + +export class MainNetContainer extends DeFiDContainer { + constructor (options?: DockerOptions) { + super('mainnet', options) + } + + async getRpcPort (): Promise { + return await this.getPort('8554/tcp') + } +} diff --git a/packages/testcontainers/src/chains/reg_test_container.ts b/packages/testcontainers/src/chains/reg_test_container.ts new file mode 100644 index 0000000000..d35c264c87 --- /dev/null +++ b/packages/testcontainers/src/chains/reg_test_container.ts @@ -0,0 +1,105 @@ +import { DockerOptions } from 'dockerode' +import { DeFiDContainer, StartOptions } from './container' +import { GenesisKeys, MasterNodeKey } from '../testkeys' + +export class RegTestContainer extends DeFiDContainer { + constructor (options?: DockerOptions) { + super('regtest', options) + } + + protected getCmd (opts: StartOptions): string[] { + return [...super.getCmd(opts), + '-regtest=1', + '-txnotokens=0', + '-logtimemicros', + '-amkheight=0', + '-bayfrontheight=1', + '-bayfrontgardensheight=2', + '-clarkequayheight=3', + '-dakotaheight=4' + ] + } + + async getNewAddress (label: string = '', addressType: 'legacy' | 'p2sh-segwit' | 'bech32' | string = 'bech32'): Promise { + return await this.call('getnewaddress', [label, addressType]) + } + + async getRpcPort (): Promise { + return await this.getPort('19554/tcp') + } +} + +/** + * RegTest with MasterNode preconfigured + */ +export class MasterNodeRegTestContainer extends RegTestContainer { + private readonly masternodeKey: MasterNodeKey + + constructor (masternodeKey: MasterNodeKey = GenesisKeys[0], options?: DockerOptions) { + super(options) + this.masternodeKey = masternodeKey + } + + /** + * Additional debug options turned on for traceability. + */ + protected getCmd (opts: StartOptions): string[] { + return [ + ...super.getCmd(opts), + '-dummypos=1', + '-nospv' + ] + } + + /** + * It is set to auto mint every 1 second by default in regtest. + * https://github.com/DeFiCh/ain/blob/6dc990c45788d6806ea/test/functional/test_framework/test_node.py#L160-L178 + */ + async generate (nblocks: number, address: string = this.masternodeKey.operator.address, maxTries: number = 1000000): Promise { + const mintedHashes: string[] = [] + + for (let minted = 0, tries = 0; minted < nblocks && tries < maxTries; tries++) { + const result = await this.call('generatetoaddress', [1, address, 1]) + + if (result === 1) { + minted += 1 + const count = await this.call('getblockcount') + const hash = await this.call('getblockhash', [count]) + mintedHashes.push(hash) + } + } + + return mintedHashes + } + + /** + * This will automatically import the necessary private key for master to mint tokens + */ + async waitForReady (timeout: number = 15000): Promise { + await super.waitForReady(timeout) + + // import keys for master node + await this.call('importprivkey', [ + this.masternodeKey.operator.privKey, 'coinbase', true + ]) + await this.call('importprivkey', [ + this.masternodeKey.owner.privKey, 'coinbase', true + ]) + + // configure the masternode + const fileContents = + 'gen=1' + '\n' + + 'spv=1' + '\n' + + `masternode_operator=${this.masternodeKey.operator.address}` + '\n' + + `masternode_owner=${this.masternodeKey.owner.address}` + + await this.exec({ + Cmd: ['bash', '-c', `echo "${fileContents}" > ~/.defi/defi.conf`] + }) + + // restart and wait for ready + await this.container?.stop() + await this.container?.start() + await super.waitForReady(timeout) + } +} diff --git a/packages/testcontainers/src/chains/test_net_container.ts b/packages/testcontainers/src/chains/test_net_container.ts new file mode 100644 index 0000000000..fdf12b2309 --- /dev/null +++ b/packages/testcontainers/src/chains/test_net_container.ts @@ -0,0 +1,16 @@ +import { DockerOptions } from 'dockerode' +import { DeFiDContainer, StartOptions } from './container' + +export class TestNetContainer extends DeFiDContainer { + constructor (options?: DockerOptions) { + super('testnet', options) + } + + protected getCmd (opts: StartOptions): string[] { + return [...super.getCmd(opts), '-testnet=1'] + } + + async getRpcPort (): Promise { + return await this.getPort('18554/tcp') + } +} diff --git a/packages/testcontainers/src/testcontainers.ts b/packages/testcontainers/src/testcontainers.ts index d76bda4325..d7f62b7d95 100644 --- a/packages/testcontainers/src/testcontainers.ts +++ b/packages/testcontainers/src/testcontainers.ts @@ -1,3 +1,12 @@ -export function getName (): string { - return 'testcontainers' -} +export { DockerOptions } from 'dockerode' + +export { StartOptions, DeFiDContainer, DeFiDRpcError } from './chains/container' +export { MainNetContainer } from './chains/main_net_container' +export { TestNetContainer } from './chains/test_net_container' +export { + RegTestContainer, MasterNodeRegTestContainer +} from './chains/reg_test_container' + +export { + KeyPair, MasterNodeKey, GenesisKeys +} from './testkeys' diff --git a/packages/testcontainers/src/testkeys.ts b/packages/testcontainers/src/testkeys.ts new file mode 100644 index 0000000000..a8fcdbfb7a --- /dev/null +++ b/packages/testcontainers/src/testkeys.ts @@ -0,0 +1,99 @@ +export interface KeyPair { + address: string + privKey: string +} + +export interface MasterNodeKey { + owner: KeyPair + operator: KeyPair +} + +/** + * As per: + * https://github.com/DeFiCh/ain/blob/6dc990c45788d6806ea/src/chainparams.cpp#L664-L677 + * https://github.com/DeFiCh/ain/blob/6dc990c45788d6806ea/test/functional/test_framework/test_node.py#L121-L132 + * + * 2 first and 2 last of genesis MNs acts as foundation members + */ +export const GenesisKeys: MasterNodeKey[] = [ + { + owner: { + address: 'mwsZw8nF7pKxWH8eoKL9tPxTpaFkz7QeLU', + privKey: 'cRiRQ9cHmy5evDqNDdEV8f6zfbK6epi9Fpz4CRZsmLEmkwy54dWz' + }, + operator: { + address: 'mswsMVsyGMj1FzDMbbxw2QW3KvQAv2FKiy', + privKey: 'cPGEaz8AGiM71NGMRybbCqFNRcuUhg3uGvyY4TFE1BZC26EW2PkC' + } + }, + { + owner: { + address: 'msER9bmJjyEemRpQoS8YYVL21VyZZrSgQ7', + privKey: 'cSCmN1tjcR2yR1eaQo9WmjTMR85SjEoNPqMPWGAApQiTLJH8JF7W' + }, + operator: { + address: 'mps7BdmwEF2vQ9DREDyNPibqsuSRZ8LuwQ', + privKey: 'cVNTRYV43guugJoDgaiPZESvNtnfnUW19YEjhybihwDbLKjyrZNV' + } + }, + { + owner: { + address: 'myF3aHuxtEuqqTw44EurtVs6mjyc1QnGUS', + privKey: 'cSXiqwTiYzECugcvCT4PyPKz2yKaTST8HowFVBBjccZCPkX6wsE9' + }, + operator: { + address: 'mtbWisYQmw9wcaecvmExeuixG7rYGqKEU4', + privKey: 'cPh5YaousYQ92tNd9FkiiS26THjSVBDHUMHZzUiBFbtGNS4Uw9AD' + } + }, + { + owner: { + address: 'mwyaBGGE7ka58F7aavH5hjMVdJENP9ZEVz', + privKey: 'cVA52y8ABsUYNuXVJ17d44N1wuSmeyPtke9urw4LchTyKsaGDMbY' + }, + operator: { + address: 'n1n6Z5Zdoku4oUnrXeQ2feLz3t7jmVLG9t', + privKey: 'cV9tJBgAnSfFmPaC6fWWvA9StLKkU3DKV7eXJHjWMUENQ8cKJDkL' + } + }, + { + owner: { + address: 'mgsE1SqrcfUhvuYuRjqy6rQCKmcCVKNhMu', + privKey: 'cRJyBuQPuUhYzN5F2Uf35958oK9AzZ5UscRfVmaRr8ktWq6Ac23u' + }, + operator: { + address: 'mzqdipBJcKX9rXXxcxw2kTHC3Xjzd3siKg', + privKey: 'cQYJ87qk39i3uFsXBZ2EkwdX1h72q1RQcX9V8X7PPydFPgujxrCy' + } + }, + { + owner: { + address: 'mud4VMfbBqXNpbt8ur33KHKx8pk3npSq8c', + privKey: 'cPjeCNka7omVbKKfywPVQyBig9eopBHy6eJqLzrdJqMP4DXApkcb' + }, + operator: { + address: 'mk5DkY4qcV6CUpuxDVyD3AHzRq5XK9kbRN', + privKey: 'cV6Hjhutf11RvFHaERkp52QNynm2ifNmtUfP8EwRRMg6NaaQsHTe' + } + }, + { + owner: { + address: 'bcrt1qyrfrpadwgw7p5eh3e9h3jmu4kwlz4prx73cqny', + privKey: 'cR4qgUdPhANDVF3bprcp5N9PNW2zyogDx6DGu2wHh2qtJB1L1vQj' + }, + operator: { + address: 'bcrt1qmfvw3dp3u6fdvqkdc0y3lr0e596le9cf22vtsv', + privKey: 'cVsa2wQvCjZZ54jGteQ8qiQbQLJQmZSBWriYUYyXbcaqUJFqK5HR' + } + }, + { + owner: { + address: 'bcrt1qyeuu9rvq8a67j86pzvh5897afdmdjpyankp4mu', + privKey: 'cUX8AEUZYsZxNUh5fTS7ZGnF6SPQuTeTDTABGrp5dbPftCga2zcp' + }, + operator: { + address: 'bcrt1qurwyhta75n2g75u2u5nds9p6w9v62y8wr40d2r', + privKey: 'cUp5EVEjuAGpemSuejP36TWWuFKzuCbUJ4QAKJTiSSB2vXzDLsJW' + } + } +]