├── LICENSE ├── README.md ├── arduino ├── hardware_wallet │ └── hardware_wallet.ino └── serial_only_wallet │ └── serial_only_wallet.ino └── ui ├── .gitignore ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── manifest.json └── serial.js └── src ├── App.css ├── App.js ├── App.test.js ├── assets └── img │ ├── bitcoin-logo.svg │ ├── chip.svg │ ├── history_checked.svg │ ├── history_unchecked.svg │ ├── receive_checked.svg │ ├── receive_transaction.svg │ ├── receive_unchecked.svg │ ├── send_checked.svg │ ├── send_transaction.svg │ └── send_unchecked.svg ├── components ├── Grid │ ├── TransactionsGrid.js │ └── styled.js └── common │ ├── Embed │ ├── index.js │ └── styled.js │ └── Layout.js ├── containers ├── Header │ ├── index.js │ └── styled.js ├── Homepage │ ├── index.js │ └── styled.js ├── Receive │ ├── index.js │ └── receive.css ├── Send │ ├── index.js │ └── send.css └── Tabs │ ├── index.js │ └── styled.js ├── index.css ├── index.js ├── logo.svg ├── registerServiceWorker.js └── services └── InsightAPI.js /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/.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/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/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/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arduino-bitcoin/simple_hardware_wallet/cb1429408b6d147ed0630ef8028dec6ea0d5bf99/ui/public/favicon.ico -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /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/assets/img/bitcoin-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /ui/src/assets/img/chip.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /ui/src/assets/img/history_checked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /ui/src/assets/img/history_unchecked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /ui/src/assets/img/receive_checked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /ui/src/assets/img/receive_transaction.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /ui/src/assets/img/receive_unchecked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /ui/src/assets/img/send_checked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /ui/src/assets/img/send_transaction.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /ui/src/assets/img/send_unchecked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /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 | transaction 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 |
description
100 |
time
101 |
date
102 |
value
103 |
status
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/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/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/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/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/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 | chip 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 | -------------------------------------------------------------------------------- /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/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/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/Receive/receive.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arduino-bitcoin/simple_hardware_wallet/cb1429408b6d147ed0630ef8028dec6ea0d5bf99/ui/src/containers/Receive/receive.css -------------------------------------------------------------------------------- /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 |
22 | 26 | 30 | 31 |
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/containers/Send/send.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arduino-bitcoin/simple_hardware_wallet/cb1429408b6d147ed0630ef8028dec6ea0d5bf99/ui/src/containers/Send/send.css -------------------------------------------------------------------------------- /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 | history 34 | TX history 35 | 36 | 37 | 41 | 42 | send 46 | Send 47 | 48 | 49 | 53 | 54 | receive 58 | Receive 59 | 60 | 61 | 62 | ); 63 | } 64 | } 65 | 66 | export default withRouter(Tabs); 67 | -------------------------------------------------------------------------------- /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/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /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/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------