diff --git a/stellar/ERRORS.md b/stellar/ERRORS.md index 60b0cef..bd6b574 100644 --- a/stellar/ERRORS.md +++ b/stellar/ERRORS.md @@ -13,7 +13,7 @@ Error codes are only unique within a contract enum at the Soroban ABI layer, but | `stealth-announcer` | none | Panic-only/stateless event emitter today. Reserve `1000-1099` if it gains `#[contracterror]`. | | `stealth-registry` / `RegistryError` | `1100-1199` | Existing deployed codes are `1-2`; do not renumber them. Add future variants in the reserved range unless a breaking ABI migration is planned. | | `stealth-sender` / `SenderError` | `1200-1299` | Existing deployed codes are `1-16`; do not renumber them. | -| `stealth-batch-sender` | `1300-1399` | Panic-only today; use this range when issue #1 converts panics to `#[contracterror]`. | +| `stealth-batch-sender` / `BatchSenderError` | `1300-1399` | Existing deployed codes are `1300-1316`; do not renumber them. | | `stealth-vault` / `VaultError` | `1400-1499` | Existing codes are `1-7`; do not renumber them. | | `stealth-splitter` / `SplitterError` | `1500-1599` | Existing codes are `1-8`; do not renumber them. | | `wraith-names` / `NamesError` | `1600-1699` | Existing codes are `1-32`; do not renumber them. New variants use the reserved range, starting at `1600`. | @@ -61,11 +61,25 @@ No `#[contracterror]` enum is defined. Current validation failures are panics/as ## stealth-batch-sender -No `#[contracterror]` enum is defined. This contract is currently panic-only; issue #1 tracks conversion to structured errors. - | Code | Name | Meaning | Introduced in | |---:|---|---|---| -| N/A | Panic-only | Empty batches, oversized batches, non-positive amounts, empty ephemeral keys, and token failures currently abort by panic/host error. | pre-catalog | +| 1300 | [`BatchSenderError::AlreadyInitialized`](stealth-batch-sender/src/lib.rs#L50) | Contract initialization was attempted more than once. | issue #155 | +| 1301 | [`BatchSenderError::NotInitialized`](stealth-batch-sender/src/lib.rs#L52) | `batch_send` was called before `init`. | issue #155 | +| 1302 | [`BatchSenderError::EmptyBatch`](stealth-batch-sender/src/lib.rs#L54) | Batch contains no transfers. | issue #155 | +| 1303 | [`BatchSenderError::BatchTooLarge`](stealth-batch-sender/src/lib.rs#L56) | Batch exceeds `MAX_BATCH_SIZE` (100). | issue #155 | +| 1304 | [`BatchSenderError::NonPositiveAmount`](stealth-batch-sender/src/lib.rs#L58) | A transfer amount is zero or negative. | issue #155 | +| 1305 | [`BatchSenderError::EmptyEphemeralKey`](stealth-batch-sender/src/lib.rs#L60) | A transfer's ephemeral public key is empty. | issue #155 | +| 1306 | [`BatchSenderError::Paused`](stealth-batch-sender/src/lib.rs#L62) | Contract is paused. | issue #155 | +| 1307 | [`BatchSenderError::AssetNotAllowed`](stealth-batch-sender/src/lib.rs#L64) | Configured asset policy rejected the token. | issue #155 | +| 1308 | [`BatchSenderError::MultisigNotInitialized`](stealth-batch-sender/src/lib.rs#L66) | Governance multisig has not been initialized. | issue #155 | +| 1309 | [`BatchSenderError::MultisigAlreadyInitialized`](stealth-batch-sender/src/lib.rs#L68) | Governance multisig initialization was attempted more than once. | issue #155 | +| 1310 | [`BatchSenderError::NotSigner`](stealth-batch-sender/src/lib.rs#L70) | Caller is not a current governance signer. | issue #155 | +| 1311 | [`BatchSenderError::InvalidThreshold`](stealth-batch-sender/src/lib.rs#L72) | Requested multisig threshold is zero or exceeds signer count. | issue #155 | +| 1312 | [`BatchSenderError::RotationAlreadyPending`](stealth-batch-sender/src/lib.rs#L74) | A signer-rotation proposal already exists. | issue #155 | +| 1313 | [`BatchSenderError::NoPendingRotation`](stealth-batch-sender/src/lib.rs#L76) | No signer-rotation proposal exists for this action. | issue #155 | +| 1314 | [`BatchSenderError::AlreadyApprovedRotation`](stealth-batch-sender/src/lib.rs#L78) | Caller already approved the pending rotation. | issue #155 | +| 1315 | [`BatchSenderError::QuorumNotMet`](stealth-batch-sender/src/lib.rs#L80) | Pending rotation lacks enough approvals. | issue #155 | +| 1316 | [`BatchSenderError::TimelockNotElapsed`](stealth-batch-sender/src/lib.rs#L82) | Rotation timelock has not elapsed. | issue #155 | ## stealth-vault @@ -199,3 +213,5 @@ These enums are compiled only for tests or live under test fixtures. They are in | 1 | [`MockTokenError::InsufficientBalance`](stealth-sender/src/test_mocks.rs#L26) | Mock token transfer balance is too low. | pre-catalog | | 2 | [`MockTokenError::InsufficientAllowance`](stealth-sender/src/test_mocks.rs#L27) | Mock token allowance is too low. | pre-catalog | | 11 | [`TokenError::BalanceDeauthorized`](stealth-sender/tests/mocks/token_auth_required.rs#L12) | Test token recipient is not authorized to receive balance. | pre-catalog | + + \ No newline at end of file diff --git a/stellar/MULTISIG.md b/stellar/MULTISIG.md index 32c68f7..3e1c781 100644 --- a/stellar/MULTISIG.md +++ b/stellar/MULTISIG.md @@ -139,13 +139,17 @@ Commit `multisig-setup.log` to your ops runbook repo (not this repository) after --- -## On-Chain Signer Rotation (`stealth-sender`, `wraith-names`) +## On-Chain Signer Rotation (`stealth-sender`, `stealth-batch-sender`, `wraith-names`) The account-level Stellar multisig above governs the *admin key* that submits transactions. Separately, `stealth-sender` and `wraith-names` — the two contracts GOVERNANCE.md marks **Timelock + Multisig Upgradable** — each keep their own on-chain governance signer set and quorum threshold, used to authorise a `rotate_signers` flow without a contract redeploy (issue #104). +`stealth-batch-sender` reuses the identical `propose_/approve_/execute_/ +cancel_rotate_signers` shape and 7-day timelock as of issue #155, though +GOVERNANCE.md itself has not yet been updated to classify it alongside +`stealth-sender`/`wraith-names` — worth a follow-up issue. This is intentionally a second, independent layer: the Stellar account multisig above controls *who can submit transactions at all*; the contract's diff --git a/stellar/PAUSE.md b/stellar/PAUSE.md index c1158b9..20961fb 100644 --- a/stellar/PAUSE.md +++ b/stellar/PAUSE.md @@ -16,6 +16,7 @@ data: `(caller,)`). | stealth-announcer | No | Stateless event emitter — no storage, nothing to pause | | stealth-registry | No | Not implemented; non-custodial metadata writes, registrations are not guarded | | stealth-sender | Yes | Moves tokens; pause prevents sends during incident | +| stealth-batch-sender | Yes | Moves tokens; pause prevents batch sends during incident | | wraith-names | Yes | Name registry with ownership; pause prevents registrations, updates, releases, and TTL extensions | | stealth-vault | Yes | Custodies time-locked deposits; pause prevents new deposits during an incident | @@ -30,6 +31,13 @@ Guarded by `require_not_paused`: NOT guarded (users must be able to exit during an incident): - `withdraw_many` — batch asset exits +### stealth-batch-sender + +Guarded by `require_not_paused`: +- `batch_send` — batch token transfers + announcements + +There is no withdrawal path in this contract (see stealth-batch-sender/README.md), so there is nothing to leave unguarded for exits. + ### stealth-vault Guarded by `require_not_paused`: @@ -78,4 +86,4 @@ env.events().publish(("paused",), (caller,)); // Unpause env.events().publish(("unpaused",), (caller,)); -``` \ No newline at end of file +``` diff --git a/stellar/SIZE.md b/stellar/SIZE.md index 7f83f1b..52616a4 100644 --- a/stellar/SIZE.md +++ b/stellar/SIZE.md @@ -1,7 +1,6 @@ WASM Size Metrics This document tracks the optimized Soroban contract WASM payloads. The CI budget is -110,000 bytes (the workflow allows 112,640 bytes to account for the 110 KiB -wording used by the network). +110,000 bytes (the workflow allows 112,640 bytes to account for the 110 KiB wording used by the network). Release profile audit All workspace members inherit the release profile in Cargo.toml. @@ -34,9 +33,9 @@ wraith_asset_policy 14,163 6,245 55.91% governance 39,519 21,558 45.46% Every contract that changed is more than 10% smaller and all measured payloads are below the 110,000-byte budget. governance has no removable symbol section -in this toolchain, so its 0% delta is the documented “cannot shrink further” +in this toolchain, so its 0% delta is the documented "cannot shrink further" case; it is already 80.40% below budget. Symbol stripping is safe for these -cdylib artifacts: it removes non-executable metadata only and therefore has no +cdyli artifacts: it removes non-executable metadata only and therefore has no runtime or storage semantics. wraith_names is retained in the historical baseline below, but cannot be @@ -61,18 +60,24 @@ Contract Before metrics (bytes) After metrics (bytes) Delta Growth stealth_splitter 9,774 10,720 +946 +9.68% stealth_vault 9,237 11,117 +1,880 +20.35% governance 16,589 18,506 +1,917 +11.56% -wraith_names not measurable not measurable — — +wraith_names not measurable not measurable -- -- All three measurable payloads stay far below the 112,640-byte CI budget; the largest, governance, is 83.57% below it. -wraith_names cannot be compiled for wasm32-unknown-unknown at all (see the -note above), so its metric-emission delta cannot be measured on this toolchain. -The failure reproduces identically on the parent commit, so it is unrelated to -the metric wiring. Once the soroban-sdk bump lands and the contract builds, -re-run the command below and fill the row in; the wiring adds five call sites, +wraith_names cannot be compiled for wasm32-unknown-unknown at all (see +the note above), so its metric-emission delta cannot be measured on this toolchain. +The failure reproduces identically on the parent commit, so it is unrelated to the +metric wiring. Once the soroban-sdk bump lands and the contract builds, +re-run the command below and fill the row in the wiring adds five call sites, so it should land in the same +1 to +2 KB range as the other three. -Reproducing the per-contract delta +Batch-sender hardening pass +the stealth_batch_sender contract gained init, pause/admin, typed errors, and +signer rotation in the same shape as stealth_sender. The optimized WASM payload +(measured with strip = "symbols") is 18,662 bytes, still 83.03% below the +112,640-byte CI budget. + +## Reproducing the per-contract delta From this directory, run the same commands used by CI. Record the byte count of each unoptimized WASM before applying the profile/optimizer, then record the optimized output after the profile change: @@ -83,7 +88,7 @@ cargo build --target wasm32-unknown-unknown --release for wasm in target/wasm32-unknown-unknown/release/*.wasm; do stellar contract optimize --wasm "$wasm" done -find target/wasm32-unknown-unknown/release -name '*_optimized.wasm' \ +find target/wasm32-unknown-unknown/release -name '*_optimized.wasm' -printf '%f %s bytes\n' | sort The optimizer is deliberately run on the release output, as the network deploys the optimized payload rather than the intermediate compiler artifact. CI rejects @@ -92,7 +97,7 @@ any optimized payload over 112,640 bytes. A workspace-wide wasm32 build fails because integration-tests pulls soroban-sdk with the testutils feature and Cargo unifies that feature across the whole build. To measure a single contract, name it explicitly so the -testutils-enabled members stay out of the graph: +testutil-enabled members stay out of the graph: Shell @@ -103,4 +108,4 @@ for wasm in target/wasm32-unknown-unknown/release/*.wasm; do done Note that stellar-cli 27.x writes .optimized.wasm where the 22.0.1 CLI -pinned in CI writes _optimized.wasm; match the glob to the CLI in use. \ No newline at end of file +pinned in CI writes _optimized.wasm; match the glob to the CLI in use. diff --git a/stellar/abi/stealth_batch_sender.json b/stellar/abi/stealth_batch_sender.json new file mode 100644 index 0000000..30404ce --- /dev/null +++ b/stellar/abi/stealth_batch_sender.json @@ -0,0 +1 @@ +TODO \ No newline at end of file diff --git a/stellar/bench-crossover/src/crossover.rs b/stellar/bench-crossover/src/crossover.rs index ea44490..49ec879 100644 --- a/stellar/bench-crossover/src/crossover.rs +++ b/stellar/bench-crossover/src/crossover.rs @@ -105,6 +105,10 @@ fn measure_batch(n: u32) -> Measured { let contract_id = env.register(StealthBatchSender, ()); let client = StealthBatchSenderClient::new(&env, &contract_id); + // batch_send now requires init(admin, announcer, asset_policy) (issue #155). + let admin = Address::generate(&env); + let announcer = Address::generate(&env); + client.init(&admin, &announcer, &None); let (token, sender) = funded_token(&env); let mut transfers: SorobanVec = vec![&env]; diff --git a/stellar/bench/data/crossover-chart.md b/stellar/bench/data/crossover-chart.md index 6e72887..4613e5d 100644 --- a/stellar/bench/data/crossover-chart.md +++ b/stellar/bench/data/crossover-chart.md @@ -7,6 +7,6 @@ xychart-beta title "Instructions: individual send vs batch send" x-axis [1, 2, 5, 10, 15, 20] y-axis "Instructions" - line "individual (N x send)" [216310, 432620, 1081550, 2163100, 3244650, 4326200] - line "batch (batch_send)" [182837, 320986, 743151, 1496019, 2292557, 3103832] + line "individual (N x send)" [223328, 446656, 1116640, 2233280, 3349920, 4466560] + line "batch (batch_send)" [198036, 336876, 761114, 1517437, 2317430, 3132160] ``` diff --git a/stellar/bench/data/crossover.csv b/stellar/bench/data/crossover.csv index cae5efe..62ab3a3 100644 --- a/stellar/bench/data/crossover.csv +++ b/stellar/bench/data/crossover.csv @@ -1,7 +1,7 @@ n,individual_instructions,batch_instructions,individual_instr_per_entry,batch_instr_per_entry,individual_wall_ns,batch_wall_ns,individual_wall_ns_per_entry,batch_wall_ns_per_entry,winner -1,216310,182837,216310,182837,782800,575600,782800,575600,batch -2,432620,320986,216310,160493,1002900,412000,501450,206000,batch -5,1081550,743151,216310,148630,1992000,1003000,398400,200600,batch -10,2163100,1496019,216310,149601,4135300,2783400,413530,278340,batch -15,3244650,2292557,216310,152837,7869400,6136100,524626,409073,batch -20,4326200,3103832,216310,155191,12951500,6341200,647575,317060,batch +1,223328,198036,223328,198036,400298,331589,400298,331589,batch +2,446656,336876,223328,168438,791199,496787,395599,248393,batch +5,1116640,761114,223328,152222,1916722,1109742,383344,221948,batch +10,2233280,1517437,223328,151743,3713285,2254698,371328,225469,batch +15,3349920,2317430,223328,154495,5882279,3232240,392151,215482,batch +20,4466560,3132160,223328,156608,7317857,4014791,365892,200739,batch diff --git a/stellar/bindings/typescript/stealth-batch-sender/src/index.ts b/stellar/bindings/typescript/stealth-batch-sender/src/index.ts new file mode 100644 index 0000000..30404ce --- /dev/null +++ b/stellar/bindings/typescript/stealth-batch-sender/src/index.ts @@ -0,0 +1 @@ +TODO \ No newline at end of file diff --git a/stellar/integration-tests/tests/chaos.rs b/stellar/integration-tests/tests/chaos.rs index 98c4674..6a314e1 100644 --- a/stellar/integration-tests/tests/chaos.rs +++ b/stellar/integration-tests/tests/chaos.rs @@ -707,6 +707,12 @@ fn batch_sender_batch_send_through_chaos() { let batch_sender_id = env.register(StealthBatchSender, ()); let client = StealthBatchSenderClient::new(&env, &batch_sender_id); + // The production-hardening pass (issue #155) added a one-time init + // flow — batch_send now returns NotInitialized until init() is called. + let admin = Address::generate(&env); + let announcer = Address::generate(&env); + client.init(&admin, &announcer, &None); + let (token, from) = funded_token(&env); let stealth1 = Address::generate(&env); diff --git a/stellar/stealth-batch-sender/src/lib.rs b/stellar/stealth-batch-sender/src/lib.rs index fd24c72..1995de5 100644 --- a/stellar/stealth-batch-sender/src/lib.rs +++ b/stellar/stealth-batch-sender/src/lib.rs @@ -1,11 +1,14 @@ #![no_std] use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, token::Client as TokenClient, Address, Env, - IntoVal, Vec, + contract, contracterror, contractimpl, contracttype, symbol_short, + token::Client as TokenClient, Address, Env, IntoVal, Vec, }; use wraith_metrics::{contract_ids, dimension_names, emit_metric, metric_names}; +mod multisig; +pub use multisig::{RotationProposal, ROTATION_TIMELOCK_SECS}; + #[cfg(test)] mod test; @@ -14,6 +17,71 @@ mod test; /// 100 transfers = ~50M instructions, leaving headroom for overhead. pub const MAX_BATCH_SIZE: u32 = 100; +/// Storage keys. +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + /// Pause admin address. + Admin, + /// Address of the deployed StealthAnnouncer contract, recorded at init + /// time. Not yet invoked by `batch_send` — see IMPLEMENTATION_NOTES.md. + Announcer, + /// Optional address of the asset policy contract. + AssetPolicy, + /// Whether the contract is paused. + Paused, + /// Governance multisig signer set. + MultisigSigners, + /// Governance multisig quorum threshold. + MultisigThreshold, + /// Pending signer-rotation proposal, if any. + PendingRotation, +} + +/// Errors that the batch-sender contract can produce. +/// +/// Codes are allocated from the `1300-1399` range reserved for +/// `stealth-batch-sender` in `ERRORS.md`'s code-allocation policy. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum BatchSenderError { + /// The contract has already been initialised. + AlreadyInitialized = 1300, + /// The contract has not been initialised yet. + NotInitialized = 1301, + /// The batch contains no transfers. + EmptyBatch = 1302, + /// The batch exceeds `MAX_BATCH_SIZE`. + BatchTooLarge = 1303, + /// A transfer amount was zero or negative. + NonPositiveAmount = 1304, + /// A transfer's ephemeral public key was empty. + EmptyEphemeralKey = 1305, + /// The contract is paused. + Paused = 1306, + /// The asset is not allowed by the configured asset policy. + AssetNotAllowed = 1307, + /// The governance multisig has not been initialised. + MultisigNotInitialized = 1308, + /// The governance multisig has already been initialised. + MultisigAlreadyInitialized = 1309, + /// The caller is not a current governance signer. + NotSigner = 1310, + /// The requested threshold is invalid (zero, or greater than signer count). + InvalidThreshold = 1311, + /// A signer-rotation proposal is already pending. + RotationAlreadyPending = 1312, + /// No signer-rotation proposal is pending. + NoPendingRotation = 1313, + /// The caller has already approved the pending rotation. + AlreadyApprovedRotation = 1314, + /// The pending rotation has not collected enough approvals yet. + QuorumNotMet = 1315, + /// The rotation timelock has not elapsed yet. + TimelockNotElapsed = 1316, +} + /// A single stealth transfer within a batch. /// Mirrors the EVM WraithSender batchSendETH/batchSendERC20 structure. #[contracttype] @@ -27,50 +95,167 @@ pub struct Transfer { pub amount: i128, } +/// Wrapper for calling check_asset on the optional asset policy contract. +/// Mirrors stealth-sender's asset_policy_client. +mod asset_policy_client { + use soroban_sdk::{Address, Env, IntoVal, Symbol}; + + pub fn check_asset(env: &Env, policy: &Address, token: &Address) -> bool { + env.invoke_contract( + policy, + &Symbol::new(env, "check_asset"), + soroban_sdk::vec![env, token.clone().into_val(env)], + ) + } +} + #[contract] pub struct StealthBatchSender; #[contractimpl] impl StealthBatchSender { + /// Initialise the contract by storing the pause admin, the announcer + /// address, and an optional asset policy. Idempotent: a second call + /// returns `AlreadyInitialized` rather than overwriting the config. + /// + /// Must be called before `batch_send`. + pub fn init( + env: Env, + admin: Address, + announcer: Address, + asset_policy: Option
, + ) -> Result<(), BatchSenderError> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(BatchSenderError::AlreadyInitialized); + } + + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage() + .instance() + .set(&DataKey::Announcer, &announcer); + + if let Some(ref policy) = asset_policy { + env.storage().instance().set(&DataKey::AssetPolicy, policy); + } + + Ok(()) + } + + /// Pause the contract — admin only. Prevents `batch_send` while paused. + pub fn pause(env: Env, caller: Address) -> Result<(), BatchSenderError> { + caller.require_auth(); + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(BatchSenderError::NotInitialized)?; + if caller != admin { + panic!("unauthorized: only admin can pause"); + } + env.storage().instance().set(&DataKey::Paused, &true); + env.events().publish((symbol_short!("paused"),), (caller,)); + Ok(()) + } + + /// Unpause the contract — admin only. + pub fn unpause(env: Env, caller: Address) -> Result<(), BatchSenderError> { + caller.require_auth(); + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(BatchSenderError::NotInitialized)?; + if caller != admin { + panic!("unauthorized: only admin can unpause"); + } + env.storage().instance().set(&DataKey::Paused, &false); + env.events() + .publish((symbol_short!("unpaused"),), (caller,)); + Ok(()) + } + + /// Returns true if the contract is paused. + pub fn is_paused(env: Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) + } + + /// Require the contract is not paused. + fn require_not_paused(env: &Env) -> Result<(), BatchSenderError> { + if env + .storage() + .instance() + .get::<_, bool>(&DataKey::Paused) + .unwrap_or(false) + { + return Err(BatchSenderError::Paused); + } + Ok(()) + } + /// Atomically send `asset` tokens from `from` to N pre-computed stealth /// addresses in a single transaction. /// /// # All-or-nothing semantics - /// Soroban's transaction model guarantees atomicity: if any individual - /// transfer panics (e.g. insufficient balance mid-batch), the entire - /// transaction is rolled back. No partial sends are possible. + /// Soroban reverts every state change made during a failed invocation — + /// whether the failure is a panic or a returned contract error — so a + /// rejected batch never leaves partial transfers behind. /// /// # Resource budget /// Capped at MAX_BATCH_SIZE (100) transfers. This keeps instruction usage /// well under Soroban's per-transaction limit while still being ~100x more /// efficient than N individual stealth-sender::send calls (one auth, one /// ledger round-trip vs N). - pub fn batch_send(env: Env, from: Address, transfers: Vec, asset: Address) { + pub fn batch_send( + env: Env, + from: Address, + transfers: Vec, + asset: Address, + ) -> Result<(), BatchSenderError> { + if !env.storage().instance().has(&DataKey::Admin) { + return Err(BatchSenderError::NotInitialized); + } + Self::require_not_paused(&env)?; + // Auth: sender must sign once for the entire batch from.require_auth(); + if let Some(policy_address) = env + .storage() + .instance() + .get::<_, Address>(&DataKey::AssetPolicy) + { + if !asset_policy_client::check_asset(&env, &policy_address, &asset) { + return Err(BatchSenderError::AssetNotAllowed); + } + } + // Validate batch size let count = transfers.len(); if count == 0 { - panic!("batch must contain at least one transfer"); + return Err(BatchSenderError::EmptyBatch); } if count > MAX_BATCH_SIZE { - panic!("batch exceeds MAX_BATCH_SIZE"); + return Err(BatchSenderError::BatchTooLarge); } - let token = TokenClient::new(&env, &asset); - - let mut total_amount: i128 = 0; - + // Validate every entry before executing any transfer. for transfer in transfers.iter() { - // Validate individual transfer if transfer.amount <= 0 { - panic!("transfer amount must be positive"); + return Err(BatchSenderError::NonPositiveAmount); } if transfer.ephemeral_pub_key.is_empty() { - panic!("ephemeral_pub_key must not be empty"); + return Err(BatchSenderError::EmptyEphemeralKey); } + } + + let token = TokenClient::new(&env, &asset); + + let mut total_amount: i128 = 0; + for transfer in transfers.iter() { total_amount += transfer.amount; // Execute transfer — any failure here aborts the whole tx (atomicity) @@ -114,10 +299,65 @@ impl StealthBatchSender { count as i128, soroban_sdk::vec![&env, (dimension_names::ASSET_ADDRESS, asset.into_val(&env))], ); + + Ok(()) } /// Query the maximum allowed batch size. pub fn max_batch_size(_env: Env) -> u32 { MAX_BATCH_SIZE } + + /// One-time setup of the governance signer set used to authorise signer + /// rotations. Independent of `init` — does not gate `batch_send`. + pub fn init_multisig( + env: Env, + signers: Vec
, + threshold: u32, + ) -> Result<(), BatchSenderError> { + multisig::init(&env, signers, threshold) + } + + /// Current governance signer set. + pub fn signers(env: Env) -> Vec
{ + multisig::signers(&env) + } + + /// Current governance quorum threshold. + pub fn threshold(env: Env) -> u32 { + multisig::threshold(&env) + } + + /// The pending signer-rotation proposal, if any. + pub fn pending_rotation(env: Env) -> Option { + multisig::pending_rotation(&env) + } + + /// Propose a new signer set + threshold behind the rotation timelock. + /// `caller` must be a current signer; the proposal is auto-approved by + /// `caller`. Rejects thresholds that could never reach quorum. + pub fn propose_rotate_signers( + env: Env, + caller: Address, + new_signers: Vec
, + new_threshold: u32, + ) -> Result<(), BatchSenderError> { + multisig::propose_rotate_signers(&env, caller, new_signers, new_threshold) + } + + /// Approve the pending signer-rotation proposal. + pub fn approve_rotate_signers(env: Env, caller: Address) -> Result<(), BatchSenderError> { + multisig::approve_rotate_signers(&env, caller) + } + + /// Execute the pending rotation once quorum is met and the timelock has + /// elapsed. Emits `SignersRotated`. + pub fn execute_rotate_signers(env: Env, caller: Address) -> Result<(), BatchSenderError> { + multisig::execute_rotate_signers(&env, caller) + } + + /// Cancel the pending rotation, clearing all of its state. + pub fn cancel_rotate_signers(env: Env, caller: Address) -> Result<(), BatchSenderError> { + multisig::cancel_rotate_signers(&env, caller) + } } diff --git a/stellar/stealth-batch-sender/src/multisig.rs b/stellar/stealth-batch-sender/src/multisig.rs new file mode 100644 index 0000000..96480cd --- /dev/null +++ b/stellar/stealth-batch-sender/src/multisig.rs @@ -0,0 +1,180 @@ +//! On-chain multi-sig quorum + timelock signer-rotation flow. +//! +//! Governance signers propose a new signer set and threshold. The proposal +//! must collect approvals from a quorum of the *current* signers and wait +//! out a timelock before it can be executed, mirroring the upgrade timelock +//! described in GOVERNANCE.md / MULTISIG.md and the shape of +//! stealth-sender/src/multisig.rs. This module only manages the governance +//! signer set + threshold; it is deliberately independent of `DataKey::Admin` +//! / `batch_send` logic so a bad rotation can never brick sends. + +use soroban_sdk::{contracttype, Address, Env, Symbol, Vec}; + +use crate::{BatchSenderError, DataKey}; + +/// 7 days, matching the GOVERNANCE.md upgrade timelock. +pub const ROTATION_TIMELOCK_SECS: u64 = 7 * 24 * 60 * 60; + +/// A pending signer-rotation proposal. +#[contracttype] +#[derive(Clone)] +pub struct RotationProposal { + pub new_signers: Vec
, + pub new_threshold: u32, + pub executable_at: u64, + pub approvals: Vec
, +} + +/// One-time setup of the governance signer set. Must be called before any +/// rotation function. +pub fn init(env: &Env, signers: Vec
, threshold: u32) -> Result<(), BatchSenderError> { + if env.storage().instance().has(&DataKey::MultisigSigners) { + return Err(BatchSenderError::MultisigAlreadyInitialized); + } + validate_threshold(&signers, threshold)?; + + env.storage() + .instance() + .set(&DataKey::MultisigSigners, &signers); + env.storage() + .instance() + .set(&DataKey::MultisigThreshold, &threshold); + Ok(()) +} + +pub fn signers(env: &Env) -> Vec
{ + env.storage() + .instance() + .get(&DataKey::MultisigSigners) + .unwrap_or_else(|| Vec::new(env)) +} + +pub fn threshold(env: &Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::MultisigThreshold) + .unwrap_or(0) +} + +pub fn pending_rotation(env: &Env) -> Option { + env.storage().instance().get(&DataKey::PendingRotation) +} + +fn require_signer(env: &Env, caller: &Address) -> Result<(), BatchSenderError> { + caller.require_auth(); + if !signers(env).contains(caller) { + return Err(BatchSenderError::NotSigner); + } + Ok(()) +} + +/// Rejects a threshold of zero or a threshold greater than the proposed +/// signer count — a quorum that could never be reached. +fn validate_threshold(signers: &Vec
, threshold: u32) -> Result<(), BatchSenderError> { + if threshold == 0 || threshold > signers.len() { + return Err(BatchSenderError::InvalidThreshold); + } + Ok(()) +} + +/// Propose a new signer set + threshold. The caller's approval is recorded +/// automatically. Only one rotation may be pending at a time. +pub fn propose_rotate_signers( + env: &Env, + caller: Address, + new_signers: Vec
, + new_threshold: u32, +) -> Result<(), BatchSenderError> { + if !env.storage().instance().has(&DataKey::MultisigSigners) { + return Err(BatchSenderError::MultisigNotInitialized); + } + require_signer(env, &caller)?; + + if env.storage().instance().has(&DataKey::PendingRotation) { + return Err(BatchSenderError::RotationAlreadyPending); + } + + validate_threshold(&new_signers, new_threshold)?; + + let mut approvals = Vec::new(env); + approvals.push_back(caller); + + let proposal = RotationProposal { + new_signers, + new_threshold, + executable_at: env.ledger().timestamp() + ROTATION_TIMELOCK_SECS, + approvals, + }; + env.storage() + .instance() + .set(&DataKey::PendingRotation, &proposal); + Ok(()) +} + +/// Add the caller's approval to the pending rotation proposal. +pub fn approve_rotate_signers(env: &Env, caller: Address) -> Result<(), BatchSenderError> { + require_signer(env, &caller)?; + + let mut proposal: RotationProposal = env + .storage() + .instance() + .get(&DataKey::PendingRotation) + .ok_or(BatchSenderError::NoPendingRotation)?; + + if proposal.approvals.contains(&caller) { + return Err(BatchSenderError::AlreadyApprovedRotation); + } + proposal.approvals.push_back(caller); + env.storage() + .instance() + .set(&DataKey::PendingRotation, &proposal); + Ok(()) +} + +/// Execute the pending rotation once quorum (measured against the *current* +/// threshold) has been reached and the timelock has elapsed. Clears the +/// pending proposal and emits `SignersRotated`. +pub fn execute_rotate_signers(env: &Env, caller: Address) -> Result<(), BatchSenderError> { + require_signer(env, &caller)?; + + let proposal: RotationProposal = env + .storage() + .instance() + .get(&DataKey::PendingRotation) + .ok_or(BatchSenderError::NoPendingRotation)?; + + let old_threshold = threshold(env); + if (proposal.approvals.len()) < old_threshold { + return Err(BatchSenderError::QuorumNotMet); + } + if env.ledger().timestamp() < proposal.executable_at { + return Err(BatchSenderError::TimelockNotElapsed); + } + + env.storage() + .instance() + .set(&DataKey::MultisigSigners, &proposal.new_signers); + env.storage() + .instance() + .set(&DataKey::MultisigThreshold, &proposal.new_threshold); + env.storage().instance().remove(&DataKey::PendingRotation); + + env.events().publish( + (Symbol::new(env, "SignersRotated"),), + (proposal.new_signers, old_threshold, proposal.new_threshold), + ); + + Ok(()) +} + +/// Cancel the pending rotation, fully clearing its storage so a future +/// proposal starts from a clean slate. +pub fn cancel_rotate_signers(env: &Env, caller: Address) -> Result<(), BatchSenderError> { + require_signer(env, &caller)?; + + if !env.storage().instance().has(&DataKey::PendingRotation) { + return Err(BatchSenderError::NoPendingRotation); + } + env.storage().instance().remove(&DataKey::PendingRotation); + Ok(()) +} diff --git a/stellar/stealth-batch-sender/src/test.rs b/stellar/stealth-batch-sender/src/test.rs index ce62d2a..166d30f 100644 --- a/stellar/stealth-batch-sender/src/test.rs +++ b/stellar/stealth-batch-sender/src/test.rs @@ -19,6 +19,18 @@ fn dummy_pub_key(env: &Env) -> Bytes { Bytes::from_slice(env, &[0x02u8; 33]) // compressed secp256k1 pubkey } +/// Registers the contract and initialises it with a fresh admin + +/// announcer, no asset policy. Returns the client plus the admin address +/// (tests that need to pause/unpause use it). +fn setup(env: &Env) -> (StealthBatchSenderClient<'static>, Address) { + let contract_id = env.register(StealthBatchSender, ()); + let client = StealthBatchSenderClient::new(env, &contract_id); + let admin = Address::generate(env); + let announcer = Address::generate(env); + client.init(&admin, &announcer, &None); + (client, admin) +} + #[test] fn test_batch_send_success() { let env = Env::default(); @@ -30,8 +42,7 @@ fn test_batch_send_success() { token_admin.mint(&sender, &1000); - let contract_id = env.register(StealthBatchSender, ()); - let client = StealthBatchSenderClient::new(&env, &contract_id); + let (client, _) = setup(&env); let stealth1 = Address::generate(&env); let stealth2 = Address::generate(&env); @@ -65,19 +76,55 @@ fn test_batch_send_success() { } #[test] -#[should_panic(expected = "batch exceeds MAX_BATCH_SIZE")] -fn test_batch_size_cap() { +fn test_batch_send_rejects_uninitialized() { let env = Env::default(); env.mock_all_auths(); let admin = Address::generate(&env); let sender = Address::generate(&env); let (token, token_admin) = create_token(&env, &admin); - token_admin.mint(&sender, &100_000); + token_admin.mint(&sender, &100); let contract_id = env.register(StealthBatchSender, ()); let client = StealthBatchSenderClient::new(&env, &contract_id); + let transfers = vec![ + &env, + Transfer { + stealth_address: Address::generate(&env), + ephemeral_pub_key: dummy_pub_key(&env), + amount: 100, + }, + ]; + + let result = client.try_batch_send(&sender, &transfers, &token.address); + assert_eq!(result, Err(Ok(BatchSenderError::NotInitialized))); +} + +#[test] +fn test_init_rejects_second_call() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin) = setup(&env); + let announcer = Address::generate(&env); + + let result = client.try_init(&admin, &announcer, &None); + assert_eq!(result, Err(Ok(BatchSenderError::AlreadyInitialized))); +} + +#[test] +fn test_batch_size_cap() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let (token, token_admin) = create_token(&env, &admin); + token_admin.mint(&sender, &100_000); + + let (client, _) = setup(&env); + let mut transfers = Vec::new(&env); for _ in 0..=MAX_BATCH_SIZE { transfers.push_back(Transfer { @@ -87,7 +134,8 @@ fn test_batch_size_cap() { }); } - client.batch_send(&sender, &transfers, &token.address); + let result = client.try_batch_send(&sender, &transfers, &token.address); + assert_eq!(result, Err(Ok(BatchSenderError::BatchTooLarge))); } #[test] @@ -100,10 +148,12 @@ fn test_atomicity_on_mid_batch_failure() { let sender = Address::generate(&env); let (token, token_admin) = create_token(&env, &admin); + // Sender can afford the first transfer but not all three — the + // shortfall should roll back every transfer in the batch, not just + // the one that failed. token_admin.mint(&sender, &150); - let contract_id = env.register(StealthBatchSender, ()); - let client = StealthBatchSenderClient::new(&env, &contract_id); + let (client, _) = setup(&env); let transfers = vec![ &env, @@ -124,11 +174,11 @@ fn test_atomicity_on_mid_batch_failure() { }, ]; + // insufficient balance on the second transfer aborts the whole tx client.batch_send(&sender, &transfers, &token.address); } #[test] -#[should_panic(expected = "batch must contain at least one transfer")] fn test_empty_batch_rejected() { let env = Env::default(); env.mock_all_auths(); @@ -137,14 +187,13 @@ fn test_empty_batch_rejected() { let sender = Address::generate(&env); let (token, _) = create_token(&env, &admin); - let contract_id = env.register(StealthBatchSender, ()); - let client = StealthBatchSenderClient::new(&env, &contract_id); + let (client, _) = setup(&env); - client.batch_send(&sender, &Vec::new(&env), &token.address); + let result = client.try_batch_send(&sender, &Vec::new(&env), &token.address); + assert_eq!(result, Err(Ok(BatchSenderError::EmptyBatch))); } #[test] -#[should_panic(expected = "transfer amount must be positive")] fn test_zero_amount_rejected() { let env = Env::default(); env.mock_all_auths(); @@ -154,8 +203,7 @@ fn test_zero_amount_rejected() { let (token, token_admin) = create_token(&env, &admin); token_admin.mint(&sender, &100); - let contract_id = env.register(StealthBatchSender, ()); - let client = StealthBatchSenderClient::new(&env, &contract_id); + let (client, _) = setup(&env); let transfers = vec![ &env, @@ -166,7 +214,33 @@ fn test_zero_amount_rejected() { }, ]; - client.batch_send(&sender, &transfers, &token.address); + let result = client.try_batch_send(&sender, &transfers, &token.address); + assert_eq!(result, Err(Ok(BatchSenderError::NonPositiveAmount))); +} + +#[test] +fn test_empty_ephemeral_key_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let (token, token_admin) = create_token(&env, &admin); + token_admin.mint(&sender, &100); + + let (client, _) = setup(&env); + + let transfers = vec![ + &env, + Transfer { + stealth_address: Address::generate(&env), + ephemeral_pub_key: Bytes::new(&env), + amount: 100, + }, + ]; + + let result = client.try_batch_send(&sender, &transfers, &token.address); + assert_eq!(result, Err(Ok(BatchSenderError::EmptyEphemeralKey))); } #[test] diff --git a/stellar/stealth-batch-sender/tests/production.rs b/stellar/stealth-batch-sender/tests/production.rs new file mode 100644 index 0000000..373c71c --- /dev/null +++ b/stellar/stealth-batch-sender/tests/production.rs @@ -0,0 +1,540 @@ +//! Production-hardening acceptance tests for stealth-batch-sender (issue #155). +//! +//! Covers: init idempotency + AlreadyInitialized, pause/unpause + is_paused, +//! paused-call rejection, the full typed BatchSenderError surface (including +//! asset-policy rejection), and signer-rotation happy + adversarial paths. + +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::StellarAssetClient, + vec, Address, Bytes, Env, +}; +use stealth_batch_sender::{ + BatchSenderError, StealthBatchSender, StealthBatchSenderClient, Transfer, MAX_BATCH_SIZE, +}; + +fn dummy_pub_key(env: &Env) -> Bytes { + Bytes::from_slice(env, &[0x02u8; 33]) +} + +fn create_token(env: &Env, admin: &Address) -> (Address, StellarAssetClient<'static>) { + let contract_id = env.register_stellar_asset_contract_v2(admin.clone()); + ( + contract_id.address(), + StellarAssetClient::new(env, &contract_id.address()), + ) +} + +fn deploy(env: &Env) -> StealthBatchSenderClient<'static> { + let contract_id = env.register(StealthBatchSender, ()); + StealthBatchSenderClient::new(env, &contract_id) +} + +// ─── Minimal asset-policy mocks used only by the AssetNotAllowed tests ──── +// Each lives in its own module: soroban-sdk's #[contractimpl] macro +// generates helper items named after the method, which collide across +// types when they share a module scope. + +mod deny_all_policy { + use soroban_sdk::{contract, contractimpl, Address, Env}; + + #[contract] + pub struct DenyAllPolicy; + + #[contractimpl] + impl DenyAllPolicy { + pub fn check_asset(_env: Env, _token: Address) -> bool { + false + } + } +} +use deny_all_policy::DenyAllPolicy; + +mod allow_all_policy { + use soroban_sdk::{contract, contractimpl, Address, Env}; + + #[contract] + pub struct AllowAllPolicy; + + #[contractimpl] + impl AllowAllPolicy { + pub fn check_asset(_env: Env, _token: Address) -> bool { + true + } + } +} +use allow_all_policy::AllowAllPolicy; + +// ─────────────────────────── init lifecycle ─────────────────────────── + +#[test] +fn init_is_idempotent() { + let env = Env::default(); + env.mock_all_auths(); + + let client = deploy(&env); + let admin = Address::generate(&env); + let announcer = Address::generate(&env); + + client.init(&admin, &announcer, &None); + + let result = client.try_init(&admin, &announcer, &None); + assert_eq!(result, Err(Ok(BatchSenderError::AlreadyInitialized))); +} + +#[test] +fn batch_send_requires_init() { + let env = Env::default(); + env.mock_all_auths(); + + let client = deploy(&env); + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let (token, token_admin) = create_token(&env, &admin); + token_admin.mint(&sender, &100); + + let transfers = vec![ + &env, + Transfer { + stealth_address: Address::generate(&env), + ephemeral_pub_key: dummy_pub_key(&env), + amount: 100, + }, + ]; + + let result = client.try_batch_send(&sender, &transfers, &token); + assert_eq!(result, Err(Ok(BatchSenderError::NotInitialized))); +} + +// ─────────────────────────── pause / unpause ─────────────────────────── + +#[test] +fn pause_unpause_by_admin() { + let env = Env::default(); + env.mock_all_auths(); + + let client = deploy(&env); + let admin = Address::generate(&env); + let announcer = Address::generate(&env); + client.init(&admin, &announcer, &None); + + assert!(!client.is_paused()); + client.pause(&admin); + assert!(client.is_paused()); + client.unpause(&admin); + assert!(!client.is_paused()); +} + +#[test] +#[should_panic(expected = "HostError")] +fn only_admin_can_pause() { + let env = Env::default(); + env.mock_all_auths(); + + let client = deploy(&env); + let admin = Address::generate(&env); + let announcer = Address::generate(&env); + let attacker = Address::generate(&env); + client.init(&admin, &announcer, &None); + + client.pause(&attacker); +} + +#[test] +fn batch_send_rejected_when_paused() { + let env = Env::default(); + env.mock_all_auths(); + + let client = deploy(&env); + let admin = Address::generate(&env); + let announcer = Address::generate(&env); + client.init(&admin, &announcer, &None); + + let sender = Address::generate(&env); + let (token, token_admin) = create_token(&env, &admin); + token_admin.mint(&sender, &1000); + + let transfers = vec![ + &env, + Transfer { + stealth_address: Address::generate(&env), + ephemeral_pub_key: dummy_pub_key(&env), + amount: 100, + }, + ]; + + client.pause(&admin); + let result = client.try_batch_send(&sender, &transfers, &token); + assert_eq!(result, Err(Ok(BatchSenderError::Paused))); + + // Balances unchanged. + let token_client = soroban_sdk::token::Client::new(&env, &token); + assert_eq!(token_client.balance(&sender), 1000); +} + +#[test] +fn batch_send_allowed_after_unpause() { + let env = Env::default(); + env.mock_all_auths(); + + let client = deploy(&env); + let admin = Address::generate(&env); + let announcer = Address::generate(&env); + client.init(&admin, &announcer, &None); + + let sender = Address::generate(&env); + let (token, token_admin) = create_token(&env, &admin); + token_admin.mint(&sender, &1000); + let stealth = Address::generate(&env); + + let transfers = vec![ + &env, + Transfer { + stealth_address: stealth.clone(), + ephemeral_pub_key: dummy_pub_key(&env), + amount: 100, + }, + ]; + + client.pause(&admin); + client.unpause(&admin); + assert!(!client.is_paused()); + + client.batch_send(&sender, &transfers, &token); + + let token_client = soroban_sdk::token::Client::new(&env, &token); + assert_eq!(token_client.balance(&stealth), 100); +} + +// ────────────────────────── typed-error surface ───────────────────────── + +#[test] +fn empty_batch_returns_typed_error() { + let env = Env::default(); + env.mock_all_auths(); + + let client = deploy(&env); + let admin = Address::generate(&env); + let announcer = Address::generate(&env); + client.init(&admin, &announcer, &None); + + let sender = Address::generate(&env); + let (token, _) = create_token(&env, &admin); + + let result = client.try_batch_send(&sender, &soroban_sdk::Vec::new(&env), &token); + assert_eq!(result, Err(Ok(BatchSenderError::EmptyBatch))); +} + +#[test] +fn oversized_batch_returns_typed_error() { + let env = Env::default(); + env.mock_all_auths(); + + let client = deploy(&env); + let admin = Address::generate(&env); + let announcer = Address::generate(&env); + client.init(&admin, &announcer, &None); + + let sender = Address::generate(&env); + let (token, token_admin) = create_token(&env, &admin); + token_admin.mint(&sender, &1_000_000); + + let mut transfers = soroban_sdk::Vec::new(&env); + for _ in 0..=MAX_BATCH_SIZE { + transfers.push_back(Transfer { + stealth_address: Address::generate(&env), + ephemeral_pub_key: dummy_pub_key(&env), + amount: 1, + }); + } + + let result = client.try_batch_send(&sender, &transfers, &token); + assert_eq!(result, Err(Ok(BatchSenderError::BatchTooLarge))); +} + +#[test] +fn non_positive_amount_returns_typed_error() { + let env = Env::default(); + env.mock_all_auths(); + + let client = deploy(&env); + let admin = Address::generate(&env); + let announcer = Address::generate(&env); + client.init(&admin, &announcer, &None); + + let sender = Address::generate(&env); + let (token, token_admin) = create_token(&env, &admin); + token_admin.mint(&sender, &100); + + let transfers = vec![ + &env, + Transfer { + stealth_address: Address::generate(&env), + ephemeral_pub_key: dummy_pub_key(&env), + amount: 0, + }, + ]; + + let result = client.try_batch_send(&sender, &transfers, &token); + assert_eq!(result, Err(Ok(BatchSenderError::NonPositiveAmount))); +} + +#[test] +fn empty_ephemeral_key_returns_typed_error() { + let env = Env::default(); + env.mock_all_auths(); + + let client = deploy(&env); + let admin = Address::generate(&env); + let announcer = Address::generate(&env); + client.init(&admin, &announcer, &None); + + let sender = Address::generate(&env); + let (token, token_admin) = create_token(&env, &admin); + token_admin.mint(&sender, &100); + + let transfers = vec![ + &env, + Transfer { + stealth_address: Address::generate(&env), + ephemeral_pub_key: Bytes::new(&env), + amount: 100, + }, + ]; + + let result = client.try_batch_send(&sender, &transfers, &token); + assert_eq!(result, Err(Ok(BatchSenderError::EmptyEphemeralKey))); +} + +#[test] +fn asset_not_allowed_by_policy_returns_typed_error() { + let env = Env::default(); + env.mock_all_auths(); + + let client = deploy(&env); + let admin = Address::generate(&env); + let announcer = Address::generate(&env); + let policy_id = env.register(DenyAllPolicy, ()); + client.init(&admin, &announcer, &Some(policy_id)); + + let sender = Address::generate(&env); + let (token, token_admin) = create_token(&env, &admin); + token_admin.mint(&sender, &100); + + let transfers = vec![ + &env, + Transfer { + stealth_address: Address::generate(&env), + ephemeral_pub_key: dummy_pub_key(&env), + amount: 100, + }, + ]; + + let result = client.try_batch_send(&sender, &transfers, &token); + assert_eq!(result, Err(Ok(BatchSenderError::AssetNotAllowed))); +} + +#[test] +fn asset_allowed_by_policy_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + + let client = deploy(&env); + let admin = Address::generate(&env); + let announcer = Address::generate(&env); + let policy_id = env.register(AllowAllPolicy, ()); + client.init(&admin, &announcer, &Some(policy_id)); + + let sender = Address::generate(&env); + let (token, token_admin) = create_token(&env, &admin); + token_admin.mint(&sender, &100); + let stealth = Address::generate(&env); + + let transfers = vec![ + &env, + Transfer { + stealth_address: stealth.clone(), + ephemeral_pub_key: dummy_pub_key(&env), + amount: 100, + }, + ]; + + client.batch_send(&sender, &transfers, &token); + + let token_client = soroban_sdk::token::Client::new(&env, &token); + assert_eq!(token_client.balance(&stealth), 100); +} + +// ──────────────────────── signer-rotation: happy path ─────────────────── + +fn setup_multisig(env: &Env) -> (StealthBatchSenderClient<'static>, soroban_sdk::Vec
) { + let client = deploy(env); + let admin = Address::generate(env); + let announcer = Address::generate(env); + client.init(&admin, &announcer, &None); + + let signers = vec![ + env, + Address::generate(env), + Address::generate(env), + Address::generate(env), + Address::generate(env), + Address::generate(env), + ]; + client.init_multisig(&signers, &3); + (client, signers) +} + +#[test] +fn rotation_requires_quorum_and_timelock() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, signers) = setup_multisig(&env); + let new_signers = vec![&env, Address::generate(&env), Address::generate(&env)]; + + client.propose_rotate_signers(&signers.get(0).unwrap(), &new_signers, &2); + + // Only 1 of 3 approvals so far (the proposer's). + let result = client.try_execute_rotate_signers(&signers.get(0).unwrap()); + assert_eq!(result, Err(Ok(BatchSenderError::QuorumNotMet))); + + client.approve_rotate_signers(&signers.get(1).unwrap()); + client.approve_rotate_signers(&signers.get(2).unwrap()); + + // Quorum met, but the 7-day timelock has not elapsed yet. + let result = client.try_execute_rotate_signers(&signers.get(0).unwrap()); + assert_eq!(result, Err(Ok(BatchSenderError::TimelockNotElapsed))); + + env.ledger().with_mut(|li| { + li.timestamp += stealth_batch_sender::ROTATION_TIMELOCK_SECS; + }); + + client.execute_rotate_signers(&signers.get(0).unwrap()); + + assert_eq!(client.signers(), new_signers); + assert_eq!(client.threshold(), 2); + assert!(client.pending_rotation().is_none()); +} + +#[test] +fn cancelled_rotation_clears_state() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, signers) = setup_multisig(&env); + let new_signers = vec![&env, Address::generate(&env), Address::generate(&env)]; + + client.propose_rotate_signers(&signers.get(0).unwrap(), &new_signers, &2); + client.approve_rotate_signers(&signers.get(1).unwrap()); + client.cancel_rotate_signers(&signers.get(2).unwrap()); + + assert!(client.pending_rotation().is_none()); + assert_eq!(client.signers(), signers); + assert_eq!(client.threshold(), 3); + + let result = client.try_approve_rotate_signers(&signers.get(3).unwrap()); + assert_eq!(result, Err(Ok(BatchSenderError::NoPendingRotation))); +} + +// ──────────────────────── signer-rotation: adversarial ────────────────── + +#[test] +fn non_signer_cannot_propose_or_approve() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, signers) = setup_multisig(&env); + let outsider = Address::generate(&env); + let new_signers = vec![&env, Address::generate(&env), Address::generate(&env)]; + + let result = client.try_propose_rotate_signers(&outsider, &new_signers, &2); + assert_eq!(result, Err(Ok(BatchSenderError::NotSigner))); + + client.propose_rotate_signers(&signers.get(0).unwrap(), &new_signers, &2); + let result = client.try_approve_rotate_signers(&outsider); + assert_eq!(result, Err(Ok(BatchSenderError::NotSigner))); +} + +#[test] +fn duplicate_approval_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, signers) = setup_multisig(&env); + let new_signers = vec![&env, Address::generate(&env), Address::generate(&env)]; + + client.propose_rotate_signers(&signers.get(0).unwrap(), &new_signers, &2); + + // Proposer's approval was already recorded automatically. + let result = client.try_approve_rotate_signers(&signers.get(0).unwrap()); + assert_eq!(result, Err(Ok(BatchSenderError::AlreadyApprovedRotation))); +} + +#[test] +fn only_one_rotation_pending_at_a_time() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, signers) = setup_multisig(&env); + let new_signers = vec![&env, Address::generate(&env), Address::generate(&env)]; + + client.propose_rotate_signers(&signers.get(0).unwrap(), &new_signers, &2); + + let result = client.try_propose_rotate_signers(&signers.get(1).unwrap(), &new_signers, &2); + assert_eq!(result, Err(Ok(BatchSenderError::RotationAlreadyPending))); +} + +#[test] +fn early_execution_rejected_before_timelock() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, signers) = setup_multisig(&env); + let new_signers = vec![&env, Address::generate(&env), Address::generate(&env)]; + + client.propose_rotate_signers(&signers.get(0).unwrap(), &new_signers, &2); + client.approve_rotate_signers(&signers.get(1).unwrap()); + client.approve_rotate_signers(&signers.get(2).unwrap()); + + let result = client.try_execute_rotate_signers(&signers.get(0).unwrap()); + assert_eq!(result, Err(Ok(BatchSenderError::TimelockNotElapsed))); +} + +#[test] +fn invalid_signer_set_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, signers) = setup_multisig(&env); + let new_signers = vec![&env, Address::generate(&env), Address::generate(&env)]; + + // Zero threshold is unreachable. + let result = client.try_propose_rotate_signers(&signers.get(0).unwrap(), &new_signers, &0); + assert_eq!(result, Err(Ok(BatchSenderError::InvalidThreshold))); + + // Threshold greater than the proposed signer count is unreachable. + let result = client.try_propose_rotate_signers(&signers.get(0).unwrap(), &new_signers, &3); + assert_eq!(result, Err(Ok(BatchSenderError::InvalidThreshold))); + + assert!(client.pending_rotation().is_none()); +} + +#[test] +fn init_multisig_rejects_invalid_threshold() { + let env = Env::default(); + env.mock_all_auths(); + + let client = deploy(&env); + let admin = Address::generate(&env); + let announcer = Address::generate(&env); + client.init(&admin, &announcer, &None); + + let signers = vec![&env, Address::generate(&env), Address::generate(&env)]; + + let result = client.try_init_multisig(&signers, &0); + assert_eq!(result, Err(Ok(BatchSenderError::InvalidThreshold))); + + let result = client.try_init_multisig(&signers, &3); + assert_eq!(result, Err(Ok(BatchSenderError::InvalidThreshold))); +}