From 0e5a362930d2165c222025371303a76ed3280685 Mon Sep 17 00:00:00 2001 From: Amiricloud1 Date: Sat, 26 Sep 2026 12:15:31 +0000 Subject: [PATCH] feat: add compliance events and settlement cancellation --- .../contracts/compliance/src/lib.rs | 155 ++++++++++++++++-- .../contracts/treasury/src/lib.rs | 113 +++++++++++++ abis/compliance.json | 8 +- abis/treasury.json | 21 ++- comebackhere-backend/src/db/mongo.ts | 2 + .../src/services/compliance-indexer.ts | 43 ++++- docs/error-codes.md | 23 ++- 7 files changed, 331 insertions(+), 34 deletions(-) diff --git a/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs b/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs index d213625..a48a192 100644 --- a/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs @@ -56,7 +56,14 @@ fn check_not_past_expiry(e: &Env, until: u64) -> Result<(), ContractError> { } } -/// Maximum number of addresses accepted by a single `batch_allow_addresses` call. +fn publish_status_event(e: &Env, event_type: &str, addr: Address, status: &str, until: Option) { + e.events().publish( + (Symbol::new(e, event_type),), + (addr, Symbol::new(e, status), until), + ); +} + +/// Maximum number of addresses accepted by a single batch status call. const MAX_BATCH_SIZE: u32 = 50; #[contractimpl] @@ -92,8 +99,7 @@ impl ComplianceContract { e.storage() .instance() .set(&DataKey::Status(addr.clone()), &AddressStatus::Allowed); - e.events() - .publish((Symbol::new(&e, "address_allowed"),), addr); + publish_status_event(&e, "address_allowed", addr, "Allowed", None); Ok(()) } @@ -103,8 +109,7 @@ impl ComplianceContract { e.storage() .instance() .set(&DataKey::Status(addr.clone()), &AddressStatus::Blocked); - e.events() - .publish((Symbol::new(&e, "address_blocked"),), addr); + publish_status_event(&e, "address_blocked", addr, "Blocked", None); Ok(()) } @@ -121,8 +126,13 @@ impl ComplianceContract { &DataKey::Status(addr.clone()), &AddressStatus::AllowedUntil(until), ); - e.events() - .publish((Symbol::new(&e, "address_allowed_until"),), (addr, until)); + publish_status_event( + &e, + "address_allowed_until", + addr, + "AllowedUntil", + Some(until), + ); Ok(()) } @@ -148,8 +158,13 @@ impl ComplianceContract { &DataKey::Status(addr.clone()), &AddressStatus::AllowedUntil(until), ); - e.events() - .publish((Symbol::new(&e, "address_allowed"),), (addr.clone(), until)); + publish_status_event( + &e, + "address_allowed_until", + addr, + "AllowedUntil", + Some(until), + ); } e.events().publish( @@ -159,6 +174,30 @@ impl ComplianceContract { Ok(()) } + /// Blocks a batch of addresses in a single invocation. + /// Enforces the same admin-only authorization and batch-size limit as + /// `batch_allow_addresses`, and rejects the whole batch before any writes. + pub fn batch_block_addresses( + e: Env, + admin: Address, + addresses: Vec
, + ) -> Result<(), ContractError> { + check_not_paused(&e)?; + admin.require_auth(); + if addresses.len() > MAX_BATCH_SIZE { + return Err(ContractError::BatchTooLarge); + } + + for addr in addresses.iter() { + e.storage() + .instance() + .set(&DataKey::Status(addr.clone()), &AddressStatus::Blocked); + publish_status_event(&e, "address_blocked", addr, "Blocked", None); + } + + Ok(()) + } + pub fn transfer_admin(e: Env, admin: Address, new_admin: Address) -> Result<(), ContractError> { check_not_paused(&e)?; admin.require_auth(); @@ -200,8 +239,7 @@ impl ComplianceContract { e.storage() .instance() .remove(&DataKey::Status(addr.clone())); - e.events() - .publish((Symbol::new(&e, "address_cleared"),), (addr, status)); + publish_status_event(&e, "address_cleared", addr, "Cleared", None); Ok(()) } @@ -278,6 +316,43 @@ mod tests { assert!(!c.is_allowed(&addr)); } + #[test] + fn test_get_address_status_retains_expiry_at_and_after_boundary() { + let (e, cid, admin, addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + c.allow_address_until(&admin, &addr, &2000u64); + + assert!(matches!(c.get_address_status(&addr), AddressStatus::AllowedUntil(2000))); + e.ledger().with_mut(|li| li.timestamp = 2000); + assert!(matches!(c.get_address_status(&addr), AddressStatus::AllowedUntil(2000))); + e.ledger().with_mut(|li| li.timestamp = 2001); + assert!(matches!(c.get_address_status(&addr), AddressStatus::AllowedUntil(2000))); + } + + #[test] + fn test_permanent_allow_replaces_expiring_allowance() { + let (e, cid, admin, addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + c.allow_address_until(&admin, &addr, &2000u64); + c.allow_address(&admin, &addr); + e.ledger().with_mut(|li| li.timestamp = 2001); + + assert!(c.is_allowed(&addr)); + assert!(matches!(c.get_address_status(&addr), AddressStatus::Allowed)); + } + + #[test] + fn test_block_replaces_expiring_allowance() { + let (e, cid, admin, addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + c.allow_address_until(&admin, &addr, &2000u64); + c.block_address(&admin, &addr); + e.ledger().with_mut(|li| li.timestamp = 1500); + + assert!(!c.is_allowed(&addr)); + assert!(matches!(c.get_address_status(&addr), AddressStatus::Blocked)); + } + // ── allow_address_until past-expiry validation ───────────────────────────── #[test] @@ -360,7 +435,7 @@ mod tests { let all_events = e.events().all(); let allowed_count = all_events .iter() - .filter(|ev| ev.0 == (cid.clone(), "address_allowed".into())) + .filter(|ev| ev.0 == (cid.clone(), "address_allowed_until".into())) .count(); assert_eq!(allowed_count, 3); @@ -446,6 +521,62 @@ mod tests { assert_eq!(res, Err(Ok(ContractError::ContractPaused))); } + #[test] + fn test_batch_block_addresses_blocks_all_and_emits_per_address_events() { + let (e, cid, admin, _addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + let mut addresses = Vec::new(&e); + for _ in 0..MAX_BATCH_SIZE { + addresses.push_back(Address::generate(&e)); + } + + c.batch_block_addresses(&admin, &addresses); + + for addr in addresses.iter() { + assert!(matches!(c.get_address_status(&addr), AddressStatus::Blocked)); + } + let blocked_count = e + .events() + .all() + .iter() + .filter(|ev| ev.0 == (cid.clone(), "address_blocked".into())) + .count(); + assert_eq!(blocked_count, MAX_BATCH_SIZE as usize); + } + + #[test] + fn test_batch_block_addresses_rejects_over_cap_without_writes() { + let (e, cid, admin, _addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + let mut addresses = Vec::new(&e); + for _ in 0..51 { + addresses.push_back(Address::generate(&e)); + } + + assert_eq!( + c.try_batch_block_addresses(&admin, &addresses), + Err(Ok(ContractError::BatchTooLarge)) + ); + assert!(matches!( + c.get_address_status(&addresses.get(0).unwrap()), + AddressStatus::Cleared + )); + assert!(e.events().all().is_empty()); + } + + #[test] + fn test_batch_block_addresses_rejects_when_paused() { + let (e, _cid, admin, addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &_cid); + let addresses = soroban_sdk::vec![&e, addr]; + c.pause(&admin); + + assert_eq!( + c.try_batch_block_addresses(&admin, &addresses), + Err(Ok(ContractError::ContractPaused)) + ); + } + #[test] fn test_permanent_allow_unaffected_by_time() { let (_e, c, admin, addr) = setup(9999); diff --git a/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs b/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs index 7d276e1..48b22e1 100644 --- a/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs @@ -83,6 +83,8 @@ pub enum TreasuryError { /// `daily_withdraw_limit` and the withdrawal would push cumulative /// withdrawals for the current 24h window above that limit. DailyLimitExceeded = 13, + /// The requested settlement ID does not exist. + SettlementNotFound = 14, } /// Storage keys for Treasury contract instance state. @@ -351,6 +353,39 @@ impl TreasuryContract { Ok(settlement_id) } + /// Cancels a pending settlement when called by its proposer or the admin. + pub fn cancel_settlement( + e: Env, + caller: Address, + settlement_id: u64, + ) -> Result<(), TreasuryError> { + check_not_paused(&e)?; + caller.require_auth(); + + let mut settlement: Settlement = e + .storage() + .instance() + .get(&DataKey::Settlement(settlement_id)) + .ok_or(TreasuryError::SettlementNotFound)?; + if settlement.status != SettlementStatus::Pending { + return Err(TreasuryError::NotPending); + } + let admin: Address = e.storage().instance().get(&DataKey::Admin).unwrap(); + if caller != settlement.proposer && caller != admin { + return Err(TreasuryError::Unauthorized); + } + + settlement.status = SettlementStatus::Cancelled; + e.storage() + .instance() + .set(&DataKey::Settlement(settlement_id), &settlement); + e.events().publish( + (Symbol::new(&e, "settlement_cancelled"),), + (settlement_id, caller, SettlementStatus::Cancelled), + ); + Ok(()) + } + /// Casts an approval vote on a pending settlement proposal. /// /// # Arguments @@ -1012,6 +1047,84 @@ mod tests { assert_eq!(result, Vec::new(&e)); } + #[test] + fn test_proposer_can_cancel_and_record_remains_readable() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let proposer = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); + let merchant = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e, (proposer.clone(), 1u64)], &1, &admin); + let settlement_id = c.propose_settlement(&proposer, &token, &100u64, &merchant); + + c.cancel_settlement(&proposer, &settlement_id); + + let settlement = c.get_settlement(&settlement_id).unwrap(); + assert_eq!(settlement.status, SettlementStatus::Cancelled); + assert_eq!( + c.try_approve_settlement(&proposer, &settlement_id), + Err(Ok(TreasuryError::NotPending)) + ); + assert!(!c + .get_pending_settlements(&None, &None) + .contains(&settlement_id)); + assert!(e + .events() + .all() + .iter() + .any(|event| event.0 == (id.clone(), "settlement_cancelled".into()))); + } + + #[test] + fn test_admin_can_cancel_and_other_callers_are_rejected() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let proposer = soroban_sdk::Address::generate(&e); + let other = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); + let merchant = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e, (proposer.clone(), 1u64)], &1, &admin); + let settlement_id = c.propose_settlement(&proposer, &token, &100u64, &merchant); + + assert_eq!( + c.try_cancel_settlement(&other, &settlement_id), + Err(Ok(TreasuryError::Unauthorized)) + ); + c.cancel_settlement(&admin, &settlement_id); + assert_eq!( + c.get_settlement(&settlement_id).unwrap().status, + SettlementStatus::Cancelled + ); + } + + #[test] + fn test_executed_and_disputed_settlements_cannot_be_cancelled() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let proposer = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); + let merchant = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e, (proposer.clone(), 1u64)], &1, &admin); + + let executed_id = c.propose_settlement(&proposer, &token, &100u64, &merchant); + c.approve_settlement(&proposer, &executed_id); + c.execute_settlement(&proposer, &executed_id, &token); + assert_eq!( + c.try_cancel_settlement(&proposer, &executed_id), + Err(Ok(TreasuryError::NotPending)) + ); + + let disputed_id = c.propose_settlement(&proposer, &token, &100u64, &merchant); + c.raise_dispute(&merchant, &disputed_id, &1u32); + assert_eq!( + c.try_cancel_settlement(&proposer, &disputed_id), + Err(Ok(TreasuryError::NotPending)) + ); + } + #[test] fn test_single_pending() { let (e, id) = setup(); diff --git a/abis/compliance.json b/abis/compliance.json index 2cb4d50..60a5fa6 100644 --- a/abis/compliance.json +++ b/abis/compliance.json @@ -8,6 +8,8 @@ "allow_address", "block_address", "allow_address_until", + "batch_allow_addresses", + "batch_block_addresses", "transfer_admin", "accept_admin", "clear_address", @@ -19,6 +21,7 @@ "address_blocked", "address_cleared", "address_allowed_until", + "compliance_batch_processed", "admin_transfer_initiated", "admin_transferred", "contract_paused", @@ -27,6 +30,9 @@ "errors": { "1": "Unauthorized", "2": "ContractPaused", - "3": "AlreadyInitialized" + "3": "AlreadyInitialized", + "4": "AddressNotFound", + "5": "PastExpiry", + "6": "BatchTooLarge" } } diff --git a/abis/treasury.json b/abis/treasury.json index e24e1aa..5e25b25 100644 --- a/abis/treasury.json +++ b/abis/treasury.json @@ -5,6 +5,7 @@ "initialize", "set_signer", "propose_settlement", + "cancel_settlement", "approve_settlement", "execute_settlement", "simulate_settlement", @@ -22,15 +23,23 @@ "add_token_to_allowlist", "remove_token_from_allowlist" ], + "events": [ + "settlement_cancelled" + ], "errors": { "1": "ContractPaused", "2": "NotPending", "3": "InsufficientApprovals", - "4": "DisputeNotFound", - "5": "DisputeAlreadyRaised", - "6": "DisputeNotRaised", - "7": "AlreadyVoted", - "8": "UnauthorizedSigner", - "9": "ThresholdNotMet" + "4": "TokenNotAllowed", + "5": "Unauthorized", + "6": "InvalidThreshold", + "7": "DuplicateSigner", + "8": "InvalidWeightSum", + "9": "NotSettlementParty", + "10": "ThresholdExceedsWeight", + "11": "InvalidPagination", + "12": "SignerNotFound", + "13": "DailyLimitExceeded", + "14": "SettlementNotFound" } } diff --git a/comebackhere-backend/src/db/mongo.ts b/comebackhere-backend/src/db/mongo.ts index 4126742..007ea49 100644 --- a/comebackhere-backend/src/db/mongo.ts +++ b/comebackhere-backend/src/db/mongo.ts @@ -40,11 +40,13 @@ export interface SettlementRecord { } export type ComplianceAuditEventType = "address_allowed" | "address_allowed_until" | "address_blocked" | "address_cleared" +export type ComplianceAuditStatus = "Allowed" | "AllowedUntil" | "Blocked" | "Cleared" export interface ComplianceAuditRecord { event_id: string event_type: ComplianceAuditEventType address: string + status?: ComplianceAuditStatus expires_at: number | null ledger: number ledger_closed_at: string | null diff --git a/comebackhere-backend/src/services/compliance-indexer.ts b/comebackhere-backend/src/services/compliance-indexer.ts index e29f0ae..3674e19 100644 --- a/comebackhere-backend/src/services/compliance-indexer.ts +++ b/comebackhere-backend/src/services/compliance-indexer.ts @@ -1,6 +1,6 @@ import { xdr } from "stellar-sdk" import { buildSorobanClient, type SorobanClient } from "../lib/soroban.js" -import { connectMongo, getCursorsCollection, getComplianceAuditCollection, type ComplianceAuditRecord } from "../db/mongo.js" +import { connectMongo, getCursorsCollection, getComplianceAuditCollection, type ComplianceAuditRecord, type ComplianceAuditStatus } from "../db/mongo.js" const CURSOR_ID = "compliance_audit_events" const EVENT_LIMIT = 100 @@ -15,15 +15,45 @@ function address(value: xdr.ScVal | undefined): string { return value?.address()?.toString() ?? "" } +function status(value: xdr.ScVal | undefined): string { + try { return value?.sym()?.toString() ?? "" } + catch { return "" } +} + +function values(event: any): xdr.ScVal[] | undefined { + try { return event.value?.vec() } + catch { return undefined } +} + +function isU64(value: xdr.ScVal | undefined): boolean { + try { return value?.u64() !== undefined } + catch { return false } +} + function eventAddress(event: any, eventType: string): string { - return address(eventType === "address_cleared" || eventType === "address_allowed_until" - ? event.value?.vec()?.[0] - : event.value) + const payload = values(event) + return address(payload?.[0] ?? event.value) +} + +function eventStatus(event: any, eventType: string): ComplianceAuditStatus { + const payload = values(event) + const emittedStatus = status(payload?.[1]) + if (emittedStatus === "Allowed" || emittedStatus === "AllowedUntil" || emittedStatus === "Blocked" || emittedStatus === "Cleared") { + return emittedStatus + } + if (isU64(payload?.[1])) return "AllowedUntil" + if (eventType === "address_allowed_until") return "AllowedUntil" + if (eventType === "address_blocked") return "Blocked" + if (eventType === "address_cleared") return "Cleared" + return "Allowed" } function eventExpiry(event: any, eventType: string): number | null { - if (eventType !== "address_allowed_until") return null - return Number(event.value?.vec()?.[1]?.u64()?.toString() ?? 0) || null + const payload = values(event) + const expiry = status(payload?.[1]) ? payload?.[2] : payload?.[1] + if (eventType !== "address_allowed_until" && !isU64(expiry)) return null + if (!isU64(expiry)) return null + return Number(expiry?.u64().toString()) || null } export function complianceEventId(event: any, eventType: string, addressValue: string): string { @@ -66,6 +96,7 @@ export async function processComplianceIndexerBatch( event_id: id, event_type: eventType as ComplianceAuditRecord["event_type"], address: addressValue, + status: eventStatus(event, eventType), expires_at: eventExpiry(event, eventType), ledger: event.ledger ?? 0, ledger_closed_at: event.ledgerClosedAt ?? null, diff --git a/docs/error-codes.md b/docs/error-codes.md index 0f707aa..70a79ef 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -81,14 +81,13 @@ Defined in `COMEBACKHERE-contracts/contracts/compliance/src/lib.rs`. Every state | Event topic | Emitted by | Data payload | Notes | | ------------- | ------------ | --------------- | ------- | -| `address_allowed` | `allow_address` | `Address` | Permanent allow, no expiry. | -| `address_allowed` | `batch_allow_addresses` | `(Address, u64)` — address and its `until` timestamp | One event per address processed. Same topic as `allow_address`, but the payload additionally carries the `until` value shared by the whole batch. | -| `address_allowed_until` | `allow_address_until` | `(Address, u64)` — address and its `until` timestamp | Single-address, time-bounded allow. | -| `address_blocked` | `block_address` | `Address` | | -| `address_cleared` | `clear_address` | `(Address, AddressStatus)` — address and the status it held immediately before clearing | Never emitted when the address was already `Cleared` (that call fails with `AddressNotFound` instead). | -| `compliance_batch_processed` | `batch_allow_addresses` | `(Address, u32)` — the calling admin and the number of addresses processed | Emitted once per `batch_allow_addresses` call, after all per-address `address_allowed` events for that call. Lets an indexer confirm a batch operation has fully landed (`processed_count` matches the number of `address_allowed` events it should have seen in that transaction) without treating event counting as the sole source of truth. | +| `address_allowed` | `allow_address` | `(Address, Symbol, Option)` — address, `Allowed`, and no expiry | Permanent allow. | +| `address_allowed_until` | `allow_address_until`, `batch_allow_addresses` | `(Address, Symbol, Option)` — address, `AllowedUntil`, and expiry | Batch allow emits one event per address. | +| `address_blocked` | `block_address`, `batch_block_addresses` | `(Address, Symbol, Option)` — address, `Blocked`, and no expiry | Batch block emits one event per address. | +| `address_cleared` | `clear_address` | `(Address, Symbol, Option)` — address, `Cleared`, and no expiry | Never emitted when the address was already `Cleared` (that call fails with `AddressNotFound` instead). | +| `compliance_batch_processed` | `batch_allow_addresses` | `(Address, u32)` — the calling admin and the number of addresses processed | Emitted once per `batch_allow_addresses` call, after all per-address `address_allowed_until` events for that call. Lets an indexer confirm a batch operation has fully landed (`processed_count` matches the number of address events it should have seen in that transaction) without treating event counting as the sole source of truth. | -`batch_allow_addresses` caps `addresses` at 50 entries per call (`ContractError::BatchTooLarge` above that) and validates `until` the same way `allow_address_until` does (`ContractError::PastExpiry` if `until <= env.ledger().timestamp()`). Both checks run before any storage writes or events, so a rejected call has no partial effects. +Both batch operations cap `addresses` at 50 entries per call (`ContractError::BatchTooLarge` above that). `batch_allow_addresses` validates `until` the same way `allow_address_until` does (`ContractError::PastExpiry` if `until <= env.ledger().timestamp()`). These checks run before any storage writes or per-address events, so a rejected call has no partial effects. --- @@ -114,13 +113,19 @@ Defined in `COMEBACKHERE-contracts/contracts/treasury/src/lib.rs`. | Code | Name | Trigger condition | Remediation | | ------ | ------ | ------------------- | ------------- | | 1 | `ContractPaused` | A state-changing call was made while the treasury is in a paused state. | Defer transactions until the admin runs `unpause`. | -| 2 | `NotPending` | `approve_settlement` or `execute_settlement` was called on a settlement that is not in `Pending` status. | Confirm pending status with `get_pending_settlements` before approving or executing. | +| 2 | `NotPending` | `approve_settlement`, `execute_settlement`, or `cancel_settlement` was called on a settlement that is not in `Pending` status. | Confirm pending status with `get_pending_settlements` before approving, executing, or cancelling. | | 3 | `InsufficientApprovals` | `execute_settlement` was called before accumulated signer weight reached the configured threshold. | Continue gathering approvals until `approval_weight ≥ threshold`, then call `execute_settlement`. | | 4 | `TokenNotAllowed` | `propose_settlement` was called with a token not present in the allowlist (when the allowlist is non-empty). | Admin must call `add_token_to_allowlist` for the token before settlements may be proposed against it. | -| 5 | `Unauthorized` | Caller is not registered as a signer (for `propose_settlement`/`approve_settlement`) or not the admin (for `set_signer`, `pause`, etc). | Use a key registered via `initialize` or `set_signer`; admin-only operations require the admin key. | +| 5 | `Unauthorized` | A caller other than the admin or proposer attempted to cancel, or an admin-only operation was called by a non-admin. | Cancel as the settlement proposer or configured admin; use the admin key for admin-only operations. | | 6 | `InvalidThreshold` | `update_threshold` was called with a threshold of 0. | Pass a positive `u32` threshold; the multi-sig cannot function with zero required weight. | | 7 | `DuplicateSigner` | `initialize` was called with the same signer address appearing more than once in the `signers` list. | Ensure every `(address, weight)` pair in the `signers` vector is unique before calling `initialize`. | | 8 | `InvalidWeightSum` | `initialize` was called with a `threshold` greater than the sum of all signer weights. | Lower the threshold or add signers with sufficient weight so that `sum(weights) ≥ threshold`. | +| 9 | `NotSettlementParty` | `raise_dispute` was called by an address other than the merchant. | Sign with the merchant address associated with the settlement. | +| 10 | `ThresholdExceedsWeight` | `update_threshold` was called with a threshold greater than the total registered signer weight. | Reduce the threshold or register enough signer weight. | +| 11 | `InvalidPagination` | `get_pending_settlements` was called with a limit above the maximum page size. | Request no more than 100 settlements per page. | +| 12 | `SignerNotFound` | `rotate_signer` was called with an address that is not a registered signer. | Use a current signer address as `old_signer`. | +| 13 | `DailyLimitExceeded` | `withdraw` would exceed the configured rolling 24-hour withdrawal limit. | Wait for the window to reset or configure a higher limit. | +| 14 | `SettlementNotFound` | `cancel_settlement` or `simulate_settlement` was called with an unknown settlement ID. | Confirm the ID returned by `propose_settlement`. | ---