├── logs └── .gitignore ├── .env.example ├── .eslintignore ├── Dockerfile ├── client ├── static │ └── logo.png ├── scss │ ├── _variables.scss │ └── index.scss ├── components │ ├── 404.vue │ ├── Navbar.vue │ ├── partials │ │ ├── Transactions.vue │ │ └── SendForm.vue │ ├── Transaction.vue │ ├── Address.vue │ ├── Wallets.vue │ ├── Spinner.vue │ ├── Block.vue │ ├── Status.vue │ └── Toast.vue ├── index.html ├── main.js ├── App.vue ├── router.js ├── vuex │ └── toast.js ├── ws.js └── store.js ├── .babelrc ├── src ├── bus.js ├── errors.js ├── config.js ├── lib │ ├── chain.js │ ├── wallet.js │ ├── block.js │ └── transaction.js ├── index.js ├── miner.js ├── peers.js ├── store.js └── server.js ├── .travis.yml ├── .postcssrc.js ├── .editorconfig ├── .gitignore ├── process.yml ├── .github └── workflows │ └── test.yml ├── .eslintrc.js ├── test ├── unit │ └── lib │ │ ├── wallet.js │ │ ├── block.js │ │ └── transaction.js └── feature │ └── store.js ├── LICENSE.md ├── docker-compose.yml ├── .snyk ├── README.md └── package.json /logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_ENV=local 2 | SENTRY_DSN=some-dsn 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/*.js 2 | src/front/config/*.js 3 | test 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8-alpine 2 | 3 | WORKDIR /app 4 | 5 | EXPOSE 3001 3002 3003 6 | -------------------------------------------------------------------------------- /client/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vedmant/my-little-bitcoin/HEAD/client/static/logo.png -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": ["@babel/plugin-transform-runtime"], 4 | "comments": false 5 | } 6 | -------------------------------------------------------------------------------- /src/bus.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events') 2 | 3 | class Bus extends EventEmitter { 4 | } 5 | 6 | module.exports = new Bus() 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 14 5 | 6 | cache: yarn 7 | 8 | before_script: 9 | - yarn 10 | 11 | script: 12 | - yarn test 13 | -------------------------------------------------------------------------------- /client/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | // Body 2 | $body-bg: #f5f8fa; 3 | 4 | // Typography 5 | $font-family-sans-serif: "Raleway", sans-serif; 6 | $font-size-base: 0.85rem; 7 | -------------------------------------------------------------------------------- /client/components/404.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /.postcssrc.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | plugins: [ 4 | require('autoprefixer')({ 5 | browsers: ['last 3 versions'], 6 | }), 7 | require('precss'), 8 | ], 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | class BlockError extends Error {} 2 | 3 | class TransactionError extends Error {} 4 | 5 | class GeneralError extends Error {} 6 | 7 | module.exports = {BlockError, TransactionError, GeneralError} 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | test/unit/coverage 8 | test/e2e/reports 9 | selenium-debug.log 10 | 11 | # Editor directories and files 12 | .idea 13 | .vscode 14 | *.suo 15 | *.ntvs* 16 | *.njsproj 17 | *.sln 18 | 19 | .env 20 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | httpPort: process.env.HTTP_PORT || 3001, 3 | httpHost: process.env.HTTP_HOST || 'localhost', 4 | p2pPort: process.env.P2P_PORT || 6001, 5 | initialPeers: process.env.PEERS ? process.env.PEERS.split(',') : [], 6 | miningReward: 50, 7 | demoMode: process.env.DEMO || false, 8 | } 9 | -------------------------------------------------------------------------------- /process.yml: -------------------------------------------------------------------------------- 1 | apps: 2 | - name: mlb 3 | script: src/index.js 4 | node_args: --max_old_space_size=20 5 | exec_mode: fork 6 | error_file: logs/app.log 7 | max_memory_restart: 120M 8 | cron_restart: "0 0 */2 * *" 9 | env: 10 | DEMO: true 11 | NODE_ENV: production 12 | HTTP_PORT: 3001 13 | -------------------------------------------------------------------------------- /src/lib/chain.js: -------------------------------------------------------------------------------- 1 | const {isBlockValid} = require('./block') 2 | 3 | function isChainValid (chain, difficulty) { 4 | for (let i = 1; i < chain.length; i++) { 5 | if (! isBlockValid(chain[i - 1], chain[i], difficulty)) { 6 | return false 7 | } 8 | } 9 | 10 | return true 11 | } 12 | 13 | module.exports = {isChainValid} 14 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | My Little Bitcoin 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /client/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | import router from './router' 4 | import store from './store' 5 | import './ws' 6 | import 'jquery' 7 | import BootstrapVue from 'bootstrap-vue' 8 | import './scss/index.scss' 9 | 10 | Vue.use(BootstrapVue) 11 | 12 | Vue.config.productionTip = false 13 | 14 | /* eslint-disable no-new */ 15 | new Vue({ 16 | el: '#app', 17 | router, 18 | store, 19 | render: h => h(App), 20 | }) 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | tests: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: '16' 15 | cache: 'yarn' 16 | 17 | - name: Yarn Install 18 | run: yarn install # --check-files --frozen-lockfile --non-interactive 19 | 20 | - name: Lint 21 | run: yarn lint 22 | 23 | - name: Test 24 | run: yarn test 25 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const Sentry = require('@sentry/node') 3 | global.debug = require('debug')('app:global') 4 | const config = require('./config') 5 | const bus = require('./bus') 6 | const store = require('./store')(config, bus) 7 | const miner = require('./miner')(config, bus, store) 8 | require('./server')(config, bus, store, miner).start() 9 | 10 | if (process.env.APP_ENV === 'production') { 11 | Sentry.init({dsn: process.env.SENTRY_DSN}) 12 | } 13 | 14 | if (config.demoMode) { 15 | miner.mine(store.wallets[0]) 16 | } else { 17 | require('./peers')(config, bus, store).start() // Connect to peers and recieve connections 18 | } 19 | -------------------------------------------------------------------------------- /client/App.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 33 | -------------------------------------------------------------------------------- /client/router.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | import Status from './components/Status.vue' 4 | import Block from './components/Block.vue' 5 | import Address from './components/Address.vue' 6 | import Transaction from './components/Transaction.vue' 7 | import Wallets from './components/Wallets.vue' 8 | import Page404 from './components/404.vue' 9 | 10 | Vue.use(Router) 11 | 12 | export default new Router({ 13 | routes: [ 14 | {path: '/', name: 'Status', component: Status}, 15 | {path: '/block/:index', name: 'Block', component: Block}, 16 | {path: '/address/:address', name: 'Address', component: Address}, 17 | {path: '/transaction/:id', name: 'Transaction', component: Transaction}, 18 | {path: '/wallets', name: 'Wallets', component: Wallets}, 19 | {path: '*', component: Page404}, 20 | ], 21 | }) 22 | -------------------------------------------------------------------------------- /client/vuex/toast.js: -------------------------------------------------------------------------------- 1 | let maxToastId = 0 2 | 3 | const state = { 4 | messages: [], 5 | } 6 | 7 | const getters = { 8 | toastMessages: (state) => state.messages, 9 | } 10 | 11 | const actions = { 12 | addToastMessage ({commit}, {text, type = 'info', dismissAfter = 5000}) { 13 | const id = ++maxToastId 14 | 15 | commit('ADD_TOAST_MESSAGE', { 16 | id, 17 | text, 18 | type, 19 | dismissAfter, 20 | }) 21 | setTimeout(() => commit('REMOVE_TOAST_MESSAGE', id), dismissAfter) 22 | }, 23 | 24 | removeToastMessage ({commit}, id) { 25 | commit('REMOVE_TOAST_MESSAGE', id) 26 | }, 27 | } 28 | 29 | const mutations = { 30 | ADD_TOAST_MESSAGE (state, data) { 31 | state.messages.push(data) 32 | }, 33 | 34 | REMOVE_TOAST_MESSAGE (state, id) { 35 | state.messages = state.messages.filter(m => m.id !== id) 36 | }, 37 | } 38 | 39 | export default { 40 | state, 41 | getters, 42 | actions, 43 | mutations, 44 | } 45 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // http://eslint.org/docs/user-guide/configuring 2 | 3 | module.exports = { 4 | root: true, 5 | parser: 'babel-eslint', 6 | parserOptions: { 7 | sourceType: 'module' 8 | }, 9 | env: { 10 | browser: true, 11 | }, 12 | // https://github.com/standard/standard/blob/master/docs/RULES-en.md 13 | extends: 'standard', 14 | // required to lint *.vue files 15 | plugins: [ 16 | 'html' 17 | ], 18 | // add your custom rules here 19 | 'rules': { 20 | // allow paren-less arrow functions 21 | 'arrow-parens': 0, 22 | // allow async-await 23 | 'generator-star-spacing': 0, 24 | // allow debugger during development 25 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0, 26 | 27 | 'comma-dangle': 0, 28 | 29 | 'no-return-assign': 0, 30 | 31 | 'space-unary-ops': [2, { 32 | 'words': true, 33 | 'nonwords': true, 34 | 'overrides': { 35 | '++': false, 36 | '--': false, 37 | '-':false, 38 | }, 39 | }], 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /client/ws.js: -------------------------------------------------------------------------------- 1 | import io from 'socket.io-client' 2 | import store from './store' 3 | 4 | const socket = io() 5 | 6 | socket.on('block-added', (block) => { 7 | store.commit('ADD_BLOCK', block) 8 | store.commit('CLEAN_MEMPOOL', block.transactions) 9 | }) 10 | 11 | socket.on('block-added-by-me', (block) => { 12 | store.dispatch('addToastMessage', {text: `You mined a new block, index: ${block.index}`, type: 'success'}) 13 | store.commit('ADD_BLOCK', block) 14 | store.commit('CLEAN_MEMPOOL', block.transactions) 15 | }) 16 | 17 | socket.on('transaction-added', (transaction) => store.commit('ADD_TRANSACTION', transaction)) 18 | socket.on('balance-updated', (balance) => store.commit('UPDATE_BALANCE', balance)) 19 | socket.on('mine-started', () => store.commit('MINE_START')) 20 | socket.on('mine-stopped', () => store.commit('MINE_STOP')) 21 | socket.on('recieved-funds', (data) => { 22 | store.commit('RECIEVED_FUNDS', data) 23 | store.dispatch('addToastMessage', {text: `You just recieved ${data.amount} MLB on wallet: ${data.name}!`, type: 'success'}) 24 | }) 25 | -------------------------------------------------------------------------------- /test/unit/lib/wallet.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | import CryptoJS from 'crypto-js' 3 | import {generateKeyPair, signHash, verifySignature} from '../../../src/lib/wallet' 4 | 5 | describe('wallet lib', () => { 6 | 7 | let wallet 8 | 9 | beforeEach(() => { 10 | wallet = generateKeyPair() 11 | }) 12 | 13 | it('should generate key pair', () => { 14 | expect(generateKeyPair()).to.have.all.keys('private', 'public') 15 | }) 16 | 17 | it('should sign hash', () => { 18 | expect(signHash(wallet.private, CryptoJS.SHA256('123').toString())).to.be.a('string') 19 | }) 20 | 21 | it('should verify signature', () => { 22 | const hash = CryptoJS.SHA256('123').toString() 23 | const signature = signHash(wallet.private, hash) 24 | expect(verifySignature(wallet.public, signature, hash)).to.be.true 25 | }) 26 | 27 | it('should not verify wrong signature', () => { 28 | const hash = CryptoJS.SHA256('123').toString() 29 | const signature = signHash(wallet.private, hash) 30 | expect(verifySignature(wallet.public, '0F' + signature.substring(2), hash)).to.be.false 31 | }) 32 | 33 | }) 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2016-2017 Vedmant, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | 4 | node1: 5 | container_name: node1 6 | build: . 7 | volumes: 8 | - .:/app 9 | ports: 10 | - "3001:3001" 11 | environment: 12 | - HTTP_HOST=0.0.0.0 13 | - DEBUG=app* 14 | tty: true 15 | command: /app/node_modules/.bin/nodemon src/index.js 16 | 17 | node2: 18 | container_name: node2 19 | build: . 20 | volumes: 21 | - .:/app 22 | ports: 23 | - "3002:3001" 24 | environment: 25 | - PEERS=ws://node1:6001 26 | - HTTP_HOST=0.0.0.0 27 | - DEBUG=app* 28 | links: 29 | - node1 30 | depends_on: 31 | - node1 32 | tty: true 33 | command: /app/node_modules/.bin/nodemon src/index.js 34 | 35 | node3: 36 | container_name: node3 37 | build: . 38 | volumes: 39 | - .:/app 40 | ports: 41 | - "3003:3001" 42 | environment: 43 | - PEERS=ws://node1:6001,ws://node2:6001 44 | - HTTP_HOST=0.0.0.0 45 | - DEBUG=app* 46 | links: 47 | - node1 48 | - node2 49 | depends_on: 50 | - node1 51 | - node2 52 | tty: true 53 | command: /app/node_modules/.bin/nodemon src/index.js 54 | -------------------------------------------------------------------------------- /client/scss/index.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | @import "~bootstrap/scss/bootstrap"; 3 | @import "~bootstrap-vue/dist/bootstrap-vue.css"; 4 | 5 | .page { 6 | padding-top: 3rem; 7 | } 8 | 9 | .smaller { 10 | font-size: 90%; 11 | } 12 | 13 | .vertical-middle { 14 | vertical-align: middle; 15 | } 16 | 17 | .github-menu-item { 18 | margin-bottom: -6px; 19 | @include media-breakpoint-down(xs) { 20 | margin-top: 15px; 21 | } 22 | } 23 | 24 | 25 | /** 26 | * Spinner 27 | */ 28 | .main-spinner { 29 | visibility: hidden; 30 | opacity: 0; 31 | transition: opacity 0.3s 0.3s ease; 32 | position: fixed; 33 | z-index: 99999; 34 | top: 0; 35 | left: 0; 36 | right: 0; 37 | bottom: 0; 38 | background-color: rgba(255, 255, 255, 0.5); 39 | } 40 | 41 | .main-spinner.active { 42 | visibility: visible; 43 | opacity: 1; 44 | } 45 | 46 | /** 47 | * Some bootstrap fixes 48 | */ 49 | .card > .card-header + .list-group > .list-group-item:first-child, 50 | .card > .list-group + .card-footer { 51 | border-top: 0; 52 | } 53 | 54 | a, button, .btn { 55 | cursor: pointer; 56 | } 57 | 58 | .pull-right { 59 | float: right; 60 | } 61 | 62 | .btn-xs { 63 | // line-height: ensure proper height of button next to extra small input 64 | @include button-size(0.2rem, 0.4rem, 0.8rem, 0.8rem, $border-radius); 65 | } 66 | -------------------------------------------------------------------------------- /src/lib/wallet.js: -------------------------------------------------------------------------------- 1 | const {randomBytes} = require('crypto') 2 | const secp256k1 = require('secp256k1') 3 | const bs58 = require('bs58') 4 | 5 | /** 6 | * Generate key pair 7 | * 8 | * @return {{private: string, public: string}} 9 | */ 10 | function generateKeyPair () { 11 | // Generate private key 12 | let privKey 13 | do { 14 | privKey = randomBytes(32) 15 | } while (! secp256k1.privateKeyVerify(privKey)) 16 | // Generate public key 17 | const pubKey = secp256k1.publicKeyCreate(privKey) 18 | 19 | return { 20 | private: privKey.toString('hex'), 21 | // Base58 format for public key, public key plays address role 22 | public: bs58.encode(pubKey), 23 | } 24 | } 25 | 26 | /** 27 | * Sign hex hash 28 | * 29 | * @param {string} privateKey 30 | * @param {string} hash 31 | * @return {string} 32 | */ 33 | function signHash (privateKey, hash) { 34 | return secp256k1.sign(Buffer.from(hash, 'hex'), Buffer.from(privateKey, 'hex')).signature.toString('base64') 35 | } 36 | 37 | /** 38 | * Verify hex hash signature 39 | * 40 | * @param {string} address 41 | * @param {string} signature 42 | * @param {string} hash 43 | * @return {bool} 44 | */ 45 | function verifySignature (address, signature, hash) { 46 | return secp256k1.verify(Buffer.from(hash, 'hex'), Buffer.from(signature, 'base64'), bs58.decode(address)) 47 | } 48 | 49 | module.exports = {generateKeyPair, signHash, verifySignature} 50 | -------------------------------------------------------------------------------- /client/components/Navbar.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 53 | -------------------------------------------------------------------------------- /client/components/partials/Transactions.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | -------------------------------------------------------------------------------- /client/components/partials/SendForm.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | -------------------------------------------------------------------------------- /client/components/Transaction.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | -------------------------------------------------------------------------------- /client/components/Address.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | -------------------------------------------------------------------------------- /client/components/Wallets.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.13.5 3 | # ignores vulnerabilities until expiry date; change duration by modifying expiry date 4 | ignore: 5 | 'npm:jquery:20170829': 6 | - jquery: 7 | reason: None given 8 | expires: '2017-12-18T10:02:20.175Z' 9 | 'npm:parsejson:20170908': 10 | - socket.io-client > engine.io-client > parsejson: 11 | reason: None given 12 | expires: '2017-12-18T10:02:20.175Z' 13 | - socket.io > socket.io-client > engine.io-client > parsejson: 14 | reason: None given 15 | expires: '2017-12-18T10:02:20.175Z' 16 | 'npm:hoek:20180212': 17 | - boom > hoek: 18 | reason: None given 19 | expires: '2018-05-14T05:53:12.212Z' 20 | - node-pre-gyp > request > hawk > hoek: 21 | reason: None given 22 | expires: '2018-05-14T05:53:12.212Z' 23 | - node-pre-gyp > request > hawk > boom > hoek: 24 | reason: None given 25 | expires: '2018-05-14T05:53:12.213Z' 26 | - node-pre-gyp > request > hawk > sntp > hoek: 27 | reason: None given 28 | expires: '2018-05-14T05:53:12.213Z' 29 | - node-pre-gyp > request > hawk > cryptiles > boom > hoek: 30 | reason: None given 31 | expires: '2018-05-14T05:53:12.213Z' 32 | 'npm:sshpk:20180409': 33 | - node-pre-gyp > request > http-signature > sshpk: 34 | reason: None given 35 | expires: '2018-05-14T05:53:12.213Z' 36 | 'npm:bootstrap:20180529': 37 | - bootstrap-vue > bootstrap: 38 | reason: None given 39 | expires: '2018-09-02T15:07:24.139Z' 40 | 'npm:chownr:20180731': 41 | - node-pre-gyp > tar > chownr: 42 | reason: None given 43 | expires: '2018-09-02T15:07:24.139Z' 44 | 'npm:open:20180512': 45 | - snyk > open: 46 | reason: None given 47 | expires: '2018-09-02T15:07:24.139Z' 48 | # patches apply the minimum changes required to fix a vulnerability 49 | patch: 50 | 'npm:debug:20170905': 51 | - node-pre-gyp > tar-pack > debug: 52 | patched: '2017-11-18T10:01:12.303Z' 53 | 'npm:tough-cookie:20170905': 54 | - node-pre-gyp > request > tough-cookie: 55 | patched: '2017-11-18T10:01:12.303Z' 56 | SNYK-JS-AXIOS-174505: 57 | - axios: 58 | patched: '2019-05-06T01:27:03.955Z' 59 | SNYK-JS-LODASH-450202: 60 | - lodash: 61 | patched: '2019-07-04T01:26:52.834Z' 62 | - express-winston > lodash: 63 | patched: '2019-07-04T01:26:52.834Z' 64 | - winston > async > lodash: 65 | patched: '2019-07-04T01:26:52.834Z' 66 | -------------------------------------------------------------------------------- /client/components/Spinner.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 36 | 37 | 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![actions workflow](https://github.com/vedmant/my-little-bitcoin/workflows/Test/badge.svg) 2 | 3 | # My Little Bitcoin 4 | 5 | > A simple cryptocurrency implementation on JavaScript in just about 650 lines of code (without comments and client). It also includes WEB GUI written on Vue.js where you can send coins and explore blockchain. 6 | 7 | This implementation is pretty naive and suitable only for studying purpose. 8 | 9 | [See Demo Here](https://my-little-bitcoin.vedmant.com/) 10 | 11 | ## Features 12 | 13 | - Blocks mining with reward and simple POW 14 | - Create transactions and send amount to address using unspent outputs 15 | - Peer to peer connection, blockchain synchronization 16 | - Multiple wallets, add new wallet feature 17 | - Demo mode mining to reduce CPU load 18 | - User interface with real time data change 19 | - Status page with latest blocks, current mempool, wallets list with balances 20 | - Chain explorer for blocks, addresses, transactions 21 | - Descriptive debug messages in develpment mode using debug package 22 | - Server requests logs with winston 23 | 24 | ### Installation ### 25 | 26 | ```bash 27 | git clone https://github.com/vedmant/my-little-bitcoin.git # To clone repo 28 | cd my-little-bitcoin 29 | yarn # Install dependencies 30 | yarn prod # Compile frontend resources 31 | 32 | yarn demo # Run in demo mode 33 | ``` 34 | 35 | And open it on: [http://localhost:3001/](http://localhost:3001/) 36 | 37 | ## Full list of backend run commands 38 | 39 | ```bash 40 | # Start demo node 41 | yarn demo 42 | 43 | # Start first node 44 | yarn start 45 | 46 | # Start second node 47 | yarn start2 48 | 49 | # Start third node 50 | yarn start3 51 | ``` 52 | 53 | ## Run 3 nodes with Docker 54 | 55 | This will require installed Docker to your PC, run: 56 | 57 | ```bash 58 | docker-compose up 59 | ``` 60 | 61 | It will build and run all needed containers and run application with exposed ports 3001, 3002, 3003. 62 | 63 | Then you can open 3 different nodes that will communicate between each other on: 64 | http://localhost:3001 65 | http://localhost:3002 66 | http://localhost:3003 67 | 68 | Start mining on one of the nodes to make network add new blocks, you can start mining on each node. Beware, it can consume a lot of CPU resources. 69 | 70 | ## Frontend development 71 | 72 | ```bash 73 | # serve with hot reload at localhost:8080 74 | yarn dev 75 | 76 | # build for production with minification 77 | yarn prod 78 | ``` 79 | 80 | ## TODO 81 | 82 | - Add unit tests 83 | - Add feature tests 84 | - Keep list of unspent transactions in a separate store array instead of filtering it on every request 85 | - Implement peers blockhains conflict resolution, download only needed part of chain since split 86 | - Add stats page with charts in UI, use separate chart module to cache chart data on server 87 | - Automatic difficulty adjustment to match 1 minute block time 88 | - Integrate NeDB to store blockchain persistently 89 | 90 | ### License ### 91 | 92 | And of course: 93 | 94 | [MIT](LICENSE.md) 95 | -------------------------------------------------------------------------------- /test/unit/lib/block.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | import {generateKeyPair} from '../../../src/lib/wallet' 3 | import {checkBlock, makeGenesisBlock, createBlock, calculateHash} from '../../../src/lib/block' 4 | import {BlockError} from '../../../src/errors' 5 | 6 | describe('block lib', () => { 7 | 8 | const genesisBlock = makeGenesisBlock() 9 | let validBlock; 10 | let invalidBlock; 11 | let wallet; 12 | 13 | beforeEach(() => { 14 | wallet = generateKeyPair() 15 | validBlock = createBlock([], genesisBlock, wallet) 16 | invalidBlock = createBlock([], genesisBlock, wallet) 17 | }) 18 | 19 | it('should create valid block', (done) => { 20 | checkBlock(genesisBlock, validBlock, Number.MAX_SAFE_INTEGER, []) 21 | done() 22 | }) 23 | 24 | describe('block data validation', () => { 25 | 26 | it('should fail on invalid index', (done) => { 27 | invalidBlock.index = 'test'; 28 | expectCheckBlockToThrow() 29 | done() 30 | }) 31 | 32 | it('should fail on invalid prevHash', (done) => { 33 | invalidBlock.prevHash = 'invalid hash'; 34 | expectCheckBlockToThrow() 35 | done() 36 | }) 37 | 38 | it('should fail on invalid time', (done) => { 39 | invalidBlock.time = 'invalid time'; 40 | expectCheckBlockToThrow() 41 | done() 42 | }) 43 | 44 | it('should fail on invalid time', (done) => { 45 | invalidBlock.time = 'invalid time'; 46 | expectCheckBlockToThrow() 47 | done() 48 | }) 49 | 50 | it('should fail on invalid nonce', (done) => { 51 | invalidBlock.nonce = 'invalid nonce'; 52 | expectCheckBlockToThrow() 53 | done() 54 | }) 55 | 56 | it('should fail on invalid hash', (done) => { 57 | invalidBlock.hash = 'invalid hash'; 58 | expectCheckBlockToThrow() 59 | done() 60 | }) 61 | }) 62 | 63 | describe('block verification', () => { 64 | 65 | it('should fail on incorrect index', (done) => { 66 | invalidBlock.index = 5 67 | expectCheckBlockToThrow() 68 | done() 69 | }) 70 | 71 | it('should fail on incorrect block prevHash', (done) => { 72 | invalidBlock.prevHash = calculateHash(invalidBlock) 73 | expectCheckBlockToThrow() 74 | done() 75 | }) 76 | 77 | it('should fail on incorrect block hash', (done) => { 78 | invalidBlock.nonce = 100 79 | expectCheckBlockToThrow() 80 | done() 81 | }) 82 | 83 | it('should fail on incorrect difficulty', (done) => { 84 | expectCheckBlockToThrow(100) 85 | done() 86 | }) 87 | 88 | }) 89 | 90 | 91 | /* ========================================================================= *\ 92 | * Helpers 93 | \* ========================================================================= */ 94 | 95 | function expectCheckBlockToThrow (difficulty = Number.MAX_SAFE_INTEGER) { 96 | expect(() => { 97 | checkBlock(genesisBlock, invalidBlock, difficulty, []) 98 | }).to.throw(BlockError) 99 | } 100 | 101 | }) 102 | -------------------------------------------------------------------------------- /src/lib/block.js: -------------------------------------------------------------------------------- 1 | const {BlockError} = require('../errors') 2 | const CryptoJS = require('crypto-js') 3 | const Joi = require('joi') 4 | const {checkTransactions, createRewardTransaction} = require('./transaction') 5 | 6 | const blockSchema = Joi.object().keys({ 7 | index: Joi.number(), // Transaction index or height 8 | prevHash: Joi.string().hex().length(64), // Hash of the previous block 9 | time: Joi.number(), // Current block timestamp 10 | transactions: Joi.array(), // List of transactions, included into the block 11 | nonce: Joi.number(), // Nonce, required for proof of work protocol 12 | hash: Joi.string().hex().length(64), // Current block hash 13 | }) 14 | 15 | /** 16 | * Validate block data 17 | * 18 | * @param block 19 | * @return {*} 20 | */ 21 | function isDataValid (block) { 22 | return Joi.validate(block, blockSchema).error === null 23 | } 24 | 25 | /** 26 | * Verify block 27 | * 28 | * @param previousBlock 29 | * @param block 30 | * @param difficulty 31 | * @param unspent 32 | */ 33 | function checkBlock (previousBlock, block, difficulty, unspent) { 34 | if (! isDataValid(block)) throw new BlockError('Invalid block data') 35 | const blockDifficulty = getDifficulty(block.hash) 36 | if (previousBlock.index + 1 !== block.index) throw new BlockError('Invalid block index') 37 | if (previousBlock.hash !== block.prevHash) throw new BlockError('Invalid block prevhash') 38 | if (calculateHash(block) !== block.hash) throw new BlockError('Invalid block hash') 39 | if (blockDifficulty > difficulty) throw new BlockError('Invalid block difficulty') 40 | checkTransactions(block.transactions, unspent) 41 | } 42 | 43 | /** 44 | * Generate block hash 45 | * 46 | * @param block 47 | */ 48 | function calculateHash ({index, prevHash, time, transactions, nonce}) { 49 | return CryptoJS.SHA256(JSON.stringify({index, prevHash, time, transactions, nonce})).toString() 50 | } 51 | 52 | /** 53 | * Create genesis block 54 | * 55 | * @return {{index: number, prevHash: string, time: number, transactions: Array, nonce: number}} 56 | */ 57 | function makeGenesisBlock () { 58 | const block = { 59 | index: 0, 60 | prevHash: '0', 61 | time: '1505759228', 62 | transactions: [], 63 | nonce: 0, 64 | } 65 | block.hash = calculateHash(block) 66 | 67 | return block 68 | } 69 | 70 | /** 71 | * Create new block 72 | * 73 | * @param transactions {array} 74 | * @param lastBlock {object} 75 | * @param wallet {{private: string, public: string}} 76 | * @return {{index: number, prevHash, time: number, transactions: Array, nonce: number}} 77 | */ 78 | function createBlock (transactions, lastBlock, wallet) { 79 | transactions = transactions.slice() 80 | transactions.push(createRewardTransaction(wallet)) 81 | const block = { 82 | index: lastBlock.index + 1, 83 | prevHash: lastBlock.hash, 84 | time: Math.floor(new Date().getTime() / 1000), 85 | transactions, 86 | nonce: 0, 87 | } 88 | block.hash = calculateHash(block) 89 | 90 | return block 91 | } 92 | 93 | /** 94 | * Get hash difficulty 95 | * 96 | * @param hash 97 | * @return {Number} 98 | */ 99 | function getDifficulty (hash) { 100 | return parseInt(hash.substring(0, 8), 16) 101 | } 102 | 103 | module.exports = {checkBlock, calculateHash, makeGenesisBlock, createBlock, getDifficulty} 104 | -------------------------------------------------------------------------------- /client/components/Block.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-little-bitcoin", 3 | "version": "1.1.0", 4 | "description": "Simple Bitcoin like crypto implementation", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "start": "cross-env DEBUG=app* nodemon src/index.js", 8 | "start2": "cross-env DEBUG=app* HTTP_PORT=3002 P2P_PORT=6002 PEERS=ws://localhost:6001 nodemon src/index.js", 9 | "start3": "cross-env DEBUG=app* HTTP_PORT=3003 P2P_PORT=6003 PEERS=ws://localhost:6001,ws://localhost:6002 nodemon src/index.js", 10 | "demo": "cross-env DEMO=true DEBUG=app* nodemon src/index.js", 11 | "dev": "NODE_OPTIONS=\"--openssl-legacy-provider\" webpack --progress --colors --config build/webpack.dev.config.js", 12 | "prod": "NODE_OPTIONS=\"--openssl-legacy-provider\" webpack --progress --colors --config build/webpack.prod.config.js", 13 | "test": "./node_modules/.bin/mocha --recursive --require @babel/register", 14 | "lint": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs" 15 | }, 16 | "author": "Vedmant", 17 | "license": "MIT", 18 | "dependencies": { 19 | "@sentry/node": "5.7.0", 20 | "axios": "^0.27.2", 21 | "body-parser": "^1.18.3", 22 | "bootstrap": "^4.3.1", 23 | "bootstrap-vue": "^2.21.2", 24 | "bs58": "^4.0.1", 25 | "co": "^4.6.0", 26 | "crypto-js": "^3.1.9-1", 27 | "debug": "^4.1.0", 28 | "dotenv": "^8.0.0", 29 | "express": "^4.16.4", 30 | "express-winston": "^3.0.1", 31 | "joi": "^14.3.0", 32 | "jquery": "^3.4.1", 33 | "lodash": "^4.17.19", 34 | "moment-mini": "^2.22.1", 35 | "octicons": "^8.2.0", 36 | "secp256k1": "^3.5.2", 37 | "socket.io": "^2.4.0", 38 | "socket.io-client": "^2.2.0", 39 | "tiny-worker": "^2.1.2", 40 | "vue": "^2.5.21", 41 | "vue-loader": "^15.4.2", 42 | "vue-router": "^3.0.2", 43 | "vuex": "^3.0.1", 44 | "winston": "^3.1.0", 45 | "ws": "^6.1.2" 46 | }, 47 | "devDependencies": { 48 | "@babel/cli": "^7.4.3", 49 | "@babel/core": "^7.4.3", 50 | "@babel/node": "^7.2.2", 51 | "@babel/plugin-transform-runtime": "^7.4.3", 52 | "@babel/preset-env": "^7.4.3", 53 | "@babel/register": "^7.4.0", 54 | "@babel/runtime": "^7.4.3", 55 | "autoprefixer": "^7.1.2", 56 | "babel-eslint": "^10.1.0", 57 | "babel-loader": "^8.0", 58 | "chai": "^4.2.0", 59 | "chai-http": "^3.0.0", 60 | "chalk": "^2.0.1", 61 | "connect-history-api-fallback": "^1.3.0", 62 | "copy-webpack-plugin": "^5.1.2", 63 | "cross-env": "^5.0.1", 64 | "cross-spawn": "^5.0.1", 65 | "css-loader": "^5.1.1", 66 | "cssnano": "^3.10.0", 67 | "eslint": "^4.4.1", 68 | "eslint-config-standard": "^10.2.1", 69 | "eslint-friendly-formatter": "^3.0.0", 70 | "eslint-loader": "^1.7.1", 71 | "eslint-plugin-html": "^3.0.0", 72 | "eslint-plugin-import": "^2.7.0", 73 | "eslint-plugin-node": "^5.1.1", 74 | "eslint-plugin-promise": "^3.4.0", 75 | "eslint-plugin-standard": "^2.0.1", 76 | "file-loader": "^2.0.0", 77 | "html-webpack-plugin": "^3.2.0", 78 | "mini-css-extract-plugin": "^1.6.2", 79 | "mocha": "^6.0.2", 80 | "nodemon": "^2.0.15", 81 | "sass": "^1.51.0", 82 | "sass-loader": "^10.1", 83 | "sinon": "^2.1.0", 84 | "sinon-chai": "^2.8.0", 85 | "stats-webpack-plugin": "^0.7.0", 86 | "style-loader": "^0.23.1", 87 | "url-loader": "^1.1.2", 88 | "vue-html-loader": "^1.2.4", 89 | "vue-style-loader": "^4.1.2", 90 | "vue-template-compiler": "^2.5.21", 91 | "webpack": "^4.27.1", 92 | "webpack-bundle-analyzer": "^4.5.0", 93 | "webpack-cli": "^3.1.2", 94 | "webpack-dev-middleware": "^3.4.0", 95 | "webpack-dev-server": "^3.1.10", 96 | "webpack-hot-middleware": "^2.24.3", 97 | "webpack-merge": "^4.1.5" 98 | }, 99 | "engines": { 100 | "node": ">= 16.0.0", 101 | "yarn": ">= 1.22.0" 102 | }, 103 | "browserslist": [ 104 | "> 1%", 105 | "last 2 versions", 106 | "not ie <= 8" 107 | ], 108 | "nodemonConfig": { 109 | "watch": [ 110 | "src/*" 111 | ] 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /client/components/Status.vue: -------------------------------------------------------------------------------- 1 | 72 | 73 | -------------------------------------------------------------------------------- /client/components/Toast.vue: -------------------------------------------------------------------------------- 1 | 130 | 131 | 141 | 142 | 187 | -------------------------------------------------------------------------------- /test/feature/store.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | import {calculateHash, createBlock} from '../../src/lib/block' 3 | import {createRewardTransaction} from '../../src/lib/transaction' 4 | import co from 'co' 5 | import config from '../../src/config' 6 | import bus from '../../src/bus' 7 | import storeFactory from '../../src/store' 8 | import minerFactory from '../../src/miner' 9 | import {BlockError, TransactionError} from '../../src/errors' 10 | 11 | describe('store', () => { 12 | 13 | let store 14 | let miner 15 | 16 | beforeEach(() => { 17 | store = storeFactory(config, bus) 18 | miner = minerFactory(config, bus, store) 19 | store.difficulty = 1000000000 20 | }) 21 | 22 | it('should add valid block', () => { 23 | return co(function *() { 24 | let block = createBlock([], store.lastBlock(), store.wallets[0]) 25 | block = yield miner.findBlockHash(block, store.difficulty) 26 | store.addBlock(block) 27 | expect(store.chain.length).to.equal(2) 28 | }) 29 | }) 30 | 31 | it('should not add block with invalid hash', () => { 32 | return co(function *() { 33 | let block = createBlock([], store.lastBlock(), store.wallets[0]) 34 | block.hash = calculateHash(block) 35 | block.hash = block.hash.slice(0, -4) + '0000' 36 | 37 | expect(() => store.addBlock(block)).to.throw(BlockError, 'Invalid block hash') 38 | }) 39 | }) 40 | 41 | it('should not add block with wrong hash difficulty', () => { 42 | return co(function *() { 43 | store.difficulty = 10000 44 | let block = createBlock([], store.lastBlock(), store.wallets[0]) 45 | block.hash = calculateHash(block) 46 | 47 | expect(() => store.addBlock(block)).to.throw(BlockError, 'Invalid block difficulty') 48 | }) 49 | }) 50 | 51 | it('should not add block with invalid prev hash', () => { 52 | return co(function *() { 53 | let block = createBlock([], store.lastBlock(), store.wallets[0]) 54 | block = yield miner.findBlockHash(block, store.difficulty) 55 | block.prevHash = calculateHash(block) 56 | 57 | expect(() => store.addBlock(block)).to.throw(BlockError, 'Invalid block prevhash') 58 | }) 59 | }) 60 | 61 | it('should not add block with invalid prev hash', () => { 62 | return co(function *() { 63 | let block = createBlock([], store.lastBlock(), store.wallets[0]) 64 | block = yield miner.findBlockHash(block, store.difficulty) 65 | block.index = 5 66 | 67 | expect(() => store.addBlock(block)).to.throw(BlockError, 'Invalid block index') 68 | }) 69 | }) 70 | 71 | it('should not add block with invalid prev hash', () => { 72 | return co(function *() { 73 | let block = createBlock([], store.lastBlock(), store.wallets[0]) 74 | block = yield miner.findBlockHash(block, store.difficulty) 75 | block.index = 5 76 | 77 | expect(() => store.addBlock(block)).to.throw(BlockError, 'Invalid block index') 78 | }) 79 | }) 80 | 81 | it('should not add block with two reward transactions', () => { 82 | return co(function *() { 83 | let block = createBlock([createRewardTransaction(store.wallets[0])], store.lastBlock(), store.wallets[0]) 84 | block = yield miner.findBlockHash(block, store.difficulty) 85 | 86 | expect(() => store.addBlock(block)).to.throw(TransactionError, 'Transactions must have exactly one reward transaction') 87 | }) 88 | }) 89 | 90 | it.skip('should not add block with invalid transaction hash', () => { 91 | 92 | }) 93 | 94 | it.skip('should not add block with invalid transaction signature', () => { 95 | 96 | }) 97 | 98 | it.skip('should not add block if transaction and input addresses dont match', () => { 99 | 100 | }) 101 | 102 | it.skip('should not add block with transactions with invalid input signature', () => { 103 | 104 | }) 105 | 106 | it.skip('should not add block with transactions that have already spent input', () => { 107 | 108 | }) 109 | 110 | it.skip('should not add block with reward transactions with more than one output', () => { 111 | 112 | }) 113 | 114 | it.skip('should not add block with wrong mining reward', () => { 115 | 116 | }) 117 | 118 | it.skip('should not add block with transactions where input and output amounts do not match', () => { 119 | 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /src/miner.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('app:miner') 2 | const Worker = require('tiny-worker') 3 | const { calculateHash, createBlock } = require('./lib/block') 4 | const co = require('co') 5 | const { BlockError, TransactionError } = require('./errors') 6 | 7 | module.exports = (config, bus, store) => ({ 8 | 9 | /** 10 | * Start mining 11 | * 12 | * @param wallet Wallet for reward transaction 13 | */ 14 | mine (wallet) { 15 | if (! store.mining) return 16 | 17 | co(function* () { 18 | while (store.mining) { 19 | const block = yield this.mineBlock(store.getTransactionsForNextBlock(), store.lastBlock(), store.difficulty, wallet) 20 | if (! block) { 21 | // Someone mined block first or new transaction was added, start mining new one 22 | continue 23 | } 24 | try { 25 | store.addBlock(block) 26 | bus.emit('block-added-by-me', block) 27 | bus.emit('balance-updated', { public: wallet.public, balance: store.getBalanceForAddress(wallet.public) }) 28 | } catch (e) { 29 | if (! (e instanceof BlockError) && ! (e instanceof TransactionError)) throw e 30 | console.error(e) 31 | } 32 | } 33 | }.bind(this)).catch(e => console.error(e)) 34 | }, 35 | 36 | /** 37 | * Mine a block in separate process 38 | * 39 | * @param transactions Transactions list to add to the block 40 | * @param lastBlock Last block in the blockchain 41 | * @param difficulty Current difficulty 42 | * @param wallet Wallet for reward transaction 43 | * @return {*} 44 | */ 45 | mineBlock (transactions, lastBlock, difficulty, wallet) { 46 | const block = createBlock(transactions, lastBlock, wallet) 47 | block.hash = calculateHash(block) 48 | 49 | debug(`Started mining block ${block.index}`) 50 | 51 | return new Promise((resolve, reject) => { 52 | if (config.demoMode) { 53 | setTimeout(() => this.findBlockHash(block, difficulty).then(block => resolve(block)), 60 * 1000) 54 | } else { 55 | this.findBlockHash(block, difficulty).then(block => resolve(block)) 56 | } 57 | }) 58 | }, 59 | 60 | /** 61 | * Find block hash according to difficulty 62 | * 63 | * @param block 64 | * @param difficulty 65 | * @return {Promise} 66 | */ 67 | findBlockHash (block, difficulty) { 68 | return new Promise((resolve, reject) => { 69 | /* 70 | * Create worker to find hash in separate process 71 | */ 72 | const worker = new Worker(function () { 73 | const util = require(require('path').resolve(process.cwd(), 'src/lib/block')) 74 | const debug = require('debug')('app:miner') 75 | self.onmessage = e => { 76 | const { block, difficulty } = e.data 77 | while (util.getDifficulty(block.hash) >= difficulty) { 78 | block.nonce++ 79 | block.hash = util.calculateHash(block) 80 | if (block.nonce % 100000 === 0) debug('100K hashes') 81 | } 82 | postMessage({ type: 'block', block }) 83 | self.close() 84 | } 85 | }) 86 | worker.onmessage = e => { 87 | removeListeners() 88 | resolve(e.data.block) 89 | } 90 | worker.postMessage({ block, difficulty }) 91 | 92 | /* 93 | * Hadnle events to stop mining when needed 94 | */ 95 | const mineStop = () => { 96 | removeListeners() 97 | resolve(null) 98 | debug('kill thread') 99 | worker.terminate() 100 | } 101 | // Listeners for stopping mining 102 | const blockAddedListener = b => { 103 | if (b.index >= block.index) mineStop() 104 | } 105 | const mineStopListener = b => mineStop() 106 | 107 | const removeListeners = () => { 108 | bus.removeListener('block-added', blockAddedListener) 109 | bus.removeListener('mine-stop', mineStopListener) 110 | bus.removeListener('transaction-added', mineStopListener) 111 | bus.removeListener('transaction-added-by-me', mineStopListener) 112 | } 113 | // If other process found the same block faster, kill current one 114 | bus.once('block-added', blockAddedListener) 115 | bus.once('mine-stop', mineStopListener) 116 | bus.once('transaction-added', mineStopListener) 117 | bus.once('transaction-added-by-me', mineStopListener) 118 | }) 119 | }, 120 | 121 | }) 122 | -------------------------------------------------------------------------------- /client/store.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import createLogger from 'vuex/dist/logger' 4 | import co from 'co' 5 | import Axios from 'axios' 6 | import toast from './vuex/toast' 7 | 8 | Vue.use(Vuex) 9 | 10 | const state = { 11 | loading: false, 12 | time: [], 13 | chain: [], 14 | mempool: [], 15 | wallets: [], 16 | stats: [], 17 | mining: false, 18 | demoMode: false, 19 | block: {}, 20 | address: {}, 21 | transaction: {transaction: {inputs: [], outputs: []}, block: {}}, 22 | } 23 | 24 | const actions = { 25 | 26 | getStatus ({commit}) { 27 | commit('GET_STATUS') 28 | 29 | return co(function* () { 30 | const resp = yield Axios.get('/v1/status') 31 | commit('GET_STATUS_OK', resp.data) 32 | }) 33 | }, 34 | 35 | startMine ({commit}) { 36 | return co(function* () { 37 | yield Axios.get('/v1/mine-start') 38 | }) 39 | }, 40 | 41 | stopMine ({commit}) { 42 | return co(function* () { 43 | yield Axios.get('/v1/mine-stop') 44 | }) 45 | }, 46 | 47 | sendFunds ({commit}, {from, to, amount}) { 48 | return co(function* () { 49 | yield Axios.get(`/v1/send/${from}/${to}/${amount}`) 50 | }) 51 | }, 52 | 53 | getBlock ({commit}, index) { 54 | commit('GET_BLOCK') 55 | 56 | return co(function* () { 57 | const resp = yield Axios.get('/v1/block/' + index) 58 | commit('GET_BLOCK_OK', resp.data.block) 59 | }) 60 | }, 61 | 62 | getAddress ({commit}, address) { 63 | commit('GET_ADDRESS') 64 | 65 | return co(function* () { 66 | const resp = yield Axios.get('/v1/address/' + address) 67 | commit('GET_ADDRESS_OK', resp.data) 68 | }) 69 | }, 70 | 71 | getTransaction ({commit}, id) { 72 | commit('GET_TRANSACTION') 73 | 74 | return co(function* () { 75 | const resp = yield Axios.get('/v1/transaction/' + id) 76 | commit('GET_TRANSACTION_OK', resp.data) 77 | }) 78 | }, 79 | 80 | getWallets ({commit}) { 81 | commit('GET_WALLETS') 82 | 83 | return co(function* () { 84 | const resp = yield Axios.get('/v1/wallets') 85 | commit('GET_WALLETS_OK', resp.data) 86 | }) 87 | }, 88 | 89 | createWallet ({commit}, name) { 90 | commit('CREATE_WALLET') 91 | 92 | return co(function* () { 93 | const resp = yield Axios.post('/v1/wallet/create', {name}) 94 | commit('CREATE_WALLET_OK', resp.data) 95 | }) 96 | }, 97 | } 98 | 99 | const mutations = { 100 | 101 | GET_STATUS (state) { 102 | state.loading = true 103 | }, 104 | 105 | GET_STATUS_OK (state, status) { 106 | state.loading = false 107 | state.time = status.time 108 | state.chain = status.chain 109 | state.mempool = status.mempool 110 | state.wallets = status.wallets 111 | state.mining = status.mining 112 | state.demoMode = status.demoMode 113 | }, 114 | 115 | ERROR (state) { 116 | state.loading = false 117 | }, 118 | 119 | ADD_BLOCK (state, block) { 120 | state.chain.push(block) 121 | state.chain = state.chain.slice(Math.max(state.chain.length - 5, 0)) 122 | }, 123 | 124 | ADD_TRANSACTION (state, transaction) { 125 | state.mempool.push(transaction) 126 | state.mempool = state.mempool.slice(Math.max(state.mempool.length - 5, 0)) 127 | }, 128 | 129 | CLEAN_MEMPOOL (state, transactions) { 130 | transactions.forEach(tx => { 131 | let index = state.mempool.findIndex(t => t.id === tx.id) 132 | if (index !== -1) state.mempool.splice(index, 1) 133 | }) 134 | }, 135 | 136 | UPDATE_BALANCE (state, balance) { 137 | const index = state.wallets.findIndex(w => w.public === balance.public) 138 | if (index === -1) return console.error('Cant find wallet to update balance') 139 | state.wallets[index].balance = balance.balance 140 | }, 141 | 142 | MINE_START (state) { 143 | state.mining = true 144 | }, 145 | 146 | MINE_STOP (state) { 147 | state.mining = false 148 | }, 149 | 150 | RECIEVED_FUNDS (state, data) { 151 | const index = state.wallets.findIndex(w => w.public === data.public) 152 | if (index === -1) return console.error('Cant find wallet to update balance') 153 | state.wallets[index].balance = data.balance 154 | }, 155 | 156 | GET_BLOCK (state) { 157 | state.loading = true 158 | }, 159 | 160 | GET_BLOCK_OK (state, block) { 161 | state.loading = false 162 | state.block = block 163 | }, 164 | 165 | GET_ADDRESS (state) { 166 | state.loading = true 167 | }, 168 | 169 | GET_ADDRESS_OK (state, address) { 170 | state.loading = false 171 | state.address = address 172 | }, 173 | 174 | GET_TRANSACTION (state) { 175 | state.loading = true 176 | }, 177 | 178 | GET_TRANSACTION_OK (state, transaction) { 179 | state.loading = false 180 | state.transaction = transaction 181 | }, 182 | 183 | GET_WALLETS (state) { 184 | state.loading = true 185 | }, 186 | 187 | GET_WALLETS_OK (state, wallets) { 188 | state.loading = false 189 | state.wallets = wallets 190 | }, 191 | 192 | CREATE_WALLET (state) { 193 | state.loading = true 194 | }, 195 | 196 | CREATE_WALLET_OK (state, wallet) { 197 | state.loading = false 198 | state.wallets.push(wallet) 199 | }, 200 | } 201 | 202 | const debug = process.env.NODE_ENV !== 'production' 203 | 204 | const store = new Vuex.Store({ 205 | strict: debug, 206 | plugins: debug ? [createLogger()] : [], 207 | state, 208 | mutations, 209 | actions, 210 | modules: {toast}, 211 | }) 212 | 213 | Axios.interceptors.response.use(function (response) { 214 | // Do something with response data 215 | return response 216 | }, function (error) { 217 | store.dispatch('addToastMessage', {type: 'danger', text: error.response.data}) 218 | store.commit('ERROR', error) 219 | return Promise.reject(error) 220 | }) 221 | 222 | export default store 223 | -------------------------------------------------------------------------------- /src/peers.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('app:peers') 2 | const WebSocket = require('ws') 3 | const {BlockError, TransactionError} = require('./errors') 4 | 5 | module.exports = (config, bus, store) => ({ 6 | 7 | connections: store.peers.map(peer => ({url: peer, ws: null, timeoutId: null, retries: 0, initial: true})), 8 | 9 | server: null, 10 | 11 | write (connection, message) { 12 | debug(`Send message: ${message.type} to: ${connection.url}`) 13 | connection.ws.send(JSON.stringify(message)) 14 | }, 15 | 16 | broadcast (message) { 17 | debug(`Broadcast message: ${message.type}`) 18 | this.connections.filter(c => c.ws).forEach(c => this.write(c, message)) 19 | }, 20 | 21 | /** 22 | * Handle incoming messages 23 | * 24 | * @param connection 25 | */ 26 | initMessageHandler (connection) { 27 | const ws = connection.ws 28 | 29 | ws.on('message', (data) => { 30 | let message = '' 31 | try { 32 | message = JSON.parse(data) 33 | } catch (e) { 34 | console.error('Failed to json parse recieved data from peer') 35 | } 36 | 37 | debug(`Received message: ${message.type}`) 38 | 39 | // TODO: validate requests 40 | switch (message.type) { 41 | case 'get-blocks-after': 42 | this.write(connection, {type: 'blocks-after', blocks: store.blocksAfter(message.index + 1)}) 43 | break 44 | 45 | case 'blocks-after': 46 | message.blocks.forEach(block => { 47 | try { 48 | store.addBlock(block) 49 | } catch (e) { 50 | if (! (e instanceof BlockError) && ! (e instanceof TransactionError)) throw e 51 | } 52 | }) 53 | break 54 | 55 | case 'new-block': 56 | try { 57 | // Load all blocks needed if recieved block is not next for our chain 58 | if (message.block.index - store.lastBlock().index > 1) { 59 | return this.write(connection, {type: 'get-blocks-after', index: store.lastBlock().index}) 60 | } 61 | const block = store.addBlock(message.block) 62 | bus.emit('block-added', block) 63 | } catch (e) { 64 | if (! (e instanceof BlockError) && ! (e instanceof TransactionError)) throw e 65 | this.write(connection, {type: 'error', message: e.message}) 66 | } 67 | break 68 | 69 | case 'new-transaction': 70 | try { 71 | store.addTransaction(message.transaction, true) 72 | } catch (e) { 73 | this.write(connection, {type: 'error', message: e.message}) 74 | } 75 | break 76 | } 77 | }) 78 | }, 79 | 80 | /** 81 | * Handle connection errors 82 | * 83 | * @param connection 84 | * @param index 85 | */ 86 | initErrorHandler (connection, index) { 87 | const closeConnection = (connection, index) => { 88 | debug(`Connection broken to: ${connection.url === undefined ? 'incoming' : connection.url}`) 89 | connection.ws = null 90 | 91 | // Retry initial connections 3 times 92 | if (connection.initial && connection.retries < 4) { 93 | connection.retries++ 94 | debug(`Retry in 3 secs, retries: ${connection.retries}`) 95 | connection.timeoutId = setTimeout(() => this.connectToPeer(connection, index), 3000) 96 | } 97 | } 98 | connection.ws.on('close', () => closeConnection(connection, index)) 99 | connection.ws.on('error', () => closeConnection(connection, index)) 100 | }, 101 | 102 | /** 103 | * Handle connection initialization 104 | * 105 | * @param ws 106 | * @param req 107 | * @param index 108 | */ 109 | initConnection (ws, req = null, index = null) { 110 | let connection = null 111 | let url = ws.url 112 | 113 | if (index === null) { 114 | // If peer connected to us 115 | url = req.connection.remoteAddress 116 | connection = {url, ws, timeoutId: null, retries: 0, initial: false} 117 | this.connections.push(connection) 118 | debug(`Peer ${url} connected to us`) 119 | } else { 120 | // We connected to peer 121 | connection = this.connections[index] 122 | debug(`Connected to peer ${url}`) 123 | } 124 | connection.retries = 0 125 | 126 | clearTimeout(connection.timeoutId) 127 | this.initMessageHandler(connection, index) 128 | this.initErrorHandler(connection, index) 129 | 130 | // Get full blockchain from first peer 131 | if (index === 0) { 132 | this.write(connection, {type: 'get-blocks-after', index: store.lastBlock().index}) 133 | } 134 | }, 135 | 136 | /** 137 | * Connect to peer 138 | * 139 | * @param connection 140 | * @param index 141 | * @param req 142 | */ 143 | connectToPeer (connection, index = null) { 144 | connection.ws = new WebSocket(connection.url) 145 | connection.ws.on('open', () => this.initConnection(connection.ws, null, index)) 146 | connection.ws.on('error', () => { 147 | debug(`Connection failed to ${connection.url}`) 148 | 149 | // Retry initial connections 3 times 150 | if (connection.initial && connection.retries < 4) { 151 | debug(`Retry in 3 secs, retries: ${connection.retries}`) 152 | connection.retries++ 153 | connection.timeoutId = setTimeout(() => { 154 | this.connectToPeer(connection, index) 155 | }, 3000) 156 | } 157 | }) 158 | }, 159 | 160 | start () { 161 | // Broadacast messages to all peers 162 | bus.on('block-added-by-me', block => this.broadcast({type: 'new-block', block})) 163 | bus.on('transaction-added-by-me', transaction => this.broadcast({type: 'new-transaction', transaction})) 164 | 165 | this.connections.forEach((connection, index) => this.connectToPeer(connection, index)) 166 | 167 | this.server = new WebSocket.Server({port: config.p2pPort}) 168 | this.server.on('connection', (ws, req) => this.initConnection(ws, req)) 169 | 170 | debug('listening websocket p2p port on: ' + config.p2pPort) 171 | }, 172 | }) 173 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('app:store') 2 | const {TransactionError, GeneralError} = require('./errors') 3 | const {isChainValid} = require('./lib/chain') 4 | const {checkBlock, makeGenesisBlock} = require('./lib/block') 5 | const {checkTransaction, buildTransaction} = require('./lib/transaction') 6 | const {generateKeyPair} = require('./lib/wallet') 7 | 8 | module.exports = (config, bus) => ({ 9 | 10 | /* ========================================================================= *\ 11 | * State 12 | \* ========================================================================= */ 13 | 14 | difficulty: config.demoMode ? 100000000 : 10000 * 1, // The less value the bigger difficulty 15 | 16 | chain: [makeGenesisBlock()], 17 | 18 | mempool: [], // This is pending transactions that will be added to the next block 19 | 20 | peers: config.initialPeers, // List of peers ['ip:port'] 21 | 22 | wallets: [ 23 | {name: 'Main', ...generateKeyPair()}, 24 | {name: 'Wallet 1', ...generateKeyPair()}, 25 | {name: 'Wallet 2', ...generateKeyPair()}, 26 | {name: 'Wallet 3', ...generateKeyPair()}, 27 | ], 28 | 29 | mining: !! config.demoMode, 30 | 31 | /* ========================================================================= *\ 32 | * Getters 33 | \* ========================================================================= */ 34 | 35 | lastBlock () { 36 | return this.chain[this.chain.length - 1] 37 | }, 38 | 39 | blocksAfter (index) { 40 | if (index >= this.chain.length) return [] 41 | return this.chain.slice(index) 42 | }, 43 | 44 | getTransactions (withMempool = true) { 45 | let transactions = this.chain.reduce((transactions, block) => transactions.concat(block.transactions), []) 46 | if (withMempool) transactions = transactions.concat(this.mempool) 47 | 48 | return transactions 49 | }, 50 | 51 | getTransactionsForAddress (address) { 52 | return this.getTransactions(false).filter(tx => tx.inputs.find(i => i.address === address) || 53 | tx.outputs.find(o => o.address === address)) 54 | }, 55 | 56 | getTransactionsForNextBlock () { 57 | const unspent = this.getUnspent(false) 58 | return this.mempool.filter(tx => { 59 | try { 60 | return checkTransaction(tx, unspent) 61 | } catch (e) { if (! (e instanceof TransactionError)) throw e } 62 | }) 63 | }, 64 | 65 | getUnspent (withMempool = false) { 66 | const transactions = this.getTransactions(withMempool) 67 | 68 | // Find all inputs with their tx ids 69 | const inputs = transactions.reduce((inputs, tx) => inputs.concat(tx.inputs), []) 70 | 71 | // Find all outputs with their tx ids 72 | const outputs = transactions.reduce((outputs, tx) => 73 | outputs.concat(tx.outputs.map(o => Object.assign({}, o, {tx: tx.id}))), []) 74 | 75 | // Figure out which outputs are unspent 76 | return outputs.filter(output => 77 | typeof inputs.find(input => input.tx === output.tx && input.index === output.index && input.amount === output.amount && input.address === output.address) === 'undefined') 78 | }, 79 | 80 | getUnspentForAddress (address) { 81 | return this.getUnspent(true).filter(u => u.address === address) 82 | }, 83 | 84 | getBalanceForAddress (address) { 85 | return this.getUnspentForAddress(address).reduce((acc, u) => acc + u.amount, 0) 86 | }, 87 | 88 | /* ========================================================================= *\ 89 | * Actions 90 | \* ========================================================================= */ 91 | 92 | addBlock (block) { 93 | checkBlock(this.lastBlock(), block, this.difficulty, this.getUnspent()) 94 | this.chain.push(block) // Push block to the chain 95 | this.cleanMempool(block.transactions) // Clean mempool 96 | debug(`Added block ${block.index} to the chain`) 97 | return block 98 | }, 99 | 100 | addTransaction (transaction, byPeer = false) { 101 | checkTransaction(transaction, this.getUnspent(true)) 102 | // TODO: check if transaction or any intputs are not in mempool already 103 | this.mempool.push(transaction) 104 | 105 | if (byPeer) bus.emit('transaction-added', transaction) 106 | else bus.emit('transaction-added-by-me', transaction) 107 | 108 | // Notify about new transaction if one of our wallets recieved funds 109 | let myWallet = null 110 | const outputToMyWallet = transaction.outputs.find(output => myWallet = this.wallets.find(w => w.public === output.address)) 111 | if (outputToMyWallet) { 112 | bus.emit('recieved-funds', { 113 | name: myWallet.name, 114 | public: myWallet.public, 115 | amount: outputToMyWallet.amount, 116 | balance: this.getBalanceForAddress(myWallet.public), 117 | }) 118 | } 119 | debug('Added transaction to mempool ', transaction.id) 120 | }, 121 | 122 | addWallet (wallet) { 123 | this.wallets.push(wallet) 124 | }, 125 | 126 | cleanMempool (transactions) { 127 | transactions.forEach(tx => { 128 | let index = this.mempool.findIndex(t => t.id === tx.id) 129 | if (index !== -1) this.mempool.splice(index, 1) 130 | }) 131 | }, 132 | 133 | updateChain (newChain) { 134 | if (newChain.length > this.chain.length && isChainValid(newChain, this.difficulty)) { 135 | this.chain = newChain 136 | return true 137 | } 138 | 139 | return false 140 | }, 141 | 142 | addPeer (peer) { 143 | this.peers.push(peer) 144 | }, 145 | 146 | send (from, toAddress, amount) { 147 | const wallet = this.wallets.find(w => w.public === from) 148 | if (! wallet) throw new GeneralError(`Wallet with address ${from} not found`) 149 | if (amount <= 0) throw new GeneralError(`Amount should be positive`) 150 | 151 | try { 152 | const transaction = buildTransaction(wallet, toAddress, parseInt(amount), this.getUnspentForAddress(wallet.public)) 153 | this.addTransaction(transaction) 154 | bus.emit('balance-updated', {public: wallet.public, balance: this.getBalanceForAddress(wallet.public)}) 155 | return 'Transaction added to pool: ' + transaction.id 156 | } catch (e) { 157 | if (! (e instanceof TransactionError)) throw e 158 | console.error(e) 159 | throw new GeneralError(e.message) 160 | } 161 | }, 162 | }) 163 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('app:server') 2 | const path = require('path') 3 | const express = require('express') 4 | const bodyParser = require('body-parser') 5 | const expressWinston = require('express-winston') 6 | const winston = require('winston') 7 | const {generateKeyPair} = require('./lib/wallet') 8 | const {TransactionError, GeneralError} = require('./errors') 9 | 10 | module.exports = (config, bus, store, miner) => ({ 11 | app: null, 12 | http: null, 13 | io: null, 14 | 15 | broadcast (type, data) { 16 | debug(`Broadcast WS message: ${type}`) 17 | this.io.emit(type, data) 18 | }, 19 | 20 | start () { 21 | this.app = express() 22 | this.http = require('http').Server(this.app) 23 | this.io = require('socket.io')(this.http) 24 | 25 | // Establish socket.io connection 26 | this.io.on('connection', function (socket) { 27 | debug('Websocket user connected') 28 | socket.on('disconnect', function () { 29 | debug('Websocket user disconnected') 30 | }) 31 | }) 32 | 33 | // Broadacast messages 34 | bus.on('block-added', block => this.broadcast('block-added', block)) 35 | bus.on('block-added-by-me', block => this.broadcast('block-added-by-me', block)) 36 | bus.on('transaction-added-by-me', transaction => this.broadcast('transaction-added', transaction)) 37 | bus.on('transaction-added', transaction => this.broadcast('transaction-added', transaction)) 38 | bus.on('balance-updated', balance => this.broadcast('balance-updated', balance)) 39 | bus.on('mine-start', () => this.broadcast('mine-started')) 40 | bus.on('mine-stop', () => this.broadcast('mine-stopped')) 41 | bus.on('recieved-funds', (data) => this.broadcast('recieved-funds', data)) 42 | 43 | // Parse bodies 44 | this.app.use(bodyParser.json()) // support json encoded bodies 45 | this.app.use(bodyParser.urlencoded({ extended: true })) // support encoded bodies 46 | 47 | // Add winston logger 48 | this.app.use(expressWinston.logger({transports: [new winston.transports.File({ 49 | filename: 'logs/express.log', json: false, maxsize: 1024 * 1024, maxFiles: 100, tailable: true, 50 | })]})) 51 | 52 | // Serve static files 53 | this.app.use('/', express.static(path.resolve(__dirname, '../dist'))) 54 | 55 | /* 56 | * Get short blockchain status 57 | */ 58 | this.app.get('/v1/status', (req, res) => res.json({ 59 | time: Math.floor(new Date().getTime() / 1000), 60 | chain: store.chain.slice(Math.max(store.chain.length - 5, 0)), 61 | mempool: store.mempool.slice(Math.max(store.mempool.length - 5, 0)), 62 | wallets: store.wallets.map(w => ({name: w.name, public: w.public, balance: store.getBalanceForAddress(w.public)})), 63 | mining: store.mining, 64 | demoMode: !! config.demoMode, 65 | })) 66 | 67 | /* 68 | * Send money to address 69 | */ 70 | this.app.get('/v1/send/:from/:to/:amount', (req, res) => { 71 | try { 72 | res.json(store.send(req.params.from, req.params.to, parseInt(req.params.amount))) 73 | } catch (e) { 74 | if (! (e instanceof GeneralError) && ! (e instanceof TransactionError)) throw e 75 | res.status(403).send(e.message) 76 | } 77 | }) 78 | 79 | /* 80 | * Get block by index 81 | */ 82 | this.app.get('/v1/block/:index', (req, res) => res.json({block: store.chain.find(b => b.index === parseInt(req.params.index))})) 83 | 84 | /* 85 | * Get address 86 | */ 87 | this.app.get('/v1/address/:address', (req, res) => { 88 | const transactions = store.getTransactionsForAddress(req.params.address) 89 | res.json({ 90 | balance: store.getBalanceForAddress(req.params.address), 91 | transactions: transactions.slice(-100).reverse(), // Last 100 transactions 92 | totalTransactions: transactions.length, 93 | totalRecieved: transactions.reduce((acc, tx) => acc + tx.outputs.reduce((acc, o) => acc + (o.address === req.params.address ? o.amount : 0), 0), 0), 94 | }) 95 | }) 96 | 97 | /* 98 | * Get transaction by txid 99 | */ 100 | this.app.get('/v1/transaction/:id', (req, res) => { 101 | const transaction = store.getTransactions().find(tx => tx.id === req.params.id) 102 | if (! transaction) return res.status(404).send('Cant find transaction') 103 | const block = store.chain.find(block => block.transactions.find(tx => tx.id === req.params.id)) 104 | res.json({transaction, block}) 105 | }) 106 | 107 | /* 108 | * My Wallets 109 | */ 110 | this.app.get('/v1/wallets', (req, res) => res.json(store.wallets.map(wallet => { 111 | const transactions = store.getTransactionsForAddress(wallet.public).reverse() 112 | return { 113 | name: wallet.name, 114 | public: wallet.public, 115 | balance: store.getBalanceForAddress(wallet.public), 116 | totalTransactions: transactions.length, 117 | transactions: transactions.slice(Math.max(transactions.length - 100, 0)), 118 | totalRecieved: transactions.reduce((acc, tx) => acc + tx.outputs.reduce((acc, o) => acc + (o.address === wallet.public ? o.amount : 0), 0), 0), 119 | totalSent: transactions.reduce((acc, tx) => acc + tx.inputs.reduce((acc, i) => acc + (i.address === wallet.public ? i.amount : 0), 0), 0), 120 | } 121 | }))) 122 | 123 | /* 124 | * Create new wallet 125 | */ 126 | this.app.post('/v1/wallet/create', (req, res) => { 127 | const wallet = {name: req.body.name, ...generateKeyPair()} 128 | store.addWallet(wallet) 129 | res.json({name: wallet.name, public: wallet.public, balance: store.getBalanceForAddress(wallet.public)}) 130 | }) 131 | 132 | /* 133 | * Start mining 134 | */ 135 | this.app.get('/v1/mine-start', (req, res) => { 136 | store.mining = true 137 | bus.emit('mine-start') 138 | if (! config.demoMode) miner.mine(store.wallets[0]) 139 | res.json('Ok') 140 | }) 141 | 142 | /* 143 | * Stop mining 144 | */ 145 | this.app.get('/v1/mine-stop', (req, res) => { 146 | if (config.demoMode) return res.status(403).send('Can not stop miner in Demo mode') 147 | store.mining = false 148 | bus.emit('mine-stop') 149 | res.json('Ok') 150 | }) 151 | 152 | this.http.listen(config.httpPort, config.httpHost, () => debug('Listening http on host: ' + config.httpHost + '; port: ' + config.httpPort)) 153 | }, 154 | 155 | }) 156 | -------------------------------------------------------------------------------- /test/unit/lib/transaction.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | import crypto from 'crypto' 3 | import {miningReward} from '../../../src/config' 4 | import {generateKeyPair} from '../../../src/lib/wallet' 5 | import blockLib from '../../../src/lib/block' 6 | import walletLib from '../../../src/lib/wallet' 7 | import {isDataValid, checkTransactions, checkTransaction, calculateHash, createRewardTransaction, buildTransaction} from '../../../src/lib/transaction' 8 | import {TransactionError} from '../../../src/errors' 9 | 10 | describe('transaction lib', () => { 11 | 12 | const wallet1 = generateKeyPair() 13 | const wallet2 = generateKeyPair() 14 | 15 | // Create simple chain with three blocks 16 | const chain = [blockLib.makeGenesisBlock()]; 17 | 18 | for (let i = 0; i < 2; i++) { 19 | let block = blockLib.createBlock([], chain[chain.length - 1], wallet1) 20 | block.hash = blockLib.calculateHash(block) 21 | chain.push(block) 22 | } 23 | 24 | const unspent = chain 25 | // Get all transactions from the chain 26 | .reduce((transactions, block) => transactions.concat(block.transactions), []) 27 | // Get all outputs from transactions and append tx id 28 | .reduce((outputs, tx) => outputs.concat(tx.outputs.map(o => Object.assign({}, o, {tx: tx.id}))), []) 29 | 30 | let tx 31 | let rewardTx 32 | 33 | beforeEach(() => { 34 | tx = buildTransaction(wallet1, wallet2.public, 100, unspent) 35 | rewardTx = createRewardTransaction(wallet1) 36 | }) 37 | 38 | describe('transaction data validation', () => { 39 | 40 | it('should validate id', (done) => { 41 | tx.id = 'invalid'; 42 | expect(isDataValid(tx)).to.be.false 43 | done() 44 | }) 45 | 46 | it('should validate time', (done) => { 47 | tx.time = 'invalid'; 48 | expect(isDataValid(tx)).to.be.false 49 | done() 50 | }) 51 | 52 | it('should validate hash', (done) => { 53 | tx.hash = 'invalid'; 54 | expect(isDataValid(tx)).to.be.false 55 | done() 56 | }) 57 | 58 | it('should validate reward flag', (done) => { 59 | tx.reward = 'invalid'; 60 | expect(isDataValid(tx)).to.be.false 61 | done() 62 | }) 63 | 64 | it('should validate inputs', (done) => { 65 | tx.inputs = 'invalid'; 66 | expect(isDataValid(tx)).to.be.false 67 | done() 68 | }) 69 | 70 | it('should validate input data', (done) => { 71 | tx.inputs = [{ 72 | tx: 'invalid', 73 | index: 'invalid', 74 | amount: 'invalid', 75 | address: false, 76 | signature: 'invalid', 77 | }]; 78 | tx.hash = calculateHash(tx) 79 | expect(isDataValid(tx)).to.be.false 80 | done() 81 | }) 82 | 83 | it('should validate outputs', (done) => { 84 | tx.outputs = 'invalid'; 85 | expect(isDataValid(tx)).to.be.false 86 | done() 87 | }) 88 | 89 | it('should validate output data', (done) => { 90 | tx.outputs = [{ 91 | index: 'invalid', 92 | amount: 'invalid', 93 | address: false, 94 | }]; 95 | tx.hash = calculateHash(tx) 96 | expect(isDataValid(tx)).to.be.false 97 | done() 98 | }) 99 | 100 | }) 101 | 102 | describe('block transactions list verification', () => { 103 | 104 | it('should fail if has more than one reward transactoins', (done) => { 105 | const rewardTx2 = createRewardTransaction(wallet1) 106 | const transactions = [tx, rewardTx, rewardTx2] 107 | 108 | expect(() => { 109 | checkTransactions(transactions, unspent) 110 | }).to.throw(TransactionError, 'Transactions must have exactly one reward transaction') 111 | done() 112 | }) 113 | 114 | }) 115 | 116 | 117 | describe('transaction verification', () => { 118 | 119 | it('should fail on invalid hash', (done) => { 120 | tx.hash = calculateHash(Object.assign({}, tx, {id: crypto.randomBytes(32).toString('hex')})) 121 | expectCheckTransactionToThrow(tx) 122 | done() 123 | }) 124 | 125 | it('should fail on invalid signature', (done) => { 126 | tx.signature = crypto.randomBytes(64).toString('base64') 127 | expectCheckTransactionToThrow(tx, 'Invalid transaction signature') 128 | done() 129 | }) 130 | 131 | it('should fail if some if inputs don\'t match transaction address', (done) => { 132 | tx.address = wallet2.public 133 | tx.hash = calculateHash(tx) 134 | tx.signature = walletLib.signHash(wallet2.private, tx.hash) 135 | expectCheckTransactionToThrow(tx, 'Transaction and input addresses dont match') 136 | done() 137 | }) 138 | 139 | it('should fail on invalid input signature', (done) => { 140 | tx.inputs[0].signature = crypto.randomBytes(64).toString('base64') 141 | tx.hash = calculateHash(tx) 142 | tx.signature = walletLib.signHash(wallet1.private, tx.hash) 143 | expectCheckTransactionToThrow(tx, 'Invalid input signature') 144 | done() 145 | }) 146 | 147 | it('should fail on invalid reward output amount', (done) => { 148 | rewardTx.outputs[0].amount = 200 149 | rewardTx.hash = calculateHash(rewardTx) 150 | rewardTx.signature = walletLib.signHash(wallet1.private, rewardTx.hash) 151 | expectCheckTransactionToThrow(rewardTx, `Mining reward must be exactly: ${miningReward}`) 152 | done() 153 | }) 154 | 155 | it('should fail on invalid reward outputs number', (done) => { 156 | rewardTx.outputs[1] = Object.assign({}, rewardTx.outputs[0]) 157 | rewardTx.hash = calculateHash(rewardTx) 158 | rewardTx.signature = walletLib.signHash(wallet1.private, rewardTx.hash) 159 | expectCheckTransactionToThrow(rewardTx, 'Reward transaction must have exactly one output') 160 | done() 161 | }) 162 | 163 | it('should fail on not matching inputs and outputs amounts', (done) => { 164 | tx.outputs[0].amount = 200 165 | tx.hash = calculateHash(tx) 166 | tx.signature = walletLib.signHash(wallet1.private, tx.hash) 167 | expectCheckTransactionToThrow(tx, 'Input and output amounts do not match') 168 | done() 169 | }) 170 | 171 | }) 172 | 173 | 174 | /* ========================================================================= *\ 175 | * Helpers 176 | \* ========================================================================= */ 177 | 178 | function expectCheckTransactionToThrow (transaction, message) { 179 | expect(() => { 180 | checkTransaction(transaction, unspent) 181 | }).to.throw(TransactionError, message) 182 | } 183 | 184 | }) 185 | -------------------------------------------------------------------------------- /src/lib/transaction.js: -------------------------------------------------------------------------------- 1 | const {TransactionError} = require('../errors') 2 | const CryptoJS = require('crypto-js') 3 | const crypto = require('crypto') 4 | const Joi = require('joi') 5 | const walletLib = require('./wallet') 6 | const {miningReward} = require('../config') 7 | 8 | const transactionSchema = Joi.object().keys({ 9 | id: Joi.string().hex().length(64), // Transaction unique id 10 | time: Joi.number(), // Transaction timestamp 11 | reward: Joi.boolean(), // Boolean to mark mining reward transaction 12 | address: Joi.string(), // Transaction is limited to only one input address for simplicity 13 | hash: Joi.string().hex().length(64), // Transaction hash 14 | signature: Joi.string().base64(), // Transaction hash signature 15 | 16 | inputs: Joi.array().items(Joi.object().keys({ 17 | tx: Joi.string().hex().length(64), // Points to transaction of referenced output 18 | index: Joi.number(), // Index of the output in the referenced transaction 19 | amount: Joi.number(), // Amount of the referenced output 20 | address: Joi.string(), // Address (public key) of the referenced output 21 | signature: Joi.string().base64(), // Signature, signed by private key and can be verified by included public key 22 | })), 23 | 24 | outputs: Joi.array().items(Joi.object().keys({ 25 | index: Joi.number(), // Output index in current transaction 26 | amount: Joi.number(), // Amount of the output 27 | address: Joi.string(), // Address (public key) of the wallet where to transfer funds 28 | })), 29 | }) 30 | 31 | /** 32 | * Validate transaction data 33 | * 34 | * @param transaction 35 | * @return {*} 36 | */ 37 | function isDataValid (transaction) { 38 | return Joi.validate(transaction, transactionSchema).error === null 39 | } 40 | 41 | /** 42 | * Verify block transactions list 43 | * 44 | * @param transactions 45 | * @param unspent 46 | */ 47 | function checkTransactions (transactions, unspent) { 48 | transactions.forEach(tx => checkTransaction(tx, unspent)) 49 | if (transactions.filter(tx => tx.reward).length !== 1) throw new TransactionError('Transactions must have exactly one reward transaction') 50 | } 51 | 52 | /** 53 | * Verify single transaction 54 | * 55 | * @param transaction 56 | * @param unspent 57 | */ 58 | function checkTransaction (transaction, unspent) { 59 | if (! isDataValid(transaction)) throw new TransactionError('Transaction data is not valid') 60 | if (transaction.hash !== calculateHash(transaction)) throw new TransactionError('Invalid transaction hash') 61 | if (! verifyTransactionSignature(transaction)) throw new TransactionError('Invalid transaction signature') 62 | 63 | // Verify that all transaction's inputs addresses match transaction address, this is to ensure 64 | // that whole transaction is genuine and node did not replace any of transaction outputs 65 | if (! transaction.inputs.every(i => i.address === transaction.address)) throw new TransactionError('Transaction and input addresses dont match') 66 | 67 | // Verify each input signature 68 | transaction.inputs.forEach(function (input) { 69 | if (! verifyInputSignature(input)) throw new TransactionError('Invalid input signature') 70 | }) 71 | 72 | // Check if inputs are in unspent list 73 | transaction.inputs.forEach(function (input) { 74 | if (! unspent.find(out => out.tx === input.tx && out.index === input.index && out.amount === input.amount && out.address === input.address)) { 75 | throw new TransactionError('Input has been already spent: ' + input.tx) 76 | } 77 | }) 78 | 79 | if (transaction.reward) { 80 | // For reward transaction: check if reward output is correct 81 | if (transaction.outputs.length !== 1) throw new TransactionError('Reward transaction must have exactly one output') 82 | if (transaction.outputs[0].amount !== miningReward) throw new TransactionError(`Mining reward must be exactly: ${miningReward}`) 83 | } else { 84 | // For normal transaction: check if total output amount equals input amount 85 | if (transaction.inputs.reduce((acc, input) => acc + input.amount, 0) !== transaction.outputs.reduce((acc, output) => acc + output.amount, 0)) { 86 | throw new TransactionError('Input and output amounts do not match') 87 | } 88 | } 89 | 90 | return true 91 | } 92 | 93 | /** 94 | * Verify input signature 95 | * 96 | * @param input 97 | * @return {*} 98 | */ 99 | function verifyInputSignature (input) { 100 | return walletLib.verifySignature(input.address, input.signature, calculateInputHash(input)) 101 | } 102 | 103 | /** 104 | * Verify transaction signature 105 | * 106 | * @param transaction 107 | * @return {*} 108 | */ 109 | function verifyTransactionSignature (transaction) { 110 | return walletLib.verifySignature(transaction.address, transaction.signature, transaction.hash) 111 | } 112 | 113 | /** 114 | * Calculate transaction hash 115 | * 116 | * @param transaction 117 | */ 118 | function calculateHash ({id, time, address, reward, inputs, outputs}) { 119 | return CryptoJS.SHA256(JSON.stringify({id, time, address, reward, inputs, outputs})).toString() 120 | } 121 | 122 | /** 123 | * Calculate input hash 124 | * 125 | * @param input 126 | */ 127 | function calculateInputHash ({tx, index, amount, address}) { 128 | return CryptoJS.SHA256(JSON.stringify({tx, index, amount, address})).toString() 129 | } 130 | 131 | /** 132 | * Create and sign input 133 | * 134 | * @param tx Based on transaction id 135 | * @param index Based on transaction output index 136 | * @param amount 137 | * @param wallet 138 | * @return {{tx: string, index: number, amount: number, address: string}} 139 | */ 140 | function createInput (tx, index, amount, wallet) { 141 | const input = { 142 | tx, 143 | index, 144 | amount, 145 | address: wallet.public, 146 | } 147 | input.signature = walletLib.signHash(wallet.private, calculateInputHash(input)) 148 | 149 | return input 150 | } 151 | 152 | /** 153 | * Create transaction from inputs and outputs 154 | * 155 | * @param {{public: string, private: string}} wallet 156 | * @param {Array} inputs 157 | * @param {Array} outputs 158 | * @param {boolean} reward 159 | * @return {{id: string, reward: boolean, inputs: Array, outputs: Array, hash: string}} 160 | */ 161 | function createTransaction (wallet, inputs, outputs, reward = false) { 162 | const tx = { 163 | id: crypto.randomBytes(32).toString('hex'), 164 | time: Math.floor(new Date().getTime() / 1000), 165 | reward, 166 | inputs, 167 | outputs, 168 | } 169 | tx.address = wallet.public 170 | tx.hash = calculateHash(tx) 171 | tx.signature = walletLib.signHash(wallet.private, tx.hash) 172 | 173 | return tx 174 | } 175 | 176 | /** 177 | * Create reward transaction for block mining 178 | * 179 | * @param {{public: string, private: string}} wallet 180 | * @return {{id: string, reward: boolean, inputs: Array, outputs: Array, hash: string}} 181 | */ 182 | function createRewardTransaction (wallet) { 183 | return createTransaction(wallet, [], [{index: 0, amount: miningReward, address: wallet.public}], true) 184 | } 185 | 186 | /** 187 | * Build a transaction for sending money 188 | * 189 | * @param {{public: string, private: string}} wallet 190 | * @param {string} toAddress 191 | * @param {Number} amount 192 | * @param {Array} unspent 193 | * @return {{id: string, reward: boolean, inputs: Array, outputs: Array, hash: string}} 194 | */ 195 | function buildTransaction (wallet, toAddress, amount, unspent) { 196 | let inputsAmount = 0 197 | const inputsRaw = unspent.filter(i => { 198 | const more = inputsAmount < amount 199 | if (more) inputsAmount += i.amount 200 | return more 201 | }) 202 | if (inputsAmount < amount) throw new TransactionError('Not enough funds') 203 | 204 | const inputs = inputsRaw.map(i => createInput(i.tx, i.index, i.amount, wallet)) 205 | 206 | // Send amount to destination address 207 | const outputs = [{index: 0, amount, address: toAddress}] 208 | // Send back change to my wallet 209 | if (inputsAmount - amount > 0) { 210 | outputs.push({index: 1, amount: inputsAmount - amount, address: wallet.public}) 211 | } 212 | 213 | return createTransaction(wallet, inputs, outputs) 214 | } 215 | 216 | module.exports = { 217 | isDataValid, 218 | checkTransactions, 219 | checkTransaction, 220 | calculateHash, 221 | createRewardTransaction, 222 | buildTransaction, 223 | } 224 | --------------------------------------------------------------------------------