From ba2b45e71bf9328d5018903ef110e8c4daf94203 Mon Sep 17 00:00:00 2001 From: Dowland Aiello Date: Tue, 23 Jul 2024 08:51:39 -0700 Subject: [PATCH] Implement IBC path unwinding. --- local-interchaintest/src/main.rs | 13 +-- local-interchaintest/src/setup.rs | 11 ++- local-interchaintest/src/util.rs | 2 +- src/strategies/naive.py | 4 + src/strategies/util.py | 60 ++++++++++-- src/util.py | 149 ++++++++++++++++++++++++++++++ 6 files changed, 220 insertions(+), 19 deletions(-) diff --git a/local-interchaintest/src/main.rs b/local-interchaintest/src/main.rs index eb75d8889..37dda38b4 100644 --- a/local-interchaintest/src/main.rs +++ b/local-interchaintest/src/main.rs @@ -61,15 +61,15 @@ fn main() -> Result<(), Box> { base_chain: String::from("neutron"), dest_chain: String::from("osmosis"), }; - let uosmo = Denom::Local { - base_chain: String::from("osmosis"), - base_denom: String::from("uosmo"), - }; let uosmo_ntrn = Denom::Interchain { base_denom: String::from("uosmo"), base_chain: String::from("osmosis"), dest_chain: String::from("neutron"), }; + let uosmo = Denom::Local { + base_chain: String::from("osmosis"), + base_denom: String::from("uosmo"), + }; TestRunner::new(&mut ctx, args) .start()? @@ -289,7 +289,7 @@ fn main() -> Result<(), Box> { .with_denom(bruhtoken_osmo.clone(), 100000000000) .with_pool( untrn.clone(), - bruhtoken.clone(), + uosmo_ntrn.clone(), Pool::Astroport( AstroportPoolBuilder::default() .with_balance_asset_a(10000000u128) @@ -310,7 +310,7 @@ fn main() -> Result<(), Box> { ), ) .with_pool( - uosmo_ntrn.clone(), + untrn.clone(), bruhtoken.clone(), Pool::Astroport( AstroportPoolBuilder::default() @@ -319,6 +319,7 @@ fn main() -> Result<(), Box> { .build()?, ), ) + .with_arbbot() .with_test(Box::new(tests::test_osmo_arb) as TestFn) .build()?, diff --git a/local-interchaintest/src/setup.rs b/local-interchaintest/src/setup.rs index 3fde6033b..66d32cdbc 100644 --- a/local-interchaintest/src/setup.rs +++ b/local-interchaintest/src/setup.rs @@ -253,10 +253,11 @@ impl<'a> TestRunner<'a> { .as_str(), &ntrn_to_osmo, &osmo_to_ntrn, - )?; - util::create_arbs_file()?; - util::create_netconfig()?; - util::create_denom_file(&self.denom_map)?; + ) + .expect("Failed to create deployments file"); + util::create_arbs_file().expect("Failed to create arbs file"); + util::create_netconfig().expect("Failed to create net config"); + util::create_denom_file(&self.denom_map).expect("Failed to create denom file"); let statuses = self.test_statuses.clone(); @@ -624,7 +625,7 @@ pub fn with_arb_bot_output(test: OwnedTestFn) -> TestResult { .arg("--deployments_file") .arg("deployments_file.json") .arg("--net_config") - .arg("net_config.json") + .arg("net_config_test.json") .arg("--base_denom") .arg("untrn") .arg("--denom_file") diff --git a/local-interchaintest/src/util.rs b/local-interchaintest/src/util.rs index d80b51d0f..1eb0c298b 100644 --- a/local-interchaintest/src/util.rs +++ b/local-interchaintest/src/util.rs @@ -110,7 +110,7 @@ pub(crate) fn create_netconfig() -> Result<(), Box> { .create(true) .truncate(true) .write(true) - .open("../net_config.json")?; + .open("../net_config_test.json")?; f.write_all( serde_json::json!({ diff --git a/src/strategies/naive.py b/src/strategies/naive.py index 3e0cb541a..6e86084b6 100644 --- a/src/strategies/naive.py +++ b/src/strategies/naive.py @@ -361,11 +361,15 @@ async def next_legs( prev_pool = path[-1] + logger.debug(f"exploring route: %s", fmt_route(path)) + # This leg **must** be connected to the previous # by construction, so therefore, if any # of the denoms match the starting denom, we are # finished, and the circuit is closed if len(path) > 1 and src == prev_pool.out_asset(): + logger.debug(f"discovered route: %s", fmt_route(path)) + if ( len(required_leg_types - set((fmt_route_leg(leg) for leg in path))) > 0 or len(path) < depth diff --git a/src/strategies/util.py b/src/strategies/util.py index da7b523d1..0595b7dbb 100644 --- a/src/strategies/util.py +++ b/src/strategies/util.py @@ -2,6 +2,7 @@ Defines common utilities shared across arbitrage strategies. """ +import json import operator from functools import reduce from decimal import Decimal @@ -23,6 +24,8 @@ try_multiple_clients_fatal, try_multiple_clients, DENOM_QUANTITY_ABORT_ARB, + denom_route, + denom_info_on_chain, ) from src.scheduler import Ctx from cosmos.base.v1beta1 import coin_pb2 @@ -343,10 +346,10 @@ async def recover_funds( await transfer( r, curr_leg.in_asset(), - route[-1].out_asset(), curr_leg, + backtracked[0], ctx, - to_swap, + to_transfer, ) resp = await quantities_for_route_profit( @@ -382,24 +385,65 @@ async def transfer( succeeded. """ - src_channel_id = prev_leg.backend.chain_transfer_channel_ids[leg.backend.chain_id] + denom_infos_on_dest = await denom_info_on_chain( + prev_leg.backend.chain_id, + denom, + leg.backend.chain_id, + ctx.http_session, + ctx.denom_map, + ) + + if not denom_infos_on_dest: + raise ValueError( + f"Missing denom info for transfer {denom} ({prev_leg.backend.chain_id}) -> {leg.backend.chain_id}" + ) + + ibc_route = await denom_route( + prev_leg.backend.chain_id, + denom, + leg.backend.chain_id, + denom_infos_on_dest[0].denom, + ctx.http_session, + ) + + if not ibc_route or len(ibc_route) == 0: + raise ValueError(f"No route from {denom} to {leg.backend.chain_id}") + + src_channel_id = ibc_route[0].channel sender_addr = str( - Address(ctx.wallet.public_key(), prefix=prev_leg.backend.chain_prefix) + Address(ctx.wallet.public_key(), prefix=ibc_route[0].from_chain.bech32_prefix) ) receiver_addr = str( - Address(ctx.wallet.public_key(), prefix=leg.backend.chain_prefix) + Address(ctx.wallet.public_key(), prefix=ibc_route[0].to_chain.bech32_prefix) ) + memo: Optional[str] = None + + for ibc_leg in reversed(ibc_route[1:]): + memo = json.dumps( + { + "forward": { + "receiver": "pfm", + "port": ibc_leg.port, + "channel": ibc_leg.channel, + "timeout": "10m", + "retries": 2, + "next": memo, + } + } + ) + await transfer_raw( denom, - prev_leg.backend.chain_id, + ibc_route[0].from_chain.chain_id, prev_leg.backend.chain_fee_denom, src_channel_id, - leg.backend.chain_id, + ibc_route[0].to_chain.chain_id, sender_addr, receiver_addr, ctx, swap_balance, + memo=memo, ) @@ -413,6 +457,7 @@ async def transfer_raw( receiver_addr: str, ctx: Ctx[Any], swap_balance: int, + memo: Optional[str] = None, route: Optional[Route] = None, ) -> None: """ @@ -429,6 +474,7 @@ async def transfer_raw( sender=sender_addr, receiver=receiver_addr, timeout_timestamp=time.time_ns() + 600 * 10**9, + memo=memo, ) msg.token.CopyFrom( coin_pb2.Coin( # pylint: disable=maybe-no-member diff --git a/src/util.py b/src/util.py index 31bfe821f..3509d10a0 100644 --- a/src/util.py +++ b/src/util.py @@ -239,6 +239,48 @@ def int_to_decimal(n: int) -> Decimal: return Decimal(n) / (Decimal(10) ** Decimal(18)) +@dataclass +class ChainInfo: + """ + Contains basic information about a chain. + """ + + chain_name: str + chain_id: str + pfm_enabled: bool + supports_memo: bool + bech32_prefix: str + fee_asset: str + chain_type: str + pretty_name: str + + +@dataclass +class DenomRouteLeg: + """ + Represents an IBC transfer as a leg of a greater IBC transfer + from some src denom on a src chain to a dest denom on a dest chain. + """ + + # The origin and destination chains + src_chain: str + dest_chain: str + + # The origin and destination denoms + src_denom: str + dest_denom: str + + # The current leg in and out chain and denoms + from_chain: ChainInfo + to_chain: ChainInfo + + denom_in: str + denom_out: str + + port: str + channel: str + + @dataclass class DenomChainInfo: """ @@ -380,6 +422,113 @@ async def denom_info_on_chain( return None +async def denom_route( + src_chain: str, + src_denom: str, + dest_chain: str, + dest_denom: str, + session: aiohttp.ClientSession, + denom_map: Optional[dict[str, list[dict[str, str]]]] = None, +) -> Optional[list[DenomRouteLeg]]: + """ + Gets a neutron denom's denom and channel on/to another chain. + """ + + head = {"accept": "application/json", "content-type": "application/json"} + + async with session.post( + "https://api.skip.money/v2/fungible/route", + headers=head, + json={ + "amount_in": "1", + "source_asset_denom": src_denom, + "source_asset_chain_id": src_chain, + "dest_asset_denom": dest_denom, + "dest_asset_chain_id": dest_chain, + "allow_multi_tx": True, + "allow_unsafe": False, + "bridges": ["IBC"], + }, + ) as resp: + if resp.status != 200: + return None + + ops = (await resp.json())["operations"] + + # The transfer includes a swap or some other operation + # we can't handle + if any(("transfer" not in op for op in ops)): + return None + + transfer_info = ops["transfer"] + + from_chain_info = await chain_info( + transfer_info["from_chain_id"], session, denom_map + ) + to_chain_info = await chain_info( + transfer_info["to_chain_id"], session, denom_map + ) + + if not from_chain_info or not to_chain_info: + return None + + return [ + DenomRouteLeg( + src_chain=src_chain, + dest_chain=dest_chain, + src_denom=src_denom, + dest_denom=dest_denom, + from_chain=from_chain_info, + to_chain=to_chain_info, + denom_in=transfer_info["denom_in"], + denom_out=transfer_info["denom_out"], + port=transfer_info["port"], + channel=transfer_info["channel"], + ) + for op in ops + ] + + +async def chain_info( + chain_id: str, + session: aiohttp.ClientSession, + denom_map: Optional[dict[str, list[dict[str, str]]]] = None, +) -> Optional[ChainInfo]: + """ + Gets basic information about a cosmos chain. + """ + + head = {"accept": "application/json", "content-type": "application/json"} + + async with session.get( + "https://api.skip.money/v2/info/chains", + headers=head, + json={ + "chain_ids": [chain_id], + }, + ) as resp: + if resp.status != 200: + return None + + chains = (await resp.json())["chains"] + + if len(chains) == 0: + return None + + chain = chains[0] + + return ChainInfo( + chain_name=chain["chain_name"], + chain_id=chain["chain_id"], + pfm_enabled=chain["pfm_enabled"], + supports_memo=chain["supports_memo"], + bech32_prefix=chain["bech32_prefix"], + fee_asset=chain["fee_asset"], + chain_type=chain["chain_type"], + pretty_name=chain["pretty_name"], + ) + + @dataclass class ContractInfo: """