A perpetual futures market implemented inside a Uniswap v4 hook: traders act through the v4 unlock-callback path, and per-pool OI skew drives on-chain fee overrides and exposure limits. Funding settlement and liquidations are automated by Reactive Network reactors subscribed to Unichain events.
Theme alignment — Specialized Markets
This repo is a hook template for perp markets on any v4 pool: market identity is the pool (PoolId) and all perp state is keyed by it. The design is Unichain-native because execution runs inside v4 hook callbacks on Unichain’s PoolManager.
Project tags
Perpetual Futures Dynamic Fees Hook-Native Perps Skew Engine Reactive Automation Unichain Pyth Oracle Non-Custodial Margin
Docs: contracts.md (protocol + contracts) · frontend.md (UI + wagmi/Pyth wiring)
Perp DEXs and spot AMMs are usually separate systems: perps depend on external oracles/keepers, while AMMs do not price the risk of one-sided perp demand to LPs.
This separation produces oracle manipulation/staleness risk, invisible LP directional exposure, and no native skew deterrent; keepers add liveness/incentive failure modes.
Uniswap v4 hooks allow the perp lifecycle to run inside swap execution: perp actions are v4 “virtual swaps” (sentinel amountSpecified + encoded hookData) so margin checks, OI updates, funding accrual, and automation events are atomic.
Perp state is pool-native (PoolId), and PerpHook.beforeSwap() converts skew into a fee override and an exposure block using RiskEngine:
skewFactor = |longOI - shortOI| / (longOI + shortOI) (WAD, 1e18)
worsensSkew fee: fee = baseFee * (1 + skewFactor * 3.0), capped at baseFee * 3.0
improvesSkew fee: fee = baseFee * max(0.2, 1 - skewFactor * 0.8), floored at baseFee * 0.2
Spot swaps currently use a fixed fee override (DEFAULT_BASE_FEE = 3000 pips); skew-fees are scoped to perp “virtual swaps” in this demo.
0x34d737160c8DE1f1a24978cae0B5a62b828190c0
v4 hook callbacks + perp coordination (margin, skew, funding, events).
0xc71c82c0a4c4D7C0D4beEc6E96668fC5439032F5
User entrypoint; routes actions through v4 unlock-callback.
0x0DA5A02762146f5697F951d423B200d419736046
Per-trader positions keyed by PoolId (WAD units).
0x0207149577b97c305C275694a3f5D89a826e8c85
USDC custody (6 decimals): free/locked collateral accounting.
[address — see unichain-1301.json]
Pure math: margin, skew, fee multipliers, max leverage (20×), and 80% dominant-side exposure cap threshold.
0x70177cbA4c0491865F6b29Aad1eaaa81Ba731B18
Funding index accrual + lazy settlement + Reactive settlement hook.
0x9E7bC7BA424E0EEFf7a25F2637da0c544A9B0B00
Maintenance margin check + close; permissionless + Reactive entrypoints.
0xcAb594a7b304a9C8BC989fA4CDF9fd042d458909
Lasna reactor: PositionUnderwater → liquidation callback.
0x487FAFf3Fdf0a2e01E28120529Ece386EA74740A
Lasna reactor: FundingDue → funding settlement callback.
0x94d48675B76CFB487feB95225a1c5D54c4f2Ee0D
6-decimal mock token with public mint() (testnet/demo only).
0x674f676860c182Ac1D04F20FeFD392B56845DfC8
8-decimal mock token with public mint() (testnet/demo only).
MarginVault.deposit()PerpController.openPosition(..., priceUpdateData)→ Pyth update +PoolManager.unlock()PerpController.unlockCallback→ sentinelPoolManager.swap()→PerpHook.beforeSwap/afterSwapFundingEngine.accrue+PerpHookemitsFundingDue(interval)FundingReactor/LiquidationReactorrelay callbacks to Unichain enginesPerpController.closePosition(..., priceUpdateData)→ Pyth update + settle + unlock collateral
- Why Unichain: v4
PoolManageris deployed natively on Unichain Sepolia at0x00B036B58a818B1BC34d502D3fE730Db729e62AC(fromcontracts/deployments/unichain-1301.json), and the hook is embedded in the pool’s immutablePoolKey. - Chain-specific code:
frontend/lib/wagmi.ts(chain1301),contracts/deployments/unichain-1301.json(addresses),frontend/scripts/sync-sepolia-env.mjs(writes.env.local), andNEXT_PUBLIC_CHAIN_ID=1301gates the faucet infrontend/components/SpotSwap.tsx. - Pool initialization: dedicated hook pool (MockWBTC/MockUSDC +
PerpHook); pool ID0xcffe71c8d7ada94270e05e66cf99b6fff73f64ae44ebe0a78de2b19788c0eb31. - Explorer:
https://sepolia.uniscan.xyz(PerpHookandPerpControllerbelow).
https://sepolia.uniscan.xyz/address/0x34d737160c8DE1f1a24978cae0B5a62b828190c0
https://sepolia.uniscan.xyz/address/0xc71c82c0a4c4D7C0D4beEc6E96668fC5439032F5
Why Reactive: funding settlement and liquidations are usually keeper jobs; here they are event-driven callbacks (no bot, no incentive token).
LiquidationReactor (contracts/src/reactive/LiquidationReactor.sol): Lasna 5318007, watches PerpHook.PositionUnderwater on Unichain 1301, callbacks LiquidationEngine.liquidateFromReactive(reactVmId, trader, pool), address 0xcAb594a7b304a9C8BC989fA4CDF9fd042d458909.
FundingReactor (contracts/src/reactive/FundingReactor.sol): Lasna 5318007, watches PerpHook.FundingDue on Unichain 1301, callbacks FundingEngine.settleFromReactive(reactVmId, pool), address 0x487FAFf3Fdf0a2e01E28120529Ece386EA74740A.
Constraints: ReactVM react() only emits Callback; constructors run twice (if (!vm) subscribe); callback arg0 is overwritten with ReactVM ID; reactors must be deployed funded (--value 0.01ether).
Deployment: contracts/script/deploy-reactive-lasna.sh. Lasna explorer: https://lasna.reactscan.net.
Pull oracle: PerpHook._getSpotPrice() uses pyth.getPriceNoOlderThan(priceFeedId, 60), and PerpController updates Pyth from bytes[] priceUpdateData fetched from Hermes (usePythPriceData.ts). Feed: BTC/USD 0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43.
Node.js >=20, forge, cast, jq, and a wallet funded with Unichain Sepolia ETH.
git clone [repo]
cd adaptive-perp-dex
cd contracts && forge install
cd ../frontend && npm installcd frontend
npm run demo:sepolia-env
# Reads ../contracts/deployments/unichain-1301.json
# Writes .env.local with all NEXT_PUBLIC_ varsnpm run dev
# Open http://localhost:3000- Connect wallet via RainbowKit
- Switch to Unichain Sepolia (chainId 1301) — auto-prompted
- SPOT tab → "Get test tokens" → mint 100k USDC + 10 MockWBTC
- PERPS tab → Deposit → approve USDC → deposit collateral
- Set size (BTC, 8 decimals) + leverage → Open Long / Open Short
- Watch position in
PositionsPanel+ skew bar update
Note about Pyth: every open/close fetches Hermes update bytes and bundles them with the tx; PerpHook enforces maxPriceAge.
After a FundingDue event fires (once fundingInterval elapses), both reactors are visible at https://lasna.reactscan.net.
# Deploy contracts to Unichain Sepolia
cd contracts
cp .env.example .env.sepolia
# fill: PRIVATE_KEY, UNICHAIN_SEPOLIA_RPC, PYTH_CONTRACT
forge script script/DeployUnichain.s.sol \
--rpc-url $UNICHAIN_SEPOLIA_RPC \
--private-key $PRIVATE_KEY \
--broadcast
# Deploy Reactive reactors to Lasna
# (ensure REACTIVE_RPC_URL and ETHEREUM_SEPOLIA_RPC are set)
bash script/deploy-reactive-lasna.shPerpController (contracts/src/core/PerpController.sol):
function openPosition(PoolKey calldata poolKey, bool isLong, int256 sizeDelta, uint256 leverage) external payable;
function openPosition(PoolKey calldata poolKey, bool isLong, int256 sizeDelta, uint256 leverage, bytes[] memory priceUpdateData)
public payable;
function closePosition(PoolKey calldata poolKey, int256 sizeDelta) external payable;
function closePosition(PoolKey calldata poolKey, int256 sizeDelta, bytes[] memory priceUpdateData) public payable;
function closePositionInternal(address trader, PoolKey calldata key) external;
function liquidate(PoolKey calldata key, address trader) external;
function unlockCallback(bytes calldata data) external returns (bytes memory);
function addCollateral(PoolKey calldata key, uint256 amount) external;
function removeCollateral(PoolKey calldata key, uint256 amount) external;
function setLiquidationEngine(address _liquidationEngine) external;
function setFundingEngine(address _fundingEngine) external;
function setPerpHook(address _perpHook) external;
function setMarginVault(address _marginVault) external;
function setPositionManager(address _positionManager) external;
function setPyth(address _pyth) external;| Function | What it does | Emits |
|---|---|---|
openPosition (2 overloads) |
Optional Pyth update; routes OPEN through v4 unlock path. | via hook: PositionOpened |
closePosition (2 overloads) |
Optional Pyth update; routes CLOSE through v4 unlock path. | via hook: PositionClosed |
closePositionInternal |
Liquidation-only close path. | via hook: PositionClosed |
liquidate |
Routes LIQUIDATE through v4 unlock path. | via engine: PositionLiquidated (if triggered) |
unlockCallback |
PoolManager-only; executes sentinel swap. | — |
addCollateral |
Claims funding; locks more USDC via hook. | CollateralAdded |
removeCollateral |
Claims funding; releases USDC via hook. | CollateralRemoved |
set* |
Admin wiring (one-time) + setPyth. |
— |
PerpHook (contracts/src/core/PerpHook.sol):
function setAdmin(address newAdmin) external;
function getHookPermissions() public pure returns (Hooks.Permissions memory);
function claimFees(PoolId poolId) external;
function adjustCollateral(address trader, PoolId pool, int256 delta) external;
function emitPositionUnderwater(address trader, bytes32 poolId, uint256 marginRatio) external;
function checkAndEmitUnderwaterIfNeeded(address trader, bytes32 poolIdBytes) external;
function setLiquidationEngine(address _le) external;
function setPositionManager(address _pm) external;
function setMarginVault(address _mv) external;
function setFundingEngine(address _fe) external;
function setPerpController(address _pc) external;
function setPyth(address _pyth) external;
function setPriceFeedId(bytes32 _feedId) external;
function setMaxPriceAge(uint256 _age) external;
function getMarkPrice(bytes32) external view returns (uint256);| Function | What it does | Emits |
|---|---|---|
setAdmin / getHookPermissions |
Admin / v4 permissions. | — |
claimFees |
Transfers accrued protocol fees to protocolFeeRecipient. |
FeesWithdrawn |
adjustCollateral |
Controller-only lock/release + position metadata update. | — |
emitPositionUnderwater / checkAndEmitUnderwaterIfNeeded |
FundingEngine-only underwater signaling. | PositionUnderwater |
setLiquidationEngine/setPositionManager/setMarginVault/setFundingEngine/setPerpController |
One-time wiring (admin-only). | — |
setPyth/setPriceFeedId/setMaxPriceAge |
Oracle config (admin-only). | — |
getMarkPrice |
Returns Pyth mark price (WAD). | — |
| Variable | Source field | Required | Description |
|---|---|---|---|
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID |
(preserved) | Optional | WalletConnect project ID. |
NEXT_PUBLIC_CHAIN_ID |
(script constant) | Required | Default chain selection + UI gating. |
NEXT_PUBLIC_RPC_URL |
(script constant) | Required | Frontend RPC. |
NEXT_PUBLIC_TOKEN0 |
token0 (or derived) |
Required | v4 currency0. |
NEXT_PUBLIC_TOKEN1 |
token1 (or derived) |
Required | v4 currency1. |
NEXT_PUBLIC_POOL_MANAGER |
poolManager |
Required | v4 PoolManager. |
NEXT_PUBLIC_PERP_HOOK |
perpHook |
Required | PerpHook address. |
NEXT_PUBLIC_PERP_CONTROLLER |
perpController |
Required | PerpController address. |
NEXT_PUBLIC_MARGIN_VAULT |
marginVault |
Required | MarginVault address. |
NEXT_PUBLIC_POSITION_MANAGER |
positionManager |
Required | PositionManager address. |
NEXT_PUBLIC_FUNDING_ENGINE |
fundingEngine |
Required | FundingEngine address. |
NEXT_PUBLIC_LIQUIDATION_ENGINE |
liquidationEngine |
Required | LiquidationEngine address. |
NEXT_PUBLIC_USDC |
usdc |
Required | MockUSDC address. |
NEXT_PUBLIC_WBTC |
wbtc |
Required | MockWBTC address. |
NEXT_PUBLIC_POOL_ID |
poolId |
Optional | Pool ID (display/scripts). |
NEXT_PUBLIC_PYTH_CONTRACT |
pythContract |
Required | Pyth contract. |
NEXT_PUBLIC_PRICE_FEED_ID |
priceFeedId |
Required | BTC/USD feed ID. |
NEXT_PUBLIC_INVERT_PRICE |
invertPrice |
Optional | Deployment flag. |
adaptive-perp-dex/
├── contracts/
│ ├── src/
│ │ ├── core/ # PerpHook, PerpController, PositionManager, MarginVault
│ │ ├── engines/ # RiskEngine, FundingEngine, LiquidationEngine
│ │ ├── reactive/ # LiquidationReactor, FundingReactor
│ │ └── mocks/ # MockUSDC, MockWBTC
│ ├── script/
│ │ ├── DeployUnichain.s.sol
│ │ └── deploy-reactive-lasna.sh
│ ├── test/
│ └── deployments/
│ ├── unichain-1301.json # live testnet addresses
│ └── anvil-31337.json # local dev addresses
└── frontend/
├── app/ # Next.js pages and layout
├── components/ # UI components
├── context/ # MarketContext (skew, fees, prices)
├── hooks/ # usePythPrice, usePythPriceData, etc.
├── lib/ # contracts.ts, abis.ts, wagmi.ts, format.ts
└── scripts/ # sync-sepolia-env.mjs, reactive-demo.mjs
MIT