Passed
Pull Request — master (#129)
by thomas
26:40 queued 15:56
created

WalletSweeper::enableLogging()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

149
                /** @scrutinizer ignore-call */ 
150
                $useNetwork = BitcoinCashNetworkFactory::bitcoinRegtest();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
150
            } else if ($testnet) {
151
                $useNetwork = BitcoinCashNetworkFactory::bitcoinTestnet();
0 ignored issues
show
Bug introduced by
The method bitcoinTestnet() does not exist on Btccom\BitcoinCash\Network\NetworkFactory. Did you maybe mean bitcoinCashTestnet()? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

151
                /** @scrutinizer ignore-call */ 
152
                $useNetwork = BitcoinCashNetworkFactory::bitcoinTestnet();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
152
            } else {
153
                $useNetwork = BitcoinCashNetworkFactory::bitcoin();
0 ignored issues
show
Bug introduced by
The method bitcoin() does not exist on Btccom\BitcoinCash\Network\NetworkFactory. Did you maybe mean bitcoinCashRegtest()? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

153
                /** @scrutinizer ignore-call */ 
154
                $useNetwork = BitcoinCashNetworkFactory::bitcoin();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
154
            }
155
        }
156
157
        Bitcoin::setNetwork($useNetwork);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $useNetwork does not seem to be defined for all execution paths leading up to this point.
Loading history...
158
    }
159
160
    /**
161
     * enable debug info logging (just to console)
162
     */
163
    public function enableLogging() {
164
        $this->debug = true;
165
        $this->utxoFinder->enableLogging();
166
    }
167
168
    /**
169
     * disable debug info logging
170
     */
171
    public function disableLogging() {
172
        $this->debug = false;
173
        $this->utxoFinder->disableLogging();
174
    }
175
176
    /**
177
     * normalize network string
178
     *
179
     * @param $network
180
     * @param $testnet
181
     * @return array
182
     * @throws \Exception
183
     */
184
    protected function normalizeNetwork($network, $testnet) {
185
        return Util::normalizeNetwork($network, $testnet);
186
    }
187
188
    /**
189
     * generate multisig address for given path
190
     *
191
     * @param string|BIP32Path $path
192
     * @return array[string, ScriptInterface]                 [address, redeemScript]
0 ignored issues
show
Documentation Bug introduced by
The doc comment array[string, ScriptInterface] at position 1 could not be parsed: Expected ']' at position 1, but found '['.
Loading history...
193
     * @throws \Exception
194
     */
195
    protected function createAddress($path) {
196
        $path = BIP32Path::path($path)->publicPath();
197
198
        $multisig = ScriptFactory::scriptPubKey()->multisig(2, BlocktrailSDK::sortMultisigKeys([
199
            $this->primaryPrivateKey->buildKey($path)->publicKey(),
200
            $this->backupPrivateKey->buildKey($path->unhardenedPath())->publicKey(),
201
            $this->getBlocktrailPublicKey($path)->buildKey($path)->publicKey()
202
        ]), false);
203
204
        if ($this->network !== "bitcoincash" && (int) $path[2] === 2) {
205
            $witnessScript = new WitnessScript($multisig);
206
            $redeemScript = new P2shScript($witnessScript);
207
            $address = $this->addressReader->fromOutputScript($redeemScript->getOutputScript())->getAddress();
208
        } else {
209
            $witnessScript = null;
210
            $redeemScript = new P2shScript($multisig);
211
            $address = $this->addressReader->fromOutputScript($redeemScript->getOutputScript())->getAddress();
212
        }
213
214
        return [$address, $redeemScript, $witnessScript];
215
    }
216
217
    /**
218
     * create a batch of multisig addresses
219
     *
220
     * @param $start
221
     * @param $count
222
     * @param $keyIndex
223
     * @return array
224
     */
225
    protected function createBatchAddresses($start, $count, $keyIndex) {
226
        $addresses = array();
227
228
        for ($i = 0; $i < $count; $i++) {
229
            //create a path subsequent address
230
            $path = (string)WalletPath::create($keyIndex, $_chain = 0, $start+$i)->path()->publicPath();
231
            list($address, $redeem, $witness) = $this->createAddress($path);
232
            $addresses[$address] = array(
233
                //'address' => $address,
234
                'redeem' => $redeem,
235
                'witness' => $witness,
236
                'path' => $path
237
            );
238
239
            if ($this->debug) {
240
                echo ".";
241
            }
242
        }
243
244
        return $addresses;
245
    }
246
247
248
    /**
249
     * gets the blocktrail pub key for the given path from the stored array of pub keys
250
     *
251
     * @param string|BIP32Path  $path
252
     * @return BIP32Key
253
     * @throws \Exception
254
     */
255
    protected function getBlocktrailPublicKey($path) {
256
        $path = BIP32Path::path($path);
257
258
        $keyIndex = str_replace("'", "", $path[1]);
259
260
        if (!isset($this->blocktrailPublicKeys[$keyIndex])) {
261
            throw new \Exception("No blocktrail publickey for key index [{$keyIndex}]");
262
        }
263
264
        return $this->blocktrailPublicKeys[$keyIndex];
265
    }
266
267
    /**
268
     * discover funds in the wallet
269
     *
270
     * @param int $increment how many addresses to scan at a time
271
     * @return array
272
     * @throws BlocktrailSDKException
273
     */
274
    public function discoverWalletFunds($increment = 200) {
275
        $totalBalance = 0;
276
        $totalUTXOs = 0;
277
        $totalAddressesGenerated = 0;
278
279
        $addressUTXOs = array();    //addresses and their utxos, paths and redeem scripts
280
281
        //for each blocktrail pub key, do fund discovery on batches of addresses
282
        foreach ($this->blocktrailPublicKeys as $keyIndex => $blocktrailPubKey) {
283
            $i = 0;
284
            do {
285
                if ($this->debug) {
286
                    echo "\ngenerating $increment addresses using blocktrail key index $keyIndex\n";
287
                }
288
                $addresses = $this->createBatchAddresses($i, $increment, $keyIndex);
289
                $totalAddressesGenerated += count($addresses);
290
                if ($this->debug) {
291
                    echo "\nstarting fund discovery for $increment addresses";
292
                }
293
294
                //get the unspent outputs for this batch of addresses
295
                $utxos = $this->utxoFinder->getUTXOs(array_keys($addresses));
296
                //save the address utxos, along with relevant path and redeem script
297
                foreach ($utxos as $utxo) {
298
                    if (!isset($utxo['address'], $utxo['value'])) {
299
                        throw new BlocktrailSDKException("Missing data");
300
                    }
301
302
                    $address = $utxo['address'];
303
304
                    if (!isset($addressUTXOs[$address])) {
305
                        $addressUTXOs[$address] = [
306
                            'path' =>  $addresses[$address]['path'],
307
                            'redeem' =>  $addresses[$address]['redeem'],
308
                            'witness' =>  $addresses[$address]['witness'],
309
                            'utxos' =>  [],
310
                        ];
311
                    }
312
313
                    $addressUTXOs[$address]['utxos'][] = $utxo;
314
                    $totalUTXOs ++;
315
316
                    //add up the total utxo value for all addresses
317
                    $totalBalance += $utxo['value'];
318
                }
319
320
                if ($this->debug) {
321
                    echo "\nfound ".count($utxos)." unspent outputs";
322
                }
323
324
                //increment for next batch
325
                $i += $increment;
326
            } while (count($utxos) > 0);
327
        }
328
329
        if ($this->debug) {
330
            echo "\nfinished fund discovery: $totalBalance Satoshi (in $totalUTXOs outputs) found when searching $totalAddressesGenerated addresses";
331
        }
332
333
        $this->sweepData = ['utxos' => $addressUTXOs, 'count' => $totalUTXOs, 'balance' => $totalBalance, 'addressesSearched' => $totalAddressesGenerated];
334
        return $this->sweepData;
335
    }
336
337
    /**
338
     * sweep the wallet of all funds and send to a single address
339
     *
340
     * @param string    $destinationAddress     address to receive found funds
341
     * @param int       $sweepBatchSize         number of addresses to search at a time
342
     * @return string                           HEX string of signed transaction
343
     * @throws \Exception
344
     */
345
    public function sweepWallet($destinationAddress, $sweepBatchSize = 200) {
346
        if ($this->debug) {
347
            echo "\nstarting wallet sweeping to address $destinationAddress";
348
        }
349
350
        //do wallet fund discovery
351
        if (!isset($this->sweepData)) {
352
            $this->discoverWalletFunds($sweepBatchSize);
353
        }
354
355
        if ($this->sweepData['balance'] === 0) {
356
            //no funds found
357
            throw new \Exception("No funds found after searching through {$this->sweepData['addressesSearched']} addresses");
358
        }
359
360
        //create and sign the transaction
361
        $transaction = $this->createTransaction($destinationAddress);
362
363
        //return or send the transaction
364
        return $transaction->getHex();
365
    }
366
367
    /**
368
     * @param $destinationAddress
369
     * @return TransactionInterface
370
     */
371
    protected function createTransaction($destinationAddress) {
372
        $txb = new TxBuilder();
373
374
        $signInfo = [];
375
        $utxos = [];
376
        foreach ($this->sweepData['utxos'] as $address => $data) {
377
            foreach ($data['utxos'] as $utxo) {
378
                $utxo = new UTXO(
379
                    $utxo['hash'],
380
                    $utxo['index'],
381
                    $utxo['value'],
382
                    $this->addressReader->fromString($address),
383
                    ScriptFactory::fromHex($utxo['script_hex']),
384
                    $data['path'],
385
                    $data['redeem'],
386
                    $data['witness'],
387
                    SignInfo::MODE_SIGN
388
                );
389
390
                $utxos[] = $utxo;
391
                $signInfo[] = $utxo->getSignInfo();
392
            }
393
        }
394
395
        foreach ($utxos as $utxo) {
396
            $txb->spendOutPoint(new OutPoint(Buffer::hex($utxo->hash, 32), $utxo->index), $utxo->scriptPubKey);
397
        }
398
399
        if ($this->network === "bitcoincash") {
400
            $tAddress = $this->makeAddressReader(['use_cashaddr' => true])->fromString($destinationAddress);
401
            if ($tAddress instanceof CashAddress) {
402
                $destinationAddress = $tAddress->getLegacyAddress()->getAddress();
403
            }
404
        }
405
        $destAddress = $this->addressReader->fromString($destinationAddress);
406
        $vsizeEstimation = SizeEstimation::estimateVsize($utxos, [
407
            new TransactionOutput(0, $destAddress->getScriptPubKey())
408
        ]);
409
410
        $fee = Wallet::baseFeeForSize($vsizeEstimation);
411
412
        $txb->payToAddress($this->sweepData['balance'] - $fee, $destAddress);
413
414
        if ($this->debug) {
415
            echo "\nSigning transaction";
416
        }
417
418
        $tx = $this->signTransaction($txb->get(), $signInfo);
419
420
        return $tx;
421
    }
422
423
    /**
424
     * sign a raw transaction with the private keys that we have
425
     *
426
     * @param TransactionInterface $tx
427
     * @param SignInfo[]  $signInfo
428
     * @return TransactionInterface
429
     * @throws \Exception
430
     */
431
    protected function signTransaction(TransactionInterface $tx, array $signInfo) {
432
        $ecAdapter = Bitcoin::getEcAdapter();
433
        $signer = new Signer($tx, $ecAdapter);
434
435
        $sigHash = SigHash::ALL;
436
        if ($this->network === "bitcoincash") {
437
            $sigHash |= BchSigHash::BITCOINCASH;
438
            $signer->setCheckerCreator(BchCheckerCreator::fromEcAdapter($ecAdapter));
439
        }
440
441
        assert(Util::all(function ($signInfo) {
442
            return $signInfo instanceof SignInfo;
443
        }, $signInfo), '$signInfo should be SignInfo[]');
444
445
        foreach ($signInfo as $idx => $info) {
446
            if ($info->mode === SignInfo::MODE_SIGN) {
447
                $path = BIP32Path::path($info->path)->privatePath();
448
                $redeemScript = $info->redeemScript;
449
                $output = $info->output;
450
451
                $key = $this->primaryPrivateKey->buildKey($path)->key()->getPrivateKey();
452
                $backupKey = $this->backupPrivateKey->buildKey($path->unhardenedPath())->key()->getPrivateKey();
453
454
                $signData = (new SignData())
455
                    ->p2sh($redeemScript);
456
457
                if ($info->witnessScript) {
458
                    $signData->p2wsh($info->witnessScript);
459
                }
460
461
                $input = $signer->input($idx, $output, $signData);
462
463
                $input->sign($key, $sigHash);
464
                $input->sign($backupKey, $sigHash);
465
            }
466
        }
467
468
        return $signer->get();
469
    }
470
}
471