Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/aqua/changelly.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,22 @@ def client(self) -> ChangellyClient:
# -- Read-only helpers ---------------------------------------------------

def list_currencies(self) -> list[str]:
return self.client.get_currencies()
"""Return Changelly's currency list filtered to our curated allowlist.

Changelly's raw list is 764 plain tickers with no metadata; most are
unrelated to AQUA's scope (USDt-Liquid ↔ USDt-on-external-chain) and
only confuse the agent. We expose just `lusdt` and the 6 external
USDt variants in `EXTERNAL_USDT_IDS`, preserving the provider's
ordering.

The override env var `CHANGELLY_ALLOW_ALL_PAIRS=1` returns the raw
response unchanged for power use / debugging.
"""
raw = self.client.get_currencies()
if _allow_all_pairs():
return raw
allowed = {LIQUID_USDT_ID, *EXTERNAL_USDT_IDS}
return [c for c in raw if c in allowed]

def fixed_quote(
self,
Expand Down
55 changes: 54 additions & 1 deletion src/aqua/sideshift.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,46 @@ def _check_pair_allowed(coin: str, network: str, side: str) -> None:
)


def _allowed_networks_for_coin(coin: str) -> set[str]:
"""Networks present alongside `coin` in `ALLOWED_PAIRS` (lowercase)."""
norm = coin.lower()
return {network for c, network in ALLOWED_PAIRS if c == norm}


def _filter_coins_to_allowlist(coins: list[dict]) -> list[dict]:
"""Trim SideShift's `/v2/coins` response to entries in `ALLOWED_PAIRS`.

For each entry whose `coin` appears in the allowlist:
- keep only the `networks` that are allowed for that coin,
- prune `tokenDetails` to those same networks (drops ~10 KB of
unrelated contract addresses),
- drop the entry entirely if no allowed networks remain.
"""
filtered: list[dict] = []
for entry in coins:
coin = entry.get("coin", "")
allowed = _allowed_networks_for_coin(coin)
if not allowed:
continue
kept_networks = [n for n in entry.get("networks", []) if n.lower() in allowed]
if not kept_networks:
continue
trimmed = dict(entry)
trimmed["networks"] = kept_networks
token_details = entry.get("tokenDetails")
if isinstance(token_details, dict):
trimmed["tokenDetails"] = {
k: v for k, v in token_details.items() if k.lower() in allowed
}
networks_with_memo = entry.get("networksWithMemo")
if isinstance(networks_with_memo, list):
trimmed["networksWithMemo"] = [
n for n in networks_with_memo if n.lower() in allowed
]
filtered.append(trimmed)
return filtered


# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -579,7 +619,20 @@ def client(self) -> SideShiftClient:
# -- Read-only helpers ---------------------------------------------------

def list_coins(self) -> list[dict]:
return self.client.get_coins()
"""Return SideShift's coin list filtered to our curated allowlist.

SideShift's full response is ~100 KB and exceeds MCP token limits.
We only ever swap USDt ↔ USDt across the chains in `ALLOWED_PAIRS`
plus mainchain BTC, so filtering here keeps the agent context tight
and prevents confusion about unsupported assets.

The override env var `SIDESHIFT_ALLOW_ALL_NETWORKS=1` returns the
raw response unchanged for power use / debugging.
"""
raw = self.client.get_coins()
if _allow_all_networks():
return raw
return _filter_coins_to_allowlist(raw)

def pair_info(
self,
Expand Down
27 changes: 16 additions & 11 deletions src/aqua/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -921,14 +921,16 @@ def pix_receive(


def changelly_list_currencies() -> dict[str, Any]:
"""List the currencies Changelly supports (Changelly's own asset id format).
"""List the Changelly currencies enabled for swaps in agentic-aqua.

Useful for discovery; the agentic-aqua surface only enables the curated
USDt-Liquid ↔ USDt-on-{ethereum,tron,bsc,solana,polygon,ton} pairs for
actual swaps, but the read-only currency list is unrestricted.
Filtered server-side to the curated USDt-Liquid ↔ USDt-on-external-chain
set: `lusdt` (Liquid) plus the 6 external USDt variants (usdt20/usdtrx/
usdtbsc/usdtsol/usdtpolygon/usdton). Other Changelly assets aren't
exposed because we don't offer swaps for them. Set
`CHANGELLY_ALLOW_ALL_PAIRS=1` to bypass the filter.

Returns:
currencies: list of asset id strings
currencies: list of asset id strings (≤ 7 entries)
count: number of entries
"""
currencies = get_changelly_manager().list_currencies()
Expand Down Expand Up @@ -1107,12 +1109,15 @@ def changelly_status(order_id: str) -> dict[str, Any]:


def sideshift_list_coins() -> dict[str, Any]:
"""List the coins and networks SideShift supports.

Use this to discover valid (coin, network) identifiers for the other
SideShift tools. Returns the SideShift response unchanged — each entry
has `coin`, `name`, `networks`, `hasMemo` (whether deposits to that
chain need a memo), `fixedOnly`/`variableOnly`, etc.
"""List the SideShift coin/network identifiers enabled for swaps.

Filtered server-side to the curated allowlist (USDt across
ethereum/tron/bsc/solana/polygon/ton/liquid, plus mainchain BTC) so the
response stays small and only surfaces pairs we actually support. Each
kept entry has `coin`, `name`, `networks` (intersected with the
allowlist), `hasMemo`, `fixedOnly`/`variableOnly`, and a pruned
`tokenDetails`. Set `SIDESHIFT_ALLOW_ALL_NETWORKS=1` to bypass the
filter.

Returns:
coins: list of {coin, name, networks, hasMemo, ...}
Expand Down
48 changes: 48 additions & 0 deletions tests/test_changelly.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,54 @@ def test_override_env_var_falsy_values_keep_enforcement(self, monkeypatch, value
_check_pair_allowed("btc", "lusdt")


class TestListCurrenciesFilter:
"""Changelly's raw `getCurrencies` returns 764 tickers. We only expose
the curated USDt set (lusdt + 6 external variants)."""

_RAW = [
"btc", "eth", "ltc", "doge", "shib", "lusdt", "usdc",
"usdt20", "usdtrx", "usdtbsc", "usdtsol", "usdtpolygon", "usdton",
"xrp", "ada",
]

def test_only_curated_usdt_remains(self):
mgr = ChangellyManager.__new__(ChangellyManager)
mgr._client = MagicMock()
mgr._client.get_currencies.return_value = list(self._RAW)
result = mgr.list_currencies()
assert set(result) == {
"lusdt", "usdt20", "usdtrx", "usdtbsc",
"usdtsol", "usdtpolygon", "usdton",
}

def test_preserves_provider_ordering(self):
mgr = ChangellyManager.__new__(ChangellyManager)
mgr._client = MagicMock()
mgr._client.get_currencies.return_value = list(self._RAW)
result = mgr.list_currencies()
# Ordering follows the raw list, not alphabetic.
assert result == [
"lusdt", "usdt20", "usdtrx", "usdtbsc",
"usdtsol", "usdtpolygon", "usdton",
]

def test_unrelated_assets_dropped(self):
mgr = ChangellyManager.__new__(ChangellyManager)
mgr._client = MagicMock()
mgr._client.get_currencies.return_value = list(self._RAW)
result = mgr.list_currencies()
for unrelated in ("btc", "eth", "ltc", "shib", "usdc", "xrp", "ada"):
assert unrelated not in result

def test_override_env_var_returns_raw(self, monkeypatch):
monkeypatch.setenv("CHANGELLY_ALLOW_ALL_PAIRS", "1")
mgr = ChangellyManager.__new__(ChangellyManager)
mgr._client = MagicMock()
raw = list(self._RAW)
mgr._client.get_currencies.return_value = raw
assert mgr.list_currencies() is raw


# ---------------------------------------------------------------------------
# settle_address validation
# ---------------------------------------------------------------------------
Expand Down
129 changes: 129 additions & 0 deletions tests/test_sideshift.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
SideShiftShift,
_check_pair_allowed,
_decimal_to_sats_8dp,
_filter_coins_to_allowlist,
recommend_shift_or_swap,
shift_is_failed,
shift_is_final,
Expand Down Expand Up @@ -246,6 +247,134 @@ def test_error_message_distinguishes_deposit_and_settle(self):
_check_pair_allowed("eth", "ethereum", side="settle")


# Sample shaped like SideShift's `/v2/coins` response (real one is ~4500 lines).
_SAMPLE_COINS = [
{
"networks": ["base", "polygon", "avax", "ethereum", "arbitrum", "optimism"],
"coin": "DAI",
"name": "Dai",
"hasMemo": False,
"tokenDetails": {
"ethereum": {"contractAddress": "0x6b17", "decimals": 18},
"polygon": {"contractAddress": "0x8f3c", "decimals": 18},
},
"networksWithMemo": [],
},
{
"networks": [
"optimism", "polygon", "solana", "stellar", "sui", "algorand",
"avax", "ethereum", "aptos", "arbitrum", "base", "bsc",
"tron", "ton", "liquid",
],
"coin": "USDT",
"name": "Tether",
"hasMemo": False,
"tokenDetails": {
"ethereum": {"contractAddress": "0xdac1", "decimals": 6},
"tron": {"contractAddress": "TR7N", "decimals": 6},
"solana": {"contractAddress": "Es9v", "decimals": 6},
"polygon": {"contractAddress": "0xc213", "decimals": 6},
"bsc": {"contractAddress": "0x5535", "decimals": 18},
"ton": {"contractAddress": "0:b113", "decimals": 6},
"avax": {"contractAddress": "0x9702", "decimals": 6},
"stellar": {"contractAddress": "USDT-G", "decimals": 7},
},
"networksWithMemo": ["stellar"],
},
{
"networks": ["bitcoin", "liquid"],
"coin": "BTC",
"name": "Bitcoin",
"hasMemo": False,
"networksWithMemo": [],
},
{
"networks": ["solana"],
"coin": "SKR",
"name": "Seeker",
"hasMemo": False,
"tokenDetails": {"solana": {"contractAddress": "SKR", "decimals": 6}},
"networksWithMemo": [],
},
]


class TestFilterCoinsToAllowlist:
"""`list_coins` returns ~100 KB raw; the filter must trim it to the
curated allowlist before the MCP layer ships it to the agent."""

def test_only_allowlisted_coins_remain(self):
filtered = _filter_coins_to_allowlist(_SAMPLE_COINS)
kept = {c["coin"] for c in filtered}
assert kept == {"USDT", "BTC"}

def test_usdt_networks_intersected_to_allowlist(self):
filtered = _filter_coins_to_allowlist(_SAMPLE_COINS)
usdt = next(c for c in filtered if c["coin"] == "USDT")
assert set(usdt["networks"]) == {
"ethereum", "tron", "bsc", "solana", "polygon", "ton", "liquid",
}
# Stellar / aptos / sui / etc are dropped
assert "stellar" not in usdt["networks"]
assert "aptos" not in usdt["networks"]

def test_btc_only_keeps_mainchain_network(self):
filtered = _filter_coins_to_allowlist(_SAMPLE_COINS)
btc = next(c for c in filtered if c["coin"] == "BTC")
# (btc, liquid) is intentionally NOT on the allowlist — use SideSwap.
assert btc["networks"] == ["bitcoin"]

def test_token_details_pruned_to_allowed_networks(self):
filtered = _filter_coins_to_allowlist(_SAMPLE_COINS)
usdt = next(c for c in filtered if c["coin"] == "USDT")
details = usdt["tokenDetails"]
assert set(details) == {
"ethereum", "tron", "solana", "polygon", "bsc", "ton",
}
# Stellar token details dropped along with the network.
assert "stellar" not in details
assert "avax" not in details

def test_networks_with_memo_pruned(self):
filtered = _filter_coins_to_allowlist(_SAMPLE_COINS)
usdt = next(c for c in filtered if c["coin"] == "USDT")
# The sample's only memo-network is stellar, which is not allowed.
assert usdt["networksWithMemo"] == []

def test_coin_dropped_when_no_networks_remain(self):
# USDC isn't in `ALLOWED_PAIRS` at all — even if SideShift returns
# it, the filter must drop it.
raw = [{
"networks": ["ethereum", "tron"],
"coin": "USDC",
"name": "USD Coin",
}]
assert _filter_coins_to_allowlist(raw) == []

def test_filter_does_not_mutate_input(self):
import copy
original = copy.deepcopy(_SAMPLE_COINS)
_filter_coins_to_allowlist(_SAMPLE_COINS)
assert _SAMPLE_COINS == original

def test_manager_uses_filter(self):
# Ensure the SideShiftManager.list_coins actually filters before
# returning — not just the helper.
mgr = SideShiftManager.__new__(SideShiftManager)
mgr._client = MagicMock()
mgr._client.get_coins.return_value = _SAMPLE_COINS
result = mgr.list_coins()
assert {c["coin"] for c in result} == {"USDT", "BTC"}

def test_manager_override_returns_raw(self, monkeypatch):
monkeypatch.setenv("SIDESHIFT_ALLOW_ALL_NETWORKS", "1")
mgr = SideShiftManager.__new__(SideShiftManager)
mgr._client = MagicMock()
mgr._client.get_coins.return_value = _SAMPLE_COINS
result = mgr.list_coins()
assert result is _SAMPLE_COINS


# ---------------------------------------------------------------------------
# SideShiftShift dataclass + storage round-trip
# ---------------------------------------------------------------------------
Expand Down
Loading