Skip to main content

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

ParameterTypeDescription
amountA_extexternalEuint64Encrypted amount of token A - Created by user's wallet
proofAbytesZero-knowledge proof for amount A
amountB_extexternalEuint64Encrypted amount of token B
proofBbytesZero-knowledge proof for amount B
lpRecipientaddressLP position owner - Address to receive liquidity position
hookContextbytesOptional 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:

  1. Governance voting on pool state

    • Vote to change swap fee
    • Vote to upgrade pool
    • Voting power based on historical liquidity provision
  2. LP position verification

    • Prove you provided liquidity at block X
    • Calculate time-weighted LP shares
    • Reward long-term LPs
  3. 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:

  • ensureUninitialized modifier 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

OperationEstimated GasNotes
bootstrap~800,000FHE operations + two token pulls
settleBootstrap~300,000Reserve initialization + checkpoint
contributeLiquidity~700,000Similar to bootstrap
settleContribution~300,000Reserve updates
Total per bootstrap~1,100,000Split across two transactions
Total per contribution~1,000,000Split 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!`);
});


Summary

Lunarys liquidity provision achieves true confidentiality through:

  1. Fully encrypted deposits - All amounts encrypted from user wallet
  2. Hook-based LP tracking - Flexible, private LP share management
  3. Encrypted reserves - Pool depth remains private
  4. Two-phase design - Bootstrap/Contribute → Oracle → Settle
  5. Checkpoint integration - Historical liquidity tracking
  6. No public LP tokens - Privacy-preserving position management

This is the first confidential AMM with encrypted liquidity tracking and a hook-based LP system!