Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 143 additions & 12 deletions COMEBACKHERE-contracts/contracts/compliance/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>) {
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]
Expand Down Expand Up @@ -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(())
}

Expand All @@ -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(())
}

Expand All @@ -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(())
}

Expand All @@ -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(
Expand All @@ -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<Address>,
) -> 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();
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
111 changes: 111 additions & 0 deletions COMEBACKHERE-contracts/contracts/treasury/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions abis/treasury.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"set_signer",
"rotate_signer",
"propose_settlement",
"cancel_settlement",
"approve_settlement",
"execute_settlement",
"simulate_settlement",
Expand Down
2 changes: 2 additions & 0 deletions comebackhere-backend/src/db/mongo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion comebackhere-backend/src/services/compliance-indexer.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading