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

GoodDollar / GoodProtocol / 3881987600

pending completion
3881987600

push

github

sirpy
fix: buyandbridge test

859 of 1096 branches covered (78.38%)

Branch coverage included in aggregate %.

2563 of 2779 relevant lines covered (92.23%)

93.13 hits per line

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

94.71
/contracts/ubi/UBIScheme.sol
1
// SPDX-License-Identifier: MIXED
2

3
// License-Identifier: MIT
4
pragma solidity >=0.8.0;
5

6
import "../utils/DAOUpgradeableContract.sol";
7
import "../utils/NameService.sol";
8
import "../Interfaces.sol";
9
import "../governance/ClaimersDistribution.sol";
10

11
// import "hardhat/console.sol";
12

13
/* @title Dynamic amount-per-day UBI scheme allowing claim once a day
14
 */
15
contract UBIScheme is DAOUpgradeableContract {
16
        struct Day {
17
                mapping(address => bool) hasClaimed;
18
                uint256 amountOfClaimers;
19
                uint256 claimAmount;
20
        }
21

22
        //daily statistics
23
        mapping(uint256 => Day) public claimDay;
24

25
        //last ubi claim of user
26
        mapping(address => uint256) public lastClaimed;
27

28
        //current day since start of contract
29
        uint256 public currentDay;
30

31
        //starting date of contract, used to determine the hour where daily ubi cycle starts
32
        uint256 public periodStart;
33

34
        // Result of distribution formula
35
        // calculated each day
36
        uint256 public dailyUbi;
37

38
        // Limits the gas for each iteration at `fishMulti`
39
        uint256 public iterationGasLimit;
40

41
        // Tracks the active users number. It changes when
42
        // a new user claim for the first time or when a user
43
        // has been fished
44
        uint256 public activeUsersCount;
45

46
        // Tracks the last withdrawal day of funds from avatar.
47
        // Withdraw occures on the first daily claim or the
48
        // first daily fish only
49
        uint256 public lastWithdrawDay;
50

51
        // How long can a user be inactive.
52
        // After those days the user can be fished
53
        // (see `fish` notes)
54
        uint256 public maxInactiveDays;
55

56
        // Whether to withdraw GD from avatar
57
        // before daily ubi calculation
58
        bool public shouldWithdrawFromDAO;
59

60
        //number of days of each UBI pool cycle
61
        //dailyPool = Pool/cycleLength
62
        uint256 public cycleLength;
63

64
        //the amount of G$ UBI pool for each day in the cycle to be divided by active users
65
        uint256 public dailyCyclePool;
66

67
        //timestamp of current cycle start
68
        uint256 public startOfCycle;
69

70
        //should be 0 for starters so distributionFormula detects new cycle on first day claim
71
        uint256 public currentCycleLength;
72

73
        //dont use first claim, and give ubi as usual
74
        bool public useFirstClaimPool;
75

76
        //minimum amount of users to divide the pool for, renamed from defaultDailyUbi
77
        uint256 public minActiveUsers;
78

79
        // A pool of GD to give to activated users,
80
        // since they will enter the UBI pool
81
        // calculations only in the next day,
82
        // meaning they can only claim in the next
83
        // day
84
        IFirstClaimPool public firstClaimPool;
85

86
        struct Funds {
87
                // marks if the funds for a specific day has
88
                // withdrawn from avatar
89
                bool hasWithdrawn;
90
                // total GD held after withdrawing
91
                uint256 openAmount;
92
        }
93

94
        // Tracks the daily withdraws and the actual amount
95
        // at the begining of a trading day
96
        mapping(uint256 => Funds) public dailyUBIHistory;
97

98
        // Marks users that have been fished to avoid
99
        // double fishing
100
        mapping(address => bool) public fishedUsersAddresses;
101

102
        // Total claims per user stat
103
        mapping(address => uint256) public totalClaimsPerUser;
104

105
        bool public paused;
106

107
        // Emits when a withdraw has been succeded
108
        event WithdrawFromDao(uint256 prevBalance, uint256 newBalance);
109

110
        // Emits when a user is activated
111
        event ActivatedUser(address indexed account);
112

113
        // Emits when a fish has been succeded
114
        event InactiveUserFished(
115
                address indexed caller,
116
                address indexed fished_account,
117
                uint256 claimAmount
118
        );
119

120
        // Emits when finishing a `multi fish` execution.
121
        // Indicates the number of users from the given
122
        // array who actually been fished. it might not
123
        // be finished going over all the array if there
124
        // no gas left.
125
        event TotalFished(uint256 total);
126

127
        // Emits when daily ubi is calculated
128
        event UBICalculated(uint256 day, uint256 dailyUbi, uint256 blockNumber);
129

130
        //Emits whenever a new multi day cycle starts
131
        event UBICycleCalculated(
132
                uint256 day,
133
                uint256 pool,
134
                uint256 cycleLength,
135
                uint256 dailyUBIPool
136
        );
137

138
        event UBIClaimed(address indexed claimer, uint256 amount);
139
        event CycleLengthSet(uint256 newCycleLength);
140
        event DaySet(uint256 newDay);
141
        event ShouldWithdrawFromDAOSet(bool ShouldWithdrawFromDAO);
142

143
        /**
144
         * @dev Constructor
145
         * @param _ns the DAO
146
         * @param _firstClaimPool A pool for GD to give out to activated users
147
         * @param _maxInactiveDays Days of grace without claiming request
148
         */
149
        function initialize(
150
                INameService _ns,
151
                IFirstClaimPool _firstClaimPool,
152
                uint256 _maxInactiveDays
153
        ) public initializer {
154
                require(_maxInactiveDays > 0, "Max inactive days cannot be zero");
44✔
155
                setDAO(_ns);
42✔
156
                maxInactiveDays = _maxInactiveDays;
42✔
157
                firstClaimPool = _firstClaimPool;
42✔
158
                shouldWithdrawFromDAO = false;
42✔
159
                cycleLength = 90; //90 days
42✔
160
                iterationGasLimit = 185000; //token transfer cost under superfluid
42✔
161
                periodStart = (block.timestamp / (1 days)) * 1 days + 12 hours; //set start time to GMT noon
42✔
162
                startOfCycle = periodStart;
42✔
163
                useFirstClaimPool = address(_firstClaimPool) != address(0);
42✔
164
                minActiveUsers = 1000;
42✔
165
        }
166

167
        function setUseFirstClaimPool(bool _use) public {
168
                _onlyAvatar();
6✔
169
                useFirstClaimPool = _use;
6✔
170
        }
171

172
        /**
173
         * @dev function that gets the amount of people who claimed on the given day
174
         * @param day the day to get claimer count from, with 0 being the starting day
175
         * @return an integer indicating the amount of people who claimed that day
176
         */
177
        function getClaimerCount(uint256 day) public view returns (uint256) {
178
                return claimDay[day].amountOfClaimers;
6✔
179
        }
180

181
        /**
182
         * @dev function that gets the amount that was claimed on the given day
183
         * @param day the day to get claimer count from, with 0 being the starting day
184
         * @return an integer indicating the amount that has been claimed on the given day
185
         */
186
        function getClaimAmount(uint256 day) public view returns (uint256) {
187
                return claimDay[day].claimAmount;
6✔
188
        }
189

190
        /**
191
         * @dev function that gets count of claimers and amount claimed for the current day
192
         * @return the count of claimers and the amount claimed.
193
         */
194
        function getDailyStats() public view returns (uint256, uint256) {
195
                uint256 today = (block.timestamp - periodStart) / 1 days;
2✔
196
                return (getClaimerCount(today), getClaimAmount(today));
2✔
197
        }
198

199
        modifier requireStarted() {
200
                require(
104!
201
                        paused == false && periodStart > 0 && block.timestamp >= periodStart,
202
                        "not in periodStarted or paused"
203
                );
204
                _;
104✔
205
        }
206

207
        /**
208
         * @dev On a daily basis UBIScheme withdraws tokens from GoodDao.
209
         * Emits event with caller address and last day balance and the
210
         * updated balance.
211
         */
212
        function _withdrawFromDao() internal {
213
                IGoodDollar token = nativeToken();
24✔
214
                uint256 prevBalance = token.balanceOf(address(this));
24✔
215
                uint256 toWithdraw = token.balanceOf(address(avatar));
24✔
216
                dao.genericCall(
24✔
217
                        address(token),
218
                        abi.encodeWithSignature(
219
                                "transfer(address,uint256)",
220
                                address(this),
221
                                toWithdraw
222
                        ),
223
                        address(avatar),
224
                        0
225
                );
226
                uint256 newBalance = prevBalance + toWithdraw;
24✔
227
                require(
24!
228
                        newBalance == token.balanceOf(address(this)),
229
                        "DAO transfer has failed"
230
                );
231
                emit WithdrawFromDao(prevBalance, newBalance);
24✔
232
        }
233

234
        /**
235
         * @dev sets the ubi calculation cycle length
236
         * @param _newLength the new length in days
237
         */
238
        function setCycleLength(uint256 _newLength) public {
239
                _onlyAvatar();
6✔
240
                require(_newLength > 0, "cycle must be at least 1 day long");
4!
241
                cycleLength = _newLength;
4✔
242
                currentCycleLength = 0; //this will trigger a distributionFormula on next claim day
4✔
243
                emit CycleLengthSet(_newLength);
4✔
244
        }
245

246
        /**
247
         * @dev returns the day count since start of current cycle
248
         */
249
        function currentDayInCycle() public view returns (uint256) {
250
                return (block.timestamp - startOfCycle) / (1 days);
82✔
251
        }
252

253
        /**
254
         * @dev The claim calculation formula. Divide the daily pool with
255
         * the sum of the active users.
256
         * the daily balance is determined by dividing current pool by the cycle length
257
         * @return The amount of GoodDollar the user can claim
258
         */
259
        function distributionFormula() internal returns (uint256) {
260
                setDay();
88✔
261
                // on first day or once in 24 hrs calculate distribution
262
                //on day 0 all users receive from firstclaim pool
263
                if (currentDay != lastWithdrawDay || dailyUbi == 0) {
88✔
264
                        IGoodDollar token = nativeToken();
64✔
265
                        uint256 currentBalance = token.balanceOf(address(this));
64✔
266
                        //start early cycle if we can increase the daily UBI pool
267
                        bool shouldStartEarlyCycle = currentBalance / cycleLength >
64✔
268
                                dailyCyclePool;
269

270
                        if (
64✔
271
                                currentDayInCycle() >= currentCycleLength || shouldStartEarlyCycle
272
                        ) //start of cycle or first time
273
                        {
274
                                if (shouldWithdrawFromDAO) {
46✔
275
                                        _withdrawFromDao();
24✔
276
                                        currentBalance = token.balanceOf(address(this));
24✔
277
                                }
278
                                dailyCyclePool = currentBalance / cycleLength;
46✔
279
                                currentCycleLength = cycleLength;
46✔
280
                                startOfCycle = (block.timestamp / (1 hours)) * 1 hours; //start at a round hour
46✔
281
                                emit UBICycleCalculated(
46✔
282
                                        currentDay,
283
                                        currentBalance,
284
                                        cycleLength,
285
                                        dailyCyclePool
286
                                );
287
                        }
288

289
                        lastWithdrawDay = currentDay;
64✔
290
                        Funds storage funds = dailyUBIHistory[currentDay];
64✔
291
                        funds.hasWithdrawn = shouldWithdrawFromDAO;
64✔
292
                        funds.openAmount = currentBalance;
64✔
293
                        dailyUbi = dailyCyclePool / max(activeUsersCount, minActiveUsers);
64✔
294
                        //update minActiveUsers as claimers grow
295
                        minActiveUsers = max(activeUsersCount / 2, minActiveUsers);
64✔
296

297
                        emit UBICalculated(currentDay, dailyUbi, block.number);
64✔
298
                }
299

300
                return dailyUbi;
88✔
301
        }
302

303
        function max(uint256 a, uint256 b) private pure returns (uint256) {
304
                return a >= b ? a : b;
138✔
305
        }
306

307
        /**
308
         *@dev Sets the currentDay variable to amount of days
309
         * since start of contract.
310
         */
311
        function setDay() public {
312
                uint256 day = (block.timestamp - periodStart) / (1 days);
88✔
313
                if (day > currentDay) {
88✔
314
                        currentDay = day;
48✔
315
                        emit DaySet(day);
48✔
316
                }
317
        }
318

319
        /**
320
         * @dev Checks if the given account has claimed today
321
         * @param account to check
322
         * @return True if the given user has already claimed today
323
         */
324
        function hasClaimed(address account) public view returns (bool) {
325
                return claimDay[currentDay].hasClaimed[account];
58✔
326
        }
327

328
        /**
329
         * @dev Checks if the given account has been owned by a registered user.
330
         * @param _account to check
331
         * @return True for an existing user. False for a new user
332
         */
333
        function isNotNewUser(address _account) public view returns (bool) {
334
                if (lastClaimed[_account] > 0) {
188✔
335
                        // the sender is not registered
336
                        return true;
126✔
337
                }
338
                return false;
62✔
339
        }
340

341
        /**
342
         * @dev Checks weather the given address is owned by an active user.
343
         * A registered user is a user that claimed at least one time. An
344
         * active user is a user that claimed at least one time but claimed
345
         * at least one time in the last `maxInactiveDays` days. A user that
346
         * has not claimed for `maxInactiveDays` is an inactive user.
347
         * @param _account to check
348
         * @return True for active user
349
         */
350
        function isActiveUser(address _account) public view returns (bool) {
351
                uint256 _lastClaimed = lastClaimed[_account];
32✔
352
                if (isNotNewUser(_account)) {
32✔
353
                        uint256 daysSinceLastClaim = (block.timestamp - _lastClaimed) / (1 days);
30✔
354
                        if (daysSinceLastClaim < maxInactiveDays) {
30✔
355
                                // active user
356
                                return true;
12✔
357
                        }
358
                }
359
                return false;
20✔
360
        }
361

362
        /**
363
         * @dev Transfers `amount` DAO tokens to `account`. Updates stats
364
         * and emits an event in case of claimed.
365
         * In case that `isFirstTime` is true, it awards the user.
366
         * @param _account the account which recieves the funds
367
         * @param _target the recipient of funds
368
         * @param _amount the amount to transfer
369
         * @param _isClaimed true for claimed
370
         * @param _isFirstTime true for new user or fished user
371
         */
372
        function _transferTokens(
373
                address _account,
374
                address _target,
375
                uint256 _amount,
376
                bool _isClaimed,
377
                bool _isFirstTime
378
        ) internal {
379
                // updates the stats
380
                if (_isClaimed || _isFirstTime) {
82✔
381
                        //in case of fishing dont update stats
382
                        claimDay[currentDay].amountOfClaimers += 1;
70✔
383
                        claimDay[currentDay].hasClaimed[_account] = true;
70✔
384
                        lastClaimed[_account] = block.timestamp;
70✔
385
                        totalClaimsPerUser[_account] += 1;
70✔
386
                }
387

388
                // awards a new user or a fished user
389
                if (_isFirstTime) {
82✔
390
                        uint256 awardAmount = firstClaimPool.awardUser(_target);
28✔
391
                        claimDay[currentDay].claimAmount += awardAmount;
24✔
392
                        emit UBIClaimed(_account, awardAmount);
24✔
393
                } else {
394
                        if (_isClaimed) {
54✔
395
                                claimDay[currentDay].claimAmount += _amount;
42✔
396
                                emit UBIClaimed(_account, _amount);
42✔
397
                        }
398
                        IGoodDollar token = nativeToken();
54✔
399
                        require(token.transfer(_target, _amount), "claim transfer failed");
54!
400
                }
401
        }
402

403
        function estimateNextDailyUBI() public view returns (uint256) {
404
                uint256 currentBalance = nativeToken().balanceOf(address(this));
10✔
405
                //start early cycle if we can increase the daily UBI pool
406
                bool shouldStartEarlyCycle = currentBalance / cycleLength > dailyCyclePool;
10✔
407

408
                uint256 _dailyCyclePool = dailyCyclePool;
10✔
409
                uint256 _dailyUbi;
10✔
410
                if (
10!
411
                        currentDayInCycle() >= currentCycleLength || shouldStartEarlyCycle
412
                ) //start of cycle or first time
413
                {
414
                        _dailyCyclePool = currentBalance / cycleLength;
10✔
415
                }
416

417
                _dailyUbi = _dailyCyclePool / max(activeUsersCount, minActiveUsers);
10✔
418

419
                return _dailyUbi;
10✔
420
        }
421

422
        function checkEntitlement() public view returns (uint256) {
423
                return checkEntitlement(msg.sender);
12✔
424
        }
425

426
        /**
427
         * @dev Checks the amount which the sender address is eligible to claim for,
428
         * regardless if they have been whitelisted or not. In case the user is
429
         * active, then the current day must be equal to the actual day, i.e. claim
430
         * or fish has already been executed today.
431
         * @return The amount of GD tokens the address can claim.
432
         */
433
        function checkEntitlement(address _member) public view returns (uint256) {
434
                if (block.timestamp < periodStart) return 0; //not started
12✔
435
                // new user or inactive should recieve the first claim reward
436
                if (
10✔
437
                        useFirstClaimPool &&
438
                        (!isNotNewUser(_member) || fishedUsersAddresses[_member])
439
                ) {
440
                        return firstClaimPool.claimAmount();
4✔
441
                }
442

443
                // current day has already been updated which means
444
                // that the dailyUbi has been updated
445
                if (
6✔
446
                        currentDay == (block.timestamp - periodStart) / (1 days) && dailyUbi > 0
447
                ) {
448
                        return hasClaimed(_member) ? 0 : dailyUbi;
4✔
449
                }
450
                return estimateNextDailyUBI();
2✔
451
        }
452

453
        /**
454
         * @dev Function for claiming UBI. Requires contract to be active. Calls distributionFormula,
455
         * calculats the amount the account can claims, and transfers the amount to the account.
456
         * Emits the address of account and amount claimed.
457
         * @param _account The claimer account
458
         * @param _target recipient of funds
459
         * @return A bool indicating if UBI was claimed
460
         */
461
        function _claim(address _account, address _target) internal returns (bool) {
462
                // calculats the formula up today ie on day 0 there are no active users, on day 1 any user
463
                // (new or active) will trigger the calculation with the active users count of the day before
464
                // and so on. the new or inactive users that will become active today, will not take into account
465
                // within the calculation.
466
                uint256 newDistribution = distributionFormula();
74✔
467

468
                // active user which has not claimed today yet, ie user last claimed < today
469
                if (
74✔
470
                        isNotNewUser(_account) &&
471
                        !fishedUsersAddresses[_account] &&
472
                        !hasClaimed(_account)
473
                ) {
474
                        _transferTokens(_account, _target, newDistribution, true, false);
34✔
475
                        return true;
34✔
476
                } else if (!isNotNewUser(_account) || fishedUsersAddresses[_account]) {
40✔
477
                        // a unregistered or fished user
478
                        activeUsersCount += 1;
36✔
479
                        fishedUsersAddresses[_account] = false;
36✔
480
                        if (useFirstClaimPool) {
36✔
481
                                _transferTokens(_account, _target, 0, false, true);
28✔
482
                        } else {
483
                                _transferTokens(_account, _target, newDistribution, true, false);
8✔
484
                        }
485
                        emit ActivatedUser(_account);
32✔
486
                        return true;
32✔
487
                }
488
                return false;
4✔
489
        }
490

491
        /**
492
         * @dev Function for claiming UBI. Requires contract to be active and claimer to be whitelisted.
493
         * Calls distributionFormula, calculats the amount the caller can claim, and transfers the amount
494
         * to the caller. Emits the address of caller and amount claimed.
495
         * @return A bool indicating if UBI was claimed
496
         */
497
        function claim() public requireStarted returns (bool) {
498
                address whitelistedRoot = IIdentityV2(nameService.getAddress("IDENTITY"))
76✔
499
                        .getWhitelistedRoot(msg.sender);
500
                require(whitelistedRoot != address(0), "UBIScheme: not whitelisted");
76✔
501
                bool didClaim = _claim(whitelistedRoot, msg.sender);
74✔
502
                address claimerDistribution = nameService.getAddress("GDAO_CLAIMERS");
70✔
503
                if (didClaim && claimerDistribution != address(0)) {
70✔
504
                        ClaimersDistribution(claimerDistribution).updateClaim(whitelistedRoot);
12✔
505
                }
506
                return didClaim;
70✔
507
        }
508

509
        function _canFish(address _account) internal view returns (bool) {
510
                return
32✔
511
                        isNotNewUser(_account) &&
512
                        !isActiveUser(_account) &&
513
                        !fishedUsersAddresses[_account];
514
        }
515

516
        function _fish(address _account, bool _withTransfer) internal returns (bool) {
517
                fishedUsersAddresses[_account] = true; // marking the account as fished so it won't refish
14✔
518

519
                // making sure that the calculation will be with the correct number of active users in case
520
                // that the fisher is the first to make the calculation today
521
                uint256 newDistribution = distributionFormula();
14✔
522
                if (activeUsersCount > 0) {
14!
523
                        activeUsersCount -= 1;
14✔
524
                }
525
                if (_withTransfer)
14✔
526
                        _transferTokens(msg.sender, msg.sender, newDistribution, false, false);
8✔
527
                emit InactiveUserFished(msg.sender, _account, newDistribution);
14✔
528
                return true;
14✔
529
        }
530

531
        /**
532
         * @dev In order to update users from active to inactive, we give out incentive to people
533
         * to update the status of inactive users, this action is called "Fishing". Anyone can
534
         * send a tx to the contract to mark inactive users. The "fisherman" receives a reward
535
         * equal to the daily UBI (ie instead of the “fished” user). User that “last claimed” > 14
536
         * can be "fished" and made inactive (reduces active users count by one). Requires
537
         * contract to be active.
538
         * @param _account to fish
539
         * @return A bool indicating if UBI was fished
540
         */
541
        function fish(address _account) public requireStarted returns (bool) {
542
                // checking if the account exists. that's been done because that
543
                // will prevent trying to fish non-existence accounts in the system
544
                require(_canFish(_account), "can't fish");
20✔
545

546
                return _fish(_account, true);
8✔
547
        }
548

549
        /**
550
         * @dev executes `fish` with multiple addresses. emits the number of users from the given
551
         * array who actually been tried being fished.
552
         * @param _accounts to fish
553
         * @return A bool indicating if all the UBIs were fished
554
         */
555
        function fishMulti(address[] memory _accounts)
556
                public
557
                requireStarted
558
                returns (uint256)
559
        {
560
                uint256 i;
8✔
561
                uint256 bounty;
8✔
562

563
                for (; i < _accounts.length; ++i) {
8✔
564
                        if (gasleft() < iterationGasLimit) {
14✔
565
                                break;
2✔
566
                        }
567
                        if (_canFish(_accounts[i])) {
12✔
568
                                require(_fish(_accounts[i], false), "fish has failed");
6!
569
                                bounty += dailyUbi;
6✔
570
                        }
571
                }
572
                if (bounty > 0) {
8✔
573
                        _transferTokens(msg.sender, msg.sender, bounty, false, false);
4✔
574
                }
575
                emit TotalFished(i);
8✔
576
                return i;
8✔
577
        }
578

579
        /**
580
         * @dev Sets whether to also withdraw GD from avatar for UBI
581
         * @param _shouldWithdraw boolean if to withdraw
582
         */
583
        function setShouldWithdrawFromDAO(bool _shouldWithdraw) public {
584
                _onlyAvatar();
4✔
585
                shouldWithdrawFromDAO = _shouldWithdraw;
4✔
586
                emit ShouldWithdrawFromDAOSet(shouldWithdrawFromDAO);
4✔
587
        }
588

589
        function pause(bool _pause) public {
590
                _onlyAvatar();
×
591
                paused = _pause;
×
592
        }
593

594
        // function upgrade() public {
595
        //         _onlyAvatar();
596
        //         paused = true;
597
        //         activeUsersCount = 50000; //estimated
598
        //         dailyUbi = 0; //required so distributionformula will trigger
599
        //         cycleLength = 30;
600
        //         currentCycleLength = 0; //this will trigger a new cycle calculation in distribution formula
601
        //         startOfCycle = block.timestamp - 91 days; //this will trigger a new calculation in distributionFormula
602
        //         periodStart = 1646136000;
603
        //         maxDailyUBI = 50000;
604
        //         distributionFormula();
605
        //         emit CycleLengthSet(cycleLength);
606
        // }
607

608
        function setActiveUserCount(uint256 _activeUserCount) public {
609
                _onlyAvatar();
×
610
                activeUsersCount = _activeUserCount;
×
611
        }
612
}
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