diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 14a57ce9..a48748ed 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,93 @@ 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 + + # Gates on actual vulnerabilities. Deliberately not --deny warnings: + # that promotes *yanked* crates to errors, and soroban-sdk pins yanked + # versions of spin and keccak inside its own tree that no cargo update + # can resolve. Yanked and unmaintained advisories are handled by cargo + # deny below, where deny.toml records each one with a reason and a review + # date rather than failing the build indefinitely. + - name: cargo audit + run: cargo audit --file Cargo.lock + + # 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 89a6be20..b89442ef 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 00000000..f16bcd91 --- /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.lock b/contracts/Cargo.lock index 03925589..68edb4bc 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -453,11 +453,10 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.4" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -901,9 +900,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -1589,30 +1588,29 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index e309bd3a..ebf91260 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 d0d91ef1..78d5a0d1 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 6ea6dc4d..3f35c212 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 ab1f712f..25d4d1a7 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 00000000..2177c72e --- /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 5bb96956..a379139c 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 70038ef8..7faff53a 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 ef17fe6c..3cdcbb2c 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 00000000..463c83bd --- /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