From 9cc95289d95ef191c0ffc642384f7aad940d54ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 20:53:57 +0000 Subject: [PATCH 1/4] Seed LendingMarket by a market_id, not the owner A market keyed on the owner's address can only ever exist once per owner, which contradicts the multi-market isolation the program is meant to support, and is inconsistent with the repo's own convention (the order-book Market is seeded by its mint pair, not by an admin's address). LendingMarket is now seeded by a unique market_id (a client-chosen Pubkey, like token-swap's Amm); owner is a stored field and admin handlers authorize with has_one = owner. Reserves, obligations, and vaults already key off the market account address, so they're unchanged. Mirrored into the Quasar port (market_id passed as a reference account) and the test harnesses; READMEs/CHANGELOGs updated. https://claude.ai/code/session_01RwE8f8ahP5S6SDNTsXmpj9 --- finance/lending/anchor/CHANGELOG.md | 2 ++ finance/lending/anchor/README.md | 4 +++- .../src/instructions/admin/collect_protocol_fees.rs | 9 +++------ .../src/instructions/admin/init_lending_market.rs | 10 ++++++++-- .../lending/src/instructions/admin/init_reserve.rs | 10 ++++------ .../src/instructions/admin/update_reserve_config.rs | 9 +++------ finance/lending/anchor/programs/lending/src/lib.rs | 7 +++++-- .../programs/lending/src/state/lending_market.rs | 5 +++++ .../anchor/programs/lending/tests/common/mod.rs | 12 ++++++++---- finance/lending/quasar/CHANGELOG.md | 2 ++ finance/lending/quasar/README.md | 3 ++- finance/lending/quasar/src/instructions/admin.rs | 10 +++++++--- finance/lending/quasar/src/state.rs | 7 +++++-- finance/lending/quasar/src/tests.rs | 7 ++++++- 14 files changed, 63 insertions(+), 34 deletions(-) diff --git a/finance/lending/anchor/CHANGELOG.md b/finance/lending/anchor/CHANGELOG.md index 2eaa9500..dba33162 100644 --- a/finance/lending/anchor/CHANGELOG.md +++ b/finance/lending/anchor/CHANGELOG.md @@ -27,3 +27,5 @@ 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 unique `market_id` (not the owner), so one owner + can run several independent markets; admin handlers authorize via `has_one = owner`. diff --git a/finance/lending/anchor/README.md b/finance/lending/anchor/README.md index c0a0e937..73252545 100644 --- a/finance/lending/anchor/README.md +++ b/finance/lending/anchor/README.md @@ -32,7 +32,9 @@ 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", market_id]`, where `market_id` is a client-chosen `Pubkey` + (typically a fresh keypair). Seeding by an id rather than the owner lets one + owner run several independent, risk-isolated markets. - **`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 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..c2ef79bd 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: Pubkey, +) -> 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,14 @@ pub fn handle_init_lending_market(context: Context) -> Result } #[derive(Accounts)] +#[instruction(market_id: Pubkey)] pub struct InitLendingMarket<'info> { + // Seeded by `market_id`, not `owner`, so one owner can run several markets. #[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.as_ref()], 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..a58e1c81 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, 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)] 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..cbcb8af3 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: Pubkey, + ) -> 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..95641d59 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,11 @@ use anchor_lang::prelude::*; #[account] #[derive(InitSpace)] pub struct LendingMarket { + /// Unique id this market's PDA is derived from (a client-chosen `Pubkey`, + /// typically a fresh keypair). Seeding by an id rather than `owner` lets one + /// owner run several independent, risk-isolated markets. + pub market_id: Pubkey, + pub owner: Pubkey, /// The mint that obligation values are denominated in (for example USDC). diff --git a/finance/lending/anchor/programs/lending/tests/common/mod.rs b/finance/lending/anchor/programs/lending/tests/common/mod.rs index c6beb8c5..a0480fda 100644 --- a/finance/lending/anchor/programs/lending/tests/common/mod.rs +++ b/finance/lending/anchor/programs/lending/tests/common/mod.rs @@ -97,7 +97,10 @@ 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 a unique id (a fresh keypair pubkey), not by + // the owner, so one owner can run several markets. + let market_id = Keypair::new().pubkey(); + let market = pda(&[LENDING_MARKET_SEED, market_id.as_ref()]); let instruction = Instruction { program_id: lending::id(), @@ -108,7 +111,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 +127,8 @@ 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()]); + let market_id = Keypair::new().pubkey(); + let market = pda(&[LENDING_MARKET_SEED, market_id.as_ref()]); let instruction = Instruction { program_id: lending::id(), accounts: lending::accounts::InitLendingMarket { @@ -134,7 +138,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 diff --git a/finance/lending/quasar/CHANGELOG.md b/finance/lending/quasar/CHANGELOG.md index 795fac4f..b004a77c 100644 --- a/finance/lending/quasar/CHANGELOG.md +++ b/finance/lending/quasar/CHANGELOG.md @@ -21,3 +21,5 @@ 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 unique `market_id` (not the owner), so one owner + can run several independent markets. diff --git a/finance/lending/quasar/README.md b/finance/lending/quasar/README.md index db8812bc..80053ee8 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", market_id]`, where `market_id` is a client-chosen address + (typically a fresh keypair), so one owner can run several isolated markets. - **`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 diff --git a/finance/lending/quasar/src/instructions/admin.rs b/finance/lending/quasar/src/instructions/admin.rs index b996d3f5..4a7d7e69 100644 --- a/finance/lending/quasar/src/instructions/admin.rs +++ b/finance/lending/quasar/src/instructions/admin.rs @@ -22,7 +22,10 @@ use { pub struct InitLendingMarket { #[account(mut)] pub owner: Signer, - #[account(init, payer = owner, address = LendingMarket::seeds(owner.address()))] + /// Only its address is used — as the market's unique seed — so one owner can + /// open many markets. Typically a fresh keypair the client generates. + pub market_id: UncheckedAccount, + #[account(init, payer = owner, address = LendingMarket::seeds(market_id.address()))] pub lending_market: Account, pub quote_mint: Account, pub system_program: Program, @@ -32,6 +35,7 @@ impl InitLendingMarket { #[inline(always)] pub fn run(&mut self, bumps: &InitLendingMarketBumps) -> Result<(), ProgramError> { self.lending_market.set_inner(LendingMarketInner { + market_id: *self.market_id.address(), owner: *self.owner.address(), quote_mint: *self.quote_mint.address(), bump: bumps.lending_market, @@ -48,7 +52,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, @@ -222,7 +226,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/state.rs b/finance/lending/quasar/src/state.rs index e5283757..277356bc 100644 --- a/finance/lending/quasar/src/state.rs +++ b/finance/lending/quasar/src/state.rs @@ -5,10 +5,13 @@ use quasar_lang::prelude::*; -/// Top-level market config. PDA: `["lending_market", owner]`. +/// Top-level market config. PDA: `["lending_market", market_id]`. +/// Seeded by a unique `market_id` (a client-chosen address, typically a fresh +/// keypair) rather than `owner`, so one owner can run several isolated markets. #[account(discriminator = 1, set_inner)] -#[seeds(b"lending_market", owner: Address)] +#[seeds(b"lending_market", market_id: Address)] pub struct LendingMarket { + pub market_id: Address, pub owner: Address, pub quote_mint: Address, pub bump: u8, diff --git a/finance/lending/quasar/src/tests.rs b/finance/lending/quasar/src/tests.rs index bdcd9919..09b8d076 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]); +// Unique id the market PDA is seeded from (a stand-in for a fresh keypair). +const MARKET_ID: Pubkey = Pubkey::new_from_array([20; 32]); 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.as_ref()]); 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()]); @@ -146,6 +148,8 @@ impl World { mint(COLL_MINT, OWNER), mint(BORROW_MINT, OWNER), mint(QUOTE_MINT, OWNER), + // Reference-only account whose address seeds the market. + empty(MARKET_ID), // PDAs created by the program. empty(market), empty(coll_reserve), @@ -200,6 +204,7 @@ impl World { fn init_market(&mut self) { let metas = vec![ meta(OWNER, true, true), + meta(MARKET_ID, false, false), meta(self.market, true, false), meta(QUOTE_MINT, false, false), meta(system_program(), false, false), From 2647401a5cf43b7af5497050b790c1466d5ead82 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 21:11:02 +0000 Subject: [PATCH 2/4] Seed LendingMarket by a per-owner u64 index, not a Pubkey id Replaces the Pubkey market_id with a per-owner u64 index: the market PDA is now seeded by (owner, market_id), e.g. "owner's market 0". An integer is more readable than an opaque pubkey and needs no keypair; scoping it under the owner keeps each owner's index space collision-free without a global registry (a bare global integer would invite the same first-come land-grab the oracle fix removed). Applied to Anchor and Quasar (Quasar takes market_id as an instruction-data u64 seed, dropping the reference account), with test harnesses, READMEs, and CHANGELOGs updated. https://claude.ai/code/session_01RwE8f8ahP5S6SDNTsXmpj9 --- finance/lending/anchor/CHANGELOG.md | 5 +++-- finance/lending/anchor/README.md | 7 ++++--- .../instructions/admin/init_lending_market.rs | 9 +++++---- .../lending/anchor/programs/lending/src/lib.rs | 2 +- .../programs/lending/src/state/lending_market.rs | 9 +++++---- .../anchor/programs/lending/tests/common/mod.rs | 16 ++++++++++------ finance/lending/quasar/CHANGELOG.md | 4 ++-- finance/lending/quasar/README.md | 4 ++-- finance/lending/quasar/src/instructions/admin.rs | 11 +++++------ finance/lending/quasar/src/lib.rs | 7 +++++-- finance/lending/quasar/src/state.rs | 10 +++++----- finance/lending/quasar/src/tests.rs | 14 +++++++------- 12 files changed, 54 insertions(+), 44 deletions(-) diff --git a/finance/lending/anchor/CHANGELOG.md b/finance/lending/anchor/CHANGELOG.md index dba33162..bb8c4377 100644 --- a/finance/lending/anchor/CHANGELOG.md +++ b/finance/lending/anchor/CHANGELOG.md @@ -27,5 +27,6 @@ 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 unique `market_id` (not the owner), so one owner - can run several independent markets; admin handlers authorize via `has_one = owner`. +- LendingMarket is seeded by `(owner, market_id)` — a per-owner `u64` index — + so one owner can run several independent markets; admin handlers authorize via + `has_one = owner`. diff --git a/finance/lending/anchor/README.md b/finance/lending/anchor/README.md index 73252545..45317174 100644 --- a/finance/lending/anchor/README.md +++ b/finance/lending/anchor/README.md @@ -32,9 +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", market_id]`, where `market_id` is a client-chosen `Pubkey` - (typically a fresh keypair). Seeding by an id rather than the owner lets one - owner run several independent, risk-isolated markets. + `["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 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 c2ef79bd..67a749dd 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 @@ -6,7 +6,7 @@ use crate::state::LendingMarket; pub fn handle_init_lending_market( context: Context, - market_id: Pubkey, + market_id: u64, ) -> Result<()> { let market = &mut context.accounts.lending_market; market.market_id = market_id; @@ -17,14 +17,15 @@ pub fn handle_init_lending_market( } #[derive(Accounts)] -#[instruction(market_id: Pubkey)] +#[instruction(market_id: u64)] pub struct InitLendingMarket<'info> { - // Seeded by `market_id`, not `owner`, so one owner can run several markets. + // Seeded by (owner, market_id), so one owner can run several markets and no + // two owners contend for the same address. #[account( init, payer = owner, space = LendingMarket::DISCRIMINATOR.len() + LendingMarket::INIT_SPACE, - seeds = [LENDING_MARKET_SEED, market_id.as_ref()], + seeds = [LENDING_MARKET_SEED, owner.key().as_ref(), &market_id.to_le_bytes()], bump, )] pub lending_market: Account<'info, LendingMarket>, diff --git a/finance/lending/anchor/programs/lending/src/lib.rs b/finance/lending/anchor/programs/lending/src/lib.rs index cbcb8af3..ca848d0b 100644 --- a/finance/lending/anchor/programs/lending/src/lib.rs +++ b/finance/lending/anchor/programs/lending/src/lib.rs @@ -17,7 +17,7 @@ pub mod lending { pub fn init_lending_market( context: Context, - market_id: Pubkey, + market_id: u64, ) -> Result<()> { instructions::handle_init_lending_market(context, market_id) } 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 95641d59..f99fe29f 100644 --- a/finance/lending/anchor/programs/lending/src/state/lending_market.rs +++ b/finance/lending/anchor/programs/lending/src/state/lending_market.rs @@ -5,10 +5,11 @@ use anchor_lang::prelude::*; #[account] #[derive(InitSpace)] pub struct LendingMarket { - /// Unique id this market's PDA is derived from (a client-chosen `Pubkey`, - /// typically a fresh keypair). Seeding by an id rather than `owner` lets one - /// owner run several independent, risk-isolated markets. - pub market_id: Pubkey, + /// Per-owner index this market's PDA is derived from. Seeding by + /// `(owner, market_id)` rather than the owner alone lets one owner run + /// several independent, risk-isolated markets ("their market 0, 1, 2 …") + /// while keeping each owner's index space free of cross-owner collisions. + pub market_id: u64, pub owner: Pubkey, diff --git a/finance/lending/anchor/programs/lending/tests/common/mod.rs b/finance/lending/anchor/programs/lending/tests/common/mod.rs index a0480fda..a05903eb 100644 --- a/finance/lending/anchor/programs/lending/tests/common/mod.rs +++ b/finance/lending/anchor/programs/lending/tests/common/mod.rs @@ -97,10 +97,10 @@ 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(); - // The market is seeded by a unique id (a fresh keypair pubkey), not by - // the owner, so one owner can run several markets. - let market_id = Keypair::new().pubkey(); - let market = pda(&[LENDING_MARKET_SEED, market_id.as_ref()]); + // The market is seeded by (owner, market_id), so one owner can run + // several markets. This is the owner's market 0. + let market_id: u64 = 0; + let market = pda(&[LENDING_MARKET_SEED, owner.pubkey().as_ref(), &market_id.to_le_bytes()]); let instruction = Instruction { program_id: lending::id(), @@ -127,8 +127,12 @@ 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_id = Keypair::new().pubkey(); - let market = pda(&[LENDING_MARKET_SEED, market_id.as_ref()]); + let market_id: u64 = 0; + let market = pda(&[ + LENDING_MARKET_SEED, + market_owner.pubkey().as_ref(), + &market_id.to_le_bytes(), + ]); let instruction = Instruction { program_id: lending::id(), accounts: lending::accounts::InitLendingMarket { diff --git a/finance/lending/quasar/CHANGELOG.md b/finance/lending/quasar/CHANGELOG.md index b004a77c..93500737 100644 --- a/finance/lending/quasar/CHANGELOG.md +++ b/finance/lending/quasar/CHANGELOG.md @@ -21,5 +21,5 @@ 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 unique `market_id` (not the owner), so one owner - can run several independent markets. +- LendingMarket is seeded by `(owner, market_id)` — a per-owner `u64` index — + so one owner can run several independent markets. diff --git a/finance/lending/quasar/README.md b/finance/lending/quasar/README.md index 80053ee8..f3493fcc 100644 --- a/finance/lending/quasar/README.md +++ b/finance/lending/quasar/README.md @@ -29,8 +29,8 @@ Everything else mirrors the Anchor version. ## Major concepts - **`LendingMarket`** — market config (owner, quote-currency mint). PDA: - `["lending_market", market_id]`, where `market_id` is a client-chosen address - (typically a fresh keypair), so one owner can run several isolated markets. + `["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 diff --git a/finance/lending/quasar/src/instructions/admin.rs b/finance/lending/quasar/src/instructions/admin.rs index 4a7d7e69..b043ff02 100644 --- a/finance/lending/quasar/src/instructions/admin.rs +++ b/finance/lending/quasar/src/instructions/admin.rs @@ -19,13 +19,12 @@ use { // --------------------------------------------------------------------------- #[derive(Accounts)] +#[instruction(market_id: u64)] pub struct InitLendingMarket { #[account(mut)] pub owner: Signer, - /// Only its address is used — as the market's unique seed — so one owner can - /// open many markets. Typically a fresh keypair the client generates. - pub market_id: UncheckedAccount, - #[account(init, payer = owner, address = LendingMarket::seeds(market_id.address()))] + // Seeded by (owner, market_id), a per-owner index — one owner, many markets. + #[account(init, payer = owner, address = LendingMarket::seeds(owner.address(), market_id))] pub lending_market: Account, pub quote_mint: Account, pub system_program: Program, @@ -33,10 +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 { - market_id: *self.market_id.address(), owner: *self.owner.address(), + market_id, quote_mint: *self.quote_mint.address(), bump: bumps.lending_market, }); 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 277356bc..0f7f598a 100644 --- a/finance/lending/quasar/src/state.rs +++ b/finance/lending/quasar/src/state.rs @@ -5,14 +5,14 @@ use quasar_lang::prelude::*; -/// Top-level market config. PDA: `["lending_market", market_id]`. -/// Seeded by a unique `market_id` (a client-chosen address, typically a fresh -/// keypair) rather than `owner`, so one owner can run several isolated markets. +/// Top-level market config. PDA: `["lending_market", owner, market_id]`. +/// Seeded by a per-owner index, so one owner can run several isolated markets +/// ("their market 0, 1, 2 …") with no cross-owner collisions. #[account(discriminator = 1, set_inner)] -#[seeds(b"lending_market", market_id: Address)] +#[seeds(b"lending_market", owner: Address, market_id: u64)] pub struct LendingMarket { - pub market_id: Address, pub owner: Address, + pub market_id: u64, pub quote_mint: Address, pub bump: u8, } diff --git a/finance/lending/quasar/src/tests.rs b/finance/lending/quasar/src/tests.rs index 09b8d076..82ca0764 100644 --- a/finance/lending/quasar/src/tests.rs +++ b/finance/lending/quasar/src/tests.rs @@ -37,8 +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]); -// Unique id the market PDA is seeded from (a stand-in for a fresh keypair). -const MARKET_ID: Pubkey = Pubkey::new_from_array([20; 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 @@ -126,7 +126,7 @@ impl World { .with_program(&crate::ID, &elf) .with_token_program(); - let (market, _) = pda(&[b"lending_market", MARKET_ID.as_ref()]); + let (market, _) = pda(&[b"lending_market", OWNER.as_ref(), &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()]); @@ -148,8 +148,6 @@ impl World { mint(COLL_MINT, OWNER), mint(BORROW_MINT, OWNER), mint(QUOTE_MINT, OWNER), - // Reference-only account whose address seeds the market. - empty(MARKET_ID), // PDAs created by the program. empty(market), empty(coll_reserve), @@ -202,14 +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(MARKET_ID, false, false), 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) { From f250b6ed95e2775a01170e8fa03863795ef45f81 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 21:38:38 +0000 Subject: [PATCH 3/4] Remove individual addresses from market and price-feed seeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markets and admin structs shouldn't be identified by a person's address. - LendingMarket is now seeded by ["lending_market", market_id] — a u64 index, no owner in the seed. owner remains a stored field and authorizes admin instructions via has_one = owner. - PriceFeed is now seeded by ["price_feed", market, mint] — scoped to a market, not to an individual. set_price requires the market's owner to sign, so prices can't be squatted, a reserve binds to its own market's feed, and isolated markets can price the same asset independently. Applied to both Anchor and Quasar, with test harnesses, READMEs, and CHANGELOGs updated. Anchor 22 tests + Quasar 6 tests pass; IDL build verified. https://claude.ai/code/session_01RwE8f8ahP5S6SDNTsXmpj9 --- finance/lending/anchor/CHANGELOG.md | 8 ++-- finance/lending/anchor/README.md | 9 ++-- .../instructions/admin/init_lending_market.rs | 7 +-- .../src/instructions/admin/init_reserve.rs | 9 +++- .../src/instructions/admin/set_price.rs | 27 +++++------ .../lending/src/state/lending_market.rs | 8 ++-- .../programs/lending/src/state/price_feed.rs | 14 +++--- .../programs/lending/tests/common/mod.rs | 45 ++++++++++++------- .../programs/lending/tests/test_security.rs | 20 +++++---- finance/lending/quasar/CHANGELOG.md | 6 ++- finance/lending/quasar/README.md | 10 ++--- .../lending/quasar/src/instructions/admin.rs | 22 ++++----- finance/lending/quasar/src/state.rs | 23 +++++----- finance/lending/quasar/src/tests.rs | 7 +-- 14 files changed, 120 insertions(+), 95 deletions(-) diff --git a/finance/lending/anchor/CHANGELOG.md b/finance/lending/anchor/CHANGELOG.md index bb8c4377..369a7f2b 100644 --- a/finance/lending/anchor/CHANGELOG.md +++ b/finance/lending/anchor/CHANGELOG.md @@ -27,6 +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 `(owner, market_id)` — a per-owner `u64` index — - so one owner can run several independent markets; admin handlers authorize via - `has_one = owner`. +- 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 45317174..43226d56 100644 --- a/finance/lending/anchor/README.md +++ b/finance/lending/anchor/README.md @@ -107,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/init_lending_market.rs b/finance/lending/anchor/programs/lending/src/instructions/admin/init_lending_market.rs index 67a749dd..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 @@ -19,13 +19,14 @@ pub fn handle_init_lending_market( #[derive(Accounts)] #[instruction(market_id: u64)] pub struct InitLendingMarket<'info> { - // Seeded by (owner, market_id), so one owner can run several markets and no - // two owners contend for the same address. + // 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(), &market_id.to_le_bytes()], + 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 a58e1c81..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, 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}; @@ -68,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/state/lending_market.rs b/finance/lending/anchor/programs/lending/src/state/lending_market.rs index f99fe29f..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,10 +5,10 @@ use anchor_lang::prelude::*; #[account] #[derive(InitSpace)] pub struct LendingMarket { - /// Per-owner index this market's PDA is derived from. Seeding by - /// `(owner, market_id)` rather than the owner alone lets one owner run - /// several independent, risk-isolated markets ("their market 0, 1, 2 …") - /// while keeping each owner's index space free of cross-owner collisions. + /// 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, 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 a05903eb..fd2e6a6d 100644 --- a/finance/lending/anchor/programs/lending/tests/common/mod.rs +++ b/finance/lending/anchor/programs/lending/tests/common/mod.rs @@ -97,10 +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(); - // The market is seeded by (owner, market_id), so one owner can run - // several markets. This is the owner's market 0. + // 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, owner.pubkey().as_ref(), &market_id.to_le_bytes()]); + let market = pda(&[LENDING_MARKET_SEED, &market_id.to_le_bytes()]); let instruction = Instruction { program_id: lending::id(), @@ -127,12 +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_id: u64 = 0; - let market = pda(&[ - LENDING_MARKET_SEED, - market_owner.pubkey().as_ref(), - &market_id.to_le_bytes(), - ]); + // 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 { @@ -161,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(), @@ -205,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(), } @@ -226,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 93500737..f9f4b2b3 100644 --- a/finance/lending/quasar/CHANGELOG.md +++ b/finance/lending/quasar/CHANGELOG.md @@ -21,5 +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 `(owner, market_id)` — a per-owner `u64` index — - so one owner can run several independent markets. +- 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 f3493fcc..5d5b5730 100644 --- a/finance/lending/quasar/README.md +++ b/finance/lending/quasar/README.md @@ -39,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 b043ff02..ab35f8de 100644 --- a/finance/lending/quasar/src/instructions/admin.rs +++ b/finance/lending/quasar/src/instructions/admin.rs @@ -23,8 +23,8 @@ use { pub struct InitLendingMarket { #[account(mut)] pub owner: Signer, - // Seeded by (owner, market_id), a per-owner index — one owner, many markets. - #[account(init, payer = owner, address = LendingMarket::seeds(owner.address(), market_id))] + // 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, @@ -62,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, @@ -187,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, @@ -206,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(()) diff --git a/finance/lending/quasar/src/state.rs b/finance/lending/quasar/src/state.rs index 0f7f598a..2c2b82f8 100644 --- a/finance/lending/quasar/src/state.rs +++ b/finance/lending/quasar/src/state.rs @@ -5,11 +5,12 @@ use quasar_lang::prelude::*; -/// Top-level market config. PDA: `["lending_market", owner, market_id]`. -/// Seeded by a per-owner index, so one owner can run several isolated markets -/// ("their market 0, 1, 2 …") with no cross-owner collisions. +/// 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, market_id: u64)] +#[seeds(b"lending_market", market_id: u64)] pub struct LendingMarket { pub owner: Address, pub market_id: u64, @@ -64,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 82ca0764..43c24493 100644 --- a/finance/lending/quasar/src/tests.rs +++ b/finance/lending/quasar/src/tests.rs @@ -126,7 +126,7 @@ impl World { .with_program(&crate::ID, &elf) .with_token_program(); - let (market, _) = pda(&[b"lending_market", OWNER.as_ref(), &MARKET_ID.to_le_bytes()]); + 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()]); @@ -134,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()]); @@ -218,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), From 3c7623ff123ee788803d447e29e4cd096b46b0a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 22:07:50 +0000 Subject: [PATCH 4/4] Spell out 'liquidation threshold' in Quasar comments The Solana skill's naming rule is to avoid abbreviations and use full words. Two Quasar comments shortened 'liquidation threshold' to 'liq threshold'; the Anchor side already spells it out. Align them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RwE8f8ahP5S6SDNTsXmpj9 --- finance/lending/quasar/src/instructions/position.rs | 2 +- finance/lending/quasar/src/tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/tests.rs b/finance/lending/quasar/src/tests.rs index 43c24493..ce939bca 100644 --- a/finance/lending/quasar/src/tests.rs +++ b/finance/lending/quasar/src/tests.rs @@ -228,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];