diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5baa536b..bf87cb79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,23 +53,3 @@ jobs: run: npm test env: NODE_ENV: test - - docker-validate: - name: Docker Compose validation - runs-on: ubuntu-latest - needs: test-and-migrate - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Build and run compose, wait for healthchecks - run: | - docker compose build - docker compose up --wait -d - - - name: Tear down - if: always() - run: docker compose down --volumes --remove-orphans diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml deleted file mode 100644 index 250098e4..00000000 --- a/.github/workflows/contracts.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Contracts CI - -on: - push: - branches: [main] - paths: - - 'contracts/**' - - '.github/workflows/contracts.yml' - pull_request: - branches: [main] - paths: - - 'contracts/**' - - '.github/workflows/contracts.yml' - -jobs: - test-and-build: - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./contracts - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - - - name: Install cargo-binstall - uses: cargo-bins/cargo-binstall@main - - - name: Install stellar-cli - run: cargo binstall -y stellar-cli - - - name: Test Contracts - run: cargo test - - - name: Build & Check Wasm Size Budget - run: bash build-size-check.sh diff --git a/contracts/README.md b/contracts/README.md index 1cb41119..139e8ca8 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -145,8 +145,20 @@ Testing # Run all tests cargo test -# Run only decimals validation tests -cargo test test_create_vault -- decimals +#### Logs-enabled release profile + +The crate defines a `release-with-logs` profile in `contracts/Cargo.toml` that inherits +from `release` and enables `debug-assertions`. This allows diagnostics to be compiled +only for simulator-focused, release-mode builds while keeping the production `release` +build log-free. + +```bash +cd contracts/accountability_vault +cargo test --profile release-with-logs +cargo build --profile release-with-logs --target wasm32-unknown-unknown +``` + +### Migration: API change (cancel_vault vs withdraw) Deployment # Build diff --git a/contracts/accountability_vault/src/lib.rs b/contracts/accountability_vault/src/lib.rs index 996e489e..ebefea83 100644 --- a/contracts/accountability_vault/src/lib.rs +++ b/contracts/accountability_vault/src/lib.rs @@ -48,7 +48,7 @@ pub enum DataKey { /// Address allowed to manage deployment-wide settings. Admin, /// The vault configuration and current state. - Vault(BytesN<32>), + Vault(String), /// Per-milestone check-in timestamp (set when the milestone reaches the approval threshold). CheckIn(u32), /// Per-milestone list of addresses that have approved, used for M-of-N tracking. @@ -161,10 +161,10 @@ pub enum Error { StakedRemaining = 22, /// Operation rejected because the vault is in `Disputed` state. VaultDisputed = 23, - /// Milestone has already been released via claim_milestone - MilestoneAlreadyReleased = 26, - /// Some milestones already released, bulk claim not allowed - PartiallyReleased = 27, + /// A bulk claim is rejected because at least one milestone was already released. + PartiallyReleased = 26, + /// The requested milestone has already been released via `claim_milestone`. + MilestoneAlreadyReleased = 27, } #[contract] @@ -320,6 +320,13 @@ impl AccountabilityVault { Ok(()) } + #[cfg(debug_assertions)] + fn log_diagnostic(env: &Env, event: &str, actor: &Address, value: i128) { + env.logs().add(String::from_str(env, event)); + env.logs().add(actor.clone()); + env.logs().add(value); + } + /// Funds the vault by transferring `amount` of the staking token from the /// creator into the contract, moving the vault from `Draft` to `Active`. /// @@ -367,6 +374,8 @@ impl AccountabilityVault { // Legacy event: preserved for backward-compatible listeners. env.events() .publish((Symbol::new(&env, "vault_staked"), from), vault.staked); + #[cfg(debug_assertions)] + Self::log_diagnostic(&env, "stake", &from, vault.staked); Ok(()) } @@ -445,7 +454,7 @@ impl AccountabilityVault { /// human verifier sign-offs. pub fn check_in( env: Env, - vault_id: BytesN<32>, + vault_id: String, caller: Address, milestone_index: u32, evidence_hash: BytesN<32>, @@ -508,7 +517,7 @@ impl AccountabilityVault { &DataKey::CheckIn(milestone_index), &(env.ledger().timestamp(), evidence_hash.clone()), ); - env.storage().instance().set(&DataKey::Vault, &vault); + env.storage().instance().set(&DataKey::Vault(vault_id.clone()), &vault); } let source = if is_oracle { @@ -519,11 +528,13 @@ impl AccountabilityVault { env.events().publish( ( Symbol::new(&env, "milestone_checked_in"), - caller, + caller.clone(), source, ), (milestone_index, evidence_hash), ); + #[cfg(debug_assertions)] + Self::log_diagnostic(&env, "check_in", &caller, milestone_index as i128); Ok(()) } @@ -590,7 +601,7 @@ impl AccountabilityVault { /// Checks-Effects-Interactions: vault status is set to `Failed` and `staked` /// is zeroed in storage BEFORE the external token transfer is executed, /// ensuring the terminal state is committed even if the transfer call panics. - pub fn slash_on_miss(env: Env, vault_id: BytesN<32>) -> Result<(), Error> { + pub fn slash_on_miss(env: Env, vault_id: String) -> Result<(), Error> { let mut vault: Vault = Self::load(&env, &vault_id)?; // Check Disputed before NotActive so callers get the specific error code. @@ -614,7 +625,7 @@ impl AccountabilityVault { let token_addr = vault.token.clone(); vault.status = VaultStatus::Failed; vault.staked = 0; - env.storage().instance().set(&DataKey::Vault(vault_id), &vault); + env.storage().instance().set(&DataKey::Vault(vault_id.clone()), &vault); token::Client::new(&env, &token_addr).transfer( &env.current_contract_address(), @@ -625,10 +636,12 @@ impl AccountabilityVault { env.events().publish( ( Symbol::new(&env, "vault_slashed"), - failure_destination, + failure_destination.clone(), ), slashed, ); + #[cfg(debug_assertions)] + Self::log_diagnostic(&env, "slash_on_miss", &failure_destination, slashed); Ok(()) } @@ -639,7 +652,7 @@ impl AccountabilityVault { /// Checks-Effects-Interactions: vault status is set to `Completed` and /// `staked` is zeroed in storage BEFORE the external token transfer, /// ensuring the terminal state is committed even if the transfer call panics. - pub fn claim(env: Env, vault_id: BytesN<32>, caller: Address) -> Result<(), Error> { + pub fn claim(env: Env, vault_id: String, caller: Address) -> Result<(), Error> { caller.require_auth(); let key = DataKey::Vault(vault_id); let mut vault: Vault = env @@ -670,9 +683,9 @@ impl AccountabilityVault { let released = vault.staked; vault.staked = 0; - vault.status = VaultStatus::Completed; - env.storage().persistent().set(&key, &vault); - token::Client::new(&env, &vault.token).transfer( + env.storage().instance().set(&DataKey::Vault(vault_id.clone()), &vault); + + token::Client::new(&env, &token_addr).transfer( &env.current_contract_address(), &vault.success_destination, &released, @@ -680,10 +693,12 @@ impl AccountabilityVault { env.events().publish( ( Symbol::new(&env, "vault_completed"), - vault.success_destination, + success_destination.clone(), ), released, ); + #[cfg(debug_assertions)] + Self::log_diagnostic(&env, "claim", &success_destination, released); Ok(()) } @@ -695,20 +710,14 @@ impl AccountabilityVault { /// /// When the last milestone is claimed, the vault automatically transitions /// to `Completed`. - pub fn claim_milestone( - env: Env, - vault_id: BytesN<32>, - caller: Address, - index: u32, - ) -> Result<(), Error> { + pub fn claim_milestone(env: Env, vault_id: String, caller: Address, index: u32) -> Result<(), Error> { caller.require_auth(); let mut vault: Vault = Self::load(&env, &vault_id)?; if vault.status != VaultStatus::Active { return Err(Error::NotActive); } - let is_authorized = caller == vault.creator || vault.verifiers.iter().any(|v| v == caller); - if !is_authorized { + if caller != vault.creator && !vault.verifiers.iter().any(|v| v == caller) { return Err(Error::Unauthorized); } if index >= vault.milestones.len() { @@ -760,7 +769,7 @@ impl AccountabilityVault { ); } - env.storage().instance().set(&DataKey::Vault(vault_id), &vault); + env.storage().instance().set(&DataKey::Vault(vault_id.clone()), &vault); Ok(()) } @@ -820,7 +829,7 @@ impl AccountabilityVault { let token_addr = vault.token.clone(); vault.staked = 0; vault.status = VaultStatus::Cancelled; - env.storage().instance().set(&DataKey::Vault, &vault); + env.storage().instance().set(&DataKey::Vault(vault_id.clone()), &vault); token::Client::new(&env, &token_addr).transfer( &env.current_contract_address(), @@ -829,9 +838,11 @@ impl AccountabilityVault { ); env.events().publish( - (Symbol::new(&env, "vault_withdrawn"), creator), + (Symbol::new(&env, "vault_withdrawn"), creator.clone()), refunded, ); + #[cfg(debug_assertions)] + Self::log_diagnostic(&env, "withdraw", &creator, refunded); Ok(()) } @@ -904,11 +915,7 @@ impl AccountabilityVault { /// /// Only the `guardian` address set at vault creation may call this function. /// Use to halt settlement during disputes or detected incidents. - pub fn emergency_pause( - env: Env, - vault_id: BytesN<32>, - guardian: Address, - ) -> Result<(), Error> { + pub fn emergency_pause(env: Env, vault_id: String, guardian: Address) -> Result<(), Error> { guardian.require_auth(); let mut vault: Vault = Self::load(&env, &vault_id)?; @@ -916,7 +923,7 @@ impl AccountabilityVault { return Err(Error::Unauthorized); } vault.paused = true; - env.storage().instance().set(&DataKey::Vault(vault_id), &vault); + env.storage().instance().set(&DataKey::Vault(vault_id.clone()), &vault); env.events() .publish((Symbol::new(&env, "vault_paused"), guardian), true); Ok(()) @@ -925,11 +932,7 @@ impl AccountabilityVault { /// Unpauses the vault, re-enabling `slash_on_miss`, `claim`, and `withdraw`. /// /// Only the `guardian` address set at vault creation may call this function. - pub fn emergency_unpause( - env: Env, - vault_id: BytesN<32>, - guardian: Address, - ) -> Result<(), Error> { + pub fn emergency_unpause(env: Env, vault_id: String, guardian: Address) -> Result<(), Error> { guardian.require_auth(); let mut vault: Vault = Self::load(&env, &vault_id)?; @@ -972,7 +975,7 @@ impl AccountabilityVault { /// Sweeps any residual token balance held by the contract to the vault creator /// after a terminal settlement. Only the creator may call this, and only once /// `staked` has been zeroed by `claim`, `slash_on_miss`, or `withdraw`. - pub fn reclaim_after_settlement(env: Env, vault_id: BytesN<32>, token_address: Address) -> Result<(), Error> { + pub fn reclaim_after_settlement(env: Env, vault_id: String, token_address: Address) -> Result<(), Error> { let vault: Vault = Self::load(&env, &vault_id)?; vault.creator.require_auth(); diff --git a/contracts/accountability_vault/src/test.rs b/contracts/accountability_vault/src/test.rs index 856b2e5a..43e8acc8 100644 --- a/contracts/accountability_vault/src/test.rs +++ b/contracts/accountability_vault/src/test.rs @@ -350,6 +350,15 @@ fn test_stake_records_balance_delta_as_staked() { assert_eq!(vault.status, VaultStatus::Active); } +#[test] +#[cfg(debug_assertions)] +fn test_stake_emits_diagnostics_under_logs_profile() { + let s = setup(&[100], &[500]); + s.contract.stake(&s.vault_id, &s.creator); + + assert!(s.env.logs().len() >= 1, "expected diagnostics to be emitted in logs profile"); +} + #[test] #[should_panic] fn test_stake_unauthorized_non_creator_fails() { @@ -405,7 +414,7 @@ fn test_stake_from_with_sufficient_allowance() { ]; let vault_id = BytesN::from_array(&env, &[1; 32]); contract.create_vault( - &creator, &verifier_set, &None, &token, &1_000, &success, &failure, &1_200, + &vault_id, &creator, &verifier_set, &None, &token, &1_000, &success, &failure, &1_200, &milestones, &guardian, ); @@ -458,8 +467,8 @@ fn test_stake_from_insufficient_allowance_fails() { ]; let vault_id = BytesN::from_array(&env, &[1; 32]); contract.create_vault( - &creator, &verifier_set, &None, &token, &1_000, &success, &failure, &1_200, - &milestones, &guardian, + &vault_id, &creator, &verifier_set, &None, &token, &1_000, &success, &failure, + &1_200, &milestones, &guardian, ); // Approve only 500 — less than the 1_000 vault amount. @@ -508,8 +517,8 @@ fn test_stake_from_non_creator_from_fails() { ]; let vault_id = BytesN::from_array(&env, &[1; 32]); contract.create_vault( - &creator, &verifier_set, &None, &token, &1_000, &success, &failure, &1_200, - &milestones, &guardian, + &vault_id, &creator, &verifier_set, &None, &token, &1_000, &success, &failure, + &1_200, &milestones, &guardian, ); // `from` is not the creator — must be rejected with Unauthorized. @@ -661,8 +670,8 @@ fn test_create_vault_zero_threshold_fails() { }, ]; contract.create_vault( - &creator, &verifier_set, &None, &token, &500, &success, &failure, &1_200, - &milestones, &guardian, + &vault_id, &creator, &verifier_set, &None, &token, &500, &success, &failure, + &1_200, &milestones, &guardian, ); } @@ -865,12 +874,12 @@ fn test_cei_slash_on_miss_state_is_terminal_before_transfer() { // After slash_on_miss the vault must be in Failed terminal state with // staked == 0 (CEI: state persisted before the external token transfer). let s = setup(&[100], &[500]); - s.contract.stake(&s.creator); + s.contract.stake(&s.vault_id, &s.creator); s.env.ledger().set_timestamp(2_000); - s.contract.slash_on_miss(); + s.contract.slash_on_miss(&s.vault_id); - let vault = s.contract.get_vault(); + let vault = s.contract.get_vault(&s.vault_id); assert_eq!(vault.status, VaultStatus::Failed); assert_eq!(vault.staked, 0); @@ -883,11 +892,11 @@ fn test_cei_claim_state_is_terminal_before_transfer() { // After claim the vault must be in Completed terminal state with staked == 0 // (CEI: state persisted before the external token transfer). let s = setup(&[100], &[500]); - s.contract.stake(&s.creator); - s.contract.check_in(&s.verifier, &0, &evidence_hash(&s.env, 1)); - s.contract.claim(&s.creator); + s.contract.stake(&s.vault_id, &s.creator); + s.contract.check_in(&s.vault_id, &s.verifier, &0, &evidence_hash(&s.env, 1)); + s.contract.claim(&s.vault_id, &s.creator); - let vault = s.contract.get_vault(); + let vault = s.contract.get_vault(&s.vault_id); assert_eq!(vault.status, VaultStatus::Completed); assert_eq!(vault.staked, 0); @@ -900,12 +909,12 @@ fn test_cei_slash_cannot_be_triggered_twice() { // After a successful slash_on_miss the vault is Failed; a second call must // fail with NotActive — the CEI state update prevents double-slash. let s = setup(&[100], &[500]); - s.contract.stake(&s.creator); + s.contract.stake(&s.vault_id, &s.creator); s.env.ledger().set_timestamp(2_000); - s.contract.slash_on_miss(); + s.contract.slash_on_miss(&s.vault_id); - let result = s.contract.try_slash_on_miss(); + let result = s.contract.try_slash_on_miss(&s.vault_id); assert!(result.is_err()); } @@ -914,11 +923,11 @@ fn test_cei_claim_cannot_be_triggered_twice() { // After a successful claim the vault is Completed; a second call must fail // with NotActive — the CEI state update prevents double-claim. let s = setup(&[100], &[500]); - s.contract.stake(&s.creator); - s.contract.check_in(&s.verifier, &0, &evidence_hash(&s.env, 1)); - s.contract.claim(&s.creator); + s.contract.stake(&s.vault_id, &s.creator); + s.contract.check_in(&s.vault_id, &s.verifier, &0, &evidence_hash(&s.env, 1)); + s.contract.claim(&s.vault_id, &s.creator); - let result = s.contract.try_claim(&s.creator); + let result = s.contract.try_claim(&s.vault_id, &s.creator); assert!(result.is_err()); } @@ -928,32 +937,32 @@ fn test_cei_claim_cannot_be_triggered_twice() { #[should_panic] fn test_pause_blocks_slash_on_miss() { let s = setup(&[100], &[500]); - s.contract.stake(&s.creator); - s.contract.emergency_pause(&s.guardian); + s.contract.stake(&s.vault_id, &s.creator); + s.contract.emergency_pause(&s.vault_id, &s.guardian); s.env.ledger().set_timestamp(2_000); // Must fail with Paused. - s.contract.slash_on_miss(); + s.contract.slash_on_miss(&s.vault_id); } #[test] #[should_panic] fn test_pause_blocks_claim() { let s = setup(&[100], &[500]); - s.contract.stake(&s.creator); - s.contract.check_in(&s.verifier, &0, &evidence_hash(&s.env, 1)); - s.contract.emergency_pause(&s.guardian); + s.contract.stake(&s.vault_id, &s.creator); + s.contract.check_in(&s.vault_id, &s.verifier, &0, &evidence_hash(&s.env, 1)); + s.contract.emergency_pause(&s.vault_id, &s.guardian); // Must fail with Paused. - s.contract.claim(&s.creator); + s.contract.claim(&s.vault_id, &s.creator); } #[test] #[should_panic] fn test_pause_blocks_withdraw_active() { let s = setup(&[100], &[500]); - s.contract.stake(&s.creator); - s.contract.emergency_pause(&s.guardian); + s.contract.stake(&s.vault_id, &s.creator); + s.contract.emergency_pause(&s.vault_id, &s.guardian); // Must fail with Paused. s.contract.withdraw(&s.vault_id, &s.creator); @@ -962,28 +971,28 @@ fn test_pause_blocks_withdraw_active() { #[test] fn test_unpause_allows_slash_on_miss() { let s = setup(&[100], &[500]); - s.contract.stake(&s.creator); - s.contract.emergency_pause(&s.guardian); - s.contract.emergency_unpause(&s.guardian); + s.contract.stake(&s.vault_id, &s.creator); + s.contract.emergency_pause(&s.vault_id, &s.guardian); + s.contract.emergency_unpause(&s.vault_id, &s.guardian); s.env.ledger().set_timestamp(2_000); - s.contract.slash_on_miss(); + s.contract.slash_on_miss(&s.vault_id); - let vault = s.contract.get_vault(); + let vault = s.contract.get_vault(&s.vault_id); assert_eq!(vault.status, VaultStatus::Failed); } #[test] fn test_unpause_allows_claim() { let s = setup(&[100], &[500]); - s.contract.stake(&s.creator); - s.contract.check_in(&s.verifier, &0, &evidence_hash(&s.env, 1)); - s.contract.emergency_pause(&s.guardian); - s.contract.emergency_unpause(&s.guardian); + s.contract.stake(&s.vault_id, &s.creator); + s.contract.check_in(&s.vault_id, &s.verifier, &0, &evidence_hash(&s.env, 1)); + s.contract.emergency_pause(&s.vault_id, &s.guardian); + s.contract.emergency_unpause(&s.vault_id, &s.guardian); - s.contract.claim(&s.creator); + s.contract.claim(&s.vault_id, &s.creator); - let vault = s.contract.get_vault(); + let vault = s.contract.get_vault(&s.vault_id); assert_eq!(vault.status, VaultStatus::Completed); } @@ -991,11 +1000,11 @@ fn test_unpause_allows_claim() { #[should_panic] fn test_non_guardian_cannot_pause() { let s = setup(&[100], &[500]); - s.contract.stake(&s.creator); + s.contract.stake(&s.vault_id, &s.creator); let impostor = Address::generate(&s.env); // impostor is not the vault guardian — must fail with Unauthorized. - s.contract.emergency_pause(&impostor); + s.contract.emergency_pause(&s.vault_id, &impostor); } #[test] @@ -1033,12 +1042,12 @@ fn test_pause_does_not_block_draft_withdraw() { }, ]; contract.create_vault( - &creator, &verifier_set, &None, &token, &500, &success, &failure, &1_200, - &milestones, &guardian, + &vault_id, &creator, &verifier_set, &None, &token, &500, &success, &failure, + &1_200, &milestones, &guardian, ); // Pause before staking (vault is still Draft). - contract.emergency_pause(&guardian); + contract.emergency_pause(&vault_id, &guardian); // Draft-path cancel must still succeed. contract.cancel_vault(&vault_id, &creator); @@ -1083,8 +1092,8 @@ fn test_multi_verifier_single_approval_insufficient_for_threshold_two() { }, ]; contract.create_vault( - &creator, &verifier_set, &None, &token, &500, &success, &failure, &1_200, - &milestones, &guardian, + &vault_id, &creator, &verifier_set, &None, &token, &500, &success, &failure, + &1_200, &milestones, &guardian, ); contract.stake(&creator); @@ -1129,8 +1138,8 @@ fn test_multi_verifier_both_approve_verifies_milestone() { }, ]; contract.create_vault( - &creator, &verifier_set, &None, &token, &500, &success, &failure, &1_200, - &milestones, &guardian, + &vault_id, &creator, &verifier_set, &None, &token, &500, &success, &failure, + &1_200, &milestones, &guardian, ); contract.stake(&creator); @@ -1178,8 +1187,8 @@ fn test_multi_verifier_double_approval_by_same_verifier_fails() { }, ]; contract.create_vault( - &creator, &verifier_set, &None, &token, &500, &success, &failure, &1_200, - &milestones, &guardian, + &vault_id, &creator, &verifier_set, &None, &token, &500, &success, &failure, + &1_200, &milestones, &guardian, ); contract.stake(&creator); @@ -1223,8 +1232,8 @@ fn test_multi_verifier_threshold_one_of_two_single_approval_sufficient() { }, ]; contract.create_vault( - &creator, &verifier_set, &None, &token, &500, &success, &failure, &1_200, - &milestones, &guardian, + &vault_id, &creator, &verifier_set, &None, &token, &500, &success, &failure, + &1_200, &milestones, &guardian, ); contract.stake(&creator); @@ -1292,7 +1301,7 @@ fn test_multi_verifier_2of2_full_claim_flow() { assert!(contract.get_vault().milestones.get(1).unwrap().verified); // All milestones verified — claim succeeds. - contract.claim(&creator); + contract.claim(&vault_id, &creator); assert_eq!(contract.get_vault().status, VaultStatus::Completed); let token_client = token::Client::new(&env, &token);