diff --git a/backend/app/api/escrow.py b/backend/app/api/escrow.py new file mode 100644 index 00000000..0808971a --- /dev/null +++ b/backend/app/api/escrow.py @@ -0,0 +1,49 @@ +"""$FNDRY Staking & Custodial Escrow API. All mutations require auth.""" +from __future__ import annotations +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query, status +from app.auth import get_current_user_id +from app.exceptions import (EscrowAlreadyExistsError, EscrowDoubleSpendError, + EscrowInvalidStateError, EscrowNotFoundError) +from app.models.escrow import (EscrowCreateRequest, EscrowListResponse, EscrowRefundRequest, + EscrowReleaseRequest, EscrowResponse, EscrowState) +from app.services.escrow_service import (create_escrow, get_escrow_status, list_escrows, + refund_escrow, release_escrow, verify_transaction_confirmed) + +router = APIRouter(prefix="/escrow", tags=["escrow"]) + +@router.post("/fund", response_model=EscrowResponse, status_code=status.HTTP_201_CREATED, summary="Fund bounty escrow") +async def fund_escrow(data: EscrowCreateRequest, user_id: str = Depends(get_current_user_id)) -> EscrowResponse: + """Lock $FNDRY in custodial escrow. Verifies tx on-chain if provided.""" + if data.tx_hash and not await verify_transaction_confirmed(data.tx_hash): + raise HTTPException(400, f"Transaction {data.tx_hash} is not confirmed on Solana") + try: return create_escrow(data) + except (EscrowAlreadyExistsError, EscrowDoubleSpendError) as e: + raise HTTPException(409, str(e)) from e + +@router.post("/release", response_model=EscrowResponse, summary="Release escrow to winner") +async def release_escrow_endpoint(data: EscrowReleaseRequest, user_id: str = Depends(get_current_user_id)) -> EscrowResponse: + """Send escrowed $FNDRY to bounty winner.""" + try: return release_escrow(data) + except EscrowNotFoundError as e: raise HTTPException(404, str(e)) from e + except (EscrowInvalidStateError, EscrowDoubleSpendError) as e: raise HTTPException(409, str(e)) from e + +@router.post("/refund", response_model=EscrowResponse, summary="Refund escrow to creator") +async def refund_escrow_endpoint(data: EscrowRefundRequest, user_id: str = Depends(get_current_user_id)) -> EscrowResponse: + """Return escrowed $FNDRY to creator on timeout/cancellation.""" + try: return refund_escrow(data) + except EscrowNotFoundError as e: raise HTTPException(404, str(e)) from e + except (EscrowInvalidStateError, EscrowDoubleSpendError) as e: raise HTTPException(409, str(e)) from e + +@router.get("/{bounty_id}", response_model=EscrowResponse, summary="Get escrow status") +async def get_escrow_status_endpoint(bounty_id: str) -> EscrowResponse: + """Current escrow state and ledger for a bounty.""" + try: return get_escrow_status(bounty_id) + except EscrowNotFoundError as e: raise HTTPException(404, str(e)) from e + +@router.get("", response_model=EscrowListResponse, summary="List escrow accounts") +async def list_escrows_endpoint( + state: Optional[EscrowState]=Query(None), creator_wallet: Optional[str]=Query(None, min_length=32, max_length=44), + skip: int=Query(0, ge=0), limit: int=Query(20, ge=1, le=100)) -> EscrowListResponse: + """Paginated escrow list with optional state/wallet filters.""" + return list_escrows(state=state, creator_wallet=creator_wallet, skip=skip, limit=limit) diff --git a/backend/app/exceptions.py b/backend/app/exceptions.py index b3f3bdab..6535dd46 100644 --- a/backend/app/exceptions.py +++ b/backend/app/exceptions.py @@ -7,3 +7,19 @@ class ContributorNotFoundError(Exception): class TierNotUnlockedError(Exception): """Raised when a contributor attempts a bounty tier they have not unlocked.""" + + +class EscrowNotFoundError(Exception): + """Raised when an escrow account does not exist for the given bounty ID.""" + + +class EscrowAlreadyExistsError(Exception): + """Raised when attempting to create an escrow for a bounty that already has one.""" + + +class EscrowInvalidStateError(Exception): + """Raised when an escrow state transition is not allowed.""" + + +class EscrowDoubleSpendError(Exception): + """Raised when a duplicate transaction hash is detected (double-spend protection).""" diff --git a/backend/app/main.py b/backend/app/main.py index b590a1e8..f6937381 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -20,6 +20,7 @@ from app.api.webhooks.github import router as github_webhook_router from app.api.websocket import router as websocket_router from app.api.agents import router as agents_router +from app.api.escrow import router as escrow_router from app.database import init_db, close_db, engine from app.services.auth_service import AuthError from app.services.websocket_manager import manager as ws_manager @@ -206,6 +207,7 @@ async def value_error_handler(request: Request, exc: ValueError): "code": "VALIDATION_ERROR" } ) + # Auth: /api/auth/* app.include_router(auth_router, prefix="/api") @@ -233,6 +235,9 @@ async def value_error_handler(request: Request, exc: ValueError): # Agents: /api/agents/* app.include_router(agents_router, prefix="/api") +# Escrow: /api/escrow/* +app.include_router(escrow_router, prefix="/api") + @app.get("/health") async def health_check(): diff --git a/backend/app/models/escrow.py b/backend/app/models/escrow.py new file mode 100644 index 00000000..9b67cb9a --- /dev/null +++ b/backend/app/models/escrow.py @@ -0,0 +1,109 @@ +"""Escrow Pydantic v2 models for custodial $FNDRY staking. + +PostgreSQL migration path (DDL):: + + CREATE TYPE escrow_state AS ENUM ('PENDING','FUNDED','ACTIVE','RELEASING','COMPLETED','REFUNDED'); + CREATE TABLE escrow_accounts (id UUID PRIMARY KEY, bounty_id VARCHAR(100) UNIQUE NOT NULL, + creator_wallet VARCHAR(44), winner_wallet VARCHAR(44), amount FLOAT8 CHECK(amount>0), + state escrow_state DEFAULT 'PENDING', fund_tx_hash VARCHAR(88), release_tx_hash VARCHAR(88), + refund_tx_hash VARCHAR(88), created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ, expires_at TIMESTAMPTZ); + CREATE TABLE escrow_ledger (id UUID PRIMARY KEY, escrow_id UUID REFERENCES escrow_accounts(id), + action VARCHAR(20), amount FLOAT8, tx_hash VARCHAR(88), wallet VARCHAR(44), created_at TIMESTAMPTZ); +""" +from __future__ import annotations +import re, uuid +from datetime import datetime, timezone +from enum import Enum +from typing import Optional +from pydantic import BaseModel, Field, field_validator + +_B58 = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$") +_TX = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{64,88}$") + +VALID_TRANSITIONS: dict[str, frozenset[str]] = { + "PENDING": frozenset({"FUNDED","REFUNDED"}), "FUNDED": frozenset({"ACTIVE","REFUNDED"}), + "ACTIVE": frozenset({"RELEASING","REFUNDED"}), "RELEASING": frozenset({"COMPLETED"}), + "COMPLETED": frozenset(), "REFUNDED": frozenset(), +} + +def _vw(v: Optional[str]) -> Optional[str]: + """Validate optional Solana base-58 wallet.""" + if v is not None and not _B58.match(v): raise ValueError("Invalid Solana base-58 address") + return v + +def _vt(v: Optional[str]) -> Optional[str]: + """Validate optional Solana tx signature.""" + if v is not None and not _TX.match(v): raise ValueError("Invalid Solana tx signature") + return v + +class EscrowState(str, Enum): + """PENDING->FUNDED->ACTIVE->RELEASING->COMPLETED | REFUNDED.""" + PENDING="PENDING"; FUNDED="FUNDED"; ACTIVE="ACTIVE" + RELEASING="RELEASING"; COMPLETED="COMPLETED"; REFUNDED="REFUNDED" + +def _now(): return datetime.now(timezone.utc) +def _uid(): return str(uuid.uuid4()) + +class EscrowRecord(BaseModel): + """Internal escrow account storage model.""" + model_config = {"from_attributes": True} + id: str = Field(default_factory=_uid) + bounty_id: str = Field(..., min_length=1, max_length=100) + creator_wallet: str = Field(..., min_length=32, max_length=44) + winner_wallet: Optional[str] = None + amount: float = Field(..., gt=0) + state: EscrowState = EscrowState.PENDING + fund_tx_hash: Optional[str] = None + release_tx_hash: Optional[str] = None + refund_tx_hash: Optional[str] = None + created_at: datetime = Field(default_factory=_now) + updated_at: datetime = Field(default_factory=_now) + expires_at: Optional[datetime] = None + _v1=field_validator("creator_wallet")(_vw); _v2=field_validator("winner_wallet")(_vw) + _v3=field_validator("fund_tx_hash","release_tx_hash","refund_tx_hash")(_vt) + +class LedgerEntry(BaseModel): + """Ledger row for escrow financial event (ForeignKey -> escrow_accounts).""" + model_config = {"from_attributes": True} + id: str = Field(default_factory=_uid) + escrow_id: str + action: str = Field(..., pattern=r"^(deposit|release|refund)$") + amount: float = Field(..., gt=0) + tx_hash: Optional[str] = None + wallet: str = Field(..., min_length=32, max_length=44) + created_at: datetime = Field(default_factory=_now) + _v1=field_validator("wallet")(_vw); _v2=field_validator("tx_hash")(_vt) + +class EscrowCreateRequest(BaseModel): + """POST /escrow/fund body.""" + bounty_id: str = Field(..., min_length=1, max_length=100) + creator_wallet: str = Field(..., min_length=32, max_length=44) + amount: float = Field(..., gt=0) + tx_hash: Optional[str] = None + expires_at: Optional[datetime] = None + _v1=field_validator("creator_wallet")(_vw); _v2=field_validator("tx_hash")(_vt) + +class EscrowReleaseRequest(BaseModel): + """POST /escrow/release body.""" + bounty_id: str = Field(..., min_length=1, max_length=100) + winner_wallet: str = Field(..., min_length=32, max_length=44) + tx_hash: Optional[str] = None + _v1=field_validator("winner_wallet")(_vw); _v2=field_validator("tx_hash")(_vt) + +class EscrowRefundRequest(BaseModel): + """POST /escrow/refund body.""" + bounty_id: str = Field(..., min_length=1, max_length=100) + tx_hash: Optional[str] = None + _v1=field_validator("tx_hash")(_vt) + +class EscrowResponse(BaseModel): + """Public escrow API response with ledger history.""" + id: str; bounty_id: str; creator_wallet: str; winner_wallet: Optional[str]=None + amount: float; state: EscrowState + fund_tx_hash: Optional[str]=None; release_tx_hash: Optional[str]=None; refund_tx_hash: Optional[str]=None + created_at: datetime; updated_at: datetime; expires_at: Optional[datetime]=None + ledger: list[LedgerEntry] = Field(default_factory=list) + +class EscrowListResponse(BaseModel): + """Paginated escrow list.""" + items: list[EscrowResponse]; total: int; skip: int; limit: int diff --git a/backend/app/services/escrow_service.py b/backend/app/services/escrow_service.py new file mode 100644 index 00000000..12e02fb3 --- /dev/null +++ b/backend/app/services/escrow_service.py @@ -0,0 +1,155 @@ +"""Custodial $FNDRY escrow service (in-memory MVP). Thread-locked for double-spend safety. +PostgreSQL DDL in ``app.models.escrow`` docstring. +""" +from __future__ import annotations +import logging, threading +from datetime import datetime, timezone +from typing import Optional + +from app.core.audit import audit_event +from app.exceptions import ( + EscrowAlreadyExistsError, EscrowDoubleSpendError, + EscrowInvalidStateError, EscrowNotFoundError, +) +from app.models.escrow import ( + VALID_TRANSITIONS, EscrowCreateRequest, EscrowListResponse, EscrowRecord, + EscrowRefundRequest, EscrowReleaseRequest, EscrowResponse, EscrowState, LedgerEntry, +) + +logger = logging.getLogger(__name__) +_lock = threading.Lock() +_escrow_store: dict[str, EscrowRecord] = {} +_bounty_index: dict[str, str] = {} +_ledger_store: dict[str, list[LedgerEntry]] = {} +_tx_hash_set: set[str] = set() + +def _check_transition(cur: EscrowState, tgt: EscrowState) -> None: + """Enforce state machine; raises EscrowInvalidStateError.""" + if tgt.value not in VALID_TRANSITIONS.get(cur.value, frozenset()): + raise EscrowInvalidStateError(f"Cannot transition from {cur.value} to {tgt.value}") + +def _check_dup_tx(tx: Optional[str]) -> None: + """Reject duplicate tx hashes (double-spend protection).""" + if tx and tx in _tx_hash_set: + raise EscrowDoubleSpendError(f"Transaction {tx} has already been recorded (double-spend rejected)") + +def _record_tx(tx: Optional[str]) -> None: + if tx: _tx_hash_set.add(tx) + +def _add_ledger(eid: str, action: str, amount: float, wallet: str, tx: Optional[str]=None) -> LedgerEntry: + """Create and store a ledger entry.""" + e = LedgerEntry(escrow_id=eid, action=action, amount=amount, wallet=wallet, tx_hash=tx) + _ledger_store.setdefault(eid, []).append(e) + return e + +def _resp(e: EscrowRecord) -> EscrowResponse: + """Convert EscrowRecord to EscrowResponse.""" + return EscrowResponse( + id=e.id, bounty_id=e.bounty_id, creator_wallet=e.creator_wallet, + winner_wallet=e.winner_wallet, amount=e.amount, state=e.state, + fund_tx_hash=e.fund_tx_hash, release_tx_hash=e.release_tx_hash, + refund_tx_hash=e.refund_tx_hash, created_at=e.created_at, + updated_at=e.updated_at, expires_at=e.expires_at, + ledger=list(_ledger_store.get(e.id, []))) + +def create_escrow(data: EscrowCreateRequest) -> EscrowResponse: + """Create new escrow. FUNDED if tx_hash given, else PENDING.""" + with _lock: + if data.bounty_id in _bounty_index: + raise EscrowAlreadyExistsError(f"Escrow already exists for bounty '{data.bounty_id}'") + _check_dup_tx(data.tx_hash) + state = EscrowState.FUNDED if data.tx_hash else EscrowState.PENDING + now = datetime.now(timezone.utc) + esc = EscrowRecord(bounty_id=data.bounty_id, creator_wallet=data.creator_wallet, + amount=data.amount, state=state, fund_tx_hash=data.tx_hash, + created_at=now, updated_at=now, expires_at=data.expires_at) + _escrow_store[esc.id] = esc; _bounty_index[data.bounty_id] = esc.id; _record_tx(data.tx_hash) + if data.tx_hash: + _add_ledger(esc.id, "deposit", data.amount, data.creator_wallet, data.tx_hash) + r = _resp(esc) + audit_event("escrow_created", escrow_id=esc.id, bounty_id=data.bounty_id, amount=data.amount) + logger.info("Escrow created: %s bounty=%s state=%s", esc.id, data.bounty_id, state.value) + return r + +def release_escrow(data: EscrowReleaseRequest) -> EscrowResponse: + """Release escrowed $FNDRY to winner (FUNDED/ACTIVE -> COMPLETED).""" + with _lock: + eid = _bounty_index.get(data.bounty_id) + if not eid: raise EscrowNotFoundError(f"No escrow found for bounty '{data.bounty_id}'") + esc = _escrow_store[eid] + if esc.state not in (EscrowState.FUNDED, EscrowState.ACTIVE): + _check_transition(esc.state, EscrowState.RELEASING) + _check_dup_tx(data.tx_hash) + now = datetime.now(timezone.utc) + esc.state = EscrowState.COMPLETED; esc.winner_wallet = data.winner_wallet + esc.release_tx_hash = data.tx_hash; esc.updated_at = now; _record_tx(data.tx_hash) + _add_ledger(esc.id, "release", esc.amount, data.winner_wallet, data.tx_hash) + r = _resp(esc) + audit_event("escrow_released", escrow_id=esc.id, bounty_id=data.bounty_id, winner=data.winner_wallet) + logger.info("Escrow released: %s bounty=%s winner=%s", esc.id, data.bounty_id, data.winner_wallet) + return r + +def refund_escrow(data: EscrowRefundRequest) -> EscrowResponse: + """Refund escrowed $FNDRY to creator (timeout/cancellation).""" + with _lock: + eid = _bounty_index.get(data.bounty_id) + if not eid: raise EscrowNotFoundError(f"No escrow found for bounty '{data.bounty_id}'") + esc = _escrow_store[eid] + _check_transition(esc.state, EscrowState.REFUNDED); _check_dup_tx(data.tx_hash) + now = datetime.now(timezone.utc) + esc.state = EscrowState.REFUNDED; esc.refund_tx_hash = data.tx_hash + esc.updated_at = now; _record_tx(data.tx_hash) + _add_ledger(esc.id, "refund", esc.amount, esc.creator_wallet, data.tx_hash) + r = _resp(esc) + audit_event("escrow_refunded", escrow_id=esc.id, bounty_id=data.bounty_id, amount=esc.amount) + logger.info("Escrow refunded: %s bounty=%s", esc.id, data.bounty_id) + return r + +def get_escrow_status(bounty_id: str) -> EscrowResponse: + """Get escrow status for a bounty; raises EscrowNotFoundError.""" + with _lock: + eid = _bounty_index.get(bounty_id) + if not eid: raise EscrowNotFoundError(f"No escrow found for bounty '{bounty_id}'") + return _resp(_escrow_store[eid]) + +def list_escrows(state: Optional[EscrowState]=None, creator_wallet: Optional[str]=None, + skip: int=0, limit: int=20) -> EscrowListResponse: + """List escrows with optional filters and pagination.""" + with _lock: + results = sorted(_escrow_store.values(), key=lambda e: e.created_at, reverse=True) + if state: results = [e for e in results if e.state == state] + if creator_wallet: results = [e for e in results if e.creator_wallet == creator_wallet] + total = len(results); page = results[skip:skip+limit] + with _lock: items = [_resp(e) for e in page] + return EscrowListResponse(items=items, total=total, skip=skip, limit=limit) + +async def verify_transaction_confirmed(tx_hash: str) -> bool: + """Check Solana RPC that a transaction is confirmed on-chain.""" + import httpx + from app.services.solana_client import SOLANA_RPC_URL, RPC_TIMEOUT + payload = {"jsonrpc":"2.0","id":1,"method":"getTransaction", + "params":[tx_hash,{"encoding":"jsonParsed","maxSupportedTransactionVersion":0}]} + try: + async with httpx.AsyncClient(timeout=RPC_TIMEOUT) as client: + resp = await client.post(SOLANA_RPC_URL, json=payload); resp.raise_for_status() + data = resp.json(); result = data.get("result") + return result is not None and result.get("meta",{}).get("err") is None + except Exception: + logger.exception("Failed to verify transaction %s", tx_hash); return False + +async def process_expired_escrows() -> list[str]: + """Auto-refund expired escrows (background task).""" + now = datetime.now(timezone.utc); expired: list[str] = [] + with _lock: + for e in list(_escrow_store.values()): + if e.expires_at and e.expires_at <= now and e.state in (EscrowState.PENDING, EscrowState.FUNDED, EscrowState.ACTIVE): + expired.append(e.bounty_id) + for bid in expired: + try: refund_escrow(EscrowRefundRequest(bounty_id=bid)); logger.info("Auto-refunded expired escrow: %s", bid) + except (EscrowNotFoundError, EscrowInvalidStateError): pass + return expired + +def reset_stores() -> None: + """Clear all in-memory escrow data (tests/dev).""" + with _lock: + _escrow_store.clear(); _bounty_index.clear(); _ledger_store.clear(); _tx_hash_set.clear() diff --git a/backend/tests/test_escrow.py b/backend/tests/test_escrow.py new file mode 100644 index 00000000..6dafdb22 --- /dev/null +++ b/backend/tests/test_escrow.py @@ -0,0 +1,173 @@ +"""Tests for $FNDRY custodial escrow: lifecycle, double-spend, state machine, validation.""" +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch +import pytest +from fastapi.testclient import TestClient +from app.main import app +from app.services.escrow_service import reset_stores + +c = TestClient(app) +W1, W2 = "97VihHW2Br7BKUU16c7RxjiEMHsD4dWisGDT2Y3LyJxF", "57uMiMHnRJCxM7Q1MdGVMLsEtxzRiy1F6qKFWyP1S9pp" +TX1, TX2, TX3, TX4 = chr(52)*88, chr(53)*88, chr(54)*88, chr(55)*88 +B, H = "bounty-42", {"X-User-ID": "00000000-0000-0000-0000-000000000042"} +MV = "app.api.escrow.verify_transaction_confirmed" +F = lambda **kw: {"bounty_id":kw.get("b",B),"creator_wallet":kw.get("w",W1),"amount":kw.get("a",50000.0),**({} if not kw.get("t") else {"tx_hash":kw["t"]}),**({} if not kw.get("e") else {"expires_at":kw["e"]})} + +@pytest.fixture(autouse=True) +def _r(): reset_stores(); yield; reset_stores() + +def _f(**kw): return c.post("/api/escrow/fund", json=F(**kw), headers=H) + +class TestLifecycle: + """Full lifecycle: fund->release, fund->refund.""" + @patch(MV, new_callable=AsyncMock, return_value=True) + def test_fund_release(self, _): + """FUNDED->COMPLETED with ledger.""" + r = _f(t=TX1); assert r.status_code==201; d=r.json() + assert d["state"]=="FUNDED" and len(d["ledger"])==1 and d["ledger"][0]["action"]=="deposit" + r = c.post("/api/escrow/release", headers=H, json={"bounty_id":B,"winner_wallet":W2,"tx_hash":TX2}) + assert r.status_code==200; d=r.json() + assert d["state"]=="COMPLETED" and d["winner_wallet"]==W2 and len(d["ledger"])==2 + + @patch(MV, new_callable=AsyncMock, return_value=True) + def test_fund_refund(self, _): + """FUNDED->REFUNDED with ledger.""" + _f(t=TX1) + r = c.post("/api/escrow/refund", headers=H, json={"bounty_id":B,"tx_hash":TX3}) + assert r.status_code==200 and r.json()["state"]=="REFUNDED" and len(r.json()["ledger"])==2 + +class TestFund: + """POST /api/escrow/fund.""" + def test_pending(self): + """No tx -> PENDING.""" + r = _f(); assert r.status_code==201 and r.json()["state"]=="PENDING" + + @patch(MV, new_callable=AsyncMock, return_value=True) + def test_funded(self, _): + """With confirmed tx -> FUNDED.""" + r = _f(t=TX1); assert r.status_code==201 and r.json()["state"]=="FUNDED" + + @patch(MV, new_callable=AsyncMock, return_value=True) + def test_dup_bounty(self, _): + """Duplicate bounty_id -> 409.""" + _f(t=TX1); assert _f(a=1.0).status_code==409 + + @patch(MV, new_callable=AsyncMock, return_value=False) + def test_unconfirmed(self, _): + """Unconfirmed tx -> 400.""" + assert _f(t=TX1).status_code==400 + + def test_expiration(self): + """Escrow with expires_at.""" + r = _f(e=(datetime.now(timezone.utc)+timedelta(days=7)).isoformat()) + assert r.status_code==201 and r.json()["expires_at"] is not None + +class TestRelease: + """POST /api/escrow/release.""" + @patch(MV, new_callable=AsyncMock, return_value=True) + def test_ok(self, _): + _f(t=TX1); assert c.post("/api/escrow/release", headers=H, json={"bounty_id":B,"winner_wallet":W2,"tx_hash":TX2}).status_code==200 + + def test_pending_409(self): + _f(); assert c.post("/api/escrow/release", headers=H, json={"bounty_id":B,"winner_wallet":W2}).status_code==409 + + def test_404(self): + assert c.post("/api/escrow/release", headers=H, json={"bounty_id":"x","winner_wallet":W2}).status_code==404 + +class TestRefund: + """POST /api/escrow/refund.""" + @patch(MV, new_callable=AsyncMock, return_value=True) + def test_funded(self, _): + _f(t=TX1); assert c.post("/api/escrow/refund", headers=H, json={"bounty_id":B,"tx_hash":TX3}).status_code==200 + + def test_pending(self): + _f(); assert c.post("/api/escrow/refund", headers=H, json={"bounty_id":B}).json()["state"]=="REFUNDED" + + @patch(MV, new_callable=AsyncMock, return_value=True) + def test_completed_409(self, _): + _f(t=TX1); c.post("/api/escrow/release", headers=H, json={"bounty_id":B,"winner_wallet":W2,"tx_hash":TX2}) + assert c.post("/api/escrow/refund", headers=H, json={"bounty_id":B}).status_code==409 + + def test_404(self): + assert c.post("/api/escrow/refund", headers=H, json={"bounty_id":"x"}).status_code==404 + +class TestDoubleSpend: + """Double-spend protection via tx_hash dedup.""" + @patch(MV, new_callable=AsyncMock, return_value=True) + def test_dup_fund(self, _): + _f(b="b1",t=TX1); r = _f(b="b2",t=TX1) + assert r.status_code==409 and "double-spend" in r.json().get("detail",r.json().get("message","")).lower() + + @patch(MV, new_callable=AsyncMock, return_value=True) + def test_dup_release(self, _): + _f(t=TX1); assert c.post("/api/escrow/release", headers=H, json={"bounty_id":B,"winner_wallet":W2,"tx_hash":TX1}).status_code==409 + +class TestStateMachine: + """Invalid state transitions rejected.""" + @patch(MV, new_callable=AsyncMock, return_value=True) + def test_double_release(self, _): + _f(t=TX1); c.post("/api/escrow/release", headers=H, json={"bounty_id":B,"winner_wallet":W2,"tx_hash":TX2}) + assert c.post("/api/escrow/release", headers=H, json={"bounty_id":B,"winner_wallet":W2,"tx_hash":TX4}).status_code==409 + + @patch(MV, new_callable=AsyncMock, return_value=True) + def test_double_refund(self, _): + _f(t=TX1); c.post("/api/escrow/refund", headers=H, json={"bounty_id":B,"tx_hash":TX3}) + assert c.post("/api/escrow/refund", headers=H, json={"bounty_id":B,"tx_hash":TX4}).status_code==409 + +class TestListing: + """GET /api/escrow and GET /api/escrow/{id}.""" + def test_status(self): _f(); assert c.get(f"/api/escrow/{B}").status_code==200 + def test_404(self): assert c.get("/api/escrow/nope").status_code==404 + def test_empty(self): assert c.get("/api/escrow").json()["total"]==0 + def test_items(self): + for i in range(3): _f(b=f"b{i}") + assert c.get("/api/escrow").json()["total"]==3 + def test_filter_state(self): + _f(b="b1"); _f(b="b2",t=TX1) + assert c.get("/api/escrow?state=PENDING").json()["total"]==1 + def test_filter_wallet(self): + _f(b="b1",w=W1); _f(b="b2",w=W2) + assert c.get(f"/api/escrow?creator_wallet={W1}").json()["total"]==1 + def test_pagination(self): + for i in range(5): _f(b=f"b{i}") + p = c.get("/api/escrow?skip=0&limit=2").json() + assert len(p["items"])==2 and p["total"]==5 + +class TestValidation: + """Input validation.""" + def test_missing(self): + assert c.post("/api/escrow/fund", headers=H, json={"creator_wallet":W1,"amount":1.0}).status_code==422 + assert c.post("/api/escrow/fund", headers=H, json={"bounty_id":B,"amount":1.0}).status_code==422 + def test_amounts(self): + assert _f(a=0).status_code==422 and _f(a=-1).status_code==422 + def test_wallets(self): + assert c.post("/api/escrow/fund", headers=H, json={"bounty_id":B,"creator_wallet":"0x","amount":1.0}).status_code==422 + def test_bounds(self): + assert c.get("/api/escrow?limit=101").status_code==422 and c.get("/api/escrow?skip=-1").status_code==422 + def test_auth(self): + assert c.post("/api/escrow/fund", json=F()).status_code==401 + assert c.post("/api/escrow/release", json={"bounty_id":B,"winner_wallet":W2}).status_code==401 + assert c.post("/api/escrow/refund", json={"bounty_id":B}).status_code==401 + +class TestExpiration: + """Auto-refund expired escrows.""" + @pytest.mark.asyncio + async def test_expired(self): + from app.services.escrow_service import process_expired_escrows + _f(e=(datetime.now(timezone.utc)-timedelta(hours=1)).isoformat()) + assert B in await process_expired_escrows() + assert c.get(f"/api/escrow/{B}").json()["state"]=="REFUNDED" + +class TestTxVerify: + """Solana RPC tx verification.""" + @pytest.mark.asyncio + async def test_verify(self): + from app.services.escrow_service import verify_transaction_confirmed + def _mr(d): r=MagicMock(); r.json.return_value=d; r.raise_for_status=MagicMock(); return r + for d, exp in [ + ({"jsonrpc":"2.0","id":1,"result":{"meta":{"err":None}}}, True), + ({"jsonrpc":"2.0","id":1,"result":{"meta":{"err":{"E":1}}}}, False), + ({"jsonrpc":"2.0","id":1,"result":None}, False), + ]: + with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as m: + m.return_value = _mr(d); assert await verify_transaction_confirmed(TX1) is exp