use super::*;
use crate::{
prop::{
RolloverWithdrawalRecorded,
SessionRolledOver,
SessionSeized,
TicketSettled,
TierEnabledChanged,
TierVersionPublishedV2,
TurboTerms,
},
prop_expiry::generate_pool_expiry_signing_payload,
};
const TURBO_TIER_ID: u64 = 7;
const TURBO_DEPOSIT: u64 = 250_000;
const TURBO_FEE: u64 = 25_000;
const TURBO_ENTRY: u64 = TURBO_DEPOSIT + TURBO_FEE;
const ROLLOVER_BAR: u64 = 900_000;
const TURBO_LINE: u64 = TURBO_DEPOSIT * 40;
fn turbo_tier_params() -> TierParams {
turbo_tier_params_for(LINE)
}
fn turbo_tier_params_for(line: u64) -> TierParams {
TierParams {
line,
leverage: 40,
duration: 0,
maintenance_bps: 100,
open_buffer_bps: 101,
liq_price_factor: 9_900,
prolong_fee: [0, 0, 0, 0],
max_credit_line_bps: 20_000,
max_price_age: u64::MAX,
open_fee: line / 400,
profit_share_bps: 0,
price_band_bps: 1_000,
}
}
fn turbo_terms(max_loss_bps: [u64; 4]) -> TurboTerms {
TurboTerms {
max_loss_bps,
rollover_profit_bps: 900,
max_rollovers: 3,
}
}
const DRAWDOWN_SCHEDULE: [u64; 4] = [150, 200, 250, 300];
async fn publish_turbo_tier(fixture: &PropFixture, terms: TurboTerms) -> u32 {
let version = publish_turbo_tier_unarmed(fixture, terms).await;
fixture
.deployment
.pool
.methods()
.set_expiry_cosigner(fixture.deployer.address())
.call()
.await
.unwrap();
version
}
async fn publish_turbo_tier_unarmed(fixture: &PropFixture, terms: TurboTerms) -> u32 {
let response = fixture
.deployment
.pool
.methods()
.publish_tier_v2(
TURBO_TIER_ID,
turbo_tier_params(),
terms.clone(),
vec![fixture.order_book.contract_id],
)
.with_contract_ids(&[
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.call()
.await
.unwrap();
let version = response.value;
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].terms, terms);
assert_eq!(published[0].rollover_window_seconds, 21_600);
assert_eq!(published[0].k, 10_000_000);
assert_eq!(published[0].threshold_floor, 10_100_000);
assert!(
response
.decode_logs_with_type::<TierVersionPublished>()
.unwrap()
.is_empty()
);
version
}
async fn open_turbo_session(fixture: &PropFixture) -> SessionOpened {
fixture.close_session().await;
start_turbo_session(fixture).await
}
async fn start_turbo_session(fixture: &PropFixture) -> SessionOpened {
start_turbo_session_paying(fixture, TURBO_ENTRY).await
}
async fn start_turbo_session_paying(fixture: &PropFixture, entry: u64) -> SessionOpened {
let response = fixture
.account
.methods()
.start_session(TURBO_TIER_ID, entry, ProlongPeriod::Week)
.call_params(CallParameters::new(
entry,
fixture.collateral_asset,
u64::MAX,
))
.unwrap()
.with_contract_ids(&[
fixture.deployment.oracle_id,
fixture.deployment.pool_id,
fixture.deployment.registry_id,
])
.with_variable_output_policy(VariableOutputPolicy::Exactly(1))
.call()
.await
.unwrap();
let opened = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<SessionOpened>(&response.tx_status.receipts)
.unwrap();
assert_eq!(opened.len(), 1);
opened.into_iter().next().unwrap()
}
async fn refresh_session_key(fixture: &PropFixture) {
let chain = fixture
.user
.try_provider()
.unwrap()
.latest_block_time()
.await
.unwrap()
.map(|time| time.timestamp() as u64)
.unwrap_or(0);
fixture
.account
.methods()
.set_session(ParallelSessionArgs {
nonce: U256::zero(),
session_id: Identity::Address(fixture.user.address()),
expiry: Time {
unix: chain + 86_400,
},
contract_ids: vec![],
})
.with_contract_ids(&[fixture.deployment.oracle_id])
.call()
.await
.unwrap();
}
async fn advance_past_expiry(fixture: &PropFixture) {
let session = fixture.session().await;
let provider = fixture.outsider.try_provider().unwrap();
let latest = provider
.latest_block_time()
.await
.unwrap()
.expect("local chain has a latest block time");
let seconds = session.expires_at.saturating_sub(latest.timestamp() as u64) + 1;
let target = latest
.checked_add_signed(chrono::TimeDelta::seconds(seconds as i64))
.expect("test expiry timestamp is representable");
provider.produce_blocks(1, Some(target)).await.unwrap();
}
async fn try_settle(
fixture: &PropFixture,
outside: u64,
roll: bool,
) -> Result<CallResponse<()>> {
let signature =
expiry_cosignature(fixture, outside, roll, fixture.deployer.clone()).await;
settle_with(fixture, outside, roll, signature, None).await
}
async fn settle_with(
fixture: &PropFixture,
outside: u64,
roll: bool,
signature: Signature,
caller: Option<Wallet>,
) -> Result<CallResponse<()>> {
let pool = match caller {
Some(caller) => fixture.deployment.pool.clone().with_account(caller),
None => fixture.deployment.pool.clone(),
};
Ok(pool
.methods()
.expire_or_rollover(fixture.child_id, vec![], outside, roll, signature)
.with_contracts(&[
&fixture.account,
&fixture.deployment.oracle,
&fixture.order_book.order_book,
&fixture.deployment.price_feed,
])
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.call()
.await?)
}
async fn try_expire(fixture: &PropFixture) -> Result<CallResponse<()>> {
try_settle(fixture, 0, false).await
}
async fn try_roll(fixture: &PropFixture) -> Result<CallResponse<()>> {
try_settle(fixture, 0, true).await
}
async fn try_legacy_expire(
fixture: &PropFixture,
caller: Wallet,
) -> Result<CallResponse<()>> {
Ok(fixture
.deployment
.pool
.clone()
.with_account(caller)
.methods()
.expire_session(fixture.child_id, vec![])
.with_contracts(&[
&fixture.account,
&fixture.deployment.oracle,
&fixture.order_book.order_book,
&fixture.deployment.price_feed,
])
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.call()
.await?)
}
async fn expiry_cosignature(
fixture: &PropFixture,
outside: u64,
roll: bool,
signer: Wallet,
) -> Signature {
let session = fixture
.deployment
.pool
.methods()
.get_session(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
.expect("no session to settle");
let chain_id = fixture
.user
.provider()
.consensus_parameters()
.await
.unwrap()
.chain_id();
let message = generate_pool_expiry_signing_payload(
*chain_id,
fixture.deployment.pool.contract_id(),
fixture.child_id,
session.session_id,
session.expires_at,
outside,
roll,
);
Signature::Secp256k1(Secp256k1 {
bits: *signer.signer().sign(message).await.unwrap(),
})
}
async fn rollover_view(fixture: &PropFixture) -> crate::prop::RolloverView {
fixture
.deployment
.pool
.methods()
.get_rollover(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
.unwrap()
}
#[tokio::test]
async fn a_turbo_publication_is_atomic_and_refuses_incoherent_terms_whole() {
let fixture = PropFixture::new().await;
let pool = fixture.deployment.pool.clone();
let before = pool
.methods()
.current_tier_version(TURBO_TIER_ID)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value;
assert_eq!(before, None, "the turbo tier does not exist yet");
let mut forfeit_only = turbo_terms(DRAWDOWN_SCHEDULE);
forfeit_only.rollover_profit_bps = 0;
forfeit_only.max_rollovers = 0;
pool.methods()
.publish_tier_v2(
TURBO_TIER_ID + 2,
turbo_tier_params(),
forfeit_only,
vec![fixture.order_book.contract_id],
)
.with_contract_ids(&[
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.call()
.await
.expect("a forfeit-only prepaid tier publishes");
for (schedule, what) in [
([0, 200, 250, 300], "a dead first step"),
([150, 0, 250, 300], "a dead middle step"),
([150, 200, 250, 0], "a dead last step"),
([150, 100, 250, 300], "a step that narrows"),
] {
let mut bad = turbo_terms(DRAWDOWN_SCHEDULE);
bad.max_loss_bps = schedule;
let error = pool
.methods()
.publish_tier_v2(
TURBO_TIER_ID,
turbo_tier_params(),
bad,
vec![fixture.order_book.contract_id],
)
.with_contract_ids(&[
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("InvalidTurboTerms"),
"{what} must be refused: {error:#}"
);
}
let mut floorless = turbo_terms(DRAWDOWN_SCHEDULE);
floorless.max_loss_bps = [0, 0, 0, 0];
for (params, bad, what) in [
(turbo_tier_params(), floorless, "a tier selling no drawdown"),
(
{
let mut narrow = turbo_tier_params();
narrow.max_credit_line_bps = 9_999;
narrow
},
turbo_terms(DRAWDOWN_SCHEDULE),
"a cap under the line",
),
] {
let error = pool
.methods()
.publish_tier_v2(
TURBO_TIER_ID,
params,
bad,
vec![fixture.order_book.contract_id],
)
.with_contract_ids(&[
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("InvalidTurboTerms"),
"{what} must be refused: {error:#}"
);
}
let mut short = turbo_terms([150, 200, 0, 0]);
short.max_rollovers = 1;
pool.methods()
.publish_tier_v2(
TURBO_TIER_ID + 1,
turbo_tier_params(),
short,
vec![fixture.order_book.contract_id],
)
.with_contract_ids(&[
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.call()
.await
.expect("only reachable steps are policed");
let error = pool
.clone()
.with_account(fixture.outsider.clone())
.methods()
.publish_tier_v2(
TURBO_TIER_ID,
turbo_tier_params(),
turbo_terms(DRAWDOWN_SCHEDULE),
vec![fixture.order_book.contract_id],
)
.with_contract_ids(&[
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("AccessControlNotAuthorized"),
"{error:#}"
);
let after = pool
.methods()
.current_tier_version(TURBO_TIER_ID)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value;
assert_eq!(after, None, "a refused publication must leave no trace");
let version = publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
let stored = pool
.methods()
.get_turbo_terms(TURBO_TIER_ID, version)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value;
assert_eq!(stored, Some(turbo_terms(DRAWDOWN_SCHEDULE)));
assert_eq!(
pool.methods()
.get_turbo_terms(TIER_ID, 1)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
None,
"a legacy publication never carries terms"
);
assert_eq!(
pool.methods()
.session_turbo_terms(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
None,
);
}
#[tokio::test]
async fn switching_an_old_tier_off_stops_its_sales_and_strands_no_session() {
let mut fixture = PropFixture::new().await;
let pool = fixture.deployment.pool.clone();
assert!(
pool.methods()
.is_tier_enabled(TIER_ID)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
let error = pool
.clone()
.with_account(fixture.outsider.clone())
.methods()
.set_tier_enabled(TIER_ID, false)
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("AccessControlNotAuthorized"),
"{error:#}"
);
let response = pool
.methods()
.set_tier_enabled(TIER_ID, false)
.call()
.await
.unwrap();
let changed = response
.decode_logs_with_type::<TierEnabledChanged>()
.unwrap();
assert_eq!(changed.len(), 1);
assert_eq!(changed[0].tier_id, TIER_ID);
assert!(!changed[0].enabled);
assert!(
!pool
.methods()
.is_tier_enabled(TIER_ID)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
assert_eq!(
pool.methods()
.session_turbo_terms(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
None,
);
let response = fixture
.call_account(vec![fixture.prolong_call(1)], 1)
.await
.unwrap();
let prolonged = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<SessionProlonged>(&response.tx_status.receipts)
.unwrap();
assert_eq!(prolonged.len(), 1);
assert_eq!(prolonged[0].fee, 6_000);
fixture.close_session().await;
let error = fixture
.account
.methods()
.start_session(TIER_ID, COLLATERAL, ProlongPeriod::SixHours)
.call_params(CallParameters::new(
COLLATERAL,
fixture.collateral_asset,
u64::MAX,
))
.unwrap()
.with_contracts(&[
&fixture.deployment.oracle,
&fixture.deployment.pool,
&fixture.deployment.registry,
])
.call()
.await
.unwrap_err();
assert!(error.to_string().contains("TierDisabled"), "{error:#}");
let opened = start_turbo_session(&fixture).await;
assert_eq!(opened.tier_id, TURBO_TIER_ID);
assert_eq!(
pool.methods()
.session_turbo_terms(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(turbo_terms(DRAWDOWN_SCHEDULE)),
);
pool.methods()
.set_tier_enabled(TIER_ID, true)
.call()
.await
.unwrap();
assert!(
pool.methods()
.is_tier_enabled(TIER_ID)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
}
#[tokio::test]
async fn a_turbo_entry_is_forfeited_whole_and_the_walkaway_is_profits_only() {
let fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
let platform_before = fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let opened = open_turbo_session(&fixture).await;
assert_eq!(opened.collateral, 0);
assert_eq!(opened.open_fee, TURBO_FEE);
assert_eq!(opened.fees_accrued, 0);
assert_eq!(opened.credit_line, LINE);
let view = rollover_view(&fixture).await;
assert_eq!(view.anchor, LINE);
assert_eq!(view.rollovers_used, 0);
assert_eq!(view.max_rollovers, 3);
assert_eq!(view.profit_required, ROLLOVER_BAR);
assert_eq!(
fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
platform_before + TURBO_FEE as u128
);
let parent_before = fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let response = fixture.close_session().await;
let closed = fixture.closed_event(&response);
assert_eq!(closed.user_net, 0);
assert!(closed.payout_parent.is_empty());
let settled = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<TicketSettled>(&response.tx_status.receipts)
.unwrap();
assert_eq!(settled.len(), 1);
assert_eq!(settled[0].ticket, TURBO_DEPOSIT);
assert_eq!(
settled[0].to_platform, TURBO_DEPOSIT,
"no loss, so no absorption"
);
assert_eq!(settled[0].absorbed_by_pool, 0);
assert_eq!(
fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
platform_before + TURBO_ENTRY as u128,
"the platform's whole take is the price: fee at the door, deposit at the close"
);
assert_eq!(
fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
parent_before,
"a break-even prepaid user gets exactly nothing back"
);
}
#[tokio::test]
async fn a_prepaid_ticket_cannot_be_netted_away() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
let platform_before = fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
open_turbo_session(&fixture).await;
let draw = fixture.draw_call(TURBO_DEPOSIT);
fixture.call_account(vec![draw], 1).await.unwrap();
let repay = fixture.repay_from_collateral_call(TURBO_DEPOSIT);
let error = fixture.call_account(vec![repay], 0).await.unwrap_err();
let refusal = format!("{error:?}");
assert!(
refusal.contains("INSUFFICIENT_BALANCE")
|| refusal.contains("InsufficientBalance"),
"there is no collateral to net - the ticket is not in the ledger: {refusal}"
);
assert_eq!(
fixture.session().await.collateral,
0,
"the refused netting moved nothing, and there was nothing to move"
);
let ret = fixture.pool_call(
fn_selector!(return_quote(u64)),
TURBO_DEPOSIT,
fixture.collateral_asset,
Some(call_data!(TURBO_DEPOSIT)),
);
fixture.call_account(vec![ret], 0).await.unwrap();
let parent_before = fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let response = fixture.close_session().await;
let closed = fixture.closed_event(&response);
assert_eq!(closed.user_net, 0);
let settled = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<TicketSettled>(&response.tx_status.receipts)
.unwrap();
assert_eq!(settled.len(), 1);
assert_eq!(
settled[0].to_platform, TURBO_DEPOSIT,
"the whole deposit reaches the platform"
);
assert_eq!(
fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
platform_before + TURBO_ENTRY as u128,
"the platform's take is the whole price"
);
assert_eq!(
fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
parent_before,
"and the parent gets nothing back"
);
}
#[tokio::test]
async fn a_top_up_lands_on_a_prepaid_session_as_it_does_on_a_legacy_one() {
let fixture = PropFixture::new().await;
fixture.fund_collateral(100_000).await;
assert_eq!(fixture.session().await.collateral, COLLATERAL + 100_000);
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
fixture
.deployment
.pool
.clone()
.with_account(fixture.outsider.clone())
.methods()
.add_collateral(fixture.child_id)
.call_params(CallParameters::new(
100_000,
fixture.collateral_asset,
u64::MAX,
))
.unwrap()
.call()
.await
.expect("an outsider may fund a prepaid session's line");
let session = fixture.session().await;
assert_eq!(session.collateral, 100_000);
}
#[tokio::test]
async fn a_topped_up_seat_withdraws_its_top_up_net_of_later_fees() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
let top_up = 100_000u64;
fixture
.deployment
.pool
.clone()
.with_account(fixture.user.clone())
.methods()
.add_collateral(fixture.child_id)
.call_params(CallParameters::new(
top_up,
fixture.collateral_asset,
u64::MAX,
))
.unwrap()
.call()
.await
.unwrap();
let quantity = 10_000_000u64;
let value = 20_000u64;
let fee = 2u64;
let borrow = fixture.pool_call(
fn_selector!(borrow(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, quantity)),
);
fixture.call_account(vec![borrow], 1).await.unwrap();
let repay = fixture.repay_base_from_collateral_call(fixture.base_asset, quantity);
fixture.call_account(vec![repay], 1).await.unwrap();
let session = fixture.session().await;
assert_eq!(session.collateral, top_up - value);
assert_eq!(session.fees_accrued, fee);
let withdrawable = top_up - value - fee;
let over = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, withdrawable + 1)),
);
let error = fixture.call_account(vec![over], 2).await.unwrap_err();
let refusal = format!("{error:?}");
assert!(
refusal.contains("BELOW_TIER_COLLATERAL")
|| refusal.contains("BelowTierCollateral"),
"the fee's cover may not leave: {refusal}"
);
let take = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, withdrawable)),
);
fixture.call_account(vec![take], 2).await.unwrap();
assert_eq!(fixture.session().await.collateral, fee);
}
#[tokio::test]
async fn a_flat_turbo_session_earns_nothing_and_expires_into_forfeit() {
let fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
advance_past_expiry(&fixture).await;
let response = try_expire(&fixture).await.unwrap();
let closed = fixture.closed_event(&response);
assert!(matches!(closed.reason, SettlementReason::Expiry));
let settled = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<TicketSettled>(&response.tx_status.receipts)
.unwrap();
assert_eq!(settled.len(), 1);
assert_eq!(settled[0].ticket, TURBO_DEPOSIT);
assert_eq!(settled[0].to_platform, TURBO_DEPOSIT);
let seized = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<SessionSeized>(&response.tx_status.receipts)
.unwrap();
assert_eq!(seized.len(), 1);
assert!(seized[0].debt.is_empty(), "{:?}", seized[0].debt);
assert!(
seized[0].transferred.is_empty(),
"{:?}",
seized[0].transferred
);
}
#[tokio::test]
async fn a_nine_percent_winner_is_rolled_by_the_expiry_trigger() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
let quantity = 1_000_000_000u64;
let cost = 2_000_000u64;
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();
fixture
.publish_base_price(4_000_000_000_000_000_000u128)
.await;
assert_eq!(fixture.session_value().await, 12_000_000);
let expires_before = fixture.session().await.expires_at;
advance_past_expiry(&fixture).await;
let response = try_roll(&fixture).await.unwrap();
let rolled = response
.decode_logs_with_type::<SessionRolledOver>()
.unwrap();
assert_eq!(rolled.len(), 1);
assert_eq!(rolled[0].rollovers_used, 1);
assert_eq!(rolled[0].max_rollovers, 3);
assert_eq!(rolled[0].anchor, 12_000_000);
assert_eq!(rolled[0].profit_required, ROLLOVER_BAR);
assert_eq!(rolled[0].new_expires_at, expires_before + 604_800);
assert_eq!(fixture.session().await.expires_at, expires_before + 604_800);
fixture
.publish_base_price(2_000_000_000_000_000_000u128)
.await;
assert_eq!(fixture.session_value().await, 10_000_000);
let lines = fixture.lines().await;
assert_eq!(lines.threshold, U256::from(9_820_000u64));
assert!(
!fixture.is_liquidatable().await,
"giving back the gain is not a loss of principal"
);
fixture
.publish_base_price(1_800_000_000_000_000_000u128)
.await;
assert_eq!(fixture.session_value().await, 9_800_000);
assert_eq!(fixture.lines().await.threshold, U256::from(9_818_000u64));
assert!(fixture.is_liquidatable().await);
let closed = fixture.closed_event(&fixture.liquidate().await);
assert!(matches!(closed.reason, SettlementReason::Liquidation));
assert!(
closed.payout_parent.is_empty(),
"the forfeited stake absorbed the loss and the rest went to the platform"
);
}
#[tokio::test]
async fn withdrawing_banked_profit_leaves_the_anchor_fixed() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
let banked = 100_000u64;
fixture
.user
.force_transfer_to_contract(
fixture.child_id,
banked,
fixture.collateral_asset,
TxPolicies::default(),
)
.await
.unwrap();
let withdrawable = banked;
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, withdrawable)),
);
let response = fixture.call_account(vec![withdraw], 2).await.unwrap();
let recorded = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<RolloverWithdrawalRecorded>(&response.tx_status.receipts)
.unwrap();
assert_eq!(recorded.len(), 1);
assert_eq!(recorded[0].amount, withdrawable);
assert_eq!(recorded[0].withdrawn_total, withdrawable);
let view = rollover_view(&fixture).await;
assert_eq!(view.anchor, LINE);
assert_eq!(view.withdrawn, withdrawable);
}
#[tokio::test]
async fn a_tier_can_sell_a_drawdown_wider_than_the_pools_principal() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms([300, 300, 300, 300])).await;
open_turbo_session(&fixture).await;
let ticket_edge = LINE - TURBO_DEPOSIT;
let threshold = fixture.lines().await.threshold;
assert_eq!(threshold, U256::from(LINE - LINE * 300 / 10_000));
assert!(
threshold < U256::from(ticket_edge),
"the sold drawdown reaches past the ticket"
);
let quantity = 1_000_000_000u64;
let cost = 2_000_000u64;
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();
fixture
.publish_base_price(1_500_000_000_000_000_000u128)
.await;
assert!(fixture.is_liquidatable().await);
let bad_debt_before = fixture.bad_debt().await;
let closed = fixture.closed_event(&fixture.liquidate().await);
assert!(matches!(closed.reason, SettlementReason::Liquidation));
assert!(
closed.bad_debt != 0,
"a liquidation below the principal must book the shortfall"
);
assert_eq!(fixture.bad_debt().await, bad_debt_before + closed.bad_debt);
}
#[tokio::test]
async fn a_banked_week_still_counts_toward_a_target_that_never_moves() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
assert_eq!(
fixture
.deployment
.pool
.methods()
.session_turbo_terms(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(turbo_terms(DRAWDOWN_SCHEDULE)),
);
let opening = rollover_view(&fixture).await;
assert_eq!(opening.anchor, LINE);
assert_eq!(opening.profit_required, ROLLOVER_BAR);
let quantity = 1_000_000_000u64;
let cost = 2_000_000u64;
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();
fixture
.publish_base_price(4_000_000_000_000_000_000u128)
.await;
assert_eq!(fixture.session_value().await, 12_000_000);
fixture
.rest_order(4_000_000, quantity, fixture.collateral_asset, 4_000_000)
.await;
let sell = fixture.book_call(4_000_000, quantity, fixture.base_asset, quantity);
let return_quote = fixture.pool_call(
fn_selector!(return_quote()),
cost,
fixture.collateral_asset,
None,
);
fixture
.call_account(vec![sell, fixture.settle_book_call(), return_quote], 2)
.await
.unwrap();
let banked = fixture.account_balance(fixture.collateral_asset).await
- fixture.session().await.fees_accrued;
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, banked)),
);
fixture.call_account(vec![withdraw], 2).await.unwrap();
let nav = fixture.session_value().await;
assert!(
nav < (LINE + ROLLOVER_BAR) as i128,
"the banked user's NAV is back near the line: {nav}"
);
let banked_view = rollover_view(&fixture).await;
assert_eq!(
banked_view.profit_required, ROLLOVER_BAR,
"the weekly target is a flat share of the account size, never re-based"
);
assert_eq!(banked_view.anchor, LINE);
assert_eq!(banked_view.withdrawn, banked);
assert_eq!(
nav + banked_view.withdrawn as i128 - banked_view.anchor as i128,
2_000_000
);
advance_past_expiry(&fixture).await;
let response = try_roll(&fixture).await.unwrap();
let rolled = response
.decode_logs_with_type::<SessionRolledOver>()
.unwrap();
assert_eq!(rolled.len(), 1);
assert_eq!(rolled[0].rollovers_used, 1);
assert_eq!(rolled[0].profit_required, ROLLOVER_BAR);
assert_eq!(rolled[0].anchor as i128, nav);
assert_eq!(rollover_view(&fixture).await.profit_required, ROLLOVER_BAR);
}
#[tokio::test]
async fn a_partial_withdrawal_still_earns_the_rollover_on_the_sum() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
assert_eq!(rollover_view(&fixture).await.anchor, LINE);
let quantity = 1_000_000_000u64;
let cost = 2_000_000u64;
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();
fixture
.publish_base_price(3_000_000_000_000_000_000u128)
.await;
assert_eq!(fixture.session_value().await, 11_000_000);
fixture
.rest_order(3_000_000, quantity, fixture.collateral_asset, 3_000_000)
.await;
let sell = fixture.book_call(3_000_000, quantity, fixture.base_asset, quantity);
let return_quote = fixture.pool_call(
fn_selector!(return_quote()),
cost,
fixture.collateral_asset,
None,
);
fixture
.call_account(vec![sell, fixture.settle_book_call(), return_quote], 2)
.await
.unwrap();
let taken = 500_000u64;
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, taken)),
);
fixture.call_account(vec![withdraw], 2).await.unwrap();
let nav = fixture.session_value().await;
assert_eq!(nav, 10_500_000);
let view = rollover_view(&fixture).await;
assert_eq!(view.anchor, LINE);
assert_eq!(view.withdrawn, taken);
let on_desk = nav - LINE as i128;
assert!(
on_desk < ROLLOVER_BAR as i128 && (view.withdrawn as i128) < ROLLOVER_BAR as i128,
"neither half alone clears the bar: on_desk={on_desk}, withdrawn={}",
view.withdrawn
);
assert_eq!(on_desk + view.withdrawn as i128, 1_000_000);
advance_past_expiry(&fixture).await;
let response = try_roll(&fixture).await.unwrap();
let rolled = response
.decode_logs_with_type::<SessionRolledOver>()
.unwrap();
assert_eq!(rolled.len(), 1);
assert_eq!(rolled[0].rollovers_used, 1);
assert_eq!(rolled[0].anchor as i128, nav);
assert_eq!(rollover_view(&fixture).await.withdrawn, 0);
}
#[tokio::test]
async fn a_turbo_account_sells_exactly_seven_days() {
let fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
fixture.close_session().await;
for period in [
ProlongPeriod::SixHours,
ProlongPeriod::Day,
ProlongPeriod::Month,
] {
let error = fixture
.account
.methods()
.start_session(TURBO_TIER_ID, TURBO_ENTRY, period.clone())
.call_params(CallParameters::new(
TURBO_ENTRY,
fixture.collateral_asset,
u64::MAX,
))
.unwrap()
.with_contracts(&[
&fixture.deployment.oracle,
&fixture.deployment.pool,
&fixture.deployment.registry,
])
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("PeriodNotOffered"),
"{period:?} must be refused by name, not by price: {error:#}"
);
}
let opened = start_turbo_session(&fixture).await;
assert_eq!(
opened.expires_at - opened.started_at,
604_800,
"the week is the whole term - the tier's base duration is pinned to zero"
);
}
#[tokio::test]
async fn one_payment_buys_at_most_four_weeks_and_the_last_profit_goes_home() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
let quantity = 1_000_000_000u64;
let cost = 2_000_000u64;
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();
let mut price = 2_000_000_000_000_000_000u128;
for week in 1u64..=3 {
price += 1_000_000_000_000_000_000;
fixture.publish_base_price(price).await;
advance_past_expiry(&fixture).await;
let expires_before = fixture.session().await.expires_at;
let response = try_roll(&fixture).await.unwrap();
let rolled = response
.decode_logs_with_type::<SessionRolledOver>()
.unwrap();
assert_eq!(rolled.len(), 1);
assert_eq!(rolled[0].rollovers_used, week, "free week number {week}");
assert_eq!(rolled[0].new_expires_at, expires_before + 604_800);
let anchor = rolled[0].anchor;
assert!(
anchor > LINE,
"a winner's anchor climbs with him: {anchor} at week {}",
week + 1
);
let allowance = LINE * DRAWDOWN_SCHEDULE[week as usize] / 10_000;
let basket_value = (price / 1_000_000_000_000) as u64;
let absorption = basket_value / 100;
let lines = fixture.lines().await;
assert_eq!(
lines.threshold,
U256::from(LINE - allowance + absorption),
"week {}'s drawdown is {} bps of the LINE, not of the anchor \
the roll just set",
week + 1,
DRAWDOWN_SCHEDULE[week as usize],
);
}
fixture.publish_base_price(6_500_000_000_000_000_000).await;
advance_past_expiry(&fixture).await;
let response = try_expire(&fixture).await.unwrap();
let closed = fixture.closed_event(&response);
assert!(matches!(closed.reason, SettlementReason::Expiry));
assert!(
closed.user_net != 0,
"the month's profit goes home with the user"
);
assert!(!closed.payout_parent.is_empty());
let settled = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<TicketSettled>(&response.tx_status.receipts)
.unwrap();
assert_eq!(settled.len(), 1);
assert_eq!(settled[0].ticket, TURBO_DEPOSIT);
assert_eq!(settled[0].to_platform, TURBO_DEPOSIT);
}
#[tokio::test]
async fn the_first_week_liquidates_at_the_sold_drawdown() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
let quantity = 1_000_000_000u64;
let cost = 2_000_000u64;
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();
fixture.publish_base_price(1_800_000_000_000_000_000).await;
let lines = fixture.lines().await;
assert_eq!(lines.threshold, U256::from(9_868_000u64));
assert_eq!(fixture.session_value().await, 9_800_000);
assert!(fixture.is_liquidatable().await);
let response = fixture.liquidate().await;
let closed = fixture.closed_event(&response);
assert!(matches!(closed.reason, SettlementReason::Liquidation));
let settled = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<TicketSettled>(&response.tx_status.receipts)
.unwrap();
assert_eq!(settled.len(), 1);
assert_eq!(settled[0].ticket, 250_000);
assert_eq!(settled[0].absorbed_by_pool, 218_000);
assert_eq!(settled[0].to_platform, 32_000);
assert_eq!(closed.bad_debt, 0);
assert!(closed.payout_parent.is_empty());
assert_eq!(closed.user_net, 0);
let seized = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<SessionSeized>(&response.tx_status.receipts)
.unwrap();
assert_eq!(seized.len(), 1);
assert_eq!(
seized[0].debt,
vec![(fixture.collateral_asset, 1_782_000)],
"the shortfall must not re-bill the ticket the pool already holds"
);
assert!(
!seized[0]
.transferred
.iter()
.any(|(asset, _)| *asset == fixture.collateral_asset),
"no session quote remains to route: {:?}",
seized[0].transferred
);
}
#[tokio::test]
async fn every_price_point_forfeits_its_own_deposit() {
let fixture = PropFixture::new().await;
fixture.close_session().await;
for (tier_id, line) in [(8u64, 5_000_000u64), (9u64, 2_500_000u64)] {
let deposit = line / 40;
let fee = line / 400;
let price = deposit + fee;
fixture
.deployment
.pool
.methods()
.publish_tier_v2(
tier_id,
turbo_tier_params_for(line),
turbo_terms(DRAWDOWN_SCHEDULE),
vec![fixture.order_book.contract_id],
)
.with_contract_ids(&[
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.call()
.await
.unwrap();
let platform_before = fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let parent_before = fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
fixture
.account
.methods()
.start_session(tier_id, price, ProlongPeriod::Week)
.call_params(CallParameters::new(
price,
fixture.collateral_asset,
u64::MAX,
))
.unwrap()
.with_contracts(&[
&fixture.deployment.oracle,
&fixture.deployment.pool,
&fixture.deployment.registry,
])
.with_variable_output_policy(VariableOutputPolicy::Exactly(1))
.call()
.await
.unwrap();
let response = fixture.close_session().await;
let settled = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<TicketSettled>(&response.tx_status.receipts)
.unwrap();
assert_eq!(settled.len(), 1, "line {line}");
assert_eq!(settled[0].ticket, deposit, "line {line}");
assert_eq!(settled[0].to_platform, deposit, "line {line}");
assert_eq!(
fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
platform_before + price as u128,
"line {line}: the platform's whole take is the price"
);
assert_eq!(
fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
parent_before - price as u128,
"line {line}: a break-even user is out exactly the price"
);
}
}
#[tokio::test]
async fn profits_withdraw_only_once_positions_are_closed() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
let banked = 100_000u64;
fixture
.user
.force_transfer_to_contract(
fixture.child_id,
banked,
fixture.collateral_asset,
TxPolicies::default(),
)
.await
.unwrap();
let quantity = 500_000_000u64;
let cost = 1_000_000u64;
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();
let withdrawable = banked;
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, withdrawable)),
);
let error = fixture
.call_account(vec![withdraw.clone()], 2)
.await
.unwrap_err();
assert!(
error.to_string().contains("QuoteOutstanding"),
"an open position blocks the withdrawal: {error:#}"
);
fixture
.rest_order(2_000_000, quantity, fixture.collateral_asset, cost)
.await;
let sell = fixture.book_call(2_000_000, quantity, fixture.base_asset, quantity);
let return_quote = fixture.pool_call(
fn_selector!(return_quote()),
cost,
fixture.collateral_asset,
None,
);
fixture
.call_account(vec![sell, fixture.settle_book_call(), return_quote], 2)
.await
.unwrap();
let parent_before = fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
fixture.call_account(vec![withdraw], 2).await.unwrap();
assert_eq!(
fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
parent_before + withdrawable as u128,
"flat, the profit is the user's immediately"
);
}
#[tokio::test]
async fn a_rollover_keeps_the_users_resting_orders() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
let quantity = 1_000_000_000u64;
let cost = 2_000_000u64;
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();
fixture.publish_base_price(4_000_000_000_000_000_000).await;
let resting = quantity / 2;
let sell = fixture.book_call(8_000_000, resting, fixture.base_asset, resting);
fixture.call_account(vec![sell], 1).await.unwrap();
let (base_on_book, _) = fixture
.order_book
.order_book
.methods()
.get_total_balance_of(Identity::ContractId(fixture.child_id))
.simulate(Execution::state_read_only())
.await
.unwrap()
.value;
assert_eq!(base_on_book, resting);
let session_before = fixture.session().await.session_id;
advance_past_expiry(&fixture).await;
try_roll(&fixture).await.unwrap();
let (base_after, _) = fixture
.order_book
.order_book
.methods()
.get_total_balance_of(Identity::ContractId(fixture.child_id))
.simulate(Execution::state_read_only())
.await
.unwrap()
.value;
assert_eq!(
base_after, resting,
"a session that keeps its seat keeps its book"
);
assert_eq!(
fixture.session().await.session_id,
session_before,
"same session, alive"
);
}
#[tokio::test]
async fn a_repurchase_discount_prices_the_whole_prepaid_ticket_down() {
let fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
fixture.close_session().await;
let manager = Identity::Address(fixture.deployer.address());
fixture
.deployment
.pool
.methods()
.grant_role(DISCOUNT_MANAGER_ROLE, manager)
.call()
.await
.unwrap();
fixture
.deployment
.pool
.methods()
.set_user_discount(
Identity::Address(fixture.user.address()),
None,
Some(UserDiscount {
discount_bps: 2_000,
scope: DiscountScope::FirstPremium,
}),
)
.call()
.await
.unwrap();
let discounted_fee = TURBO_FEE * 8 / 10;
let discounted_deposit = TURBO_DEPOSIT * 8 / 10;
let opened =
start_turbo_session_paying(&fixture, discounted_deposit + discounted_fee).await;
assert_eq!(
opened.open_fee, discounted_fee,
"20% off the entry fee, reported as charged"
);
assert_eq!(opened.fees_accrued, 0, "the fee was bought, not owed");
assert_eq!(
opened.credit_line, TURBO_LINE,
"a discounted ticket buys the tier's full line"
);
let rollover = fixture
.deployment
.pool
.methods()
.get_rollover(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
.expect("a rollover-bearing tier anchors at open");
assert_eq!(
rollover.anchor, TURBO_LINE,
"and the bar he must clear is unmoved by the discount"
);
fixture
.deployer
.force_transfer_to_contract(
fixture.child_id,
TURBO_DEPOSIT + 100_000,
fixture.collateral_asset,
TxPolicies::default(),
)
.await
.unwrap();
let response = fixture.close_session().await;
let settled = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<TicketSettled>(&response.tx_status.receipts)
.unwrap();
assert_eq!(settled.len(), 1);
assert_eq!(
settled[0].ticket, discounted_deposit,
"the discounted ticket is what the pool holds and settles"
);
assert_eq!(settled[0].to_platform, discounted_deposit);
}
#[tokio::test]
async fn withdrawing_an_asset_lowers_the_anchor_at_the_oracle_bid() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
let held = 500_000_000u64;
fixture
.user
.force_transfer_to_contract(
fixture.child_id,
held,
fixture.base_asset,
TxPolicies::default(),
)
.await
.unwrap();
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, held)),
);
let response = fixture.call_account(vec![withdraw], 2).await.unwrap();
let recorded = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<RolloverWithdrawalRecorded>(&response.tx_status.receipts)
.unwrap();
assert_eq!(recorded.len(), 1);
assert_eq!(recorded[0].amount, 1_000_000);
assert_eq!(recorded[0].withdrawn_total, 1_000_000);
let view = rollover_view(&fixture).await;
assert_eq!(view.anchor, LINE);
assert_eq!(view.withdrawn, 1_000_000);
}
#[tokio::test]
async fn a_turbo_account_takes_its_price_and_never_a_unit_more() {
let fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
fixture.close_session().await;
let error = fixture
.account
.methods()
.start_session(TURBO_TIER_ID, TURBO_ENTRY + 1, ProlongPeriod::Week)
.call_params(CallParameters::new(
TURBO_ENTRY + 1,
fixture.collateral_asset,
u64::MAX,
))
.unwrap()
.with_contracts(&[
&fixture.deployment.oracle,
&fixture.deployment.pool,
&fixture.deployment.registry,
])
.with_variable_output_policy(VariableOutputPolicy::Exactly(1))
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("TurboEntryNotExact"),
"a turbo entry over its price is refused: {error:#}"
);
let opened = start_turbo_session_paying(&fixture, TURBO_ENTRY).await;
assert_eq!(opened.collateral, 0, "the purchase posts nothing");
assert_eq!(opened.open_fee, TURBO_FEE);
assert_eq!(opened.fees_accrued, 0, "the fee was bought, not owed");
assert_eq!(fixture.session().await.fees_accrued, 0);
fixture
.deployment
.pool
.clone()
.with_account(fixture.user.clone())
.methods()
.add_collateral(fixture.child_id)
.call_params(CallParameters::new(
1_000,
fixture.collateral_asset,
u64::MAX,
))
.unwrap()
.call()
.await
.expect("a top-up is no longer refused — the bar nets outside money");
fixture
.deployment
.pool
.clone()
.with_account(fixture.outsider.clone())
.methods()
.add_collateral(fixture.child_id)
.call_params(CallParameters::new(
1_000,
fixture.collateral_asset,
u64::MAX,
))
.unwrap()
.call()
.await
.expect("a gift funds the line like any other top-up");
assert_eq!(fixture.session().await.collateral, 1_000 + 1_000);
}
async fn earn_and_bank_a_week(fixture: &mut PropFixture) -> u64 {
let quantity = 1_000_000_000u64;
let cost = 2_000_000u64;
fixture.publish_base_price(2_000_000_000_000_000_000).await;
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();
fixture.publish_base_price(4_000_000_000_000_000_000).await;
fixture
.rest_order(4_000_000, quantity, fixture.collateral_asset, 4_000_000)
.await;
let sell = fixture.book_call(4_000_000, quantity, fixture.base_asset, quantity);
let return_quote = fixture.pool_call(
fn_selector!(return_quote()),
cost,
fixture.collateral_asset,
None,
);
fixture
.call_account(vec![sell, fixture.settle_book_call(), return_quote], 2)
.await
.unwrap();
let banked = fixture.account_balance(fixture.collateral_asset).await
- fixture.session().await.fees_accrued;
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, banked)),
);
fixture.call_account(vec![withdraw], 2).await.unwrap();
banked
}
#[tokio::test]
async fn the_whole_life_of_a_turbo_account_good_weeks_and_a_bad_one() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
let wallet_after_open = fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let mut total_banked = 0u64;
let mut week = 0usize;
while week < 3 {
let banked = earn_and_bank_a_week(&mut fixture).await;
total_banked += banked;
advance_past_expiry(&fixture).await;
let response = try_roll(&fixture).await.unwrap();
let rolled = response
.decode_logs_with_type::<SessionRolledOver>()
.unwrap();
assert_eq!(rolled.len(), 1);
assert_eq!(
rolled[0].rollovers_used,
week as u64 + 1,
"free week number {}",
week + 1
);
let anchor = rolled[0].anchor;
let allowance = LINE * DRAWDOWN_SCHEDULE[week + 1] / 10_000;
fixture.publish_base_price(2_000_000_000_000_000_000).await;
assert_eq!(
fixture.lines().await.threshold,
U256::from(LINE - allowance),
"week {}'s drawdown is {} bps of the line, and the anchor at \
{anchor} does not move it",
week + 2,
DRAWDOWN_SCHEDULE[week + 1],
);
refresh_session_key(&fixture).await;
week += 1;
}
assert_eq!(
rollover_view(&fixture).await.rollovers_used,
3,
"three free weeks earned and consumed"
);
assert_eq!(
fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
wallet_after_open + total_banked as u128,
"every banked week is in the wallet"
);
let quantity = 1_000_000_000u64;
let cost = 2_000_000u64;
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();
fixture.publish_base_price(1_600_000_000_000_000_000).await;
assert!(fixture.is_liquidatable().await);
advance_past_expiry(&fixture).await;
let bad_debt_before = fixture.bad_debt().await;
let wallet_before_liq = fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let closed = fixture.closed_event(&fixture.liquidate().await);
assert!(matches!(closed.reason, SettlementReason::Liquidation));
assert!(
closed.profit_is_negative,
"the session's own result is a loss"
);
assert!(
closed.bad_debt != 0,
"a loss past the stake must land as bad debt"
);
assert_eq!(
fixture.bad_debt().await,
bad_debt_before + closed.bad_debt,
"the shortfall is booked, once"
);
assert_eq!(
closed.user_net, 0,
"a liquidated session pays the user nothing"
);
assert!(closed.payout_parent.is_empty());
assert_eq!(
fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
wallet_before_liq,
"the bad week takes nothing further from the user's banked profit"
);
assert!(
fixture
.deployment
.pool
.methods()
.get_session(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
.is_none()
);
assert!(
fixture
.deployment
.pool
.methods()
.get_rollover(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
.is_none()
);
let final_wallet = fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
assert_eq!(
final_wallet,
wallet_after_open + total_banked as u128,
"the bad week moved nothing in the wallet; the three good weeks stand"
);
assert!(
total_banked > TURBO_ENTRY,
"the banked profit dwarfs the one forfeited stake"
);
}
#[tokio::test]
async fn the_cohort_boundary_refuses_each_product_the_others_machinery() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
let error = try_expire(&fixture).await.unwrap_err();
assert!(
error.to_string().contains("UsePermissionlessExpiry"),
"a legacy deadline is settled by `expire_session`: {error:#}"
);
open_turbo_session(&fixture).await;
let arm = fixture.pool_call(
fn_selector!(set_auto_prolong(Option<ProlongPeriod>)),
0,
AssetId::default(),
Some(call_data!(Some(ProlongPeriod::Day))),
);
let error = fixture.call_account(vec![arm], 0).await.unwrap_err();
assert!(
error.to_string().contains("PeriodNotOffered"),
"a prepaid tier prices no period to arm: {error:#}"
);
advance_past_expiry(&fixture).await;
let error = try_legacy_expire(&fixture, fixture.deployer.clone())
.await
.unwrap_err();
assert!(
error.to_string().contains("UseExpireOrRollover"),
"a prepaid deadline is settled by `expire_or_rollover`: {error:#}"
);
let error = try_legacy_expire(&fixture, fixture.outsider.clone())
.await
.unwrap_err();
assert!(
error.to_string().contains("UseExpireOrRollover"),
"the refusal is by cohort, not by caller: {error:#}"
);
assert!(
fixture.session_opt().await.is_some(),
"a refused deadline leaves the session untouched"
);
}
#[tokio::test]
async fn the_prepaid_deadline_takes_only_a_figure_its_own_cosigner_signed() {
let fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
advance_past_expiry(&fixture).await;
let forged = expiry_cosignature(&fixture, 0, false, fixture.outsider.clone()).await;
let error = settle_with(&fixture, 0, false, forged, None)
.await
.unwrap_err();
assert!(
error.to_string().contains("InvalidExpiryCosigner"),
"only the pool's own cosigner can settle a prepaid deadline: {error:#}"
);
let for_another_figure =
expiry_cosignature(&fixture, 900_000, false, fixture.deployer.clone()).await;
let error = settle_with(&fixture, 0, false, for_another_figure, None)
.await
.unwrap_err();
assert!(
error.to_string().contains("InvalidExpiryCosigner"),
"the signature pins the figure it authorised: {error:#}"
);
assert!(fixture.session_opt().await.is_some());
let signed = expiry_cosignature(&fixture, 0, false, fixture.deployer.clone()).await;
let response = fixture
.deployment
.pool
.clone()
.with_account(fixture.outsider.clone())
.methods()
.expire_or_rollover(fixture.child_id, vec![], 0, false, signed)
.with_contracts(&[
&fixture.account,
&fixture.deployment.oracle,
&fixture.order_book.order_book,
&fixture.deployment.price_feed,
])
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.call()
.await
.unwrap();
assert!(matches!(
fixture.closed_event(&response).reason,
SettlementReason::Expiry
));
}
#[tokio::test]
async fn an_unarmed_pool_settles_no_prepaid_deadline() {
let fixture = PropFixture::new().await;
publish_turbo_tier_unarmed(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
advance_past_expiry(&fixture).await;
assert!(
fixture
.deployment
.pool
.methods()
.expiry_cosigner()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
.is_none(),
"the pool starts with no expiry cosigner"
);
let error = try_expire(&fixture).await.unwrap_err();
assert!(
error.to_string().contains("ExpiryCosignerNotSet"),
"an unarmed pool refuses rather than accepting anyone: {error:#}"
);
assert!(fixture.session_opt().await.is_some());
fixture
.deployment
.pool
.methods()
.set_expiry_cosigner(fixture.deployer.address())
.call()
.await
.unwrap();
let response = try_expire(&fixture).await.unwrap();
assert!(matches!(
fixture.closed_event(&response).reason,
SettlementReason::Expiry
));
}
#[tokio::test]
async fn the_pool_keeps_its_own_expiry_cosigner_not_the_oracles() {
let fixture = PropFixture::new().await;
publish_turbo_tier_unarmed(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
advance_past_expiry(&fixture).await;
fixture
.deployment
.pool
.methods()
.set_expiry_cosigner(fixture.outsider.address())
.call()
.await
.unwrap();
assert_eq!(
fixture
.deployment
.pool
.methods()
.expiry_cosigner()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(fixture.outsider.address())
);
let error = try_expire(&fixture).await.unwrap_err();
assert!(
error.to_string().contains("InvalidExpiryCosigner"),
"the pool does not defer to the oracle's cosigner: {error:#}"
);
let signed = expiry_cosignature(&fixture, 0, false, fixture.outsider.clone()).await;
let response = settle_with(&fixture, 0, false, signed, None).await.unwrap();
assert!(matches!(
fixture.closed_event(&response).reason,
SettlementReason::Expiry
));
}
#[tokio::test]
async fn only_governance_arms_the_expiry_cosigner() {
let fixture = PropFixture::new().await;
let error = fixture
.deployment
.pool
.clone()
.with_account(fixture.outsider.clone())
.methods()
.set_expiry_cosigner(fixture.outsider.address())
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("AccessControlNotAuthorized"),
"arming the cosigner is governance's alone: {error:#}"
);
}
#[tokio::test]
async fn a_transferred_gift_does_not_buy_the_free_week() {
let fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
let value_before = fixture.session_value().await;
fixture
.outsider
.force_transfer_to_contract(
fixture.child_id,
ROLLOVER_BAR,
fixture.collateral_asset,
TxPolicies::default(),
)
.await
.unwrap();
assert_eq!(
fixture.session_value().await,
value_before + ROLLOVER_BAR as i128,
"the pool values the gift exactly as it would value a profit"
);
advance_past_expiry(&fixture).await;
let error = try_settle(&fixture, ROLLOVER_BAR, true).await.unwrap_err();
assert!(
error.to_string().contains("RolloverNotEarned"),
"a declared gift is netted off the bar: {error:#}"
);
try_roll(&fixture).await.unwrap();
}
#[tokio::test]
async fn a_gift_alongside_real_profit_does_not_cost_the_earned_week() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
let quantity = 1_000_000_000u64;
let cost = 2_000_000u64;
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();
fixture
.publish_base_price(4_000_000_000_000_000_000u128)
.await;
let gift = 300_000u64;
fixture
.outsider
.force_transfer_to_contract(
fixture.child_id,
gift,
fixture.collateral_asset,
TxPolicies::default(),
)
.await
.unwrap();
advance_past_expiry(&fixture).await;
let response = try_settle(&fixture, gift, true).await.unwrap();
let rolled = response
.decode_logs_with_type::<SessionRolledOver>()
.unwrap();
assert_eq!(rolled.len(), 1, "the earned week survives the netting");
assert_eq!(rolled[0].rollovers_used, 1);
}
#[tokio::test]
async fn a_gift_landing_after_the_keeper_signed_cannot_turn_an_absorb_into_a_roll() {
let fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
advance_past_expiry(&fixture).await;
let signature =
expiry_cosignature(&fixture, 0, false, fixture.deployer.clone()).await;
fixture
.outsider
.force_transfer_to_contract(
fixture.child_id,
ROLLOVER_BAR,
fixture.collateral_asset,
TxPolicies::default(),
)
.await
.unwrap();
let response = settle_with(&fixture, 0, false, signature, None)
.await
.unwrap();
assert!(matches!(
fixture.closed_event(&response).reason,
SettlementReason::Expiry
));
assert!(fixture.session_opt().await.is_none());
}
#[tokio::test]
async fn a_cosigned_decision_cannot_be_flipped() {
let fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
advance_past_expiry(&fixture).await;
let for_absorb =
expiry_cosignature(&fixture, 0, false, fixture.deployer.clone()).await;
let error = settle_with(&fixture, 0, true, for_absorb, None)
.await
.unwrap_err();
assert!(
error.to_string().contains("InvalidExpiryCosigner"),
"an absorb signature does not authorise a roll: {error:#}"
);
let for_roll = expiry_cosignature(&fixture, 0, true, fixture.deployer.clone()).await;
let error = settle_with(&fixture, 0, false, for_roll, None)
.await
.unwrap_err();
assert!(
error.to_string().contains("InvalidExpiryCosigner"),
"a roll signature does not authorise an absorb: {error:#}"
);
assert!(fixture.session_opt().await.is_some());
}
#[tokio::test]
async fn a_roll_the_pool_disagrees_with_is_refused_rather_than_absorbed() {
let fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
advance_past_expiry(&fixture).await;
let error = try_roll(&fixture).await.unwrap_err();
assert!(
error.to_string().contains("RolloverNotEarned"),
"the pool keeps its veto over granting a week: {error:#}"
);
assert!(fixture.session_opt().await.is_some());
let response = try_expire(&fixture).await.unwrap();
assert!(matches!(
fixture.closed_event(&response).reason,
SettlementReason::Expiry
));
}
#[tokio::test]
async fn a_top_up_reaches_the_bar_like_any_other_outside_money() {
let fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
let value_before = fixture.session_value().await;
fixture.fund_collateral(ROLLOVER_BAR).await;
assert_eq!(
fixture.session_value().await,
value_before + ROLLOVER_BAR as i128,
"a top-up moves the session's value as much as a transfer does"
);
advance_past_expiry(&fixture).await;
let error = try_settle(&fixture, ROLLOVER_BAR, true).await.unwrap_err();
assert!(
error.to_string().contains("RolloverNotEarned"),
"an outsider's top-up is outside money too: {error:#}"
);
}
#[tokio::test]
async fn cycling_the_same_coins_through_the_account_pumps_nothing() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
let cycle = 400_000u64;
let mut donated = 0u64;
for _ in 0..3 {
fixture
.outsider
.force_transfer_to_contract(
fixture.child_id,
cycle,
fixture.collateral_asset,
TxPolicies::default(),
)
.await
.unwrap();
donated += cycle;
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, cycle)),
);
fixture.call_account(vec![withdraw], 2).await.unwrap();
}
advance_past_expiry(&fixture).await;
let error = try_settle(&fixture, donated, true).await.unwrap_err();
assert!(
error.to_string().contains("RolloverNotEarned"),
"a round trip nets to nothing, however many times it runs: {error:#}"
);
}
#[tokio::test]
async fn a_gift_does_not_move_the_liquidation_floor() {
let fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
assert_eq!(fixture.lines().await.threshold, U256::from(9_850_000u64));
fixture
.outsider
.force_transfer_to_contract(
fixture.child_id,
1_000_000,
fixture.collateral_asset,
TxPolicies::default(),
)
.await
.unwrap();
assert_eq!(
fixture.lines().await.threshold,
U256::from(9_850_000u64),
"the floor is the tier's, not the balance's"
);
}
#[tokio::test]
async fn a_settled_deadline_cannot_be_settled_again() {
let fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
advance_past_expiry(&fixture).await;
let second = expiry_cosignature(&fixture, 0, false, fixture.deployer.clone()).await;
try_expire(&fixture).await.unwrap();
assert!(fixture.session_opt().await.is_none());
let error = settle_with(&fixture, 0, false, second, None)
.await
.unwrap_err();
assert!(
error.to_string().contains("NoActiveSession"),
"a spent deadline has nothing left to settle: {error:#}"
);
}
#[tokio::test]
async fn a_cosigned_settlement_cannot_act_before_the_deadline() {
let fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
let error = try_expire(&fixture).await.unwrap_err();
assert!(
error.to_string().contains("NotExpired"),
"a live session cannot be absorbed on anyone's say-so: {error:#}"
);
fixture.fund_collateral(2_000_000).await;
let error = try_roll(&fixture).await.unwrap_err();
assert!(
error.to_string().contains("RolloverNotEarned"),
"a week cannot be claimed before the term runs out: {error:#}"
);
let session = fixture.session().await;
assert_eq!(session.collateral, 2_000_000);
assert_eq!(
fixture
.deployment
.pool
.methods()
.get_rollover(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
.expect("still anchored")
.rollovers_used,
0,
"no week was spent"
);
}
#[tokio::test]
async fn banking_profit_does_not_widen_the_sold_drawdown() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
assert_eq!(fixture.lines().await.threshold, U256::from(9_850_000u64));
let banked = earn_and_bank_a_week(&mut fixture).await;
assert!(banked > 0);
fixture.publish_base_price(2_000_000_000_000_000_000).await;
let view = rollover_view(&fixture).await;
assert_eq!(view.anchor, LINE);
assert_eq!(view.withdrawn, banked);
assert_eq!(fixture.lines().await.threshold, U256::from(9_850_000u64));
let quantity = 1_000_000_000u64;
let cost = 2_000_000u64;
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();
fixture.publish_base_price(1_800_000_000_000_000_000).await;
assert!(fixture.is_liquidatable().await);
}
#[tokio::test]
async fn a_discounted_users_profit_withdraws_and_its_ticket_still_does_not() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
let manager = Identity::Address(fixture.deployer.address());
fixture
.deployment
.pool
.methods()
.grant_role(DISCOUNT_MANAGER_ROLE, manager)
.call()
.await
.unwrap();
fixture
.deployment
.pool
.methods()
.set_user_discount(
Identity::Address(fixture.user.address()),
None,
Some(UserDiscount {
discount_bps: 5_000,
scope: DiscountScope::FirstPremium,
}),
)
.call()
.await
.unwrap();
fixture.close_session().await;
let entry = (TURBO_DEPOSIT + TURBO_FEE) / 2;
start_turbo_session_paying(&fixture, entry).await;
let profit = 100_000u64;
fixture
.deployer
.force_transfer_to_contract(
fixture.child_id,
profit,
fixture.collateral_asset,
TxPolicies::default(),
)
.await
.unwrap();
let wallet_before = fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, profit)),
);
fixture
.call_account(vec![withdraw], 2)
.await
.expect("a discounted session's profit is the user's to take");
assert_eq!(
fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
wallet_before + u128::from(profit),
"the whole profit reached the wallet"
);
let over = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, 1_000u64)),
);
let error = fixture.call_account(vec![over], 2).await.unwrap_err();
assert!(
error.to_string().contains("BelowTierCollateral"),
"the discounted ticket is no more withdrawable than a full-price \
one: {error:#}"
);
}
#[tokio::test]
async fn a_prepaid_session_holding_a_position_forfeits_its_ticket_at_the_deadline() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
open_turbo_session(&fixture).await;
let price = 2_000_000u64;
let cost = 200_000u64;
let quantity = cost * 1_000_000_000 / price;
fixture
.rest_order(price, quantity, fixture.base_asset, quantity)
.await;
let draw = fixture.draw_call(cost);
let buy = fixture.book_call(price, quantity, fixture.collateral_asset, cost);
fixture
.call_account(vec![draw, buy, fixture.settle_book_call()], 2)
.await
.unwrap();
fixture
.publish_base_price(8_000_000_000_000_000_000u128)
.await;
assert_eq!(
fixture.session().await.drawn_quote,
cost,
"the draw cannot be cleared: no cash, and the ticket is not nettable"
);
let parent_base_before = fixture
.user
.get_asset_balance(&fixture.base_asset)
.await
.unwrap();
advance_past_expiry(&fixture).await;
let response = try_expire(&fixture).await.unwrap();
let settled = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<TicketSettled>(&response.tx_status.receipts)
.unwrap();
assert_eq!(settled.len(), 1);
assert_eq!(
settled[0].to_platform, TURBO_DEPOSIT,
"the entry is non-refundable however the session ends"
);
let base_paid = fixture
.user
.get_asset_balance(&fixture.base_asset)
.await
.unwrap()
- parent_base_before;
assert!(
base_paid > 0,
"the winnings are the user's: {:?}",
fixture.closed_event(&response)
);
}
#[tokio::test]
async fn a_fully_forgiven_prepaid_entry_cannot_be_opened() {
let fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
let manager = Identity::Address(fixture.deployer.address());
fixture
.deployment
.pool
.methods()
.grant_role(DISCOUNT_MANAGER_ROLE, manager)
.call()
.await
.unwrap();
fixture
.deployment
.pool
.methods()
.set_user_discount(
Identity::Address(fixture.user.address()),
None,
Some(UserDiscount {
discount_bps: 10_000,
scope: DiscountScope::FirstPremium,
}),
)
.call()
.await
.expect("the pool admits a fully forgiven premium");
fixture.close_session().await;
let error = fixture
.account
.methods()
.start_session(TURBO_TIER_ID, 0, ProlongPeriod::Week)
.call_params(CallParameters::new(0, fixture.collateral_asset, u64::MAX))
.unwrap()
.with_contracts(&[
&fixture.deployment.oracle,
&fixture.deployment.pool,
&fixture.deployment.registry,
])
.with_variable_output_policy(VariableOutputPolicy::Exactly(1))
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("AmountIsZero"),
"a zero entry forwards no coins at all: {error:#}"
);
let error = fixture
.account
.methods()
.start_session(TURBO_TIER_ID, 1, ProlongPeriod::Week)
.call_params(CallParameters::new(1, fixture.collateral_asset, u64::MAX))
.unwrap()
.with_contracts(&[
&fixture.deployment.oracle,
&fixture.deployment.pool,
&fixture.deployment.registry,
&fixture.deployment.price_feed,
])
.with_variable_output_policy(VariableOutputPolicy::Exactly(1))
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("TurboEntryNotExact"),
"a prepaid entry must equal its price exactly: {error:#}"
);
}
#[tokio::test]
async fn a_discounted_session_cannot_walk_its_ticket_out_through_the_netting() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
let manager = Identity::Address(fixture.deployer.address());
fixture
.deployment
.pool
.methods()
.grant_role(DISCOUNT_MANAGER_ROLE, manager)
.call()
.await
.unwrap();
fixture
.deployment
.pool
.methods()
.set_user_discount(
Identity::Address(fixture.user.address()),
None,
Some(UserDiscount {
discount_bps: 5_000,
scope: DiscountScope::FirstPremium,
}),
)
.call()
.await
.unwrap();
fixture.close_session().await;
let entry = (TURBO_DEPOSIT + TURBO_FEE) / 2;
let opened = start_turbo_session_paying(&fixture, entry).await;
assert_eq!(
opened.credit_line, TURBO_LINE,
"a discounted ticket still buys the whole line"
);
let deposit_paid = TURBO_DEPOSIT / 2;
let draw = fixture.draw_call(deposit_paid);
fixture.call_account(vec![draw], 1).await.unwrap();
let repay = fixture.repay_from_collateral_call(deposit_paid);
let error = fixture.call_account(vec![repay], 0).await.unwrap_err();
let refusal = format!("{error:?}");
assert!(
refusal.contains("INSUFFICIENT_BALANCE")
|| refusal.contains("InsufficientBalance"),
"there is no collateral to net at any discount: {refusal}"
);
let session = fixture.session().await;
assert_eq!(session.collateral, 0, "the refused netting moved nothing");
assert_eq!(session.drawn_quote, deposit_paid);
}
#[tokio::test]
async fn every_discount_rate_opens_exactly_on_the_floor_with_the_whole_line() {
let mut fixture = PropFixture::new().await;
publish_turbo_tier(&fixture, turbo_terms(DRAWDOWN_SCHEDULE)).await;
let manager = Identity::Address(fixture.deployer.address());
fixture
.deployment
.pool
.methods()
.grant_role(DISCOUNT_MANAGER_ROLE, manager)
.call()
.await
.unwrap();
fixture.close_session().await;
for discount_bps in [0u64, 2_000, 5_000, 9_000, 9_900] {
fixture
.deployment
.pool
.methods()
.set_user_discount(
Identity::Address(fixture.user.address()),
None,
Some(UserDiscount {
discount_bps,
scope: DiscountScope::AllPremiums,
}),
)
.call()
.await
.unwrap();
let rate = 10_000 - discount_bps;
let deposit = TURBO_DEPOSIT * rate / 10_000;
let fee = TURBO_FEE * rate / 10_000;
let opened = start_turbo_session_paying(&fixture, deposit + fee).await;
assert_eq!(
opened.credit_line, TURBO_LINE,
"{discount_bps} bps off must still buy the tier's whole line"
);
assert_eq!(
opened.collateral, 0,
"{discount_bps} bps: the purchase posts nothing at any rate"
);
let over = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, 1u64)),
);
let error = fixture.call_account(vec![over], 2).await.unwrap_err();
let refusal = format!("{error:?}");
assert!(
refusal.contains("BELOW_TIER_COLLATERAL")
|| refusal.contains("BelowTierCollateral")
|| refusal.contains("InsufficientBalance"),
"{discount_bps} bps: the ticket must be stuck: {refusal}"
);
let profit = 50_000u64;
fixture
.deployer
.force_transfer_to_contract(
fixture.child_id,
profit,
fixture.collateral_asset,
TxPolicies::default(),
)
.await
.unwrap();
let take = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, profit)),
);
fixture
.call_account(vec![take], 2)
.await
.unwrap_or_else(|error| {
panic!("{discount_bps} bps: profit must be withdrawable: {error:#}")
});
fixture.close_session().await;
}
}