feat(ARM): support 6 or 18 decimal liquidity and base assets#282
Draft
clement-ux wants to merge 2 commits into
Draft
feat(ARM): support 6 or 18 decimal liquidity and base assets#282clement-ux wants to merge 2 commits into
clement-ux wants to merge 2 commits into
Conversation
AbstractARM previously hard-required every asset to be 18 decimals (`require(decimals() == 18)` in the constructor and a `!= 18` revert in `addBaseAsset`). This adds support for 6-decimal assets (e.g. USDC) for the liquidity asset and for each base asset, in any combination (liquidity 6/18 x each base 6/18, pegged or not). Design: keep all accounting in the liquidity asset's native decimals and apply the only decimal adjustment at the base<->liquidity boundary, in the pegged-conversion path (`_convertToAssets`/`_convertToShares`). Prices stay 1e36 value ratios. LP shares stay at 18 decimals for strong inflation-attack resistance (1e12 dead shares) and a uniform LP token across all ARMs. Every added path is a no-op when decimals are 18, so existing 18:18 deployments behave byte-identically and swaps take zero extra gas. - constructor: accept 6 or 18, store `liquidityAssetDecimals` - split `MIN_TOTAL_SUPPLY` (1e12 dead shares, 18 dec) from `MIN_LIQUIDITY` (native-liquidity floor used by totalAssets/insolvency/cross-price/init) - `BaseAssetConfig`: add `baseAssetDecimals`, packed with `adapter` - `addBaseAsset`: validate 6/18, allow pegged base with mismatched decimals - pegged conversions scale by 1e12 only when base/liquidity decimals differ
Adding `baseAssetDecimals` to `BaseAssetConfig` widened the public `baseAssetConfigs` getter tuple from 8 to 9 fields. Update the positional destructuring across the unit, fork, smoke and invariant suites (new `baseAssetDecimals` at index 7, `adapter` shifts to index 8). The Ethena smoke test still runs against the live 031 implementation (8-field struct), so read its config via a low-level staticcall and decode only the prefix up to `crossPrice` (index 4 / slot 2, unchanged across both layouts) to stay robust to either ABI. Admin unit test: 6 is now a valid decimals value, so assert the stored `baseAssetDecimals` and switch the invalid-decimals revert case to an 8-decimal token.
naddison36
reviewed
Jun 26, 2026
naddison36
left a comment
Collaborator
There was a problem hiding this comment.
first pass looks good
| liquidityAssetDecimals = decimals_; | ||
| // Native-liquidity floor. 1e12 for an 18-decimal asset keeps existing deployments unchanged; | ||
| // 1 for a 6-decimal asset is the same 1e-6-token magnitude. | ||
| MIN_LIQUIDITY = decimals_ == 18 ? 1e12 : 1; |
Collaborator
There was a problem hiding this comment.
I initially though 1 was too small to prevent donation attacks when there are no assets in the ARM.
On my nicka/paxos branch, I used 1e15 = 0.001 of an 18-decimal asset and 1,000 = 0.001 of a 6-decimal asset.
This would mean MIN_TOTAL_SUPPLY would have to be increased from 1e12 to 1e15.
But looking at the maths, a MIN_TOTAL_SUPPLY of 1e12 prevents the donation attack minting with zero shares.
For a 6-decimal asset:
MIN_LIQUIDITY = 1 unit = 0.000001 token
dead shares = 1e12 units = 0.000001 LP share
So initial rate is balanced:
1 asset unit : 1e12 share units
1 full token : 1e18 share units
After a donation D, a 1-token deposit mints:
shares = 1e6 * 1e12 / (1 + D)
To round this to zero:
D > 1e18 units = 1e12 tokens
So the attacker would need to donate about 1 trillion tokens to break a 1-token first deposit. MIN_LIQUIDITY = 1 is OK.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
AbstractARMhard-required every asset to be 18 decimals (require(IERC20(_liquidityAsset).decimals() == 18)in the constructor, and a!= 18revert inaddBaseAsset). To deploy ARMs on stablecoins like USDC (6 decimals) we need to support 6-decimal assets — for the liquidity asset and for each base asset, in any combination (liquidity 6/18 × each base 6/18, pegged or not).This builds on @OriginProtocol's earlier start (#274). That PR kept native-decimal accounting but forbade a pegged base asset whose decimals differ from the liquidity asset. This PR closes that gap and makes the support complete, while keeping the change small and swap gas untouched.
Design choices (and why)
We explored three approaches during review:
Full 18-decimal normalization everywhere — convert every native amount to 18 decimals at every boundary, keep all internal state in 18 dec.
❌ Rejected: ~3× larger diff, adds scaling on every swap, and is mathematically equivalent to the option below (the
1e12factor is applied either way) — so it costs gas for zero functional gain. The only upside was cosmetic uniformity.Native accounting, scale only at the base↔liquidity boundary ✅ chosen.
All accounting stays in the liquidity asset's native decimals. The only decimal adjustment lives in the pegged path of
_convertToAssets/_convertToShares(×/÷1e12). Prices remain pure1e36value ratios. Non-pegged base assets delegate decimals to their adapter (unchanged contract).return amountwhenbaseDecimals == liquidityDecimals.totalAssets()/views stay in native units (standard ERC-4626) → CapManager, Zappers and off-chain stay on the same unit.LP share decimals = liquidity decimals (as in WIP Stables ARM for Paxos #274) vs 18 decimals.
✅ Chose 18 decimals (no
decimals()override). For a 6-decimal liquidity asset this gives much stronger inflation-attack resistance — dead shares are1e12instead of1e3— and a uniform LP token across all ARMs. A 1 USDC deposit still mints ~1.0 LP token because the native/share decimals cancel inconvertToShares/Assets.Scope: strictly 6 or 18 decimals are allowed (anything else reverts).
What changed (
AbstractARM.solonly)InvalidLiquidityAssetDecimalsotherwise), storeliquidityAssetDecimals.MIN_TOTAL_SUPPLYsplit into two concepts (they no longer share decimals):MIN_TOTAL_SUPPLY=1e12constant — dead LP shares minted at init (18 dec).MIN_LIQUIDITYimmutable — native-liquidity floor used bytotalAssets()/insolvency/cross-price checks and pulled at init.1e12for 18-dec (unchanged from before),1for 6-dec (same 1e-6-token magnitude).BaseAssetConfig: adduint8 baseAssetDecimals, packed for free in theadapterslot.addBaseAsset: validate 6/18; a pegged base asset is no longer required to match the liquidity decimals (the conversion handles the mismatch)._convertToAssets/_convertToShares: pegged path scales by1e12only when base/liquidity decimals differ (_scaleBaseToLiquidity/_scaleLiquidityToBase); identity otherwise. Non-pegged path unchanged.Everything else —
deposit,redeem,claim, fees,allocate,totalAssets,_availableAssets, swaps,setPrices,getReserves— is unchanged.Coverage
Verified for all 4 combinations × {pegged, non-pegged} × {exactIn, exactOut}:
base/1e12↔×1e12base×1e12↔/1e12Multiple base assets with different decimals on one ARM are supported (each
BaseAssetConfigcarries its ownbaseAssetDecimals; all are converted to liquidity-native before summing intotalAssets).Active-market value (
_availableAssets) is already correct:addMarketsenforcesmarket.asset() == liquidityAsset, and ERC-4626convertToAssetsreturns amounts denominated inasset()regardless of the market share token's decimals.Rounding / safety
All decimal truncation (the
/1e12divides) rounds down, leaving sub-unit dust in the ARM (favoring LPs), and only affects the "liquidity coarser than base" case by < 1 native wei. No correctness or economic impact.Non-regression
For 18-decimal deployments every added path is a no-op:
MIN_LIQUIDITY == MIN_TOTAL_SUPPLY == 1e12, nodecimals()override, pegged conversions returnamount,allocateThresholdstays native. Behavior is byte-identical to before.Out of scope / follow-ups
AbstractARM.sol). ThebaseAssetConfigspublic getter tuple grew from 8 → 9 fields (addedbaseAssetDecimalsbeforeadapter), so positional destructuring in tests (Shared.sol,Helpers.sol, smoke/invariant suites) and the off-chainarm.jstoConfigObjectpositional fallback need a one-field update, plus ABI regeneration. Until then the full test suite does not compile, so the non-regression suite could not be run here (the 18-dec equivalence holds by construction, as noted above).