• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

lightningnetwork / lnd / 13907742068

17 Mar 2025 07:03PM UTC coverage: 58.307% (-0.008%) from 58.315%
13907742068

Pull #9334

github

web-flow
Merge 1b389e91e into 053d63e11
Pull Request #9334: Use all valid routes during blinded path construction

103 of 117 new or added lines in 4 files covered. (88.03%)

49 existing lines in 12 files now uncovered.

94796 of 162581 relevant lines covered (58.31%)

1.81 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

76.58
/lnrpc/invoicesrpc/addinvoice.go
1
package invoicesrpc
2

3
import (
4
        "bytes"
5
        "context"
6
        "crypto/rand"
7
        "errors"
8
        "fmt"
9
        "math"
10
        mathRand "math/rand"
11
        "sort"
12
        "time"
13

14
        "github.com/btcsuite/btcd/btcec/v2"
15
        "github.com/btcsuite/btcd/btcec/v2/ecdsa"
16
        "github.com/btcsuite/btcd/btcutil"
17
        "github.com/btcsuite/btcd/chaincfg"
18
        "github.com/btcsuite/btcd/chaincfg/chainhash"
19
        "github.com/btcsuite/btcd/wire"
20
        "github.com/lightningnetwork/lnd/channeldb"
21
        "github.com/lightningnetwork/lnd/graph/db/models"
22
        "github.com/lightningnetwork/lnd/invoices"
23
        "github.com/lightningnetwork/lnd/lntypes"
24
        "github.com/lightningnetwork/lnd/lnutils"
25
        "github.com/lightningnetwork/lnd/lnwire"
26
        "github.com/lightningnetwork/lnd/netann"
27
        "github.com/lightningnetwork/lnd/routing"
28
        "github.com/lightningnetwork/lnd/routing/blindedpath"
29
        "github.com/lightningnetwork/lnd/routing/route"
30
        "github.com/lightningnetwork/lnd/zpay32"
31
)
32

33
const (
34
        // DefaultInvoiceExpiry is the default invoice expiry for new MPP
35
        // invoices.
36
        DefaultInvoiceExpiry = 24 * time.Hour
37

38
        // DefaultAMPInvoiceExpiry is the default invoice expiry for new AMP
39
        // invoices.
40
        DefaultAMPInvoiceExpiry = 30 * 24 * time.Hour
41

42
        // hopHintFactor is factor by which we scale the total amount of
43
        // inbound capacity we want our hop hints to represent, allowing us to
44
        // have some leeway if peers go offline.
45
        hopHintFactor = 2
46

47
        // maxHopHints is the maximum number of hint paths that will be included
48
        // in an invoice.
49
        maxHopHints = 20
50
)
51

52
// AddInvoiceConfig contains dependencies for invoice creation.
53
type AddInvoiceConfig struct {
54
        // AddInvoice is called to add the invoice to the registry.
55
        AddInvoice func(ctx context.Context, invoice *invoices.Invoice,
56
                paymentHash lntypes.Hash) (uint64, error)
57

58
        // IsChannelActive is used to generate valid hop hints.
59
        IsChannelActive func(chanID lnwire.ChannelID) bool
60

61
        // ChainParams are required to properly decode invoice payment requests
62
        // that are marshalled over rpc.
63
        ChainParams *chaincfg.Params
64

65
        // NodeSigner is an implementation of the MessageSigner implementation
66
        // that's backed by the identity private key of the running lnd node.
67
        NodeSigner *netann.NodeSigner
68

69
        // DefaultCLTVExpiry is the default invoice expiry if no values is
70
        // specified.
71
        DefaultCLTVExpiry uint32
72

73
        // ChanDB is a global boltdb instance which is needed to access the
74
        // channel graph.
75
        ChanDB *channeldb.ChannelStateDB
76

77
        // Graph gives the invoice server access to various graph related
78
        // queries.
79
        Graph GraphSource
80

81
        // GenInvoiceFeatures returns a feature containing feature bits that
82
        // should be advertised on freshly generated invoices.
83
        GenInvoiceFeatures func() *lnwire.FeatureVector
84

85
        // GenAmpInvoiceFeatures returns a feature containing feature bits that
86
        // should be advertised on freshly generated AMP invoices.
87
        GenAmpInvoiceFeatures func() *lnwire.FeatureVector
88

89
        // GetAlias allows the peer's alias SCID to be retrieved for private
90
        // option_scid_alias channels.
91
        GetAlias func(lnwire.ChannelID) (lnwire.ShortChannelID, error)
92

93
        // BestHeight returns the current best block height that this node is
94
        // aware of.
95
        BestHeight func() (uint32, error)
96

97
        // QueryBlindedPaths can be used to generate a few paths to this node
98
        // that can then be used in the construction of a blinded payment path.
99
        QueryBlindedPaths func() ([][]blindedpath.BlindedHop, error)
100

101
        // ProbabilitySource is a callback that is expected to return the
102
        // success probability of traversing the channel from the node.
103
        ProbabilitySource func(route.Vertex, route.Vertex,
104
                lnwire.MilliSatoshi, btcutil.Amount) float64
105
}
106

107
// AddInvoiceData contains the required data to create a new invoice.
108
type AddInvoiceData struct {
109
        // An optional memo to attach along with the invoice. Used for record
110
        // keeping purposes for the invoice's creator, and will also be set in
111
        // the description field of the encoded payment request if the
112
        // description_hash field is not being used.
113
        Memo string
114

115
        // The preimage which will allow settling an incoming HTLC payable to
116
        // this preimage. If Preimage is set, Hash should be nil. If both
117
        // Preimage and Hash are nil, a random preimage is generated.
118
        Preimage *lntypes.Preimage
119

120
        // The hash of the preimage. If Hash is set, Preimage should be nil.
121
        // This condition indicates that we have a 'hold invoice' for which the
122
        // htlc will be accepted and held until the preimage becomes known.
123
        Hash *lntypes.Hash
124

125
        // The value of this invoice in millisatoshis.
126
        Value lnwire.MilliSatoshi
127

128
        // Hash (SHA-256) of a descriptirouteson of the payment. Used if the
129
        // description of payment (memo) is too long to naturally fit within the
130
        // description field of an encoded payment request.
131
        DescriptionHash []byte
132

133
        // Payment request expiry time in seconds. Default is 3600 (1 hour).
134
        Expiry int64
135

136
        // Fallback on-chain address.
137
        FallbackAddr string
138

139
        // Delta to use for the time-locroutesk of the CLTV extended to the
140
        // final hop.
141
        CltvExpiry uint64
142

143
        // Whether this invoice should include routing hints for private
144
        // channels.
145
        Private bool
146

147
        // HodlInvoice signals that this invoice shouldn't be settled
148
        // immediately upon receiving the payment.
149
        HodlInvoice bool
150

151
        // Amp signals whether or not to create an AMP invoice.
152
        //
153
        // NOTE: Preimage should always be set to nil when this value is true.
154
        Amp bool
155

156
        // BlindedPathCfg holds the config values to use when constructing
157
        // blinded paths to add to the invoice. A non-nil BlindedPathCfg signals
158
        // that this invoice should disguise the location of the recipient by
159
        // adding blinded payment paths to the invoice instead of revealing the
160
        // destination node's real pub key.
161
        BlindedPathCfg *BlindedPathConfig
162

163
        // RouteHints are optional route hints that can each be individually
164
        // used to assist in reaching the invoice's destination.
165
        RouteHints [][]zpay32.HopHint
166
}
167

168
// BlindedPathConfig holds the configuration values required for blinded path
169
// generation for invoices.
170
type BlindedPathConfig struct {
171
        // RoutePolicyIncrMultiplier is the amount by which policy values for
172
        // hops in a blinded route will routesbe bumped to avoid easy probing.
173
        // For example, a multiplier of 1.1 will bump all appropriate the values
174
        // (base fee, fee rate, CLTV delta and min HLTC) by 10%.
175
        RoutePolicyIncrMultiplier float64
176

177
        // RoutePolicyDecrMultiplier is the amount by which appropriate policy
178
        // values for hops in a blinded route will be decreased to avoid easy
179
        // probing. For example, a multiplier of 0.9 will reduce appropriate
180
        // values (like maximum HTLC) by 10%.
181
        RoutePolicyDecrMultiplier float64
182

183
        // MinNumPathHops is the minimum number of hops that a blinded path
184
        // should be. Dummy hops will be used to pad any route with a length
185
        // less than this.
186
        MinNumPathHops uint8
187

188
        // MaxNumPaths is the maximum number of blinded paths to select.
189
        MaxNumPaths uint8
190

191
        // DefaultDummyHopPolicy holds the default policy values to use for
192
        // dummy hops in a blinded path in the case where they cant be derived
193
        // through other means.
194
        DefaultDummyHopPolicy *blindedpath.BlindedHopPolicy
195
}
196

197
// paymentHashAndPreimage returns the payment hash and preimage for this invoice
198
// depending on the configuration.
199
//
200
// For AMP invoices (when Amp flag is true), this method always returns a nil
201
// preimage. The hash value can be set externally by the user using the Hash
202
// field, or one will be generated randomly. The payment hash here only serves
203
// as a unique identifier for insertion into the invoice index, as there is
204
// no universal preimage for an AMP payment.
205
//
206
// For MPP invoices (when Amp flag is false), this method may return nil
207
// preimage when create a hodl invoice, but otherwise will always return a
208
// non-nil preimage and the corresponding payment hash. The valid combinations
209
// are parsed as follows:
210
//   - Preimage == nil && Hash == nil -> (random preimage, H(random preimage))
211
//   - Preimage != nil && Hash == nil -> (Preimage, H(Preimage))
212
//   - Preimage == nil && Hash != nil -> (nil, Hash)
213
func (d *AddInvoiceData) paymentHashAndPreimage() (
214
        *lntypes.Preimage, lntypes.Hash, error) {
3✔
215

3✔
216
        if d.Amp {
6✔
217
                return d.ampPaymentHashAndPreimage()
3✔
218
        }
3✔
219

220
        return d.mppPaymentHashAndPreimage()
3✔
221
}
222

223
// ampPaymentHashAndPreimage returns the payment hash to use for an AMP invoice.
224
// The preimage will always be nil.
225
func (d *AddInvoiceData) ampPaymentHashAndPreimage() (*lntypes.Preimage,
226
        lntypes.Hash, error) {
3✔
227

3✔
228
        switch {
3✔
229
        // Preimages cannot be set on AMP invoice.
230
        case d.Preimage != nil:
×
231
                return nil, lntypes.Hash{},
×
232
                        errors.New("preimage set on AMP invoice")
×
233

234
        // If a specific hash was requested, use that.
235
        case d.Hash != nil:
×
236
                return nil, *d.Hash, nil
×
237

238
        // Otherwise generate a random hash value, just needs to be unique to be
239
        // added to the invoice index.
240
        default:
3✔
241
                var paymentHash lntypes.Hash
3✔
242
                if _, err := rand.Read(paymentHash[:]); err != nil {
3✔
243
                        return nil, lntypes.Hash{}, err
×
244
                }
×
245

246
                return nil, paymentHash, nil
3✔
247
        }
248
}
249

250
// mppPaymentHashAndPreimage returns the payment hash and preimage to use for an
251
// MPP invoice.
252
func (d *AddInvoiceData) mppPaymentHashAndPreimage() (*lntypes.Preimage,
253
        lntypes.Hash, error) {
3✔
254

3✔
255
        var (
3✔
256
                paymentPreimage *lntypes.Preimage
3✔
257
                paymentHash     lntypes.Hash
3✔
258
        )
3✔
259

3✔
260
        switch {
3✔
261

262
        // Only either preimage or hash can be set.
263
        case d.Preimage != nil && d.Hash != nil:
×
264
                return nil, lntypes.Hash{},
×
265
                        errors.New("preimage and hash both set")
×
266

267
        // If no hash or preimage is given, generate a random preimage.
268
        case d.Preimage == nil && d.Hash == nil:
3✔
269
                paymentPreimage = &lntypes.Preimage{}
3✔
270
                if _, err := rand.Read(paymentPreimage[:]); err != nil {
3✔
271
                        return nil, lntypes.Hash{}, err
×
272
                }
×
273
                paymentHash = paymentPreimage.Hash()
3✔
274

275
        // If just a hash is given, we create a hold invoice by setting the
276
        // preimage to unknown.
277
        case d.Preimage == nil && d.Hash != nil:
3✔
278
                paymentHash = *d.Hash
3✔
279

280
        // A specific preimage was supplied. Use that for the invoice.
281
        case d.Preimage != nil && d.Hash == nil:
3✔
282
                preimage := *d.Preimage
3✔
283
                paymentPreimage = &preimage
3✔
284
                paymentHash = d.Preimage.Hash()
3✔
285
        }
286

287
        return paymentPreimage, paymentHash, nil
3✔
288
}
289

290
// BlindedPathsToRoutes converts blinded paths into *route.Route objects,
291
// filtering out low-probability routes based on a given ProbabilitySource, and
292
// returns the valid ones sorted by success probability.
293
func BlindedPathsToRoutes(blindedPaths [][]blindedpath.BlindedHop,
294
        cfg *AddInvoiceConfig,
295
        amt lnwire.MilliSatoshi) ([]*route.Route, error) {
3✔
296
        // routeWithProbability groups a route with the probability of a
3✔
297
        // payment of the given amount succeeding on that path.
3✔
298
        type routeWithProbability struct {
3✔
299
                route       *route.Route
3✔
300
                probability float64
3✔
301
        }
3✔
302

3✔
303
        // Iterate over all the candidate paths and determine the
3✔
304
        // success probability of each path given the data we have about
3✔
305
        // forwards between any two nodes on a path.
3✔
306
        routes := make([]*routeWithProbability, 0, len(blindedPaths))
3✔
307
        for _, path := range blindedPaths {
6✔
308
                if len(path) < 1 {
3✔
NEW
309
                        return nil,
×
NEW
310
                                fmt.Errorf("a blinded path must have " +
×
NEW
311
                                        "at least one hop")
×
NEW
312
                }
×
313

314
                var (
3✔
315
                        introNode = path[0].Vertex
3✔
316
                        prevNode  = introNode
3✔
317
                        hops      = make(
3✔
318
                                []*route.Hop, 0, len(path)-1,
3✔
319
                        )
3✔
320
                        totalRouteProbability = float64(1)
3✔
321
                )
3✔
322

3✔
323
                // For each set of hops on the path, get the success
3✔
324
                // probability of a forward between those two vertices
3✔
325
                // and use that to update the overall route probability.
3✔
326
                for j := 1; j < len(path); j++ {
6✔
327
                        probability := cfg.ProbabilitySource(
3✔
328
                                prevNode, path[j].Vertex, amt,
3✔
329
                                path[j-1].EdgeCapacity,
3✔
330
                        )
3✔
331

3✔
332
                        totalRouteProbability *= probability
3✔
333

3✔
334
                        hops = append(hops, &route.Hop{
3✔
335
                                PubKeyBytes: path[j].Vertex,
3✔
336
                                ChannelID:   path[j-1].ChannelID,
3✔
337
                        })
3✔
338

3✔
339
                        prevNode = path[j].Vertex
3✔
340
                }
3✔
341

342
                // Don't bother adding a route if its success
343
                // probability less minimum that can be assigned to any
344
                // single pair.
345
                if totalRouteProbability <=
3✔
346
                        routing.DefaultMinRouteProbability {
3✔
NEW
347

×
NEW
348
                        continue
×
349
                }
350

351
                routes = append(routes, &routeWithProbability{
3✔
352
                        route: &route.Route{
3✔
353
                                SourcePubKey: introNode,
3✔
354
                                Hops:         hops,
3✔
355
                        },
3✔
356
                        probability: totalRouteProbability,
3✔
357
                })
3✔
358
        }
359

360
        // Sort the routes based on probability.
361
        sort.Slice(routes, func(i, j int) bool {
6✔
362
                return routes[i].probability > routes[j].probability
3✔
363
        })
3✔
364

365
        log.Debugf("Found %v routes, discarded %v low probability "+
3✔
366
                "routes", len(blindedPaths),
3✔
367
                len(blindedPaths)-len(routes),
3✔
368
        )
3✔
369

3✔
370
        // Return all routes.
3✔
371
        allRoutes := make([]*route.Route, 0, len(routes))
3✔
372
        for _, route := range routes {
6✔
373
                allRoutes = append(allRoutes, route.route)
3✔
374
        }
3✔
375

376
        return allRoutes, nil
3✔
377
}
378

379
// AddInvoice attempts to add a new invoice to the invoice database. Any
380
// duplicated invoices are rejected, therefore all invoices *must* have a
381
// unique payment preimage.
382
func AddInvoice(ctx context.Context, cfg *AddInvoiceConfig,
383
        invoice *AddInvoiceData) (*lntypes.Hash, *invoices.Invoice, error) {
3✔
384

3✔
385
        blind := invoice.BlindedPathCfg != nil
3✔
386

3✔
387
        if invoice.Amp && blind {
3✔
388
                return nil, nil, fmt.Errorf("AMP invoices with blinded paths " +
×
389
                        "are not yet supported")
×
390
        }
×
391

392
        paymentPreimage, paymentHash, err := invoice.paymentHashAndPreimage()
3✔
393
        if err != nil {
3✔
394
                return nil, nil, err
×
395
        }
×
396

397
        // The size of the memo, receipt and description hash attached must not
398
        // exceed the maximum values for either of the fields.
399
        if len(invoice.Memo) > invoices.MaxMemoSize {
3✔
400
                return nil, nil, fmt.Errorf("memo too large: %v bytes "+
×
401
                        "(maxsize=%v)", len(invoice.Memo),
×
402
                        invoices.MaxMemoSize)
×
403
        }
×
404
        if len(invoice.DescriptionHash) > 0 &&
3✔
405
                len(invoice.DescriptionHash) != 32 {
3✔
406

×
407
                return nil, nil, fmt.Errorf("description hash is %v bytes, "+
×
408
                        "must be 32", len(invoice.DescriptionHash))
×
409
        }
×
410

411
        // We set the max invoice amount to 100k BTC, which itself is several
412
        // multiples off the current block reward.
413
        maxInvoiceAmt := btcutil.Amount(btcutil.SatoshiPerBitcoin * 100000)
3✔
414

3✔
415
        switch {
3✔
416
        // The value of the invoice must not be negative.
417
        case int64(invoice.Value) < 0:
×
418
                return nil, nil, fmt.Errorf("payments of negative value "+
×
419
                        "are not allowed, value is %v", int64(invoice.Value))
×
420

421
        // Also ensure that the invoice is actually realistic, while preventing
422
        // any issues due to underflow.
423
        case invoice.Value.ToSatoshis() > maxInvoiceAmt:
×
424
                return nil, nil, fmt.Errorf("invoice amount %v is "+
×
425
                        "too large, max is %v", invoice.Value.ToSatoshis(),
×
426
                        maxInvoiceAmt)
×
427
        }
428

429
        amtMSat := invoice.Value
3✔
430

3✔
431
        // We also create an encoded payment request which allows the
3✔
432
        // caller to compactly send the invoice to the payer. We'll create a
3✔
433
        // list of options to be added to the encoded payment request. For now
3✔
434
        // we only support the required fields description/description_hash,
3✔
435
        // expiry, fallback address, and the amount field.
3✔
436
        var options []func(*zpay32.Invoice)
3✔
437

3✔
438
        // We only include the amount in the invoice if it is greater than 0.
3✔
439
        // By not including the amount, we enable the creation of invoices that
3✔
440
        // allow the payer to specify the amount of satoshis they wish to send.
3✔
441
        if amtMSat > 0 {
6✔
442
                options = append(options, zpay32.Amount(amtMSat))
3✔
443
        }
3✔
444

445
        // If specified, add a fallback address to the payment request.
446
        if len(invoice.FallbackAddr) > 0 {
3✔
447
                addr, err := btcutil.DecodeAddress(
×
448
                        invoice.FallbackAddr, cfg.ChainParams,
×
449
                )
×
450
                if err != nil {
×
451
                        return nil, nil, fmt.Errorf("invalid fallback "+
×
452
                                "address: %v", err)
×
453
                }
×
454

455
                if !addr.IsForNet(cfg.ChainParams) {
×
456
                        return nil, nil, fmt.Errorf("fallback address is not "+
×
457
                                "for %s", cfg.ChainParams.Name)
×
458
                }
×
459

460
                options = append(options, zpay32.FallbackAddr(addr))
×
461
        }
462

463
        var expiry time.Duration
3✔
464
        switch {
3✔
465
        // An invoice expiry has been provided by the caller.
466
        case invoice.Expiry > 0:
×
467

×
468
                // We'll ensure that the specified expiry is restricted to sane
×
469
                // number of seconds. As a result, we'll reject an invoice with
×
470
                // an expiry greater than 1 year.
×
471
                maxExpiry := time.Hour * 24 * 365
×
472
                expSeconds := invoice.Expiry
×
473

×
474
                if float64(expSeconds) > maxExpiry.Seconds() {
×
475
                        return nil, nil, fmt.Errorf("expiry of %v seconds "+
×
476
                                "greater than max expiry of %v seconds",
×
477
                                float64(expSeconds), maxExpiry.Seconds())
×
478
                }
×
479

480
                expiry = time.Duration(invoice.Expiry) * time.Second
×
481

482
        // If no custom expiry is provided, use the default MPP expiry.
483
        case !invoice.Amp:
3✔
484
                expiry = DefaultInvoiceExpiry
3✔
485

486
        // Otherwise, use the default AMP expiry.
487
        default:
3✔
488
                expiry = DefaultAMPInvoiceExpiry
3✔
489
        }
490

491
        options = append(options, zpay32.Expiry(expiry))
3✔
492

3✔
493
        // If the description hash is set, then we add it do the list of
3✔
494
        // options. If not, use the memo field as the payment request
3✔
495
        // description.
3✔
496
        if len(invoice.DescriptionHash) > 0 {
3✔
497
                var descHash [32]byte
×
498
                copy(descHash[:], invoice.DescriptionHash[:])
×
499
                options = append(options, zpay32.DescriptionHash(descHash))
×
500
        } else {
3✔
501
                // Use the memo field as the description. If this is not set
3✔
502
                // this will just be an empty string.
3✔
503
                options = append(options, zpay32.Description(invoice.Memo))
3✔
504
        }
3✔
505

506
        if invoice.CltvExpiry > routing.MaxCLTVDelta {
3✔
507
                return nil, nil, fmt.Errorf("CLTV delta of %v is too large, "+
×
508
                        "max accepted is: %v", invoice.CltvExpiry,
×
509
                        math.MaxUint16)
×
510
        }
×
511

512
        // We'll use our current default CLTV value unless one was specified as
513
        // an option on the command line when creating an invoice.
514
        cltvExpiryDelta := uint64(cfg.DefaultCLTVExpiry)
3✔
515
        if invoice.CltvExpiry != 0 {
6✔
516
                // Disallow user-chosen final CLTV deltas below the required
3✔
517
                // minimum.
3✔
518
                if invoice.CltvExpiry < routing.MinCLTVDelta {
3✔
519
                        return nil, nil, fmt.Errorf("CLTV delta of %v must be "+
×
520
                                "greater than minimum of %v",
×
521
                                invoice.CltvExpiry, routing.MinCLTVDelta)
×
522
                }
×
523

524
                cltvExpiryDelta = invoice.CltvExpiry
3✔
525
        }
526

527
        // Only include a final CLTV expiry delta if this is not a blinded
528
        // invoice. In a blinded invoice, this value will be added to the total
529
        // blinded route CLTV delta value
530
        if !blind {
6✔
531
                options = append(options, zpay32.CLTVExpiry(cltvExpiryDelta))
3✔
532
        }
3✔
533

534
        // We make sure that the given invoice routing hints number is within
535
        // the valid range
536
        if len(invoice.RouteHints) > maxHopHints {
3✔
537
                return nil, nil, fmt.Errorf("number of routing hints must "+
×
538
                        "not exceed maximum of %v", maxHopHints)
×
539
        }
×
540

541
        // Include route hints if needed.
542
        if len(invoice.RouteHints) > 0 || invoice.Private {
6✔
543
                if blind {
3✔
544
                        return nil, nil, fmt.Errorf("can't set both hop " +
×
545
                                "hints and add blinded payment paths")
×
546
                }
×
547

548
                // Validate provided hop hints.
549
                for _, hint := range invoice.RouteHints {
6✔
550
                        if len(hint) == 0 {
3✔
551
                                return nil, nil, fmt.Errorf("number of hop " +
×
552
                                        "hint within a route must be positive")
×
553
                        }
×
554
                }
555

556
                totalHopHints := len(invoice.RouteHints)
3✔
557
                if invoice.Private {
6✔
558
                        totalHopHints = maxHopHints
3✔
559
                }
3✔
560

561
                hopHintsCfg := newSelectHopHintsCfg(cfg, totalHopHints)
3✔
562
                hopHints, err := PopulateHopHints(
3✔
563
                        hopHintsCfg, amtMSat, invoice.RouteHints,
3✔
564
                )
3✔
565
                if err != nil {
3✔
566
                        return nil, nil, fmt.Errorf("unable to populate hop "+
×
567
                                "hints: %v", err)
×
568
                }
×
569

570
                // Convert our set of selected hop hints into route
571
                // hints and add to our invoice options.
572
                for _, hopHint := range hopHints {
6✔
573
                        routeHint := zpay32.RouteHint(hopHint)
3✔
574

3✔
575
                        options = append(
3✔
576
                                options, routeHint,
3✔
577
                        )
3✔
578
                }
3✔
579
        }
580

581
        // Set our desired invoice features and add them to our list of options.
582
        var invoiceFeatures *lnwire.FeatureVector
3✔
583
        if invoice.Amp {
6✔
584
                invoiceFeatures = cfg.GenAmpInvoiceFeatures()
3✔
585
        } else {
6✔
586
                invoiceFeatures = cfg.GenInvoiceFeatures()
3✔
587
        }
3✔
588
        options = append(options, zpay32.Features(invoiceFeatures))
3✔
589

3✔
590
        // Generate and set a random payment address for this payment. If the
3✔
591
        // sender understands payment addresses, this can be used to avoid
3✔
592
        // intermediaries probing the receiver. If the invoice does not have
3✔
593
        // blinded paths, then this will be encoded in the invoice itself.
3✔
594
        // Otherwise, it will instead be embedded in the encrypted recipient
3✔
595
        // data of blinded paths. In the blinded path case, this will be used
3✔
596
        // for the PathID.
3✔
597
        var paymentAddr [32]byte
3✔
598
        if _, err := rand.Read(paymentAddr[:]); err != nil {
3✔
599
                return nil, nil, err
×
600
        }
×
601

602
        if blind {
6✔
603
                blindCfg := invoice.BlindedPathCfg
3✔
604

3✔
605
                // Use the 10-min-per-block assumption to get a rough estimate
3✔
606
                // of the number of blocks until the invoice expires. We want
3✔
607
                // to make sure that the blinded path definitely does not expire
3✔
608
                // before the invoice does, and so we add a healthy buffer.
3✔
609
                invoiceExpiry := uint32(expiry.Minutes() / 10)
3✔
610
                blindedPathExpiry := invoiceExpiry * 2
3✔
611

3✔
612
                // Add BlockPadding to the finalCltvDelta so that the receiving
3✔
613
                // node does not reject the HTLC if some blocks are mined while
3✔
614
                // the payment is in-flight. Note that unlike vanilla invoices,
3✔
615
                // with blinded paths, the recipient is responsible for adding
3✔
616
                // this block padding instead of the sender.
3✔
617
                finalCLTVDelta := uint32(cltvExpiryDelta)
3✔
618
                finalCLTVDelta += uint32(routing.BlockPadding)
3✔
619

3✔
620
                // This will return a set of paths made up of real nodes.
3✔
621
                allPaths, err := cfg.QueryBlindedPaths()
3✔
622
                if err != nil {
3✔
NEW
623
                        return nil, nil, err
×
NEW
624
                }
×
625

626
                allRoutes, err := BlindedPathsToRoutes(allPaths, cfg, amtMSat)
3✔
627
                if err != nil {
3✔
NEW
628
                        return nil, nil, err
×
NEW
629
                }
×
630

631
                //nolint:ll
632
                paths, err := blindedpath.BuildBlindedPaymentPaths(
3✔
633
                        &blindedpath.BuildBlindedPathCfg{
3✔
634
                                Routes:                  allRoutes,
3✔
635
                                FetchChannelEdgesByID:   cfg.Graph.FetchChannelEdgesByID,
3✔
636
                                FetchOurOpenChannels:    cfg.ChanDB.FetchAllOpenChannels,
3✔
637
                                PathID:                  paymentAddr[:],
3✔
638
                                ValueMsat:               invoice.Value,
3✔
639
                                BestHeight:              cfg.BestHeight,
3✔
640
                                MinFinalCLTVExpiryDelta: finalCLTVDelta,
3✔
641
                                BlocksUntilExpiry:       blindedPathExpiry,
3✔
642
                                AddPolicyBuffer: func(
3✔
643
                                        p *blindedpath.BlindedHopPolicy) (
3✔
644
                                        *blindedpath.BlindedHopPolicy, error) {
6✔
645

3✔
646
                                        //nolint:ll
3✔
647
                                        return blindedpath.AddPolicyBuffer(
3✔
648
                                                p, blindCfg.RoutePolicyIncrMultiplier,
3✔
649
                                                blindCfg.RoutePolicyDecrMultiplier,
3✔
650
                                        )
3✔
651
                                },
3✔
652
                                MinNumHops:            blindCfg.MinNumPathHops,
653
                                DefaultDummyHopPolicy: blindCfg.DefaultDummyHopPolicy,
654
                                MaxNumPaths:           blindCfg.MaxNumPaths,
655
                        },
656
                )
657
                if err != nil {
3✔
658
                        return nil, nil, err
×
659
                }
×
660

661
                for _, path := range paths {
6✔
662
                        options = append(options, zpay32.WithBlindedPaymentPath(
3✔
663
                                path,
3✔
664
                        ))
3✔
665
                }
3✔
666
        } else {
3✔
667
                options = append(options, zpay32.PaymentAddr(paymentAddr))
3✔
668
        }
3✔
669

670
        // Create and encode the payment request as a bech32 (zpay32) string.
671
        creationDate := time.Now()
3✔
672
        payReq, err := zpay32.NewInvoice(
3✔
673
                cfg.ChainParams, paymentHash, creationDate, options...,
3✔
674
        )
3✔
675
        if err != nil {
3✔
676
                return nil, nil, err
×
677
        }
×
678

679
        payReqString, err := payReq.Encode(zpay32.MessageSigner{
3✔
680
                SignCompact: func(msg []byte) ([]byte, error) {
6✔
681
                        // For an invoice without a blinded path, the main node
3✔
682
                        // key is used to sign the invoice so that the sender
3✔
683
                        // can derive the true pub key of the recipient.
3✔
684
                        if !blind {
6✔
685
                                return cfg.NodeSigner.SignMessageCompact(
3✔
686
                                        msg, false,
3✔
687
                                )
3✔
688
                        }
3✔
689

690
                        // For an invoice with a blinded path, we use an
691
                        // ephemeral key to sign the invoice since we don't want
692
                        // the sender to be able to know the real pub key of
693
                        // the recipient.
694
                        ephemKey, err := btcec.NewPrivateKey()
3✔
695
                        if err != nil {
3✔
696
                                return nil, err
×
697
                        }
×
698

699
                        return ecdsa.SignCompact(
3✔
700
                                ephemKey, chainhash.HashB(msg), true,
3✔
701
                        ), nil
3✔
702
                },
703
        })
704
        if err != nil {
3✔
705
                return nil, nil, err
×
706
        }
×
707

708
        newInvoice := &invoices.Invoice{
3✔
709
                CreationDate:   creationDate,
3✔
710
                Memo:           []byte(invoice.Memo),
3✔
711
                PaymentRequest: []byte(payReqString),
3✔
712
                Terms: invoices.ContractTerm{
3✔
713
                        FinalCltvDelta:  int32(payReq.MinFinalCLTVExpiry()),
3✔
714
                        Expiry:          payReq.Expiry(),
3✔
715
                        Value:           amtMSat,
3✔
716
                        PaymentPreimage: paymentPreimage,
3✔
717
                        PaymentAddr:     paymentAddr,
3✔
718
                        Features:        invoiceFeatures,
3✔
719
                },
3✔
720
                HodlInvoice: invoice.HodlInvoice,
3✔
721
        }
3✔
722

3✔
723
        log.Tracef("[addinvoice] adding new invoice %v",
3✔
724
                lnutils.SpewLogClosure(newInvoice))
3✔
725

3✔
726
        // With all sanity checks passed, write the invoice to the database.
3✔
727
        _, err = cfg.AddInvoice(ctx, newInvoice, paymentHash)
3✔
728
        if err != nil {
3✔
729
                return nil, nil, err
×
730
        }
×
731

732
        return &paymentHash, newInvoice, nil
3✔
733
}
734

735
// chanCanBeHopHint returns true if the target channel is eligible to be a hop
736
// hint.
737
func chanCanBeHopHint(channel *HopHintInfo, cfg *SelectHopHintsCfg) (
738
        *models.ChannelEdgePolicy, bool) {
3✔
739

3✔
740
        // Since we're only interested in our private channels, we'll skip
3✔
741
        // public ones.
3✔
742
        if channel.IsPublic {
3✔
743
                return nil, false
×
744
        }
×
745

746
        // Make sure the channel is active.
747
        if !channel.IsActive {
6✔
748
                log.Debugf("Skipping channel %v due to not "+
3✔
749
                        "being eligible to forward payments",
3✔
750
                        channel.ShortChannelID)
3✔
751
                return nil, false
3✔
752
        }
3✔
753

754
        // To ensure we don't leak unadvertised nodes, we'll make sure our
755
        // counterparty is publicly advertised within the network.  Otherwise,
756
        // we'll end up leaking information about nodes that intend to stay
757
        // unadvertised, like in the case of a node only having private
758
        // channels.
759
        var remotePub [33]byte
3✔
760
        copy(remotePub[:], channel.RemotePubkey.SerializeCompressed())
3✔
761
        isRemoteNodePublic, err := cfg.IsPublicNode(remotePub)
3✔
762
        if err != nil {
3✔
763
                log.Errorf("Unable to determine if node %x "+
×
764
                        "is advertised: %v", remotePub, err)
×
765
                return nil, false
×
766
        }
×
767

768
        if !isRemoteNodePublic {
6✔
769
                log.Debugf("Skipping channel %v due to "+
3✔
770
                        "counterparty %x being unadvertised",
3✔
771
                        channel.ShortChannelID, remotePub)
3✔
772
                return nil, false
3✔
773
        }
3✔
774

775
        // Fetch the policies for each end of the channel.
776
        info, p1, p2, err := cfg.FetchChannelEdgesByID(channel.ShortChannelID)
3✔
777
        if err != nil {
3✔
778
                // In the case of zero-conf channels, it may be the case that
×
779
                // the alias SCID was deleted from the graph, and replaced by
×
780
                // the confirmed SCID. Check the Graph for the confirmed SCID.
×
781
                confirmedScid := channel.ConfirmedScidZC
×
782
                info, p1, p2, err = cfg.FetchChannelEdgesByID(confirmedScid)
×
783
                if err != nil {
×
784
                        log.Errorf("Unable to fetch the routing policies for "+
×
785
                                "the edges of the channel %v: %v",
×
786
                                channel.ShortChannelID, err)
×
787
                        return nil, false
×
788
                }
×
789
        }
790

791
        // Now, we'll need to determine which is the correct policy for HTLCs
792
        // being sent from the remote node.
793
        var remotePolicy *models.ChannelEdgePolicy
3✔
794
        if bytes.Equal(remotePub[:], info.NodeKey1Bytes[:]) {
6✔
795
                remotePolicy = p1
3✔
796
        } else {
6✔
797
                remotePolicy = p2
3✔
798
        }
3✔
799

800
        return remotePolicy, true
3✔
801
}
802

803
// HopHintInfo contains the channel information required to create a hop hint.
804
type HopHintInfo struct {
805
        // IsPublic indicates whether a channel is advertised to the network.
806
        IsPublic bool
807

808
        // IsActive indicates whether the channel is online and available for
809
        // use.
810
        IsActive bool
811

812
        // FundingOutpoint is the funding txid:index for the channel.
813
        FundingOutpoint wire.OutPoint
814

815
        // RemotePubkey is the public key of the remote party that this channel
816
        // is in.
817
        RemotePubkey *btcec.PublicKey
818

819
        // RemoteBalance is the remote party's balance (our current incoming
820
        // capacity).
821
        RemoteBalance lnwire.MilliSatoshi
822

823
        // ShortChannelID is the short channel ID of the channel.
824
        ShortChannelID uint64
825

826
        // ConfirmedScidZC is the confirmed SCID of a zero-conf channel. This
827
        // may be used for looking up a channel in the graph.
828
        ConfirmedScidZC uint64
829

830
        // ScidAliasFeature denotes whether the channel has negotiated the
831
        // option-scid-alias feature bit.
832
        ScidAliasFeature bool
833
}
834

835
func newHopHintInfo(c *channeldb.OpenChannel, isActive bool) *HopHintInfo {
3✔
836
        isPublic := c.ChannelFlags&lnwire.FFAnnounceChannel != 0
3✔
837

3✔
838
        return &HopHintInfo{
3✔
839
                IsPublic:         isPublic,
3✔
840
                IsActive:         isActive,
3✔
841
                FundingOutpoint:  c.FundingOutpoint,
3✔
842
                RemotePubkey:     c.IdentityPub,
3✔
843
                RemoteBalance:    c.LocalCommitment.RemoteBalance,
3✔
844
                ShortChannelID:   c.ShortChannelID.ToUint64(),
3✔
845
                ConfirmedScidZC:  c.ZeroConfRealScid().ToUint64(),
3✔
846
                ScidAliasFeature: c.ChanType.HasScidAliasFeature(),
3✔
847
        }
3✔
848
}
3✔
849

850
// newHopHint returns a new hop hint using the relevant data from a hopHintInfo
851
// and a ChannelEdgePolicy.
852
func newHopHint(hopHintInfo *HopHintInfo,
853
        chanPolicy *models.ChannelEdgePolicy) zpay32.HopHint {
3✔
854

3✔
855
        return zpay32.HopHint{
3✔
856
                NodeID:      hopHintInfo.RemotePubkey,
3✔
857
                ChannelID:   hopHintInfo.ShortChannelID,
3✔
858
                FeeBaseMSat: uint32(chanPolicy.FeeBaseMSat),
3✔
859
                FeeProportionalMillionths: uint32(
3✔
860
                        chanPolicy.FeeProportionalMillionths,
3✔
861
                ),
3✔
862
                CLTVExpiryDelta: chanPolicy.TimeLockDelta,
3✔
863
        }
3✔
864
}
3✔
865

866
// SelectHopHintsCfg contains the dependencies required to obtain hop hints
867
// for an invoice.
868
type SelectHopHintsCfg struct {
869
        // IsPublicNode is returns a bool indicating whether the node with the
870
        // given public key is seen as a public node in the graph from the
871
        // graph's source node's point of view.
872
        IsPublicNode func(pubKey [33]byte) (bool, error)
873

874
        // FetchChannelEdgesByID attempts to lookup the two directed edges for
875
        // the channel identified by the channel ID.
876
        FetchChannelEdgesByID func(chanID uint64) (*models.ChannelEdgeInfo,
877
                *models.ChannelEdgePolicy, *models.ChannelEdgePolicy,
878
                error)
879

880
        // GetAlias allows the peer's alias SCID to be retrieved for private
881
        // option_scid_alias channels.
882
        GetAlias func(lnwire.ChannelID) (lnwire.ShortChannelID, error)
883

884
        // FetchAllChannels retrieves all open channels currently stored
885
        // within the database.
886
        FetchAllChannels func() ([]*channeldb.OpenChannel, error)
887

888
        // IsChannelActive checks whether the channel identified by the provided
889
        // ChannelID is considered active.
890
        IsChannelActive func(chanID lnwire.ChannelID) bool
891

892
        // MaxHopHints is the maximum number of hop hints we are interested in.
893
        MaxHopHints int
894
}
895

896
func newSelectHopHintsCfg(invoicesCfg *AddInvoiceConfig,
897
        maxHopHints int) *SelectHopHintsCfg {
3✔
898

3✔
899
        return &SelectHopHintsCfg{
3✔
900
                FetchAllChannels:      invoicesCfg.ChanDB.FetchAllChannels,
3✔
901
                IsChannelActive:       invoicesCfg.IsChannelActive,
3✔
902
                IsPublicNode:          invoicesCfg.Graph.IsPublicNode,
3✔
903
                FetchChannelEdgesByID: invoicesCfg.Graph.FetchChannelEdgesByID,
3✔
904
                GetAlias:              invoicesCfg.GetAlias,
3✔
905
                MaxHopHints:           maxHopHints,
3✔
906
        }
3✔
907
}
3✔
908

909
// sufficientHints checks whether we have sufficient hop hints, based on the
910
// any of the following criteria:
911
//   - Hop hint count: the number of hints have reach our max target.
912
//   - Total incoming capacity (for non-zero invoice amounts): the sum of the
913
//     remote balance amount in the hints is bigger of equal than our target
914
//     (currently twice the invoice amount)
915
//
916
// We limit our number of hop hints like this to keep our invoice size down,
917
// and to avoid leaking all our private channels when we don't need to.
918
func sufficientHints(nHintsLeft int, currentAmount,
919
        targetAmount lnwire.MilliSatoshi) bool {
3✔
920

3✔
921
        if nHintsLeft <= 0 {
3✔
922
                log.Debugf("Reached targeted number of hop hints")
×
923
                return true
×
924
        }
×
925

926
        if targetAmount != 0 && currentAmount >= targetAmount {
6✔
927
                log.Debugf("Total hint amount: %v has reached target hint "+
3✔
928
                        "bandwidth: %v", currentAmount, targetAmount)
3✔
929
                return true
3✔
930
        }
3✔
931

932
        return false
3✔
933
}
934

935
// getPotentialHints returns a slice of open channels that should be considered
936
// for the hopHint list in an invoice. The slice is sorted in descending order
937
// based on the remote balance.
938
func getPotentialHints(cfg *SelectHopHintsCfg) ([]*channeldb.OpenChannel,
939
        error) {
3✔
940

3✔
941
        // TODO(positiveblue): get the channels slice already filtered by
3✔
942
        // private == true and sorted by RemoteBalance?
3✔
943
        openChannels, err := cfg.FetchAllChannels()
3✔
944
        if err != nil {
3✔
945
                return nil, err
×
946
        }
×
947

948
        privateChannels := make([]*channeldb.OpenChannel, 0, len(openChannels))
3✔
949
        for _, oc := range openChannels {
6✔
950
                isPublic := oc.ChannelFlags&lnwire.FFAnnounceChannel != 0
3✔
951
                if !isPublic {
6✔
952
                        privateChannels = append(privateChannels, oc)
3✔
953
                }
3✔
954
        }
955

956
        // Sort the channels in descending remote balance.
957
        compareRemoteBalance := func(i, j int) bool {
6✔
958
                iBalance := privateChannels[i].LocalCommitment.RemoteBalance
3✔
959
                jBalance := privateChannels[j].LocalCommitment.RemoteBalance
3✔
960
                return iBalance > jBalance
3✔
961
        }
3✔
962
        sort.Slice(privateChannels, compareRemoteBalance)
3✔
963

3✔
964
        return privateChannels, nil
3✔
965
}
966

967
// shouldIncludeChannel returns true if the channel passes all the checks to
968
// be a hopHint in a given invoice.
969
func shouldIncludeChannel(cfg *SelectHopHintsCfg,
970
        channel *channeldb.OpenChannel,
971
        alreadyIncluded map[uint64]bool) (zpay32.HopHint, lnwire.MilliSatoshi,
972
        bool) {
3✔
973

3✔
974
        if _, ok := alreadyIncluded[channel.ShortChannelID.ToUint64()]; ok {
3✔
975
                return zpay32.HopHint{}, 0, false
×
976
        }
×
977

978
        chanID := lnwire.NewChanIDFromOutPoint(
3✔
979
                channel.FundingOutpoint,
3✔
980
        )
3✔
981

3✔
982
        hopHintInfo := newHopHintInfo(channel, cfg.IsChannelActive(chanID))
3✔
983

3✔
984
        // If this channel can't be a hop hint, then skip it.
3✔
985
        edgePolicy, canBeHopHint := chanCanBeHopHint(hopHintInfo, cfg)
3✔
986
        if edgePolicy == nil || !canBeHopHint {
6✔
987
                return zpay32.HopHint{}, 0, false
3✔
988
        }
3✔
989

990
        if hopHintInfo.ScidAliasFeature {
6✔
991
                alias, err := cfg.GetAlias(chanID)
3✔
992
                if err != nil {
3✔
993
                        return zpay32.HopHint{}, 0, false
×
994
                }
×
995

996
                if alias.IsDefault() || alreadyIncluded[alias.ToUint64()] {
3✔
997
                        return zpay32.HopHint{}, 0, false
×
998
                }
×
999

1000
                hopHintInfo.ShortChannelID = alias.ToUint64()
3✔
1001
        }
1002

1003
        // Now that we know this channel use usable, add it as a hop hint and
1004
        // the indexes we'll use later.
1005
        hopHint := newHopHint(hopHintInfo, edgePolicy)
3✔
1006
        return hopHint, hopHintInfo.RemoteBalance, true
3✔
1007
}
1008

1009
// selectHopHints iterates a list of potential hints selecting the valid hop
1010
// hints until we have enough hints or run out of channels.
1011
//
1012
// NOTE: selectHopHints expects potentialHints to be already sorted in
1013
// descending priority.
1014
func selectHopHints(cfg *SelectHopHintsCfg, nHintsLeft int,
1015
        targetBandwidth lnwire.MilliSatoshi,
1016
        potentialHints []*channeldb.OpenChannel,
1017
        alreadyIncluded map[uint64]bool) [][]zpay32.HopHint {
3✔
1018

3✔
1019
        currentBandwidth := lnwire.MilliSatoshi(0)
3✔
1020
        hopHints := make([][]zpay32.HopHint, 0, nHintsLeft)
3✔
1021
        for _, channel := range potentialHints {
6✔
1022
                enoughHopHints := sufficientHints(
3✔
1023
                        nHintsLeft, currentBandwidth, targetBandwidth,
3✔
1024
                )
3✔
1025
                if enoughHopHints {
6✔
1026
                        return hopHints
3✔
1027
                }
3✔
1028

1029
                hopHint, remoteBalance, include := shouldIncludeChannel(
3✔
1030
                        cfg, channel, alreadyIncluded,
3✔
1031
                )
3✔
1032

3✔
1033
                if include {
6✔
1034
                        // Now that we now this channel use usable, add it as a hop
3✔
1035
                        // hint and the indexes we'll use later.
3✔
1036
                        hopHints = append(hopHints, []zpay32.HopHint{hopHint})
3✔
1037
                        currentBandwidth += remoteBalance
3✔
1038
                        nHintsLeft--
3✔
1039
                }
3✔
1040
        }
1041

1042
        // We do not want to leak information about how our remote balance is
1043
        // distributed in our private channels. We shuffle the selected ones
1044
        // here so they do not appear in order in the invoice.
1045
        mathRand.Shuffle(
3✔
1046
                len(hopHints), func(i, j int) {
3✔
1047
                        hopHints[i], hopHints[j] = hopHints[j], hopHints[i]
×
1048
                },
×
1049
        )
1050
        return hopHints
3✔
1051
}
1052

1053
// PopulateHopHints will select up to cfg.MaxHophints from the current open
1054
// channels. The set of hop hints will be returned as a slice of functional
1055
// options that'll append the route hint to the set of all route hints.
1056
//
1057
// TODO(roasbeef): do proper sub-set sum max hints usually << numChans.
1058
func PopulateHopHints(cfg *SelectHopHintsCfg, amtMSat lnwire.MilliSatoshi,
1059
        forcedHints [][]zpay32.HopHint) ([][]zpay32.HopHint, error) {
3✔
1060

3✔
1061
        hopHints := forcedHints
3✔
1062

3✔
1063
        // If we already have enough hints we don't need to add any more.
3✔
1064
        nHintsLeft := cfg.MaxHopHints - len(hopHints)
3✔
1065
        if nHintsLeft <= 0 {
6✔
1066
                return hopHints, nil
3✔
1067
        }
3✔
1068

1069
        alreadyIncluded := make(map[uint64]bool)
3✔
1070
        for _, hopHint := range hopHints {
3✔
1071
                alreadyIncluded[hopHint[0].ChannelID] = true
×
1072
        }
×
1073

1074
        potentialHints, err := getPotentialHints(cfg)
3✔
1075
        if err != nil {
3✔
1076
                return nil, err
×
1077
        }
×
1078

1079
        targetBandwidth := amtMSat * hopHintFactor
3✔
1080
        selectedHints := selectHopHints(
3✔
1081
                cfg, nHintsLeft, targetBandwidth, potentialHints,
3✔
1082
                alreadyIncluded,
3✔
1083
        )
3✔
1084

3✔
1085
        hopHints = append(hopHints, selectedHints...)
3✔
1086
        return hopHints, nil
3✔
1087
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc