Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
69.70% covered (warning)
69.70%
46 / 66
16.67% covered (danger)
16.67%
1 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Blockstream_Info_API
69.70% covered (warning)
69.70%
46 / 66
16.67% covered (danger)
16.67%
1 / 6
23.12
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_address_data
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 get_address_balance
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
2.02
 get_received_by_address
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 get_transactions_received
84.38% covered (warning)
84.38%
27 / 32
0.00% covered (danger)
0.00%
0 / 1
5.10
 get_blockchain_height
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * @see https://github.com/Blockstream/esplora/blob/master/API.md
4 *
5 * @package    brianhenryie/bh-wp-bitcoin-gateway
6 */
7
8namespace BrianHenryIE\WP_Bitcoin_Gateway\API\Blockchain;
9
10use BrianHenryIE\WP_Bitcoin_Gateway\API\Blockchain_API_Interface;
11use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Address_Balance;
12use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Transaction_Interface;
13use DateTimeImmutable;
14use DateTimeInterface;
15use DateTimeZone;
16use BrianHenryIE\WP_Bitcoin_Gateway\API_Interface;
17use JsonException;
18use Psr\Log\LoggerAwareInterface;
19use Psr\Log\LoggerAwareTrait;
20use Psr\Log\LoggerInterface;
21
22/**
23 * @phpstan-type Stats array{funded_txo_count:int, funded_txo_sum:int, spent_txo_count:int, spent_txo_sum:int, tx_count:int}
24 * @phpstan-import-type TransactionArray from API_Interface as TransactionArray
25 */
26class Blockstream_Info_API implements Blockchain_API_Interface, LoggerAwareInterface {
27    use LoggerAwareTrait;
28
29    public function __construct( LoggerInterface $logger ) {
30        $this->logger = $logger;
31    }
32
33    /**
34     *
35     * @return array{address:string, chain_stats:Stats, mempool_stats:Stats}
36     */
37    protected function get_address_data( string $btc_address ): array {
38        $address_info_url = 'https://blockstream.info/api/address/' . $btc_address;
39
40        $this->logger->debug( 'URL: ' . $address_info_url );
41
42        $request_response = wp_remote_get( $address_info_url );
43
44        if ( is_wp_error( $request_response ) || 200 !== $request_response['response']['code'] ) {
45            throw new \Exception();
46        }
47
48        $address_info = json_decode( $request_response['body'], true );
49
50        return $address_info;
51    }
52
53    /**
54     * @see Blockchain_API_Interface::get_address_balance()
55     *
56     * @param string $btc_address
57     * @param int    $number_of_confirmations
58     *
59     * @return array{confirmed_balance:string, unconfirmed_balance:string, number_of_confirmations:int}
60     * @throws \Exception
61     */
62    public function get_address_balance( string $btc_address, int $number_of_confirmations ): Address_Balance {
63
64        if ( 1 !== $number_of_confirmations ) {
65            error_log( __CLASS__ . ' ' . __FUNCTION__ . ' using 1 for number of confirmations.' );
66
67            // Maybe `number_of_confirmations` should be block_height and the client can decide is that enough.
68        }
69
70        $result                            = array();
71        $result['number_of_confirmations'] = $number_of_confirmations;
72
73        $address_info = $this->get_address_data( $btc_address );
74
75        $confirmed_balance = ( $address_info['chain_stats']['funded_txo_sum'] - $address_info['chain_stats']['spent_txo_sum'] ) / 100000000;
76        $this->logger->debug( 'Confirmed balance: ' . number_format( $confirmed_balance, 8 ), array( 'address_info' => $address_info ) );
77
78        $result['confirmed_balance'] = (string) $confirmed_balance;
79
80        $unconfirmed_balance = ( $address_info['mempool_stats']['funded_txo_sum'] - $address_info['mempool_stats']['spent_txo_sum'] ) / 100000000;
81        $this->logger->debug( 'Unconfirmed balance: ' . number_format( $unconfirmed_balance, 8 ), array( 'address_info' => $address_info ) );
82
83        $result['unconfirmed_balance'] = (string) $unconfirmed_balance;
84
85        return new class($result) implements Address_Balance {
86            protected $result;
87            public function __construct( $result ) {
88                $this->result = $result;
89            }
90
91            public function get_confirmed_balance(): string {
92                return $this->result['confirmed_balance'];
93            }
94
95            public function get_unconfirmed_balance(): string {
96                return $this->result['unconfirmed_balance'];
97            }
98
99            public function get_number_of_confirmations(): int {
100                return $this->result['number_of_confirmations'];
101            }
102        };
103    }
104
105    /**
106     * The total amount in BTC received at this address.
107     *
108     * @param string $btc_address The Bitcoin address.
109     *
110     * @return string
111     * @throws \Exception
112     */
113    public function get_received_by_address( string $btc_address, bool $confirmed ): string {
114
115        $address_info = $this->get_address_data( $btc_address );
116
117        if ( $confirmed ) {
118            $calc = $address_info['chain_stats']['funded_txo_sum'] / 100000000;
119        } else {
120            $calc = ( $address_info['chain_stats']['funded_txo_sum'] + $address_info['mempool_stats']['funded_txo_sum'] ) / 100000000;
121        }
122        return "{$calc}";
123    }
124
125    /**
126     * @param string $btc_address
127     *
128     * @return array<string, Transaction_Interface>
129     *
130     * @throws JsonException
131     */
132    public function get_transactions_received( string $btc_address ): array {
133
134        $address_info_url_bs = "https://blockstream.info/api/address/{$btc_address}/txs";
135
136        $this->logger->debug( 'URL: ' . $address_info_url_bs );
137
138        $request_response = wp_remote_get( $address_info_url_bs );
139
140        if ( is_wp_error( $request_response ) ) {
141            throw new \Exception( $request_response->get_error_message() );
142        }
143        if ( 200 !== $request_response['response']['code'] ) {
144            throw new \Exception( 'Unexpected response received.' );
145        }
146
147        $blockstream_transactions = json_decode( $request_response['body'], true, 512, JSON_THROW_ON_ERROR );
148
149        /**
150         * block_time is in unixtime.
151         *
152         * @param array{txid:string, version:int, locktime:int, vin:array, vout:array, size:int, weight:int, fee:int, status:array{confirmed:bool, block_height:int, block_hash:string, block_time:int}} $blockstream_transaction
153         *
154         * @return Transaction_Interface
155         */
156        $blockstream_mapper = function ( array $blockstream_transaction ): Transaction_Interface {
157
158            return new class( $blockstream_transaction ) implements Transaction_Interface {
159
160                protected $blockstream_transaction;
161
162                public function __construct( $blockstream_transaction ) {
163                    $this->blockstream_transaction = $blockstream_transaction;
164                }
165
166                public function get_txid(): string {
167                    return (string) $this->blockstream_transaction['txid'];
168                }
169
170                public function get_time(): \DateTimeInterface {
171
172                    $block_time = (int) $this->blockstream_transaction['status']['block_time'];
173
174                    return new DateTimeImmutable( '@' . $block_time, new DateTimeZone( 'UTC' ) );
175                }
176
177                public function get_value( string $to_address ): float {
178                    $value_including_fee = array_reduce(
179                        $this->blockstream_transaction['vout'],
180                        function ( $carry, $out ) use ( $to_address ) {
181                            if ( $out['scriptpubkey_address'] === $to_address ) {
182                                return $carry + $out['value'];
183                            }
184                            return $carry;
185                        },
186                        0
187                    );
188
189                    return $value_including_fee / 100000000;
190                }
191
192                public function get_block_height(): int {
193
194                    return $this->blockstream_transaction['status']['block_height'];
195
196                    // TODO: Confirmations was returning the block height - 1. Presumably that meant mempool/0 confirmations, but I need test data to understand.
197                    // Correct solution is probably to check does $blockstream_transaction['status']['block_height'] exist, else 0.
198                    // Quick fix.
199                }
200            };
201        };
202
203        $transactions = array_map( $blockstream_mapper, $blockstream_transactions );
204
205        $keyed_transactions = array();
206        foreach ( $transactions as $transaction ) {
207            $keyed_transactions[ $transaction->get_txid() ] = $transaction;
208        }
209
210        return $keyed_transactions;
211    }
212
213    /**
214     * @return int
215     * @throws \Exception
216     */
217    public function get_blockchain_height(): int {
218        $blocks_url_bs    = 'https://blockstream.info/api/blocks/tip/height';
219        $request_response = wp_remote_get( $blocks_url_bs );
220        if ( is_wp_error( $request_response ) || 200 !== $request_response['response']['code'] ) {
221            throw new \Exception();
222        }
223        return intval( $request_response['body'] );
224    }
225}