Prepared by:
HALBORN
Last Updated 09/25/2025
Date of Engagement: August 8th, 2025 - August 29th, 2025
100% of all REPORTED Findings have been addressed
All findings
5
Critical
0
High
0
Medium
0
Low
0
Informational
5
Lince
engaged Halborn
to conduct a security assessment on their Solana programs beginning on August 8th, 2025, and ending on August 29th, 2025. The security assessment was scoped to the Solana Programs provided in lince_dpm GitHub repository. Commit hashes and further details can be found in the Scope section of this report.
Lince
protocol is a Solana-based asset management system that allows users to deposit stablecoins into strategy-driven investment pools in exchange for LP tokens representing their share of the pool. Each pool specifies a target asset composition, and the protocol periodically rebalances allocations using Jupiter swaps investing stablecoins into various yield-generating assets and later converting them back. Users can withdraw at any time to receive their proportional share of stablecoins and underlying assets. The protocol manages fees, reserves, and valuations using oracle based snapshots, but currently relies heavily on off-chain infrastructure to manage user keys and execute rebalancing operations.
Halborn
was provided 16 days for the engagement and assigned one full-time security engineer to review the security of the Solana Programs in scope. The engineer is a blockchain and smart contract security expert with advanced smart contract hacking skills, and deep knowledge of multiple blockchain protocols.
The purpose of the assessment is to:
Identify potential security issues within the Solana Programs.
Ensure that smart contract functionality operates as intended.
In summary, Halborn identified some improvements to reduce the likelihood and impact of risks, which has been addressed by the Lince team
. The main ones were the following:
Enforce strict validation that the assets passed in during deposit and withdrawal match the original pool composition by verifying asset_type against the stored composition.
Validate that the provided reserve_ata_acc_infos via remaining_accounts are owned by the config PDA.
Include the constraint associated_token::token_program = token_program for transfer_admin, reserve_swap_jupiter and pool_swap_jupiter.
Update get_swap_data_amounts() to correctly parse quoted_in_amount and out_amount for shared_accounts_exact_out_route .
Enforce a runtime check to ensure both the stablecoin and LP mint use 6 decimals.
Halborn
performed a combination of a manual review of the source code and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of the program assessment. While manual testing is recommended to uncover flaws in business logic, processes, and implementation; automated testing techniques help enhance coverage of programs and can quickly identify items that do not follow security best practices.
The following phases and associated tools were used throughout the term of the assessment:
Research into the architecture, purpose, and use of the platform.
Manual program source code review to identify business logic issues.
Mapping out possible attack vectors
Thorough assessment of safety and usage of critical Rust variables and functions in scope that could lead to arithmetic vulnerabilities.
Scanning dependencies for known vulnerabilities (cargo audit
).
Local runtime testing (anchor test
)
EXPLOITABILITY METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Attack Origin (AO) | Arbitrary (AO:A) Specific (AO:S) | 1 0.2 |
Attack Cost (AC) | Low (AC:L) Medium (AC:M) High (AC:H) | 1 0.67 0.33 |
Attack Complexity (AX) | Low (AX:L) Medium (AX:M) High (AX:H) | 1 0.67 0.33 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Confidentiality (C) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Integrity (I) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Availability (A) | None (A:N) Low (A:L) Medium (A:M) High (A:H) Critical (A:C) | 0 0.25 0.5 0.75 1 |
Deposit (D) | None (D:N) Low (D:L) Medium (D:M) High (D:H) Critical (D:C) | 0 0.25 0.5 0.75 1 |
Yield (Y) | None (Y:N) Low (Y:L) Medium (Y:M) High (Y:H) Critical (Y:C) | 0 0.25 0.5 0.75 1 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
0
High
0
Medium
0
Low
0
Informational
5
Security analysis | Risk level | Remediation Date |
---|---|---|
Missing Assets Validation in Pool Allows for LP Value Manipulation | Informational | Solved - 08/29/2025 |
Missing Ownership Validation for Reserve ATA in withdraw instruction | Informational | Solved - 08/29/2025 |
Incompatible ATA Derivation for Token-2022 Mints | Informational | Solved - 08/14/2025 |
No decimal consistency check between stablecoin and LP mint | Informational | Solved - 08/25/2025 |
Incorrect Swap Amount Parsing for Exact-Out Instructions May Cause Transaction Failures | Informational | Solved - 08/27/2025 |
//
The Lince protocol allows pool initialization via the initialize_pool
instruction, where a strategy and an array of compositions
are specified. Each composition includes weight_bps
, asset_type
, operation
, and platform
. The asset_type
is an enum that refers to a known asset mint (e.g., USDC, JITOSOL, USDY etc.).
programs/dpm/src/state/pool.rs
#[derive(Clone, AnchorSerialize, AnchorDeserialize, Debug)]
pub struct Composition {
pub weight_bps: u16,
pub asset_type: AssetType,
pub operation: Operation,
pub platform: Platform,
}
programs/dpm/src/state/asset.rs
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AssetType {
CRT,
JLP,
FLP1,
ALP,
JITOSOL,
WFRAGSOL,
SYRUPUSDC,
PST,
USDY,
SUSDE,
SUSD,
INF,
USDC,
}
The instructions such as process_user_deposit
and process_user_withdraw
which allows users to deposit and withdraw stablecoins from the protocol. In each instruction asset snapshots are calculated from a set of remaining_accounts
without verifying that the passed in assets corresponds to the original pool composition assetType
. The valuation logic (get_pool_assets_snapshots
, get_pool_value_internal
) simply deserializes the provided accounts and uses their balances and oracle price feeds, regardless of whether those assets were ever part of the pool.
programs/dpm/src/instructions/process_user_deposit.rs
pub fn process_user_deposit(
ctx: Context<ProcessUserDeposit>,
strategy: Strategy,
deposit_amount: u64,
) -> Result<()> {
let config = &ctx.accounts.config;
let pool = &ctx.accounts.pool;
let pool_stable_ata = &ctx.accounts.pool_stable_ata;
let user_lince_pk = &ctx.accounts.user_lince_pk;
let user_pda = &mut ctx.accounts.user_pda;
let user_lince_stable_ata = &ctx.accounts.user_lince_stable_ata;
let stable = &ctx.accounts.stablecoin;
let lp_mint = &ctx.accounts.lp_mint;
let token_program = &ctx.accounts.token_program;
let rem_accs = ctx.remaining_accounts;
// ************
// Pre - checks
// ************
require!(
deposit_amount
>= MIN_DEPOSIT_AMOUNT_ALLOWED
.checked_mul(10u64.pow(stable.decimals as u32))
.ok_or(DpmError::Overflow)?,
DpmError::LessThanMinAmountAllowed
);
require!(
user_lince_stable_ata.amount >= deposit_amount,
DpmError::NotEnoughFundsForDeposit
);
// *****************************************************
// Get, deserialize and check req accounts from rem_accs
// *****************************************************
// We are expecting exactly an Asset, an Ata, and a PriceUpdateV2 account for each composition
let n = pool.compositions.len();
require!(
rem_accs.len() == n * 3,
DpmError::RemainingAccountsLengthMismatch
);
let snapshots =
get_pool_assets_snapshots(rem_accs, pool, config, stable.decimals, ctx.program_id)?;
require!(snapshots.len() == n, DpmError::SnapshotMissing);
let pool_value = get_pool_value_internal(pool_stable_ata.amount, &snapshots)?;
let lp_amount_to_mint = get_lp_amount_internal(deposit_amount, lp_mint.supply, pool_value)?;
// Mint LP Tokens
let pool_seeds = &[Pool::SEED.as_bytes(), &strategy.as_bytes(), &[pool.bump]];
let pool_signer_seeds = [&pool_seeds[..]];
let mint_to_ctx = MintTo {
mint: lp_mint.to_account_info(),
to: ctx.accounts.user_lince_lp_ata.to_account_info(),
authority: pool.to_account_info(),
};
mint_to(
CpiContext::new_with_signer(
token_program.to_account_info(),
mint_to_ctx,
&pool_signer_seeds,
),
lp_amount_to_mint,
)?;
// Transfer stable to pool
transfer_spl_tokens(
user_lince_stable_ata,
pool_stable_ata,
deposit_amount,
user_lince_pk,
token_program,
)?;
// Update user deposit
let new_deposit = Deposit {
amount: deposit_amount,
strategy,
};
user_pda.update_deposit(new_deposit)?;
emit!(DepositProcessed {
strategy,
user_lince_pk: user_lince_pk.key(),
assets_snapshots: snapshots,
pool_value,
deposit_amount,
lp_minted: lp_amount_to_mint,
});
Ok(())
}
programs/dpm/src/instructions/withdraw.rs
pub fn withdraw<'info>(
ctx: Context<'_, '_, '_, 'info, Withdraw<'info>>,
strategy: Strategy,
amount_to_withdraw: u64,
) -> Result<()> {
let user_lince_pk = &ctx.accounts.user_lince_pk;
let pool = &ctx.accounts.pool;
let pool_stable_ata = &ctx.accounts.pool_stable_ata;
let user_pda = &mut ctx.accounts.user_pda;
let user_lince_lp_ata = &ctx.accounts.user_lince_lp_ata;
let user_lince_stable_ata = &ctx.accounts.user_lince_stable_ata;
let management_stable_ata = &ctx.accounts.management_stable_ata;
let success_stable_ata = &ctx.accounts.success_stable_ata;
let reserve_stable_ata = &mut ctx.accounts.reserve_stable_ata;
let lp_mint = &mut ctx.accounts.lp_mint;
let config = &ctx.accounts.config;
let rem_accs = ctx.remaining_accounts;
let stable = &ctx.accounts.stablecoin;
let token_program = &ctx.accounts.token_program;
require!(
amount_to_withdraw
>= MIN_WITHDRAW_AMOUNT_ALLOWED
.checked_mul(10u64.pow(stable.decimals as u32))
.ok_or(DpmError::Overflow)?,
DpmError::LessThanMinAmountAllowed
);
// *****************************************************
// Get, deserialize and check req accounts from rem_accs
// *****************************************************
// We are expecting exactly an Asset, a pool Ata, a PriceUpdateV2 and a reserve Ata account for each composition
let n = pool.compositions.len();
require!(
rem_accs.len() == n * 4,
DpmError::RemainingAccountsLengthMismatch
);
// For snapshots we only need Asset, pool Ata, PriceUpdate accs
let rem_accs_for_snapshot = &rem_accs[..n * 3];
let snapshots = get_pool_assets_snapshots(
rem_accs_for_snapshot,
pool,
config,
stable.decimals,
ctx.program_id,
)?;
require!(snapshots.len() == n, DpmError::SnapshotMissing);
// ************
// Prepare vars
// ************
let deposit = user_pda.get_deposit(strategy)?;
let user_lp_balance = user_lince_lp_ata.amount;
let lp_supply_before = lp_mint.supply;
let user_lince_pubkey = user_lince_pk.key();
let principal = deposit.amount;
// ***************************
// Get User Lp Tokens Value
// ***************************
let pool_value = get_pool_value_internal(pool_stable_ata.amount, &snapshots)?;
let user_lp_tokens_value =
get_lp_value_internal(user_lp_balance, pool_value, lp_supply_before)?;
require!(
amount_to_withdraw <= user_lp_tokens_value,
DpmError::NotEnoughForReqWithdrawal
);
// TODO - Improve this logic
// - Define the max amount allowed to withdraw, is 50% ok?
// - Emit an event if the stable reserve amount is below a % of the TVL
// - Thus, we need a way of knowing the TVL
// - Last 2 things can be done off-chain
// - Mabe we can put something simple on-chain, like emit a warning if is below a hardcoded amount, and let the backend
// find if it's needed to deposit stable on the reserve.
require!(
reserve_stable_ata.amount / 2 >= amount_to_withdraw,
DpmError::NotEnoughFundsOnReserve
);
// **************************
// Get user lp amount to burn
// **************************
let lp_price = get_lp_price_internal(pool_value, lp_supply_before, lp_mint.decimals)?;
let lp_amount_to_burn = get_lp_amount_to_burn(lp_price, amount_to_withdraw, lp_mint.decimals);
// ************************
// Get pool share withdrawn
// ************************
let pool_share_withdrawn = get_share(lp_amount_to_burn, lp_supply_before);
// ***************************************************
// Calculate each asset amount and transfer to reserve
// ***************************************************
let mut assets_transferred_to_reserve: HashMap<Pubkey, u64> = HashMap::new();
let pool_seeds = &[Pool::SEED.as_bytes(), &strategy.as_bytes(), &[pool.bump]];
let pool_signer_seeds = [&pool_seeds[..]];
// Send stable first
let stable_amount = get_share_amount(pool_stable_ata.amount, pool_share_withdrawn);
if stable_amount > 0 {
// CHECK: deprecated state acknowledged.
// Using it to avoid having to pass the mint account with transfer_checked
transfer(
CpiContext::new_with_signer(
token_program.to_account_info(),
Transfer {
from: pool_stable_ata.to_account_info(),
to: reserve_stable_ata.to_account_info(),
authority: pool.to_account_info(),
},
&pool_signer_seeds,
),
stable_amount,
)?;
}
// Iterate through each asset
let pool_ata_acc_infos = &rem_accs[n..n * 2];
let reserve_ata_acc_infos = &rem_accs[n * 3..n * 4];
for i in 0..n {
let pool_ata_acc_info = &pool_ata_acc_infos[i];
let reserve_ata_acc_info = &reserve_ata_acc_infos[i];
let pool_asset_ata =
ITokenAccount::try_deserialize(&mut &**pool_ata_acc_info.data.borrow())
.map_err(|_| DpmError::InvalidTokenAccount)?;
let asset_amount = get_share_amount(pool_asset_ata.amount, pool_share_withdrawn);
let asset_token_program: &AccountInfo<'info> = if pool_ata_acc_info.owner == &Token::id() {
&ctx.accounts.token_program
} else {
&ctx.accounts.token_2022
};
if asset_amount > 0 {
// CHECK: deprecated state acknowledged.
// Using it to avoid having to pass the mint account with transfer_checked
transfer(
CpiContext::new_with_signer(
asset_token_program.to_account_info(),
Transfer {
from: pool_ata_acc_info.to_account_info(),
to: reserve_ata_acc_info.to_account_info(),
authority: pool.to_account_info(),
},
&pool_signer_seeds,
),
asset_amount,
)?;
}
// Store to emit later
assets_transferred_to_reserve.insert(pool_asset_ata.mint, asset_amount);
}
// **********************************
// Get user principal share withdrawn
// **********************************
let principal_share_withdrawn = get_share(lp_amount_to_burn, user_lp_balance);
let principal_withdrawn_amount = get_share_amount(principal, principal_share_withdrawn);
// ********************************
// Get fees and profit and transfer
// ********************************
let management_fee = get_amount_from_bps(amount_to_withdraw, config.management_fee);
let loss = management_fee
.checked_add(principal_withdrawn_amount)
.ok_or(DpmError::Overflow)?;
let user_return: i64 = if amount_to_withdraw >= loss {
(amount_to_withdraw - loss) as i64
} else {
-((loss - amount_to_withdraw) as i64)
};
let success_fee = get_success_fee_amount(user_return, config.success_fee);
let profit = amount_to_withdraw
.saturating_sub(management_fee)
.saturating_sub(success_fee);
let config_seeds = &[Config::SEED.as_bytes(), &[config.bump]];
if management_fee > 0 {
make_spl_transfer_with_signer(
reserve_stable_ata,
management_stable_ata,
config.to_account_info(),
management_fee,
config_seeds,
token_program,
)?;
}
if success_fee > 0 {
make_spl_transfer_with_signer(
reserve_stable_ata,
success_stable_ata,
config.to_account_info(),
success_fee,
config_seeds,
token_program,
)?;
}
if profit > 0 {
make_spl_transfer_with_signer(
reserve_stable_ata,
user_lince_stable_ata,
config.to_account_info(),
profit,
config_seeds,
token_program,
)?;
}
// ***********************
// 5. Reset deposit amount
// ***********************
deposit.amount -= principal_withdrawn_amount;
// **********************
// 6. Burn user Lp Tokens
// **********************
let burn_ctx = Burn {
mint: lp_mint.to_account_info(),
from: user_lince_lp_ata.to_account_info(),
authority: user_lince_pk.to_account_info(),
};
burn(
CpiContext::new(token_program.to_account_info(), burn_ctx),
lp_amount_to_burn,
)?;
// *****************
// 7. Reload to emit
// *****************
reserve_stable_ata.reload()?;
lp_mint.reload()?;
// ****** END ******
emit!(WithdrawEvent {
strategy,
user_lince_pk: user_lince_pubkey,
amount_withdrawn: amount_to_withdraw,
principal,
principal_left: principal.saturating_sub(principal_withdrawn_amount),
assets_snapshots: snapshots,
pool_value,
lp_price,
lp_supply_before,
lp_supply_after: lp_mint.supply,
lp_burned: lp_amount_to_burn,
profit,
management_fee,
success_fee,
assets_transferred_to_reserve: assets_transferred_to_reserve.into_iter().collect(),
reserve_stable_balance_left: reserve_stable_ata.amount
});
Ok(())
}
programs/dpm/src/utils/helpers.rs
#[inline(always)]
pub fn get_pool_assets_snapshots(
rem_accs_for_snapshot: &[AccountInfo<'_>],
pool: &Account<Pool>,
config: &Account<Config>,
stable_decimals: u8,
prog_id: &Pubkey,
) -> Result<Vec<AssetSnapshot>> {
let n = pool.compositions.len();
let mut snapshots = Vec::with_capacity(n);
require!(
rem_accs_for_snapshot.len() == n * 3,
DpmError::RemainingAccountsLengthMismatch
);
// Grab them as expected
let asset_acc_infos = &rem_accs_for_snapshot[..n];
let ata_acc_infos = &rem_accs_for_snapshot[n..2 * n];
let price_update_acc_infos = &rem_accs_for_snapshot[2 * n..];
let assets = get_deserialized_assets_from_rem_accs(asset_acc_infos, prog_id)?;
require!(assets.len() == n, DpmError::AssetAccsMissing);
let atas = get_deserialized_atas_from_rem_accs(ata_acc_infos, &assets, &pool.key())?;
require!(atas.len() == n, DpmError::MissingAtas);
let price_updates =
get_deserialized_price_update_accs_from_rem_accs(price_update_acc_infos, &assets)?;
require!(price_updates.len() == n, DpmError::MissingPriceUpdates);
for (idx, asset) in assets.iter().enumerate() {
snapshots.push(get_asset_snapshot(
asset,
atas[idx].amount,
&price_updates[idx],
stable_decimals,
config.max_price_age_secs,
config.max_slot_delta,
config.max_confidence_ratio,
)?)
}
Ok(snapshots)
}
This allows the user to systematically get more funds than intended by,
By choosing a combination of registered assets PDA which yields the lowest pool value during deposit
to receive more LP tokens.
Then, by choosing a combination of registered assets PDA which yields the highest pool value during withdrawal
to redeem more funds for fewer LP tokens blocking honest users withdrawal due to deficit.
Initial Setup and Exploit Path
Allowed assets and prices:
A=$2, B=$3, C=$5, D=$10, E=$20 (per token)
Actual pool holdings (owned by the pool PDA):
A: 100 → $200
B: 100 → $300
C: 100 → $500
D: 3 → $30
E: 2 → $40
Stable in pool_stable_ata
: $50
True TVL (sum of all balances actually held) = 200 + 300 + 500 + 30 + 40 + 50 = $1,120
Assume current LP supply = 1,120 (so ≈ $1/LP).
Composition is 3 slots intended for {A,B,C}, but the code prices with whatever 3 assets are passed in remaining_accounts
.
Honest deposit (uses {A,B,C})
Reported assets = 200 + 300 + 500 = $1,000; + stable $50 → $1,050 TVL seen.
LP minted for a $100 deposit: lp_minted = 100 × 1,120 / 1,050 ≈ 106.7 LP
Attacker deposit (picks the 3 lowest-value assets {D,E,A})
Reported assets = 30 + 40 + 200 = $270; + stable $50 → $320 TVL seen.
LP minted for the same $100 deposit: lp_minted = 100 × 1,120 / 320 = 350 LP
They mint ~3.28× more LP than the honest user for the same cash
It is recommended to enforce strict validation that the assets passed in during deposit
and withdrawal
match the original pool composition by verifying asset_type
against the stored composition.
SOLVED: The Lince team resolved the finding by checking if the received assets are from strategy.
//
The withdraw
instruction allows a user to redeem their LP tokens in exchange for their share of stables and underlying assets from the pool. It calculates the user’s proportional share based on LP tokens burned, transfers the corresponding amounts of stablecoins and other assets into reserve accounts for future swaps, and deducts management and success fees. The net stable profit is then sent back to the user. However, the instruction relies on reserve_ata_acc_infos
being passed via remaining_accounts
without verifying that it is owned by the expected config
PDA. This opens up few potential issues:
A user could pass the pool's own reserve ATA twice, causing assets to remain in the pool account rather than being reserved for future swaps leading to inaccurate tracking of available liquidity.
A user could pass a reserve ATA they control as the destination. This would cause intended reserve assets to be transferred directly to their wallet instead of the protocol reserves. As a result, no assets would be left in the reserve for swap logic, leaving the protocol under-collateralized.
programs/dpm/src/instructions/withdraw.rs
pub fn withdraw<'info>(
ctx: Context<'_, '_, '_, 'info, Withdraw<'info>>,
strategy: Strategy,
amount_to_withdraw: u64,
) -> Result<()> {
let user_lince_pk = &ctx.accounts.user_lince_pk;
let pool = &ctx.accounts.pool;
let pool_stable_ata = &ctx.accounts.pool_stable_ata;
let user_pda = &mut ctx.accounts.user_pda;
let user_lince_lp_ata = &ctx.accounts.user_lince_lp_ata;
let user_lince_stable_ata = &ctx.accounts.user_lince_stable_ata;
let management_stable_ata = &ctx.accounts.management_stable_ata;
let success_stable_ata = &ctx.accounts.success_stable_ata;
let reserve_stable_ata = &mut ctx.accounts.reserve_stable_ata;
let lp_mint = &mut ctx.accounts.lp_mint;
let config = &ctx.accounts.config;
let rem_accs = ctx.remaining_accounts;
let stable = &ctx.accounts.stablecoin;
let token_program = &ctx.accounts.token_program;
require!(
amount_to_withdraw
>= MIN_WITHDRAW_AMOUNT_ALLOWED
.checked_mul(10u64.pow(stable.decimals as u32))
.ok_or(DpmError::Overflow)?,
DpmError::LessThanMinAmountAllowed
);
// *****************************************************
// Get, deserialize and check req accounts from rem_accs
// *****************************************************
// We are expecting exactly an Asset, a pool Ata, a PriceUpdateV2 and a reserve Ata account for each composition
let n = pool.compositions.len();
require!(
rem_accs.len() == n * 4,
DpmError::RemainingAccountsLengthMismatch
);
// For snapshots we only need Asset, pool Ata, PriceUpdate accs
let rem_accs_for_snapshot = &rem_accs[..n * 3];
let snapshots = get_pool_assets_snapshots(
rem_accs_for_snapshot,
pool,
config,
stable.decimals,
ctx.program_id,
)?;
require!(snapshots.len() == n, DpmError::SnapshotMissing);
// ************
// Prepare vars
// ************
let deposit = user_pda.get_deposit(strategy)?;
let user_lp_balance = user_lince_lp_ata.amount;
let lp_supply_before = lp_mint.supply;
let user_lince_pubkey = user_lince_pk.key();
let principal = deposit.amount;
// ***************************
// Get User Lp Tokens Value
// ***************************
let pool_value = get_pool_value_internal(pool_stable_ata.amount, &snapshots)?;
let user_lp_tokens_value =
get_lp_value_internal(user_lp_balance, pool_value, lp_supply_before)?;
require!(
amount_to_withdraw <= user_lp_tokens_value,
DpmError::NotEnoughForReqWithdrawal
);
// TODO - Improve this logic
// - Define the max amount allowed to withdraw, is 50% ok?
// - Emit an event if the stable reserve amount is below a % of the TVL
// - Thus, we need a way of knowing the TVL
// - Last 2 things can be done off-chain
// - Mabe we can put something simple on-chain, like emit a warning if is below a hardcoded amount, and let the backend
// find if it's needed to deposit stable on the reserve.
require!(
reserve_stable_ata.amount / 2 >= amount_to_withdraw,
DpmError::NotEnoughFundsOnReserve
);
// **************************
// Get user lp amount to burn
// **************************
let lp_price = get_lp_price_internal(pool_value, lp_supply_before, lp_mint.decimals)?;
let lp_amount_to_burn = get_lp_amount_to_burn(lp_price, amount_to_withdraw, lp_mint.decimals);
// ************************
// Get pool share withdrawn
// ************************
let pool_share_withdrawn = get_share(lp_amount_to_burn, lp_supply_before);
// ***************************************************
// Calculate each asset amount and transfer to reserve
// ***************************************************
let mut assets_transferred_to_reserve: HashMap<Pubkey, u64> = HashMap::new();
let pool_seeds = &[Pool::SEED.as_bytes(), &strategy.as_bytes(), &[pool.bump]];
let pool_signer_seeds = [&pool_seeds[..]];
// Send stable first
let stable_amount = get_share_amount(pool_stable_ata.amount, pool_share_withdrawn);
if stable_amount > 0 {
// CHECK: deprecated state acknowledged.
// Using it to avoid having to pass the mint account with transfer_checked
transfer(
CpiContext::new_with_signer(
token_program.to_account_info(),
Transfer {
from: pool_stable_ata.to_account_info(),
to: reserve_stable_ata.to_account_info(),
authority: pool.to_account_info(),
},
&pool_signer_seeds,
),
stable_amount,
)?;
}
// Iterate through each asset
let pool_ata_acc_infos = &rem_accs[n..n * 2];
let reserve_ata_acc_infos = &rem_accs[n * 3..n * 4];
for i in 0..n {
let pool_ata_acc_info = &pool_ata_acc_infos[i];
let reserve_ata_acc_info = &reserve_ata_acc_infos[i];
let pool_asset_ata =
ITokenAccount::try_deserialize(&mut &**pool_ata_acc_info.data.borrow())
.map_err(|_| DpmError::InvalidTokenAccount)?;
let asset_amount = get_share_amount(pool_asset_ata.amount, pool_share_withdrawn);
let asset_token_program: &AccountInfo<'info> = if pool_ata_acc_info.owner == &Token::id() {
&ctx.accounts.token_program
} else {
&ctx.accounts.token_2022
};
if asset_amount > 0 {
// CHECK: deprecated state acknowledged.
// Using it to avoid having to pass the mint account with transfer_checked
transfer(
CpiContext::new_with_signer(
asset_token_program.to_account_info(),
Transfer {
from: pool_ata_acc_info.to_account_info(),
to: reserve_ata_acc_info.to_account_info(),
authority: pool.to_account_info(),
},
&pool_signer_seeds,
),
asset_amount,
)?;
}
// Store to emit later
assets_transferred_to_reserve.insert(pool_asset_ata.mint, asset_amount);
}
// Additional code skipped
}
It is recommended to validate that the provided reserve_ata_acc_infos
via remaining_accounts
are owned by the config PDA.
SOLVED: The Lince team resolved the finding by validating that reserve_ata_acc_infos
passed in via remaining_accounts
is owned by the config PDA account.
//
The admin_transfer
, pool_swap_jupiter
(during Liquidate), and reserve_swap_jupiter
instructions rely on default SPL Token program assumptions when deriving associated token accounts (ATAs). However, for Token-2022 mints, ATA derivation must use the Token-2022 program instead. Since the code does not account for this, any instruction involving Token-2022 assets will fail at runtime due to invalid or missing ATAs blocking transfers and causing operations like liquidation or reserve swaps to break.
programs/dpm/src/instructions/admin_transfer.rs
#[derive(Accounts)]
#[instruction(strategy: Strategy)]
pub struct AdminTransfer<'info> {
#[account(mut)]
pub admin: Signer<'info>,
#[account(
has_one = admin,
seeds = [Config::SEED.as_bytes()],
bump = config.bump,
)]
pub config: Account<'info, Config>,
#[account(mut,
seeds = [Pool::SEED.as_bytes(), &strategy.as_bytes()],
bump = pool.bump
)]
pub pool: Account<'info, Pool>,
pub mint: InterfaceAccount<'info, Mint>,
#[account(
mut,
associated_token::mint = mint,
associated_token::authority = pool,
)]
pub from: InterfaceAccount<'info, TokenAccount>,
#[account(mut)]
pub to: InterfaceAccount<'info, TokenAccount>,
pub token_program: Interface<'info, TokenInterface>,
}
programs/dpm/src/instructions/reserve_swap_jupiter.rs
#[derive(Accounts)]
pub struct ReserveSwapJupiter<'info> {
#[account(mut)]
pub lince_manager: Signer<'info>,
#[account(
has_one = lince_manager,
constraint = !config.is_paused @ DpmError::Paused,
seeds = [Config::SEED.as_bytes()],
bump = config.bump,
)]
pub config: Box<Account<'info, Config>>,
/// CHECK: Mint out
#[account(
address = config.stablecoin
)]
pub stablecoin: Box<Account<'info, Mint>>,
/// CHECK: From - Enough checking auth.
#[account(
mut,
token::authority = config,
)]
pub reserve_mint_in_ata: Box<InterfaceAccount<'info, ITokenAccount>>,
/// CHECK: To
#[account(
mut,
associated_token::mint = stablecoin,
associated_token::authority = config,
)]
pub reserve_mint_out_ata: Box<Account<'info, TokenAccount>>,
#[account(
address = JUPITER_V6_PROGRAM_ID
)]
pub jupiter_program: Program<'info, Jupiter>,
}
programs/dpm/src/instructions/pool_swap_jupiter.rs
#[derive(Accounts)]
#[instruction(strategy: Strategy, mint_in_asset_type: AssetType, mint_out_asset_type: AssetType)]
pub struct PoolSwapJupiter<'info> {
#[account(mut)]
pub lince_manager: Signer<'info>,
#[account(
has_one = lince_manager,
constraint = !config.is_paused @ DpmError::Paused,
seeds = [Config::SEED.as_bytes()],
bump = config.bump,
)]
pub config: Box<Account<'info, Config>>,
#[account(
mut,
seeds = [Pool::SEED.as_bytes(), &strategy.as_bytes()],
bump = pool.bump
)]
pub pool: Box<Account<'info, Pool>>,
pub mint_in: Box<InterfaceAccount<'info, Mint>>,
/// CHECK: If exists, asset is registered (ok if not active at this point)
#[account(
seeds = [Asset::SEED.as_bytes(), mint_in_asset_type.as_seed()],
bump = mint_in_asset.bump,
constraint = mint_in_asset.mint == mint_in.key() @ DpmError::InvalidAssetAccount
)]
pub mint_in_asset: Box<Account<'info, Asset>>,
pub mint_out: Box<InterfaceAccount<'info, Mint>>,
/// CHECK: If exists, asset is registered (ok if not active at this point)
#[account(
seeds = [Asset::SEED.as_bytes(), mint_out_asset_type.as_seed()],
bump = mint_out_asset.bump,
constraint = mint_out_asset.mint == mint_out.key() @ DpmError::InvalidAssetAccount
)]
pub mint_out_asset: Box<Account<'info, Asset>>,
#[account(
mut,
associated_token::mint = mint_in,
associated_token::authority = pool,
)]
pub pool_mint_in_ata: Box<InterfaceAccount<'info, TokenAccount>>,
#[account(
init_if_needed,
payer = lince_manager,
associated_token::mint = mint_out,
associated_token::authority = pool,
associated_token::token_program = mint_out_program,
)]
pub pool_mint_out_ata: Box<InterfaceAccount<'info, TokenAccount>>,
pub mint_out_program: Interface<'info, TokenInterface>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub system_program: Program<'info, System>,
#[account(
address = JUPITER_V6_PROGRAM_ID
)]
pub jupiter_program: Program<'info, Jupiter>,
}
It is recommended to include the constraint associated_token::token_program = token_program
for transfer_admin
instruction when deriving ATAs for known mints. For reserve_swap_jupiter
and pool_swap_jupiter
, accept two token_program
accounts and derive the ATAs using the appropriate program for each mint.
SOLVED: The Lince team resolved the finding by adding associated_token::token_program = token_program
constraint in reserve_swap_jupiter
, pool_swap_jupiter
and transfer_admin
instructions.
//
The initialize_pool
instruction sets up a new pool by recording its strategy and asset composition, transferring an initial stablecoin deposit from the pool manager, minting an equal amount of LP tokens. It initializes the pool’s configuration and prepares it for future deposits, swaps, and withdrawals.
The instruction assumes that both the stablecoin
and LP mint
have 6 decimal places, which is implicitly relied upon during share and LP token calculations. However, the program does not validate this assumption at runtime. If a stablecoin or LP mint with a different decimal configuration is used, it could lead to incorrect share calculations, pool imbalance, or unexpected behavior during deposits and withdrawals.
programs/dpm/src/instructions/initialize_pool.rs
pub fn initialize_pool(
ctx: Context<InitializePool>,
strategy: Strategy,
compositions: Vec<Composition>,
first_deposit_amount: u64,
args: PoolMetadataArgs,
) -> Result<()> {
let pools_manager = &ctx.accounts.pools_manager;
let pools_manager_stable_ata = &ctx.accounts.pools_manager_stable_ata;
let pools_manager_user_pda = &mut ctx.accounts.pools_manager_user_pda;
let pools_manager_lp_ata = &ctx.accounts.pools_manager_lp_ata;
let pool_stable_ata = &ctx.accounts.pool_stable_ata;
let token_program = &ctx.accounts.token_program;
let lp_mint = &ctx.accounts.lp_mint;
let pool = &mut ctx.accounts.pool;
let admin = &ctx.accounts.admin;
let rem_accs = ctx.remaining_accounts;
let stable = &ctx.accounts.stablecoin;
let bump = ctx.bumps.pool;
let prog_id = ctx.program_id;
let token_metadata_program = &ctx.accounts.token_metadata_program;
// **********
// Pre-checks
// **********
require!(
first_deposit_amount
>= MIN_INIT_DEPOSIT_AMOUNT_ALLOWED
.checked_mul(10u64.pow(stable.decimals as u32))
.ok_or(DpmError::Overflow)?,
DpmError::LessThanMinAmountAllowed
);
require!(
pools_manager_stable_ata.amount >= first_deposit_amount,
DpmError::NotEnoughFundsForDeposit
);
require!(args.name.len() <= 32, DpmError::NameTooLong);
require!(args.symbol.len() <= 10, DpmError::SymbolTooLong);
require!(args.uri.len() <= 200, DpmError::UriTooLong);
// *****************************************************
// Get, deserialize and check req accounts from rem_accs
// *****************************************************
let assets = get_deserialized_assets_from_rem_accs(rem_accs, prog_id)?;
require!(
assets.len() == compositions.len(),
DpmError::AssetAccsMissing
);
// ************************************************
// Transfer first deposit amount to pool_stable_ata
// ************************************************
transfer_spl_tokens(
pools_manager_stable_ata,
pool_stable_ata,
first_deposit_amount,
pools_manager,
token_program,
)?;
// ****************************
// Update pools_manager deposit
// ****************************
let new_deposit = Deposit {
amount: first_deposit_amount,
strategy: strategy,
};
pools_manager_user_pda.update_deposit(new_deposit)?;
// *******************************************************************
// Create metadata for LP mint
// *******************************************************************
let pool_seeds = &[Pool::SEED.as_bytes(), &strategy.as_bytes(), &[bump]];
let pool_signer_seeds = [&pool_seeds[..]];
let mut builder = mpl_token_metadata::instructions::CreateMetadataAccountV3CpiBuilder::new(
&token_metadata_program,
);
let lp_mint_info = lp_mint.to_account_info();
let pool_info = pool.to_account_info();
builder.mint(&lp_mint_info);
builder.mint_authority(&pool_info);
builder.metadata(&ctx.accounts.metadata);
builder.is_mutable(true);
builder.update_authority(&admin, false);
builder.payer(&pools_manager);
builder.system_program(&ctx.accounts.system_program);
let data = DataV2 {
collection: None,
creators: None,
name: args.name.to_string(),
symbol: args.symbol.to_string(),
seller_fee_basis_points: 0,
uses: None,
uri: args.uri.to_string(),
};
builder.data(data);
builder.invoke_signed(&pool_signer_seeds)?;
// *******************************************************************
// Mint Lp Tokens to pools_manager in the same amount as first deposit
// *******************************************************************
let mint_to_ctx = MintTo {
mint: lp_mint.to_account_info(),
to: pools_manager_lp_ata.to_account_info(),
authority: pool.to_account_info(),
};
mint_to(
CpiContext::new_with_signer(
token_program.to_account_info(),
mint_to_ctx,
&pool_signer_seeds,
),
first_deposit_amount,
)?;
// ***************
// Initiliaze pool
// ***************
pool.init(lp_mint.key(), strategy, &compositions, &assets, bump)?;
emit!(PoolInitialized {
pool_address: pool.key(),
pools_manager: pools_manager.key(),
strategy: pool.strategy,
compositions: pool.compositions.clone(),
lp_mint: pool.lp_mint,
first_deposit_amount,
lp_tokens_minted: first_deposit_amount,
});
Ok(())
}
It is recommended to enforce a runtime check to ensure both the stablecoin
and LP mint
use 6 decimals.
SOLVED: The Lince team resolved the finding by ensuring that stablecoin
and LP mint
use the same decimals.
//
The pool_swap_jupiter
instruction lets the lince_manager
swap tokens using Jupiter v6 as part of a pool rebalance. It ensures swaps follow the expected phase (Invest or Liquidate), checks token amounts and order, and tracks progress using a bitmask. Once all strategy assets are swapped, the rebalance cycle ends. The instruction uses check_is_swap_ix
to verify that the provided swap_data
matches one of three supported Jupiter swap types. It then extracts in_amount
and quoted_out_amount
using the get_swap_data_amounts()
helper.
However get_swap_data_amounts
assumes a fixed layout for these values in the serialized instruction data. While this layout works for route
and shared_accounts_route
types, it does not match shared_accounts_exact_out_route
(used for exact-out swaps). As a result, the program mistakenly interprets in_amount
as quoted_out_amount
and later checks if require!(amounts.quoted_out_amount > 0, DpmError::SwapAmountError);
. This issue is also present in reserve_swap_jupiter
instruction.
The impact is limited to cases where the input amount is set to zero, which causes the transaction to fail due to an incorrect quoted_out_amount
check.
programs/dpm/src/utils/helpers.rs
#[inline(always)]
pub fn check_is_swap_ix(discriminator: &[u8]) -> Result<()> {
match discriminator
.try_into()
.map_err(|_| DpmError::MalformedSwapInstructionData)
.unwrap()
{
SHARED_ACCOUNTS_ROUTE_DISCRIMINATOR => (),
ROUTE_DISCRIMINATOR => (),
SHARED_ACCOUNTS_EXACT_OUT_DISCRIMINATOR => (),
_ => err!(DpmError::InvalidSwapInstructionDiscriminator)?,
}
Ok(())
}
programs/dpm/src/utils/helpers.rs
#[inline(always)]
pub fn get_swap_data_amounts(data: &[u8]) -> RouteAmounts {
let len = data.len();
let quoted_out_amount_bytes: [u8; 8] = data[len - 11..len - 3].try_into().unwrap();
let in_amount_bytes: [u8; 8] = data[len - 19..len - 11].try_into().unwrap();
RouteAmounts {
in_amount: u64::from_le_bytes(in_amount_bytes),
quoted_out_amount: u64::from_le_bytes(quoted_out_amount_bytes),
}
}
It is recommended to update get_swap_data_amounts()
to correctly parse quoted_in_amount
and out_amount
for shared_accounts_exact_out_route
;.if this swap type is not used, consider removing it entirely for clarity and safety.
SOLVED: The Lince team resolved the issue by removing SHARED_ACCOUNTS_EXACT_OUT_DISCRIMINATOR
route in check_is_swap_ix
function.
Halborn used automated security scanners to assist with the detection of well-known security issues and vulnerabilities. Among the tools used was cargo-audit
, a security scanner for vulnerabilities reported to the RustSec Advisory Database. All vulnerabilities published in https://crates.io are stored in a repository named The RustSec Advisory Database. cargo audit
is a human-readable version of the advisory database which performs a scanning on Cargo.lock. Security Detections are only in scope. All vulnerabilities shown here were already disclosed in the above report. However, to better assist the developers maintaining this code, the reviewers are including the output with the dependencies tree, and this is included in the cargo audit
output to better know the dependencies affected by unmaintained and vulnerable crates.
ID | package | Short Description |
---|---|---|
RUSTSEC-2025-0009 | ring | Some |
RUSTSEC-2022-0093 | ed25519-dalek | Double Public Key Signing Function Oracle Attack on |
Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.
// Download the full report
Lince DPM
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed