diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d8e9f6..e203719 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,38 @@ jobs: - name: Build wasm run: cargo build --target wasm32-unknown-unknown --release + coverage: + name: Code coverage + runs-on: ubuntu-latest + defaults: + run: + working-directory: intent_settlement + steps: + - uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: intent_settlement + - name: Install cargo-tarpaulin + run: cargo install cargo-tarpaulin + - name: Generate coverage report + run: cargo tarpaulin --out Xml --timeout 300 --exclude-files tests + - name: Check coverage threshold + run: | + COVERAGE=$(grep -oP 'line-rate="\K[^"]*' cobertura.xml | head -1) + THRESHOLD=0.70 + if (( $(echo "$COVERAGE < $THRESHOLD" | bc -l) )); then + echo "Coverage $COVERAGE is below threshold $THRESHOLD" + exit 1 + fi + echo "Coverage $COVERAGE meets minimum threshold of $THRESHOLD" + - name: Upload coverage report + uses: codecov/codecov-action@v3 + with: + files: ./intent_settlement/cobertura.xml + fail_ci_if_error: false + audit: name: Dependency audit runs-on: ubuntu-latest diff --git a/README.md b/README.md index a9c8ced..93c61c0 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ **Soroban smart contracts for [Vortex Protocol](https://github.com/vortex-protocol) — intent-based cross-chain swaps settled on Stellar.** [![CI](https://github.com/vortex-protocol/vortex-contract/actions/workflows/ci.yml/badge.svg)](https://github.com/vortex-protocol/vortex-contract/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/vortex-protocol/vortex-contracts/branch/main/graph/badge.svg)](https://codecov.io/gh/vortex-protocol/vortex-contracts) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](./LICENSE) This repository holds the on-chain logic that guarantees settlement: intent diff --git a/intent_settlement/src/test.rs b/intent_settlement/src/test.rs index 74e0521..32d981a 100644 --- a/intent_settlement/src/test.rs +++ b/intent_settlement/src/test.rs @@ -234,6 +234,83 @@ fn pause_does_not_block_slashing_an_already_accepted_intent() { assert_eq!(c.get_solver(&ctx.solver).unwrap().fills_failed, 1); } +#[test] +fn pause_blocks_fill_intent() { + let ctx = setup(); + let c = ctx.client(); + ctx.register_solver(); + let id = ctx.submit(); + c.accept_intent(&ctx.solver, &id); + + c.pause(); + + let fee = FILL * 5 / 10_000; + ctx.dst_admin().mint(&ctx.solver, &(FILL + fee)); + let res = c.try_fill_intent(&ctx.solver, &id, &FILL); + assert_eq!(res, Err(Ok(Error::ContractPaused.into()))); +} + +#[test] +fn pause_does_not_block_cancel_intent() { + let ctx = setup(); + let c = ctx.client(); + let id = ctx.submit(); + + c.pause(); + assert!(c.is_paused()); + + // cancel_intent should succeed even while paused + c.cancel_intent(&ctx.user, &id); + assert!(c.get_intent(&id).unwrap().state == IntentState::Cancelled); +} + +#[test] +fn pause_blocks_submit_accept_fill_but_allows_cancel_and_slash() { + let ctx = setup(); + let c = ctx.client(); + ctx.register_solver(); + + // Submit and accept before pausing + let id = ctx.submit(); + c.accept_intent(&ctx.solver, &id); + + // Submit another intent to test that it can't be accepted while paused + let id2 = ctx.submit(); + + c.pause(); + assert!(c.is_paused()); + + // Test blocked operations + let deadline: Option = None; + let res = c.try_submit_intent( + &ctx.user, + &String::from_str(&ctx.env, "ethereum"), + &String::from_str(&ctx.env, "0xdef"), + &SRC_AMT, + &ctx.dst_token, + &MIN_DST, + &deadline, + ); + assert_eq!(res, Err(Ok(Error::ContractPaused.into()))); + + let res = c.try_accept_intent(&ctx.solver, &id2); + assert_eq!(res, Err(Ok(Error::ContractPaused.into()))); + + let fee = FILL * 5 / 10_000; + ctx.dst_admin().mint(&ctx.solver, &(FILL + fee)); + let res = c.try_fill_intent(&ctx.solver, &id, &FILL); + assert_eq!(res, Err(Ok(Error::ContractPaused.into()))); + + // Test allowed operations + let id3 = ctx.submit(); + c.cancel_intent(&ctx.user, &id3); + assert!(c.get_intent(&id3).unwrap().state == IntentState::Cancelled); + + ctx.pass_time(FILL_WINDOW + 1); + c.slash_solver(&id); + assert_eq!(c.get_solver(&ctx.solver).unwrap().fills_failed, 1); +} + // ─── Solver registration ──────────────────────────────────────────────────────── #[test] @@ -610,6 +687,64 @@ fn dst_allowlist_removal_blocks_previously_allowed_token() { assert_eq!(res, Err(Ok(Error::DstTokenNotAllowed.into()))); } +#[test] +fn dst_allowlist_toggled_mid_lifecycle_does_not_retroactively_affect_open_intent() { + let ctx = setup(); + let c = ctx.client(); + // Allowlist disabled by default, so any token is accepted + assert!(!c.is_dst_allowlist_enabled()); + let id = ctx.submit(); + + // Enable allowlist without adding the dst_token + c.set_dst_allowlist_enabled(&true); + assert!(!c.is_dst_token_allowed(&ctx.dst_token)); + + // The already-open intent should still be readable/usable + let intent = c.get_intent(&id).unwrap(); + assert!(intent.state == IntentState::Open); + + // But new submissions with non-allowed tokens fail + let deadline: Option = None; + let res = c.try_submit_intent( + &ctx.user, + &String::from_str(&ctx.env, "ethereum"), + &String::from_str(&ctx.env, "0xabc"), + &SRC_AMT, + &ctx.dst_token, + &MIN_DST, + &deadline, + ); + assert_eq!(res, Err(Ok(Error::DstTokenNotAllowed.into()))); +} + +#[test] +fn dst_allowlist_can_be_re_enabled_to_accept_previously_blocked_tokens() { + let ctx = setup(); + let c = ctx.client(); + + // Enable allowlist and block the token + c.set_dst_allowlist_enabled(&true); + assert!(!c.is_dst_token_allowed(&ctx.dst_token)); + + let deadline: Option = None; + let res = c.try_submit_intent( + &ctx.user, + &String::from_str(&ctx.env, "ethereum"), + &String::from_str(&ctx.env, "0xabc"), + &SRC_AMT, + &ctx.dst_token, + &MIN_DST, + &deadline, + ); + assert_eq!(res, Err(Ok(Error::DstTokenNotAllowed.into()))); + + // Disable the allowlist + c.set_dst_allowlist_enabled(&false); + + // Now submissions with any token succeed again + ctx.submit(); +} + #[test] fn submit_intent_zero_amount_fails() { let ctx = setup(); @@ -793,6 +928,8 @@ fn cannot_cancel_someone_elses_intent() { let stranger = Address::generate(&ctx.env); let res = ctx.client().try_cancel_intent(&stranger, &id); assert_eq!(res, Err(Ok(Error::Unauthorized.into()))); + // State remains unchanged after rejected cancellation attempt + assert!(ctx.client().get_intent(&id).unwrap().state == IntentState::Open); } // ─── Slashing ───────────────────────────────────────────────────────────────────