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;