diff --git a/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs b/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs index 290fb81..bfa6c12 100644 --- a/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs @@ -58,7 +58,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] @@ -108,8 +115,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(()) } @@ -119,8 +125,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(()) } @@ -137,8 +142,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(()) } @@ -164,8 +174,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( @@ -175,6 +190,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(); @@ -216,8 +255,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(()) } @@ -294,6 +332,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] @@ -376,7 +451,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); @@ -462,6 +537,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 9cf071a..ed36dcb 100644 --- a/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs @@ -412,6 +412,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 @@ -1146,6 +1179,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/treasury.json b/abis/treasury.json index 678b52d..e1049f3 100644 --- a/abis/treasury.json +++ b/abis/treasury.json @@ -7,6 +7,7 @@ "set_signer", "rotate_signer", "propose_settlement", + "cancel_settlement", "approve_settlement", "execute_settlement", "simulate_settlement", diff --git a/comebackhere-backend/src/db/mongo.ts b/comebackhere-backend/src/db/mongo.ts index 1e3a662..b5d031e 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 8277644..b07600a 100644 --- a/comebackhere-backend/src/services/compliance-indexer.ts +++ b/comebackhere-backend/src/services/compliance-indexer.ts @@ -1,6 +1,6 @@ import { SorobanRpc, 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 @@ -67,6 +67,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 0297734..cac45f7 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -82,14 +82,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. --- @@ -115,10 +114,10 @@ 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`. |