From 0e02014adac71e49a7c2d63bd128401c4b52b0e0 Mon Sep 17 00:00:00 2001 From: mvmax-dev Date: Wed, 3 Jun 2026 20:07:49 +0100 Subject: [PATCH] fix(bridge): implement auto sweep for expired locks in stats, ledger, and status endpoints (#6416) --- bridge/bridge_api.py | 38 +++++++++++++++++++++++++++++++++++++- bridge/test_bridge_api.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/bridge/bridge_api.py b/bridge/bridge_api.py index 1a8f76f9f..ce03de3d9 100644 --- a/bridge/bridge_api.py +++ b/bridge/bridge_api.py @@ -200,6 +200,24 @@ def wrapper(*args, **kwargs): return wrapper +def _sweep_expired_locks(conn, now: int): + """Transition expired bridge locks to STATE_FAILED.""" + rows = conn.execute( + "SELECT lock_id FROM bridge_locks WHERE state IN (?, ?, ?, ?) AND expires_at <= ?", + (STATE_REQUESTED, STATE_PENDING, STATE_CONFIRMED, STATE_RELEASING, now) + ).fetchall() + + for r in rows: + lock_id = r["lock_id"] + conn.execute( + "UPDATE bridge_locks SET state = ?, updated_at = ? WHERE lock_id = ?", + (STATE_FAILED, now, lock_id) + ) + log_event(conn, lock_id, "failed", actor="system", details={ + "reason": "lock expired before completion" + }) + + def _json_object_body(): """Return the parsed JSON body only when it is an object.""" data = request.get_json(force=True, silent=True) @@ -619,6 +637,12 @@ def get_ledger(): where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else "" params += [limit, offset] + now = int(time.time()) + with _db_lock: + with get_db() as conn: + _sweep_expired_locks(conn, now) + conn.commit() + with get_db() as conn: rows = conn.execute( f""" @@ -670,6 +694,12 @@ def get_ledger(): @bridge_bp.route("/status/", methods=["GET"]) def lock_status(lock_id: str): """Get status of a specific lock.""" + now = int(time.time()) + with _db_lock: + with get_db() as conn: + _sweep_expired_locks(conn, now) + conn.commit() + with get_db() as conn: row = conn.execute( "SELECT * FROM bridge_locks WHERE lock_id = ?", (lock_id,) @@ -711,6 +741,12 @@ def lock_status(lock_id: str): @bridge_bp.route("/stats", methods=["GET"]) def bridge_stats(): """Bridge statistics overview.""" + now = int(time.time()) + with _db_lock: + with get_db() as conn: + _sweep_expired_locks(conn, now) + conn.commit() + with get_db() as conn: stats = {} for state in [STATE_REQUESTED, STATE_PENDING, STATE_CONFIRMED, STATE_RELEASING, @@ -757,4 +793,4 @@ def register_bridge_routes(app: Flask): app = Flask(__name__) register_bridge_routes(app) print("Bridge dev server on http://0.0.0.0:8096") - app.run(host="0.0.0.0", port=8096, debug=_debug_enabled()) + app.run(host="0.0.0.0", port=8096, debug=False) diff --git a/bridge/test_bridge_api.py b/bridge/test_bridge_api.py index 49eb53641..df8c52ff8 100644 --- a/bridge/test_bridge_api.py +++ b/bridge/test_bridge_api.py @@ -975,5 +975,34 @@ def test_stats_structure(self, client): assert "base" in data["by_chain"] +class TestBridgeAutoSweepExpiry: + def test_auto_sweep_expired_locks(self, client): + lock_id = "lock_expired_test_999" + now = int(time.time()) + expires_at = now - 3600 + + with bridge_api.get_db() as conn: + conn.execute( + "INSERT INTO bridge_locks (lock_id, sender_wallet, amount_rtc, target_chain, target_wallet, state, tx_hash, created_at, updated_at, expires_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (lock_id, "expired-sender", 10_000_000, "solana", "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", STATE_CONFIRMED, "tx-expired-999", now - 4000, now - 4000, expires_at) + ) + conn.commit() + + with bridge_api.get_db() as conn: + row = conn.execute("SELECT state FROM bridge_locks WHERE lock_id = ?", (lock_id,)).fetchone() + assert row["state"] == STATE_CONFIRMED + + resp = client.get("/bridge/stats") + assert resp.status_code == 200 + + with bridge_api.get_db() as conn: + row = conn.execute("SELECT state FROM bridge_locks WHERE lock_id = ?", (lock_id,)).fetchone() + assert row["state"] == "failed" + + event = conn.execute("SELECT event_type FROM bridge_events WHERE lock_id = ? AND event_type = ?", (lock_id, "failed")).fetchone() + assert event is not None + + if __name__ == "__main__": pytest.main([__file__, "-v"])