use super::*;
use crate::{
order_book_deploy::Side,
prop::{
PropOrderBookCleanup,
SessionView,
TierDailyLossLimitPublished,
TierEvalProfitTargetPublished,
},
prop_eval_close::generate_pool_eval_close_signing_payload,
};
use fuels::crypto::Message;
async fn policy(fixture: &PropFixture, version: u32) -> Option<u64> {
fixture
.deployment
.pool
.methods()
.get_eval_profit_target(TURBO_TIER_ID, version)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
}
async fn publish(fixture: &PropFixture, target: Option<u64>) -> CallResponse<u32> {
fixture
.deployment
.pool
.methods()
.publish_tier_v4(
TURBO_TIER_ID,
turbo_tier_params(),
turbo_terms(DRAWDOWN_SCHEDULE),
vec![fixture.order_book.contract_id],
Some(100),
target,
)
.with_contract_ids(&[
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.call()
.await
.unwrap()
}
async fn set_policy(fixture: &PropFixture, version: u32, target: Option<u64>) {
fixture
.deployment
.pool
.methods()
.set_eval_profit_target(TURBO_TIER_ID, version, target)
.call()
.await
.unwrap();
}
async fn armed_fixture(target: Option<u64>) -> PropFixture {
let fixture = PropFixture::new().await;
publish(&fixture, target).await;
fixture
.deployment
.pool
.methods()
.set_expiry_cosigner(fixture.deployer.address())
.call()
.await
.unwrap();
open_turbo_session(&fixture).await;
fixture
}
async fn sign(signer: &Wallet, message: Message) -> Signature {
Signature::Secp256k1(Secp256k1 {
bits: *signer.signer().sign(message).await.unwrap(),
})
}
async fn state(fixture: &PropFixture) -> (Option<SessionView>, Vec<u128>) {
let mut balances = Vec::new();
for asset in [fixture.base_asset, fixture.collateral_asset] {
balances.push(fixture.pool_balance(asset).await as u128);
balances.push(fixture.account_balance(asset).await as u128);
for wallet in [&fixture.user, &fixture.deployer, &fixture.outsider] {
balances.push(wallet.get_asset_balance(&asset).await.unwrap());
}
}
(fixture.session_opt().await, balances)
}
#[tokio::test]
async fn v4_publishes_both_versioned_policies_and_preserves_the_tier_event() {
let fixture = PropFixture::new().await;
assert_eq!(policy(&fixture, 1).await, None);
for (index, target) in [Some(500), None, Some(0), Some(10_000)]
.into_iter()
.enumerate()
{
let response = publish(&fixture, target).await;
let version = index as u32 + 1;
assert_eq!(response.value, version);
assert_eq!(policy(&fixture, version).await, target);
let tier = response
.decode_logs_with_type::<TierVersionPublishedV2>()
.unwrap();
assert_eq!(tier.len(), 1);
assert_eq!(tier[0].params, turbo_tier_params());
assert_eq!(tier[0].terms, turbo_terms(DRAWDOWN_SCHEDULE));
assert_eq!(tier[0].books, vec![fixture.order_book.contract_id]);
assert_eq!(tier[0].k, LINE);
let eval = response
.decode_logs_with_type::<TierEvalProfitTargetPublished>()
.unwrap();
assert_eq!(eval.len(), 1);
assert_eq!(eval[0].tier_id, TURBO_TIER_ID);
assert_eq!(eval[0].version, version);
assert_eq!(eval[0].eval_profit_target_bps, target);
assert_eq!(eval[0].timestamp, tier[0].timestamp);
let daily = response
.decode_logs_with_type::<TierDailyLossLimitPublished>()
.unwrap();
assert_eq!(daily.len(), 1);
assert_eq!(daily[0].daily_loss_limit_bps, Some(100));
assert_eq!(daily[0].version, version);
assert_eq!(daily[0].timestamp, eval[0].timestamp);
assert_eq!(
fixture
.deployment
.pool
.methods()
.get_daily_loss_limit(TURBO_TIER_ID, version)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(100)
);
}
let v2 = publish_turbo_tier_unarmed(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
let v3 = fixture
.deployment
.pool
.methods()
.publish_tier_v3(
TURBO_TIER_ID,
turbo_tier_params(),
turbo_terms(DRAWDOWN_SCHEDULE),
vec![fixture.order_book.contract_id],
Some(250),
)
.with_contract_ids(&[
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.call()
.await
.unwrap()
.value;
assert_eq!(policy(&fixture, v2).await, None);
assert_eq!(policy(&fixture, v3).await, None);
assert_eq!(policy(&fixture, 1).await, Some(500));
assert_eq!(policy(&fixture, 3).await, Some(0));
assert_eq!(policy(&fixture, 4).await, Some(10_000));
}
#[tokio::test]
async fn eval_policy_is_admin_only_version_scoped_and_repairable_while_paused() {
let fixture = armed_fixture(Some(500)).await;
let session = fixture.session().await;
let newer = publish(&fixture, Some(800)).await.value;
let pool = &fixture.deployment.pool;
for caller in [&fixture.user, &fixture.outsider] {
let unauthorized = pool.clone().with_account(caller.clone());
let error = unauthorized
.methods()
.set_eval_profit_target(TURBO_TIER_ID, session.tier_version, None)
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("AccessControlNotAuthorized"),
"{error:#}"
);
let error = unauthorized
.methods()
.publish_tier_v4(
TURBO_TIER_ID,
turbo_tier_params(),
turbo_terms(DRAWDOWN_SCHEDULE),
vec![fixture.order_book.contract_id],
None,
Some(1),
)
.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, version) in [(TURBO_TIER_ID, 0), (TURBO_TIER_ID, newer + 1), (999, 1)] {
let error = pool
.methods()
.set_eval_profit_target(tier, version, Some(1))
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("InvalidTierVersion"),
"{error:#}"
);
}
for invalid in [10_001, u64::MAX] {
let error = pool
.methods()
.set_eval_profit_target(TURBO_TIER_ID, session.tier_version, Some(invalid))
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("InvalidEvalProfitTarget"),
"{error:#}"
);
assert_eq!(policy(&fixture, session.tier_version).await, Some(500));
}
pool.methods().pause().call().await.unwrap();
for target in [Some(0), None, None, Some(10_000)] {
let response = pool
.methods()
.set_eval_profit_target(TURBO_TIER_ID, session.tier_version, target)
.call()
.await
.unwrap();
let events = response
.decode_logs_with_type::<TierEvalProfitTargetPublished>()
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].eval_profit_target_bps, target);
assert_eq!(events[0].version, session.tier_version);
assert_eq!(events[0].tier_id, TURBO_TIER_ID);
assert!(events[0].timestamp.unix > 0);
assert!(
response
.decode_logs_with_type::<TierVersionPublishedV2>()
.unwrap()
.is_empty()
);
assert_eq!(policy(&fixture, session.tier_version).await, target);
assert_eq!(policy(&fixture, newer).await, Some(800));
assert_eq!(fixture.session().await, session);
}
assert_eq!(
pool.methods()
.current_tier_version(TURBO_TIER_ID)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(newer)
);
assert_eq!(
pool.methods()
.get_daily_loss_limit(TURBO_TIER_ID, session.tier_version)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(100)
);
}
#[tokio::test]
async fn invalid_v4_publications_leave_no_partial_tier_or_policy() {
let fixture = PropFixture::new().await;
let version = publish(&fixture, Some(500)).await.value;
let pool = &fixture.deployment.pool;
let mut invalid_terms = turbo_terms(DRAWDOWN_SCHEDULE);
invalid_terms.max_loss_bps = [0; 4];
for (daily, target, terms, books, expected) in [
(
Some(100),
Some(10_001),
turbo_terms(DRAWDOWN_SCHEDULE),
vec![fixture.order_book.contract_id],
"InvalidEvalProfitTarget",
),
(
Some(100),
Some(u64::MAX),
turbo_terms(DRAWDOWN_SCHEDULE),
vec![fixture.order_book.contract_id],
"InvalidEvalProfitTarget",
),
(
Some(10_001),
Some(500),
turbo_terms(DRAWDOWN_SCHEDULE),
vec![fixture.order_book.contract_id],
"InvalidDailyLossLimit",
),
(
None,
Some(500),
invalid_terms,
vec![fixture.order_book.contract_id],
"InvalidTurboTerms",
),
(
Some(100),
Some(500),
turbo_terms(DRAWDOWN_SCHEDULE),
vec![fixture.order_book.contract_id; 2],
"InvalidBook",
),
] {
let error = pool
.methods()
.publish_tier_v4(
TURBO_TIER_ID,
turbo_tier_params(),
terms,
books,
daily,
target,
)
.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!(policy(&fixture, version).await, Some(500));
assert_eq!(policy(&fixture, version + 1).await, None);
assert_eq!(
pool.methods()
.get_daily_loss_limit(TURBO_TIER_ID, version + 1)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
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(&fixture, None).await.value, version + 1);
}
#[tokio::test]
async fn only_the_pinned_enabled_policy_gates_an_existing_authorization() {
let fixture = PropFixture::new().await;
let version = publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
let session = fixture.session().await.session_id;
let signature = fixture
.eval_close_cosignature(session, &fixture.deployer)
.await;
let newer = publish(&fixture, Some(500)).await.value;
assert!(newer > version);
let before = state(&fixture).await;
for target in [None, Some(700), None] {
if target.is_some() || policy(&fixture, version).await.is_some() {
set_policy(&fixture, version, target).await;
}
if target.is_some() {
continue;
}
let error = fixture
.close_eval_session_signed(
&fixture.outsider,
session,
vec![],
signature.clone(),
)
.await
.unwrap_err();
assert!(
error.to_string().contains("EvalProfitTargetNotConfigured"),
"{error:#}"
);
assert_eq!(state(&fixture).await, before);
}
set_policy(&fixture, newer, None).await;
set_policy(&fixture, version, Some(0)).await;
let response = fixture
.close_eval_session_signed(&fixture.outsider, session, vec![], signature)
.await
.unwrap();
assert_eq!(
fixture.closed_event(&response).reason,
SettlementReason::ProfitTargetReached
);
assert!(fixture.session_opt().await.is_none());
}
#[tokio::test]
async fn eval_relayer_settles_below_target_with_existing_ticket_and_payout_economics() {
let fixture = armed_fixture(Some(10_000)).await;
let session = fixture.session().await;
let signature = fixture
.eval_close_cosignature(session.session_id, &fixture.deployer)
.await;
set_policy(&fixture, session.tier_version, Some(9_000)).await;
let gain = 500_000;
fixture
.user
.force_transfer_to_contract(
fixture.child_id,
gain,
fixture.collateral_asset,
TxPolicies::default(),
)
.await
.unwrap();
assert!(!fixture.is_liquidatable().await);
let parent_before = fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let platform_before = fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let relayer_before = fixture
.outsider
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let response = fixture
.close_eval_session_signed(
&fixture.outsider,
session.session_id,
vec![],
signature,
)
.await
.unwrap();
let closed = fixture.closed_event(&response);
assert_eq!(closed.reason, SettlementReason::ProfitTargetReached);
assert_eq!(closed.session_id, session.session_id);
assert_eq!(closed.user_net, gain);
assert_eq!(closed.payout_parent, vec![(fixture.collateral_asset, gain)]);
let seized = response.decode_logs_with_type::<SessionSeized>().unwrap();
assert_eq!(seized.len(), 1);
assert_eq!(seized[0].reason, SettlementReason::ProfitTargetReached);
let ticket = response.decode_logs_with_type::<TicketSettled>().unwrap();
assert_eq!(ticket.len(), 1);
assert_eq!(ticket[0].to_platform, TURBO_DEPOSIT);
assert_eq!(ticket[0].absorbed_by_pool, 0);
assert_eq!(
fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
parent_before + u128::from(gain)
);
assert_eq!(
fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
platform_before + u128::from(TURBO_DEPOSIT)
);
assert_eq!(
fixture
.outsider
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
relayer_before
);
assert!(
response
.decode_logs_with_type::<SessionRolledOver>()
.unwrap()
.is_empty()
);
assert!(fixture.session_opt().await.is_none());
}
async fn resting_cleanup(
fixture: &PropFixture,
side: Side,
price: u64,
) -> PropOrderBookCleanup {
let orders = fixture
.order_book
.order_book
.methods()
.get_orders_at_price(side, price)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value;
let order_ids = orders
.into_iter()
.filter(|order| order.trader_id == Identity::ContractId(fixture.child_id))
.map(|order| order.order_id)
.collect::<Vec<_>>();
assert!(!order_ids.is_empty());
PropOrderBookCleanup {
order_book: fixture.order_book.contract_id,
order_ids,
}
}
#[tokio::test]
async fn eval_close_drains_orders_and_debts_and_retries_incomplete_cleanup_atomically() {
let mut fixture = armed_fixture(Some(500)).await;
let session = fixture.session().await.session_id;
let signature = fixture
.eval_close_cosignature(session, &fixture.deployer)
.await;
let borrowed = 100_000_000;
let drawn = 1_000_000;
let gift = 100_000_000;
fixture
.deployer
.force_transfer_to_contract(
fixture.child_id,
gift,
fixture.base_asset,
TxPolicies::default(),
)
.await
.unwrap();
let borrow = fixture.pool_call(
fn_selector!(borrow(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, borrowed)),
);
let draw = fixture.draw_call(drawn);
let buy =
fixture.book_call(1_000_000, 1_000_000_000, fixture.collateral_asset, drawn);
let sell = fixture.book_call(
3_000_000,
borrowed + gift,
fixture.base_asset,
borrowed + gift,
);
fixture
.call_account(vec![borrow, draw, buy, sell], 2)
.await
.unwrap();
let buy_cleanup = resting_cleanup(&fixture, Side::Buy, 1_000_000).await;
let sell_cleanup = resting_cleanup(&fixture, Side::Sell, 3_000_000).await;
let before = state(&fixture).await;
assert_eq!(fixture.session().await.drawn_quote, drawn);
assert_eq!(fixture.debt(fixture.base_asset).await, borrowed);
let error = fixture
.close_eval_session_signed(
&fixture.outsider,
session,
vec![buy_cleanup.clone()],
signature.clone(),
)
.await
.unwrap_err();
assert!(error.to_string().contains("BookNotDrained"), "{error:#}");
assert_eq!(state(&fixture).await, before);
assert_eq!(
resting_cleanup(&fixture, Side::Buy, 1_000_000)
.await
.order_ids,
buy_cleanup.order_ids
);
assert_eq!(
resting_cleanup(&fixture, Side::Sell, 3_000_000)
.await
.order_ids,
sell_cleanup.order_ids
);
assert_eq!(fixture.debt(fixture.base_asset).await, borrowed);
let response = fixture
.close_eval_session_signed(
&fixture.outsider,
session,
vec![buy_cleanup, sell_cleanup],
signature,
)
.await
.unwrap();
let closed = fixture.closed_event(&response);
assert_eq!(closed.reason, SettlementReason::ProfitTargetReached);
assert!(closed.cancelled_debt.is_empty());
assert_eq!(fixture.debt(fixture.base_asset).await, 0);
assert_eq!(fixture.account_balance(fixture.base_asset).await, 0);
assert_eq!(fixture.account_balance(fixture.collateral_asset).await, 0);
assert!(fixture.session_opt().await.is_none());
let seized = response.decode_logs_with_type::<SessionSeized>().unwrap();
assert_eq!(seized.len(), 1);
assert_eq!(seized[0].reason, SettlementReason::ProfitTargetReached);
}
#[tokio::test]
async fn eval_authorization_binds_every_field_and_cannot_be_reused_for_forced_close() {
let fixture = armed_fixture(Some(500)).await;
let session = fixture.session().await.session_id;
let chain = *fixture
.user
.provider()
.consensus_parameters()
.await
.unwrap()
.chain_id();
let pool = fixture.deployment.pool_id;
let account = fixture.child_id;
let before = state(&fixture).await;
let mut invalid = vec![
fixture.eval_close_cosignature(session, &fixture.user).await,
Signature::Secp256k1(Secp256k1 { bits: [0; 64] }),
fixture
.force_close_cosignature(session, &fixture.deployer)
.await,
expiry_cosignature(&fixture, 0, false, fixture.deployer.clone()).await,
];
for (chain, pool, account, id) in [
(chain + 1, pool, account, session),
(chain, ContractId::new([0x66; 32]), account, session),
(chain, pool, ContractId::new([0x77; 32]), session),
(chain, pool, account, session + 1),
] {
invalid.push(
sign(
&fixture.deployer,
generate_pool_eval_close_signing_payload(chain, pool, account, id),
)
.await,
);
}
for signature in invalid {
let error = fixture
.close_eval_session_signed(&fixture.outsider, session, vec![], signature)
.await
.unwrap_err();
assert!(
error.to_string().contains("InvalidForceCloseCosigner"),
"{error:#}"
);
assert_eq!(state(&fixture).await, before);
}
let valid = fixture
.eval_close_cosignature(session, &fixture.deployer)
.await;
let error = fixture
.force_close_session_signed(&fixture.outsider, session, vec![], valid)
.await
.unwrap_err();
assert!(
error.to_string().contains("InvalidForceCloseCosigner"),
"{error:#}"
);
assert_eq!(state(&fixture).await, before);
}
#[tokio::test]
async fn eval_signer_configuration_pause_and_session_replay_follow_forced_closure() {
let fixture = PropFixture::new().await;
publish(&fixture, Some(500)).await;
open_turbo_session(&fixture).await;
let session = fixture.session().await.session_id;
let original = fixture
.eval_close_cosignature(session, &fixture.deployer)
.await;
let before = state(&fixture).await;
let error = fixture
.close_eval_session_signed(&fixture.outsider, session, vec![], original.clone())
.await
.unwrap_err();
assert!(
error.to_string().contains("ExpiryCosignerNotSet"),
"{error:#}"
);
assert_eq!(state(&fixture).await, before);
let pool = &fixture.deployment.pool;
pool.methods()
.set_expiry_cosigner(fixture.deployer.address())
.call()
.await
.unwrap();
pool.methods().pause().call().await.unwrap();
let error = fixture
.close_eval_session_signed(&fixture.outsider, session, vec![], original.clone())
.await
.unwrap_err();
assert!(error.to_string().contains("Paused"), "{error:#}");
assert_eq!(state(&fixture).await, before);
pool.methods().unpause().call().await.unwrap();
pool.methods()
.set_expiry_cosigner(fixture.outsider.address())
.call()
.await
.unwrap();
let error = fixture
.close_eval_session_signed(&fixture.user, session, vec![], original)
.await
.unwrap_err();
assert!(
error.to_string().contains("InvalidForceCloseCosigner"),
"{error:#}"
);
assert_eq!(state(&fixture).await, before);
let current = fixture
.eval_close_cosignature(session, &fixture.outsider)
.await;
let error = fixture
.close_eval_session_signed(&fixture.user, session + 1, vec![], current.clone())
.await
.unwrap_err();
assert!(error.to_string().contains("SessionIdMismatch"), "{error:#}");
pool.methods().pause().call().await.unwrap();
let error = fixture
.close_eval_session_signed(&fixture.user, session, vec![], current.clone())
.await
.unwrap_err();
assert!(error.to_string().contains("Paused"), "{error:#}");
assert_eq!(state(&fixture).await, before);
pool.methods().unpause().call().await.unwrap();
fixture
.close_eval_session_signed(&fixture.user, session, vec![], current.clone())
.await
.unwrap();
let after = state(&fixture).await;
let error = fixture
.close_eval_session_signed(&fixture.user, session, vec![], current.clone())
.await
.unwrap_err();
assert!(error.to_string().contains("NoActiveSession"), "{error:#}");
assert_eq!(state(&fixture).await, after);
let reopened = start_turbo_session(&fixture).await;
assert!(reopened.session_id > session);
let before = state(&fixture).await;
for (id, expected) in [
(session, "SessionIdMismatch"),
(reopened.session_id, "InvalidForceCloseCosigner"),
] {
let error = fixture
.close_eval_session_signed(&fixture.user, id, vec![], current.clone())
.await
.unwrap_err();
assert!(error.to_string().contains(expected), "{error:#}");
assert_eq!(state(&fixture).await, before);
}
}