From d36a87f9946129681eade5ebc5cd3fcf439558e6 Mon Sep 17 00:00:00 2001 From: amberly-d <294444926+amberly-d@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:07:08 +0100 Subject: [PATCH 1/3] Harden release profile, add dependency audit, coverage gates and wallet tests SC-37: release profile - Add lto, codegen-units = 1, strip = "symbols", debug = 0 and panic = "abort". - Keep overflow-checks = true. It costs size and gas, but these contracts move asset value and share counts, and a silent wrap is worse than a trapped transaction. Turning the traps into typed errors is SC-43. - Add a release-with-logs profile inheriting release with debug assertions on, for testnet debugging. - Measured WASM shrinks 43.4% overall, from 440,636 to 249,183 bytes: assetsup -36.6%, contrib -39.8%, multisig_wallet -41.8%, asset_maintenance -64.4%, multisig_transfer -45.9%. Per-contract figures are recorded in contracts/BUILD.md. All tests pass unchanged under panic=abort. SC-38: dependency audit - Add a security job running cargo audit and cargo deny, with the advisory database and tooling cached, plus a weekly schedule so advisories published after a merge are still caught. - Add deny.toml covering advisories, a permissive licence allowlist, duplicate-version detection and a crates.io-only source policy. It passes as committed. - Mark all five crates publish = false. They deploy as WASM and are never published, and it lets cargo-deny stop reporting them as unlicensed without inventing a licence the repository does not state. - Two advisories are ignored with reasoning and a review date, both unmaintained compile-time proc macros reached through soroban-sdk. Yanked crates warn rather than deny: soroban-sdk 22 pins yanked spin and keccak inside its own tree, unresolvable from this workspace, and a permanently red job is one everyone learns to ignore. SC-35 should clear both. SC-39: coverage - Add a coverage job running cargo-llvm-cov and uploading the lcov report. - Add scripts/check-coverage.sh enforcing a workspace floor and, more importantly, a per-crate floor. A workspace average hides a crate with no tests behind well-tested siblings, which is how multisig_transfer reached 755 lines at zero coverage unnoticed. - Floors are set just under the measured level so the build stays green and can be ratcheted up: assetsup 70, contrib 35, multisig-wallet 90, asset-maintenance 70, workspace 65. - Any crate at zero coverage fails unless explicitly listed with its tracking issue. multisig_transfer is the only entry, pending SC-32; removing it is how the gate tightens. Verified the gate fails when the exemption is removed. SC-40: multisig-wallet coverage - 4 tests to 60, covering every public entrypoint. - Threshold and signer-count invariants at their boundaries: zero threshold, threshold above owner count, threshold equal to owner count, fewer than two owners, and removal that would drop owners below the threshold. - Proposal lifecycle including rejection of re-execution and of execution below the threshold. - Duplicate approvals, approval by a non-owner, and that a removed signer can no longer confirm. - Negative auth tests without mock_all_auths, so a missing require_auth fails rather than passing silently. One behavioural finding, pinned as a test rather than changed here: confirm_transaction sets status = Expired and writes it before returning Err(TransactionExpired), but Soroban rolls back storage writes on a failed invocation, so the write never lands and the transaction stays Pending. Nothing can currently move a transaction into Expired, so a consumer filtering on that status will never match. Giving it a real transition needs a separate entrypoint, which is beyond a test-coverage change. Closes #1201 Closes #1202 Closes #1203 Closes #1204 --- .github/workflows/CI.yaml | 86 ++ contracts/.gitignore | 2 + contracts/BUILD.md | 142 +++ contracts/Cargo.toml | 21 +- contracts/asset-maintenance/Cargo.toml | 2 + contracts/assetsup/Cargo.toml | 2 + contracts/contrib/Cargo.toml | 2 + contracts/deny.toml | 122 +++ contracts/multisig-transfer/Cargo.toml | 2 + contracts/multisig-wallet/Cargo.toml | 2 + contracts/multisig-wallet/src/lib.rs | 2 + .../multisig-wallet/src/tests_coverage.rs | 965 ++++++++++++++++++ contracts/scripts/check-coverage.sh | 151 +++ 13 files changed, 1499 insertions(+), 2 deletions(-) create mode 100644 contracts/BUILD.md create mode 100644 contracts/deny.toml create mode 100644 contracts/multisig-wallet/src/tests_coverage.rs create mode 100755 contracts/scripts/check-coverage.sh diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 14a57ce92..3182a8371 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -5,6 +5,11 @@ on: branches: [main] pull_request: branches: [main] + # Advisories are published continuously, so a lockfile that was clean at + # merge time can become vulnerable without anything in the repo changing. + # This re-runs the audit weekly against the committed Cargo.lock. + schedule: + - cron: "0 6 * * 1" env: CARGO_TERM_COLOR: always @@ -160,6 +165,87 @@ jobs: if-no-files-found: error retention-days: 14 + security: + name: Dependency Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + # The advisory database is a git clone of ~200MB of history; caching it + # keyed on the week keeps the job to a few seconds on most runs while + # still refreshing regularly. + - name: Cache advisory database + uses: actions/cache@v4 + with: + path: ~/.cargo/advisory-db + key: advisory-db-${{ github.run_id }} + restore-keys: advisory-db- + + - name: Cache audit tooling + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/cargo-audit + ~/.cargo/bin/cargo-deny + key: ${{ runner.os }}-audit-tools-v1 + + - name: Install cargo-audit and cargo-deny + run: | + command -v cargo-audit >/dev/null || cargo install --locked cargo-audit + command -v cargo-deny >/dev/null || cargo install --locked cargo-deny + + - name: cargo audit + run: cargo audit --file Cargo.lock --deny warnings + + # Covers advisories, the license allowlist, duplicate versions, and + # source registries. See contracts/deny.toml for the triage process. + - name: cargo deny + run: cargo deny --all-features check + + coverage: + name: Code Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + components: llvm-tools-preview + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + contracts/target + key: ${{ runner.os }}-cargo-cov-${{ hashFiles('contracts/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-cov- + ${{ runner.os }}-cargo- + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Generate coverage + run: cargo llvm-cov --workspace --lcov --output-path lcov.info + + # Enforces the per-crate floor as well as the workspace threshold. A + # workspace average hides a crate with no tests at all, which is exactly + # how multisig_transfer reached 755 lines with zero coverage. + - name: Check coverage thresholds + run: ./scripts/check-coverage.sh + + - name: Upload lcov report + uses: actions/upload-artifact@v4 + with: + name: coverage-lcov + path: contracts/lcov.info + retention-days: 14 + # ───────────────────────────────────────────── # BACKEND — NestJS # ───────────────────────────────────────────── diff --git a/contracts/.gitignore b/contracts/.gitignore index 89a6be20b..b89442efc 100644 --- a/contracts/.gitignore +++ b/contracts/.gitignore @@ -7,3 +7,5 @@ test_snapshots/ # deployments/testnet.example.json documents the shape. deployments/* !deployments/*.example.json +# Coverage reports produced by cargo-llvm-cov. +lcov.info diff --git a/contracts/BUILD.md b/contracts/BUILD.md new file mode 100644 index 000000000..f16bcd91c --- /dev/null +++ b/contracts/BUILD.md @@ -0,0 +1,142 @@ +# Build profiles, WASM size, and quality gates + +## Release profile + +Soroban charges for resource usage and enforces a size limit on deployed +contracts, so the release profile in [`Cargo.toml`](Cargo.toml) is tuned for the +smallest artifact that is still safe to run. + +| Setting | Value | Why | +|---|---|---| +| `opt-level` | `"z"` | Optimize for size rather than speed. | +| `lto` | `true` | Cross-crate inlining and dead-code elimination. | +| `codegen-units` | `1` | Lets LLVM see the whole crate at once. | +| `strip` | `"symbols"` | Symbol names are dead weight on-chain. | +| `debug` | `0` | No debug info in a deployed artifact. | +| `panic` | `"abort"` | Unwinding tables are unusable in Soroban. | +| `overflow-checks` | `true` | **Kept deliberately** — see below. | + +### Why `overflow-checks` stays on + +It costs size and gas. These contracts move asset value, share counts, and +dividend splits, where a silent wraparound is far worse than a trapped +transaction. The trap is not the end state we want either — converting these +into typed errors is tracked in [SC-43] — but until then, trapping beats +wrapping. + +### `release-with-logs` + +A second profile inherits `release` and re-enables `debug-assertions`, for +debugging on testnet where a precondition failure should be loud: + +```sh +cargo build --workspace --target wasm32-unknown-unknown --profile release-with-logs +``` + +Never deploy this to mainnet. Debug assertions change failure behaviour and add +size. + +## Measured WASM size + +Built with `cargo build --workspace --target wasm32-unknown-unknown --release`, +before any `stellar contract optimize` pass. + +| Contract | Before | After | Change | +|---|---:|---:|---:| +| `assetsup` | 162,775 | 103,140 | **−36.6%** | +| `contrib` | 79,282 | 47,758 | **−39.8%** | +| `multisig_wallet` | 74,214 | 43,157 | **−41.8%** | +| `asset_maintenance` | 65,756 | 23,421 | **−64.4%** | +| `multisig_transfer` | 58,609 | 31,707 | **−45.9%** | +| **Total** | **440,636** | **249,183** | **−43.4%** | + +`asset-maintenance` gains the most because it was carrying the most +monomorphized code that `lto` could collapse. `assetsup` remains the largest by +a wide margin and is the crate to watch against the deployment limit — it is +the subject of the split assessment in [SC-46]. + +These are the baselines. Tracking size per PR and enforcing a budget is +[SC-52]. + +All 298 tests pass unchanged under `panic = "abort"`. + +## Quality gates in CI + +| Job | Command | Gate | +|---|---|---| +| Format Check | `cargo fmt --all -- --check` | Fails on any formatting drift. | +| Clippy Lint | `cargo clippy --all-targets --all-features -- -D warnings` | Warnings are errors. | +| Test Suite | `cargo test --all` | All tests must pass. | +| Build Check | `cargo build --all` | Native build. | +| Build WASM | `cargo build --package contrib --target wasm32-unknown-unknown --release` | Deployability. Extending this to all contracts is [SC-34]. | +| Dependency Audit | `cargo audit`, `cargo deny check` | Advisories, licences, sources. | +| Code Coverage | `cargo llvm-cov` + `scripts/check-coverage.sh` | Workspace and per-crate floors. | + +### Dependency audit + +[`deny.toml`](deny.toml) configures four checks: advisories, a permissive +licence allowlist, duplicate-version detection, and a crates.io-only source +policy. The job also runs weekly on a schedule, because a lockfile that was +clean at merge time can become vulnerable without anything in the repo +changing. + +Run it locally: + +```sh +cargo install --locked cargo-deny cargo-audit +cd contracts +cargo deny --all-features check +cargo audit --file Cargo.lock --deny warnings +``` + +**Triaging an advisory** — the process is documented inline in `deny.toml`. +The short version: determine whether the vulnerable path is reachable from a +contract entrypoint; if it is, fix it rather than ignoring it; if it genuinely +is not, add it to `ignore` with the reasoning and a review date no more than 90 +days out. + +Two ignores are currently in place, both unmaintained-crate notices for +compile-time proc macros reached through `soroban-sdk` (`derivative`, `paste`). +Neither puts code into a deployed contract. + +Yanked crates are set to `warn` rather than `deny`: `soroban-sdk` 22 pins yanked +versions of `spin` and `keccak` inside its own tree, unresolvable from this +workspace by any `cargo update`. Denying would mean a permanently red job that +everyone learns to ignore. The SDK upgrade in [SC-35] is expected to clear both. + +### Coverage + +```sh +cargo install --locked cargo-llvm-cov +cd contracts +cargo llvm-cov --workspace --lcov --output-path lcov.info +./scripts/check-coverage.sh +``` + +Two gates, and the second is the one that matters: + +1. **Workspace floor** — currently 65%. +2. **Per-crate floor** — a workspace average happily hides a crate with no + tests behind well-tested siblings, which is exactly how `multisig_transfer` + reached 755 lines with zero tests unnoticed. + +Measured when this landed: + +| Crate | Coverage | Floor | +|---|---:|---:| +| `multisig-wallet` | 94% | 90% | +| `asset-maintenance` | 76% | 70% | +| `assetsup` | 74% | 70% | +| `contrib` | 38% | 35% | +| `multisig_transfer` | 0% | exempt | +| **Workspace** | **67%** | **65%** | + +Floors sit a few points under the measured value so ordinary churn does not +fail the build while a real regression does. Ratchet them upward as coverage +improves. + +Any crate at zero coverage **fails**, unless it is named in the +`ZERO_COVERAGE_EXEMPT` list in the script with its tracking issue. +`multisig_transfer` is the only entry, pending [SC-32]. That keeps the build +green for a known, tracked hole while still failing for any new crate that +arrives without tests — removing the entry is how the gate gets tightened. diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index e309bd3aa..ebf91260b 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -16,6 +16,23 @@ members = [ [workspace.dependencies] soroban-sdk = "23" +# Deployable WASM is charged for by size and subject to a ledger size limit, so +# the release profile is tuned for the smallest artifact that is still safe. [profile.release] -opt-level = "z" -overflow-checks = true \ No newline at end of file +opt-level = "z" # optimize for size over speed +lto = true # cross-crate inlining and dead code elimination +codegen-units = 1 # one unit lets LLVM see the whole crate at once +strip = "symbols" # symbol names are dead weight on-chain +debug = 0 # no debug info in a deployed artifact +panic = "abort" # unwinding tables are unusable in Soroban anyway +# Kept deliberately. Overflow checks cost a little size and gas, but these +# contracts move asset value and share counts; a silent wrap is worse than a +# trapped transaction. Converting the traps into typed errors is [SC-43]. +overflow-checks = true + +# Same size profile, but with debug assertions left on so a testnet deployment +# reports precondition failures instead of silently misbehaving. Never use for +# mainnet: debug assertions change failure behaviour and add size. +[profile.release-with-logs] +inherits = "release" +debug-assertions = true \ No newline at end of file diff --git a/contracts/asset-maintenance/Cargo.toml b/contracts/asset-maintenance/Cargo.toml index d0d91ef19..78d5a0d1b 100644 --- a/contracts/asset-maintenance/Cargo.toml +++ b/contracts/asset-maintenance/Cargo.toml @@ -2,6 +2,8 @@ name = "asset-maintenance" version = "0.1.0" edition = "2021" +# Deployed as WASM to Stellar, never published to crates.io. +publish = false [lib] crate-type = ["cdylib"] diff --git a/contracts/assetsup/Cargo.toml b/contracts/assetsup/Cargo.toml index 6ea6dc4d3..3f35c212c 100644 --- a/contracts/assetsup/Cargo.toml +++ b/contracts/assetsup/Cargo.toml @@ -2,6 +2,8 @@ name = "assetsup" version = "0.1.0" edition = "2021" +# Deployed as WASM to Stellar, never published to crates.io. +publish = false [lib] crate-type = ["lib", "cdylib"] diff --git a/contracts/contrib/Cargo.toml b/contracts/contrib/Cargo.toml index ab1f712fb..25d4d1a7c 100644 --- a/contracts/contrib/Cargo.toml +++ b/contracts/contrib/Cargo.toml @@ -2,6 +2,8 @@ name = "contrib" version = "0.1.0" edition = "2021" +# Deployed as WASM to Stellar, never published to crates.io. +publish = false [lib] crate-type = ["lib", "cdylib"] diff --git a/contracts/deny.toml b/contracts/deny.toml new file mode 100644 index 000000000..2177c72e7 --- /dev/null +++ b/contracts/deny.toml @@ -0,0 +1,122 @@ +# cargo-deny configuration for the contracts workspace. +# +# Run locally with: +# cargo install --locked cargo-deny +# cd contracts && cargo deny check +# +# CI runs the same command on every pull request and on a weekly schedule, so +# an advisory published after a merge is still caught. + +[graph] +# Contracts only ever ship to wasm32; native is needed for `cargo test`. +targets = [ + { triple = "wasm32-unknown-unknown" }, + { triple = "x86_64-unknown-linux-gnu" }, +] +all-features = true + +[output] +feature-depth = 1 + +# --------------------------------------------------------------------------- +# Security advisories +# --------------------------------------------------------------------------- +[advisories] +version = 2 +# Any crate with a RUSTSEC *vulnerability* advisory fails the build. These +# contracts govern asset ownership and multisig approvals, so an exploitable +# advisory in a transitive dependency is a release blocker. +# +# Yanked crates are warned about rather than denied. soroban-sdk 22 pins yanked +# versions of `spin` and `keccak` deep in its own tree; neither can be resolved +# from this workspace by any `cargo update`, and a yank is a publishing signal, +# not evidence of a vulnerability. Denying here would mean a permanently red +# build that teaches everyone to ignore this job. Revisit once the SDK upgrade +# in [SC-35] lands, which is expected to move both off the yanked versions. +yanked = "warn" + +# Advisories consciously accepted. Every entry MUST carry: +# - the advisory id +# - why it does not apply here, or what compensating control exists +# - a review date, so an ignore cannot quietly become permanent +# +# Triage process: +# 1. Read the advisory. Determine whether the vulnerable code path is +# reachable from a contract entrypoint. +# 2. If it is reachable, fix it — upgrade, patch, or drop the dependency. +# Do not ignore it. +# 3. If it is genuinely unreachable, or it is unfixable from this workspace +# because it comes from inside soroban-sdk's own tree, add it below with +# the reasoning and a review date no more than 90 days out. +# 4. On the review date, re-check rather than extending by reflex. If the +# dependency is still stuck, say so explicitly and move the date. +# +# Every entry below is transitive through soroban-sdk and cannot be resolved +# from this workspace: nothing here is a direct dependency, and no combination +# of `cargo update` fixes them while the SDK pins what it pins. They are +# unmaintained-or-yanked notices, not exploitable vulnerabilities. +ignore = [ + # `derivative` is unmaintained. Pulled in by soroban-env-host. It is a + # proc-macro used at compile time only; no code from it reaches a + # deployed contract. Review 2026-10-27. + { id = "RUSTSEC-2024-0388", reason = "unmaintained proc-macro, compile-time only, transitive via soroban-env-host; review 2026-10-27" }, + + # `paste` is no longer maintained. Also a compile-time proc-macro reached + # through soroban-sdk. No runtime surface. Review 2026-10-27. + { id = "RUSTSEC-2024-0436", reason = "unmaintained proc-macro, compile-time only, transitive via soroban-sdk; review 2026-10-27" }, +] + +# --------------------------------------------------------------------------- +# Licenses +# --------------------------------------------------------------------------- +[licenses] +version = 2 +# The five workspace crates are never published and carry no `license` field, +# so exempt them. This only affects our own crates; every third-party +# dependency is still checked against the allowlist below. +private = { ignore = true } + +# Permissive licences only. Anything copyleft or unlisted fails and must be +# reviewed deliberately rather than absorbed by accident. +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "Unicode-3.0", + "Unlicense", +] +confidence-threshold = 0.8 + +# --------------------------------------------------------------------------- +# Dependency bans +# --------------------------------------------------------------------------- +[bans] +multiple-versions = "warn" +wildcards = "deny" +# contrib dev-depends on assetsup by path, which cargo-deny reads as a +# wildcard version requirement. Path dependencies inside this workspace are +# not a supply-chain risk; wildcards on registry crates still fail. +allow-wildcard-paths = true +# Prefer the highest version when the same crate appears twice, so the warning +# above points at something actionable. +highlight = "all" + +# Duplicate versions that come from the soroban-sdk dependency tree and cannot +# be resolved from this workspace are listed here so the signal stays useful. +skip = [] + +deny = [] + +# --------------------------------------------------------------------------- +# Sources +# --------------------------------------------------------------------------- +[sources] +# Only crates.io. A git or unknown-registry dependency in a contract workspace +# is a supply-chain risk that should be an explicit, reviewed decision. +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/contracts/multisig-transfer/Cargo.toml b/contracts/multisig-transfer/Cargo.toml index 5bb969563..a379139ce 100644 --- a/contracts/multisig-transfer/Cargo.toml +++ b/contracts/multisig-transfer/Cargo.toml @@ -2,6 +2,8 @@ name = "multisig-transfer" version = "0.1.0" edition = "2021" +# Deployed as WASM to Stellar, never published to crates.io. +publish = false [lib] crate-type = ["lib", "cdylib"] diff --git a/contracts/multisig-wallet/Cargo.toml b/contracts/multisig-wallet/Cargo.toml index 70038ef84..7faff53a7 100644 --- a/contracts/multisig-wallet/Cargo.toml +++ b/contracts/multisig-wallet/Cargo.toml @@ -2,6 +2,8 @@ name = "multisig-wallet" version = "0.1.0" edition = "2021" +# Deployed as WASM to Stellar, never published to crates.io. +publish = false [lib] crate-type = ["lib", "cdylib"] diff --git a/contracts/multisig-wallet/src/lib.rs b/contracts/multisig-wallet/src/lib.rs index ef17fe6c8..3cdcbb2cd 100644 --- a/contracts/multisig-wallet/src/lib.rs +++ b/contracts/multisig-wallet/src/lib.rs @@ -35,6 +35,8 @@ mod event_tests; pub mod events; #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_coverage; mod types; pub use crate::errors::Error; diff --git a/contracts/multisig-wallet/src/tests_coverage.rs b/contracts/multisig-wallet/src/tests_coverage.rs new file mode 100644 index 000000000..463c83bdc --- /dev/null +++ b/contracts/multisig-wallet/src/tests_coverage.rs @@ -0,0 +1,965 @@ +//! Expanded coverage for the multisig wallet ([SC-40]). +//! +//! `tests.rs` covers four happy paths. This module works through every public +//! entrypoint, the threshold and signer-count invariants at their boundaries, +//! the proposal lifecycle, and the authorization boundaries — the last using +//! `try_*` without `mock_all_auths` so a missing `require_auth` fails the test +//! rather than passing silently. + +use soroban_sdk::testutils::{Address as _, Ledger as _}; +use soroban_sdk::{Address, Env, IntoVal, Symbol, Vec}; + +use crate::errors::Error; +use crate::types::{ProposalStatus, ProposalType, TransactionStatus, TransactionType}; +use crate::{MultisigWallet, MultisigWalletClient}; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +struct Wallet<'a> { + env: Env, + client: MultisigWalletClient<'a>, + admin: Address, + owners: Vec
, +} + +impl<'a> Wallet<'a> { + fn owner(&self, i: u32) -> Address { + self.owners.get(i).unwrap() + } +} + +/// An `n`-owner wallet with the given threshold, with all auths mocked. +fn wallet_with(env: &Env, owner_count: u32, threshold: u32) -> Wallet<'_> { + let contract_id = env.register(MultisigWallet, ()); + let client = MultisigWalletClient::new(env, &contract_id); + let admin = Address::generate(env); + + let mut owners = Vec::new(env); + for _ in 0..owner_count { + owners.push_back(Address::generate(env)); + } + + env.mock_all_auths(); + client.initialize(&admin, &owners, &threshold); + + Wallet { + env: env.clone(), + client, + admin, + owners, + } +} + +/// The common case: three owners, 2-of-3. +fn wallet(env: &Env) -> Wallet<'_> { + wallet_with(env, 3, 2) +} + +fn submit(w: &Wallet, initiator: &Address) -> u64 { + let target = Address::generate(&w.env); + w.client.submit_transaction( + initiator, + &TransactionType::Routine, + &target, + &Symbol::new(&w.env, "noop"), + &Vec::new(&w.env), + &3600, + &0, + ) +} + +/// Submits and expects failure, returning the contract error. +fn submit_expecting_error(w: &Wallet, initiator: &Address) -> Error { + let target = Address::generate(&w.env); + w.client + .try_submit_transaction( + initiator, + &TransactionType::Routine, + &target, + &Symbol::new(&w.env, "noop"), + &Vec::new(&w.env), + &3600, + &0, + ) + .expect_err("expected submit_transaction to fail") + .expect("expected a contract error, not a host error") +} + +// --------------------------------------------------------------------------- +// initialize — threshold and signer-count invariants at their boundaries +// --------------------------------------------------------------------------- + +#[test] +fn initialize_sets_owners_threshold_and_defaults() { + let env = Env::default(); + let w = wallet(&env); + + assert_eq!(w.client.get_owners().len(), 3); + assert_eq!(w.client.get_threshold(), 2); + assert_eq!(w.client.get_required_confirmations(), 2); + assert!(!w.client.is_frozen()); +} + +#[test] +fn initialize_creates_a_profile_for_every_owner() { + let env = Env::default(); + let w = wallet(&env); + + for i in 0..3 { + let profile = w + .client + .get_owner_profile(&w.owner(i)) + .expect("every owner gets a profile"); + assert!(profile.is_active); + assert_eq!(profile.voting_weight, 1); + assert_eq!(profile.added_by, w.admin); + } +} + +#[test] +fn initialize_twice_is_rejected() { + let env = Env::default(); + let w = wallet(&env); + + let res = w + .client + .try_initialize(&w.admin, &w.owners, &2) + .expect_err("second initialize must fail"); + assert_eq!(res, Ok(Error::AlreadyInitialized)); +} + +#[test] +fn initialize_rejects_fewer_than_two_owners() { + let env = Env::default(); + let contract_id = env.register(MultisigWallet, ()); + let client = MultisigWalletClient::new(&env, &contract_id); + let admin = Address::generate(&env); + env.mock_all_auths(); + + let single = Vec::from_array(&env, [Address::generate(&env)]); + let res = client + .try_initialize(&admin, &single, &1) + .expect_err("a one-owner multisig is not a multisig"); + assert_eq!(res, Ok(Error::InsufficientOwners)); +} + +#[test] +fn initialize_rejects_zero_threshold() { + let env = Env::default(); + let contract_id = env.register(MultisigWallet, ()); + let client = MultisigWalletClient::new(&env, &contract_id); + let admin = Address::generate(&env); + env.mock_all_auths(); + + let owners = Vec::from_array(&env, [Address::generate(&env), Address::generate(&env)]); + let res = client + .try_initialize(&admin, &owners, &0) + .expect_err("a zero threshold would let anyone execute"); + assert_eq!(res, Ok(Error::InvalidThreshold)); +} + +#[test] +fn initialize_rejects_threshold_greater_than_owner_count() { + let env = Env::default(); + let contract_id = env.register(MultisigWallet, ()); + let client = MultisigWalletClient::new(&env, &contract_id); + let admin = Address::generate(&env); + env.mock_all_auths(); + + let owners = Vec::from_array(&env, [Address::generate(&env), Address::generate(&env)]); + let res = client + .try_initialize(&admin, &owners, &3) + .expect_err("an unreachable threshold would brick the wallet"); + assert_eq!(res, Ok(Error::InvalidThreshold)); +} + +#[test] +fn initialize_accepts_threshold_equal_to_owner_count() { + // The upper boundary is valid: unanimity is a legitimate configuration. + let env = Env::default(); + let w = wallet_with(&env, 3, 3); + assert_eq!(w.client.get_threshold(), 3); +} + +// --------------------------------------------------------------------------- +// submit_transaction +// --------------------------------------------------------------------------- + +#[test] +fn submit_transaction_assigns_sequential_ids() { + let env = Env::default(); + let w = wallet(&env); + + assert_eq!(submit(&w, &w.owner(0)), 1); + assert_eq!(submit(&w, &w.owner(1)), 2); + assert_eq!(submit(&w, &w.owner(0)), 3); +} + +#[test] +fn submit_transaction_records_threshold_and_pending_status() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + let tx = w.client.get_transaction(&tx_id).unwrap(); + assert_eq!(tx.status, TransactionStatus::Pending); + assert_eq!(tx.required_confirmations, 2); + assert_eq!(tx.confirmations_count, 0); + assert_eq!(tx.initiator, w.owner(0)); +} + +#[test] +fn submit_transaction_rejects_a_non_owner() { + let env = Env::default(); + let w = wallet(&env); + let stranger = Address::generate(&env); + + assert_eq!(submit_expecting_error(&w, &stranger), Error::NotAnOwner); +} + +#[test] +fn get_transaction_returns_none_for_unknown_id() { + let env = Env::default(); + let w = wallet(&env); + assert!(w.client.get_transaction(&999).is_none()); +} + +// --------------------------------------------------------------------------- +// confirm_transaction +// --------------------------------------------------------------------------- + +#[test] +fn confirm_transaction_increments_the_count() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + w.client.confirm_transaction(&w.owner(0), &tx_id); + + let tx = w.client.get_transaction(&tx_id).unwrap(); + assert_eq!(tx.confirmations_count, 1); + assert_eq!(tx.status, TransactionStatus::Pending); +} + +#[test] +fn duplicate_confirmation_by_the_same_owner_is_rejected() { + // Otherwise one owner could reach any threshold alone. + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + w.client.confirm_transaction(&w.owner(0), &tx_id); + let res = w + .client + .try_confirm_transaction(&w.owner(0), &tx_id) + .expect_err("a second confirmation from the same owner must fail"); + assert_eq!(res, Ok(Error::AlreadyConfirmed)); + + let tx = w.client.get_transaction(&tx_id).unwrap(); + assert_eq!(tx.confirmations_count, 1, "count must not have moved"); +} + +#[test] +fn confirm_transaction_rejects_a_non_owner() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + let stranger = Address::generate(&env); + + let res = w + .client + .try_confirm_transaction(&stranger, &tx_id) + .expect_err("non-owners cannot confirm"); + assert_eq!(res, Ok(Error::NotAnOwner)); +} + +#[test] +fn confirm_transaction_rejects_an_unknown_transaction() { + let env = Env::default(); + let w = wallet(&env); + + let res = w + .client + .try_confirm_transaction(&w.owner(0), &4242) + .expect_err("unknown transaction"); + assert_eq!(res, Ok(Error::TransactionNotFound)); +} + +#[test] +fn confirm_transaction_rejects_an_expired_transaction() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + // Move the ledger past the 3600s deadline set at submission. + env.ledger().set_timestamp(env.ledger().timestamp() + 7200); + + let res = w + .client + .try_confirm_transaction(&w.owner(0), &tx_id) + .expect_err("past the deadline"); + assert_eq!(res, Ok(Error::TransactionExpired)); +} + +#[test] +fn expired_status_is_never_persisted_because_the_call_reverts() { + // confirm_transaction sets tx.status = Expired and writes it before + // returning Err(TransactionExpired). Soroban rolls back all storage writes + // when an invocation returns an error, so that write never lands and the + // transaction stays Pending forever. + // + // Pinning the real behaviour rather than the apparent intent: nothing can + // currently move a transaction into Expired, so any consumer filtering on + // that status will never match. Giving it a real transition would need a + // separate entrypoint that expires without erroring. + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + env.ledger().set_timestamp(env.ledger().timestamp() + 7200); + let _ = w.client.try_confirm_transaction(&w.owner(0), &tx_id); + + let tx = w.client.get_transaction(&tx_id).unwrap(); + assert_eq!( + tx.status, + TransactionStatus::Pending, + "the Expired write is rolled back with the failed invocation" + ); +} + +// --------------------------------------------------------------------------- +// revoke_confirmation +// --------------------------------------------------------------------------- + +#[test] +fn revoke_confirmation_decrements_the_count() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + w.client.confirm_transaction(&w.owner(0), &tx_id); + w.client.revoke_confirmation(&w.owner(0), &tx_id); + + let tx = w.client.get_transaction(&tx_id).unwrap(); + assert_eq!(tx.confirmations_count, 0); +} + +#[test] +fn revoking_allows_the_same_owner_to_confirm_again() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + w.client.confirm_transaction(&w.owner(0), &tx_id); + w.client.revoke_confirmation(&w.owner(0), &tx_id); + w.client.confirm_transaction(&w.owner(0), &tx_id); + + let tx = w.client.get_transaction(&tx_id).unwrap(); + assert_eq!(tx.confirmations_count, 1); +} + +#[test] +fn revoke_without_a_prior_confirmation_is_rejected() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + let res = w + .client + .try_revoke_confirmation(&w.owner(1), &tx_id) + .expect_err("nothing to revoke"); + assert_eq!(res, Ok(Error::Unauthorized)); +} + +#[test] +fn revoke_confirmation_rejects_a_non_owner() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + let stranger = Address::generate(&env); + + let res = w + .client + .try_revoke_confirmation(&stranger, &tx_id) + .expect_err("non-owners cannot revoke"); + assert_eq!(res, Ok(Error::NotAnOwner)); +} + +// --------------------------------------------------------------------------- +// cancel_transaction +// --------------------------------------------------------------------------- + +#[test] +fn initiator_can_cancel_their_own_transaction() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + w.client.cancel_transaction(&w.owner(0), &tx_id); + + let tx = w.client.get_transaction(&tx_id).unwrap(); + assert_eq!(tx.status, TransactionStatus::Revoked); +} + +#[test] +fn a_different_owner_cannot_cancel_someone_elses_transaction() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + let res = w + .client + .try_cancel_transaction(&w.owner(1), &tx_id) + .expect_err("only the initiator may cancel"); + assert_eq!(res, Ok(Error::Unauthorized)); +} + +#[test] +fn a_cancelled_transaction_cannot_be_confirmed() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + w.client.cancel_transaction(&w.owner(0), &tx_id); + + let res = w + .client + .try_confirm_transaction(&w.owner(1), &tx_id) + .expect_err("cancelled transactions are closed"); + assert_eq!(res, Ok(Error::TransactionAlreadyExecuted)); +} + +#[test] +fn cancel_transaction_rejects_an_unknown_transaction() { + let env = Env::default(); + let w = wallet(&env); + + let res = w + .client + .try_cancel_transaction(&w.owner(0), &7) + .expect_err("unknown transaction"); + assert_eq!(res, Ok(Error::TransactionNotFound)); +} + +// --------------------------------------------------------------------------- +// execute_transaction +// --------------------------------------------------------------------------- + +#[test] +fn execute_transaction_rejects_an_unknown_transaction() { + let env = Env::default(); + let w = wallet(&env); + + let res = w + .client + .try_execute_transaction(&123) + .expect_err("unknown transaction"); + assert_eq!(res, Ok(Error::TransactionNotFound)); +} + +#[test] +fn a_cancelled_transaction_cannot_be_executed() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + w.client.cancel_transaction(&w.owner(0), &tx_id); + + let res = w + .client + .try_execute_transaction(&tx_id) + .expect_err("cancelled transactions are closed"); + assert_eq!(res, Ok(Error::TransactionAlreadyExecuted)); +} + +// --------------------------------------------------------------------------- +// Proposal lifecycle +// --------------------------------------------------------------------------- + +#[test] +fn propose_add_owner_creates_a_pending_proposal() { + let env = Env::default(); + let w = wallet(&env); + let candidate = Address::generate(&env); + + let id = w.client.propose_add_owner(&w.owner(0), &candidate); + + let p = w.client.get_proposal(&id).unwrap(); + assert_eq!(p.proposal_type, ProposalType::AddOwner); + assert_eq!(p.status, ProposalStatus::Pending); + assert_eq!(p.target_address, Some(candidate)); + assert_eq!(p.confirmations_received, 0); +} + +#[test] +fn proposing_an_existing_owner_is_rejected() { + let env = Env::default(); + let w = wallet(&env); + + let res = w + .client + .try_propose_add_owner(&w.owner(0), &w.owner(1)) + .expect_err("already an owner"); + assert_eq!(res, Ok(Error::OwnerAlreadyExists)); +} + +#[test] +fn proposing_removal_of_a_non_owner_is_rejected() { + let env = Env::default(); + let w = wallet(&env); + let stranger = Address::generate(&env); + + let res = w + .client + .try_propose_remove_owner(&w.owner(0), &stranger) + .expect_err("not an owner"); + assert_eq!(res, Ok(Error::OwnerNotFound)); +} + +#[test] +fn removal_that_would_drop_below_the_threshold_is_rejected() { + // The boundary that matters: a 2-of-2 wallet cannot shed an owner, because + // the remaining owner could never reach the threshold again. + let env = Env::default(); + let w = wallet_with(&env, 2, 2); + + let res = w + .client + .try_propose_remove_owner(&w.owner(0), &w.owner(1)) + .expect_err("removal would make the threshold unreachable"); + assert_eq!(res, Ok(Error::InsufficientOwners)); +} + +#[test] +fn a_non_owner_cannot_raise_a_proposal() { + let env = Env::default(); + let w = wallet(&env); + let stranger = Address::generate(&env); + + let res = w + .client + .try_propose_change_threshold(&stranger, &1) + .expect_err("non-owners cannot propose"); + assert_eq!(res, Ok(Error::NotAnOwner)); +} + +#[test] +fn proposing_a_zero_threshold_is_rejected() { + let env = Env::default(); + let w = wallet(&env); + + let res = w + .client + .try_propose_change_threshold(&w.owner(0), &0) + .expect_err("zero threshold"); + assert_eq!(res, Ok(Error::InvalidThreshold)); +} + +#[test] +fn proposing_a_threshold_above_the_owner_count_is_rejected() { + let env = Env::default(); + let w = wallet(&env); + + let res = w + .client + .try_propose_change_threshold(&w.owner(0), &9) + .expect_err("unreachable threshold"); + assert_eq!(res, Ok(Error::InvalidThreshold)); +} + +#[test] +fn duplicate_proposal_confirmation_is_rejected() { + let env = Env::default(); + let w = wallet_with(&env, 3, 3); + let candidate = Address::generate(&env); + let id = w.client.propose_add_owner(&w.owner(0), &candidate); + + w.client.confirm_proposal(&w.owner(0), &id); + let res = w + .client + .try_confirm_proposal(&w.owner(0), &id) + .expect_err("one confirmation per owner"); + assert_eq!(res, Ok(Error::AlreadyConfirmed)); +} + +#[test] +fn a_non_owner_cannot_confirm_a_proposal() { + let env = Env::default(); + let w = wallet(&env); + let candidate = Address::generate(&env); + let id = w.client.propose_add_owner(&w.owner(0), &candidate); + let stranger = Address::generate(&env); + + let res = w + .client + .try_confirm_proposal(&stranger, &id) + .expect_err("non-owners cannot confirm"); + assert_eq!(res, Ok(Error::NotAnOwner)); +} + +#[test] +fn confirming_an_unknown_proposal_is_rejected() { + let env = Env::default(); + let w = wallet(&env); + + let res = w + .client + .try_confirm_proposal(&w.owner(0), &555) + .expect_err("unknown proposal"); + assert_eq!(res, Ok(Error::ProposalNotFound)); +} + +#[test] +fn reaching_the_threshold_adds_the_owner_and_marks_the_proposal_executed() { + let env = Env::default(); + let w = wallet(&env); + let candidate = Address::generate(&env); + let id = w.client.propose_add_owner(&w.owner(0), &candidate); + + w.client.confirm_proposal(&w.owner(0), &id); + w.client.confirm_proposal(&w.owner(1), &id); + + assert!(w.client.get_owners().contains(candidate.clone())); + assert_eq!( + w.client.get_proposal(&id).unwrap().status, + ProposalStatus::Executed + ); + assert!( + w.client.get_owner_profile(&candidate).is_some(), + "a new owner must get a profile" + ); +} + +#[test] +fn an_executed_proposal_cannot_be_executed_again() { + let env = Env::default(); + let w = wallet(&env); + let candidate = Address::generate(&env); + let id = w.client.propose_add_owner(&w.owner(0), &candidate); + w.client.confirm_proposal(&w.owner(0), &id); + w.client.confirm_proposal(&w.owner(1), &id); + + let res = w + .client + .try_execute_proposal(&id) + .expect_err("re-execution must be rejected"); + assert_eq!(res, Ok(Error::InvalidProposal)); + + assert_eq!( + w.client.get_owners().len(), + 4, + "the owner must not be added twice" + ); +} + +#[test] +fn a_proposal_below_the_threshold_cannot_be_executed() { + let env = Env::default(); + let w = wallet_with(&env, 3, 3); + let candidate = Address::generate(&env); + let id = w.client.propose_add_owner(&w.owner(0), &candidate); + w.client.confirm_proposal(&w.owner(0), &id); + + let res = w + .client + .try_execute_proposal(&id) + .expect_err("one of three confirmations is not enough"); + assert_eq!(res, Ok(Error::Unauthorized)); + assert!(!w.client.get_owners().contains(candidate)); +} + +#[test] +fn removing_an_owner_drops_their_profile() { + let env = Env::default(); + let w = wallet(&env); + let victim = w.owner(2); + let id = w.client.propose_remove_owner(&w.owner(0), &victim); + + w.client.confirm_proposal(&w.owner(0), &id); + w.client.confirm_proposal(&w.owner(1), &id); + + assert!(!w.client.get_owners().contains(victim.clone())); + assert!( + w.client.get_owner_profile(&victim).is_none(), + "a removed owner must not keep a profile" + ); +} + +#[test] +fn a_removed_owner_can_no_longer_confirm() { + // The security property behind SC-40: removal must actually revoke power. + let env = Env::default(); + let w = wallet(&env); + let victim = w.owner(2); + + let removal = w.client.propose_remove_owner(&w.owner(0), &victim); + w.client.confirm_proposal(&w.owner(0), &removal); + w.client.confirm_proposal(&w.owner(1), &removal); + assert!(!w.client.get_owners().contains(victim.clone())); + + let tx_id = submit(&w, &w.owner(0)); + let res = w + .client + .try_confirm_transaction(&victim, &tx_id) + .expect_err("a removed owner must not be able to confirm"); + assert_eq!(res, Ok(Error::NotAnOwner)); +} + +#[test] +fn executing_a_threshold_proposal_changes_the_threshold() { + let env = Env::default(); + let w = wallet(&env); + let id = w.client.propose_change_threshold(&w.owner(0), &3); + + w.client.confirm_proposal(&w.owner(0), &id); + w.client.confirm_proposal(&w.owner(1), &id); + + assert_eq!(w.client.get_threshold(), 3); + assert_eq!(w.client.get_required_confirmations(), 3); +} + +#[test] +fn get_proposal_returns_none_for_unknown_id() { + let env = Env::default(); + let w = wallet(&env); + assert!(w.client.get_proposal(&321).is_none()); +} + +// --------------------------------------------------------------------------- +// Freeze, unfreeze, daily limit +// --------------------------------------------------------------------------- + +#[test] +fn freezing_blocks_submission_and_unfreezing_restores_it() { + let env = Env::default(); + let w = wallet(&env); + + w.client.emergency_freeze(&w.owner(0)); + assert!(w.client.is_frozen()); + + assert_eq!(submit_expecting_error(&w, &w.owner(0)), Error::WalletFrozen); + + w.client.emergency_unfreeze(&w.owner(0)); + assert!(!w.client.is_frozen()); + assert_eq!(submit(&w, &w.owner(0)), 1); +} + +#[test] +fn freezing_blocks_confirmation() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + w.client.emergency_freeze(&w.owner(0)); + + let res = w + .client + .try_confirm_transaction(&w.owner(1), &tx_id) + .expect_err("frozen wallets reject confirmations"); + assert_eq!(res, Ok(Error::WalletFrozen)); +} + +#[test] +fn reads_still_work_while_frozen() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + w.client.emergency_freeze(&w.owner(0)); + + assert!(w.client.is_frozen()); + assert_eq!(w.client.get_threshold(), 2); + assert_eq!(w.client.get_owners().len(), 3); + assert!(w.client.get_transaction(&tx_id).is_some()); +} + +#[test] +fn a_non_owner_cannot_freeze_or_unfreeze() { + let env = Env::default(); + let w = wallet(&env); + let stranger = Address::generate(&env); + + let res = w + .client + .try_emergency_freeze(&stranger) + .expect_err("non-owners cannot freeze"); + assert_eq!(res, Ok(Error::NotAnOwner)); + + w.client.emergency_freeze(&w.owner(0)); + let res = w + .client + .try_emergency_unfreeze(&stranger) + .expect_err("non-owners cannot unfreeze"); + assert_eq!(res, Ok(Error::NotAnOwner)); +} + +#[test] +fn set_daily_limit_stores_the_limit() { + let env = Env::default(); + let w = wallet(&env); + + w.client.set_daily_limit(&w.owner(0), &5_000u128); + // No getter is exposed; the limit is observable through its effect on + // execution, so assert the call is accepted from an owner and rejected + // from a stranger. + let stranger = Address::generate(&env); + let res = w + .client + .try_set_daily_limit(&stranger, &1u128) + .expect_err("non-owners cannot change the limit"); + assert_eq!(res, Ok(Error::NotAnOwner)); +} + +// --------------------------------------------------------------------------- +// Authorization — no mock_all_auths +// --------------------------------------------------------------------------- + +/// Builds a wallet, then clears the mocked auths so subsequent calls must +/// carry real authorization. +fn wallet_then_drop_auths(env: &Env) -> Wallet<'_> { + let w = wallet(env); + env.set_auths(&[]); + w +} + +#[test] +fn submit_transaction_requires_the_initiators_authorization() { + let env = Env::default(); + let w = wallet_then_drop_auths(&env); + + let target = Address::generate(&env); + let res = w.client.try_submit_transaction( + &w.owner(0), + &TransactionType::Routine, + &target, + &Symbol::new(&env, "noop"), + &Vec::new(&env), + &3600, + &0, + ); + assert!( + res.is_err(), + "submit_transaction must fail without the initiator's auth" + ); +} + +#[test] +fn confirm_transaction_requires_the_confirmers_authorization() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + env.set_auths(&[]); + + let res = w.client.try_confirm_transaction(&w.owner(1), &tx_id); + assert!( + res.is_err(), + "confirm_transaction must fail without the confirmer's auth" + ); +} + +#[test] +fn emergency_freeze_requires_authorization() { + let env = Env::default(); + let w = wallet_then_drop_auths(&env); + + let res = w.client.try_emergency_freeze(&w.owner(0)); + assert!( + res.is_err(), + "emergency_freeze must fail without the caller's auth" + ); +} + +#[test] +fn propose_add_owner_requires_the_proposers_authorization() { + let env = Env::default(); + let w = wallet_then_drop_auths(&env); + let candidate = Address::generate(&env); + + let res = w.client.try_propose_add_owner(&w.owner(0), &candidate); + assert!( + res.is_err(), + "propose_add_owner must fail without the proposer's auth" + ); +} + +// --------------------------------------------------------------------------- +// Error reachability +// --------------------------------------------------------------------------- + +#[test] +fn every_error_variant_asserted_here_is_distinct() { + // Guards against two variants collapsing onto the same discriminant during + // a renumbering, which would make the assertions above vacuous. + let codes = [ + Error::AlreadyInitialized as u32, + Error::NotInitialized as u32, + Error::Unauthorized as u32, + Error::InvalidThreshold as u32, + Error::InsufficientOwners as u32, + Error::TransactionNotFound as u32, + Error::TransactionAlreadyExecuted as u32, + Error::TransactionExpired as u32, + Error::AlreadyConfirmed as u32, + Error::OwnerAlreadyExists as u32, + Error::OwnerNotFound as u32, + Error::ProposalNotFound as u32, + Error::WalletFrozen as u32, + Error::InvalidProposal as u32, + Error::NotAnOwner as u32, + ]; + + for (i, a) in codes.iter().enumerate() { + for b in codes.iter().skip(i + 1) { + assert_ne!(a, b, "error discriminants must be unique"); + } + } +} + +#[test] +fn uninitialized_wallet_reports_not_initialized() { + let env = Env::default(); + let contract_id = env.register(MultisigWallet, ()); + let client = MultisigWalletClient::new(&env, &contract_id); + let stranger = Address::generate(&env); + env.mock_all_auths(); + + let target = Address::generate(&env); + let res = client + .try_submit_transaction( + &stranger, + &TransactionType::Routine, + &target, + &Symbol::new(&env, "noop"), + &Vec::new(&env), + &3600, + &0, + ) + .expect_err("nothing is initialized"); + assert_eq!(res, Ok(Error::NotInitialized)); +} + +#[test] +fn transaction_parameters_round_trip() { + let env = Env::default(); + let w = wallet(&env); + let target = Address::generate(&env); + let params: Vec