diff --git a/bin/blocktrail b/bin/blocktrail new file mode 100755 index 0000000..ad7834f --- /dev/null +++ b/bin/blocktrail @@ -0,0 +1,5 @@ +#!/usr/bin/env php +run(); diff --git a/bin/compile b/bin/compile new file mode 100755 index 0000000..a8bdc54 --- /dev/null +++ b/bin/compile @@ -0,0 +1,21 @@ +#!/usr/bin/env php +compile($phar); + + chmod($phar, 0755); +} catch (\Exception $e) { + echo 'Failed to compile phar: ['.get_class($e).'] '.$e->getMessage().' at '.$e->getFile().':'.$e->getLine(); + exit(1); +} diff --git a/composer.json b/composer.json index a0ae8bd..74023f7 100644 --- a/composer.json +++ b/composer.json @@ -36,10 +36,14 @@ "rych/hash_pbkdf2-compat": "~1.0", "ramsey/array_column": "~1.1", "dompdf/dompdf" : "0.6.*", - "endroid/qrcode": "1.5.*" + "endroid/qrcode": "1.5.*", + "symfony/console": "~2.6", + "monolog/monolog": "~1.13" }, "require-dev": { "phpunit/phpunit": "4.3.*", - "squizlabs/php_codesniffer": "1.*" + "squizlabs/php_codesniffer": "1.*", + "symfony/process": "~2.6", + "symfony/finder": "~2.6" } } diff --git a/src/BlocktrailSDK.php b/src/BlocktrailSDK.php index 7d42bd9..859782c 100644 --- a/src/BlocktrailSDK.php +++ b/src/BlocktrailSDK.php @@ -7,6 +7,8 @@ use BitWasp\BitcoinLib\BitcoinLib; use BitWasp\BitcoinLib\RawTransaction; use Blocktrail\SDK\Connection\RestClient; +use Blocktrail\SDK\Exceptions\WalletChecksumException; +use Monolog\Logger; /** * Class BlocktrailSDK @@ -17,6 +19,11 @@ class BlocktrailSDK implements BlocktrailSDKInterface { */ protected $client; + /** + * @var Logger + */ + private $logger = null; + /** * @var string currently only supporting; bitcoin */ @@ -135,6 +142,24 @@ public function getRestClient() { return $this->client; } + /** + * @return Logger + */ + public function getLogger() { + return $this->logger; + } + + /** + * @param Logger $logger + * @param bool $setOnRestClient + */ + public function setLogger(Logger $logger, $setOnRestClient = true) { + $this->logger = $logger; + if ($setOnRestClient) { + $this->client->setLogger($logger); + } + } + /** * get a single address * @param string $address address hash diff --git a/src/Compiler.php b/src/Compiler.php new file mode 100644 index 0000000..11ef146 --- /dev/null +++ b/src/Compiler.php @@ -0,0 +1,183 @@ +run() != 0) { + throw new \RuntimeException('Can\'t run git log. You must ensure to run compile from blocktrail git repository clone and that git binary is available.'); + } + $this->version = trim($process->getOutput()); + + $process = new Process('git log -n1 --pretty=%ci HEAD', __DIR__); + if ($process->run() != 0) { + throw new \RuntimeException('Can\'t run git log. You must ensure to run compile from blocktrail git repository clone and that git binary is available.'); + } + + $date = new \DateTime(trim($process->getOutput())); + $date->setTimezone(new \DateTimeZone('UTC')); + $this->versionDate = $date->format('Y-m-d H:i:s'); + + $process = new Process('git describe --tags --exact-match HEAD'); + if ($process->run() == 0) { + $this->version = trim($process->getOutput()); + } else { + $this->version = "dev"; + } + + $phar = new \Phar($pharFile, 0, 'blocktrail.phar'); + $phar->setSignatureAlgorithm(\Phar::SHA1); + + $phar->startBuffering(); + + $finder = new Finder(); + $finder->files() + ->ignoreVCS(true) + ->name('*.php') + ->notName('Compiler.php') + ->in(__DIR__); + + foreach ($finder as $file) { + $this->addFile($phar, $file); + } + + $finder = new Finder(); + $finder->files() + ->ignoreVCS(true) + ->name('*.php') + ->exclude('Test') + ->exclude('Tests') + ->exclude('test') + ->exclude('tests') + ->exclude('phpunit') + ->exclude('php_codesniffer') + ->in(__DIR__ . '/../vendor/'); + + foreach ($finder as $file) { + $this->addFile($phar, $file); + } + + $this->addFile($phar, new \SplFileInfo(__DIR__ . '/../vendor/autoload.php')); + $this->addFile($phar, new \SplFileInfo(__DIR__ . '/../vendor/composer/autoload_namespaces.php')); + $this->addFile($phar, new \SplFileInfo(__DIR__ . '/../vendor/composer/autoload_psr4.php')); + $this->addFile($phar, new \SplFileInfo(__DIR__ . '/../vendor/composer/autoload_classmap.php')); + $this->addFile($phar, new \SplFileInfo(__DIR__ . '/../vendor/composer/autoload_real.php')); + if (file_exists(__DIR__ . '/../vendor/composer/include_paths.php')) { + $this->addFile($phar, new \SplFileInfo(__DIR__ . '/../vendor/composer/include_paths.php')); + } + $this->addFile($phar, new \SplFileInfo(__DIR__ . '/../vendor/composer/ClassLoader.php')); + $this->addBin($phar); + + // Stubs + $phar->setStub($this->getStub()); + + $phar->stopBuffering(); + + // can be disabled for interoperability with systems without gzip ext + $phar->compressFiles(\Phar::GZ); + + $this->addFile($phar, new \SplFileInfo(__DIR__ . '/../LICENSE.md'), false); + + unset($phar); + } + + private function addFile($phar, $file, $strip = true) { + $path = strtr(str_replace(dirname(__DIR__) . DIRECTORY_SEPARATOR, '', $file->getRealPath()), '\\', '/'); + + $content = file_get_contents($file); + if ($strip) { + $content = $this->stripWhitespace($content); + } elseif ('LICENSE.md' === basename($file)) { + $content = "\n" . $content . "\n"; + } + + $phar->addFromString($path, $content); + } + + private function addBin($phar) { + $content = file_get_contents(__DIR__ . '/../bin/blocktrail'); + $content = preg_replace('{^#!/usr/bin/env php\s*}', '', $content); + $phar->addFromString('bin/blocktrail', $content); + } + + /** + * Removes whitespace from a PHP source string while preserving line numbers. + * + * @param string $source A PHP string + * @return string The PHP string with the whitespace removed + */ + private function stripWhitespace($source) { + if (!function_exists('token_get_all')) { + return $source; + } + + $output = ''; + foreach (token_get_all($source) as $token) { + if (is_string($token)) { + $output .= $token; + } elseif (in_array($token[0], array(T_COMMENT, T_DOC_COMMENT))) { + $output .= str_repeat("\n", substr_count($token[1], "\n")); + } elseif (T_WHITESPACE === $token[0]) { + // reduce wide spaces + $whitespace = preg_replace('{[ \t]+}', ' ', $token[1]); + // normalize newlines to \n + $whitespace = preg_replace('{(?:\r\n|\r|\n)}', "\n", $whitespace); + // trim leading spaces + $whitespace = preg_replace('{\n +}', "\n", $whitespace); + $output .= $whitespace; + } else { + $output .= $token[1]; + } + } + + return $output; + } + + private function getStub() { + $stub = <<<'EOF' +#!/usr/bin/env php +=')) { + ini_set('apc.cache_by_default', 0); + } else { + fwrite(STDERR, 'Warning: APC <= 3.0.12 may cause fatal errors when running blocktrail commands.'.PHP_EOL); + fwrite(STDERR, 'Update APC, or set apc.enable_cli or apc.cache_by_default to 0 in your php.ini.'.PHP_EOL); + } +} + +Phar::mapPhar('blocktrail.phar'); + +EOF; + + return $stub . <<<'EOF' +require 'phar://blocktrail.phar/bin/blocktrail'; + +__HALT_COMPILER(); +EOF; + } +} diff --git a/src/Connection/RestClient.php b/src/Connection/RestClient.php index 276c8fc..2ebdcbe 100644 --- a/src/Connection/RestClient.php +++ b/src/Connection/RestClient.php @@ -4,6 +4,8 @@ use GuzzleHttp\Client as Guzzle; use GuzzleHttp\Message\RequestInterface; +use GuzzleHttp\Event\BeforeEvent; +use GuzzleHttp\Event\CompleteEvent; use GuzzleHttp\Message\ResponseInterface; use GuzzleHttp\Post\PostBodyInterface; use GuzzleHttp\Query; @@ -19,6 +21,7 @@ use Blocktrail\SDK\Connection\Exceptions\InvalidCredentials; use Blocktrail\SDK\Connection\Exceptions\MissingEndpoint; use Blocktrail\SDK\Connection\Exceptions\GenericHTTPError; +use Monolog\Logger; use Symfony\Component\HttpFoundation\Request; /** @@ -44,6 +47,13 @@ class RestClient { */ protected $verboseErrors = false; + private $timeIt = null; + + /** + * @var Logger|null + */ + private $logger = null; + public function __construct($apiEndpoint, $apiVersion, $apiKey, $apiSecret) { $this->guzzle = new Guzzle(array( 'base_url' => $apiEndpoint, @@ -64,6 +74,19 @@ public function __construct($apiEndpoint, $apiVersion, $apiKey, $apiSecret) { $this->apiKey = $apiKey; + $this->guzzle->getEmitter()->on('before', function (BeforeEvent $e) { + $this->timeIt = microtime(true); + if ($this->logger) { + $this->logger->debug("BEFORE {$e->getRequest()->getUrl()}"); + } + }); + + $this->guzzle->getEmitter()->on('complete', function (CompleteEvent $e) { + if ($this->logger) { + $this->logger->debug("COMPLETE {$e->getRequest()->getUrl()} " . (microtime(true) - $this->timeIt)); + } + }); + $this->guzzle->getEmitter()->attach(new RequestSubscriber( new Context([ 'keys' => [$apiKey => $apiSecret], @@ -89,6 +112,15 @@ public function setCurlDebugging($debug = true) { $this->guzzle->setDefaultOption('debug', $debug); } + /** + * set a logger to handle debug info + * + * @param Logger $logger + */ + public function setLogger(Logger $logger) { + $this->logger = $logger; + } + /** * enable verbose errors * diff --git a/src/Console/Application.php b/src/Console/Application.php new file mode 100644 index 0000000..685476d --- /dev/null +++ b/src/Console/Application.php @@ -0,0 +1,65 @@ +getFormatter()->setStyle('success', new OutputFormatterStyle('green', null, ['bold'])); + $output->getFormatter()->setStyle('bold', new OutputFormatterStyle(null, null, ['bold'])); + $output->getFormatter()->setStyle('info-bold', new OutputFormatterStyle('green', null, ['bold'])); + $output->getFormatter()->setStyle('question-bold', new OutputFormatterStyle('yellow', 'cyan', ['bold'])); + $output->getFormatter()->setStyle('comment-bold', new OutputFormatterStyle('yellow', null, ['bold'])); + + return parent::run($input, $output); + } +} diff --git a/src/Console/Commands/AbstractCommand.php b/src/Console/Commands/AbstractCommand.php new file mode 100644 index 0000000..de27ce1 --- /dev/null +++ b/src/Console/Commands/AbstractCommand.php @@ -0,0 +1,104 @@ +addOption('api_key', null, InputOption::VALUE_REQUIRED, 'API_KEY to be used') + ->addOption('api_secret', null, InputOption::VALUE_REQUIRED, 'API_SECRET to be used') + ->addOption('testnet', null, InputOption::VALUE_NONE, 'use testnet instead of mainnet') + ->addOption('config', 'c', InputOption::VALUE_REQUIRED, "config file to use; defaults to {$file}", $file); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + /** @var Output $output */ + $config = $this->getConfig($input); + + $this->apiKey = trim($input->getOption('api_key')) ?: (isset($config['api_key']) ? $config['api_key'] : null); + $this->apiSecret = trim($input->getOption('api_secret')) ?: (isset($config['api_secret']) ? $config['api_secret'] : null); + $this->testnet = $input->getOption('testnet') ?: (isset($config['testnet']) ? $config['testnet'] : false); + + if (!$this->apiKey) { + throw new \RuntimeException('API_KEY is required.'); + } + if (!$this->apiSecret) { + throw new \RuntimeException('API_SECRET is required.'); + } + + if ($output->isVeryVerbose()) { + $stderr = new StreamHandler('php://stderr', Logger::DEBUG); + $logger = new Logger("blocktrail-sdk", [$stderr]); + $this->getBlocktrailSDK()->getRestClient()->setLogger($logger); + } + } + + /** + * @return BlocktrailSDKInterface + */ + public function getBlocktrailSDK() { + return new BlocktrailSDK($this->apiKey, $this->apiSecret, "BTC", $this->testnet); + } + + protected function getNetwork() { + return $this->testnet ? "tBTC" : "BTC"; + } + + public function getConfig(InputInterface $input) { + $file = $input->getOption('config'); + + if (!file_exists($file)) { + return []; + } + + return json_decode(file_get_contents($file), true); + } + + public function replaceConfig(InputInterface $input, array $config) { + $file = $input->getOption('config'); + $dir = dirname($file); + + if (!file_exists($dir)) { + mkdir($dir); + } + + file_put_contents($file, json_encode($config)); + + return null; + } + + public function updateConfig(InputInterface $input, array $config) { + return $this->replaceConfig($input, array_replace_recursive($this->getConfig($input), $config)); + } +} diff --git a/src/Console/Commands/AbstractWalletCommand.php b/src/Console/Commands/AbstractWalletCommand.php new file mode 100644 index 0000000..0396058 --- /dev/null +++ b/src/Console/Commands/AbstractWalletCommand.php @@ -0,0 +1,69 @@ +addOption('identifier', null, InputOption::VALUE_REQUIRED, 'Wallet identifier') + ->addOption('passphrase', null, InputOption::VALUE_REQUIRED, 'Wallet passphrase'); + + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + parent::execute($input, $output); + + $config = $this->getConfig($input); + + $this->identifier = trim($input->getOption('identifier')) ?: (isset($config[$this->getNetwork()]['default_wallet']) ? $config[$this->getNetwork()]['default_wallet'] : null); + + if (!$this->identifier) { + throw new \RuntimeException('indentifier is required.'); + } + + if ($input->getOption('passphrase')) { + $this->passphrase = trim($input->getOption('passphrase')); + } else if (isset($config[$this->getNetwork()]['wallet_passphrase'][$this->identifier])) { + $this->passphrase = $config[$this->getNetwork()]['wallet_passphrase'][$this->identifier]; + } + } + + protected function getWallet(InputInterface $input, OutputInterface $output) { + /** @var Output $output */ + /** @var QuestionHelper $questionHelper */ + $questionHelper = $this->getHelper('question'); + + $sdk = $this->getBlocktrailSDK(); + + while (!trim($this->passphrase)) { + $question = new Question("Please provide passphrase for wallet [{$this->identifier}]: \n"); + $question->setHidden(true); + $this->passphrase = $questionHelper->ask($input, $output, $question); + } + + $wallet = $sdk->initWallet($this->identifier, $this->passphrase); + + $output->isVerbose() && $output->writeln("Wallet initialized"); + + return $wallet; + } +} diff --git a/src/Console/Commands/BalanceCommand.php b/src/Console/Commands/BalanceCommand.php new file mode 100644 index 0000000..e96b51d --- /dev/null +++ b/src/Console/Commands/BalanceCommand.php @@ -0,0 +1,42 @@ +setName('balance') + ->setDescription("Get wallet balance"); + + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + /** @var Output $output */ + parent::execute($input, $output); + + $wallet = $this->getWallet($input, $output); + + list($confirmed, $unconfirmed) = $wallet->getBalance(); + $final = $confirmed + $unconfirmed; + + $output->writeln("Confirmed Balance; {$confirmed} Satoshi = " . BlocktrailSDK::toBTCString($confirmed) . " BTC"); + $output->writeln("Unconfirmed Balance; {$unconfirmed} Satoshi = " . BlocktrailSDK::toBTCString($unconfirmed) . " BTC"); + $output->writeln("Final Balance; {$final} Satoshi = " . BlocktrailSDK::toBTCString($final) . " BTC"); + } +} diff --git a/src/Console/Commands/ConfigureCommand.php b/src/Console/Commands/ConfigureCommand.php new file mode 100644 index 0000000..f42ab23 --- /dev/null +++ b/src/Console/Commands/ConfigureCommand.php @@ -0,0 +1,60 @@ +setName('configure') + // ->setAliases(['setup']) + ->setDescription("Configure credentials"); + + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + /** @var Output $output */ + /** @var QuestionHelper $questionHelper */ + $questionHelper = $this->getHelper('question'); + + $interactive = true; // @TODO + $apiKey = trim($input->getOption('api_key')); + $apiSecret = trim($input->getOption('api_secret')); + $testnet = !!$input->getOption('testnet'); + + if ($interactive) { + if (!$apiKey) { + $question = new Question("Set the default API_KEY to use (blank to not set a default API_KEY): \n"); + $apiKey = $questionHelper->ask($input, $output, $question); + } + if (!$apiSecret) { + $question = new Question("Set the default API_SECRET to use (blank to not set a default API_SECRET): \n"); + $apiSecret = $questionHelper->ask($input, $output, $question); + } + + if (!$testnet) { + $question = new ConfirmationQuestion("Set weither to use TESTNET by default? [y/N] \n", false); + $testnet = $questionHelper->ask($input, $output, $question); + } + } + + $this->replaceConfig($input, [ + 'api_key' => $apiKey, + 'api_secret' => $apiSecret, + 'testnet' => $testnet + ]); + } +} diff --git a/src/Console/Commands/CreateNewWalletCommand.php b/src/Console/Commands/CreateNewWalletCommand.php new file mode 100644 index 0000000..642c2c6 --- /dev/null +++ b/src/Console/Commands/CreateNewWalletCommand.php @@ -0,0 +1,83 @@ +setName('create_new_wallet') + // ->setAliases(['create_new_wallet', 'create_wallet']) + ->setDescription("Create a new wallet") + ->addArgument('identifier', InputArgument::REQUIRED, 'A unique identifier to be used as wallet identifier') + ->addArgument('passphrase', InputArgument::OPTIONAL, 'A strong passphrase to be used as wallet passphrase'); + + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + /** @var Output $output */ + parent::execute($input, $output); + + /** @var QuestionHelper $questionHelper */ + $questionHelper = $this->getHelper('question'); + + $identifier = trim($input->getArgument('identifier')); + $passphrase = trim($input->getArgument('passphrase')); + + if (!$identifier) { + $output->writeln("Identifier is required!"); + exit(1); + } + + $output->writeln("Creating wallet with identifier [{$identifier}]"); + + while (!$passphrase) { + do { + $question = new Question("Please choose a strong passphrase for the wallet: \n"); + $question->setHidden(true); + $passphrase1 = $questionHelper->ask($input, $output, $question); + } while (!trim($passphrase1)); + + do { + $question = new Question("Please repeat the passphrase for the wallet: \n"); + $question->setHidden(true); + $passphrase2 = $questionHelper->ask($input, $output, $question); + } while (!trim($passphrase2)); + + if ($passphrase1 != $passphrase2) { + $output->writeln("Both passwords must be the same, please try again! \n"); + } else { + $passphrase = trim($passphrase1); + } + } + + $sdk = $this->getBlocktrailSDK(); + + /** @var WalletInterface $wallet */ + list($wallet, $primaryMnemonic, $backupMnemonic) = $sdk->createNewWallet($identifier, $passphrase, 9999); + + $output->writeln("Wallet created"); + + $output->writeln("Make sure to backup the following information somewhere safe;"); + $output->writeln("Primary Mnemonic:\n {$primaryMnemonic}"); + $output->writeln("Backup Mnemonic:\n {$backupMnemonic}"); + + while (!$questionHelper->ask($input, $output, new ConfirmationQuestion("Did you store the information? [y/N] ", false))) { + $output->writeln("..."); + } + + $output->writeln("DONE!"); + } +} diff --git a/src/Console/Commands/DiscoveryCommand.php b/src/Console/Commands/DiscoveryCommand.php new file mode 100644 index 0000000..460335f --- /dev/null +++ b/src/Console/Commands/DiscoveryCommand.php @@ -0,0 +1,42 @@ +setName('discovery') + ->setDescription("Do wallet discovery"); + + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + /** @var Output $output */ + parent::execute($input, $output); + + $wallet = $this->getWallet($input, $output); + + list($confirmed, $unconfirmed) = $wallet->doDiscovery(); + $final = $confirmed + $unconfirmed; + + $output->writeln("Confirmed Balance; {$confirmed} Satoshi = " . BlocktrailSDK::toBTCString($confirmed) . " BTC"); + $output->writeln("Unconfirmed Balance; {$unconfirmed} Satoshi = " . BlocktrailSDK::toBTCString($unconfirmed) . " BTC"); + $output->writeln("Final Balance; {$final} Satoshi = " . BlocktrailSDK::toBTCString($final) . " BTC"); + } +} diff --git a/src/Console/Commands/GetNewAddressCommand.php b/src/Console/Commands/GetNewAddressCommand.php new file mode 100644 index 0000000..6655edd --- /dev/null +++ b/src/Console/Commands/GetNewAddressCommand.php @@ -0,0 +1,35 @@ +setName('new_address') + // ->setAliases(['get_new_address', 'address']) + ->setDescription("Get a new address for a wallet"); + + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + /** @var Output $output */ + parent::execute($input, $output); + + $wallet = $this->getWallet($input, $output); + + $output->writeln($wallet->getNewAddress()); + } +} diff --git a/src/Console/Commands/ListUTXOCommand.php b/src/Console/Commands/ListUTXOCommand.php new file mode 100644 index 0000000..9285eb1 --- /dev/null +++ b/src/Console/Commands/ListUTXOCommand.php @@ -0,0 +1,55 @@ +setName('list_utxos') + // ->setAliases(['utxos']) + ->setDescription("List UTXO set for a wallet") + ->addOption('page', 'p', InputOption::VALUE_REQUIRED, 'pagination page', 1) + ->addOption('per-page', 'pp', InputOption::VALUE_REQUIRED, 'pagination limit', 50); + + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + /** @var Output $output */ + parent::execute($input, $output); + + $wallet = $this->getWallet($input, $output); + + $page = $input->getOption('page'); + $perpage = $input->getOption('per-page'); + + $UTXOs = $wallet->utxos($page, $perpage)['data']; + + $table = new Table($output); + $table->setHeaders(['#', 'tx', 'idx', 'value', 'confirmations', 'address']); + foreach ($UTXOs as $i => $UTXO) { + $table->addRow([$i, $UTXO['hash'], $UTXO['idx'], BlocktrailSDK::toBTCString($UTXO['value']), $UTXO['confirmations'], $UTXO['address']]); + } + + $table->render(); + + if (count($UTXOs) >= $perpage) { + $output->writeln("there are more UTXOs, use --page and --perpage to see all of them ..."); + } + } +} diff --git a/src/Console/Commands/ListWalletsCommand.php b/src/Console/Commands/ListWalletsCommand.php new file mode 100644 index 0000000..3a128a2 --- /dev/null +++ b/src/Console/Commands/ListWalletsCommand.php @@ -0,0 +1,63 @@ +setName('list_wallets') + // ->setAliases(['create_new_wallet', 'create_wallet']) + ->setDescription("List all wallets") + ->addOption('page', 'p', InputOption::VALUE_REQUIRED, 'pagination page', 1) + ->addOption('per-page', 'pp', InputOption::VALUE_REQUIRED, 'pagination limit', 50); + + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + /** @var Output $output */ + parent::execute($input, $output); + + $sdk = $this->getBlocktrailSDK(); + $config = $this->getConfig($input); + + $page = $input->getOption('page'); + $perpage = $input->getOption('per-page'); + + $wallets = $sdk->allWallets($page, $perpage)['data']; + + if (!$wallets) { + $output->writeln("There are no wallets!"); + exit(1); + } + + $table = new Table($output); + $table->setHeaders(['identifier', 'balance', '']); + foreach ($wallets as $wallet) { + $isDefault = isset($config[$this->getNetwork()]['default_wallet']) && $config[$this->getNetwork()]['default_wallet'] == $wallet['identifier']; + + $table->addRow([$wallet['identifier'], BlocktrailSDK::toBTCString($wallet['balance']), $isDefault ? 'IS_DEFAULT' : '']); + } + + $table->render(); + + if (count($wallets) >= $perpage) { + $output->writeln("there are more wallets, use --page and --perpage to see all of them ..."); + } + } +} diff --git a/src/Console/Commands/PayCommand.php b/src/Console/Commands/PayCommand.php new file mode 100644 index 0000000..b10389e --- /dev/null +++ b/src/Console/Commands/PayCommand.php @@ -0,0 +1,105 @@ +setName('pay') + // ->setAliases(['send']) + ->setDescription("Send a payment") + ->addArgument("recipient", InputArgument::IS_ARRAY, "
:") + ->addOption('silent', 's', InputOption::VALUE_NONE, "don't ask for confirmation"); + + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + /** @var Output $output */ + parent::execute($input, $output); + + /** @var QuestionHelper $questionHelper */ + $questionHelper = $this->getHelper('question'); + + $wallet = $this->getWallet($input, $output); + + $pay = []; + $address = null; + foreach ($input->getArgument("recipient") as $recipient) { + $recipient = explode(":", $recipient); + + if (count($recipient) == 2) { + if ($address) { + throw new \Exception("Bad input"); + } + + $address = $recipient[0]; + $value = $recipient[1]; + } else if (count($recipient) == 1) { + if (!$address) { + $address = $recipient[0]; + continue; + } else { + $value = $recipient[0]; + } + } else { + throw new \Exception("Bad input"); + } + + if (!BitcoinLib::validate_address($address)) { + throw new \Exception("Invalid address"); + } + + if (isset($pay[$address])) { + throw new \Exception("Same address apears twice in input"); + } + + if (strpos($value, ".") !== false || strpos($value, "," !== false)) { + $value = BlocktrailSDK::toSatoshi($value); + } else { + if (!$questionHelper->ask($input, $output, new ConfirmationQuestion("Did you specify this value in satoshi? [y/N] ", false))) { + $value = BlocktrailSDK::toSatoshi($value); + } else { + $value = (int)$value; + } + } + + $pay[$address] = $value; + $address = null; + } + + if ($address) { + throw new \Exception("Bad input"); + } + + if (!$input->getOption('silent')) { + $output->writeln("Sending payment from [{$wallet->getIdentifier()}] to:"); + foreach ($pay as $address => $value) { + $output->writeln("[{$address}] {$value} Satoshi = " . BlocktrailSDK::toBTCString($value) . " BTC"); + } + + if (!$questionHelper->ask($input, $output, new ConfirmationQuestion("Send? [Y/n] ", true))) { + exit(1); + } + } + + $txHash = $wallet->pay($pay); + + $output->writeln("TX {$txHash}"); + } +} diff --git a/src/Console/Commands/SplitUTXOsCommand.php b/src/Console/Commands/SplitUTXOsCommand.php new file mode 100644 index 0000000..01c207b --- /dev/null +++ b/src/Console/Commands/SplitUTXOsCommand.php @@ -0,0 +1,84 @@ +setName('split_utxos') + ->setDescription("Split UTXOs") + ->addArgument("count", InputArgument::REQUIRED, "the amount of chunks") + ->addArgument("value", InputArgument::REQUIRED, "the value of each chunk") + ->addOption('value-is-total', null, InputOption::VALUE_NONE, "the value argument should be devided by the count") + ->addOption('silent', 's', InputOption::VALUE_NONE, "don't ask for confirmation"); + + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + /** @var Output $output */ + parent::execute($input, $output); + + /** @var QuestionHelper $questionHelper */ + $questionHelper = $this->getHelper('question'); + + $wallet = $this->getWallet($input, $output); + + list($confirmed, $unconfirmed) = $wallet->getBalance(); + + $count = $input->getArgument('count'); + $value = $input->getArgument('value'); + + if (strpos($value, ".") !== false || strpos($value, "," !== false)) { + $value = BlocktrailSDK::toSatoshi($value); + } else { + if (!$questionHelper->ask($input, $output, new ConfirmationQuestion("Did you specify this value in satoshi? [y/N] ", false))) { + $value = BlocktrailSDK::toSatoshi($value); + } + } + + if ($input->getOption('value-is-total')) { + $value = (int)floor($value / $count); + } + + if ($value * $count > $confirmed) { + $output->writeln("You do not have enough confirmed balance; " . BlocktrailSDK::toBTCString($value * $count) . " BTC > " . BlocktrailSDK::toBTCString($confirmed) . " BTC) "); + exit(1); + } + + $pay = []; + for ($i = 0; $i < $count; $i++) { + $pay[$wallet->getNewAddress()] = $value; + } + + if (!$input->getOption('silent')) { + $output->writeln("Sending payment from [{$wallet->getIdentifier()}] to:"); + foreach ($pay as $address => $value) { + $output->writeln("[{$address}] {$value} Satoshi = " . BlocktrailSDK::toBTCString($value) . " BTC"); + } + + if (!$questionHelper->ask($input, $output, new ConfirmationQuestion("Send? [Y/n] ", true))) { + exit(1); + } + } + + $txHash = $wallet->pay($pay); + + $output->writeln("TX {$txHash}"); + } +} diff --git a/src/Console/Commands/StoreWalletPassphraseCommand.php b/src/Console/Commands/StoreWalletPassphraseCommand.php new file mode 100644 index 0000000..63a66ef --- /dev/null +++ b/src/Console/Commands/StoreWalletPassphraseCommand.php @@ -0,0 +1,131 @@ +setName('store_wallet_passphrase') + ->setDescription("Store a wallets passphrase") + ->addOption('identifier', null, InputOption::VALUE_REQUIRED, 'Wallet identifier') + ->addOption('passphrase', null, InputOption::VALUE_REQUIRED, 'Wallet passphrase'); + + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + /** @var Output $output */ + parent::execute($input, $output); + + /** @var QuestionHelper $questionHelper */ + $questionHelper = $this->getHelper('question'); + + $sdk = $this->getBlocktrailSDK(); + $interactive = true; // @TODO; + $identifier = trim($input->getOption('identifier')); + $passphrase = trim($input->getOption('passphrase')); + + if ($interactive) { + if (!$identifier) { + $identifier = $this->promptForIdentifier($input, $output, $sdk); + } + + if (!$passphrase) { + $question = new Question("Input the wallet passphrase to store (blank to not/un set the default passphrase): \n"); + $question->setHidden(true); + $passphrase = $questionHelper->ask($input, $output, $question); + } + } + + if ($identifier && $passphrase) { + $this->getBlocktrailSDK()->initWallet($identifier, $passphrase); + } + + $this->updateConfig($input, [ + $this->getNetwork() => [ + 'wallet_passphrase' => [ + $identifier => $passphrase + ] + ] + ]); + + $output->writeln("OK!"); + } + + protected function promptForIdentifier($input, $output, BlocktrailSDKInterface $sdk) { + /** @var QuestionHelper $questionHelper */ + $questionHelper = $this->getHelper('question'); + + $identifier = null; + + $page = 1; + $perpage = 50; + + while (!$identifier) { + $wallets = $sdk->allWallets($page, $perpage)['data']; + $fill = ($perpage * ($page - 1) + 1); + $options = array_slice( + array_merge( + array_fill(0, $fill, ''), + array_column($wallets, 'identifier') + ), + $fill, + null, + true + ); + + if (count($wallets) >= $perpage) { + $options['more'] = self::OPTION_MORE; + } + if ($page > 1) { + $options['less'] = self::OPTION_LESS; + } + + $options['no'] = self::OPTION_NO_DEFAULT; + $options['manual'] = self::OPTION_FREEFORM; + + $question = new ChoiceQuestion("Please select the wallet you'd like to store a passphrase for", $options, null); + $question->setAutocompleterValues([]); + $choice = $questionHelper->ask($input, $output, $question); + + if ($choice == self::OPTION_NO_DEFAULT) { + $identifier = null; + break; + } else if ($choice == self::OPTION_FREEFORM) { + $question = new Question("Please fill in the wallet identifier you'd like to store a passphrase for? "); + $identifier = $questionHelper->ask($input, $output, $question); + } else if ($choice == self::OPTION_MORE) { + $page += 1; + + } else if ($choice == self::OPTION_LESS) { + $page -= 1; + + } else { + $identifier = $choice; + } + } + + return $identifier; + } +} diff --git a/src/Console/Commands/UseWalletCommand.php b/src/Console/Commands/UseWalletCommand.php new file mode 100644 index 0000000..7038405 --- /dev/null +++ b/src/Console/Commands/UseWalletCommand.php @@ -0,0 +1,125 @@ +setName('default_wallet') + ->setDescription("Configure default wallet to use") + ->addOption('identifier', null, InputOption::VALUE_REQUIRED, 'Wallet identifier') + ->addOption('passphrase', null, InputOption::VALUE_REQUIRED, 'Wallet passphrase'); + + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + /** @var Output $output */ + parent::execute($input, $output); + + $sdk = $this->getBlocktrailSDK(); + $interactive = true; // @TODO; + $identifier = trim($input->getOption('identifier')); + + if ($interactive) { + if (!$identifier) { + $identifier = $this->promptForIdentifier($input, $output, $sdk); + } + } + + if ($identifier) { + try { + $this->getBlocktrailSDK()->initWallet($identifier, ""); + } catch (WalletChecksumException $e) { + // OK + } + } + + $this->updateConfig($input, [ + $this->getNetwork() => [ + 'default_wallet' => $identifier + ] + ]); + + $output->writeln("OK!"); + } + + protected function promptForIdentifier($input, $output, BlocktrailSDKInterface $sdk) { + /** @var QuestionHelper $questionHelper */ + $questionHelper = $this->getHelper('question'); + + $identifier = null; + + $page = 1; + $perpage = 50; + + while (!$identifier) { + $wallets = $sdk->allWallets($page, $perpage)['data']; + $fill = ($perpage * ($page - 1) + 1); + $options = array_slice( + array_merge( + array_fill(0, $fill, ''), + array_column($wallets, 'identifier') + ), + $fill, + null, + true + ); + + if (count($wallets) >= $perpage) { + $options['more'] = self::OPTION_MORE; + } + if ($page > 1) { + $options['less'] = self::OPTION_LESS; + } + + $options['no'] = self::OPTION_NO_DEFAULT; + $options['manual'] = self::OPTION_FREEFORM; + + $question = new ChoiceQuestion("Please select the wallet you'd like to use as default", $options, null); + $question->setAutocompleterValues([]); + $choice = $questionHelper->ask($input, $output, $question); + + if ($choice == self::OPTION_NO_DEFAULT) { + $identifier = null; + break; + } else if ($choice == self::OPTION_FREEFORM) { + $question = new Question("Please fill in the wallet identifier you'd like to use as default? "); + $identifier = $questionHelper->ask($input, $output, $question); + } else if ($choice == self::OPTION_MORE) { + $page += 1; + + } else if ($choice == self::OPTION_LESS) { + $page -= 1; + + } else { + $identifier = $choice; + } + } + + return $identifier; + } +} diff --git a/src/Exceptions/WalletChecksumException.php b/src/Exceptions/WalletChecksumException.php new file mode 100644 index 0000000..2b98246 --- /dev/null +++ b/src/Exceptions/WalletChecksumException.php @@ -0,0 +1,11 @@ +checksum) { - throw new \Exception("Checksum [{$checksum}] does not match [{$this->checksum}], most likely due to incorrect password"); + throw new WalletChecksumException("Checksum [{$checksum}] does not match [{$this->checksum}], most likely due to incorrect password"); } $this->locked = false;