├── .gitignore ├── .npmignore ├── test ├── fixtures │ ├── pubkey.json │ ├── varint.json │ ├── time.json │ ├── version.json │ ├── block_id.json │ ├── validator_hash_input.json │ ├── vote.json │ └── generate.go ├── browser.sh ├── travis.sh ├── utils.js ├── common.js ├── lightNodeIntegration.js ├── attacks.js ├── types.js ├── verify.js └── rpc.js ├── index.js ├── src ├── pubkey.js ├── common.js ├── methods.js ├── varint.js ├── hash.js ├── rpc.js ├── lightNode.js ├── verify.js └── types.js ├── example └── lightNode.js ├── .travis.yml ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nyc_output 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | test 3 | .nyc_output 4 | .travis.yml 5 | example 6 | -------------------------------------------------------------------------------- /test/fixtures/pubkey.json: -------------------------------------------------------------------------------- 1 | { 2 | "value": { 3 | "type": "tendermint/PubKeyEd25519", 4 | "value": "NNJledu0Vmk+VAZyz5IvUt3g1lMuNb8GvgE6fFMvIOA=" 5 | }, 6 | "encoding": "1624de640a2034d26579dbb456693e540672cf922f52dde0d6532e35bf06be013a7c532f20e0" 7 | } -------------------------------------------------------------------------------- /test/browser.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ $BROWSER ]; then 4 | airtap \ 5 | --browser-name $BROWSER \ 6 | --browser-version latest \ 7 | --loopback airtap.local \ 8 | -- test/*.js 9 | else 10 | airtap \ 11 | --local \ 12 | --open \ 13 | -- test/*.js 14 | fi 15 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/lightNode.js') 2 | module.exports.LightNode = require('./lib/lightNode.js') 3 | module.exports.RpcClient = require('./lib/rpc.js') 4 | module.exports.RpcClient.METHODS = require('./lib/methods.js') 5 | Object.assign(module.exports, require('./lib/verify.js')) 6 | -------------------------------------------------------------------------------- /src/pubkey.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { tmhash } = require('./hash.js') 4 | 5 | function getAddress (pubkey) { 6 | let bytes = Buffer.from(pubkey.value, 'base64') 7 | return tmhash(bytes) 8 | .slice(0, 20) 9 | .toString('hex') 10 | .toUpperCase() 11 | } 12 | 13 | module.exports = { getAddress } 14 | -------------------------------------------------------------------------------- /test/fixtures/varint.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "value": 0, 4 | "encoding": "00" 5 | }, 6 | { 7 | "value": 1, 8 | "encoding": "02" 9 | }, 10 | { 11 | "value": 255, 12 | "encoding": "fe03" 13 | }, 14 | { 15 | "value": 256, 16 | "encoding": "8004" 17 | }, 18 | { 19 | "value": 1234, 20 | "encoding": "a413" 21 | }, 22 | { 23 | "value": 100000, 24 | "encoding": "c09a0c" 25 | } 26 | ] -------------------------------------------------------------------------------- /src/common.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | function safeParseInt (nStr) { 4 | let n = parseInt(nStr) 5 | if (!Number.isSafeInteger(n)) { 6 | throw Error(`Value ${JSON.stringify(nStr)} is not an integer in the valid range`) 7 | } 8 | if (String(n) !== String(nStr)) { 9 | throw Error(`Value ${JSON.stringify(nStr)} is not a canonical integer string representation`) 10 | } 11 | return n 12 | } 13 | 14 | module.exports = { safeParseInt } 15 | -------------------------------------------------------------------------------- /test/fixtures/time.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "value": "1973-11-29T21:33:09.123456789Z", 4 | "encoding": "08959aef3a10959aef3a" 5 | }, 6 | { 7 | "value": "1973-11-29T21:33:09Z", 8 | "encoding": "08959aef3a" 9 | }, 10 | { 11 | "value": "1970-01-01T00:00:00.123456789Z", 12 | "encoding": "10959aef3a" 13 | }, 14 | { 15 | "value": "2020-03-24T00:32:07.286559642Z", 16 | "encoding": "0887aae5f305109a9bd28801" 17 | } 18 | ] -------------------------------------------------------------------------------- /test/fixtures/version.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "value": { 4 | "block": 1234, 5 | "app": 5678 6 | }, 7 | "encoding": "08d20910ae2c" 8 | }, 9 | { 10 | "value": { 11 | "block": 1, 12 | "app": 0 13 | }, 14 | "encoding": "0801" 15 | }, 16 | { 17 | "value": { 18 | "block": 0, 19 | "app": 1 20 | }, 21 | "encoding": "1001" 22 | }, 23 | { 24 | "value": { 25 | "block": 0, 26 | "app": 0 27 | }, 28 | "encoding": "" 29 | } 30 | ] -------------------------------------------------------------------------------- /test/travis.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -z $NODE ]; then 4 | export NODE=10 5 | fi 6 | 7 | echo Installing nvm... 8 | git clone https://github.com/creationix/nvm.git /tmp/.nvm 9 | source /tmp/.nvm/nvm.sh 10 | echo Installed nvm version `nvm --version` 11 | nvm install $NODE 12 | nvm use $NODE 13 | nvm alias default $NODE 14 | echo node version: `node --version` 15 | echo npm version: `npm --version` 16 | npm install 17 | echo Install completed 18 | 19 | if [ $BROWSER ]; then 20 | npm run test-browser 21 | else 22 | npm test 23 | fi 24 | -------------------------------------------------------------------------------- /src/methods.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = [ 4 | 'subscribe', 5 | 'unsubscribe', 6 | 'unsubscribe_all', 7 | 8 | 'status', 9 | 'net_info', 10 | 'dial_peers', 11 | 'dial_seeds', 12 | 'blockchain', 13 | 'genesis', 14 | 'health', 15 | 'block', 16 | 'block_results', 17 | 'blockchain', 18 | 'validators', 19 | 'consensus_state', 20 | 'dump_consensus_state', 21 | 'broadcast_tx_commit', 22 | 'broadcast_tx_sync', 23 | 'broadcast_tx_async', 24 | 'unconfirmed_txs', 25 | 'num_unconfirmed_txs', 26 | 'commit', 27 | 'tx', 28 | 'tx_search', 29 | 30 | 'abci_query', 31 | 'abci_info', 32 | 33 | 'unsafe_flush_mempool', 34 | 'unsafe_start_cpu_profiler', 35 | 'unsafe_stop_cpu_profiler', 36 | 'unsafe_write_heap_profile' 37 | ] 38 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | let WebSocketServer = require('ws').Server 2 | 3 | function createWsServer (port = 26657, onRequest) { 4 | let server = new WebSocketServer({ port }) 5 | let connections = [] 6 | server.on('connection', (ws) => { 7 | connections.push(ws) 8 | ws.on('message', (data) => { 9 | let req = JSON.parse(data.toString()) 10 | let send = (error, result, id = req.id) => { 11 | let res = { id, error, result } 12 | ws.send(JSON.stringify(res) + '\n') 13 | } 14 | onRequest(req, send) 15 | }) 16 | }) 17 | let _close = server.close.bind(server) 18 | let close = () => { 19 | connections.forEach((ws) => ws.close()) 20 | _close() 21 | } 22 | server.close = close 23 | return server 24 | } 25 | 26 | module.exports = { createWsServer } 27 | -------------------------------------------------------------------------------- /example/lightNode.js: -------------------------------------------------------------------------------- 1 | let tendermint = require('..') 2 | 3 | async function main ({ argv }) { 4 | let rpcUrl = argv[2] || 'ws://localhost:26657' 5 | 6 | // this example fetches the initial commit/validators dynamically 7 | // for convenience. you should NEVER do this in production, this 8 | // should be hardcoded or manually approved by the user. otherwise, 9 | // a malicious node or MITM can trivially trick you onto their own chain! 10 | let rpc = tendermint.RpcClient(rpcUrl) 11 | let commit = await rpc.commit({ height: 1 }) 12 | let { validators } = await rpc.validators({ height: 1 }) 13 | 14 | let state = { 15 | ...commit.signed_header, 16 | validators 17 | } 18 | 19 | let node = tendermint(rpcUrl, state) 20 | 21 | node.on('error', (err) => console.error(err.stack)) 22 | node.on('synced', () => console.log('synced')) 23 | node.on('update', (update) => console.log('update', update)) 24 | } 25 | 26 | main(process).catch((err) => { 27 | console.error(err.stack) 28 | }) 29 | -------------------------------------------------------------------------------- /test/fixtures/block_id.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "value": { 4 | "hash": "", 5 | "parts": { 6 | "total": 0, 7 | "hash": "" 8 | } 9 | }, 10 | "encoding": "" 11 | }, 12 | { 13 | "value": { 14 | "hash": "3031323334353637383930313233343536373839303132333435363738393031", 15 | "parts": { 16 | "total": 1, 17 | "hash": "3031323334353637383930313233343536373839303132333435363738393031" 18 | } 19 | }, 20 | "encoding": "0a2030313233343536373839303132333435363738393031323334353637383930311224080112203031323334353637383930313233343536373839303132333435363738393031" 21 | }, 22 | { 23 | "value": { 24 | "hash": "3031323334353637383930313233343536373839303132333435363738393031", 25 | "parts": { 26 | "total": 123, 27 | "hash": "3031323334353637383930313233343536373839303132333435363738393031" 28 | } 29 | }, 30 | "encoding": "0a2030313233343536373839303132333435363738393031323334353637383930311224087b12203031323334353637383930313233343536373839303132333435363738393031" 31 | } 32 | ] -------------------------------------------------------------------------------- /src/varint.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { safeParseInt } = require('./common.js') 4 | 5 | function VarInt (signed) { 6 | function decode (buffer, start = 0, end = buffer.length) { 7 | throw Error('not implemented') 8 | } 9 | 10 | function encode (n, buffer = Buffer.alloc(encodingLength(n)), offset = 0) { 11 | n = safeParseInt(n) 12 | 13 | // amino signed varint is multiplied by 2 ¯\_(ツ)_/¯ 14 | if (signed) n *= 2 15 | 16 | let i = 0 17 | while (n >= 0x80) { 18 | buffer[offset + i] = (n & 0xff) | 0x80 19 | n >>= 7 20 | i++ 21 | } 22 | buffer[offset + i] = n & 0xff 23 | encode.bytes = i + 1 24 | return buffer 25 | } 26 | 27 | function encodingLength (n) { 28 | n = safeParseInt(n) 29 | 30 | if (signed) n *= 2 31 | if ((!signed && n < 0) || Math.abs(n) > Number.MAX_SAFE_INTEGER) { 32 | throw Error('varint value is out of bounds') 33 | } 34 | let bits = Math.log2(n + 1) 35 | return Math.ceil(bits / 7) || 1 36 | } 37 | 38 | return { encode, decode, encodingLength } 39 | } 40 | 41 | module.exports = VarInt(true) 42 | module.exports.UVarInt = VarInt(false) 43 | module.exports.VarInt = module.exports 44 | -------------------------------------------------------------------------------- /test/common.js: -------------------------------------------------------------------------------- 1 | let test = require('tape') 2 | let { safeParseInt } = require('../lib/common.js') 3 | 4 | test('safeParseInt', (t) => { 5 | t.equals(safeParseInt('123'), 123) 6 | t.equals(safeParseInt('-123'), -123) 7 | t.equals(safeParseInt(123), 123) 8 | t.equals(safeParseInt(-123), -123) 9 | t.throws(() => safeParseInt(), 10 | 'Value undefined is not an integer') 11 | t.throws(() => safeParseInt(''), 12 | 'Value "" is not an integer') 13 | t.throws(() => safeParseInt([]), 14 | 'Value [] is not an integer') 15 | t.throws(() => safeParseInt({}), 16 | 'Value {} is not an integer') 17 | t.throws(() => safeParseInt(String(Number.MAX_SAFE_INTEGER + 123)), 18 | 'Absolute value must be < 2^53') 19 | t.throws(() => safeParseInt(String(-Number.MAX_SAFE_INTEGER - 123)), 20 | 'Absolute value must be < 2^53') 21 | t.throws(() => safeParseInt(Number.MAX_SAFE_INTEGER + 123), 22 | 'Absolute value must be < 2^53') 23 | t.throws(() => safeParseInt('0x123'), 24 | 'Value "0x123" is not a canonical integer string representation') 25 | t.throws(() => safeParseInt('123.5'), 26 | 'Value "123.5" is not a canonical integer string representation') 27 | t.throws(() => safeParseInt(123.5), 28 | 'Value 123.5 is not a canonical integer string representation') 29 | t.end() 30 | }) 31 | -------------------------------------------------------------------------------- /test/fixtures/validator_hash_input.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "value": { 4 | "pub_key": [ 5 | 52, 6 | 210, 7 | 101, 8 | 121, 9 | 219, 10 | 180, 11 | 86, 12 | 105, 13 | 62, 14 | 84, 15 | 6, 16 | 114, 17 | 207, 18 | 146, 19 | 47, 20 | 82, 21 | 221, 22 | 224, 23 | 214, 24 | 83, 25 | 46, 26 | 53, 27 | 191, 28 | 6, 29 | 190, 30 | 1, 31 | 58, 32 | 124, 33 | 83, 34 | 47, 35 | 32, 36 | 224 37 | ], 38 | "voting_power": 1234 39 | }, 40 | "encoding": "0a251624de642034d26579dbb456693e540672cf922f52dde0d6532e35bf06be013a7c532f20e010d209" 41 | }, 42 | { 43 | "value": { 44 | "pub_key": [ 45 | 52, 46 | 210, 47 | 101, 48 | 121, 49 | 219, 50 | 180, 51 | 86, 52 | 105, 53 | 62, 54 | 84, 55 | 6, 56 | 114, 57 | 207, 58 | 146, 59 | 47, 60 | 82, 61 | 221, 62 | 224, 63 | 214, 64 | 83, 65 | 46, 66 | 53, 67 | 191, 68 | 6, 69 | 190, 70 | 1, 71 | 58, 72 | 124, 73 | 83, 74 | 47, 75 | 32, 76 | 224 77 | ], 78 | "voting_power": 2000000 79 | }, 80 | "encoding": "0a251624de642034d26579dbb456693e540672cf922f52dde0d6532e35bf06be013a7c532f20e01080897a" 81 | } 82 | ] -------------------------------------------------------------------------------- /test/lightNodeIntegration.js: -------------------------------------------------------------------------------- 1 | let test = require('ava') 2 | let tm = require('tendermint-node') 3 | let createTempDir = require('tempy').directory 4 | let getPort = require('get-port') 5 | let { LightNode, RpcClient } = require('..') 6 | 7 | test.beforeEach(async (t) => { 8 | let home = createTempDir() 9 | await tm.init(home) 10 | 11 | let ports = { 12 | p2p: await getPort(), 13 | rpc: await getPort() 14 | } 15 | 16 | let node = tm.node(home, { 17 | p2p: { laddr: `tcp://127.0.0.1:${ports.p2p}` }, 18 | rpc: { laddr: `tcp://127.0.0.1:${ports.rpc}` }, 19 | proxy_app: 'noop' 20 | }) 21 | 22 | t.context.ports = ports 23 | t.context.node = node 24 | }) 25 | 26 | test.afterEach((t) => { 27 | return t.context.node.kill() 28 | }) 29 | 30 | test('simple light node sync', async (t) => { 31 | let { ports, node } = t.context 32 | 33 | await node.synced() 34 | 35 | // get initial state through rpc 36 | let rpcHost = `ws://localhost:${ports.rpc}` 37 | let rpc = RpcClient(rpcHost) 38 | let commit = await rpc.commit({ height: 1 }) 39 | let { validators } = await rpc.validators({ height: 1 }) 40 | let state = { 41 | ...commit.signed_header, 42 | validators 43 | } 44 | rpc.close() 45 | 46 | let lightNode = LightNode(rpcHost, state) 47 | lightNode.once('error', (err) => { 48 | console.error(err.stack) 49 | t.fail() 50 | }) 51 | 52 | await new Promise((resolve) => { 53 | lightNode.once('synced', resolve) 54 | }) 55 | await new Promise((resolve) => { 56 | lightNode.once('update', resolve) 57 | }) 58 | 59 | // TODO: check event data 60 | 61 | lightNode.close() 62 | 63 | t.pass() 64 | }) 65 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | install: true 4 | script: "./test/travis.sh" 5 | env: 6 | matrix: 7 | - NODE=11 8 | - NODE=10 9 | - NODE=9 10 | - NODE=8 11 | - BROWSER=chrome 12 | - BROWSER=firefox 13 | - BROWSER=safari 14 | - BROWSER=iphone 15 | - BROWSER=android 16 | global: 17 | - secure: KTNbnHgpdJODNAI08ilDdOlStbIEnEOG+YtWMrP7/cyy7KB3X26vtLiihE0TBPaN+1nlhxvGb51Edx4rJqp42DI3Wy4pJ8A6U7zTaudUFbW+gDKLsDla+A3QjO4U5ZQsZpY1v78b3lQAka0cwXQWe4KrLN4bqDJ6UvOdPw/jGg8aSnK0P32IYo2mostr3o2g3AtTCuh2tGGtpFuQI2AQGuiokY+zNs/oL31h7AxZ0Xp0TXp4sEennU9ZOv3Tu7iFWuMaysRSCKCEgxRRp9wkPiN78BDO8/avfACkI3spPRsAU7/xOBjY+88M2ximUH4taxy02B3C33q/RpAkDP51kw6m0qv4ikN+o1znS0Q+9WoaYfiTGmxzxXNHlSBNly67bH4PjCkCR3hVRqwgzV36jhuxWtI7QKhIDRT2y3Rfzaam9yb+SmqK50IuKwP6E17JUi0jpPcW9gWTCG++/xsogYjGbo4CABmPv6/CWF6M/ZTAnqk9UTBn9nCz2hSos62ykkapJo/DAk+7vgOFMRium5x1RnOGmOSmUKbnxY0AS7Wd4c809z7Uo1M7akIYQRxOtq+M6iQ7qBPaVFesudvgq0Nollw1hhmgC/GuC0WwPpDfmnziM0DZQjMvC81yX6gvEg9TsA8JF6421xBu1aZEuhyH0eaoNag7aWFvH94463g= 18 | - secure: AbVZwOHaq0eYDwdbLUpNxEECycRgWkGYo5iCwQyt95YStBA6GhNj+tYAcjkJ17BhrMArMsmGFRzRJyVh2tg1QAqegABW2lWjwEI+YD/Ap2/uo9Zo7YygpcvMq3IwrfLngjcVmND8QZpuRDj+lBGSN0n+98YJDw4B4BhO1efiyj6rnnV1KBbWt2SKEttEZWnowpd3CT3PssTF4JVv0KYn9hUhqaZzsW/hG51VQ5YzB014AGTr6a481faZ0mQtA8QoD//runRYwLsL5PL8LwR/QnUqMcVeer5zgpDStV7o03baZKG1YmP7vXk/tE2o8Jxf+4PQG/s7d+ixPMmRgDgEUzZ9G6Vot0NKNQjnQkAAVGCMYQ+TTbtpOWqboXzx/vpM4LqalTO3XvMF85ImS5+wVCPMkZN/pN64n+SMMnh5jnh04QjvMcbZ/m8L7qrUsN863QgNGP7G53HzNk64f4DeHrRqnz0OBtEjxO4z/AIcdhBZwDSNyX8IEXONhI3PjhpxvAZxuLoLl07H8At1zzPXyvzRSfuvf378oX7P2mq3lwkyRStyQ0ExjjCjNOQzTX4mbtP+DFWjehwDkT6l4yPGHPjioHbqJrlJ5y+sMroC4vqO4CcPjNM4YHkEm6k2U0xfohxzJzJM0EExkgE3DV910X37hDA+SLDfs9RSP9fpms0= 19 | notifications: 20 | email: false 21 | addons: 22 | sauce_connect: true 23 | hosts: 24 | - airtap.local 25 | before_install: if [ -z "$BROWSER" ]; then unset SAUCE_USERNAME && unset SAUCE_ACCESS_KEY; 26 | fi 27 | -------------------------------------------------------------------------------- /test/attacks.js: -------------------------------------------------------------------------------- 1 | let test = require('ava') 2 | let getPort = require('get-port') 3 | let { LightNode } = require('..') 4 | let { createWsServer } = require('./utils.js') 5 | 6 | // TODO: enable when we use time heuristic to detect fake high heights (see lightNode.js) 7 | test.cb.skip('fake sync height', (t) => { 8 | getPort().then((rpcPort) => { 9 | let expectedReqs = [ 10 | [ 11 | 'status', { 12 | sync_info: { 13 | latest_block_height: 10000 14 | } 15 | } 16 | ] 17 | ] 18 | 19 | let server = createWsServer(rpcPort, (message, send) => { 20 | if (expectedReqs.length === 0) { 21 | t.fail('unexpected request') 22 | return 23 | } 24 | let [ req, res ] = expectedReqs.shift() 25 | if (message.method !== req) { 26 | t.fail(`expected "${req}" request`) 27 | return 28 | } 29 | send(null, res) 30 | 31 | if (expectedReqs.length === 0) { 32 | t.end() 33 | } 34 | }) 35 | let peer = `ws://localhost:${rpcPort}` 36 | 37 | let node = LightNode(peer, { 38 | validators: [], 39 | header: { height: 1 } 40 | }) 41 | }) 42 | }) 43 | 44 | test('fake header timestamp', async (t) => { 45 | let rpcPort = await getPort() 46 | let server = createWsServer(rpcPort, () => {}) 47 | let peer = `ws://localhost:${rpcPort}` 48 | 49 | let node = LightNode(peer, { 50 | validators: [ 51 | { 52 | "address": "96E2BEAF73D1694F1FE2747AF128C42E0CF1CBB8", 53 | "pub_key": { 54 | "type": "tendermint/PubKeyEd25519", 55 | "value": "mME8SWTtXhXX/H242IDYLPQsSl8I6ybZjDUcHjyXeE4=" 56 | }, 57 | "voting_power": "10", 58 | "proposer_priority": "0" 59 | } 60 | ], 61 | header: { height: 1 } 62 | }) 63 | 64 | try { 65 | await node.update({ 66 | height: '123', 67 | time: Date.now() + 1e8 68 | }) 69 | t.fail() 70 | } catch (err) { 71 | t.is(err.message, 'Header time is too far in the future') 72 | } 73 | }) 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tendermint", 3 | "version": "5.0.2", 4 | "description": "A light client which talks to your Tendermint node over RPC", 5 | "main": "index.js", 6 | "scripts": { 7 | "pretest": "depcheck --ignores=airtap && standard src test example index.js", 8 | "test": "npm run pretest && nyc ava test/lightNodeIntegration.js test/rpc.js test/attacks.js && npm run test-light", 9 | "test-light": "nyc tape test/common.js test/types.js test/verify.js", 10 | "test-browser": "npm run pretest && ./test/browser.sh", 11 | "build": "rm -rf lib && babel src -d lib", 12 | "source": "rm -rf lib && ln -s src lib", 13 | "prepublish": "npm run build", 14 | "publish": "npm run source", 15 | "generateFixtures": "go run test/fixtures/generate.go" 16 | }, 17 | "dependencies": { 18 | "axios": "^0.19.0", 19 | "camelcase": "^4.0.0", 20 | "create-hash": "^1.1.3", 21 | "debug": "^3.1.0", 22 | "json-stable-stringify": "^1.0.1", 23 | "ndjson": "^1.5.0", 24 | "old": "^0.1.3", 25 | "pumpify": "^1.3.5", 26 | "supercop.js": "^2.0.1", 27 | "varstruct": "^6.1.1", 28 | "websocket-stream": "^5.1.1" 29 | }, 30 | "devDependencies": { 31 | "airtap": "^2.0.1", 32 | "ava": "^0.25.0", 33 | "babel-cli": "^6.18.0", 34 | "babel-preset-es2015": "^6.18.0", 35 | "depcheck": "^0.6.11", 36 | "get-port": "^3.2.0", 37 | "nyc": "^11.8.0", 38 | "standard": "^11.0.1", 39 | "tape": "^4.9.1", 40 | "tempy": "^0.2.1", 41 | "tendermint-node": "^5.2.0", 42 | "ws": "^5.2.1" 43 | }, 44 | "babel": { 45 | "presets": [ 46 | "es2015" 47 | ] 48 | }, 49 | "keywords": [ 50 | "tendermint", 51 | "blockchain", 52 | "consensus", 53 | "cosmos", 54 | "client" 55 | ], 56 | "author": "Matt Bell ", 57 | "license": "MIT", 58 | "directories": { 59 | "example": "example", 60 | "lib": "lib", 61 | "test": "test" 62 | }, 63 | "repository": { 64 | "type": "git", 65 | "url": "git+https://github.com/mappum/js-tendermint.git" 66 | }, 67 | "bugs": { 68 | "url": "https://github.com/mappum/js-tendermint/issues" 69 | }, 70 | "homepage": "https://github.com/mappum/js-tendermint#readme" 71 | } 72 | -------------------------------------------------------------------------------- /src/hash.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const createHash = require('create-hash') 4 | const { 5 | UVarInt, 6 | VarInt, 7 | VarString, 8 | VarBuffer, 9 | VarHexBuffer, 10 | Time, 11 | BlockID, 12 | ValidatorHashInput, 13 | Version 14 | } = require('./types.js') 15 | 16 | const sha256 = hashFunc('sha256') 17 | const tmhash = sha256 18 | 19 | function getBlockHash (header) { 20 | let encodedFields = [ 21 | Version.encode(header.version), 22 | VarString.encode(header.chain_id), 23 | UVarInt.encode(header.height), 24 | Time.encode(header.time), 25 | BlockID.encode(header.last_block_id), 26 | omitEmpty(VarHexBuffer.encode(header.last_commit_hash)), 27 | omitEmpty(VarHexBuffer.encode(header.data_hash)), 28 | VarHexBuffer.encode(header.validators_hash), 29 | VarHexBuffer.encode(header.next_validators_hash), 30 | VarHexBuffer.encode(header.consensus_hash), 31 | omitEmpty(VarHexBuffer.encode(header.app_hash)), 32 | omitEmpty(VarHexBuffer.encode(header.last_results_hash)), 33 | omitEmpty(VarHexBuffer.encode(header.evidence_hash)), 34 | VarHexBuffer.encode(header.proposer_address) 35 | ] 36 | return treeHash(encodedFields).toString('hex').toUpperCase() 37 | } 38 | 39 | function getValidatorSetHash (validators) { 40 | let bytes = validators.map(ValidatorHashInput.encode) 41 | return treeHash(bytes).toString('hex').toUpperCase() 42 | } 43 | 44 | function omitEmpty (bytes) { 45 | if (bytes.length === 1 && bytes[0] === 0) { 46 | return Buffer.alloc(0) 47 | } 48 | return bytes 49 | } 50 | 51 | function treeHash (hashes) { 52 | if (hashes.length === 0) { 53 | return null 54 | } 55 | if (hashes.length === 1) { 56 | // leaf hash 57 | return tmhash(Buffer.concat([ 58 | Buffer.from([ 0 ]), 59 | hashes[0] 60 | ])) 61 | } 62 | let splitPoint = getSplitPoint(hashes.length) 63 | let left = treeHash(hashes.slice(0, splitPoint)) 64 | let right = treeHash(hashes.slice(splitPoint)) 65 | // inner hash 66 | return tmhash(Buffer.concat([ 67 | Buffer.from([ 1 ]), 68 | left, 69 | right 70 | ])) 71 | } 72 | 73 | function getSplitPoint (n) { 74 | if (n < 1) { 75 | throw Error('Trying to split tree with length < 1') 76 | } 77 | 78 | let mid = 2 ** Math.floor(Math.log2(n)) 79 | if (mid === n) { 80 | mid /= 2 81 | } 82 | return mid 83 | } 84 | 85 | function hashFunc (algorithm) { 86 | return function (...chunks) { 87 | let hash = createHash(algorithm) 88 | for (let data of chunks) hash.update(data) 89 | return hash.digest() 90 | } 91 | } 92 | 93 | module.exports = { 94 | getBlockHash, 95 | getValidatorSetHash, 96 | sha256, 97 | tmhash 98 | } 99 | -------------------------------------------------------------------------------- /test/fixtures/vote.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "value": { 4 | "type": 0, 5 | "height": 1, 6 | "round": 2, 7 | "block_id": { 8 | "hash": "", 9 | "parts": { 10 | "total": 0, 11 | "hash": "" 12 | } 13 | }, 14 | "timestamp": "1973-11-29T21:33:09.123456789Z", 15 | "validator_address": "", 16 | "validator_index": 0, 17 | "signature": null 18 | }, 19 | "encoding": "1101000000000000001902000000000000002a0a08959aef3a10959aef3a3208636861696e2d6964" 20 | }, 21 | { 22 | "value": { 23 | "type": 1, 24 | "height": 1234567890, 25 | "round": 0, 26 | "block_id": { 27 | "hash": "3031323334353637383930313233343536373839303132333435363738393031", 28 | "parts": { 29 | "total": 1, 30 | "hash": "3031323334353637383930313233343536373839303132333435363738393031" 31 | } 32 | }, 33 | "timestamp": "1973-11-29T21:33:09.123456789Z", 34 | "validator_address": "", 35 | "validator_index": 0, 36 | "signature": null 37 | }, 38 | "encoding": "080111d20296490000000022480a20303132333435363738393031323334353637383930313233343536373839303112240a20303132333435363738393031323334353637383930313233343536373839303110012a0a08959aef3a10959aef3a3208636861696e2d6964" 39 | }, 40 | { 41 | "value": { 42 | "type": 1, 43 | "height": 1234567890, 44 | "round": 0, 45 | "block_id": { 46 | "hash": "3031323334353637383930313233343536373839303132333435363738393031", 47 | "parts": { 48 | "total": 1, 49 | "hash": "3031323334353637383930313233343536373839303132333435363738393031" 50 | } 51 | }, 52 | "timestamp": "1973-11-29T21:33:09Z", 53 | "validator_address": "", 54 | "validator_index": 0, 55 | "signature": null 56 | }, 57 | "encoding": "080111d20296490000000022480a20303132333435363738393031323334353637383930313233343536373839303112240a20303132333435363738393031323334353637383930313233343536373839303110012a0508959aef3a3208636861696e2d6964" 58 | }, 59 | { 60 | "value": { 61 | "type": 1, 62 | "height": 1234567890, 63 | "round": 0, 64 | "block_id": { 65 | "hash": "3031323334353637383930313233343536373839303132333435363738393031", 66 | "parts": { 67 | "total": 123, 68 | "hash": "3031323334353637383930313233343536373839303132333435363738393031" 69 | } 70 | }, 71 | "timestamp": "1973-11-29T21:33:09Z", 72 | "validator_address": "", 73 | "validator_index": 0, 74 | "signature": null 75 | }, 76 | "encoding": "080111d20296490000000022480a20303132333435363738393031323334353637383930313233343536373839303112240a203031323334353637383930313233343536373839303132333435363738393031107b2a0508959aef3a3208636861696e2d6964" 77 | } 78 | ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tendermint 2 | 3 | A light client for Tendermint blockchains which works in Node.js and browsers. 4 | 5 | Supports Tendermint v0.33. 6 | 7 | **NOTICE:** This library has not undergone any kind of security review, so think twice before using it to secure any real value. 8 | 9 | ### Usage 10 | ``` 11 | npm install tendermint 12 | ``` 13 | 14 | **Light Node** 15 | 16 | Requests data over RPC and verifies blockchain headers 17 | 18 | ```js 19 | let Tendermint = require('tendermint') 20 | 21 | // some full node's RPC port 22 | let peer = 'ws://localhost:26657' 23 | 24 | // `state` contains a part of the chain we know to be valid. If it's 25 | // too old, we cannot safely verify the chain and need to get a newer 26 | // state out-of-band. 27 | let state = { 28 | // a header, in the same format as returned by RPC 29 | // (see http://localhost:26657/commit, under `"header":`) 30 | header: { ... }, 31 | 32 | // the valdiator set for this header, in the same format as returned by RPC 33 | // (see http://localhost:26657/validators) 34 | validators: [ ... ], 35 | 36 | // the commit (validator signatures) for this header, in the same format as 37 | // returned by RPC (see http://localhost:26657/commit, under `"commit":`) 38 | commit: { ... } 39 | } 40 | 41 | // options 42 | let opts = { 43 | // the maximum age of a state to be safely accepted, 44 | // e.g. the unbonding period 45 | // (in seconds) 46 | maxAge: 1728000 // defaults to 30 days 47 | } 48 | 49 | // instantiate client. will automatically start syncing to the latest state of 50 | // the blockchain 51 | let node = Tendermint(peer, state, opts) 52 | 53 | // make sure to handle errors 54 | node.on('error', (err) => { ... }) 55 | // emitted once we have caught up to the current chain tip 56 | node.on('synced', () => { ... }) 57 | // emitted every time we have verified a new part of the blockchain 58 | node.on('update', () => { ... }) 59 | 60 | // returns the height of the most recent header we have verified 61 | node.height() 62 | 63 | // returns the state object ({ header, validators, commit }) of the most recently 64 | // verified header, should be stored and used to instantiate the light client 65 | // the next time the user runs the app 66 | node.state() 67 | ``` 68 | 69 | **RPC Client** 70 | 71 | Simple client to make RPC requests to nodes 72 | 73 | ```js 74 | let { RpcClient } = require('tendermint') 75 | 76 | let client = RpcClient('ws://localhost:26657') 77 | 78 | // request a block 79 | client.block({ height: 100 }) 80 | .then((res) => console.log(res)) 81 | ``` 82 | 83 | The following RPC methods are available: 84 | 85 | ``` 86 | - subscribe 87 | - unsubscribe 88 | - status 89 | - netInfo 90 | - dialSeeds 91 | - blockchain 92 | - genesis 93 | - block 94 | - validators 95 | - dumpConsensusState 96 | - broadcastTxCommit 97 | - broadcastTxAsync 98 | - broadcastTxSync 99 | - unconfirmedTxs 100 | - numUnconfirmedTxs 101 | - abciQuery 102 | - abciInfo 103 | - abciProof 104 | - unsafeFlushMempool 105 | - unsafeSetConfig 106 | - unsafeStartCpuProfiler 107 | - unsafeStopCpuProfiler 108 | - unsafeWriteHeapProfile 109 | ``` 110 | -------------------------------------------------------------------------------- /test/types.js: -------------------------------------------------------------------------------- 1 | let test = require('tape') 2 | let { 3 | VarInt, 4 | VarHexBuffer, 5 | Time, 6 | BlockID, 7 | PubKey, 8 | ValidatorHashInput, 9 | CanonicalVote, 10 | Version 11 | } = require('../lib/types.js') 12 | 13 | let versionFixtures = require('./fixtures/version.json') 14 | let voteFixtures = require('./fixtures/vote.json') 15 | let varintFixtures = require('./fixtures/varint.json') 16 | let timeFixtures = require('./fixtures/time.json') 17 | let blockIDFixtures = require('./fixtures/block_id.json') 18 | let pubkeyFixture = require('./fixtures/pubkey.json') 19 | let validatorHashInputFixtures = require('./fixtures/validator_hash_input.json') 20 | for (let vhi of validatorHashInputFixtures) { 21 | vhi.value.pub_key = pubkeyFixture.value 22 | } 23 | 24 | function EncodeTest (t, type) { 25 | return (value, expected) => { 26 | let actual = type.encode(value).toString('hex') 27 | t.equals(actual, expected, `encode ${JSON.stringify(value, null, ' ')}`) 28 | } 29 | } 30 | 31 | test('Version', (t) => { 32 | for (let { value, encoding } of versionFixtures) { 33 | let actual = Version.encode(value).toString('hex') 34 | t.equals(actual, encoding, `encode ${JSON.stringify(value)}`) 35 | } 36 | t.end() 37 | }) 38 | 39 | test('Vote', (t) => { 40 | for (let { value, encoding } of voteFixtures) { 41 | value.chain_id = 'chain-id' 42 | let actual = CanonicalVote.encode(value).toString('hex') 43 | t.equals(actual, encoding, `encode ${JSON.stringify(value)}`) 44 | } 45 | t.end() 46 | }) 47 | 48 | test('VarInt', (t) => { 49 | for (let { value, encoding } of varintFixtures) { 50 | let actual = VarInt.encode(value).toString('hex') 51 | t.equals(actual, encoding, `encode ${value}`) 52 | } 53 | t.end() 54 | }) 55 | 56 | test('VarHexBuffer', (t) => { 57 | // encode 58 | let data = '0001020304050607' 59 | let output = Buffer.alloc(9) 60 | VarHexBuffer.encode(data, output, 0) 61 | t.equals(output.toString('hex'), '080001020304050607') 62 | t.equals(VarHexBuffer.encode.bytes, 9) 63 | 64 | // encodingLength 65 | let length = VarHexBuffer.encodingLength(data) 66 | t.equals(length, 9) 67 | t.end() 68 | }) 69 | 70 | test('Time', (t) => { 71 | // TODO: failure case 72 | for (let { value, encoding } of timeFixtures) { 73 | let actual = Time.encode(value).toString('hex') 74 | t.equals(actual, encoding, `encode ${value}`) 75 | } 76 | t.end() 77 | }) 78 | 79 | test('BlockID', (t) => { 80 | for (let { value, encoding } of blockIDFixtures) { 81 | let actual = BlockID.encode(value).toString('hex') 82 | t.equals(actual, encoding, `encode ${value}`) 83 | } 84 | t.end() 85 | }) 86 | 87 | test.skip('PubKey', (t) => { 88 | let encodeTest = EncodeTest(t, PubKey) 89 | encodeTest(null, '00') 90 | // annoyingly, tendermint uses a different encoding when the pubkey is alone 91 | // vs when inside the validatorhashinput, so the following currently fails against 92 | // the fixture. 93 | // encodeTest(pubkeyFixture.value, pubkeyFixture.encoding) 94 | t.end() 95 | }) 96 | 97 | test('ValidatorHashInput', (t) => { 98 | for (let { value, encoding } of validatorHashInputFixtures) { 99 | let actual = ValidatorHashInput.encode(value).toString('hex') 100 | t.equals(actual, encoding, `encode ${value}`) 101 | } 102 | t.end() 103 | }) 104 | -------------------------------------------------------------------------------- /src/rpc.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const EventEmitter = require('events') 4 | const axios = require('axios') 5 | const url = require('url') 6 | const old = require('old') 7 | const camel = require('camelcase') 8 | const websocket = require('websocket-stream') 9 | const ndjson = require('ndjson') 10 | const pumpify = require('pumpify').obj 11 | const debug = require('debug')('tendermint:rpc') 12 | const tendermintMethods = require('./methods.js') 13 | 14 | function convertHttpArgs (args) { 15 | args = args || {} 16 | for (let k in args) { 17 | let v = args[k] 18 | if (typeof v === 'number') { 19 | args[k] = `"${v}"` 20 | } 21 | } 22 | return args 23 | } 24 | 25 | function convertWsArgs (args) { 26 | args = args || {} 27 | for (let k in args) { 28 | let v = args[k] 29 | if (typeof v === 'number') { 30 | args[k] = String(v) 31 | } else if (Buffer.isBuffer(v)) { 32 | args[k] = '0x' + v.toString('hex') 33 | } else if (v instanceof Uint8Array) { 34 | args[k] = '0x' + Buffer.from(v).toString('hex') 35 | } 36 | } 37 | return args 38 | } 39 | 40 | const wsProtocols = [ 'ws:', 'wss:' ] 41 | const httpProtocols = [ 'http:', 'https:' ] 42 | const allProtocols = wsProtocols.concat(httpProtocols) 43 | 44 | class Client extends EventEmitter { 45 | constructor (uriString = 'localhost:26657') { 46 | super() 47 | 48 | // parse full-node URI 49 | let { protocol, hostname, port } = url.parse(uriString) 50 | 51 | // default to http 52 | if (!allProtocols.includes(protocol)) { 53 | let uri = url.parse(`http://${uriString}`) 54 | protocol = uri.protocol 55 | hostname = uri.hostname 56 | port = uri.port 57 | } 58 | 59 | // default port 60 | if (!port) { 61 | port = 26657 62 | } 63 | 64 | if (wsProtocols.includes(protocol)) { 65 | this.websocket = true 66 | this.uri = `${protocol}//${hostname}:${port}/websocket` 67 | this.call = this.callWs 68 | this.connectWs() 69 | } else if (httpProtocols.includes(protocol)) { 70 | this.uri = `${protocol}//${hostname}:${port}/` 71 | this.call = this.callHttp 72 | } 73 | } 74 | 75 | connectWs () { 76 | this.ws = pumpify( 77 | ndjson.stringify(), 78 | websocket(this.uri) 79 | ) 80 | this.ws.on('error', (err) => this.emit('error', err)) 81 | this.ws.on('close', () => { 82 | if (this.closed) return 83 | this.emit('error', Error('websocket disconnected')) 84 | }) 85 | this.ws.on('data', (data) => { 86 | data = JSON.parse(data) 87 | if (data.result && data.result.query) { 88 | this.emit('query#' + data.result.query, data.error, data.result) 89 | } 90 | if (!data.id) return 91 | this.emit(data.id, data.error, data.result) 92 | }) 93 | } 94 | 95 | callHttp (method, args) { 96 | return axios({ 97 | url: this.uri + method, 98 | params: convertHttpArgs(args) 99 | }).then(function ({ data }) { 100 | if (data.error) { 101 | let err = Error(data.error.message) 102 | Object.assign(err, data.error) 103 | throw err 104 | } 105 | return data.result 106 | }, function (err) { 107 | throw Error(err) 108 | }) 109 | } 110 | 111 | callWs (method, args, listener) { 112 | let self = this 113 | return new Promise((resolve, reject) => { 114 | let id = Math.random().toString(36) 115 | let params = convertWsArgs(args) 116 | 117 | if (method === 'subscribe') { 118 | if (typeof listener !== 'function') { 119 | throw Error('Must provide listener function') 120 | } 121 | 122 | // id-free query responses in tendermint-0.33 are returned as follows 123 | if (params.query) { 124 | this.on('query#' + params.query, (err, res) => { 125 | if (err) return self.emit('error', err) 126 | listener(res.data.value) 127 | }) 128 | } 129 | 130 | // promise resolves on successful subscription or error 131 | this.once(id, (err, res) => { 132 | if (err) return reject(err) 133 | 134 | // now that we are subscribed, pass further events to listener 135 | this.on(id, (err, res) => { 136 | if (err) return this.emit('error', err) 137 | listener(res.data.value) 138 | }) 139 | 140 | resolve() 141 | }) 142 | } else { 143 | // response goes to promise 144 | this.once(id, (err, res) => { 145 | if (err) return reject(err) 146 | resolve(res) 147 | }) 148 | } 149 | 150 | this.ws.write({ jsonrpc: '2.0', id, method, params }) 151 | }) 152 | } 153 | 154 | close () { 155 | this.closed = true 156 | if (!this.ws) return 157 | this.ws.destroy() 158 | } 159 | } 160 | 161 | // add methods to Client class based on methods defined in './methods.js' 162 | for (let name of tendermintMethods) { 163 | Client.prototype[camel(name)] = function (args, listener) { 164 | if (args) { 165 | debug('>>', name, args) 166 | } else { 167 | debug('>>', name) 168 | } 169 | return this.call(name, args, listener) 170 | .then((res) => { 171 | debug('<<', name, res) 172 | return res 173 | }) 174 | } 175 | } 176 | 177 | module.exports = old(Client) 178 | -------------------------------------------------------------------------------- /test/verify.js: -------------------------------------------------------------------------------- 1 | let randomBytes = require('crypto').pseudoRandomBytes 2 | let test = require('tape') 3 | let ed25519 = require('supercop.js') 4 | let { 5 | verifyCommit, 6 | getVoteSignBytes 7 | } = require('../lib/verify.js') 8 | let { getAddress } = require('../lib/pubkey.js') 9 | let { 10 | getValidatorSetHash, 11 | getBlockHash 12 | } = require('../lib/hash.js') 13 | 14 | test('verifyCommit with mismatched header and commit', (t) => { 15 | let validators = genValidators() 16 | let header = genGenesisHeader(validators) 17 | let commit = genCommit(genGenesisHeader(validators), validators) 18 | t.throws( 19 | () => verifyCommit(header, commit, validators), 20 | 'Commit does not match block hash' 21 | ) 22 | t.end() 23 | }) 24 | 25 | test('verifyCommit with mismatched header and precommit', (t) => { 26 | let validators = genValidators() 27 | let header = genGenesisHeader(validators) 28 | let commit = genCommit(header, validators) 29 | let commit2 = genCommit(genGenesisHeader(validators), validators) 30 | // copy a precommit for a different header 31 | commit.precommits[20] = commit2.precommits[20] 32 | t.throws( 33 | () => verifyCommit(header, commit, validators), 34 | 'Precommit block hash does not match commit' 35 | ) 36 | 37 | t.end() 38 | }) 39 | 40 | test('verifyCommit with fixture', (t) => { 41 | let validators = [ 42 | { 43 | "address": "00BA391A74E7DFDE058DF93DFCEBAD5980E5330D", 44 | "pub_key": { 45 | "type": "tendermint/PubKeyEd25519", 46 | "value": "KHcvGxobAi0VjlBfjYU2A5SIl571qXuIeMIv9nyLTmU=" 47 | }, 48 | "voting_power": "10", 49 | "proposer_priority": "0" 50 | } 51 | ] 52 | let header = { "version": { 53 | "block": "10", 54 | "app": "0" 55 | }, 56 | "chain_id": "test-chain-0ExC6E", 57 | "height": "15", 58 | "time": "2020-03-23T23:04:27.217591086Z", 59 | "last_block_id": { 60 | "hash": "0E1011B6D7CF5BD72DC505837E81F84916EACB7EF7B0AA223C7F3E14E3DB6CA5", 61 | "parts": { 62 | "total": "1", 63 | "hash": "2BBE679AEC7B43F418DC39F281F2713F1C9AF0AFD413D6072379877D49BD315F" 64 | } 65 | }, 66 | "last_commit_hash": "A10FD6F0E34214B2A05314724AE7A0122D8E17FBA786C3A1E2175840518AFE31", 67 | "data_hash": "", 68 | "validators_hash": "D1023F5B4022334F6D000080572565D468028E485E081089CDA21BBCC31F6DAC", 69 | "next_validators_hash": "D1023F5B4022334F6D000080572565D468028E485E081089CDA21BBCC31F6DAC", 70 | "consensus_hash": "048091BC7DDC283F77BFBF91D73C44DA58C3DF8A9CBC867405D8B7F3DAADA22F", 71 | "app_hash": "000000000000000B", 72 | "last_results_hash": "", 73 | "evidence_hash": "", 74 | "proposer_address": "00BA391A74E7DFDE058DF93DFCEBAD5980E5330D" 75 | } 76 | let commit = { 77 | "height": "15", 78 | "round": "0", 79 | "block_id": { 80 | "hash": "1FF1F9E06945CCFCAB2F1EEF42B24D462B06E005685BC8DEFA428706BE30B21C", 81 | "parts": { 82 | "total": "1", 83 | "hash": "6E581F5F989C9C94C0D95E336C122F6D685EF79DE8C6227C63F7B6169AF8C4B7" 84 | } 85 | }, 86 | "signatures": [ 87 | { 88 | "block_id_flag": 2, 89 | "validator_address": "00BA391A74E7DFDE058DF93DFCEBAD5980E5330D", 90 | "timestamp": "2020-03-23T23:04:28.36126444Z", 91 | "signature": "ITM9rAZl1SfgwfF8aXbNUGgzO9cvQ6cLKcZrCNCalwdkaY/gTD2dBR1HBOrMq1MbmtYGXyH1un40DXBOfu+3Bg==" 92 | } 93 | ] 94 | } 95 | verifyCommit(header, commit, validators) 96 | t.pass() 97 | t.end() 98 | }) 99 | 100 | function genGenesisHeader (validators) { 101 | let validatorsHash = getValidatorSetHash(validators) 102 | return { 103 | version: { block: 123, app: 456 }, 104 | chain_id: Math.random().toString(36), 105 | height: 1, 106 | time: new Date().toISOString(), 107 | num_txs: 0, 108 | last_block_id: { 109 | hash: '', 110 | parts: { total: '0', hash: '' } 111 | }, 112 | total_txs: 0, 113 | last_commit_hash: '', 114 | data_hash: '', 115 | validators_hash: validatorsHash, 116 | next_validators_hash: validatorsHash, 117 | consensus_hash: genHash(), 118 | app_hash: '', 119 | last_results_hash: '', 120 | evidence_hash: '', 121 | proposer_address: '0001020304050607080900010203040506070809' 122 | } 123 | } 124 | 125 | function genCommit (header, validators) { 126 | let blockId = { 127 | hash: getBlockHash(header), 128 | parts: { 129 | total: 1, 130 | hash: genHash() 131 | } 132 | } 133 | let precommits = [] 134 | let time = new Date(header.time).getTime() 135 | for (let i = 0; i < validators.length; i++) { 136 | let validator = validators[i] 137 | let precommit = { 138 | validator_address: validator.address, 139 | validator_index: String(i), 140 | height: header.height, 141 | round: '0', 142 | timestamp: new Date(time + Math.random() * 1000).toISOString(), 143 | type: 2, 144 | block_id: blockId 145 | } 146 | let signBytes = Buffer.from(getVoteSignBytes(header.chain_id, precommit)) 147 | let pub = Buffer.from(validator.pub_key.value, 'base64') 148 | let signature = ed25519.sign(signBytes, pub, validator.priv_key) 149 | precommit.signature = { 150 | type: 'tendermint/SignatureEd25519', 151 | value: signature.toString('base64') 152 | } 153 | precommits.push(precommit) 154 | } 155 | return { 156 | block_id: blockId, 157 | precommits 158 | } 159 | } 160 | 161 | function genValidators () { 162 | let validators = [] 163 | for (let i = 0; i < 100; i++) { 164 | let priv = randomBytes(32) 165 | let pub = { 166 | type: 'tendermint/PubKeyEd25519', 167 | value: priv.toString('base64') 168 | } 169 | validators.push({ 170 | priv_key: priv, 171 | pub_key: pub, 172 | address: getAddress(pub), 173 | voting_power: '10', 174 | accum: '0' 175 | }) 176 | } 177 | return validators 178 | } 179 | 180 | function genHash () { 181 | return randomBytes(20).toString('hex').toUpperCase() 182 | } 183 | -------------------------------------------------------------------------------- /test/rpc.js: -------------------------------------------------------------------------------- 1 | let { createServer } = require('http') 2 | let parseUrl = require('url').parse 3 | let test = require('ava') 4 | let getPort = require('get-port') 5 | let { RpcClient } = require('..') 6 | let { createWsServer } = require('./utils.js') 7 | 8 | function createHttpServer (port = 26657, onRequest) { 9 | let server = createServer((req, res) => { 10 | let { query } = parseUrl(req.url, true) 11 | let resValue = onRequest(req, query) 12 | res.end(JSON.stringify(resValue)) 13 | }) 14 | server.listen(port) 15 | return server 16 | } 17 | 18 | test('default constructor', (t) => { 19 | let rpc = RpcClient() 20 | t.is(rpc.uri, 'http://localhost:26657/') 21 | t.falsy(rpc.websocket) 22 | }) 23 | 24 | test('constructor with no protocol', (t) => { 25 | let rpc = RpcClient('localhost:1234') 26 | t.is(rpc.uri, 'http://localhost:1234/') 27 | t.falsy(rpc.websocket) 28 | }) 29 | 30 | test('constructor with no port', (t) => { 31 | let rpc = RpcClient('https://localhost') 32 | t.is(rpc.uri, 'https://localhost:26657/') 33 | t.falsy(rpc.websocket) 34 | }) 35 | 36 | test('constructor with websocket', async (t) => { 37 | let rpc = RpcClient('ws://localhost:26657') 38 | t.is(rpc.uri, 'ws://localhost:26657/websocket') 39 | t.true(rpc.websocket) 40 | rpc.close() 41 | }) 42 | 43 | test('constructor with secure websocket', async (t) => { 44 | let rpc = RpcClient('wss://localhost:26657') 45 | t.is(rpc.uri, 'wss://localhost:26657/websocket') 46 | t.true(rpc.websocket) 47 | rpc.close() 48 | }) 49 | 50 | test('http path', async (t) => { 51 | let port = await getPort() 52 | let server = createHttpServer(port, (req, query) => { 53 | t.is(req.url, '/status') 54 | return { result: 'foo' } 55 | }) 56 | let rpc = RpcClient(`http://localhost:${port}`) 57 | let res = await rpc.status() 58 | t.is(res, 'foo') 59 | server.close() 60 | }) 61 | 62 | test('http arg conversion', async (t) => { 63 | let port = await getPort() 64 | let server = createHttpServer(port, (req, query) => { 65 | t.is(JSON.parse(query.height), '123') 66 | return {} 67 | }) 68 | let rpc = RpcClient(`http://localhost:${port}`) 69 | await rpc.commit({ height: 123 }) 70 | server.close() 71 | }) 72 | 73 | test('http response errors are thrown', async (t) => { 74 | let port = await getPort() 75 | let server = createHttpServer(port, (req, query) => { 76 | return { error: { code: 123, message: 'test' } } 77 | }) 78 | let rpc = RpcClient(`http://localhost:${port}`) 79 | try { 80 | await rpc.commit({ height: 123 }) 81 | t.fail('should have thrown') 82 | } catch (err) { 83 | t.is(err.code, 123) 84 | t.is(err.message, 'test') 85 | } 86 | server.close() 87 | }) 88 | 89 | test('http non-response errors are thrown', async (t) => { 90 | let rpc = RpcClient(`http://localhost:0`) 91 | try { 92 | await rpc.commit({ height: 123 }) 93 | t.fail('should have thrown') 94 | } catch (err) { 95 | t.pass() 96 | } 97 | }) 98 | 99 | test('ws response error', async (t) => { 100 | let port = await getPort() 101 | let server = createWsServer(port, (req, res) => res({ message: 'err' })) 102 | let rpc = RpcClient(`ws://localhost:${port}`) 103 | try { 104 | await rpc.commit() 105 | t.fail('should have thrown') 106 | } catch (err) { 107 | t.is(err.message, 'err') 108 | } 109 | rpc.close() 110 | await server.close() 111 | }) 112 | 113 | test('ws arg conversion', async (t) => { 114 | let port = await getPort() 115 | let server = createWsServer(port, (req, res) => { 116 | t.is(req.method, 'commit') 117 | t.is(req.params.number, '123') 118 | t.is(req.params.buffer, '0x68656c6c6f') 119 | t.is(req.params.uint8array, '0x01020304') 120 | res(null, 'bar') 121 | }) 122 | let rpc = RpcClient(`ws://localhost:${port}`) 123 | let res = await rpc.commit({ 124 | number: 123, 125 | buffer: Buffer.from('hello'), 126 | uint8array: new Uint8Array([ 1, 2, 3, 4 ]) 127 | }) 128 | t.is(res, 'bar') 129 | rpc.close() 130 | await server.close() 131 | }) 132 | 133 | test('ws subscription', async (t) => { 134 | let port = await getPort() 135 | let events = [] 136 | let server = createWsServer(port, (req, res) => { 137 | res(null, {}) 138 | res(null, 139 | { data: { value: 'foo' } }, 140 | req.id) 141 | res(null, 142 | { data: { value: 'bar' } }, 143 | req.id) 144 | }) 145 | let rpc = RpcClient(`ws://localhost:${port}`) 146 | await new Promise((resolve) => { 147 | rpc.subscribe({ query: 'foo' }, async (event) => { 148 | events.push(event) 149 | if (events.length < 2) return 150 | rpc.close() 151 | await server.close() 152 | t.deepEqual(events, [ 'foo', 'bar' ]) 153 | resolve() 154 | }) 155 | }) 156 | }) 157 | 158 | test('ws subscription error', async (t) => { 159 | let port = await getPort() 160 | let server = createWsServer(port, (req, res) => { 161 | res({ code: 123, message: 'uh oh' }) 162 | }) 163 | let rpc = RpcClient(`ws://localhost:${port}`) 164 | try { 165 | await rpc.subscribe({ query: 'foo' }, () => {}) 166 | t.fail('should have thrown') 167 | } catch (err) { 168 | t.is(err.code, 123) 169 | t.is(err.message, 'uh oh') 170 | } 171 | rpc.close() 172 | await server.close() 173 | }) 174 | 175 | test('ws disconnect emits error', async (t) => { 176 | let port = await getPort() 177 | let server = createWsServer(port, (req, res) => res(null, {})) 178 | let rpc = RpcClient(`ws://localhost:${port}`) 179 | await rpc.status() 180 | let waitForError = new Promise((resolve) => { 181 | rpc.on('error', resolve) 182 | }) 183 | server.close() 184 | await waitForError 185 | t.pass() 186 | }) 187 | 188 | test('ws subscription requires listener', async (t) => { 189 | let port = await getPort() 190 | let server = createWsServer(port, (req, res) => res(null, {})) 191 | let rpc = RpcClient(`ws://localhost:${port}`) 192 | try { 193 | await rpc.subscribe('foo') 194 | t.fail() 195 | } catch (err) { 196 | t.is(err.message, 'Must provide listener function') 197 | } 198 | rpc.close() 199 | server.close() 200 | }) 201 | -------------------------------------------------------------------------------- /src/lightNode.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const old = require('old') 4 | const EventEmitter = require('events') 5 | const RpcClient = require('./rpc.js') 6 | const { 7 | verifyCommit, 8 | verifyCommitSigs, 9 | verifyValidatorSet, 10 | verify 11 | } = require('./verify.js') 12 | const { getValidatorSetHash } = require('./hash.js') 13 | const { safeParseInt } = require('./common.js') 14 | 15 | const HOUR = 60 * 60 * 1000 16 | const FOUR_HOURS = 4 * HOUR 17 | const THIRTY_DAYS = 30 * 24 * HOUR 18 | 19 | // TODO: support multiple peers 20 | // (multiple connections to listen for headers, 21 | // get current height from multiple peers before syncing, 22 | // randomly select peer when requesting data, 23 | // broadcast txs to many peers) 24 | 25 | // TODO: on error, disconnect from peer and try again 26 | 27 | // TODO: use time heuristic to ensure nodes can't DoS by 28 | // sending fake high heights. 29 | // (applies to getting height when getting status in `sync()`, 30 | // and when receiving a block in `update()`) 31 | 32 | // talks to nodes via RPC and does light-client verification 33 | // of block headers. 34 | class LightNode extends EventEmitter { 35 | constructor (peer, state, opts = {}) { 36 | super() 37 | 38 | this.maxAge = opts.maxAge || THIRTY_DAYS 39 | 40 | if (state.header.height == null) { 41 | throw Error('Expected state header to have a height') 42 | } 43 | state.header.height = safeParseInt(state.header.height) 44 | 45 | // we should be able to trust this state since it was either 46 | // hardcoded into the client, or previously verified/stored, 47 | // but it doesn't hurt to do a sanity check. commit verifification 48 | // not required for first block, since we might be deriving it from 49 | // genesis 50 | verifyValidatorSet(state.validators, state.header.validators_hash) 51 | if (state.header.height > 1 || state.commit != null) { 52 | verifyCommit(state.header, state.commit, state.validators) 53 | } else { 54 | // add genesis validator hash to state 55 | let validatorHash = getValidatorSetHash(state.validators) 56 | state.header.validators_hash = validatorHash.toString('hex').toUpperCase() 57 | } 58 | 59 | this._state = state 60 | 61 | this.rpc = RpcClient(peer) 62 | // TODO: ensure we're using websocket 63 | this.emitError = this.emitError.bind(this) 64 | this.rpc.on('error', this.emitError) 65 | 66 | this.handleError(this.initialSync)() 67 | .then(() => this.emit('synced')) 68 | } 69 | 70 | handleError (func) { 71 | return (...args) => { 72 | return func.call(this, ...args) 73 | .catch((err) => this.emitError(err)) 74 | } 75 | } 76 | 77 | emitError (err) { 78 | this.rpc.close() 79 | this.emit('error', err) 80 | } 81 | 82 | state () { 83 | // TODO: deep clone 84 | return Object.assign({}, this._state) 85 | } 86 | 87 | height () { 88 | return this._state.header.height 89 | } 90 | 91 | // sync from current state to latest block 92 | async initialSync () { 93 | // TODO: use time heuristic (see comment at top of file) 94 | // TODO: get tip height from multiple peers and make sure 95 | // they give us similar results 96 | let status = await this.rpc.status() 97 | let tip = safeParseInt(status.sync_info.latest_block_height) 98 | if (tip > this.height()) { 99 | await this.syncTo(tip) 100 | } 101 | this.handleError(this.subscribe)() 102 | } 103 | 104 | // binary search to find furthest block from our current state, 105 | // which is signed by 2/3+ voting power of our current validator set 106 | async syncTo (nextHeight, targetHeight = nextHeight) { 107 | let { signed_header: { header, commit } } = 108 | await this.rpc.commit({ height: nextHeight }) 109 | header.height = safeParseInt(header.height) 110 | 111 | try { 112 | // try to verify (throws if we can't) 113 | await this.update(header, commit) 114 | 115 | // reached target 116 | if (nextHeight === targetHeight) return 117 | 118 | // continue syncing from this point 119 | return this.syncTo(targetHeight) 120 | } catch (err) { 121 | // throw real errors 122 | if (!err.insufficientVotingPower) { 123 | throw err 124 | } 125 | 126 | // insufficient verifiable voting power error, 127 | // couldn't verify this header 128 | 129 | let height = this.height() 130 | if (nextHeight === height + 1) { 131 | // should not happen unless peer sends us fake transition 132 | throw Error('Could not verify transition') 133 | } 134 | 135 | // let's try going halfway back and see if we can verify 136 | let midpoint = height + Math.ceil((nextHeight - height) / 2) 137 | return this.syncTo(midpoint, targetHeight) 138 | } 139 | } 140 | 141 | // start verifying new blocks as they come in 142 | async subscribe () { 143 | let query = 'tm.event = \'NewBlockHeader\'' 144 | let syncing = false 145 | let targetHeight = this.height() 146 | await this.rpc.subscribe({ query }, this.handleError(async ({ header }) => { 147 | header.height = safeParseInt(header.height) 148 | targetHeight = header.height 149 | 150 | // don't start another sync loop if we are in the middle of syncing 151 | if (syncing) return 152 | syncing = true 153 | 154 | // sync one block at a time to target 155 | while (this.height() < targetHeight) { 156 | await this.syncTo(this.height() + 1) 157 | } 158 | 159 | // unlock 160 | syncing = false 161 | })) 162 | } 163 | 164 | async update (header, commit) { 165 | header.height = safeParseInt(header.height) 166 | let { height } = header 167 | 168 | // make sure we aren't syncing from longer than than the unbonding period 169 | let prevTime = new Date(this._state.header.time).getTime() 170 | if (Date.now() - prevTime > this.maxAge) { 171 | throw Error('Our state is too old, cannot update safely') 172 | } 173 | 174 | // make sure new commit isn't too far in the future 175 | let nextTime = new Date(header.time).getTime() 176 | if (nextTime - Date.now() > FOUR_HOURS) { 177 | throw Error('Header time is too far in the future') 178 | } 179 | 180 | if (commit == null) { 181 | let res = await this.rpc.commit({ height }) 182 | commit = res.signed_header.commit 183 | commit.header.height = safeParseInt(commit.header.height) 184 | } 185 | 186 | let validators = this._state.validators 187 | 188 | let validatorSetChanged = header.validators_hash !== this._state.header.validators_hash 189 | if (validatorSetChanged) { 190 | let res = await this.rpc.validators({ height, per_page: -1 }) 191 | validators = res.validators 192 | } 193 | 194 | let newState = { header, commit, validators } 195 | verify(this._state, newState) 196 | 197 | this._state = newState 198 | this.emit('update', header, commit, validators) 199 | } 200 | 201 | close () { 202 | this.rpc.close() 203 | } 204 | } 205 | 206 | module.exports = old(LightNode) 207 | -------------------------------------------------------------------------------- /test/fixtures/generate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/hex" 6 | "encoding/json" 7 | "io/ioutil" 8 | "os" 9 | "time" 10 | 11 | "github.com/tendermint/go-amino" 12 | "github.com/tendermint/tendermint/crypto" 13 | "github.com/tendermint/tendermint/crypto/ed25519" 14 | "github.com/tendermint/tendermint/types" 15 | "github.com/tendermint/tendermint/version" 16 | ) 17 | 18 | var versionValues = []version.Consensus{ 19 | version.Consensus{ 20 | Block: 1234, 21 | App: 5678, 22 | }, 23 | version.Consensus{ 24 | Block: 1, 25 | App: 0, 26 | }, 27 | version.Consensus{ 28 | Block: 0, 29 | App: 1, 30 | }, 31 | version.Consensus{ 32 | Block: 0, 33 | App: 0, 34 | }, 35 | } 36 | 37 | var voteValues = []types.Vote{ 38 | types.Vote{ 39 | Type: 0, 40 | Height: 1, 41 | Round: 2, 42 | BlockID: types.BlockID{}, 43 | Timestamp: time.Unix(123456789, 123456789).UTC(), 44 | }, 45 | types.Vote{ 46 | Type: 1, 47 | Height: 1234567890, 48 | Round: 0, 49 | BlockID: types.BlockID{ 50 | Hash: []byte("01234567890123456789012345678901"), 51 | PartsHeader: types.PartSetHeader{ 52 | Hash: []byte("01234567890123456789012345678901"), 53 | Total: 1, 54 | }, 55 | }, 56 | Timestamp: time.Unix(123456789, 123456789).UTC(), 57 | }, 58 | types.Vote{ 59 | Type: 1, 60 | Height: 1234567890, 61 | Round: 0, 62 | BlockID: types.BlockID{ 63 | Hash: []byte("01234567890123456789012345678901"), 64 | PartsHeader: types.PartSetHeader{ 65 | Hash: []byte("01234567890123456789012345678901"), 66 | Total: 1, 67 | }, 68 | }, 69 | Timestamp: time.Unix(123456789, 0).UTC(), 70 | }, 71 | types.Vote{ 72 | Type: 1, 73 | Height: 1234567890, 74 | Round: 0, 75 | BlockID: types.BlockID{ 76 | Hash: []byte("01234567890123456789012345678901"), 77 | PartsHeader: types.PartSetHeader{ 78 | Hash: []byte("01234567890123456789012345678901"), 79 | Total: 123, 80 | }, 81 | }, 82 | Timestamp: time.Unix(123456789, 0).UTC(), 83 | }, 84 | } 85 | 86 | var varintValues = []int64{ 87 | 0, 88 | 1, 89 | 255, 90 | 256, 91 | 1234, 92 | 100000, 93 | // 10000000000, TODO: fix encoding 94 | } 95 | 96 | var blockIDValues = []types.BlockID{ 97 | types.BlockID{PartsHeader: types.PartSetHeader{}}, 98 | types.BlockID{ 99 | Hash: []byte("01234567890123456789012345678901"), 100 | PartsHeader: types.PartSetHeader{ 101 | Hash: []byte("01234567890123456789012345678901"), 102 | Total: 1, 103 | }, 104 | }, 105 | types.BlockID{ 106 | Hash: []byte("01234567890123456789012345678901"), 107 | PartsHeader: types.PartSetHeader{ 108 | Hash: []byte("01234567890123456789012345678901"), 109 | Total: 123, 110 | }, 111 | }, 112 | } 113 | 114 | var pubkeyValue = ed25519.GenPrivKeyFromSecret([]byte("foo")).PubKey() 115 | 116 | var validatorHashInputs = []ValidatorHashInput{ 117 | ValidatorHashInput{ 118 | pubkeyValue, 119 | 1234, 120 | }, 121 | ValidatorHashInput{ 122 | pubkeyValue, 123 | 2000000, 124 | }, 125 | } 126 | 127 | type ValidatorHashInput struct { 128 | PubKey crypto.PubKey `json:"pub_key"` 129 | VotingPower int64 `json:"voting_power"` 130 | } 131 | 132 | type encoding struct { 133 | Value interface{} `json:"value"` 134 | Encoding string `json:"encoding"` 135 | } 136 | 137 | var cdc *amino.Codec 138 | var hktTimeZone *time.Location 139 | var timeValues []time.Time 140 | 141 | func init() { 142 | cdc = amino.NewCodec() 143 | 144 | cdc.RegisterInterface((*crypto.PubKey)(nil), nil) 145 | cdc.RegisterConcrete(ed25519.PubKeyEd25519{}, 146 | "tendermint/PubKeyEd25519", nil) 147 | 148 | var err error 149 | hktTimeZone, err = time.LoadLocation("Hongkong") 150 | if err != nil { 151 | panic(err) 152 | } 153 | 154 | timeValues = []time.Time{ 155 | time.Unix(123456789, 123456789).UTC(), 156 | time.Unix(123456789, 0).UTC(), 157 | time.Unix(0, 123456789).UTC(), 158 | time.Now().UTC(), 159 | } 160 | } 161 | 162 | func encodeVarints(values []int64) []encoding { 163 | encodings := make([]encoding, len(values)) 164 | for i, value := range values { 165 | buf := new(bytes.Buffer) 166 | err := amino.EncodeVarint(buf, value) 167 | if err != nil { 168 | panic(err) 169 | } 170 | encodings[i] = encoding{ 171 | Value: value, 172 | Encoding: hex.EncodeToString(buf.Bytes()), 173 | } 174 | } 175 | return encodings 176 | } 177 | 178 | func encodeVotes(values []types.Vote) []encoding { 179 | encodings := make([]encoding, len(values)) 180 | for i, value := range values { 181 | canonical := types.CanonicalizeVote("chain-id", &value) 182 | 183 | bz, err := cdc.MarshalBinaryBare(canonical) 184 | if err != nil { 185 | panic(err) 186 | } 187 | encodings[i] = encoding{ 188 | Value: value, 189 | Encoding: hex.EncodeToString(bz), 190 | } 191 | } 192 | return encodings 193 | } 194 | 195 | func encodeValidatorHashInputs(values []ValidatorHashInput) []encoding { 196 | encodings := make([]encoding, len(values)) 197 | for i, value := range values { 198 | bz, err := cdc.MarshalBinaryBare(value) 199 | if err != nil { 200 | panic(err) 201 | } 202 | // validatorHashInputFixtures, err := json.MarshalIndent(encoding{ 203 | // &value, 204 | // hex.EncodeToString(bz), 205 | // }, "", " ") 206 | // if err != nil { 207 | // panic(err) 208 | // } 209 | 210 | encodings[i] = encoding{ 211 | Value: value, 212 | Encoding: hex.EncodeToString(bz), 213 | } 214 | } 215 | return encodings 216 | } 217 | 218 | func encode(values []interface{}) []encoding { 219 | encodings := make([]encoding, len(values)) 220 | for i, value := range values { 221 | bz, err := cdc.MarshalBinaryBare(value) 222 | if err != nil { 223 | panic(err) 224 | } 225 | encodings[i] = encoding{ 226 | Value: value, 227 | Encoding: hex.EncodeToString(bz), 228 | } 229 | } 230 | return encodings 231 | } 232 | 233 | func generateJSON(encodings []encoding) []byte { 234 | output, err := json.MarshalIndent(encodings, "", " ") 235 | if err != nil { 236 | panic(err) 237 | } 238 | return output 239 | } 240 | 241 | func main() { 242 | filePerm := os.FileMode(0644) 243 | 244 | versionIValues := make([]interface{}, len(versionValues)) 245 | for i, v := range versionValues { 246 | versionIValues[i] = v 247 | } 248 | versionFixtures := generateJSON(encode(versionIValues)) 249 | ioutil.WriteFile("test/fixtures/version.json", versionFixtures, filePerm) 250 | 251 | varintFixtures := generateJSON(encodeVarints(varintValues)) 252 | ioutil.WriteFile("test/fixtures/varint.json", varintFixtures, filePerm) 253 | 254 | voteFixtures := generateJSON(encodeVotes(voteValues)) 255 | ioutil.WriteFile("test/fixtures/vote.json", voteFixtures, filePerm) 256 | 257 | timeIValues := make([]interface{}, len(timeValues)) 258 | for i, v := range timeValues { 259 | timeIValues[i] = v 260 | } 261 | timeFixtures := generateJSON(encode(timeIValues)) 262 | ioutil.WriteFile("test/fixtures/time.json", timeFixtures, filePerm) 263 | 264 | blockIDIValues := make([]interface{}, len(blockIDValues)) 265 | for i, v := range blockIDValues { 266 | blockIDIValues[i] = v 267 | } 268 | blockIDFixtures := generateJSON(encode(blockIDIValues)) 269 | ioutil.WriteFile("test/fixtures/block_id.json", blockIDFixtures, filePerm) 270 | 271 | pubkeyBytes, err := cdc.MarshalBinaryBare(pubkeyValue) 272 | if err != nil { 273 | panic(err) 274 | } 275 | pubkeyFixtures, err := cdc.MarshalJSONIndent(struct { 276 | Value *crypto.PubKey `json:"value"` 277 | Encoding string `json:"encoding"` 278 | }{ 279 | &pubkeyValue, 280 | hex.EncodeToString(pubkeyBytes), 281 | }, "", " ") 282 | if err != nil { 283 | panic(err) 284 | } 285 | ioutil.WriteFile("test/fixtures/pubkey.json", pubkeyFixtures, filePerm) 286 | 287 | validatorHashInputFixtures := generateJSON(encodeValidatorHashInputs(validatorHashInputs)) 288 | ioutil.WriteFile("test/fixtures/validator_hash_input.json", validatorHashInputFixtures, filePerm) 289 | } 290 | -------------------------------------------------------------------------------- /src/verify.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const stringify = require('json-stable-stringify') 4 | const ed25519 = require('supercop.js') 5 | // TODO: try to load native ed25519 implementation, fall back to supercop.js 6 | const { 7 | getBlockHash, 8 | getValidatorSetHash 9 | } = require('./hash.js') 10 | const { 11 | VarBuffer, 12 | CanonicalVote 13 | } = require('./types.js') 14 | const { getAddress } = require('./pubkey.js') 15 | const { safeParseInt } = require('./common.js') 16 | 17 | // gets the serialized representation of a vote, which is used 18 | // in the commit signatures 19 | function getVoteSignBytes (chainId, vote) { 20 | let canonicalVote = Object.assign({}, vote) 21 | canonicalVote.chain_id = chainId 22 | canonicalVote.height = safeParseInt(vote.height) 23 | canonicalVote.round = safeParseInt(vote.round) 24 | canonicalVote.block_id.parts.total = safeParseInt(vote.block_id.parts.total) 25 | if (vote.validator_index) { 26 | canonicalVote.validator_index = safeParseInt(vote.validator_index) 27 | } 28 | let encodedVote = CanonicalVote.encode(canonicalVote) 29 | return VarBuffer.encode(encodedVote) 30 | } 31 | 32 | // verifies that a number is a positive integer, less than the 33 | // maximum safe JS integer 34 | function verifyPositiveInt (n) { 35 | if (!Number.isInteger(n)) { 36 | throw Error('Value must be an integer') 37 | } 38 | if (n > Number.MAX_SAFE_INTEGER) { 39 | throw Error('Value must be < 2^53') 40 | } 41 | if (n < 0) { 42 | throw Error('Value must be >= 0') 43 | } 44 | } 45 | 46 | // verifies a commit signs the given header, with 2/3+ of 47 | // the voting power from given validator set 48 | // 49 | // This is for Tendermint v0.33.0 and later 50 | function verifyCommit (header, commit, validators) { 51 | let blockHash = getBlockHash(header) 52 | 53 | if (blockHash !== commit.block_id.hash) { 54 | throw Error('Commit does not match block hash') 55 | } 56 | 57 | let countedValidators = new Set() 58 | 59 | for (let signature of commit.signatures) { 60 | // ensure there are never multiple signatures from a single validator 61 | let validator_address = signature.validator_address 62 | if (countedValidators.has(validator_address)) { 63 | throw Error('Validator has multiple signatures') 64 | } 65 | countedValidators.add(signature.validator_address) 66 | } 67 | 68 | // ensure this signature references at least one validator 69 | let validator = validators.find((v) => countedValidators.has(v.address)) 70 | if (!validator) { 71 | throw Error('No recognized validators have signatures') 72 | } 73 | 74 | verifyCommitSigs(header, commit, validators) 75 | } 76 | 77 | 78 | // verifies a commit is signed by at least 2/3+ of the voting 79 | // power of the given validator set 80 | // 81 | // This is for Tendermint v0.33.0 and later 82 | function verifyCommitSigs (header, commit, validators) { 83 | let committedVotingPower = 0 84 | 85 | // index validators by address 86 | let validatorsByAddress = new Map() 87 | for (let validator of validators) { 88 | validatorsByAddress.set(validator.address, validator) 89 | } 90 | 91 | const PrecommitType = 2; 92 | const BlockIDFlagAbsent = 1; 93 | const BlockIDFlagCommit = 2; 94 | const BlockIDFlagNil = 3; 95 | 96 | for (let cs of commit.signatures) { 97 | switch (cs.block_id_flag) { 98 | case BlockIDFlagAbsent: 99 | case BlockIDFlagCommit: 100 | case BlockIDFlagNil: 101 | break; 102 | 103 | default: 104 | throw Error(`unknown block_id_flag: ${cs.block_id_flag}`) 105 | } 106 | 107 | let validator = validatorsByAddress.get(cs.validator_address) 108 | 109 | // skip if this validator isn't in the set 110 | // (we allow signatures from validators not in the set, 111 | // because we sometimes check the commit against older 112 | // validator sets) 113 | if (!validator) continue 114 | 115 | let signature = Buffer.from(cs.signature, 'base64') 116 | let vote = { 117 | type: PrecommitType, 118 | timestamp: cs.timestamp, 119 | block_id: commit.block_id, 120 | height: commit.height, 121 | round: commit.round, 122 | } 123 | let signBytes = getVoteSignBytes(header.chain_id, vote) 124 | // TODO: support secp256k1 signatures 125 | let pubKey = Buffer.from(validator.pub_key.value, 'base64') 126 | if (!ed25519.verify(signature, signBytes, pubKey)) { 127 | throw Error('Invalid signature') 128 | } 129 | 130 | // count this validator's voting power 131 | committedVotingPower += safeParseInt(validator.voting_power) 132 | } 133 | 134 | // sum all validators' voting power 135 | let totalVotingPower = validators.reduce( 136 | (sum, v) => sum + safeParseInt(v.voting_power), 0) 137 | // JS numbers have no loss of precision up to 2^53, but we 138 | // error at over 2^52 since we have to do arithmetic. apps 139 | // should be able to keep voting power lower than this anyway 140 | if (totalVotingPower > 2 ** 52) { 141 | throw Error('Total voting power must be less than 2^52') 142 | } 143 | 144 | // verify enough voting power signed 145 | let twoThirds = Math.ceil(totalVotingPower * 2 / 3) 146 | if (committedVotingPower < twoThirds) { 147 | let error = Error('Not enough committed voting power') 148 | error.insufficientVotingPower = true 149 | throw error 150 | } 151 | } 152 | 153 | // verifies that a validator set is in the correct format 154 | // and hashes to the correct value 155 | function verifyValidatorSet (validators, expectedHash) { 156 | for (let validator of validators) { 157 | if (getAddress(validator.pub_key) !== validator.address) { 158 | throw Error('Validator address does not match pubkey') 159 | } 160 | 161 | validator.voting_power = safeParseInt(validator.voting_power) 162 | verifyPositiveInt(validator.voting_power) 163 | if (validator.voting_power === 0) { 164 | throw Error('Validator voting power must be > 0') 165 | } 166 | } 167 | 168 | let validatorSetHash = getValidatorSetHash(validators) 169 | if (expectedHash != null && validatorSetHash !== expectedHash) { 170 | throw Error('Validator set does not match what we expected') 171 | } 172 | } 173 | 174 | // verifies transition from one block to a higher one, given 175 | // each block's header, commit, and validator set 176 | function verify (oldState, newState) { 177 | let oldHeader = oldState.header 178 | let oldValidators = oldState.validators 179 | let newHeader = newState.header 180 | let newValidators = newState.validators 181 | 182 | if (newHeader.chain_id !== oldHeader.chain_id) { 183 | throw Error('Chain IDs do not match') 184 | } 185 | if (newHeader.height <= oldHeader.height) { 186 | throw Error('New state height must be higher than old state height') 187 | } 188 | 189 | let validatorSetChanged = 190 | newHeader.validators_hash !== oldHeader.validators_hash 191 | if (validatorSetChanged && newValidators == null) { 192 | throw Error('Must specify new validator set') 193 | } 194 | 195 | // make sure new header has a valid commit 196 | let validators = validatorSetChanged 197 | ? newValidators : oldValidators 198 | verifyCommit(newHeader, newState.commit, validators) 199 | 200 | if (validatorSetChanged) { 201 | // make sure new validator set is valid 202 | 203 | // make sure new validator set has correct hash 204 | verifyValidatorSet(newValidators, newHeader.validators_hash) 205 | 206 | // if previous state's `next_validators_hash` matches the new validator 207 | // set hash, then we already know it is valid 208 | if (oldHeader.next_validators_hash !== newHeader.validators_hash) { 209 | // otherwise, make sure new commit is signed by 2/3+ of old validator set. 210 | // sometimes we will take this path to skip ahead, we don't need any 211 | // headers between `oldState` and `newState` if this check passes 212 | verifyCommitSigs(newHeader, newState.commit, oldValidators) 213 | } 214 | 215 | // TODO: also pass transition if +2/3 of old validator set is still represented in commit 216 | } 217 | } 218 | 219 | module.exports = verify 220 | Object.assign(module.exports, { 221 | verifyCommit, 222 | verifyCommitSigs, 223 | verifyValidatorSet, 224 | verify, 225 | getVoteSignBytes 226 | }) 227 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const struct = require('varstruct') 4 | const { Int64LE } = struct 5 | const { VarInt, UVarInt } = require('./varint.js') 6 | 7 | const VarString = struct.VarString(UVarInt) 8 | const VarBuffer = struct.VarBuffer(UVarInt) 9 | 10 | const VarHexBuffer = { 11 | decode () { 12 | throw Error('Decode not implemented') 13 | }, 14 | encode (value, buffer, offset) { 15 | value = Buffer.from(value, 'hex') 16 | let bytes = VarBuffer.encode(value, buffer, offset) 17 | VarHexBuffer.encode.bytes = VarBuffer.encode.bytes 18 | return bytes 19 | }, 20 | encodingLength (value) { 21 | let length = value.length / 2 22 | return length + UVarInt.encodingLength(length) 23 | } 24 | } 25 | 26 | const Time = { 27 | encode (value, buffer, offset = 0) { 28 | if (value[value.length - 1] !== 'Z') { 29 | throw Error('Timestamp must be UTC timezone') 30 | } 31 | 32 | let length = Time.encodingLength(value) 33 | buffer = buffer || Buffer.alloc(length) 34 | 35 | let { seconds, nanoseconds } = Time.getComponents(value) 36 | 37 | // seconds field 38 | if (seconds) { 39 | buffer[offset] = 0x08 40 | UVarInt.encode(seconds, buffer, offset + 1) 41 | offset += UVarInt.encode.bytes + 1 42 | } 43 | 44 | // nanoseconds field 45 | if (nanoseconds) { 46 | buffer[offset] = 0x10 47 | UVarInt.encode(nanoseconds, buffer, offset + 1) 48 | } 49 | 50 | Time.encode.bytes = length 51 | return buffer 52 | }, 53 | 54 | encodingLength (value) { 55 | let { seconds, nanoseconds } = Time.getComponents(value) 56 | 57 | let length = 0 58 | if (seconds) { 59 | length += 1 + UVarInt.encodingLength(seconds) 60 | } 61 | if (nanoseconds) { 62 | length += 1 + UVarInt.encodingLength(nanoseconds) 63 | } 64 | return length 65 | }, 66 | 67 | getComponents (value) { 68 | let millis = new Date(value).getTime() 69 | let seconds = Math.floor(millis / 1000) 70 | 71 | // ghetto, we're pulling the nanoseconds from the string 72 | let withoutZone = value.slice(0, -1) 73 | let nanosStr = withoutZone.split('.')[1] || '' 74 | let nanoseconds = Number(nanosStr.padEnd(9, '0')) 75 | 76 | return { seconds, nanoseconds } 77 | } 78 | } 79 | 80 | const BlockID = { 81 | encode (value, buffer, offset = 0) { 82 | let length = BlockID.encodingLength(value) 83 | buffer = buffer || Buffer.alloc(length) 84 | 85 | // TODO: actually do amino encoding stuff 86 | 87 | // hash field 88 | if (value.hash) { 89 | let hash = Buffer.from(value.hash, 'hex') 90 | buffer[offset + 0] = 0x0a 91 | buffer[offset + 1] = hash.length 92 | hash.copy(buffer, offset + 2) 93 | offset += hash.length + 2 94 | } 95 | 96 | // block parts 97 | if (value.parts && value.parts.hash) { 98 | let partsHash = Buffer.from(value.parts.hash, 'hex') 99 | buffer[offset] = 0x12 100 | buffer[offset + 1] = partsHash.length + 4 101 | 102 | buffer[offset + 2] = 0x08 103 | buffer[offset + 3] = value.parts.total 104 | 105 | buffer[offset + 4] = 0x12 106 | buffer[offset + 5] = partsHash.length 107 | partsHash.copy(buffer, offset + 6) 108 | offset += partsHash.length + 4 109 | } 110 | 111 | CanonicalBlockID.encode.bytes = length 112 | return buffer 113 | }, 114 | 115 | encodingLength (value) { 116 | let length = 0 117 | if (value.hash) length += value.hash.length / 2 + 2 118 | if (value.parts && value.parts.hash) { 119 | length += value.parts.hash.length / 2 + 6 120 | } 121 | return length 122 | } 123 | } 124 | 125 | const CanonicalBlockID = { 126 | encode (value, buffer, offset = 0) { 127 | let length = CanonicalBlockID.encodingLength(value) 128 | buffer = buffer || Buffer.alloc(length) 129 | 130 | // TODO: actually do amino encoding stuff 131 | 132 | // hash field 133 | let hash = Buffer.from(value.hash, 'hex') 134 | buffer[offset + 0] = 0x0a 135 | buffer[offset + 1] = hash.length 136 | hash.copy(buffer, offset + 2) 137 | offset += hash.length + 2 138 | 139 | // block parts 140 | let partsHash = Buffer.from(value.parts.hash, 'hex') 141 | buffer[offset] = 0x12 142 | buffer[offset + 1] = partsHash.length + 4 143 | buffer[offset + 2] = 0x0a 144 | buffer[offset + 3] = partsHash.length 145 | partsHash.copy(buffer, offset + 4) 146 | offset += partsHash.length + 4 147 | 148 | buffer[offset] = 0x10 149 | buffer[offset + 1] = value.parts.total 150 | 151 | CanonicalBlockID.encode.bytes = length 152 | return buffer 153 | }, 154 | 155 | encodingLength (value) { 156 | return (value.hash.length / 2) + 157 | (value.parts.hash.length / 2) + 158 | 8 159 | } 160 | } 161 | 162 | const TreeHashInput = struct([ 163 | { name: 'left', type: VarBuffer }, 164 | { name: 'right', type: VarBuffer } 165 | ]) 166 | 167 | // TODO: support secp keys (separate prefix) 168 | const pubkeyAminoPrefix = Buffer.from('1624DE6420', 'hex') 169 | const PubKey = { 170 | decode (buffer, start = 0, end = buffer.length) { 171 | throw Error('Decode not implemented') 172 | }, 173 | encode (pub, buffer, offset = 0) { 174 | let length = PubKey.encodingLength(pub) 175 | buffer = buffer || Buffer.alloc(length) 176 | if (pub == null) { 177 | buffer[offset] = 0 178 | } else { 179 | pubkeyAminoPrefix.copy(buffer, offset) 180 | Buffer.from(pub.value, 'base64') 181 | .copy(buffer, offset + pubkeyAminoPrefix.length) 182 | } 183 | PubKey.encode.bytes = length 184 | return buffer 185 | }, 186 | encodingLength (pub) { 187 | if (pub == null) return 1 188 | return 37 189 | } 190 | } 191 | 192 | const ValidatorHashInput = { 193 | decode (buffer, start = 0, end = buffer.length) { 194 | throw Error('Decode not implemented') 195 | }, 196 | encode (validator) { 197 | let length = ValidatorHashInput.encodingLength(validator) 198 | let buffer = Buffer.alloc(length) 199 | 200 | // pubkey field 201 | buffer[0] = 0x0a 202 | buffer[1] = 0x25 203 | PubKey.encode(validator.pub_key, buffer, 2) 204 | 205 | // TODO: handle pubkeys of different length 206 | 207 | // voting power field 208 | buffer[39] = 0x10 209 | UVarInt.encode(validator.voting_power, buffer, 40) 210 | 211 | ValidatorHashInput.encode.bytes = length 212 | return buffer 213 | }, 214 | encodingLength (validator) { 215 | return 40 + UVarInt.encodingLength(validator.voting_power) 216 | } 217 | } 218 | 219 | const CanonicalVote = { 220 | decode (buffer, start = 0, end = buffer.length) { 221 | throw Error('Decode not implemented') 222 | }, 223 | encode (vote) { 224 | let length = CanonicalVote.encodingLength(vote) 225 | let buffer = Buffer.alloc(length) 226 | let offset = 0 227 | 228 | // type field 229 | if (Number(vote.type)) { 230 | buffer[offset] = 0x08 231 | buffer.writeUInt8(vote.type, offset + 1) 232 | offset += 2 233 | } 234 | 235 | // height field 236 | if (Number(vote.height)) { 237 | buffer[offset] = 0x11 238 | Int64LE.encode(vote.height, buffer, offset + 1) 239 | offset += 9 240 | } 241 | 242 | // round field 243 | if (Number(vote.round)) { 244 | buffer[offset] = 0x19 245 | Int64LE.encode(vote.round, buffer, offset + 1) 246 | offset += 9 247 | } 248 | 249 | // block_id field 250 | if (vote.block_id && vote.block_id.hash) { 251 | buffer[offset] = 0x22 252 | CanonicalBlockID.encode(vote.block_id, buffer, offset + 2) 253 | buffer[offset + 1] = CanonicalBlockID.encode.bytes 254 | offset += CanonicalBlockID.encode.bytes + 2 255 | } 256 | 257 | // time field 258 | buffer[offset] = 0x2a 259 | Time.encode(vote.timestamp, buffer, offset + 2) 260 | buffer[offset + 1] = Time.encode.bytes 261 | offset += Time.encode.bytes + 2 262 | 263 | // chain_id field 264 | buffer[offset] = 0x32 265 | buffer.writeUInt8(vote.chain_id.length, offset + 1) 266 | Buffer.from(vote.chain_id).copy(buffer, offset + 2) 267 | 268 | CanonicalVote.encode.bytes = length 269 | return buffer 270 | }, 271 | encodingLength (vote) { 272 | let length = 0 273 | 274 | // type field 275 | if (Number(vote.type)) { 276 | length += 2 277 | } 278 | 279 | // height field 280 | if (Number(vote.height)) { 281 | length += 9 282 | } 283 | 284 | // round field 285 | if (Number(vote.round)) { 286 | length += 9 287 | } 288 | 289 | // block_id field 290 | if (vote.block_id && vote.block_id.hash) { 291 | length += CanonicalBlockID.encodingLength(vote.block_id) + 2 292 | } 293 | 294 | // time field 295 | length += Time.encodingLength(vote.timestamp) + 2 296 | 297 | // chain_id field 298 | length += vote.chain_id.length + 2 299 | 300 | return length 301 | } 302 | } 303 | 304 | const Version = { 305 | decode (buffer, start = 0, end = buffer.length) { 306 | throw Error('Decode not implemented') 307 | }, 308 | encode (version) { 309 | let length = Version.encodingLength(version) 310 | let buffer = Buffer.alloc(length) 311 | let offset = 0 312 | 313 | let block = Number(version.block) 314 | let app = Number(version.app) 315 | 316 | // block field 317 | if (block) { 318 | buffer[offset] = 0x08 319 | UVarInt.encode(version.block, buffer, offset + 1) 320 | offset += UVarInt.encode.bytes + 1 321 | } 322 | 323 | // app field 324 | if (app) { 325 | buffer[offset] = 0x10 326 | UVarInt.encode(version.app, buffer, offset + 1) 327 | } 328 | 329 | CanonicalVote.encode.bytes = length 330 | return buffer 331 | }, 332 | encodingLength (version) { 333 | let block = Number(version.block) 334 | let app = Number(version.app) 335 | 336 | let length = 0 337 | if (block) { 338 | length += UVarInt.encodingLength(version.block) + 1 339 | } 340 | if (app) { 341 | length += UVarInt.encodingLength(version.app) + 1 342 | } 343 | return length 344 | } 345 | } 346 | 347 | module.exports = { 348 | VarInt, 349 | UVarInt, 350 | VarString, 351 | VarBuffer, 352 | VarHexBuffer, 353 | Time, 354 | BlockID, 355 | CanonicalBlockID, 356 | TreeHashInput, 357 | ValidatorHashInput, 358 | PubKey, 359 | Int64LE, 360 | CanonicalVote, 361 | Version 362 | } 363 | --------------------------------------------------------------------------------