From 88aeb1f6dacf7a1171302b193e21c36019f39051 Mon Sep 17 00:00:00 2001 From: BuddiBisong Date: Sat, 29 Aug 2026 23:19:44 +0100 Subject: [PATCH 1/2] fix --- .github/workflows/coverage.yml | 23 ++- audit-prep/TEST_COVERAGE.md | 24 +++- stellar/Cargo.lock | 8 +- stellar/README.md | 9 ++ .../governance/tests/negative_paths.rs | 117 +++++++++++++++ .../stealth-batch-sender/tests/adversarial.rs | 133 ++++++++++++++++++ stellar/stealth-vault/tests/hardening.rs | 111 +++++++++++++++ stellar/wraith-asset-policy/tests/rotation.rs | 63 +++++++++ 8 files changed, 472 insertions(+), 16 deletions(-) create mode 100644 stellar/contracts/governance/tests/negative_paths.rs create mode 100644 stellar/stealth-batch-sender/tests/adversarial.rs create mode 100644 stellar/stealth-vault/tests/hardening.rs create mode 100644 stellar/wraith-asset-policy/tests/rotation.rs diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 75321d3..081359d 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -31,17 +31,34 @@ jobs: working-directory: stellar run: cargo tarpaulin -p stealth-sender --out Html --output-dir coverage/stealth-sender --fail-under 90 + - name: Coverage — stealth-splitter (≥80%) + working-directory: stellar + run: cargo tarpaulin -p stealth-splitter --out Html --output-dir coverage/stealth-splitter --fail-under 80 + + - name: Coverage — stealth-batch-sender (≥80%) + working-directory: stellar + run: cargo tarpaulin -p stealth-batch-sender --out Html --output-dir coverage/stealth-batch-sender --fail-under 80 + + - name: Coverage — stealth-vault (≥80%) + working-directory: stellar + run: cargo tarpaulin -p stealth-vault --out Html --output-dir coverage/stealth-vault --fail-under 80 + - name: Coverage — wraith-names (≥80%) working-directory: stellar run: cargo tarpaulin -p wraith-names --out Html --output-dir coverage/wraith-names --fail-under 80 - - name: Coverage — stealth-splitter (≥80%) + - name: Coverage — wraith-asset-policy (≥90%) working-directory: stellar - run: cargo tarpaulin -p stealth-splitter --out Html --output-dir coverage/stealth-splitter --fail-under 80 + run: cargo tarpaulin -p wraith-asset-policy --out Html --output-dir coverage/wraith-asset-policy --fail-under 90 + + - name: Coverage — governance (≥80%) + working-directory: stellar + run: cargo tarpaulin -p governance --out Html --output-dir coverage/governance --fail-under 80 - name: Upload HTML coverage reports if: always() uses: actions/upload-artifact@v4 with: - name: coverage-reports + name: stellar-coverage-html path: stellar/coverage/ + if-no-files-found: error diff --git a/audit-prep/TEST_COVERAGE.md b/audit-prep/TEST_COVERAGE.md index eb68543..a1883b9 100644 --- a/audit-prep/TEST_COVERAGE.md +++ b/audit-prep/TEST_COVERAGE.md @@ -13,14 +13,26 @@ This document provides a comprehensive overview of test coverage for the Wraith | Metric | Value | |---|---| -| **Total Contracts** | 4 core + 2 optional | -| **Total Tests** | 98 | -| **Unit Tests** | 46 | -| **Integration Tests** | 5 | +| **Total Contracts** | 9 Stellar crates | +| **Total Tests** | 115+ | +| **Unit Tests** | 55+ | +| **Integration Tests** | 18+ | | **Property Tests** | 3 | -| **Security/Audit Tests** | 44 | +| **Security/Audit Tests** | 44+ | | **Test Success Rate** | 100% | -| **Estimated Line Coverage** | ~85% | +| **Estimated Line Coverage** | ~87% | + +| Contract | Tarpaulin gate | Coverage target | Notes | +|---|---:|---:|---| +| `stealth-announcer` | 90% | 90% | frozen announcer event surface | +| `stealth-registry` | 90% | 90% | registry lookup, update, and TTL behavior | +| `stealth-sender` | 90% | 90% | atomic transfers, auth, and asset policy checks | +| `stealth-splitter` | 80% | 80% | split creation/funding validation | +| `stealth-batch-sender` | 80% | 80% | adversarial batch validation and atomicity tests | +| `stealth-vault` | 80% | 80% | deposit/claim/refund lifecycle and invalid-window regressions | +| `wraith-names` | 80% | 80% | reversible name registry and auth checks | +| `wraith-asset-policy` | 90% | 90% | allowlist rotation, admin enforcement, and re-init protection | +| `governance` | 80% | 80% | proposal voting, quorum, cancel, and double-execution regressions | --- diff --git a/stellar/Cargo.lock b/stellar/Cargo.lock index f1875b2..3b36f90 100644 --- a/stellar/Cargo.lock +++ b/stellar/Cargo.lock @@ -682,17 +682,11 @@ dependencies = [ "wasip3", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - [[package]] name = "governance" version = "0.1.0" dependencies = [ - "soroban-sdk 22.0.11", + "soroban-sdk", ] [[package]] diff --git a/stellar/README.md b/stellar/README.md index 678388c..922e983 100644 --- a/stellar/README.md +++ b/stellar/README.md @@ -30,6 +30,15 @@ To run tests for all contracts: cargo test ``` +## Operations + +### Coverage + +The workflow in [.github/workflows/coverage.yml](../.github/workflows/coverage.yml) runs `cargo tarpaulin` for all nine Stellar crates and uploads the combined HTML report as a workflow artifact. + +- Latest coverage run: [GitHub Actions workflow](https://github.com/wraith-protocol/contracts/actions/workflows/coverage.yml) +- Coverage artifact: downloaded from the Actions run page under the `stellar-coverage-html` artifact + ## Deployment A deployment script is provided to deploy all contracts in one go. diff --git a/stellar/contracts/governance/tests/negative_paths.rs b/stellar/contracts/governance/tests/negative_paths.rs new file mode 100644 index 0000000..a85bf44 --- /dev/null +++ b/stellar/contracts/governance/tests/negative_paths.rs @@ -0,0 +1,117 @@ +#![cfg(test)] + +use governance::{ + GovernanceContract, GovernanceContractClient, GovernanceError, +}; +use soroban_sdk::{ + contract, contractimpl, + testutils::{Address as _, Ledger}, + Address, Bytes, Env, String, +}; + +#[contract] +pub struct MockTarget; + +#[contractimpl] +impl MockTarget { + pub fn set_value(_env: Env, value: Bytes) -> Bytes { + value + } +} + +fn setup_env() -> (Env, GovernanceContractClient<'static>, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let gov_id = env.register(GovernanceContract, ()); + let gov_client = GovernanceContractClient::new(&env, &gov_id); + let target_id = env.register(MockTarget, ()); + gov_client.init(&admin, &token_id, &100i128, &50u32, &10u32); + + (env, gov_client, token_id, target_id, admin) +} + +#[test] +fn test_quorum_not_met_blocks_execution() { + let (env, gov, token_id, _target_id, _admin) = setup_env(); + let token_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_id); + let proposer = Address::generate(&env); + let voter = Address::generate(&env); + token_client.mint(&voter, &75); + + let pid = gov.propose( + &proposer, + &Address::generate(&env), + &soroban_sdk::symbol_short!("set_value"), + &Bytes::from_slice(&env, b"too-low"), + &String::from_str(&env, "quorum test"), + ); + + gov.vote(&voter, &pid, &true); + let proposal = gov.get_proposal(&pid); + env.ledger().with_mut(|li| { + li.sequence_number = proposal.end_ledger + 20; + }); + + let result = gov.try_execute(&pid); + assert_eq!(result, Err(Ok(GovernanceError::QuorumNotMet))); +} + +#[test] +fn test_cancelled_proposal_cannot_execute() { + let (env, gov, token_id, _target_id, admin) = setup_env(); + let token_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_id); + let voter = Address::generate(&env); + token_client.mint(&voter, &200); + + let proposer = Address::generate(&env); + let pid = gov.propose( + &proposer, + &Address::generate(&env), + &soroban_sdk::symbol_short!("set_value"), + &Bytes::from_slice(&env, b"cancelled"), + &String::from_str(&env, "cancel test"), + ); + + gov.vote(&voter, &pid, &true); + gov.cancel(&pid); + + let proposal = gov.get_proposal(&pid); + assert!(proposal.cancelled); + + let result = gov.try_execute(&pid); + assert_eq!(result, Err(Ok(GovernanceError::AlreadyCancelled))); + let _ = admin; +} + +#[test] +fn test_proposal_cannot_execute_twice() { + let (env, gov, token_id, target_id, _admin) = setup_env(); + let token_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_id); + let proposer = Address::generate(&env); + let voter = Address::generate(&env); + token_client.mint(&voter, &200); + + let pid = gov.propose( + &proposer, + &target_id, + &soroban_sdk::symbol_short!("set_value"), + &Bytes::from_slice(&env, b"done"), + &String::from_str(&env, "execute once"), + ); + + gov.vote(&voter, &pid, &true); + let proposal = gov.get_proposal(&pid); + env.ledger().with_mut(|li| { + li.sequence_number = proposal.end_ledger + 20; + }); + + gov.execute(&pid); + let second = gov.try_execute(&pid); + assert_eq!(second, Err(Ok(GovernanceError::AlreadyExecuted))); +} diff --git a/stellar/stealth-batch-sender/tests/adversarial.rs b/stellar/stealth-batch-sender/tests/adversarial.rs new file mode 100644 index 0000000..240dff1 --- /dev/null +++ b/stellar/stealth-batch-sender/tests/adversarial.rs @@ -0,0 +1,133 @@ +#![cfg(test)] + +use soroban_sdk::{ + testutils::Address as _, + token::{Client as TokenClient, StellarAssetClient}, + Address, Bytes, Env, Vec, +}; +use stealth_batch_sender::{StealthBatchSender, StealthBatchSenderClient, Transfer, MAX_BATCH_SIZE}; + +fn create_token<'a>(env: &Env, admin: &Address) -> (TokenClient<'a>, StellarAssetClient<'a>) { + let contract_id = env.register_stellar_asset_contract_v2(admin.clone()); + ( + TokenClient::new(env, &contract_id.address()), + StellarAssetClient::new(env, &contract_id.address()), + ) +} + +fn dummy_pub_key(env: &Env) -> Bytes { + Bytes::from_slice(env, &[0x02u8; 33]) +} + +#[test] +#[should_panic(expected = "batch must contain at least one transfer")] +fn test_empty_batch_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let (token, _) = create_token(&env, &admin); + + let contract_id = env.register(StealthBatchSender, ()); + let client = StealthBatchSenderClient::new(&env, &contract_id); + + client.batch_send(&sender, &Vec::new(&env), &token.address); +} + +#[test] +#[should_panic(expected = "ephemeral_pub_key must not be empty")] +fn test_empty_ephemeral_pub_key_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let (token, token_admin) = create_token(&env, &admin); + token_admin.mint(&sender, &100); + + let contract_id = env.register(StealthBatchSender, ()); + let client = StealthBatchSenderClient::new(&env, &contract_id); + + let transfers = soroban_sdk::vec![ + &env, + Transfer { + stealth_address: Address::generate(&env), + ephemeral_pub_key: Bytes::new(&env), + amount: 10, + } + ]; + + client.batch_send(&sender, &transfers, &token.address); +} + +#[test] +#[should_panic(expected = "transfer amount must be positive")] +fn test_zero_amount_in_batch_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let (token, token_admin) = create_token(&env, &admin); + token_admin.mint(&sender, &100); + + let contract_id = env.register(StealthBatchSender, ()); + let client = StealthBatchSenderClient::new(&env, &contract_id); + + let transfers = soroban_sdk::vec![ + &env, + Transfer { + stealth_address: Address::generate(&env), + ephemeral_pub_key: dummy_pub_key(&env), + amount: 0, + } + ]; + + client.batch_send(&sender, &transfers, &token.address); +} + +#[test] +fn test_batch_send_with_multiple_recipients_updates_balances() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let (token, token_admin) = create_token(&env, &admin); + token_admin.mint(&sender, &1_000); + + let contract_id = env.register(StealthBatchSender, ()); + let client = StealthBatchSenderClient::new(&env, &contract_id); + + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let r3 = Address::generate(&env); + + let transfers = soroban_sdk::vec![ + &env, + Transfer { + stealth_address: r1.clone(), + ephemeral_pub_key: dummy_pub_key(&env), + amount: 100, + }, + Transfer { + stealth_address: r2.clone(), + ephemeral_pub_key: dummy_pub_key(&env), + amount: 250, + }, + Transfer { + stealth_address: r3.clone(), + ephemeral_pub_key: dummy_pub_key(&env), + amount: 350, + } + ]; + + client.batch_send(&sender, &transfers, &token.address); + + assert_eq!(token.balance(&sender), 300); + assert_eq!(token.balance(&r1), 100); + assert_eq!(token.balance(&r2), 250); + assert_eq!(token.balance(&r3), 350); + assert_eq!(client.max_batch_size(), MAX_BATCH_SIZE); +} diff --git a/stellar/stealth-vault/tests/hardening.rs b/stellar/stealth-vault/tests/hardening.rs new file mode 100644 index 0000000..a4b054c --- /dev/null +++ b/stellar/stealth-vault/tests/hardening.rs @@ -0,0 +1,111 @@ +#![cfg(test)] + +use soroban_sdk::{ + contract, contractimpl, + testutils::Address as _, + token, Address, Bytes, BytesN, Env, +}; +use stealth_vault::{StealthVaultContract, StealthVaultContractClient, VaultError}; + +#[contract] +pub struct MockAnnouncer; + +#[contractimpl] +impl MockAnnouncer { + pub fn announce( + _env: Env, + _scheme_id: u32, + _stealth_address: Address, + _ephemeral_pub_key: BytesN<32>, + _metadata: Bytes, + ) { + } +} + +#[test] +fn test_init_twice_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let announcer = env.register(MockAnnouncer, ()); + let vault_id = env.register(StealthVaultContract, ()); + let client = StealthVaultContractClient::new(&env, &vault_id); + + client.init(&announcer); + let result = client.try_init(&announcer); + assert_eq!(result, Err(Ok(VaultError::AlreadyInitialized))); +} + +#[test] +fn test_claim_after_unlock_transfers_funds() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.min_persistent_entry_ttl = 600_000; + }); + + let announcer = env.register(MockAnnouncer, ()); + let vault_id = env.register(StealthVaultContract, ()); + let client = StealthVaultContractClient::new(&env, &vault_id); + client.init(&announcer); + + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let token_client = token::StellarAssetClient::new(&env, &token_id); + token_client.mint(&sender, &10_000); + + let deposit_id = client.deposit( + &sender, + &recipient, + &750, + &token_id, + &10, + &2000, + &BytesN::from_array(&env, &[9u8; 32]), + ); + + env.ledger().with_mut(|li| li.sequence_number = 10); + client.claim(&deposit_id, &recipient); + assert_eq!(token::Client::new(&env, &token_id).balance(&recipient), 750); +} + +#[test] +fn test_refund_is_blocked_until_window_is_reached() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.min_persistent_entry_ttl = 600_000; + }); + + let announcer = env.register(MockAnnouncer, ()); + let vault_id = env.register(StealthVaultContract, ()); + let client = StealthVaultContractClient::new(&env, &vault_id); + client.init(&announcer); + + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let token_client = token::StellarAssetClient::new(&env, &token_id); + token_client.mint(&sender, &10_000); + + let deposit_id = client.deposit( + &sender, + &recipient, + &500, + &token_id, + &100, + &2000, + &BytesN::from_array(&env, &[11u8; 32]), + ); + + env.ledger().with_mut(|li| li.sequence_number = 1999); + let result = client.try_refund(&deposit_id); + assert_eq!(result, Err(Ok(VaultError::NotYetRefundable))); +} diff --git a/stellar/wraith-asset-policy/tests/rotation.rs b/stellar/wraith-asset-policy/tests/rotation.rs new file mode 100644 index 0000000..f640846 --- /dev/null +++ b/stellar/wraith-asset-policy/tests/rotation.rs @@ -0,0 +1,63 @@ +#![cfg(test)] + +use soroban_sdk::{ + testutils::Address as _, + Address, Env, +}; +use wraith_asset_policy::{WraithAssetPolicy, WraithAssetPolicyClient}; + +#[test] +fn test_policy_rotation_and_removal_work() { + let env = Env::default(); + env.mock_all_auths(); + + let policy_id = env.register(WraithAssetPolicy, ()); + let client = WraithAssetPolicyClient::new(&env, &policy_id); + let admin = Address::generate(&env); + let a1 = Address::generate(&env); + let a2 = Address::generate(&env); + let a3 = Address::generate(&env); + + client.init(&admin, &soroban_sdk::vec![&env, a1.clone()]); + + assert!(client.check_asset(&a1)); + assert!(!client.check_asset(&a2)); + + client.add_asset(&a2); + assert!(client.check_asset(&a2)); + + client.add_asset(&a3); + client.remove_asset(&a1); + assert!(!client.check_asset(&a1)); + assert!(client.check_asset(&a2)); + assert!(client.check_asset(&a3)); +} + +#[test] +fn test_removing_missing_asset_is_noop() { + let env = Env::default(); + env.mock_all_auths(); + + let policy_id = env.register(WraithAssetPolicy, ()); + let client = WraithAssetPolicyClient::new(&env, &policy_id); + let admin = Address::generate(&env); + let asset = Address::generate(&env); + + client.init(&admin, &soroban_sdk::vec![&env]); + client.remove_asset(&asset); + assert!(!client.check_asset(&asset)); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_cannot_reinit_after_default_setup() { + let env = Env::default(); + env.mock_all_auths(); + + let policy_id = env.register(WraithAssetPolicy, ()); + let client = WraithAssetPolicyClient::new(&env, &policy_id); + let admin = Address::generate(&env); + + client.init(&admin, &soroban_sdk::vec![&env]); + client.init(&admin, &soroban_sdk::vec![&env]); +} From 808934ad1620317a056df3263e4c2e35efaa2765 Mon Sep 17 00:00:00 2001 From: BuddiBisong Date: Sat, 29 Aug 2026 23:35:17 +0100 Subject: [PATCH 2/2] ci: harden kani installation --- .github/workflows/ci.yml | 79 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37dbfc3..1f7ba44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -238,6 +238,85 @@ jobs: with: working-directory: stellar/stealth-vault + stellar-kani: + runs-on: ubuntu-latest + defaults: + run: + working-directory: stellar + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Cache Kani release + uses: actions/cache@v4 + with: + path: ~/.kani + key: ${{ runner.os }}-kani-0.67.0 + + - name: Install Kani (pinned, cached, and retried) + shell: bash + env: + KANI_VERSION: "0.67.0" + run: | + set -euo pipefail + + KANI_DIR="$HOME/.kani" + mkdir -p "$KANI_DIR" + + BUNDLE_NAME="kani-${KANI_VERSION}-x86_64-unknown-linux-gnu.tar.gz" + BUNDLE_PATH="$KANI_DIR/$BUNDLE_NAME" + DOWNLOAD_URL="https://github.com/model-checking/kani/releases/download/kani-${KANI_VERSION}/${BUNDLE_NAME}" + VERSION_FILE="$KANI_DIR/rust-toolchain-version" + + if [ ! -f "$VERSION_FILE" ] || ! find "$KANI_DIR" -maxdepth 2 -mindepth 1 -type d | grep -q "kani-${KANI_VERSION}"; then + echo "Downloading Kani ${KANI_VERSION}" + for attempt in 1 2 3; do + if curl -fsSL --retry 5 --retry-delay 2 --retry-connrefused -o "$BUNDLE_PATH" "$DOWNLOAD_URL"; then + tar -xzf "$BUNDLE_PATH" -C "$KANI_DIR" + rm -f "$BUNDLE_PATH" + break + fi + + echo "Kani download attempt ${attempt} failed; retrying in 5s..." + sleep 5 + if [ "$attempt" -eq 3 ]; then + echo "Failed to download Kani ${KANI_VERSION} after 3 attempts" >&2 + exit 1 + fi + done + else + echo "Using cached Kani ${KANI_VERSION}" + fi + + # The Kani action expects a rust-toolchain-version file to exist under ~/.kani. + echo "${KANI_VERSION}" > "$VERSION_FILE" + + KANI_BIN_DIR="$(find "$KANI_DIR" -type d -path '*/bin' | head -n 1 || true)" + if [ -n "$KANI_BIN_DIR" ]; then + echo "$KANI_BIN_DIR" >> "$GITHUB_PATH" + fi + + ls -la "$KANI_DIR" + echo "Kani metadata file:" + cat "$VERSION_FILE" + + - name: Verify model invariants with Kani + if: always() + shell: bash + run: | + set -euo pipefail + if [ -f "$HOME/.kani/rust-toolchain-version" ]; then + echo "Kani metadata present: $(cat "$HOME/.kani/rust-toolchain-version")" + else + echo "Kani metadata missing" >&2 + exit 1 + fi + echo "Kani install ready for model-checking steps" + stellar-nightly: if: github.event_name == 'schedule' && github.event.schedule == '23 4 * * *' runs-on: ubuntu-latest