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

lightningnetwork / lnd / 13157733617

05 Feb 2025 12:49PM UTC coverage: 57.712% (-1.1%) from 58.82%
13157733617

Pull #9447

github

yyforyongyu
sweep: rename methods for clarity

We now rename "third party" to "unknown" as the inputs can be spent via
an older sweeping tx, a third party (anchor), or a remote party (pin).
In fee bumper we don't have the info to distinguish the above cases, and
leave them to be further handled by the sweeper as it has more context.
Pull Request #9447: sweep: start tracking input spending status in the fee bumper

83 of 87 new or added lines in 2 files covered. (95.4%)

19472 existing lines in 252 files now uncovered.

103634 of 179570 relevant lines covered (57.71%)

24840.31 hits per line

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

88.27
/invoices/update.go
1
package invoices
2

3
import (
4
        "bytes"
5
        "encoding/hex"
6
        "errors"
7

8
        "github.com/btcsuite/btcd/chaincfg/chainhash"
9
        "github.com/lightningnetwork/lnd/amp"
10
        "github.com/lightningnetwork/lnd/lntypes"
11
        "github.com/lightningnetwork/lnd/lnwire"
12
        "github.com/lightningnetwork/lnd/record"
13
)
14

15
// invoiceUpdateCtx is an object that describes the context for the invoice
16
// update to be carried out.
17
type invoiceUpdateCtx struct {
18
        hash                 lntypes.Hash
19
        circuitKey           CircuitKey
20
        amtPaid              lnwire.MilliSatoshi
21
        expiry               uint32
22
        currentHeight        int32
23
        finalCltvRejectDelta int32
24

25
        // wireCustomRecords are the custom records that were included with the
26
        // HTLC wire message.
27
        wireCustomRecords lnwire.CustomRecords
28

29
        // customRecords is a map of custom records that were included with the
30
        // HTLC onion payload.
31
        customRecords record.CustomSet
32

33
        mpp          *record.MPP
34
        amp          *record.AMP
35
        metadata     []byte
36
        pathID       *chainhash.Hash
37
        totalAmtMsat lnwire.MilliSatoshi
38
}
39

40
// invoiceRef returns an identifier that can be used to lookup or update the
41
// invoice this HTLC is targeting.
42
func (i *invoiceUpdateCtx) invoiceRef() InvoiceRef {
2,227✔
43
        switch {
2,227✔
UNCOV
44
        case i.pathID != nil:
×
UNCOV
45
                return InvoiceRefByHashAndAddr(i.hash, *i.pathID)
×
46

47
        case i.amp != nil && i.mpp != nil:
87✔
48
                payAddr := i.mpp.PaymentAddr()
87✔
49
                return InvoiceRefByAddr(payAddr)
87✔
50

51
        case i.mpp != nil:
1,593✔
52
                payAddr := i.mpp.PaymentAddr()
1,593✔
53
                return InvoiceRefByHashAndAddr(i.hash, payAddr)
1,593✔
54

55
        default:
547✔
56
                return InvoiceRefByHash(i.hash)
547✔
57
        }
58
}
59

60
// setID returns an identifier that identifies other possible HTLCs that this
61
// particular one is related to. If nil is returned this means the HTLC is an
62
// MPP or legacy payment, otherwise the HTLC belongs AMP payment.
63
func (i invoiceUpdateCtx) setID() *[32]byte {
2,609✔
64
        if i.amp != nil {
2,741✔
65
                setID := i.amp.SetID()
132✔
66
                return &setID
132✔
67
        }
132✔
68
        return nil
2,477✔
69
}
70

71
// log logs a message specific to this update context.
72
func (i *invoiceUpdateCtx) log(s string) {
945✔
73
        // Don't use %x in the log statement below, because it doesn't
945✔
74
        // distinguish between nil and empty metadata.
945✔
75
        metadata := "<nil>"
945✔
76
        if i.metadata != nil {
945✔
77
                metadata = hex.EncodeToString(i.metadata)
×
78
        }
×
79

80
        log.Debugf("Invoice%v: %v, amt=%v, expiry=%v, circuit=%v, mpp=%v, "+
945✔
81
                "amp=%v, metadata=%v", i.invoiceRef(), s, i.amtPaid, i.expiry,
945✔
82
                i.circuitKey, i.mpp, i.amp, metadata)
945✔
83
}
84

85
// failRes is a helper function which creates a failure resolution with
86
// the information contained in the invoiceUpdateCtx and the fail resolution
87
// result provided.
88
func (i invoiceUpdateCtx) failRes(outcome FailResolutionResult) *HtlcFailResolution {
26✔
89
        return NewFailResolution(i.circuitKey, i.currentHeight, outcome)
26✔
90
}
26✔
91

92
// settleRes is a helper function which creates a settle resolution with
93
// the information contained in the invoiceUpdateCtx and the preimage and
94
// the settle resolution result provided.
95
func (i invoiceUpdateCtx) settleRes(preimage lntypes.Preimage,
96
        outcome SettleResolutionResult) *HtlcSettleResolution {
473✔
97

473✔
98
        return NewSettleResolution(
473✔
99
                preimage, i.circuitKey, i.currentHeight, outcome,
473✔
100
        )
473✔
101
}
473✔
102

103
// acceptRes is a helper function which creates an accept resolution with
104
// the information contained in the invoiceUpdateCtx and the accept resolution
105
// result provided.
106
func (i invoiceUpdateCtx) acceptRes(
107
        outcome acceptResolutionResult) *htlcAcceptResolution {
437✔
108

437✔
109
        return newAcceptResolution(i.circuitKey, outcome)
437✔
110
}
437✔
111

112
// resolveReplayedHtlc returns the HTLC resolution for a replayed HTLC. The
113
// returned boolean indicates whether the HTLC was replayed or not.
114
func resolveReplayedHtlc(ctx *invoiceUpdateCtx, inv *Invoice) (bool,
115
        HtlcResolution, error) {
939✔
116

939✔
117
        // Don't update the invoice when this is a replayed htlc.
939✔
118
        htlc, replayedHTLC := inv.Htlcs[ctx.circuitKey]
939✔
119
        if !replayedHTLC {
1,862✔
120
                return false, nil, nil
923✔
121
        }
923✔
122

123
        switch htlc.State {
16✔
124
        case HtlcStateCanceled:
3✔
125
                return true, ctx.failRes(ResultReplayToCanceled), nil
3✔
126

127
        case HtlcStateAccepted:
7✔
128
                return true, ctx.acceptRes(resultReplayToAccepted), nil
7✔
129

130
        case HtlcStateSettled:
6✔
131
                pre := inv.Terms.PaymentPreimage
6✔
132

6✔
133
                // Terms.PaymentPreimage will be nil for AMP invoices.
6✔
134
                // Set it to the HTLCs AMP Preimage instead.
6✔
135
                if pre == nil {
6✔
136
                        pre = htlc.AMP.Preimage
×
137
                }
×
138

139
                return true, ctx.settleRes(
6✔
140
                        *pre,
6✔
141
                        ResultReplayToSettled,
6✔
142
                ), nil
6✔
143

144
        default:
×
145
                return true, nil, errors.New("unknown htlc state")
×
146
        }
147
}
148

149
// updateInvoice is a callback for DB.UpdateInvoice that contains the invoice
150
// settlement logic. It returns a HTLC resolution that indicates what the
151
// outcome of the update was.
152
//
153
// NOTE: Make sure replayed HTLCs are always considered before calling this
154
// function.
155
func updateInvoice(ctx *invoiceUpdateCtx, inv *Invoice) (
156
        *InvoiceUpdateDesc, HtlcResolution, error) {
920✔
157

920✔
158
        // If no MPP payload was provided, then we expect this to be a keysend,
920✔
159
        // or a payment to an invoice created before we started to require the
920✔
160
        // MPP payload.
920✔
161
        if ctx.mpp == nil && ctx.pathID == nil {
1,168✔
162
                return updateLegacy(ctx, inv)
248✔
163
        }
248✔
164

165
        return updateMpp(ctx, inv)
672✔
166
}
167

168
// updateMpp is a callback for DB.UpdateInvoice that contains the invoice
169
// settlement logic for mpp payments.
170
func updateMpp(ctx *invoiceUpdateCtx, inv *Invoice) (*InvoiceUpdateDesc,
171
        HtlcResolution, error) {
672✔
172

672✔
173
        // Reject HTLCs to AMP invoices if they are missing an AMP payload, and
672✔
174
        // HTLCs to MPP invoices if they have an AMP payload.
672✔
175
        switch {
672✔
176
        case inv.Terms.Features.RequiresFeature(lnwire.AMPRequired) &&
177
                ctx.amp == nil:
×
178

×
179
                return nil, ctx.failRes(ResultHtlcInvoiceTypeMismatch), nil
×
180

181
        case !inv.Terms.Features.RequiresFeature(lnwire.AMPRequired) &&
182
                ctx.amp != nil:
×
183

×
184
                return nil, ctx.failRes(ResultHtlcInvoiceTypeMismatch), nil
×
185
        }
186

187
        setID := ctx.setID()
672✔
188

672✔
189
        var (
672✔
190
                totalAmt    = ctx.totalAmtMsat
672✔
191
                paymentAddr []byte
672✔
192
        )
672✔
193
        // If an MPP record is present, then the payment address and total
672✔
194
        // payment amount is extracted from it. Otherwise, the pathID is used
672✔
195
        // to extract the payment address.
672✔
196
        if ctx.mpp != nil {
1,344✔
197
                totalAmt = ctx.mpp.TotalMsat()
672✔
198
                payAddr := ctx.mpp.PaymentAddr()
672✔
199
                paymentAddr = payAddr[:]
672✔
200
        } else {
672✔
UNCOV
201
                paymentAddr = ctx.pathID[:]
×
UNCOV
202
        }
×
203

204
        // For storage, we don't really care where the custom records came from.
205
        // So we merge them together and store them in the same field.
206
        customRecords := lnwire.CustomRecords(
672✔
207
                ctx.customRecords,
672✔
208
        ).MergedCopy(ctx.wireCustomRecords)
672✔
209

672✔
210
        // Start building the accept descriptor.
672✔
211
        acceptDesc := &HtlcAcceptDesc{
672✔
212
                Amt:           ctx.amtPaid,
672✔
213
                Expiry:        ctx.expiry,
672✔
214
                AcceptHeight:  ctx.currentHeight,
672✔
215
                MppTotalAmt:   totalAmt,
672✔
216
                CustomRecords: record.CustomSet(customRecords),
672✔
217
        }
672✔
218

672✔
219
        if ctx.amp != nil {
711✔
220
                acceptDesc.AMP = &InvoiceHtlcAMPData{
39✔
221
                        Record:   *ctx.amp,
39✔
222
                        Hash:     ctx.hash,
39✔
223
                        Preimage: nil,
39✔
224
                }
39✔
225
        }
39✔
226

227
        // Only accept payments to open invoices. This behaviour differs from
228
        // non-mpp payments that are accepted even after the invoice is settled.
229
        // Because non-mpp payments don't have a payment address, this is needed
230
        // to thwart probing.
231
        if inv.State != ContractOpen {
672✔
232
                return nil, ctx.failRes(ResultInvoiceNotOpen), nil
×
233
        }
×
234

235
        // Check the payment address that authorizes the payment.
236
        if !bytes.Equal(paymentAddr, inv.Terms.PaymentAddr[:]) {
672✔
237
                return nil, ctx.failRes(ResultAddressMismatch), nil
×
238
        }
×
239

240
        // Don't accept zero-valued sets.
241
        if totalAmt == 0 {
672✔
242
                return nil, ctx.failRes(ResultHtlcSetTotalTooLow), nil
×
243
        }
×
244

245
        // Check that the total amt of the htlc set is high enough. In case this
246
        // is a zero-valued invoice, it will always be enough.
247
        if totalAmt < inv.Terms.Value {
672✔
248
                return nil, ctx.failRes(ResultHtlcSetTotalTooLow), nil
×
249
        }
×
250

251
        htlcSet := inv.HTLCSet(setID, HtlcStateAccepted)
672✔
252

672✔
253
        // Check whether total amt matches other HTLCs in the set.
672✔
254
        var newSetTotal lnwire.MilliSatoshi
672✔
255
        for _, htlc := range htlcSet {
1,005✔
256
                if totalAmt != htlc.MppTotalAmt {
333✔
257
                        return nil, ctx.failRes(ResultHtlcSetTotalMismatch), nil
×
258
                }
×
259

260
                newSetTotal += htlc.Amt
333✔
261
        }
262

263
        // Add amount of new htlc.
264
        newSetTotal += ctx.amtPaid
672✔
265

672✔
266
        // The invoice is still open. Check the expiry.
672✔
267
        if ctx.expiry < uint32(ctx.currentHeight+ctx.finalCltvRejectDelta) {
672✔
268
                return nil, ctx.failRes(ResultExpiryTooSoon), nil
×
269
        }
×
270

271
        if ctx.expiry < uint32(ctx.currentHeight+inv.Terms.FinalCltvDelta) {
672✔
272
                return nil, ctx.failRes(ResultExpiryTooSoon), nil
×
273
        }
×
274

275
        if setID != nil && *setID == BlankPayAddr {
672✔
276
                return nil, ctx.failRes(ResultAmpError), nil
×
277
        }
×
278

279
        // Record HTLC in the invoice database.
280
        newHtlcs := map[CircuitKey]*HtlcAcceptDesc{
672✔
281
                ctx.circuitKey: acceptDesc,
672✔
282
        }
672✔
283

672✔
284
        update := InvoiceUpdateDesc{
672✔
285
                UpdateType: AddHTLCsUpdate,
672✔
286
                AddHtlcs:   newHtlcs,
672✔
287
        }
672✔
288

672✔
289
        // If the invoice cannot be settled yet, only record the htlc.
672✔
290
        setComplete := newSetTotal >= totalAmt
672✔
291
        if !setComplete {
1,020✔
292
                return &update, ctx.acceptRes(resultPartialAccepted), nil
348✔
293
        }
348✔
294

295
        // Check to see if we can settle or this is a hold invoice, and
296
        // we need to wait for the preimage.
297
        if inv.HodlInvoice {
333✔
298
                update.State = &InvoiceStateUpdateDesc{
9✔
299
                        NewState: ContractAccepted,
9✔
300
                }
9✔
301
                return &update, ctx.acceptRes(resultAccepted), nil
9✔
302
        }
9✔
303

304
        var (
315✔
305
                htlcPreimages map[CircuitKey]lntypes.Preimage
315✔
306
                htlcPreimage  lntypes.Preimage
315✔
307
        )
315✔
308
        if ctx.amp != nil {
327✔
309
                var failRes *HtlcFailResolution
12✔
310
                htlcPreimages, failRes = reconstructAMPPreimages(ctx, htlcSet)
12✔
311
                if failRes != nil {
18✔
312
                        update.UpdateType = CancelInvoiceUpdate
6✔
313
                        update.State = &InvoiceStateUpdateDesc{
6✔
314
                                NewState: ContractCanceled,
6✔
315
                                SetID:    setID,
6✔
316
                        }
6✔
317
                        return &update, failRes, nil
6✔
318
                }
6✔
319

320
                // The preimage for _this_ HTLC will be the one with context's
321
                // circuit key.
322
                htlcPreimage = htlcPreimages[ctx.circuitKey]
6✔
323
        } else {
303✔
324
                htlcPreimage = *inv.Terms.PaymentPreimage
303✔
325
        }
303✔
326

327
        update.State = &InvoiceStateUpdateDesc{
309✔
328
                NewState:      ContractSettled,
309✔
329
                Preimage:      inv.Terms.PaymentPreimage,
309✔
330
                HTLCPreimages: htlcPreimages,
309✔
331
                SetID:         setID,
309✔
332
        }
309✔
333

309✔
334
        return &update, ctx.settleRes(htlcPreimage, ResultSettled), nil
309✔
335
}
336

337
// HTLCSet is a map of CircuitKey to InvoiceHTLC.
338
type HTLCSet = map[CircuitKey]*InvoiceHTLC
339

340
// HTLCPreimages is a map of CircuitKey to preimage.
341
type HTLCPreimages = map[CircuitKey]lntypes.Preimage
342

343
// reconstructAMPPreimages reconstructs the root seed for an AMP HTLC set and
344
// verifies that all derived child hashes match the payment hashes of the HTLCs
345
// in the set. This method is meant to be called after receiving the full amount
346
// committed to via mpp_total_msat. This method will return a fail resolution if
347
// any of the child hashes fail to match their corresponding HTLCs.
348
func reconstructAMPPreimages(ctx *invoiceUpdateCtx,
349
        htlcSet HTLCSet) (HTLCPreimages, *HtlcFailResolution) {
12✔
350

12✔
351
        // Create a slice containing all the child descriptors to be used for
12✔
352
        // reconstruction. This should include all HTLCs currently in the HTLC
12✔
353
        // set, plus the incoming HTLC.
12✔
354
        childDescs := make([]amp.ChildDesc, 0, 1+len(htlcSet))
12✔
355

12✔
356
        // Add the new HTLC's child descriptor at index 0.
12✔
357
        childDescs = append(childDescs, amp.ChildDesc{
12✔
358
                Share: ctx.amp.RootShare(),
12✔
359
                Index: ctx.amp.ChildIndex(),
12✔
360
        })
12✔
361

12✔
362
        // Next, construct an index mapping the position in childDescs to a
12✔
363
        // circuit key for all preexisting HTLCs.
12✔
364
        indexToCircuitKey := make(map[int]CircuitKey)
12✔
365

12✔
366
        // Add the child descriptor for each HTLC in the HTLC set, recording
12✔
367
        // it's position within the slice.
12✔
368
        var htlcSetIndex int
12✔
369
        for circuitKey, htlc := range htlcSet {
24✔
370
                childDescs = append(childDescs, amp.ChildDesc{
12✔
371
                        Share: htlc.AMP.Record.RootShare(),
12✔
372
                        Index: htlc.AMP.Record.ChildIndex(),
12✔
373
                })
12✔
374
                indexToCircuitKey[htlcSetIndex] = circuitKey
12✔
375
                htlcSetIndex++
12✔
376
        }
12✔
377

378
        // Using the child descriptors, reconstruct the root seed and derive the
379
        // child hash/preimage pairs for each of the HTLCs.
380
        children := amp.ReconstructChildren(childDescs...)
12✔
381

12✔
382
        // Validate that the derived child preimages match the hash of each
12✔
383
        // HTLC's respective hash.
12✔
384
        if ctx.hash != children[0].Hash {
18✔
385
                return nil, ctx.failRes(ResultAmpReconstruction)
6✔
386
        }
6✔
387
        for idx, child := range children[1:] {
12✔
388
                circuitKey := indexToCircuitKey[idx]
6✔
389
                htlc := htlcSet[circuitKey]
6✔
390
                if htlc.AMP.Hash != child.Hash {
6✔
391
                        return nil, ctx.failRes(ResultAmpReconstruction)
×
392
                }
×
393
        }
394

395
        // Finally, construct the map of learned preimages indexed by circuit
396
        // key, so that they can be persisted along with each HTLC when updating
397
        // the invoice.
398
        htlcPreimages := make(map[CircuitKey]lntypes.Preimage)
6✔
399
        htlcPreimages[ctx.circuitKey] = children[0].Preimage
6✔
400
        for idx, child := range children[1:] {
12✔
401
                circuitKey := indexToCircuitKey[idx]
6✔
402
                htlcPreimages[circuitKey] = child.Preimage
6✔
403
        }
6✔
404

405
        return htlcPreimages, nil
6✔
406
}
407

408
// updateLegacy is a callback for DB.UpdateInvoice that contains the invoice
409
// settlement logic for legacy payments.
410
//
411
// NOTE: This function is only kept in place in order to be able to handle key
412
// send payments and any invoices we created in the past that are valid and
413
// still had the optional mpp bit set.
414
func updateLegacy(ctx *invoiceUpdateCtx,
415
        inv *Invoice) (*InvoiceUpdateDesc, HtlcResolution, error) {
248✔
416

248✔
417
        // If the invoice is already canceled, there is no further
248✔
418
        // checking to do.
248✔
419
        if inv.State == ContractCanceled {
252✔
420
                return nil, ctx.failRes(ResultInvoiceAlreadyCanceled), nil
4✔
421
        }
4✔
422

423
        // If an invoice amount is specified, check that enough is paid. Also
424
        // check this for duplicate payments if the invoice is already settled
425
        // or accepted. In case this is a zero-valued invoice, it will always be
426
        // enough.
427
        if ctx.amtPaid < inv.Terms.Value {
247✔
428
                return nil, ctx.failRes(ResultAmountTooLow), nil
3✔
429
        }
3✔
430

431
        // If the invoice had the required feature bit set at this point, then
432
        // if we're in this method it means that the remote party didn't supply
433
        // the expected payload. However if this is a keysend payment, then
434
        // we'll permit it to pass.
435
        _, isKeySend := ctx.customRecords[record.KeySendType]
241✔
436
        invoiceFeatures := inv.Terms.Features
241✔
437
        paymentAddrRequired := invoiceFeatures.RequiresFeature(
241✔
438
                lnwire.PaymentAddrRequired,
241✔
439
        )
241✔
440
        if !isKeySend && paymentAddrRequired {
244✔
441
                log.Warnf("Payment to pay_hash=%v doesn't include MPP "+
3✔
442
                        "payload, rejecting", ctx.hash)
3✔
443
                return nil, ctx.failRes(ResultAddressMismatch), nil
3✔
444
        }
3✔
445

446
        // Don't allow settling the invoice with an old style
447
        // htlc if we are already in the process of gathering an
448
        // mpp set.
449
        for _, htlc := range inv.HTLCSet(nil, HtlcStateAccepted) {
241✔
450
                if htlc.MppTotalAmt > 0 {
3✔
451
                        return nil, ctx.failRes(ResultMppInProgress), nil
×
452
                }
×
453
        }
454

455
        // The invoice is still open. Check the expiry.
456
        if ctx.expiry < uint32(ctx.currentHeight+ctx.finalCltvRejectDelta) {
244✔
457
                return nil, ctx.failRes(ResultExpiryTooSoon), nil
6✔
458
        }
6✔
459

460
        if ctx.expiry < uint32(ctx.currentHeight+inv.Terms.FinalCltvDelta) {
233✔
461
                return nil, ctx.failRes(ResultExpiryTooSoon), nil
1✔
462
        }
1✔
463

464
        // For storage, we don't really care where the custom records came from.
465
        // So we merge them together and store them in the same field.
466
        customRecords := lnwire.CustomRecords(
231✔
467
                ctx.customRecords,
231✔
468
        ).MergedCopy(ctx.wireCustomRecords)
231✔
469

231✔
470
        // Record HTLC in the invoice database.
231✔
471
        newHtlcs := map[CircuitKey]*HtlcAcceptDesc{
231✔
472
                ctx.circuitKey: {
231✔
473
                        Amt:           ctx.amtPaid,
231✔
474
                        Expiry:        ctx.expiry,
231✔
475
                        AcceptHeight:  ctx.currentHeight,
231✔
476
                        CustomRecords: record.CustomSet(customRecords),
231✔
477
                },
231✔
478
        }
231✔
479

231✔
480
        update := InvoiceUpdateDesc{
231✔
481
                AddHtlcs:   newHtlcs,
231✔
482
                UpdateType: AddHTLCsUpdate,
231✔
483
        }
231✔
484

231✔
485
        // Don't update invoice state if we are accepting a duplicate payment.
231✔
486
        // We do accept or settle the HTLC.
231✔
487
        switch inv.State {
231✔
488
        case ContractAccepted:
×
489
                return &update, ctx.acceptRes(resultDuplicateToAccepted), nil
×
490

491
        case ContractSettled:
3✔
492
                return &update, ctx.settleRes(
3✔
493
                        *inv.Terms.PaymentPreimage, ResultDuplicateToSettled,
3✔
494
                ), nil
3✔
495
        }
496

497
        // Check to see if we can settle or this is an hold invoice and we need
498
        // to wait for the preimage.
499
        if inv.HodlInvoice {
301✔
500
                update.State = &InvoiceStateUpdateDesc{
73✔
501
                        NewState: ContractAccepted,
73✔
502
                }
73✔
503

73✔
504
                return &update, ctx.acceptRes(resultAccepted), nil
73✔
505
        }
73✔
506

507
        update.State = &InvoiceStateUpdateDesc{
155✔
508
                NewState: ContractSettled,
155✔
509
                Preimage: inv.Terms.PaymentPreimage,
155✔
510
        }
155✔
511

155✔
512
        return &update, ctx.settleRes(
155✔
513
                *inv.Terms.PaymentPreimage, ResultSettled,
155✔
514
        ), nil
155✔
515
}
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