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

tari-project / tari / 17834551818

18 Sep 2025 04:04PM UTC coverage: 60.651% (-0.3%) from 60.975%
17834551818

push

github

SWvheerden
chore: new release v5.1.0-pre.1

74207 of 122350 relevant lines covered (60.65%)

293003.61 hits per line

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

70.65
/base_layer/node_components/src/blocks/block_header.rs
1
// Copyright 2018 The Tari Project
2
//
3
// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
4
// following conditions are met:
5
//
6
// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following
7
// disclaimer.
8
//
9
// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the
10
// following disclaimer in the documentation and/or other materials provided with the distribution.
11
//
12
// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote
13
// products derived from this software without specific prior written permission.
14
//
15
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
16
// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
18
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
19
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
20
// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
21
// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
22
//
23
// Portions of this file were originally copyrighted (c) 2018 The Grin Developers, issued under the Apache License,
24
// Version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0.
25

26
//! Blockchain state
27
//!
28
//! For [technical reasons](https://www.tari.com/2019/07/15/tari-protocol-discussion-42.html), the commitment in
29
//! block headers commits to the entire TXO set, rather than just UTXOs using a merkle mountain range.
30
//! However, it's really important to commit to the actual UTXO set at a given height and have the ability to
31
//! distinguish between spent and unspent outputs in the MMR.
32
//!
33
//! To solve this we commit to the MMR root in the header of each block. This will give as an immutable state at the
34
//! given height. But this does not provide us with a UTXO, only TXO set. To identify UTXOs we create a roaring bit map
35
//! of all the UTXO's positions inside of the MMR leaves. We hash this, and combine it with the MMR root, to provide us
36
//! with a TXO set that will represent the UTXO state of the chain at the given height:
37
//! state = Hash(Hash(mmr_root)|| Hash(roaring_bitmap))
38
//! This hash is called the UTXO merkle root, and is used as the output_mr
39

40
use std::{
41
    cmp::Ordering,
42
    convert::TryFrom,
43
    fmt::{Display, Error, Formatter},
44
};
45

46
use blake2::Blake2b;
47
use borsh::{BorshDeserialize, BorshSerialize};
48
use chrono::{DateTime, Utc};
49
use digest::consts::U32;
50
use serde::{Deserialize, Serialize};
51
use tari_common_types::types::{BlockHash, FixedHash, PrivateKey};
52
use tari_hashing::BlocksHashDomain;
53
use tari_transaction_components::{
54
    consensus::DomainSeparatedConsensusHasher,
55
    tari_proof_of_work::{PowAlgorithm, PowError, ProofOfWork},
56
};
57
use tari_utilities::{epoch_time::EpochTime, hex::Hex};
58
use thiserror::Error;
59

60
use crate::blocks::{BlockBuilder, NewBlockHeaderTemplate};
61
#[derive(Debug, Error)]
62
pub enum BlockHeaderValidationError {
63
    #[error("The Genesis block header is incorrectly chained")]
64
    ChainedGenesisBlockHeader,
65
    #[error("Incorrect Genesis block header")]
66
    IncorrectGenesisBlockHeader,
67
    #[error("Header does not form a valid chain")]
68
    InvalidChaining,
69
    #[error("Invalid timestamp received on the header: {0}")]
70
    InvalidTimestamp(String),
71
    #[error("Invalid timestamp future time limit received on the header")]
72
    InvalidTimestampFutureTimeLimit,
73
    #[error("Invalid Proof of work for the header: {0}")]
74
    ProofOfWorkError(#[from] PowError),
75
    #[error("Monero seed hash too old")]
76
    OldSeedHash,
77
    #[error("Monero blocks must have a nonce of 0")]
78
    InvalidNonce,
79
    #[error("Incorrect height: Expected {expected} but got {actual}")]
80
    InvalidHeight { expected: u64, actual: u64 },
81
    #[error("Incorrect previous hash: Expected {expected} but got {actual}")]
82
    InvalidPreviousHash { expected: BlockHash, actual: BlockHash },
83
    #[error("Invalid block POW algorithm: {0}")]
84
    InvalidPowAlgorithm(String),
85
}
86

87
/// The BlockHeader contains all the metadata for the block, including proof of work, a link to the previous block
88
/// and the transaction kernels.
89
#[derive(Serialize, Deserialize, Clone, Debug, BorshSerialize, BorshDeserialize)]
×
90
pub struct BlockHeader {
91
    /// Version of the block
92
    pub version: u16,
93
    /// Height of this block since the genesis block (height 0)
94
    pub height: u64,
95
    /// Hash of the block previous to this in the chain.
96
    pub prev_hash: BlockHash,
97
    /// Timestamp at which the block was built.
98
    pub timestamp: EpochTime,
99
    /// This is the Merkle root of the inputs in this block
100
    pub input_mr: FixedHash,
101
    /// This is the UTXO merkle root of the outputs on the blockchain
102
    pub output_mr: FixedHash,
103
    /// This is the block_output_mr
104
    pub block_output_mr: FixedHash,
105
    /// The size (number  of leaves) of the output and range proof MMRs at the time of this header
106
    pub output_smt_size: u64,
107
    /// This is the MMR root of the kernels
108
    pub kernel_mr: FixedHash,
109
    /// The number of MMR leaves in the kernel MMR
110
    pub kernel_mmr_size: u64,
111
    /// Sum of kernel offsets for all kernels in this block.
112
    pub total_kernel_offset: PrivateKey,
113
    /// Sum of script offsets for all kernels in this block.
114
    pub total_script_offset: PrivateKey,
115
    /// Merkle root of all active validator node.
116
    pub validator_node_mr: FixedHash,
117
    /// The number of validator node hashes
118
    pub validator_node_size: u64,
119
    /// Proof of work summary
120
    pub pow: ProofOfWork,
121
    /// Nonce increment used to mine this block.
122
    pub nonce: u64,
123
}
124

125
impl BlockHeader {
126
    /// Create a new, default header with the given version.
127
    pub fn new(blockchain_version: u16) -> BlockHeader {
402✔
128
        BlockHeader {
402✔
129
            version: blockchain_version,
402✔
130
            height: 0,
402✔
131
            prev_hash: FixedHash::zero(),
402✔
132
            timestamp: EpochTime::now(),
402✔
133
            output_mr: FixedHash::zero(),
402✔
134
            block_output_mr: FixedHash::zero(),
402✔
135
            output_smt_size: 0,
402✔
136
            kernel_mr: FixedHash::zero(),
402✔
137
            kernel_mmr_size: 0,
402✔
138
            input_mr: FixedHash::zero(),
402✔
139
            total_kernel_offset: PrivateKey::default(),
402✔
140
            total_script_offset: PrivateKey::default(),
402✔
141
            nonce: 0,
402✔
142
            pow: ProofOfWork::default(),
402✔
143
            validator_node_mr: FixedHash::zero(),
402✔
144
            validator_node_size: 0,
402✔
145
        }
402✔
146
    }
402✔
147

148
    pub fn hash(&self) -> FixedHash {
7,056✔
149
        DomainSeparatedConsensusHasher::<BlocksHashDomain, Blake2b<U32>>::new("block_header")
7,056✔
150
            .chain(&self.mining_hash())
7,056✔
151
            .chain(&self.pow)
7,056✔
152
            .chain(&self.nonce)
7,056✔
153
            .finalize()
7,056✔
154
            .into()
7,056✔
155
    }
7,056✔
156

157
    /// Create a new block header using relevant data from the previous block. The height is incremented by one, the
158
    /// previous block hash is set, the timestamp is set to the current time, and the kernel/output mmr sizes are set to
159
    /// the previous block. All other fields, including proof of work are set to defaults.
160
    pub fn from_previous(prev: &BlockHeader) -> BlockHeader {
251✔
161
        let prev_hash = prev.hash();
251✔
162
        BlockHeader {
251✔
163
            version: prev.version,
251✔
164
            height: prev.height + 1,
251✔
165
            prev_hash,
251✔
166
            timestamp: EpochTime::now(),
251✔
167
            output_mr: FixedHash::zero(),
251✔
168
            output_smt_size: prev.output_smt_size,
251✔
169
            block_output_mr: FixedHash::zero(),
251✔
170
            kernel_mr: FixedHash::zero(),
251✔
171
            kernel_mmr_size: prev.kernel_mmr_size,
251✔
172
            input_mr: FixedHash::zero(),
251✔
173
            total_kernel_offset: PrivateKey::default(),
251✔
174
            total_script_offset: PrivateKey::default(),
251✔
175
            nonce: 0,
251✔
176
            pow: ProofOfWork::default(),
251✔
177
            validator_node_mr: FixedHash::zero(),
251✔
178
            validator_node_size: prev.validator_node_size,
251✔
179
        }
251✔
180
    }
251✔
181

182
    pub fn into_builder(self) -> BlockBuilder {
382✔
183
        BlockBuilder::new(self.version).with_header(self)
382✔
184
    }
382✔
185

186
    /// Given a slice of headers, calculate the maximum, minimum and average periods between them.
187
    /// Expects the slice of headers to be ordered from youngest to oldest, but will reverse them if not.
188
    /// This function always allocates a vec of the slice length. This is in case it needs to reverse the list.
189
    pub fn timing_stats(headers: &[BlockHeader]) -> (u64, u64, f64) {
6✔
190
        if headers.len() < 2 {
6✔
191
            (0, 0, 0.0)
2✔
192
        } else {
193
            let mut headers = headers.to_vec();
4✔
194

4✔
195
            // ensure the slice is in reverse order
4✔
196
            let ordering = headers
4✔
197
                .first()
4✔
198
                .expect("Already checked")
4✔
199
                .timestamp
4✔
200
                .cmp(&headers.last().expect("Already checked").timestamp);
4✔
201
            if ordering == Ordering::Less {
4✔
202
                headers.reverse();
1✔
203
            }
3✔
204

205
            let last_ts = headers.first().expect("Already checked").timestamp;
4✔
206
            let first_ts = headers.last().expect("Already checked").timestamp;
4✔
207

4✔
208
            let (max, min) = headers.windows(2).fold((0u64, u64::MAX), |(max, min), next| {
8✔
209
                let dt = match next
8✔
210
                    .first()
8✔
211
                    .expect("Cannot fail")
8✔
212
                    .timestamp
8✔
213
                    .checked_sub(next.get(1).expect("Cannot fail").timestamp)
8✔
214
                {
215
                    Some(delta) => delta.as_u64(),
7✔
216
                    None => 0u64,
1✔
217
                };
218
                (max.max(dt), min.min(dt))
8✔
219
            });
8✔
220

221
            let dt = match last_ts.checked_sub(first_ts) {
4✔
222
                Some(t) => t,
4✔
223
                None => 0.into(),
×
224
            };
225
            let n = headers.len() - 1;
4✔
226
            let avg = dt.as_u64() as f64 / n as f64;
4✔
227

4✔
228
            (max, min, avg)
4✔
229
        }
230
    }
6✔
231

232
    /// Provides a mining hash of the header, used for the mining.
233
    /// This differs from the normal hash by not hashing the nonce and kernel pow.
234
    pub fn mining_hash(&self) -> FixedHash {
10,015✔
235
        DomainSeparatedConsensusHasher::<BlocksHashDomain, Blake2b<U32>>::new("block_header")
10,015✔
236
            .chain(&self.version)
10,015✔
237
            .chain(&self.height)
10,015✔
238
            .chain(&self.prev_hash)
10,015✔
239
            .chain(&self.timestamp)
10,015✔
240
            .chain(&self.input_mr)
10,015✔
241
            .chain(&self.output_mr)
10,015✔
242
            .chain(&self.output_smt_size)
10,015✔
243
            .chain(&self.block_output_mr)
10,015✔
244
            .chain(&self.kernel_mr)
10,015✔
245
            .chain(&self.kernel_mmr_size)
10,015✔
246
            .chain(&self.total_kernel_offset)
10,015✔
247
            .chain(&self.total_script_offset)
10,015✔
248
            .chain(&self.validator_node_mr)
10,015✔
249
            .chain(&self.validator_node_size)
10,015✔
250
            .finalize()
10,015✔
251
            .into()
10,015✔
252
    }
10,015✔
253

254
    pub fn merge_mining_hash(&self) -> FixedHash {
15✔
255
        // let mining_hash = self.mining_hash();
15✔
256
        // At a later stage if we want to allow other coins to be merge mined, we can add a prefix
15✔
257
        // mining_hash[0..4].copy_from_slice(b"TARI"); // Maybe put this in a `const`
15✔
258
        self.mining_hash()
15✔
259
    }
15✔
260

261
    #[inline]
262
    pub fn timestamp(&self) -> EpochTime {
3,467✔
263
        self.timestamp
3,467✔
264
    }
3,467✔
265

266
    pub fn to_chrono_datetime(&self) -> DateTime<Utc> {
×
267
        DateTime::<Utc>::from_timestamp(i64::try_from(self.timestamp.as_u64()).unwrap_or(i64::MAX), 0)
×
268
            .unwrap_or(DateTime::<Utc>::MAX_UTC)
×
269
    }
×
270

271
    #[inline]
272
    pub fn pow_algo(&self) -> PowAlgorithm {
935✔
273
        self.pow.pow_algo
935✔
274
    }
935✔
275
}
276

277
impl From<NewBlockHeaderTemplate> for BlockHeader {
278
    fn from(header_template: NewBlockHeaderTemplate) -> Self {
×
279
        Self {
×
280
            version: header_template.version,
×
281
            height: header_template.height,
×
282
            prev_hash: header_template.prev_hash,
×
283
            timestamp: EpochTime::now(),
×
284
            output_mr: FixedHash::zero(),
×
285
            block_output_mr: FixedHash::zero(),
×
286
            output_smt_size: 0,
×
287
            kernel_mr: FixedHash::zero(),
×
288
            kernel_mmr_size: 0,
×
289
            input_mr: FixedHash::zero(),
×
290
            total_kernel_offset: header_template.total_kernel_offset,
×
291
            total_script_offset: header_template.total_script_offset,
×
292
            nonce: 0,
×
293
            pow: header_template.pow,
×
294
            validator_node_mr: FixedHash::zero(),
×
295
            validator_node_size: 0,
×
296
        }
×
297
    }
×
298
}
299

300
impl From<tari_transaction_components::rpc::models::BlockHeader> for BlockHeader {
301
    fn from(header: tari_transaction_components::rpc::models::BlockHeader) -> Self {
×
302
        Self {
×
303
            version: header.version,
×
304
            height: header.height,
×
305
            prev_hash: header.prev_hash,
×
306
            timestamp: header.timestamp,
×
307
            input_mr: header.input_mr,
×
308
            output_mr: header.output_mr,
×
309
            block_output_mr: header.block_output_mr,
×
310
            output_smt_size: header.output_smt_size,
×
311
            kernel_mr: header.kernel_mr,
×
312
            kernel_mmr_size: header.kernel_mmr_size,
×
313
            total_kernel_offset: header.total_kernel_offset,
×
314
            total_script_offset: header.total_script_offset,
×
315
            validator_node_mr: header.validator_node_mr,
×
316
            validator_node_size: header.validator_node_size,
×
317
            pow: header.pow,
×
318
            nonce: header.nonce,
×
319
        }
×
320
    }
×
321
}
322

323
impl From<BlockHeader> for tari_transaction_components::rpc::models::BlockHeader {
324
    fn from(header: BlockHeader) -> Self {
×
325
        Self {
×
326
            hash: header.hash(),
×
327
            version: header.version,
×
328
            height: header.height,
×
329
            prev_hash: header.prev_hash,
×
330
            timestamp: header.timestamp,
×
331
            input_mr: header.input_mr,
×
332
            output_mr: header.output_mr,
×
333
            block_output_mr: header.block_output_mr,
×
334
            output_smt_size: header.output_smt_size,
×
335
            kernel_mr: header.kernel_mr,
×
336
            kernel_mmr_size: header.kernel_mmr_size,
×
337
            total_kernel_offset: header.total_kernel_offset,
×
338
            total_script_offset: header.total_script_offset,
×
339
            validator_node_mr: header.validator_node_mr,
×
340
            validator_node_size: header.validator_node_size,
×
341
            pow: header.pow,
×
342
            nonce: header.nonce,
×
343
        }
×
344
    }
×
345
}
346

347
impl PartialEq for BlockHeader {
348
    fn eq(&self, other: &Self) -> bool {
197✔
349
        self.hash() == other.hash()
197✔
350
    }
197✔
351
}
352

353
impl Eq for BlockHeader {}
354

355
impl Display for BlockHeader {
356
    fn fmt(&self, fmt: &mut Formatter<'_>) -> Result<(), Error> {
×
357
        writeln!(
×
358
            fmt,
×
359
            "Version: {}\nBlock height: {}\nPrevious block hash: {}\nTimestamp: {}",
×
360
            self.version,
×
361
            self.height,
×
362
            self.prev_hash,
×
363
            self.to_chrono_datetime().to_rfc2822()
×
364
        )?;
×
365
        writeln!(
×
366
            fmt,
×
367
            "Merkle roots:\nInputs: {},\nOutputs: {} ({})\n\nKernels: {} ({})",
×
368
            self.input_mr, self.output_mr, self.output_smt_size, self.kernel_mr, self.kernel_mmr_size
×
369
        )?;
×
370
        writeln!(fmt, "ValidatorNode: {}\n", self.validator_node_mr.to_hex())?;
×
371
        writeln!(
×
372
            fmt,
×
373
            "Total offset: {}\nTotal script offset: {}\nNonce: {}\nProof of work:\n{}",
×
374
            self.total_kernel_offset.to_hex(),
×
375
            self.total_script_offset.to_hex(),
×
376
            self.nonce,
×
377
            self.pow
×
378
        )
×
379
    }
×
380
}
381

382
#[cfg(test)]
383
mod test {
384
    use chrono::NaiveDate;
385

386
    use super::*;
387

388
    fn get_header() -> BlockHeader {
1✔
389
        let mut header = BlockHeader::new(2);
1✔
390

1✔
391
        #[allow(clippy::cast_sign_loss)]
1✔
392
        let epoch_secs = DateTime::<Utc>::from_naive_utc_and_offset(
1✔
393
            NaiveDate::from_ymd_opt(2000, 1, 1)
1✔
394
                .unwrap()
1✔
395
                .and_hms_opt(1, 1, 1)
1✔
396
                .unwrap(),
1✔
397
            Utc,
1✔
398
        )
1✔
399
        .timestamp() as u64;
1✔
400
        header.timestamp = EpochTime::from_secs_since_epoch(epoch_secs);
1✔
401
        header.pow.pow_algo = PowAlgorithm::Sha3x;
1✔
402
        header
1✔
403
    }
1✔
404

405
    #[test]
406
    fn from_previous() {
1✔
407
        let mut h1 = get_header();
1✔
408
        h1.nonce = 7600;
1✔
409
        assert_eq!(h1.height, 0, "Default block height");
1✔
410
        let hash1 = h1.hash();
1✔
411
        let h2 = BlockHeader::from_previous(&h1);
1✔
412
        assert_eq!(h2.height, h1.height + 1, "Incrementing block height");
1✔
413
        assert!(h2.timestamp > h1.timestamp, "Timestamp");
1✔
414
        assert_eq!(h2.prev_hash, hash1, "Previous hash");
1✔
415
    }
1✔
416

417
    #[test]
418
    fn test_timing_stats() {
1✔
419
        let headers = vec![500, 350, 300, 210, 100u64]
1✔
420
            .into_iter()
1✔
421
            .map(|t| BlockHeader {
5✔
422
                timestamp: t.into(),
5✔
423
                ..BlockHeader::new(0)
5✔
424
            })
5✔
425
            .collect::<Vec<BlockHeader>>();
1✔
426
        let (max, min, avg) = BlockHeader::timing_stats(&headers);
1✔
427
        assert_eq!(max, 150);
1✔
428
        assert_eq!(min, 50);
1✔
429
        let error_margin = f64::EPSILON; // Use an epsilon for comparison of floats
1✔
430
        assert!((avg - 100f64).abs() < error_margin);
1✔
431
    }
1✔
432

433
    #[test]
434
    fn timing_negative_blocks() {
1✔
435
        let headers = vec![150, 90, 100u64]
1✔
436
            .into_iter()
1✔
437
            .map(|t| BlockHeader {
3✔
438
                timestamp: t.into(),
3✔
439
                ..BlockHeader::new(0)
3✔
440
            })
3✔
441
            .collect::<Vec<BlockHeader>>();
1✔
442
        let (max, min, avg) = BlockHeader::timing_stats(&headers);
1✔
443
        assert_eq!(max, 60);
1✔
444
        assert_eq!(min, 0);
1✔
445
        let error_margin = f64::EPSILON; // Use machine epsilon for comparison of floats
1✔
446
        assert!((avg - 25f64).abs() < error_margin);
1✔
447
    }
1✔
448

449
    #[test]
450
    fn timing_empty_list() {
1✔
451
        let (max, min, avg) = BlockHeader::timing_stats(&[]);
1✔
452
        assert_eq!(max, 0);
1✔
453
        assert_eq!(min, 0);
1✔
454
        let error_margin = f64::EPSILON; // Use machine epsilon for comparison of floats
1✔
455
        assert!((avg - 0f64).abs() < error_margin);
1✔
456
    }
1✔
457

458
    #[test]
459
    fn timing_one_block() {
1✔
460
        let header = BlockHeader {
1✔
461
            timestamp: 0.into(),
1✔
462
            ..BlockHeader::new(0)
1✔
463
        };
1✔
464

1✔
465
        let (max, min, avg) = BlockHeader::timing_stats(&[header]);
1✔
466
        assert_eq!((max, min), (0, 0));
1✔
467
        assert!((avg - 0f64).abs() < f64::EPSILON);
1✔
468
    }
1✔
469

470
    #[test]
471
    fn timing_two_blocks() {
1✔
472
        let headers = vec![150, 90]
1✔
473
            .into_iter()
1✔
474
            .map(|t| BlockHeader {
2✔
475
                timestamp: t.into(),
2✔
476
                ..BlockHeader::new(0)
2✔
477
            })
2✔
478
            .collect::<Vec<_>>();
1✔
479
        let (max, min, avg) = BlockHeader::timing_stats(&headers);
1✔
480
        assert_eq!(max, 60);
1✔
481
        assert_eq!(min, 60);
1✔
482
        let error_margin = f64::EPSILON; // Use machine epsilon for comparison of floats
1✔
483
        assert!((avg - 60f64).abs() < error_margin);
1✔
484
    }
1✔
485

486
    #[test]
487
    fn timing_wrong_order() {
1✔
488
        let headers = vec![90, 150]
1✔
489
            .into_iter()
1✔
490
            .map(|t| BlockHeader {
2✔
491
                timestamp: t.into(),
2✔
492
                ..BlockHeader::new(0)
2✔
493
            })
2✔
494
            .collect::<Vec<_>>();
1✔
495
        let (max, min, avg) = BlockHeader::timing_stats(&headers);
1✔
496
        assert_eq!(max, 60);
1✔
497
        assert_eq!(min, 60);
1✔
498
        let error_margin = f64::EPSILON; // Use machine epsilon for comparison of floats
1✔
499
        assert!((avg - 60f64).abs() < error_margin);
1✔
500
    }
1✔
501
}
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