LndService::canRequest()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 3
eloc 5
c 2
b 0
f 0
nc 3
nop 1
dl 0
loc 7
ccs 0
cts 2
cp 0
crap 12
rs 10
1
<?php declare(strict_types=1);
2
/**
3
 * This file is part of the ngutech/lnd-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\Lnd\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\Timestamp;
17
use Grpc\ServerStreamingCall;
18
use Lnrpc\AddInvoiceResponse;
19
use Lnrpc\FeeLimit;
20
use Lnrpc\GetInfoRequest;
21
use Lnrpc\GetInfoResponse;
22
use Lnrpc\Invoice;
23
use Lnrpc\Invoice\InvoiceState as LnrpcInvoiceState;
24
use Lnrpc\Payment;
25
use Lnrpc\Payment\PaymentStatus as LnrpcPaymentStatus;
26
use Lnrpc\PaymentFailureReason;
27
use Lnrpc\PaymentHash;
28
use Lnrpc\PayReq;
29
use Lnrpc\PayReqString;
30
use Lnrpc\QueryRoutesRequest;
31
use Lnrpc\QueryRoutesResponse;
32
use Lnrpc\Route;
33
use NGUtech\Bitcoin\Service\SatoshiCurrencies;
34
use NGUtech\Bitcoin\ValueObject\Bitcoin;
35
use NGUtech\Bitcoin\ValueObject\Hash;
36
use NGUtech\Lightning\Entity\LightningInvoice;
37
use NGUtech\Lightning\Service\LightningServiceInterface;
38
use NGUtech\Lightning\ValueObject\Request;
39
use NGUtech\Lightning\Entity\LightningPayment;
40
use NGUtech\Lightning\ValueObject\InvoiceState;
41
use NGUtech\Lightning\ValueObject\PaymentState;
42
use NGUtech\Lnd\Connector\LndGrpcClient;
43
use NGUtech\Lnd\Connector\LndGrpcConnector;
44
use Psr\Log\LoggerInterface;
45
use Routerrpc\SendPaymentRequest;
46
use Routerrpc\TrackPaymentRequest;
47
48
class LndService implements LightningServiceInterface
49
{
50
    protected LoggerInterface $logger;
51
52
    protected LndGrpcConnector $connector;
53
54
    protected MoneyServiceInterface $moneyService;
55
56
    protected array $settings;
57
58
    public function __construct(
59
        LoggerInterface $logger,
60
        LndGrpcConnector $connector,
61
        MoneyServiceInterface $moneyService,
62
        array $settings = []
63
    ) {
64
        $this->logger = $logger;
65
        $this->connector = $connector;
66
        $this->moneyService = $moneyService;
67
        $this->settings = $settings;
68
    }
69
70
    public function request(LightningInvoice $invoice): LightningInvoice
71
    {
72
        Assertion::true($this->canRequest($invoice->getAmount()), 'Lnd service cannot request given amount.');
73
74
        $expiry = $invoice->getExpiry()->toNative();
75
        Assertion::between($expiry, 60, 31536000, 'Invoice expiry is not acceptable.');
76
77
        /** @var LndGrpcClient $client */
78
        $client = $this->connector->getConnection();
79
80
        /** @var AddInvoiceResponse $response */
81
        list($response, $status) = $client->lnrpc->AddInvoice(new Invoice([
82
            'r_preimage' => $invoice->getPreimage()->toBinary(),
83
            'memo' => (string)$invoice->getLabel(),
84
            'value_msat' => $this->convert((string)$invoice->getAmount())->getAmount(),
85
            'expiry' => $expiry,
86
            'cltv_expiry' => $invoice->getCltvExpiry()->toNative()
87
        ]))->wait();
88
89
        if ($status->code !== 0) {
90
            $this->logger->error($status->details);
91
            throw new PaymentServiceFailed($status->details);
92
        }
93
94
        return $invoice->withValues([
95
            'preimageHash' => bin2hex($response->getRHash()),
96
            'request' => $response->getPaymentRequest(),
97
            'expiry' => $expiry,
98
            'blockHeight' => $this->getInfo()['blockHeight'],
99
            'createdAt' => Timestamp::now()
100
        ]);
101
    }
102
103
    public function send(LightningPayment $payment): LightningPayment
104
    {
105
        Assertion::true($this->canSend($payment->getAmount()), 'Lnd service cannot send given amount.');
106
107
        /** @var LndGrpcClient $client */
108
        $client = $this->connector->getConnection();
109
110
        /** @var ServerStreamingCall $stream */
111
        $stream = $client->routerrpc->SendPaymentV2(new SendPaymentRequest([
112
            'max_parts' => $this->settings['send']['max_parts'] ?? 5,
113
            'payment_request' => (string)$payment->getRequest(),
114
            'timeout_seconds' => $this->settings['send']['timeout'] ?? 30,
115
            'fee_limit_msat' => $payment->getFeeEstimate()->getAmount(),
116
        ]), [], ['timeout' => ($this->settings['send']['timeout'] ?? 30) * 1000000]);
117
118
        $result = null;
119
        foreach ($stream->responses() as $response) {
120
            $result = $response;
121
        }
122
123
        if ($stream->getStatus()->code !== 0) {
124
            $this->logger->error($stream->getStatus()->details);
125
            throw new PaymentServiceFailed($stream->getStatus()->details);
126
        }
127
128
        /** @var Payment $result */
129
        if ($result->getStatus() === LnrpcPaymentStatus::FAILED) {
130
            $failureCode = $result->getFailureReason();
131
            $failureMessage = PaymentFailureReason::name($failureCode);
132
            if (in_array($failureCode, [
133
                PaymentFailureReason::FAILURE_REASON_NO_ROUTE,
134
                PaymentFailureReason::FAILURE_REASON_INSUFFICIENT_BALANCE
135
            ])) {
136
                throw new PaymentServiceUnavailable($failureMessage);
137
            }
138
            $this->logger->error($failureMessage);
139
            throw new PaymentServiceFailed($failureMessage);
140
        }
141
142
        return $payment->withValues([
143
            'preimage' => $result->getPaymentPreimage(),
144
            'preimageHash' => $result->getPaymentHash(),
145
            'feeSettled' => $result->getFeeMsat().SatoshiCurrencies::MSAT,
146
        ]);
147
    }
148
149
    public function decode(Request $request): LightningInvoice
150
    {
151
        /** @var LndGrpcClient $client */
152
        $client = $this->connector->getConnection();
153
154
        /** @var PayReq $response */
155
        list($response, $status) = $client->lnrpc->DecodePayReq(
156
            new PayReqString(['pay_req' => (string)$request])
157
        )->wait();
158
159
        if ($status->code !== 0) {
160
            $this->logger->error($status->details);
161
            throw new PaymentServiceFailed($status->details);
162
        }
163
164
        return LightningInvoice::fromNative([
165
            'preimageHash' => $response->getPaymentHash(),
166
            'request' => $request,
167
            'destination' => $response->getDestination(),
168
            'amount' => $response->getNumMsat().SatoshiCurrencies::MSAT,
169
            'description' => $response->getDescription(),
170
            'expiry' => $response->getExpiry(),
171
            'cltvExpiry' => $response->getCltvExpiry(),
172
            'createdAt' => $response->getTimestamp()
173
        ]);
174
    }
175
176
    public function estimateFee(LightningPayment $payment): Bitcoin
177
    {
178
        $feeLimit = $payment->getAmount()->percentage($payment->getFeeLimit()->toNative(), Bitcoin::ROUND_UP);
179
180
        /** @var LndGrpcClient $client */
181
        $client = $this->connector->getConnection();
182
183
        /** @var QueryRoutesResponse $response */
184
        list($response, $status) = $client->lnrpc->QueryRoutes(
185
            new QueryRoutesRequest([
186
                'pub_key' => (string)$payment->getDestination(),
187
                'amt_msat' => $payment->getAmount()->getAmount(),
188
                'fee_limit' => new FeeLimit(['fixed_msat' => $feeLimit->getAmount()])
189
            ])
190
        )->wait();
191
192
        //@todo handle no-route errors
193
        if ($status->code !== 0) {
194
            $this->logger->error($status->details);
195
            throw new PaymentServiceFailed($status->details);
196
        }
197
198
        $routeFee = Bitcoin::zero();
199
        /** @var Route $route */
200
        foreach ($response->getRoutes() as $route) {
201
            $currentRouteFee = Bitcoin::fromNative($route->getTotalFeesMsat().SatoshiCurrencies::MSAT);
202
            if (!$currentRouteFee->isLessThanOrEqual($routeFee)) {
203
                $routeFee = $currentRouteFee;
204
            }
205
        }
206
207
        //@risky if a zero cost route is available then assume node will use that
208
        return !$routeFee->isZero() && $feeLimit->isGreaterThanOrEqual($routeFee) ? $feeLimit : $routeFee;
209
    }
210
211
    public function getInvoice(Hash $preimageHash): ?LightningInvoice
212
    {
213
        /** @var LndGrpcClient $client */
214
        $client = $this->connector->getConnection();
215
216
        /** @var Invoice $invoice */
217
        list($invoice, $status) = $client->lnrpc->LookupInvoice(
218
            new PaymentHash(['r_hash_str' => (string)$preimageHash])
219
        )->wait();
220
221
        if ($status->code !== 0) {
222
            return null;
223
        }
224
225
        return LightningInvoice::fromNative([
226
            'preimage' => bin2hex($invoice->getRPreimage()),
227
            'preimageHash' => bin2hex($invoice->getRHash()),
228
            'request' => $invoice->getPaymentRequest(),
229
            'amount' => $invoice->getValueMsat().SatoshiCurrencies::MSAT,
230
            'amountPaid' => $invoice->getAmtPaidMsat().SatoshiCurrencies::MSAT,
231
            'label' => $invoice->getMemo(),
232
            'state' => (string)$this->mapInvoiceState($invoice->getState()),
233
            'createdAt' => $invoice->getCreationDate()
234
        ]);
235
    }
236
237
    public function getPayment(Hash $preimageHash): ?LightningPayment
238
    {
239
        /** @var LndGrpcClient $client */
240
        $client = $this->connector->getConnection();
241
242
        $stream = $client->routerrpc->TrackPaymentV2(new TrackPaymentRequest([
243
            'payment_hash' => $preimageHash->toBinary(),
244
            'no_inflight_updates' => true
245
        ]), [], ['timeout' => 5 * 1000000]);
246
247
        $payment = null;
248
        foreach ($stream->responses() as $response) {
249
            /** @var Payment $payment */
250
            $payment = $response;
251
        }
252
253
        if ($stream->getStatus()->code !== 0) {
254
            return null;
255
        }
256
257
        //@todo return null if not found
258
        if (!$payment) {
259
            $this->logger->error($stream->getStatus()->details);
260
            throw new PaymentServiceFailed($stream->getStatus()->details);
261
        }
262
263
        return LightningPayment::fromNative([
264
            'preimage' => $payment->getPaymentPreimage(),
265
            'preimageHash' => $payment->getPaymentHash(),
266
            'request' => $payment->getPaymentRequest(),
267
            'amount' => $payment->getValueMsat().SatoshiCurrencies::MSAT,
268
            'amountPaid' => $payment->getValueMsat().SatoshiCurrencies::MSAT, //may change in future
269
            'feeSettled' => $payment->getFeeMsat().SatoshiCurrencies::MSAT,
270
            'state' => (string)$this->mapPaymentState($payment->getStatus()),
271
            'createdAt' => $payment->getCreationDate()
272
        ]);
273
    }
274
275
    public function getInfo(): array
276
    {
277
        /** @var LndGrpcClient $client */
278
        $client = $this->connector->getConnection();
279
280
        /** @var GetInfoResponse $response */
281
        list($response, $status) = $client->lnrpc->GetInfo(new GetInfoRequest)->wait();
282
283
        if ($status->code !== 0) {
284
            $this->logger->error($status->details);
285
            throw new PaymentServiceFailed($status->details);
286
        }
287
288
        return json_decode($response->serializeToJsonString(), true);
289
    }
290
291
    public function canRequest(MoneyInterface $amount): bool
292
    {
293
        return ($this->settings['request']['enabled'] ?? true)
294
            && $amount->isGreaterThanOrEqual(
295
                $this->convert(($this->settings['request']['minimum'] ?? LightningInvoice::AMOUNT_MIN))
296
            ) && $amount->isLessThanOrEqual(
297
                $this->convert(($this->settings['request']['maximum'] ?? LightningInvoice::AMOUNT_MAX))
298
            );
299
    }
300
301
    public function canSend(MoneyInterface $amount): bool
302
    {
303
        return ($this->settings['send']['enabled'] ?? true)
304
            && $amount->isGreaterThanOrEqual(
305
                $this->convert(($this->settings['send']['minimum'] ?? LightningInvoice::AMOUNT_MIN))
306
            ) && $amount->isLessThanOrEqual(
307
                $this->convert(($this->settings['send']['maximum'] ?? LightningInvoice::AMOUNT_MAX))
308
            );
309
    }
310
311
    protected function convert(string $amount, string $currency = SatoshiCurrencies::MSAT): Bitcoin
312
    {
313
        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...
314
    }
315
316
    protected function mapInvoiceState(int $state): InvoiceState
317
    {
318
        $invoiceState = null;
319
        switch ($state) {
320
            case LnrpcInvoiceState::OPEN:
321
            case LnrpcInvoiceState::ACCEPTED:
322
                $invoiceState = InvoiceState::PENDING;
323
                break;
324
            case LnrpcInvoiceState::SETTLED:
325
                $invoiceState = InvoiceState::SETTLED;
326
                break;
327
            case LnrpcInvoiceState::CANCELED:
328
                $invoiceState = InvoiceState::CANCELLED;
329
                break;
330
            default:
331
                throw new PaymentServiceFailed("Unknown invoice state '$state'.");
332
        }
333
        return InvoiceState::fromNative($invoiceState);
334
    }
335
336
    protected function mapPaymentState(int $state): PaymentState
337
    {
338
        $paymentState = null;
339
        switch ($state) {
340
            case LnrpcPaymentStatus::UNKNOWN:
341
            case LnrpcPaymentStatus::IN_FLIGHT:
342
                $paymentState = PaymentState::PENDING;
343
                break;
344
            case LnrpcPaymentStatus::SUCCEEDED:
345
                $paymentState = PaymentState::COMPLETED;
346
                break;
347
            case LnrpcPaymentStatus::FAILED:
348
                $paymentState = PaymentState::FAILED;
349
                break;
350
            default:
351
                throw new PaymentServiceFailed("Unknown payment state '$state'.");
352
        }
353
        return PaymentState::fromNative($paymentState);
354
    }
355
}
356