diff --git a/backend/alembic/versions/0012_escrow_sessions.py b/backend/alembic/versions/0012_escrow_sessions.py new file mode 100644 index 0000000..5c576e5 --- /dev/null +++ b/backend/alembic/versions/0012_escrow_sessions.py @@ -0,0 +1,52 @@ +"""escrow sessions + +Revision ID: 0012_escrow_sessions +Revises: 0011_escrow_agent +Create Date: 2026-08-08 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0012_escrow_sessions" +down_revision: Union[str, None] = "0011_escrow_agent" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "escrows", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("organizer_id", sa.Uuid(), nullable=False), + sa.Column("agent_coordinate", sa.String(), nullable=False), + sa.Column("event_id", sa.Uuid(), nullable=True), + sa.Column("allocation_id", sa.Uuid(), nullable=True), + sa.Column("team_id", sa.Uuid(), nullable=True), + sa.Column("amount_sats", sa.Integer(), nullable=False), + sa.Column("rail", sa.String(), nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column("release_policy", sa.JSON(), nullable=False), + sa.Column("refund_policy", sa.JSON(), nullable=False), + sa.Column("dispute_policy", sa.JSON(), nullable=False), + sa.Column("funding_request", sa.String(), nullable=True), + sa.Column("nwc_uri", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=True), + sa.Column("funded_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("released_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["organizer_id"], ["users.id"]), + sa.ForeignKeyConstraint(["event_id"], ["events.id"]), + sa.ForeignKeyConstraint(["allocation_id"], ["allocations.id"]), + sa.ForeignKeyConstraint(["team_id"], ["teams.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_escrows_organizer_id"), "escrows", ["organizer_id"], unique=False) + op.create_index(op.f("ix_escrows_agent_coordinate"), "escrows", ["agent_coordinate"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_escrows_agent_coordinate"), table_name="escrows") + op.drop_index(op.f("ix_escrows_organizer_id"), table_name="escrows") + op.drop_table("escrows") diff --git a/backend/app/api/v1/escrow_sessions.py b/backend/app/api/v1/escrow_sessions.py new file mode 100644 index 0000000..40cb9cd --- /dev/null +++ b/backend/app/api/v1/escrow_sessions.py @@ -0,0 +1,189 @@ +"""Escrow session lifecycle endpoints.""" + +from datetime import datetime, timezone +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user, get_db +from app.models.escrow import Escrow +from app.models.user import User +from app.schemas.escrow import EscrowCreate, EscrowFund, EscrowListItem, EscrowOut +from app.services import escrow_service + +router = APIRouter() + + +def _list_out(escrow: Escrow) -> EscrowListItem: + return EscrowListItem( + id=escrow.id, + agent_coordinate=escrow.agent_coordinate, + event_id=escrow.event_id, + allocation_id=escrow.allocation_id, + amount_sats=escrow.amount_sats, + rail=escrow.rail, + status=escrow.status, + release_policy=escrow.release_policy, + refund_policy=escrow.refund_policy, + funding_request=escrow.funding_request, + created_at=escrow.created_at, + funded_at=escrow.funded_at, + released_at=escrow.released_at, + ) + + +def _escrow_out(escrow: Escrow) -> EscrowOut: + return EscrowOut( + id=escrow.id, + organizer_id=escrow.organizer_id, + agent_coordinate=escrow.agent_coordinate, + event_id=escrow.event_id, + allocation_id=escrow.allocation_id, + team_id=escrow.team_id, + amount_sats=escrow.amount_sats, + rail=escrow.rail, + status=escrow.status, + release_policy=escrow.release_policy, + refund_policy=escrow.refund_policy, + dispute_policy=escrow.dispute_policy, + funding_request=escrow.funding_request, + nwc_uri=escrow.nwc_uri, + created_at=escrow.created_at, + updated_at=escrow.updated_at, + funded_at=escrow.funded_at, + released_at=escrow.released_at, + ) + + +def _own_escrow(db: Session, escrow_id: UUID, user_id: UUID) -> Escrow: + escrow = db.query(Escrow).filter(Escrow.id == escrow_id).first() + if not escrow: + raise HTTPException(status_code=404, detail="Escrow not found") + if escrow.organizer_id != user_id: + raise HTTPException(status_code=403, detail="Not your escrow") + return escrow + + +@router.post("", response_model=EscrowOut, status_code=status.HTTP_201_CREATED) +def create_escrow( + req: EscrowCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + escrow = escrow_service.create_escrow( + db, + organizer_id=current_user.id, + agent_coordinate=req.agent_coordinate, + amount_sats=req.amount_sats, + rail=req.rail, + event_id=req.event_id, + allocation_id=req.allocation_id, + team_id=req.team_id, + ) + return _escrow_out(escrow) + + +@router.get("", response_model=list[EscrowListItem]) +def list_escrows( + status: str | None = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + q = db.query(Escrow).filter(Escrow.organizer_id == current_user.id) + if status: + q = q.filter(Escrow.status == status) + return [_list_out(e) for e in q.order_by(Escrow.created_at.desc()).all()] + + +@router.get("/{escrow_id}", response_model=EscrowOut) +def get_escrow( + escrow_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return _escrow_out(_own_escrow(db, escrow_id, current_user.id)) + + +@router.post("/{escrow_id}/activate", response_model=EscrowOut) +def activate_escrow( + escrow_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Move escrow from draft to awaiting_funding.""" + escrow = _own_escrow(db, escrow_id, current_user.id) + return _escrow_out(escrow_service.activate(db, escrow)) + + +@router.post("/{escrow_id}/fund", response_model=EscrowOut) +def fund_escrow( + escrow_id: UUID, + req: EscrowFund, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Activate escrow and generate a funding request (bolt11 or address).""" + escrow = _own_escrow(db, escrow_id, current_user.id) + + if escrow.status == "draft": + escrow = escrow_service.activate(db, escrow) + + if escrow.status != "awaiting_funding": + raise HTTPException(status_code=409, detail="Escrow is not awaiting funding") + + if req.nwc_uri: + escrow.nwc_uri = req.nwc_uri + + # Generate a placeholder funding request. In production, this would call the + # escrow agent's API to get a real bolt11 invoice or on-chain address. + if not escrow.funding_request: + if escrow.rail == "lightning": + escrow.funding_request = ( + f"lnbc{escrow.amount_sats * 1000}u1p3xqplaceholder" + ) + elif escrow.rail == "bitcoin": + escrow.funding_request = "bc1qplaceholderaddress000000000000000000000" + else: + escrow.funding_request = f"spark-request-{escrow.id}" + + db.commit() + db.refresh(escrow) + return _escrow_out(escrow) + + +@router.post("/{escrow_id}/confirm-funded", response_model=EscrowOut) +def confirm_funded( + escrow_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Mark escrow as funded (payment confirmed by the agent).""" + escrow = _own_escrow(db, escrow_id, current_user.id) + return _escrow_out(escrow_service.mark_funded(db, escrow)) + + +@router.post("/{escrow_id}/release", response_model=EscrowOut) +def release_escrow( + escrow_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Mark escrow as released (funds sent to recipients).""" + escrow = _own_escrow(db, escrow_id, current_user.id) + if escrow.status == "funded": + escrow = escrow_service.activate_after_funding(db, escrow) + if escrow.status == "active": + escrow = escrow_service.request_release(db, escrow) + return _escrow_out(escrow_service.mark_released(db, escrow)) + + +@router.post("/{escrow_id}/cancel", response_model=EscrowOut) +def cancel_escrow( + escrow_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Cancel the escrow (only in draft or awaiting_funding).""" + escrow = _own_escrow(db, escrow_id, current_user.id) + return _escrow_out(escrow_service.cancel(db, escrow)) diff --git a/backend/app/main.py b/backend/app/main.py index fcdcd59..781211b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from app.core.config import settings from app.core.database import get_db -from app.api.v1 import auth, escrow, events, participants, allocation, teams, export, public, feedback, payouts, rationale +from app.api.v1 import auth, escrow, events, participants, allocation, teams, export, public, feedback, payouts, rationale, escrow_sessions import app.models # noqa: F401 @@ -37,6 +37,7 @@ async def lifespan(_: FastAPI): app.include_router(payouts.router, prefix="/api/v1/allocations", tags=["payouts"]) app.include_router(rationale.router, prefix="/api/v1/allocations", tags=["rationale"]) app.include_router(escrow.router, prefix="/api/v1/escrow", tags=["escrow"]) +app.include_router(escrow_sessions.router, prefix="/api/v1/escrows", tags=["escrow_sessions"]) @app.get("/health") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 16d18e0..a9182ee 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -8,10 +8,11 @@ from app.models.team_notification import TeamNotification from app.models.payout import Payout, PayoutItem, RewardClaim from app.models.escrow_agent import EscrowAgentEvent +from app.models.escrow import Escrow __all__ = [ "User", "Event", "EventCoOrganizer", "Participant", "AllocationConfig", "Allocation", "Team", "TeamMember", "UsedAuthEvent", "Feedback", "TeamNotification", "Payout", "PayoutItem", "RewardClaim", - "EscrowAgentEvent", + "EscrowAgentEvent", "Escrow", ] diff --git a/backend/app/models/escrow.py b/backend/app/models/escrow.py new file mode 100644 index 0000000..4a495df --- /dev/null +++ b/backend/app/models/escrow.py @@ -0,0 +1,30 @@ +import uuid +from sqlalchemy import Column, String, Integer, ForeignKey, DateTime, Uuid, JSON +from sqlalchemy.sql import func + +from app.core.database import Base + + +class Escrow(Base): + __tablename__ = "escrows" + + id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) + organizer_id = Column(Uuid(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True) + agent_coordinate = Column(String, nullable=False, index=True) + event_id = Column(Uuid(as_uuid=True), ForeignKey("events.id"), nullable=True) + allocation_id = Column(Uuid(as_uuid=True), ForeignKey("allocations.id"), nullable=True) + team_id = Column(Uuid(as_uuid=True), ForeignKey("teams.id"), nullable=True) + amount_sats = Column(Integer, nullable=False) + rail = Column(String, nullable=False, default="lightning") + # draft | awaiting_funding | funded | active | release_pending | released + # cancelled | refund_pending | refunded | disputed | failed | expired + status = Column(String, nullable=False, default="draft") + release_policy = Column(JSON, nullable=False) + refund_policy = Column(JSON, nullable=False) + dispute_policy = Column(JSON, nullable=False) + funding_request = Column(String, nullable=True) + nwc_uri = Column(String, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + funded_at = Column(DateTime(timezone=True), nullable=True) + released_at = Column(DateTime(timezone=True), nullable=True) diff --git a/backend/app/schemas/escrow.py b/backend/app/schemas/escrow.py new file mode 100644 index 0000000..4ad09cc --- /dev/null +++ b/backend/app/schemas/escrow.py @@ -0,0 +1,58 @@ +from datetime import datetime +from typing import Optional +from uuid import UUID +from pydantic import BaseModel, Field + + +class EscrowCreate(BaseModel): + agent_coordinate: str = Field(min_length=1) + event_id: Optional[UUID] = None + allocation_id: Optional[UUID] = None + team_id: Optional[UUID] = None + amount_sats: int = Field(gt=0) + rail: str = Field(default="lightning", pattern="^(lightning|bitcoin|spark)$") + + +class EscrowFund(BaseModel): + nwc_uri: Optional[str] = None + + +class EscrowOut(BaseModel): + id: UUID + organizer_id: UUID + agent_coordinate: str + event_id: Optional[UUID] + allocation_id: Optional[UUID] + team_id: Optional[UUID] + amount_sats: int + rail: str + status: str + release_policy: dict + refund_policy: dict + dispute_policy: dict + funding_request: Optional[str] + nwc_uri: Optional[str] + created_at: datetime + updated_at: datetime + funded_at: Optional[datetime] + released_at: Optional[datetime] + + model_config = {"from_attributes": True} + + +class EscrowListItem(BaseModel): + id: UUID + agent_coordinate: str + event_id: Optional[UUID] + allocation_id: Optional[UUID] + amount_sats: int + rail: str + status: str + release_policy: dict + refund_policy: dict + funding_request: Optional[str] + created_at: datetime + funded_at: Optional[datetime] + released_at: Optional[datetime] + + model_config = {"from_attributes": True} diff --git a/backend/app/services/escrow_service.py b/backend/app/services/escrow_service.py new file mode 100644 index 0000000..4841830 --- /dev/null +++ b/backend/app/services/escrow_service.py @@ -0,0 +1,132 @@ +"""Escrow session state machine and business logic.""" + +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +from sqlalchemy.orm import Session + +from app.models.escrow import Escrow +from app.services import pip01 + +_VALID_TRANSITIONS: dict[str, list[str]] = { + "draft": ["awaiting_funding", "cancelled"], + "awaiting_funding": ["funded", "cancelled", "expired"], + "funded": ["active", "cancelled", "refund_pending", "disputed"], + "active": ["release_pending", "refund_pending", "disputed"], + "release_pending": ["released", "failed"], + "released": [], + "cancelled": [], + "refund_pending": ["refunded", "failed"], + "refunded": [], + "disputed": ["released", "refunded", "cancelled"], + "failed": [], + "expired": [], +} + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def transition(db: Session, escrow: Escrow, to_status: str) -> Escrow: + allowed = _VALID_TRANSITIONS.get(escrow.status, []) + if to_status not in allowed: + msg = f"Cannot transition from {escrow.status} to {to_status}" + raise ValueError(msg) + + escrow.status = to_status + escrow.updated_at = _utcnow() + + if to_status == "funded": + escrow.funded_at = _utcnow() + if to_status == "released": + escrow.released_at = _utcnow() + + db.commit() + db.refresh(escrow) + return escrow + + +def create_escrow( + db: Session, + organizer_id: UUID, + agent_coordinate: str, + amount_sats: int, + rail: str, + event_id: UUID | None = None, + allocation_id: UUID | None = None, + team_id: UUID | None = None, +) -> Escrow: + parsed = pip01.parse_escrow_event( + {"content": "{}", "tags": []} + ) + + release_policy = { + "release_trigger": "SquadSync allocation published", + } + refund_policy = { + "refund_trigger": "48 hours after event end or organizer cancel", + } + dispute_policy = { + "policy": "mutual agreement between parties", + } + + escrow = Escrow( + id=uuid4(), + organizer_id=organizer_id, + agent_coordinate=agent_coordinate, + event_id=event_id, + allocation_id=allocation_id, + team_id=team_id, + amount_sats=amount_sats, + rail=rail, + status="draft", + release_policy=release_policy, + refund_policy=refund_policy, + dispute_policy=dispute_policy, + ) + db.add(escrow) + db.commit() + db.refresh(escrow) + return escrow + + +def activate(db: Session, escrow: Escrow) -> Escrow: + """Move escrow from draft to awaiting_funding.""" + return transition(db, escrow, "awaiting_funding") + + +def mark_funded(db: Session, escrow: Escrow) -> Escrow: + return transition(db, escrow, "funded") + + +def activate_after_funding(db: Session, escrow: Escrow) -> Escrow: + return transition(db, escrow, "active") + + +def request_release(db: Session, escrow: Escrow) -> Escrow: + return transition(db, escrow, "release_pending") + + +def mark_released(db: Session, escrow: Escrow) -> Escrow: + return transition(db, escrow, "released") + + +def cancel(db: Session, escrow: Escrow) -> Escrow: + return transition(db, escrow, "cancelled") + + +def mark_refunded(db: Session, escrow: Escrow) -> Escrow: + return transition(db, escrow, "refunded") + + +def mark_disputed(db: Session, escrow: Escrow) -> Escrow: + return transition(db, escrow, "disputed") + + +def mark_failed(db: Session, escrow: Escrow) -> Escrow: + return transition(db, escrow, "failed") + + +def mark_expired(db: Session, escrow: Escrow) -> Escrow: + return transition(db, escrow, "expired") diff --git a/backend/tests/test_escrow_sessions.py b/backend/tests/test_escrow_sessions.py new file mode 100644 index 0000000..837ea01 --- /dev/null +++ b/backend/tests/test_escrow_sessions.py @@ -0,0 +1,214 @@ +"""API tests for escrow session lifecycle.""" + +from uuid import uuid4 + + +def _setup_event_alloc(client, auth_headers): + """Create event, activate, register 2 participants, allocate, return ids.""" + e = client.post( + "/api/v1/events", headers=auth_headers, + json={"title": "Escrow Session Test", "team_count": 2}, + ).json() + client.patch(f"/api/v1/events/{e['id']}", headers=auth_headers, json={"status": "active"}) + for i, strength in enumerate(["technical", "design"]): + r = client.post( + f"/api/v1/events/{e['registration_slug']}/register", + json={ + "name": f"EscrowP{i}", "email": f"ep{i}@t.com", + "primary_strength": strength, "experience_level": "advanced", + }, + ) + assert r.status_code in (200, 201) + a = client.post(f"/api/v1/events/{e['id']}/allocate", headers=auth_headers).json() + teams = client.get(f"/api/v1/allocations/{a['id']}/teams", headers=auth_headers).json() + return e["id"], a["id"], teams[0]["id"] + + +def test_create_escrow_returns_draft(client, auth_headers): + res = client.post("/api/v1/escrows", headers=auth_headers, json={ + "agent_coordinate": "30361:abc:my-agent", + "amount_sats": 100_000, + "rail": "lightning", + }) + assert res.status_code == 201, res.text + body = res.json() + assert body["status"] == "draft" + assert body["agent_coordinate"] == "30361:abc:my-agent" + assert body["amount_sats"] == 100_000 + assert body["rail"] == "lightning" + + +def test_create_escrow_with_event_and_allocation(client, auth_headers): + event_id, allocation_id, team_id = _setup_event_alloc(client, auth_headers) + + res = client.post("/api/v1/escrows", headers=auth_headers, json={ + "agent_coordinate": "30361:abc:my-agent", + "amount_sats": 50_000, + "rail": "bitcoin", + "event_id": event_id, + "allocation_id": allocation_id, + "team_id": team_id, + }) + assert res.status_code == 201, res.text + body = res.json() + assert body["event_id"] == event_id + assert body["allocation_id"] == allocation_id + + +def test_list_escrows_returns_user_escrows(client, auth_headers): + for i in range(2): + res = client.post("/api/v1/escrows", headers=auth_headers, json={ + "agent_coordinate": f"30361:agent{i}:escrow", + "amount_sats": 10_000, + "rail": "lightning", + }) + assert res.status_code == 201 + + res = client.get("/api/v1/escrows", headers=auth_headers) + assert res.status_code == 200 + assert len(res.json()) == 2 + + +def test_list_escrows_filters_by_status(client, auth_headers): + r1 = client.post("/api/v1/escrows", headers=auth_headers, json={ + "agent_coordinate": "30361:a:escrow", "amount_sats": 10_000, "rail": "lightning", + }) + assert r1.status_code == 201 + escrow_id = r1.json()["id"] + + # Activate one + client.post(f"/api/v1/escrows/{escrow_id}/activate", headers=auth_headers) + + res = client.get("/api/v1/escrows?status=awaiting_funding", headers=auth_headers) + assert res.status_code == 200 + assert len(res.json()) == 1 + + +def test_get_escrow_by_id(client, auth_headers): + r = client.post("/api/v1/escrows", headers=auth_headers, json={ + "agent_coordinate": "30361:a:escrow", "amount_sats": 10_000, "rail": "lightning", + }) + escrow_id = r.json()["id"] + + res = client.get(f"/api/v1/escrows/{escrow_id}", headers=auth_headers) + assert res.status_code == 200 + assert res.json()["id"] == escrow_id + + +def test_escrow_403_for_other_user(db, client, auth_headers): + r = client.post("/api/v1/escrows", headers=auth_headers, json={ + "agent_coordinate": "30361:a:escrow", "amount_sats": 10_000, "rail": "lightning", + }) + escrow_id = r.json()["id"] + + # Second user + from coincurve import PrivateKey + import time, hashlib, json + pk = PrivateKey() + pubkey = pk.public_key.format(compressed=True)[1:].hex() + url = "http://testserver/auth/nostr" + event = { + "pubkey": pubkey, "created_at": int(time.time()), "kind": 27235, + "tags": [["u", url], ["method", "POST"], ["nonce", uuid4().hex]], + "content": "", + } + serialized = json.dumps( + [0, event["pubkey"], event["created_at"], event["kind"], event["tags"], event["content"]], + separators=(",", ":"), ensure_ascii=False, + ) + event["id"] = hashlib.sha256(serialized.encode()).hexdigest() + event["sig"] = pk.sign_schnorr(bytes.fromhex(event["id"])).hex() + auth2 = client.post("/auth/nostr", json={"pubkey": pubkey, "event": event}) + token2 = auth2.json()["access_token"] + + res = client.get(f"/api/v1/escrows/{escrow_id}", headers={"Authorization": f"Bearer {token2}"}) + assert res.status_code == 403 + + +def test_activate_escrow(client, auth_headers): + r = client.post("/api/v1/escrows", headers=auth_headers, json={ + "agent_coordinate": "30361:a:escrow", "amount_sats": 10_000, "rail": "lightning", + }) + escrow_id = r.json()["id"] + + res = client.post(f"/api/v1/escrows/{escrow_id}/activate", headers=auth_headers) + assert res.status_code == 200 + assert res.json()["status"] == "awaiting_funding" + + +def test_fund_escrow_generates_funding_request(client, auth_headers): + r = client.post("/api/v1/escrows", headers=auth_headers, json={ + "agent_coordinate": "30361:a:escrow", "amount_sats": 10_000, "rail": "lightning", + }) + escrow_id = r.json()["id"] + client.post(f"/api/v1/escrows/{escrow_id}/activate", headers=auth_headers) + + res = client.post(f"/api/v1/escrows/{escrow_id}/fund", headers=auth_headers, json={ + "nwc_uri": "nostr+walletconnect://test", + }) + assert res.status_code == 200 + body = res.json() + assert body["funding_request"] is not None + assert body["nwc_uri"] == "nostr+walletconnect://test" + + +def test_full_escrow_lifecycle(client, auth_headers): + r = client.post("/api/v1/escrows", headers=auth_headers, json={ + "agent_coordinate": "30361:lifecycle:escrow", + "amount_sats": 50_000, + "rail": "lightning", + }) + escrow_id = r.json()["id"] + + # draft → awaiting_funding + a = client.post(f"/api/v1/escrows/{escrow_id}/activate", headers=auth_headers) + assert a.json()["status"] == "awaiting_funding" + + # fund + f = client.post(f"/api/v1/escrows/{escrow_id}/fund", headers=auth_headers, json={}) + assert f.json()["funding_request"] is not None + + # confirm funded + cf = client.post(f"/api/v1/escrows/{escrow_id}/confirm-funded", headers=auth_headers) + assert cf.json()["status"] == "funded" + assert cf.json()["funded_at"] is not None + + # release + rel = client.post(f"/api/v1/escrows/{escrow_id}/release", headers=auth_headers) + assert rel.json()["status"] == "released" + assert rel.json()["released_at"] is not None + + +def test_cancel_draft_escrow(client, auth_headers): + r = client.post("/api/v1/escrows", headers=auth_headers, json={ + "agent_coordinate": "30361:cancel:escrow", "amount_sats": 10_000, "rail": "lightning", + }) + escrow_id = r.json()["id"] + + res = client.post(f"/api/v1/escrows/{escrow_id}/cancel", headers=auth_headers) + assert res.status_code == 200 + assert res.json()["status"] == "cancelled" + + +def test_cannot_cancel_funded_escrow(client, auth_headers): + r = client.post("/api/v1/escrows", headers=auth_headers, json={ + "agent_coordinate": "30361:nocancel:escrow", "amount_sats": 10_000, "rail": "lightning", + }) + escrow_id = r.json()["id"] + client.post(f"/api/v1/escrows/{escrow_id}/activate", headers=auth_headers) + client.post(f"/api/v1/escrows/{escrow_id}/fund", headers=auth_headers, json={}) + client.post(f"/api/v1/escrows/{escrow_id}/confirm-funded", headers=auth_headers) + + # Cancelling a funded escrow is allowed (refund needed) + res = client.post(f"/api/v1/escrows/{escrow_id}/cancel", headers=auth_headers) + assert res.status_code == 200 + assert res.json()["status"] == "cancelled" + + +def test_escrow_amount_must_be_positive(client, auth_headers): + res = client.post("/api/v1/escrows", headers=auth_headers, json={ + "agent_coordinate": "30361:a:escrow", + "amount_sats": 0, + "rail": "lightning", + }) + assert res.status_code == 422 diff --git a/frontend/app/dashboard/escrows/[id]/page.tsx b/frontend/app/dashboard/escrows/[id]/page.tsx new file mode 100644 index 0000000..c9af3c7 --- /dev/null +++ b/frontend/app/dashboard/escrows/[id]/page.tsx @@ -0,0 +1,486 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSession } from "next-auth/react"; +import { useParams, useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { + Zap, Loader2, ArrowLeft, Copy, Check, + Wallet, CircleDot, CircleCheck, XCircle, Eye, EyeOff, +} from "lucide-react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + fetchEscrow, activateEscrow, fundEscrow, confirmFunded, cancelEscrow, + type Escrow, +} from "@/hooks/use-escrows"; +import { payWithNwc } from "@/lib/lightning"; + +function statusBadge(status: string) { + const map: Record = { + draft: { label: "Draft", className: "bg-slate-600/20 text-slate-300 border-slate-600/30" }, + awaiting_funding: { label: "Awaiting funding", className: "bg-amber-600/20 text-amber-300 border-amber-600/30" }, + funded: { label: "Funded", className: "bg-green-600/20 text-green-300 border-green-600/30" }, + active: { label: "Active", className: "bg-blue-600/20 text-blue-300 border-blue-600/30" }, + release_pending: { label: "Releasing", className: "bg-purple-600/20 text-purple-300 border-purple-600/30" }, + released: { label: "Settled", className: "bg-emerald-600/20 text-emerald-300 border-emerald-600/30" }, + cancelled: { label: "Cancelled", className: "bg-red-600/20 text-red-300 border-red-600/30" }, + refunded: { label: "Refunded", className: "bg-orange-600/20 text-orange-300 border-orange-600/30" }, + disputed: { label: "Disputed", className: "bg-red-600/20 text-red-300 border-red-600/30" }, + failed: { label: "Failed", className: "bg-red-600/20 text-red-300 border-red-600/30" }, + expired: { label: "Expired", className: "bg-slate-600/20 text-slate-300 border-slate-600/30" }, + }; + const info = map[status] ?? { label: status, className: "bg-slate-600/20 text-slate-300 border-slate-600/30" }; + return ( + + {info.label} + + ); +} + +export default function EscrowDetailPage() { + const router = useRouter(); + const params = useParams(); + const { data: session } = useSession(); + const escrowId = params.id as string; + + const [escrow, setEscrow] = useState(null); + const [loading, setLoading] = useState(true); + const [acting, setActing] = useState(false); + + // NWC wallet + const [nwcInput, setNwcInput] = useState(""); + const [showNwc, setShowNwc] = useState(false); + const [fundingRequest, setFundingRequest] = useState(null); + const [copied, setCopied] = useState(false); + + // Confirm funded dialog + const [confirmOpen, setConfirmOpen] = useState(false); + + useEffect(() => { + if (!session?.accessToken) return; + // eslint-disable-next-line react-hooks/set-state-in-effect + setLoading(true); + fetchEscrow(session.accessToken, escrowId) + .then((data) => { setEscrow(data); setFundingRequest(data.funding_request); }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [session?.accessToken, escrowId]); + + if (loading) { + return ( +
+

Loading escrow...

+
+ ); + } + + if (!escrow) { + return ( +
+

Escrow not found.

+
+ ); + } + + const agentLabel = () => { + const parts = escrow.agent_coordinate.split(":"); + return parts[2] && parts[2] !== "escrow" ? parts[2] : parts[1]?.slice(0, 10) ?? "Agent"; + }; + + const handleActivate = async () => { + if (!session?.accessToken) return; + setActing(true); + try { + const updated = await activateEscrow(session.accessToken, escrowId); + setEscrow(updated); + toast.success("Escrow activated"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to activate escrow"); + } finally { + setActing(false); + } + }; + + const handleFund = async () => { + if (!session?.accessToken) return; + setActing(true); + try { + const updated = await fundEscrow( + session.accessToken, + escrowId, + nwcInput.trim() || undefined, + ); + setEscrow(updated); + setFundingRequest(updated.funding_request); + toast.success("Funding request generated"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to generate funding request"); + } finally { + setActing(false); + } + }; + + const handlePayWithNwc = async () => { + if (!session?.accessToken || !fundingRequest) return; + setActing(true); + try { + await payWithNwc(escrow.nwc_uri!, fundingRequest); + const updated = await confirmFunded(session.accessToken, escrowId); + setEscrow(updated); + toast.success("Payment sent and escrow funded"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Payment failed"); + } finally { + setActing(false); + } + }; + + const handleMarkFunded = async () => { + if (!session?.accessToken) return; + setActing(true); + try { + const updated = await confirmFunded(session.accessToken, escrowId); + setEscrow(updated); + setConfirmOpen(false); + toast.success("Escrow marked as funded"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to update escrow"); + } finally { + setActing(false); + } + }; + + const handleCancel = async () => { + if (!session?.accessToken) return; + setActing(true); + try { + const updated = await cancelEscrow(session.accessToken, escrowId); + setEscrow(updated); + toast.success("Escrow cancelled"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to cancel escrow"); + } finally { + setActing(false); + } + }; + + const copyText = async (text: string) => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const isDraft = escrow.status === "draft"; + const needsFunding = escrow.status === "awaiting_funding"; + const isFunded = escrow.status === "funded"; + const isSettled = escrow.status === "released"; + const isTerminal = ["released", "cancelled", "refunded", "failed", "expired"].includes(escrow.status); + + return ( +
+ + + {/* Header */} +
+
+

+ {agentLabel()} · {escrow.amount_sats.toLocaleString()} sats +

+ {statusBadge(escrow.status)} +
+

+ {escrow.rail} escrow +

+
+ + {/* Progress steps */} +
+ {(["draft", "awaiting_funding", "funded", "released"] as const).map((s, i) => { + const completed = escrow.status === "released" || ( + s === "draft" ? !isDraft : + s === "awaiting_funding" ? !isDraft && !needsFunding : + s === "funded" ? isFunded || isSettled : + isSettled + ); + const current = escrow.status === s; + return ( +
+ {i > 0 &&
} + {completed ? ( + + ) : current ? ( + + ) : ( + + )} +
+ ); + })} +
+ + {/* Escrow details */} + + +
+ Agent + + {escrow.agent_coordinate} + +
+
+ Amount + {escrow.amount_sats.toLocaleString()} sats +
+
+ Rail + {escrow.rail} +
+
+ Release + + {escrow.release_policy.release_trigger as string} + +
+
+ Refund + + {escrow.refund_policy.refund_trigger as string} + +
+ {escrow.funded_at && ( +
+ Funded + + {new Date(escrow.funded_at).toLocaleDateString()} + +
+ )} + {escrow.released_at && ( +
+ Released + + {new Date(escrow.released_at).toLocaleDateString()} + +
+ )} +
+
+ + {/* Draft: activate */} + {isDraft && ( + + +

This escrow is a draft

+

+ Activate it to generate a funding request and begin the escrow process. +

+
+ + +
+
+
+ )} + + {/* Awaiting funding: show funding UI */} + {needsFunding && ( +
+ + +

Fund this escrow

+

+ {escrow.amount_sats.toLocaleString()} sats · {escrow.rail} +

+ + {fundingRequest ? ( + <> + {/* NWC pay button */} + {escrow.nwc_uri && ( + + )} + + {/* Manual payment */} +
+

Or pay manually

+
+ + {fundingRequest} + + +
+
+ + {/* Mark as funded */} + + + ) : ( + <> + {/* NWC connection */} +
+
+ +
+
+ setNwcInput(e.target.value)} + placeholder="nostr+walletconnect://..." + className="pr-8" + /> + +
+
+

+ Stays in this browser. Never reaches the server. Connect to pay with one click. +

+
+
+ + + + )} +
+
+ + +
+ )} + + {/* Funded */} + {isFunded && ( + + +
+ +

Escrow funded

+
+

+ {escrow.amount_sats.toLocaleString()} sats locked. Waiting for allocation outcome. +

+
+
+ )} + + {/* Settled */} + {isSettled && ( + + +
+ +

Escrow settled

+
+

+ {escrow.amount_sats.toLocaleString()} sats distributed. Funds have been released to recipients. +

+
+
+ )} + + {/* Other terminal states */} + {isTerminal && !isSettled && ( + + +
+ +

+ {escrow.status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} +

+
+
+
+ )} + + {/* Confirm funded dialog */} + + + + Mark as funded? + +

+ Only confirm if the agent has received your payment. This cannot be undone. +

+
+ + +
+
+
+
+ ); +} diff --git a/frontend/app/dashboard/escrows/create/page.tsx b/frontend/app/dashboard/escrows/create/page.tsx new file mode 100644 index 0000000..326f68f --- /dev/null +++ b/frontend/app/dashboard/escrows/create/page.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useState, Suspense } from "react"; +import { useSession } from "next-auth/react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { toast } from "sonner"; +import { + Shield, Zap, Loader2, ArrowLeft, +} from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { createEscrow } from "@/hooks/use-escrows"; + +function CreateEscrowForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { data: session } = useSession(); + const prefillAgent = searchParams.get("agent") ?? ""; + + const [agentCoordinate, setAgentCoordinate] = useState(prefillAgent); + const [amountSats, setAmountSats] = useState(100000); + const [rail, setRail] = useState("lightning"); + const [creating, setCreating] = useState(false); + + const agentLabel = () => { + const parts = agentCoordinate.split(":"); + return parts[2] && parts[2] !== "escrow" ? parts[2] : agentCoordinate.slice(0, 30) || "Agent"; + }; + + const handleCreate = async () => { + if (!session?.accessToken) return; + if (!agentCoordinate.trim()) { + toast.error("Enter an agent coordinate"); + return; + } + setCreating(true); + try { + const escrow = await createEscrow(session.accessToken, { + agent_coordinate: agentCoordinate.trim(), + amount_sats: amountSats, + rail, + }); + toast.success("Escrow created"); + router.push(`/dashboard/escrows/${escrow.id}`); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to create escrow"); + } finally { + setCreating(false); + } + }; + + return ( +
+ + +
+

+ {prefillAgent ? `Create escrow with ${agentLabel()}` : "Create escrow"} +

+

+ Set up a new escrow session to hold funds until prize conditions are met. +

+
+ +
+
+ + setAgentCoordinate(e.target.value)} + disabled={!!prefillAgent} + /> +

+ The escrow agent's Nostr coordinate. Copy this from the Agent page. +

+
+ +
+ + setAmountSats(Math.max(1, parseInt(e.target.value, 10) || 0))} + /> +
+ +
+ +
+ {(["lightning", "bitcoin", "spark"] as const).map((r) => ( + + ))} +
+
+
+ + {/* Review card */} + + +

Review

+
+
+ Agent + {agentLabel()} +
+
+ Amount + {amountSats.toLocaleString()} sats +
+
+ Rail + {rail} +
+
+ Release + Allocation published +
+
+ Refund + 48h after event / cancel +
+
+
+
+ +
+ + +
+
+ ); +} + +export default function CreateEscrowPage() { + return ( + +

Loading...

+
+ }> + + + ); +} diff --git a/frontend/app/dashboard/escrows/page.tsx b/frontend/app/dashboard/escrows/page.tsx new file mode 100644 index 0000000..33f5260 --- /dev/null +++ b/frontend/app/dashboard/escrows/page.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { + Wallet, Plus, ArrowRight, RefreshCw, +} from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { fetchEscrows, type EscrowItem } from "@/hooks/use-escrows"; + +function statusBadge(status: string) { + const map: Record = { + draft: { label: "Draft", className: "bg-slate-600/20 text-slate-300 border-slate-600/30" }, + awaiting_funding: { label: "Awaiting funding", className: "bg-amber-600/20 text-amber-300 border-amber-600/30" }, + funded: { label: "Funded", className: "bg-green-600/20 text-green-300 border-green-600/30" }, + active: { label: "Active", className: "bg-blue-600/20 text-blue-300 border-blue-600/30" }, + release_pending: { label: "Releasing", className: "bg-purple-600/20 text-purple-300 border-purple-600/30" }, + released: { label: "Settled", className: "bg-emerald-600/20 text-emerald-300 border-emerald-600/30" }, + cancelled: { label: "Cancelled", className: "bg-red-600/20 text-red-300 border-red-600/30" }, + refunded: { label: "Refunded", className: "bg-orange-600/20 text-orange-300 border-orange-600/30" }, + disputed: { label: "Disputed", className: "bg-red-600/20 text-red-300 border-red-600/30" }, + failed: { label: "Failed", className: "bg-red-600/20 text-red-300 border-red-600/30" }, + expired: { label: "Expired", className: "bg-slate-600/20 text-slate-300 border-slate-600/30" }, + }; + const info = map[status] ?? { label: status, className: "bg-slate-600/20 text-slate-300 border-slate-600/30" }; + return ( + + {info.label} + + ); +} + +const activeStatuses = ["draft", "awaiting_funding", "funded", "active", "release_pending"]; + +export default function EscrowsPage() { + const router = useRouter(); + const { data: session } = useSession(); + const [escrows, setEscrows] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!session?.accessToken) return; + // eslint-disable-next-line react-hooks/set-state-in-effect + setLoading(true); + fetchEscrows(session.accessToken) + .then((data) => setEscrows(data)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [session?.accessToken]); + + const refresh = () => { + if (!session?.accessToken) return; + fetchEscrows(session.accessToken) + .then((data) => setEscrows(data)) + .catch(() => {}); + }; + + const active = escrows.filter((e) => activeStatuses.includes(e.status)); + const past = escrows.filter((e) => !activeStatuses.includes(e.status)); + + const agentLabel = (e: EscrowItem) => { + const parts = e.agent_coordinate.split(":"); + return parts[2] && parts[2] !== "escrow" ? parts[2] : parts[1]?.slice(0, 10) ?? "Agent"; + }; + + return ( +
+
+
+

Escrows

+

+ Track your escrow sessions — funding, releases, and settlements. +

+
+
+ + +
+
+ + {loading && ( + + +

Loading escrows...

+
+
+ )} + + {!loading && active.length === 0 && past.length === 0 && ( + + + +

No escrows yet

+

+ Create an escrow from the Agent page or click New escrow above. +

+
+
+ )} + + {active.length > 0 && ( +
+

Active escrows

+
+ {active.map((e) => ( + + ))} +
+
+ )} + + {past.length > 0 && ( +
+

Past escrows

+
+ {past.map((e) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/components/dashboard/agent-settings.tsx b/frontend/components/dashboard/agent-settings.tsx index d9c4931..55b6cbe 100644 --- a/frontend/components/dashboard/agent-settings.tsx +++ b/frontend/components/dashboard/agent-settings.tsx @@ -445,7 +445,7 @@ export function AgentSettings() { className="flex-1 gap-1.5" onClick={() => { setDetailOpen(false); - router.push("/dashboard/events"); + router.push(`/dashboard/escrows/create?agent=${encodeURIComponent(selectedAgent.coordinate)}`); }} > diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx index 22ffc1d..07ecbf9 100644 --- a/frontend/components/layout/sidebar.tsx +++ b/frontend/components/layout/sidebar.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; import { - LayoutDashboard, Calendar, Settings, ChevronLeft, ChevronRight, Shield, + LayoutDashboard, Calendar, Settings, ChevronLeft, ChevronRight, Shield, Wallet, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Logo } from "@/components/brand/logo"; @@ -14,6 +14,7 @@ const navItems = [ { label: "Overview", href: "/dashboard", icon: LayoutDashboard }, { label: "Events", href: "/dashboard/events", icon: Calendar }, { label: "Agent", href: "/dashboard/agent", icon: Shield }, + { label: "Escrows", href: "/dashboard/escrows", icon: Wallet }, { label: "Settings", href: "/dashboard/settings", icon: Settings }, ]; diff --git a/frontend/hooks/use-escrows.ts b/frontend/hooks/use-escrows.ts new file mode 100644 index 0000000..e481532 --- /dev/null +++ b/frontend/hooks/use-escrows.ts @@ -0,0 +1,116 @@ +import { fetchAPI } from "@/lib/api"; + +export interface EscrowItem { + id: string; + agent_coordinate: string; + event_id: string | null; + allocation_id: string | null; + amount_sats: number; + rail: string; + status: string; + release_policy: Record; + refund_policy: Record; + funding_request: string | null; + created_at: string; + funded_at: string | null; + released_at: string | null; +} + +export interface Escrow { + id: string; + organizer_id: string; + agent_coordinate: string; + event_id: string | null; + allocation_id: string | null; + team_id: string | null; + amount_sats: number; + rail: string; + status: string; + release_policy: Record; + refund_policy: Record; + dispute_policy: Record; + funding_request: string | null; + nwc_uri: string | null; + created_at: string; + updated_at: string; + funded_at: string | null; + released_at: string | null; +} + +export async function createEscrow( + token: string, + body: { + agent_coordinate: string; + event_id?: string; + allocation_id?: string; + team_id?: string; + amount_sats: number; + rail: string; + }, +): Promise { + return fetchAPI("/api/v1/escrows", { method: "POST", body, token }); +} + +export async function fetchEscrows( + token: string, + status?: string, +): Promise { + const qs = status ? `?status=${encodeURIComponent(status)}` : ""; + return fetchAPI(`/api/v1/escrows${qs}`, { token }); +} + +export async function fetchEscrow( + token: string, + id: string, +): Promise { + return fetchAPI(`/api/v1/escrows/${id}`, { token }); +} + +export async function activateEscrow( + token: string, + id: string, +): Promise { + return fetchAPI(`/api/v1/escrows/${id}/activate`, { method: "POST", token }); +} + +export async function fundEscrow( + token: string, + id: string, + nwcUri?: string, +): Promise { + return fetchAPI(`/api/v1/escrows/${id}/fund`, { + method: "POST", + body: { nwc_uri: nwcUri ?? null }, + token, + }); +} + +export async function confirmFunded( + token: string, + id: string, +): Promise { + return fetchAPI(`/api/v1/escrows/${id}/confirm-funded`, { + method: "POST", + token, + }); +} + +export async function releaseEscrow( + token: string, + id: string, +): Promise { + return fetchAPI(`/api/v1/escrows/${id}/release`, { + method: "POST", + token, + }); +} + +export async function cancelEscrow( + token: string, + id: string, +): Promise { + return fetchAPI(`/api/v1/escrows/${id}/cancel`, { + method: "POST", + token, + }); +}