hak-saucerswap-plugin 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. hak_saucerswap_plugin-0.1.0/.env.example +8 -0
  2. hak_saucerswap_plugin-0.1.0/.github/workflows/ci.yml +33 -0
  3. hak_saucerswap_plugin-0.1.0/.github/workflows/publish.yml +23 -0
  4. hak_saucerswap_plugin-0.1.0/.gitignore +7 -0
  5. hak_saucerswap_plugin-0.1.0/PKG-INFO +86 -0
  6. hak_saucerswap_plugin-0.1.0/README.md +70 -0
  7. hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/__init__.py +70 -0
  8. hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/api/__init__.py +4 -0
  9. hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/api/client.py +147 -0
  10. hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/api/endpoints.py +8 -0
  11. hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/config.py +148 -0
  12. hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/networks.py +42 -0
  13. hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/tools/__init__.py +25 -0
  14. hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/tools/common.py +51 -0
  15. hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/tools/farms.py +62 -0
  16. hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/tools/liquidity.py +256 -0
  17. hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/tools/pools.py +84 -0
  18. hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/tools/quote.py +115 -0
  19. hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/tools/swap.py +165 -0
  20. hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/utils/__init__.py +23 -0
  21. hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/utils/amm.py +30 -0
  22. hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/utils/quote.py +37 -0
  23. hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/utils/tokens.py +47 -0
  24. hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/utils/units.py +39 -0
  25. hak_saucerswap_plugin-0.1.0/pyproject.toml +44 -0
  26. hak_saucerswap_plugin-0.1.0/tests/test_amm.py +24 -0
  27. hak_saucerswap_plugin-0.1.0/tests/test_client.py +90 -0
  28. hak_saucerswap_plugin-0.1.0/tests/test_config.py +78 -0
  29. hak_saucerswap_plugin-0.1.0/tests/test_tools.py +144 -0
  30. hak_saucerswap_plugin-0.1.0/tests/test_units.py +41 -0
  31. hak_saucerswap_plugin-0.1.0/uv.lock +4424 -0
@@ -0,0 +1,8 @@
1
+ # Hedera operator (consumer app constructs the Client)
2
+ HEDERA_OPERATOR_ID=
3
+ HEDERA_OPERATOR_KEY=
4
+
5
+ # SaucerSwap plugin
6
+ # Request an API key at support@saucerswap.finance
7
+ SAUCERSWAP_API_KEY=
8
+ SAUCERSWAP_NETWORK=mainnet
@@ -0,0 +1,33 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@v5
20
+ with:
21
+ python-version: ${{ matrix.python-version }}
22
+
23
+ - name: Install dependencies
24
+ run: uv sync --python ${{ matrix.python-version }}
25
+
26
+ - name: Lint
27
+ run: uv run ruff check .
28
+
29
+ - name: Test
30
+ run: uv run pytest -q
31
+
32
+ - name: Build
33
+ run: uv build
@@ -0,0 +1,23 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write # required for PyPI Trusted Publishing (OIDC)
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Install uv
17
+ uses: astral-sh/setup-uv@v5
18
+
19
+ - name: Build
20
+ run: uv build
21
+
22
+ - name: Publish
23
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ .env
6
+ .pytest_cache/
7
+ .ruff_cache/
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: hak-saucerswap-plugin
3
+ Version: 0.1.0
4
+ Summary: Hedera Agent Kit (Python) plugin for SaucerSwap DEX: quotes, swaps, liquidity, pools, and farms
5
+ Project-URL: Repository, https://github.com/jmgomezl/hak-saucerswap-plugin-py
6
+ Project-URL: Documentation, https://github.com/jmgomezl/hak-saucerswap-plugin-py#readme
7
+ Author-email: Juan Gomez <juanmag.lpez@gmail.com>
8
+ License: MIT
9
+ Keywords: agent,ai,dex,hashgraph,hedera,hedera-agent-kit,saucerswap
10
+ Requires-Python: <3.14,>=3.10
11
+ Requires-Dist: hedera-agent-kit<4,>=3.4.2
12
+ Requires-Dist: hiero-sdk-python<0.3,>=0.2.0
13
+ Requires-Dist: httpx>=0.27
14
+ Requires-Dist: pydantic>=2.7
15
+ Description-Content-Type: text/markdown
16
+
17
+ # hak-saucerswap-plugin (Python)
18
+
19
+ A [Hedera Agent Kit](https://github.com/hashgraph/hedera-agent-kit-py) plugin that integrates the [SaucerSwap](https://saucerswap.finance) DEX, letting AI agents quote swaps, execute trades, manage liquidity, and explore farming opportunities on Hedera.
20
+
21
+ Python port of the TypeScript plugin [`hak-saucerswap-plugin`](https://github.com/jmgomezl/hak-saucerswap-plugin).
22
+
23
+ ## Tools
24
+
25
+ | Method | Description | On-chain |
26
+ | --- | --- | --- |
27
+ | `saucerswap_get_swap_quote` | Price quote with min output, price impact, and route | No |
28
+ | `saucerswap_swap_tokens` | Execute a swap via the router (`swapExactTokensForTokens`) | Yes |
29
+ | `saucerswap_get_pools` | List/filter liquidity pools and reserves | No |
30
+ | `saucerswap_add_liquidity` | Add liquidity to a pool (`addLiquidity`) | Yes |
31
+ | `saucerswap_remove_liquidity` | Burn LP tokens for the underlying pair (`removeLiquidity`) | Yes |
32
+ | `saucerswap_get_farms` | List active yield farms | No |
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install hak-saucerswap-plugin
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ ```python
43
+ from hedera_agent_kit.shared.configuration import AgentMode, Configuration, Context
44
+ from hak_saucerswap_plugin import saucerswap_plugin
45
+
46
+ context = Context(account_id="0.0.xxxx", mode=AgentMode.AUTONOMOUS)
47
+ # Attach plugin configuration to the context (optional; env vars also work):
48
+ context.saucerswap = {"network": "mainnet"}
49
+
50
+ configuration = Configuration(plugins=[saucerswap_plugin], context=context)
51
+ ```
52
+
53
+ ## Configuration
54
+
55
+ Settings resolve with this precedence: context config → environment variables → network defaults → built-in defaults.
56
+
57
+ | Context key | Env var | Description | Default |
58
+ | --- | --- | --- | --- |
59
+ | `base_url` | `SAUCERSWAP_BASE_URL` | SaucerSwap REST API base URL | `https://api.saucerswap.finance` |
60
+ | `api_key` | `SAUCERSWAP_API_KEY` | API key sent as `x-api-key` (request at support@saucerswap.finance) | — |
61
+ | `network` | `SAUCERSWAP_NETWORK` | `mainnet` or `testnet`; fills router/WHBAR/alias defaults | — |
62
+ | `timeout_seconds` | `SAUCERSWAP_TIMEOUT_SECONDS` | HTTP timeout in seconds | `10` |
63
+ | `retries` | `SAUCERSWAP_RETRIES` | Retries for 429/5xx/transport errors | `2` |
64
+ | `router_contract_id` | `SAUCERSWAP_ROUTER_CONTRACT_ID` | V1 router contract ID | from network |
65
+ | `router_v2_contract_id` | `SAUCERSWAP_ROUTER_V2_CONTRACT_ID` | V2 router contract ID | from network |
66
+ | `wrapped_hbar_token_id` | `SAUCERSWAP_WRAPPED_HBAR_TOKEN_ID` | WHBAR token ID (used for the `HBAR` alias) | from network |
67
+ | `token_aliases` | `SAUCERSWAP_TOKEN_ALIASES` | Symbol → token ID map (env var takes JSON) | from network |
68
+ | `default_pool_version` | `SAUCERSWAP_DEFAULT_POOL_VERSION` | `v1` or `v2` | `v2` |
69
+ | `gas_limit` | `SAUCERSWAP_GAS_LIMIT` | Gas limit for router calls | `2000000` |
70
+ | `deadline_minutes` | `SAUCERSWAP_DEADLINE_MINUTES` | Default transaction deadline | `20` |
71
+
72
+ Network defaults (contract addresses from the [official SaucerSwap deployments](https://docs.saucerswap.finance/developerx/contract-deployments)) are exported as `SAUCERSWAP_MAINNET` and `SAUCERSWAP_TESTNET`.
73
+
74
+ A pre-configured `httpx.AsyncClient` can also be injected by attaching it to the context as `context.saucerswap_client` (e.g. to share auth headers or transports).
75
+
76
+ ## Development
77
+
78
+ ```bash
79
+ uv sync
80
+ uv run pytest
81
+ uv run ruff check .
82
+ ```
83
+
84
+ ## License
85
+
86
+ MIT
@@ -0,0 +1,70 @@
1
+ # hak-saucerswap-plugin (Python)
2
+
3
+ A [Hedera Agent Kit](https://github.com/hashgraph/hedera-agent-kit-py) plugin that integrates the [SaucerSwap](https://saucerswap.finance) DEX, letting AI agents quote swaps, execute trades, manage liquidity, and explore farming opportunities on Hedera.
4
+
5
+ Python port of the TypeScript plugin [`hak-saucerswap-plugin`](https://github.com/jmgomezl/hak-saucerswap-plugin).
6
+
7
+ ## Tools
8
+
9
+ | Method | Description | On-chain |
10
+ | --- | --- | --- |
11
+ | `saucerswap_get_swap_quote` | Price quote with min output, price impact, and route | No |
12
+ | `saucerswap_swap_tokens` | Execute a swap via the router (`swapExactTokensForTokens`) | Yes |
13
+ | `saucerswap_get_pools` | List/filter liquidity pools and reserves | No |
14
+ | `saucerswap_add_liquidity` | Add liquidity to a pool (`addLiquidity`) | Yes |
15
+ | `saucerswap_remove_liquidity` | Burn LP tokens for the underlying pair (`removeLiquidity`) | Yes |
16
+ | `saucerswap_get_farms` | List active yield farms | No |
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install hak-saucerswap-plugin
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```python
27
+ from hedera_agent_kit.shared.configuration import AgentMode, Configuration, Context
28
+ from hak_saucerswap_plugin import saucerswap_plugin
29
+
30
+ context = Context(account_id="0.0.xxxx", mode=AgentMode.AUTONOMOUS)
31
+ # Attach plugin configuration to the context (optional; env vars also work):
32
+ context.saucerswap = {"network": "mainnet"}
33
+
34
+ configuration = Configuration(plugins=[saucerswap_plugin], context=context)
35
+ ```
36
+
37
+ ## Configuration
38
+
39
+ Settings resolve with this precedence: context config → environment variables → network defaults → built-in defaults.
40
+
41
+ | Context key | Env var | Description | Default |
42
+ | --- | --- | --- | --- |
43
+ | `base_url` | `SAUCERSWAP_BASE_URL` | SaucerSwap REST API base URL | `https://api.saucerswap.finance` |
44
+ | `api_key` | `SAUCERSWAP_API_KEY` | API key sent as `x-api-key` (request at support@saucerswap.finance) | — |
45
+ | `network` | `SAUCERSWAP_NETWORK` | `mainnet` or `testnet`; fills router/WHBAR/alias defaults | — |
46
+ | `timeout_seconds` | `SAUCERSWAP_TIMEOUT_SECONDS` | HTTP timeout in seconds | `10` |
47
+ | `retries` | `SAUCERSWAP_RETRIES` | Retries for 429/5xx/transport errors | `2` |
48
+ | `router_contract_id` | `SAUCERSWAP_ROUTER_CONTRACT_ID` | V1 router contract ID | from network |
49
+ | `router_v2_contract_id` | `SAUCERSWAP_ROUTER_V2_CONTRACT_ID` | V2 router contract ID | from network |
50
+ | `wrapped_hbar_token_id` | `SAUCERSWAP_WRAPPED_HBAR_TOKEN_ID` | WHBAR token ID (used for the `HBAR` alias) | from network |
51
+ | `token_aliases` | `SAUCERSWAP_TOKEN_ALIASES` | Symbol → token ID map (env var takes JSON) | from network |
52
+ | `default_pool_version` | `SAUCERSWAP_DEFAULT_POOL_VERSION` | `v1` or `v2` | `v2` |
53
+ | `gas_limit` | `SAUCERSWAP_GAS_LIMIT` | Gas limit for router calls | `2000000` |
54
+ | `deadline_minutes` | `SAUCERSWAP_DEADLINE_MINUTES` | Default transaction deadline | `20` |
55
+
56
+ Network defaults (contract addresses from the [official SaucerSwap deployments](https://docs.saucerswap.finance/developerx/contract-deployments)) are exported as `SAUCERSWAP_MAINNET` and `SAUCERSWAP_TESTNET`.
57
+
58
+ A pre-configured `httpx.AsyncClient` can also be injected by attaching it to the context as `context.saucerswap_client` (e.g. to share auth headers or transports).
59
+
60
+ ## Development
61
+
62
+ ```bash
63
+ uv sync
64
+ uv run pytest
65
+ uv run ruff check .
66
+ ```
67
+
68
+ ## License
69
+
70
+ MIT
@@ -0,0 +1,70 @@
1
+ """hak-saucerswap-plugin: Hedera Agent Kit (Python) plugin for the SaucerSwap DEX."""
2
+
3
+ from hedera_agent_kit.shared.plugin import Plugin
4
+
5
+ from .config import SaucerSwapConfig, resolve_saucerswap_config
6
+ from .networks import (
7
+ NETWORK_DEFAULTS,
8
+ SAUCERSWAP_MAINNET,
9
+ SAUCERSWAP_TESTNET,
10
+ SaucerSwapNetworkDefaults,
11
+ )
12
+ from .tools import (
13
+ SAUCERSWAP_ADD_LIQUIDITY_TOOL,
14
+ SAUCERSWAP_GET_FARMS_TOOL,
15
+ SAUCERSWAP_GET_POOLS_TOOL,
16
+ SAUCERSWAP_GET_SWAP_QUOTE_TOOL,
17
+ SAUCERSWAP_REMOVE_LIQUIDITY_TOOL,
18
+ SAUCERSWAP_SWAP_TOKENS_TOOL,
19
+ AddLiquidityTool,
20
+ FarmsTool,
21
+ PoolsTool,
22
+ QuoteTool,
23
+ RemoveLiquidityTool,
24
+ SwapTool,
25
+ )
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ saucerswap_plugin_tool_names = {
30
+ "SAUCERSWAP_GET_SWAP_QUOTE_TOOL": SAUCERSWAP_GET_SWAP_QUOTE_TOOL,
31
+ "SAUCERSWAP_SWAP_TOKENS_TOOL": SAUCERSWAP_SWAP_TOKENS_TOOL,
32
+ "SAUCERSWAP_GET_POOLS_TOOL": SAUCERSWAP_GET_POOLS_TOOL,
33
+ "SAUCERSWAP_ADD_LIQUIDITY_TOOL": SAUCERSWAP_ADD_LIQUIDITY_TOOL,
34
+ "SAUCERSWAP_REMOVE_LIQUIDITY_TOOL": SAUCERSWAP_REMOVE_LIQUIDITY_TOOL,
35
+ "SAUCERSWAP_GET_FARMS_TOOL": SAUCERSWAP_GET_FARMS_TOOL,
36
+ }
37
+
38
+ saucerswap_plugin = Plugin(
39
+ name="saucerswap",
40
+ version=__version__,
41
+ description=(
42
+ "Integration with SaucerSwap DEX for token swaps, liquidity provision, "
43
+ "and yield farming"
44
+ ),
45
+ tools=lambda context: [
46
+ SwapTool(context),
47
+ QuoteTool(context),
48
+ PoolsTool(context),
49
+ AddLiquidityTool(context),
50
+ RemoveLiquidityTool(context),
51
+ FarmsTool(context),
52
+ ],
53
+ )
54
+
55
+ __all__ = [
56
+ "saucerswap_plugin",
57
+ "saucerswap_plugin_tool_names",
58
+ "SaucerSwapConfig",
59
+ "resolve_saucerswap_config",
60
+ "SaucerSwapNetworkDefaults",
61
+ "SAUCERSWAP_MAINNET",
62
+ "SAUCERSWAP_TESTNET",
63
+ "NETWORK_DEFAULTS",
64
+ "SwapTool",
65
+ "QuoteTool",
66
+ "PoolsTool",
67
+ "AddLiquidityTool",
68
+ "RemoveLiquidityTool",
69
+ "FarmsTool",
70
+ ]
@@ -0,0 +1,4 @@
1
+ from .client import SaucerSwapClient, create_saucerswap_client
2
+ from .endpoints import SAUCER_ENDPOINTS
3
+
4
+ __all__ = ["SaucerSwapClient", "create_saucerswap_client", "SAUCER_ENDPOINTS"]
@@ -0,0 +1,147 @@
1
+ """Async HTTP client for the SaucerSwap REST API with retries and token caching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from .endpoints import SAUCER_ENDPOINTS
12
+
13
+ TOKEN_ID_RE = re.compile(r"^\d+\.\d+\.\d+$")
14
+
15
+ RETRYABLE_STATUS = {429}
16
+
17
+
18
+ def _is_retryable_status(status: int) -> bool:
19
+ return status in RETRYABLE_STATUS or 500 <= status < 600
20
+
21
+
22
+ class SaucerSwapClient:
23
+ """Thin wrapper over the SaucerSwap REST API.
24
+
25
+ Accepts an optional pre-configured ``httpx.AsyncClient`` (``http``) so a
26
+ host application can inject auth headers or custom transports.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ base_url: str = "https://api.saucerswap.finance",
33
+ timeout_seconds: float = 10.0,
34
+ retries: int = 2,
35
+ api_key: str | None = None,
36
+ http: httpx.AsyncClient | None = None,
37
+ ):
38
+ self.retries = retries
39
+ if http is not None:
40
+ self._http = http
41
+ else:
42
+ headers = {"x-api-key": api_key} if api_key else None
43
+ self._http = httpx.AsyncClient(
44
+ base_url=base_url, timeout=timeout_seconds, headers=headers
45
+ )
46
+ self._tokens_cache: list[dict[str, Any]] | None = None
47
+ self._token_index: dict[str, dict[str, Any]] | None = None
48
+
49
+ async def _request(self, path: str, params: dict[str, Any] | None = None) -> Any:
50
+ last_error: Exception | None = None
51
+ for attempt in range(self.retries + 1):
52
+ try:
53
+ response = await self._http.get(path, params=params)
54
+ if _is_retryable_status(response.status_code) and attempt < self.retries:
55
+ await asyncio.sleep(0.2 * 2**attempt)
56
+ continue
57
+ response.raise_for_status()
58
+ return response.json()
59
+ except (httpx.TransportError, httpx.HTTPStatusError) as error:
60
+ last_error = error
61
+ retryable = isinstance(error, httpx.TransportError) or (
62
+ isinstance(error, httpx.HTTPStatusError)
63
+ and _is_retryable_status(error.response.status_code)
64
+ )
65
+ if attempt < self.retries and retryable:
66
+ await asyncio.sleep(0.2 * 2**attempt)
67
+ continue
68
+ raise
69
+ raise last_error if last_error else RuntimeError("SaucerSwap request failed")
70
+
71
+ async def get_tokens(self) -> list[dict[str, Any]]:
72
+ tokens = await self._request(SAUCER_ENDPOINTS["tokens"])
73
+ self._cache_tokens(tokens)
74
+ return tokens
75
+
76
+ async def get_token_by_id_or_symbol(self, value: str) -> dict[str, Any] | None:
77
+ if self._token_index is None:
78
+ await self.get_tokens()
79
+ assert self._token_index is not None
80
+ return self._token_index.get(value.lower())
81
+
82
+ async def resolve_token_id(self, value: str) -> str:
83
+ if TOKEN_ID_RE.match(value):
84
+ return value
85
+ token = await self.get_token_by_id_or_symbol(value)
86
+ return token["id"] if token else value
87
+
88
+ async def get_pools(self, version: str) -> list[dict[str, Any]]:
89
+ endpoint = (
90
+ SAUCER_ENDPOINTS["pools_v1"] if version == "v1" else SAUCER_ENDPOINTS["pools_v2"]
91
+ )
92
+ return await self._request(endpoint)
93
+
94
+ async def get_pool_by_tokens(
95
+ self, token_a: str, token_b: str, version: str
96
+ ) -> dict[str, Any] | None:
97
+ pools = await self.get_pools(version)
98
+ for pool in pools:
99
+ a = pool.get("tokenA", {}).get("id")
100
+ b = pool.get("tokenB", {}).get("id")
101
+ if (a == token_a and b == token_b) or (a == token_b and b == token_a):
102
+ return pool
103
+ return None
104
+
105
+ async def get_swap_quote(
106
+ self, *, from_token: str, to_token: str, amount: str
107
+ ) -> dict[str, Any]:
108
+ return await self._request(
109
+ SAUCER_ENDPOINTS["swap_quote"],
110
+ params={
111
+ "tokenIn": from_token,
112
+ "tokenOut": to_token,
113
+ "amount": amount,
114
+ "fromToken": from_token,
115
+ "toToken": to_token,
116
+ },
117
+ )
118
+
119
+ async def get_farms(self) -> list[dict[str, Any]]:
120
+ return await self._request(SAUCER_ENDPOINTS["farms"])
121
+
122
+ async def get_stats(self) -> dict[str, Any]:
123
+ return await self._request(SAUCER_ENDPOINTS["stats"])
124
+
125
+ async def aclose(self) -> None:
126
+ await self._http.aclose()
127
+
128
+ def _cache_tokens(self, tokens: list[dict[str, Any]]) -> None:
129
+ self._tokens_cache = tokens
130
+ self._token_index = {}
131
+ for token in tokens:
132
+ for key in ("id", "symbol", "name"):
133
+ value = token.get(key)
134
+ if isinstance(value, str):
135
+ self._token_index[value.lower()] = token
136
+
137
+
138
+ def create_saucerswap_client(
139
+ config: Any, http: httpx.AsyncClient | None = None
140
+ ) -> SaucerSwapClient:
141
+ return SaucerSwapClient(
142
+ base_url=config.base_url,
143
+ timeout_seconds=config.timeout_seconds,
144
+ retries=config.retries,
145
+ api_key=config.api_key,
146
+ http=http,
147
+ )
@@ -0,0 +1,8 @@
1
+ SAUCER_ENDPOINTS = {
2
+ "pools_v1": "/pools",
3
+ "pools_v2": "/v2/pools",
4
+ "swap_quote": "/v1/swap/quote",
5
+ "tokens": "/tokens",
6
+ "farms": "/farms",
7
+ "stats": "/stats",
8
+ }
@@ -0,0 +1,148 @@
1
+ """Configuration resolution for the SaucerSwap plugin.
2
+
3
+ Precedence (highest first): context-attached config -> environment variables
4
+ -> network defaults -> built-in defaults. Mirrors the TypeScript plugin's
5
+ `resolveSaucerSwapConfig`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ from .networks import NETWORK_DEFAULTS
16
+
17
+ PoolVersion = str # "v1" | "v2"
18
+
19
+ DEFAULT_BASE_URL = "https://api.saucerswap.finance"
20
+ DEFAULT_TIMEOUT_SECONDS = 10.0
21
+ DEFAULT_RETRIES = 2
22
+ DEFAULT_POOL_VERSION: PoolVersion = "v2"
23
+ DEFAULT_GAS_LIMIT = 2_000_000
24
+ DEFAULT_DEADLINE_MINUTES = 20
25
+
26
+
27
+ @dataclass
28
+ class SaucerSwapConfig:
29
+ base_url: str = DEFAULT_BASE_URL
30
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
31
+ retries: int = DEFAULT_RETRIES
32
+ api_key: str | None = None
33
+ network: str | None = None
34
+ router_contract_id: str | None = None
35
+ router_v2_contract_id: str | None = None
36
+ wrapped_hbar_token_id: str | None = None
37
+ token_aliases: dict[str, str] = field(default_factory=dict)
38
+ default_pool_version: PoolVersion = DEFAULT_POOL_VERSION
39
+ gas_limit: int = DEFAULT_GAS_LIMIT
40
+ deadline_minutes: int = DEFAULT_DEADLINE_MINUTES
41
+
42
+
43
+ def _to_number(value: str | None, fallback: float) -> float:
44
+ if not value:
45
+ return fallback
46
+ try:
47
+ return float(value)
48
+ except ValueError:
49
+ return fallback
50
+
51
+
52
+ def _to_int(value: str | None, fallback: int) -> int:
53
+ if not value:
54
+ return fallback
55
+ try:
56
+ return int(float(value))
57
+ except ValueError:
58
+ return fallback
59
+
60
+
61
+ def _read_token_aliases(value: str | None) -> dict[str, str]:
62
+ if not value:
63
+ return {}
64
+ try:
65
+ parsed = json.loads(value)
66
+ except json.JSONDecodeError:
67
+ return {}
68
+ if isinstance(parsed, dict):
69
+ return {str(k): str(v) for k, v in parsed.items()}
70
+ return {}
71
+
72
+
73
+ def _read_pool_version(value: str | None) -> PoolVersion | None:
74
+ return value if value in ("v1", "v2") else None
75
+
76
+
77
+ def _read_network(value: str | None) -> str | None:
78
+ return value if value in ("mainnet", "testnet") else None
79
+
80
+
81
+ def _read_context_config(context: Any) -> dict[str, Any]:
82
+ """Extract plugin config attached to the runtime context.
83
+
84
+ Supports a `saucerswap` attribute (or key) on the context holding either a
85
+ dict or a SaucerSwapConfig-like object.
86
+ """
87
+ if context is None:
88
+ return {}
89
+ raw = None
90
+ if isinstance(context, dict):
91
+ raw = context.get("saucerswap")
92
+ else:
93
+ raw = getattr(context, "saucerswap", None)
94
+ if raw is None:
95
+ return {}
96
+ if isinstance(raw, dict):
97
+ return dict(raw)
98
+ if isinstance(raw, SaucerSwapConfig):
99
+ return {k: v for k, v in vars(raw).items()}
100
+ return {k: v for k, v in vars(raw).items() if not k.startswith("_")}
101
+
102
+
103
+ def resolve_saucerswap_config(context: Any = None) -> SaucerSwapConfig:
104
+ ctx = _read_context_config(context)
105
+
106
+ network = _read_network(ctx.get("network")) or _read_network(
107
+ os.environ.get("SAUCERSWAP_NETWORK")
108
+ )
109
+ defaults = NETWORK_DEFAULTS.get(network) if network else None
110
+
111
+ env_aliases = _read_token_aliases(os.environ.get("SAUCERSWAP_TOKEN_ALIASES"))
112
+ env_pool_version = _read_pool_version(os.environ.get("SAUCERSWAP_DEFAULT_POOL_VERSION"))
113
+
114
+ token_aliases: dict[str, str] = {}
115
+ if defaults:
116
+ token_aliases.update(defaults.token_aliases)
117
+ token_aliases.update(env_aliases)
118
+ token_aliases.update(ctx.get("token_aliases") or {})
119
+
120
+ return SaucerSwapConfig(
121
+ base_url=ctx.get("base_url")
122
+ or os.environ.get("SAUCERSWAP_BASE_URL")
123
+ or DEFAULT_BASE_URL,
124
+ timeout_seconds=ctx.get("timeout_seconds")
125
+ or _to_number(os.environ.get("SAUCERSWAP_TIMEOUT_SECONDS"), DEFAULT_TIMEOUT_SECONDS),
126
+ retries=ctx.get("retries")
127
+ if ctx.get("retries") is not None
128
+ else _to_int(os.environ.get("SAUCERSWAP_RETRIES"), DEFAULT_RETRIES),
129
+ api_key=ctx.get("api_key") or os.environ.get("SAUCERSWAP_API_KEY"),
130
+ network=network,
131
+ router_contract_id=ctx.get("router_contract_id")
132
+ or os.environ.get("SAUCERSWAP_ROUTER_CONTRACT_ID")
133
+ or (defaults.router_contract_id if defaults else None),
134
+ router_v2_contract_id=ctx.get("router_v2_contract_id")
135
+ or os.environ.get("SAUCERSWAP_ROUTER_V2_CONTRACT_ID")
136
+ or (defaults.router_v2_contract_id if defaults else None),
137
+ wrapped_hbar_token_id=ctx.get("wrapped_hbar_token_id")
138
+ or os.environ.get("SAUCERSWAP_WRAPPED_HBAR_TOKEN_ID")
139
+ or (defaults.wrapped_hbar_token_id if defaults else None),
140
+ token_aliases=token_aliases,
141
+ default_pool_version=ctx.get("default_pool_version")
142
+ or env_pool_version
143
+ or DEFAULT_POOL_VERSION,
144
+ gas_limit=ctx.get("gas_limit")
145
+ or _to_int(os.environ.get("SAUCERSWAP_GAS_LIMIT"), DEFAULT_GAS_LIMIT),
146
+ deadline_minutes=ctx.get("deadline_minutes")
147
+ or _to_int(os.environ.get("SAUCERSWAP_DEADLINE_MINUTES"), DEFAULT_DEADLINE_MINUTES),
148
+ )
@@ -0,0 +1,42 @@
1
+ """Official SaucerSwap contract addresses per Hedera network.
2
+
3
+ Source: https://docs.saucerswap.finance/developerx/contract-deployments
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class SaucerSwapNetworkDefaults:
13
+ router_contract_id: str
14
+ router_v2_contract_id: str
15
+ wrapped_hbar_token_id: str
16
+ token_aliases: dict[str, str] = field(default_factory=dict)
17
+
18
+
19
+ SAUCERSWAP_MAINNET = SaucerSwapNetworkDefaults(
20
+ router_contract_id="0.0.3045981",
21
+ router_v2_contract_id="0.0.3949434",
22
+ wrapped_hbar_token_id="0.0.1456986",
23
+ token_aliases={
24
+ "SAUCE": "0.0.731861",
25
+ "XSAUCE": "0.0.1460200",
26
+ },
27
+ )
28
+
29
+ SAUCERSWAP_TESTNET = SaucerSwapNetworkDefaults(
30
+ router_contract_id="0.0.19264",
31
+ router_v2_contract_id="0.0.1414040",
32
+ wrapped_hbar_token_id="0.0.15058",
33
+ token_aliases={
34
+ "SAUCE": "0.0.1183558",
35
+ "XSAUCE": "0.0.1418651",
36
+ },
37
+ )
38
+
39
+ NETWORK_DEFAULTS: dict[str, SaucerSwapNetworkDefaults] = {
40
+ "mainnet": SAUCERSWAP_MAINNET,
41
+ "testnet": SAUCERSWAP_TESTNET,
42
+ }
@@ -0,0 +1,25 @@
1
+ from .farms import SAUCERSWAP_GET_FARMS_TOOL, FarmsTool
2
+ from .liquidity import (
3
+ SAUCERSWAP_ADD_LIQUIDITY_TOOL,
4
+ SAUCERSWAP_REMOVE_LIQUIDITY_TOOL,
5
+ AddLiquidityTool,
6
+ RemoveLiquidityTool,
7
+ )
8
+ from .pools import SAUCERSWAP_GET_POOLS_TOOL, PoolsTool
9
+ from .quote import SAUCERSWAP_GET_SWAP_QUOTE_TOOL, QuoteTool
10
+ from .swap import SAUCERSWAP_SWAP_TOKENS_TOOL, SwapTool
11
+
12
+ __all__ = [
13
+ "FarmsTool",
14
+ "AddLiquidityTool",
15
+ "RemoveLiquidityTool",
16
+ "PoolsTool",
17
+ "QuoteTool",
18
+ "SwapTool",
19
+ "SAUCERSWAP_GET_FARMS_TOOL",
20
+ "SAUCERSWAP_ADD_LIQUIDITY_TOOL",
21
+ "SAUCERSWAP_REMOVE_LIQUIDITY_TOOL",
22
+ "SAUCERSWAP_GET_POOLS_TOOL",
23
+ "SAUCERSWAP_GET_SWAP_QUOTE_TOOL",
24
+ "SAUCERSWAP_SWAP_TOKENS_TOOL",
25
+ ]