Passed
Branch master (3b540a)
by Mr
08:09
created

BitcoindService::getBlock()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 8
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 10
ccs 0
cts 3
cp 0
crap 2
rs 10
1
<?php declare(strict_types=1);
2
/**
3
 * This file is part of the ngutech/bitcoind-adapter project.
4
 *
5
 * For the full copyright and license information, please view the LICENSE
6
 * file that was distributed with this source code.
7
 */
8
9
namespace NGUtech\Bitcoind\Service;
10
11
use Daikon\Interop\Assertion;
12
use Daikon\Money\Exception\PaymentServiceFailed;
13
use Daikon\Money\Exception\PaymentServiceUnavailable;
14
use Daikon\Money\Service\MoneyServiceInterface;
15
use Daikon\Money\ValueObject\MoneyInterface;
16
use Daikon\ValueObject\Natural;
17
use Denpa\Bitcoin\Client;
18
use Denpa\Bitcoin\Exceptions\BadRemoteCallException;
19
use Denpa\Bitcoin\Responses\BitcoindResponse;
20
use NGUtech\Bitcoin\Entity\BitcoinBlock;
21
use NGUtech\Bitcoin\Entity\BitcoinTransaction;
22
use NGUtech\Bitcoin\Service\BitcoinCurrencies;
23
use NGUtech\Bitcoin\Service\BitcoinServiceInterface;
24
use NGUtech\Bitcoin\Service\SatoshiCurrencies;
25
use NGUtech\Bitcoin\ValueObject\Address;
26
use NGUtech\Bitcoin\ValueObject\Bitcoin;
27
use NGUtech\Bitcoin\ValueObject\Hash;
28
use NGUtech\Bitcoin\ValueObject\Output;
29
use NGUtech\Bitcoin\ValueObject\OutputList;
30
use NGUtech\Bitcoind\Connector\BitcoindRpcConnector;
31
use Psr\Log\LoggerInterface;
32
33
class BitcoindService implements BitcoinServiceInterface
34
{
35
    public const FAILURE_REASON_INSUFFICIENT_FUNDS = -4;
36
    public const FAILURE_REASON_UNRECOGNISED_TX_ID = -5;
37
38
    protected LoggerInterface $logger;
39
40
    protected BitcoindRpcConnector $connector;
41
42
    protected MoneyServiceInterface $moneyService;
43
44
    protected array $settings;
45
46
    public function __construct(
47
        LoggerInterface $logger,
48
        BitcoindRpcConnector $connector,
49
        MoneyServiceInterface $moneyService,
50
        array $settings = []
51
    ) {
52
        $this->logger = $logger;
53
        $this->connector = $connector;
54
        $this->moneyService = $moneyService;
55
        $this->settings = $settings;
56
    }
57
58
    public function request(BitcoinTransaction $transaction): BitcoinTransaction
59
    {
60
        Assertion::true($this->canRequest($transaction->getAmount()), 'Bitcoind service cannot request given amout.');
61
62
        $result = $this->call('getnewaddress', [
63
            (string)$transaction->getLabel(),
64
            $this->settings['request']['address_type'] ?? 'legacy'
65
        ]);
66
67
        return $transaction->withValues([
68
            'outputs' => [['address' => $result, 'value' => (string)$transaction->getAmount()]],
69
            'confTarget' => $this->settings['request']['conf_target'] ?? 3
70
        ]);
71
    }
72
73
    public function send(BitcoinTransaction $transaction): BitcoinTransaction
74
    {
75
        Assertion::true($this->canSend($transaction->getAmount()), 'Bitcoind service cannot send given amount.');
76
77
        $fundedTransaction = $this->createFundedTransaction($transaction);
78
        $signedTransaction = $this->call('signrawtransactionwithwallet', [$fundedTransaction['hex']]);
79
        if ($signedTransaction['complete'] !== true) {
80
            throw new PaymentServiceFailed('Incomplete transaction.');
81
        }
82
        //@todo improve high fee rate handling
83
        $hex = $this->call('sendrawtransaction', [$signedTransaction['hex'], 0]);
84
85
        return $transaction
86
            ->withValue('id', $hex)
87
            ->withValue('feeSettled', $this->convert($fundedTransaction['fee'].BitcoinCurrencies::BTC));
88
    }
89
90
    public function validateAddress(Address $address): bool
91
    {
92
        $result = $this->call('validateaddress', [(string)$address]);
93
        return $result['isvalid'] === true;
94
    }
95
96
    public function estimateFee(BitcoinTransaction $transaction): Bitcoin
97
    {
98
        $result = $this->createFundedTransaction($transaction);
99
        return $this->convert($result['fee'].BitcoinCurrencies::BTC);
100
    }
101
102
    public function getBlock(Hash $id): BitcoinBlock
103
    {
104
        $result = $this->call('getblock', [(string)$id]);
105
        return BitcoinBlock::fromNative([
106
            'hash' => $result['hash'],
107
            'merkleRoot' => $result['merkleroot'],
108
            'confirmations' => $result['confirmations'],
109
            'transactions' => $result['tx'],
110
            'height' => $result['height'],
111
            'timestamp' => (string)$result['time']
112
        ]);
113
    }
114
115
    public function getTransaction(Hash $id): ?BitcoinTransaction
116
    {
117
        try {
118
            $result = $this->call('gettransaction', [(string)$id]);
119
        } catch (PaymentServiceFailed $error) {
120
            if ($error->getCode() === self::FAILURE_REASON_UNRECOGNISED_TX_ID) {
121
                return null;
122
            }
123
            throw $error;
124
        }
125
126
        $outputs = $this->makeOutputList($result['details']);
127
128
        return BitcoinTransaction::fromNative([
129
            'id' => $result['txid'],
130
            'amount' => (string)$outputs->getTotal(),
131
            'outputs' => $outputs->toNative(),
132
            'confirmations' => $result['confirmations'],
133
            'feeSettled' => $this->convert(ltrim($result['fee'], '-').BitcoinCurrencies::BTC),
134
            'rbf' => $result['bip125-replaceable'] === 'yes'
135
        ]);
136
    }
137
138
    public function getConfirmedBalance(Address $address, Natural $confirmations): Bitcoin
139
    {
140
        $result = $this->call('listreceivedbyaddress', [$confirmations->toNative(), false, false, (string)$address]);
141
        return $this->convert(($result[0]['amount'] ?? '0').BitcoinCurrencies::BTC);
142
    }
143
144
    public function canRequest(MoneyInterface $amount): bool
145
    {
146
        return ($this->settings['request']['enabled'] ?? true)
147
            && $amount->isGreaterThanOrEqual(
148
                $this->convert(($this->settings['request']['minimum'] ?? '1'.SatoshiCurrencies::SAT))
149
            );
150
    }
151
152
    public function canSend(MoneyInterface $amount): bool
153
    {
154
        return ($this->settings['send']['enabled'] ?? true)
155
            && $amount->isGreaterThanOrEqual(
156
                $this->convert(($this->settings['send']['minimum'] ?? '1'.SatoshiCurrencies::SAT))
157
            );
158
    }
159
160
    /** @return mixed */
161
    protected function call(string $command, array $params = [])
162
    {
163
        /** @var Client $client */
164
        $client = $this->connector->getConnection();
165
166
        try {
167
            /** @var BitcoindResponse $response */
168
            $response = $client->request($command, ...$params);
169
        } catch (BadRemoteCallException $error) {
170
            if ($error->getCode() === self::FAILURE_REASON_INSUFFICIENT_FUNDS) {
171
                throw new PaymentServiceUnavailable($error->getMessage(), $error->getCode());
172
            }
173
            $this->logger->error($error->getMessage());
174
            throw new PaymentServiceFailed("Bitcoind '$command' error.", $error->getCode());
175
        }
176
177
        //convert numbers to strings
178
        $json = preg_replace('/"([\w-]+?)":([\d\.e-]+)([,}\]])/', '"$1":"$2"$3', (string)$response->getBody());
179
        $response = json_decode($json, true);
180
        if (!empty($response['error']) || !empty($response['result']['errors'])) {
181
            $this->logger->error($response['error'] ?? $response['result']['errors']);
182
            throw new PaymentServiceFailed("Bitcoind '$command' request failed.", $response['error']['code']);
183
        }
184
185
        return $response['result'];
186
    }
187
188
    protected function convert(string $amount, string $currency = SatoshiCurrencies::MSAT): Bitcoin
189
    {
190
        return $this->moneyService->convert($this->moneyService->parse($amount), $currency);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->moneyServi...se($amount), $currency) returns the type Daikon\Money\ValueObject\MoneyInterface which includes types incompatible with the type-hinted return NGUtech\Bitcoin\ValueObject\Bitcoin.
Loading history...
191
    }
192
193
    protected function createFundedTransaction(BitcoinTransaction $transaction): array
194
    {
195
        //@todo coin control in an async context
196
        $rawTransaction = $this->call('createrawtransaction', [
197
            [],
198
            array_map(
199
                fn(Output $output): array => [
200
                    (string)$output->getAddress() => $this->formatForRpc($output->getValue())
201
                ],
202
                $transaction->getOutputs()->unwrap()
203
            ),
204
            0,
205
            $this->settings['send']['rbf'] ?? true
206
        ]);
207
208
        return $this->call('fundrawtransaction', [$rawTransaction, [
209
            'feeRate' => $transaction->getFeeRate()->format(8),
210
            'change_type' => $this->settings['send']['change_type'] ?? 'bech32'
211
        ]]);
212
    }
213
214
    protected function formatForRpc(Bitcoin $bitcoin): string
215
    {
216
        return preg_replace('/[^\d\.]/', '', $this->moneyService->format(
217
            $this->convert((string)$bitcoin, BitcoinCurrencies::BTC)
218
        ));
219
    }
220
221
    protected function makeOutputList(array $details): OutputList
222
    {
223
        return OutputList::fromNative(
224
            array_reduce($details, function (array $carry, array $entry): array {
225
                if ($entry['category'] === 'send') {
226
                    $carry[] = [
227
                        'address' => $entry['address'],
228
                        'value' => (string)$this->convert(ltrim($entry['amount'], '-').BitcoinCurrencies::BTC)
229
                    ];
230
                }
231
                return $carry;
232
            }, [])
233
        );
234
    }
235
}
236