SOLANA WATCHDOG
Automated security review
Report 2026-10-13
Sample report. A real Solana DeFi protocol deployed on devnet (about 11,000 lines of Anchor), scanned at a commit where a write-back bug was still present. Names, addresses and contacts are anonymized; everything else is the engine's unedited output.

defi-protocol

Program: protocol
Risk ratingHigh

Watchdog read 62 account contexts (607 accounts) and 130 handlers across 34 source files of protocol, followed the 141 crates that actually compile into the program out of 263 in the lockfile, and checked the deployed program on devnet. It found 6 high issues to fix, and 4 points worth a second look. On devnet, a single key can replace the program's code at any time, with no delay. 2 dependency advisories reach the deployed program; 1 needs attention.

6
issues to fix
4
points to verify
1 / 2
advisories reaching the program that need attention
12
vulnerability classes checked, 10 clean

Issues to fix

W-01HighConfidence: medium

sell_pool and hop_pool can be the same account

programs/protocol/src/instructions/router.rs:663 · CrankRoute · handler crank_route
660        seeds = [AMM_POOL_SEED, hop_pool.token_a_mint.as_ref(), hop_pool.token_b_mint.as_ref()],661        bump = hop_pool.bump,662    )]663    pub hop_pool: Option<Box<Account<'info, AmmPool>>>,664 665    #[account(mut)]666    pub hop_usdc_vault: Option<Box<Account<'info, TokenAccount>>>,
Why it matters

Both are mutable Account<AmmPool>. Anchor accepts the same account twice and, at the end of the instruction, writes each deserialized copy back in field order: the stale copy of hop_pool overwrites what the handler wrote through sell_pool. A caller who passes one account in both slots can revert a balance or reserve update (seen in practice: a pool recording funds its vault no longer held).

Fix

Add constraint = sell_pool.key() != hop_pool.key() to the struct, which covers every handler, or require_keys_neq! at the top of crank_route.

W-02HighConfidence: medium

sell_pool and target_pool can be the same account

programs/protocol/src/instructions/router.rs:677 · CrankRoute · handler crank_route
674        seeds = [AMM_POOL_SEED, target_pool.token_a_mint.as_ref(), target_pool.token_b_mint.as_ref()],675        bump = target_pool.bump,676    )]677    pub target_pool: Box<Account<'info, AmmPool>>,678 679    // …680    // …
Why it matters

Both are mutable Account<AmmPool>. Anchor accepts the same account twice and, at the end of the instruction, writes each deserialized copy back in field order: the stale copy of target_pool overwrites what the handler wrote through sell_pool. A caller who passes one account in both slots can revert a balance or reserve update (seen in practice: a pool recording funds its vault no longer held).

Fix

Add constraint = sell_pool.key() != target_pool.key() to the struct, which covers every handler, or require_keys_neq! at the top of crank_route.

W-03HighConfidence: medium

hop_pool and target_pool can be the same account

programs/protocol/src/instructions/router.rs:677 · CrankRoute · handler crank_route
674        seeds = [AMM_POOL_SEED, target_pool.token_a_mint.as_ref(), target_pool.token_b_mint.as_ref()],675        bump = target_pool.bump,676    )]677    pub target_pool: Box<Account<'info, AmmPool>>,678 679    // …680    // …
Why it matters

Both are mutable Account<AmmPool>. Anchor accepts the same account twice and, at the end of the instruction, writes each deserialized copy back in field order: the stale copy of target_pool overwrites what the handler wrote through hop_pool. A caller who passes one account in both slots can revert a balance or reserve update (seen in practice: a pool recording funds its vault no longer held).

Fix

Add constraint = hop_pool.key() != target_pool.key() to the struct, which covers every handler, or require_keys_neq! at the top of crank_route.

W-04HighConfidence: medium

sell_pool and hop_pool can be the same account

programs/protocol/src/instructions/strategy.rs:507 · CrankStrategy · handler crank_strategy
504        seeds = [AMM_POOL_SEED, hop_pool.token_a_mint.as_ref(), hop_pool.token_b_mint.as_ref()],505        bump = hop_pool.bump,506    )]507    pub hop_pool: Option<Box<Account<'info, AmmPool>>>,508 509    #[account(mut)]510    pub hop_usdc_vault: Option<Box<Account<'info, TokenAccount>>>,
Why it matters

Both are mutable Account<AmmPool>. Anchor accepts the same account twice and, at the end of the instruction, writes each deserialized copy back in field order: the stale copy of hop_pool overwrites what the handler wrote through sell_pool. A caller who passes one account in both slots can revert a balance or reserve update (seen in practice: a pool recording funds its vault no longer held).

Fix

Add constraint = sell_pool.key() != hop_pool.key() to the struct, which covers every handler, or require_keys_neq! at the top of crank_strategy.

W-05HighConfidence: medium

sell_pool and target_pool can be the same account

programs/protocol/src/instructions/strategy.rs:520 · CrankStrategy · handler crank_strategy
517        seeds = [AMM_POOL_SEED, target_pool.token_a_mint.as_ref(), target_pool.token_b_mint.as_ref()],518        bump = target_pool.bump,519    )]520    pub target_pool: Box<Account<'info, AmmPool>>,521 522    #[account(mut)]523    pub target_deposit_vault: Box<Account<'info, TokenAccount>>,
Why it matters

Both are mutable Account<AmmPool>. Anchor accepts the same account twice and, at the end of the instruction, writes each deserialized copy back in field order: the stale copy of target_pool overwrites what the handler wrote through sell_pool. A caller who passes one account in both slots can revert a balance or reserve update (seen in practice: a pool recording funds its vault no longer held).

Fix

Add constraint = sell_pool.key() != target_pool.key() to the struct, which covers every handler, or require_keys_neq! at the top of crank_strategy.

W-06HighConfidence: medium

hop_pool and target_pool can be the same account

programs/protocol/src/instructions/strategy.rs:520 · CrankStrategy · handler crank_strategy
517        seeds = [AMM_POOL_SEED, target_pool.token_a_mint.as_ref(), target_pool.token_b_mint.as_ref()],518        bump = target_pool.bump,519    )]520    pub target_pool: Box<Account<'info, AmmPool>>,521 522    #[account(mut)]523    pub target_deposit_vault: Box<Account<'info, TokenAccount>>,
Why it matters

Both are mutable Account<AmmPool>. Anchor accepts the same account twice and, at the end of the instruction, writes each deserialized copy back in field order: the stale copy of target_pool overwrites what the handler wrote through hop_pool. A caller who passes one account in both slots can revert a balance or reserve update (seen in practice: a pool recording funds its vault no longer held).

Fix

Add constraint = hop_pool.key() != target_pool.key() to the struct, which covers every handler, or require_keys_neq! at the top of crank_strategy.

Worth a second look

Lower-confidence observations. Each may be handled by an invariant this analysis cannot follow; read the code before acting.

Same account passed twice (write-back) (2)
source_pool and target_pool can be the same account
programs/protocol/src/instructions/strategy.rs:520 · CrankStrategy

Both are mutable Account<AmmPool>. Anchor accepts the same account twice and, at the end of the instruction, writes each deserialized copy back in field order: the stale copy of target_pool overwrites what the handler wrote through source_pool. A caller who passes one account in both slots can revert a balance or reserve update (seen in practice: a pool recording funds its vault no longer held).

source_lp_user_info and target_lp_user_info can be the same account
programs/protocol/src/instructions/strategy.rs:540 · CrankStrategy

Both are mutable Account<LpUserInfo>. Anchor accepts the same account twice and, at the end of the instruction, writes each deserialized copy back in field order: the stale copy of target_lp_user_info overwrites what the handler wrote through source_lp_user_info. A caller who passes one account in both slots can revert a balance or reserve update (seen in practice: a pool recording funds its vault no longer held).

u128 result cast down with `as` (2)
A u128 result is cast down with as and no bound
programs/protocol/src/amm_math.rs:34

as silently drops the high bits when the value does not fit, even with overflow-checks on. In AMM and reward math this turns an overflow into a tiny wrong number instead of an error (Cetus, May 2025: $223M).

A u128 result is cast down with as and no bound
programs/protocol/src/instructions/strategy.rs:217

as silently drops the high bits when the value does not fit, even with overflow-checks on. In AMM and reward math this turns an overflow into a tiny wrong number instead of an error (Cetus, May 2025: $223M).

Deployed program

[address redacted]devnet

One private key can replace this program's code at any time, with no delay.

Upgrade authority
[address redacted]
Last code change
2026-10-08
Verified build
none
security.txt
email:[redacted]
  • Medium Code changed on 2026-10-08, less than 7 days ago.
  • Medium No verified build: nothing ties the deployed binary to public source code.

Dependencies

Only crates that compile into the program for the Solana target count as on-chain: 141 of the 263 crates in the lockfile. The rest (CLI, RPC client, test validator, proc-macros) never reach the deployed binary.

Reaching the deployed program (2)

Medium
RUSTSEC-2023-0033 borsh 0.10.4
Parsing borsh messages with ZST which are not-copy/clone is unsound
Depends on your code: Unsound only when deserializing a type that contains a zero-sized field that is not Copy. Program state and instruction data almost never do.
Info
RUSTSEC-2026-0190 anyhow 1.0.100
Unsoundness in `Error::downcast_mut()`
Tooling and off-chain crates (1) not in the deployed binary; they run on your machines and in CI
Low
RUSTSEC-2026-0097 rand 0.7.3
Rand is unsound with a custom logger using rand::rng()
Unmaintained crates (2) no known defect; no future patches
Info
RUSTSEC-2025-0141 bincode 1.3.3
Bincode is unmaintained
Info
RUSTSEC-2025-0161 libsecp256k1 0.6.0
libsecp256k1 is unmaintained

What was checked

Vulnerability classScopeResult
Authority that never has to signevery authority-like account✓ clean
Re-initialization takeover (init_if_needed)46 init_if_needed state accounts✓ clean
Same account passed twice (write-back)every pair of mutable program-owned accounts6 to fix
Unchecked CPI targetevery program account taken raw✓ clean
Data read from an account of unknown ownerevery UncheckedAccount / AccountInfo✓ clean
Forged sysvarevery sysvar taken raw✓ clean
remaining_accounts used without checksevery handler✓ clean
Writable account with no constraint and no CHECK noteevery UncheckedAccount / AccountInfo✓ clean
Token-2022 transfer without _checkedevery token CPI✓ clean
Account closed by draining lamports onlyevery manual close✓ clean
Bound checked on a truncated valueevery check on a narrowing cast✓ clean
u128 result cast down with `as`every narrowing cast2 to verify
overflow-checks in release profileCargo.toml✓ on
Dependency advisories (RustSec / OSV)263 pinned crates, exact versions2 reach the program
Upgrade authority, verified build, security.txtmainnet, then devnet1 program found
Method and limits.
  • Static analysis of the program source as Anchor reads it: account contexts, constraints, and the handlers and helpers that use them. A rule reports only when no guard is visible in the constraints or in the code the handler runs.
  • Dependency reach follows the real dependency graph for the Solana target (target-specific dependencies, dev-dependencies and proc-macros excluded), from the lockfile and the crates.io index.
  • Economic logic (rounding direction, price and reward math, oracle use) is not judged: those bugs live in what the numbers mean, not in the account wiring.
  • It does not execute the program, prove its economics, or replace a manual audit. A clean section means none of the listed patterns was found, not that no bug exists.
Generated by Solana Watchdog · watchdog.protocol.finance