Skip to content

Commit 530e5af

Browse files
committed
[simplex,sdk]: add Uniswap V4 funding venue and unified FundingVenue interface
Introduce a FundingVenue abstraction so FXFiller holds a single venue array instead of venue-specific fields. This enables atomic LP withdrawal from multiple liquidity sources (Aerodrome, Uniswap V4) within a single ERC-7821 batched UserOp. Key changes: - FundingVenue interface with initialise/refresh/planWithdrawalForToken - UniswapV4FundingPlanner: withdraws from V4 concentrated-liquidity NFT positions via modifyLiquidities (DECREASE_LIQUIDITY + TAKE_PAIR) - UniswapV4LiquidityState: hydrates position metadata on-chain from just tokenId, tracks liquidity accounting for concurrent orders - Integer-only tick math (port of Uniswap TickMath.sol) for amount calcs - AerodromeFundingPlanner now implements FundingVenue - Remove gas multiplier heuristic: pass prependCalls to SDK estimator so bundler simulates the full atomic batch for accurate gas limits - Move venue config validation into respective planner classes
1 parent 7f32793 commit 530e5af

File tree

16 files changed

+1081
-98
lines changed

16 files changed

+1081
-98
lines changed

sdk/packages/sdk/src/configs/ChainConfigService.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,14 @@ export class ChainConfigService {
127127
return (this.getConfig(chain)?.addresses.UniswapV4Quoter ?? "0x") as HexString
128128
}
129129

130+
getUniswapV4PositionManagerAddress(chain: string): HexString {
131+
return (this.getConfig(chain)?.addresses.UniswapV4PositionManager ?? "0x") as HexString
132+
}
133+
134+
getUniswapV4PoolManagerAddress(chain: string): HexString {
135+
return (this.getConfig(chain)?.addresses.UniswapV4PoolManager ?? "0x") as HexString
136+
}
137+
130138
getPermit2Address(chain: string): HexString {
131139
return (this.getConfig(chain)?.addresses.Permit2 ?? "0x") as HexString
132140
}

sdk/packages/sdk/src/configs/chain.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ export interface ChainConfigData {
125125
SolverAccount?: `0x${string}`
126126
/** Aerodrome (Solidly-style) router for LP removal / swaps on chains where Aerodrome is deployed */
127127
AerodromeRouter?: `0x${string}`
128+
/** Uniswap V4 PositionManager (canonical CREATE2 address) for LP position management */
129+
UniswapV4PositionManager?: `0x${string}`
130+
/** Uniswap V4 PoolManager (canonical CREATE2 address) for pool state reads via extsload */
131+
UniswapV4PoolManager?: `0x${string}`
128132
}
129133
rpcEnvKey?: string
130134
defaultRpcUrl?: string
@@ -450,6 +454,8 @@ export const chainConfigs: Record<number, ChainConfigData> = {
450454
Permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
451455
EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108",
452456
AerodromeRouter: "0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43",
457+
UniswapV4PositionManager: "0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e",
458+
UniswapV4PoolManager: "0x000000000004444c5dc75cB358380D2e3dE08A90",
453459
// Usdt0Oft: Not available on Base
454460
},
455461
rpcEnvKey: "BASE_MAINNET",

sdk/packages/sdk/src/protocols/intents/GasEstimator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ export class GasEstimator {
157157
if (this.ctx.bundlerUrl) {
158158
try {
159159
const callData = this.crypto.encodeERC7821Execute([
160+
...(params.prependCalls ?? []),
160161
{ target: intentGatewayV2Address, value: totalNativeValue, data: fillOrderCalldata },
161162
])
162163

sdk/packages/sdk/src/types/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1212,6 +1212,12 @@ export interface SubmitBidOptions {
12121212

12131213
export interface EstimateFillOrderParams {
12141214
order: Order
1215+
/**
1216+
* Optional ERC-7821 calls to prepend before the fillOrder call in the
1217+
* simulated UserOp. Used for funding calls (e.g. LP withdrawal) so the
1218+
* bundler estimates gas for the complete atomic batch.
1219+
*/
1220+
prependCalls?: ERC7821Call[]
12151221
/**
12161222
* Optional percentage to bump maxPriorityFeePerGas.
12171223
* This is added on top of the base gasPrice.

sdk/packages/simplex/src/bin/simplex.ts

Lines changed: 40 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import { parse } from "toml"
77
import { IntentFiller } from "@/core/filler"
88
import { BasicFiller } from "@/strategies/basic"
99
import { FXFiller } from "@/strategies/fx"
10-
import type { AerodromePoolConfig, OutputFundingConfig } from "@/funding/types"
10+
import type { AerodromePoolConfig, FundingVenue, UniswapV4PositionConfig } from "@/funding/types"
11+
import { AerodromeFundingPlanner } from "@/funding/aerodrome/AerodromeFundingPlanner"
12+
import { UniswapV4FundingPlanner } from "@/funding/uniswapV4/UniswapV4FundingPlanner"
1113
import { ConfirmationPolicy, FillerBpsPolicy, FillerPricePolicy } from "@/config/interpolated-curve"
12-
import { ChainConfig, Chains, FillerConfig, HexString, getConfigByStateMachineId } from "@hyperbridge/sdk"
14+
import { ChainConfig, FillerConfig, HexString } from "@hyperbridge/sdk"
1315
import {
1416
FillerConfigService,
1517
UserProvidedChainConfig,
@@ -75,6 +77,12 @@ interface AerodromePoolToml extends AerodromePoolConfig {
7577
chain: string
7678
}
7779

80+
/** TOML row for a Uniswap V4 position; only chain + tokenId needed. */
81+
interface UniswapV4PositionToml {
82+
chain: string
83+
tokenId: string // bigint as string in TOML
84+
}
85+
7886
interface FxStrategyConfig {
7987
type: "hyperfx"
8088
/**
@@ -101,11 +109,14 @@ interface FxStrategyConfig {
101109
exoticTokenAddresses: Record<string, HexString>
102110
/** Optional per-chain confirmation policies for cross-chain orders */
103111
confirmationPolicies?: Record<string, ChainConfirmationPolicy>
104-
/** Optional Aerodrome LP funding for destination-chain outputs */
112+
/** Optional on-chain liquidity funding for destination-chain outputs */
105113
outputFunding?: {
106114
aerodrome?: {
107115
pools?: AerodromePoolToml[]
108116
}
117+
uniswapV4?: {
118+
positions?: UniswapV4PositionToml[]
119+
}
109120
}
110121
}
111122

@@ -421,7 +432,7 @@ program
421432
"No confirmationPolicies configured for hyperfx strategy; cross-chain orders will be skipped",
422433
)
423434
}
424-
let outputFunding: OutputFundingConfig | undefined
435+
const fundingVenues: FundingVenue[] = []
425436
if (strategyConfig.outputFunding?.aerodrome?.pools?.length) {
426437
const poolsByChain: Record<string, AerodromePoolConfig[]> = {}
427438
for (const row of strategyConfig.outputFunding.aerodrome.pools) {
@@ -432,12 +443,22 @@ program
432443
gauge: row.gauge,
433444
})
434445
}
435-
436-
outputFunding = {
437-
aerodrome: {
438-
poolsByChain,
439-
},
446+
fundingVenues.push(
447+
new AerodromeFundingPlanner(chainClientManager, { poolsByChain }, configService),
448+
)
449+
}
450+
if (strategyConfig.outputFunding?.uniswapV4?.positions?.length) {
451+
const positionsByChain: Record<string, UniswapV4PositionConfig[]> = {}
452+
for (const row of strategyConfig.outputFunding.uniswapV4.positions) {
453+
const chain = row.chain
454+
if (!positionsByChain[chain]) positionsByChain[chain] = []
455+
positionsByChain[chain].push({
456+
tokenId: BigInt(row.tokenId),
457+
})
440458
}
459+
fundingVenues.push(
460+
new UniswapV4FundingPlanner(chainClientManager, { positionsByChain }, configService),
461+
)
441462
}
442463
return new FXFiller(
443464
runtimeSigner,
@@ -449,23 +470,23 @@ program
449470
strategyConfig.maxOrderUsd,
450471
strategyConfig.exoticTokenAddresses,
451472
fxConfirmationPolicy,
452-
outputFunding,
473+
fundingVenues,
453474
)
454475
}
455476
default:
456477
throw new Error(`Unknown strategy type: ${(strategyConfig as StrategyConfig).type}`)
457478
}
458479
})
459480

460-
// Initialise FXFiller strategies
481+
// Initialise FXFiller strategies (hydrate funding venue state)
461482
for (const strategy of strategies) {
462483
if (strategy instanceof FXFiller) {
463-
logger.info("Hydrating Aerodrome funding state...")
484+
logger.info("Hydrating funding venue state...")
464485
await strategy.initialise()
465486
}
466487
}
467488

468-
// Set up periodic Aerodrome state refresh (~12s)
489+
// Set up periodic funding state refresh (~12s)
469490
const FUNDING_REFRESH_INTERVAL_MS = 12_000
470491
const fundingRefreshTimers: ReturnType<typeof setInterval>[] = []
471492
for (const strategy of strategies) {
@@ -474,7 +495,7 @@ program
474495
try {
475496
await strategy.refreshFundingState()
476497
} catch (err) {
477-
logger.error({ err }, "Aerodrome funding state refresh failed")
498+
logger.error({ err }, "Funding state refresh failed")
478499
}
479500
}, FUNDING_REFRESH_INTERVAL_MS)
480501
fundingRefreshTimers.push(timer)
@@ -677,27 +698,11 @@ function validateConfig(config: FillerTomlConfig): void {
677698
}
678699

679700
if (strategy.type === "hyperfx") {
680-
if (strategy.outputFunding?.aerodrome) {
681-
if (strategy.outputFunding.aerodrome.pools?.length) {
682-
for (const pool of strategy.outputFunding.aerodrome.pools) {
683-
if (!pool.chain?.trim()) {
684-
throw new Error(
685-
"Each Aerodrome outputFunding pool must have a non-empty 'chain' (e.g. EVM-8453)",
686-
)
687-
}
688-
if (!pool.pair) {
689-
throw new Error("Each Aerodrome pool must include a 'pair' address")
690-
}
691-
const chainCfg = getConfigByStateMachineId(pool.chain as Chains)
692-
const aerodromeRouter = chainCfg?.addresses.AerodromeRouter
693-
const z = aerodromeRouter?.toLowerCase()
694-
if (!z || z === "0x" || z === "0x0000000000000000000000000000000000000000") {
695-
throw new Error(
696-
`Aerodrome pool ${pool.pair} uses chain ${pool.chain} but SDK chain config has no addresses.AerodromeRouter for that chain`,
697-
)
698-
}
699-
}
700-
}
701+
if (strategy.outputFunding?.aerodrome?.pools?.length) {
702+
AerodromeFundingPlanner.validateConfig(strategy.outputFunding.aerodrome.pools)
703+
}
704+
if (strategy.outputFunding?.uniswapV4?.positions?.length) {
705+
UniswapV4FundingPlanner.validateConfig(strategy.outputFunding.uniswapV4.positions)
701706
}
702707

703708
// Validate bid price curve
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* Minimal Uniswap V4 PositionManager and PoolManager ABIs.
3+
*
4+
* PositionManager manages concentrated-liquidity positions as ERC-721 NFTs.
5+
* PoolManager stores all pool state and supports `extsload` for direct
6+
* storage reads (used to fetch slot0 for price data).
7+
*/
8+
9+
// ---------------------------------------------------------------------------
10+
// PositionManager ABI fragments
11+
// ---------------------------------------------------------------------------
12+
13+
/**
14+
* PoolKey is the canonical identifier for a V4 pool.
15+
* Shared tuple definition reused across multiple ABI entries.
16+
*/
17+
const POOL_KEY_COMPONENTS = [
18+
{ name: "currency0", type: "address" },
19+
{ name: "currency1", type: "address" },
20+
{ name: "fee", type: "uint24" },
21+
{ name: "tickSpacing", type: "int24" },
22+
{ name: "hooks", type: "address" },
23+
] as const
24+
25+
export const UNISWAP_V4_POSITION_MANAGER_ABI = [
26+
{
27+
name: "getPoolAndPositionInfo",
28+
type: "function",
29+
stateMutability: "view",
30+
inputs: [{ name: "tokenId", type: "uint256" }],
31+
outputs: [
32+
{
33+
name: "poolKey",
34+
type: "tuple",
35+
components: POOL_KEY_COMPONENTS,
36+
},
37+
// PositionInfo is a packed uint256:
38+
// 200 bits poolId | 24 bits tickUpper | 24 bits tickLower | 8 bits hasSubscriber
39+
{ name: "info", type: "uint256" },
40+
],
41+
},
42+
{
43+
name: "modifyLiquidities",
44+
type: "function",
45+
stateMutability: "payable",
46+
inputs: [
47+
{ name: "unlockData", type: "bytes" },
48+
{ name: "deadline", type: "uint256" },
49+
],
50+
outputs: [],
51+
},
52+
{
53+
name: "ownerOf",
54+
type: "function",
55+
stateMutability: "view",
56+
inputs: [{ name: "tokenId", type: "uint256" }],
57+
outputs: [{ type: "address" }],
58+
},
59+
{
60+
name: "getPositionLiquidity",
61+
type: "function",
62+
stateMutability: "view",
63+
inputs: [{ name: "tokenId", type: "uint256" }],
64+
outputs: [{ name: "liquidity", type: "uint128" }],
65+
},
66+
] as const
67+
68+
// ---------------------------------------------------------------------------
69+
// PoolManager ABI fragments (IExtsload)
70+
// ---------------------------------------------------------------------------
71+
72+
export const UNISWAP_V4_POOL_MANAGER_ABI = [
73+
{
74+
name: "extsload",
75+
type: "function",
76+
stateMutability: "view",
77+
inputs: [{ name: "slot", type: "bytes32" }],
78+
outputs: [{ name: "value", type: "bytes32" }],
79+
},
80+
] as const
81+
82+
// ---------------------------------------------------------------------------
83+
// Action constants for modifyLiquidities unlockData encoding
84+
// ---------------------------------------------------------------------------
85+
86+
/** Action opcodes from v4-periphery/src/libraries/Actions.sol */
87+
export const V4_ACTIONS = {
88+
INCREASE_LIQUIDITY: 0x00,
89+
DECREASE_LIQUIDITY: 0x01,
90+
MINT_POSITION: 0x02,
91+
BURN_POSITION: 0x03,
92+
TAKE: 0x0e,
93+
TAKE_PAIR: 0x11,
94+
CLOSE_CURRENCY: 0x12,
95+
SETTLE: 0x0b,
96+
SETTLE_PAIR: 0x0d,
97+
SWEEP: 0x14,
98+
} as const
99+
100+
// ---------------------------------------------------------------------------
101+
// PositionInfo (packed uint256) decoding helpers
102+
// ---------------------------------------------------------------------------
103+
104+
/**
105+
* Extracts tickLower from a packed PositionInfo uint256.
106+
* Layout: ... | 24 bits tickLower | 8 bits hasSubscriber
107+
*/
108+
export function decodeTickLower(positionInfo: bigint): number {
109+
const raw = Number((positionInfo >> 8n) & 0xffffffn)
110+
// Sign-extend 24-bit int24
111+
return raw >= 0x800000 ? raw - 0x1000000 : raw
112+
}
113+
114+
/**
115+
* Extracts tickUpper from a packed PositionInfo uint256.
116+
* Layout: ... | 24 bits tickUpper | 24 bits tickLower | 8 bits hasSubscriber
117+
*/
118+
export function decodeTickUpper(positionInfo: bigint): number {
119+
const raw = Number((positionInfo >> 32n) & 0xffffffn)
120+
return raw >= 0x800000 ? raw - 0x1000000 : raw
121+
}
122+
123+
// ---------------------------------------------------------------------------
124+
// Slot0 (packed bytes32) decoding helpers
125+
// ---------------------------------------------------------------------------
126+
127+
/**
128+
* Extracts sqrtPriceX96 (lowest 160 bits) from a packed Slot0 bytes32.
129+
* Layout: 24 lpFee | 12 protocolFee(1→0) | 12 protocolFee(0→1) | 24 tick | 160 sqrtPriceX96
130+
*/
131+
export function decodeSlot0SqrtPriceX96(slot0: bigint): bigint {
132+
return slot0 & ((1n << 160n) - 1n)
133+
}
134+
135+
/**
136+
* Extracts tick (bits 160-183) from a packed Slot0 bytes32.
137+
*/
138+
export function decodeSlot0Tick(slot0: bigint): number {
139+
const raw = Number((slot0 >> 160n) & 0xffffffn)
140+
return raw >= 0x800000 ? raw - 0x1000000 : raw
141+
}
142+
143+
// ---------------------------------------------------------------------------
144+
// PoolManager storage slot computation
145+
// ---------------------------------------------------------------------------
146+
147+
/**
148+
* The `_pools` mapping storage slot in PoolManager.
149+
* `mapping(PoolId id => Pool.State) internal _pools` is at slot 6
150+
* in the canonical v4-core PoolManager layout.
151+
*/
152+
export const POOLS_MAPPING_SLOT = 6n

0 commit comments

Comments
 (0)