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.
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 mattersBoth 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).
FixAdd 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 mattersBoth 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).
FixAdd 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 mattersBoth 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).
FixAdd 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 mattersBoth 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).
FixAdd 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 mattersBoth 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).
FixAdd 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 mattersBoth 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).
FixAdd 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 accountprograms/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 accountprograms/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 boundprograms/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 boundprograms/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.
Tooling and off-chain crates (1) not in the deployed binary; they run on your machines and in CI
Unmaintained crates (2) no known defect; no future patches
What was checked
| Vulnerability class | Scope | Result |
| Authority that never has to sign | every 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 accounts | 6 to fix |
| Unchecked CPI target | every program account taken raw | ✓ clean |
| Data read from an account of unknown owner | every UncheckedAccount / AccountInfo | ✓ clean |
| Forged sysvar | every sysvar taken raw | ✓ clean |
| remaining_accounts used without checks | every handler | ✓ clean |
| Writable account with no constraint and no CHECK note | every UncheckedAccount / AccountInfo | ✓ clean |
| Token-2022 transfer without _checked | every token CPI | ✓ clean |
| Account closed by draining lamports only | every manual close | ✓ clean |
| Bound checked on a truncated value | every check on a narrowing cast | ✓ clean |
| u128 result cast down with `as` | every narrowing cast | 2 to verify |
| overflow-checks in release profile | Cargo.toml | ✓ on |
| Dependency advisories (RustSec / OSV) | 263 pinned crates, exact versions | 2 reach the program |
| Upgrade authority, verified build, security.txt | mainnet, then devnet | 1 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.