From 53b0ec992243062e852ad2e99ae29f9fb2ff459f Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Wed, 23 Sep 2026 01:45:38 +0000 Subject: [PATCH] lending(quasar): port the share-inflation attack test Add inflating_shares_through_own_borrow_does_not_pay to the clock_warp harness, the Quasar counterpart of the Anchor test from #155: same scenario, amounts and assertions. This port has no refresh_reserve, so the attacker opens the reserve with two shares and redeems one after the clock moves, which accrues the reserve and lands on the state the Anchor test reaches after its refresh. Harness changes: attacker and victim wallets and token accounts, an obligation per owner, setup_empty_markets split out of setup_markets, signer parameters on redeem, initialize_obligation, post_collateral and borrow, and new supply, repay, tokens and borrow_reserve_totals helpers. With MINIMUM_SHARES = 0 the test fails with the attacker ending at 4,155,201,816 from 4,000,000,000, as in the Anchor ports. With 1_000 the attacker ends 1,065 down and the victim gets back the whole deposit. Claude-Session: https://claude.ai/code/session_01DbVgVzvUFMqd7JtSPnheSe --- finance/lending/quasar/CHANGELOG.md | 4 +- finance/lending/quasar/src/tests.rs | 411 +++++++++++++++++++++++----- 2 files changed, 340 insertions(+), 75 deletions(-) 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}" + ); + } }