Degen Safe engaged Guardian to review the security of its Contract Updates. From the 03/09/2026 to the 03/12/2026, a team of 2 auditors reviewed the source code in scope.
- Published
- Rounds
- Main Review, Remediation Review
- Language
- Rust
- Chains
- Solana
- Sector
- Token launches, Staking
- 0 Critical
- 0 High
- 1 Medium
- 5 Low
- 11 Informational
Scope
Overview
Degen Safe engaged Guardian to review the security of its Contract Updates. From the 03/09/2026 to the 03/12/2026, a team of 2 auditors reviewed the source code in scope.
Findings 17
Main Review
13 findings-
M-01 Medium Changing Reward Token Causes Loss Of Rewards Logical Error Resolved
Description
Checking
total_staked == 0before allowing a reward mint swap, if nobody has staked, should be safe. The problem is thattotal_stakedonly tracks active deposits. It ignoresuser_stake.unclaimed.A user can withdraw their full stake while the reward vault is empty. When that happens, the code stores all owed rewards into
user_stake.unclaimedand decrementspool.total_stakedto 0. The pool now passes the guard, but there's still a user owed rewards in the old mint.Once the admin swaps the mint,
claim_rewardpays from the new vault. The user'sunclaimed, denominated in the old token, gets paid in the new token. If the tokens have different decimals or value, the user either loses money or gets overpaid.Example: User has 50 USDC (6 decimals) unclaimed. Admin swaps to a 9-decimal token. User claims and gets
50_000_000raw units of the new token = 0.05 tokens instead of 50.Recommendation
The guard needs to also check that no user has outstanding unclaimed rewards.
Resolution
Degen Safe: Resolved.
-
L-01 Low Locked Rewards In Old Vault After Mint Update Validation Resolved
Description
update_reward_mintallows the admin to update the reward token, and this instruction updates thereward_vaulttbased on the new token.The admin can withdraw reward tokens from the reward vault. However, withdrawals are only supported for the current reward vault. If the reward mint is updated while some rewards from the previous token remain unclaimed, there is no mechanism to withdraw those remaining tokens from the previous reward vault.
Recommendation
Consider allowing a reward mint update only after all rewards from the previous mint have been claimed and/or withdrawn. Alternatively, allow the admin to withdraw tokens from previous
reward_vaults.Resolution
Degen Safe: Resolved.
-
L-02 Low No Duplicate Check In Update_reward_percentage Validation Resolved
Description
The
update_reward_percentageinstruction does not check whether the new reward rate is equal to the current reward rate. As a result, the reward rate can be updated to the same value. While this does not affect reward calculations, it unnecessarily creates an additional epoch, which is subject to a maximum limit.Recommendation
Add a check in the instruction to ensure the new reward rate differs from the current rate.
Resolution
Degen Safe: Resolved.
-
L-03 Low Rent On User PDAs Not Reclaimable On Withdraw Validation Resolved
Description
For each of the below accounts:
- stake_program:
user_stakePDA (DepositStake,init_if_needed,payer = user). - spl_token_vault_program:
deposit_recordPDA (Deposit,init,payer = user). - sol_vault_program:
deposit_recordPDA (Deposit,init,payer = depositor).
the user (or depositor) pays rent at creation. The programs never close these accounts: there is no
close = ...constraint and no instruction that closes them. So after a user fully withdraws stake (stake program) or the deposit record is no longer needed (vault programs), the rent lamports remain locked in the PDA forever. Users cannot reclaim that rent, and the protocol does not redirect it to any beneficiary.Recommendation
Consider adding an instruction that closes the account when it is safe, or documenting that rent is non-recoverable so users and integrators can account for it.
Resolution
Degen Safe: Resolved.
- stake_program:
-
L-04 Low Reward Mint Check Bypassed On Update Logical Error Resolved
Description
At pool creation,
create_poolenforces that the reward mint must match the staking token mint:require!( ctx.accounts.token_mint.key() == ctx.accounts.reward_mint.key(), CustomError::RewardMintMustMatchStakeMint );The comment above it (line 158) explains the rationale:
"Enforce same-token staking: reward mint must be the same as the staking token mint. This eliminates decimal mismatch issues and simplifies reward calculations."
However, the
update_reward_mintfunction performs no such check.Recommendation
Consider either allowing to have any token, or remove the
update_reward_mintfunction.Resolution
Degen Safe: Resolved.
-
I-01 Informational Notes Regarding Fee-On-Transfer Support Informational Resolved
Description
The deposit flow in
spl-token-vaultrecords the actual amount of tokens received to support fee-on-transfer tokens. In contrast, the deposit flow in the staking program does not perform this check and accepts the provided amount without verification. As a result, the staking program does not support fee-on-transfer tokens in its current form.This creates a discrepancy between the
spl-token-vaultand staking programs regarding support for fee-on-transfer tokens.Additionally, the current programs do not support Token-2022 and only interact with the legacy SPL Token program. Since the legacy token program does not implement hooks or fee-on-transfer logic, the before/after balance check becomes redundant when using legacy tokens, even though the comments state fee-on-transfer token support.
Recommendation
Document this behavior if it is intended. Alternatively, consider aligning the behavior between the programs. If fee-on-transfer token support is necessary, consider using Token-2022.
Resolution
Degen Safe: Resolved.
-
I-02 Informational Unused Errors Error Resolved
Description
Recommendation
Consider removing all unused errors.
Resolution
Degen Safe: Resolved.
-
I-03 Informational No Guard On Reward Deposit Or Withdrawal Rewards Partially resolved
Description
withdraw_rewardlets the admin pull any amount from the reward vault with no cap. There's nothing stopping them from withdrawing tokens that are already owed to stakers, the function doesn't track how much of the vault is committed.On the flip side, there's also no requirement for the admin to deposit enough rewards to cover what stakers have accrued. If the vault is underfunded,
claim_rewardreverts andwithdraw_stakesilently pays 0 rewards.Both are admin responsibility, but the program doesn't enforce either.
Recommendation
Track committed rewards at pool level and cap admin withdrawals to the uncommitted balance or consider documenting this behavior for users.
Resolution
Degen Safe: Partially Resolved.
-
I-04 Informational Deprecated Rent Sysvar Passed As Account Best Practices Resolved
Description
Sysvar<'info, Rent>is deprecated by Solana. The runtime still supports it but the recommended approach isRent::get().sol-vaultalready does this, butstakeandspl-token-vaultstill pass it as an account.Recommendation
Remove pub rent:
Sysvar<'info, Rent>from all account structs and useRent::get().Resolution
Degen Safe: Resolved.
-
I-05 Informational Missing Events Events Resolved
Description
Three state-changing functions only use
msg!()logs but don't emit typed events:initialize_config— sets the global admintransfer_admin— transfers global admin to a new addressupdate_pool_authority— changes pool owner
All other state-changing functions (
create_pool,deposit_stake,withdraw_stake,claim_reward, etc.) properly emit typed events. These three are the only ones that don't.Recommendation
Add typed events for all three, e.g.
ConfigInitializedEvent,AdminTransferredEvent,PoolAuthorityUpdatedEvent.Resolution
Degen Safe: Resolved.
-
I-06 Informational No Cancel Path For Deposits And Trust Assumptions Trust Assumptions Acknowledged
Description
Both
sol-vaultandspl-token-vaultexpose only adeposit(order_id, amount)instruction for users. Deposited funds move into PDAs fully controlled by the program, and the only withdrawal instruction can be invoked by the vault authority to sweep all funds to a preconfigured wallet. No user-facing cancel/withdraw path exists, and deposit records are read-only receipts.Users must rely entirely on the operator’s off-chain infrastructure to acknowledge deposits, fulfill services, or process refunds. A failure or malicious action off-chain leaves deposits permanently locked or lost upon authority withdrawal.
Recommendation
This issue is intended to inform users, and no change is required as this is a design choice. Consider documenting these behaviors and expected workflows.
Resolution
Degen Safe: Acknowledged.
-
I-07 Informational Consider Using is_on_curve Check For Updates Informational Acknowledged
Description
set_withdrawal_account,update_authority,transfer_admin, andupdate_pool_authorityinstructions only deny a small set of obviously invalid pubkeys and do not verify that the new authority/account is compatible with the program’s wallet-based authorization model. Since these roles are expected to be controlled by ordinary signer wallets, assigning an off-curve address such as a PDA, or another non-signable address class, can render future authority-gated operations unusable.While it is impossible to check and deny all possible unusable addresses, it is recommended to use
Pubkey::is_on_curve()to at least ensure the address corresponds to a valid Ed25519 point.Recommendation
Consider validating new addresses using
Pubkey::is_on_curve()when updating addresses or wallets.Resolution
Degen Safe: Acknowledged.
-
I-08 Informational Warning Related To Offchain Part Warning Acknowledged
Description
Users are expected to interact with the protocol via the backend, and
order_idsare provided by the backend during deposits. However, users can deposit directly on-chain and provide arbitraryorder_ids. In such cases, there is no guarantee that these deposits will be accounted for or refunded manually. Users must be made aware of this behavior and warned accordingly. They should not interact with the protocol directly on-chain.Additionally, backend validation must account for arbitrarily supplied on-chain
order_idsand ensure it does not reuse an already-usedorder_idif IDs are generated via simple incrementation.Recommendation
Inform and warn users not to perform direct deposits, document the protocol's expected behavior, and ensure that backend validation accounts for this case.
Resolution
Degen Safe: Acknowledged.
Remediation Review
4 findings-
L-01 Low Incorrect Committed_rewards Accounting Allows Draining Of Rewards Logical Error Resolved
Description
In
withdraw_stake, when rewards get paid out(reward_to_send > 0), bothprev_unclaimedandpendingare subtracted frompool.committed_rewards:pool.committed_rewards = pool.committed_rewards .saturating_sub(prev_unclaimed) .saturating_sub(pending);The problem is that
pending, freshly calculated bycalculate_pending_reward, was never added tocommitted_rewardsto begin with.committed_rewardsonly grows when a user interaction materializes rewards (viadeposit_stake, or theelsebranch ofwithdraw_stakewhen rewards stay as unclaimed). So subtractingpendinghere removes value that was never accounted for, deflating the counter.Since
saturating_subclamps to 0 instead of reverting, the transaction goes through without error. The pool's accounting just gets quietly corrupted.This feeds directly into
let uncommitted = reward_vault.amount.saturating_sub(pool.committed_rewards);withdraw_reward, where the admin's withdrawable balance is:Every time a user withdraws stake and gets rewards paid,
committed_rewardsdrops further than it should.uncommittedgrows, and the admin can pull out tokens that are owed to other stakers. After enough withdrawals the counter can hit 0, leaving the entire vault exposed.Recommendation
Only subtract
prev_unclaimedwhen rewards are paid out,pendingwas never added so it should not be subtracted.Resolution
Degen Safe: Resolved.
-
I-01 Informational Misplaced Comment Informational Resolved
Description
After adding the
close_deposit_recordinstruction in thespl-token-vault, the comment related to theupdate_authorityinstruction remained above the newly added instruction./// Transfer vault authority to a new address. /// Validates the new authority is not a reserved address. /// Close a deposit record and return rent to the depositor. /// Only the original depositor can close their own record. pub fn close_deposit_record(_ctx: Context<CloseDepositRecord>, _order_id: String) -> Result<()> {Recommendation
Update the comment.
Resolution
Degen Safe: Resolved.
-
I-02 Informational Notes Related To Committed Rewards Informational Resolved
Description
The
committed_rewardsfield is introduced to track rewards earned by users and to prevent the admin from withdrawing them. However, it only accounts for "unclaimed" rewards and does not include "pending" rewards.If a user stakes and remains idle for a long time, their rewards continue to accrue as "pending", but are not recorded as "unclaimed" until they interact with their stake (e.g., deposit, withdraw, or claim). As a result, these rewards are not included in
committed_rewardsIt is not feasible to track all users’ pending rewards across the codebase, as doing so would require iterating over all users or a significant architectural change. However, it is important to note that pending rewards are not protected and can still be withdrawn.
Recommendation
Document this behavior as a design choice and inform users that pending rewards may be withdrawn. Stakers should periodically update their stake or claim if they want their rewards to be protected.
Optionally, introduce a guard such as: ‘if pool.total_staked > 0, only allow withdrawals above a defined safety buffer.’
Resolution
Degen Safe: Resolved.
-
I-03 Informational Warning Regarding Deposit Record Closure Warning Resolved
Description
Users can close their deposit records to reclaim the rent. The
CloseDepositRecordinstruction ensures that only the original depositor can close the record.However, there are no additional restrictions on when a deposit record may be closed. A user may close it even before the backend or off-chain components of the protocol have processed or fulfilled that deposit. Once closed, the on-chain deposit record is deleted and can no longer be queried through
check_deposit.Users should be clearly warned not to close a deposit record before the backend has fully processed it, as doing so removes the on-chain record and may interfere with verification or reconciliation of that deposit.
Recommendation
Document this behavior and warn users about the deposit record closures.
Resolution
Degen Safe: 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.
