Skip to content

New ARM features before Ethena audit#208

Open
naddison36 wants to merge 31 commits into
mainfrom
nicka/withdraw-on-swap
Open

New ARM features before Ethena audit#208
naddison36 wants to merge 31 commits into
mainfrom
nicka/withdraw-on-swap

Conversation

@naddison36
Copy link
Copy Markdown
Collaborator

@naddison36 naddison36 commented Apr 10, 2026

Summary

Adds the next set of ARM upgrades ahead of the Ethena audit. This PR now includes active-market liquidity sourcing during swaps, discounted base-asset swap fees, per-price liquidity limits, multi-base-asset support, adapter-based redemption flows, and updated loss/fee accounting.

Merged changes

Key Changes

Withdraw From Active Market During Swaps

Swaps that need more liquidity than is currently held in the ARM can now pull the shortfall from the active ERC-4626 lending market.

This lets the ARM keep more liquidity deployed while still supporting swaps, provided the active market has enough withdrawable liquidity.

Discount-Based Swap Fees

Replaces the old performance fee on asset growth with fees accrued when the ARM buys base assets at a discount.

Fees are now tied to the executed buy price and accrued in liquidity-asset terms.

Per-Price Liquidity Limits

Buy and sell liquidity limits are tracked per configured price.

This lets operators cap how much liquidity can be consumed at the current buy/sell prices before prices are refreshed.

Multi-Base-Asset ARM

Folds reusable multi-base logic into AbstractARM, allowing a single ARM to support multiple base assets against one liquidity asset.

Examples:

  • Lido ARM: stETH, wstETH
  • EtherFi ARM: eETH, weETH
  • Ethena ARM: sUSDe
  • Origin ARM: OETH, wOETH

Each base asset has its own config:

  • buy price
  • sell price
  • buy liquidity remaining
  • sell liquidity remaining
  • cross price
  • pending redeem assets
  • pegged/non-pegged conversion mode
  • redemption adapter

Asset Adapter Redemption Flow

Protocol-specific redemption logic has moved out of the ARM and into per-asset adapters.

Adapters now own details such as:

  • Lido withdrawal NFT queue
  • wstETH unwrap and Lido withdrawal request
  • EtherFi withdrawal NFTs
  • Ethena cooldown/unstaker flow
  • Origin vault withdrawal requests
  • wrapped Origin asset redemption

The ARM only tracks generic pending redeem accounting in liquidity-asset terms.

LP Loss Accounting

Updates LP redeem accounting so losses are shared pro-rata between redeemers and remaining LPs instead of allowing one side to absorb the full impact.

If assets per share falls after a redeem request is created, the claim uses the lower claim-time value.

Swap Fee Uses Cross Price

Swap fee accounting now calculates discount against the configured cross price rather than par value.

This aligns fee accrual with the ARM’s valuation price for the base asset.

Deployment, Task, and Test Updates

Includes updated deploy/upgrade scripts, ABIs, Hardhat tasks, diagrams, fork tests, smoke tests, invariant tests, and upgrade guard tests for the new architecture.

Gas Comparisons

Lido ARM

Scenario Gas Used Delta vs Current
Current deployed Lido ARM, enough ARM liquidity 110,041 -
Upgraded Lido ARM, enough ARM liquidity 145,926 +35,885 (+32.61%)
Upgraded Lido ARM, needs Morpho liquidity 573,729 +463,688 (+421.38%)

Ethena ARM

Scenario Gas Used Delta vs Current
Current deployed Ethena ARM, enough ARM liquidity 92,525 -
Upgraded Ethena ARM, enough ARM liquidity 137,573 +45,048 (+48.69%)
Upgraded Ethena ARM, needs Aave liquidity 262,190 +169,665 (+183.37%)

Testing

  • Unit tests for swap liquidity limits, fees, reserves, LP deposits/redeems, active-market liquidity sourcing, and adapter behavior
  • Fork tests for Lido, EtherFi, Ethena, Origin, and Harvester flows
  • Smoke tests for upgraded ARM deployments
  • Invariant tests for ARM accounting and redemption queues
  • Upgrade guard tests for Lido, EtherFi, and Ethena

@naddison36 naddison36 marked this pull request as ready for review April 29, 2026 02:38
@naddison36 naddison36 changed the title WIP Withdraw from lending market on swap if not enough liquidity Withdraw from lending market on swap if not enough liquidity Apr 29, 2026
@naddison36 naddison36 marked this pull request as draft April 29, 2026 04:08
@naddison36 naddison36 changed the title Withdraw from lending market on swap if not enough liquidity New ARM features before Ethena audit Apr 29, 2026
@naddison36 naddison36 marked this pull request as ready for review April 29, 2026 08:17
Comment thread src/contracts/AbstractARM.sol Outdated
Comment thread src/contracts/AbstractARM.sol Outdated
Comment thread src/contracts/AbstractARM.sol Outdated
buyLiquidityRemaining = buyAmount;
sellLiquidityRemaining = sellAmount;

emit TraderateChanged(traderate0, traderate1);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we have enough space on contract size, it could be worth for integrator/MEV to emit the buyLiquidityRemaining and sellLiquidityRemaining. WDYT?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in the past we have avoided emitted events in the swaps to save gas. But gas is becoming less important. We could add a new Swap event that includes the remaining liquidity.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant it for setPrices() not for swap, but it could make sense too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I've added in commit 28d4fb6

naddison36 and others added 6 commits May 5, 2026 21:05
* Using swap multiplier to calculate accrued fee

* Removed Maths lib to get contract size down
Removed lastAvailableAssets() function

* Improved gas usage of _accrueSwapFee by removing if swapFeeMultiplier is zero

* Fixed gap after storage variables were compacted

* Removed outToken from _accrueSwapFee

* Simplified _updateSwapFeeMultiplier

* More gas savings
Allow price liquidity cap to be unlimited

* Moved state changes before token transfers

* Update swap gas compare test
* WIP multi base assets

* - Move reusable multi-base asset config and swap accounting into AbstractARM
- Replace protocol-specific ARM redemption logic with generic asset adapters
- removed legacy max-cap sentinel handling
- Upgrade all ARMs to multi base assets
- Added wstETH to the Lido ARM
- Added weETH to EtherFi ARM

* Updated AI context

* Added wOETH to OETH ARM

* Updated ARM contract dependencies

* Updated Hardhat tasks

* Proxied the adapters

* Lido ARM upgrade checks

* Added Ethena upgrade guard

* Updated migration error

* More cleanup of legacy immutables

* fmt

* Update EtherFi ABI

* Update claimEthenaWithdrawals Hardhat task

* Fixed fork tests

* Fixed smoke tests

* Fix invariant tests

* Updated contract diagrams

* Renamed the ARM adapter-facing methods to requestBaseAssetRedeem and claimBaseAssetRedeem

* Used modifiers on adapters

* Pro-rata losses to redeemers and remaining LPs (#223)

* Pro-rata losses to redeemers and remaining LPs

* Calculate swap fee against cross price rather than par value (#224)
Comment on lines +1117 to +1126
function migrateLegacyWithdrawQueue() external onlyOwner {
require(withdrawsQueuedShares == 0 && withdrawsClaimedShares == 0, "ARM: already migrated");

uint256 packedLegacyQueue = reservedWithdrawLiquidity;
uint128 legacyQueued = uint128(packedLegacyQueue);
uint128 legacyClaimed = uint128(packedLegacyQueue >> 128);
require(legacyQueued == legacyClaimed, "ARM: legacy withdrawals pending");

reservedWithdrawLiquidity = 0;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 This function is not called as part of the deploy script. But reservedWithdrawLiquidity is used as part of requestRedeem, getReserves and claimRedeem, all those methods seem to have no checks to ensure that migration has happened. if there's a pending withdrawal, migration wouldn't work and claim wouldn't work either since it's overriding the slots, am I right?

Here's what Claude has to say when I asked it this question:

You're essentially right, but with some nuance worth tracing precisely. Let me walk through what actually happens.                                                                                                                               
                                                                                                                                                                                                                                                   
  The packed legacy slot                                                                                                                                                                                                                             
                                                                                                                                                                                                                                                     
  Pre-upgrade, the slot holds withdrawsQueued | (withdrawsClaimed << 128). Cases:                                                                                                                                                                    
                                                                                                                                                                                                                                                     
  - Fresh ARM, never had a queue: slot = 0. Migration trivially passes.                                                                                                                                                                              
  - ARM with pending requests, none claimed yet: low 128 = queued, high 128 = 0. Not bloated — reservedWithdrawLiquidity reads as just queued. The new code semi-coincidentally treats this as "amount reserved for withdrawals" — semantically    
  similar to the old meaning.                                                                                                                                                                                                                        
  - ARM with claim history: high 128 = cumulative claimed. Bloated. A real Lido ARM with months of claims will sit somewhere in the 10⁵⁰+ range.                                                                                                   
                                                                                                                                                                                                                                                     
  For the deployed Lido / EtherFi / Ethena ARMs that have been operating, you should assume bloated.                                                                                                                                                 
                                                                                                                                                                                                                                                     
  Your two claims                                                                                                                                                                                                                                    
                                                                                                                                                                                                                                                   
  "migration wouldn't work" — correct                                                                                                                                                                                                                
                                                                                                                                                                                                                                                   
  migrateLegacyWithdrawQueue requires legacyQueued == legacyClaimed (i.e. the packed slot must be X | X<<128). Any pending pre-upgrade LP request makes legacyQueued > legacyClaimed, and migration reverts.                                         
  
  The upgrade scripts (028 / 029 / 030 / 006) only check the protocol queue (checkNoLegacyLidoWithdrawalRequests checks _deprecatedLidoWithdrawalQueueAmount — the Lido NFT queue, not the LP queue). Nothing in the deploy data calls               
  migrateLegacyWithdrawQueue, and nothing prevents the upgrade going through with LP queue pending.                                                                                                                                                
                                                                                                                                                                                                                                                     
  "claim wouldn't work" — partially right, mechanism is more subtle                                                                                                                                                                                
                                     
  The legacy claim function is gone (old claimRedeem on LidoARM/etc is removed). Legacy entries can only be claimed through the new claimRedeem. Here's what happens for a legacy request post-upgrade:                                              
  
  - The struct grew (added uint128 shares). Legacy entries occupy slots 0–1; slot 2 (shares) reads as 0 — uninitialized.                                                                                                                             
  - claimRedeem line 750: assetsAtClaim = request.shares > 0 ? convertToAssets(request.shares) : request.assets → falls back to request.assets. OK.                                                                                                
  - _burn(address(this), request.shares) → burn 0. OK.                                                                                                                                                                                               
  - request.queued <= claimable() — legacy queued was in liquidity-asset terms, new claimable() is in share terms. For 1:1 share-asset ratios this passes numerically by coincidence; for non-1:1 (e.g. after gains/losses) the comparison is        
  semantically meaningless and could pass or fail wrongly.                                                                                                                                                                                           
  - reservedWithdrawLiquidity -= request.assets — subtracts a small number from the bloated value. Slot is still bloated.                                                                                                                            
                                                                                                                                                                                                                                                     
  So the LP actually gets their funds — claim "works" in the sense that they're paid. The catastrophe is elsewhere.                                                                                                                                  
                                                                                                                                                                                                                                                     
  The actual catastrophic consequence                                                                                                                                                                                                                
                                                                                                                                                                                                                                                   
  reservedWithdrawLiquidity being bloated (~10⁵⁰+) breaks four call sites:                                                                                                                                                                           
  
  1. _ensureLiquidityAvailableForSwap (line 444): requiredLiquidity = amount + reservedWithdrawLiquidity overflows past any plausible liquidityBalance, falls into the market-withdraw path, tries to pull 10⁵⁰ from the market, reverts. All        
  buy-side swaps are dead. Sell-side swaps don't call this, so they still work.                                                                                                                                                                    
  2. _allocate line 1051: currentArmLiquidity = balance - reservedWithdrawLiquidity is hugely negative → _allocate tries to withdraw an absurd amount from the market every time, reverts.                                                           
  3. _requireLiquidityAvailable (line 838): any positive amount fails → collectFees is dead.                                                                                                                                                         
  4. getReserves (line 821): reservedWithdrawLiquidity > liquidityAssets, so returns 0 → off-chain price routers see zero liquidity.                                                                                                                 
                                                                                                                                                                                                                                                     
  And then migration becomes permanently impossible                                                                                                                                                                                                  
                                                                                                                                                                                                                                                     
  migrateLegacyWithdrawQueue requires both withdrawsQueuedShares == 0 && withdrawsClaimedShares == 0. The instant any LP calls requestRedeem post-upgrade, withdrawsQueuedShares becomes non-zero forever. Even after that new request is claimed,   
  withdrawsClaimedShares is also non-zero forever. There is no path to call migrateLegacyWithdrawQueue once any new LP activity has happened.                                                                                                      
                                                                                                                                                                                                                                                     
  So the operational sequence required to land this upgrade safely is:                                                                                                                                                                             
                                     
  1. Drain LP queue (all pending claimed).                                                                                                                                                                                                           
  2. Drain protocol queue (Lido NFT etc).
  3. Freeze new LP activity.                                                                                                                                                                                                                         
  4. Upgrade.                                                                                                                                                                                                                                        
  5. Owner calls migrateLegacyWithdrawQueue immediately, before unfreezing.
  6. Resume.                                                                                                                                                                                                                                         
                                                                                                                                                                                                                                                   
  Steps 1, 3, and 5 are not enforced by code, not bundled into upgradeToAndCall, and not asserted by any test. Step 3 isn't even possible — there's no pause mechanism on AbstractARM (no Pausable inheritance, no whenNotPaused modifiers). The     
  owner can't freeze deposits/redeems atomically with the upgrade.                                                                                                                                                                                 
                                                                                                                                                                                                                                                     
  Verdict                                                                                                                                                                                                                                            
                                     
  You're right that this is a footgun. The right fix is one of:                                                                                                                                                                                      
                                                                                                                                                                                                                                                   
  - Bundle the migration into upgradeToAndCall with a multicall (upgradeToAndCallBatch or have the new impl expose a single initializeUpgrade() that both checks the protocol queue and clears the legacy slot in one call). Then step 3 is just     
  "land the upgrade tx".
  - Loosen the migration's pre-condition to "any time the legacy queued == claimed in the slot, even if new activity exists". This lets ops run migration whenever the legacy queue is balanced — though they still need to drain pending pre-upgrade
   requests first.                                                                                                                                                                                                                                   
  - Add a pause modifier to requestRedeem so the owner can freeze new activity between upgrade and migration.
                                                                                                                                                                                                                                                     
  I'm adding this as a new Critical finding (C6) to the review document, with severity equal to C5 — it's effectively the operational version of the same root cause: the migration assumes a clean state that the deploy scripts don't enforce.     
  

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm joining Shahul on this subject, and I think that someone can block us into the migration process.

  • Check the governance proposal
  • Deposit+request
  • never claim it
  • we are stuck.

Should we be able to claim on behalf of someone and remove this require on claimRedeem()? :

require(request.withdrawer == msg.sender, "Not requester");

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are correct. I missed making it so the Operator can claim a redeem on behalf of someone else. Fixed in commit 1988a98

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks good. But I also wonder if we should pause new requests until the migration happened. Otherwise, someone could theoretically create requests constantly to DoS from us upgrading it. Also, what about the storage slot overriding behaviour? If we delay the migration after upgrade, it'll mess up the storage slots

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm trying to squeeze in pause in PR #226

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps I'm missing something but I still think this is gonna mess up that storage slot: https://github.com/OriginProtocol/arm-oeth/blob/nicka/withdraw-on-swap/src/contracts/AbstractARM.sol#L756-L757

claimRedeem modifies that variable as well and it's not excluded during pause. This is fine for newly deployed ARM contracts. But for the upgrades, would be better to test this on fork first

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any claimRedeem will also make the migration to not run since it messes up that storage slot

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is annoying to address this issue while also battling with the contract size limits. There is another way this could be done which is the cleanest, but operationally very annoying:

  • upgrade the existing ARMs by adding the ability to pause (we would need to be mindful of deprecating this storage slot in this PR / future contract upgrade) and claim withdrawals on other user's behalf
  • once upgrade lands, and withdrawals are claimable claim all of them
  • upgrade the arms to this implementation where the withdrawal queues are drained. The migration call happens atomically in the deploy script's "upgradeToAndCall"

uint256 convertedAmountOut = _convertToAssets(config, amountOut);
// amountOut is converted to liquidity terms first, then multiplied by sellPrice
// to solve for the required liquidity input.
amountIn = convertedAmountOut * config.sellPrice / PRICE_SCALE + 3;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why add 3 wei? To accounting for rounding issues? Wouldn't 1 wei work in that case?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because stETH can round more with larger transfers. I've seen 2 wei. 3 wei was to be safe

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment about this would be good

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/contracts/AbstractARM.sol Outdated
}

function setUnstakers(address[MAX_UNSTAKERS] calldata _unstakers) external onlyOwner {
unstakers = _unstakers;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude flagged this:

EthenaAssetAdapter.setUnstakers blindly overwrites — can orphan pending requests

     function setUnstakers(address[MAX_UNSTAKERS] calldata _unstakers) external onlyOwner {
         unstakers = _unstakers;
     }

     If any slot currently holds an unstaker with a non-zero requestShares[unstaker] (still cooling down), replacing it makes the funds locked in the old unstaker's cooldown unrecoverable through this adapter — redeem loops over
     pendingUnstakerIndexes and reads unstakers[idx], which now points to a new (empty) address.

     Recommendation: require requestShares[oldUnstaker] == 0 for every slot being overwritten, OR add a replaceUnstaker(uint8 index, address newUnstaker) that does the check per-slot.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good one. Fixed in commit 6ef36ee

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good but I think we probably need to be cautious of the length of the unstaker array. If it's too long to fit in a block tx, we will be blocked from using this function without an upgrade

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAX_UNSTAKERS is currently set to 42. I think that's small enough

* legacy single-base storage so Lido, EtherFi, Ethena, and Origin ARMs can share this implementation.
* @author Origin Protocol Inc
*/
abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We usually have a method to recover tokens (transferToken), should we have something like that here for non-base assets?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are super tight on the contract size. That would definitely push the ARM contracts to be oversized

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, that makes sense

lastAvailableAssets -= SafeCast.toInt128(SafeCast.toInt256(assets));

// Escrow the redeemer's shares so they stay in totalSupply() and share losses/gains pro-rata.
_transfer(msg.sender, address(this), shares);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There seems to be no shares > 0 check here. This _transfer could fail for zero value transfers but having an explicit error would be nice

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_transfer itself allows zero-value transfers in OZ v5, but we should probably reject zero-share redeems explicitly so users cannot create empty withdrawal requests / advance the queue. The problem is we don't have much room to add this check.

The same goes for zero deposits in PR #229

I'll see if I can get the contract size down

Copy link
Copy Markdown
Contributor

@clement-ux clement-ux left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First review session focused on high level code architecture and not on security.

Comment thread src/contracts/AbstractARM.sol
Comment on lines +1117 to +1126
function migrateLegacyWithdrawQueue() external onlyOwner {
require(withdrawsQueuedShares == 0 && withdrawsClaimedShares == 0, "ARM: already migrated");

uint256 packedLegacyQueue = reservedWithdrawLiquidity;
uint128 legacyQueued = uint128(packedLegacyQueue);
uint128 legacyClaimed = uint128(packedLegacyQueue >> 128);
require(legacyQueued == legacyClaimed, "ARM: legacy withdrawals pending");

reservedWithdrawLiquidity = 0;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm joining Shahul on this subject, and I think that someone can block us into the migration process.

  • Check the governance proposal
  • Deposit+request
  • never claim it
  • we are stuck.

Should we be able to claim on behalf of someone and remove this require on claimRedeem()? :

require(request.withdrawer == msg.sender, "Not requester");

Comment thread src/contracts/AbstractARM.sol
Comment thread src/contracts/LidoARM.sol
@clement-ux
Copy link
Copy Markdown
Contributor

Another idea to decrease the size of the contract could be to move the lending part into an external contract, like you did for the Adapter. Or to reduce gas, to have the following logic in the MorphoMarket.sol:

  • addMarkets()
  • removeMarket()
  • setActiveMarket()
  • allocate() and _allocate()

It shouldn't increase gas cost for swap, even for large swap that need to withdraw from lending market.

Comment on lines +353 to 373

if (!isBuySide) {
// Trader sells liquidity asset and buys the base asset.
// The ARM buys the liquidity asset and sells the base asset.
// ARM prices the base sale at sellPrice.
uint256 convertedAmountIn = _convertToShares(config, amountIn);
// sellPrice is liquidity assets per base asset, so divide liquidity input by sellPrice
// to get the base output owed to the trader.
amountOut = convertedAmountIn * PRICE_SCALE / config.sellPrice;
} else {
revert("ARM: Invalid in token");
// Trader sells base asset and buys the liquidity asset.
// The ARM buys the base asset and sells the liquidity asset.
// ARM prices the base purchase at buyPrice.
uint256 convertedAmountIn = _convertToAssets(config, amountIn);
// buyPrice is liquidity assets per base asset. Since convertedAmountIn is the
// base input expressed in liquidity terms, multiply by buyPrice to get liquidity output.
amountOut = convertedAmountIn * config.buyPrice / PRICE_SCALE;

_accrueSwapFee(config.buyPrice, config.crossPrice, amountOut);
_ensureLiquidityAvailableForSwap(amountOut);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this can be simplified into:

uint256 convertedAmountIn = _convertToShares(config, amountIn);
if (!isBuySide) {
    // Trader sells liquidity asset and buys the base asset.
    // The ARM buys the liquidity asset and sells the base asset.
    // ARM prices the base sale at sellPrice.
    // sellPrice is liquidity assets per base asset, so divide liquidity input by sellPrice
    // to get the base output owed to the trader.
    amountOut = convertedAmountIn * PRICE_SCALE / config.sellPrice;
} else {
    // Trader sells base asset and buys the liquidity asset.
    // The ARM buys the base asset and sells the liquidity asset.
    // ARM prices the base purchase at buyPrice.
    // buyPrice is liquidity assets per base asset. Since convertedAmountIn is the
    // base input expressed in liquidity terms, multiply by buyPrice to get liquidity output.
    amountOut = convertedAmountIn * config.buyPrice / PRICE_SCALE;

    _accrueSwapFee(config.buyPrice, config.crossPrice, amountOut);
    _ensureLiquidityAvailableForSwap(amountOut);
}

Comment on lines +500 to +501
uint256 feeMultiplier =
buyPrice == 0 ? 0 : (crossPrice - buyPrice) * uint256(fee) * PRICE_SCALE / (buyPrice * FEE_SCALE);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we already tested it before, should we store this value and update it at every call of setPrices() and setFee(). Like this we avoid this calculation at every swap and thus reduce gas cost for a swap.

/// @param isBuySide True when the ARM buys base asset and pays out liquidity asset.
/// @param amountOut Amount of output token sent by the ARM.
function _consumeSwapLiquidityLimit(BaseAssetConfig storage config, bool isBuySide, uint256 amountOut) internal {
uint256 remaining = isBuySide ? config.buyLiquidityRemaining : config.sellLiquidityRemaining;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we plan having config.buyLiquidityRemaining and config.sellLiquidityRemaining set to type(uint256).max? For example in a situation where we want to disable swap liquidity limits?

If so what do you think about returning earlier, to avoid writing new value?

/// @notice Clear the legacy packed asset queue counter slot during the Model A upgrade.
/// @dev The reused slot previously packed `withdrawsQueued` in the low 128 bits and
/// `withdrawsClaimed` in the high 128 bits. It may be nonzero even when the old queue
/// is fully drained, so upgrade scripts should call this with `upgradeToAndCall`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: comment upgrade scripts don't call this in "upgradeToAndCall"

Comment on lines +1117 to +1126
function migrateLegacyWithdrawQueue() external onlyOwner {
require(withdrawsQueuedShares == 0 && withdrawsClaimedShares == 0, "ARM: already migrated");

uint256 packedLegacyQueue = reservedWithdrawLiquidity;
uint128 legacyQueued = uint128(packedLegacyQueue);
uint128 legacyClaimed = uint128(packedLegacyQueue >> 128);
require(legacyQueued == legacyClaimed, "ARM: legacy withdrawals pending");

reservedWithdrawLiquidity = 0;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is annoying to address this issue while also battling with the contract size limits. There is another way this could be done which is the cleanest, but operationally very annoying:

  • upgrade the existing ARMs by adding the ability to pause (we would need to be mindful of deprecating this storage slot in this PR / future contract upgrade) and claim withdrawals on other user's behalf
  • once upgrade lands, and withdrawals are claimable claim all of them
  • upgrade the arms to this implementation where the withdrawal queues are drained. The migration call happens atomically in the deploy script's "upgradeToAndCall"

address[] calldata path,
address to,
uint256 deadline
) external virtual returns (uint256[] memory amounts) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this one should be non-reentrant

address[] calldata path,
address to,
uint256 deadline
) external virtual returns (uint256[] memory amounts) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this one should be non-reentrant

amountOut = convertedAmountIn * config.buyPrice / PRICE_SCALE;

_accrueSwapFee(config.buyPrice, config.crossPrice, amountOut);
_ensureLiquidityAvailableForSwap(amountOut);
Copy link
Copy Markdown
Member

@sparrowDom sparrowDom May 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 _ensureLiquidityAvailableForSwap can do an external 4626 call and _consumeSwapLiquidityLimit only later on changes the storage state. A maicious 4626 market could re-enter this function and cause damage.

Maybe move _consumeSwapLiquidityLimit(config, isBuySide, amountOut); into the if statement:

 if (!isBuySide) {
    // Trader sells liquidity asset and buys the base asset.
    // The ARM buys the liquidity asset and sells the base asset.
    // ARM prices the base sale at sellPrice.
    uint256 convertedAmountIn = _convertToShares(config, amountIn);
    // sellPrice is liquidity assets per base asset, so divide liquidity input by sellPrice
    // to get the base output owed to the trader.
    amountOut = convertedAmountIn * PRICE_SCALE / config.sellPrice;
   _consumeSwapLiquidityLimit(config, isBuySide, amountOut);
} else {
    revert("ARM: Invalid in token");
    // Trader sells base asset and buys the liquidity asset.
    // The ARM buys the base asset and sells the liquidity asset.
    // ARM prices the base purchase at buyPrice.
    uint256 convertedAmountIn = _convertToAssets(config, amountIn);
    // buyPrice is liquidity assets per base asset. Since convertedAmountIn is the
    // base input expressed in liquidity terms, multiply by buyPrice to get liquidity output.
    amountOut = convertedAmountIn * config.buyPrice / PRICE_SCALE;

    _accrueSwapFee(config.buyPrice, config.crossPrice, amountOut);
    _consumeSwapLiquidityLimit(config, isBuySide, amountOut);
    _ensureLiquidityAvailableForSwap(amountOut);
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants