Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
42.91% covered (danger)
42.91%
112 / 261
31.58% covered (danger)
31.58%
6 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
API
42.91% covered (danger)
42.91%
112 / 261
31.58% covered (danger)
31.58%
6 / 19
661.49
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 is_bitcoin_gateway
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 get_bitcoin_gateways
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 is_order_has_bitcoin_gateway
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
4.59
 get_fresh_address_for_order
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 get_fresh_addresses_for_gateway
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
2.09
 is_fresh_address_available_for_gateway
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_order_details
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 refresh_order
60.53% covered (warning)
60.53%
46 / 76
0.00% covered (danger)
0.00%
0 / 1
26.06
 get_formatted_order_details
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 get_exchange_rate
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
2.50
 convert_fiat_to_btc
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 generate_new_wallet
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 generate_new_addresses
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
42
 generate_new_addresses_for_wallet
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
30
 check_new_addresses_for_transactions
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 check_addresses_for_transactions
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 update_address_transactions
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 is_server_has_dependencies
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Main plugin functions for:
4 * * checking is a gateway a Bitcoin gateway
5 * * generating new wallets
6 * * converting fiat<->BTC
7 * * generating/getting new addresses for orders
8 * * checking addresses for transactions
9 * * getting order details for display
10 *
11 * @package    brianhenryie/bh-wp-bitcoin-gateway
12 */
13
14namespace BrianHenryIE\WP_Bitcoin_Gateway\API;
15
16use BrianHenryIE\WP_Bitcoin_Gateway\API\Blockchain\Blockstream_Info_API;
17use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Bitcoin_Order;
18use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Bitcoin_Order_Interface;
19use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Transaction_Interface;
20use DateTimeImmutable;
21use DateTimeZone;
22use Exception;
23use BrianHenryIE\WP_Bitcoin_Gateway\Action_Scheduler\Background_Jobs;
24use BrianHenryIE\WP_Bitcoin_Gateway\API\Addresses\Bitcoin_Address;
25use BrianHenryIE\WP_Bitcoin_Gateway\API\Addresses\Bitcoin_Address_Factory;
26use BrianHenryIE\WP_Bitcoin_Gateway\API\Addresses\Bitcoin_Wallet;
27use BrianHenryIE\WP_Bitcoin_Gateway\API\Addresses\Bitcoin_Wallet_Factory;
28use BrianHenryIE\WP_Bitcoin_Gateway\API\Exchange_Rate\Bitfinex_API;
29use BrianHenryIE\WP_Bitcoin_Gateway\API\Addresses\BitWasp_API;
30use BrianHenryIE\WP_Bitcoin_Gateway\API_Interface;
31use BrianHenryIE\WP_Bitcoin_Gateway\Settings_Interface;
32use BrianHenryIE\WP_Bitcoin_Gateway\WooCommerce\Order;
33use BrianHenryIE\WP_Bitcoin_Gateway\WooCommerce\Thank_You;
34use BrianHenryIE\WP_Bitcoin_Gateway\WooCommerce\Bitcoin_Gateway;
35use JsonException;
36use Psr\Log\LoggerAwareTrait;
37use Psr\Log\LoggerInterface;
38use WC_Order;
39use WC_Payment_Gateway;
40use WC_Payment_Gateways;
41
42/**
43 *
44 */
45class API implements API_Interface {
46    use LoggerAwareTrait;
47
48    /**
49     * Plugin settings.
50     */
51    protected Settings_Interface $settings;
52
53    /**
54     * API to query transactions.
55     */
56    protected Blockchain_API_Interface $blockchain_api;
57
58    /**
59     * API to calculate prices.
60     */
61    protected Exchange_Rate_API_Interface $exchange_rate_api;
62
63    /**
64     * Object to derive payment addresses.
65     */
66    protected Generate_Address_API_Interface $generate_address_api;
67
68    /**
69     * Factory to save and fetch wallets from wp_posts.
70     */
71    protected Bitcoin_Wallet_Factory $bitcoin_wallet_factory;
72
73    /**
74     * Factory to save and fetch addresses from wp_posts.
75     */
76    protected Bitcoin_Address_Factory $bitcoin_address_factory;
77
78    /**
79     * Constructor
80     *
81     * @param Settings_Interface      $settings The plugin settings.
82     * @param LoggerInterface         $logger A PSR logger.
83     * @param Bitcoin_Wallet_Factory  $bitcoin_wallet_factory Wallet factory.
84     * @param Bitcoin_Address_Factory $bitcoin_address_factory Address factory.
85     */
86    public function __construct(
87        Settings_Interface $settings,
88        LoggerInterface $logger,
89        Bitcoin_Wallet_Factory $bitcoin_wallet_factory,
90        Bitcoin_Address_Factory $bitcoin_address_factory,
91        Blockchain_API_Interface $blockchain_api,
92        Generate_Address_API_Interface $generate_address_api,
93        Exchange_Rate_API_Interface $exchange_rate_api
94    ) {
95        $this->setLogger( $logger );
96        $this->settings = $settings;
97
98        $this->bitcoin_wallet_factory  = $bitcoin_wallet_factory;
99        $this->bitcoin_address_factory = $bitcoin_address_factory;
100
101        $this->blockchain_api       = $blockchain_api;
102        $this->generate_address_api = $generate_address_api;
103        $this->exchange_rate_api    = $exchange_rate_api;
104    }
105
106    /**
107     * Check a gateway id and determine is it an instance of this gateway type.
108     * Used on thank you page to return early.
109     *
110     * @used-by Thank_You::print_instructions()
111     *
112     * @param string $gateway_id The id of the gateway to check.
113     *
114     * @return bool
115     */
116    public function is_bitcoin_gateway( string $gateway_id ): bool {
117        if ( ! is_plugin_active( 'woocommerce/woocommerce.php' ) ) {
118            return false;
119        }
120
121        $bitcoin_gateways = $this->get_bitcoin_gateways();
122
123        $gateway_ids = array_map(
124            function ( WC_Payment_Gateway $gateway ): string {
125                return $gateway->id;
126            },
127            $bitcoin_gateways
128        );
129
130        return in_array( $gateway_id, $gateway_ids, true );
131    }
132
133    /**
134     * Get all instances of the Bitcoin gateway.
135     * (typically there is only one).
136     *
137     * @return array<string, Bitcoin_Gateway>
138     */
139    public function get_bitcoin_gateways(): array {
140        if ( ! is_plugin_active( 'woocommerce/woocommerce.php' ) ) {
141            return array();
142        }
143
144        $payment_gateways = WC_Payment_Gateways::instance()->payment_gateways();
145        $bitcoin_gateways = array();
146        foreach ( $payment_gateways as $gateway ) {
147            if ( $gateway instanceof Bitcoin_Gateway ) {
148                $bitcoin_gateways[ $gateway->id ] = $gateway;
149            }
150        }
151
152        return $bitcoin_gateways;
153    }
154
155    /**
156     * Given an order id, determine is the order's gateway an instance of this Bitcoin gateway.
157     *
158     * @see https://github.com/BrianHenryIE/bh-wp-duplicate-payment-gateways
159     *
160     * @param int $order_id The id of the (presumed) WooCommerce order to check.
161     */
162    public function is_order_has_bitcoin_gateway( int $order_id ): bool {
163        if ( ! is_plugin_active( 'woocommerce/woocommerce.php' ) ) {
164            return false;
165        }
166
167        $order = wc_get_order( $order_id );
168
169        if ( ! ( $order instanceof WC_Order ) ) {
170            // Unlikely.
171            return false;
172        }
173
174        $payment_gateway_id = $order->get_payment_method();
175
176        if ( ! $this->is_bitcoin_gateway( $payment_gateway_id ) ) {
177            // Exit, this isn't for us.
178            return false;
179        }
180
181        return true;
182    }
183
184    /**
185     * Fetches an unused address from the cache, or generates a new one if none are available.
186     *
187     * Called inside the "place order" function, then it can throw an exception.
188     * if there's a problem and the user can immediately choose another payment method.
189     *
190     * Load our already generated fresh list.
191     * Check with a remote API that it has not been used.
192     * Save it to the order metadata.
193     * Save it locally as used.
194     * Maybe schedule more address generation.
195     * Return it to be used in an order.
196     *
197     * @used-by Bitcoin_Gateway::process_payment()
198     *
199     * @param WC_Order $order The order that will use the address.
200     *
201     * @return Bitcoin_Address
202     *
203     * @throws JsonException
204     */
205    public function get_fresh_address_for_order( WC_Order $order ): Bitcoin_Address {
206        $this->logger->debug( 'Get fresh address for `shop_order:' . $order->get_id() . '`' );
207
208        $btc_addresses = $this->get_fresh_addresses_for_gateway( $this->get_bitcoin_gateways()[ $order->get_payment_method() ] );
209
210        $btc_address = array_shift( $btc_addresses );
211
212        $order->add_meta_data( Order::BITCOIN_ADDRESS_META_KEY, $btc_address->get_raw_address() );
213        $order->save();
214
215        $btc_address->set_status( 'assigned' );
216
217        $this->logger->info(
218            sprintf(
219                'Assigned `bh-bitcoin-address:%d` %s to `shop_order:%d`.',
220                $this->bitcoin_address_factory->get_post_id_for_address( $btc_address->get_raw_address() ),
221                $btc_address->get_raw_address(),
222                $order->get_id()
223            )
224        );
225
226        return $btc_address;
227    }
228
229    /**
230     * @param Bitcoin_Gateway $gateway
231     *
232     * @return Bitcoin_Address[]
233     * @throws Exception
234     */
235    public function get_fresh_addresses_for_gateway( Bitcoin_Gateway $gateway ): array {
236
237        if ( empty( $gateway->get_xpub() ) ) {
238            $this->logger->debug( "No master public key set on gateway {$gateway->id}", array( 'gateway' => $gateway ) );
239            return array();
240        }
241
242        $wallet_post_id = $this->bitcoin_wallet_factory->get_post_id_for_wallet( $gateway->get_xpub() )
243                        ?? $this->bitcoin_wallet_factory->save_new( $gateway->get_xpub(), $gateway->id );
244
245        $wallet = $this->bitcoin_wallet_factory->get_by_post_id( $wallet_post_id );
246
247        /** @var Bitcoin_Address[] $fresh_addresses */
248        return $wallet->get_fresh_addresses();
249    }
250
251    /**
252     * Check do we have at least one address already generated and ready to use.
253     *
254     * @param Bitcoin_Gateway $gateway The gateway id the address is for.
255     *
256     * @used-by Bitcoin_Gateway::is_available()
257     *
258     * @return bool
259     */
260    public function is_fresh_address_available_for_gateway( Bitcoin_Gateway $gateway ): bool {
261        return count( $this->get_fresh_addresses_for_gateway( $gateway ) ) > 0;
262    }
263
264    /**
265     * Get the current status of the order's payment.
266     *
267     * As a really detailed array for printing.
268     *
269     * `array{btc_address:string, bitcoin_total:string, btc_price_at_at_order_time:string, transactions:array<string, TransactionArray>, btc_exchange_rate:string, last_checked_time:DateTimeInterface, btc_amount_received:string, order_status_before:string}`
270     *
271     * @param WC_Order $wc_order The WooCommerce order to check.
272     * @param bool     $refresh Should the result be returned from cache or refreshed from remote APIs.
273     *
274     * @return Bitcoin_Order_Interface
275     * @throws Exception
276     */
277    public function get_order_details( WC_Order $wc_order, bool $refresh = true ): Bitcoin_Order_Interface {
278
279        $bitcoin_order = new Bitcoin_Order( $wc_order, $this->bitcoin_address_factory );
280
281        if ( $refresh ) {
282            $this->refresh_order( $bitcoin_order );
283        }
284
285        return $bitcoin_order;
286    }
287
288    /**
289     *
290     * TODO: mempool.
291     */
292    protected function refresh_order( Bitcoin_Order_Interface $bitcoin_order ): bool {
293
294        $updated = false;
295
296        $time_now = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
297
298        $order_transactions_before = $bitcoin_order->get_address()->get_blockchain_transactions();
299
300        if ( is_null( $order_transactions_before ) ) {
301            $this->logger->debug( 'Checking for the first time' );
302            $order_transactions_before = array();
303        }
304
305        /** @var array<string, Transaction_Interface> $address_transactions_current */
306        $address_transactions_current = $this->update_address_transactions( $bitcoin_order->get_address() );
307
308        // TODO: Check are any previous transactions no longer present!!!
309
310        // Filter to transactions that occurred after the order was placed.
311        $order_transactions_current = array();
312        foreach ( $address_transactions_current as $txid => $transaction ) {
313            // TODO: maybe use block height at order creation rather than date?
314            // TODO: be careful with timezones.
315            if ( $transaction->get_time() > $bitcoin_order->get_date_created() ) {
316                $order_transactions_current[ $txid ] = $transaction;
317            }
318        }
319
320        $order_transactions_current_mempool = array_filter(
321            $address_transactions_current,
322            function ( Transaction_Interface $transaction ) {
323                is_null( $transaction->get_block_height() );
324            }
325        );
326
327        $order_transactions_current_blockchain = array_filter(
328            $address_transactions_current,
329            function ( Transaction_Interface $transaction ) {
330                ! is_null( $transaction->get_block_height() );
331            }
332        );
333
334        $gateway = $bitcoin_order->get_gateway();
335
336        // $confirmations = $gateway->get_confirmations();
337        $required_confirmations = 3;
338
339        $blockchain_height = $this->blockchain_api->get_blockchain_height();
340
341        $raw_address               = $bitcoin_order->get_address()->get_raw_address();
342        $confirmed_value_current   = array_reduce(
343            $order_transactions_current_blockchain,
344            function ( float $carry, Transaction_Interface $transaction ) use ( $blockchain_height, $required_confirmations, $raw_address ) {
345                if ( $blockchain_height - $transaction->get_block_height() ?? $blockchain_height > $required_confirmations ) {
346                    return $carry + $transaction->get_value( $raw_address );
347                }
348                return $carry;
349            },
350            0.0
351        );
352        $unconfirmed_value_current = array_reduce(
353            $order_transactions_current_blockchain,
354            function ( float $carry, Transaction_Interface $transaction ) use ( $blockchain_height, $required_confirmations, $raw_address ) {
355                if ( $blockchain_height - $transaction->get_block_height() ?? $blockchain_height > $required_confirmations ) {
356                    return $carry;
357                }
358                return $carry + $transaction->get_value( $raw_address );
359            },
360            0.0
361        );
362
363        // Filter to transactions that have just been seen, so we can record them in notes.
364        $new_order_transactions = array();
365        foreach ( $order_transactions_current as $txid => $transaction ) {
366            if ( ! isset( $order_transactions_before[ $txid ] ) ) {
367                $new_order_transactions[ $txid ] = $transaction;
368            }
369        }
370
371        $transaction_formatter = new Transaction_Formatter();
372
373        // Add a note saying "one new transactions seen, unconfirmed total =, confirmed total = ...".
374        $note = '';
375        if ( ! empty( $new_order_transactions ) ) {
376            $updated = true;
377            $note   .= $transaction_formatter->get_order_note( $new_order_transactions );
378        }
379
380        if ( ! empty( $note ) ) {
381            $this->logger->info(
382                $note,
383                array(
384                    'order_id' => $bitcoin_order->get_id(),
385                    'updates'  => $order_transactions_current,
386                )
387            );
388
389            $bitcoin_order->add_order_note( $note );
390        }
391
392        if ( ! $bitcoin_order->is_paid() && $confirmed_value_current > 0 ) {
393            $expected        = $bitcoin_order->get_btc_total_price();
394            $price_margin    = $gateway->get_price_margin_percent();
395            $minimum_payment = $expected * ( 100 - $price_margin ) / 100;
396
397            if ( $confirmed_value_current > $minimum_payment ) {
398                $bitcoin_order->payment_complete( $order_transactions_current[ array_key_last( $order_transactions_current ) ]->get_txid() );
399                $this->logger->info( "`shop_order:{$bitcoin_order->get_id()}` has been marked paid.", array( 'order_id' => $bitcoin_order->get_id() ) );
400
401                $updated = true;
402            }
403        }
404
405        if ( $updated ) {
406            $bitcoin_order->set_amount_received( $confirmed_value_current );
407        }
408        $bitcoin_order->set_last_checked_time( $time_now );
409
410        $bitcoin_order->save();
411
412        return $updated;
413    }
414
415
416    /**
417     * Get order details for printing in HTML templates.
418     *
419     * Returns an array of:
420     * * html formatted values
421     * * raw values that are known to be used in the templates
422     * * objects the values are from
423     *
424     * @uses \BrianHenryIE\WP_Bitcoin_Gateway\API_Interface::get_order_details()
425     * @see Details_Formatter
426     *
427     * @param WC_Order $order The WooCommerce order object to update.
428     * @param bool     $refresh Should saved order details be returned or remote APIs be queried.
429     *
430     * @return array<string, mixed>
431     */
432    public function get_formatted_order_details( WC_Order $order, bool $refresh = true ): array {
433
434        $order_details = $this->get_order_details( $order, $refresh );
435
436        $formatted = new Details_Formatter( $order_details );
437
438        // HTML formatted data.
439        $result = $formatted->to_array();
440
441        // Raw data.
442        $result['btc_total']           = $order_details->get_btc_total_price();
443        $result['btc_exchange_rate']   = $order_details->get_btc_exchange_rate();
444        $result['btc_address']         = $order_details->get_address()->get_raw_address();
445        $result['transactions']        = $order_details->get_address()->get_blockchain_transactions();
446        $result['btc_amount_received'] = $order_details->get_address()->get_amount_received() ?? 'unknown';
447
448        // Objects.
449        $result['order']         = $order;
450        $result['bitcoin_order'] = $order_details;
451
452        return $result;
453    }
454
455    /**
456     * Return the cached exchange rate, or fetch it.
457     * Cache for one hour.
458     *
459     * Value of 1 BTC.
460     *
461     * @param string $currency
462     *
463     * @throws Exception
464     */
465    public function get_exchange_rate( string $currency ): string {
466        $currency       = strtoupper( $currency );
467        $transient_name = 'bh_wp_bitcoin_gateway_exchange_rate_' . $currency;
468        $exchange_rate  = get_transient( $transient_name );
469
470        if ( empty( $exchange_rate ) ) {
471            $exchange_rate = $this->exchange_rate_api->get_exchange_rate( $currency );
472            set_transient( $transient_name, $exchange_rate, HOUR_IN_SECONDS );
473        }
474
475        return $exchange_rate;
476    }
477
478    /**
479     * Get the BTC value of another currency amount.
480     *
481     * Rounds to ~6 decimal places.
482     *
483     * @param string $currency 'USD'|'EUR'|'GBP', maybe others.
484     * @param float  $fiat_amount This is stored in the WC_Order object as a float (as a string in meta).
485     *
486     * @return string Bitcoin amount.
487     */
488    public function convert_fiat_to_btc( string $currency, float $fiat_amount = 1.0 ): string {
489
490        // 1 BTC = xx USD.
491        $exchange_rate = $this->get_exchange_rate( $currency );
492
493        $float_result = $fiat_amount / floatval( $exchange_rate );
494
495        // This is a good number for January 2023, 0.000001 BTC = 0.02 USD.
496        // TODO: Calculate the appropriate number of decimals on the fly.
497        $num_decimal_places = 6;
498        $string_result      = (string) wc_round_discount( $float_result, $num_decimal_places + 1 );
499        return $string_result;
500    }
501
502    /**
503     * Given an xpub, create the wallet post (if not already existing) and generate addresses until some fresh ones
504     * are generated.
505     *
506     * TODO: refactor this so it can handle 429 rate limiting.
507     *
508     * @param string  $xpub
509     * @param ?string $gateway_id
510     *
511     * @return array{wallet: Bitcoin_Wallet, wallet_post_id: int, existing_fresh_addresses:array<Bitcoin_Address>, generated_addresses:array<Bitcoin_Address>}
512     * @throws Exception
513     */
514    public function generate_new_wallet( string $xpub, string $gateway_id = null ): array {
515
516        $result = array();
517
518        $post_id = $this->bitcoin_wallet_factory->get_post_id_for_wallet( $xpub )
519            ?? $this->bitcoin_wallet_factory->save_new( $xpub, $gateway_id );
520
521        $wallet = $this->bitcoin_wallet_factory->get_by_post_id( $post_id );
522
523        $result['wallet'] = $wallet;
524
525        $existing_fresh_addresses = $wallet->get_fresh_addresses();
526
527        $generated_addresses = array();
528
529        while ( count( $wallet->get_fresh_addresses() ) < 20 ) {
530
531            $generate_addresses_result = $this->generate_new_addresses_for_wallet( $xpub );
532            $new_generated_addresses   = $generate_addresses_result['generated_addresses'];
533
534            $generated_addresses = array_merge( $generated_addresses, $new_generated_addresses );
535
536            $check_new_addresses_result = $this->check_new_addresses_for_transactions( $generated_addresses );
537        }
538
539        $result['existing_fresh_addresses'] = $existing_fresh_addresses;
540
541        // TODO: Only return / distinguish which generated addresses are fresh.
542        $result['generated_addresses'] = $generated_addresses;
543
544        $result['wallet_post_id'] = $post_id;
545
546        return $result;
547    }
548
549
550    /**
551     * If a wallet has fewer than 20 fresh addresses available, generate some more.
552     *
553     * @see API_Interface::generate_new_addresses()
554     * @used-by CLI::generate_new_addresses()
555     * @used-by Background_Jobs::generate_new_addresses()
556     *
557     * @return array<string, array{}|array{wallet_post_id:int, new_addresses: array{gateway_id:string, xpub:string, generated_addresses:array<Bitcoin_Address>, generated_addresses_count:int, generated_addresses_post_ids:array<int>, address_index:int}}>
558     */
559    public function generate_new_addresses(): array {
560
561        $result = array();
562
563        foreach ( $this->get_bitcoin_gateways() as $gateway ) {
564
565            $result[ $gateway->id ] = array();
566
567            $wallet_address = $gateway->get_xpub();
568
569            $wallet_post_id = $this->bitcoin_wallet_factory->get_post_id_for_wallet( $wallet_address );
570
571            if ( is_null( $wallet_post_id ) ) {
572                try {
573                    $wallet_post_id = $this->bitcoin_wallet_factory->save_new( $wallet_address, $gateway->id );
574                } catch ( Exception $exception ) {
575                    $this->logger->error( 'Failed to save new wallet.' );
576                    continue;
577                }
578            }
579
580            $result[ $gateway->id ]['wallet_post_id'] = $wallet_post_id;
581
582            try {
583                $wallet = $this->bitcoin_wallet_factory->get_by_post_id( $wallet_post_id );
584            } catch ( Exception $exception ) {
585                $this->logger->error( $exception->getMessage(), array( 'exception' => $exception ) );
586                continue;
587            }
588
589            $fresh_addresses = $wallet->get_fresh_addresses();
590
591            if ( count( $fresh_addresses ) > 20 ) {
592                continue;
593            }
594
595            $generated_addresses_result = $this->generate_new_addresses_for_wallet( $gateway->get_xpub() );
596
597            $generated_addresses = $generated_addresses_result['generated_addresses'];
598
599            $result[ $gateway->id ]['new_addresses'] = $generated_addresses;
600
601            $this->check_new_addresses_for_transactions( $generated_addresses );
602
603        }
604
605        return $result;
606    }
607
608
609    /**
610     * @param string $master_public_key
611     * @param int    $generate_count // TODO:  20 is the standard. cite.
612     *
613     * @return array{xpub:string, generated_addresses:array<Bitcoin_Address>, generated_addresses_count:int, generated_addresses_post_ids:array<int>, address_index:int}
614     *
615     * @throws Exception When no wallet object is found for the master public key (xpub) string.
616     */
617    public function generate_new_addresses_for_wallet( string $master_public_key, int $generate_count = 20 ): array {
618
619        $result = array();
620
621        $result['xpub'] = $master_public_key;
622
623        $wallet_post_id = $this->bitcoin_wallet_factory->get_post_id_for_wallet( $master_public_key )
624            ?? $this->bitcoin_wallet_factory->save_new( $master_public_key );
625
626        $wallet = $this->bitcoin_wallet_factory->get_by_post_id( $wallet_post_id );
627
628        $address_index = $wallet->get_address_index();
629
630        $generated_addresses_post_ids = array();
631        $generated_addresses_count    = 0;
632
633        do {
634
635            // TODO: Post increment or we will never generate address 0 like this.
636            ++$address_index;
637
638            $new_address = $this->generate_address_api->generate_address( $master_public_key, $address_index );
639
640            if ( ! is_null( $this->bitcoin_address_factory->get_post_id_for_address( $new_address ) ) ) {
641                continue;
642            }
643
644            $bitcoin_address_new_post_id = $this->bitcoin_address_factory->save_new( $new_address, $address_index, $wallet );
645
646            $generated_addresses_post_ids[] = $bitcoin_address_new_post_id;
647            ++$generated_addresses_count;
648
649        } while ( $generated_addresses_count < $generate_count );
650
651        $result['generated_addresses_count']    = $generated_addresses_count;
652        $result['generated_addresses_post_ids'] = $generated_addresses_post_ids;
653        $result['generated_addresses']          = array_map(
654            function ( int $post_id ): Bitcoin_Address {
655                return $this->bitcoin_address_factory->get_by_post_id( $post_id );
656            },
657            $generated_addresses_post_ids
658        );
659        $result['address_index']                = $address_index;
660
661        $wallet->set_address_index( $address_index );
662
663        if ( $generate_count > 0 ) {
664            // Check the new addresses for transactions etc.
665            $this->check_new_addresses_for_transactions();
666
667            // Schedule more generation after it determines how many unused addresses are available.
668            if ( count( $wallet->get_fresh_addresses() ) < 20 ) {
669
670                $hook = Background_Jobs::GENERATE_NEW_ADDRESSES_HOOK;
671                if ( ! as_has_scheduled_action( $hook ) ) {
672                    as_schedule_single_action( time(), $hook );
673                    $this->logger->debug( 'New generate new addresses background job scheduled.' );
674                }
675            }
676        }
677
678        return $result;
679    }
680
681    /**
682     * @used-by Background_Jobs::check_new_addresses_for_transactions()
683     *
684     * @param Bitcoin_Address[] $addresses Array of address objects to query and update.
685     *
686     * @return array<string, Transaction_Interface>
687     */
688    public function check_new_addresses_for_transactions(): array {
689
690        $addresses = array();
691
692        // Get all wallets whose status is unknown.
693        $posts = get_posts(
694            array(
695                'post_type'      => Bitcoin_Address::POST_TYPE,
696                'post_status'    => 'unknown',
697                'posts_per_page' => 100,
698                'orderby'        => 'ID',
699                'order'          => 'ASC',
700            )
701        );
702
703        if ( empty( $posts ) ) {
704            $this->logger->debug( 'No addresses with "unknown" status to check' );
705
706            return array(); // TODO: return something meaningful.
707        }
708
709        foreach ( $posts as $post ) {
710
711            $post_id = $post->ID;
712
713            $addresses[] = $this->bitcoin_address_factory->get_by_post_id( $post_id );
714
715        }
716
717        return $this->check_addresses_for_transactions( $addresses );
718    }
719
720    /**
721     * @used-by Background_Jobs::check_new_addresses_for_transactions()
722     *
723     * @param Bitcoin_Address[] $addresses Array of address objects to query and update.
724     *
725     * @return array<string, Transaction_Interface>
726     */
727    public function check_addresses_for_transactions( array $addresses ): array {
728
729        $result = array();
730
731        try {
732            foreach ( $addresses as $bitcoin_address ) {
733                $result[ $bitcoin_address->get_raw_address() ] = $this->update_address_transactions( $bitcoin_address );
734            }
735        } catch ( Exception $exception ) {
736            // Reschedule if we hit 429 (there will always be at least one address to check if it 429s.).
737            $this->logger->debug( $exception->getMessage() );
738
739            $hook = Background_Jobs::CHECK_NEW_ADDRESSES_TRANSACTIONS_HOOK;
740            if ( ! as_has_scheduled_action( $hook ) ) {
741                // TODO: Add new scheduled time to log.
742                $this->logger->debug( 'Exception during checking addresses for transactions, scheduling new background job' );
743                // TODO: Base the new time of the returned 429 header.
744                as_schedule_single_action( time() + ( 10 * MINUTE_IN_SECONDS ), $hook );
745            }
746        }
747
748        // TODO: After this is complete, there could be 0 fresh addresses (e.g. if we start at index 0 but 200 addresses
749        // are already used). => We really need to generate new addresses until we have some.
750
751        // TODO: Return something useful.
752        return $result;
753    }
754
755    /**
756     * Remotely check/fetch the latest data for an address.
757     *
758     * @param Bitcoin_Address $address The address object to query.
759     *
760     * @return array<string, Transaction_Interface>
761     *
762     * @throws JsonException
763     */
764    public function update_address_transactions( Bitcoin_Address $address ): array {
765
766        $btc_xpub_address_string = $address->get_raw_address();
767
768        // TODO: retry on rate limit.
769        $transactions = $this->blockchain_api->get_transactions_received( $btc_xpub_address_string );
770
771        $address->set_transactions( $transactions );
772
773        return $transactions;
774    }
775
776    /**
777     * The PHP GMP extension is required to derive the payment addresses. This function
778     * checks is it present.
779     *
780     * @see https://github.com/Bit-Wasp/bitcoin-php
781     * @see https://www.php.net/manual/en/book.gmp.php
782     *
783     * @see gmp_init()
784     */
785    public function is_server_has_dependencies(): bool {
786        return function_exists( 'gmp_init' );
787    }
788}