Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.86% covered (warning)
72.86%
51 / 70
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Payment_Service
72.86% covered (warning)
72.86%
51 / 70
42.86% covered (danger)
42.86%
3 / 7
22.78
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
 check_address_for_payment
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 get_blockchain_height
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 update_address_transactions
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 get_address_confirmed_received
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 get_value_for_transaction
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 get_saved_transactions
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
1<?php
2/**
3 * Checks the blockchain for transactions; calculates total amount received.
4 *
5 * @see https://en.bitcoin.it/wiki/Confirmation
6 * @see https://blog.lopp.net/how-many-bitcoin-confirmations-is-enough/
7 *
8 * @package brianhenryie/bh-wp-bitcoin-gateway
9 */
10
11namespace BrianHenryIE\WP_Bitcoin_Gateway\API\Services;
12
13use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Payments\Bitcoin_Transaction;
14use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Address;
15use BrianHenryIE\WP_Bitcoin_Gateway\API\Clients\Blockchain_API_Interface;
16use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Exceptions\BH_WP_Bitcoin_Gateway_Exception;
17use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Exceptions\Rate_Limit_Exception;
18use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Payments\Transaction_Interface;
19use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Payments\Transaction_VOut;
20use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Results\Update_Address_Transactions_Result;
21use BrianHenryIE\WP_Bitcoin_Gateway\API\Repositories\Bitcoin_Transaction_Repository;
22use BrianHenryIE\WP_Bitcoin_Gateway\API\Services\Results\Check_Address_For_Payment_Service_Result;
23use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Exception\MoneyMismatchException;
24use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Exception\UnknownCurrencyException;
25use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Money;
26use DateInterval;
27use DateMalformedStringException;
28use DateTimeImmutable;
29use Psr\Log\LoggerAwareInterface;
30use Psr\Log\LoggerAwareTrait;
31use Psr\Log\LoggerInterface;
32
33/**
34 * Functions for querying the blockchain height, querying APIs for transactions, then summing the value of those
35 * transactions in context of the number of required confirmations.
36 */
37class Payment_Service implements LoggerAwareInterface {
38    use LoggerAwareTrait;
39
40    /**
41     * Constructor
42     *
43     * @param Blockchain_API_Interface       $blockchain_api External API for querying the blockchain height and for new transactions.
44     * @param Bitcoin_Transaction_Repository $bitcoin_transaction_repository Used to save, retrieve and update transactions saved as WP_Posts.
45     * @param LoggerInterface                $logger A PSR logger for debug/error.
46     */
47    public function __construct(
48        protected Blockchain_API_Interface $blockchain_api,
49        protected Bitcoin_Transaction_Repository $bitcoin_transaction_repository,
50        LoggerInterface $logger
51    ) {
52        $this->setLogger( $logger );
53    }
54
55    /**
56     * Check a Bitcoin address for payment and mark as paid if sufficient funds received.
57     *
58     * TODO: Get `$required_confirmations` from global setting / address specific.
59     *
60     * @param Bitcoin_Address $bitcoin_address The Bitcoin address to check.
61     * @param int             $required_confirmations The number of additional blocks that must be mined to avoid confirming on an orphaned block.
62     */
63    public function check_address_for_payment(
64        Bitcoin_Address $bitcoin_address,
65        int $required_confirmations = 6
66    ): Check_Address_For_Payment_Service_Result {
67
68        $update_address_transactions_result = $this->update_address_transactions( $bitcoin_address );
69
70        $blockchain_height = $this->get_blockchain_height();
71
72        $confirmed_amount = $this->get_address_confirmed_received(
73            raw_address: $bitcoin_address->get_raw_address(),
74            blockchain_height: $blockchain_height,
75            required_confirmations: $required_confirmations,
76            transactions: $update_address_transactions_result->all_transactions
77        );
78
79        return new Check_Address_For_Payment_Service_Result(
80            update_address_transactions_result: $update_address_transactions_result,
81            blockchain_height: $blockchain_height,
82            required_confirmations: $required_confirmations,
83            confirmed_received: $confirmed_amount,
84        );
85    }
86
87    /**
88     * Fetch the current blockchain height from the API (no more than once every ten minutes).
89     *
90     * Needed to compare transactions' block heights to the number of required confirmations.
91     *
92     * TODO: The time of the block would be useful to know so as not to check again for ten minutes. For now, we are
93     * using the time we checked at.
94     *
95     * @throws Rate_Limit_Exception If the API in use returns a 429 response.
96     */
97    protected function get_blockchain_height(): int {
98        $option_name                  = 'bh_wp_bitcoin_gateway_blockchain_height';
99        $saved_blockchain_height_json = get_option( $option_name );
100        if ( is_string( $saved_blockchain_height_json ) ) {
101            /** @var ?array{blockchain_height?:int, time?:string} $saved_blockchain_height_array */
102            $saved_blockchain_height_array = json_decode( $saved_blockchain_height_json, true );
103            if ( is_array( $saved_blockchain_height_array ) && isset( $saved_blockchain_height_array['time'], $saved_blockchain_height_array['blockchain_height'] ) ) {
104                try {
105                    $saved_blockchain_height_date_time = new DateTimeImmutable( $saved_blockchain_height_array['time'] );
106                    $ten_minutes_ago                   = new DateTimeImmutable()->sub( new DateInterval( 'PT10M' ) );
107                    if ( $saved_blockchain_height_date_time > $ten_minutes_ago ) {
108                        return (int) $saved_blockchain_height_array['blockchain_height'];
109                    }
110                } catch ( DateMalformedStringException $e ) {
111                    // The stored time is invalid, so we'll fetch a new value.
112                    $this->logger->warning( 'Could not parse stored blockchain height time. Refetching.', array( 'error' => $e->getMessage() ) );
113                }
114            }
115        }
116        $latest_block_height = $this->blockchain_api->get_blockchain_height();
117
118        update_option(
119            $option_name,
120            wp_json_encode(
121                array(
122                    'blockchain_height' => $latest_block_height,
123                    'time'              => new DateTimeImmutable()->format( \DateTimeInterface::ATOM ),
124                )
125            )
126        );
127
128        return $latest_block_height;
129    }
130
131    /**
132     * Remotely check/fetch the latest data for an address.
133     *
134     * TODO: use post_status to indicate mempool.
135     * TODO: use payment address wp_comments table to log every time it is checked, which API was used.
136     *
137     * @param Bitcoin_Address $address The Bitcoin address to query the blockchain API for, retrieving all transactions where this address received funds.
138     *
139     * @throws Rate_Limit_Exception When the blockchain API returns HTTP 429, indicating too many requests and the service should back off until the reset time.
140     */
141    public function update_address_transactions( Bitcoin_Address $address ): Update_Address_Transactions_Result {
142        // TODO: sort by last updated.
143        // TODO: retry on rate limit.
144
145        /** @var array<int, Bitcoin_Transaction> $transactions_by_post_ids */
146        $transactions_by_post_ids = array();
147
148        /** @var array<int, string> $tx_ids_by_post_ids */
149        $tx_ids_by_post_ids = array();
150
151        /**
152         * First, get `get_saved_transactions( array_keys( $address->get_tx_ids() ) )` then `::get_by_transaction_id()`
153         * and merge objects rather than overwriting.
154         */
155
156        $updated_transactions = $this->blockchain_api->get_transactions_received( btc_address: $address->get_raw_address() );
157
158        foreach ( $updated_transactions as $transaction ) {
159
160            // TODO: Don't overwrite an existing one. associate_bitcoin_address_post_ids_to_transaction().
161            $saved_transaction                                       = $this->bitcoin_transaction_repository->save_new(
162                $transaction,
163                $address
164            );
165            $tx_ids_by_post_ids[ $saved_transaction->get_post_id() ] = $saved_transaction->get_txid();
166            $transactions_by_post_ids[ $saved_transaction->get_post_id() ] = $saved_transaction;
167        }
168
169        /**
170         * Save an array of post_id:tx_id to the address object for quick reference, e.g. before/after checks.
171         */
172        // TODO: refresh. make sure to record changes for the result object.
173        // $address = $this->bitcoin_address_repository->refresh($address).
174
175        // TODO: run a check on the address to see has the amount been paid, then  update the address status/state.
176
177        // TODO: do_action on changes for logging.
178
179        // TODO: Check are any previous transactions no longer present!!! (unlikely?).
180
181        return new Update_Address_Transactions_Result(
182            queried_address: $address,
183            known_tx_ids_before: $address->get_tx_ids(),
184            all_transactions: $transactions_by_post_ids,
185        );
186
187        // Throws when e.g. API is offline.
188        // TODO: log, rate limit, notify.
189    }
190
191    /**
192     * From the received transactions, sum those who have enough confirmations.
193     *
194     * @param string                  $raw_address The raw Bitcoin address to calculate balance for.
195     * @param int                     $blockchain_height The current blockchain height. (TODO: explain why).
196     * @param int                     $required_confirmations A confirmation is a subsequent block mined after the transaction.
197     * @param Transaction_Interface[] $transactions Array of transactions to inspect for confirmations and relevant amounts received.
198     *
199     * @throws MoneyMismatchException If the calculations were somehow using two different currencies.
200     * @throws UnknownCurrencyException If `BTC` has not correctly been added to Money's currency list.
201     */
202    public function get_address_confirmed_received( string $raw_address, int $blockchain_height, int $required_confirmations, array $transactions ): Money {
203        return array_reduce(
204            $transactions,
205            function ( Money $carry, Transaction_Interface $transaction ) use ( $raw_address, $blockchain_height, $required_confirmations ) {
206                // TODO: run contract tests to see is this how mempool does behave.
207                if ( is_null( $transaction->get_block_height() ) ) {
208                    return $carry;
209                }
210                if ( ( $blockchain_height - $transaction->get_block_height() ) >= $required_confirmations ) {
211                    return $carry->plus( $this->get_value_for_transaction( $raw_address, $transaction ) );
212                }
213                return $carry;
214            },
215            Money::of( 0, 'BTC' )
216        );
217    }
218
219    /**
220     * Sum the transaction's vector-outputs sent to a specific address.
221     *
222     * @param string                $to_address The Bitcoin address to calculate value for.
223     * @param Transaction_Interface $transaction The transaction to calculate value from.
224     * @throws UnknownCurrencyException 0% likely, this exception would be happening somewhere else first.
225     */
226    protected function get_value_for_transaction( string $to_address, Transaction_Interface $transaction ): Money {
227
228        return array_reduce(
229            $transaction->get_v_out(),
230            function ( Money $carry, Transaction_VOut $out ) use ( $to_address ) {
231                if ( $to_address === $out->scriptpubkey_address ) {
232                    return $carry->plus( $out->value );
233                }
234                return $carry;
235            },
236            Money::of( 0, 'BTC' )
237        );
238    }
239
240    /**
241     * Return the previously saved transactions by their post_id.
242     *
243     * The Bitcoin_Address's wp_post has a meta key that holds an array of post ids for saved transactions.
244     *
245     * @see Bitcoin_Transaction_Repository::get_by_wp_post_id()
246     *
247     * @see Addresses_List_Table::column_transactions_count() When displaying all addresses.
248     * @used-by API::get_saved_transactions() When displaying all addresses.
249     *
250     * @param int[] $transaction_post_ids A list of known post ids, presumably linked to an address.
251     *
252     * @return null|array<int, Bitcoin_Transaction> Post_id:transaction object; where null suggests there was nothing saved before, and an empty array suggests it has been checked but no transactions had been seen.
253     * @throws BH_WP_Bitcoin_Gateway_Exception When a stored transaction post ID cannot be converted to a Bitcoin_Transaction object.
254     */
255    public function get_saved_transactions(
256        array $transaction_post_ids,
257    ): ?array {
258
259        $transaction_by_post_ids = array();
260        foreach ( $transaction_post_ids as $transaction_post_id ) {
261            $transaction_by_post_ids[ $transaction_post_id ] = $this->bitcoin_transaction_repository->get_by_post_id( $transaction_post_id );
262        }
263
264        return $transaction_by_post_ids;
265    }
266}