View Audit Report
This document presents the results of the security review conducted by Nethermind Security for Stabilizer. The focus of the engagement has been the Stabilizer AMM, ReserveHolder, Pool, and Adapter contracts, enabling zero-slippage stabelecoin swaps on approved assets.
The protocol enables users to exchange approved stablecoin assets for USDZ, which can then be used in other DeFi applications or provided as liquidity alongside another approved asset in Stabilizer pools. These pools enable 1:1 swaps between USDZ and other stablecoins with zero slippage and a small, customizable fee. The pools operate using a constant-sum mechanism, with funds deposited into or withdrawn from an adapter contract through rebalancing to keep each pool balanced.
The audit comprises 1521 lines of Solidity code. The audit was performed using (a) manual analysis of the codebase, and (b) automated analysis tools.
| Audit assessment | |
| Audit Type | Security Review |
| Initial Report | July 27, 2026 |
| Final Report | August 18, 2026 |
| Initial Commit | d13226947b |
| Final Commit | c4caafae4f |
| Documentation Assessment | High |
| Test Suite Assessment | High |
Findings by severity
The Stabilizer system is a collateral-backed stablecoin protocol that coordinates mint/redeem flows, pooled liquidity, and reserve accounting to keep USDZ redeemable and swaps liquid. Users can mint USDZ with supported collateral, redeem it for collateral, provide two-sided pool liquidity, and swap between collateral and USDZ through a hybrid pricing curve that adapts during imbalance or depeg pressure. Reserves are tracked in normalized units, pool safety constraints are enforced before sensitive outflows, and protocol and LP fees are accounted for separately. The system consists of four main components: the StabilizerAMM, ReserveHolder, Adapters, and Pools. At the end of this section a diagram presenting the call interactions between all contracts is shown.
StabilizerAMM.sol): The protocol coordinator for user-facing mint/redeem operations, asset routing, and pool rebalancing. It converts collateral deposits into USDZ using decimal normalization and protocol mint fees, then routes the collateral into the reserve layer. During redemptions, it burns USDZ and releases collateral subject to reserve safety checks. It also measures pool imbalance and executes corrective swaps to move pools toward their target balance, while exposing configuration options for fees and pool parameters.ReserveHolder.sol): The system balance sheet that tracks collateral backing USDZ across supported assets. It records per-asset reserves, aggregates normalized reserve values, compares them against circulating USDZ, and enforces global solvency and pool-local safety thresholds before withdrawals. Inflows increase tracked collateral, while outflows reduce it only when the relevant constraints remain satisfied. Adapter and pool mappings identify where assets are managed and which risk boundaries apply.AdapterBase.sol): The collateral execution layer that abstracts how and where underlying assets are held while exposing a uniform deposit and withdrawal interface. The base adapter maintains an internal balance ledger, accepts reserve-directed deposits, and processes withdrawals to recipients. This separates reserve accounting from token-specific handling and allows new collateral integrations to be added without changing the AMM or reserve logic.PoolBase.sol): The liquidity and price-discovery layer where collateral and USDZ are paired for liquidity provision and swaps. Pools maintain dual reserves, mint and burn LP shares, apply trading fees and emergency or depeg overrides, and calculate swap outputs using hybrid stable-swap math that adapts to balanced and imbalanced states. Trading fees are split between LPs and the protocol through per-share accrual accounting, supporting predictable claims while preserving liquidity.Remarks about Stabilizer's oracle-less design
The Stabilizer protocol is designed to facilitate 1:1 swaps between stablecoins, falling back to the StableSwap pricing model when a 1:1 exchange is not feasible. This design allows swaps to be executed without relying on an external price oracle during normal protocol operation.
However, the absence of an onchain price oracle means that the protocol cannot independently determine whether a supported asset has depegged from its intended value. This limitation affects not only swaps, but also the protocol’s mint and redeem flows. Unless the relevant pool is paused or appropriate fees are applied, users may continue to mint, redeem, or swap assets using protocol-defined pricing that no longer reflects their external market value.
To manage this risk, Stabilizer relies on offchain infrastructure to monitor the market prices of supported assets and respond to depeg events. Upon detecting a depeg, this infrastructure is expected to pause the affected pool or set appropriate emergency fees. The effectiveness of these protections therefore depends on the offchain systems detecting the event accurately and responding sufficiently quickly.
Even where emergency fees are updated at regular intervals, the market price of a depegged asset may move significantly between updates. This may allow opportunistic traders or arbitrageurs to mint, redeem, or swap against the protocol at favorable terms before the fees are adjusted or the affected pool is paused.
The behavior and reliability of this offchain infrastructure cannot be verified onchain, and its implementation was not reviewed as part of this engagement. Although the protocol is oracleless during normal operation, its ability to respond safely to a depeg depends on centralized offchain systems and authorized actors operating correctly and rapidly. A failure, delay, or misconfiguration in these systems could leave the protocol exposed during periods of significant market volatility.
The risk rating methodology used by Nethermind Security follows the principles established by the OWASP Foundation. The severity of each finding is determined by two factors: Likelihood and Impact.
Likelihood measures how likely the finding is to be uncovered and exploited by an attacker. This factor will be one of the following values:
When defining the likelihood of a finding, other factors are also considered. These can include but are not limited to motive, opportunity, exploit accessibility, ease of discovery, and ease of exploit.
Impact is a measure of the damage that may be caused if an attacker exploits the finding. This factor will be one of the following values:
When defining the impact of a finding, other factors are also considered. These can include but are not limited to data and state integrity, loss of availability, financial loss, and reputation damage. After defining the likelihood and impact of an issue, the severity is determined according to the table below.
| Impact / Likelihood | Low | Medium | High |
| High | Medium | High | Critical |
| Medium | Low | Medium | High |
| Low | Info / Best Practices | Low | Medium |
| Undetermined | Undetermined | Undetermined | Undetermined |
To address issues that do not fit a High, Medium or Low severity, Nethermind Security also uses three more finding severities: Informational, Best Practices, and Undetermined.
USDZ| File | Fix commit | Type | Status |
contracts/StabilizerAMM.sol | 6637a1c | High | Fixed |
USDZ is minted against collateral at a fixed nominal 1:1, with no price feed. The mintUSDZ(...) function treats one unit of any supported asset as one dollar of backing and mints USDZ accordingly, and isSolvent() in ReserveHolder measures backing by token quantity rather than market value. This is by design, since the protocol is oracle-free, and it holds while every collateral trades at $1.
The problem appears when a collateral asset loses its peg. If USDC falls to $0.90, mintUSDZ(...) still mints one USDZ per USDC, so every mint injects $0.10 of bad debt into USDZ's backing, and nothing in the mint path prices the asset any lower. The only knob the protocol has here is the mint fee, and it cannot defend the peg.
The fee is taken out of the collateral pool rather than kept as backing. mintUSDZ(...) forwards only the post-fee amount into the reserve and books the fee as protocol revenue:
IReserveHolder(reserveHolder).depositToAdapter(address(this), asset, amountAssetIn - fee);
// @audit-issue The fee is booked as revenue, so it never backs USDZ.
accumulatedFees[asset] += fee;
IUSDZ(usdz).mint(msg.sender, amountUSDZOut);The minted USDZ always equals the collateral added (both are amountAssetIn - fee), so the backing ratio stays at a nominal 1:1 no matter how high the fee is set; raising the fee just shrinks both sides together. Suppose USDC is at $0.90 and the fee is raised to 10% to match. A 1 USDC mint deposits 0.9 USDC into the reserve and mints 0.9 USDZ, so the new USDZ is backed by $0.81, still only $0.90 per USDZ.
Recommendation
Consider implementing a special haircut for depeg scenarios that can feed into USDZ reserves.
Resolution
resolved in
| File | Fix commit | Type | Status |
contracts/pools/PoolBase.sol | 19c2a09 | High | Fixed |
Every pool pays trading fees to its liquidity providers with a per-share accumulator. Two terms matter here. totalSupply is the pool's LP-share supply, the total liquidity provider tokens minted for this pool, which changes only when someone adds or removes liquidity. accFeePerShareX is a running total of "fee earned per LP share, summed over all swaps"; it exists so the pool can pay many LPs without looping over them, since any LP's claim is just their shares times how much the accumulator grew while they held those shares.
On an asset-side swap the LP part of the fee is added to feeReserveX, the pot LPs later withdraw from, and accFeePerShareX is bumped so that new fee becomes claimable. An LP's claimable amount is read straight off that accumulator, so if it does not move, the fee cannot be claimed.
function _distributeFeeX(uint256 feeAmount) internal {
// ...
if (lpFee > 0) {
feeReserveX += lpFee;
// @audit-issue Rounds to 0 when lpFee < totalSupply / 1e18, and the remainder is dropped.
if (totalSupply > 0) accFeePerShareX += (lpFee * ACC_PRECISION) / totalSupply;
}
// ...
}The two lines use different units. totalSupply is tracked on an 18-decimal basis, but the asset-side lpFee is in the asset's own decimals. For a 6-decimal asset like USDC that is a 12-decimal gap, and ACC_PRECISION is 1e18. So the increment lpFee * 1e18 / totalSupply rounds down to 0 whenever lpFee is smaller than totalSupply / 1e18. The leftover is not saved for next time, so a run of small fees never adds up either: feeReserveX keeps growing while the accumulator stays put.
The cutoff is a fixed fraction of pool depth. On a pool holding 1,000,000 per side, totalSupply is 2e24, so the accumulator only moves once lpFee reaches 2 USDC, which at the default 1 bps fee is any swapFromAsset(...) below 20,000 USDC. That is about 2% of the per-side size, and the same 2% rule holds at any pool size. Since most swaps are far smaller than 2% of the pool, ordinary trading strands its asset-side fee almost every time, and the 1 bps rate the protocol advertises is the worst case.
When the accumulator does not move, no LP accrues anything, so the fee sitting in feeReserveX is unreachable. The protocol cannot take it either, because it was routed to feeReserveX and not protocolFeeReserveX, and removeLiquidity(...) pays out only reserveX and reserveY. There is no path that pays out feeReserveX beyond what the accumulator recorded, so the stranded fee is frozen in the pool for good.
This affects the asset side only. The USDZ side is already 18-decimal, so its cutoff is below one wei and it credits normally.
Recommendation
Consider matching the units before the division, for example by scaling the asset-side fee up to the same 18-decimal basis as totalSupply, or by using a much larger accumulator precision, so a realistic fee no longer rounds to zero.
Resolution
resolved in
USDZ mint and redeem| File | Fix commit | Type | Status |
contracts/StabilizerAMM.sol | c4caafa | High | Fixed |
The protocol has two separate ways to convert between a collateral asset and USDZ. One is a pool swap (swapFromAsset(...) / swapFromUSDZ(...)), which is priced on the hybrid curve and carries both the trading fee and the emergency depeg fee. The other is minting and redeeming on the StabilizerAMM contract (mintUSDZ(...) / redeemUSDZ(...)), which converts at a fixed 1:1 rate and carries only a small mint or redeem fee.
The emergency depeg fee is the protocol's defense during a depeg. When an asset loses its peg the guardian raises that pool's fee through setEmergencyDepegFee(...), making it expensive to trade the bad asset. The fee is read by getEffectiveFee(...) and charged on both directions of a pool swap:
function swapFromAsset(uint256 amountIn) external ... {
// ...
uint256 feeAmount = (amountIn * getEffectiveFee()) / BPS_DENOMINATOR;
uint256 netInput = amountIn - feeAmount;
// ...
}The root problem is that this defense exists only on the pool path. The mint and redeem path, which is the more direct way to move value between an asset and USDZ, never reads the emergency fee. It charges only mintFeeBPS / redeemFeeBPS, which default to 1 BPS and are set by the admin, not the guardian:
function mintUSDZ(address asset, uint256 amountAssetIn) external ... {
// ...
// @audit-issue Only mintFeeBPS is charged here; the pool emergency fee is never read.
uint256 fee = (amountAssetIn * mintFeeBPS) / BPS_DENOMINATOR;
uint256 amountAssetAfterFee = amountAssetIn - fee;
amountUSDZOut = amountAssetAfterFee * (10 ** (18 - assetDecimals));
// ...
}So any protection the guardian applies to a pool can be sidestepped by routing through mint or redeem instead. The clearest impact is that the emergency fee can be bypassed outright. Suppose USDC drops to $0.90 and the guardian sets a large emergency fee on the USDC pool. An attacker holding cheap USDC skips the pool and calls mintUSDZ(...), turning USDC worth $0.90 into USDZ worth $1 for a 1 BPS fee, then swaps that USDZ into a healthy asset such as USDT or simply holds it. The protocol is left holding the bad USDC as backing, and the loss falls on the protocol and its LPs, which is exactly what the emergency fee was meant to stop. This was reproduced in a PoC: with a 50% emergency fee live on the USDC pool, the same USDC produces almost no USDZ through the pool but a near full 1:1 amount through mintUSDZ(...), and the full USDC - USDZ - USDT route lets the attacker exit close to par.
The same root cause shows up in several other ways. The guardian's emergency lever has no reach over the main path, since setEmergencyDepegFee(...) only touches pools while mint and redeem can be stopped only by pause(), which is onlyAdmin, so the fast, per-asset, lower-trust response the protocol built for a depeg does not cover the path an attacker would actually use, and only a full admin pause does. The redeem side makes this worse: once an asset depegs USDZ becomes partly backed by the bad asset, yet redeemUSDZ(...) still pays out a healthy asset at 1:1 (minus the redeem fee), letting holders race to pull good collateral and leave the bad behind while the emergency fee cannot slow them down, because redeem never reads it.
Recommendation
Consider enforcing the depeg protection on every path that converts between an asset and USDZ, not just pool swaps, for example by applying the same per-asset, guardian-settable brake (or a shared peg check) inside mintUSDZ(...) and redeemUSDZ(...).
Resolution
resolved in
| File | Fix commit | Type | Status |
contracts/pools/PoolBase.sol | 4f23167 | Medium | Fixed |
When someone provides liquidity to a pool they receive LP shares, which are a claim on a slice of the pool's two reserves. For that claim to be fair, a deposit should receive shares equal to the fraction of the pool it represents, so a depositor can only ever take back what they put in. This pool mints shares equal to the face value of the deposit, the raw amount handed over, ignoring what a share is currently worth:
dy = (dx * reserveY) / reserveX;
// ...
// @audit-issue Shares equal the deposited value, not a fraction of the existing shares.
lpMinted = _normX(dx) + dy;
_mint(msg.sender, lpMinted);Face value is only correct while the pool's total value k (its two reserves) equals its share supply totalSupply, so that one share is worth exactly one unit. Once the pool holds more value than that, a face-value deposit hands out shares worth more than the depositor paid. The pool comes to hold that extra value through trading, because when a swap is large enough to move onto the StableSwap curve the trader pays a bit of slippage that stays in the pool. That value belongs to the LPs who are already there, so each existing share becomes worth a little more than one unit (k / totalSupply rises above 1). Rebalancing does not remove it; it only shuffles value between the two reserves and leaves k unchanged. Withdrawals, meanwhile, always pay a strict fraction of the reserves:
amountAssetOut = (lpAmount * reserveX) / totalSupply;
amountUSDZOut = (lpAmount * reserveY) / totalSupply;So a newcomer who deposits at face value and then withdraws at true value walks off with more than they put in, and the difference is taken from the existing LPs.
An example makes the flow concrete. Say the pool starts balanced with only Alice as its liquidity provider. Alice adds a large amount of liquidity, which pushes the pool onto the StableSwap curve, since adding liquidity raises the pool's internal k/2 floor until swaps begin pricing on the curve. Bob then makes a swap on that curve and pays slippage, and his lost slippage stays in the pool as extra value; because Alice is the LP, that value is now hers, and her shares are worth more, which is the fair reward for providing the liquidity Bob traded against. Carol wants that value without having earned it, but she cannot add liquidity yet, because the pool's reserve is sitting at its floor. So she first mints some USDZ, which tops the reserve back up and leaves Bob's slippage untouched in the pool, and now that she can add liquidity she deposits, is minted shares at face value (over-credited, since each share is really worth more than one unit), and immediately withdraws, leaving with a share of Bob's slippage. The result is that Carol pockets value that should have gone to Alice and any other LP who was there when Bob traded and her round trip is risk-free.
Recommendation
Consider minting LP shares in proportion to the existing share supply so a deposit always receives shares matching its true fraction of the pool, no matter how much extra value the pool has built up.
Resolution
resolved in
| File | Fix commit | Type | Status |
contracts/pools/PoolBase.sol | e10ffa6 | Low | Fixed |
The pool exposes two swap entry points, swapFromAsset(...) and swapFromUSDZ(...). Both are external and permissionless, and neither takes a minimum-output or deadline argument, so a caller receives whatever the curve returns at execution time with no floor on the result:
function swapFromAsset(uint256 amountIn)
external override whenNotPaused returns (uint256 amountOut)
{
// @audit-issue No minAmountOut or deadline, so the caller cannot bound the output.
// ...
}The only slippage protection in the system lives one layer up, in the router, which reverts when the final output is below the caller's limit:
if (amountOut < minAmountOut) revert InsufficientOutput();So router users are protected, but anyone who calls a pool directly has none. This matters because a pool need not be near balance when they trade. Near balance the pool prices at a flat 1:1, but once it is imbalanced it prices on the StableSwap curve and swaps take real slippage, and that imbalanced state can be forced permissionlessly. An attacker can push a target pool onto the StableSwap curve without even holding that pool's assets: they mint USDZ against a different asset's reserve, then swap that USDZ into the target pool to drain it and leave it imbalanced. A direct caller who then swaps against that pool receives a degraded rate, and can be sandwiched, the attacker deepening the imbalance before the victim's swap and unwinding it after, because the pool applies no output check to stop it.
Router users are shielded because the router's minAmountOut makes their transaction revert instead, so the exposure is specific to direct pool interaction rather than the protocol's main entry point, which is why this is rated Low.
Recommendation
Consider adding a minAmountOut (and ideally a deadline) argument to swapFromAsset(...) and swapFromUSDZ(...) so a direct caller can bound the price they accept, or restricting the pool swap entry points so that only the router can call them. Either approach removes the unprotected direct-swap path.
Resolution
resolved in
| File | Fix commit | Type | Status |
contracts/StabilizerAMM.sol | 0d5fc98 | Low | Fixed |
The protocol charges a fee on every mint and every redeem. Both fees are single global values set by the admin:
uint256 public mintFeeBPS;
uint256 public redeemFeeBPS;Their setters take only a new fee value, with no asset parameter, so one number applies to every collateral at once and there is no way to charge a different mint or redeem fee for a single asset:
function setMintFee(uint256 newFeeBPS) external onlyAdmin {
if (newFeeBPS > MAX_FEE_BPS) revert InvalidFee();
// @audit-issue One global mint fee for all assets; no per-asset dimension.
mintFeeBPS = newFeeBPS;
emit MintFeeUpdated(newFeeBPS);
}A single global fee is fine only while every collateral sits exactly at $1. In practice the supported stablecoins drift around their peg all the time, and because USDZ is minted and redeemed against them at a fixed 1:1, holding USDZ itself at $1 requires continuously steering flow per asset: encouraging mints of the assets trading at or above peg, and discouraging mints of, while encouraging redemptions of, any asset trading below it. The correct fee response is therefore per-asset and points in opposite directions on the two paths. On the mint side the fee on a deviating, cheaper asset should be raised while the others stay low, so that bringing weak collateral in for full-value USDZ is discouraged without choking honest minting of the assets holding their peg. On the redeem side it is the reverse: the fee on the deviating asset should be lowered while the others are raised, so that holders are encouraged to redeem the weak asset out of the reserve and discouraged from pulling out the good collateral.
A single global fee for each path cannot express any of this. Raising the global mint fee to slow the intake of a weak asset also taxes minting of every healthy asset, and raising the global redeem fee to protect the good collateral also discourages redeeming the weak asset, which is exactly the action worth encouraging. So the protocol cannot nudge flow toward the assets holding their peg and away from those drifting off it; its only fee lever is a blunt, all-asset one that cannot be aimed where it is needed. A full depeg is just the extreme case of a deviation these fees already cannot steer.
Recommendation
Consider making the mint and redeem fees per-asset so that friction can be adjusted on a deviating asset without affecting the others.
Resolution
resolved in
k/2 floor| File | Fix commit | Type | Status |
contracts/StabilizerAMM.sol | 0bab644 | Low | Fixed |
The mint fee is booked as claimable revenue: mintUSDZ(...) keeps the fee in the StabilizerAMM under accumulatedFees[asset] and it is withdrawn through claimProtocolFees(...). The redeem fee is not booked anywhere. redeemUSDZ(...) charges the fee in USDZ but burns the full input while paying out only the post-fee asset, so the fee's collateral is never removed from the reserve.
uint256 fee = (amountUSDZIn * redeemFeeBPS) / BPS_DENOMINATOR;
amountAssetOut = (amountUSDZIn - fee) / scaleFactor;
// ...
// @audit-issue The full input is burned but only the post-fee asset is paid out, so the
// @audit-issue fee's collateral is left mixed in the reserve, untracked.
IUSDZ(usdz).burn(address(this), amountUSDZIn);
IReserveHolder(reserveHolder).withdrawFromAdapter(msg.sender, asset, amountAssetOut);The fee therefore accrues as over-collateralization, E = totalReserves() - backedSupply(), mixed in with reserves[asset]. The only way to collect it is rescueExcessCollateral(...), which refuses to drop the reserve below the pool's k/2 floor.
uint256 k = IPool(pool).totalLiquidity();
uint256 postReserve = reserves[asset] - amount;
// ...
// @audit-issue Recovery reverts if it would leave the reserve below the pool's k/2 floor.
if (normPostReserve < k / 2) revert LocalSafetyViolation();The same reserve figure gates liquidity additions: addLiquidityX(...) and addLiquidityY(...) call checkLocalSafety(...), which passes as long as reserves[asset] covers newK/2. Because the accrued fee inflates reserves[asset], a deposit can raise the pool's k until k/2 equals the reserve, leaving the fee pinned below the floor and unrecoverable.
For example, on the USDS pool seeded at 100,000 / 100,000 (reserve at its 100,000 floor): minting a buffer and redeeming 20,000 USDZ at a 10% fee leaves 2,000 of fee as excess (reserve 102,000, so it is rescuable). Adding the maximum liquidity the reserve allows (2,000 per side) makes the pool 102,000 / 102,000, so k/2 becomes 102,000, exactly the reserve. rescueExcessCollateral(...) then reverts for any amount and the 2,000 fee is stranded. It becomes recoverable again only if that liquidity is removed, which lowers k. The amount at stake is bounded by the accrued fees and no third party gains, so there is no fund loss, but the protocol can be blocked from collecting its own fee revenue.
The same mixing also feeds swap pricing: while the fee sits in reserves[asset], the hybrid swap math counts it as rebalancing capacity, so the pool keeps quoting 1:1 instead of falling to the StableSwap curve as it should through market activity, pricing against more capacity than its true backing provides.
Recommendation
Consider booking the redeem fee in a dedicated accumulator outside reserves[asset], the way the mint fee uses accumulatedFees, so it is tracked, claimable through the normal claimProtocolFees(...) path, and never subject to a pool's k/2 floor.
Resolution
resolved in
| File | Fix commit | Type | Status |
contracts/pools/PoolBase.sol | b4127a0 | Low | Fixed |
The trading fee from a swap is credited to whoever holds LP shares at the instant of that swap.
if (lpFee > 0) {
feeReserveX += lpFee;
// @audit Credited by current share of totalSupply, to whoever holds shares now.
if (totalSupply > 0) accFeePerShareX += (lpFee * ACC_PRECISION) / totalSupply;
}There is no minimum holding period and no lock: addLiquidityX(...) and addLiquidityY(...) mint shares immediately, and removeLiquidity(...) burns them immediately. So anyone who sees a large swap in the mempool can, in the same block, add liquidity just before it, take a proportional share of that swap's fee, then remove the liquidity and claim.
Because the pool is constant-sum, this is risk-free: add and remove are exactly proportional and there is no impermanent loss, so the actor recovers their principal intact and keeps the fee. They supply liquidity for a single block yet earn the same per-share fee as LPs who keep liquidity in the pool, diluting the standing LPs' yield. This is the standard just-in-time liquidity dynamic, worth noting here mainly because the constant-sum design removes the price risk that normally limits it on other AMMs. No protocol or user funds are lost. The effect is a redistribution of fee income away from passive LPs.
Recommendation
Consider discouraging just-in-time liquidity, for example by requiring a minimum holding period before freshly added shares accrue fees, or by locking newly minted LP shares for a short window before they can be removed.
Resolution
resolved in
| File | Fix commit | Type | Status |
contracts/StabilizerAMM.sol | 0bab644 | Low | Fixed |
The StabilizerAMM implements the functions mintUSDZ and redeemUSDZ that allow a user to exchange supported stablecoin assets for USDZ, or vice versa. Both functions support a fee mechanism, with the fee amount being limited to a maximum of 10% of the amount being exchanged. The fee mechanism for the mint flow and redeem flow are different in how they track the accumulated fees, and where the fee asset is stored. To describe the different behaviors, let's assume a scenario with a 1% fee on both mint and redeem, with a user who owns 1000 USDC.
For the mint flow, a user calls mintUSDZ and provides their 1000 USDC, of which 1% will be treated as mint fees. The 10 USDC in mint fees will stay in custody of the StabilizerAMM contract, while the 990 USDC will be transferred to the adapter via a depositToAdapter call. The 10 USDC in fees on the AMM contract are accounted for in the accumulatedFees mapping, while the 990 USDC in the adapter is tracked in the reserves mapping. The user then receives 990 USDZ.
function mintUSDZ(address asset, uint256 amountAssetIn) ... {
// ...
// Transfer in 1000 USDC
IERC20(asset).safeTransferFrom(msg.sender, address(this), amountAssetIn);
// Send out 990 USDC to adapter
IERC20(asset).forceApprove(reserveHolder, amountAssetIn - fee);
IReserveHolder(reserveHolder).depositToAdapter(address(this), asset, amountAssetIn - fee);
// Account for the remaining 10 USDC in `accumulatedFees`
accumulatedFees[asset] += fee;
IUSDZ(usdz).mint(msg.sender, amountUSDZOut);
// ...
}For the redeem flow, a user calls redeemUSDZ and following the prior example, they want to exchange their 990 USDZ for USDC. The fee of 1% will mean that 9USDC (not 10 due to integer division) is treated as fees, and the user is entitled to receive 981 USDC. The 990 USDZ is burned and 981 USDC is withdrawn from the adapter, then passed to the user. The remaining 9 USDC which represent the withdrawal fee is left in the adapter. No updates to the accumulatedFees mapping is done and the redeem USDC fee is stored on the adapter which is different to the mint flow which stores the fee asset in the AMM.
function redeemUSDZ(address asset, uint256 amountUSDZIn) ... {
// ...
// Determine withdrawal amount after fee
uint256 amountUSDZAfterFee = amountUSDZIn - fee;
amountAssetOut = amountUSDZAfterFee / scaleFactor;
// Burn the 990 USDZ
IReserveHolder(reserveHolder).assertLocalSafety(pool, amountAssetOut);
IERC20(usdz).safeTransferFrom(msg.sender, address(this), amountUSDZIn);
IUSDZ(usdz).burn(address(this), amountUSDZIn);
// Withdraw the 981 (postfee) USDC from the reserve, leaving 9 USDC in reserve as fee
IReserveHolder(reserveHolder).withdrawFromAdapter(msg.sender, asset, amountAssetOut);
// ...
}Since the redeem fees remain in the adapter, they are still accounted for in the reserves mapping in the adapter. The only way to collect it is rescueExcessCollateral(...), which refuses to drop the reserve below the pool's k/2 floor.
uint256 k = IPool(pool).totalLiquidity();
uint256 postReserve = reserves[asset] - amount;
// ...
// @audit-issue Recovery reverts if it would leave the reserve below the pool's k/2 floor.
if (normPostReserve < k / 2) revert LocalSafetyViolation();The same reserve figure gates liquidity additions: addLiquidityX(...) and addLiquidityY(...) call checkLocalSafety(...), which passes as long as reserves[asset] covers newK/2. Because the accrued fee inflates reserves[asset], a deposit can raise the pool's k until k/2 equals the reserve, leaving the fee pinned below the floor and unrecoverable.
For example, on a USDS pool seeded at 100,000 / 100,000 (reserve at its 100,000 floor): minting a buffer and redeeming 20,000 USDZ at a 10% fee leaves 2,000 of fee as excess (reserve 102,000, so it is rescuable). Adding the maximum liquidity the reserve allows (2,000 per side) makes the pool 102,000 / 102,000, so k/2 becomes 102,000, exactly the reserve. rescueExcessCollateral(...) then reverts for any amount and the 2,000 fee is stranded. It becomes recoverable again only if that liquidity is removed, which lowers k. The amount at stake is bounded by the accrued fees and no third party gains, so there is no fund loss, but the protocol can be blocked from collecting its own fee revenue.
The same mixing also feeds swap pricing: while the fee sits in reserves[asset], the hybrid swap math counts it as rebalancing capacity, so the pool keeps quoting 1:1 instead of falling to the StableSwap curve as it should through market activity, pricing against more capacity than its true backing provides.
Recommendation
Consider storing and tracking redeem fees in the same way as mint fees, by withdrawing the entire redeem amount of USDC from the adapter and then using the accumulatedFees mapping on redeem.
Resolution
resolved in
AdapterUSDS contract overrides balance() to return the live token balance, diverging from the other adapters| File | Fix commit | Type | Status |
contracts/adapters/AdapterUSDS.sol | 372b95e | Info | Fixed |
All four adapters inherit AdapterBase, whose balance() returns the internal trackedBalance. Three adapters (AdapterUSDC, AdapterUSDT, AdapterPYUSD) use this default; AdapterUSDS alone overrides it to return the live on-chain balance.
// AdapterUSDS.sol
function balance() external view override returns (uint256) {
return IERC20(underlying).balanceOf(address(this));
}// AdapterBase.sol
function balance() external view virtual override returns (uint256) {
return trackedBalance;
}Recommendation
Consider making the balance() semantics uniform across all adapters, or documenting explicitly why AdapterUSDS must expose the live balance while the others expose the tracked balance, and defining a single authoritative balance source to avoid future integrations mixing tracked and live balances.
Resolution
resolved in
| File | Fix commit | Type | Status |
contracts/StabilizerAMM.sol | a55aeca | Info | Acknowledged |
Two roles can act on a pool. The admin can halt it with pause(), which is onlyAdmin. The guardian is a separate, lower-privilege role whose power is setEmergencyDepegFee(...), meant to raise a pool's swap fee temporarily during a depeg. Unlike every other fee setter (setMintFee, setRedeemFee, and setTradingFee all cap at 1000 BPS), the emergency fee has no upper bound.
function setEmergencyDepegFee(uint256 newFeeBPS) external onlyAMM {
emergencyDepegFeeBPS = newFeeBPS;
emit EmergencyDepegFeeSet(newFeeBPS);
}That value becomes the effective fee on every swap (feeAmount = amountIn * getEffectiveFee() / BPS_DENOMINATOR, then netInput = amountIn - feeAmount), so an uncapped, guardian-settable fee gives the guardian pausing power as at 10000 BPS (100%) netInput is 0 and every swap reverts with ZeroOutput; above 10000 the subtraction underflows and reverts. swapFromAsset(...), swapFromUSDZ(...), and every router route through the pool stop working. This is the same effect as pause(), reached by the guardian, even though pausing is onlyAdmin.
Recommendation
Consider capping emergencyDepegFeeBPS in the setter in order to prevent the scenario or consider documenting that the guardian should be able to pause.
Resolution
acknowledged, guardian already has broad responsibility during a depeg so the halt power was kept; but added a 100% cap as a guard against human error in commit a55aeca7237aaff32bb88feea62c773d0bea0b8f
| File | Fix commit | Type | Status |
contracts/StabilizerAMM.solcontracts/pools/PoolBase.sol | 7f88437 | Info | Fixed |
The AMM, each pool, USDZ, and the reserve holder each have their own pause switch. Two gaps break the isolation.
First, pausing the AMM stops all pool swaps. Every pool swap ends by calling rebalance(...) on the AMM, and rebalance(...) is whenNotPaused, so pausing the AMM (for example to halt mint/redeem during an incident) makes every swapFromAsset(...), swapFromUSDZ(...), and router swap revert, even though the pools were never paused.
// @audit-issue rebalance is whenNotPaused, so an AMM pause reverts every pool swap.
IStabilizerAMM(stabilizerAMM).rebalance(address(this));Second, pausing a pool does not stop rebalancing into it. The user-facing swap functions check the pool's pause, but the AMM-only functions the rebalancer uses do not, and rebalance(...) never checks the pool's pause either.
// @audit-issue No whenNotPaused, unlike the user-facing swap functions.
function ammSwapFromAsset(...) external override onlyAMM returns (uint256 amountOut) {
// ...
}Since rebalance(...) is permissionless, anyone can call it on a paused pool and move its reserves (pulling collateral in or out). The rebalance only nudges the pool toward balance and pays the caller nothing, so by itself it takes no funds; the concern is that a pool the admin paused (say, to contain a separate bug) is not actually frozen. Pausing the pool's adapter or the AMM does close this path.
Recommendation
Consider aligning the two pause switches: do not gate rebalance(...) on the AMM pause (or document that an AMM pause is meant to halt swaps too), and gate the AMM-only pool functions or rebalance(...) on the target pool's pause so a paused pool is fully frozen.
Resolution
resolved in
USDZ amount burns USDZ but returns zero asset for low-decimal collateral| File | Fix commit | Type | Status |
contracts/StabilizerAMM.sol | 0b4d63d | Info | Fixed |
The redeemUSDZ(...) function lets a holder burn USDZ (18 decimals) and receive the chosen collateral. For collateral with fewer than 18 decimals (USDC, USDT, PYUSD are 6 on mainnet) the USDZ amount is divided by 10 ** (18 - assetDecimals) to convert to asset units, and the full input is burned.
uint256 scaleFactor = 10 ** (18 - assetDecimals);
uint256 fee = (amountUSDZIn * redeemFeeBPS) / BPS_DENOMINATOR;
uint256 amountUSDZAfterFee = amountUSDZIn - fee;
// @audit-issue For 6-dec assets, any amountUSDZAfterFee < 1e12 floors amountAssetOut to 0.
amountAssetOut = amountUSDZAfterFee / scaleFactor;
// ...
IUSDZ(usdz).burn(address(this), amountUSDZIn);
IReserveHolder(reserveHolder).withdrawFromAdapter(msg.sender, asset, amountAssetOut);There is no check that amountAssetOut 0. If a user redeems any amountUSDZIn whose post-fee value is below one asset unit (below 1e12 for a 6-decimal asset), amountAssetOut floors to 0, yet the entire amountUSDZIn is pulled in and burned and the withdrawal transfers 0 tokens. The user destroys their USDZ and receives nothing; the burned value becomes silent over-collateralization for the protocol.
Recommendation
Consider reverting when the computed amountAssetOut is zero (as the swap paths already do with ZeroOutput), so redemptions that would return nothing fail loudly instead of burning USDZ for no asset.
Resolution
resolved in
| File | Fix commit | Type | Status |
contracts/StabilizerAMM.sol | 8cf17a1 | Info | Fixed |
When redeeming USDZ for an underlying asset in the StabilizerAMM, user's funds are transferred from their own address to the AMM contract, and then they are burned. The AMM-only burn function in the USDZ contract allows the AMM to burn funds from any address, not necessarily just funds belonging to itself. This makes the transferFrom call to move assets from the caller to the AMM unnecessary, costing additional gas.
function redeemUSDZ(...) ... returns (...) {
// ...
IERC20(usdz).safeTransferFrom(msg.sender, address(this), amountUSDZIn);
IUSDZ(usdz).burn(address(this), amountUSDZIn);
// ...
}Recommendation
Consider burning assets directly from the user to reduce gas costs.
Resolution
resolved in
setGuardian(...) function emits the wrong value for the old guardian| File | Fix commit | Type | Status |
contracts/StabilizerAMM.sol | 24bd80a | Best Practices | Fixed |
The StabilizerAMM emits GuardianTransferred(oldGuardian, newGuardian) when the guardian changes and AdminTransferred(oldAdmin, newAdmin) when the admin changes; both share "old value, new value" semantics.
function transferAdmin(address newAdmin) external onlyAdmin {
if (newAdmin == address(0)) revert ZeroAddress();
emit AdminTransferred(admin, newAdmin);
admin = newAdmin;
}The setGuardian(...) function does the opposite: it overwrites guardian first and then emits, reading the already-updated guardian as the "old" argument.
function setGuardian(address newGuardian) external onlyAdmin {
guardian = newGuardian;
// @audit-issue guardian already equals newGuardian, so old value is emitted as new.
emit GuardianTransferred(guardian, newGuardian);
}As a result the GuardianTransferred event always reports the new guardian in both fields, so off-chain indexers and monitoring can never reconstruct the previous guardian from logs.
Recommendation
Consider emitting the GuardianTransferred event before the state assignment, matching the ordering already used by transferAdmin(...).
Resolution
resolved in
updateReserve| File | Fix commit | Type | Status |
contracts/ReseveHolder.sol | e205b51 | Best Practices | Fixed |
The ReserveHolder contract has a function updateReserve which can be used to manually adjust the reserves of a given asset. This function is only accessible to the AMM contract, however, the AMM contract has no logic which calls this function. As a result, the function is uncallable and unused.
// Only callable by AMM, but AMM cannot call
function updateReserve(...) external override onlyAMM {
// ...
}Recommendation
Consider removing the unused function.
Resolution
resolved in
| File | Fix commit | Type | Status |
contracts/StabilizerAMM.sol | e16c326 | Best Practices | Fixed |
In the function rebalance on the StabilizerAMM the pool address is passed as an argument, from which the pool asset can be derived. This function queries the same pool asset twice, under two different variable names asset and _asset. Since they both hold the same information, one of these declarations can be removed.
function rebalance(address pool) external override whenNotPaused noReentrantRebalance {
address _asset = IPool(pool).asset(); // Asset declaration
if (poolForAsset[_asset] != pool) revert AssetNotSupported();
(uint256 delta, bool xGreater) = computeDelta(pool);
if (delta == 0) return;
address asset = IPool(pool).asset(); // Asset declaration
// ...
}Recommendation
Consider removing one of these duplicate declarations.
Resolution
resolved in
USDZ contract should inherit Openzeppelin ERC20 logic| File | Fix commit | Type | Status |
contracts/USDZ.sol | 2a10339 | Best Practices | Fixed |
The USDZ contract is an ERC20 token with additional features such as upgradeability, pause/unpause, and the mint/burn flows being accessible to the StabilizerAMM. The underlying ERC20 token logic (such as transfers, allowances, balance checks, etc) has been implemented by the Stabilizer team rather than importing from the OpenZeppelin ERC20 library. A reference implementation should always be imported where possible, especially in the case of heavily standardised EIPs such as ERC20. The custom implementation deviates from the standard in the following ways:
transferFrom function emits an Approval event on every call, since it internally calls _approve which emits the event. This excessive event emission when an actual approval didn't occur may affect offchain infrastructure for protocols which integrate with the USDZ token.uint256.max does not give an infinite allowance. Executing a transferFrom will always reduce the allowance of the caller. While the ERC20 specification doesn't state that uint256.max should be treated as an infinite allowance, it is extremely common in other implementations. Other protocols which integrate with this token may have unexpected behavior when an infinite allowance assumption breaks.USDZ token upon a call failure have a different naming standard, and lack context such as the involved addresses and token amounts. For example: InsufficientBalance vs ERC20InsufficientBalance(sender, balance, needed). Offchain infrastructure, wallets, or block explorers may be designed to detect the more common OpenZeppelin errors and present them in an easier to understand way to the user.The OpenZeppelin ERC20 library also includes an upgradeable variant ERC20Upgradeable which supports the USDZ design approach of using a UUPS proxy.
Recommendation
Consider reimplementing the USDZ contract to inherit ERC20Upgradeable and then use function overrides to add the additional features such as pausing and AMM-only minting/burning.
Resolution
resolved in
Software documentation refers to the written or visual information that describes the functionality, architecture, design, and implementation of software. It provides a comprehensive overview of the software system and helps users, developers, and stakeholders understand how the software works, how to use it, and how to maintain it. Software documentation can take different forms, such as user manuals, system manuals, technical specifications, requirements documents, design documents, and code comments. Software documentation is critical in software development, enabling effective communication between developers, testers, users, and other stakeholders. It helps to ensure that everyone involved in the development process has a shared understanding of the software system and its functionality. Moreover, software documentation can improve software maintenance by providing a clear and complete understanding of the software system, making it easier for developers to maintain, modify, and update the software over time. Smart contracts can use various types of software documentation. Some of the most common types include:
These types of documentation are essential for smart contract development and maintenance. They help ensure that the contract is properly designed, implemented, and tested, and they provide a reference for developers who need to modify or maintain the contract in the future.
The Stabilizer team provided multiple forms of documentation during the audit engagement. A high-level summary of the protocol (available here) describes each contract and provides a brief explanation of the steps involved in swaps, mints, redemptions, and rebalances. For more detailed information, the technical documentation hosted on GitBook (available here) describes all the core components of the protocol, including fee mechanisms, liquidity provision, and swaps, in much greater detail. Finally, a whitepaper was also provided that goes into further detail, describing the constant-sum AMM, its invariants, multi-pool stabilization, and liquidity measures.
The codebase itself makes use of NatSpec for some functions, but coverage is sparse and could be improved. It is recommended that each public-facing function have NatSpec documentation. For functions that execute complicated or convoluted logic, inline comments could also be added to improve clarity and help readers understand the code more quickly.
| 400Tests passed | 0Failed |
The complete test output is in the full report.
As auditors, we recommend that any changes or updates made to the audited codebase undergo a re-audit or security review to address potential vulnerabilities or risks introduced by the modifications. By conducting a re-audit or security review of the modified codebase, you can significantly enhance the overall security of your system and reduce the likelihood of exploitation. However, we do not possess the authority or right to impose obligations or restrictions on our clients regarding codebase updates, modifications, or subsequent audits. Accordingly, the decision to seek a re-audit or security review lies solely with you.
This report is based on the scope of materials and documentation provided by you to Nethermind in order that Nethermind could conduct the security review outlined in 1. Executive Summary and 2. Audited Files. The results set out in this report may not be complete nor inclusive of all vulnerabilities. Nethermind has provided the review and this report on an as-is, where-is, and as-available basis. You agree that your access and/or use, including but not limited to any associated services, products, protocols, platforms, content, and materials, will be at your sole risk. Blockchain technology remains under development and is subject to unknown risks and flaws. The review does not extend to the compiler layer, or any other areas beyond the programming language, or other programming aspects that could present security risks. This report does not indicate the endorsement of any particular project or team, nor guarantee its security. No third party should rely on this report in any way, including for the purpose of making any decisions to buy or sell a product, service or any other asset. To the fullest extent permitted by law, Nethermind disclaims any liability in connection with this report, its content, and any related services and products and your use thereof, including, without limitation, the implied warranties of merchantability, fitness for a particular purpose, and non-infringement. Nethermind does not warrant, endorse, guarantee, or assume responsibility for any product or service advertised or offered by a third party through the product, any open source or third-party software, code, libraries, materials, or information linked to, called by, referenced by or accessible through the report, its content, and the related services and products, any hyperlinked websites, any websites or mobile applications appearing on any advertising, and Nethermind will not be a party to or in any way be responsible for monitoring any transaction between you and any third-party providers of products or services. As with the purchase or use of a product or service through any medium or in any environment, you should use your best judgment and exercise caution where appropriate. FOR AVOIDANCE OF DOUBT, THE REPORT, ITS CONTENT, ACCESS, AND/OR USAGE THEREOF, INCLUDING ANY ASSOCIATED SERVICES OR MATERIALS, SHALL NOT BE CONSIDERED OR RELIED UPON AS ANY FORM OF FINANCIAL, INVESTMENT, TAX, LEGAL, REGULATORY, OR OTHER ADVICE.
The Leading Engineers of Blockchain Infrastructure