Spaces:
Runtime error
Runtime error
Commit ·
0bda635
0
Parent(s):
itial commit
Browse files- .gitignore +34 -0
- Dockerfile +24 -0
- README.md +41 -0
- app/__init__.py +0 -0
- app/config.py +50 -0
- app/database.py +18 -0
- app/main.py +37 -0
- app/models.py +5 -0
- app/routes/__init__.py +0 -0
- app/routes/ads.py +216 -0
- app/routes/auth.py +209 -0
- app/routes/settlement.py +96 -0
- app/routes/videos.py +85 -0
- app/routes/views.py +77 -0
- app/routes/wallets.py +55 -0
- app/schemas.py +81 -0
- app/services/__init__.py +0 -0
- app/services/algorand_service.py +219 -0
- app/services/banner_engine.py +109 -0
- app/services/reward_engine.py +133 -0
- app/services/settlement_service.py +10 -0
- app/services/storage_service.py +32 -0
- app/utils/anti_bot.py +55 -0
- approval.teal +138 -0
- clear.teal +3 -0
- contracts/smart_contract.py +145 -0
- requirements.txt +15 -0
- schema.sql +102 -0
- scripts/_route_test_tmp.py +262 -0
- scripts/check_schema.py +31 -0
- scripts/create_asset.py +78 -0
- scripts/create_token.py +63 -0
- scripts/show_wallet.py +59 -0
- scripts/test_all_routes.py +143 -0
- scripts/test_auth.py +92 -0
- scripts/test_insert.py +46 -0
- scripts/verify_algorand.py +139 -0
.gitignore
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# Python
|
| 3 |
+
venv/
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.pyc
|
| 6 |
+
.env
|
| 7 |
+
.venv
|
| 8 |
+
.env.example
|
| 9 |
+
ENV/
|
| 10 |
+
env/
|
| 11 |
+
|
| 12 |
+
# JS (if any)
|
| 13 |
+
node_modules/
|
| 14 |
+
dist/
|
| 15 |
+
build/
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# Logs
|
| 19 |
+
*.log
|
| 20 |
+
|
| 21 |
+
# Python
|
| 22 |
+
__pycache__/
|
| 23 |
+
*.py[cod]
|
| 24 |
+
*$py.class
|
| 25 |
+
venv/
|
| 26 |
+
.env
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# System
|
| 31 |
+
.DS_Store
|
| 32 |
+
Thumbs.db
|
| 33 |
+
|
| 34 |
+
|
Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# Use an official Python runtime as a parent image
|
| 3 |
+
FROM python:3.10-slim
|
| 4 |
+
|
| 5 |
+
# Set the working directory in the container
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
# Copy the requirements file into the container
|
| 9 |
+
COPY requirements.txt .
|
| 10 |
+
|
| 11 |
+
# Install any needed packages specified in requirements.txt
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
# Copy the current directory contents into the container at /app
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
# Expose port 8000
|
| 18 |
+
EXPOSE 8000
|
| 19 |
+
|
| 20 |
+
# Define environment variable
|
| 21 |
+
ENV PORT=8000
|
| 22 |
+
|
| 23 |
+
# Run app.py when the container launches
|
| 24 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Rift Backend
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: orange
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 8000
|
| 8 |
+
pinned: false
|
| 9 |
+
tags:
|
| 10 |
+
- backend
|
| 11 |
+
- fastapi
|
| 12 |
+
- algorand
|
| 13 |
+
- pinata
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
# Rift Backend
|
| 17 |
+
|
| 18 |
+
FastAPI backend for the decentralized ad-based video platform.
|
| 19 |
+
|
| 20 |
+
## Core Endpoints
|
| 21 |
+
|
| 22 |
+
- `POST /auth/challenge`
|
| 23 |
+
- `POST /auth/signup`
|
| 24 |
+
- `POST /auth/login`
|
| 25 |
+
- `GET /auth/me`
|
| 26 |
+
- `POST /videos/upload`
|
| 27 |
+
- `GET /videos/list`
|
| 28 |
+
- `GET /videos/{video_id}`
|
| 29 |
+
- `POST /views/track`
|
| 30 |
+
- `POST /ads/create`
|
| 31 |
+
- `POST /ads/banner/create`
|
| 32 |
+
- `GET /settlement/`
|
| 33 |
+
- `POST /settlement/trigger`
|
| 34 |
+
- `POST /settlement/trigger-banner`
|
| 35 |
+
|
| 36 |
+
## Stack Integration
|
| 37 |
+
|
| 38 |
+
- **Supabase PostgreSQL** for metadata and analytics
|
| 39 |
+
- **Pinata (IPFS)** for video/ad file storage
|
| 40 |
+
- **Algorand Testnet/Mainnet** for ADMC token settlement
|
| 41 |
+
- **APScheduler** for automated reward and banner distribution jobs
|
app/__init__.py
ADDED
|
File without changes
|
app/config.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class Settings(BaseSettings):
|
| 7 |
+
supabase_url: str = ""
|
| 8 |
+
supabase_key: str = ""
|
| 9 |
+
|
| 10 |
+
jwt_secret: str = ""
|
| 11 |
+
jwt_expire_minutes: int = 60 * 24
|
| 12 |
+
cors_origins: str = "*"
|
| 13 |
+
|
| 14 |
+
pinata_jwt: str = ""
|
| 15 |
+
pinata_gateway: str = "gateway.pinata.cloud"
|
| 16 |
+
|
| 17 |
+
algod_address: str = "https://testnet-api.algonode.cloud"
|
| 18 |
+
algod_token: str = ""
|
| 19 |
+
algorand_mnemonic: str = ""
|
| 20 |
+
platform_wallet: str = ""
|
| 21 |
+
|
| 22 |
+
asset_id: int = 0
|
| 23 |
+
app_id: int = 0
|
| 24 |
+
token_decimals: int = 6
|
| 25 |
+
settlement_fee_bps: int = 200
|
| 26 |
+
use_contract_settlement: bool = False
|
| 27 |
+
|
| 28 |
+
reward_interval_minutes: int = 60
|
| 29 |
+
scheduler_enabled: bool = True
|
| 30 |
+
|
| 31 |
+
view_min_watch_seconds: int = 30
|
| 32 |
+
view_wallet_cooldown_seconds: int = 3600
|
| 33 |
+
view_ip_hourly_limit: int = 120
|
| 34 |
+
view_fingerprint_hourly_limit: int = 60
|
| 35 |
+
|
| 36 |
+
model_config = SettingsConfigDict(env_file=".env", case_sensitive=False)
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def effective_jwt_secret(self) -> str:
|
| 40 |
+
return self.jwt_secret or self.supabase_key
|
| 41 |
+
|
| 42 |
+
@property
|
| 43 |
+
def cors_origin_list(self) -> list[str]:
|
| 44 |
+
if not self.cors_origins.strip():
|
| 45 |
+
return ["*"]
|
| 46 |
+
origins = [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
| 47 |
+
return origins or ["*"]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
settings = Settings()
|
app/database.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from functools import lru_cache
|
| 4 |
+
|
| 5 |
+
from supabase import Client, create_client
|
| 6 |
+
|
| 7 |
+
from .config import settings
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@lru_cache
|
| 11 |
+
def _build_client() -> Client:
|
| 12 |
+
if not settings.supabase_url or not settings.supabase_key:
|
| 13 |
+
raise RuntimeError("SUPABASE_URL and SUPABASE_KEY must be configured.")
|
| 14 |
+
return create_client(settings.supabase_url, settings.supabase_key)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def get_db() -> Client:
|
| 18 |
+
return _build_client()
|
app/main.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from contextlib import asynccontextmanager
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
|
| 6 |
+
from .config import settings
|
| 7 |
+
from .routes import ads, auth, settlement, videos, views, wallets
|
| 8 |
+
from .services import reward_engine
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@asynccontextmanager
|
| 12 |
+
async def lifespan(app: FastAPI):
|
| 13 |
+
reward_engine.start()
|
| 14 |
+
yield
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
app = FastAPI(title="Rift Decentralized Video Platform", lifespan=lifespan)
|
| 18 |
+
|
| 19 |
+
app.add_middleware(
|
| 20 |
+
CORSMiddleware,
|
| 21 |
+
allow_origins=settings.cors_origin_list,
|
| 22 |
+
allow_credentials=True,
|
| 23 |
+
allow_methods=["*"],
|
| 24 |
+
allow_headers=["*"],
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
app.include_router(auth.router, prefix="/auth", tags=["Auth"])
|
| 28 |
+
app.include_router(videos.router, prefix="/videos", tags=["Videos"])
|
| 29 |
+
app.include_router(views.router, prefix="/views", tags=["Views"])
|
| 30 |
+
app.include_router(ads.router, prefix="/ads", tags=["Ads"])
|
| 31 |
+
app.include_router(settlement.router, prefix="/settlement", tags=["Settlement"])
|
| 32 |
+
app.include_router(wallets.router, prefix="/wallets", tags=["Wallets"])
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@app.get("/")
|
| 36 |
+
def read_root():
|
| 37 |
+
return {"message": "Welcome to Rift API", "storage": "Pinata", "network": "Algorand"}
|
app/models.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Models are handled via direct Supabase calls in this MVP.
|
| 2 |
+
# This file is reserved for future ORM models (e.g. SQLAlchemy or SQLModel) if needed.
|
| 3 |
+
|
| 4 |
+
class Base:
|
| 5 |
+
pass
|
app/routes/__init__.py
ADDED
|
File without changes
|
app/routes/ads.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import date
|
| 4 |
+
from decimal import Decimal, InvalidOperation
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
| 7 |
+
|
| 8 |
+
from ..database import get_db
|
| 9 |
+
from ..services import algorand_service, storage_service
|
| 10 |
+
from .auth import get_current_user
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _to_decimal(value: str | float) -> Decimal:
|
| 17 |
+
try:
|
| 18 |
+
return Decimal(str(value))
|
| 19 |
+
except InvalidOperation as exc:
|
| 20 |
+
raise HTTPException(status_code=400, detail="Invalid numeric value.") from exc
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.post("/create")
|
| 24 |
+
async def create_campaign(
|
| 25 |
+
video_id: str = Form(...),
|
| 26 |
+
budget: float = Form(...),
|
| 27 |
+
reward_per_view: float = Form(...),
|
| 28 |
+
file: UploadFile | None = File(None),
|
| 29 |
+
current_user: dict = Depends(get_current_user),
|
| 30 |
+
):
|
| 31 |
+
budget_dec = _to_decimal(budget)
|
| 32 |
+
reward_per_view_dec = _to_decimal(reward_per_view)
|
| 33 |
+
if budget_dec <= 0 or reward_per_view_dec <= 0:
|
| 34 |
+
raise HTTPException(status_code=400, detail="budget and reward_per_view must be > 0.")
|
| 35 |
+
if reward_per_view_dec > budget_dec:
|
| 36 |
+
raise HTTPException(status_code=400, detail="reward_per_view cannot exceed budget.")
|
| 37 |
+
|
| 38 |
+
db = get_db()
|
| 39 |
+
video = db.table("videos").select("id").eq("id", video_id).limit(1).execute()
|
| 40 |
+
if not video.data:
|
| 41 |
+
raise HTTPException(status_code=404, detail="Video not found.")
|
| 42 |
+
|
| 43 |
+
ad_cid = None
|
| 44 |
+
if file:
|
| 45 |
+
content = await file.read()
|
| 46 |
+
if not content:
|
| 47 |
+
raise HTTPException(status_code=400, detail="Ad file is empty.")
|
| 48 |
+
ad_cid = storage_service.upload_file(content, file.filename or "ad-video.mp4")
|
| 49 |
+
|
| 50 |
+
created = db.table("ad_campaigns").insert(
|
| 51 |
+
{
|
| 52 |
+
"advertiser_wallet": current_user["wallet_address"],
|
| 53 |
+
"video_id": video_id,
|
| 54 |
+
"budget": float(budget_dec),
|
| 55 |
+
"remaining_budget": float(budget_dec),
|
| 56 |
+
"reward_per_view": float(reward_per_view_dec),
|
| 57 |
+
"active": True,
|
| 58 |
+
"ad_video_cid": ad_cid,
|
| 59 |
+
}
|
| 60 |
+
).execute()
|
| 61 |
+
if not created.data:
|
| 62 |
+
raise HTTPException(status_code=500, detail="Failed to create ad campaign.")
|
| 63 |
+
|
| 64 |
+
return {
|
| 65 |
+
"status": "success",
|
| 66 |
+
"campaign_id": created.data[0]["id"],
|
| 67 |
+
"ad_cid": ad_cid,
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@router.get("/active")
|
| 72 |
+
async def list_active_campaigns():
|
| 73 |
+
db = get_db()
|
| 74 |
+
res = db.table("ad_campaigns").select("*").eq("active", True).gt("remaining_budget", 0).execute()
|
| 75 |
+
return res.data or []
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@router.get("/me")
|
| 79 |
+
async def list_my_campaigns(current_user: dict = Depends(get_current_user)):
|
| 80 |
+
db = get_db()
|
| 81 |
+
res = (
|
| 82 |
+
db.table("ad_campaigns")
|
| 83 |
+
.select("*, videos(title)")
|
| 84 |
+
.eq("advertiser_wallet", current_user["wallet_address"])
|
| 85 |
+
.order("created_at", desc=True)
|
| 86 |
+
.execute()
|
| 87 |
+
)
|
| 88 |
+
return res.data or []
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@router.post("/campaign/{campaign_id}/withdraw")
|
| 92 |
+
async def withdraw_unused_budget(campaign_id: str, current_user: dict = Depends(get_current_user)):
|
| 93 |
+
db = get_db()
|
| 94 |
+
campaign_res = (
|
| 95 |
+
db.table("ad_campaigns")
|
| 96 |
+
.select("*")
|
| 97 |
+
.eq("id", campaign_id)
|
| 98 |
+
.eq("advertiser_wallet", current_user["wallet_address"])
|
| 99 |
+
.limit(1)
|
| 100 |
+
.execute()
|
| 101 |
+
)
|
| 102 |
+
if not campaign_res.data:
|
| 103 |
+
raise HTTPException(status_code=404, detail="Campaign not found.")
|
| 104 |
+
|
| 105 |
+
campaign = campaign_res.data[0]
|
| 106 |
+
remaining_budget = _to_decimal(campaign.get("remaining_budget", 0))
|
| 107 |
+
if remaining_budget <= 0:
|
| 108 |
+
raise HTTPException(status_code=400, detail="No remaining budget to withdraw.")
|
| 109 |
+
|
| 110 |
+
tx_hash = algorand_service.withdraw_unused(current_user["wallet_address"], remaining_budget)
|
| 111 |
+
db.table("ad_campaigns").update({"remaining_budget": 0, "active": False}).eq("id", campaign_id).execute()
|
| 112 |
+
|
| 113 |
+
return {"status": "success", "tx_hash": tx_hash, "withdrawn_amount": float(remaining_budget)}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@router.post("/banner/create")
|
| 117 |
+
async def create_banner_campaign(
|
| 118 |
+
tier: str = Form(...),
|
| 119 |
+
fixed_price: float = Form(...),
|
| 120 |
+
start_date: str = Form(...),
|
| 121 |
+
end_date: str = Form(...),
|
| 122 |
+
current_user: dict = Depends(get_current_user),
|
| 123 |
+
):
|
| 124 |
+
if tier not in {"1m", "3m", "6m"}:
|
| 125 |
+
raise HTTPException(status_code=400, detail="tier must be one of: 1m, 3m, 6m.")
|
| 126 |
+
|
| 127 |
+
fixed_price_dec = _to_decimal(fixed_price)
|
| 128 |
+
if fixed_price_dec <= 0:
|
| 129 |
+
raise HTTPException(status_code=400, detail="fixed_price must be > 0.")
|
| 130 |
+
|
| 131 |
+
try:
|
| 132 |
+
parsed_start = date.fromisoformat(start_date)
|
| 133 |
+
parsed_end = date.fromisoformat(end_date)
|
| 134 |
+
except ValueError as exc:
|
| 135 |
+
raise HTTPException(status_code=400, detail="Dates must be ISO format (YYYY-MM-DD).") from exc
|
| 136 |
+
|
| 137 |
+
if parsed_end <= parsed_start:
|
| 138 |
+
raise HTTPException(status_code=400, detail="end_date must be later than start_date.")
|
| 139 |
+
|
| 140 |
+
db = get_db()
|
| 141 |
+
created = db.table("banner_campaigns").insert(
|
| 142 |
+
{
|
| 143 |
+
"advertiser_wallet": current_user["wallet_address"],
|
| 144 |
+
"tier": tier,
|
| 145 |
+
"fixed_price": float(fixed_price_dec),
|
| 146 |
+
"start_date": parsed_start.isoformat(),
|
| 147 |
+
"end_date": parsed_end.isoformat(),
|
| 148 |
+
"active": True,
|
| 149 |
+
"distributed": False,
|
| 150 |
+
}
|
| 151 |
+
).execute()
|
| 152 |
+
if not created.data:
|
| 153 |
+
raise HTTPException(status_code=500, detail="Failed to create banner campaign.")
|
| 154 |
+
return {"status": "success", "banner_campaign_id": created.data[0]["id"]}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@router.get("/banner/active")
|
| 158 |
+
async def list_active_banner_campaigns():
|
| 159 |
+
db = get_db()
|
| 160 |
+
campaigns = db.table("banner_campaigns").select("*").eq("active", True).execute()
|
| 161 |
+
return campaigns.data or []
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
@router.get("/banner/me")
|
| 165 |
+
async def list_my_banner_campaigns(current_user: dict = Depends(get_current_user)):
|
| 166 |
+
db = get_db()
|
| 167 |
+
campaigns = (
|
| 168 |
+
db.table("banner_campaigns")
|
| 169 |
+
.select("*")
|
| 170 |
+
.eq("advertiser_wallet", current_user["wallet_address"])
|
| 171 |
+
.order("created_at", desc=True)
|
| 172 |
+
.execute()
|
| 173 |
+
)
|
| 174 |
+
return campaigns.data or []
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@router.get("/summary")
|
| 178 |
+
async def advertiser_spend_summary(current_user: dict = Depends(get_current_user)):
|
| 179 |
+
db = get_db()
|
| 180 |
+
|
| 181 |
+
ad_campaigns = (
|
| 182 |
+
db.table("ad_campaigns")
|
| 183 |
+
.select("budget, remaining_budget")
|
| 184 |
+
.eq("advertiser_wallet", current_user["wallet_address"])
|
| 185 |
+
.execute()
|
| 186 |
+
.data
|
| 187 |
+
or []
|
| 188 |
+
)
|
| 189 |
+
total_budget = sum(_to_decimal(c.get("budget")) for c in ad_campaigns)
|
| 190 |
+
total_remaining = sum(_to_decimal(c.get("remaining_budget")) for c in ad_campaigns)
|
| 191 |
+
total_spent = total_budget - total_remaining
|
| 192 |
+
if total_spent < 0:
|
| 193 |
+
total_spent = _to_decimal(0)
|
| 194 |
+
|
| 195 |
+
banner_campaigns = (
|
| 196 |
+
db.table("banner_campaigns")
|
| 197 |
+
.select("fixed_price, distributed")
|
| 198 |
+
.eq("advertiser_wallet", current_user["wallet_address"])
|
| 199 |
+
.execute()
|
| 200 |
+
.data
|
| 201 |
+
or []
|
| 202 |
+
)
|
| 203 |
+
banner_committed = sum(_to_decimal(c.get("fixed_price")) for c in banner_campaigns)
|
| 204 |
+
banner_distributed = sum(_to_decimal(c.get("fixed_price")) for c in banner_campaigns if c.get("distributed"))
|
| 205 |
+
|
| 206 |
+
return {
|
| 207 |
+
"video_ads": {
|
| 208 |
+
"total_budget": float(total_budget),
|
| 209 |
+
"total_remaining": float(total_remaining),
|
| 210 |
+
"total_spent": float(total_spent),
|
| 211 |
+
},
|
| 212 |
+
"banner_ads": {
|
| 213 |
+
"total_committed": float(banner_committed),
|
| 214 |
+
"total_distributed": float(banner_distributed),
|
| 215 |
+
},
|
| 216 |
+
}
|
app/routes/auth.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import secrets
|
| 4 |
+
from datetime import datetime, timedelta, timezone
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 8 |
+
from fastapi.security import OAuth2PasswordBearer
|
| 9 |
+
from jose import JWTError, jwt
|
| 10 |
+
from pydantic import BaseModel, Field
|
| 11 |
+
|
| 12 |
+
from ..config import settings
|
| 13 |
+
from ..database import get_db
|
| 14 |
+
from ..services import algorand_service
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
|
| 19 |
+
|
| 20 |
+
ALGORITHM = "HS256"
|
| 21 |
+
CHALLENGE_TTL_SECONDS = 5 * 60
|
| 22 |
+
challenge_store: dict[str, dict[str, Any]] = {}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ChallengeRequest(BaseModel):
|
| 26 |
+
wallet_address: str
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ChallengeResponse(BaseModel):
|
| 30 |
+
message: str
|
| 31 |
+
expires_at: str
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class LoginRequest(BaseModel):
|
| 35 |
+
wallet_address: str
|
| 36 |
+
signature: str
|
| 37 |
+
message: str
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class SignupRequest(BaseModel):
|
| 41 |
+
wallet_address: str
|
| 42 |
+
signature: str
|
| 43 |
+
message: str
|
| 44 |
+
username: str = Field(min_length=2, max_length=32)
|
| 45 |
+
role: str = "viewer"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class Token(BaseModel):
|
| 49 |
+
access_token: str
|
| 50 |
+
token_type: str
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _now() -> datetime:
|
| 54 |
+
return datetime.now(timezone.utc)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _normalize_wallet(wallet: str) -> str:
|
| 58 |
+
return wallet.strip()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _cleanup_challenges() -> None:
|
| 62 |
+
now = _now()
|
| 63 |
+
expired_wallets = [wallet for wallet, payload in challenge_store.items() if payload["expires_at"] <= now]
|
| 64 |
+
for wallet in expired_wallets:
|
| 65 |
+
challenge_store.pop(wallet, None)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _issue_challenge(wallet_address: str) -> ChallengeResponse:
|
| 69 |
+
_cleanup_challenges()
|
| 70 |
+
nonce = secrets.token_urlsafe(24)
|
| 71 |
+
issued_at = _now()
|
| 72 |
+
expires_at = issued_at + timedelta(seconds=CHALLENGE_TTL_SECONDS)
|
| 73 |
+
message = f"RIFT_AUTH:{nonce}:{int(issued_at.timestamp())}"
|
| 74 |
+
|
| 75 |
+
challenge_store[_normalize_wallet(wallet_address)] = {
|
| 76 |
+
"message": message,
|
| 77 |
+
"expires_at": expires_at,
|
| 78 |
+
"used": False,
|
| 79 |
+
}
|
| 80 |
+
return ChallengeResponse(message=message, expires_at=expires_at.isoformat())
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _validate_challenge(wallet_address: str, message: str) -> bool:
|
| 84 |
+
_cleanup_challenges()
|
| 85 |
+
payload = challenge_store.get(_normalize_wallet(wallet_address))
|
| 86 |
+
if not payload:
|
| 87 |
+
return False
|
| 88 |
+
if payload["used"]:
|
| 89 |
+
return False
|
| 90 |
+
if payload["expires_at"] <= _now():
|
| 91 |
+
return False
|
| 92 |
+
if payload["message"] != message:
|
| 93 |
+
return False
|
| 94 |
+
payload["used"] = True
|
| 95 |
+
return True
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _create_access_token(data: dict[str, Any]) -> str:
|
| 99 |
+
expires_at = _now() + timedelta(minutes=settings.jwt_expire_minutes)
|
| 100 |
+
token_payload = {**data, "exp": int(expires_at.timestamp())}
|
| 101 |
+
return jwt.encode(token_payload, settings.effective_jwt_secret, algorithm=ALGORITHM)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _validate_role(role: str) -> str:
|
| 105 |
+
allowed_roles = {"creator", "viewer", "advertiser"}
|
| 106 |
+
normalized = (role or "viewer").lower()
|
| 107 |
+
if normalized not in allowed_roles:
|
| 108 |
+
raise HTTPException(status_code=400, detail="Invalid role.")
|
| 109 |
+
return normalized
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@router.post("/challenge", response_model=ChallengeResponse)
|
| 113 |
+
async def auth_challenge(request: ChallengeRequest):
|
| 114 |
+
wallet_address = _normalize_wallet(request.wallet_address)
|
| 115 |
+
if not wallet_address:
|
| 116 |
+
raise HTTPException(status_code=400, detail="wallet_address is required.")
|
| 117 |
+
return _issue_challenge(wallet_address)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@router.post("/signup", response_model=Token)
|
| 121 |
+
async def signup(request: SignupRequest):
|
| 122 |
+
wallet_address = _normalize_wallet(request.wallet_address)
|
| 123 |
+
role = _validate_role(request.role)
|
| 124 |
+
|
| 125 |
+
if not _validate_challenge(wallet_address, request.message):
|
| 126 |
+
raise HTTPException(status_code=401, detail="Invalid or expired challenge.")
|
| 127 |
+
|
| 128 |
+
if not algorand_service.verify_signature(wallet_address, request.message, request.signature):
|
| 129 |
+
raise HTTPException(status_code=401, detail="Invalid signature.")
|
| 130 |
+
|
| 131 |
+
db = get_db()
|
| 132 |
+
existing_user = db.table("users").select("id").eq("wallet_address", wallet_address).execute()
|
| 133 |
+
if existing_user.data:
|
| 134 |
+
raise HTTPException(status_code=400, detail="User already exists. Please login.")
|
| 135 |
+
|
| 136 |
+
created = db.table("users").insert(
|
| 137 |
+
{
|
| 138 |
+
"wallet_address": wallet_address,
|
| 139 |
+
"username": request.username.strip(),
|
| 140 |
+
"role": role,
|
| 141 |
+
"subscribers_count": 0,
|
| 142 |
+
}
|
| 143 |
+
).execute()
|
| 144 |
+
if not created.data:
|
| 145 |
+
raise HTTPException(status_code=500, detail="Failed to create user.")
|
| 146 |
+
|
| 147 |
+
user = created.data[0]
|
| 148 |
+
token = _create_access_token(
|
| 149 |
+
{
|
| 150 |
+
"sub": wallet_address,
|
| 151 |
+
"user_id": user["id"],
|
| 152 |
+
"role": user.get("role", role),
|
| 153 |
+
}
|
| 154 |
+
)
|
| 155 |
+
return Token(access_token=token, token_type="bearer")
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@router.post("/login", response_model=Token)
|
| 159 |
+
async def login(request: LoginRequest):
|
| 160 |
+
wallet_address = _normalize_wallet(request.wallet_address)
|
| 161 |
+
|
| 162 |
+
if not _validate_challenge(wallet_address, request.message):
|
| 163 |
+
raise HTTPException(status_code=401, detail="Invalid or expired challenge.")
|
| 164 |
+
|
| 165 |
+
if not algorand_service.verify_signature(wallet_address, request.message, request.signature):
|
| 166 |
+
raise HTTPException(status_code=401, detail="Invalid signature.")
|
| 167 |
+
|
| 168 |
+
db = get_db()
|
| 169 |
+
user_res = db.table("users").select("*").eq("wallet_address", wallet_address).execute()
|
| 170 |
+
if not user_res.data:
|
| 171 |
+
raise HTTPException(status_code=404, detail="User not found. Please sign up.")
|
| 172 |
+
|
| 173 |
+
user = user_res.data[0]
|
| 174 |
+
token = _create_access_token(
|
| 175 |
+
{
|
| 176 |
+
"sub": wallet_address,
|
| 177 |
+
"user_id": user["id"],
|
| 178 |
+
"role": user.get("role", "viewer"),
|
| 179 |
+
}
|
| 180 |
+
)
|
| 181 |
+
return Token(access_token=token, token_type="bearer")
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
async def get_current_user(token: str = Depends(oauth2_scheme)) -> dict[str, str]:
|
| 185 |
+
credentials_exception = HTTPException(
|
| 186 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 187 |
+
detail="Could not validate credentials",
|
| 188 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 189 |
+
)
|
| 190 |
+
try:
|
| 191 |
+
payload = jwt.decode(token, settings.effective_jwt_secret, algorithms=[ALGORITHM])
|
| 192 |
+
except JWTError:
|
| 193 |
+
raise credentials_exception
|
| 194 |
+
|
| 195 |
+
wallet_address = payload.get("sub")
|
| 196 |
+
user_id = payload.get("user_id")
|
| 197 |
+
role = payload.get("role", "viewer")
|
| 198 |
+
if not wallet_address or not user_id:
|
| 199 |
+
raise credentials_exception
|
| 200 |
+
return {"wallet_address": wallet_address, "user_id": user_id, "role": role}
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
@router.get("/me")
|
| 204 |
+
async def get_me(current_user: dict[str, str] = Depends(get_current_user)):
|
| 205 |
+
db = get_db()
|
| 206 |
+
user_res = db.table("users").select("*").eq("id", current_user["user_id"]).limit(1).execute()
|
| 207 |
+
if not user_res.data:
|
| 208 |
+
raise HTTPException(status_code=404, detail="User not found.")
|
| 209 |
+
return user_res.data[0]
|
app/routes/settlement.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from decimal import Decimal
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
+
|
| 7 |
+
from ..config import settings
|
| 8 |
+
from ..database import get_db
|
| 9 |
+
from ..services import banner_engine, reward_engine
|
| 10 |
+
from .auth import get_current_user
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _require_platform_operator(current_user: dict) -> None:
|
| 17 |
+
configured_platform = (settings.platform_wallet or "").strip().lower()
|
| 18 |
+
if not configured_platform:
|
| 19 |
+
return
|
| 20 |
+
|
| 21 |
+
wallet = current_user["wallet_address"].strip().lower()
|
| 22 |
+
if wallet != configured_platform:
|
| 23 |
+
raise HTTPException(status_code=403, detail="Only platform wallet can trigger settlement.")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@router.get("/")
|
| 27 |
+
async def get_settlements(limit: int = 100):
|
| 28 |
+
db = get_db()
|
| 29 |
+
result = (
|
| 30 |
+
db.table("settlements")
|
| 31 |
+
.select("*")
|
| 32 |
+
.order("timestamp", desc=True)
|
| 33 |
+
.limit(min(max(limit, 1), 500))
|
| 34 |
+
.execute()
|
| 35 |
+
)
|
| 36 |
+
return result.data or []
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@router.post("/trigger")
|
| 40 |
+
async def trigger_settlement(current_user: dict = Depends(get_current_user)):
|
| 41 |
+
_require_platform_operator(current_user)
|
| 42 |
+
try:
|
| 43 |
+
report = reward_engine.calculate_and_settle()
|
| 44 |
+
return {"status": "success", "report": report}
|
| 45 |
+
except Exception as exc:
|
| 46 |
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.post("/trigger-banner")
|
| 50 |
+
async def trigger_banner_distribution(current_user: dict = Depends(get_current_user)):
|
| 51 |
+
_require_platform_operator(current_user)
|
| 52 |
+
try:
|
| 53 |
+
report = banner_engine.distribute_banner_rewards()
|
| 54 |
+
return {"status": "success", "report": report}
|
| 55 |
+
except Exception as exc:
|
| 56 |
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@router.get("/summary")
|
| 60 |
+
async def settlement_summary():
|
| 61 |
+
db = get_db()
|
| 62 |
+
settlements = db.table("settlements").select("*").order("timestamp", desc=True).limit(1000).execute().data or []
|
| 63 |
+
banner_campaigns = db.table("banner_campaigns").select("fixed_price, distributed").execute().data or []
|
| 64 |
+
|
| 65 |
+
totals = {
|
| 66 |
+
"video_ad_creator_payout": Decimal("0"),
|
| 67 |
+
"video_ad_platform_fee": Decimal("0"),
|
| 68 |
+
"banner_creator_payout": Decimal("0"),
|
| 69 |
+
"banner_revenue": Decimal("0"),
|
| 70 |
+
"banner_platform_share": Decimal("0"),
|
| 71 |
+
"count": len(settlements),
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
for settlement in settlements:
|
| 75 |
+
settlement_type = settlement.get("settlement_type", "video_ad")
|
| 76 |
+
amount = Decimal(str(settlement.get("amount", 0)))
|
| 77 |
+
platform_fee = Decimal(str(settlement.get("platform_fee", 0)))
|
| 78 |
+
|
| 79 |
+
if settlement_type == "banner":
|
| 80 |
+
totals["banner_creator_payout"] += amount
|
| 81 |
+
else:
|
| 82 |
+
totals["video_ad_creator_payout"] += amount
|
| 83 |
+
totals["video_ad_platform_fee"] += platform_fee
|
| 84 |
+
|
| 85 |
+
banner_revenue = sum(Decimal(str(campaign.get("fixed_price", 0))) for campaign in banner_campaigns if campaign.get("distributed"))
|
| 86 |
+
totals["banner_revenue"] = banner_revenue
|
| 87 |
+
totals["banner_platform_share"] = (banner_revenue * Decimal("0.30")).quantize(Decimal("0.000001"))
|
| 88 |
+
|
| 89 |
+
return {
|
| 90 |
+
"count": totals["count"],
|
| 91 |
+
"video_ad_creator_payout": float(totals["video_ad_creator_payout"]),
|
| 92 |
+
"video_ad_platform_fee": float(totals["video_ad_platform_fee"]),
|
| 93 |
+
"banner_creator_payout": float(totals["banner_creator_payout"]),
|
| 94 |
+
"banner_revenue": float(totals["banner_revenue"]),
|
| 95 |
+
"banner_platform_share": float(totals["banner_platform_share"]),
|
| 96 |
+
}
|
app/routes/videos.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
| 4 |
+
|
| 5 |
+
from ..database import get_db
|
| 6 |
+
from ..services import storage_service
|
| 7 |
+
from .auth import get_current_user
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.post("/upload")
|
| 14 |
+
async def upload_video(
|
| 15 |
+
title: str = Form(...),
|
| 16 |
+
description: str = Form(""),
|
| 17 |
+
file: UploadFile = File(...),
|
| 18 |
+
current_user: dict = Depends(get_current_user),
|
| 19 |
+
):
|
| 20 |
+
file_content = await file.read()
|
| 21 |
+
if not file_content:
|
| 22 |
+
raise HTTPException(status_code=400, detail="Video file is empty.")
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
cid = storage_service.upload_file(file_content, file.filename or "video.mp4")
|
| 26 |
+
except Exception as exc:
|
| 27 |
+
raise HTTPException(status_code=500, detail=f"Pinata upload failed: {exc}") from exc
|
| 28 |
+
|
| 29 |
+
db = get_db()
|
| 30 |
+
insert_payload = {
|
| 31 |
+
"creator_id": current_user["user_id"],
|
| 32 |
+
"cid": cid,
|
| 33 |
+
"title": title.strip(),
|
| 34 |
+
"description": description.strip(),
|
| 35 |
+
"ads_enabled": True,
|
| 36 |
+
}
|
| 37 |
+
created = db.table("videos").insert(insert_payload).execute()
|
| 38 |
+
if not created.data:
|
| 39 |
+
raise HTTPException(status_code=500, detail="Failed to save video metadata.")
|
| 40 |
+
|
| 41 |
+
created_video = created.data[0]
|
| 42 |
+
return {
|
| 43 |
+
"status": "success",
|
| 44 |
+
"video_id": created_video["id"],
|
| 45 |
+
"cid": cid,
|
| 46 |
+
"ipfs_url": storage_service.build_ipfs_url(cid),
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@router.get("/list")
|
| 51 |
+
async def list_videos():
|
| 52 |
+
db = get_db()
|
| 53 |
+
videos = db.table("videos").select("*, users(username, wallet_address)").order("created_at", desc=True).execute()
|
| 54 |
+
return videos.data or []
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@router.get("/me")
|
| 58 |
+
async def list_my_videos(current_user: dict = Depends(get_current_user)):
|
| 59 |
+
db = get_db()
|
| 60 |
+
videos = (
|
| 61 |
+
db.table("videos")
|
| 62 |
+
.select("*")
|
| 63 |
+
.eq("creator_id", current_user["user_id"])
|
| 64 |
+
.order("created_at", desc=True)
|
| 65 |
+
.execute()
|
| 66 |
+
)
|
| 67 |
+
return videos.data or []
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.get("/{video_id}")
|
| 71 |
+
async def get_video(video_id: str):
|
| 72 |
+
db = get_db()
|
| 73 |
+
result = (
|
| 74 |
+
db.table("videos")
|
| 75 |
+
.select("*, users(username, wallet_address)")
|
| 76 |
+
.eq("id", video_id)
|
| 77 |
+
.limit(1)
|
| 78 |
+
.execute()
|
| 79 |
+
)
|
| 80 |
+
if not result.data:
|
| 81 |
+
raise HTTPException(status_code=404, detail="Video not found.")
|
| 82 |
+
|
| 83 |
+
video = result.data[0]
|
| 84 |
+
video["ipfs_url"] = storage_service.build_ipfs_url(video["cid"])
|
| 85 |
+
return video
|
app/routes/views.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
|
| 8 |
+
from ..database import get_db
|
| 9 |
+
from ..utils import anti_bot
|
| 10 |
+
from .auth import get_current_user
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class TrackViewRequest(BaseModel):
|
| 17 |
+
video_id: str
|
| 18 |
+
watch_seconds: int
|
| 19 |
+
wallet: str | None = None
|
| 20 |
+
device_fingerprint: str | None = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.post("/track")
|
| 24 |
+
async def track_view(
|
| 25 |
+
payload: TrackViewRequest,
|
| 26 |
+
request: Request,
|
| 27 |
+
current_user: dict = Depends(get_current_user),
|
| 28 |
+
):
|
| 29 |
+
wallet_address = current_user["wallet_address"]
|
| 30 |
+
if payload.wallet and payload.wallet != wallet_address:
|
| 31 |
+
raise HTTPException(status_code=403, detail="Wallet mismatch.")
|
| 32 |
+
|
| 33 |
+
ip_address = request.client.host if request.client else None
|
| 34 |
+
fingerprint = payload.device_fingerprint or request.headers.get("x-device-fingerprint")
|
| 35 |
+
|
| 36 |
+
is_valid, reason = anti_bot.validate_view(
|
| 37 |
+
wallet=wallet_address,
|
| 38 |
+
video_id=payload.video_id,
|
| 39 |
+
watch_seconds=payload.watch_seconds,
|
| 40 |
+
ip_address=ip_address,
|
| 41 |
+
device_fingerprint=fingerprint,
|
| 42 |
+
)
|
| 43 |
+
if not is_valid:
|
| 44 |
+
return {"status": "ignored", "reason": reason}
|
| 45 |
+
|
| 46 |
+
db = get_db()
|
| 47 |
+
video_res = (
|
| 48 |
+
db.table("videos")
|
| 49 |
+
.select("id, total_views, total_watch_time, ads_enabled")
|
| 50 |
+
.eq("id", payload.video_id)
|
| 51 |
+
.limit(1)
|
| 52 |
+
.execute()
|
| 53 |
+
)
|
| 54 |
+
if not video_res.data:
|
| 55 |
+
raise HTTPException(status_code=404, detail="Video not found.")
|
| 56 |
+
|
| 57 |
+
video = video_res.data[0]
|
| 58 |
+
insert_payload = {
|
| 59 |
+
"video_id": payload.video_id,
|
| 60 |
+
"viewer_wallet": wallet_address,
|
| 61 |
+
"watch_seconds": payload.watch_seconds,
|
| 62 |
+
"settled": False,
|
| 63 |
+
"viewer_fingerprint": fingerprint,
|
| 64 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 65 |
+
}
|
| 66 |
+
created = db.table("views").insert(insert_payload).execute()
|
| 67 |
+
if not created.data:
|
| 68 |
+
raise HTTPException(status_code=500, detail="Failed to store view.")
|
| 69 |
+
|
| 70 |
+
db.table("videos").update(
|
| 71 |
+
{
|
| 72 |
+
"total_views": int(video.get("total_views", 0)) + 1,
|
| 73 |
+
"total_watch_time": int(video.get("total_watch_time", 0)) + int(payload.watch_seconds),
|
| 74 |
+
}
|
| 75 |
+
).eq("id", payload.video_id).execute()
|
| 76 |
+
|
| 77 |
+
return {"status": "recorded"}
|
app/routes/wallets.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from decimal import Decimal
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
+
|
| 7 |
+
from ..config import settings
|
| 8 |
+
from ..services import algorand_service
|
| 9 |
+
from .auth import get_current_user
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _require_platform_operator(current_user: dict) -> None:
|
| 16 |
+
configured_platform = (settings.platform_wallet or "").strip().lower()
|
| 17 |
+
if not configured_platform:
|
| 18 |
+
raise HTTPException(status_code=500, detail="PLATFORM_WALLET is not configured.")
|
| 19 |
+
|
| 20 |
+
wallet = current_user["wallet_address"].strip().lower()
|
| 21 |
+
if wallet != configured_platform:
|
| 22 |
+
raise HTTPException(status_code=403, detail="Only platform wallet can access this resource.")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _to_float(value: Decimal) -> float:
|
| 26 |
+
return float(value.quantize(Decimal("0.000001")))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@router.get("/balance")
|
| 30 |
+
async def get_my_wallet_balance(current_user: dict = Depends(get_current_user)):
|
| 31 |
+
wallet = current_user["wallet_address"]
|
| 32 |
+
try:
|
| 33 |
+
balance = algorand_service.get_asset_balance(wallet)
|
| 34 |
+
except Exception as exc:
|
| 35 |
+
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
| 36 |
+
return {
|
| 37 |
+
"wallet_address": wallet,
|
| 38 |
+
"asset_id": settings.asset_id,
|
| 39 |
+
"balance": _to_float(balance),
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@router.get("/platform-balance")
|
| 44 |
+
async def get_platform_wallet_balance(current_user: dict = Depends(get_current_user)):
|
| 45 |
+
_require_platform_operator(current_user)
|
| 46 |
+
platform_wallet = (settings.platform_wallet or "").strip()
|
| 47 |
+
try:
|
| 48 |
+
balance = algorand_service.get_asset_balance(platform_wallet)
|
| 49 |
+
except Exception as exc:
|
| 50 |
+
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
| 51 |
+
return {
|
| 52 |
+
"wallet_address": platform_wallet,
|
| 53 |
+
"asset_id": settings.asset_id,
|
| 54 |
+
"balance": _to_float(balance),
|
| 55 |
+
}
|
app/schemas.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, ConfigDict
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class User(BaseModel):
|
| 9 |
+
id: str
|
| 10 |
+
wallet_address: str
|
| 11 |
+
username: str | None = None
|
| 12 |
+
role: str
|
| 13 |
+
subscribers_count: int
|
| 14 |
+
created_at: datetime
|
| 15 |
+
|
| 16 |
+
model_config = ConfigDict(from_attributes=True)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class Video(BaseModel):
|
| 20 |
+
id: str
|
| 21 |
+
creator_id: str
|
| 22 |
+
cid: str
|
| 23 |
+
title: str
|
| 24 |
+
description: str | None = ""
|
| 25 |
+
ads_enabled: bool
|
| 26 |
+
total_views: int
|
| 27 |
+
total_watch_time: int
|
| 28 |
+
created_at: datetime
|
| 29 |
+
|
| 30 |
+
model_config = ConfigDict(from_attributes=True)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class View(BaseModel):
|
| 34 |
+
id: str
|
| 35 |
+
video_id: str
|
| 36 |
+
viewer_wallet: str
|
| 37 |
+
watch_seconds: int
|
| 38 |
+
settled: bool
|
| 39 |
+
timestamp: datetime
|
| 40 |
+
|
| 41 |
+
model_config = ConfigDict(from_attributes=True)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class AdCampaign(BaseModel):
|
| 45 |
+
id: str
|
| 46 |
+
advertiser_wallet: str
|
| 47 |
+
video_id: str
|
| 48 |
+
budget: float
|
| 49 |
+
remaining_budget: float
|
| 50 |
+
reward_per_view: float
|
| 51 |
+
active: bool
|
| 52 |
+
ad_video_cid: str | None = None
|
| 53 |
+
created_at: datetime
|
| 54 |
+
|
| 55 |
+
model_config = ConfigDict(from_attributes=True)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class BannerCampaign(BaseModel):
|
| 59 |
+
id: str
|
| 60 |
+
advertiser_wallet: str
|
| 61 |
+
tier: str
|
| 62 |
+
fixed_price: float
|
| 63 |
+
start_date: str
|
| 64 |
+
end_date: str
|
| 65 |
+
active: bool
|
| 66 |
+
distributed: bool
|
| 67 |
+
created_at: datetime
|
| 68 |
+
|
| 69 |
+
model_config = ConfigDict(from_attributes=True)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class Settlement(BaseModel):
|
| 73 |
+
id: str
|
| 74 |
+
creator_wallet: str
|
| 75 |
+
amount: float
|
| 76 |
+
platform_fee: float
|
| 77 |
+
tx_hash: str | None = None
|
| 78 |
+
settlement_type: str
|
| 79 |
+
timestamp: datetime
|
| 80 |
+
|
| 81 |
+
model_config = ConfigDict(from_attributes=True)
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/algorand_service.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
from decimal import Decimal, ROUND_DOWN
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from algosdk import account, mnemonic, util
|
| 8 |
+
from algosdk.transaction import AssetTransferTxn, wait_for_confirmation
|
| 9 |
+
from algosdk.v2client import algod
|
| 10 |
+
from algosdk import transaction
|
| 11 |
+
|
| 12 |
+
from ..config import settings
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def get_algod_client() -> algod.AlgodClient:
|
| 16 |
+
return algod.AlgodClient(settings.algod_token, settings.algod_address)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _token_scale() -> Decimal:
|
| 20 |
+
return Decimal(10) ** settings.token_decimals
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _token_quantizer() -> Decimal:
|
| 24 |
+
if settings.token_decimals <= 0:
|
| 25 |
+
return Decimal("1")
|
| 26 |
+
return Decimal(f"1.{'0' * settings.token_decimals}")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def to_base_units(amount_tokens: Decimal | float | int | str) -> int:
|
| 30 |
+
amount = Decimal(str(amount_tokens))
|
| 31 |
+
if amount <= 0:
|
| 32 |
+
return 0
|
| 33 |
+
scaled = (amount * _token_scale()).quantize(Decimal("1"), rounding=ROUND_DOWN)
|
| 34 |
+
return int(scaled)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def from_base_units(amount_base_units: int) -> Decimal:
|
| 38 |
+
if amount_base_units <= 0:
|
| 39 |
+
return Decimal("0").quantize(_token_quantizer())
|
| 40 |
+
return (Decimal(amount_base_units) / _token_scale()).quantize(_token_quantizer())
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def get_asset_balance(wallet_address: str) -> Decimal:
|
| 44 |
+
wallet = (wallet_address or "").strip()
|
| 45 |
+
if not wallet:
|
| 46 |
+
return Decimal("0").quantize(_token_quantizer())
|
| 47 |
+
if settings.asset_id <= 0:
|
| 48 |
+
return Decimal("0").quantize(_token_quantizer())
|
| 49 |
+
|
| 50 |
+
client = get_algod_client()
|
| 51 |
+
info = client.account_info(wallet)
|
| 52 |
+
holdings = info.get("assets") or []
|
| 53 |
+
for holding in holdings:
|
| 54 |
+
if int(holding.get("asset-id", 0)) == int(settings.asset_id):
|
| 55 |
+
amount = int(holding.get("amount", 0))
|
| 56 |
+
return from_base_units(amount)
|
| 57 |
+
return Decimal("0").quantize(_token_quantizer())
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _decode_signature(signature: str) -> bytes:
|
| 61 |
+
try:
|
| 62 |
+
return base64.b64decode(signature)
|
| 63 |
+
except Exception:
|
| 64 |
+
try:
|
| 65 |
+
return bytes.fromhex(signature)
|
| 66 |
+
except Exception:
|
| 67 |
+
return signature.encode("utf-8")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def verify_signature(wallet_address: str, message: str, signature: str) -> bool:
|
| 71 |
+
if not wallet_address or not message or not signature:
|
| 72 |
+
return False
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
signature_bytes = _decode_signature(signature)
|
| 76 |
+
return util.verify_bytes(message.encode("utf-8"), signature_bytes, wallet_address)
|
| 77 |
+
except Exception:
|
| 78 |
+
return False
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _get_signer() -> tuple[str, str]:
|
| 82 |
+
if not settings.algorand_mnemonic:
|
| 83 |
+
raise RuntimeError("ALGORAND_MNEMONIC is not configured.")
|
| 84 |
+
|
| 85 |
+
private_key = mnemonic.to_private_key(settings.algorand_mnemonic)
|
| 86 |
+
sender_address = account.address_from_private_key(private_key)
|
| 87 |
+
return private_key, sender_address
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _send_asset_transfer(receiver_wallet: str, amount_base_units: int, note: str | None = None) -> str:
|
| 91 |
+
if settings.asset_id <= 0:
|
| 92 |
+
raise RuntimeError("ASSET_ID is not configured.")
|
| 93 |
+
if amount_base_units <= 0:
|
| 94 |
+
raise RuntimeError("Transfer amount must be > 0.")
|
| 95 |
+
|
| 96 |
+
client = get_algod_client()
|
| 97 |
+
private_key, sender_address = _get_signer()
|
| 98 |
+
params = client.suggested_params()
|
| 99 |
+
|
| 100 |
+
txn = AssetTransferTxn(
|
| 101 |
+
sender=sender_address,
|
| 102 |
+
sp=params,
|
| 103 |
+
receiver=receiver_wallet,
|
| 104 |
+
amt=amount_base_units,
|
| 105 |
+
index=settings.asset_id,
|
| 106 |
+
note=note.encode("utf-8") if note else None,
|
| 107 |
+
)
|
| 108 |
+
signed_txn = txn.sign(private_key)
|
| 109 |
+
txid = client.send_transaction(signed_txn)
|
| 110 |
+
wait_for_confirmation(client, txid, 4)
|
| 111 |
+
return txid
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _call_settle_contract(creator_wallet: str, gross_amount_base_units: int) -> str:
|
| 115 |
+
if settings.app_id <= 0:
|
| 116 |
+
raise RuntimeError("APP_ID is not configured.")
|
| 117 |
+
if settings.asset_id <= 0:
|
| 118 |
+
raise RuntimeError("ASSET_ID is not configured.")
|
| 119 |
+
|
| 120 |
+
client = get_algod_client()
|
| 121 |
+
private_key, sender_address = _get_signer()
|
| 122 |
+
params = client.suggested_params()
|
| 123 |
+
|
| 124 |
+
txn = transaction.ApplicationNoOpTxn(
|
| 125 |
+
sender=sender_address,
|
| 126 |
+
sp=params,
|
| 127 |
+
index=settings.app_id,
|
| 128 |
+
app_args=[b"settle_reward", gross_amount_base_units.to_bytes(8, "big")],
|
| 129 |
+
accounts=[creator_wallet],
|
| 130 |
+
foreign_assets=[settings.asset_id],
|
| 131 |
+
)
|
| 132 |
+
signed_txn = txn.sign(private_key)
|
| 133 |
+
txid = client.send_transaction(signed_txn)
|
| 134 |
+
wait_for_confirmation(client, txid, 4)
|
| 135 |
+
return txid
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _call_withdraw_contract(advertiser_wallet: str, amount_base_units: int) -> str:
|
| 139 |
+
if settings.app_id <= 0:
|
| 140 |
+
raise RuntimeError("APP_ID is not configured.")
|
| 141 |
+
if settings.asset_id <= 0:
|
| 142 |
+
raise RuntimeError("ASSET_ID is not configured.")
|
| 143 |
+
|
| 144 |
+
client = get_algod_client()
|
| 145 |
+
private_key, sender_address = _get_signer()
|
| 146 |
+
params = client.suggested_params()
|
| 147 |
+
|
| 148 |
+
txn = transaction.ApplicationNoOpTxn(
|
| 149 |
+
sender=sender_address,
|
| 150 |
+
sp=params,
|
| 151 |
+
index=settings.app_id,
|
| 152 |
+
app_args=[b"withdraw_unused", amount_base_units.to_bytes(8, "big")],
|
| 153 |
+
accounts=[advertiser_wallet],
|
| 154 |
+
foreign_assets=[settings.asset_id],
|
| 155 |
+
)
|
| 156 |
+
signed_txn = txn.sign(private_key)
|
| 157 |
+
txid = client.send_transaction(signed_txn)
|
| 158 |
+
wait_for_confirmation(client, txid, 4)
|
| 159 |
+
return txid
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def settle_reward(creator_wallet: str, gross_amount_tokens: Decimal | float | int | str) -> dict[str, Any]:
|
| 163 |
+
gross_base_units = to_base_units(gross_amount_tokens)
|
| 164 |
+
if gross_base_units <= 0:
|
| 165 |
+
raise RuntimeError("Settlement amount too small.")
|
| 166 |
+
|
| 167 |
+
fee_base_units = (gross_base_units * settings.settlement_fee_bps) // 10000
|
| 168 |
+
creator_base_units = gross_base_units - fee_base_units
|
| 169 |
+
if creator_base_units <= 0:
|
| 170 |
+
raise RuntimeError("Settlement amount too small after fee.")
|
| 171 |
+
|
| 172 |
+
if settings.use_contract_settlement and settings.app_id > 0:
|
| 173 |
+
tx_hash = _call_settle_contract(creator_wallet, gross_base_units)
|
| 174 |
+
else:
|
| 175 |
+
# Fallback path: transfer creator share from platform wallet.
|
| 176 |
+
# Fee remains in platform-controlled wallet balance.
|
| 177 |
+
tx_hash = _send_asset_transfer(
|
| 178 |
+
creator_wallet,
|
| 179 |
+
creator_base_units,
|
| 180 |
+
note="rift:video-settlement",
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
return {
|
| 184 |
+
"tx_hash": tx_hash,
|
| 185 |
+
"gross_amount": from_base_units(gross_base_units),
|
| 186 |
+
"platform_fee": from_base_units(fee_base_units),
|
| 187 |
+
"creator_amount": from_base_units(creator_base_units),
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def transfer_tokens(receiver_wallet: str, amount_tokens: Decimal | float | int | str) -> dict[str, Any]:
|
| 192 |
+
amount_base_units = to_base_units(amount_tokens)
|
| 193 |
+
if amount_base_units <= 0:
|
| 194 |
+
raise RuntimeError("Transfer amount too small.")
|
| 195 |
+
|
| 196 |
+
tx_hash = _send_asset_transfer(
|
| 197 |
+
receiver_wallet,
|
| 198 |
+
amount_base_units,
|
| 199 |
+
note="rift:banner-distribution",
|
| 200 |
+
)
|
| 201 |
+
return {
|
| 202 |
+
"tx_hash": tx_hash,
|
| 203 |
+
"amount": from_base_units(amount_base_units),
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def withdraw_unused(advertiser_wallet: str, amount_tokens: Decimal | float | int | str) -> str:
|
| 208 |
+
amount_base_units = to_base_units(amount_tokens)
|
| 209 |
+
if amount_base_units <= 0:
|
| 210 |
+
raise RuntimeError("Withdrawal amount too small.")
|
| 211 |
+
|
| 212 |
+
if settings.use_contract_settlement and settings.app_id > 0:
|
| 213 |
+
return _call_withdraw_contract(advertiser_wallet, amount_base_units)
|
| 214 |
+
|
| 215 |
+
return _send_asset_transfer(
|
| 216 |
+
advertiser_wallet,
|
| 217 |
+
amount_base_units,
|
| 218 |
+
note="rift:withdraw-unused",
|
| 219 |
+
)
|
app/services/banner_engine.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import date, datetime, timezone
|
| 4 |
+
from decimal import Decimal, ROUND_DOWN
|
| 5 |
+
|
| 6 |
+
from ..database import get_db
|
| 7 |
+
from . import algorand_service
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _to_decimal(value: object) -> Decimal:
|
| 11 |
+
return Decimal(str(value or 0))
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _token_quantizer() -> Decimal:
|
| 15 |
+
return Decimal("0.000001")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _eligible_banner_campaigns(campaigns: list[dict]) -> list[dict]:
|
| 19 |
+
today = date.today()
|
| 20 |
+
eligible: list[dict] = []
|
| 21 |
+
for campaign in campaigns:
|
| 22 |
+
if campaign.get("distributed"):
|
| 23 |
+
continue
|
| 24 |
+
end_date_raw = campaign.get("end_date")
|
| 25 |
+
if not end_date_raw:
|
| 26 |
+
eligible.append(campaign)
|
| 27 |
+
continue
|
| 28 |
+
try:
|
| 29 |
+
end_date = date.fromisoformat(str(end_date_raw))
|
| 30 |
+
except ValueError:
|
| 31 |
+
eligible.append(campaign)
|
| 32 |
+
continue
|
| 33 |
+
if end_date <= today:
|
| 34 |
+
eligible.append(campaign)
|
| 35 |
+
return eligible
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def distribute_banner_rewards() -> dict[str, int | float]:
|
| 39 |
+
db = get_db()
|
| 40 |
+
campaigns = db.table("banner_campaigns").select("*").eq("active", True).execute().data or []
|
| 41 |
+
eligible_campaigns = _eligible_banner_campaigns(campaigns)
|
| 42 |
+
if not eligible_campaigns:
|
| 43 |
+
return {
|
| 44 |
+
"campaigns_distributed": 0,
|
| 45 |
+
"creators_paid": 0,
|
| 46 |
+
"creator_pool": 0.0,
|
| 47 |
+
"platform_share": 0.0,
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
total_revenue = sum(_to_decimal(campaign.get("fixed_price")) for campaign in eligible_campaigns)
|
| 51 |
+
if total_revenue <= 0:
|
| 52 |
+
return {
|
| 53 |
+
"campaigns_distributed": 0,
|
| 54 |
+
"creators_paid": 0,
|
| 55 |
+
"creator_pool": 0.0,
|
| 56 |
+
"platform_share": 0.0,
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
platform_share = (total_revenue * Decimal("0.30")).quantize(_token_quantizer(), rounding=ROUND_DOWN)
|
| 60 |
+
creator_pool = (total_revenue * Decimal("0.70")).quantize(_token_quantizer(), rounding=ROUND_DOWN)
|
| 61 |
+
if creator_pool <= 0:
|
| 62 |
+
return {
|
| 63 |
+
"campaigns_distributed": 0,
|
| 64 |
+
"creators_paid": 0,
|
| 65 |
+
"creator_pool": float(creator_pool),
|
| 66 |
+
"platform_share": float(platform_share),
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
creators = db.table("users").select("id, wallet_address, subscribers_count").eq("role", "creator").execute().data or []
|
| 70 |
+
eligible_creators = [creator for creator in creators if int(creator.get("subscribers_count") or 0) > 0]
|
| 71 |
+
total_subscribers = sum(int(creator["subscribers_count"]) for creator in eligible_creators)
|
| 72 |
+
if total_subscribers <= 0:
|
| 73 |
+
return {
|
| 74 |
+
"campaigns_distributed": 0,
|
| 75 |
+
"creators_paid": 0,
|
| 76 |
+
"creator_pool": float(creator_pool),
|
| 77 |
+
"platform_share": float(platform_share),
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
creators_paid = 0
|
| 81 |
+
for creator in eligible_creators:
|
| 82 |
+
subscribers_count = int(creator["subscribers_count"])
|
| 83 |
+
ratio = Decimal(subscribers_count) / Decimal(total_subscribers)
|
| 84 |
+
creator_reward = (creator_pool * ratio).quantize(_token_quantizer(), rounding=ROUND_DOWN)
|
| 85 |
+
if creator_reward <= 0:
|
| 86 |
+
continue
|
| 87 |
+
|
| 88 |
+
transfer = algorand_service.transfer_tokens(creator["wallet_address"], creator_reward)
|
| 89 |
+
db.table("settlements").insert(
|
| 90 |
+
{
|
| 91 |
+
"creator_wallet": creator["wallet_address"],
|
| 92 |
+
"amount": float(transfer["amount"]),
|
| 93 |
+
"platform_fee": 0.0,
|
| 94 |
+
"tx_hash": transfer["tx_hash"],
|
| 95 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 96 |
+
"settlement_type": "banner",
|
| 97 |
+
}
|
| 98 |
+
).execute()
|
| 99 |
+
creators_paid += 1
|
| 100 |
+
|
| 101 |
+
campaign_ids = [campaign["id"] for campaign in eligible_campaigns]
|
| 102 |
+
db.table("banner_campaigns").update({"distributed": True, "active": False}).in_("id", campaign_ids).execute()
|
| 103 |
+
|
| 104 |
+
return {
|
| 105 |
+
"campaigns_distributed": len(eligible_campaigns),
|
| 106 |
+
"creators_paid": creators_paid,
|
| 107 |
+
"creator_pool": float(creator_pool),
|
| 108 |
+
"platform_share": float(platform_share),
|
| 109 |
+
}
|
app/services/reward_engine.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
from decimal import Decimal, ROUND_DOWN
|
| 5 |
+
|
| 6 |
+
from apscheduler.schedulers.background import BackgroundScheduler
|
| 7 |
+
|
| 8 |
+
from ..config import settings
|
| 9 |
+
from ..database import get_db
|
| 10 |
+
from . import algorand_service, banner_engine
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
_scheduler: BackgroundScheduler | None = None
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _to_decimal(value: object) -> Decimal:
|
| 17 |
+
return Decimal(str(value or 0))
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def calculate_and_settle() -> dict[str, int]:
|
| 21 |
+
db = get_db()
|
| 22 |
+
campaigns = (
|
| 23 |
+
db.table("ad_campaigns")
|
| 24 |
+
.select("*")
|
| 25 |
+
.eq("active", True)
|
| 26 |
+
.gt("remaining_budget", 0)
|
| 27 |
+
.execute()
|
| 28 |
+
.data
|
| 29 |
+
or []
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
report = {
|
| 33 |
+
"campaigns_processed": len(campaigns),
|
| 34 |
+
"campaigns_settled": 0,
|
| 35 |
+
"views_settled": 0,
|
| 36 |
+
"settlements_created": 0,
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
for campaign in campaigns:
|
| 40 |
+
try:
|
| 41 |
+
campaign_id = campaign["id"]
|
| 42 |
+
video_id = campaign["video_id"]
|
| 43 |
+
reward_per_view = _to_decimal(campaign.get("reward_per_view"))
|
| 44 |
+
remaining_budget = _to_decimal(campaign.get("remaining_budget"))
|
| 45 |
+
|
| 46 |
+
if reward_per_view <= 0 or remaining_budget <= 0:
|
| 47 |
+
db.table("ad_campaigns").update({"active": False}).eq("id", campaign_id).execute()
|
| 48 |
+
continue
|
| 49 |
+
|
| 50 |
+
views = (
|
| 51 |
+
db.table("views")
|
| 52 |
+
.select("id")
|
| 53 |
+
.eq("video_id", video_id)
|
| 54 |
+
.eq("settled", False)
|
| 55 |
+
.gte("watch_seconds", settings.view_min_watch_seconds)
|
| 56 |
+
.order("timestamp", desc=False)
|
| 57 |
+
.execute()
|
| 58 |
+
.data
|
| 59 |
+
or []
|
| 60 |
+
)
|
| 61 |
+
if not views:
|
| 62 |
+
continue
|
| 63 |
+
|
| 64 |
+
max_affordable_views = int((remaining_budget / reward_per_view).to_integral_value(rounding=ROUND_DOWN))
|
| 65 |
+
if max_affordable_views <= 0:
|
| 66 |
+
db.table("ad_campaigns").update({"active": False, "remaining_budget": 0}).eq("id", campaign_id).execute()
|
| 67 |
+
continue
|
| 68 |
+
|
| 69 |
+
payable_views = views[:max_affordable_views]
|
| 70 |
+
payable_view_count = len(payable_views)
|
| 71 |
+
if payable_view_count <= 0:
|
| 72 |
+
continue
|
| 73 |
+
|
| 74 |
+
creator_earnings = reward_per_view * Decimal(payable_view_count)
|
| 75 |
+
new_remaining_budget = remaining_budget - creator_earnings
|
| 76 |
+
if new_remaining_budget < 0:
|
| 77 |
+
new_remaining_budget = Decimal("0")
|
| 78 |
+
|
| 79 |
+
video_res = db.table("videos").select("creator_id").eq("id", video_id).limit(1).execute()
|
| 80 |
+
if not video_res.data:
|
| 81 |
+
continue
|
| 82 |
+
creator_id = video_res.data[0]["creator_id"]
|
| 83 |
+
|
| 84 |
+
creator_res = db.table("users").select("wallet_address").eq("id", creator_id).limit(1).execute()
|
| 85 |
+
if not creator_res.data:
|
| 86 |
+
continue
|
| 87 |
+
creator_wallet = creator_res.data[0]["wallet_address"]
|
| 88 |
+
|
| 89 |
+
settlement = algorand_service.settle_reward(creator_wallet, creator_earnings)
|
| 90 |
+
tx_hash = settlement["tx_hash"]
|
| 91 |
+
|
| 92 |
+
view_ids = [row["id"] for row in payable_views]
|
| 93 |
+
db.table("views").update({"settled": True}).in_("id", view_ids).execute()
|
| 94 |
+
|
| 95 |
+
db.table("settlements").insert(
|
| 96 |
+
{
|
| 97 |
+
"creator_wallet": creator_wallet,
|
| 98 |
+
"amount": float(settlement["creator_amount"]),
|
| 99 |
+
"platform_fee": float(settlement["platform_fee"]),
|
| 100 |
+
"tx_hash": tx_hash,
|
| 101 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 102 |
+
"settlement_type": "video_ad",
|
| 103 |
+
"campaign_id": campaign_id,
|
| 104 |
+
}
|
| 105 |
+
).execute()
|
| 106 |
+
|
| 107 |
+
db.table("ad_campaigns").update(
|
| 108 |
+
{
|
| 109 |
+
"remaining_budget": float(new_remaining_budget),
|
| 110 |
+
"active": bool(new_remaining_budget > 0),
|
| 111 |
+
}
|
| 112 |
+
).eq("id", campaign_id).execute()
|
| 113 |
+
|
| 114 |
+
report["campaigns_settled"] += 1
|
| 115 |
+
report["views_settled"] += payable_view_count
|
| 116 |
+
report["settlements_created"] += 1
|
| 117 |
+
except Exception:
|
| 118 |
+
continue
|
| 119 |
+
|
| 120 |
+
return report
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def start() -> None:
|
| 124 |
+
global _scheduler
|
| 125 |
+
if not settings.scheduler_enabled:
|
| 126 |
+
return
|
| 127 |
+
if _scheduler and _scheduler.running:
|
| 128 |
+
return
|
| 129 |
+
|
| 130 |
+
_scheduler = BackgroundScheduler()
|
| 131 |
+
_scheduler.add_job(calculate_and_settle, "interval", minutes=max(1, settings.reward_interval_minutes))
|
| 132 |
+
_scheduler.add_job(banner_engine.distribute_banner_rewards, "cron", day=1, hour=0, minute=5)
|
| 133 |
+
_scheduler.start()
|
app/services/settlement_service.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from decimal import Decimal
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from . import algorand_service
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def settle_rewards(creator_wallet: str, amount_tokens: Decimal | float | int | str) -> dict[str, Any]:
|
| 10 |
+
return algorand_service.settle_reward(creator_wallet, amount_tokens)
|
app/services/storage_service.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import requests
|
| 4 |
+
|
| 5 |
+
from ..config import settings
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def upload_file(file_content: bytes, filename: str) -> str:
|
| 9 |
+
if not settings.pinata_jwt:
|
| 10 |
+
raise RuntimeError("PINATA_JWT is not configured.")
|
| 11 |
+
|
| 12 |
+
response = requests.post(
|
| 13 |
+
"https://api.pinata.cloud/pinning/pinFileToIPFS",
|
| 14 |
+
headers={"Authorization": f"Bearer {settings.pinata_jwt}"},
|
| 15 |
+
files={"file": (filename, file_content)},
|
| 16 |
+
timeout=120,
|
| 17 |
+
)
|
| 18 |
+
response.raise_for_status()
|
| 19 |
+
payload = response.json()
|
| 20 |
+
cid = payload.get("IpfsHash")
|
| 21 |
+
if not cid:
|
| 22 |
+
raise RuntimeError("Pinata response missing IpfsHash.")
|
| 23 |
+
return cid
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def build_ipfs_url(cid: str) -> str:
|
| 27 |
+
gateway = settings.pinata_gateway.strip()
|
| 28 |
+
if gateway.startswith("http://") or gateway.startswith("https://"):
|
| 29 |
+
base = gateway.rstrip("/")
|
| 30 |
+
else:
|
| 31 |
+
base = f"https://{gateway.rstrip('/')}"
|
| 32 |
+
return f"{base}/ipfs/{cid}"
|
app/utils/anti_bot.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections import defaultdict, deque
|
| 4 |
+
from datetime import datetime, timedelta, timezone
|
| 5 |
+
from typing import Deque
|
| 6 |
+
|
| 7 |
+
from ..config import settings
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
wallet_video_last_seen: dict[str, datetime] = {}
|
| 11 |
+
ip_events: defaultdict[str, Deque[datetime]] = defaultdict(deque)
|
| 12 |
+
fingerprint_events: defaultdict[str, Deque[datetime]] = defaultdict(deque)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _trim_old(events: Deque[datetime], now: datetime, window: timedelta) -> None:
|
| 16 |
+
while events and now - events[0] > window:
|
| 17 |
+
events.popleft()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def validate_view(
|
| 21 |
+
wallet: str,
|
| 22 |
+
video_id: str,
|
| 23 |
+
watch_seconds: int,
|
| 24 |
+
ip_address: str | None = None,
|
| 25 |
+
device_fingerprint: str | None = None,
|
| 26 |
+
) -> tuple[bool, str]:
|
| 27 |
+
if watch_seconds < settings.view_min_watch_seconds:
|
| 28 |
+
return False, "min_watch_time_not_met"
|
| 29 |
+
|
| 30 |
+
now = datetime.now(timezone.utc)
|
| 31 |
+
cooldown = timedelta(seconds=settings.view_wallet_cooldown_seconds)
|
| 32 |
+
wallet_key = f"{wallet}:{video_id}"
|
| 33 |
+
|
| 34 |
+
last_view = wallet_video_last_seen.get(wallet_key)
|
| 35 |
+
if last_view and (now - last_view) < cooldown:
|
| 36 |
+
return False, "wallet_rate_limited"
|
| 37 |
+
wallet_video_last_seen[wallet_key] = now
|
| 38 |
+
|
| 39 |
+
one_hour = timedelta(hours=1)
|
| 40 |
+
|
| 41 |
+
if ip_address:
|
| 42 |
+
ip_log = ip_events[ip_address]
|
| 43 |
+
_trim_old(ip_log, now, one_hour)
|
| 44 |
+
if len(ip_log) >= settings.view_ip_hourly_limit:
|
| 45 |
+
return False, "ip_rate_limited"
|
| 46 |
+
ip_log.append(now)
|
| 47 |
+
|
| 48 |
+
if device_fingerprint:
|
| 49 |
+
fingerprint_log = fingerprint_events[device_fingerprint]
|
| 50 |
+
_trim_old(fingerprint_log, now, one_hour)
|
| 51 |
+
if len(fingerprint_log) >= settings.view_fingerprint_hourly_limit:
|
| 52 |
+
return False, "fingerprint_rate_limited"
|
| 53 |
+
fingerprint_log.append(now)
|
| 54 |
+
|
| 55 |
+
return True, "ok"
|
approval.teal
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#pragma version 6
|
| 2 |
+
txn ApplicationID
|
| 3 |
+
int 0
|
| 4 |
+
==
|
| 5 |
+
bnz main_l16
|
| 6 |
+
txn OnCompletion
|
| 7 |
+
int DeleteApplication
|
| 8 |
+
==
|
| 9 |
+
bnz main_l15
|
| 10 |
+
txn OnCompletion
|
| 11 |
+
int UpdateApplication
|
| 12 |
+
==
|
| 13 |
+
bnz main_l14
|
| 14 |
+
txn OnCompletion
|
| 15 |
+
int CloseOut
|
| 16 |
+
==
|
| 17 |
+
bnz main_l13
|
| 18 |
+
txn OnCompletion
|
| 19 |
+
int OptIn
|
| 20 |
+
==
|
| 21 |
+
bnz main_l12
|
| 22 |
+
txna ApplicationArgs 0
|
| 23 |
+
byte "update_wallet"
|
| 24 |
+
==
|
| 25 |
+
bnz main_l11
|
| 26 |
+
txna ApplicationArgs 0
|
| 27 |
+
byte "optin_asset"
|
| 28 |
+
==
|
| 29 |
+
bnz main_l10
|
| 30 |
+
txna ApplicationArgs 0
|
| 31 |
+
byte "settle"
|
| 32 |
+
==
|
| 33 |
+
bnz main_l9
|
| 34 |
+
err
|
| 35 |
+
main_l9:
|
| 36 |
+
txn Sender
|
| 37 |
+
byte "admin"
|
| 38 |
+
app_global_get
|
| 39 |
+
==
|
| 40 |
+
assert
|
| 41 |
+
int 2
|
| 42 |
+
txnas Accounts
|
| 43 |
+
byte "platform_wallet"
|
| 44 |
+
app_global_get
|
| 45 |
+
==
|
| 46 |
+
assert
|
| 47 |
+
itxn_begin
|
| 48 |
+
int axfer
|
| 49 |
+
itxn_field TypeEnum
|
| 50 |
+
txna Assets 0
|
| 51 |
+
itxn_field XferAsset
|
| 52 |
+
int 1
|
| 53 |
+
txnas Accounts
|
| 54 |
+
itxn_field AssetReceiver
|
| 55 |
+
txna ApplicationArgs 1
|
| 56 |
+
btoi
|
| 57 |
+
txna ApplicationArgs 1
|
| 58 |
+
btoi
|
| 59 |
+
int 200
|
| 60 |
+
*
|
| 61 |
+
int 10000
|
| 62 |
+
/
|
| 63 |
+
-
|
| 64 |
+
itxn_field AssetAmount
|
| 65 |
+
itxn_next
|
| 66 |
+
int axfer
|
| 67 |
+
itxn_field TypeEnum
|
| 68 |
+
txna Assets 0
|
| 69 |
+
itxn_field XferAsset
|
| 70 |
+
int 2
|
| 71 |
+
txnas Accounts
|
| 72 |
+
itxn_field AssetReceiver
|
| 73 |
+
txna ApplicationArgs 1
|
| 74 |
+
btoi
|
| 75 |
+
int 200
|
| 76 |
+
*
|
| 77 |
+
int 10000
|
| 78 |
+
/
|
| 79 |
+
itxn_field AssetAmount
|
| 80 |
+
itxn_submit
|
| 81 |
+
int 1
|
| 82 |
+
return
|
| 83 |
+
main_l10:
|
| 84 |
+
txn Sender
|
| 85 |
+
byte "admin"
|
| 86 |
+
app_global_get
|
| 87 |
+
==
|
| 88 |
+
assert
|
| 89 |
+
itxn_begin
|
| 90 |
+
int axfer
|
| 91 |
+
itxn_field TypeEnum
|
| 92 |
+
txna Assets 0
|
| 93 |
+
itxn_field XferAsset
|
| 94 |
+
global CurrentApplicationAddress
|
| 95 |
+
itxn_field AssetReceiver
|
| 96 |
+
int 0
|
| 97 |
+
itxn_field AssetAmount
|
| 98 |
+
itxn_submit
|
| 99 |
+
int 1
|
| 100 |
+
return
|
| 101 |
+
main_l11:
|
| 102 |
+
txn Sender
|
| 103 |
+
byte "admin"
|
| 104 |
+
app_global_get
|
| 105 |
+
==
|
| 106 |
+
assert
|
| 107 |
+
byte "platform_wallet"
|
| 108 |
+
txna Accounts 1
|
| 109 |
+
app_global_put
|
| 110 |
+
int 1
|
| 111 |
+
return
|
| 112 |
+
main_l12:
|
| 113 |
+
int 1
|
| 114 |
+
return
|
| 115 |
+
main_l13:
|
| 116 |
+
int 1
|
| 117 |
+
return
|
| 118 |
+
main_l14:
|
| 119 |
+
txn Sender
|
| 120 |
+
byte "admin"
|
| 121 |
+
app_global_get
|
| 122 |
+
==
|
| 123 |
+
return
|
| 124 |
+
main_l15:
|
| 125 |
+
txn Sender
|
| 126 |
+
byte "admin"
|
| 127 |
+
app_global_get
|
| 128 |
+
==
|
| 129 |
+
return
|
| 130 |
+
main_l16:
|
| 131 |
+
byte "admin"
|
| 132 |
+
txn Sender
|
| 133 |
+
app_global_put
|
| 134 |
+
byte "platform_wallet"
|
| 135 |
+
txn Sender
|
| 136 |
+
app_global_put
|
| 137 |
+
int 1
|
| 138 |
+
return
|
clear.teal
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#pragma version 6
|
| 2 |
+
int 1
|
| 3 |
+
return
|
contracts/smart_contract.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pyteal import *
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def approval_program():
|
| 5 |
+
fee_bps = Int(200)
|
| 6 |
+
bps_denominator = Int(10000)
|
| 7 |
+
|
| 8 |
+
token_key = Bytes("token_id")
|
| 9 |
+
platform_key = Bytes("platform_wallet")
|
| 10 |
+
admin_key = Bytes("admin")
|
| 11 |
+
|
| 12 |
+
on_create = Seq(
|
| 13 |
+
App.globalPut(admin_key, Txn.sender()),
|
| 14 |
+
App.globalPut(
|
| 15 |
+
token_key,
|
| 16 |
+
If(Txn.application_args.length() >= Int(1), Btoi(Txn.application_args[0]), Int(0)),
|
| 17 |
+
),
|
| 18 |
+
App.globalPut(
|
| 19 |
+
platform_key,
|
| 20 |
+
If(Txn.application_args.length() >= Int(2), Txn.application_args[1], Txn.sender()),
|
| 21 |
+
),
|
| 22 |
+
Approve(),
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
is_admin = Txn.sender() == App.globalGet(admin_key)
|
| 26 |
+
|
| 27 |
+
set_config = Seq(
|
| 28 |
+
Assert(is_admin),
|
| 29 |
+
Assert(Txn.application_args.length() >= Int(2)),
|
| 30 |
+
App.globalPut(token_key, Btoi(Txn.application_args[1])),
|
| 31 |
+
App.globalPut(
|
| 32 |
+
platform_key,
|
| 33 |
+
If(Txn.accounts.length() >= Int(1), Txn.accounts[1], App.globalGet(platform_key)),
|
| 34 |
+
),
|
| 35 |
+
Approve(),
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
opt_in_asset = Seq(
|
| 39 |
+
Assert(is_admin),
|
| 40 |
+
Assert(Txn.assets[0] == App.globalGet(token_key)),
|
| 41 |
+
InnerTxnBuilder.Begin(),
|
| 42 |
+
InnerTxnBuilder.SetFields(
|
| 43 |
+
{
|
| 44 |
+
TxnField.type_enum: TxnType.AssetTransfer,
|
| 45 |
+
TxnField.xfer_asset: Txn.assets[0],
|
| 46 |
+
TxnField.asset_receiver: Global.current_application_address(),
|
| 47 |
+
TxnField.asset_amount: Int(0),
|
| 48 |
+
}
|
| 49 |
+
),
|
| 50 |
+
InnerTxnBuilder.Submit(),
|
| 51 |
+
Approve(),
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
deposit = Seq(
|
| 55 |
+
Assert(Global.group_size() == Int(2)),
|
| 56 |
+
Assert(Txn.group_index() == Int(1)),
|
| 57 |
+
Assert(Gtxn[0].type_enum() == TxnType.AssetTransfer),
|
| 58 |
+
Assert(Gtxn[0].sender() == Txn.sender()),
|
| 59 |
+
Assert(Gtxn[0].xfer_asset() == App.globalGet(token_key)),
|
| 60 |
+
Assert(Gtxn[0].asset_receiver() == Global.current_application_address()),
|
| 61 |
+
Assert(Gtxn[0].asset_amount() > Int(0)),
|
| 62 |
+
Approve(),
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
total_amount = Btoi(Txn.application_args[1])
|
| 66 |
+
platform_fee = (total_amount * fee_bps) / bps_denominator
|
| 67 |
+
creator_amount = total_amount - platform_fee
|
| 68 |
+
|
| 69 |
+
settle_reward = Seq(
|
| 70 |
+
Assert(is_admin),
|
| 71 |
+
Assert(Txn.application_args.length() >= Int(2)),
|
| 72 |
+
Assert(Txn.assets[0] == App.globalGet(token_key)),
|
| 73 |
+
Assert(Txn.accounts.length() >= Int(1)),
|
| 74 |
+
Assert(total_amount > Int(0)),
|
| 75 |
+
Assert(creator_amount > Int(0)),
|
| 76 |
+
InnerTxnBuilder.Begin(),
|
| 77 |
+
InnerTxnBuilder.SetFields(
|
| 78 |
+
{
|
| 79 |
+
TxnField.type_enum: TxnType.AssetTransfer,
|
| 80 |
+
TxnField.xfer_asset: Txn.assets[0],
|
| 81 |
+
TxnField.asset_receiver: Txn.accounts[1],
|
| 82 |
+
TxnField.asset_amount: creator_amount,
|
| 83 |
+
}
|
| 84 |
+
),
|
| 85 |
+
InnerTxnBuilder.Submit(),
|
| 86 |
+
If(platform_fee > Int(0)).Then(
|
| 87 |
+
Seq(
|
| 88 |
+
InnerTxnBuilder.Begin(),
|
| 89 |
+
InnerTxnBuilder.SetFields(
|
| 90 |
+
{
|
| 91 |
+
TxnField.type_enum: TxnType.AssetTransfer,
|
| 92 |
+
TxnField.xfer_asset: Txn.assets[0],
|
| 93 |
+
TxnField.asset_receiver: App.globalGet(platform_key),
|
| 94 |
+
TxnField.asset_amount: platform_fee,
|
| 95 |
+
}
|
| 96 |
+
),
|
| 97 |
+
InnerTxnBuilder.Submit(),
|
| 98 |
+
)
|
| 99 |
+
),
|
| 100 |
+
Approve(),
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
withdraw_unused = Seq(
|
| 104 |
+
Assert(is_admin),
|
| 105 |
+
Assert(Txn.application_args.length() >= Int(2)),
|
| 106 |
+
Assert(Txn.assets[0] == App.globalGet(token_key)),
|
| 107 |
+
Assert(Txn.accounts.length() >= Int(1)),
|
| 108 |
+
Assert(Btoi(Txn.application_args[1]) > Int(0)),
|
| 109 |
+
InnerTxnBuilder.Begin(),
|
| 110 |
+
InnerTxnBuilder.SetFields(
|
| 111 |
+
{
|
| 112 |
+
TxnField.type_enum: TxnType.AssetTransfer,
|
| 113 |
+
TxnField.xfer_asset: Txn.assets[0],
|
| 114 |
+
TxnField.asset_receiver: Txn.accounts[1],
|
| 115 |
+
TxnField.asset_amount: Btoi(Txn.application_args[1]),
|
| 116 |
+
}
|
| 117 |
+
),
|
| 118 |
+
InnerTxnBuilder.Submit(),
|
| 119 |
+
Approve(),
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
return Cond(
|
| 123 |
+
[Txn.application_id() == Int(0), on_create],
|
| 124 |
+
[Txn.on_completion() == OnComplete.DeleteApplication, Return(is_admin)],
|
| 125 |
+
[Txn.on_completion() == OnComplete.UpdateApplication, Return(is_admin)],
|
| 126 |
+
[Txn.on_completion() == OnComplete.CloseOut, Approve()],
|
| 127 |
+
[Txn.on_completion() == OnComplete.OptIn, Approve()],
|
| 128 |
+
[Txn.application_args[0] == Bytes("set_config"), set_config],
|
| 129 |
+
[Txn.application_args[0] == Bytes("optin_asset"), opt_in_asset],
|
| 130 |
+
[Txn.application_args[0] == Bytes("deposit"), deposit],
|
| 131 |
+
[Txn.application_args[0] == Bytes("settle_reward"), settle_reward],
|
| 132 |
+
[Txn.application_args[0] == Bytes("withdraw_unused"), withdraw_unused],
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def clear_state_program():
|
| 137 |
+
return Approve()
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
if __name__ == "__main__":
|
| 141 |
+
with open("approval.teal", "w") as approval_file:
|
| 142 |
+
approval_file.write(compileTeal(approval_program(), mode=Mode.Application, version=6))
|
| 143 |
+
|
| 144 |
+
with open("clear.teal", "w") as clear_file:
|
| 145 |
+
clear_file.write(compileTeal(clear_state_program(), mode=Mode.Application, version=6))
|
requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
supabase
|
| 4 |
+
requests
|
| 5 |
+
py-algorand-sdk
|
| 6 |
+
pyteal
|
| 7 |
+
python-dotenv
|
| 8 |
+
pydantic
|
| 9 |
+
pydantic-settings
|
| 10 |
+
apscheduler
|
| 11 |
+
passlib
|
| 12 |
+
python-multipart
|
| 13 |
+
httpx
|
| 14 |
+
pytest
|
| 15 |
+
python-jose[cryptography]
|
schema.sql
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
create extension if not exists "uuid-ossp";
|
| 2 |
+
|
| 3 |
+
create table if not exists public.users (
|
| 4 |
+
id uuid primary key default uuid_generate_v4(),
|
| 5 |
+
wallet_address text not null unique,
|
| 6 |
+
username text,
|
| 7 |
+
role text not null default 'viewer' check (role in ('creator', 'viewer', 'advertiser')),
|
| 8 |
+
subscribers_count bigint not null default 0,
|
| 9 |
+
created_at timestamptz not null default timezone('utc', now())
|
| 10 |
+
);
|
| 11 |
+
|
| 12 |
+
create table if not exists public.videos (
|
| 13 |
+
id uuid primary key default uuid_generate_v4(),
|
| 14 |
+
creator_id uuid not null references public.users(id) on delete cascade,
|
| 15 |
+
cid text not null,
|
| 16 |
+
title text not null,
|
| 17 |
+
description text default '',
|
| 18 |
+
ads_enabled boolean not null default true,
|
| 19 |
+
total_views bigint not null default 0,
|
| 20 |
+
total_watch_time bigint not null default 0,
|
| 21 |
+
created_at timestamptz not null default timezone('utc', now())
|
| 22 |
+
);
|
| 23 |
+
|
| 24 |
+
create table if not exists public.subscriptions (
|
| 25 |
+
id uuid primary key default uuid_generate_v4(),
|
| 26 |
+
subscriber_id uuid not null references public.users(id) on delete cascade,
|
| 27 |
+
creator_id uuid not null references public.users(id) on delete cascade,
|
| 28 |
+
created_at timestamptz not null default timezone('utc', now()),
|
| 29 |
+
constraint subscriptions_unique unique(subscriber_id, creator_id),
|
| 30 |
+
constraint subscriptions_self_guard check (subscriber_id <> creator_id)
|
| 31 |
+
);
|
| 32 |
+
|
| 33 |
+
create table if not exists public.views (
|
| 34 |
+
id uuid primary key default uuid_generate_v4(),
|
| 35 |
+
video_id uuid not null references public.videos(id) on delete cascade,
|
| 36 |
+
viewer_wallet text not null,
|
| 37 |
+
viewer_fingerprint text,
|
| 38 |
+
watch_seconds int not null default 0 check (watch_seconds >= 0),
|
| 39 |
+
settled boolean not null default false,
|
| 40 |
+
timestamp timestamptz not null default timezone('utc', now())
|
| 41 |
+
);
|
| 42 |
+
|
| 43 |
+
create table if not exists public.ad_campaigns (
|
| 44 |
+
id uuid primary key default uuid_generate_v4(),
|
| 45 |
+
advertiser_wallet text not null,
|
| 46 |
+
video_id uuid not null references public.videos(id) on delete cascade,
|
| 47 |
+
budget numeric(20, 6) not null check (budget >= 0),
|
| 48 |
+
remaining_budget numeric(20, 6) not null check (remaining_budget >= 0),
|
| 49 |
+
reward_per_view numeric(20, 6) not null check (reward_per_view > 0),
|
| 50 |
+
active boolean not null default true,
|
| 51 |
+
ad_video_cid text,
|
| 52 |
+
created_at timestamptz not null default timezone('utc', now())
|
| 53 |
+
);
|
| 54 |
+
|
| 55 |
+
create table if not exists public.banner_campaigns (
|
| 56 |
+
id uuid primary key default uuid_generate_v4(),
|
| 57 |
+
advertiser_wallet text not null,
|
| 58 |
+
tier text not null check (tier in ('1m', '3m', '6m')),
|
| 59 |
+
fixed_price numeric(20, 6) not null check (fixed_price > 0),
|
| 60 |
+
start_date date not null,
|
| 61 |
+
end_date date not null,
|
| 62 |
+
active boolean not null default true,
|
| 63 |
+
distributed boolean not null default false,
|
| 64 |
+
created_at timestamptz not null default timezone('utc', now())
|
| 65 |
+
);
|
| 66 |
+
|
| 67 |
+
create table if not exists public.settlements (
|
| 68 |
+
id uuid primary key default uuid_generate_v4(),
|
| 69 |
+
creator_wallet text not null,
|
| 70 |
+
amount numeric(20, 6) not null,
|
| 71 |
+
platform_fee numeric(20, 6) not null default 0,
|
| 72 |
+
tx_hash text,
|
| 73 |
+
settlement_type text not null default 'video_ad',
|
| 74 |
+
campaign_id uuid,
|
| 75 |
+
timestamp timestamptz not null default timezone('utc', now())
|
| 76 |
+
);
|
| 77 |
+
|
| 78 |
+
create index if not exists idx_videos_creator_id on public.videos(creator_id);
|
| 79 |
+
create index if not exists idx_views_video_id on public.views(video_id);
|
| 80 |
+
create index if not exists idx_views_settled on public.views(settled);
|
| 81 |
+
create index if not exists idx_ad_campaigns_video_id on public.ad_campaigns(video_id);
|
| 82 |
+
create index if not exists idx_settlements_timestamp on public.settlements(timestamp desc);
|
| 83 |
+
|
| 84 |
+
alter table public.users enable row level security;
|
| 85 |
+
alter table public.videos enable row level security;
|
| 86 |
+
alter table public.subscriptions enable row level security;
|
| 87 |
+
alter table public.views enable row level security;
|
| 88 |
+
alter table public.ad_campaigns enable row level security;
|
| 89 |
+
alter table public.banner_campaigns enable row level security;
|
| 90 |
+
alter table public.settlements enable row level security;
|
| 91 |
+
|
| 92 |
+
drop policy if exists "Public read users" on public.users;
|
| 93 |
+
drop policy if exists "Public read videos" on public.videos;
|
| 94 |
+
drop policy if exists "Public read ad campaigns" on public.ad_campaigns;
|
| 95 |
+
drop policy if exists "Public read banner campaigns" on public.banner_campaigns;
|
| 96 |
+
drop policy if exists "Public read settlements" on public.settlements;
|
| 97 |
+
|
| 98 |
+
create policy "Public read users" on public.users for select using (true);
|
| 99 |
+
create policy "Public read videos" on public.videos for select using (true);
|
| 100 |
+
create policy "Public read ad campaigns" on public.ad_campaigns for select using (true);
|
| 101 |
+
create policy "Public read banner campaigns" on public.banner_campaigns for select using (true);
|
| 102 |
+
create policy "Public read settlements" on public.settlements for select using (true);
|
scripts/_route_test_tmp.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import random
|
| 3 |
+
import string
|
| 4 |
+
from datetime import date, timedelta
|
| 5 |
+
|
| 6 |
+
import requests
|
| 7 |
+
from algosdk import account, mnemonic, util
|
| 8 |
+
|
| 9 |
+
BASE_URL = "http://localhost:8000"
|
| 10 |
+
REQ_TIMEOUT = 20
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class SimpleResp:
|
| 14 |
+
def __init__(self, status_code, text=""):
|
| 15 |
+
self.status_code = status_code
|
| 16 |
+
self.text = text
|
| 17 |
+
|
| 18 |
+
def json(self):
|
| 19 |
+
raise ValueError("no json body")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def safe_request(method, url, **kwargs):
|
| 23 |
+
if "timeout" not in kwargs:
|
| 24 |
+
kwargs["timeout"] = REQ_TIMEOUT
|
| 25 |
+
try:
|
| 26 |
+
return requests.request(method, url, **kwargs)
|
| 27 |
+
except Exception as exc:
|
| 28 |
+
return SimpleResp(599, str(exc))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def parse_env(path):
|
| 32 |
+
data = {}
|
| 33 |
+
try:
|
| 34 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 35 |
+
for line in f:
|
| 36 |
+
line = line.strip()
|
| 37 |
+
if not line or line.startswith("#"):
|
| 38 |
+
continue
|
| 39 |
+
if "=" not in line:
|
| 40 |
+
continue
|
| 41 |
+
key, val = line.split("=", 1)
|
| 42 |
+
key = key.strip()
|
| 43 |
+
val = val.strip().strip("\"").strip("'")
|
| 44 |
+
data[key] = val
|
| 45 |
+
except FileNotFoundError:
|
| 46 |
+
pass
|
| 47 |
+
return data
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
env = parse_env("/home/chidori/Projects/rift/backend/.env")
|
| 51 |
+
platform_wallet = (env.get("PLATFORM_WALLET") or "").strip()
|
| 52 |
+
algorand_mnemonic = (env.get("ALGORAND_MNEMONIC") or "").strip()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def rand_name(prefix):
|
| 56 |
+
return prefix + "_" + "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def issue_challenge(wallet_address):
|
| 60 |
+
return safe_request("POST", f"{BASE_URL}/auth/challenge", json={"wallet_address": wallet_address})
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def sign_message(private_key, message):
|
| 64 |
+
sig = util.sign_bytes(message.encode("utf-8"), private_key)
|
| 65 |
+
if isinstance(sig, str):
|
| 66 |
+
sig = sig.encode("utf-8")
|
| 67 |
+
return base64.b64encode(sig).decode("utf-8")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def signup(wallet_address, private_key, username, role):
|
| 71 |
+
chall = issue_challenge(wallet_address)
|
| 72 |
+
if chall.status_code != 200:
|
| 73 |
+
return None, chall
|
| 74 |
+
try:
|
| 75 |
+
message = chall.json()["message"]
|
| 76 |
+
except Exception:
|
| 77 |
+
return None, SimpleResp(599, "challenge missing json")
|
| 78 |
+
signature = sign_message(private_key, message)
|
| 79 |
+
payload = {
|
| 80 |
+
"wallet_address": wallet_address,
|
| 81 |
+
"signature": signature,
|
| 82 |
+
"message": message,
|
| 83 |
+
"username": username,
|
| 84 |
+
"role": role,
|
| 85 |
+
}
|
| 86 |
+
res = safe_request("POST", f"{BASE_URL}/auth/signup", json=payload)
|
| 87 |
+
return res, None
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def login(wallet_address, private_key):
|
| 91 |
+
chall = issue_challenge(wallet_address)
|
| 92 |
+
if chall.status_code != 200:
|
| 93 |
+
return None, chall
|
| 94 |
+
try:
|
| 95 |
+
message = chall.json()["message"]
|
| 96 |
+
except Exception:
|
| 97 |
+
return None, SimpleResp(599, "challenge missing json")
|
| 98 |
+
signature = sign_message(private_key, message)
|
| 99 |
+
payload = {
|
| 100 |
+
"wallet_address": wallet_address,
|
| 101 |
+
"signature": signature,
|
| 102 |
+
"message": message,
|
| 103 |
+
}
|
| 104 |
+
res = safe_request("POST", f"{BASE_URL}/auth/login", json=payload)
|
| 105 |
+
return res, None
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def get_token_for_new_user(role):
|
| 109 |
+
private_key, address = account.generate_account()
|
| 110 |
+
username = rand_name(role)
|
| 111 |
+
res, chall_err = signup(address, private_key, username, role)
|
| 112 |
+
if chall_err is not None:
|
| 113 |
+
return None, address, private_key, chall_err
|
| 114 |
+
if res.status_code == 200:
|
| 115 |
+
try:
|
| 116 |
+
return res.json()["access_token"], address, private_key, None
|
| 117 |
+
except Exception:
|
| 118 |
+
return None, address, private_key, SimpleResp(599, "signup missing json")
|
| 119 |
+
if res.status_code == 400 and "User already exists" in res.text:
|
| 120 |
+
res2, chall_err2 = login(address, private_key)
|
| 121 |
+
if chall_err2 is not None:
|
| 122 |
+
return None, address, private_key, chall_err2
|
| 123 |
+
if res2.status_code == 200:
|
| 124 |
+
try:
|
| 125 |
+
return res2.json()["access_token"], address, private_key, None
|
| 126 |
+
except Exception:
|
| 127 |
+
return None, address, private_key, SimpleResp(599, "login missing json")
|
| 128 |
+
return None, address, private_key, res2
|
| 129 |
+
return None, address, private_key, res
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
results = []
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def record(name, res, extra=None):
|
| 136 |
+
if res is None:
|
| 137 |
+
results.append((name, "SKIP", extra or ""))
|
| 138 |
+
return
|
| 139 |
+
status = "PASS" if res.status_code < 400 else "FAIL"
|
| 140 |
+
detail = f"{res.status_code}"
|
| 141 |
+
if res.status_code >= 400:
|
| 142 |
+
detail += f" {res.text[:300]}"
|
| 143 |
+
if extra:
|
| 144 |
+
detail += f" | {extra}"
|
| 145 |
+
results.append((name, status, detail))
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
record("GET /", safe_request("GET", f"{BASE_URL}/"))
|
| 149 |
+
|
| 150 |
+
# Creator
|
| 151 |
+
creator_token, creator_addr, creator_pk, err = get_token_for_new_user("creator")
|
| 152 |
+
if not creator_token:
|
| 153 |
+
if err is None:
|
| 154 |
+
results.append(("creator token", "FAIL", "unknown error"))
|
| 155 |
+
else:
|
| 156 |
+
results.append(("creator token", "FAIL", f"{err.status_code} {err.text[:300]}"))
|
| 157 |
+
else:
|
| 158 |
+
headers_creator = {"Authorization": f"Bearer {creator_token}"}
|
| 159 |
+
record("GET /auth/me", safe_request("GET", f"{BASE_URL}/auth/me", headers=headers_creator))
|
| 160 |
+
|
| 161 |
+
files = {"file": ("test_video.mp4", b"dummy_video_content_bytes", "video/mp4")}
|
| 162 |
+
data = {"title": f"Test Video {rand_name('v')}", "description": "Automated test"}
|
| 163 |
+
res_upload = safe_request("POST", f"{BASE_URL}/videos/upload", headers=headers_creator, files=files, data=data)
|
| 164 |
+
record("POST /videos/upload", res_upload)
|
| 165 |
+
video_id = None
|
| 166 |
+
if res_upload.status_code == 200:
|
| 167 |
+
try:
|
| 168 |
+
video_id = res_upload.json().get("video_id")
|
| 169 |
+
except Exception:
|
| 170 |
+
video_id = None
|
| 171 |
+
|
| 172 |
+
record("GET /videos/list", safe_request("GET", f"{BASE_URL}/videos/list"))
|
| 173 |
+
record("GET /videos/me", safe_request("GET", f"{BASE_URL}/videos/me", headers=headers_creator))
|
| 174 |
+
if video_id:
|
| 175 |
+
record("GET /videos/{id}", safe_request("GET", f"{BASE_URL}/videos/{video_id}"))
|
| 176 |
+
|
| 177 |
+
if video_id:
|
| 178 |
+
res_view = safe_request(
|
| 179 |
+
"POST",
|
| 180 |
+
f"{BASE_URL}/views/track",
|
| 181 |
+
headers=headers_creator,
|
| 182 |
+
json={"video_id": video_id, "watch_seconds": 30},
|
| 183 |
+
)
|
| 184 |
+
record("POST /views/track", res_view)
|
| 185 |
+
|
| 186 |
+
record("GET /wallets/balance", safe_request("GET", f"{BASE_URL}/wallets/balance", headers=headers_creator))
|
| 187 |
+
|
| 188 |
+
# Advertiser
|
| 189 |
+
advertiser_token, advertiser_addr, advertiser_pk, err = get_token_for_new_user("advertiser")
|
| 190 |
+
if not advertiser_token:
|
| 191 |
+
if err is None:
|
| 192 |
+
results.append(("advertiser token", "FAIL", "unknown error"))
|
| 193 |
+
else:
|
| 194 |
+
results.append(("advertiser token", "FAIL", f"{err.status_code} {err.text[:300]}"))
|
| 195 |
+
else:
|
| 196 |
+
headers_ad = {"Authorization": f"Bearer {advertiser_token}"}
|
| 197 |
+
record("GET /auth/me (advertiser)", safe_request("GET", f"{BASE_URL}/auth/me", headers=headers_ad))
|
| 198 |
+
if 'video_id' in locals() and video_id:
|
| 199 |
+
res_campaign = safe_request(
|
| 200 |
+
"POST",
|
| 201 |
+
f"{BASE_URL}/ads/create",
|
| 202 |
+
headers=headers_ad,
|
| 203 |
+
data={"video_id": video_id, "budget": "100", "reward_per_view": "1"},
|
| 204 |
+
)
|
| 205 |
+
record("POST /ads/create", res_campaign)
|
| 206 |
+
|
| 207 |
+
record("GET /ads/active", safe_request("GET", f"{BASE_URL}/ads/active"))
|
| 208 |
+
record("GET /ads/me", safe_request("GET", f"{BASE_URL}/ads/me", headers=headers_ad))
|
| 209 |
+
record("GET /ads/summary", safe_request("GET", f"{BASE_URL}/ads/summary", headers=headers_ad))
|
| 210 |
+
|
| 211 |
+
start = date.today().isoformat()
|
| 212 |
+
end = (date.today() + timedelta(days=1)).isoformat()
|
| 213 |
+
res_banner = safe_request(
|
| 214 |
+
"POST",
|
| 215 |
+
f"{BASE_URL}/ads/banner/create",
|
| 216 |
+
headers=headers_ad,
|
| 217 |
+
data={"tier": "1m", "fixed_price": "25", "start_date": start, "end_date": end},
|
| 218 |
+
)
|
| 219 |
+
record("POST /ads/banner/create", res_banner)
|
| 220 |
+
record("GET /ads/banner/active", safe_request("GET", f"{BASE_URL}/ads/banner/active"))
|
| 221 |
+
record("GET /ads/banner/me", safe_request("GET", f"{BASE_URL}/ads/banner/me", headers=headers_ad))
|
| 222 |
+
|
| 223 |
+
# Settlement (public)
|
| 224 |
+
record("GET /settlement/", safe_request("GET", f"{BASE_URL}/settlement/"))
|
| 225 |
+
record("GET /settlement/summary", safe_request("GET", f"{BASE_URL}/settlement/summary"))
|
| 226 |
+
|
| 227 |
+
# Platform-only
|
| 228 |
+
platform_token = None
|
| 229 |
+
platform_pk = None
|
| 230 |
+
platform_addr = None
|
| 231 |
+
if platform_wallet and algorand_mnemonic:
|
| 232 |
+
try:
|
| 233 |
+
platform_pk = mnemonic.to_private_key(algorand_mnemonic)
|
| 234 |
+
platform_addr = account.address_from_private_key(platform_pk)
|
| 235 |
+
if platform_addr == platform_wallet:
|
| 236 |
+
res, err = signup(platform_addr, platform_pk, rand_name("platform"), "viewer")
|
| 237 |
+
if err is None and res.status_code == 200:
|
| 238 |
+
platform_token = res.json()["access_token"]
|
| 239 |
+
else:
|
| 240 |
+
res2, err2 = login(platform_addr, platform_pk)
|
| 241 |
+
if err2 is None and res2.status_code == 200:
|
| 242 |
+
platform_token = res2.json()["access_token"]
|
| 243 |
+
else:
|
| 244 |
+
results.append(("platform auth", "SKIP", "PLATFORM_WALLET does not match ALGORAND_MNEMONIC address"))
|
| 245 |
+
except Exception as exc:
|
| 246 |
+
results.append(("platform auth", "SKIP", f"error: {exc}"))
|
| 247 |
+
else:
|
| 248 |
+
results.append(("platform auth", "SKIP", "PLATFORM_WALLET or ALGORAND_MNEMONIC not set"))
|
| 249 |
+
|
| 250 |
+
if platform_token:
|
| 251 |
+
headers_platform = {"Authorization": f"Bearer {platform_token}"}
|
| 252 |
+
record("GET /wallets/platform-balance", safe_request("GET", f"{BASE_URL}/wallets/platform-balance", headers=headers_platform))
|
| 253 |
+
record("POST /settlement/trigger", safe_request("POST", f"{BASE_URL}/settlement/trigger", headers=headers_platform))
|
| 254 |
+
record("POST /settlement/trigger-banner", safe_request("POST", f"{BASE_URL}/settlement/trigger-banner", headers=headers_platform))
|
| 255 |
+
else:
|
| 256 |
+
record("GET /wallets/platform-balance", None, "no platform token")
|
| 257 |
+
record("POST /settlement/trigger", None, "no platform token")
|
| 258 |
+
record("POST /settlement/trigger-banner", None, "no platform token")
|
| 259 |
+
|
| 260 |
+
print("\n=== Route Test Summary ===")
|
| 261 |
+
for name, status, detail in results:
|
| 262 |
+
print(f"{status:<5} {name} -> {detail}")
|
scripts/check_schema.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
# Add backend directory to sys.path
|
| 7 |
+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
| 8 |
+
|
| 9 |
+
# Load env
|
| 10 |
+
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
| 11 |
+
|
| 12 |
+
from app.database import get_db
|
| 13 |
+
|
| 14 |
+
def main():
|
| 15 |
+
print("Checking Users Table Schema...")
|
| 16 |
+
db = get_db()
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
# Try to select one item
|
| 20 |
+
res = db.table("users").select("*").limit(1).execute()
|
| 21 |
+
if res.data:
|
| 22 |
+
print("Row 1 keys:", res.data[0].keys())
|
| 23 |
+
else:
|
| 24 |
+
print("Table is empty, cannot infer schema directly from rows.")
|
| 25 |
+
# Try inserting a dummy with username to see if it fails?
|
| 26 |
+
# Or just assume we need to add it.
|
| 27 |
+
except Exception as e:
|
| 28 |
+
print(f"Error selecting: {e}")
|
| 29 |
+
|
| 30 |
+
if __name__ == "__main__":
|
| 31 |
+
main()
|
scripts/create_asset.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
from algosdk import account, mnemonic
|
| 6 |
+
from algosdk.v2client import algod
|
| 7 |
+
from algosdk.transaction import AssetConfigTxn, wait_for_confirmation
|
| 8 |
+
|
| 9 |
+
# Add backend directory to sys.path
|
| 10 |
+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
| 11 |
+
|
| 12 |
+
# Load env BEFORE importing settings
|
| 13 |
+
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
| 14 |
+
|
| 15 |
+
from app.config import settings
|
| 16 |
+
|
| 17 |
+
def main():
|
| 18 |
+
print("--- Creating ADMC Asset ---")
|
| 19 |
+
|
| 20 |
+
# Client
|
| 21 |
+
algod_address = "https://testnet-api.algonode.cloud"
|
| 22 |
+
algod_token = ""
|
| 23 |
+
client = algod.AlgodClient(algod_token, algod_address)
|
| 24 |
+
|
| 25 |
+
mnemonic_phrase = settings.algorand_mnemonic
|
| 26 |
+
if not mnemonic_phrase:
|
| 27 |
+
print("ERROR: ALGORAND_MNEMONIC is empty in .env")
|
| 28 |
+
return
|
| 29 |
+
|
| 30 |
+
private_key = mnemonic.to_private_key(mnemonic_phrase)
|
| 31 |
+
address = account.address_from_private_key(private_key)
|
| 32 |
+
print(f"Creator Address: {address}")
|
| 33 |
+
|
| 34 |
+
# Asset Details
|
| 35 |
+
# ADMC: "Ad Me Coin" or similar? The prompt mentioned "ADMC".
|
| 36 |
+
# Let's call it "Rift Ad Token" (ADMC)
|
| 37 |
+
# Total Supply: 1,000,000,000
|
| 38 |
+
# Decimals: 0 for simplicity in this MVP, or 6?
|
| 39 |
+
# Script assumes int amounts, so 0 decimals is easier for verification logic unless specified.
|
| 40 |
+
# Let's use 0 decimals.
|
| 41 |
+
|
| 42 |
+
params = client.suggested_params()
|
| 43 |
+
|
| 44 |
+
txn = AssetConfigTxn(
|
| 45 |
+
sender=address,
|
| 46 |
+
sp=params,
|
| 47 |
+
total=1_000_000_000,
|
| 48 |
+
default_frozen=False,
|
| 49 |
+
unit_name="ADMC",
|
| 50 |
+
asset_name="Rift Ad Token",
|
| 51 |
+
manager=address,
|
| 52 |
+
reserve=address,
|
| 53 |
+
freeze=address,
|
| 54 |
+
clawback=address,
|
| 55 |
+
url="https://rift-platform.com",
|
| 56 |
+
decimals=0
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
signed_txn = txn.sign(private_key)
|
| 60 |
+
try:
|
| 61 |
+
txid = client.send_transaction(signed_txn)
|
| 62 |
+
print(f"Transaction Sent: {txid}")
|
| 63 |
+
|
| 64 |
+
wait_for_confirmation(client, txid, 4)
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
ptx = client.pending_transaction_info(txid)
|
| 68 |
+
asset_id = ptx["asset-index"]
|
| 69 |
+
print(f"Asset Created! Asset ID: {asset_id}")
|
| 70 |
+
print(f"PLEASE UPDATE .env WITH: ASSET_ID={asset_id}")
|
| 71 |
+
except Exception as e:
|
| 72 |
+
print(f"Error getting asset ID: {e}")
|
| 73 |
+
|
| 74 |
+
except Exception as e:
|
| 75 |
+
print(f"Failed to create asset: {e}")
|
| 76 |
+
|
| 77 |
+
if __name__ == "__main__":
|
| 78 |
+
main()
|
scripts/create_token.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from algosdk import account, mnemonic
|
| 3 |
+
from algosdk.v2client import algod
|
| 4 |
+
from algosdk.transaction import AssetConfigTxn, wait_for_confirmation
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
load_dotenv()
|
| 8 |
+
|
| 9 |
+
# Configure Algorand Client (Testnet)
|
| 10 |
+
# You can use a free API key from AlgoNode or PureStake, or a local node
|
| 11 |
+
ALGOD_ADDRESS = "https://testnet-api.algonode.cloud"
|
| 12 |
+
ALGOD_TOKEN = ""
|
| 13 |
+
|
| 14 |
+
def create_admc_token():
|
| 15 |
+
# Helper function to get the private key and address
|
| 16 |
+
mnemonic_phrase = os.getenv("ALGORAND_MNEMONIC")
|
| 17 |
+
if not mnemonic_phrase:
|
| 18 |
+
print("Please set ALGORAND_MNEMONIC in .env")
|
| 19 |
+
return
|
| 20 |
+
|
| 21 |
+
private_key = mnemonic.to_private_key(mnemonic_phrase)
|
| 22 |
+
sender_address = account.address_from_private_key(private_key)
|
| 23 |
+
print(f"Sender Address: {sender_address}")
|
| 24 |
+
|
| 25 |
+
# Initialize Algod Client
|
| 26 |
+
algod_client = algod.AlgodClient(ALGOD_TOKEN, ALGOD_ADDRESS)
|
| 27 |
+
|
| 28 |
+
# Asset Creation Transaction
|
| 29 |
+
params = algod_client.suggested_params()
|
| 30 |
+
|
| 31 |
+
txn = AssetConfigTxn(
|
| 32 |
+
sender=sender_address,
|
| 33 |
+
sp=params,
|
| 34 |
+
total=1_000_000_000, # 1 billion tokens
|
| 35 |
+
decimals=6,
|
| 36 |
+
default_frozen=False,
|
| 37 |
+
unit_name="ADMC",
|
| 38 |
+
asset_name="AdMarket Coin",
|
| 39 |
+
manager=sender_address,
|
| 40 |
+
reserve=sender_address,
|
| 41 |
+
freeze=sender_address,
|
| 42 |
+
clawback=sender_address,
|
| 43 |
+
url="https://rift.video", # Placeholder URL
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# Sign transaction
|
| 47 |
+
signed_txn = txn.sign(private_key)
|
| 48 |
+
|
| 49 |
+
# Send transaction
|
| 50 |
+
txid = algod_client.send_transaction(signed_txn)
|
| 51 |
+
print(f"Transaction sent with ID: {txid}")
|
| 52 |
+
|
| 53 |
+
# Wait for confirmation
|
| 54 |
+
confirmed_txn = wait_for_confirmation(algod_client, txid, 4)
|
| 55 |
+
print(f"Result confirmed in round: {confirmed_txn['confirmed-round']}")
|
| 56 |
+
|
| 57 |
+
asset_id = confirmed_txn["asset-index"]
|
| 58 |
+
print(f"Created Asset ID: {asset_id}")
|
| 59 |
+
|
| 60 |
+
return asset_id
|
| 61 |
+
|
| 62 |
+
if __name__ == "__main__":
|
| 63 |
+
create_admc_token()
|
scripts/show_wallet.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
from algosdk import account, mnemonic
|
| 6 |
+
from algosdk.v2client import algod
|
| 7 |
+
|
| 8 |
+
# Add backend directory to sys.path
|
| 9 |
+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
| 10 |
+
|
| 11 |
+
# Load env
|
| 12 |
+
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
| 13 |
+
|
| 14 |
+
from app.config import settings
|
| 15 |
+
|
| 16 |
+
def main():
|
| 17 |
+
print("--- Platform Wallet Info ---")
|
| 18 |
+
|
| 19 |
+
# Client
|
| 20 |
+
algod_address = "https://testnet-api.algonode.cloud"
|
| 21 |
+
algod_token = ""
|
| 22 |
+
client = algod.AlgodClient(algod_token, algod_address)
|
| 23 |
+
|
| 24 |
+
mnemonic_phrase = settings.algorand_mnemonic
|
| 25 |
+
if not mnemonic_phrase:
|
| 26 |
+
print("ERROR: ALGORAND_MNEMONIC is empty in .env")
|
| 27 |
+
return
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
private_key = mnemonic.to_private_key(mnemonic_phrase)
|
| 31 |
+
address = account.address_from_private_key(private_key)
|
| 32 |
+
print(f"Address: {address}")
|
| 33 |
+
|
| 34 |
+
info = client.account_info(address)
|
| 35 |
+
algo_balance = info.get('amount') / 1_000_000
|
| 36 |
+
print(f"ALGO Balance: {algo_balance} ALGO")
|
| 37 |
+
|
| 38 |
+
asset_id = settings.asset_id
|
| 39 |
+
print(f"Target Asset ID: {asset_id}")
|
| 40 |
+
|
| 41 |
+
assets = info.get('assets', [])
|
| 42 |
+
admc_balance = 0
|
| 43 |
+
found = False
|
| 44 |
+
for asset in assets:
|
| 45 |
+
if asset['asset-id'] == asset_id:
|
| 46 |
+
admc_balance = asset['amount']
|
| 47 |
+
found = True
|
| 48 |
+
break
|
| 49 |
+
|
| 50 |
+
if found:
|
| 51 |
+
print(f"ADMC Balance: {admc_balance}")
|
| 52 |
+
else:
|
| 53 |
+
print(f"ADMC Balance: 0 (Not opted in or 0 balance)")
|
| 54 |
+
|
| 55 |
+
except Exception as e:
|
| 56 |
+
print(f"Error retrieving info: {e}")
|
| 57 |
+
|
| 58 |
+
if __name__ == "__main__":
|
| 59 |
+
main()
|
scripts/test_all_routes.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import requests
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
import random
|
| 6 |
+
import string
|
| 7 |
+
import time
|
| 8 |
+
from algosdk import account, util
|
| 9 |
+
|
| 10 |
+
# Configuration
|
| 11 |
+
BASE_URL = "http://localhost:8000"
|
| 12 |
+
|
| 13 |
+
def generate_username():
|
| 14 |
+
return "tester_" + ''.join(random.choices(string.ascii_lowercase + string.digits, k=5))
|
| 15 |
+
|
| 16 |
+
def sign_message(private_key, message):
|
| 17 |
+
return util.sign_bytes(message.encode('utf-8'), private_key)
|
| 18 |
+
|
| 19 |
+
def print_step(step_name):
|
| 20 |
+
print(f"\n{'='*20}\n{step_name}\n{'='*20}")
|
| 21 |
+
|
| 22 |
+
def test_api():
|
| 23 |
+
print("Starting Comprehensive API Test...")
|
| 24 |
+
|
| 25 |
+
# --- 1. Authenticaton ---
|
| 26 |
+
print_step("1. Authentication")
|
| 27 |
+
|
| 28 |
+
# Generate User
|
| 29 |
+
private_key, address = account.generate_account()
|
| 30 |
+
username = generate_username()
|
| 31 |
+
print(f"User: {username} ({address})")
|
| 32 |
+
|
| 33 |
+
# Signup
|
| 34 |
+
message = "Sign up for Rift"
|
| 35 |
+
signature = sign_message(private_key, message)
|
| 36 |
+
|
| 37 |
+
signup_payload = {
|
| 38 |
+
"wallet_address": address,
|
| 39 |
+
"signature": signature,
|
| 40 |
+
"message": message,
|
| 41 |
+
"username": username
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
print("-> Signing Up...")
|
| 45 |
+
res = requests.post(f"{BASE_URL}/auth/signup", json=signup_payload)
|
| 46 |
+
if res.status_code != 200:
|
| 47 |
+
print(f"FAILED: Signup {res.status_code} {res.text}")
|
| 48 |
+
return
|
| 49 |
+
token = res.json()["access_token"]
|
| 50 |
+
print("SUCCESS: Signed Up. Token received.")
|
| 51 |
+
|
| 52 |
+
headers = {"Authorization": f"Bearer {token}"}
|
| 53 |
+
|
| 54 |
+
# --- 2. Video Upload ---
|
| 55 |
+
print_step("2. Video Upload")
|
| 56 |
+
|
| 57 |
+
# Create dummy video content
|
| 58 |
+
files = {
|
| 59 |
+
'file': ('test_video.mp4', b'dummy_video_content_bytes', 'video/mp4')
|
| 60 |
+
}
|
| 61 |
+
data = {
|
| 62 |
+
'title': 'Test Video ' + username,
|
| 63 |
+
'description': 'This is an automated test video.'
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
print("-> Uploading Video...")
|
| 67 |
+
res = requests.post(f"{BASE_URL}/videos/upload", headers=headers, files=files, data=data)
|
| 68 |
+
|
| 69 |
+
if res.status_code != 200:
|
| 70 |
+
print(f"FAILED: Upload {res.status_code} {res.text}")
|
| 71 |
+
return
|
| 72 |
+
|
| 73 |
+
video_id = res.json()["video_id"]
|
| 74 |
+
cid = res.json()["cid"]
|
| 75 |
+
print(f"SUCCESS: Video Uploaded. ID: {video_id}, CID: {cid}")
|
| 76 |
+
|
| 77 |
+
# --- 3. List Videos ---
|
| 78 |
+
print_step("3. List Videos")
|
| 79 |
+
res = requests.get(f"{BASE_URL}/videos/list")
|
| 80 |
+
if res.status_code != 200:
|
| 81 |
+
print(f"FAILED: List Videos {res.status_code} {res.text}")
|
| 82 |
+
return
|
| 83 |
+
videos = res.json()
|
| 84 |
+
print(f"SUCCESS: Retrieved {len(videos)} videos.")
|
| 85 |
+
|
| 86 |
+
# --- 4. Track View ---
|
| 87 |
+
print_step("4. Track View")
|
| 88 |
+
|
| 89 |
+
# View the video we just uploaded
|
| 90 |
+
view_payload = {
|
| 91 |
+
"video_id": video_id,
|
| 92 |
+
"watch_seconds": 30
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
print(f"-> Tracking view for video {video_id}...")
|
| 96 |
+
res = requests.post(f"{BASE_URL}/views/track", headers=headers, json=view_payload)
|
| 97 |
+
|
| 98 |
+
if res.status_code != 200:
|
| 99 |
+
print(f"FAILED: Track View {res.status_code} {res.text}")
|
| 100 |
+
else:
|
| 101 |
+
print(f"SUCCESS: View recorded. Status: {res.json()}")
|
| 102 |
+
|
| 103 |
+
# --- 5. Create Ad Campaign ---
|
| 104 |
+
print_step("5. Create Ad Campaign")
|
| 105 |
+
|
| 106 |
+
campaign_payload = {
|
| 107 |
+
"video_id": video_id,
|
| 108 |
+
"budget": 100.0,
|
| 109 |
+
"reward_per_view": 1.0 # 1 ADMC per view
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
print("-> Creating Campaign...")
|
| 113 |
+
res = requests.post(f"{BASE_URL}/ads/create", headers=headers, json=campaign_payload)
|
| 114 |
+
|
| 115 |
+
if res.status_code != 200:
|
| 116 |
+
print(f"FAILED: Create Campaign {res.status_code} {res.text}")
|
| 117 |
+
else:
|
| 118 |
+
campaign_id = res.json()["campaign_id"]
|
| 119 |
+
print(f"SUCCESS: Campaign Created. ID: {campaign_id}")
|
| 120 |
+
|
| 121 |
+
# --- 6. Trigger Settlement ---
|
| 122 |
+
print_step("6. Trigger Settlement")
|
| 123 |
+
|
| 124 |
+
print("-> Triggering Settlement Engine...")
|
| 125 |
+
res = requests.post(f"{BASE_URL}/settlement/trigger")
|
| 126 |
+
|
| 127 |
+
if res.status_code != 200:
|
| 128 |
+
print(f"FAILED: Trigger Settlement {res.status_code} {res.text}")
|
| 129 |
+
else:
|
| 130 |
+
print(f"SUCCESS: Settlement Triggered. Response: {res.json()}")
|
| 131 |
+
|
| 132 |
+
# --- 7. Check Settlement History ---
|
| 133 |
+
print_step("7. Settlement History")
|
| 134 |
+
res = requests.get(f"{BASE_URL}/settlement/")
|
| 135 |
+
if res.status_code != 200:
|
| 136 |
+
print(f"FAILED: Get Settlements {res.status_code} {res.text}")
|
| 137 |
+
else:
|
| 138 |
+
print(f"SUCCESS: Retrieved latest settlements. Count: {len(res.json())}")
|
| 139 |
+
|
| 140 |
+
print("\nAPI Test Complete.")
|
| 141 |
+
|
| 142 |
+
if __name__ == "__main__":
|
| 143 |
+
test_api()
|
scripts/test_auth.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import requests
|
| 3 |
+
import time
|
| 4 |
+
from algosdk import account, mnemonic, util
|
| 5 |
+
import sys
|
| 6 |
+
import os
|
| 7 |
+
import random
|
| 8 |
+
import string
|
| 9 |
+
|
| 10 |
+
# URL
|
| 11 |
+
BASE_URL = "http://localhost:8000"
|
| 12 |
+
|
| 13 |
+
def generate_username():
|
| 14 |
+
return "user_" + ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
| 15 |
+
|
| 16 |
+
def sign_message(private_key, message):
|
| 17 |
+
# Determine how backend expects signature.
|
| 18 |
+
# algorand_service.verify_signature uses util.verify_bytes
|
| 19 |
+
# which expects the signature of the byte encoded message.
|
| 20 |
+
# We need to sign the bytes of the message.
|
| 21 |
+
|
| 22 |
+
# In Pera/standard dApps, usually we sign "MX..." if it's a specific format
|
| 23 |
+
# But here backend does: util.verify_bytes(message.encode('utf-8'), signature, wallet_address)
|
| 24 |
+
# So we just sign the raw bytes.
|
| 25 |
+
|
| 26 |
+
signature = util.sign_bytes(message.encode('utf-8'), private_key)
|
| 27 |
+
return signature
|
| 28 |
+
|
| 29 |
+
def test_signup_login():
|
| 30 |
+
print("--- Testing Auth Flow ---")
|
| 31 |
+
|
| 32 |
+
# 1. Generate Wallet
|
| 33 |
+
private_key, address = account.generate_account()
|
| 34 |
+
print(f"Generated Wallet: {address}")
|
| 35 |
+
|
| 36 |
+
username = generate_username()
|
| 37 |
+
print(f"Username: {username}")
|
| 38 |
+
|
| 39 |
+
message = "Login to Rift" # In real app, this should be a nonce
|
| 40 |
+
signature = sign_message(private_key, message)
|
| 41 |
+
|
| 42 |
+
# 2. Test Login (Unregistered) -> Should Fail
|
| 43 |
+
print("\n[Test 1] Login Unregistered User...")
|
| 44 |
+
login_payload = {
|
| 45 |
+
"wallet_address": address,
|
| 46 |
+
"signature": signature,
|
| 47 |
+
"message": message
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
res = requests.post(f"{BASE_URL}/auth/login", json=login_payload)
|
| 51 |
+
if res.status_code == 404:
|
| 52 |
+
print("SUCCESS: Login failed as expected (404 User not found).")
|
| 53 |
+
else:
|
| 54 |
+
print(f"FAILURE: Login unexpected response: {res.status_code} {res.text}")
|
| 55 |
+
return
|
| 56 |
+
|
| 57 |
+
# 3. Test Signup -> Should Success
|
| 58 |
+
print("\n[Test 2] Signup New User...")
|
| 59 |
+
signup_payload = {
|
| 60 |
+
"wallet_address": address,
|
| 61 |
+
"signature": signature,
|
| 62 |
+
"message": message,
|
| 63 |
+
"username": username
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
res = requests.post(f"{BASE_URL}/auth/signup", json=signup_payload)
|
| 67 |
+
if res.status_code == 200:
|
| 68 |
+
print("SUCCESS: Signup successful.")
|
| 69 |
+
token = res.json().get("access_token")
|
| 70 |
+
print(f"Token received: {token[:10]}...")
|
| 71 |
+
else:
|
| 72 |
+
print(f"FAILURE: Signup failed: {res.status_code} {res.text}")
|
| 73 |
+
return
|
| 74 |
+
|
| 75 |
+
# 4. Test Login (Registered) -> Should Success
|
| 76 |
+
print("\n[Test 3] Login Registered User...")
|
| 77 |
+
res = requests.post(f"{BASE_URL}/auth/login", json=login_payload)
|
| 78 |
+
if res.status_code == 200:
|
| 79 |
+
print("SUCCESS: Login successful.")
|
| 80 |
+
else:
|
| 81 |
+
print(f"FAILURE: Login failed: {res.status_code} {res.text}")
|
| 82 |
+
|
| 83 |
+
# 5. Test Signup (Duplicate) -> Should Fail
|
| 84 |
+
print("\n[Test 4] Signup Duplicate User...")
|
| 85 |
+
res = requests.post(f"{BASE_URL}/auth/signup", json=signup_payload)
|
| 86 |
+
if res.status_code == 400:
|
| 87 |
+
print("SUCCESS: Duplicate signup failed as expected (400).")
|
| 88 |
+
else:
|
| 89 |
+
print(f"FAILURE: Duplicate signup unexpected response: {res.status_code} {res.text}")
|
| 90 |
+
|
| 91 |
+
if __name__ == "__main__":
|
| 92 |
+
test_signup_login()
|
scripts/test_insert.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
import random
|
| 5 |
+
import string
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
|
| 8 |
+
# Add backend directory to sys.path
|
| 9 |
+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
| 10 |
+
|
| 11 |
+
# Load env
|
| 12 |
+
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
| 13 |
+
|
| 14 |
+
from app.database import get_db
|
| 15 |
+
|
| 16 |
+
def generate_wallet():
|
| 17 |
+
return "test_wallet_" + ''.join(random.choices(string.ascii_lowercase + string.digits, k=10))
|
| 18 |
+
|
| 19 |
+
def main():
|
| 20 |
+
print("Testing Insert...")
|
| 21 |
+
db = get_db()
|
| 22 |
+
|
| 23 |
+
# Test 1: Minimal Insert (just wallet_address, assuming it exists)
|
| 24 |
+
w1 = generate_wallet()
|
| 25 |
+
print(f"1. Inserting {w1} (no username)...")
|
| 26 |
+
try:
|
| 27 |
+
res = db.table("users").insert({"wallet_address": w1}).execute()
|
| 28 |
+
print("Success!", res.data)
|
| 29 |
+
except Exception as e:
|
| 30 |
+
with open("error.log", "w") as f:
|
| 31 |
+
f.write(str(e))
|
| 32 |
+
print(f"Failed: {e}")
|
| 33 |
+
|
| 34 |
+
# Test 2: Insert with username
|
| 35 |
+
w2 = generate_wallet()
|
| 36 |
+
print(f"2. Inserting {w2} WITH username...")
|
| 37 |
+
try:
|
| 38 |
+
res = db.table("users").insert({"wallet_address": w2, "username": "testuser"}).execute()
|
| 39 |
+
print("Success!", res.data)
|
| 40 |
+
except Exception as e:
|
| 41 |
+
with open("error_username.log", "w") as f:
|
| 42 |
+
f.write(str(e))
|
| 43 |
+
print(f"Failed: {e}")
|
| 44 |
+
|
| 45 |
+
if __name__ == "__main__":
|
| 46 |
+
main()
|
scripts/verify_algorand.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
import time
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
from algosdk import account, mnemonic, util
|
| 7 |
+
from algosdk.v2client import algod
|
| 8 |
+
from algosdk.transaction import AssetTransferTxn, PaymentTxn, AssetOptInTxn, wait_for_confirmation
|
| 9 |
+
|
| 10 |
+
# Add backend directory to sys.path to import services
|
| 11 |
+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
| 12 |
+
|
| 13 |
+
# Load env
|
| 14 |
+
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
| 15 |
+
|
| 16 |
+
from app.services import algorand_service
|
| 17 |
+
from app.config import settings
|
| 18 |
+
|
| 19 |
+
def main():
|
| 20 |
+
print("Starting Algorand Verification...")
|
| 21 |
+
|
| 22 |
+
# 1. Initialize Client
|
| 23 |
+
client = algorand_service.get_algod_client()
|
| 24 |
+
try:
|
| 25 |
+
status = client.status()
|
| 26 |
+
print(f"Connected to Algorand TestNet. Last Round: {status['last-round']}")
|
| 27 |
+
except Exception as e:
|
| 28 |
+
print(f"Failed to connect to Algorand: {e}")
|
| 29 |
+
return
|
| 30 |
+
|
| 31 |
+
# 2. Check Platform Wallet
|
| 32 |
+
platform_mnemonic = settings.algorand_mnemonic
|
| 33 |
+
if not platform_mnemonic:
|
| 34 |
+
print("Error: ALGORAND_MNEMONIC not set in .env")
|
| 35 |
+
return
|
| 36 |
+
|
| 37 |
+
platform_private_key = mnemonic.to_private_key(platform_mnemonic)
|
| 38 |
+
platform_address = account.address_from_private_key(platform_private_key)
|
| 39 |
+
print(f"Platform Wallet: {platform_address}")
|
| 40 |
+
|
| 41 |
+
account_info = client.account_info(platform_address)
|
| 42 |
+
algo_balance = account_info.get('amount') / 1_000_000
|
| 43 |
+
print(f"Platform ALGO Balance: {algo_balance} ALGO")
|
| 44 |
+
|
| 45 |
+
if algo_balance < 1:
|
| 46 |
+
print("Warning: Low ALGO balance. Might fail to fund receivers.")
|
| 47 |
+
|
| 48 |
+
# Check Asset Balance
|
| 49 |
+
asset_id = settings.asset_id
|
| 50 |
+
assets = account_info.get('assets', [])
|
| 51 |
+
platform_asset_balance = 0
|
| 52 |
+
for asset in assets:
|
| 53 |
+
if asset['asset-id'] == asset_id:
|
| 54 |
+
platform_asset_balance = asset['amount']
|
| 55 |
+
break
|
| 56 |
+
|
| 57 |
+
print(f"Platform ADMC Balance: {platform_asset_balance} (Asset ID: {asset_id})")
|
| 58 |
+
|
| 59 |
+
if platform_asset_balance < 100:
|
| 60 |
+
print("Error: Insufficient ADMC in Platform Wallet to test.")
|
| 61 |
+
return
|
| 62 |
+
|
| 63 |
+
# 3. Setup Receiver
|
| 64 |
+
receiver_private_key, receiver_address = account.generate_account()
|
| 65 |
+
print(f"\nGenerated Test Receiver: {receiver_address}")
|
| 66 |
+
|
| 67 |
+
# Fund Receiver with ALGO (for opt-in fees)
|
| 68 |
+
print("Funding Receiver with 0.3 ALGO...")
|
| 69 |
+
params = client.suggested_params()
|
| 70 |
+
pay_txn = PaymentTxn(platform_address, params, receiver_address, 300_000) # 0.3 ALGO
|
| 71 |
+
signed_pay_txn = pay_txn.sign(platform_private_key)
|
| 72 |
+
try:
|
| 73 |
+
txid = client.send_transaction(signed_pay_txn)
|
| 74 |
+
wait_for_confirmation(client, txid, 4)
|
| 75 |
+
print(f"Funded Receiver. TxID: {txid}")
|
| 76 |
+
except Exception as e:
|
| 77 |
+
print(f"Failed to fund receiver: {e}")
|
| 78 |
+
return
|
| 79 |
+
|
| 80 |
+
# 4. Opt-In Receiver to ADMC
|
| 81 |
+
print("Receiver opting in to ADMC...")
|
| 82 |
+
params = client.suggested_params()
|
| 83 |
+
optin_txn = AssetOptInTxn(receiver_address, params, asset_id)
|
| 84 |
+
signed_optin_txn = optin_txn.sign(receiver_private_key)
|
| 85 |
+
try:
|
| 86 |
+
txid = client.send_transaction(signed_optin_txn)
|
| 87 |
+
wait_for_confirmation(client, txid, 4)
|
| 88 |
+
print(f"Receiver Opted-in. TxID: {txid}")
|
| 89 |
+
except Exception as e:
|
| 90 |
+
print(f"Failed to opt-in: {e}")
|
| 91 |
+
return
|
| 92 |
+
|
| 93 |
+
# 5. Test Settlement (Transfer from Platform to Receiver)
|
| 94 |
+
print("\nTesting settle_rewards (10 ADMC)...")
|
| 95 |
+
try:
|
| 96 |
+
# Note: settle_rewards takes amount, calculates 2% fee, sends rest.
|
| 97 |
+
# If we send 10, Fee is 0.2, Receiver gets 9.8.
|
| 98 |
+
# But wait, algorand_service.py logic:
|
| 99 |
+
# platform_fee = int(amount * 0.02)
|
| 100 |
+
# creator_final = amount - platform_fee
|
| 101 |
+
# It sends creator_final.
|
| 102 |
+
|
| 103 |
+
# Let's try sending 100 raw units of ADMC (assuming 0 decimals or handling it)
|
| 104 |
+
# If ADMC has decimals, we need to account for that.
|
| 105 |
+
# Verification script should just use raw integers for now as logic seems to expect int.
|
| 106 |
+
|
| 107 |
+
test_amount = 100 # Raw units
|
| 108 |
+
txid = algorand_service.settle_rewards(receiver_address, test_amount)
|
| 109 |
+
|
| 110 |
+
if txid:
|
| 111 |
+
print(f"Settlement Successful. TxID: {txid}")
|
| 112 |
+
else:
|
| 113 |
+
print("Settlement Failed (returned None).")
|
| 114 |
+
return
|
| 115 |
+
|
| 116 |
+
except Exception as e:
|
| 117 |
+
print(f"Settlement Exception: {e}")
|
| 118 |
+
return
|
| 119 |
+
|
| 120 |
+
# 6. Verify Final Balance
|
| 121 |
+
print("\nVerifying Receiver Balance...")
|
| 122 |
+
receiver_info = client.account_info(receiver_address)
|
| 123 |
+
receiver_assets = receiver_info.get('assets', [])
|
| 124 |
+
receiver_admc = 0
|
| 125 |
+
for asset in receiver_assets:
|
| 126 |
+
if asset['asset-id'] == asset_id:
|
| 127 |
+
receiver_admc = asset['amount']
|
| 128 |
+
break
|
| 129 |
+
|
| 130 |
+
print(f"Receiver ADMC Balance: {receiver_admc}")
|
| 131 |
+
|
| 132 |
+
expected = 100 - int(100 * 0.02)
|
| 133 |
+
if receiver_admc == expected:
|
| 134 |
+
print("SUCCESS: Balance matches expected amount (Amount - 2% fee).")
|
| 135 |
+
else:
|
| 136 |
+
print(f"FAILURE: Balance {receiver_admc} does not match expected {expected}.")
|
| 137 |
+
|
| 138 |
+
if __name__ == "__main__":
|
| 139 |
+
main()
|