Open
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
6 changes: 6 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,12 @@ export AWS_REGION=''
export AWS_ACCESS_KEY_ID=''
export AWS_SECRET_ACCESS_KEY=''
export SOLANA_RPC_URL=''
export ETH_RPC_URL='' # Ethereum RPC (Alchemy/Infura recommended)

# The Graph subgraph URLs (optional — defaults are provided)
# Production usage requires a Graph API key: https://thegraph.com/studio/
export UNISWAP_V3_SUBGRAPH_URL=''
export AAVE_V3_SUBGRAPH_URL=''
export OPENROUTER_API_KEY=''
export GEMINI_API_KEY=''
export DD_API_KEY=''
Expand Down
30 changes: 29 additions & 1 deletion agent/tools.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,7 +44,7 @@ async def retrieve_solana_pools(

# Create a query to filter pools
query = PoolQuery(
chain=Chain.SOLANA, # Currently only supporting Solana
chain=Chain.SOLANA,
tokens=tokens or [],
user_tokens=user_tokens,
)
Expand All@@ -56,9 +56,37 @@ async def retrieve_solana_pools(
return pools


@tool
@track_tool_usage("retrieve_ethereum_pools")
async def retrieve_ethereum_pools(
tokens: List[str] = None,
config: RunnableConfig = None,
) -> List[Pool]:
"""
Retrieves Ethereum pools matching the specified criteria that the user can invest in.
Includes Uniswap V3 AMM pools and Aave V3 lending pools.
"""
configurable = config["configurable"]
user_tokens: List[WalletTokenHolding] = configurable["tokens"]
protocol_registry: ProtocolRegistry = configurable["protocol_registry"]

query = PoolQuery(
chain=Chain.ETHEREUM,
tokens=tokens or [],
user_tokens=user_tokens,
)

pools = await protocol_registry.get_pools(query)
if len(pools) == 0:
return "No Ethereum pools found."

return pools


def create_investor_agent_toolkit() -> List[BaseTool]:
return [
retrieve_solana_pools,
retrieve_ethereum_pools,
]


Expand Down
4 changes: 4 additions & 0 deletions main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,12 +19,16 @@
from onchain.pools.solana.orca_protocol import OrcaProtocol
from onchain.pools.solana.save_protocol import SaveProtocol
from onchain.pools.solana.kamino_protocol import KaminoProtocol
from onchain.pools.ethereum.uniswap_v3_protocol import UniswapV3Protocol
from onchain.pools.ethereum.aave_protocol import AaveProtocol

# Define protocols enabled
protocols = [
OrcaProtocol.PROTOCOL_NAME,
SaveProtocol.PROTOCOL_NAME,
KaminoProtocol.PROTOCOL_NAME,
UniswapV3Protocol.PROTOCOL_NAME,
AaveProtocol.PROTOCOL_NAME,
]

# Create the FastAPI app
Expand Down
Empty file.
162 changes: 162 additions & 0 deletions onchain/pools/ethereum/aave_protocol.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
"""Aave V3 protocol implementation for Ethereum lending pool discovery."""

from typing import List, Optional, Dict, Any
import logging
import os

import aiohttp

from api.api_types import Pool, Token, Chain, PoolType
from onchain.pools.protocol import Protocol
from onchain.tokens.metadata import TokenMetadataRepo

logger = logging.getLogger(__name__)

# Aave V3 Ethereum subgraph — configurable via env var.
AAVE_V3_SUBGRAPH_URL = os.environ.get(
"AAVE_V3_SUBGRAPH_URL",
"https://gateway.thegraph.com/api/subgraphs/id/Cd2gEDVeqnjBn1hSeqFMitw8Q1iiyV9FYUZkLNRcL87g",
)

# Stablecoins for classification
STABLECOIN_SYMBOLS = {"USDC", "USDT", "DAI", "BUSD", "TUSD", "FRAX", "LUSD", "GUSD", "sUSD"}


class AaveProtocol(Protocol):
"""
Aave V3 protocol — fetches Ethereum lending reserves.

Returns each reserve as a lending Pool with supply APR.
Uses The Graph subgraph for on-chain data.
"""

PROTOCOL_NAME = "aave-v3"

_session: Optional[aiohttp.ClientSession] = None

@property
def name(self) -> str:
return self.PROTOCOL_NAME

@property
async def session(self) -> aiohttp.ClientSession:
if self._session is None:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30)
)
return self._session

async def close(self):
if self._session:
await self._session.close()
self._session = None

async def get_pools(self, token_metadata_repo: TokenMetadataRepo) -> List[Pool]:
"""Fetch Aave V3 reserves from the subgraph."""
query = """
{
reserves(
first: 50,
where: { isActive: true }
) {
id
symbol
name
decimals
underlyingAsset
liquidityRate
totalATokenSupply
totalCurrentVariableDebt
availableLiquidity
price {
priceInEth
}
}
}
"""

try:
session = await self.session
async with session.post(
AAVE_V3_SUBGRAPH_URL,
json={"query": query},
) as response:
if response.status in (401, 403):
logger.error(
f"Aave V3 subgraph auth failed ({response.status}). "
f"Set AAVE_V3_SUBGRAPH_URL with a valid Graph API key."
)
return []
if response.status != 200:
logger.error(f"Aave V3 subgraph returned {response.status}")
return []
data = await response.json()
except Exception as e:
logger.error(f"Error fetching Aave V3 reserves: {e}")
return []

reserves = data.get("data", {}).get("reserves", [])

# Resolve ETH/USD price for TVL conversion.
# Use the WETH token metadata from DexScreener (already cached).
eth_usd_price = 0.0
eth_meta = await token_metadata_repo.get_token_metadata(
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "ethereum"
)
if eth_meta and eth_meta.price:
eth_usd_price = float(eth_meta.price)

return self._convert_to_pools(reserves, eth_usd_price)

def _convert_to_pools(
self, reserves: List[Dict[str, Any]], eth_usd_price: float
) -> List[Pool]:
result: List[Pool] = []

for reserve in reserves:
symbol = reserve.get("symbol", "")
name = reserve.get("name", "")
address = reserve.get("underlyingAsset", "")
decimals = int(reserve.get("decimals", 18))

tokens = [
Token(address=address, name=name, symbol=symbol),
]

# Aave liquidityRate is in RAY units (1e27), convert to APR percentage
liquidity_rate = int(reserve.get("liquidityRate", 0))
supply_apr = (liquidity_rate / 1e27) * 100

# Calculate TVL in USD:
# totalATokenSupply is in token-native units (needs /10^decimals)
# priceInEth is the token price denominated in ETH (wei-scaled, 1e18)
# Multiply by eth_usd_price to get USD
total_supply_raw = int(reserve.get("totalATokenSupply", 0))
total_supply = total_supply_raw / (10**decimals)

price_in_eth_raw = reserve.get("price", {}).get("priceInEth", "0")
price_in_eth = int(price_in_eth_raw) / 1e18

if eth_usd_price > 0 and price_in_eth > 0:
tvl_usd = str(round(total_supply * price_in_eth * eth_usd_price, 2))
else:
tvl_usd = str(round(total_supply, 2))

is_stablecoin = symbol.upper() in STABLECOIN_SYMBOLS

pool = Pool(
id=reserve.get("id", ""),
chain=Chain.ETHEREUM,
protocol="Aave V3",
tokens=tokens,
type=PoolType.LENDING,
TVL=tvl_usd,
APRLastDay=round(supply_apr, 2),
APRLastWeek=round(supply_apr, 2),
APRLastMonth=round(supply_apr, 2),
isStableCoin=is_stablecoin,
impermanentLossRisk=False, # Lending has no IL
)
result.append(pool)

return result
126 changes: 126 additions & 0 deletions onchain/pools/ethereum/test_ethereum_protocols.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
import unittest
import os
import boto3
import asyncio

from onchain.pools.ethereum.uniswap_v3_protocol import UniswapV3Protocol
from onchain.pools.ethereum.aave_protocol import AaveProtocol
from onchain.portfolio.ethereum_portfolio import EthereumPortfolioFetcher
from onchain.tokens.metadata import TokenMetadataRepo
from api.api_types import Chain, PoolType
import dotenv

dotenv.load_dotenv()


class TestEthereumProtocols(unittest.TestCase):
def setUp(self):
dynamodb = boto3.resource(
"dynamodb",
region_name=os.environ.get("AWS_REGION"),
aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
)
tokens_table = dynamodb.Table("token_metadata_v2")
self.token_metadata_repo = TokenMetadataRepo(tokens_table)

def test_uniswap_v3(self):
uniswap = UniswapV3Protocol()
pools = asyncio.run(uniswap.get_pools(self.token_metadata_repo))

self.assertGreater(len(pools), 5)
print(f"Uniswap V3 pools: {len(pools)}")

# Verify pool structure
for pool in pools[:3]:
self.assertEqual(pool.chain, Chain.ETHEREUM)
self.assertEqual(pool.type, PoolType.AMM)
self.assertIn("Uniswap V3", pool.protocol)
self.assertEqual(len(pool.tokens), 2)
self.assertIsNotNone(pool.TVL)
self.assertGreaterEqual(pool.APRLastDay, 0)

def test_uniswap_v3_stablecoin_detection(self):
uniswap = UniswapV3Protocol()
pools = asyncio.run(uniswap.get_pools(self.token_metadata_repo))

# Non-stablecoin pools should have IL risk
for pool in pools:
if not pool.isStableCoin:
self.assertTrue(pool.impermanentLossRisk)
else:
self.assertFalse(pool.impermanentLossRisk)

def test_aave_v3(self):
aave = AaveProtocol()
pools = asyncio.run(aave.get_pools(self.token_metadata_repo))

self.assertGreater(len(pools), 5)
print(f"Aave V3 reserves: {len(pools)}")

# Verify pool structure
for pool in pools[:3]:
self.assertEqual(pool.chain, Chain.ETHEREUM)
self.assertEqual(pool.type, PoolType.LENDING)
self.assertEqual(pool.protocol, "Aave V3")
self.assertEqual(len(pool.tokens), 1)
self.assertFalse(pool.impermanentLossRisk)
self.assertGreaterEqual(pool.APRLastDay, 0)

def test_aave_v3_tvl_is_usd(self):
"""TVL should be in USD (not raw token units)."""
aave = AaveProtocol()
pools = asyncio.run(aave.get_pools(self.token_metadata_repo))

for pool in pools:
tvl = float(pool.TVL)
# Any active Aave reserve should have meaningful TVL in USD
if tvl > 0:
self.assertGreater(tvl, 100, f"{pool.tokens[0].symbol} TVL too low: {tvl}")


class TestEthereumPortfolio(unittest.TestCase):
def setUp(self):
dynamodb = boto3.resource(
"dynamodb",
region_name=os.environ.get("AWS_REGION"),
aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
)
tokens_table = dynamodb.Table("token_metadata_v2")
self.token_metadata_repo = TokenMetadataRepo(tokens_table)
self.portfolio_fetcher = EthereumPortfolioFetcher(self.token_metadata_repo)

def tearDown(self):
asyncio.run(self.portfolio_fetcher.close())

def test_get_portfolio(self):
# Vitalik's public wallet
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio(
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
)
)
print(portfolio)

self.assertGreater(len(portfolio.holdings), 0)
# Vitalik holds ETH
eth_holdings = [h for h in portfolio.holdings if h.symbol == "ETH"]
self.assertEqual(len(eth_holdings), 1)
self.assertGreater(eth_holdings[0].amount, 0)

def test_empty_wallet(self):
# Zero address should have no holdings
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio(
"0x0000000000000000000000000000000000000001"
)
)
self.assertEqual(len(portfolio.holdings), 0)

def test_invalid_address(self):
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio("not-a-valid-address")
)
self.assertEqual(len(portfolio.holdings), 0)
self.assertEqual(portfolio.total_value_usd, 0)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Open
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
6 changes: 6 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,12 @@ export AWS_REGION=''
export AWS_ACCESS_KEY_ID=''
export AWS_SECRET_ACCESS_KEY=''
export SOLANA_RPC_URL=''
export ETH_RPC_URL='' # Ethereum RPC (Alchemy/Infura recommended)

# The Graph subgraph URLs (optional — defaults are provided)
# Production usage requires a Graph API key: https://thegraph.com/studio/
export UNISWAP_V3_SUBGRAPH_URL=''
export AAVE_V3_SUBGRAPH_URL=''
export OPENROUTER_API_KEY=''
export GEMINI_API_KEY=''
export DD_API_KEY=''
Expand Down
30 changes: 29 additions & 1 deletion agent/tools.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,7 +44,7 @@ async def retrieve_solana_pools(

# Create a query to filter pools
query = PoolQuery(
chain=Chain.SOLANA, # Currently only supporting Solana
chain=Chain.SOLANA,
tokens=tokens or [],
user_tokens=user_tokens,
)
Expand All@@ -56,9 +56,37 @@ async def retrieve_solana_pools(
return pools


@tool
@track_tool_usage("retrieve_ethereum_pools")
async def retrieve_ethereum_pools(
tokens: List[str] = None,
config: RunnableConfig = None,
) -> List[Pool]:
"""
Retrieves Ethereum pools matching the specified criteria that the user can invest in.
Includes Uniswap V3 AMM pools and Aave V3 lending pools.
"""
configurable = config["configurable"]
user_tokens: List[WalletTokenHolding] = configurable["tokens"]
protocol_registry: ProtocolRegistry = configurable["protocol_registry"]

query = PoolQuery(
chain=Chain.ETHEREUM,
tokens=tokens or [],
user_tokens=user_tokens,
)

pools = await protocol_registry.get_pools(query)
if len(pools) == 0:
return "No Ethereum pools found."

return pools


def create_investor_agent_toolkit() -> List[BaseTool]:
return [
retrieve_solana_pools,
retrieve_ethereum_pools,
]


Expand Down
4 changes: 4 additions & 0 deletions main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,12 +19,16 @@
from onchain.pools.solana.orca_protocol import OrcaProtocol
from onchain.pools.solana.save_protocol import SaveProtocol
from onchain.pools.solana.kamino_protocol import KaminoProtocol
from onchain.pools.ethereum.uniswap_v3_protocol import UniswapV3Protocol
from onchain.pools.ethereum.aave_protocol import AaveProtocol

# Define protocols enabled
protocols = [
OrcaProtocol.PROTOCOL_NAME,
SaveProtocol.PROTOCOL_NAME,
KaminoProtocol.PROTOCOL_NAME,
UniswapV3Protocol.PROTOCOL_NAME,
AaveProtocol.PROTOCOL_NAME,
]

# Create the FastAPI app
Expand Down
Empty file.
162 changes: 162 additions & 0 deletions onchain/pools/ethereum/aave_protocol.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
"""Aave V3 protocol implementation for Ethereum lending pool discovery."""

from typing import List, Optional, Dict, Any
import logging
import os

import aiohttp

from api.api_types import Pool, Token, Chain, PoolType
from onchain.pools.protocol import Protocol
from onchain.tokens.metadata import TokenMetadataRepo

logger = logging.getLogger(__name__)

# Aave V3 Ethereum subgraph — configurable via env var.
AAVE_V3_SUBGRAPH_URL = os.environ.get(
"AAVE_V3_SUBGRAPH_URL",
"https://gateway.thegraph.com/api/subgraphs/id/Cd2gEDVeqnjBn1hSeqFMitw8Q1iiyV9FYUZkLNRcL87g",
)

# Stablecoins for classification
STABLECOIN_SYMBOLS = {"USDC", "USDT", "DAI", "BUSD", "TUSD", "FRAX", "LUSD", "GUSD", "sUSD"}


class AaveProtocol(Protocol):
"""
Aave V3 protocol — fetches Ethereum lending reserves.

Returns each reserve as a lending Pool with supply APR.
Uses The Graph subgraph for on-chain data.
"""

PROTOCOL_NAME = "aave-v3"

_session: Optional[aiohttp.ClientSession] = None

@property
def name(self) -> str:
return self.PROTOCOL_NAME

@property
async def session(self) -> aiohttp.ClientSession:
if self._session is None:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30)
)
return self._session

async def close(self):
if self._session:
await self._session.close()
self._session = None

async def get_pools(self, token_metadata_repo: TokenMetadataRepo) -> List[Pool]:
"""Fetch Aave V3 reserves from the subgraph."""
query = """
{
reserves(
first: 50,
where: { isActive: true }
) {
id
symbol
name
decimals
underlyingAsset
liquidityRate
totalATokenSupply
totalCurrentVariableDebt
availableLiquidity
price {
priceInEth
}
}
}
"""

try:
session = await self.session
async with session.post(
AAVE_V3_SUBGRAPH_URL,
json={"query": query},
) as response:
if response.status in (401, 403):
logger.error(
f"Aave V3 subgraph auth failed ({response.status}). "
f"Set AAVE_V3_SUBGRAPH_URL with a valid Graph API key."
)
return []
if response.status != 200:
logger.error(f"Aave V3 subgraph returned {response.status}")
return []
data = await response.json()
except Exception as e:
logger.error(f"Error fetching Aave V3 reserves: {e}")
return []

reserves = data.get("data", {}).get("reserves", [])

# Resolve ETH/USD price for TVL conversion.
# Use the WETH token metadata from DexScreener (already cached).
eth_usd_price = 0.0
eth_meta = await token_metadata_repo.get_token_metadata(
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "ethereum"
)
if eth_meta and eth_meta.price:
eth_usd_price = float(eth_meta.price)

return self._convert_to_pools(reserves, eth_usd_price)

def _convert_to_pools(
self, reserves: List[Dict[str, Any]], eth_usd_price: float
) -> List[Pool]:
result: List[Pool] = []

for reserve in reserves:
symbol = reserve.get("symbol", "")
name = reserve.get("name", "")
address = reserve.get("underlyingAsset", "")
decimals = int(reserve.get("decimals", 18))

tokens = [
Token(address=address, name=name, symbol=symbol),
]

# Aave liquidityRate is in RAY units (1e27), convert to APR percentage
liquidity_rate = int(reserve.get("liquidityRate", 0))
supply_apr = (liquidity_rate / 1e27) * 100

# Calculate TVL in USD:
# totalATokenSupply is in token-native units (needs /10^decimals)
# priceInEth is the token price denominated in ETH (wei-scaled, 1e18)
# Multiply by eth_usd_price to get USD
total_supply_raw = int(reserve.get("totalATokenSupply", 0))
total_supply = total_supply_raw / (10**decimals)

price_in_eth_raw = reserve.get("price", {}).get("priceInEth", "0")
price_in_eth = int(price_in_eth_raw) / 1e18

if eth_usd_price > 0 and price_in_eth > 0:
tvl_usd = str(round(total_supply * price_in_eth * eth_usd_price, 2))
else:
tvl_usd = str(round(total_supply, 2))

is_stablecoin = symbol.upper() in STABLECOIN_SYMBOLS

pool = Pool(
id=reserve.get("id", ""),
chain=Chain.ETHEREUM,
protocol="Aave V3",
tokens=tokens,
type=PoolType.LENDING,
TVL=tvl_usd,
APRLastDay=round(supply_apr, 2),
APRLastWeek=round(supply_apr, 2),
APRLastMonth=round(supply_apr, 2),
isStableCoin=is_stablecoin,
impermanentLossRisk=False, # Lending has no IL
)
result.append(pool)

return result
126 changes: 126 additions & 0 deletions onchain/pools/ethereum/test_ethereum_protocols.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
import unittest
import os
import boto3
import asyncio

from onchain.pools.ethereum.uniswap_v3_protocol import UniswapV3Protocol
from onchain.pools.ethereum.aave_protocol import AaveProtocol
from onchain.portfolio.ethereum_portfolio import EthereumPortfolioFetcher
from onchain.tokens.metadata import TokenMetadataRepo
from api.api_types import Chain, PoolType
import dotenv

dotenv.load_dotenv()


class TestEthereumProtocols(unittest.TestCase):
def setUp(self):
dynamodb = boto3.resource(
"dynamodb",
region_name=os.environ.get("AWS_REGION"),
aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
)
tokens_table = dynamodb.Table("token_metadata_v2")
self.token_metadata_repo = TokenMetadataRepo(tokens_table)

def test_uniswap_v3(self):
uniswap = UniswapV3Protocol()
pools = asyncio.run(uniswap.get_pools(self.token_metadata_repo))

self.assertGreater(len(pools), 5)
print(f"Uniswap V3 pools: {len(pools)}")

# Verify pool structure
for pool in pools[:3]:
self.assertEqual(pool.chain, Chain.ETHEREUM)
self.assertEqual(pool.type, PoolType.AMM)
self.assertIn("Uniswap V3", pool.protocol)
self.assertEqual(len(pool.tokens), 2)
self.assertIsNotNone(pool.TVL)
self.assertGreaterEqual(pool.APRLastDay, 0)

def test_uniswap_v3_stablecoin_detection(self):
uniswap = UniswapV3Protocol()
pools = asyncio.run(uniswap.get_pools(self.token_metadata_repo))

# Non-stablecoin pools should have IL risk
for pool in pools:
if not pool.isStableCoin:
self.assertTrue(pool.impermanentLossRisk)
else:
self.assertFalse(pool.impermanentLossRisk)

def test_aave_v3(self):
aave = AaveProtocol()
pools = asyncio.run(aave.get_pools(self.token_metadata_repo))

self.assertGreater(len(pools), 5)
print(f"Aave V3 reserves: {len(pools)}")

# Verify pool structure
for pool in pools[:3]:
self.assertEqual(pool.chain, Chain.ETHEREUM)
self.assertEqual(pool.type, PoolType.LENDING)
self.assertEqual(pool.protocol, "Aave V3")
self.assertEqual(len(pool.tokens), 1)
self.assertFalse(pool.impermanentLossRisk)
self.assertGreaterEqual(pool.APRLastDay, 0)

def test_aave_v3_tvl_is_usd(self):
"""TVL should be in USD (not raw token units)."""
aave = AaveProtocol()
pools = asyncio.run(aave.get_pools(self.token_metadata_repo))

for pool in pools:
tvl = float(pool.TVL)
# Any active Aave reserve should have meaningful TVL in USD
if tvl > 0:
self.assertGreater(tvl, 100, f"{pool.tokens[0].symbol} TVL too low: {tvl}")


class TestEthereumPortfolio(unittest.TestCase):
def setUp(self):
dynamodb = boto3.resource(
"dynamodb",
region_name=os.environ.get("AWS_REGION"),
aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
)
tokens_table = dynamodb.Table("token_metadata_v2")
self.token_metadata_repo = TokenMetadataRepo(tokens_table)
self.portfolio_fetcher = EthereumPortfolioFetcher(self.token_metadata_repo)

def tearDown(self):
asyncio.run(self.portfolio_fetcher.close())

def test_get_portfolio(self):
# Vitalik's public wallet
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio(
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
)
)
print(portfolio)

self.assertGreater(len(portfolio.holdings), 0)
# Vitalik holds ETH
eth_holdings = [h for h in portfolio.holdings if h.symbol == "ETH"]
self.assertEqual(len(eth_holdings), 1)
self.assertGreater(eth_holdings[0].amount, 0)

def test_empty_wallet(self):
# Zero address should have no holdings
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio(
"0x0000000000000000000000000000000000000001"
)
)
self.assertEqual(len(portfolio.holdings), 0)

def test_invalid_address(self):
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio("not-a-valid-address")
)
self.assertEqual(len(portfolio.holdings), 0)
self.assertEqual(portfolio.total_value_usd, 0)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
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
6 changes: 6 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,12 @@ export AWS_REGION=''
export AWS_ACCESS_KEY_ID=''
export AWS_SECRET_ACCESS_KEY=''
export SOLANA_RPC_URL=''
export ETH_RPC_URL='' # Ethereum RPC (Alchemy/Infura recommended)

# The Graph subgraph URLs (optional — defaults are provided)
# Production usage requires a Graph API key: https://thegraph.com/studio/
export UNISWAP_V3_SUBGRAPH_URL=''
export AAVE_V3_SUBGRAPH_URL=''
export OPENROUTER_API_KEY=''
export GEMINI_API_KEY=''
export DD_API_KEY=''
Expand Down
30 changes: 29 additions & 1 deletion agent/tools.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,7 +44,7 @@ async def retrieve_solana_pools(

# Create a query to filter pools
query = PoolQuery(
chain=Chain.SOLANA, # Currently only supporting Solana
chain=Chain.SOLANA,
tokens=tokens or [],
user_tokens=user_tokens,
)
Expand All@@ -56,9 +56,37 @@ async def retrieve_solana_pools(
return pools


@tool
@track_tool_usage("retrieve_ethereum_pools")
async def retrieve_ethereum_pools(
tokens: List[str] = None,
config: RunnableConfig = None,
) -> List[Pool]:
"""
Retrieves Ethereum pools matching the specified criteria that the user can invest in.
Includes Uniswap V3 AMM pools and Aave V3 lending pools.
"""
configurable = config["configurable"]
user_tokens: List[WalletTokenHolding] = configurable["tokens"]
protocol_registry: ProtocolRegistry = configurable["protocol_registry"]

query = PoolQuery(
chain=Chain.ETHEREUM,
tokens=tokens or [],
user_tokens=user_tokens,
)

pools = await protocol_registry.get_pools(query)
if len(pools) == 0:
return "No Ethereum pools found."

return pools


def create_investor_agent_toolkit() -> List[BaseTool]:
return [
retrieve_solana_pools,
retrieve_ethereum_pools,
]


Expand Down
4 changes: 4 additions & 0 deletions main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,12 +19,16 @@
from onchain.pools.solana.orca_protocol import OrcaProtocol
from onchain.pools.solana.save_protocol import SaveProtocol
from onchain.pools.solana.kamino_protocol import KaminoProtocol
from onchain.pools.ethereum.uniswap_v3_protocol import UniswapV3Protocol
from onchain.pools.ethereum.aave_protocol import AaveProtocol

# Define protocols enabled
protocols = [
OrcaProtocol.PROTOCOL_NAME,
SaveProtocol.PROTOCOL_NAME,
KaminoProtocol.PROTOCOL_NAME,
UniswapV3Protocol.PROTOCOL_NAME,
AaveProtocol.PROTOCOL_NAME,
]

# Create the FastAPI app
Expand Down
Empty file.
162 changes: 162 additions & 0 deletions onchain/pools/ethereum/aave_protocol.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
"""Aave V3 protocol implementation for Ethereum lending pool discovery."""

from typing import List, Optional, Dict, Any
import logging
import os

import aiohttp

from api.api_types import Pool, Token, Chain, PoolType
from onchain.pools.protocol import Protocol
from onchain.tokens.metadata import TokenMetadataRepo

logger = logging.getLogger(__name__)

# Aave V3 Ethereum subgraph — configurable via env var.
AAVE_V3_SUBGRAPH_URL = os.environ.get(
"AAVE_V3_SUBGRAPH_URL",
"https://gateway.thegraph.com/api/subgraphs/id/Cd2gEDVeqnjBn1hSeqFMitw8Q1iiyV9FYUZkLNRcL87g",
)

# Stablecoins for classification
STABLECOIN_SYMBOLS = {"USDC", "USDT", "DAI", "BUSD", "TUSD", "FRAX", "LUSD", "GUSD", "sUSD"}


class AaveProtocol(Protocol):
"""
Aave V3 protocol — fetches Ethereum lending reserves.

Returns each reserve as a lending Pool with supply APR.
Uses The Graph subgraph for on-chain data.
"""

PROTOCOL_NAME = "aave-v3"

_session: Optional[aiohttp.ClientSession] = None

@property
def name(self) -> str:
return self.PROTOCOL_NAME

@property
async def session(self) -> aiohttp.ClientSession:
if self._session is None:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30)
)
return self._session

async def close(self):
if self._session:
await self._session.close()
self._session = None

async def get_pools(self, token_metadata_repo: TokenMetadataRepo) -> List[Pool]:
"""Fetch Aave V3 reserves from the subgraph."""
query = """
{
reserves(
first: 50,
where: { isActive: true }
) {
id
symbol
name
decimals
underlyingAsset
liquidityRate
totalATokenSupply
totalCurrentVariableDebt
availableLiquidity
price {
priceInEth
}
}
}
"""

try:
session = await self.session
async with session.post(
AAVE_V3_SUBGRAPH_URL,
json={"query": query},
) as response:
if response.status in (401, 403):
logger.error(
f"Aave V3 subgraph auth failed ({response.status}). "
f"Set AAVE_V3_SUBGRAPH_URL with a valid Graph API key."
)
return []
if response.status != 200:
logger.error(f"Aave V3 subgraph returned {response.status}")
return []
data = await response.json()
except Exception as e:
logger.error(f"Error fetching Aave V3 reserves: {e}")
return []

reserves = data.get("data", {}).get("reserves", [])

# Resolve ETH/USD price for TVL conversion.
# Use the WETH token metadata from DexScreener (already cached).
eth_usd_price = 0.0
eth_meta = await token_metadata_repo.get_token_metadata(
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "ethereum"
)
if eth_meta and eth_meta.price:
eth_usd_price = float(eth_meta.price)

return self._convert_to_pools(reserves, eth_usd_price)

def _convert_to_pools(
self, reserves: List[Dict[str, Any]], eth_usd_price: float
) -> List[Pool]:
result: List[Pool] = []

for reserve in reserves:
symbol = reserve.get("symbol", "")
name = reserve.get("name", "")
address = reserve.get("underlyingAsset", "")
decimals = int(reserve.get("decimals", 18))

tokens = [
Token(address=address, name=name, symbol=symbol),
]

# Aave liquidityRate is in RAY units (1e27), convert to APR percentage
liquidity_rate = int(reserve.get("liquidityRate", 0))
supply_apr = (liquidity_rate / 1e27) * 100

# Calculate TVL in USD:
# totalATokenSupply is in token-native units (needs /10^decimals)
# priceInEth is the token price denominated in ETH (wei-scaled, 1e18)
# Multiply by eth_usd_price to get USD
total_supply_raw = int(reserve.get("totalATokenSupply", 0))
total_supply = total_supply_raw / (10**decimals)

price_in_eth_raw = reserve.get("price", {}).get("priceInEth", "0")
price_in_eth = int(price_in_eth_raw) / 1e18

if eth_usd_price > 0 and price_in_eth > 0:
tvl_usd = str(round(total_supply * price_in_eth * eth_usd_price, 2))
else:
tvl_usd = str(round(total_supply, 2))

is_stablecoin = symbol.upper() in STABLECOIN_SYMBOLS

pool = Pool(
id=reserve.get("id", ""),
chain=Chain.ETHEREUM,
protocol="Aave V3",
tokens=tokens,
type=PoolType.LENDING,
TVL=tvl_usd,
APRLastDay=round(supply_apr, 2),
APRLastWeek=round(supply_apr, 2),
APRLastMonth=round(supply_apr, 2),
isStableCoin=is_stablecoin,
impermanentLossRisk=False, # Lending has no IL
)
result.append(pool)

return result
126 changes: 126 additions & 0 deletions onchain/pools/ethereum/test_ethereum_protocols.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
import unittest
import os
import boto3
import asyncio

from onchain.pools.ethereum.uniswap_v3_protocol import UniswapV3Protocol
from onchain.pools.ethereum.aave_protocol import AaveProtocol
from onchain.portfolio.ethereum_portfolio import EthereumPortfolioFetcher
from onchain.tokens.metadata import TokenMetadataRepo
from api.api_types import Chain, PoolType
import dotenv

dotenv.load_dotenv()


class TestEthereumProtocols(unittest.TestCase):
def setUp(self):
dynamodb = boto3.resource(
"dynamodb",
region_name=os.environ.get("AWS_REGION"),
aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
)
tokens_table = dynamodb.Table("token_metadata_v2")
self.token_metadata_repo = TokenMetadataRepo(tokens_table)

def test_uniswap_v3(self):
uniswap = UniswapV3Protocol()
pools = asyncio.run(uniswap.get_pools(self.token_metadata_repo))

self.assertGreater(len(pools), 5)
print(f"Uniswap V3 pools: {len(pools)}")

# Verify pool structure
for pool in pools[:3]:
self.assertEqual(pool.chain, Chain.ETHEREUM)
self.assertEqual(pool.type, PoolType.AMM)
self.assertIn("Uniswap V3", pool.protocol)
self.assertEqual(len(pool.tokens), 2)
self.assertIsNotNone(pool.TVL)
self.assertGreaterEqual(pool.APRLastDay, 0)

def test_uniswap_v3_stablecoin_detection(self):
uniswap = UniswapV3Protocol()
pools = asyncio.run(uniswap.get_pools(self.token_metadata_repo))

# Non-stablecoin pools should have IL risk
for pool in pools:
if not pool.isStableCoin:
self.assertTrue(pool.impermanentLossRisk)
else:
self.assertFalse(pool.impermanentLossRisk)

def test_aave_v3(self):
aave = AaveProtocol()
pools = asyncio.run(aave.get_pools(self.token_metadata_repo))

self.assertGreater(len(pools), 5)
print(f"Aave V3 reserves: {len(pools)}")

# Verify pool structure
for pool in pools[:3]:
self.assertEqual(pool.chain, Chain.ETHEREUM)
self.assertEqual(pool.type, PoolType.LENDING)
self.assertEqual(pool.protocol, "Aave V3")
self.assertEqual(len(pool.tokens), 1)
self.assertFalse(pool.impermanentLossRisk)
self.assertGreaterEqual(pool.APRLastDay, 0)

def test_aave_v3_tvl_is_usd(self):
"""TVL should be in USD (not raw token units)."""
aave = AaveProtocol()
pools = asyncio.run(aave.get_pools(self.token_metadata_repo))

for pool in pools:
tvl = float(pool.TVL)
# Any active Aave reserve should have meaningful TVL in USD
if tvl > 0:
self.assertGreater(tvl, 100, f"{pool.tokens[0].symbol} TVL too low: {tvl}")


class TestEthereumPortfolio(unittest.TestCase):
def setUp(self):
dynamodb = boto3.resource(
"dynamodb",
region_name=os.environ.get("AWS_REGION"),
aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
)
tokens_table = dynamodb.Table("token_metadata_v2")
self.token_metadata_repo = TokenMetadataRepo(tokens_table)
self.portfolio_fetcher = EthereumPortfolioFetcher(self.token_metadata_repo)

def tearDown(self):
asyncio.run(self.portfolio_fetcher.close())

def test_get_portfolio(self):
# Vitalik's public wallet
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio(
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
)
)
print(portfolio)

self.assertGreater(len(portfolio.holdings), 0)
# Vitalik holds ETH
eth_holdings = [h for h in portfolio.holdings if h.symbol == "ETH"]
self.assertEqual(len(eth_holdings), 1)
self.assertGreater(eth_holdings[0].amount, 0)

def test_empty_wallet(self):
# Zero address should have no holdings
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio(
"0x0000000000000000000000000000000000000001"
)
)
self.assertEqual(len(portfolio.holdings), 0)

def test_invalid_address(self):
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio("not-a-valid-address")
)
self.assertEqual(len(portfolio.holdings), 0)
self.assertEqual(portfolio.total_value_usd, 0)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
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
6 changes: 6 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,12 @@ export AWS_REGION=''
export AWS_ACCESS_KEY_ID=''
export AWS_SECRET_ACCESS_KEY=''
export SOLANA_RPC_URL=''
export ETH_RPC_URL='' # Ethereum RPC (Alchemy/Infura recommended)

# The Graph subgraph URLs (optional — defaults are provided)
# Production usage requires a Graph API key: https://thegraph.com/studio/
export UNISWAP_V3_SUBGRAPH_URL=''
export AAVE_V3_SUBGRAPH_URL=''
export OPENROUTER_API_KEY=''
export GEMINI_API_KEY=''
export DD_API_KEY=''
Expand Down
30 changes: 29 additions & 1 deletion agent/tools.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,7 +44,7 @@ async def retrieve_solana_pools(

# Create a query to filter pools
query = PoolQuery(
chain=Chain.SOLANA, # Currently only supporting Solana
chain=Chain.SOLANA,
tokens=tokens or [],
user_tokens=user_tokens,
)
Expand All@@ -56,9 +56,37 @@ async def retrieve_solana_pools(
return pools


@tool
@track_tool_usage("retrieve_ethereum_pools")
async def retrieve_ethereum_pools(
tokens: List[str] = None,
config: RunnableConfig = None,
) -> List[Pool]:
"""
Retrieves Ethereum pools matching the specified criteria that the user can invest in.
Includes Uniswap V3 AMM pools and Aave V3 lending pools.
"""
configurable = config["configurable"]
user_tokens: List[WalletTokenHolding] = configurable["tokens"]
protocol_registry: ProtocolRegistry = configurable["protocol_registry"]

query = PoolQuery(
chain=Chain.ETHEREUM,
tokens=tokens or [],
user_tokens=user_tokens,
)

pools = await protocol_registry.get_pools(query)
if len(pools) == 0:
return "No Ethereum pools found."

return pools


def create_investor_agent_toolkit() -> List[BaseTool]:
return [
retrieve_solana_pools,
retrieve_ethereum_pools,
]


Expand Down
4 changes: 4 additions & 0 deletions main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,12 +19,16 @@
from onchain.pools.solana.orca_protocol import OrcaProtocol
from onchain.pools.solana.save_protocol import SaveProtocol
from onchain.pools.solana.kamino_protocol import KaminoProtocol
from onchain.pools.ethereum.uniswap_v3_protocol import UniswapV3Protocol
from onchain.pools.ethereum.aave_protocol import AaveProtocol

# Define protocols enabled
protocols = [
OrcaProtocol.PROTOCOL_NAME,
SaveProtocol.PROTOCOL_NAME,
KaminoProtocol.PROTOCOL_NAME,
UniswapV3Protocol.PROTOCOL_NAME,
AaveProtocol.PROTOCOL_NAME,
]

# Create the FastAPI app
Expand Down
Empty file.
162 changes: 162 additions & 0 deletions onchain/pools/ethereum/aave_protocol.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
"""Aave V3 protocol implementation for Ethereum lending pool discovery."""

from typing import List, Optional, Dict, Any
import logging
import os

import aiohttp

from api.api_types import Pool, Token, Chain, PoolType
from onchain.pools.protocol import Protocol
from onchain.tokens.metadata import TokenMetadataRepo

logger = logging.getLogger(__name__)

# Aave V3 Ethereum subgraph — configurable via env var.
AAVE_V3_SUBGRAPH_URL = os.environ.get(
"AAVE_V3_SUBGRAPH_URL",
"https://gateway.thegraph.com/api/subgraphs/id/Cd2gEDVeqnjBn1hSeqFMitw8Q1iiyV9FYUZkLNRcL87g",
)

# Stablecoins for classification
STABLECOIN_SYMBOLS = {"USDC", "USDT", "DAI", "BUSD", "TUSD", "FRAX", "LUSD", "GUSD", "sUSD"}


class AaveProtocol(Protocol):
"""
Aave V3 protocol — fetches Ethereum lending reserves.

Returns each reserve as a lending Pool with supply APR.
Uses The Graph subgraph for on-chain data.
"""

PROTOCOL_NAME = "aave-v3"

_session: Optional[aiohttp.ClientSession] = None

@property
def name(self) -> str:
return self.PROTOCOL_NAME

@property
async def session(self) -> aiohttp.ClientSession:
if self._session is None:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30)
)
return self._session

async def close(self):
if self._session:
await self._session.close()
self._session = None

async def get_pools(self, token_metadata_repo: TokenMetadataRepo) -> List[Pool]:
"""Fetch Aave V3 reserves from the subgraph."""
query = """
{
reserves(
first: 50,
where: { isActive: true }
) {
id
symbol
name
decimals
underlyingAsset
liquidityRate
totalATokenSupply
totalCurrentVariableDebt
availableLiquidity
price {
priceInEth
}
}
}
"""

try:
session = await self.session
async with session.post(
AAVE_V3_SUBGRAPH_URL,
json={"query": query},
) as response:
if response.status in (401, 403):
logger.error(
f"Aave V3 subgraph auth failed ({response.status}). "
f"Set AAVE_V3_SUBGRAPH_URL with a valid Graph API key."
)
return []
if response.status != 200:
logger.error(f"Aave V3 subgraph returned {response.status}")
return []
data = await response.json()
except Exception as e:
logger.error(f"Error fetching Aave V3 reserves: {e}")
return []

reserves = data.get("data", {}).get("reserves", [])

# Resolve ETH/USD price for TVL conversion.
# Use the WETH token metadata from DexScreener (already cached).
eth_usd_price = 0.0
eth_meta = await token_metadata_repo.get_token_metadata(
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "ethereum"
)
if eth_meta and eth_meta.price:
eth_usd_price = float(eth_meta.price)

return self._convert_to_pools(reserves, eth_usd_price)

def _convert_to_pools(
self, reserves: List[Dict[str, Any]], eth_usd_price: float
) -> List[Pool]:
result: List[Pool] = []

for reserve in reserves:
symbol = reserve.get("symbol", "")
name = reserve.get("name", "")
address = reserve.get("underlyingAsset", "")
decimals = int(reserve.get("decimals", 18))

tokens = [
Token(address=address, name=name, symbol=symbol),
]

# Aave liquidityRate is in RAY units (1e27), convert to APR percentage
liquidity_rate = int(reserve.get("liquidityRate", 0))
supply_apr = (liquidity_rate / 1e27) * 100

# Calculate TVL in USD:
# totalATokenSupply is in token-native units (needs /10^decimals)
# priceInEth is the token price denominated in ETH (wei-scaled, 1e18)
# Multiply by eth_usd_price to get USD
total_supply_raw = int(reserve.get("totalATokenSupply", 0))
total_supply = total_supply_raw / (10**decimals)

price_in_eth_raw = reserve.get("price", {}).get("priceInEth", "0")
price_in_eth = int(price_in_eth_raw) / 1e18

if eth_usd_price > 0 and price_in_eth > 0:
tvl_usd = str(round(total_supply * price_in_eth * eth_usd_price, 2))
else:
tvl_usd = str(round(total_supply, 2))

is_stablecoin = symbol.upper() in STABLECOIN_SYMBOLS

pool = Pool(
id=reserve.get("id", ""),
chain=Chain.ETHEREUM,
protocol="Aave V3",
tokens=tokens,
type=PoolType.LENDING,
TVL=tvl_usd,
APRLastDay=round(supply_apr, 2),
APRLastWeek=round(supply_apr, 2),
APRLastMonth=round(supply_apr, 2),
isStableCoin=is_stablecoin,
impermanentLossRisk=False, # Lending has no IL
)
result.append(pool)

return result
126 changes: 126 additions & 0 deletions onchain/pools/ethereum/test_ethereum_protocols.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
import unittest
import os
import boto3
import asyncio

from onchain.pools.ethereum.uniswap_v3_protocol import UniswapV3Protocol
from onchain.pools.ethereum.aave_protocol import AaveProtocol
from onchain.portfolio.ethereum_portfolio import EthereumPortfolioFetcher
from onchain.tokens.metadata import TokenMetadataRepo
from api.api_types import Chain, PoolType
import dotenv

dotenv.load_dotenv()


class TestEthereumProtocols(unittest.TestCase):
def setUp(self):
dynamodb = boto3.resource(
"dynamodb",
region_name=os.environ.get("AWS_REGION"),
aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
)
tokens_table = dynamodb.Table("token_metadata_v2")
self.token_metadata_repo = TokenMetadataRepo(tokens_table)

def test_uniswap_v3(self):
uniswap = UniswapV3Protocol()
pools = asyncio.run(uniswap.get_pools(self.token_metadata_repo))

self.assertGreater(len(pools), 5)
print(f"Uniswap V3 pools: {len(pools)}")

# Verify pool structure
for pool in pools[:3]:
self.assertEqual(pool.chain, Chain.ETHEREUM)
self.assertEqual(pool.type, PoolType.AMM)
self.assertIn("Uniswap V3", pool.protocol)
self.assertEqual(len(pool.tokens), 2)
self.assertIsNotNone(pool.TVL)
self.assertGreaterEqual(pool.APRLastDay, 0)

def test_uniswap_v3_stablecoin_detection(self):
uniswap = UniswapV3Protocol()
pools = asyncio.run(uniswap.get_pools(self.token_metadata_repo))

# Non-stablecoin pools should have IL risk
for pool in pools:
if not pool.isStableCoin:
self.assertTrue(pool.impermanentLossRisk)
else:
self.assertFalse(pool.impermanentLossRisk)

def test_aave_v3(self):
aave = AaveProtocol()
pools = asyncio.run(aave.get_pools(self.token_metadata_repo))

self.assertGreater(len(pools), 5)
print(f"Aave V3 reserves: {len(pools)}")

# Verify pool structure
for pool in pools[:3]:
self.assertEqual(pool.chain, Chain.ETHEREUM)
self.assertEqual(pool.type, PoolType.LENDING)
self.assertEqual(pool.protocol, "Aave V3")
self.assertEqual(len(pool.tokens), 1)
self.assertFalse(pool.impermanentLossRisk)
self.assertGreaterEqual(pool.APRLastDay, 0)

def test_aave_v3_tvl_is_usd(self):
"""TVL should be in USD (not raw token units)."""
aave = AaveProtocol()
pools = asyncio.run(aave.get_pools(self.token_metadata_repo))

for pool in pools:
tvl = float(pool.TVL)
# Any active Aave reserve should have meaningful TVL in USD
if tvl > 0:
self.assertGreater(tvl, 100, f"{pool.tokens[0].symbol} TVL too low: {tvl}")


class TestEthereumPortfolio(unittest.TestCase):
def setUp(self):
dynamodb = boto3.resource(
"dynamodb",
region_name=os.environ.get("AWS_REGION"),
aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
)
tokens_table = dynamodb.Table("token_metadata_v2")
self.token_metadata_repo = TokenMetadataRepo(tokens_table)
self.portfolio_fetcher = EthereumPortfolioFetcher(self.token_metadata_repo)

def tearDown(self):
asyncio.run(self.portfolio_fetcher.close())

def test_get_portfolio(self):
# Vitalik's public wallet
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio(
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
)
)
print(portfolio)

self.assertGreater(len(portfolio.holdings), 0)
# Vitalik holds ETH
eth_holdings = [h for h in portfolio.holdings if h.symbol == "ETH"]
self.assertEqual(len(eth_holdings), 1)
self.assertGreater(eth_holdings[0].amount, 0)

def test_empty_wallet(self):
# Zero address should have no holdings
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio(
"0x0000000000000000000000000000000000000001"
)
)
self.assertEqual(len(portfolio.holdings), 0)

def test_invalid_address(self):
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio("not-a-valid-address")
)
self.assertEqual(len(portfolio.holdings), 0)
self.assertEqual(portfolio.total_value_usd, 0)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Open
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
6 changes: 6 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,12 @@ export AWS_REGION=''
export AWS_ACCESS_KEY_ID=''
export AWS_SECRET_ACCESS_KEY=''
export SOLANA_RPC_URL=''
export ETH_RPC_URL='' # Ethereum RPC (Alchemy/Infura recommended)

# The Graph subgraph URLs (optional — defaults are provided)
# Production usage requires a Graph API key: https://thegraph.com/studio/
export UNISWAP_V3_SUBGRAPH_URL=''
export AAVE_V3_SUBGRAPH_URL=''
export OPENROUTER_API_KEY=''
export GEMINI_API_KEY=''
export DD_API_KEY=''
Expand Down
30 changes: 29 additions & 1 deletion agent/tools.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,7 +44,7 @@ async def retrieve_solana_pools(

# Create a query to filter pools
query = PoolQuery(
chain=Chain.SOLANA, # Currently only supporting Solana
chain=Chain.SOLANA,
tokens=tokens or [],
user_tokens=user_tokens,
)
Expand All@@ -56,9 +56,37 @@ async def retrieve_solana_pools(
return pools


@tool
@track_tool_usage("retrieve_ethereum_pools")
async def retrieve_ethereum_pools(
tokens: List[str] = None,
config: RunnableConfig = None,
) -> List[Pool]:
"""
Retrieves Ethereum pools matching the specified criteria that the user can invest in.
Includes Uniswap V3 AMM pools and Aave V3 lending pools.
"""
configurable = config["configurable"]
user_tokens: List[WalletTokenHolding] = configurable["tokens"]
protocol_registry: ProtocolRegistry = configurable["protocol_registry"]

query = PoolQuery(
chain=Chain.ETHEREUM,
tokens=tokens or [],
user_tokens=user_tokens,
)

pools = await protocol_registry.get_pools(query)
if len(pools) == 0:
return "No Ethereum pools found."

return pools


def create_investor_agent_toolkit() -> List[BaseTool]:
return [
retrieve_solana_pools,
retrieve_ethereum_pools,
]


Expand Down
4 changes: 4 additions & 0 deletions main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,12 +19,16 @@
from onchain.pools.solana.orca_protocol import OrcaProtocol
from onchain.pools.solana.save_protocol import SaveProtocol
from onchain.pools.solana.kamino_protocol import KaminoProtocol
from onchain.pools.ethereum.uniswap_v3_protocol import UniswapV3Protocol
from onchain.pools.ethereum.aave_protocol import AaveProtocol

# Define protocols enabled
protocols = [
OrcaProtocol.PROTOCOL_NAME,
SaveProtocol.PROTOCOL_NAME,
KaminoProtocol.PROTOCOL_NAME,
UniswapV3Protocol.PROTOCOL_NAME,
AaveProtocol.PROTOCOL_NAME,
]

# Create the FastAPI app
Expand Down
Empty file.
162 changes: 162 additions & 0 deletions onchain/pools/ethereum/aave_protocol.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
"""Aave V3 protocol implementation for Ethereum lending pool discovery."""

from typing import List, Optional, Dict, Any
import logging
import os

import aiohttp

from api.api_types import Pool, Token, Chain, PoolType
from onchain.pools.protocol import Protocol
from onchain.tokens.metadata import TokenMetadataRepo

logger = logging.getLogger(__name__)

# Aave V3 Ethereum subgraph — configurable via env var.
AAVE_V3_SUBGRAPH_URL = os.environ.get(
"AAVE_V3_SUBGRAPH_URL",
"https://gateway.thegraph.com/api/subgraphs/id/Cd2gEDVeqnjBn1hSeqFMitw8Q1iiyV9FYUZkLNRcL87g",
)

# Stablecoins for classification
STABLECOIN_SYMBOLS = {"USDC", "USDT", "DAI", "BUSD", "TUSD", "FRAX", "LUSD", "GUSD", "sUSD"}


class AaveProtocol(Protocol):
"""
Aave V3 protocol — fetches Ethereum lending reserves.

Returns each reserve as a lending Pool with supply APR.
Uses The Graph subgraph for on-chain data.
"""

PROTOCOL_NAME = "aave-v3"

_session: Optional[aiohttp.ClientSession] = None

@property
def name(self) -> str:
return self.PROTOCOL_NAME

@property
async def session(self) -> aiohttp.ClientSession:
if self._session is None:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30)
)
return self._session

async def close(self):
if self._session:
await self._session.close()
self._session = None

async def get_pools(self, token_metadata_repo: TokenMetadataRepo) -> List[Pool]:
"""Fetch Aave V3 reserves from the subgraph."""
query = """
{
reserves(
first: 50,
where: { isActive: true }
) {
id
symbol
name
decimals
underlyingAsset
liquidityRate
totalATokenSupply
totalCurrentVariableDebt
availableLiquidity
price {
priceInEth
}
}
}
"""

try:
session = await self.session
async with session.post(
AAVE_V3_SUBGRAPH_URL,
json={"query": query},
) as response:
if response.status in (401, 403):
logger.error(
f"Aave V3 subgraph auth failed ({response.status}). "
f"Set AAVE_V3_SUBGRAPH_URL with a valid Graph API key."
)
return []
if response.status != 200:
logger.error(f"Aave V3 subgraph returned {response.status}")
return []
data = await response.json()
except Exception as e:
logger.error(f"Error fetching Aave V3 reserves: {e}")
return []

reserves = data.get("data", {}).get("reserves", [])

# Resolve ETH/USD price for TVL conversion.
# Use the WETH token metadata from DexScreener (already cached).
eth_usd_price = 0.0
eth_meta = await token_metadata_repo.get_token_metadata(
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "ethereum"
)
if eth_meta and eth_meta.price:
eth_usd_price = float(eth_meta.price)

return self._convert_to_pools(reserves, eth_usd_price)

def _convert_to_pools(
self, reserves: List[Dict[str, Any]], eth_usd_price: float
) -> List[Pool]:
result: List[Pool] = []

for reserve in reserves:
symbol = reserve.get("symbol", "")
name = reserve.get("name", "")
address = reserve.get("underlyingAsset", "")
decimals = int(reserve.get("decimals", 18))

tokens = [
Token(address=address, name=name, symbol=symbol),
]

# Aave liquidityRate is in RAY units (1e27), convert to APR percentage
liquidity_rate = int(reserve.get("liquidityRate", 0))
supply_apr = (liquidity_rate / 1e27) * 100

# Calculate TVL in USD:
# totalATokenSupply is in token-native units (needs /10^decimals)
# priceInEth is the token price denominated in ETH (wei-scaled, 1e18)
# Multiply by eth_usd_price to get USD
total_supply_raw = int(reserve.get("totalATokenSupply", 0))
total_supply = total_supply_raw / (10**decimals)

price_in_eth_raw = reserve.get("price", {}).get("priceInEth", "0")
price_in_eth = int(price_in_eth_raw) / 1e18

if eth_usd_price > 0 and price_in_eth > 0:
tvl_usd = str(round(total_supply * price_in_eth * eth_usd_price, 2))
else:
tvl_usd = str(round(total_supply, 2))

is_stablecoin = symbol.upper() in STABLECOIN_SYMBOLS

pool = Pool(
id=reserve.get("id", ""),
chain=Chain.ETHEREUM,
protocol="Aave V3",
tokens=tokens,
type=PoolType.LENDING,
TVL=tvl_usd,
APRLastDay=round(supply_apr, 2),
APRLastWeek=round(supply_apr, 2),
APRLastMonth=round(supply_apr, 2),
isStableCoin=is_stablecoin,
impermanentLossRisk=False, # Lending has no IL
)
result.append(pool)

return result
126 changes: 126 additions & 0 deletions onchain/pools/ethereum/test_ethereum_protocols.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
import unittest
import os
import boto3
import asyncio

from onchain.pools.ethereum.uniswap_v3_protocol import UniswapV3Protocol
from onchain.pools.ethereum.aave_protocol import AaveProtocol
from onchain.portfolio.ethereum_portfolio import EthereumPortfolioFetcher
from onchain.tokens.metadata import TokenMetadataRepo
from api.api_types import Chain, PoolType
import dotenv

dotenv.load_dotenv()


class TestEthereumProtocols(unittest.TestCase):
def setUp(self):
dynamodb = boto3.resource(
"dynamodb",
region_name=os.environ.get("AWS_REGION"),
aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
)
tokens_table = dynamodb.Table("token_metadata_v2")
self.token_metadata_repo = TokenMetadataRepo(tokens_table)

def test_uniswap_v3(self):
uniswap = UniswapV3Protocol()
pools = asyncio.run(uniswap.get_pools(self.token_metadata_repo))

self.assertGreater(len(pools), 5)
print(f"Uniswap V3 pools: {len(pools)}")

# Verify pool structure
for pool in pools[:3]:
self.assertEqual(pool.chain, Chain.ETHEREUM)
self.assertEqual(pool.type, PoolType.AMM)
self.assertIn("Uniswap V3", pool.protocol)
self.assertEqual(len(pool.tokens), 2)
self.assertIsNotNone(pool.TVL)
self.assertGreaterEqual(pool.APRLastDay, 0)

def test_uniswap_v3_stablecoin_detection(self):
uniswap = UniswapV3Protocol()
pools = asyncio.run(uniswap.get_pools(self.token_metadata_repo))

# Non-stablecoin pools should have IL risk
for pool in pools:
if not pool.isStableCoin:
self.assertTrue(pool.impermanentLossRisk)
else:
self.assertFalse(pool.impermanentLossRisk)

def test_aave_v3(self):
aave = AaveProtocol()
pools = asyncio.run(aave.get_pools(self.token_metadata_repo))

self.assertGreater(len(pools), 5)
print(f"Aave V3 reserves: {len(pools)}")

# Verify pool structure
for pool in pools[:3]:
self.assertEqual(pool.chain, Chain.ETHEREUM)
self.assertEqual(pool.type, PoolType.LENDING)
self.assertEqual(pool.protocol, "Aave V3")
self.assertEqual(len(pool.tokens), 1)
self.assertFalse(pool.impermanentLossRisk)
self.assertGreaterEqual(pool.APRLastDay, 0)

def test_aave_v3_tvl_is_usd(self):
"""TVL should be in USD (not raw token units)."""
aave = AaveProtocol()
pools = asyncio.run(aave.get_pools(self.token_metadata_repo))

for pool in pools:
tvl = float(pool.TVL)
# Any active Aave reserve should have meaningful TVL in USD
if tvl > 0:
self.assertGreater(tvl, 100, f"{pool.tokens[0].symbol} TVL too low: {tvl}")


class TestEthereumPortfolio(unittest.TestCase):
def setUp(self):
dynamodb = boto3.resource(
"dynamodb",
region_name=os.environ.get("AWS_REGION"),
aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
)
tokens_table = dynamodb.Table("token_metadata_v2")
self.token_metadata_repo = TokenMetadataRepo(tokens_table)
self.portfolio_fetcher = EthereumPortfolioFetcher(self.token_metadata_repo)

def tearDown(self):
asyncio.run(self.portfolio_fetcher.close())

def test_get_portfolio(self):
# Vitalik's public wallet
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio(
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
)
)
print(portfolio)

self.assertGreater(len(portfolio.holdings), 0)
# Vitalik holds ETH
eth_holdings = [h for h in portfolio.holdings if h.symbol == "ETH"]
self.assertEqual(len(eth_holdings), 1)
self.assertGreater(eth_holdings[0].amount, 0)

def test_empty_wallet(self):
# Zero address should have no holdings
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio(
"0x0000000000000000000000000000000000000001"
)
)
self.assertEqual(len(portfolio.holdings), 0)

def test_invalid_address(self):
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio("not-a-valid-address")
)
self.assertEqual(len(portfolio.holdings), 0)
self.assertEqual(portfolio.total_value_usd, 0)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
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
6 changes: 6 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,12 @@ export AWS_REGION=''
export AWS_ACCESS_KEY_ID=''
export AWS_SECRET_ACCESS_KEY=''
export SOLANA_RPC_URL=''
export ETH_RPC_URL='' # Ethereum RPC (Alchemy/Infura recommended)

# The Graph subgraph URLs (optional — defaults are provided)
# Production usage requires a Graph API key: https://thegraph.com/studio/
export UNISWAP_V3_SUBGRAPH_URL=''
export AAVE_V3_SUBGRAPH_URL=''
export OPENROUTER_API_KEY=''
export GEMINI_API_KEY=''
export DD_API_KEY=''
Expand Down
30 changes: 29 additions & 1 deletion agent/tools.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,7 +44,7 @@ async def retrieve_solana_pools(

# Create a query to filter pools
query = PoolQuery(
chain=Chain.SOLANA, # Currently only supporting Solana
chain=Chain.SOLANA,
tokens=tokens or [],
user_tokens=user_tokens,
)
Expand All@@ -56,9 +56,37 @@ async def retrieve_solana_pools(
return pools


@tool
@track_tool_usage("retrieve_ethereum_pools")
async def retrieve_ethereum_pools(
tokens: List[str] = None,
config: RunnableConfig = None,
) -> List[Pool]:
"""
Retrieves Ethereum pools matching the specified criteria that the user can invest in.
Includes Uniswap V3 AMM pools and Aave V3 lending pools.
"""
configurable = config["configurable"]
user_tokens: List[WalletTokenHolding] = configurable["tokens"]
protocol_registry: ProtocolRegistry = configurable["protocol_registry"]

query = PoolQuery(
chain=Chain.ETHEREUM,
tokens=tokens or [],
user_tokens=user_tokens,
)

pools = await protocol_registry.get_pools(query)
if len(pools) == 0:
return "No Ethereum pools found."

return pools


def create_investor_agent_toolkit() -> List[BaseTool]:
return [
retrieve_solana_pools,
retrieve_ethereum_pools,
]


Expand Down
4 changes: 4 additions & 0 deletions main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,12 +19,16 @@
from onchain.pools.solana.orca_protocol import OrcaProtocol
from onchain.pools.solana.save_protocol import SaveProtocol
from onchain.pools.solana.kamino_protocol import KaminoProtocol
from onchain.pools.ethereum.uniswap_v3_protocol import UniswapV3Protocol
from onchain.pools.ethereum.aave_protocol import AaveProtocol

# Define protocols enabled
protocols = [
OrcaProtocol.PROTOCOL_NAME,
SaveProtocol.PROTOCOL_NAME,
KaminoProtocol.PROTOCOL_NAME,
UniswapV3Protocol.PROTOCOL_NAME,
AaveProtocol.PROTOCOL_NAME,
]

# Create the FastAPI app
Expand Down
Empty file.
162 changes: 162 additions & 0 deletions onchain/pools/ethereum/aave_protocol.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
"""Aave V3 protocol implementation for Ethereum lending pool discovery."""

from typing import List, Optional, Dict, Any
import logging
import os

import aiohttp

from api.api_types import Pool, Token, Chain, PoolType
from onchain.pools.protocol import Protocol
from onchain.tokens.metadata import TokenMetadataRepo

logger = logging.getLogger(__name__)

# Aave V3 Ethereum subgraph — configurable via env var.
AAVE_V3_SUBGRAPH_URL = os.environ.get(
"AAVE_V3_SUBGRAPH_URL",
"https://gateway.thegraph.com/api/subgraphs/id/Cd2gEDVeqnjBn1hSeqFMitw8Q1iiyV9FYUZkLNRcL87g",
)

# Stablecoins for classification
STABLECOIN_SYMBOLS = {"USDC", "USDT", "DAI", "BUSD", "TUSD", "FRAX", "LUSD", "GUSD", "sUSD"}


class AaveProtocol(Protocol):
"""
Aave V3 protocol — fetches Ethereum lending reserves.

Returns each reserve as a lending Pool with supply APR.
Uses The Graph subgraph for on-chain data.
"""

PROTOCOL_NAME = "aave-v3"

_session: Optional[aiohttp.ClientSession] = None

@property
def name(self) -> str:
return self.PROTOCOL_NAME

@property
async def session(self) -> aiohttp.ClientSession:
if self._session is None:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30)
)
return self._session

async def close(self):
if self._session:
await self._session.close()
self._session = None

async def get_pools(self, token_metadata_repo: TokenMetadataRepo) -> List[Pool]:
"""Fetch Aave V3 reserves from the subgraph."""
query = """
{
reserves(
first: 50,
where: { isActive: true }
) {
id
symbol
name
decimals
underlyingAsset
liquidityRate
totalATokenSupply
totalCurrentVariableDebt
availableLiquidity
price {
priceInEth
}
}
}
"""

try:
session = await self.session
async with session.post(
AAVE_V3_SUBGRAPH_URL,
json={"query": query},
) as response:
if response.status in (401, 403):
logger.error(
f"Aave V3 subgraph auth failed ({response.status}). "
f"Set AAVE_V3_SUBGRAPH_URL with a valid Graph API key."
)
return []
if response.status != 200:
logger.error(f"Aave V3 subgraph returned {response.status}")
return []
data = await response.json()
except Exception as e:
logger.error(f"Error fetching Aave V3 reserves: {e}")
return []

reserves = data.get("data", {}).get("reserves", [])

# Resolve ETH/USD price for TVL conversion.
# Use the WETH token metadata from DexScreener (already cached).
eth_usd_price = 0.0
eth_meta = await token_metadata_repo.get_token_metadata(
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "ethereum"
)
if eth_meta and eth_meta.price:
eth_usd_price = float(eth_meta.price)

return self._convert_to_pools(reserves, eth_usd_price)

def _convert_to_pools(
self, reserves: List[Dict[str, Any]], eth_usd_price: float
) -> List[Pool]:
result: List[Pool] = []

for reserve in reserves:
symbol = reserve.get("symbol", "")
name = reserve.get("name", "")
address = reserve.get("underlyingAsset", "")
decimals = int(reserve.get("decimals", 18))

tokens = [
Token(address=address, name=name, symbol=symbol),
]

# Aave liquidityRate is in RAY units (1e27), convert to APR percentage
liquidity_rate = int(reserve.get("liquidityRate", 0))
supply_apr = (liquidity_rate / 1e27) * 100

# Calculate TVL in USD:
# totalATokenSupply is in token-native units (needs /10^decimals)
# priceInEth is the token price denominated in ETH (wei-scaled, 1e18)
# Multiply by eth_usd_price to get USD
total_supply_raw = int(reserve.get("totalATokenSupply", 0))
total_supply = total_supply_raw / (10**decimals)

price_in_eth_raw = reserve.get("price", {}).get("priceInEth", "0")
price_in_eth = int(price_in_eth_raw) / 1e18

if eth_usd_price > 0 and price_in_eth > 0:
tvl_usd = str(round(total_supply * price_in_eth * eth_usd_price, 2))
else:
tvl_usd = str(round(total_supply, 2))

is_stablecoin = symbol.upper() in STABLECOIN_SYMBOLS

pool = Pool(
id=reserve.get("id", ""),
chain=Chain.ETHEREUM,
protocol="Aave V3",
tokens=tokens,
type=PoolType.LENDING,
TVL=tvl_usd,
APRLastDay=round(supply_apr, 2),
APRLastWeek=round(supply_apr, 2),
APRLastMonth=round(supply_apr, 2),
isStableCoin=is_stablecoin,
impermanentLossRisk=False, # Lending has no IL
)
result.append(pool)

return result
126 changes: 126 additions & 0 deletions onchain/pools/ethereum/test_ethereum_protocols.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
import unittest
import os
import boto3
import asyncio

from onchain.pools.ethereum.uniswap_v3_protocol import UniswapV3Protocol
from onchain.pools.ethereum.aave_protocol import AaveProtocol
from onchain.portfolio.ethereum_portfolio import EthereumPortfolioFetcher
from onchain.tokens.metadata import TokenMetadataRepo
from api.api_types import Chain, PoolType
import dotenv

dotenv.load_dotenv()


class TestEthereumProtocols(unittest.TestCase):
def setUp(self):
dynamodb = boto3.resource(
"dynamodb",
region_name=os.environ.get("AWS_REGION"),
aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
)
tokens_table = dynamodb.Table("token_metadata_v2")
self.token_metadata_repo = TokenMetadataRepo(tokens_table)

def test_uniswap_v3(self):
uniswap = UniswapV3Protocol()
pools = asyncio.run(uniswap.get_pools(self.token_metadata_repo))

self.assertGreater(len(pools), 5)
print(f"Uniswap V3 pools: {len(pools)}")

# Verify pool structure
for pool in pools[:3]:
self.assertEqual(pool.chain, Chain.ETHEREUM)
self.assertEqual(pool.type, PoolType.AMM)
self.assertIn("Uniswap V3", pool.protocol)
self.assertEqual(len(pool.tokens), 2)
self.assertIsNotNone(pool.TVL)
self.assertGreaterEqual(pool.APRLastDay, 0)

def test_uniswap_v3_stablecoin_detection(self):
uniswap = UniswapV3Protocol()
pools = asyncio.run(uniswap.get_pools(self.token_metadata_repo))

# Non-stablecoin pools should have IL risk
for pool in pools:
if not pool.isStableCoin:
self.assertTrue(pool.impermanentLossRisk)
else:
self.assertFalse(pool.impermanentLossRisk)

def test_aave_v3(self):
aave = AaveProtocol()
pools = asyncio.run(aave.get_pools(self.token_metadata_repo))

self.assertGreater(len(pools), 5)
print(f"Aave V3 reserves: {len(pools)}")

# Verify pool structure
for pool in pools[:3]:
self.assertEqual(pool.chain, Chain.ETHEREUM)
self.assertEqual(pool.type, PoolType.LENDING)
self.assertEqual(pool.protocol, "Aave V3")
self.assertEqual(len(pool.tokens), 1)
self.assertFalse(pool.impermanentLossRisk)
self.assertGreaterEqual(pool.APRLastDay, 0)

def test_aave_v3_tvl_is_usd(self):
"""TVL should be in USD (not raw token units)."""
aave = AaveProtocol()
pools = asyncio.run(aave.get_pools(self.token_metadata_repo))

for pool in pools:
tvl = float(pool.TVL)
# Any active Aave reserve should have meaningful TVL in USD
if tvl > 0:
self.assertGreater(tvl, 100, f"{pool.tokens[0].symbol} TVL too low: {tvl}")


class TestEthereumPortfolio(unittest.TestCase):
def setUp(self):
dynamodb = boto3.resource(
"dynamodb",
region_name=os.environ.get("AWS_REGION"),
aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
)
tokens_table = dynamodb.Table("token_metadata_v2")
self.token_metadata_repo = TokenMetadataRepo(tokens_table)
self.portfolio_fetcher = EthereumPortfolioFetcher(self.token_metadata_repo)

def tearDown(self):
asyncio.run(self.portfolio_fetcher.close())

def test_get_portfolio(self):
# Vitalik's public wallet
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio(
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
)
)
print(portfolio)

self.assertGreater(len(portfolio.holdings), 0)
# Vitalik holds ETH
eth_holdings = [h for h in portfolio.holdings if h.symbol == "ETH"]
self.assertEqual(len(eth_holdings), 1)
self.assertGreater(eth_holdings[0].amount, 0)

def test_empty_wallet(self):
# Zero address should have no holdings
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio(
"0x0000000000000000000000000000000000000001"
)
)
self.assertEqual(len(portfolio.holdings), 0)

def test_invalid_address(self):
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio("not-a-valid-address")
)
self.assertEqual(len(portfolio.holdings), 0)
self.assertEqual(portfolio.total_value_usd, 0)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
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
6 changes: 6 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,12 @@ export AWS_REGION=''
export AWS_ACCESS_KEY_ID=''
export AWS_SECRET_ACCESS_KEY=''
export SOLANA_RPC_URL=''
export ETH_RPC_URL='' # Ethereum RPC (Alchemy/Infura recommended)

# The Graph subgraph URLs (optional — defaults are provided)
# Production usage requires a Graph API key: https://thegraph.com/studio/
export UNISWAP_V3_SUBGRAPH_URL=''
export AAVE_V3_SUBGRAPH_URL=''
export OPENROUTER_API_KEY=''
export GEMINI_API_KEY=''
export DD_API_KEY=''
Expand Down
30 changes: 29 additions & 1 deletion agent/tools.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,7 +44,7 @@ async def retrieve_solana_pools(

# Create a query to filter pools
query = PoolQuery(
chain=Chain.SOLANA, # Currently only supporting Solana
chain=Chain.SOLANA,
tokens=tokens or [],
user_tokens=user_tokens,
)
Expand All@@ -56,9 +56,37 @@ async def retrieve_solana_pools(
return pools


@tool
@track_tool_usage("retrieve_ethereum_pools")
async def retrieve_ethereum_pools(
tokens: List[str] = None,
config: RunnableConfig = None,
) -> List[Pool]:
"""
Retrieves Ethereum pools matching the specified criteria that the user can invest in.
Includes Uniswap V3 AMM pools and Aave V3 lending pools.
"""
configurable = config["configurable"]
user_tokens: List[WalletTokenHolding] = configurable["tokens"]
protocol_registry: ProtocolRegistry = configurable["protocol_registry"]

query = PoolQuery(
chain=Chain.ETHEREUM,
tokens=tokens or [],
user_tokens=user_tokens,
)

pools = await protocol_registry.get_pools(query)
if len(pools) == 0:
return "No Ethereum pools found."

return pools


def create_investor_agent_toolkit() -> List[BaseTool]:
return [
retrieve_solana_pools,
retrieve_ethereum_pools,
]


Expand Down
4 changes: 4 additions & 0 deletions main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,12 +19,16 @@
from onchain.pools.solana.orca_protocol import OrcaProtocol
from onchain.pools.solana.save_protocol import SaveProtocol
from onchain.pools.solana.kamino_protocol import KaminoProtocol
from onchain.pools.ethereum.uniswap_v3_protocol import UniswapV3Protocol
from onchain.pools.ethereum.aave_protocol import AaveProtocol

# Define protocols enabled
protocols = [
OrcaProtocol.PROTOCOL_NAME,
SaveProtocol.PROTOCOL_NAME,
KaminoProtocol.PROTOCOL_NAME,
UniswapV3Protocol.PROTOCOL_NAME,
AaveProtocol.PROTOCOL_NAME,
]

# Create the FastAPI app
Expand Down
Empty file.
162 changes: 162 additions & 0 deletions onchain/pools/ethereum/aave_protocol.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
"""Aave V3 protocol implementation for Ethereum lending pool discovery."""

from typing import List, Optional, Dict, Any
import logging
import os

import aiohttp

from api.api_types import Pool, Token, Chain, PoolType
from onchain.pools.protocol import Protocol
from onchain.tokens.metadata import TokenMetadataRepo

logger = logging.getLogger(__name__)

# Aave V3 Ethereum subgraph — configurable via env var.
AAVE_V3_SUBGRAPH_URL = os.environ.get(
"AAVE_V3_SUBGRAPH_URL",
"https://gateway.thegraph.com/api/subgraphs/id/Cd2gEDVeqnjBn1hSeqFMitw8Q1iiyV9FYUZkLNRcL87g",
)

# Stablecoins for classification
STABLECOIN_SYMBOLS = {"USDC", "USDT", "DAI", "BUSD", "TUSD", "FRAX", "LUSD", "GUSD", "sUSD"}


class AaveProtocol(Protocol):
"""
Aave V3 protocol — fetches Ethereum lending reserves.

Returns each reserve as a lending Pool with supply APR.
Uses The Graph subgraph for on-chain data.
"""

PROTOCOL_NAME = "aave-v3"

_session: Optional[aiohttp.ClientSession] = None

@property
def name(self) -> str:
return self.PROTOCOL_NAME

@property
async def session(self) -> aiohttp.ClientSession:
if self._session is None:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30)
)
return self._session

async def close(self):
if self._session:
await self._session.close()
self._session = None

async def get_pools(self, token_metadata_repo: TokenMetadataRepo) -> List[Pool]:
"""Fetch Aave V3 reserves from the subgraph."""
query = """
{
reserves(
first: 50,
where: { isActive: true }
) {
id
symbol
name
decimals
underlyingAsset
liquidityRate
totalATokenSupply
totalCurrentVariableDebt
availableLiquidity
price {
priceInEth
}
}
}
"""

try:
session = await self.session
async with session.post(
AAVE_V3_SUBGRAPH_URL,
json={"query": query},
) as response:
if response.status in (401, 403):
logger.error(
f"Aave V3 subgraph auth failed ({response.status}). "
f"Set AAVE_V3_SUBGRAPH_URL with a valid Graph API key."
)
return []
if response.status != 200:
logger.error(f"Aave V3 subgraph returned {response.status}")
return []
data = await response.json()
except Exception as e:
logger.error(f"Error fetching Aave V3 reserves: {e}")
return []

reserves = data.get("data", {}).get("reserves", [])

# Resolve ETH/USD price for TVL conversion.
# Use the WETH token metadata from DexScreener (already cached).
eth_usd_price = 0.0
eth_meta = await token_metadata_repo.get_token_metadata(
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "ethereum"
)
if eth_meta and eth_meta.price:
eth_usd_price = float(eth_meta.price)

return self._convert_to_pools(reserves, eth_usd_price)

def _convert_to_pools(
self, reserves: List[Dict[str, Any]], eth_usd_price: float
) -> List[Pool]:
result: List[Pool] = []

for reserve in reserves:
symbol = reserve.get("symbol", "")
name = reserve.get("name", "")
address = reserve.get("underlyingAsset", "")
decimals = int(reserve.get("decimals", 18))

tokens = [
Token(address=address, name=name, symbol=symbol),
]

# Aave liquidityRate is in RAY units (1e27), convert to APR percentage
liquidity_rate = int(reserve.get("liquidityRate", 0))
supply_apr = (liquidity_rate / 1e27) * 100

# Calculate TVL in USD:
# totalATokenSupply is in token-native units (needs /10^decimals)
# priceInEth is the token price denominated in ETH (wei-scaled, 1e18)
# Multiply by eth_usd_price to get USD
total_supply_raw = int(reserve.get("totalATokenSupply", 0))
total_supply = total_supply_raw / (10**decimals)

price_in_eth_raw = reserve.get("price", {}).get("priceInEth", "0")
price_in_eth = int(price_in_eth_raw) / 1e18

if eth_usd_price > 0 and price_in_eth > 0:
tvl_usd = str(round(total_supply * price_in_eth * eth_usd_price, 2))
else:
tvl_usd = str(round(total_supply, 2))

is_stablecoin = symbol.upper() in STABLECOIN_SYMBOLS

pool = Pool(
id=reserve.get("id", ""),
chain=Chain.ETHEREUM,
protocol="Aave V3",
tokens=tokens,
type=PoolType.LENDING,
TVL=tvl_usd,
APRLastDay=round(supply_apr, 2),
APRLastWeek=round(supply_apr, 2),
APRLastMonth=round(supply_apr, 2),
isStableCoin=is_stablecoin,
impermanentLossRisk=False, # Lending has no IL
)
result.append(pool)

return result
126 changes: 126 additions & 0 deletions onchain/pools/ethereum/test_ethereum_protocols.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
import unittest
import os
import boto3
import asyncio

from onchain.pools.ethereum.uniswap_v3_protocol import UniswapV3Protocol
from onchain.pools.ethereum.aave_protocol import AaveProtocol
from onchain.portfolio.ethereum_portfolio import EthereumPortfolioFetcher
from onchain.tokens.metadata import TokenMetadataRepo
from api.api_types import Chain, PoolType
import dotenv

dotenv.load_dotenv()


class TestEthereumProtocols(unittest.TestCase):
def setUp(self):
dynamodb = boto3.resource(
"dynamodb",
region_name=os.environ.get("AWS_REGION"),
aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
)
tokens_table = dynamodb.Table("token_metadata_v2")
self.token_metadata_repo = TokenMetadataRepo(tokens_table)

def test_uniswap_v3(self):
uniswap = UniswapV3Protocol()
pools = asyncio.run(uniswap.get_pools(self.token_metadata_repo))

self.assertGreater(len(pools), 5)
print(f"Uniswap V3 pools: {len(pools)}")

# Verify pool structure
for pool in pools[:3]:
self.assertEqual(pool.chain, Chain.ETHEREUM)
self.assertEqual(pool.type, PoolType.AMM)
self.assertIn("Uniswap V3", pool.protocol)
self.assertEqual(len(pool.tokens), 2)
self.assertIsNotNone(pool.TVL)
self.assertGreaterEqual(pool.APRLastDay, 0)

def test_uniswap_v3_stablecoin_detection(self):
uniswap = UniswapV3Protocol()
pools = asyncio.run(uniswap.get_pools(self.token_metadata_repo))

# Non-stablecoin pools should have IL risk
for pool in pools:
if not pool.isStableCoin:
self.assertTrue(pool.impermanentLossRisk)
else:
self.assertFalse(pool.impermanentLossRisk)

def test_aave_v3(self):
aave = AaveProtocol()
pools = asyncio.run(aave.get_pools(self.token_metadata_repo))

self.assertGreater(len(pools), 5)
print(f"Aave V3 reserves: {len(pools)}")

# Verify pool structure
for pool in pools[:3]:
self.assertEqual(pool.chain, Chain.ETHEREUM)
self.assertEqual(pool.type, PoolType.LENDING)
self.assertEqual(pool.protocol, "Aave V3")
self.assertEqual(len(pool.tokens), 1)
self.assertFalse(pool.impermanentLossRisk)
self.assertGreaterEqual(pool.APRLastDay, 0)

def test_aave_v3_tvl_is_usd(self):
"""TVL should be in USD (not raw token units)."""
aave = AaveProtocol()
pools = asyncio.run(aave.get_pools(self.token_metadata_repo))

for pool in pools:
tvl = float(pool.TVL)
# Any active Aave reserve should have meaningful TVL in USD
if tvl > 0:
self.assertGreater(tvl, 100, f"{pool.tokens[0].symbol} TVL too low: {tvl}")


class TestEthereumPortfolio(unittest.TestCase):
def setUp(self):
dynamodb = boto3.resource(
"dynamodb",
region_name=os.environ.get("AWS_REGION"),
aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
)
tokens_table = dynamodb.Table("token_metadata_v2")
self.token_metadata_repo = TokenMetadataRepo(tokens_table)
self.portfolio_fetcher = EthereumPortfolioFetcher(self.token_metadata_repo)

def tearDown(self):
asyncio.run(self.portfolio_fetcher.close())

def test_get_portfolio(self):
# Vitalik's public wallet
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio(
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
)
)
print(portfolio)

self.assertGreater(len(portfolio.holdings), 0)
# Vitalik holds ETH
eth_holdings = [h for h in portfolio.holdings if h.symbol == "ETH"]
self.assertEqual(len(eth_holdings), 1)
self.assertGreater(eth_holdings[0].amount, 0)

def test_empty_wallet(self):
# Zero address should have no holdings
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio(
"0x0000000000000000000000000000000000000001"
)
)
self.assertEqual(len(portfolio.holdings), 0)

def test_invalid_address(self):
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio("not-a-valid-address")
)
self.assertEqual(len(portfolio.holdings), 0)
self.assertEqual(portfolio.total_value_usd, 0)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Open
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
6 changes: 6 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,12 @@ export AWS_REGION=''
export AWS_ACCESS_KEY_ID=''
export AWS_SECRET_ACCESS_KEY=''
export SOLANA_RPC_URL=''
export ETH_RPC_URL='' # Ethereum RPC (Alchemy/Infura recommended)

# The Graph subgraph URLs (optional — defaults are provided)
# Production usage requires a Graph API key: https://thegraph.com/studio/
export UNISWAP_V3_SUBGRAPH_URL=''
export AAVE_V3_SUBGRAPH_URL=''
export OPENROUTER_API_KEY=''
export GEMINI_API_KEY=''
export DD_API_KEY=''
Expand Down
30 changes: 29 additions & 1 deletion agent/tools.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,7 +44,7 @@ async def retrieve_solana_pools(

# Create a query to filter pools
query = PoolQuery(
chain=Chain.SOLANA, # Currently only supporting Solana
chain=Chain.SOLANA,
tokens=tokens or [],
user_tokens=user_tokens,
)
Expand All@@ -56,9 +56,37 @@ async def retrieve_solana_pools(
return pools


@tool
@track_tool_usage("retrieve_ethereum_pools")
async def retrieve_ethereum_pools(
tokens: List[str] = None,
config: RunnableConfig = None,
) -> List[Pool]:
"""
Retrieves Ethereum pools matching the specified criteria that the user can invest in.
Includes Uniswap V3 AMM pools and Aave V3 lending pools.
"""
configurable = config["configurable"]
user_tokens: List[WalletTokenHolding] = configurable["tokens"]
protocol_registry: ProtocolRegistry = configurable["protocol_registry"]

query = PoolQuery(
chain=Chain.ETHEREUM,
tokens=tokens or [],
user_tokens=user_tokens,
)

pools = await protocol_registry.get_pools(query)
if len(pools) == 0:
return "No Ethereum pools found."

return pools


def create_investor_agent_toolkit() -> List[BaseTool]:
return [
retrieve_solana_pools,
retrieve_ethereum_pools,
]


Expand Down
4 changes: 4 additions & 0 deletions main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,12 +19,16 @@
from onchain.pools.solana.orca_protocol import OrcaProtocol
from onchain.pools.solana.save_protocol import SaveProtocol
from onchain.pools.solana.kamino_protocol import KaminoProtocol
from onchain.pools.ethereum.uniswap_v3_protocol import UniswapV3Protocol
from onchain.pools.ethereum.aave_protocol import AaveProtocol

# Define protocols enabled
protocols = [
OrcaProtocol.PROTOCOL_NAME,
SaveProtocol.PROTOCOL_NAME,
KaminoProtocol.PROTOCOL_NAME,
UniswapV3Protocol.PROTOCOL_NAME,
AaveProtocol.PROTOCOL_NAME,
]

# Create the FastAPI app
Expand Down
Empty file.
162 changes: 162 additions & 0 deletions onchain/pools/ethereum/aave_protocol.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
"""Aave V3 protocol implementation for Ethereum lending pool discovery."""

from typing import List, Optional, Dict, Any
import logging
import os

import aiohttp

from api.api_types import Pool, Token, Chain, PoolType
from onchain.pools.protocol import Protocol
from onchain.tokens.metadata import TokenMetadataRepo

logger = logging.getLogger(__name__)

# Aave V3 Ethereum subgraph — configurable via env var.
AAVE_V3_SUBGRAPH_URL = os.environ.get(
"AAVE_V3_SUBGRAPH_URL",
"https://gateway.thegraph.com/api/subgraphs/id/Cd2gEDVeqnjBn1hSeqFMitw8Q1iiyV9FYUZkLNRcL87g",
)

# Stablecoins for classification
STABLECOIN_SYMBOLS = {"USDC", "USDT", "DAI", "BUSD", "TUSD", "FRAX", "LUSD", "GUSD", "sUSD"}


class AaveProtocol(Protocol):
"""
Aave V3 protocol — fetches Ethereum lending reserves.

Returns each reserve as a lending Pool with supply APR.
Uses The Graph subgraph for on-chain data.
"""

PROTOCOL_NAME = "aave-v3"

_session: Optional[aiohttp.ClientSession] = None

@property
def name(self) -> str:
return self.PROTOCOL_NAME

@property
async def session(self) -> aiohttp.ClientSession:
if self._session is None:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30)
)
return self._session

async def close(self):
if self._session:
await self._session.close()
self._session = None

async def get_pools(self, token_metadata_repo: TokenMetadataRepo) -> List[Pool]:
"""Fetch Aave V3 reserves from the subgraph."""
query = """
{
reserves(
first: 50,
where: { isActive: true }
) {
id
symbol
name
decimals
underlyingAsset
liquidityRate
totalATokenSupply
totalCurrentVariableDebt
availableLiquidity
price {
priceInEth
}
}
}
"""

try:
session = await self.session
async with session.post(
AAVE_V3_SUBGRAPH_URL,
json={"query": query},
) as response:
if response.status in (401, 403):
logger.error(
f"Aave V3 subgraph auth failed ({response.status}). "
f"Set AAVE_V3_SUBGRAPH_URL with a valid Graph API key."
)
return []
if response.status != 200:
logger.error(f"Aave V3 subgraph returned {response.status}")
return []
data = await response.json()
except Exception as e:
logger.error(f"Error fetching Aave V3 reserves: {e}")
return []

reserves = data.get("data", {}).get("reserves", [])

# Resolve ETH/USD price for TVL conversion.
# Use the WETH token metadata from DexScreener (already cached).
eth_usd_price = 0.0
eth_meta = await token_metadata_repo.get_token_metadata(
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "ethereum"
)
if eth_meta and eth_meta.price:
eth_usd_price = float(eth_meta.price)

return self._convert_to_pools(reserves, eth_usd_price)

def _convert_to_pools(
self, reserves: List[Dict[str, Any]], eth_usd_price: float
) -> List[Pool]:
result: List[Pool] = []

for reserve in reserves:
symbol = reserve.get("symbol", "")
name = reserve.get("name", "")
address = reserve.get("underlyingAsset", "")
decimals = int(reserve.get("decimals", 18))

tokens = [
Token(address=address, name=name, symbol=symbol),
]

# Aave liquidityRate is in RAY units (1e27), convert to APR percentage
liquidity_rate = int(reserve.get("liquidityRate", 0))
supply_apr = (liquidity_rate / 1e27) * 100

# Calculate TVL in USD:
# totalATokenSupply is in token-native units (needs /10^decimals)
# priceInEth is the token price denominated in ETH (wei-scaled, 1e18)
# Multiply by eth_usd_price to get USD
total_supply_raw = int(reserve.get("totalATokenSupply", 0))
total_supply = total_supply_raw / (10**decimals)

price_in_eth_raw = reserve.get("price", {}).get("priceInEth", "0")
price_in_eth = int(price_in_eth_raw) / 1e18

if eth_usd_price > 0 and price_in_eth > 0:
tvl_usd = str(round(total_supply * price_in_eth * eth_usd_price, 2))
else:
tvl_usd = str(round(total_supply, 2))

is_stablecoin = symbol.upper() in STABLECOIN_SYMBOLS

pool = Pool(
id=reserve.get("id", ""),
chain=Chain.ETHEREUM,
protocol="Aave V3",
tokens=tokens,
type=PoolType.LENDING,
TVL=tvl_usd,
APRLastDay=round(supply_apr, 2),
APRLastWeek=round(supply_apr, 2),
APRLastMonth=round(supply_apr, 2),
isStableCoin=is_stablecoin,
impermanentLossRisk=False, # Lending has no IL
)
result.append(pool)

return result
126 changes: 126 additions & 0 deletions onchain/pools/ethereum/test_ethereum_protocols.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
import unittest
import os
import boto3
import asyncio

from onchain.pools.ethereum.uniswap_v3_protocol import UniswapV3Protocol
from onchain.pools.ethereum.aave_protocol import AaveProtocol
from onchain.portfolio.ethereum_portfolio import EthereumPortfolioFetcher
from onchain.tokens.metadata import TokenMetadataRepo
from api.api_types import Chain, PoolType
import dotenv

dotenv.load_dotenv()


class TestEthereumProtocols(unittest.TestCase):
def setUp(self):
dynamodb = boto3.resource(
"dynamodb",
region_name=os.environ.get("AWS_REGION"),
aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
)
tokens_table = dynamodb.Table("token_metadata_v2")
self.token_metadata_repo = TokenMetadataRepo(tokens_table)

def test_uniswap_v3(self):
uniswap = UniswapV3Protocol()
pools = asyncio.run(uniswap.get_pools(self.token_metadata_repo))

self.assertGreater(len(pools), 5)
print(f"Uniswap V3 pools: {len(pools)}")

# Verify pool structure
for pool in pools[:3]:
self.assertEqual(pool.chain, Chain.ETHEREUM)
self.assertEqual(pool.type, PoolType.AMM)
self.assertIn("Uniswap V3", pool.protocol)
self.assertEqual(len(pool.tokens), 2)
self.assertIsNotNone(pool.TVL)
self.assertGreaterEqual(pool.APRLastDay, 0)

def test_uniswap_v3_stablecoin_detection(self):
uniswap = UniswapV3Protocol()
pools = asyncio.run(uniswap.get_pools(self.token_metadata_repo))

# Non-stablecoin pools should have IL risk
for pool in pools:
if not pool.isStableCoin:
self.assertTrue(pool.impermanentLossRisk)
else:
self.assertFalse(pool.impermanentLossRisk)

def test_aave_v3(self):
aave = AaveProtocol()
pools = asyncio.run(aave.get_pools(self.token_metadata_repo))

self.assertGreater(len(pools), 5)
print(f"Aave V3 reserves: {len(pools)}")

# Verify pool structure
for pool in pools[:3]:
self.assertEqual(pool.chain, Chain.ETHEREUM)
self.assertEqual(pool.type, PoolType.LENDING)
self.assertEqual(pool.protocol, "Aave V3")
self.assertEqual(len(pool.tokens), 1)
self.assertFalse(pool.impermanentLossRisk)
self.assertGreaterEqual(pool.APRLastDay, 0)

def test_aave_v3_tvl_is_usd(self):
"""TVL should be in USD (not raw token units)."""
aave = AaveProtocol()
pools = asyncio.run(aave.get_pools(self.token_metadata_repo))

for pool in pools:
tvl = float(pool.TVL)
# Any active Aave reserve should have meaningful TVL in USD
if tvl > 0:
self.assertGreater(tvl, 100, f"{pool.tokens[0].symbol} TVL too low: {tvl}")


class TestEthereumPortfolio(unittest.TestCase):
def setUp(self):
dynamodb = boto3.resource(
"dynamodb",
region_name=os.environ.get("AWS_REGION"),
aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
)
tokens_table = dynamodb.Table("token_metadata_v2")
self.token_metadata_repo = TokenMetadataRepo(tokens_table)
self.portfolio_fetcher = EthereumPortfolioFetcher(self.token_metadata_repo)

def tearDown(self):
asyncio.run(self.portfolio_fetcher.close())

def test_get_portfolio(self):
# Vitalik's public wallet
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio(
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
)
)
print(portfolio)

self.assertGreater(len(portfolio.holdings), 0)
# Vitalik holds ETH
eth_holdings = [h for h in portfolio.holdings if h.symbol == "ETH"]
self.assertEqual(len(eth_holdings), 1)
self.assertGreater(eth_holdings[0].amount, 0)

def test_empty_wallet(self):
# Zero address should have no holdings
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio(
"0x0000000000000000000000000000000000000001"
)
)
self.assertEqual(len(portfolio.holdings), 0)

def test_invalid_address(self):
portfolio = asyncio.run(
self.portfolio_fetcher.get_portfolio("not-a-valid-address")
)
self.assertEqual(len(portfolio.holdings), 0)
self.assertEqual(portfolio.total_value_usd, 0)
Loading