Universal Router
The Universal Router is the coordination contract that discovers confidential pools, validates swap paths, and safely creates new pools on demand. It complements the pool contracts by acting as the single source of truth for routing metadata, while leaving encrypted executions to each PrivacyPool.
Key difference vs. public AMMs: encrypted inputs are bound to a specific pool address in fhEVM. For that reason the router never proxies swaps or liquidity calls—it focuses on orchestration, not execution.
Responsibilities
- Deterministic pool creation via
PrivacyPoolFactory - Pool registry lookups (
getPool,poolExists) - Path validation and quoting for multi-hop routes
- Safety checks around token ordering and fee tiers
- Emission of canonical
PoolCreatedevents for indexers
Capabilities Overview
| Feature | Description |
|---|---|
| Pool deployment | Calls factory.createPool and returns the deployed pool address. |
| Path discovery | Validates the (path, fees) tuple and returns the concrete pool addresses for every hop. |
| Pool existence checks | Lightweight guards for frontends and scripts before presenting an action to a user. |
| Helper utilities | Leverages UniversalRouterHelper to reuse path validation logic off-chain and on-chain. |
The contract inherits SepoliaConfig to gain access to fhEVM configuration and ReentrancyGuard for safety during pool creation bursts.
On-chain API
| Function | Signature | Notes |
|---|---|---|
createPool | function createPool(IERC7984 tokenA, IERC7984 tokenB, uint24 fee) external returns (address pool) | Deploys a new pool through the factory and emits PoolCreated. No liquidity is added—call bootstrap on the returned pool. |
getPool | function getPool(IERC7984 tokenA, IERC7984 tokenB, uint24 fee) external view returns (address pool) | Returns the deterministic address or address(0) if the pair has not been deployed for that fee tier. |
quotePath | function quotePath(address[] calldata path, uint24[] calldata fees) external view returns (address[] memory pools) | Validates the entire route and returns the ordered pool list; reverts with InvalidPath/PoolNotFound when a hop is missing. |
poolExists | function poolExists(IERC7984 tokenA, IERC7984 tokenB, uint24 fee) external view returns (bool exists) | Gas-cheap existence probe for dashboards or bots. |
isValidPath | function isValidPath(address[] calldata path, uint24[] calldata fees) external view returns (bool valid) | Boolean-only validation helper for interfaces that prefer not to bubble custom errors. |
All revert reasons are intentionally explicit (InvalidPath, InvalidFeeLength, PoolNotFound, ZeroAddress) to improve UX logging when routing pipelines fail fast.
Deployment & Bootstrap Flow
- Create the pool through the router
address pool = router.createPool(lunToken, usdcToken, 3000); - Frontends store the emitted
PoolCreatedevent so liquidity UIs and analytics know the canonical pool address. - Bootstrap directly on the pool using encrypted inputs that are generated for the pool address:
const enc = await fhevm.createEncryptedInput(pool, providerAddress);
enc.add64(bootstrapAmount);
const encrypted = await enc.encrypt();
await privacyPool.bootstrap(
encrypted.handles[0],
encrypted.inputProof,
encrypted.handles[1],
encrypted.inputProof,
lpRecipient,
"0x"
); - Subsequent liquidity and swap operations continue to call the pool, never the router, ensuring encrypted handles remain valid.
Path Discovery & Validation
Use the read-only helpers to guarantee that every hop in a route has an existing pool and fee tier.
const path = [LUN, USDC, eBTC];
const fees = [3000, 3000];
const pools = await router.quotePath(path, fees); // throws if a hop is missing
console.log("Pool sequence:", pools);
const isValid = await router.isValidPath(path, fees); // boolean guard
Under the hood the router relies on UniversalRouterHelper.validatePath to enforce:
path.length >= 2fees.length === path.length - 1- No zero addresses
- Consecutive tokens match between hops
These checks can also be reused off-chain by importing the helper library in scripts.
Single-Hop Swap Workflow
While the router cannot execute encrypted swaps, it still plays a role in operator UX:
- Fetch the pool address via
getPool(tokenIn, tokenOut, fee). - Generate encrypted input handles that target that pool.
- Call
swapExactAForBorswapExactBForAon the pool and capture theSwapDecryptionRequestedevent. - Monitor the oracle settlement and emit UI notifications once
SwapSettledfires.
Example helper snippet:
const pool = await router.getPool(LUN, USDC, 3000);
if (pool === ethers.ZeroAddress) throw new Error("Pool missing");
const encIn = await fhevm.createEncryptedInput(pool, signer.address);
encIn.add64(ethers.parseUnits("1000", 6));
const encrypted = await encIn.encrypt();
await privacyPool__factory.connect(pool, signer).swapExactAForB(
encrypted.handles[0],
encrypted.inputProof,
recipient,
"0x"
);
Multi-Hop Routing Strategy
- Use
quotePathto retrieve every pool address in the route. - Execute each hop sequentially, waiting for settlement before forwarding the encrypted output of hop
nto hopn+1. - Keep contextual metadata (deadline, hook context, masking parameters) in your orchestration layer because the router does not store per-route state.
const pools = await router.quotePath(path, fees);
for (const pool of pools) {
await waitForSettlement(
await privacyPool__factory.connect(pool, signer).swapExactAForB(
currentHopHandle,
currentHopProof,
signer.address,
"0x"
)
);
// derive the handle for the next hop from the oracle output...
}
Operational Considerations
- **Encrypted handles are pool-bound.**Never reuse encrypted amounts generated for the router or a different pool.
- **Multi-hop is asynchronous.**Each hop must settle before proceeding, so orchestrators should persist state between steps.
- **Slippage management is custom.**Because the router cannot preview plaintext reserves, enforce slippage bounds off-chain by simulating expected outputs and comparing against oracle results.
- **Factory ownership.**Pool creation rights follow whatever ownership the factory exposes (typically the deployer or DAO). Ensure governance controls align with the DAO model.
Event Reference
| Event | Parameters | Usage |
|---|---|---|
PoolCreated(address pool, address tokenA, address tokenB, uint24 fee) | Emitted after every successful call to createPool. | Index new pools, kick off bootstrap workflows, or refresh analytics caches. |
Integration Checklist
Sepolia Testnet Deployment
The UniversalRouter redeployment is pending. The latest PrivacyPoolFactory lives at 0x325E2fC63CD617C1A09bA41f6fD9bbe5eFc074d8 on Sepolia and will be linked once the new router address is finalized. Refer to Deployed Addresses for updates.
Integration Steps
-
Connect to the Router: Once redeployed, use the router address published in Deployed Addresses. The router will reference the factory at
0x325E2fC63CD617C1A09bA41f6fD9bbe5eFc074d8. -
ABI Integration: Mirror the router ABI in your SDK or reuse the generated TypeChain types from the Hardhat deployment.
-
Pool Discovery: Always call
router.poolExists(tokenA, tokenB, fee)before presenting swap or liquidity actions to end-users to verify the pool exists. -
Event Monitoring: When orchestrating swaps, persist each pending
requestIDfrom pool events and subscribe to the oracle settlement events for asynchronous completion. -
Documentation: Reference the complete lifecycle documentation in PrivacyPool, Liquidity Provision, and Swap Mechanism for detailed implementation guidance.
Contract Addresses Reference
For the complete list of deployed contracts, token addresses, and pool addresses, see Deployed Addresses.
By keeping the router focused on discovery and path validation while pools handle encrypted execution, Lunarys preserves the privacy guarantees of fhEVM without sacrificing developer ergonomics.