Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions .cargo/mutants.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
additional_cargo_args = ["--all-features"]
gitignore = true
examine_globs = ["payjoin/src/core/uri/*.rs", "payjoin/src/core/receive/**/*.rs", "payjoin/src/core/send/**/*.rs"]
exclude_globs = []
exclude_re = [
Expand All @@ -9,7 +10,32 @@ exclude_re = [
".*Error",

# ---------------------Crate-specific exculsions---------------------
# Receive
# src/receive/v1/mod.rs
# Timeout loops
# src/receive/v1/mod.rs
"interleave_shuffle", # Replacing index += 1 with index *= 1 in a loop causes a timeout due to an infinite loop

# Trivial mutations
# These exlusions are allowing code blocks to run with artithmetic invloving zero and as a result do nothing
# payjoin/src/core/receive/v1/mod.rs
"replace > with >= in ProvisionalProposal::apply_fee",
# payjoin/src/core/send/mod.rs
"replace < with <= in PsbtContext::check_outputs",
"replace > with >= in PsbtContext::check_fees",
# payjoin/src/core/send/mod.rs
"replace < with <= in SenderBuilder<'a>::build_recommended", # clamping the fee contribution when the fee equals to the recommended fee does not do anything

# Async SystemTime comparison
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may want to figure out a way to mock SystemTime in tests. I've used freezegun in Python in the past, I wonder if a similar tool exists the Rust ecosystem. We may even be able to mock it with a Trait, per Claude:

use std::time::SystemTime;

trait TimeProvider {
    fn now(&self) -> SystemTime;
}

struct RealTimeProvider;

impl TimeProvider for RealTimeProvider {
    fn now(&self) -> SystemTime {
        SystemTime::now()
    }
}

struct MockTimeProvider {
    time: SystemTime,
}

impl TimeProvider for MockTimeProvider {
    fn now(&self) -> SystemTime {
        self.time
    }
}

// Your code uses the trait
fn process_with_timestamp<T: TimeProvider>(time_provider: &T) -> SystemTime {
    time_provider.now()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{Duration, UNIX_EPOCH};

    #[test]
    fn test_with_mock_time() {
        let mock_time = UNIX_EPOCH + Duration::from_secs(1000);
        let provider = MockTimeProvider { time: mock_time };
        
        let result = process_with_timestamp(&provider);
        assert_eq!(result, mock_time);
    }
}

This seems reasonable to leave as a follow-up, maybe to be addressed alongside #893.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh interesting, I got hung up on trying to fit the tokio::test pause() method in but it just wasn't working.

# checking if the system time is equal to the expiry is difficult to reasonably test
# payjoin/src/core/receive/v2/mod.rs
"replace < with <= in Receiver<Initialized>::apply_unchecked_from_payload",
"replace > with >= in Receiver<Initialized>::create_poll_request",
"replace > with >= in extract_err_req",
# payjoin/src/core/send/v2/mod.rs
"replace > with >= in Sender<WithReplyKey>::create_v2_post_request",

# TODO exclusions
# payjoin/src/core/receive/v1/mod.rs
"replace > with >= in WantsInputs::avoid_uih", # This mutation I am unsure about whether or not it is a trivial mutant and have not decided on how the best way to approach testing it is
# payjoin/src/core/send/mod.rs
"replace match guard proposed_txout.script_pubkey == original_output.script_pubkey with true in PsbtContext::check_outputs", # This non-deterministic mutation has a possible test to catch it
]
2 changes: 1 addition & 1 deletion .github/workflows/cron-weekly-mutants.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
- uses: actions/checkout@v4
- uses: taiki-e/install-action@v2
with:
tool: cargo-mutants@25.0.1
tool: cargo-mutants@25.2.2
- run: cargo mutants --in-place --no-shuffle
- uses: actions/upload-artifact@v4
if: always()
Expand Down
17 changes: 17 additions & 0 deletions payjoin/src/core/receive/multiparty/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,23 @@ mod test {
Ok(())
}

#[test]
fn test_build_multiparty() -> Result<(), BoxError> {
let proposal_one = v2::UncheckedProposal {
v1: multiparty_proposals()[0].clone(),
context: SHARED_CONTEXT.clone(),
};
let proposal_two = v2::UncheckedProposal {
v1: multiparty_proposals()[1].clone(),
context: SHARED_CONTEXT_TWO.clone(),
};
let mut multiparty = UncheckedProposalBuilder::new();
multiparty.add(v2::Receiver { state: proposal_one })?;
multiparty.add(v2::Receiver { state: proposal_two })?;
assert!(multiparty.build().is_ok());
Ok(())
}

#[test]
fn test_duplicate_context_multiparty() -> Result<(), BoxError> {
let proposal_one = v2::UncheckedProposal {
Expand Down
41 changes: 29 additions & 12 deletions payjoin/src/core/receive/v1/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -973,19 +973,25 @@ pub(crate) mod test {
}

#[test]
fn unchecked_proposal_below_min_fee() {
fn unchecked_proposal_min_fee() {
let proposal = unchecked_proposal_from_test_vector();

let min_fee_rate = proposal.psbt_fee_rate().expect("Feerate calculation should not fail");
let _ = proposal
.clone()
.check_broadcast_suitability(Some(min_fee_rate), |_| Ok(true))
.expect("Broadcast suitability check with appropriate min_fee_rate should succeed");
assert_eq!(proposal.clone().psbt_fee_rate().unwrap(), min_fee_rate);

let min_fee_rate = FeeRate::MAX;
match proposal.clone().check_broadcast_suitability(Some(min_fee_rate), |_| Ok(true)) {
Err(ReplyableError::Payload(PayloadError(InternalPayloadError::PsbtBelowFeeRate(
proposal_rate,
min_rate,
)))) => {
assert_eq!(proposal_rate, proposal.clone().psbt_fee_rate().unwrap());
assert_eq!(min_rate, min_fee_rate);
},
_ => panic!("Broadcast suitability check should fail due to being below the min fee rate or unexpected error type"),
};
let expected_err = ReplyableError::Payload(PayloadError(
InternalPayloadError::PsbtBelowFeeRate(proposal.psbt_fee_rate().unwrap(), min_fee_rate),
));
let proposal_below_min_fee = proposal
.clone()
.check_broadcast_suitability(Some(min_fee_rate), |_| Ok(true))
.expect_err("Broadcast suitability with min_fee_rate below minimum should fail");
assert_eq!(proposal_below_min_fee.to_string(), expected_err.to_string());
}

#[test]
Expand Down Expand Up @@ -1193,6 +1199,17 @@ pub(crate) mod test {
[wants_outputs.change_vout]
.script_pubkey;

let output_value =
wants_outputs.original_psbt.unsigned_tx.output[wants_outputs.change_vout].value;
let outputs = vec![TxOut { value: output_value, script_pubkey: script_pubkey.clone() }];
let unchanged_amount =
wants_outputs.clone().replace_receiver_outputs(outputs, script_pubkey.as_script());
assert!(
unchanged_amount.is_ok(),
"Not touching the receiver output amount is always allowed"
);
assert_ne!(wants_outputs.payjoin_psbt, unchanged_amount.unwrap().payjoin_psbt);

let output_value =
wants_outputs.original_psbt.unsigned_tx.output[wants_outputs.change_vout].value
+ Amount::ONE_SAT;
Expand All @@ -1201,7 +1218,7 @@ pub(crate) mod test {
wants_outputs.clone().replace_receiver_outputs(outputs, script_pubkey.as_script());
assert!(
increased_amount.is_ok(),
"Increasing the receiver output amount should always be allowed"
"Increasing the receiver output amount is always allowed"
);
assert_ne!(wants_outputs.payjoin_psbt, increased_amount.unwrap().payjoin_psbt);

Expand Down
10 changes: 10 additions & 0 deletions payjoin/src/core/send/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,16 @@ mod test {
fee_contribution.err(),
Some(InternalBuildSenderError::FeeOutputValueLowerThanFeeContribution)
);
// This tests the max allowed fee contribution of the given input amount
let fee_contribution = determine_fee_contribution(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some explanatory comments on these new edge cases wouldn't hurt. I'm guessing this is testing the maximum allowed fee contribution based on the input value?

&PARSED_ORIGINAL_PSBT,
Script::from_bytes(&<Vec<u8> as FromHex>::from_hex(
"0014b60943f60c3ee848828bdace7474a92e81f3fcdd",
)?),
Some((Amount::from_sat(95983068), None)),
false,
);
assert!(fee_contribution.is_ok());
Ok(())
}

Expand Down
14 changes: 11 additions & 3 deletions payjoin/src/core/send/v1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,11 +350,14 @@ mod test {

#[test]
fn process_response_invalid_utf8() {
// In UTF-8, 0xF0 represents the start of a 4-byte sequence, so 0xF0 by itself is invalid
let invalid_utf8 = &[0xF0];
// A PSBT expects an exact match so padding with null bytes for the from_str method is
// invalid
let mut invalid_utf8_padding = PAYJOIN_PROPOSAL.as_bytes().to_vec();
invalid_utf8_padding
.extend(std::iter::repeat(0x00).take(MAX_CONTENT_LENGTH - invalid_utf8_padding.len()));

let ctx = create_v1_context();
let response = ctx.process_response(invalid_utf8);
let response = ctx.process_response(&invalid_utf8_padding);
match response {
Ok(_) => panic!("Invalid UTF-8 should have caused an error"),
Err(error) => match error {
Expand Down Expand Up @@ -389,4 +392,9 @@ mod test {
},
}
}

#[test]
fn test_non_witness_input_weight_const() {
assert_eq!(NON_WITNESS_INPUT_WEIGHT, bitcoin::Weight::from_wu(160));
}
}