diff --git a/node/claims_settlement.py b/node/claims_settlement.py index cdf555ac2..96f673a12 100644 --- a/node/claims_settlement.py +++ b/node/claims_settlement.py @@ -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 @@ -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( diff --git a/node/tests/test_claims_settlement.py b/node/tests/test_claims_settlement.py index 7acdde7f5..fdb9057f7 100644 --- a/node/tests/test_claims_settlement.py +++ b/node/tests/test_claims_settlement.py @@ -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, @@ -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, @@ -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 # ═══════════════════════════════════════════════════════════════════════