├── ui
├── src
│ ├── containers
│ │ ├── Send
│ │ │ ├── send.css
│ │ │ └── index.js
│ │ ├── Receive
│ │ │ ├── receive.css
│ │ │ └── index.js
│ │ ├── Homepage
│ │ │ ├── styled.js
│ │ │ └── index.js
│ │ ├── Header
│ │ │ ├── index.js
│ │ │ └── styled.js
│ │ └── Tabs
│ │ │ ├── styled.js
│ │ │ └── index.js
│ ├── index.css
│ ├── components
│ │ ├── common
│ │ │ ├── Embed
│ │ │ │ ├── styled.js
│ │ │ │ └── index.js
│ │ │ └── Layout.js
│ │ └── Grid
│ │ │ ├── styled.js
│ │ │ └── TransactionsGrid.js
│ ├── index.js
│ ├── App.test.js
│ ├── App.css
│ ├── assets
│ │ └── img
│ │ │ ├── send_checked.svg
│ │ │ ├── receive_checked.svg
│ │ │ ├── send_unchecked.svg
│ │ │ ├── receive_unchecked.svg
│ │ │ ├── send_transaction.svg
│ │ │ ├── receive_transaction.svg
│ │ │ ├── chip.svg
│ │ │ ├── history_checked.svg
│ │ │ ├── history_unchecked.svg
│ │ │ └── bitcoin-logo.svg
│ ├── logo.svg
│ ├── services
│ │ └── InsightAPI.js
│ ├── registerServiceWorker.js
│ └── App.js
├── public
│ ├── favicon.ico
│ ├── manifest.json
│ ├── index.html
│ └── serial.js
├── .gitignore
├── package.json
└── README.md
├── LICENSE
├── README.md
└── arduino
├── serial_only_wallet
└── serial_only_wallet.ino
└── hardware_wallet
└── hardware_wallet.ino
/ui/src/containers/Send/send.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/ui/src/containers/Receive/receive.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/ui/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: sans-serif;
5 | }
6 |
--------------------------------------------------------------------------------
/ui/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arduino-bitcoin/simple_hardware_wallet/HEAD/ui/public/favicon.ico
--------------------------------------------------------------------------------
/ui/src/components/common/Embed/styled.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Wrapper = styled.embed`
4 | width: 250px;
5 | height: 250px;
6 | margin-top: 40px;
7 | `;
8 |
--------------------------------------------------------------------------------
/ui/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 | import registerServiceWorker from './registerServiceWorker';
6 |
7 | ReactDOM.render(, document.getElementById('root'));
8 | registerServiceWorker();
9 |
--------------------------------------------------------------------------------
/ui/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 |
5 | it('renders without crashing', () => {
6 | const div = document.createElement('div');
7 | ReactDOM.render(, div);
8 | ReactDOM.unmountComponentAtNode(div);
9 | });
10 |
--------------------------------------------------------------------------------
/ui/src/components/common/Layout.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Layout = styled.div`
4 | display: grid;
5 | background-color: #fff;
6 | grid-template-columns: 196px calc(100% - 196px);
7 | background-color: #f4f8f9;
8 | min-height: 500px;
9 | overflow: hidden;
10 | min-height: calc(100vh - 65px);
11 | `;
12 |
--------------------------------------------------------------------------------
/ui/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 | yarn.lock
23 | package-lock.json
24 |
--------------------------------------------------------------------------------
/ui/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Arduino hardware wallet ui",
3 | "name": "Simple hardware wallet for Arduino user interface",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/ui/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .App-logo {
6 | animation: App-logo-spin infinite 20s linear;
7 | height: 80px;
8 | }
9 |
10 | .App-header {
11 | background-color: #222;
12 | height: 150px;
13 | padding: 20px;
14 | color: white;
15 | }
16 |
17 | .App-title {
18 | font-size: 1.5em;
19 | }
20 |
21 | .App-intro {
22 | font-size: large;
23 | }
24 |
25 | @keyframes App-logo-spin {
26 | from { transform: rotate(0deg); }
27 | to { transform: rotate(360deg); }
28 | }
29 |
--------------------------------------------------------------------------------
/ui/src/assets/img/send_checked.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/ui/src/assets/img/receive_checked.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/ui/src/assets/img/send_unchecked.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/ui/src/components/common/Embed/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import Proptypes from 'prop-types';
3 | import { Wrapper } from './styled';
4 |
5 | class Embed extends Component {
6 | render() {
7 | const { src } = this.props;
8 |
9 | if(!src) {
10 | return null;
11 | }
12 |
13 | return (
14 |
15 | );
16 | }
17 | }
18 |
19 | Embed.proptypes = {
20 | src: Proptypes.string
21 | }
22 |
23 | export default Embed;
24 |
--------------------------------------------------------------------------------
/ui/src/assets/img/receive_unchecked.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/ui/src/assets/img/send_transaction.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/ui/src/assets/img/receive_transaction.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/ui/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "simple-hardware-wallet",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "axios": "^0.18.0",
7 | "bitcoinjs-lib": "^4.0.1",
8 | "prop-types": "^15.6.2",
9 | "react": "^16.4.2",
10 | "react-dom": "^16.4.2",
11 | "react-router-dom": "^4.3.1",
12 | "react-scripts": "1.1.4",
13 | "socket.io": "^2.1.1",
14 | "styled-components": "^3.4.5"
15 | },
16 | "scripts": {
17 | "start": "react-scripts start",
18 | "build": "react-scripts build",
19 | "test": "react-scripts test --env=jsdom",
20 | "eject": "react-scripts eject"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/ui/src/containers/Homepage/styled.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const HomepageWrapper = styled.div`
4 | float: left;
5 | width: 100%;
6 | height: 100%;
7 | `;
8 |
9 | export const Title = styled.span`
10 | margin: 27px 35px;
11 | font-size: 20px;
12 | font-weight: bold;
13 | letter-spacing: 0.1px;
14 | color: #1a173b;
15 | text-align: left;
16 | float: left;
17 | `;
18 |
19 | export const TransactionsFragment = styled.div`
20 | float: left;
21 | margin: 27px 35px;
22 | width: calc(100% - 70px);
23 | background: white;
24 | border-radius: 3.5px;
25 | border: solid 1px #ebedf8;
26 |
27 | >div {
28 | padding: 50px;
29 | }
30 | `;
31 |
--------------------------------------------------------------------------------
/ui/src/assets/img/chip.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/ui/src/containers/Homepage/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import Proptypes from 'prop-types';
3 | import { HomepageWrapper, Title, TransactionsFragment } from './styled';
4 |
5 | // Transactions Grid
6 | import TransactionsGrid from '../../components/Grid/TransactionsGrid';
7 |
8 | class Homepage extends Component {
9 | render() {
10 | const { address, transactions } = this.props;
11 | return (
12 |
13 |
14 | Recents transactions
15 |
16 |
17 |
21 |
22 |
23 | );
24 | }
25 | }
26 |
27 | Homepage.proptypes = {
28 | address: Proptypes.string,
29 | transactions: Proptypes.array.isRequired,
30 | }
31 |
32 | export default Homepage;
33 |
--------------------------------------------------------------------------------
/ui/README.md:
--------------------------------------------------------------------------------
1 | # React UI for simple-hardware-wallet
2 |
3 | ## Available Scripts
4 |
5 | In the project directory, you can run:
6 |
7 | ### `npm install`
8 |
9 | Install the project dependencies.
10 |
11 | ### `npm start`
12 |
13 | Runs the app in the development mode.
14 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
15 |
16 | The page will reload if you make edits.
17 | You will also see any lint errors in the console.
18 |
19 | ### `npm test`
20 |
21 | Launches the test runner in the interactive watch mode.
22 | See the section about [running tests](#running-tests) for more information.
23 |
24 | ### `npm run build`
25 |
26 | Builds the app for production to the `build` folder.
27 | It correctly bundles React in production mode and optimizes the build for the best performance.
28 |
29 | The build is minified and the filenames include the hashes.
30 | Your app is ready to be deployed!
31 |
32 | See the section about [deployment](#deployment) for more information.
33 |
34 | ### `npm run eject`
35 |
--------------------------------------------------------------------------------
/ui/src/containers/Receive/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import Embed from '../../components/common/Embed';
3 | import './receive.css';
4 |
5 | class Receive extends Component {
6 | // TODO: add a input for the amount, so we can add it to the qr code.
7 | // ex: https://chart.googleapis.com/chart?chs=250x250&cht=qr&chl=bitcoin:${ADDRESS}?&amount=${AMOUNT_IN_BTC}
8 | getQrCodeSrc(address) {
9 | if(!address) {
10 | return null;
11 | }
12 | return `https://chart.googleapis.com/chart?chs=250x250&cht=qr&chl=bitcoin:${address}`;
13 |
14 | }
15 |
16 | render() {
17 | const { address } = this.props;
18 | const qrCodeSrc = this.getQrCodeSrc(address);
19 | return (
20 |
21 |
This is the receive container!
22 | {!!address &&
Your Bitcoin address: {address}
}
23 |
24 |
25 |
26 | );
27 | }
28 | }
29 |
30 | export default Receive;
31 |
--------------------------------------------------------------------------------
/ui/src/containers/Header/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | import BitcoinLogo from '../../assets/img/bitcoin-logo.svg';
4 | import Chip from '../../assets/img/chip.svg';
5 |
6 | import { Wrapper, Logo, DeviceFragment, DeviceTitle, Connected } from './styled';
7 |
8 | class Header extends Component {
9 | render() {
10 | return (
11 |
12 |
15 |
16 |
17 |
21 | Device
22 |
23 |
27 | {this.props.isConnected ? 'Connected' : 'Not connected'}
28 |
29 |
30 |
31 | );
32 | }
33 | }
34 |
35 | export default Header;
36 |
--------------------------------------------------------------------------------
/ui/src/containers/Header/styled.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Wrapper = styled.header`
4 | height: 64px;
5 | width: 100%;
6 | display: inline-block;
7 | background: white;
8 | border-bottom: solid 1px #e8e8e8;
9 | margin-bottom: -4px;
10 | `;
11 |
12 | export const Logo = styled.img`
13 | float: left;
14 | height: 30px;
15 | width: 20px;
16 | margin: 17px 30px;
17 | `;
18 |
19 | export const DeviceFragment = styled.div`
20 | min-width: 200px;
21 | height: 100%;
22 | float: right;
23 | padding-right: 30px;
24 | `;
25 |
26 | export const DeviceTitle = styled.div`
27 | margin-top: 14px;
28 | text-align: right;
29 |
30 | >span {
31 | width: 100%;
32 | opacity: 0.5;
33 | font-family: Lato;
34 | font-size: 10px;
35 | font-weight: bold;
36 | letter-spacing: 0.1px;
37 | color: #323c47;
38 | }
39 |
40 | >img {
41 | margin-right: 6px;
42 | }
43 | `;
44 |
45 | export const Connected = styled.div`
46 | text-align: right;
47 | color: ${(props) => props.isConnected ? '#0077ff' : 'red'}
48 | `;
49 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018
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 all
13 | 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 THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/ui/src/assets/img/history_checked.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/ui/src/containers/Send/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import './send.css';
3 |
4 | class SendForm extends React.Component {
5 | constructor(props) {
6 | super(props);
7 | this.handleSubmit = this.handleSubmit.bind(this);
8 | this.address = React.createRef();
9 | this.amount = React.createRef();
10 | }
11 |
12 | handleSubmit(event) {
13 | event.preventDefault();
14 | const address = this.address.current.value;
15 | const amount = this.amount.current.value;
16 | this.props.signTx(address, Number(amount));
17 | }
18 |
19 | render() {
20 | return (
21 |
32 | );
33 | }
34 | }
35 |
36 | class Send extends Component {
37 | render() {
38 | return (
39 |
40 |
This is the send container!
41 | {this.props.connected &&
}
42 |
43 | );
44 | }
45 | }
46 |
47 | export default Send;
48 |
--------------------------------------------------------------------------------
/ui/src/assets/img/history_unchecked.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/ui/src/containers/Tabs/styled.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const TabsWrapper = styled.div`
4 | width: 100%;
5 | float: left;
6 | height: 100%;
7 | border-right: solid 1px #e8e8e8;
8 | background: white;
9 | display: inline-block;
10 | padding: 10px 0px;
11 | `;
12 |
13 |
14 | export const Tab = styled.div`
15 | margin: 20px 0;
16 | float: left;
17 | line-height: 65px;
18 | padding: 0 30px;
19 | float: left;
20 | height: 50px;
21 | line-height: 50px;
22 | color: #637280;
23 | font-size: 13.7px;
24 | letter-spacing: 0.1px;
25 | color: #C0C5D2;
26 | position: relative;
27 |
28 |
29 | ${(props) => {
30 | if(props.active) {
31 | return 'border-left: solid 2px #0290ff;padding-left: 28px;font-weight: bold;color: #1880e7;'
32 | }
33 |
34 | }}
35 |
36 | > a {
37 | float: left;
38 | width: 100%;
39 | height: 100%;
40 | text-decoration: none;
41 | color: inherit;
42 |
43 | }
44 |
45 | span {
46 | padding-left: 36px;
47 | }
48 |
49 | img {
50 | position: absolute;
51 | height: ${(props) => props.imgSize || '18px'};
52 | width: ${(props) => props.imgSize || '18px'};
53 | top: 50%;
54 | transform: translateY(-50%);
55 |
56 | ${(props) => {
57 | if(props.active) {
58 | return 'left: 28px;'
59 | } else {
60 | return 'left: 30px;';
61 | }
62 |
63 | }}
64 |
65 |
66 | }
67 |
68 |
69 | `;
70 |
--------------------------------------------------------------------------------
/ui/src/components/Grid/styled.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const TransactionsWrapper = styled.div`
4 | display: grid;
5 | grid-template-columns: 4fr repeat(4, 1fr);
6 | grid-template-rows: 21px repeat(auto-fill, 28px);
7 | text-align: left;
8 | grid-row-gap: 18px;
9 | grid-column-gap: 10px;
10 | `;
11 |
12 | export const Header = styled.div`
13 | height: 11px;
14 | font-size: 9.5px;
15 | letter-spacing: 1.4px;
16 | color: #b4bac6;
17 | padding-bottom: 10px;
18 | text-transform: uppercase;
19 | text-align: ${(props) => props.textCenter ? 'center' : 'left'};
20 | `;
21 |
22 | export const Cell = styled.div`
23 | font-size: 14px;
24 | letter-spacing: 0.1px;
25 | color: #8a96a0;
26 | line-height: 28px;
27 | `;
28 |
29 | export const BigCell = styled(Cell)`
30 | font-weight: bold;
31 | letter-spacing: normal;
32 | color: #354052;
33 |
34 | >img {
35 | float: left;
36 | height: 20px;
37 | width: 20px;
38 | margin: 4px 20px 4px 0;
39 | }
40 |
41 | >span {
42 | float: left;
43 | width: calc(100% - 40px);
44 | height: 100%;
45 | text-overflow: ellipsis;
46 | white-space: nowrap;
47 | overflow: hidden;
48 | }
49 | `;
50 |
51 | export const Status = styled.div`
52 | padding: 0 17px;
53 | height: 28px;
54 | font-family: Lato;
55 | border-radius: 3.6px;
56 | font-size: 13px;
57 | font-weight: bold;
58 | color: white;
59 | text-align: center;
60 | background-color: ${(props) => props.color || '#cf5757'};
61 | float: right;
62 | `;
63 |
--------------------------------------------------------------------------------
/ui/src/assets/img/bitcoin-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
20 |
--------------------------------------------------------------------------------
/ui/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
14 |
15 |
16 |
25 | Arduino hardware wallet user interface
26 |
27 |
28 |
29 |
32 |
33 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/ui/src/containers/Tabs/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Link, withRouter } from 'react-router-dom'
3 |
4 | import { TabsWrapper, Tab } from './styled';
5 |
6 | import HistoryUnchecked from '../../assets/img/history_unchecked.svg';
7 | import HistoryChecked from '../../assets/img/history_checked.svg';
8 | import ReceiveUnchecked from '../../assets/img/receive_unchecked.svg';
9 | import ReceiveChecked from '../../assets/img/receive_checked.svg';
10 | import SendUnchecked from '../../assets/img/send_unchecked.svg';
11 | import SendChecked from '../../assets/img/send_checked.svg';
12 |
13 | const HOMEPAGE_ROUTE = '/';
14 | const SEND_ROUTE = '/send';
15 | const RECEIVE_ROUTE = '/receive';
16 |
17 | class Tabs extends Component {
18 | isActive(target) {
19 | return target === this.props.location.pathname
20 | }
21 |
22 | render() {
23 | return (
24 |
25 |
29 |
30 |
34 | TX history
35 |
36 |
37 |
41 |
42 |
46 | Send
47 |
48 |
49 |
53 |
54 |
58 | Receive
59 |
60 |
61 |
62 | );
63 | }
64 | }
65 |
66 | export default withRouter(Tabs);
67 |
--------------------------------------------------------------------------------
/ui/public/serial.js:
--------------------------------------------------------------------------------
1 | var serial = {};
2 |
3 | (function() {
4 | 'use strict';
5 |
6 | serial.getPorts = function() {
7 | return navigator.usb.getDevices().then(devices => {
8 | return devices.map(device => new serial.Port(device));
9 | });
10 | };
11 |
12 | serial.requestPort = function() {
13 | const filters = [
14 | { 'vendorId': 0x2341, 'productId': 0x8036 },
15 | { 'vendorId': 0x2341, 'productId': 0x8037 },
16 | { 'vendorId': 0x2341, 'productId': 0x804d },
17 | { 'vendorId': 0x2341, 'productId': 0x804e },
18 | { 'vendorId': 0x2341, 'productId': 0x804f },
19 | { 'vendorId': 0x2341, 'productId': 0x8050 },
20 | // Feather MO is the next line
21 | // Other than this line the entire flie was copied from
22 | // arduino-webusb examples
23 | { 'vendorId': 0x239a, 'productId': 0x800b },
24 | ];
25 | return navigator.usb.requestDevice({ 'filters': filters }).then(
26 | device => new serial.Port(device)
27 | );
28 | }
29 |
30 | serial.Port = function(device) {
31 | this.device_ = device;
32 | };
33 |
34 | serial.Port.prototype.connect = function() {
35 | let readLoop = () => {
36 | this.device_.transferIn(5, 64).then(result => {
37 | this.onReceive(result.data);
38 | readLoop();
39 | }, error => {
40 | this.onReceiveError(error);
41 | });
42 | };
43 |
44 | return this.device_.open()
45 | .then(() => {
46 | if (this.device_.configuration === null) {
47 | return this.device_.selectConfiguration(1);
48 | }
49 | })
50 | .then(() => this.device_.claimInterface(2))
51 | .then(() => this.device_.selectAlternateInterface(2, 0))
52 | .then(() => this.device_.controlTransferOut({
53 | 'requestType': 'class',
54 | 'recipient': 'interface',
55 | 'request': 0x22,
56 | 'value': 0x01,
57 | 'index': 0x02}))
58 | .then(() => {
59 | readLoop();
60 | });
61 | };
62 |
63 | serial.Port.prototype.disconnect = function() {
64 | return this.device_.controlTransferOut({
65 | 'requestType': 'class',
66 | 'recipient': 'interface',
67 | 'request': 0x22,
68 | 'value': 0x00,
69 | 'index': 0x02})
70 | .then(() => this.device_.close());
71 | };
72 |
73 | serial.Port.prototype.send = function(data) {
74 | return this.device_.transferOut(4, data);
75 | };
76 | })();
77 |
--------------------------------------------------------------------------------
/ui/src/logo.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/ui/src/services/InsightAPI.js:
--------------------------------------------------------------------------------
1 | import bitcoin from 'bitcoinjs-lib';
2 |
3 | function clean(str){
4 | return str.replace(/[^0-9a-z]/gi, '');
5 | }
6 |
7 | class InsightAPI {
8 | constructor(options){
9 | let defaults = {
10 | url: "https://test-insight.bitpay.com/api/",
11 | network: bitcoin.networks.testnet,
12 | };
13 | Object.assign(this, defaults, options);
14 | }
15 |
16 | async balance(address){
17 | let result = await fetch(this.url + "addr/" + address);
18 | let json = await result.json();
19 | return json.balanceSat + json.unconfirmedBalanceSat;
20 | }
21 |
22 | async transactions(address){
23 | let result = await fetch(this.url + "addr/" + address);
24 | let json = await result.json();
25 | return json.transactions;
26 | }
27 |
28 | async transactionDetails(transactionId){
29 | let result = await fetch(this.url + "tx/" + transactionId);
30 | let json = await result.json();
31 | return json;
32 | }
33 |
34 | async utxo(address){
35 | let result = await fetch(this.url + "addr/" + address + "/utxo");
36 | let json = await result.json();
37 | return json;
38 | }
39 |
40 | async buildTx(my_address, address, amount, fee = 1500){
41 | // cleaning up random characters
42 | address = clean(address);
43 | my_address = clean(my_address);
44 |
45 | let builder = new bitcoin.TransactionBuilder(this.network);
46 |
47 | let utxo = await this.utxo(my_address);
48 | let total = 0;
49 | for(let i = 0; i < utxo.length; i++){
50 | let tx = utxo[i];
51 | total += tx.satoshis;
52 | builder.addInput(tx.txid, tx.vout);
53 | if(total > amount+fee){
54 | break;
55 | }
56 | }
57 | if(total < amount+fee){
58 | throw "Not enough funds";
59 | }
60 | console.log(address, amount, address.length);
61 | console.log(my_address, total - amount - fee, my_address.length);
62 |
63 | builder.addOutput(address, amount);
64 | builder.addOutput(my_address, total - amount - fee); // change
65 | return builder.buildIncomplete().toHex()
66 | }
67 |
68 | async broadcast(tx){
69 | tx = clean(tx);
70 | console.log("broadcasting tx:", tx);
71 | const result = await fetch(this.url + "tx/send", {
72 | method: "POST",
73 | mode: "cors",
74 | cache: "no-cache",
75 | credentials: "same-origin", // include, same-origin, *omit
76 | headers: {
77 | "Content-Type": "application/json; charset=utf-8",
78 | },
79 | redirect: "follow",
80 | referrer: "no-referrer",
81 | body: JSON.stringify({ rawtx: tx }),
82 | })
83 | const text = await result.text();
84 | console.log(text);
85 | }
86 | }
87 |
88 | export default InsightAPI;
89 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Simple hardware wallet for Arduino
2 |
3 | A minimalistic hardware wallet working with electrum transactions.
4 |
5 | This sketch is a simple demo that shows how to use [arduino-bitcoin](https://github.com/arduino-bitcoin/arduino-bitcoin) library to build your own hardware wallet.
6 |
7 | It should be used only for educational or testing purposes as default Arduino boards are not secure, their firmware can be updated from the computer and this process doesn't require any user interaction.
8 |
9 | A manual on how to make it more secure will follow.
10 |
11 | ## Required hardware
12 |
13 | - [Adafruit M0 Adalogger board with SD card](https://www.adafruit.com/product/2796)
14 | - [Adafruit OLED screen](https://www.adafruit.com/product/2900)
15 | - [Headers](https://www.adafruit.com/product/2886)
16 | - Soldering station to solder the headers and pins
17 | - USB cable
18 | - SD card (16 GB or less work fine, not sure about larger)
19 |
20 | If you don't have an OLED screen you can try it out with [serial only wallet](./arduino/serial_only_wallet/serial_only_wallet.ino).
21 |
22 | ## Uploading firmware
23 |
24 | Follow the manuals from Adafruit to set up the board and OLED screen:
25 |
26 | - [Adding the board to Arduino IDE](https://learn.adafruit.com/adafruit-feather-m0-adalogger/setup)
27 | - [Installing OLED library](https://learn.adafruit.com/adafruit-oled-featherwing/featheroled-library)
28 | - Install [arduino-bitcoin](https://github.com/arduino-bitcoin/arduino-bitcoin) library
29 | - Upload [the sketch](./arduino/hardware_wallet/hardware_wallet.ino) to the board
30 |
31 | ## Setting up
32 |
33 | Put a `xprv.txt` file on the SD card with your xprv key (for testnet it will start with tprv). You can generate one [here](https://iancoleman.io/bip39/).
34 |
35 | Communication with the wallet happens over USB. Open Serial Monitor in the Arduino IDE and type commands.
36 |
37 | Keys are stored UNENCRYPTED AS A PLAIN TEXT on SD card.
38 |
39 | Available commands:
40 |
41 | - `xpub` - returns a master public key that you can import to electrum or any other watch-only wallet
42 | - `addr `, for example `addr 5` - returns a receiving address derived from xpub `/0/n/`, also shows it on the OLED screen
43 | - `changeaddr ` - returns a change address derived from xpub `/1/n/` and shows it on the OLED screen
44 | - `sign_tx ` - parses unsigned transaction, asks user for confirmation showing outputs one at a time. User can scroll to another output with button B, confirm with button A and cancel with button C. If user confirmed, wallet will sign a transaction and send it back via serial in hex format. This transaction can be broadcasted to the network from electrum console using `broadcast("")` command or just go to [blockcypher](https://live.blockcypher.com/btc-testnet/pushtx/) and broadcast it there.
45 |
46 | ## Future development
47 |
48 | This sketch will evolve, we would love to add:
49 |
50 | - native segwit and segwit nested in p2sh support
51 | - generation of a new key
52 | - encryption of the key on the SD card
53 | - mnemonic support
54 | - [PSBT](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki) support
55 | - multisig support
56 | - electrum plugin
57 |
--------------------------------------------------------------------------------
/ui/src/components/Grid/TransactionsGrid.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import Proptypes from 'prop-types';
3 | import { TransactionsWrapper, Header, Cell, BigCell, Status } from './styled';
4 |
5 | import SendTransactionIcon from '../../assets/img/send_transaction.svg';
6 | import ReceiveTransactionIcon from '../../assets/img/receive_transaction.svg';
7 |
8 | class TransactionsGrid extends Component {
9 |
10 | calculateValue(myAddress, transaction, isSendTransaction) {
11 | let totalValue = 0;
12 |
13 | transaction.vout.map(({ value, scriptPubKey }) => {
14 | return scriptPubKey.addresses.map((address) => {
15 | if (isSendTransaction) {
16 | if (address !== myAddress) {
17 | totalValue += parseFloat(value);
18 | }
19 | } else {
20 | if (address === myAddress) {
21 | totalValue += parseFloat(value);
22 | }
23 | }
24 |
25 | return totalValue;
26 | })
27 | })
28 |
29 | return totalValue;
30 | }
31 |
32 | // @dev - Display the transactions
33 | renderTransactions() {
34 | const { address, transactions } = this.props;
35 |
36 | return transactions
37 | .sort((a, b) => a.confirmations - b.confirmations)
38 | .map((transaction, index) => {
39 | // TODO find another way to check if it a received or send transaction
40 | const isSendTransaction = address === transaction.vin[0].addr;
41 | const imgSrc = isSendTransaction ? SendTransactionIcon : ReceiveTransactionIcon;
42 |
43 | const transactionTime = new Date(transaction.time * 1000);
44 | const timeOptions = { hour: '2-digit', minute:'2-digit' };
45 | return [
46 |
50 |
54 |
55 | {transaction.txid}
56 |
57 | ,
58 |
61 | {transactionTime.toLocaleTimeString(navigator.language, timeOptions)}
62 | | ,
63 |
66 | {transactionTime.toLocaleDateString()}
67 | | ,
68 |
71 | {this.calculateValue(address, transaction, isSendTransaction)}
72 | | ,
73 |
76 | {this.renderTransactionStatus(transaction.confirmations, isSendTransaction)}
77 | | ,
78 | ];
79 | })
80 | }
81 |
82 | renderTransactionStatus(confirmations = 0, isSendTransaction) {
83 | if (confirmations === 0) {
84 | return Pending;
85 | }
86 |
87 | if (confirmations <= 6) {
88 | return {`${confirmations} ${confirmations === 1 ? 'confirmation' : 'confirmations'}`} ;
89 | }
90 |
91 | const msg = isSendTransaction ? 'Sent' : 'Received';
92 |
93 | return {msg};
94 | }
95 |
96 | render() {
97 | return (
98 |
99 |
100 |
101 |
102 |
103 |
104 | {this.renderTransactions()}
105 |
106 | );
107 | }
108 | }
109 |
110 | TransactionsGrid.Proptypes = {
111 | address: Proptypes.string,
112 | transactions: Proptypes.array.isRequired,
113 | }
114 |
115 | export default TransactionsGrid;
116 |
--------------------------------------------------------------------------------
/ui/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(
17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
18 | )
19 | );
20 |
21 | export default function register() {
22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
23 | // The URL constructor is available in all browsers that support SW.
24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
25 | if (publicUrl.origin !== window.location.origin) {
26 | // Our service worker won't work if PUBLIC_URL is on a different origin
27 | // from what our page is served on. This might happen if a CDN is used to
28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
29 | return;
30 | }
31 |
32 | window.addEventListener('load', () => {
33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
34 |
35 | if (isLocalhost) {
36 | // This is running on localhost. Lets check if a service worker still exists or not.
37 | checkValidServiceWorker(swUrl);
38 |
39 | // Add some additional logging to localhost, pointing developers to the
40 | // service worker/PWA documentation.
41 | navigator.serviceWorker.ready.then(() => {
42 | console.log(
43 | 'This web app is being served cache-first by a service ' +
44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ'
45 | );
46 | });
47 | } else {
48 | // Is not local host. Just register service worker
49 | registerValidSW(swUrl);
50 | }
51 | });
52 | }
53 | }
54 |
55 | function registerValidSW(swUrl) {
56 | navigator.serviceWorker
57 | .register(swUrl)
58 | .then(registration => {
59 | registration.onupdatefound = () => {
60 | const installingWorker = registration.installing;
61 | installingWorker.onstatechange = () => {
62 | if (installingWorker.state === 'installed') {
63 | if (navigator.serviceWorker.controller) {
64 | // At this point, the old content will have been purged and
65 | // the fresh content will have been added to the cache.
66 | // It's the perfect time to display a "New content is
67 | // available; please refresh." message in your web app.
68 | console.log('New content is available; please refresh.');
69 | } else {
70 | // At this point, everything has been precached.
71 | // It's the perfect time to display a
72 | // "Content is cached for offline use." message.
73 | console.log('Content is cached for offline use.');
74 | }
75 | }
76 | };
77 | };
78 | })
79 | .catch(error => {
80 | console.error('Error during service worker registration:', error);
81 | });
82 | }
83 |
84 | function checkValidServiceWorker(swUrl) {
85 | // Check if the service worker can be found. If it can't reload the page.
86 | fetch(swUrl)
87 | .then(response => {
88 | // Ensure service worker exists, and that we really are getting a JS file.
89 | if (
90 | response.status === 404 ||
91 | response.headers.get('content-type').indexOf('javascript') === -1
92 | ) {
93 | // No service worker found. Probably a different app. Reload the page.
94 | navigator.serviceWorker.ready.then(registration => {
95 | registration.unregister().then(() => {
96 | window.location.reload();
97 | });
98 | });
99 | } else {
100 | // Service worker found. Proceed as normal.
101 | registerValidSW(swUrl);
102 | }
103 | })
104 | .catch(() => {
105 | console.log(
106 | 'No internet connection found. App is running in offline mode.'
107 | );
108 | });
109 | }
110 |
111 | export function unregister() {
112 | if ('serviceWorker' in navigator) {
113 | navigator.serviceWorker.ready.then(registration => {
114 | registration.unregister();
115 | });
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/arduino/serial_only_wallet/serial_only_wallet.ino:
--------------------------------------------------------------------------------
1 | // bitcoin library
2 | #include
3 | #include
4 |
5 | // SD card libs
6 | #include
7 | #include
8 |
9 | // root key (master)
10 | HDPrivateKey rootKey;
11 | // account key (m/44'/1'/0'/)
12 | HDPrivateKey hd;
13 |
14 | // set to false to use on mainnet
15 | #define USE_TESTNET true
16 |
17 | void show(String msg, bool done=true){
18 | Serial.println(msg);
19 | }
20 |
21 | // uses last bit of the analogRead values
22 | // to generate a random byte
23 | byte getRandomByte(int analogInput = A0){
24 | byte val = 0;
25 | for(int i = 0; i < 8; i++){
26 | int init = analogRead(analogInput);
27 | int count = 0;
28 | // waiting for analog value to change
29 | while(analogRead(analogInput) == init){
30 | ++count;
31 | }
32 | // if we've got a new value right away
33 | // use last bit of the ADC
34 | if (count == 0) {
35 | val = (val << 1) | (init & 0x01);
36 | } else { // if not, use last bit of count
37 | val = (val << 1) | (count & 0x01);
38 | }
39 | }
40 | }
41 |
42 | HDPrivateKey getRandomKey(int analogInput = A0){
43 | byte seed[64];
44 | // fill seed with random bytes
45 | for(int i=0; i sha512(seed)
50 | sha512(seed, sizeof(seed), seed);
51 | HDPrivateKey key;
52 | key.fromSeed(seed, sizeof(seed), USE_TESTNET);
53 | return key;
54 | }
55 |
56 | bool requestTransactionSignature(Transaction tx){
57 | // clean serial buffer
58 | while(Serial.available()){
59 | Serial.read();
60 | }
61 | Serial.println("Sign transaction?");
62 | for(int i=0; i<2-byte index1><2-byte index2>
114 | byte arr[100] = { 0 };
115 | // serialize() will add script len varint in the beginning
116 | // serializeScript will give only script content
117 | size_t scriptLen = tx.txIns[i].scriptSig.serializeScript(arr, sizeof(arr));
118 | // it's enough to compare public keys of hd keys
119 | byte sec[33];
120 | hd.privateKey.publicKey().sec(sec, 33);
121 | if(memcmp(sec, arr+50, 33) != 0){
122 | Serial.print("error: wrong key on input ");
123 | Serial.println(i);
124 | show("Wrong master pubkey!");
125 | return;
126 | }
127 | int index1 = littleEndianToInt(arr+scriptLen-4, 2);
128 | int index2 = littleEndianToInt(arr+scriptLen-2, 2);
129 | tx.signInput(i, hd.child(index1).child(index2).privateKey);
130 | }
131 | show("ok, signed");
132 | Serial.print("success: ");
133 | Serial.println(tx);
134 | }else{
135 | show("cancelled");
136 | Serial.println("error: user cancelled");
137 | }
138 | }
139 |
140 | void load_xprv(){
141 | show("Loading private key");
142 | // open the file. note that only one file can be open at a time,
143 | // so you have to close this one before opening another.
144 | File file = SD.open("xprv.txt");
145 | char xprv_buf[120] = { 0 };
146 | if(file){
147 | // read content from the file to buffer
148 | size_t len = file.available();
149 | if(len > sizeof(xprv_buf)){
150 | len = sizeof(xprv_buf);
151 | }
152 | file.read(xprv_buf, len);
153 | // close the file
154 | file.close();
155 | // import hd key from buffer
156 | HDPrivateKey imported_hd(xprv_buf);
157 | if(imported_hd){ // check if parsing was successfull
158 | Serial.println("success: private key loaded");
159 | rootKey = imported_hd;
160 | // we will use bip44: m/44'/coin'/0'
161 | // coin = 1 for testnet, 0 for mainnet
162 | hd = rootKey.hardenedChild(44).hardenedChild(USE_TESTNET).hardenedChild(0);
163 | Serial.println(hd.xpub()); // print xpub to serial
164 | }else{
165 | Serial.println("error: can't parse xprv.txt");
166 | }
167 | } else {
168 | Serial.println("error: xprv.txt file is missing");
169 | }
170 | }
171 |
172 | void get_address(char * cmd, bool change=false){
173 | String s(cmd);
174 | int index = s.toInt();
175 | String addr = hd.child(change).child(index).address();
176 | Serial.println(addr);
177 | show(addr);
178 | }
179 |
180 | void generate_key(){
181 | show("Generating new key...");
182 | rootKey = getRandomKey();
183 | hd = rootKey.hardenedChild(44).hardenedChild(USE_TESTNET).hardenedChild(0);
184 | show(hd);
185 | Serial.println("success: random key generated");
186 | Serial.println(hd.xpub());
187 | }
188 |
189 | void parseCommand(char * cmd){
190 | if(memcmp(cmd, "sign_tx", strlen("sign_tx"))==0){
191 | sign_tx(cmd + strlen("sign_tx") + 1);
192 | return;
193 | }
194 | if(memcmp(cmd, "load_xprv", strlen("load_xprv"))==0){
195 | load_xprv();
196 | return;
197 | }
198 | if(memcmp(cmd, "xpub", strlen("xpub"))==0){
199 | Serial.println(hd.xpub());
200 | return;
201 | }
202 | if(memcmp(cmd, "addr", strlen("addr"))==0){
203 | get_address(cmd + strlen("addr"));
204 | return;
205 | }
206 | if(memcmp(cmd, "changeaddr", strlen("changeaddr"))==0){
207 | get_address(cmd + strlen("changeaddr"), true);
208 | return;
209 | }
210 | if(memcmp(cmd, "generate_key", strlen("generate_key"))==0){
211 | generate_key();
212 | return;
213 | }
214 | Serial.println("error: unknown command");
215 | }
216 |
217 | void setup() {
218 | show("I am alive!");
219 | // serial connection
220 | Serial.begin(9600);
221 | // loading master private key
222 | if (!SD.begin(4)){
223 | Serial.println("error: no SD card controller on pin 4");
224 | return;
225 | }
226 | load_xprv();
227 | while(!Serial){
228 | ; // wait for serial port to open
229 | }
230 | show("Ready for requests");
231 | }
232 |
233 | void loop() {
234 | // reads serial port
235 | while(Serial.available()){
236 | // stores new request
237 | char buf[4000] = { 0 };
238 | // reads a line to buf
239 | Serial.readBytesUntil('\n', buf, sizeof(buf));
240 | // parses the command and does something
241 | parseCommand(buf);
242 | // clear the buffer
243 | memset(buf, 0, sizeof(buf));
244 | }
245 | }
246 |
--------------------------------------------------------------------------------
/ui/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { BrowserRouter, Route, Switch } from 'react-router-dom';
3 |
4 | import io from 'socket.io-client'
5 | import InsightAPI from './services/InsightAPI';
6 |
7 | import { Layout } from './components/common/Layout';
8 | import './App.css';
9 |
10 | import Header from './containers/Header';
11 | import Tabs from './containers/Tabs';
12 | import Homepage from './containers/Homepage';
13 | import Send from './containers/Send';
14 | import Receive from './containers/Receive';
15 |
16 | class App extends Component {
17 | constructor(props) {
18 | super(props)
19 | this.state = {
20 | port: undefined,
21 | address: undefined,
22 | buffer: "", // receive buffer from serialport
23 | blockchain: new InsightAPI(),
24 | transactions: [],
25 | }
26 |
27 | this.connect = this.connect.bind(this);
28 | this.reconnect = this.reconnect.bind(this);
29 | this.handleDisconnect = this.handleDisconnect.bind(this);
30 |
31 | // Set handler for USB disconnections
32 | navigator.usb.ondisconnect = this.handleDisconnect;
33 |
34 | // Set handler for USB connections
35 | navigator.usb.onconnect = this.reconnect;
36 | }
37 |
38 | componentDidMount() {
39 | this.reconnect();
40 | }
41 |
42 | connect() {
43 | window.serial.requestPort().then((port) => this.handlePort(port));
44 | }
45 |
46 | reconnect() {
47 | window.serial.getPorts().then(ports => {
48 | if (ports.length === 0) {
49 | console.log("no ports found");
50 | } else {
51 | console.log("found ports:", ports);
52 | // For now, just connect to the first one
53 | this.handlePort(ports[0]);
54 | }
55 | })
56 | }
57 |
58 | disconnect() {
59 | this.state.port.disconnect();
60 | this.setState({ port: undefined });
61 | }
62 |
63 | handleDisconnect(evt) {
64 | if (this.state.port.device_ === evt.device) {
65 | // The device has disconnect
66 | // We need to update the state to reflect this
67 | this.setState({ port: undefined });
68 | }
69 | }
70 |
71 | handlePort(port) {
72 | console.log('Connecting to ' + port.device_.productName + '...');
73 | port.connect().then(() => {
74 | console.log('Connected to port:', port);
75 | port.onReceive = this.handleSerialMessage.bind(this);
76 | port.onReceiveError = this.handleSerialError.bind(this);
77 |
78 | // Save the port object on state
79 | this.setState({ port })
80 |
81 | // Try to load our bitcoin address
82 | const textEncoder = new TextEncoder();
83 | const payload = textEncoder.encode("addr");
84 | port.send(payload)
85 | .catch(error => console.log("Error requesting Bitcoin address", error))
86 |
87 | }, error => {
88 | console.log('Connection error: ' + error);
89 | });
90 | }
91 |
92 | handleSerialMessage(raw) {
93 | let buffer = this.state.buffer;
94 | const textDecoder = new TextDecoder();
95 | const message = textDecoder.decode(raw);
96 | // append new data to buffer
97 | buffer += message;
98 | // check if new line character is there
99 | let index = buffer.indexOf("\n");
100 | if(index < 0){
101 | this.setState({ buffer });
102 | return;
103 | }
104 | let commands = buffer.split("\n");
105 | buffer = commands.pop(); // last unfinished command
106 | this.setState({ buffer });
107 |
108 | // going through all the commands
109 | commands.forEach(message => {
110 | let [command, payload] = message.split(",");
111 | if (command === "addr") {
112 | console.log("received addr message");
113 | const address = payload.replace(/[^0-9a-z]/gi, '');
114 |
115 | this.setState({ address });
116 | this.handleChangeAddress(address);
117 | }
118 | else if (command === "sign_tx") {
119 | console.log("received tx signature");
120 | this.setState({ sign_tx: payload });
121 | this.state.blockchain.broadcast(payload);
122 | }
123 | else if (command === "sign_tx_error") {
124 | console.log("sign_tx error", payload);
125 | }
126 | else {
127 | console.log("unhandled message", message);
128 | }
129 | });
130 |
131 | }
132 |
133 | handleSerialError(error) {
134 | console.log('Serial receive error: ' + error);
135 | }
136 |
137 | // @dev handles setting new address
138 | // We will fetch all the address' transactions and connect to the web socket
139 | handleChangeAddress(newAddress, oldAddress = null) {
140 |
141 | // @dev - If the new address is null, return null
142 | if(!newAddress) {
143 | return null;
144 | }
145 |
146 | // @dev - If the address is the same, we skip reconnecting to the socket
147 | if (newAddress !== oldAddress) {
148 |
149 | const socket = io("https://test-insight.bitpay.com/");
150 | // Start the connection to the bitpay websocket
151 | // TODO as for now we are using the test network, in the future,
152 | // set the network dynamically
153 | socket.on('connect', function() {
154 | // Join the room.
155 | socket.emit('subscribe', 'inv');
156 | });
157 |
158 | socket.on('bitcoind/addresstxid', ({ address, txid }) => { return this.addTransaction(txid) });
159 | socket.on('connect', () => socket.emit('subscribe', 'bitcoind/addresstxid', [ newAddress ]))
160 | }
161 |
162 | // Delete previous transactions
163 | this.setState({ transactions: [] });
164 |
165 | // Fetch all the address' transactions
166 | this.getTransactions(newAddress)
167 | .then((transactions) => {
168 | transactions.map((transactionId) => {
169 | this.addTransaction(transactionId);
170 | })
171 | });
172 |
173 | }
174 |
175 | async signTx(address, amount) {
176 | const unsigned = await this.state.blockchain.buildTx(this.state.address, address, amount);
177 | const textEncoder = new TextEncoder();
178 | const message = "sign_tx " + unsigned;
179 | this.state.port.send(textEncoder.encode(message))
180 | .catch(error => {
181 | console.log('Send error: ' + error);
182 | });
183 | }
184 |
185 | async getTransactions(address) {
186 | return await this.state.blockchain.transactions(address);
187 | }
188 |
189 | async getTransactionDetails(transactionId) {
190 | return await this.state.blockchain.transactionDetails(transactionId);
191 | }
192 |
193 | // @dev - Adds or Updates an transaction in the transactions list
194 | // @params {string} - transaction ID
195 | async addTransaction(transactionId) {
196 | const transactions = this.state.transactions;
197 | return this.getTransactionDetails(transactionId)
198 | .then((transDetails) => {
199 |
200 | // try to find if there is a transaction with Id equal to the {transactionId}
201 | // If there is, update that transaction with new infromation
202 | // If not add that transaction to the transactions array
203 | let isNewTransaction = true;
204 | transactions.map((trans, index) => {
205 | if (trans.txid === transactionId) {
206 |
207 | console.log("Updating transaction:", transactionId);
208 | transactions[index] = transDetails;
209 | isNewTransaction = false;
210 | }
211 | });
212 |
213 | // Is a new transaction?
214 | // If so add it to the array
215 | if (isNewTransaction) {
216 | console.log("Inserting transaction:", transactionId);
217 | return this.setState({ transactions: [...this.state.transactions, transDetails] })
218 | }
219 | });
220 | }
221 |
222 | renderPage() {
223 | const address = this.state.address;
224 | const connected = !!this.state.port;
225 | return (
226 |
227 | } />
228 | } />
230 | } />
231 |
232 | );
233 | }
234 |
235 | render() {
236 | return (
237 |
238 |
239 | this.connect(port)}
241 | disconnect={() => this.disconnect()}
242 | isConnected={!!this.state.port}
243 | />
244 |
245 |
246 | {this.renderPage()}
247 |
248 |
249 |
250 | );
251 | }
252 | }
253 |
254 | export default App;
255 |
--------------------------------------------------------------------------------
/arduino/hardware_wallet/hardware_wallet.ino:
--------------------------------------------------------------------------------
1 | #include
2 |
3 | // bitcoin library
4 | #include
5 | #include
6 |
7 | // screen libs
8 | #include
9 | #include
10 | #include
11 | #include
12 |
13 | // SD card libs
14 | #include
15 | #include
16 |
17 | // root key (master)
18 | HDPrivateKey rootKey;
19 | // account key (m/44'/1'/0'/)
20 | HDPrivateKey hd;
21 |
22 | // the screen
23 | Adafruit_FeatherOLED oled = Adafruit_FeatherOLED();
24 |
25 | // set to false to use on mainnet
26 | #define USE_TESTNET true
27 |
28 |
29 | WebUSB WebUSBSerial(1 /* https:// */, "localhost:3000");
30 |
31 | #define Serial WebUSBSerial
32 |
33 |
34 | // cleans the display and shows message on the screen
35 | void show(String msg, bool done=true){
36 | oled.clearDisplay();
37 | oled.setCursor(0,0);
38 | oled.println(msg);
39 | if(done){
40 | oled.display();
41 | }
42 | }
43 |
44 | void send_command(String command, String payload) {
45 | Serial.println(command + "," + payload);
46 | }
47 |
48 | // uses last bit of the analogRead values
49 | // to generate a random byte
50 | byte getRandomByte(int analogInput = A0){
51 | byte val = 0;
52 | for(int i = 0; i < 8; i++){
53 | int init = analogRead(analogInput);
54 | int count = 0;
55 | // waiting for analog value to change
56 | while(analogRead(analogInput) == init){
57 | ++count;
58 | }
59 | // if we've got a new value right away
60 | // use last bit of the ADC
61 | if (count == 0) {
62 | val = (val << 1) | (init & 0x01);
63 | } else { // if not, use last bit of count
64 | val = (val << 1) | (count & 0x01);
65 | }
66 | }
67 | }
68 |
69 | HDPrivateKey getRandomKey(int analogInput = A0){
70 | byte seed[64];
71 | // fill seed with random bytes
72 | for(int i=0; i sha512(seed)
77 | sha512(seed, sizeof(seed), seed);
78 | HDPrivateKey key;
79 | key.fromSeed(seed, sizeof(seed), USE_TESTNET);
80 | return key;
81 | }
82 |
83 | // This function displays info about transaction.
84 | // As OLED screen is small we show one output at a time
85 | // and use Button B to switch to the next output
86 | // Buttons A and C work as Confirm and Cancel
87 | bool requestTransactionSignature(Transaction tx){
88 | // when digital pins are set with INPUT_PULLUP in the setup
89 | // they show 1 when not pressed, so we need to invert them
90 | bool confirm = !digitalRead(9);
91 | bool not_confirm = !digitalRead(5);
92 | bool more_info = !digitalRead(6);
93 | int i = 0; // index of output that we show
94 | // waiting for user to confirm / cancel
95 | while((!confirm && !not_confirm)){
96 | // show one output on the screen
97 | oled.clearDisplay();
98 | oled.setCursor(0,0);
99 | oled.print("Sign? Output ");
100 | oled.print(i);
101 | oled.println();
102 | TransactionOutput output = tx.txOuts[i];
103 | oled.print(output.address(USE_TESTNET));
104 | oled.println(":");
105 | oled.print(((float)output.amount)/100000);
106 | oled.print(" mBTC");
107 | oled.display();
108 | // waiting user to press any button
109 | while((!confirm && !not_confirm && !more_info)){
110 | confirm = !digitalRead(9);
111 | not_confirm = !digitalRead(5);
112 | more_info = !digitalRead(6);
113 | }
114 | delay(300); // wait to release the button
115 | more_info = false; // reset to default
116 | // scrolling output
117 | i++;
118 | if(i >= tx.outputsNumber){
119 | i=0;
120 | }
121 | }
122 | if(confirm){
123 | show("Ok, confirmed.\nSigning...");
124 | return true;
125 | }else{
126 | show("Cancelled");
127 | return false;
128 | }
129 | }
130 |
131 | void sign_tx(char * cmd){
132 | String success_command = String("sign_tx");
133 | String error_command = String("sign_tx_error");
134 | Transaction tx;
135 | // first we need to convert tx from hex
136 | byte raw_tx[2000];
137 | bool electrum = false;
138 | size_t l = fromHex(cmd, strlen(cmd), raw_tx, sizeof(raw_tx));
139 | if(l == 0){
140 | show("can't decode tx from hex");
141 | send_command(error_command, "can't decode tx from hex");
142 | return;
143 | }
144 | size_t l_parsed;
145 | // check if transaction is from electrum
146 | if(memcmp(raw_tx,"EPTF",4)==0){
147 | // if electrum transaction
148 | l_parsed = tx.parse(raw_tx+6, l-6);
149 | electrum = true;
150 | }else if(memcmp(raw_tx, "PSBT", 4)==0){
151 | // TODO: add PSBT support
152 | send_command(error_command, "PSBT is not supported yet");
153 | return;
154 | }else{
155 | l_parsed = tx.parse(raw_tx, l);
156 | }
157 | // then we parse transaction
158 | if(l_parsed == 0){
159 | show("can't parse tx");
160 | send_command(error_command, "can't parse tx");
161 | return;
162 | }
163 | bool ok = requestTransactionSignature(tx);
164 | if(ok){
165 | for(int i=0; i<2-byte index1><2-byte index2>
173 | byte arr[100] = { 0 };
174 | // serialize() will add script len varint in the beginning
175 | // serializeScript will give only script content
176 | size_t scriptLen = tx.txIns[i].scriptSig.serializeScript(arr, sizeof(arr));
177 | // it's enough to compare public keys of hd keys
178 | byte sec[33];
179 | hd.privateKey.publicKey().sec(sec, 33);
180 | if(memcmp(sec, arr+50, 33) != 0){
181 | show("Wrong master pubkey!");
182 | send_command(error_command, "Wrong master pubkey!");
183 | return;
184 | }
185 | index1 = littleEndianToInt(arr+scriptLen-4, 2);
186 | index2 = littleEndianToInt(arr+scriptLen-2, 2);
187 | }
188 | tx.signInput(i, hd.child(index1).child(index2).privateKey);
189 | }
190 | show("ok, signed");
191 | send_command(success_command, tx);
192 | }else{
193 | show("cancelled");
194 | send_command(error_command, "user cancelled");
195 | }
196 | }
197 |
198 | void load_xprv(){
199 | show("Loading private key");
200 | // open the file. note that only one file can be open at a time,
201 | // so you have to close this one before opening another.
202 | File file = SD.open("xprv.txt");
203 | char xprv_buf[120] = { 0 };
204 | if(file){
205 | // read content from the file to buffer
206 | size_t len = file.available();
207 | if(len > sizeof(xprv_buf)){
208 | len = sizeof(xprv_buf);
209 | }
210 | file.read(xprv_buf, len);
211 | // close the file
212 | file.close();
213 | // import hd key from buffer
214 | HDPrivateKey imported_hd(xprv_buf);
215 | if(imported_hd){ // check if parsing was successfull
216 | // we will use bip44: m/44'/coin'/0'
217 | // coin = 1 for testnet, 0 for mainnet
218 | rootKey = imported_hd;
219 | hd = rootKey.hardenedChild(44).hardenedChild(USE_TESTNET).hardenedChild(0);
220 | show(hd); // show xprv on the screen
221 | send_command("load_xprv", hd.xpub()); // print xpub to serial
222 | }else{
223 | send_command("load_xprv_error", "can't parse xprv.txt");
224 | }
225 | } else {
226 | send_command("load_xprv_error", "xprv.txt file is missing");
227 | }
228 | }
229 |
230 | void get_address(char * cmd, bool change=false){
231 | String s(cmd);
232 | int index = s.toInt();
233 | String addr = hd.child(change).child(index).privateKey.address();
234 | send_command("addr", addr);
235 | show(addr);
236 | }
237 |
238 | void generate_key(String command){
239 | show("Generating new key...");
240 | rootKey = getRandomKey();
241 | hd = rootKey.hardenedChild(44).hardenedChild(USE_TESTNET).hardenedChild(0);
242 | show(hd);
243 | send_command(command, hd.xpub());
244 | }
245 |
246 | void parseCommand(char * cmd){
247 | if(memcmp(cmd, "sign_tx", strlen("sign_tx"))==0){
248 | sign_tx(cmd + strlen("sign_tx") + 1);
249 | return;
250 | }
251 | // TODO: load_xprv
252 | if(memcmp(cmd, "load_xprv", strlen("load_xprv"))==0){
253 | load_xprv();
254 | return;
255 | }
256 | if(memcmp(cmd, "xpub", strlen("xpub"))==0){
257 | send_command("xpub", hd.xpub());
258 | return;
259 | }
260 | if(memcmp(cmd, "addr", strlen("addr"))==0){
261 | get_address(cmd + strlen("addr"));
262 | return;
263 | }
264 | if(memcmp(cmd, "changeaddr", strlen("changeaddr"))==0){
265 | get_address(cmd + strlen("changeaddr"), true);
266 | return;
267 | }
268 | if(memcmp(cmd, "generate_key", strlen("generate_key"))==0){
269 | generate_key("generate_key");
270 | return;
271 | }
272 | // TODO: save_xprv
273 | send_command("error", "unknown command");
274 | }
275 |
276 | void setup() {
277 | // setting buttons as inputs
278 | pinMode(9, INPUT_PULLUP);
279 | pinMode(6, INPUT_PULLUP);
280 | pinMode(5, INPUT_PULLUP);
281 | // screen init
282 | oled.init();
283 | oled.setBatteryVisible(false);
284 | show("I am alive!");
285 | // loading master private key
286 | if (!SD.begin(4)){
287 | // Serial.println("error: no SD card controller on pin 4");
288 | return;
289 | }
290 | load_xprv();
291 | // serial connection
292 | Serial.begin(9600);
293 | while(!Serial){
294 | ; // wait for serial port to open
295 | }
296 | show("Ready for requests");
297 | }
298 |
299 | void loop() {
300 | // reads serial port
301 | while(Serial.available()){
302 | // stores new request
303 | char buf[4000] = { 0 };
304 | // reads a line to buf
305 | Serial.readBytesUntil('\n', buf, sizeof(buf));
306 | // parses the command and does something
307 | parseCommand(buf);
308 | // clear the buffer
309 | memset(buf, 0, sizeof(buf));
310 | }
311 | }
312 |
--------------------------------------------------------------------------------