Skip to content

Commit 76df7a7

Browse files
timbrindedmatias-gonzCopilot
authored
fix: 🛠️ Add Base Support (#43)
* feat: ✨ Added config file support * tidy * tidy * lint * refactor: ♻️ tidy * ci: 💚 fix ci * fix: 🐛 fix asset order * fix: 🐛 fix oraclehelper issue * fix * refactor: ♻️ tidy * fmt * fix default overriding * Refactor/big refactor (#39) * refactor: ♻️ big refactor * refactor: ♻️ Tidy * docs: 📝 update readme * refactor: ♻️ pr comments * refactor: asset addresses constants as typed dicts (#38) * feat: add validation for asset prices in total_assets calculation (#35) * feat: add validation for asset prices in total_assets calculation - Implemented checks for invalid prices (<= 0) in the calculate_total_assets function. - Added error handling to raise ValueError with details of invalid prices. - Introduced a new test to ensure that invalid prices trigger the appropriate exception. * refactor: turn invalid prices computation into list comprehension * feat: add price validation for price adapter (#36) * fix: update WstETHAdapter and tests to set ETH base asset price to 1 - Changed the base asset price for ETH in WstETHAdapter from 0 to 1 to ensure accurate pricing. - Updated related tests to verify that the ETH price is now correctly set to 1 instead of 0. - Adjusted documentation to clarify the base asset pricing behavior. * feat: add price validation to BasePriceAdapter * refactor: change validate_prices method to synchronous in price adapters - Updated the validate_prices method in BasePriceAdapter to be synchronous instead of asynchronous. - Adjusted calls to validate_prices in ChainlinkAdapter, CowSwapAdapter, and WstETHAdapter accordingly. * feat: add TypedDict for network addresses in constants.py * feat: add NetworkAddresses instances * refactor: rename NetworkAddresses to NetworkTokens and update address constants * feat: add network configuration and asset retrieval to OracleCLIConfig * refactor: update WstETHAdapter to use dynamic asset addresses from config * refactor: enhance WstETHAdapter to enforce required ETH address and simplify checks * refactor: update CowSwapAdapter to use dynamic asset addresses and improve network configuration * refactor: update ChainlinkAdapter to use dynamic asset addresses from config and enforce required ETH address * refactor: update IdleBalancesAdapter to use dynamic asset addresses from config and enforce required ETH and USDC addresses * refactor: remove optional asset address attributes from Chainlink, CowSwap, and WstETH adapters * refactor: enforce required USDC address in HyperliquidAdapter and update asset address retrieval from config * refactor: update Chainlink test suite to use dynamic asset addresses from config * refactor: update WstETHAdapter tests to utilize dynamic asset addresses from config * refactor: enhance HyperliquidAdapter tests to utilize dynamic USDC addresses from config * refactor: update tests to utilize dynamic asset addresses from config for improved consistency * refactor: update Chainlink and CowSwap tests to utilize dynamic asset addresses from config for improved consistency * refactor: update Chainlink tests to include dynamic ETH address in price fetching for USDT and USDS on testnet * refactor: update import paths from config to settings in CowSwap and Hyperliquid test files * test fix * refactor: ♻️ pr comments * pr comments * Update constants.py Co-authored-by: Copilot <[email protected]> * Feat/improved docs checks (#40) * docs * docs * Update src/tq_oracle/adapters/asset_adapters/idle_balances.py Co-authored-by: Copilot <[email protected]> * Update tq-oracle-example.toml Co-authored-by: Copilot <[email protected]> * Update README.md Co-authored-by: Copilot <[email protected]> * update cli opts * fix: 🐛 working with base now * rename l1->vault * fix private key * price fix * fix tests * update tests * give steth disclaimer * fmt --------- Co-authored-by: Matías Ignacio González <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 23a0044 commit 76df7a7

32 files changed

+339
-163
lines changed

ARCHITECTURE.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ Multi-stage pricing system with validation:
8282
1. **Price Fetching**: Queries multiple price sources sequentially
8383
- `CowSwapAdapter`: Primary DEX aggregator price source
8484
- `WstETHAdapter`: Specialized adapter for wrapped staked ETH
85+
- **⚠️ Pricing Assumption**: Assumes 1:1 peg between stETH and ETH
86+
- Calculates wstETH price by querying the stETH/wstETH exchange rate from the wstETH contract
87+
- Does NOT fetch or validate the market price of stETH/ETH
88+
- During stETH depeg events, wstETH will be mispriced relative to market value
8589
- `ChainlinkAdapter`: Not used in main pipeline, only for validation
8690

8791
2. **Price Validation** (`checks/price_validators.py`):
@@ -198,7 +202,7 @@ TQ Oracle uses a three-tier configuration system with precedence:
198202

199203
- `vault_address`: Target vault to report on
200204
- `oracle_helper_address`: OracleHelper contract for price normalization
201-
- `l1_rpc` / `hl_rpc`: RPC endpoints for different chains
205+
- `vault_rpc` / `hl_rpc`: RPC endpoints for different chains (vault network and Hyperliquid)
202206
- `safe_address`: Gnosis Safe for multi-sig submission
203207
- `private_key`: Signer key for proposing Safe transactions
204208
- `subvault_adapters`: Per-subvault adapter configuration

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ TQ Oracle performs read smart contract READ calls through a registry of protocol
88

99
For detailed system architecture and integration with Mellow Finance flexible-vaults, see [ARCHITECTURE.md](ARCHITECTURE.md).
1010

11+
## Known Limitations & Assumptions
12+
13+
> [!WARNING]
14+
> **stETH/ETH Peg Assumption**: The wstETH price adapter assumes a 1:1 peg between stETH and ETH when calculating wstETH prices. This assumption is **not validated** against market prices.
15+
>
16+
> **Impact**: During stETH depeg events (e.g., market stress, liquidity crises), the oracle will report inaccurate TVL for vaults holding wstETH. The reported value will reflect the stETH/wstETH exchange rate multiplied by an assumed 1:1 stETH/ETH rate, not the actual market value.
17+
1118
## Running without installing
1219

1320
You can run this CLI without any git cloning, directly with `uv`
@@ -67,7 +74,7 @@ All configuration options can be set via CLI arguments, environment variables, o
6774
| `VAULT_ADDRESS` | - | `vault_address` | *required* | Vault contract address (positional argument) |
6875
| `--config` `-c` | - | - | Auto-detect | Path to TOML configuration file |
6976
| `--oracle-helper-address` `-h` | - | `oracle_helper_address` | Auto (mainnet/testnet) | OracleHelper contract address |
70-
| `--l1-rpc` | `L1_RPC` | `l1_rpc` | Auto (mainnet/testnet) | Ethereum L1 RPC endpoint |
77+
| `--vault-rpc` | `VAULT_RPC` | `vault_rpc` | Auto (mainnet/sepolia/base) | Vault network RPC endpoint |
7178
| `--hl-rpc` | `HL_EVM_RPC` | `hl_rpc` | Auto (mainnet/testnet) | Hyperliquid RPC endpoint |
7279
| `--l1-subvault-address` | `L1_SUBVAULT_ADDRESS` | `l1_subvault_address` | - | L1 subvault for CCTP monitoring |
7380
| `--hl-subvault-address` | `HL_SUBVAULT_ADDRESS` | `hl_subvault_address` | Vault address | Hyperliquid subvault address |

scripts/check_cctp_inflight.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ async def test_cctp_bridge(
2525
l1_subvault_address: str,
2626
hl_subvault_address: str,
2727
testnet: bool = False,
28-
l1_rpc: str | None = None,
28+
vault_rpc: str | None = None,
2929
hl_rpc: str | None = None,
3030
) -> int:
3131
"""Test CCTP bridge in-flight transaction detection.
@@ -34,21 +34,21 @@ async def test_cctp_bridge(
3434
l1_subvault_address: L1 subvault address to monitor
3535
hl_subvault_address: Hyperliquid subvault address to monitor
3636
testnet: Whether to use testnet (default: False for mainnet)
37-
l1_rpc: Custom L1 RPC URL (optional, defaults based on testnet flag)
37+
vault_rpc: Custom L1 RPC URL (optional, defaults based on testnet flag)
3838
hl_rpc: Custom Hyperliquid RPC URL (optional, defaults based on testnet flag)
3939
"""
40-
using_default_rpc = l1_rpc is None or hl_rpc is None
40+
using_default_rpc = vault_rpc is None or hl_rpc is None
4141

42-
if l1_rpc is None:
43-
l1_rpc = DEFAULT_SEPOLIA_RPC_URL if testnet else DEFAULT_MAINNET_RPC_URL
42+
if vault_rpc is None:
43+
vault_rpc = DEFAULT_SEPOLIA_RPC_URL if testnet else DEFAULT_MAINNET_RPC_URL
4444

4545
if hl_rpc is None:
4646
hl_rpc = HL_TEST_EVM_RPC if testnet else HL_PROD_EVM_RPC
4747

4848
config = OracleSettings(
4949
vault_address="",
5050
oracle_helper_address="",
51-
l1_rpc=l1_rpc,
51+
vault_rpc=vault_rpc,
5252
l1_subvault_address=l1_subvault_address,
5353
safe_address=None,
5454
hl_rpc=hl_rpc,
@@ -65,7 +65,7 @@ async def test_cctp_bridge(
6565
logger.info(f"L1 Subvault: {l1_subvault_address}")
6666
logger.info(f"HL Subvault: {hl_subvault_address}")
6767
logger.info(f"Network: {'Testnet' if testnet else 'Mainnet'}")
68-
logger.info(f"L1 RPC: {l1_rpc}")
68+
logger.info(f"Vault RPC: {vault_rpc}")
6969
logger.info(f"HL RPC: {hl_rpc}")
7070
logger.info("=" * 60)
7171

@@ -116,7 +116,7 @@ def main() -> int:
116116
l1_subvault_address=args.l1_subvault_address,
117117
hl_subvault_address=args.hl_subvault_address,
118118
testnet=args.testnet,
119-
l1_rpc=args.l1_rpc,
119+
vault_rpc=args.vault_rpc,
120120
hl_rpc=args.hl_rpc,
121121
)
122122
)

src/tq_oracle/adapters/asset_adapters/base.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
class AdapterChain(Enum):
1010
"""Enum indicating which blockchain/RPC an adapter uses."""
1111

12-
L1 = "l1" # Ethereum L1 (uses l1_rpc)
12+
VAULT_CHAIN = "vault_chain" # Main vault network (uses vault_rpc)
1313
HYPERLIQUID = "hyperliquid" # Hyperliquid chain (uses hl_rpc)
1414

1515

@@ -24,12 +24,12 @@ class AssetData:
2424
class BaseAssetAdapter(ABC):
2525
"""Abstract base class for asset adapters."""
2626

27-
def __init__(self, config: OracleSettings, chain: str = "l1"):
27+
def __init__(self, config: OracleSettings, chain: str = "vault_chain"):
2828
"""Initialize the adapter with configuration.
2929
3030
Args:
3131
config: Oracle configuration
32-
chain: Which chain to operate on - "l1" or "hyperliquid"
32+
chain: Which chain to operate on - "vault_chain" or "hyperliquid"
3333
"""
3434
self.config = config
3535
self._chain = chain

src/tq_oracle/adapters/asset_adapters/idle_balances.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,12 @@ class IdleBalancesAdapter(BaseAssetAdapter):
2828
eth_address: str
2929
usdc_address: str
3030

31-
def __init__(self, config: OracleSettings, chain: str = "l1"):
31+
def __init__(self, config: OracleSettings, chain: str = "vault_chain"):
3232
"""Initialize the adapter.
3333
3434
Args:
3535
config: Oracle configuration
36-
chain: Which chain to query - "l1" or "hyperliquid"
36+
chain: Which chain to query - "vault_chain" or "hyperliquid"
3737
"""
3838
super().__init__(config, chain=chain)
3939

@@ -42,9 +42,10 @@ def __init__(self, config: OracleSettings, chain: str = "l1"):
4242
raise ValueError("hl_rpc must be configured to use hyperliquid chain")
4343
self.w3 = Web3(Web3.HTTPProvider(config.hl_rpc))
4444
else:
45-
self.w3 = Web3(Web3.HTTPProvider(config.l1_rpc))
45+
self.w3 = Web3(Web3.HTTPProvider(config.vault_rpc))
4646

4747
assets = config.assets
48+
logger.debug(f"Assets available: {assets}")
4849
eth_address = assets["ETH"]
4950
if eth_address is None:
5051
raise ValueError("ETH address is required for IdleBalances adapter")
@@ -80,7 +81,7 @@ def chain(self) -> AdapterChain:
8081
return (
8182
AdapterChain.HYPERLIQUID
8283
if self._chain == "hyperliquid"
83-
else AdapterChain.L1
84+
else AdapterChain.VAULT_CHAIN
8485
)
8586

8687
async def fetch_assets(self, subvault_address: str) -> list[AssetData]:
@@ -221,7 +222,7 @@ async def _fetch_supported_assets(self) -> list[str]:
221222
"""Get the supported assets for the given vault."""
222223
oracle_abi = load_oracle_abi()
223224
oracle_address = get_oracle_address_from_vault(
224-
self.config.vault_address_required, self.config.l1_rpc_required
225+
self.config.vault_address_required, self.config.vault_rpc_required
225226
)
226227
return await self._fetch_contract_list(
227228
contract_address=oracle_address,

src/tq_oracle/adapters/check_adapters/active_submit_report_proposal_check.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import logging
77
from typing import TYPE_CHECKING
88

9+
import requests
910
from eth_typing import URI
1011
from safe_eth.eth import EthereumClient, EthereumNetwork
1112
from safe_eth.safe.api import TransactionServiceApi
@@ -93,7 +94,7 @@ async def _get_active_submit_report_proposals(self) -> list[object]:
9394
List of active submitReport() proposals
9495
"""
9596
network = EthereumNetwork(self._config.chain_id)
96-
ethereum_client = EthereumClient(URI(self._config.l1_rpc_required))
97+
ethereum_client = EthereumClient(URI(self._config.vault_rpc_required))
9798

9899
api_key = (
99100
self._config.safe_txn_srvc_api_key.get_secret_value()
@@ -104,6 +105,12 @@ async def _get_active_submit_report_proposals(self) -> list[object]:
104105

105106
safe_checksum = Web3.to_checksum_address(self._config.safe_address)
106107

108+
safe_api_url = f"{tx_service.base_url}/api/v1/safes/{safe_checksum}/"
109+
safe_info_response = await asyncio.to_thread(requests.get, safe_api_url)
110+
safe_info_response.raise_for_status()
111+
safe_info_data = safe_info_response.json()
112+
current_nonce = int(safe_info_data.get("nonce", 0))
113+
107114
pending_txs = await asyncio.to_thread(
108115
tx_service.get_transactions,
109116
safe_checksum,
@@ -117,6 +124,10 @@ async def _get_active_submit_report_proposals(self) -> list[object]:
117124
if not tx.get("isExecuted", False):
118125
tx_data = tx.get("data", "")
119126
if tx_data and tx_data.startswith(SUBMIT_REPORTS_SELECTOR):
120-
active_submit_report_proposals.append(tx)
127+
# Filter out stale transactions (nonce < current_nonce)
128+
# These are rejected/superseded proposals that can never execute
129+
tx_nonce = tx.get("nonce")
130+
if tx_nonce is not None and int(tx_nonce) >= current_nonce:
131+
active_submit_report_proposals.append(tx)
121132

122133
return active_submit_report_proposals

src/tq_oracle/adapters/check_adapters/cctp_bridge.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,8 @@ async def run_check(self) -> CheckResult:
125125
message="Skipping CCTP bridge checks - HL subvault address not configured",
126126
retry_recommended=False,
127127
)
128-
logger.debug(f"Connecting to L1 RPC: {self._config.l1_rpc}")
129-
l1_w3 = AsyncWeb3(AsyncWeb3.AsyncHTTPProvider(self._config.l1_rpc))
128+
logger.debug(f"Connecting to vault-chain RPC: {self._config.vault_rpc}")
129+
l1_w3 = AsyncWeb3(AsyncWeb3.AsyncHTTPProvider(self._config.vault_rpc))
130130
logger.debug(f"Connecting to HL RPC: {self._config.hl_rpc}")
131131
hl_w3 = AsyncWeb3(AsyncWeb3.AsyncHTTPProvider(self._config.hl_rpc))
132132

src/tq_oracle/adapters/check_adapters/timeout_check.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ async def run_check(self) -> CheckResult:
5959
"""
6060
w3 = None
6161
try:
62-
w3 = AsyncWeb3(AsyncWeb3.AsyncHTTPProvider(self._config.l1_rpc))
62+
w3 = AsyncWeb3(AsyncWeb3.AsyncHTTPProvider(self._config.vault_rpc))
6363

6464
oracle_address = self._config.oracle_address
6565
logger.debug(f"Using oracle address: {oracle_address}")

src/tq_oracle/adapters/price_adapters/chainlink.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ class ChainlinkAdapter(BasePriceAdapter):
2626

2727
def __init__(self, config: OracleSettings):
2828
super().__init__(config)
29-
self.l1_rpc = config.l1_rpc
29+
self.vault_rpc = config.vault_rpc
3030
assets = config.assets
3131
eth_address = assets["ETH"]
3232
if eth_address is None:
@@ -83,7 +83,7 @@ async def fetch_prices(
8383
if not direct_feed_assets and not has_usds:
8484
return prices_accumulator
8585

86-
w3 = Web3(Web3.HTTPProvider(self.l1_rpc))
86+
w3 = Web3(Web3.HTTPProvider(self.vault_rpc))
8787
aggregator_abi = load_aggregator_abi()
8888

8989
for asset_address, price_feed in direct_feed_assets:

src/tq_oracle/adapters/price_adapters/wsteth.py

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,51 @@
11
from __future__ import annotations
22

3+
import logging
34
from typing import TYPE_CHECKING
45

56
from web3 import Web3
67

78
from ...abi import load_wsteth_abi
9+
from ...constants import DEFAULT_MAINNET_RPC_URL, ETH_MAINNET_ASSETS
10+
from ...settings import Network
811
from .base import BasePriceAdapter, PriceData
912

1013
if TYPE_CHECKING:
1114
from ...settings import OracleSettings
1215

16+
logger = logging.getLogger(__name__)
17+
1318

1419
class WstETHAdapter(BasePriceAdapter):
15-
"""Adapter for pricing ETH, WETH, and wstETH."""
20+
"""Adapter for pricing ETH, WETH, and wstETH.
21+
22+
WARNING: This adapter assumes a 1:1 peg between stETH and ETH when pricing wstETH.
23+
The wstETH price is calculated by querying the wstETH contract's stETH/wstETH exchange
24+
rate and treating that value as equivalent to ETH. During stETH depeg events (e.g.,
25+
market stress, liquidity issues), this will result in inaccurate TVL reporting.
26+
27+
The adapter does NOT validate the stETH/ETH market price. Operators should monitor
28+
stETH/ETH price deviation and be aware that reported wstETH values may not reflect
29+
true market pricing during depeg scenarios.
30+
"""
1631

1732
eth_address: str
1833

1934
def __init__(self, config: OracleSettings):
2035
super().__init__(config)
21-
self.l1_rpc = config.l1_rpc
36+
37+
if config.network == Network.MAINNET:
38+
self.mainnet_rpc = config.vault_rpc
39+
else:
40+
# On L2s (Base, Sepolia, etc), use eth_mainnet_rpc or fall back to default
41+
self.mainnet_rpc = config.eth_mainnet_rpc or DEFAULT_MAINNET_RPC_URL
42+
if not config.eth_mainnet_rpc:
43+
logger.warning(
44+
f"eth_mainnet_rpc not configured for {config.network.value}. "
45+
f"Using default public RPC ({DEFAULT_MAINNET_RPC_URL}) for wstETH pricing. "
46+
f"For production deployments, configure eth_mainnet_rpc in your settings."
47+
)
48+
2249
assets = config.assets
2350
eth_address = assets["ETH"]
2451
if eth_address is None:
@@ -54,12 +81,16 @@ async def fetch_prices(
5481
if prices_accumulator.base_asset != self.eth_address:
5582
raise ValueError("WstETH adapter only supports ETH as base asset")
5683

57-
has_eth = self.eth_address in asset_addresses
84+
asset_addresses_lower = [addr.lower() for addr in asset_addresses]
85+
86+
has_eth = self.eth_address.lower() in asset_addresses_lower
5887
has_weth = (
59-
self.weth_address is not None and self.weth_address in asset_addresses
88+
self.weth_address is not None
89+
and self.weth_address.lower() in asset_addresses_lower
6090
)
6191
has_wsteth = (
62-
self.wsteth_address is not None and self.wsteth_address in asset_addresses
92+
self.wsteth_address is not None
93+
and self.wsteth_address.lower() in asset_addresses_lower
6394
)
6495

6596
if has_eth:
@@ -71,15 +102,23 @@ async def fetch_prices(
71102

72103
if has_wsteth:
73104
assert self.wsteth_address is not None
74-
w3 = Web3(Web3.HTTPProvider(self.l1_rpc))
105+
wsteth_addr_actual = next(
106+
addr
107+
for addr in asset_addresses
108+
if addr.lower() == self.wsteth_address.lower()
109+
)
110+
111+
w3 = Web3(Web3.HTTPProvider(self.mainnet_rpc))
75112
wsteth_abi = load_wsteth_abi()
113+
mainnet_wsteth = ETH_MAINNET_ASSETS["WSTETH"]
114+
assert mainnet_wsteth is not None
76115
wsteth_contract = w3.eth.contract(
77-
address=w3.to_checksum_address(self.wsteth_address),
116+
address=w3.to_checksum_address(mainnet_wsteth),
78117
abi=wsteth_abi,
79118
)
80119

81120
wsteth_price = wsteth_contract.functions.getStETHByWstETH(10**18).call()
82-
prices_accumulator.prices[self.wsteth_address] = int(wsteth_price)
121+
prices_accumulator.prices[wsteth_addr_actual] = int(wsteth_price)
83122

84123
self.validate_prices(prices_accumulator)
85124

0 commit comments

Comments
 (0)