use super::*;
const TEST_MAX_OFFCHAIN_AGE_SECONDS: u64 = 10_000_000_000;
const PUBLISH_CLOCK_LAG_SECONDS: u64 = 1_000_000;
use crate::{
call_data,
fn_selector,
order_book_deploy::{
OrderArgs,
OrderBookConfigurables,
OrderBookDeploy,
OrderBookDeployConfig,
OrderType,
},
parallel_nonce::{
build_parallel_nonce,
generate_parallel_session_signing_payload,
},
prop::{
BaseDebtRepaidFromCollateral,
CallContractArg,
CallParams,
DiscountScope,
MarginContractCallEvent,
MarginPoolLiquidatorChanged,
MarginWithdrawn,
ParallelMultiCallContractArgs,
ParallelSessionArgs,
PriceInput,
ProlongPeriod,
ProlongTrigger,
PropAccountContract,
PropAccountOracleProxyContract,
PropAccountProxyContract,
QuoteRepaidFromCollateral,
Secp256k1,
SessionClosed,
SessionOpened,
SessionProlonged,
SessionSeized,
SettlementReason,
Signature,
State,
TierParams,
TierVersionPublished,
Time,
UserDiscount,
UserDiscountChanged,
UserDiscountsRemoved,
},
trial_trade_account::generate_trial_trade_approval_signing_payload,
};
use fuels::{
programs::responses::CallResponse,
test_helpers::{
AssetConfig,
ChainConfig,
DbType,
NodeConfig,
WalletsConfig,
launch_custom_provider_and_get_wallets,
},
types::U256,
};
use futures::TryStreamExt;
use std::time::{
SystemTime,
UNIX_EPOCH,
};
const TIER_ID: u64 = 1;
const DISCOUNT_MANAGER_ROLE: u64 = 1;
const COLLATERAL: u64 = 2_000_000;
const LINE: u64 = 10_000_000;
struct PropFixture {
_node_db: Option<tempfile::TempDir>,
collateral_asset: AssetId,
base_asset: AssetId,
deployer: Wallet,
user: Wallet,
outsider: Wallet,
deployment: PropDeployment<Wallet>,
order_book: OrderBookDeploy<Wallet>,
child_id: ContractId,
account: PropAccountContract<Wallet>,
nonce_position: u8,
publish_clock: std::sync::atomic::AtomicU64,
}
impl PropFixture {
async fn new() -> Self {
Self::new_with_node_config(None, None).await
}
async fn new_with_historical_storage() -> Self {
let node_db = tempfile::tempdir().unwrap();
let database_type = DbType::RocksDb(Some(node_db.path().to_path_buf()));
Self::new_with_node_config(
Some(NodeConfig {
database_type,
historical_execution: true,
..NodeConfig::default()
}),
Some(node_db),
)
.await
}
async fn new_with_node_config(
node_config: Option<NodeConfig>,
node_db: Option<tempfile::TempDir>,
) -> Self {
let collateral_asset = AssetId::new([0x31; 32]);
let base_asset = AssetId::new([0x32; 32]);
let initial_balance = 100_000_000_000u64;
let mut wallets = launch_custom_provider_and_get_wallets(
WalletsConfig::new_multiple_assets(
3,
vec![
AssetConfig {
id: AssetId::default(),
num_coins: 4,
coin_amount: initial_balance,
},
AssetConfig {
id: collateral_asset,
num_coins: 4,
coin_amount: initial_balance,
},
AssetConfig {
id: base_asset,
num_coins: 4,
coin_amount: initial_balance,
},
],
),
node_config,
Some(ChainConfig::local_testnet()),
)
.await
.unwrap();
let outsider = wallets.pop().unwrap();
let user = wallets.pop().unwrap();
let deployer = wallets.pop().unwrap();
let mut config = PropDeployConfig::new(collateral_asset);
config.max_offchain_age_seconds = Some(TEST_MAX_OFFCHAIN_AGE_SECONDS);
let deployment = PropDeployment::deploy(&deployer, &config).await.unwrap();
let order_book_configurables = OrderBookConfigurables::default()
.with_MAKER_FEE(0u64.into())
.unwrap()
.with_TAKER_FEE(0u64.into())
.unwrap()
.with_MIN_ORDER(1)
.unwrap()
.with_DUST(0)
.unwrap();
let order_book = OrderBookDeploy::deploy(
&deployer,
base_asset,
collateral_asset,
&OrderBookDeployConfig::with_configurables(order_book_configurables),
)
.await
.unwrap();
deployment
.price_feed
.methods()
.set_asset_decimals(collateral_asset, 6)
.call()
.await
.unwrap();
deployment
.price_feed
.methods()
.set_asset_decimals(base_asset, 9)
.call()
.await
.unwrap();
let chain_now = deployer
.try_provider()
.unwrap()
.latest_block_time()
.await
.unwrap()
.map(|time| time.timestamp() as u64)
.unwrap_or(0);
let publish_clock =
std::sync::atomic::AtomicU64::new(chain_now - PUBLISH_CLOCK_LAG_SECONDS);
let first_publish =
publish_clock.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
deployment
.price_feed
.methods()
.publish_prices(vec![
PriceInput {
asset: collateral_asset,
bid: 1_000_000_000_000_000_000u64.into(),
ask: 1_000_000_000_000_000_000u64.into(),
timestamp: first_publish,
},
PriceInput {
asset: base_asset,
bid: 2_000_000_000_000_000_000u64.into(),
ask: 2_000_000_000_000_000_000u64.into(),
timestamp: first_publish,
},
])
.call()
.await
.unwrap();
let tier_params = TierParams {
line: LINE,
leverage: 5,
duration: 64_800,
maintenance_bps: 250,
open_buffer_bps: 375,
liq_price_factor: 9_900,
prolong_fee: [0, 6_000, 76_000, 738_000],
max_credit_line_bps: 20_000,
max_price_age: u64::MAX,
open_fee: 0,
profit_share_bps: 1_000,
price_band_bps: 1_000,
};
let tier_response = deployment
.pool
.methods()
.publish_tier_version(
TIER_ID,
tier_params.clone(),
vec![order_book.contract_id],
)
.with_contract_ids(&[order_book.contract_id, deployment.price_feed_id])
.call()
.await
.unwrap();
let tier_events = tier_response
.decode_logs_with_type::<TierVersionPublished>()
.unwrap();
assert_eq!(tier_events.len(), 1);
assert_eq!(tier_events[0].params, tier_params);
assert_eq!(tier_events[0].threshold_floor, 8_250_000);
assert_eq!(tier_events[0].base_repay_fee_ppm, config.base_repay_fee_ppm);
assert_eq!(
tier_events[0].auto_prolong_periods,
vec![ProlongPeriod::SixHours, ProlongPeriod::Day]
);
assert!(tier_events[0].timestamp.unix != 0);
for (asset, amount) in
[(collateral_asset, 50_000_000), (base_asset, 10_000_000_000)]
{
deployer
.force_transfer_to_contract(
deployment.pool.contract_id(),
amount,
asset,
TxPolicies::default(),
)
.await
.unwrap();
}
let parent = Identity::Address(user.address());
let child = deployment.deploy_account(&user, parent, 0).await.unwrap();
let child_id = child.contract_id();
let account = PropAccountContract::new(child_id, user.clone());
let opened_response = account
.methods()
.start_session(TIER_ID, COLLATERAL, ProlongPeriod::SixHours)
.call_params(CallParameters::new(COLLATERAL, collateral_asset, u64::MAX))
.unwrap()
.with_contract_ids(&[
deployment.oracle_id,
deployment.pool_id,
deployment.registry_id,
])
.call()
.await
.unwrap();
let opened_events = deployment
.pool
.log_decoder()
.decode_logs_with_type::<SessionOpened>(&opened_response.tx_status.receipts)
.unwrap();
assert_eq!(opened_events.len(), 1);
assert_eq!(opened_events[0].account, child_id);
assert_eq!(opened_events[0].parent, parent);
assert_eq!(
opened_events[0].expires_at,
opened_events[0].started_at + 86_400
);
assert_eq!(opened_events[0].open_fee, 0);
assert_eq!(opened_events[0].fees_accrued, 0);
assert!(opened_events[0].timestamp.unix != 0);
let expiry = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 86_400;
account
.methods()
.set_session(ParallelSessionArgs {
nonce: U256::zero(),
session_id: Identity::Address(user.address()),
expiry: Time { unix: expiry },
contract_ids: vec![],
})
.with_contract_ids(&[deployment.oracle_id])
.call()
.await
.unwrap();
Self {
_node_db: node_db,
collateral_asset,
base_asset,
deployer,
user,
outsider,
deployment,
order_book,
child_id,
account,
nonce_position: 0,
publish_clock,
}
}
fn pool_call(
&self,
function_selector: Vec<u8>,
coins: u64,
asset_id: AssetId,
call_data: Option<Vec<u8>>,
) -> CallContractArg {
CallContractArg {
contract_id: self.deployment.pool_id,
function_selector: Bytes(function_selector),
call_params: CallParams {
coins,
asset_id,
gas: 10_000_000,
},
call_data: call_data.map(Bytes),
}
}
async fn signed_calls(
&mut self,
call_contract_args: Vec<CallContractArg>,
) -> (Signature, Signature, ParallelMultiCallContractArgs) {
let wall_clock = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let chain = self
.user
.try_provider()
.unwrap()
.latest_block_time()
.await
.unwrap()
.map(|time| time.timestamp() as u64)
.unwrap_or(0);
let now = wall_clock.max(chain);
let nonce = build_parallel_nonce(0, now + 3_600, 0, self.nonce_position);
self.nonce_position += 1;
let calls = ParallelMultiCallContractArgs {
nonce,
call_contract_args,
};
let user_message = generate_parallel_session_signing_payload(
calls.nonce,
calls.call_contract_args.clone(),
);
let chain_id = self
.user
.provider()
.consensus_parameters()
.await
.unwrap()
.chain_id();
let cosigner_message = generate_trial_trade_approval_signing_payload(
*chain_id,
self.child_id,
calls.nonce,
calls.call_contract_args.clone(),
);
let user_signature = self.user.signer().sign(user_message).await.unwrap();
let cosigner_signature =
self.deployer.signer().sign(cosigner_message).await.unwrap();
(
Signature::Secp256k1(Secp256k1 {
bits: *user_signature,
}),
Signature::Secp256k1(Secp256k1 {
bits: *cosigner_signature,
}),
calls,
)
}
async fn call_account(
&mut self,
calls: Vec<CallContractArg>,
variable_outputs: usize,
) -> Result<CallResponse<()>> {
let (user_signature, cosigner_signature, calls) = self.signed_calls(calls).await;
Ok(self
.account
.methods()
.call_contracts(user_signature, cosigner_signature, calls)
.with_contracts(&[
&self.deployment.oracle,
&self.deployment.pool,
&self.deployment.price_feed,
&self.deployment.registry,
&self.order_book.order_book,
])
.with_variable_output_policy(VariableOutputPolicy::Exactly(variable_outputs))
.call()
.await?)
}
async fn session(&self) -> crate::prop::SessionView {
self.deployment
.pool
.methods()
.get_session(self.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
.unwrap()
}
async fn pool_balance(&self, asset: AssetId) -> u64 {
self.deployer
.try_provider()
.unwrap()
.get_contract_asset_balance(&self.deployment.pool_id, &asset)
.await
.unwrap()
}
async fn account_balance(&self, asset: AssetId) -> u64 {
self.deployer
.try_provider()
.unwrap()
.get_contract_asset_balance(&self.child_id, &asset)
.await
.unwrap()
}
async fn lines(&self) -> crate::prop::SessionLines {
self.deployment
.pool
.methods()
.get_session_lines(self.child_id)
.with_contract_ids(&[
self.child_id,
self.order_book.contract_id,
self.deployment.price_feed_id,
])
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
}
async fn session_value(&self) -> i128 {
let lines = self.lines().await;
i128::try_from(lines.positive.as_u128()).unwrap()
- i128::try_from(lines.negative.as_u128()).unwrap()
}
fn next_publish_time(&self) -> u64 {
self.publish_clock
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+ 1
}
async fn publish_base_price(&self, price: u128) {
self.deployment
.price_feed
.methods()
.publish_prices(vec![PriceInput {
asset: self.base_asset,
bid: price,
ask: price,
timestamp: self.next_publish_time(),
}])
.call()
.await
.unwrap();
}
async fn rest_order(&self, price: u64, quantity: u64, asset: AssetId, coins: u64) {
self.order_book
.order_book
.methods()
.create_order(OrderArgs {
price,
quantity,
order_type: OrderType::Spot,
})
.call_params(CallParameters::new(coins, asset, u64::MAX))
.unwrap()
.with_variable_output_policy(VariableOutputPolicy::Exactly(1))
.call()
.await
.unwrap();
}
fn book_call(
&self,
price: u64,
quantity: u64,
asset: AssetId,
coins: u64,
) -> CallContractArg {
CallContractArg {
contract_id: self.order_book.contract_id,
function_selector: Bytes(fn_selector!(create_order(OrderArgs))),
call_params: CallParams {
coins,
asset_id: asset,
gas: 10_000_000,
},
call_data: Some(Bytes(call_data!(OrderArgs {
price,
quantity,
order_type: OrderType::Spot,
}))),
}
}
fn settle_book_call(&self) -> CallContractArg {
CallContractArg {
contract_id: self.order_book.contract_id,
function_selector: Bytes(fn_selector!(settle_balance(Identity))),
call_params: CallParams {
coins: 0,
asset_id: AssetId::default(),
gas: 10_000_000,
},
call_data: Some(Bytes(call_data!(Identity::ContractId(self.child_id)))),
}
}
fn draw_call(&self, amount: u64) -> CallContractArg {
self.pool_call(
fn_selector!(draw(u64)),
0,
AssetId::default(),
Some(call_data!(amount)),
)
}
fn prolong_call(&self, times: u64) -> CallContractArg {
self.pool_call(
fn_selector!(prolong_session(ProlongPeriod, u64)),
0,
AssetId::default(),
Some(call_data!(ProlongPeriod::Day, times)),
)
}
fn repay_base_from_collateral_call(
&self,
asset: AssetId,
amount: u64,
) -> CallContractArg {
self.pool_call(
fn_selector!(repay_base_from_collateral(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(asset, amount)),
)
}
fn repay_from_collateral_call(&self, amount: u64) -> CallContractArg {
self.pool_call(
fn_selector!(repay_from_collateral(u64)),
0,
AssetId::default(),
Some(call_data!(amount)),
)
}
async fn call_account_with_contract_ids(
&mut self,
calls: Vec<CallContractArg>,
contract_ids: &[ContractId],
) -> Result<CallResponse<()>> {
let (user_signature, cosigner_signature, calls) = self.signed_calls(calls).await;
Ok(self
.account
.methods()
.call_contracts(user_signature, cosigner_signature, calls)
.with_contract_ids(contract_ids)
.with_variable_output_policy(VariableOutputPolicy::Exactly(0))
.call()
.await?)
}
async fn debt(&self, asset: AssetId) -> u64 {
self.deployment
.pool
.methods()
.get_debt(self.child_id, asset)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
}
async fn fund_collateral(&self, amount: u64) {
self.deployment
.pool
.clone()
.with_account(self.outsider.clone())
.methods()
.add_collateral(self.child_id)
.call_params(CallParameters::new(amount, self.collateral_asset, u64::MAX))
.unwrap()
.call()
.await
.unwrap();
}
async fn bad_debt(&self) -> u64 {
self.deployment
.pool
.methods()
.get_bad_debt()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
}
async fn is_liquidatable(&self) -> bool {
let lines = self.lines().await;
lines.positive <= lines.negative + lines.threshold
}
async fn liquidate(&self) -> CallResponse<()> {
self.deployment
.pool
.clone()
.with_account(self.outsider.clone())
.methods()
.liquidate(self.child_id, vec![])
.with_contracts(&[
&self.account,
&self.deployment.oracle,
&self.order_book.order_book,
&self.deployment.price_feed,
])
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.call()
.await
.unwrap()
}
async fn close_session(&self) -> CallResponse<()> {
self.account
.methods()
.close_session(vec![])
.with_contracts(&[
&self.deployment.pool,
&self.deployment.oracle,
&self.order_book.order_book,
&self.deployment.price_feed,
])
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.call()
.await
.unwrap()
}
fn closed_event(&self, response: &CallResponse<()>) -> SessionClosed {
let closed = self
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<SessionClosed>(&response.tx_status.receipts)
.unwrap();
assert_eq!(closed.len(), 1);
closed.into_iter().next().unwrap()
}
}
async fn expire_fixture_session(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_until_expired =
session.expires_at.saturating_sub(latest.timestamp() as u64) + 1;
let expired_time = latest
.checked_add_signed(chrono::TimeDelta::seconds(seconds_until_expired as i64))
.expect("test expiry timestamp is representable");
provider
.produce_blocks(1, Some(expired_time))
.await
.unwrap();
fixture
.deployment
.pool
.clone()
.with_account(fixture.outsider.clone())
.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
.unwrap();
}
async fn occupied_pool_storage_slots(fixture: &PropFixture) -> usize {
fixture
.deployer
.try_provider()
.unwrap()
.client()
.contract_storage_slots(&fixture.deployment.pool_id)
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap()
.len()
}
#[test]
fn settlement_reason_wire_order_matches_backend_abi() {
fn discriminant(reason: SettlementReason) -> u64 {
match fuels::core::traits::Tokenizable::into_token(reason) {
fuels::types::Token::Enum(selector) => selector.0,
token => panic!("expected enum token, got {token:?}"),
}
}
assert_eq!(discriminant(SettlementReason::UserClose), 0);
assert_eq!(discriminant(SettlementReason::Expiry), 1);
assert_eq!(discriminant(SettlementReason::Liquidation), 2);
}
#[tokio::test]
async fn admin_controls_the_liquidator() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let fixture = PropFixture::new().await;
let deployer = Identity::Address(fixture.deployer.address());
let liquidator = Identity::Address(fixture.outsider.address());
assert_eq!(
fixture
.deployment
.pool
.methods()
.liquidator()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
deployer
);
let outsider_pool = fixture
.deployment
.pool
.clone()
.with_account(fixture.outsider.clone());
assert!(
outsider_pool
.methods()
.set_liquidator(liquidator)
.call()
.await
.is_err()
);
for invalid in [
Identity::Address(Address::zeroed()),
Identity::ContractId(ContractId::zeroed()),
] {
assert!(
fixture
.deployment
.pool
.methods()
.set_liquidator(invalid)
.call()
.await
.is_err()
);
}
let response = fixture
.deployment
.pool
.methods()
.set_liquidator(liquidator)
.call()
.await
.unwrap();
let events = response
.decode_logs_with_type::<MarginPoolLiquidatorChanged>()
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].old_liquidator, deployer);
assert_eq!(events[0].new_liquidator, liquidator);
assert!(events[0].timestamp.unix != 0);
assert_eq!(
fixture
.deployment
.pool
.methods()
.liquidator()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
liquidator
);
}
#[tokio::test]
async fn live_withdrawal_emits_canonical_event() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let amount = 100_000;
fixture
.user
.force_transfer_to_contract(
fixture.child_id,
amount,
fixture.collateral_asset,
TxPolicies::default(),
)
.await
.unwrap();
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, amount)),
);
let response = fixture.call_account(vec![withdraw], 2).await.unwrap();
let events = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<MarginWithdrawn>(&response.tx_status.receipts)
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].account, fixture.child_id);
assert_eq!(events[0].session_id, 1);
assert_eq!(events[0].asset_id, fixture.collateral_asset);
assert_eq!(events[0].amount, amount);
assert_eq!(events[0].from_account, amount);
assert_eq!(events[0].from_pool, 0);
assert_eq!(events[0].from_capitalised, 0);
assert_eq!(events[0].share_qty, 10_000);
assert_eq!(events[0].new_collateral, COLLATERAL);
assert_eq!(events[0].new_credit_line, LINE);
assert_eq!(events[0].capitalised_total, 0);
assert!(events[0].timestamp.unix != 0);
}
#[tokio::test]
async fn outside_funders_can_rescue_debt_but_self_funding_still_requires_a_clean_slate() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let lines = fixture
.deployment
.pool
.methods()
.get_session_lines(fixture.child_id)
.with_contract_ids(&[
fixture.child_id,
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.simulate(Execution::state_read_only())
.await
.unwrap()
.value;
assert_eq!(lines.positive, U256::from(LINE));
assert_eq!(lines.negative, U256::zero());
assert_eq!(lines.threshold, U256::from(8_250_000u64));
assert_eq!(lines.freeze, U256::from(8_375_000u64));
let liquidation_error = fixture
.deployment
.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!(
liquidation_error.to_string().contains("NotLiquidatable"),
"permissionless liquidation should reach the objective health gate: {liquidation_error:#}"
);
let draw_amount = 1_000_000;
let draw = fixture.pool_call(
fn_selector!(draw(u64)),
0,
AssetId::default(),
Some(call_data!(draw_amount)),
);
fixture.call_account(vec![draw], 1).await.unwrap();
let rescue_amount = 500_000;
fixture
.deployment
.pool
.clone()
.with_account(fixture.outsider.clone())
.methods()
.add_collateral(fixture.child_id)
.call_params(CallParameters::new(
rescue_amount,
fixture.collateral_asset,
u64::MAX,
))
.unwrap()
.call()
.await
.unwrap();
let rescued = fixture.session().await;
assert_eq!(rescued.drawn_quote, draw_amount);
assert_eq!(rescued.collateral, COLLATERAL + rescue_amount);
assert_eq!(rescued.credit_line, LINE + rescue_amount);
assert_eq!(rescued.capitalised, 0);
let self_amount = 100_000;
fixture
.user
.force_transfer_to_contract(
fixture.child_id,
self_amount,
fixture.collateral_asset,
TxPolicies::default(),
)
.await
.unwrap();
let self_top_up = fixture.pool_call(
fn_selector!(add_collateral(ContractId)),
self_amount,
fixture.collateral_asset,
Some(call_data!(fixture.child_id)),
);
let error = fixture
.call_account(vec![self_top_up], 0)
.await
.unwrap_err();
assert!(
error.to_string().contains("QuoteOutstanding"),
"unexpected self-funding error: {error:#}"
);
let return_quote = fixture.pool_call(
fn_selector!(return_quote()),
draw_amount,
fixture.collateral_asset,
None,
);
fixture.call_account(vec![return_quote], 0).await.unwrap();
let self_top_up = fixture.pool_call(
fn_selector!(add_collateral(ContractId)),
self_amount,
fixture.collateral_asset,
Some(call_data!(fixture.child_id)),
);
fixture.call_account(vec![self_top_up], 0).await.unwrap();
let capitalised = fixture.session().await;
assert_eq!(capitalised.drawn_quote, 0);
assert_eq!(capitalised.capitalised, self_amount);
let borrow_amount = 1_000_000_000u64;
let borrow = fixture.pool_call(
fn_selector!(borrow(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, borrow_amount)),
);
fixture.call_account(vec![borrow], 1).await.unwrap();
fixture
.deployment
.pool
.clone()
.with_account(fixture.outsider.clone())
.methods()
.add_collateral(fixture.child_id)
.call_params(CallParameters::new(
rescue_amount,
fixture.collateral_asset,
u64::MAX,
))
.unwrap()
.call()
.await
.unwrap();
let rescued_debt = fixture.session().await;
assert_eq!(rescued_debt.capitalised, self_amount);
fixture
.user
.force_transfer_to_contract(
fixture.child_id,
self_amount,
fixture.collateral_asset,
TxPolicies::default(),
)
.await
.unwrap();
let self_top_up = fixture.pool_call(
fn_selector!(add_collateral(ContractId)),
self_amount,
fixture.collateral_asset,
Some(call_data!(fixture.child_id)),
);
let error = fixture
.call_account(vec![self_top_up], 0)
.await
.unwrap_err();
assert!(
error.to_string().contains("DebtsOutstanding"),
"unexpected self-funding error: {error:#}"
);
assert_oracle_proxy_is_upgradeable(&fixture).await;
assert_new_pool_defaults_do_not_require_a_create_manifest(&fixture).await;
}
#[tokio::test]
async fn outsider_can_expire_an_elapsed_session() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let fixture = PropFixture::new().await;
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_until_expired =
session.expires_at.saturating_sub(latest.timestamp() as u64) + 1;
let expired_time = latest
.checked_add_signed(chrono::TimeDelta::seconds(seconds_until_expired as i64))
.expect("test expiry timestamp is representable");
provider
.produce_blocks(1, Some(expired_time))
.await
.unwrap();
let response = fixture
.deployment
.pool
.clone()
.with_account(fixture.outsider.clone())
.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::Exactly(1))
.call()
.await
.unwrap();
let events = response.decode_logs_with_type::<SessionClosed>().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].reason, SettlementReason::Expiry);
assert!(events[0].timestamp.unix > session.expires_at);
assert!(
!fixture
.deployment
.pool
.methods()
.has_session(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
}
#[tokio::test]
async fn expiry_routes_in_kind_debt_repayment_to_the_liquidator() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let liquidator = Identity::Address(fixture.outsider.address());
fixture
.deployment
.pool
.methods()
.set_liquidator(liquidator)
.call()
.await
.unwrap();
assert!(
!fixture
.deployment
.pool
.methods()
.has_debts(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
let borrowed = 1_000_000_000u64;
let borrow = fixture.pool_call(
fn_selector!(borrow(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, borrowed)),
);
fixture.call_account(vec![borrow], 1).await.unwrap();
assert!(
fixture
.deployment
.pool
.methods()
.has_debts(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
assert_eq!(
fixture
.deployment
.pool
.methods()
.get_debt(fixture.child_id, fixture.base_asset)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
borrowed
);
let pool_after_borrow = fixture.pool_balance(fixture.base_asset).await;
let liquidator_before = fixture
.outsider
.get_asset_balance(&fixture.base_asset)
.await
.unwrap();
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_until_expired =
session.expires_at.saturating_sub(latest.timestamp() as u64) + 1;
let expired_time = latest
.checked_add_signed(chrono::TimeDelta::seconds(seconds_until_expired as i64))
.expect("test expiry timestamp is representable");
provider
.produce_blocks(1, Some(expired_time))
.await
.unwrap();
let response = fixture
.deployment
.pool
.clone()
.with_account(fixture.outsider.clone())
.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::Exactly(2))
.call()
.await
.unwrap();
let seized = response.decode_logs_with_type::<SessionSeized>().unwrap();
assert_eq!(seized.len(), 1);
assert_eq!(seized[0].account, fixture.child_id);
assert_eq!(seized[0].session_id, session.session_id);
assert_eq!(seized[0].reason, SettlementReason::Expiry);
assert_eq!(seized[0].liquidator, liquidator);
assert_eq!(seized[0].holdings, vec![(fixture.base_asset, borrowed)]);
assert_eq!(seized[0].debt, vec![(fixture.base_asset, borrowed)]);
assert_eq!(seized[0].transferred, vec![(fixture.base_asset, borrowed)]);
let closed = fixture.closed_event(&response);
assert!(closed.cancelled_debt.is_empty());
assert!(seized[0].timestamp.unix > session.expires_at);
assert_eq!(
fixture.pool_balance(fixture.base_asset).await,
pool_after_borrow
);
assert_eq!(
fixture
.outsider
.get_asset_balance(&fixture.base_asset)
.await
.unwrap(),
liquidator_before + u128::from(borrowed)
);
assert!(
!fixture
.deployment
.pool
.methods()
.has_session(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
assert_eq!(
fixture
.deployment
.pool
.methods()
.get_debt(fixture.child_id, fixture.base_asset)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
0
);
assert!(
!fixture
.deployment
.pool
.methods()
.has_debts(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
}
#[tokio::test]
async fn settlement_clears_session_scoped_accounting_storage() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let clean_fixture = PropFixture::new_with_historical_storage().await;
expire_fixture_session(&clean_fixture).await;
let clean_close_slots = occupied_pool_storage_slots(&clean_fixture).await;
let mut debt_fixture = PropFixture::new_with_historical_storage().await;
let borrowed = 1_000_000_000u64;
let borrow = debt_fixture.pool_call(
fn_selector!(borrow(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(debt_fixture.base_asset, borrowed)),
);
debt_fixture.call_account(vec![borrow], 1).await.unwrap();
expire_fixture_session(&debt_fixture).await;
let debt_close_slots = occupied_pool_storage_slots(&debt_fixture).await;
assert_eq!(
debt_close_slots, clean_close_slots,
"settlement left debt, debt-asset index, or received-asset storage behind",
);
let mut netted_fixture = PropFixture::new_with_historical_storage().await;
let netted = 1_000_000u64;
let draw = netted_fixture.draw_call(netted);
netted_fixture.call_account(vec![draw], 1).await.unwrap();
let repay = netted_fixture.repay_from_collateral_call(netted);
netted_fixture.call_account(vec![repay], 0).await.unwrap();
expire_fixture_session(&netted_fixture).await;
assert_eq!(
occupied_pool_storage_slots(&netted_fixture).await,
clean_close_slots,
"settlement left the session's `decapitalised` memo behind, where a \
re-opened session could inherit it",
);
}
#[tokio::test]
async fn liquidation_routes_the_basket_after_the_accounts_short_trade() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let liquidator = Identity::Address(fixture.outsider.address());
fixture
.deployment
.pool
.methods()
.set_liquidator(liquidator)
.call()
.await
.unwrap();
let quantity = 1_000_000_000u64;
let sale_proceeds = 2_000_000u64;
fixture
.order_book
.order_book
.methods()
.create_order(OrderArgs {
price: sale_proceeds,
quantity,
order_type: OrderType::Spot,
})
.call_params(CallParameters::new(
sale_proceeds,
fixture.collateral_asset,
u64::MAX,
))
.unwrap()
.with_variable_output_policy(VariableOutputPolicy::Exactly(1))
.call()
.await
.unwrap();
let borrow = fixture.pool_call(
fn_selector!(borrow(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, quantity)),
);
let sell = CallContractArg {
contract_id: fixture.order_book.contract_id,
function_selector: Bytes(fn_selector!(create_order(OrderArgs))),
call_params: CallParams {
coins: quantity,
asset_id: fixture.base_asset,
gas: 10_000_000,
},
call_data: Some(Bytes(call_data!(OrderArgs {
price: sale_proceeds,
quantity,
order_type: OrderType::Spot,
}))),
};
let settle_book = CallContractArg {
contract_id: fixture.order_book.contract_id,
function_selector: Bytes(fn_selector!(settle_balance(Identity))),
call_params: CallParams {
coins: 0,
asset_id: AssetId::default(),
gas: 10_000_000,
},
call_data: Some(Bytes(call_data!(Identity::ContractId(fixture.child_id,)))),
};
fixture
.call_account(vec![borrow, sell, settle_book], 2)
.await
.unwrap();
let adverse_price = 10_000_000_000_000_000_000u128;
fixture
.deployment
.price_feed
.methods()
.publish_prices(vec![PriceInput {
asset: fixture.base_asset,
bid: adverse_price,
ask: adverse_price,
timestamp: fixture.next_publish_time(),
}])
.call()
.await
.unwrap();
let lines = fixture
.deployment
.pool
.methods()
.get_session_lines(fixture.child_id)
.with_contract_ids(&[
fixture.child_id,
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.simulate(Execution::state_read_only())
.await
.unwrap()
.value;
assert!(lines.positive <= lines.negative + lines.threshold);
let pool_cash_before = fixture.pool_balance(fixture.collateral_asset).await;
let liquidator_before = fixture
.outsider
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let response = fixture
.deployment
.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,
])
.with_variable_output_policy(VariableOutputPolicy::Exactly(1))
.call()
.await
.unwrap();
let seized = response.decode_logs_with_type::<SessionSeized>().unwrap();
let seized_cash = COLLATERAL + sale_proceeds;
assert_eq!(seized.len(), 1);
assert_eq!(seized[0].account, fixture.child_id);
assert_eq!(seized[0].reason, SettlementReason::Liquidation);
assert_eq!(seized[0].liquidator, liquidator);
assert_eq!(
seized[0].holdings,
vec![(fixture.collateral_asset, sale_proceeds)]
);
assert_eq!(seized[0].debt, vec![(fixture.base_asset, quantity)]);
assert_eq!(
seized[0].transferred,
vec![(fixture.collateral_asset, seized_cash)]
);
assert_eq!(seized_cash - sale_proceeds, COLLATERAL);
assert!(seized[0].timestamp.unix != 0);
assert_eq!(
fixture
.outsider
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
liquidator_before + u128::from(seized_cash)
);
assert_eq!(
fixture.pool_balance(fixture.collateral_asset).await,
pool_cash_before - COLLATERAL
);
let closed = response.decode_logs_with_type::<SessionClosed>().unwrap();
assert_eq!(closed.len(), 1);
assert_eq!(closed[0].reason, SettlementReason::Liquidation);
assert!(closed[0].profit_is_negative);
assert!(closed[0].bad_debt != 0);
assert_eq!(
closed[0].cancelled_debt,
vec![(fixture.base_asset, quantity)]
);
assert!(closed[0].payout_parent.is_empty());
assert!(closed[0].payout_platform.is_empty());
assert_eq!(
fixture
.deployment
.pool
.methods()
.get_bad_debt()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
closed[0].bad_debt
);
assert!(
!fixture
.deployment
.pool
.methods()
.has_session(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
}
#[tokio::test]
async fn a_liquidated_draw_reports_the_quote_the_debt_table_cannot_hold() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let liquidator = Identity::Address(fixture.outsider.address());
fixture
.deployment
.pool
.methods()
.set_liquidator(liquidator)
.call()
.await
.unwrap();
let quantity = 1_000_000_000u64;
let cost = 2_000_000u64;
fixture
.rest_order(cost, quantity, fixture.base_asset, quantity)
.await;
let draw = fixture.draw_call(cost);
let buy = fixture.book_call(cost, quantity, fixture.collateral_asset, cost);
fixture
.call_account(vec![draw, buy, fixture.settle_book_call()], 2)
.await
.unwrap();
assert_eq!(fixture.account_balance(fixture.base_asset).await, quantity);
assert_eq!(fixture.account_balance(fixture.collateral_asset).await, 0);
assert_eq!(fixture.session().await.drawn_quote, cost);
fixture
.publish_base_price(200_000_000_000_000_000u128)
.await;
assert_eq!(fixture.session_value().await, 8_200_000);
assert!(
fixture.is_liquidatable().await,
"the session must be the keeper's for this test to mean anything"
);
let liquidator_before = fixture
.outsider
.get_asset_balance(&fixture.base_asset)
.await
.unwrap();
let response = fixture.liquidate().await;
let closed = fixture.closed_event(&response);
let seized = response.decode_logs_with_type::<SessionSeized>().unwrap();
assert_eq!(seized.len(), 1);
assert_eq!(seized[0].account, fixture.child_id);
assert_eq!(seized[0].session_id, closed.session_id);
assert_eq!(seized[0].reason, SettlementReason::Liquidation);
assert_eq!(seized[0].liquidator, liquidator);
assert_eq!(seized[0].holdings, vec![(fixture.base_asset, quantity)]);
assert_eq!(seized[0].transferred, vec![(fixture.base_asset, quantity)]);
let credited = 198_000u64;
assert_eq!(seized[0].debt, vec![(fixture.collateral_asset, credited)]);
assert!(closed.cancelled_debt.is_empty());
assert_eq!(closed.user_net, credited);
assert_eq!(
closed.payout_parent,
vec![(fixture.collateral_asset, credited)]
);
assert_eq!(closed.reason, SettlementReason::Liquidation);
assert!(closed.profit_is_negative);
assert_eq!(closed.bad_debt, 0);
assert_eq!(
fixture
.outsider
.get_asset_balance(&fixture.base_asset)
.await
.unwrap(),
liquidator_before + u128::from(quantity)
);
}
#[tokio::test]
async fn a_seized_session_reports_both_debt_legs_against_one_holding() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let liquidator = Identity::Address(fixture.outsider.address());
fixture
.deployment
.pool
.methods()
.set_liquidator(liquidator)
.call()
.await
.unwrap();
let bought = 1_000_000_000u64;
let borrowed = 100_000_000u64;
let cost = 2_000_000u64;
fixture
.rest_order(cost, bought, fixture.base_asset, bought)
.await;
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(cost);
let buy = fixture.book_call(cost, bought, fixture.collateral_asset, cost);
fixture
.call_account(vec![borrow, draw, buy, fixture.settle_book_call()], 3)
.await
.unwrap();
assert_eq!(
fixture.account_balance(fixture.base_asset).await,
borrowed + bought
);
assert_eq!(fixture.account_balance(fixture.collateral_asset).await, 0);
assert_eq!(fixture.session().await.drawn_quote, cost);
fixture
.publish_base_price(200_000_000_000_000_000u128)
.await;
assert_eq!(fixture.session_value().await, 8_200_000);
assert!(fixture.is_liquidatable().await);
let response = fixture.liquidate().await;
let closed = fixture.closed_event(&response);
let seized = response.decode_logs_with_type::<SessionSeized>().unwrap();
assert_eq!(seized.len(), 1);
assert_eq!(
seized[0].debt,
vec![
(fixture.base_asset, borrowed),
(fixture.collateral_asset, 198_000),
]
);
assert_eq!(
seized[0].holdings,
vec![(fixture.base_asset, borrowed + bought)]
);
assert_eq!(
seized[0].transferred,
vec![(fixture.base_asset, borrowed + bought)]
);
assert!(closed.cancelled_debt.is_empty());
assert_eq!(closed.reason, SettlementReason::Liquidation);
}
#[tokio::test]
async fn a_zero_duration_tier_sells_its_term_at_the_door() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let fixture = PropFixture::new().await;
const WEEK_FEE: u64 = 76_000;
let tier_params = TierParams {
line: LINE,
leverage: 5,
duration: 0,
maintenance_bps: 250,
open_buffer_bps: 375,
liq_price_factor: 9_900,
prolong_fee: [6_000, 21_000, WEEK_FEE, 738_000],
max_credit_line_bps: 20_000,
max_price_age: u64::MAX,
open_fee: 0,
profit_share_bps: 1_000,
price_band_bps: 1_000,
};
fixture
.deployment
.pool
.methods()
.publish_tier_version(TIER_ID, tier_params, vec![fixture.order_book.contract_id])
.with_contract_ids(&[
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.call()
.await
.unwrap();
let parent = Identity::Address(fixture.user.address());
let child = fixture
.deployment
.deploy_account(&fixture.user, parent, 1)
.await
.unwrap();
let account = PropAccountContract::new(child.contract_id(), fixture.user.clone());
let platform_before = fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let opened_response = account
.methods()
.start_session(TIER_ID, COLLATERAL, ProlongPeriod::Week)
.call_params(CallParameters::new(
COLLATERAL,
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>(&opened_response.tx_status.receipts)
.unwrap();
assert_eq!(opened.len(), 1);
assert_eq!(opened[0].open_fee, WEEK_FEE);
assert_eq!(opened[0].fees_accrued, WEEK_FEE);
assert_eq!(
fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
platform_before + u128::from(WEEK_FEE)
);
assert_eq!(
opened[0].expires_at - opened[0].started_at,
604_800,
"a zero-duration tier must grant exactly the purchased term"
);
assert_eq!(opened[0].credit_line, 9_924_000);
}
#[tokio::test]
async fn prolongation_fee_can_move_a_session_through_its_health_lines() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let before = fixture.session().await;
let times = 300u64;
let expected_fee = 1_800_000u64;
let expected_extension = 86_400u64 * times;
let prolong = fixture.pool_call(
fn_selector!(prolong_session(ProlongPeriod, u64)),
0,
AssetId::default(),
Some(call_data!(ProlongPeriod::Day, times)),
);
let platform_before = fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let pool_before = fixture.pool_balance(fixture.collateral_asset).await;
let response = fixture.call_account(vec![prolong], 1).await.unwrap();
let events = response
.decode_logs_with_type::<SessionProlonged>()
.unwrap();
let call_events = response
.decode_logs_with_type::<MarginContractCallEvent>()
.unwrap();
assert_eq!(call_events.len(), 1);
assert_eq!(
call_events[0].authority,
Identity::Address(fixture.user.address())
);
assert_eq!(call_events[0].called_contract, fixture.deployment.pool_id);
assert!(call_events[0].timestamp.unix != 0);
assert_eq!(events.len(), 1);
assert_eq!(events[0].period, ProlongPeriod::Day);
assert_eq!(events[0].times, times);
assert_eq!(events[0].seconds, expected_extension);
assert_eq!(events[0].trigger, ProlongTrigger::User);
assert_eq!(events[0].fee, expected_fee);
assert_eq!(events[0].fees_accrued, expected_fee);
assert_eq!(
fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
platform_before + u128::from(expected_fee)
);
assert_eq!(
fixture.pool_balance(fixture.collateral_asset).await,
pool_before - expected_fee
);
let after = fixture.session().await;
assert_eq!(after.expires_at, before.expires_at + expected_extension);
assert_eq!(after.fees_accrued, expected_fee);
let lines = fixture
.deployment
.pool
.methods()
.get_session_lines(fixture.child_id)
.with_contract_ids(&[
fixture.child_id,
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.simulate(Execution::state_read_only())
.await
.unwrap()
.value;
assert!(lines.positive <= lines.negative + lines.freeze);
assert!(lines.positive <= lines.negative + lines.threshold);
}
#[tokio::test]
async fn the_open_fee_reaches_the_platform_inside_the_opening_call() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let fixture = PropFixture::new().await;
let open_fee = 100_000u64;
let tier_params = TierParams {
line: LINE,
leverage: 5,
duration: 86_400,
maintenance_bps: 250,
open_buffer_bps: 375,
liq_price_factor: 9_900,
prolong_fee: [0, 21_000, 76_000, 738_000],
max_credit_line_bps: 20_000,
max_price_age: u64::MAX,
open_fee,
profit_share_bps: 1_000,
price_band_bps: 1_000,
};
fixture
.deployment
.pool
.methods()
.publish_tier_version(TIER_ID, tier_params, vec![fixture.order_book.contract_id])
.with_contract_ids(&[
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.call()
.await
.unwrap();
let parent = Identity::Address(fixture.user.address());
let child = fixture
.deployment
.deploy_account(&fixture.user, parent, 1)
.await
.unwrap();
let child_id = child.contract_id();
let account = PropAccountContract::new(child_id, fixture.user.clone());
let platform_before = fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let pool_before = fixture.pool_balance(fixture.collateral_asset).await;
let opened_response = account
.methods()
.start_session(TIER_ID, COLLATERAL, ProlongPeriod::SixHours)
.call_params(CallParameters::new(
COLLATERAL,
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>(&opened_response.tx_status.receipts)
.unwrap();
assert_eq!(opened.len(), 1);
assert_eq!(opened[0].open_fee, open_fee);
assert_eq!(opened[0].fees_accrued, open_fee);
assert_eq!(opened[0].collateral, COLLATERAL);
assert_eq!(opened[0].credit_line, 9_900_000);
assert_eq!(
fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
platform_before + u128::from(open_fee)
);
assert_eq!(
fixture.pool_balance(fixture.collateral_asset).await,
pool_before + COLLATERAL - open_fee
);
let value = fixture
.deployment
.pool
.methods()
.get_session_lines(child_id)
.with_contract_ids(&[
child_id,
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.simulate(Execution::state_read_only())
.await
.unwrap()
.value;
assert_eq!(value.positive - value.negative, U256::from(9_900_000u64));
let parent_before = fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let close_response = account
.methods()
.close_session(vec![])
.with_contracts(&[
&fixture.deployment.pool,
&fixture.deployment.oracle,
&fixture.order_book.order_book,
&fixture.deployment.price_feed,
])
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.call()
.await
.unwrap();
let closed = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<SessionClosed>(&close_response.tx_status.receipts)
.unwrap();
assert_eq!(closed.len(), 1);
let closed = &closed[0];
assert_eq!(closed.fees_accrued, open_fee);
assert_eq!(closed.platform_total, 0);
assert!(closed.payout_platform.is_empty());
assert_eq!(closed.bad_debt, 0);
assert_eq!(closed.user_net, COLLATERAL - open_fee);
assert_eq!(
closed.payout_parent,
vec![(fixture.collateral_asset, COLLATERAL - open_fee)]
);
let parent_gain = (fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap()
- parent_before) as u64;
assert_eq!(parent_gain + open_fee, COLLATERAL);
}
async fn assert_oracle_proxy_is_upgradeable(fixture: &PropFixture) {
let expected_owner =
State::Initialized(Identity::Address(fixture.deployer.address()));
assert_eq!(
fixture
.deployment
.oracle_proxy
.methods()
.proxy_target()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(ContractId::from(fixture.deployment.oracle_blob_id))
);
assert_eq!(
fixture
.deployment
.oracle_proxy
.methods()
.proxy_owner()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
expected_owner
);
assert!(
fixture
.deployment
.oracle_proxy
.methods()
.initialize_proxy()
.call()
.await
.is_err()
);
assert!(
fixture
.deployment
.oracle
.methods()
.initialize()
.call()
.await
.is_err()
);
let outsider_proxy = PropAccountOracleProxyContract::new(
fixture.deployment.oracle_id,
fixture.outsider.clone(),
);
assert!(
outsider_proxy
.methods()
.set_proxy_target(ContractId::from(fixture.deployment.account_blob_id,))
.call()
.await
.is_err()
);
assert!(
fixture
.deployment
.oracle_proxy
.methods()
.set_proxy_target(ContractId::zeroed())
.call()
.await
.is_err()
);
fixture
.deployment
.oracle_proxy
.methods()
.set_proxy_target(ContractId::from(fixture.deployment.account_blob_id))
.call()
.await
.unwrap();
assert_eq!(
fixture
.deployment
.oracle_proxy
.methods()
.proxy_target()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(ContractId::from(fixture.deployment.account_blob_id))
);
fixture
.deployment
.oracle_proxy
.methods()
.set_proxy_target(ContractId::from(fixture.deployment.oracle_blob_id))
.call()
.await
.unwrap();
assert_eq!(
fixture
.deployment
.oracle
.methods()
.get_prop_account_impl()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(ContractId::from(fixture.deployment.account_blob_id))
);
assert_eq!(
fixture
.deployment
.oracle
.methods()
.get_prop_margin_pool()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(fixture.deployment.pool_id)
);
assert_eq!(
PropAccountProxyContract::new(fixture.child_id, fixture.user.clone(),)
.methods()
.oracle()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
fixture.deployment.oracle_id
);
}
async fn assert_new_pool_defaults_do_not_require_a_create_manifest(
fixture: &PropFixture,
) {
let proxy_configurables = PropMarginPoolProxyContractConfigurables::default()
.with_INITIAL_OWNER(State::Initialized(Identity::Address(
fixture.deployer.address(),
)))
.unwrap()
.with_INITIAL_TARGET(ContractId::from(fixture.deployment.pool_blob_id))
.unwrap();
let proxy_contract = regular_contract(
PROP_MARGIN_POOL_PROXY_BYTECODE,
PROP_MARGIN_POOL_PROXY_STORAGE,
Salt::from([0x77; 32]),
)
.unwrap()
.with_configurables(proxy_configurables);
let (proxy_id, is_new) = deploy_regular(&fixture.deployer, proxy_contract)
.await
.unwrap();
assert!(is_new);
let proxy = PropMarginPoolProxyContract::new(proxy_id, fixture.deployer.clone());
proxy.methods().initialize_proxy().call().await.unwrap();
let pool = PropMarginPoolContract::new(proxy_id, fixture.deployer.clone());
assert!(
!pool
.methods()
.is_initialized()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
assert_eq!(
pool.methods()
.get_bad_debt()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
0
);
}
#[tokio::test]
async fn repay_from_collateral_nets_the_draw_below_the_freeze_line() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let drawn = 2_500_000u64;
let netted = COLLATERAL;
let draw = fixture.draw_call(drawn);
fixture.call_account(vec![draw], 1).await.unwrap();
let prolong = fixture.prolong_call(275);
fixture.call_account(vec![prolong], 1).await.unwrap();
let before = fixture.session().await;
let value_before = fixture.session_value().await;
let lines = fixture.lines().await;
assert_eq!(before.fees_accrued, 1_650_000);
assert_eq!(before.credit_line, 8_350_000);
assert_eq!(value_before, 8_350_000);
assert!(
lines.positive <= lines.negative + lines.freeze,
"the session must be below the freeze line for this test to mean anything"
);
assert!(lines.positive > lines.negative + lines.threshold);
let repay = fixture.repay_from_collateral_call(drawn);
let error = fixture.call_account(vec![repay], 0).await.unwrap_err();
assert!(
error.to_string().contains("InsufficientBalance"),
"unexpected netting error: {error:#}"
);
let pool_cash_before = fixture.pool_balance(fixture.collateral_asset).await;
let account_cash_before = fixture.account_balance(fixture.collateral_asset).await;
let repay = fixture.repay_from_collateral_call(netted);
let response = fixture.call_account(vec![repay], 0).await.unwrap();
let events = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<QuoteRepaidFromCollateral>(&response.tx_status.receipts)
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].account, fixture.child_id);
assert_eq!(events[0].session_id, before.session_id);
assert_eq!(events[0].amount, netted);
assert_eq!(events[0].new_drawn_quote, drawn - netted);
assert_eq!(events[0].new_collateral, 0);
assert_eq!(events[0].new_credit_line, 8_000_000);
assert_eq!(events[0].from_capitalised, 0);
assert_eq!(events[0].capitalised_total, 0);
assert_eq!(events[0].decapitalised_total, netted);
assert!(events[0].timestamp.unix != 0);
let after = fixture.session().await;
assert_eq!(after.drawn_quote, drawn - netted);
assert_eq!(after.collateral, 0);
assert_eq!(after.credit_line, 8_000_000);
assert_eq!(after.decapitalised, events[0].decapitalised_total);
assert_eq!(after.decapitalised, netted);
assert_eq!(
fixture.pool_balance(fixture.collateral_asset).await,
pool_cash_before
);
assert_eq!(
fixture.account_balance(fixture.collateral_asset).await,
account_cash_before
);
assert_eq!(fixture.session_value().await, value_before);
let repay = fixture.repay_from_collateral_call(0);
let error = fixture.call_account(vec![repay], 0).await.unwrap_err();
assert!(
error.to_string().contains("AmountIsZero"),
"unexpected netting error: {error:#}"
);
}
#[tokio::test]
async fn netting_while_liquidatable_changes_nothing_the_keeper_collects() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
async fn underwater_then_liquidated(netted: bool) -> SessionClosed {
let mut fixture = PropFixture::new().await;
let drawn = 1_000_000u64;
let draw = fixture.draw_call(drawn);
fixture.call_account(vec![draw], 1).await.unwrap();
let prolong = fixture.prolong_call(300);
fixture.call_account(vec![prolong], 1).await.unwrap();
assert_eq!(fixture.session_value().await, 8_200_000);
assert!(
fixture.is_liquidatable().await,
"the session must be the liquidator's for this test to mean anything"
);
if netted {
let repay = fixture.repay_from_collateral_call(drawn);
fixture.call_account(vec![repay], 0).await.unwrap();
let after = fixture.session().await;
assert_eq!(after.drawn_quote, 0);
assert_eq!(after.collateral, COLLATERAL - drawn);
assert_eq!(fixture.session_value().await, 8_200_000);
assert!(
fixture.is_liquidatable().await,
"the netting must not be an escape from liquidation"
);
}
let closed = fixture.closed_event(&fixture.liquidate().await);
assert!(
!fixture
.deployment
.pool
.methods()
.has_session(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
closed
}
let plain = underwater_then_liquidated(false).await;
let netted = underwater_then_liquidated(true).await;
let collateral_asset = AssetId::new([0x31; 32]);
assert_eq!(plain.reason, SettlementReason::Liquidation);
assert_eq!(netted.reason, plain.reason);
assert_eq!(plain.v, 8_200_000);
assert_eq!(netted.v, plain.v);
assert!(!plain.v_is_negative && !netted.v_is_negative);
assert_eq!(plain.fees_accrued, 1_800_000);
assert_eq!(netted.fees_accrued, plain.fees_accrued);
assert_eq!(plain.platform_total, 0);
assert_eq!(netted.platform_total, plain.platform_total);
assert_eq!(plain.fees_accrued + plain.platform_total, 1_800_000);
assert!(plain.payout_platform.is_empty());
assert_eq!(netted.payout_platform, plain.payout_platform);
assert_eq!(plain.user_net, netted.user_net);
assert_eq!(plain.user_net, COLLATERAL - 1_800_000);
assert_eq!(
plain.payout_parent,
vec![(collateral_asset, COLLATERAL - 1_800_000)]
);
assert_eq!(plain.payout_parent, netted.payout_parent);
assert_eq!(plain.bad_debt, 0);
assert_eq!(netted.bad_debt, plain.bad_debt);
}
#[tokio::test]
async fn netting_asks_for_neither_the_price_feed_nor_the_books() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let drawn = 1_000_000u64;
let draw = fixture.draw_call(drawn);
let buy = fixture.book_call(2_000_000, 500_000_000, fixture.collateral_asset, drawn);
fixture.call_account(vec![draw, buy], 2).await.unwrap();
let prolong = fixture.prolong_call(275);
fixture.call_account(vec![prolong], 1).await.unwrap();
assert_eq!(fixture.account_balance(fixture.collateral_asset).await, 0);
assert_eq!(
fixture
.order_book
.order_book
.methods()
.get_total_balance_of(Identity::ContractId(fixture.child_id))
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
(0, drawn)
);
let value_before = fixture.session_value().await;
assert_eq!(value_before, 8_350_000);
let repay = fixture.repay_from_collateral_call(drawn);
fixture
.call_account_with_contract_ids(
vec![repay],
&[fixture.deployment.oracle_id, fixture.deployment.pool_id],
)
.await
.unwrap();
let after = fixture.session().await;
assert_eq!(after.drawn_quote, 0);
assert_eq!(after.collateral, COLLATERAL - drawn);
assert_eq!(fixture.session_value().await, value_before);
assert_eq!(
fixture
.order_book
.order_book
.methods()
.get_total_balance_of(Identity::ContractId(fixture.child_id))
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
(0, drawn)
);
let repay = fixture.repay_from_collateral_call(1);
let error = fixture.call_account(vec![repay], 0).await.unwrap_err();
assert!(
error.to_string().contains("OverRepay"),
"unexpected netting error: {error:#}"
);
}
#[tokio::test]
async fn netting_clears_a_draw_while_an_asset_debt_is_still_open() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let quantity = 1_000_000_000u64;
let sale = 2_000_000u64;
let drawn = 1_000_000u64;
fixture
.rest_order(sale, quantity, fixture.collateral_asset, sale)
.await;
let borrow = fixture.pool_call(
fn_selector!(borrow(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, quantity)),
);
let sell = fixture.book_call(sale, quantity, fixture.base_asset, quantity);
fixture
.call_account(vec![borrow, sell, fixture.settle_book_call()], 2)
.await
.unwrap();
let draw = fixture.draw_call(drawn);
fixture.call_account(vec![draw], 1).await.unwrap();
assert_eq!(fixture.debt(fixture.base_asset).await, quantity);
assert_eq!(
fixture.account_balance(fixture.collateral_asset).await,
sale + drawn
);
let value_before = fixture.session_value().await;
let pool_cash_before = fixture.pool_balance(fixture.collateral_asset).await;
let repay = fixture.repay_from_collateral_call(drawn);
fixture.call_account(vec![repay], 0).await.unwrap();
let after = fixture.session().await;
assert_eq!(after.drawn_quote, 0);
assert_eq!(after.collateral, COLLATERAL - drawn);
assert_eq!(fixture.debt(fixture.base_asset).await, quantity);
assert_eq!(fixture.session_value().await, value_before);
assert_eq!(
fixture.pool_balance(fixture.collateral_asset).await,
pool_cash_before
);
assert_eq!(
fixture.account_balance(fixture.collateral_asset).await,
sale + drawn
);
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, 1u64)),
);
let error = fixture.call_account(vec![withdraw], 2).await.unwrap_err();
assert!(
error.to_string().contains("DebtsOutstanding"),
"the netting must not open a drain: {error:#}"
);
let capitalise = fixture.pool_call(
fn_selector!(add_collateral(ContractId)),
1,
fixture.collateral_asset,
Some(call_data!(fixture.child_id)),
);
let error = fixture.call_account(vec![capitalise], 0).await.unwrap_err();
assert!(
error.to_string().contains("DebtsOutstanding"),
"self-funding must still need a clean slate: {error:#}"
);
let error = fixture
.account
.methods()
.close_session(vec![])
.with_contracts(&[
&fixture.deployment.pool,
&fixture.deployment.oracle,
&fixture.order_book.order_book,
&fixture.deployment.price_feed,
])
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("DebtsOutstanding"),
"a voluntary close must still need the debt repaid: {error:#}"
);
fixture
.rest_order(sale, quantity, fixture.base_asset, quantity)
.await;
let buy_back = fixture.book_call(sale, quantity, fixture.collateral_asset, sale);
let repay_in_kind =
fixture.pool_call(fn_selector!(repay()), quantity, fixture.base_asset, None);
fixture
.call_account(vec![buy_back, fixture.settle_book_call(), repay_in_kind], 2)
.await
.unwrap();
assert_eq!(fixture.debt(fixture.base_asset).await, 0);
let parent_before = fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let closed = fixture.closed_event(&fixture.close_session().await);
assert_eq!(closed.reason, SettlementReason::UserClose);
assert_eq!(closed.bad_debt, 0);
assert_eq!(
closed.platform_total, 0,
"a flat round trip owes the platform nothing: {:?}",
closed.payout_platform
);
assert_eq!(
closed.payout_parent,
vec![(fixture.collateral_asset, COLLATERAL)]
);
assert_eq!(
fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
parent_before + u128::from(COLLATERAL)
);
}
#[tokio::test]
async fn netting_leaves_the_loan_cap_headroom_exactly_where_it_was() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let drawn = 1_000_000u64;
let draw = fixture.draw_call(drawn);
fixture.call_account(vec![draw], 1).await.unwrap();
let before = fixture.session().await;
let headroom = before.credit_line - before.drawn_quote;
assert_eq!(headroom, 9_000_000);
let repay = fixture.repay_from_collateral_call(drawn);
fixture.call_account(vec![repay], 0).await.unwrap();
let after = fixture.session().await;
assert_eq!(after.drawn_quote, 0);
assert_eq!(after.credit_line, before.credit_line - drawn);
assert_eq!(after.credit_line - after.drawn_quote, headroom);
let draw = fixture.draw_call(headroom + 1);
let error = fixture.call_account(vec![draw], 1).await.unwrap_err();
assert!(
error.to_string().contains("LoanCapExceeded"),
"the netting must not widen the cap: {error:#}"
);
let draw = fixture.draw_call(headroom);
fixture.call_account(vec![draw], 1).await.unwrap();
assert_eq!(fixture.session().await.drawn_quote, headroom);
}
#[tokio::test]
async fn netting_at_the_tier_cap_returns_headroom_the_collateral_still_earns() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
const K: u64 = 8_000_000;
const CAP: u64 = 20_000_000;
let line_for = |collateral: u64| (K + collateral).min(CAP);
fixture.fund_collateral(18_000_000).await;
let funded = fixture.session().await;
assert_eq!(funded.collateral, 20_000_000);
assert_eq!(funded.credit_line, CAP);
assert_eq!(funded.capitalised, 0, "an outside funder buys no share");
let drawn = 5_000_000u64;
let draw = fixture.draw_call(drawn);
fixture.call_account(vec![draw], 1).await.unwrap();
let before = fixture.session().await;
let headroom_before = before.credit_line - before.drawn_quote;
assert_eq!(headroom_before, 15_000_000);
let pool_cash_before = fixture.pool_balance(fixture.collateral_asset).await;
let value_before = fixture.session_value().await;
assert_eq!(value_before, 28_000_000);
let repay = fixture.repay_from_collateral_call(drawn);
fixture.call_account(vec![repay], 0).await.unwrap();
let after = fixture.session().await;
assert_eq!(after.drawn_quote, 0);
assert_eq!(after.collateral, 15_000_000);
assert_eq!(after.credit_line, CAP);
assert_eq!(
after.credit_line - after.drawn_quote,
headroom_before + drawn
);
assert_eq!(after.credit_line, line_for(after.collateral));
assert_eq!(fixture.session_value().await, value_before);
assert_eq!(
fixture.pool_balance(fixture.collateral_asset).await,
pool_cash_before
);
let draw = fixture.draw_call(CAP + 1);
let error = fixture.call_account(vec![draw], 1).await.unwrap_err();
assert!(
error.to_string().contains("LoanCapExceeded"),
"the cap must still bind after a netting: {error:#}"
);
let draw = fixture.draw_call(CAP);
fixture.call_account(vec![draw], 1).await.unwrap();
assert_eq!(fixture.session().await.drawn_quote, CAP);
let repay = fixture.repay_from_collateral_call(15_000_000);
fixture.call_account(vec![repay], 0).await.unwrap();
let uncapped = fixture.session().await;
assert_eq!(uncapped.collateral, 0);
assert_eq!(uncapped.drawn_quote, CAP - 15_000_000);
assert_eq!(uncapped.credit_line, line_for(uncapped.collateral));
assert_eq!(uncapped.credit_line, K);
assert_eq!(
fixture.pool_balance(fixture.collateral_asset).await,
pool_cash_before - CAP
);
}
#[tokio::test]
async fn a_crash_past_the_whole_collateral_books_the_same_bad_debt_either_way() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
async fn capped_loan_then_crash(netted: bool) -> (SessionClosed, u64) {
let mut fixture = PropFixture::new().await;
fixture.fund_collateral(10_000_000).await;
let drawn = 20_000_000u64;
let quantity = 10_000_000_000u64;
let session = fixture.session().await;
assert_eq!(session.collateral, 12_000_000);
assert_eq!(session.credit_line, drawn);
fixture
.rest_order(2_000_000, quantity, fixture.base_asset, quantity)
.await;
let draw = fixture.draw_call(drawn);
let buy = fixture.book_call(2_000_000, quantity, fixture.collateral_asset, drawn);
fixture
.call_account(vec![draw, buy, fixture.settle_book_call()], 2)
.await
.unwrap();
assert_eq!(fixture.account_balance(fixture.base_asset).await, quantity);
assert_eq!(fixture.account_balance(fixture.collateral_asset).await, 0);
fixture.publish_base_price(20_000_000_000_000_000u128).await;
let value_before = fixture.session_value().await;
assert_eq!(value_before, 200_000);
assert!(fixture.is_liquidatable().await);
if netted {
let repay = fixture.repay_from_collateral_call(12_000_000);
fixture.call_account(vec![repay], 0).await.unwrap();
let after = fixture.session().await;
assert_eq!(after.collateral, 0);
assert_eq!(after.drawn_quote, 8_000_000);
assert_eq!(after.credit_line, 8_000_000);
assert_eq!(fixture.session_value().await, value_before);
assert!(fixture.is_liquidatable().await);
let repay = fixture.repay_from_collateral_call(1);
let error = fixture.call_account(vec![repay], 0).await.unwrap_err();
assert!(
error.to_string().contains("InsufficientBalance"),
"the collateral is spent and the netting must say so: {error:#}"
);
}
let closed = fixture.closed_event(&fixture.liquidate().await);
(closed, fixture.bad_debt().await)
}
let (plain, plain_book) = capped_loan_then_crash(false).await;
let (netted, netted_book) = capped_loan_then_crash(true).await;
assert_eq!(plain.reason, SettlementReason::Liquidation);
assert_eq!(netted.reason, plain.reason);
assert_eq!(plain.v, 200_000);
assert_eq!(netted.v, plain.v);
assert_eq!(plain.bad_debt, 7_802_000);
assert_eq!(netted.bad_debt, plain.bad_debt);
assert_eq!(plain_book, plain.bad_debt);
assert_eq!(netted_book, plain_book);
assert_eq!(plain.user_net, 0);
assert_eq!(netted.user_net, plain.user_net);
assert_eq!(plain.platform_total, 0);
assert_eq!(netted.platform_total, plain.platform_total);
assert!(plain.payout_parent.is_empty() && netted.payout_parent.is_empty());
assert!(plain.payout_platform.is_empty() && netted.payout_platform.is_empty());
}
#[tokio::test]
async fn netting_returns_principal_untaxed_while_real_profit_still_pays() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let principal = 1_000_000u64;
let quantity = 500_000_000u64;
let sale = 1_500_000u64;
let profit = sale - principal;
let topped_up = COLLATERAL + 2_000_000;
fixture.fund_collateral(2_000_000).await;
assert_eq!(fixture.session().await.collateral, topped_up);
let draw = fixture.draw_call(principal);
fixture.call_account(vec![draw], 1).await.unwrap();
let repay = fixture.repay_from_collateral_call(principal);
let response = fixture.call_account(vec![repay], 0).await.unwrap();
let netted = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<QuoteRepaidFromCollateral>(&response.tx_status.receipts)
.unwrap();
assert_eq!(netted[0].decapitalised_total, principal);
assert_eq!(
fixture.account_balance(fixture.collateral_asset).await,
principal
);
assert_eq!(fixture.session().await.collateral, topped_up - principal);
fixture
.rest_order(2_000_000, quantity, fixture.base_asset, quantity)
.await;
let buy = fixture.book_call(2_000_000, quantity, fixture.collateral_asset, principal);
fixture
.call_account(vec![buy, fixture.settle_book_call()], 2)
.await
.unwrap();
fixture
.rest_order(3_000_000, quantity, fixture.collateral_asset, sale)
.await;
let sell = fixture.book_call(3_000_000, quantity, fixture.base_asset, quantity);
fixture
.call_account(vec![sell, fixture.settle_book_call()], 2)
.await
.unwrap();
assert_eq!(
fixture.account_balance(fixture.collateral_asset).await,
sale
);
let parent_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, sale)),
);
let response = fixture.call_account(vec![withdraw], 2).await.unwrap();
let withdrawn = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<MarginWithdrawn>(&response.tx_status.receipts)
.unwrap();
assert_eq!(withdrawn.len(), 1);
assert_eq!(withdrawn[0].amount, sale);
assert_eq!(withdrawn[0].from_account, sale);
assert_eq!(withdrawn[0].from_pool, 0);
assert_eq!(
withdrawn[0].share_qty,
profit / 10,
"the netted principal must not be taxed as profit"
);
assert_eq!(
fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
parent_before + u128::from(sale - profit / 10)
);
let remaining = topped_up - principal;
let closed = fixture.closed_event(&fixture.close_session().await);
assert_eq!(closed.reason, SettlementReason::UserClose);
assert_eq!(closed.bad_debt, 0);
assert_eq!(closed.platform_total, 0);
assert_eq!(
closed.payout_parent,
vec![(fixture.collateral_asset, remaining)]
);
assert_eq!(
fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
parent_before + u128::from(sale - profit / 10 + remaining)
);
}
#[tokio::test]
async fn only_a_contract_with_a_session_can_net_a_draw() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let draw = fixture.draw_call(1_000_000);
fixture.call_account(vec![draw], 1).await.unwrap();
let error = fixture
.deployment
.pool
.clone()
.with_account(fixture.outsider.clone())
.methods()
.repay_from_collateral(1_000_000)
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("CallerNotContract"),
"a wallet must not be able to net anybody's draw: {error:#}"
);
assert_eq!(fixture.session().await.drawn_quote, 1_000_000);
assert_eq!(fixture.session().await.collateral, COLLATERAL);
}
#[tokio::test]
async fn a_long_that_fell_nets_its_draw_away_and_closes_cleanly() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let quantity = 1_000_000_000u64;
let cost = 2_000_000u64;
fixture
.rest_order(cost, quantity, fixture.base_asset, quantity)
.await;
let draw = fixture.draw_call(cost);
let buy = fixture.book_call(cost, quantity, fixture.collateral_asset, cost);
fixture
.call_account(vec![draw, buy, fixture.settle_book_call()], 2)
.await
.unwrap();
assert_eq!(fixture.account_balance(fixture.base_asset).await, quantity);
assert_eq!(fixture.account_balance(fixture.collateral_asset).await, 0);
fixture
.publish_base_price(1_000_000_000_000_000_000u128)
.await;
let value_before = fixture.session_value().await;
assert_eq!(value_before, 9_000_000);
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, quantity)),
);
let error = fixture.call_account(vec![withdraw], 2).await.unwrap_err();
assert!(
error.to_string().contains("QuoteOutstanding"),
"the draw is what traps him: {error:#}"
);
let repay = fixture.repay_from_collateral_call(cost);
fixture.call_account(vec![repay], 0).await.unwrap();
let after = fixture.session().await;
assert_eq!(after.drawn_quote, 0);
assert_eq!(after.collateral, COLLATERAL - cost);
assert_eq!(fixture.session_value().await, value_before);
let released = 100_000_000u64;
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, released)),
);
let error = fixture.call_account(vec![withdraw], 2).await.unwrap_err();
assert!(
error.to_string().contains("BelowTierCollateral"),
"a session funded below its tier floor must not release anything: {error:#}"
);
let closed = fixture.closed_event(&fixture.close_session().await);
assert_eq!(closed.reason, SettlementReason::UserClose);
assert_eq!(closed.platform_total, 0);
assert!(
closed.payout_platform.is_empty(),
"a netted draw must not be taxed as profit: {:?}",
closed.payout_platform
);
assert_eq!(closed.payout_parent, vec![(fixture.base_asset, quantity)]);
assert_eq!(closed.bad_debt, 0);
}
#[tokio::test]
async fn a_short_that_rose_nets_its_draw_away_and_closes_cleanly() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let quantity = 1_000_000_000u64;
let sale = 2_000_000u64;
let buy_back = 3_000_000u64;
fixture
.rest_order(sale, quantity, fixture.collateral_asset, sale)
.await;
let borrow = fixture.pool_call(
fn_selector!(borrow(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, quantity)),
);
let sell = fixture.book_call(sale, quantity, fixture.base_asset, quantity);
fixture
.call_account(vec![borrow, sell, fixture.settle_book_call()], 2)
.await
.unwrap();
assert_eq!(
fixture.account_balance(fixture.collateral_asset).await,
sale
);
fixture
.publish_base_price(3_000_000_000_000_000_000u128)
.await;
fixture
.rest_order(buy_back, quantity, fixture.base_asset, quantity)
.await;
let draw = fixture.draw_call(buy_back - sale);
let buy = fixture.book_call(buy_back, quantity, fixture.collateral_asset, buy_back);
let repay_in_kind =
fixture.pool_call(fn_selector!(repay()), quantity, fixture.base_asset, None);
fixture
.call_account(
vec![draw, buy, fixture.settle_book_call(), repay_in_kind],
2,
)
.await
.unwrap();
let before = fixture.session().await;
assert_eq!(before.drawn_quote, buy_back - sale);
assert_eq!(fixture.account_balance(fixture.collateral_asset).await, 0);
assert_eq!(fixture.account_balance(fixture.base_asset).await, 0);
assert!(
!fixture
.deployment
.pool
.methods()
.has_debts(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
let value_before = fixture.session_value().await;
let repay = fixture.repay_from_collateral_call(before.drawn_quote);
fixture.call_account(vec![repay], 0).await.unwrap();
let after = fixture.session().await;
assert_eq!(after.drawn_quote, 0);
assert_eq!(after.collateral, COLLATERAL - before.drawn_quote);
assert_eq!(fixture.session_value().await, value_before);
let parent_before = fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let closed = fixture.closed_event(&fixture.close_session().await);
assert_eq!(closed.reason, SettlementReason::UserClose);
assert_eq!(closed.bad_debt, 0);
assert_eq!(closed.platform_total, 0);
assert_eq!(
closed.payout_parent,
vec![(fixture.collateral_asset, after.collateral)]
);
assert_eq!(
fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
parent_before + u128::from(after.collateral)
);
}
#[tokio::test]
async fn netting_leaves_the_platforms_settlement_take_exactly_where_it_was() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
async fn profitable_long_then_close(netted: bool) -> SessionClosed {
let mut fixture = PropFixture::new().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;
if netted {
let repay = fixture.repay_from_collateral_call(cost);
fixture.call_account(vec![repay], 0).await.unwrap();
let session = fixture.session().await;
assert_eq!(session.drawn_quote, 0);
assert_eq!(session.collateral, 0);
assert_eq!(fixture.account_balance(fixture.base_asset).await, quantity);
} else {
let sold = quantity / 2;
fixture
.rest_order(4_000_000, sold, fixture.collateral_asset, cost)
.await;
let sell = fixture.book_call(4_000_000, sold, fixture.base_asset, sold);
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 session = fixture.session().await;
assert_eq!(session.drawn_quote, 0);
assert_eq!(session.collateral, COLLATERAL);
assert_eq!(
fixture.account_balance(fixture.base_asset).await,
quantity - sold
);
}
fixture.closed_event(&fixture.close_session().await)
}
fn realised_take(closed: &SessionClosed, collateral: AssetId) -> u128 {
closed
.payout_platform
.iter()
.map(|(asset, amount)| {
if *asset == collateral {
u128::from(*amount)
} else {
u128::from(*amount) * 4 / 1_000
}
})
.sum()
}
let plain = profitable_long_then_close(false).await;
let netted = profitable_long_then_close(true).await;
let collateral_asset = AssetId::new([0x31; 32]);
assert_eq!(plain.platform_total, 200_000);
assert_eq!(netted.platform_total, plain.platform_total);
assert_eq!(realised_take(&plain, collateral_asset), 200_000);
assert_eq!(realised_take(&netted, collateral_asset), 200_000);
assert!(
plain.user_net.abs_diff(netted.user_net) <= 1,
"the route to a clean slate moved the user's settlement: {} vs {}",
plain.user_net,
netted.user_net
);
assert_eq!(plain.bad_debt, 0);
assert_eq!(netted.bad_debt, 0);
}
#[tokio::test]
async fn capitalised_profit_survives_a_netting_and_is_still_shared_at_close() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let quantity = 1_000_000_000u64;
let cost = 2_000_000u64;
let sale = 3_000_000u64;
let profit = sale - cost;
fixture
.rest_order(cost, quantity, fixture.base_asset, quantity)
.await;
let draw = fixture.draw_call(cost);
let buy = fixture.book_call(cost, 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;
fixture
.rest_order(sale, quantity, fixture.collateral_asset, sale)
.await;
let sell = fixture.book_call(sale, 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();
assert_eq!(
fixture.account_balance(fixture.collateral_asset).await,
profit
);
let capitalise = fixture.pool_call(
fn_selector!(add_collateral(ContractId)),
profit,
fixture.collateral_asset,
Some(call_data!(fixture.child_id)),
);
fixture.call_account(vec![capitalise], 0).await.unwrap();
let capitalised = fixture.session().await;
assert_eq!(capitalised.collateral, COLLATERAL + profit);
assert_eq!(capitalised.capitalised, profit);
let draw = fixture.draw_call(profit);
fixture.call_account(vec![draw], 1).await.unwrap();
let repay = fixture.repay_from_collateral_call(profit);
let response = fixture.call_account(vec![repay], 0).await.unwrap();
let events = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<QuoteRepaidFromCollateral>(&response.tx_status.receipts)
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].from_capitalised, profit);
assert_eq!(events[0].capitalised_total, 0);
assert_eq!(
events[0].decapitalised_total, 0,
"the netting was covered by the capitalised balance, so deferring any \
of it as well would count the same correction twice",
);
let netted = fixture.session().await;
assert_eq!(netted.collateral, COLLATERAL);
assert_eq!(netted.drawn_quote, 0);
assert_eq!(netted.capitalised, 0);
assert_eq!(
fixture.account_balance(fixture.collateral_asset).await,
profit
);
let closed = fixture.closed_event(&fixture.close_session().await);
assert_eq!(closed.reason, SettlementReason::UserClose);
assert_eq!(closed.platform_total, profit / 10);
assert_eq!(
closed.payout_platform,
vec![(fixture.collateral_asset, profit / 10)]
);
assert_eq!(
closed.payout_parent,
vec![(fixture.collateral_asset, COLLATERAL + profit - profit / 10)]
);
assert_eq!(closed.bad_debt, 0);
}
#[tokio::test]
async fn a_session_prolonged_past_its_line_can_still_be_valued_and_liquidated() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let drawn = 9_000_000u64;
let draw = fixture.draw_call(drawn);
fixture.call_account(vec![draw], 1).await.unwrap();
let prolong = fixture.prolong_call(300);
fixture.call_account(vec![prolong], 1).await.unwrap();
let session = fixture.session().await;
assert_eq!(session.fees_accrued, 1_800_000);
assert_eq!(session.credit_line, 8_200_000);
assert!(
session.credit_line < session.drawn_quote,
"the fixture must reach the overdrawn state this test is about"
);
let lines = fixture.lines().await;
assert_eq!(lines.positive, U256::from(10_800_000u64));
assert_eq!(lines.negative, U256::from(2_600_000u64));
assert_eq!(fixture.session_value().await, 8_200_000);
assert!(
lines.positive <= lines.negative + lines.threshold,
"an overdrawn session must be liquidatable, not merely valuable"
);
let response = fixture
.deployment
.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,
])
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.call()
.await
.unwrap();
let closed = response.decode_logs_with_type::<SessionClosed>().unwrap();
assert_eq!(closed.len(), 1);
assert_eq!(closed[0].reason, SettlementReason::Liquidation);
assert_eq!(closed[0].v, 8_200_000);
assert!(!closed[0].v_is_negative);
assert!(
!fixture
.deployment
.pool
.methods()
.has_session(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
}
#[tokio::test]
async fn netting_survives_a_pause_and_an_elapsed_deadline() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let half = 500_000u64;
let draw = fixture.draw_call(half * 2);
fixture.call_account(vec![draw], 1).await.unwrap();
fixture
.deployment
.pool
.methods()
.pause()
.call()
.await
.unwrap();
let draw = fixture.draw_call(1);
let error = fixture.call_account(vec![draw], 1).await.unwrap_err();
assert!(
error.to_string().contains("Paused"),
"the pause must actually be in force: {error:#}"
);
let repay = fixture.repay_from_collateral_call(half);
fixture.call_account(vec![repay], 0).await.unwrap();
fixture
.deployment
.pool
.methods()
.unpause()
.call()
.await
.unwrap();
let session = fixture.session().await;
fixture
.account
.methods()
.set_session(ParallelSessionArgs {
nonce: U256::one(),
session_id: Identity::Address(fixture.user.address()),
expiry: Time {
unix: session.expires_at + 86_400,
},
contract_ids: vec![],
})
.with_contract_ids(&[fixture.deployment.oracle_id])
.call()
.await
.unwrap();
let provider = fixture.outsider.try_provider().unwrap();
let latest = provider
.latest_block_time()
.await
.unwrap()
.expect("local chain has a latest block time");
let expired_time = latest
.checked_add_signed(chrono::TimeDelta::seconds(
(session.expires_at.saturating_sub(latest.timestamp() as u64) + 1) as i64,
))
.expect("test expiry timestamp is representable");
provider
.produce_blocks(1, Some(expired_time))
.await
.unwrap();
let repay = fixture.repay_from_collateral_call(half);
fixture.call_account(vec![repay], 0).await.unwrap();
let after = fixture.session().await;
assert_eq!(after.drawn_quote, 0);
assert_eq!(after.collateral, COLLATERAL - half * 2);
expire_fixture_session(&fixture).await;
assert!(
!fixture
.deployment
.pool
.methods()
.has_session(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
}
#[tokio::test]
async fn netting_cannot_walk_funded_collateral_below_the_tier_floor() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let floor = LINE / 5;
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, 1u64)),
);
let error = fixture.call_account(vec![withdraw], 2).await.unwrap_err();
assert!(
error.to_string().contains("BelowTierCollateral"),
"the pool leg must still hold the floor: {error:#}"
);
let drawn = 1_624_999u64;
let draw = fixture.draw_call(drawn);
let net = fixture.repay_from_collateral_call(drawn);
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, drawn)),
);
let error = fixture
.call_account(vec![draw, net, withdraw], 6)
.await
.unwrap_err();
assert!(
error.to_string().contains("BelowTierCollateral"),
"the account leg must hold the same floor: {error:#}"
);
let session = fixture.session().await;
assert_eq!(session.collateral, COLLATERAL);
assert_eq!(session.drawn_quote, 0);
assert_eq!(fixture.account_balance(fixture.collateral_asset).await, 0);
let quantity = 500_000_000u64;
fixture
.rest_order(2_000_000, quantity, fixture.base_asset, quantity)
.await;
let draw = fixture.draw_call(1_000_000);
let net = fixture.repay_from_collateral_call(1_000_000);
let buy = fixture.book_call(2_000_000, quantity, fixture.collateral_asset, 1_000_000);
fixture
.call_account(vec![draw, net, buy, fixture.settle_book_call()], 3)
.await
.unwrap();
let session = fixture.session().await;
assert_eq!(session.collateral, COLLATERAL - 1_000_000);
assert!(
session.collateral < floor,
"the walk did put it under the floor"
);
assert_eq!(fixture.account_balance(fixture.base_asset).await, quantity);
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, quantity)),
);
let error = fixture.call_account(vec![withdraw], 2).await.unwrap_err();
assert!(
error.to_string().contains("BelowTierCollateral"),
"an in-kind exit must not escape the floor either: {error:#}"
);
assert_eq!(session.credit_line, LINE - LINE / 5 + session.collateral);
let redraw = fixture.draw_call(session.credit_line);
fixture.call_account(vec![redraw], 2).await.unwrap();
let levered = fixture.session().await;
assert_eq!(levered.drawn_quote, levered.credit_line);
assert!(
!fixture.is_liquidatable().await,
"the netted principal is still on the account, so the session is sound"
);
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, 1u64)),
);
let error = fixture.call_account(vec![withdraw], 2).await.unwrap_err();
assert!(
error.to_string().contains("QuoteOutstanding"),
"and it still cannot take anything out: {error:#}"
);
}
#[tokio::test]
async fn repeated_netting_with_outside_top_ups_still_taxes_only_profit() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
fixture.fund_collateral(4_000_000).await;
let mut netted = 0u64;
for _ in 0..3 {
let draw = fixture.draw_call(1_000_000);
let net = fixture.repay_from_collateral_call(1_000_000);
fixture.call_account(vec![draw, net], 2).await.unwrap();
netted += 1_000_000;
}
let session = fixture.session().await;
assert_eq!(session.collateral, COLLATERAL + 4_000_000 - netted);
assert_eq!(session.drawn_quote, 0);
assert_eq!(
fixture.account_balance(fixture.collateral_asset).await,
netted
);
let quantity = 1_500_000_000u64;
fixture
.rest_order(2_000_000, quantity, fixture.base_asset, quantity)
.await;
let buy = fixture.book_call(2_000_000, quantity, fixture.collateral_asset, netted);
fixture
.call_account(vec![buy, fixture.settle_book_call()], 2)
.await
.unwrap();
let sale = 4_500_000u64;
fixture
.rest_order(3_000_000, quantity, fixture.collateral_asset, sale)
.await;
let sell = fixture.book_call(3_000_000, quantity, fixture.base_asset, quantity);
fixture
.call_account(vec![sell, fixture.settle_book_call()], 2)
.await
.unwrap();
assert_eq!(
fixture.account_balance(fixture.collateral_asset).await,
sale
);
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, sale)),
);
let response = fixture.call_account(vec![withdraw], 3).await.unwrap();
let withdrawn = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<MarginWithdrawn>(&response.tx_status.receipts)
.unwrap();
assert_eq!(
withdrawn[0].share_qty,
(sale - netted) / 10,
"the memo must survive repeated nettings and outside top-ups intact"
);
}
#[tokio::test]
async fn a_voluntary_close_is_refused_while_the_quote_draw_is_outstanding() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let drawn = 10_000_000u64;
let quantity = 5_000_000_000u64;
fixture
.rest_order(2_000_000, quantity, fixture.base_asset, quantity)
.await;
let draw = fixture.draw_call(drawn);
let buy = fixture.book_call(2_000_000, quantity, fixture.collateral_asset, drawn);
fixture
.call_account(vec![draw, buy, fixture.settle_book_call()], 2)
.await
.unwrap();
fixture
.publish_base_price(1_640_000_000_000_000_000u128)
.await;
assert!(fixture.is_liquidatable().await);
assert!(
!fixture
.deployment
.pool
.methods()
.has_debts(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
"no in-kind debt was ever created - only a quote draw"
);
let error = fixture
.account
.methods()
.close_session(vec![])
.with_contracts(&[
&fixture.deployment.pool,
&fixture.deployment.oracle,
&fixture.order_book.order_book,
&fixture.deployment.price_feed,
])
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("QuoteOutstanding"),
"a par close must not be available while the pool is still owed cash: {error:#}"
);
let repay = fixture.repay_from_collateral_call(COLLATERAL);
fixture.call_account(vec![repay], 0).await.unwrap();
let after = fixture.session().await;
assert_eq!(after.collateral, 0);
assert_eq!(after.drawn_quote, drawn - COLLATERAL);
let error = fixture
.account
.methods()
.close_session(vec![])
.with_contracts(&[
&fixture.deployment.pool,
&fixture.deployment.oracle,
&fixture.order_book.order_book,
&fixture.deployment.price_feed,
])
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("QuoteOutstanding"),
"still owed 8_000_000: {error:#}"
);
let closed = fixture.closed_event(&fixture.liquidate().await);
assert_eq!(closed.reason, SettlementReason::Liquidation);
assert_eq!(closed.bad_debt, 0);
assert!(
!fixture
.deployment
.pool
.methods()
.has_session(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
}
#[tokio::test]
async fn repay_base_from_collateral_closes_a_short_the_user_cannot_buy_back() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let quantity = 1_000_000_000u64;
let sale = 2_000_000u64;
let marked = 3_000_000u64;
let charged = marked;
let fee = charged / 10_000; fixture.fund_collateral(4_000_000).await;
let funded = COLLATERAL + 4_000_000;
fixture
.rest_order(2_000_000, quantity, fixture.collateral_asset, sale)
.await;
let borrow = fixture.pool_call(
fn_selector!(borrow(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, quantity)),
);
let sell = fixture.book_call(2_000_000, quantity, fixture.base_asset, quantity);
fixture
.call_account(vec![borrow, sell, fixture.settle_book_call()], 2)
.await
.unwrap();
assert_eq!(fixture.debt(fixture.base_asset).await, quantity);
assert_eq!(
fixture.account_balance(fixture.collateral_asset).await,
sale
);
fixture
.publish_base_price(3_000_000_000_000_000_000u128)
.await;
let pool_cash_before = fixture.pool_balance(fixture.collateral_asset).await;
let account_cash_before = fixture.account_balance(fixture.collateral_asset).await;
let platform_cash_before = fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let repay = fixture.repay_base_from_collateral_call(fixture.base_asset, quantity);
let response = fixture.call_account(vec![repay], 1).await.unwrap();
let events = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<BaseDebtRepaidFromCollateral>(
&response.tx_status.receipts,
)
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].asset_id, fixture.base_asset);
assert_eq!(events[0].amount, quantity);
assert_eq!(events[0].ask, U256::from(3_000_000_000_000_000_000u64));
assert_eq!(
events[0].value, charged,
"the plain ask, with no liq_price_factor widening"
);
assert_eq!(events[0].fee, fee);
assert_eq!(events[0].debt_total, 0);
assert_eq!(events[0].decapitalised_total, charged);
assert_eq!(events[0].from_capitalised, 0);
let after = fixture.session().await;
assert_eq!(after.collateral, funded - charged);
assert_eq!(after.fees_accrued, fee);
assert_eq!(
after.credit_line,
LINE - LINE / 5 + after.collateral - after.fees_accrued
);
assert_eq!(fixture.debt(fixture.base_asset).await, 0);
assert!(
!fixture
.deployment
.pool
.methods()
.has_debts(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
"the debt index must be clear, which is what unblocks every exit"
);
assert_eq!(
fixture.pool_balance(fixture.collateral_asset).await,
pool_cash_before - fee
);
assert_eq!(
fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
platform_cash_before + u128::from(fee)
);
assert_eq!(
fixture.account_balance(fixture.collateral_asset).await,
account_cash_before
);
let parent_before = fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let closed = fixture.closed_event(&fixture.close_session().await);
assert_eq!(closed.reason, SettlementReason::UserClose);
assert_eq!(closed.bad_debt, 0);
assert_eq!(closed.platform_total, 0);
assert_eq!(closed.fees_accrued, fee);
let expected = funded - (charged - sale) - fee;
assert_eq!(
closed.payout_parent,
vec![(fixture.collateral_asset, expected)]
);
assert_eq!(
fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
parent_before + u128::from(expected)
);
let _ = marked;
}
#[tokio::test]
async fn repay_base_from_collateral_guards_its_inputs_and_rounds_the_fee_up() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let quantity = 1_000_000_000u64;
fixture.fund_collateral(4_000_000).await;
fixture
.rest_order(2_000_000, quantity, fixture.collateral_asset, 2_000_000)
.await;
let borrow = fixture.pool_call(
fn_selector!(borrow(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, quantity)),
);
let sell = fixture.book_call(2_000_000, quantity, fixture.base_asset, quantity);
fixture
.call_account(vec![borrow, sell, fixture.settle_book_call()], 2)
.await
.unwrap();
let repay = fixture.repay_base_from_collateral_call(fixture.collateral_asset, 1);
let error = fixture.call_account(vec![repay], 1).await.unwrap_err();
assert!(
error.to_string().contains("UseRepayQuote"),
"the collateral asset must be routed to the quote netting: {error:#}"
);
for bad in [0u64, quantity + 1] {
let repay = fixture.repay_base_from_collateral_call(fixture.base_asset, bad);
let error = fixture.call_account(vec![repay], 1).await.unwrap_err();
assert!(
error.to_string().contains("OverRepay"),
"unexpected error for amount {bad}: {error:#}"
);
}
let sliver = 2_500_000u64;
let repay = fixture.repay_base_from_collateral_call(fixture.base_asset, sliver);
let response = fixture.call_account(vec![repay], 1).await.unwrap();
let events = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<BaseDebtRepaidFromCollateral>(
&response.tx_status.receipts,
)
.unwrap();
assert_eq!(events[0].value, 5_000);
assert_eq!(events[0].fee, 1, "a floored fee would be free");
assert_eq!(events[0].debt_total, quantity - sliver);
assert_eq!(fixture.session().await.fees_accrued, 1);
fixture
.publish_base_price(100_000_000_000_000_000_000u128)
.await;
let repay =
fixture.repay_base_from_collateral_call(fixture.base_asset, quantity - sliver);
let error = fixture.call_account(vec![repay], 1).await.unwrap_err();
assert!(
error.to_string().contains("InsufficientBalance"),
"collateral must bound the conversion: {error:#}"
);
}
#[tokio::test]
async fn red_team_a_liquidatable_short_buys_a_par_exit_out_of_the_keepers_hands() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
async fn underwater_short(escaped: bool) -> (SessionClosed, u64) {
let mut fixture = PropFixture::new().await;
let quantity = 100_000_000u64;
fixture
.rest_order(2_000_000, quantity, fixture.collateral_asset, 200_000)
.await;
let borrow = fixture.pool_call(
fn_selector!(borrow(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, quantity)),
);
let sell = fixture.book_call(2_000_000, quantity, fixture.base_asset, quantity);
fixture
.call_account(vec![borrow, sell, fixture.settle_book_call()], 2)
.await
.unwrap();
fixture
.publish_base_price(19_600_000_000_000_000_000u128)
.await;
assert_eq!(fixture.session_value().await, 8_240_000);
assert!(
fixture.is_liquidatable().await,
"the keeper must already own this session or the test means nothing"
);
let parent_before = fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let closed = if escaped {
let repay =
fixture.repay_base_from_collateral_call(fixture.base_asset, quantity);
fixture.call_account(vec![repay], 1).await.unwrap();
assert!(
fixture.is_liquidatable().await,
"the par exit is taken by a session the keeper still owns"
);
fixture.closed_event(&fixture.close_session().await)
} else {
fixture.closed_event(&fixture.liquidate().await)
};
let parent_after = fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
(closed, (parent_after - parent_before) as u64)
}
let (seized, seized_paid) = underwater_short(false).await;
let (escaped, escaped_paid) = underwater_short(true).await;
assert_eq!(seized.reason, SettlementReason::Liquidation);
assert_eq!(escaped.reason, SettlementReason::UserClose);
assert_eq!(seized.bad_debt, 0);
assert_eq!(escaped.bad_debt, 0);
let provision = 1_979_798u64 - 1_960_000;
let fee = 196u64;
assert_eq!(
escaped_paid,
seized_paid + provision - fee,
"the conversion must sell the liquidation provision for its fee and \
nothing else (seized event {seized:#?}, escaped event {escaped:#?})"
);
}
#[tokio::test]
async fn red_team_repay_base_from_collateral_unliquidates_a_session_the_keeper_owns() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let quantity = 6_500_000_000u64;
fixture.fund_collateral(11_260_000).await;
fixture
.rest_order(2_000_000, quantity, fixture.collateral_asset, 13_000_000)
.await;
let borrow = fixture.pool_call(
fn_selector!(borrow(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, quantity)),
);
let sell = fixture.book_call(2_000_000, quantity, fixture.base_asset, quantity);
fixture
.call_account(vec![borrow, sell, fixture.settle_book_call()], 2)
.await
.unwrap();
fixture
.publish_base_price(4_000_000_000_000_000_000u128)
.await;
let before = fixture.lines().await;
let threshold_before = before.threshold.as_u128() as u64;
let value_before = fixture.session_value().await;
assert!(
fixture.is_liquidatable().await,
"the keeper must already own this session or the test means nothing"
);
let repay = fixture.repay_base_from_collateral_call(fixture.base_asset, 150_000_000);
let response = fixture.call_account(vec![repay], 1).await.unwrap();
let events = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<BaseDebtRepaidFromCollateral>(
&response.tx_status.receipts,
)
.unwrap();
let value = events[0].value;
let fee = events[0].fee;
assert_eq!(value, 600_000);
assert_eq!(fee, 60);
let after = fixture.lines().await;
let threshold_after = after.threshold.as_u128() as u64;
let value_after = fixture.session_value().await;
assert_eq!(value_after, value_before - i128::from(fee));
let released = threshold_before - threshold_after;
assert!(
released > fee,
"the release must outrun the fee for the escape to exist: \
released {released}, fee {fee}"
);
assert!(
!fixture.is_liquidatable().await,
"the plain-ask conversion de-risks the session past the line; value \
{value}, fee {fee}, threshold {threshold_before} -> {threshold_after}"
);
let error = fixture
.deployment
.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,
])
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("NotLiquidatable"),
"the keeper must be refused after the escape: {error:#}"
);
assert!(
fixture
.deployment
.pool
.methods()
.has_session(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
}
#[tokio::test]
async fn red_team_an_expired_short_cannot_pick_its_own_mark() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let quantity = 1_000_000_000u64;
fixture.fund_collateral(4_000_000).await;
fixture
.rest_order(2_000_000, quantity, fixture.collateral_asset, 2_000_000)
.await;
let borrow = fixture.pool_call(
fn_selector!(borrow(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, quantity)),
);
let sell = fixture.book_call(2_000_000, quantity, fixture.base_asset, quantity);
fixture
.call_account(vec![borrow, sell, fixture.settle_book_call()], 2)
.await
.unwrap();
let session = fixture.session().await;
fixture
.account
.methods()
.set_session(ParallelSessionArgs {
nonce: U256::one(),
session_id: Identity::Address(fixture.user.address()),
expiry: Time {
unix: session.expires_at + 86_400,
},
contract_ids: vec![],
})
.with_contract_ids(&[fixture.deployment.oracle_id])
.call()
.await
.unwrap();
let provider = fixture.outsider.try_provider().unwrap();
let latest = provider
.latest_block_time()
.await
.unwrap()
.expect("local chain has a latest block time");
let expired_time = latest
.checked_add_signed(chrono::TimeDelta::seconds(
(session.expires_at.saturating_sub(latest.timestamp() as u64) + 1) as i64,
))
.expect("test expiry timestamp is representable");
provider
.produce_blocks(1, Some(expired_time))
.await
.unwrap();
let repay = fixture.repay_base_from_collateral_call(fixture.base_asset, quantity);
let error = fixture.call_account(vec![repay], 1).await.unwrap_err();
assert!(
error.to_string().contains("SessionEnded"),
"an expired session must not choose the mark its keeper would have set: {error:#}"
);
expire_fixture_session(&fixture).await;
assert!(
!fixture
.deployment
.pool
.methods()
.has_session(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
}
#[tokio::test]
async fn red_team_a_wide_oracle_spread_is_charged_on_the_ask_and_cannot_be_farmed() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let quantity = 1_000_000_000u64;
fixture.fund_collateral(18_000_000).await;
fixture
.deployment
.price_feed
.methods()
.publish_prices(vec![PriceInput {
asset: fixture.base_asset,
bid: 2_000_000_000_000_000_000u128,
ask: 3_000_000_000_000_000_000u128,
timestamp: fixture.next_publish_time(),
}])
.call()
.await
.unwrap();
fixture
.rest_order(2_000_000, quantity, fixture.collateral_asset, 2_000_000)
.await;
let borrow = fixture.pool_call(
fn_selector!(borrow(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, quantity)),
);
let sell = fixture.book_call(2_000_000, quantity, fixture.base_asset, quantity);
fixture
.call_account(vec![borrow, sell, fixture.settle_book_call()], 2)
.await
.unwrap();
let before = fixture.session_value().await;
let repay = fixture.repay_base_from_collateral_call(fixture.base_asset, quantity);
let response = fixture.call_account(vec![repay], 1).await.unwrap();
let events = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<BaseDebtRepaidFromCollateral>(
&response.tx_status.receipts,
)
.unwrap();
assert_eq!(
events[0].ask,
U256::from(3_000_000_000_000_000_000u64),
"the sell side of the book is what a buy-back costs"
);
assert_eq!(
events[0].value, 3_000_000,
"priced at the ask, never the bid or a mid"
);
assert_eq!(events[0].fee, 300);
assert_eq!(fixture.session_value().await, before - 300);
let before = fixture.session_value().await;
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();
assert_eq!(
fixture.session_value().await,
before,
"a debt covered in kind is worth nothing to the gate either way"
);
let repay = fixture.repay_base_from_collateral_call(fixture.base_asset, quantity);
fixture.call_account(vec![repay], 1).await.unwrap();
assert_eq!(
fixture.session_value().await,
before - (3_000_000 - 2_000_000) - 300,
"the survivor is marked at the BID while the charge is the ASK, so the \
user pays the whole spread plus the fee to convert a self-covered debt"
);
}
#[tokio::test]
async fn red_team_slicing_a_base_repay_never_beats_doing_it_once() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
async fn short_then_repay(slices: u64) -> (u64, u64) {
let mut fixture = PropFixture::new().await;
let quantity = 1_000_000_000u64;
fixture.fund_collateral(4_000_000).await;
fixture
.rest_order(2_000_000, quantity, fixture.collateral_asset, 2_000_000)
.await;
let borrow = fixture.pool_call(
fn_selector!(borrow(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, quantity)),
);
let sell = fixture.book_call(2_000_000, quantity, fixture.base_asset, quantity);
fixture
.call_account(vec![borrow, sell, fixture.settle_book_call()], 2)
.await
.unwrap();
let before = fixture.session().await.collateral;
let slice = quantity / slices;
for index in 0..slices {
let amount = if index + 1 == slices {
quantity - slice * (slices - 1)
} else {
slice
};
let repay =
fixture.repay_base_from_collateral_call(fixture.base_asset, amount);
fixture.call_account(vec![repay], 1).await.unwrap();
}
let after = fixture.session().await;
assert_eq!(fixture.debt(fixture.base_asset).await, 0);
(before - after.collateral, after.fees_accrued)
}
let (one_value, one_fee) = short_then_repay(1).await;
let (many_value, many_fee) = short_then_repay(7).await;
assert_eq!(one_value, 2_000_000);
assert_eq!(one_fee, 200);
assert!(
many_value >= one_value && many_fee >= one_fee,
"seven slices cost {many_value}/{many_fee} against {one_value}/{one_fee} for one"
);
let mut fixture = PropFixture::new().await;
let quantity = 1_000_000_000u64;
fixture.fund_collateral(4_000_000).await;
fixture
.rest_order(2_000_000, quantity, fixture.collateral_asset, 2_000_000)
.await;
let borrow = fixture.pool_call(
fn_selector!(borrow(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, quantity)),
);
let sell = fixture.book_call(2_000_000, quantity, fixture.base_asset, quantity);
fixture
.call_account(vec![borrow, sell, fixture.settle_book_call()], 2)
.await
.unwrap();
let repay = fixture.repay_base_from_collateral_call(fixture.base_asset, 1);
let response = fixture.call_account(vec![repay], 1).await.unwrap();
let events = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<BaseDebtRepaidFromCollateral>(
&response.tx_status.receipts,
)
.unwrap();
assert_eq!(events[0].value, 1);
assert_eq!(events[0].fee, 1, "dust pays a whole unit, never zero");
}
#[tokio::test]
async fn the_tier_floor_bounds_withdrawals_not_conversions() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let quantity = 1_000_000_000u64;
assert_eq!(fixture.session().await.collateral, COLLATERAL);
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 base_repay_fee = 200u64;
fixture.fund_collateral(base_repay_fee).await;
let repay = fixture.repay_base_from_collateral_call(fixture.base_asset, quantity);
fixture.call_account(vec![repay], 1).await.unwrap();
let after = fixture.session().await;
assert_eq!(after.fees_accrued, base_repay_fee);
assert_eq!(after.collateral, base_repay_fee);
assert_eq!(after.credit_line, LINE - LINE / 5);
assert!(
fixture
.deployment
.pool
.methods()
.has_session(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
"and the session is still live"
);
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, quantity)),
);
let error = fixture.call_account(vec![withdraw], 2).await.unwrap_err();
assert!(
error.to_string().contains("BelowTierCollateral"),
"the withdrawal floor must still hold: {error:#}"
);
let draw = fixture.draw_call(after.credit_line);
fixture.call_account(vec![draw], 1).await.unwrap();
let drawn = fixture.session().await;
assert_eq!(drawn.drawn_quote, LINE - LINE / 5);
assert_eq!(drawn.collateral, base_repay_fee);
fixture.publish_base_price(10_000_000_000_000_000u128).await;
assert!(
fixture.is_liquidatable().await,
"a session running on the pool's own capital must be seizable the instant \
its equity goes"
);
let closed = fixture.closed_event(&fixture.liquidate().await);
assert_eq!(closed.reason, SettlementReason::Liquidation);
}
#[tokio::test]
async fn red_team_the_base_repay_guards_hold_under_a_pause_and_an_unknown_asset() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let quantity = 1_000_000_000u64;
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 stranger = AssetId::new([0x77; 32]);
let repay = fixture.repay_base_from_collateral_call(stranger, 1);
let error = fixture.call_account(vec![repay], 1).await.unwrap_err();
assert!(
error.to_string().contains("OverRepay"),
"an unborrowed asset has nothing to close: {error:#}"
);
fixture
.deployment
.pool
.methods()
.pause()
.call()
.await
.unwrap();
let repay = fixture.repay_base_from_collateral_call(fixture.base_asset, quantity);
let error = fixture.call_account(vec![repay], 1).await.unwrap_err();
assert!(
error.to_string().contains("Paused"),
"the marked repay must stop with every other marked operation: {error:#}"
);
let repay_in_kind =
fixture.pool_call(fn_selector!(repay()), quantity, fixture.base_asset, None);
fixture.call_account(vec![repay_in_kind], 0).await.unwrap();
assert_eq!(fixture.debt(fixture.base_asset).await, 0);
}
#[tokio::test]
async fn audit_base_repay_leaves_the_session_over_its_loan_cap() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let quantity = 500_000_000u64;
let mark = 1_000_000u64;
let drawn = 9_000_000u64;
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 draw = fixture.draw_call(drawn);
fixture.call_account(vec![draw], 1).await.unwrap();
let before = fixture.session().await;
assert_eq!(before.collateral, COLLATERAL);
assert_eq!(before.credit_line, LINE);
assert_eq!(before.drawn_quote, drawn);
let over = fixture.draw_call(1);
let error = fixture.call_account(vec![over], 1).await.unwrap_err();
assert!(
error.to_string().contains("LoanCapExceeded"),
"the session must start ON its cap for this test to mean anything: {error:#}"
);
let repay = fixture.repay_base_from_collateral_call(fixture.base_asset, quantity);
let response = fixture.call_account(vec![repay], 1).await.unwrap();
let events = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<BaseDebtRepaidFromCollateral>(
&response.tx_status.receipts,
)
.unwrap();
let value = events[0].value;
let fee = events[0].fee;
assert_eq!(value, 1_000_000, "the plain mark, no widening");
assert_eq!(fee, 100, "100 ppm of 1_000_000");
let after = fixture.session().await;
assert_eq!(after.collateral, COLLATERAL - value);
assert_eq!(after.drawn_quote, drawn, "the draw is untouched");
assert_eq!(fixture.debt(fixture.base_asset).await, 0);
assert!(
after.drawn_quote > after.credit_line,
"the loan cap must still hold after a repayment: drawn {} against a line of {}",
after.drawn_quote,
after.credit_line
);
assert_eq!(
after.drawn_quote - after.credit_line,
(value - mark) + fee,
"the breach is exactly the fee the line gave up and the cap's \
plain-ask measure never saw"
);
assert!(
!fixture.is_liquidatable().await,
"the over-cap session is not seizable, so the breach persists"
);
let draw = fixture.draw_call(1);
let error = fixture.call_account(vec![draw], 1).await.unwrap_err();
assert!(error.to_string().contains("LoanCapExceeded"), "{error:#}");
}
#[tokio::test]
async fn audit_base_repay_memo_taxes_exactly_the_realised_gain_through_withdraw() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let quantity = 1_000_000_000u64;
let sale = 2_000_000u64;
fixture.fund_collateral(8_000_000).await;
let funded = COLLATERAL + 8_000_000;
assert_eq!(fixture.session().await.collateral, funded);
fixture
.rest_order(2_000_000, quantity, fixture.collateral_asset, sale)
.await;
let borrow = fixture.pool_call(
fn_selector!(borrow(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, quantity)),
);
let sell = fixture.book_call(2_000_000, quantity, fixture.base_asset, quantity);
fixture
.call_account(vec![borrow, sell, fixture.settle_book_call()], 2)
.await
.unwrap();
assert_eq!(
fixture.account_balance(fixture.collateral_asset).await,
sale
);
fixture
.publish_base_price(1_000_000_000_000_000_000u128)
.await;
let repay = fixture.repay_base_from_collateral_call(fixture.base_asset, quantity);
let response = fixture.call_account(vec![repay], 1).await.unwrap();
let repaid = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<BaseDebtRepaidFromCollateral>(
&response.tx_status.receipts,
)
.unwrap();
let value = repaid[0].value;
let fee = repaid[0].fee;
assert_eq!(value, 1_000_000);
assert_eq!(fee, 100);
assert_eq!(repaid[0].decapitalised_total, value);
assert_eq!(fixture.session().await.decapitalised, value);
let realised = sale - value;
let parent_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, sale)),
);
let response = fixture.call_account(vec![withdraw], 4).await.unwrap();
let withdrawn = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<MarginWithdrawn>(&response.tx_status.receipts)
.unwrap();
assert_eq!(withdrawn.len(), 1);
assert_eq!(withdrawn[0].from_account, sale - fee);
assert_eq!(withdrawn[0].from_pool, fee);
assert_eq!(withdrawn[0].share_qty, (sale - fee - value).div_ceil(10));
assert_eq!(fixture.session().await.decapitalised, 0);
let closed = fixture.closed_event(&fixture.close_session().await);
assert_eq!(closed.reason, SettlementReason::UserClose);
assert_eq!(closed.bad_debt, 0);
assert_eq!(closed.fees_accrued, fee);
let platform_total =
withdrawn[0].share_qty + closed.platform_total + closed.fees_accrued;
let parent_gain = (fixture
.user
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap()
- parent_before) as u64;
assert_eq!(
parent_gain + platform_total,
funded + realised,
"parent {parent_gain} + platform {platform_total} must be the {funded} funded \
plus the {realised} realised"
);
let expected = realised.div_ceil(10) + fee;
assert!(
platform_total >= expected && platform_total <= expected + 2,
"platform took {platform_total} against {expected} = 10% of the {realised} \
realised plus the {fee} fee (withdraw {}, close {}, repayment {})",
withdrawn[0].share_qty,
closed.platform_total,
closed.fees_accrued
);
}
#[tokio::test]
async fn audit_base_repay_never_narrows_the_gap_when_the_asset_leg_flips_sides() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let quantity = 1_000_000_000u64;
let sold = 500_000_000u64;
fixture.fund_collateral(4_000_000).await;
fixture
.rest_order(2_000_000, sold, fixture.collateral_asset, 1_000_000)
.await;
let borrow = fixture.pool_call(
fn_selector!(borrow(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.base_asset, quantity)),
);
let sell = fixture.book_call(2_000_000, sold, fixture.base_asset, sold);
fixture
.call_account(vec![borrow, sell, fixture.settle_book_call()], 2)
.await
.unwrap();
assert_eq!(fixture.debt(fixture.base_asset).await, quantity);
assert_eq!(fixture.account_balance(fixture.base_asset).await, sold);
let before = fixture.lines().await;
let before_v = fixture.session_value().await;
let before_gap = before_v - i128::try_from(before.threshold.as_u128()).unwrap();
assert_eq!(before_v, 14_000_000);
let repay = fixture.repay_base_from_collateral_call(fixture.base_asset, quantity);
let response = fixture.call_account(vec![repay], 1).await.unwrap();
let events = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<BaseDebtRepaidFromCollateral>(
&response.tx_status.receipts,
)
.unwrap();
assert_eq!(events[0].value, 2_000_000, "the WHOLE 1e9 at the plain ask");
assert_eq!(events[0].fee, 200);
assert_eq!(fixture.debt(fixture.base_asset).await, 0);
let after = fixture.lines().await;
let after_v = fixture.session_value().await;
let after_gap = after_v - i128::try_from(after.threshold.as_u128()).unwrap();
assert!(
after_v < before_v,
"V must fall across the flip: {before_v} -> {after_v}"
);
assert!(
after_gap < before_gap,
"the gap to the liquidation line must never widen: {before_gap} -> {after_gap}"
);
assert_eq!(
before_gap - after_gap,
i128::from((events[0].value - 2_000_000) + events[0].fee),
"the gap gives up exactly the fee - the charge equals the plain mark and \
the bid/ask spread is zero here, so nothing else is charged"
);
}
#[tokio::test]
async fn a_plain_registry_upgrade_keeps_the_prop_wiring() {
use crate::trade_account_registry::{
TradeAccountRegistryConfigurables,
TradeAccountRegistryDeployConfig,
TradeAccountRegistryManager,
};
let fixture = PropFixture::new().await;
let registry = TradeAccountRegistryManager::new(
fixture.deployer.clone(),
fixture.deployment.registry_id,
);
let oracle_id = fixture.deployment.oracle_id;
let wiring = async |registry: &TradeAccountRegistryManager<Wallet>| {
let oracle = registry
.registry
.methods()
.get_prop_oracle_id()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value;
let account_proxy = registry
.registry
.methods()
.default_prop_bytecode()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value;
(oracle, account_proxy)
};
let (live_oracle, live_account_proxy) = wiring(®istry).await;
assert_eq!(live_oracle, oracle_id);
assert_ne!(live_account_proxy, ContractId::zeroed());
let preserved = registry
.live_prop_config(TradeAccountRegistryConfigurables::default())
.await
.unwrap();
registry
.upgrade(
ContractId::zeroed(),
ContractId::zeroed(),
&TradeAccountRegistryDeployConfig {
registry_config: preserved,
..Default::default()
},
)
.await
.unwrap();
let (oracle_after_fix, proxy_after_fix) = wiring(®istry).await;
assert_eq!(
oracle_after_fix, oracle_id,
"the upgrade must preserve PROP_ACCOUNT_ORACLE_CONTRACT_ID"
);
assert_eq!(
proxy_after_fix, live_account_proxy,
"the upgrade must preserve DEFAULT_PROP_ACCOUNT_PROXY"
);
registry
.upgrade(
ContractId::zeroed(),
ContractId::zeroed(),
&TradeAccountRegistryDeployConfig::default(),
)
.await
.unwrap();
let (oracle_after_bug, proxy_after_bug) = wiring(®istry).await;
assert_eq!(
oracle_after_bug,
ContractId::zeroed(),
"a default-config upgrade zeroes the oracle - the regression this guards"
);
assert_eq!(proxy_after_bug, ContractId::zeroed());
let carried: Configurables = registry
.live_prop_config(TradeAccountRegistryConfigurables::default())
.await
.unwrap()
.into();
assert!(
carried.offsets_with_data.is_empty(),
"a zeroed registry has no wiring to carry, and must not invent any"
);
}
#[tokio::test]
async fn a_pinned_pool_survives_a_derivation_change() {
let fixture = PropFixture::new().await;
let funded_pool = fixture.deployment.pool_id;
let mut moved = PropDeployConfig::new(fixture.collateral_asset);
moved.salt = Salt::new([0x5a; 32]);
let derived = PropDeployment::deploy(&fixture.deployer, &moved)
.await
.unwrap();
assert_ne!(
derived.pool_id, funded_pool,
"the derivation must actually move, or this test proves nothing"
);
moved.existing_pool = Some(funded_pool);
let deployed = PropDeployment::deploy(&fixture.deployer, &moved)
.await
.unwrap();
assert_eq!(
deployed.pool_id, funded_pool,
"a pinned pool must win over the derivation in the real run"
);
let session = PropMarginPoolContract::new(deployed.pool_id, fixture.deployer.clone())
.methods()
.get_session(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value;
assert!(
session.is_some(),
"the pinned pool must be the funded one, not a fresh deployment"
);
}
#[tokio::test]
async fn the_fee_cover_gate_is_unreachable_behind_the_net_floor() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let floor = LINE / 5;
fixture.fund_collateral(1_000_000).await;
fixture
.call_account(vec![fixture.prolong_call(100)], 1)
.await
.unwrap();
let session = fixture.session().await;
let collateral = session.collateral;
let fees = session.fees_accrued;
assert_eq!(collateral, COLLATERAL + 1_000_000);
assert_eq!(fees, 600_000, "100 days at the Day price");
let floor_allows = collateral - fees - floor;
let fees_would_allow = collateral - fees;
assert!(
floor_allows < fees_would_allow,
"the floor must bind first: {floor_allows} vs {fees_would_allow}"
);
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, floor_allows + 1)),
);
let error = fixture.call_account(vec![withdraw], 2).await.unwrap_err();
let rendered = error.to_string();
assert!(
rendered.contains("BelowTierCollateral"),
"the net floor is what binds: {error:#}"
);
assert!(
!rendered.contains("FeesNotCovered"),
"the fee gate must be unreachable: {error:#}"
);
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, floor_allows)),
);
fixture.call_account(vec![withdraw], 2).await.unwrap();
let after = fixture.session().await;
assert_eq!(after.collateral, collateral - floor_allows);
assert_eq!(after.collateral - after.fees_accrued, floor);
}
#[tokio::test]
async fn a_redeploy_reconciles_the_account_implementation_on_the_oracle() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let fixture = PropFixture::new().await;
let oracle = &fixture.deployment.oracle;
let correct = ContractId::from(fixture.deployment.account_blob_id);
assert_eq!(
oracle
.methods()
.get_prop_account_impl()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(correct),
"the first deploy initializes the pointer"
);
let stale = ContractId::from(fixture.deployment.pool_blob_id);
assert_ne!(stale, correct, "the stand-in must actually differ");
oracle
.methods()
.set_prop_account_impl(stale)
.call()
.await
.unwrap();
let mut config = PropDeployConfig::new(fixture.collateral_asset);
config.max_offchain_age_seconds = Some(TEST_MAX_OFFCHAIN_AGE_SECONDS);
let again = PropDeployment::deploy(&fixture.deployer, &config)
.await
.unwrap();
assert_eq!(
again.oracle_id, fixture.deployment.oracle_id,
"the redeploy must resolve the SAME oracle, or this proves nothing"
);
assert_eq!(
oracle
.methods()
.get_prop_account_impl()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(correct),
"the redeploy must put the account implementation back"
);
}
#[tokio::test]
async fn admin_controls_user_discounts() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let fixture = PropFixture::new().await;
let user = Identity::Address(fixture.user.address());
let raw = |tier_id: Option<u64>| {
let pool = fixture.deployment.pool.clone();
async move {
pool.methods()
.get_user_discount(user, tier_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
}
};
let resolved = |tier_id: u64| {
let pool = fixture.deployment.pool.clone();
async move {
pool.methods()
.resolve_user_discount(user, tier_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
}
};
assert_eq!(raw(None).await, None);
assert_eq!(raw(Some(TIER_ID)).await, None);
assert_eq!(resolved(TIER_ID).await, None);
let wide = UserDiscount {
discount_bps: 2_500,
scope: DiscountScope::FirstPremium,
};
let tier_override = UserDiscount {
discount_bps: 7_500,
scope: DiscountScope::AllPremiums,
};
assert!(
fixture
.deployment
.pool
.methods()
.set_user_discount(user, None, Some(wide.clone()))
.call()
.await
.is_err()
);
let manager = Identity::Address(fixture.outsider.address());
let manager_pool = fixture
.deployment
.pool
.clone()
.with_account(fixture.outsider.clone());
assert!(
manager_pool
.methods()
.grant_role(DISCOUNT_MANAGER_ROLE, manager)
.call()
.await
.is_err()
);
fixture
.deployment
.pool
.methods()
.grant_role(DISCOUNT_MANAGER_ROLE, manager)
.call()
.await
.unwrap();
assert!(
fixture
.deployment
.pool
.methods()
.has_role(DISCOUNT_MANAGER_ROLE, manager)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
for slot in [None, Some(TIER_ID)] {
assert!(
manager_pool
.methods()
.set_user_discount(
user,
slot,
Some(UserDiscount {
discount_bps: 10_001,
scope: DiscountScope::AllPremiums,
}),
)
.call()
.await
.is_err()
);
}
let response = manager_pool
.methods()
.set_user_discount(user, None, Some(wide.clone()))
.call()
.await
.unwrap();
let events = response
.decode_logs_with_type::<UserDiscountChanged>()
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].user, user);
assert_eq!(events[0].tier_id, None);
assert_eq!(events[0].discount, Some(wide.clone()));
assert!(events[0].timestamp.unix != 0);
assert_eq!(raw(None).await, Some(wide.clone()));
assert_eq!(raw(Some(TIER_ID)).await, None);
assert_eq!(resolved(TIER_ID).await, Some(wide.clone()));
assert_eq!(resolved(TIER_ID + 1).await, Some(wide.clone()));
let response = manager_pool
.methods()
.set_user_discount(user, Some(TIER_ID), Some(tier_override.clone()))
.call()
.await
.unwrap();
let events = response
.decode_logs_with_type::<UserDiscountChanged>()
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].tier_id, Some(TIER_ID));
assert_eq!(events[0].discount, Some(tier_override.clone()));
assert_eq!(raw(None).await, Some(wide.clone()));
assert_eq!(raw(Some(TIER_ID)).await, Some(tier_override.clone()));
assert_eq!(resolved(TIER_ID).await, Some(tier_override.clone()));
assert_eq!(resolved(TIER_ID + 1).await, Some(wide.clone()));
let response = manager_pool
.methods()
.set_user_discount(user, Some(TIER_ID), None)
.call()
.await
.unwrap();
let events = response
.decode_logs_with_type::<UserDiscountChanged>()
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].tier_id, Some(TIER_ID));
assert_eq!(events[0].discount, None);
assert_eq!(raw(Some(TIER_ID)).await, None);
assert_eq!(resolved(TIER_ID).await, Some(wide.clone()));
let response = manager_pool
.methods()
.set_user_discount(user, None, None)
.call()
.await
.unwrap();
let events = response
.decode_logs_with_type::<UserDiscountChanged>()
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].tier_id, None);
assert_eq!(events[0].discount, None);
assert_eq!(raw(None).await, None);
assert_eq!(resolved(TIER_ID).await, None);
fixture
.deployment
.pool
.methods()
.revoke_role(DISCOUNT_MANAGER_ROLE, manager)
.call()
.await
.unwrap();
assert!(
manager_pool
.methods()
.set_user_discount(user, None, Some(wide))
.call()
.await
.is_err()
);
}
#[tokio::test]
async fn a_discounted_entry_pays_the_rounded_down_premium() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let parent = Identity::Address(fixture.user.address());
fixture
.deployment
.pool
.methods()
.grant_role(
DISCOUNT_MANAGER_ROLE,
Identity::Address(fixture.deployer.address()),
)
.call()
.await
.unwrap();
fixture
.deployment
.pool
.methods()
.set_user_discount(
parent,
None,
Some(UserDiscount {
discount_bps: 2_500,
scope: DiscountScope::FirstPremium,
}),
)
.call()
.await
.unwrap();
let open_fee = 100_001u64;
let tier_params = TierParams {
line: LINE,
leverage: 5,
duration: 64_800,
maintenance_bps: 250,
open_buffer_bps: 375,
liq_price_factor: 9_900,
prolong_fee: [0, 6_000, 76_000, 738_000],
max_credit_line_bps: 20_000,
max_price_age: u64::MAX,
open_fee,
profit_share_bps: 1_000,
price_band_bps: 1_000,
};
fixture
.deployment
.pool
.methods()
.publish_tier_version(TIER_ID, tier_params, vec![fixture.order_book.contract_id])
.with_contract_ids(&[
fixture.order_book.contract_id,
fixture.deployment.price_feed_id,
])
.call()
.await
.unwrap();
let child = fixture
.deployment
.deploy_account(&fixture.user, parent, 1)
.await
.unwrap();
let account = PropAccountContract::new(child.contract_id(), fixture.user.clone());
let platform_before = fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let opened_response = account
.methods()
.start_session(TIER_ID, COLLATERAL, ProlongPeriod::SixHours)
.call_params(CallParameters::new(
COLLATERAL,
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>(&opened_response.tx_status.receipts)
.unwrap();
assert_eq!(opened.len(), 1);
assert_eq!(opened[0].open_fee, 75_000);
assert_eq!(opened[0].fees_accrued, 75_000);
assert_eq!(
fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
platform_before + 75_000
);
assert_eq!(opened[0].credit_line, 9_925_000);
let prolong = fixture.prolong_call(3);
let response = fixture.call_account(vec![prolong], 1).await.unwrap();
let prolonged = response
.decode_logs_with_type::<SessionProlonged>()
.unwrap();
assert_eq!(prolonged.len(), 1);
assert_eq!(prolonged[0].fee, 18_000);
}
#[tokio::test]
async fn an_all_premiums_discount_prices_prolongation_rounded_down() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let parent = Identity::Address(fixture.user.address());
fixture
.deployment
.pool
.methods()
.grant_role(
DISCOUNT_MANAGER_ROLE,
Identity::Address(fixture.deployer.address()),
)
.call()
.await
.unwrap();
fixture
.deployment
.pool
.methods()
.set_user_discount(
parent,
None,
Some(UserDiscount {
discount_bps: 3_333,
scope: DiscountScope::AllPremiums,
}),
)
.call()
.await
.unwrap();
let times = 3u64;
let platform_before = fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap();
let pool_before = fixture.pool_balance(fixture.collateral_asset).await;
let before = fixture.session().await;
let prolong = fixture.prolong_call(times);
let response = fixture.call_account(vec![prolong], 1).await.unwrap();
let prolonged = response
.decode_logs_with_type::<SessionProlonged>()
.unwrap();
assert_eq!(prolonged.len(), 1);
assert_eq!(prolonged[0].fee, 12_000);
assert_eq!(prolonged[0].fees_accrued, 12_000);
assert_eq!(prolonged[0].seconds, 86_400 * times);
assert_eq!(
fixture
.deployer
.get_asset_balance(&fixture.collateral_asset)
.await
.unwrap(),
platform_before + 12_000
);
assert_eq!(
fixture.pool_balance(fixture.collateral_asset).await,
pool_before - 12_000
);
let after = fixture.session().await;
assert_eq!(after.expires_at, before.expires_at + 86_400 * times);
assert_eq!(after.fees_accrued, 12_000);
fixture
.deployment
.pool
.methods()
.set_user_discount(
parent,
None,
Some(UserDiscount {
discount_bps: 10_000,
scope: DiscountScope::AllPremiums,
}),
)
.call()
.await
.unwrap();
let prolong = fixture.prolong_call(1);
let response = fixture.call_account(vec![prolong], 1).await.unwrap();
let prolonged = response
.decode_logs_with_type::<SessionProlonged>()
.unwrap();
assert_eq!(prolonged.len(), 1);
assert_eq!(prolonged[0].fee, 0);
assert_eq!(prolonged[0].fees_accrued, 12_000);
}
#[tokio::test]
async fn a_tier_scoped_discount_ignores_other_tiers() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let parent = Identity::Address(fixture.user.address());
fixture
.deployment
.pool
.methods()
.grant_role(
DISCOUNT_MANAGER_ROLE,
Identity::Address(fixture.deployer.address()),
)
.call()
.await
.unwrap();
fixture
.deployment
.pool
.methods()
.set_user_discount(
parent,
Some(TIER_ID + 1),
Some(UserDiscount {
discount_bps: 5_000,
scope: DiscountScope::AllPremiums,
}),
)
.call()
.await
.unwrap();
let prolong = fixture.prolong_call(1);
let response = fixture.call_account(vec![prolong], 1).await.unwrap();
let prolonged = response
.decode_logs_with_type::<SessionProlonged>()
.unwrap();
assert_eq!(prolonged.len(), 1);
assert_eq!(prolonged[0].fee, 6_000);
fixture
.deployment
.pool
.methods()
.set_user_discount(
parent,
Some(TIER_ID),
Some(UserDiscount {
discount_bps: 5_000,
scope: DiscountScope::AllPremiums,
}),
)
.call()
.await
.unwrap();
let prolong = fixture.prolong_call(1);
let response = fixture.call_account(vec![prolong], 1).await.unwrap();
let prolonged = response
.decode_logs_with_type::<SessionProlonged>()
.unwrap();
assert_eq!(prolonged.len(), 1);
assert_eq!(prolonged[0].fee, 3_000);
assert_eq!(prolonged[0].fees_accrued, 9_000);
}
#[tokio::test]
async fn a_tier_override_beats_the_user_wide_grant() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let parent = Identity::Address(fixture.user.address());
fixture
.deployment
.pool
.methods()
.grant_role(
DISCOUNT_MANAGER_ROLE,
Identity::Address(fixture.deployer.address()),
)
.call()
.await
.unwrap();
fixture
.deployment
.pool
.methods()
.set_user_discount(
parent,
None,
Some(UserDiscount {
discount_bps: 5_000,
scope: DiscountScope::AllPremiums,
}),
)
.call()
.await
.unwrap();
let prolong = fixture.prolong_call(1);
let response = fixture.call_account(vec![prolong], 1).await.unwrap();
let prolonged = response
.decode_logs_with_type::<SessionProlonged>()
.unwrap();
assert_eq!(prolonged.len(), 1);
assert_eq!(prolonged[0].fee, 3_000);
fixture
.deployment
.pool
.methods()
.set_user_discount(
parent,
Some(TIER_ID),
Some(UserDiscount {
discount_bps: 2_500,
scope: DiscountScope::AllPremiums,
}),
)
.call()
.await
.unwrap();
let prolong = fixture.prolong_call(1);
let response = fixture.call_account(vec![prolong], 1).await.unwrap();
let prolonged = response
.decode_logs_with_type::<SessionProlonged>()
.unwrap();
assert_eq!(prolonged.len(), 1);
assert_eq!(prolonged[0].fee, 4_500);
fixture
.deployment
.pool
.methods()
.set_user_discount(
parent,
Some(TIER_ID),
Some(UserDiscount {
discount_bps: 0,
scope: DiscountScope::AllPremiums,
}),
)
.call()
.await
.unwrap();
let prolong = fixture.prolong_call(1);
let response = fixture.call_account(vec![prolong], 1).await.unwrap();
let prolonged = response
.decode_logs_with_type::<SessionProlonged>()
.unwrap();
assert_eq!(prolonged.len(), 1);
assert_eq!(prolonged[0].fee, 6_000);
assert_eq!(
fixture
.deployment
.pool
.methods()
.resolve_user_discount(parent, TIER_ID + 1)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(UserDiscount {
discount_bps: 5_000,
scope: DiscountScope::AllPremiums,
})
);
fixture
.deployment
.pool
.methods()
.set_user_discount(parent, Some(TIER_ID), None)
.call()
.await
.unwrap();
let prolong = fixture.prolong_call(1);
let response = fixture.call_account(vec![prolong], 1).await.unwrap();
let prolonged = response
.decode_logs_with_type::<SessionProlonged>()
.unwrap();
assert_eq!(prolonged.len(), 1);
assert_eq!(prolonged[0].fee, 3_000);
assert_eq!(prolonged[0].fees_accrued, 16_500);
}
#[tokio::test]
async fn removing_a_discount_clears_the_slot_it_addresses() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let fixture = PropFixture::new().await;
let user = Identity::Address(fixture.user.address());
const DISCOUNT_MANAGER_ROLE: u64 = 1;
fixture
.deployment
.pool
.methods()
.grant_role(
DISCOUNT_MANAGER_ROLE,
Identity::Address(fixture.deployer.address()),
)
.call()
.await
.unwrap();
let grant = |bps| UserDiscount {
discount_bps: bps,
scope: DiscountScope::AllPremiums,
};
let resolved = |tier_id: u64| {
let pool = fixture.deployment.pool.clone();
async move {
pool.methods()
.resolve_user_discount(user, tier_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
}
};
for (slot, bps) in [(None, 1_000u64), (Some(TIER_ID), 5_000)] {
fixture
.deployment
.pool
.methods()
.set_user_discount(user, slot, Some(grant(bps)))
.call()
.await
.unwrap();
}
assert!(
fixture
.deployment
.pool
.clone()
.with_account(fixture.outsider.clone())
.methods()
.remove_user_discount(user, None)
.call()
.await
.is_err()
);
let response = fixture
.deployment
.pool
.methods()
.remove_user_discount(user, None)
.call()
.await
.unwrap();
let events = response
.decode_logs_with_type::<UserDiscountsRemoved>()
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].user, user);
assert_eq!(events[0].tier_id, None);
assert!(events[0].removed);
assert!(events[0].timestamp.unix != 0);
assert_eq!(resolved(TIER_ID).await, Some(grant(5_000)));
assert_eq!(resolved(TIER_ID + 1).await, None);
let response = fixture
.deployment
.pool
.methods()
.remove_user_discount(user, Some(TIER_ID))
.call()
.await
.unwrap();
let events = response
.decode_logs_with_type::<UserDiscountsRemoved>()
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].tier_id, Some(TIER_ID));
assert!(events[0].removed);
assert_eq!(resolved(TIER_ID).await, None);
let response = fixture
.deployment
.pool
.methods()
.remove_user_discount(user, Some(TIER_ID))
.call()
.await
.unwrap();
let events = response
.decode_logs_with_type::<UserDiscountsRemoved>()
.unwrap();
assert_eq!(events.len(), 1);
assert!(!events[0].removed);
}