From 67f93f6cc3519eaf23829734972f89738ca62c41 Mon Sep 17 00:00:00 2001 From: David Tang Date: Thu, 12 Feb 2026 22:38:59 +0000 Subject: [PATCH] hardening: payout preflight validation + no-500 transfer guards --- docs/PAYOUT_PREFLIGHT.md | 41 ++++++++ node/payout_preflight.py | 93 +++++++++++++++++++ node/rustchain_v2_integrated_v2.2.1_rip200.py | 66 ++++--------- node/tests/test_payout_preflight.py | 54 +++++++++++ tools/payout_preflight_check.py | 39 ++++++++ 5 files changed, 245 insertions(+), 48 deletions(-) create mode 100644 docs/PAYOUT_PREFLIGHT.md create mode 100644 node/payout_preflight.py create mode 100644 node/tests/test_payout_preflight.py create mode 100644 tools/payout_preflight_check.py diff --git a/docs/PAYOUT_PREFLIGHT.md b/docs/PAYOUT_PREFLIGHT.md new file mode 100644 index 0000000..375ac71 --- /dev/null +++ b/docs/PAYOUT_PREFLIGHT.md @@ -0,0 +1,41 @@ +# Payout Preflight (Dry-Run Validation) + +Goal: payout operations should never return server 500s due to malformed input. This repo includes a small, dependency-light preflight validator to catch bad payloads early and provide predictable 4xx errors. + +## What It Covers + +- `POST /wallet/transfer` (admin transfer) + - Rejects malformed JSON bodies (non-object) + - Rejects missing `from_miner` / `to_miner` + - Rejects non-numeric, non-finite, or non-positive `amount_rtc` + +- `POST /wallet/transfer/signed` (client signed transfer) + - Rejects malformed JSON bodies (non-object) + - Rejects missing required fields + - Rejects non-numeric, non-finite, or non-positive `amount_rtc` + - Rejects invalid address formats / from==to + - Rejects invalid/non-positive nonces + +Note: this preflight does not replace signature verification or admin-key authorization. It is a guardrail to prevent 500s and to make failure modes consistent. + +## CLI Checker + +Use the CLI to validate payloads before submitting a payout request: + +```bash +python3 tools/payout_preflight_check.py --mode admin --input payload.json +python3 tools/payout_preflight_check.py --mode signed --input payload.json +``` + +You can also read from stdin: + +```bash +cat payload.json | python3 tools/payout_preflight_check.py --mode admin --input - +``` + +Exit codes: + +- `0`: ok +- `1`: invalid payload (preflight failed) +- `2`: invalid JSON parse / unreadable input + diff --git a/node/payout_preflight.py b/node/payout_preflight.py new file mode 100644 index 0000000..6906c24 --- /dev/null +++ b/node/payout_preflight.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, Dict, Optional, Tuple + + +@dataclass(frozen=True) +class PreflightResult: + ok: bool + error: str + details: Dict[str, Any] + + +def _as_dict(payload: Any) -> Tuple[Optional[Dict[str, Any]], str]: + if not isinstance(payload, dict): + return None, "invalid_json_body" + return payload, "" + + +def _safe_float(v: Any) -> Tuple[Optional[float], str]: + try: + f = float(v) + except (TypeError, ValueError): + return None, "amount_not_number" + if not math.isfinite(f): + return None, "amount_not_finite" + return f, "" + + +def validate_wallet_transfer_admin(payload: Any) -> PreflightResult: + """Validate POST /wallet/transfer payload shape (admin transfer).""" + data, err = _as_dict(payload) + if err: + return PreflightResult(ok=False, error=err, details={}) + + from_miner = data.get("from_miner") + to_miner = data.get("to_miner") + amount_rtc, aerr = _safe_float(data.get("amount_rtc", 0)) + + if not from_miner or not to_miner: + return PreflightResult(ok=False, error="missing_from_or_to", details={}) + if aerr: + return PreflightResult(ok=False, error=aerr, details={}) + if amount_rtc is None or amount_rtc <= 0: + return PreflightResult(ok=False, error="amount_must_be_positive", details={}) + + return PreflightResult( + ok=True, + error="", + details={"from_miner": str(from_miner), "to_miner": str(to_miner), "amount_rtc": amount_rtc}, + ) + + +def validate_wallet_transfer_signed(payload: Any) -> PreflightResult: + """Validate POST /wallet/transfer/signed payload shape (client-signed).""" + data, err = _as_dict(payload) + if err: + return PreflightResult(ok=False, error=err, details={}) + + required = ["from_address", "to_address", "amount_rtc", "nonce", "signature", "public_key"] + missing = [k for k in required if not data.get(k)] + if missing: + return PreflightResult(ok=False, error="missing_required_fields", details={"missing": missing}) + + from_address = str(data.get("from_address", "")).strip() + to_address = str(data.get("to_address", "")).strip() + amount_rtc, aerr = _safe_float(data.get("amount_rtc", 0)) + if aerr: + return PreflightResult(ok=False, error=aerr, details={}) + if amount_rtc is None or amount_rtc <= 0: + return PreflightResult(ok=False, error="amount_must_be_positive", details={}) + + if not (from_address.startswith("RTC") and len(from_address) == 43): + return PreflightResult(ok=False, error="invalid_from_address_format", details={}) + if not (to_address.startswith("RTC") and len(to_address) == 43): + return PreflightResult(ok=False, error="invalid_to_address_format", details={}) + if from_address == to_address: + return PreflightResult(ok=False, error="from_to_must_differ", details={}) + + try: + nonce_int = int(str(data.get("nonce"))) + except (TypeError, ValueError): + return PreflightResult(ok=False, error="nonce_not_int", details={}) + if nonce_int <= 0: + return PreflightResult(ok=False, error="nonce_must_be_gt_zero", details={}) + + return PreflightResult( + ok=True, + error="", + details={"from_address": from_address, "to_address": to_address, "amount_rtc": amount_rtc, "nonce": nonce_int}, + ) + diff --git a/node/rustchain_v2_integrated_v2.2.1_rip200.py b/node/rustchain_v2_integrated_v2.2.1_rip200.py index 6cc410d..590c465 100644 --- a/node/rustchain_v2_integrated_v2.2.1_rip200.py +++ b/node/rustchain_v2_integrated_v2.2.1_rip200.py @@ -5,6 +5,7 @@ """ import os, time, json, secrets, hashlib, hmac, sqlite3, base64, struct, uuid, glob, logging, sys, binascii, math from flask import Flask, request, jsonify, g +from node.payout_preflight import validate_wallet_transfer_admin, validate_wallet_transfer_signed # Hardware Binding v2.0 - Anti-Spoof with Entropy Validation try: @@ -3082,17 +3083,16 @@ def wallet_transfer_v2(): "hint": "Use /wallet/transfer/signed for user transfers" }), 401 - data = request.get_json() - from_miner = data.get('from_miner') - to_miner = data.get('to_miner') - amount_rtc = float(data.get('amount_rtc', 0)) - reason = data.get('reason', 'admin_transfer') - - if not all([from_miner, to_miner]): - return jsonify({"error": "Missing from_miner or to_miner"}), 400 - - if amount_rtc <= 0: - return jsonify({"error": "Amount must be positive"}), 400 + data = request.get_json(silent=True) + pre = validate_wallet_transfer_admin(data) + if not pre.ok: + # Hardening: malformed/edge payloads should never produce server 500s. + return jsonify({"error": pre.error, "details": pre.details}), 400 + + from_miner = pre.details["from_miner"] + to_miner = pre.details["to_miner"] + amount_rtc = pre.details["amount_rtc"] + reason = str((data or {}).get('reason', 'admin_transfer')) amount_i64 = int(amount_rtc * 1000000) now = int(time.time()) @@ -3712,44 +3712,22 @@ def wallet_transfer_signed(): - memo: optional memo """ data = request.get_json(silent=True) - if not isinstance(data, dict): - return jsonify({"error": "Invalid JSON body"}), 400 + pre = validate_wallet_transfer_signed(data) + if not pre.ok: + return jsonify({"error": pre.error, "details": pre.details}), 400 # Extract client IP (handle nginx proxy) client_ip = request.headers.get("X-Forwarded-For", request.remote_addr) if client_ip and "," in client_ip: client_ip = client_ip.split(",")[0].strip() # First IP in chain - from_address = str(data.get("from_address", "")).strip() - to_address = str(data.get("to_address", "")).strip() - nonce = data.get("nonce") + from_address = pre.details["from_address"] + to_address = pre.details["to_address"] + nonce_int = pre.details["nonce"] signature = str(data.get("signature", "")).strip() public_key = str(data.get("public_key", "")).strip() memo = str(data.get("memo", "")) - - try: - amount_rtc = float(data.get("amount_rtc", 0)) - except (TypeError, ValueError): - return jsonify({"error": "amount_rtc must be a valid number"}), 400 - - if not math.isfinite(amount_rtc): - return jsonify({"error": "amount_rtc must be finite"}), 400 - - # Validate required fields - if not all([from_address, to_address, signature, public_key, nonce]): - return jsonify({"error": "Missing required fields (from_address, to_address, signature, public_key, nonce)"}), 400 - - if amount_rtc <= 0: - return jsonify({"error": "Amount must be positive"}), 400 - - if not (from_address.startswith("RTC") and len(from_address) == 43): - return jsonify({"error": "Invalid from_address format"}), 400 - - if not (to_address.startswith("RTC") and len(to_address) == 43): - return jsonify({"error": "Invalid to_address format"}), 400 - - if from_address == to_address: - return jsonify({"error": "from_address and to_address must differ"}), 400 + amount_rtc = pre.details["amount_rtc"] # Verify public key matches from_address expected_address = address_from_pubkey(public_key) @@ -3760,14 +3738,6 @@ def wallet_transfer_signed(): "got": from_address }), 400 - try: - nonce_int = int(str(nonce)) - except (TypeError, ValueError): - return jsonify({"error": "nonce must be an integer-like value"}), 400 - - if nonce_int <= 0: - return jsonify({"error": "nonce must be > 0"}), 400 - nonce = str(nonce_int) # Recreate the signed message (must match client signing format) diff --git a/node/tests/test_payout_preflight.py b/node/tests/test_payout_preflight.py new file mode 100644 index 0000000..3605c7f --- /dev/null +++ b/node/tests/test_payout_preflight.py @@ -0,0 +1,54 @@ +import unittest + +from node.payout_preflight import validate_wallet_transfer_admin, validate_wallet_transfer_signed + + +class PayoutPreflightTests(unittest.TestCase): + def test_admin_rejects_non_dict(self): + r = validate_wallet_transfer_admin(None) + self.assertFalse(r.ok) + self.assertEqual(r.error, "invalid_json_body") + + def test_admin_rejects_bad_amount(self): + r = validate_wallet_transfer_admin({"from_miner": "a", "to_miner": "b", "amount_rtc": "nope"}) + self.assertFalse(r.ok) + self.assertEqual(r.error, "amount_not_number") + + def test_admin_ok(self): + r = validate_wallet_transfer_admin({"from_miner": "a", "to_miner": "b", "amount_rtc": 1}) + self.assertTrue(r.ok) + + def test_signed_rejects_missing(self): + r = validate_wallet_transfer_signed({"from_address": "RTC" + "a" * 40}) + self.assertFalse(r.ok) + self.assertEqual(r.error, "missing_required_fields") + + def test_signed_rejects_non_finite(self): + payload = { + "from_address": "RTC" + "a" * 40, + "to_address": "RTC" + "b" * 40, + "amount_rtc": float("nan"), + "nonce": "1", + "signature": "00", + "public_key": "00", + } + r = validate_wallet_transfer_signed(payload) + self.assertFalse(r.ok) + self.assertEqual(r.error, "amount_not_finite") + + def test_signed_ok_shape(self): + payload = { + "from_address": "RTC" + "a" * 40, + "to_address": "RTC" + "b" * 40, + "amount_rtc": 1.25, + "nonce": "123", + "signature": "00", + "public_key": "00", + } + r = validate_wallet_transfer_signed(payload) + self.assertTrue(r.ok) + + +if __name__ == "__main__": + unittest.main() + diff --git a/tools/payout_preflight_check.py b/tools/payout_preflight_check.py new file mode 100644 index 0000000..e923565 --- /dev/null +++ b/tools/payout_preflight_check.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import sys +from typing import Any + +from node.payout_preflight import validate_wallet_transfer_admin, validate_wallet_transfer_signed + + +def read_payload(path: str) -> Any: + if path == "-": + raw = sys.stdin.read() + else: + raw = open(path, "r", encoding="utf-8").read() + return json.loads(raw) + + +def main() -> int: + p = argparse.ArgumentParser(description="RustChain payout preflight checker (dry-run validation)") + p.add_argument("--mode", choices=["admin", "signed"], required=True) + p.add_argument("--input", required=True, help="path to JSON file, or '-' for stdin") + args = p.parse_args() + + try: + payload = read_payload(args.input) + except Exception as e: + print(json.dumps({"ok": False, "error": "invalid_json", "details": str(e)})) + return 2 + + res = validate_wallet_transfer_admin(payload) if args.mode == "admin" else validate_wallet_transfer_signed(payload) + print(json.dumps({"ok": res.ok, "error": res.error, "details": res.details}, indent=2)) + return 0 if res.ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) +