Skip to content

Commit 3985a19

Browse files
authored
Merge pull request #309 from Chucks1093/feat/htlc-clients-matching-solana-15-23-35-55
feat: add HTLC tracking, chain clients, and order matching
2 parents de60067 + c1fc68a commit 3985a19

101 files changed

Lines changed: 3529 additions & 1283 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,15 @@ jobs:
219219
${{ runner.os }}-cargo-tests-
220220
221221
- name: Run integration tests
222+
if: ${{ hashFiles('tests/Cargo.toml') != '' }}
222223
run: cargo test --manifest-path tests/Cargo.toml --verbose
223-
224+
225+
- name: Skip missing integration crate
226+
if: ${{ hashFiles('tests/Cargo.toml') == '' }}
227+
run: echo "tests/Cargo.toml not present; skipping integration-tests job."
228+
224229
- name: Run stress tests
230+
if: ${{ hashFiles('tests/Cargo.toml') != '' }}
225231
run: cargo test --manifest-path tests/Cargo.toml -- stress --test-threads=1
226232

227233
code-quality:

backend/app/config/settings.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from pydantic import field_validator
12
from pydantic_settings import BaseSettings
23

34

@@ -6,7 +7,9 @@ class Settings(BaseSettings):
67
debug: bool = False
78

89
# Database
9-
database_url: str = "postgresql+asyncpg://chainbridge:password@localhost:5432/chainbridge"
10+
database_url: str = (
11+
"postgresql+asyncpg://chainbridge:password@localhost:5432/chainbridge"
12+
)
1013

1114
# Redis
1215
redis_url: str = "redis://localhost:6379/0"
@@ -25,6 +28,17 @@ class Settings(BaseSettings):
2528
soroban_rpc_url: str = "https://soroban-testnet.stellar.org"
2629
chainbridge_contract_id: str = ""
2730

31+
@field_validator("debug", mode="before")
32+
@classmethod
33+
def parse_debug(cls, value):
34+
if isinstance(value, str):
35+
normalized = value.strip().lower()
36+
if normalized in {"release", "prod", "production", "false", "0", "off"}:
37+
return False
38+
if normalized in {"debug", "dev", "development", "true", "1", "on"}:
39+
return True
40+
return value
41+
2842
class Config:
2943
env_file = ".env"
3044

backend/app/indexer/base.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,7 @@ async def get_latest_block(self) -> int:
5656
"""Get the latest confirmed block number on the chain."""
5757

5858
@abstractmethod
59-
async def fetch_events(
60-
self, from_block: int, to_block: int
61-
) -> list[IndexedEvent]:
59+
async def fetch_events(self, from_block: int, to_block: int) -> list[IndexedEvent]:
6260
"""Fetch events from a block range."""
6361

6462
@abstractmethod

backend/app/indexer/bitcoin_indexer.py

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
import os
5+
from asyncio import sleep
56
from datetime import datetime
67

78
import httpx
@@ -20,27 +21,41 @@ def __init__(self):
2021
self.rpc_user = os.getenv("BITCOIN_RPC_USER", "")
2122
self.rpc_password = os.getenv("BITCOIN_RPC_PASSWORD", "")
2223
self.confirmations = int(os.getenv("BITCOIN_CONFIRMATIONS", "6"))
24+
self.max_retries = int(os.getenv("BITCOIN_RPC_RETRIES", "3"))
25+
self._last_block_hash: str | None = None
2326

2427
async def _rpc_call(self, method: str, params: list = None) -> dict:
2528
"""Make a JSON-RPC call to the Bitcoin node."""
26-
async with httpx.AsyncClient() as client:
27-
response = await client.post(
28-
self.rpc_url,
29-
json={
30-
"jsonrpc": "2.0",
31-
"id": 1,
32-
"method": method,
33-
"params": params or [],
34-
},
35-
auth=(self.rpc_user, self.rpc_password)
36-
if self.rpc_user
37-
else None,
38-
timeout=30,
39-
)
40-
data = response.json()
41-
if "error" in data and data["error"]:
42-
raise Exception(f"Bitcoin RPC error: {data['error']}")
43-
return data.get("result", {})
29+
last_error: Exception | None = None
30+
for attempt in range(1, self.max_retries + 1):
31+
try:
32+
async with httpx.AsyncClient() as client:
33+
response = await client.post(
34+
self.rpc_url,
35+
json={
36+
"jsonrpc": "2.0",
37+
"id": 1,
38+
"method": method,
39+
"params": params or [],
40+
},
41+
auth=(
42+
(self.rpc_user, self.rpc_password)
43+
if self.rpc_user
44+
else None
45+
),
46+
timeout=30,
47+
)
48+
response.raise_for_status()
49+
data = response.json()
50+
if "error" in data and data["error"]:
51+
raise RuntimeError(f"Bitcoin RPC error: {data['error']}")
52+
return data.get("result", {})
53+
except Exception as exc:
54+
last_error = exc
55+
if attempt == self.max_retries:
56+
break
57+
await sleep(attempt)
58+
raise RuntimeError("Bitcoin RPC call failed") from last_error
4459

4560
async def get_latest_block(self) -> int:
4661
try:
@@ -51,13 +66,13 @@ async def get_latest_block(self) -> int:
5166
logger.error("[bitcoin] Failed to get block count: %s", e)
5267
raise
5368

54-
async def fetch_events(
55-
self, from_block: int, to_block: int
56-
) -> list[IndexedEvent]:
69+
async def fetch_events(self, from_block: int, to_block: int) -> list[IndexedEvent]:
70+
await self.detect_reorg(from_block)
5771
events = []
5872
for height in range(from_block, to_block + 1):
5973
try:
6074
block_hash = await self._rpc_call("getblockhash", [height])
75+
self._last_block_hash = block_hash
6176
block = await self._rpc_call("getblock", [block_hash, 2])
6277

6378
for tx in block.get("tx", []):
@@ -95,6 +110,21 @@ async def handle_reorg(self, reorg_block: int) -> None:
95110
# In production: delete indexed events >= reorg_block from DB
96111
# and re-index from reorg_block
97112

113+
async def detect_reorg(self, height: int) -> None:
114+
if height <= 0 or not self._last_block_hash:
115+
return
116+
current_hash = await self._rpc_call("getblockhash", [height - 1])
117+
if current_hash != self._last_block_hash:
118+
await self.handle_reorg(height - 1)
119+
120+
async def get_mempool_transactions(self) -> list[str]:
121+
mempool = await self._rpc_call("getrawmempool")
122+
return list(mempool or [])
123+
124+
async def broadcast_transaction(self, raw_tx: str) -> str:
125+
tx_hash = await self._rpc_call("sendrawtransaction", [raw_tx])
126+
return str(tx_hash)
127+
98128
def _is_bridge_tx(self, asm: str) -> bool:
99129
"""Check if an OP_RETURN contains a ChainBridge marker."""
100130
# ChainBridge transactions use a specific prefix in OP_RETURN

backend/app/indexer/ethereum_indexer.py

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
import os
5+
from asyncio import sleep
56
from datetime import datetime
67

78
import httpx
@@ -21,24 +22,36 @@ def __init__(self):
2122
)
2223
self.contract_address = os.getenv("ETHEREUM_BRIDGE_CONTRACT", "")
2324
self.confirmations = int(os.getenv("ETHEREUM_CONFIRMATIONS", "12"))
25+
self.max_retries = int(os.getenv("ETHEREUM_RPC_RETRIES", "3"))
26+
self._last_block_hash: str | None = None
2427

2528
async def _rpc_call(self, method: str, params: list = None) -> dict:
2629
"""Make a JSON-RPC call to the Ethereum node."""
27-
async with httpx.AsyncClient() as client:
28-
response = await client.post(
29-
self.rpc_url,
30-
json={
31-
"jsonrpc": "2.0",
32-
"id": 1,
33-
"method": method,
34-
"params": params or [],
35-
},
36-
timeout=30,
37-
)
38-
data = response.json()
39-
if "error" in data and data["error"]:
40-
raise Exception(f"Ethereum RPC error: {data['error']}")
41-
return data.get("result")
30+
last_error: Exception | None = None
31+
for attempt in range(1, self.max_retries + 1):
32+
try:
33+
async with httpx.AsyncClient() as client:
34+
response = await client.post(
35+
self.rpc_url,
36+
json={
37+
"jsonrpc": "2.0",
38+
"id": 1,
39+
"method": method,
40+
"params": params or [],
41+
},
42+
timeout=30,
43+
)
44+
response.raise_for_status()
45+
data = response.json()
46+
if "error" in data and data["error"]:
47+
raise RuntimeError(f"Ethereum RPC error: {data['error']}")
48+
return data.get("result")
49+
except Exception as exc:
50+
last_error = exc
51+
if attempt == self.max_retries:
52+
break
53+
await sleep(attempt)
54+
raise RuntimeError("Ethereum RPC call failed") from last_error
4255

4356
async def get_latest_block(self) -> int:
4457
try:
@@ -49,12 +62,11 @@ async def get_latest_block(self) -> int:
4962
logger.error("[ethereum] Failed to get block number: %s", e)
5063
raise
5164

52-
async def fetch_events(
53-
self, from_block: int, to_block: int
54-
) -> list[IndexedEvent]:
65+
async def fetch_events(self, from_block: int, to_block: int) -> list[IndexedEvent]:
5566
if not self.contract_address:
5667
return []
5768

69+
await self.detect_reorg(from_block)
5870
events = []
5971
try:
6072
logs = await self._rpc_call(
@@ -71,10 +83,19 @@ async def fetch_events(
7183
for log in logs or []:
7284
try:
7385
block_num = int(log["blockNumber"], 16)
86+
block_data = await self._rpc_call(
87+
"eth_getBlockByNumber",
88+
[log["blockNumber"], False],
89+
)
90+
self._last_block_hash = (
91+
block_data.get("hash") if block_data else None
92+
)
7493
events.append(
7594
IndexedEvent(
7695
chain="ethereum",
77-
event_type=log["topics"][0] if log.get("topics") else "unknown",
96+
event_type=(
97+
log["topics"][0] if log.get("topics") else "unknown"
98+
),
7899
tx_hash=log.get("transactionHash", ""),
79100
block_number=block_num,
80101
contract_address=log.get("address"),
@@ -101,3 +122,23 @@ async def handle_reorg(self, reorg_block: int) -> None:
101122
reorg_block,
102123
)
103124
# In production: delete indexed events >= reorg_block from DB
125+
126+
async def detect_reorg(self, from_block: int) -> None:
127+
if from_block <= 0 or not self._last_block_hash:
128+
return
129+
previous_block = await self._rpc_call(
130+
"eth_getBlockByNumber", [hex(from_block - 1), False]
131+
)
132+
if previous_block and previous_block.get("hash") != self._last_block_hash:
133+
await self.handle_reorg(from_block - 1)
134+
135+
async def get_mempool_transactions(self) -> list[str]:
136+
txpool = await self._rpc_call("txpool_content")
137+
if not isinstance(txpool, dict):
138+
return []
139+
pending = txpool.get("pending", {})
140+
return list(pending.keys())
141+
142+
async def broadcast_transaction(self, raw_tx: str) -> str:
143+
tx_hash = await self._rpc_call("eth_sendRawTransaction", [raw_tx])
144+
return str(tx_hash)

backend/app/indexer/manager.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77

88
import asyncio
99
import logging
10-
from datetime import datetime
10+
11+
from app.config.redis import CacheService, get_redis
1112

1213
from .base import BaseIndexer, IndexerStatus
1314
from .stellar_indexer import StellarIndexer
@@ -35,6 +36,7 @@ def __init__(self):
3536
"ethereum": EthereumIndexer(),
3637
}
3738
self._tasks: dict[str, asyncio.Task] = {}
39+
self._status_task: asyncio.Task | None = None
3840

3941
async def start_all(
4042
self,
@@ -51,6 +53,7 @@ async def start_all(
5153
)
5254
self._tasks[chain] = task
5355
logger.info("Started %s indexer from block %d", chain, from_block)
56+
self._status_task = asyncio.create_task(self._publish_status_loop())
5457

5558
async def stop_all(self) -> None:
5659
"""Stop all running indexers."""
@@ -64,6 +67,14 @@ async def stop_all(self) -> None:
6467
except asyncio.CancelledError:
6568
pass
6669

70+
if self._status_task:
71+
self._status_task.cancel()
72+
try:
73+
await self._status_task
74+
except asyncio.CancelledError:
75+
pass
76+
self._status_task = None
77+
6778
self._tasks.clear()
6879
logger.info("All indexers stopped")
6980

@@ -80,9 +91,7 @@ def get_all_status(self) -> dict[str, dict]:
8091
"blocks_behind": s.blocks_behind,
8192
"events_processed": s.events_processed,
8293
"last_error": s.last_error,
83-
"last_sync_at": s.last_sync_at.isoformat()
84-
if s.last_sync_at
85-
else None,
94+
"last_sync_at": s.last_sync_at.isoformat() if s.last_sync_at else None,
8695
}
8796
return statuses
8897

@@ -109,3 +118,12 @@ async def catch_up(self, chain: str, from_block: int, to_block: int) -> int:
109118
to_block,
110119
)
111120
return len(events)
121+
122+
async def _publish_status_loop(self) -> None:
123+
cache = CacheService(get_redis())
124+
while True:
125+
statuses = self.get_all_status()
126+
for chain, status in statuses.items():
127+
status["last_updated"] = status["last_sync_at"]
128+
await cache.set(f"indexer:status:{chain}", status, ttl=60)
129+
await asyncio.sleep(5)

backend/app/indexer/stellar_indexer.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,7 @@ class StellarIndexer(BaseIndexer):
1717

1818
def __init__(self):
1919
super().__init__(chain="stellar")
20-
rpc_url = os.getenv(
21-
"SOROBAN_RPC_URL", "https://soroban-testnet.stellar.org"
22-
)
20+
rpc_url = os.getenv("SOROBAN_RPC_URL", "https://soroban-testnet.stellar.org")
2321
self.server = SorobanServer(rpc_url)
2422
self.contract_id = os.getenv("CHAINBRIDGE_CONTRACT_ID", "")
2523

@@ -31,21 +29,21 @@ async def get_latest_block(self) -> int:
3129
logger.error("[stellar] Failed to get latest ledger: %s", e)
3230
raise
3331

34-
async def fetch_events(
35-
self, from_block: int, to_block: int
36-
) -> list[IndexedEvent]:
32+
async def fetch_events(self, from_block: int, to_block: int) -> list[IndexedEvent]:
3733
events = []
3834
try:
3935
response = self.server.get_events(
4036
start_ledger=from_block,
41-
filters=[
42-
{
43-
"type": "contract",
44-
"contractIds": [self.contract_id],
45-
}
46-
]
47-
if self.contract_id
48-
else [],
37+
filters=(
38+
[
39+
{
40+
"type": "contract",
41+
"contractIds": [self.contract_id],
42+
}
43+
]
44+
if self.contract_id
45+
else []
46+
),
4947
limit=200,
5048
)
5149

0 commit comments

Comments
 (0)