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.
- hak_saucerswap_plugin-0.1.0/.env.example +8 -0
- hak_saucerswap_plugin-0.1.0/.github/workflows/ci.yml +33 -0
- hak_saucerswap_plugin-0.1.0/.github/workflows/publish.yml +23 -0
- hak_saucerswap_plugin-0.1.0/.gitignore +7 -0
- hak_saucerswap_plugin-0.1.0/PKG-INFO +86 -0
- hak_saucerswap_plugin-0.1.0/README.md +70 -0
- hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/__init__.py +70 -0
- hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/api/__init__.py +4 -0
- hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/api/client.py +147 -0
- hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/api/endpoints.py +8 -0
- hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/config.py +148 -0
- hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/networks.py +42 -0
- hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/tools/__init__.py +25 -0
- hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/tools/common.py +51 -0
- hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/tools/farms.py +62 -0
- hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/tools/liquidity.py +256 -0
- hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/tools/pools.py +84 -0
- hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/tools/quote.py +115 -0
- hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/tools/swap.py +165 -0
- hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/utils/__init__.py +23 -0
- hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/utils/amm.py +30 -0
- hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/utils/quote.py +37 -0
- hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/utils/tokens.py +47 -0
- hak_saucerswap_plugin-0.1.0/hak_saucerswap_plugin/utils/units.py +39 -0
- hak_saucerswap_plugin-0.1.0/pyproject.toml +44 -0
- hak_saucerswap_plugin-0.1.0/tests/test_amm.py +24 -0
- hak_saucerswap_plugin-0.1.0/tests/test_client.py +90 -0
- hak_saucerswap_plugin-0.1.0/tests/test_config.py +78 -0
- hak_saucerswap_plugin-0.1.0/tests/test_tools.py +144 -0
- hak_saucerswap_plugin-0.1.0/tests/test_units.py +41 -0
- hak_saucerswap_plugin-0.1.0/uv.lock +4424 -0
|
@@ -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,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,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,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
|
+
]
|