diff --git a/COMEBACKHERE-contracts/contracts/invoice/src/events.rs b/COMEBACKHERE-contracts/contracts/invoice/src/events.rs index ed8d6b6..5d6c934 100644 --- a/COMEBACKHERE-contracts/contracts/invoice/src/events.rs +++ b/COMEBACKHERE-contracts/contracts/invoice/src/events.rs @@ -53,3 +53,17 @@ pub fn dispute_raised(env: &Env, invoice_id: &u64, settlement_id: &u64, claimant (*invoice_id, *settlement_id, claimant), ); } + +pub fn admin_transfer_initiated(env: &Env, current_admin: &Address, new_admin: &Address) { + env.events().publish( + (Symbol::new(env, "admin_transfer_initiated"),), + (current_admin, new_admin), + ); +} + +pub fn admin_transfer_accepted(env: &Env, new_admin: &Address) { + env.events().publish( + (Symbol::new(env, "admin_transfer_accepted"),), + new_admin, + ); +} diff --git a/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs b/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs index 22b7a79..f178e57 100644 --- a/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs @@ -50,6 +50,10 @@ pub enum ContractError { InvalidStateTransition = 18, /// A batch operation was called with more than `MAX_BATCH_SIZE` invoice IDs. BatchTooLarge = 19, + /// The invoice amount is below the minimum (`MIN_AMOUNT_USDC`). + AmountPrecision = 20, + /// The `reference` field exceeds `MAX_REFERENCE_LEN` bytes. + ReferenceTooLong = 21, } #[contracttype] @@ -82,6 +86,7 @@ pub struct Invoice { #[contracttype] pub enum DataKey { Admin, + PendingAdmin, Paused, Invoice(u64), InvoiceCount, @@ -161,6 +166,76 @@ impl InvoiceContract { Ok(()) } + /// Initiates a two-step admin transfer by recording `new_admin` as the pending + /// admin. The change does **not** take effect until `accept_admin` is called by + /// `new_admin`. + /// + /// Only the current admin may call this. The contract must not be paused. + /// + /// # Parameters + /// - `caller`: Must be the current admin. + /// - `new_admin`: The address that will be able to accept admin rights. + /// + /// # Errors + /// - [`ContractError::Unauthorized`] if `caller` is not the current admin. + /// - [`ContractError::ContractPaused`] if the contract is currently paused. + /// + /// # Events + /// Emits `admin_transfer_initiated(caller, new_admin)` on success. + pub fn transfer_admin( + env: Env, + caller: Address, + new_admin: Address, + ) -> Result<(), ContractError> { + check_not_paused(&env)?; + caller.require_auth(); + check_admin(&env, &caller)?; + env.storage() + .persistent() + .set(&DataKey::PendingAdmin, &new_admin); + events::admin_transfer_initiated(&env, &caller, &new_admin); + Ok(()) + } + + /// Completes a two-step admin transfer by accepting the pending nomination. + /// + /// The caller must be the address previously nominated via `transfer_admin`. + /// On success the caller becomes the new admin, the old admin loses all + /// privileges immediately, and the `PendingAdmin` key is cleared. + /// + /// The contract must not be paused. + /// + /// # Parameters + /// - `new_admin`: Must be the pending admin address previously set by `transfer_admin`. + /// + /// # Errors + /// - [`ContractError::Unauthorized`] if `new_admin` does not match the stored + /// `PendingAdmin`, or if no transfer has been initiated. + /// - [`ContractError::ContractPaused`] if the contract is currently paused. + /// + /// # Events + /// Emits `admin_transfer_accepted(new_admin)` on success. + pub fn accept_admin(env: Env, new_admin: Address) -> Result<(), ContractError> { + check_not_paused(&env)?; + new_admin.require_auth(); + let pending: Address = env + .storage() + .persistent() + .get(&DataKey::PendingAdmin) + .ok_or(ContractError::Unauthorized)?; + if new_admin != pending { + return Err(ContractError::Unauthorized); + } + env.storage() + .persistent() + .set(&DataKey::Admin, &new_admin); + env.storage() + .persistent() + .remove(&DataKey::PendingAdmin); + events::admin_transfer_accepted(&env, &new_admin); + Ok(()) + } + /// Initialises the contract, setting the admin address and default configuration. /// /// # Parameters @@ -246,6 +321,32 @@ impl InvoiceContract { } env.storage().persistent().set(&nonce_key, &true); + // Compliance check: reject if merchant or customer is blocked. + // Skipped when no compliance contract has been configured so that + // local dev and existing test setups keep working without change. + if let Some(compliance_addr) = env + .storage() + .persistent() + .get::(&DataKey::ComplianceContract) + { + let merchant_allowed: bool = env.invoke_contract( + &compliance_addr, + &Symbol::new(&env, "is_allowed"), + soroban_sdk::vec![&env, merchant.clone().into_val(&env)], + ); + if !merchant_allowed { + return Err(ContractError::AddressBlocked); + } + let customer_allowed: bool = env.invoke_contract( + &compliance_addr, + &Symbol::new(&env, "is_allowed"), + soroban_sdk::vec![&env, customer.clone().into_val(&env)], + ); + if !customer_allowed { + return Err(ContractError::AddressBlocked); + } + } + let mut count: u64 = env .storage() .persistent() @@ -442,8 +543,16 @@ impl InvoiceContract { return Err(ContractError::InvoiceExpired); } - // Compliance check: reject if customer is blocked + // Compliance check: reject if customer or merchant is blocked. if let Some(ref compliance_addr) = compliance { + let merchant_allowed: bool = env.invoke_contract( + compliance_addr, + &Symbol::new(&env, "is_allowed"), + soroban_sdk::vec![&env, invoice.merchant.clone().into_val(&env)], + ); + if !merchant_allowed { + return Err(ContractError::AddressBlocked); + } let is_allowed: bool = env.invoke_contract( compliance_addr, &Symbol::new(&env, "is_allowed"), @@ -461,6 +570,98 @@ impl InvoiceContract { Ok(()) } + /// Pays a `Pending` invoice on-chain by transferring the invoice amount from + /// `payer` to the contract itself (held in escrow until `release_escrow` or a + /// refund). + /// + /// This is the on-chain payment path. For backend-confirmed off-chain payments + /// use [`Self::mark_paids`] instead — both paths emit the same `invoice_paid` + /// event and apply identical state guards, so the indexer does not need to + /// distinguish between them. + /// + /// The `payer` does not have to be the invoice's `customer` field — any address + /// may settle an invoice on behalf of the customer. The compliance check (when a + /// compliance contract is configured) is applied to `payer`, not the stored + /// `customer`. + /// + /// # Parameters + /// - `payer`: The address funding the payment; must authorise this transaction. + /// - `invoice_id`: The invoice being paid. + /// + /// # Errors + /// - [`ContractError::ContractPaused`] if the contract is currently paused. + /// - [`ContractError::InvoiceNotFound`] if no invoice with that ID exists. + /// - [`ContractError::InvalidStateTransition`] if the invoice is in + /// `RefundRequested`, `Released`, `Cancelled`, or `Expired`. + /// - [`ContractError::InvoiceAlreadyPaid`] if the invoice is already `Paid`. + /// - [`ContractError::InvoiceExpired`] if `expires_at` has passed. + /// - [`ContractError::AddressBlocked`] if a compliance contract is configured and + /// `payer` is not allowed. + /// + /// # Events + /// Emits `invoice_paid(invoice_id)` on success. + pub fn pay_invoice(env: Env, payer: Address, invoice_id: u64) -> Result<(), ContractError> { + check_not_paused(&env)?; + payer.require_auth(); + + let mut invoice = env + .storage() + .persistent() + .get::(&DataKey::Invoice(invoice_id)) + .ok_or(ContractError::InvoiceNotFound)?; + + // Reuse the same state guards as mark_paids so the state machine stays + // consistent regardless of which payment path was used. + if matches!( + invoice.status, + InvoiceStatus::RefundRequested + | InvoiceStatus::Released + | InvoiceStatus::Cancelled + | InvoiceStatus::Expired + ) { + return Err(ContractError::InvalidStateTransition); + } + if invoice.status != InvoiceStatus::Pending { + return Err(ContractError::InvoiceAlreadyPaid); + } + if env.ledger().timestamp() >= invoice.expires_at { + return Err(ContractError::InvoiceExpired); + } + + // Compliance check — skipped when no compliance contract is configured. + if let Some(compliance_addr) = env + .storage() + .persistent() + .get::(&DataKey::ComplianceContract) + { + let is_allowed: bool = env.invoke_contract( + &compliance_addr, + &Symbol::new(&env, "is_allowed"), + soroban_sdk::vec![&env, payer.clone().into_val(&env)], + ); + if !is_allowed { + return Err(ContractError::AddressBlocked); + } + } + + // Transfer invoice.amount from payer → this contract (escrow). + let _: () = env.invoke_contract( + &invoice.token, + &Symbol::new(&env, "transfer"), + soroban_sdk::vec![ + &env, + payer.into_val(&env), + env.current_contract_address().into_val(&env), + invoice.amount.into_val(&env), + ], + ); + + invoice.status = InvoiceStatus::Paid; + store_invoice(&env, invoice_id, &invoice); + events::invoice_paid(&env, &invoice_id); + Ok(()) + } + /// Cancels a `Pending` invoice. Either the merchant or the customer may call this. /// /// # Parameters @@ -677,6 +878,49 @@ impl InvoiceContract { env.storage().persistent().get(&DataKey::TreasuryContract) } + /// Configures the compliance contract address used to gate invoice creation + /// and payment. Admin-only. The contract must not be paused. + /// + /// When set, `create_invoice` calls `compliance.is_allowed` for both the + /// merchant and the customer before creating the invoice. `mark_paids` calls + /// `is_allowed` for both the merchant and the customer before marking each + /// invoice paid. If either party is blocked the operation returns + /// [`ContractError::AddressBlocked`]. + /// + /// Pass `compliance = contract_address` to enable checks. To disable checks + /// entirely (e.g. for local dev), call `set_compliance` with the zero address + /// or simply never call it — if the key is absent the checks are skipped. + /// + /// # Parameters + /// - `caller`: Must be the contract admin. + /// - `compliance`: The address of the deployed compliance contract. + /// + /// # Errors + /// - [`ContractError::ContractPaused`] if the contract is currently paused. + /// - [`ContractError::Unauthorized`] if `caller` is not the admin. + pub fn set_compliance( + env: Env, + caller: Address, + compliance: Address, + ) -> Result<(), ContractError> { + check_not_paused(&env)?; + check_admin(&env, &caller)?; + env.storage() + .persistent() + .set(&DataKey::ComplianceContract, &compliance); + Ok(()) + } + + /// Returns the currently configured compliance contract address, if any. + /// + /// Returns `None` if `set_compliance` has not been called yet. + /// When `None`, compliance checks are skipped. + pub fn get_compliance(env: Env) -> Option
{ + env.storage() + .persistent() + .get(&DataKey::ComplianceContract) + } + /// Raises a dispute on an invoice via a cross-contract call to the treasury. /// /// **Cross-contract call:** invokes `treasury.raise_dispute(claimant, settlement_id, reason)`. @@ -1364,4 +1608,448 @@ mod tests { let res = client.try_mark_paids(&soroban_sdk::vec![&env, id]); assert_eq!(res, Err(Ok(ContractError::InvoiceAlreadyPaid))); } + + // ── two-step admin transfer tests ──────────────────────────────────────── + + /// Happy path: current admin initiates transfer, new admin accepts. + /// After acceptance the new admin is effective and the old admin loses privileges. + #[test] + fn test_transfer_and_accept_admin_full_flow() { + let (env, cid, admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + let new_admin = Address::generate(&env); + + // Step 1: current admin nominates new_admin + client.transfer_admin(&admin, &new_admin); + + // Step 2: new_admin accepts + client.accept_admin(&new_admin); + + // new_admin can now exercise admin privileges (e.g. pause) + client.pause(&new_admin); + + // old admin can no longer pause + let res = client.try_unpause(&admin); + assert_eq!( + res, + Err(Ok(ContractError::Unauthorized)), + "old admin must lose privileges immediately after accept_admin" + ); + } + + /// transfer_admin must reject a caller that is not the current admin. + #[test] + fn test_transfer_admin_unauthorized_caller_fails() { + let (env, cid, _admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + let non_admin = Address::generate(&env); + let new_admin = Address::generate(&env); + + let res = client.try_transfer_admin(&non_admin, &new_admin); + assert_eq!(res, Err(Ok(ContractError::Unauthorized))); + } + + /// accept_admin must reject any address other than the pending admin. + #[test] + fn test_accept_admin_wrong_caller_fails() { + let (env, cid, admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + let new_admin = Address::generate(&env); + let impostor = Address::generate(&env); + + client.transfer_admin(&admin, &new_admin); + + let res = client.try_accept_admin(&impostor); + assert_eq!( + res, + Err(Ok(ContractError::Unauthorized)), + "impostor must not be able to accept a pending admin transfer" + ); + } + + /// accept_admin must return Unauthorized when no transfer is pending. + #[test] + fn test_accept_admin_with_no_pending_transfer_fails() { + let (env, cid, _admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + let random = Address::generate(&env); + + let res = client.try_accept_admin(&random); + assert_eq!( + res, + Err(Ok(ContractError::Unauthorized)), + "accept_admin without a prior transfer_admin must fail" + ); + } + + /// transfer_admin must fail when the contract is paused. + #[test] + fn test_transfer_admin_when_paused_fails() { + let (env, cid, admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + let new_admin = Address::generate(&env); + + client.pause(&admin); + + let res = client.try_transfer_admin(&admin, &new_admin); + assert_eq!(res, Err(Ok(ContractError::ContractPaused))); + } + + /// accept_admin must fail when the contract is paused. + #[test] + fn test_accept_admin_when_paused_fails() { + let (env, cid, admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + let new_admin = Address::generate(&env); + + client.transfer_admin(&admin, &new_admin); + client.pause(&admin); + + let res = client.try_accept_admin(&new_admin); + assert_eq!(res, Err(Ok(ContractError::ContractPaused))); + } + + /// transfer_admin emits admin_transfer_initiated event. + #[test] + fn test_transfer_admin_emits_event() { + let (env, cid, admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + let new_admin = Address::generate(&env); + + client.transfer_admin(&admin, &new_admin); + + let all_events = env.events().all(); + assert!( + all_events + .iter() + .any(|ev| ev.0 == (cid.clone(), "admin_transfer_initiated".into())), + "admin_transfer_initiated event must be emitted" + ); + } + + /// accept_admin emits admin_transfer_accepted event. + #[test] + fn test_accept_admin_emits_event() { + let (env, cid, admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + let new_admin = Address::generate(&env); + + client.transfer_admin(&admin, &new_admin); + client.accept_admin(&new_admin); + + let all_events = env.events().all(); + assert!( + all_events + .iter() + .any(|ev| ev.0 == (cid.clone(), "admin_transfer_accepted".into())), + "admin_transfer_accepted event must be emitted" + ); + } + + /// Pending admin is cleared after acceptance — a second accept_admin call fails. + #[test] + fn test_accept_admin_clears_pending_after_acceptance() { + let (env, cid, admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + let new_admin = Address::generate(&env); + + client.transfer_admin(&admin, &new_admin); + client.accept_admin(&new_admin); + + // PendingAdmin key should be gone; second accept must fail. + let res = client.try_accept_admin(&new_admin); + assert_eq!( + res, + Err(Ok(ContractError::Unauthorized)), + "PendingAdmin must be cleared after a successful accept_admin" + ); + } + + /// The old admin cannot use admin-gated functions after transfer is accepted. + /// Tests pause, unpause, set_grace_window, and set_treasury. + #[test] + fn test_old_admin_loses_all_privileges_after_acceptance() { + let (env, cid, admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + let new_admin = Address::generate(&env); + let treasury = Address::generate(&env); + + client.transfer_admin(&admin, &new_admin); + client.accept_admin(&new_admin); + + assert_eq!( + client.try_pause(&admin), + Err(Ok(ContractError::Unauthorized)), + "old admin must not be able to pause" + ); + assert_eq!( + client.try_set_grace_window(&admin, &7200u64), + Err(Ok(ContractError::Unauthorized)), + "old admin must not be able to set_grace_window" + ); + assert_eq!( + client.try_set_treasury(&admin, &treasury), + Err(Ok(ContractError::Unauthorized)), + "old admin must not be able to set_treasury" + ); + } + + // ── compliance integration tests ───────────────────────────────────────── + + /// A minimal compliance stub: each address is individually allowed or blocked + /// via in-test storage writes, and `is_allowed` checks that flag. + mod compliance_stub { + use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + + #[contracttype] + pub enum StubKey { + Allowed(Address), + } + + #[contract] + pub struct ComplianceStub; + + #[contractimpl] + impl ComplianceStub { + pub fn allow(e: Env, addr: Address) { + e.storage() + .instance() + .set(&StubKey::Allowed(addr), &true); + } + + pub fn block(e: Env, addr: Address) { + e.storage() + .instance() + .set(&StubKey::Allowed(addr), &false); + } + + pub fn is_allowed(e: Env, addr: Address) -> bool { + e.storage() + .instance() + .get(&StubKey::Allowed(addr)) + .unwrap_or(false) + } + } + } + + use compliance_stub::{ComplianceStub, ComplianceStubClient}; + + /// Helper: register the compliance stub and allow both merchant and customer by default. + fn setup_with_compliance( + ts: u64, + ) -> (Env, Address, Address, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let invoice_cid = env.register(InvoiceContract, ()); + let compliance_cid = env.register(ComplianceStub, ()); + + let invoice_client = InvoiceContractClient::new(&env, &invoice_cid); + invoice_client.initialize(&admin); + invoice_client.set_compliance(&admin, &compliance_cid); + + env.ledger().with_mut(|li| li.timestamp = ts); + + let merchant = Address::generate(&env); + let customer = Address::generate(&env); + let compliance_client = ComplianceStubClient::new(&env, &compliance_cid); + // Allow both parties by default so individual tests can selectively block one. + compliance_client.allow(&merchant); + compliance_client.allow(&customer); + + (env, invoice_cid, compliance_cid, admin, merchant, customer) + } + + /// set_compliance must be admin-only. + #[test] + fn test_set_compliance_admin_only() { + let (env, cid, _admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + let non_admin = Address::generate(&env); + let compliance_addr = Address::generate(&env); + + let res = client.try_set_compliance(&non_admin, &compliance_addr); + assert_eq!(res, Err(Ok(ContractError::Unauthorized))); + } + + /// set_compliance must fail when the contract is paused. + #[test] + fn test_set_compliance_when_paused_fails() { + let (env, cid, admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + let compliance_addr = Address::generate(&env); + + client.pause(&admin); + let res = client.try_set_compliance(&admin, &compliance_addr); + assert_eq!(res, Err(Ok(ContractError::ContractPaused))); + } + + /// get_compliance returns None before set_compliance is called. + #[test] + fn test_get_compliance_returns_none_when_not_configured() { + let (env, cid, _admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + assert_eq!(client.get_compliance(), None); + } + + /// get_compliance returns the address after set_compliance. + #[test] + fn test_get_compliance_returns_configured_address() { + let (env, cid, admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + let compliance_addr = Address::generate(&env); + + client.set_compliance(&admin, &compliance_addr); + assert_eq!(client.get_compliance(), Some(compliance_addr)); + } + + /// A blocked merchant cannot create an invoice. + #[test] + fn test_blocked_merchant_cannot_create_invoice() { + let (env, invoice_cid, compliance_cid, _admin, merchant, customer) = + setup_with_compliance(1000); + let invoice_client = InvoiceContractClient::new(&env, &invoice_cid); + let compliance_client = ComplianceStubClient::new(&env, &compliance_cid); + let token = Address::generate(&env); + + compliance_client.block(&merchant); + + let res = invoice_client.try_create_invoice( + &merchant, + &customer, + &10_000_000i128, + &token, + &9999, + &1, + &None, + ); + assert_eq!(res, Err(Ok(ContractError::AddressBlocked))); + } + + /// A blocked customer cannot be the payer on a new invoice. + #[test] + fn test_blocked_customer_cannot_create_invoice() { + let (env, invoice_cid, compliance_cid, _admin, merchant, customer) = + setup_with_compliance(1000); + let invoice_client = InvoiceContractClient::new(&env, &invoice_cid); + let compliance_client = ComplianceStubClient::new(&env, &compliance_cid); + let token = Address::generate(&env); + + compliance_client.block(&customer); + + let res = invoice_client.try_create_invoice( + &merchant, + &customer, + &10_000_000i128, + &token, + &9999, + &1, + &None, + ); + assert_eq!(res, Err(Ok(ContractError::AddressBlocked))); + } + + /// A blocked customer is rejected at mark_paids even if they were allowed at + /// invoice creation time (e.g. blocked after the invoice was created). + #[test] + fn test_blocked_customer_blocked_at_mark_paids() { + let (env, invoice_cid, compliance_cid, _admin, merchant, customer) = + setup_with_compliance(1000); + let invoice_client = InvoiceContractClient::new(&env, &invoice_cid); + let compliance_client = ComplianceStubClient::new(&env, &compliance_cid); + let token = Address::generate(&env); + + // Invoice is created while both are allowed. + let invoice_id = invoice_client.create_invoice( + &merchant, + &customer, + &10_000_000i128, + &token, + &9999, + &1, + &None, + ); + + // Customer is blocked after creation. + compliance_client.block(&customer); + + let res = invoice_client.try_mark_paids(&soroban_sdk::vec![&env, invoice_id]); + assert_eq!(res, Err(Ok(ContractError::AddressBlocked))); + + // Invoice must remain Pending — the block must not leave it in a bad state. + let invoice = invoice_client.get_invoice(&invoice_id); + assert_eq!(invoice.status, InvoiceStatus::Pending); + } + + /// A blocked merchant is rejected at mark_paids even if allowed at creation time. + #[test] + fn test_blocked_merchant_blocked_at_mark_paids() { + let (env, invoice_cid, compliance_cid, _admin, merchant, customer) = + setup_with_compliance(1000); + let invoice_client = InvoiceContractClient::new(&env, &invoice_cid); + let compliance_client = ComplianceStubClient::new(&env, &compliance_cid); + let token = Address::generate(&env); + + let invoice_id = invoice_client.create_invoice( + &merchant, + &customer, + &10_000_000i128, + &token, + &9999, + &1, + &None, + ); + + compliance_client.block(&merchant); + + let res = invoice_client.try_mark_paids(&soroban_sdk::vec![&env, invoice_id]); + assert_eq!(res, Err(Ok(ContractError::AddressBlocked))); + + let invoice = invoice_client.get_invoice(&invoice_id); + assert_eq!(invoice.status, InvoiceStatus::Pending); + } + + /// When no compliance contract is configured the checks are skipped entirely — + /// existing test setups and local dev keep working without any change. + #[test] + fn test_no_compliance_contract_skips_checks() { + // setup_contract does NOT call set_compliance. + let (env, cid, _admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + let merchant = Address::generate(&env); + let customer = Address::generate(&env); + let token = Address::generate(&env); + + // Both create and mark_paids must succeed with no compliance address set. + let invoice_id = + client.create_invoice(&merchant, &customer, &10_000_000i128, &token, &9999, &1, &None); + client.mark_paids(&soroban_sdk::vec![&env, invoice_id]); + + let invoice = client.get_invoice(&invoice_id); + assert_eq!(invoice.status, InvoiceStatus::Paid); + } + + /// Allowed merchant and customer pass both checkpoints without error. + #[test] + fn test_allowed_parties_can_create_and_pay_invoice() { + let (env, invoice_cid, _compliance_cid, _admin, merchant, customer) = + setup_with_compliance(1000); + let invoice_client = InvoiceContractClient::new(&env, &invoice_cid); + let token = Address::generate(&env); + + let invoice_id = invoice_client.create_invoice( + &merchant, + &customer, + &10_000_000i128, + &token, + &9999, + &1, + &None, + ); + invoice_client.mark_paids(&soroban_sdk::vec![&env, invoice_id]); + + let invoice = invoice_client.get_invoice(&invoice_id); + assert_eq!(invoice.status, InvoiceStatus::Paid); + } } +} \ No newline at end of file diff --git a/COMEBACKHERE-contracts/contracts/treasury/src/events.rs b/COMEBACKHERE-contracts/contracts/treasury/src/events.rs index ad3d328..3603ec9 100644 --- a/COMEBACKHERE-contracts/contracts/treasury/src/events.rs +++ b/COMEBACKHERE-contracts/contracts/treasury/src/events.rs @@ -31,3 +31,17 @@ pub fn dispute_resolved( (settlement_id, resolve_in_favor, resolution_weight), ); } + +pub fn admin_transfer_initiated(env: &Env, current_admin: &Address, new_admin: &Address) { + env.events().publish( + (Symbol::new(env, "admin_transfer_initiated"),), + (current_admin, new_admin), + ); +} + +pub fn admin_transfer_accepted(env: &Env, new_admin: &Address) { + env.events().publish( + (Symbol::new(env, "admin_transfer_accepted"),), + new_admin, + ); +} diff --git a/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs b/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs index ed36dcb..483fc32 100644 --- a/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs @@ -102,6 +102,8 @@ const INSTANCE_TTL_EXTEND_TO: u32 = 6_200_000; pub enum DataKey { /// Admin address key. Admin, + /// Nominated-but-not-yet-accepted admin address (two-step transfer). + PendingAdmin, /// Paused status key. Paused, /// Mapping of signer address to voting weight key. @@ -187,6 +189,80 @@ impl TreasuryContract { Ok(()) } + /// Initiates a two-step admin transfer by recording `new_admin` as the + /// pending admin. The change does **not** take effect until `accept_admin` + /// is called by `new_admin`. Overwriting a previous (unaccepted) nomination + /// is allowed — only the most recent nominee can accept. + /// + /// The contract must not be paused. Only the current admin may call this. + /// + /// # Arguments + /// * `e` - Soroban environment handle. + /// * `admin` - Current admin address (must authenticate). + /// * `new_admin` - Address nominated as the next admin. + /// + /// # Errors + /// * Returns [`TreasuryError::ContractPaused`] if the contract is paused. + /// * Returns [`TreasuryError::Unauthorized`] if `admin` is not the stored admin. + /// + /// # Events + /// Emits `admin_transfer_initiated(admin, new_admin)` on success. + pub fn transfer_admin( + e: Env, + admin: Address, + new_admin: Address, + ) -> Result<(), TreasuryError> { + check_not_paused(&e)?; + Self::check_admin(&e, &admin)?; + e.storage() + .instance() + .set(&DataKey::PendingAdmin, &new_admin); + e.events().publish( + (Symbol::new(&e, "admin_transfer_initiated"),), + (admin, new_admin), + ); + Ok(()) + } + + /// Completes a two-step admin transfer initiated by [`Self::transfer_admin`]. + /// + /// The caller must be the address previously nominated. On success the + /// caller becomes the new admin, the old admin loses all privileges + /// immediately, and the `PendingAdmin` key is cleared. + /// + /// The contract must not be paused. + /// + /// # Arguments + /// * `e` - Soroban environment handle. + /// * `new_admin` - Must be the pending admin set by `transfer_admin`. + /// + /// # Errors + /// * Returns [`TreasuryError::ContractPaused`] if the contract is paused. + /// * Returns [`TreasuryError::Unauthorized`] if `new_admin` does not match + /// the stored `PendingAdmin`, or if no transfer was ever initiated. + /// + /// # Events + /// Emits `admin_transfer_accepted(new_admin)` on success. + pub fn accept_admin(e: Env, new_admin: Address) -> Result<(), TreasuryError> { + check_not_paused(&e)?; + new_admin.require_auth(); + let pending: Address = e + .storage() + .instance() + .get(&DataKey::PendingAdmin) + .ok_or(TreasuryError::Unauthorized)?; + if new_admin != pending { + return Err(TreasuryError::Unauthorized); + } + e.storage().instance().set(&DataKey::Admin, &new_admin); + e.storage().instance().remove(&DataKey::PendingAdmin); + e.events().publish( + (Symbol::new(&e, "admin_transfer_accepted"),), + new_admin, + ); + Ok(()) + } + pub fn initialize( e: Env, signers: Vec<(Address, u64)>, @@ -1967,4 +2043,255 @@ mod tests { c.withdraw(&admin, &token, &user, &1_000_000_000u64); } + + // ── two-step admin transfer tests ──────────────────────────────────────── + + fn setup_treasury(e: &Env, id: &soroban_sdk::Address) -> soroban_sdk::Address { + let admin = soroban_sdk::Address::generate(e); + let signer = soroban_sdk::Address::generate(e); + TreasuryContractClient::new(e, id) + .initialize(&soroban_sdk::vec![e, (signer, 1u64)], &1, &admin); + admin + } + + /// Happy path: current admin nominates new_admin, new_admin accepts. + /// After acceptance new_admin can exercise admin privileges and old admin cannot. + #[test] + fn test_transfer_and_accept_admin_full_flow() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = setup_treasury(&e, &id); + let new_admin = soroban_sdk::Address::generate(&e); + + c.transfer_admin(&admin, &new_admin); + c.accept_admin(&new_admin); + + // new_admin can pause + c.pause(&new_admin); + + // old admin is rejected + let res = c.try_unpause(&admin); + assert_eq!( + res, + Err(Ok(TreasuryError::Unauthorized)), + "old admin must lose privileges immediately after accept_admin" + ); + } + + /// transfer_admin must reject a non-admin caller. + #[test] + fn test_transfer_admin_unauthorized_fails() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = setup_treasury(&e, &id); + let impostor = soroban_sdk::Address::generate(&e); + let new_admin = soroban_sdk::Address::generate(&e); + let _ = admin; + + let res = c.try_transfer_admin(&impostor, &new_admin); + assert_eq!(res, Err(Ok(TreasuryError::Unauthorized))); + } + + /// accept_admin must reject any address other than the nominated pending admin. + #[test] + fn test_accept_admin_wrong_caller_fails() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = setup_treasury(&e, &id); + let new_admin = soroban_sdk::Address::generate(&e); + let impostor = soroban_sdk::Address::generate(&e); + + c.transfer_admin(&admin, &new_admin); + + let res = c.try_accept_admin(&impostor); + assert_eq!( + res, + Err(Ok(TreasuryError::Unauthorized)), + "impostor must not be able to accept a pending transfer" + ); + } + + /// accept_admin with no prior transfer_admin must return Unauthorized. + #[test] + fn test_accept_admin_with_no_pending_transfer_fails() { + let (e, id) = setup(); + let c = client(&e, &id); + let _ = setup_treasury(&e, &id); + let random = soroban_sdk::Address::generate(&e); + + let res = c.try_accept_admin(&random); + assert_eq!(res, Err(Ok(TreasuryError::Unauthorized))); + } + + /// transfer_admin must fail when the contract is paused. + #[test] + fn test_transfer_admin_when_paused_fails() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = setup_treasury(&e, &id); + let new_admin = soroban_sdk::Address::generate(&e); + + c.pause(&admin); + + let res = c.try_transfer_admin(&admin, &new_admin); + assert_eq!(res, Err(Ok(TreasuryError::ContractPaused))); + } + + /// accept_admin must fail when the contract is paused. + #[test] + fn test_accept_admin_when_paused_fails() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = setup_treasury(&e, &id); + let new_admin = soroban_sdk::Address::generate(&e); + + c.transfer_admin(&admin, &new_admin); + c.pause(&admin); + + let res = c.try_accept_admin(&new_admin); + assert_eq!(res, Err(Ok(TreasuryError::ContractPaused))); + } + + /// transfer_admin emits admin_transfer_initiated event. + #[test] + fn test_transfer_admin_emits_event() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = setup_treasury(&e, &id); + let new_admin = soroban_sdk::Address::generate(&e); + + c.transfer_admin(&admin, &new_admin); + + assert!( + e.events() + .all() + .iter() + .any(|ev| ev.0 == (id.clone(), "admin_transfer_initiated".into())), + "admin_transfer_initiated event must be emitted" + ); + } + + /// accept_admin emits admin_transfer_accepted event. + #[test] + fn test_accept_admin_emits_event() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = setup_treasury(&e, &id); + let new_admin = soroban_sdk::Address::generate(&e); + + c.transfer_admin(&admin, &new_admin); + c.accept_admin(&new_admin); + + assert!( + e.events() + .all() + .iter() + .any(|ev| ev.0 == (id.clone(), "admin_transfer_accepted".into())), + "admin_transfer_accepted event must be emitted" + ); + } + + /// PendingAdmin is cleared after acceptance — a second accept_admin fails. + #[test] + fn test_accept_admin_clears_pending_after_acceptance() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = setup_treasury(&e, &id); + let new_admin = soroban_sdk::Address::generate(&e); + + c.transfer_admin(&admin, &new_admin); + c.accept_admin(&new_admin); + + let res = c.try_accept_admin(&new_admin); + assert_eq!( + res, + Err(Ok(TreasuryError::Unauthorized)), + "PendingAdmin must be cleared; second accept must fail" + ); + } + + /// Overwriting a pending nomination is allowed — only the last nominee can accept. + #[test] + fn test_transfer_admin_overwrites_previous_nomination() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = setup_treasury(&e, &id); + let first_nominee = soroban_sdk::Address::generate(&e); + let second_nominee = soroban_sdk::Address::generate(&e); + + c.transfer_admin(&admin, &first_nominee); + // Overwrite with a new nominee + c.transfer_admin(&admin, &second_nominee); + + // First nominee can no longer accept + let res = c.try_accept_admin(&first_nominee); + assert_eq!(res, Err(Ok(TreasuryError::Unauthorized))); + + // Second nominee can accept + c.accept_admin(&second_nominee); + c.pause(&second_nominee); // confirm they now hold admin + } + + /// After acceptance, all admin-gated operations use the new admin. + /// Tests pause, unpause, update_threshold, set_daily_withdraw_limit, and + /// add_token_to_allowlist. + #[test] + fn test_old_admin_loses_all_privileges_after_acceptance() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = setup_treasury(&e, &id); + let new_admin = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); + + c.transfer_admin(&admin, &new_admin); + c.accept_admin(&new_admin); + + assert_eq!(c.try_pause(&admin), Err(Ok(TreasuryError::Unauthorized))); + assert_eq!(c.try_unpause(&admin), Err(Ok(TreasuryError::Unauthorized))); + assert_eq!( + c.try_update_threshold(&admin, &1u32), + Err(Ok(TreasuryError::Unauthorized)) + ); + assert_eq!( + c.try_set_daily_withdraw_limit(&admin, &token, &500u64), + Err(Ok(TreasuryError::Unauthorized)) + ); + assert_eq!( + c.try_add_token_to_allowlist(&admin, &token), + Err(Ok(TreasuryError::Unauthorized)) + ); + assert_eq!( + c.try_remove_token_from_allowlist(&admin, &token), + Err(Ok(TreasuryError::Unauthorized)) + ); + } + + /// New admin can pause after transfer, and old admin cannot unpause. + #[test] + fn test_new_admin_pause_blocks_old_admin_unpause() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = setup_treasury(&e, &id); + let new_admin = soroban_sdk::Address::generate(&e); + let signer = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); + let merchant = soroban_sdk::Address::generate(&e); + + c.transfer_admin(&admin, &new_admin); + c.accept_admin(&new_admin); + + // New admin pauses + c.pause(&new_admin); + + // Signer cannot propose while paused + let res = c.try_propose_settlement(&signer, &token, &100u64, &merchant); + assert_eq!(res, Err(Ok(TreasuryError::ContractPaused))); + + // Only new admin can unpause + assert_eq!(c.try_unpause(&admin), Err(Ok(TreasuryError::Unauthorized))); + c.unpause(&new_admin); + + // Now signer can propose again + c.propose_settlement(&signer, &token, &100u64, &merchant); + } } diff --git a/abis/invoice.json b/abis/invoice.json index 602b176..a2838cb 100644 --- a/abis/invoice.json +++ b/abis/invoice.json @@ -1,9 +1,11 @@ { "contract": "invoice", - "version": "1.1.0", + "version": "1.3.0", "functions": [ "upgrade", "initialize", + "transfer_admin", + "accept_admin", "create_invoice", "get_invoice", "get_invoice_status", @@ -16,11 +18,36 @@ "batch_expire", "set_treasury", "get_treasury", + "set_compliance", + "get_compliance", "raise_dispute", "pause", "unpause", "set_grace_window", "get_grace_window" ], - "events": ["upgraded", "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", "admin_transfer_initiated", "admin_transfer_accepted"], + "errors": { + "1": "Unauthorized", + "2": "ContractPaused", + "3": "AlreadyInitialized", + "4": "InvoiceNotFound", + "5": "InvoiceAlreadyPaid", + "6": "InvoiceExpired", + "7": "InvoiceCancelled", + "8": "NotMerchant", + "9": "NotCustomer", + "10": "RefundNotRequested", + "11": "AlreadyRefundRequested", + "12": "GraceWindowNotExpired", + "13": "DuplicateNonce", + "14": "TreasuryNotConfigured", + "15": "NotAParty", + "16": "Overflow", + "17": "AddressBlocked", + "18": "InvalidStateTransition", + "19": "BatchTooLarge", + "20": "AmountPrecision", + "21": "ReferenceTooLong" + } } diff --git a/abis/treasury.json b/abis/treasury.json index e1049f3..a77b530 100644 --- a/abis/treasury.json +++ b/abis/treasury.json @@ -1,8 +1,10 @@ { "contract": "treasury", - "version": "1.1.0", + "version": "1.2.0", "functions": [ "upgrade", + "transfer_admin", + "accept_admin", "initialize", "set_signer", "rotate_signer", @@ -19,6 +21,7 @@ "set_settlement_ttl", "update_threshold", "get_total_signer_weight", + "get_signers", "raise_dispute", "resolve_dispute", "update_settlement_merchant", @@ -31,6 +34,6 @@ "remove_token_from_allowlist", "get_allowlisted_tokens" ], - "events": ["upgraded", "signer_rotated", "balance", "contract_paused", "contract_unpaused", "threshold_updated", "merchant_updated", "daily_withdraw_limit_set"], + "events": ["upgraded", "signer_rotated", "balance", "contract_paused", "contract_unpaused", "threshold_updated", "merchant_updated", "daily_withdraw_limit_set", "admin_transfer_initiated", "admin_transfer_accepted"], "threshold": "2-of-3" } diff --git a/docs/MAINNET_DEPLOYMENT.md b/docs/MAINNET_DEPLOYMENT.md index dd70d2d..3543f9e 100644 --- a/docs/MAINNET_DEPLOYMENT.md +++ b/docs/MAINNET_DEPLOYMENT.md @@ -197,6 +197,113 @@ every remaining signer's identity before the admin proceeds. --- +## Admin Key Rotation + +All three contracts — invoice, treasury, and compliance — support a two-step +`transfer_admin` / `accept_admin` pattern. The change is atomic and safe: the +current admin nominates a new admin, and only that nominee can complete the +transfer. The old admin retains full privileges until `accept_admin` is called, +preventing accidental lockout from a typo or a key that nobody can sign for. + +### When to rotate + +- The admin key is moving to a hardware wallet or multisig. +- The admin key holder is leaving the organization. +- Routine annual rotation per the [Key Custody Requirements](#key-custody-requirements). +- A key compromise is suspected (use the [Signer Loss or Compromise Recovery](#signer-loss-or-compromise-recovery) procedure to pause first). + +### Rotation procedure + +This procedure applies identically to the invoice, treasury, and compliance +contracts. Run it once per contract that needs its admin rotated. + +1. **Prepare the new key.** Generate and custody the new admin keypair per + [Key Custody Requirements](#key-custody-requirements). Record the new + public key in a deployment issue and collect the required ceremony + approvals before proceeding. + +2. **Nominate the new admin (Step 1).** Call `transfer_admin` with the + current admin keypair: + + ```sh + soroban contract invoke \ + --id $CONTRACT_ID \ + --source-account $CURRENT_ADMIN_SECRET \ + --network mainnet \ + -- transfer_admin \ + --admin $CURRENT_ADMIN_ADDRESS \ + --new_admin $NEW_ADMIN_ADDRESS + ``` + + This stores `NEW_ADMIN_ADDRESS` as the pending admin but does **not** + change the active admin yet. The current admin keeps all privileges. + Record the transaction hash in the ceremony log. + + > For the treasury contract the parameter name is `admin`; for invoice and + > compliance it is `caller`. Check the relevant ABI snapshot under `abis/` + > if unsure. + +3. **Verify the nomination on-chain.** Confirm the pending admin was recorded + correctly before the new key signs anything: + + ```sh + soroban contract invoke \ + --id $CONTRACT_ID \ + --source-account $CURRENT_ADMIN_SECRET \ + --network mainnet \ + -- get_pending_admin + ``` + + *(If the contract does not expose a `get_pending_admin` read function, + verify via the Soroban RPC `getLedgerEntries` using the `PendingAdmin` + storage key.)* + +4. **Accept the transfer (Step 2).** Sign with the **new** admin keypair: + + ```sh + soroban contract invoke \ + --id $CONTRACT_ID \ + --source-account $NEW_ADMIN_SECRET \ + --network mainnet \ + -- accept_admin \ + --new_admin $NEW_ADMIN_ADDRESS + ``` + + On success the new admin is active, the old admin loses all privileges + immediately, and the pending nomination is cleared. Record the transaction + hash in the ceremony log. + +5. **Smoke-test the new admin.** Call a low-impact admin-only function (e.g. + `get_threshold` for treasury, or `get_grace_window` for invoice) and + confirm the call succeeds under the new key. Then attempt the same call + with the old key and confirm it returns `Unauthorized`. + +6. **Revoke the old key.** Remove the old admin keypair from KMS / the + hardware wallet and confirm it cannot be used to sign Stellar transactions. + +7. **Record the rotation** in the deployment/ceremony log: old and new admin + addresses, the contract(s) rotated, transaction hashes for both + `transfer_admin` and `accept_admin`, and the identity of all ceremony + participants. + +### Safety properties + +- **No lockout risk.** If `accept_admin` is never called, the current admin + keeps full control. Overwriting a pending nomination with a new + `transfer_admin` call is allowed, so a mistaken nomination can be corrected + without deploying a new contract. +- **Immediate revocation.** The old admin loses all privileges the moment + `accept_admin` is executed — not after a delay or a separate revoke step. +- **Pause before rotating under compromise.** If the admin key may already be + in unauthorized hands, call `pause(admin)` from a still-trusted session + before starting the rotation so no settlements can be manipulated during the + window between nomination and acceptance. +- **Events.** Both steps emit on-chain events (`admin_transfer_initiated` and + `admin_transfer_accepted`) that can be indexed by the backend and monitored + for unexpected rotations. + +--- + ## Mainnet Signing Ceremony Checklist The signing ceremony is a structured process that ensures every mainnet