Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
42.91% |
112 / 261 |
|
31.58% |
6 / 19 |
CRAP | |
0.00% |
0 / 1 |
API | |
42.91% |
112 / 261 |
|
31.58% |
6 / 19 |
661.49 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
is_bitcoin_gateway | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
2.00 | |||
get_bitcoin_gateways | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
4.03 | |||
is_order_has_bitcoin_gateway | |
66.67% |
6 / 9 |
|
0.00% |
0 / 1 |
4.59 | |||
get_fresh_address_for_order | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
1 | |||
get_fresh_addresses_for_gateway | |
71.43% |
5 / 7 |
|
0.00% |
0 / 1 |
2.09 | |||
is_fresh_address_available_for_gateway | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
get_order_details | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
refresh_order | |
60.53% |
46 / 76 |
|
0.00% |
0 / 1 |
26.06 | |||
get_formatted_order_details | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
get_exchange_rate | |
50.00% |
3 / 6 |
|
0.00% |
0 / 1 |
2.50 | |||
convert_fiat_to_btc | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
generate_new_wallet | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
6 | |||
generate_new_addresses | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
42 | |||
generate_new_addresses_for_wallet | |
0.00% |
0 / 33 |
|
0.00% |
0 / 1 |
30 | |||
check_new_addresses_for_transactions | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
12 | |||
check_addresses_for_transactions | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
update_address_transactions | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
is_server_has_dependencies | |
0.00% |
0 / 1 |
|
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 | |
14 | namespace BrianHenryIE\WP_Bitcoin_Gateway\API; |
15 | |
16 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Blockchain\Blockstream_Info_API; |
17 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Bitcoin_Order; |
18 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Bitcoin_Order_Interface; |
19 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Transaction_Interface; |
20 | use DateTimeImmutable; |
21 | use DateTimeZone; |
22 | use Exception; |
23 | use BrianHenryIE\WP_Bitcoin_Gateway\Action_Scheduler\Background_Jobs; |
24 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Addresses\Bitcoin_Address; |
25 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Addresses\Bitcoin_Address_Factory; |
26 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Addresses\Bitcoin_Wallet; |
27 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Addresses\Bitcoin_Wallet_Factory; |
28 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Exchange_Rate\Bitfinex_API; |
29 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Addresses\BitWasp_API; |
30 | use BrianHenryIE\WP_Bitcoin_Gateway\API_Interface; |
31 | use BrianHenryIE\WP_Bitcoin_Gateway\Settings_Interface; |
32 | use BrianHenryIE\WP_Bitcoin_Gateway\WooCommerce\Order; |
33 | use BrianHenryIE\WP_Bitcoin_Gateway\WooCommerce\Thank_You; |
34 | use BrianHenryIE\WP_Bitcoin_Gateway\WooCommerce\Bitcoin_Gateway; |
35 | use JsonException; |
36 | use Psr\Log\LoggerAwareTrait; |
37 | use Psr\Log\LoggerInterface; |
38 | use WC_Order; |
39 | use WC_Payment_Gateway; |
40 | use WC_Payment_Gateways; |
41 | |
42 | /** |
43 | * |
44 | */ |
45 | class 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 | } |