Skip to main content

Frontend Integration Guide

This document provides complete instructions for integrating swap, add liquidity, and remove liquidity operations with the Lunarys EPOOL contracts using Fully Homomorphic Encryption (FHE).

Architecture Overview

Lunarys eDEX uses encrypted AMM pools where all amounts (reserves, balances, swap amounts) are encrypted using FHE technology. This provides complete privacy for traders.

Key Concepts

ConceptDescription
CERC20Confidential ERC20 tokens (ERC-7984 standard) with encrypted balances
EPOOLEncrypted AMM pool holding two CERC20 assets
Obfuscated ReservesPublicly decryptable reserves multiplied by a random factor (1M-3M)
Encrypted InputsUser amounts encrypted client-side before sending to contracts
ProofsFHE signatures proving encrypted values match decrypted values

Flow Overview

User Input (clear) -> Encrypt (FHE SDK) -> Contract (encrypted operations) -> Decrypt Result

For swaps and liquidity:

  1. Get obfuscated reserves from the pool (publicly decryptable)
  2. Decrypt reserves using FHE gateway and get proof
  3. Encrypt user amounts (swap amount, liquidity amounts)
  4. Call contract with encrypted amounts + decrypted reserves + proofs
  5. Contract validates proofs and executes operation

Prerequisites

Dependencies

npm install @lunarys/fhe-sdk ethers@6

Package Versions

{
"@lunarys/fhe-sdk": "^0.6.0",
"ethers": "^6.15.0"
}

FHE SDK Setup

Initialize FHE Instance

import { createInstance, FheInstance } from "@lunarys/fhe-sdk";
import { BrowserProvider, JsonRpcSigner } from "ethers";

// FHE Gateway configuration for Sepolia
const FHE_CONFIG = {
networkUrl: "https://rpc.sepolia.org",
gatewayUrl: "https://gateway.sepolia.lunarys.app",
aclAddress: "0xcA2E8f1F656CD25C01F05d0b243Ab1ecd4a8ffb6",
kmsVerifierAddress: "0x9D6891A6240D6130c54ae243d8005063D05fE14b",
};

let fheInstance: FheInstance | null = null;

export async function initFhe(): Promise<FheInstance> {
if (fheInstance) return fheInstance;

fheInstance = await createInstance({
networkUrl: FHE_CONFIG.networkUrl,
gatewayUrl: FHE_CONFIG.gatewayUrl,
aclAddress: FHE_CONFIG.aclAddress,
kmsVerifierAddress: FHE_CONFIG.kmsVerifierAddress,
});

return fheInstance;
}

Generate User Keys (Required for Decryption)

export async function generateFheKeys(
provider: BrowserProvider,
signer: JsonRpcSigner
): Promise<void> {
const instance = await initFhe();
const address = await signer.getAddress();

if (instance.hasKeypair(address)) {
return;
}

const { publicKey, privateKey } = instance.generateKeypair();

const eip712 = instance.createEIP712(publicKey, address);
const signature = await signer.signTypedData(
eip712.domain,
{ Reencrypt: eip712.types.Reencrypt },
eip712.message
);

instance.setKeypair(address, { publicKey, privateKey, signature });
}

Token Operations

Token Decimals

All tokens in Lunarys eDEX use 6 decimals.

const TOKEN_DECIMALS = 6;

export function toTokenAmount(amount: number): bigint {
return BigInt(Math.floor(amount * 10 ** TOKEN_DECIMALS));
}

export function fromTokenAmount(amount: bigint): number {
return Number(amount) / 10 ** TOKEN_DECIMALS;
}

Set Operator (Required Before Swaps/Liquidity)

Before interacting with a pool, the user must set the pool as an operator on both tokens. This allows the pool to transfer tokens on behalf of the user.

import { Contract, Signer } from "ethers";

const CERC20_ABI = [
"function setOperator(address operator, uint48 until) external",
"function isOperator(address holder, address spender) external view returns (bool)",
"function symbol() external view returns (string)",
"function decimals() external view returns (uint8)",
"function confidentialBalanceOf(address account) external view returns (bytes32)",
];

export async function setPoolAsOperator(
tokenAddress: string,
poolAddress: string,
signer: Signer,
durationSeconds: number = 86400
): Promise<void> {
const token = new Contract(tokenAddress, CERC20_ABI, signer);
const signerAddress = await signer.getAddress();

const isOperator = await token.isOperator(signerAddress, poolAddress);
if (isOperator) {
return;
}

const currentTimestamp = Math.floor(Date.now() / 1000);
const expiryTimestamp = currentTimestamp + durationSeconds;

const tx = await token.setOperator(poolAddress, expiryTimestamp);
await tx.wait();
}

Swap Operations

Pool Contract ABI

const EPOOL_ABI = [
// Read functions
"function assetA() external view returns (address)",
"function assetB() external view returns (address)",
"function swapFeeBps() external view returns (uint24)",
"function reservesInitialized() external view returns (bool)",
"function obfuscatedStates() external view returns (bytes32 obfuscatedReserveA, bytes32 obfuscatedReserveB, bytes32 lpSupply)",

// Swap functions
"function atomicSwapAForB(bytes calldata amountInExt, bytes calldata minAmountOutExt, bytes calldata proofIn, address recipient, uint128 decryptedORA, uint128 decryptedORB, bytes calldata reserveProof) external",
"function atomicSwapBForA(bytes calldata amountInExt, bytes calldata minAmountOutExt, bytes calldata proofIn, address recipient, uint128 decryptedORA, uint128 decryptedORB, bytes calldata reserveProof) external",

// Liquidity functions
"function contributeLiquidity(bytes calldata amountAExt, bytes calldata amountBExt, bytes calldata amountProof, uint128 decryptedORA, uint128 decryptedORB, uint128 decryptedOL, bytes calldata OProof) external",
"function removeLiquidity(bytes calldata sharesToRemoveExt, bytes calldata sharesProof, uint128 decryptedORA, uint128 decryptedORB, uint128 decryptedOL, bytes calldata OProof) external",

// Events
"event AtomicSwapExecuted(address indexed caller, address indexed recipient, bool aForB, bytes32 amountOutHandle)",
"event LiquidityAdjusted(address indexed caller, bool add)",
];

Get Obfuscated Reserves and Decrypt

export interface ObfuscatedReserves {
obfuscatedReserveA: string;
obfuscatedReserveB: string;
lpSupply: string;
}

export interface DecryptedReserves {
reserveA: bigint;
reserveB: bigint;
lpSupply: bigint;
proof: string;
}

export async function getObfuscatedReserves(
poolAddress: string,
provider: BrowserProvider
): Promise<ObfuscatedReserves> {
const pool = new Contract(poolAddress, EPOOL_ABI, provider);
const states = await pool.obfuscatedStates();

return {
obfuscatedReserveA: states.obfuscatedReserveA,
obfuscatedReserveB: states.obfuscatedReserveB,
lpSupply: states.lpSupply,
};
}

export async function decryptReserves(
obfuscatedReserves: ObfuscatedReserves,
includeLP: boolean = false
): Promise<DecryptedReserves> {
const instance = await initFhe();

const handles = [
obfuscatedReserves.obfuscatedReserveA,
obfuscatedReserves.obfuscatedReserveB,
];

if (includeLP) {
handles.push(obfuscatedReserves.lpSupply);
}

const decryption = await instance.gateway.decrypt(handles);

return {
reserveA: BigInt(decryption.values[0]),
reserveB: BigInt(decryption.values[1]),
lpSupply: includeLP ? BigInt(decryption.values[2]) : 0n,
proof: decryption.proof,
};
}

Create Encrypted Swap Inputs

export async function createSwapEncryptedInputs(
poolAddress: string,
userAddress: string,
amountIn: bigint,
minAmountOut: bigint = 0n
) {
const instance = await initFhe();
const input = instance.createEncryptedInput(poolAddress, userAddress);

input.add64(amountIn);
input.add64(minAmountOut);

const encrypted = input.encrypt();

return {
amountInHandle: encrypted.handles[0],
minAmountOutHandle: encrypted.handles[1],
inputProof: encrypted.inputProof,
};
}

Calculate Expected Output (Approximate)

Since reserves are obfuscated, we can only estimate the output. The actual output is computed on-chain with the real (encrypted) reserves.

// Fee varies per pool: read pool.swapFeeBps() for the actual value
// Most pools use 3000 (0.3%), stablecoin pools use 1000 (0.1%)
const SWAP_FEE_BPS = 3000n; // default 0.3% fee
const BPS_DENOMINATOR = 1_000_000n;
const MIN_OBFUSCATION_FACTOR = 1_000_000n;
const MAX_OBFUSCATION_FACTOR = 3_000_000n;

export function estimateSwapOutput(
amountIn: bigint,
obfuscatedReserveIn: bigint,
obfuscatedReserveOut: bigint
): { minOutput: bigint; maxOutput: bigint; midOutput: bigint } {
const reserveInMin = obfuscatedReserveIn / MAX_OBFUSCATION_FACTOR;
const reserveInMax = obfuscatedReserveIn / MIN_OBFUSCATION_FACTOR;
const reserveOutMin = obfuscatedReserveOut / MAX_OBFUSCATION_FACTOR;
const reserveOutMax = obfuscatedReserveOut / MIN_OBFUSCATION_FACTOR;

const effectiveIn = (amountIn * (BPS_DENOMINATOR - SWAP_FEE_BPS)) / BPS_DENOMINATOR;

const maxOutput = (effectiveIn * reserveOutMax) / (reserveInMin + effectiveIn);
const minOutput = (effectiveIn * reserveOutMin) / (reserveInMax + effectiveIn);

const reserveInMid = (reserveInMin + reserveInMax) / 2n;
const reserveOutMid = (reserveOutMin + reserveOutMax) / 2n;
const midOutput = (effectiveIn * reserveOutMid) / (reserveInMid + effectiveIn);

return { minOutput, maxOutput, midOutput };
}

Execute Swap

export async function executeSwap(
params: {
poolAddress: string;
amountIn: bigint;
minAmountOut: bigint;
swapAForB: boolean;
recipient?: string;
},
signer: Signer
): Promise<string> {
const { poolAddress, amountIn, minAmountOut, swapAForB, recipient } = params;

const userAddress = await signer.getAddress();
const recipientAddress = recipient || userAddress;
const pool = new Contract(poolAddress, EPOOL_ABI, signer);

const assetA = await pool.assetA();
const assetB = await pool.assetB();
const inputAsset = swapAForB ? assetA : assetB;

await setPoolAsOperator(inputAsset, poolAddress, signer);

const obfuscatedReserves = await getObfuscatedReserves(
poolAddress, signer.provider as BrowserProvider
);
const decryptedReserves = await decryptReserves(obfuscatedReserves, false);

const encryptedInputs = await createSwapEncryptedInputs(
poolAddress, userAddress, amountIn, minAmountOut
);

let tx;
if (swapAForB) {
tx = await pool.atomicSwapAForB(
encryptedInputs.amountInHandle, encryptedInputs.minAmountOutHandle,
encryptedInputs.inputProof, recipientAddress,
decryptedReserves.reserveA, decryptedReserves.reserveB,
decryptedReserves.proof
);
} else {
tx = await pool.atomicSwapBForA(
encryptedInputs.amountInHandle, encryptedInputs.minAmountOutHandle,
encryptedInputs.inputProof, recipientAddress,
decryptedReserves.reserveA, decryptedReserves.reserveB,
decryptedReserves.proof
);
}

const receipt = await tx.wait();
return receipt.hash;
}

Liquidity Operations

Add Liquidity

export async function addLiquidity(
params: {
poolAddress: string;
amountA: bigint;
amountB: bigint;
},
signer: Signer
): Promise<string> {
const { poolAddress, amountA, amountB } = params;

const userAddress = await signer.getAddress();
const pool = new Contract(poolAddress, EPOOL_ABI, signer);

const assetA = await pool.assetA();
const assetB = await pool.assetB();

await setPoolAsOperator(assetA, poolAddress, signer);
await setPoolAsOperator(assetB, poolAddress, signer);

const obfuscatedReserves = await getObfuscatedReserves(
poolAddress, signer.provider as BrowserProvider
);
const decryptedState = await decryptReserves(obfuscatedReserves, true);

const instance = await initFhe();
const input = instance.createEncryptedInput(poolAddress, userAddress);
input.add64(amountA);
input.add64(amountB);
const encrypted = input.encrypt();

const tx = await pool.contributeLiquidity(
encrypted.handles[0], encrypted.handles[1],
encrypted.inputProof,
decryptedState.reserveA, decryptedState.reserveB,
decryptedState.lpSupply, decryptedState.proof
);

const receipt = await tx.wait();
return receipt.hash;
}

Remove Liquidity

export async function removeLiquidity(
params: {
poolAddress: string;
sharesToRemove: bigint;
},
signer: Signer
): Promise<string> {
const { poolAddress, sharesToRemove } = params;

const userAddress = await signer.getAddress();
const pool = new Contract(poolAddress, EPOOL_ABI, signer);

await setPoolAsOperator(poolAddress, poolAddress, signer);

const obfuscatedReserves = await getObfuscatedReserves(
poolAddress, signer.provider as BrowserProvider
);
const decryptedState = await decryptReserves(obfuscatedReserves, true);

const instance = await initFhe();
const input = instance.createEncryptedInput(poolAddress, userAddress);
input.add64(sharesToRemove);
const encrypted = input.encrypt();

const tx = await pool.removeLiquidity(
encrypted.handles[0], encrypted.inputProof,
decryptedState.reserveA, decryptedState.reserveB,
decryptedState.lpSupply, decryptedState.proof
);

const receipt = await tx.wait();
return receipt.hash;
}

Estimate LP Tokens

export function estimateLPTokens(
amountA: bigint,
amountB: bigint,
decryptedReserveA: bigint,
decryptedReserveB: bigint,
decryptedLPSupply: bigint
): bigint {
const lpFromA = (amountA * decryptedLPSupply) / decryptedReserveA;
const lpFromB = (amountB * decryptedLPSupply) / decryptedReserveB;
return lpFromA < lpFromB ? lpFromA : lpFromB;
}

Estimate Remove Liquidity Output

const WITHDRAWAL_FEE_BPS = 500n; // 0.05%

export function estimateRemoveLiquidityOutput(
sharesToRemove: bigint,
decryptedReserveA: bigint,
decryptedReserveB: bigint,
decryptedLPSupply: bigint
): { amountA: bigint; amountB: bigint; feeA: bigint; feeB: bigint } {
const grossA = (sharesToRemove * decryptedReserveA) / decryptedLPSupply;
const grossB = (sharesToRemove * decryptedReserveB) / decryptedLPSupply;

const feeA = (grossA * WITHDRAWAL_FEE_BPS) / BPS_DENOMINATOR;
const feeB = (grossB * WITHDRAWAL_FEE_BPS) / BPS_DENOMINATOR;

const amountA = grossA - feeA;
const amountB = grossB - feeB;

return { amountA, amountB, feeA, feeB };
}

Error Handling

Common Errors

ErrorCauseSolution
EPOOL: reserves-not-initializedPool not bootstrappedWait for pool initialization
EPOOL: already-initializedBootstrap called twiceSkip bootstrap
InvalidRecipientZero address recipientProvide valid recipient
Taylor bound exceededSwap too largeSplit into smaller swaps
Signature verification failedStale obfuscated reservesRe-fetch reserves and proof
Operator not setMissing setOperator callCall setOperator first

Important Notes

  1. All amounts use 6 decimals - Convert user input accordingly
  2. Operator must be set before any transfer - Set pool as operator on input tokens
  3. Obfuscated reserves change after every operation - Always fetch fresh before transactions
  4. Proofs have limited validity - Re-fetch reserves and proof if transaction fails
  5. Withdrawal fee is 0.05% - Deducted from removed liquidity, stays in pool
  6. Swap fee varies per pool - Read pool.swapFeeBps() (0.3% for most, 0.1% for stablecoin pairs)
  7. Taylor approximation bounds - Large swaps (>6.25% of reserve) may be limited