diff --git a/docs/authorization.md b/docs/authorization.md index bc87908d..b064c52c 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -9,6 +9,7 @@ Only the configured administrator address can perform administrative operations. * `pause` / `unpause` * `add_caller` / `remove_caller` * `transfer_admin` +* `set_limits` ## Admin Ownership Transfer (Two-Step) @@ -39,7 +40,10 @@ Recommended custody practices: * Prefer a multisig or timelock arrangement for any administrative action that would materially affect operations. * Keep the admin key materially segregated from day-to-day operational keys and rotate or recover it through a documented process. -Security note: compromise of the admin key can pause the contract and change the allowlist, but it cannot directly withdraw escrowed funds from the contract. +Security note: compromise of the admin key can pause the contract, change the +allowlist, and change operational limits, but it cannot directly withdraw +escrowed funds from the contract. A new global escrow limit cannot be set below +the amount already held in escrow. ## Privileged Callers Allowlist The contract maintains an allowlist of privileged callers who are authorized to lock funds and create new escrow transfers. diff --git a/docs/cli-usage.md b/docs/cli-usage.md index 37453c5d..00976287 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -194,6 +194,15 @@ stellar contract invoke --id $CONTRACT_ID --network $NETWORK \ # Get configured limits stellar contract invoke --id $CONTRACT_ID --network $NETWORK -- get_limits +# Update operational limits (admin only) +stellar contract invoke --id $CONTRACT_ID --network $NETWORK --source-account $ADMIN \ + -- set_limits --limits '{ + "max_amount": "500", + "max_expiry_window": "2000", + "max_total_escrowed": "2000", + "max_page_size": 25 + }' + ``` --- diff --git a/docs/data-types.md b/docs/data-types.md index 0c739e4e..7c34ed91 100644 --- a/docs/data-types.md +++ b/docs/data-types.md @@ -10,12 +10,15 @@ See the README and the sources under src/ for the authoritative implementation. ## ConfiguredLimits -`ConfiguredLimits` represents the static operational bounds and limits configured for the contract. +`ConfiguredLimits` represents the operational bounds enforced by the contract. +Defaults match the compile-time constants (`MAX_AMOUNT`, `MAX_EXPIRY_WINDOW`, +`MAX_TOTAL_ESCROWED`, `MAX_PAGE_SIZE`). After initialization the admin may +update them through [`set_limits`](entrypoint-reference.md#set_limitslimits-configuredlimits---result-error). | Field | Type | Description | | --- | --- | --- | -| `max_amount` | `i128` | Largest token amount accepted for a single escrowed transfer (`1_000_000_000_000_000_000`). | -| `max_expiry_window` | `u64` | Maximum allowed distance, in seconds, between current timestamp and transfer expiry (`31_536_000`, ~1 year). | -| `max_total_escrowed` | `i128` | Global cap on the total escrowed amount (`1_000_000_000_000_000_000`). | -| `max_page_size` | `u32` | Maximum number of records returned by a paginated transfer query (`100`). | +| `max_amount` | `i128` | Largest token amount accepted for a single escrowed transfer (default `1_000_000_000_000_000_000`). | +| `max_expiry_window` | `u64` | Maximum allowed distance, in seconds, between current timestamp and transfer expiry (default `31_536_000`, ~1 year). | +| `max_total_escrowed` | `i128` | Global cap on the total escrowed amount (default `1_000_000_000_000_000_000`). | +| `max_page_size` | `u32` | Maximum number of records returned by a paginated transfer query (default `100`). | diff --git a/docs/entrypoint-reference.md b/docs/entrypoint-reference.md index b20be748..0d4867aa 100644 --- a/docs/entrypoint-reference.md +++ b/docs/entrypoint-reference.md @@ -107,8 +107,8 @@ Returns a bounded page of transfer records ordered by ascending transfer id. * **Authorization**: None (public view) * **Cursor**: `start_id` is inclusive; `0` is treated as transfer id `1` -* **Page size**: Returns at most `min(limit, MAX_PAGE_SIZE)`, where - `MAX_PAGE_SIZE` is 100 +* **Page size**: Returns at most `min(limit, configured max_page_size)`, where + the default `max_page_size` is 100 (see [`get_limits`](#get_limits---configuredlimits)) * **Empty pages**: Returns an empty vector when `limit` is zero, no transfers exist, or `start_id` is beyond the current transfer counter * **Next page**: If a full page is returned, pass the last returned transfer @@ -116,11 +116,24 @@ Returns a bounded page of transfer records ordered by ascending transfer id. ## Operational Configuration & Resource Queries +### `set_limits(limits: ConfiguredLimits) -> Result<(), Error>` + +Updates the operational limits enforced for new transfers and paginated queries. + +* **Authorization**: Current admin (`admin.require_auth()`) +* **Effect**: Stores the new `max_amount`, `max_expiry_window`, + `max_total_escrowed`, and `max_page_size` values in instance storage. +* **Validation**: Every value must be positive, `max_amount` cannot exceed + `max_total_escrowed`, and `max_total_escrowed` cannot be lower than the + amount currently held in escrow. +* **Events**: Emits `limits_changed` with the admin address and the complete + old and new [`ConfiguredLimits`](data-types.md#configuredlimits) values. + Supplying the current configuration is a no-op and emits no event. +* **Errors**: `NotInitialized`, `InvalidLimits`, or `CooldownNotElapsed`. + ### `get_limits() -> ConfiguredLimits` Returns the contract's configured operational limits. * **Authorization**: None (public view, callable pre/post-initialization) * **Returns**: [`ConfiguredLimits`](data-types.md#configuredlimits) containing `max_amount`, `max_expiry_window`, `max_total_escrowed`, and `max_page_size`. - - diff --git a/docs/error-reference.md b/docs/error-reference.md index 6647932b..973bd4ad 100644 --- a/docs/error-reference.md +++ b/docs/error-reference.md @@ -12,7 +12,7 @@ meaning, and the entrypoints that can produce it. | Code | Variant | Description | Returned By | |------|---------|-------------|-------------| | 1 | `AlreadyInitialized` | The contract has already been initialised with an admin and token address. Second calls to `initialize` are rejected. | `initialize` | -| 2 | `NotInitialized` | The contract has not been initialised yet. Most public entrypoints require initialisation before they can proceed. | `initialize`, `get_admin`, `get_token`, `get_initialized_at`, `pause`, `unpause`, `create_transfer`, `claim_transfer`, `cancel_transfer`, `add_caller`, `remove_caller` | +| 2 | `NotInitialized` | The contract has not been initialised yet. Most public entrypoints require initialisation before they can proceed. | `initialize`, `get_admin`, `get_token`, `get_initialized_at`, `pause`, `unpause`, `create_transfer`, `claim_transfer`, `cancel_transfer`, `add_caller`, `remove_caller`, `set_limits` | | 3 | `TransferNotFound` | No record exists for the supplied transfer `id`. Either the id was never created or it was assigned to a transfer that was purged. | `get_transfer`, `get_status`, `transfer_exists`, `is_expired`, `claim_transfer`, `cancel_transfer` | | 4 | `InvalidAmount` | The supplied transfer `amount` is not strictly positive (zero or negative). | `create_transfer` | | 5 | `InvalidExpiry` | The supplied `expiry` timestamp is not in the future — it must be strictly greater than the current ledger timestamp. | `create_transfer` | @@ -31,6 +31,7 @@ meaning, and the entrypoints that can produce it. | 18 | `InvalidAddress` | A supplied address resolves to the contract's own address, where an external party address is required. Guards against uninitialized or placeholder address inputs masquerading as a valid party. | `initialize`, `create_transfer`, `claim_transfer`, `cancel_transfer`, `add_caller`, `transfer_admin` | | 20 | `SupplyInvariantViolation` | The contract's actual token balance is less than its internally tracked `TotalEscrowed` liability. Checked automatically after every entrypoint that moves escrowed funds; see [Invariants](./invariants.md). | `create_transfer`, `claim_transfer`, `cancel_transfer`, `check_supply_invariant` | | 21 | `BatchTooLarge` | The number of operations in a `batch_operations` call exceeds `MAX_BATCH_SIZE`. | `batch_operations` | +| 22 | `InvalidLimits` | One or more fields in the supplied `ConfiguredLimits` are invalid (non-positive values, `max_amount` above `max_total_escrowed`, or `max_total_escrowed` below the amount already held in escrow). | `set_limits` | --- @@ -50,6 +51,7 @@ meaning, and the entrypoints that can produce it. | `check_supply_invariant` | `NotInitialized` (2), `SupplyInvariantViolation` (20) | | `transfer_admin` | `NotInitialized` (2), `InvalidAddress` (18) | | `accept_admin` | `NoPendingAdmin` (17), `NotInitialized` (2) | +| `set_limits` | `NotInitialized` (2), `InvalidLimits` (22), `CooldownNotElapsed` (21) | | `get_pending_admin` | None† | | `get_transfer`, `transfer_exists`, `get_status`, `is_expired` | `NotInitialized` (2), `TransferNotFound` (3)‡ | | `total_escrowed`, `count_for_sender`, `count_for_recipient`, `count_by_status`, `get_transfers_paged` | None† | diff --git a/docs/event-reference.md b/docs/event-reference.md index 235eaf4e..f10e2d0d 100644 --- a/docs/event-reference.md +++ b/docs/event-reference.md @@ -14,6 +14,7 @@ This document describes all events emitted by the RemitFlow smart contract, deta | `created` | `("created", id: u64)` | `(from: Address, recipient: Address, amount: i128, expiry: u64)` | A new transfer is created and funds escrowed. | | `claimed` | `("claimed", id: u64)` | `(recipient: Address, amount: i128)` | Recipient claims escrowed transfer. | | `cancelled` | `("cancelled", id: u64)` | `(from: Address, amount: i128)` | Sender cancels and receives a refund for an expired transfer. | +| `limits_changed` | `("limits_changed",)` | `(admin: Address, old_limits: ConfiguredLimits, new_limits: ConfiguredLimits)` | Admin changes the contract's operational limits. | | `admin_transfer_started` | `("admin_transfer_started",)` | `(current_admin: Address, pending_admin: Address)` | Admin initiates ownership transfer. | | `admin_transfer_completed` | `("admin_transfer_completed",)` | `(old_admin: Address, new_admin: Address)` | Pending admin accepts ownership transfer. | diff --git a/docs/storage-model.md b/docs/storage-model.md index ad3d7ea3..3bbd7c5d 100644 --- a/docs/storage-model.md +++ b/docs/storage-model.md @@ -57,6 +57,8 @@ Instance and persistent stores are separate namespaces at the Soroban host level | `Counter` | `u64` | Monotonically increasing id issued to the next transfer | | `Paused` | `bool` | When `true`, `create_transfer` is blocked | | `InitializedAt` | `u64` | Ledger timestamp at which `initialize` was called | +| `LastPrivilegedCall` | `u64` | Timestamp of the most recent privileged administrative call | +| `Limits` | `ConfiguredLimits` | Mutable operational limits enforced for new transfers and pagination | ### `PersistentKey` — persistent storage diff --git a/docs/testing-guide.md b/docs/testing-guide.md index 39204946..e4573e5e 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -38,6 +38,7 @@ Admin-only guards are authorization checks that restrict certain contract operat 1. **initialize()** - Sets up the contract with an admin and token address 2. **pause()** - Blocks creation of new transfers 3. **unpause()** - Re-enables transfer creation +4. **set_limits()** - Updates operational transfer and pagination limits ### Testing Admin Authorization @@ -95,11 +96,24 @@ fn test_unpause_requires_admin_auth() { } ``` +#### Limit-Change Guards +```rust +#[test] +fn test_set_limits_requires_admin_auth() { + // Verifies set_limits() checks admin.require_auth() and does not mutate state +} + +#[test] +fn test_set_limits_by_admin_succeeds() { + // Verifies the admin can update ConfiguredLimits when authorized +} +``` + #### Operational State Guards ```rust #[test] fn test_admin_operations_require_initialization() { - // Ensures pause, unpause, and other admin ops fail when contract is not initialized + // Ensures pause, unpause, set_limits, and other admin ops fail when contract is not initialized } ``` diff --git a/docs/unit-tests.md b/docs/unit-tests.md index 0da914fa..b29f59c2 100644 --- a/docs/unit-tests.md +++ b/docs/unit-tests.md @@ -49,6 +49,11 @@ The contract includes comprehensive unit tests organized by functionality: - **test_count_by_status_tracks_lifecycle**: Tests transfer status counting - **test_get_limits_returns_configured_constants**: Confirms `get_limits` returns configured bounds (`MAX_AMOUNT`, `MAX_EXPIRY_WINDOW`, `MAX_TOTAL_ESCROWED`, `MAX_PAGE_SIZE`) - **test_get_limits_works_uninitialized**: Verifies `get_limits` can be queried before contract initialization +- **test_get_limits_returns_defaults**: Confirms initialized contracts expose the default `ConfiguredLimits` +- **test_set_limits_by_admin_succeeds**: Confirms the admin can update operational limits when authorized +- **test_set_limits_requires_admin_auth**: Verifies `set_limits()` rejects callers that do not satisfy `admin.require_auth()`, leaving stored limits unchanged +- **test_updated_limits_are_enforced**: Ensures newly configured `max_amount` is applied by `create_transfer` +- **test_set_limits_rejects_invalid_configuration**: Ensures invalid `ConfiguredLimits` return `InvalidLimits` without mutating state - **test_get_balances_returns_balances_in_order**: Validates bulk-reading token balances for a list of addresses - **test_get_balances_empty_addresses_list**: Verifies get_balances with an empty address vector diff --git a/src/error.rs b/src/error.rs index e64f6219..3cc18a10 100644 --- a/src/error.rs +++ b/src/error.rs @@ -52,5 +52,7 @@ pub enum Error { /// The number of operations in a atch_operations call exceeds /// MAX_BATCH_SIZE. BatchTooLarge = 21, + /// One or more configured operational limits are invalid. + InvalidLimits = 22, } diff --git a/src/events.rs b/src/events.rs index 8adf1935..4650587a 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,5 +1,7 @@ use soroban_sdk::{Address, Env, Symbol}; +use crate::types::ConfiguredLimits; + /// Publish an event recording contract initialization. pub fn init(env: &Env, admin: &Address, token: &Address) { let topics = (Symbol::new(env, "init"),); @@ -68,3 +70,17 @@ pub fn admin_transfer_completed(env: &Env, old_admin: &Address, new_admin: &Addr env.events() .publish(topics, (old_admin.clone(), new_admin.clone())); } + +/// Publish an event recording an administrator's operational-limit update. +pub fn limits_changed( + env: &Env, + admin: &Address, + old_limits: &ConfiguredLimits, + new_limits: &ConfiguredLimits, +) { + let topics = (Symbol::new(env, "limits_changed"),); + env.events().publish( + topics, + (admin.clone(), old_limits.clone(), new_limits.clone()), + ); +} diff --git a/src/lib.rs b/src/lib.rs index 750de6ec..6f8e84aa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,6 +53,15 @@ pub const MAX_PAGE_SIZE: u32 = 100; /// Maximum number of operations allowed in a single batch_operations call. pub const MAX_BATCH_SIZE: u32 = 50; +fn default_limits() -> ConfiguredLimits { + ConfiguredLimits { + max_amount: MAX_AMOUNT, + max_expiry_window: MAX_EXPIRY_WINDOW, + max_total_escrowed: MAX_TOTAL_ESCROWED, + max_page_size: MAX_PAGE_SIZE, + } +} + fn require_external_address(env: &Env, address: &Address) -> Result<(), Error> { if *address == env.current_contract_address() { return Err(Error::InvalidAddress); @@ -137,6 +146,7 @@ impl RemitFlowContract { storage::set_token(&env, &token); storage::set_counter(&env, 0); storage::set_initialized_at(&env, env.ledger().timestamp()); + storage::set_limits(&env, &default_limits()); storage::extend_instance(&env); events::init(&env, &admin, &token); Ok(()) @@ -209,23 +219,24 @@ impl RemitFlowContract { if amount <= 0 { return Err(Error::InvalidAmount); } + let limits = storage::get_limits(&env).unwrap_or_else(default_limits); if storage::get_account_op_count(&env, &from) >= MAX_ACCOUNT_OPS { return Err(Error::AccountLimitReached); } - if amount > MAX_AMOUNT { + if amount > limits.max_amount { return Err(Error::AmountTooLarge); } let total_escrowed = storage::get_total_escrowed(&env); let updated_total = math::checked_add_amount(total_escrowed, amount).ok_or(Error::AmountTooLarge)?; - if updated_total > MAX_TOTAL_ESCROWED { + if updated_total > limits.max_total_escrowed { return Err(Error::EscrowCapReached); } let now = env.ledger().timestamp(); if expiry <= now { return Err(Error::InvalidExpiry); } - if expiry - now > MAX_EXPIRY_WINDOW { + if expiry - now > limits.max_expiry_window { return Err(Error::ExpiryTooFar); } if from == recipient { @@ -346,7 +357,11 @@ impl RemitFlowContract { let last = storage::get_counter(&env); let mut page = Vec::new(&env); let mut id = start_id.max(1); - let page_size = limit.min(MAX_PAGE_SIZE); + let page_size = limit.min( + storage::get_limits(&env) + .unwrap_or_else(default_limits) + .max_page_size, + ); while id <= last && page.len() < page_size { if let Some(transfer) = storage::get_transfer(&env, id) { page.push_back(transfer); @@ -485,13 +500,35 @@ impl RemitFlowContract { storage::get_pending_admin(&env) } - pub fn get_limits(_env: Env) -> ConfiguredLimits { - ConfiguredLimits { - max_amount: MAX_AMOUNT, - max_expiry_window: MAX_EXPIRY_WINDOW, - max_total_escrowed: MAX_TOTAL_ESCROWED, - max_page_size: MAX_PAGE_SIZE, + pub fn set_limits(env: Env, limits: ConfiguredLimits) -> Result<(), Error> { + let admin = storage::get_admin(&env).ok_or(Error::NotInitialized)?; + admin.require_auth(); + + let old_limits = storage::get_limits(&env).unwrap_or_else(default_limits); + if limits == old_limits { + return Ok(()); + } + require_cooldown(&env)?; + + if limits.max_amount <= 0 + || limits.max_expiry_window == 0 + || limits.max_total_escrowed <= 0 + || limits.max_page_size == 0 + || limits.max_amount > limits.max_total_escrowed + || limits.max_total_escrowed < storage::get_total_escrowed(&env) + { + return Err(Error::InvalidLimits); } + + storage::set_limits(&env, &limits); + record_privileged_call(&env); + storage::extend_instance(&env); + events::limits_changed(&env, &admin, &old_limits, &limits); + Ok(()) + } + + pub fn get_limits(env: Env) -> ConfiguredLimits { + storage::get_limits(&env).unwrap_or_else(default_limits) } /// Sweeps an expired transfer, returning the escrowed funds to the original sender. diff --git a/src/storage.rs b/src/storage.rs index 588c5082..390f0aa1 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1,6 +1,6 @@ use soroban_sdk::{contracttype, Address, Env}; -use crate::types::Transfer; +use crate::types::{ConfiguredLimits, Transfer}; /// Number of ledgers used as the threshold before bumping instance TTL. pub const INSTANCE_BUMP_THRESHOLD: u32 = 518_400; @@ -20,8 +20,8 @@ pub const PERSISTENT_BUMP_AMOUNT: u32 = 535_680; /// # Collision safety /// Soroban serialises #[contracttype] enum keys as an XDR ScVec whose /// first element is the variant name as a Symbol. Because the name string is -/// part of the on-chain key, no two distinct variants even with identical -/// payloads can ever collide. Separating instance and persistent keys into +/// part of the on-chain key, no two distinct variants even with identical +/// payloads can ever collide. Separating instance and persistent keys into /// two enums makes a mis-routed write (e.g. passing an [InstanceKey] to the /// persistent store) a compile error rather than a silent bug. #[contracttype] @@ -48,6 +48,8 @@ pub enum InstanceKey { InitializedAt, /// Timestamp of the most recent privileged administrative call. LastPrivilegedCall, + /// Mutable operational limits enforced by the contract. + Limits, } /// Keys for values held in **persistent** storage. @@ -193,6 +195,16 @@ pub fn set_last_privileged_call(env: &Env, timestamp: u64) { .set(&InstanceKey::LastPrivilegedCall, ×tamp); } +/// Read the configured operational limits, if they have been stored. +pub fn get_limits(env: &Env) -> Option { + env.storage().instance().get(&InstanceKey::Limits) +} + +/// Persist the operational limits in instance storage. +pub fn set_limits(env: &Env, limits: &ConfiguredLimits) { + env.storage().instance().set(&InstanceKey::Limits, limits); +} + // --------------------------------------------------------------------------- // Persistent storage helpers // --------------------------------------------------------------------------- diff --git a/src/test.rs b/src/test.rs index 187a0a30..49f99a7d 100644 --- a/src/test.rs +++ b/src/test.rs @@ -9,7 +9,8 @@ use crate::test_utils::{ TestFixture, DEFAULT_EXPIRY_OFFSET, DEFAULT_SENDER_BALANCE, DEFAULT_TRANSFER_AMOUNT, }; use crate::types::{ - BatchOperation, BatchOperationResult, ClaimTransferOperation, CreateTransferOperation, Status, + BatchOperation, BatchOperationResult, ClaimTransferOperation, ConfiguredLimits, + CreateTransferOperation, Status, }; use crate::{RemitFlowContract, RemitFlowContractClient}; @@ -60,6 +61,95 @@ fn setup<'a>() -> TestFixture<'a> { TestFixture::new() } +#[test] +fn test_get_limits_returns_defaults() { + let s = setup(); + + assert_eq!( + s.client.get_limits(), + ConfiguredLimits { + max_amount: crate::MAX_AMOUNT, + max_expiry_window: crate::MAX_EXPIRY_WINDOW, + max_total_escrowed: crate::MAX_TOTAL_ESCROWED, + max_page_size: crate::MAX_PAGE_SIZE, + } + ); +} + +#[test] +fn test_set_limits_by_admin_succeeds() { + let s = setup(); + let new_limits = ConfiguredLimits { + max_amount: 500, + max_expiry_window: 2_000, + max_total_escrowed: 2_000, + max_page_size: 25, + }; + + s.client.set_limits(&new_limits); + assert_eq!(s.client.get_limits(), new_limits); +} + +#[test] +fn test_set_limits_requires_admin_auth() { + let env = Env::default(); + let admin = Address::generate(&env); + let (token, _, _) = create_token(&env, &admin); + + let contract_id = env.register_contract(None, RemitFlowContract); + let client = RemitFlowContractClient::new(&env, &contract_id); + env.mock_all_auths(); + client.initialize(&admin, &token); + let original = client.get_limits(); + env.set_auths(&[]); + + let new_limits = ConfiguredLimits { + max_amount: 500, + max_expiry_window: 2_000, + max_total_escrowed: 2_000, + max_page_size: 25, + }; + let res = client.try_set_limits(&new_limits); + assert!(res.is_err()); + assert_eq!(client.get_limits(), original); +} + +#[test] +fn test_updated_limits_are_enforced() { + let s = setup(); + let limits = ConfiguredLimits { + max_amount: 250, + max_expiry_window: crate::MAX_EXPIRY_WINDOW, + max_total_escrowed: crate::MAX_TOTAL_ESCROWED, + max_page_size: crate::MAX_PAGE_SIZE, + }; + s.client.set_limits(&limits); + + let result = s + .client + .try_create_transfer(&s.from, &s.recipient, &251, &s.future_expiry()); + + assert_eq!(result, Err(Ok(crate::error::Error::AmountTooLarge))); +} + +#[test] +fn test_set_limits_rejects_invalid_configuration() { + let s = setup(); + let original = s.client.get_limits(); + let invalid = ConfiguredLimits { + max_amount: 0, + max_expiry_window: original.max_expiry_window, + max_total_escrowed: original.max_total_escrowed, + max_page_size: original.max_page_size, + }; + + assert_eq!( + s.client.try_set_limits(&invalid), + Err(Ok(crate::error::Error::InvalidLimits)) + ); + assert_eq!(s.client.get_limits(), original); +} + #[test] fn test_batch_operations_executes_successful_batch_in_order() { let s = setup(); @@ -995,6 +1085,15 @@ fn test_admin_operations_require_initialization() { let res = client.try_unpause(); assert_eq!(res, Err(Ok(crate::error::Error::NotInitialized))); + + let limits = ConfiguredLimits { + max_amount: 500, + max_expiry_window: 2_000, + max_total_escrowed: 2_000, + max_page_size: 25, + }; + let res = client.try_set_limits(&limits); + assert_eq!(res, Err(Ok(crate::error::Error::NotInitialized))); } // --- Arithmetic boundary tests ---