Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
35.85% |
38 / 106 |
|
11.76% |
2 / 17 |
CRAP | |
0.00% |
0 / 1 |
| Bitcoin_Wallet_Service | |
35.85% |
38 / 106 |
|
11.76% |
2 / 17 |
251.03 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| get_or_save_wallet_for_xpub | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
20 | |||
| has_gateway_recorded | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
| get_wallet_by_wp_post_id | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| get_all_wallets | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| generate_new_addresses | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
| generate_new_addresses_for_wallet | |
90.32% |
28 / 31 |
|
0.00% |
0 / 1 |
3.01 | |||
| get_assigned_bitcoin_addresses | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| refresh_address | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| get_unused_bitcoin_addresses | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| has_unused_bitcoin_address | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
| get_unknown_bitcoin_addresses | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| set_payment_address_status | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
| assign_order_to_bitcoin_payment_address | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
| has_assigned_bitcoin_addresses | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| get_saved_address_by_bitcoin_payment_address | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| update_address_transactions_posts | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Save xpub as Wallet object, create new payment addresses for wallets, associate transactions with payment address. |
| 4 | * |
| 5 | * @package brianhenryie/bh-wp-bitcoin-gateway |
| 6 | */ |
| 7 | |
| 8 | namespace BrianHenryIE\WP_Bitcoin_Gateway\API\Services; |
| 9 | |
| 10 | use BrianHenryIE\WP_Bitcoin_Gateway\Action_Scheduler\Background_Jobs_Actions_Handler; |
| 11 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Payments\Transaction_Interface; |
| 12 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Address; |
| 13 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Address_Status; |
| 14 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Wallet; |
| 15 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Wallet_Status; |
| 16 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Helpers\Generate_Address_API_Interface; |
| 17 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Results\Addresses_Generation_Result; |
| 18 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Exceptions\BH_WP_Bitcoin_Gateway_Exception; |
| 19 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Repositories\Bitcoin_Address_Repository; |
| 20 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Repositories\Bitcoin_Wallet_Repository; |
| 21 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Repositories\Queries\WP_Posts_Query_Order; |
| 22 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Services\Results\Get_Wallet_For_Xpub_Service_Result; |
| 23 | use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Money; |
| 24 | use DateTimeImmutable; |
| 25 | use InvalidArgumentException; |
| 26 | use Psr\Log\LoggerAwareInterface; |
| 27 | use Psr\Log\LoggerAwareTrait; |
| 28 | |
| 29 | /** |
| 30 | * Local functions to create addresses; query addresses; update addresses. |
| 31 | */ |
| 32 | class Bitcoin_Wallet_Service implements LoggerAwareInterface { |
| 33 | use LoggerAwareTrait; |
| 34 | |
| 35 | /** |
| 36 | * Constructor |
| 37 | * |
| 38 | * @param Generate_Address_API_Interface $generate_address_api Local class to derive payment addresses from a wallet's master public key. |
| 39 | * @param Bitcoin_Wallet_Repository $bitcoin_wallet_repository Used to save, retrieve and update wallets saved as WP_Posts. |
| 40 | * @param Bitcoin_Address_Repository $bitcoin_address_repository Used to save, retrieve and update payment addresses saved as WP_Posts. |
| 41 | */ |
| 42 | public function __construct( |
| 43 | protected Generate_Address_API_Interface $generate_address_api, |
| 44 | protected Bitcoin_Wallet_Repository $bitcoin_wallet_repository, |
| 45 | protected Bitcoin_Address_Repository $bitcoin_address_repository, |
| 46 | ) { |
| 47 | } |
| 48 | |
| 49 | /** |
| 50 | * Get or create a Bitcoin_Wallet from a master public key. Optionally associate a gateway id with it. |
| 51 | * |
| 52 | * @param string $xpub The master public key – xpub/ypub/zpub. |
| 53 | * @param ?array{integration:class-string, gateway_id:string} $gateway_details Optional gateway id to associate the wallet with. |
| 54 | * @throws BH_WP_Bitcoin_Gateway_Exception If a previous bug has saved two wp_posts for the same xpub. |
| 55 | */ |
| 56 | public function get_or_save_wallet_for_xpub( string $xpub, ?array $gateway_details = null ): Get_Wallet_For_Xpub_Service_Result { |
| 57 | $existing_wallet = $this->bitcoin_wallet_repository->get_by_xpub( $xpub ); |
| 58 | |
| 59 | if ( $existing_wallet ) { |
| 60 | |
| 61 | if ( $gateway_details && ! $this->has_gateway_recorded( $existing_wallet, $gateway_details ) ) { |
| 62 | $this->bitcoin_wallet_repository->append_gateway_details( $existing_wallet, $gateway_details ); |
| 63 | } |
| 64 | |
| 65 | return new Get_Wallet_For_Xpub_Service_Result( |
| 66 | xpub: $xpub, |
| 67 | gateway_details: $gateway_details, |
| 68 | wallet: $existing_wallet, |
| 69 | is_new: false, |
| 70 | ); |
| 71 | } |
| 72 | |
| 73 | // TODO: Validate xpub, throw exception. |
| 74 | |
| 75 | $new_wallet = $this->bitcoin_wallet_repository->save_new( $xpub, $gateway_details ); |
| 76 | |
| 77 | return new Get_Wallet_For_Xpub_Service_Result( |
| 78 | xpub: $xpub, |
| 79 | gateway_details: $gateway_details, |
| 80 | wallet: $new_wallet, |
| 81 | is_new: true, |
| 82 | ); |
| 83 | } |
| 84 | |
| 85 | /** |
| 86 | * Check does is a wallet associated with a gateway. |
| 87 | * |
| 88 | * @param Bitcoin_Wallet $wallet The Wallet which may have multiple gateways attached. |
| 89 | * @param array{integration:class-string, gateway_id:string} $gateway_details Optional gateway id to associate the wallet with. |
| 90 | */ |
| 91 | protected function has_gateway_recorded( Bitcoin_Wallet $wallet, array $gateway_details ): bool { |
| 92 | return array_any( |
| 93 | $wallet->get_associated_gateways_details(), |
| 94 | fn( $saved_gateway_detail ) => |
| 95 | $saved_gateway_detail['integration'] === $gateway_details['integration'] |
| 96 | && |
| 97 | $saved_gateway_detail['gateway_id'] === $gateway_details['gateway_id'] |
| 98 | ); |
| 99 | } |
| 100 | |
| 101 | |
| 102 | /** |
| 103 | * Given a post_id, get the Bitcoin_Wallet. |
| 104 | * |
| 105 | * @used-by Background_Jobs_Actions_Handler::single_ensure_unused_addresses() To convert the job's args to objects. |
| 106 | * |
| 107 | * @param int $wallet_post_id The WordPress post id this wallet is stored under. |
| 108 | * |
| 109 | * @throws InvalidArgumentException When the supplied post_id is not a post of this type. |
| 110 | */ |
| 111 | public function get_wallet_by_wp_post_id( int $wallet_post_id ): Bitcoin_Wallet { |
| 112 | return $this->bitcoin_wallet_repository->get_by_wp_post_id( $wallet_post_id ); |
| 113 | } |
| 114 | |
| 115 | /** |
| 116 | * Get all saved wallets. |
| 117 | * |
| 118 | * @return Bitcoin_Wallet[] |
| 119 | */ |
| 120 | public function get_all_wallets(): array { |
| 121 | return $this->bitcoin_wallet_repository->get_all( |
| 122 | status: Bitcoin_Wallet_Status::ALL |
| 123 | ); |
| 124 | } |
| 125 | |
| 126 | /** |
| 127 | * If a wallet has fewer than 20 fresh addresses available, generate some more. |
| 128 | * |
| 129 | * @return Addresses_Generation_Result[] |
| 130 | * @see API_Interface::generate_new_addresses() |
| 131 | * @used-by CLI::generate_new_addresses() |
| 132 | * @used-by Background_Jobs_Actions_Handler::generate_new_addresses() |
| 133 | * @throws BH_WP_Bitcoin_Gateway_Exception When address derivation fails or addresses cannot be saved to the database. |
| 134 | */ |
| 135 | public function generate_new_addresses(): array { |
| 136 | |
| 137 | /** |
| 138 | * @var array<int, Addresses_Generation_Result> $results |
| 139 | */ |
| 140 | $results = array(); |
| 141 | |
| 142 | $wallets = $this->bitcoin_wallet_repository->get_all( Bitcoin_Wallet_Status::ALL ); |
| 143 | |
| 144 | foreach ( $wallets as $wallet ) { |
| 145 | $results[] = $this->generate_new_addresses_for_wallet( $wallet ); |
| 146 | } |
| 147 | |
| 148 | return $results; |
| 149 | } |
| 150 | |
| 151 | /** |
| 152 | * Derive new Bitcoin addresses for a saved wallet. |
| 153 | * |
| 154 | * @param Bitcoin_Wallet $wallet The wallet to generate child addresses from using its master public key and current address index. |
| 155 | * @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. |
| 156 | * |
| 157 | * @throws BH_WP_Bitcoin_Gateway_Exception When address derivation fails or addresses cannot be saved to the database. |
| 158 | */ |
| 159 | public function generate_new_addresses_for_wallet( Bitcoin_Wallet $wallet, int $generate_count = 2 ): Addresses_Generation_Result { |
| 160 | |
| 161 | // This will start the first address creation at index 0 and others at n+1. |
| 162 | $prior_index = $wallet->get_address_index(); |
| 163 | $address_index = $prior_index ?? -1; |
| 164 | |
| 165 | /** @var non-empty-array<Bitcoin_Address> $generated_addresses */ |
| 166 | $generated_addresses = array(); |
| 167 | $generated_addresses_count = 0; |
| 168 | |
| 169 | /** @var array<Bitcoin_Address> $orphaned_addresses */ |
| 170 | $orphaned_addresses = array(); |
| 171 | |
| 172 | do { |
| 173 | ++$address_index; |
| 174 | |
| 175 | $new_address_string = $this->generate_address_api->generate_address( $wallet->get_xpub(), $address_index ); |
| 176 | |
| 177 | $existing_address_post_id = $this->bitcoin_address_repository->get_post_id_for_address( $new_address_string ); |
| 178 | if ( ! is_null( $existing_address_post_id ) ) { |
| 179 | |
| 180 | $existing_address = $this->bitcoin_address_repository->get_by_post_id( $existing_address_post_id ); |
| 181 | |
| 182 | // The wallet was probably deleted and left orphaned saved addresses (likely to happen during testing). |
| 183 | if ( $existing_address->get_wallet_parent_post_id() !== $wallet->get_post_id() ) { |
| 184 | $this->bitcoin_address_repository->set_wallet_id( $existing_address, $wallet->get_post_id() ); |
| 185 | $orphaned_address = $this->bitcoin_address_repository->refresh( $existing_address ); |
| 186 | $orphaned_addresses[] = $orphaned_address; |
| 187 | } |
| 188 | |
| 189 | // Although inefficient to run this inside the loop, overall, searching past the known index could cause a PHP timeout. |
| 190 | // (emphasizing that this should be run as a scheduled task). |
| 191 | $this->bitcoin_wallet_repository->set_highest_address_index( $wallet, $address_index ); |
| 192 | continue; |
| 193 | } |
| 194 | |
| 195 | $bitcoin_address = $this->bitcoin_address_repository->save_new_address( |
| 196 | wallet: $wallet, |
| 197 | derivation_path_sequence_index: $address_index, |
| 198 | address: $new_address_string, |
| 199 | ); |
| 200 | |
| 201 | $generated_addresses[] = $bitcoin_address; |
| 202 | |
| 203 | ++$generated_addresses_count; |
| 204 | |
| 205 | } while ( $generated_addresses_count < $generate_count ); |
| 206 | |
| 207 | $this->bitcoin_wallet_repository->set_highest_address_index( $wallet, $address_index ); |
| 208 | |
| 209 | return new Addresses_Generation_Result( |
| 210 | wallet: $this->bitcoin_wallet_repository->refresh( $wallet ), |
| 211 | new_addresses: $generated_addresses, |
| 212 | orphaned_addresses: $orphaned_addresses, |
| 213 | prior_address_index: $prior_index, |
| 214 | ); |
| 215 | } |
| 216 | |
| 217 | /** |
| 218 | * @return Bitcoin_Address[] |
| 219 | */ |
| 220 | public function get_assigned_bitcoin_addresses(): array { |
| 221 | return $this->bitcoin_address_repository->get_assigned_bitcoin_addresses(); |
| 222 | } |
| 223 | |
| 224 | /** |
| 225 | * Fetch the address from the datastore again. I.e. it is immutable. |
| 226 | * |
| 227 | * @param Bitcoin_Address $address Existing object, presumably updated elsewhere. |
| 228 | */ |
| 229 | public function refresh_address( Bitcoin_Address $address ): Bitcoin_Address { |
| 230 | return $this->bitcoin_address_repository->refresh( $address ); |
| 231 | } |
| 232 | |
| 233 | /** |
| 234 | * Gets previously saved addresses which have at least once been checked and see to be unused. |
| 235 | * |
| 236 | * It may be the case that they have been used in the meantime. |
| 237 | * |
| 238 | * @param ?Bitcoin_Wallet $wallet Optional wallet to filter addresses by. |
| 239 | * @return Bitcoin_Address[] |
| 240 | */ |
| 241 | public function get_unused_bitcoin_addresses( ?Bitcoin_Wallet $wallet = null ): array { |
| 242 | |
| 243 | return $this->bitcoin_address_repository->get_unused_bitcoin_addresses( $wallet ); |
| 244 | } |
| 245 | |
| 246 | /** |
| 247 | * Local db query to check is there an unused address that has recently been verified as unused. |
| 248 | * |
| 249 | * We should be making remote calls to verify an address is unused regularly, both on cron and on customer activity, |
| 250 | * so this should generally return true, but can be used to schedule a background check. |
| 251 | * |
| 252 | * @param Bitcoin_Wallet $wallet The wallet we need a payment address for. |
| 253 | */ |
| 254 | public function has_unused_bitcoin_address( Bitcoin_Wallet $wallet ): bool { |
| 255 | |
| 256 | $unused_addresses = $this->bitcoin_address_repository->get_unused_bitcoin_addresses( |
| 257 | wallet: $wallet, |
| 258 | query_order: new WP_Posts_Query_Order( |
| 259 | count: 1, |
| 260 | order_by: 'post_modified', |
| 261 | order_direction: 'DESC', |
| 262 | ) |
| 263 | ); |
| 264 | |
| 265 | if ( empty( $unused_addresses ) ) { |
| 266 | return false; |
| 267 | } |
| 268 | |
| 269 | $address = $unused_addresses[ array_key_first( $unused_addresses ) ]; |
| 270 | |
| 271 | return $address->was_checked_recently(); |
| 272 | } |
| 273 | |
| 274 | /** |
| 275 | * Find all generated addresses that have never been checked for transactions. |
| 276 | * |
| 277 | * @return Bitcoin_Address[] |
| 278 | */ |
| 279 | public function get_unknown_bitcoin_addresses(): array { |
| 280 | return $this->bitcoin_address_repository->get_unknown_bitcoin_addresses(); |
| 281 | } |
| 282 | |
| 283 | /** |
| 284 | * Update a payment address's status, e.g. once paid, set it to used. |
| 285 | * |
| 286 | * @param Bitcoin_Address $address Address object to update. |
| 287 | * @param Bitcoin_Address_Status $status The new status to set. |
| 288 | */ |
| 289 | public function set_payment_address_status( |
| 290 | Bitcoin_Address $address, |
| 291 | Bitcoin_Address_Status $status |
| 292 | ): void { |
| 293 | $this->bitcoin_address_repository->set_status( |
| 294 | address: $address, |
| 295 | status: $status, |
| 296 | ); |
| 297 | } |
| 298 | |
| 299 | /** |
| 300 | * Associate the Bitcoin Address with an order's post_id, set the expected amount to be paid, change the status |
| 301 | * to "assigned". |
| 302 | * |
| 303 | * @see Bitcoin_Address_Status::ASSIGNED |
| 304 | * |
| 305 | * @param Bitcoin_Address $address The Bitcoin payment address to link. |
| 306 | * @param string|class-string $integration_id The plugin that is using this address. |
| 307 | * @param int $order_id The post_id (e.g. WooCommerce order id) that transactions to this address represent payment for. |
| 308 | * @param Money $btc_total The target amount to be paid, after which the order should be updated. |
| 309 | */ |
| 310 | public function assign_order_to_bitcoin_payment_address( |
| 311 | Bitcoin_Address $address, |
| 312 | string $integration_id, |
| 313 | int $order_id, |
| 314 | Money $btc_total |
| 315 | ): Bitcoin_Address { |
| 316 | $this->bitcoin_address_repository->assign_to_order( |
| 317 | address: $address, |
| 318 | integration_id: $integration_id, |
| 319 | order_id: $order_id, |
| 320 | btc_total: $btc_total, |
| 321 | ); |
| 322 | return $this->refresh_address( $address ); |
| 323 | } |
| 324 | |
| 325 | /** |
| 326 | * Check do we have at least 1 assigned address, i.e. an address waiting for transactions. |
| 327 | * |
| 328 | * Across all wallets. |
| 329 | * |
| 330 | * @used-by Background_Jobs_Actions_Handler::check_assigned_addresses_for_transactions() |
| 331 | */ |
| 332 | public function has_assigned_bitcoin_addresses(): bool { |
| 333 | return $this->bitcoin_address_repository->has_assigned_bitcoin_addresses(); |
| 334 | } |
| 335 | |
| 336 | /** |
| 337 | * Fetch a previously saved Bitcoin_Address object from the repository. E.g. an order may know the address as a |
| 338 | * string but not its post_id. |
| 339 | * |
| 340 | * @param string $assigned_payment_address Derived Bitcoin payment address. |
| 341 | * @throws BH_WP_Bitcoin_Gateway_Exception If the address is not found. |
| 342 | */ |
| 343 | public function get_saved_address_by_bitcoin_payment_address( string $assigned_payment_address ): Bitcoin_Address { |
| 344 | $post_id = $this->bitcoin_address_repository->get_post_id_for_address( $assigned_payment_address ); |
| 345 | if ( is_null( $post_id ) ) { |
| 346 | throw new BH_WP_Bitcoin_Gateway_Exception( 'No saved payment address found for: ' . $assigned_payment_address ); |
| 347 | } |
| 348 | return $this->bitcoin_address_repository->get_by_post_id( $post_id ); |
| 349 | } |
| 350 | |
| 351 | /** |
| 352 | * Update a payment addresses to link to its related transactions. |
| 353 | * |
| 354 | * @see Bitcoin_Address::get_tx_ids() |
| 355 | * |
| 356 | * @param Bitcoin_Address $address The Bitcoin address the transactions relate to. |
| 357 | * @param array<int, Transaction_Interface> $all_transactions The transactions, indexed by their post_ids. |
| 358 | */ |
| 359 | public function update_address_transactions_posts( Bitcoin_Address $address, array $all_transactions ): void { |
| 360 | /** |
| 361 | * @var array<int,string> $existing_meta_transactions_post_ids |
| 362 | */ |
| 363 | $existing_meta_transactions_post_ids = $address->get_tx_ids(); |
| 364 | |
| 365 | if ( empty( $existing_meta_transactions_post_ids ) ) { |
| 366 | $existing_meta_transactions_post_ids = array(); |
| 367 | } |
| 368 | |
| 369 | $new_transactions_post_ids = array(); |
| 370 | |
| 371 | foreach ( $all_transactions as $post_id => $transaction ) { |
| 372 | if ( ! isset( $existing_meta_transactions_post_ids[ $post_id ] ) ) { |
| 373 | $new_transactions_post_ids[ $post_id ] = $transaction->get_txid(); |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | $updated_transactions_post_ids = $existing_meta_transactions_post_ids + $new_transactions_post_ids; |
| 378 | |
| 379 | // Even if we have no changes, we want to update the object's modified timestamp. |
| 380 | $this->bitcoin_address_repository->set_transactions_post_ids_to_address( $address, $updated_transactions_post_ids ); |
| 381 | } |
| 382 | } |