Passed
Push — master ( 624f3a...735dfe )
by Mr
02:04
created

BitcoindService::canRequest()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 3
c 1
b 0
f 0
nc 2
nop 1
dl 0
loc 5
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 Denpa\Bitcoin\Client;
17
use Denpa\Bitcoin\Exceptions\BadRemoteCallException;
18
use Denpa\Bitcoin\Responses\BitcoindResponse;
19
use NGUtech\Bitcoin\Entity\BitcoinBlock;
20
use NGUtech\Bitcoin\Entity\BitcoinTransaction;
21
use NGUtech\Bitcoin\Service\BitcoinCurrencies;
22
use NGUtech\Bitcoin\Service\BitcoinServiceInterface;
23
use NGUtech\Bitcoin\Service\SatoshiCurrencies;
24
use NGUtech\Bitcoin\ValueObject\Address;
25
use NGUtech\Bitcoin\ValueObject\Bitcoin;
26
use NGUtech\Bitcoin\ValueObject\Hash;
27
use NGUtech\Bitcoin\ValueObject\Output;
28
use NGUtech\Bitcoin\ValueObject\OutputList;
29
use NGUtech\Bitcoind\Connector\BitcoindRpcConnector;
30
use Psr\Log\LoggerInterface;
31
32
class BitcoindService implements BitcoinServiceInterface
33
{
34
    public const FAILURE_REASON_INSUFFICIENT_FUNDS = -4;
35
    public const FAILURE_REASON_UNRECOGNISED_TX_ID = -5;
36
37
    protected LoggerInterface $logger;
38
39
    protected BitcoindRpcConnector $connector;
40
41
    protected MoneyServiceInterface $moneyService;
42
43
    protected array $settings;
44
45
    public function __construct(
46
        LoggerInterface $logger,
47
        BitcoindRpcConnector $connector,
48
        MoneyServiceInterface $moneyService,
49
        array $settings = []
50
    ) {
51
        $this->logger = $logger;
52
        $this->connector = $connector;
53
        $this->moneyService = $moneyService;
54
        $this->settings = $settings;
55
    }
56
57
    public function request(BitcoinTransaction $transaction): BitcoinTransaction
58
    {
59
        Assertion::true($this->canRequest($transaction->getAmount()), 'Bitcoind service cannot request given amout.');
60
61
        $result = $this->call('getnewaddress', [
62
            (string)$transaction->getLabel(),
63
            $this->settings['request']['address_type'] ?? 'legacy'
64
        ]);
65
66
        return $transaction->withValues([
67
            'outputs' => [['address' => $result, 'value' => (string)$transaction->getAmount()]],
68
            'confTarget' => $this->settings['request']['conf_target'] ?? 3
69
        ]);
70
    }
71
72
    public function send(BitcoinTransaction $transaction): BitcoinTransaction
73
    {
74
        Assertion::true($this->canSend($transaction->getAmount()), 'Bitcoind service cannot send given amount.');
75
76
        $fundedTransaction = $this->createFundedTransaction($transaction);
77
        $signedTransaction = $this->call('signrawtransactionwithwallet', [$fundedTransaction['hex']]);
78
        if ($signedTransaction['complete'] !== true) {
79
            throw new PaymentServiceFailed('Incomplete transaction.');
80
        }
81
        //@todo improve high fee rate handling
82
        $hex = $this->call('sendrawtransaction', [$signedTransaction['hex'], 0]);
83
84
        return $transaction
85
            ->withValue('id', $hex)
86
            ->withValue('feeSettled', $this->convert($fundedTransaction['fee'].BitcoinCurrencies::BTC));
87
    }
88
89
    public function validateAddress(Address $address): bool
90
    {
91
        $result = $this->call('validateaddress', [(string)$address]);
92
        return $result['isvalid'] === true;
93
    }
94
95
    public function estimateFee(BitcoinTransaction $transaction): Bitcoin
96
    {
97
        $result = $this->createFundedTransaction($transaction);
98
        return $this->convert($result['fee'].BitcoinCurrencies::BTC);
99
    }
100
101
    public function getBlock(Hash $id): BitcoinBlock
102
    {
103
        $result = $this->call('getblock', [(string)$id]);
104
        return BitcoinBlock::fromNative([
105
            'hash' => $result['hash'],
106
            'merkleRoot' => $result['merkleroot'],
107
            'confirmations' => $result['confirmations'],
108
            'transactions' => $result['tx'],
109
            'height' => $result['height'],
110
            'timestamp' => (string)$result['time']
111
        ]);
112
    }
113
114
    public function getTransaction(Hash $id): ?BitcoinTransaction
115
    {
116
        try {
117
            $result = $this->call('gettransaction', [(string)$id]);
118
        } catch (PaymentServiceFailed $error) {
119
            if ($error->getCode() === self::FAILURE_REASON_UNRECOGNISED_TX_ID) {
120
                return null;
121
            }
122
            throw $error;
123
        }
124
125
        $outputs = $this->makeOutputList($result['details']);
126
127
        return BitcoinTransaction::fromNative([
128
            'id' => $result['txid'],
129
            'amount' => (string)$outputs->getTotal(),
130
            'outputs' => $outputs->toNative(),
131
            'confirmations' => $result['confirmations'],
132
            'feeSettled' => $this->convert(ltrim($result['fee'], '-').BitcoinCurrencies::BTC),
133
            'rbf' => $result['bip125-replaceable'] === 'yes'
134
        ]);
135
    }
136
137
    public function getConfirmedBalance(Address $address): Bitcoin
138
    {
139
        $confTarget = $this->settings['request']['conf_target'] ?? 3;
140
        $result = $this->call('listreceivedbyaddress', [$confTarget, 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);
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 => [
0 ignored issues
show
Bug introduced by
A parse error occurred: Syntax error, unexpected ':', expecting T_DOUBLE_ARROW on line 199 at column 34
Loading history...
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