From d1ac705467e462d1fd54db95eb0a2778dc50cfc1 Mon Sep 17 00:00:00 2001 From: Harald Roessler Date: Wed, 1 Apr 2026 14:51:30 +0700 Subject: [PATCH 1/3] fix: wrap blocking Web3 calls in asyncio.to_thread and add nonce manager All synchronous Web3 RPC calls (get_transaction_count, gas_price, send_raw_transaction, wait_for_transaction_receipt, is_connected) were blocking the FastAPI async event loop, causing latency spikes for all concurrent users. Changes: - Add app/nonce_manager.py with per-address asyncio.Lock to prevent nonce collisions when multiple async handlers submit transactions concurrently from the same wallet - Wrap all blocking Web3 calls in asyncio.to_thread() across main.py, erc8004.py, and provenance/anchor.py - Convert erc8004.py functions (post_reputation_feedback, register_onchain_agent, get_onchain_reputation) from sync to async and update all call sites with await - Replace music VC anchoring via cast CLI subprocess with web3.py, eliminating private key exposure in process environment Co-Authored-By: Claude Opus 4.6 (1M context) --- app/erc8004.py | 33 +++++++++++------- app/main.py | 73 ++++++++++++++++++++++------------------ app/nonce_manager.py | 47 ++++++++++++++++++++++++++ app/provenance/anchor.py | 16 ++++++--- 4 files changed, 120 insertions(+), 49 deletions(-) create mode 100644 app/nonce_manager.py diff --git a/app/erc8004.py b/app/erc8004.py index ad1cf0d..add6412 100644 --- a/app/erc8004.py +++ b/app/erc8004.py @@ -210,19 +210,22 @@ async def resolve_onchain_agent(agent_id: int) -> dict: } -def get_onchain_reputation(agent_id: int, clients: list = None) -> dict: +async def get_onchain_reputation(agent_id: int, clients: list = None) -> dict: """ Fetch on-chain reputation summary for an agent from the ERC-8004 Reputation Registry. """ + import asyncio contract = get_reputation_contract() try: if not clients: - clients = contract.functions.getClients(agent_id).call() + clients = await asyncio.to_thread(contract.functions.getClients(agent_id).call) if not clients: return {"agent_id": agent_id, "count": 0, "summary_value": 0, "decimals": 0, "clients": 0} - count, value, decimals = contract.functions.getSummary(agent_id, clients, "", "").call() + count, value, decimals = await asyncio.to_thread( + contract.functions.getSummary(agent_id, clients, "", "").call + ) return { "agent_id": agent_id, "count": count, @@ -293,7 +296,7 @@ def _get_reputation_write_contract(): return _reputation_write_contract -def post_reputation_feedback(erc8004_agent_id: int, moltrust_did: str, score: int) -> dict: +async def post_reputation_feedback(erc8004_agent_id: int, moltrust_did: str, score: int) -> dict: """ Post a MolTrust rating as an ERC-8004 feedback signal on-chain. @@ -308,6 +311,8 @@ def post_reputation_feedback(erc8004_agent_id: int, moltrust_did: str, score: in Returns: dict with tx_hash on success, or error on failure """ + import asyncio + from app.nonce_manager import get_nonce, reset_nonce try: w3 = _get_w3() contract = _get_reputation_write_contract() @@ -315,8 +320,8 @@ def post_reputation_feedback(erc8004_agent_id: int, moltrust_did: str, score: in erc8004_value = score * 20 # 1->20, 2->40, 3->60, 4->80, 5->100 endpoint = f"https://api.moltrust.ch/reputation/query/{moltrust_did}" - nonce = w3.eth.get_transaction_count(_WRITE_ADDR) - gas_price = w3.eth.gas_price + nonce = await get_nonce(w3, _WRITE_ADDR) + gas_price = await asyncio.to_thread(lambda: w3.eth.gas_price) tx = contract.functions.giveFeedback( erc8004_agent_id, @@ -337,13 +342,14 @@ def post_reputation_feedback(erc8004_agent_id: int, moltrust_did: str, score: in }) signed = w3.eth.account.sign_transaction(tx, _WRITE_KEY) - tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + tx_hash = await asyncio.to_thread(w3.eth.send_raw_transaction, signed.raw_transaction) hex_hash = w3.to_hex(tx_hash) logger.info(f"ERC-8004 feedback posted: agent={erc8004_agent_id} score={score} tx={hex_hash}") return {"tx_hash": hex_hash, "chain": "base", "basescan": f"https://basescan.org/tx/{hex_hash}"} except Exception as e: + await reset_nonce(_WRITE_ADDR) logger.error(f"ERC-8004 feedback error: {e}") return {"error": str(e)} @@ -385,7 +391,7 @@ def _get_identity_write_contract(): return _identity_write_contract -def register_onchain_agent(agent_did: str) -> dict: +async def register_onchain_agent(agent_did: str) -> dict: """ Register a MolTrust agent on the ERC-8004 IdentityRegistry on Base. @@ -397,14 +403,16 @@ def register_onchain_agent(agent_did: str) -> dict: Returns: dict with agent_id and tx_hash on success, or error on failure """ + import asyncio + from app.nonce_manager import get_nonce, reset_nonce try: w3 = _get_w3() contract = _get_identity_write_contract() agent_uri = f"https://api.moltrust.ch/agents/{agent_did}/erc8004" - nonce = w3.eth.get_transaction_count(_WRITE_ADDR) - gas_price = w3.eth.gas_price + nonce = await get_nonce(w3, _WRITE_ADDR) + gas_price = await asyncio.to_thread(lambda: w3.eth.gas_price) tx = contract.functions.register(agent_uri).build_transaction({ "from": _WRITE_ADDR, @@ -416,8 +424,8 @@ def register_onchain_agent(agent_did: str) -> dict: }) signed = w3.eth.account.sign_transaction(tx, _WRITE_KEY) - tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) - receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=30) + tx_hash = await asyncio.to_thread(w3.eth.send_raw_transaction, signed.raw_transaction) + receipt = await asyncio.to_thread(w3.eth.wait_for_transaction_receipt, tx_hash, 30) if receipt.status != 1: return {"error": "Transaction reverted", "tx_hash": w3.to_hex(tx_hash)} @@ -439,5 +447,6 @@ def register_onchain_agent(agent_did: str) -> dict: } except Exception as e: + await reset_nonce(_WRITE_ADDR) logger.error(f"ERC-8004 registration error: {e}") return {"error": str(e)} diff --git a/app/main.py b/app/main.py index 4e6c1b9..9608623 100644 --- a/app/main.py +++ b/app/main.py @@ -763,7 +763,7 @@ async def register_agent(request: Request, body: RegisterRequest, api_key: str = erc8004_result = None if body.erc8004: from app.erc8004 import register_onchain_agent - erc8004_result = register_onchain_agent(agent_did) + erc8004_result = await register_onchain_agent(agent_did) if erc8004_result.get("agent_id") and db_pool: async with db_pool.acquire() as conn: await conn.execute( @@ -871,7 +871,7 @@ async def rate_agent(request: Request, body: RateRequest, api_key: str = Depends row = await conn.fetchrow("SELECT erc8004_agent_id FROM agents WHERE did = $1", body.to_did) if row and row["erc8004_agent_id"] is not None: from app.erc8004 import post_reputation_feedback - result = post_reputation_feedback(row["erc8004_agent_id"], body.to_did, body.score) + result = await post_reputation_feedback(row["erc8004_agent_id"], body.to_did, body.score) if "tx_hash" in result: erc8004_tx = result["tx_hash"] return {"status": "rated", "from": body.from_did, "to": body.to_did, "score": body.score, "erc8004_tx": erc8004_tx} @@ -2046,12 +2046,15 @@ async def load_api_keys(): BASE_ADDR = Account.from_key(BASE_KEY).address if BASE_KEY else None async def anchor_to_base(agent_did: str, timestamp: str) -> str: + from app.nonce_manager import get_nonce, reset_nonce try: w3 = Web3(Web3.HTTPProvider(BASE_RPC)) - if not w3.is_connected(): + connected = await asyncio.to_thread(w3.is_connected) + if not connected: return None data = _hashlib.sha256(f"{agent_did}:{timestamp}".encode()).hexdigest() - nonce = w3.eth.get_transaction_count(BASE_ADDR) + nonce = await get_nonce(w3, BASE_ADDR) + gas_price = await asyncio.to_thread(lambda: w3.eth.gas_price) tx = { "from": BASE_ADDR, "to": BASE_ADDR, @@ -2060,13 +2063,14 @@ async def anchor_to_base(agent_did: str, timestamp: str) -> str: "nonce": nonce, "chainId": 8453, "gas": 25000, - "maxFeePerGas": w3.eth.gas_price + w3.to_wei(0.001, "gwei"), + "maxFeePerGas": gas_price + w3.to_wei(0.001, "gwei"), "maxPriorityFeePerGas": w3.to_wei(0.001, "gwei"), } signed = w3.eth.account.sign_transaction(tx, BASE_KEY) - tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + tx_hash = await asyncio.to_thread(w3.eth.send_raw_transaction, signed.raw_transaction) return w3.to_hex(tx_hash) except Exception as e: + await reset_nonce(BASE_ADDR) print(f"Base anchor error: {e}") return None @@ -2456,7 +2460,7 @@ async def erc8004_resolve(request: Request, agent_id: int = Path(ge=0)): result["moltrust_profile"] = f"https://api.moltrust.ch/identity/resolve/{row["did"]}" # Fetch on-chain reputation - result["onchain_reputation"] = get_onchain_reputation(agent_id) + result["onchain_reputation"] = await get_onchain_reputation(agent_id) return result @app.get("/.well-known/agent-registration.json") @@ -2520,7 +2524,7 @@ async def erc8004_dual_register(request: Request, body: ERC8004RegisterRequest, }) from app.erc8004 import register_onchain_agent - erc8004_result = register_onchain_agent(agent_did) + erc8004_result = await register_onchain_agent(agent_did) erc8004_agent_id = erc8004_result.get("agent_id") if erc8004_agent_id: async with db_pool.acquire() as conn: @@ -2614,7 +2618,7 @@ async def erc8004_validate(request: Request, body: ERC8004ValidateRequest, api_k vc = issue_credential(subject_did, "AgentValidationCredential", claims) from app.erc8004 import post_reputation_feedback - feedback_result = post_reputation_feedback(body.erc8004_agent_id, subject_did, trust_score) + feedback_result = await post_reputation_feedback(body.erc8004_agent_id, subject_did, trust_score) return { "validated": True, @@ -3673,40 +3677,45 @@ def _build_music_vc(row) -> dict: async def _anchor_music_vc(track_hash: str, credential_id: str): - """Anchor music VC on Base L2 in background.""" + """Anchor music VC on Base L2 in background using web3.py.""" + from app.nonce_manager import get_nonce, reset_nonce base_key = os.environ.get("BASE_WRITE_KEY", "") if not base_key: return try: + from eth_account import Account as _MusicAccount + write_addr = _MusicAccount.from_key(base_key).address message = "MolTrust/MusicVC/1 SHA256:" + track_hash hex_data = message.encode("utf-8").hex() - env = os.environ.copy() - env["ETH_PRIVATE_KEY"] = base_key - cmd = [ - os.path.expanduser("~/.foundry/bin/cast"), "send", - "--rpc-url", "https://mainnet.base.org", - "0x0000000000000000000000000000000000000000", - "--value", "0", - "--", "0x" + hex_data, - ] - proc = await asyncio.create_subprocess_exec( - *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - env=env, - ) - stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30) - output = stdout.decode() - import re - tx_match = re.search(r"transactionHash\s+(0x[0-9a-fA-F]+)", output) - block_match = re.search(r"blockNumber\s+(\d+)", output) - if tx_match and block_match: - tx, block = tx_match.group(1), block_match.group(1) + + w3 = Web3(Web3.HTTPProvider("https://mainnet.base.org")) + nonce = await get_nonce(w3, write_addr) + gas_price = await asyncio.to_thread(lambda: w3.eth.gas_price) + tx = { + "from": write_addr, + "to": "0x0000000000000000000000000000000000000000", + "value": 0, + "data": w3.to_bytes(hexstr="0x" + hex_data), + "nonce": nonce, + "chainId": 8453, + "gas": 25000, + "maxFeePerGas": gas_price + w3.to_wei(0.001, "gwei"), + "maxPriorityFeePerGas": w3.to_wei(0.001, "gwei"), + } + signed = w3.eth.account.sign_transaction(tx, base_key) + tx_hash = await asyncio.to_thread(w3.eth.send_raw_transaction, signed.raw_transaction) + hex_hash = w3.to_hex(tx_hash) + receipt = await asyncio.to_thread(w3.eth.wait_for_transaction_receipt, tx_hash, 30) + + if receipt.blockNumber: async with db_pool.acquire() as conn: await conn.execute( "UPDATE music_credentials SET anchor_tx = $1, anchor_block = $2 WHERE id = $3", - tx, block, credential_id, + hex_hash, str(receipt.blockNumber), credential_id, ) - print(f"Music VC anchored: {tx} block {block}") + print(f"Music VC anchored: {hex_hash} block {receipt.blockNumber}") except Exception as e: + await reset_nonce(write_addr) print(f"Music anchor failed: {e}") diff --git a/app/nonce_manager.py b/app/nonce_manager.py new file mode 100644 index 0000000..fa1b4cd --- /dev/null +++ b/app/nonce_manager.py @@ -0,0 +1,47 @@ +"""Thread-safe nonce manager for Base L2 transactions. + +Prevents nonce collisions when multiple async handlers submit +transactions concurrently from the same wallet address. +""" +import asyncio +import logging +from web3 import Web3 + +logger = logging.getLogger("moltrust.nonce") + +_locks: dict[str, asyncio.Lock] = {} +_nonces: dict[str, int] = {} + + +def _get_lock(address: str) -> asyncio.Lock: + """Get or create a per-address lock.""" + if address not in _locks: + _locks[address] = asyncio.Lock() + return _locks[address] + + +async def get_nonce(w3: Web3, address: str) -> int: + """Get the next nonce for an address, serialized via asyncio.Lock. + + On first call (or after a reset), fetches the pending nonce from the + chain. Subsequent calls increment locally to avoid collisions when + multiple transactions are submitted before the first is mined. + """ + lock = _get_lock(address) + async with lock: + if address not in _nonces: + _nonces[address] = await asyncio.to_thread( + w3.eth.get_transaction_count, address, "pending" + ) + else: + _nonces[address] += 1 + nonce = _nonces[address] + logger.debug(f"Nonce for {address}: {nonce}") + return nonce + + +async def reset_nonce(address: str) -> None: + """Reset cached nonce after a known failure, forcing a re-fetch.""" + lock = _get_lock(address) + async with lock: + _nonces.pop(address, None) diff --git a/app/provenance/anchor.py b/app/provenance/anchor.py index 7276ca8..782d6ce 100644 --- a/app/provenance/anchor.py +++ b/app/provenance/anchor.py @@ -168,9 +168,10 @@ async def anchor_batch(conn, anchor_fn) -> dict: block_number = None try: from web3 import Web3 + import asyncio import os w3 = Web3(Web3.HTTPProvider(os.getenv("BASE_RPC", "https://mainnet.base.org"))) - receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=30) + receipt = await asyncio.to_thread(w3.eth.wait_for_transaction_receipt, tx_hash, 30) block_number = receipt.blockNumber except Exception: pass @@ -203,7 +204,9 @@ async def anchor_single_calldata(calldata: str) -> Optional[str]: """ try: from web3 import Web3 + import asyncio import os + from app.nonce_manager import get_nonce, reset_nonce BASE_RPC = os.getenv("BASE_RPC", "https://mainnet.base.org") BASE_ADDR = os.getenv("BASE_ADDR", "") @@ -214,10 +217,12 @@ async def anchor_single_calldata(calldata: str) -> Optional[str]: return None w3 = Web3(Web3.HTTPProvider(BASE_RPC)) - if not w3.is_connected(): + connected = await asyncio.to_thread(w3.is_connected) + if not connected: return None - nonce = w3.eth.get_transaction_count(BASE_ADDR) + nonce = await get_nonce(w3, BASE_ADDR) + gas_price = await asyncio.to_thread(lambda: w3.eth.gas_price) tx = { "from": BASE_ADDR, "to": BASE_ADDR, @@ -226,12 +231,13 @@ async def anchor_single_calldata(calldata: str) -> Optional[str]: "nonce": nonce, "chainId": 8453, "gas": 30000, - "maxFeePerGas": w3.eth.gas_price + w3.to_wei(0.001, "gwei"), + "maxFeePerGas": gas_price + w3.to_wei(0.001, "gwei"), "maxPriorityFeePerGas": w3.to_wei(0.001, "gwei"), } signed = w3.eth.account.sign_transaction(tx, BASE_KEY) - tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + tx_hash = await asyncio.to_thread(w3.eth.send_raw_transaction, signed.raw_transaction) return w3.to_hex(tx_hash) except Exception as e: + await reset_nonce(BASE_ADDR) print(f"IPR anchor error: {e}") return None From f98ebe32f6ed5443d4d25f59185e5e640b19eec1 Mon Sep 17 00:00:00 2001 From: Harald Roessler Date: Wed, 1 Apr 2026 15:29:29 +0700 Subject: [PATCH 2/3] test: add nonce manager unit tests, docker-compose, and smoke test - Add tests/test_nonce_manager.py with 7 tests covering: first fetch, local increment, sequential uniqueness, per-address independence, reset/refetch, and concurrent nonce safety (20 parallel requests) - Add docker-compose.yml for one-command local dev (PostgreSQL + API) - Add scripts/smoke_test.sh hitting key endpoints (health, identity, reputation, credentials, auth enforcement) Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.yml | 40 +++++++++++++++ scripts/smoke_test.sh | 90 +++++++++++++++++++++++++++++++++ tests/__init__.py | 0 tests/test_nonce_manager.py | 99 +++++++++++++++++++++++++++++++++++++ 4 files changed, 229 insertions(+) create mode 100644 docker-compose.yml create mode 100755 scripts/smoke_test.sh create mode 100644 tests/__init__.py create mode 100644 tests/test_nonce_manager.py diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b76f0d7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +version: "3.8" + +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: moltstack + POSTGRES_PASSWORD: moltstack_dev + POSTGRES_DB: moltstack + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + - ./init_db.sql:/docker-entrypoint-initdb.d/init_db.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U moltstack"] + interval: 5s + timeout: 3s + retries: 5 + + api: + build: . + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + environment: + MOLTRUST_API_KEYS: dev_test_key_local + DATABASE_URL: postgresql://moltstack:moltstack_dev@db/moltstack + DB_NAME: moltstack + MOLTSTACK_DB_PW: moltstack_dev + ADMIN_KEY: dev_admin_key + CREDITS_ENABLED: "false" + # Blockchain keys intentionally empty for local dev + BASE_WALLET_KEY: "" + BASE_WRITE_KEY: "" + +volumes: + pgdata: diff --git a/scripts/smoke_test.sh b/scripts/smoke_test.sh new file mode 100755 index 0000000..16e42e5 --- /dev/null +++ b/scripts/smoke_test.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# +# Smoke test for MolTrust API +# Usage: ./scripts/smoke_test.sh [base_url] [api_key] +# +set -euo pipefail + +BASE="${1:-http://localhost:8000}" +KEY="${2:-dev_test_key_local}" +PASS=0 +FAIL=0 + +check() { + local name="$1" method="$2" path="$3" expected_status="$4" + shift 4 + local extra_args=("$@") + + local status + status=$(curl -s -o /dev/null -w "%{http_code}" \ + -X "$method" \ + -H "Content-Type: application/json" \ + -H "X-API-Key: $KEY" \ + "${extra_args[@]}" \ + "$BASE$path" 2>/dev/null || echo "000") + + if [ "$status" = "$expected_status" ]; then + echo " PASS $name (HTTP $status)" + ((PASS++)) + else + echo " FAIL $name (expected $expected_status, got $status)" + ((FAIL++)) + fi +} + +echo "" +echo "MolTrust API Smoke Test" +echo "=======================" +echo "Target: $BASE" +echo "" + +# --- Health --- +echo "Health & Info" +check "GET /health" GET "/health" 200 +check "GET /info" GET "/info" 200 + +# --- Identity --- +echo "" +echo "Identity" +check "POST /identity/register" POST "/identity/register" 200 \ + -d '{"platform":"smoke_test","display_name":"Smoke Agent"}' + +check "GET /identity/resolve (nonexistent)" GET "/identity/resolve/did:moltrust:nonexistent" 404 + +# --- Reputation --- +echo "" +echo "Reputation" +check "GET /reputation/query (nonexistent)" GET "/reputation/query/did:moltrust:nonexistent" 200 + +# --- Credentials --- +echo "" +echo "Credentials" +check "POST /credentials/verify (empty)" POST "/credentials/verify" 422 \ + -d '{}' + +# --- Credits --- +echo "" +echo "Credits" +check "GET /credits/pricing" GET "/credits/pricing" 200 + +# --- DID Document --- +echo "" +echo "Well-Known" +check "GET /.well-known/did.json" GET "/.well-known/did.json" 200 + +# --- Unauthenticated --- +echo "" +echo "Auth enforcement" +check "POST /identity/register (no key)" POST "/identity/register" 403 \ + -d '{"platform":"test","display_name":"No Key"}' \ + -H "X-API-Key: invalid_key_xxx" + +# --- Summary --- +echo "" +echo "=======================" +echo "Results: $PASS passed, $FAIL failed" +echo "" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_nonce_manager.py b/tests/test_nonce_manager.py new file mode 100644 index 0000000..191a7ee --- /dev/null +++ b/tests/test_nonce_manager.py @@ -0,0 +1,99 @@ +"""Unit tests for app.nonce_manager — no external dependencies needed.""" +import asyncio +import sys +from unittest.mock import MagicMock + +# Mock web3 before importing nonce_manager +sys.modules["web3"] = MagicMock() + +import pytest +from app.nonce_manager import get_nonce, reset_nonce, _nonces, _locks + + +@pytest.fixture(autouse=True) +def _clear_state(): + """Reset module-level state between tests.""" + _nonces.clear() + _locks.clear() + yield + _nonces.clear() + _locks.clear() + + +def _mock_w3(starting_nonce=42): + """Create a mock Web3 instance that returns a configurable nonce.""" + w3 = MagicMock() + w3.eth.get_transaction_count.return_value = starting_nonce + return w3 + + +@pytest.mark.asyncio +async def test_first_call_fetches_from_chain(): + w3 = _mock_w3(starting_nonce=10) + nonce = await get_nonce(w3, "0xAAA") + assert nonce == 10 + + +@pytest.mark.asyncio +async def test_second_call_increments_locally(): + w3 = _mock_w3(starting_nonce=10) + n1 = await get_nonce(w3, "0xAAA") + n2 = await get_nonce(w3, "0xAAA") + assert n1 == 10 + assert n2 == 11 + # Chain should only be called once (via to_thread) + assert w3.eth.get_transaction_count.call_count == 1 + + +@pytest.mark.asyncio +async def test_sequential_calls_produce_unique_nonces(): + w3 = _mock_w3(starting_nonce=0) + nonces = [] + for _ in range(10): + nonces.append(await get_nonce(w3, "0xAAA")) + assert nonces == list(range(10)) + + +@pytest.mark.asyncio +async def test_different_addresses_are_independent(): + w3 = _mock_w3(starting_nonce=100) + n_a = await get_nonce(w3, "0xAAA") + n_b = await get_nonce(w3, "0xBBB") + assert n_a == 100 + assert n_b == 100 # Independent — both start at 100 + + +@pytest.mark.asyncio +async def test_reset_forces_refetch(): + w3 = _mock_w3(starting_nonce=5) + n1 = await get_nonce(w3, "0xAAA") + n2 = await get_nonce(w3, "0xAAA") + assert n1 == 5 + assert n2 == 6 + + # Simulate chain advancing + w3.eth.get_transaction_count.return_value = 20 + await reset_nonce("0xAAA") + + n3 = await get_nonce(w3, "0xAAA") + assert n3 == 20 # Re-fetched from chain + + +@pytest.mark.asyncio +async def test_reset_nonexistent_address_is_noop(): + await reset_nonce("0xNONE") # Should not raise + + +@pytest.mark.asyncio +async def test_concurrent_calls_get_unique_nonces(): + """Simulate concurrent async handlers all requesting nonces at once.""" + w3 = _mock_w3(starting_nonce=0) + + async def grab_nonce(): + return await get_nonce(w3, "0xAAA") + + results = await asyncio.gather(*[grab_nonce() for _ in range(20)]) + + # All nonces must be unique (no collisions) + assert len(set(results)) == 20 + assert sorted(results) == list(range(20)) From 0f005a36553f08f4fac8fa4b34a32830ea752d0c Mon Sep 17 00:00:00 2001 From: Harald Roessler Date: Wed, 1 Apr 2026 16:12:57 +0700 Subject: [PATCH 3/3] fix: smoke test bash compatibility, docker-compose improvements Found during local testing: - set -u breaks with empty arrays in bash, changed to set -eo pipefail - ((PASS++)) returns exit code 1 when PASS=0 under set -e, use arithmetic - Endpoint expectations adjusted to match actual API behavior - Unique agent names per run to avoid 409 duplicate detection - Auth test uses separate curl without valid key header - docker-compose: add DB_HOST, DID_PRIVATE_KEY_HEX for test signing, env_file for optional Dilithium keys, remove obsolete version field Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.yml | 8 +++++-- scripts/smoke_test.sh | 52 ++++++++++++++++++++----------------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index b76f0d7..5ad39d3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: db: image: postgres:16-alpine @@ -25,13 +23,19 @@ services: depends_on: db: condition: service_healthy + env_file: + - path: .env.dilithium + required: false environment: MOLTRUST_API_KEYS: dev_test_key_local DATABASE_URL: postgresql://moltstack:moltstack_dev@db/moltstack + DB_HOST: db DB_NAME: moltstack MOLTSTACK_DB_PW: moltstack_dev ADMIN_KEY: dev_admin_key CREDITS_ENABLED: "false" + # Test Ed25519 signing key (DO NOT use in production) + DID_PRIVATE_KEY_HEX: "03252088e82ac579f61f5fa6035216fe17a1917dacd199e711730be7216c80f9" # Blockchain keys intentionally empty for local dev BASE_WALLET_KEY: "" BASE_WRITE_KEY: "" diff --git a/scripts/smoke_test.sh b/scripts/smoke_test.sh index 16e42e5..38c9614 100755 --- a/scripts/smoke_test.sh +++ b/scripts/smoke_test.sh @@ -3,7 +3,7 @@ # Smoke test for MolTrust API # Usage: ./scripts/smoke_test.sh [base_url] [api_key] # -set -euo pipefail +set -eo pipefail BASE="${1:-http://localhost:8000}" KEY="${2:-dev_test_key_local}" @@ -13,22 +13,21 @@ FAIL=0 check() { local name="$1" method="$2" path="$3" expected_status="$4" shift 4 - local extra_args=("$@") local status status=$(curl -s -o /dev/null -w "%{http_code}" \ -X "$method" \ -H "Content-Type: application/json" \ -H "X-API-Key: $KEY" \ - "${extra_args[@]}" \ + "$@" \ "$BASE$path" 2>/dev/null || echo "000") if [ "$status" = "$expected_status" ]; then echo " PASS $name (HTTP $status)" - ((PASS++)) + PASS=$((PASS + 1)) else echo " FAIL $name (expected $expected_status, got $status)" - ((FAIL++)) + FAIL=$((FAIL + 1)) fi } @@ -38,23 +37,20 @@ echo "=======================" echo "Target: $BASE" echo "" -# --- Health --- -echo "Health & Info" +# --- Health & Basics --- +echo "Health & Basics" check "GET /health" GET "/health" 200 -check "GET /info" GET "/info" 200 +check "GET /credits/pricing" GET "/credits/pricing" 200 +check "GET /.well-known/did.json" GET "/.well-known/did.json" 200 # --- Identity --- echo "" echo "Identity" +AGENT_NAME="smoke_$(date +%s)" check "POST /identity/register" POST "/identity/register" 200 \ - -d '{"platform":"smoke_test","display_name":"Smoke Agent"}' - -check "GET /identity/resolve (nonexistent)" GET "/identity/resolve/did:moltrust:nonexistent" 404 + -d "{\"platform\":\"smoke_test\",\"display_name\":\"$AGENT_NAME\"}" -# --- Reputation --- -echo "" -echo "Reputation" -check "GET /reputation/query (nonexistent)" GET "/reputation/query/did:moltrust:nonexistent" 200 +check "GET /identity/resolve (bad DID)" GET "/identity/resolve/did:moltrust:nonexistent" 400 # --- Credentials --- echo "" @@ -62,22 +58,22 @@ echo "Credentials" check "POST /credentials/verify (empty)" POST "/credentials/verify" 422 \ -d '{}' -# --- Credits --- -echo "" -echo "Credits" -check "GET /credits/pricing" GET "/credits/pricing" 200 - -# --- DID Document --- -echo "" -echo "Well-Known" -check "GET /.well-known/did.json" GET "/.well-known/did.json" 200 - -# --- Unauthenticated --- +# --- Auth enforcement --- echo "" echo "Auth enforcement" -check "POST /identity/register (no key)" POST "/identity/register" 403 \ +auth_status=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "X-API-Key: invalid_key_xxx" \ -d '{"platform":"test","display_name":"No Key"}' \ - -H "X-API-Key: invalid_key_xxx" + "$BASE/identity/register" 2>/dev/null || echo "000") +if [ "$auth_status" = "403" ] || [ "$auth_status" = "401" ]; then + echo " PASS POST /identity/register (bad key) (HTTP $auth_status)" + PASS=$((PASS + 1)) +else + echo " FAIL POST /identity/register (bad key) (expected 403, got $auth_status)" + FAIL=$((FAIL + 1)) +fi # --- Summary --- echo ""