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

lightningnetwork / lnd / 13035292482

29 Jan 2025 03:59PM UTC coverage: 49.3% (-9.5%) from 58.777%
13035292482

Pull #9456

github

mohamedawnallah
docs: update release-notes-0.19.0.md

In this commit, we warn users about the removal
of RPCs `SendToRoute`, `SendToRouteSync`, `SendPayment`,
and `SendPaymentSync` in the next release 0.20.
Pull Request #9456: lnrpc+docs: deprecate warning `SendToRoute`, `SendToRouteSync`, `SendPayment`, and `SendPaymentSync` in Release 0.19

100634 of 204126 relevant lines covered (49.3%)

1.54 hits per line

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

1.29
/autopilot/graph.go
1
package autopilot
2

3
import (
4
        "bytes"
5
        "encoding/hex"
6
        "errors"
7
        "net"
8
        "sort"
9
        "sync/atomic"
10
        "time"
11

12
        "github.com/btcsuite/btcd/btcec/v2"
13
        "github.com/btcsuite/btcd/btcec/v2/ecdsa"
14
        "github.com/btcsuite/btcd/btcutil"
15
        graphdb "github.com/lightningnetwork/lnd/graph/db"
16
        "github.com/lightningnetwork/lnd/graph/db/models"
17
        "github.com/lightningnetwork/lnd/kvdb"
18
        "github.com/lightningnetwork/lnd/lnwire"
19
        "github.com/lightningnetwork/lnd/routing/route"
20
)
21

22
var (
23
        testRBytes, _ = hex.DecodeString("8ce2bc69281ce27da07e6683571319d18e949ddfa2965fb6caa1bf0314f882d7")
24
        testSBytes, _ = hex.DecodeString("299105481d63e0f4bc2a88121167221b6700d72a0ead154c03be696a292d24ae")
25
        testRScalar   = new(btcec.ModNScalar)
26
        testSScalar   = new(btcec.ModNScalar)
27
        _             = testRScalar.SetByteSlice(testRBytes)
28
        _             = testSScalar.SetByteSlice(testSBytes)
29
        testSig       = ecdsa.NewSignature(testRScalar, testSScalar)
30

31
        chanIDCounter uint64 // To be used atomically.
32
)
33

34
// databaseChannelGraph wraps a channeldb.ChannelGraph instance with the
35
// necessary API to properly implement the autopilot.ChannelGraph interface.
36
//
37
// TODO(roasbeef): move inmpl to main package?
38
type databaseChannelGraph struct {
39
        db *graphdb.ChannelGraph
40
}
41

42
// A compile time assertion to ensure databaseChannelGraph meets the
43
// autopilot.ChannelGraph interface.
44
var _ ChannelGraph = (*databaseChannelGraph)(nil)
45

46
// ChannelGraphFromDatabase returns an instance of the autopilot.ChannelGraph
47
// backed by a live, open channeldb instance.
48
func ChannelGraphFromDatabase(db *graphdb.ChannelGraph) ChannelGraph {
3✔
49
        return &databaseChannelGraph{
3✔
50
                db: db,
3✔
51
        }
3✔
52
}
3✔
53

54
// type dbNode is a wrapper struct around a database transaction an
55
// channeldb.LightningNode. The wrapper method implement the autopilot.Node
56
// interface.
57
type dbNode struct {
58
        db *graphdb.ChannelGraph
59

60
        tx kvdb.RTx
61

62
        node *models.LightningNode
63
}
64

65
// A compile time assertion to ensure dbNode meets the autopilot.Node
66
// interface.
67
var _ Node = (*dbNode)(nil)
68

69
// PubKey is the identity public key of the node. This will be used to attempt
70
// to target a node for channel opening by the main autopilot agent. The key
71
// will be returned in serialized compressed format.
72
//
73
// NOTE: Part of the autopilot.Node interface.
74
func (d *dbNode) PubKey() [33]byte {
×
75
        return d.node.PubKeyBytes
×
76
}
×
77

78
// Addrs returns a slice of publicly reachable public TCP addresses that the
79
// peer is known to be listening on.
80
//
81
// NOTE: Part of the autopilot.Node interface.
82
func (d *dbNode) Addrs() []net.Addr {
×
83
        return d.node.Addresses
×
84
}
×
85

86
// ForEachChannel is a higher-order function that will be used to iterate
87
// through all edges emanating from/to the target node. For each active
88
// channel, this function should be called with the populated ChannelEdge that
89
// describes the active channel.
90
//
91
// NOTE: Part of the autopilot.Node interface.
92
func (d *dbNode) ForEachChannel(cb func(ChannelEdge) error) error {
×
93
        return d.db.ForEachNodeChannelTx(d.tx, d.node.PubKeyBytes,
×
94
                func(tx kvdb.RTx, ei *models.ChannelEdgeInfo, ep,
×
95
                        _ *models.ChannelEdgePolicy) error {
×
96

×
97
                        // Skip channels for which no outgoing edge policy is
×
98
                        // available.
×
99
                        //
×
100
                        // TODO(joostjager): Ideally the case where channels
×
101
                        // have a nil policy should be supported, as autopilot
×
102
                        // is not looking at the policies. For now, it is not
×
103
                        // easily possible to get a reference to the other end
×
104
                        // LightningNode object without retrieving the policy.
×
105
                        if ep == nil {
×
106
                                return nil
×
107
                        }
×
108

109
                        node, err := d.db.FetchLightningNodeTx(
×
110
                                tx, ep.ToNode,
×
111
                        )
×
112
                        if err != nil {
×
113
                                return err
×
114
                        }
×
115

116
                        edge := ChannelEdge{
×
117
                                ChanID: lnwire.NewShortChanIDFromInt(
×
118
                                        ep.ChannelID,
×
119
                                ),
×
120
                                Capacity: ei.Capacity,
×
121
                                Peer: &dbNode{
×
122
                                        tx:   tx,
×
123
                                        db:   d.db,
×
124
                                        node: node,
×
125
                                },
×
126
                        }
×
127

×
128
                        return cb(edge)
×
129
                })
130
}
131

132
// ForEachNode is a higher-order function that should be called once for each
133
// connected node within the channel graph. If the passed callback returns an
134
// error, then execution should be terminated.
135
//
136
// NOTE: Part of the autopilot.ChannelGraph interface.
137
func (d *databaseChannelGraph) ForEachNode(cb func(Node) error) error {
×
138
        return d.db.ForEachNode(func(tx kvdb.RTx,
×
139
                n *models.LightningNode) error {
×
140

×
141
                // We'll skip over any node that doesn't have any advertised
×
142
                // addresses. As we won't be able to reach them to actually
×
143
                // open any channels.
×
144
                if len(n.Addresses) == 0 {
×
145
                        return nil
×
146
                }
×
147

148
                node := &dbNode{
×
149
                        db:   d.db,
×
150
                        tx:   tx,
×
151
                        node: n,
×
152
                }
×
153
                return cb(node)
×
154
        })
155
}
156

157
// addRandChannel creates a new channel two target nodes. This function is
158
// meant to aide in the generation of random graphs for use within test cases
159
// the exercise the autopilot package.
160
func (d *databaseChannelGraph) addRandChannel(node1, node2 *btcec.PublicKey,
161
        capacity btcutil.Amount) (*ChannelEdge, *ChannelEdge, error) {
×
162

×
163
        fetchNode := func(pub *btcec.PublicKey) (*models.LightningNode, error) {
×
164
                if pub != nil {
×
165
                        vertex, err := route.NewVertexFromBytes(
×
166
                                pub.SerializeCompressed(),
×
167
                        )
×
168
                        if err != nil {
×
169
                                return nil, err
×
170
                        }
×
171

172
                        dbNode, err := d.db.FetchLightningNode(vertex)
×
173
                        switch {
×
174
                        case errors.Is(err, graphdb.ErrGraphNodeNotFound):
×
175
                                fallthrough
×
176
                        case errors.Is(err, graphdb.ErrGraphNotFound):
×
177
                                graphNode := &models.LightningNode{
×
178
                                        HaveNodeAnnouncement: true,
×
179
                                        Addresses: []net.Addr{
×
180
                                                &net.TCPAddr{
×
181
                                                        IP: bytes.Repeat([]byte("a"), 16),
×
182
                                                },
×
183
                                        },
×
184
                                        Features: lnwire.NewFeatureVector(
×
185
                                                nil, lnwire.Features,
×
186
                                        ),
×
187
                                        AuthSigBytes: testSig.Serialize(),
×
188
                                }
×
189
                                graphNode.AddPubKey(pub)
×
190
                                if err := d.db.AddLightningNode(graphNode); err != nil {
×
191
                                        return nil, err
×
192
                                }
×
193
                        case err != nil:
×
194
                                return nil, err
×
195
                        }
196

197
                        return dbNode, nil
×
198
                }
199

200
                nodeKey, err := randKey()
×
201
                if err != nil {
×
202
                        return nil, err
×
203
                }
×
204
                dbNode := &models.LightningNode{
×
205
                        HaveNodeAnnouncement: true,
×
206
                        Addresses: []net.Addr{
×
207
                                &net.TCPAddr{
×
208
                                        IP: bytes.Repeat([]byte("a"), 16),
×
209
                                },
×
210
                        },
×
211
                        Features: lnwire.NewFeatureVector(
×
212
                                nil, lnwire.Features,
×
213
                        ),
×
214
                        AuthSigBytes: testSig.Serialize(),
×
215
                }
×
216
                dbNode.AddPubKey(nodeKey)
×
217
                if err := d.db.AddLightningNode(dbNode); err != nil {
×
218
                        return nil, err
×
219
                }
×
220

221
                return dbNode, nil
×
222
        }
223

224
        vertex1, err := fetchNode(node1)
×
225
        if err != nil {
×
226
                return nil, nil, err
×
227
        }
×
228

229
        vertex2, err := fetchNode(node2)
×
230
        if err != nil {
×
231
                return nil, nil, err
×
232
        }
×
233

234
        var lnNode1, lnNode2 *btcec.PublicKey
×
235
        if bytes.Compare(vertex1.PubKeyBytes[:], vertex2.PubKeyBytes[:]) == -1 {
×
236
                lnNode1, _ = vertex1.PubKey()
×
237
                lnNode2, _ = vertex2.PubKey()
×
238
        } else {
×
239
                lnNode1, _ = vertex2.PubKey()
×
240
                lnNode2, _ = vertex1.PubKey()
×
241
        }
×
242

243
        chanID := randChanID()
×
244
        edge := &models.ChannelEdgeInfo{
×
245
                ChannelID: chanID.ToUint64(),
×
246
                Capacity:  capacity,
×
247
        }
×
248
        edge.AddNodeKeys(lnNode1, lnNode2, lnNode1, lnNode2)
×
249
        if err := d.db.AddChannelEdge(edge); err != nil {
×
250
                return nil, nil, err
×
251
        }
×
252
        edgePolicy := &models.ChannelEdgePolicy{
×
253
                SigBytes:                  testSig.Serialize(),
×
254
                ChannelID:                 chanID.ToUint64(),
×
255
                LastUpdate:                time.Now(),
×
256
                TimeLockDelta:             10,
×
257
                MinHTLC:                   1,
×
258
                MaxHTLC:                   lnwire.NewMSatFromSatoshis(capacity),
×
259
                FeeBaseMSat:               10,
×
260
                FeeProportionalMillionths: 10000,
×
261
                MessageFlags:              1,
×
262
                ChannelFlags:              0,
×
263
        }
×
264

×
265
        if err := d.db.UpdateEdgePolicy(edgePolicy); err != nil {
×
266
                return nil, nil, err
×
267
        }
×
268
        edgePolicy = &models.ChannelEdgePolicy{
×
269
                SigBytes:                  testSig.Serialize(),
×
270
                ChannelID:                 chanID.ToUint64(),
×
271
                LastUpdate:                time.Now(),
×
272
                TimeLockDelta:             10,
×
273
                MinHTLC:                   1,
×
274
                MaxHTLC:                   lnwire.NewMSatFromSatoshis(capacity),
×
275
                FeeBaseMSat:               10,
×
276
                FeeProportionalMillionths: 10000,
×
277
                MessageFlags:              1,
×
278
                ChannelFlags:              1,
×
279
        }
×
280
        if err := d.db.UpdateEdgePolicy(edgePolicy); err != nil {
×
281
                return nil, nil, err
×
282
        }
×
283

284
        return &ChannelEdge{
×
285
                        ChanID:   chanID,
×
286
                        Capacity: capacity,
×
287
                        Peer: &dbNode{
×
288
                                db:   d.db,
×
289
                                node: vertex1,
×
290
                        },
×
291
                },
×
292
                &ChannelEdge{
×
293
                        ChanID:   chanID,
×
294
                        Capacity: capacity,
×
295
                        Peer: &dbNode{
×
296
                                db:   d.db,
×
297
                                node: vertex2,
×
298
                        },
×
299
                },
×
300
                nil
×
301
}
302

303
func (d *databaseChannelGraph) addRandNode() (*btcec.PublicKey, error) {
×
304
        nodeKey, err := randKey()
×
305
        if err != nil {
×
306
                return nil, err
×
307
        }
×
308
        dbNode := &models.LightningNode{
×
309
                HaveNodeAnnouncement: true,
×
310
                Addresses: []net.Addr{
×
311
                        &net.TCPAddr{
×
312
                                IP: bytes.Repeat([]byte("a"), 16),
×
313
                        },
×
314
                },
×
315
                Features: lnwire.NewFeatureVector(
×
316
                        nil, lnwire.Features,
×
317
                ),
×
318
                AuthSigBytes: testSig.Serialize(),
×
319
        }
×
320
        dbNode.AddPubKey(nodeKey)
×
321
        if err := d.db.AddLightningNode(dbNode); err != nil {
×
322
                return nil, err
×
323
        }
×
324

325
        return nodeKey, nil
×
326

327
}
328

329
// memChannelGraph is an implementation of the autopilot.ChannelGraph backed by
330
// an in-memory graph.
331
type memChannelGraph struct {
332
        graph map[NodeID]*memNode
333
}
334

335
// A compile time assertion to ensure memChannelGraph meets the
336
// autopilot.ChannelGraph interface.
337
var _ ChannelGraph = (*memChannelGraph)(nil)
338

339
// newMemChannelGraph creates a new blank in-memory channel graph
340
// implementation.
341
func newMemChannelGraph() *memChannelGraph {
×
342
        return &memChannelGraph{
×
343
                graph: make(map[NodeID]*memNode),
×
344
        }
×
345
}
×
346

347
// ForEachNode is a higher-order function that should be called once for each
348
// connected node within the channel graph. If the passed callback returns an
349
// error, then execution should be terminated.
350
//
351
// NOTE: Part of the autopilot.ChannelGraph interface.
352
func (m memChannelGraph) ForEachNode(cb func(Node) error) error {
×
353
        for _, node := range m.graph {
×
354
                if err := cb(node); err != nil {
×
355
                        return err
×
356
                }
×
357
        }
358

359
        return nil
×
360
}
361

362
// randChanID generates a new random channel ID.
363
func randChanID() lnwire.ShortChannelID {
×
364
        id := atomic.AddUint64(&chanIDCounter, 1)
×
365
        return lnwire.NewShortChanIDFromInt(id)
×
366
}
×
367

368
// randKey returns a random public key.
369
func randKey() (*btcec.PublicKey, error) {
×
370
        priv, err := btcec.NewPrivateKey()
×
371
        if err != nil {
×
372
                return nil, err
×
373
        }
×
374

375
        return priv.PubKey(), nil
×
376
}
377

378
// addRandChannel creates a new channel two target nodes. This function is
379
// meant to aide in the generation of random graphs for use within test cases
380
// the exercise the autopilot package.
381
func (m *memChannelGraph) addRandChannel(node1, node2 *btcec.PublicKey,
382
        capacity btcutil.Amount) (*ChannelEdge, *ChannelEdge, error) {
×
383

×
384
        var (
×
385
                vertex1, vertex2 *memNode
×
386
                ok               bool
×
387
        )
×
388

×
389
        if node1 != nil {
×
390
                vertex1, ok = m.graph[NewNodeID(node1)]
×
391
                if !ok {
×
392
                        vertex1 = &memNode{
×
393
                                pub: node1,
×
394
                                addrs: []net.Addr{
×
395
                                        &net.TCPAddr{
×
396
                                                IP: bytes.Repeat([]byte("a"), 16),
×
397
                                        },
×
398
                                },
×
399
                        }
×
400
                }
×
401
        } else {
×
402
                newPub, err := randKey()
×
403
                if err != nil {
×
404
                        return nil, nil, err
×
405
                }
×
406
                vertex1 = &memNode{
×
407
                        pub: newPub,
×
408
                        addrs: []net.Addr{
×
409
                                &net.TCPAddr{
×
410
                                        IP: bytes.Repeat([]byte("a"), 16),
×
411
                                },
×
412
                        },
×
413
                }
×
414
        }
415

416
        if node2 != nil {
×
417
                vertex2, ok = m.graph[NewNodeID(node2)]
×
418
                if !ok {
×
419
                        vertex2 = &memNode{
×
420
                                pub: node2,
×
421
                                addrs: []net.Addr{
×
422
                                        &net.TCPAddr{
×
423
                                                IP: bytes.Repeat([]byte("a"), 16),
×
424
                                        },
×
425
                                },
×
426
                        }
×
427
                }
×
428
        } else {
×
429
                newPub, err := randKey()
×
430
                if err != nil {
×
431
                        return nil, nil, err
×
432
                }
×
433
                vertex2 = &memNode{
×
434
                        pub: newPub,
×
435
                        addrs: []net.Addr{
×
436
                                &net.TCPAddr{
×
437
                                        IP: bytes.Repeat([]byte("a"), 16),
×
438
                                },
×
439
                        },
×
440
                }
×
441
        }
442

443
        edge1 := ChannelEdge{
×
444
                ChanID:   randChanID(),
×
445
                Capacity: capacity,
×
446
                Peer:     vertex2,
×
447
        }
×
448
        vertex1.chans = append(vertex1.chans, edge1)
×
449

×
450
        edge2 := ChannelEdge{
×
451
                ChanID:   randChanID(),
×
452
                Capacity: capacity,
×
453
                Peer:     vertex1,
×
454
        }
×
455
        vertex2.chans = append(vertex2.chans, edge2)
×
456

×
457
        m.graph[NewNodeID(vertex1.pub)] = vertex1
×
458
        m.graph[NewNodeID(vertex2.pub)] = vertex2
×
459

×
460
        return &edge1, &edge2, nil
×
461
}
462

463
func (m *memChannelGraph) addRandNode() (*btcec.PublicKey, error) {
×
464
        newPub, err := randKey()
×
465
        if err != nil {
×
466
                return nil, err
×
467
        }
×
468
        vertex := &memNode{
×
469
                pub: newPub,
×
470
                addrs: []net.Addr{
×
471
                        &net.TCPAddr{
×
472
                                IP: bytes.Repeat([]byte("a"), 16),
×
473
                        },
×
474
                },
×
475
        }
×
476
        m.graph[NewNodeID(newPub)] = vertex
×
477

×
478
        return newPub, nil
×
479
}
480

481
// databaseChannelGraphCached wraps a channeldb.ChannelGraph instance with the
482
// necessary API to properly implement the autopilot.ChannelGraph interface.
483
type databaseChannelGraphCached struct {
484
        db *graphdb.ChannelGraph
485
}
486

487
// A compile time assertion to ensure databaseChannelGraphCached meets the
488
// autopilot.ChannelGraph interface.
489
var _ ChannelGraph = (*databaseChannelGraphCached)(nil)
490

491
// ChannelGraphFromCachedDatabase returns an instance of the
492
// autopilot.ChannelGraph backed by a live, open channeldb instance.
493
func ChannelGraphFromCachedDatabase(db *graphdb.ChannelGraph) ChannelGraph {
×
494
        return &databaseChannelGraphCached{
×
495
                db: db,
×
496
        }
×
497
}
×
498

499
// dbNodeCached is a wrapper struct around a database transaction for a
500
// channeldb.LightningNode. The wrapper methods implement the autopilot.Node
501
// interface.
502
type dbNodeCached struct {
503
        node     route.Vertex
504
        channels map[uint64]*graphdb.DirectedChannel
505
}
506

507
// A compile time assertion to ensure dbNodeCached meets the autopilot.Node
508
// interface.
509
var _ Node = (*dbNodeCached)(nil)
510

511
// PubKey is the identity public key of the node.
512
//
513
// NOTE: Part of the autopilot.Node interface.
514
func (nc dbNodeCached) PubKey() [33]byte {
×
515
        return nc.node
×
516
}
×
517

518
// Addrs returns a slice of publicly reachable public TCP addresses that the
519
// peer is known to be listening on.
520
//
521
// NOTE: Part of the autopilot.Node interface.
522
func (nc dbNodeCached) Addrs() []net.Addr {
×
523
        // TODO: Add addresses to be usable by autopilot.
×
524
        return []net.Addr{}
×
525
}
×
526

527
// ForEachChannel is a higher-order function that will be used to iterate
528
// through all edges emanating from/to the target node. For each active
529
// channel, this function should be called with the populated ChannelEdge that
530
// describes the active channel.
531
//
532
// NOTE: Part of the autopilot.Node interface.
533
func (nc dbNodeCached) ForEachChannel(cb func(ChannelEdge) error) error {
×
534
        for cid, channel := range nc.channels {
×
535
                edge := ChannelEdge{
×
536
                        ChanID:   lnwire.NewShortChanIDFromInt(cid),
×
537
                        Capacity: channel.Capacity,
×
538
                        Peer: dbNodeCached{
×
539
                                node: channel.OtherNode,
×
540
                        },
×
541
                }
×
542

×
543
                if err := cb(edge); err != nil {
×
544
                        return err
×
545
                }
×
546
        }
547

548
        return nil
×
549
}
550

551
// ForEachNode is a higher-order function that should be called once for each
552
// connected node within the channel graph. If the passed callback returns an
553
// error, then execution should be terminated.
554
//
555
// NOTE: Part of the autopilot.ChannelGraph interface.
556
func (dc *databaseChannelGraphCached) ForEachNode(cb func(Node) error) error {
×
557
        return dc.db.ForEachNodeCached(func(n route.Vertex,
×
558
                channels map[uint64]*graphdb.DirectedChannel) error {
×
559

×
560
                if len(channels) > 0 {
×
561
                        node := dbNodeCached{
×
562
                                node:     n,
×
563
                                channels: channels,
×
564
                        }
×
565
                        return cb(node)
×
566
                }
×
567
                return nil
×
568
        })
569
}
570

571
// memNode is a purely in-memory implementation of the autopilot.Node
572
// interface.
573
type memNode struct {
574
        pub *btcec.PublicKey
575

576
        chans []ChannelEdge
577

578
        addrs []net.Addr
579
}
580

581
// A compile time assertion to ensure memNode meets the autopilot.Node
582
// interface.
583
var _ Node = (*memNode)(nil)
584

585
// PubKey is the identity public key of the node. This will be used to attempt
586
// to target a node for channel opening by the main autopilot agent.
587
//
588
// NOTE: Part of the autopilot.Node interface.
589
func (m memNode) PubKey() [33]byte {
×
590
        var n [33]byte
×
591
        copy(n[:], m.pub.SerializeCompressed())
×
592

×
593
        return n
×
594
}
×
595

596
// Addrs returns a slice of publicly reachable public TCP addresses that the
597
// peer is known to be listening on.
598
//
599
// NOTE: Part of the autopilot.Node interface.
600
func (m memNode) Addrs() []net.Addr {
×
601
        return m.addrs
×
602
}
×
603

604
// ForEachChannel is a higher-order function that will be used to iterate
605
// through all edges emanating from/to the target node. For each active
606
// channel, this function should be called with the populated ChannelEdge that
607
// describes the active channel.
608
//
609
// NOTE: Part of the autopilot.Node interface.
610
func (m memNode) ForEachChannel(cb func(ChannelEdge) error) error {
×
611
        for _, channel := range m.chans {
×
612
                if err := cb(channel); err != nil {
×
613
                        return err
×
614
                }
×
615
        }
616

617
        return nil
×
618
}
619

620
// Median returns the median value in the slice of Amounts.
621
func Median(vals []btcutil.Amount) btcutil.Amount {
×
622
        sort.Slice(vals, func(i, j int) bool {
×
623
                return vals[i] < vals[j]
×
624
        })
×
625

626
        num := len(vals)
×
627
        switch {
×
628
        case num == 0:
×
629
                return 0
×
630

631
        case num%2 == 0:
×
632
                return (vals[num/2-1] + vals[num/2]) / 2
×
633

634
        default:
×
635
                return vals[num/2]
×
636
        }
637
}
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