use super::*;
use crate::prop::TierDailyLossLimitPublished;
async fn daily_loss_limit(
fixture: &PropFixture,
tier_id: u64,
version: u32,
) -> Option<u64> {
fixture
.deployment
.pool
.methods()
.get_daily_loss_limit(tier_id, version)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
}
async fn publish_daily_loss_tier(
fixture: &PropFixture,
limit: Option<u64>,
) -> CallResponse<u32> {
fixture
.deployment
.pool
.methods()
.publish_tier_v3(
TURBO_TIER_ID,
turbo_tier_params(),
turbo_terms(DRAWDOWN_SCHEDULE),
vec![fixture.order_book.contract_id],
limit,
)
.with_contract_ids(&[
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.call()
.await
.unwrap()
}
#[tokio::test]
async fn v3_publishes_daily_policy_atomically_without_changing_the_tier_event() {
let fixture = PropFixture::new().await;
let pool = &fixture.deployment.pool;
assert_eq!(daily_loss_limit(&fixture, TIER_ID, 1).await, None);
assert_eq!(daily_loss_limit(&fixture, TURBO_TIER_ID, 1).await, None);
for (index, limit) in [Some(100), None, Some(0), Some(10_000)]
.into_iter()
.enumerate()
{
let response = publish_daily_loss_tier(&fixture, limit).await;
let version = index as u32 + 1;
assert_eq!(response.value, version);
assert_eq!(
daily_loss_limit(&fixture, TURBO_TIER_ID, version).await,
limit
);
let published = response
.decode_logs_with_type::<TierVersionPublishedV2>()
.unwrap();
assert_eq!(published.len(), 1);
assert_eq!(published[0].tier_id, TURBO_TIER_ID);
assert_eq!(published[0].version, version);
assert_eq!(published[0].params, turbo_tier_params());
assert_eq!(published[0].terms, turbo_terms(DRAWDOWN_SCHEDULE));
assert_eq!(published[0].books, vec![fixture.order_book.contract_id]);
assert_eq!(
published[0].assets,
vec![fixture.base_asset, fixture.collateral_asset]
);
assert_eq!(published[0].k, LINE);
assert_eq!(published[0].required_collateral, TURBO_DEPOSIT);
assert_eq!(published[0].term_seconds, turbo_tier_params().duration);
assert!(published[0].timestamp.unix > 0);
assert!(
response
.decode_logs_with_type::<TierVersionPublished>()
.unwrap()
.is_empty()
);
let policies = response
.decode_logs_with_type::<TierDailyLossLimitPublished>()
.unwrap();
assert_eq!(policies.len(), 1);
assert_eq!(policies[0].tier_id, TURBO_TIER_ID);
assert_eq!(policies[0].version, version);
assert_eq!(policies[0].daily_loss_limit_bps, limit);
assert_eq!(policies[0].timestamp, published[0].timestamp);
assert_eq!(
pool.methods()
.get_tier(TURBO_TIER_ID, version)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(turbo_tier_params())
);
assert_eq!(
pool.methods()
.get_turbo_terms(TURBO_TIER_ID, version)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(turbo_terms(DRAWDOWN_SCHEDULE))
);
}
let v2 = publish_turbo_tier_unarmed(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
assert_eq!(v2, 5);
assert_eq!(daily_loss_limit(&fixture, TURBO_TIER_ID, v2).await, None);
assert_eq!(
daily_loss_limit(&fixture, TURBO_TIER_ID, 1).await,
Some(100)
);
assert_eq!(daily_loss_limit(&fixture, TURBO_TIER_ID, 2).await, None);
assert_eq!(daily_loss_limit(&fixture, TURBO_TIER_ID, 3).await, Some(0));
assert_eq!(
daily_loss_limit(&fixture, TURBO_TIER_ID, 4).await,
Some(10_000)
);
assert_eq!(daily_loss_limit(&fixture, TIER_ID, 1).await, None);
}
#[tokio::test]
async fn daily_policy_updates_are_scoped_to_a_version_and_visible_to_live_sessions() {
let fixture = PropFixture::new().await;
let pool = &fixture.deployment.pool;
let version = publish_daily_loss_tier(&fixture, Some(100)).await.value;
open_turbo_session(&fixture).await;
let session = fixture.session().await;
let lines = fixture.lines().await;
let newer = publish_daily_loss_tier(&fixture, Some(200)).await.value;
pool.methods().pause().call().await.unwrap();
for limit in [Some(50), Some(0), None, None, Some(1), Some(10_000)] {
let response = pool
.methods()
.set_daily_loss_limit(TURBO_TIER_ID, version, limit)
.call()
.await
.unwrap();
let events = response
.decode_logs_with_type::<TierDailyLossLimitPublished>()
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].tier_id, TURBO_TIER_ID);
assert_eq!(events[0].version, version);
assert_eq!(events[0].daily_loss_limit_bps, limit);
let now = fixture
.deployer
.try_provider()
.unwrap()
.latest_block_time()
.await
.unwrap()
.unwrap()
.timestamp() as u64;
assert_eq!(events[0].timestamp.unix, now);
assert!(
response
.decode_logs_with_type::<TierVersionPublishedV2>()
.unwrap()
.is_empty()
);
assert_eq!(
daily_loss_limit(&fixture, session.tier_id, session.tier_version).await,
limit
);
assert_eq!(
daily_loss_limit(&fixture, TURBO_TIER_ID, newer).await,
Some(200)
);
assert_eq!(fixture.session().await, session);
}
pool.methods()
.set_daily_loss_limit(TIER_ID, 1, Some(25))
.call()
.await
.unwrap();
assert_eq!(daily_loss_limit(&fixture, TIER_ID, 1).await, Some(25));
assert_eq!(
daily_loss_limit(&fixture, TURBO_TIER_ID, version).await,
Some(10_000)
);
assert_eq!(
pool.methods()
.current_tier_version(TURBO_TIER_ID)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(newer)
);
pool.methods().unpause().call().await.unwrap();
assert_eq!(fixture.lines().await, lines);
assert_eq!(
pool.methods()
.get_turbo_terms(TURBO_TIER_ID, version)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(turbo_terms(DRAWDOWN_SCHEDULE))
);
}
#[tokio::test]
async fn daily_policy_requires_an_admin_and_an_existing_tier_version() {
let fixture = PropFixture::new().await;
let pool = &fixture.deployment.pool;
let version = publish_daily_loss_tier(&fixture, Some(100)).await.value;
let outsider = Identity::Address(fixture.outsider.address());
pool.methods()
.grant_role(ACCOUNT_CLOSER_ROLE, outsider)
.call()
.await
.unwrap();
pool.methods()
.grant_role(DISCOUNT_MANAGER_ROLE, outsider)
.call()
.await
.unwrap();
pool.methods()
.set_liquidator(outsider)
.call()
.await
.unwrap();
for caller in [&fixture.user, &fixture.outsider] {
let unauthorized = pool.clone().with_account(caller.clone());
let error = unauthorized
.methods()
.set_daily_loss_limit(TURBO_TIER_ID, version, None)
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("AccessControlNotAuthorized"),
"{error:#}"
);
let error = unauthorized
.methods()
.publish_tier_v3(
TURBO_TIER_ID,
turbo_tier_params(),
turbo_terms(DRAWDOWN_SCHEDULE),
vec![fixture.order_book.contract_id],
Some(0),
)
.with_contract_ids(&[
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("AccessControlNotAuthorized"),
"{error:#}"
);
}
for (tier_id, absent_version) in
[(TURBO_TIER_ID, 0), (TURBO_TIER_ID, version + 1), (999, 1)]
{
let error = pool
.methods()
.set_daily_loss_limit(tier_id, absent_version, Some(50))
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("InvalidTierVersion"),
"{error:#}"
);
assert_eq!(
daily_loss_limit(&fixture, tier_id, absent_version).await,
None
);
}
assert_eq!(
daily_loss_limit(&fixture, TURBO_TIER_ID, version).await,
Some(100)
);
assert_eq!(
pool.methods()
.current_tier_version(TURBO_TIER_ID)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(version)
);
pool.methods().grant_role(0, outsider).call().await.unwrap();
let delegated = pool.clone().with_account(fixture.outsider.clone());
delegated
.methods()
.set_daily_loss_limit(TURBO_TIER_ID, version, Some(80))
.call()
.await
.unwrap();
pool.methods()
.revoke_role(0, outsider)
.call()
.await
.unwrap();
let error = delegated
.methods()
.set_daily_loss_limit(TURBO_TIER_ID, version, None)
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("AccessControlNotAuthorized"),
"{error:#}"
);
assert_eq!(
daily_loss_limit(&fixture, TURBO_TIER_ID, version).await,
Some(80)
);
}
#[tokio::test]
async fn invalid_daily_basis_points_cannot_replace_policy_or_publish_a_version() {
let fixture = PropFixture::new().await;
let pool = &fixture.deployment.pool;
let version = publish_daily_loss_tier(&fixture, Some(0)).await.value;
for invalid in [10_001, u64::MAX] {
let error = pool
.methods()
.set_daily_loss_limit(TURBO_TIER_ID, version, Some(invalid))
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("InvalidDailyLossLimit"),
"{error:#}"
);
let error = pool
.methods()
.publish_tier_v3(
TURBO_TIER_ID,
turbo_tier_params(),
turbo_terms(DRAWDOWN_SCHEDULE),
vec![fixture.order_book.contract_id],
Some(invalid),
)
.with_contract_ids(&[
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("InvalidDailyLossLimit"),
"{error:#}"
);
assert_eq!(
daily_loss_limit(&fixture, TURBO_TIER_ID, version).await,
Some(0)
);
assert_eq!(
daily_loss_limit(&fixture, TURBO_TIER_ID, version + 1).await,
None
);
assert_eq!(
pool.methods()
.get_tier(TURBO_TIER_ID, version + 1)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
None
);
assert_eq!(
pool.methods()
.get_turbo_terms(TURBO_TIER_ID, version + 1)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
None
);
assert_eq!(
pool.methods()
.current_tier_version(TURBO_TIER_ID)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(version)
);
}
assert_eq!(
publish_daily_loss_tier(&fixture, None).await.value,
version + 1
);
}
#[tokio::test]
async fn refused_v3_publications_leave_no_policy_or_partial_tier() {
let fixture = PropFixture::new().await;
let pool = &fixture.deployment.pool;
let version = publish_daily_loss_tier(&fixture, Some(100)).await.value;
let mut invalid_terms = turbo_terms(DRAWDOWN_SCHEDULE);
invalid_terms.max_loss_bps = [0; 4];
let mut invalid_params = turbo_tier_params();
invalid_params.liq_price_factor = 0;
for (params, terms, books, expected) in [
(
turbo_tier_params(),
invalid_terms,
vec![fixture.order_book.contract_id],
"InvalidTurboTerms",
),
(
invalid_params,
turbo_terms(DRAWDOWN_SCHEDULE),
vec![fixture.order_book.contract_id],
"InvalidTierParams",
),
(
turbo_tier_params(),
turbo_terms(DRAWDOWN_SCHEDULE),
vec![fixture.order_book.contract_id; 2],
"InvalidBook",
),
] {
let error = pool
.methods()
.publish_tier_v3(TURBO_TIER_ID, params, terms, books, Some(1))
.with_contract_ids(&[
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.call()
.await
.unwrap_err();
assert!(error.to_string().contains(expected), "{error:#}");
assert_eq!(
pool.methods()
.current_tier_version(TURBO_TIER_ID)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(version)
);
assert_eq!(
pool.methods()
.get_tier(TURBO_TIER_ID, version + 1)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
None
);
assert_eq!(
pool.methods()
.get_turbo_terms(TURBO_TIER_ID, version + 1)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
None
);
assert_eq!(
daily_loss_limit(&fixture, TURBO_TIER_ID, version + 1).await,
None
);
assert_eq!(
daily_loss_limit(&fixture, TURBO_TIER_ID, version).await,
Some(100)
);
}
assert_eq!(
publish_daily_loss_tier(&fixture, Some(200)).await.value,
version + 1
);
}
#[tokio::test]
async fn permissioned_closure_needs_no_daily_policy_or_daily_loss_breach() {
for limit in [None, Some(0), Some(10_000)] {
let fixture = PropFixture::new().await;
let version = publish_daily_loss_tier(&fixture, limit).await.value;
let opened = open_turbo_session(&fixture).await;
fixture
.deployment
.pool
.methods()
.grant_role(
ACCOUNT_CLOSER_ROLE,
Identity::Address(fixture.outsider.address()),
)
.call()
.await
.unwrap();
assert!(!fixture.is_liquidatable().await);
let response = fixture
.force_close_session(&fixture.outsider, vec![])
.await
.unwrap();
let closed = fixture.closed_event(&response);
assert_eq!(closed.session_id, opened.session_id);
assert_eq!(closed.reason, SettlementReason::PermissionedClosure);
assert_eq!(closed.bad_debt, 0);
assert_eq!(closed.user_net, 0);
let seized = response.decode_logs_with_type::<SessionSeized>().unwrap();
assert_eq!(seized.len(), 1);
assert_eq!(seized[0].reason, SettlementReason::PermissionedClosure);
assert!(fixture.session_opt().await.is_none());
assert_eq!(
daily_loss_limit(&fixture, TURBO_TIER_ID, version).await,
limit
);
}
}
async fn buy_one_base(fixture: &mut PropFixture) {
let quantity = 1_000_000_000;
let cost = 2_000_000;
fixture
.rest_order(2_000_000, quantity, fixture.base_asset, quantity)
.await;
let draw = fixture.draw_call(cost);
let buy = fixture.book_call(2_000_000, quantity, fixture.collateral_asset, cost);
fixture
.call_account(vec![draw, buy, fixture.settle_book_call()], 2)
.await
.unwrap();
}
#[tokio::test]
async fn a_daily_loss_breach_does_not_change_health_or_grant_closure_rights() {
let mut fixture = PropFixture::new().await;
let version = publish_daily_loss_tier(&fixture, Some(10)).await.value;
open_turbo_session(&fixture).await;
buy_one_base(&mut fixture).await;
fixture.publish_base_price(1_950_000_000_000_000_000).await;
assert!(!fixture.is_liquidatable().await);
let lines = fixture.lines().await;
let pool = &fixture.deployment.pool;
let error = pool
.clone()
.with_account(fixture.outsider.clone())
.methods()
.liquidate(fixture.child_id, vec![])
.with_contracts(&[
&fixture.account,
&fixture.deployment.oracle,
&fixture.order_book.order_book,
&fixture.deployment.price_feed,
])
.call()
.await
.unwrap_err();
assert!(error.to_string().contains("NotLiquidatable"), "{error:#}");
let error = fixture
.force_close_session(&fixture.outsider, vec![])
.await
.unwrap_err();
assert!(
error.to_string().contains("AccessControlNotAuthorized"),
"{error:#}"
);
for limit in [None, Some(0), Some(10_000)] {
pool.methods()
.set_daily_loss_limit(TURBO_TIER_ID, version, limit)
.call()
.await
.unwrap();
assert_eq!(fixture.lines().await, lines);
}
pool.methods()
.grant_role(
ACCOUNT_CLOSER_ROLE,
Identity::Address(fixture.outsider.address()),
)
.call()
.await
.unwrap();
let response = fixture
.force_close_session(&fixture.outsider, vec![])
.await
.unwrap();
let closed = fixture.closed_event(&response);
assert_eq!(closed.reason, SettlementReason::PermissionedClosure);
assert!(closed.profit_is_negative);
assert_eq!(closed.profit_abs, 69_500);
assert_eq!(closed.bad_debt, 0);
assert!(fixture.session_opt().await.is_none());
}
#[tokio::test]
async fn daily_policy_leaves_voluntary_closure_and_onchain_liquidation_available() {
for liquidate in [false, true] {
let mut fixture = PropFixture::new().await;
let limit = if liquidate { 10_000 } else { 1 };
publish_daily_loss_tier(&fixture, Some(limit)).await;
open_turbo_session(&fixture).await;
if liquidate {
buy_one_base(&mut fixture).await;
fixture.publish_base_price(1_800_000_000_000_000_000).await;
}
assert_eq!(fixture.is_liquidatable().await, liquidate);
let response = if liquidate {
fixture.liquidate().await
} else {
fixture.close_session().await
};
assert_eq!(
fixture.closed_event(&response).reason,
if liquidate {
SettlementReason::Liquidation
} else {
SettlementReason::UserClose
}
);
assert!(fixture.session_opt().await.is_none());
}
}