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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions contracts/agent_vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down
89 changes: 83 additions & 6 deletions contracts/agent_vault/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -564,12 +564,22 @@ impl MockRegistry {
.set(&Symbol::new(&env, "status"), &status);
}

pub fn get_agent(env: Env, _agent: Address) -> Result<crate::AgentInfo, crate::RegistryError> {
let status: Option<crate::AgentStatus> = env
.storage()
pub fn set_agent_status(env: Env, agent: Address, status: Option<crate::AgentStatus>) {
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<crate::AgentInfo, crate::RegistryError> {
let override_key = (Symbol::new(&env, "agent"), agent);
let status: Option<crate::AgentStatus> = 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,
Expand All @@ -587,6 +597,17 @@ fn mock_registry(env: &Env, status: Option<crate::AgentStatus>) -> Address {
env.register(MockRegistry, (status,))
}

fn mock_registry_with_agent(
env: &Env,
default_status: Option<crate::AgentStatus>,
target: &Address,
target_status: Option<crate::AgentStatus>,
) -> Address {
let reg = env.register(MockRegistry, (default_status,));
MockRegistryClient::new(env, &reg).set_agent_status(target, &target_status);
reg
}

#[test]
fn test_mint_float_rejects_non_active_agent() {
for status in [
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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();
Expand Down