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

4.4 KiB

Lux Liquid

Overview

Self-repaying lending protocol on Lux. Users deposit yield-bearing collateral, borrow synthetic debt tokens (e.g. LETH), and debt is automatically repaid via yield. Includes a Transmuter for converting synthetics back to yield tokens over time.

Tech Stack

  • Language: Solidity 0.8.28
  • Framework: Foundry (forge, cast)
  • EVM: Cancun
  • Dependencies: OpenZeppelin 5.x, forge-std, vault-v2, permit2, solmate

Build & Run

forge build           # Compile all contracts
forge test -v         # Run tests
forge test --match-path "src/test/Liquid.t.sol" -v  # Run specific test

Key Contracts

  • Liquid.sol -- Core lending engine (Initializable proxy pattern, empty constructor + initialize)
  • LiquidPosition.sol -- ERC721 NFT representing user positions (constructor takes liquid address)
  • LiquidTransmuter.sol -- Converts synthetic tokens to yield tokens over time (Fenwick tree / StakingGraph)
  • LiquidETHVault.sol -- ETH/WETH fee vault (constructor: weth, liquid, owner)
  • LiquidTokenVault.sol -- ERC20 fee vault
  • LiquidCurator.sol -- Strategy cap management (constructor: admin, operator)
  • LiquidAllocator.sol -- Capital allocation to strategies (constructor: vault-v2, admin, operator)
  • LiquidStrategyClassifier.sol -- Risk class management (constructor: admin)
  • LiquidStrategy.sol -- Base strategy adapter for VaultV2 flows
  • LiquidGauge.sol -- Governance gauge for strategy weight voting
  • LiquidGate.sol / LiquidCompliance.sol -- Auth + KYC gating for redemptions

Constructor Signatures (critical for deployment)

  • Liquid() -- empty, then call initialize(LiquidInitializationParams)
  • LiquidPosition(address liquid_)
  • LiquidTransmuter(ILiquidTransmuter.TransmuterInitializationParams memory params)
  • LiquidETHVault(address _weth, address _liquid, address _owner)
  • LiquidCurator(address _admin, address _operator) via PermissionedProxy
  • LiquidAllocator(address _vault, address _admin, address _operator) via PermissionedProxy
  • LiquidStrategyClassifier(address _admin)

Deployment Order

  1. Deploy Liquid (empty constructor)
  2. Deploy LiquidPosition (needs liquid address)
  3. Deploy LiquidTransmuter (needs debt token address)
  4. Deploy LiquidETHVault (needs WLUX + liquid address)
  5. Call liquid.initialize(params) with transmuter address
  6. Call liquid.setLiquidPositionNFT(position)
  7. Call transmuter.setLiquid(liquid)
  8. Whitelist liquid as minter on debt token (LETH)

Canonical Lux Addresses

  • LETH: 0x60E0a8167FC13dE89348978860466C9ceC24B9ba
  • WLUX: 0x4888E4a2Ee0F03051c72D2BD3ACf755eD3498B3E
  • LBTC: 0x1E48D32a4F5e9f08DB9aE4959163300FaF8A6C8e

Test Failures (pre-existing)

  • Strategy fork tests (SfrxETH, MorphoYearnOGWETH, PeapodsETH, TokeAutoETH) -- need MAINNET_RPC_URL
  • IntegrationTest -- needs Ethereum mainnet fork
  • LiquidGauge (2 tests) -- logic bugs in cap application and vote aggregation
  • MockERC20 IERC20 compliance -- mock doesn't implement full interface
  • LiquidETHVault testDepositWETH -- mock WETH incomplete

Scripts

  • script/DeployBrandL1.s.sol -- Any brand L1, one like-kind market per run
  • script/DeployMainnet.s.sol -- Lux mainnet ETH market, canonical LETH pinned
  • script/DeployLocal.s.sol -- Full local stack (Anvil/luxd)
  • script/TestFlow.s.sol -- E2E smoke flow against a deployed local stack

Structure

src/
  Liquid.sol, LiquidPosition.sol, LiquidTransmuter.sol
  LiquidETHVault.sol, LiquidTokenVault.sol
  LiquidCurator.sol, LiquidAllocator.sol
  LiquidStrategyClassifier.sol, LiquidStrategy.sol, LiquidGauge.sol
  LiquidGate.sol, LiquidCompliance.sol
  adapters/       -- AbstractFeeVault, EulerUSDCAdapter, SecurityTokenAdapter
  base/           -- Errors, ErrorMessages, LiquidTransmuterErrors
  external/       -- LETH (synthetic), interfaces
  governance/     -- LiquidGovernor, LiquidToken
  interfaces/     -- All interfaces
  libraries/      -- FixedPointMath, SafeCast, SafeERC20, TokenUtils, StakingGraph, …
  mocks/          -- ERC20Mock, Pool, Stake, StakingPoolMock
  strategies/     -- EETH, SfrxETH, Lido, EigenLayer, Pendle, Morpho, Yearn, …
  test/           -- All test files
  utils/          -- PermissionedProxy, Whitelist, ZeroXSwapVerifier
script/
  DeployBrandL1.s.sol, DeployMainnet.s.sol, DeployLocal.s.sol, TestFlow.s.sol
lib/
  forge-std, openzeppelin-contracts, openzeppelin-contracts-upgradeable
  vault-v2, permit2, solmate, chainlink-brownie-contracts, halmos-cheatcodes