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'
+ }
+ }
+]