Skip to content
Merged
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
52 changes: 52 additions & 0 deletions backend/alembic/versions/0012_escrow_sessions.py
Original file line number Diff line number Diff line change
@@ -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")
189 changes: 189 additions & 0 deletions backend/app/api/v1/escrow_sessions.py
Original file line number Diff line number Diff line change
@@ -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))
3 changes: 2 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
30 changes: 30 additions & 0 deletions backend/app/models/escrow.py
Original file line number Diff line number Diff line change
@@ -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)
58 changes: 58 additions & 0 deletions backend/app/schemas/escrow.py
Original file line number Diff line number Diff line change
@@ -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}
Loading
Loading