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
| Concept | Description |
|---|---|
| CERC20 | Confidential ERC20 tokens (ERC-7984 standard) with encrypted balances |
| EPOOL | Encrypted AMM pool holding two CERC20 assets |
| Obfuscated Reserves | Publicly decryptable reserves multiplied by a random factor (1M-3M) |
| Encrypted Inputs | User amounts encrypted client-side before sending to contracts |
| Proofs | FHE signatures proving encrypted values match decrypted values |
Flow Overview
User Input (clear) -> Encrypt (FHE SDK) -> Contract (encrypted operations) -> Decrypt Result
For swaps and liquidity:
- Get obfuscated reserves from the pool (publicly decryptable)
- Decrypt reserves using FHE gateway and get proof
- Encrypt user amounts (swap amount, liquidity amounts)
- Call contract with encrypted amounts + decrypted reserves + proofs
- 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
| Error | Cause | Solution |
|---|---|---|
EPOOL: reserves-not-initialized | Pool not bootstrapped | Wait for pool initialization |
EPOOL: already-initialized | Bootstrap called twice | Skip bootstrap |
InvalidRecipient | Zero address recipient | Provide valid recipient |
| Taylor bound exceeded | Swap too large | Split into smaller swaps |
| Signature verification failed | Stale obfuscated reserves | Re-fetch reserves and proof |
| Operator not set | Missing setOperator call | Call setOperator first |
Important Notes
- All amounts use 6 decimals - Convert user input accordingly
- Operator must be set before any transfer - Set pool as operator on input tokens
- Obfuscated reserves change after every operation - Always fetch fresh before transactions
- Proofs have limited validity - Re-fetch reserves and proof if transaction fails
- Withdrawal fee is 0.05% - Deducted from removed liquidity, stays in pool
- Swap fee varies per pool - Read
pool.swapFeeBps()(0.3% for most, 0.1% for stablecoin pairs) - Taylor approximation bounds - Large swaps (>6.25% of reserve) may be limited