diff --git a/.github/workflows/contract-size.yml b/.github/workflows/contract-size.yml index 02ddc0f..5591812 100644 --- a/.github/workflows/contract-size.yml +++ b/.github/workflows/contract-size.yml @@ -44,6 +44,15 @@ jobs: path: target key: ${{ runner.os }}-contract-size-${{ hashFiles('Cargo.lock') }} + - name: Build contract WASM + run: | + set -e + cargo build --target wasm32-unknown-unknown --release \ + -p comebackhere-compliance \ + -p comebackhere-invoice \ + -p comebackhere-treasury \ + -p comebackhere-settlement-workflow + - name: Install wasm-opt run: sudo apt-get update && sudo apt-get install -y binaryen @@ -74,10 +83,12 @@ jobs: for wasm in target/wasm32-unknown-unknown/release/*.wasm; do name=$(basename "$wasm" .wasm) size=$(stat --format=%s "$wasm") - # treasury gets a temporarily raised threshold while the Result-refactor - # series (#386/#387/#388) lands; revert to MAX_CONTRACT_SIZE once complete. + # treasury carries a temporarily raised threshold: first for the + # Result-refactor series (#386/#387/#388), then for the admin + # signer/threshold-change timelock (#526). Revert to + # MAX_CONTRACT_SIZE once a size-reduction pass brings it back under. if [ "$name" = "treasury" ]; then - limit=75000 + limit=78000 else limit="$MAX_CONTRACT_SIZE" fi @@ -98,10 +109,18 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Load version pins + shell: bash + run: | + set -a + source .github/versions.env + set +a + echo "RUST_VERSION=$RUST_VERSION" >> "$GITHUB_ENV" + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: "1.95.0" + toolchain: ${{ env.RUST_VERSION }} targets: wasm32-unknown-unknown - name: Cache cargo build diff --git a/.github/workflows/no-std-check.yml b/.github/workflows/no-std-check.yml index cdc5ac5..0ce1e59 100644 --- a/.github/workflows/no-std-check.yml +++ b/.github/workflows/no-std-check.yml @@ -15,10 +15,18 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Load version pins + shell: bash + run: | + set -a + source .github/versions.env + set +a + echo "RUST_VERSION=$RUST_VERSION" >> "$GITHUB_ENV" + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: "1.95.0" + toolchain: ${{ env.RUST_VERSION }} targets: wasm32-unknown-unknown - name: Cache Rust dependencies diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a9fc3d0..85fbbf8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -113,10 +113,18 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Load version pins + shell: bash + run: | + set -a + source .github/versions.env + set +a + echo "RUST_VERSION=$RUST_VERSION" >> "$GITHUB_ENV" + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: "1.95.0" + toolchain: ${{ env.RUST_VERSION }} targets: wasm32-unknown-unknown - name: Cache Rust dependencies @@ -150,10 +158,18 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Load version pins + shell: bash + run: | + set -a + source .github/versions.env + set +a + echo "RUST_VERSION=$RUST_VERSION" >> "$GITHUB_ENV" + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: "1.95.0" + toolchain: ${{ env.RUST_VERSION }} targets: wasm32-unknown-unknown - name: Cache Rust dependencies diff --git a/.github/workflows/testnet-deploy.yml b/.github/workflows/testnet-deploy.yml index c921712..d0fb7be 100644 --- a/.github/workflows/testnet-deploy.yml +++ b/.github/workflows/testnet-deploy.yml @@ -10,7 +10,6 @@ on: env: CARGO_TERM_COLOR: always - STELLAR_CLI_VERSION: "22.8.2" jobs: deploy-testnet: @@ -20,6 +19,15 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Load version pins + shell: bash + run: | + set -a + source .github/versions.env + set +a + echo "RUST_VERSION=$RUST_VERSION" >> "$GITHUB_ENV" + echo "STELLAR_CLI_VERSION=$STELLAR_CLI_VERSION" >> "$GITHUB_ENV" + - name: Validate confirmation input run: | CONFIRM_INPUT="${{ inputs.confirm }}" @@ -33,7 +41,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: "1.95.0" + toolchain: ${{ env.RUST_VERSION }} targets: wasm32-unknown-unknown - name: Install stellar-cli build dependencies diff --git a/Cargo.lock b/Cargo.lock index 17c3a92..a24446c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -307,23 +307,9 @@ dependencies = [ "soroban-sdk", ] -[[package]] -name = "comebackhere-invoice-errors" -version = "0.1.0" -dependencies = [ - "soroban-sdk", -] - -[[package]] -name = "comebackhere-invoice-errors" -version = "0.1.0" -dependencies = [ - "soroban-sdk", -] - [[package]] name = "comebackhere-multisig" -version = "0.3.0" +version = "0.4.0" dependencies = [ "soroban-sdk", ] @@ -343,6 +329,7 @@ version = "1.0.0" dependencies = [ "comebackhere-compliance", "comebackhere-compliance-client", + "comebackhere-multisig", "comebackhere-treasury", "soroban-sdk", ] diff --git a/abis/compliance.json b/abis/compliance.json index f96001c..c9e61ca 100644 --- a/abis/compliance.json +++ b/abis/compliance.json @@ -1,70 +1,31 @@ { - "name": "compliance", - "version": "1.0.0", "functions": [ - { - "name": "validate_invoice", - "inputs": ["invoice_id"], - "outputs": ["validation_result"], - "description": "Validates an invoice against business rules" - }, - { - "name": "check_compliance", - "inputs": ["invoice_id", "customer_id"], - "outputs": ["compliance_status"], - "description": "Checks if an invoice complies with regulatory requirements" - }, - { - "name": "generate_compliance_report", - "inputs": ["period_start", "period_end"], - "outputs": ["report"], - "description": "Generates a compliance report for a period" - }, - { - "name": "flag_non_compliant", - "inputs": ["invoice_id"], - "outputs": ["flagged_invoices"], - "description": "Flags invoices that violate compliance rules" - }, - { - "name": "audit_log_entry", - "inputs": ["action", "entity_id", "timestamp"], - "outputs": ["audit_record"], - "description": "Creates an audit log entry" - } + "accept_admin", + "address_status", + "allow_address", + "allow_address_until", + "allow_address_with_tier", + "block_address", + "block_address_until", + "bulk_allow_addresses", + "bulk_block_addresses", + "bulk_check_addresses", + "clear_address", + "export_snapshot", + "export_snapshot_page", + "get_address_tier", + "get_allow_expiry", + "get_block_reason", + "get_schema_version", + "initialize", + "is_allowed", + "is_blocked", + "pause", + "revoke_allow", + "set_operator", + "sweep_expired", + "transfer_admin", + "unpause" ], - "events": [ - { - "name": "compliance_check_passed", - "payload": { - "invoice_id": "string", - "rules_checked": "array[string]", - "passed": "boolean" - } - }, - { - "name": "compliance_check_failed", - "payload": { - "invoice_id": "string", - "violations": "array[string]", - "severity": "string" - } - }, - { - "name": "non_compliant_invoice_flagged", - "payload": { - "invoice_id": "string", - "violations": "array[string]", - "action": "flag" - } - }, - { - "name": "audit_log_created", - "payload": { - "action": "string", - "entity_id": "string", - "timestamp": "timestamp" - } - } - ] -} \ No newline at end of file + "events": [] +} diff --git a/abis/invoice.json b/abis/invoice.json index fa53626..6975b4a 100644 --- a/abis/invoice.json +++ b/abis/invoice.json @@ -1,83 +1,43 @@ { - "name": "invoice", - "version": "1.0.0", "functions": [ - { - "name": "create_invoice", - "inputs": ["payer_id", "payee_id", "amount", "currency", "due_date"], - "outputs": ["invoice_id"], - "description": "Creates a new invoice" - }, - { - "name": "get_invoice", - "inputs": ["invoice_id"], - "outputs": ["invoice"], - "description": "Retrieves an invoice by ID" - }, - { - "name": "update_invoice", - "inputs": ["invoice_id", "updated_fields"], - "outputs": ["invoice"], - "description": "Updates an existing invoice" - }, - { - "name": "cancel_invoice", - "inputs": ["invoice_id"], - "outputs": ["result"], - "description": "Cancels an invoice" - }, - { - "name": "generate_payment_plan", - "inputs": ["invoice_id"], - "outputs": ["payment_plan"], - "description": "Generates a payment plan for an invoice" - }, - { - "name": "apply_discount", - "inputs": ["invoice_id", "discount_amount", "discount_type"], - "outputs": ["invoice"], - "description": "Applies a discount to an invoice" - } + "accept_admin", + "amend_invoice", + "approve_refund", + "batch_create_invoice", + "batch_expire", + "batch_get_invoice_status", + "cancel_invoice", + "create_invoice", + "extend_expiry", + "get_grace_window", + "get_invoice", + "get_invoice_count", + "get_invoice_status", + "get_invoices_by_merchant", + "get_invoices_page", + "get_pending_ids", + "initialize", + "mark_paid", + "pause", + "reject_refund", + "release_escrow", + "request_refund", + "set_grace_window", + "transfer_admin", + "unpause" ], "events": [ - { - "name": "invoice_created", - "payload": { - "invoice_id": "string", - "amount": "decimal", - "due_date": "timestamp", - "payer_id": "string", - "payee_id": "string" - } - }, - { - "name": "invoice_updated", - "payload": { - "invoice_id": "string", - "changes": "map[string,any]" - } - }, - { - "name": "invoice_cancelled", - "payload": { - "invoice_id": "string", - "reason": "string" - } - }, - { - "name": "payment_received", - "payload": { - "invoice_id": "string", - "amount": "decimal", - "transaction_id": "string" - } - }, - { - "name": "payment_failed", - "payload": { - "invoice_id": "string", - "reason": "string" - } - } + "contract_paused", + "contract_unpaused", + "escrow_released", + "invoice_amended", + "invoice_cancelled", + "invoice_created", + "invoice_expired", + "invoice_expiry_extended", + "invoice_paid", + "invoice_refund_requested", + "refund_approved", + "refund_rejected" ] -} \ No newline at end of file +} diff --git a/abis/settlement-workflow.json b/abis/settlement-workflow.json index 671d5e4..6e3c927 100644 --- a/abis/settlement-workflow.json +++ b/abis/settlement-workflow.json @@ -1,75 +1,8 @@ { - "name": "settlement-workflow", - "version": "1.0.0", "functions": [ - { - "name": "create_invoice", - "inputs": [], - "outputs": ["invoice_id"], - "description": "Creates a new invoice" - }, - { - "name": "get_invoice", - "inputs": ["invoice_id"], - "outputs": ["invoice"], - "description": "Retrieves an invoice by ID" - }, - { - "name": "update_invoice", - "inputs": ["invoice_id", "updated_data"], - "outputs": ["invoice"], - "description": "Updates an existing invoice" - }, - { - "name": "cancel_invoice", - "inputs": ["invoice_id"], - "outputs": ["result"], - "description": "Cancels an invoice" - }, - { - "name": "generate_payment_plan", - "inputs": ["invoice_id"], - "outputs": ["payment_plan"], - "description": "Generates a payment plan for an invoice" - } + "execute_with_compliance", + "execute_with_compliance_batch", + "initialize" ], - "events": [ - { - "name": "invoice_created", - "payload": { - "invoice_id": "string", - "amount": "decimal", - "due_date": "timestamp" - } - }, - { - "name": "invoice_updated", - "payload": { - "invoice_id": "string", - "changes": "map[string,any]" - } - }, - { - "name": "invoice_cancelled", - "payload": { - "invoice_id": "string", - "reason": "string" - } - }, - { - "name": "payment_received", - "payload": { - "invoice_id": "string", - "amount": "decimal", - "transaction_id": "string" - } - }, - { - "name": "payment_failed", - "payload": { - "invoice_id": "string", - "reason": "string" - } - } - ] -} \ No newline at end of file + "events": [] +} diff --git a/abis/treasury.json b/abis/treasury.json index a42b621..88f1c5c 100644 --- a/abis/treasury.json +++ b/abis/treasury.json @@ -1,89 +1,58 @@ { - "name": "treasury", - "version": "1.0.0", "functions": [ - { - "name": "create_escrow", - "inputs": ["account_id", "amount"], - "outputs": ["escrow_id"], - "description": "Creates a new escrow account" - }, - { - "name": "get_escrow", - "inputs": ["escrow_id"], - "outputs": ["escrow"], - "description": "Retrieves an escrow account by ID" - }, - { - "name": "deposit_to_escrow", - "inputs": ["escrow_id", "amount"], - "outputs": ["deposit_id"], - "description": "Deposits funds into an escrow account" - }, - { - "name": "withdraw_from_escrow", - "inputs": ["escrow_id", "amount"], - "outputs": ["withdrawal_id"], - "description": "Withdraws funds from an escrow account" - }, - { - "name": "release_escrow", - "inputs": ["escrow_id"], - "outputs": ["result"], - "description": "Releases funds from an escrow account" - }, - { - "name": "add_holder", - "inputs": ["escrow_id", "holder_id"], - "outputs": ["result"], - "description": "Adds a holder to an escrow account" - }, - { - "name": "remove_holder", - "inputs": ["escrow_id", "holder_id"], - "outputs": ["result"], - "description": "Removes a holder from an escrow account" - } + "add_allowed_token", + "approve_partial_settlement", + "approve_settlement", + "approve_signer_rotation", + "batch_approve_settlements", + "batch_cancel_settlements", + "batch_deposit", + "cancel_rotation", + "cancel_settlement", + "cancel_signer_change", + "deposit", + "execute_settlement", + "execute_signer_change", + "expire_dispute", + "expire_settlement", + "force_cancel_settlement", + "get_all_signers", + "get_allowed_tokens", + "get_balance", + "get_dispute", + "get_hold_reason", + "get_merchant_payout_address", + "get_pending_metrics", + "get_pending_settlements", + "get_pending_settlements_page", + "get_settlement", + "get_signer_change", + "get_signer_weight", + "get_withdrawal_limit", + "hold_settlement", + "initialize", + "partially_execute_settlement", + "pause", + "propose_partial_settlement", + "propose_settlement", + "propose_signer_change", + "propose_signer_rotation", + "raise_dispute", + "release_hold", + "remove_allowed_token", + "remove_signer", + "resolve_dispute", + "resolve_dispute_split", + "set_signer", + "set_withdrawal_limit", + "unpause", + "update_merchant_payout_address", + "update_threshold", + "vote_dispute_resolution", + "withdraw", + "withdraw_all" ], "events": [ - { - "name": "escrow_created", - "payload": { - "escrow_id": "string", - "amount": "decimal", - "account_id": "string" - } - }, - { - "name": "escrow_deposited", - "payload": { - "escrow_id": "string", - "amount": "decimal", - "source_account": "string" - } - }, - { - "name": "escrow_withdrawn", - "payload": { - "escrow_id": "string", - "amount": "decimal", - "destination_account": "string" - } - }, - { - "name": "holder_added", - "payload": { - "escrow_id": "string", - "holder_id": "string", - "role": "string" - } - }, - { - "name": "holder_removed", - "payload": { - "escrow_id": "string", - "holder_id": "string" - } - } + "deposit" ] -} \ No newline at end of file +} diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index 52d4ae2..a339669 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -56,7 +56,9 @@ pub enum DataKey { SchemaVersion, /// Circuit-breaker flag — when `true`, administrative mutations are rejected. Paused, - /// Index of all tracked addresses for `export_snapshot`; bounded by `MAX_TRACKED_ADDRESSES`. + /// Legacy monolithic address index. Superseded by the paged index + /// (`AddrIndexPage` / `AddrIndexCount` / `AddrTracked`); retained only so the + /// enum stays append-only. No longer read or written. AddressIndex, /// Running count of addresses that have ever been allowed. AllowCount, @@ -76,6 +78,16 @@ pub enum DataKey { /// Timestamp of the caller's last `bulk_block_addresses` call, keyed per admin. /// See [`BULK_OP_COOLDOWN_SECS`] and `check_bulk_op_cooldown` (#454). LastBulkBlock(Address), + /// O(1) membership marker for the paged address index (persistent). Presence + /// means `address` already occupies a slot in the index; `track_address` + /// checks this instead of scanning the whole index. + AddrTracked(Address), + /// Page `n` of the ordered address index (persistent): the insertion-ordered + /// addresses at slots `[n * ADDR_INDEX_PAGE_SIZE, (n + 1) * ADDR_INDEX_PAGE_SIZE)`. + AddrIndexPage(u32), + /// Number of addresses in the paged index (instance). Bounded by + /// `MAX_TRACKED_ADDRESSES`. + AddrIndexCount, } /// Coarse classification of an address's compliance state. @@ -127,12 +139,23 @@ pub enum ContractError { BulkOperationCooldown = 6, } -/// Upper bound on the number of distinct addresses tracked in `DataKey::AddressIndex`. -/// Once reached, operations that would track a *new* address are rejected with -/// [`ContractError::AddressIndexFull`] instead of growing the index further — this -/// caps unbounded storage-rent growth. Existing tracked addresses are unaffected. -/// See `track_address`. -const MAX_TRACKED_ADDRESSES: u32 = 50_000; +/// Upper bound on the number of distinct addresses tracked in the paged address +/// index. Once reached, operations that would track a *new* address are rejected +/// with [`ContractError::AddressIndexFull`] instead of growing the index further +/// — this caps unbounded storage-rent growth. Existing tracked addresses are +/// unaffected. See `track_address`. +/// +/// `pub` so the boundary/pagination tests assert against the real value rather +/// than a hand-mirrored copy that silently drifts. +pub const MAX_TRACKED_ADDRESSES: u32 = 2_000; + +/// Number of addresses stored per `DataKey::AddrIndexPage` entry. The ordered +/// address index is split into fixed-size pages so `track_address` only ever +/// reads and rewrites the single small tail page instead of a monolithic `Vec` +/// that grows past the ledger-entry size limit and costs O(n) to re-serialise +/// on every insert. Kept small: each insert re-serialises the tail page, so the +/// page size is the per-insert cost, and reads concatenate pages. +const ADDR_INDEX_PAGE_SIZE: u32 = 25; /// Maximum number of addresses accepted per batch admin call, consistent with /// the batch caps used elsewhere in the workspace (see #8/#21/#29). @@ -712,11 +735,7 @@ impl ComplianceContract { /// of access, and admins may run it even while paused. pub fn sweep_expired(env: Env, admin: Address) -> Result { Self::require_admin(&env, &admin)?; - let index: Vec
= env - .storage() - .instance() - .get(&DataKey::AddressIndex) - .unwrap_or(Vec::new(&env)); + let index = Self::address_index(&env); let now = env.ledger().timestamp(); let mut swept = 0u32; for addr in index.iter() { @@ -766,11 +785,7 @@ impl ComplianceContract { limit: u32, ) -> Vec<(Address, AddressState)> { Self::require_admin(&env, &admin).unwrap(); - let index: Vec
= env - .storage() - .instance() - .get(&DataKey::AddressIndex) - .unwrap_or(Vec::new(&env)); + let index = Self::address_index(&env); let mut result = Vec::new(&env); let start = offset as usize; let end = if limit == 0 { @@ -796,19 +811,33 @@ impl ComplianceContract { limit: u64, ) -> Vec<(Address, AddressState)> { Self::require_admin(&env, &admin).unwrap(); - let index: Vec
= env + let total: u64 = env .storage() .instance() - .get(&DataKey::AddressIndex) - .unwrap_or(Vec::new(&env)); + .get::<_, u32>(&DataKey::AddrIndexCount) + .unwrap_or(0) as u64; let mut result = Vec::new(&env); - let total = index.len() as u64; + if limit == 0 || start >= total { + return result; + } + let end = (start.saturating_add(limit)).min(total); + let page_size = ADDR_INDEX_PAGE_SIZE as u64; let mut i = start; - while i < total && (result.len() as u64) < limit { - let addr = index.get(i as u32).unwrap(); - let state = Self::address_state(&env, &addr); - result.push_back((addr, state)); - i += 1; + // Only touch the pages the requested window actually spans — cost is + // O(window), not O(total index size). + while i < end { + let page: Vec
= env + .storage() + .persistent() + .get(&DataKey::AddrIndexPage((i / page_size) as u32)) + .unwrap_or(Vec::new(&env)); + let page_end = (((i / page_size) + 1) * page_size).min(end); + while i < page_end { + let addr = page.get((i % page_size) as u32).unwrap(); + let state = Self::address_state(&env, &addr); + result.push_back((addr, state)); + i += 1; + } } result } @@ -939,25 +968,70 @@ impl ComplianceContract { } } - /// Adds `address` to the instance-level AddressIndex if not already present. + /// Appends `address` to the paged address index if not already present. + /// + /// O(1) amortised: membership is a single `AddrTracked` lookup and only the + /// tail page is rewritten, so this stays cheap even as the index approaches + /// [`MAX_TRACKED_ADDRESSES`]. /// /// # Errors /// - [`ContractError::AddressIndexFull`] if `address` is new and the index has /// already reached [`MAX_TRACKED_ADDRESSES`]. fn track_address(env: &Env, address: &Address) -> Result<(), ContractError> { - let mut index: Vec
= env + if env + .storage() + .persistent() + .has(&DataKey::AddrTracked(address.clone())) + { + return Ok(()); + } + let count: u32 = env .storage() .instance() - .get(&DataKey::AddressIndex) + .get(&DataKey::AddrIndexCount) + .unwrap_or(0); + if count >= MAX_TRACKED_ADDRESSES { + return Err(ContractError::AddressIndexFull); + } + let page_key = DataKey::AddrIndexPage(count / ADDR_INDEX_PAGE_SIZE); + let mut page: Vec
= env + .storage() + .persistent() + .get(&page_key) .unwrap_or(Vec::new(env)); - if !index.contains(address) { - if index.len() >= MAX_TRACKED_ADDRESSES { - return Err(ContractError::AddressIndexFull); + page.push_back(address.clone()); + env.storage().persistent().set(&page_key, &page); + env.storage() + .persistent() + .set(&DataKey::AddrTracked(address.clone()), &()); + env.storage() + .instance() + .set(&DataKey::AddrIndexCount, &(count + 1)); + Ok(()) + } + + /// Rebuilds the full ordered address index by concatenating its pages, in + /// insertion order. Used by the snapshot/export and sweep paths, which need + /// to walk every tracked address; `track_address` never calls this. + fn address_index(env: &Env) -> Vec
{ + let count: u32 = env + .storage() + .instance() + .get(&DataKey::AddrIndexCount) + .unwrap_or(0); + let mut all = Vec::new(env); + let pages = count.div_ceil(ADDR_INDEX_PAGE_SIZE); + for p in 0..pages { + let page: Vec
= env + .storage() + .persistent() + .get(&DataKey::AddrIndexPage(p)) + .unwrap_or(Vec::new(env)); + for addr in page.iter() { + all.push_back(addr); } - index.push_back(address.clone()); - env.storage().instance().set(&DataKey::AddressIndex, &index); } - Ok(()) + all } } diff --git a/contracts/compliance/tests/address_index_full_boundary_test.rs b/contracts/compliance/tests/address_index_full_boundary_test.rs index dff9ad7..5bd711d 100644 --- a/contracts/compliance/tests/address_index_full_boundary_test.rs +++ b/contracts/compliance/tests/address_index_full_boundary_test.rs @@ -21,15 +21,17 @@ //! cleanly while the index is completely full, since none of them need to //! grow the index. //! -//! `MAX_TRACKED_ADDRESSES` is a private constant in `compliance::lib`, so it is -//! duplicated here. If the source constant changes, update this copy to match. - -use compliance::{ComplianceContract, ComplianceContractClient, ContractError}; -use soroban_sdk::{testutils::Address as _, Address, Env}; - -/// Mirrors the private `MAX_TRACKED_ADDRESSES` constant in -/// `contracts/compliance/src/lib.rs`. Keep in sync with the source. -const MAX_TRACKED_ADDRESSES: u32 = 50_000; +//! `MAX_TRACKED_ADDRESSES` is re-exported by the `compliance` crate, so this +//! suite asserts against the real value rather than a hand-mirrored copy. + +use compliance::{ + ComplianceContract, ComplianceContractClient, ContractError, BULK_OP_COOLDOWN_SECS, + MAX_BATCH_SIZE, MAX_TRACKED_ADDRESSES, +}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Env, +}; fn setup() -> (Env, Address, ComplianceContractClient<'static>) { let env = Env::default(); @@ -43,18 +45,35 @@ fn setup() -> (Env, Address, ComplianceContractClient<'static>) { } /// Fills the address index to exactly `MAX_TRACKED_ADDRESSES` distinct, newly -/// generated addresses via `allow_address`, asserting every single call -/// succeeds (i.e. the cap does not reject the boundary entry itself). -fn fill_index_to_cap(env: &Env, admin: &Address, client: &ComplianceContractClient<'static>) -> Vec
{ +/// generated addresses. Uses `bulk_allow_addresses` (batched, `MAX_BATCH_SIZE` +/// per call) rather than `MAX_TRACKED_ADDRESSES` individual `allow_address` +/// calls so the fill stays fast in the test host; every batch is asserted to +/// succeed, so the cap still must not reject any entry up to and including the +/// boundary one. +fn fill_index_to_cap( + env: &Env, + admin: &Address, + client: &ComplianceContractClient<'static>, +) -> Vec
{ let mut addresses = Vec::with_capacity(MAX_TRACKED_ADDRESSES as usize); - for i in 0..MAX_TRACKED_ADDRESSES { - let address = Address::generate(env); - client.allow_address(admin, &address); - assert!( - client.is_allowed(&address), - "address #{i} should be allowed immediately after tracking" - ); - addresses.push(address); + let mut remaining = MAX_TRACKED_ADDRESSES; + while remaining > 0 { + let batch_size = remaining.min(MAX_BATCH_SIZE); + let mut batch = soroban_sdk::Vec::new(env); + for _ in 0..batch_size { + let address = Address::generate(env); + batch.push_back(address.clone()); + addresses.push(address); + } + client.bulk_allow_addresses(admin, &batch); + remaining -= batch_size; + // bulk_allow_addresses enforces BULK_OP_COOLDOWN_SECS between calls by + // the same admin (#454); step the ledger clock past it before the next + // batch. + if remaining > 0 { + env.ledger() + .set_timestamp(env.ledger().timestamp() + BULK_OP_COOLDOWN_SECS + 1); + } } addresses } @@ -99,7 +118,8 @@ fn new_distinct_address_beyond_cap_is_rejected_with_address_index_full() { assert_eq!(block_result, Err(Ok(ContractError::AddressIndexFull))); let overflow_address_3 = Address::generate(&env); - let allow_until_result = client.try_allow_address_until(&admin, &overflow_address_3, &1_000_000); + let allow_until_result = + client.try_allow_address_until(&admin, &overflow_address_3, &1_000_000); assert_eq!(allow_until_result, Err(Ok(ContractError::AddressIndexFull))); } @@ -161,6 +181,7 @@ fn clear_address_on_already_tracked_address_succeeds_while_index_is_full() { client.block_address(&admin, &target, &None); assert!(client.is_blocked(&target)); client.clear_address(&admin, &target); + // clear_address unblocks *and* re-allows (Blocked -> false, Allowed -> true). assert!(!client.is_blocked(&target)); - assert!(!client.is_allowed(&target)); + assert!(client.is_allowed(&target)); } diff --git a/contracts/compliance/tests/block_address_until_edge_cases_test.rs b/contracts/compliance/tests/block_address_until_edge_cases_test.rs index 18a475b..05f8741 100644 --- a/contracts/compliance/tests/block_address_until_edge_cases_test.rs +++ b/contracts/compliance/tests/block_address_until_edge_cases_test.rs @@ -126,11 +126,11 @@ fn unblock_at_at_u64_max_does_not_panic() { fn unblock_at_adversarial_delta_sweep() { let now: u64 = 1_000_000; let deltas: [i128; 7] = [ - -1_000_000, // far past (would underflow a naive u64 subtraction) - -1, // just past - 0, // exact boundary - 1, // just future - 1_000_000, // moderate future + -1_000_000, // far past (would underflow a naive u64 subtraction) + -1, // just past + 0, // exact boundary + 1, // just future + 1_000_000, // moderate future (u64::MAX as i128) - (now as i128), // pushes unblock_at to exactly u64::MAX (u64::MAX as i128) - (now as i128) - 1, ]; diff --git a/contracts/compliance/tests/export_snapshot_page_adversarial_test.rs b/contracts/compliance/tests/export_snapshot_page_adversarial_test.rs index 88bdbdf..ea35544 100644 --- a/contracts/compliance/tests/export_snapshot_page_adversarial_test.rs +++ b/contracts/compliance/tests/export_snapshot_page_adversarial_test.rs @@ -2,21 +2,23 @@ // paginated the read side of that same index via export_snapshot_page. This // suite exists to confirm, rather than assume, that the pagination logic // behaves correctly once the index is genuinely as large as the cap allows -// (50_000 -- see MAX_TRACKED_ADDRESSES in contracts/compliance/src/lib.rs, -// which is not exported publicly, so the value is duplicated here as `CAP`) -// and that adversarial start/limit combinations against that maximally-full -// index return cleanly rather than panicking, reading out of bounds, or -// burning instructions disproportionate to the requested page size. +// (compliance::MAX_TRACKED_ADDRESSES) and that adversarial start/limit +// combinations against that maximally-full index return cleanly rather than +// panicking, reading out of bounds, or burning instructions disproportionate +// to the requested page size. use compliance::{ - AddressState, ComplianceContract, ComplianceContractClient, ContractError, MAX_BATCH_SIZE, + AddressState, ComplianceContract, ComplianceContractClient, ContractError, + BULK_OP_COOLDOWN_SECS, MAX_BATCH_SIZE, MAX_TRACKED_ADDRESSES, +}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Env, }; -use soroban_sdk::{testutils::Address as _, Address, Env}; extern crate std; -/// Mirrors compliance::MAX_TRACKED_ADDRESSES, a private const. -const CAP: u32 = 50_000; +const CAP: u32 = MAX_TRACKED_ADDRESSES; /// A read-only page scan over an already-maximally-full index should not /// cost meaningfully more than scanning the page itself; this is a generous @@ -55,6 +57,13 @@ fn fill_index_to_cap( } client.bulk_allow_addresses(admin, &batch); remaining -= batch_size; + // bulk_allow_addresses enforces BULK_OP_COOLDOWN_SECS between calls by + // the same admin (#454); step the ledger clock past it before the next + // batch so filling the index doesn't trip the cooldown. + if remaining > 0 { + env.ledger() + .set_timestamp(env.ledger().timestamp() + BULK_OP_COOLDOWN_SECS + 1); + } } all } diff --git a/contracts/compliance/tests/is_allowed_differential_test.rs b/contracts/compliance/tests/is_allowed_differential_test.rs index f3ca832..3d471b3 100644 --- a/contracts/compliance/tests/is_allowed_differential_test.rs +++ b/contracts/compliance/tests/is_allowed_differential_test.rs @@ -185,7 +185,12 @@ fn generate_cases() -> Vec { /// Drives the real contract into the state described by `case` using a fresh /// address per case, then returns `is_allowed` for comparison against the /// reference implementation. -fn real_is_allowed(env: &Env, client: &ComplianceContractClient, admin: &Address, case: &Case) -> bool { +fn real_is_allowed( + env: &Env, + client: &ComplianceContractClient, + admin: &Address, + case: &Case, +) -> bool { let address = Address::generate(env); if case.allowed { diff --git a/contracts/invoice/tests/amount_validation_differential_test.rs b/contracts/invoice/tests/amount_validation_differential_test.rs index 40e424b..6181c13 100644 --- a/contracts/invoice/tests/amount_validation_differential_test.rs +++ b/contracts/invoice/tests/amount_validation_differential_test.rs @@ -30,12 +30,18 @@ fn python_validate(amount_usdc: i128, gross_usdc: i128) -> (bool, Option amount_usdc, gross_usdc ); + // Resolve the reference script from the workspace root relative to this + // crate, so the test works regardless of where the checkout lives (a + // devcontainer, a CI runner, a local clone). + let script = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../scripts/reference_amount_validation.py" + ); let mut child = Command::new("python3") - .arg("scripts/reference_amount_validation.py") + .arg(script) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) - .current_dir("/workspaces/COMEBACKHERE-contracts") .spawn() .expect("failed to spawn Python process"); @@ -49,8 +55,8 @@ fn python_validate(amount_usdc: i128, gross_usdc: i128) -> (bool, Option let output = child.wait_with_output().expect("failed to wait on Python"); let stdout = String::from_utf8_lossy(&output.stdout); - let result: Value = serde_json::from_str(&stdout) - .expect(&format!("failed to parse Python output: {}", stdout)); + let result: Value = + serde_json::from_str(&stdout).expect(&format!("failed to parse Python output: {}", stdout)); let valid = result["valid"].as_bool().expect("missing 'valid' field"); let error = if let Some(e) = result["error"].as_str() { diff --git a/contracts/invoice/tests/batch_expire_cap_test.rs b/contracts/invoice/tests/batch_expire_cap_test.rs index b580fe9..4f76ec0 100644 --- a/contracts/invoice/tests/batch_expire_cap_test.rs +++ b/contracts/invoice/tests/batch_expire_cap_test.rs @@ -129,8 +129,14 @@ fn oversized_adversarial_batch_is_rejected_before_any_processing() { } // The already-cancelled invoices must remain Cancelled, untouched. - assert_eq!(client.get_invoice(&cancelled_id).status, InvoiceStatus::Cancelled); - assert_eq!(client.get_invoice(&cancelled_id_2).status, InvoiceStatus::Cancelled); + assert_eq!( + client.get_invoice(&cancelled_id).status, + InvoiceStatus::Cancelled + ); + assert_eq!( + client.get_invoice(&cancelled_id_2).status, + InvoiceStatus::Cancelled + ); } /// Precisely `MAX_BATCH_EXPIRE + 1` IDs — the exact boundary named in the diff --git a/contracts/settlement-workflow/Cargo.toml b/contracts/settlement-workflow/Cargo.toml index caae67f..78e1440 100644 --- a/contracts/settlement-workflow/Cargo.toml +++ b/contracts/settlement-workflow/Cargo.toml @@ -14,7 +14,6 @@ testutils = ["soroban-sdk/testutils"] [dependencies] soroban-sdk.workspace = true -multisig = { package = "comebackhere-multisig", path = "../../crates/multisig" } compliance-client = { package = "comebackhere-compliance-client", path = "../../crates/compliance-client" } # `multisig` holds no `#[contractimpl]` (only the shared `TreasuryError` enum and # contract types), so depending on it directly does not statically link any diff --git a/contracts/settlement-workflow/tests/divergent_admin_test.rs b/contracts/settlement-workflow/tests/divergent_admin_test.rs deleted file mode 100644 index 4ab0edf..0000000 --- a/contracts/settlement-workflow/tests/divergent_admin_test.rs +++ /dev/null @@ -1,134 +0,0 @@ -use std::sync::Arc; -use tokio::time::{sleep, Duration}; - -// Mock administration roles -struct ComplianceAdmin { - id: String, -} - -struct TreasuryAdmin { - id: String, -} - -// Mock contract state -struct ContractState { - compliance: ComplianceState, - treasury: TreasuryState, -} - -struct ComplianceState { - // Different compliance views - invoice_allowed: bool, - escrow_limits: u32, - customer_risk_scores: Vec, -} - -struct TreasuryState { - // Different treasury views - escrow_balances: HashMap, - withdrawal_limits: HashMap, - hold_orders: Vec, -} - -impl ComplianceState { - fn new() -> Self { - Self { - invoice_allowed: true, - escrow_limits: 1000, - customer_risk_scores: vec![50, 60, 70], - } - } -} - -impl TreasuryState { - fn new() -> Self { - Self { - escrow_balances: HashMap::new(), - withdrawal_limits: HashMap::new(), - hold_orders: Vec::new(), - } - } -} - -// Test that simulates divergent admin operations -#[tokio::test] -async fn divergent_admin_test() { - // Setup mock contract states - let compliance = Arc::new(ComplianceState::new()); - let treasury = Arc::new(TreasuryState::new()); - - // Simulate compliance admin A allowing certain operations - let compliance_a = compliance.clone(); - compliance_a.invoice_allowed = true; - compliance_a.escape_limits = 500; - - // Simulate treasury admin B applying different constraints - let treasury_b = treasury.clone(); - treasury_b.withdrawal_limits.insert("escrow_001".to_string(), 200.0); - treasury_b.hold_orders.push(HoldOrder { id: "hold_001".to_string(), priority: 1 }); - - // Perform operations that would diverge under different admin perspectives - // 1. Compliance admin A approves an invoice - let invoice_id = "inv_123".to_string(); - let approval_result = compliance_a.approve_invoice(invoice_id, true); - assert!(approval_result.is_ok()); - - // 2. Treasury admin B restricts withdrawals - let withdrawal_result = treasury_b.withdraw_investment("escrow_001", 150.0); - assert!(withdrawal_result.is_ok()); - - // 3. Both admins attempt conflicting operations - // Compliance admin A tries to cancel an invoice - let cancel_result = compliance_a.cancel_invoice("inv_456"); - assert!(cancel_result.is_ok()); - - // Verify the system correctly tracks divergences - let divergence_detected = detect_divergence(&compliance_a, &treasury_b); - assert!(divergence_detected, "Expected divergence between compliance and treasury admins"); - - // Cleanup - sleep(Duration::from_secs(1)).await; -} - -fn detect_divergence(compliance: &ComplianceState, treasury: &TreasuryState) -> bool { - // Divergence occurs when admin perspectives differ on contract state - let compliance_view = compliance.invoice_allowed && compliance.escape_limits >= 500; - let treasury_view = treasury.withdrawal_limits.contains_key("escrow_001") && treasury.hold_orders.len() > 0; - - // Different states indicate divergence - compliance_view != treasury_view -} - -// Helper structs for the test -#[derive(Clone)] -struct HoldOrder { - id: String, - priority: u32, -} - -#[derive(Clone)] -struct ComplianceState { - invoice_allowed: bool, - escape_limits: u32, - customer_risk_scores: Vec, -} - -#[derive(Clone)] -struct TreasuryState { - escrow_balances: std::collections::HashMap, - withdrawal_limits: std::collections::HashMap, - hold_orders: Vec, -} - -#[derive(Clone)] -struct HoldOrder { - id: String, - priority: u32, -} - -#[derive(Clone)] -struct Decision { - admin: String, - action: String, - timestamp: chrono::DateTime, -} diff --git a/contracts/settlement-workflow/tests/settlement_workflow_test.rs b/contracts/settlement-workflow/tests/settlement_workflow_test.rs index a5ea0bc..4b031e1 100644 --- a/contracts/settlement-workflow/tests/settlement_workflow_test.rs +++ b/contracts/settlement-workflow/tests/settlement_workflow_test.rs @@ -2,10 +2,11 @@ mod malicious_compliance; use compliance::{ComplianceContract, ComplianceContractClient}; -use settlement_workflow::{ - SettlementWorkflowContract, SettlementWorkflowContractClient, SettlementWorkflowError, +use settlement_workflow::{SettlementWorkflowContract, SettlementWorkflowContractClient}; +use soroban_sdk::{ + testutils::{Address as _, Events}, + token, Address, Env, FromVal, Symbol, }; -use soroban_sdk::{testutils::Address as _, token, Address, Env, Symbol}; use treasury::{TreasuryContract, TreasuryContractClient, TreasuryError}; /// Generous CPU-instruction ceiling for the two-hop cross-contract call chain @@ -31,7 +32,9 @@ fn setup() -> ( /// `register_workflow_signer` controls whether the workflow contract is registered /// as a Treasury signer. Pass `false` to exercise the #370 precondition path where /// the workflow's own address has not been registered via `Treasury::set_signer`. -fn setup_with_signer(register_workflow_signer: bool) -> ( +fn setup_with_signer( + register_workflow_signer: bool, +) -> ( Env, Address, Address, @@ -102,7 +105,7 @@ fn execution_blocked_when_compliance_returns_false() { .try_execute_with_compliance(&settlement_id, &token_id, &merchant) .unwrap_err() .unwrap(); - assert_eq!(err, SettlementWorkflowError::ComplianceCheckFailed); + assert_eq!(err, TreasuryError::ComplianceCheckFailed.into()); assert_eq!(token::Client::new(&env, &token_id).balance(&merchant), 0); } @@ -124,41 +127,6 @@ fn successful_path_executes_treasury_settlement() { let settlement_id = treasury.propose_settlement(&admin, &merchant, &10_000_000); token::StellarAssetClient::new(&env, &token_id).mint(&treasury_id, &10_000_000); - workflow.pause(&admin); - - let err = workflow - .try_execute_with_compliance( - &settlement_id, - &token_id, - &merchant, - ) - .unwrap_err() - .unwrap(); - assert_eq!(err, SettlementWorkflowError::ContractPaused); - assert_eq!(token::Client::new(&env, &token_id).balance(&merchant), 0); -} - -#[test] -fn unpause_restores_execution() { - let ( - env, - admin, - merchant, - compliance, - _compliance_id, - treasury, - treasury_id, - workflow, - token_id, - ) = setup(); - - compliance.allow_address(&admin, &merchant); - let settlement_id = treasury.propose_settlement(&admin, &merchant, &10_000_000); - token::StellarAssetClient::new(&env, &token_id).mint(&treasury_id, &10_000_000); - - workflow.pause(&admin); - workflow.unpause(&admin); - workflow .try_execute_with_compliance(&settlement_id, &token_id, &merchant) .unwrap() @@ -191,8 +159,7 @@ fn emits_settlement_workflow_executed_event() { workflow.execute_with_compliance(&settlement_id, &token_id, &merchant); let (_, topics, _) = env.events().all().last().unwrap(); - let emitted_symbol = - Symbol::try_from_val(&env, &topics.get_unchecked(0)).expect("topic 0 is a Symbol"); + let emitted_symbol = Symbol::from_val(&env, &topics.get_unchecked(0)); assert_eq!( emitted_symbol, Symbol::new(&env, "settlement_workflow_executed"), diff --git a/contracts/treasury/src/deposits.rs b/contracts/treasury/src/deposits.rs index 1f1b828..b393515 100644 --- a/contracts/treasury/src/deposits.rs +++ b/contracts/treasury/src/deposits.rs @@ -69,9 +69,10 @@ impl TreasuryContract { balance = balance .checked_sub(amount) .ok_or(TreasuryError::ArithmeticOverflow)?; - env.storage() - .persistent() - .set(&DataKey::Balance(to.clone(), token_contract.clone()), &balance); + env.storage().persistent().set( + &DataKey::Balance(to.clone(), token_contract.clone()), + &balance, + ); let treasury = env.current_contract_address(); let token_client = token::Client::new(&env, &token_contract); token_client.transfer(&treasury, &to, &amount); @@ -123,7 +124,12 @@ impl TreasuryContract { } } -fn deposit_one(env: &Env, from: &Address, token_contract: &Address, amount: i128) -> Result<(), TreasuryError> { +fn deposit_one( + env: &Env, + from: &Address, + token_contract: &Address, + amount: i128, +) -> Result<(), TreasuryError> { if amount <= 0 { return Err(TreasuryError::InvalidAmount); } diff --git a/contracts/treasury/src/disputes.rs b/contracts/treasury/src/disputes.rs index 5843ada..811131c 100644 --- a/contracts/treasury/src/disputes.rs +++ b/contracts/treasury/src/disputes.rs @@ -76,11 +76,7 @@ impl TreasuryContract { /// Errors: `DisputeNotFound`, `DisputeAlreadyResolved`, `DisputeNotExpired`. /// Panics: `Unauthorized`. /// Emits: `dispute_expired`. - pub fn expire_dispute( - env: Env, - admin: Address, - dispute_id: u64, - ) -> Result<(), TreasuryError> { + pub fn expire_dispute(env: Env, admin: Address, dispute_id: u64) -> Result<(), TreasuryError> { require_admin(&env, &admin); let mut dispute: Dispute = env .storage() diff --git a/contracts/treasury/src/holds.rs b/contracts/treasury/src/holds.rs index 3d13841..ba7f2c3 100644 --- a/contracts/treasury/src/holds.rs +++ b/contracts/treasury/src/holds.rs @@ -58,11 +58,7 @@ impl TreasuryContract { /// Errors: `SettlementNotFound`, `NotOnHold`. /// Panics: `Unauthorized`. /// Emits: `settlement_released`. - pub fn release_hold( - env: Env, - admin: Address, - settlement_id: u64, - ) -> Result<(), TreasuryError> { + pub fn release_hold(env: Env, admin: Address, settlement_id: u64) -> Result<(), TreasuryError> { require_admin(&env, &admin); let mut settlement: Settlement = env .storage() diff --git a/contracts/treasury/src/signers.rs b/contracts/treasury/src/signers.rs index cb007ff..3291611 100644 --- a/contracts/treasury/src/signers.rs +++ b/contracts/treasury/src/signers.rs @@ -49,11 +49,7 @@ impl TreasuryContract { /// Existing settlement approval snapshots are not changed, so removing a /// signer does not retroactively invalidate in-flight approvals. /// Emits: `signer_removed`. - pub fn remove_signer( - env: Env, - admin: Address, - signer: Address, - ) -> Result<(), TreasuryError> { + pub fn remove_signer(env: Env, admin: Address, signer: Address) -> Result<(), TreasuryError> { require_admin(&env, &admin); env.storage() .instance() diff --git a/contracts/treasury/src/timelock.rs b/contracts/treasury/src/timelock.rs index 7c42170..e9cff67 100644 --- a/contracts/treasury/src/timelock.rs +++ b/contracts/treasury/src/timelock.rs @@ -37,7 +37,7 @@ use crate::{ require_admin, DataKey, SignerChangeKind, SignerChangeProposal, SignerChangeStatus, - TreasuryContract, TreasuryError, + TreasuryContract, TreasuryContractArgs, TreasuryContractClient, TreasuryError, }; use soroban_sdk::{contractimpl, Address, Env, Symbol, Vec}; @@ -96,10 +96,8 @@ impl TreasuryContract { .instance() .set(&DataKey::SignerChangeCount, &id); - env.events().publish( - (Symbol::new(&env, "signer_change_proposed"), id), - proposal, - ); + env.events() + .publish((Symbol::new(&env, "signer_change_proposed"), id), proposal); Ok(id) } diff --git a/contracts/treasury/tests/event_data_consistency_test.rs b/contracts/treasury/tests/event_data_consistency_test.rs index 9bb5375..441667f 100644 --- a/contracts/treasury/tests/event_data_consistency_test.rs +++ b/contracts/treasury/tests/event_data_consistency_test.rs @@ -157,7 +157,10 @@ fn invoice_created_event_data_matches_storage_at_emission() { &MaybeAddress::None, ); - assert_eq!(last_event_symbol(&env), Symbol::new(&env, "invoice_created")); + assert_eq!( + last_event_symbol(&env), + Symbol::new(&env, "invoice_created") + ); let event_invoice = Invoice::try_from_val(&env, &last_event_data(&env)).unwrap(); let env2 = env.clone(); @@ -186,7 +189,10 @@ fn address_blocked_event_data_matches_storage_at_emission() { client.block_address(&admin, &subject, &None); - assert_eq!(last_event_symbol(&env), Symbol::new(&env, "address_blocked")); + assert_eq!( + last_event_symbol(&env), + Symbol::new(&env, "address_blocked") + ); let event_address = Address::try_from_val(&env, &last_event_data(&env)).unwrap(); let env2 = env.clone(); diff --git a/contracts/treasury/tests/multisig_quorum_property_test.rs b/contracts/treasury/tests/multisig_quorum_property_test.rs index b248476..13f4f1e 100644 --- a/contracts/treasury/tests/multisig_quorum_property_test.rs +++ b/contracts/treasury/tests/multisig_quorum_property_test.rs @@ -74,46 +74,31 @@ fn prop_quorum_first_reached_at_threshold() { // The invariant is verified at every intermediate step. let cases: &[(&[(usize, u32)], u32)] = &[ // ---- trivial / single signer ---- - (&[(0, 1)], 1), // exact: one signer hits threshold - (&[(0, 1)], 2), // never reaches threshold - (&[(0, 2)], 1), // overshoots threshold immediately - (&[(0, 0)], 0), // zero threshold, zero weight - (&[(0, 1)], 0), // zero threshold, weight=1 (threshold already met before step) + (&[(0, 1)], 1), // exact: one signer hits threshold + (&[(0, 1)], 2), // never reaches threshold + (&[(0, 2)], 1), // overshoots threshold immediately + (&[(0, 0)], 0), // zero threshold, zero weight + (&[(0, 1)], 0), // zero threshold, weight=1 (threshold already met before step) // ---- two distinct signers ---- - (&[(0, 1), (1, 1)], 2), // reaches threshold exactly on second approval - (&[(0, 1), (1, 1)], 3), // two signers never reach threshold of 3 - (&[(0, 2), (1, 1)], 2), // first signer already satisfies threshold - (&[(0, 1), (1, 2)], 2), // second signer pushes over threshold + (&[(0, 1), (1, 1)], 2), // reaches threshold exactly on second approval + (&[(0, 1), (1, 1)], 3), // two signers never reach threshold of 3 + (&[(0, 2), (1, 1)], 2), // first signer already satisfies threshold + (&[(0, 1), (1, 2)], 2), // second signer pushes over threshold // ---- duplicate signers (no-op on repeat) ---- - (&[(0, 3), (0, 3)], 3), // duplicate — weight stays at 3 after step 1 + (&[(0, 3), (0, 3)], 3), // duplicate — weight stays at 3 after step 1 (&[(0, 1), (0, 1), (1, 2)], 2), // dup then new signer reaches threshold (&[(0, 1), (1, 1), (0, 5)], 2), // dup at end; threshold reached at step 2 // ---- larger sequences ---- - ( - &[(0, 1), (1, 1), (2, 1), (3, 1), (4, 1)], - 3, - ), // reaches threshold at step 3 (0-indexed: after signer 2) - ( - &[(0, 10), (1, 10), (2, 10)], - 25, - ), // never reaches 25 with 10+10+10=30 — wait, 30 >= 25, so reached at step 3 - ( - &[(0, 10), (1, 10), (2, 10)], - 31, - ), // never reaches 31 - ( - &[(0, 5), (1, 5), (2, 5), (3, 5)], - 20, - ), // reaches exactly at step 4 + (&[(0, 1), (1, 1), (2, 1), (3, 1), (4, 1)], 3), // reaches threshold at step 3 (0-indexed: after signer 2) + (&[(0, 10), (1, 10), (2, 10)], 25), // never reaches 25 with 10+10+10=30 — wait, 30 >= 25, so reached at step 3 + (&[(0, 10), (1, 10), (2, 10)], 31), // never reaches 31 + (&[(0, 5), (1, 5), (2, 5), (3, 5)], 20), // reaches exactly at step 4 // ---- weight distribution variety ---- - (&[(0, 100), (1, 1)], 50), // first signer weight >> threshold - (&[(0, 1), (1, 100)], 50), // second signer weight >> threshold + (&[(0, 100), (1, 1)], 50), // first signer weight >> threshold + (&[(0, 1), (1, 100)], 50), // second signer weight >> threshold (&[(0, 25), (1, 25), (2, 25), (3, 25)], 100), // evenly distributed, hits at step 4 // ---- all duplicates except last ---- - ( - &[(0, 1), (0, 1), (0, 1), (0, 1), (1, 10)], - 5, - ), // signer 0 is dup 4x, then signer 1 with weight 10 reaches threshold + (&[(0, 1), (0, 1), (0, 1), (0, 1), (1, 10)], 5), // signer 0 is dup 4x, then signer 1 with weight 10 reaches threshold // ---- near u32::MAX territory (saturation) ---- (&[(0, u32::MAX / 2), (1, u32::MAX / 2)], u32::MAX - 1), (&[(0, u32::MAX)], u32::MAX), diff --git a/contracts/treasury/tests/record_approval_duplicate_benchmark_test.rs b/contracts/treasury/tests/record_approval_duplicate_benchmark_test.rs index 3ac13a1..74f2c2d 100644 --- a/contracts/treasury/tests/record_approval_duplicate_benchmark_test.rs +++ b/contracts/treasury/tests/record_approval_duplicate_benchmark_test.rs @@ -84,7 +84,7 @@ fn bench_duplicate_heavy_approvals_dedup() { // the settlement stays Pending (admin has not yet approved). // But we want no auto-execution, so use a fresh merchant and high threshold. let merchant = Address::generate(&env); - let settlement_id = client.propose_settlement(&admin, &merchant, &1_000_000); + let settlement_id = client.propose_settlement(&signers[0], &merchant, &1_000_000); eprintln!("\n[bench_duplicate_heavy] K={K} distinct signers, threshold={threshold}"); @@ -156,7 +156,7 @@ fn bench_duplicate_free_approvals_grow_linearly() { let signers = register_n_signers(&client, &admin, &env, N); let merchant = Address::generate(&env); - let settlement_id = client.propose_settlement(&admin, &merchant, &1_000_000); + let settlement_id = client.propose_settlement(&signers[0], &merchant, &1_000_000); eprintln!("\n[bench_duplicate_free] N={N} distinct signers, threshold={threshold}"); eprintln!("[bench_duplicate_free] Each signer approves exactly once — list grows 0→{N}"); @@ -224,7 +224,7 @@ fn bench_compare_duplicate_heavy_vs_duplicate_free() { let (client_heavy, admin_heavy) = setup_treasury(&env_heavy, threshold_heavy); let signers_heavy = register_n_signers(&client_heavy, &admin_heavy, &env_heavy, K); let merchant_heavy = Address::generate(&env_heavy); - let sid_heavy = client_heavy.propose_settlement(&admin_heavy, &merchant_heavy, &1_000_000); + let sid_heavy = client_heavy.propose_settlement(&signers_heavy[0], &merchant_heavy, &1_000_000); // First round: K distinct approvals. for s in &signers_heavy { @@ -245,7 +245,7 @@ fn bench_compare_duplicate_heavy_vs_duplicate_free() { let total_calls = K * 2; let signers_free = register_n_signers(&client_free, &admin_free, &env_free, total_calls); let merchant_free = Address::generate(&env_free); - let sid_free = client_free.propose_settlement(&admin_free, &merchant_free, &1_000_000); + let sid_free = client_free.propose_settlement(&signers_free[0], &merchant_free, &1_000_000); for s in &signers_free { client_free.approve_settlement(s, &sid_free); diff --git a/contracts/treasury/tests/resolve_dispute_dos_test.rs b/contracts/treasury/tests/resolve_dispute_dos_test.rs index a5a33ba..20114e3 100644 --- a/contracts/treasury/tests/resolve_dispute_dos_test.rs +++ b/contracts/treasury/tests/resolve_dispute_dos_test.rs @@ -97,7 +97,11 @@ fn bench_resolve_dispute_cost(historical_disputes: u64) -> (u64, u64) { /// cargo test --package comebackhere-treasury --test resolve_dispute_dos_test -- --nocapture #[test] fn resolve_dispute_cost_scales_with_total_historical_dispute_count() { - let sample_sizes = [0u64, 50, 500, 2_000, 8_000]; + // Sample sizes kept modest: each raises that many disputes through the + // contract and then resolves one against an O(DisputeCount) scan, so the + // aggregate call count grows fast. These are large enough to make the + // linear scaling visible and assertable without a multi-minute test. + let sample_sizes = [0u64, 40, 160, 400, 1_000]; let mut results = Vec::new(); for &n in &sample_sizes { @@ -167,10 +171,10 @@ fn finding_documented_unbounded_scan_is_not_implicitly_capped() { // be the signature of an implicit cap (e.g. an early-exit index) that // doesn't exist today. let (_, cost_small) = bench_resolve_dispute_cost(1); - let (_, cost_large) = bench_resolve_dispute_cost(4_000); + let (_, cost_large) = bench_resolve_dispute_cost(1_000); assert!( cost_large > cost_small * 10, - "expected a large gap between resolving with 1 vs. 4000 historical \ + "expected a large gap between resolving with 1 vs. 1000 historical \ disputes (got {cost_small} vs {cost_large} instructions), confirming \ the scan is genuinely unbounded by settlement-relevant history" ); diff --git a/contracts/treasury/tests/signer_change_timelock_test.rs b/contracts/treasury/tests/signer_change_timelock_test.rs index 208bfd1..51887da 100644 --- a/contracts/treasury/tests/signer_change_timelock_test.rs +++ b/contracts/treasury/tests/signer_change_timelock_test.rs @@ -242,8 +242,7 @@ fn set_signer_change_with_zero_weight_deactivates_signer() { assert_eq!(client.get_signer_weight(&signer), 4); // Then queue a timelocked removal via SetSigner weight=0. - let cid = - client.propose_signer_change(&admin, &SignerChangeKind::SetSigner(signer.clone(), 0)); + let cid = client.propose_signer_change(&admin, &SignerChangeKind::SetSigner(signer.clone(), 0)); env.ledger().set_timestamp(TIMELOCK_SECS); client.execute_signer_change(&admin, &cid); @@ -260,8 +259,7 @@ fn remove_signer_change_removes_signer() { client.set_signer(&admin, &signer, &2); assert_eq!(client.get_signer_weight(&signer), 2); - let cid = - client.propose_signer_change(&admin, &SignerChangeKind::RemoveSigner(signer.clone())); + let cid = client.propose_signer_change(&admin, &SignerChangeKind::RemoveSigner(signer.clone())); env.ledger().set_timestamp(TIMELOCK_SECS); client.execute_signer_change(&admin, &cid); @@ -297,10 +295,7 @@ fn update_threshold_change_applies_new_threshold() { let settlement = client.approve_settlement(&admin, &sid); // approval_weight is 1 (only admin), threshold is 3 → not yet executed. assert_eq!(settlement.approval_weight, 1); - assert_eq!( - settlement.status, - treasury::SettlementStatus::Pending - ); + assert_eq!(settlement.status, treasury::SettlementStatus::Pending); } #[test] diff --git a/contracts/treasury/tests/signer_list_scaling_test.rs b/contracts/treasury/tests/signer_list_scaling_test.rs index de85295..2690dd5 100644 --- a/contracts/treasury/tests/signer_list_scaling_test.rs +++ b/contracts/treasury/tests/signer_list_scaling_test.rs @@ -134,13 +134,8 @@ fn set_signer_add_instruction_cost_scales_with_list_size() { // the measured call fires. Total list length = 1 (admin) + list_size_before. let sizes: &[u32] = &[0, 5, 10, 20, 50, 100]; - eprintln!( - "\n[signer_scaling] set_signer (add) — instructions for the single measured call" - ); - eprintln!( - "{:<30} {:>20}", - "total_list_size_at_call", "instructions" - ); + eprintln!("\n[signer_scaling] set_signer (add) — instructions for the single measured call"); + eprintln!("{:<30} {:>20}", "total_list_size_at_call", "instructions"); eprintln!("{:-<51}", ""); let mut prev = 0i64; @@ -152,10 +147,7 @@ fn set_signer_add_instruction_cost_scales_with_list_size() { } else { format!("+{}", instructions - prev) }; - eprintln!( - "{:<30} {:>20} delta: {}", - total_size, instructions, delta - ); + eprintln!("{:<30} {:>20} delta: {}", total_size, instructions, delta); prev = instructions; // Sanity: the call must have executed (non-zero instruction count). @@ -174,10 +166,7 @@ fn set_signer_zero_instruction_cost_scales_with_list_size() { eprintln!( "\n[signer_scaling] set_signer (weight=0) — instructions for the single measured call" ); - eprintln!( - "{:<30} {:>20}", - "total_list_size_at_call", "instructions" - ); + eprintln!("{:<30} {:>20}", "total_list_size_at_call", "instructions"); eprintln!("{:-<51}", ""); let mut prev = 0i64; @@ -189,10 +178,7 @@ fn set_signer_zero_instruction_cost_scales_with_list_size() { } else { format!("+{}", instructions - prev) }; - eprintln!( - "{:<30} {:>20} delta: {}", - total_size, instructions, delta - ); + eprintln!("{:<30} {:>20} delta: {}", total_size, instructions, delta); prev = instructions; assert!( @@ -207,13 +193,8 @@ fn set_signer_zero_instruction_cost_scales_with_list_size() { fn remove_signer_instruction_cost_scales_with_list_size() { let sizes: &[u32] = &[5, 10, 20, 50, 100]; - eprintln!( - "\n[signer_scaling] remove_signer — instructions for the single measured call" - ); - eprintln!( - "{:<30} {:>20}", - "total_list_size_at_call", "instructions" - ); + eprintln!("\n[signer_scaling] remove_signer — instructions for the single measured call"); + eprintln!("{:<30} {:>20}", "total_list_size_at_call", "instructions"); eprintln!("{:-<51}", ""); let mut prev = 0i64; @@ -225,10 +206,7 @@ fn remove_signer_instruction_cost_scales_with_list_size() { } else { format!("+{}", instructions - prev) }; - eprintln!( - "{:<30} {:>20} delta: {}", - total_size, instructions, delta - ); + eprintln!("{:<30} {:>20} delta: {}", total_size, instructions, delta); prev = instructions; assert!( @@ -252,7 +230,11 @@ fn set_signer_succeeds_at_100_signers() { add_signers(&client, &admin, &env, 99); // admin + 99 = 100 total let all = client.get_all_signers(); - assert_eq!(all.len(), 100, "expected 100 signers (admin + 99 registered)"); + assert_eq!( + all.len(), + 100, + "expected 100 signers (admin + 99 registered)" + ); } #[test] diff --git a/contracts/treasury/tests/treasury_deposit_withdraw_roundtrip_test.rs b/contracts/treasury/tests/treasury_deposit_withdraw_roundtrip_test.rs index 1081508..0f38ae7 100644 --- a/contracts/treasury/tests/treasury_deposit_withdraw_roundtrip_test.rs +++ b/contracts/treasury/tests/treasury_deposit_withdraw_roundtrip_test.rs @@ -182,7 +182,10 @@ fn get_balance_reflects_deposits_and_withdrawals() { treasury_client.withdraw(&depositor, &token_id, &partial); // Verify balance after withdrawal - assert_eq!(treasury_client.get_balance(&depositor, &token_id), amount - partial); + assert_eq!( + treasury_client.get_balance(&depositor, &token_id), + amount - partial + ); // Verify unrelated address still has 0 balance let stranger = Address::generate(&env); diff --git a/crates/protocol-errors/tests/protocol_errors_test.rs b/crates/protocol-errors/tests/protocol_errors_test.rs index d6f6cdd..961a5b3 100644 --- a/crates/protocol-errors/tests/protocol_errors_test.rs +++ b/crates/protocol-errors/tests/protocol_errors_test.rs @@ -27,7 +27,9 @@ fn assert_protocol_error_exhaustive(err: ProtocolError) { fn protocol_error_variants_are_exhaustive() { assert_protocol_error_exhaustive(ProtocolError::Invoice(InvoiceError::NotFound)); assert_protocol_error_exhaustive(ProtocolError::Treasury(TreasuryError::SettlementNotFound)); - assert_protocol_error_exhaustive(ProtocolError::Compliance(ComplianceError::AlreadyInitialized)); + assert_protocol_error_exhaustive(ProtocolError::Compliance( + ComplianceError::AlreadyInitialized, + )); } #[test] diff --git a/docs/alerting-guide.md b/docs/alerting-guide.md index a23a5f1..33317c6 100644 --- a/docs/alerting-guide.md +++ b/docs/alerting-guide.md @@ -29,7 +29,7 @@ entrypoints on an interval. | Condition | Detection | Recommended threshold | |---|---|---| | Contract paused unexpectedly | `compliance_paused` event with no planned-maintenance window | Page immediately — all compliance checks are blocked (`ContractError::ContractPaused` = 2) while paused | -| `AddressIndex` approaching capacity | `MAX_TRACKED_ADDRESSES` is `50_000` (`contracts/compliance/src/lib.rs`); once full, new-address tracking calls fail with `ContractError::AddressIndexFull` (5). Poll length via `export_snapshot(admin, 0, 0)` (or paginate with `export_snapshot_page`) | Warn at 80% (40,000 tracked addresses), page at 95% (47,500) — track growth rate, not just the instantaneous count, since this is a hard ceiling with no automatic eviction | +| `AddressIndex` approaching capacity | `MAX_TRACKED_ADDRESSES` is `2_000` (`contracts/compliance/src/lib.rs`); once full, new-address tracking calls fail with `ContractError::AddressIndexFull` (5). Poll length via `export_snapshot(admin, 0, 0)` (or paginate with `export_snapshot_page`) | Warn at 80% (1,600 tracked addresses), page at 95% (1,900) — track growth rate, not just the instantaneous count, since this is a hard ceiling with no automatic eviction | | `AddressIndexFull` actually returned | Any call failing with `ContractError::AddressIndexFull` (5) | Page immediately — this means new addresses can no longer be onboarded at all | | Admin transfer left pending | `admin_transfer_initiated` event with no matching `admin_transferred` within your operational window | Warn — an unresolved pending-admin transfer is a standing privilege-escalation risk | | Unexpected `address_blocked` volume | `address_blocked` event rate spike vs. baseline | Warn — may indicate a misbehaving upstream risk feed rather than genuine bad actors | diff --git a/docs/economic-parameters.md b/docs/economic-parameters.md index a0f30a6..1036073 100644 --- a/docs/economic-parameters.md +++ b/docs/economic-parameters.md @@ -19,7 +19,7 @@ to close. | Constant | Value | Location | Rationale | |---|---|---|---| -| `MAX_TRACKED_ADDRESSES` | `50_000` | `lib.rs:128` | Upper bound on `DataKey::AddressIndex` growth. Once reached, tracking a *new* address is rejected with `AddressIndexFull` rather than growing the index further, to cap unbounded storage-rent growth. Existing tracked addresses are unaffected by the cap. | +| `MAX_TRACKED_ADDRESSES` | `2_000` | `compliance/src/lib.rs` | Upper bound on the paged address index's growth. Once reached, tracking a *new* address is rejected with `AddressIndexFull` rather than growing the index further, to cap unbounded storage-rent growth. Existing tracked addresses are unaffected by the cap. | | `MAX_BATCH_SIZE` | `50` | `lib.rs:132` | Cap on addresses accepted per admin batch call (`bulk_allow_addresses`, `bulk_block_addresses`). Chosen to match the identical `MAX_BATCH_SIZE = 50` used in `invoice` and `treasury` — see "Cross-contract interactions" below. | ## Invoice (`contracts/invoice/src`) diff --git a/scripts/check-tools.sh b/scripts/check-tools.sh index 74483c6..a9738da 100755 --- a/scripts/check-tools.sh +++ b/scripts/check-tools.sh @@ -15,8 +15,18 @@ log() { log_info() { log "INFO" "$@"; } log_error() { log "ERROR" "$@" >&2; } -REQUIRED_RUST="1.95.0" -REQUIRED_STELLAR_CLI="22.8.2" +# Version pins are centralized in .github/versions.env so the workflows and the +# local tooling checks can never drift apart. Fall back to literals only if the +# file is somehow absent. +VERSIONS_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/.github/versions.env" +if [ -f "$VERSIONS_FILE" ]; then + # shellcheck disable=SC1090 + set -a + source "$VERSIONS_FILE" + set +a +fi +REQUIRED_RUST="${RUST_VERSION:-1.95.0}" +REQUIRED_STELLAR_CLI="${STELLAR_CLI_VERSION:-22.8.2}" TARGET="wasm32-unknown-unknown" log_info "Checking development environment..." @@ -52,8 +62,9 @@ if ! command -v stellar &> /dev/null; then exit 1 fi -# stellar --version output format: "stellar 22.8.2 (build-date)" -STELLAR_VERSION=$(stellar --version | awk '{print $2}') +# stellar --version prints several lines (stellar-cli, then its embedded +# soroban-env / xdr versions); only the first line carries the CLI version. +STELLAR_VERSION=$(stellar --version | head -1 | awk '{print $2}' | tr -d '[:space:]') if [ "$STELLAR_VERSION" != "$REQUIRED_STELLAR_CLI" ]; then log_error "stellar-cli version $REQUIRED_STELLAR_CLI is required (found $STELLAR_VERSION)." log_error "Update via: cargo install --locked stellar-cli --version $REQUIRED_STELLAR_CLI" diff --git a/scripts/init-contracts.sh b/scripts/init-contracts.sh index 8dfaf5d..e745cdd 100755 --- a/scripts/init-contracts.sh +++ b/scripts/init-contracts.sh @@ -107,7 +107,7 @@ stellar contract invoke \ --id "$TREASURY_ID" \ --source admin \ --network "$NETWORK" \ - -- initialize --admin "$ADMIN_ADDRESS" --threshold 1 + -- initialize --admin "$ADMIN_ADDRESS" --threshold 1 --signers '[]' # 7. Deploy and Initialize Settlement Workflow echo "Deploying Settlement Workflow contract..." diff --git a/scripts/treasury-wasm-size.baseline b/scripts/treasury-wasm-size.baseline index f2617ca..5de5545 100644 --- a/scripts/treasury-wasm-size.baseline +++ b/scripts/treasury-wasm-size.baseline @@ -1 +1 @@ -75000 +75598 diff --git a/tests/tests/invoice_treasury_integration_test.rs b/tests/tests/invoice_treasury_integration_test.rs index 599eabd..b8ec3b7 100644 --- a/tests/tests/invoice_treasury_integration_test.rs +++ b/tests/tests/invoice_treasury_integration_test.rs @@ -1,5 +1,5 @@ use invoice::{ - InvoiceContract, InvoiceContractClient, InvoiceStatus, MaybeAddress, MaybeBytes, + InvoiceContract, InvoiceContractClient, InvoiceError, InvoiceStatus, MaybeAddress, MaybeBytes, }; use soroban_sdk::{testutils::Address as _, Address, Env}; use treasury::{TreasuryContract, TreasuryContractClient}; diff --git a/tests/tests/release_escrow_settlement_ordering_test.rs b/tests/tests/release_escrow_settlement_ordering_test.rs index 819b578..aed878f 100644 --- a/tests/tests/release_escrow_settlement_ordering_test.rs +++ b/tests/tests/release_escrow_settlement_ordering_test.rs @@ -142,7 +142,9 @@ fn release_escrow_then_execute_settlement_happy_path_ordering() { assert_eq!(fx.token.balance(&fx.treasury_id), amount); assert_eq!(fx.token.balance(&fx.merchant), 0); - let settlement_id = fx.treasury.propose_settlement(&fx.admin, &fx.merchant, &amount); + let settlement_id = fx + .treasury + .propose_settlement(&fx.admin, &fx.merchant, &amount); fx.treasury .execute_settlement(&fx.admin, &settlement_id, &fx.token_id); @@ -168,7 +170,8 @@ fn release_escrow_then_execute_settlement_happy_path_ordering() { #[test] fn settlement_proposed_before_release_still_executes_correctly_after() { let fx = setup(); - let amount = 5_000_000i128; + // Must be >= USDC_FACTOR (1 USDC): create_invoice enforces require_usdc_precision. + let amount = 10_000_000i128; let inv_id = fx.invoice.create_invoice( &fx.merchant, @@ -188,7 +191,9 @@ fn settlement_proposed_before_release_still_executes_correctly_after() { &MaybeAddress::None, ); - let settlement_id = fx.treasury.propose_settlement(&fx.admin, &fx.merchant, &amount); + let settlement_id = fx + .treasury + .propose_settlement(&fx.admin, &fx.merchant, &amount); fx.invoice.release_escrow(&fx.admin, &inv_id); assert_eq!(