use super::*;
use crate::prop::{
MarginTradeAccountWithdrawn,
ParallelRevokeArgs,
PropOrderBookCleanup,
};
async fn sibling_account(
fixture: &PropFixture,
index: u64,
) -> (ContractId, PropAccountContract<Wallet>) {
let parent = Identity::Address(fixture.user.address());
let child = fixture
.deployment
.deploy_account(&fixture.user, parent, index)
.await
.unwrap();
let child_id = child.contract_id();
(
child_id,
PropAccountContract::new(child_id, fixture.user.clone()),
)
}
async fn arm_session_key(
account: &PropAccountContract<Wallet>,
oracle_id: ContractId,
session_id: Identity,
) {
let expiry = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 86_400;
account
.methods()
.set_session(ParallelSessionArgs {
nonce: U256::zero(),
session_id,
expiry: Time { unix: expiry },
contract_ids: vec![],
})
.with_contract_ids(&[oracle_id])
.call()
.await
.unwrap();
}
#[tokio::test]
async fn a_stolen_session_key_can_spend_but_can_never_name_the_payee() {
let mut fixture = PropFixture::new().await;
fixture.fund_collateral(500_000).await;
let provider = fixture.user.try_provider().unwrap().clone();
let parent = fixture.user.address();
let thief = fixture.outsider.address();
let parent_before = provider
.get_asset_balance(&parent, &fixture.collateral_asset)
.await
.unwrap();
let thief_before = provider
.get_asset_balance(&thief, &fixture.collateral_asset)
.await
.unwrap();
let withdraw = fixture.pool_call(
fn_selector!(withdraw(AssetId, u64)),
0,
AssetId::default(),
Some(call_data!(fixture.collateral_asset, 100_000u64)),
);
fixture.call_account(vec![withdraw], 2).await.unwrap();
let parent_after = provider
.get_asset_balance(&parent, &fixture.collateral_asset)
.await
.unwrap();
let thief_after = provider
.get_asset_balance(&thief, &fixture.collateral_asset)
.await
.unwrap();
assert_eq!(
parent_after - parent_before,
100_000u128,
"the withdrawal must reach the parent"
);
assert_eq!(
thief_after, thief_before,
"the session key names no payee: the thief is paid nothing"
);
let draw = fixture.draw_call(1_000_000);
fixture.call_account(vec![draw], 1).await.unwrap();
assert_eq!(
fixture.account_balance(fixture.collateral_asset).await,
1_000_000,
"a draw funds the account contract, which only the parent can empty"
);
}
#[tokio::test]
async fn a_session_key_reaches_the_pool_and_its_tier_books_and_nothing_else() {
let mut fixture = PropFixture::new().await;
let book_call = CallContractArg {
contract_id: fixture.order_book.contract_id,
function_selector: Bytes(fn_selector!(get_base_asset())),
call_params: CallParams {
coins: 0,
asset_id: AssetId::default(),
gas: 10_000_000,
},
call_data: None,
};
fixture.call_account(vec![book_call], 0).await.unwrap();
let feed_call = CallContractArg {
contract_id: fixture.deployment.price_feed_id,
function_selector: Bytes(fn_selector!(owner())),
call_params: CallParams {
coins: 0,
asset_id: AssetId::default(),
gas: 10_000_000,
},
call_data: None,
};
let error = fixture.call_account(vec![feed_call], 0).await.unwrap_err();
assert!(
error.to_string().contains("TargetNotAllowed"),
"only the pool and the tier's books are reachable: {error:#}"
);
let stranger_book = OrderBookDeploy::deploy(
&fixture.deployer,
AssetId::new([0x44; 32]),
fixture.collateral_asset,
&OrderBookDeployConfig::default(),
)
.await
.unwrap();
let stranger_call = CallContractArg {
contract_id: stranger_book.contract_id,
function_selector: Bytes(fn_selector!(get_base_asset())),
call_params: CallParams {
coins: 0,
asset_id: AssetId::default(),
gas: 10_000_000,
},
call_data: None,
};
let error = fixture
.call_account(vec![stranger_call], 0)
.await
.unwrap_err();
assert!(
error.to_string().contains("TargetNotAllowed"),
"an unlisted book is not a target: {error:#}"
);
}
#[tokio::test]
async fn closing_the_session_takes_the_books_away_from_the_key() {
let mut fixture = PropFixture::new().await;
async fn allowed(fixture: &PropFixture) -> bool {
fixture
.deployment
.pool
.methods()
.is_call_allowed(fixture.child_id, fixture.order_book.contract_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
}
assert!(allowed(&fixture).await);
fixture.close_session().await;
assert!(!allowed(&fixture).await, "the pin goes with the session");
let book_call = CallContractArg {
contract_id: fixture.order_book.contract_id,
function_selector: Bytes(fn_selector!(get_base_asset())),
call_params: CallParams {
coins: 0,
asset_id: AssetId::default(),
gas: 10_000_000,
},
call_data: None,
};
let error = fixture.call_account(vec![book_call], 0).await.unwrap_err();
assert!(
error.to_string().contains("TargetNotAllowed"),
"a key that outlived its session reaches no book: {error:#}"
);
let draw = fixture.draw_call(1);
let error = fixture.call_account(vec![draw], 1).await.unwrap_err();
assert!(
error.to_string().contains("NoActiveSession"),
"and nothing on the pool to act on: {error:#}"
);
}
#[tokio::test]
async fn a_stolen_session_key_can_grief_the_collateral_but_only_through_the_fee_ledger() {
let mut fixture = PropFixture::new().await;
let before = fixture.session().await;
assert_eq!(before.fees_accrued, 0);
let prolong = fixture.prolong_call(10);
fixture.call_account(vec![prolong], 1).await.unwrap();
let after = fixture.session().await;
assert_eq!(after.fees_accrued, 60_000);
assert_eq!(after.collateral, before.collateral);
assert!(
after.credit_line < before.credit_line,
"burnt fees shrink the line one for one"
);
let prolong = fixture.prolong_call(1_000);
let error = fixture.call_account(vec![prolong], 1).await.unwrap_err();
assert!(
error.to_string().contains("ProlongAboveCollateral"),
"the grief stops at the collateral: {error:#}"
);
}
#[tokio::test]
async fn a_captured_batch_cannot_be_replayed() {
let mut fixture = PropFixture::new().await;
let draw = fixture.draw_call(500_000);
let (user_signature, cosigner_signature, calls) =
fixture.signed_calls(vec![draw]).await;
let submit = |fixture: &PropFixture| {
fixture
.account
.methods()
.call_contracts(
user_signature.clone(),
cosigner_signature.clone(),
calls.clone(),
)
.with_contracts(&[
&fixture.deployment.oracle,
&fixture.deployment.pool,
&fixture.deployment.price_feed,
&fixture.deployment.registry,
&fixture.order_book.order_book,
])
.with_variable_output_policy(VariableOutputPolicy::Exactly(1))
};
submit(&fixture).call().await.unwrap();
assert_eq!(fixture.session().await.drawn_quote, 500_000);
let error = submit(&fixture).call().await.unwrap_err();
assert!(
error.to_string().contains("AlreadyUsed"),
"the second submission must be refused by the nonce bitmap: {error:#}"
);
assert_eq!(
fixture.session().await.drawn_quote,
500_000,
"the replay moved nothing"
);
}
#[tokio::test]
async fn a_revoked_or_expired_session_key_is_refused_as_a_signature_failure() {
let mut fixture = PropFixture::new().await;
let draw = fixture.draw_call(100_000);
fixture.call_account(vec![draw], 1).await.unwrap();
fixture
.account
.methods()
.revoke_session(ParallelRevokeArgs {
nonce: U256::zero(),
})
.with_contract_ids(&[fixture.deployment.oracle_id])
.call()
.await
.unwrap();
assert!(
fixture
.account
.methods()
.get_current_session()
.with_contract_ids(&[fixture.deployment.oracle_id])
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
.is_none()
);
let draw = fixture.draw_call(100_000);
let error = fixture.call_account(vec![draw], 1).await.unwrap_err();
assert!(
error.to_string().contains("InvalidUserSig"),
"a revoked key is a signature failure, not a parenthood failure: {error:#}"
);
let latest = fixture
.user
.try_provider()
.unwrap()
.latest_block_time()
.await
.unwrap()
.unwrap()
.timestamp() as u64;
let error = fixture
.account
.methods()
.set_session(ParallelSessionArgs {
nonce: U256::zero(),
session_id: Identity::Address(fixture.user.address()),
expiry: Time {
unix: latest.saturating_sub(1),
},
contract_ids: vec![],
})
.with_contract_ids(&[fixture.deployment.oracle_id])
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("SessionInThePast"),
"an already-dead key is refused at arming: {error:#}"
);
}
#[tokio::test]
async fn a_batch_without_the_platforms_cosignature_is_refused() {
let mut fixture = PropFixture::new().await;
let draw = fixture.draw_call(100_000);
let (user_signature, _, calls) = fixture.signed_calls(vec![draw]).await;
let chain_id = *fixture
.user
.provider()
.consensus_parameters()
.await
.unwrap()
.chain_id();
let forged = fixture
.user
.signer()
.sign(generate_trial_trade_approval_signing_payload(
chain_id,
fixture.child_id,
calls.nonce,
calls.call_contract_args.clone(),
))
.await
.unwrap();
let error = fixture
.account
.methods()
.call_contracts(
user_signature,
Signature::Secp256k1(Secp256k1 { bits: *forged }),
calls,
)
.with_contracts(&[
&fixture.deployment.oracle,
&fixture.deployment.pool,
&fixture.deployment.price_feed,
&fixture.deployment.registry,
&fixture.order_book.order_book,
])
.with_variable_output_policy(VariableOutputPolicy::Exactly(1))
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("InvalidCosigner"),
"the platform's signature is not optional: {error:#}"
);
}
#[tokio::test]
async fn a_batch_cosigned_for_one_account_cannot_be_replayed_on_its_sibling() {
let mut fixture = PropFixture::new().await;
let (sibling_id, sibling) = sibling_account(&fixture, 1).await;
arm_session_key(
&sibling,
fixture.deployment.oracle_id,
Identity::Address(fixture.user.address()),
)
.await;
let draw = fixture.draw_call(100_000);
let (user_signature, cosigner_signature, calls) =
fixture.signed_calls(vec![draw]).await;
let error = sibling
.methods()
.call_contracts(user_signature, cosigner_signature, calls)
.with_contracts(&[
&fixture.deployment.oracle,
&fixture.deployment.pool,
&fixture.deployment.price_feed,
&fixture.deployment.registry,
&fixture.order_book.order_book,
])
.with_variable_output_policy(VariableOutputPolicy::Exactly(1))
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("InvalidCosigner"),
"the cosignature is bound to the account it was issued for: {error:#}"
);
assert!(
fixture
.deployment
.pool
.methods()
.get_session(sibling_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
.is_none(),
"and the sibling never opened anything"
);
}
#[tokio::test]
async fn only_the_parent_arms_opens_closes_and_withdraws() {
let fixture = PropFixture::new().await;
let intruder = PropAccountContract::new(fixture.child_id, fixture.outsider.clone());
let expiry = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 86_400;
let error = intruder
.methods()
.set_session(ParallelSessionArgs {
nonce: U256::zero(),
session_id: Identity::Address(fixture.outsider.address()),
expiry: Time { unix: expiry },
contract_ids: vec![],
})
.with_contract_ids(&[fixture.deployment.oracle_id])
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("NotParent"),
"a stranger may not arm a key on someone else's account: {error:#}"
);
let error = intruder
.methods()
.revoke_session(ParallelRevokeArgs {
nonce: U256::zero(),
})
.with_contract_ids(&[fixture.deployment.oracle_id])
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("NotParent"),
"nor revoke one: {error:#}"
);
let error = intruder
.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("NotParent"),
"nor close the session out from under it: {error:#}"
);
let error = intruder
.methods()
.withdraw(fixture.collateral_asset, 1)
.with_contracts(&[&fixture.deployment.pool, &fixture.deployment.oracle])
.with_variable_output_policy(VariableOutputPolicy::Exactly(1))
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("NotParent"),
"nor drain it: {error:#}"
);
let error = intruder
.methods()
.start_session(TIER_ID, 1, ProlongPeriod::SixHours)
.call_params(CallParameters::new(1, fixture.collateral_asset, u64::MAX))
.unwrap()
.with_contract_ids(&[
fixture.deployment.oracle_id,
fixture.deployment.pool_id,
fixture.deployment.registry_id,
])
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("NotParent"),
"nor open one on it: {error:#}"
);
}
#[tokio::test]
async fn only_the_pool_can_drive_cleanup_release_and_sweep() {
let mut fixture = PropFixture::new().await;
let draw = fixture.draw_call(1_000_000);
fixture.call_account(vec![draw], 1).await.unwrap();
assert_eq!(
fixture.account_balance(fixture.collateral_asset).await,
1_000_000
);
for caller in [fixture.outsider.clone(), fixture.user.clone()] {
let account = PropAccountContract::new(fixture.child_id, caller.clone());
let error = account
.methods()
.pool_release(
fixture.collateral_asset,
1_000_000,
Identity::Address(fixture.outsider.address()),
)
.with_contracts(&[&fixture.deployment.pool, &fixture.deployment.oracle])
.with_variable_output_policy(VariableOutputPolicy::Exactly(1))
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("NotPool"),
"release names its own payee, so only the pool may call it: {error:#}"
);
let error = account
.methods()
.pool_sweep(vec![fixture.collateral_asset])
.with_contracts(&[&fixture.deployment.pool, &fixture.deployment.oracle])
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("NotPool"),
"nor may anyone else push the account's balance into settlement: {error:#}"
);
let error = account
.methods()
.pool_cleanup(vec![PropOrderBookCleanup {
order_book: fixture.order_book.contract_id,
order_ids: vec![],
}])
.with_contracts(&[
&fixture.deployment.pool,
&fixture.deployment.oracle,
&fixture.order_book.order_book,
])
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("NotPool"),
"nor cancel its orders: {error:#}"
);
}
assert_eq!(
fixture.account_balance(fixture.collateral_asset).await,
1_000_000,
"not one coin moved"
);
}
#[tokio::test]
async fn opening_a_session_returns_every_stray_balance_to_the_parent() {
let fixture = PropFixture::new().await;
let (sibling_id, sibling) = sibling_account(&fixture, 2).await;
let stray_base = 7_000_000_000u64;
let overpay = 250_000u64;
fixture
.deployer
.force_transfer_to_contract(
sibling_id,
stray_base,
fixture.base_asset,
TxPolicies::default(),
)
.await
.unwrap();
let provider = fixture.user.try_provider().unwrap();
let parent = fixture.user.address();
let base_before = provider
.get_asset_balance(&parent, &fixture.base_asset)
.await
.unwrap();
let error = sibling
.methods()
.start_session(TIER_ID, COLLATERAL, ProlongPeriod::SixHours)
.call_params(CallParameters::new(
COLLATERAL + overpay,
fixture.collateral_asset,
u64::MAX,
))
.unwrap()
.with_contracts(&[
&fixture.deployment.oracle,
&fixture.deployment.pool,
&fixture.deployment.registry,
])
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("PaymentAmountMismatch"),
"the coins sent must equal the amount signed for: {error:#}"
);
let session = fixture
.deployment
.pool
.methods()
.get_session(sibling_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value;
assert!(
session.is_none(),
"a payment that disagrees with the named amount opens nothing"
);
sibling
.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::EstimateMinimum)
.call()
.await
.unwrap();
let base_after = provider
.get_asset_balance(&parent, &fixture.base_asset)
.await
.unwrap();
assert_eq!(
base_after - base_before,
stray_base as u128,
"the stray base asset went home to the parent"
);
let session = fixture
.deployment
.pool
.methods()
.get_session(sibling_id)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
.unwrap();
assert_eq!(session.collateral, COLLATERAL);
assert_eq!(session.session_id, 1);
}
#[tokio::test]
async fn opening_refuses_a_zero_entry_the_wrong_asset_and_a_second_seat() {
let fixture = PropFixture::new().await;
let (_, sibling) = sibling_account(&fixture, 3).await;
let error = sibling
.methods()
.start_session(TIER_ID, 0, ProlongPeriod::SixHours)
.call_params(CallParameters::new(0, fixture.collateral_asset, u64::MAX))
.unwrap()
.with_contract_ids(&[
fixture.deployment.oracle_id,
fixture.deployment.pool_id,
fixture.deployment.registry_id,
])
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("AmountIsZero"),
"a free seat is not on offer: {error:#}"
);
let error = sibling
.methods()
.start_session(TIER_ID, COLLATERAL, ProlongPeriod::SixHours)
.call_params(CallParameters::new(
COLLATERAL,
fixture.base_asset,
u64::MAX,
))
.unwrap()
.with_contract_ids(&[
fixture.deployment.oracle_id,
fixture.deployment.pool_id,
fixture.deployment.registry_id,
])
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("WrongAsset"),
"collateral is the collateral asset: {error:#}"
);
let error = fixture
.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,
])
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("SessionActive"),
"one seat per account: {error:#}"
);
}
#[tokio::test]
async fn the_accounts_own_withdrawal_waits_until_the_session_is_gone() {
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
.account
.methods()
.withdraw(fixture.collateral_asset, 1_000_000)
.with_contracts(&[&fixture.deployment.pool, &fixture.deployment.oracle])
.with_variable_output_policy(VariableOutputPolicy::Exactly(1))
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("SessionActive"),
"the pool's rules cannot be walked around while a session lives: {error:#}"
);
let repay = fixture.pool_call(
fn_selector!(return_quote()),
1_000_000,
fixture.collateral_asset,
None,
);
fixture.call_account(vec![repay], 0).await.unwrap();
fixture.close_session().await;
fixture
.deployer
.force_transfer_to_contract(
fixture.child_id,
123_456,
fixture.collateral_asset,
TxPolicies::default(),
)
.await
.unwrap();
let response = fixture
.account
.methods()
.withdraw(fixture.collateral_asset, 123_456)
.with_contracts(&[&fixture.deployment.pool, &fixture.deployment.oracle])
.with_variable_output_policy(VariableOutputPolicy::Exactly(1))
.call()
.await
.unwrap();
let events = response
.decode_logs_with_type::<MarginTradeAccountWithdrawn>()
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].account, fixture.child_id);
assert_eq!(events[0].parent, Identity::Address(fixture.user.address()));
assert_eq!(events[0].amount, 123_456);
assert!(events[0].timestamp.unix != 0);
let error = fixture
.account
.methods()
.withdraw(fixture.collateral_asset, 0)
.with_contracts(&[&fixture.deployment.pool, &fixture.deployment.oracle])
.with_variable_output_policy(VariableOutputPolicy::Exactly(1))
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("AmountIsZero"),
"a zero withdrawal is a no-op worth refusing: {error:#}"
);
}