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

stacks-network / stacks-core / 25801484257-1

13 May 2026 01:15PM UTC coverage: 85.648% (-0.06%) from 85.712%
25801484257-1

Pull #7183

github

2d7e6d
web-flow
Merge 420cb597a into 31276d071
Pull Request #7183: Fix problematic transaction handling

110 of 130 new or added lines in 7 files covered. (84.62%)

5464 existing lines in 98 files now uncovered.

188263 of 219809 relevant lines covered (85.65%)

18940648.33 hits per line

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

0.0
/stacks-node/src/main.rs
1
// Copyright (C) 2013-2020 Blockstack PBC, a public benefit corporation
2
// Copyright (C) 2020-2026 Stacks Open Internet Foundation
3
//
4
// This program is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// This program is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

17
#[macro_use]
18
extern crate serde_derive;
19
#[macro_use]
20
extern crate stacks_common;
21

22
extern crate clarity;
23
extern crate stacks;
24

25
#[allow(unused_imports)]
26
#[macro_use(o, slog_log, slog_trace, slog_debug, slog_info, slog_warn, slog_error)]
27
extern crate slog;
28

29
pub use stacks_common::util;
30
use stacks_common::util::hash::hex_bytes;
31

32
pub mod monitoring;
33

34
pub mod burnchains;
35
pub mod event_dispatcher;
36
pub mod genesis_data;
37
pub mod globals;
38
pub mod keychain;
39
pub mod nakamoto_node;
40
pub mod neon_node;
41
pub mod node;
42
pub mod operations;
43
pub mod run_loop;
44
pub mod syncctl;
45
pub mod tenure;
46

47
use std::collections::HashMap;
48
use std::{env, panic, process};
49

50
use backtrace::Backtrace;
51
use pico_args::Arguments;
52
use stacks::chainstate::burn::db::sortdb::SortitionDB;
53
use stacks::chainstate::burn::operations::leader_block_commit::RewardSetInfo;
54
use stacks::chainstate::coordinator::{get_next_recipients, OnChainRewardSetProvider};
55
use stacks::chainstate::stacks::address::PoxAddress;
56
use stacks::chainstate::stacks::db::blocks::DummyEventDispatcher;
57
use stacks::chainstate::stacks::db::StacksChainState;
58
use stacks::config::chain_data::MinerStats;
59
pub use stacks::config::{Config, ConfigFile};
60
use stacks_common::alloc_tracker::{tracking_allocator_installed, TrackingAllocator};
61
#[cfg(not(any(target_os = "macos", target_os = "windows", target_arch = "arm")))]
62
use tikv_jemallocator::Jemalloc;
63

64
pub use self::burnchains::{
65
    BitcoinRegtestController, BurnchainController, BurnchainTip, MocknetController,
66
};
67
pub use self::event_dispatcher::EventDispatcher;
68
pub use self::keychain::Keychain;
69
pub use self::node::{ChainTip, Node};
70
pub use self::run_loop::{helium, neon};
71
pub use self::tenure::Tenure;
72
use crate::neon_node::{BlockMinerThread, TipCandidate};
73
use crate::run_loop::boot_nakamoto;
74

75
#[cfg(not(any(target_os = "macos", target_os = "windows", target_arch = "arm")))]
76
#[global_allocator]
77
static GLOBAL: TrackingAllocator<Jemalloc> = TrackingAllocator { inner: Jemalloc };
78

79
#[cfg(any(target_os = "macos", target_os = "windows", target_arch = "arm"))]
80
#[global_allocator]
81
static GLOBAL: TrackingAllocator<std::alloc::System> = TrackingAllocator {
82
    inner: std::alloc::System,
83
};
84

85
/// Implmentation of `pick_best_tip` CLI option
86
fn cli_pick_best_tip(config_path: &str, at_stacks_height: Option<u64>) -> TipCandidate {
×
UNCOV
87
    info!("Loading config at path {config_path}");
×
88
    let config = match ConfigFile::from_path(config_path) {
×
UNCOV
89
        Ok(config_file) => Config::from_config_file(config_file, true).unwrap(),
×
90
        Err(e) => {
×
UNCOV
91
            warn!("Invalid config file: {e}");
×
UNCOV
92
            process::exit(1);
×
93
        }
94
    };
95
    let burn_db_path = config.get_burn_db_file_path();
×
96
    let stacks_chainstate_path = config.get_chainstate_path_str();
×
97
    let burnchain = config.get_burnchain();
×
UNCOV
98
    let (mut chainstate, _) = StacksChainState::open(
×
UNCOV
99
        config.is_mainnet(),
×
100
        config.burnchain.chain_id,
×
101
        &stacks_chainstate_path,
×
UNCOV
102
        Some(config.node.get_marf_opts()),
×
UNCOV
103
    )
×
UNCOV
104
    .unwrap();
×
105
    let mut sortdb = SortitionDB::open(
×
106
        &burn_db_path,
×
107
        false,
108
        burnchain.pox_constants,
×
109
        Some(config.node.get_marf_opts()),
×
110
    )
111
    .unwrap();
×
112

113
    let max_depth = config.miner.max_reorg_depth;
×
114

115
    // There could be more than one possible chain tip. Go find them.
UNCOV
116
    let stacks_tips = BlockMinerThread::load_candidate_tips(
×
UNCOV
117
        &mut sortdb,
×
118
        &mut chainstate,
×
119
        max_depth,
×
120
        at_stacks_height,
×
121
    );
122

123
    BlockMinerThread::inner_pick_best_tip(stacks_tips, HashMap::new()).unwrap()
×
124
}
×
125

126
/// Implementation of `get_miner_spend` CLI option
127
#[allow(clippy::incompatible_msrv)]
128
fn cli_get_miner_spend(
×
129
    config_path: &str,
×
130
    mine_start: Option<u64>,
×
UNCOV
131
    at_burnchain_height: Option<u64>,
×
132
) -> u64 {
×
133
    info!("Loading config at path {config_path}");
×
UNCOV
134
    let config = match ConfigFile::from_path(config_path) {
×
135
        Ok(config_file) => Config::from_config_file(config_file, true).unwrap(),
×
136
        Err(e) => {
×
137
            warn!("Invalid config file: {e}");
×
138
            process::exit(1);
×
139
        }
140
    };
141
    let keychain = Keychain::default(config.node.seed.clone());
×
UNCOV
142
    let burn_db_path = config.get_burn_db_file_path();
×
143
    let stacks_chainstate_path = config.get_chainstate_path_str();
×
UNCOV
144
    let burnchain = config.get_burnchain();
×
UNCOV
145
    let (mut chainstate, _) = StacksChainState::open(
×
146
        config.is_mainnet(),
×
147
        config.burnchain.chain_id,
×
148
        &stacks_chainstate_path,
×
149
        Some(config.node.get_marf_opts()),
×
150
    )
×
151
    .unwrap();
×
152
    let mut sortdb = SortitionDB::open(
×
UNCOV
153
        &burn_db_path,
×
154
        true,
UNCOV
155
        burnchain.pox_constants.clone(),
×
156
        Some(config.node.get_marf_opts()),
×
157
    )
UNCOV
158
    .unwrap();
×
159
    let tip = if let Some(at_burnchain_height) = at_burnchain_height {
×
UNCOV
160
        let tip = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn()).unwrap();
×
UNCOV
161
        let ih = sortdb.index_handle(&tip.sortition_id);
×
162
        ih.get_block_snapshot_by_height(at_burnchain_height)
×
163
            .unwrap()
×
164
            .unwrap()
×
165
    } else {
166
        SortitionDB::get_canonical_burn_chain_tip(sortdb.conn()).unwrap()
×
167
    };
168

169
    let no_dispatcher: Option<&DummyEventDispatcher> = None;
×
170
    let recipients = get_next_recipients(
×
171
        &tip,
×
172
        &mut chainstate,
×
UNCOV
173
        &mut sortdb,
×
174
        &burnchain,
×
175
        &OnChainRewardSetProvider(no_dispatcher),
×
176
    )
177
    .unwrap();
×
178

179
    let commit_outs = if !burnchain.is_in_prepare_phase(tip.block_height + 1) {
×
UNCOV
180
        RewardSetInfo::into_commit_outs(recipients, config.is_mainnet())
×
181
    } else {
182
        vec![PoxAddress::standard_burn_address(config.is_mainnet())]
×
183
    };
184

185
    let spend_amount = BlockMinerThread::get_mining_spend_amount(
×
UNCOV
186
        &config,
×
187
        &keychain,
×
188
        &burnchain,
×
189
        &sortdb,
×
190
        &commit_outs,
×
UNCOV
191
        mine_start.unwrap_or(tip.block_height),
×
192
        at_burnchain_height,
×
193
        |burn_block_height| {
×
194
            let sortdb = SortitionDB::open(
×
195
                &burn_db_path,
×
196
                true,
197
                burnchain.pox_constants.clone(),
×
UNCOV
198
                Some(config.node.get_marf_opts()),
×
199
            )
200
            .unwrap();
×
201
            let Some(miner_stats) = config.get_miner_stats() else {
×
UNCOV
202
                return 0.0;
×
203
            };
UNCOV
204
            let Ok(active_miners_and_commits) =
×
UNCOV
205
                MinerStats::get_active_miners(&sortdb, Some(burn_block_height))
×
206
                    .inspect_err(|e| warn!("Failed to get active miners: {e:?}"))
×
207
            else {
208
                return 0.0;
×
209
            };
UNCOV
210
            if active_miners_and_commits.is_empty() {
×
211
                warn!("No active miners detected; using config file burn_fee_cap");
×
UNCOV
212
                return 0.0;
×
213
            }
×
214

215
            let active_miners: Vec<_> = active_miners_and_commits
×
216
                .iter()
×
217
                .map(|(miner, _cmt)| miner.as_str())
×
218
                .collect();
×
219

220
            info!("Active miners: {active_miners:?}");
×
221

222
            let Ok(unconfirmed_block_commits) = miner_stats
×
223
                .get_unconfirmed_commits(burn_block_height + 1, &active_miners)
×
UNCOV
224
                .inspect_err(|e| warn!("Failed to find unconfirmed block-commits: {e}"))
×
225
            else {
UNCOV
226
                return 0.0;
×
227
            };
228

229
            let unconfirmed_miners_and_amounts: Vec<(String, u64)> = unconfirmed_block_commits
×
230
                .iter()
×
231
                .map(|cmt| (format!("{}", &cmt.apparent_sender), cmt.burn_fee))
×
232
                .collect();
×
233

234
            info!("Found unconfirmed block-commits: {unconfirmed_miners_and_amounts:?}");
×
235

236
            let (spend_dist, _total_spend) = MinerStats::get_spend_distribution(
×
UNCOV
237
                &active_miners_and_commits,
×
238
                &unconfirmed_block_commits,
×
UNCOV
239
                &commit_outs,
×
UNCOV
240
            );
×
241
            let win_probs = if config.miner.fast_rampup {
×
242
                // look at spends 6+ blocks in the future
UNCOV
243
                MinerStats::get_future_win_distribution(
×
244
                    &active_miners_and_commits,
×
245
                    &unconfirmed_block_commits,
×
UNCOV
246
                    &commit_outs,
×
247
                )
248
            } else {
249
                // look at the current spends
250
                let Ok(unconfirmed_burn_dist) = miner_stats
×
251
                    .get_unconfirmed_burn_distribution(
×
252
                        &burnchain,
×
253
                        &sortdb,
×
254
                        &active_miners_and_commits,
×
255
                        unconfirmed_block_commits,
×
UNCOV
256
                        &commit_outs,
×
257
                        at_burnchain_height,
×
258
                    )
UNCOV
259
                    .inspect_err(|e| warn!("Failed to get unconfirmed burn distribution: {e:?}"))
×
260
                else {
261
                    return 0.0;
×
262
                };
263

UNCOV
264
                MinerStats::burn_dist_to_prob_dist(&unconfirmed_burn_dist)
×
265
            };
266

UNCOV
267
            info!("Unconfirmed spend distribution: {spend_dist:?}");
×
268
            info!(
×
269
                "Unconfirmed win probabilities (fast_rampup={}): {win_probs:?}",
270
                config.miner.fast_rampup
271
            );
272

UNCOV
273
            let miner_addrs = BlockMinerThread::get_miner_addrs(&config, &keychain);
×
UNCOV
274
            let win_prob = miner_addrs
×
UNCOV
275
                .iter()
×
UNCOV
276
                .find_map(|x| win_probs.get(x))
×
277
                .copied()
×
278
                .unwrap_or(0.0);
×
279

UNCOV
280
            info!(
×
281
                "This miner's win probability at {} is {win_prob}",
282
                tip.block_height
283
            );
UNCOV
284
            win_prob
×
UNCOV
285
        },
×
UNCOV
286
        |_burn_block_height, _win_prob| {},
×
287
    );
UNCOV
288
    spend_amount
×
UNCOV
289
}
×
290

291
/// If the previous session was terminated before all the pending events had been sent,
292
/// the DB will still contain them. Work through that before doing anything new.
293
/// Pending events for observers that are no longer registered will be discarded.
UNCOV
294
fn send_pending_event_payloads(conf: &Config) {
×
295
    // This dispatcher gets a queue size of 0 to ensure that it blocks. Technically
296
    // process_pending_payloads() always blocks; this is just an additional safeguard.
297
    let mut event_dispatcher =
×
UNCOV
298
        EventDispatcher::new_with_custom_queue_size(conf.get_working_dir(), 0);
×
299
    for observer in &conf.events_observers {
×
300
        event_dispatcher.register_observer(observer);
×
301
    }
×
UNCOV
302
    event_dispatcher.process_pending_payloads();
×
303
}
×
304

305
fn main() {
×
306
    panic::set_hook(Box::new(|panic_info| {
×
UNCOV
307
        error!("Process abort due to thread panic: {panic_info}");
×
308
        let bt = Backtrace::new();
×
309
        error!("Panic backtrace: {bt:?}");
×
310

311
        // force a core dump
312
        #[cfg(unix)]
313
        {
314
            let pid = process::id();
×
UNCOV
315
            eprintln!("Dumping core for pid {}", std::process::id());
×
316

317
            use libc::{kill, SIGQUIT};
318

319
            // *should* trigger a core dump, if you run `ulimit -c unlimited` first!
320
            unsafe { kill(pid.try_into().unwrap(), SIGQUIT) };
×
321
        }
322

323
        // just in case
324
        process::exit(1);
×
325
    }));
326

327
    let mut args = Arguments::from_env();
×
UNCOV
328
    let subcommand = args.subcommand().unwrap().unwrap_or_default();
×
329

330
    info!("{}", version());
×
331

UNCOV
332
    let mine_start: Option<u64> = args
×
UNCOV
333
        .opt_value_from_str("--mine-at-height")
×
334
        .expect("Failed to parse --mine-at-height argument");
×
335

336
    if let Some(mine_start) = mine_start {
×
337
        info!("Will begin mining once Stacks chain has synced to height >= {mine_start}");
×
UNCOV
338
    }
×
339

340
    let config_file = match subcommand.as_str() {
×
341
        "mocknet" => {
×
UNCOV
342
            args.finish();
×
UNCOV
343
            ConfigFile::mocknet()
×
344
        }
345
        "helium" => {
×
346
            args.finish();
×
347
            ConfigFile::helium()
×
348
        }
349
        "testnet" => {
×
350
            args.finish();
×
351
            ConfigFile::xenon()
×
352
        }
353
        "mainnet" => {
×
UNCOV
354
            args.finish();
×
UNCOV
355
            ConfigFile::mainnet()
×
356
        }
357
        "check-config" => {
×
358
            let config_path: String = args.value_from_str("--config").unwrap();
×
359
            args.finish();
×
UNCOV
360
            info!("Loading config at path {config_path}");
×
361
            let config_file = match ConfigFile::from_path(&config_path) {
×
362
                Ok(config_file) => {
×
363
                    debug!("Loaded config file: {config_file:?}");
×
364
                    config_file
×
365
                }
366
                Err(e) => {
×
UNCOV
367
                    warn!("Invalid config file: {e}");
×
UNCOV
368
                    process::exit(1);
×
369
                }
370
            };
371
            match Config::from_config_file(config_file, true) {
×
372
                Ok(_) => {
373
                    info!("Loaded config!");
×
374
                    process::exit(0);
×
375
                }
376
                Err(e) => {
×
377
                    warn!("Invalid config: {e}");
×
378
                    process::exit(1);
×
379
                }
380
            };
381
        }
382
        "start" => {
×
UNCOV
383
            let config_path: String = args.value_from_str("--config").unwrap();
×
384
            args.finish();
×
UNCOV
385
            info!("Loading config at path {config_path}");
×
386
            match ConfigFile::from_path(&config_path) {
×
UNCOV
387
                Ok(config_file) => config_file,
×
388
                Err(e) => {
×
UNCOV
389
                    warn!("Invalid config file: {e}");
×
390
                    process::exit(1);
×
391
                }
392
            }
393
        }
394
        "version" => {
×
395
            println!("{}", &version());
×
396
            return;
×
397
        }
398
        "key-for-seed" => {
×
399
            let seed = {
×
400
                let config_path: Option<String> = args.opt_value_from_str("--config").unwrap();
×
UNCOV
401
                if let Some(config_path) = config_path {
×
402
                    let conf = Config::from_config_file(
×
403
                        ConfigFile::from_path(&config_path).unwrap(),
×
404
                        true,
405
                    )
406
                    .unwrap();
×
UNCOV
407
                    args.finish();
×
408
                    conf.node.seed
×
409
                } else {
410
                    let free_args = args.finish();
×
UNCOV
411
                    let seed_hex = free_args
×
UNCOV
412
                        .first()
×
413
                        .expect("`wif-for-seed` must be passed either a config file via the `--config` flag or a hex seed string");
×
414
                    hex_bytes(seed_hex.to_str().unwrap())
×
UNCOV
415
                        .expect("Seed should be a hex encoded string")
×
416
                }
417
            };
418
            let keychain = Keychain::default(seed);
×
419
            println!(
×
420
                "Hex formatted secret key: {}",
421
                keychain.generate_op_signer().get_secret_key_as_hex()
×
422
            );
UNCOV
423
            println!(
×
424
                "WIF formatted secret key: {}",
UNCOV
425
                keychain.generate_op_signer().get_secret_key_as_wif()
×
426
            );
427
            return;
×
428
        }
UNCOV
429
        "pick-best-tip" => {
×
430
            let config_path: String = args.value_from_str("--config").unwrap();
×
UNCOV
431
            let at_stacks_height: Option<u64> =
×
432
                args.opt_value_from_str("--at-stacks-height").unwrap();
×
433
            args.finish();
×
434

435
            let best_tip = cli_pick_best_tip(&config_path, at_stacks_height);
×
436
            println!("Best tip is {best_tip:?}");
×
437
            process::exit(0);
×
438
        }
439
        "get-spend-amount" => {
×
440
            let config_path: String = args.value_from_str("--config").unwrap();
×
441
            let at_burnchain_height: Option<u64> =
×
442
                args.opt_value_from_str("--at-bitcoin-height").unwrap();
×
443
            args.finish();
×
444

445
            let spend_amount = cli_get_miner_spend(&config_path, mine_start, at_burnchain_height);
×
446
            println!("Will spend {spend_amount}");
×
447
            process::exit(0);
×
448
        }
449
        _ => {
450
            print_help();
×
451
            return;
×
452
        }
453
    };
454

455
    let conf = match Config::from_config_file(config_file, true) {
×
UNCOV
456
        Ok(conf) => conf,
×
457
        Err(e) => {
×
UNCOV
458
            warn!("Invalid config: {e}");
×
UNCOV
459
            process::exit(1);
×
460
        }
461
    };
462

UNCOV
463
    debug!("node configuration {:?}", &conf.node);
×
UNCOV
464
    debug!("burnchain configuration {:?}", &conf.burnchain);
×
UNCOV
465
    debug!("connection configuration {:?}", &conf.connection_options);
×
466

UNCOV
467
    send_pending_event_payloads(&conf);
×
468

UNCOV
469
    let num_round: u64 = 0; // Infinite number of rounds
×
470

UNCOV
471
    if conf.burnchain.mode == "helium" || conf.burnchain.mode == "mocknet" {
×
UNCOV
472
        let mut run_loop = helium::RunLoop::new(conf);
×
UNCOV
473
        if let Err(e) = run_loop.start(num_round) {
×
UNCOV
474
            warn!("Helium runloop exited: {e}");
×
UNCOV
475
        }
×
UNCOV
476
    } else if conf.burnchain.mode == "neon"
×
UNCOV
477
        || conf.burnchain.mode == "nakamoto-neon"
×
UNCOV
478
        || conf.burnchain.mode == "xenon"
×
UNCOV
479
        || conf.burnchain.mode == "krypton"
×
UNCOV
480
        || conf.burnchain.mode == "mainnet"
×
481
    {
UNCOV
482
        if conf.miner.max_assembly_mem_bytes > 0
×
UNCOV
483
            || conf.connection_options.block_proposal_max_tx_mem_bytes > 0
×
484
        {
UNCOV
485
            if !tracking_allocator_installed() {
×
UNCOV
486
                panic!("Tracking allocator must be installed to set a memory limit");
×
UNCOV
487
            }
×
UNCOV
488
        }
×
UNCOV
489
        let mut run_loop = boot_nakamoto::BootRunLoop::new(conf).unwrap();
×
UNCOV
490
        run_loop.start(None, 0);
×
UNCOV
491
    } else {
×
UNCOV
492
        println!("Burnchain mode '{}' not supported", conf.burnchain.mode);
×
UNCOV
493
    }
×
UNCOV
494
}
×
495

UNCOV
496
fn version() -> String {
×
UNCOV
497
    stacks::version_string("stacks-node", option_env!("STACKS_NODE_VERSION"))
×
UNCOV
498
}
×
499

UNCOV
500
fn print_help() {
×
UNCOV
501
    let argv: Vec<_> = env::args().collect();
×
502

UNCOV
503
    eprintln!(
×
504
        "\
505
{} <SUBCOMMAND>
506
Run a stacks-node.
507

508
USAGE:
509
stacks-node <SUBCOMMAND>
510

511
SUBCOMMANDS:
512

513
mainnet\t\tStart a node that will join and stream blocks from the public mainnet.
514

515
mocknet\t\tStart a node based on a fast local setup emulating a burnchain. Ideal for smart contract development.
516

517
helium\t\tStart a node based on a local setup relying on a local instance of bitcoind.
518
\t\tThe following bitcoin.conf is expected:
519
\t\t  chain=regtest
520
\t\t  disablewallet=0
521
\t\t  txindex=1
522
\t\t  server=1
523
\t\t  rpcuser=helium
524
\t\t  rpcpassword=helium
525

526
testnet\t\tStart a node that will join and stream blocks from the public testnet, relying on Bitcoin Testnet.
527

528
start\t\tStart a node with a config of your own. Can be used for joining a network, starting new chain, etc.
529
\t\tArguments:
530
\t\t  --config: path of the config (such as https://github.com/blockstack/stacks-blockchain/blob/master/sample/conf/testnet-follower-conf.toml).
531
\t\tExample:
532
\t\t  stacks-node start --config /path/to/config.toml
533

534
check-config\t\tValidates the config file without starting up the node. Uses same arguments as start subcommand.
535

536
version\t\tDisplay information about the current version and our release cycle.
537

538
key-for-seed\tOutput the associated secret key for a burnchain signer created with a given seed.
539
\t\tCan be passed a config file for the seed via the `--config <file>` option *or* by supplying the hex seed on
540
\t\tthe command line directly.
541

542
replay-mock-mining\tReplay mock mined blocks from <dir>
543
\t\tArguments:
544
\t\t  --path: path to directory of mock mined blocks
545
\t\t  --config: path to the config file
546

547
help\t\tDisplay this help.
548

549
OPTIONAL ARGUMENTS:
550

551
\t\t--mine-at-height=<height>: optional argument for a miner to not attempt mining until Stacks block has sync'ed to <height>
552

UNCOV
553
", argv[0]);
×
UNCOV
554
}
×
555

556
#[cfg(test)]
557
pub mod tests;
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

© 2026 Coveralls, Inc