This commit is contained in:
oib
2026-01-26 19:58:21 +01:00
parent 329b3beeba
commit 5c99c92ffb
54 changed files with 6790 additions and 654 deletions

View File

@@ -0,0 +1,54 @@
-- Migration: Add payment support
-- Date: 2026-01-26
-- Add payment tracking to jobs table
ALTER TABLE job
ADD COLUMN payment_id VARCHAR(255) REFERENCES job_payments(id),
ADD COLUMN payment_status VARCHAR(20);
-- Create job_payments table
CREATE TABLE IF NOT EXISTS job_payments (
id VARCHAR(255) PRIMARY KEY,
job_id VARCHAR(255) NOT NULL,
amount DECIMAL(20, 8) NOT NULL,
currency VARCHAR(10) DEFAULT 'AITBC',
status VARCHAR(20) DEFAULT 'pending',
payment_method VARCHAR(20) DEFAULT 'aitbc_token',
escrow_address VARCHAR(100),
refund_address VARCHAR(100),
transaction_hash VARCHAR(100),
refund_transaction_hash VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
escrowed_at TIMESTAMP,
released_at TIMESTAMP,
refunded_at TIMESTAMP,
expires_at TIMESTAMP,
metadata JSON
);
-- Create payment_escrows table
CREATE TABLE IF NOT EXISTS payment_escrows (
id VARCHAR(255) PRIMARY KEY,
payment_id VARCHAR(255) NOT NULL,
amount DECIMAL(20, 8) NOT NULL,
currency VARCHAR(10) DEFAULT 'AITBC',
address VARCHAR(100) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
is_released BOOLEAN DEFAULT FALSE,
is_refunded BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
released_at TIMESTAMP,
refunded_at TIMESTAMP,
expires_at TIMESTAMP
);
-- Create indexes
CREATE INDEX IF NOT EXISTS idx_job_payments_job_id ON job_payments(job_id);
CREATE INDEX IF NOT EXISTS idx_job_payments_status ON job_payments(status);
CREATE INDEX IF NOT EXISTS idx_job_payments_created_at ON job_payments(created_at);
CREATE INDEX IF NOT EXISTS idx_payment_escrows_payment_id ON payment_escrows(payment_id);
CREATE INDEX IF NOT EXISTS idx_payment_escrows_address ON payment_escrows(address);
-- Add index for job payment_id
CREATE INDEX IF NOT EXISTS idx_job_payment_id ON job(payment_id);

View File

@@ -4,8 +4,8 @@ from datetime import datetime
from typing import Optional
from uuid import uuid4
from sqlalchemy import Column, JSON
from sqlmodel import Field, SQLModel
from sqlalchemy import Column, JSON, String
from sqlmodel import Field, SQLModel, Relationship
from ..types import JobState
@@ -28,3 +28,10 @@ class Job(SQLModel, table=True):
receipt: Optional[dict] = Field(default=None, sa_column=Column(JSON, nullable=True))
receipt_id: Optional[str] = Field(default=None, index=True)
error: Optional[str] = None
# Payment tracking
payment_id: Optional[str] = Field(default=None, foreign_key="job_payments.id", index=True)
payment_status: Optional[str] = Field(default=None, max_length=20) # pending, escrowed, released, refunded
# Relationships
payment: Optional["JobPayment"] = Relationship(back_populates="jobs")

View File

@@ -0,0 +1,74 @@
"""Payment domain model"""
from __future__ import annotations
from datetime import datetime
from typing import Optional, List
from uuid import uuid4
from sqlalchemy import Column, String, DateTime, Numeric, ForeignKey
from sqlmodel import Field, SQLModel, Relationship
from ..schemas.payments import PaymentStatus, PaymentMethod
class JobPayment(SQLModel, table=True):
"""Payment record for a job"""
__tablename__ = "job_payments"
id: str = Field(default_factory=lambda: uuid4().hex, primary_key=True, index=True)
job_id: str = Field(index=True)
# Payment details
amount: float = Field(sa_column=Column(Numeric(20, 8), nullable=False))
currency: str = Field(default="AITBC", max_length=10)
status: PaymentStatus = Field(default=PaymentStatus.PENDING)
payment_method: PaymentMethod = Field(default=PaymentMethod.AITBC_TOKEN)
# Addresses
escrow_address: Optional[str] = Field(default=None, max_length=100)
refund_address: Optional[str] = Field(default=None, max_length=100)
# Transaction hashes
transaction_hash: Optional[str] = Field(default=None, max_length=100)
refund_transaction_hash: Optional[str] = Field(default=None, max_length=100)
# Timestamps
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
escrowed_at: Optional[datetime] = None
released_at: Optional[datetime] = None
refunded_at: Optional[datetime] = None
expires_at: Optional[datetime] = None
# Additional metadata
metadata: Optional[dict] = Field(default=None)
# Relationships
jobs: List["Job"] = Relationship(back_populates="payment")
class PaymentEscrow(SQLModel, table=True):
"""Escrow record for holding payments"""
__tablename__ = "payment_escrows"
id: str = Field(default_factory=lambda: uuid4().hex, primary_key=True, index=True)
payment_id: str = Field(index=True)
# Escrow details
amount: float = Field(sa_column=Column(Numeric(20, 8), nullable=False))
currency: str = Field(default="AITBC", max_length=10)
address: str = Field(max_length=100)
# Status
is_active: bool = Field(default=True)
is_released: bool = Field(default=False)
is_refunded: bool = Field(default=False)
# Timestamps
created_at: datetime = Field(default_factory=datetime.utcnow)
released_at: Optional[datetime] = None
refunded_at: Optional[datetime] = None
expires_at: Optional[datetime] = None

View File

@@ -16,6 +16,7 @@ from .routers import (
marketplace_offers,
zk_applications,
explorer,
payments,
)
from .routers import zk_applications
from .routers.governance import router as governance
@@ -48,6 +49,7 @@ def create_app() -> FastAPI:
app.include_router(exchange, prefix="/v1")
app.include_router(users, prefix="/v1/users")
app.include_router(services, prefix="/v1")
app.include_router(payments, prefix="/v1")
app.include_router(marketplace_offers, prefix="/v1")
app.include_router(zk_applications.router, prefix="/v1")
app.include_router(governance, prefix="/v1")

View File

@@ -9,6 +9,7 @@ from .services import router as services
from .users import router as users
from .exchange import router as exchange
from .marketplace_offers import router as marketplace_offers
from .payments import router as payments
# from .registry import router as registry
__all__ = ["client", "miner", "admin", "marketplace", "explorer", "services", "users", "exchange", "marketplace_offers", "registry"]
__all__ = ["client", "miner", "admin", "marketplace", "explorer", "services", "users", "exchange", "marketplace_offers", "payments", "registry"]

View File

@@ -2,12 +2,15 @@ from fastapi import APIRouter, Depends, HTTPException, status
from ..deps import require_client_key
from ..schemas import JobCreate, JobView, JobResult
from ..schemas.payments import JobPaymentCreate, PaymentMethod
from ..types import JobState
from ..services import JobService
from ..services.payments import PaymentService
from ..storage import SessionDep
router = APIRouter(tags=["client"])
@router.post("/jobs", response_model=JobView, status_code=status.HTTP_201_CREATED, summary="Submit a job")
async def submit_job(
req: JobCreate,
@@ -16,6 +19,22 @@ async def submit_job(
) -> JobView: # type: ignore[arg-type]
service = JobService(session)
job = service.create_job(client_id, req)
# Create payment if amount is specified
if req.payment_amount and req.payment_amount > 0:
payment_service = PaymentService(session)
payment_create = JobPaymentCreate(
job_id=job.id,
amount=req.payment_amount,
currency=req.payment_currency,
payment_method=PaymentMethod.AITBC_TOKEN # Jobs use AITBC tokens
)
payment = await payment_service.create_payment(job.id, payment_create)
job.payment_id = payment.id
job.payment_status = payment.status.value
session.commit()
session.refresh(job)
return service.to_view(job)

View File

@@ -0,0 +1,171 @@
"""Payment router for job payments"""
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List
from ..deps import require_client_key
from ..schemas.payments import (
JobPaymentCreate,
JobPaymentView,
PaymentRequest,
PaymentReceipt,
EscrowRelease,
RefundRequest
)
from ..services.payments import PaymentService
from ..storage import SessionDep
router = APIRouter(tags=["payments"])
@router.post("/payments", response_model=JobPaymentView, status_code=status.HTTP_201_CREATED, summary="Create payment for a job")
async def create_payment(
payment_data: JobPaymentCreate,
session: SessionDep,
client_id: str = Depends(require_client_key()),
) -> JobPaymentView:
"""Create a payment for a job"""
service = PaymentService(session)
payment = await service.create_payment(payment_data.job_id, payment_data)
return service.to_view(payment)
@router.get("/payments/{payment_id}", response_model=JobPaymentView, summary="Get payment details")
async def get_payment(
payment_id: str,
session: SessionDep,
client_id: str = Depends(require_client_key()),
) -> JobPaymentView:
"""Get payment details by ID"""
service = PaymentService(session)
payment = service.get_payment(payment_id)
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found"
)
return service.to_view(payment)
@router.get("/jobs/{job_id}/payment", response_model=JobPaymentView, summary="Get payment for a job")
async def get_job_payment(
job_id: str,
session: SessionDep,
client_id: str = Depends(require_client_key()),
) -> JobPaymentView:
"""Get payment information for a specific job"""
service = PaymentService(session)
payment = service.get_job_payment(job_id)
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found for this job"
)
return service.to_view(payment)
@router.post("/payments/{payment_id}/release", response_model=dict, summary="Release payment from escrow")
async def release_payment(
payment_id: str,
release_data: EscrowRelease,
session: SessionDep,
client_id: str = Depends(require_client_key()),
) -> dict:
"""Release payment from escrow (for completed jobs)"""
service = PaymentService(session)
# Verify the payment belongs to the client's job
payment = service.get_payment(payment_id)
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found"
)
success = await service.release_payment(
release_data.job_id,
payment_id,
release_data.reason
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to release payment"
)
return {"status": "released", "payment_id": payment_id}
@router.post("/payments/{payment_id}/refund", response_model=dict, summary="Refund payment")
async def refund_payment(
payment_id: str,
refund_data: RefundRequest,
session: SessionDep,
client_id: str = Depends(require_client_key()),
) -> dict:
"""Refund payment (for failed or cancelled jobs)"""
service = PaymentService(session)
# Verify the payment belongs to the client's job
payment = service.get_payment(payment_id)
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found"
)
success = await service.refund_payment(
refund_data.job_id,
payment_id,
refund_data.reason
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to refund payment"
)
return {"status": "refunded", "payment_id": payment_id}
@router.get("/payments/{payment_id}/receipt", response_model=PaymentReceipt, summary="Get payment receipt")
async def get_payment_receipt(
payment_id: str,
session: SessionDep,
client_id: str = Depends(require_client_key()),
) -> PaymentReceipt:
"""Get payment receipt with verification status"""
service = PaymentService(session)
payment = service.get_payment(payment_id)
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found"
)
receipt = PaymentReceipt(
payment_id=payment.id,
job_id=payment.job_id,
amount=float(payment.amount),
currency=payment.currency,
status=payment.status,
transaction_hash=payment.transaction_hash,
created_at=payment.created_at,
verified_at=payment.released_at or payment.refunded_at
)
return receipt

View File

@@ -66,6 +66,8 @@ class JobCreate(BaseModel):
payload: Dict[str, Any]
constraints: Constraints = Field(default_factory=Constraints)
ttl_seconds: int = 900
payment_amount: Optional[float] = None # Amount to pay for the job
payment_currency: str = "AITBC" # Jobs paid with AITBC tokens
class JobView(BaseModel):
@@ -75,6 +77,8 @@ class JobView(BaseModel):
requested_at: datetime
expires_at: datetime
error: Optional[str] = None
payment_id: Optional[str] = None
payment_status: Optional[str] = None
class JobResult(BaseModel):

View File

@@ -0,0 +1,85 @@
"""Payment-related schemas for job payments"""
from __future__ import annotations
from datetime import datetime
from typing import Optional, Dict, Any
from enum import Enum
from pydantic import BaseModel, Field
class PaymentStatus(str, Enum):
"""Payment status values"""
PENDING = "pending"
ESCROWED = "escrowed"
RELEASED = "released"
REFUNDED = "refunded"
FAILED = "failed"
class PaymentMethod(str, Enum):
"""Payment methods"""
AITBC_TOKEN = "aitbc_token" # Primary method for job payments
BITCOIN = "bitcoin" # Only for exchange purchases
class JobPaymentCreate(BaseModel):
"""Request to create a payment for a job"""
job_id: str
amount: float
currency: str = "AITBC" # Jobs paid with AITBC tokens
payment_method: PaymentMethod = PaymentMethod.AITBC_TOKEN
escrow_timeout_seconds: int = 3600 # 1 hour default
class JobPaymentView(BaseModel):
"""Payment information for a job"""
job_id: str
payment_id: str
amount: float
currency: str
status: PaymentStatus
payment_method: PaymentMethod
escrow_address: Optional[str] = None
refund_address: Optional[str] = None
created_at: datetime
updated_at: datetime
released_at: Optional[datetime] = None
refunded_at: Optional[datetime] = None
transaction_hash: Optional[str] = None
refund_transaction_hash: Optional[str] = None
class PaymentRequest(BaseModel):
"""Request to pay for a job"""
job_id: str
amount: float
currency: str = "BTC"
refund_address: Optional[str] = None
class PaymentReceipt(BaseModel):
"""Receipt for a payment"""
payment_id: str
job_id: str
amount: float
currency: str
status: PaymentStatus
transaction_hash: Optional[str] = None
created_at: datetime
verified_at: Optional[datetime] = None
class EscrowRelease(BaseModel):
"""Request to release escrow payment"""
job_id: str
payment_id: str
reason: Optional[str] = None
class RefundRequest(BaseModel):
"""Request to refund a payment"""
job_id: str
payment_id: str
reason: str

View File

@@ -7,11 +7,13 @@ from sqlmodel import Session, select
from ..domain import Job, Miner, JobReceipt
from ..schemas import AssignedJob, Constraints, JobCreate, JobResult, JobState, JobView
from .payments import PaymentService
class JobService:
def __init__(self, session: Session):
self.session = session
self.payment_service = PaymentService(session)
def create_job(self, client_id: str, req: JobCreate) -> Job:
ttl = max(req.ttl_seconds, 1)
@@ -27,6 +29,19 @@ class JobService:
self.session.add(job)
self.session.commit()
self.session.refresh(job)
# Create payment if amount is specified
if req.payment_amount and req.payment_amount > 0:
from ..schemas.payments import JobPaymentCreate, PaymentMethod
payment_create = JobPaymentCreate(
job_id=job.id,
amount=req.payment_amount,
currency=req.payment_currency,
payment_method=PaymentMethod.BITCOIN
)
# Note: This is async, so we'll handle it in the router
job.payment_pending = True
return job
def get_job(self, job_id: str, client_id: Optional[str] = None) -> Job:

View File

@@ -0,0 +1,270 @@
"""Payment service for job payments"""
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import httpx
import logging
from ..domain.payment import JobPayment, PaymentEscrow
from ..schemas.payments import (
JobPaymentCreate,
JobPaymentView,
PaymentStatus,
PaymentMethod,
EscrowRelease,
RefundRequest
)
from ..storage import SessionDep
logger = logging.getLogger(__name__)
class PaymentService:
"""Service for handling job payments"""
def __init__(self, session: SessionDep):
self.session = session
self.wallet_base_url = "http://127.0.0.1:20000" # Wallet daemon URL
self.exchange_base_url = "http://127.0.0.1:23000" # Exchange API URL
async def create_payment(self, job_id: str, payment_data: JobPaymentCreate) -> JobPayment:
"""Create a new payment for a job"""
# Create payment record
payment = JobPayment(
job_id=job_id,
amount=payment_data.amount,
currency=payment_data.currency,
payment_method=payment_data.payment_method,
expires_at=datetime.utcnow() + timedelta(seconds=payment_data.escrow_timeout_seconds)
)
self.session.add(payment)
self.session.commit()
self.session.refresh(payment)
# For AITBC token payments, use token escrow
if payment_data.payment_method == PaymentMethod.AITBC_TOKEN:
await self._create_token_escrow(payment)
# Bitcoin payments only for exchange purchases
elif payment_data.payment_method == PaymentMethod.BITCOIN:
await self._create_bitcoin_escrow(payment)
return payment
async def _create_token_escrow(self, payment: JobPayment) -> None:
"""Create an escrow for AITBC token payments"""
try:
# For AITBC tokens, we use the token contract escrow
async with httpx.AsyncClient() as client:
# Call exchange API to create token escrow
response = await client.post(
f"{self.exchange_base_url}/api/v1/token/escrow/create",
json={
"amount": payment.amount,
"currency": payment.currency,
"job_id": payment.job_id,
"timeout_seconds": 3600 # 1 hour
}
)
if response.status_code == 200:
escrow_data = response.json()
payment.escrow_address = escrow_data.get("escrow_id")
payment.status = PaymentStatus.ESCROWED
payment.escrowed_at = datetime.utcnow()
payment.updated_at = datetime.utcnow()
# Create escrow record
escrow = PaymentEscrow(
payment_id=payment.id,
amount=payment.amount,
currency=payment.currency,
address=escrow_data.get("escrow_id"),
expires_at=datetime.utcnow() + timedelta(hours=1)
)
self.session.add(escrow)
self.session.commit()
logger.info(f"Created AITBC token escrow for payment {payment.id}")
else:
logger.error(f"Failed to create token escrow: {response.text}")
except Exception as e:
logger.error(f"Error creating token escrow: {e}")
payment.status = PaymentStatus.FAILED
payment.updated_at = datetime.utcnow()
self.session.commit()
async def _create_bitcoin_escrow(self, payment: JobPayment) -> None:
"""Create an escrow for Bitcoin payments (exchange only)"""
try:
async with httpx.AsyncClient() as client:
# Call wallet daemon to create escrow
response = await client.post(
f"{self.wallet_base_url}/api/v1/escrow/create",
json={
"amount": payment.amount,
"currency": payment.currency,
"timeout_seconds": 3600 # 1 hour
}
)
if response.status_code == 200:
escrow_data = response.json()
payment.escrow_address = escrow_data["address"]
payment.status = PaymentStatus.ESCROWED
payment.escrowed_at = datetime.utcnow()
payment.updated_at = datetime.utcnow()
# Create escrow record
escrow = PaymentEscrow(
payment_id=payment.id,
amount=payment.amount,
currency=payment.currency,
address=escrow_data["address"],
expires_at=datetime.utcnow() + timedelta(hours=1)
)
self.session.add(escrow)
self.session.commit()
logger.info(f"Created Bitcoin escrow for payment {payment.id}")
else:
logger.error(f"Failed to create Bitcoin escrow: {response.text}")
except Exception as e:
logger.error(f"Error creating Bitcoin escrow: {e}")
payment.status = PaymentStatus.FAILED
payment.updated_at = datetime.utcnow()
self.session.commit()
async def release_payment(self, job_id: str, payment_id: str, reason: Optional[str] = None) -> bool:
"""Release payment from escrow to miner"""
payment = self.session.get(JobPayment, payment_id)
if not payment or payment.job_id != job_id:
return False
if payment.status != PaymentStatus.ESCROWED:
return False
try:
async with httpx.AsyncClient() as client:
# Call wallet daemon to release escrow
response = await client.post(
f"{self.wallet_base_url}/api/v1/escrow/release",
json={
"address": payment.escrow_address,
"reason": reason or "Job completed successfully"
}
)
if response.status_code == 200:
release_data = response.json()
payment.status = PaymentStatus.RELEASED
payment.released_at = datetime.utcnow()
payment.updated_at = datetime.utcnow()
payment.transaction_hash = release_data.get("transaction_hash")
# Update escrow record
escrow = self.session.exec(
self.session.query(PaymentEscrow).where(
PaymentEscrow.payment_id == payment_id
)
).first()
if escrow:
escrow.is_released = True
escrow.released_at = datetime.utcnow()
self.session.commit()
logger.info(f"Released payment {payment_id} for job {job_id}")
return True
else:
logger.error(f"Failed to release payment: {response.text}")
return False
except Exception as e:
logger.error(f"Error releasing payment: {e}")
return False
async def refund_payment(self, job_id: str, payment_id: str, reason: str) -> bool:
"""Refund payment to client"""
payment = self.session.get(JobPayment, payment_id)
if not payment or payment.job_id != job_id:
return False
if payment.status not in [PaymentStatus.ESCROWED, PaymentStatus.PENDING]:
return False
try:
async with httpx.AsyncClient() as client:
# Call wallet daemon to refund
response = await client.post(
f"{self.wallet_base_url}/api/v1/refund",
json={
"payment_id": payment_id,
"address": payment.refund_address,
"amount": payment.amount,
"reason": reason
}
)
if response.status_code == 200:
refund_data = response.json()
payment.status = PaymentStatus.REFUNDED
payment.refunded_at = datetime.utcnow()
payment.updated_at = datetime.utcnow()
payment.refund_transaction_hash = refund_data.get("transaction_hash")
# Update escrow record
escrow = self.session.exec(
self.session.query(PaymentEscrow).where(
PaymentEscrow.payment_id == payment_id
)
).first()
if escrow:
escrow.is_refunded = True
escrow.refunded_at = datetime.utcnow()
self.session.commit()
logger.info(f"Refunded payment {payment_id} for job {job_id}")
return True
else:
logger.error(f"Failed to refund payment: {response.text}")
return False
except Exception as e:
logger.error(f"Error refunding payment: {e}")
return False
def get_payment(self, payment_id: str) -> Optional[JobPayment]:
"""Get payment by ID"""
return self.session.get(JobPayment, payment_id)
def get_job_payment(self, job_id: str) -> Optional[JobPayment]:
"""Get payment for a specific job"""
return self.session.exec(
self.session.query(JobPayment).where(JobPayment.job_id == job_id)
).first()
def to_view(self, payment: JobPayment) -> JobPaymentView:
"""Convert payment to view model"""
return JobPaymentView(
job_id=payment.job_id,
payment_id=payment.id,
amount=float(payment.amount),
currency=payment.currency,
status=payment.status,
payment_method=payment.payment_method,
escrow_address=payment.escrow_address,
refund_address=payment.refund_address,
created_at=payment.created_at,
updated_at=payment.updated_at,
released_at=payment.released_at,
refunded_at=payment.refunded_at,
transaction_hash=payment.transaction_hash,
refund_transaction_hash=payment.refund_transaction_hash
)