Files
zeekayandHanzo Dev 781d9312b6 fix the audit's ship blockers and give the fuzzing campaign a risk engine to test
The engine could mint synthetic that could never be burned, valued collateral
from an unbounded admin-swappable oracle, priced unrelated assets against each
other as if they were the same, could not liquidate anything, and compiled
1.3x past the EIP-170 limit. The invariant suite passed over all of it.

Engine:
- Debt tokens are reached through ISynthetic (mint / burn / burnFrom), matching
  what the token family now exposes.
- A market is bound to its adapter at initialize and on every swap: the adapter
  must name this market's own yield and underlying token. Price is admitted
  through a per-block deviation cap, anchored on the last accepted value, so a
  swap or a compromised feed cannot teleport collateral value, and a same-block
  move cannot move borrowing power at all. A dead adapter leaves the last good
  price standing rather than marking every position worthless.
- The three collateralization levels are checked as one ordering wherever any
  of them is set, so the global floor can no longer sit above the mint bar and
  route every liquidation through full seizure. The fee-vault bonus fails soft
  when no vault is set: liquidation clears the position either way.
- _forceRepay derives debt relief from the clamped credit, so the transmuter
  can no longer be handed less than the debt retired.
- claimRedemption returns a shortfall instead of burning through it, and the
  bad-debt ratio is taken in debt units, rounded up.
- Deposits and new debt are refused while the protocol is short, and the state
  is observable through inBadDebt().
- getMaxBorrowable saturates at zero instead of underflowing; Liquidated is
  emitted from one place, so batch and single liquidations report alike.

Markets are like-kind by deployment: bridged ETH backs LETH, bridged BTC backs
LBTC, WLUX backs LLUX. The engine converts underlying to debt with a decimals
scalar and holds no cross-asset price source, so the mainnet script's LETH
debt against WLUX collateral was a drain bounded only by the deposit cap.
DeployL2 was a byte-identical copy of DeployBrandL1 carrying the same market;
DeployLux could not execute at all. Both are gone.

Yul optimizer on: Liquid goes from 32,268 bytes to 22,589, under EIP-170 with
room to spare. Tests read the block through the cheatcode rather than the
opcode -- block.number is constant within a real transaction, so the optimizer
is entitled to fold it, and vm.roll is not.

Testing. Every audit finding is a regression test in src/test/Audit.t.sol,
kept in the shape of the exploit it began as, including an 8-decimal BTC market
for the 10^10 conversion factor. The invariant suite gains price movement,
strategy loss and both liquidation paths -- and, more to the point, a borrow
path: _randomMinter discarded the borrow ceiling it queried, so no account in
that suite had ever carried debt, liquidate reverted on all 4096 calls of every
campaign, and the invariants described a protocol nobody had borrowed from.
Stakers now stake synthetic they borrowed rather than minting it, which is what
the transmuter's claim on the protocol rests on.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-25 12:34:16 -07:00

14 KiB

liquid

Liquid Protocol

License: MIT Solidity Foundry

Self-repaying lending on Lux. Deposit yield-bearing collateral, borrow synthetic debt against it, and let yield retire the debt over time. No liquidation spirals, no manual repayments — the position pays itself off.

  • Core: src/Liquid.sol — lending engine (UUPS-style proxy, initialize pattern)
  • Synthetic debt: LETH (alETH-style 1:1-pegged synthetic), redeemable for the underlying via the Transmuter on a time-decay schedule
  • Yield routing: VaultV2 + curated strategy adapters (Aave, Compound, Lido, EtherFi, Pendle, Morpho, Yearn, Ethena, EigenLayer, Maker DSR, plus native Lux-side strategies)
  • Compliance: optional LiquidCompliance for whitelisted/KYC'd redemption flows on regulated collateral

How it works

                              ┌──────────────────────────┐
   user deposits yield asset  │        Liquid.sol        │  mints LETH
   ─────────────────────────▶ │   (lending + position)   │ ─────────────▶ user
                              └─────────┬────────────────┘
                                        │ accounts position as ERC-721
                                        ▼
                              ┌──────────────────────────┐
                              │     LiquidPosition       │   NFT id ↔ owner
                              └──────────────────────────┘
                                        │
                                  yield │ (continuous)
                                        ▼
                              ┌──────────────────────────┐
                              │     LiquidTransmuter     │  burns LETH,
   LETH holders stake here ──▶│  (Fenwick / StakingGraph)│  releases yield-token
                              └──────────────────────────┘
  1. Deposit a yield-bearing asset (wstETH, sfrxETH, weETH, sDAI, …) into Liquid. You receive an ERC-721 position via LiquidPosition.
  2. Mint LETH against the position up to the protocol's collateralization floor. LETH is a transferable, fungible synthetic.
  3. Yield earned by the deposited collateral is harvested into the LiquidTransmuter, which burns LETH 1:1 against the yield asset over time.
  4. Self-repayment: as yield accrues, the position's debt is automatically reduced — no liquidation needed as long as the collateral keeps yielding.
  5. Transmute: LETH holders can stake LETH in the Transmuter to redeem the underlying yield asset on a linear, block-weighted schedule (Fenwick tree accounting in StakingGraph).

Risk is bounded because debt is always backed by the same asset that produces the yield repaying it. Liquidations exist as a backstop for tail-risk de-pegging only.


Contracts

Core lending

Contract Purpose
Liquid.sol Lending engine. Holds positions, mints/burns synthetic debt, harvests yield, enforces collateralization. UUPS-initializable; constructor empty.
LiquidPosition.sol ERC-721 representing a user's collateral+debt position. Only Liquid can mint/burn.
LiquidTransmuter.sol Converts synthetic LETH → underlying yield-token over time. Uses a Fenwick tree (StakingGraph) for O(log n) per-block accrual. ERC-721 tickets per stake.

Vaults & fees

Contract Purpose
LiquidETHVault.sol Native ETH/WETH fee vault. Owner-gated withdrawals, anyone-deposits.
LiquidTokenVault.sol ERC-20 fee vault counterpart.
adapters/AbstractFeeVault.sol Shared base — auth, accounting, events.

Yield routing

Contract Purpose
LiquidStrategy.sol Base strategy that wraps a vault-v2 adapter. APR/APY snapshotting, kill-switch, allocator whitelist, 0x-swap verification via ZeroXSwapVerifier.
LiquidCurator.sol DAO-only contract that sets absolute and relative caps on each adapter. Permissioned proxy pattern.
LiquidAllocator.sol Routes capital from the underlying VaultV2 into approved strategy adapters. Per-strategy allocation cap.
LiquidStrategyClassifier.sol Risk-class registry (per-class globalCap/localCap).
LiquidGauge.sol Token-weighted gauge for voting strategy weights; keeper executes the weighted allocation each epoch.

Strategy adapters (src/strategies/)

DeFi blue-chips: AaveV3Strategy, CompoundV3Strategy, LidoStrategy (stETH/wstETH), EETH (EtherFi weETH), SfrxETH (Frax), MakerDSRStrategy (sDAI), MorphoStrategy, MorphoYearnOGWETH, YearnV3Strategy, PendleStrategy, EthenaStrategy (sUSDe), EigenLayerStrategy, PeapodsETH, TokeAutoEth, plus LuxNative for Lux-chain native staking.

Each adapter is a thin wrapper that conforms to ITokenAdapter and is risk-classified by the classifier before the curator approves it for allocation.

Governance

Contract Purpose
governance/LiquidToken.sol Governance token (LIQUID).
governance/LiquidGovernor.sol OZ Governor over LiquidToken. Sets caps, classes, transmuter parameters, and curator/allocator admins.

Compliance & access

Contract Purpose
LiquidGate.sol Per-vault, per-account allowlist for redemptions.
LiquidCompliance.sol KYC-tier gate (0=none, 1=basic, 2=accredited Reg D, 3=qualified purchaser Reg S). Set per-vault required level. Used for regulated-collateral deployments.
adapters/SecurityTokenAdapter.sol Adapter for tokenized securities collateral, paired with the compliance gate.
utils/Whitelist.sol Generic whitelist primitive.
utils/PermissionedProxy.sol Selector-gated proxy used by curator/allocator.

Libraries

PositionDecay, StakingGraph (Fenwick tree), FixedPointMath, SafeCast, SafeERC20, TokenUtils, Sets, NFTMetadataGenerator.


Canonical deployments (Lux mainnet)

Token Address
LETH (synthetic) 0x60E0a8167FC13dE89348978860466C9ceC24B9ba
WLUX 0x4888E4a2Ee0F03051c72D2BD3ACf755eD3498B3E
LBTC 0x1E48D32a4F5e9f08DB9aE4959163300FaF8A6C8e

Brand L1s deploy through script/DeployBrandL1.s.sol, one market per run, on Lux mainnet/testnet/devnet and local dev 1337/31337.


Quickstart

git clone https://github.com/luxfi/liquid.git
cd liquid
forge install
forge build
forge test -vv

Run a full local stack

End-to-end deploy + smoke flow on Anvil or a local luxd:

anvil --chain-id 1337 &
forge script script/DeployLocal.s.sol --rpc-url http://127.0.0.1:8545 --broadcast
forge script script/TestFlow.s.sol    --rpc-url http://127.0.0.1:8545 --broadcast

DeployLocal brings up: Liquid + Position + Transmuter + ETHVault + Curator + StrategyClassifier + ComplianceGate, plus dev tokens (WLUX, LUSD, mock securities) and a SecurityTokenAdapter so the regulated-collateral path is exercisable locally.

Deploy to a network

make deploy-devnet    # api.lux-dev.network
make deploy-testnet   # api.lux-test.network
make deploy-mainnet   # api.lux.network (uses canonical LETH/WLUX/LBTC)

Set LUX_MNEMONIC in .env. Add --verify (and the appropriate verifier key) for explorer verification.


Deployment order (canonical)

// 1. core
Liquid liquid                = new Liquid();                              // empty ctor
LiquidPosition position      = new LiquidPosition(address(liquid));
LiquidTransmuter transmuter  = new LiquidTransmuter(transmuterParams);    // needs debtToken
LiquidETHVault feeVault      = new LiquidETHVault(WETH, address(liquid), owner);

// 2. wire
liquid.initialize(LiquidInitializationParams({ ... transmuter: address(transmuter) ... }));
liquid.setLiquidPositionNFT(address(position));
transmuter.setLiquid(address(liquid));

// 3. authorize the engine to mint synthetic debt
ILiquidMintable(LETH).setMinter(address(liquid), true);

// 4. yield routing (optional but typical)
LiquidStrategyClassifier classifier = new LiquidStrategyClassifier(admin);
LiquidCurator curator               = new LiquidCurator(admin, operator);
LiquidAllocator allocator           = new LiquidAllocator(vaultV2, admin, operator);

See script/DeployMainnet.s.sol for the full, parameterized version with collateralization floors, fee BPS, and block-rate constants.


Building on Liquid

Open a position (yield-bearing collateral → LETH)

IERC20(yieldToken).approve(address(liquid), amount);
uint256 tokenId = 0;                              // 0 = mint a new position NFT
uint256 shares  = liquid.deposit(amount, msg.sender, tokenId);
liquid.mint(tokenId, debtAmount, msg.sender);     // borrow LETH against position

Repay or withdraw

liquid.burn(tokenId, repayAmount);                // burn LETH, reduce debt
liquid.repay(tokenId, repayAmount);               // alt: repay in underlying yield-asset
liquid.withdraw(tokenId, shares, msg.sender);

Transmute LETH → underlying

IERC20(LETH).approve(address(transmuter), amount);
uint256 ticketId = transmuter.createRedemption(amount);
// ... time passes ...
transmuter.claimRedemption(ticketId);             // proportionally vested

Add a strategy adapter

  1. Implement ITokenAdapter (or extend LiquidStrategy for VaultV2-backed flows).
  2. Register risk class with LiquidStrategyClassifier.setStrategyRiskLevel(strategyId, riskLevel).
  3. Curator sets caps: LiquidCurator.setCaps(adapter, abs, rel).
  4. Allocator routes flow: LiquidAllocator.allocate(adapter, data, assets).
  5. (Optional) wire into LiquidGauge for token-vote-weighted allocation.

Deploy a regulated variant

Pair SecurityTokenAdapter with LiquidCompliance:

gate.setRequiredLevel(vault, 2);                  // accredited (Reg D)
gate.approve(investor, /*level*/ 2);
gate.setAuthorization(vault, investor, true);

The standard ERC-20 LETH path stays open for deposits; redemption and collateral-backed mints route through the gate.


Build, test, audit

make build         # forge build
make test          # forge test --summary
make test-fuzz     # fuzz suite (1000 runs)
make coverage      # IR-min coverage summary
make halmos        # symbolic execution on `check_*` properties

# static analysis (slither + semgrep + aderyn, set up via uv venv)
make security
make audit         # lint + test + security

Liquid.t.sol, LiquidTransmuter.t.sol, fuzz/, and integration tests live in src/test/. Fork-mode strategy tests (SfrxETH, MorphoYearnOGWETH, PeapodsETH, TokeAutoEth, IntegrationTest) require MAINNET_RPC_URL.


Layout

src/
  Liquid.sol                     LiquidPosition.sol     LiquidTransmuter.sol
  LiquidETHVault.sol             LiquidTokenVault.sol
  LiquidStrategy.sol             LiquidCurator.sol      LiquidAllocator.sol
  LiquidStrategyClassifier.sol   LiquidGauge.sol
  LiquidGate.sol                 LiquidCompliance.sol
  adapters/      AbstractFeeVault, EulerUSDCAdapter, SecurityTokenAdapter
  governance/    LiquidToken, LiquidGovernor
  strategies/    Aave/Compound/Lido/EETH/SfrxETH/Pendle/Morpho/Yearn/Ethena/…
  libraries/     StakingGraph, PositionDecay, FixedPointMath, SafeCast, …
  interfaces/    ILiquid*, ITokenAdapter, IWETH, IYieldToken, …
  utils/         PermissionedProxy, Whitelist, ZeroXSwapVerifier
  external/      interfaces
  test/          unit + fuzz + integration
script/
  DeployBrandL1.s.sol any brand L1, one like-kind market per run
  DeployMainnet.s.sol Lux mainnet ETH market, canonical LETH
  DeployLocal.s.sol   end-to-end local stack on Anvil/luxd
  TestFlow.s.sol      smoke test against a deployed local stack
lib/
  forge-std, openzeppelin-{contracts,upgradeable}, vault-v2,
  permit2, solmate, chainlink-brownie-contracts, halmos-cheatcodes

Versioning

IVersioned is implemented across core contracts. Liquid + Transmuter are at 3.0.0. Update only the version of the contract that changed.

Audits

Drop received reports under audits/ at the repo root. Static analyzers (Slither, Semgrep, Aderyn) and Halmos symbolic execution run via make security / make halmos.

Security

Disclosure: see SECURITY.md.

License

MIT — see LICENSE.