swayamshetkar commited on
Commit
0bda635
·
0 Parent(s):

itial commit

Browse files
.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()