Skip to content

feat(ARM): support 6 or 18 decimal liquidity and base assets#282

Draft
clement-ux wants to merge 2 commits into
clement/ether-arm-fresh-deployfrom
clement/support-6-decimal-liquidity-assets
Draft

feat(ARM): support 6 or 18 decimal liquidity and base assets#282
clement-ux wants to merge 2 commits into
clement/ether-arm-fresh-deployfrom
clement/support-6-decimal-liquidity-assets

Conversation

@clement-ux

Copy link
Copy Markdown
Collaborator

Why

AbstractARM hard-required every asset to be 18 decimals (require(IERC20(_liquidityAsset).decimals() == 18) in the constructor, and a != 18 revert in addBaseAsset). 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:

  1. 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 1e12 factor is applied either way) — so it costs gas for zero functional gain. The only upside was cosmetic uniformity.

  2. Native accounting, scale only at the base↔liquidity boundarychosen.
    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 pure 1e36 value ratios. Non-pegged base assets delegate decimals to their adapter (unchanged contract).

    • 0 extra swap gas for existing 18:18 ARMs: the pegged path short-circuits to return amount when baseDecimals == liquidityDecimals.
    • Smallest diff (+56/−19, one file).
    • totalAssets()/views stay in native units (standard ERC-4626) → CapManager, Zappers and off-chain stay on the same unit.
  3. 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 are 1e12 instead of 1e3 — and a uniform LP token across all ARMs. A 1 USDC deposit still mints ~1.0 LP token because the native/share decimals cancel in convertToShares/Assets.

Scope: strictly 6 or 18 decimals are allowed (anything else reverts).

What changed (AbstractARM.sol only)

  • Constructor: accept 6 or 18 (InvalidLiquidityAssetDecimals otherwise), store liquidityAssetDecimals.
  • MIN_TOTAL_SUPPLY split into two concepts (they no longer share decimals):
    • MIN_TOTAL_SUPPLY = 1e12 constant — dead LP shares minted at init (18 dec).
    • MIN_LIQUIDITY immutable — native-liquidity floor used by totalAssets()/insolvency/cross-price checks and pulled at init. 1e12 for 18-dec (unchanged from before), 1 for 6-dec (same 1e-6-token magnitude).
  • BaseAssetConfig: add uint8 baseAssetDecimals, packed for free in the adapter slot.
  • 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 by 1e12 only 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}:

Liquidity Base Pegged Non-pegged
18 18 identity (0 gas) adapter (current behavior)
6 6 identity (0 gas) adapter
6 18 base/1e12×1e12 adapter
18 6 base×1e12/1e12 adapter

Multiple base assets with different decimals on one ARM are supported (each BaseAssetConfig carries its own baseAssetDecimals; all are converted to liquidity-native before summing in totalAssets).

Active-market value (_availableAssets) is already correct: addMarkets enforces market.asset() == liquidityAsset, and ERC-4626 convertToAssets returns amounts denominated in asset() regardless of the market share token's decimals.

Rounding / safety

All decimal truncation (the /1e12 divides) 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, no decimals() override, pegged conversions return amount, allocateThreshold stays native. Behavior is byte-identical to before.

Out of scope / follow-ups

  • Tests not updated (per request, this PR touches only AbstractARM.sol). The baseAssetConfigs public getter tuple grew from 8 → 9 fields (added baseAssetDecimals before adapter), so positional destructuring in tests (Shared.sol, Helpers.sol, smoke/invariant suites) and the off-chain arm.js toConfigObject positional 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).
  • New 6-decimal deploy script + adapter + decimal tests (USDC) to be added separately.

Stacked on clement/ether-arm-fresh-deploy (the fresh-deploy ARM). Base this PR on that branch; it retargets to main automatically once the base merges.

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 naddison36 left a comment

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.

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;

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.

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.

@clement-ux clement-ux marked this pull request as draft June 26, 2026 10:44
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.

2 participants