Skip to content
Merged
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
38 changes: 37 additions & 1 deletion bridge/bridge_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"""
Expand Down Expand Up @@ -670,6 +694,12 @@ def get_ledger():
@bridge_bp.route("/status/<lock_id>", 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,)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
29 changes: 29 additions & 0 deletions bridge/test_bridge_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Loading