Issues (2)

src/Service/BitcoindService.php (1 issue)

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'] ?? BitcoinTransaction::AMOUNT_MIN))
149
            ) && $amount->isLessThanOrEqual(
150
                $this->convert(($this->settings['request']['maximum'] ?? BitcoinTransaction::AMOUNT_MAX))
151
            );
152
    }
153
154
    public function canSend(MoneyInterface $amount): bool
155
    {
156
        return ($this->settings['send']['enabled'] ?? true)
157
            && $amount->isGreaterThanOrEqual(
158
                $this->convert(($this->settings['send']['minimum'] ?? BitcoinTransaction::AMOUNT_MIN))
159
            ) && $amount->isLessThanOrEqual(
160
                $this->convert(($this->settings['send']['maximum'] ?? BitcoinTransaction::AMOUNT_MAX))
161
            );
162
    }
163
164
    /** @return mixed */
165
    protected function call(string $command, array $params = [])
166
    {
167
        /** @var Client $client */
168
        $client = $this->connector->getConnection();
169
170
        try {
171
            /** @var BitcoindResponse $response */
172
            $response = $client->request($command, ...$params);
173
        } catch (BadRemoteCallException $error) {
174
            if ($error->getCode() === self::FAILURE_REASON_INSUFFICIENT_FUNDS) {
175
                throw new PaymentServiceUnavailable($error->getMessage(), $error->getCode());
176
            }
177
            $this->logger->error($error->getMessage());
178
            throw new PaymentServiceFailed("Bitcoind '$command' error.", $error->getCode());
179
        }
180
181
        //convert numbers to strings
182
        $json = preg_replace('/"([\w-]+?)":([\d\.e-]+)([,}\]])/', '"$1":"$2"$3', (string)$response->getBody());
183
        $response = json_decode($json, true);
184
        if (!empty($response['error']) || !empty($response['result']['errors'])) {
185
            $this->logger->error($response['error'] ?? $response['result']['errors']);
186
            throw new PaymentServiceFailed("Bitcoind '$command' request failed.", $response['error']['code']);
187
        }
188
189
        return $response['result'];
190
    }
191
192
    protected function convert(string $amount, string $currency = SatoshiCurrencies::MSAT): Bitcoin
193
    {
194
        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...
195
    }
196
197
    protected function createFundedTransaction(BitcoinTransaction $transaction): array
198
    {
199
        //@todo coin control in an async context
200
        $rawTransaction = $this->call('createrawtransaction', [
201
            [],
202
            array_map(
203
                fn(Output $output): array => [
204
                    (string)$output->getAddress() => $this->formatForRpc($output->getValue())
205
                ],
206
                $transaction->getOutputs()->unwrap()
207
            ),
208
            0,
209
            $this->settings['send']['rbf'] ?? true
210
        ]);
211
212
        return $this->call('fundrawtransaction', [$rawTransaction, [
213
            'feeRate' => $transaction->getFeeRate()->format(8),
214
            'change_type' => $this->settings['send']['change_type'] ?? 'bech32'
215
        ]]);
216
    }
217
218
    protected function formatForRpc(Bitcoin $bitcoin): string
219
    {
220
        return preg_replace('/[^\d\.]/', '', $this->moneyService->format(
221
            $this->convert((string)$bitcoin, BitcoinCurrencies::BTC)
222
        ));
223
    }
224
225
    protected function makeOutputList(array $details): OutputList
226
    {
227
        return OutputList::fromNative(
228
            array_reduce($details, function (array $carry, array $entry): array {
229
                if ($entry['category'] === 'send') {
230
                    $carry[] = [
231
                        'address' => $entry['address'],
232
                        'value' => (string)$this->convert(ltrim($entry['amount'], '-').BitcoinCurrencies::BTC)
233
                    ];
234
                }
235
                return $carry;
236
            }, [])
237
        );
238
    }
239
}
240