Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
72.86% |
51 / 70 |
|
42.86% |
3 / 7 |
CRAP | |
0.00% |
0 / 1 |
| Payment_Service | |
72.86% |
51 / 70 |
|
42.86% |
3 / 7 |
22.78 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| check_address_for_payment | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
2 | |||
| get_blockchain_height | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
6 | |||
| update_address_transactions | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
2 | |||
| get_address_confirmed_received | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
3.01 | |||
| get_value_for_transaction | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| get_saved_transactions | |
75.00% |
3 / 4 |
|
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 | |
| 11 | namespace BrianHenryIE\WP_Bitcoin_Gateway\API\Services; |
| 12 | |
| 13 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Payments\Bitcoin_Transaction; |
| 14 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Address; |
| 15 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Clients\Blockchain_API_Interface; |
| 16 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Exceptions\BH_WP_Bitcoin_Gateway_Exception; |
| 17 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Exceptions\Rate_Limit_Exception; |
| 18 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Payments\Transaction_Interface; |
| 19 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Payments\Transaction_VOut; |
| 20 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Results\Update_Address_Transactions_Result; |
| 21 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Repositories\Bitcoin_Transaction_Repository; |
| 22 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Services\Results\Check_Address_For_Payment_Service_Result; |
| 23 | use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Exception\MoneyMismatchException; |
| 24 | use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Exception\UnknownCurrencyException; |
| 25 | use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Money; |
| 26 | use DateInterval; |
| 27 | use DateMalformedStringException; |
| 28 | use DateTimeImmutable; |
| 29 | use Psr\Log\LoggerAwareInterface; |
| 30 | use Psr\Log\LoggerAwareTrait; |
| 31 | use 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 | */ |
| 37 | class 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 | } |