├── .coveralls.yml ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── .travis.yml ├── Dockerfile ├── LICENSE.md ├── README.md ├── bin └── bhn ├── docker-compose.yml ├── lib ├── headerindexer.js ├── headernode.js ├── headerpool.js ├── http.js ├── index.js ├── layout.js ├── records.js ├── rpc.js └── util.js ├── package.json ├── test ├── headerindexer-test.js ├── headernode-test.js ├── util-test.js └── util │ ├── common.js │ └── regtest.js └── yarn.lock /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-pro 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "extends": ["eslint:recommended", "plugin:prettier/recommended"], 7 | "parserOptions": { 8 | "ecmaVersion": 2018 9 | }, 10 | "overrides": { 11 | "files": ["test/**/*-test.js"], 12 | "env": { 13 | "mocha": true 14 | } 15 | }, 16 | "plugins": ["prettier"], 17 | "rules": { 18 | "prettier/prettier": "error", 19 | "linebreak-style": ["error", "unix"], 20 | "no-console": "off", 21 | "camelcase": [ 22 | "error", 23 | { 24 | "properties": "never", 25 | "ignoreDestructuring": true 26 | } 27 | ] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | yarn-error.log 4 | .nyc_output/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 120, 4 | "semi": false 5 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '10' 4 | cache: yarn 5 | script: 6 | - yarn run lint 7 | - yarn test 8 | after_success: yarn run coverage 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine AS base 2 | 3 | RUN mkdir /code 4 | WORKDIR /code 5 | 6 | # copy dependency information and package files 7 | COPY package.json \ 8 | yarn.lock \ 9 | /code/ 10 | 11 | COPY bin /code/bin 12 | COPY lib /code/lib 13 | 14 | # intermediate image with dependencies needed for initial build 15 | FROM base as build 16 | 17 | # Install updates and dependencies needed to build the package 18 | RUN apk upgrade --no-cache && \ 19 | apk add --no-cache git python make g++ bash && \ 20 | npm install -g -s --no-progress yarn 21 | 22 | # Install package dependencies 23 | RUN yarn install 24 | 25 | # Copy built files, but don't include build deps 26 | FROM base 27 | ENV PATH="${PATH}:/code/bin:/code/node_modules/.bin" 28 | COPY --from=build /code /code/ 29 | 30 | # start the header node. Can pass additional options with 31 | # CMD in docker-compose or from command line with `docker run` 32 | ENTRYPOINT ["bhn"] 33 | 34 | # Main-net and Test-net 35 | EXPOSE 8334 8333 8332 18334 18333 18332 36 | HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 CMD [ "bcoin-cli info >/dev/null" ] 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Portions of this library have been influenced by or borrowed code from 2 | the [bcoin](https://github.com/bcoin-org/bcoin) library, licensed 3 | under the MIT license: 4 | 5 | Copyright (c) 2014-2015, Fedor Indutny (https://github.com/indutny) 6 | 7 | Copyright (c) 2014-2017, Christopher Jeffrey (https://github.com/chjj) 8 | 9 | Copyright (c) 2017-2019, bcoin developers (https://github.com/bcoin-org) 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy 12 | of this software and associated documentation files (the "Software"), to deal 13 | in the Software without restriction, including without limitation the rights 14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | copies of the Software, and to permit persons to whom the Software is 16 | furnished to do so, subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in 19 | all copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 27 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) 2 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 3 | [![Build Status](https://travis-ci.com/chainpoint/bitcoin-header-node.svg?branch=master)](https://travis-ci.com/chainpoint/bitcoin-header-node) 4 | [![Coverage Status](https://coveralls.io/repos/github/chainpoint/bitcoin-header-node/badge.svg?branch=master)](https://coveralls.io/github/chainpoint/bitcoin-header-node?branch=master) 5 | 6 | 8 | 9 | # Bitcoin Header Node 10 | 11 | ##### A lightweight node for syncing header data w/ as little data as possible from the Bitcoin network 12 | 13 | ## Background 14 | 15 | A bcoin spv node is already very lightweight, around 160MB of chain data on mainnet as of block 568,134. 16 | However, it also stores some extra metadata with the headers. This helps for PoW verification but makes the 17 | headers a little heavier than the minimum [80 bytes per header](https://bitcoin.org/en/glossary/block-header) 18 | (in fact bcoin stores a data structure internally called the [ChainEntry](https://github.com/bcoin-org/bcoin/blob/master/lib/blockchain/chainentry.js) 19 | for spv sync rather than just the headers). 20 | 21 | This Bitcoin Header Node implementation reduces the size of the data stored on disk for header syncing by using an in-memory 22 | chain to sync with peers and a separate indexer database to store the headers. This brings the db size down to 76MB 23 | though further optimizations may be possible. The Header Indexer is based on a new feature 24 | for bcoin that separtes out the indexers (Tx, Address, and Compact Filters) into their own databases and exposes 25 | utilities for creating your own custom indexers. 26 | 27 | ## Installation 28 | 29 | Configuration options are the same as with bcoin. See more information 30 | [here](https://github.com/bcoin-org/bcoin/blob/master/docs/configuration.md). 31 | 32 | ### System Requirements: 33 | 34 | - Linux, OSX, or Windows (\*) 35 | - node.js >= v11.12.0 36 | - npm >= v6.4.1 37 | - python2 (for node-gyp) 38 | - gcc/g++ (for leveldb and secp256k1) 39 | 40 | If running on a fresh VPS with Ubuntu, you can run `sudo apt-get install build-essential` to 41 | get the necessary build tools and then run the following commands. 42 | 43 | (\*): Note that bhn works best with unix-like OSes, and has not yet been thoroughly tested on Windows. 44 | 45 | #### Using from GitHub 46 | 47 | ```bash 48 | $ git clone https://github.com/chainpoint/bitcoin-header-node 49 | $ cd bitcoin-header-node 50 | $ yarn install 51 | $ ./bin/bhn 52 | ``` 53 | 54 | Mainnet should take between 1-2 hours for initial sync from genesis, less with a custom start height 55 | 56 | #### You can also install from npm 57 | 58 | ```bash 59 | $ npm install -g bhn 60 | $ bhn [...options] 61 | ``` 62 | 63 | #### Or use it as a library 64 | 65 | ```javascript 66 | const BHN = require('bhn') 67 | 68 | async function startNode(config) { 69 | let node = new BHN({ 70 | network: 'testnet', 71 | startHeight: 1045000 72 | }) 73 | 74 | process.on('unhandledRejection', err => { 75 | throw err 76 | }) 77 | 78 | process.on('SIGINT', async () => { 79 | if (node && node.opened) await node.close() 80 | process.exit() 81 | }) 82 | 83 | // you can even set event listeners! 84 | node.on('connect', entry => console.log('new block connected!:', entry)) 85 | 86 | try { 87 | await node.ensure() 88 | await node.open() 89 | await node.connect() 90 | await node.startSync() 91 | } catch (e) { 92 | console.error(e.stack) 93 | process.exit(1) 94 | } 95 | 96 | return node 97 | } 98 | ``` 99 | 100 | ## Configuration 101 | 102 | Since BHN is just an extension of a normal bcoin full node, configuration works the same as well. 103 | You can add config options to a config file `bhn.conf`, which by default is searched for in the `~/.bhn` 104 | prefix data dir. Command line args and env vars, prefixed with `BHN_` are also supported. 105 | 106 | Read more at the [bcoin Configuration docs](https://github.com/bcoin-org/bcoin/blob/master/docs/configuration.md). 107 | 108 | ## `Fast Sync` with a custom start block 109 | 110 | ### About Custom Start Blocks 111 | 112 | Header Node supports the ability to start syncing from a custom start height rather than syncing a 113 | new header chain from scratch. This can have the advantage of saving space and sync time if you don't 114 | need the full history of previous headers. 115 | 116 | This can be a little tricky however since a blockchain by its nature relies on the fact of an unbroken chain 117 | of history connected by hashes going back to the Genesis Block. **Make sure that you trust the block 118 | data you are starting with**. Even if you have an incorrect start block however, unless you're connecting 119 | to and 120 | [eclipsed by malicious peers](https://bitcoin.stackexchange.com/questions/61151/eclipse-attack-vs-sybil-attack#61154), 121 | the sync should just fail with a bad starting block. 122 | 123 | ### Usage 124 | 125 | You need to tell your node you want to start with a custom start point. There are two ways to do this on 126 | mainnet and testnet: with the start height or with the raw header data for the start block and 127 | _its previous block_ (this is needed for contextual checks). These should be put in order, 128 | block at index 0 in the array should be the prev block, and the last block in the array (index 1) will be 129 | saved as the actual starting block. 130 | 131 | For a contained testing network like regtest or simnet, only the raw data will work since the 132 | height functionality works by querying the [blockcypher.com](https://blockcypher.com) API for the 133 | target blocks (you can see how to set the raw block data in the bhn tests for startBlock). 134 | 135 | Both options, `start-block` or `start-height`, can be passed as with any 136 | [bcoin Configuration](https://github.com/bcoin-org/bcoin/blob/master/docs/configuration.md). 137 | 138 | For example, to start from block 337022, you can pass in the following at runtime: 139 | 140 | ```bash 141 | $ ./bin/bhn --start-height=337022 142 | ``` 143 | 144 | Alternatively, adding it to a bcoin.conf configuration file in your node's prefix directory or as an 145 | environment variable `BCOIN_START_HEIGHT` will also work. For a start-block, you must pass in an 146 | array of two raw block headers (i.e. as Buffers). 147 | 148 | ## Header Node Client 149 | 150 | The Header Node comes with a built-in HTTP server that includes both a REST API and RPC interface (on the backend it uses an 151 | extended instance of the [bweb](https://github.org/bcoin-org/bweb) object used in bcoin) 152 | You can use the `bclient` package to interact with your header node, either installed as a global 153 | package with npm and used via CLI or used directly in a script. Authentication is also supported on the node. 154 | A client that wishes to connect will need an API key if this is enabled. 155 | 156 | (Read the [bcoin API docs](http://bcoin.io/api-docs/index.html) for more information on installing and setting up a client). 157 | 158 | ### New Endpoints 159 | 160 | All instances of `client` in the following examples are references to the `bclient` package, 161 | [available on npm](https://www.npmjs.com/package/bclient). Read more about using bclient 162 | [here](http://bcoin.io/api-docs/index.html#configuring-clients). 163 | 164 | #### GET /block/:height 165 | 166 | #### GET /header/:height 167 | 168 | ```js 169 | ;(async () => { 170 | const height = 450000 171 | // these two requests are equivalent 172 | await client.getBlock(height) 173 | await client.get(`/header/${height}`) 174 | })() 175 | ``` 176 | 177 | ##### HTTP Response 178 | 179 | ```json 180 | { 181 | "hash": "0000000000000000014083723ed311a461c648068af8cef8a19dcd620c07a20b", 182 | "version": 536870912, 183 | "prevBlock": "0000000000000000024c4a35f0485bab79ce341cdd5cc6b15186d9b5b57bf3da", 184 | "merkleRoot": "ff508cf57d57bd086451493f100dd69b6ba7bdab2a0c14254053224d42521925", 185 | "time": 1485382289, 186 | "bits": 402836551, 187 | "nonce": 2972550269, 188 | "height": 450000, 189 | "chainwork": "00000000000000000000000000000000000000000036fb5c7c89f1a9eedb191c" 190 | } 191 | ``` 192 | 193 | #### `getheaderbyheight` 194 | 195 | The RPC interface is also available 196 | 197 | ```js 198 | ;(async () => { 199 | const height = 450000 200 | await client.execute('getheaderbyheight', [height]) 201 | })() 202 | ``` 203 | 204 | ##### Response 205 | 206 | ```json 207 | { 208 | "hash": "0000000000000000014083723ed311a461c648068af8cef8a19dcd620c07a20b", 209 | "confirmations": 121271, 210 | "height": 450000, 211 | "version": 536870912, 212 | "versionHex": "20000000", 213 | "merkleroot": "ff508cf57d57bd086451493f100dd69b6ba7bdab2a0c14254053224d42521925", 214 | "time": 1485382289, 215 | "mediantime": 1485382289, 216 | "bits": 402836551, 217 | "difficulty": 392963262344.37036, 218 | "chainwork": "00000000000000000000000000000000000000000036fb5c7c89f1a9eedb191c", 219 | "previousblockhash": "0000000000000000024c4a35f0485bab79ce341cdd5cc6b15186d9b5b57bf3da", 220 | "nextblockhash": null 221 | } 222 | ``` 223 | 224 | #### `getstartheader` and `getStart` 225 | 226 | This endpoint is for getting the header of the starting block for when you have a custom 227 | start height set. Useful for when you need to check how far back you can get headers for. 228 | 229 | ```js 230 | ;(async () => { 231 | await client.execute('getstartheader') 232 | })() 233 | ``` 234 | 235 | For a node that started on block 337022, the rpc will return: 236 | 237 | ```json 238 | { 239 | "hash": "00000000000000001324bcae72265c48b69328266afffe0d4a526ca400942550", 240 | "confirmations": 243410, 241 | "height": 337022, 242 | "version": 2, 243 | "versionHex": "00000002", 244 | "merkleroot": "63fec4d1079d12855590ddd99b5a94035fd6a30fcbe8581be7ed862fa7582ae2", 245 | "time": 1420156149, 246 | "mediantime": 1420156149, 247 | "bits": 404426186, 248 | "difficulty": 40640955016.57649, 249 | "previousblockhash": "00000000000000001591acd927bff8a122aeb6fea74cb7aff3ba535fa431a3c2", 250 | "nextblockhash": "00000000000000000b2622fab43b722df811c28b64005c82f56285a46aa9605c" 251 | } 252 | ``` 253 | 254 | or... 255 | 256 | ```js 257 | ;(async () => { 258 | await client.get('/start') 259 | })() 260 | ``` 261 | 262 | returns... 263 | 264 | ```json 265 | { 266 | "hash": "00000000000000001324bcae72265c48b69328266afffe0d4a526ca400942550", 267 | "height": 337022, 268 | "version": 2, 269 | "prevBlock": "00000000000000001591acd927bff8a122aeb6fea74cb7aff3ba535fa431a3c2", 270 | "merkleRoot": "63fec4d1079d12855590ddd99b5a94035fd6a30fcbe8581be7ed862fa7582ae2", 271 | "time": 1420156149, 272 | "bits": 404426186, 273 | "nonce": 2449800613 274 | } 275 | ``` 276 | 277 | #### `getblockheader` 278 | 279 | NOTE: The api is the same as for normal bcoin/bitcoin nodes and takes the block hash as input. 280 | However, when using against a header node, this will only work on recent blocks. Since the bhn indexer 281 | only indexes by height and all other chain data is saved in memory, older blocks will not be found. 282 | Use `getheaderbyheight` method above instead when possible 283 | 284 | ## Testing 285 | 286 | Tests are available and can be run with the following command: 287 | 288 | ```bash 289 | $ yarn test 290 | ``` 291 | 292 | ## Notes 293 | 294 | - If the initial sync is interrupted and restarted, you may notice your logs (if they are on and set to level "spam") 295 | spitting out a bunch of messages about blocks being re-added to the chain. 296 | This is the in-memory chain getting re-initialized from the headersindex. This is necessary 297 | for blocks after the network's lastCheckpoint since the chain db is used for certain contextual checks 298 | when syncing a node, for example handling re-orgs and orphan blocks. We take the header index data that is persisted 299 | and add these to the chain db so that they are available for these operations. 300 | 301 | - The HeaderIndexer takes the place of the chain in several places for the Header Node to avoid some of this 302 | reliance on the chain that is not persisted. The custom `HeaderPool` is extended from bcoin's default `Pool` object 303 | to replace calls to methods normally done by the chain that won't work given that there is no chain (or in the case 304 | of a custom start point, not even a proper genesis block). The best example is `getLocator` which 305 | normally gets block hashes all the way back to genesis on the chain, but in our case will run 306 | the checks on the header index, and stop early if using a custom start point. 307 | 308 | - In the unlikely case that you are using a header node on regtest or simnet (such as in the unit tests), 309 | it is not recommended to use a custom start height. The reason is that there are some different PoW checks that are done 310 | for testing networks to account for variance in mining hash power. So in a situation where there are no checkpoints or you're 311 | starting your node _after_ the lastCheckpoint (which is zero for regtest/simnet), the chain will search backwards for old blocks 312 | to confirm proof of work even if not in a new retargeting interval. Start height initialization will typically account for this 313 | on testnet and mainnet for example, but since regtest does not have a lastCheckpoint, this can make behavior a little weird. 314 | For the tests, to confirm that the start height functionality works with checkpoints, we adjust the retarget interval down in some cases and 315 | set a custom lastCheckpoint rather than having to mine over 2k blocks which would slow the tests down. 316 | 317 | ## TODO: 318 | 319 | - [ ] Investigate other performance improvements such as [compressed headers](https://github.com/RCasatta/compressedheaders) 320 | - [ ] Fix or avoid tedious process of re-initializing chain from headers index when past lastCheckpoint 321 | - [ ] Add support for later start heights, after lastCheckpoint. 322 | 323 | ## License 324 | 325 | [Apache License, Version 2.0](https://opensource.org/licenses/Apache-2.0) 326 | 327 | ```txt 328 | Copyright (C) 2019 Tierion 329 | 330 | Licensed under the Apache License, Version 2.0 (the "License"); 331 | you may not use this file except in compliance with the License. 332 | You may obtain a copy of the License at 333 | 334 | http://www.apache.org/licenses/LICENSE-2.0 335 | 336 | Unless required by applicable law or agreed to in writing, software 337 | distributed under the License is distributed on an "AS IS" BASIS, 338 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 339 | See the License for the specific language governing permissions and 340 | limitations under the License. 341 | ``` 342 | 343 | See LICENSE for more info. 344 | -------------------------------------------------------------------------------- /bin/bhn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | process.title = 'bhn'; 6 | 7 | const BHN = require('../lib/headernode'); 8 | 9 | const node = new BHN({ 10 | file: true, 11 | argv: true, 12 | env: true, 13 | logFile: true, 14 | logConsole: true, 15 | logLevel: 'debug', 16 | db: 'leveldb', 17 | memory: false, 18 | persistent: true, 19 | workers: true, 20 | listen: true, 21 | loader: require, 22 | }); 23 | 24 | // Temporary hack 25 | // default to no-wallet for the filter node 26 | if (!node.config.bool('no-wallet', true) && !node.has('walletdb')) { 27 | const plugin = require('../lib/wallet/plugin'); 28 | node.use(plugin); 29 | } 30 | 31 | process.on('unhandledRejection', (err, promise) => { 32 | throw err; 33 | }); 34 | 35 | process.on('SIGINT', async () => { 36 | if (node.opened) await node.close(); 37 | }); 38 | 39 | (async () => { 40 | await node.ensure(); 41 | await node.open(); 42 | await node.connect(); 43 | await node.startSync(); 44 | })().catch(err => { 45 | console.error(err.stack); 46 | process.exit(1); 47 | }); 48 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | bhn: 4 | build: . 5 | environment: 6 | BCOIN_HTTP_HOST: 0.0.0.0 7 | # uncomment to set an api key or add `BCOIN_NO_AUTH:'true'` 8 | # BCOIN_API_KEY: my-api-key 9 | ports: 10 | #-- Mainnet 11 | - '8333:8333' 12 | - '8332:8332' # RPC 13 | - '8334:8334' # Wallet 14 | #-- Testnet 15 | - '18333:18333' 16 | - '18332:18332' # RPC 17 | - '18334:18334' # Wallet 18 | -------------------------------------------------------------------------------- /lib/headerindexer.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const bdb = require('bdb') 4 | const assert = require('bsert') 5 | const bio = require('bufio') 6 | const { Lock } = require('bmutex') 7 | const { Indexer, Headers, ChainEntry, CoinView, util } = require('bcoin') 8 | const { BlockMeta } = require('./records') 9 | const layout = require('./layout') 10 | const { getRemoteBlockEntries } = require('./util') 11 | /** 12 | * FilterIndexer 13 | * @alias module:indexer.FilterIndexer 14 | * @extends Indexer 15 | */ 16 | class HeaderIndexer extends Indexer { 17 | /** 18 | * Create a indexer 19 | * @constructor 20 | * @param {Object} options 21 | */ 22 | 23 | constructor(options) { 24 | super('headers', options) 25 | 26 | this.db = bdb.create(this.options) 27 | this.checkpoints = this.chain.options.checkpoints 28 | this.locker = new Lock() 29 | this.bound = [] 30 | if (options) this.fromOptions(options) 31 | } 32 | 33 | /** 34 | * Inject properties from object. 35 | * @private 36 | * @param {Object} options 37 | * @returns {HeaderIndexer options} 38 | */ 39 | 40 | fromOptions(options) { 41 | if (!options.network) { 42 | // Without this, the base indexer defaults to mainnet if no 43 | // network is set even if chain has a different network 44 | this.network = this.chain.network 45 | } 46 | 47 | if (options.startHeight) { 48 | assert(typeof options.startHeight === 'number') 49 | this.startHeight = options.startHeight 50 | } else { 51 | // always need to initialize a startHeight so set to genesis if none is passed 52 | // this will get overriden later if one has been saved in the database already 53 | // or below if a startBlock is passed 54 | this.startHeight = 0 55 | } 56 | 57 | // start block will take precedence over startHeight 58 | if (options.startBlock) { 59 | assert(options.startBlock.length >= 2, 'Chain tip array must have two items to initiate block') 60 | 61 | // check that we have two buffers for chain tip 62 | for (let raw of options.startBlock) { 63 | assert(Buffer.isBuffer(raw), 'Chain tip items must be buffers') 64 | } 65 | 66 | // start entry is the last in the tip field since we need a previous block 67 | // to pass contextual chain checks 68 | const startEntry = ChainEntry.fromRaw(options.startBlock.slice(-1)[0]) 69 | 70 | // once everything passes, we can set our values 71 | this.startBlock = options.startBlock 72 | this.startHeight = startEntry.height 73 | } 74 | 75 | return this 76 | } 77 | 78 | async open() { 79 | // ensure is normally run by `super`'s `open` method 80 | // but in this case we need to make sure that the required 81 | // direcetories exist before setStartBlock is run 82 | await this.ensure() 83 | 84 | // need to setStartBlock before anything else since 85 | // the super open method will also connect with the chain 86 | // which causes events to fire that the tip needs to be initialized for first 87 | await this.setStartBlock() 88 | await super.open() 89 | await this.initializeChain() 90 | this.logger.info('Indexer successfully loaded') 91 | } 92 | 93 | /** 94 | * Close the indexer, wait for the database to close, 95 | * unbind all events. 96 | * @returns {Promise} 97 | */ 98 | 99 | async close() { 100 | await this.db.close() 101 | // removing listeners when close to avoid duplicated listeners if 102 | // the indexer is re-opened 103 | for (const [event, listener] of this.bound) this.chain.removeListener(event, listener) 104 | 105 | this.bound.length = 0 106 | } 107 | 108 | /** 109 | * Bind to chain events and save listeners for removal on close 110 | * @private 111 | */ 112 | 113 | bind() { 114 | const listener = async (entry, block, view) => { 115 | const meta = new BlockMeta(entry.hash, entry.height) 116 | 117 | try { 118 | await this.sync(meta, block, view) 119 | } catch (e) { 120 | this.emit('error', e) 121 | } 122 | } 123 | 124 | for (const event of ['connect', 'disconnect', 'reset']) { 125 | this.bound.push([event, listener]) 126 | this.chain.on(event, listener) 127 | } 128 | } 129 | 130 | /** 131 | * @private 132 | * Set custom starting entry for fast(er) sync 133 | */ 134 | 135 | async setStartBlock() { 136 | const unlock = await this.locker.lock() 137 | try { 138 | // since this method is run before the `open` method where 139 | // there are other contextual checks that are done, 140 | // let's manually open the indexer db to initialize starting tip 141 | if (!this.db.loaded) await this.db.open() 142 | this.start() // batch db write operations 143 | await this._setStartBlock() 144 | this.commit() // write to the db 145 | } finally { 146 | unlock() 147 | if (this.db.loaded) await this.db.close() 148 | } 149 | } 150 | 151 | async _setStartBlock() { 152 | // first need to see if a start height was already saved in the db 153 | this.logger.debug('Checking database for existing starting entry') 154 | 155 | const startBlock = await this.getStartBlock() 156 | if (startBlock) { 157 | const startEntry = ChainEntry.fromRaw(startBlock[1]) 158 | 159 | // perform some checks on the startEntry: 160 | // if a start height was passed as an option and doesn't match with 161 | // one saved in DB, throw an error 162 | if (this.startHeight && startEntry.height !== this.startHeight) 163 | throw new Error( 164 | `Cannot retroactively change start height. Current start height is ${startEntry.height}. To change the start height delete indexer database otherwise remove start height config to use existing.` 165 | ) 166 | else if (this.startHeight && this.startHeight === startEntry.height) 167 | this.logger.debug(`Start height already set at block ${startEntry.height}.`) 168 | else this.logger.info(`Starting block for header chain initializing to ${startEntry.height}`) 169 | 170 | // if checks have completed, we can initialize the start block in the db 171 | await this.initStartBlock(startBlock) 172 | 173 | // set indexer's start block and start height for reference 174 | this.startHeight = startEntry.height 175 | this.startBlock = startBlock 176 | 177 | // if we had a startBlock then we're done and can return 178 | return 179 | } 180 | 181 | // if no custom startBlock or startHeight then we can skip and sync from genesis 182 | if (!this.startBlock && !this.startHeight) { 183 | this.startHeight = 0 184 | return 185 | } 186 | 187 | // validate the startHeight that has been set correctly 188 | // will throw on any validation errors and should end startup 189 | this.validateStartHeight(this.startHeight) 190 | 191 | // Next, if we have a startHeight but no startBlock, we can "cheat" by using an external api 192 | // to retrieve the block header information for mainnet and testnet blocks. 193 | // This is not a trustless approach but is easier to bootstrap. 194 | // startBlock will take precedence if one is set however and the chain won't be able to sync 195 | // if initialized with "fake" blocks 196 | if (this.startHeight && !this.startBlock) { 197 | assert( 198 | this.network.type === 'main' || this.network.type === 'testnet', 199 | 'Can only get starting block data for mainnet or testnet. Use `startBlock` \ 200 | with raw block data instead for other networks' 201 | ) 202 | 203 | const entries = await getRemoteBlockEntries(this.network.type, this.startHeight - 1, this.startHeight) 204 | 205 | this.logger.info('Setting custom start height at %d', this.startHeight) 206 | this.startBlock = entries 207 | } 208 | 209 | // Next, validate and init starting tip in db 210 | const tipEntries = await this.initStartBlock(this.startBlock) 211 | 212 | // get last two items for the prev and tip to index and set indexer state 213 | const [prev, tip] = tipEntries.slice(-2) 214 | 215 | // need to set the indexer state so that the tip can be properly read and set 216 | this.height = tip.height 217 | this.startHeight = tip.height 218 | 219 | // save the starting height in the database for re-starting later 220 | // this value is checked in getStartBlock which is called at the beginning of this method 221 | // TODO: would be nice if this was in records.js for reading and writing from buffer/database 222 | const bw = bio.write() 223 | bw.writeU32(this.startHeight) 224 | await this.db.put(layout.s.encode(), bw.render()) 225 | 226 | // also need to add the entry to the header index if it doesn't exist 227 | // this will index the entry and set the tip 228 | if (!(await this.getHeader(prev.height))) await this.indexEntryBlock(prev) 229 | if (!(await this.getHeader(tip.height))) await this.indexEntryBlock(tip) 230 | 231 | // closing and re-opening the chain will reset the state 232 | // based on the custom starting tip 233 | await this.chain.close() 234 | await this.chain.open() 235 | } 236 | 237 | /* 238 | * @private 239 | * check the headers index database for an existing start block. 240 | * Needs the db to be open first 241 | * @returns {null|Buffer[]} - null if no start height set otherwise an array 242 | * of two items with the two starting entries 243 | */ 244 | 245 | async getStartBlock() { 246 | const data = await this.db.get(layout.s.encode()) 247 | 248 | // if no height is saved then return null 249 | if (!data) return null 250 | 251 | // convert data buffer to U32 252 | const buffReader = bio.read(data) 253 | const startHeight = buffReader.readU32() 254 | let startEntry = await this.getEntry(startHeight) 255 | assert(startEntry, `Could not find an entry in database for starting height ${startHeight}`) 256 | 257 | // Need to also get a prevEntry which is necessary for contextual checks of an entry 258 | const prevEntry = await this.getEntry(startHeight - 1) 259 | assert(prevEntry, `No entry in db for starting block's previous entry at: ${startHeight - 1}`) 260 | return [prevEntry.toRaw(), startEntry.toRaw()] 261 | } 262 | 263 | /** 264 | * @private 265 | * verify start block or height to confirm it passes the minimum threshold 266 | * MUST be called after this.network has been set 267 | * @param {ChainEntry | Number} entryOrHeight 268 | * @returns {void|Boolean} throws on any invalidations otherwise re 269 | */ 270 | 271 | validateStartHeight(height) { 272 | assert(typeof height === 'number', 'Must pass a number as the start height to verify') 273 | 274 | const { lastCheckpoint } = this.network 275 | 276 | // cannot be genesis (this is default anyway though) 277 | assert(height >= 0, 'Custom start height must be a positive integer') 278 | 279 | // must be less than last checkpoint 280 | // must qualify as historical with at least one retarget interval occuring between height and lastCheckpoint 281 | if (lastCheckpoint) 282 | assert( 283 | this.isHistorical(height), 284 | `Starting entry height ${height} is too high. Must be before the lastCheckpoint (${lastCheckpoint}) ` + 285 | `and a retargetting interval. Recommended max start height: ${this.getHistoricalPoint()}` 286 | ) 287 | 288 | return true 289 | } 290 | 291 | /** 292 | * @private 293 | * initialize a startBlock by running some validations and adding it to the _chain_ db 294 | * This will validate the startBlock argument and add them to the chain db 295 | * @param {Buffer[]} startBlock - an array of at least two raw chain entries 296 | * @returns {ChainEntry[]} entry - promise that resolves to array of tip entries 297 | */ 298 | 299 | async initStartBlock(startBlock) { 300 | // when chain is reset and the tip is not 301 | // the genesis block, chain will check to see if 302 | // it can find the previous block for the tip. 303 | // this means that for a custom start, we need two 304 | // entries: the tip to start from, and the previous entry 305 | assert(Array.isArray(startBlock) && startBlock.length >= 2, 'Need at least two blocks for custom start block') 306 | 307 | // need the chain db to be open so that we can set the tip there to match the indexer 308 | assert(this.chain.opened, 'Chain should be opened to set the header index tip') 309 | 310 | const tip = [] // store an array of serialized entries to return if everything is successful 311 | 312 | let entry, prev 313 | for (let raw of startBlock) { 314 | prev = entry ? entry : null 315 | try { 316 | // this will fail if serialization is wrong (i.e. not an entry buffer) or if data is not a Buffer 317 | entry = ChainEntry.fromRaw(raw) 318 | } catch (e) { 319 | if (e.type === 'EncodingError') 320 | throw new Error( 321 | 'headerindexer: There was a problem deserializing data. Must pass a block or chain entry buffer to start fast sync.' 322 | ) 323 | throw e 324 | } 325 | 326 | this.validateStartHeight(entry.height) 327 | 328 | // confirm that the starter tip is made up of incrementing blocks 329 | // i.e. prevBlock hash matches hash of previous block in array 330 | if (prev) { 331 | assert.equal( 332 | entry.prevBlock.toString('hex'), 333 | prev.hash.toString('hex'), 334 | `Entry's prevBlock doesn't match previous block hash (prev: ${prev.hash.toString( 335 | 'hex' 336 | )}, tip: ${entry.prevBlock.toString('hex')})` 337 | ) 338 | } 339 | 340 | // and then add the entries to the chaindb with reconnect 341 | // note that this won't update the chain object, just its db 342 | await this.addEntryToChain(entry) 343 | tip.push(entry) 344 | } 345 | return tip 346 | } 347 | 348 | /** 349 | * Initialize chain by comparing with an existing 350 | * Headers index if one exists 351 | * Because we only use an in-memory chain, we may need to initialize 352 | * the chain from saved state in the headers index if it's being persisted 353 | */ 354 | 355 | async initializeChain() { 356 | const unlock = await this.locker.lock() 357 | try { 358 | await this._initializeChain() 359 | } finally { 360 | unlock() 361 | } 362 | } 363 | 364 | async _initializeChain() { 365 | const indexerHeight = await this.height 366 | 367 | // if everything is fresh, we can sync as normal 368 | if (!indexerHeight) return 369 | 370 | // get chain tip to compare w/ headers index 371 | let chainTip = await this.chain.db.getTip() 372 | 373 | // if there is no chainTip or chainTip is behind the headers height 374 | // then we need to rebuild the in-memory chain for contextual checks 375 | // and index management 376 | if (!chainTip || chainTip.height < indexerHeight) { 377 | this.logger.info('Chain state is behind header. Re-initializing...') 378 | 379 | // Need to set the starting entry to initialize the chain from. 380 | // Option 1) If tip is before historical point, then we will re-intialize chain from the startHeight 381 | // Option 2) If there's no lastCheckpoint (e.g. regtest), re-initialize from genesis 382 | // Option 3) When header tip is not historical, the chain still needs to be initialized to 383 | // start from first non-historical block for contextual checks (e.g. pow) 384 | let entry 385 | if (this.isHistorical(indexerHeight)) { 386 | this.logger.debug( 387 | 'Headers tip before last checkpoint. Re-initializing chain from start height: %d', 388 | this.startHeight 389 | ) 390 | 391 | entry = await this.getEntry(this.startHeight) 392 | } else if (!this.network.lastCheckpoint) { 393 | this.logger.info('Re-initializing chain db from genesis block') 394 | // since the genesis block will be hard-coded in, we actually will be initializing from block #1 395 | // but first run sanity check that we have a genesis block 396 | assert(this.network.genesisBlock, `Could not find a genesis block for ${this.network.type}`) 397 | entry = await this.getEntry(1) 398 | } else { 399 | // otherwise first entry in the chain should be the first "non-historical" block 400 | this.logger.info('Re-initializing chain from last historical block: %d', this.getHistoricalPoint()) 401 | entry = await this.getHeader(this.getHistoricalPoint() + 1) 402 | } 403 | 404 | // add entries until chain is caught up to the header index 405 | while (entry && entry.height <= indexerHeight) { 406 | this.logger.spam('Re-indexing block entry %d to chain: %h', entry.height, entry.hash) 407 | await this.addEntryToChain(entry) 408 | // increment to the next entry 409 | entry = await this.getEntry(entry.height + 1) 410 | } 411 | 412 | // reset the chain once the db is loaded 413 | await this.chain.close() 414 | await this.chain.open() 415 | 416 | this.logger.info('ChainDB successfully re-initialized to headers tip.') 417 | } 418 | } 419 | 420 | /** 421 | * Add a block's transactions without a lock. 422 | * modified addBlock from parent class 423 | * @private 424 | * @param {BlockMeta} meta 425 | * @param {Block} block 426 | * @param {CoinView} view 427 | * @returns {Promise} 428 | */ 429 | 430 | async _addBlock(meta, block, view) { 431 | // removed hasRaw check for block from parent class since we are in spv mode, 432 | // which we use for header node, we get merkleblocks which don't have 433 | // the `hasRaw` method and the check is for tx serialization anyway 434 | const start = util.bench() 435 | 436 | if (meta.height !== this.height + 1) throw new Error('Indexer: Can not add block.') 437 | 438 | // Start the batch write. 439 | this.start() 440 | 441 | // Call the implemented indexer to add to 442 | // the batch write. 443 | await this.indexBlock(meta, block, view) 444 | 445 | // Sync the height to the new tip. 446 | const height = await this._setTip(meta) 447 | 448 | // Commit the write batch to disk. 449 | await this.commit() 450 | 451 | // Update height _after_ successful commit. 452 | this.height = height 453 | 454 | // Log the current indexer status. 455 | this.logStatus(start, block, meta) 456 | } 457 | 458 | /** 459 | * add header to index. 460 | * @private 461 | * @param {ChainEntry} entry for block to chain 462 | * @param {Block} block - Block to index 463 | * @param {CoinView} view - Coin View 464 | * @returns {Promise} returns promise 465 | */ 466 | async indexBlock(meta, block) { 467 | const height = meta.height 468 | 469 | // save block header 470 | // if block is historical (i.e. older than last checkpoint w/ at least one retarget interval) 471 | // we can save the header. Otherwise need to save the 472 | // whole entry so the chain can be replayed from that point 473 | if (this.isHistorical(height)) { 474 | const header = Headers.fromBlock(block) 475 | this.put(layout.b.encode(height), header.toRaw()) 476 | } else { 477 | const prev = await this.chain.getEntry(height - 1) 478 | const entry = ChainEntry.fromBlock(block, prev) 479 | this.put(layout.b.encode(height), entry.toRaw()) 480 | } 481 | } 482 | 483 | /** 484 | * Remove header from index. 485 | * @private 486 | * @param {ChainEntry} entry 487 | * @param {Block} block 488 | * @param {CoinView} view 489 | */ 490 | 491 | async unindexBlock(meta) { 492 | const height = meta.height 493 | 494 | this.del(layout.b.encode(height)) 495 | } 496 | 497 | /** 498 | * locator code is mostly from bcoin's chain.getLocator 499 | * Calculate chain locator (an array of hashes). 500 | * Need this to override chain's getLocator to account for custom startBlock 501 | * which means we have no history earlier than that block which breaks 502 | * the normal getLocator 503 | * @param {Hash?} start - Height or hash to treat as the tip. 504 | * The current tip will be used if not present. Note that this can be a 505 | * non-existent hash, which is useful for headers-first locators. 506 | * @returns {Promise} - Returns {@link Hash}[]. 507 | */ 508 | 509 | async getLocator(start) { 510 | const unlock = await this.locker.lock() 511 | try { 512 | return await this._getLocator(start) 513 | } finally { 514 | unlock() 515 | } 516 | } 517 | 518 | /** 519 | * Calculate chain locator without a lock. 520 | * Last locator should be genesis _or_ startHeight 521 | * if there is one 522 | * @private 523 | * @param {Hash?} start 524 | * @returns {Hash[]} hashes - array of entry hashs 525 | */ 526 | async _getLocator(start) { 527 | let entry 528 | if (start == null) { 529 | entry = await this.getEntry(this.height) 530 | } else { 531 | assert(Buffer.isBuffer(start)) 532 | entry = await this.chain.getEntryByHash(start) 533 | } 534 | const hashes = [] 535 | 536 | let main = await this.chain.isMainChain(entry) 537 | let hash = entry.hash 538 | let height = entry.height 539 | let step = 1 540 | 541 | hashes.push(hash) 542 | 543 | // in `Chain` version of getLocator this is just zero. But this will break if 544 | // we try and get an entry older than the historical point 545 | const end = this.startHeight 546 | 547 | while (height > end) { 548 | height -= step 549 | 550 | if (height < end) height = end 551 | 552 | if (hashes.length > 10) step *= 2 553 | 554 | if (main) { 555 | // If we're on the main chain, we can 556 | // do a fast lookup of the hash. 557 | hash = await this.getHash(height) 558 | assert(hash) 559 | } else { 560 | const ancestor = await this.chain.getAncestor(entry, height) 561 | assert(ancestor) 562 | main = await this.chain.isMainChain(ancestor) 563 | hash = ancestor.hash 564 | } 565 | 566 | hashes.push(hash) 567 | } 568 | 569 | return hashes 570 | } 571 | 572 | /** 573 | * Get block header by height 574 | * @param {height} block height 575 | * @returns {Headers|null} block header 576 | */ 577 | 578 | async getHeader(height) { 579 | assert(typeof height === 'number' && height >= 0, 'Must pass valid height to get header') 580 | const data = await this.db.get(layout.b.encode(height)) 581 | if (!data) return null 582 | if (this.isHistorical(height)) return Headers.fromRaw(data) 583 | return ChainEntry.fromRaw(data) 584 | } 585 | 586 | /** 587 | * Get block entry by height or hash 588 | * Overwrites the parent method which only handles by hash 589 | * If passed a height then it can convert a header to entry for 590 | * historical blocks which don't have an entry available 591 | * @param {Number|Buffer} height or hash - block height or hash 592 | * @returns {Headers|null} block entry 593 | */ 594 | async getEntry(heightOrHash) { 595 | // indexer checks the chain db first by default 596 | // we can use that first and use header indexer 597 | // if none is found in the chain db (since it is not persisted) 598 | const entry = await super.getEntry(heightOrHash) 599 | if (entry) return entry 600 | 601 | let header = await this.getHeader(heightOrHash) 602 | 603 | // return null if none exists 604 | if (!header) return null 605 | 606 | // if it is already a chainentry then we can return it 607 | if (ChainEntry.isChainEntry(header)) return header 608 | let { height } = header 609 | 610 | if (!height) height = heightOrHash 611 | 612 | assert(typeof height === 'number') 613 | 614 | // otherwise convert to an entry by getting JSON w/ correct height 615 | // and adding a null chainwork (needed for entry initialization) 616 | header = header.getJSON(this.network.type, null, height) 617 | header.chainwork = '0' 618 | return ChainEntry.fromJSON(header) 619 | } 620 | 621 | /** 622 | * Test whether the entry is potentially an ancestor of a checkpoint. 623 | * This is adapted from the chain's "isHistorical" 624 | * but to account for custom startHeights. Historical in this case is shifted to be before 625 | * the last retarget before the lastCheckpoint since chain needs at least 1 retargeted entry 626 | * @param {ChainEntry} prev 627 | * @returns {Boolean} 628 | */ 629 | 630 | isHistorical(height) { 631 | if (this.checkpoints) { 632 | // in the case where there is no lastCheckpoint then we just set to zero 633 | const historicalPoint = this.getHistoricalPoint() 634 | if (height <= historicalPoint) return true 635 | } 636 | return false 637 | } 638 | 639 | getHistoricalPoint() { 640 | const { 641 | lastCheckpoint, 642 | pow: { retargetInterval } 643 | } = this.network 644 | // in the case where there is no lastCheckpoint then we just set to zero 645 | return lastCheckpoint ? lastCheckpoint - (lastCheckpoint % retargetInterval) : 0 646 | } 647 | 648 | /* 649 | * Simple utility to add an entry to the chain 650 | * with chaindb's 'reconnect' 651 | */ 652 | async addEntryToChain(entry) { 653 | // `reconnect` needs a block. The AbstractBlock class 654 | // that Headers inherits from should be sufficient 655 | const block = Headers.fromHead(entry.toRaw()) 656 | block.txs = [] 657 | 658 | // chaindb's reconnect will make the updates to the 659 | // the chain state that we need to catch up 660 | await this.chain.db.reconnect(entry, block, new CoinView()) 661 | } 662 | 663 | /* 664 | * Takes a ChainEntry and derives a block so that it can index 665 | * the block and set a new tip 666 | * @param {ChainEntry} entry - chain entry to index 667 | */ 668 | async indexEntryBlock(entry) { 669 | this.logger.debug('Indexing entry block %d: %h', entry.height, entry.hash) 670 | const block = Headers.fromHead(entry.toRaw()) 671 | await this.indexBlock(entry, block, new CoinView()) 672 | const tip = BlockMeta.fromEntry(entry) 673 | await this._setTip(tip) 674 | } 675 | 676 | /** 677 | * Get the hash of a block by height. Note that this 678 | * will only return hashes in the main chain. 679 | * @param {Number} height 680 | * @returns {Promise} - Returns {@link Hash}. 681 | */ 682 | 683 | async getHash(height) { 684 | if (Buffer.isBuffer(height)) return height 685 | 686 | assert(typeof height === 'number') 687 | 688 | if (height < 0) return null 689 | 690 | // NOTE: indexer has no cache 691 | // this.getHash is replacing functionality normally done by the chain 692 | // which does cacheing for performance improvement 693 | // this would be a good target for future optimization of the header chain 694 | 695 | // const entry = this.cacheHeight.get(height); 696 | 697 | // if (entry) 698 | // return entry.hash; 699 | 700 | return this.db.get(layout.h.encode(height)) 701 | } 702 | 703 | /** 704 | * Get index tip. 705 | * @param {Hash} hash 706 | * @returns {Promise} 707 | */ 708 | 709 | async getTip() { 710 | let height = this.height 711 | assert(height, 'Cannot get headers tip until indexer has been initialized and synced') 712 | 713 | // in some instances when this has been run the indexer hasn't had a chance to 714 | // catch up to the chain and re-index a block, so we need to rollforward to the tip 715 | if (height < this.chain.height) { 716 | await this._rollforward() 717 | height = this.height 718 | } 719 | 720 | const tip = await this.getHeader(height) 721 | 722 | if (!tip) throw new Error('Indexer: Tip not found!') 723 | 724 | return tip 725 | } 726 | } 727 | 728 | module.exports = HeaderIndexer 729 | -------------------------------------------------------------------------------- /lib/headernode.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * headernode.js - header node for bcoin based on 3 | * spv node from bcoin 4 | */ 5 | 6 | 'use strict' 7 | 8 | const assert = require('bsert') 9 | const { Chain, Node, blockstore } = require('bcoin') 10 | 11 | const HTTP = require('./http') 12 | const RPC = require('./rpc') 13 | const HeaderIndexer = require('./headerindexer') 14 | const HeaderPool = require('./headerpool') 15 | 16 | /** 17 | * Header Node 18 | * Create a headernode which only maintains 19 | * an in-memory chain, a pool, a headers index and an http server. 20 | * @alias module:node.HeaderNode 21 | * @extends Node 22 | */ 23 | 24 | class HeaderNode extends Node { 25 | /** 26 | * Create Headers node. 27 | * @constructor 28 | * @param {Object?} options 29 | */ 30 | 31 | constructor(options) { 32 | super('bhn', 'bhn.conf', 'debug.log', options) 33 | 34 | this.opened = false 35 | 36 | this.spv = true 37 | this.checkpoints = true 38 | this.selfish = true 39 | 40 | // Instantiate block storage. 41 | this.blocks = blockstore.create({ 42 | network: this.network, 43 | logger: this.logger, 44 | prefix: this.config.prefix, 45 | cacheSize: this.config.mb('block-cache-size'), 46 | memory: this.memory 47 | }) 48 | 49 | // Instantiate blockchain. 50 | // Chain needs access to blocks. 51 | this.chain = new Chain({ 52 | network: this.network, 53 | logger: this.logger, 54 | blocks: this.blocks, 55 | workers: this.workers, 56 | memory: true, 57 | prefix: this.config.prefix, 58 | maxFiles: this.config.uint('max-files'), 59 | cacheSize: this.config.mb('cache-size'), 60 | forceFlags: this.config.bool('force-flags'), 61 | bip91: this.config.bool('bip91'), 62 | bip148: this.config.bool('bip148'), 63 | prune: this.config.bool('prune'), 64 | checkpoints: this.checkpoints, 65 | entryCache: this.config.uint('entry-cache'), 66 | selfish: this.selfish, 67 | spv: this.spv 68 | }) 69 | 70 | this.headerindex = new HeaderIndexer({ 71 | network: this.network, 72 | logger: this.logger, 73 | blocks: this.blocks, 74 | chain: this.chain, 75 | memory: this.config.bool('memory'), 76 | prefix: this.config.filter('index').str('prefix') || this.config.prefix, 77 | startBlock: this.config.array('start-block'), 78 | startHeight: this.config.int('start-height') 79 | }) 80 | 81 | this.pool = new HeaderPool({ 82 | network: this.network, 83 | logger: this.logger, 84 | chain: this.chain, 85 | prefix: this.config.prefix, 86 | selfish: this.selfish, 87 | compact: false, 88 | bip37: false, 89 | maxOutbound: this.config.uint('max-outbound'), 90 | maxInbound: this.config.uint('max-inbound'), 91 | createSocket: this.config.func('create-socket'), 92 | proxy: this.config.str('proxy'), 93 | onion: this.config.bool('onion'), 94 | upnp: this.config.bool('upnp'), 95 | seeds: this.config.array('seeds'), 96 | nodes: this.config.array('nodes'), 97 | only: this.config.array('only'), 98 | publicHost: this.config.str('public-host'), 99 | publicPort: this.config.uint('public-port'), 100 | host: this.config.str('host'), 101 | port: this.config.uint('port'), 102 | listen: false, 103 | memory: this.config.bool('memory'), 104 | headerindex: this.headerindex, 105 | checkpoints: this.checkpoints 106 | }) 107 | 108 | this.rpc = new RPC(this) 109 | 110 | this.http = new HTTP({ 111 | network: this.network, 112 | logger: this.logger, 113 | node: this, 114 | prefix: this.config.prefix, 115 | ssl: this.config.bool('ssl'), 116 | keyFile: this.config.path('ssl-key'), 117 | certFile: this.config.path('ssl-cert'), 118 | host: this.config.str('http-host'), 119 | port: this.config.uint('http-port'), 120 | apiKey: this.config.str('api-key'), 121 | noAuth: this.config.bool('no-auth'), 122 | cors: this.config.bool('cors') 123 | }) 124 | 125 | this.init() 126 | } 127 | 128 | /** 129 | * Initialize the node. 130 | * @private 131 | */ 132 | 133 | init() { 134 | // Bind to errors 135 | this.chain.on('error', err => this.error(err.stack)) 136 | this.pool.on('error', err => this.error(err.stack)) 137 | 138 | if (this.http) this.http.on('error', err => this.error(err.stack)) 139 | 140 | if (this.headerindex) this.headerindex.on('error', err => this.error(err.stack)) 141 | 142 | this.chain.on('block', block => this.emit('block', block)) 143 | 144 | this.chain.on('connect', async (entry, block) => { 145 | this.emit('block', block) 146 | this.emit('connect', entry, block) 147 | }) 148 | 149 | this.chain.on('disconnect', (entry, block) => { 150 | this.emit('disconnect', entry, block) 151 | }) 152 | 153 | this.chain.on('reorganize', (tip, competitor) => { 154 | this.emit('reorganize', tip, competitor) 155 | }) 156 | 157 | this.chain.on('reset', tip => this.emit('reset', tip)) 158 | } 159 | 160 | /** 161 | * Open the node and all its child objects, 162 | * wait for the database to load. 163 | * @returns {Promise} 164 | */ 165 | 166 | async open() { 167 | assert(!this.opened, 'HeaderNode is already open.') 168 | this.opened = true 169 | 170 | await this.handlePreopen() 171 | await this.blocks.open() 172 | await this.chain.open() 173 | await this.headerindex.open() 174 | await this.pool.open() 175 | await this.openPlugins() 176 | await this.http.open() 177 | await this.handleOpen() 178 | 179 | this.logger.info('Node is loaded.') 180 | } 181 | 182 | /** 183 | * Close the node, wait for the database to close. 184 | * @returns {Promise} 185 | */ 186 | 187 | async close() { 188 | assert(this.opened, 'HeaderNode is not open.') 189 | this.opened = false 190 | 191 | await this.handlePreclose() 192 | if (this.http.opened) await this.http.close() 193 | 194 | await this.closePlugins() 195 | await this.headerindex.close() 196 | await this.pool.close() 197 | await this.chain.close() 198 | await this.blocks.close() 199 | await this.handleClose() 200 | } 201 | 202 | /** 203 | * Connect to the network. 204 | * @returns {Promise} 205 | */ 206 | 207 | connect() { 208 | return this.pool.connect() 209 | } 210 | 211 | /** 212 | * Disconnect from the network. 213 | * @returns {Promise} 214 | */ 215 | 216 | disconnect() { 217 | return this.pool.disconnect() 218 | } 219 | 220 | /** 221 | * Start the blockchain sync. 222 | */ 223 | 224 | async startSync() { 225 | this.headerindex.sync() 226 | return this.pool.startSync() 227 | } 228 | 229 | /** 230 | * Stop syncing the blockchain. 231 | */ 232 | 233 | stopSync() { 234 | return this.pool.stopSync() 235 | } 236 | 237 | /** 238 | * Retrieve a block header from the header index. 239 | * @param {Height} height 240 | * @returns {Promise} - Returns {@link Headers}. 241 | */ 242 | 243 | getHeader(height) { 244 | return this.headerindex.getHeader(height) 245 | } 246 | 247 | /** 248 | * Get header index tip 249 | * @returns {Promise} - Returns {@link Headers}. 250 | */ 251 | 252 | getTip() { 253 | return this.headerindex.getTip() 254 | } 255 | 256 | /** 257 | * Get indexer start height 258 | * @returns {Promise} - Returns {@link Headers}. 259 | */ 260 | 261 | getStartHeight() { 262 | return this.headerindex.startHeight 263 | } 264 | } 265 | 266 | /* 267 | * Expose 268 | */ 269 | 270 | module.exports = HeaderNode 271 | -------------------------------------------------------------------------------- /lib/headerpool.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * headerpool.js - special Pool object for header nodes 3 | */ 4 | 5 | 'use strict' 6 | 7 | const assert = require('bsert') 8 | const { Pool } = require('bcoin') 9 | 10 | /** 11 | * Need a custom Pool object to deal with certain edge cases 12 | * for a header node where we don't have the full history 13 | * of the chain which, for example, breaks the chain.getLocator calls 14 | */ 15 | class HeaderPool extends Pool { 16 | constructor(options) { 17 | super(options) 18 | 19 | this.headerindex = null 20 | 21 | if (options.headerindex) { 22 | assert(typeof options.headerindex === 'object') 23 | this.headerindex = options.headerindex 24 | } 25 | } 26 | /** 27 | * Send a sync to each peer. 28 | * @private 29 | * @param {Boolean?} force 30 | * @returns {Promise} 31 | */ 32 | 33 | async resync(force) { 34 | if (!this.syncing) return 35 | 36 | let locator 37 | try { 38 | locator = await this.headerindex.getLocator() 39 | } catch (e) { 40 | this.emit('error', e) 41 | return 42 | } 43 | 44 | for (let peer = this.peers.head(); peer; peer = peer.next) { 45 | if (!peer.outbound) continue 46 | 47 | if (!force && peer.syncing) continue 48 | 49 | this.sendLocator(locator, peer) 50 | } 51 | } 52 | 53 | /** 54 | * Start syncing from peer. 55 | * @method 56 | * @param {Peer} peer 57 | * @returns {Promise} 58 | */ 59 | 60 | async sendSync(peer) { 61 | if (peer.syncing) return false 62 | 63 | if (!this.isSyncable(peer)) return false 64 | 65 | peer.syncing = true 66 | peer.blockTime = Date.now() 67 | 68 | let locator 69 | try { 70 | locator = await this.headerindex.getLocator() 71 | } catch (e) { 72 | peer.syncing = false 73 | peer.blockTime = -1 74 | this.emit('error', e) 75 | return false 76 | } 77 | 78 | return this.sendLocator(locator, peer) 79 | } 80 | 81 | /** 82 | * Send `getblocks` to peer after building 83 | * locator and resolving orphan root. 84 | * @method 85 | * @param {Peer} peer 86 | * @param {Hash} orphan - Orphan hash to resolve. 87 | * @returns {Promise} 88 | */ 89 | async resolveOrphan(peer, orphan) { 90 | const locator = await this.headerindex.getLocator() 91 | const root = this.chain.getOrphanRoot(orphan) 92 | 93 | assert(root) 94 | 95 | peer.sendGetBlocks(locator, root) 96 | } 97 | 98 | /** 99 | * Send `getheaders` to peer after building locator. 100 | * @method 101 | * @param {Peer} peer 102 | * @param {Hash} tip - Tip to build chain locator from. 103 | * @param {Hash?} stop 104 | * @returns {Promise} 105 | */ 106 | 107 | async getHeaders(peer, tip, stop) { 108 | const locator = await this.headerindex.getLocator(tip) 109 | peer.sendGetHeaders(locator, stop) 110 | } 111 | 112 | /** 113 | * Send `getblocks` to peer after building locator. 114 | * @method 115 | * @param {Peer} peer 116 | * @param {Hash} tip - Tip hash to build chain locator from. 117 | * @param {Hash?} stop 118 | * @returns {Promise} 119 | */ 120 | 121 | async getBlocks(peer, tip, stop) { 122 | const locator = await this.headerindex.getLocator(tip) 123 | peer.sendGetBlocks(locator, stop) 124 | } 125 | } 126 | 127 | /* 128 | * Expose 129 | */ 130 | 131 | module.exports = HeaderPool 132 | -------------------------------------------------------------------------------- /lib/http.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * server.js - http server for header node 3 | * Copyright (c) 2014-2015, Fedor Indutny (MIT License) 4 | * Copyright (c) 2014-2017, Christopher Jeffrey (MIT License). 5 | * Copyright (c) 2019-, Tierion Inc (MIT License). 6 | * https://github.com/bcoin-org/bcoin 7 | */ 8 | 9 | 'use strict' 10 | 11 | const Validator = require('bval') 12 | const sha256 = require('bcrypto/lib/sha256') 13 | const util = require('./util') 14 | const { 15 | pkg, 16 | Headers, 17 | node: { HTTP } 18 | } = require('bcoin') 19 | 20 | /** 21 | * HTTP 22 | * @alias module:http.Server 23 | */ 24 | 25 | class HeaderHTTP extends HTTP { 26 | /** 27 | * Create an http server. 28 | * @constructor 29 | * @param {Object} options 30 | */ 31 | 32 | constructor(options) { 33 | super(options) 34 | this.headerindex = this.node.headerindex 35 | } 36 | 37 | /** 38 | * Initialize routes. 39 | * @private 40 | */ 41 | 42 | initRouter() { 43 | if (this.options.cors) this.use(this.cors()) 44 | 45 | if (!this.options.noAuth) { 46 | this.use( 47 | this.basicAuth({ 48 | hash: sha256.digest, 49 | password: this.options.apiKey, 50 | realm: 'node' 51 | }) 52 | ) 53 | } 54 | 55 | this.use( 56 | this.bodyParser({ 57 | type: 'json' 58 | }) 59 | ) 60 | 61 | this.use(this.jsonRPC()) 62 | this.use(this.router()) 63 | 64 | this.error((err, req, res) => { 65 | const code = err.statusCode || 500 66 | res.json(code, { 67 | error: { 68 | type: err.type, 69 | code: err.code, 70 | message: err.message 71 | } 72 | }) 73 | }) 74 | 75 | this.get('/', async (req, res) => { 76 | let addr = this.pool.hosts.getLocal() 77 | 78 | if (!addr) addr = this.pool.hosts.address 79 | 80 | res.json(200, { 81 | version: pkg.version, 82 | network: this.network.type, 83 | chain: { 84 | height: this.chain.height, 85 | tip: this.chain.tip.rhash(), 86 | progress: this.chain.getProgress() 87 | }, 88 | pool: { 89 | host: addr.host, 90 | port: addr.port, 91 | agent: this.pool.options.agent, 92 | services: this.pool.options.services.toString(2), 93 | outbound: this.pool.peers.outbound, 94 | inbound: this.pool.peers.inbound 95 | }, 96 | time: { 97 | uptime: this.node.uptime(), 98 | system: util.now(), 99 | adjusted: this.network.now(), 100 | offset: this.network.time.offset 101 | }, 102 | memory: this.logger.memoryUsage() 103 | }) 104 | }) 105 | 106 | // Block by hash/height 107 | this.get('/block/:height', (req, res) => this.getBlockHeader(req, res)) 108 | this.get('/header/:height', (req, res) => this.getBlockHeader(req, res)) 109 | 110 | // get the start block 111 | this.get('/start', async (req, res) => { 112 | const start = this.headerindex.startHeight 113 | if (!start) { 114 | res.json(404) 115 | return 116 | } 117 | 118 | // getEntry will fall back to header if no entry found in chain so this is ok 119 | const entry = await this.headerindex.getEntry(start) 120 | entry.height = start 121 | enforce(entry != null, `Could not find entry for starting height ${start}`) 122 | 123 | // convert to header since this is what is expected by the API 124 | const header = Headers.fromBlock(entry) 125 | const json = header.getJSON(this.network, null, start) 126 | res.json(200, json) 127 | }) 128 | } 129 | 130 | /* 131 | * Get a block header by height. 132 | * This method is used by two paths so pulling out as helper method 133 | */ 134 | async getBlockHeader(req, res) { 135 | const valid = Validator.fromRequest(req) 136 | const height = valid.uint('height') 137 | 138 | enforce(height != null, 'Height required.') 139 | 140 | const header = await this.headerindex.getHeader(height) 141 | 142 | if (!header) { 143 | res.json(404) 144 | return 145 | } 146 | res.json(200, header.toJSON()) 147 | } 148 | } 149 | 150 | /* 151 | * Helpers 152 | */ 153 | 154 | function enforce(value, msg) { 155 | if (!value) { 156 | const err = new Error(msg) 157 | err.statusCode = 400 158 | throw err 159 | } 160 | } 161 | 162 | /* 163 | * Expose 164 | */ 165 | 166 | module.exports = HeaderHTTP 167 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * @module node 5 | */ 6 | 7 | exports.BHN = require('./headernode') 8 | -------------------------------------------------------------------------------- /lib/layout.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * layout.js - indexer layout for bcoin 3 | * Copyright (c) 2018, the bcoin developers (MIT License). 4 | * https://github.com/bcoin-org/bcoin 5 | */ 6 | 7 | 'use strict' 8 | 9 | const bdb = require('bdb') 10 | 11 | /* 12 | * Index Database Layout: 13 | * To be extended by indexer implementations 14 | * V -> db version 15 | * O -> flags 16 | * R -> chain sync state 17 | * b[height] -> block header 18 | * h[height] -> recent block hash 19 | * s -> starting block height 20 | */ 21 | 22 | const layout = { 23 | V: bdb.key('V'), 24 | O: bdb.key('O'), 25 | R: bdb.key('R'), 26 | b: bdb.key('b', ['uint32']), 27 | h: bdb.key('h', ['uint32']), 28 | s: bdb.key('s') 29 | } 30 | 31 | /* 32 | * Expose 33 | */ 34 | 35 | module.exports = layout 36 | -------------------------------------------------------------------------------- /lib/records.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Chain State for use with bcoin blockchain module 3 | * Copyright (c) 2014-2015, Fedor Indutny (MIT License) 4 | * Copyright (c) 2014-2019, Christopher Jeffrey (MIT License). 5 | * https://github.com/bcoin-org/bcoin 6 | */ 7 | 'use strict' 8 | const assert = require('bsert') 9 | const { protocol } = require('bcoin') 10 | const { ZERO_HASH } = protocol.consensus 11 | 12 | /** 13 | * Block Meta 14 | */ 15 | 16 | class BlockMeta { 17 | constructor(hash, height) { 18 | this.hash = hash || ZERO_HASH 19 | this.height = height || 0 20 | 21 | assert(Buffer.isBuffer(this.hash) && this.hash.length === 32) 22 | assert(Number.isInteger(this.height)) 23 | } 24 | 25 | /** 26 | * Instantiate block meta from chain entry. 27 | * @private 28 | * @param {IndexEntry} entry 29 | */ 30 | 31 | fromEntry(entry) { 32 | this.hash = entry.hash 33 | this.height = entry.height 34 | return this 35 | } 36 | 37 | /** 38 | * Instantiate block meta from chain entry. 39 | * @param {IndexEntry} entry 40 | * @returns {BlockMeta} 41 | */ 42 | 43 | static fromEntry(entry) { 44 | return new this().fromEntry(entry) 45 | } 46 | } 47 | 48 | /* 49 | * Expose 50 | */ 51 | 52 | exports.BlockMeta = BlockMeta 53 | 54 | module.exports = exports 55 | -------------------------------------------------------------------------------- /lib/rpc.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Extended rpc module for header node 3 | * Copyright (c) 2019-, Tierion Inc (MIT License). 4 | * https://github.com/chainpoint/bitcoin-header-node 5 | */ 6 | 7 | 'use strict' 8 | 9 | const Validator = require('bval') 10 | const assert = require('bsert') 11 | const { 12 | pkg, 13 | protocol: { Network, consensus }, 14 | node: { RPC } 15 | } = require('bcoin') 16 | const { RPCError } = require('bweb') 17 | const util = require('./util') 18 | 19 | // constants from base implementation 20 | const errs = { 21 | // General application defined errors 22 | MISC_ERROR: -1 23 | } 24 | 25 | class HeaderRPC extends RPC { 26 | constructor(node) { 27 | super(node) 28 | this.headerindex = node.headerindex 29 | } 30 | 31 | /* 32 | * Overall control/query calls 33 | */ 34 | 35 | async getInfo(args, help) { 36 | if (help || args.length !== 0) throw new RPCError(errs.MISC_ERROR, 'getinfo') 37 | 38 | return { 39 | version: pkg.version, 40 | protocolversion: this.pool.options.version, 41 | blocks: this.headerindex.height, 42 | startheight: this.headerindex.startHeight ? this.headerindex.startHeight : undefined, 43 | timeoffset: this.network.time.offset, 44 | connections: this.pool.peers.size(), 45 | proxy: '', 46 | difficulty: toDifficulty(this.chain.tip.bits), 47 | testnet: this.network !== Network.main, 48 | keypoololdest: 0, 49 | keypoolsize: 0, 50 | unlocked_until: 0, 51 | errors: '' 52 | } 53 | } 54 | 55 | init() { 56 | super.init() 57 | this.add('getheaderbyheight', this.getHeaderByHeight) 58 | this.add('getstartheader', this.getStartHeader) 59 | } 60 | 61 | async help(args) { 62 | if (args.length === 0) return `Select a command:\n${Object.keys(this.calls).join('\n')}` 63 | 64 | const json = { 65 | method: args[0], 66 | params: [] 67 | } 68 | 69 | return await this.execute(json, true) 70 | } 71 | 72 | async getStartHeader(args, help) { 73 | if (help || args.length > 1) throw new RPCError(errs.MISC_ERROR, 'getstartheader') 74 | 75 | const valid = new Validator(args) 76 | const verbose = valid.bool(0, true) 77 | 78 | const height = this.node.headerindex.startHeight 79 | const header = await this.node.headerindex.getEntry(height) 80 | if (!header) throw new RPCError(errs.MISC_ERROR, 'Block not found') 81 | 82 | if (!verbose) return header.toRaw().toString('hex', 0, 80) 83 | const json = await this.headerToJSON(header) 84 | // json.chainwork = undefined 85 | return json 86 | } 87 | 88 | async getHeaderByHeight(args, help) { 89 | if (help || args.length < 1 || args.length > 2) 90 | throw new RPCError(errs.MISC_ERROR, 'getheaderbyheight "height" ( verbose )') 91 | 92 | const valid = new Validator(args) 93 | const height = valid.u32(0) 94 | const verbose = valid.bool(1, true) 95 | 96 | if (height == null || height > this.chain.height || height < this.node.getStartHeight()) 97 | throw new RPCError(errs.MISC_ERROR, 'Block height out of range.') 98 | 99 | const entry = await this.node.headerindex.getEntry(height) 100 | 101 | if (!entry) throw new RPCError(errs.MISC_ERROR, 'Block not found') 102 | 103 | if (!verbose) return entry.toRaw().toString('hex', 0, 80) 104 | 105 | return await this.headerToJSON(entry) 106 | } 107 | 108 | // slightly different from the parent since the next lookup won't always work 109 | // and we probably wont' have the chainwork for a historical block 110 | async headerToJSON(entry) { 111 | const mtp = await this.chain.getMedianTime(entry) 112 | const next = await this.node.headerindex.getEntry(entry.height + 1) 113 | 114 | return { 115 | hash: entry.rhash(), 116 | confirmations: this.chain.height - entry.height + 1, 117 | height: entry.height, 118 | version: entry.version, 119 | versionHex: hex32(entry.version), 120 | merkleroot: util.revHex(entry.merkleRoot), 121 | time: entry.time, 122 | mediantime: mtp, 123 | bits: entry.bits, 124 | difficulty: toDifficulty(entry.bits), 125 | previousblockhash: !entry.prevBlock.equals(consensus.ZERO_HASH) ? util.revHex(entry.prevBlock) : null, 126 | nextblockhash: next.hash ? util.revHex(next.hash) : null 127 | } 128 | } 129 | } 130 | 131 | /* 132 | * Helpers 133 | */ 134 | 135 | function toDifficulty(bits) { 136 | let shift = (bits >>> 24) & 0xff 137 | let diff = 0x0000ffff / (bits & 0x00ffffff) 138 | 139 | while (shift < 29) { 140 | diff *= 256.0 141 | shift++ 142 | } 143 | 144 | while (shift > 29) { 145 | diff /= 256.0 146 | shift-- 147 | } 148 | 149 | return diff 150 | } 151 | 152 | function hex32(num) { 153 | assert(num >= 0) 154 | 155 | num = num.toString(16) 156 | 157 | assert(num.length <= 8) 158 | 159 | while (num.length < 8) num = '0' + num 160 | 161 | return num 162 | } 163 | 164 | module.exports = HeaderRPC 165 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * helper functions 3 | * Copyright (c) 2019-, Tierion (MIT License). 4 | * Copyright (c) 2014-2015, Fedor Indutny (MIT License). 5 | * Copyright (c) 2014-2017, Christopher Jeffrey (MIT License). 6 | * https://github.com/bcoin-org/bcoin 7 | */ 8 | 9 | 'use strict' 10 | 11 | const assert = require('bsert') 12 | const bcurl = require('bcurl') 13 | const { ChainEntry } = require('bcoin') 14 | 15 | /** 16 | * Reverse a hex-string. 17 | * @param {String} str - Hex string. 18 | * @returns {String} Reversed hex string. 19 | */ 20 | 21 | function revHex(buf) { 22 | assert(Buffer.isBuffer(buf)) 23 | 24 | const str = buf.toString('hex') 25 | 26 | let out = '' 27 | 28 | for (let i = str.length - 2; i >= 0; i -= 2) { 29 | out += str[i] + str[i + 1] 30 | } 31 | 32 | return out 33 | } 34 | 35 | function fromRev(str) { 36 | assert(typeof str === 'string') 37 | assert((str.length & 1) === 0) 38 | 39 | let out = '' 40 | 41 | for (let i = str.length - 2; i >= 0; i -= 2) { 42 | out += str[i] + str[i + 1] 43 | } 44 | 45 | return Buffer.from(out, 'hex') 46 | } 47 | 48 | /** 49 | * Get current time in unix time (seconds). 50 | * @returns {Number} 51 | */ 52 | 53 | function now() { 54 | return Math.floor(Date.now() / 1000) 55 | } 56 | 57 | /** 58 | * Retrieve block info from blockcypher 59 | * @param {Number} heights - a params list of block heights to retrieve 60 | * @returns {ChainEntry[]} - array of bcoin ChainEntries 61 | */ 62 | async function getRemoteBlockEntries(network, ...heights) { 63 | assert(typeof network === 'string', 'Must pass a network type of main or testnet as first argument') 64 | assert(network === 'main' || network === 'testnet', `${network} is not a valid network`) 65 | const client = bcurl.client(`https://api.blockcypher.com/v1/btc/${network === 'main' ? network : 'test3'}/blocks`) 66 | 67 | const blocks = [] 68 | 69 | for (let height of heights) { 70 | let block = await client.get(`/${height}`) 71 | if (!block) 72 | throw new Error( 73 | `No block returned for height ${height} on ${network} network from blockcypher. 74 | Make sure you have not exceeded API limit and the block exists on the blockchain.` 75 | ) 76 | block = convertBlockcypherMeta(block) 77 | blocks.push(ChainEntry.fromOptions(block).toRaw()) 78 | } 79 | 80 | return blocks 81 | } 82 | 83 | /** 84 | * Because the block data returned from blockcypher 85 | * does not conform to the same JSON structure as bcoin 86 | * we need to convert the property names 87 | * @param {Object} meta - blockcypher returned metadata for one block 88 | * @returns {Object} block - the bcoin conforming block object 89 | */ 90 | function convertBlockcypherMeta(meta) { 91 | const block = {} 92 | 93 | block.hash = fromRev(meta.hash) 94 | block.version = meta.ver 95 | block.prevBlock = fromRev(meta.prev_block) 96 | block.merkleRoot = fromRev(meta.mrkl_root) 97 | block.time = new Date(meta.time).getTime() / 1000 98 | block.bits = meta.bits 99 | block.nonce = meta.nonce 100 | block.height = meta.height 101 | 102 | return block 103 | } 104 | 105 | exports.revHex = revHex 106 | exports.fromRev = fromRev 107 | exports.getRemoteBlockEntries = getRemoteBlockEntries 108 | exports.convertBlockcypherMeta = convertBlockcypherMeta 109 | exports.now = now 110 | 111 | module.exports = exports 112 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bhn", 3 | "version": "0.0.2", 4 | "description": "Bitcoin Header Node- A lightweight node for syncing only bitcoin block headers from a specific height", 5 | "main": "lib/index.js", 6 | "repository": "git://github.com/chainpoint/bitcoin-header-node.git", 7 | "scripts": { 8 | "coverage": "nyc report --reporter=text-lcov | coveralls", 9 | "lint": "eslint lib/*.js test/*.js", 10 | "test": "nyc mocha --reporter spec test/*-test.js", 11 | "test:watch": "mocha --reporter spec --watch test/*-test.js", 12 | "test:inspect": "mocha --reporter spec --inspect-brk --watch test/*-test.js" 13 | }, 14 | "author": "bucko", 15 | "license": "MIT", 16 | "bin": { 17 | "bhn": "./bin/bhn" 18 | }, 19 | "dependencies": { 20 | "bclient": "^0.1.7", 21 | "bcoin": "bcoin-org/bcoin", 22 | "bcurl": "^0.1.6", 23 | "bdb": "^1.1.7", 24 | "bfile": "^0.2.0", 25 | "bmutex": "^0.1.6", 26 | "bsert": "^0.0.9", 27 | "bufio": "^1.0.5", 28 | "bweb": "^0.1.8" 29 | }, 30 | "devDependencies": { 31 | "coveralls": "^3.0.3", 32 | "eslint": "^5.15.1", 33 | "eslint-config-prettier": "^4.1.0", 34 | "eslint-plugin-prettier": "^3.0.1", 35 | "husky": "^1.3.1", 36 | "lint-staged": ">=8", 37 | "mocha": "^6.0.2", 38 | "nyc": "^14.0.0", 39 | "prettier": "^1.17.0" 40 | }, 41 | "husky": { 42 | "hooks": { 43 | "pre-commit": "lint-staged" 44 | } 45 | }, 46 | "lint-staged": { 47 | "*.js": [ 48 | "eslint --fix", 49 | "git add" 50 | ] 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test/headerindexer-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('bsert') 4 | const { Chain, protocol, Miner, Headers, ChainEntry, blockstore } = require('bcoin') 5 | 6 | const { sleep, setCustomCheckpoint } = require('./util/common') 7 | const HeaderIndexer = require('../lib/headerindexer') 8 | 9 | const { Network } = protocol 10 | const network = Network.get('regtest') 11 | 12 | const blocks = new blockstore.LevelBlockStore({ 13 | memory: true, 14 | network 15 | }) 16 | 17 | const chain = new Chain({ 18 | memory: true, 19 | blocks, 20 | network 21 | }) 22 | 23 | const miner = new Miner({ 24 | chain, 25 | version: 4 26 | }) 27 | 28 | const cpu = miner.cpu 29 | miner.addresses.length = 0 30 | miner.addAddress('muhtvdmsnbQEPFuEmxcChX58fGvXaaUoVt') 31 | 32 | async function mineBlocks(count) { 33 | assert(chain.opened, 'chain not open') 34 | assert(miner.opened, 'miner not open') 35 | 36 | for (let i = 0; i < count; i++) { 37 | const block = await cpu.mineBlock() 38 | assert(block) 39 | assert(await chain.add(block)) 40 | } 41 | } 42 | 43 | describe('HeaderIndexer', () => { 44 | let indexer, options, count 45 | 46 | before(async () => { 47 | options = { memory: true, chain, blocks } 48 | indexer = new HeaderIndexer(options) 49 | count = 10 50 | 51 | await blocks.open() 52 | await chain.open() 53 | await miner.open() 54 | await indexer.open() 55 | // need to let the indexer get setup 56 | // otherwise close happens too early 57 | await sleep(500) 58 | 59 | // mine some blocks 60 | await mineBlocks(count) 61 | }) 62 | 63 | after(async () => { 64 | if (indexer.opened) await indexer.close() 65 | await chain.close() 66 | await miner.close() 67 | }) 68 | 69 | afterEach(() => { 70 | // in case something failed, reset lastCheckpoint to 0 71 | if (indexer.network.lastCheckpoint) setCustomCheckpoint(indexer) 72 | }) 73 | 74 | it('should create a new HeaderIndexer', async () => { 75 | assert(indexer) 76 | }) 77 | 78 | it('should index headers for 10 blocks by height', async () => { 79 | let prevBlock 80 | 81 | for (let i = 0; i < count; i++) { 82 | if (i !== 0) { 83 | let header = await indexer.getHeader(i) 84 | header = Headers.fromRaw(header.toRaw()) 85 | if (prevBlock) assert.equal(prevBlock, header.prevBlock.toString('hex')) 86 | prevBlock = header.hash().toString('hex') 87 | } 88 | } 89 | }) 90 | 91 | it('should be able to set a custom checkpoint', async () => { 92 | // first check that we're starting from a fresh 93 | assert(!indexer.network.lastCheckpoint, 'lastCheckpoint should be zero when using regtest') 94 | const checkpoint = await chain.getEntryByHeight(5) 95 | assert(checkpoint) 96 | 97 | setCustomCheckpoint(indexer, checkpoint.height, checkpoint.hash) 98 | 99 | assert.equal(indexer.network.lastCheckpoint, checkpoint.height, `Indexer's network's lastCheckpoint didn't match`) 100 | assert.equal(indexer.network.checkpointMap[checkpoint.height], checkpoint.hash, `Indexer's network's didn't match`) 101 | 102 | // reset checkpoints 103 | setCustomCheckpoint(indexer) 104 | assert(!network.lastCheckpoint, 'lastCheckpoint should clear when no args are passed to setCustomCheckpoint') 105 | assert( 106 | !Object.keys(network.checkpointMap).length, 107 | 'checkpointMap should clear when no args are passed to setCustomCheckpoint' 108 | ) 109 | }) 110 | 111 | describe('getHeader and getEnry', () => { 112 | let lastCheckpoint, historicalPoint, nonHistoricalPoint 113 | beforeEach(async () => { 114 | lastCheckpoint = 5 115 | indexer.network.pow.retargetInterval = Math.floor(lastCheckpoint / 2) 116 | setCustomCheckpoint(indexer, lastCheckpoint) 117 | historicalPoint = await indexer.getHistoricalPoint() 118 | nonHistoricalPoint = historicalPoint + 1 119 | }) 120 | 121 | afterEach(async () => { 122 | indexer.network.pow.retargetInterval = 2016 123 | }) 124 | 125 | it('getHeader should return a header for historical entries', async () => { 126 | let header = await indexer.getHeader(historicalPoint) 127 | assert(Headers.isHeaders(header), 'Expected to get a header for a historical entry') 128 | }) 129 | 130 | it('getHeader should return entries for non-historical entries', async () => { 131 | let entry = await indexer.getHeader(nonHistoricalPoint) 132 | assert(ChainEntry.isChainEntry(entry), `Expected to get a chain entry for height ${nonHistoricalPoint}`) 133 | }) 134 | 135 | it('getEntry should return the same as getHeader for non-historical entries', async () => { 136 | let header = await indexer.getHeader(nonHistoricalPoint) 137 | let entry = await indexer.getEntry(nonHistoricalPoint) 138 | assert.equal( 139 | entry.rhash(), 140 | header.rhash(), 141 | `getEntry to return same entry for non-historical height ${nonHistoricalPoint}` 142 | ) 143 | }) 144 | 145 | it('getEntry should always return a ChainEntry object', async () => { 146 | let entry = await indexer.getEntry(nonHistoricalPoint) 147 | let historicalEntry = await indexer.getEntry(historicalPoint - 1) 148 | assert(ChainEntry.isChainEntry(entry), `Expected to get a chain entry for height ${nonHistoricalPoint}`) 149 | assert( 150 | ChainEntry.isChainEntry(historicalEntry), 151 | `Expected to get a chain entry for height ${historicalPoint - 1}` 152 | ) 153 | }) 154 | }) 155 | 156 | describe('startBlock', () => { 157 | let startHeight, prevEntry, startEntry, checkpointHeight, newIndexer 158 | beforeEach(async () => { 159 | startHeight = 10 160 | checkpointHeight = indexer.network.pow.retargetInterval * 2.5 161 | prevEntry = await chain.getEntryByHeight(startHeight - 1) 162 | startEntry = await chain.getEntryByHeight(startHeight) 163 | }) 164 | afterEach(async () => { 165 | setCustomCheckpoint(indexer) 166 | if (newIndexer && newIndexer.db.loaded) { 167 | await newIndexer.db.close() 168 | newIndexer = null 169 | } 170 | }) 171 | 172 | it('should throw if a startBlock is between last retarget height and last checkpoint', async () => { 173 | const { 174 | pow: { retargetInterval } 175 | } = indexer.network 176 | 177 | // this is a change that will effect all other tests since they share the same instance bcoin 178 | // setting this somewhat arbitrarily since this is just testing the initialization of the chain 179 | // would not sync correctly since the block at this height doesn't exist 180 | setCustomCheckpoint(indexer, checkpointHeight) 181 | 182 | const maxStart = checkpointHeight - (checkpointHeight % retargetInterval) 183 | 184 | // need to make copies so it doesn't affect rest of tests 185 | const prevEntryCopy = ChainEntry.fromJSON(prevEntry.toJSON()) 186 | const startEntryCopy = ChainEntry.fromJSON(startEntry.toJSON()) 187 | 188 | // doesn't matter that these entries aren't valid, the only test that should be run on initialization is 189 | // the serialization and the height. The height should be after last retarget and before lastCheckpoint 190 | prevEntryCopy.height = maxStart + 1 191 | startEntryCopy.height = maxStart + 2 192 | const newOptions = { ...options, startBlock: [prevEntryCopy.toRaw(), startEntryCopy.toRaw()] } 193 | 194 | let failed = false 195 | let message 196 | try { 197 | let newIndexer = new HeaderIndexer(newOptions) 198 | await newIndexer.open() 199 | } catch (e) { 200 | failed = true 201 | message = e.message 202 | } 203 | 204 | assert(failed, 'Expected HeaderIndexer open to fail') 205 | assert( 206 | message.includes('retarget') && message.includes(maxStart.toString()), 207 | `Expected failure message to mention retarget interval and suggest a new height. Instead it was: ${message}` 208 | ) 209 | }) 210 | 211 | it('should throw for a startHeight that is between last retarget and lastCheckpoint to the retarget block when opened', async () => { 212 | // this is a change that will effect all other tests since they share the same instance bcoin 213 | // setting this somewhat arbitrarily since this is just testing the initialization of the chain 214 | // would not sync correctly since the block at this height doesn't exist 215 | setCustomCheckpoint(indexer, checkpointHeight) 216 | 217 | const { 218 | pow: { retargetInterval } 219 | } = indexer.network 220 | 221 | const newOptions = { ...options, startHeight: retargetInterval * 2.25, chain } 222 | let fastIndexer = new HeaderIndexer(newOptions) 223 | const { lastCheckpoint } = fastIndexer.network 224 | 225 | // confirm that our various bootstrapping checkpoints are placed correctly 226 | assert( 227 | lastCheckpoint - newOptions.startHeight < retargetInterval && 228 | lastCheckpoint - (lastCheckpoint % retargetInterval), 229 | 'Problem setting up the test. Expected start height to before the last checkpoint but after a retarget' 230 | ) 231 | 232 | const maxStart = lastCheckpoint - (lastCheckpoint % retargetInterval) 233 | 234 | let failed = false 235 | let message 236 | try { 237 | await fastIndexer.open() 238 | } catch (e) { 239 | message = e.message 240 | failed = true 241 | } 242 | 243 | assert( 244 | failed, 245 | `indexer should have failed to open with a start height ${newOptions.startHeight} that was after ${maxStart}` 246 | ) 247 | assert( 248 | message.includes('retarget') && message.includes(maxStart.toString()), 249 | `Expected failure message to mention retarget interval and suggest a new height. Instead it was: ${message}` 250 | ) 251 | }) 252 | 253 | it('should properly vaidate startHeights', () => { 254 | // this is a change that will effect all other tests since they share the same instance bcoin 255 | // setting this somewhat arbitrarily since this is just testing the initialization of the chain 256 | // would not sync correctly since the block at this height doesn't exist 257 | setCustomCheckpoint(indexer, checkpointHeight) 258 | const { 259 | lastCheckpoint, 260 | pow: { retargetInterval } 261 | } = indexer.network 262 | const maxStart = lastCheckpoint - (lastCheckpoint % retargetInterval) 263 | assert.equal(maxStart, indexer.getHistoricalPoint(), `getHistoricalPoint should return max start value`) 264 | 265 | let failed = false 266 | let message 267 | 268 | try { 269 | indexer.validateStartHeight(new Buffer.from()) 270 | } catch (e) { 271 | failed = true 272 | } 273 | 274 | assert(failed, 'Expected validation to fail when not passed a number') 275 | 276 | failed = false 277 | 278 | try { 279 | indexer.validateStartHeight(maxStart + 1) 280 | } catch (e) { 281 | failed = true 282 | message = e.message 283 | } 284 | 285 | assert(failed, 'Expected validation to fail when passed a non-historical block') 286 | assert( 287 | message.includes(lastCheckpoint.toString()) && 288 | message.includes('retarget') && 289 | message.includes(maxStart.toString()), 290 | 'Should have failed with correct message' 291 | ) 292 | }) 293 | 294 | it('should be able to set and get a startBlock', async () => { 295 | // setting a custom checkpoint so we can set the startBlock without throwing an error 296 | // hash (third arg) can be arbitrary for the purposes of this test 297 | // since a change on one indexer affects all instances that share the same bcoin, module 298 | // this will serve for creating a new test indexer later in the test 299 | setCustomCheckpoint(indexer, checkpointHeight, startEntry.hash) 300 | 301 | const newOptions = { ...options, startBlock: [prevEntry.toRaw(), startEntry.toRaw()], logLevel: 'error' } 302 | const newIndexer = new HeaderIndexer(newOptions) 303 | await newIndexer.setStartBlock() 304 | const [actualPrev, actualStart] = newIndexer.startBlock 305 | 306 | assert.equal(ChainEntry.fromRaw(actualPrev).rhash(), prevEntry.rhash(), "prevEntries for tip didn't match") 307 | assert.equal(ChainEntry.fromRaw(actualStart).rhash(), startEntry.rhash(), "startEntries for tip didn't match") 308 | await newIndexer.open() 309 | const [dbPrev, dbStart] = await newIndexer.getStartBlock() 310 | assert.equal(ChainEntry.fromRaw(dbPrev).rhash(), prevEntry.rhash(), "prevEntries for tip from db didn't match") 311 | assert.equal(ChainEntry.fromRaw(dbStart).rhash(), startEntry.rhash(), "startEntries for tip from db didn't match") 312 | }) 313 | 314 | it('should return null for getStartBlock when none is set', async () => { 315 | newIndexer = new HeaderIndexer(options) 316 | await newIndexer.db.open() 317 | const startBlock = await newIndexer.getStartBlock() 318 | assert.equal(startBlock, null, 'Expected startBlock to be null when none was passed') 319 | }) 320 | }) 321 | 322 | describe('getLocator', () => { 323 | it('should get an array of hashes from header chain tip back to genesis', async () => { 324 | const locator = await indexer.getLocator() 325 | const genesis = await chain.network.genesis 326 | const tip = chain.tip 327 | 328 | assert.equal( 329 | locator[0].toString('hex'), 330 | tip.hash.toString('hex'), 331 | 'Expected first locator hash to equal tip hash' 332 | ) 333 | assert.equal( 334 | locator[locator.length - 1].toString('hex'), 335 | genesis.hash.toString('hex'), 336 | 'Expected last locator hash to equal genesis hash' 337 | ) 338 | }) 339 | 340 | it('should not retrieve or return hashes for blocks older than a custom startHeight', async () => { 341 | // indexer hasn't been initialized with a custom startHeight yet 342 | // so we'll add one here and remove it at the end so it doesn't interfere with other tests 343 | indexer.startHeight = count 344 | 345 | await mineBlocks(10) 346 | 347 | const locator = await indexer.getLocator() 348 | const expected = await chain.getEntryByHeight(indexer.startHeight) 349 | 350 | assert.equal( 351 | locator[locator.length - 1].toString('hex'), 352 | expected.hash.toString('hex'), 353 | 'Last item in locator should be hash of entry at startHeight' 354 | ) 355 | 356 | // reset startHeight 357 | indexer.startHeight = null 358 | }) 359 | }) 360 | }) 361 | -------------------------------------------------------------------------------- /test/headernode-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const assert = require('bsert') 3 | 4 | const { Network, ChainEntry, networks, Headers } = require('bcoin') 5 | const { NodeClient } = require('bclient') 6 | 7 | const HeaderNode = require('../lib/headernode') 8 | const { rimraf, sleep, setCustomCheckpoint } = require('./util/common') 9 | const { revHex, fromRev } = require('../lib/util') 10 | const { 11 | initFullNode, 12 | initNodeClient, 13 | initWalletClient, 14 | initWallet, 15 | generateInitialBlocks, 16 | generateBlocks 17 | // generateReorg, 18 | } = require('./util/regtest') 19 | 20 | const network = Network.get('regtest') 21 | 22 | const testPrefix = '/tmp/bcoin-fullnode' 23 | const headerTestPrefix = '/tmp/bcoin-headernode' 24 | const genesisTime = 1534965859 25 | 26 | const ports = { 27 | full: { 28 | p2p: 49331, 29 | node: 49332, 30 | wallet: 49333 31 | }, 32 | header: { 33 | p2p: 49431, 34 | node: 49432 35 | } 36 | } 37 | 38 | describe('HeaderNode', function() { 39 | this.timeout(30000) 40 | let lastCheckpoint, 41 | retargetInterval = null 42 | let node = null 43 | let headerNode = null 44 | let fastNode = null 45 | let wallet = null 46 | let nclient, 47 | wclient = null 48 | let coinbase, headerNodeOptions 49 | 50 | before(async () => { 51 | await rimraf(testPrefix) 52 | await rimraf(headerTestPrefix) 53 | 54 | node = await initFullNode({ 55 | ports, 56 | prefix: testPrefix, 57 | logLevel: 'error' 58 | }) 59 | 60 | nclient = await initNodeClient({ ports: ports.full }) 61 | wclient = await initWalletClient({ ports: ports.full }) 62 | wallet = await initWallet(wclient) 63 | 64 | await wclient.execute('selectwallet', ['test']) 65 | coinbase = await wclient.execute('getnewaddress', ['blue']) 66 | 67 | // lastCheckpoint and retargetInterval need to be set smaller for testing the behavior of 68 | // of checkpoints and custom startHeights so that the tests don't have to mine an unreasonable 69 | // number of blocks to test effectively 70 | // NOTE: since the functionality to start at a later height and changing retarget interval involves 71 | // mutating the `networks` module's lastCheckpoint this will impact all other nodes involved in tests 72 | // since they all share the same bcoin instance 73 | retargetInterval = 25 74 | lastCheckpoint = Math.floor(retargetInterval * 2.5) 75 | node.network.pow.retargetInterval = retargetInterval 76 | 77 | await generateInitialBlocks({ 78 | nclient, 79 | wclient, 80 | coinbase, 81 | genesisTime 82 | }) 83 | 84 | const checkpoint = await nclient.execute('getblockbyheight', [lastCheckpoint]) 85 | setCustomCheckpoint(node, lastCheckpoint, fromRev(checkpoint.hash)) 86 | 87 | headerNodeOptions = { 88 | prefix: headerTestPrefix, 89 | network: network.type, 90 | port: ports.header.p2p, 91 | httpPort: ports.header.node, 92 | apiKey: 'iamsatoshi', 93 | logLevel: 'error', 94 | nodes: [`127.0.0.1:${ports.full.p2p}`], 95 | memory: false, 96 | workers: true, 97 | listen: true 98 | } 99 | 100 | headerNode = new HeaderNode(headerNodeOptions) 101 | 102 | await headerNode.ensure() 103 | await headerNode.open() 104 | await headerNode.connect() 105 | await headerNode.startSync() 106 | 107 | await sleep(500) 108 | }) 109 | 110 | after(async () => { 111 | // reset the retargetInterval 112 | networks.regtest.pow.retargetInterval = 2016 113 | // clear checkpoint information on bcoin module 114 | if (node.network.lastCheckpoint) setCustomCheckpoint(node) 115 | 116 | await wallet.close() 117 | await wclient.close() 118 | await nclient.close() 119 | await node.close() 120 | await headerNode.close() 121 | await rimraf(testPrefix) 122 | await rimraf(headerTestPrefix) 123 | 124 | if (fastNode && fastNode.opened) await fastNode.close() 125 | }) 126 | 127 | it('should create a new HeaderNode', async () => { 128 | assert(headerNode) 129 | }) 130 | 131 | it('should sync a chain of block headers from peers', async () => { 132 | for (let i = 0; i < 10; i++) { 133 | // first block doesn't have valid headers 134 | if (i === 0) continue 135 | 136 | const entry = await node.chain.getEntry(i) 137 | const header = await headerNode.getHeader(i) 138 | 139 | if (!header) throw new Error(`No header in the index for block ${i}`) 140 | 141 | assert.equal(header.rhash(), entry.rhash()) 142 | } 143 | }) 144 | 145 | it('should index new block headers when new blocks are mined on the network', async () => { 146 | const count = 10 147 | 148 | // mine some blocks while header node is offline 149 | await generateBlocks(count, nclient, coinbase) 150 | await sleep(250) 151 | 152 | const tip = await nclient.execute('getblockcount') 153 | 154 | const headerTip = await headerNode.getTip() 155 | const header = await headerNode.getHeader(headerTip.height) 156 | 157 | assert.equal(headerTip.height, tip, 'Expected chain tip and header tip to be the same') 158 | assert(header, 'Expected to get a header for the latest tip') 159 | }) 160 | 161 | it('should start syncing from last tip when restarted', async () => { 162 | let headerTip 163 | const count = 10 164 | await headerNode.disconnect() 165 | 166 | // mine some blocks while header node is offline 167 | await generateBlocks(count, nclient, coinbase) 168 | await sleep(250) 169 | 170 | let tip = await nclient.execute('getblockcount') 171 | headerTip = await headerNode.getTip() 172 | 173 | assert.equal(headerTip.height, tip - count, 'Headers tip before sync should be same as before blocks were mined') 174 | 175 | // reset the chain in case in-memory chain not picked up by GC 176 | await resetChain(headerNode, 0, false) 177 | 178 | headerTip = await headerNode.getTip() 179 | const header = await headerNode.getHeader(headerTip.height) 180 | 181 | assert.equal(headerTip.height, tip, 'Expected chain tip and header tip to be the same') 182 | assert(header, 'Expected to get a header for the latest tip after restart') 183 | 184 | // now check subscriptions are still working for new blocks 185 | await generateBlocks(count, nclient, coinbase) 186 | await sleep(250) 187 | tip = await nclient.execute('getblockcount') 188 | 189 | headerTip = await headerNode.getTip() 190 | assert.equal(headerTip.height, tip, 'Expected chain tip and header tip to be the same after new blocks mined') 191 | 192 | assert(header, 'Expected to get a header for the latest tip after blocks mined') 193 | }) 194 | 195 | it('should support checkpoints', async () => { 196 | // header index needs to maintain chain entries for all non-historical blocks 197 | // this test will confirm that only the non-historical blocks were 198 | // restored on the chain, i.e. blocks newer than lastCheckpoint 199 | // in addition to a set of historical block entries between the last retarget height 200 | // and the lastCheckpoint 201 | const count = 10 202 | const historicalHeight = lastCheckpoint - (lastCheckpoint % retargetInterval) 203 | await headerNode.disconnect() 204 | 205 | // mine some blocks while header node is offline 206 | await generateBlocks(count, nclient, coinbase) 207 | await sleep(500) 208 | 209 | // resetting chain db to clear from memory 210 | await resetChain(headerNode, historicalHeight) 211 | const testHeight = historicalHeight - retargetInterval 212 | const historicalHeader = await headerNode.getHeader(testHeight) 213 | 214 | assert(Headers.isHeaders(historicalHeader), `Expected header for height ${testHeight} to be returned as a header`) 215 | assert( 216 | !ChainEntry.isChainEntry(historicalHeader), 217 | `Expected header for height ${testHeight} to not be a valid chain entry` 218 | ) 219 | 220 | let entry = await headerNode.getHeader(lastCheckpoint + count - 1) 221 | assert(ChainEntry.isChainEntry(entry), 'Expected there to be a chain entry for non-historical heights') 222 | }) 223 | 224 | it('should support custom starting header where startHeight is less than lastCheckpoint and at least 1 retarget', async () => { 225 | // in order to test that pow checks will work, we need to mine past a retarget interval 226 | // to test that the start point is adjusted accordingly. If we don't have at least one retarget 227 | // block then it will adjust back to genesis 228 | // await generateBlocks(retargetInterval, nclient, coinbase) 229 | const chainHeight = await nclient.execute('getblockcount') 230 | 231 | // set a custom lastCheckpoint for testing since regtest has none 232 | let checkpointEntry = await node.chain.getEntryByHeight(lastCheckpoint) 233 | assert(checkpointEntry, 'Problem finding checkpoint block') 234 | assert( 235 | checkpointEntry.height < chainHeight && checkpointEntry.height - retargetInterval > 0, 236 | 'Problem setting up the test. Checkpoint height should be less than the chain tip and after at least 1 retarget' 237 | ) 238 | 239 | // starting block must less than lastCheckpoint and less than or equal to a retargeting interval 240 | // this sets the starting height to the last retargeting interval before the lastCheckpoint 241 | const startHeight = checkpointEntry.height - (checkpointEntry.height % retargetInterval) - 1 242 | const startBlock = [] 243 | let entry = await node.chain.getEntryByHeight(startHeight) 244 | startBlock.push(entry.toRaw('hex')) 245 | entry = await node.chain.getEntryByHeight(startHeight + 1) 246 | startBlock.push(entry.toRaw('hex')) 247 | 248 | const options = { 249 | ...headerNodeOptions, 250 | port: ports.header.p2p + 10, 251 | httpPort: ports.header.node + 10, 252 | startBlock: startBlock, 253 | memory: true 254 | } 255 | 256 | fastNode = new HeaderNode(options) 257 | 258 | // startup and sync our fastNode with custom start height 259 | await fastNode.ensure() 260 | await fastNode.open() 261 | await fastNode.connect() 262 | await fastNode.startSync() 263 | await sleep(500) 264 | 265 | const beforeStartHeight = await fastNode.getHeader(startHeight - 1) 266 | const afterStartHeight = await fastNode.getHeader(startHeight + 5) 267 | 268 | assert(!beforeStartHeight, 'Did not expect to see an earlier block than the start height') 269 | assert(afterStartHeight, 'Expected to be able to retrieve a header later than start point') 270 | 271 | // let's just test that it can reconnect 272 | // after losing its in-memory chain 273 | const startBlockHeight = ChainEntry.fromRaw(startBlock[1]).height 274 | await fastNode.disconnect() 275 | await resetChain(fastNode, startBlockHeight + 1) 276 | 277 | const tip = await nclient.execute('getblockcount') 278 | const fastTip = await fastNode.getTip() 279 | 280 | assert.equal(fastTip.height, tip, 'expected tips to be in sync after "restart"') 281 | setCustomCheckpoint(fastNode) 282 | }) 283 | 284 | xit('should handle a reorg', () => {}) 285 | 286 | describe('HTTP/RPC', () => { 287 | let client 288 | beforeEach(async () => { 289 | client = new NodeClient({ 290 | port: ports.header.node, 291 | apiKey: headerNodeOptions.apiKey 292 | }) 293 | await client.open() 294 | }) 295 | 296 | afterEach(async () => { 297 | headerNode.headerindex.startHeight = 0 298 | await client.close() 299 | }) 300 | 301 | it('should be able to return info about the node', async () => { 302 | // just want to set it to confirm that it is returned in info 303 | headerNode.headerindex.startHeight = 10 304 | const info = await client.getInfo() 305 | const rpcInfo = await client.execute('getinfo') 306 | const chain = headerNode.chain 307 | assert.equal(info.chain.height, chain.height, 'Expected to get back chain height from info endpoint') 308 | assert(rpcInfo) 309 | assert.equal( 310 | rpcInfo.startheight, 311 | headerNode.headerindex.startHeight, 312 | 'Expected to get back start height from rpc info endpoint' 313 | ) 314 | }) 315 | 316 | it('should support getting block headers with rpc and http endpoints', async () => { 317 | const height = Math.floor(headerNode.chain.height / 2) 318 | const header = await headerNode.getHeader(height) 319 | 320 | // http 321 | const httpBlockHeader = await client.getBlock(height) 322 | const httpHeader = await client.get(`/header/${height}`) 323 | 324 | // note that these will be the same (block and header) 325 | // but we want to maintain support for the block endpoint 326 | assert(httpBlockHeader, 'Could not get block with http') 327 | assert(httpHeader, 'Could not get header by height with http') 328 | 329 | assert.equal( 330 | httpBlockHeader.merkleRoot, 331 | revHex(header.merkleRoot), 332 | 'Expected merkle root returned by server to match with one from header node' 333 | ) 334 | 335 | // rpc 336 | const rpcHeader = await client.execute('getblockheader', [header.rhash()]) 337 | const rpcHeaderByHeight = await client.execute('getheaderbyheight', [height]) 338 | 339 | assert.equal( 340 | rpcHeader.merkleroot, 341 | revHex(header.merkleRoot), 342 | 'Expected merkle root returned by server to match with one from header node' 343 | ) 344 | assert(rpcHeaderByHeight, 'Could not get block by height with rpc') 345 | assert.equal( 346 | rpcHeaderByHeight.merkleroot, 347 | revHex(header.merkleRoot), 348 | 'Expected merkle root returned by server to match with one from header node' 349 | ) 350 | }) 351 | 352 | it('should support socket subscriptions to new block events', async () => { 353 | let tip = await client.getTip() 354 | assert(tip) 355 | let entry 356 | client.bind('chain connect', raw => { 357 | entry = ChainEntry.fromRaw(raw) 358 | }) 359 | 360 | await generateBlocks(1, nclient, coinbase) 361 | await sleep(500) 362 | 363 | tip = await client.getTip() 364 | assert(entry, 'Did not get an entry from a chain connect event after mining a block') 365 | 366 | assert.equal(revHex(entry.hash), revHex(ChainEntry.fromRaw(tip).hash)) 367 | }) 368 | 369 | it('should be able to get a start height', async () => { 370 | const startHeight = 10 371 | headerNode.headerindex.startHeight = startHeight 372 | const header = await headerNode.headerindex.getEntry(startHeight) 373 | const next = await headerNode.headerindex.getEntry(startHeight + 1) 374 | const httpStartHeader = await client.get('/start') 375 | 376 | assert(httpStartHeader, 'Could not get header by height with http') 377 | assert.equal( 378 | httpStartHeader.merkleRoot, 379 | revHex(header.merkleRoot), 380 | 'Expected merkle root returned by server to match with one from header node' 381 | ) 382 | assert.equal( 383 | httpStartHeader.height, 384 | startHeight, 385 | `RPC came back with a header not at the start height ${startHeight}` 386 | ) 387 | 388 | // rpc 389 | const rpcStartHeader = await client.execute('getstartheader') 390 | assert(rpcStartHeader, 'Could not get block by height with rpc') 391 | assert.equal( 392 | rpcStartHeader.height, 393 | startHeight, 394 | `RPC came back with a header not at the start height ${startHeight}` 395 | ) 396 | assert.equal( 397 | rpcStartHeader.merkleroot, 398 | revHex(header.merkleRoot), 399 | 'Expected merkle root returned by rpc server to match with one from header node' 400 | ) 401 | assert.equal(revHex(next.hash), rpcStartHeader.nextblockhash, `next block hash did not match for start header`) 402 | }) 403 | }) 404 | }) 405 | 406 | /* 407 | * Helpers 408 | */ 409 | 410 | async function resetChain(node, start = 0, replay = true) { 411 | // reset chain to custom start 412 | // can't always reset to 0 because `chaindb.reset` 413 | // won't work when there is a custom start point 414 | // because chain "rewind" won't work 415 | if (replay) await node.chain.replay(start) 416 | await node.close() 417 | await node.open() 418 | await node.connect() 419 | await node.startSync() 420 | 421 | // let indexer catch up 422 | await sleep(500) 423 | } 424 | -------------------------------------------------------------------------------- /test/util-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('bsert') 4 | const { ChainEntry } = require('bcoin') 5 | const utils = require('../lib/util') 6 | 7 | describe('utils', () => { 8 | describe('getRemoteBlockEntries', () => { 9 | let heights, blockMetaTest, blockMetaMain, expectedMain, expectedTest 10 | 11 | beforeEach(() => { 12 | heights = [294322, 294323] 13 | blockMetaMain = [ 14 | { 15 | hash: utils.fromRev('0000000000000000189bba3564a63772107b5673c940c16f12662b3e8546b412'), 16 | version: 2, 17 | prevBlock: utils.fromRev('0000000000000000ced0958bd27720b71d32c5847e40660aaca39f33c298abb0'), 18 | merkleRoot: utils.fromRev('359d624d37aee1efa5662b7f5dbc390e996d561afc8148e8d716cf6ad765a952'), 19 | time: 1396684158, 20 | bits: 419486617, 21 | nonce: 1225187768, 22 | height: 294322 23 | }, 24 | { 25 | hash: utils.fromRev('00000000000000003883bd7de39066462154e28c6dbf5ecb90d356b7d8910ddc'), 26 | version: 2, 27 | prevBlock: utils.fromRev('0000000000000000189bba3564a63772107b5673c940c16f12662b3e8546b412'), 28 | merkleRoot: utils.fromRev('7bec36b9ee4c8114755f21188ebc7fad7da6144e147918a6326c79bb7a2015a6'), 29 | time: 1396685483, 30 | bits: 419486617, 31 | nonce: 1301373755, 32 | height: 294323 33 | } 34 | ] 35 | blockMetaTest = [ 36 | { 37 | hash: utils.fromRev('00000000001be2d75acc520630a117874316c07fd7a724afae1a5d99038f4f4a'), 38 | version: 2, 39 | prevBlock: utils.fromRev('000000000024f2b5690d852116dce43768c9c38922e94a5d7e848f7c2514e517'), 40 | merkleRoot: utils.fromRev('9c66b31403a26d737a7408d00d242fc99761d1c2cc9f2f3f205c79804f22848f'), 41 | time: 1412364679, 42 | bits: 457179072, 43 | nonce: 3733494575, 44 | height: 294322 45 | }, 46 | { 47 | hash: utils.fromRev('00000000002143762d6db1abc355661005947947eb6117ce8bd1e03b2904a2d0'), 48 | version: 2, 49 | prevBlock: utils.fromRev('00000000001be2d75acc520630a117874316c07fd7a724afae1a5d99038f4f4a'), 50 | merkleRoot: utils.fromRev('e3cba7ae7244085266c9f8d3c8221ae3d3e775d58700eb3c51d56c9bb0a23303'), 51 | time: 1412364681, 52 | bits: 457179072, 53 | nonce: 3262081148, 54 | height: 294323 55 | } 56 | ] 57 | 58 | expectedMain = blockMetaMain.map(meta => ChainEntry.fromOptions(meta).toRaw()) 59 | expectedTest = blockMetaTest.map(meta => ChainEntry.fromOptions(meta).toRaw()) 60 | }) 61 | 62 | it('should return an array of block entries for each height requested', async () => { 63 | try { 64 | let actualMain = await utils.getRemoteBlockEntries('main', ...heights) 65 | 66 | assert(actualMain.length === heights.length) 67 | actualMain.forEach((raw, index) => assert(raw.toString('hex') === expectedMain[index].toString('hex'))) 68 | } catch (e) { 69 | if (e.message.includes('Bad response')) 70 | console.log('The current IP has reached the blockcypher api limit. Cannot complete test.') 71 | else throw e 72 | } 73 | }) 74 | 75 | it('should support mainnet and testnet block requests', async () => { 76 | try { 77 | let actualTest = await utils.getRemoteBlockEntries('testnet', ...heights) 78 | 79 | assert(actualTest.length === heights.length) 80 | actualTest.forEach((raw, index) => { 81 | assert(raw.toString('hex') === expectedTest[index].toString('hex')) 82 | }) 83 | } catch (e) { 84 | if (e.message.includes('Bad response')) 85 | console.log('The current IP has reached the blockcypher api limit. Cannot complete test.') 86 | else throw e 87 | } 88 | }) 89 | 90 | it('should throw without passing in a network as first arg', async () => { 91 | let failed = false 92 | try { 93 | await utils.getRemoteBlockEntries(...heights) 94 | } catch (e) { 95 | failed = true 96 | } 97 | 98 | assert(failed, 'expected it to fail when not passing in a network first arg') 99 | 100 | failed = false 101 | 102 | try { 103 | await utils.getRemoteBlockEntries('mainnet', ...heights) 104 | } catch (e) { 105 | failed = true 106 | } 107 | 108 | assert(failed, 'expected it to fail when not passing in "main" or "testnet" as first arg') 109 | }) 110 | }) 111 | }) 112 | -------------------------------------------------------------------------------- /test/util/common.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const fs = require('bfile') 3 | const assert = require('bsert') 4 | 5 | const common = exports 6 | 7 | common.sleep = async function sleep(ms) { 8 | return new Promise(resolve => setTimeout(resolve, ms)) 9 | } 10 | 11 | common.rimraf = async function(p) { 12 | const allowed = new RegExp('^/tmp/(.*)$') 13 | if (!allowed.test(p)) throw new Error(`Path not allowed: ${p}.`) 14 | 15 | return await fs.rimraf(p) 16 | } 17 | 18 | /* 19 | * Sets a custom checkpoint on the network object 20 | * Useful for syncing from a custom block height 21 | * NOTE: This will affect anything that shares the same 22 | * bcoin module, e.g. for tests when running multiple nodes 23 | * @param {Object} checkpoint 24 | * @param {Number} checkpoint.height 25 | * @param {Buffer} checkpoint.hash 26 | */ 27 | common.setCustomCheckpoint = function(obj, height = 0, hash) { 28 | assert(!hash || Buffer.isBuffer(hash), 'Must pass in a buffer for checkpoint hash') 29 | assert(obj.network, 'Object passed to setCustomCheckpoint must have a network object attached') 30 | obj.logger.info('Setting custom lastCheckpoint as %d (checkpoint=%h)', height, hash) 31 | obj.network.lastCheckpoint = height 32 | if (height) { 33 | obj.network.checkpointMap[height] = hash 34 | obj.network.checkpoints.push({ hash, height }) 35 | obj.network.checkpoints.sort((a, b) => a.height - b.height) 36 | } else { 37 | // if lastCheckpoint height is zero then clear checkpoint map 38 | obj.logger.debug('Empty height passed to setCustomCheckpoint') 39 | obj.logger.debug("Clearing %s network's checkpoint map", obj.network.type) 40 | obj.network.checkpointMap = {} 41 | obj.network.checkpoints = [] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/util/regtest.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * regtest.js - regtest utilities for test 3 | * Copyright (c) 2019, the bcoin developers (MIT License). 4 | * https://github.com/bcoin-org/bcoin 5 | */ 6 | 7 | 'use strict' 8 | 9 | const { NodeClient, WalletClient } = require('bclient') 10 | 11 | const assert = require('bsert') 12 | const { sleep } = require('./common') 13 | const { FullNode, SPVNode, Coin, MTX } = require('bcoin') 14 | 15 | async function initFullNode(options) { 16 | const node = new FullNode({ 17 | prefix: options.prefix, 18 | network: 'regtest', 19 | apiKey: 'foo', 20 | walletAuth: true, 21 | workers: true, 22 | listen: true, 23 | bip37: true, 24 | port: options.ports.full.p2p, 25 | httpPort: options.ports.full.node, 26 | memory: false, 27 | plugins: [require('bcoin/lib/wallet/plugin')], 28 | env: { 29 | BCOIN_WALLET_HTTP_PORT: options.ports.full.wallet.toString() 30 | }, 31 | logLevel: options.logLevel 32 | }) 33 | await node.ensure() 34 | await node.open() 35 | await node.connect() 36 | await node.startSync() 37 | return node 38 | } 39 | 40 | async function initSPVNode(options) { 41 | const node = new SPVNode({ 42 | prefix: options.prefix, 43 | network: 'regtest', 44 | apiKey: 'foo', 45 | walletAuth: true, 46 | workers: true, 47 | listen: true, 48 | port: options.ports.spv.p2p, 49 | httpPort: options.ports.spv.node, 50 | maxOutbound: 1, 51 | seeds: [], 52 | nodes: [`127.0.0.1:${options.ports.full.p2p}`], 53 | memory: false, 54 | plugins: [require('bcoin/lib/wallet/plugin')], 55 | env: { 56 | BCOIN_WALLET_HTTP_PORT: options.ports.spv.wallet.toString() 57 | }, 58 | logLevel: options.logLevel 59 | }) 60 | 61 | await node.ensure() 62 | await node.open() 63 | await node.connect() 64 | await node.startSync() 65 | return node 66 | } 67 | 68 | async function initNodeClient(options) { 69 | const nclient = new NodeClient({ 70 | network: 'regtest', 71 | port: options.ports.node, 72 | apiKey: 'foo' 73 | }) 74 | await nclient.open() 75 | return nclient 76 | } 77 | 78 | async function initWalletClient(options) { 79 | const wclient = new WalletClient({ 80 | network: 'regtest', 81 | port: options.ports.wallet, 82 | apiKey: 'foo' 83 | }) 84 | await wclient.open() 85 | return wclient 86 | } 87 | 88 | async function initWallet(wclient) { 89 | const winfo = await wclient.createWallet('test') 90 | assert.strictEqual(winfo.id, 'test') 91 | const wallet = wclient.wallet('test', winfo.token) 92 | await wallet.open() 93 | 94 | // A lookahead is increased to avoid timing issues with bloom 95 | // filters not being loaded in time and transactions not being 96 | // broadcast to spv node wallets. 97 | const info = await wallet.createAccount('blue', { 98 | witness: true, 99 | lookahead: 40 100 | }) 101 | assert(info.initialized) 102 | assert.strictEqual(info.name, 'blue') 103 | assert.strictEqual(info.accountIndex, 1) 104 | assert.strictEqual(info.m, 1) 105 | assert.strictEqual(info.n, 1) 106 | 107 | return wallet 108 | } 109 | 110 | async function generateBlocks(count, nclient, coinbase) { 111 | return await nclient.execute('generatetoaddress', [count, coinbase]) 112 | } 113 | 114 | async function generateRollback(depth, nclient) { 115 | const invalidated = [] 116 | 117 | for (let i = 0; i < depth; i++) { 118 | const hash = await nclient.execute('getbestblockhash') 119 | invalidated.push(hash) 120 | await nclient.execute('invalidateblock', [hash]) 121 | } 122 | 123 | return invalidated 124 | } 125 | 126 | async function generateReorg(depth, nclient, wclient, coinbase) { 127 | const blockInterval = 600 128 | 129 | const invalidated = [] 130 | let lastTime = null 131 | 132 | // Invalidate blocks to the depth. 133 | for (let i = 0; i < depth; i++) { 134 | const hash = await nclient.execute('getbestblockhash') 135 | invalidated.push(hash) 136 | 137 | // Get the time for the block before it's removed. 138 | const lastBlock = await nclient.execute('getblock', [hash]) 139 | lastTime = lastBlock.time 140 | 141 | await nclient.execute('invalidateblock', [hash]) 142 | } 143 | 144 | // Increase time so that blocks do not have 145 | // the same time stamp as before. 146 | lastTime += 10000 147 | 148 | // TODO remove 149 | await sleep(1000) 150 | 151 | // Mature coinbase transactions past depth 152 | await generateBlocks(depth, nclient, coinbase) 153 | 154 | const txids = await wclient.execute('resendwallettransactions') 155 | 156 | const validated = [] 157 | 158 | // Add new blocks back to the same height plus two 159 | // so that it becomes the chain with the most work. 160 | for (let c = 0; c < depth + 2; c++) { 161 | const blocktime = lastTime + c * blockInterval 162 | await nclient.execute('setmocktime', [blocktime]) 163 | 164 | const blockhashes = await generateBlocks(1, nclient, coinbase) 165 | const block = await nclient.execute('getblock', [blockhashes[0]]) 166 | 167 | validated.push(block.hash) 168 | 169 | assert(block.time <= blocktime + 1) 170 | assert(block.time >= blocktime) 171 | } 172 | 173 | return { 174 | invalidated, 175 | validated, 176 | txids 177 | } 178 | } 179 | 180 | async function generateTxs(options) { 181 | const { wclient, spvwclient, count, amount } = options 182 | let addr, 183 | txid = null 184 | 185 | await wclient.execute('selectwallet', ['test']) 186 | 187 | const txids = [] 188 | 189 | for (let i = 0; i < count; i++) { 190 | if (options.gap && !(i % options.gap)) await sleep(options.sleep) 191 | 192 | if (spvwclient) addr = await spvwclient.execute('getnewaddress', ['blue']) 193 | else addr = await wclient.execute('getnewaddress', ['blue']) 194 | 195 | txid = await wclient.execute('sendtoaddress', [addr, amount]) 196 | txids.push(txid) 197 | } 198 | 199 | return txids 200 | } 201 | 202 | async function sendCoinbase(options) { 203 | const { nclient, height, address, coinbaseKey } = options 204 | 205 | const hash = await nclient.execute('getblockhash', [height]) 206 | const block = await nclient.execute('getblock', [hash, true, true]) 207 | 208 | const script = Buffer.from(block.tx[0].vout[0].scriptPubKey.hex, 'hex') 209 | const prevhash = Buffer.from(block.tx[0].txid, 'hex') 210 | prevhash.reverse() 211 | 212 | const mtx = new MTX() 213 | 214 | mtx.addCoin( 215 | Coin.fromOptions({ 216 | value: 5000000000, 217 | script: script, 218 | hash: prevhash, 219 | index: 0 220 | }) 221 | ) 222 | 223 | mtx.addOutput({ 224 | address: address, 225 | value: 4999000000 226 | }) 227 | 228 | mtx.sign(coinbaseKey) 229 | 230 | const tx = mtx.toTX() 231 | 232 | await nclient.execute('sendrawtransaction', [tx.toRaw().toString('hex')]) 233 | } 234 | 235 | async function generateInitialBlocks(options) { 236 | const { nclient, wclient, spvwclient, coinbase, genesisTime } = options 237 | 238 | let { blocks, count } = options 239 | 240 | if (!blocks) blocks = 100 241 | 242 | if (!count) count = 50 243 | 244 | const blockInterval = 600 245 | const timewarp = 3200 246 | 247 | let c = 0 248 | 249 | // Establish baseline block interval for a median time 250 | for (; c < 11; c++) { 251 | const blocktime = genesisTime + c * blockInterval 252 | await nclient.execute('setmocktime', [blocktime]) 253 | 254 | const blockhashes = await generateBlocks(1, nclient, coinbase) 255 | const block = await nclient.execute('getblock', [blockhashes[0]]) 256 | 257 | assert(block.time <= blocktime + 1) 258 | assert(block.time >= blocktime) 259 | } 260 | 261 | async function makeBlock(includeTxs) { 262 | // Time warping blocks that have time previous 263 | // to the previous block 264 | let blocktime = genesisTime + c * blockInterval 265 | if (c % 5) blocktime -= timewarp 266 | await nclient.execute('setmocktime', [blocktime]) 267 | 268 | if (wclient && includeTxs) await generateTxs({ wclient, spvwclient, count, amount: 0.11111111 }) 269 | 270 | const blockhashes = await generateBlocks(1, nclient, coinbase) 271 | const block = await nclient.execute('getblock', [blockhashes[0]]) 272 | 273 | assert(block.time <= blocktime + 1) 274 | assert(block.time >= blocktime) 275 | } 276 | 277 | // Mature coinbase transactions 278 | for (; c < 116; c++) { 279 | await makeBlock(false) 280 | } 281 | 282 | // Wait for wallet to sync with chain 283 | await sleep(500) 284 | 285 | // Create blocks sending transactions 286 | for (; c < blocks; c++) { 287 | await makeBlock(true) 288 | } 289 | } 290 | 291 | module.exports = { 292 | initFullNode, 293 | initSPVNode, 294 | initNodeClient, 295 | initWalletClient, 296 | initWallet, 297 | generateBlocks, 298 | generateInitialBlocks, 299 | generateReorg, 300 | generateRollback, 301 | generateTxs, 302 | sendCoinbase 303 | } 304 | --------------------------------------------------------------------------------