diff --git a/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs b/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs index d213625..290fb81 100644 --- a/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs @@ -1,6 +1,8 @@ #![no_std] -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, Symbol, Vec}; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, Symbol, Vec, +}; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] @@ -61,6 +63,20 @@ const MAX_BATCH_SIZE: u32 = 50; #[contractimpl] impl ComplianceContract { + /// Replaces this contract's Wasm while preserving its address and storage. + /// The stored admin must authorize the call. + pub fn upgrade(e: Env, new_wasm_hash: BytesN<32>) -> Result<(), ContractError> { + let admin: Address = e.storage().instance().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + e.deployer() + .update_current_contract_wasm(new_wasm_hash.clone()); + e.events().publish( + (Symbol::new(&e, "upgraded"),), + new_wasm_hash, + ); + Ok(()) + } + pub fn initialize(e: Env, admin: Address) { e.storage().instance().set(&DataKey::Admin, &admin); e.storage().instance().set(&DataKey::Paused, &false); diff --git a/COMEBACKHERE-contracts/contracts/invoice/src/events.rs b/COMEBACKHERE-contracts/contracts/invoice/src/events.rs index 5392259..ed8d6b6 100644 --- a/COMEBACKHERE-contracts/contracts/invoice/src/events.rs +++ b/COMEBACKHERE-contracts/contracts/invoice/src/events.rs @@ -1,4 +1,9 @@ -use soroban_sdk::{Address, Env, Symbol}; +use soroban_sdk::{Address, BytesN, Env, Symbol}; + +pub fn upgraded(env: &Env, new_wasm_hash: BytesN<32>) { + env.events() + .publish((Symbol::new(env, "upgraded"),), new_wasm_hash); +} pub fn invoice_created(env: &Env, merchant: &Address, invoice_id: &u64) { env.events().publish( diff --git a/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs b/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs index 614d0c3..ef76e9d 100644 --- a/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs @@ -3,13 +3,16 @@ mod events; use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, Address, Env, IntoVal, String, Symbol, - Vec, + contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, IntoVal, String, + Symbol, Vec, }; /// Maximum length, in bytes, allowed for the optional `reference` field on an invoice. const MAX_REFERENCE_LEN: u32 = 64; +/// Maximum number of invoice IDs accepted by a single batch operation. +const MAX_BATCH_SIZE: u32 = 50; + /// Minimum invoice amount, in stroops (10,000,000 stroops == 1 USDC given 7 decimals). const MIN_AMOUNT_USDC: i128 = 10_000_000; @@ -38,6 +41,8 @@ pub enum ContractError { /// (e.g. `mark_paids` called on an invoice that is `RefundRequested`, /// `Released`, `Cancelled`, or `Expired`). InvalidStateTransition = 18, + /// A batch operation was called with more than `MAX_BATCH_SIZE` invoice IDs. + BatchTooLarge = 19, } #[contracttype] @@ -116,6 +121,17 @@ pub struct InvoiceContract; #[contractimpl] impl InvoiceContract { + /// Replaces this contract's Wasm while preserving its address and storage. + /// The stored admin must authorize the call. + pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), ContractError> { + let contract_admin = admin(&env); + contract_admin.require_auth(); + env.deployer() + .update_current_contract_wasm(new_wasm_hash.clone()); + events::upgraded(&env, new_wasm_hash); + Ok(()) + } + /// Initialises the contract, setting the admin address and default configuration. /// /// # Parameters @@ -335,6 +351,7 @@ impl InvoiceContract { /// # Errors /// - [`ContractError::ContractPaused`] if the contract is currently paused. /// - [`ContractError::InvoiceNotFound`] if any ID in the batch does not exist. + /// - [`ContractError::BatchTooLarge`] if `invoice_ids` has more than `MAX_BATCH_SIZE` IDs. /// - [`ContractError::InvalidStateTransition`] if any invoice is `RefundRequested`, /// `Released`, `Cancelled`, or `Expired` — a payment confirmation must never /// silently override a refund already in progress or a closed invoice. @@ -345,6 +362,9 @@ impl InvoiceContract { /// Emits `invoice_paid(invoice_id)` for each successfully marked invoice. pub fn mark_paids(env: Env, invoice_ids: Vec) -> Result<(), ContractError> { check_not_paused(&env)?; + if invoice_ids.len() > MAX_BATCH_SIZE { + return Err(ContractError::BatchTooLarge); + } // Resolve compliance contract once; if set, every invoice // customer must be allowed. @@ -573,11 +593,15 @@ impl InvoiceContract { /// # Errors /// - [`ContractError::ContractPaused`] if the contract is currently paused. /// - [`ContractError::InvoiceNotFound`] if any ID in the batch does not exist. + /// - [`ContractError::BatchTooLarge`] if `invoice_ids` has more than `MAX_BATCH_SIZE` IDs. /// /// # Events /// Emits `invoice_expired(invoice_id)` for each invoice that transitions to `Expired`. pub fn batch_expire(env: Env, invoice_ids: Vec) -> Result<(), ContractError> { check_not_paused(&env)?; + if invoice_ids.len() > MAX_BATCH_SIZE { + return Err(ContractError::BatchTooLarge); + } let now = env.ledger().timestamp(); for id in invoice_ids.iter() { let mut invoice = env diff --git a/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs b/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs index 7d276e1..de4f92d 100644 --- a/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs @@ -4,7 +4,8 @@ extern crate std; use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, Address, Env, IntoVal, Symbol, Vec, + contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, IntoVal, Symbol, + Vec, }; /// Status of a settlement proposal within the Treasury contract. @@ -83,6 +84,8 @@ pub enum TreasuryError { /// `daily_withdraw_limit` and the withdrawal would push cumulative /// withdrawals for the current 24h window above that limit. DailyLimitExceeded = 13, + /// An upgrade was requested while a settlement was partially executed. + UpgradeInProgress = 14, } /// Storage keys for Treasury contract instance state. @@ -133,6 +136,39 @@ pub struct TreasuryContract; #[contractimpl] impl TreasuryContract { + /// Replaces this contract's Wasm while preserving its address and storage. + /// The stored admin must authorize the call. Upgrades are rejected while any + /// settlement is in the partially executed state. + pub fn upgrade(e: Env, new_wasm_hash: BytesN<32>) -> Result<(), TreasuryError> { + let admin: Address = e.storage().instance().get(&DataKey::Admin).unwrap(); + Self::check_admin(&e, &admin)?; + + let next_settlement_id: u64 = e + .storage() + .instance() + .get(&DataKey::NextSettlementId) + .unwrap_or(1u64); + for settlement_id in 1..next_settlement_id { + if let Some(settlement) = e + .storage() + .instance() + .get::(&DataKey::Settlement(settlement_id)) + { + if settlement.status == SettlementStatus::PartiallyExecuted { + return Err(TreasuryError::UpgradeInProgress); + } + } + } + + e.deployer() + .update_current_contract_wasm(new_wasm_hash.clone()); + e.events().publish( + (Symbol::new(&e, "upgraded"),), + new_wasm_hash, + ); + Ok(()) + } + pub fn initialize( e: Env, signers: Vec<(Address, u64)>, diff --git a/abis/compliance.json b/abis/compliance.json index 2cb4d50..033c75c 100644 --- a/abis/compliance.json +++ b/abis/compliance.json @@ -2,31 +2,27 @@ "contract": "compliance", "version": "1.0.0", "functions": [ + "upgrade", "initialize", "is_allowed", "get_address_status", "allow_address", "block_address", "allow_address_until", + "batch_allow_addresses", "transfer_admin", "accept_admin", "clear_address", "pause", "unpause" ], - "events": [ - "address_allowed", - "address_blocked", - "address_cleared", - "address_allowed_until", - "admin_transfer_initiated", - "admin_transferred", - "contract_paused", - "contract_unpaused" - ], + "events": ["upgraded", "address_allowed", "address_blocked", "address_allowed_until", "compliance_batch_processed", "accept_admin", "address_cleared", "contract_paused", "contract_unpaused"], "errors": { "1": "Unauthorized", "2": "ContractPaused", - "3": "AlreadyInitialized" + "3": "AlreadyInitialized", + "4": "AddressNotFound", + "5": "PastExpiry", + "6": "BatchTooLarge" } } diff --git a/abis/invoice.json b/abis/invoice.json index f289192..c1424cf 100644 --- a/abis/invoice.json +++ b/abis/invoice.json @@ -2,6 +2,7 @@ "contract": "invoice", "version": "1.1.0", "functions": [ + "upgrade", "initialize", "create_invoice", "get_invoice", @@ -20,5 +21,5 @@ "set_grace_window", "get_grace_window" ], - "events": ["invoice_created", "invoice_paid", "invoice_expired", "invoice_cancelled", "invoice_refund_req", "escrow_released", "contract_paused", "contract_unpaused", "dispute_raised"] + "events": ["upgraded", "invoice_created", "invoice_paid", "invoice_expired", "invoice_cancelled", "invoice_refund_req", "escrow_released", "contract_paused", "contract_unpaused", "dispute_raised"] } diff --git a/abis/treasury.json b/abis/treasury.json index e24e1aa..11546bf 100644 --- a/abis/treasury.json +++ b/abis/treasury.json @@ -2,8 +2,10 @@ "contract": "treasury", "version": "1.1.0", "functions": [ + "upgrade", "initialize", "set_signer", + "rotate_signer", "propose_settlement", "approve_settlement", "execute_settlement", @@ -13,24 +15,18 @@ "unpause", "get_threshold", "update_threshold", + "get_total_signer_weight", "raise_dispute", "resolve_dispute", "update_settlement_merchant", "get_settlement", "deposit", + "set_daily_withdraw_limit", + "get_daily_withdraw_limit", "withdraw", "add_token_to_allowlist", "remove_token_from_allowlist" ], - "errors": { - "1": "ContractPaused", - "2": "NotPending", - "3": "InsufficientApprovals", - "4": "DisputeNotFound", - "5": "DisputeAlreadyRaised", - "6": "DisputeNotRaised", - "7": "AlreadyVoted", - "8": "UnauthorizedSigner", - "9": "ThresholdNotMet" - } + "events": ["upgraded", "signer_rotated", "balance", "contract_paused", "contract_unpaused", "threshold_updated", "merchant_updated", "daily_withdraw_limit_set"], + "threshold": "2-of-3" } diff --git a/docs/MAINNET_DEPLOYMENT.md b/docs/MAINNET_DEPLOYMENT.md index d6a454e..dd70d2d 100644 --- a/docs/MAINNET_DEPLOYMENT.md +++ b/docs/MAINNET_DEPLOYMENT.md @@ -413,6 +413,8 @@ A `SYSTEM` contract event is emitted automatically on upgrade with: - `data = []` Backend services that monitor contract events can use this to detect upgrades. +Each protocol contract also emits an application-level `upgraded` event whose +data is the uploaded Wasm hash. ### Upgrade Procedure @@ -442,12 +444,23 @@ Backend services that monitor contract events can use this to detect upgrades. 5. **Invoke the upgrade function** through the standard ceremony process: ```sh - stellar contract invoke \ - --id \ - --source-account \ - --network mainnet \ - -- upgrade \ - --new_wasm_hash + # Invoice + INVOICE_WASM_HASH=$(stellar contract upload --source-account "$ADMIN_KEY" \ + --wasm target/wasm32-unknown-unknown/release/comebackhere_invoice.wasm --network mainnet) + stellar contract invoke --id "$INVOICE_CONTRACT_ID" --source-account "$ADMIN_KEY" \ + --network mainnet -- upgrade --new_wasm_hash "$INVOICE_WASM_HASH" + + # Treasury + TREASURY_WASM_HASH=$(stellar contract upload --source-account "$ADMIN_KEY" \ + --wasm target/wasm32-unknown-unknown/release/comebackhere_treasury.wasm --network mainnet) + stellar contract invoke --id "$TREASURY_CONTRACT_ID" --source-account "$ADMIN_KEY" \ + --network mainnet -- upgrade --new_wasm_hash "$TREASURY_WASM_HASH" + + # Compliance + COMPLIANCE_WASM_HASH=$(stellar contract upload --source-account "$ADMIN_KEY" \ + --wasm target/wasm32-unknown-unknown/release/comebackhere_compliance.wasm --network mainnet) + stellar contract invoke --id "$COMPLIANCE_CONTRACT_ID" --source-account "$ADMIN_KEY" \ + --network mainnet -- upgrade --new_wasm_hash "$COMPLIANCE_WASM_HASH" ``` 6. **Verify** the upgrade by querying contract state and running the @@ -460,7 +473,15 @@ if a new contract was deployed rather than upgraded in-place. ### Upgrade Authorization -Each contract enforces admin authorization in its `upgrade` function: +Each contract's `upgrade(new_wasm_hash)` entrypoint loads the stored admin and +requires that address to authorize the call. This is a single-key on-chain +authorization; the treasury signer threshold does not gate contract upgrades. +The multi-sig process described above is currently an off-chain governance +control. A compromised admin key could bypass that process, so maintainers +should consider moving upgrade authorization to the treasury multisig in a +future change before relying on these entrypoints for mainnet governance. + +The on-chain authorization pattern is: ```rust pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) { diff --git a/docs/contract-interaction-guide.md b/docs/contract-interaction-guide.md index 9971a7e..9ad57b7 100644 --- a/docs/contract-interaction-guide.md +++ b/docs/contract-interaction-guide.md @@ -97,6 +97,10 @@ soroban contract invoke \ --invoice_ids '[1]' ``` +`mark_paids` and `batch_expire` accept at most 50 invoice IDs per call. Larger +jobs must be split into chunks of 50 or fewer; a larger batch fails with +`ContractError::BatchTooLarge` before any invoice is changed. + --- ### Raise a dispute diff --git a/docs/error-codes.md b/docs/error-codes.md index 0f707aa..0297734 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -53,6 +53,7 @@ Defined in `COMEBACKHERE-contracts/contracts/invoice/src/lib.rs`. Shares some va | 16 | `Overflow` | An internal counter (invoice ID, or `created_at + grace_window`) would overflow `u64`. | Practically unreachable outside of adversarial ledger state; not user-actionable. | | 17 | `AddressBlocked` | `mark_paids` was called for a customer that the configured compliance contract reports as not allowed. | Confirm the customer's compliance status with `ComplianceContract.is_allowed` before retrying. | | 18 | `InvalidStateTransition` | `mark_paids` was called on an invoice in `RefundRequested`, `Released`, `Cancelled`, or `Expired` status — see [ARCHITECTURE.md § Invoice state machine](../ARCHITECTURE.md#invoice-state-machine) for the full legal-transition diagram. | Fetch the current status with `get_invoice_status` first. A refund already in progress must not be overridden by a stale payment confirmation. | +| 19 | `BatchTooLarge` | `mark_paids` or `batch_expire` was called with more than 50 invoice IDs. | Split the input into batches of 50 or fewer and submit multiple calls. | --- @@ -121,6 +122,7 @@ Defined in `COMEBACKHERE-contracts/contracts/treasury/src/lib.rs`. | 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`. | +| 14 | `UpgradeInProgress` | `upgrade` was called while at least one settlement is in `PartiallyExecuted` status. | Allow the settlement to reach a safe terminal state before retrying the upgrade. | --- diff --git a/scripts/generate_abi_metadata.py b/scripts/generate_abi_metadata.py index 5622734..b5d8137 100644 --- a/scripts/generate_abi_metadata.py +++ b/scripts/generate_abi_metadata.py @@ -54,6 +54,13 @@ def invoice_events() -> list[str]: return re.findall(r'Symbol::new\([^,]+,\s*"([^"]+)"\)', events_rs) +def contract_events(crate_dir: str) -> list[str]: + lib = CONTRACTS_ROOT / "contracts" / crate_dir / "src" / "lib.rs" + text = lib.read_text(encoding="utf-8") + events = re.findall(r'Symbol::new\([^,]+,\s*"([^"]+)"\)', text) + return list(dict.fromkeys(events)) + + def format_invoice(payload: dict) -> str: functions = ",\n ".join(f'"{name}"' for name in payload["functions"]) events = ", ".join(f'"{name}"' for name in payload["events"]) @@ -69,11 +76,13 @@ def format_invoice(payload: dict) -> str: def format_treasury(payload: dict) -> str: functions = ",\n ".join(f'"{name}"' for name in payload["functions"]) + events = ", ".join(f'"{name}"' for name in payload["events"]) return ( "{\n" f' "contract": "{payload["contract"]}",\n' f' "version": "{payload["version"]}",\n' f' "functions": [\n {functions}\n ],\n' + f' "events": [{events}],\n' f' "threshold": "{payload["threshold"]}"\n' "}\n" ) @@ -96,6 +105,7 @@ def compliance_errors(crate_dir: str) -> dict[str, str]: def format_compliance(payload: dict) -> str: functions = ",\n ".join(f'"{name}"' for name in payload["functions"]) + events = ", ".join(f'"{name}"' for name in payload["events"]) errors_items = ",\n ".join( f'"{k}": "{v}"' for k, v in sorted(payload["errors"].items(), key=lambda x: int(x[0])) @@ -105,6 +115,7 @@ def format_compliance(payload: dict) -> str: f' "contract": "{payload["contract"]}",\n' f' "version": "{payload["version"]}",\n' f' "functions": [\n {functions}\n ],\n' + f' "events": [{events}],\n' f' "errors": {{\n {errors_items}\n }}\n' "}\n" ) @@ -126,6 +137,7 @@ def main() -> None: "contract": "treasury", "version": package_version("treasury"), "functions": contract_public_functions("treasury"), + "events": contract_events("treasury"), "threshold": "2-of-3", } (out_dir / "treasury.json").write_text(format_treasury(treasury), encoding="utf-8") @@ -134,6 +146,7 @@ def main() -> None: "contract": "compliance", "version": package_version("compliance"), "functions": contract_public_functions("compliance"), + "events": contract_events("compliance"), "errors": compliance_errors("compliance"), } (out_dir / "compliance.json").write_text(format_compliance(compliance), encoding="utf-8")