diff --git a/finance/lending/anchor/CHANGELOG.md b/finance/lending/anchor/CHANGELOG.md index 2eaa9500..369a7f2b 100644 --- a/finance/lending/anchor/CHANGELOG.md +++ b/finance/lending/anchor/CHANGELOG.md @@ -27,3 +27,8 @@ Initial lending program: a Kamino/Solend-style borrow/lend market. - Reserve factor: the protocol keeps `reserve_factor_bps` of accrued interest as fees the market owner withdraws with `collect_protocol_fees`; the fees are carved out of `total_liquidity` so they never inflate the supplier exchange rate. +- LendingMarket is seeded by a `market_id` index (`["lending_market", market_id]`), + not by any individual; one owner can run several independent markets, and admin + handlers authorize via `has_one = owner`. +- Price feeds are seeded `["price_feed", market, mint]` (scoped to a market, not + to an individual); only the market owner may write one (`has_one = owner`). diff --git a/finance/lending/anchor/README.md b/finance/lending/anchor/README.md index c0a0e937..43226d56 100644 --- a/finance/lending/anchor/README.md +++ b/finance/lending/anchor/README.md @@ -32,7 +32,10 @@ crosses the liquidation threshold and a liquidator can close part of the positio ### Accounts - **`LendingMarket`** — top-level config (owner, quote-currency mint). PDA seeds - `["lending_market", owner]`. + `["lending_market", owner, market_id]`, where `market_id` is a per-owner `u64` + index. Seeding by an index (not the owner alone) lets one owner run several + independent, risk-isolated markets — their market 0, 1, 2 … — with no + cross-owner collisions. - **`Reserve`** — one per asset. Owns a program-controlled liquidity vault and a share-token mint, and stores the interest-rate config, the cumulative borrow- rate index, available liquidity, and scaled total debt. PDA seeds @@ -104,10 +107,11 @@ round-trips. `PriceFeed` mirrors a Switchboard On-Demand pull feed: a signed mantissa, an exponent (`price = mantissa * 10^exponent`), and the slot the price was written. Freshness is checked in **slots** (`MAX_PRICE_STALENESS_SLOTS`), not wall-clock -time. The feed PDA is seeded by `[b"price_feed", authority, mint]`, so a signer -can only ever write the feed derived from their own key — there is no shared -per-mint feed to claim first — and a reserve trusts exactly one feed: the -account its market owner passed to `init_reserve`. +time. The feed PDA is seeded by `[b"price_feed", market, mint]` — scoped to a +market, not to any individual — and only that market's `owner` may write it +(`set_price` checks `has_one = owner`). So prices can't be squatted, a reserve +trusts exactly its own market's feed for the mint, and isolated markets can +price the same asset independently. The `set_price` handler writes the feed directly so the LiteSVM tests are deterministic; in production a reserve points at the real Switchboard feed and the diff --git a/finance/lending/anchor/programs/lending/src/instructions/admin/collect_protocol_fees.rs b/finance/lending/anchor/programs/lending/src/instructions/admin/collect_protocol_fees.rs index 0a5554db..2e0b0061 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/admin/collect_protocol_fees.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/admin/collect_protocol_fees.rs @@ -3,7 +3,6 @@ use anchor_spl::token_interface::{ transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, }; -use crate::constants::LENDING_MARKET_SEED; use crate::errors::LendingError; use crate::state::{reserve_signer_seeds, LendingMarket, Reserve}; @@ -51,11 +50,9 @@ pub fn handle_collect_protocol_fees(context: Context) -> Re #[derive(Accounts)] pub struct CollectProtocolFees<'info> { - #[account( - has_one = owner, - seeds = [LENDING_MARKET_SEED, owner.key().as_ref()], - bump = lending_market.bump, - )] + // Identified by the reserve's `has_one = lending_market`; we only prove the + // signer owns it. + #[account(has_one = owner)] pub lending_market: Account<'info, LendingMarket>, #[account(mut)] diff --git a/finance/lending/anchor/programs/lending/src/instructions/admin/init_lending_market.rs b/finance/lending/anchor/programs/lending/src/instructions/admin/init_lending_market.rs index d3fba8a8..377b7fbc 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/admin/init_lending_market.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/admin/init_lending_market.rs @@ -4,8 +4,12 @@ use anchor_spl::token_interface::Mint; use crate::constants::LENDING_MARKET_SEED; use crate::state::LendingMarket; -pub fn handle_init_lending_market(context: Context) -> Result<()> { +pub fn handle_init_lending_market( + context: Context, + market_id: u64, +) -> Result<()> { let market = &mut context.accounts.lending_market; + market.market_id = market_id; market.owner = context.accounts.owner.key(); market.quote_currency_mint = context.accounts.quote_currency_mint.key(); market.bump = context.bumps.lending_market; @@ -13,12 +17,16 @@ pub fn handle_init_lending_market(context: Context) -> Result } #[derive(Accounts)] +#[instruction(market_id: u64)] pub struct InitLendingMarket<'info> { + // Seeded by `market_id` alone — the market is not identified by any + // individual's address. `owner` is stored as a field and used only for + // authorization (`has_one = owner`) on admin instructions. #[account( init, payer = owner, space = LendingMarket::DISCRIMINATOR.len() + LendingMarket::INIT_SPACE, - seeds = [LENDING_MARKET_SEED, owner.key().as_ref()], + seeds = [LENDING_MARKET_SEED, &market_id.to_le_bytes()], bump, )] pub lending_market: Account<'info, LendingMarket>, diff --git a/finance/lending/anchor/programs/lending/src/instructions/admin/init_reserve.rs b/finance/lending/anchor/programs/lending/src/instructions/admin/init_reserve.rs index ad9c57b6..0a975f9a 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/admin/init_reserve.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/admin/init_reserve.rs @@ -2,7 +2,7 @@ use anchor_lang::prelude::*; use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; use crate::constants::{ - FIXED_POINT_SCALE, LENDING_MARKET_SEED, LIQUIDITY_VAULT_SEED, RESERVE_SEED, SHARE_MINT_SEED, + FIXED_POINT_SCALE, LIQUIDITY_VAULT_SEED, PRICE_FEED_SEED, RESERVE_SEED, SHARE_MINT_SEED, }; use crate::state::{LendingMarket, PriceFeed, Reserve, ReserveConfig}; @@ -29,11 +29,9 @@ pub fn handle_init_reserve(context: Context, config: ReserveConfig) #[derive(Accounts)] pub struct InitReserve<'info> { - #[account( - has_one = owner, - seeds = [LENDING_MARKET_SEED, owner.key().as_ref()], - bump = lending_market.bump, - )] + // The reserve PDA below is seeded by this market's address, so the market is + // pinned by that seed; we only need to prove the signer owns it. + #[account(has_one = owner)] pub lending_market: Account<'info, LendingMarket>, #[account(mut)] @@ -70,7 +68,12 @@ pub struct InitReserve<'info> { )] pub share_mint: InterfaceAccount<'info, Mint>, - #[account(constraint = price_feed.mint == liquidity_mint.key() @ crate::errors::LendingError::InvalidConfig)] + // Bound by seeds to this market's feed for this mint — the reserve can only + // trust the price its own market publishes. + #[account( + seeds = [PRICE_FEED_SEED, lending_market.key().as_ref(), liquidity_mint.key().as_ref()], + bump = price_feed.bump, + )] pub price_feed: Account<'info, PriceFeed>, pub token_program: Interface<'info, TokenInterface>, diff --git a/finance/lending/anchor/programs/lending/src/instructions/admin/set_price.rs b/finance/lending/anchor/programs/lending/src/instructions/admin/set_price.rs index 8e6428ad..4044c255 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/admin/set_price.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/admin/set_price.rs @@ -2,23 +2,22 @@ use anchor_lang::prelude::*; use anchor_spl::token_interface::Mint; use crate::constants::PRICE_FEED_SEED; -use crate::state::PriceFeed; +use crate::state::{LendingMarket, PriceFeed}; /// Test stand-in for a Switchboard On-Demand feed: writes a price directly so /// LiteSVM tests are deterministic. In production the reserve points at a real /// Switchboard feed instead and this handler is unused. /// -/// The feed PDA is seeded by `[b"price_feed", authority, mint]`, so each -/// authority can only ever write its own feed — there is no shared per-mint -/// feed to race for. A reserve trusts exactly one feed account: the one the -/// market owner passed to `init_reserve`. +/// The feed PDA is seeded by `[b"price_feed", market, mint]` and writing it +/// requires the market's `owner` to sign, so a market's prices can only be set +/// by that market and never squatted by an outsider. pub fn handle_set_price( context: Context, price_mantissa: i128, exponent: i32, ) -> Result<()> { let feed = &mut context.accounts.price_feed; - feed.authority = context.accounts.authority.key(); + feed.market = context.accounts.lending_market.key(); feed.mint = context.accounts.mint.key(); feed.bump = context.bumps.price_feed; feed.price_mantissa = price_mantissa; @@ -29,20 +28,22 @@ pub fn handle_set_price( #[derive(Accounts)] pub struct SetPrice<'info> { - // The authority is part of the seeds: a signer can only ever address (and - // therefore write) the feed derived from their own key. + // Only the market's owner may publish its prices. + #[account(has_one = owner)] + pub lending_market: Account<'info, LendingMarket>, + + #[account(mut)] + pub owner: Signer<'info>, + #[account( init_if_needed, - payer = authority, + payer = owner, space = PriceFeed::DISCRIMINATOR.len() + PriceFeed::INIT_SPACE, - seeds = [PRICE_FEED_SEED, authority.key().as_ref(), mint.key().as_ref()], + seeds = [PRICE_FEED_SEED, lending_market.key().as_ref(), mint.key().as_ref()], bump, )] pub price_feed: Account<'info, PriceFeed>, - #[account(mut)] - pub authority: Signer<'info>, - pub mint: InterfaceAccount<'info, Mint>, pub system_program: Program<'info, System>, diff --git a/finance/lending/anchor/programs/lending/src/instructions/admin/update_reserve_config.rs b/finance/lending/anchor/programs/lending/src/instructions/admin/update_reserve_config.rs index c4ebda7a..06df1b1b 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/admin/update_reserve_config.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/admin/update_reserve_config.rs @@ -1,6 +1,5 @@ use anchor_lang::prelude::*; -use crate::constants::LENDING_MARKET_SEED; use crate::state::{LendingMarket, Reserve, ReserveConfig}; pub fn handle_update_reserve_config( @@ -14,11 +13,9 @@ pub fn handle_update_reserve_config( #[derive(Accounts)] pub struct UpdateReserveConfig<'info> { - #[account( - has_one = owner, - seeds = [LENDING_MARKET_SEED, owner.key().as_ref()], - bump = lending_market.bump, - )] + // The market is identified by the reserve's `has_one = lending_market`; we + // only need to prove the signer owns it, not re-derive its address. + #[account(has_one = owner)] pub lending_market: Account<'info, LendingMarket>, pub owner: Signer<'info>, diff --git a/finance/lending/anchor/programs/lending/src/lib.rs b/finance/lending/anchor/programs/lending/src/lib.rs index df2d3933..ca848d0b 100644 --- a/finance/lending/anchor/programs/lending/src/lib.rs +++ b/finance/lending/anchor/programs/lending/src/lib.rs @@ -15,8 +15,11 @@ declare_id!("4bvT6A8S7ZVL6bSvK2KoL2nQ4F5H6AF9133kCYbMJj1t"); pub mod lending { use super::*; - pub fn init_lending_market(context: Context) -> Result<()> { - instructions::handle_init_lending_market(context) + pub fn init_lending_market( + context: Context, + market_id: u64, + ) -> Result<()> { + instructions::handle_init_lending_market(context, market_id) } pub fn init_reserve(context: Context, config: ReserveConfig) -> Result<()> { diff --git a/finance/lending/anchor/programs/lending/src/state/lending_market.rs b/finance/lending/anchor/programs/lending/src/state/lending_market.rs index d4ee951d..387dff1d 100644 --- a/finance/lending/anchor/programs/lending/src/state/lending_market.rs +++ b/finance/lending/anchor/programs/lending/src/state/lending_market.rs @@ -5,6 +5,12 @@ use anchor_lang::prelude::*; #[account] #[derive(InitSpace)] pub struct LendingMarket { + /// Index this market's PDA is derived from (`["lending_market", market_id]`). + /// The market is identified by this id, not by any individual — `owner` below + /// is a stored field used only for authorization, never part of the address. + /// Distinct markets (0, 1, 2 …) give independent, risk-isolated pools. + pub market_id: u64, + pub owner: Pubkey, /// The mint that obligation values are denominated in (for example USDC). diff --git a/finance/lending/anchor/programs/lending/src/state/price_feed.rs b/finance/lending/anchor/programs/lending/src/state/price_feed.rs index ba876797..aa4f4785 100644 --- a/finance/lending/anchor/programs/lending/src/state/price_feed.rs +++ b/finance/lending/anchor/programs/lending/src/state/price_feed.rs @@ -5,9 +5,9 @@ use crate::errors::LendingError; use crate::math::price_mantissa_to_scaled; /// A price for one token, denominated in the market's quote currency. -/// PDA seeds: `[b"price_feed", authority, mint]` — the writer is part of the -/// address, so no two authorities can contend for the same feed account, and a -/// reserve trusts exactly the feed its market owner selected at `init_reserve`. +/// PDA seeds: `[b"price_feed", market, mint]` — scoped to a market (not to any +/// individual), so each market prices its own assets and one market can never +/// write another's feed. Only the market's `owner` may write it (`set_price`). /// /// The layout mirrors a Switchboard On-Demand pull feed: a signed mantissa plus /// an exponent (`price = price_mantissa * 10^exponent`) and the slot the value @@ -21,6 +21,9 @@ use crate::math::price_mantissa_to_scaled; #[account] #[derive(InitSpace)] pub struct PriceFeed { + /// The lending market this feed serves; part of the PDA seeds. + pub market: Pubkey, + pub mint: Pubkey, pub price_mantissa: i128, @@ -29,11 +32,6 @@ pub struct PriceFeed { pub last_updated_slot: u64, - /// The signer whose key is in this feed's PDA seeds; the only account that - /// can write it. In production this field does not exist — the feed is - /// owned and written by Switchboard. - pub authority: Pubkey, - pub bump: u8, } diff --git a/finance/lending/anchor/programs/lending/tests/common/mod.rs b/finance/lending/anchor/programs/lending/tests/common/mod.rs index c6beb8c5..fd2e6a6d 100644 --- a/finance/lending/anchor/programs/lending/tests/common/mod.rs +++ b/finance/lending/anchor/programs/lending/tests/common/mod.rs @@ -97,7 +97,9 @@ impl Env { let owner = create_wallet(&mut svm, 1_000_000_000_000).unwrap(); let quote_mint = create_token_mint(&mut svm, &owner, 6, None).unwrap(); - let market = pda(&[LENDING_MARKET_SEED, owner.pubkey().as_ref()]); + // The market is seeded by its market_id index alone (no owner). Market 0. + let market_id: u64 = 0; + let market = pda(&[LENDING_MARKET_SEED, &market_id.to_le_bytes()]); let instruction = Instruction { program_id: lending::id(), @@ -108,7 +110,7 @@ impl Env { system_program: system_program::id(), } .to_account_metas(None), - data: lending::instruction::InitLendingMarket {}.data(), + data: lending::instruction::InitLendingMarket { market_id }.data(), }; send(&mut svm, vec![instruction], &[&owner], &owner.pubkey()).unwrap(); @@ -124,7 +126,10 @@ impl Env { pub fn init_market_for(&mut self, market_owner: &Keypair) -> Pubkey { let env_owner = self.owner.insecure_clone(); let quote_mint = create_token_mint(&mut self.svm, &env_owner, 6, None).unwrap(); - let market = pda(&[LENDING_MARKET_SEED, market_owner.pubkey().as_ref()]); + // A distinct id from the env's market 0, since the id is the market's + // global identifier (the owner is not part of the seed). + let market_id: u64 = 1; + let market = pda(&[LENDING_MARKET_SEED, &market_id.to_le_bytes()]); let instruction = Instruction { program_id: lending::id(), accounts: lending::accounts::InitLendingMarket { @@ -134,7 +139,7 @@ impl Env { system_program: system_program::id(), } .to_account_metas(None), - data: lending::instruction::InitLendingMarket {}.data(), + data: lending::instruction::InitLendingMarket { market_id }.data(), }; send(&mut self.svm, vec![instruction], &[market_owner], &market_owner.pubkey()).unwrap(); market @@ -153,12 +158,12 @@ impl Env { ) -> ReserveHandle { let env_owner = self.owner.insecure_clone(); let mint = create_token_mint(&mut self.svm, &env_owner, decimals, None).unwrap(); - self.set_price(mint, price_mantissa); + self.set_price_for(market_owner, market, mint, price_mantissa); let reserve = pda(&[RESERVE_SEED, market.as_ref(), mint.as_ref()]); let share_mint = pda(&[SHARE_MINT_SEED, reserve.as_ref()]); let liquidity_vault = pda(&[LIQUIDITY_VAULT_SEED, reserve.as_ref()]); - let price_feed = self.price_feed_address(mint); + let price_feed = self.price_feed_address(market, mint); let instruction = Instruction { program_id: lending::id(), @@ -197,17 +202,32 @@ impl Env { /// The feed PDA the market owner writes for `mint`: seeded by the owner's /// key, so it is the feed `add_reserve` registers reserves against. - pub fn price_feed_address(&self, mint: Pubkey) -> Pubkey { - pda(&[PRICE_FEED_SEED, self.owner.pubkey().as_ref(), mint.as_ref()]) + /// The feed PDA for a given market and mint (seeds `["price_feed", market, mint]`). + pub fn price_feed_address(&self, market: Pubkey, mint: Pubkey) -> Pubkey { + pda(&[PRICE_FEED_SEED, market.as_ref(), mint.as_ref()]) } pub fn set_price(&mut self, mint: Pubkey, price_mantissa: i128) { - let price_feed = self.price_feed_address(mint); + let owner = self.owner.insecure_clone(); + let market = self.market; + self.set_price_for(&owner, market, mint, price_mantissa); + } + + /// Publish a price for `mint` in `market`, signed by that market's `owner`. + pub fn set_price_for( + &mut self, + owner: &Keypair, + market: Pubkey, + mint: Pubkey, + price_mantissa: i128, + ) { + let price_feed = self.price_feed_address(market, mint); let instruction = Instruction { program_id: lending::id(), accounts: lending::accounts::SetPrice { + lending_market: market, + owner: owner.pubkey(), price_feed, - authority: self.owner.pubkey(), mint, system_program: system_program::id(), } @@ -218,8 +238,7 @@ impl Env { } .data(), }; - let owner = self.owner.insecure_clone(); - send(&mut self.svm, vec![instruction], &[&owner], &owner.pubkey()).unwrap(); + send(&mut self.svm, vec![instruction], &[owner], &owner.pubkey()).unwrap(); } pub fn add_reserve( diff --git a/finance/lending/anchor/programs/lending/tests/test_security.rs b/finance/lending/anchor/programs/lending/tests/test_security.rs index 89466494..a6f1c219 100644 --- a/finance/lending/anchor/programs/lending/tests/test_security.rs +++ b/finance/lending/anchor/programs/lending/tests/test_security.rs @@ -36,23 +36,25 @@ fn cross_market_reserve_is_rejected() { ); } -/// The price feed PDA is seeded by its authority, so no signer can write (or -/// pre-claim) the feed another authority's reserves trust. +/// A market's price feed can only be written by that market's owner: an +/// outsider cannot publish (or squat) prices for a market they don't own. #[test] -fn foreign_signer_cannot_write_owner_price_feed() { +fn non_owner_cannot_write_market_price_feed() { let mut env = Env::new(); let usdc = env.add_reserve(6, dollars(1), default_config()); let attacker = env.create_user(); - let owner_feed = env.price_feed_address(usdc.mint); + // The market's feed for this mint (seeds ["price_feed", market, mint]). + let market_feed = env.price_feed_address(env.market, usdc.mint); - // The attacker targets the owner's feed address while signing as themself. - // The seeds [b"price_feed", authority, mint] cannot match, so this fails. + // The attacker passes the real market but signs as themself; `has_one = owner` + // on the market rejects them before any write. let instruction = Instruction { program_id: lending::id(), accounts: lending::accounts::SetPrice { - price_feed: owner_feed, - authority: attacker.pubkey(), + lending_market: env.market, + owner: attacker.pubkey(), + price_feed: market_feed, mint: usdc.mint, system_program: system_program::id(), } @@ -71,6 +73,6 @@ fn foreign_signer_cannot_write_owner_price_feed() { ); assert!( result.is_err(), - "only the authority in a feed's seeds may write that feed" + "only the market owner may write its price feed" ); } diff --git a/finance/lending/quasar/CHANGELOG.md b/finance/lending/quasar/CHANGELOG.md index 795fac4f..f9f4b2b3 100644 --- a/finance/lending/quasar/CHANGELOG.md +++ b/finance/lending/quasar/CHANGELOG.md @@ -21,3 +21,7 @@ Initial Quasar port of the Kamino/Solend-style borrow/lend program. (`LiquidationTooLarge`). - Reserve factor: the protocol keeps `reserve_factor_bps` of accrued interest as fees the market owner withdraws with `collect_protocol_fees`. +- LendingMarket is seeded by a `market_id` index (`["lending_market", market_id]`), + not by any individual; one owner can run several independent markets. +- Price feeds are seeded `["price_feed", market, mint]` (scoped to a market, not + to an individual); only the market owner may write one. diff --git a/finance/lending/quasar/README.md b/finance/lending/quasar/README.md index db8812bc..5d5b5730 100644 --- a/finance/lending/quasar/README.md +++ b/finance/lending/quasar/README.md @@ -29,7 +29,8 @@ Everything else mirrors the Anchor version. ## Major concepts - **`LendingMarket`** — market config (owner, quote-currency mint). PDA: - `["lending_market", owner]`. + `["lending_market", owner, market_id]`, where `market_id` is a per-owner `u64` + index, so one owner can run several isolated markets (their market 0, 1, 2 …). - **`Reserve`** — one asset's pool. Owns a program-controlled liquidity vault and a share-token mint (both PDAs, authority = the reserve), and stores the interest-rate config, the cumulative borrow-rate index, available liquidity, and @@ -38,11 +39,11 @@ Everything else mirrors the Anchor version. deposited share amount, plus the borrow reserve and scaled debt. PDA: `["obligation", market, owner]`. - **`PriceFeed`** — a Switchboard-On-Demand-shaped price (`mantissa * 10^exponent` - + slot). PDA: `["price_feed", authority, mint]` — the writer is part of the - address, so no signer can write or pre-claim another authority's feed, and each - reserve is bound to the feed its market owner registered. `set_price` writes it - directly for deterministic tests; in production a reserve points at the real - Switchboard feed. Freshness is checked in slots. + + slot). PDA: `["price_feed", market, mint]` — scoped to a market, not to any + individual; only the market's `owner` may write it, so prices can't be squatted + and each market prices its own assets. `set_price` writes it directly for + deterministic tests; in production a reserve points at the real Switchboard + feed. Freshness is checked in slots. - **Liquidation** — the close factor (max fraction of the debt one call repays) comes from the borrow reserve; the bonus from the collateral reserve. A repayment whose seizure would exceed the posted collateral fails with diff --git a/finance/lending/quasar/src/instructions/admin.rs b/finance/lending/quasar/src/instructions/admin.rs index b996d3f5..ab35f8de 100644 --- a/finance/lending/quasar/src/instructions/admin.rs +++ b/finance/lending/quasar/src/instructions/admin.rs @@ -19,10 +19,12 @@ use { // --------------------------------------------------------------------------- #[derive(Accounts)] +#[instruction(market_id: u64)] pub struct InitLendingMarket { #[account(mut)] pub owner: Signer, - #[account(init, payer = owner, address = LendingMarket::seeds(owner.address()))] + // Seeded by `market_id` alone — owner is stored for auth, not in the address. + #[account(init, payer = owner, address = LendingMarket::seeds(market_id))] pub lending_market: Account, pub quote_mint: Account, pub system_program: Program, @@ -30,9 +32,10 @@ pub struct InitLendingMarket { impl InitLendingMarket { #[inline(always)] - pub fn run(&mut self, bumps: &InitLendingMarketBumps) -> Result<(), ProgramError> { + pub fn run(&mut self, market_id: u64, bumps: &InitLendingMarketBumps) -> Result<(), ProgramError> { self.lending_market.set_inner(LendingMarketInner { owner: *self.owner.address(), + market_id, quote_mint: *self.quote_mint.address(), bump: bumps.lending_market, }); @@ -48,7 +51,7 @@ impl InitLendingMarket { pub struct InitReserve { #[account(mut)] pub owner: Signer, - #[account(has_one(owner), address = LendingMarket::seeds(owner.address()))] + #[account(has_one(owner))] pub lending_market: Account, #[account(init, payer = owner, address = Reserve::seeds(lending_market.address(), liquidity_mint.address()))] pub reserve: Account, @@ -59,9 +62,8 @@ pub struct InitReserve { /// Created and initialized as a share-token mint (authority = reserve) in the handler. #[account(mut, address = ShareMintPda::seeds(reserve.address()))] pub share_mint: UncheckedAccount, - // The reserve trusts the feed written by the market owner: feed PDAs are - // seeded by their authority, so this binds the reserve to the owner's feed. - #[account(address = PriceFeed::seeds(owner.address(), liquidity_mint.address()))] + // Bound to this market's feed for this mint (seeds: market + mint). + #[account(address = PriceFeed::seeds(lending_market.address(), liquidity_mint.address()))] pub price_feed: Account, pub token_program: Program, pub system_program: Program, @@ -184,11 +186,12 @@ impl InitReserve { #[derive(Accounts)] pub struct SetPrice { #[account(mut)] - pub authority: Signer, - // The authority is part of the seeds: a signer can only ever address (and - // therefore write) the feed derived from their own key, so there is no - // shared per-mint feed to claim first. - #[account(init(idempotent), payer = authority, address = PriceFeed::seeds(authority.address(), mint.address()))] + pub owner: Signer, + // Only the market's owner may publish its prices. + #[account(has_one(owner))] + pub lending_market: Account, + // Seeded by (market, mint) — scoped to the market, not to any individual. + #[account(init(idempotent), payer = owner, address = PriceFeed::seeds(lending_market.address(), mint.address()))] pub price_feed: Account, pub mint: Account, pub system_program: Program, @@ -203,11 +206,11 @@ impl SetPrice { bumps: &SetPriceBumps, ) -> Result<(), ProgramError> { self.price_feed.set_inner(PriceFeedInner { + market: *self.lending_market.address(), mint: *self.mint.address(), price_mantissa, exponent, last_updated_slot: now()?, - authority: *self.authority.address(), bump: bumps.price_feed, }); Ok(()) @@ -222,7 +225,7 @@ impl SetPrice { pub struct CollectProtocolFees { #[account(mut)] pub owner: Signer, - #[account(has_one(owner), address = LendingMarket::seeds(owner.address()))] + #[account(has_one(owner))] pub lending_market: Account, #[account(mut, has_one(lending_market), has_one(liquidity_mint), has_one(liquidity_vault))] pub reserve: Account, diff --git a/finance/lending/quasar/src/instructions/position.rs b/finance/lending/quasar/src/instructions/position.rs index 5976b09f..1cfd5aad 100644 --- a/finance/lending/quasar/src/instructions/position.rs +++ b/finance/lending/quasar/src/instructions/position.rs @@ -477,7 +477,7 @@ impl LiquidateObligation { let collateral_price = price_scaled(&self.collateral_price, slot)?; let borrow_price = price_scaled(&self.borrow_price, slot)?; - // Health: unhealthy when debt value exceeds collateral value * liq threshold. + // Health: unhealthy when debt value exceeds collateral value * liquidation threshold. let collateral_total = net_total_liquidity( collateral.available_liquidity, collateral.borrowed_amount_scaled, diff --git a/finance/lending/quasar/src/lib.rs b/finance/lending/quasar/src/lib.rs index 8358d7a8..4c2abaa2 100644 --- a/finance/lending/quasar/src/lib.rs +++ b/finance/lending/quasar/src/lib.rs @@ -34,8 +34,11 @@ mod quasar_lending { use super::*; #[instruction(discriminator = 0)] - pub fn init_lending_market(ctx: Ctx) -> Result<(), ProgramError> { - ctx.accounts.run(&ctx.bumps) + pub fn init_lending_market( + ctx: Ctx, + market_id: u64, + ) -> Result<(), ProgramError> { + ctx.accounts.run(market_id, &ctx.bumps) } #[instruction(discriminator = 1)] diff --git a/finance/lending/quasar/src/state.rs b/finance/lending/quasar/src/state.rs index e5283757..2c2b82f8 100644 --- a/finance/lending/quasar/src/state.rs +++ b/finance/lending/quasar/src/state.rs @@ -5,11 +5,15 @@ use quasar_lang::prelude::*; -/// Top-level market config. PDA: `["lending_market", owner]`. +/// Top-level market config. PDA: `["lending_market", market_id]`. +/// Seeded by its `market_id` index alone — the market is not identified by any +/// individual. `owner` is a stored field used only for authorization. Distinct +/// ids (0, 1, 2 …) give independent, risk-isolated markets. #[account(discriminator = 1, set_inner)] -#[seeds(b"lending_market", owner: Address)] +#[seeds(b"lending_market", market_id: u64)] pub struct LendingMarket { pub owner: Address, + pub market_id: u64, pub quote_mint: Address, pub bump: u8, } @@ -61,19 +65,19 @@ pub struct Obligation { pub bump: u8, } -/// Switchboard-On-Demand-shaped price feed. PDA: `["price_feed", authority, mint]` -/// — the writer is part of the address, so no two authorities can contend for -/// the same feed, and a reserve trusts exactly the feed its market owner passed -/// to `init_reserve`. `price = price_mantissa * 10^exponent`; freshness is -/// checked in slots. In production this account would be the real Switchboard feed. +/// Switchboard-On-Demand-shaped price feed. PDA: `["price_feed", market, mint]` +/// — scoped to a market (not to any individual); only the market's `owner` may +/// write it, so prices can't be squatted and each market prices its own assets. +/// `price = price_mantissa * 10^exponent`; freshness is checked in slots. In +/// production this account would be the real Switchboard feed. #[account(discriminator = 4, set_inner)] -#[seeds(b"price_feed", authority: Address, mint: Address)] +#[seeds(b"price_feed", market: Address, mint: Address)] pub struct PriceFeed { + pub market: Address, pub mint: Address, pub price_mantissa: i128, pub exponent: i32, pub last_updated_slot: u64, - pub authority: Address, pub bump: u8, } diff --git a/finance/lending/quasar/src/tests.rs b/finance/lending/quasar/src/tests.rs index bdcd9919..ce939bca 100644 --- a/finance/lending/quasar/src/tests.rs +++ b/finance/lending/quasar/src/tests.rs @@ -37,6 +37,8 @@ const BORROWER_BORROW: Pubkey = Pubkey::new_from_array([14; 32]); const LIQUIDATOR_BORROW: Pubkey = Pubkey::new_from_array([15; 32]); const LIQUIDATOR_COLL_SHARE: Pubkey = Pubkey::new_from_array([16; 32]); const OWNER_BORROW: Pubkey = Pubkey::new_from_array([17; 32]); +// Per-owner market index this market is seeded from (owner's market 0). +const MARKET_ID: u64 = 0; fn token_program() -> Pubkey { quasar_svm::SPL_TOKEN_PROGRAM_ID @@ -124,7 +126,7 @@ impl World { .with_program(&crate::ID, &elf) .with_token_program(); - let (market, _) = pda(&[b"lending_market", OWNER.as_ref()]); + let (market, _) = pda(&[b"lending_market", &MARKET_ID.to_le_bytes()]); let (coll_reserve, _) = pda(&[b"reserve", market.as_ref(), COLL_MINT.as_ref()]); let (borrow_reserve, _) = pda(&[b"reserve", market.as_ref(), BORROW_MINT.as_ref()]); let (coll_vault, _) = pda(&[b"liquidity_vault", coll_reserve.as_ref()]); @@ -132,8 +134,8 @@ impl World { let (coll_share_mint, _) = pda(&[b"share_mint", coll_reserve.as_ref()]); let (borrow_share_mint, _) = pda(&[b"share_mint", borrow_reserve.as_ref()]); // Feed PDAs are seeded by their writing authority (the market owner here). - let (coll_price, _) = pda(&[b"price_feed", OWNER.as_ref(), COLL_MINT.as_ref()]); - let (borrow_price, _) = pda(&[b"price_feed", OWNER.as_ref(), BORROW_MINT.as_ref()]); + let (coll_price, _) = pda(&[b"price_feed", market.as_ref(), COLL_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", coll_reserve.as_ref(), obligation.as_ref()]); @@ -198,13 +200,16 @@ impl World { } fn init_market(&mut self) { + // Instruction data: [discriminator 0][market_id u64 LE]. + let mut data = vec![0u8]; + data.extend_from_slice(&MARKET_ID.to_le_bytes()); let metas = vec![ meta(OWNER, true, true), meta(self.market, true, false), meta(QUOTE_MINT, false, false), meta(system_program(), false, false), ]; - self.run(vec![0], metas).assert_success(); + self.run(data, metas).assert_success(); } fn set_price(&mut self, the_mint: Pubkey, price_feed: Pubkey, mantissa: i128) { @@ -213,6 +218,7 @@ impl World { data.extend_from_slice(&EXP.to_le_bytes()); let metas = vec![ meta(OWNER, true, true), + meta(self.market, false, false), meta(price_feed, true, false), meta(the_mint, false, false), meta(system_program(), false, false), @@ -222,7 +228,7 @@ impl World { #[allow(clippy::too_many_arguments)] fn init_reserve(&mut self, the_mint: Pubkey, reserve: Pubkey, vault: Pubkey, share: Pubkey, price: Pubkey) { - // 75% LTV, 80% liq threshold, 5% bonus, 50% close factor, 10% reserve + // 75% LTV, 80% liquidation threshold, 5% bonus, 50% close factor, 10% reserve // factor, kink 80%, 2% / 20% / 150% APR curve. let config: [u16; 9] = [7_500, 8_000, 500, 5_000, 1_000, 8_000, 200, 2_000, 15_000]; let mut data = vec![1u8];