use super::*;
use crate::prop::{
AssetBorrowed,
AssetRepaid,
PropOrderBookCleanup,
QuoteReturned,
SettlementReceived,
};
fn borrow_call(fixture: &PropFixture, asset: AssetId, amount: u64) -> CallContractArg {
fixture.pool_call(
fn_selector!(borrow(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(asset, amount)),
)
}
fn repay_call(fixture: &PropFixture, asset: AssetId, amount: u64) -> CallContractArg {
fixture.pool_call(fn_selector!(repay()), amount, asset, None)
}
async fn publish_spread(fixture: &PropFixture, bid: u128, ask: u128) {
fixture
.deployment
.price_feed
.methods()
.publish_prices(vec![PriceInput {
asset: fixture.base_asset,
bid,
ask,
timestamp: fixture.next_publish_time(),
}])
.call()
.await
.unwrap();
}
#[tokio::test]
async fn the_loan_cap_charges_an_in_kind_debt_at_the_ask() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
publish_spread(
&fixture,
1_000_000_000_000_000_000,
2_000_000_000_000_000_000,
)
.await;
let line = fixture.session().await.credit_line;
assert_eq!(line, 10_000_000);
fixture
.call_account(
vec![borrow_call(&fixture, fixture.base_asset, 4_000_000_000)],
1,
)
.await
.unwrap();
assert_eq!(fixture.debt(fixture.base_asset).await, 4_000_000_000);
let error = fixture
.call_account(
vec![borrow_call(&fixture, fixture.base_asset, 2_000_000_000)],
1,
)
.await
.unwrap_err();
assert!(
error.to_string().contains("LoanCapExceeded"),
"the cap prices the buy-back, not the sale: {error:#}"
);
let response = fixture
.call_account(
vec![borrow_call(&fixture, fixture.base_asset, 1_000_000_000)],
1,
)
.await
.unwrap();
let events = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<AssetBorrowed>(&response.tx_status.receipts)
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].debt_total, 5_000_000_000);
assert!(events[0].timestamp.unix != 0);
let error = fixture
.call_account(vec![borrow_call(&fixture, fixture.base_asset, 1)], 1)
.await
.unwrap_err();
assert!(
error.to_string().contains("LoanCapExceeded"),
"the last unit is charged like every other: {error:#}"
);
let error = fixture
.call_account(vec![fixture.draw_call(1)], 1)
.await
.unwrap_err();
assert!(
error.to_string().contains("LoanCapExceeded"),
"cash and in-kind exposure share one line: {error:#}"
);
}
#[tokio::test]
async fn a_borrow_past_the_pools_float_is_refused_before_the_ledger_moves() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let float = fixture.pool_balance(fixture.base_asset).await;
assert!(float > 0);
let error = fixture
.call_account(
vec![borrow_call(&fixture, fixture.base_asset, float + 1)],
1,
)
.await
.unwrap_err();
assert!(
error.to_string().contains("InsufficientInventory"),
"the pool lends its float, not its ledger: {error:#}"
);
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
);
assert_eq!(fixture.pool_balance(fixture.base_asset).await, float);
}
#[tokio::test]
async fn the_collateral_asset_is_routed_to_its_own_door_and_a_stranger_to_none() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
let error = fixture
.call_account(
vec![borrow_call(&fixture, fixture.collateral_asset, 1_000)],
1,
)
.await
.unwrap_err();
assert!(
error.to_string().contains("UseDraw"),
"quote is drawn, not borrowed: {error:#}"
);
let error = fixture
.call_account(
vec![fixture.repay_base_from_collateral_call(fixture.collateral_asset, 1)],
1,
)
.await
.unwrap_err();
assert!(
error.to_string().contains("UseRepayQuote"),
"a quote obligation nets without a price: {error:#}"
);
let stranger = AssetId::new([0x81; 32]);
let error = fixture
.call_account(vec![borrow_call(&fixture, stranger, 1)], 1)
.await
.unwrap_err();
assert!(
error.to_string().contains("AssetNotInTier"),
"the tier decides what may be borrowed: {error:#}"
);
let error = fixture
.call_account(
vec![fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(stranger, 1u64)),
)],
2,
)
.await
.unwrap_err();
assert!(
error.to_string().contains("AssetNotInTier"),
"and what may be withdrawn: {error:#}"
);
for (name, call) in [
("draw", fixture.draw_call(0)),
("borrow", borrow_call(&fixture, fixture.base_asset, 0)),
(
"withdraw",
fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, 0u64)),
),
),
(
"repay_from_collateral",
fixture.repay_from_collateral_call(0),
),
] {
let error = fixture.call_account(vec![call], 2).await.unwrap_err();
assert!(
error.to_string().contains("AmountIsZero"),
"{name} refuses a zero amount: {error:#}"
);
}
}
#[tokio::test]
async fn returning_more_quote_than_was_drawn_hands_the_excess_straight_back() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
fixture
.deployer
.force_transfer_to_contract(
fixture.child_id,
500_000,
fixture.collateral_asset,
TxPolicies::default(),
)
.await
.unwrap();
fixture
.call_account(vec![fixture.draw_call(1_000_000)], 1)
.await
.unwrap();
assert_eq!(
fixture.account_balance(fixture.collateral_asset).await,
1_500_000
);
let response = fixture
.call_account(
vec![fixture.pool_call(
fn_selector!(return_quote()),
1_500_000,
fixture.collateral_asset,
None,
)],
1,
)
.await
.unwrap();
let events = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<QuoteReturned>(&response.tx_status.receipts)
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(
events[0].amount, 1_000_000,
"the event reports what was credited, not what arrived"
);
assert_eq!(events[0].drawn_total, 0);
assert_eq!(fixture.session().await.drawn_quote, 0);
assert_eq!(
fixture.account_balance(fixture.collateral_asset).await,
500_000,
"the excess went home to the account, not into the pool's float"
);
}
#[tokio::test]
async fn partial_repayments_walk_the_debt_down_and_clear_the_index_exactly_once() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
async fn has_debts(fixture: &PropFixture) -> bool {
fixture
.deployment
.pool
.methods()
.has_debts(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
}
fixture
.call_account(
vec![borrow_call(&fixture, fixture.base_asset, 1_000_000_000)],
1,
)
.await
.unwrap();
assert!(has_debts(&fixture).await);
fixture
.deployer
.force_transfer_to_contract(
fixture.child_id,
1,
fixture.base_asset,
TxPolicies::default(),
)
.await
.unwrap();
fixture
.call_account(
vec![repay_call(&fixture, fixture.base_asset, 400_000_000)],
0,
)
.await
.unwrap();
assert_eq!(fixture.debt(fixture.base_asset).await, 600_000_000);
assert!(
has_debts(&fixture).await,
"a partly repaid debt still bars the exit gates"
);
let error = fixture
.call_account(
vec![repay_call(&fixture, fixture.base_asset, 600_000_001)],
0,
)
.await
.unwrap_err();
assert!(
error.to_string().contains("OverRepay"),
"the pool takes back what it lent and no more: {error:#}"
);
let response = fixture
.call_account(
vec![repay_call(&fixture, fixture.base_asset, 600_000_000)],
0,
)
.await
.unwrap();
let events = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<AssetRepaid>(&response.tx_status.receipts)
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].debt_total, 0);
assert_eq!(fixture.debt(fixture.base_asset).await, 0);
assert!(
!has_debts(&fixture).await,
"the last unit takes the asset out of the index"
);
let error = fixture
.call_account(vec![repay_call(&fixture, fixture.base_asset, 1)], 0)
.await
.unwrap_err();
assert!(
error.to_string().contains("OverRepay"),
"a cleared debt takes nothing more: {error:#}"
);
fixture.close_session().await;
assert!(
fixture
.deployment
.pool
.methods()
.get_session(fixture.child_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
.is_none()
);
}
#[tokio::test]
async fn a_settlement_receipt_is_neutral_and_scoped_to_the_session_that_sent_it() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let mut fixture = PropFixture::new().await;
fixture
.call_account(vec![fixture.draw_call(1_000_000)], 1)
.await
.unwrap();
let before = fixture.session_value().await;
fn push(fixture: &PropFixture, amount: u64, asset: AssetId) -> CallContractArg {
fixture.pool_call(fn_selector!(receive_settlement()), amount, asset, None)
}
let call = push(&fixture, 400_000, fixture.collateral_asset);
let response = fixture.call_account(vec![call], 0).await.unwrap();
let events = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<SettlementReceived>(&response.tx_status.receipts)
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].received_total, 400_000);
assert_eq!(events[0].session_id, 1);
let call = push(&fixture, 600_000, fixture.collateral_asset);
let response = fixture.call_account(vec![call], 0).await.unwrap();
let events = fixture
.deployment
.pool
.log_decoder()
.decode_logs_with_type::<SettlementReceived>(&response.tx_status.receipts)
.unwrap();
assert_eq!(events[0].received_total, 1_000_000);
assert_eq!(
fixture.session_value().await,
before,
"moving the cash across the pool's own boundary changes nothing"
);
assert_eq!(fixture.account_balance(fixture.collateral_asset).await, 0);
let stranger = AssetId::default();
fixture
.deployer
.force_transfer_to_contract(fixture.child_id, 10, stranger, TxPolicies::default())
.await
.unwrap();
let call = push(&fixture, 1, stranger);
let error = fixture.call_account(vec![call], 0).await.unwrap_err();
assert!(
error.to_string().contains("AssetNotInTier"),
"the basket holds tier assets only: {error:#}"
);
let error = fixture
.account
.methods()
.close_session(Vec::<PropOrderBookCleanup>::new())
.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 receipt is not a repayment: {error:#}"
);
}
#[tokio::test]
async fn min_sellable_separates_no_market_from_no_bid() {
let _test_guard = PROP_DEPLOY_TEST_LOCK.lock().await;
let fixture = PropFixture::new().await;
assert_eq!(
fixture
.deployment
.pool
.methods()
.min_sellable(fixture.child_id, fixture.collateral_asset)
.with_contract_ids(&[fixture.order_book.contract_id])
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
None,
"an asset no tier book trades has no minimum"
);
let error = fixture
.deployment
.pool
.methods()
.min_sellable(fixture.child_id, fixture.base_asset)
.with_contract_ids(&[fixture.order_book.contract_id])
.simulate(Execution::state_read_only())
.await
.unwrap_err();
assert!(
error.to_string().contains("NoBestBid"),
"an empty book is a liveness problem, not an absence: {error:#}"
);
fixture
.rest_order(
1_900_000,
1_000_000_000,
fixture.collateral_asset,
1_900_000,
)
.await;
assert_eq!(
fixture
.deployment
.pool
.methods()
.min_sellable(fixture.child_id, fixture.base_asset)
.with_contract_ids(&[fixture.order_book.contract_id])
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(527),
"ceil(MIN_ORDER * BASE_DECIMALS / best_bid) = ceil(1e9 / 1_900_000)"
);
}