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

lightningnetwork / lnd / 17191871672

24 Aug 2025 05:44PM UTC coverage: 66.162% (+8.8%) from 57.321%
17191871672

Pull #9147

github

web-flow
Merge edec0450e into 0c2f045f5
Pull Request #9147: [Part 1|3] Introduce SQL Payment schema into LND

72 of 1878 new or added lines in 12 files covered. (3.83%)

17 existing lines in 7 files now uncovered.

135954 of 205487 relevant lines covered (66.16%)

21319.58 hits per line

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

0.0
/payments/db/sql_convert.go
1
package paymentsdb
2

3
import (
4
        "bytes"
5
        "encoding/binary"
6
        "fmt"
7
        "strconv"
8
        "time"
9

10
        "github.com/btcsuite/btcd/btcec/v2"
11
        "github.com/lightningnetwork/lnd/lntypes"
12
        "github.com/lightningnetwork/lnd/lnwire"
13
        "github.com/lightningnetwork/lnd/record"
14
        "github.com/lightningnetwork/lnd/routing/route"
15
        "github.com/lightningnetwork/lnd/sqldb/sqlc"
16
        "github.com/lightningnetwork/lnd/tlv"
17
)
18

19
// unmarshalHtlcAttempt converts a sqlc.PaymentHtlcAttempt and its hops to
20
// an HTLCAttempt.
21
func unmarshalHtlcAttempt(dbAttempt sqlc.PaymentHtlcAttempt,
22
        hops []sqlc.PaymentRouteHop,
23
        dbRouteCustomRecords []sqlc.PaymentHtlcAttemptCustomRecord) (
NEW
24
        *HTLCAttempt, error) {
×
NEW
25

×
NEW
26
        if len(dbAttempt.SessionKey) != btcec.PrivKeyBytesLen {
×
NEW
27
                return nil, fmt.Errorf("invalid session key length: %d",
×
NEW
28
                        len(dbAttempt.SessionKey))
×
NEW
29
        }
×
30

31
        // Convert session key of the sphinx packet.
NEW
32
        var sessionKey [btcec.PrivKeyBytesLen]byte
×
NEW
33
        copy(sessionKey[:], dbAttempt.SessionKey)
×
NEW
34

×
NEW
35
        // Reconstruct the route from hops
×
NEW
36
        route, err := unmarshalRoute(dbAttempt, hops, dbRouteCustomRecords)
×
NEW
37
        if err != nil {
×
NEW
38
                return nil, fmt.Errorf("unable to reconstruct route: %w", err)
×
NEW
39
        }
×
40

NEW
41
        var hash lntypes.Hash
×
NEW
42
        copy(hash[:], dbAttempt.PaymentHash)
×
NEW
43

×
NEW
44
        // Create HTLCAttemptInfo
×
NEW
45
        htlcInfo := HTLCAttemptInfo{
×
NEW
46
                AttemptID:   uint64(dbAttempt.AttemptIndex),
×
NEW
47
                sessionKey:  sessionKey,
×
NEW
48
                AttemptTime: dbAttempt.AttemptTime.Local(),
×
NEW
49
                Route:       *route,
×
NEW
50
                Hash:        &hash,
×
NEW
51
        }
×
NEW
52

×
NEW
53
        // Create the HTLCAttempt
×
NEW
54
        htlcAttempt := &HTLCAttempt{
×
NEW
55
                HTLCAttemptInfo: htlcInfo,
×
NEW
56
        }
×
NEW
57

×
NEW
58
        // Handle settle info.
×
NEW
59
        if dbAttempt.SettlePreimage != nil {
×
NEW
60
                var preimage lntypes.Preimage
×
NEW
61
                if len(dbAttempt.SettlePreimage) != 32 {
×
NEW
62
                        return nil, fmt.Errorf("invalid preimage length: %d",
×
NEW
63
                                len(dbAttempt.SettlePreimage))
×
NEW
64
                }
×
NEW
65
                copy(preimage[:], dbAttempt.SettlePreimage)
×
NEW
66

×
NEW
67
                var settleTime time.Time
×
NEW
68
                if dbAttempt.SettleTime.Valid {
×
NEW
69
                        settleTime = dbAttempt.SettleTime.Time.Local()
×
NEW
70
                }
×
71

NEW
72
                htlcAttempt.Settle = &HTLCSettleInfo{
×
NEW
73
                        Preimage:   preimage,
×
NEW
74
                        SettleTime: settleTime,
×
NEW
75
                }
×
76
        }
77

78
        // Handle failure info.
NEW
79
        if dbAttempt.HtlcFailReason.Valid {
×
NEW
80
                htlcAttempt.Failure = &HTLCFailInfo{
×
NEW
81
                        FailTime: dbAttempt.FailTime.Time.Local(),
×
NEW
82
                        Reason: HTLCFailReason(
×
NEW
83
                                dbAttempt.HtlcFailReason.Int32,
×
NEW
84
                        ),
×
NEW
85
                        FailureSourceIndex: uint32(
×
NEW
86
                                dbAttempt.FailureSourceIndex.Int32),
×
NEW
87
                }
×
NEW
88

×
NEW
89
                // If we have a failure message, we could parse it here
×
NEW
90
                if dbAttempt.FailureMsg != nil {
×
NEW
91
                        failureMsg, err := lnwire.DecodeFailureMessage(
×
NEW
92
                                bytes.NewReader(dbAttempt.FailureMsg), 0)
×
NEW
93
                        if err != nil {
×
NEW
94
                                return nil, fmt.Errorf(
×
NEW
95
                                        "unable to decode failure message: %w",
×
NEW
96
                                        err)
×
NEW
97
                        }
×
98

NEW
99
                        htlcAttempt.Failure.Message = failureMsg
×
100
                }
101
        }
102

NEW
103
        return htlcAttempt, nil
×
104
}
105

106
// unmarshalRoute converts a sqlc.PaymentHtlcAttempt and its hops to a
107
// route.Route.
108
func unmarshalRoute(dbAttempt sqlc.PaymentHtlcAttempt,
109
        hops []sqlc.PaymentRouteHop,
NEW
110
        dbRouteCustomRecords []sqlc.PaymentHtlcAttemptCustomRecord) (*route.Route, error) {
×
NEW
111

×
NEW
112
        // Unmarshal the hops.
×
NEW
113
        routeHops, err := unmarshalRouteHops(hops)
×
NEW
114
        if err != nil {
×
NEW
115
                return nil, fmt.Errorf("unable to unmarshal route hops: %w",
×
NEW
116
                        err)
×
NEW
117
        }
×
118

119
        // Add the additional data to the route.
NEW
120
        sourcePubKey, err := route.NewVertexFromBytes(dbAttempt.RouteSourceKey)
×
NEW
121
        if err != nil {
×
NEW
122
                return nil, fmt.Errorf("unable to convert source "+
×
NEW
123
                        "pubkey: %w", err)
×
NEW
124
        }
×
125

NEW
126
        route := &route.Route{
×
NEW
127
                TotalTimeLock: uint32(dbAttempt.RouteTotalTimeLock),
×
NEW
128
                TotalAmount:   lnwire.MilliSatoshi(dbAttempt.RouteTotalAmount),
×
NEW
129
                SourcePubKey:  sourcePubKey,
×
NEW
130
                Hops:          routeHops,
×
NEW
131
        }
×
NEW
132

×
NEW
133
        // Attach the custom records to the route.
×
NEW
134
        customRecords, err := unmarshalRouteCustomRecords(
×
NEW
135
                dbRouteCustomRecords,
×
NEW
136
        )
×
NEW
137
        if err != nil {
×
NEW
138
                return nil, fmt.Errorf("unable to unmarshal route "+
×
NEW
139
                        "custom records: %w", err)
×
NEW
140
        }
×
141

NEW
142
        route.FirstHopWireCustomRecords = customRecords
×
NEW
143

×
NEW
144
        firstHopAmount, ok := extractFirstHopAmount(customRecords)
×
NEW
145
        if ok {
×
NEW
146
                route.FirstHopAmount = tlv.NewRecordT[tlv.TlvType0](
×
NEW
147
                        tlv.NewBigSizeT(firstHopAmount),
×
NEW
148
                )
×
NEW
149
        }
×
150

NEW
151
        return route, nil
×
152
}
153

154
// Extract first hop amount from custom records which is encoded as TLV type 0.
155
func extractFirstHopAmount(
NEW
156
        customRecords lnwire.CustomRecords) (lnwire.MilliSatoshi, bool) {
×
NEW
157

×
NEW
158
        tlvType := uint64(0)
×
NEW
159

×
NEW
160
        if amountBytes, exists := lnwire.ExtractAndRemoveCustomRecord(
×
NEW
161
                customRecords, tlvType,
×
NEW
162
        ); exists {
×
NEW
163
                if len(amountBytes) == 8 {
×
NEW
164
                        amount := binary.BigEndian.Uint64(amountBytes)
×
NEW
165
                        return lnwire.MilliSatoshi(amount), true
×
NEW
166
                }
×
167
        }
168

NEW
169
        return 0, false
×
170
}
171

172
// unmarshalHops converts a slice of sqlc.PaymentRouteHop to a slice of
173
// *route.Hop.
NEW
174
func unmarshalRouteHops(dbHops []sqlc.PaymentRouteHop) ([]*route.Hop, error) {
×
NEW
175
        if len(dbHops) == 0 {
×
NEW
176
                return nil, fmt.Errorf("no hops provided")
×
NEW
177
        }
×
178

NEW
179
        routeHops := make([]*route.Hop, len(dbHops))
×
NEW
180
        for i, dbHop := range dbHops {
×
NEW
181
                routeHop, err := unmarshalHop(dbHop)
×
NEW
182
                if err != nil {
×
NEW
183
                        return nil, fmt.Errorf("unable to unmarshal "+
×
NEW
184
                                "hop %d: %w", i, err)
×
NEW
185
                }
×
NEW
186
                routeHops[i] = routeHop
×
187
        }
188

NEW
189
        return routeHops, nil
×
190
}
191

192
// unmarshalHop converts a sqlc.PaymentRouteHop to a route.Hop.
NEW
193
func unmarshalHop(dbHop sqlc.PaymentRouteHop) (*route.Hop, error) {
×
NEW
194
        // Parse channel ID which is represented as a string in the sql db and
×
NEW
195
        // uses the integer representation of the channel id.
×
NEW
196
        chanID, err := strconv.ParseUint(dbHop.Scid, 10, 64)
×
NEW
197
        if err != nil {
×
NEW
198
                return nil, fmt.Errorf("invalid channel ID: %w", err)
×
NEW
199
        }
×
200

NEW
201
        var pubKey [33]byte
×
NEW
202
        if len(dbHop.PubKey) != 33 {
×
NEW
203
                return nil, fmt.Errorf("invalid public key length: %d",
×
NEW
204
                        len(dbHop.PubKey))
×
NEW
205
        }
×
NEW
206
        copy(pubKey[:], dbHop.PubKey)
×
NEW
207

×
NEW
208
        hop := &route.Hop{
×
NEW
209
                ChannelID:        chanID,
×
NEW
210
                PubKeyBytes:      pubKey,
×
NEW
211
                OutgoingTimeLock: uint32(dbHop.OutgoingTimeLock),
×
NEW
212
                AmtToForward:     lnwire.MilliSatoshi(dbHop.AmtToForward),
×
NEW
213
                Metadata:         dbHop.MetaData,
×
NEW
214
                LegacyPayload:    dbHop.LegacyPayload,
×
NEW
215
        }
×
NEW
216

×
NEW
217
        // Handle MPP fields if available. This should only be present for
×
NEW
218
        // the last hop of the route in MPP payments.
×
NEW
219
        if len(dbHop.MppPaymentAddr) > 0 {
×
NEW
220
                var paymentAddr [32]byte
×
NEW
221
                copy(paymentAddr[:], dbHop.MppPaymentAddr)
×
NEW
222
                totalMsat := lnwire.MilliSatoshi(0)
×
NEW
223
                if dbHop.MppTotalMsat.Valid {
×
NEW
224
                        totalMsat = lnwire.MilliSatoshi(
×
NEW
225
                                dbHop.MppTotalMsat.Int64,
×
NEW
226
                        )
×
NEW
227
                }
×
NEW
228
                hop.MPP = record.NewMPP(totalMsat, paymentAddr)
×
229
        }
230

231
        // Handle AMP fields if available. This should only be present for
232
        // the last hop of the route in AMP payments.
NEW
233
        if len(dbHop.AmpRootShare) > 0 && len(dbHop.AmpSetID) > 0 {
×
NEW
234
                var rootShare, setID [32]byte
×
NEW
235
                copy(rootShare[:], dbHop.AmpRootShare)
×
NEW
236
                copy(setID[:], dbHop.AmpSetID)
×
NEW
237

×
NEW
238
                childIndex := uint32(0)
×
NEW
239
                if dbHop.AmpChildIndex.Valid {
×
NEW
240
                        childIndex = uint32(dbHop.AmpChildIndex.Int32)
×
NEW
241
                }
×
242

NEW
243
                hop.AMP = record.NewAMP(rootShare, setID, childIndex)
×
244
        }
245

246
        // Handle blinded path fields
NEW
247
        if len(dbHop.EncryptedData) > 0 {
×
NEW
248
                hop.EncryptedData = dbHop.EncryptedData
×
NEW
249
        }
×
250

NEW
251
        if len(dbHop.BlindingPoint) > 0 {
×
NEW
252
                pubKey, err := btcec.ParsePubKey(dbHop.BlindingPoint)
×
NEW
253
                if err != nil {
×
NEW
254
                        return nil, fmt.Errorf("invalid blinding "+
×
NEW
255
                                "point: %w", err)
×
NEW
256
                }
×
NEW
257
                hop.BlindingPoint = pubKey
×
258
        }
259

NEW
260
        if dbHop.BlindedPathTotalAmt.Valid {
×
NEW
261
                hop.TotalAmtMsat = lnwire.MilliSatoshi(
×
NEW
262
                        dbHop.BlindedPathTotalAmt.Int64)
×
NEW
263
        }
×
264

NEW
265
        return hop, nil
×
266
}
267

268
// unmarshalFirstHopCustomRecords converts a slice of
269
// sqlc.PaymentFirstHopCustomRecord to a lnwire.CustomRecords.
270
func unmarshalFirstHopCustomRecords(
271
        dbFirstHopCustomRecords []sqlc.PaymentFirstHopCustomRecord) (
NEW
272
        lnwire.CustomRecords, error) {
×
NEW
273

×
NEW
274
        tlvMap := make(tlv.TypeMap)
×
NEW
275
        for _, dbRecord := range dbFirstHopCustomRecords {
×
NEW
276
                // We need to make a copy to prevent nil/empty value comparison
×
NEW
277
                // issues for empty values.
×
NEW
278
                value := make([]byte, len(dbRecord.Value))
×
NEW
279
                copy(value, dbRecord.Value)
×
NEW
280
                tlvMap[tlv.Type(dbRecord.Key)] = value
×
NEW
281
        }
×
NEW
282
        firstHopCustomRecordsMap, err := lnwire.NewCustomRecords(tlvMap)
×
NEW
283
        if err != nil {
×
NEW
284
                return nil, fmt.Errorf("unable to convert first "+
×
NEW
285
                        "hop custom records to tlv map: %w", err)
×
NEW
286
        }
×
287

NEW
288
        return firstHopCustomRecordsMap, nil
×
289
}
290

291
// unmarshalRouteCustomRecords converts a slice of
292
// sqlc.PaymentHtlcAttemptCustomRecord to a lnwire.CustomRecords.
293
func unmarshalRouteCustomRecords(
294
        dbRouteCustomRecords []sqlc.PaymentHtlcAttemptCustomRecord) (
NEW
295
        lnwire.CustomRecords, error) {
×
NEW
296

×
NEW
297
        tlvMap := make(tlv.TypeMap)
×
NEW
298
        for _, dbRecord := range dbRouteCustomRecords {
×
NEW
299
                // We need to make a copy to prevent nil/empty value comparison
×
NEW
300
                // issues for empty values.
×
NEW
301
                value := make([]byte, len(dbRecord.Value))
×
NEW
302
                copy(value, dbRecord.Value)
×
NEW
303
                tlvMap[tlv.Type(dbRecord.Key)] = value
×
NEW
304
        }
×
NEW
305
        routeCustomRecordsMap, err := lnwire.NewCustomRecords(tlvMap)
×
NEW
306
        if err != nil {
×
NEW
307
                return nil, fmt.Errorf("unable to convert route "+
×
NEW
308
                        "custom records to tlv map: %w", err)
×
NEW
309
        }
×
310

NEW
311
        return routeCustomRecordsMap, nil
×
312
}
313

314
func unmarshalHopCustomRecords(
315
        dbHopCustomRecords []sqlc.PaymentRouteHopCustomRecord) (
NEW
316
        record.CustomSet, error) {
×
NEW
317

×
NEW
318
        tlvMap := make(tlv.TypeMap)
×
NEW
319
        for _, dbRecord := range dbHopCustomRecords {
×
NEW
320
                // We need to make a copy to prevent nil/empty value comparison
×
NEW
321
                // issues for empty values.
×
NEW
322
                value := make([]byte, len(dbRecord.Value))
×
NEW
323
                copy(value, dbRecord.Value)
×
NEW
324
                tlvMap[tlv.Type(dbRecord.Key)] = value
×
NEW
325
        }
×
NEW
326
        hopCustomRecordsMap, err := lnwire.NewCustomRecords(tlvMap)
×
NEW
327
        if err != nil {
×
NEW
328
                return nil, fmt.Errorf("unable to convert hop "+
×
NEW
329
                        "custom records to tlv map: %w", err)
×
NEW
330
        }
×
331

NEW
332
        return record.CustomSet(hopCustomRecordsMap), nil
×
333
}
334

335
// unmarshalPaymentData unmarshals the payment data into the MPPayment object.
336
func unmarshalPaymentData(dbPayment sqlc.Payment,
337
        dbHtlcAttempts []sqlc.PaymentHtlcAttempt, dbHops []sqlc.PaymentRouteHop,
338
        dbRouteCustomRecords []sqlc.PaymentHtlcAttemptCustomRecord,
339
        dbHopCustomRecords []sqlc.PaymentRouteHopCustomRecord,
340
        dbFirstHopCustomRecords []sqlc.PaymentFirstHopCustomRecord) (*MPPayment,
NEW
341
        error) {
×
NEW
342

×
NEW
343
        // Create maps for efficient lookups
×
NEW
344
        hopsByAttemptIndex := make(map[int64][]sqlc.PaymentRouteHop)
×
NEW
345
        for _, hop := range dbHops {
×
NEW
346
                hopsByAttemptIndex[hop.HtlcAttemptIndex] = append(
×
NEW
347
                        hopsByAttemptIndex[hop.HtlcAttemptIndex], hop,
×
NEW
348
                )
×
NEW
349
        }
×
350

NEW
351
        customRecordsByHopID := make(
×
NEW
352
                map[int64][]sqlc.PaymentRouteHopCustomRecord,
×
NEW
353
        )
×
NEW
354
        for _, record := range dbHopCustomRecords {
×
NEW
355
                customRecordsByHopID[record.HopID] = append(
×
NEW
356
                        customRecordsByHopID[record.HopID], record,
×
NEW
357
                )
×
NEW
358
        }
×
359

360
        // Convert the db htlc attempts to our internal htlc attempts data
361
        // structure.
NEW
362
        var htlcAttempts []HTLCAttempt
×
NEW
363
        for _, dbAttempt := range dbHtlcAttempts {
×
NEW
364
                // Get hops for this attempt from our pre-fetched data
×
NEW
365
                dbHops := hopsByAttemptIndex[dbAttempt.AttemptIndex]
×
NEW
366

×
NEW
367
                attempt, err := unmarshalHtlcAttempt(
×
NEW
368
                        dbAttempt, dbHops, dbRouteCustomRecords,
×
NEW
369
                )
×
NEW
370
                if err != nil {
×
NEW
371
                        return nil, fmt.Errorf("unable to convert htlc "+
×
NEW
372
                                "attempt(id=%d): %w", dbAttempt.AttemptIndex,
×
NEW
373
                                err)
×
NEW
374
                }
×
375

NEW
376
                hops := attempt.Route.Hops
×
NEW
377

×
NEW
378
                // Attach the custom records to the hop which are in a separate
×
NEW
379
                // table.
×
NEW
380
                for i, dbHop := range dbHops {
×
NEW
381
                        // Get custom records for this hop from our pre-fetched
×
NEW
382
                        // data.
×
NEW
383
                        dbHopCustomRecords := customRecordsByHopID[dbHop.ID]
×
NEW
384

×
NEW
385
                        hops[i].CustomRecords, err = unmarshalHopCustomRecords(
×
NEW
386
                                dbHopCustomRecords,
×
NEW
387
                        )
×
NEW
388
                        if err != nil {
×
NEW
389
                                return nil, fmt.Errorf("unable to unmarshal "+
×
NEW
390
                                        "hop custom records: %w", err)
×
NEW
391
                        }
×
392
                }
393

NEW
394
                htlcAttempts = append(htlcAttempts, *attempt)
×
395
        }
396

397
        // Convert first hop custom records
NEW
398
        firstHopCustomRecords, err := unmarshalFirstHopCustomRecords(
×
NEW
399
                dbFirstHopCustomRecords,
×
NEW
400
        )
×
NEW
401
        if err != nil {
×
NEW
402
                return nil, fmt.Errorf("unable to unmarshal first hop "+
×
NEW
403
                        "custom records: %w", err)
×
NEW
404
        }
×
405

406
        // Convert payment hash from bytes to lntypes.Hash.
NEW
407
        var paymentHash lntypes.Hash
×
NEW
408
        copy(paymentHash[:], dbPayment.PaymentHash)
×
NEW
409

×
NEW
410
        // Create PaymentCreationInfo from the payment data.
×
NEW
411
        creationInfo := &PaymentCreationInfo{
×
NEW
412
                PaymentIdentifier: paymentHash,
×
NEW
413
                Value: lnwire.MilliSatoshi(
×
NEW
414
                        dbPayment.AmountMsat,
×
NEW
415
                ),
×
NEW
416
                CreationTime:          dbPayment.CreatedAt.Local(),
×
NEW
417
                PaymentRequest:        dbPayment.PaymentRequest,
×
NEW
418
                FirstHopCustomRecords: firstHopCustomRecords,
×
NEW
419
        }
×
NEW
420

×
NEW
421
        var failureReason *FailureReason
×
NEW
422
        if dbPayment.FailReason.Valid {
×
NEW
423
                reason := FailureReason(dbPayment.FailReason.Int32)
×
NEW
424
                failureReason = &reason
×
NEW
425
        }
×
426

427
        // Create MPPayment in memory object.
NEW
428
        payment := &MPPayment{
×
NEW
429
                SequenceNum:   uint64(dbPayment.ID),
×
NEW
430
                FailureReason: failureReason,
×
NEW
431
                Info:          creationInfo,
×
NEW
432
                HTLCs:         htlcAttempts,
×
NEW
433
        }
×
NEW
434

×
NEW
435
        return payment, nil
×
436
}
437

438
// unmarshalPaymentWithoutHTLCs unmarshals the payment data into the MPPayment
439
// object without HTLCs.
NEW
440
func unmarshalPaymentWithoutHTLCs(dbPayment sqlc.Payment) (*MPPayment, error) {
×
NEW
441
        // Convert payment hash from bytes to lntypes.Hash
×
NEW
442
        var paymentHash lntypes.Hash
×
NEW
443
        copy(paymentHash[:], dbPayment.PaymentHash)
×
NEW
444

×
NEW
445
        // Create PaymentCreationInfo from the payment data
×
NEW
446
        creationInfo := &PaymentCreationInfo{
×
NEW
447
                PaymentIdentifier: paymentHash,
×
NEW
448
                Value:             lnwire.MilliSatoshi(dbPayment.AmountMsat),
×
NEW
449
                CreationTime:      dbPayment.CreatedAt.Local(),
×
NEW
450
                PaymentRequest:    dbPayment.PaymentRequest,
×
NEW
451
                // No first hop custom records for payments without HTLCs
×
NEW
452
        }
×
NEW
453

×
NEW
454
        var failureReason *FailureReason
×
NEW
455
        if dbPayment.FailReason.Valid {
×
NEW
456
                reason := FailureReason(dbPayment.FailReason.Int32)
×
NEW
457
                failureReason = &reason
×
NEW
458
        }
×
459

460
        // Create MPPayment object
NEW
461
        payment := &MPPayment{
×
NEW
462
                SequenceNum:   uint64(dbPayment.ID),
×
NEW
463
                FailureReason: failureReason,
×
NEW
464
                Info:          creationInfo,
×
NEW
465
                HTLCs:         []HTLCAttempt{},
×
NEW
466
        }
×
NEW
467

×
NEW
468
        return payment, nil
×
469
}
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