From 0fa43b8952708677fb00c4e76b2d31e351bb09ac Mon Sep 17 00:00:00 2001 From: Ofem Eteng Date: Tue, 25 Feb 2025 17:30:59 +0100 Subject: [PATCH] feat: Add deBridge plugin (#364) * add deBridge plugin * Update deBridge plugin readme * Update deBridge plugin readme and add linting * Update get_supported_chains tool and add author information --- python/src/plugins/debridge/README.md | 59 +++++ .../goat_plugins/debridge/__init__.py | 22 ++ .../goat_plugins/debridge/parameters.py | 186 +++++++++++++ .../debridge/goat_plugins/debridge/service.py | 249 ++++++++++++++++++ python/src/plugins/debridge/pyproject.toml | 44 ++++ 5 files changed, 560 insertions(+) create mode 100644 python/src/plugins/debridge/README.md create mode 100644 python/src/plugins/debridge/goat_plugins/debridge/__init__.py create mode 100644 python/src/plugins/debridge/goat_plugins/debridge/parameters.py create mode 100644 python/src/plugins/debridge/goat_plugins/debridge/service.py create mode 100644 python/src/plugins/debridge/pyproject.toml diff --git a/python/src/plugins/debridge/README.md b/python/src/plugins/debridge/README.md new file mode 100644 index 00000000..83357621 --- /dev/null +++ b/python/src/plugins/debridge/README.md @@ -0,0 +1,59 @@ +# deBridge Plugin for GOAT SDK + +A plugin for the GOAT SDK that provides access to deBridge Liquidity Network (DLN) functionality. + +## Installation + +```bash +# Install the plugin +poetry add goat-sdk-plugin-debridge + + +``` + +## Usage + +```python +from goat_plugins.debridge import debridge, DebridgePluginOptions + +# Initialize the plugin +options = DebridgePluginOptions() +plugin = debridge(options) + +# Get order data +order_data = await plugin.get_order_data( + id="0x81fbb0ed8209eb57d084aee1986c00e597c1e1ec6bb93bc4dbe97266ca0398fb" +) + +# Get supported chains +chains = await plugin.get_supported_chains() + +# Get order IDs +order_IDs = await plugin.get_order_IDs( + hash="0xbe9071de34de9bd84a52039bc4bc6c8229d4bd65127d034ffc66b600d8260276" # Hash of the creation transaction +) +``` + +## Features + +- DLN + - Create order transaction `create_order_transaction` + - Get order data `get_order_data` + - Get order status `get_order_status` + - Get order IDs `get_order_IDs` + - Cancel order `cancel_order` + - Cancel external call `cancel_external_call` +- Utils + - Get supported chains `get_supported_chains` + - Get token list `get_token_list` +- Single Chain Swap + - Estimation `single_chain_swap_estimation` + - Transaction `single_chain_swap_transaction` + +### DLN Swagger Docs +The deBridge plugin tools is a 1-to-1 representation of the DLN API. +You can access the [deBridge Swagger docs](https://dln.debridge.finance/v1.0) page for more information about the various parameters. + +## License + +This project is licensed under the terms of the MIT license. diff --git a/python/src/plugins/debridge/goat_plugins/debridge/__init__.py b/python/src/plugins/debridge/goat_plugins/debridge/__init__.py new file mode 100644 index 00000000..b2d74316 --- /dev/null +++ b/python/src/plugins/debridge/goat_plugins/debridge/__init__.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass +from goat.classes.plugin_base import PluginBase +from .service import DebridgeService + + +@dataclass +class DebridgePluginOptions: + # Debridge currently doesn't require any auth keys + pass + + +class DebridgePlugin(PluginBase): + + def __init__(self, options: DebridgePluginOptions): + super().__init__("debridge", [DebridgeService()]) + + def supports_chain(self, chain) -> bool: + return True + + +def debridge(options: DebridgePluginOptions) -> DebridgePlugin: + return DebridgePlugin(options) diff --git a/python/src/plugins/debridge/goat_plugins/debridge/parameters.py b/python/src/plugins/debridge/goat_plugins/debridge/parameters.py new file mode 100644 index 00000000..3fe615e6 --- /dev/null +++ b/python/src/plugins/debridge/goat_plugins/debridge/parameters.py @@ -0,0 +1,186 @@ +from pydantic import BaseModel, Field +from typing import Optional + + +class EmptyParameters(BaseModel): + pass + + +class GetTokenListParameters(BaseModel): + chainId: str = Field(description="ID of a chain") + + +class GetOrderDataParameters(BaseModel): + id: str = Field(description="ID of the order") + + +class GetOrderStatusParameters(BaseModel): + id: str = Field(description="ID of the order") + + +class GetOrderIDsParameters(BaseModel): + hash: str = Field(description="Hash of the creation transaction") + + +class CancelOrderParameters(BaseModel): + id: str = Field(description="ID of the order") + + +class CancelExternalCallParameters(BaseModel): + id: str = Field(description="ID of the order") + + +class CreateOrderTransactionParameters(BaseModel): + srcChainId: str = Field( + description= + "An ID of a source chain, a chain where the cross-chain swap will start" + ) + srcChainTokenIn: str = Field( + description="An address (on a source chain) of an input token to swap") + srcChainTokenInAmount: str = Field( + description="An amount of input tokens to swap") + dstChainId: str = Field( + description= + "An ID of a destination chain, a chain where the cross-chain swap will finish. Must differ from srcChainId!" + ) + dstChainTokenOut: str = Field( + description="An address (on a destination chain) of a target token") + dstChainTokenOutAmount: Optional[str] = Field( + default="auto", + description= + "Amount of the target asset the market maker expects to receive upon order fulfillment.", + ) + additionalTakerRewardBps: Optional[int] = Field( + description= + "additionalTakerRewardBps is additionally laid in on top of default taker margin" + ) + srcIntermediaryTokenAddress: Optional[str] = Field( + description= + "An address (on a source chain) of an intermediary token a user's input funds should be swapped to prior order creation" + ) + dstIntermediaryTokenAddress: Optional[str] = Field( + description= + "An address (on a destination chain) of an intermediary token whose value assumed to be equal to the value of srcIntermediaryTokenAddress" + ) + dstIntermediaryTokenSpenderAddress: Optional[str] = Field( + description= + "Applicable to a EVM-compatible destination chain. An address (on a EVM-compatible destination chain) assumed as a spender of the intermediary token (set as dstIntermediaryTokenAddress) during order fulfillment" + ) + intermediaryTokenUSDPrice: Optional[float] = Field( + description= + "A value (a spot price) of the given intermediary token expressed in US dollars" + ) + dstChainTokenOutRecipient: Optional[str] = Field( + description= + "Address (on the destination chain) where target tokens should be transferred to after the swap. Required for transaction construction, otherwise only the quote is returned!" + ) + senderAddress: Optional[str] = Field( + description= + "Address (on the source chain) who submits input tokens for a cross-chain swap" + ) + srcChainOrderAuthorityAddress: Optional[str] = Field( + description= + "Address (on the source chain) who submits input tokens for a cross-chain swap. Required for transaction construction, otherwise only the quote is returned!" + ) + srcAllowedCancelBeneficiary: Optional[str] = Field( + description= + "Fixed recipient of the funds of an order in case it is being cancelled. If not set, the recipient could be set later upon order cancellation" + ) + referralCode: Optional[float] = Field( + default=31494, + description= + "Your referral code which can be generated here: https://app.debridge.finance/refer", + ) + affiliateFeePercent: Optional[float] = Field( + default=0, + description= + "The share of the input amount to be distributed to the affiliateFeeRecipient (if given) address as an affiliate fee", + ) + affiliateFeeRecipient: Optional[str] = Field( + description= + "An address (on an origin chain) that will receive affiliate fees according to the affiliateFeePercent parameter" + ) + srcChainTokenInSenderPermit: Optional[str] = Field( + description= + "Typically, a sender is required to approve token transfer to deBridge forwarder for further transfer and swap" + ) + dstChainOrderAuthorityAddress: Optional[str] = Field( + description= + "Address on the destination chain whom should be granted the privileges to manage the order (patch, cancel, etc). Required for transaction construction, otherwise only the quote is returned!" + ) + enableEstimate: Optional[bool] = Field( + description= + "This flag forces deSwap API to validate the resulting transaction and estimate its gas consumption" + ) + allowedTaker: Optional[str] = Field( + description="An address (on a destination chain) of a allowed taker") + dlnHook: Optional[str] = Field( + description="JSON representing a DLN Hook to be attached to an order") + prependOperatingExpenses: Optional[bool] = Field( + default=False, + description= + "Tells API server to prepend operating expenses to the input amount", + ) + metadata: Optional[str] = Field(default=False, description="Metadata") + ptp: Optional[bool] = Field( + default=False, + description= + "Forces a P2P order where input and output tokens are left intact", + ) + skipSolanaRecipientValidation: Optional[bool] = Field( + default=False, + description= + "Skip system address validation dstChainTokenOutRecipient in Solana", + ) + + +class SingleChainSwapEstimationParameters(BaseModel): + chainId: str = Field( + description="An ID of a chain, a chain where the swap must be performed" + ) + tokenIn: str = Field(description="An address of an input token to swap") + tokenInAmount: str = Field(description="An amount of input tokens to swap") + slippage: Optional[str] = Field( + default="auto", + description= + "A slippage constraint (in %) is a safeguard during swaps (on both source and destination chains, if applicable). It is also used to calculate the minimum possible outcome during estimation", + ) + tokenOut: str = Field(description="An address of a target token") + affiliateFeePercent: Optional[float] = Field( + default=0, + description= + "The share of the input amount to be distributed to the affiliateFeeRecipient (if given) address as an affiliate fee", + ) + affiliateFeeRecipient: Optional[str] = Field( + description= + "An address (on an origin chain) that will receive affiliate fees according to the affiliateFeePercent parameter" + ) + + +class SingleChainSwapTransactionParameters(BaseModel): + chainId: str = Field( + description="An ID of a chain, a chain where the swap must be performed" + ) + tokenIn: str = Field(description="An address of an input token to swap") + tokenInAmount: str = Field(description="An amount of input tokens to swap") + slippage: Optional[str] = Field( + default="auto", + description= + "A slippage constraint (in %) is a safeguard during swaps (on both source and destination chains, if applicable). It is also used to calculate the minimum possible outcome during estimation", + ) + tokenOut: str = Field(description="An address of a target token") + tokenOutRecipient: str = Field( + description="Address who receives the tokens from the swap") + affiliateFeePercent: Optional[float] = Field( + default=0, + description= + "The share of the input amount to be distributed to the affiliateFeeRecipient (if given) address as an affiliate fee", + ) + affiliateFeeRecipient: Optional[str] = Field( + description= + "An address (on an origin chain) that will receive affiliate fees according to the affiliateFeePercent parameter" + ) + senderAddress: Optional[str] = Field( + description= + "Address (on the source chain) who submits input tokens for a cross-chain swap" + ) diff --git a/python/src/plugins/debridge/goat_plugins/debridge/service.py b/python/src/plugins/debridge/goat_plugins/debridge/service.py new file mode 100644 index 00000000..127c2265 --- /dev/null +++ b/python/src/plugins/debridge/goat_plugins/debridge/service.py @@ -0,0 +1,249 @@ +import aiohttp +from goat.decorators.tool import Tool +from urllib.parse import urlencode +from .parameters import ( + CancelExternalCallParameters, + CancelOrderParameters, + CreateOrderTransactionParameters, + EmptyParameters, + GetOrderDataParameters, + GetOrderIDsParameters, + GetOrderStatusParameters, + GetTokenListParameters, + SingleChainSwapEstimationParameters, + SingleChainSwapTransactionParameters, +) + + +class DebridgeService: + + def __init__(self): + self.base_url = "https://dln.debridge.finance/v1.0" + + async def _fetch(self, url: str, action: str): + try: + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + if not response.ok: + raise Exception( + f"HTTP error! status: {response.status} {await response.text()}" + ) + return await response.json() + except Exception as e: + raise Exception(f"Failed to {action}: {e}") + + @Tool({ + "description": + "Get details for the chains supported by the deBridge Liquidity Network", + "parameters_schema": EmptyParameters, + }) + async def get_supported_chains(self, parameters: dict): + """Get details for the chains supported by the deBridge Liquidity Network""" + url = f"{self.base_url}/supported-chains-info" + return await self._fetch(url, "get supported chains") + + @Tool({ + "description": + "Get the token list supported by the deBridge Liquidity Network", + "parameters_schema": GetTokenListParameters, + }) + async def get_token_list(self, parameters: dict): + """Get the token list supported by the deBridge Liquidity Network""" + chainId = parameters["chainId"] + url = f"{self.base_url}/token-list?chainId={chainId}" + return await self._fetch(url, "get token list") + + @Tool({ + "description": "Get the data of order", + "parameters_schema": GetOrderDataParameters, + }) + async def get_order_data(self, parameters: dict): + """Get the data of order""" + id = parameters["id"] + url = f"{self.base_url}/dln/order/{id}" + return await self._fetch(url, "get order data") + + @Tool({ + "description": "Get the status of order", + "parameters_schema": GetOrderStatusParameters, + }) + async def get_order_status(self, parameters: dict): + """Get the status of order""" + id = parameters["id"] + url = f"{self.base_url}/dln/order/{id}/status" + return await self._fetch(url, "get order status") + + @Tool({ + "description": + "Get the order IDs from the hash of the creation transaction", + "parameters_schema": GetOrderIDsParameters, + }) + async def get_order_IDs(self, parameters: dict): + """Get the order IDs from the hash of the creation transaction""" + hash = parameters["hash"] + url = f"{self.base_url}/dln/tx/{hash}/order-ids" + return await self._fetch(url, "get order IDs") + + @Tool({ + "description": "Generate a transaction that cancels the given order", + "parameters_schema": CancelOrderParameters, + }) + async def cancel_order(self, parameters: dict): + """Generate a transaction that cancels the given order""" + id = parameters["id"] + url = f"{self.base_url}/dln/order/{id}/cancel-tx" + return await self._fetch(url, "cancel order") + + @Tool({ + "description": + "Generate a transaction that cancels external call in the given order", + "parameters_schema": CancelExternalCallParameters, + }) + async def cancel_external_call(self, parameters: dict): + """Generate a transaction that cancels external call in the given order""" + id = parameters["id"] + url = f"{self.base_url}/dln/order/{id}/extcall-cancel-tx" + return await self._fetch(url, "cancel external call") + + @Tool({ + "description": + "Generate the data for a transaction to place a cross-chain DLN order", + "parameters_schema": CreateOrderTransactionParameters, + }) + async def create_order_transaction(self, parameters: dict): + """Generate the data for a transaction to place a cross-chain DLN order""" + # Required parameters + base_params = { + "srcChainId": parameters["srcChainId"], + "srcChainTokenIn": parameters["srcChainTokenIn"], + "srcChainTokenInAmount": parameters["srcChainTokenInAmount"], + "dstChainId": parameters["dstChainId"], + "dstChainTokenOut": parameters["dstChainTokenOut"], + } + + # Optional parameters - only add them if they exist in parameters + optional_params = { + "dstChainTokenOutAmount": + parameters.get("dstChainTokenOutAmount", "auto"), + "additionalTakerRewardBps": + parameters.get("additionalTakerRewardBps"), + "srcIntermediaryTokenAddress": + parameters.get("srcIntermediaryTokenAddress"), + "dstIntermediaryTokenAddress": + parameters.get("dstIntermediaryTokenAddress"), + "dstIntermediaryTokenSpenderAddress": + parameters.get("dstIntermediaryTokenSpenderAddress"), + "intermediaryTokenUSDPrice": + parameters.get("intermediaryTokenUSDPrice"), + "dstChainTokenOutRecipient": + parameters.get("dstChainTokenOutRecipient"), + "senderAddress": + parameters.get("senderAddress"), + "srcChainOrderAuthorityAddress": + parameters.get("srcChainOrderAuthorityAddress"), + "srcAllowedCancelBeneficiary": + parameters.get("srcAllowedCancelBeneficiary"), + "referralCode": + parameters.get("referralCode", 31494), + "affiliateFeePercent": + parameters.get("affiliateFeePercent", 0), + "affiliateFeeRecipient": + parameters.get("affiliateFeeRecipient"), + "srcChainTokenInSenderPermit": + parameters.get("srcChainTokenInSenderPermit"), + "dstChainOrderAuthorityAddress": + parameters.get("dstChainOrderAuthorityAddress"), + "enableEstimate": + parameters.get("enableEstimate"), + "allowedTaker": + parameters.get("allowedTaker"), + "dlnHook": + parameters.get("dlnHook"), + "prependOperatingExpenses": + parameters.get("prependOperatingExpenses", False), + "metadata": + parameters.get("metadata"), + "ptp": + parameters.get("ptp"), + "skipSolanaRecipientValidation": + parameters.get("skipSolanaRecipientValidation", False) + } + + # Remove None values from optional parameters + optional_params = { + k: v + for k, v in optional_params.items() if v is not None + } + + # Combine all parameters + all_params = {**base_params, **optional_params} + + # Construct URL with query parameters + query_string = urlencode(all_params) + url = f"{self.base_url}/dln/order/create-tx?{query_string}" + + return await self._fetch(url, "create order transaction") + + @Tool({ + "description": "Get the data for a single chain swap estimation", + "parameters_schema": SingleChainSwapEstimationParameters, + }) + async def single_chain_swap_estimation(self, parameters: dict): + """Get the data for a single chain swap estimation""" + base_params = { + "chainId": parameters["chainId"], + "tokenIn": parameters["tokenIn"], + "tokenInAmount": parameters["tokenInAmount"], + "tokenOut": parameters["tokenOut"], + } + + optional_params = { + "slippage": parameters.get("slippage", "auto"), + "affiliateFeePercent": parameters.get("affiliateFeePercent", 0), + "affiliateFeeRecipient": parameters.get("affiliateFeeRecipient"), + } + + optional_params = { + k: v + for k, v in optional_params.items() if v is not None + } + + all_params = {**base_params, **optional_params} + + query_string = urlencode(all_params) + url = f"{self.base_url}/chain/estimation?{query_string}" + + return await self._fetch(url, "single chain swap estimation") + + @Tool({ + "description": "Get the data for a single chain swap transaction", + "parameters_schema": SingleChainSwapTransactionParameters, + }) + async def single_chain_swap_transaction(self, parameters: dict): + """Get the data for a single chain swap transaction""" + base_params = { + "chainId": parameters["chainId"], + "tokenIn": parameters["tokenIn"], + "tokenInAmount": parameters["tokenInAmount"], + "tokenOut": parameters["tokenOut"], + "tokenOutRecipient": parameters["tokenOutRecipient"], + } + + optional_params = { + "slippage": parameters.get("slippage", "auto"), + "affiliateFeePercent": parameters.get("affiliateFeePercent", 0), + "affiliateFeeRecipient": parameters.get("affiliateFeeRecipient"), + "senderAddress": parameters.get("senderAddress"), + } + + optional_params = { + k: v + for k, v in optional_params.items() if v is not None + } + + all_params = {**base_params, **optional_params} + + query_string = urlencode(all_params) + url = f"{self.base_url}/chain/transaction?{query_string}" + + return await self._fetch(url, "single chain swap transaction") diff --git a/python/src/plugins/debridge/pyproject.toml b/python/src/plugins/debridge/pyproject.toml new file mode 100644 index 00000000..3026d8a1 --- /dev/null +++ b/python/src/plugins/debridge/pyproject.toml @@ -0,0 +1,44 @@ +[tool.poetry] +name = "goat-sdk-plugin-debridge" +version = "0.1.0" +description = "Goat plugin for deBridge" +authors = ["Ofem Eteng "] +readme = "README.md" +keywords = ["goat", "sdk", "agents", "ai", "debridge"] +homepage = "https://ohmygoat.dev/" +repository = "https://github.com/goat-sdk/goat" +packages = [ + { include = "goat_plugins/debridge" }, +] + +[tool.poetry.dependencies] +python = "^3.10" +aiohttp = "^3.8.6" +goat-sdk = "^0.1.0" +pydantic = "^2.0.0" + +[tool.poetry.group.test.dependencies] +pytest = "^8.3.4" +pytest-asyncio = "^0.25.0" + +[tool.poetry.urls] +"Bug Tracker" = "https://github.com/goat-sdk/goat/issues" + +[tool.pytest.ini_options] +addopts = [ + "--import-mode=importlib", +] +pythonpath = "src" +asyncio_default_fixture_loop_scope = "function" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.group.dev.dependencies] +ruff = "^0.8.6" +goat-sdk = { path = "../../goat-sdk", develop = true } + +[tool.ruff] +line-length = 120 +target-version = "py312"