LightningdService::estimateFee()   A
last analyzed

Complexity

Conditions 5
Paths 16

Size

Total Lines 20
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 12
c 1
b 0
f 0
nc 16
nop 1
dl 0
loc 20
ccs 0
cts 10
cp 0
crap 30
rs 9.5555
1
<?php declare(strict_types=1);
2
/**
3
 * This file is part of the ngutech/lightningd-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\Lightningd\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 NGUtech\Bitcoin\Service\SatoshiCurrencies;
18
use NGUtech\Bitcoin\ValueObject\Bitcoin;
19
use NGUtech\Bitcoin\ValueObject\Hash;
20
use NGUtech\Lightning\Entity\LightningInvoice;
21
use NGUtech\Lightning\Entity\LightningPayment;
22
use NGUtech\Lightning\Service\LightningServiceInterface;
23
use NGUtech\Lightning\ValueObject\InvoiceState;
24
use NGUtech\Lightning\ValueObject\PaymentState;
25
use NGUtech\Lightning\ValueObject\Request;
26
use NGUtech\Lightningd\Connector\LightningdRpcConnector;
27
use Psr\Log\LoggerInterface;
28
use Socket\Raw\Socket;
29
30
class LightningdService implements LightningServiceInterface
31
{
32
    public const INVOICE_STATUS_UNPAID = 'unpaid';
33
    public const INVOICE_STATUS_PAID = 'paid';
34
    public const INVOICE_STATUS_EXPIRED = 'expired';
35
    public const PAYMENT_STATUS_PENDING = 'pending';
36
    public const PAYMENT_STATUS_COMPLETE = 'complete';
37
    public const PAYMENT_STATUS_FAILED = 'failed';
38
    public const FAILURE_REASON_NO_ROUTE = 205;
39
    public const FAILURE_REASON_PAYMENT_TIMEOUT = 210;
40
41
    protected LoggerInterface $logger;
42
43
    protected LightningdRpcConnector $connector;
44
45
    protected MoneyServiceInterface $moneyService;
46
47
    protected array $settings;
48
49
    public function __construct(
50
        LoggerInterface $logger,
51
        LightningdRpcConnector $connector,
52
        MoneyServiceInterface $moneyService,
53
        array $settings = []
54
    ) {
55
        $this->logger = $logger;
56
        $this->connector = $connector;
57
        $this->moneyService = $moneyService;
58
        $this->settings = $settings;
59
    }
60
61
    public function request(LightningInvoice $invoice): LightningInvoice
62
    {
63
        Assertion::true($this->canRequest($invoice->getAmount()), 'Lightningd service cannot request given amount.');
64
65
        $expiry = $invoice->getExpiry()->toNative();
66
        Assertion::between($expiry, 60, 31536000, 'Invoice expiry is not acceptable.');
67
68
        $result = $this->call('invoice', [
69
            'msatoshi' => $this->convert((string)$invoice->getAmount())->getAmount(),
70
            'label' => (string)$invoice->getLabel(),
71
            'description' => (string)$invoice->getDescription(),
72
            'preimage' => (string)$invoice->getPreimage(),
73
            'expiry' => $expiry
74
        ]);
75
76
        return $invoice->withValues([
77
            'preimageHash' => $result['payment_hash'],
78
            'request' => $result['bolt11'],
79
            'expiry' => $expiry,
80
            'blockHeight' => $this->getInfo()['blockheight'],
81
            'createdAt' => Timestamp::now(),
82
        ]);
83
    }
84
85
    public function send(LightningPayment $payment): LightningPayment
86
    {
87
        Assertion::true($this->canSend($payment->getAmount()), 'Lightningd service cannot send given amount.');
88
89
        $result = $this->call('pay', [
90
            'bolt11' => (string)$payment->getRequest(),
91
            'label' => (string)$payment->getLabel(),
92
            'retry_for' => $this->settings['send']['timeout'] ?? 30,
93
            'maxfeepercent' => $payment->getFeeLimit()->format(6),
94
            'riskfactor' => $this->settings['send']['riskfactor'] ?? 10,
95
            'exemptfee' => $this->convert(
96
                ($this->settings['send']['exemptfee'] ?? '5000'.SatoshiCurrencies::MSAT)
97
            )->getAmount()
98
        ]);
99
100
        return $payment->withValues([
101
            'preimage' => $result['payment_preimage'],
102
            'preimageHash' => $result['payment_hash'],
103
            'feeSettled' => ($result['msatoshi_sent'] - $result['msatoshi']).SatoshiCurrencies::MSAT
104
        ]);
105
    }
106
107
    public function decode(Request $request): LightningInvoice
108
    {
109
        $result = $this->call('decodepay', ['bolt11' => (string)$request]);
110
111
        return LightningInvoice::fromNative([
112
            'preimageHash' => $result['payment_hash'],
113
            'request' => (string)$request,
114
            'destination' => $result['payee'],
115
            'amount' => ($result['msatoshi'] ?? 0).SatoshiCurrencies::MSAT,
116
            'description' => $result['description'],
117
            'expiry' => $result['expiry'],
118
            'cltvExpiry' => $result['min_final_cltv_expiry'],
119
            'createdAt' => $result['created_at']
120
        ]);
121
    }
122
123
    public function estimateFee(LightningPayment $payment): Bitcoin
124
    {
125
        $feeLimit = $payment->getAmount()->percentage($payment->getFeeLimit()->toNative(), Bitcoin::ROUND_UP);
126
        $exemptFee = $this->convert(($this->settings['send']['exemptfee'] ?? '5000'.SatoshiCurrencies::MSAT));
127
        $feeEstimate = $feeLimit->isGreaterThanOrEqual($exemptFee) ? $feeLimit : $exemptFee;
128
129
        $result = $this->call('getroute', [
130
            'id' => (string)$payment->getDestination(),
131
            'msatoshi' => $payment->getAmount()->getAmount(),
132
            'riskfactor' => $this->settings['send']['riskfactor'] ?? 10
133
        ]);
134
135
        $routeFee = Bitcoin::zero();
136
        foreach ($result['route'] as $route) {
137
            $hopFee = Bitcoin::fromNative($route['amount_msat'])->subtract($payment->getAmount());
138
            $routeFee = $routeFee->add($hopFee);
139
        }
140
141
        //@risky if a zero cost route is available then assume node will use that
142
        return !$routeFee->isZero() && $feeEstimate->isGreaterThanOrEqual($routeFee) ? $feeEstimate : $routeFee;
143
    }
144
145
    public function getInvoice(Hash $preimageHash): ?LightningInvoice
146
    {
147
        $result = $this->call('listinvoices', ['payment_hash' => (string)$preimageHash]);
148
        if (empty($result['invoices'][0])) {
149
            return null;
150
        }
151
152
        return LightningInvoice::fromNative([
153
            'preimage' => $result['invoices'][0]['preimage'],
154
            'preimageHash' => $result['invoices'][0]['payment_hash'],
155
            'request' => $result['invoices'][0]['bolt11'],
156
            'destination' => $result['invoices'][0]['destination'],
157
            'amount' => $result['invoices'][0]['amount_msat'],
158
            'amountPaid' => $result['invoices'][0]['amount_received_msat'],
159
            'label' => $result['invoices'][0]['label'],
160
            'description' => $result['invoices'][0]['description'],
161
            'state' => (string)$this->mapInvoiceState($result['invoices'][0]['status']),
162
            'settledAt' => $result['invoices'][0]['paid_at']
163
        ]);
164
    }
165
166
    public function getPayment(Hash $preimageHash): ?LightningPayment
167
    {
168
        $result = $this->call('listpays', ['payment_hash' => (string)$preimageHash]);
169
        if (empty($result['pays'][0])) {
170
            return null;
171
        }
172
173
        return LightningPayment::fromNative([
174
            'preimage' => $result['pays'][0]['preimage'],
175
            'preimageHash' => $result['pays'][0]['payment_hash'],
176
            'request' => $result['pays'][0]['bolt11'],
177
            'destination' => $result['pays'][0]['destination'],
178
            'amount' => $result['pays'][0]['amount_msat'],
179
            'amountPaid' => $result['pays'][0]['amount_sent_msat'],
180
            'feeSettled' => (
181
                intval($result['pays'][0]['amount_sent_msat']) - intval($result['pays'][0]['amount_msat'])
182
            ).SatoshiCurrencies::MSAT,
183
            'label' => $result['pays'][0]['label'],
184
            'state' => (string)$this->mapPaymentState($result['pays'][0]['status']),
185
            'createdAt' => $result['pays'][0]['created_at']
186
        ]);
187
    }
188
189
    public function getInfo(): array
190
    {
191
        return $this->call('getinfo');
192
    }
193
194
    public function canRequest(MoneyInterface $amount): bool
195
    {
196
        return ($this->settings['request']['enabled'] ?? true)
197
            && $amount->isGreaterThanOrEqual(
198
                $this->convert(($this->settings['request']['minimum'] ?? LightningInvoice::AMOUNT_MIN))
199
            ) && $amount->isLessThanOrEqual(
200
                $this->convert(($this->settings['request']['maximum'] ?? LightningInvoice::AMOUNT_MAX))
201
            );
202
    }
203
204
    public function canSend(MoneyInterface $amount): bool
205
    {
206
        return ($this->settings['send']['enabled'] ?? true)
207
            && $amount->isGreaterThanOrEqual(
208
                $this->convert(($this->settings['send']['minimum'] ?? LightningInvoice::AMOUNT_MIN))
209
            ) && $amount->isLessThanOrEqual(
210
                $this->convert(($this->settings['send']['maximum'] ?? LightningInvoice::AMOUNT_MAX))
211
            );
212
    }
213
214
    protected function call(string $method, array $params = []): array
215
    {
216
        /** @var Socket $socket */
217
        $socket = $this->connector->getConnection();
218
219
        $socket->write(json_encode([
220
            'id' => 0,
221
            'method' => $method,
222
            'params' => $params
223
        ]));
224
225
        $response = '';
226
        do {
227
            $response .= $socket->read(1024);
228
        } while (substr($response, -1) !== PHP_EOL);
229
        $content = json_decode($response, true);
230
        if (!$content || isset($content['error'])) {
231
            if (isset($content['error']) && in_array($content['error']['code'], [
232
                self::FAILURE_REASON_NO_ROUTE,
233
                self::FAILURE_REASON_PAYMENT_TIMEOUT
234
            ])) {
235
                throw new PaymentServiceUnavailable($content['error']['message'], $content['error']['code']);
236
            }
237
            $this->logger->error($content['error']['message'] ?? 'Unknown response.');
238
            throw new PaymentServiceFailed("Lightningd '$method' request failed.", $content['error']['code']);
239
        }
240
241
        return $content['result'];
242
    }
243
244
    protected function convert(string $amount, string $currency = SatoshiCurrencies::MSAT): Bitcoin
245
    {
246
        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...
247
    }
248
249
    protected function mapInvoiceState(string $state): InvoiceState
250
    {
251
        $invoiceState = null;
252
        switch ($state) {
253
            case self::INVOICE_STATUS_UNPAID:
254
                $invoiceState = InvoiceState::PENDING;
255
                break;
256
            case self::INVOICE_STATUS_PAID:
257
                $invoiceState = InvoiceState::SETTLED;
258
                break;
259
            case self::INVOICE_STATUS_EXPIRED:
260
                $invoiceState = InvoiceState::CANCELLED;
261
                break;
262
            default:
263
                throw new PaymentServiceFailed("Unknown invoice state '$state'.");
264
        }
265
        return InvoiceState::fromNative($invoiceState);
266
    }
267
268
    protected function mapPaymentState(string $state): PaymentState
269
    {
270
        $paymentState = null;
271
        switch ($state) {
272
            case self::PAYMENT_STATUS_PENDING:
273
                $paymentState = PaymentState::PENDING;
274
                break;
275
            case self::PAYMENT_STATUS_COMPLETE:
276
                $paymentState = PaymentState::COMPLETED;
277
                break;
278
            case self::PAYMENT_STATUS_FAILED:
279
                $paymentState = PaymentState::FAILED;
280
                break;
281
            default:
282
                throw new PaymentServiceFailed("Unknown payment state '$state'.");
283
        }
284
        return PaymentState::fromNative($paymentState);
285
    }
286
}
287