diff --git a/Cargo.lock b/Cargo.lock index 3334cb7..dc73bde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3716,6 +3716,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "solana-security-txt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "468aa43b7edb1f9b7b7b686d5c3aeb6630dc1708e86e31343499dd5c4d775183" + [[package]] name = "solana-stake-program" version = "1.14.20" @@ -4392,6 +4398,7 @@ dependencies = [ "mpl-token-metadata", "proptest", "serde", + "solana-security-txt", "spl-associated-token-account", "spl-math", ] diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a392e57 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +We pay a bug bounty at our discretion after verifying the bug, up to 10% of value at risk, limited by a maximum of USD $250,000. This bounty is only paid out if details about the security issues have not been provided to third parties before a fix has been introduced and verified. Furthermore, the reporter is in no way allowed to exploit the issue without our explicit consent. + +Additionally, the following are out of scope for the bug bounty: + +- Any submission violating [Immunefi's rules](https://immunefi.com/rules/) diff --git a/programs/unstake/Cargo.toml b/programs/unstake/Cargo.toml index 132fe50..6b63cc5 100644 --- a/programs/unstake/Cargo.toml +++ b/programs/unstake/Cargo.toml @@ -29,4 +29,5 @@ anchor-lang = { version = "0.28.0", features = ["init-if-needed"] } anchor-spl = { version = "0.28.0", features = ["metadata", "stake", "token"] } mpl-token-metadata = { version = "^1.13", features = ["no-entrypoint"] } serde = { version = "1.0.171", features = ["derive"] } +solana-security-txt = "1.1.1" spl-associated-token-account = "^1.1" # required for anchor-spl token diff --git a/programs/unstake/src/errors.rs b/programs/unstake/src/errors.rs index e35a657..233e24d 100644 --- a/programs/unstake/src/errors.rs +++ b/programs/unstake/src/errors.rs @@ -47,4 +47,7 @@ pub enum UnstakeError { #[msg("No succeeding repay flash loan instruction found")] NoSucceedingRepayFlashLoan, // 0x177e + + #[msg("Flash loan active, no further flash loans and liquidity addition allowed")] + FlashLoanActive, // 0x177f } diff --git a/programs/unstake/src/instructions/add_liquidity.rs b/programs/unstake/src/instructions/add_liquidity.rs index c449677..147e89f 100644 --- a/programs/unstake/src/instructions/add_liquidity.rs +++ b/programs/unstake/src/instructions/add_liquidity.rs @@ -65,6 +65,10 @@ impl<'info> AddLiquidity<'info> { let token_program = &ctx.accounts.token_program; let system_program = &ctx.accounts.system_program; + if !flash_account.data_is_empty() { + return Err(UnstakeError::FlashLoanActive.into()); + } + // order matters, must calculate first before mutation let pool_owned_lamports = calc_pool_owned_lamports(pool_sol_reserves, pool_account, flash_account)?; diff --git a/programs/unstake/src/instructions/flash_loan/take_flash_loan.rs b/programs/unstake/src/instructions/flash_loan/take_flash_loan.rs index 5520664..7d32a08 100644 --- a/programs/unstake/src/instructions/flash_loan/take_flash_loan.rs +++ b/programs/unstake/src/instructions/flash_loan/take_flash_loan.rs @@ -66,6 +66,10 @@ impl<'info> TakeFlashLoan<'info> { let instructions = &ctx.accounts.instructions; let system_program = &ctx.accounts.system_program; + if !flash_account.data_is_empty() { + return Err(UnstakeError::FlashLoanActive.into()); + } + // Check corresponding repay instruction exists let current_idx: usize = load_current_index_checked(instructions.as_ref())?.into(); let mut next_ix_idx = current_idx + 1; @@ -86,39 +90,37 @@ impl<'info> TakeFlashLoan<'info> { .ok_or(UnstakeError::PdaBumpNotCached)?], ]; - // init flash_account if required - if flash_account.data_is_empty() { - // you can only invoke_signed with one seed, so - // we need to split create_account up into - // allocate, assign, transfer - let flash_account_seeds: &[&[u8]] = &[ - &pool_account.key().to_bytes(), - FLASH_ACCOUNT_SEED_SUFFIX, - &[*ctx - .bumps - .get("flash_account") - .ok_or(UnstakeError::PdaBumpNotCached)?], - ]; - allocate_assign_pda(AllocateAssignPdaArgs { - system_program, - pda_account: flash_account, - pda_account_owner_program: &crate::ID, - pda_account_len: FlashAccount::account_len(), - pda_account_signer_seeds: &[flash_account_seeds], - })?; - if flash_account.lamports() == 0 { - transfer( - CpiContext::new_with_signer( - system_program.to_account_info(), - Transfer { - from: pool_sol_reserves.to_account_info(), - to: flash_account.to_account_info(), - }, - &[seeds], - ), - 1, // 1 lamport hot potato - )?; - } + // init flash_account + // you can only invoke_signed with one seed, so + // we need to split create_account up into + // allocate, assign, transfer + let flash_account_seeds: &[&[u8]] = &[ + &pool_account.key().to_bytes(), + FLASH_ACCOUNT_SEED_SUFFIX, + &[*ctx + .bumps + .get("flash_account") + .ok_or(UnstakeError::PdaBumpNotCached)?], + ]; + allocate_assign_pda(AllocateAssignPdaArgs { + system_program, + pda_account: flash_account, + pda_account_owner_program: &crate::ID, + pda_account_len: FlashAccount::account_len(), + pda_account_signer_seeds: &[flash_account_seeds], + })?; + if flash_account.lamports() == 0 { + transfer( + CpiContext::new_with_signer( + system_program.to_account_info(), + Transfer { + from: pool_sol_reserves.to_account_info(), + to: flash_account.to_account_info(), + }, + &[seeds], + ), + 1, // 1 lamport hot potato + )?; } // increment and save flash_account diff --git a/programs/unstake/src/lib.rs b/programs/unstake/src/lib.rs index e60147d..37366b2 100644 --- a/programs/unstake/src/lib.rs +++ b/programs/unstake/src/lib.rs @@ -8,6 +8,17 @@ declare_id!("6KBz9djJAH3gRHscq9ujMpyZ5bCK9a27o3ybDtJLXowz"); #[cfg(not(feature = "local-testing"))] declare_id!("unpXTU2Ndrc7WWNyEhQWe4udTzSibLPi25SXv2xbCHQ"); +#[cfg(not(feature = "no-entrypoint"))] +solana_security_txt::security_txt! { + name: "Sanctum Unstake Program", + project_url: "https://sanctum.so", + contacts: "telegram:gnaynaud,telegram:f812_socean,telegram:fpsocean", + policy: "https://github.com/igneous-labs/sanctum-unstake-program/blob/master/SECURITY.md", + preferred_languages: "en", + source_code: "https://github.com/igneous-labs/sanctum-unstake-program", + auditors: "Sec3" +} + pub mod anchor_len; pub mod consts; pub mod errors; diff --git a/tests/test-unstake-internal.ts b/tests/test-unstake-internal.ts index 5f3a67f..7fd0fff 100644 --- a/tests/test-unstake-internal.ts +++ b/tests/test-unstake-internal.ts @@ -1742,15 +1742,8 @@ describe("internals", () => { expect(flashAccountInfo).to.be.null; }); - it("it take flash loan twice in same tx", async () => { + it("it fails to take flash loan twice in same tx", async () => { const loanAmt = new BN(1_000_000_000); - const [protocolFeeDestBalancePre, poolSolReservesBalancePre] = - await Promise.all( - [protocolFeeDestination, poolSolReserves].map((pk) => - program.provider.connection.getBalance(pk) - ) - ); - const takeIx = await program.methods .takeFlashLoan(loanAmt) .accounts({ @@ -1785,24 +1778,79 @@ describe("internals", () => { const signature = await program.provider.connection.sendTransaction(tx, { skipPreflight: true, }); - await program.provider.connection.confirmTransaction({ + const { + value: { err }, + } = await program.provider.connection.confirmTransaction({ signature, ...bh, }); - - const [protocolFeeDestBalancePost, poolSolReservesBalancePost] = - await Promise.all( - [protocolFeeDestination, poolSolReserves].map((pk) => - program.provider.connection.getBalance(pk) - ) - ); - const flashAccountInfo = await program.provider.connection.getAccountInfo( - flashAccount + expect(err).to.satisfy( + checkAnchorError( + 6015, + "Flash loan active, no further flash loans and liquidity addition allowed" + ) ); + }); - expect(protocolFeeDestBalancePost).to.be.gt(protocolFeeDestBalancePre); - expect(poolSolReservesBalancePost).to.be.gt(poolSolReservesBalancePre); - expect(flashAccountInfo).to.be.null; + it("it fails to take flash loan then add liquidity", async () => { + const loanAmt = new BN(1_000_000_000); + const takeIx = await program.methods + .takeFlashLoan(loanAmt) + .accounts({ + receiver: flashLoaner.publicKey, + poolAccount: poolKeypair.publicKey, + poolSolReserves, + flashAccount, + instructions: SYSVAR_INSTRUCTIONS_PUBKEY, + }) + .instruction(); + const addLiquidityIx = await program.methods + .addLiquidity(loanAmt) + .accounts({ + from: flashLoaner.publicKey, + poolAccount: poolKeypair.publicKey, + poolSolReserves, + lpMint: lpMintKeypair.publicKey, + mintLpTokensTo: lperAta, + flashAccount, + }) + .instruction(); + const repayIx = await program.methods + .repayFlashLoan() + .accounts({ + repayer: flashLoaner.publicKey, + poolAccount: poolKeypair.publicKey, + poolSolReserves, + flashAccount, + flashLoanFeeAccount, + protocolFeeAccount: protocolFeeAddr, + protocolFeeDestination, + }) + .instruction(); + const bh = await program.provider.connection.getLatestBlockhash(); + const tx = new VersionedTransaction( + new TransactionMessage({ + payerKey: flashLoaner.publicKey, + recentBlockhash: bh.blockhash, + instructions: [takeIx, addLiquidityIx, repayIx], + }).compileToV0Message() + ); + tx.sign([flashLoaner]); + const signature = await program.provider.connection.sendTransaction(tx, { + skipPreflight: true, + }); + const { + value: { err }, + } = await program.provider.connection.confirmTransaction({ + signature, + ...bh, + }); + expect(err).to.satisfy( + checkAnchorError( + 6015, + "Flash loan active, no further flash loans and liquidity addition allowed" + ) + ); }); }); }); diff --git a/tests/utils.ts b/tests/utils.ts index c960170..7442f41 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -32,6 +32,10 @@ export function checkAnchorError( if (err.code != undefined) { // first error type return err.code === errorCode && err.msg === errorMessage; + } else if (err.InstructionError !== undefined) { + // confirmTransaction result + const [_ixIndex, errObj] = err.InstructionError; + return errObj.Custom === errorCode; } else { // second error type return (