Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
35.85% covered (danger)
35.85%
38 / 106
11.76% covered (danger)
11.76%
2 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Bitcoin_Wallet_Service
35.85% covered (danger)
35.85%
38 / 106
11.76% covered (danger)
11.76%
2 / 17
251.03
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_or_save_wallet_for_xpub
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 has_gateway_recorded
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 get_wallet_by_wp_post_id
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_all_wallets
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 generate_new_addresses
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 generate_new_addresses_for_wallet
90.32% covered (success)
90.32%
28 / 31
0.00% covered (danger)
0.00%
0 / 1
3.01
 get_assigned_bitcoin_addresses
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 refresh_address
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_unused_bitcoin_addresses
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 has_unused_bitcoin_address
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 get_unknown_bitcoin_addresses
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 set_payment_address_status
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 assign_order_to_bitcoin_payment_address
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 has_assigned_bitcoin_addresses
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_saved_address_by_bitcoin_payment_address
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 update_address_transactions_posts
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
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
8namespace BrianHenryIE\WP_Bitcoin_Gateway\API\Services;
9
10use BrianHenryIE\WP_Bitcoin_Gateway\Action_Scheduler\Background_Jobs_Actions_Handler;
11use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Payments\Transaction_Interface;
12use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Address;
13use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Address_Status;
14use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Wallet;
15use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Wallet_Status;
16use BrianHenryIE\WP_Bitcoin_Gateway\API\Helpers\Generate_Address_API_Interface;
17use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Results\Addresses_Generation_Result;
18use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Exceptions\BH_WP_Bitcoin_Gateway_Exception;
19use BrianHenryIE\WP_Bitcoin_Gateway\API\Repositories\Bitcoin_Address_Repository;
20use BrianHenryIE\WP_Bitcoin_Gateway\API\Repositories\Bitcoin_Wallet_Repository;
21use BrianHenryIE\WP_Bitcoin_Gateway\API\Repositories\Queries\WP_Posts_Query_Order;
22use BrianHenryIE\WP_Bitcoin_Gateway\API\Services\Results\Get_Wallet_For_Xpub_Service_Result;
23use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Money;
24use DateTimeImmutable;
25use InvalidArgumentException;
26use Psr\Log\LoggerAwareInterface;
27use Psr\Log\LoggerAwareTrait;
28
29/**
30 * Local functions to create addresses; query addresses; update addresses.
31 */
32class 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}