diff --git a/src/lib.rs b/src/lib.rs index 833440b..482f4f5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -223,10 +223,7 @@ impl AnchornetContract { if amount <= 0 { return Err(Error::InvalidAmount); } - Self::calculate_fee( - amount, - Self::effective_fee_bps(&env, &asset), - ) + Self::calculate_fee(amount, Self::effective_fee_bps(&env, &asset)) } /// Grants or revokes a protocol fee waiver for `anchor`. While waived, @@ -725,7 +722,10 @@ impl AnchornetContract { } let mut pool = storage::get_pool(&env, &settlement.asset); - pool.total = pool.total.checked_add(settlement.amount).ok_or(Error::Overflow)?; + pool.total = pool + .total + .checked_add(settlement.amount) + .ok_or(Error::Overflow)?; storage::set_pool(&env, &settlement.asset, &pool); settlement.status = SettlementStatus::Cancelled; @@ -776,7 +776,10 @@ impl AnchornetContract { } let mut pool = storage::get_pool(&env, &settlement.asset); - pool.total = pool.total.checked_add(settlement.amount).ok_or(Error::Overflow)?; + pool.total = pool + .total + .checked_add(settlement.amount) + .ok_or(Error::Overflow)?; storage::set_pool(&env, &settlement.asset, &pool); settlement.status = SettlementStatus::Expired; diff --git a/src/test.rs b/src/test.rs index 4495dcb..6e248b7 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1227,11 +1227,20 @@ fn test_list_settlements_by_anchor_and_asset_empty_for_unknown() { let stranger = Address::generate(&env); let other_asset = symbol_short!("EURC"); - assert_eq!(client.list_settlements_by_anchor_and_asset(&stranger, &asset, &1, &10).len(), 0); - assert_eq!(client.list_settlements_by_anchor_and_asset(&anchor, &other_asset, &1, &10).len(), 0); + assert_eq!( + client + .list_settlements_by_anchor_and_asset(&stranger, &asset, &1, &10) + .len(), + 0 + ); + assert_eq!( + client + .list_settlements_by_anchor_and_asset(&anchor, &other_asset, &1, &10) + .len(), + 0 + ); } - #[test] fn test_version() { let env = Env::default(); @@ -1502,6 +1511,87 @@ fn test_list_anchors_reflects_reregistration() { assert_eq!(anchors.get(0).unwrap(), anchor); } +/// Regression test for deregister/re-register cycle preserving balance and +/// pool.providers state (no double-count, no reset, funds remain withdrawable). +/// +/// Per `deregister_anchor` doc: "Existing pool liquidity is unaffected; the +/// anchor simply cannot open new positions". +/// +/// Covers acceptance criteria: +/// - balances / anchor_balances / pool.providers unchanged across deregister +/// - re-register does not reset or double-count +/// - immediate withdraw after re-register succeeds +/// - provide_liquidity and open_settlement blocked while deregistered +#[test] +fn test_deregister_re_register_preserves_balances_and_provider_count() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup(&env); + let anchor = Address::generate(&env); + let asset = symbol_short!("USDC"); + + client.initialize(&admin); + client.register_anchor(&anchor); + client.provide_liquidity(&anchor, &asset, &1_000); + + // Initial state + assert_eq!(client.balance(&anchor, &asset), 1_000); + let pool = client.pool(&asset); + assert_eq!(pool.total, 1_000); + assert_eq!(pool.providers, 1); + let bals = client.anchor_balances(&anchor, &0, &10); + assert_eq!(bals.len(), 1); + assert_eq!(bals.get(0).unwrap(), (asset.clone(), 1_000)); + + // Deregister — balances and provider count unaffected + client.deregister_anchor(&anchor); + assert!(!client.is_anchor(&anchor)); + + assert_eq!(client.balance(&anchor, &asset), 1_000); + let pool = client.pool(&asset); + assert_eq!(pool.total, 1_000); + assert_eq!(pool.providers, 1); + let bals = client.anchor_balances(&anchor, &0, &10); + assert_eq!(bals.len(), 1); + assert_eq!(bals.get(0).unwrap(), (asset.clone(), 1_000)); + + // Cannot provide or open settlement while deregistered (AnchorNotRegistered) + let err_provide = client + .try_provide_liquidity(&anchor, &asset, &100) + .err() + .unwrap() + .unwrap(); + assert_eq!(err_provide, Error::AnchorNotRegistered); + + let err_settle = client + .try_open_settlement(&anchor, &asset, &100) + .err() + .unwrap() + .unwrap(); + assert_eq!(err_settle, Error::AnchorNotRegistered); + + // Re-register the same anchor + client.register_anchor(&anchor); + assert!(client.is_anchor(&anchor)); + + // State must be exactly as before deregistration (no reset, no double-count) + assert_eq!(client.balance(&anchor, &asset), 1_000); + let pool = client.pool(&asset); + assert_eq!(pool.total, 1_000); + assert_eq!(pool.providers, 1); + let bals = client.anchor_balances(&anchor, &0, &10); + assert_eq!(bals.len(), 1); + assert_eq!(bals.get(0).unwrap(), (asset.clone(), 1_000)); + + // Anchor can immediately withdraw its preserved balance (no need to re-provide) + let withdrawn = client.withdraw_all_liquidity(&anchor, &asset); + assert_eq!(withdrawn, 1_000); + assert_eq!(client.balance(&anchor, &asset), 0); + let pool = client.pool(&asset); + assert_eq!(pool.total, 0); + assert_eq!(pool.providers, 0); +} + #[test] fn test_list_anchors_pagination() { let env = Env::default(); @@ -2331,8 +2421,22 @@ fn test_clear_operator() { assert_eq!(err, Error::NoOperator); assert!(!client.is_operator(&operator)); - assert_operator_rejected!(env, client, operator, "pause", (), client.try_pause(&operator)); - assert_operator_rejected!(env, client, operator, "unpause", (), client.try_unpause(&operator)); + assert_operator_rejected!( + env, + client, + operator, + "pause", + (), + client.try_pause(&operator) + ); + assert_operator_rejected!( + env, + client, + operator, + "unpause", + (), + client.try_unpause(&operator) + ); assert_operator_rejected!( env, client, @@ -2341,7 +2445,7 @@ fn test_clear_operator() { (), client.try_extend_instance_ttl(&operator) ); - + // Admin can still act client.pause(&admin); assert!(client.is_paused()); @@ -2888,11 +2992,7 @@ fn test_settlement_age_rejects_unknown_id() { let env = Env::default(); let (client, _admin, _anchor, _asset) = funded(&env, 1_000); - let err = client - .try_settlement_age(&99) - .err() - .unwrap() - .unwrap(); + let err = client.try_settlement_age(&99).err().unwrap().unwrap(); assert_eq!(err, Error::SettlementNotFound); } @@ -3760,6 +3860,127 @@ fn test_provide_liquidity_multi_blocked_while_paused() { assert_eq!(err, Error::Paused); } +// --------------------------------------------------------------------------- +// provide_liquidity_multi atomicity regression tests +// +// The existing test_provide_liquidity_multi_rejects_duplicate_asset test only +// covers the case where the duplicate is at the front of the batch (both +// entries are the same asset). These regression tests verify that an invalid +// entry appearing *later* in the requests vector — after several valid distinct +// assets — causes zero mutations across the entire batch, including the valid +// entries that appeared before the invalid one. This enforces the all-or-nothing +// atomicity guarantee that provide_liquidity_multi's doc comment promises. +// --------------------------------------------------------------------------- + +/// Regression test: an invalid entry (duplicate asset) appearing *later* in the +/// batch — after several valid distinct assets — must cause zero mutations across +/// the entire batch, including the valid entries that appeared before the invalid +/// one. Without the two-pass validate-then-apply design, the first two legs could +/// be partially applied before the duplicate is detected on the third. +#[test] +fn test_provide_liquidity_multi_zero_mutations_on_late_duplicate_failure() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup(&env); + let anchor = Address::generate(&env); + let asset1 = symbol_short!("AST1"); + let asset2 = symbol_short!("AST2"); + + client.initialize(&admin); + client.register_anchor(&anchor); + + // Snapshot balances and pool totals for every affected asset before the + // call. All are zero since no liquidity has been provided yet. + let bal1_before = client.balance(&anchor, &asset1); + let bal2_before = client.balance(&anchor, &asset2); + let total1_before = client.total_liquidity(&asset1); + let total2_before = client.total_liquidity(&asset2); + let providers1_before = client.pool(&asset1).providers; + let providers2_before = client.pool(&asset2).providers; + + // First two entries are valid distinct assets; third is a duplicate of the + // first — the invalid entry appears *after* valid ones. + let requests = vec![ + &env, + (asset1.clone(), 100), + (asset2.clone(), 200), + (asset1.clone(), 300), // duplicate of asset1 + ]; + let err = client + .try_provide_liquidity_multi(&anchor, &requests) + .err() + .unwrap() + .unwrap(); + assert_eq!(err, Error::DuplicateAssetInBatch); + + // Verify state unchanged for every asset in the batch — including the valid + // ones (asset1, asset2) that appeared before the invalid entry. + assert_eq!(client.balance(&anchor, &asset1), bal1_before); + assert_eq!(client.balance(&anchor, &asset2), bal2_before); + assert_eq!(client.total_liquidity(&asset1), total1_before); + assert_eq!(client.total_liquidity(&asset2), total2_before); + assert_eq!(client.pool(&asset1).providers, providers1_before); + assert_eq!(client.pool(&asset2).providers, providers2_before); +} + +/// Regression test: an invalid entry (non-positive amount) appearing *later* in +/// the batch — after several valid distinct assets — must cause zero mutations +/// across the entire batch, including the valid entries that appeared before the +/// invalid one. The non-positive amount is detected at a different point in the +/// validation loop than the duplicate-asset check, so it is covered separately. +#[test] +fn test_provide_liquidity_multi_zero_mutations_on_late_nonpositive_failure() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup(&env); + let anchor = Address::generate(&env); + let asset1 = symbol_short!("AST1"); + let asset2 = symbol_short!("AST2"); + let asset3 = symbol_short!("AST3"); + + client.initialize(&admin); + client.register_anchor(&anchor); + + // Snapshot balances and pool totals for every affected asset before the + // call. All are zero since no liquidity has been provided yet. + let bal1_before = client.balance(&anchor, &asset1); + let bal2_before = client.balance(&anchor, &asset2); + let bal3_before = client.balance(&anchor, &asset3); + let total1_before = client.total_liquidity(&asset1); + let total2_before = client.total_liquidity(&asset2); + let total3_before = client.total_liquidity(&asset3); + let providers1_before = client.pool(&asset1).providers; + let providers2_before = client.pool(&asset2).providers; + let providers3_before = client.pool(&asset3).providers; + + // First two entries are valid distinct assets; third has a non-positive + // amount — the invalid entry appears *after* valid ones. + let requests = vec![ + &env, + (asset1.clone(), 100), + (asset2.clone(), 200), + (asset3.clone(), 0), // non-positive amount + ]; + let err = client + .try_provide_liquidity_multi(&anchor, &requests) + .err() + .unwrap() + .unwrap(); + assert_eq!(err, Error::InvalidAmount); + + // Verify state unchanged for every asset in the batch — including the valid + // ones (asset1, asset2) that appeared before the invalid entry. + assert_eq!(client.balance(&anchor, &asset1), bal1_before); + assert_eq!(client.balance(&anchor, &asset2), bal2_before); + assert_eq!(client.balance(&anchor, &asset3), bal3_before); + assert_eq!(client.total_liquidity(&asset1), total1_before); + assert_eq!(client.total_liquidity(&asset2), total2_before); + assert_eq!(client.total_liquidity(&asset3), total3_before); + assert_eq!(client.pool(&asset1).providers, providers1_before); + assert_eq!(client.pool(&asset2).providers, providers2_before); + assert_eq!(client.pool(&asset3).providers, providers3_before); +} + #[test] fn test_total_settled_amount_sums_by_status() { let env = Env::default(); @@ -4325,7 +4546,9 @@ fn test_list_settlements_by_anchor_and_asset_start_past_end_returns_empty() { client.open_settlement(&anchor, &asset, &100); assert_eq!( - client.list_settlements_by_anchor_and_asset(&anchor, &asset, &3, &10).len(), + client + .list_settlements_by_anchor_and_asset(&anchor, &asset, &3, &10) + .len(), 0 ); assert_eq!( @@ -4343,11 +4566,15 @@ fn test_list_settlements_by_anchor_and_asset_limit_zero_returns_empty() { client.open_settlement(&anchor, &asset, &100); assert_eq!( - client.list_settlements_by_anchor_and_asset(&anchor, &asset, &1, &0).len(), + client + .list_settlements_by_anchor_and_asset(&anchor, &asset, &1, &0) + .len(), 0 ); assert_eq!( - client.list_settlements_by_anchor_and_asset(&anchor, &asset, &0, &0).len(), + client + .list_settlements_by_anchor_and_asset(&anchor, &asset, &0, &0) + .len(), 0 ); }