From 84fd11e740fba577aa6bf8a53c428645508e9052 Mon Sep 17 00:00:00 2001 From: Markodiba Date: Mon, 30 Mar 2026 10:15:47 +0100 Subject: [PATCH 1/2] feat(router-timelock): validate non-empty description in queue - Add InvalidDescription = 17 error variant - Guard queue against empty description strings - Add test_queue_empty_description_fails and test_queue_nonempty_description_succeeds - All 37 tests pass --- contracts/router-timelock/src/lib.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/contracts/router-timelock/src/lib.rs b/contracts/router-timelock/src/lib.rs index 8f05084..fc318fd 100644 --- a/contracts/router-timelock/src/lib.rs +++ b/contracts/router-timelock/src/lib.rs @@ -68,6 +68,7 @@ pub enum TimelockError { NotCriticalOp = 14, InvalidConfig = 15, InvalidTarget = 16, + InvalidDescription = 17, } // ── Contract ────────────────────────────────────────────────────────────────── @@ -152,6 +153,10 @@ impl RouterTimelock { return Err(TimelockError::InvalidDelay); } + if description.len() == 0 { + return Err(TimelockError::InvalidDescription); + } + let op_id = Self::next_op_id(&env); let eta = env.ledger().timestamp() + delay; @@ -1287,4 +1292,25 @@ mod tests { let all = client.get_ops_by_state(&false); assert_eq!(all.len(), 3); } + + #[test] + fn test_queue_empty_description_fails() { + let (env, admin, client) = setup(); + let target = Address::generate(&env); + let deps = Vec::new(&env); + assert_eq!( + client.try_queue(&admin, &String::from_str(&env, ""), &target, &3600u64, &deps), + Err(Ok(TimelockError::InvalidDescription)) + ); + } + + #[test] + fn test_queue_nonempty_description_succeeds() { + let (env, admin, client) = setup(); + let target = Address::generate(&env); + let deps = Vec::new(&env); + assert!( + client.try_queue(&admin, &String::from_str(&env, "upgrade oracle"), &target, &3600u64, &deps).is_ok() + ); + } } From 407a0a66c0c0bec13a2bd54936c8abc169b4d071 Mon Sep 17 00:00:00 2001 From: Markodiba Date: Mon, 30 Mar 2026 10:27:50 +0100 Subject: [PATCH 2/2] fix(router-multicall): include caller in call_result event - call_result event data changed from (target, function, success) to (caller, target, function, success) - Add test_call_result_event_includes_caller - Document breaking change in CHANGELOG.md - All 19 tests pass --- CHANGELOG.md | 11 ++++++++ contracts/router-multicall/src/lib.rs | 39 +++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c74a5d..d82d183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `router-timelock`: `get_op_count` — returns total operations ever queued +- `router-timelock`: `get_ops_by_state(only_pending)` — server-side filtering of ops by state + +### Changed +- `router-multicall`: `call_result` event data now includes the batch caller as the first element: `(caller, target, function, success)`. **Breaking change** — indexers consuming this event must update their schema. + +### Fixed +- `router-timelock`: `queue` now rejects empty descriptions with `InvalidDescription` error +- `router-timelock`: type inference bug in `get_pending_ops` (missing turbofish on `.get()`) + ## [0.1.0] - 2026-03-14 ### Added diff --git a/contracts/router-multicall/src/lib.rs b/contracts/router-multicall/src/lib.rs index 1c474b5..b65a71f 100644 --- a/contracts/router-multicall/src/lib.rs +++ b/contracts/router-multicall/src/lib.rs @@ -207,7 +207,7 @@ impl RouterMulticall { env.events().publish( (Symbol::new(&env, "call_result"),), - (&call.target, &call.function, success), + (&caller, &call.target, &call.function, success), ); } @@ -352,7 +352,7 @@ impl RouterMulticall { mod tests { extern crate std; use super::*; - use soroban_sdk::{testutils::Address as _, Env, Symbol, Vec}; + use soroban_sdk::{testutils::{Address as _, Events}, Env, FromVal, Symbol, Vec}; fn setup() -> (Env, Address, RouterMulticallClient<'static>) { let env = Env::default(); @@ -689,4 +689,39 @@ mod tests { assert_eq!(summary.succeeded, 1); assert_eq!(summary.failed, 1); } + + #[test] + fn test_call_result_event_includes_caller() { + let (env, _admin, client) = setup(); + let mock_id = env.register_contract(None, MockContract); + let caller = Address::generate(&env); + + let mut calls = Vec::new(&env); + calls.push_back(CallDescriptor { + target: mock_id.clone(), + function: Symbol::new(&env, "success"), + required: true, + instruction_budget: None, + }); + + client.execute_batch(&caller, &calls, &false); + + // Find the call_result event — tuple is (contract_id, topics: Vec, data: Val) + let all_events = env.events().all(); + let (_, _, data) = all_events + .iter() + .find(|(_, topics, _)| { + topics + .get(0) + .map(|v| Symbol::from_val(&env, &v) == Symbol::new(&env, "call_result")) + .unwrap_or(false) + }) + .expect("call_result event not found"); + + // Data is a Vec; decode first element as Address and assert it equals caller + let data_vec = soroban_sdk::Vec::::from_val(&env, &data); + let event_caller = Address::from_val(&env, &data_vec.get(0).unwrap()); + + assert_eq!(event_caller, caller); + } }