Organization
- @alto-money
Engagement Type
Cantina Solo
Period
-
Researchers
Findings
Informational
7 findings
3 fixed
4 acknowledged
Gas Optimizations
2 findings
1 fixed
1 acknowledged
Informational7 findings
In-place upgrades from legacy USM to advanced USM can misconfigure access state
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
phaze
Summary
Upgrading an existing
DUSDUsmproxy directly toDUSDAdvancedPermissionsUsmwould reinterpret the stored access-control byte under different enum semantics. This can produce unexpected buy or sell access settings after the upgrade.The team indicated that legacy USM deployments are not intended to be upgraded in place to the advanced implementation. The advanced USMs are expected to be deployed as new contracts, so this is an informational deployment and migration hardening note.
Description
The legacy
Usmcontract stores a single_accessModeenum at storage slot 4 offset 16 to gate both swap directions. The advanced implementation keeps the same slot and offset for_buyDirectionAccess, then introduces_sellDirectionAccessat slot 4 offset 17.The enum values are not equivalent between the two implementations:
enum AccessMode { PERMISSIONLESS, // 0 PERMISSIONED // 1} enum DirectionAccess { PERMISSIONED, // 0 PERMISSIONLESS // 1}As a result, a direct implementation upgrade would not preserve access semantics. A legacy permissioned USM stores
_accessMode == 1, which the advanced implementation would read as_buyDirectionAccess == PERMISSIONLESS. The newly added_sellDirectionAccesswould read from previously unused padding and would typically decode toPERMISSIONED.Conversely, a legacy permissionless USM stores
_accessMode == 0, which the advanced implementation would read as_buyDirectionAccess == PERMISSIONED, while_sellDirectionAccesswould also typically decode toPERMISSIONED. Since legacy role holders haveSWAPPER_ROLErather thanBUY_SWAPPER_ROLEorSELL_SWAPPER_ROLE, the upgraded contract may also lack the role grants expected by the advanced implementation.The advanced initializer cannot repair this state on an already initialized proxy because
initialize()uses the standardinitializermodifier. The advanced implementation also does not expose a dedicated migration reinitializer that maps the legacy access mode into explicit buy and sell direction settings.Impact Explanation
If a legacy proxy were upgraded directly, the resulting access configuration could be more permissive or more restrictive than intended. In the permissioned-to-advanced case, buy-side access may become permissionless; in the permissionless-to-advanced case, ordinary swaps may become unavailable until the new roles and access settings are configured.
Likelihood Explanation
This requires an authorized operator to intentionally perform an in-place upgrade from
DUSDUsmtoDUSDAdvancedPermissionsUsm. The team has indicated that this is not the intended migration path and that advanced USMs will be deployed as new contracts, which makes the practical likelihood low.Recommendation
Consider documenting that legacy
DUSDUsmproxies must not be upgraded directly toDUSDAdvancedPermissionsUsm. If an in-place migration is ever needed, add an explicit migration path that preserves storage compatibility, maps the legacy_accessModeinto both direction-access fields, and grants the correspondingBUY_SWAPPER_ROLEandSELL_SWAPPER_ROLEpermissions in the same governance action.One approach could be to add a dedicated reinitializer on a storage-compatible migration implementation:
function initializeV2(DirectionAccess buyAccess, DirectionAccess sellAccess) external reinitializer(2) onlyRole(DEFAULT_ADMIN_ROLE){ _updateAccessConfig(buyAccess, sellAccess);}For the currently planned approach, fresh advanced USM deployments avoid this storage reinterpretation issue.
Liquidation events could use the original liquidator for easier indexing
Description
When liquidations are executed through
LiquidationPeriphery, the base market receives the periphery contract asmsg.sender. The market decodes the original liquidator fromliquidationDataand uses that value when preparing the liquidation, including for priority-liquidation checks.The base market
Liquidationevent currently emitsmsg.senderas theliquidatorfield. For periphery liquidations, this means the base market event records the periphery contract rather than the actual liquidator. The periphery emits a separateLiquidationevent that includes the original liquidator, so indexers need to match events emitted by two different contracts to reconstruct the full liquidation context.This makes liquidation indexing and downstream analytics more cumbersome. Event matching across contracts is possible, but it is more tedious for node-backed indexers than reading the original liquidator directly from the base market event.
Recommendation
Consider changing the convention for the existing
liquidatorargument in the base marketLiquidationevent so that it represents the economic liquidator rather than always representingmsg.sender. For direct liquidations, this remains equivalent tomsg.sender; for periphery liquidations, it would use the decodedoriginalLiquidator.One approach could be:
emit Liquidation( borrower,- msg.sender,+ originalLiquidator, result.seizedCollateralAssets, result.protocolSeizedCollateralFee, result.repaidBorrowAmount, result.repaidBorrowShares, badDebt.assets, badDebt.shares);This preserves the event shape and avoids changing the event signature, while making periphery liquidations easier to index from the base market event alone.
Supported market removal could avoid external validation calls
Description
LiquidationPeriphery.setSupportedMarket()validates that a market'sborrowToken()matches the peripheryBORROW_TOKENbefore updating support status. This validation is useful when adding a new supported market because it prevents configuring a market for the wrong borrow token.The same validation also runs when removing support. In normal operation this is harmless, but it means that removing a market still depends on an external call to the market succeeding and returning the expected token. If a supported market were paused in an unexpected way, upgraded incorrectly, or otherwise unable to respond to
borrowToken(), the owner could be blocked from removing it fromsupportedMarkets.This is not a security issue in normal conditions. It is a defensive administration and failure-isolation improvement: disabling support should ideally be possible even when the target market is in a degraded state.
Recommendation
Consider applying the borrow-token validation only when support is being enabled. When support is being disabled, allow the owner to clear the mapping without making an external call to the market.
One approach could be:
function setSupportedMarket(address market, bool isSupported) external onlyOwner { if (market == address(0)) revert ZeroAddress();- if (IMarket(market).borrowToken() != BORROW_TOKEN) revert InvalidInput();+ if (isSupported && IMarket(market).borrowToken() != BORROW_TOKEN) revert InvalidInput(); supportedMarkets[market] = isSupported; emit SupportedMarketSet(market, isSupported);}This preserves the safety check for onboarding new markets while making removal more robust during incident response or misconfiguration cleanup.
Independent USM caps can create liquidation configuration footguns
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
phaze
Description
The USM path used by liquidation periphery depends on two separate capacity controls. The USM must be able to accept the underlying asset being sold into it, which is limited by the USM exposure cap. The USM must also be able to mint the corresponding DUSD, which is limited by the DUSD minter ceiling for the USM address.
During deployment, the mint ceiling can be derived from the configured exposure cap. However, after deployment these are independent administrative controls:
USM exposure cap: AdvancedPermissionsUsm.updateExposureCap()DUSD mint ceiling: DUSD.setMinterCeiling()If the two values are not kept in sync, the lower effective cap becomes the binding limit for the liquidation route. For example, if the DUSD minter ceiling is raised but the USM exposure cap is not, a large liquidation can still revert when
sellAsset()checks that the resulting exposure does not exceed_exposureCap. Conversely, if the exposure cap is raised but the DUSD minter ceiling is not, the USM can still be unable to mint the DUSD required for repayment.This is most relevant because the liquidation periphery is intended to provide liquidators with reliable access to DUSD even when ordinary market liquidity is insufficient. If either cap is stale or misconfigured, liquidation cascades may fail despite the other cap being set high enough. The team indicated that both values are intended to be set to equal, arbitrarily high values for the liquidation USM; the concern is mainly that maintaining two independent caps creates an operational footgun.
Recommendation
Consider reducing the chance of configuration drift between the USM exposure cap and the DUSD minter ceiling by consolidating the capacity control.
One approach could be to rely on the DUSD minter ceiling as the canonical cap and remove, disable, or bypass the advanced USM exposure cap for liquidation-oriented USMs. The DUSD contract already limits how much DUSD a given USM can mint, so a second independently maintained exposure cap may not add much protection when both values are intended to represent the same effective capacity.
If retaining both caps is preferred, consider making one value derive from the other or providing a single governance helper that updates both atomically and emits an event showing the paired configuration. The goal is to avoid a state where one stale limit silently blocks liquidations while the other appears correctly configured.
USM seize could handle accrued stable-token fees more explicitly
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
phaze
Description
AdvancedPermissionsUsm.seize()is an emergency shutdown function that marks the USM as seized, clears current exposure, clears accrued fees, sets the exposure cap to zero, and transfers the USM's full underlying-asset balance to the stable token treasury.The function clears
_accruedFeesbut does not transfer the stable-token fee balance before doing so. As a result, any stable-token fees already held by the USM remain in the contract after seizure, but they are no longer reflected in_accruedFees.This is not a material security issue because an authorized token rescuer can still recover the remaining stable-token balance after seizure. However, the accounting semantics are less clean: fees that were previously tracked as accrued fees become ordinary unaccounted tokens held by the USM.
Since
seize()is an emergency-state function, this may be acceptable operationally. Still, handling the fee balance explicitly during seizure would make the shutdown flow easier to reason about and reduce the need for a follow-up rescue action.Recommendation
Consider moving or distributing accrued stable-token fees before clearing
_accruedFeesinseize(). One approach could be to call the existing fee distribution flow before zeroing the seized state, when a fee recipient is configured.For example:
function seize() external notSeized onlyRole(LIQUIDATOR_ROLE) returns (uint256) {+ distributeFeesToFeeRecipient(); _isSeized = true; _currentExposure = 0; _accruedFees = 0; _updateExposureCap(0); ...}Alternatively, the function could transfer the accrued stable-token fee balance to the stable token treasury or emit a dedicated event indicating that fee accounting has been intentionally cleared and the remaining tokens should be rescued separately.
Advanced USM compatibility is not explicitly discoverable
Description
The liquidation periphery depends on advanced USM functionality when sourcing DUSD for liquidations. In particular, it calls the originator-aware advanced quote function before selling the selected USM's underlying asset. Legacy USMs expose the same high-level swap functions, but their quote functions do not include the originator argument and therefore use different function selectors.
Both legacy and advanced USMs inherit OpenZeppelin access control, which exposes ERC165
supportsInterface(). However, the advanced USM does not currently advertise support for the advanced permissions USM interface through ERC165. As a result, callers and off-chain integrations need to infer compatibility from deployment context, ABI knowledge, or trial calls rather than from a clear, non-reverting on-chain signal.This does not create a direct loss-of-funds path by itself. The worst-case outcome is that a legacy USM is selected for the liquidation periphery, the advanced quote call reverts, and the attempted liquidation route fails. The concern is mainly operational: multiple USM generations can make route discovery ambiguous for liquidators, frontends, keepers, and deployment checks, causing avoidable user or operator confusion.
Recommendation
Consider making advanced USM compatibility explicit through ERC165. Since both legacy and advanced USMs already expose
supportsInterface(), the advanced USM can override it to also report support forIAdvancedPermissionsUsm:function supportsInterface(bytes4 interfaceId) public view override returns (bool) { return interfaceId == type(IAdvancedPermissionsUsm).interfaceId || super.supportsInterface(interfaceId);}Integrations can then safely call the same discovery function on both generations:
if (!IERC165(usm).supportsInterface(type(IAdvancedPermissionsUsm).interfaceId)) { revert InvalidInput();}Legacy USMs would return
false, while advanced USMs would returntrue. This avoids relying on an advanced-only quote call reverting as the compatibility check.DUSD is closely linked to frxUSD through the liquidation USM
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
phaze
Description
The advanced permissions frxUSD USM is intended to support liquidations by allowing the liquidation periphery to sell frxUSD into the USM and receive freshly minted DUSD. The planned deployment also makes the buy side permissionless with a 5 bps fee, meaning any user can sell DUSD into the USM to buy frxUSD.
This creates a public redemption route that should keep DUSD closely pegged to frxUSD while the USM has frxUSD liquidity. If DUSD trades below frxUSD by more than the fee and execution costs, users can buy discounted DUSD and redeem it through the USM for frxUSD. As a result, the configuration intentionally ties DUSD's practical redemption value closely to frxUSD through this USM.
The planned deployment sets both the USM exposure cap and the DUSD mint ceiling to
5,000,000,000, while the USM price strategy values frxUSD and DUSD at a fixed 1:1 ratio. This capacity is effectively unlimited relative to normal liquidation demand. That supports the intended goal of keeping liquidations available during large market moves, but it also means the liquidation route can mint a very large amount of DUSD against frxUSD at par as long as the USM has remaining exposure and DUSD mint ceiling.This is mostly a configuration and risk-management consideration rather than a code vulnerability. The main scenario to account for is a frxUSD depeg or impairment. If frxUSD trades materially below other stable assets while the USM continues valuing it at 1:1 against DUSD, liquidations can still route through the USM and mint DUSD against the impaired asset at the fixed price. The permissionless 5 bps buy route would also tend to pull DUSD toward frxUSD's market value rather than toward a stronger external dollar reference. This may be acceptable if the protocol explicitly treats frxUSD as reliable backing for emergency liquidation liquidity, but the effective size of the cap makes that assumption important.
Recommendation
Consider using an oracle-aware pricing strategy for the frxUSD USM instead of a fixed 1:1 price. This would allow the USM to account for frxUSD market conditions and avoid continuing to closely link DUSD redemption value and liquidation minting capacity to frxUSD at par if frxUSD materially depegs or becomes impaired.
Gas Optimizations2 findings
Liquidation token flow could be simplified or separated more clearly
State
- Acknowledged
Severity
- Severity: Gas optimization
Submitted by
phaze
Description
The liquidation flow currently mixes two design approaches. In one direction,
AltoBaseMarketis aware of authorized liquidation periphery contracts: whenmsg.senderis an authorized periphery, the market decodesoriginalLiquidatorfromliquidationDataand passes that address to the liquidation engine. This lets the core market account for the economic liquidator when checking liquidation permissions.In the token flow, however, the market still sends seized collateral to
msg.sender, which is the periphery during periphery liquidations. The periphery then forwards that collateral to the original liquidator. This creates an additional token hop:market -> liquidation periphery -> original liquidatorSince the market already has special handling for authorized periphery callers and already decodes
originalLiquidator, it could theoretically use that additional context to send collateral directly to the original liquidator:market -> original liquidatorThe current approach is not a security issue by itself. It is a design tradeoff. Sending tokens to
msg.senderis a common convention that keeps core market contracts agnostic to periphery-specific flows. However, the market is no longer fully agnostic because it already has periphery-specific authorization and decoding logic. This creates an intermediate design where the market knows enough about the periphery to support priority checks, but does not use that knowledge to simplify token routing or event indexing.An alternative architecture would be to keep
AltoBaseMarketfully sender-agnostic and move the concept of periphery-specific original liquidators into the liquidation engine or periphery layer. Conversely, if the market intentionally remains aware of authorized periphery contracts, it could use that awareness consistently for conveniences such as direct collateral routing and simpler indexing semantics.Recommendation
Consider choosing one convention more explicitly.
One option is to keep the current core-market convention: the market always transfers collateral to
msg.sender, and periphery contracts are responsible for any forwarding. This keeps token movement simple from the market's perspective, but accepts the extra token hop and event-matching burden.Another option is to lean into the existing periphery-aware market design. In that model, when
msg.senderis an authorized liquidation periphery, the market could send seized collateral directly tooriginalLiquidatorwhile still invoking the periphery callback for repayment orchestration. This would avoid the intermediate collateral transfer and make the market's periphery-specific behavior more internally consistent.If direct collateral transfer is adopted, ensure the callback interface and repayment assumptions remain clear: the periphery would no longer custody the seized collateral before calling the liquidator, so any periphery callback data should assume the original liquidator has received the collateral directly.
Advanced fee strategy can pack fee values into smaller storage types
Description
AdvancedPermissionsFeeStrategystores buy and sell fee values asuint256, including default fees and overridden fee data. However, all configured fee values are validated to be belowMAXIMUM_FEE_PERCENT, which is5000basis points.This means the fee values fit comfortably in a much smaller integer type such as
uint16. The currentOverriddenFeeDatalayout also stores a boolean override flag next to a full-width fee value, causing each mapping entry to use more storage than necessary. This is not a security issue, but it increases storage writes for fee configuration and makes the fee data layout larger than the value range requires.Recommendation
Consider using compact internal storage for fee values. If preserving the external interface is preferred, the public ABI can continue accepting and returning
uint256while the implementation stores the values in a packed internal struct.struct PackedFeeData { bool isOverridden; uint16 fee;} uint16 internal _buyFee;uint16 internal _sellFee; mapping(address => PackedFeeData) internal _overrideBuyFee;mapping(address => PackedFeeData) internal _overrideSellFee;mapping(bytes32 => PackedFeeData) internal _buyFeeByRoleHash;mapping(bytes32 => PackedFeeData) internal _sellFeeByRoleHash;The setter functions can continue validating
fee < MAXIMUM_FEE_PERCENTbefore casting touint16, and the getter functions can cast back touint256at the ABI boundary.