From 67f93f6cc3519eaf23829734972f89738ca62c41 Mon Sep 17 00:00:00 2001 From: David Tang Date: Thu, 12 Feb 2026 22:38:59 +0000 Subject: [PATCH 1/3] 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()) + From fd47f9f999cb4b312d99f4de6024965ea6f5dc6f Mon Sep 17 00:00:00 2001 From: David Tang Date: Thu, 12 Feb 2026 22:51:01 +0000 Subject: [PATCH 2/3] fix(security): enforce pending 2-phase commit for signed transfers --- node/rustchain_v2_integrated_v2.2.1_rip200.py | 83 +++++++++++-------- 1 file changed, 48 insertions(+), 35 deletions(-) diff --git a/node/rustchain_v2_integrated_v2.2.1_rip200.py b/node/rustchain_v2_integrated_v2.2.1_rip200.py index 590c465..74eb4a7 100644 --- a/node/rustchain_v2_integrated_v2.2.1_rip200.py +++ b/node/rustchain_v2_integrated_v2.2.1_rip200.py @@ -3770,61 +3770,74 @@ def wallet_transfer_signed(): "used_at": nonce_row[0] }), 400 + # SECURITY/HARDENING: signed transfers should follow the same 2-phase commit + # semantics as admin transfers (pending_ledger + delayed confirmation). This + # prevents bypassing the 24h pending window via the signed endpoint. amount_i64 = int(amount_rtc * 1000000) - + now = int(time.time()) + confirms_at = now + CONFIRMATION_DELAY_SECONDS + current_epoch = current_slot() + + # Deterministic tx hash derived from the signed message + signature. + tx_hash = hashlib.sha256(message + bytes.fromhex(signature)).hexdigest()[:32] + conn = sqlite3.connect(DB_PATH) try: c = conn.cursor() - + # Check sender balance (using from_address as wallet ID) row = c.execute("SELECT amount_i64 FROM balances WHERE miner_id = ?", (from_address,)).fetchone() sender_balance = row[0] if row else 0 - - if sender_balance < amount_i64: + + # Calculate pending debits (uncommitted outgoing transfers) + pending_debits = c.execute(""" + SELECT COALESCE(SUM(amount_i64), 0) FROM pending_ledger + WHERE from_miner = ? AND status = 'pending' + """, (from_address,)).fetchone()[0] + + available_balance = sender_balance - pending_debits + + if available_balance < amount_i64: return jsonify({ - "error": "Insufficient balance", + "error": "Insufficient available balance", "balance_rtc": sender_balance / 1000000, + "pending_debits_rtc": pending_debits / 1000000, + "available_rtc": available_balance / 1000000, "requested_rtc": amount_rtc }), 400 - - # Execute transfer - c.execute("INSERT OR IGNORE INTO balances (miner_id, amount_i64) VALUES (?, 0)", (to_address,)) - c.execute("UPDATE balances SET amount_i64 = amount_i64 - ? WHERE miner_id = ?", (amount_i64, from_address)) - c.execute("UPDATE balances SET amount_i64 = amount_i64 + ?, balance_rtc = (amount_i64 + ?) / 1000000.0 WHERE miner_id = ?", (amount_i64, amount_i64, to_address)) - - # Record in ledger - now = int(time.time()) - c.execute( - "INSERT INTO ledger (ts, epoch, miner_id, delta_i64, reason) VALUES (?, ?, ?, ?, ?)", - (now, current_slot(), from_address, -amount_i64, f"transfer_out:{to_address[:20]}:{memo[:30]}") - ) - c.execute( - "INSERT INTO ledger (ts, epoch, miner_id, delta_i64, reason) VALUES (?, ?, ?, ?, ?)", - (now, current_slot(), to_address, amount_i64, f"transfer_in:{from_address[:20]}:{memo[:30]}") - ) - - sender_new = c.execute("SELECT amount_i64 FROM balances WHERE miner_id = ?", (from_address,)).fetchone()[0] - recipient_new = c.execute("SELECT amount_i64 FROM balances WHERE miner_id = ?", (to_address,)).fetchone()[0] - - # SECURITY: Record nonce to prevent replay + + # Insert into pending_ledger (NOT direct balance update!) + reason = f"signed_transfer:{memo[:80]}" + c.execute(""" + INSERT INTO pending_ledger + (ts, epoch, from_miner, to_miner, amount_i64, reason, status, created_at, confirms_at, tx_hash) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?) + """, (now, current_epoch, from_address, to_address, amount_i64, reason, now, confirms_at, tx_hash)) + + pending_id = c.lastrowid + + # SECURITY: Record nonce to prevent replay (store at enqueue time) c.execute( "INSERT INTO transfer_nonces (from_address, nonce, used_at) VALUES (?, ?, ?)", - (from_address, str(nonce), int(time.time())) + (from_address, str(nonce), now) ) - + conn.commit() - + return jsonify({ "ok": True, + "verified": True, + "signature_type": "Ed25519", + "replay_protected": True, + "phase": "pending", + "pending_id": pending_id, + "tx_hash": tx_hash, "from_address": from_address, "to_address": to_address, "amount_rtc": amount_rtc, - "sender_balance_rtc": sender_new / 1000000, - "recipient_balance_rtc": recipient_new / 1000000, - "memo": memo, - "verified": True, - "signature_type": "Ed25519", - "replay_protected": True + "confirms_at": confirms_at, + "confirms_in_hours": CONFIRMATION_DELAY_SECONDS / 3600, + "message": f"Transfer pending. Will confirm in {CONFIRMATION_DELAY_SECONDS // 3600} hours unless voided." }) finally: conn.close() From 64754b2a850434796810e4fdecace76683ad07df Mon Sep 17 00:00:00 2001 From: David Tang Date: Thu, 12 Feb 2026 23:18:49 +0000 Subject: [PATCH 3/3] fix: deployment-compatible payout_preflight imports --- node/rustchain_v2_integrated_v2.2.1_rip200.py | 6 +- node/tests/test_payout_preflight.py | 6 +- payout_preflight.py | 97 +++++++++++++++++++ tools/payout_preflight_check.py | 6 +- 4 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 payout_preflight.py diff --git a/node/rustchain_v2_integrated_v2.2.1_rip200.py b/node/rustchain_v2_integrated_v2.2.1_rip200.py index 74eb4a7..d76cce6 100644 --- a/node/rustchain_v2_integrated_v2.2.1_rip200.py +++ b/node/rustchain_v2_integrated_v2.2.1_rip200.py @@ -5,7 +5,11 @@ """ 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 +try: + # Deployment compatibility: production may run this file as a single script. + from payout_preflight import validate_wallet_transfer_admin, validate_wallet_transfer_signed +except ImportError: + from node.payout_preflight import validate_wallet_transfer_admin, validate_wallet_transfer_signed # Hardware Binding v2.0 - Anti-Spoof with Entropy Validation try: diff --git a/node/tests/test_payout_preflight.py b/node/tests/test_payout_preflight.py index 3605c7f..82d4c4d 100644 --- a/node/tests/test_payout_preflight.py +++ b/node/tests/test_payout_preflight.py @@ -1,6 +1,9 @@ import unittest -from node.payout_preflight import validate_wallet_transfer_admin, validate_wallet_transfer_signed +try: + from payout_preflight import validate_wallet_transfer_admin, validate_wallet_transfer_signed +except ImportError: + from node.payout_preflight import validate_wallet_transfer_admin, validate_wallet_transfer_signed class PayoutPreflightTests(unittest.TestCase): @@ -51,4 +54,3 @@ def test_signed_ok_shape(self): if __name__ == "__main__": unittest.main() - diff --git a/payout_preflight.py b/payout_preflight.py new file mode 100644 index 0000000..d7b5669 --- /dev/null +++ b/payout_preflight.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +# Deployment-compat shim: some production environments run the node server as a +# single script (no package layout). Keep this module at repo root so +# `from payout_preflight import ...` works, while tests can still import it. + +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/tools/payout_preflight_check.py b/tools/payout_preflight_check.py index e923565..551d6ab 100644 --- a/tools/payout_preflight_check.py +++ b/tools/payout_preflight_check.py @@ -6,7 +6,10 @@ import sys from typing import Any -from node.payout_preflight import validate_wallet_transfer_admin, validate_wallet_transfer_signed +try: + from payout_preflight import validate_wallet_transfer_admin, validate_wallet_transfer_signed +except ImportError: + from node.payout_preflight import validate_wallet_transfer_admin, validate_wallet_transfer_signed def read_payload(path: str) -> Any: @@ -36,4 +39,3 @@ def main() -> int: if __name__ == "__main__": raise SystemExit(main()) -