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
115 changes: 54 additions & 61 deletions node/claims_settlement.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,9 +315,9 @@ def sign_and_broadcast_transaction(
"""
Sign transaction with treasury key and broadcast to network.

Uses Ed25519 signing via settlement_signer when a treasury key is
available. Falls back to a deterministic SHA-256 hash of the batch
data when no key is configured (test/development mode).
Requires both TREASURY_KEY_PATH (Ed25519 PEM private key) and
NODE_API_URL to be configured, and a successful 2xx response from
the node endpoint to confirm on-chain broadcast.

Environment variables:
TREASURY_KEY_PATH — path to Ed25519 PEM private key
Expand All @@ -331,66 +331,59 @@ def sign_and_broadcast_transaction(
key_path = os.environ.get("TREASURY_KEY_PATH", "")
node_url = os.environ.get("NODE_API_URL", "").rstrip("/")

if key_path:
# ── Real Ed25519 signing path ──────────────────────────────
try:
from settlement_signer import sign_settlement_batch

success, tx_hash, error = sign_settlement_batch(tx_data, key_path)
if not success:
return False, None, error

print(f"[SETTLEMENT] Signed batch {tx_data.get('batch_id', '?')}: "
f"{len(tx_data.get('outputs', []))} outputs, "
f"{tx_data.get('total_amount_urtc', 0)} uRTC")

if node_url and tx_hash:
# Broadcast to node
import requests
try:
resp = requests.post(
f"{node_url}/api/tx/submit",
json={
"batch_id": tx_data.get("batch_id"),
"claim_ids": [c for c in tx_data.get("claim_ids", [])],
"outputs": tx_data.get("outputs", []),
"fee_urtc": tx_data.get("fee_urtc", 0),
"signature": tx_hash,
},
timeout=30,
)
if resp.status_code in (200, 201):
result = resp.json()
on_chain_hash = result.get("tx_hash", tx_hash)
print(f"[SETTLEMENT] Broadcast confirmed: {on_chain_hash}")
return True, on_chain_hash, None
else:
print(f"[SETTLEMENT] Broadcast returned {resp.status_code}, "
f"using signature as tx_hash")
except Exception as e:
print(f"[SETTLEMENT] Broadcast failed ({e}), "
f"using signature as tx_hash")

return True, tx_hash, None
if not key_path:
error_msg = "TREASURY_KEY_PATH not configured"
print(f"[SETTLEMENT] Error: {error_msg}")
return False, None, error_msg

if not node_url:
error_msg = "NODE_API_URL not configured"
print(f"[SETTLEMENT] Error: {error_msg}")
return False, None, error_msg

try:
from settlement_signer import sign_settlement_batch

success, tx_hash, error = sign_settlement_batch(tx_data, key_path)
if not success or not tx_hash:
return False, None, error or "Signing failed"

print(f"[SETTLEMENT] Signed batch {tx_data.get('batch_id', '?')}: "
f"{len(tx_data.get('outputs', []))} outputs, "
f"{tx_data.get('total_amount_urtc', 0)} uRTC")

# Broadcast to node
import requests
try:
resp = requests.post(
f"{node_url}/api/tx/submit",
json={
"batch_id": tx_data.get("batch_id"),
"claim_ids": [c for c in tx_data.get("claim_ids", [])],
"outputs": tx_data.get("outputs", []),
"fee_urtc": tx_data.get("fee_urtc", 0),
"signature": tx_hash,
},
timeout=30,
)
if resp.status_code in (200, 201):
result = resp.json()
on_chain_hash = result.get("tx_hash", tx_hash)
print(f"[SETTLEMENT] Broadcast confirmed: {on_chain_hash}")
return True, on_chain_hash, None
else:
err = f"Broadcast returned {resp.status_code}: {resp.text}"
print(f"[SETTLEMENT] {err}")
return False, None, err
except Exception as e:
print(f"[SETTLEMENT] Signing module error ({e}), "
f"falling back to hash")

# ── Fallback: SHA-256 hash of batch data ──────────────────────
# Used when no treasury key is configured (test/dev).
import hashlib
print(f"[SETTLEMENT] Constructing transaction with "
f"{len(tx_data.get('outputs', []))} outputs")
print(f"[SETTLEMENT] Total amount: {tx_data.get('total_amount_urtc', 0)} uRTC")
print(f"[SETTLEMENT] Fee: {tx_data.get('fee_urtc', 0)} uRTC")

tx_hash = hashlib.sha256(
f"{tx_data.get('batch_id', '')}"
f"-{tx_data.get('total_amount_urtc', 0)}"
f"-{tx_data.get('created_at', 0)}".encode()
).hexdigest()
return True, "0x" + tx_hash, None
err = f"Broadcast failed: {e}"
print(f"[SETTLEMENT] {err}")
return False, None, err

except Exception as e:
err = f"Signing error: {e}"
print(f"[SETTLEMENT] {err}")
return False, None, err


def reserve_claims_for_settlement(
Expand Down
117 changes: 73 additions & 44 deletions node/tests/test_claims_settlement.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,9 @@ def test_large_batch(self):
# ═══════════════════════════════════════════════════════════════════════

class TestSignAndBroadcastTransaction:
def test_returns_success_with_deterministic_hash(self):
def test_fails_when_treasury_key_missing(self, monkeypatch):
monkeypatch.delenv("TREASURY_KEY_PATH", raising=False)
monkeypatch.delenv("NODE_API_URL", raising=False)
tx = {
"batch_id": "batch_2025_01_01_001",
"total_amount_urtc": 5000,
Expand All @@ -506,12 +508,15 @@ def test_returns_success_with_deterministic_hash(self):
"created_at": 1700000000,
}
success, tx_hash, error = sign_and_broadcast_transaction(tx, ":memory:")
assert success is True
assert tx_hash.startswith("0x")
assert len(tx_hash) == 66 # 0x + 64 hex chars
assert error is None

def test_deterministic_hash_same_input(self):
assert success is False
assert tx_hash is None
assert "TREASURY_KEY_PATH" in error

def test_fails_when_node_url_missing(self, monkeypatch, tmp_path):
key_file = tmp_path / "treasury.pem"
key_file.write_text("fake-key")
monkeypatch.setenv("TREASURY_KEY_PATH", str(key_file))
monkeypatch.delenv("NODE_API_URL", raising=False)
tx = {
"batch_id": "batch_2025_01_01_001",
"total_amount_urtc": 5000,
Expand All @@ -520,44 +525,68 @@ def test_deterministic_hash_same_input(self):
"claim_ids": ["c-1"],
"created_at": 1700000000,
}
_, h1, _ = sign_and_broadcast_transaction(tx, ":memory:")
_, h2, _ = sign_and_broadcast_transaction(tx, ":memory:")
assert h1 == h2 # deterministic

def test_different_input_different_hash(self):
tx1 = {
"batch_id": "batch_a",
"total_amount_urtc": 1000,
"outputs": [],
"fee_urtc": 1000,
"claim_ids": ["c-1"],
"created_at": 1,
}
tx2 = {
"batch_id": "batch_b",
"total_amount_urtc": 1000,
"outputs": [],
"fee_urtc": 1000,
"claim_ids": ["c-1"],
"created_at": 1,
}
_, h1, _ = sign_and_broadcast_transaction(tx1, ":memory:")
_, h2, _ = sign_and_broadcast_transaction(tx2, ":memory:")
assert h1 != h2
success, tx_hash, error = sign_and_broadcast_transaction(tx, ":memory:")
assert success is False
assert tx_hash is None
assert "NODE_API_URL" in error

def test_fails_on_node_broadcast_non_2xx(self, monkeypatch, tmp_path):
import sys
key_file = tmp_path / "treasury.pem"
key_file.write_text("fake-key")
monkeypatch.setenv("TREASURY_KEY_PATH", str(key_file))
monkeypatch.setenv("NODE_API_URL", "http://fake-node:8000")

class MockSigner:
@staticmethod
def sign_settlement_batch(tx_data, key_path):
return True, "0xsig123", None

monkeypatch.setitem(sys.modules, "settlement_signer", MockSigner)

class MockResponse:
status_code = 500
text = "Internal Server Error"

import requests
monkeypatch.setattr(requests, "post", lambda *args, **kwargs: MockResponse())

tx = {"batch_id": "b1", "outputs": [], "fee_urtc": 100, "claim_ids": ["c1"]}
success, tx_hash, error = sign_and_broadcast_transaction(tx, ":memory:")
assert success is False
assert tx_hash is None
assert "500" in error

def test_succeeds_on_confirmed_broadcast(self, monkeypatch, tmp_path):
import sys
key_file = tmp_path / "treasury.pem"
key_file.write_text("fake-key")
monkeypatch.setenv("TREASURY_KEY_PATH", str(key_file))
monkeypatch.setenv("NODE_API_URL", "http://fake-node:8000")

class MockSigner:
@staticmethod
def sign_settlement_batch(tx_data, key_path):
return True, "0xsig123", None

monkeypatch.setitem(sys.modules, "settlement_signer", MockSigner)

class MockResponse:
status_code = 200
@staticmethod
def json():
return {"tx_hash": "0xrealonchainhash789"}

import requests
monkeypatch.setattr(requests, "post", lambda *args, **kwargs: MockResponse())

tx = {"batch_id": "b1", "outputs": [], "fee_urtc": 100, "claim_ids": ["c1"]}
success, tx_hash, error = sign_and_broadcast_transaction(tx, ":memory:")
assert success is True
assert tx_hash == "0xrealonchainhash789"
assert error is None


def test_outputs_printed_but_not_critical(self, capsys):
tx = {
"batch_id": "batch_2025_01_01_001",
"total_amount_urtc": 5000,
"outputs": [{"address": "RTCaaa", "amount_urtc": 5000}],
"fee_urtc": 1100,
"claim_ids": ["c-1"],
"created_at": 1700000000,
}
sign_and_broadcast_transaction(tx, ":memory:")
captured = capsys.readouterr()
assert "Constructing transaction with 1 outputs" in captured.out
assert "Total amount: 5000" in captured.out


# ═══════════════════════════════════════════════════════════════════════
Expand Down