Guardian's review of Treasury Updates for Synthetix, published April 2025. The report records 15 findings, including 2 high and 2 medium.
- Published
- Review window
- April 7 to 9, 2025
- Language
- Solidity
- Chains
- Ethereum, Optimism, Base, Arbitrum
- Sector
- Perpetuals
- 0 Critical
- 2 High
- 2 Medium
- 11 Low
- 0 Informational
Scope
Findings 15
-
H-01 High Anyone can loop
saddle-unsaddleand draindepositRewardsLogical Error AcknowledgedDescription
In the Treasury Market, the
saddle()function either allocates rewards or initiates debt repayment based on the user's account debt:- Zero debt: Rewards are allocated to
depositRewards[accountId][config.token]. - Non-zero debt: Debt repayment is prioritized.
Only one of these actions occurs per
saddle()call. Rewards are recorded duringsaddle()but not cleared duringunsaddle(), relying on the nextsaddle()to overwrite or reset them. This assumption introduces vulnerabilities.
Issue 1: Reward Persistence Across Debt States
Steps:
- User saddles with zero debt, earning rewards in
depositRewards. - User unsaddles post vesting, claims rewards, but
depositRewardsremains uncleared. - User saddles again with non-zero debt (e.g., accrued via delegation to other pools).
- Since
debt > 0,saddle()prioritizes repayment and does not reset prior depositRewards. - User loops this process of saddling with non zero debt and unsaddle.
This could have been critical, however since the minimum delegation time is expected to be 7 days, the damage could be capped to first loop itself.
We tested this on a mainnet fork with
poolId = 1and confirmed that delegation is still possible for other pools, allowing the scenario described above.Synthetix also handles cases where a user could saddle with a debt after their first saddle here, which enables this attack vector.
Issue 2: Reward Leakage via Config Manipulation
Even if Synthetix restricts debt accrual in other pools post-Treasury Market migration, reward leakage persists.
Steps:
- User saddles while Token A is in
depositRewardConfigurations, accruing rewards. - User unsaddles, but
depositRewardsfor Token A is not reset. - SNX removes Token A from the config.
- User saddles again—no reset occurs for Token A (it’s not in the active config).
- Token A is re-added to the config later.
- User claims Token A rewards accrued from the initial period, despite not staking during the new reward period. This undermines the time-based reward vesting logic.
Root Cause
The protocol assumes
depositRewardsis reset duringsaddle(), but this reset:- Only applies to currently active reward tokens.
- Ignores tokens removed and re-added during unsaddle periods.
- Fails to account for debt changes between saddle-unsaddle cycles.
Recommendation
Consider reset of
depositRewards[accountId]fully duringunsaddle()to prevent stale reward data from persisting across states or config changes. - Zero debt: Rewards are allocated to
-
H-02 High Liquidated collateral is credited twice Unexpected Behavior Acknowledged
Description
When
LiquidationModule.liquidateToTreasury()is called, the treasury account is credited the liquidated collateral twice// move the collateral Account.load(treasuryAccountId).increaseAvailableCollateral( collateralType, liquidationData.collateralLiquidated ); treasuryAccountEpoch.collateralAmounts.set( // solhint-disable-next-line numcast/safe-cast bytes32(uint256(treasuryAccountId)), // solhint-disable-next-line numcast/safe-cast treasuryAccountEpoch.collateralAmounts.get(bytes32(uint256(treasuryAccountId))) + liquidationData.collateralLiquidated );The first part of the code deposits the collateral into the treasury account and the second one delegates it to the appropriate vault in the treasury pool. Normally, when delegations happen, the available collateral of the given user is decreased, otherwise it can be used again. As we can see from the code above, this step is missed here and in result the treasury account receives twice the collateral. This can lead to serious backing problems for Synthetix.
Recommendation
Consider removing the
increaseAvailableCollateralcall to achieve correct collateral delegation to the pool. -
M-01 Medium
burnUsdlocks the available collateral Rewards AcknowledgedDescription
A new check has been added to
burnUsd()to make sure the locked collateral cannot be burned.if ( account.collaterals[usdToken.getAddress()].getTotalLocked() > account.collaterals[usdToken.getAddress()].amountAvailableForDelegationD18 ) { revert InsufficientAvailableCollateral( account.collaterals[usdToken.getAddress()].amountAvailableForDelegationD18, account.collaterals[usdToken.getAddress()].getTotalLocked() ); }The burn transaction will revert if we leave our available amount lower than what's locked. However, if we look at CollateralModule.withdraw(), we can see the locked amount is applied on the total user collateral amount, i.e available + already delegated. Because of this, the
burnUsdwill be reverting in cases where it should succeed. For example:- totalDeposited = 3000; assigned = 1000; locked = 800; availableForDelegation = 2000
- The
unavailableCollateral = 1000, because 1000 (assigned) > 800 (locked) - Therefore, we should be able to use all of the
availableForDelegation = 2000collateral - However,
burnUsd()will require at least 800 to be left inavailableForDelegation, which makes it a total of1800locked amount instead of1000.
This can result in the user being liquidated in situations where they need to repay their debt by burning sUsd and not being able to undelegate their collateral.
Recommendation
Change the check in
burnUsdto calculateunavailableCollateraltaking into account the total amount. -
M-02 Medium Reward token removal can be abused for potential future earnings Rewards Acknowledged
Description
The owner can remove reward configurations from
depositRewardConfigurations. This can be abused in the following way:- A user frontruns the owner's transaction to
saddlea large amount of funds which will record a big reward in theirdepositRewardsfor all the tokens (including the one to be removed) - The owner transaction is executed and the given token is removed from
depositRewardConfigurations - After the minimum delegation time expires, the user can
unsaddle. The removed token is not insidedepositRewardConfigurations, the rest of it is the same as when the user saddled. Because of this, the user receives their fair share of rewards for the rest of the tokens, but their data indepositRewardsfor the removed token is not changed, so it continues accruing rewards. - If that token is ever re-added, the user can frontrun the admin transaction again to saddle and receive their rewards even though they aren't eligible for it.
Recommendation
You can introduce a new array containing the active reward loans for each users and use it in the
unsaddle()flow, by either paying the user the tokens or clearing the removed ones. - A user frontruns the owner's transaction to
-
L-01 Low Delegation in
liquidateToTreasurylacks checks Validation AcknowledgedDescription
When the liquidated collateral is delegated by adding it to
treasuryAccountEpoch.collateralAmounts,liquidateToTreasury()the checks which usually run indelegateCollateralare not executed. This can lead to unexpected behaviors, including, but not limited to:- delegating not enabled collateral if the liquidated collateral is not enabled on the treasury pool
- exceeding the pool collateral limit
- performing insufficient delegation, i.e below the minimum limit
Recommendation
If the team considers these behaviours as acceptable, then document them. Otherwise, checks can be added for each desired validation, but keep in mind that each check added increases the probability of DOS for
liquidateToTreasury(). -
L-02 Low Anyone can DoS
setDepositRewardConfigurationsInformational AcknowledgedDescription
Since
setDepositRewardConfigurationsreverts ifavailableDepositRewards[token] > 0, anyone can front-run it by callingfundForDepositRewardwith even 1 wei, causing a revert and blocking config updates.Recommendation
- Make
fundForDepositRewardpermissioned or - Call SNX Core to set
maxDepositable = 0temporarily to allow removal. Code Reference or - Add a function in the peripheral contract that calls
removeFromDepositReward(token, market.availableDepositRewards(token))to remove the entire balance before making the call to set. or - Use Front-running resistant RPC
- Make
-
L-03 Low Unsaddle may be blocked because of rewards DoS Acknowledged
Description
When a user unsaddles, any available rewards will be unconditionally claimed. However, if the rewards are still not deposited and the amount recored in
availableDepositRewardsis less than what's to be claimed, the unsaddle will revert. The reward mechanism serve the user as a benefit, but in this case it blocks their withdrawal which may create big inconvenience if the user needs the funds immediately.Recommendation
Consider adding
forceUnsaddleboolean parameter to theunsaddle()function which if true skips any rewards that cannot be paid out. -
L-04 Low The
oracleManagershould properly adjust decimals Math AcknowledgedDescription
The
rewardAmountcomputed insaddle()depends on the result returned by theoracleManagerto convert the collateral token to reward token. The result ofrewardAmountshould be inrewardTokenDecimals. Currently, the calculation for it will result incollateralTokenDecimals + oracleDecimals - 18.We should have `collateralTokenDecimals + oracleDecimals - 18 = rewardDecimals => The following should hold true for each oracle:
oracleDecimals = rewardDecimals - collateralTokenDecimals + 18Recommendation
Make sure each oracle returns the price in the correct decimal precision.
-
L-05 Low Unclaimed rewards are likely lost when
DepositRewardTokenis removed Rewards AcknowledgedDescription
The owner can remove reward configurations from
depositRewardConfigurations. Because of this, any unclaimed rewards for users will be lost when theyunsaddlesince the loop won't execute any logic for the corresponding token.Recommendation
Make sure that in the event of reward token removal users are informed in advance so they can take the appropriate actions on time.
-
L-06 Low Precision Revert in
withdrawMarketCollateralDuring Unsaddle Logical Error AcknowledgedDescription
The rewards allocated to users are deposited into the market as collateral by the treasury. These rewards are withdrawn when users claim them during the unsaddle process.
The
withdrawMarketCollateralfunction internally callsconvertTokenToSystemAmountto convert the token amount into the system's internal accounting units.function convertTokenToSystemAmount( Data storage self, uint256 tokenAmount ) internal view returns (uint256 amountD18) { ------- // ensure no precision is lost when converting to 18 decimals if (tokenAmount % (10 ** (decimals - 18)) != 0) { revert PrecisionLost(tokenAmount, decimals); } -------- }However, if the token in question has more than 18 decimals, this conversion may revert due to precision loss during the conversion. This could cause unexpected reverts during user unsaddles.
Recommendation
If tokens with more than 18 decimals are used, ensure to truncate any precision beyond 18 decimals before invoking
withdrawMarketCollateral. -
L-07 Low Approvals are not reset Logical Error Acknowledged
Description
The approvals for the reward tokens in the
TreasuryMarketare never revoked, which means that if a token like USDT is used, its configuration cannot be edited and if once removed, it cannot be added again because the approval will try to change the already non-zero allowance value to another non-zero allowance value, which is not supported by these weird tokens.Recommendation
Consider using an approach like
safeApproveWithRetry()which will reset the approval to 0 first if needed. -
L-08 Low The
treasuryAccountIdis not checked for existence when liquidating Validation AcknowledgedDescription
In
liquidateToTreasury()thetreasuryAccountIdis fetched from the_CONFIG_TREASURY_ACCOUNT_IDconfig and is checked to not be 0, but there is no check to ensure the account exists.Recommendation
Check for account existence.
uint128 treasuryAccountId = uint128(Config.readUint(_CONFIG_TREASURY_ACCOUNT_ID, 0)); if (treasuryAccountId == 0) { revert ParameterError.InvalidParameter("treasuryAccountId", "not set"); } + Account.exists(treasuryAccountId); -
L-09 Low The pool loaded in
liquidateToTreasuryis not checked for existence Validation AcknowledgedDescription
In
liquidateToTreasury()thetreasuryPoolIdis fetched from the_CONFIG_TREASURY_POOL_IDconfig and is checked to not be 0, but there is no check to ensure the pool exists.Recommendation
When loading the pool, instead of
Pool.load(), usePool.loadExisting(). This will revert if the pool doesn't exist. -
L-10 Low
_decimalPow()supports only 18 decimals precisionbaseMath AcknowledgedDescription
The
_decimalPow()helper function in theTreasuryMarketcontract raises a givenbaseto anexpby multiplyingbaseby itselfexptimes. However, it usesmulDecimalfor the multiplication, which works by multiplying the two numbers and removing 18 decimals after that. After the firstcur = cur.mulDecimal(base)is executed, the newcurwill be inbasedecimals. This will result in a wrong result from the second iteration and return a wrong result. Currently, the code uses this function only in_loanedAmount()and thebasethere has 18 decimals of precision, but if you decide to use the function with a different base in the future, it can result in unexpected behaviors.Recommendation
Never use the function as it is for any
basewhich is not in 18 decimals precision. -
L-11 Low Misplaced Comment in
LiquidationModule.solBest Practices AcknowledgedDescription
Recommendation
Consider moving the comment to the appropriate line (L232) or removing it if no longer relevant.
No findings match.
More from Synthetix
All 14 reports-
Update Reviews
34 findings2 critical · 4 high 34 findings: 2 critical, 4 high, 13 medium, 10 low, 5 informational -
Deposit Contract
38 findings1 high 38 findings: 1 high, 6 medium, 20 low, 11 informational -
Fixed Staking Rewards
6 findings1 high 6 findings: 1 high, 2 medium, 3 low -
Auto-Compounding LP Vault
80 findings1 critical · 4 high 80 findings: 1 critical, 4 high, 14 medium, 61 low
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.
