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
4 changes: 2 additions & 2 deletions backend/app/api/v1/api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from fastapi import APIRouter

from app.api.v1.endpoints import admin, artisan, auth, booking, health, payments, user
from app.api.v1.endpoints import admin, artisan, auth, booking, health, payments, stats, user

api_router = APIRouter()

Expand All @@ -12,4 +12,4 @@
api_router.include_router(artisan.router, tags=["artisans"])
api_router.include_router(admin.router, tags=["admin"])
api_router.include_router(payments.router, prefix="/payments", tags=["payments"])
api_router.include_router(stats.router, tags=["stats"])
api_router.include_router(stats.router, tags=["stats"])
85 changes: 84 additions & 1 deletion backend/app/api/v1/endpoints/payments.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# app/api/v1/endpoints/payments.py
import uuid
from decimal import Decimal
from typing import Any

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from stellar_sdk import TransactionEnvelope
Expand All @@ -13,7 +14,9 @@
from app.models.booking import Booking
from app.models.user import User
from app.services import payments as payments_service
from app.services.email import send_auto_release_email
from app.services.payments import (
auto_release_milestone_payment,
prepare_payment,
refund_payment,
release_payment,
Expand Down Expand Up @@ -47,6 +50,36 @@ class RefundRequest(BaseModel):
amount: Decimal = Field(..., gt=0)


class OracleAutoReleaseRequest(BaseModel):
booking_id: str
engagement_id: int = Field(..., gt=0)
token_address: str = Field(..., min_length=3)
confidence_score: float = Field(..., ge=0, le=1)
test_results: dict[str, Any] | list[str] | str


def _serialize_test_results(test_results: dict[str, Any] | list[str] | str) -> str:
if isinstance(test_results, str):
return test_results
if isinstance(test_results, list):
return "\n".join(f"- {item}" for item in test_results)
return "\n".join(f"- {key}: {value}" for key, value in test_results.items())


def _require_oracle_token(x_oracle_token: str | None) -> None:
if not settings.BACKEND_ORACLE_TOKEN:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Backend oracle token is not configured",
)

if x_oracle_token != settings.BACKEND_ORACLE_TOKEN:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid backend oracle token",
)


# The old /hold endpoint has been removed due to security concerns. Clients
# should use the two-step prepare/submit flow instead. A request to this path
# will now return 404 (FastAPI simply won't register it).
Expand Down Expand Up @@ -170,3 +203,53 @@ def refund(req: RefundRequest, db: Session = Depends(get_db)):
if res.get("status") == "error":
raise HTTPException(status_code=400, detail=res.get("message"))
return res


@router.post(
"/oracle/auto-release",
summary="Auto-release escrow through the backend oracle for high-confidence jobs",
)
def auto_release(
req: OracleAutoReleaseRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
x_oracle_token: str | None = Header(default=None, alias="X-Oracle-Token"),
):
_require_oracle_token(x_oracle_token)
rendered_test_results = _serialize_test_results(req.test_results)
result = auto_release_milestone_payment(
db,
booking_id=req.booking_id,
engagement_id=req.engagement_id,
token_address=req.token_address,
confidence_score=req.confidence_score,
test_results=rendered_test_results,
threshold=settings.AUTO_RELEASE_CONFIDENCE_THRESHOLD,
)

if result.get("status") == "error":
raise HTTPException(status_code=400, detail=result["message"])

if result.get("status") in {"success", "exists"}:
background_tasks.add_task(
send_auto_release_email,
to=result["client_email"],
full_name=result["client_name"],
booking_id=req.booking_id,
confidence_score=req.confidence_score,
transaction_hash=result["transaction_hash"],
test_results=rendered_test_results,
)
result["client_notification"] = {
"channel": "email",
"recipient": result["client_email"],
"delivered_test_results": True,
}
else:
result["client_notification"] = {
"channel": "email",
"recipient": None,
"delivered_test_results": False,
}

return result
8 changes: 7 additions & 1 deletion backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from pydantic import AnyHttpUrl, field_validator
from pydantic_settings import BaseSettings
from stellar_sdk import Network


class Settings(BaseSettings):
Expand Down Expand Up @@ -55,10 +56,15 @@ def assemble_cors_origins(cls, v: str | list[str]) -> list[str] | str:
STRIPE_SECRET_KEY: str | None = None
STRIPE_PUBLISHABLE_KEY: str | None = None

# Soroban Configuration
# Soroban Configuration
SOROBAN_RPC_URL: str = "https://soroban-testnet.stellar.org"
SOROBAN_NETWORK_PASSPHRASE: str = Network.TESTNET_NETWORK_PASSPHRASE
SOROBAN_BASE_FEE: int = 300
ESCROW_CONTRACT_ID: str | None = None
REPUTATION_CONTRACT_ID: str | None = None
BACKEND_SECRET_KEY: str | None = None
BACKEND_ORACLE_TOKEN: str | None = None
AUTO_RELEASE_CONFIDENCE_THRESHOLD: float = 0.90

model_config = {"env_file": ".env", "case_sensitive": True, "extra": "ignore"}

Expand Down
2 changes: 1 addition & 1 deletion backend/app/schemas/user.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

import re
from enum import StrEnum
from enum import Enum, StrEnum

from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator

Expand Down
48 changes: 48 additions & 0 deletions backend/app/services/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,51 @@ async def send_verification_email(to: str, full_name: str, verify_url: str) -> N

fm = FastMail(conf)
await fm.send_message(message)


async def send_auto_release_email(
to: str,
full_name: str,
booking_id: str,
confidence_score: float,
transaction_hash: str,
test_results: str,
) -> None:
"""Notify the client that the backend oracle auto-released an escrow."""
subject = f"{settings.PROJECT_NAME} - Automated milestone release completed"
body = (
f"Hi {full_name},\n\n"
"Your milestone was automatically approved and released because the AI "
f"confidence score reached {confidence_score:.2f}, above the "
f"{settings.AUTO_RELEASE_CONFIDENCE_THRESHOLD:.2f} threshold.\n\n"
f"Booking ID: {booking_id}\n"
f"Transaction Hash: {transaction_hash}\n\n"
"Test results:\n"
f"{test_results}\n\n"
"No manual approval was required from you for this release."
)

message = MessageSchema(
subject=subject,
recipients=[to],
body=body,
subtype="plain",
)

conf = ConnectionConfig(
MAIL_USERNAME=settings.SMTP_USER or "",
MAIL_PASSWORD=settings.SMTP_PASSWORD or "",
MAIL_FROM=(
settings.EMAILS_FROM or settings.SMTP_USER or "no-reply@example.com"
),
MAIL_PORT=settings.SMTP_PORT or 587,
MAIL_SERVER=settings.SMTP_HOST or "localhost",
MAIL_STARTTLS=settings.SMTP_TLS,
MAIL_SSL_TLS=not settings.SMTP_TLS,
USE_CREDENTIALS=True,
VALIDATE_CERTS=True,
SUPPRESS_SEND=(settings.SMTP_HOST is None or settings.SMTP_HOST == "localhost"),
)

fm = FastMail(conf)
await fm.send_message(message)
98 changes: 98 additions & 0 deletions backend/app/services/payments.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from app.models.booking import Booking
from app.models.payment import Payment, PaymentStatus
from app.services.soroban import release_escrow_via_oracle

# Stellar config
HORIZON = os.getenv("STELLAR_HORIZON", "https://horizon-testnet.stellar.org")
Expand Down Expand Up @@ -367,3 +368,100 @@ def submit_signed_payment(db: Session, signed_xdr: str) -> dict[str, Any]:
return {"status": "error", "message": str(e)}
except Exception as e:
return {"status": "error", "message": f"Invalid or rejected transaction: {e}"}


def auto_release_milestone_payment(
db: Session,
*,
booking_id: str,
engagement_id: int,
token_address: str,
confidence_score: float,
test_results: str,
threshold: float,
) -> dict[str, Any]:
"""Trigger a Soroban escrow release when the confidence score is high enough."""
if confidence_score <= threshold:
return {
"status": "skipped",
"auto_released": False,
"reason": (
f"Confidence score {confidence_score:.2f} did not exceed the "
f"{threshold:.2f} auto-release threshold."
),
"confidence_score": confidence_score,
"threshold": threshold,
"test_results": test_results,
}

try:
booking_uuid = uuid.UUID(booking_id)
except ValueError:
return {"status": "error", "message": "Booking not found"}

booking = db.query(Booking).filter(Booking.id == booking_uuid).first()
if not booking:
return {"status": "error", "message": "Booking not found"}

held_payment = (
db.query(Payment)
.filter(Payment.booking_id == booking_uuid, Payment.status == PaymentStatus.HELD)
.order_by(Payment.created_at.desc())
.first()
)
if not held_payment:
return {
"status": "error",
"message": "No held escrow payment found for this booking",
}

existing_release = (
db.query(Payment)
.filter(
Payment.booking_id == booking_uuid,
Payment.status == PaymentStatus.RELEASED,
)
.order_by(Payment.created_at.desc())
.first()
)
if existing_release:
return {
"status": "exists",
"auto_released": True,
"payment_id": str(existing_release.id),
"transaction_hash": existing_release.transaction_hash,
"confidence_score": confidence_score,
"threshold": threshold,
"test_results": test_results,
"client_email": booking.client.user.email,
"client_name": booking.client.user.full_name or "there",
}

soroban_result = release_escrow_via_oracle(engagement_id, token_address)
if not soroban_result.get("success"):
return {"status": "error", "message": "Oracle release transaction failed"}

memo = f"auto-release-{str(booking_uuid)[:15]}"[:MAX_MEMO_LENGTH]
record = _record_payment(
db,
booking_id,
soroban_result["hash"],
PaymentStatus.RELEASED,
Decimal(held_payment.amount),
os.getenv("ESCROW_CONTRACT_ID", "soroban-escrow"),
f"engagement:{engagement_id}",
memo,
)

return {
**record,
"auto_released": True,
"confidence_score": confidence_score,
"threshold": threshold,
"engagement_id": engagement_id,
"test_results": test_results,
"prepared_xdr": soroban_result.get("prepared_xdr"),
"signed_xdr": soroban_result.get("signed_xdr"),
"client_email": booking.client.user.email,
"client_name": booking.client.user.full_name or "there",
}
Loading
Loading