Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
19.33% |
29 / 150 |
|
11.76% |
2 / 17 |
CRAP | |
0.00% |
0 / 1 |
| API | |
19.33% |
29 / 150 |
|
11.76% |
2 / 17 |
1156.70 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| get_exchange_rate | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| convert_fiat_to_btc | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| update_exchange_rate | |
84.62% |
11 / 13 |
|
0.00% |
0 / 1 |
2.01 | |||
| get_or_save_wallet_for_master_public_key | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
| is_unused_address_available_for_wallet | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| generate_new_addresses | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| ensure_unused_addresses | |
0.00% |
0 / 53 |
|
0.00% |
0 / 1 |
210 | |||
| ensure_unused_addresses_for_wallet_synchronously | n/a |
0 / 0 |
n/a |
0 / 0 |
1 | |||||
| generate_new_addresses_for_wallet | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| check_new_addresses_for_transactions | |
42.86% |
3 / 7 |
|
0.00% |
0 / 1 |
2.75 | |||
| check_addresses_for_transactions | |
52.00% |
13 / 25 |
|
0.00% |
0 / 1 |
9.98 | |||
| check_assigned_addresses_for_payment | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
| check_address_for_payment | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
| maybe_fire_new_transactions_seen_action | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
| maybe_mark_address_as_paid | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
12 | |||
| maybe_fire_mark_address_paid_action | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
| get_saved_transactions | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| 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 | * - TODO After x unpaid time, mark unpaid orders as failed/cancelled. |
| 14 | * - TODO: There should be a global cap on how long an address can be assigned without payment. Not something to handle in this class |
| 15 | * – TODO: hook into post_status changes (+count) to decide to schedule? Or call directly from API class when it assigns an Address to an Order? |
| 16 | */ |
| 17 | |
| 18 | namespace BrianHenryIE\WP_Bitcoin_Gateway\API; |
| 19 | |
| 20 | use BrianHenryIE\WP_Bitcoin_Gateway\Action_Scheduler\API_Background_Jobs_Interface; |
| 21 | use BrianHenryIE\WP_Bitcoin_Gateway\Action_Scheduler\Background_Jobs_Actions_Interface; |
| 22 | use BrianHenryIE\WP_Bitcoin_Gateway\Action_Scheduler\Background_Jobs_Scheduler_Interface; |
| 23 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Payments\Bitcoin_Transaction; |
| 24 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Results\Update_Exchange_Rate_Result; |
| 25 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Address_Status; |
| 26 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Results\Check_Address_For_Payment_Result; |
| 27 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Exceptions\Rate_Limit_Exception; |
| 28 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Results\Addresses_Generation_Result; |
| 29 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Exceptions\BH_WP_Bitcoin_Gateway_Exception; |
| 30 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Results\Check_Assigned_Addresses_For_Transactions_Result; |
| 31 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Results\Ensure_Unused_Addresses_Result; |
| 32 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Results\Mark_Address_As_Paid_Result; |
| 33 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Results\Wallet_Generation_Result; |
| 34 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Services\Bitcoin_Wallet_Service; |
| 35 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Services\Exchange_Rate_Service; |
| 36 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Services\Payment_Service; |
| 37 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Services\Results\Check_Address_For_Payment_Service_Result; |
| 38 | use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Currency; |
| 39 | use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Exception\MoneyMismatchException; |
| 40 | use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Exception\UnknownCurrencyException; |
| 41 | use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Money; |
| 42 | use BrianHenryIE\WP_Bitcoin_Gateway\Action_Scheduler\Background_Jobs_Actions_Handler; |
| 43 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Address; |
| 44 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Wallet; |
| 45 | use BrianHenryIE\WP_Bitcoin_Gateway\API_Interface; |
| 46 | use BrianHenryIE\WP_Bitcoin_Gateway\Integrations\WooCommerce\Bitcoin_Gateway; |
| 47 | use BrianHenryIE\WP_Bitcoin_Gateway\Integrations\WooCommerce\Checkout; |
| 48 | use BrianHenryIE\WP_Bitcoin_Gateway\Settings_Interface; |
| 49 | use DateInterval; |
| 50 | use DateTimeImmutable; |
| 51 | use Exception; |
| 52 | use JsonException; |
| 53 | use Psr\Log\LoggerAwareTrait; |
| 54 | use Psr\Log\LoggerInterface; |
| 55 | |
| 56 | /** |
| 57 | * Main API implementation for the Bitcoin Gateway plugin. |
| 58 | * |
| 59 | * Handles core functionality including exchange rate conversion between fiat and BTC, |
| 60 | * wallet and address generation from master public keys (xpub/ypub/zpub), checking |
| 61 | * Bitcoin addresses for incoming transactions via blockchain APIs, and managing payment |
| 62 | * confirmation workflows for assigned addresses. |
| 63 | */ |
| 64 | class API implements API_Interface, API_Background_Jobs_Interface { |
| 65 | use LoggerAwareTrait; |
| 66 | |
| 67 | /** |
| 68 | * Constructor |
| 69 | * |
| 70 | * @param Settings_Interface $settings The plugin settings. |
| 71 | * @param Exchange_Rate_Service $exchange_rate_service Client for fetching current BTC exchange rates from external APIs (e.g., Bitfinex, Bitstamp). |
| 72 | * @param Bitcoin_Wallet_Service $wallet_service Generating wallets and payment addresses. |
| 73 | * @param Payment_Service $payment_service Service for confirming payments. |
| 74 | * @param Background_Jobs_Scheduler_Interface $background_jobs_scheduler Scheduler for queuing recurring tasks like checking addresses for payments and generating new addresses via Action Scheduler. |
| 75 | * @param LoggerInterface $logger A PSR logger for recording errors, warnings, and debug information. |
| 76 | */ |
| 77 | public function __construct( |
| 78 | protected Settings_Interface $settings, |
| 79 | protected Exchange_Rate_Service $exchange_rate_service, |
| 80 | protected Bitcoin_Wallet_Service $wallet_service, |
| 81 | protected Payment_Service $payment_service, |
| 82 | protected Background_Jobs_Scheduler_Interface $background_jobs_scheduler, |
| 83 | LoggerInterface $logger, |
| 84 | ) { |
| 85 | $this->setLogger( $logger ); |
| 86 | } |
| 87 | |
| 88 | /** |
| 89 | * Return the cached exchange rate, or fetch it. |
| 90 | * Cache for one hour. |
| 91 | * |
| 92 | * Value of 1 BTC. |
| 93 | * |
| 94 | * @param Currency $currency The fiat currency to get the BTC exchange rate for (e.g., USD, EUR, GBP). |
| 95 | * |
| 96 | * @throws BH_WP_Bitcoin_Gateway_Exception When the exchange rate API returns invalid data or the currency is not supported. |
| 97 | */ |
| 98 | public function get_exchange_rate( Currency $currency ): ?Money { |
| 99 | return $this->exchange_rate_service->get_exchange_rate( $currency ); |
| 100 | } |
| 101 | |
| 102 | /** |
| 103 | * Get the BTC value of another currency amount. |
| 104 | * |
| 105 | * Limited currency support: 'USD'|'EUR'|'GBP', maybe others. |
| 106 | * |
| 107 | * @param Money $fiat_amount The order total amount in fiat currency from the WooCommerce order (stored as a float string in order meta). |
| 108 | * |
| 109 | * @throws BH_WP_Bitcoin_Gateway_Exception When no exchange rate is available for the given currency. |
| 110 | */ |
| 111 | public function convert_fiat_to_btc( Money $fiat_amount ): Money { |
| 112 | return $this->exchange_rate_service->convert_fiat_to_btc( $fiat_amount ); |
| 113 | } |
| 114 | |
| 115 | /** |
| 116 | * Update the exchange rate. |
| 117 | * |
| 118 | * Fetches and caches the current BTC exchange rate. |
| 119 | * |
| 120 | * @used-by Background_Jobs_Actions_Handler::update_exchange_rate() |
| 121 | * @throws UnknownCurrencyException If the currency code is not known by the brick/money library. |
| 122 | * @throws JsonException If the API response cannot be parsed. |
| 123 | * @throws BH_WP_Bitcoin_Gateway_Exception If the request fails or the response !== 2xx. |
| 124 | */ |
| 125 | public function update_exchange_rate(): Update_Exchange_Rate_Result { |
| 126 | |
| 127 | // If WooCommerce is not active, default to USD. |
| 128 | if ( function_exists( 'get_woocommerce_currency' ) ) { |
| 129 | $source = 'woocommerce'; |
| 130 | $currency_code = get_woocommerce_currency(); |
| 131 | } else { |
| 132 | $source = 'default-usd'; |
| 133 | $currency_code = 'USD'; |
| 134 | } |
| 135 | |
| 136 | $currency = Currency::of( $currency_code ); |
| 137 | $updated_exchange_rate = $this->exchange_rate_service->update_exchange_rate( $currency ); |
| 138 | $this->logger->debug( 'Exchange rate updated for {currency}.', array( 'currency' => $currency->getCurrencyCode() ) ); |
| 139 | |
| 140 | return new Update_Exchange_Rate_Result( |
| 141 | requested_exchange_rate_currency: $currency_code, |
| 142 | source: $source, |
| 143 | updated_exchange_rate: $updated_exchange_rate |
| 144 | ); |
| 145 | } |
| 146 | |
| 147 | /** |
| 148 | * Get or create a Wallet for the given master public key. Optionally, set the gateway id if the wallet is new. |
| 149 | * |
| 150 | * @param string $xpub Bitcoin master public key. |
| 151 | * @param ?array{integration:class-string, gateway_id:string} $gateway_details Gateway id. |
| 152 | * @throws BH_WP_Bitcoin_Gateway_Exception If two wallets for the xpub exist, or if saving fails. |
| 153 | */ |
| 154 | public function get_or_save_wallet_for_master_public_key( string $xpub, ?array $gateway_details = null ): Wallet_Generation_Result { |
| 155 | $result = $this->wallet_service->get_or_save_wallet_for_xpub( $xpub, $gateway_details ); |
| 156 | |
| 157 | if ( $result->is_new ) { |
| 158 | $this->background_jobs_scheduler->schedule_single_ensure_unused_addresses( $result->wallet ); |
| 159 | } |
| 160 | |
| 161 | return new Wallet_Generation_Result( |
| 162 | get_wallet_for_xpub_service_result: $result, |
| 163 | did_schedule_ensure_addresses: $result->is_new, |
| 164 | ); |
| 165 | } |
| 166 | |
| 167 | /** |
| 168 | * A local-only check to see if the wallet has any payment addresses whose status has been checked in the last ten |
| 169 | * minutes. |
| 170 | * |
| 171 | * @param Bitcoin_Wallet $wallet The wallet object we need an address to give to a customer. |
| 172 | */ |
| 173 | public function is_unused_address_available_for_wallet( Bitcoin_Wallet $wallet ): bool { |
| 174 | $success = $this->wallet_service->has_unused_bitcoin_address( $wallet ); |
| 175 | |
| 176 | if ( ! $success ) { |
| 177 | $this->background_jobs_scheduler->schedule_single_ensure_unused_addresses( $wallet ); |
| 178 | } |
| 179 | |
| 180 | return $success; |
| 181 | } |
| 182 | |
| 183 | /** |
| 184 | * If a wallet has fewer than 20 fresh addresses available, generate some more. |
| 185 | * |
| 186 | * @return Addresses_Generation_Result[] |
| 187 | * @see API_Interface::generate_new_addresses() |
| 188 | * @used-by CLI::generate_new_addresses() |
| 189 | * @used-by Background_Jobs_Actions_Handler::generate_new_addresses() |
| 190 | */ |
| 191 | public function generate_new_addresses(): array { |
| 192 | return $this->wallet_service->generate_new_addresses(); |
| 193 | } |
| 194 | |
| 195 | /** |
| 196 | * Check that there are two addresses generated and unused for every wallet (or specific wallet/number). |
| 197 | * |
| 198 | * TODO: This should be checking the address's last modified time and not making remote API calls if it was recently checked. |
| 199 | * |
| 200 | * TODO: If a store has 100 orders/minute, this should still only check each address once every ten minutes, since |
| 201 | * until a new block is mined, the result won't change. TODO: mempool? |
| 202 | * |
| 203 | * Payment addresses may be used outside WordPress and if we were to reuse those addresses, confirming the payment |
| 204 | * can't be done confidently. (TODO: still only consider transactions made after the address is assigned to an order). |
| 205 | * |
| 206 | * @used-by Background_Jobs_Actions_Interface::ensure_unused_addresses() |
| 207 | * |
| 208 | * @param int $required_count The number of addresses to be sure have not been used yet. There is no real point checking more than 2, especially if using free APIs with low rate limits. |
| 209 | * @param Bitcoin_Wallet[] $wallets The Bitcoin Wallets to check. When on a recurring schedule, check all; when at checkout using a specific wallet, only check that one. |
| 210 | * |
| 211 | * @return array<string, Ensure_Unused_Addresses_Result> array<wallet_xpub: Ensure_Unused_Addresses_Result> |
| 212 | * |
| 213 | * @throws Rate_Limit_Exception TODO: I think this should be caught before here, the job rescheduled and that fact recorded in function response (Ensure_Unused_Addresses_Result probably isn't enough, so). |
| 214 | */ |
| 215 | public function ensure_unused_addresses( int $required_count = 2, array $wallets = array() ): array { |
| 216 | // TODO: write a test to see is wallet status being used correctly, then add filter: `Bitcoin_Wallet_Status::ACTIVE`. |
| 217 | $wallets = ! empty( $wallets ) ? $wallets : $this->wallet_service->get_all_wallets(); |
| 218 | |
| 219 | /** @var array<int, array<Bitcoin_Address>> $assumed_existing_unused_addresses Wallet post id:Bitcoin_Address[] */ |
| 220 | $assumed_existing_unused_addresses = array(); |
| 221 | /** @var array<int, array<Bitcoin_Address>> $actual_unused_addresses_by_wallet Wallet post id:Bitcoin_Address[] */ |
| 222 | $actual_unused_addresses_by_wallet = array(); |
| 223 | /** @var array<int, array<Bitcoin_Address>> $unexpectedly_used_addresses_by_wallet Wallet post id:Bitcoin_Address[] */ |
| 224 | $unexpectedly_used_addresses_by_wallet = array(); |
| 225 | /** @var array<int, array<Bitcoin_Address>> $new_addresses_by_wallet Wallet post id:Bitcoin_Address[] */ |
| 226 | $new_addresses_by_wallet = array(); |
| 227 | foreach ( $wallets as $wallet ) { |
| 228 | $assumed_existing_unused_addresses[ $wallet->get_post_id() ] = array(); |
| 229 | $actual_unused_addresses_by_wallet[ $wallet->get_post_id() ] = array(); |
| 230 | $unexpectedly_used_addresses_by_wallet[ $wallet->get_post_id() ] = array(); |
| 231 | $new_addresses_by_wallet[ $wallet->get_post_id() ] = array(); |
| 232 | } |
| 233 | |
| 234 | // Sort by last updated (checked) and get two per wallet. |
| 235 | // TODO: check the modified time and assume any that were checked in the past ten minutes are still valid (since no new block has been completed since). |
| 236 | $unused_addresses = $this->wallet_service->get_unused_bitcoin_addresses(); |
| 237 | |
| 238 | /** |
| 239 | * @param array<int, array<Bitcoin_Address>> $unused_addresses_by_wallet |
| 240 | * @param int $required_count |
| 241 | */ |
| 242 | $all_wallets_have_enough_addresses_fn = ( fn( array $unused_addresses_by_wallet, int $required_count ): bool => array_reduce( |
| 243 | $unused_addresses_by_wallet, |
| 244 | /** |
| 245 | * This is definitely safe to ignore. |
| 246 | * "Parameter #2 $callback of function array_reduce expects callable(bool, mixed): bool, Closure(bool, array): bool given". |
| 247 | * |
| 248 | * @phpstan-ignore argument.type |
| 249 | */ |
| 250 | fn( bool $carry, array $addresses ): bool => $carry && count( $addresses ) >= $required_count, |
| 251 | true |
| 252 | ) ); |
| 253 | |
| 254 | foreach ( $unused_addresses as $address ) { |
| 255 | $address_wallet_id = $address->get_wallet_parent_post_id(); |
| 256 | if ( count( $actual_unused_addresses_by_wallet[ $address_wallet_id ] ) >= $required_count ) { |
| 257 | continue; |
| 258 | } |
| 259 | |
| 260 | // TODO: Should we index by anything? |
| 261 | $assumed_existing_unused_addresses[ $address_wallet_id ][] = $address; |
| 262 | |
| 263 | if ( $address->was_checked_recently() ) { |
| 264 | $actual_unused_addresses_by_wallet[ $address_wallet_id ][] = $address; |
| 265 | continue; |
| 266 | } |
| 267 | |
| 268 | // TODO: handle rate limits. |
| 269 | $address_transactions_result = $this->payment_service->update_address_transactions( $address ); |
| 270 | $this->wallet_service->update_address_transactions_posts( $address, $address_transactions_result->all_transactions ); |
| 271 | |
| 272 | if ( $address_transactions_result->is_unused() ) { |
| 273 | $actual_unused_addresses_by_wallet[ $address_wallet_id ][] = $address; |
| 274 | } else { |
| 275 | $unexpectedly_used_addresses_by_wallet[ $address_wallet_id ][] = $address; |
| 276 | |
| 277 | $this->wallet_service->set_payment_address_status( |
| 278 | address: $address, |
| 279 | status: Bitcoin_Address_Status::USED, |
| 280 | ); |
| 281 | |
| 282 | // TODO: log more. |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | // This could loop hundreds of time, e.g. you add a wallet that has been in use elsewhere and it has |
| 287 | // to check each used address until it finds an unused one. |
| 288 | while ( ! $all_wallets_have_enough_addresses_fn( $actual_unused_addresses_by_wallet, $required_count ) ) { |
| 289 | foreach ( $wallets as $wallet ) { |
| 290 | if ( count( $actual_unused_addresses_by_wallet[ $wallet->get_post_id() ] ) < $required_count ) { |
| 291 | $address_generation_result = $this->generate_new_addresses_for_wallet( $wallet, 1 ); |
| 292 | $new_address = $address_generation_result->new_addresses[ array_key_first( $address_generation_result->new_addresses ) ]; |
| 293 | |
| 294 | $address_transactions_result = $this->payment_service->update_address_transactions( $new_address ); |
| 295 | $this->wallet_service->update_address_transactions_posts( $new_address, $address_transactions_result->all_transactions ); |
| 296 | |
| 297 | $is_used_status = $address_transactions_result->is_unused() ? Bitcoin_Address_Status::UNUSED : Bitcoin_Address_Status::USED; |
| 298 | |
| 299 | $this->wallet_service->set_payment_address_status( |
| 300 | address: $new_address, |
| 301 | status: $is_used_status, |
| 302 | ); |
| 303 | |
| 304 | if ( $address_transactions_result->is_unused() ) { |
| 305 | $actual_unused_addresses_by_wallet[ $wallet->get_post_id() ][] = $new_address; |
| 306 | $new_addresses_by_wallet[ $wallet->get_post_id() ][] = $new_address; |
| 307 | } |
| 308 | } |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | /** @var array<string, Ensure_Unused_Addresses_Result> $result_by_wallet */ |
| 313 | $result_by_wallet = array(); |
| 314 | |
| 315 | foreach ( $wallets as $wallet ) { |
| 316 | $result_by_wallet[ $wallet->get_xpub() ] = new Ensure_Unused_Addresses_Result( |
| 317 | wallet: $wallet, |
| 318 | assumed_existing_unused_addresses: $assumed_existing_unused_addresses[ $wallet->get_post_id() ], |
| 319 | actual_existing_unused_addresses: $actual_unused_addresses_by_wallet[ $wallet->get_post_id() ], |
| 320 | unexpectedly_used_addresses_by_wallet: $unexpectedly_used_addresses_by_wallet[ $wallet->get_post_id() ], |
| 321 | new_unused_addresses: $new_addresses_by_wallet[ $wallet->get_post_id() ], |
| 322 | ); |
| 323 | } |
| 324 | |
| 325 | return $result_by_wallet; |
| 326 | } |
| 327 | |
| 328 | /** |
| 329 | * Ensure a specific wallet has the required number of verified unused addresses available. |
| 330 | * |
| 331 | * @used-by Checkout::ensure_one_address_for_payment() |
| 332 | * @see Bitcoin_Gateway::process_payment() |
| 333 | * |
| 334 | * @param Bitcoin_Wallet $wallet The wallet to generate unused addresses for by querying the blockchain to verify generated addresses have no transaction history. |
| 335 | * @param int $required_count The minimum number of unused addresses that must be available for this wallet before returning. |
| 336 | */ |
| 337 | public function ensure_unused_addresses_for_wallet_synchronously( Bitcoin_Wallet $wallet, int $required_count = 2 ): Ensure_Unused_Addresses_Result { |
| 338 | return $this->ensure_unused_addresses( $required_count, array( $wallet ) )[ $wallet->get_xpub() ]; |
| 339 | } |
| 340 | |
| 341 | /** |
| 342 | * Derive new Bitcoin addresses for a saved wallet. |
| 343 | * |
| 344 | * @param Bitcoin_Wallet $wallet The wallet to generate child addresses from using its master public key and current address index. |
| 345 | * @param int $generate_count The number of sequential addresses to derive from the wallet's next available derivation path index. 20 is the standard lookahead for wallets. |
| 346 | * |
| 347 | * @throws BH_WP_Bitcoin_Gateway_Exception When address derivation fails or addresses cannot be saved to the database. |
| 348 | */ |
| 349 | public function generate_new_addresses_for_wallet( Bitcoin_Wallet $wallet, int $generate_count = 2 ): Addresses_Generation_Result { |
| 350 | $address_generation_result = $this->wallet_service->generate_new_addresses_for_wallet( $wallet, $generate_count ); |
| 351 | |
| 352 | /** |
| 353 | * @see self::check_addresses_for_transactions() |
| 354 | */ |
| 355 | $this->background_jobs_scheduler->schedule_single_ensure_unused_addresses( $wallet ); |
| 356 | |
| 357 | return $address_generation_result; |
| 358 | } |
| 359 | |
| 360 | /** |
| 361 | * Check newly generated addresses with "unknown" status for incoming transactions. |
| 362 | * |
| 363 | * @used-by Background_Jobs_Actions_Handler::check_new_addresses_for_transactions() |
| 364 | * |
| 365 | * @return Check_Assigned_Addresses_For_Transactions_Result Result containing the count of addresses checked before rate limiting or completion. |
| 366 | * @throws Rate_Limit_Exception When the blockchain API's rate limit is exceeded, containing the reset time for rescheduling the job. |
| 367 | */ |
| 368 | public function check_new_addresses_for_transactions(): Check_Assigned_Addresses_For_Transactions_Result { |
| 369 | |
| 370 | $addresses = $this->wallet_service->get_unknown_bitcoin_addresses(); |
| 371 | |
| 372 | if ( empty( $addresses ) ) { |
| 373 | $this->logger->debug( 'No addresses with "unknown" status to check' ); |
| 374 | |
| 375 | return new Check_Assigned_Addresses_For_Transactions_Result( |
| 376 | count: 0 |
| 377 | ); // TODO: return something meaningful. |
| 378 | } |
| 379 | |
| 380 | return $this->check_addresses_for_transactions( $addresses ); |
| 381 | } |
| 382 | |
| 383 | /** |
| 384 | * Query the blockchain for transactions on multiple addresses and update their status. |
| 385 | * |
| 386 | * @used-by Background_Jobs_Actions_Handler::check_new_addresses_for_transactions() |
| 387 | * |
| 388 | * @param Bitcoin_Address[] $addresses Array of address objects to query for transactions and save results to the database. |
| 389 | * |
| 390 | * @return Check_Assigned_Addresses_For_Transactions_Result Result containing the count of addresses successfully checked before encountering rate limits or errors. |
| 391 | * |
| 392 | * @throws Rate_Limit_Exception When the blockchain API rate limit (HTTP 429) is hit, so the job can be rescheduled using the exception's reset time. |
| 393 | */ |
| 394 | protected function check_addresses_for_transactions( array $addresses ): Check_Assigned_Addresses_For_Transactions_Result { |
| 395 | |
| 396 | $result = array(); |
| 397 | |
| 398 | try { |
| 399 | foreach ( $addresses as $bitcoin_address ) { |
| 400 | $update_result = $this->payment_service->update_address_transactions( $bitcoin_address ); |
| 401 | $this->wallet_service->update_address_transactions_posts( $bitcoin_address, $update_result->all_transactions ); |
| 402 | |
| 403 | if ( $bitcoin_address->get_status() === Bitcoin_Address_Status::UNKNOWN ) { |
| 404 | $this->wallet_service->set_payment_address_status( |
| 405 | address: $bitcoin_address, |
| 406 | status: ( 0 === count( $update_result->all_transactions ) ) ? Bitcoin_Address_Status::UNUSED : Bitcoin_Address_Status::USED |
| 407 | ); |
| 408 | } |
| 409 | |
| 410 | $result[ $bitcoin_address->get_raw_address() ] = $update_result; |
| 411 | } |
| 412 | } catch ( Rate_Limit_Exception $exception ) { |
| 413 | // Reschedule if we hit 429 (there will always be at least one address to check if it 429s.). |
| 414 | |
| 415 | $this->background_jobs_scheduler->schedule_check_newly_generated_bitcoin_addresses_for_transactions( |
| 416 | datetime: $exception->get_reset_time() |
| 417 | ); |
| 418 | |
| 419 | return new Check_Assigned_Addresses_For_Transactions_Result( |
| 420 | count: count( $result ) |
| 421 | ); |
| 422 | } catch ( Exception $exception ) { |
| 423 | $this->logger->error( $exception->getMessage() ); |
| 424 | |
| 425 | $this->background_jobs_scheduler->schedule_check_newly_generated_bitcoin_addresses_for_transactions( |
| 426 | new DateTimeImmutable()->add( new DateInterval( 'PT15M' ) ), |
| 427 | ); |
| 428 | } |
| 429 | |
| 430 | // TODO: After this is complete, there could be 0 fresh addresses (e.g. if we start at index 0 but 200 addresses |
| 431 | // are already used). => We really need to generate new addresses until we have some. |
| 432 | |
| 433 | // TODO: Return something useful. |
| 434 | return new Check_Assigned_Addresses_For_Transactions_Result( |
| 435 | count: count( $result ) |
| 436 | ); |
| 437 | } |
| 438 | |
| 439 | |
| 440 | |
| 441 | /** |
| 442 | * @see Background_Jobs_Actions_Interface::check_assigned_addresses_for_transactions() |
| 443 | * @used-by Background_Jobs_Actions_Handler::check_assigned_addresses_for_transactions() |
| 444 | */ |
| 445 | public function check_assigned_addresses_for_payment(): Check_Assigned_Addresses_For_Transactions_Result { |
| 446 | |
| 447 | foreach ( $this->wallet_service->get_assigned_bitcoin_addresses() as $bitcoin_address ) { |
| 448 | $this->check_address_for_payment( $bitcoin_address ); |
| 449 | } |
| 450 | // TODO: Return actual result with count of addresses checked. |
| 451 | return new Check_Assigned_Addresses_For_Transactions_Result( count:0 ); |
| 452 | } |
| 453 | |
| 454 | /** |
| 455 | * Check a Bitcoin address for payment and mark as paid if sufficient funds received. |
| 456 | * |
| 457 | * @param Bitcoin_Address $payment_address The Bitcoin address to check. |
| 458 | * @throws BH_WP_Bitcoin_Gateway_Exception If the address being checked has no `target_amount` set. |
| 459 | * @throws MoneyMismatchException If the `target_amount` and `total_received` currencies were somehow different. |
| 460 | */ |
| 461 | public function check_address_for_payment( Bitcoin_Address $payment_address ): Check_Address_For_Payment_Result { |
| 462 | |
| 463 | // TODO: Maybe throw if the address has not been assigned. |
| 464 | // TODO: Check _when_ the address was assigned and discard any transactions before that time. This should |
| 465 | // never actually happen due to other checks that are present, but it's a simple and logical check to not |
| 466 | // count payments received before the order was placed. |
| 467 | |
| 468 | $check_address_for_payment_service_result = $this->payment_service->check_address_for_payment( $payment_address ); |
| 469 | |
| 470 | // Always "update" transactions here to record the time it was last checked. |
| 471 | $this->wallet_service->update_address_transactions_posts( $payment_address, $check_address_for_payment_service_result->all_transactions ); |
| 472 | |
| 473 | // If there are new transactions, fire an action to let integrations know. |
| 474 | $this->maybe_fire_new_transactions_seen_action( $payment_address, $check_address_for_payment_service_result ); |
| 475 | |
| 476 | // `$payment_address` should be NOT refreshed at this point, hopefully none of the methods called before this called `::refresh()`. |
| 477 | $this->maybe_mark_address_as_paid( $payment_address, $check_address_for_payment_service_result ); |
| 478 | |
| 479 | return new Check_Address_For_Payment_Result( |
| 480 | check_address_for_payment_service_result: $check_address_for_payment_service_result, |
| 481 | is_paid: $check_address_for_payment_service_result->is_paid(), |
| 482 | refreshed_address: $this->wallet_service->refresh_address( $payment_address ), |
| 483 | ); |
| 484 | } |
| 485 | |
| 486 | /** |
| 487 | * Fire the `bh_wp_bitcoin_gateway_new_transactions_seen` action, whe appropriate. |
| 488 | * |
| 489 | * @param Bitcoin_Address $payment_address The address that was checked by cron / checked by user interaction. |
| 490 | * @param Check_Address_For_Payment_Service_Result $check_address_for_payment_service_result The full details of before/after transactions and the confirmed/unconfirmed values based on the address's requirements. |
| 491 | */ |
| 492 | protected function maybe_fire_new_transactions_seen_action( |
| 493 | Bitcoin_Address $payment_address, |
| 494 | Check_Address_For_Payment_Service_Result $check_address_for_payment_service_result |
| 495 | ): void { |
| 496 | |
| 497 | if ( ! $check_address_for_payment_service_result->is_updated() ) { |
| 498 | return; |
| 499 | } |
| 500 | |
| 501 | $payment_address = $this->wallet_service->refresh_address( $payment_address ); |
| 502 | $order_post_id = $payment_address->get_order_id(); |
| 503 | $integration_id = $payment_address->get_integration_id(); |
| 504 | |
| 505 | /** |
| 506 | * When new transactions have been seen for a payment address, fire an action so integrations can act, |
| 507 | * |
| 508 | * E.g. to log the txid+time+href to blockchain.com/explorer as notes on a WooCommerce order. |
| 509 | * |
| 510 | * @example {@see Order::new_transactions_seen()} |
| 511 | * |
| 512 | * @param string|class-string|null $integration_id The bh-wp-bitcoin-gateway integration that "owns" the payment address. |
| 513 | * @param ?int $order_post_id The post_id that the integration will know how to manage. |
| 514 | * @param Bitcoin_Address $payment_address The payment address that was assigned to that order. |
| 515 | * @param Check_Address_For_Payment_Service_Result $check_address_for_payment_service_result The prior state + new transaction detail. |
| 516 | */ |
| 517 | do_action( 'bh_wp_bitcoin_gateway_new_transactions_seen', $integration_id, $order_post_id, $payment_address, $check_address_for_payment_service_result ); |
| 518 | } |
| 519 | |
| 520 | /** |
| 521 | * Mark a Bitcoin address as paid and notify integrations. |
| 522 | * |
| 523 | * TODO: maybe split this into ~"set address status to used" and ~"fire action to alert integrations". |
| 524 | * |
| 525 | * @used-by self::check_address_for_payment() |
| 526 | * |
| 527 | * @param Bitcoin_Address $payment_address The Bitcoin address to mark as paid. |
| 528 | * @param Check_Address_For_Payment_Service_Result $check_address_for_payment_service_result The full detail of the before/after transactions for this address. |
| 529 | * @throws BH_WP_Bitcoin_Gateway_Exception If there is no target amount set on the payment address. |
| 530 | */ |
| 531 | protected function maybe_mark_address_as_paid( |
| 532 | Bitcoin_Address $payment_address, |
| 533 | Check_Address_For_Payment_Service_Result $check_address_for_payment_service_result |
| 534 | ): Mark_Address_As_Paid_Result { |
| 535 | |
| 536 | if ( ! $check_address_for_payment_service_result->is_paid() ) { |
| 537 | return new Mark_Address_As_Paid_Result( |
| 538 | $payment_address, |
| 539 | $payment_address->get_status(), |
| 540 | ); |
| 541 | } |
| 542 | |
| 543 | $target_amount = $payment_address->get_target_amount(); |
| 544 | if ( is_null( $target_amount ) ) { |
| 545 | throw new BH_WP_Bitcoin_Gateway_Exception( 'No target payment amount on address "' . $payment_address->get_raw_address() . '"' ); |
| 546 | } |
| 547 | |
| 548 | $status_before = $payment_address->get_status(); |
| 549 | |
| 550 | $this->wallet_service->set_payment_address_status( |
| 551 | address: $payment_address, |
| 552 | status: Bitcoin_Address_Status::USED |
| 553 | ); |
| 554 | |
| 555 | $this->maybe_fire_mark_address_paid_action( $payment_address, $check_address_for_payment_service_result ); |
| 556 | |
| 557 | return new Mark_Address_As_Paid_Result( |
| 558 | $payment_address, |
| 559 | $status_before, |
| 560 | ); |
| 561 | } |
| 562 | |
| 563 | /** |
| 564 | * Fire `bh_wp_bitcoin_gateway_payment_received` action when `$target_amount` is reached. |
| 565 | * |
| 566 | * @param Bitcoin_Address $payment_address_before The payment address (we will refresh this). |
| 567 | * @param Check_Address_For_Payment_Service_Result $check_address_for_payment_service_result The details of the new transactions. |
| 568 | */ |
| 569 | protected function maybe_fire_mark_address_paid_action( |
| 570 | Bitcoin_Address $payment_address_before, |
| 571 | Check_Address_For_Payment_Service_Result $check_address_for_payment_service_result |
| 572 | ): void { |
| 573 | |
| 574 | // We've probably already checked this before reaching this point. |
| 575 | if ( ! $check_address_for_payment_service_result->is_paid() ) { |
| 576 | return; |
| 577 | } |
| 578 | |
| 579 | $order_post_id = $payment_address_before->get_order_id(); |
| 580 | $integration_id = $payment_address_before->get_integration_id(); |
| 581 | $payment_address = $this->wallet_service->refresh_address( $payment_address_before ); |
| 582 | |
| 583 | /** |
| 584 | * |
| 585 | * |
| 586 | * `bh_wp_bitcoin_gateway_new_transactions_seen` will _always_ be fired before this. |
| 587 | * |
| 588 | * {@see Check_Address_For_Payment_Service_Result::$queried_address} has the state of the address before. |
| 589 | * |
| 590 | * @param string|class-string|null $integration_id The bh-wp-bitcoin-gateway integration that "owns" the payment address. |
| 591 | * @param ?int $order_post_id The post_id that the integration will know how to manage. |
| 592 | * @param Bitcoin_Address $payment_address The payment address that was assigned to that order. |
| 593 | * @param Check_Address_For_Payment_Service_Result $check_address_for_payment_service_result The prior state + new transaction detail. |
| 594 | */ |
| 595 | do_action( 'bh_wp_bitcoin_gateway_payment_received', $integration_id, $order_post_id, $payment_address, $check_address_for_payment_service_result ); |
| 596 | } |
| 597 | |
| 598 | /** |
| 599 | * Get saved transactions for a Bitcoin address (`null` if never checked). |
| 600 | * |
| 601 | * @param Bitcoin_Address $bitcoin_address The Bitcoin address to get transactions for. |
| 602 | * @return ?array<int,Bitcoin_Transaction> WP post_id: transaction object. |
| 603 | * @throws BH_WP_Bitcoin_Gateway_Exception If one of the post IDs does not match the transaction post type. |
| 604 | */ |
| 605 | public function get_saved_transactions( Bitcoin_Address $bitcoin_address ): ?array { |
| 606 | |
| 607 | $transaction_post_ids = $bitcoin_address->get_tx_ids(); |
| 608 | |
| 609 | if ( is_null( $transaction_post_ids ) ) { |
| 610 | return null; |
| 611 | } |
| 612 | |
| 613 | return $this->payment_service->get_saved_transactions( array_keys( $transaction_post_ids ) ); |
| 614 | } |
| 615 | } |