Degensafe engaged Guardian to review the security of their Degensafe. From the 8th of December 2025 to the 11th of December 2025, a team of 2 auditors reviewed the source code in scope.
- Published
- Review window
- December 8 to 11, 2025
- Language
- Rust
- Chains
- Solana
- Sector
- Token launches, Staking
- 5 Critical
- 2 High
- 4 Medium
- 9 Low
- 7 Informational
Scope
Overview
Degensafe engaged Guardian to review the security of their Degensafe. From the 8th of December 2025 to the 11th of December 2025, a team of 2 auditors reviewed the source code in scope.
Findings 27
-
C-01 Critical Anyone Can Takeover Pool And Corrupt State Logical Error Resolved
Description
The
create_poolinstruction usesinit_if_neededfor the pool account, which allows the instruction to succeed even when the pool already exists. Whileinit_if_neededprevents re-allocation of an existing account, the function unconditionally overwrites all pool state:pool.owner = maybe_owner.unwrap_or(ctx.accounts.admin.key()); pool.token_mint = ctx.accounts.token_mint.key(); pool.reward_mint = ctx.accounts.reward_mint.key(); pool.reward_percentage = reward_percentage; pool.total_staked = 0; pool.bump = ctx.bumps.pool; pool.reward_vault = ctx.accounts.reward_vault.key(); pool.is_active = true;There is no check verifying that the pool is uninitialized or that the caller is the existing owner. Any user can call
create_poolon an existing pool and:- Seize ownership to set themselves as pool.owner
- Reset total_staked to 0 -> causes underflow panics on subsequent withdrawals when
pool.total_staked.checked_sub(amount)is called- Change
reward_mintandreward_vault-> redirect rewards or cause withdrawal failures - Modify
reward_percentage-> manipulate reward calculations
Recommendation
Replace
init_if_neededwithinitto ensure the instruction fails if the pool already exists.Resolution
Degensafe Team: Resolved.
-
C-02 Critical Attacker Can Drain Arbitrary User Stake Logical Error Resolved
Description
The
withdraw_stakeinstruction only checks thatuser_stake.amount >= amountand lacks any further validation on theuser_stakeaccount. An attacker can exploit this by passing any victim'sUserStakeaccount while providing their own token accounts as the withdrawal destination.Ultimately, an arbitrary attacker can steal other user's tokens/rewards to their own accounts, and drain arbitrary pools.
Recommendation
Add constraints that
user_stake.owner == user.key()anduser_stake.pool == pool.key().Resolution
Degensafe Team: Resolved.
-
C-03 Critical Vault PDA Collision Enables Cross-Pool Theft Logical Error Resolved
Description
The
reward_vaultPDA is derived only from the reward mint, not the pool. When multiple pools use the same reward token, they all share a single vault, but only the first pool to create it becomes the token account authority.Consider the following scenario:
- Admin A creates Pool A with
token_mint = SOLandreward_mint = USDC - The
reward_vaultPDA is created with Pool A as authority - Admin B creates Pool B with
token_mint = ETHandreward_mint = USDC - The vault already exists;
init_if_neededskips initialization - Pool A remains the authority
- Admin B deposits 100,000 USDC rewards into the shared vault
- Admin A calls
withdraw_rewardfor 100,000 USDC - The program signs with Pool A's seeds, which matches the vault authority
- Admin A receives all of Pool B's rewards
Ultimately Pool B's users stake tokens expecting rewards, but the vault has been drained by an unrelated pool owner.
Recommendation
Include the pool address in the reward vault PDA seeds to ensure each pool has its own isolated vault
Resolution
Degensafe Team: Resolved.
- Admin A creates Pool A with
-
C-04 Critical Vault Authority Takeover Logical Error Resolved
Description
The
initializeinstruction usesinit_if_neededfor thevault_stateaccount without verifying whether the vault has already been initialized. Whileinit_if_neededprevents re-allocation of an existing account, it does not preventinitializefrom being called again and overwriting fields on an existing account.Any user can call
initializeon an existing vault and: 1. Seize ownership -> overwriteauthoritywith their own pubkey 2. Setwallet_accountto themselves -> drain all tokens to themselves throughwithdraw3. Corrupt accounting -> resetbalanceto 0Once an attacker becomes the authority, they gain full control over
set_withdrawal_accountandwithdraw, allowing them to drain all deposited funds.Recommendation
Replace
vault_state'sinit_if_neededwithinitto ensure the instruction fails if the vault already exists or validate against anis_initializedboolean which is set totrueon the firstinitializecall.Resolution
Degensafe Team: Resolved.
-
C-05 Critical Authority Hijacking In SOL Deposit Logical Error Resolved
Description
The
initializefunction usesinit_if_neededto create the vault state account, but unconditionally overwrites all state fields including theauthorityon every call.Since the vault state is a PDA derived from a static seed (
b"vault_state"), the account will already exist after the first legitimate initialization.However, because
init_if_neededallows the instruction to succeed even when the account exists, any attacker can callinitialize()to overwrite theauthorityfield with their own public key.This grants the attacker full administrative control over the vault, allowing them to set the withdrawal wallet and drain all deposited funds.
Recommendation
Replace
init_if_neededwithinitto ensure the instruction can only succeed once.Resolution
Degensafe Team: Resolved.
-
H-01 High Varied Reward And Deposit Token Leads To Drain Logical Error Resolved
Description
The reward calculation treats staked token base units as equivalent to reward token base units, with no price or value conversion. This fundamental flaw causes mispayments when the reward token has a different value than the staking token.
The formula simply computes:
reward = staked_amount * percentage * time / yearThis assumes 1 base unit staked = 1 base unit rewarded, ignoring that tokens have entirely different valuations.Underpayment Scenario:
- Staking token: WBTC (8 decimals, ~$100,000 per coin)
- Reward token: USDC (6 decimals, $1 per coin)
- Pool configured with 10% APY
- User stakes 1 WBTC = 100,000,000 base units ($100,000 value)
- After 1 year, reward calculation:
100,000,000 * 10 / 100 = 10,000,000 base units - Result: User receives 10,000,000 USDC base units = 10 USDC
Expected: $10,000 in rewards (10% of $100,000) Actual: $10 in rewards
Overpayment Scenario:
- Staking token: USDC (6 decimals)
- Reward token: WBTC (8 decimals)
- User stakes 1,000 USDC = 1,000,000,000 base units ($1,000 value)
- After 1 year:
1,000,000,000 * 10 / 100 = 100,000,000 base units - Result: User receives 100,000,000 WBTC base units = 1 WBTC = $100,000
Expected: $100 in rewards (10% of $1,000) Actual: $100,000 in rewards — a 1000x overpayment
A single user staking $1,000 USDC for one year drains $100,000 from the reward vault.
Recommendation
Implement a price oracle or admin-configured exchange rate to convert between staking token value and reward token value. Alternatively, restrict the protocol to same-token staking and rewards
Resolution
Degensafe Team: Unresolved.
-
H-02 High Deposit Record Grief Through Order ID Creation Griefing Resolved
Description
The
DepositRecordPDA seeds in bothlc_depositandsol_depositdo not include the depositor's address, allowing any user to claim arbitraryorder_idvalues and block legitimate deposits.In
lc_deposit, the seeds are:seeds = [b"deposit_record", vault_state.token_mint.as_ref(),order_id.as_bytes()].In
sol_deposit, the seeds are:seeds = [b"deposit_record", order_id.as_bytes()].Since
useris not part of the PDA derivation, an attacker can front-run or preemptively create deposit records for anyorder_idwith minimal cost. Theinitconstraint causes the victim's transaction to fail when the PDA already exists, blocking specific users from depositing.This is especially impactful since token creation fees must be paid in both sol_deposit and lc_deposit each time, and the order_id’s must match between the two programs.
Therefore, after a user deposits in one vault, an attacker can take the order_id in the other vault for minimal cost — preventing the token creation process from fully going through and locking user funds.
Recommendation
Consider including the user's pubkey in the PDA seeds.
Resolution
Degensafe Team: Resolved.
-
M-01 Medium Reward Rate Changes Apply Retroactively Logical Error Resolved
Description
The reward calculation uses the current
reward_percentagemultiplied by the entire elapsed time sincelast_staked_time. When an admin updates the reward rate, it retroactively applies to all previously accrued but unclaimed rewards.Consider the following scenario:
- User stakes 10,000 tokens at 20% APY
- User waits 364 days, expecting ~2,000 tokens in rewards
- Admin changes rate to 1% APY on day 364
- User withdraws on day 365
- Calculation uses 1% for the entire 365 days
- User receives ~100 tokens instead of ~2,000
Recommendation
Snapshot pending rewards before applying rate changes.
Resolution
Degensafe Team: Resolved.
-
M-02 Medium Withdrawal Destination Not Validated Logical Error Resolved
Description
The
withdrawfunction checks that a withdrawal wallet has been configured in the vault state, but thewallet_accountpassed into the instruction is never validated against this stored value. The account is marked with/// CHECKand only constrained to be mutable.The authority can pass any arbitrary address as
wallet_account, and the funds will be transferred to that address regardless of what was configured withset_withdrawal_account, rendering the withdrawal wallet configuration meaningless.Recommendation
Add
has_one = wallet_accountto thevault_stateaccount constraints in theWithdrawstruct.Resolution
Degensafe Team: Resolved.
-
M-03 Medium Initialize Frontrunning Risk Frontrunning Resolved
Description
Deployment of the program and the call to
initializeon Solana are not atomic -- meaning the program is first deployed, andinitializemust be called in a separate transaction.Since
initializelacks any access control, anyone can call it immediately after deployment. A malicious actor could front-run the intended initializer, set the authority to their own address, and take full control of the protocol’s configuration and operations.Recommendation
Deploy and call initialize in the same transaction using a deployment script.
Resolution
Degensafe Team: Unresolved.
-
M-04 Medium Insufficient Rewards Block Principal Withdrawal DoS Resolved
Description
In
withdraw_stake, the function checks if the reward vault has sufficient balance to pay rewards before allowing any withdrawal:require!( ctx.accounts.reward_vault.amount >= reward_to_send, CustomError::InsufficientRewardVault );If the reward vault is underfunded, users cannot withdraw their staked principal even though the principal tokens exist in a separate
pool_vault. This creates a situation where user funds can be locked if the admin fails to maintain adequate reward token reserves or drains the reward vault.Recommendation
Consider allowing principal withdrawal even when rewards cannot be paid. Otherwise, clearly document this risk.
Resolution
Degensafe Team: Resolved.
-
L-01 Low Fractional Reward Rates Not Supported Logical Error Resolved
Description
The
reward_percentagefield is stored as au64and interpreted as a whole number percentage, dividing by 100 in the reward calculation. This prevents configuring fractional APY rates (e.g. 5.5%).Recommendation
Consider using basis points instead of percentages directly.
Resolution
Degensafe Team: Resolved.
-
L-02 Low Withdrawal Wallet Argument And Account Mismatch Validation Resolved
Description
The
set_withdrawal_accountfunction accepts both anew_wallet: Pubkeyargument and actx.accounts.new_walletaccount. The vault state stores the argument value, but the ATA creation uses the account as the authority.If the caller passes mismatched values:
new_walletarg = address Actx.accounts.new_walletaccount = address B
The vault state will point to A, but the ATA is created for B. When
withdrawis called, it expects an ATA with authority matchingvault_state.wallet_account(A), which doesn't exist. Withdrawals fail until the admin callsset_withdrawal_accountagain with matching values.Recommendation
Enforce the account matches passed
new_walletargument.Resolution
Degensafe Team: Resolved.
-
L-03 Low Token Mint Not Validated In SetWithdrawalAccount Validation Resolved
Description
The
SetWithdrawalAccountcontext accepts atoken_mintaccount but does not constrain it to matchvault_state.token_mint. If the wrong mint is passed, the function creates an ATA for an unrelated token which can trigger reverts when callingwithdraw.Recommendation
Add a constraint to ensure the provided mint matches the vault's configured mint.
Resolution
Degensafe Team: Resolved.
-
L-04 Low Loose ATA Existence Check Validation Partially resolved
Description
Both
set_withdrawal_accountandcreate_wallet_ata_if_neededcompute the canonical ATA address but use weak checks to determine if the ATA already exists:set_withdrawal_accountchecks if the account is owned by the Token programcreate_wallet_ata_if_neededchecks if the account is not owned by the System program
Neither verifies that the passed
associated_tokenaccount actually matches the derived canonical ATA address. Any account passing these ownership checks is treated as the valid ATA, including unrelated token accounts, other wallets' ATAs, or accounts for different mints.Recommendation
Verify the passed account address matches the canonical ATA before checking existence. Explicitly handle the three cases: Token program owned (exists), System program owned (needs creation), and other (reject).
Resolution
Degensafe Team: Partially Resolved.
-
L-05 Low Balance Tracking Desync With Direct Transfer Logical Error Resolved
Description
The
sol_depositandlc_depositprograms maintain abalancefield invault_statethat is incremented during deposits and set to zero during withdrawals. However, the actual withdrawal transfers the full lamport balance ofvault_pda, not the trackedbalancevalue.If SOL/SPL token is sent directly to
vault_pdaoutside of thedepositinstruction, thevault_state.balancewill not reflect the true holdings. This creates a desync between tracked and actual balances and may confuse integrators/DegenSafe’s backend component.Recommendation
Consider removing the redundant
balancetracking and rely solely on the PDA's lamport balance and token amount in the vault ATA.Resolution
Degensafe Team: Resolved.
-
L-06 Low ATA Address Not Validated Validation Resolved
Description
Both
set_withdrawal_accountandcreate_wallet_ata_if_neededcompute the canonical ATA address usingget_associated_token_address, but neither verifies that the passedassociated_tokenaccount actually matches this derived address.A caller could pass any arbitrary account as
associated_token. The function would either skip creation (if the account passes the loose existence check) or attempt to create an ATA at the wrong address.Recommendation
Add a check to verify the passed associated token account matches the calculated ATA.
Resolution
Degensafe Team: Unresolved.
-
L-07 Low Reward Capping For High-Decimal Tokens Logical Error Resolved
Description
The reward calculation uses u128 for intermediate arithmetic but clamps to u64 before transfer:
reward.min(u64::MAX as u128) as u64.For tokens with high decimals (e.g., 18 decimals), u64 can only represent ~18.44 whole tokens. If such tokens are used, rewards exceeding
u64::MAXwould be capped rather than failing, resulting in users receiving less than earned.Recommendation
Document that this system is only intended for usage with lower decimal tokens.
Resolution
Degensafe Team: Unresolved.
-
L-08 Low Order ID Length Not Validated Validation Resolved
Description
The deposit function in both
lc_depositandsol_depositprograms accepts anorder_idparameter of type String without validating its length. TheDepositRecordaccount allocates a fixed 64 bytes for the order ID string content, but no runtime check enforces this limit.When a user submits an order ID exceeding 64 bytes, the transaction fails during account serialization because the data cannot fit in the allocated space. This results in a confusing error message rather than a clear validation failure
Recommendation
Consider adding explicit length validation at the start of the
depositfunction in both programs.Resolution
Degensafe Team: Unresolved.
-
L-09 Low Backend Must Act Accordingly Warning Resolved
Description
The backend systems consuming the on-chain data exposed through functions such as
check_deposit,get_user_stake_info, etc. must validate and interpret it correctly. Consider some of the potential risks below that depend on backend implementation:- Deposit Record Reuse
- Deposit records (
DepositRecord) persist on-chain indefinitely after creation - If the backend does not mark deposits as "consumed" after token creation, an attacker could potentially reuse the same deposit
proof multiple times
- Impact: Double-spending of deposit credits, unlimited token creation from single payment
- Depositor Identity Verification
- The
userfield in deposit records shows who made the deposit, but contracts don't enforce that the requester matches the
depositor
- If backend doesn't verify
deposit_record.user == requesting_user, one user could claim another's deposit - Impact: Theft of payment credits, unauthorized token creation
- Timestamp/Expiry Validation
- Deposit records include
timestampbut contracts don't enforce expiry - If backend doesn't check deposit freshness, old deposits could be used indefinitely
- Impact: Stale payment exploitation, accounting issues
- Flash-Staking Exploitation
last_staked_timeis provided but no minimum lock period is enforced on-chain- If off-chain systems grant benefits (airdrops, governance, tier access) based solely on
amount > 0without checking staking
duration, attackers can:
- Stake immediately before snapshots
- Qualify for benefits
- Withdraw immediately after
- Impact: Unfair distribution of rewards, governance manipulation
- Balance Desynchronization
vault_state.balancetracks deposits through the program, but actual on-chain balance may differ if funds are sent directly- If backend relies on
vault_state.balanceinstead of actual account balance, accounting errors occur - Impact: Incorrect financial reporting, potential loss tracking
Recommendation
Ensure the backend has been appropriately reviewed as it plays a key role in the processing of on-chain data.
Resolution
Degensafe Team: Unresolved.
-
I-01 Informational Unbounded Reward Percentage Validation Resolved
Description
The
update_reward_percentagefunction accepts anyu64value without validation. An admin can setreward_percentageto an arbitrarily high value, causing the reward calculation to produce extreme payouts.Recommendation
Consider enforcing validation for the reward percentage on-chain.
Resolution
Degensafe Team: Resolved.
-
I-02 Informational Unused DepositNotFound Error Best Practices Resolved
Description
The
VaultErrorenum defines aDepositNotFoundvariant that is never used anywhere in the program logic.Recommendation
Remove the unused error variant to reduce code clutter, or implement it in
check_depositif the intent was to return a custom error for missing records.Resolution
Degensafe Team: Resolved.
-
I-03 Informational First Deposit Requires Rent-Exempt Amount Warning Resolved
Description
The
vault_pdaaccount is declared as an uncheckedAccountInfowith no initialization logic. On the first deposit, this PDA does not exist on-chain and holds zero lamports.The System Program's transfer instruction will fail if the destination account would fall below the rent-exempt minimum after the transfer, effectively requiring the first depositor to send at least the rent-exempt amount to bootstrap the PDA.
Hence the first deposit actually has a hidden minimum that the
amount > 0validation does not encompass, and that subsequent deposits do not have to follow.Recommendation
Explicitly initialize the
vault_pdaand/or document this behavior clearly.Resolution
Degensafe Team: Unresolved.
-
I-04 Informational Missing PDA Seed Validation On Pool Account Warning Partially resolved
Description
Multiple instruction account structs in the staking program lack PDA seed validation on the
poolaccount.While
CreatePoolcorrectly derives the pool PDA using seeds[b"staking_pool",token_mint.key().as_ref()], subsequent instructions only use#[account(mut)]without verifying the pool address matches the expected PDA derivation.Note that impact is limited since Pool is a typed
Account<'info, Pool>, hence Anchor already enforces program ownership.Structs missing seeds constraint on pool:
GetPoolInfo- read-only, no owner checkUpdateRewardMint- runtime owner checkDepositReward- runtime owner checkGetUserStakeInfo- read-only, no owner checkWithdrawStake- no owner checkWithdrawReward- runtime owner checkUpdateRewardPercentage- runtime owner checkSetStakingActive- runtime owner checkDepositStake- no owner check
Recommendation
Consider adding the seeds for the
poolaccount in the various structs.Resolution
Degensafe Team: Partially Resolved.
-
I-05 Informational Reward Claiming Requires Zero Amount Withdrawal Logical Error Resolved
Description
To claim accumulated rewards without unstaking principal, users must call
withdraw_stake(0). This may be unintuitive for users and should be clearly documented.Recommendation
Clearly document this behavior.
Resolution
Degensafe Team: Resolved.
-
I-06 Informational Single Pool Per Token Mint Limitation Documentation Resolved
Description
The pool PDA is derived using seeds
[b"staking_pool", token_mint.key().as_ref()], which means only one staking pool can exist per token mint. This is a design constraint that prevents creating multiple pools with different reward configurations for the same token.Recommendation
Clearly document this constraint.
Resolution
Degensafe Team: Resolved.
-
I-07 Informational Inconsistent ATA Existence Check Warning Partially resolved
Description
The codebase uses two different methods to determine whether an Associated Token Account (ATA) already exists.
In
set_withdrawal_account, the check verifies ownership by the Token Program specifically, whilecreate_wallet_ata_if_neededuses a looser check that assumes any account not owned by the System Program is an existing ATA.The loose check would incorrectly skip ATA creation if the account were owned by any program other than System, not just the Token Program.
Recommendation
Standardize on the stricter ownership check (verifying
owner == token::ID) and consider validating the generated ata address against the passed account if exists.Resolution
Degensafe Team: Partially Resolved.
No findings match.
More from Degen Safe
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
