Liquidity Provision Deep Dive
Overview
Lunarys DEX uses a fully confidential liquidity provision system where all reserve amounts, liquidity positions, and LP shares remain encrypted. Unlike traditional AMMs that use ERC20 LP tokens, Lunarys implements a hook-based architecture that delegates LP tracking to external contracts while keeping all amounts private.
Key Differences from Traditional AMMs
Traditional AMM (Uniswap V2)
1. User deposits tokens → Receives LP tokens
2. LP tokens are ERC20 (public balances)
3. Reserves are public uint256 values
4. Anyone can see liquidity depth
Lunarys Confidential AMM
1. User deposits encrypted tokens → No LP tokens issued
2. Liquidity tracked by hooks (encrypted)
3. Reserves are euint64 (encrypted)
4. Liquidity depth is private
Two-Phase Liquidity System
Phase 1: Bootstrap (First-Time Initialization)
The first liquidity provider must bootstrap the pool to establish initial reserves and pricing.
Phase 2: Contribute (Subsequent Deposits)
After bootstrap, liquidity providers use contributeLiquidity to add more liquidity at the current ratio.
Bootstrap Process
Function Signature
function bootstrap(
externalEuint64 amountA_ext,
bytes calldata proofA,
externalEuint64 amountB_ext,
bytes calldata proofB,
address lpRecipient,
bytes calldata hookContext
) external ensureUninitialized nonReentrant returns (uint256 requestID)
Parameters
| Parameter | Type | Description |
|---|---|---|
amountA_ext | externalEuint64 | Encrypted amount of token A - Created by user's wallet |
proofA | bytes | Zero-knowledge proof for amount A |
amountB_ext | externalEuint64 | Encrypted amount of token B |
proofB | bytes | Zero-knowledge proof for amount B |
lpRecipient | address | LP position owner - Address to receive liquidity position |
hookContext | bytes | Optional hook data - Passed to liquidity hooks |
Step-by-Step Process
Step 1: Verify Pool Uninitialized
modifier ensureUninitialized() {
if (reservesInitialized) {
revert AlreadyInitialized();
}
_;
}
Why this matters:
- Bootstrap can only happen once
- Prevents re-initialization attacks
- Ensures consistent pricing from start
Step 2: Pull Encrypted Tokens
// Convert external encrypted inputs to internal FHE ciphertexts
euint64 rawA = FHE.fromExternal(amountA_ext, proofA);
euint64 rawB = FHE.fromExternal(amountB_ext, proofB);
// Pull tokens from LP provider
euint64 pulledA = _pullIntoPool(assetA, rawA);
euint64 pulledB = _pullIntoPool(assetB, rawB);
Internal pull function:
function _pullIntoPool(IERC7984 asset, euint64 amount) private returns (euint64) {
ebool success = asset.confidentialTransferFrom(
msg.sender,
address(this),
amount
);
// If transfer failed, return 0 (encrypted)
euint64 zero = FHE.asEuint64(0);
euint64 pulled = FHE.select(success, amount, zero);
return pulled;
}
Key insight:
- User might not have enough balance
- Pulled amount might differ from requested
- Everything stays encrypted
- Failed transfers return encrypted zero
Step 3: Verify Non-Zero Liquidity
// Request decryption to verify amounts are non-zero
bytes32 handleA = FHE.toBytes32(pulledA);
bytes32 handleB = FHE.toBytes32(pulledB);
uint256 requestID = _requestDecryption(handleA, handleB);
Why decryption needed:
- Must verify liquidity is actually provided
- Prevent bootstrap with zero amounts
- Decryption is safe here (LP provider knows amounts)
- Two-phase process: Bootstrap → Settle
Step 4: Store Pending Bootstrap
pendingBootstraps[requestID] = PendingBootstrap({
exists: true,
caller: msg.sender,
lpRecipient: lpRecipient,
pulledA: pulledA,
pulledB: pulledB,
handleA: handleA,
handleB: handleB,
hookContext: hookContext
});
This struct preserves all state needed for settlement.
Step 5: Emit Event & Call Hook
emit BootstrapDecryptionRequested(
requestID,
msg.sender,
lpRecipient,
handleA,
handleB
);
if (hasLiquidityHook) {
hooks.liquidityHook.beforeBootstrap(
msg.sender,
lpRecipient,
handleA,
handleB,
hookData,
hookContext
);
}
Bootstrap Settlement
Function Signature
function settleBootstrap(
uint256 requestID,
bytes memory cleartexts,
bytes memory decryptionProof
) external nonReentrant
Settlement Process
Step 1: Verify Decryption Proof
FHE.checkSignatures(requestID, cleartexts, decryptionProof);
Ensures oracle decryption is valid and tamper-proof.
Step 2: Decode Decrypted Values
(uint256 clearA, uint256 clearB) = abi.decode(cleartexts, (uint256, uint256));
Step 3: Check for Zero Liquidity (Refund Case)
if (clearA == 0 || clearB == 0) {
// Refund both tokens to caller
assetA.confidentialTransfer(pending.caller, pending.pulledA);
assetB.confidentialTransfer(pending.caller, pending.pulledB);
emit BootstrapRejected(requestID, pending.caller, pending.lpRecipient);
delete pendingBootstraps[requestID];
return;
}
Why refund?
- User might have had insufficient balance
- Both amounts must be non-zero
- Protect user from failed bootstrap
Step 4: Initialize Pool Reserves
reserveA = pending.pulledA;
reserveB = pending.pulledB;
// Create first checkpoint for both reserves
reserveATrace.push(uint48(block.number), reserveA);
reserveBTrace.push(uint48(block.number), reserveB);
reservesInitialized = true;
lastUpdate = uint32(block.timestamp);
Key points:
- Reserves now have encrypted initial values
- Checkpoints created for historical tracking
- Pool is now open for swaps and contributions
- Initial price ratio established:
priceA/B = reserveA / reserveB
Step 5: Call After-Bootstrap Hook
if (hasLiquidityHook) {
hooks.liquidityHook.afterBootstrap(
pending.caller,
pending.lpRecipient,
pending.handleA,
pending.handleB,
hookData,
pending.hookContext
);
}
Hooks can:
- Track LP position with encrypted shares
- Mint internal LP tokens (hook contract)
- Update analytics
- Distribute initial rewards
Step 6: Emit Events & Cleanup
emit BootstrapSettled(
requestID,
pending.caller,
pending.lpRecipient,
pending.handleA,
pending.handleB
);
emit LiquidityBootstrapped(
pending.caller,
pending.lpRecipient
);
delete pendingBootstraps[requestID];
Contribute Liquidity (After Bootstrap)
Function Signature
function contributeLiquidity(
externalEuint64 amountA_ext,
bytes calldata proofA,
externalEuint64 amountB_ext,
bytes calldata proofB,
address lpRecipient,
bytes calldata hookContext
) external ensureInitialized nonReentrant returns (uint256 requestID)
Parameters
Same as bootstrap, but pool must already be initialized.
Step-by-Step Process
Step 1: Verify Pool Initialized
modifier ensureInitialized() {
if (!reservesInitialized) {
revert NotInitialized();
}
_;
}
Pool must have reserves before accepting contributions.
Step 2: Pull Encrypted Tokens
euint64 rawA = FHE.fromExternal(amountA_ext, proofA);
euint64 rawB = FHE.fromExternal(amountB_ext, proofB);
euint64 pulledA = _pullIntoPool(assetA, rawA);
euint64 pulledB = _pullIntoPool(assetB, rawB);
Same as bootstrap.
Step 3: Calculate Expected Ratios (Encrypted)
// Read current reserves
euint64 currentReserveA = reserveA;
euint64 currentReserveB = reserveB;
// Calculate what the new reserves would be
euint64 newReserveA = FHE.add(currentReserveA, pulledA);
euint64 newReserveB = FHE.add(currentReserveB, pulledB);
Important:
- All calculations done on encrypted values
- No one can see how much liquidity is being added
- Ratios computed homomorphically
Step 4: Request Decryption for Validation
bytes32 handleA = FHE.toBytes32(pulledA);
bytes32 handleB = FHE.toBytes32(pulledB);
uint256 requestID = _requestDecryption(handleA, handleB);
We need to verify:
- Amounts are non-zero
- Ratio is reasonable (prevent imbalance)
Step 5: Store Pending Contribution
pendingContributions[requestID] = PendingContribution({
exists: true,
caller: msg.sender,
lpRecipient: lpRecipient,
pulledA: pulledA,
pulledB: pulledB,
reserveABefore: currentReserveA,
reserveBBefore: currentReserveB,
handleA: handleA,
handleB: handleB,
hookContext: hookContext
});
Step 6: Emit Event & Call Hook
emit ContributionDecryptionRequested(
requestID,
msg.sender,
lpRecipient,
handleA,
handleB
);
if (hasLiquidityHook) {
hooks.liquidityHook.beforeContribute(
msg.sender,
lpRecipient,
handleA,
handleB,
hookData,
hookContext
);
}
Contribution Settlement
Function Signature
function settleContribution(
uint256 requestID,
bytes memory cleartexts,
bytes memory decryptionProof
) external nonReentrant
Settlement Process
Step 1: Verify Decryption Proof
FHE.checkSignatures(requestID, cleartexts, decryptionProof);
Step 2: Decode Decrypted Values
(uint256 clearA, uint256 clearB) = abi.decode(cleartexts, (uint256, uint256));
Step 3: Check for Zero Liquidity (Refund Case)
if (clearA == 0 || clearB == 0) {
// Refund both tokens
assetA.confidentialTransfer(pending.caller, pending.pulledA);
assetB.confidentialTransfer(pending.caller, pending.pulledB);
emit ContributionRejected(requestID, pending.caller, pending.lpRecipient);
delete pendingContributions[requestID];
return;
}
Step 4: Update Pool Reserves
// Add contribution to reserves
reserveA = FHE.add(pending.reserveABefore, pending.pulledA);
reserveB = FHE.add(pending.reserveBBefore, pending.pulledB);
// Update checkpoints
reserveATrace.push(uint48(block.number), reserveA);
reserveBTrace.push(uint48(block.number), reserveB);
lastUpdate = uint32(block.timestamp);
Key points:
- Reserves increase by contribution amounts
- New checkpoints created
- Pool liquidity depth increases (but stays encrypted)
Step 5: Call After-Contribute Hook
if (hasLiquidityHook) {
hooks.liquidityHook.afterContribute(
pending.caller,
pending.lpRecipient,
pending.handleA,
pending.handleB,
hookData,
pending.hookContext
);
}
Hooks can:
- Calculate encrypted LP shares
- Update LP position
- Distribute rewards
- Track total liquidity
Step 6: Emit Events & Cleanup
emit ContributionSettled(
requestID,
pending.caller,
pending.lpRecipient,
pending.handleA,
pending.handleB
);
emit LiquidityContributed(
pending.caller,
pending.lpRecipient
);
delete pendingContributions[requestID];
Why No LP Tokens?
Traditional AMM LP Token Model
// Uniswap V2 style
uint256 shares = sqrt(amount0 * amount1);
_mint(lpRecipient, shares); // Public ERC20 balance
Problems for confidential AMM:
- LP token balances would be public
- Reveals liquidity position sizes
- Enables tracking of LP behavior
- Defeats purpose of privacy
Lunarys Hook-Based Model
// Hook contract tracks encrypted LP shares
hooks.liquidityHook.afterBootstrap(
caller,
lpRecipient,
encryptedAmountA,
encryptedAmountB,
hookData,
hookContext
);
Advantages:
- LP shares can be encrypted in hook contract
- Flexible LP token implementations
- Hook can implement complex logic
- Pool contract stays simple
- Privacy preserved
Example Hook Implementation
contract ConfidentialLPToken is ILiquidityHook {
// Encrypted LP share balances
mapping(address => euint64) public lpShares;
// Total encrypted supply
euint64 public totalSupply;
function afterBootstrap(
address caller,
address lpRecipient,
bytes32 amountAHandle,
bytes32 amountBHandle,
bytes memory hookData,
bytes memory hookContext
) external override {
// Convert handles back to euint64
euint64 amountA = FHE.fromBytes32(amountAHandle);
euint64 amountB = FHE.fromBytes32(amountBHandle);
// Calculate encrypted shares: sqrt(a * b)
euint128 product = FHE.mul(amountA, amountB);
euint64 shares = FHE.sqrt(product); // Hypothetical FHE sqrt
// Mint encrypted shares
lpShares[lpRecipient] = FHE.add(lpShares[lpRecipient], shares);
totalSupply = FHE.add(totalSupply, shares);
}
function afterContribute(
address caller,
address lpRecipient,
bytes32 amountAHandle,
bytes32 amountBHandle,
bytes memory hookData,
bytes memory hookContext
) external override {
// Similar logic for calculating proportional shares
// shares = (amountA / reserveA) * totalSupply
}
}
Liquidity Removal (Hook-Based)
Lunarys pools do not have built-in liquidity removal. Instead, this functionality is delegated to liquidity hooks.
Why?
Design philosophy:
- Pool contract handles core AMM logic (swaps, reserves)
- Hooks handle LP position management (shares, withdrawals)
- Separation of concerns
- Flexibility for different LP models
Example Withdrawal Flow
// In hook contract
function removeLiquidity(
address pool,
euint64 sharesToBurn,
bytes calldata proof
) external {
// 1. Verify caller has encrypted shares
euint64 userShares = lpShares[msg.sender];
ebool hasEnough = FHE.gte(userShares, sharesToBurn);
require(FHE.decrypt(hasEnough), "Insufficient shares");
// 2. Calculate proportional amounts
// amountA = (sharesToBurn / totalSupply) * reserveA
// amountB = (sharesToBurn / totalSupply) * reserveB
// 3. Burn encrypted shares
lpShares[msg.sender] = FHE.sub(userShares, sharesToBurn);
totalSupply = FHE.sub(totalSupply, sharesToBurn);
// 4. Call pool to withdraw (requires pool function)
// pool.withdrawLiquidity(amountA, amountB, msg.sender);
}
**Note:**Current PrivacyPool contract focuses on swaps. Withdrawal functions would be added based on hook requirements.
Pricing and Reserve Ratios
Initial Price (Bootstrap)
The first LP provider sets the initial price by choosing amounts:
Price of A in terms of B = reserveB / reserveA
Price of B in terms of A = reserveA / reserveB
Example:
- Bootstrap with 1000 LUN and 2000 USDC
- Price: 1 LUN = 2 USDC
- Price: 1 USDC = 0.5 LUN
Important:
- All calculations encrypted
- Price ratio public knowledge (can infer from swap outcomes)
- But absolute reserve amounts remain private
Subsequent Contributions
Later LPs should contribute at current ratio to avoid price impact:
Optimal ratio: amountB / amountA = reserveB / reserveA
If ratio is imbalanced:
- Pool accepts both amounts
- But one token contributes more to liquidity
- Hook can calculate fair LP shares
Example:
- Current reserves: 1000 LUN / 2000 USDC (1:2 ratio)
- LP contributes: 100 LUN / 100 USDC (1:1 ratio)
- Effective contribution: 100 LUN + 200 USDC worth
- LP gets shares for minimum proportional amount
Reserve Checkpoints
Every liquidity operation updates reserve checkpoints:
reserveATrace.push(uint48(block.number), reserveA);
reserveBTrace.push(uint48(block.number), reserveB);
Why Checkpoints for Reserves?
Use cases:
-
Governance voting on pool state
- Vote to change swap fee
- Vote to upgrade pool
- Voting power based on historical liquidity provision
-
LP position verification
- Prove you provided liquidity at block X
- Calculate time-weighted LP shares
- Reward long-term LPs
-
Pool analytics (via hooks)
- Track reserve growth over time
- Measure APY for LPs
- Historical depth analysis
Example: Time-Weighted LP Shares
// Calculate LP shares weighted by time held
function calculateTimeWeightedShares(
address lp,
uint256 fromBlock,
uint256 toBlock
) external view returns (euint64) {
// Get LP's contribution at fromBlock
euint64 sharesAtStart = lpSharesTrace[lp].lowerLookup(fromBlock);
// Get LP's contribution at toBlock
euint64 sharesAtEnd = lpSharesTrace[lp].upperLookup(toBlock);
// Weight by blocks held
uint256 blocksHeld = toBlock - fromBlock;
euint64 weightedShares = FHE.mul(sharesAtEnd, blocksHeld);
return weightedShares;
}
Privacy Guarantees
What Remains Private
Liquidity Amounts
- Bootstrap amounts encrypted
- Contribution amounts encrypted
- LP cannot see others' positions
Pool Reserves
- Total liquidity depth private
- Reserve ratio derivable from swaps
- But absolute amounts encrypted
LP Share Balances
- Tracked by hooks with encryption
- No public LP token balances
- Cannot track LP behavior
Withdrawal Amounts
- If implemented, would be encrypted
- LP gets back encrypted tokens
- Position size private
What Is Public
Liquidity Events
- That bootstrap/contribution occurred
- LP recipient address
- Transaction hash
- Settlement status
Pool State
- That reserves exist (boolean)
- Initialization status
- Last update timestamp
**Not revealed:**Actual reserve amounts, LP shares, liquidity depth
Security Considerations
Bootstrap Front-Running
**Threat:**Attacker sees bootstrap transaction in mempool and front-runs with own bootstrap.
Mitigation:
ensureUninitializedmodifier prevents double bootstrap- First transaction wins
- Attacker's transaction reverts
- No economic gain from front-running
Imbalanced Contributions
**Threat:**LP contributes imbalanced ratio to manipulate price.
Mitigation:
- Price determined by constant product formula
- Imbalanced contributions don't change effective price
- Attacker loses value to arbitrageurs
- Hook can reject severely imbalanced contributions
Zero Liquidity Attacks
**Threat:**Bootstrap or contribute with zero amounts.
Mitigation:
- Decryption verifies amounts are non-zero
- Zero amounts trigger refund
- Pool state unchanged
- No economic attack vector
Hook Vulnerabilities
**Threat:**Malicious hook drains liquidity or manipulates LP shares.
Mitigation:
- Hooks are opt-in at pool creation
- LPs should verify hook contract
- Pool owner sets hooks (governance)
- Hooks cannot access pool reserves directly
Gas Costs
| Operation | Estimated Gas | Notes |
|---|---|---|
bootstrap | ~800,000 | FHE operations + two token pulls |
settleBootstrap | ~300,000 | Reserve initialization + checkpoint |
contributeLiquidity | ~700,000 | Similar to bootstrap |
settleContribution | ~300,000 | Reserve updates |
| Total per bootstrap | ~1,100,000 | Split across two transactions |
| Total per contribution | ~1,000,000 | Split across two transactions |
Why high gas?
- FHE operations expensive (10-100x normal)
- Encrypted transfers costly
- Checkpoint updates
- Hook calls add overhead
- Worth it for privacy!
Complete Example: Bootstrap Flow
User Perspective
import { ethers } from "ethers";
import { fhevm } from "@fhevm/sdk";
async function bootstrapPool() {
// 1. Initialize FHEVM
await fhevm.initializeCLIApi();
// 2. Create encrypted inputs
const input = await fhevm.createEncryptedInput(poolAddress, userAddress);
input.add64(ethers.parseUnits("1000", 6)); // 1000 token A
input.add64(ethers.parseUnits("2000", 6)); // 2000 token B
const encrypted = await input.encrypt();
// 3. Approve pool to spend tokens (one-time per token)
const futureTimestamp = Math.floor(Date.now() / 1000) + 365 * 24 * 60 * 60; // 1 year
await tokenA.setOperator(poolAddress, futureTimestamp);
await tokenB.setOperator(poolAddress, futureTimestamp);
// 4. Initiate bootstrap
const tx = await pool.bootstrap(
encrypted.handles[0], // amountA
encrypted.inputProof, // proofA
encrypted.handles[1], // amountB
encrypted.inputProof, // proofB (same proof for both)
userAddress, // lpRecipient
"0x" // no hook context
);
const receipt = await tx.wait();
// 5. Extract request ID
const event = receipt.logs.find(log =>
pool.interface.parseLog(log)?.name === "BootstrapDecryptionRequested"
);
const requestID = event.args.requestID;
console.log(`Bootstrap initiated. Request ID: ${requestID}`);
console.log("Waiting for oracle to settle...");
// 6. Wait for settlement (automatic by oracle)
pool.once(pool.filters.BootstrapSettled(requestID), () => {
console.log("Bootstrap completed! Pool is now initialized.");
});
}
Oracle Perspective
// Oracle monitors for bootstrap requests
pool.on("BootstrapDecryptionRequested", async (
requestID,
caller,
lpRecipient,
handleA,
handleB
) => {
// 1. Threshold network decrypts both amounts
const [clearA, clearB] = await thresholdNetwork.decrypt([handleA, handleB]);
// 2. Validators sign the decrypted values
const proof = await thresholdNetwork.generateProof(
requestID,
[clearA, clearB]
);
// 3. One validator submits settlement
const cleartexts = ethers.AbiCoder.defaultAbiCoder().encode(
["uint256", "uint256"],
[clearA, clearB]
);
await pool.settleBootstrap(requestID, cleartexts, proof);
console.log(`Bootstrap ${requestID} settled!`);
});
Related Documentation
- PrivacyPool Contract - Full contract reference
- Swap Mechanism - How swaps work
- Checkpoints System - Historical tracking
- Hook System - LP token implementations
- DAO System - Pool governance
Summary
Lunarys liquidity provision achieves true confidentiality through:
- Fully encrypted deposits - All amounts encrypted from user wallet
- Hook-based LP tracking - Flexible, private LP share management
- Encrypted reserves - Pool depth remains private
- Two-phase design - Bootstrap/Contribute → Oracle → Settle
- Checkpoint integration - Historical liquidity tracking
- No public LP tokens - Privacy-preserving position management
This is the first confidential AMM with encrypted liquidity tracking and a hook-based LP system!