Completed
Pull Request — master (#99)
by thomas
21:02
created

WalletSweeper::makeAddressReader()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 11
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 0
Metric Value
cc 4
eloc 8
nc 3
nop 1
dl 0
loc 11
ccs 0
cts 11
cp 0
crap 20
rs 9.2
c 0
b 0
f 0
1
<?php
2
3
namespace Blocktrail\SDK;
4
5
use BitWasp\Bitcoin\Address\AddressFactory;
6
use BitWasp\Bitcoin\Bitcoin;
7
use BitWasp\Bitcoin\Key\Deterministic\HierarchicalKeyFactory;
8
use BitWasp\Bitcoin\Network\NetworkFactory;
9
use BitWasp\Bitcoin\Script\P2shScript;
10
use BitWasp\Bitcoin\Script\ScriptFactory;
11
use BitWasp\Bitcoin\Script\WitnessScript;
12
use BitWasp\Bitcoin\Transaction\Factory\SignData;
13
use BitWasp\Bitcoin\Transaction\Factory\Signer;
14
use BitWasp\Bitcoin\Transaction\Factory\TxBuilder;
15
use BitWasp\Bitcoin\Transaction\OutPoint;
16
use BitWasp\Bitcoin\Transaction\SignatureHash\SigHash;
17
use BitWasp\Bitcoin\Transaction\TransactionInterface;
18
use BitWasp\Bitcoin\Transaction\TransactionOutput;
19
use BitWasp\Buffertools\Buffer;
20
use BitWasp\Buffertools\BufferInterface;
21
use Blocktrail\SDK\Address\AddressReaderBase;
22
use Blocktrail\SDK\Address\BitcoinAddressReader;
23
use Blocktrail\SDK\Address\BitcoinCashAddressReader;
24
use Blocktrail\SDK\Address\CashAddress;
25
use Blocktrail\SDK\Bitcoin\BIP32Key;
26
use Blocktrail\SDK\Bitcoin\BIP32Path;
27
use Blocktrail\SDK\Exceptions\BlocktrailSDKException;
28
use Blocktrail\SDK\Network\BitcoinCash;
29
30
abstract class WalletSweeper {
31
32
    /**
33
     * network to use - currently only supporting 'bitcoin'
34
     * @var string
35
     */
36
    protected $network;
37
38
    /**
39
     * using testnet or not
40
     * @var bool
41
     */
42
    protected $testnet;
43
44
    /**
45
     * backup private key
46
     * @var BIP32Key
47
     */
48
    protected $backupPrivateKey;
49
50
    /**
51
     * primary private key
52
     * @var BIP32Key
53
     */
54
    protected $primaryPrivateKey;
55
56
    /**
57
     * blocktrail public keys, mapped to their relevant keyIndex
58
     * @var
59
     */
60
    protected $blocktrailPublicKeys;
61
62
    /**
63
     * gets unspent outputs for addresses
64
     * @var UnspentOutputFinder
65
     */
66
    protected $utxoFinder;
67
68
    /**
69
     * holds wallet addresses, along with path, redeem script, and discovered unspent outputs
70
     * @var array
71
     */
72
    protected $sweepData;
73
74
    /**
75
     * @var AddressReaderBase
76
     */
77
    protected $addressReader;
78
79
    /**
80
     * process logging for debugging
81
     * @var bool
82
     */
83
    protected $debug = false;
84
85
    /**
86
     * @param BufferInterface     $primarySeed
87
     * @param BufferInterface     $backupSeed
88
     * @param array               $blocktrailPublicKeys =
89
     * @param UnspentOutputFinder $utxoFinder
90
     * @param string              $network
91
     * @param bool                $testnet
92
     * @throws \Exception
93
     */
94
    public function __construct(BufferInterface $primarySeed, BufferInterface $backupSeed, array $blocktrailPublicKeys, UnspentOutputFinder $utxoFinder, $network = 'btc', $testnet = false) {
95
        // normalize network and set bitcoinlib to the right magic-bytes
96
        list($this->network, $this->testnet) = $this->normalizeNetwork($network, $testnet);
97
98
        $this->setBitcoinLibMagicBytes($this->network, $this->testnet);
99
        $this->addressReader = $this->makeAddressReader([
100
            "use_cashaddress" => false,
101
        ]);
102
103
        //create BIP32 keys for the Blocktrail public keys
104
        foreach ($blocktrailPublicKeys as $blocktrailKey) {
105
            $this->blocktrailPublicKeys[$blocktrailKey['keyIndex']] = BlocktrailSDK::normalizeBIP32Key([$blocktrailKey['pubkey'], $blocktrailKey['path']]);
106
        }
107
108
        $this->utxoFinder = $utxoFinder;
109
110
        $this->primaryPrivateKey = BIP32Key::create(HierarchicalKeyFactory::fromEntropy($primarySeed), "m");
111
        $this->backupPrivateKey = BIP32Key::create(HierarchicalKeyFactory::fromEntropy($backupSeed), "m");
112
    }
113
114
    /**
115
     * @param array $options
116
     * @return AddressReaderBase
117
     */
118
    private function makeAddressReader(array $options) {
119
        if ($this->network == "bitcoincash") {
120
            $useCashAddress = false;
121
            if (array_key_exists("use_cashaddress", $options) && $options['use_cashaddress']) {
122
                $useCashAddress = true;
123
            }
124
            return new BitcoinCashAddressReader($useCashAddress);
125
        } else {
126
            return new BitcoinAddressReader();
127
        }
128
    }
129
130
    /**
131
     * set BitcoinLib to the correct magic-byte defaults for the selected network
132
     *
133
     * @param $network
134
     * @param $testnet
135
     */
136
    protected function setBitcoinLibMagicBytes($network, $testnet) {
137
        assert($network == "bitcoin" || $network == "bitcoincash");
138
        if ($network === "bitcoin") {
139
            if ($testnet) {
140
                $useNetwork = NetworkFactory::bitcoinTestnet();
141
            } else {
142
                $useNetwork = NetworkFactory::bitcoin();
143
            }
144
        } else if ($network === "bitcoincash") {
145
            $useNetwork = new BitcoinCash((bool) $testnet);
146
        }
147
148
        Bitcoin::setNetwork($useNetwork);
0 ignored issues
show
Bug introduced by
The variable $useNetwork does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
149
    }
150
151
    /**
152
     * enable debug info logging (just to console)
153
     */
154
    public function enableLogging() {
155
        $this->debug = true;
156
        $this->utxoFinder->enableLogging();
157
    }
158
159
    /**
160
     * disable debug info logging
161
     */
162
    public function disableLogging() {
163
        $this->debug = false;
164
        $this->utxoFinder->disableLogging();
165
    }
166
167
    /**
168
     * normalize network string
169
     *
170
     * @param $network
171
     * @param $testnet
172
     * @return array
173
     * @throws \Exception
174
     */
175
    protected function normalizeNetwork($network, $testnet) {
176
        return Util::normalizeNetwork($network, $testnet);
177
    }
178
179
    /**
180
     * generate multisig address for given path
181
     *
182
     * @param string|BIP32Path $path
183
     * @return array[string, ScriptInterface]                 [address, redeemScript]
0 ignored issues
show
Documentation introduced by
The doc-type array[string, could not be parsed: Expected "]" at position 2, but found "string". (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
184
     * @throws \Exception
185
     */
186
    protected function createAddress($path) {
187
        $path = BIP32Path::path($path)->publicPath();
188
189
        $multisig = ScriptFactory::scriptPubKey()->multisig(2, BlocktrailSDK::sortMultisigKeys([
190
            $this->primaryPrivateKey->buildKey($path)->publicKey(),
191
            $this->backupPrivateKey->buildKey($path->unhardenedPath())->publicKey(),
192
            $this->getBlocktrailPublicKey($path)->buildKey($path)->publicKey()
193
        ]), false);
194
195
        if ($this->network !== "bitcoincash" && (int) $path[2] === 2) {
196
            $witnessScript = new WitnessScript($multisig);
197
            $redeemScript = new P2shScript($witnessScript);
198
            $address = $this->addressReader->fromOutputScript($redeemScript->getOutputScript())->getAddress();
199
        } else {
200
            $witnessScript = null;
201
            $redeemScript = new P2shScript($multisig);
202
            $address = $this->addressReader->fromOutputScript($redeemScript->getOutputScript())->getAddress();
203
        }
204
205
        return [$address, $redeemScript, $witnessScript];
206
    }
207
208
    /**
209
     * create a batch of multisig addresses
210
     *
211
     * @param $start
212
     * @param $count
213
     * @param $keyIndex
214
     * @return array
215
     */
216
    protected function createBatchAddresses($start, $count, $keyIndex) {
217
        $addresses = array();
218
219
        for ($i = 0; $i < $count; $i++) {
220
            //create a path subsequent address
221
            $path = (string)WalletPath::create($keyIndex, $_chain = 0, $start+$i)->path()->publicPath();
222
            list($address, $redeem, $witness) = $this->createAddress($path);
223
            $addresses[$address] = array(
224
                //'address' => $address,
0 ignored issues
show
Unused Code Comprehensibility introduced by
67% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
225
                'redeem' => $redeem,
226
                'witness' => $witness,
227
                'path' => $path
228
            );
229
230
            if ($this->debug) {
231
                echo ".";
232
            }
233
        }
234
235
        return $addresses;
236
    }
237
238
239
    /**
240
     * gets the blocktrail pub key for the given path from the stored array of pub keys
241
     *
242
     * @param string|BIP32Path  $path
243
     * @return BIP32Key
244
     * @throws \Exception
245
     */
246
    protected function getBlocktrailPublicKey($path) {
247
        $path = BIP32Path::path($path);
248
249
        $keyIndex = str_replace("'", "", $path[1]);
250
251
        if (!isset($this->blocktrailPublicKeys[$keyIndex])) {
252
            throw new \Exception("No blocktrail publickey for key index [{$keyIndex}]");
253
        }
254
255
        return $this->blocktrailPublicKeys[$keyIndex];
256
    }
257
258
    /**
259
     * discover funds in the wallet
260
     *
261
     * @param int $increment how many addresses to scan at a time
262
     * @return array
263
     * @throws BlocktrailSDKException
264
     */
265
    public function discoverWalletFunds($increment = 200) {
266
        $totalBalance = 0;
267
        $totalUTXOs = 0;
268
        $totalAddressesGenerated = 0;
269
270
        $addressUTXOs = array();    //addresses and their utxos, paths and redeem scripts
271
272
        //for each blocktrail pub key, do fund discovery on batches of addresses
273
        foreach ($this->blocktrailPublicKeys as $keyIndex => $blocktrailPubKey) {
274
            $i = 0;
275
            do {
276
                if ($this->debug) {
277
                    echo "\ngenerating $increment addresses using blocktrail key index $keyIndex\n";
278
                }
279
                $addresses = $this->createBatchAddresses($i, $increment, $keyIndex);
280
                $totalAddressesGenerated += count($addresses);
281
282
                if ($this->debug) {
283
                    echo "\nstarting fund discovery for $increment addresses";
284
                }
285
286
                //get the unspent outputs for this batch of addresses
287
                $utxos = $this->utxoFinder->getUTXOs(array_keys($addresses));
288
                //save the address utxos, along with relevant path and redeem script
289
                foreach ($utxos as $utxo) {
290
                    if (!isset($utxo['address'], $utxo['value'])) {
291
                        throw new BlocktrailSDKException("Missing data");
292
                    }
293
294
                    $address = $utxo['address'];
295
296
                    if (!isset($addressUTXOs[$address])) {
297
                        $addressUTXOs[$address] = [
298
                            'path' =>  $addresses[$address]['path'],
299
                            'redeem' =>  $addresses[$address]['redeem'],
300
                            'witness' =>  $addresses[$address]['witness'],
301
                            'utxos' =>  [],
302
                        ];
303
                    }
304
305
                    $addressUTXOs[$address]['utxos'][] = $utxo;
306
                    $totalUTXOs ++;
307
308
                    //add up the total utxo value for all addresses
309
                    $totalBalance += $utxo['value'];
310
                }
311
312
                if ($this->debug) {
313
                    echo "\nfound ".count($utxos)." unspent outputs";
314
                }
315
316
                //increment for next batch
317
                $i += $increment;
318
            } while (count($utxos) > 0);
319
        }
320
321
        if ($this->debug) {
322
            echo "\nfinished fund discovery: $totalBalance Satoshi (in $totalUTXOs outputs) found when searching $totalAddressesGenerated addresses";
323
        }
324
325
        $this->sweepData = ['utxos' => $addressUTXOs, 'count' => $totalUTXOs, 'balance' => $totalBalance, 'addressesSearched' => $totalAddressesGenerated];
326
        return $this->sweepData;
327
    }
328
329
    /**
330
     * sweep the wallet of all funds and send to a single address
331
     *
332
     * @param string    $destinationAddress     address to receive found funds
333
     * @param int       $sweepBatchSize         number of addresses to search at a time
334
     * @return string                           HEX string of signed transaction
335
     * @throws \Exception
336
     */
337
    public function sweepWallet($destinationAddress, $sweepBatchSize = 200) {
338
        if ($this->debug) {
339
            echo "\nstarting wallet sweeping to address $destinationAddress";
340
        }
341
342
        //do wallet fund discovery
343
        if (!isset($this->sweepData)) {
344
            $this->discoverWalletFunds($sweepBatchSize);
345
        }
346
347
        if ($this->sweepData['balance'] === 0) {
348
            //no funds found
349
            throw new \Exception("No funds found after searching through {$this->sweepData['addressesSearched']} addresses");
350
        }
351
352
        //create and sign the transaction
353
        $transaction = $this->createTransaction($destinationAddress);
354
355
        //return or send the transaction
356
        return $transaction->getHex();
357
    }
358
359
    /**
360
     * @param $destinationAddress
361
     * @return TransactionInterface
362
     */
363
    protected function createTransaction($destinationAddress) {
364
        $txb = new TxBuilder();
365
366
        $signInfo = [];
367
        $utxos = [];
368
        foreach ($this->sweepData['utxos'] as $address => $data) {
369
            foreach ($data['utxos'] as $utxo) {
370
                $utxo = new UTXO(
371
                    $utxo['hash'],
372
                    $utxo['index'],
373
                    $utxo['value'],
374
                    AddressFactory::fromString($address),
375
                    ScriptFactory::fromHex($utxo['script_hex']),
376
                    $data['path'],
377
                    $data['redeem'],
378
                    $data['witness'],
379
                    SignInfo::MODE_SIGN
380
                );
381
382
                $utxos[] = $utxo;
383
                $signInfo[] = $utxo->getSignInfo();
384
            }
385
        }
386
387
        foreach ($utxos as $utxo) {
388
            $txb->spendOutPoint(new OutPoint(Buffer::hex($utxo->hash, 32), $utxo->index), $utxo->scriptPubKey);
389
        }
390
391
        if ($this->network === "bitcoincash") {
392
            $tAddress = $this->makeAddressReader(['use_cashaddr' => true])->fromString($destinationAddress);
393
            if ($tAddress instanceof CashAddress) {
394
                $destinationAddress = $tAddress->getLegacyAddress()->getAddress();
395
            }
396
        }
397
398
        $destAddress = $this->addressReader->fromString($destinationAddress);
399
        $vsizeEstimation = SizeEstimation::estimateVsize($utxos, [
400
            new TransactionOutput(0, $destAddress->getScriptPubKey())
401
        ]);
402
403
        $fee = Wallet::baseFeeForSize($vsizeEstimation);
404
405
        $txb->payToAddress($this->sweepData['balance'] - $fee, $destAddress);
406
407
        if ($this->debug) {
408
            echo "\nSigning transaction";
409
        }
410
411
        $tx = $this->signTransaction($txb->get(), $signInfo);
412
413
        return $tx;
414
    }
415
416
    /**
417
     * sign a raw transaction with the private keys that we have
418
     *
419
     * @param TransactionInterface $tx
420
     * @param SignInfo[]  $signInfo
421
     * @return TransactionInterface
422
     * @throws \Exception
423
     */
424
    protected function signTransaction(TransactionInterface $tx, array $signInfo) {
425
        $signer = new Signer($tx, Bitcoin::getEcAdapter());
426
427
        $sigHash = SigHash::ALL;
428
        if ($this->network === "bitcoincash") {
429
            $sigHash |= SigHash::BITCOINCASH;
430
            $signer->redeemBitcoinCash(true);
431
        }
432
433
        assert(Util::all(function ($signInfo) {
434
            return $signInfo instanceof SignInfo;
435
        }, $signInfo), '$signInfo should be SignInfo[]');
436
437
        foreach ($signInfo as $idx => $info) {
438
            if ($info->mode === SignInfo::MODE_SIGN) {
439
                $path = BIP32Path::path($info->path)->privatePath();
440
                $redeemScript = $info->redeemScript;
441
                $output = $info->output;
442
443
                $key = $this->primaryPrivateKey->buildKey($path)->key()->getPrivateKey();
444
                $backupKey = $this->backupPrivateKey->buildKey($path->unhardenedPath())->key()->getPrivateKey();
445
446
                $signData = (new SignData())
447
                    ->p2sh($redeemScript);
448
449
                if ($info->witnessScript) {
450
                    $signData->p2wsh($info->witnessScript);
451
                }
452
453
                $input = $signer->input($idx, $output, $signData);
454
455
                $input->sign($key, $sigHash);
456
                $input->sign($backupKey, $sigHash);
457
            }
458
        }
459
460
        return $signer->get();
461
    }
462
}
463