Prepared by:
HALBORN
Last Updated 08/27/2024
Date of Engagement by: July 22nd, 2024 - August 19th, 2024
100% of all REPORTED Findings have been addressed
All findings
9
Critical
1
High
1
Medium
2
Low
3
Informational
2
Vaultka
engaged Halborn
to conduct a security assessment on their Waterusdc and Vaultka Solana programs beginning on July 22, 2024, and ending on August 19, 2024. The security assessment was scoped to the Solana Programs provided in vaultkarust GitHub repository. Commit hashes and further details can be found in the Scope section of this report.
The Waterusdc
program is a single side lending pool program that allows whitelisted users or other programs to borrow USDC tokens from the lending pool. The Vaultkausdc
program is a strategy program allowing users to borrow USDC tokens via the Waterusdc
program and gain exposure to the Jupiter's JLP
token. The program uses the Pyth oracle in order to fetch current JLP and USDC prices, and also allows users to use leverage up to certain limits to increase market exposure.
Halborn
was provided 4 weeks 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 security concerns that were addressed by Vaultka team
. The main ones were the following:
Withdraw fee is transferred to the user instead of the fee vault
Inefficient slippage control
Incorrect token price conversion prevent withdrawal
Incorrect accounts mutability
Only informational issues were currently only acknowledged and not addressed.
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 anchor testing (`anchor test`)
EXPLOITABILIY 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
1
High
1
Medium
2
Low
3
Informational
2
Security analysis | Risk level | Remediation Date |
---|---|---|
WITHDRAW FEE IS TRANSFERRED TO THE USER INSTEAD OF THE FEE VAULT | Critical | Solved - 08/21/2024 |
INEFFICIENT SLIPPAGE CONTROL | High | Solved - 08/26/2024 |
INCORRECT ACCOUNTS MUTABILITY | Medium | Solved - 08/23/2024 |
INCORRECT TOKEN PRICE CONVERSION PREVENTS WITHDRAWAL | Medium | Solved - 08/21/2024 |
RISK OF OUTDATED PRICE FEED | Low | Solved - 08/21/2024 |
SET_JLP_PRICE INSTRUCTION WILL ALWAYS FAIL | Low | Solved - 08/21/2024 |
UNBOUNDED FEES CALCULATION | Low | Solved - 08/21/2024 |
INABILITY TO CLOSE UNNEEDED ACCOUNTS | Informational | Acknowledged |
FORMAL ISSUES AND RECOMMENDATIONS | Informational | Acknowledged |
// Critical
The Withdraw
instruction of the Waterusdc
program allows users to withdraw liquidity from the lending program for a predefined fee.
However, instead of transferring the fee to the fee vault as intended, the instruction mistakenly returns the fee to the user.
As a result, the protocol is unable to collect fees from users.
waterusdc/src/lib.rs
let fee_transfer_accounts = TransferChecked {
from: ctx.accounts.usdc_token_account.to_account_info(),
to: ctx.accounts.user_ata.to_account_info(),
authority: ctx.accounts.program_authority.to_account_info(),
mint: ctx.accounts.usdc_mint.to_account_info(),
};
let fee_amount_context = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
fee_transfer_accounts,
signer_seeds,
);
anchor_spl::token::transfer_checked(fee_amount_context, w_fee, 6)?;
Initialize waterusdc program
Deposit USDC tokens
Withdraw USDC tokens
To address this issue, it is recommended to correct the destination address so that the fee is sent to the fee vault account.
SOLVED: The Vaultka team solved the issue by modifying the destination address and setting it to the correct fee vault account address.
// High
Both the ExecuteDeposit
and ExecuteWithdraw
instructions ensure that the received amount falls within the slippage range defined by the expected amount and slippage percentage, verifying that there hasn't been a significant exchange rate change between the request and execution.
However, the RequestDeposit
and RequestWithdraw
instructions do not allow users to set slippage limits. In volatile markets, outdated Pyth oracle prices, or incorrect manual JLP price settings, users might request a deposit or withdrawal that results in an unexpected output amount.
Depending on the slippage control settings in the ExecuteDeposit
or ExecuteWithdraw
instructions, the execution might fail, causing funds to become stuck, or it might succeed, with the user receiving less than expected.
In addition to the issue above, the amount in the ExecuteDeposit
or ExecuteWithdraw
instructions is verified only using the slippage control. However, the slippage control parameter can be adjusted by the admin user without any bounds. Invoking the ExecuteDeposit
instruction with an incorrect amount and large slippage range can result in loses either for the user or the protocol.
Initialize the vaultkausdc and waterusdc programs
Set strategy parameters
Manually set the JLP token price to twice of the current price to simulate incorrect/outdated/volatile price
Request deposit
Set back the JLP token price to the current price
Execute deposit
Manually set the JLP token price to twice of the current price to simulate incorrect/outdated/volatile price
Set back the JLP token price to the current price
Execute deposit
Deposit request with incorrect price:
Execute deposit with correct price fails and with incorrect price completes successfully:
To address this issue, it is recommended to implement slippage control also for the RequestDeposit
and RequestWithdraw
instructions and limit the allowed range of slippage.
SOLVED: The Vaultka team solved the issue by adding a new input parameter to the RequestDeposit
, RequestIncreaseCollateral
and RequestWithdraw
instructions so that the user is able to set the minimal output amount that he or she can accept. If the calculated output amount is less than the expected minimum, the instruction fails and the user has to repeat the request.
// Medium
The vaultkausdc
program implements manually the AccountsMeta
structures, that are used during CPI (cross-program invocation) of Borrow
and Repay
instructions of the waterusdc
program.
However, these AccountsMeta
structs are implemented incorrectly and the token_program
and usdc_mint
accounts are marked as mutable instead of read-only.
This prevents the RequestDeposit
and the ExecuteWithdraw
instructions from being invoked due to cross-program invocation error.
vaultkausdc/src/lib.rs
impl<'info> ToAccountMetas for Borrow<'info> {
fn to_account_metas(&self, _is_signer: Option<bool>) -> Vec<AccountMeta> {
vec![
AccountMeta::new(*self.borrower.key, true),
AccountMeta::new(*self.vault.key, false),
AccountMeta::new(*self.whitelisted_borrower.key, false),
AccountMeta::new(*self.program_authority.key, false),
AccountMeta::new(*self.receiver_ata.key, false),
AccountMeta::new(*self.usdc_token_account.key, false),
AccountMeta::new(*self.token_program.key, false),
AccountMeta::new(*self.usdc_mint.key, false),
]
}
}
impl<'info> ToAccountMetas for Repay<'info> {
fn to_account_metas(&self, _is_signer: Option<bool>) -> Vec<AccountMeta> {
vec![
AccountMeta::new(*self.borrower.key, true),
AccountMeta::new(*self.vault.key, false),
AccountMeta::new(*self.whitelisted_borrower.key, false),
AccountMeta::new(*self.program_authority_lending.key, false),
AccountMeta::new(*self.usdc_token_account.key, false),
AccountMeta::new(*self.borrower_ata.key, false),
AccountMeta::new(*self.token_program.key, false),
AccountMeta::new(*self.usdc_mint.key, false)
]
}
}
In addition to this issue, the context structures Deposit
, ExecuteDeposit
, RequestIncreaseCollateral
, RequestWithdraw
and ExecuteWithdraw
of the vaultkausdc
program do not set correctly the mutability attribute of their accounts (some accounts are missing the mut
attribute and are considered as read-only instead of mutable). It does not create a vulnerability in the program, however it does not allow the Anchor Typescript client to correctly generate the AccountMetas
for the corresponding instruction. Therefore it is impossible to invoke these instructions using the generated Anchor Typescript client.
Initialize the vaultkausdc and waterusdc programs
Set strategy parameters
Request deposit
Execute deposit
Request increase collateral
Execute deposit
Request withdrawal
Execute withdrawal
To address this issue, it is recommended to set the accounts token_program
and usdc_mint
as read-only. Also review the mutability attribute for each account with the Deposit
, ExecuteDeposit
, RequestIncreaseCollateral
, RequestWithdraw
and ExecuteWithdraw
instruction context structs.
SOLVED: The Vaultka team solved the issue by correctly setting the token_program
and usdc_mint
accounts as read-only and by correctly setting the mutability attribute to the corresponding accounts.
// Medium
The RequestWithdraw
instruction allows users to initiate withdrawal and close their positions. The instruction calculates, among other calculations also the current price of JLP tokens in USDC tokens based on the price of JLP and USDC tokens in USD.
However, the price of JLP tokens is incorrectly scaled and is 10 times greater than it is supposed to be (it is multiplied by 10 twice instead of only once). This results in the JLP/USDC price being 10 times greater, and consequently also the resulting expected amount of USDC tokens is 10 times greater. This prevents the invocation of the ExecuteWithdraw
instruction, because the received amount of USDC tokens will not be within the accepted range of allowed slippage.
vaultkausdc/src/lib.rs
let usdc_price = get_pyth_price_helper(pyth_price_account_usdc, strategy.usdc_usd_feed)?;
let mut jlp_price = 0;
if price_control.is_manual_price_update{
jlp_price = price_control.jlp_price * 10; // here is the JLP price multiplied by 10
msg!("Manual JLP price: {}", jlp_price);
} else {
jlp_price = get_pyth_price_helper(pyth_price_account_jlp, strategy.jlp_sol_feed)? * 10; // here is the JLP price multiplied by 10
msg!("Pyth JLP price: {}", jlp_price);
}
let mut jlp_p: u128 = jlp_price as u128;
jlp_p = jlp_p * 10; // here is the JLP price multiplied by 10 for the second time
msg!("JLP price: {}", jlp_p);
let mut usdc_p: u128 = usdc_price as u128;
usdc_p = usdc_p * 10;
msg!("USDC price: {}", usdc_p);
let jlp_price_usdc: u128 = jlp_p.checked_mul(10u128.pow(6)).ok_or(ErrorCode::ReceivedAmountOperationError)?
.checked_div(usdc_p).ok_or(ErrorCode::ReceivedAmountOperationError)?;
msg!("JLP price in USDC: {}", jlp_price_usdc);
pos_amount = pos_amount.checked_mul(10u64.pow(3)).ok_or(ErrorCode::ReceivedAmountOperationError)?;
let pa_a = pos_amount as u128;
let expected_amount_out_usdc: u128 = pa_a.checked_mul(jlp_price_usdc).ok_or(ErrorCode::ReceivedAmountOperationError)?
.checked_div(10u128.pow(9)).ok_or(ErrorCode::ReceivedAmountOperationError)?;
Initialize the vaultkausdc and waterusdc programs
Set strategy parameters
Request deposit
Execute deposit
Request withdrawal
Manually execute withdrawal
To address this issue, it is recommended to correct the calculation of the JLP/USDC price pair.
SOLVED: The Vaultka team solved the issue by removing the superfluous multiplication of the the JLP token price and thus corrected the price conversion.
// Low
The program relies on the Pyth oracle to retrieve the current USD prices of JLP and USDC tokens. However, the maximum age for the price feed update is currently set to 120,000 seconds (approximately 33.3 hours). If prices become outdated, such as during a Pyth oracle outage, and market volatility is high, both the user and the protocol risk losses due to incorrect exchange rates.
waterusdc/src/lib.rs
pub fn get_pyth_price_helper<'info>(price_account: &Account<'info, PriceUpdateV2>,feed_id: [u8; 32]) -> Result<u64> {
let price_update = price_account;
// get_price_no_older_than will fail if the price update is more than 30 seconds old
let maximum_age: u64 = 120000;
// get_price_no_older_than will fail if the price update is for a different price feed.
// This string is the id of the WIF/USD feed (close to JLP price). See https://pyth.network/developers/price-feed-ids for all available IDs.
//let feed_id: [u8; 32] = get_feed_id_from_hex("0x4ca4beeca86f0d164160323817a4e42b10010a724c2217c6ee41b54cd4cc61fc")?;
let price = price_update.get_price_no_older_than(&Clock::get()?, maximum_age, &feed_id)?;
// Sample output:
// The price is (7160106530699 ± 5129162301) * 10^-8
msg!("The price is ({} ± {}) * 10^{}", price.price, price.conf, price.exponent);
let final_price = price.price as u64;
Ok(final_price)
}
To address this issue, it is recommended to reduce the maximum age parameter.
SOLVED: The Vaultka team solved the issue by decreasing the maximum age parameter to 120 seconds and thus reducing the risk of outdated price feed.
// Low
The SetJlpPrice
instruction is intended to manually set the USD price of the JLP token by the admin and supposedly also by the authorized keeper.
However, the access control condition is flawed, as it always evaluates to true, leading to an IncorrectOwner
error. Specifically, the code is checking whether the user.key
is not equal to both admin.admin
and strategy.keeper
at the same time. This creates a situation where the condition will always be true, even if the user.key
matches one of the two addresses, because a single user.key
cannot simultaneously match both admin.admin
and strategy.keeper
.
waterusdc/src/lib.rs
// Set the JLP Price manually
pub fn set_jlp_price(ctx: Context<SetJLPPrice>, jlp_price: u64) -> ProgramResult {
let strategy = &mut ctx.accounts.strategy;
let admin = &ctx.accounts.admin;
let price_control = &mut ctx.accounts.price_control;
if *ctx.accounts.user.key != admin.admin || *ctx.accounts.user.key != strategy.keeper{
return Err(ErrorCode::IncorrectOwner.into());
}
//MUST BE IN DECIMAL 8
//ensure the new JLP price is not 0
if jlp_price == 0 {
return Err(ErrorCode::InvalidSetterData.into());
}
let min_jlp_price = price_control.jlp_price - (price_control.jlp_price * price_control.jlp_price_control_slippage / 100);
let max_jlp_price = price_control.jlp_price + (price_control.jlp_price * price_control.jlp_price_control_slippage / 100);
if jlp_price < min_jlp_price || jlp_price > max_jlp_price {
return Err(ErrorCode::SlippageError.into());
}
price_control.jlp_price = jlp_price;
Ok(())
}
To address this issue, it is recommended to correct the access control condition. Either to authorize only one defined account (admin or keeper) or change the logical operator from OR
to AND
.
SOLVED: The Vaultka team solved the issue by modifying the logical operator from OR
to AND
and thus authorizing the admin or keeper accounts to invoke the setJlpPrice
instruction.
// Low
During the ExecuteWithdraw
instruction, the program deducts various fees from the user's profit during the withdrawal process. The amount of fees depends on the following parameters:
leverage
fixed_fee_split
mfee_percent
Although these parameters can be set within predefined limits, the resulting fees are not limited and certain parameter combinations may result in fees equal to or exceeding 100% of the profit. If the fees are less than or equal to 100% of the profit, the ExecuteWithdraw
instruction will succeed, but the user risks receiving very little or no profit at all. If the fees exceed 100% of the profit, the ExecuteWithdraw
instruction will fail due to overflow by subtraction, preventing the user from withdrawing their position.
waterusdc/src/lib.rs
fn get_profit_split(profit: u64, leverage: u64, fixed_fee_split: u64, mfee_percent: u64) -> Result<(u64,u64,u64)> {
msg!("fixed_fee_split: {}", fixed_fee_split);
msg!("leverage: {}", leverage);
let fixed_split_leverage = fixed_fee_split * leverage;
msg!("Fixed split leverage: {}", fixed_split_leverage);
let fixed_split_adjusted = fixed_fee_split * 10;
msg!("Fixed split adjusted: {}", fixed_split_adjusted);
let split = fixed_split_leverage.checked_add(fixed_split_adjusted).ok_or(ErrorCode::ReceivedAmountOperationError)?;
msg!("Split: {}", split);
let to_water = profit.checked_mul(split).ok_or(ErrorCode::ReceivedAmountOperationError)?
.checked_div(10000).ok_or(ErrorCode::ReceivedAmountOperationError)?;
msg!("To water: {}", to_water);
let m_fee = profit.checked_mul(mfee_percent).ok_or(ErrorCode::ReceivedAmountOperationError)?
.checked_div(10000).ok_or(ErrorCode::ReceivedAmountOperationError)?;
msg!("M fee: {}", m_fee);
let to_user = profit.checked_sub(to_water.checked_add(m_fee).ok_or(ErrorCode::ReceivedAmountOperationError)?).ok_or(ErrorCode::ReceivedAmountOperationError)?;
msg!("To user: {}", to_user);
Ok((to_water, m_fee, to_user))
}
To address this issue, it is recommended to cap the fees to ensure they do not exceed a maximum percentage of the profit.
SOLVED: The Vaultka team solved the issue by limiting the maximal percentage of total fees and thus ensuring that the fees will always be less than 100 percent of the profit. This guarantees that a user always receives part of the profit and that no arithmetic issues can occur.
// Informational
The program lacks functionality to close unnecessary PositionInfo
and UserInfo
accounts, resulting in rent lamports being permanently locked.
To address this issue, it is recommended to close the unneeded accounts and sent the rent back to the initializer.
ACKNOWLEDGED: The client acknowledged the finding.
// Informational
The project is written in Rust and utilizes the Anchor framework to improve code clarity and security. While the program generally makes effective use of Rust and Anchor, some areas could be enhanced to better align with common Solana development conventions and best software engineering practices. Additionally, numerous formal issues, such as incorrect naming, unused variables, and inaccurate comments, need to be addressed.
To address this issue, it is recommended to implement following actions:
Do not redefine accounts and instruction data structs (such as lending::cpi::accounts::Repay) for CPI calls.
Use automatically generated code from Anchor. Follow the corresponding Anchor CPI documentation.
Avoid recalculating PDAs and corresponding bumps when not necessary.
Use ctx.bumps.program_authority
instead of Pubkey::find_program_address(...)
.
To reload accounts after CPI completion, use the reload()
method.
For example: ctx.accounts.jlp_token_account.reload()?;
Avoid using msg!()
macro if not necessary.
Avoid truncating downcast.
// instead of truncating downcast
let variable_u64 = variable_u128 as u64;
// prefer using
let variable_u64 = u64::try_from(variable_u128).map_err(...)?;
Add programs as last accounts in the context struct.
Preferably perform the access control checks in the context struct to separate it from the business logic.
// For example in set_vault_keeper, instead of
if ctx.accounts.user.key() != vault_data.owner {}
// use rather the has_one attribute
#[account(mut, has_one = owner, seeds = [b"LENDING".as_ref()], bump)]
pub lending_account: Account<'info, Lending>,
#[account(mut)]
pub owner: Signer<'info>, // rename user to owner
// Instead of passing unchecked AccountInfo
pub lending_program: AccountInfo<'info>,
// prefer using dedicated Anchor types such as
pub lending_program: Program<‘info, LendingProgram>
Avoid separate variables declaration and initialization.
Define seeds as constants and reuse them across accounts.
Modularize the program and split it in multiple files.
Do not use magic numbers.
Avoid passing unnecessary (unused) accounts to instructions.
The System program in SetVaultKeeper
, SetFeeParams
and SetMaxUtilRate
instructions is not needed.
The strategy account in ManualPriceUpdate
, SetSlippageControl
, SetJLPPriceSlippage
instructions is not needed.
Remove the invalid #[instruction(to_whitelist: Pubkey)]
macro annotation from SetVaultKeeper
struct.
Use more descriptive names for accounts.
For example the account user
in SetFeeParams, SetWhitelisted, SetMaxUtilRate, SetVaultKeeper or DisableWhitelisted instruction should rather be owner
.
Return correct errors:
After checked_div
operations, only division by zero error can happen and not overflow or underflow.
Review all errors returned.
waterusdc/src/lib.rs
pub fn repay(ctx: Context<Repay>, repay_amount: u64, debt_value: u64) -> Result<()> {
let ra:u128;
ra = repay_amount as u128;
let dv:u128;
dv = debt_value as u128;
if ra <= 0 {
return err!(ErrorCode::InvalidRepayAmount);
}
if dv <= 0 {
return err!(ErrorCode::InvalidRepayAmount); // FIX incorrect error
}
let whitelisted = &ctx.accounts.whitelisted_borrower;
if !whitelisted.whitelisted {
return err!(ErrorCode::InvalidBorrowAmount); // FIX incorrect error
}
// ...
}
Rename incorrect variable names:
waterusdc/src/lib.rs
pub fn set_strategy_params(
ctx: Context<SetStrategyParams>,
dtv_limit: u64,
keeper: Pubkey,
keeper_ata: Pubkey,
leverage_limit: u64,
fixed_fee_split: u64,
mfee_percent: u64,
sol_jlp_feed: [u8; 32], // FIX incorrect name
jlp_sol_feed: [u8; 32], // FIX incorrect name
jlp_mint: Pubkey,
keeper_fees: u64,
usdc_mint: Pubkey,
maturity_time: i64) -> ProgramResult {...)
Use cargo clippy
and resolve all warnings.
ACKNOWLEDGED: The client acknowledged the finding.
Halborn used automated security scanners to assist with 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 auditors 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.
Cargo Audit Results
ID | Crate | Description |
---|---|---|
RUSTSEC-2024-0344 | curve25519-dalek | Timing variability in |
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
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed