muthuk1 commited on
Commit
1955a7b
·
verified ·
1 Parent(s): a3d6119

Add agent/executor.py

Browse files
Files changed (1) hide show
  1. agent/executor.py +451 -0
agent/executor.py ADDED
@@ -0,0 +1,451 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ On-Chain Executor — Construct unsigned transactions for Mantle L2
3
+ =================================================================
4
+ Follows the mantle-agent-scaffold pattern: construct-only safety.
5
+ Never handles private keys. Returns unsigned transaction payloads
6
+ that must be signed externally (e.g., via ERC-4337 wallet).
7
+
8
+ Supported DEXes: Fluxion (primary), Agni, Merchant Moe
9
+ Supported Lending: Aave V3 (Lendle)
10
+ """
11
+
12
+ import logging
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from enum import Enum
16
+ from typing import Any, Dict, List, Optional, Tuple
17
+
18
+ from eth_abi import encode as abi_encode
19
+ from eth_utils import function_signature_to_4byte_selector
20
+ import json
21
+
22
+ from config.constants import (
23
+ MANTLE_CHAIN_ID,
24
+ MANTLE_RPC_URL,
25
+ Tokens,
26
+ DEXRouters,
27
+ LendingProtocols,
28
+ ERC20_ABI,
29
+ SWAP_ROUTER_ABI,
30
+ AAVE_POOL_ABI,
31
+ PortfolioConfig,
32
+ )
33
+
34
+ logger = logging.getLogger("executor")
35
+
36
+
37
+ @dataclass
38
+ class UnsignedTx:
39
+ """
40
+ Unsigned transaction payload — the executor's output.
41
+ Must be signed by an external wallet (e.g., ERC-4337 bundler).
42
+
43
+ Follows mantle-agent-scaffold's UnsignedTxResult pattern.
44
+ """
45
+ to: str
46
+ data: str # hex-encoded calldata
47
+ value: str = "0x0" # msg.value in hex
48
+ chain_id: int = MANTLE_CHAIN_ID
49
+ gas_limit: Optional[int] = None
50
+ human_summary: str = ""
51
+
52
+ def to_dict(self) -> Dict[str, Any]:
53
+ d = {
54
+ "to": self.to,
55
+ "data": self.data,
56
+ "value": self.value,
57
+ "chainId": self.chain_id,
58
+ "human_summary": self.human_summary,
59
+ }
60
+ if self.gas_limit:
61
+ d["gasLimit"] = hex(self.gas_limit)
62
+ return d
63
+
64
+
65
+ @dataclass
66
+ class RebalancePlan:
67
+ """Complete rebalancing execution plan."""
68
+ timestamp: float
69
+ current_weights: List[float]
70
+ target_weights: List[float]
71
+ trades: List[UnsignedTx]
72
+ approvals: List[UnsignedTx]
73
+ estimated_gas_usd: float
74
+ estimated_slippage_bps: int
75
+ human_summary: str
76
+
77
+ def to_dict(self) -> Dict:
78
+ return {
79
+ "timestamp": self.timestamp,
80
+ "current_weights": self.current_weights,
81
+ "target_weights": self.target_weights,
82
+ "n_trades": len(self.trades),
83
+ "n_approvals": len(self.approvals),
84
+ "estimated_gas_usd": self.estimated_gas_usd,
85
+ "estimated_slippage_bps": self.estimated_slippage_bps,
86
+ "human_summary": self.human_summary,
87
+ "trades": [t.to_dict() for t in self.trades],
88
+ "approvals": [a.to_dict() for a in self.approvals],
89
+ }
90
+
91
+
92
+ class SwapRoute(Enum):
93
+ """Available swap routing options."""
94
+ FLUXION = "fluxion"
95
+ AGNI = "agni"
96
+ MERCHANT_MOE = "merchant_moe"
97
+
98
+
99
+ # ─────────────────────── ABI Encoding Helpers ───────────────────────
100
+
101
+ def encode_function_call(sig: str, args: list) -> str:
102
+ """Encode a Solidity function call to hex calldata."""
103
+ selector = function_signature_to_4byte_selector(sig).hex()
104
+ if not args:
105
+ return "0x" + selector
106
+
107
+ # Map signature to types
108
+ param_str = sig[sig.index("(") + 1 : sig.index(")")]
109
+ param_types = [t.strip() for t in param_str.split(",")] if param_str else []
110
+
111
+ encoded = abi_encode(param_types, args).hex()
112
+ return "0x" + selector + encoded
113
+
114
+
115
+ def encode_erc20_approve(spender: str, amount: int) -> str:
116
+ """Encode ERC20 approve(spender, amount)."""
117
+ return encode_function_call(
118
+ "approve(address,uint256)",
119
+ [bytes.fromhex(spender[2:]), amount]
120
+ )
121
+
122
+
123
+ # ─────────────────────── Token Registry ─────────────────────────────
124
+
125
+ TOKEN_DECIMALS = {
126
+ "USDY": 18,
127
+ "mETH": 18,
128
+ "MI4": 18,
129
+ "USDC": 6,
130
+ "USDT": 6,
131
+ "WMNT": 18,
132
+ "WETH": 18,
133
+ }
134
+
135
+ TOKEN_ADDRESSES = {
136
+ "USDY": Tokens.USDY,
137
+ "mETH": Tokens.METH,
138
+ "USDC": Tokens.USDC,
139
+ "USDT": Tokens.USDT,
140
+ "WMNT": Tokens.WMNT,
141
+ "WETH": Tokens.WETH,
142
+ }
143
+
144
+ # Fluxion fee tiers (Uni V3 compatible)
145
+ FEE_TIERS = [100, 500, 3000, 10000] # 0.01%, 0.05%, 0.3%, 1%
146
+
147
+
148
+ # ─────────────────────── Executor ───────────────────────────────────
149
+
150
+ class OnChainExecutor:
151
+ """
152
+ Constructs unsigned transactions for portfolio rebalancing.
153
+
154
+ Safety guarantees:
155
+ - NEVER handles private keys
156
+ - ONLY interacts with whitelisted contracts
157
+ - ALL outputs are unsigned transaction payloads
158
+ - Human-readable summaries for every action
159
+ """
160
+
161
+ WHITELISTED_CONTRACTS = {
162
+ DEXRouters.FLUXION_SWAP_ROUTER,
163
+ DEXRouters.AGNI_SWAP_ROUTER,
164
+ DEXRouters.MERCHANT_MOE_ROUTER,
165
+ LendingProtocols.AAVE_V3_POOL,
166
+ Tokens.USDY,
167
+ Tokens.METH,
168
+ Tokens.USDC,
169
+ Tokens.WMNT,
170
+ }
171
+
172
+ def __init__(
173
+ self,
174
+ wallet_address: str,
175
+ portfolio_config: Optional[PortfolioConfig] = None,
176
+ preferred_dex: SwapRoute = SwapRoute.FLUXION,
177
+ ):
178
+ self.wallet = wallet_address
179
+ self.config = portfolio_config or PortfolioConfig()
180
+ self.preferred_dex = preferred_dex
181
+
182
+ # Execution history
183
+ self.execution_log: List[Dict] = []
184
+
185
+ def is_whitelisted(self, address: str) -> bool:
186
+ """Check if contract address is in the whitelist."""
187
+ return address.lower() in {a.lower() for a in self.WHITELISTED_CONTRACTS}
188
+
189
+ def build_rebalance_plan(
190
+ self,
191
+ current_weights: List[float],
192
+ target_weights: List[float],
193
+ portfolio_value_usd: float,
194
+ asset_prices: Dict[str, float],
195
+ max_slippage_bps: Optional[int] = None,
196
+ ) -> RebalancePlan:
197
+ """
198
+ Build a complete rebalancing plan from current to target weights.
199
+
200
+ 1. Compute required trades (sell overweight, buy underweight)
201
+ 2. Route through best DEX
202
+ 3. Generate approval + swap transactions
203
+ 4. Estimate gas costs
204
+
205
+ Args:
206
+ current_weights: [USDY_w, mETH_w, MI4_w]
207
+ target_weights: [USDY_w, mETH_w, MI4_w]
208
+ portfolio_value_usd: Total portfolio value
209
+ asset_prices: {"USDY": 1.0, "mETH": 3200.0, "MI4": 100.0}
210
+ max_slippage_bps: Override max slippage
211
+
212
+ Returns:
213
+ RebalancePlan with all unsigned transactions
214
+ """
215
+ slippage = max_slippage_bps or self.config.max_slippage_bps
216
+ assets = ["USDY", "mETH", "MI4"]
217
+
218
+ # Calculate weight deltas
219
+ deltas = [target_weights[i] - current_weights[i] for i in range(3)]
220
+
221
+ # Determine sells (negative delta) and buys (positive delta)
222
+ sells = []
223
+ buys = []
224
+ for i, (asset, delta) in enumerate(zip(assets, deltas)):
225
+ if abs(delta) < 0.005: # skip if delta < 0.5%
226
+ continue
227
+ usd_amount = abs(delta) * portfolio_value_usd
228
+ token_amount = usd_amount / max(asset_prices.get(asset, 1.0), 0.01)
229
+
230
+ if delta < 0:
231
+ sells.append((asset, token_amount, usd_amount))
232
+ else:
233
+ buys.append((asset, token_amount, usd_amount))
234
+
235
+ # Build transactions
236
+ approvals = []
237
+ trades = []
238
+ summary_parts = []
239
+
240
+ # Process sells first (to get intermediate tokens for buys)
241
+ for sell_asset, sell_amount, sell_usd in sells:
242
+ # Route: sell_asset → USDC (intermediate)
243
+ decimals = TOKEN_DECIMALS.get(sell_asset, 18)
244
+ amount_raw = int(sell_amount * (10 ** decimals))
245
+
246
+ # Build approval
247
+ router = self._get_router_address()
248
+ approval_tx = self._build_approval(
249
+ token=TOKEN_ADDRESSES[sell_asset],
250
+ spender=router,
251
+ amount=amount_raw,
252
+ summary=f"Approve {sell_amount:.4f} {sell_asset} for swap",
253
+ )
254
+ approvals.append(approval_tx)
255
+
256
+ # Build swap
257
+ swap_tx = self._build_swap(
258
+ token_in=TOKEN_ADDRESSES[sell_asset],
259
+ token_out=Tokens.USDC,
260
+ amount_in=amount_raw,
261
+ min_amount_out=int(sell_usd * (10 ** 6) * (1 - slippage / 10000)),
262
+ fee=3000,
263
+ summary=f"Sell {sell_amount:.4f} {sell_asset} (~${sell_usd:.2f}) → USDC",
264
+ )
265
+ trades.append(swap_tx)
266
+ summary_parts.append(f"SELL {sell_amount:.4f} {sell_asset} (${sell_usd:.0f})")
267
+
268
+ # Process buys
269
+ for buy_asset, buy_amount, buy_usd in buys:
270
+ decimals_in = TOKEN_DECIMALS["USDC"]
271
+ amount_in_raw = int(buy_usd * (10 ** decimals_in))
272
+
273
+ decimals_out = TOKEN_DECIMALS.get(buy_asset, 18)
274
+ min_out = int(buy_amount * (10 ** decimals_out) * (1 - slippage / 10000))
275
+
276
+ # Approve USDC
277
+ router = self._get_router_address()
278
+ approval_tx = self._build_approval(
279
+ token=Tokens.USDC,
280
+ spender=router,
281
+ amount=amount_in_raw,
282
+ summary=f"Approve {buy_usd:.2f} USDC for {buy_asset} purchase",
283
+ )
284
+ approvals.append(approval_tx)
285
+
286
+ # Swap USDC → buy_asset
287
+ swap_tx = self._build_swap(
288
+ token_in=Tokens.USDC,
289
+ token_out=TOKEN_ADDRESSES.get(buy_asset, Tokens.USDY),
290
+ amount_in=amount_in_raw,
291
+ min_amount_out=min_out,
292
+ fee=3000,
293
+ summary=f"Buy {buy_amount:.4f} {buy_asset} (~${buy_usd:.2f}) with USDC",
294
+ )
295
+ trades.append(swap_tx)
296
+ summary_parts.append(f"BUY {buy_amount:.4f} {buy_asset} (${buy_usd:.0f})")
297
+
298
+ # Estimate gas
299
+ gas_per_swap = 250_000 # typical Uni V3 swap
300
+ gas_per_approval = 50_000
301
+ total_gas = len(trades) * gas_per_swap + len(approvals) * gas_per_approval
302
+ gas_price_gwei = 0.02 # Mantle is cheap
303
+ gas_cost_mnt = total_gas * gas_price_gwei / 1e9
304
+ mnt_price = 0.70 # approximate
305
+ gas_cost_usd = gas_cost_mnt * mnt_price
306
+
307
+ plan = RebalancePlan(
308
+ timestamp=time.time(),
309
+ current_weights=current_weights,
310
+ target_weights=target_weights,
311
+ trades=trades,
312
+ approvals=approvals,
313
+ estimated_gas_usd=gas_cost_usd,
314
+ estimated_slippage_bps=slippage,
315
+ human_summary=(
316
+ f"Rebalance: {' | '.join(summary_parts) or 'No trades needed'}\n"
317
+ f"Trades: {len(trades)}, Approvals: {len(approvals)}, "
318
+ f"Est. gas: ${gas_cost_usd:.4f}"
319
+ ),
320
+ )
321
+
322
+ logger.info(f"Rebalance plan: {plan.human_summary}")
323
+ self.execution_log.append(plan.to_dict())
324
+ return plan
325
+
326
+ def _get_router_address(self) -> str:
327
+ """Get the router address for the preferred DEX."""
328
+ if self.preferred_dex == SwapRoute.FLUXION:
329
+ return DEXRouters.FLUXION_SWAP_ROUTER
330
+ elif self.preferred_dex == SwapRoute.AGNI:
331
+ return DEXRouters.AGNI_SWAP_ROUTER
332
+ elif self.preferred_dex == SwapRoute.MERCHANT_MOE:
333
+ return DEXRouters.MERCHANT_MOE_ROUTER
334
+ return DEXRouters.FLUXION_SWAP_ROUTER
335
+
336
+ def _build_approval(
337
+ self, token: str, spender: str, amount: int, summary: str
338
+ ) -> UnsignedTx:
339
+ """Build ERC20 approve transaction."""
340
+ if not self.is_whitelisted(token) or not self.is_whitelisted(spender):
341
+ raise ValueError(f"Contract not whitelisted: token={token}, spender={spender}")
342
+
343
+ # approve(address,uint256)
344
+ selector = "095ea7b3"
345
+ spender_padded = spender[2:].lower().zfill(64)
346
+ amount_hex = hex(amount)[2:].zfill(64)
347
+ calldata = "0x" + selector + spender_padded + amount_hex
348
+
349
+ return UnsignedTx(
350
+ to=token,
351
+ data=calldata,
352
+ human_summary=summary,
353
+ gas_limit=60_000,
354
+ )
355
+
356
+ def _build_swap(
357
+ self,
358
+ token_in: str,
359
+ token_out: str,
360
+ amount_in: int,
361
+ min_amount_out: int,
362
+ fee: int = 3000,
363
+ summary: str = "",
364
+ ) -> UnsignedTx:
365
+ """Build Uniswap V3-style exactInputSingle swap transaction."""
366
+ router = self._get_router_address()
367
+
368
+ if not self.is_whitelisted(router):
369
+ raise ValueError(f"Router not whitelisted: {router}")
370
+
371
+ # exactInputSingle((address,address,uint24,address,uint256,uint256,uint256,uint160))
372
+ selector = "414bf389"
373
+ deadline = int(time.time()) + 1800 # 30 min deadline
374
+
375
+ # ABI encode the tuple
376
+ params = (
377
+ token_in[2:].lower().zfill(64) +
378
+ token_out[2:].lower().zfill(64) +
379
+ hex(fee)[2:].zfill(64) +
380
+ self.wallet[2:].lower().zfill(64) +
381
+ hex(deadline)[2:].zfill(64) +
382
+ hex(amount_in)[2:].zfill(64) +
383
+ hex(min_amount_out)[2:].zfill(64) +
384
+ "0" * 64 # sqrtPriceLimitX96 = 0 (no limit)
385
+ )
386
+
387
+ calldata = "0x" + selector + params
388
+
389
+ return UnsignedTx(
390
+ to=router,
391
+ data=calldata,
392
+ human_summary=summary,
393
+ gas_limit=300_000,
394
+ )
395
+
396
+ def build_aave_supply(
397
+ self, asset: str, amount: int, asset_symbol: str = "USDY"
398
+ ) -> List[UnsignedTx]:
399
+ """Build Aave V3 supply transactions (approval + supply)."""
400
+ pool = LendingProtocols.AAVE_V3_POOL
401
+
402
+ # Approval
403
+ approve_tx = self._build_approval(
404
+ token=asset,
405
+ spender=pool,
406
+ amount=amount,
407
+ summary=f"Approve {asset_symbol} for Aave V3 supply",
408
+ )
409
+
410
+ # supply(address,uint256,address,uint16)
411
+ selector = "617ba037"
412
+ calldata = "0x" + selector + (
413
+ asset[2:].lower().zfill(64) +
414
+ hex(amount)[2:].zfill(64) +
415
+ self.wallet[2:].lower().zfill(64) +
416
+ "0" * 64 # referralCode = 0
417
+ )
418
+
419
+ supply_tx = UnsignedTx(
420
+ to=pool,
421
+ data=calldata,
422
+ human_summary=f"Supply {asset_symbol} to Aave V3 for additional yield",
423
+ gas_limit=300_000,
424
+ )
425
+
426
+ return [approve_tx, supply_tx]
427
+
428
+ def build_aave_withdraw(
429
+ self, asset: str, amount: int, asset_symbol: str = "USDY"
430
+ ) -> UnsignedTx:
431
+ """Build Aave V3 withdraw transaction."""
432
+ pool = LendingProtocols.AAVE_V3_POOL
433
+
434
+ # withdraw(address,uint256,address)
435
+ selector = "69328dec"
436
+ calldata = "0x" + selector + (
437
+ asset[2:].lower().zfill(64) +
438
+ hex(amount)[2:].zfill(64) +
439
+ self.wallet[2:].lower().zfill(64)
440
+ )
441
+
442
+ return UnsignedTx(
443
+ to=pool,
444
+ data=calldata,
445
+ human_summary=f"Withdraw {asset_symbol} from Aave V3",
446
+ gas_limit=250_000,
447
+ )
448
+
449
+ def get_execution_history(self) -> List[Dict]:
450
+ """Return execution log."""
451
+ return self.execution_log