diff --git a/CONTRACTS.md b/CONTRACTS.md index c65e20e5..048ff750 100644 --- a/CONTRACTS.md +++ b/CONTRACTS.md @@ -48,6 +48,22 @@ The invariant is checked at **every state-mutating operation**: | `settle_net` | Verified for each entry in the batch | | `set_haircut` | Verified with new haircut ratio | +### Registry Status Gate & Exit Paths (Issues #788, #835, #865) + +When an `AgentRegistry` contract is wired into `agent_vault` via `set_registry(Some(registry))`, float expansion and peer-to-peer routing require `AgentStatus::Active`, while liability-reducing unwind paths remain open so non-Active agents and the operator can settle outstanding obligations and recover collateral once `float == 0` (matching `agent_registry::withdraw_bond`, which requires the agent not to be `Active`): + +| Operation | `Active` | `Pending` | `Suspended` | `Expired` | Unregistered | Rationale | +| ------------------------------------- | -------- | --------- | ----------- | --------- | ------------ | -------------------------------------------------------------------------------------------------------------- | +| `mint_float` | Allowed | Rejected | Rejected | Rejected | Rejected | Only live, KYC-verified, bonded agents with an unexpired license may be issued new float. | +| `transfer_float` / `transfer_to_agent` (as `to`) | Allowed | Rejected | Rejected | Rejected | Rejected | Prevents crediting float to non-Active or unregistered vaults via peer-to-peer transfers. | +| `transfer_float` / `transfer_to_agent` (as `from`) | Allowed | Rejected | Rejected | Rejected | Rejected | Peer-to-peer float routing is restricted to `Active` agents; non-Active agents must unwind via burn or settle. | +| `settle_net` (`delta > 0`) | Allowed | Rejected | Rejected | Rejected | Rejected | Prevents net settlement from expanding float held by non-Active or unregistered agents. | +| `settle_net` (`delta <= 0`) | Allowed | Allowed | Allowed | Allowed | Allowed | Lets the operator net down end-of-day positions for agents that became `Suspended` or `Expired`. | +| `burn_float` | Allowed | Allowed | Allowed | Allowed | Allowed | Always reduces protocol float liability; required so non-Active agents can unwind `float` to `0`. | +| `withdraw_collateral` | Allowed | Allowed | Allowed | Allowed | Allowed | Enforces `min_collateral` and solvency while `float > 0`, and allows full USDC exit once `float == 0`. | + +Rejected status checks return `VaultError::AgentNotFound`. + ### View Functions for Off-Chain Verification - **`check_invariant(agent)`**: Returns `(holds, float, max_allowed)` for a diff --git a/contracts/agent_vault/src/lib.rs b/contracts/agent_vault/src/lib.rs index 5a857bb4..3650c214 100644 --- a/contracts/agent_vault/src/lib.rs +++ b/contracts/agent_vault/src/lib.rs @@ -475,8 +475,9 @@ impl AgentVault { Ok(()) } - /// Atomic float transfer between agents. Both must authorise. The - /// recipient's float must stay within their own collateral bound. + /// Atomic float transfer between agents. Both must authorise and be + /// `Active` in the configured `AgentRegistry` (#865). The recipient's + /// float must stay within their own collateral bound. pub fn transfer_float( env: Env, from: Address, @@ -496,6 +497,7 @@ impl AgentVault { from.require_auth(); to.require_auth(); Self::assert_circuit_ok(&env, Symbol::new(&env, "transfer_float"))?; + Self::assert_agent_active(&env, &from)?; Self::assert_agent_active(&env, &to)?; if amount <= 0 { return Err(VaultError::InvalidAmount); diff --git a/contracts/agent_vault/src/test.rs b/contracts/agent_vault/src/test.rs index 35b15efc..ebaa74d8 100644 --- a/contracts/agent_vault/src/test.rs +++ b/contracts/agent_vault/src/test.rs @@ -564,12 +564,22 @@ impl MockRegistry { .set(&Symbol::new(&env, "status"), &status); } - pub fn get_agent(env: Env, _agent: Address) -> Result { - let status: Option = env - .storage() + pub fn set_agent_status(env: Env, agent: Address, status: Option) { + env.storage() .instance() - .get(&Symbol::new(&env, "status")) - .unwrap(); + .set(&(Symbol::new(&env, "agent"), agent), &status); + } + + pub fn get_agent(env: Env, agent: Address) -> Result { + let override_key = (Symbol::new(&env, "agent"), agent); + let status: Option = if env.storage().instance().has(&override_key) { + env.storage().instance().get(&override_key).unwrap() + } else { + env.storage() + .instance() + .get(&Symbol::new(&env, "status")) + .unwrap() + }; status .map(|status| crate::AgentInfo { status, @@ -587,6 +597,17 @@ fn mock_registry(env: &Env, status: Option) -> Address { env.register(MockRegistry, (status,)) } +fn mock_registry_with_agent( + env: &Env, + default_status: Option, + target: &Address, + target_status: Option, +) -> Address { + let reg = env.register(MockRegistry, (default_status,)); + MockRegistryClient::new(env, ®).set_agent_status(target, &target_status); + reg +} + #[test] fn test_mint_float_rejects_non_active_agent() { for status in [ @@ -631,11 +652,28 @@ fn setup_with_float() -> (Setup, Address, Address) { fn test_transfer_float_rejects_non_active_recipient() { for status in NON_ACTIVE_STATUSES { let (s, a, b) = setup_with_float(); - s.client.set_registry(&Some(mock_registry(&s.env, status))); + let reg = mock_registry_with_agent(&s.env, Some(crate::AgentStatus::Active), &b, status); + s.client.set_registry(&Some(reg)); + assert_eq!( + s.client.try_transfer_float(&a, &b, &100), + Err(Ok(VaultError::AgentNotFound)) + ); + assert_eq!(s.client.get_vault(&a).float, 300); + assert_eq!(s.client.get_vault(&b).float, 300); + } +} + +#[test] +fn test_transfer_float_rejects_non_active_sender() { + for status in NON_ACTIVE_STATUSES { + let (s, a, b) = setup_with_float(); + let reg = mock_registry_with_agent(&s.env, Some(crate::AgentStatus::Active), &a, status); + s.client.set_registry(&Some(reg)); assert_eq!( s.client.try_transfer_float(&a, &b, &100), Err(Ok(VaultError::AgentNotFound)) ); + assert_eq!(s.client.get_vault(&a).float, 300); assert_eq!(s.client.get_vault(&b).float, 300); } } @@ -655,6 +693,45 @@ fn test_settle_net_rejects_non_active_positive_delta() { } } +#[test] +fn test_settle_net_allows_non_active_negative_delta() { + for status in NON_ACTIVE_STATUSES { + let (s, a, b) = setup_with_float(); + let reg = mock_registry_with_agent(&s.env, Some(crate::AgentStatus::Active), &a, status); + s.client.set_registry(&Some(reg)); + let entries: Vec<(Address, i128)> = vec![&s.env, (a.clone(), -100), (b.clone(), 100)]; + s.client.settle_net(&entries); + assert_eq!(s.client.get_vault(&a).float, 200); + assert_eq!(s.client.get_vault(&b).float, 400); + } +} + +#[test] +fn test_burn_float_allows_non_active_agents() { + for status in NON_ACTIVE_STATUSES { + let (s, a, _b) = setup_with_float(); + s.client.set_registry(&Some(mock_registry(&s.env, status))); + s.client.burn_float(&a, &120); + assert_eq!(s.client.get_vault(&a).float, 180); + } +} + +#[test] +fn test_withdraw_collateral_allows_non_active_agents() { + for status in NON_ACTIVE_STATUSES { + let (s, a, _b) = setup_with_float(); + s.client.set_registry(&Some(mock_registry(&s.env, status))); + // Partial withdraw while float (300) is outstanding stays within the 80% solvency bound. + s.client.withdraw_collateral(&a, &500); + assert_eq!(s.client.get_vault(&a).collateral, 500); + // Full exit after burning remaining float to zero. + s.client.burn_float(&a, &300); + s.client.withdraw_collateral(&a, &500); + assert_eq!(s.client.get_vault(&a).collateral, 0); + assert_eq!(s.client.get_vault(&a).float, 0); + } +} + #[test] fn test_transfer_and_settle_allow_active_agents() { let (s, a, b) = setup_with_float();