├── .babelrc ├── .eslintrc ├── .flowconfig ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── argparse.js ├── auth.js ├── cli.js ├── data.js ├── encrypt.js ├── index.js ├── keys.js ├── network.js └── utils.js └── tests └── unitTests └── src └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "flow"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parser": "babel-eslint", 4 | "plugins": [], 5 | "ecmaFeatures": { 6 | "jsx": false, 7 | "modules": true 8 | }, 9 | "env": { 10 | "browser": true, 11 | "es6": true, 12 | "node": true, 13 | }, 14 | "rules": { 15 | "comma-dangle": ["error", "never"], 16 | "quotes": [2, "single"], 17 | "eol-last": 2, 18 | "no-debugger": 1, 19 | "no-mixed-requires": 0, 20 | "no-underscore-dangle": 0, 21 | "no-multi-spaces": 0, 22 | "no-trailing-spaces": 0, 23 | "no-extra-boolean-cast": 0, 24 | "no-undef": 2, 25 | "no-unused-vars": 2, 26 | "no-var": 2, 27 | "no-param-reassign": 0, 28 | "no-else-return": 0, 29 | "no-console": 0, 30 | "prefer-const": 2, 31 | "new-cap": 0, 32 | "camelcase": 2, 33 | "semi": [2, "never"], 34 | "valid-jsdoc": ["error"] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/documentation/* 3 | .*/node_modules/.staging/* 4 | .*/blockstack.js/* 5 | 6 | [include] 7 | ../blockstack.js 8 | 9 | [libs] 10 | 11 | [options] 12 | 13 | [lints] 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | # Other 30 | unused 31 | .DS_Store 32 | 33 | # Folder to ignore for development with es6 34 | lib 35 | bundle.js 36 | 37 | src/testing/browser/blockstack-proofs.js 38 | 39 | *.swp 40 | *.swo 41 | .nyc_output 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jude Nelson 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 | # This repository has moved to https://github.com/blockstack/cli-blockstack 2 | 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blockstack-cli", 3 | "version": "0.0.1", 4 | "description": "Node.js Blockstack CLI", 5 | "main": "lib/index", 6 | "scripts": { 7 | "compile": "rm -rf lib; babel src -d lib", 8 | "compile-tests": "rm -rf tests/unitTests/lib; babel tests/unitTests/src -d tests/unitTests/lib;", 9 | "prepublish": "npm run build", 10 | "unit-test": "npm run lint && npm run flow && npm run compile && npm run compile-tests && node ./tests/unitTests/lib/index.js", 11 | "build": "npm run flow && npm run compile && npm run force-executable", 12 | "flow": "flow || true", 13 | "lint": "eslint src && eslint tests", 14 | "test": "nyc --reporter=text npm run unit-test", 15 | "force-executable": "test -f ./lib/index.js && chmod +x ./lib/index.js" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/jcnelson/cli-blockstack.git" 20 | }, 21 | "author": { 22 | "name": "Jude Nelson", 23 | "email": "jude@blockstack.com", 24 | "url": "https://blockstack.com" 25 | }, 26 | "license": "MIT", 27 | "bin": { 28 | "blockstack-cli": "./lib/index.js" 29 | }, 30 | "bugs": { 31 | "url": "https://github.com/jcnelson/cli-blockstack/issues" 32 | }, 33 | "keywords": [ 34 | "blockchain", 35 | "id", 36 | "auth", 37 | "authentication", 38 | "bitcoin", 39 | "blockchain auth", 40 | "blockchain authentication", 41 | "blockchainid", 42 | "blockchain id", 43 | "bitcoin auth", 44 | "bitcoin authentication", 45 | "bitcoin login", 46 | "blockchain login", 47 | "authorization", 48 | "login", 49 | "signin", 50 | "sso", 51 | "crypto", 52 | "cryptography", 53 | "token", 54 | "blockstack", 55 | "blockstack auth", 56 | "profile", 57 | "identity", 58 | "ethereum" 59 | ], 60 | "homepage": "https://blockstack.org", 61 | "contributors": [ 62 | { 63 | "name": "Jude Nelson" 64 | } 65 | ], 66 | "devDependencies": { 67 | "babel-cli": "^6.24.1", 68 | "babel-eslint": "^6.0.4", 69 | "babel-preset-env": "^1.6.1", 70 | "babel-preset-flow": "^6.23.0", 71 | "blue-tape": "^1.0.0", 72 | "bluebird": "^3.5.1", 73 | "browserify": "^13.1.1", 74 | "documentation": "^4.0.0-rc.1", 75 | "eslint": "^2.10.2", 76 | "eslint-config-airbnb": "^9.0.1", 77 | "eslint-plugin-import": "^1.8.1", 78 | "eslint-plugin-jsx-a11y": "^1.2.2", 79 | "eslint-plugin-react": "^5.1.1", 80 | "fetch-mock": "^5.5.0", 81 | "flow-bin": "^0.49.1", 82 | "mock-local-storage": "^1.0.5", 83 | "nock": "^9.1.6", 84 | "node-fetch": "^2.1.2", 85 | "nyc": "^11.4.1", 86 | "opn": "^4.0.2", 87 | "proxyquire": "^1.8.0", 88 | "sinon": "^4.2.1", 89 | "tape": "^4.6.3", 90 | "tape-promise": "^2.0.1" 91 | }, 92 | "dependencies": { 93 | "ajv": "^4.11.5", 94 | "bigi": "^1.4.2", 95 | "bip39": "^2.5.0", 96 | "bitcoinjs-lib": "^4", 97 | "blockstack": "^18.0.4", 98 | "blockstack-keychains": "^0.0.8", 99 | "c32check": "^0.0.6", 100 | "cors": "^2.8.4", 101 | "cross-fetch": "^2.2.2", 102 | "ecurve": "^1.0.6", 103 | "es6-promise": "^4.2.4", 104 | "express": "^4.15.0", 105 | "express-winston": "^2.4.0", 106 | "jsontokens": "^0.7.7", 107 | "promise": "^7.1.1", 108 | "request": "^2.79.0", 109 | "ripemd160": "^2.0.1", 110 | "triplesec": "^3.0.26", 111 | "winston": "^2.3.1", 112 | "zone-file": "^0.2.2" 113 | }, 114 | "engines": { 115 | "node": ">=6", 116 | "npm": ">=5" 117 | }, 118 | "nyc": { 119 | "include": [ 120 | "lib/**" 121 | ] 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/argparse.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | const Ajv = require('ajv'); 4 | const process = require('process'); 5 | const c32check = require('c32check'); 6 | 7 | import os from 'os'; 8 | import fs from 'fs'; 9 | 10 | export const NAME_PATTERN = 11 | '^([0-9a-z_.+-]{3,37})$' 12 | 13 | export const NAMESPACE_PATTERN = 14 | '^([0-9a-z_-]{1,19})$' 15 | 16 | export const ADDRESS_CHARS = 17 | '[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{1,35}'; 18 | 19 | export const C32_ADDRESS_CHARS = '[0123456789ABCDEFGHJKMNPQRSTVWXYZ]+'; 20 | 21 | export const ADDRESS_PATTERN = `^(${ADDRESS_CHARS})$`; 22 | 23 | export const ID_ADDRESS_PATTERN = `^ID-${ADDRESS_CHARS}$`; 24 | 25 | export const STACKS_ADDRESS_PATTERN = `^(${C32_ADDRESS_CHARS})$`; 26 | 27 | // hex private key 28 | export const PRIVATE_KEY_PATTERN = 29 | '^([0-9a-f]{64,66})$'; 30 | 31 | // hex private key, no compression 32 | export const PRIVATE_KEY_UNCOMPRESSED_PATTERN = 33 | '^([0-9a-f]{64})$'; 34 | 35 | // m,pk1,pk2,...,pkn 36 | export const PRIVATE_KEY_MULTISIG_PATTERN = 37 | '^([0-9]+),([0-9a-f]{64,66},)*([0-9a-f]{64,66})$'; 38 | 39 | // segwit:p2sh:m,pk1,pk2,...,pkn 40 | export const PRIVATE_KEY_SEGWIT_P2SH_PATTERN = 41 | `^segwit:p2sh:([0-9]+),([0-9a-f]{64,66},)*([0-9a-f]{64,66})$`; 42 | 43 | // any private key pattern we support 44 | export const PRIVATE_KEY_PATTERN_ANY = 45 | `${PRIVATE_KEY_PATTERN}|${PRIVATE_KEY_MULTISIG_PATTERN}|${PRIVATE_KEY_SEGWIT_P2SH_PATTERN}`; 46 | 47 | export const PUBLIC_KEY_PATTERN = 48 | '^([0-9a-f]{66,130})$' 49 | 50 | export const INT_PATTERN = '^-?[0-9]+$' 51 | 52 | export const ZONEFILE_HASH_PATTERN = '^([0-9a-f]{40})$' 53 | 54 | export const URL_PATTERN = "^http[s]?://.+$" 55 | 56 | export const SUBDOMAIN_PATTERN = 57 | '^([0-9a-z_+-]{1,37})\.([0-9a-z_.+-]{3,37})$' 58 | 59 | export const TXID_PATTERN = 60 | '^([0-9a-f]{64})$' 61 | 62 | export const BOOLEAN_PATTERN = '^(0|1|true|false)$' 63 | 64 | const LOG_CONFIG_DEFAULTS = { 65 | level: 'warn', 66 | handleExceptions: true, 67 | timestamp: true, 68 | stringify: true, 69 | colorize: true, 70 | json: true 71 | } 72 | 73 | const CONFIG_DEFAULTS = { 74 | blockstackAPIUrl: 'https://core.blockstack.org', 75 | blockstackNodeUrl: 'https://node.blockstack.org:6263', 76 | broadcastServiceUrl: 'https://broadcast.blockstack.org', 77 | utxoServiceUrl: 'https://blockchain.info', 78 | logConfig: LOG_CONFIG_DEFAULTS 79 | }; 80 | 81 | const CONFIG_REGTEST_DEFAULTS = { 82 | blockstackAPIUrl: 'http://localhost:16268', 83 | blockstackNodeUrl: 'http://localhost:16264', 84 | broadcastServiceUrl: 'http://localhost:16269', 85 | utxoServiceUrl: 'http://localhost:18332', 86 | logConfig: LOG_CONFIG_DEFAULTS 87 | }; 88 | 89 | const PUBLIC_TESTNET_HOST = 'testnet.blockstack.org'; 90 | 91 | const CONFIG_TESTNET_DEFAULTS = { 92 | blockstackAPIUrl: `http://${PUBLIC_TESTNET_HOST}:16268`, 93 | blockstackNodeUrl: `http://${PUBLIC_TESTNET_HOST}:16264`, 94 | broadcastServiceUrl: `http://${PUBLIC_TESTNET_HOST}:16269`, 95 | utxoServiceUrl: `http://${PUBLIC_TESTNET_HOST}:18332`, 96 | logConfig: Object.assign({}, LOG_CONFIG_DEFAULTS, { level: 'debug' }) 97 | }; 98 | 99 | export const DEFAULT_CONFIG_PATH = '~/.blockstack-cli.conf' 100 | export const DEFAULT_CONFIG_REGTEST_PATH = '~/.blockstack-cli-regtest.conf' 101 | export const DEFAULT_CONFIG_TESTNET_PATH = '~/.blockstack-cli-testnet.conf' 102 | 103 | // CLI usage 104 | const CLI_ARGS = { 105 | type: 'object', 106 | properties: { 107 | announce: { 108 | type: "array", 109 | items: [ 110 | { 111 | name: 'message_hash', 112 | type: "string", 113 | realtype: 'zonefile_hash', 114 | pattern: ZONEFILE_HASH_PATTERN, 115 | }, 116 | { 117 | name: 'owner_key', 118 | type: "string", 119 | realtype: 'private_key', 120 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 121 | }, 122 | ], 123 | minItems: 2, 124 | maxItems: 2, 125 | help: 'Broadcast a message on the blockchain for subscribers to read. ' + 126 | 'The MESSAGE_HASH argument must be the hash of a previously-announced zone file. ' + 127 | 'The OWNER_KEY used to sign the transaction must correspond to the Blockstack ID ' + 128 | 'to which other users have already subscribed. OWNER_KEY can be a single private key ' + 129 | 'or a serialized multisig private key bundle.\n' + 130 | '\n' + 131 | 'If this command succeeds, it will print a transaction ID. The rest of the Blockstack peer ' + 132 | 'network will process it once the transaction reaches 7 confirmations.\n' + 133 | '\n' + 134 | 'Examples:\n' + 135 | ' $ # Tip: You can obtain the owner key with the get_owner_keys command\n' + 136 | ' $ export OWNER_KEY="136ff26efa5db6f06b28f9c8c7a0216a1a52598045162abfe435d13036154a1b01"\n' + 137 | ' $ blockstack-cli announce 737c631c7c5d911c6617993c21fba731363f1cfe "$OWNER_KEY"\n' + 138 | ' d51749aeec2803e91a2f8bdec8d3e413491fd816b4962372b214ab74acb0bba8\n' + 139 | '\n' + 140 | ' $ export OWNER_KEY="2,136ff26efa5db6f06b28f9c8c7a0216a1a52598045162abfe435d13036154a1b01,1885cba486a42960499d1f137ef3a475725ceb11f45d74631f9928280196f67401,2418981c7f3a91d4467a65a518e14fafa30e07e6879c11fab7106ea72b49a7cb01"\n' + 141 | ' $ blockstack-cli announce 737c631c7c5d911c6617993c21fba731363f1cfe "$OWNER_KEY"\n' + 142 | ' 8136a1114098893b28a693e8d84451abf99ee37ef8766f4bc59808eed76968c9\n' + 143 | '\n', 144 | group: 'Peer Services' 145 | }, 146 | authenticator: { 147 | type: "array", 148 | items: [ 149 | { 150 | name: 'app_gaia_hub', 151 | type: 'string', 152 | realtype: 'url', 153 | pattern: URL_PATTERN, 154 | }, 155 | { 156 | name: 'backup_phrase', 157 | type: 'string', 158 | realtype: '12_words_or_ciphertext', 159 | pattern: '.+', 160 | }, 161 | { 162 | name: 'profileGaiaHub', 163 | type: 'string', 164 | realtype: 'url', 165 | pattern: URL_PATTERN, 166 | }, 167 | { 168 | name: 'port', 169 | type: 'string', 170 | realtype: 'portnum', 171 | pattern: '^[0-9]+', 172 | }, 173 | ], 174 | minItems: 2, 175 | maxItems: 4, 176 | help: 'Run an authentication endpoint for the set of names owned ' + 177 | 'by the given backup phrase. Send applications the given Gaia hub URL on sign-in, ' + 178 | 'so the application will use it to read/write user data.\n' + 179 | '\n' + 180 | 'You can supply your encrypted backup phrase instead of the raw backup phrase. If so, ' + 181 | 'then you will be prompted for your password before any authentication takes place.\n' + 182 | '\n' + 183 | 'Example:\n' + 184 | '\n' + 185 | ' $ export BACKUP_PHRASE="oak indicate inside poet please share dinner monitor glow hire source perfect"\n' + 186 | ' $ export APP_GAIA_HUB="https://1.2.3.4"\n' + 187 | ' $ export PROFILE_GAIA_HUB="https://hub.blockstack.org"\n' + 188 | ' $ blockstack-cli authenticator "$APP_GAIA_HUB" "$BACKUP_PHRASE" "$PROFILE_GAIA_HUB" 8888\n' + 189 | ' Press Ctrl+C to exit\n' + 190 | ' Authentication server started on 8888\n', 191 | group: 'Authentication', 192 | }, 193 | balance: { 194 | type: "array", 195 | items: [ 196 | { 197 | name: 'address', 198 | type: "string", 199 | realtype: 'address', 200 | pattern: `${ADDRESS_PATTERN}|${STACKS_ADDRESS_PATTERN}`, 201 | } 202 | ], 203 | minItems: 1, 204 | maxItems: 1, 205 | help: 'Query the balance of an account. Returns the balances of each kind of token ' + 206 | 'that the account owns. The balances will be in the *smallest possible units* of the ' + 207 | 'token (i.e. satoshis for BTC, microStacks for Stacks, etc.).\n' + 208 | '\n' + 209 | 'Example:\n' + 210 | '\n' + 211 | ' $ blockstack-cli balance 16pm276FpJYpm7Dv3GEaRqTVvGPTdceoY4\n' + 212 | ' {\n' + 213 | ' "BTC": "123456"\n' + 214 | ' "STACKS": "123456"\n' + 215 | ' }\n' + 216 | ' $ blockstack-cli balance SPZY1V53Z4TVRHHW9Z7SFG8CZNRAG7BD8WJ6SXD0\n' + 217 | ' {\n' + 218 | ' "BTC": "123456"\n' + 219 | ' "STACKS": "123456"\n' + 220 | ' }\n', 221 | group: 'Account Management', 222 | }, 223 | convert_address: { 224 | type: "array", 225 | items: [ 226 | { 227 | name: "address", 228 | type: "string", 229 | realtype: "address", 230 | pattern: `${ADDRESS_PATTERN}|${STACKS_ADDRESS_PATTERN}` 231 | }, 232 | ], 233 | minItems: 1, 234 | maxItems: 1, 235 | help: 'Convert a Bitcoin address to a Stacks address and vice versa.\n' + 236 | '\n' + 237 | 'Example:\n' + 238 | '\n' + 239 | ' $ blockstack-cli convert_address 12qdRgXxgNBNPnDeEChy3fYTbSHQ8nfZfD\n' + 240 | ' {\n' + 241 | ' "STACKS": "SPA2MZWV9N67TBYVWTE0PSSKMJ2F6YXW7CBE6YPW",\n' + 242 | ' "BTC": "12qdRgXxgNBNPnDeEChy3fYTbSHQ8nfZfD"\n' + 243 | ' }\n' + 244 | ' $ blockstack-cli convert_address SPA2MZWV9N67TBYVWTE0PSSKMJ2F6YXW7CBE6YPW\n' + 245 | ' {\n' + 246 | ' "STACKS": "SPA2MZWV9N67TBYVWTE0PSSKMJ2F6YXW7CBE6YPW",\n' + 247 | ' "BTC": "12qdRgXxgNBNPnDeEChy3fYTbSHQ8nfZfD"\n' + 248 | ' }\n', 249 | group: 'Account Management', 250 | }, 251 | decrypt_keychain: { 252 | type: "array", 253 | items: [ 254 | { 255 | name: "encrypted_backup_phrase", 256 | type: "string", 257 | realtype: "encrypted_backup_phrase", 258 | pattern: "^[^ ]+$", 259 | }, 260 | { 261 | name: 'password', 262 | type: 'string', 263 | realtype: 'password', 264 | pattern: '.+', 265 | }, 266 | ], 267 | minItems: 1, 268 | maxItems: 2, 269 | help: 'Decrypt an encrypted backup phrase with a password. Decrypts to a 12-word ' + 270 | 'backup phrase if done correctly. The password will be prompted if not given.\n' + 271 | '\n' + 272 | 'Example:\n' + 273 | '\n' + 274 | ' $ # password is "asdf"\n' + 275 | ' $ blockstack-cli decrypt_keychain "bfMDtOucUGcJXjZo6vkrZWgEzue9fzPsZ7A6Pl4LQuxLI1xsVF0VPgBkMsnSLCmYS5YHh7R3mNtMmX45Bq9sNGPfPsseQMR0fD9XaHi+tBg=\n' + 276 | ' Enter password:\n' + 277 | ' section amount spend resemble spray verify night immune tattoo best emotion parrot', 278 | group: "Key Management", 279 | }, 280 | encrypt_keychain: { 281 | type: "array", 282 | items: [ 283 | { 284 | name: "backup_phrase", 285 | type: "string", 286 | realtype: "backup_phrase", 287 | pattern: ".+", 288 | }, 289 | { 290 | name: 'password', 291 | type: 'string', 292 | realtype: 'password', 293 | pattern: '.+', 294 | }, 295 | ], 296 | minItems: 1, 297 | maxItems: 2, 298 | help: "Encrypt a 12-word backup phrase, which can be decrypted later with the " + 299 | "decrypt_backup_phrase command. The password will be prompted if not given.\n" + 300 | '\n' + 301 | 'Example:\n' + 302 | '\n' + 303 | ' $ # password is "asdf"\n' + 304 | ' $ blockstack-cli encrypt_keychain "section amount spend resemble spray verify night immune tattoo best emotion parrot"\n' + 305 | ' Enter password:\n' + 306 | ' Enter password again:\n' + 307 | ' M+DnBHYb1fgw4N3oZ+5uTEAua5bAWkgTW/SjmmBhGGbJtjOtqVV+RrLJEJOgT35hBon4WKdGWye2vTdgqDo7+HIobwJwkQtN2YF9g3zPsKk=', 308 | group: "Key Management", 309 | }, 310 | gaia_dump_bucket: { 311 | type: "array", 312 | items: [ 313 | { 314 | name: 'name_or_id_address', 315 | type: 'string', 316 | realtype: 'name_or_id_address', 317 | pattern: `${ID_ADDRESS_PATTERN}|${NAME_PATTERN}|${SUBDOMAIN_PATTERN}` 318 | }, 319 | { 320 | name: 'app_origin', 321 | type: 'string', 322 | realtype: 'url', 323 | pattern: URL_PATTERN, 324 | }, 325 | { 326 | name: 'gaia_hub', 327 | type: 'string', 328 | realtype: 'url', 329 | pattern: URL_PATTERN, 330 | }, 331 | { 332 | name: 'backup_phrase', 333 | type: 'string', 334 | realtype: '12_words_or_ciphertext', 335 | }, 336 | { 337 | name: 'dump_dir', 338 | type: 'string', 339 | realtype: 'path', 340 | pattern: '.+', 341 | } 342 | ], 343 | minItems: 5, 344 | maxItems: 5, 345 | help: 'Download the contents of a Gaia hub bucket to a given directory. The GAIA_HUB argument ' + 346 | 'must correspond to the *write* endpoint of the Gaia hub -- that is, you should be able to fetch ' + 347 | '$GAIA_HUB/hub_info. If DUMP_DIR does not exist, it will be created.\n' + 348 | '\n' + 349 | 'Example:\n' + 350 | '\n' + 351 | ' $ export BACKUP_PHRASE="section amount spend resemble spray verify night immune tattoo best emotion parrot\n' + 352 | ' $ blockstack-cli gaia_dump_bucket hello.id.blockstack https://sample.app https://hub.blockstack.org "$BACKUP_PHRASE" ./backups\n' + 353 | ' Download 3 files...\n' + 354 | ' Download hello_world to ./backups/hello_world\n' + 355 | ' Download dir/format to ./backups/dir\\x2fformat\n' + 356 | ' Download /.dotfile to ./backups/\\x2f.dotfile\n' + 357 | ' 3\n', 358 | group: "Gaia", 359 | }, 360 | gaia_getfile: { 361 | type: "array", 362 | items: [ 363 | { 364 | name: 'blockstack_id', 365 | type: 'string', 366 | realtype: 'blockstack_id', 367 | pattern: `${NAME_PATTERN}|${SUBDOMAIN_PATTERN}$`, 368 | }, 369 | { 370 | name: 'app_origin', 371 | type: 'string', 372 | realtype: 'url', 373 | pattern: URL_PATTERN, 374 | }, 375 | { 376 | name: 'filename', 377 | type: 'string', 378 | realtype: 'filename', 379 | pattern: '.+', 380 | }, 381 | { 382 | name: 'app_private_key', 383 | type: 'string', 384 | realtype: 'private_key', 385 | pattern: PRIVATE_KEY_UNCOMPRESSED_PATTERN, 386 | }, 387 | { 388 | name: 'decrypt', 389 | type: 'string', 390 | realtype: 'boolean', 391 | pattern: BOOLEAN_PATTERN, 392 | }, 393 | { 394 | name: 'verify', 395 | type: 'string', 396 | realtype: 'boolean', 397 | pattern: BOOLEAN_PATTERN, 398 | }, 399 | ], 400 | minItems: 3, 401 | maxItems: 6, 402 | help: 'Get a file from another user\'s Gaia hub. Prints the file data to stdout. If you ' + 403 | 'want to read an encrypted file, and/or verify a signed file, then you must pass an app ' + 404 | 'private key, and pass 1 for DECRYPT and/or VERIFY. If the file is encrypted, and you do not ' + 405 | 'pass an app private key, then this command downloads the ciphertext. If the file is signed, ' + 406 | 'and you want to download its data and its signature, then you must run this command twice -- ' + 407 | 'once to get the file contents at FILENAME, and once to get the signature (whose name will be FILENAME.sig).\n' + 408 | '\n' + 409 | 'Gaia is a key-value store, so it does not have any built-in notion of directories. However, ' + 410 | 'most underlying storage systems do -- directory separators in the name of a file in ' + 411 | 'Gaia may be internally treated as first-class directories (it depends on the Gaia hub\'s driver).' + 412 | 'As such, repeated directory separators will be treated as a single directory separator by this command. ' + 413 | 'For example, the file name a/b.txt, /a/b.txt, and ///a////b.txt will be treated as identical.\n' + 414 | '\n' + 415 | 'Example without encryption:\n' + 416 | '\n' + 417 | ' $ # Get an unencrypted, unsigned file\n' + 418 | ' $ blockstack-cli gaia_getfile ryan.id http://public.ykliao.com statuses.json\n' + 419 | ' [{"id":0,"text":"Hello, Blockstack!","created_at":1515786983492}]\n' + 420 | '\n' + 421 | 'Example with encryption:\n' + 422 | '\n' + 423 | ' $ # Get an encrypted file without decrypting\n' + 424 | ' $ blockstack-cli gaia_getfile ryan.id https://app.graphitedocs.com documentscollection.json\n' + 425 | ' ' + 426 | ' $ # Get an encrypted file, and decrypt it\n' + 427 | ' $ # Tip: You can obtain the app key with the get_app_keys command\n' + 428 | ' $ export APP_KEY="3ac770e8c3d88b1003bf4a0a148ceb920a6172bdade8e0325a1ed1480ab4fb19"\n' + 429 | ' $ blockstack-cli gaia_getfile ryan.id https://app.graphitedocs.com documentscollection.json "$APP_KEY" 1 0\n', 430 | group: 'Gaia', 431 | }, 432 | gaia_putfile: { 433 | type: "array", 434 | items: [ 435 | { 436 | name: 'gaia_hub', 437 | type: 'string', 438 | realtype: 'url', 439 | pattern: URL_PATTERN, 440 | }, 441 | { 442 | name: 'app_private_key', 443 | type: 'string', 444 | realtype: 'private_key', 445 | pattern: PRIVATE_KEY_UNCOMPRESSED_PATTERN, 446 | }, 447 | { 448 | name: 'data_path', 449 | type: 'string', 450 | realtype: 'path', 451 | pattern: '.+', 452 | }, 453 | { 454 | name: 'gaia_filename', 455 | type: 'string', 456 | realtype: 'filename', 457 | pattern: '.+', 458 | }, 459 | { 460 | name: 'encrypt', 461 | type: 'string', 462 | realtype: 'boolean', 463 | pattern: BOOLEAN_PATTERN, 464 | }, 465 | { 466 | name: 'sign', 467 | type: 'string', 468 | realtype: 'boolean', 469 | pattern: BOOLEAN_PATTERN, 470 | }, 471 | ], 472 | minItems: 4, 473 | maxItems: 6, 474 | help: 'Put a file into a given Gaia hub, authenticating with the given app private key. ' + 475 | 'Optionally encrypt and/or sign the data with the given app private key. If the file is ' + 476 | 'successfully stored, this command prints out the URLs at which it can be fetched.\n' + 477 | '\n' + 478 | 'Gaia is a key-value store, so it does not have any built-in notion of directories. However, ' + 479 | 'most underlying storage systems do -- directory separators in the name of a file in ' + 480 | 'Gaia may be internally treated as first-class directories (it depends on the Gaia hub\'s driver).' + 481 | 'As such, repeated directory separators will be treated as a single directory separator by this command. ' + 482 | 'For example, the file name a/b.txt, /a/b.txt, and ///a////b.txt will be treated as identical.\n' + 483 | '\n' + 484 | 'Example:\n' + 485 | '\n' + 486 | ' $ # Store 4 versions of a file: plaintext, encrypted, signed, and encrypted+signed\n' + 487 | ' $ # Tip: You can obtain the app key with the get_app_keys command.\n' + 488 | ' $ export APP_KEY="3ac770e8c3d88b1003bf4a0a148ceb920a6172bdade8e0325a1ed1480ab4fb19"\n' + 489 | ' $ blockstack-cli gaia_putfile https://hub.blockstack.org "$APP_KEY" /path/to/file.txt file.txt\n' + 490 | ' {\n' + 491 | ' "urls": "https://gaia.blockstack.org/hub/19KAzYp4kSKozeAGMUsnuqkEGdgQQLEvwo/file.txt"\n' + 492 | ' }\n' + 493 | ' $ blockstack-cli gaia_putfile https://hub.blockstack.org "$APP_KEY" /path/to/file.txt file-encrypted.txt 1\n' + 494 | ' {\n' + 495 | ' "urls": "https://gaia.blockstack.org/hub/19KAzYp4kSKozeAGMUsnuqkEGdgQQLEvwo/file-encrypted.txt"\n' + 496 | ' }\n' + 497 | ' $ blockstack-cli gaia_putfile https://hub.blockstack.org "$APP_KEY" /path/to/file.txt file-signed.txt 0 1\n' + 498 | ' {\n' + 499 | ' "urls": "https://gaia.blockstack.org/hub/19KAzYp4kSKozeAGMUsnuqkEGdgQQLEvwo/file-signed.txt"\n' + 500 | ' }\n' + 501 | ' $ blockstack-cli gaia_putfile https://hub.blockstack.org "$APP_KEY" /path/to/file.txt file-encrypted-signed.txt 1 1\n' + 502 | ' {\n' + 503 | ' "urls": "https://gaia.blockstack.org/hub/19KAzYp4kSKozeAGMUsnuqkEGdgQQLEvwo/file-encrypted-signed.txt"\n' + 504 | ' }\n', 505 | group: 'Gaia', 506 | }, 507 | gaia_listfiles: { 508 | type: "array", 509 | items: [ 510 | { 511 | name: 'gaia_hub', 512 | type: 'string', 513 | realtype: 'url', 514 | pattern: URL_PATTERN, 515 | }, 516 | { 517 | name: 'app_private_key', 518 | type: 'string', 519 | realtype: 'private_key', 520 | pattern: PRIVATE_KEY_UNCOMPRESSED_PATTERN, 521 | } 522 | ], 523 | minItems: 2, 524 | maxItems: 3, 525 | help: 'List all the files in a Gaia hub bucket. You must have the private key for the bucket ' + 526 | 'in order to list its contents. The command prints each file name on its own line, and when ' + 527 | 'finished, it prints the number of files listed.\n' + 528 | '\n' + 529 | 'Example:\n' + 530 | '\n' + 531 | ' $ # Tip: You can obtain the app key with the get_app_keys command.\n' + 532 | ' $ export APP_KEY="3ac770e8c3d88b1003bf4a0a148ceb920a6172bdade8e0325a1ed1480ab4fb19"\n' + 533 | ' $ blockstack-cli gaia_listfiles "https://hub.blockstack.org" "$APP_KEY"\n' + 534 | ' hello_world\n' + 535 | ' dir/format\n' + 536 | ' /.dotfile\n' + 537 | ' 3\n', 538 | group: 'Gaia', 539 | }, 540 | gaia_restore_bucket: { 541 | type: "array", 542 | items: [ 543 | { 544 | name: 'name_or_id_address', 545 | type: 'string', 546 | realtype: 'name_or_id_address', 547 | pattern: `${ID_ADDRESS_PATTERN}|${NAME_PATTERN}|${SUBDOMAIN_PATTERN}` 548 | }, 549 | { 550 | name: 'app_origin', 551 | type: 'string', 552 | realtype: 'url', 553 | pattern: URL_PATTERN, 554 | }, 555 | { 556 | name: 'gaia_hub', 557 | type: 'string', 558 | realtype: 'url', 559 | pattern: URL_PATTERN, 560 | }, 561 | { 562 | name: 'backup_phrase', 563 | type: 'string', 564 | realtype: '12_words_or_ciphertext', 565 | }, 566 | { 567 | name: 'dump_dir', 568 | type: 'string', 569 | realtype: 'path', 570 | pattern: '.+', 571 | } 572 | ], 573 | minItems: 5, 574 | maxItems: 5, 575 | help: 'Upload the contents of a previously-dumped Gaia bucket to a new Gaia hub. The GAIA_HUB argument ' + 576 | 'must correspond to the *write* endpoint of the Gaia hub -- that is, you should be able to fetch ' + 577 | '$GAIA_HUB/hub_info. DUMP_DIR must contain the file contents created by a previous successful run of the gaia_dump_bucket command, ' + 578 | 'and both NAME_OR_ID_ADDRESS and APP_ORIGIN must be the same as they were when it was run.\n' + 579 | '\n' + 580 | 'Example:\n' + 581 | '\n' + 582 | ' $ export BACKUP_PHRASE="section amount spend resemble spray verify night immune tattoo best emotion parrot"\n' + 583 | ' $ blockstack-cli gaia_restore_bucket hello.id.blockstack https://sample.app https://new.gaia.hub "$BACKUP_PHRASE" ./backups\n' + 584 | ' Uploaded ./backups/hello_world to https://new.gaia.hub/hub/1Lr8ggSgdmfcb4764woYutUfFqQMjEoKHc/hello_world\n' + 585 | ' Uploaded ./backups/dir\\x2fformat to https://new.gaia.hub/hub/1Lr8ggSgdmfcb4764woYutUfFqQMjEoKHc/dir/format\n' + 586 | ' Uploaded ./backups/\\x2f.dotfile to https://new.gaia.hub/hub/1Lr8ggSgdmfcb4764woYutUfFqQMjEoKHc//.dotfile\n' + 587 | ' 3\n', 588 | group: "Gaia", 589 | }, 590 | gaia_sethub: { 591 | type: "array", 592 | items: [ 593 | { 594 | name: 'blockstack_id', 595 | type: 'string', 596 | realtype: 'blockstack_id', 597 | pattern: `^${NAME_PATTERN}|${SUBDOMAIN_PATTERN}$`, 598 | }, 599 | { 600 | name: 'owner_gaia_hub', 601 | type: 'string', 602 | realtype: 'url', 603 | pattern: URL_PATTERN, 604 | }, 605 | { 606 | name: 'app_origin', 607 | type: 'string', 608 | realtype: 'url', 609 | pattern: URL_PATTERN, 610 | }, 611 | { 612 | name: 'app_gaia_hub', 613 | type: 'string', 614 | realtype: 'url', 615 | pattern: URL_PATTERN, 616 | }, 617 | { 618 | name: 'backup_phrase', 619 | type: 'string', 620 | realtype: '12_words_or_ciphertext', 621 | }, 622 | ], 623 | minItems: 5, 624 | maxItems: 5, 625 | help: 'Set the Gaia hub for a particular application for a Blockstack ID. If the command succeeds, ' + 626 | 'the URLs to your updated profile will be printed and your profile will contain an entry in its "apps" ' + 627 | 'key that links the given APP_ORIGIN to the given APP_GAIA_HUB. Note that both OWNER_GAIA_HUB and ' + 628 | 'APP_GAIA_HUB must be the *write* endpoints of their respective Gaia hubs.\n' + 629 | '\n' + 630 | 'Your 12-word phrase (in either raw or encrypted form) is required to re-sign and store your ' + 631 | 'profile and to generate an app-specific key and Gaia bucket. If you give the encrypted backup phrase, you will be prompted for a password.\n' + 632 | '\n' + 633 | 'Example:\n' + 634 | '\n' + 635 | ' $ export BACKUP_PHRASE="soap fog wealth upon actual blossom neither timber phone exile monkey vocal"\n' + 636 | ' $ blockstack-cli gaia_sethub hello_world.id https://hub.blockstack.org https://my.cool.app https://my.app.gaia.hub "$BACKUP_PHRASE"\n' + 637 | ' {\n' + 638 | ' "profileUrls": {\n' + 639 | ' "error": null,\n' + 640 | ' "dataUrls": [\n' + 641 | ' "https://gaia.blockstack.org/hub/1ArdkA2oLaKnbNbLccBaFhEV4pYju8hJ82/profile.json"\n' + 642 | ' ]\n' + 643 | ' }\n' + 644 | ' }\n' + 645 | ' \n' + 646 | ' $ # You can check the new apps entry with curl and jq as follows:\n' + 647 | ' $ curl -sL https://gaia.blockstack.org/hub/1ArdkA2oLaKnbNbLccBaFhEV4pYju8hJ82/profile.json | jq ".[0].decodedToken.payload.claim.apps"\n' + 648 | ' {\n' + 649 | ' "https://my.cool.app": "https://my.app.gaia.hub/hub/1EqzyQLJ15KG1WQmi5cf1HtmSeqS1Wb8tY/"\n' + 650 | ' }\n' + 651 | '\n', 652 | group: 'Gaia', 653 | }, 654 | get_account_history: { 655 | type: "array", 656 | items: [ 657 | { 658 | name: 'address', 659 | type: "string", 660 | realtype: 'address', 661 | pattern: STACKS_ADDRESS_PATTERN, 662 | }, 663 | { 664 | name: 'page', 665 | type: "string", 666 | realtype: "integer", 667 | pattern: "^[0-9]+$", 668 | }, 669 | ], 670 | minItems: 2, 671 | maxItems: 2, 672 | help: 'Query the history of account debits and credits over a given block range. ' + 673 | 'Returns the history one page at a time. An empty result indicates that the page ' + 674 | 'number has exceeded the number of historic operations in the given block range.\n' + 675 | '\n' + 676 | 'Example:\n' + 677 | '\n' + 678 | ' $ blockstack-cli get_account_history SP2H7VMY13ESQDAD5808QEY1EMGESMHZWBJRTN2YA 0\n' + 679 | ' [\n' + 680 | ' {\n' + 681 | ' "address": "SP2H7VMY13ESQDAD5808QEY1EMGESMHZWBJRTN2YA",\n' + 682 | ' "block_id": 56789\n' + 683 | ' "credit_value": "100000000000",\n' + 684 | ' "debit_value": "0",\n' + 685 | ' "lock_transfer_block_id": 0,\n' + 686 | ' "txid": "0e5db84d94adff5b771262b9df015164703b39bb4a70bf499a1602b858a0a5a1",\n' + 687 | ' "type": "STACKS",\n' + 688 | ' "vtxindex": 0\n' + 689 | ' },\n' + 690 | ' {\n' + 691 | ' "address": "SP2H7VMY13ESQDAD5808QEY1EMGESMHZWBJRTN2YA",\n' + 692 | ' "block_id": 56790,\n' + 693 | ' "credit_value": "100000000000",\n' + 694 | ' "debit_value": "64000000000",\n' + 695 | ' "lock_transfer_block_id": 0,\n' + 696 | ' "txid": "5a0c67144626f7bd4514e4de3f3bbf251383ca13887444f326bac4bc8b8060ee",\n' + 697 | ' "type": "STACKS",\n' + 698 | ' "vtxindex": 1\n' + 699 | ' },\n' + 700 | ' {\n' + 701 | ' "address": "SP2H7VMY13ESQDAD5808QEY1EMGESMHZWBJRTN2YA",\n' + 702 | ' "block_id": 56791,\n' + 703 | ' "credit_value": "100000000000",\n' + 704 | ' "debit_value": "70400000000",\n' + 705 | ' "lock_transfer_block_id": 0,\n' + 706 | ' "txid": "e54c271d6a9feb4d1859d32bc99ffd713493282adef5b4fbf50bca9e33fc0ecc",\n' + 707 | ' "type": "STACKS",\n' + 708 | ' "vtxindex": 2\n' + 709 | ' },\n' + 710 | ' {\n' + 711 | ' "address": "SP2H7VMY13ESQDAD5808QEY1EMGESMHZWBJRTN2YA",\n' + 712 | ' "block_id": 56792,\n' + 713 | ' "credit_value": "100000000000",\n' + 714 | ' "debit_value": "76800000000",\n' + 715 | ' "lock_transfer_block_id": 0,\n' + 716 | ' "txid": "06e0d313261baefec1e59783e256ab487e17e0e776e2fdab0920cc624537e3c8",\n' + 717 | ' "type": "STACKS",\n' + 718 | ' "vtxindex": 3\n' + 719 | ' }\n' + 720 | ' ]\n' + 721 | '\n', 722 | group: 'Account Management', 723 | }, 724 | get_account_at: { 725 | type: "array", 726 | items: [ 727 | { 728 | name: 'address', 729 | type: "string", 730 | realtype: 'address', 731 | pattern: STACKS_ADDRESS_PATTERN, 732 | }, 733 | { 734 | name: 'blocknumber', 735 | type: "string", 736 | realtype: 'integer', 737 | pattern: "^[0-9]+$", 738 | }, 739 | ], 740 | minItems: 2, 741 | maxItems: 2, 742 | help: 'Query the list of token debits and credits on a given address that occurred ' + 743 | 'at a particular block height. Does not include BTC debits and credits; only Stacks.\n' + 744 | '\n' + 745 | 'Example\n' + 746 | '\n' + 747 | ' $ blockstack-cli -t get_account_at SP2NTAQFECYGSTE1W47P71FG21H8F00KZZWFGEVKQ 56789\n' + 748 | ' [\n' + 749 | ' {\n' + 750 | ' "debit_value": "0",\n' + 751 | ' "block_id": 56789\n' + 752 | ' "lock_transfer_block_id": 0,\n' + 753 | ' "txid": "291817c78a865c1f72938695218a48174265b2358e89b9448edc89ceefd66aa0",\n' + 754 | ' "address": "SP2NTAQFECYGSTE1W47P71FG21H8F00KZZWFGEVKQ",\n' + 755 | ' "credit_value": "1000000000000000000",\n' + 756 | ' "type": "STACKS",\n' + 757 | ' "vtxindex": 0\n' + 758 | ' }\n' + 759 | ' ]\n' + 760 | '\n', 761 | group: 'Account Management', 762 | }, 763 | get_address: { 764 | type: 'array', 765 | items: [ 766 | { 767 | name: 'private_key', 768 | type: 'string', 769 | realtype: 'private_key', 770 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 771 | } 772 | ], 773 | minItems: 1, 774 | maxItems: 1, 775 | help: 'Get the address of a private key or multisig private key bundle. Gives the BTC and STACKS addresses\n' + 776 | '\n' + 777 | 'Example:\n' + 778 | '\n' + 779 | ' $ blockstack-cli get_address f5185b9ca93bdcb5753fded3b097dab8547a8b47d2be578412d0687a9a0184cb01\n' + 780 | ' {\n' + 781 | ' "BTC": "1JFhWyVPpZQjbPcXFtpGtTmU22u4fhBVmq",\n' + 782 | ' "STACKS": "SP2YM3J4KQK09V670TD6ZZ1XYNYCNGCWCVVKSDFWQ"\n' + 783 | ' }\n' + 784 | ' $ blockstack-cli get_address 1,f5185b9ca93bdcb5753fded3b097dab8547a8b47d2be578412d0687a9a0184cb01,ff2ff4f4e7f8a1979ffad4fc869def1657fd5d48fc9cf40c1924725ead60942c01\n' + 785 | ' {\n' + 786 | ' "BTC": "363pKBhc5ipDws1k5181KFf6RSxhBZ7e3p",\n' + 787 | ' "STACKS": "SMQWZ30EXVG6XEC1K4QTDP16C1CAWSK1JSWMS0QN"\n' + 788 | ' }', 789 | group: 'Key Management', 790 | }, 791 | get_blockchain_record: { 792 | type: "array", 793 | items: [ 794 | { 795 | name: 'blockstack_id', 796 | type: "string", 797 | realtype: 'blockstack_id', 798 | pattern: `^${NAME_PATTERN}|${SUBDOMAIN_PATTERN}$`, 799 | }, 800 | ], 801 | minItems: 1, 802 | maxItems: 1, 803 | help: 'Get the low-level blockchain-hosted state for a Blockstack ID. This command ' + 804 | 'is used mainly for debugging and diagnostics. You should not rely on it to be stable.', 805 | group: 'Querying Blockstack IDs' 806 | }, 807 | get_blockchain_history: { 808 | type: "array", 809 | items: [ 810 | { 811 | name: 'blockstack_id', 812 | type: "string", 813 | realtype: 'blockstack_id', 814 | pattern: `${NAME_PATTERN}|${SUBDOMAIN_PATTERN}$`, 815 | }, 816 | ], 817 | minItems: 1, 818 | maxItems: 1, 819 | help: 'Get the low-level blockchain-hosted history of operations on a Blocktack ID. ' + 820 | 'This command is used mainly for debugging and diagnostics, and is not guaranteed to ' + 821 | 'be stable across releases.', 822 | group: 'Querying Blockstack IDs', 823 | }, 824 | get_confirmations: { 825 | type: "array", 826 | items: [ 827 | { 828 | name: 'txid', 829 | type: 'string', 830 | realtype: 'transaction_id', 831 | pattern: TXID_PATTERN, 832 | }, 833 | ], 834 | minItems: 1, 835 | maxItems: 1, 836 | help: 'Get the block height and number of confirmations for a transaction.\n' + 837 | '\n' + 838 | 'Example:\n' + 839 | '\n' + 840 | ' $ blockstack-cli get_confirmations e41ce043ab64fd5a5fd382fba21acba8c1f46cbb1d7c08771ada858ce7d29eea\n' + 841 | ' {\n' + 842 | ' "blockHeight": 567890,\n' + 843 | ' "confirmations": 7,\n' + 844 | ' }\n' + 845 | '\n', 846 | group: 'Peer Services', 847 | }, 848 | get_namespace_blockchain_record: { 849 | type: "array", 850 | items: [ 851 | { 852 | name: 'namespace_id', 853 | type: "string", 854 | realtype: 'namespace_id', 855 | pattern: NAMESPACE_PATTERN, 856 | }, 857 | ], 858 | minItems: 1, 859 | maxItems: 1, 860 | help: 'Get the low-level blockchain-hosted state for a Blockstack namespace. This command ' + 861 | 'is used mainly for debugging and diagnostics, and is not guaranteed to be stable across ' + 862 | 'releases.', 863 | group: 'Namespace Operations', 864 | }, 865 | get_app_keys: { 866 | type: "array", 867 | items: [ 868 | { 869 | name: 'backup_phrase', 870 | type: 'string', 871 | realtype: '12_words_or_ciphertext', 872 | }, 873 | { 874 | name: 'name_or_id_address', 875 | type: 'string', 876 | realtype: 'name-or-id-address', 877 | pattern: `${NAME_PATTERN}|${SUBDOMAIN_PATTERN}|${ID_ADDRESS_PATTERN}`, 878 | }, 879 | { 880 | name: 'app_origin', 881 | type: 'string', 882 | realtype: 'url', 883 | pattern: URL_PATTERN, 884 | }, 885 | ], 886 | minItems: 3, 887 | maxItems: 3, 888 | help: 'Get the application private key from a 12-word backup phrase and a name or ID-address. ' + 889 | 'This is the private key used to sign data in Gaia, and its address is the Gaia bucket ' + 890 | 'address. If you provide your encrypted backup phrase, you will be asked to decrypt it. ' + 891 | 'If you provide a name instead of an ID-address, its ID-address will be queried automatically ' + 892 | '(note that this means that the name must already be registered). Note that this command does NOT ' + 893 | 'verify whether or not the name or ID-address was created by the backup phrase. You should do this yourself ' + 894 | 'via the "get_owner_keys" command if you are not sure.\n' + 895 | 'There are two derivation paths emitted by this command: a "keyInfo" path and a "legacyKeyInfo"' + 896 | 'path. You should use the one that matches the Gaia hub read URL\'s address, if you have already ' + 897 | 'signed in before. If not, then you should use the "keyInfo" path when possible.\n' + 898 | '\n' + 899 | 'Example:\n' + 900 | '\n' + 901 | ' $ export BACKUP_PHRASE="one race buffalo dynamic icon drip width lake extra forest fee kit"\n' + 902 | ' $ blockstack-cli get_app_keys "$BACKUP_PHRASE" example.id.blockstack https://my.cool.dapp\n' + 903 | ' {\n' + 904 | ' "keyInfo": {\n' + 905 | ' "privateKey": "TODO",\n' + 906 | ' "address": "TODO"\n' + 907 | ' },\n' + 908 | ' "legacyKeyInfo": {\n' + 909 | ' "privateKey": "90f9ec4e13fb9a00243b4c1510075157229bda73076c7c721208c2edca28ea8b",\n' + 910 | ' "address": "1Lr8ggSgdmfcb4764woYutUfFqQMjEoKHc"\n' + 911 | ' },\n' + 912 | ' "ownerKeyIndex": 0\n' + 913 | ' }', 914 | group: 'Key Management', 915 | }, 916 | get_owner_keys: { 917 | type: "array", 918 | items: [ 919 | { 920 | name: 'backup_phrase', 921 | type: "string", 922 | realtype: '12_words_or_ciphertext', 923 | }, 924 | { 925 | name: 'index', 926 | type: "string", 927 | realtype: 'integer', 928 | pattern: "^[0-9]+$", 929 | } 930 | ], 931 | minItems: 1, 932 | maxItems: 2, 933 | help: 'Get the list of owner private keys and ID-addresses from a 12-word backup phrase. ' + 934 | 'Pass non-zero values for INDEX to generate the sequence of ID-addresses that can be used ' + 935 | 'to own Blockstack IDs. If you provide an encrypted 12-word backup phrase, you will be ' + 936 | 'asked for your password to decrypt it.\n' + 937 | '\n' + 938 | 'Example:\n' + 939 | '\n' + 940 | ' $ # get the first 3 owner keys and addresses for a backup phrase\n' + 941 | ' $ export BACKUP_PHRASE="soap fog wealth upon actual blossom neither timber phone exile monkey vocal"\n' + 942 | ' $ blockstack-cli get_owner_keys "$BACKKUP_PHRASE" 3\n' + 943 | ' [\n' + 944 | ' {\n' + 945 | ' "privateKey": "14b0811d5cd3486d47279d8f3a97008647c64586b121e99862c18863e2a4183501",\n' + 946 | ' "version": "v0.10-current",\n' + 947 | ' "index": 0,\n' + 948 | ' "idAddress": "ID-1ArdkA2oLaKnbNbLccBaFhEV4pYju8hJ82"\n' + 949 | ' },\n' + 950 | ' {\n' + 951 | ' "privateKey": "1b3572d8dd6866828281ac6cf135f04153210c1f9b123743eccb795fd3095e4901",\n' + 952 | ' "version": "v0.10-current",\n' + 953 | ' "index": 1,\n' + 954 | ' "idAddress": "ID-18pR3UpD1KFrnk88a3MGZmG2dLuZmbJZ25"\n' + 955 | ' },\n' + 956 | ' {\n' + 957 | ' "privateKey": "b19b6d62356db96d570fb5f08b78f0aa7f384525ba3bdcb96fbde29b8e11710d01",\n' + 958 | ' "version": "v0.10-current",\n' + 959 | ' "index": 2,\n' + 960 | ' "idAddress": "ID-1Gx4s7ggkjENw3wSY6bNd1CwoQKk857AqN"\n' + 961 | ' }\n' + 962 | ' ]\n' + 963 | '\n', 964 | group: 'Key Management', 965 | }, 966 | get_payment_key: { 967 | type: "array", 968 | items: [ 969 | { 970 | name: 'backup_phrase', 971 | type: "string", 972 | realtype: '12_words_or_ciphertext', 973 | }, 974 | ], 975 | minItems: 1, 976 | maxItems: 1, 977 | help: 'Get the payment private key from a 12-word backup phrase. If you provide an ' + 978 | 'encrypted backup phrase, you will be asked for your password to decrypt it. This command ' + 979 | 'will tell you your Bitcoin and Stacks token addresses as well.\n' + 980 | '\n' + 981 | 'Example\n' + 982 | '\n' + 983 | ' $ blockstack-cli get_payment_key "soap fog wealth upon actual blossom neither timber phone exile monkey vocal"\n' + 984 | ' [\n' + 985 | ' {\n' + 986 | ' "privateKey": "4023435e33da4aff0775f33e7b258f257fb20ecff039c919b5782313ab73afb401",\n' + 987 | ' "address": {\n' + 988 | ' "BTC": "1ybaP1gaRwRSWRE4f8JXo2W8fiTZmA4rV",\n' + 989 | ' "STACKS": "SP5B89ZJAQHBRXVYP15YB5PAY5E24FEW9K4Q63PE"\n' + 990 | ' },\n' + 991 | ' "index": 0\n' + 992 | ' }\n' + 993 | ' ]\n' + 994 | '\n', 995 | group: 'Key Management', 996 | }, 997 | get_zonefile: { 998 | type: "array", 999 | items: [ 1000 | { 1001 | name: 'zonefile_hash', 1002 | type: "string", 1003 | realtype: 'zonefile_hash', 1004 | pattern: ZONEFILE_HASH_PATTERN 1005 | }, 1006 | ], 1007 | minItems: 1, 1008 | maxItems: 1, 1009 | help: 'Get a zone file by hash', 1010 | group: 'Peer Services', 1011 | }, 1012 | help: { 1013 | type: 'array', 1014 | items: [ 1015 | { 1016 | name: 'command', 1017 | type: 'string', 1018 | realtype: 'command', 1019 | }, 1020 | ], 1021 | minItems: 0, 1022 | maxItems: 1, 1023 | help: 'Get the usage string for a CLI command', 1024 | group: 'CLI', 1025 | }, 1026 | lookup: { 1027 | type: "array", 1028 | items: [ 1029 | { 1030 | name: 'blockstack_id', 1031 | type: "string", 1032 | realtype: 'blockstack_id', 1033 | pattern: `${NAME_PATTERN}|${SUBDOMAIN_PATTERN}$`, 1034 | }, 1035 | ], 1036 | minItems: 1, 1037 | maxItems: 1, 1038 | help: 'Get and authenticate the profile and zone file for a Blockstack ID.\n' + 1039 | '\n' + 1040 | 'Example:\n' + 1041 | '\n' + 1042 | ' $ blockstack-cli lookup example.id\n' + 1043 | '\n', 1044 | group: 'Querying Blockstack IDs', 1045 | }, 1046 | names: { 1047 | type: "array", 1048 | items: [ 1049 | { 1050 | name: 'id_address', 1051 | type: "string", 1052 | realtype: 'id-address', 1053 | pattern: ID_ADDRESS_PATTERN, 1054 | }, 1055 | ], 1056 | minItems: 1, 1057 | maxItems: 1, 1058 | help: 'Get the list of Blockstack IDs owned by an ID-address.\n' + 1059 | '\n' + 1060 | 'Example:\n' + 1061 | '\n' + 1062 | ' $ blockstack-cli names ID-1FpBChfzHG3TdQQRKWAipbLragCUArueG9\n' + 1063 | '\n', 1064 | group: 'Querying Blockstack IDs', 1065 | }, 1066 | make_keychain: { 1067 | type: "array", 1068 | items: [ 1069 | { 1070 | name: 'backup_phrase', 1071 | type: 'string', 1072 | realtype: '12_words_or_ciphertext', 1073 | }, 1074 | ], 1075 | minItems: 0, 1076 | maxItems: 1, 1077 | help: 'Generate the owner and payment private keys, optionally from a given 12-word ' + 1078 | 'backup phrase. If no backup phrase is given, a new one will be generated. If you provide ' + 1079 | 'your encrypted backup phrase, you will be asked to decrypt it.\n' + 1080 | '\n' + 1081 | 'Example:\n' + 1082 | '\n' + 1083 | ' $ blockstack-cli make_keychain\n' + 1084 | ' {\n' + 1085 | ' "mnemonic": "apart spin rich leader siren foil dish sausage fee pipe ethics bundle",\n' + 1086 | ' "ownerKeyInfo": {\n' + 1087 | ' "idAddress": "ID-192yxWSvZrss3f4ZY48tUMJJxQvuB6DcXE",\n' + 1088 | ' "index": 0,\n' + 1089 | ' "privateKey": "c0a1c606bf509059be1be27aeb88a60bb530910e16e2e605c7ef9e20a0d6bbb001",\n' + 1090 | ' "version": "v0.10-current"\n' + 1091 | ' },\n' + 1092 | ' "paymentKeyInfo": {\n' + 1093 | ' "address": {\n' + 1094 | ' "BTC": "1MRq8Vzb2pCVx9fcrBThWaV7reHhudt3ix",\n' + 1095 | ' "STACKS": "SP3G19B6J50FH6JGXAKS06N6WA1XPJCKKM4JCHC2D"\n' + 1096 | ' },\n' + 1097 | ' "index": 0,\n' + 1098 | ' "privateKey": "56d30f2b605ed114c7dc45599ae521c525d07e1286fbab67452a6586ea49332a01"\n' + 1099 | ' }\n' + 1100 | ' }\n' + 1101 | '\n', 1102 | group: 'Key Management', 1103 | }, 1104 | make_zonefile: { 1105 | type: "array", 1106 | items: [ 1107 | { 1108 | name: 'blockstack_id', 1109 | type: 'string', 1110 | realtype: 'blockstack_id', 1111 | pattern: `^${NAME_PATTERN}|${SUBDOMAIN_PATTERN}$`, 1112 | }, 1113 | { 1114 | name: 'id_address', 1115 | type: 'string', 1116 | realtype: 'ID-address', 1117 | pattern: ID_ADDRESS_PATTERN, 1118 | }, 1119 | { 1120 | name: 'gaia_url_prefix', 1121 | type: 'string', 1122 | realtype: 'url', 1123 | pattern: '.+', 1124 | }, 1125 | { 1126 | name: 'resolver_url', 1127 | type: 'string', 1128 | realtype: 'url', 1129 | pattern: '.+', 1130 | }, 1131 | ], 1132 | minItems: 3, 1133 | maxItems: 4, 1134 | help: "Generate a zone file for a Blockstack ID with the given profile URL. If you know " + 1135 | "the ID-address for the Blockstack ID, the profile URL usually takes the form of:\n" + 1136 | "\n" + 1137 | " {GAIA_URL_PREFIX}/{ADDRESS}/profile.json\n" + 1138 | "\n" + 1139 | "where {GAIA_URL_PREFIX} is the *read* endpoint of your Gaia hub (e.g. https://gaia.blockstack.org/hub) and " + 1140 | "{ADDRESS} is the base58check part of your ID-address (i.e. the string following 'ID-').\n" + 1141 | '\n' + 1142 | 'Example:\n' + 1143 | '\n' + 1144 | ' $ blockstack-cli make_zonefile example.id ID-1ArdkA2oLaKnbNbLccBaFhEV4pYju8hJ82 https://my.gaia.hub/hub\n' + 1145 | ' $ORIGIN example.id\n' + 1146 | ' $TTL 3600\n' + 1147 | ' _http._tcp IN URI 10 1 "https://my.gaia.hub/hub/1ArdkA2oLaKnbNbLccBaFhEV4pYju8hJ82/profile.json"\n' + 1148 | '\n', 1149 | group: "Peer Services", 1150 | }, 1151 | name_import: { 1152 | type: "array", 1153 | items: [ 1154 | { 1155 | name: 'blockstack_id', 1156 | type: "string", 1157 | realtype: 'blockstack_id', 1158 | pattern: NAME_PATTERN, 1159 | }, 1160 | { 1161 | name: 'id_address', 1162 | type: "string", 1163 | realtype: 'id-address', 1164 | pattern: ID_ADDRESS_PATTERN, 1165 | }, 1166 | { 1167 | name: 'gaia_url_prefix', 1168 | type: "string", 1169 | realtype: 'url', 1170 | pattern: '.+', 1171 | }, 1172 | { 1173 | name: 'reveal_key', 1174 | type: "string", 1175 | realtype: 'private_key', 1176 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 1177 | }, 1178 | { 1179 | name: 'zonefile', 1180 | type: 'string', 1181 | realtype: 'path', 1182 | pattern: '.+', 1183 | }, 1184 | { 1185 | name: 'zonefile_hash', 1186 | type: 'string', 1187 | realtype: 'zonefile_hash', 1188 | pattern: ZONEFILE_HASH_PATTERN, 1189 | }, 1190 | ], 1191 | minItems: 4, 1192 | maxItems: 6, 1193 | help: 'Import a name into a namespace you revealed. The REVEAL_KEY must be the same as ' + 1194 | 'the key that revealed the namespace. You can only import a name into a namespace if ' + 1195 | 'the namespace has not yet been launched (i.e. via `namespace_ready`), and if the ' + 1196 | 'namespace was revealed less than a year ago (52595 blocks ago).\n' + 1197 | '\n' + 1198 | 'A zone file will be generated for this name automatically, if "ZONEFILE" is not given. By default, ' + 1199 | 'the zone file will have a URL to the name owner\'s profile prefixed by GAIA_URL_PREFIX. If you ' + 1200 | 'know the *write* endpoint for the name owner\'s Gaia hub, you can find out the GAIA_URL_PREFIX ' + 1201 | 'to use with "curl $GAIA_HUB/hub_info".\n' + 1202 | '\n' + 1203 | 'If you specify an argument for "ZONEFILE," then the GAIA_URL_PREFIX argument is ignored in favor of ' + 1204 | 'your custom zone file on disk.\n' + 1205 | '\n' + 1206 | 'If you specify a valid zone file hash for "ZONEFILE_HASH," then it will be used in favor of ' + 1207 | 'both ZONEFILE and GAIA_URL_PREFIX. The zone file hash will be incorporated directly into the ' + 1208 | 'name-import transaction.\n' + 1209 | '\n' + 1210 | 'This command prints out a transaction ID if it succeeds, and it replicates the zone file (if given) ' + 1211 | 'to a transaction broadcaster (you can choose which one with -T). The zone file will be automatically ' + 1212 | 'broadcast to the Blockstack peer network when the transaction confirms. Alternatively, you can do so ' + 1213 | 'yourself with the "zonefile_push" command.\n' + 1214 | '\n' + 1215 | 'Example:\n' + 1216 | '\n' + 1217 | ' $ export REVEAL_KEY="bfeffdf57f29b0cc1fab9ea197bb1413da2561fe4b83e962c7f02fbbe2b1cd5401"\n' + 1218 | ' $ export ID_ADDRESS="ID-18e1bqU7B5qUPY3zJgMLxDnexyStTeSnvV"\n' + 1219 | ' $ blockstack-cli name_import example.id "$ID_ADDRESS" https://gaia.blockstack.org/hub "$REVEAL_KEY"\n' + 1220 | ' f726309cea7a9db364307466dc0e0e759d5c0d6bad1405e2fd970740adc7dc45\n' + 1221 | '\n', 1222 | group: 'Namespace Operations', 1223 | }, 1224 | namespace_preorder: { 1225 | type: 'array', 1226 | items: [ 1227 | { 1228 | name: 'namespace_id', 1229 | type: 'string', 1230 | realtype: 'namespace_id', 1231 | pattern: NAMESPACE_PATTERN, 1232 | }, 1233 | { 1234 | name: 'reveal_address', 1235 | type: 'string', 1236 | realtype: 'address', 1237 | pattern: ADDRESS_PATTERN, 1238 | }, 1239 | { 1240 | name: 'payment_key', 1241 | type: 'string', 1242 | realtype: 'private_key', 1243 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 1244 | }, 1245 | ], 1246 | minItems: 3, 1247 | maxItems: 3, 1248 | help: 'Preorder a namespace. This is the first of three steps to creating a namespace. ' + 1249 | 'Once this transaction is confirmed, you will need to use the `namespace_reveal` command ' + 1250 | 'to reveal the namespace (within 24 hours, or 144 blocks).', 1251 | group: 'Namespace Operations', 1252 | }, 1253 | namespace_reveal: { 1254 | type: 'array', 1255 | items: [ 1256 | { 1257 | name: 'namespace_id', 1258 | type: 'string', 1259 | realtype: 'namespace_id', 1260 | pattern: NAMESPACE_PATTERN, 1261 | }, 1262 | { 1263 | name: 'reveal_address', 1264 | type: 'string', 1265 | realtype: 'address', 1266 | pattern: ADDRESS_PATTERN, 1267 | }, 1268 | { 1269 | // version 1270 | name: 'version', 1271 | type: 'string', 1272 | realtype: '2-byte-integer', 1273 | pattern: INT_PATTERN, 1274 | }, 1275 | { 1276 | // lifetime 1277 | name: 'lifetime', 1278 | type: 'string', 1279 | realtype: '4-byte-integer', 1280 | pattern: INT_PATTERN, 1281 | }, 1282 | { 1283 | // coeff 1284 | name: 'coefficient', 1285 | type: 'string', 1286 | realtype: '1-byte-integer', 1287 | pattern: INT_PATTERN, 1288 | }, 1289 | { 1290 | // base 1291 | name: 'base', 1292 | type: 'string', 1293 | realtype: '1-byte-integer', 1294 | pattern: INT_PATTERN, 1295 | }, 1296 | { 1297 | // buckets 1298 | name: 'price_buckets', 1299 | type: 'string', 1300 | realtype: 'csv-of-16-nybbles', 1301 | pattern: '^([0-9]{1,2},){15}[0-9]{1,2}$' 1302 | }, 1303 | { 1304 | // non-alpha discount 1305 | name: 'nonalpha_discount', 1306 | type: 'string', 1307 | realtype: 'nybble', 1308 | pattern: INT_PATTERN, 1309 | }, 1310 | { 1311 | // no-vowel discount 1312 | name: 'no_vowel_discount', 1313 | type: 'string', 1314 | realtype: 'nybble', 1315 | pattern: INT_PATTERN, 1316 | }, 1317 | { 1318 | name: 'payment_key', 1319 | type: 'string', 1320 | realtype: 'private_key', 1321 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 1322 | }, 1323 | ], 1324 | minItems: 10, 1325 | maxItems: 10, 1326 | help: 'Reveal a preordered namespace, and set the price curve and payment options. ' + 1327 | 'This is the second of three steps required to create a namespace, and must be done ' + 1328 | 'shortly after the associated "namespace_preorder" command.', 1329 | group: 'Namespace Operations' 1330 | }, 1331 | namespace_ready: { 1332 | type: 'array', 1333 | items: [ 1334 | { 1335 | name: 'namespace_id', 1336 | type: 'string', 1337 | realtype: 'namespace_id', 1338 | pattern: NAMESPACE_PATTERN, 1339 | }, 1340 | { 1341 | name: 'reveal_key', 1342 | type: 'string', 1343 | realtype: 'private_key', 1344 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 1345 | }, 1346 | ], 1347 | minItems: 2, 1348 | maxItems: 2, 1349 | help: 'Launch a revealed namespace. This is the third and final step of creating a namespace. ' + 1350 | 'Once launched, you will not be able to import names anymore.', 1351 | group: 'Namespace Operations' 1352 | }, 1353 | price: { 1354 | type: "array", 1355 | items: [ 1356 | { 1357 | name: 'blockstack_id', 1358 | type: "string", 1359 | realtype: 'blockstack_id', 1360 | pattern: NAME_PATTERN, 1361 | }, 1362 | ], 1363 | minItems: 1, 1364 | maxItems: 1, 1365 | help: 'Get the price of an on-chain Blockstack ID. Its namespace must already exist.\n' + 1366 | '\n' + 1367 | 'Example:\n' + 1368 | '\n' + 1369 | ' $ blockstack-cli price example.id\n' + 1370 | ' {\n' + 1371 | ' "units": "BTC",\n' + 1372 | ' "amount": "5500"\n' + 1373 | ' }\n' + 1374 | '\n', 1375 | group: 'Querying Blockstack IDs', 1376 | }, 1377 | price_namespace: { 1378 | type: "array", 1379 | items: [ 1380 | { 1381 | name: 'namespace_id', 1382 | type: "string", 1383 | realtype: 'namespace_id', 1384 | pattern: NAMESPACE_PATTERN, 1385 | }, 1386 | ], 1387 | minItems: 1, 1388 | maxItems: 1, 1389 | help: 'Get the price of a namespace.\n' + 1390 | '\n' + 1391 | 'Example:\n' + 1392 | '\n' + 1393 | ' $ # get the price of the .hello namespace\n' + 1394 | ' $ blockstack-cli price_namespace hello\n' + 1395 | ' {\n' + 1396 | ' "units": "BTC",\n' + 1397 | ' "amount": "40000000"\n' + 1398 | ' }\n' + 1399 | '\n', 1400 | group: 'Namespace Operations', 1401 | }, 1402 | profile_sign: { 1403 | type: "array", 1404 | items: [ 1405 | { 1406 | name: 'profile', 1407 | type: "string", 1408 | realtype: 'path', 1409 | }, 1410 | { 1411 | name: 'owner_key', 1412 | type: "string", 1413 | realtype: 'private_key', 1414 | pattern: PRIVATE_KEY_PATTERN 1415 | } 1416 | ], 1417 | minItems: 2, 1418 | maxItems: 2, 1419 | help: 'Sign a profile on disk with a given owner private key. Print out the signed profile JWT.\n' + 1420 | '\n' + 1421 | 'Example:\n' + 1422 | '\n' + 1423 | ' $ # Tip: you can get the owner key from your 12-word backup phrase using the get_owner_keys command\n' + 1424 | ' $ blockstack-cli profile_sign /path/to/profile.json 0ffd299af9c257173be8486ef54a4dd1373407d0629ca25ca68ff24a76be09fb01\n' + 1425 | '\n', 1426 | group: 'Profiles', 1427 | }, 1428 | profile_store: { 1429 | type: "array", 1430 | items: [ 1431 | { 1432 | name: 'user_id', 1433 | type: "string", 1434 | realtype: 'name-or-id-address', 1435 | pattern: `${NAME_PATTERN}|${SUBDOMAIN_PATTERN}|${ID_ADDRESS_PATTERN}`, 1436 | }, 1437 | { 1438 | name: 'profile', 1439 | type: "string", 1440 | realtype: 'path', 1441 | }, 1442 | { 1443 | name: 'owner_key', 1444 | type: "string", 1445 | realtype: 'private_key', 1446 | pattern: PRIVATE_KEY_PATTERN 1447 | }, 1448 | { 1449 | name: 'gaia_hub', 1450 | type: "string", 1451 | realtype: 'url', 1452 | } 1453 | ], 1454 | minItems: 4, 1455 | maxItems: 4, 1456 | help: 'Store a profile on disk to a Gaia hub. USER_ID can be either a Blockstack ID or ' + 1457 | 'an ID-address. The GAIA_HUB argument must be the *write* endpoint for the user\'s Gaia hub ' + 1458 | '(e.g. https://hub.blockstack.org). You can verify this by ensuring that you can run \'curl ' + 1459 | '"$GAIA_HUB/hub_info"\' successfully.', 1460 | group: 'Profiles' 1461 | }, 1462 | profile_verify: { 1463 | type: "array", 1464 | items: [ 1465 | { 1466 | name: 'profile', 1467 | type: "string", 1468 | realtype: 'path', 1469 | }, 1470 | { 1471 | name: 'id_address', 1472 | type: 'string', 1473 | realtype: 'id-address', 1474 | pattern: `${ID_ADDRESS_PATTERN}|${PUBLIC_KEY_PATTERN}`, 1475 | } 1476 | ], 1477 | minItems: 2, 1478 | maxItems: 2, 1479 | help: 'Verify a JWT encoding a profile on disk using an ID-address. Prints out the contained profile on success.\n' + 1480 | '\n' + 1481 | 'Example:\n' + 1482 | '\n' + 1483 | ' $ # get the raw profile JWT\n' + 1484 | ' $ curl -sL https://raw.githubusercontent.com/jcnelson/profile/master/judecn.id > /tmp/judecn.id.jwt\n' + 1485 | ' $ # Tip: you can get the ID-address for a name with the "whois" command\n' + 1486 | ' $ blockstack-cli profile_verify /tmp/judecn.id.jwt ID-16EMaNw3pkn3v6f2BgnSSs53zAKH4Q8YJg\n' + 1487 | '\n', 1488 | group: 'Profiles', 1489 | }, 1490 | renew: { 1491 | type: "array", 1492 | items: [ 1493 | { 1494 | name: 'blockstack_id', 1495 | type: 'string', 1496 | realtype: 'on-chain-blockstack_id', 1497 | pattern: NAME_PATTERN, 1498 | }, 1499 | { 1500 | name: 'owner_key', 1501 | type: 'string', 1502 | realtype: 'private_key', 1503 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 1504 | }, 1505 | { 1506 | name: 'payment_key', 1507 | type: 'string', 1508 | realtype: 'private_key', 1509 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 1510 | }, 1511 | { 1512 | name: 'new_id_address', 1513 | type: 'string', 1514 | realtype: 'id-address', 1515 | pattern: ID_ADDRESS_PATTERN, 1516 | }, 1517 | { 1518 | name: 'zonefile', 1519 | type: 'string', 1520 | realtype: 'path', 1521 | }, 1522 | { 1523 | name: 'zonefile_hash', 1524 | type: 'string', 1525 | realtype: 'zonefile_hash', 1526 | pattern: ZONEFILE_HASH_PATTERN, 1527 | }, 1528 | ], 1529 | minItems: 3, 1530 | maxItems: 6, 1531 | help: 'Renew a name. Optionally transfer it to a new owner address (NEW_ID_ADDRESS), ' + 1532 | 'and optionally load up and give it a new zone file on disk (ZONEFILE). If the command ' + 1533 | 'succeeds, it prints out a transaction ID. You can use with the "get_confirmations" ' + 1534 | 'command to track its confirmations on the underlying blockchain -- once it reaches 7 ' + 1535 | 'confirmations, the rest of the Blockstack peer network will process it.\n' + 1536 | '\n' + 1537 | 'If you create a new zonefile for your name, you will need ' + 1538 | 'to later use "zonefile_push" to replicate the zone file to the Blockstack peer network ' + 1539 | 'once the transaction reaches 7 confirmations.\n' + 1540 | '\n' + 1541 | 'Example:\n' + 1542 | '\n' + 1543 | ' $ # Tip: you can get your owner key from your backup phrase with "get_owner_keys".\n' + 1544 | ' $ # Tip: you can get your payment key from your backup phrase with "get_payment_key".\n' + 1545 | ' $ export OWNER="136ff26efa5db6f06b28f9c8c7a0216a1a52598045162abfe435d13036154a1b01"\n' + 1546 | ' $ export PAYMENT="bfeffdf57f29b0cc1fab9ea197bb1413da2561fe4b83e962c7f02fbbe2b1cd5401"\n' + 1547 | ' $ blockstack-cli renew hello_world.id "$OWNER" "$PAYMENT"\n' + 1548 | ' 3d8945ce76d4261678d76592b472ed639a10d4298f9d730af4edbbc3ec02882e\n' + 1549 | '\n' + 1550 | ' $ # Renew with a new owner\n' + 1551 | ' $ export NEW_OWNER="ID-141BcmFVbEuuMb7Bd6umXyV6ZD1WYomYDE"\n' + 1552 | ' $ blockstack-cli renew hello_world.id "$OWNER" "$PAYMENT" "$NEW_OWNER"\n' + 1553 | ' 33865625ef3f1b607111c0dfba9e58604927173bd2e299a343e19aa6d2cfb263\n' + 1554 | '\n' + 1555 | ' $ # Renew with a new zone file.\n' + 1556 | ' $ # Tip: you can create a new zonefile with the "make_zonefile" command.\n' + 1557 | ' $ export ZONEFILE_PATH="/path/to/new/zonefile.txt"\n' + 1558 | ' $ blockstack-cli renew hello_world.id "$OWNER" "$PAYMENT" --zonefile "$ZONEFILE_PATH"\n' + 1559 | ' e41ce043ab64fd5a5fd382fba21acba8c1f46cbb1d7c08771ada858ce7d29eea\n' + 1560 | ' $ # wait 7 confirmations\n' + 1561 | ' $ blockstack-cli get_confirmations e41ce043ab64fd5a5fd382fba21acba8c1f46cbb1d7c08771ada858ce7d29eea\n' + 1562 | ' {\n' + 1563 | ' "blockHeight": 567890,\n' + 1564 | ' "confirmations": 7,\n' + 1565 | ' }\n' + 1566 | ' $ blockstack-cli -H https://core.blockstack.org zonefile_push "$ZONEFILE_PATH"\n' + 1567 | ' [\n' + 1568 | ' "https://core.blockstack.org"\n' + 1569 | ' ]\n' + 1570 | '\n', 1571 | group: 'Blockstack ID Management', 1572 | }, 1573 | register: { 1574 | type: "array", 1575 | items: [ 1576 | { 1577 | name: 'blockstack_id', 1578 | type: 'string', 1579 | realtype: 'on-chain-blockstack_id', 1580 | pattern: NAME_PATTERN, 1581 | }, 1582 | { 1583 | name: 'owner_key', 1584 | type: 'string', 1585 | realtype: 'private_key', 1586 | pattern: PRIVATE_KEY_PATTERN, 1587 | }, 1588 | { 1589 | name: 'payment_key', 1590 | type: 'string', 1591 | realtype: 'private_key', 1592 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 1593 | }, 1594 | { 1595 | name: 'gaia_hub', 1596 | type: 'string', 1597 | realtype: 'url', 1598 | }, 1599 | { 1600 | name: 'zonefile', 1601 | type: 'string', 1602 | realtype: 'path', 1603 | }, 1604 | ], 1605 | minItems: 4, 1606 | maxItems: 5, 1607 | help: 'If you are trying to register a name for a *private key*, use this command.\n' + 1608 | '\n' + 1609 | 'Register a name to a single name-owning private key. After successfully running this command, ' + 1610 | 'and after waiting a couple hours, your name will be ready to use and will resolve to a ' + 1611 | 'signed empty profile hosted on the given Gaia hub (GAIA_HUB).\n' + 1612 | '\n' + 1613 | 'Behind the scenes, this will generate and send two transactions ' + 1614 | 'and generate and replicate a zone file with the given Gaia hub URL (GAIA_HUB). ' + 1615 | 'Note that the GAIA_HUB argument must correspond to the *write* endpoint of the Gaia hub ' + 1616 | '(i.e. you should be able to run \'curl "$GAIA_HUB/hub_info"\' and get back data). If you ' + 1617 | 'are using Blockstack PBC\'s default Gaia hub, pass "https://hub.blockstack.org" for this ' + 1618 | 'argument.\n' + 1619 | '\n' + 1620 | 'By default, this command generates a zone file automatically that points to the Gaia hub\'s ' + 1621 | 'read endpoint (which is queried on-the-fly from GAIA_HUB). If you instead want to have a custom zone file for this name, ' + 1622 | 'you can specify a path to it on disk with the ZONEFILE argument.\n' + 1623 | '\n' + 1624 | 'If this command completes successfully, your name will be ready to use once both transactions have 7+ confirmations. ' + 1625 | 'You can use the "get_confirmations" command to track the confirmations ' + 1626 | 'on the transaction IDs returned by this command.\n' + 1627 | '\n' + 1628 | 'WARNING: You should *NOT* use the payment private key (PAYMENT_KEY) while the name is being confirmed. ' + 1629 | 'If you do so, you could double-spend one of the pending transactions and lose your name.\n' + 1630 | '\n' + 1631 | 'Example:\n' + 1632 | '\n' + 1633 | ' $ export OWNER="136ff26efa5db6f06b28f9c8c7a0216a1a52598045162abfe435d13036154a1b01"\n' + 1634 | ' $ export PAYMENT="bfeffdf57f29b0cc1fab9ea197bb1413da2561fe4b83e962c7f02fbbe2b1cd5401"\n' + 1635 | ' $ blockstack-cli register example.id "$OWNER" "$PAYMENT" https://hub.blockstack.org\n' + 1636 | ' 9bb908bfd4ab221f0829167a461229172184fc825a012c4e551533aa283207b1\n' + 1637 | '\n', 1638 | group: 'Blockstack ID Management', 1639 | }, 1640 | register_addr: { 1641 | type: "array", 1642 | items: [ 1643 | { 1644 | name: 'blockstack_id', 1645 | type: 'string', 1646 | realtype: 'blockstack_id', 1647 | pattern: NAME_PATTERN, 1648 | }, 1649 | { 1650 | name: 'id-address', 1651 | type: 'string', 1652 | realtype: 'id-address', 1653 | pattern: ID_ADDRESS_PATTERN, 1654 | }, 1655 | { 1656 | name: 'payment_key', 1657 | type: 'string', 1658 | realtype: 'private_key', 1659 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 1660 | }, 1661 | { 1662 | name: 'gaia_url_prefix', 1663 | type: 'string', 1664 | realtype: 'url', 1665 | }, 1666 | { 1667 | name: 'zonefile', 1668 | type: 'string', 1669 | realtype: 'path', 1670 | } 1671 | ], 1672 | minItems: 4, 1673 | maxItems: 4, 1674 | help: 'If you are trying to register a name for an *ID-address*, use this command.\n' + 1675 | '\n' + 1676 | 'Register a name to someone\'s ID-address. After successfully running this ' + 1677 | 'command and waiting a couple of hours, the name will be registered on-chain and have a ' + 1678 | 'zone file with a URL to where the owner\'s profile should be. This command does NOT ' + 1679 | 'generate, sign, or replicate a profile for the name---the name owner will need to do this ' + 1680 | 'separately, once the name is registered.\n' + 1681 | '\n' + 1682 | 'Behind the scenes, this command will generate two ' + 1683 | 'transactions, and generate and replicate a zone file with the given Gaia hub read URL ' + 1684 | '(GAIA_URL_PREFIX). Note that the GAIA_URL_PREFIX argument must correspond to the *read* endpoint of the Gaia hub ' + 1685 | '(e.g. if you are using Blockstack PBC\'s default Gaia hub, this is "https://gaia.blockstack.org/hub"). ' + 1686 | 'If you know the *write* endpoint of the name owner\'s Gaia hub, you can find the right value for ' + 1687 | 'GAIA_URL_PREFIX by running "curl $GAIA_HUB/hub_info".\n' + 1688 | '\n' + 1689 | 'No profile will be generated or uploaded by this command. Instead, this command generates ' + 1690 | 'a zone file that will include the URL to a profile based on the GAIA_URL_PREFIX argument.\n' + 1691 | '\n' + 1692 | 'The zone file will be generated automatically from the GAIA_URL_PREFIX argument. If you need ' + 1693 | 'to use a custom zone file, you can pass the path to it on disk via the ZONEFILE argument.\n' + 1694 | '\n' + 1695 | 'If this command completes successfully, the name will be ready to use in a couple of ' + 1696 | 'hours---that is, once both transactions have 7+ confirmations. ' + 1697 | 'You can use the "get_confirmations" command to track the confirmations.\n' + 1698 | '\n' + 1699 | 'WARNING: You should *NOT* use the payment private key (PAYMENT_KEY) while the name is being confirmed. ' + 1700 | 'If you do so, you could double-spend one of the pending transactions and lose the name.\n' + 1701 | '\n' + 1702 | 'Example:\n' + 1703 | '\n' + 1704 | ' $ export ID_ADDRESS="ID-18e1bqU7B5qUPY3zJgMLxDnexyStTeSnvV"\n' + 1705 | ' $ export PAYMENT="bfeffdf57f29b0cc1fab9ea197bb1413da2561fe4b83e962c7f02fbbe2b1cd5401"\n' + 1706 | ' $ blockstack-cli register_addr example.id "$ID_ADDRESS" "$PAYMENT" https://gaia.blockstack.org/hub', 1707 | group: 'Blockstack ID Management', 1708 | }, 1709 | register_subdomain: { 1710 | type: "array", 1711 | items: [ 1712 | { 1713 | name: 'blockstack_id', 1714 | type: 'string', 1715 | realtype: 'blockstack_id', 1716 | pattern: SUBDOMAIN_PATTERN, 1717 | }, 1718 | { 1719 | name: 'owner_key', 1720 | type: 'string', 1721 | realtype: 'private_key', 1722 | pattern: PRIVATE_KEY_PATTERN, 1723 | }, 1724 | { 1725 | name: 'gaia_hub', 1726 | type: 'string', 1727 | realtype: 'url', 1728 | }, 1729 | { 1730 | name: 'registrar', 1731 | type: 'string', 1732 | realtype: 'url', 1733 | }, 1734 | { 1735 | name: 'zonefile', 1736 | type: 'string', 1737 | realtype: 'path', 1738 | }, 1739 | ], 1740 | minItems: 4, 1741 | maxItems: 5, 1742 | help: 'Register a subdomain. This will generate and sign a subdomain zone file record ' + 1743 | 'with the given GAIA_HUB URL and send it to the given subdomain registrar (REGISTRAR).\n' + 1744 | '\n' + 1745 | 'This command generates, signs, and uploads a profile to the GAIA_HUB url. Note that the GAIA_HUB ' + 1746 | 'argument must correspond to the *write* endpoint of your Gaia hub (i.e. you should be able ' + 1747 | 'to run \'curl "$GAIA_HUB/hub_info"\' successfully). If you are using Blockstack PBC\'s default ' + 1748 | 'Gaia hub, this argument should be "https://hub.blockstack.org".\n' + 1749 | '\n' + 1750 | 'WARNING: At this time, no validation will occur on the registrar URL. Be sure that the URL ' + 1751 | 'corresponds to the registrar for the on-chain name before running this command!\n' + 1752 | '\n' + 1753 | 'Example:\n' + 1754 | '\n' + 1755 | ' $ export OWNER="6e50431b955fe73f079469b24f06480aee44e4519282686433195b3c4b5336ef01"\n' + 1756 | ' $ # NOTE: https://registrar.blockstack.org is the registrar for personal.id!\n' + 1757 | ' $ blockstack-cli register_subdomain hello.personal.id "$OWNER" https://hub.blockstack.org https://registrar.blockstack.org\n', 1758 | group: 'Blockstack ID Management', 1759 | }, 1760 | revoke: { 1761 | type: "array", 1762 | items: [ 1763 | { 1764 | name: 'blockstack_id', 1765 | type: 'string', 1766 | realtype: 'on-chain-blockstack_id', 1767 | pattern: NAME_PATTERN, 1768 | }, 1769 | { 1770 | name: 'owner_key', 1771 | type: 'string', 1772 | realtype: 'private_key', 1773 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 1774 | }, 1775 | { 1776 | name: 'payment_key', 1777 | type: 'string', 1778 | realtype: 'private_key', 1779 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 1780 | }, 1781 | ], 1782 | minItems: 3, 1783 | maxItems: 3, 1784 | help: 'Revoke a name. This renders it unusable until it expires (if ever). This command ' + 1785 | 'prints out the transaction ID if it succeeds. Once the transaction confirms, the name will ' + 1786 | 'be revoked by each node in the peer network. This command only works for on-chain names, not ' + 1787 | 'subdomains.\n' + 1788 | '\n' + 1789 | 'Example:\n' + 1790 | '\n' + 1791 | ' $ # Tip: you can get your owner and payment keys from your 12-word backup phrase using the get_owner_keys and get_payment_key commands.\n' + 1792 | ' $ export OWNER="6e50431b955fe73f079469b24f06480aee44e4519282686433195b3c4b5336ef01"\n' + 1793 | ' $ export PAYMENT="bfeffdf57f29b0cc1fab9ea197bb1413da2561fe4b83e962c7f02fbbe2b1cd5401"\n' + 1794 | ' $ blockstack-cli revoke example.id "$OWNER" "$PAYMENT"\n' + 1795 | ' 233b559c97891affa010567bd582110508d0236b4e3f88d3b1d0731629e030b0\n' + 1796 | '\n', 1797 | group: 'Blockstack ID Management', 1798 | }, 1799 | send_btc: { 1800 | type: "array", 1801 | items: [ 1802 | { 1803 | name: 'recipient_address', 1804 | type: 'string', 1805 | realtype: 'address', 1806 | pattern: ADDRESS_PATTERN, 1807 | }, 1808 | { 1809 | name: 'amount', 1810 | type: 'string', 1811 | realtype: 'satoshis', 1812 | pattern: INT_PATTERN, 1813 | }, 1814 | { 1815 | name: 'payment_key', 1816 | type: 'string', 1817 | realtype: 'private_key', 1818 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 1819 | }, 1820 | ], 1821 | minItems: 3, 1822 | maxItems: 3, 1823 | help: 'Send some Bitcoin (in satoshis) from a payment key to an address. Up to the given ' + 1824 | 'amount will be spent, but likely less---the actual amount sent will be the amount given, ' + 1825 | 'minus the transaction fee. For example, if you want to send 10000 satoshis but the ' + 1826 | 'transaction fee is 2000 satoshis, then the resulting transaction will send 8000 satoshis ' + 1827 | 'to the given address. This is to ensure that this command does not *over*-spend your ' + 1828 | 'Bitcoin. If you want to check the amount before spending, pass the -x flag to see the ' + 1829 | 'raw transaction.\n' + 1830 | '\n' + 1831 | 'If the command succeeds, it prints out the transaction ID. You can track its confirmations ' + 1832 | 'with the get_confirmations command.\n' + 1833 | '\n' + 1834 | 'Example:\n' + 1835 | '\n' + 1836 | ' $ export PAYMENT="bfeffdf57f29b0cc1fab9ea197bb1413da2561fe4b83e962c7f02fbbe2b1cd5401"\n' + 1837 | ' $ blockstack-cli send_btc 18qTSE5PPQmypwKKej7QX5Db2XAttgYeA1 123456 "$PAYMENT"\n' + 1838 | ' c7e239fd24da30e36e011e6bc7db153574a5b40a3a8dc3b727adb54ad038acc5\n' + 1839 | '\n', 1840 | group: 'Account Management' 1841 | }, 1842 | send_tokens: { 1843 | type: "array", 1844 | items: [ 1845 | { 1846 | name: 'address', 1847 | type: 'string', 1848 | realtype: 'address', 1849 | pattern: STACKS_ADDRESS_PATTERN, 1850 | }, 1851 | { 1852 | name: 'type', 1853 | type: 'string', 1854 | realtype: 'token-type', 1855 | pattern: `^${NAMESPACE_PATTERN}$|^STACKS$` 1856 | }, 1857 | { 1858 | name: 'amount', 1859 | type: 'string', 1860 | realtype: 'integer', 1861 | pattern: '^[0-9]+$', 1862 | }, 1863 | { 1864 | name: 'payment_key', 1865 | type: 'string', 1866 | realtype: 'private_key', 1867 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 1868 | }, 1869 | { 1870 | name: 'memo', 1871 | type: 'string', 1872 | realtype: 'string', 1873 | pattern: '^.{0,34}$', 1874 | }, 1875 | ], 1876 | minItems: 4, 1877 | maxItems: 5, 1878 | help: 'Send a particular type of tokens to the given ADDRESS. Right now, only supported TOKEN-TYPE is "STACKS". Optionally ' + 1879 | 'include a memo string (MEMO) up to 34 characters long.\n' + 1880 | '\n' + 1881 | 'If the command succeeds, it prints out a transaction ID. You can track the confirmations on the transaction ' + 1882 | 'via the get_confirmations command. Once the transaction has 7 confirmations, the Blockstack peer network ' + 1883 | 'will have processed it, and your payment key balance and recipient balance will be updated.\n' + 1884 | '\n' + 1885 | 'At this time, token transfers are encoded as Bitcoin transactions. As such, you will need to pay a transaction ' + 1886 | 'fee in Bitcoin. Your payment key should have both a Bitcoin balance and a Stacks balance (you can check with ' + 1887 | 'the "balance" command).\n' + 1888 | '\n' + 1889 | 'Example:\n' + 1890 | '\n' + 1891 | ' $ # check balances of sender and recipient before sending.\n' + 1892 | ' $ # address of the key below is SP2SC16ASH76GX549PT7J5WQZA4GHMFBKYMBQFF9V\n' + 1893 | ' $ export PAYMENT="bfeffdf57f29b0cc1fab9ea197bb1413da2561fe4b83e962c7f02fbbe2b1cd5401"\n' + 1894 | ' $ blockstack-cli balance SP2SC16ASH76GX549PT7J5WQZA4GHMFBKYMBQFF9V\n' + 1895 | ' {\n' + 1896 | ' "BTC": "125500"\n' + 1897 | ' "STACKS": "10000000"\n' + 1898 | ' }\n' + 1899 | ' $ blockstack-cli balance SP1P10PS2T517S4SQGZT5WNX8R00G1ECTRKYCPMHY\n' + 1900 | ' {\n' + 1901 | ' "BTC": "0"\n' + 1902 | ' "STACKS": "0"\n' + 1903 | ' }\n' + 1904 | '\n' + 1905 | ' $ # send tokens\n' + 1906 | ' $ blockstack-cli send_tokens SP1P10PS2T517S4SQGZT5WNX8R00G1ECTRKYCPMHY STACKS 12345 "$PAYMENT"\n' + 1907 | ' a9d387a925fb0ba7a725fb1e11f2c3f1647473699dd5a147c312e6453d233456\n' + 1908 | '\n' + 1909 | ' $ # wait 7 confirmations\n' + 1910 | ' $ blockstack-cli get_confirmations a9d387a925fb0ba7a725fb1e11f2c3f1647473699dd5a147c312e6453d233456\n' + 1911 | ' {\n' + 1912 | ' "blockHeight": 567890,\n' + 1913 | ' "confirmations": 7,\n' + 1914 | ' }\n' + 1915 | '\n' + 1916 | ' $ # check balance again. The recipient receives some dust to encode the Stacks transfer,\n' + 1917 | ' $ # and the sender pays for the transaction fee.\n' + 1918 | ' $ blockstack-cli balance SP2SC16ASH76GX549PT7J5WQZA4GHMFBKYMBQFF9V\n' + 1919 | ' {\n' + 1920 | ' "BTC": "117000"\n' + 1921 | ' "STACKS": "9987655"\n' + 1922 | ' }\n' + 1923 | ' $ blockstack-cli balance SP1P10PS2T517S4SQGZT5WNX8R00G1ECTRKYCPMHY\n' + 1924 | ' {\n' + 1925 | ' "BTC": "5500"\n' + 1926 | ' "STACKS": "12345"\n' + 1927 | ' }\n' + 1928 | '\n', 1929 | group: 'Account Management', 1930 | }, 1931 | transfer: { 1932 | type: "array", 1933 | items: [ 1934 | { 1935 | name: 'blockstack_id', 1936 | type: 'string', 1937 | realtype: 'on-chain-blockstack_id', 1938 | pattern: NAME_PATTERN, 1939 | }, 1940 | { 1941 | name: 'new_id_address', 1942 | type: 'string', 1943 | realtype: 'id-address', 1944 | pattern: ID_ADDRESS_PATTERN, 1945 | }, 1946 | { 1947 | name: 'keep_zonefile', 1948 | type: 'string', 1949 | realtype: 'true-or-false', 1950 | pattern: '^true$|^false$', 1951 | }, 1952 | { 1953 | name: 'owner_key', 1954 | type: 'string', 1955 | realtype: 'private_key', 1956 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 1957 | }, 1958 | { 1959 | name: 'payment_key', 1960 | type: 'string', 1961 | realtype: 'private_key', 1962 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 1963 | }, 1964 | ], 1965 | minItems: 5, 1966 | maxItems: 5, 1967 | help: 'Transfer a Blockstack ID to a new address (NEW_ID_ADDRESS). Optionally preserve ' + 1968 | 'its zone file (KEEP_ZONEFILE). If the command succeeds, it will print a transaction ID. ' + 1969 | 'Once the transaction reaches 7 confirmations, the Blockstack peer network will transfer the ' + 1970 | 'Blockstack ID to the new ID-address. You can track the transaction\'s confirmations with ' + 1971 | 'the "get_confirmations" command.\n' + 1972 | '\n' + 1973 | 'This command only works for on-chain Blockstack IDs. It does not yet work for subdomains.\n' + 1974 | '\n' + 1975 | 'An ID-address can only own up to 25 Blockstack IDs. In practice, you should generate a new ' + 1976 | 'owner key and ID-address for each name you receive (via the "get_owner_keys" command).\n' + 1977 | '\n' + 1978 | 'Example:\n' + 1979 | '\n' + 1980 | ' $ # Tip: you can get your owner key from your backup phrase with "get_owner_keys".\n' + 1981 | ' $ # Tip: you can get your payment key from your backup phrase with "get_payment_key".\n' + 1982 | ' $ export OWNER="136ff26efa5db6f06b28f9c8c7a0216a1a52598045162abfe435d13036154a1b01"\n' + 1983 | ' $ export PAYMENT="bfeffdf57f29b0cc1fab9ea197bb1413da2561fe4b83e962c7f02fbbe2b1cd5401"\n' + 1984 | ' $ blockstack-cli transfer example.id ID-1HJA1AJvWef21XbQVL2AcTv71b6JHGPfDX true "$OWNER" "$PAYMENT"\n' + 1985 | ' e09dc158e586d0c09dbcdcba917ec394e6c6ac2b9c91c4b55f32f5973e4f08fc\n' + 1986 | '\n', 1987 | group: 'Blockstack ID Management', 1988 | }, 1989 | tx_preorder: { 1990 | type: "array", 1991 | items: [ 1992 | { 1993 | name: 'blockstack_id', 1994 | type: 'string', 1995 | realtype: 'on-chain-blockstack_id', 1996 | pattern: NAME_PATTERN, 1997 | }, 1998 | { 1999 | name: 'id_address', 2000 | type: 'string', 2001 | realtype: 'id-address', 2002 | pattern: ID_ADDRESS_PATTERN, 2003 | }, 2004 | { 2005 | name: 'payment_key', 2006 | type: 'string', 2007 | realtype: 'private_key', 2008 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 2009 | }, 2010 | ], 2011 | minItems: 3, 2012 | maxItems: 3, 2013 | help: 'Generate and send NAME_PREORDER transaction, for a Blockstack ID to be owned ' + 2014 | 'by a given ID_ADDRESS. The name cost will be paid for by the gven PAYMENT_KEY. The ' + 2015 | 'ID-address should be a never-before-seen address, since it will be used as a salt when ' + 2016 | 'generating the name preorder hash.\n' + 2017 | '\n' + 2018 | 'This is a low-level command that only experienced Blockstack developers should use. ' + 2019 | 'If you just want to register a name, use the "register" command.\n', 2020 | group: 'Blockstack ID Management', 2021 | }, 2022 | tx_register: { 2023 | type: "array", 2024 | items: [ 2025 | { 2026 | name: 'blockstack_id', 2027 | type: 'string', 2028 | realtype: 'on-chain-blockstack_id', 2029 | pattern: NAME_PATTERN, 2030 | }, 2031 | { 2032 | name: 'id_address', 2033 | type: 'string', 2034 | realtype: 'id-address', 2035 | pattern: ID_ADDRESS_PATTERN, 2036 | }, 2037 | { 2038 | name: 'payment_key', 2039 | type: 'string', 2040 | realtype: 'private_key', 2041 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 2042 | }, 2043 | { 2044 | name: 'zonefile', 2045 | type: 'string', 2046 | realtype: 'path', 2047 | }, 2048 | { 2049 | name: 'zonefile_hash', 2050 | type: 'string', 2051 | realtype: 'zoenfile_hash', 2052 | pattern: ZONEFILE_HASH_PATTERN, 2053 | }, 2054 | ], 2055 | minItems: 3, 2056 | maxItems: 5, 2057 | help: 'Generate and send a NAME_REGISTRATION transaction, assigning the given BLOCKSTACK_ID ' + 2058 | 'to the given ID_ADDRESS. Optionally pair the Blockstack ID with a zone file (ZONEFILE) or ' + 2059 | 'the hash of the zone file (ZONEFILE_HASH). You will need to push the zone file to the peer ' + 2060 | 'network after the transaction confirms (i.e. with "zonefile_push").\n' + 2061 | '\n' + 2062 | 'This is a low-level command that only experienced Blockstack developers should use. If you ' + 2063 | 'just want to register a name, you should use the "register" command.', 2064 | group: 'Blockstack ID Management', 2065 | }, 2066 | update: { 2067 | type: "array", 2068 | items: [ 2069 | { 2070 | name: 'blockstack_id', 2071 | type: 'string', 2072 | realtype: 'on-chain-blockstack_id', 2073 | pattern: NAME_PATTERN, 2074 | }, 2075 | { 2076 | name: 'zonefile', 2077 | type: 'string', 2078 | realtype: 'path', 2079 | }, 2080 | { 2081 | name: 'owner_key', 2082 | type: 'string', 2083 | realtype: 'private_key', 2084 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 2085 | }, 2086 | { 2087 | name: 'payment_key', 2088 | type: 'string', 2089 | realtype: 'private_key', 2090 | pattern: `${PRIVATE_KEY_PATTERN_ANY}` 2091 | }, 2092 | { 2093 | name: 'zonefile_hash', 2094 | type: 'string', 2095 | realtype: 'zonefile_hash', 2096 | pattern: ZONEFILE_HASH_PATTERN, 2097 | }, 2098 | ], 2099 | minItems: 4, 2100 | maxItems: 5, 2101 | help: 'Update the zonefile for an on-chain Blockstack ID. You can generate a well-formed ' + 2102 | 'zone file using the "make_zonefile" command, or you can supply your own. Zone files can be ' + 2103 | 'up to 40Kb. Alternatively, if you only want to announce the hash of a zone file (or any ' + 2104 | 'arbitrary 20-byte hex string), you can do so by passing a value for ZONEFILE_HASH. If ZONEFILE_HASH ' + 2105 | 'is given, then the value for ZONEFILE will be ignored.\n' + 2106 | '\n' + 2107 | 'If this command succeeds, it prints out a transaction ID. Once the transaction has 7 confirmations, ' + 2108 | 'the Blockstack peer network will set the name\'s zone file hash to the RIPEMD160(SHA256) hash of ' + 2109 | 'the given zone file (or it will simply set it to the hash given in ZONEFILE_HASH).\n' + 2110 | '\n' + 2111 | 'Once the transaction confirms, you will need to replicate the zone file to the Blockstack peer network. ' + 2112 | 'This can be done with the "zonefile_push" command. Until you do so, no Blockstack clients will be able ' + 2113 | 'to obtain the zone file announced by this command.\n' + 2114 | '\n' + 2115 | 'Example:\n' + 2116 | '\n' + 2117 | ' $ # Tip: you can get your owner and payment keys from your 12-word backup phrase using the get_owner_keys and get_payment_key commands.\n' + 2118 | ' $ export OWNER="6e50431b955fe73f079469b24f06480aee44e4519282686433195b3c4b5336ef01"\n' + 2119 | ' $ export PAYMENT="bfeffdf57f29b0cc1fab9ea197bb1413da2561fe4b83e962c7f02fbbe2b1cd5401"\n' + 2120 | ' $ # make a new zone file\n' + 2121 | ' $ blockstack-cli make_zonefile example.id ID-1ArdkA2oLaKnbNbLccBaFhEV4pYju8hJ82 https://my.gaia.hub/hub > /tmp/zonefile.txt\n' + 2122 | ' \n' + 2123 | ' $ # update the name to reference this new zone file\n' + 2124 | ' $ blockstack-cli update example.id /tmp/zonefile.txt "$OWNER" "$PAYMENT"\n' + 2125 | ' 8e94a5b6647276727a343713d3213d587836e1322b1e38bc158406f5f8ebe3fd\n' + 2126 | ' \n' + 2127 | ' $ # check confirmations\n' + 2128 | ' $ blockstack-cli get_confirmations e41ce043ab64fd5a5fd382fba21acba8c1f46cbb1d7c08771ada858ce7d29eea\n' + 2129 | ' {\n' + 2130 | ' "blockHeight": 567890,\n' + 2131 | ' "confirmations": 7,\n' + 2132 | ' }\n' + 2133 | ' \n' + 2134 | ' $ # send out the new zone file to a Blockstack peer\n' + 2135 | ' $ blockstack-cli -H https://core.blockstack.org zonefile_push /tmp/zonefile.txt\n' + 2136 | ' [\n' + 2137 | ' "https://core.blockstack.org"\n' + 2138 | ' ]\n' + 2139 | '\n', 2140 | group: 'Blockstack ID Management' 2141 | }, 2142 | whois: { 2143 | type: "array", 2144 | items: [ 2145 | { 2146 | name: 'blockstack_id', 2147 | type: "string", 2148 | realtype: 'blockstack_id', 2149 | pattern: NAME_PATTERN + "|"+ SUBDOMAIN_PATTERN, 2150 | }, 2151 | ], 2152 | minItems: 1, 2153 | maxItems: 1, 2154 | help: 'Look up the zone file and owner of a Blockstack ID. Works with both on-chain and off-chain names.\n' + 2155 | '\n' + 2156 | 'Example:\n' + 2157 | '\n' + 2158 | ' $ blockstack-cli whois example.id\n' + 2159 | ' {\n' + 2160 | ' "address": "1ArdkA2oLaKnbNbLccBaFhEV4pYju8hJ82",\n' + 2161 | ' "block_renewed_at": 567890,\n' + 2162 | ' "blockchain": "bitcoin",\n' + 2163 | ' "expire_block": 687010,\n' + 2164 | ' "grace_period": false,\n' + 2165 | ' "last_transaction_height": "567891",\n' + 2166 | ' "last_txid": "a564aa482ee43eb2bdfb016e01ea3b950bab0cfa39eace627d632e73c7c93e48",\n' + 2167 | ' "owner_script": "76a9146c1c2fc3cf74d900c51e9b5628205130d7b98ae488ac",\n' + 2168 | ' "renewal_deadline": 692010,\n' + 2169 | ' "resolver": null,\n' + 2170 | ' "status": "registered",\n' + 2171 | ' "zonefile": "$ORIGIN example.id\\n$TTL 3600\\n_http._tcp URI 10 1 \\"https://gaia.blockstack.org/hub/1ArdkA2oLaKnbNbLccBaFhEV4pYju8hJ82/profile.json\\"\\n",\n' + 2172 | ' "zonefile_hash": "ae4ee8e7f30aa890468164e667e2c203266f726e"\n' + 2173 | ' }\n' + 2174 | '\n', 2175 | group: 'Querying Blockstack IDs', 2176 | }, 2177 | zonefile_push: { 2178 | type: "array", 2179 | items: [ 2180 | { 2181 | name: 'zonefile', 2182 | type: "string", 2183 | realtype: 'path', 2184 | }, 2185 | ], 2186 | minItems: 1, 2187 | maxItems: 1, 2188 | help: 'Push a zone file on disk to the Blockstack peer network. The zone file must ' + 2189 | 'correspond to a zone file hash that has already been announced. That is, you use this command ' + 2190 | 'in conjunction with the "register", "update", "renew", or "name_import" commands.\n' + 2191 | '\n' + 2192 | 'Example:\n' + 2193 | '\n' + 2194 | ' $ blockstack-cli -H https://core.blockstack.org zonefile_push /path/to/zonefile.txt\n' + 2195 | ' [\n' + 2196 | ' "https://core.blockstack.org"\n' + 2197 | ' ]\n' + 2198 | '\n', 2199 | group: 'Peer Services', 2200 | }, 2201 | }, 2202 | additionalProperties: false, 2203 | strict: true 2204 | }; 2205 | 2206 | // usage string for built-in options 2207 | export const USAGE = `Usage: ${process.argv[1]} [options] command [command arguments] 2208 | Options can be: 2209 | -c Path to a config file (defaults to 2210 | ${DEFAULT_CONFIG_PATH}) 2211 | 2212 | -d Print verbose debugging output 2213 | 2214 | -e Estimate the BTC cost of an transaction (in satoshis). 2215 | Do not generate or send any transactions. 2216 | 2217 | -t Use the public testnet instead of mainnet. 2218 | 2219 | -i Use integration test framework instead of mainnet. 2220 | 2221 | -U Unsafe mode. No safety checks will be performed. 2222 | 2223 | -x Do not broadcast a transaction. Only generate and 2224 | print them to stdout. 2225 | 2226 | -B BURN_ADDR Use the given namespace burn address instead of the one 2227 | obtained from the Blockstack network (DANGEROUS) 2228 | 2229 | -D DENOMINATION Denominate the price to pay in the given units 2230 | (DANGEROUS) 2231 | 2232 | -C CONSENSUS_HASH Use the given consensus hash instead of one obtained 2233 | from the network 2234 | 2235 | -F FEE_RATE Use the given transaction fee rate instead of the one 2236 | obtained from the Bitcoin network 2237 | 2238 | -G GRACE_PERIOD Number of blocks in which a name can be renewed after it 2239 | expires (DANGEROUS) 2240 | 2241 | -H URL Use an alternative Blockstack Core API endpoint. 2242 | 2243 | -I URL Use an alternative Blockstack Core Indexer endpoint. 2244 | 2245 | -N PAY2NS_PERIOD Number of blocks in which a namespace receives the registration 2246 | and renewal fees after it is created (DANGEROUS) 2247 | 2248 | -P PRICE Use the given price to pay for names or namespaces 2249 | (DANGEROUS) 2250 | 2251 | -T URL Use an alternative Blockstack transaction broadcaster. 2252 | `; 2253 | 2254 | /* 2255 | * Format help 2256 | */ 2257 | function formatHelpString(indent: number, limit: number, helpString: string) : string { 2258 | const lines = helpString.split('\n'); 2259 | let buf = ""; 2260 | let pad = ""; 2261 | for (let i = 0; i < indent; i++) { 2262 | pad += ' '; 2263 | } 2264 | 2265 | for (let i = 0; i < lines.length; i++) { 2266 | let linebuf = pad.slice(); 2267 | const words = lines[i].split(/ /).filter((word) => word.length > 0); 2268 | if (words.length == 0) { 2269 | buf += '\n'; 2270 | continue; 2271 | } 2272 | 2273 | if (words[0] === '$' || lines[i].substring(0, 4) === ' ') { 2274 | // literal line 2275 | buf += lines[i] + '\n'; 2276 | continue; 2277 | } 2278 | 2279 | for (let j = 0; j < words.length; j++) { 2280 | if (words[j].length === 0) { 2281 | // explicit line break 2282 | linebuf += '\n'; 2283 | break; 2284 | } 2285 | 2286 | if (linebuf.split('\n').slice(-1)[0].length + 1 + words[j].length > limit) { 2287 | linebuf += '\n'; 2288 | linebuf += pad; 2289 | } 2290 | linebuf += words[j] + ' '; 2291 | } 2292 | 2293 | buf += linebuf + '\n'; 2294 | } 2295 | return buf; 2296 | } 2297 | 2298 | /* 2299 | * Format command usage lines. 2300 | * Generate two strings: 2301 | * raw string: 2302 | * COMMAND ARG_NAME ARG_NAME ARG_NAME [OPTINONAL ARG NAME] 2303 | * keyword string: 2304 | * COMMAND --arg_name TYPE 2305 | * --arg_name TYPE 2306 | * [--arg_name TYPE] 2307 | */ 2308 | function formatCommandHelpLines(commandName: string, commandArgs: Array) : Object { 2309 | let rawUsage = ''; 2310 | let kwUsage = ''; 2311 | let kwPad = ''; 2312 | const commandInfo = CLI_ARGS.properties[commandName]; 2313 | 2314 | rawUsage = ` ${commandName} `; 2315 | for (let i = 0; i < commandArgs.length; i++) { 2316 | if (!commandArgs[i].name) { 2317 | console.log(commandName); 2318 | console.log(commandArgs[i]); 2319 | throw new Error(`BUG: command info is missing a "name" field`); 2320 | } 2321 | if (i + 1 <= commandInfo.minItems) { 2322 | rawUsage += `${commandArgs[i].name.toUpperCase()} `; 2323 | } 2324 | else { 2325 | rawUsage += `[${commandArgs[i].name.toUpperCase()}] `; 2326 | } 2327 | } 2328 | 2329 | kwUsage = ` ${commandName} `; 2330 | for (let i = 0; i < commandName.length + 3; i++) { 2331 | kwPad += ' '; 2332 | } 2333 | 2334 | for (let i = 0; i < commandArgs.length; i++) { 2335 | if (!commandArgs[i].realtype) { 2336 | console.log(commandName) 2337 | console.log(commandArgs[i]) 2338 | throw new Error(`BUG: command info is missing a "realtype" field`); 2339 | } 2340 | if (i + 1 <= commandInfo.minItems) { 2341 | kwUsage += `--${commandArgs[i].name} ${commandArgs[i].realtype.toUpperCase()}`; 2342 | } 2343 | else { 2344 | kwUsage += `[--${commandArgs[i].name} ${commandArgs[i].realtype.toUpperCase()}]`; 2345 | } 2346 | kwUsage += '\n'; 2347 | kwUsage += kwPad; 2348 | } 2349 | 2350 | return {'raw': rawUsage, 'kw': kwUsage}; 2351 | } 2352 | 2353 | /* 2354 | * Get the set of commands grouped by command group 2355 | */ 2356 | function getCommandGroups() : Object { 2357 | let groups = {}; 2358 | const commands = Object.keys(CLI_ARGS.properties); 2359 | for (let i = 0; i < commands.length; i++) { 2360 | const command = commands[i]; 2361 | const group = CLI_ARGS.properties[command].group; 2362 | const help = CLI_ARGS.properties[command].help; 2363 | 2364 | if (!groups.hasOwnProperty(group)) { 2365 | groups[group] = [Object.assign({}, CLI_ARGS.properties[command], { 2366 | 'command': command 2367 | })]; 2368 | } 2369 | else { 2370 | groups[group].push(Object.assign({}, CLI_ARGS.properties[command], { 2371 | 'command': command 2372 | })); 2373 | } 2374 | } 2375 | return groups; 2376 | } 2377 | 2378 | /* 2379 | * Make all commands list 2380 | */ 2381 | export function makeAllCommandsList() : string { 2382 | const groups = getCommandGroups(); 2383 | const groupNames = Object.keys(groups).sort(); 2384 | 2385 | let res = `All commands (run '${process.argv[1]} help COMMAND' for details):\n`; 2386 | for (let i = 0; i < groupNames.length; i++) { 2387 | res += ` ${groupNames[i]}: `; 2388 | let cmds = []; 2389 | for (let j = 0; j < groups[groupNames[i]].length; j++) { 2390 | cmds.push(groups[groupNames[i]][j].command); 2391 | } 2392 | 2393 | // wrap at 80 characters 2394 | const helpLineSpaces = formatHelpString(4, 70, cmds.join(' ')); 2395 | const helpLineCSV = ' ' + helpLineSpaces.split('\n ') 2396 | .map((line) => line.trim().replace(/ /g, ', ')).join('\n ') + '\n'; 2397 | 2398 | res += '\n' + helpLineCSV; 2399 | res += '\n'; 2400 | } 2401 | return res.trim(); 2402 | } 2403 | 2404 | /* 2405 | * Make help for all commands 2406 | */ 2407 | export function makeAllCommandsHelp(): string { 2408 | const groups = getCommandGroups(); 2409 | const groupNames = Object.keys(groups).sort(); 2410 | 2411 | let helps = []; 2412 | let cmds = []; 2413 | for (let i = 0; i < groupNames.length; i++) { 2414 | for (let j = 0; j < groups[groupNames[i]].length; j++) { 2415 | cmds.push(groups[groupNames[i]][j].command); 2416 | } 2417 | } 2418 | 2419 | cmds = cmds.sort(); 2420 | for (let i = 0; i < cmds.length; i++) { 2421 | helps.push(makeCommandUsageString(cmds[i]).trim()); 2422 | } 2423 | 2424 | return helps.join('\n\n'); 2425 | } 2426 | 2427 | /* 2428 | * Make a usage string for a single command 2429 | */ 2430 | export function makeCommandUsageString(command: string) : string { 2431 | let res = ""; 2432 | if (command === 'all') { 2433 | return makeAllCommandsHelp(); 2434 | } 2435 | 2436 | const commandInfo = CLI_ARGS.properties[command]; 2437 | if (!commandInfo || command === 'help') { 2438 | return makeAllCommandsList(); 2439 | } 2440 | 2441 | const groups = getCommandGroups(); 2442 | const groupNames = Object.keys(groups).sort(); 2443 | const help = commandInfo.help; 2444 | 2445 | const cmdFormat = formatCommandHelpLines(command, commandInfo.items); 2446 | const formattedHelp = formatHelpString(2, 78, help); 2447 | 2448 | // make help string for one command 2449 | res += `Command: ${command}\n`; 2450 | res += `Usage:\n`; 2451 | res += `${cmdFormat.raw}\n`; 2452 | res += `${cmdFormat.kw}\n`; 2453 | res += formattedHelp; 2454 | return res.trim() + '\n'; 2455 | } 2456 | 2457 | /* 2458 | * Make the usage documentation 2459 | */ 2460 | export function makeUsageString(usageString: string) : string { 2461 | let res = `${USAGE}\n\nCommand reference\n`; 2462 | const groups = getCommandGroups(); 2463 | const groupNames = Object.keys(groups).sort(); 2464 | 2465 | for (let i = 0; i < groupNames.length; i++) { 2466 | const groupName = groupNames[i]; 2467 | const groupCommands = groups[groupName]; 2468 | 2469 | res += `Command group: ${groupName}\n\n`; 2470 | for (let j = 0; j < groupCommands.length; j++) { 2471 | const command = groupCommands[j].command; 2472 | const help = groupCommands[j].help; 2473 | 2474 | const commandInfo = CLI_ARGS.properties[command]; 2475 | 2476 | const cmdFormat = formatCommandHelpLines(command, commandInfo.items); 2477 | const formattedHelp = formatHelpString(4, 76, help); 2478 | 2479 | res += cmdFormat.raw; 2480 | res += '\n'; 2481 | res += cmdFormat.kw; 2482 | res += '\n'; 2483 | res += formattedHelp; 2484 | res += '\n'; 2485 | } 2486 | res += '\n'; 2487 | } 2488 | 2489 | return res; 2490 | } 2491 | 2492 | /* 2493 | * Print usage 2494 | */ 2495 | export function printUsage() { 2496 | console.error(makeUsageString(USAGE)); 2497 | } 2498 | 2499 | /* 2500 | * Implement just enough getopt(3) to be useful. 2501 | * Only handles short options. 2502 | * Returns an object whose keys are option flags that map to true/false, 2503 | * or to a value. 2504 | * The key _ is mapped to the non-opts list. 2505 | */ 2506 | export function getCLIOpts(argv: Array, 2507 | opts: string = 'deitUxC:F:B:P:D:G:N:H:T:I:') : Object { 2508 | let optsTable = {}; 2509 | let remainingArgv = []; 2510 | let argvBuff = argv.slice(0); 2511 | 2512 | for (let i = 0; i < opts.length; i++) { 2513 | if (opts[i] == ':') { 2514 | continue; 2515 | } 2516 | if (i+1 < opts.length && opts[i+1] == ':') { 2517 | optsTable[opts[i]] = null; 2518 | } 2519 | else { 2520 | optsTable[opts[i]] = false; 2521 | } 2522 | } 2523 | 2524 | for (let opt of Object.keys(optsTable)) { 2525 | for (let i = 0; i < argvBuff.length; i++) { 2526 | if (argvBuff[i] === null) { 2527 | break; 2528 | } 2529 | if (argvBuff[i] === '--') { 2530 | break; 2531 | } 2532 | 2533 | const argvOpt = `-${opt}`; 2534 | if (argvOpt === argvBuff[i]) { 2535 | if (optsTable[opt] === false) { 2536 | // boolean switch 2537 | optsTable[opt] = true; 2538 | argvBuff[i] = ''; 2539 | } 2540 | else { 2541 | // argument 2542 | optsTable[opt] = argvBuff[i+1]; 2543 | argvBuff[i] = ''; 2544 | argvBuff[i+1] = ''; 2545 | } 2546 | } 2547 | } 2548 | } 2549 | 2550 | for (let i = 0; i < argvBuff.length; i++) { 2551 | if (argvBuff[i].length > 0) { 2552 | if (argvBuff[i] === '--') { 2553 | continue; 2554 | } 2555 | remainingArgv.push(argvBuff[i]) 2556 | } 2557 | } 2558 | 2559 | optsTable['_'] = remainingArgv; 2560 | return optsTable; 2561 | } 2562 | 2563 | 2564 | /* 2565 | * Use the CLI schema to get all positional and keyword args 2566 | * for a given command. 2567 | */ 2568 | export function getCommandArgs(command: string, argsList: Array) { 2569 | let commandProps = CLI_ARGS.properties[command].items; 2570 | if (!Array.isArray(commandProps)) { 2571 | commandProps = [commandProps]; 2572 | } 2573 | 2574 | let orderedArgs = []; 2575 | let foundArgs = {}; 2576 | 2577 | // scan for keywords 2578 | for (let i = 0; i < argsList.length; i++) { 2579 | if (argsList[i].startsWith('--')) { 2580 | // positional argument 2581 | const argName = argsList[i].slice(2); 2582 | let argValue = null; 2583 | 2584 | // dup? 2585 | if (foundArgs.hasOwnProperty(argName)) { 2586 | return { 2587 | 'status': false, 2588 | 'error': `duplicate argument ${argsList[i]}`, 2589 | }; 2590 | } 2591 | 2592 | for (let j = 0; j < commandProps.length; j++) { 2593 | if (!commandProps[j].hasOwnProperty('name')) { 2594 | continue; 2595 | } 2596 | if (commandProps[j].name === argName) { 2597 | // found! 2598 | // end of args? 2599 | if (i + 1 >= argsList.length) { 2600 | return { 2601 | 'status': false, 2602 | 'error': `no value for argument ${argsList[i]}` 2603 | }; 2604 | } 2605 | 2606 | argValue = argsList[i+1]; 2607 | } 2608 | } 2609 | 2610 | if (argValue) { 2611 | // found something! 2612 | i += 1; 2613 | foundArgs[argName] = argValue; 2614 | } 2615 | else { 2616 | return { 2617 | 'status': false, 2618 | 'error': `no such argument ${argsList[i]}`, 2619 | }; 2620 | } 2621 | } 2622 | else { 2623 | // positional argument 2624 | orderedArgs.push(argsList[i]); 2625 | } 2626 | } 2627 | 2628 | // merge foundArgs and orderedArgs back into an ordered argument list 2629 | // that is conformant to the CLI specification. 2630 | let mergedArgs = []; 2631 | let orderedArgIndex = 0; 2632 | 2633 | for (let i = 0; i < commandProps.length; i++) { 2634 | if (!commandProps[i].hasOwnProperty('name')) { 2635 | // positional argument 2636 | if (orderedArgIndex >= orderedArgs.length) { 2637 | break; 2638 | } 2639 | mergedArgs.push(orderedArgs[orderedArgIndex]); 2640 | orderedArgIndex += 1; 2641 | } 2642 | else if (!foundArgs.hasOwnProperty(commandProps[i].name)) { 2643 | // positional argument 2644 | if (orderedArgIndex >= orderedArgs.length) { 2645 | break; 2646 | } 2647 | mergedArgs.push(orderedArgs[orderedArgIndex]); 2648 | orderedArgIndex += 1; 2649 | } 2650 | else { 2651 | // keyword argument 2652 | mergedArgs.push(foundArgs[commandProps[i].name]); 2653 | } 2654 | } 2655 | 2656 | return { 2657 | 'status': true, 2658 | 'arguments': mergedArgs 2659 | }; 2660 | } 2661 | 2662 | /* 2663 | * Check command args 2664 | */ 2665 | type checkArgsSuccessType = { 2666 | 'success': true, 2667 | 'command': string, 2668 | 'args': Array 2669 | }; 2670 | 2671 | type checkArgsFailType = { 2672 | 'success': false, 2673 | 'error': string, 2674 | 'command': string, 2675 | 'usage': boolean 2676 | }; 2677 | 2678 | export function checkArgs(argList: Array) 2679 | : checkArgsSuccessType | checkArgsFailType { 2680 | if (argList.length <= 2) { 2681 | return { 2682 | 'success': false, 2683 | 'error': 'No command given', 2684 | 'usage': true, 2685 | 'command': '', 2686 | } 2687 | } 2688 | 2689 | const commandName = argList[2]; 2690 | const allCommandArgs = argList.slice(3); 2691 | 2692 | if (!CLI_ARGS.properties.hasOwnProperty(commandName)) { 2693 | return { 2694 | 'success': false, 2695 | 'error': `Unrecognized command '${commandName}'`, 2696 | 'usage': true, 2697 | 'command': commandName, 2698 | }; 2699 | } 2700 | 2701 | const parsedCommandArgs = getCommandArgs(commandName, allCommandArgs); 2702 | if (!parsedCommandArgs.status) { 2703 | return { 2704 | 'success': false, 2705 | 'error': parsedCommandArgs.error, 2706 | 'usage': true, 2707 | 'command': commandName, 2708 | }; 2709 | } 2710 | 2711 | const commandArgs = parsedCommandArgs.arguments; 2712 | 2713 | const commands = new Object(); 2714 | commands[commandName] = commandArgs; 2715 | 2716 | const ajv = Ajv(); 2717 | const valid = ajv.validate(CLI_ARGS, commands); 2718 | if (!valid) { 2719 | // console.error(ajv.errors); 2720 | return { 2721 | 'success': false, 2722 | 'error': 'Invalid command arguments', 2723 | 'usage': true, 2724 | 'command': commandName, 2725 | }; 2726 | } 2727 | 2728 | return { 2729 | 'success': true, 2730 | 'command': commandName, 2731 | 'args': commandArgs 2732 | }; 2733 | } 2734 | 2735 | /** 2736 | * Load the config file and return a config dict. 2737 | * If no config file exists, then return the default config. 2738 | * 2739 | * @configPath (string) the path to the config file. 2740 | * @networkType (sring) 'mainnet', 'regtest', or 'testnet' 2741 | */ 2742 | export function loadConfig(configFile: string, networkType: string) : Object { 2743 | if (networkType !== 'mainnet' && networkType !== 'testnet' && networkType != 'regtest') { 2744 | throw new Error("Unregognized network") 2745 | } 2746 | 2747 | let configData = null; 2748 | let configRet = null; 2749 | 2750 | if (networkType === 'mainnet') { 2751 | configRet = Object.assign({}, CONFIG_DEFAULTS); 2752 | } else if (networkType === 'regtest') { 2753 | configRet = Object.assign({}, CONFIG_REGTEST_DEFAULTS); 2754 | } else { 2755 | configRet = Object.assign({}, CONFIG_TESTNET_DEFAULTS); 2756 | } 2757 | 2758 | try { 2759 | configData = JSON.parse(fs.readFileSync(configFile).toString()); 2760 | Object.assign(configRet, configData); 2761 | } 2762 | catch (e) { 2763 | ; 2764 | } 2765 | 2766 | return configRet; 2767 | } 2768 | 2769 | -------------------------------------------------------------------------------- /src/auth.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | const blockstack = require('blockstack'); 4 | const jsontokens = require('jsontokens') 5 | const express = require('express') 6 | const crypto = require('crypto') 7 | 8 | import winston from 'winston' 9 | import logger from 'winston' 10 | 11 | import { 12 | gaiaConnect, 13 | gaiaUploadProfileAll, 14 | makeAssociationToken 15 | } from './data'; 16 | 17 | import { 18 | getApplicationKeyInfo, 19 | getOwnerKeyInfo, 20 | extractAppKey 21 | } from './keys'; 22 | 23 | import { 24 | nameLookup, 25 | makeProfileJWT, 26 | getPublicKeyFromPrivateKey, 27 | canonicalPrivateKey 28 | } from './utils'; 29 | 30 | import { 31 | type GaiaHubConfig 32 | } from 'blockstack'; 33 | 34 | export const SIGNIN_HEADER = '

Blockstack CLI Sign-in


' 35 | export const SIGNIN_FMT_NAME = '

{blockstackID} ({idAddress})

' 36 | export const SIGNIN_FMT_ID = '

{idAddress} (anonymous)

' 37 | export const SIGNIN_FOOTER = '' 38 | 39 | export type NamedIdentityType = { 40 | name: string, 41 | idAddress: string, 42 | privateKey: string, 43 | index: number, 44 | profileUrl: string 45 | }; 46 | 47 | // new ecdsa private key each time 48 | const authTransitKey = crypto.randomBytes(32).toString('hex'); 49 | const authTransitPubkey = getPublicKeyFromPrivateKey(authTransitKey); 50 | 51 | /* 52 | * Get the app private key 53 | */ 54 | function getAppPrivateKey(network: Object, 55 | mnemonic: string, 56 | id: NamedIdentityType, 57 | appOrigin: string 58 | ): string { 59 | const appKeyInfo = getApplicationKeyInfo(network, mnemonic, id.idAddress, appOrigin, id.index); 60 | const appPrivateKey = extractAppKey(appKeyInfo); 61 | return appPrivateKey; 62 | } 63 | 64 | /* 65 | * Make a sign-in link 66 | */ 67 | function makeSignInLink(network: Object, 68 | authPort: number, 69 | mnemonic: string, 70 | authRequest: Object, 71 | hubUrl: string, 72 | id: NamedIdentityType) : string { 73 | 74 | const appOrigin = authRequest.domain_name; 75 | const appKeyInfo = getApplicationKeyInfo(network, mnemonic, id.idAddress, appOrigin, id.index); 76 | const appPrivateKey = getAppPrivateKey(network, mnemonic, id, appOrigin); 77 | 78 | const associationToken = makeAssociationToken(appPrivateKey, id.privateKey) 79 | const authResponseTmp = blockstack.makeAuthResponse( 80 | id.privateKey, 81 | {}, 82 | id.name, 83 | { email: null, profileUrl: id.profileUrl }, 84 | null, 85 | appPrivateKey, 86 | undefined, 87 | authRequest.public_keys[0], 88 | hubUrl, 89 | blockstack.config.network.blockstackAPIUrl, 90 | associationToken 91 | ); 92 | 93 | // pass along some helpful data from the authRequest 94 | const authResponsePayload = jsontokens.decodeToken(authResponseTmp).payload; 95 | authResponsePayload.metadata = { 96 | id: id, 97 | profileUrl: id.profileUrl, 98 | appOrigin: appOrigin, 99 | redirect_uri: authRequest.redirect_uri, 100 | scopes: authRequest.scopes, 101 | salt: crypto.randomBytes(16).toString('hex'), 102 | // fill in more CLI-specific fields here 103 | }; 104 | 105 | const tokenSigner = new jsontokens.TokenSigner('ES256k', id.privateKey); 106 | const authResponse = tokenSigner.sign(authResponsePayload) 107 | 108 | // encrypt the auth response in-flight, so a rogue program can't just curl it out 109 | const encryptedAuthResponseJSON = blockstack.encryptContent( 110 | authResponse, { publicKey: authTransitPubkey }) 111 | const encryptedAuthResponse = { json: encryptedAuthResponseJSON } 112 | 113 | const encTokenSigner = new jsontokens.TokenSigner('ES256k', authTransitKey) 114 | const encAuthResponse = encTokenSigner.sign(encryptedAuthResponse) 115 | 116 | return blockstack.updateQueryStringParameter( 117 | `http://localhost:${authPort}/signin`, 'encAuthResponse', encAuthResponse); 118 | } 119 | 120 | /* 121 | * Make the sign-in page 122 | */ 123 | function makeAuthPage(network: Object, 124 | authPort: number, 125 | mnemonic: string, 126 | hubUrl: string, 127 | manifest: Object, 128 | authRequest: Object, 129 | ids: Array) : string { 130 | 131 | let signinBody = SIGNIN_HEADER; 132 | 133 | for (let i = 0; i < ids.length; i++) { 134 | let signinEntry 135 | if (ids[i].name) { 136 | signinEntry = SIGNIN_FMT_NAME 137 | .replace(/{authRedirect}/, makeSignInLink( 138 | network, 139 | authPort, 140 | mnemonic, 141 | authRequest, 142 | hubUrl, 143 | ids[i])) 144 | .replace(/{blockstackID}/, ids[i].name) 145 | .replace(/{idAddress}/, ids[i].idAddress); 146 | } 147 | else { 148 | signinEntry = SIGNIN_FMT_ID 149 | .replace(/{authRedirect}/, makeSignInLink( 150 | network, 151 | authPort, 152 | mnemonic, 153 | authRequest, 154 | hubUrl, 155 | ids[i])) 156 | .replace(/{idAddress}/, ids[i].idAddress); 157 | } 158 | 159 | signinBody = `${signinBody}${signinEntry}`; 160 | } 161 | 162 | signinBody = `${signinBody}${SIGNIN_FOOTER}`; 163 | return signinBody; 164 | } 165 | 166 | 167 | /* 168 | * Find all identity addresses that have names attached to them. 169 | * Fills in identities. 170 | */ 171 | function loadNamedIdentitiesLoop(network: Object, 172 | mnemonic: string, 173 | index: number, 174 | identities: Array) { 175 | const ret = []; 176 | 177 | // 65536 is a ridiculously huge number 178 | if (index > 65536) { 179 | throw new Error('Too many names') 180 | } 181 | 182 | const keyInfo = getOwnerKeyInfo(network, mnemonic, index); 183 | return network.getNamesOwned(keyInfo.idAddress.slice(3)) 184 | .then((nameList) => { 185 | if (nameList.length === 0) { 186 | // out of names 187 | return identities; 188 | } 189 | for (let i = 0; i < nameList.length; i++) { 190 | identities.push({ 191 | name: nameList[i], 192 | idAddress: keyInfo.idAddress, 193 | privateKey: keyInfo.privateKey, 194 | index: index, 195 | profileUrl: '' 196 | }); 197 | } 198 | return loadNamedIdentitiesLoop(network, mnemonic, index + 1, identities); 199 | }); 200 | } 201 | 202 | /* 203 | * Load all named identities for a mnemonic. 204 | * Keep loading until we find an ID-address that does not have a name. 205 | */ 206 | export function loadNamedIdentities(network: Object, mnemonic: string) 207 | : Promise> { 208 | return loadNamedIdentitiesLoop(network, mnemonic, 0, []); 209 | } 210 | 211 | 212 | /* 213 | * Generate identity info for an unnamed ID 214 | */ 215 | function loadUnnamedIdentity(network: Object, mnemonic: string, index: number): NamedIdentityType { 216 | const keyInfo = getOwnerKeyInfo(network, mnemonic, index); 217 | const idInfo = { 218 | name: '', 219 | idAddress: keyInfo.idAddress, 220 | privateKey: keyInfo.privateKey, 221 | index: index, 222 | profileUrl: '' 223 | }; 224 | return idInfo; 225 | } 226 | 227 | /* 228 | * Send a JSON HTTP response 229 | */ 230 | function sendJSON(res: express.response, data: Object, statusCode: number) { 231 | res.writeHead(statusCode, {'Content-Type' : 'application/json'}) 232 | res.write(JSON.stringify(data)) 233 | res.end() 234 | } 235 | 236 | 237 | /* 238 | * Get all of a 12-word phrase's identities, profiles, and Gaia connections. 239 | * Returns a Promise to an Array of NamedIdentityType instances. 240 | * 241 | * NOTE: should be the *only* promise chain running! 242 | */ 243 | function getIdentityInfo(network: Object, mnemonic: string, appGaiaHub: string, profileGaiaHub: string) 244 | : Promise> { 245 | 246 | let identities = []; 247 | network.setCoerceMainnetAddress(true); // for lookups in regtest 248 | 249 | // load up all of our identity addresses and profile URLs 250 | const identitiesPromise = loadNamedIdentities(network, mnemonic) 251 | .then((ids) => { 252 | const nameInfoPromises = []; 253 | for (let i = 0; i < ids.length; i++) { 254 | const nameInfoPromise = nameLookup(network, ids[i].name, false) 255 | .catch(() => null); 256 | 257 | nameInfoPromises.push(nameInfoPromise); 258 | } 259 | 260 | identities = ids; 261 | return Promise.all(nameInfoPromises); 262 | }) 263 | .then((nameDatas) => { 264 | network.setCoerceMainnetAddress(false); 265 | nameDatas = nameDatas.filter((p) => p !== null && p !== undefined); 266 | 267 | for (let i = 0; i < nameDatas.length; i++) { 268 | if (nameDatas[i].hasOwnProperty('error') && nameDatas[i].error) { 269 | // no data for this name 270 | identities[i].profileUrl = ''; 271 | } 272 | else { 273 | identities[i].profileUrl = nameDatas[i].profileUrl; 274 | } 275 | } 276 | 277 | const nextIndex = identities.length + 1 278 | 279 | // ignore identities with no data 280 | identities = identities.filter((id) => !!id.profileUrl); 281 | 282 | // add in the next non-named identity 283 | identities.push(loadUnnamedIdentity(network, mnemonic, nextIndex)) 284 | return identities; 285 | }) 286 | .catch((e) => { 287 | network.setCoerceMainnetAddress(false); 288 | throw e; 289 | }); 290 | 291 | return identitiesPromise; 292 | } 293 | 294 | 295 | /* 296 | * Handle GET /auth?authRequest=... 297 | * If the authRequest is verifiable and well-formed, and if we can fetch the application 298 | * manifest, then we can render an auth page to the user. 299 | * Serves back the sign-in page on success. 300 | * Serves back an error page on error. 301 | * Returns a Promise that resolves to nothing. 302 | * 303 | * NOTE: should be the *only* promise chain running! 304 | */ 305 | export function handleAuth(network: Object, 306 | mnemonic: string, 307 | gaiaHubUrl: string, 308 | profileGaiaHub: string, 309 | port: number, 310 | req: express.request, 311 | res: express.response 312 | ) : Promise<*> { 313 | 314 | const authToken = req.query.authRequest; 315 | if (!authToken) { 316 | return Promise.resolve().then(() => { 317 | sendJSON(res, { error: 'No authRequest given' }, 400); 318 | }); 319 | } 320 | 321 | let errorMsg; 322 | let identities; 323 | return getIdentityInfo(network, mnemonic, gaiaHubUrl, profileGaiaHub) 324 | .then((ids) => { 325 | identities = ids; 326 | errorMsg = 'Unable to verify authentication token'; 327 | return blockstack.verifyAuthRequest(authToken); 328 | }) 329 | .then((valid) => { 330 | if (!valid) { 331 | errorMsg = 'Invalid authentication token: could not verify'; 332 | throw new Error(errorMsg); 333 | } 334 | errorMsg = 'Unable to fetch app manifest'; 335 | return blockstack.fetchAppManifest(authToken); 336 | }) 337 | .then((appManifest) => { 338 | const decodedAuthToken = jsontokens.decodeToken(authToken); 339 | const decodedAuthPayload = decodedAuthToken.payload; 340 | if (!decodedAuthPayload) { 341 | errorMsg = 'Invalid authentication token: no payload'; 342 | throw new Error(errorMsg); 343 | } 344 | 345 | // make sign-in page 346 | const authPage = makeAuthPage( 347 | network, port, mnemonic, gaiaHubUrl, appManifest, decodedAuthPayload, identities); 348 | 349 | res.writeHead(200, {'Content-Type': 'text/html', 'Content-Length': authPage.length}); 350 | res.write(authPage); 351 | res.end(); 352 | return; 353 | }) 354 | .catch((e) => { 355 | if (!errorMsg) { 356 | errorMsg = e.message; 357 | } 358 | 359 | logger.error(e) 360 | logger.error(errorMsg) 361 | sendJSON(res, { error: `Unable to authenticate app request: ${errorMsg}` }, 400); 362 | return; 363 | }); 364 | } 365 | 366 | /* 367 | * Update a named identity's profile with new app data, if necessary. 368 | * Indicates whether or not the profile was changed. 369 | */ 370 | function updateProfileApps(network: Object, 371 | id: NamedIdentityType, 372 | appOrigin: string, 373 | appGaiaConfig: GaiaHubConfig 374 | ): Promise<{ profile: Object, changed: boolean }> { 375 | 376 | let profile; 377 | let needProfileUpdate = false; 378 | 379 | // go get the profile from the profile URL in the id 380 | const profilePromise = nameLookup(network, id.name) 381 | .catch((e) => null) 382 | 383 | return profilePromise.then((profileData) => { 384 | if (profileData) { 385 | profile = profileData.profile; 386 | } 387 | 388 | if (!profile) { 389 | // instantiate 390 | logger.debug(`Instantiating profile for ${id.name}`); 391 | needProfileUpdate = true; 392 | profile = { 393 | 'type': '@Person', 394 | 'account': [], 395 | 'apps': {}, 396 | }; 397 | } 398 | 399 | // do we need to update the Gaia hub read URL in the profile? 400 | if (profile.apps === null || profile.apps === undefined) { 401 | needProfileUpdate = true; 402 | 403 | logger.debug(`Adding multi-reader Gaia links to profile for ${id.name}`); 404 | profile.apps = {}; 405 | } 406 | 407 | const gaiaPrefix = `${appGaiaConfig.url_prefix}${appGaiaConfig.address}/`; 408 | 409 | if (!profile.apps.hasOwnProperty(appOrigin) || !profile.apps[appOrigin]) { 410 | needProfileUpdate = true; 411 | logger.debug(`Setting Gaia read URL ${gaiaPrefix} for ${appOrigin} ` + 412 | `in profile for ${id.name}`); 413 | 414 | profile.apps[appOrigin] = gaiaPrefix; 415 | } 416 | else if (!profile.apps[appOrigin].startsWith(gaiaPrefix)) { 417 | needProfileUpdate = true; 418 | logger.debug(`Overriding Gaia read URL for ${appOrigin} from ${profile.apps[appOrigin]} ` + 419 | `to ${gaiaPrefix} in profile for ${id.name}`); 420 | 421 | profile.apps[appOrigin] = gaiaPrefix; 422 | } 423 | 424 | return { profile, changed: needProfileUpdate }; 425 | }) 426 | } 427 | 428 | 429 | /* 430 | * Handle GET /signin?encAuthResponse=... 431 | * Takes an encrypted authResponse from the page generated on GET /auth?authRequest=...., 432 | * verifies it, updates the name's profile's app's entry with the latest Gaia 433 | * hub information (if necessary), and redirects the user back to the application. 434 | * 435 | * If adminKey is given, then the new app private key will be automatically added 436 | * as an authorized writer to the Gaia hub. 437 | * 438 | * Redirects the user on success. 439 | * Sends the user an error page on failure. 440 | * Returns a Promise that resolves to nothing. 441 | */ 442 | export function handleSignIn(network: Object, 443 | mnemonic: string, 444 | appGaiaHub: string, 445 | profileGaiaHub: string, 446 | req: express.request, 447 | res: express.response 448 | ): Promise<*> { 449 | 450 | const encAuthResponse = req.query.encAuthResponse; 451 | if (!encAuthResponse) { 452 | return Promise.resolve().then(() => { 453 | sendJSON(res, { error: 'No encAuthResponse given' }, 400); 454 | }); 455 | } 456 | const nameLookupUrl = `${network.blockstackAPIUrl}/v1/names/`; 457 | 458 | let errorMsg; 459 | let errorStatusCode = 400; 460 | let authResponsePayload; 461 | 462 | let id; 463 | let profileUrl; 464 | let appOrigin; 465 | let redirectUri; 466 | let scopes; 467 | let authResponse; 468 | 469 | return Promise.resolve().then(() => { 470 | // verify and decrypt 471 | const valid = new jsontokens.TokenVerifier('ES256K', authTransitPubkey) 472 | .verify(encAuthResponse) 473 | 474 | if (!valid) { 475 | throw new Error('Invalid encrypted auth response: not signed by this authenticator') 476 | } 477 | 478 | const encAuthResponseToken = jsontokens.decodeToken(encAuthResponse) 479 | const encAuthResponsePayload = encAuthResponseToken.payload; 480 | 481 | authResponse = blockstack.decryptContent( 482 | encAuthResponsePayload.json, { privateKey: authTransitKey }); 483 | 484 | return blockstack.verifyAuthResponse(authResponse, nameLookupUrl); 485 | }) 486 | .then((valid) => { 487 | if (!valid) { 488 | errorMsg = `Unable to verify authResponse token ${authResponse}`; 489 | throw new Error(errorMsg); 490 | } 491 | 492 | const authResponseToken = jsontokens.decodeToken(authResponse); 493 | authResponsePayload = authResponseToken.payload; 494 | 495 | id = authResponsePayload.metadata.id; 496 | profileUrl = authResponsePayload.metadata.profileUrl; 497 | appOrigin = authResponsePayload.metadata.appOrigin; 498 | redirectUri = authResponsePayload.metadata.redirect_uri; 499 | scopes = authResponsePayload.metadata.scopes; 500 | 501 | const appPrivateKey = getAppPrivateKey(network, mnemonic, id, appOrigin); 502 | 503 | // remove sensitive (CLI-specific) information 504 | authResponsePayload.metadata = { 505 | profileUrl: profileUrl 506 | }; 507 | 508 | authResponse = new jsontokens.TokenSigner('ES256K', id.privateKey).sign(authResponsePayload); 509 | 510 | logger.debug(`App ${appOrigin} requests scopes ${JSON.stringify(scopes)}`); 511 | 512 | // connect to the app gaia hub 513 | return gaiaConnect(network, appGaiaHub, appPrivateKey); 514 | }) 515 | .then((appHubConfig) => { 516 | return updateProfileApps(network, id, appOrigin, appHubConfig); 517 | }) 518 | .then((newProfileData) => { 519 | const profile = newProfileData.profile; 520 | const needProfileUpdate = newProfileData.changed && scopes.includes('store_write'); 521 | 522 | // sign and replicate new profile if we need to. 523 | // otherwise do nothing 524 | if (needProfileUpdate) { 525 | logger.debug(`Upload new profile to ${profileGaiaHub}`); 526 | const profileJWT = makeProfileJWT(profile, id.privateKey); 527 | return gaiaUploadProfileAll( 528 | network, [profileGaiaHub], profileJWT, id.privateKey, id.name); 529 | } 530 | else { 531 | logger.debug(`Gaia read URL for ${appOrigin} is ${profile.apps[appOrigin]}`); 532 | return { dataUrls: [], error: null }; 533 | } 534 | }) 535 | .then((gaiaUrls) => { 536 | if (gaiaUrls.hasOwnProperty('error') && gaiaUrls.error) { 537 | errorMsg = `Failed to upload new profile: ${gaiaUrls.error}`; 538 | errorStatusCode = 502; 539 | throw new Error(errorMsg); 540 | } 541 | 542 | // success! 543 | // redirect to application 544 | const appUri = blockstack.updateQueryStringParameter(redirectUri, 'authResponse', authResponse); 545 | res.writeHead(302, {'Location': appUri}); 546 | res.end(); 547 | return; 548 | }) 549 | .catch((e) => { 550 | logger.error(e); 551 | logger.error(errorMsg); 552 | sendJSON(res, { error: `Unable to process signin request: ${errorMsg}` }, errorStatusCode); 553 | return; 554 | }); 555 | } 556 | -------------------------------------------------------------------------------- /src/data.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | const blockstack = require('blockstack'); 4 | const jsontokens = require('jsontokens'); 5 | const bitcoin = require('bitcoinjs-lib'); 6 | const URL = require('url'); 7 | const crypto = require('crypto'); 8 | 9 | import { 10 | canonicalPrivateKey, 11 | getPrivateKeyAddress, 12 | checkUrl, 13 | SafetyError, 14 | getPublicKeyFromPrivateKey 15 | } from './utils'; 16 | 17 | import { 18 | parseZoneFile 19 | } from 'zone-file'; 20 | 21 | /* 22 | * Set up a session for Gaia. 23 | * Generate an authentication response like what the browser would do, 24 | * and store the relevant data to our emulated localStorage. 25 | */ 26 | function makeFakeGaiaSessionToken(appPrivateKey: string | null, 27 | hubURL: string | null, 28 | associationToken?: string) { 29 | const ownerPrivateKey = '24004db06ef6d26cdd2b0fa30b332a1b10fa0ba2b07e63505ffc2a9ed7df22b4'; 30 | const transitPrivateKey = 'f33fb466154023aba2003c17158985aa6603db68db0f1afc0fcf1d641ea6c2cb'; 31 | const transitPublicKey = '0496345da77fb5e06757b9c4fd656bf830a3b293f245a6cc2f11f8334ebb690f1' + 32 | '9582124f4b07172eb61187afba4514828f866a8a223e0d5c539b2e38a59ab8bb3'; 33 | 34 | window.localStorage.setItem('blockstack-transit-private-key', transitPrivateKey) 35 | 36 | const authResponse = blockstack.makeAuthResponse( 37 | ownerPrivateKey, 38 | {type: '@Person', accounts: []}, 39 | null, 40 | {}, 41 | null, 42 | appPrivateKey, 43 | undefined, 44 | transitPublicKey, 45 | hubURL, 46 | blockstack.config.network.blockstackAPIUrl, 47 | associationToken 48 | ); 49 | 50 | return authResponse; 51 | } 52 | 53 | /* 54 | * Make an association token for the given address. 55 | * TODO belongs in a "gaia.js" library 56 | */ 57 | export function makeAssociationToken(appPrivateKey: string, identityKey: string) : string { 58 | const appPublicKey = getPublicKeyFromPrivateKey(`${canonicalPrivateKey(appPrivateKey)}01`) 59 | const FOUR_MONTH_SECONDS = 60 * 60 * 24 * 31 * 4 60 | const salt = crypto.randomBytes(16).toString('hex') 61 | const identityPublicKey = getPublicKeyFromPrivateKey(identityKey) 62 | const associationTokenClaim = { 63 | childToAssociate: appPublicKey, 64 | iss: identityPublicKey, 65 | exp: FOUR_MONTH_SECONDS + (new Date()/1000), 66 | salt 67 | } 68 | const associationToken = new jsontokens.TokenSigner('ES256K', identityKey) 69 | .sign(associationTokenClaim) 70 | return associationToken 71 | } 72 | 73 | /* 74 | * Authenticate to Gaia. Used for reading, writing, and listing files. 75 | * Process a (fake) session token and set up a Gaia hub connection. 76 | * Returns a Promise that resolves to the (fake) userData 77 | */ 78 | export function gaiaAuth(appPrivateKey: string | null, 79 | hubUrl: string | null, 80 | ownerPrivateKey?: string) { 81 | // Gaia speaks mainnet only! 82 | if (!blockstack.config.network.isMainnet()) { 83 | throw new Error('Gaia only works with mainnet networks.'); 84 | } 85 | 86 | let associationToken; 87 | if (ownerPrivateKey && appPrivateKey) { 88 | associationToken = makeAssociationToken(appPrivateKey, ownerPrivateKey); 89 | } 90 | 91 | const gaiaSessionToken = makeFakeGaiaSessionToken(appPrivateKey, hubUrl, associationToken); 92 | const nameLookupUrl = `${blockstack.config.network.blockstackAPIUrl}/v1/names/`; 93 | return blockstack.handlePendingSignIn(nameLookupUrl, gaiaSessionToken); 94 | } 95 | 96 | 97 | /* 98 | * Connect to Gaia hub and generate a hub config. 99 | * Used for reading and writing profiles. 100 | * Make sure we use a mainnet address always, even in test mode. 101 | * Returns a Promise that resolves to a GaiaHubConfig 102 | */ 103 | export function gaiaConnect(network: Object, 104 | gaiaHubUrl: string, 105 | privateKey: string, 106 | ownerPrivateKey?: string 107 | ) { 108 | const addressMainnet = network.coerceMainnetAddress( 109 | getPrivateKeyAddress(network, `${canonicalPrivateKey(privateKey)}01`)) 110 | const addressMainnetCanonical = network.coerceMainnetAddress( 111 | getPrivateKeyAddress(network, canonicalPrivateKey(privateKey))) 112 | 113 | let associationToken 114 | if (ownerPrivateKey) { 115 | associationToken = makeAssociationToken(privateKey, ownerPrivateKey) 116 | } 117 | 118 | return blockstack.connectToGaiaHub(gaiaHubUrl, canonicalPrivateKey(privateKey), associationToken) 119 | .then((hubConfig) => { 120 | // ensure that hubConfig always has a mainnet address, even if we're in testnet 121 | if (network.coerceMainnetAddress(hubConfig.address) === addressMainnet) { 122 | hubConfig.address = addressMainnet; 123 | } 124 | else if (network.coerceMainnetAddress(hubConfig.address) === addressMainnetCanonical) { 125 | hubConfig.address = addressMainnetCanonical; 126 | } 127 | else { 128 | throw new Error('Invalid private key: ' + 129 | `${network.coerceMainnetAddress(hubConfig.address)} is neither ` + 130 | `${addressMainnet} or ${addressMainnetCanonical}`); 131 | } 132 | return hubConfig; 133 | }); 134 | } 135 | 136 | /* 137 | * Find the profile.json path for a name 138 | * @network (object) the network to use 139 | * @blockstackID (string) the blockstack ID to query 140 | * 141 | * Returns a Promise that resolves to the filename to use for the profile 142 | * Throws an exception if the profile URL could not be determined 143 | */ 144 | function gaiaFindProfileName(network: Object, 145 | hubConfig: Object, 146 | blockstackID?: string 147 | ): Promise { 148 | if (blockstackID === null || blockstackID === undefined) { 149 | return Promise.resolve().then(() => 'profile.json') 150 | } 151 | else { 152 | return network.getNameInfo(blockstackID) 153 | .then((nameInfo) => { 154 | let profileUrl; 155 | try { 156 | const zonefileJSON = parseZoneFile(nameInfo.zonefile); 157 | if (zonefileJSON.uri && zonefileJSON.hasOwnProperty('$origin')) { 158 | profileUrl = blockstack.getTokenFileUrl(zonefileJSON); 159 | } 160 | } 161 | catch(e) { 162 | throw new Error(`Could not determine profile URL for ${String(blockstackID)}: could not parse zone file`); 163 | } 164 | 165 | if (profileUrl === null || profileUrl === undefined) { 166 | throw new Error(`Could not determine profile URL for ${String(blockstackID)}: no URL in zone file`); 167 | } 168 | 169 | // profile URL path must match Gaia hub's URL prefix and address 170 | // (the host can be different) 171 | const gaiaReadPrefix = `${hubConfig.url_prefix}${hubConfig.address}`; 172 | const gaiaReadUrlPath = String(URL.parse(gaiaReadPrefix).path); 173 | const profileUrlPath = String(URL.parse(profileUrl).path); 174 | 175 | if (!profileUrlPath.startsWith(gaiaReadUrlPath)) { 176 | throw new Error(`Could not determine profile URL for ${String(blockstackID)}: wrong Gaia hub` + 177 | ` (${gaiaReadPrefix} does not correspond to ${profileUrl})`); 178 | } 179 | 180 | const profilePath = profileUrlPath.substring(gaiaReadUrlPath.length + 1); 181 | return profilePath; 182 | }) 183 | } 184 | } 185 | 186 | 187 | /* 188 | * Upload profile data to a Gaia hub. 189 | * 190 | * Legacy compat: 191 | * If a blockstack ID is given, then the zone file will be queried and the profile URL 192 | * inspected to make sure that we handle the special (legacy) case where a profile.json 193 | * file got stored to $GAIA_URL/$ADDRESS/$INDEX/profile.json (where $INDEX is a number). 194 | * In such cases, the profile will be stored to $INDEX/profile.json, instead of just 195 | * profile.json. 196 | * 197 | * @network (object) the network to use 198 | * @gaiaHubUrl (string) the base scheme://host:port URL to the Gaia hub 199 | * @gaiaData (string) the data to upload 200 | * @privateKey (string) the private key to use to sign the challenge 201 | * @blockstackID (string) optional; the blockstack ID for which this profile will be stored. 202 | */ 203 | export function gaiaUploadProfile(network: Object, 204 | gaiaHubURL: string, 205 | gaiaData: string, 206 | privateKey: string, 207 | blockstackID?: string 208 | ) { 209 | let hubConfig; 210 | return gaiaConnect(network, gaiaHubURL, privateKey) 211 | .then((hubconf) => { 212 | // make sure we use the *right* gaia path. 213 | // if the blockstackID is given, then we should inspect the zone file to 214 | // determine if the Gaia profile URL contains an index. If it does, then 215 | // we need to preserve it! 216 | hubConfig = hubconf; 217 | return gaiaFindProfileName(network, hubConfig, blockstackID); 218 | }) 219 | .then((profilePath) => { 220 | return blockstack.uploadToGaiaHub(profilePath, gaiaData, hubConfig); 221 | }); 222 | } 223 | 224 | /* 225 | * Upload profile data to all Gaia hubs, given a zone file. 226 | * @network (object) the network to use 227 | * @gaiaUrls (array) list of Gaia URLs 228 | * @gaiaData (string) the data to store 229 | * @privateKey (string) the hex-encoded private key 230 | * @return a promise with {'dataUrls': [urls to the data]}, or {'error': ...} 231 | */ 232 | export function gaiaUploadProfileAll(network: Object, 233 | gaiaUrls: Array, 234 | gaiaData: string, 235 | privateKey: string, 236 | blockstackID?: string 237 | ) : Promise<*> { 238 | const sanitizedGaiaUrls = gaiaUrls.map((gaiaUrl) => { 239 | const urlInfo = URL.parse(gaiaUrl); 240 | if (!urlInfo.protocol) { 241 | return ''; 242 | } 243 | if (!urlInfo.host) { 244 | return ''; 245 | } 246 | // keep flow happy 247 | return `${String(urlInfo.protocol)}//${String(urlInfo.host)}`; 248 | }) 249 | .filter((gaiaUrl) => gaiaUrl.length > 0); 250 | 251 | const uploadPromises = sanitizedGaiaUrls.map((gaiaUrl) => 252 | gaiaUploadProfile(network, gaiaUrl, gaiaData, privateKey, blockstackID)); 253 | 254 | return Promise.all(uploadPromises) 255 | .then((publicUrls) => { 256 | return { error: null, dataUrls: publicUrls }; 257 | }) 258 | .catch((e) => { 259 | return { error: `Failed to upload: ${e.message}`, dataUrls: null }; 260 | }); 261 | } 262 | 263 | /* 264 | * Make a zone file from a Gaia hub---reach out to the Gaia hub, get its read URL prefix, 265 | * and generate a zone file with the profile mapped to the Gaia hub. 266 | * 267 | * @network (object) the network connection 268 | * @name (string) the name that owns the zone file 269 | * @gaiaHubUrl (string) the URL to the gaia hub write endpoint 270 | * @ownerKey (string) the owner private key 271 | * 272 | * Returns a promise that resolves to the zone file with the profile URL 273 | */ 274 | export function makeZoneFileFromGaiaUrl(network: Object, name: string, 275 | gaiaHubUrl: string, ownerKey: string) { 276 | 277 | const address = getPrivateKeyAddress(network, ownerKey); 278 | const mainnetAddress = network.coerceMainnetAddress(address) 279 | 280 | return gaiaConnect(network, gaiaHubUrl, ownerKey) 281 | .then((hubConfig) => { 282 | if (!hubConfig.url_prefix) { 283 | throw new Error('Invalid hub config: no read_url_prefix defined'); 284 | } 285 | const gaiaReadUrl = hubConfig.url_prefix.replace(/\/+$/, ""); 286 | const profileUrl = `${gaiaReadUrl}/${mainnetAddress}/profile.json`; 287 | try { 288 | checkUrl(profileUrl); 289 | } 290 | catch(e) { 291 | throw new SafetyError({ 292 | 'status': false, 293 | 'error': e.message, 294 | 'hints': [ 295 | 'Make sure the Gaia hub read URL scheme is present and well-formed.', 296 | `Check the "read_url_prefix" field of ${gaiaHubUrl}/hub_info` 297 | ], 298 | }); 299 | } 300 | return blockstack.makeProfileZoneFile(name, profileUrl); 301 | }); 302 | } 303 | 304 | -------------------------------------------------------------------------------- /src/encrypt.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | // TODO: lifted wholesale from the Browser. 4 | // will remove once it's merged to blockstack.js 5 | 6 | import bip39 from 'bip39' 7 | import crypto from 'crypto' 8 | import triplesec from 'triplesec' 9 | 10 | function normalizeMnemonic(mnemonic: string) { 11 | return bip39.mnemonicToEntropy(mnemonic).toString('hex') 12 | } 13 | 14 | function denormalizeMnemonic(normalizedMnemonic : string) { 15 | return bip39.entropyToMnemonic(normalizedMnemonic) 16 | } 17 | 18 | function encryptMnemonic(plaintextBuffer : Buffer, password: string) : Promise { 19 | return Promise.resolve().then(() => { 20 | // must be bip39 mnemonic 21 | if (!bip39.validateMnemonic(plaintextBuffer.toString())) { 22 | throw new Error('Not a valid bip39 nmemonic') 23 | } 24 | 25 | // normalize plaintext to fixed length byte string 26 | const plaintextNormalized = Buffer.from( 27 | normalizeMnemonic(plaintextBuffer.toString()), 'hex') 28 | 29 | // AES-128-CBC with SHA256 HMAC 30 | const salt = crypto.randomBytes(16) 31 | const keysAndIV = crypto.pbkdf2Sync(password, salt, 100000, 48, 'sha512') 32 | const encKey = keysAndIV.slice(0, 16) 33 | const macKey = keysAndIV.slice(16, 32) 34 | const iv = keysAndIV.slice(32, 48) 35 | 36 | const cipher = crypto.createCipheriv('aes-128-cbc', encKey, iv) 37 | let cipherText = cipher.update(plaintextNormalized, undefined, 'hex') 38 | cipherText += cipher.final('hex') 39 | 40 | const hmacPayload = Buffer.concat([salt, Buffer.from(cipherText, 'hex')]) 41 | 42 | const hmac = crypto.createHmac('sha256', macKey) 43 | hmac.write(hmacPayload) 44 | const hmacDigest = hmac.digest() 45 | 46 | const payload = Buffer.concat([salt, hmacDigest, Buffer.from(cipherText, 'hex')]) 47 | return payload 48 | }) 49 | } 50 | 51 | function decryptMnemonic(dataBuffer: Buffer, password: string) : Promise { 52 | return Promise.resolve().then(() => { 53 | const salt = dataBuffer.slice(0, 16) 54 | const hmacSig = dataBuffer.slice(16, 48) // 32 bytes 55 | const cipherText = dataBuffer.slice(48) 56 | const hmacPayload = Buffer.concat([salt, cipherText]) 57 | 58 | const keysAndIV = crypto.pbkdf2Sync(password, salt, 100000, 48, 'sha512') 59 | const encKey = keysAndIV.slice(0, 16) 60 | const macKey = keysAndIV.slice(16, 32) 61 | const iv = keysAndIV.slice(32, 48) 62 | 63 | const decipher = crypto.createDecipheriv('aes-128-cbc', encKey, iv) 64 | let plaintext = decipher.update(cipherText.toString('hex'), 'hex').toString('hex') 65 | plaintext += decipher.final().toString('hex') 66 | 67 | const hmac = crypto.createHmac('sha256', macKey) 68 | hmac.write(hmacPayload) 69 | const hmacDigest = hmac.digest() 70 | 71 | // hash both hmacSig and hmacDigest so string comparison time 72 | // is uncorrelated to the ciphertext 73 | const hmacSigHash = crypto.createHash('sha256') 74 | .update(hmacSig) 75 | .digest() 76 | .toString('hex') 77 | 78 | const hmacDigestHash = crypto.createHash('sha256') 79 | .update(hmacDigest) 80 | .digest() 81 | .toString('hex') 82 | 83 | if (hmacSigHash !== hmacDigestHash) { 84 | // not authentic 85 | throw new Error('Wrong password (HMAC mismatch)') 86 | } 87 | 88 | const mnemonic = denormalizeMnemonic(plaintext) 89 | if (!bip39.validateMnemonic(mnemonic)) { 90 | throw new Error('Wrong password (invalid plaintext)') 91 | } 92 | 93 | return mnemonic 94 | }) 95 | } 96 | 97 | 98 | export function encryptBackupPhrase(plaintextBuffer: Buffer, password: string) : Promise { 99 | return encryptMnemonic(plaintextBuffer, password) 100 | } 101 | 102 | export function decryptBackupPhrase(dataBuffer: Buffer, password: string) : Promise { 103 | return decryptMnemonic(dataBuffer, password) 104 | .catch((e) => // try the old way 105 | new Promise((resolve, reject) => { 106 | triplesec.decrypt( 107 | { 108 | key: new Buffer(password), 109 | data: dataBuffer 110 | }, 111 | (err, plaintextBuffer) => { 112 | if (!err) { 113 | resolve(plaintextBuffer) 114 | } else { 115 | reject(new Error(`current algorithm: "${e.message}", ` + 116 | `legacy algorithm: "${err.message}"`)) 117 | } 118 | } 119 | ) 120 | }) 121 | ) 122 | } 123 | 124 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | export { CLIMain } from './cli'; 4 | 5 | // implement just enough of window to be useful to blockstack.js. 6 | // do this here, so we can be *sure* it's in RAM. 7 | const localStorageRAM = {}; 8 | 9 | global['window'] = { 10 | location: { 11 | origin: 'localhost' 12 | }, 13 | localStorage: { 14 | getItem: function(itemName) { 15 | return localStorageRAM[itemName]; 16 | }, 17 | setItem: function(itemName, itemValue) { 18 | localStorageRAM[itemName] = itemValue; 19 | }, 20 | removeItem: function(itemName) { 21 | delete localStorageRAM[itemName]; 22 | } 23 | } 24 | }; 25 | 26 | global['localStorage'] = global['window'].localStorage 27 | 28 | // cross-fetch overrides 29 | import fetch from 'cross-fetch' 30 | 31 | require('.').CLIMain() 32 | -------------------------------------------------------------------------------- /src/keys.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | // TODO: most of this code should be in blockstack.js 4 | // Will remove most of this code once the wallet functionality is there instead. 5 | 6 | const blockstack = require('blockstack') 7 | const keychains = require('blockstack-keychains') 8 | const bitcoin = require('bitcoinjs-lib') 9 | const bip39 = require('bip39') 10 | const crypto = require('crypto') 11 | const c32check = require('c32check') 12 | 13 | import { 14 | getPrivateKeyAddress 15 | } from './utils'; 16 | 17 | const IDENTITY_KEYCHAIN = 888 18 | const BLOCKSTACK_ON_BITCOIN = 0 19 | const APPS_NODE_INDEX = 0 20 | const SIGNING_NODE_INDEX = 1 21 | const ENCRYPTION_NODE_INDEX = 2 22 | 23 | export const STRENGTH = 128; // 12 words 24 | 25 | class IdentityAddressOwnerNode { 26 | hdNode: Object 27 | salt: string 28 | 29 | constructor(ownerHdNode: Object, salt: string) { 30 | this.hdNode = ownerHdNode 31 | this.salt = salt 32 | } 33 | 34 | getNode() { 35 | return this.hdNode 36 | } 37 | 38 | getSalt() { 39 | return this.salt 40 | } 41 | 42 | getIdentityKey() { 43 | return toPrivkeyHex(this.hdNode) 44 | } 45 | 46 | getIdentityKeyID() { 47 | return this.hdNode.publicKey.toString('hex') 48 | } 49 | 50 | getAppsNode() { 51 | return new AppsNode(this.hdNode.deriveHardened(APPS_NODE_INDEX), this.salt) 52 | } 53 | 54 | getAddress() { 55 | return getPrivateKeyAddress(blockstack.config.network, this.getIdentityKey()) 56 | } 57 | 58 | getEncryptionNode() { 59 | return this.hdNode.deriveHardened(ENCRYPTION_NODE_INDEX) 60 | } 61 | 62 | getSigningNode() { 63 | return this.hdNode.deriveHardened(SIGNING_NODE_INDEX) 64 | } 65 | } 66 | 67 | // for portal versions before 2038088458012dcff251027ea23a22afce443f3b 68 | class IdentityNode{ 69 | key : Object 70 | constructor(key: Object) { 71 | this.key = key; 72 | } 73 | getAddress() : string { 74 | return getPrivateKeyAddress(blockstack.config.network, this.getSKHex()) 75 | } 76 | getSKHex() : string { 77 | return toPrivkeyHex(this.key) 78 | } 79 | } 80 | 81 | const VERSIONS = { 82 | "pre-v0.9" : (m, i) => { return getIdentityKeyPre09(m) }, 83 | "v0.9-v0.10" : (m, i) => { return getIdentityKey09to10(getMaster(m), i) }, 84 | "v0.10-current" : (m, i) => { return getIdentityKeyCurrent(getMaster(m), i) }, 85 | "current-btc" : (m, i) => { return getBTC(getMaster(m)) }, 86 | } 87 | 88 | function getBTC(pK : Object) { 89 | const BIP_44_PURPOSE = 44; 90 | const BITCOIN_COIN_TYPE = 0; 91 | const ACCOUNT_INDEX = 0; 92 | 93 | return pK.deriveHardened(BIP_44_PURPOSE) 94 | .deriveHardened(BITCOIN_COIN_TYPE) 95 | .deriveHardened(ACCOUNT_INDEX).derive(0).derive(0); 96 | } 97 | 98 | function getIdentityNodeFromPhrase(phrase : string, 99 | index : number, 100 | version : string = "current"){ 101 | if (! (version in VERSIONS)){ 102 | throw new Error(`Key derivation version '${version}' not uspported`); 103 | } 104 | return VERSIONS[version](phrase, index); 105 | } 106 | 107 | function getIdentityKeyCurrent(pK : Object, index : number = 0){ 108 | return new IdentityNode( 109 | pK.deriveHardened(IDENTITY_KEYCHAIN) 110 | .deriveHardened(BLOCKSTACK_ON_BITCOIN) 111 | .deriveHardened(index)); 112 | } 113 | 114 | function getIdentityKey09to10(pK : Object, index : number = 0){ 115 | return new IdentityNode( 116 | pK.deriveHardened(IDENTITY_KEYCHAIN) 117 | .deriveHardened(BLOCKSTACK_ON_BITCOIN) 118 | .deriveHardened(index) 119 | .derive(0)); 120 | } 121 | 122 | function toPrivkeyHex(k : Object) : string { 123 | return `${k.privateKey.toString('hex')}01`; 124 | } 125 | 126 | function getIdentityKeyPre09(mnemonic : string) : IdentityNode { 127 | // on browser branch, v09 was commit -- 848d1f5445f01db1e28cde4a52bb3f22e5ca014c 128 | const pK = keychains.PrivateKeychain.fromMnemonic(mnemonic); 129 | const identityKey = pK.privatelyNamedChild('blockstack-0'); 130 | const secret = identityKey.privateKey; 131 | const keyPair = new bitcoin.ECPair(secret, false, {"network" : 132 | bitcoin.networks.bitcoin}); 133 | return new IdentityNode({ keyPair }); 134 | } 135 | 136 | function getMaster(mnemonic : string) { 137 | const seed = bip39.mnemonicToSeed(mnemonic); 138 | return bitcoin.bip32.fromSeed(seed); 139 | } 140 | 141 | 142 | // NOTE: legacy 143 | function hashCode(string) { 144 | let hash = 0 145 | if (string.length === 0) return hash 146 | for (let i = 0; i < string.length; i++) { 147 | const character = string.charCodeAt(i) 148 | hash = (hash << 5) - hash + character 149 | hash = hash & hash 150 | } 151 | return hash & 0x7fffffff 152 | } 153 | 154 | export class AppNode { 155 | hdNode: Object 156 | appDomain: string 157 | 158 | constructor(hdNode: Object, appDomain: string) { 159 | this.hdNode = hdNode 160 | this.appDomain = appDomain 161 | } 162 | 163 | getAppPrivateKey() { 164 | return toPrivkeyHex(this.hdNode) 165 | } 166 | 167 | getAddress() { 168 | return getPrivateKeyAddress(blockstack.config.network, toPrivkeyHex(this.hdNode)) 169 | } 170 | } 171 | 172 | export class AppsNode { 173 | hdNode: Object 174 | salt: string 175 | 176 | constructor(appsHdNode: Object, salt: string) { 177 | this.hdNode = appsHdNode 178 | this.salt = salt 179 | } 180 | 181 | getNode() { 182 | return this.hdNode 183 | } 184 | 185 | getAppNode(appDomain: string) { 186 | const hash = crypto 187 | .createHash('sha256') 188 | .update(`${appDomain}${this.salt}`) 189 | .digest('hex') 190 | const appIndex = hashCode(hash) 191 | const appNode = this.hdNode.deriveHardened(appIndex) 192 | return new AppNode(appNode, appDomain) 193 | } 194 | 195 | toBase58() { 196 | return this.hdNode.toBase58() 197 | } 198 | 199 | getSalt() { 200 | return this.salt 201 | } 202 | } 203 | 204 | export function getIdentityPrivateKeychain(masterKeychain: Object) { 205 | return masterKeychain.deriveHardened(IDENTITY_KEYCHAIN).deriveHardened(BLOCKSTACK_ON_BITCOIN) 206 | } 207 | 208 | export function getIdentityPublicKeychain(masterKeychain: Object) { 209 | return getIdentityPrivateKeychain(masterKeychain).neutered() 210 | } 211 | 212 | export function getIdentityOwnerAddressNode( 213 | identityPrivateKeychain: Object, identityIndex: ?number = 0) { 214 | if (identityPrivateKeychain.isNeutered()) { 215 | throw new Error('You need the private key to generate identity addresses') 216 | } 217 | 218 | // const publicKeyHex = identityPrivateKeychain.keyPair.getPublicKeyBuffer().toString('hex') 219 | const publicKeyHex = identityPrivateKeychain.publicKey.toString('hex') 220 | const salt = crypto 221 | .createHash('sha256') 222 | .update(publicKeyHex) 223 | .digest('hex') 224 | 225 | return new IdentityAddressOwnerNode(identityPrivateKeychain.deriveHardened(identityIndex), salt) 226 | } 227 | 228 | export function deriveIdentityKeyPair(identityOwnerAddressNode: Object) { 229 | const address = identityOwnerAddressNode.getAddress() 230 | const identityKey = identityOwnerAddressNode.getIdentityKey() 231 | const identityKeyID = identityOwnerAddressNode.getIdentityKeyID() 232 | const appsNode = identityOwnerAddressNode.getAppsNode() 233 | const keyPair = { 234 | key: identityKey, 235 | keyID: identityKeyID, 236 | address, 237 | appsNodeKey: appsNode.toBase58(), 238 | salt: appsNode.getSalt() 239 | } 240 | return keyPair 241 | } 242 | 243 | 244 | /* 245 | * Get the owner key information for a 12-word phrase, at a specific index. 246 | * @network (object) the blockstack network 247 | * @mnemonic (string) the 12-word phrase 248 | * @index (number) the account index 249 | * @version (string) the derivation version string 250 | * 251 | * Returns an object with: 252 | * .privateKey (string) the hex private key 253 | * .version (string) the version string of the derivation 254 | * .idAddress (string) the ID-address 255 | */ 256 | export function getOwnerKeyInfo(network: Object, 257 | mnemonic : string, 258 | index : number, 259 | version : string = 'v0.10-current') { 260 | const identity = getIdentityNodeFromPhrase(mnemonic, index, version); 261 | const addr = network.coerceAddress(identity.getAddress()); 262 | const privkey = identity.getSKHex(); 263 | return { 264 | privateKey: privkey, 265 | version: version, 266 | index: index, 267 | idAddress: `ID-${addr}`, 268 | }; 269 | } 270 | 271 | /* 272 | * Get the payment key information for a 12-word phrase. 273 | * @network (object) the blockstack network 274 | * @mnemonic (string) the 12-word phrase 275 | * 276 | * Returns an object with: 277 | * .privateKey (string) the hex private key 278 | * .address (string) the address of the private key 279 | */ 280 | export function getPaymentKeyInfo(network: Object, mnemonic : string) { 281 | const identityHDNode = getIdentityNodeFromPhrase(mnemonic, 0, 'current-btc'); 282 | const privkey = toPrivkeyHex(identityHDNode); 283 | const addr = getPrivateKeyAddress(network, privkey); 284 | return { 285 | privateKey: privkey, 286 | address: { 287 | BTC: addr, 288 | STACKS: c32check.b58ToC32(addr), 289 | }, 290 | index: 0 291 | }; 292 | } 293 | 294 | /* 295 | * Find the index of an ID address, given the mnemonic. 296 | * Returns the index if found 297 | * Returns -1 if not found 298 | */ 299 | export function findIdentityIndex(network: Object, mnemonic: string, idAddress: string, maxIndex: ?number = 16) { 300 | if (!maxIndex) { 301 | maxIndex = 16; 302 | } 303 | 304 | if (idAddress.substring(0,3) !== 'ID-') { 305 | throw new Error('Not an identity address'); 306 | } 307 | 308 | for (let i = 0; i < maxIndex; i++) { 309 | const identity = getIdentityNodeFromPhrase(mnemonic, i, 'v0.10-current'); 310 | if (network.coerceAddress(identity.getAddress()) === 311 | network.coerceAddress(idAddress.slice(3))) { 312 | return i; 313 | } 314 | } 315 | 316 | return -1; 317 | } 318 | 319 | /* 320 | * Get the Gaia application key from a 12-word phrase 321 | * @network (object) the blockstack network 322 | * @mmemonic (string) the 12-word phrase 323 | * @idAddress (string) the ID-address used to sign in 324 | * @appDomain (string) the application's Origin 325 | * 326 | * Returns an object with 327 | * .keyInfo (object) the app key info with the current derivation path 328 | * .privateKey (string) the app's hex private key 329 | * .address (string) the address of the private key 330 | * .legacyKeyInfo (object) the app key info with the legacy derivation path 331 | * .privateKey (string) the app's hex private key 332 | * .address (string) the address of the private key 333 | */ 334 | export function getApplicationKeyInfo(network: Object, 335 | mnemonic : string, 336 | idAddress: string, 337 | appDomain: string, 338 | idIndex: ?number) { 339 | if (!idIndex) { 340 | idIndex = -1; 341 | } 342 | 343 | if (idIndex < 0) { 344 | idIndex = findIdentityIndex(network, mnemonic, idAddress); 345 | if (idIndex < 0) { 346 | throw new Error('Identity address does not belong to this keychain'); 347 | } 348 | } 349 | 350 | const masterKeychain = getMaster(mnemonic); 351 | const identityPrivateKeychainNode = getIdentityPrivateKeychain(masterKeychain); 352 | const identityOwnerAddressNode = getIdentityOwnerAddressNode( 353 | identityPrivateKeychainNode, idIndex); 354 | 355 | // legacy app key (will update later to use the longer derivation path) 356 | const identityInfo = deriveIdentityKeyPair(identityOwnerAddressNode); 357 | const appsNodeKey = identityInfo.appsNodeKey; 358 | const salt = identityInfo.salt; 359 | const appsNode = new AppsNode(bitcoin.bip32.fromBase58(appsNodeKey), salt); 360 | 361 | // NOTE: we don't include the 'compressed' flag for app private keys, even though 362 | // the app address is the hash of the compressed public key. 363 | const appPrivateKey = appsNode.getAppNode(appDomain).getAppPrivateKey().substring(0,64); 364 | 365 | const res = { 366 | keyInfo: { 367 | privateKey: 'TODO', 368 | address: 'TODO', 369 | }, 370 | legacyKeyInfo: { 371 | privateKey: appPrivateKey, 372 | address: getPrivateKeyAddress(network, `${appPrivateKey}01`) 373 | }, 374 | ownerKeyIndex: idIndex 375 | }; 376 | return res; 377 | } 378 | 379 | /* 380 | * Extract the "right" app key 381 | */ 382 | export function extractAppKey(appKeyInfo: { keyInfo: { privateKey: string }, legacyKeyInfo: { privateKey : string } }) { 383 | const appPrivateKey = (appKeyInfo.keyInfo.privateKey === 'TODO' || !appKeyInfo.keyInfo.privateKey ? 384 | appKeyInfo.legacyKeyInfo.privateKey : 385 | appKeyInfo.keyInfo.privateKey); 386 | return appPrivateKey; 387 | } 388 | -------------------------------------------------------------------------------- /src/network.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | const blockstack = require('blockstack'); 4 | const Promise = require('bluebird'); 5 | const bigi = require('bigi'); 6 | const bitcoin = require('bitcoinjs-lib'); 7 | 8 | Promise.onPossiblyUnhandledRejection(function(error){ 9 | throw error; 10 | }); 11 | 12 | const SATOSHIS_PER_BTC = 1e8 13 | 14 | /* 15 | * Adapter class that allows us to use data obtained 16 | * from the CLI. 17 | */ 18 | export class CLINetworkAdapter extends blockstack.network.BlockstackNetwork { 19 | consensusHash: string | null 20 | feeRate: number | null 21 | namespaceBurnAddress: string | null 22 | priceToPay: number | null 23 | priceUnits: string | null 24 | gracePeriod: number | null 25 | 26 | constructor(network: blockstack.network.BlockstackNetwork, opts: Object) { 27 | const optsDefault = { 28 | consensusHash: null, 29 | feeRate: null, 30 | namesspaceBurnAddress: null, 31 | priceToPay: null, 32 | priceUnits: null, 33 | receiveFeesPeriod: null, 34 | gracePeriod: null, 35 | altAPIUrl: network.blockstackAPIUrl, 36 | altTransactionBroadcasterUrl: network.broadcastServiceUrl, 37 | nodeAPIUrl: null 38 | } 39 | 40 | opts = Object.assign({}, optsDefault, opts); 41 | 42 | super(opts.altAPIUrl, opts.altTransactionBroadcasterUrl, network.btc, network.layer1) 43 | this.consensusHash = opts.consensusHash 44 | this.feeRate = opts.feeRate 45 | this.namespaceBurnAddress = opts.namespaceBurnAddress 46 | this.priceToPay = opts.priceToPay 47 | this.priceUnits = opts.priceUnits 48 | this.receiveFeesPeriod = opts.receiveFeesPeriod 49 | this.gracePeriod = opts.gracePeriod 50 | this.nodeAPIUrl = opts.nodeAPIUrl 51 | 52 | this.optAlwaysCoerceAddress = false 53 | } 54 | 55 | isMainnet() : boolean { 56 | return this.layer1.pubKeyHash === bitcoin.networks.bitcoin.pubKeyHash 57 | } 58 | 59 | isTestnet() : boolean { 60 | return this.layer1.pubKeyHash === bitcoin.networks.testnet.pubKeyHash 61 | } 62 | 63 | setCoerceMainnetAddress(value: boolean) { 64 | this.optAlwaysCoerceAddress = value 65 | } 66 | 67 | coerceMainnetAddress(address: string) : string { 68 | const addressInfo = bitcoin.address.fromBase58Check(address) 69 | const addressHash = addressInfo.hash 70 | const addressVersion = addressInfo.version 71 | let newVersion = 0 72 | 73 | if (addressVersion === this.layer1.pubKeyHash) { 74 | newVersion = 0 75 | } 76 | else if (addressVersion === this.layer1.scriptHash) { 77 | newVersion = 5 78 | } 79 | return bitcoin.address.toBase58Check(addressHash, newVersion) 80 | } 81 | 82 | getFeeRate() : Promise { 83 | if (this.feeRate) { 84 | // override with CLI option 85 | return Promise.resolve(this.feeRate) 86 | } 87 | if (this.isTestnet()) { 88 | // in regtest mode 89 | return Promise.resolve(Math.floor(0.00001000 * SATOSHIS_PER_BTC)) 90 | } 91 | return super.getFeeRate() 92 | } 93 | 94 | getConsensusHash() { 95 | // override with CLI option 96 | if (this.consensusHash) { 97 | return new Promise((resolve) => resolve(this.consensusHash)) 98 | } 99 | return super.getConsensusHash() 100 | } 101 | 102 | getGracePeriod() { 103 | if (this.gracePeriod) { 104 | return this.gracePeriod 105 | } 106 | return super.getGracePeriod() 107 | } 108 | 109 | getNamePrice(name: string) { 110 | // override with CLI option 111 | if (this.priceUnits && this.priceToPay) { 112 | return new Promise((resolve) => resolve({ 113 | units: String(this.priceUnits), 114 | amount: bigi.fromByteArrayUnsigned(String(this.priceToPay)) 115 | })) 116 | } 117 | return super.getNamePrice(name) 118 | .then((priceInfo) => { 119 | // use v2 scheme 120 | if (!priceInfo.units) { 121 | priceInfo = { 122 | units: 'BTC', 123 | amount: bigi.fromByteArrayUnsigned(String(priceInfo)) 124 | } 125 | } 126 | return priceInfo; 127 | }) 128 | } 129 | 130 | getNamespacePrice(namespaceID: string) { 131 | // override with CLI option 132 | if (this.priceUnits && this.priceToPay) { 133 | return new Promise((resolve) => resolve({ 134 | units: String(this.priceUnits), 135 | amount: bigi.fromByteArrayUnsigned(String(this.priceToPay)) 136 | })) 137 | } 138 | return super.getNamespacePrice(namespaceID) 139 | .then((priceInfo) => { 140 | // use v2 scheme 141 | if (!priceInfo.units) { 142 | priceInfo = { 143 | units: 'BTC', 144 | amount: bigi.fromByteArrayUnsigned(String(priceInfo)) 145 | } 146 | } 147 | return priceInfo; 148 | }) 149 | } 150 | 151 | getNamespaceBurnAddress(namespace: string, useCLI: ?boolean = true) { 152 | // override with CLI option 153 | if (this.namespaceBurnAddress && useCLI) { 154 | return new Promise((resolve) => resolve(this.namespaceBurnAddress)) 155 | } 156 | 157 | return Promise.all([ 158 | fetch(`${this.blockstackAPIUrl}/v1/namespaces/${namespace}`), 159 | this.getBlockHeight() 160 | ]) 161 | .then(([resp, blockHeight]) => { 162 | if (resp.status === 404) { 163 | throw new Error(`No such namespace '${namespace}'`) 164 | } else if (resp.status !== 200) { 165 | throw new Error(`Bad response status: ${resp.status}`) 166 | } else { 167 | return Promise.all([resp.json(), blockHeight]) 168 | } 169 | }) 170 | .then(([namespaceInfo, blockHeight]) => { 171 | let address = '1111111111111111111114oLvT2' // default burn address 172 | if (namespaceInfo.version === 2) { 173 | // pay-to-namespace-creator if this namespace is less than $receiveFeesPeriod blocks old 174 | if (namespaceInfo.reveal_block + this.receiveFeesPeriod > blockHeight) { 175 | address = namespaceInfo.address 176 | } 177 | } 178 | return address 179 | }) 180 | .then(address => this.coerceAddress(address)) 181 | } 182 | 183 | getNameInfo(name: string) { 184 | // optionally coerce addresses 185 | return super.getNameInfo(name) 186 | .then((nameInfo) => { 187 | if (this.optAlwaysCoerceAddress) { 188 | nameInfo = Object.assign(nameInfo, { 189 | 'address': this.coerceMainnetAddress(nameInfo.address) 190 | }) 191 | } 192 | 193 | return nameInfo 194 | }) 195 | } 196 | 197 | getBlockchainNameRecordLegacy(name: string) : Promise<*> { 198 | // legacy code path. 199 | if (!this.nodeAPIUrl) { 200 | throw new Error("No indexer URL given. Pass -I.") 201 | } 202 | 203 | // this is EVIL code, and I'm a BAD PERSON for writing it. 204 | // will be removed once the /v1/blockchains/${blockchain}/names/${name} endpoint ships. 205 | const postData = '' + 206 | 'get_name_blockchain_record' + 207 | '' + 208 | `${name}` + 209 | '' + 210 | '' 211 | 212 | // try and guess which node we're talking to 213 | // (reminder: this is EVIL CODE that WILL BE REMOVED as soon as possible) 214 | return fetch(`${this.nodeAPIUrl}/RPC2`, 215 | { method: 'POST', 216 | body: postData }) 217 | .then((resp) => { 218 | if (resp.status >= 200 && resp.status <= 299){ 219 | return resp.text(); 220 | } 221 | else { 222 | throw new Error(`Bad response code: ${resp.status}`); 223 | } 224 | }) 225 | .then((respText) => { 226 | // response is a single string 227 | const start = respText.indexOf('') + ''.length 228 | const stop = respText.indexOf('') 229 | const dataResp = respText.slice(start, stop); 230 | let dataJson = null; 231 | try { 232 | dataJson = JSON.parse(dataResp); 233 | if (!dataJson.record) { 234 | // error response 235 | return dataJson 236 | } 237 | const nameRecord = dataJson.record; 238 | if (nameRecord.hasOwnProperty('history')) { 239 | // don't return history, since this is not expected in the new API 240 | delete nameRecord.history; 241 | } 242 | return nameRecord; 243 | } 244 | catch(e) { 245 | throw new Error('Invalid JSON returned (legacy codepath)'); 246 | } 247 | }); 248 | } 249 | 250 | getBlockchainNameRecord(name: string) : Promise<*> { 251 | // TODO: send to blockstack.js, once we can drop the legacy code path 252 | const url = `${this.blockstackAPIUrl}/v1/blockchains/bitcoin/names/${name}` 253 | return fetch(url) 254 | .then((resp) => { 255 | if (resp.status !== 200) { 256 | return this.getBlockchainNameRecordLegacy(name); 257 | } 258 | else { 259 | return resp.json(); 260 | } 261 | }) 262 | .then((nameInfo) => { 263 | // coerce all addresses 264 | let fixedAddresses = {} 265 | for (let addrAttr of ['address', 'importer_address', 'recipient_address']) { 266 | if (nameInfo.hasOwnProperty(addrAttr) && nameInfo[addrAttr]) { 267 | fixedAddresses[addrAttr] = this.coerceAddress(nameInfo[addrAttr]) 268 | } 269 | } 270 | return Object.assign(nameInfo, fixedAddresses) 271 | }) 272 | } 273 | 274 | getNameHistory(name: string, page: number) : Promise<*> { 275 | // TODO: send to blockstack.js 276 | const url = `${this.blockstackAPIUrl}/v1/names/${name}/history?page=${page}` 277 | return fetch(url) 278 | .then((resp) => { 279 | if (resp.status !== 200) { 280 | throw new Error(`Bad response status: ${resp.status}`) 281 | } 282 | return resp.json(); 283 | }) 284 | .then((historyInfo) => { 285 | // coerce all addresses 286 | let fixedHistory = {} 287 | for (let historyBlock of Object.keys(historyInfo)) { 288 | let fixedHistoryList = [] 289 | for (let historyEntry of historyInfo[historyBlock]) { 290 | let fixedAddresses = {} 291 | let fixedHistoryEntry = null 292 | for (let addrAttr of ['address', 'importer_address', 'recipient_address']) { 293 | if (historyEntry.hasOwnProperty(addrAttr) && historyEntry[addrAttr]) { 294 | fixedAddresses[addrAttr] = this.coerceAddress(historyEntry[addrAttr]) 295 | } 296 | } 297 | fixedHistoryEntry = Object.assign(historyEntry, fixedAddresses) 298 | fixedHistoryList.push(fixedHistoryEntry) 299 | } 300 | fixedHistory[historyBlock] = fixedHistoryList 301 | } 302 | return fixedHistory 303 | }) 304 | } 305 | 306 | // stub out accounts 307 | getAccountStatus(address: string) : Promise<*> { 308 | if (!super.getAccountStatus) { 309 | throw new Error('Getting an account status is not yet implemented in blockstack.js'); 310 | } 311 | return super.getAccountStatus(address); 312 | } 313 | 314 | // stub out accounts 315 | getAccountHistoryPage(address: string, page: number): Promise<*> { 316 | if (!super.getAccountHistoryPage) { 317 | return Promise.resolve().then(() => []); 318 | } 319 | return super.getAccountHistoryPage(address, page); 320 | } 321 | 322 | // stub out accounts 323 | getAccountBalance(address: string, tokenType: string): Promise<*> { 324 | if (!super.getAccountBalance) { 325 | return Promise.resolve().then(() => bigi.fromByteArrayUnsigned('0')); 326 | } 327 | return super.getAccountBalance(address, tokenType); 328 | } 329 | 330 | // stub out accounts 331 | getAccountAt(address: string, blockHeight: number): Promise<*> { 332 | if (!super.getAccountAt) { 333 | return Promise.resolve().then(() => []); 334 | } 335 | return super.getAccountAt(address, blockHeight); 336 | } 337 | 338 | // stub out accounts 339 | getAccountTokens(address: string): Promise<*> { 340 | if (!super.getAccountTokens) { 341 | return Promise.resolve().then(() => { 342 | return { tokens: [] }; 343 | }); 344 | } 345 | return super.getAccountTokens(address); 346 | } 347 | } 348 | 349 | /* 350 | * Instantiate a network using settings from the config file. 351 | */ 352 | export function getNetwork(configData: Object, regTest: boolean) 353 | : blockstack.network.BlockstackNetwork { 354 | if (regTest) { 355 | const network = new blockstack.network.LocalRegtest( 356 | configData.blockstackAPIUrl, configData.broadcastServiceUrl, 357 | new blockstack.network.BitcoindAPI(configData.utxoServiceUrl, 358 | { username: 'blockstack', password: 'blockstacksystem' })) 359 | 360 | return network 361 | } else { 362 | const network = new blockstack.network.BlockstackNetwork( 363 | configData.blockstackAPIUrl, configData.broadcastServiceUrl, 364 | new blockstack.network.BlockchainInfoApi(configData.utxoServiceUrl)) 365 | 366 | return network 367 | } 368 | } 369 | 370 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import logger from 'winston'; 4 | 5 | const bitcoinjs = require('bitcoinjs-lib'); 6 | const blockstack = require('blockstack'); 7 | const URL = require('url'); 8 | const RIPEMD160 = require('ripemd160'); 9 | const readline = require('readline'); 10 | const stream = require('stream'); 11 | const fs = require('fs'); 12 | 13 | import { 14 | parseZoneFile 15 | } from 'zone-file'; 16 | 17 | import ecurve from 'ecurve'; 18 | 19 | import { ECPair } from 'bitcoinjs-lib'; 20 | const secp256k1 = ecurve.getCurveByName('secp256k1'); 21 | 22 | import { 23 | PRIVATE_KEY_PATTERN, 24 | PRIVATE_KEY_MULTISIG_PATTERN, 25 | PRIVATE_KEY_SEGWIT_P2SH_PATTERN, 26 | ID_ADDRESS_PATTERN 27 | } from './argparse'; 28 | 29 | import { 30 | TransactionSigner 31 | } from 'blockstack'; 32 | 33 | import { 34 | decryptBackupPhrase 35 | } from './encrypt'; 36 | 37 | import { 38 | getOwnerKeyInfo, 39 | getApplicationKeyInfo, 40 | extractAppKey, 41 | } from './keys'; 42 | 43 | type UTXO = { value?: number, 44 | confirmations?: number, 45 | tx_hash: string, 46 | tx_output_n: number } 47 | 48 | 49 | export class MultiSigKeySigner implements TransactionSigner { 50 | redeemScript: Buffer 51 | privateKeys: Array 52 | address: string 53 | m: number 54 | constructor(redeemScript: string, privateKeys: Array) { 55 | this.redeemScript = Buffer.from(redeemScript, 'hex') 56 | this.privateKeys = privateKeys 57 | try { 58 | // try to deduce m (as in m-of-n) 59 | const chunks = bitcoinjs.script.decompile(this.redeemScript) 60 | const firstOp = chunks[0] 61 | this.m = parseInt(bitcoinjs.script.toASM([firstOp]).slice(3), 10) 62 | this.address = bitcoinjs.address.toBase58Check( 63 | bitcoinjs.crypto.hash160(this.redeemScript), 64 | blockstack.config.network.layer1.scriptHash) 65 | } catch (e) { 66 | logger.error(e); 67 | throw new Error('Improper redeem script for multi-sig input.') 68 | } 69 | } 70 | 71 | getAddress() : Promise { 72 | return Promise.resolve().then(() => this.address); 73 | } 74 | 75 | signTransaction(txIn: bitcoinjs.TransactionBuilder, signingIndex: number) : Promise { 76 | return Promise.resolve().then(() => { 77 | const keysToUse = this.privateKeys.slice(0, this.m) 78 | keysToUse.forEach((keyHex) => { 79 | const ecPair = blockstack.hexStringToECPair(keyHex) 80 | txIn.sign(signingIndex, ecPair, this.redeemScript) 81 | }) 82 | }); 83 | } 84 | } 85 | 86 | 87 | export class SegwitP2SHKeySigner implements TransactionSigner { 88 | redeemScript: Buffer 89 | witnessScript: Buffer 90 | privateKeys: Array 91 | address: string 92 | m: number 93 | 94 | constructor(redeemScript: string, witnessScript: string, m: number, privateKeys: Array) { 95 | this.redeemScript = Buffer.from(redeemScript, 'hex'); 96 | this.witnessScript = Buffer.from(witnessScript, 'hex'); 97 | this.address = bitcoinjs.address.toBase58Check( 98 | bitcoinjs.crypto.hash160(this.redeemScript), 99 | blockstack.config.network.layer1.scriptHash) 100 | 101 | this.privateKeys = privateKeys; 102 | this.m = m; 103 | } 104 | 105 | getAddress() : Promise { 106 | return Promise.resolve().then(() => this.address); 107 | } 108 | 109 | findUTXO(txIn: bitcoinjs.TransactionBuilder, signingIndex: number, utxos: Array) : UTXO { 110 | // NOTE: this is O(n*2) complexity for n UTXOs when signing an n-input transaction 111 | // NOTE: as of bitcoinjs-lib 4.x, the "tx" field is private 112 | const txidBuf = new Buffer(txIn.__tx.ins[signingIndex].hash.slice()); 113 | const outpoint = txIn.__tx.ins[signingIndex].index; 114 | 115 | txidBuf.reverse(); // NOTE: bitcoinjs encodes txid as big-endian 116 | const txid = txidBuf.toString('hex') 117 | 118 | for (let i = 0; i < utxos.length; i++) { 119 | if (utxos[i].tx_hash === txid && utxos[i].tx_output_n === outpoint) { 120 | if (!utxos[i].value) { 121 | throw new Error(`UTXO for hash=${txid} vout=${outpoint} has no value`); 122 | } 123 | return utxos[i]; 124 | } 125 | } 126 | throw new Error(`No UTXO for input hash=${txid} vout=${outpoint}`); 127 | } 128 | 129 | signTransaction(txIn: bitcoinjs.TransactionBuilder, signingIndex: number) : Promise { 130 | // This is an interface issue more than anything else. Basically, in order to 131 | // form the segwit sighash, we need the UTXOs. If we knew better, we would have 132 | // blockstack.js simply pass the consumed UTXO into this method. But alas, we do 133 | // not. Therefore, we need to re-query them. This is probably fine, since we're 134 | // not pressured for time when it comes to generating transactions. 135 | return Promise.resolve().then(() => { 136 | return this.getAddress(); 137 | }) 138 | .then((address) => { 139 | return blockstack.config.network.getUTXOs(address); 140 | }) 141 | .then((utxos) => { 142 | const utxo = this.findUTXO(txIn, signingIndex, utxos); 143 | if (this.m === 1) { 144 | // p2sh-p2wpkh 145 | const ecPair = blockstack.hexStringToECPair(this.privateKeys[0]); 146 | txIn.sign(signingIndex, ecPair, this.redeemScript, null, utxo.value); 147 | } 148 | else { 149 | // p2sh-p2wsh 150 | const keysToUse = this.privateKeys.slice(0, this.m) 151 | keysToUse.forEach((keyHex) => { 152 | const ecPair = blockstack.hexStringToECPair(keyHex) 153 | txIn.sign(signingIndex, ecPair, this.redeemScript, null, utxo.value, this.witnessScript); 154 | }); 155 | } 156 | }); 157 | } 158 | } 159 | 160 | export class SafetyError extends Error { 161 | safetyErrors: Object 162 | constructor(safetyErrors: Object) { 163 | super(JSONStringify(safetyErrors, true)); 164 | this.safetyErrors = safetyErrors; 165 | } 166 | } 167 | 168 | 169 | /* 170 | * Parse a string into a MultiSigKeySigner. 171 | * The string has the format "m,pk1,pk2,...,pkn" 172 | * @serializedPrivateKeys (string) the above string 173 | * @return a MultiSigKeySigner instance 174 | */ 175 | export function parseMultiSigKeys(serializedPrivateKeys: string) : MultiSigKeySigner { 176 | const matches = serializedPrivateKeys.match(PRIVATE_KEY_MULTISIG_PATTERN); 177 | if (!matches) { 178 | throw new Error('Invalid multisig private key string'); 179 | } 180 | 181 | const m = parseInt(matches[1]); 182 | const parts = serializedPrivateKeys.split(','); 183 | const privkeys = []; 184 | for (let i = 1; i < 256; i++) { 185 | const pk = parts[i]; 186 | if (!pk) { 187 | break; 188 | } 189 | 190 | if (!pk.match(PRIVATE_KEY_PATTERN)) { 191 | throw new Error('Invalid private key string'); 192 | } 193 | 194 | privkeys.push(pk); 195 | } 196 | 197 | // generate public keys 198 | const pubkeys = privkeys.map((pk) => { 199 | return Buffer.from(getPublicKeyFromPrivateKey(pk), 'hex'); 200 | }); 201 | 202 | // generate redeem script 203 | const multisigInfo = bitcoinjs.payments.p2ms({ m, pubkeys }); 204 | return new MultiSigKeySigner(multisigInfo.output, privkeys); 205 | } 206 | 207 | 208 | /* 209 | * Parse a string into a SegwitP2SHKeySigner 210 | * The string has the format "segwit:p2sh:m,pk1,pk2,...,pkn" 211 | * @serializedPrivateKeys (string) the above string 212 | * @return a MultiSigKeySigner instance 213 | */ 214 | export function parseSegwitP2SHKeys(serializedPrivateKeys: string) : SegwitP2SHKeySigner { 215 | const matches = serializedPrivateKeys.match(PRIVATE_KEY_SEGWIT_P2SH_PATTERN); 216 | if (!matches) { 217 | throw new Error('Invalid segwit p2sh private key string'); 218 | } 219 | 220 | const m = parseInt(matches[1]); 221 | const parts = serializedPrivateKeys.split(','); 222 | const privkeys = []; 223 | for (let i = 1; i < 256; i++) { 224 | const pk = parts[i]; 225 | if (!pk) { 226 | break; 227 | } 228 | 229 | if (!pk.match(PRIVATE_KEY_PATTERN)) { 230 | throw new Error('Invalid private key string'); 231 | } 232 | 233 | privkeys.push(pk); 234 | } 235 | 236 | // generate public keys 237 | const pubkeys = privkeys.map((pk) => { 238 | return Buffer.from(getPublicKeyFromPrivateKey(pk), 'hex'); 239 | }); 240 | 241 | // generate redeem script for p2wpkh or p2sh, depending on how many keys 242 | let redeemScript; 243 | let witnessScript = ''; 244 | if (m === 1) { 245 | // p2wpkh 246 | const p2wpkh = bitcoinjs.payments.p2wpkh({ pubkey: pubkeys[0] }); 247 | const p2sh = bitcoinjs.payments.p2sh({ redeem: p2wpkh }); 248 | 249 | redeemScript = p2sh.redeem.output; 250 | } 251 | else { 252 | // p2wsh 253 | const p2ms = bitcoinjs.payments.p2ms({ m, pubkeys }); 254 | const p2wsh = bitcoinjs.payments.p2wsh({ redeem: p2ms }); 255 | const p2sh = bitcoinjs.payments.p2sh({ redeem: p2wsh }); 256 | 257 | redeemScript = p2sh.redeem.output; 258 | witnessScript = p2wsh.redeem.output; 259 | } 260 | 261 | return new SegwitP2SHKeySigner(redeemScript, witnessScript, m, privkeys); 262 | } 263 | 264 | /* 265 | * Decode one or more private keys from a string. 266 | * Can be used to parse single private keys (as strings), 267 | * or multisig bundles (as TransactionSigners) 268 | * @serializedPrivateKey (string) the private key, encoded 269 | * @return a TransactionSigner or a String 270 | */ 271 | export function decodePrivateKey(serializedPrivateKey: string) : string | TransactionSigner { 272 | const singleKeyMatches = serializedPrivateKey.match(PRIVATE_KEY_PATTERN); 273 | if (!!singleKeyMatches) { 274 | // one private key 275 | return serializedPrivateKey; 276 | } 277 | 278 | const multiKeyMatches = serializedPrivateKey.match(PRIVATE_KEY_MULTISIG_PATTERN); 279 | if (!!multiKeyMatches) { 280 | // multisig bundle 281 | return parseMultiSigKeys(serializedPrivateKey); 282 | } 283 | 284 | const segwitP2SHMatches = serializedPrivateKey.match(PRIVATE_KEY_SEGWIT_P2SH_PATTERN); 285 | if (!!segwitP2SHMatches) { 286 | // segwit p2sh bundle 287 | return parseSegwitP2SHKeys(serializedPrivateKey); 288 | } 289 | 290 | throw new Error('Unparseable private key'); 291 | } 292 | 293 | /* 294 | * JSON stringify helper 295 | * -- if stdout is a TTY, then pretty-format the JSON 296 | * -- otherwise, print it all on one line to make it easy for programs to consume 297 | */ 298 | export function JSONStringify(obj: any, stderr: boolean = false) : string { 299 | if ((!stderr && process.stdout.isTTY) || (stderr && process.stderr.isTTY)) { 300 | return JSON.stringify(obj, null, 2); 301 | } 302 | else { 303 | return JSON.stringify(obj); 304 | } 305 | } 306 | 307 | /* 308 | * Get a private key's public key, while honoring the 01 to compress it. 309 | * @privateKey (string) the hex-encoded private key 310 | */ 311 | export function getPublicKeyFromPrivateKey(privateKey: string) : string { 312 | const ecKeyPair = blockstack.hexStringToECPair(privateKey); 313 | return ecKeyPair.publicKey.toString('hex'); 314 | } 315 | 316 | /* 317 | * Get a private key's address. Honor the 01 to compress the public key 318 | * @privateKey (string) the hex-encoded private key 319 | */ 320 | export function getPrivateKeyAddress(network: Object, privateKey: string | TransactionSigner) 321 | : string { 322 | if (typeof privateKey === 'string') { 323 | const ecKeyPair = blockstack.hexStringToECPair(privateKey); 324 | return network.coerceAddress(blockstack.ecPairToAddress(ecKeyPair)); 325 | } 326 | else { 327 | return privateKey.address; 328 | } 329 | } 330 | 331 | /* 332 | * Is a name a sponsored name (a subdomain)? 333 | */ 334 | export function isSubdomain(name: string) : boolean { 335 | return !!name.match(/^[^\.]+\.[^.]+\.[^.]+$/); 336 | } 337 | 338 | /* 339 | * Get the canonical form of a hex-encoded private key 340 | * (i.e. strip the trailing '01' if present) 341 | */ 342 | export function canonicalPrivateKey(privkey: string) : string { 343 | if (privkey.length == 66 && privkey.slice(-2) === '01') { 344 | return privkey.substring(0,64); 345 | } 346 | return privkey; 347 | } 348 | 349 | /* 350 | * Get the sum of a set of UTXOs' values 351 | * @txIn (object) the transaction 352 | */ 353 | export function sumUTXOs(utxos: Array) { 354 | return utxos.reduce((agg, x) => agg + x.value, 0); 355 | } 356 | 357 | /* 358 | * Hash160 function for zone files 359 | */ 360 | export function hash160(buff: Buffer) { 361 | return bitcoinjs.crypto.hash160(buff); 362 | } 363 | 364 | /* 365 | * Normalize a URL--remove duplicate /'s from the root of the path. 366 | * Throw an exception if it's not well-formed. 367 | */ 368 | export function checkUrl(url: string) : string { 369 | let urlinfo = URL.parse(url); 370 | if (!urlinfo.protocol) { 371 | throw new Error(`Malformed full URL: missing scheme in ${url}`); 372 | } 373 | 374 | if (!urlinfo.path || urlinfo.path.startsWith('//')) { 375 | throw new Error(`Malformed full URL: path root has multiple /'s: ${url}`); 376 | } 377 | 378 | return url; 379 | } 380 | 381 | /* 382 | * Sign a profile into a JWT 383 | */ 384 | export function makeProfileJWT(profileData: Object, privateKey: string) : string { 385 | const signedToken = blockstack.signProfileToken(profileData, privateKey); 386 | const wrappedToken = blockstack.wrapProfileToken(signedToken); 387 | const tokenRecords = [wrappedToken]; 388 | return JSONStringify(tokenRecords); 389 | } 390 | 391 | /* 392 | * Broadcast a transaction and a zone file. 393 | * Returns an object that encodes the success/failure of doing so. 394 | * If zonefile is None, then only the transaction will be sent. 395 | */ 396 | export function broadcastTransactionAndZoneFile(network: Object, 397 | tx: string, 398 | zonefile: ?string = null) { 399 | let txid; 400 | return Promise.resolve().then(() => { 401 | return network.broadcastTransaction(tx); 402 | }) 403 | .then((_txid) => { 404 | txid = _txid; 405 | if (zonefile) { 406 | return network.broadcastZoneFile(zonefile, txid); 407 | } 408 | else { 409 | return { 'status': true }; 410 | } 411 | }) 412 | .then((resp) => { 413 | if (!resp.status) { 414 | return { 415 | 'status': false, 416 | 'error': 'Failed to broadcast zone file', 417 | 'txid': txid 418 | }; 419 | } 420 | else { 421 | return { 422 | 'status': true, 423 | 'txid': txid 424 | }; 425 | } 426 | }) 427 | .catch((e) => { 428 | return { 429 | 'status': false, 430 | 'error': 'Caught exception sending transaction or zone file', 431 | 'message': e.message, 432 | 'stacktrace': e.stack 433 | } 434 | }); 435 | } 436 | 437 | 438 | /* 439 | * Easier-to-use getNameInfo. Returns null if the name does not exist. 440 | */ 441 | export function getNameInfoEasy(network: Object, name: string) : Promise<*> { 442 | const nameInfoPromise = network.getNameInfo(name) 443 | .then((nameInfo) => nameInfo) 444 | .catch((error) => { 445 | if (error.message === 'Name not found') { 446 | return null; 447 | } else { 448 | throw error; 449 | } 450 | }); 451 | 452 | return nameInfoPromise; 453 | } 454 | 455 | /* 456 | * Look up a name's zone file, profile URL, and profile 457 | * Returns a Promise to the above, or throws an error. 458 | */ 459 | export function nameLookup(network: Object, name: string, includeProfile?: boolean = true) 460 | : Promise<{ profile: Object | null, profileUrl: ?string, zonefile: ?string }> { 461 | 462 | const nameInfoPromise = getNameInfoEasy(network, name); 463 | const profilePromise = includeProfile ? 464 | blockstack.lookupProfile(name).catch(() => null) : 465 | Promise.resolve().then(() => null); 466 | 467 | const zonefilePromise = nameInfoPromise.then((nameInfo) => nameInfo ? nameInfo.zonefile : null); 468 | 469 | return Promise.resolve().then(() => { 470 | return Promise.all([profilePromise, zonefilePromise, nameInfoPromise]) 471 | }) 472 | .then(([profile, zonefile, nameInfo]) => { 473 | if (!nameInfo) { 474 | throw new Error('Name not found') 475 | } 476 | if (nameInfo.hasOwnProperty('grace_period') && nameInfo.grace_period) { 477 | throw new Error(`Name is expired at block ${nameInfo.expire_block} ` + 478 | `and must be renewed by block ${nameInfo.renewal_deadline}`); 479 | } 480 | 481 | let profileUrl = null; 482 | try { 483 | const zonefileJSON = parseZoneFile(zonefile); 484 | if (zonefileJSON.uri && zonefileJSON.hasOwnProperty('$origin')) { 485 | profileUrl = blockstack.getTokenFileUrl(zonefileJSON); 486 | } 487 | } 488 | catch(e) { 489 | profile = null; 490 | } 491 | 492 | const ret = { 493 | zonefile: zonefile, 494 | profile: profile, 495 | profileUrl: profileUrl 496 | }; 497 | return ret; 498 | }); 499 | } 500 | 501 | /* 502 | * Get a password. Don't echo characters to stdout. 503 | * Password will be passed to the given callback. 504 | */ 505 | export function getpass(promptStr: string, cb: (passwd: string) => void) { 506 | const silentOutput = new stream.Writable({ 507 | write: (chunk, encoding, callback) => { 508 | callback(); 509 | } 510 | }); 511 | 512 | const rl = readline.createInterface({ 513 | input: process.stdin, 514 | output: silentOutput, 515 | terminal: true 516 | }); 517 | 518 | process.stderr.write(promptStr); 519 | rl.question('', (passwd) => { 520 | rl.close(); 521 | process.stderr.write('\n'); 522 | cb(passwd); 523 | }); 524 | 525 | return; 526 | } 527 | 528 | /* 529 | * Extract a 12-word backup phrase. If the raw 12-word phrase is given, it will 530 | * be returned. If the ciphertext is given, the user will be prompted for a password 531 | * (if a password is not given as an argument). 532 | */ 533 | export function getBackupPhrase(backupPhraseOrCiphertext: string, password: ?string) : Promise { 534 | if (backupPhraseOrCiphertext.split(/ +/g).length > 1) { 535 | // raw backup phrase 536 | return Promise.resolve().then(() => backupPhraseOrCiphertext); 537 | } 538 | else { 539 | // ciphertext 540 | return new Promise((resolve, reject) => { 541 | if (!process.stdin.isTTY && !password) { 542 | // password must be given 543 | reject(new Error('Password argument required in non-interactive mode')); 544 | } 545 | else { 546 | // prompt password 547 | getpass('Enter password: ', (p) => { 548 | resolve(p); 549 | }); 550 | } 551 | }) 552 | .then((pass) => decryptBackupPhrase(Buffer.from(backupPhraseOrCiphertext, 'base64'), pass)); 553 | } 554 | } 555 | 556 | /* 557 | * mkdir -p 558 | * path must be absolute 559 | */ 560 | export function mkdirs(path: string) : void { 561 | if (path.length === 0 || path[0] !== '/') { 562 | throw new Error('Path must be absolute'); 563 | } 564 | 565 | const pathParts = path.replace(/^\//, '').split('/'); 566 | let tmpPath = '/'; 567 | for (let i = 0; i <= pathParts.length; i++) { 568 | try { 569 | const statInfo = fs.lstatSync(tmpPath); 570 | if ((statInfo.mode & fs.constants.S_IFDIR) === 0) { 571 | throw new Error(`Not a directory: ${tmpPath}`); 572 | } 573 | } 574 | catch (e) { 575 | if (e.code === 'ENOENT') { 576 | // need to create 577 | fs.mkdirSync(tmpPath); 578 | } 579 | else { 580 | throw e; 581 | } 582 | } 583 | if (i === pathParts.length) { 584 | break; 585 | } 586 | tmpPath = `${tmpPath}/${pathParts[i]}`; 587 | } 588 | } 589 | 590 | /* 591 | * Given a name or ID address, return a promise to the ID Address 592 | */ 593 | export function getIDAddress(network: Object, nameOrIDAddress: string) : Promise<*> { 594 | let idAddressPromise; 595 | if (nameOrIDAddress.match(ID_ADDRESS_PATTERN)) { 596 | idAddressPromise = Promise.resolve().then(() => nameOrIDAddress); 597 | } 598 | else { 599 | // need to look it up 600 | idAddressPromise = network.getNameInfo(nameOrIDAddress) 601 | .then((nameInfo) => `ID-${nameInfo.address}`); 602 | } 603 | return idAddressPromise; 604 | } 605 | 606 | /* 607 | * Find all identity addresses until we have one that matches the given one. 608 | * Loops forever if not found 609 | */ 610 | export function getOwnerKeyFromIDAddress(network: Object, 611 | mnemonic: string, 612 | idAddress: string 613 | ) : string { 614 | let index = 0; 615 | while(true) { 616 | const keyInfo = getOwnerKeyInfo(network, mnemonic, index); 617 | if (keyInfo.idAddress === idAddress) { 618 | return keyInfo.privateKey; 619 | } 620 | index++; 621 | } 622 | throw new Error('Unreachable') 623 | } 624 | 625 | /* 626 | * Given a name or an ID address and a possibly-encrypted mnemonic, get the owner and app 627 | * private keys. 628 | * May prompt for a password if mnemonic is encrypted. 629 | */ 630 | export function getIDAppKeys(network: Object, 631 | nameOrIDAddress: string, 632 | appOrigin: string, 633 | mnemonicOrCiphertext: string, 634 | ) : Promise<{ ownerPrivateKey: string, appPrivateKey: string, mnemonic: string }> { 635 | 636 | let mnemonic; 637 | let ownerPrivateKey; 638 | let appPrivateKey; 639 | 640 | return getBackupPhrase(mnemonicOrCiphertext) 641 | .then((mn) => { 642 | mnemonic = mn; 643 | return getIDAddress(network, nameOrIDAddress) 644 | }) 645 | .then((idAddress) => { 646 | const appKeyInfo = getApplicationKeyInfo(network, mnemonic, idAddress, appOrigin); 647 | appPrivateKey = extractAppKey(appKeyInfo); 648 | ownerPrivateKey = getOwnerKeyFromIDAddress(network, mnemonic, idAddress); 649 | const ret = { 650 | appPrivateKey, 651 | ownerPrivateKey, 652 | mnemonic 653 | }; 654 | return ret; 655 | }); 656 | } 657 | 658 | -------------------------------------------------------------------------------- /tests/unitTests/src/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jcnelson/cli-blockstack/e55775bdaf0ad1a7a1a93adf3bf14d35a0b63949/tests/unitTests/src/index.js --------------------------------------------------------------------------------