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:
| Approach | Price Discovery | MEV Protection |
|---|---|---|
| Fully public reserves (Uniswap V2) | Exact prices | None -- MEV bots exploit every trade |
| Fully hidden reserves | Impossible -- users can't set slippage | Perfect -- 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:
- Exact reserves -- to compute price impact of the victim's trade
- 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()
| Operation | Trigger |
|---|---|
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):
| Parameter | Value |
|---|---|
| Minimum factor | 1,000,000 |
| Maximum factor | 1,000,000 + 65,535 x 30 = 2,966,050 |
| Ratio max/min | ~2.97x |
| Distribution | Uniform across 65,536 possible values |
Why These Parameters?
The parameters balance three competing concerns:
-
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.
-
Taylor accuracy (narrower range = better): The swap math uses
reserveInLowerBound = obfIn / MAX_OBFUSCATION_FACTORas a conservative denominator. A wider factor range means a more pessimistic lower bound, which tightens the max swap size. -
FHE cost (fewer ops = better): Using
euint16(noteuint64) 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:
| Derivable | Formula | Accuracy |
|---|---|---|
| Spot price | obfReserveB / obfReserveA | Exact |
| Expected swap output | Via Taylor approximation | Within ~1% for max-size swaps |
| LP share value ratio | obfReserveA / obfLPSupply | Exact ratio |
| Pool liquidity depth | obfReserveA / factor | Unknown -- 3x uncertainty |
What Stays Hidden
| Hidden | Why It Matters |
|---|---|
| True reserve magnitudes | MEV bots can't compute exact price impact |
| Pool TVL | Competitors can't track liquidity |
| Individual trade sizes | All encrypted (separate from obfuscation) |
| LP position sizes | CERC20 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 = kis 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
- User calls
gateway.decrypt([obfReserveA, obfReserveB])to get cleartext values + an FHE signature - User passes the cleartext values and signature to the pool
- 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:
| Observable | Information Leaked |
|---|---|
obfuscatedReserveA value | True reserve is between value/2.97 and value/1.0 |
obfuscatedReserveB value | Same range uncertainty |
obfReserveA / obfReserveB | Exact price ratio (by design) |
| Value changed after tx | A state-changing tx occurred |
AtomicSwapExecuted event | Direction (A→B or B→A), participants |
| New obfuscated values after swap | Cannot 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 Vector | Defense Mechanism |
|---|---|
| Front-running | Cannot determine exact price impact (3x reserve uncertainty + encrypted amounts) |
| Sandwich attacks | Cannot calculate profitable sandwich bounds |
| MEV extraction | Obfuscation makes all MEV strategies unprofitable |
| JIT liquidity | 0.05% withdrawal fee makes in-and-out attacks costly |
| Balance inference | CERC20 encrypted balances prevent tracking individual positions |
| Cross-tx correlation | Factor rotation on every operation prevents linking observations |
| Statistical analysis | Uniform factor distribution prevents frequency-based inference |