diff --git a/finance/lending/quasar/CHANGELOG.md b/finance/lending/quasar/CHANGELOG.md index 717329d4..dc6a19eb 100644 --- a/finance/lending/quasar/CHANGELOG.md +++ b/finance/lending/quasar/CHANGELOG.md @@ -15,8 +15,8 @@ also counts interest owed on borrows, and a lone supplier who borrowed from their own reserve could raise their single share's value with that interest and then with rounding, until a later deposit rounded down in their favor. - The Anchor ports carry the test that runs it. Tested here by - `first_deposit_withholds_the_minimum` and + `inflating_shares_through_own_borrow_does_not_pay` runs that attack. Also + tested by `first_deposit_withholds_the_minimum` and `first_deposit_must_exceed_the_minimum`; the test harnesses now open each reserve with a deposit from the market owner. diff --git a/finance/lending/quasar/src/tests.rs b/finance/lending/quasar/src/tests.rs index 9568ff3e..31bf1db1 100644 --- a/finance/lending/quasar/src/tests.rs +++ b/finance/lending/quasar/src/tests.rs @@ -41,6 +41,8 @@ const LIQUIDATOR: Pubkey = Pubkey::new_from_array([4; 32]); const COLLATERAL_MINT: Pubkey = Pubkey::new_from_array([5; 32]); const BORROW_MINT: Pubkey = Pubkey::new_from_array([6; 32]); const QUOTE_MINT: Pubkey = Pubkey::new_from_array([7; 32]); +const ATTACKER: Pubkey = Pubkey::new_from_array([8; 32]); +const VICTIM: Pubkey = Pubkey::new_from_array([9; 32]); // Token accounts. const SUPPLIER_BORROW: Pubkey = Pubkey::new_from_array([10; 32]); const SUPPLIER_BORROW_SHARE: Pubkey = Pubkey::new_from_array([11; 32]); @@ -53,6 +55,12 @@ const OWNER_BORROW: Pubkey = Pubkey::new_from_array([17; 32]); const OWNER_COLLATERAL: Pubkey = Pubkey::new_from_array([18; 32]); const OWNER_COLLATERAL_SHARE: Pubkey = Pubkey::new_from_array([19; 32]); const OWNER_BORROW_SHARE: Pubkey = Pubkey::new_from_array([20; 32]); +const ATTACKER_BORROW: Pubkey = Pubkey::new_from_array([21; 32]); +const ATTACKER_BORROW_SHARE: Pubkey = Pubkey::new_from_array([22; 32]); +const ATTACKER_COLLATERAL: Pubkey = Pubkey::new_from_array([23; 32]); +const ATTACKER_COLLATERAL_SHARE: Pubkey = Pubkey::new_from_array([24; 32]); +const VICTIM_BORROW: Pubkey = Pubkey::new_from_array([25; 32]); +const VICTIM_BORROW_SHARE: Pubkey = Pubkey::new_from_array([26; 32]); /// What the market owner deposits to open each reserve: the smallest first /// deposit that clears the withheld minimum, minting the owner one share. const OPENING_DEPOSIT: u64 = crate::constants::MINIMUM_SHARES + 1; @@ -442,14 +450,19 @@ mod clock_warp { use { super::{dollars, EXP, TENTH_OF_A_YEAR}, super::{ - BORROWER, BORROWER_BORROW, BORROWER_COLLATERAL, BORROWER_COLLATERAL_SHARE, BORROW_MINT, - COLLATERAL_MINT, DECIMALS, MARKET_ID, OPENING_DEPOSIT, OWNER, OWNER_BORROW, - OWNER_BORROW_SHARE, OWNER_COLLATERAL, OWNER_COLLATERAL_SHARE, QUOTE_MINT, SUPPLIER, - SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, UNIT, + ATTACKER, ATTACKER_BORROW, ATTACKER_BORROW_SHARE, ATTACKER_COLLATERAL, + ATTACKER_COLLATERAL_SHARE, BORROWER, BORROWER_BORROW, BORROWER_COLLATERAL, + BORROWER_COLLATERAL_SHARE, BORROW_MINT, COLLATERAL_MINT, DECIMALS, MARKET_ID, + OPENING_DEPOSIT, OWNER, OWNER_BORROW, OWNER_BORROW_SHARE, OWNER_COLLATERAL, + OWNER_COLLATERAL_SHARE, QUOTE_MINT, SUPPLIER, SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, + UNIT, VICTIM, VICTIM_BORROW, VICTIM_BORROW_SHARE, }, crate::{ - constants::FIXED_POINT_SCALE, - math::{borrow_rate_per_second, utilization_bps}, + constants::{FIXED_POINT_SCALE, MINIMUM_SHARES}, + math::{ + borrow_rate_per_second, current_debt, net_total_liquidity, total_shares, + utilization_bps, + }, state::Reserve, }, quasar_lang::traits::Discriminator, @@ -526,8 +539,6 @@ mod clock_warp { borrow_vault: Pubkey, borrow_share_mint: Pubkey, borrow_price: Pubkey, - obligation: Pubkey, - obligation_vault: Pubkey, } impl World { @@ -535,7 +546,7 @@ mod clock_warp { // Runtime read (NOT include_bytes!) so the crate compiles without // the .so; only running the test requires a prior `quasar build`. let elf = std::fs::read("target/deploy/quasar_lending.so").unwrap(); - let mut svm = QuasarSvm::new() + let svm = QuasarSvm::new() .with_program(&crate::ID, &elf) .with_token_program(); @@ -551,17 +562,27 @@ mod clock_warp { let (collateral_price, _) = pda(&[b"price_feed", market.as_ref(), COLLATERAL_MINT.as_ref()]); let (borrow_price, _) = pda(&[b"price_feed", market.as_ref(), BORROW_MINT.as_ref()]); - let (obligation, _) = pda(&[b"obligation", market.as_ref(), BORROWER.as_ref()]); - let (obligation_vault, _) = pda(&[ - b"obligation_vault", - collateral_reserve.as_ref(), - obligation.as_ref(), - ]); + let mut world = World { + svm, + market, + collateral_reserve, + collateral_vault, + collateral_share_mint, + collateral_price, + borrow_reserve, + borrow_vault, + borrow_share_mint, + borrow_price, + }; + let (borrower_obligation, borrower_obligation_vault) = world.obligation(BORROWER); + let (attacker_obligation, attacker_obligation_vault) = world.obligation(ATTACKER); for account in [ system(OWNER), system(SUPPLIER), system(BORROWER), + system(ATTACKER), + system(VICTIM), mint(COLLATERAL_MINT, OWNER), mint(BORROW_MINT, OWNER), mint(QUOTE_MINT, OWNER), @@ -575,8 +596,10 @@ mod clock_warp { empty(borrow_share_mint), empty(collateral_price), empty(borrow_price), - empty(obligation), - empty(obligation_vault), + empty(borrower_obligation), + empty(borrower_obligation_vault), + empty(attacker_obligation), + empty(attacker_obligation_vault), // Funded user token accounts. token(SUPPLIER_BORROW, BORROW_MINT, SUPPLIER, 1_000 * UNIT), token(SUPPLIER_BORROW_SHARE, borrow_share_mint, SUPPLIER, 0), @@ -595,24 +618,34 @@ mod clock_warp { token(OWNER_BORROW_SHARE, borrow_share_mint, OWNER, 0), token(OWNER_COLLATERAL, COLLATERAL_MINT, OWNER, OPENING_DEPOSIT), token(OWNER_COLLATERAL_SHARE, collateral_share_mint, OWNER, 0), + token(ATTACKER_BORROW, BORROW_MINT, ATTACKER, 4_000 * UNIT), + token(ATTACKER_BORROW_SHARE, borrow_share_mint, ATTACKER, 0), + token(ATTACKER_COLLATERAL, COLLATERAL_MINT, ATTACKER, 1_000 * UNIT), + token( + ATTACKER_COLLATERAL_SHARE, + collateral_share_mint, + ATTACKER, + 0, + ), + token(VICTIM_BORROW, BORROW_MINT, VICTIM, 1_000 * UNIT), + token(VICTIM_BORROW_SHARE, borrow_share_mint, VICTIM, 0), ] { - svm.set_account(account); + world.svm.set_account(account); } - World { - svm, - market, - collateral_reserve, - collateral_vault, - collateral_share_mint, - collateral_price, - borrow_reserve, - borrow_vault, - borrow_share_mint, - borrow_price, - obligation, - obligation_vault, - } + world + } + + /// The obligation PDA `owner` opens in this market, and the vault it + /// holds posted collateral shares in. + fn obligation(&self, owner: Pubkey) -> (Pubkey, Pubkey) { + let (obligation, _) = pda(&[b"obligation", self.market.as_ref(), owner.as_ref()]); + let (obligation_vault, _) = pda(&[ + b"obligation_vault", + self.collateral_reserve.as_ref(), + obligation.as_ref(), + ]); + (obligation, obligation_vault) } fn current_timestamp(&self) -> i64 { @@ -660,6 +693,28 @@ mod clock_warp { unsafe { core::ptr::read_unaligned(fields.as_ptr() as *const ReserveState) } } + /// Read an SPL token account's amount from the committed state, as + /// `balance` reads it from one instruction's result. + fn tokens(&self, address: Pubkey) -> u64 { + let account = self.svm.get_account(&address).expect("account present"); + u64::from_le_bytes(account.data[64..72].try_into().unwrap()) + } + + /// The borrow reserve's liquidity, net of protocol fees, and the share + /// count every conversion divides by, as the program prices them. + fn borrow_reserve_totals(&self) -> (u128, u128) { + let reserve = self.reserve(self.borrow_reserve); + let total = net_total_liquidity( + u64::from(reserve.available_liquidity), + u128::from(reserve.borrowed_principal), + u128::from(reserve.borrow_accumulation_factor), + u64::from(reserve.accumulated_protocol_fees), + ) + .unwrap(); + let shares = total_shares(u64::from(reserve.share_mint_supply)).unwrap(); + (total, shares) + } + fn run(&mut self, data: Vec, metas: Vec) -> quasar_svm::ExecutionResult { let instruction = Instruction { program_id: crate::ID, @@ -725,7 +780,17 @@ mod clock_warp { self.run(data, metas).assert_success(); } + /// Create the market and both reserves, then open each reserve with the + /// owner's deposit, as the quasar-test harness does. fn setup_markets(&mut self) { + self.setup_empty_markets(); + self.open_collateral_reserve(); + self.open_borrow_reserve(); + } + + /// Create the market and both reserves with no deposits, for tests of + /// the first deposit itself. + fn setup_empty_markets(&mut self) { self.init_market(); self.set_price(COLLATERAL_MINT, self.collateral_price, dollars(1)); self.set_price(BORROW_MINT, self.borrow_price, dollars(1)); @@ -743,8 +808,9 @@ mod clock_warp { self.borrow_share_mint, self.borrow_price, ); - // Open each reserve with the owner's deposit, as the quasar-test - // harness does. + } + + fn open_collateral_reserve(&mut self) { self.deposit( OWNER, self.collateral_reserve, @@ -756,17 +822,11 @@ mod clock_warp { OPENING_DEPOSIT, ) .assert_success(); - self.deposit( - OWNER, - self.borrow_reserve, - BORROW_MINT, - self.borrow_vault, - self.borrow_share_mint, - OWNER_BORROW, - OWNER_BORROW_SHARE, - OPENING_DEPOSIT, - ) - .assert_success(); + } + + fn open_borrow_reserve(&mut self) { + self.supply(OWNER, OWNER_BORROW, OWNER_BORROW_SHARE, OPENING_DEPOSIT) + .assert_success(); } #[allow(clippy::too_many_arguments)] @@ -796,8 +856,29 @@ mod clock_warp { self.run(data, metas) } + /// Deposit into the borrow reserve. + fn supply( + &mut self, + supplier: Pubkey, + supplier_liq: Pubkey, + supplier_share: Pubkey, + amount: u64, + ) -> quasar_svm::ExecutionResult { + self.deposit( + supplier, + self.borrow_reserve, + BORROW_MINT, + self.borrow_vault, + self.borrow_share_mint, + supplier_liq, + supplier_share, + amount, + ) + } + fn redeem( &mut self, + supplier: Pubkey, supplier_liq: Pubkey, supplier_share: Pubkey, shares: u64, @@ -805,7 +886,7 @@ mod clock_warp { let mut data = vec![4u8]; data.extend_from_slice(&shares.to_le_bytes()); let metas = vec![ - meta(SUPPLIER, true, true), + meta(supplier, true, true), meta(self.borrow_reserve, true, false), meta(BORROW_MINT, false, false), meta(self.borrow_vault, true, false), @@ -817,27 +898,34 @@ mod clock_warp { self.run(data, metas) } - fn initialize_obligation(&mut self) { + fn initialize_obligation(&mut self, owner: Pubkey) { + let (obligation, _) = self.obligation(owner); let metas = vec![ - meta(BORROWER, true, true), + meta(owner, true, true), meta(self.market, false, false), - meta(self.obligation, true, false), + meta(obligation, true, false), meta(quasar_svm::system_program::ID, false, false), ]; self.run(vec![5], metas).assert_success(); } - fn post_collateral(&mut self, shares: u64) -> quasar_svm::ExecutionResult { + fn post_collateral( + &mut self, + owner: Pubkey, + owner_share: Pubkey, + shares: u64, + ) -> quasar_svm::ExecutionResult { + let (obligation, obligation_vault) = self.obligation(owner); let mut data = vec![6u8]; data.extend_from_slice(&shares.to_le_bytes()); let metas = vec![ - meta(BORROWER, true, true), + meta(owner, true, true), meta(self.market, false, false), - meta(self.obligation, true, false), + meta(obligation, true, false), meta(self.collateral_reserve, false, false), meta(self.collateral_share_mint, false, false), - meta(self.obligation_vault, true, false), - meta(BORROWER_COLLATERAL_SHARE, true, false), + meta(obligation_vault, true, false), + meta(owner_share, true, false), meta(quasar_svm::solana_sdk_ids::sysvar::rent::ID, false, false), meta(quasar_svm::SPL_TOKEN_PROGRAM_ID, false, false), meta(quasar_svm::system_program::ID, false, false), @@ -845,20 +933,50 @@ mod clock_warp { self.run(data, metas) } - fn borrow(&mut self, amount: u64) -> quasar_svm::ExecutionResult { + fn borrow( + &mut self, + owner: Pubkey, + owner_liquidity: Pubkey, + amount: u64, + ) -> quasar_svm::ExecutionResult { + let (obligation, _) = self.obligation(owner); let mut data = vec![8u8]; data.extend_from_slice(&amount.to_le_bytes()); let metas = vec![ - meta(BORROWER, true, true), + meta(owner, true, true), meta(self.market, false, false), - meta(self.obligation, true, false), + meta(obligation, true, false), meta(self.collateral_reserve, true, false), meta(self.collateral_price, false, false), meta(self.borrow_reserve, true, false), meta(self.borrow_price, false, false), meta(BORROW_MINT, false, false), meta(self.borrow_vault, true, false), - meta(BORROWER_BORROW, true, false), + meta(owner_liquidity, true, false), + meta(quasar_svm::SPL_TOKEN_PROGRAM_ID, false, false), + ]; + self.run(data, metas) + } + + /// `repayer` pays down the debt on `owner`'s obligation. The handler + /// caps the repayment at what is owed. + fn repay( + &mut self, + repayer: Pubkey, + repayer_liquidity: Pubkey, + owner: Pubkey, + amount: u64, + ) -> quasar_svm::ExecutionResult { + let (obligation, _) = self.obligation(owner); + let mut data = vec![9u8]; + data.extend_from_slice(&amount.to_le_bytes()); + let metas = vec![ + meta(repayer, true, true), + meta(obligation, true, false), + meta(self.borrow_reserve, true, false), + meta(BORROW_MINT, false, false), + meta(self.borrow_vault, true, false), + meta(repayer_liquidity, true, false), meta(quasar_svm::SPL_TOKEN_PROGRAM_ID, false, false), ]; self.run(data, metas) @@ -867,12 +985,8 @@ mod clock_warp { /// Supplier funds the borrow reserve; borrower posts 1000 units of collateral. fn bootstrap_position(&mut self) { self.setup_markets(); - self.deposit( + self.supply( SUPPLIER, - self.borrow_reserve, - BORROW_MINT, - self.borrow_vault, - self.borrow_share_mint, SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, 1_000 * UNIT, @@ -889,8 +1003,9 @@ mod clock_warp { 1_000 * UNIT, ) .assert_success(); - self.initialize_obligation(); - self.post_collateral(1_000 * UNIT).assert_success(); + self.initialize_obligation(BORROWER); + self.post_collateral(BORROWER, BORROWER_COLLATERAL_SHARE, 1_000 * UNIT) + .assert_success(); } /// Accrue the borrow reserve. This port has no `refresh_reserve`: every @@ -898,7 +1013,7 @@ mod clock_warp { /// is the smallest such call that needs no price, so it stands in for /// the refresh here. fn refresh_borrow_reserve(&mut self) { - self.redeem(SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, 1) + self.redeem(SUPPLIER, SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, 1) .assert_success(); } @@ -923,7 +1038,9 @@ mod clock_warp { fn interest_accrues_and_lifts_share_value() { let mut world = World::new(); world.bootstrap_position(); - world.borrow(500 * UNIT).assert_success(); + world + .borrow(BORROWER, BORROWER_BORROW, 500 * UNIT) + .assert_success(); // A tenth of a year passes; re-publish prices so feeds stay fresh. world.warp_seconds(TENTH_OF_A_YEAR); @@ -932,7 +1049,7 @@ mod clock_warp { // Supplier redeems 100 shares; interest on the 500 borrowed means each // share is now worth more than one liquidity unit. - let result = world.redeem(SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, 100 * UNIT); + let result = world.redeem(SUPPLIER, SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, 100 * UNIT); result.assert_success(); assert!( balance(&result, SUPPLIER_BORROW) > 100 * UNIT, @@ -957,7 +1074,7 @@ mod clock_warp { world.svm.sysvars.last_restart_slot.last_restart_slot = restart_slot; world - .borrow(100 * UNIT) + .borrow(BORROWER, BORROWER_BORROW, 100 * UNIT) .assert_error(quasar_svm::ProgramError::Custom( crate::error::LendingError::PricePredatesRestart as u32, )); @@ -965,7 +1082,9 @@ mod clock_warp { // Publishing after the restart reopens the market. world.set_price(COLLATERAL_MINT, world.collateral_price, dollars(1)); world.set_price(BORROW_MINT, world.borrow_price, dollars(1)); - world.borrow(100 * UNIT).assert_success(); + world + .borrow(BORROWER, BORROWER_BORROW, 100 * UNIT) + .assert_success(); } /// The factor after one accrual `seconds` after the last: one multiply by @@ -998,7 +1117,9 @@ mod clock_warp { fn interest_accrues_by_seconds_not_slots() { let mut world = World::new(); world.bootstrap_position(); - world.borrow(500 * UNIT).assert_success(); + world + .borrow(BORROWER, BORROWER_BORROW, 500 * UNIT) + .assert_success(); let before = world.reserve(world.borrow_reserve); world.warp_slots(1_000_000); @@ -1036,7 +1157,9 @@ mod clock_warp { fn a_timestamp_behind_the_last_accrual_charges_nothing() { let mut world = World::new(); world.bootstrap_position(); - world.borrow(500 * UNIT).assert_success(); + world + .borrow(BORROWER, BORROWER_BORROW, 500 * UNIT) + .assert_success(); let before = world.reserve(world.borrow_reserve); world.shift_timestamp(-600); @@ -1068,7 +1191,9 @@ mod clock_warp { fn protocol_fees_accrue_and_owner_can_collect() { let mut world = World::new(); world.bootstrap_position(); - world.borrow(500 * UNIT).assert_success(); + world + .borrow(BORROWER, BORROWER_BORROW, 500 * UNIT) + .assert_success(); // A tenth of a year passes; interest accrues, and the reserve factor // (10%) sets some of it aside for the market owner. @@ -1082,4 +1207,144 @@ mod clock_warp { balance(&result, OWNER_BORROW) ); } + + /// First-depositor share inflation without a donation. Shares are priced + /// against tracked total liquidity, not the vault balance, so tokens sent + /// straight to the vault move nothing. But total liquidity also counts + /// interest owed on borrows, and a supplier can borrow from their own + /// reserve. + /// + /// The attacker opens the reserve holding a single share, borrows one base + /// unit of it against collateral in another reserve, and lets one second + /// pass. Debt is rounded up, so the one unit now reads as two and the lone + /// share is worth two units without having minted anything. From there, + /// deposits and redemptions that round down in the pool's favor ratchet + /// the price up: each deposit is the largest that still mints one share, + /// and redeeming that share leaves the rounding behind for the only other + /// share, the attacker's own. A deposit that would mint zero shares is + /// refused (`DepositTooSmall`), so the victim is not robbed outright; + /// instead a deposit just under two shares' worth mints one, and the + /// attacker's share redeems half the pool. + /// + /// The rate curve is the harness's usual one. Nothing about the attack + /// needs the market owner's cooperation or bad debt: the attacker repays + /// what they owe. + /// + /// `MINIMUM_SHARES` counts as shares nobody holds in every share + /// conversion, so the attacker's one share is 1 of 1_001 and whatever the + /// rounding leaves behind is spread mostly across shares they cannot + /// redeem. + #[test] + fn inflating_shares_through_own_borrow_does_not_pay() { + let mut world = World::new(); + world.setup_empty_markets(); + world.open_collateral_reserve(); + let budget = world.tokens(ATTACKER_BORROW); + + // Open the reserve with two shares. This port has no `refresh_reserve`, + // so the second share is spent below as the refresh, leaving the + // attacker holding exactly one. + world + .supply( + ATTACKER, + ATTACKER_BORROW, + ATTACKER_BORROW_SHARE, + MINIMUM_SHARES + 2, + ) + .assert_success(); + assert_eq!(world.tokens(ATTACKER_BORROW_SHARE), 2); + + // Borrow one base unit against collateral in another reserve, and let a + // second of interest round that debt up to two. + world + .deposit( + ATTACKER, + world.collateral_reserve, + COLLATERAL_MINT, + world.collateral_vault, + world.collateral_share_mint, + ATTACKER_COLLATERAL, + ATTACKER_COLLATERAL_SHARE, + 1_000 * UNIT, + ) + .assert_success(); + world.initialize_obligation(ATTACKER); + let collateral_shares = world.tokens(ATTACKER_COLLATERAL_SHARE); + world + .post_collateral(ATTACKER, ATTACKER_COLLATERAL_SHARE, collateral_shares) + .assert_success(); + world.borrow(ATTACKER, ATTACKER_BORROW, 1).assert_success(); + world.warp_seconds(1); + world + .redeem(ATTACKER, ATTACKER_BORROW, ATTACKER_BORROW_SHARE, 1) + .assert_success(); + assert_eq!(world.tokens(ATTACKER_BORROW_SHARE), 1); + let reserve = world.reserve(world.borrow_reserve); + let debt = current_debt( + u128::from(reserve.borrowed_principal), + u128::from(reserve.borrow_accumulation_factor), + ) + .unwrap(); + assert_eq!(debt, 2); + + // Ratchet until one share is worth more than half the victim's deposit. + let victim_deposit = world.tokens(VICTIM_BORROW); + for _ in 0..64 { + let (total, shares) = world.borrow_reserve_totals(); + if total * 2 > victim_deposit as u128 * shares { + break; + } + // The largest deposit that mints exactly one share. + let deposit = (2 * total).div_ceil(shares) - 1; + world + .supply( + ATTACKER, + ATTACKER_BORROW, + ATTACKER_BORROW_SHARE, + deposit as u64, + ) + .assert_success(); + world + .redeem(ATTACKER, ATTACKER_BORROW, ATTACKER_BORROW_SHARE, 1) + .assert_success(); + } + + world + .supply(VICTIM, VICTIM_BORROW, VICTIM_BORROW_SHARE, victim_deposit) + .assert_success(); + + // The attacker exits: redeems their share and repays the debt. + let attacker_shares = world.tokens(ATTACKER_BORROW_SHARE); + world + .redeem( + ATTACKER, + ATTACKER_BORROW, + ATTACKER_BORROW_SHARE, + attacker_shares, + ) + .assert_success(); + world + .repay(ATTACKER, ATTACKER_BORROW, ATTACKER, 10) + .assert_success(); + assert_eq!( + u128::from(world.reserve(world.borrow_reserve).borrowed_principal), + 0 + ); + let attacker_end = world.tokens(ATTACKER_BORROW); + assert!( + attacker_end <= budget, + "attacker started with {budget} and ended with {attacker_end}" + ); + + // The victim exits with everything and gets back all but a sliver. + let victim_shares = world.tokens(VICTIM_BORROW_SHARE); + world + .redeem(VICTIM, VICTIM_BORROW, VICTIM_BORROW_SHARE, victim_shares) + .assert_success(); + let victim_back = world.tokens(VICTIM_BORROW); + assert!( + victim_back * 1_000 >= victim_deposit * 999, + "victim deposited {victim_deposit} and got back {victim_back}" + ); + } }