• 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

61.09
/lnwallet/btcwallet/signer.go
1
package btcwallet
2

3
import (
4
        "fmt"
5

6
        "github.com/btcsuite/btcd/btcec/v2"
7
        "github.com/btcsuite/btcd/btcec/v2/ecdsa"
8
        "github.com/btcsuite/btcd/btcec/v2/schnorr"
9
        "github.com/btcsuite/btcd/btcutil"
10
        "github.com/btcsuite/btcd/btcutil/hdkeychain"
11
        "github.com/btcsuite/btcd/btcutil/psbt"
12
        "github.com/btcsuite/btcd/chaincfg/chainhash"
13
        "github.com/btcsuite/btcd/txscript"
14
        "github.com/btcsuite/btcd/wire"
15
        "github.com/btcsuite/btcwallet/waddrmgr"
16
        "github.com/btcsuite/btcwallet/walletdb"
17
        "github.com/lightningnetwork/lnd/input"
18
        "github.com/lightningnetwork/lnd/keychain"
19
        "github.com/lightningnetwork/lnd/lnwallet"
20
)
21

22
// FetchOutpointInfo queries for the WalletController's knowledge of the passed
23
// outpoint. If the base wallet determines this output is under its control,
24
// then the original txout should be returned. Otherwise, a non-nil error value
25
// of ErrNotMine should be returned instead.
26
//
27
// This is a part of the WalletController interface.
28
func (b *BtcWallet) FetchOutpointInfo(prevOut *wire.OutPoint) (*lnwallet.Utxo,
29
        error) {
88✔
30

88✔
31
        prevTx, txOut, confirmations, err := b.wallet.FetchOutpointInfo(prevOut)
88✔
32
        if err != nil {
88✔
UNCOV
33
                return nil, err
×
UNCOV
34
        }
×
35

36
        // Then, we'll populate all of the information required by the struct.
37
        addressType := lnwallet.UnknownAddressType
88✔
38
        switch {
88✔
39
        case txscript.IsPayToWitnessPubKeyHash(txOut.PkScript):
88✔
40
                addressType = lnwallet.WitnessPubKey
88✔
UNCOV
41
        case txscript.IsPayToScriptHash(txOut.PkScript):
×
UNCOV
42
                addressType = lnwallet.NestedWitnessPubKey
×
UNCOV
43
        case txscript.IsPayToTaproot(txOut.PkScript):
×
UNCOV
44
                addressType = lnwallet.TaprootPubkey
×
45
        }
46

47
        return &lnwallet.Utxo{
88✔
48
                AddressType:   addressType,
88✔
49
                Value:         btcutil.Amount(txOut.Value),
88✔
50
                PkScript:      txOut.PkScript,
88✔
51
                Confirmations: confirmations,
88✔
52
                OutPoint:      *prevOut,
88✔
53
                PrevTx:        prevTx,
88✔
54
        }, nil
88✔
55
}
56

57
// FetchDerivationInfo queries for the wallet's knowledge of the passed
58
// pkScript and constructs the derivation info and returns it.
59
func (b *BtcWallet) FetchDerivationInfo(
UNCOV
60
        pkScript []byte) (*psbt.Bip32Derivation, error) {
×
UNCOV
61

×
UNCOV
62
        return b.wallet.FetchDerivationInfo(pkScript)
×
UNCOV
63
}
×
64

65
// ScriptForOutput returns the address, witness program and redeem script for a
66
// given UTXO. An error is returned if the UTXO does not belong to our wallet or
67
// it is not a managed pubKey address.
68
func (b *BtcWallet) ScriptForOutput(output *wire.TxOut) (
UNCOV
69
        waddrmgr.ManagedPubKeyAddress, []byte, []byte, error) {
×
UNCOV
70

×
UNCOV
71
        return b.wallet.ScriptForOutput(output)
×
UNCOV
72
}
×
73

74
// deriveFromKeyLoc attempts to derive a private key using a fully specified
75
// KeyLocator.
76
func deriveFromKeyLoc(scopedMgr *waddrmgr.ScopedKeyManager,
77
        addrmgrNs walletdb.ReadWriteBucket,
78
        keyLoc keychain.KeyLocator) (*btcec.PrivateKey, error) {
8✔
79

8✔
80
        path := waddrmgr.DerivationPath{
8✔
81
                InternalAccount: uint32(keyLoc.Family),
8✔
82
                Account:         uint32(keyLoc.Family),
8✔
83
                Branch:          0,
8✔
84
                Index:           keyLoc.Index,
8✔
85
        }
8✔
86
        addr, err := scopedMgr.DeriveFromKeyPath(addrmgrNs, path)
8✔
87
        if err != nil {
8✔
88
                return nil, err
×
89
        }
×
90

91
        return addr.(waddrmgr.ManagedPubKeyAddress).PrivKey()
8✔
92
}
93

94
// deriveKeyByBIP32Path derives a key described by a BIP32 path. We expect the
95
// first three elements of the path to be hardened according to BIP44, so they
96
// must be a number >= 2^31.
97
func (b *BtcWallet) deriveKeyByBIP32Path(path []uint32) (*btcec.PrivateKey,
98
        error) {
25✔
99

25✔
100
        // Make sure we get a full path with exactly 5 elements. A path is
25✔
101
        // either custom purpose one with 4 dynamic and one static elements:
25✔
102
        //    m/1017'/coinType'/keyFamily'/0/index
25✔
103
        // Or a default BIP49/89 one with 5 elements:
25✔
104
        //    m/purpose'/coinType'/account'/change/index
25✔
105
        const expectedDerivationPathDepth = 5
25✔
106
        if len(path) != expectedDerivationPathDepth {
25✔
107
                return nil, fmt.Errorf("invalid BIP32 derivation path, "+
×
108
                        "expected path length %d, instead was %d",
×
109
                        expectedDerivationPathDepth, len(path))
×
110
        }
×
111

112
        // Assert that the first three parts of the path are actually hardened
113
        // to avoid under-flowing the uint32 type.
114
        if err := assertHardened(path[0], path[1], path[2]); err != nil {
27✔
115
                return nil, fmt.Errorf("invalid BIP32 derivation path, "+
2✔
116
                        "expected first three elements to be hardened: %w", err)
2✔
117
        }
2✔
118

119
        purpose := path[0] - hdkeychain.HardenedKeyStart
23✔
120
        coinType := path[1] - hdkeychain.HardenedKeyStart
23✔
121
        account := path[2] - hdkeychain.HardenedKeyStart
23✔
122
        change, index := path[3], path[4]
23✔
123

23✔
124
        // Is this a custom lnd internal purpose key?
23✔
125
        switch purpose {
23✔
126
        case keychain.BIP0043Purpose:
8✔
127
                // Make sure it's for the same coin type as our wallet's
8✔
128
                // keychain scope.
8✔
129
                if coinType != b.chainKeyScope.Coin {
9✔
130
                        return nil, fmt.Errorf("invalid BIP32 derivation "+
1✔
131
                                "path, expected coin type %d, instead was %d",
1✔
132
                                b.chainKeyScope.Coin, coinType)
1✔
133
                }
1✔
134

135
                return b.deriveKeyByLocator(keychain.KeyLocator{
7✔
136
                        Family: keychain.KeyFamily(account),
7✔
137
                        Index:  index,
7✔
138
                })
7✔
139

140
        // Is it a standard, BIP defined purpose that the wallet understands?
141
        case waddrmgr.KeyScopeBIP0044.Purpose,
142
                waddrmgr.KeyScopeBIP0049Plus.Purpose,
143
                waddrmgr.KeyScopeBIP0084.Purpose,
144
                waddrmgr.KeyScopeBIP0086.Purpose:
15✔
145

146
                // We're going to continue below the switch statement to avoid
147
                // unnecessary indentation for this default case.
148

149
        // Currently, there is no way to import any other key scopes than the
150
        // one custom purpose or three standard ones into lnd's wallet. So we
151
        // shouldn't accept any other scopes to sign for.
152
        default:
×
153
                return nil, fmt.Errorf("invalid BIP32 derivation path, "+
×
154
                        "unknown purpose %d", purpose)
×
155
        }
156

157
        // Okay, we made sure it's a BIP49/84 key, so we need to derive it now.
158
        // Interestingly, the btcwallet never actually uses a coin type other
159
        // than 0 for those keys, so we need to make sure this behavior is
160
        // replicated here.
161
        if coinType != 0 {
16✔
162
                return nil, fmt.Errorf("invalid BIP32 derivation path, coin " +
1✔
163
                        "type must be 0 for BIP49/84 btcwallet keys")
1✔
164
        }
1✔
165

166
        // We only expect to be asked to sign with key scopes that we know
167
        // about. So if the scope doesn't exist, we don't create it.
168
        scope := waddrmgr.KeyScope{
14✔
169
                Purpose: purpose,
14✔
170
                Coin:    coinType,
14✔
171
        }
14✔
172
        scopedMgr, err := b.wallet.Manager.FetchScopedKeyManager(scope)
14✔
173
        if err != nil {
14✔
174
                return nil, fmt.Errorf("error fetching manager for scope %v: "+
×
175
                        "%w", scope, err)
×
176
        }
×
177

178
        // Let's see if we can hit the private key cache.
179
        keyPath := waddrmgr.DerivationPath{
14✔
180
                InternalAccount: account,
14✔
181
                Account:         account,
14✔
182
                Branch:          change,
14✔
183
                Index:           index,
14✔
184
        }
14✔
185
        privKey, err := scopedMgr.DeriveFromKeyPathCache(keyPath)
14✔
186
        if err == nil {
23✔
187
                return privKey, nil
9✔
188
        }
9✔
189

190
        // The key wasn't in the cache, let's fully derive it now.
191
        err = walletdb.View(b.db, func(tx walletdb.ReadTx) error {
10✔
192
                addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey)
5✔
193

5✔
194
                addr, err := scopedMgr.DeriveFromKeyPath(addrmgrNs, keyPath)
5✔
195
                if err != nil {
7✔
196
                        return fmt.Errorf("error deriving private key: %w", err)
2✔
197
                }
2✔
198

199
                privKey, err = addr.(waddrmgr.ManagedPubKeyAddress).PrivKey()
3✔
200
                return err
3✔
201
        })
202
        if err != nil {
7✔
203
                return nil, fmt.Errorf("error deriving key from path %#v: %w",
2✔
204
                        keyPath, err)
2✔
205
        }
2✔
206

207
        return privKey, nil
3✔
208
}
209

210
// assertHardened makes sure each given element is >= 2^31.
211
func assertHardened(elements ...uint32) error {
25✔
212
        for idx, element := range elements {
98✔
213
                if element < hdkeychain.HardenedKeyStart {
75✔
214
                        return fmt.Errorf("element at index %d is not hardened",
2✔
215
                                idx)
2✔
216
                }
2✔
217
        }
218

219
        return nil
23✔
220
}
221

222
// deriveKeyByLocator attempts to derive a key stored in the wallet given a
223
// valid key locator.
224
func (b *BtcWallet) deriveKeyByLocator(
225
        keyLoc keychain.KeyLocator) (*btcec.PrivateKey, error) {
43✔
226

43✔
227
        // We'll assume the special lightning key scope in this case.
43✔
228
        scopedMgr, err := b.wallet.Manager.FetchScopedKeyManager(
43✔
229
                b.chainKeyScope,
43✔
230
        )
43✔
231
        if err != nil {
43✔
232
                return nil, err
×
233
        }
×
234

235
        // First try to read the key from the cached store, if this fails, then
236
        // we'll fall through to the method below that requires a database
237
        // transaction.
238
        path := waddrmgr.DerivationPath{
43✔
239
                InternalAccount: uint32(keyLoc.Family),
43✔
240
                Account:         uint32(keyLoc.Family),
43✔
241
                Branch:          0,
43✔
242
                Index:           keyLoc.Index,
43✔
243
        }
43✔
244
        privKey, err := scopedMgr.DeriveFromKeyPathCache(path)
43✔
245
        if err == nil {
78✔
246
                return privKey, nil
35✔
247
        }
35✔
248

249
        var key *btcec.PrivateKey
8✔
250
        err = walletdb.Update(b.db, func(tx walletdb.ReadWriteTx) error {
16✔
251
                addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey)
8✔
252

8✔
253
                key, err = deriveFromKeyLoc(scopedMgr, addrmgrNs, keyLoc)
8✔
254
                if waddrmgr.IsError(err, waddrmgr.ErrAccountNotFound) {
8✔
255
                        // If we've reached this point, then the account
×
256
                        // doesn't yet exist, so we'll create it now to ensure
×
257
                        // we can sign.
×
258
                        acctErr := scopedMgr.NewRawAccount(
×
259
                                addrmgrNs, uint32(keyLoc.Family),
×
260
                        )
×
261
                        if acctErr != nil {
×
262
                                return acctErr
×
263
                        }
×
264

265
                        // Now that we know the account exists, we'll attempt
266
                        // to re-derive the private key.
267
                        key, err = deriveFromKeyLoc(
×
268
                                scopedMgr, addrmgrNs, keyLoc,
×
269
                        )
×
270
                        if err != nil {
×
271
                                return err
×
272
                        }
×
273
                }
274

275
                return err
8✔
276
        })
277
        if err != nil {
8✔
278
                return nil, err
×
279
        }
×
280

281
        return key, nil
8✔
282
}
283

284
// fetchPrivKey attempts to retrieve the raw private key corresponding to the
285
// passed public key if populated, or the key descriptor path (if non-empty).
286
func (b *BtcWallet) fetchPrivKey(
287
        keyDesc *keychain.KeyDescriptor) (*btcec.PrivateKey, error) {
79✔
288

79✔
289
        // If the key locator within the descriptor *isn't* empty, then we can
79✔
290
        // directly derive the keys raw.
79✔
291
        emptyLocator := keyDesc.KeyLocator.IsEmpty()
79✔
292
        if !emptyLocator || keyDesc.PubKey == nil {
115✔
293
                return b.deriveKeyByLocator(keyDesc.KeyLocator)
36✔
294
        }
36✔
295

296
        hash160 := btcutil.Hash160(keyDesc.PubKey.SerializeCompressed())
43✔
297
        addr, err := btcutil.NewAddressWitnessPubKeyHash(hash160, b.netParams)
43✔
298
        if err != nil {
43✔
299
                return nil, err
×
300
        }
×
301

302
        // Otherwise, we'll attempt to derive the key based on the address.
303
        // This will only work if we've already derived this address in the
304
        // past, since the wallet relies on a mapping of addr -> key.
305
        key, err := b.wallet.PrivKeyForAddress(addr)
43✔
306
        switch {
43✔
307
        // If we didn't find this key in the wallet, then there's a chance that
308
        // this is actually an "empty" key locator. The legacy KeyLocator
309
        // format failed to properly distinguish an empty key locator from the
310
        // very first in the index (0, 0).IsEmpty() == true.
311
        case waddrmgr.IsError(err, waddrmgr.ErrAddressNotFound) && emptyLocator:
×
312
                return b.deriveKeyByLocator(keyDesc.KeyLocator)
×
313

314
        case err != nil:
×
315
                return nil, err
×
316

317
        default:
43✔
318
                return key, nil
43✔
319
        }
320
}
321

322
// maybeTweakPrivKey examines the single and double tweak parameters on the
323
// passed sign descriptor and may perform a mapping on the passed private key
324
// in order to utilize the tweaks, if populated.
325
func maybeTweakPrivKey(signDesc *input.SignDescriptor,
326
        privKey *btcec.PrivateKey) (*btcec.PrivateKey, error) {
95✔
327

95✔
328
        var retPriv *btcec.PrivateKey
95✔
329
        switch {
95✔
330

331
        case signDesc.SingleTweak != nil:
4✔
332
                retPriv = input.TweakPrivKey(privKey,
4✔
333
                        signDesc.SingleTweak)
4✔
334

335
        case signDesc.DoubleTweak != nil:
4✔
336
                retPriv = input.DeriveRevocationPrivKey(privKey,
4✔
337
                        signDesc.DoubleTweak)
4✔
338

339
        default:
87✔
340
                retPriv = privKey
87✔
341
        }
342

343
        return retPriv, nil
95✔
344
}
345

346
// SignOutputRaw generates a signature for the passed transaction according to
347
// the data within the passed SignDescriptor.
348
//
349
// This is a part of the WalletController interface.
350
func (b *BtcWallet) SignOutputRaw(tx *wire.MsgTx,
351
        signDesc *input.SignDescriptor) (input.Signature, error) {
63✔
352

63✔
353
        witnessScript := signDesc.WitnessScript
63✔
354

63✔
355
        // First attempt to fetch the private key which corresponds to the
63✔
356
        // specified public key.
63✔
357
        privKey, err := b.fetchPrivKey(&signDesc.KeyDesc)
63✔
358
        if err != nil {
63✔
359
                return nil, err
×
360
        }
×
361

362
        // If a tweak (single or double) is specified, then we'll need to use
363
        // this tweak to derive the final private key to be used for signing
364
        // this output.
365
        privKey, err = maybeTweakPrivKey(signDesc, privKey)
63✔
366
        if err != nil {
63✔
367
                return nil, err
×
368
        }
×
369

370
        // In case of a taproot output any signature is always a Schnorr
371
        // signature, based on the new tapscript sighash algorithm.
372
        if txscript.IsPayToTaproot(signDesc.Output.PkScript) {
63✔
UNCOV
373
                sigHashes := txscript.NewTxSigHashes(
×
UNCOV
374
                        tx, signDesc.PrevOutputFetcher,
×
UNCOV
375
                )
×
UNCOV
376

×
UNCOV
377
                // Are we spending a script path or the key path? The API is
×
UNCOV
378
                // slightly different, so we need to account for that to get the
×
UNCOV
379
                // raw signature.
×
UNCOV
380
                var rawSig []byte
×
UNCOV
381
                switch signDesc.SignMethod {
×
382
                case input.TaprootKeySpendBIP0086SignMethod,
UNCOV
383
                        input.TaprootKeySpendSignMethod:
×
UNCOV
384

×
UNCOV
385
                        // This function tweaks the private key using the tap
×
UNCOV
386
                        // root key supplied as the tweak.
×
UNCOV
387
                        rawSig, err = txscript.RawTxInTaprootSignature(
×
UNCOV
388
                                tx, sigHashes, signDesc.InputIndex,
×
UNCOV
389
                                signDesc.Output.Value, signDesc.Output.PkScript,
×
UNCOV
390
                                signDesc.TapTweak, signDesc.HashType,
×
UNCOV
391
                                privKey,
×
UNCOV
392
                        )
×
UNCOV
393
                        if err != nil {
×
394
                                return nil, err
×
395
                        }
×
396

UNCOV
397
                case input.TaprootScriptSpendSignMethod:
×
UNCOV
398
                        leaf := txscript.TapLeaf{
×
UNCOV
399
                                LeafVersion: txscript.BaseLeafVersion,
×
UNCOV
400
                                Script:      witnessScript,
×
UNCOV
401
                        }
×
UNCOV
402
                        rawSig, err = txscript.RawTxInTapscriptSignature(
×
UNCOV
403
                                tx, sigHashes, signDesc.InputIndex,
×
UNCOV
404
                                signDesc.Output.Value, signDesc.Output.PkScript,
×
UNCOV
405
                                leaf, signDesc.HashType, privKey,
×
UNCOV
406
                        )
×
UNCOV
407
                        if err != nil {
×
408
                                return nil, err
×
409
                        }
×
410

411
                default:
×
412
                        return nil, fmt.Errorf("unknown sign method: %v",
×
413
                                signDesc.SignMethod)
×
414
                }
415

416
                // The signature returned above might have a sighash flag
417
                // attached if a non-default type was used. We'll slice this
418
                // off if it exists to ensure we can properly parse the raw
419
                // signature.
UNCOV
420
                sig, err := schnorr.ParseSignature(
×
UNCOV
421
                        rawSig[:schnorr.SignatureSize],
×
UNCOV
422
                )
×
UNCOV
423
                if err != nil {
×
424
                        return nil, err
×
425
                }
×
426

UNCOV
427
                return sig, nil
×
428
        }
429

430
        // TODO(roasbeef): generate sighash midstate if not present?
431

432
        amt := signDesc.Output.Value
63✔
433
        sig, err := txscript.RawTxInWitnessSignature(
63✔
434
                tx, signDesc.SigHashes, signDesc.InputIndex, amt,
63✔
435
                witnessScript, signDesc.HashType, privKey,
63✔
436
        )
63✔
437
        if err != nil {
63✔
438
                return nil, err
×
439
        }
×
440

441
        // Chop off the sighash flag at the end of the signature.
442
        return ecdsa.ParseDERSignature(sig[:len(sig)-1])
63✔
443
}
444

445
// ComputeInputScript generates a complete InputScript for the passed
446
// transaction with the signature as defined within the passed SignDescriptor.
447
// This method is capable of generating the proper input script for both
448
// regular p2wkh output and p2wkh outputs nested within a regular p2sh output.
449
//
450
// This is a part of the WalletController interface.
451
func (b *BtcWallet) ComputeInputScript(tx *wire.MsgTx,
452
        signDesc *input.SignDescriptor) (*input.Script, error) {
32✔
453

32✔
454
        // If a tweak (single or double) is specified, then we'll need to use
32✔
455
        // this tweak to derive the final private key to be used for signing
32✔
456
        // this output.
32✔
457
        privKeyTweaker := func(k *btcec.PrivateKey) (*btcec.PrivateKey, error) {
64✔
458
                return maybeTweakPrivKey(signDesc, k)
32✔
459
        }
32✔
460

461
        // Let the wallet compute the input script now.
462
        witness, sigScript, err := b.wallet.ComputeInputScript(
32✔
463
                tx, signDesc.Output, signDesc.InputIndex, signDesc.SigHashes,
32✔
464
                signDesc.HashType, privKeyTweaker,
32✔
465
        )
32✔
466
        if err != nil {
32✔
467
                return nil, err
×
468
        }
×
469

470
        return &input.Script{
32✔
471
                Witness:   witness,
32✔
472
                SigScript: sigScript,
32✔
473
        }, nil
32✔
474
}
475

476
// A compile time check to ensure that BtcWallet implements the Signer
477
// interface.
478
var _ input.Signer = (*BtcWallet)(nil)
479

480
// SignMessage attempts to sign a target message with the private key that
481
// corresponds to the passed key locator. If the target private key is unable to
482
// be found, then an error will be returned. The actual digest signed is the
483
// double SHA-256 of the passed message.
484
//
485
// NOTE: This is a part of the MessageSigner interface.
486
func (b *BtcWallet) SignMessage(keyLoc keychain.KeyLocator,
UNCOV
487
        msg []byte, doubleHash bool) (*ecdsa.Signature, error) {
×
UNCOV
488

×
UNCOV
489
        // First attempt to fetch the private key which corresponds to the
×
UNCOV
490
        // specified public key.
×
UNCOV
491
        privKey, err := b.fetchPrivKey(&keychain.KeyDescriptor{
×
UNCOV
492
                KeyLocator: keyLoc,
×
UNCOV
493
        })
×
UNCOV
494
        if err != nil {
×
495
                return nil, err
×
496
        }
×
497

498
        // Double hash and sign the data.
UNCOV
499
        var msgDigest []byte
×
UNCOV
500
        if doubleHash {
×
UNCOV
501
                msgDigest = chainhash.DoubleHashB(msg)
×
UNCOV
502
        } else {
×
503
                msgDigest = chainhash.HashB(msg)
×
504
        }
×
UNCOV
505
        return ecdsa.Sign(privKey, msgDigest), nil
×
506
}
507

508
// A compile time check to ensure that BtcWallet implements the MessageSigner
509
// interface.
510
var _ lnwallet.MessageSigner = (*BtcWallet)(nil)
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