Completed
Branch master (5ab745)
by
unknown
05:26
created

WalletSweeper::enableLogging()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
eloc 3
nc 1
nop 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\Transaction\Factory\SignData;
12
use BitWasp\Bitcoin\Transaction\Factory\Signer;
13
use BitWasp\Bitcoin\Transaction\Factory\TxBuilder;
14
use BitWasp\Bitcoin\Transaction\OutPoint;
15
use BitWasp\Bitcoin\Transaction\TransactionInterface;
16
use BitWasp\Bitcoin\Transaction\TransactionOutput;
17
use BitWasp\Buffertools\Buffer;
18
use BitWasp\Buffertools\BufferInterface;
19
use Blocktrail\SDK\Bitcoin\BIP32Key;
20
use Blocktrail\SDK\Bitcoin\BIP32Path;
21
use Blocktrail\SDK\Exceptions\BlocktrailSDKException;
22
23
abstract class WalletSweeper {
24
25
    /**
26
     * network to use - currently only supporting 'bitcoin'
27
     * @var string
28
     */
29
    protected $network;
30
31
    /**
32
     * using testnet or not
33
     * @var bool
34
     */
35
    protected $testnet;
36
37
    /**
38
     * backup private key
39
     * @var BIP32Key
40
     */
41
    protected $backupPrivateKey;
42
43
    /**
44
     * primary private key
45
     * @var BIP32Key
46
     */
47
    protected $primaryPrivateKey;
48
49
    /**
50
     * blocktrail public keys, mapped to their relevant keyIndex
51
     * @var
52
     */
53
    protected $blocktrailPublicKeys;
54
55
    /**
56
     * gets unspent outputs for addresses
57
     * @var UnspentOutputFinder
58
     */
59
    protected $utxoFinder;
60
61
    /**
62
     * holds wallet addresses, along with path, redeem script, and discovered unspent outputs
63
     * @var array
64
     */
65
    protected $sweepData;
66
67
    /**
68
     * process logging for debugging
69
     * @var bool
70
     */
71
    protected $debug = false;
72
73
    /**
74
     * @param BufferInterface     $primarySeed
75
     * @param BufferInterface     $backupSeed
76
     * @param array               $blocktrailPublicKeys =
77
     * @param UnspentOutputFinder $utxoFinder
78
     * @param string              $network
79
     * @param bool                $testnet
80
     * @throws \Exception
81
     */
82
    public function __construct(BufferInterface $primarySeed, BufferInterface $backupSeed, array $blocktrailPublicKeys, UnspentOutputFinder $utxoFinder, $network = 'btc', $testnet = false) {
83
        // normalize network and set bitcoinlib to the right magic-bytes
84
        list($this->network, $this->testnet) = $this->normalizeNetwork($network, $testnet);
85
86
        assert($this->network == "bitcoin");
87
        Bitcoin::setNetwork($this->testnet ? NetworkFactory::bitcoinTestnet() : NetworkFactory::bitcoin());
88
89
        //create BIP32 keys for the Blocktrail public keys
90
        foreach ($blocktrailPublicKeys as $blocktrailKey) {
91
            $this->blocktrailPublicKeys[$blocktrailKey['keyIndex']] = BlocktrailSDK::normalizeBIP32Key([$blocktrailKey['pubkey'], $blocktrailKey['path']]);
92
        }
93
94
        $this->utxoFinder = $utxoFinder;
95
96
        $this->primaryPrivateKey = BIP32Key::create(HierarchicalKeyFactory::fromEntropy($primarySeed), "m");
97
        $this->backupPrivateKey = BIP32Key::create(HierarchicalKeyFactory::fromEntropy($backupSeed), "m");
98
    }
99
100
    /**
101
     * enable debug info logging (just to console)
102
     */
103
    public function enableLogging() {
104
        $this->debug = true;
105
        $this->utxoFinder->enableLogging();
106
    }
107
108
    /**
109
     * disable debug info logging
110
     */
111
    public function disableLogging() {
112
        $this->debug = false;
113
        $this->utxoFinder->disableLogging();
114
    }
115
116
117
    /**
118
     * normalize network string
119
     *
120
     * @param $network
121
     * @param $testnet
122
     * @return array
123
     * @throws \Exception
124
     */
125 View Code Duplication
    protected function normalizeNetwork($network, $testnet) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
126
        switch (strtolower($network)) {
127
            case 'btc':
128
            case 'bitcoin':
129
                $network = 'bitcoin';
130
131
                break;
132
133
            case 'tbtc':
134
            case 'bitcoin-testnet':
135
                $network = 'bitcoin';
136
                $testnet = true;
137
138
                break;
139
140
            default:
141
                throw new \Exception("Unknown network [{$network}]");
142
        }
143
144
        return [$network, $testnet];
145
    }
146
147
148
    /**
149
     * generate multisig address for given path
150
     *
151
     * @param string|BIP32Path $path
152
     * @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...
153
     * @throws \Exception
154
     */
155
    protected function createAddress($path) {
156
        $path = BIP32Path::path($path)->publicPath();
157
158
        $redeemScript = ScriptFactory::scriptPubKey()->multisig(2, BlocktrailSDK::sortMultisigKeys([
159
            $this->primaryPrivateKey->buildKey($path)->publicKey(),
160
            $this->backupPrivateKey->buildKey($path->unhardenedPath())->publicKey(),
161
            $this->getBlocktrailPublicKey($path)->buildKey($path)->publicKey()
162
        ]), false);
163
164
        return [(new P2shScript($redeemScript))->getAddress()->getAddress(), $redeemScript];
165
    }
166
167
    /**
168
     * create a batch of multisig addresses
169
     *
170
     * @param $start
171
     * @param $count
172
     * @param $keyIndex
173
     * @return array
174
     */
175
    protected function createBatchAddresses($start, $count, $keyIndex) {
176
        $addresses = array();
177
178
        for ($i = 0; $i < $count; $i++) {
179
            //create a path subsequent address
180
            $path = (string)WalletPath::create($keyIndex, $_chain = 0, $start+$i)->path()->publicPath();
181
            list($address, $redeem) = $this->createAddress($path);
182
            $addresses[$address] = array(
183
                //'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...
184
                'redeem' => $redeem,
185
                'path' => $path
186
            );
187
188
            if ($this->debug) {
189
                echo ".";
190
            }
191
        }
192
193
        return $addresses;
194
    }
195
196
197
    /**
198
     * gets the blocktrail pub key for the given path from the stored array of pub keys
199
     *
200
     * @param string|BIP32Path  $path
201
     * @return BIP32Key
202
     * @throws \Exception
203
     */
204 View Code Duplication
    protected function getBlocktrailPublicKey($path) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
205
        $path = BIP32Path::path($path);
206
207
        $keyIndex = str_replace("'", "", $path[1]);
208
209
        if (!isset($this->blocktrailPublicKeys[$keyIndex])) {
210
            throw new \Exception("No blocktrail publickey for key index [{$keyIndex}]");
211
        }
212
213
        return $this->blocktrailPublicKeys[$keyIndex];
214
    }
215
216
    /**
217
     * discover funds in the wallet
218
     *
219
     * @param int $increment how many addresses to scan at a time
220
     * @return array
221
     * @throws BlocktrailSDKException
222
     */
223
    public function discoverWalletFunds($increment = 200) {
224
        $totalBalance = 0;
225
        $totalUTXOs = 0;
226
        $totalAddressesGenerated = 0;
227
228
        $addressUTXOs = array();    //addresses and their utxos, paths and redeem scripts
229
230
        //for each blocktrail pub key, do fund discovery on batches of addresses
231
        foreach ($this->blocktrailPublicKeys as $keyIndex => $blocktrailPubKey) {
232
            $i = 0;
233
            do {
234
                if ($this->debug) {
235
                    echo "\ngenerating $increment addresses using blocktrail key index $keyIndex\n";
236
                }
237
                $addresses = $this->createBatchAddresses($i, $increment, $keyIndex);
238
                $totalAddressesGenerated += count($addresses);
239
240
                if ($this->debug) {
241
                    echo "\nstarting fund discovery for $increment addresses";
242
                }
243
244
                //get the unspent outputs for this batch of addresses
245
                $utxos = $this->utxoFinder->getUTXOs(array_keys($addresses));
246
                //save the address utxos, along with relevant path and redeem script
247
                foreach ($utxos as $utxo) {
248
                    if (!isset($utxo['address'], $utxo['value'])) {
249
                        throw new BlocktrailSDKException("Missing data");
250
                    }
251
252
                    $address = $utxo['address'];
253
254
                    if (!isset($addressUTXOs[$address])) {
255
                        $addressUTXOs[$address] = [
256
                            'path' =>  $addresses[$address]['path'],
257
                            'redeem' =>  $addresses[$address]['redeem'],
258
                            'utxos' =>  [],
259
                        ];
260
                    }
261
262
                    $addressUTXOs[$address]['utxos'][] = $utxo;
263
                    $totalUTXOs ++;
264
265
                    //add up the total utxo value for all addresses
266
                    $totalBalance += $utxo['value'];
267
                }
268
269
                if ($this->debug) {
270
                    echo "\nfound ".count($utxos)." unspent outputs";
271
                }
272
273
                //increment for next batch
274
                $i += $increment;
275
            } while (count($utxos) > 0);
276
        }
277
278
        if ($this->debug) {
279
            echo "\nfinished fund discovery: $totalBalance Satoshi (in $totalUTXOs outputs) found when searching $totalAddressesGenerated addresses";
280
        }
281
282
        $this->sweepData = ['utxos' => $addressUTXOs, 'count' => $totalUTXOs, 'balance' => $totalBalance, 'addressesSearched' => $totalAddressesGenerated];
283
        return $this->sweepData;
284
    }
285
286
    /**
287
     * sweep the wallet of all funds and send to a single address
288
     *
289
     * @param string    $destinationAddress     address to receive found funds
290
     * @param int       $sweepBatchSize         number of addresses to search at a time
291
     * @return string                           HEX string of signed transaction
292
     * @throws \Exception
293
     */
294
    public function sweepWallet($destinationAddress, $sweepBatchSize = 200) {
295
        if ($this->debug) {
296
            echo "\nstarting wallet sweeping to address $destinationAddress";
297
        }
298
299
        //do wallet fund discovery
300
        if (!isset($this->sweepData)) {
301
            $this->discoverWalletFunds($sweepBatchSize);
302
        }
303
304
        if ($this->sweepData['balance'] === 0) {
305
            //no funds found
306
            throw new \Exception("No funds found after searching through {$this->sweepData['addressesSearched']} addresses");
307
        }
308
309
        //create and sign the transaction
310
        $transaction = $this->createTransaction($destinationAddress);
311
312
        //return or send the transaction
313
        return $transaction->getHex();
314
    }
315
316
    /**
317
     * @param $destinationAddress
318
     * @return TransactionInterface
319
     */
320
    protected function createTransaction($destinationAddress) {
321
        $txb = new TxBuilder();
322
323
        $signInfo = [];
324
        $utxos = [];
325
        foreach ($this->sweepData['utxos'] as $address => $data) {
326
            foreach ($data['utxos'] as $utxo) {
327
                $utxo = new UTXO(
328
                    $utxo['hash'],
329
                    $utxo['index'],
330
                    $utxo['value'],
331
                    AddressFactory::fromString($address),
332
                    ScriptFactory::fromHex($utxo['script_hex']),
333
                    $data['path'],
334
                    $data['redeem']
335
                );
336
337
                $utxos[] = $utxo;
338
                $signInfo[] = new SignInfo($utxo->path, $utxo->redeemScript, new TransactionOutput($utxo->value, $utxo->scriptPubKey));
339
            }
340
        }
341
342
        foreach ($utxos as $utxo) {
343
            $txb->spendOutPoint(new OutPoint(Buffer::hex($utxo->hash), $utxo->index), $utxo->scriptPubKey);
344
        }
345
346
        $fee = Wallet::estimateFee($this->sweepData['count'], 1);
347
348
        $txb->payToAddress($this->sweepData['balance'] - $fee, AddressFactory::fromString($destinationAddress));
349
350
351
        if ($this->debug) {
352
            echo "\nSigning transaction";
353
        }
354
355
        $tx = $this->signTransaction($txb->get(), $signInfo);
356
357
        return $tx;
358
    }
359
360
    /**
361
     * sign a raw transaction with the private keys that we have
362
     *
363
     * @param TransactionInterface $tx
364
     * @param SignInfo[]  $signInfo
365
     * @return TransactionInterface
366
     * @throws \Exception
367
     */
368
    protected function signTransaction(TransactionInterface $tx, array $signInfo) {
369
        $signer = new Signer($tx, Bitcoin::getEcAdapter());
370
371
        assert(Util::all(function ($signInfo) {
372
            return $signInfo instanceof SignInfo;
373
        }, $signInfo), '$signInfo should be SignInfo[]');
374
375
        foreach ($signInfo as $idx => $info) {
376
            $path = BIP32Path::path($info->path)->privatePath();
377
            $redeemScript = $info->redeemScript;
378
            $output = $info->output;
379
380
            $key = $this->primaryPrivateKey->buildKey($path)->key()->getPrivateKey();
381
            $backupKey = $this->backupPrivateKey->buildKey($path->unhardenedPath())->key()->getPrivateKey();
382
383
            $signData = new SignData();
384
            $signData->p2sh($redeemScript);
385
            $input = $signer->input($idx, $output, $signData);
386
387
            $input->sign($key);
388
            $input->sign($backupKey);
389
        }
390
391
        return $signer->get();
392
    }
393
}
394