Completed
Pull Request — master (#89)
by thomas
20:32
created

WalletSweeper   D

Complexity

Total Complexity 36

Size/Duplication

Total Lines 353
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 27

Test Coverage

Coverage 0%

Importance

Changes 0
Metric Value
dl 0
loc 353
ccs 0
cts 178
cp 0
rs 4.4
c 0
b 0
f 0
wmc 36
lcom 1
cbo 27

10 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 15 3
A enableLogging() 0 4 1
A disableLogging() 0 4 1
A createAddress() 0 21 3
A createBatchAddresses() 0 21 3
A getBlocktrailPublicKey() 0 11 2
C discoverWalletFunds() 0 63 10
A sweepWallet() 0 21 4
B createTransaction() 0 40 5
B signTransaction() 0 34 4
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\Network\NetworkInterface;
10
use BitWasp\Bitcoin\Script\P2shScript;
11
use BitWasp\Bitcoin\Script\ScriptFactory;
12
use BitWasp\Bitcoin\Script\WitnessScript;
13
use BitWasp\Bitcoin\Transaction\Factory\SignData;
14
use BitWasp\Bitcoin\Transaction\Factory\Signer;
15
use BitWasp\Bitcoin\Transaction\Factory\TxBuilder;
16
use BitWasp\Bitcoin\Transaction\OutPoint;
17
use BitWasp\Bitcoin\Transaction\SignatureHash\SigHash;
18
use BitWasp\Bitcoin\Transaction\TransactionInterface;
19
use BitWasp\Buffertools\Buffer;
20
use BitWasp\Buffertools\BufferInterface;
21
use Blocktrail\SDK\Bitcoin\BIP32Key;
22
use Blocktrail\SDK\Bitcoin\BIP32Path;
23
use Blocktrail\SDK\Exceptions\BlocktrailSDKException;
24
25
abstract class WalletSweeper {
26
27
    /**
28
     * network to use - currently only supporting 'bitcoin'
29
     * @var NetworkParams
30
     */
31
    protected $networkParams;
32
33
    /**
34
     * backup private key
35
     * @var BIP32Key
36
     */
37
    protected $backupPrivateKey;
38
39
    /**
40
     * primary private key
41
     * @var BIP32Key
42
     */
43
    protected $primaryPrivateKey;
44
45
    /**
46
     * blocktrail public keys, mapped to their relevant keyIndex
47
     * @var
48
     */
49
    protected $blocktrailPublicKeys;
50
51
    /**
52
     * gets unspent outputs for addresses
53
     * @var UnspentOutputFinder
54
     */
55
    protected $utxoFinder;
56
57
    /**
58
     * holds wallet addresses, along with path, redeem script, and discovered unspent outputs
59
     * @var array
60
     */
61
    protected $sweepData;
62
63
    /**
64
     * process logging for debugging
65
     * @var bool
66
     */
67
    protected $debug = false;
68
69
    /**
70
     * @param BufferInterface     $primarySeed
71
     * @param BufferInterface     $backupSeed
72
     * @param array               $blocktrailPublicKeys =
73
     * @param UnspentOutputFinder $utxoFinder
74
     * @param string $network
75
     * @param bool $testnet
0 ignored issues
show
Documentation introduced by
There is no parameter named $testnet. Did you maybe mean $tesnet?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function. It has, however, found a similar but not annotated parameter which might be a good fit.

Consider the following example. The parameter $ireland is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $ireland
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was changed, but the annotation was not.

Loading history...
76
     * @throws \Exception
77
     */
78
    public function __construct(BufferInterface $primarySeed, BufferInterface $backupSeed, array $blocktrailPublicKeys, UnspentOutputFinder $utxoFinder, $network, $tesnet = false) {
79
80
        $params = Util::normalizeNetwork($network, $tesnet);
81
        assert($params->isNetwork("bitcoin") || $params->isNetwork("bitcoincash"));
82
83
        $network = $params->getNetwork();
84
        //create BIP32 keys for the Blocktrail public keys
85
        foreach ($blocktrailPublicKeys as $blocktrailKey) {
86
            $this->blocktrailPublicKeys[$blocktrailKey['keyIndex']] = BlocktrailSDK::normalizeBIP32Key([$blocktrailKey['pubkey'], $blocktrailKey['path']], $network);
87
        }
88
89
        $this->utxoFinder = $utxoFinder;
90
        $this->primaryPrivateKey = BIP32Key::create($network, HierarchicalKeyFactory::fromEntropy($primarySeed), "m");
91
        $this->backupPrivateKey = BIP32Key::create($network, HierarchicalKeyFactory::fromEntropy($backupSeed), "m");
92
    }
93
94
    /**
95
     * enable debug info logging (just to console)
96
     */
97
    public function enableLogging() {
98
        $this->debug = true;
99
        $this->utxoFinder->enableLogging();
100
    }
101
102
    /**
103
     * disable debug info logging
104
     */
105
    public function disableLogging() {
106
        $this->debug = false;
107
        $this->utxoFinder->disableLogging();
108
    }
109
110
    /**
111
     * generate multisig address for given path
112
     *
113
     * @param string|BIP32Path $path
114
     * @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...
115
     * @throws \Exception
116
     */
117
    protected function createAddress($path) {
118
        $path = BIP32Path::path($path)->publicPath();
119
120
        $multisig = ScriptFactory::scriptPubKey()->multisig(2, BlocktrailSDK::sortMultisigKeys([
121
            $this->primaryPrivateKey->buildKey($path)->publicKey(),
122
            $this->backupPrivateKey->buildKey($path->unhardenedPath())->publicKey(),
123
            $this->getBlocktrailPublicKey($path)->buildKey($path)->publicKey()
124
        ]), false);
125
126
        if ($this->networkParams->getName() !== "bitcoincash" && (int) $path[2] === 2) {
127
            $witnessScript = new WitnessScript($multisig);
128
            $redeemScript = new P2shScript($witnessScript);
129
            $address = $redeemScript->getAddress()->getAddress($this->networkParams->getNetwork());
130
        } else {
131
            $witnessScript = null;
132
            $redeemScript = new P2shScript($multisig);
133
            $address = $redeemScript->getAddress()->getAddress($this->networkParams->getNetwork());
134
        }
135
136
        return [$address, $redeemScript, $witnessScript];
137
    }
138
139
    /**
140
     * create a batch of multisig addresses
141
     *
142
     * @param $start
143
     * @param $count
144
     * @param $keyIndex
145
     * @return array
146
     */
147
    protected function createBatchAddresses($start, $count, $keyIndex) {
148
        $addresses = array();
149
150
        for ($i = 0; $i < $count; $i++) {
151
            //create a path subsequent address
152
            $path = (string)WalletPath::create($keyIndex, $_chain = 0, $start+$i)->path()->publicPath();
153
            list($address, $redeem, $witness) = $this->createAddress($path);
154
            $addresses[$address] = array(
155
                //'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...
156
                'redeem' => $redeem,
157
                'witness' => $witness,
158
                'path' => $path
159
            );
160
161
            if ($this->debug) {
162
                echo ".";
163
            }
164
        }
165
166
        return $addresses;
167
    }
168
169
170
    /**
171
     * gets the blocktrail pub key for the given path from the stored array of pub keys
172
     *
173
     * @param string|BIP32Path  $path
174
     * @return BIP32Key
175
     * @throws \Exception
176
     */
177
    protected function getBlocktrailPublicKey($path) {
178
        $path = BIP32Path::path($path);
179
180
        $keyIndex = str_replace("'", "", $path[1]);
181
182
        if (!isset($this->blocktrailPublicKeys[$keyIndex])) {
183
            throw new \Exception("No blocktrail publickey for key index [{$keyIndex}]");
184
        }
185
186
        return $this->blocktrailPublicKeys[$keyIndex];
187
    }
188
189
    /**
190
     * discover funds in the wallet
191
     *
192
     * @param int $increment how many addresses to scan at a time
193
     * @return array
194
     * @throws BlocktrailSDKException
195
     */
196
    public function discoverWalletFunds($increment = 200) {
197
        $totalBalance = 0;
198
        $totalUTXOs = 0;
199
        $totalAddressesGenerated = 0;
200
201
        $addressUTXOs = array();    //addresses and their utxos, paths and redeem scripts
202
203
        //for each blocktrail pub key, do fund discovery on batches of addresses
204
        foreach ($this->blocktrailPublicKeys as $keyIndex => $blocktrailPubKey) {
205
            $i = 0;
206
            do {
207
                if ($this->debug) {
208
                    echo "\ngenerating $increment addresses using blocktrail key index $keyIndex\n";
209
                }
210
                $addresses = $this->createBatchAddresses($i, $increment, $keyIndex);
211
                $totalAddressesGenerated += count($addresses);
212
213
                if ($this->debug) {
214
                    echo "\nstarting fund discovery for $increment addresses";
215
                }
216
217
                //get the unspent outputs for this batch of addresses
218
                $utxos = $this->utxoFinder->getUTXOs(array_keys($addresses));
219
                //save the address utxos, along with relevant path and redeem script
220
                foreach ($utxos as $utxo) {
221
                    if (!isset($utxo['address'], $utxo['value'])) {
222
                        throw new BlocktrailSDKException("Missing data");
223
                    }
224
225
                    $address = $utxo['address'];
226
227
                    if (!isset($addressUTXOs[$address])) {
228
                        $addressUTXOs[$address] = [
229
                            'path' =>  $addresses[$address]['path'],
230
                            'redeem' =>  $addresses[$address]['redeem'],
231
                            'witness' =>  $addresses[$address]['witness'],
232
                            'utxos' =>  [],
233
                        ];
234
                    }
235
236
                    $addressUTXOs[$address]['utxos'][] = $utxo;
237
                    $totalUTXOs ++;
238
239
                    //add up the total utxo value for all addresses
240
                    $totalBalance += $utxo['value'];
241
                }
242
243
                if ($this->debug) {
244
                    echo "\nfound ".count($utxos)." unspent outputs";
245
                }
246
247
                //increment for next batch
248
                $i += $increment;
249
            } while (count($utxos) > 0);
250
        }
251
252
        if ($this->debug) {
253
            echo "\nfinished fund discovery: $totalBalance Satoshi (in $totalUTXOs outputs) found when searching $totalAddressesGenerated addresses";
254
        }
255
256
        $this->sweepData = ['utxos' => $addressUTXOs, 'count' => $totalUTXOs, 'balance' => $totalBalance, 'addressesSearched' => $totalAddressesGenerated];
257
        return $this->sweepData;
258
    }
259
260
    /**
261
     * sweep the wallet of all funds and send to a single address
262
     *
263
     * @param string    $destinationAddress     address to receive found funds
264
     * @param int       $sweepBatchSize         number of addresses to search at a time
265
     * @return string                           HEX string of signed transaction
266
     * @throws \Exception
267
     */
268
    public function sweepWallet($destinationAddress, $sweepBatchSize = 200) {
269
        if ($this->debug) {
270
            echo "\nstarting wallet sweeping to address $destinationAddress";
271
        }
272
273
        //do wallet fund discovery
274
        if (!isset($this->sweepData)) {
275
            $this->discoverWalletFunds($sweepBatchSize);
276
        }
277
278
        if ($this->sweepData['balance'] === 0) {
279
            //no funds found
280
            throw new \Exception("No funds found after searching through {$this->sweepData['addressesSearched']} addresses");
281
        }
282
283
        //create and sign the transaction
284
        $transaction = $this->createTransaction($destinationAddress);
285
286
        //return or send the transaction
287
        return $transaction->getHex();
288
    }
289
290
    /**
291
     * @param $destinationAddress
292
     * @return TransactionInterface
293
     */
294
    protected function createTransaction($destinationAddress) {
295
        $txb = new TxBuilder();
296
297
        $signInfo = [];
298
        $utxos = [];
299
        foreach ($this->sweepData['utxos'] as $address => $data) {
300
            foreach ($data['utxos'] as $utxo) {
301
                $utxo = new UTXO(
302
                    $utxo['hash'],
303
                    $utxo['index'],
304
                    $utxo['value'],
305
                    AddressFactory::fromString($address),
306
                    ScriptFactory::fromHex($utxo['script_hex']),
307
                    $data['path'],
308
                    $data['redeem'],
309
                    $data['witness'],
310
                    SignInfo::MODE_SIGN
311
                );
312
313
                $utxos[] = $utxo;
314
                $signInfo[] = $utxo->getSignInfo();
315
            }
316
        }
317
318
        foreach ($utxos as $utxo) {
319
            $txb->spendOutPoint(new OutPoint(Buffer::hex($utxo->hash, 32), $utxo->index), $utxo->scriptPubKey);
320
        }
321
322
        $fee = Wallet::estimateFee($this->sweepData['count'], 1);
323
324
        $txb->payToAddress($this->sweepData['balance'] - $fee, AddressFactory::fromString($destinationAddress));
325
326
        if ($this->debug) {
327
            echo "\nSigning transaction";
328
        }
329
330
        $tx = $this->signTransaction($txb->get(), $signInfo);
331
332
        return $tx;
333
    }
334
335
    /**
336
     * sign a raw transaction with the private keys that we have
337
     *
338
     * @param TransactionInterface $tx
339
     * @param SignInfo[]  $signInfo
340
     * @return TransactionInterface
341
     * @throws \Exception
342
     */
343
    protected function signTransaction(TransactionInterface $tx, array $signInfo) {
344
        $signer = new Signer($tx, Bitcoin::getEcAdapter());
345
346
        $sigHash = SigHash::ALL;
347
        if ($this->network === "bitcoincash") {
0 ignored issues
show
Bug introduced by
The property network does not seem to exist. Did you mean networkParams?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
348
            $sigHash |= SigHash::BITCOINCASH;
349
            $signer->redeemBitcoinCash(true);
350
        }
351
352
        assert(Util::all(function ($signInfo) {
353
            return $signInfo instanceof SignInfo;
354
        }, $signInfo), '$signInfo should be SignInfo[]');
355
356
        foreach ($signInfo as $idx => $info) {
357
            if ($info->mode === SignInfo::MODE_SIGN) {
358
                $path = BIP32Path::path($info->path)->privatePath();
359
                $redeemScript = $info->redeemScript;
360
                $output = $info->output;
361
362
                $key = $this->primaryPrivateKey->buildKey($path)->key()->getPrivateKey();
363
                $backupKey = $this->backupPrivateKey->buildKey($path->unhardenedPath())->key()->getPrivateKey();
364
365
                $signData = (new SignData())
366
                    ->p2sh($redeemScript);
367
368
                $input = $signer->input($idx, $output, $signData);
369
370
                $input->sign($key, $sigHash);
371
                $input->sign($backupKey, $sigHash);
372
            }
373
        }
374
375
        return $signer->get();
376
    }
377
}
378