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 | } |