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
14 changes: 13 additions & 1 deletion node/utxo_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,15 @@ def _reserve_transfer_nonce(conn: sqlite3.Connection, from_address: str, nonce)
return conn.execute("SELECT changes()").fetchone()[0] == 1


def _missing_transfer_nonce(nonce) -> bool:
return (
nonce is None
or isinstance(nonce, bool)
or not isinstance(nonce, (int, str))
or (isinstance(nonce, str) and nonce.strip() == '')
)


def register_utxo_blueprint(app, utxo_db: UtxoDB, db_path: str,
verify_sig_fn, addr_from_pk_fn,
current_slot_fn, dual_write: bool = False):
Expand Down Expand Up @@ -361,7 +370,10 @@ def utxo_transfer():

# --- validation ---------------------------------------------------------

if not all([from_address, to_address, public_key, signature, nonce]):
if (
not all([from_address, to_address, public_key, signature])
or _missing_transfer_nonce(nonce)
):
return jsonify({
'error': 'Missing required fields',
'required': ['from_address', 'to_address', 'public_key',
Expand Down
165 changes: 165 additions & 0 deletions tests/test_utxo_transfer_nonce_required.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# SPDX-License-Identifier: MIT
import os
import sqlite3
import sys
import tempfile
import time
from pathlib import Path

from flask import Flask


PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "node"))

from utxo_db import UNIT, UtxoDB
from utxo_endpoints import register_utxo_blueprint


def mock_verify_sig(pubkey_hex, message, sig_hex):
return True


def mock_addr_from_pk(pubkey_hex):
return f"RTC_test_{pubkey_hex[:8]}"


def mock_current_slot():
return 100


def build_client():
fd, db_path = tempfile.mkstemp(suffix=".db")
os.close(fd)

conn = sqlite3.connect(db_path)
conn.execute(
"CREATE TABLE balances (miner_id TEXT PRIMARY KEY, amount_i64 INTEGER DEFAULT 0)"
)
conn.commit()
conn.close()

utxo_db = UtxoDB(db_path)
utxo_db.init_tables()

app = Flask(__name__)
app.config["TESTING"] = True
register_utxo_blueprint(
app,
utxo_db,
db_path,
verify_sig_fn=mock_verify_sig,
addr_from_pk_fn=mock_addr_from_pk,
current_slot_fn=mock_current_slot,
dual_write=False,
)

return app.test_client(), utxo_db, db_path


def seed_coinbase(utxo_db, address, value_nrtc, height=1):
ok = utxo_db.apply_transaction(
{
"tx_type": "mining_reward",
"inputs": [],
"outputs": [{"address": address, "value_nrtc": value_nrtc}],
"timestamp": int(time.time()),
"_allow_minting": True,
},
block_height=height,
)
assert ok is True


def payload(nonce=1733420000000, amount_rtc=10.0):
return {
"from_address": "RTC_test_aabbccdd",
"to_address": "bob",
"amount_rtc": amount_rtc,
"public_key": "aabbccdd" * 8,
"signature": "sig" * 22,
"nonce": nonce,
"memo": "nonce-required-test",
}


def cleanup_db(db_path):
for suffix in ("", "-wal", "-shm"):
path = db_path + suffix
if os.path.exists(path):
os.unlink(path)


def transfer_nonce_count(db_path):
conn = sqlite3.connect(db_path)
try:
return conn.execute("SELECT COUNT(*) FROM transfer_nonces").fetchone()[0]
finally:
conn.close()


def test_utxo_transfer_accepts_numeric_zero_nonce():
client, utxo_db, db_path = build_client()
try:
seed_coinbase(utxo_db, "RTC_test_aabbccdd", 100 * UNIT)

response = client.post("/utxo/transfer", json=payload(nonce=0))

assert response.status_code == 200
assert response.get_json()["ok"] is True
assert transfer_nonce_count(db_path) == 1
finally:
cleanup_db(db_path)


def test_utxo_transfer_accepts_string_zero_nonce_as_same_replay_key():
client, utxo_db, db_path = build_client()
try:
seed_coinbase(utxo_db, "RTC_test_aabbccdd", 100 * UNIT)

response = client.post("/utxo/transfer", json=payload(nonce="0"))
assert response.status_code == 200
assert response.get_json()["ok"] is True

replay = client.post("/utxo/transfer", json=payload(nonce=0))
assert replay.status_code == 400
assert replay.get_json()["code"] == "REPLAY_DETECTED"
assert transfer_nonce_count(db_path) == 1
finally:
cleanup_db(db_path)


def test_utxo_transfer_rejects_blank_nonce_values():
for blank_nonce in ("", " ", None, False):
client, utxo_db, db_path = build_client()
try:
seed_coinbase(utxo_db, "RTC_test_aabbccdd", 100 * UNIT)

response = client.post(
"/utxo/transfer",
json=payload(nonce=blank_nonce),
)

assert response.status_code == 400
assert response.get_json()["error"] == "Missing required fields"
assert transfer_nonce_count(db_path) == 0
finally:
cleanup_db(db_path)


def test_utxo_transfer_rejects_container_nonce_values():
for container_nonce in ({}, [], {"x": 1}, [0]):
client, utxo_db, db_path = build_client()
try:
seed_coinbase(utxo_db, "RTC_test_aabbccdd", 100 * UNIT)

response = client.post(
"/utxo/transfer",
json=payload(nonce=container_nonce),
)

assert response.status_code == 400
assert response.get_json()["error"] == "Missing required fields"
assert transfer_nonce_count(db_path) == 0
finally:
cleanup_db(db_path)
21 changes: 11 additions & 10 deletions tests/test_utxo_transfer_replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ def payload(nonce=1733420000000, amount_rtc=10.0):
}


def nonce_count(db_path):
conn = sqlite3.connect(db_path)
try:
return conn.execute("SELECT COUNT(*) FROM transfer_nonces").fetchone()[0]
finally:
conn.close()


def test_utxo_transfer_rejects_duplicate_nonce():
client, utxo_db, db_path = build_client()
try:
Expand All @@ -99,10 +107,7 @@ def test_utxo_transfer_rejects_duplicate_nonce():

assert utxo_db.get_balance("bob") == 10 * UNIT

with sqlite3.connect(db_path) as conn:
nonce_count = conn.execute("SELECT COUNT(*) FROM transfer_nonces").fetchone()[0]

assert nonce_count == 1
assert nonce_count(db_path) == 1
finally:
os.unlink(db_path)

Expand All @@ -117,18 +122,14 @@ def test_utxo_transfer_failed_attempt_does_not_burn_nonce():
assert rejected.status_code == 400
assert rejected.get_json()["error"] == "Insufficient UTXO balance"

with sqlite3.connect(db_path) as conn:
nonce_count = conn.execute("SELECT COUNT(*) FROM transfer_nonces").fetchone()[0]
assert nonce_count == 0
assert nonce_count(db_path) == 0

seed_coinbase(utxo_db, "RTC_test_aabbccdd", 20 * UNIT, height=2)
accepted = client.post("/utxo/transfer", json=req)
assert accepted.status_code == 200
assert accepted.get_json()["ok"] is True

with sqlite3.connect(db_path) as conn:
nonce_count = conn.execute("SELECT COUNT(*) FROM transfer_nonces").fetchone()[0]
assert nonce_count == 1
assert nonce_count(db_path) == 1
assert utxo_db.get_balance("bob") == 10 * UNIT
finally:
os.unlink(db_path)