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

lightningnetwork / lnd / 13536249039

26 Feb 2025 03:42AM UTC coverage: 57.462% (-1.4%) from 58.835%
13536249039

Pull #8453

github

Roasbeef
peer: update chooseDeliveryScript to gen script if needed

In this commit, we update `chooseDeliveryScript` to generate a new
script if needed. This allows us to fold in a few other lines that
always followed this function into this expanded function.

The tests have been updated accordingly.
Pull Request #8453: [4/4] - multi: integrate new rbf coop close FSM into the existing peer flow

275 of 1318 new or added lines in 22 files covered. (20.86%)

19521 existing lines in 257 files now uncovered.

103858 of 180741 relevant lines covered (57.46%)

24750.23 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