Skip to content

Commit

Permalink
Refactored Multicall handling mess
Browse files Browse the repository at this point in the history
  • Loading branch information
miohtama committed Dec 1, 2024
1 parent 698dee7 commit 6267f86
Show file tree
Hide file tree
Showing 3 changed files with 140 additions and 36 deletions.
2 changes: 1 addition & 1 deletion eth_defi/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ def chain_id(self) -> int:
return self.contract.w3.eth.chain_id

@property
def address(self) -> TokenAddress:
def address(self) -> HexAddress:
"""The address of this token."""
return self.contract.address

Expand Down
134 changes: 100 additions & 34 deletions eth_defi/vault/valuation.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import pandas as pd
from eth_typing import HexAddress, BlockIdentifier
from multicall import Call, Multicall
from safe_eth.eth.constants import NULL_ADDRESS
from web3 import Web3
from web3.contract import Contract

Expand Down Expand Up @@ -56,6 +57,7 @@ def get_total_equity(self) -> Decimal:
return sum(self.spot_valuations.values())



@dataclass(slots=True, frozen=True)
class Route:
"""One potential swap path.
Expand All @@ -68,19 +70,15 @@ class Route:
"""
source_token: TokenDetails
target_token: TokenDetails
router: "ValuationQuoter"
path: tuple[HexAddress]
contract_address: HexAddress
signature: list[Any]
extra_data: Any | None = None
debug: bool = False # Unit test flag
quoter: "ValuationQuoter"
path: tuple[HexAddress, HexAddress] | tuple[HexAddress, HexAddress, HexAddress]

def __repr__(self):
return f"<Route {self.path} using quoter {self.signature[0]}>"

def __hash__(self) -> int:
"""Unique hash for this instance"""
return hash((self.router, self.source_token.address, self.path))
return hash((self.quoter, self.source_token.address, self.path))

def __eq__(self, other: "Route") -> int:
return self.source_token == other.source_token and self.path == other.path and self.contract_address == other.contract_address
Expand All @@ -91,14 +89,46 @@ def function_signature_string(self) -> str:

@property
def token(self) -> TokenDetails:
return self.path[0]
return self.source_token

def create_multicall(self) -> Call:
# If we need to optimise Python parsing speed, we can directly pass function selectors and pre-packed ABI
return Call(self.contract_address, self.signature, [(self, self.handle_onchain_return_value)])

def handle_onchain_return_value(self, succeed: bool, raw_return_value: Any) -> TokenAmount | None:
"""Convert the rwa Solidity function call result to a denominated token amount.

@dataclass(slots=True, frozen=True)
class MulticallWrapper:
"""Wrap the undertlying Multicall with diagnostics data.
- Because the underlying Multicall lib is not powerful enough.
- And we do not have time to fix it
"""

quoter: "ValuationQuoter"
route: Route
amount_in: int
signature_string: str
contract_address: HexAddress
signature: list[Any]
debug: bool = False # Unit test flag

def __repr__(self):
return f"<MulticallWrapper {self.amount_in} for {self.signature_string}>"

def create_multicall(self) -> Call:
"""Create underlying call about."""
call = Call(self.contract_address, self.signature, [(self.route, self)])
return call

def get_data(self) -> bytes:
"""Return data field for the transaction payload"""
call = self.create_multicall()
data = call.signature.fourbyte + call.data
return data

def multicall_callback(self, succeed: bool, raw_return_value: Any) -> TokenAmount | None:
"""Convert the raw Solidity function call result to a denominated token amount.
- Multicall library callback
Expand All @@ -112,11 +142,11 @@ def handle_onchain_return_value(self, succeed: bool, raw_return_value: Any) -> T
# Avoid expensive logging if we do not need it
if self.debug:
# Print calldata so we can copy-paste it to Tenderly for symbolic debug stack trace
data = self.get_data()
call = self.create_multicall()
data = call.signature.fourbyte + call.data
logger.info("Path did not success: %s on %s, selector %s",
self,
self.function_signature_string,
self.signature_string,
call.signature.fourbyte.hex(),
)
logger.info("Arguments: %s", self.signature[1:])
Expand All @@ -128,7 +158,7 @@ def handle_onchain_return_value(self, succeed: bool, raw_return_value: Any) -> T
return None

try:
token_amount = self.router.handle_onchain_return_value(
token_amount = self.quoter.handle_onchain_return_value(
self,
raw_return_value,
)
Expand All @@ -139,19 +169,35 @@ def handle_onchain_return_value(self, succeed: bool, raw_return_value: Any) -> T
except Exception as e:
logger.error(
"Router handler failed %s for return value %s",
self.router,
self.quoter,
raw_return_value,
)
raise e

if self.debug:
logger.info(
"Path succeed: %s, we can sell %s for %s reserve currency",
"Route succeed: %s, we can sell %s for %s reserve currency",
self,
self.token,
self.route,
token_amount
)

def create_tx_data(self, from_= NULL_ADDRESS) -> dict:
"""Create payload for eth_call."""
return {
"from": NULL_ADDRESS,
"to": self.contract_address,
"data": self.get_data(),
}

def __call__(
self,
success: bool,
raw_return_value: Any
):
"""Called by Multicall lib"""
return self.multicall_callback(success, raw_return_value)


class ValuationQuoter(ABC):
"""Handle asset valuation on a specific DEX/quoter.
Expand All @@ -165,6 +211,9 @@ class ValuationQuoter(ABC):
- Resolves the onchain Solidity function return value to a token amount we get
"""

def __init__(self, debug: bool = False):
self.debug = debug

@abstractmethod
def generate_routes(
self,
Expand All @@ -186,6 +235,10 @@ def handle_onchain_return_value(
):
pass

@abstractmethod
def create_multicall(self, route: Route, amount_in: int) -> MulticallWrapper:
pass



class UniswapV2Router02Quoter(ValuationQuoter):
Expand All @@ -194,18 +247,39 @@ class UniswapV2Router02Quoter(ValuationQuoter):
https://docs.uniswap.org/contracts/v2/reference/smart-contracts/router-02#getamountsout
"""

#: Quoter signature string for Multicall lib.
#:
#: Not the standard string signature format,
#: because Multicall lib wants it special output format suffix here
signature_string = "getAmountsOut(uint256,address[])(uint256[])"

def __init__(
self,
swap_router_v2: Contract,
debug: bool = False,
):
assert isinstance(swap_router_v2, Contract)
super().__init__(debug=debug)
assert isinstance(swap_router_v2, Contract)
self.swap_router_v2 = swap_router_v2

@cached_property
def func_string(self):
# Not the standard string signature format,
# because Multicall lib wants it special output format suffix here
return "getAmountsOut(uint256,address[])(uint256[])"
def create_multicall(self, route: Route, amount_in: int) -> MulticallWrapper:
# If we need to optimise Python parsing speed, we can directly pass function selectors and pre-packed ABI

signature = [
self.signature_string,
route.source_token.convert_to_raw(amount_in),
route.path,
]

return MulticallWrapper(
quoter=self,
route=route,
amount_in=amount_in,
debug=self.debug,
signature_string=self.signature_string,
contract_address=self.swap_router_v2.address,
signature=signature,
)

def generate_routes(
self,
Expand All @@ -222,19 +296,11 @@ def generate_routes(
target_token,
intermediate_tokens,
):
signature = [
self.func_string,
source_token.convert_to_raw(amount),
path,
]
yield Route(
contract_address=self.swap_router_v2.address,
source_token=source_token,
target_token=target_token,
router=self,
quoter=self,
path=path,
signature=signature,
debug=debug,
)

def handle_onchain_return_value(
Expand Down Expand Up @@ -480,8 +546,8 @@ def create_route_diagnostics(
data.append({
"Asset": route.source_token.symbol,
"Balance": f"{portfolio.spot_erc20[route.source_token.address]:.6f}",
"Router": route.router.__class__.__name__,
"Path": _format_symbolic_path(self.web3, route),
"Router": route.quoter.__class__.__name__,
"Path": _format_symbolic_path_uniswap_v2(self.web3, route),
"Works": "yes" if out_balance is not None else "no",
"Value": formatted_balance,
})
Expand All @@ -501,7 +567,7 @@ def _convert_to_token_details(
return fetch_erc20_details(web3, token_or_address, chain_id=chain_id)


def _format_symbolic_path(web3, route: Route) -> str:
def _format_symbolic_path_uniswap_v2(web3, route: Route) -> str:
"""Get human-readable route path line."""

chain_id = web3.eth.chain_id
Expand Down
40 changes: 39 additions & 1 deletion tests/lagoon/test_lagoon_valuation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from decimal import Decimal

import pytest
from safe_eth.eth.constants import NULL_ADDRESS
from web3 import Web3

from eth_defi.lagoon.vault import LagoonVault
Expand All @@ -11,7 +12,7 @@
from eth_defi.uniswap_v2.constants import UNISWAP_V2_DEPLOYMENTS
from eth_defi.uniswap_v2.deployment import fetch_deployment, UniswapV2Deployment
from eth_defi.vault.base import TradingUniverse
from eth_defi.vault.valuation import NetAssetValueCalculator, UniswapV2Router02Quoter
from eth_defi.vault.valuation import NetAssetValueCalculator, UniswapV2Router02Quoter, Route


@pytest.fixture()
Expand All @@ -24,6 +25,43 @@ def uniswap_v2(web3):
)


def test_uniswap_v2_weth_usdc_sell_route(
web3: Web3,
lagoon_vault: LagoonVault,
base_usdc: TokenDetails,
base_weth: TokenDetails,
base_dino: TokenDetails,
uniswap_v2: UniswapV2Deployment,
):
"""Test a simple WETH->USDC sell route on Uniswap v2.
- See that the logic for a single route works
"""

uniswap_v2_quoter_v2 = UniswapV2Router02Quoter(
uniswap_v2.router,
debug=True,
)

route = Route(
source_token=base_weth,
target_token=base_usdc,
quoter=uniswap_v2_quoter_v2,
path=(base_weth.address, base_usdc.address),
)

# Sell 1000 WETH
wrapped_call = uniswap_v2_quoter_v2.create_multicall(route, 1000 * 10**18)
tx_data = wrapped_call.create_tx_data()

try:
raw_result = web3.eth.call(tx_data)
except Exception as e:
raise AssertionError(f"Could not execute the call.\nAddress: {tx_data['to']}\nData: {tx_data['data'].hex()}") from e

assert raw_result > 100


def test_lagoon_calculate_portfolio_nav(
web3: Web3,
lagoon_vault: LagoonVault,
Expand Down

0 comments on commit 6267f86

Please sign in to comment.