Skip to content
Closed
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
41 changes: 41 additions & 0 deletions docs/PAYOUT_PREFLIGHT.md
Original file line number Diff line number Diff line change
@@ -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

93 changes: 93 additions & 0 deletions node/payout_preflight.py
Original file line number Diff line number Diff line change
@@ -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},
)

66 changes: 18 additions & 48 deletions node/rustchain_v2_integrated_v2.2.1_rip200.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
54 changes: 54 additions & 0 deletions node/tests/test_payout_preflight.py
Original file line number Diff line number Diff line change
@@ -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()

39 changes: 39 additions & 0 deletions tools/payout_preflight_check.py
Original file line number Diff line number Diff line change
@@ -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())