o2-tools 0.4.7-rc2

Reusable tooling for trade account and order book contract interactions on Fuel
Documentation
//! Red regression: a $0.2516 debt buyback blocks closure under the $1 order minimum.
//! The same closure must pass with a lower minimum.

use super::*;
use crate::prop::TurboTerms;
use fuels::programs::calls::CallHandler;

// Keep the mock's generated schema types separate from the production bindings.
mod mock {
    use fuels::prelude::abigen;

    abigen!(Contract(
        name = "PropMarginPoolMockContract",
        abi = "artifacts/test_artifacts/prop-margin-pool-mock-abi.json"
    ));
}

const UNIT: u64 = 1_000_000_000;
const TIER_ID: u64 = 13;
const CLOSE_LINE: u64 = 2_500_000_000_000;
const OPEN_FEE: u64 = 12_500_000_000; // The supplied "12.5" is USDC, not atoms.
const TICKET: u64 = CLOSE_LINE / 40;
const WBTC_DEBT: u64 = 3_145;
const WBTC_PRICE: u64 = 80_000 * UNIT;
const DEBT_COST: u64 = WBTC_DEBT * WBTC_PRICE / UNIT;
const NET_PROFIT: u64 = 7 * UNIT;
const MAKER_QUANTITY: u64 = 100_000; // $8 of asks, comfortably above $1.

const CLOSE_TIER: TierParams = TierParams {
    line: CLOSE_LINE,
    leverage: 40,
    duration: 604_800,
    maintenance_bps: 100,
    open_buffer_bps: 101,
    liq_price_factor: 9_900,
    prolong_fee: [0, 0, 0, 0],
    max_credit_line_bps: 20_000,
    max_price_age: u64::MAX,
    open_fee: OPEN_FEE,
    profit_share_bps: 0,
    price_band_bps: 85,
};
const TURBO_TERMS: TurboTerms = TurboTerms {
    max_loss_bps: [150, 200, 250, 300],
    rollover_profit_bps: 800,
    max_rollovers: 3,
};

/// Production contracts, with the mock used only to inject residual debt.
async fn close_fixture(min_order: u64) -> PropFixture {
    let collateral_asset = AssetId::new([0x31; 32]); // USDC, 9 decimals.
    let base_asset = AssetId::new([0x32; 32]); // wBTC, 9 decimals.
    let mut wallets = launch_custom_provider_and_get_wallets(
        WalletsConfig::new_multiple_assets(
            3,
            [AssetId::default(), collateral_asset, base_asset]
                .into_iter()
                .map(|id| AssetConfig {
                    id,
                    num_coins: 4,
                    coin_amount: 1_000_000 * UNIT,
                })
                .collect(),
        ),
        None,
        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.collateral_decimals = 9;
    let deployment = PropDeployment::deploy(&deployer, &config).await.unwrap();
    let order_book = OrderBookDeploy::deploy(
        &deployer,
        base_asset,
        collateral_asset,
        &OrderBookDeployConfig::with_configurables(
            OrderBookConfigurables::default()
                .with_BASE_DECIMALS(UNIT)
                .unwrap()
                .with_QUOTE_DECIMALS(UNIT)
                .unwrap()
                .with_MIN_ORDER(min_order)
                .unwrap()
                .with_MAKER_FEE(0u64.into())
                .unwrap()
                .with_TAKER_FEE(0u64.into())
                .unwrap()
                .with_DUST(0)
                .unwrap(),
        ),
    )
    .await
    .unwrap();

    for asset in [collateral_asset, base_asset] {
        deployment
            .price_feed
            .methods()
            .set_asset_decimals(asset, 9)
            .call()
            .await
            .unwrap();
    }
    let now = deployer
        .try_provider()
        .unwrap()
        .latest_block_time()
        .await
        .unwrap()
        .unwrap()
        .timestamp() as u64;
    let price_scale = 1_000_000_000_000_000_000u128;
    deployment
        .price_feed
        .methods()
        .publish_prices(vec![
            PriceInput {
                asset: collateral_asset,
                bid: price_scale,
                ask: price_scale,
                timestamp: now,
            },
            PriceInput {
                asset: base_asset,
                bid: 80_000 * price_scale,
                ask: 80_000 * price_scale,
                timestamp: now,
            },
        ])
        .call()
        .await
        .unwrap();
    deployment
        .pool
        .methods()
        .publish_tier_v2(
            TIER_ID,
            CLOSE_TIER,
            TURBO_TERMS,
            vec![order_book.contract_id],
        )
        .with_contracts(&[&order_book.order_book, &deployment.price_feed])
        .call()
        .await
        .unwrap();

    deployer
        .force_transfer_to_contract(
            deployment.pool_id,
            2 * CLOSE_LINE,
            collateral_asset,
            TxPolicies::default(),
        )
        .await
        .unwrap();
    let child = deployment
        .deploy_account(&user, Identity::Address(user.address()), 0)
        .await
        .unwrap();
    let child_id = child.contract_id();
    let account = PropAccountContract::new(child_id, user.clone());
    account
        .methods()
        .start_session(TIER_ID, TICKET + OPEN_FEE, ProlongPeriod::Week)
        .call_params(CallParameters::new(
            TICKET + OPEN_FEE,
            collateral_asset,
            u64::MAX,
        ))
        .unwrap()
        .with_contracts(&[&deployment.pool, &deployment.oracle, &deployment.registry])
        .with_variable_output_policy(VariableOutputPolicy::Exactly(1))
        .call()
        .await
        .unwrap();
    account
        .methods()
        .set_session(ParallelSessionArgs {
            nonce: U256::zero(),
            session_id: Identity::Address(user.address()),
            expiry: Time { unix: now + 86_400 },
            contract_ids: vec![],
        })
        .with_contract_ids(&[deployment.oracle_id])
        .call()
        .await
        .unwrap();

    let mut fixture = PropFixture {
        _node_db: None,
        collateral_asset,
        base_asset,
        deployer,
        user,
        outsider,
        deployment,
        order_book,
        child_id,
        account,
        nonce_position: 0,
        publish_clock: std::sync::atomic::AtomicU64::new(now),
    };
    fixture
        .call_account(vec![fixture.draw_call(CLOSE_LINE)], 1)
        .await
        .unwrap();
    // Cash covers the draw, the debt buyback, and $7 net profit.
    fixture
        .deployer
        .force_transfer_to_contract(
            child_id,
            NET_PROFIT + DEBT_COST,
            collateral_asset,
            TxPolicies::default(),
        )
        .await
        .unwrap();

    let mock_blob_id = upload_implementation(
        &fixture.deployer,
        include_bytes!("../artifacts/test_artifacts/prop-margin-pool-mock.bin"),
        include_bytes!(
            "../artifacts/test_artifacts/prop-margin-pool-mock-storage_slots.json"
        ),
        mock::PropMarginPoolMockContractConfigurables::default()
            .with_COLLATERAL_ASSET(collateral_asset)
            .unwrap()
            .with_COLLATERAL_DECIMALS(9)
            .unwrap(),
        &config,
    )
    .await
    .unwrap();
    let mock_pool = mock::PropMarginPoolMockContract::new(
        fixture.deployment.pool_id,
        fixture.deployer.clone(),
    );
    let proxy = &fixture.deployment.pool_proxy;
    // Inject and restore atomically; the close never runs against the mock.
    CallHandler::new_multi_call(fixture.deployer.clone())
        .add_call(proxy.methods().set_proxy_target(mock_blob_id.into()))
        .add_call(
            mock_pool
                .methods()
                .set_account_debt(child_id, base_asset, WBTC_DEBT),
        )
        .add_call(
            proxy
                .methods()
                .set_proxy_target(fixture.deployment.pool_blob_id.into()),
        )
        .call::<((), (), ())>()
        .await
        .unwrap();

    fixture
        .rest_order(WBTC_PRICE, MAKER_QUANTITY, base_asset, MAKER_QUANTITY)
        .await;
    assert_eq!(fixture.debt(base_asset).await, WBTC_DEBT);
    assert_eq!(
        fixture.session_value().await,
        i128::from(CLOSE_LINE + NET_PROFIT)
    );
    assert_eq!(DEBT_COST, 251_600_000); // $0.2516: exact, no truncation.
    assert_eq!(WBTC_DEBT * WBTC_PRICE % UNIT, 0);
    assert!(DEBT_COST < UNIT);
    fixture
}

/// Atomically buy/settle wBTC, repay both debts, then close through the account.
async fn try_close(fixture: &mut PropFixture) -> Result<()> {
    let buyback = CallContractArg {
        contract_id: fixture.order_book.contract_id,
        function_selector: Bytes(fn_selector!(create_order(OrderArgs))),
        call_params: CallParams {
            coins: DEBT_COST,
            asset_id: fixture.collateral_asset,
            gas: 10_000_000,
        },
        call_data: Some(Bytes(call_data!(OrderArgs {
            price: WBTC_PRICE,
            quantity: WBTC_DEBT,
            order_type: OrderType::Market,
        }))),
    };
    let repay_base =
        fixture.pool_call(fn_selector!(repay()), WBTC_DEBT, fixture.base_asset, None);
    let return_quote = fixture.pool_call(
        fn_selector!(return_quote()),
        CLOSE_LINE,
        fixture.collateral_asset,
        None,
    );
    let (user_signature, cosigner_signature, calls) = fixture
        .signed_calls(vec![
            buyback,
            fixture.settle_book_call(),
            repay_base,
            return_quote,
        ])
        .await;
    let prepare = fixture
        .account
        .methods()
        .call_contracts(user_signature, cosigner_signature, calls)
        .with_contracts(&[
            &fixture.deployment.oracle,
            &fixture.deployment.pool,
            &fixture.order_book.order_book,
        ]);
    let close = fixture
        .account
        .methods()
        .close_session(vec![])
        .with_contracts(&[
            &fixture.deployment.pool,
            &fixture.deployment.oracle,
            &fixture.deployment.price_feed,
            &fixture.order_book.order_book,
        ]);
    CallHandler::new_multi_call(fixture.user.clone())
        .add_call(prepare)
        .add_call(close)
        .with_variable_output_policy(VariableOutputPolicy::Exactly(2))
        .call::<((), ())>()
        .await?;
    Ok(())
}

async fn assert_can_close(min_order: u64) {
    let mut fixture = close_fixture(min_order).await;
    let parent_before = fixture
        .user
        .get_asset_balance(&fixture.collateral_asset)
        .await
        .unwrap();
    if let Err(error) = try_close(&mut fixture).await {
        assert!(
            error.to_string().contains("InvalidInputAmount"),
            "unexpected close failure: {error:#}"
        );
        assert_eq!(fixture.debt(fixture.base_asset).await, WBTC_DEBT);
        assert_eq!(
            fixture.account_balance(fixture.collateral_asset).await,
            CLOSE_LINE + NET_PROFIT + DEBT_COST
        );
        panic!("The $0.2516 debt buyback must not block closure: {error:#}");
    }
    assert!(fixture.session_opt().await.is_none());
    assert_eq!(fixture.debt(fixture.base_asset).await, 0);
    assert_eq!(fixture.account_balance(fixture.base_asset).await, 0);
    assert_eq!(fixture.account_balance(fixture.collateral_asset).await, 0);
    assert_eq!(
        fixture
            .user
            .get_asset_balance(&fixture.collateral_asset)
            .await
            .unwrap()
            - parent_before,
        u128::from(NET_PROFIT)
    );
}

/// Intentionally red: the debt buyback is below the $1 minimum.
#[tokio::test]
async fn turbo_close_with_sub_minimum_wbtc_debt_should_succeed() {
    assert_can_close(UNIT).await;
}

/// Same fixture; only the minimum changes to one quote atom.
#[tokio::test]
async fn turbo_close_with_sub_minimum_wbtc_debt_lower_minimum_control() {
    assert_can_close(1).await;
}