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
49 changes: 49 additions & 0 deletions backend/app/api/escrow.py
Original file line number Diff line number Diff line change
@@ -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)

Check failure on line 20 in backend/app/api/escrow.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (E701)

app/api/escrow.py:20:8: E701 Multiple statements on one line (colon)
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)

Check failure on line 27 in backend/app/api/escrow.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (E701)

app/api/escrow.py:27:8: E701 Multiple statements on one line (colon)
except EscrowNotFoundError as e: raise HTTPException(404, str(e)) from e

Check failure on line 28 in backend/app/api/escrow.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (E701)

app/api/escrow.py:28:36: E701 Multiple statements on one line (colon)
except (EscrowInvalidStateError, EscrowDoubleSpendError) as e: raise HTTPException(409, str(e)) from e

Check failure on line 29 in backend/app/api/escrow.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (E701)

app/api/escrow.py:29:66: E701 Multiple statements on one line (colon)

@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)

Check failure on line 34 in backend/app/api/escrow.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (E701)

app/api/escrow.py:34:8: E701 Multiple statements on one line (colon)
except EscrowNotFoundError as e: raise HTTPException(404, str(e)) from e

Check failure on line 35 in backend/app/api/escrow.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (E701)

app/api/escrow.py:35:36: E701 Multiple statements on one line (colon)
except (EscrowInvalidStateError, EscrowDoubleSpendError) as e: raise HTTPException(409, str(e)) from e

Check failure on line 36 in backend/app/api/escrow.py

View workflow job for this annotation

GitHub Actions / Backend Lint (Ruff)

ruff (E701)

app/api/escrow.py:36:66: E701 Multiple statements on one line (colon)

@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)
16 changes: 16 additions & 0 deletions backend/app/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
5 changes: 5 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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():
Expand Down
109 changes: 109 additions & 0 deletions backend/app/models/escrow.py
Original file line number Diff line number Diff line change
@@ -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
155 changes: 155 additions & 0 deletions backend/app/services/escrow_service.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading