Skip to content

Commit 25c0867

Browse files
committed
backend: multi-network support (RPC, DB, collector)
1 parent 8197380 commit 25c0867

3 files changed

Lines changed: 106 additions & 21 deletions

File tree

backend/src/services/collector_service.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ def run_collector():
2222
res = rpc_service.estimate_smart_fee(t, "unset", 1)
2323
if "feerate_sat_per_vb" in res:
2424
rate = res["feerate_sat_per_vb"]
25-
db_service.save_estimate(current_height, t, rate)
25+
chain = rpc_service.get_current_chain()
26+
db_service.save_estimate(current_height, t, rate, network=chain)
2627
# Log as collected for the target
2728
logger.info(f"[Collector] SAVED: target={t} height={current_height} rate={rate:.2f} sat/vB")
2829
except Exception as e:

backend/src/services/database_service.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,22 @@ def init_db():
2222
target INTEGER,
2323
estimate_feerate REAL,
2424
expected_height INTEGER,
25+
network TEXT DEFAULT 'main',
2526
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP -- UTC
2627
)
2728
''')
29+
# Migration: add network column if missing (existing DBs)
30+
try:
31+
cursor.execute("ALTER TABLE fee_estimates ADD COLUMN network TEXT DEFAULT 'main'")
32+
except sqlite3.OperationalError:
33+
pass # column already exists
2834
cursor.execute('CREATE INDEX IF NOT EXISTS idx_poll_height ON fee_estimates(poll_height)')
2935
cursor.execute('CREATE INDEX IF NOT EXISTS idx_target ON fee_estimates(target)')
30-
# Composite index for the most common query pattern (poll_height + target together)
36+
cursor.execute('CREATE INDEX IF NOT EXISTS idx_network ON fee_estimates(network)')
37+
# Composite index for the most common query pattern (poll_height + target + network)
3138
cursor.execute('''
32-
CREATE INDEX IF NOT EXISTS idx_poll_height_target
33-
ON fee_estimates(poll_height, target)
39+
CREATE INDEX IF NOT EXISTS idx_poll_height_target_network
40+
ON fee_estimates(poll_height, target, network)
3441
''')
3542
conn.commit()
3643
logger.info(f"Database initialised at {DB_PATH}")
@@ -39,23 +46,23 @@ def init_db():
3946
raise
4047

4148

42-
def save_estimate(poll_height, target, feerate):
49+
def save_estimate(poll_height, target, feerate, network="main"):
4350
expected_height = poll_height + target
4451
try:
4552
with sqlite3.connect(DB_PATH) as conn:
4653
cursor = conn.cursor()
4754
cursor.execute('''
48-
INSERT INTO fee_estimates (poll_height, target, estimate_feerate, expected_height)
49-
VALUES (?, ?, ?, ?)
50-
''', (poll_height, target, feerate, expected_height))
55+
INSERT INTO fee_estimates (poll_height, target, estimate_feerate, expected_height, network)
56+
VALUES (?, ?, ?, ?, ?)
57+
''', (poll_height, target, feerate, expected_height, network))
5158
conn.commit()
52-
logger.debug(f"Saved estimate: poll_height={poll_height}, target={target}, feerate={feerate}")
59+
logger.debug(f"Saved estimate: poll_height={poll_height}, target={target}, feerate={feerate}, network={network}")
5360
except sqlite3.Error as e:
5461
logger.error(f"Failed to save estimate (poll_height={poll_height}, target={target}): {e}", exc_info=True)
5562
raise
5663

5764

58-
def get_estimates_in_range(start_height, end_height, target=2):
65+
def get_estimates_in_range(start_height, end_height, target=2, network="main"):
5966
# Enforce a max block range to prevent runaway queries
6067
if end_height - start_height > MAX_RANGE_BLOCKS:
6168
logger.warning(
@@ -70,9 +77,9 @@ def get_estimates_in_range(start_height, end_height, target=2):
7077
cursor.execute('''
7178
SELECT DISTINCT poll_height, target, estimate_feerate, expected_height
7279
FROM fee_estimates
73-
WHERE poll_height >= ? AND poll_height <= ? AND target = ?
80+
WHERE poll_height >= ? AND poll_height <= ? AND target = ? AND (network = ? OR network IS NULL)
7481
ORDER BY poll_height ASC, timestamp ASC
75-
''', (start_height, end_height, target))
82+
''', (start_height, end_height, target, network))
7683
rows = cursor.fetchall()
7784

7885
if not rows:
@@ -84,13 +91,13 @@ def get_estimates_in_range(start_height, end_height, target=2):
8491
raise
8592

8693

87-
def get_db_height_range(target=2):
94+
def get_db_height_range(target=2, network="main"):
8895
try:
8996
with sqlite3.connect(DB_PATH) as conn:
9097
cursor = conn.cursor()
9198
cursor.execute(
92-
'SELECT MIN(poll_height), MAX(poll_height) FROM fee_estimates WHERE target = ?',
93-
(target,)
99+
'SELECT MIN(poll_height), MAX(poll_height) FROM fee_estimates WHERE target = ? AND (network = ? OR network IS NULL)',
100+
(target, network)
94101
)
95102
row = cursor.fetchone()
96103

backend/src/services/rpc_service.py

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def _find_config(filename: str = "rpc_config.ini") -> Optional[str]:
2222
for _ in range(5):
2323
candidate = os.path.join(directory, filename)
2424
if os.path.isfile(candidate):
25-
return candidate
25+
return os.path.abspath(candidate)
2626
directory = os.path.dirname(directory)
2727
return None
2828

@@ -117,16 +117,92 @@ def get_single_block_stats(height: int) -> Dict[str, Any]:
117117
# Public RPC wrappers
118118
# ---------------------------------------------------------------------------
119119

120+
# Chain name mapping: Bitcoin Core returns "main"|"test"|"signet"|"regtest"
121+
CHAIN_DISPLAY_NAMES = {"main": "MAINNET", "test": "TESTNET", "signet": "SIGNET", "regtest": "REGTEST"}
122+
123+
# Cached chain for DB writes (fixed for process lifetime)
124+
_current_chain_cache: Optional[str] = None
125+
126+
127+
def get_current_chain() -> str:
128+
"""Return current network chain for DB isolation. Cached for process lifetime."""
129+
global _current_chain_cache
130+
if _current_chain_cache is None:
131+
info = get_blockchain_info()
132+
_current_chain_cache = info["chain"]
133+
return _current_chain_cache
134+
135+
120136
def get_block_count() -> int:
121137
return _rpc_call("getblockcount", [])
122138

139+
140+
def get_blockchain_info() -> Dict[str, Any]:
141+
"""
142+
Returns chain and block count from getblockchaininfo RPC.
143+
Used for dynamic network detection (mainnet/testnet/signet/regtest).
144+
"""
145+
result = _rpc_call("getblockchaininfo", [])
146+
if not result:
147+
return {"chain": "main", "blockcount": get_block_count()}
148+
chain = result.get("chain", "main")
149+
blocks = result.get("blocks", get_block_count())
150+
display_chain = CHAIN_DISPLAY_NAMES.get(chain, chain.upper())
151+
logger.debug(f"Network detected: {display_chain} (chain={chain})")
152+
return {"chain": chain, "chain_display": display_chain, "blockcount": blocks}
153+
154+
155+
def get_mempool_health_statistics() -> List[Dict[str, Any]]:
156+
"""
157+
Fetches stats for the last 5 blocks to compare their weights with
158+
the current mempool's readiness.
159+
"""
160+
current_height = get_block_count()
161+
stats = []
162+
163+
# Using getmempoolfeeratediagram for accurate total weight
164+
mempool_diagram = _rpc_call("getmempoolfeeratediagram", [])
165+
total_mempool_weight = mempool_diagram[-1]["weight"] if mempool_diagram else 0
166+
167+
for h in range(current_height - 4, current_height + 1):
168+
try:
169+
b = get_single_block_stats(h)
170+
weight = b.get("total_weight", 0)
171+
172+
stats.append({
173+
"block_height": h,
174+
"block_weight": weight,
175+
"mempool_txs_weight": total_mempool_weight,
176+
"ratio": min(1.0, total_mempool_weight / 4_000_000)
177+
})
178+
except Exception:
179+
continue
180+
return stats
181+
182+
123183
def estimate_smart_fee(conf_target: int, mode: str = "unset", verbosity_level: int = 2) -> Dict[str, Any]:
124184
effective_target = _clamp_target(conf_target)
125-
result = _rpc_call("estimatesmartfee", [effective_target, mode, verbosity_level])
185+
# Bitcoin Core estimatesmartfee: (conf_target, estimate_mode) — verbosity added in newer versions
186+
result = _rpc_call("estimatesmartfee", [effective_target, mode])
126187
if result and "feerate" in result:
127188
# feerate is BTC/kVB → sat/vB: × 1e8 (BTC→sat) ÷ 1e3 (kVB→vB) = × 1e5
128189
result["feerate_sat_per_vb"] = result["feerate"] * 100_000
129-
190+
191+
# Include chain so frontend shows correct network (mainnet/testnet/signet/regtest)
192+
if result is not None:
193+
try:
194+
info = get_blockchain_info()
195+
result["chain"] = info["chain"]
196+
result["chain_display"] = info["chain_display"]
197+
except Exception as e:
198+
logger.debug(f"Could not attach chain to fee response: {e}")
199+
200+
# Include health stats for the frontend
201+
try:
202+
result["mempool_health_statistics"] = get_mempool_health_statistics()
203+
except Exception as e:
204+
logger.error(f"Failed to include health stats: {e}")
205+
130206
return result
131207

132208
def get_mempool_feerate_diagram_analysis() -> Dict[str, Any]:
@@ -192,7 +268,8 @@ def get_performance_data(start_height: int, count: int = 100, target: int = 2) -
192268
import services.database_service as db_service # late import — breaks circular dep
193269

194270
effective_target = _clamp_target(target)
195-
db_rows = db_service.get_estimates_in_range(start_height, start_height + count, effective_target)
271+
chain = get_current_chain()
272+
db_rows = db_service.get_estimates_in_range(start_height, start_height + count, effective_target, network=chain)
196273

197274
# Deduplicate to latest estimate per height (dict preserves insertion order in Py3.7+)
198275
latest_estimates_map = {row["poll_height"]: row["estimate_feerate"] for row in db_rows}
@@ -216,8 +293,8 @@ def calculate_local_summary(target: int = 2) -> Dict[str, Any]:
216293

217294
effective_target = _clamp_target(target)
218295
current_h = get_block_count()
219-
220-
db_rows = db_service.get_estimates_in_range(current_h - 1000, current_h, effective_target)
296+
chain = get_current_chain()
297+
db_rows = db_service.get_estimates_in_range(current_h - 1000, current_h, effective_target, network=chain)
221298

222299
total = 0
223300
over = 0

0 commit comments

Comments
 (0)