Skip to main content

Obfuscation System

Overview

The obfuscation system is Lunarys's core privacy mechanism. It enables public price discovery while keeping true reserve magnitudes private. Every time the pool state changes, reserves are multiplied by a fresh random factor and published -- giving users accurate price ratios but hiding the actual liquidity depth behind ~3x uncertainty.

Why Obfuscation?

The AMM Privacy Dilemma

AMMs face a fundamental tension between privacy and usability:

ApproachPrice DiscoveryMEV Protection
Fully public reserves (Uniswap V2)Exact pricesNone -- MEV bots exploit every trade
Fully hidden reservesImpossible -- users can't set slippagePerfect -- but unusable
Obfuscated reserves (Lunarys)Exact price ratios, hidden magnitudes~3x uncertainty makes MEV unprofitable

What MEV Bots Need

To profitably front-run or sandwich a trade, an MEV bot needs to know:

  1. Exact reserves -- to compute price impact of the victim's trade
  2. Trade size -- to calculate optimal sandwich bounds

Lunarys denies both: reserves are obfuscated (~3x uncertainty) and trade amounts are fully encrypted. Even if a bot observes the swap event, it cannot determine whether the trade moved the price by 0.1% or 0.3% -- making sandwich attacks a losing gamble.

flowchart TD
subgraph Traditional["Traditional DEX (Uniswap)"]
T1["Public reserves: 1000 ETH / 2.7M USDC"] --> T2["Bot sees pending swap: 10 ETH"]
T2 --> T3["Bot calculates exact price impact"]
T3 --> T4["Bot front-runs + back-runs = profit"]
end

subgraph Lunarys["Lunarys eDEX"]
L1["Obfuscated reserves:<br/>~1.5B to ~4.5B units visible"] --> L2["Bot sees swap event (no amount)"]
L2 --> L3["Bot cannot determine:<br/>• True reserves (3x uncertainty)<br/>• Trade size (encrypted)<br/>• Price impact (unknown)"]
L3 --> L4["Sandwich attack = losing gamble"]
end

style Traditional fill:#e53e3e,color:#fff
style Lunarys fill:#38a169,color:#fff
style T4 fill:#c53030,color:#fff
style L4 fill:#276749,color:#fff

Architecture

On-Chain State

The EPOOL stores both private and public state:

// Private -- only accessible to FHE coprocessor
euint64 private reserveA; // True encrypted reserve
euint64 private reserveB; // True encrypted reserve

// Private -- obfuscation parameters (set by factory at pool creation)
euint64 private minObfuscationFactor; // Encrypted minimum factor (e.g. 1,000,000)
euint64 private obfuscationFactorStep; // Encrypted step per RNG unit (e.g. 30)

// Public -- visible to anyone via gateway decryption
struct obfuscatedStatesStruct {
euint64 obfuscatedReserveA; // reserveA * factor
euint64 obfuscatedReserveB; // reserveB * factor
euint64 lpSupply; // totalSupply * factor
}

The _updateStates() Flow

Every state-changing operation in EPOOL calls _updateStates() at the end:

flowchart TD
A[State-Changing Operation<br/>swap / add / remove liquidity] --> B[Sync reserves from token balances]
B --> C[reserveA = assetA.confidentialBalanceOf]
B --> D[reserveB = assetB.confidentialBalanceOf]
C --> E[Generate fresh random factor<br/>EPoolObfuscationLib.computeUniformObfuscationFactor]
D --> E
E --> F[Apply factor to all values]
F --> G["obfReserveA = reserveA × factor"]
F --> H["obfReserveB = reserveB × factor"]
F --> I["obfLPSupply = totalSupply × factor"]
G --> J[Grant FHE permissions]
H --> J
I --> J
J --> K[Mark publicly decryptable<br/>Anyone can decrypt via gateway]

style A fill:#805ad5,color:#fff
style E fill:#d69e2e,color:#000
style K fill:#38a169,color:#fff

This function:

1. Sync reserves from token balances:
reserveA = assetA.confidentialBalanceOf(address(this))
reserveB = assetB.confidentialBalanceOf(address(this))

2. Generate fresh random factor:
factor = EPoolObfuscationLib.computeUniformObfuscationFactor(minFactor, step)

3. Apply factor to all three values:
obfuscatedStates.obfuscatedReserveA = FHE.mul(reserveA, factor)
obfuscatedStates.obfuscatedReserveB = FHE.mul(reserveB, factor)
obfuscatedStates.lpSupply = FHE.mul(confidentialTotalSupply(), factor)

4. Grant permissions and publish:
FHE.allowThis(...) // Pool can use these values
FHE.makePubliclyDecryptable(...) // Anyone can decrypt via gateway

This Uniswap V2-style "sync from balances" pattern means reserves always reflect actual token holdings, even if tokens are sent directly to the contract.

Operations That Trigger _updateStates()

OperationTrigger
bootstrap()After initial liquidity deposit
contributeLiquidity()After LP tokens minted
removeLiquidity()After LP tokens burned and tokens sent
_executeAtomicSwap()After swap transfer completed

Every single one rotates the factor. An observer watching the pool sees a different random multiplier after every transaction.

Factor Generation

Algorithm: EPoolObfuscationLib.computeUniformObfuscationFactor

The factor is generated with exactly 3 FHE operations (optimized for the FHE coprocessor):

// Step 1: Generate encrypted random seed (1 FHE op)
euint16 seed = FHE.randEuint16(); // Uniform in [0, 65535]

// Step 2: Scale seed by step size (1 FHE op - multiply)
euint64 offset = FHE.mul(FHE.asEuint64(seed), step); // seed * 30

// Step 3: Add minimum factor (1 FHE op - addition)
euint64 factor = FHE.add(offset, minFactor); // 1,000,000 + offset

Factor Range

With default parameters (minFactor = 1,000,000, step = 30):

ParameterValue
Minimum factor1,000,000
Maximum factor1,000,000 + 65,535 x 30 = 2,966,050
Ratio max/min~2.97x
DistributionUniform across 65,536 possible values

Why These Parameters?

The parameters balance three competing concerns:

  1. Privacy (wider range = better): A 3x range means an observer can't determine if the true reserve is 1/3 or 1/1 of the visible value. This is enough to make MEV unprofitable -- a sandwich attacker can't predict price impact within 3x.

  2. Taylor accuracy (narrower range = better): The swap math uses reserveInLowerBound = obfIn / MAX_OBFUSCATION_FACTOR as a conservative denominator. A wider factor range means a more pessimistic lower bound, which tightens the max swap size.

  3. FHE cost (fewer ops = better): Using euint16 (not euint64) for the seed keeps the RNG to a single cheap operation. The multiply-and-add pattern uses only 2 additional ops.

Why the Factor Is Never Revealed

The factor itself is an encrypted value (euint64). It exists only inside the FHE coprocessor:

  • The pool computes FHE.mul(reserve, factor) in encrypted space
  • The result is marked publiclyDecryptable -- but the factor is not
  • Even if you decrypt two consecutive obfuscated values, you can't recover the factor because it changed between observations

Price Discovery

Price Ratio Preservation

Since the same factor multiplies both reserves:

obfReserveA / obfReserveB
= (reserveA × factor) / (reserveB × factor)
= reserveA / reserveB

The factor cancels perfectly. Users get an exact price ratio from obfuscated reserves -- the same ratio they'd get from true reserves.

What Users Can Derive

From the publicly decryptable obfuscated values, a user can compute:

DerivableFormulaAccuracy
Spot priceobfReserveB / obfReserveAExact
Expected swap outputVia Taylor approximationWithin ~1% for max-size swaps
LP share value ratioobfReserveA / obfLPSupplyExact ratio
Pool liquidity depthobfReserveA / factorUnknown -- 3x uncertainty

What Stays Hidden

HiddenWhy It Matters
True reserve magnitudesMEV bots can't compute exact price impact
Pool TVLCompetitors can't track liquidity
Individual trade sizesAll encrypted (separate from obfuscation)
LP position sizesCERC20 encrypted balances

How Swap Math Uses Obfuscated Values

The SwapLib Taylor approximation operates on obfuscated reserves directly:

// Price ratio -- factor cancels, so this is exact
price = obfOut × SCALE / obfIn

// Conservative lower bound on true reserve
reserveInLowerBound = obfIn / MAX_OBFUSCATION_FACTOR // obfIn / 3,000,000

// Max allowed swap (Taylor bound)
maxEffectiveIn = reserveInLowerBound / MAX_TAYLOR_EPS_DENOMINATOR // / 16

The MAX_OBFUSCATION_FACTOR (3,000,000) is intentionally set above the actual maximum factor (~2,966,050). This ensures reserveInLowerBound is always a true lower bound on the real reserve, which guarantees:

  • The Taylor approximation never overestimates the output (no overpayment)
  • The constant product invariant x × y = k is preserved after every swap

How Liquidity Math Uses Obfuscated Values

LP minting and withdrawal also work correctly because the factor cancels in proportional calculations:

// LP minting: factor cancels in ratio
amountA × obfTotalSupply / obfReserveA
= amountA × (totalSupply × f) / (reserveA × f)
= amountA × totalSupply / reserveA

// LP withdrawal: same cancellation
shares × obfReserveA / obfTotalSupply
= shares × (reserveA × f) / (totalSupply × f)
= shares × reserveA / totalSupply

Proof Verification

Why Proofs Are Required

Users pass decrypted obfuscated values (as clear uint128) to the pool. Without verification, a user could submit fake reserve values to manipulate swap pricing.

How It Works

  1. User calls gateway.decrypt([obfReserveA, obfReserveB]) to get cleartext values + an FHE signature
  2. User passes the cleartext values and signature to the pool
  3. Pool verifies the signature matches the current on-chain ciphertexts:
bytes32[] memory cts = new bytes32[](2);
cts[0] = FHE.toBytes32(obfuscatedStates.obfuscatedReserveA);
cts[1] = FHE.toBytes32(obfuscatedStates.obfuscatedReserveB);
FHE.checkSignatures(cts, abi.encode(decryptedORA, decryptedORB), reserveProof);

This prevents:

  • Stale values: If the pool state changed since decryption, the ciphertexts won't match
  • Forged values: The FHE network won't sign arbitrary plaintext-ciphertext pairs
  • Replay attacks: Each factor rotation produces new ciphertexts, invalidating old proofs

Liquidity Operations Need 3 Values

Swap functions verify 2 values (reserves only), but contributeLiquidity and removeLiquidity verify 3 (reserves + LP supply) because LP math requires the total supply ratio.

Privacy Analysis

Observer Model

An observer monitoring the blockchain sees:

ObservableInformation Leaked
obfuscatedReserveA valueTrue reserve is between value/2.97 and value/1.0
obfuscatedReserveB valueSame range uncertainty
obfReserveA / obfReserveBExact price ratio (by design)
Value changed after txA state-changing tx occurred
AtomicSwapExecuted eventDirection (A→B or B→A), participants
New obfuscated values after swapCannot determine trade size (amounts encrypted, factor also changed)

Correlation Resistance

Even if an observer records obfuscated values before and after a swap:

Before: obfA₁ = reserveA₁ × factor₁
After: obfA₂ = reserveA₂ × factor₂

They cannot solve for the trade amount because there are two unknowns (the trade amount and the new factor) but only one equation. The factor rotation breaks any correlation.

Quantitative Privacy Guarantee

For any observed obfuscated reserve value V:

True reserve ∈ [V / 2,966,050, V / 1,000,000]

This is a ~3x uncertainty range. An attacker estimating the true reserve has at best a uniform distribution across this range -- insufficient for profitable MEV extraction.

Attack Resistance Summary

Attack VectorDefense Mechanism
Front-runningCannot determine exact price impact (3x reserve uncertainty + encrypted amounts)
Sandwich attacksCannot calculate profitable sandwich bounds
MEV extractionObfuscation makes all MEV strategies unprofitable
JIT liquidity0.05% withdrawal fee makes in-and-out attacks costly
Balance inferenceCERC20 encrypted balances prevent tracking individual positions
Cross-tx correlationFactor rotation on every operation prevents linking observations
Statistical analysisUniform factor distribution prevents frequency-based inference