Skip to content
Open
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
74 changes: 54 additions & 20 deletions node/bridge_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import logging
import os
from typing import Optional, Tuple, Dict, Any
from decimal import Decimal
from decimal import Decimal, InvalidOperation
from dataclasses import dataclass
from enum import Enum

Expand Down Expand Up @@ -116,9 +116,19 @@ class ValidationResult:
VALID_BRIDGE_TYPES = {"bottube", "internal", "custom"}


def _text_payload_field(data: Dict[str, Any], field: str) -> Tuple[Optional[str], Optional[str]]:
value = data.get(field)
if not isinstance(value, str):
return None, f"{field} must be a string"
value = value.strip()
if not value:
return None, f"Missing required field: {field}"
return value, None


def validate_bridge_request(data: Optional[Dict]) -> ValidationResult:
"""Validate bridge transfer request payload."""
if not data:
if not isinstance(data, dict):
return ValidationResult(ok=False, error="Request body is required")

# Required fields
Expand All @@ -128,13 +138,22 @@ def validate_bridge_request(data: Optional[Dict]) -> ValidationResult:
return ValidationResult(ok=False, error=f"Missing required field: {field}")

# Validate direction
direction = data.get("direction")
direction, error = _text_payload_field(data, "direction")
if error:
return ValidationResult(ok=False, error=error)
direction = direction.lower()
if direction not in ["deposit", "withdraw"]:
return ValidationResult(ok=False, error=f"Invalid direction: {direction}. Must be 'deposit' or 'withdraw'")

# Validate chains
source_chain = data.get("source_chain", "").lower()
dest_chain = data.get("dest_chain", "").lower()
source_chain, error = _text_payload_field(data, "source_chain")
if error:
return ValidationResult(ok=False, error=error)
dest_chain, error = _text_payload_field(data, "dest_chain")
if error:
return ValidationResult(ok=False, error=error)
source_chain = source_chain.lower()
dest_chain = dest_chain.lower()

if source_chain not in VALID_CHAINS:
return ValidationResult(ok=False, error=f"Invalid source_chain: {source_chain}")
Expand All @@ -154,8 +173,12 @@ def validate_bridge_request(data: Optional[Dict]) -> ValidationResult:
return ValidationResult(ok=False, error="Withdraw dest_chain must be rustchain")

# Validate addresses
source_address = data.get("source_address", "")
dest_address = data.get("dest_address", "")
source_address, error = _text_payload_field(data, "source_address")
if error:
return ValidationResult(ok=False, error=error)
dest_address, error = _text_payload_field(data, "dest_address")
if error:
return ValidationResult(ok=False, error=error)

if not source_address or len(source_address) < 10:
return ValidationResult(ok=False, error="Invalid source_address (too short)")
Expand All @@ -164,9 +187,14 @@ def validate_bridge_request(data: Optional[Dict]) -> ValidationResult:

# Validate amount
try:
amount_rtc = float(data.get("amount_rtc", 0))
except (TypeError, ValueError):
if isinstance(data.get("amount_rtc"), bool):
raise ValueError
amount_decimal = Decimal(str(data.get("amount_rtc", 0)))
except (TypeError, ValueError, InvalidOperation):
return ValidationResult(ok=False, error="amount_rtc must be a number")
if not amount_decimal.is_finite():
return ValidationResult(ok=False, error="amount_rtc must be a finite number")
amount_rtc = float(amount_decimal)

if amount_rtc <= 0:
return ValidationResult(ok=False, error="amount_rtc must be positive")
Expand All @@ -175,11 +203,16 @@ def validate_bridge_request(data: Optional[Dict]) -> ValidationResult:

# Validate bridge type (optional)
bridge_type = data.get("bridge_type", "bottube")
if not isinstance(bridge_type, str):
return ValidationResult(ok=False, error="bridge_type must be a string")
bridge_type = bridge_type.strip().lower()
if bridge_type not in VALID_BRIDGE_TYPES:
return ValidationResult(ok=False, error=f"Invalid bridge_type: {bridge_type}")

# Validate memo (optional)
memo = data.get("memo")
if memo is not None and not isinstance(memo, str):
return ValidationResult(ok=False, error="memo must be a string")
if memo and len(memo) > 256:
return ValidationResult(ok=False, error="Memo must be <= 256 characters")

Expand Down Expand Up @@ -699,9 +732,10 @@ def initiate_bridge():
return jsonify({"error": validation.error}), 400

# Validate address formats
details = validation.details
for chain, addr in [
(data["source_chain"], data["source_address"]),
(data["dest_chain"], data["dest_address"])
(details["source_chain"], details["source_address"]),
(details["dest_chain"], details["dest_address"])
]:
valid, msg = validate_chain_address_format(chain, addr)
if not valid:
Expand All @@ -711,7 +745,7 @@ def initiate_bridge():
admin_key = request.headers.get("X-Admin-Key", "")
expected_admin_key = os.environ.get("RC_ADMIN_KEY", "")
admin_initiated = bool(expected_admin_key) and hmac.compare_digest(admin_key, expected_admin_key)
if data["direction"] == "deposit":
if details["direction"] == "deposit":
# Deposits create balance locks by source_address; require operator
# authorization until a wallet-owner signature flow exists.
if not expected_admin_key:
Expand All @@ -721,14 +755,14 @@ def initiate_bridge():

# Create bridge transfer
req = BridgeTransferRequest(
direction=data["direction"],
source_chain=data["source_chain"],
dest_chain=data["dest_chain"],
source_address=data["source_address"],
dest_address=data["dest_address"],
amount_rtc=data["amount_rtc"],
memo=data.get("memo"),
bridge_type=data.get("bridge_type", "bottube")
direction=details["direction"],
source_chain=details["source_chain"],
dest_chain=details["dest_chain"],
source_address=details["source_address"],
dest_address=details["dest_address"],
amount_rtc=details["amount_rtc"],
memo=details.get("memo"),
bridge_type=details.get("bridge_type", "bottube")
)

conn = sqlite3.connect(DB_PATH)
Expand Down
49 changes: 49 additions & 0 deletions tests/test_bridge_lock_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,55 @@ def test_amount_below_minimum(self, setup_test_db):
assert result.ok is False
assert "must be >=" in result.error

def test_rejects_non_object_payload(self, setup_test_db):
bridge_api = setup_test_db['bridge_api']
result = bridge_api.validate_bridge_request(["not", "object"])
assert result.ok is False
assert result.error == "Request body is required"

def test_rejects_non_string_route_fields(self, setup_test_db):
bridge_api = setup_test_db['bridge_api']
data = {
"direction": {"op": "deposit"},
"source_chain": "rustchain",
"dest_chain": "solana",
"source_address": "RTC_test123",
"dest_address": "4TRwNqXqXqXqXqXqXqXqXqXqXqXqXqXqXqXq",
"amount_rtc": 10.0
}
result = bridge_api.validate_bridge_request(data)
assert result.ok is False
assert result.error == "direction must be a string"

def test_rejects_non_finite_amount(self, setup_test_db):
bridge_api = setup_test_db['bridge_api']
data = {
"direction": "deposit",
"source_chain": "rustchain",
"dest_chain": "solana",
"source_address": "RTC_test123",
"dest_address": "4TRwNqXqXqXqXqXqXqXqXqXqXqXqXqXqXqXq",
"amount_rtc": "NaN"
}
result = bridge_api.validate_bridge_request(data)
assert result.ok is False
assert result.error == "amount_rtc must be a finite number"

def test_rejects_non_string_optional_fields(self, setup_test_db):
bridge_api = setup_test_db['bridge_api']
data = {
"direction": "deposit",
"source_chain": "rustchain",
"dest_chain": "solana",
"source_address": "RTC_test123",
"dest_address": "4TRwNqXqXqXqXqXqXqXqXqXqXqXqXqXqXqXq",
"amount_rtc": 10.0,
"memo": {"note": "bad"},
}
result = bridge_api.validate_bridge_request(data)
assert result.ok is False
assert result.error == "memo must be a string"


# =============================================================================
# Address Validation Tests
Expand Down